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