bauiv1lib.coop.browser
UI for browsing available co-op levels/games/etc.
1# Released under the MIT License. See LICENSE for details. 2# 3"""UI for browsing available co-op levels/games/etc.""" 4# FIXME: Break this up. 5# pylint: disable=too-many-lines 6 7from __future__ import annotations 8 9import logging 10from typing import TYPE_CHECKING, override 11 12 13import bauiv1 as bui 14 15if TYPE_CHECKING: 16 from typing import Any 17 18 from bauiv1lib.coop.tournamentbutton import TournamentButton 19 20HARD_REQUIRES_PRO = False 21 22 23class CoopBrowserWindow(bui.MainWindow): 24 """Window for browsing co-op levels/games/etc.""" 25 26 def __init__( 27 self, 28 transition: str | None = 'in_right', 29 origin_widget: bui.Widget | None = None, 30 ): 31 # pylint: disable=too-many-statements 32 # pylint: disable=too-many-locals 33 # pylint: disable=cyclic-import 34 35 plus = bui.app.plus 36 assert plus is not None 37 38 # Preload some modules we use in a background thread so we won't 39 # have a visual hitch when the user taps them. 40 bui.app.threadpool.submit_no_wait(self._preload_modules) 41 42 bui.set_analytics_screen('Coop Window') 43 44 app = bui.app 45 classic = app.classic 46 assert classic is not None 47 cfg = app.config 48 49 # Quick note to players that tourneys won't work in ballistica 50 # core builds. (need to split the word so it won't get subbed 51 # out) 52 if 'ballistica' + 'kit' == bui.appname() and bui.do_once(): 53 bui.apptimer( 54 1.0, 55 lambda: bui.screenmessage( 56 bui.Lstr(resource='noTournamentsInTestBuildText'), 57 color=(1, 1, 0), 58 ), 59 ) 60 61 # Try to recreate the same number of buttons we had last time so our 62 # re-selection code works. 63 self._tournament_button_count = app.config.get('Tournament Rows', 0) 64 assert isinstance(self._tournament_button_count, int) 65 66 self.star_tex = bui.gettexture('star') 67 self.lsbt = bui.getmesh('level_select_button_transparent') 68 self.lsbo = bui.getmesh('level_select_button_opaque') 69 self.a_outline_tex = bui.gettexture('achievementOutline') 70 self.a_outline_mesh = bui.getmesh('achievementOutline') 71 self._campaign_sub_container: bui.Widget | None = None 72 self._tournament_info_button: bui.Widget | None = None 73 self._easy_button: bui.Widget | None = None 74 self._hard_button: bui.Widget | None = None 75 self._hard_button_lock_image: bui.Widget | None = None 76 self._campaign_percent_text: bui.Widget | None = None 77 78 uiscale = app.ui_v1.uiscale 79 self._width = 1600 if uiscale is bui.UIScale.SMALL else 1120 80 self._height = ( 81 1200 82 if uiscale is bui.UIScale.SMALL 83 else 730 if uiscale is bui.UIScale.MEDIUM else 800 84 ) 85 self._r = 'coopSelectWindow' 86 top_extra = 0 if uiscale is bui.UIScale.SMALL else 0 87 88 self._tourney_data_up_to_date = False 89 90 self._campaign_difficulty = plus.get_v1_account_misc_val( 91 'campaignDifficulty', 'easy' 92 ) 93 94 if ( 95 self._campaign_difficulty == 'hard' 96 and HARD_REQUIRES_PRO 97 and not classic.accounts.have_pro_options() 98 ): 99 plus.add_v1_account_transaction( 100 { 101 'type': 'SET_MISC_VAL', 102 'name': 'campaignDifficulty', 103 'value': 'easy', 104 } 105 ) 106 self._campaign_difficulty = 'easy' 107 108 # Do some fancy math to fill all available screen area up to the 109 # size of our backing container. This lets us fit to the exact 110 # screen shape at small ui scale. 111 screensize = bui.get_virtual_screen_size() 112 scale = ( 113 1.5 114 if uiscale is bui.UIScale.SMALL 115 else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75 116 ) 117 # Calc screen size in our local container space and clamp to a 118 # bit smaller than our container size. 119 target_width = min(self._width - 120, screensize[0] / scale) 120 target_height = min(self._height - 120, screensize[1] / scale) 121 122 # To get top/left coords, go to the center of our window and 123 # offset by half the width/height of our target area. 124 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 125 126 self._scroll_width = target_width 127 self._scroll_height = target_height - 40 128 self._scroll_bottom = yoffs - 70 - self._scroll_height 129 130 super().__init__( 131 root_widget=bui.containerwidget( 132 size=(self._width, self._height + top_extra), 133 toolbar_visibility='menu_full', 134 scale=scale, 135 ), 136 transition=transition, 137 origin_widget=origin_widget, 138 # We're affected by screen size only at small ui-scale. 139 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 140 ) 141 142 if uiscale is bui.UIScale.SMALL: 143 self._back_button = bui.get_special_widget('back_button') 144 bui.containerwidget( 145 edit=self._root_widget, on_cancel_call=self.main_window_back 146 ) 147 else: 148 self._back_button = bui.buttonwidget( 149 parent=self._root_widget, 150 position=(75, yoffs - 48.0), 151 size=(60, 50), 152 scale=1.2, 153 autoselect=True, 154 label=bui.charstr(bui.SpecialChar.BACK), 155 button_type='backSmall', 156 on_activate_call=self.main_window_back, 157 ) 158 bui.containerwidget( 159 edit=self._root_widget, cancel_button=self._back_button 160 ) 161 162 self._last_tournament_query_time: float | None = None 163 self._last_tournament_query_response_time: float | None = None 164 self._doing_tournament_query = False 165 166 self._selected_campaign_level = cfg.get( 167 'Selected Coop Campaign Level', None 168 ) 169 self._selected_custom_level = cfg.get( 170 'Selected Coop Custom Level', None 171 ) 172 173 if uiscale is bui.UIScale.SMALL: 174 tmaxw = 130 if bui.get_virtual_screen_size()[0] < 1320 else 175 175 else: 176 tmaxw = 300 177 178 # Don't want initial construction affecting our last-selected. 179 self._do_selection_callbacks = False 180 bui.textwidget( 181 parent=self._root_widget, 182 position=( 183 self._width * 0.5, 184 yoffs - (50 if uiscale is bui.UIScale.SMALL else 24), 185 ), 186 size=(0, 0), 187 text=bui.Lstr( 188 resource='playModes.singlePlayerCoopText', 189 fallback_resource='playModes.coopText', 190 ), 191 h_align='center', 192 color=app.ui_v1.title_color, 193 scale=0.85 if uiscale is bui.UIScale.SMALL else 1.5, 194 maxwidth=tmaxw, 195 v_align='center', 196 ) 197 198 self._selected_row = cfg.get('Selected Coop Row', None) 199 200 self._subcontainerwidth = 800.0 201 self._subcontainerheight = 1400.0 202 203 self._scrollwidget = bui.scrollwidget( 204 parent=self._root_widget, 205 highlight=False, 206 size=(self._scroll_width, self._scroll_height), 207 position=( 208 self._width * 0.5 - self._scroll_width * 0.5, 209 self._scroll_bottom, 210 ), 211 simple_culling_v=10.0, 212 claims_left_right=True, 213 selection_loops_to_parent=True, 214 border_opacity=0.4, 215 ) 216 217 if uiscale is bui.UIScale.SMALL: 218 blotchwidth = 500.0 219 blotchheight = 200.0 220 bimg = bui.imagewidget( 221 parent=self._root_widget, 222 texture=bui.gettexture('uiAtlas'), 223 mesh_transparent=bui.getmesh('windowBGBlotch'), 224 position=( 225 self._width * 0.5 226 - self._scroll_width * 0.5 227 + 60.0 228 - blotchwidth * 0.5, 229 self._scroll_bottom - blotchheight * 0.5, 230 ), 231 size=(blotchwidth, blotchheight), 232 color=(0.4, 0.37, 0.49), 233 # color=(1, 0, 0), 234 ) 235 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 236 bimg = bui.imagewidget( 237 parent=self._root_widget, 238 texture=bui.gettexture('uiAtlas'), 239 mesh_transparent=bui.getmesh('windowBGBlotch'), 240 position=( 241 self._width * 0.5 242 + self._scroll_width * 0.5 243 - 60.0 244 - blotchwidth * 0.5, 245 self._scroll_bottom - blotchheight * 0.5, 246 ), 247 size=(blotchwidth, blotchheight), 248 color=(0.4, 0.37, 0.49), 249 # color=(1, 0, 0), 250 ) 251 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 252 253 self._subcontainer: bui.Widget | None = None 254 255 # Take note of our account state; we'll refresh later if this changes. 256 self._account_state_num = plus.get_v1_account_state_num() 257 258 # Same for fg/bg state. 259 self._fg_state = app.fg_state 260 261 self._refresh() 262 self._restore_state() 263 264 # Even though we might display cached tournament data immediately, we 265 # don't consider it valid until we've pinged. 266 # the server for an update 267 self._tourney_data_up_to_date = False 268 269 # If we've got a cached tournament list for our account and info for 270 # each one of those tournaments, go ahead and display it as a 271 # starting point. 272 if ( 273 classic.accounts.account_tournament_list is not None 274 and classic.accounts.account_tournament_list[0] 275 == plus.get_v1_account_state_num() 276 and all( 277 t_id in classic.accounts.tournament_info 278 for t_id in classic.accounts.account_tournament_list[1] 279 ) 280 ): 281 tourney_data = [ 282 classic.accounts.tournament_info[t_id] 283 for t_id in classic.accounts.account_tournament_list[1] 284 ] 285 self._update_for_data(tourney_data) 286 287 # This will pull new data periodically, update timers, etc. 288 self._update_timer = bui.AppTimer( 289 1.0, bui.WeakCall(self._update), repeat=True 290 ) 291 self._update() 292 293 @override 294 def get_main_window_state(self) -> bui.MainWindowState: 295 # Support recreating our window for back/refresh purposes. 296 cls = type(self) 297 return bui.BasicMainWindowState( 298 create_call=lambda transition, origin_widget: cls( 299 transition=transition, origin_widget=origin_widget 300 ) 301 ) 302 303 @override 304 def on_main_window_close(self) -> None: 305 self._save_state() 306 307 @staticmethod 308 def _preload_modules() -> None: 309 """Preload modules we use; avoids hitches (called in bg thread).""" 310 # pylint: disable=cyclic-import 311 import bauiv1lib.purchase as _unused1 312 import bauiv1lib.coop.gamebutton as _unused2 313 import bauiv1lib.confirm as _unused3 314 import bauiv1lib.account as _unused4 315 import bauiv1lib.league.rankwindow as _unused5 316 import bauiv1lib.store.browser as _unused6 317 import bauiv1lib.account.viewer as _unused7 318 import bauiv1lib.tournamentscores as _unused8 319 import bauiv1lib.tournamententry as _unused9 320 import bauiv1lib.play as _unused10 321 import bauiv1lib.coop.tournamentbutton as _unused11 322 323 def _update(self) -> None: 324 plus = bui.app.plus 325 assert plus is not None 326 327 # Do nothing if we've somehow outlived our actual UI. 328 if not self._root_widget: 329 return 330 331 cur_time = bui.apptime() 332 333 # If its been a while since we got a tournament update, consider 334 # the data invalid (prevents us from joining tournaments if our 335 # internet connection goes down for a while). 336 if ( 337 self._last_tournament_query_response_time is None 338 or bui.apptime() - self._last_tournament_query_response_time 339 > 60.0 * 2 340 ): 341 self._tourney_data_up_to_date = False 342 343 # If our account login state has changed, do a 344 # full request. 345 account_state_num = plus.get_v1_account_state_num() 346 347 if account_state_num != self._account_state_num: 348 self._account_state_num = account_state_num 349 self._save_state() 350 self._refresh() 351 352 # Also encourage a new tournament query since this will clear out 353 # our current results. 354 if not self._doing_tournament_query: 355 self._last_tournament_query_time = None 356 357 # If we've been backgrounded/foregrounded, invalidate our 358 # tournament entries (they will be refreshed below asap). 359 if self._fg_state != bui.app.fg_state: 360 self._tourney_data_up_to_date = False 361 362 # Send off a new tournament query if its been long enough or whatnot. 363 if not self._doing_tournament_query and ( 364 self._last_tournament_query_time is None 365 or cur_time - self._last_tournament_query_time > 30.0 366 or self._fg_state != bui.app.fg_state 367 ): 368 self._fg_state = bui.app.fg_state 369 self._last_tournament_query_time = cur_time 370 self._doing_tournament_query = True 371 plus.tournament_query( 372 args={'source': 'coop window refresh', 'numScores': 1}, 373 callback=bui.WeakCall(self._on_tournament_query_response), 374 ) 375 376 # Decrement time on our tournament buttons. 377 ads_enabled = plus.have_incentivized_ad() 378 for tbtn in self._tournament_buttons: 379 tbtn.time_remaining = max(0, tbtn.time_remaining - 1) 380 if tbtn.time_remaining_value_text is not None: 381 bui.textwidget( 382 edit=tbtn.time_remaining_value_text, 383 text=( 384 bui.timestring(tbtn.time_remaining, centi=False) 385 if ( 386 tbtn.has_time_remaining 387 and self._tourney_data_up_to_date 388 ) 389 else '-' 390 ), 391 ) 392 393 # Also adjust the ad icon visibility. 394 if tbtn.allow_ads and plus.has_video_ads(): 395 bui.imagewidget( 396 edit=tbtn.entry_fee_ad_image, 397 opacity=1.0 if ads_enabled else 0.25, 398 ) 399 bui.textwidget( 400 edit=tbtn.entry_fee_text_remaining, 401 color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2), 402 ) 403 404 self._update_hard_mode_lock_image() 405 406 def _update_hard_mode_lock_image(self) -> None: 407 assert bui.app.classic is not None 408 try: 409 bui.imagewidget( 410 edit=self._hard_button_lock_image, 411 opacity=( 412 0.0 413 if ( 414 (not HARD_REQUIRES_PRO) 415 or bui.app.classic.accounts.have_pro_options() 416 ) 417 else 1.0 418 ), 419 ) 420 except Exception: 421 logging.exception('Error updating campaign lock.') 422 423 def _update_for_data(self, data: list[dict[str, Any]] | None) -> None: 424 425 # If the number of tournaments or challenges in the data differs 426 # from our current arrangement, refresh with the new number. 427 if (data is None and self._tournament_button_count != 0) or ( 428 data is not None and (len(data) != self._tournament_button_count) 429 ): 430 self._tournament_button_count = len(data) if data is not None else 0 431 bui.app.config['Tournament Rows'] = self._tournament_button_count 432 self._refresh() 433 434 # Update all of our tourney buttons based on whats in data. 435 for i, tbtn in enumerate(self._tournament_buttons): 436 assert data is not None 437 tbtn.update_for_data(data[i]) 438 439 def _on_tournament_query_response( 440 self, data: dict[str, Any] | None 441 ) -> None: 442 plus = bui.app.plus 443 assert plus is not None 444 445 assert bui.app.classic is not None 446 accounts = bui.app.classic.accounts 447 if data is not None: 448 tournament_data = data['t'] # This used to be the whole payload. 449 self._last_tournament_query_response_time = bui.apptime() 450 else: 451 tournament_data = None 452 453 # Keep our cached tourney info up to date. 454 if data is not None: 455 self._tourney_data_up_to_date = True 456 accounts.cache_tournament_info(tournament_data) 457 458 # Also cache the current tourney list/order for this account. 459 accounts.account_tournament_list = ( 460 plus.get_v1_account_state_num(), 461 [e['tournamentID'] for e in tournament_data], 462 ) 463 464 self._doing_tournament_query = False 465 self._update_for_data(tournament_data) 466 467 def _set_campaign_difficulty(self, difficulty: str) -> None: 468 # pylint: disable=cyclic-import 469 from bauiv1lib.purchase import PurchaseWindow 470 471 plus = bui.app.plus 472 assert plus is not None 473 474 assert bui.app.classic is not None 475 if difficulty != self._campaign_difficulty: 476 if ( 477 difficulty == 'hard' 478 and HARD_REQUIRES_PRO 479 and not bui.app.classic.accounts.have_pro_options() 480 ): 481 PurchaseWindow(items=['pro']) 482 return 483 bui.getsound('gunCocking').play() 484 if difficulty not in ('easy', 'hard'): 485 print('ERROR: invalid campaign difficulty:', difficulty) 486 difficulty = 'easy' 487 self._campaign_difficulty = difficulty 488 plus.add_v1_account_transaction( 489 { 490 'type': 'SET_MISC_VAL', 491 'name': 'campaignDifficulty', 492 'value': difficulty, 493 } 494 ) 495 self._refresh_campaign_row() 496 else: 497 bui.getsound('click01').play() 498 499 def _refresh_campaign_row(self) -> None: 500 # pylint: disable=too-many-locals 501 # pylint: disable=too-many-statements 502 # pylint: disable=cyclic-import 503 from bauiv1lib.coop.gamebutton import GameButton 504 505 parent_widget = self._campaign_sub_container 506 507 # Clear out anything in the parent widget already. 508 assert parent_widget is not None 509 for child in parent_widget.get_children(): 510 child.delete() 511 512 next_widget_down = self._tournament_info_button 513 514 h = 0 515 v2 = -2 516 sel_color = (0.75, 0.85, 0.5) 517 sel_color_hard = (0.4, 0.7, 0.2) 518 un_sel_color = (0.5, 0.5, 0.5) 519 sel_textcolor = (2, 2, 0.8) 520 un_sel_textcolor = (0.6, 0.6, 0.6) 521 self._easy_button = bui.buttonwidget( 522 parent=parent_widget, 523 position=(h + 30, v2 + 105), 524 size=(120, 70), 525 label=bui.Lstr(resource='difficultyEasyText'), 526 button_type='square', 527 autoselect=True, 528 enable_sound=False, 529 on_activate_call=bui.Call(self._set_campaign_difficulty, 'easy'), 530 on_select_call=bui.Call(self.sel_change, 'campaign', 'easyButton'), 531 color=( 532 sel_color 533 if self._campaign_difficulty == 'easy' 534 else un_sel_color 535 ), 536 textcolor=( 537 sel_textcolor 538 if self._campaign_difficulty == 'easy' 539 else un_sel_textcolor 540 ), 541 ) 542 bui.widget(edit=self._easy_button, show_buffer_left=100) 543 if self._selected_campaign_level == 'easyButton': 544 bui.containerwidget( 545 edit=parent_widget, 546 selected_child=self._easy_button, 547 visible_child=self._easy_button, 548 ) 549 lock_tex = bui.gettexture('lock') 550 551 self._hard_button = bui.buttonwidget( 552 parent=parent_widget, 553 position=(h + 30, v2 + 32), 554 size=(120, 70), 555 label=bui.Lstr(resource='difficultyHardText'), 556 button_type='square', 557 autoselect=True, 558 enable_sound=False, 559 on_activate_call=bui.Call(self._set_campaign_difficulty, 'hard'), 560 on_select_call=bui.Call(self.sel_change, 'campaign', 'hardButton'), 561 color=( 562 sel_color_hard 563 if self._campaign_difficulty == 'hard' 564 else un_sel_color 565 ), 566 textcolor=( 567 sel_textcolor 568 if self._campaign_difficulty == 'hard' 569 else un_sel_textcolor 570 ), 571 ) 572 self._hard_button_lock_image = bui.imagewidget( 573 parent=parent_widget, 574 size=(30, 30), 575 draw_controller=self._hard_button, 576 position=(h + 30 - 10, v2 + 32 + 70 - 35), 577 texture=lock_tex, 578 ) 579 self._update_hard_mode_lock_image() 580 bui.widget(edit=self._hard_button, show_buffer_left=100) 581 if self._selected_campaign_level == 'hardButton': 582 bui.containerwidget( 583 edit=parent_widget, 584 selected_child=self._hard_button, 585 visible_child=self._hard_button, 586 ) 587 588 bui.widget(edit=self._hard_button, down_widget=next_widget_down) 589 h_spacing = 200 590 campaign_buttons = [] 591 if self._campaign_difficulty == 'easy': 592 campaignname = 'Easy' 593 else: 594 campaignname = 'Default' 595 items = [ 596 campaignname + ':Onslaught Training', 597 campaignname + ':Rookie Onslaught', 598 campaignname + ':Rookie Football', 599 campaignname + ':Pro Onslaught', 600 campaignname + ':Pro Football', 601 campaignname + ':Pro Runaround', 602 campaignname + ':Uber Onslaught', 603 campaignname + ':Uber Football', 604 campaignname + ':Uber Runaround', 605 ] 606 items += [campaignname + ':The Last Stand'] 607 if self._selected_campaign_level is None: 608 self._selected_campaign_level = items[0] 609 h = 150 610 for i in items: 611 is_last_sel = i == self._selected_campaign_level 612 campaign_buttons.append( 613 GameButton( 614 self, parent_widget, i, h, v2, is_last_sel, 'campaign' 615 ).get_button() 616 ) 617 h += h_spacing 618 619 bui.widget(edit=campaign_buttons[0], left_widget=self._easy_button) 620 621 bui.widget( 622 edit=self._easy_button, 623 left_widget=self._back_button, 624 up_widget=self._back_button, 625 ) 626 bui.widget(edit=self._hard_button, left_widget=self._back_button) 627 for btn in campaign_buttons: 628 bui.widget( 629 edit=btn, 630 up_widget=self._back_button, 631 ) 632 for btn in campaign_buttons: 633 bui.widget(edit=btn, down_widget=next_widget_down) 634 635 # Update our existing percent-complete text. 636 assert bui.app.classic is not None 637 campaign = bui.app.classic.getcampaign(campaignname) 638 levels = campaign.levels 639 levels_complete = sum((1 if l.complete else 0) for l in levels) 640 641 # Last level cant be completed; hence the -1. 642 progress = min(1.0, float(levels_complete) / (len(levels) - 1)) 643 p_str = str(int(progress * 100.0)) + '%' 644 645 self._campaign_percent_text = bui.textwidget( 646 edit=self._campaign_percent_text, 647 text=bui.Lstr( 648 value='${C} (${P})', 649 subs=[ 650 ('${C}', bui.Lstr(resource=f'{self._r}.campaignText')), 651 ('${P}', p_str), 652 ], 653 ), 654 ) 655 656 def _on_tournament_info_press(self) -> None: 657 # pylint: disable=cyclic-import 658 from bauiv1lib.confirm import ConfirmWindow 659 660 txt = bui.Lstr(resource=f'{self._r}.tournamentInfoText') 661 ConfirmWindow( 662 txt, 663 cancel_button=False, 664 width=550, 665 height=260, 666 origin_widget=self._tournament_info_button, 667 ) 668 669 def _refresh(self) -> None: 670 # pylint: disable=too-many-statements 671 # pylint: disable=too-many-branches 672 # pylint: disable=too-many-locals 673 # pylint: disable=cyclic-import 674 from bauiv1lib.coop.gamebutton import GameButton 675 from bauiv1lib.coop.tournamentbutton import TournamentButton 676 677 plus = bui.app.plus 678 assert plus is not None 679 assert bui.app.classic is not None 680 681 # (Re)create the sub-container if need be. 682 if self._subcontainer is not None: 683 self._subcontainer.delete() 684 685 tourney_row_height = 200 686 self._subcontainerheight = ( 687 700 + self._tournament_button_count * tourney_row_height 688 ) 689 690 self._subcontainer = bui.containerwidget( 691 parent=self._scrollwidget, 692 size=(self._subcontainerwidth, self._subcontainerheight), 693 background=False, 694 claims_left_right=True, 695 selection_loops_to_parent=True, 696 ) 697 698 bui.containerwidget( 699 edit=self._root_widget, selected_child=self._scrollwidget 700 ) 701 702 w_parent = self._subcontainer 703 h_base = 6 704 705 v = self._subcontainerheight - 90 706 707 self._campaign_percent_text = bui.textwidget( 708 parent=w_parent, 709 position=(h_base + 27, v + 30), 710 size=(0, 0), 711 text='', 712 h_align='left', 713 v_align='center', 714 color=bui.app.ui_v1.title_color, 715 scale=1.1, 716 ) 717 718 row_v_show_buffer = 80 719 v -= 198 720 721 h_scroll = bui.hscrollwidget( 722 parent=w_parent, 723 size=(self._scroll_width, 205), 724 position=(-5, v), 725 simple_culling_h=70, 726 highlight=False, 727 border_opacity=0.0, 728 color=(0.45, 0.4, 0.5), 729 on_select_call=lambda: self._on_row_selected('campaign'), 730 ) 731 self._campaign_h_scroll = h_scroll 732 bui.widget( 733 edit=h_scroll, 734 show_buffer_top=row_v_show_buffer, 735 show_buffer_bottom=row_v_show_buffer, 736 autoselect=True, 737 ) 738 if self._selected_row == 'campaign': 739 bui.containerwidget( 740 edit=w_parent, selected_child=h_scroll, visible_child=h_scroll 741 ) 742 bui.containerwidget(edit=h_scroll, claims_left_right=True) 743 self._campaign_sub_container = bui.containerwidget( 744 parent=h_scroll, size=(180 + 200 * 10, 200), background=False 745 ) 746 747 # Tournaments 748 749 self._tournament_buttons: list[TournamentButton] = [] 750 751 v -= 53 752 # FIXME shouldn't use hard-coded strings here. 753 txt = bui.Lstr( 754 resource='tournamentsText', fallback_resource='tournamentText' 755 ).evaluate() 756 t_width = bui.get_string_width(txt, suppress_warning=True) 757 bui.textwidget( 758 parent=w_parent, 759 position=(h_base + 27, v + 30), 760 size=(0, 0), 761 text=txt, 762 h_align='left', 763 v_align='center', 764 color=bui.app.ui_v1.title_color, 765 scale=1.1, 766 ) 767 self._tournament_info_button = bui.buttonwidget( 768 parent=w_parent, 769 label='?', 770 size=(20, 20), 771 text_scale=0.6, 772 position=(h_base + 27 + t_width * 1.1 + 15, v + 18), 773 button_type='square', 774 color=(0.6, 0.5, 0.65), 775 textcolor=(0.7, 0.6, 0.75), 776 autoselect=True, 777 up_widget=self._campaign_h_scroll, 778 left_widget=self._back_button, 779 on_activate_call=self._on_tournament_info_press, 780 ) 781 bui.widget( 782 edit=self._tournament_info_button, 783 right_widget=self._tournament_info_button, 784 ) 785 786 # Say 'unavailable' if there are zero tournaments, and if we're 787 # not signed in add that as well (that's probably why we see no 788 # tournaments). 789 if self._tournament_button_count == 0: 790 unavailable_text = bui.Lstr(resource='unavailableText') 791 if plus.get_v1_account_state() != 'signed_in': 792 unavailable_text = bui.Lstr( 793 value='${A} (${B})', 794 subs=[ 795 ('${A}', unavailable_text), 796 ('${B}', bui.Lstr(resource='notSignedInText')), 797 ], 798 ) 799 bui.textwidget( 800 parent=w_parent, 801 position=(h_base + 47, v), 802 size=(0, 0), 803 text=unavailable_text, 804 h_align='left', 805 v_align='center', 806 color=bui.app.ui_v1.title_color, 807 scale=0.9, 808 ) 809 v -= 40 810 v -= 198 811 812 tournament_h_scroll = None 813 if self._tournament_button_count > 0: 814 for i in range(self._tournament_button_count): 815 tournament_h_scroll = h_scroll = bui.hscrollwidget( 816 parent=w_parent, 817 size=(self._scroll_width, 205), 818 position=(-5, v), 819 highlight=False, 820 border_opacity=0.0, 821 color=(0.45, 0.4, 0.5), 822 on_select_call=bui.Call( 823 self._on_row_selected, 'tournament' + str(i + 1) 824 ), 825 ) 826 bui.widget( 827 edit=h_scroll, 828 show_buffer_top=row_v_show_buffer, 829 show_buffer_bottom=row_v_show_buffer, 830 autoselect=True, 831 ) 832 if self._selected_row == 'tournament' + str(i + 1): 833 bui.containerwidget( 834 edit=w_parent, 835 selected_child=h_scroll, 836 visible_child=h_scroll, 837 ) 838 bui.containerwidget(edit=h_scroll, claims_left_right=True) 839 sc2 = bui.containerwidget( 840 parent=h_scroll, 841 size=(self._scroll_width - 24, 200), 842 background=False, 843 ) 844 h = 0 845 v2 = -2 846 is_last_sel = True 847 self._tournament_buttons.append( 848 TournamentButton( 849 sc2, 850 h, 851 v2, 852 is_last_sel, 853 on_pressed=bui.WeakCall(self.run_tournament), 854 ) 855 ) 856 v -= 200 857 858 # Custom Games. (called 'Practice' in UI these days). 859 v -= 50 860 bui.textwidget( 861 parent=w_parent, 862 position=(h_base + 27, v + 30 + 198), 863 size=(0, 0), 864 text=bui.Lstr( 865 resource='practiceText', 866 fallback_resource='coopSelectWindow.customText', 867 ), 868 h_align='left', 869 v_align='center', 870 color=bui.app.ui_v1.title_color, 871 scale=1.1, 872 ) 873 874 items = [ 875 'Challenges:Infinite Onslaught', 876 'Challenges:Infinite Runaround', 877 'Challenges:Ninja Fight', 878 'Challenges:Pro Ninja Fight', 879 'Challenges:Meteor Shower', 880 'Challenges:Target Practice B', 881 'Challenges:Target Practice', 882 ] 883 884 # Show easter-egg-hunt either if its easter or we own it. 885 if plus.get_v1_account_misc_read_val( 886 'easter', False 887 ) or plus.get_v1_account_product_purchased('games.easter_egg_hunt'): 888 items = [ 889 'Challenges:Easter Egg Hunt', 890 'Challenges:Pro Easter Egg Hunt', 891 ] + items 892 893 # If we've defined custom games, put them at the beginning. 894 if bui.app.classic.custom_coop_practice_games: 895 items = bui.app.classic.custom_coop_practice_games + items 896 897 self._custom_h_scroll = custom_h_scroll = h_scroll = bui.hscrollwidget( 898 parent=w_parent, 899 size=(self._scroll_width, 205), 900 position=(-5, v), 901 highlight=False, 902 border_opacity=0.0, 903 color=(0.45, 0.4, 0.5), 904 on_select_call=bui.Call(self._on_row_selected, 'custom'), 905 ) 906 bui.widget( 907 edit=h_scroll, 908 show_buffer_top=row_v_show_buffer, 909 show_buffer_bottom=1.5 * row_v_show_buffer, 910 autoselect=True, 911 ) 912 if self._selected_row == 'custom': 913 bui.containerwidget( 914 edit=w_parent, selected_child=h_scroll, visible_child=h_scroll 915 ) 916 bui.containerwidget(edit=h_scroll, claims_left_right=True) 917 sc2 = bui.containerwidget( 918 parent=h_scroll, 919 size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200), 920 background=False, 921 ) 922 h_spacing = 200 923 self._custom_buttons: list[GameButton] = [] 924 h = 0 925 v2 = -2 926 for item in items: 927 is_last_sel = item == self._selected_custom_level 928 self._custom_buttons.append( 929 GameButton(self, sc2, item, h, v2, is_last_sel, 'custom') 930 ) 931 h += h_spacing 932 933 # We can't fill in our campaign row until tourney buttons are in place. 934 # (for wiring up) 935 self._refresh_campaign_row() 936 937 for i, tbutton in enumerate(self._tournament_buttons): 938 bui.widget( 939 edit=tbutton.button, 940 up_widget=( 941 self._tournament_info_button 942 if i == 0 943 else self._tournament_buttons[i - 1].button 944 ), 945 down_widget=( 946 self._tournament_buttons[(i + 1)].button 947 if i + 1 < len(self._tournament_buttons) 948 else custom_h_scroll 949 ), 950 left_widget=self._back_button, 951 ) 952 bui.widget( 953 edit=tbutton.more_scores_button, 954 down_widget=( 955 self._tournament_buttons[(i + 1)].current_leader_name_text 956 if i + 1 < len(self._tournament_buttons) 957 else custom_h_scroll 958 ), 959 ) 960 bui.widget( 961 edit=tbutton.current_leader_name_text, 962 up_widget=( 963 self._tournament_info_button 964 if i == 0 965 else self._tournament_buttons[i - 1].more_scores_button 966 ), 967 ) 968 969 for i, btn in enumerate(self._custom_buttons): 970 try: 971 bui.widget( 972 edit=btn.get_button(), 973 up_widget=( 974 tournament_h_scroll 975 if self._tournament_buttons 976 else self._tournament_info_button 977 ), 978 ) 979 if i == 0: 980 bui.widget( 981 edit=btn.get_button(), left_widget=self._back_button 982 ) 983 984 except Exception: 985 logging.exception('Error wiring up custom buttons.') 986 987 # There's probably several 'onSelected' callbacks pushed onto the 988 # event queue.. we need to push ours too so we're enabled *after* them. 989 bui.pushcall(self._enable_selectable_callback) 990 991 def _on_row_selected(self, row: str) -> None: 992 if self._do_selection_callbacks: 993 if self._selected_row != row: 994 self._selected_row = row 995 996 def _enable_selectable_callback(self) -> None: 997 self._do_selection_callbacks = True 998 999 def is_tourney_data_up_to_date(self) -> bool: 1000 """Return whether our tourney data is up to date.""" 1001 return self._tourney_data_up_to_date 1002 1003 def run_game( 1004 self, game: str, origin_widget: bui.Widget | None = None 1005 ) -> None: 1006 """Run the provided game.""" 1007 from efro.util import strict_partial 1008 from bauiv1lib.confirm import ConfirmWindow 1009 1010 classic = bui.app.classic 1011 assert classic is not None 1012 1013 if classic.chest_dock_full: 1014 ConfirmWindow( 1015 bui.Lstr(resource='chests.slotsFullWarningText'), 1016 width=550, 1017 height=140, 1018 ok_text=bui.Lstr(resource='continueText'), 1019 origin_widget=origin_widget, 1020 action=strict_partial( 1021 self._run_game, game=game, origin_widget=origin_widget 1022 ), 1023 ) 1024 else: 1025 self._run_game(game=game, origin_widget=origin_widget) 1026 1027 def _run_game( 1028 self, game: str, origin_widget: bui.Widget | None = None 1029 ) -> None: 1030 """Run the provided game.""" 1031 # pylint: disable=cyclic-import 1032 from bauiv1lib.confirm import ConfirmWindow 1033 from bauiv1lib.purchase import PurchaseWindow 1034 from bauiv1lib.account.signin import show_sign_in_prompt 1035 1036 plus = bui.app.plus 1037 assert plus is not None 1038 1039 assert bui.app.classic is not None 1040 1041 args: dict[str, Any] = {} 1042 1043 if game == 'Easy:The Last Stand': 1044 ConfirmWindow( 1045 bui.Lstr( 1046 resource='difficultyHardUnlockOnlyText', 1047 fallback_resource='difficultyHardOnlyText', 1048 ), 1049 cancel_button=False, 1050 width=460, 1051 height=130, 1052 ) 1053 return 1054 1055 required_purchases = bui.app.classic.required_purchases_for_game(game) 1056 1057 # Show pop-up to allow purchasing any required stuff we don't have. 1058 for purchase in required_purchases: 1059 if not plus.get_v1_account_product_purchased(purchase): 1060 if plus.get_v1_account_state() != 'signed_in': 1061 show_sign_in_prompt() 1062 else: 1063 PurchaseWindow( 1064 items=[purchase], origin_widget=origin_widget 1065 ) 1066 return 1067 1068 self._save_state() 1069 1070 if bui.app.classic.launch_coop_game(game, args=args): 1071 bui.containerwidget(edit=self._root_widget, transition='out_left') 1072 1073 def run_tournament(self, tournament_button: TournamentButton) -> None: 1074 """Run the provided tournament game.""" 1075 # pylint: disable=too-many-return-statements 1076 1077 from bauiv1lib.purchase import PurchaseWindow 1078 from bauiv1lib.account.signin import show_sign_in_prompt 1079 from bauiv1lib.tournamententry import TournamentEntryWindow 1080 1081 plus = bui.app.plus 1082 assert plus is not None 1083 1084 classic = bui.app.classic 1085 assert classic is not None 1086 1087 if plus.get_v1_account_state() != 'signed_in': 1088 show_sign_in_prompt() 1089 return 1090 1091 if bui.workspaces_in_use(): 1092 bui.screenmessage( 1093 bui.Lstr(resource='tournamentsDisabledWorkspaceText'), 1094 color=(1, 0, 0), 1095 ) 1096 bui.getsound('error').play() 1097 return 1098 1099 if not self._tourney_data_up_to_date: 1100 bui.screenmessage( 1101 bui.Lstr(resource='tournamentCheckingStateText'), 1102 color=(1, 1, 0), 1103 ) 1104 bui.getsound('error').play() 1105 return 1106 1107 if tournament_button.tournament_id is None: 1108 bui.screenmessage( 1109 bui.Lstr(resource='internal.unavailableNoConnectionText'), 1110 color=(1, 0, 0), 1111 ) 1112 bui.getsound('error').play() 1113 return 1114 1115 if tournament_button.required_league is not None: 1116 bui.screenmessage( 1117 bui.Lstr( 1118 resource='league.tournamentLeagueText', 1119 subs=[ 1120 ( 1121 '${NAME}', 1122 bui.Lstr( 1123 translate=( 1124 'leagueNames', 1125 tournament_button.required_league, 1126 ) 1127 ), 1128 ) 1129 ], 1130 ), 1131 color=(1, 0, 0), 1132 ) 1133 bui.getsound('error').play() 1134 return 1135 1136 if tournament_button.game is not None and not classic.is_game_unlocked( 1137 tournament_button.game 1138 ): 1139 required_purchases = classic.required_purchases_for_game( 1140 tournament_button.game 1141 ) 1142 # We gotta be missing *something* if its locked. 1143 assert required_purchases 1144 1145 for purchase in required_purchases: 1146 if not plus.get_v1_account_product_purchased(purchase): 1147 if plus.get_v1_account_state() != 'signed_in': 1148 show_sign_in_prompt() 1149 else: 1150 PurchaseWindow( 1151 items=[purchase], 1152 origin_widget=tournament_button.button, 1153 ) 1154 return 1155 1156 # assert required_purchases 1157 # if plus.get_v1_account_state() != 'signed_in': 1158 # show_sign_in_prompt() 1159 # else: 1160 # # Hmm; just show the first requirement. They can come 1161 # # back to see more after they purchase the first. 1162 # PurchaseWindow( 1163 # items=[required_purchases[0]], 1164 # origin_widget=tournament_button.button, 1165 # ) 1166 # return 1167 1168 if tournament_button.time_remaining <= 0: 1169 bui.screenmessage( 1170 bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0) 1171 ) 1172 bui.getsound('error').play() 1173 return 1174 1175 self._save_state() 1176 1177 assert tournament_button.tournament_id is not None 1178 TournamentEntryWindow( 1179 tournament_id=tournament_button.tournament_id, 1180 position=tournament_button.button.get_screen_space_center(), 1181 ) 1182 1183 def _save_state(self) -> None: 1184 cfg = bui.app.config 1185 try: 1186 sel = self._root_widget.get_selected_child() 1187 if sel == self._back_button: 1188 sel_name = 'Back' 1189 elif sel == self._scrollwidget: 1190 sel_name = 'Scroll' 1191 else: 1192 raise ValueError('unrecognized selection') 1193 assert bui.app.classic is not None 1194 bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} 1195 except Exception: 1196 logging.exception('Error saving state for %s.', self) 1197 1198 cfg['Selected Coop Row'] = self._selected_row 1199 cfg['Selected Coop Custom Level'] = self._selected_custom_level 1200 cfg['Selected Coop Campaign Level'] = self._selected_campaign_level 1201 cfg.commit() 1202 1203 def _restore_state(self) -> None: 1204 try: 1205 assert bui.app.classic is not None 1206 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1207 'sel_name' 1208 ) 1209 if sel_name == 'Back': 1210 sel = self._back_button 1211 elif sel_name == 'Scroll': 1212 sel = self._scrollwidget 1213 else: 1214 sel = self._scrollwidget 1215 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1216 except Exception: 1217 logging.exception('Error restoring state for %s.', self) 1218 1219 def sel_change(self, row: str, game: str) -> None: 1220 """(internal)""" 1221 if self._do_selection_callbacks: 1222 if row == 'custom': 1223 self._selected_custom_level = game 1224 elif row == 'campaign': 1225 self._selected_campaign_level = game
HARD_REQUIRES_PRO =
False
class
CoopBrowserWindow(bauiv1._uitypes.MainWindow):
24class CoopBrowserWindow(bui.MainWindow): 25 """Window for browsing co-op levels/games/etc.""" 26 27 def __init__( 28 self, 29 transition: str | None = 'in_right', 30 origin_widget: bui.Widget | None = None, 31 ): 32 # pylint: disable=too-many-statements 33 # pylint: disable=too-many-locals 34 # pylint: disable=cyclic-import 35 36 plus = bui.app.plus 37 assert plus is not None 38 39 # Preload some modules we use in a background thread so we won't 40 # have a visual hitch when the user taps them. 41 bui.app.threadpool.submit_no_wait(self._preload_modules) 42 43 bui.set_analytics_screen('Coop Window') 44 45 app = bui.app 46 classic = app.classic 47 assert classic is not None 48 cfg = app.config 49 50 # Quick note to players that tourneys won't work in ballistica 51 # core builds. (need to split the word so it won't get subbed 52 # out) 53 if 'ballistica' + 'kit' == bui.appname() and bui.do_once(): 54 bui.apptimer( 55 1.0, 56 lambda: bui.screenmessage( 57 bui.Lstr(resource='noTournamentsInTestBuildText'), 58 color=(1, 1, 0), 59 ), 60 ) 61 62 # Try to recreate the same number of buttons we had last time so our 63 # re-selection code works. 64 self._tournament_button_count = app.config.get('Tournament Rows', 0) 65 assert isinstance(self._tournament_button_count, int) 66 67 self.star_tex = bui.gettexture('star') 68 self.lsbt = bui.getmesh('level_select_button_transparent') 69 self.lsbo = bui.getmesh('level_select_button_opaque') 70 self.a_outline_tex = bui.gettexture('achievementOutline') 71 self.a_outline_mesh = bui.getmesh('achievementOutline') 72 self._campaign_sub_container: bui.Widget | None = None 73 self._tournament_info_button: bui.Widget | None = None 74 self._easy_button: bui.Widget | None = None 75 self._hard_button: bui.Widget | None = None 76 self._hard_button_lock_image: bui.Widget | None = None 77 self._campaign_percent_text: bui.Widget | None = None 78 79 uiscale = app.ui_v1.uiscale 80 self._width = 1600 if uiscale is bui.UIScale.SMALL else 1120 81 self._height = ( 82 1200 83 if uiscale is bui.UIScale.SMALL 84 else 730 if uiscale is bui.UIScale.MEDIUM else 800 85 ) 86 self._r = 'coopSelectWindow' 87 top_extra = 0 if uiscale is bui.UIScale.SMALL else 0 88 89 self._tourney_data_up_to_date = False 90 91 self._campaign_difficulty = plus.get_v1_account_misc_val( 92 'campaignDifficulty', 'easy' 93 ) 94 95 if ( 96 self._campaign_difficulty == 'hard' 97 and HARD_REQUIRES_PRO 98 and not classic.accounts.have_pro_options() 99 ): 100 plus.add_v1_account_transaction( 101 { 102 'type': 'SET_MISC_VAL', 103 'name': 'campaignDifficulty', 104 'value': 'easy', 105 } 106 ) 107 self._campaign_difficulty = 'easy' 108 109 # Do some fancy math to fill all available screen area up to the 110 # size of our backing container. This lets us fit to the exact 111 # screen shape at small ui scale. 112 screensize = bui.get_virtual_screen_size() 113 scale = ( 114 1.5 115 if uiscale is bui.UIScale.SMALL 116 else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75 117 ) 118 # Calc screen size in our local container space and clamp to a 119 # bit smaller than our container size. 120 target_width = min(self._width - 120, screensize[0] / scale) 121 target_height = min(self._height - 120, screensize[1] / scale) 122 123 # To get top/left coords, go to the center of our window and 124 # offset by half the width/height of our target area. 125 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 126 127 self._scroll_width = target_width 128 self._scroll_height = target_height - 40 129 self._scroll_bottom = yoffs - 70 - self._scroll_height 130 131 super().__init__( 132 root_widget=bui.containerwidget( 133 size=(self._width, self._height + top_extra), 134 toolbar_visibility='menu_full', 135 scale=scale, 136 ), 137 transition=transition, 138 origin_widget=origin_widget, 139 # We're affected by screen size only at small ui-scale. 140 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 141 ) 142 143 if uiscale is bui.UIScale.SMALL: 144 self._back_button = bui.get_special_widget('back_button') 145 bui.containerwidget( 146 edit=self._root_widget, on_cancel_call=self.main_window_back 147 ) 148 else: 149 self._back_button = bui.buttonwidget( 150 parent=self._root_widget, 151 position=(75, yoffs - 48.0), 152 size=(60, 50), 153 scale=1.2, 154 autoselect=True, 155 label=bui.charstr(bui.SpecialChar.BACK), 156 button_type='backSmall', 157 on_activate_call=self.main_window_back, 158 ) 159 bui.containerwidget( 160 edit=self._root_widget, cancel_button=self._back_button 161 ) 162 163 self._last_tournament_query_time: float | None = None 164 self._last_tournament_query_response_time: float | None = None 165 self._doing_tournament_query = False 166 167 self._selected_campaign_level = cfg.get( 168 'Selected Coop Campaign Level', None 169 ) 170 self._selected_custom_level = cfg.get( 171 'Selected Coop Custom Level', None 172 ) 173 174 if uiscale is bui.UIScale.SMALL: 175 tmaxw = 130 if bui.get_virtual_screen_size()[0] < 1320 else 175 176 else: 177 tmaxw = 300 178 179 # Don't want initial construction affecting our last-selected. 180 self._do_selection_callbacks = False 181 bui.textwidget( 182 parent=self._root_widget, 183 position=( 184 self._width * 0.5, 185 yoffs - (50 if uiscale is bui.UIScale.SMALL else 24), 186 ), 187 size=(0, 0), 188 text=bui.Lstr( 189 resource='playModes.singlePlayerCoopText', 190 fallback_resource='playModes.coopText', 191 ), 192 h_align='center', 193 color=app.ui_v1.title_color, 194 scale=0.85 if uiscale is bui.UIScale.SMALL else 1.5, 195 maxwidth=tmaxw, 196 v_align='center', 197 ) 198 199 self._selected_row = cfg.get('Selected Coop Row', None) 200 201 self._subcontainerwidth = 800.0 202 self._subcontainerheight = 1400.0 203 204 self._scrollwidget = bui.scrollwidget( 205 parent=self._root_widget, 206 highlight=False, 207 size=(self._scroll_width, self._scroll_height), 208 position=( 209 self._width * 0.5 - self._scroll_width * 0.5, 210 self._scroll_bottom, 211 ), 212 simple_culling_v=10.0, 213 claims_left_right=True, 214 selection_loops_to_parent=True, 215 border_opacity=0.4, 216 ) 217 218 if uiscale is bui.UIScale.SMALL: 219 blotchwidth = 500.0 220 blotchheight = 200.0 221 bimg = bui.imagewidget( 222 parent=self._root_widget, 223 texture=bui.gettexture('uiAtlas'), 224 mesh_transparent=bui.getmesh('windowBGBlotch'), 225 position=( 226 self._width * 0.5 227 - self._scroll_width * 0.5 228 + 60.0 229 - blotchwidth * 0.5, 230 self._scroll_bottom - blotchheight * 0.5, 231 ), 232 size=(blotchwidth, blotchheight), 233 color=(0.4, 0.37, 0.49), 234 # color=(1, 0, 0), 235 ) 236 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 237 bimg = bui.imagewidget( 238 parent=self._root_widget, 239 texture=bui.gettexture('uiAtlas'), 240 mesh_transparent=bui.getmesh('windowBGBlotch'), 241 position=( 242 self._width * 0.5 243 + self._scroll_width * 0.5 244 - 60.0 245 - blotchwidth * 0.5, 246 self._scroll_bottom - blotchheight * 0.5, 247 ), 248 size=(blotchwidth, blotchheight), 249 color=(0.4, 0.37, 0.49), 250 # color=(1, 0, 0), 251 ) 252 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 253 254 self._subcontainer: bui.Widget | None = None 255 256 # Take note of our account state; we'll refresh later if this changes. 257 self._account_state_num = plus.get_v1_account_state_num() 258 259 # Same for fg/bg state. 260 self._fg_state = app.fg_state 261 262 self._refresh() 263 self._restore_state() 264 265 # Even though we might display cached tournament data immediately, we 266 # don't consider it valid until we've pinged. 267 # the server for an update 268 self._tourney_data_up_to_date = False 269 270 # If we've got a cached tournament list for our account and info for 271 # each one of those tournaments, go ahead and display it as a 272 # starting point. 273 if ( 274 classic.accounts.account_tournament_list is not None 275 and classic.accounts.account_tournament_list[0] 276 == plus.get_v1_account_state_num() 277 and all( 278 t_id in classic.accounts.tournament_info 279 for t_id in classic.accounts.account_tournament_list[1] 280 ) 281 ): 282 tourney_data = [ 283 classic.accounts.tournament_info[t_id] 284 for t_id in classic.accounts.account_tournament_list[1] 285 ] 286 self._update_for_data(tourney_data) 287 288 # This will pull new data periodically, update timers, etc. 289 self._update_timer = bui.AppTimer( 290 1.0, bui.WeakCall(self._update), repeat=True 291 ) 292 self._update() 293 294 @override 295 def get_main_window_state(self) -> bui.MainWindowState: 296 # Support recreating our window for back/refresh purposes. 297 cls = type(self) 298 return bui.BasicMainWindowState( 299 create_call=lambda transition, origin_widget: cls( 300 transition=transition, origin_widget=origin_widget 301 ) 302 ) 303 304 @override 305 def on_main_window_close(self) -> None: 306 self._save_state() 307 308 @staticmethod 309 def _preload_modules() -> None: 310 """Preload modules we use; avoids hitches (called in bg thread).""" 311 # pylint: disable=cyclic-import 312 import bauiv1lib.purchase as _unused1 313 import bauiv1lib.coop.gamebutton as _unused2 314 import bauiv1lib.confirm as _unused3 315 import bauiv1lib.account as _unused4 316 import bauiv1lib.league.rankwindow as _unused5 317 import bauiv1lib.store.browser as _unused6 318 import bauiv1lib.account.viewer as _unused7 319 import bauiv1lib.tournamentscores as _unused8 320 import bauiv1lib.tournamententry as _unused9 321 import bauiv1lib.play as _unused10 322 import bauiv1lib.coop.tournamentbutton as _unused11 323 324 def _update(self) -> None: 325 plus = bui.app.plus 326 assert plus is not None 327 328 # Do nothing if we've somehow outlived our actual UI. 329 if not self._root_widget: 330 return 331 332 cur_time = bui.apptime() 333 334 # If its been a while since we got a tournament update, consider 335 # the data invalid (prevents us from joining tournaments if our 336 # internet connection goes down for a while). 337 if ( 338 self._last_tournament_query_response_time is None 339 or bui.apptime() - self._last_tournament_query_response_time 340 > 60.0 * 2 341 ): 342 self._tourney_data_up_to_date = False 343 344 # If our account login state has changed, do a 345 # full request. 346 account_state_num = plus.get_v1_account_state_num() 347 348 if account_state_num != self._account_state_num: 349 self._account_state_num = account_state_num 350 self._save_state() 351 self._refresh() 352 353 # Also encourage a new tournament query since this will clear out 354 # our current results. 355 if not self._doing_tournament_query: 356 self._last_tournament_query_time = None 357 358 # If we've been backgrounded/foregrounded, invalidate our 359 # tournament entries (they will be refreshed below asap). 360 if self._fg_state != bui.app.fg_state: 361 self._tourney_data_up_to_date = False 362 363 # Send off a new tournament query if its been long enough or whatnot. 364 if not self._doing_tournament_query and ( 365 self._last_tournament_query_time is None 366 or cur_time - self._last_tournament_query_time > 30.0 367 or self._fg_state != bui.app.fg_state 368 ): 369 self._fg_state = bui.app.fg_state 370 self._last_tournament_query_time = cur_time 371 self._doing_tournament_query = True 372 plus.tournament_query( 373 args={'source': 'coop window refresh', 'numScores': 1}, 374 callback=bui.WeakCall(self._on_tournament_query_response), 375 ) 376 377 # Decrement time on our tournament buttons. 378 ads_enabled = plus.have_incentivized_ad() 379 for tbtn in self._tournament_buttons: 380 tbtn.time_remaining = max(0, tbtn.time_remaining - 1) 381 if tbtn.time_remaining_value_text is not None: 382 bui.textwidget( 383 edit=tbtn.time_remaining_value_text, 384 text=( 385 bui.timestring(tbtn.time_remaining, centi=False) 386 if ( 387 tbtn.has_time_remaining 388 and self._tourney_data_up_to_date 389 ) 390 else '-' 391 ), 392 ) 393 394 # Also adjust the ad icon visibility. 395 if tbtn.allow_ads and plus.has_video_ads(): 396 bui.imagewidget( 397 edit=tbtn.entry_fee_ad_image, 398 opacity=1.0 if ads_enabled else 0.25, 399 ) 400 bui.textwidget( 401 edit=tbtn.entry_fee_text_remaining, 402 color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2), 403 ) 404 405 self._update_hard_mode_lock_image() 406 407 def _update_hard_mode_lock_image(self) -> None: 408 assert bui.app.classic is not None 409 try: 410 bui.imagewidget( 411 edit=self._hard_button_lock_image, 412 opacity=( 413 0.0 414 if ( 415 (not HARD_REQUIRES_PRO) 416 or bui.app.classic.accounts.have_pro_options() 417 ) 418 else 1.0 419 ), 420 ) 421 except Exception: 422 logging.exception('Error updating campaign lock.') 423 424 def _update_for_data(self, data: list[dict[str, Any]] | None) -> None: 425 426 # If the number of tournaments or challenges in the data differs 427 # from our current arrangement, refresh with the new number. 428 if (data is None and self._tournament_button_count != 0) or ( 429 data is not None and (len(data) != self._tournament_button_count) 430 ): 431 self._tournament_button_count = len(data) if data is not None else 0 432 bui.app.config['Tournament Rows'] = self._tournament_button_count 433 self._refresh() 434 435 # Update all of our tourney buttons based on whats in data. 436 for i, tbtn in enumerate(self._tournament_buttons): 437 assert data is not None 438 tbtn.update_for_data(data[i]) 439 440 def _on_tournament_query_response( 441 self, data: dict[str, Any] | None 442 ) -> None: 443 plus = bui.app.plus 444 assert plus is not None 445 446 assert bui.app.classic is not None 447 accounts = bui.app.classic.accounts 448 if data is not None: 449 tournament_data = data['t'] # This used to be the whole payload. 450 self._last_tournament_query_response_time = bui.apptime() 451 else: 452 tournament_data = None 453 454 # Keep our cached tourney info up to date. 455 if data is not None: 456 self._tourney_data_up_to_date = True 457 accounts.cache_tournament_info(tournament_data) 458 459 # Also cache the current tourney list/order for this account. 460 accounts.account_tournament_list = ( 461 plus.get_v1_account_state_num(), 462 [e['tournamentID'] for e in tournament_data], 463 ) 464 465 self._doing_tournament_query = False 466 self._update_for_data(tournament_data) 467 468 def _set_campaign_difficulty(self, difficulty: str) -> None: 469 # pylint: disable=cyclic-import 470 from bauiv1lib.purchase import PurchaseWindow 471 472 plus = bui.app.plus 473 assert plus is not None 474 475 assert bui.app.classic is not None 476 if difficulty != self._campaign_difficulty: 477 if ( 478 difficulty == 'hard' 479 and HARD_REQUIRES_PRO 480 and not bui.app.classic.accounts.have_pro_options() 481 ): 482 PurchaseWindow(items=['pro']) 483 return 484 bui.getsound('gunCocking').play() 485 if difficulty not in ('easy', 'hard'): 486 print('ERROR: invalid campaign difficulty:', difficulty) 487 difficulty = 'easy' 488 self._campaign_difficulty = difficulty 489 plus.add_v1_account_transaction( 490 { 491 'type': 'SET_MISC_VAL', 492 'name': 'campaignDifficulty', 493 'value': difficulty, 494 } 495 ) 496 self._refresh_campaign_row() 497 else: 498 bui.getsound('click01').play() 499 500 def _refresh_campaign_row(self) -> None: 501 # pylint: disable=too-many-locals 502 # pylint: disable=too-many-statements 503 # pylint: disable=cyclic-import 504 from bauiv1lib.coop.gamebutton import GameButton 505 506 parent_widget = self._campaign_sub_container 507 508 # Clear out anything in the parent widget already. 509 assert parent_widget is not None 510 for child in parent_widget.get_children(): 511 child.delete() 512 513 next_widget_down = self._tournament_info_button 514 515 h = 0 516 v2 = -2 517 sel_color = (0.75, 0.85, 0.5) 518 sel_color_hard = (0.4, 0.7, 0.2) 519 un_sel_color = (0.5, 0.5, 0.5) 520 sel_textcolor = (2, 2, 0.8) 521 un_sel_textcolor = (0.6, 0.6, 0.6) 522 self._easy_button = bui.buttonwidget( 523 parent=parent_widget, 524 position=(h + 30, v2 + 105), 525 size=(120, 70), 526 label=bui.Lstr(resource='difficultyEasyText'), 527 button_type='square', 528 autoselect=True, 529 enable_sound=False, 530 on_activate_call=bui.Call(self._set_campaign_difficulty, 'easy'), 531 on_select_call=bui.Call(self.sel_change, 'campaign', 'easyButton'), 532 color=( 533 sel_color 534 if self._campaign_difficulty == 'easy' 535 else un_sel_color 536 ), 537 textcolor=( 538 sel_textcolor 539 if self._campaign_difficulty == 'easy' 540 else un_sel_textcolor 541 ), 542 ) 543 bui.widget(edit=self._easy_button, show_buffer_left=100) 544 if self._selected_campaign_level == 'easyButton': 545 bui.containerwidget( 546 edit=parent_widget, 547 selected_child=self._easy_button, 548 visible_child=self._easy_button, 549 ) 550 lock_tex = bui.gettexture('lock') 551 552 self._hard_button = bui.buttonwidget( 553 parent=parent_widget, 554 position=(h + 30, v2 + 32), 555 size=(120, 70), 556 label=bui.Lstr(resource='difficultyHardText'), 557 button_type='square', 558 autoselect=True, 559 enable_sound=False, 560 on_activate_call=bui.Call(self._set_campaign_difficulty, 'hard'), 561 on_select_call=bui.Call(self.sel_change, 'campaign', 'hardButton'), 562 color=( 563 sel_color_hard 564 if self._campaign_difficulty == 'hard' 565 else un_sel_color 566 ), 567 textcolor=( 568 sel_textcolor 569 if self._campaign_difficulty == 'hard' 570 else un_sel_textcolor 571 ), 572 ) 573 self._hard_button_lock_image = bui.imagewidget( 574 parent=parent_widget, 575 size=(30, 30), 576 draw_controller=self._hard_button, 577 position=(h + 30 - 10, v2 + 32 + 70 - 35), 578 texture=lock_tex, 579 ) 580 self._update_hard_mode_lock_image() 581 bui.widget(edit=self._hard_button, show_buffer_left=100) 582 if self._selected_campaign_level == 'hardButton': 583 bui.containerwidget( 584 edit=parent_widget, 585 selected_child=self._hard_button, 586 visible_child=self._hard_button, 587 ) 588 589 bui.widget(edit=self._hard_button, down_widget=next_widget_down) 590 h_spacing = 200 591 campaign_buttons = [] 592 if self._campaign_difficulty == 'easy': 593 campaignname = 'Easy' 594 else: 595 campaignname = 'Default' 596 items = [ 597 campaignname + ':Onslaught Training', 598 campaignname + ':Rookie Onslaught', 599 campaignname + ':Rookie Football', 600 campaignname + ':Pro Onslaught', 601 campaignname + ':Pro Football', 602 campaignname + ':Pro Runaround', 603 campaignname + ':Uber Onslaught', 604 campaignname + ':Uber Football', 605 campaignname + ':Uber Runaround', 606 ] 607 items += [campaignname + ':The Last Stand'] 608 if self._selected_campaign_level is None: 609 self._selected_campaign_level = items[0] 610 h = 150 611 for i in items: 612 is_last_sel = i == self._selected_campaign_level 613 campaign_buttons.append( 614 GameButton( 615 self, parent_widget, i, h, v2, is_last_sel, 'campaign' 616 ).get_button() 617 ) 618 h += h_spacing 619 620 bui.widget(edit=campaign_buttons[0], left_widget=self._easy_button) 621 622 bui.widget( 623 edit=self._easy_button, 624 left_widget=self._back_button, 625 up_widget=self._back_button, 626 ) 627 bui.widget(edit=self._hard_button, left_widget=self._back_button) 628 for btn in campaign_buttons: 629 bui.widget( 630 edit=btn, 631 up_widget=self._back_button, 632 ) 633 for btn in campaign_buttons: 634 bui.widget(edit=btn, down_widget=next_widget_down) 635 636 # Update our existing percent-complete text. 637 assert bui.app.classic is not None 638 campaign = bui.app.classic.getcampaign(campaignname) 639 levels = campaign.levels 640 levels_complete = sum((1 if l.complete else 0) for l in levels) 641 642 # Last level cant be completed; hence the -1. 643 progress = min(1.0, float(levels_complete) / (len(levels) - 1)) 644 p_str = str(int(progress * 100.0)) + '%' 645 646 self._campaign_percent_text = bui.textwidget( 647 edit=self._campaign_percent_text, 648 text=bui.Lstr( 649 value='${C} (${P})', 650 subs=[ 651 ('${C}', bui.Lstr(resource=f'{self._r}.campaignText')), 652 ('${P}', p_str), 653 ], 654 ), 655 ) 656 657 def _on_tournament_info_press(self) -> None: 658 # pylint: disable=cyclic-import 659 from bauiv1lib.confirm import ConfirmWindow 660 661 txt = bui.Lstr(resource=f'{self._r}.tournamentInfoText') 662 ConfirmWindow( 663 txt, 664 cancel_button=False, 665 width=550, 666 height=260, 667 origin_widget=self._tournament_info_button, 668 ) 669 670 def _refresh(self) -> None: 671 # pylint: disable=too-many-statements 672 # pylint: disable=too-many-branches 673 # pylint: disable=too-many-locals 674 # pylint: disable=cyclic-import 675 from bauiv1lib.coop.gamebutton import GameButton 676 from bauiv1lib.coop.tournamentbutton import TournamentButton 677 678 plus = bui.app.plus 679 assert plus is not None 680 assert bui.app.classic is not None 681 682 # (Re)create the sub-container if need be. 683 if self._subcontainer is not None: 684 self._subcontainer.delete() 685 686 tourney_row_height = 200 687 self._subcontainerheight = ( 688 700 + self._tournament_button_count * tourney_row_height 689 ) 690 691 self._subcontainer = bui.containerwidget( 692 parent=self._scrollwidget, 693 size=(self._subcontainerwidth, self._subcontainerheight), 694 background=False, 695 claims_left_right=True, 696 selection_loops_to_parent=True, 697 ) 698 699 bui.containerwidget( 700 edit=self._root_widget, selected_child=self._scrollwidget 701 ) 702 703 w_parent = self._subcontainer 704 h_base = 6 705 706 v = self._subcontainerheight - 90 707 708 self._campaign_percent_text = bui.textwidget( 709 parent=w_parent, 710 position=(h_base + 27, v + 30), 711 size=(0, 0), 712 text='', 713 h_align='left', 714 v_align='center', 715 color=bui.app.ui_v1.title_color, 716 scale=1.1, 717 ) 718 719 row_v_show_buffer = 80 720 v -= 198 721 722 h_scroll = bui.hscrollwidget( 723 parent=w_parent, 724 size=(self._scroll_width, 205), 725 position=(-5, v), 726 simple_culling_h=70, 727 highlight=False, 728 border_opacity=0.0, 729 color=(0.45, 0.4, 0.5), 730 on_select_call=lambda: self._on_row_selected('campaign'), 731 ) 732 self._campaign_h_scroll = h_scroll 733 bui.widget( 734 edit=h_scroll, 735 show_buffer_top=row_v_show_buffer, 736 show_buffer_bottom=row_v_show_buffer, 737 autoselect=True, 738 ) 739 if self._selected_row == 'campaign': 740 bui.containerwidget( 741 edit=w_parent, selected_child=h_scroll, visible_child=h_scroll 742 ) 743 bui.containerwidget(edit=h_scroll, claims_left_right=True) 744 self._campaign_sub_container = bui.containerwidget( 745 parent=h_scroll, size=(180 + 200 * 10, 200), background=False 746 ) 747 748 # Tournaments 749 750 self._tournament_buttons: list[TournamentButton] = [] 751 752 v -= 53 753 # FIXME shouldn't use hard-coded strings here. 754 txt = bui.Lstr( 755 resource='tournamentsText', fallback_resource='tournamentText' 756 ).evaluate() 757 t_width = bui.get_string_width(txt, suppress_warning=True) 758 bui.textwidget( 759 parent=w_parent, 760 position=(h_base + 27, v + 30), 761 size=(0, 0), 762 text=txt, 763 h_align='left', 764 v_align='center', 765 color=bui.app.ui_v1.title_color, 766 scale=1.1, 767 ) 768 self._tournament_info_button = bui.buttonwidget( 769 parent=w_parent, 770 label='?', 771 size=(20, 20), 772 text_scale=0.6, 773 position=(h_base + 27 + t_width * 1.1 + 15, v + 18), 774 button_type='square', 775 color=(0.6, 0.5, 0.65), 776 textcolor=(0.7, 0.6, 0.75), 777 autoselect=True, 778 up_widget=self._campaign_h_scroll, 779 left_widget=self._back_button, 780 on_activate_call=self._on_tournament_info_press, 781 ) 782 bui.widget( 783 edit=self._tournament_info_button, 784 right_widget=self._tournament_info_button, 785 ) 786 787 # Say 'unavailable' if there are zero tournaments, and if we're 788 # not signed in add that as well (that's probably why we see no 789 # tournaments). 790 if self._tournament_button_count == 0: 791 unavailable_text = bui.Lstr(resource='unavailableText') 792 if plus.get_v1_account_state() != 'signed_in': 793 unavailable_text = bui.Lstr( 794 value='${A} (${B})', 795 subs=[ 796 ('${A}', unavailable_text), 797 ('${B}', bui.Lstr(resource='notSignedInText')), 798 ], 799 ) 800 bui.textwidget( 801 parent=w_parent, 802 position=(h_base + 47, v), 803 size=(0, 0), 804 text=unavailable_text, 805 h_align='left', 806 v_align='center', 807 color=bui.app.ui_v1.title_color, 808 scale=0.9, 809 ) 810 v -= 40 811 v -= 198 812 813 tournament_h_scroll = None 814 if self._tournament_button_count > 0: 815 for i in range(self._tournament_button_count): 816 tournament_h_scroll = h_scroll = bui.hscrollwidget( 817 parent=w_parent, 818 size=(self._scroll_width, 205), 819 position=(-5, v), 820 highlight=False, 821 border_opacity=0.0, 822 color=(0.45, 0.4, 0.5), 823 on_select_call=bui.Call( 824 self._on_row_selected, 'tournament' + str(i + 1) 825 ), 826 ) 827 bui.widget( 828 edit=h_scroll, 829 show_buffer_top=row_v_show_buffer, 830 show_buffer_bottom=row_v_show_buffer, 831 autoselect=True, 832 ) 833 if self._selected_row == 'tournament' + str(i + 1): 834 bui.containerwidget( 835 edit=w_parent, 836 selected_child=h_scroll, 837 visible_child=h_scroll, 838 ) 839 bui.containerwidget(edit=h_scroll, claims_left_right=True) 840 sc2 = bui.containerwidget( 841 parent=h_scroll, 842 size=(self._scroll_width - 24, 200), 843 background=False, 844 ) 845 h = 0 846 v2 = -2 847 is_last_sel = True 848 self._tournament_buttons.append( 849 TournamentButton( 850 sc2, 851 h, 852 v2, 853 is_last_sel, 854 on_pressed=bui.WeakCall(self.run_tournament), 855 ) 856 ) 857 v -= 200 858 859 # Custom Games. (called 'Practice' in UI these days). 860 v -= 50 861 bui.textwidget( 862 parent=w_parent, 863 position=(h_base + 27, v + 30 + 198), 864 size=(0, 0), 865 text=bui.Lstr( 866 resource='practiceText', 867 fallback_resource='coopSelectWindow.customText', 868 ), 869 h_align='left', 870 v_align='center', 871 color=bui.app.ui_v1.title_color, 872 scale=1.1, 873 ) 874 875 items = [ 876 'Challenges:Infinite Onslaught', 877 'Challenges:Infinite Runaround', 878 'Challenges:Ninja Fight', 879 'Challenges:Pro Ninja Fight', 880 'Challenges:Meteor Shower', 881 'Challenges:Target Practice B', 882 'Challenges:Target Practice', 883 ] 884 885 # Show easter-egg-hunt either if its easter or we own it. 886 if plus.get_v1_account_misc_read_val( 887 'easter', False 888 ) or plus.get_v1_account_product_purchased('games.easter_egg_hunt'): 889 items = [ 890 'Challenges:Easter Egg Hunt', 891 'Challenges:Pro Easter Egg Hunt', 892 ] + items 893 894 # If we've defined custom games, put them at the beginning. 895 if bui.app.classic.custom_coop_practice_games: 896 items = bui.app.classic.custom_coop_practice_games + items 897 898 self._custom_h_scroll = custom_h_scroll = h_scroll = bui.hscrollwidget( 899 parent=w_parent, 900 size=(self._scroll_width, 205), 901 position=(-5, v), 902 highlight=False, 903 border_opacity=0.0, 904 color=(0.45, 0.4, 0.5), 905 on_select_call=bui.Call(self._on_row_selected, 'custom'), 906 ) 907 bui.widget( 908 edit=h_scroll, 909 show_buffer_top=row_v_show_buffer, 910 show_buffer_bottom=1.5 * row_v_show_buffer, 911 autoselect=True, 912 ) 913 if self._selected_row == 'custom': 914 bui.containerwidget( 915 edit=w_parent, selected_child=h_scroll, visible_child=h_scroll 916 ) 917 bui.containerwidget(edit=h_scroll, claims_left_right=True) 918 sc2 = bui.containerwidget( 919 parent=h_scroll, 920 size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200), 921 background=False, 922 ) 923 h_spacing = 200 924 self._custom_buttons: list[GameButton] = [] 925 h = 0 926 v2 = -2 927 for item in items: 928 is_last_sel = item == self._selected_custom_level 929 self._custom_buttons.append( 930 GameButton(self, sc2, item, h, v2, is_last_sel, 'custom') 931 ) 932 h += h_spacing 933 934 # We can't fill in our campaign row until tourney buttons are in place. 935 # (for wiring up) 936 self._refresh_campaign_row() 937 938 for i, tbutton in enumerate(self._tournament_buttons): 939 bui.widget( 940 edit=tbutton.button, 941 up_widget=( 942 self._tournament_info_button 943 if i == 0 944 else self._tournament_buttons[i - 1].button 945 ), 946 down_widget=( 947 self._tournament_buttons[(i + 1)].button 948 if i + 1 < len(self._tournament_buttons) 949 else custom_h_scroll 950 ), 951 left_widget=self._back_button, 952 ) 953 bui.widget( 954 edit=tbutton.more_scores_button, 955 down_widget=( 956 self._tournament_buttons[(i + 1)].current_leader_name_text 957 if i + 1 < len(self._tournament_buttons) 958 else custom_h_scroll 959 ), 960 ) 961 bui.widget( 962 edit=tbutton.current_leader_name_text, 963 up_widget=( 964 self._tournament_info_button 965 if i == 0 966 else self._tournament_buttons[i - 1].more_scores_button 967 ), 968 ) 969 970 for i, btn in enumerate(self._custom_buttons): 971 try: 972 bui.widget( 973 edit=btn.get_button(), 974 up_widget=( 975 tournament_h_scroll 976 if self._tournament_buttons 977 else self._tournament_info_button 978 ), 979 ) 980 if i == 0: 981 bui.widget( 982 edit=btn.get_button(), left_widget=self._back_button 983 ) 984 985 except Exception: 986 logging.exception('Error wiring up custom buttons.') 987 988 # There's probably several 'onSelected' callbacks pushed onto the 989 # event queue.. we need to push ours too so we're enabled *after* them. 990 bui.pushcall(self._enable_selectable_callback) 991 992 def _on_row_selected(self, row: str) -> None: 993 if self._do_selection_callbacks: 994 if self._selected_row != row: 995 self._selected_row = row 996 997 def _enable_selectable_callback(self) -> None: 998 self._do_selection_callbacks = True 999 1000 def is_tourney_data_up_to_date(self) -> bool: 1001 """Return whether our tourney data is up to date.""" 1002 return self._tourney_data_up_to_date 1003 1004 def run_game( 1005 self, game: str, origin_widget: bui.Widget | None = None 1006 ) -> None: 1007 """Run the provided game.""" 1008 from efro.util import strict_partial 1009 from bauiv1lib.confirm import ConfirmWindow 1010 1011 classic = bui.app.classic 1012 assert classic is not None 1013 1014 if classic.chest_dock_full: 1015 ConfirmWindow( 1016 bui.Lstr(resource='chests.slotsFullWarningText'), 1017 width=550, 1018 height=140, 1019 ok_text=bui.Lstr(resource='continueText'), 1020 origin_widget=origin_widget, 1021 action=strict_partial( 1022 self._run_game, game=game, origin_widget=origin_widget 1023 ), 1024 ) 1025 else: 1026 self._run_game(game=game, origin_widget=origin_widget) 1027 1028 def _run_game( 1029 self, game: str, origin_widget: bui.Widget | None = None 1030 ) -> None: 1031 """Run the provided game.""" 1032 # pylint: disable=cyclic-import 1033 from bauiv1lib.confirm import ConfirmWindow 1034 from bauiv1lib.purchase import PurchaseWindow 1035 from bauiv1lib.account.signin import show_sign_in_prompt 1036 1037 plus = bui.app.plus 1038 assert plus is not None 1039 1040 assert bui.app.classic is not None 1041 1042 args: dict[str, Any] = {} 1043 1044 if game == 'Easy:The Last Stand': 1045 ConfirmWindow( 1046 bui.Lstr( 1047 resource='difficultyHardUnlockOnlyText', 1048 fallback_resource='difficultyHardOnlyText', 1049 ), 1050 cancel_button=False, 1051 width=460, 1052 height=130, 1053 ) 1054 return 1055 1056 required_purchases = bui.app.classic.required_purchases_for_game(game) 1057 1058 # Show pop-up to allow purchasing any required stuff we don't have. 1059 for purchase in required_purchases: 1060 if not plus.get_v1_account_product_purchased(purchase): 1061 if plus.get_v1_account_state() != 'signed_in': 1062 show_sign_in_prompt() 1063 else: 1064 PurchaseWindow( 1065 items=[purchase], origin_widget=origin_widget 1066 ) 1067 return 1068 1069 self._save_state() 1070 1071 if bui.app.classic.launch_coop_game(game, args=args): 1072 bui.containerwidget(edit=self._root_widget, transition='out_left') 1073 1074 def run_tournament(self, tournament_button: TournamentButton) -> None: 1075 """Run the provided tournament game.""" 1076 # pylint: disable=too-many-return-statements 1077 1078 from bauiv1lib.purchase import PurchaseWindow 1079 from bauiv1lib.account.signin import show_sign_in_prompt 1080 from bauiv1lib.tournamententry import TournamentEntryWindow 1081 1082 plus = bui.app.plus 1083 assert plus is not None 1084 1085 classic = bui.app.classic 1086 assert classic is not None 1087 1088 if plus.get_v1_account_state() != 'signed_in': 1089 show_sign_in_prompt() 1090 return 1091 1092 if bui.workspaces_in_use(): 1093 bui.screenmessage( 1094 bui.Lstr(resource='tournamentsDisabledWorkspaceText'), 1095 color=(1, 0, 0), 1096 ) 1097 bui.getsound('error').play() 1098 return 1099 1100 if not self._tourney_data_up_to_date: 1101 bui.screenmessage( 1102 bui.Lstr(resource='tournamentCheckingStateText'), 1103 color=(1, 1, 0), 1104 ) 1105 bui.getsound('error').play() 1106 return 1107 1108 if tournament_button.tournament_id is None: 1109 bui.screenmessage( 1110 bui.Lstr(resource='internal.unavailableNoConnectionText'), 1111 color=(1, 0, 0), 1112 ) 1113 bui.getsound('error').play() 1114 return 1115 1116 if tournament_button.required_league is not None: 1117 bui.screenmessage( 1118 bui.Lstr( 1119 resource='league.tournamentLeagueText', 1120 subs=[ 1121 ( 1122 '${NAME}', 1123 bui.Lstr( 1124 translate=( 1125 'leagueNames', 1126 tournament_button.required_league, 1127 ) 1128 ), 1129 ) 1130 ], 1131 ), 1132 color=(1, 0, 0), 1133 ) 1134 bui.getsound('error').play() 1135 return 1136 1137 if tournament_button.game is not None and not classic.is_game_unlocked( 1138 tournament_button.game 1139 ): 1140 required_purchases = classic.required_purchases_for_game( 1141 tournament_button.game 1142 ) 1143 # We gotta be missing *something* if its locked. 1144 assert required_purchases 1145 1146 for purchase in required_purchases: 1147 if not plus.get_v1_account_product_purchased(purchase): 1148 if plus.get_v1_account_state() != 'signed_in': 1149 show_sign_in_prompt() 1150 else: 1151 PurchaseWindow( 1152 items=[purchase], 1153 origin_widget=tournament_button.button, 1154 ) 1155 return 1156 1157 # assert required_purchases 1158 # if plus.get_v1_account_state() != 'signed_in': 1159 # show_sign_in_prompt() 1160 # else: 1161 # # Hmm; just show the first requirement. They can come 1162 # # back to see more after they purchase the first. 1163 # PurchaseWindow( 1164 # items=[required_purchases[0]], 1165 # origin_widget=tournament_button.button, 1166 # ) 1167 # return 1168 1169 if tournament_button.time_remaining <= 0: 1170 bui.screenmessage( 1171 bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0) 1172 ) 1173 bui.getsound('error').play() 1174 return 1175 1176 self._save_state() 1177 1178 assert tournament_button.tournament_id is not None 1179 TournamentEntryWindow( 1180 tournament_id=tournament_button.tournament_id, 1181 position=tournament_button.button.get_screen_space_center(), 1182 ) 1183 1184 def _save_state(self) -> None: 1185 cfg = bui.app.config 1186 try: 1187 sel = self._root_widget.get_selected_child() 1188 if sel == self._back_button: 1189 sel_name = 'Back' 1190 elif sel == self._scrollwidget: 1191 sel_name = 'Scroll' 1192 else: 1193 raise ValueError('unrecognized selection') 1194 assert bui.app.classic is not None 1195 bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} 1196 except Exception: 1197 logging.exception('Error saving state for %s.', self) 1198 1199 cfg['Selected Coop Row'] = self._selected_row 1200 cfg['Selected Coop Custom Level'] = self._selected_custom_level 1201 cfg['Selected Coop Campaign Level'] = self._selected_campaign_level 1202 cfg.commit() 1203 1204 def _restore_state(self) -> None: 1205 try: 1206 assert bui.app.classic is not None 1207 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1208 'sel_name' 1209 ) 1210 if sel_name == 'Back': 1211 sel = self._back_button 1212 elif sel_name == 'Scroll': 1213 sel = self._scrollwidget 1214 else: 1215 sel = self._scrollwidget 1216 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1217 except Exception: 1218 logging.exception('Error restoring state for %s.', self) 1219 1220 def sel_change(self, row: str, game: str) -> None: 1221 """(internal)""" 1222 if self._do_selection_callbacks: 1223 if row == 'custom': 1224 self._selected_custom_level = game 1225 elif row == 'campaign': 1226 self._selected_campaign_level = game
Window for browsing co-op levels/games/etc.
CoopBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
27 def __init__( 28 self, 29 transition: str | None = 'in_right', 30 origin_widget: bui.Widget | None = None, 31 ): 32 # pylint: disable=too-many-statements 33 # pylint: disable=too-many-locals 34 # pylint: disable=cyclic-import 35 36 plus = bui.app.plus 37 assert plus is not None 38 39 # Preload some modules we use in a background thread so we won't 40 # have a visual hitch when the user taps them. 41 bui.app.threadpool.submit_no_wait(self._preload_modules) 42 43 bui.set_analytics_screen('Coop Window') 44 45 app = bui.app 46 classic = app.classic 47 assert classic is not None 48 cfg = app.config 49 50 # Quick note to players that tourneys won't work in ballistica 51 # core builds. (need to split the word so it won't get subbed 52 # out) 53 if 'ballistica' + 'kit' == bui.appname() and bui.do_once(): 54 bui.apptimer( 55 1.0, 56 lambda: bui.screenmessage( 57 bui.Lstr(resource='noTournamentsInTestBuildText'), 58 color=(1, 1, 0), 59 ), 60 ) 61 62 # Try to recreate the same number of buttons we had last time so our 63 # re-selection code works. 64 self._tournament_button_count = app.config.get('Tournament Rows', 0) 65 assert isinstance(self._tournament_button_count, int) 66 67 self.star_tex = bui.gettexture('star') 68 self.lsbt = bui.getmesh('level_select_button_transparent') 69 self.lsbo = bui.getmesh('level_select_button_opaque') 70 self.a_outline_tex = bui.gettexture('achievementOutline') 71 self.a_outline_mesh = bui.getmesh('achievementOutline') 72 self._campaign_sub_container: bui.Widget | None = None 73 self._tournament_info_button: bui.Widget | None = None 74 self._easy_button: bui.Widget | None = None 75 self._hard_button: bui.Widget | None = None 76 self._hard_button_lock_image: bui.Widget | None = None 77 self._campaign_percent_text: bui.Widget | None = None 78 79 uiscale = app.ui_v1.uiscale 80 self._width = 1600 if uiscale is bui.UIScale.SMALL else 1120 81 self._height = ( 82 1200 83 if uiscale is bui.UIScale.SMALL 84 else 730 if uiscale is bui.UIScale.MEDIUM else 800 85 ) 86 self._r = 'coopSelectWindow' 87 top_extra = 0 if uiscale is bui.UIScale.SMALL else 0 88 89 self._tourney_data_up_to_date = False 90 91 self._campaign_difficulty = plus.get_v1_account_misc_val( 92 'campaignDifficulty', 'easy' 93 ) 94 95 if ( 96 self._campaign_difficulty == 'hard' 97 and HARD_REQUIRES_PRO 98 and not classic.accounts.have_pro_options() 99 ): 100 plus.add_v1_account_transaction( 101 { 102 'type': 'SET_MISC_VAL', 103 'name': 'campaignDifficulty', 104 'value': 'easy', 105 } 106 ) 107 self._campaign_difficulty = 'easy' 108 109 # Do some fancy math to fill all available screen area up to the 110 # size of our backing container. This lets us fit to the exact 111 # screen shape at small ui scale. 112 screensize = bui.get_virtual_screen_size() 113 scale = ( 114 1.5 115 if uiscale is bui.UIScale.SMALL 116 else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75 117 ) 118 # Calc screen size in our local container space and clamp to a 119 # bit smaller than our container size. 120 target_width = min(self._width - 120, screensize[0] / scale) 121 target_height = min(self._height - 120, screensize[1] / scale) 122 123 # To get top/left coords, go to the center of our window and 124 # offset by half the width/height of our target area. 125 yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 126 127 self._scroll_width = target_width 128 self._scroll_height = target_height - 40 129 self._scroll_bottom = yoffs - 70 - self._scroll_height 130 131 super().__init__( 132 root_widget=bui.containerwidget( 133 size=(self._width, self._height + top_extra), 134 toolbar_visibility='menu_full', 135 scale=scale, 136 ), 137 transition=transition, 138 origin_widget=origin_widget, 139 # We're affected by screen size only at small ui-scale. 140 refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, 141 ) 142 143 if uiscale is bui.UIScale.SMALL: 144 self._back_button = bui.get_special_widget('back_button') 145 bui.containerwidget( 146 edit=self._root_widget, on_cancel_call=self.main_window_back 147 ) 148 else: 149 self._back_button = bui.buttonwidget( 150 parent=self._root_widget, 151 position=(75, yoffs - 48.0), 152 size=(60, 50), 153 scale=1.2, 154 autoselect=True, 155 label=bui.charstr(bui.SpecialChar.BACK), 156 button_type='backSmall', 157 on_activate_call=self.main_window_back, 158 ) 159 bui.containerwidget( 160 edit=self._root_widget, cancel_button=self._back_button 161 ) 162 163 self._last_tournament_query_time: float | None = None 164 self._last_tournament_query_response_time: float | None = None 165 self._doing_tournament_query = False 166 167 self._selected_campaign_level = cfg.get( 168 'Selected Coop Campaign Level', None 169 ) 170 self._selected_custom_level = cfg.get( 171 'Selected Coop Custom Level', None 172 ) 173 174 if uiscale is bui.UIScale.SMALL: 175 tmaxw = 130 if bui.get_virtual_screen_size()[0] < 1320 else 175 176 else: 177 tmaxw = 300 178 179 # Don't want initial construction affecting our last-selected. 180 self._do_selection_callbacks = False 181 bui.textwidget( 182 parent=self._root_widget, 183 position=( 184 self._width * 0.5, 185 yoffs - (50 if uiscale is bui.UIScale.SMALL else 24), 186 ), 187 size=(0, 0), 188 text=bui.Lstr( 189 resource='playModes.singlePlayerCoopText', 190 fallback_resource='playModes.coopText', 191 ), 192 h_align='center', 193 color=app.ui_v1.title_color, 194 scale=0.85 if uiscale is bui.UIScale.SMALL else 1.5, 195 maxwidth=tmaxw, 196 v_align='center', 197 ) 198 199 self._selected_row = cfg.get('Selected Coop Row', None) 200 201 self._subcontainerwidth = 800.0 202 self._subcontainerheight = 1400.0 203 204 self._scrollwidget = bui.scrollwidget( 205 parent=self._root_widget, 206 highlight=False, 207 size=(self._scroll_width, self._scroll_height), 208 position=( 209 self._width * 0.5 - self._scroll_width * 0.5, 210 self._scroll_bottom, 211 ), 212 simple_culling_v=10.0, 213 claims_left_right=True, 214 selection_loops_to_parent=True, 215 border_opacity=0.4, 216 ) 217 218 if uiscale is bui.UIScale.SMALL: 219 blotchwidth = 500.0 220 blotchheight = 200.0 221 bimg = bui.imagewidget( 222 parent=self._root_widget, 223 texture=bui.gettexture('uiAtlas'), 224 mesh_transparent=bui.getmesh('windowBGBlotch'), 225 position=( 226 self._width * 0.5 227 - self._scroll_width * 0.5 228 + 60.0 229 - blotchwidth * 0.5, 230 self._scroll_bottom - blotchheight * 0.5, 231 ), 232 size=(blotchwidth, blotchheight), 233 color=(0.4, 0.37, 0.49), 234 # color=(1, 0, 0), 235 ) 236 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 237 bimg = bui.imagewidget( 238 parent=self._root_widget, 239 texture=bui.gettexture('uiAtlas'), 240 mesh_transparent=bui.getmesh('windowBGBlotch'), 241 position=( 242 self._width * 0.5 243 + self._scroll_width * 0.5 244 - 60.0 245 - blotchwidth * 0.5, 246 self._scroll_bottom - blotchheight * 0.5, 247 ), 248 size=(blotchwidth, blotchheight), 249 color=(0.4, 0.37, 0.49), 250 # color=(1, 0, 0), 251 ) 252 bui.widget(edit=bimg, depth_range=(0.9, 1.0)) 253 254 self._subcontainer: bui.Widget | None = None 255 256 # Take note of our account state; we'll refresh later if this changes. 257 self._account_state_num = plus.get_v1_account_state_num() 258 259 # Same for fg/bg state. 260 self._fg_state = app.fg_state 261 262 self._refresh() 263 self._restore_state() 264 265 # Even though we might display cached tournament data immediately, we 266 # don't consider it valid until we've pinged. 267 # the server for an update 268 self._tourney_data_up_to_date = False 269 270 # If we've got a cached tournament list for our account and info for 271 # each one of those tournaments, go ahead and display it as a 272 # starting point. 273 if ( 274 classic.accounts.account_tournament_list is not None 275 and classic.accounts.account_tournament_list[0] 276 == plus.get_v1_account_state_num() 277 and all( 278 t_id in classic.accounts.tournament_info 279 for t_id in classic.accounts.account_tournament_list[1] 280 ) 281 ): 282 tourney_data = [ 283 classic.accounts.tournament_info[t_id] 284 for t_id in classic.accounts.account_tournament_list[1] 285 ] 286 self._update_for_data(tourney_data) 287 288 # This will pull new data periodically, update timers, etc. 289 self._update_timer = bui.AppTimer( 290 1.0, bui.WeakCall(self._update), repeat=True 291 ) 292 self._update()
Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.
294 @override 295 def get_main_window_state(self) -> bui.MainWindowState: 296 # Support recreating our window for back/refresh purposes. 297 cls = type(self) 298 return bui.BasicMainWindowState( 299 create_call=lambda transition, origin_widget: cls( 300 transition=transition, origin_widget=origin_widget 301 ) 302 )
Return a WindowState to recreate this window, if supported.
@override
def
on_main_window_close(self) -> None:
Called before transitioning out a main window.
A good opportunity to save window state/etc.
def
is_tourney_data_up_to_date(self) -> bool:
1000 def is_tourney_data_up_to_date(self) -> bool: 1001 """Return whether our tourney data is up to date.""" 1002 return self._tourney_data_up_to_date
Return whether our tourney data is up to date.
def
run_game(self, game: str, origin_widget: _bauiv1.Widget | None = None) -> None:
1004 def run_game( 1005 self, game: str, origin_widget: bui.Widget | None = None 1006 ) -> None: 1007 """Run the provided game.""" 1008 from efro.util import strict_partial 1009 from bauiv1lib.confirm import ConfirmWindow 1010 1011 classic = bui.app.classic 1012 assert classic is not None 1013 1014 if classic.chest_dock_full: 1015 ConfirmWindow( 1016 bui.Lstr(resource='chests.slotsFullWarningText'), 1017 width=550, 1018 height=140, 1019 ok_text=bui.Lstr(resource='continueText'), 1020 origin_widget=origin_widget, 1021 action=strict_partial( 1022 self._run_game, game=game, origin_widget=origin_widget 1023 ), 1024 ) 1025 else: 1026 self._run_game(game=game, origin_widget=origin_widget)
Run the provided game.
def
run_tournament( self, tournament_button: bauiv1lib.coop.tournamentbutton.TournamentButton) -> None:
1074 def run_tournament(self, tournament_button: TournamentButton) -> None: 1075 """Run the provided tournament game.""" 1076 # pylint: disable=too-many-return-statements 1077 1078 from bauiv1lib.purchase import PurchaseWindow 1079 from bauiv1lib.account.signin import show_sign_in_prompt 1080 from bauiv1lib.tournamententry import TournamentEntryWindow 1081 1082 plus = bui.app.plus 1083 assert plus is not None 1084 1085 classic = bui.app.classic 1086 assert classic is not None 1087 1088 if plus.get_v1_account_state() != 'signed_in': 1089 show_sign_in_prompt() 1090 return 1091 1092 if bui.workspaces_in_use(): 1093 bui.screenmessage( 1094 bui.Lstr(resource='tournamentsDisabledWorkspaceText'), 1095 color=(1, 0, 0), 1096 ) 1097 bui.getsound('error').play() 1098 return 1099 1100 if not self._tourney_data_up_to_date: 1101 bui.screenmessage( 1102 bui.Lstr(resource='tournamentCheckingStateText'), 1103 color=(1, 1, 0), 1104 ) 1105 bui.getsound('error').play() 1106 return 1107 1108 if tournament_button.tournament_id is None: 1109 bui.screenmessage( 1110 bui.Lstr(resource='internal.unavailableNoConnectionText'), 1111 color=(1, 0, 0), 1112 ) 1113 bui.getsound('error').play() 1114 return 1115 1116 if tournament_button.required_league is not None: 1117 bui.screenmessage( 1118 bui.Lstr( 1119 resource='league.tournamentLeagueText', 1120 subs=[ 1121 ( 1122 '${NAME}', 1123 bui.Lstr( 1124 translate=( 1125 'leagueNames', 1126 tournament_button.required_league, 1127 ) 1128 ), 1129 ) 1130 ], 1131 ), 1132 color=(1, 0, 0), 1133 ) 1134 bui.getsound('error').play() 1135 return 1136 1137 if tournament_button.game is not None and not classic.is_game_unlocked( 1138 tournament_button.game 1139 ): 1140 required_purchases = classic.required_purchases_for_game( 1141 tournament_button.game 1142 ) 1143 # We gotta be missing *something* if its locked. 1144 assert required_purchases 1145 1146 for purchase in required_purchases: 1147 if not plus.get_v1_account_product_purchased(purchase): 1148 if plus.get_v1_account_state() != 'signed_in': 1149 show_sign_in_prompt() 1150 else: 1151 PurchaseWindow( 1152 items=[purchase], 1153 origin_widget=tournament_button.button, 1154 ) 1155 return 1156 1157 # assert required_purchases 1158 # if plus.get_v1_account_state() != 'signed_in': 1159 # show_sign_in_prompt() 1160 # else: 1161 # # Hmm; just show the first requirement. They can come 1162 # # back to see more after they purchase the first. 1163 # PurchaseWindow( 1164 # items=[required_purchases[0]], 1165 # origin_widget=tournament_button.button, 1166 # ) 1167 # return 1168 1169 if tournament_button.time_remaining <= 0: 1170 bui.screenmessage( 1171 bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0) 1172 ) 1173 bui.getsound('error').play() 1174 return 1175 1176 self._save_state() 1177 1178 assert tournament_button.tournament_id is not None 1179 TournamentEntryWindow( 1180 tournament_id=tournament_button.tournament_id, 1181 position=tournament_button.button.get_screen_space_center(), 1182 )
Run the provided tournament game.