baclassic
Components for the classic BombSquad experience.
This package/feature-set contains functionality related to the classic
BombSquad experience. Note that much legacy BombSquad code is still a
bit tangled and thus this feature-set is largely inseperable from
bascenev1
and bauiv1
. Future feature-sets will be
designed in a more modular way.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Components for the classic BombSquad experience. 4 5This package/feature-set contains functionality related to the classic 6BombSquad experience. Note that much legacy BombSquad code is still a 7bit tangled and thus this feature-set is largely inseperable from 8:mod:`bascenev1` and :mod:`bauiv1`. Future feature-sets will be 9designed in a more modular way. 10""" 11 12# ba_meta require api 9 13 14# Note: Code relying on classic should import things from here *only* 15# for type-checking and use the versions in ba*.app.classic at runtime; 16# that way type-checking will cleanly cover the classic-not-present case 17# (ba*.app.classic being None). 18import logging 19 20from efro.util import set_canonical_module_names 21 22from baclassic._appmode import ClassicAppMode 23from baclassic._appsubsystem import ClassicAppSubsystem 24from baclassic._achievement import Achievement, AchievementSubsystem 25from baclassic._chest import ( 26 ChestAppearanceDisplayInfo, 27 CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, 28 CHEST_APPEARANCE_DISPLAY_INFOS, 29) 30from baclassic._displayitem import show_display_item 31 32__all__ = [ 33 'ChestAppearanceDisplayInfo', 34 'CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT', 35 'CHEST_APPEARANCE_DISPLAY_INFOS', 36 'ClassicAppMode', 37 'ClassicAppSubsystem', 38 'Achievement', 39 'AchievementSubsystem', 40 'show_display_item', 41] 42 43# We want stuff here to show up as packagename.Foo instead of 44# packagename._submodule.Foo. 45set_canonical_module_names(globals()) 46 47# Sanity check: we want to keep ballistica's dependencies and 48# bootstrapping order clearly defined; let's check a few particular 49# modules to make sure they never directly or indirectly import us 50# before their own execs complete. 51if __debug__: 52 for _mdl in 'babase', '_babase': 53 if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): 54 logging.warning( 55 '%s was imported before %s finished importing;' 56 ' should not happen.', 57 __name__, 58 _mdl, 59 )
16@dataclass 17class ChestAppearanceDisplayInfo: 18 """Info about how to locally display chest appearances.""" 19 20 # NOTE TO SELF: Don't rename these attrs; the C++ layer is hard 21 # coded to look for them. 22 23 texclosed: str 24 texclosedtint: str 25 texopen: str 26 texopentint: str 27 color: tuple[float, float, float] 28 tint: tuple[float, float, float] 29 tint2: tuple[float, float, float]
Info about how to locally display chest appearances.
30class ClassicAppMode(babase.AppMode): 31 """AppMode for the classic BombSquad experience.""" 32 33 _LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals' 34 35 def __init__(self) -> None: 36 self._on_primary_account_changed_callback: ( 37 CallbackRegistration | None 38 ) = None 39 self._on_connectivity_changed_callback: CallbackRegistration | None = ( 40 None 41 ) 42 self._test_sub: babase.CloudSubscription | None = None 43 self._account_data_sub: babase.CloudSubscription | None = None 44 45 self._have_account_values = False 46 self._have_connectivity = False 47 self._current_account_id: str | None = None 48 self._should_restore_account_display_state = False 49 50 self._purchase_ui_pause: bauiv1.RootUIUpdatePause | None = None 51 self._last_tokens_value = 0 52 53 @override 54 @classmethod 55 def get_app_experience(cls) -> AppExperience: 56 return AppExperience.MELEE 57 58 @override 59 @classmethod 60 def _can_handle_intent(cls, intent: babase.AppIntent) -> bool: 61 # We support default and exec intents currently. 62 return isinstance( 63 intent, babase.AppIntentExec | babase.AppIntentDefault 64 ) 65 66 @override 67 def handle_intent(self, intent: babase.AppIntent) -> None: 68 if isinstance(intent, babase.AppIntentExec): 69 _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) 70 return 71 assert isinstance(intent, babase.AppIntentDefault) 72 _baclassic.classic_app_mode_handle_app_intent_default() 73 74 @override 75 def on_activate(self) -> None: 76 77 # Let the native layer do its thing. 78 _baclassic.classic_app_mode_activate() 79 80 app = babase.app 81 plus = app.plus 82 assert plus is not None 83 84 # Wire up the root ui to do what we want. 85 ui = app.ui_v1 86 ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = ( 87 self._root_ui_account_press 88 ) 89 ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = ( 90 self._root_ui_menu_press 91 ) 92 ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = ( 93 self._root_ui_squad_press 94 ) 95 ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = ( 96 self._root_ui_settings_press 97 ) 98 ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = ( 99 self._root_ui_store_press 100 ) 101 ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = ( 102 self._root_ui_inventory_press 103 ) 104 ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = ( 105 self._root_ui_get_tokens_press 106 ) 107 ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = ( 108 self._root_ui_inbox_press 109 ) 110 ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = ( 111 self._root_ui_tickets_meter_press 112 ) 113 ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = ( 114 self._root_ui_tokens_meter_press 115 ) 116 ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = ( 117 self._root_ui_trophy_meter_press 118 ) 119 ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = ( 120 self._root_ui_level_meter_press 121 ) 122 ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = ( 123 self._root_ui_achievements_press 124 ) 125 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial( 126 self._root_ui_chest_slot_pressed, 0 127 ) 128 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial( 129 self._root_ui_chest_slot_pressed, 1 130 ) 131 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial( 132 self._root_ui_chest_slot_pressed, 2 133 ) 134 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial( 135 self._root_ui_chest_slot_pressed, 3 136 ) 137 138 # We want to be informed when connectivity changes. 139 self._on_connectivity_changed_callback = ( 140 plus.cloud.on_connectivity_changed_callbacks.register( 141 self._update_for_connectivity_change 142 ) 143 ) 144 # We want to be informed when primary account changes. 145 self._on_primary_account_changed_callback = ( 146 plus.accounts.on_primary_account_changed_callbacks.register( 147 self._update_for_primary_account 148 ) 149 ) 150 # Establish subscriptions/etc. for any current primary account. 151 self._update_for_primary_account(plus.accounts.primary) 152 self._have_connectivity = plus.cloud.is_connected() 153 self._update_for_connectivity_change(self._have_connectivity) 154 155 @override 156 def on_deactivate(self) -> None: 157 158 classic = babase.app.classic 159 160 # Store latest league vis vals for any active account. 161 self._save_account_display_state() 162 163 # Stop being informed of account changes. 164 self._on_primary_account_changed_callback = None 165 166 # Cancel any ui-pause we may have had going. 167 self._purchase_ui_pause = None 168 169 # Remove anything following any current account. 170 self._update_for_primary_account(None) 171 172 # Save where we were in the UI so we return there next time. 173 if classic is not None: 174 classic.save_ui_state() 175 176 # Let the native layer do its thing. 177 _baclassic.classic_app_mode_deactivate() 178 179 @override 180 def on_app_active_changed(self) -> None: 181 if not babase.app.active: 182 # If we've gone inactive, bring up the main menu, which has the 183 # side effect of pausing the action (when possible). 184 babase.invoke_main_menu() 185 186 # Also store any league vis state for the active account. 187 # this may be our last chance to do this on mobile. 188 self._save_account_display_state() 189 190 @override 191 def on_purchase_process_begin( 192 self, item_id: str, user_initiated: bool 193 ) -> None: 194 195 # Do the default thing (announces 'updating account...') 196 super().on_purchase_process_begin( 197 item_id=item_id, user_initiated=user_initiated 198 ) 199 200 # Pause the root ui so stuff like token counts don't change 201 # automatically, allowing us to animate them. Note that we 202 # need to explicitly kill this pause if we are deactivated since 203 # we wouldn't get the on_purchase_process_end() call; the next 204 # app-mode would. 205 self._purchase_ui_pause = bauiv1.RootUIUpdatePause() 206 207 # Also grab our last known token count here to plug into animations. 208 # We need to do this here before the purchase gets submitted so that 209 # we know we're seeing the old value. 210 assert babase.app.classic is not None 211 self._last_tokens_value = babase.app.classic.tokens 212 213 @override 214 def on_purchase_process_end( 215 self, item_id: str, user_initiated: bool, applied: bool 216 ) -> None: 217 218 # Let the UI auto-update again after any animations we may apply 219 # here. 220 self._purchase_ui_pause = None 221 222 # Ignore user_initiated; we want to announce newly applied stuff 223 # even if it was from a different launch or client or whatever. 224 del user_initiated 225 226 # If the purchase wasn't applied, do nothing. This likely means it 227 # was redundant or something else harmless. 228 if not applied: 229 return 230 231 if item_id.startswith('tokens'): 232 if item_id == 'tokens1': 233 tokens = bacommon.bs.TOKENS1_COUNT 234 tokens_str = str(tokens) 235 anim_time = 2.0 236 elif item_id == 'tokens2': 237 tokens = bacommon.bs.TOKENS2_COUNT 238 tokens_str = str(tokens) 239 anim_time = 2.5 240 elif item_id == 'tokens3': 241 tokens = bacommon.bs.TOKENS3_COUNT 242 tokens_str = str(tokens) 243 anim_time = 3.0 244 elif item_id == 'tokens4': 245 tokens = bacommon.bs.TOKENS4_COUNT 246 tokens_str = str(tokens) 247 anim_time = 3.5 248 else: 249 tokens = 0 250 tokens_str = '???' 251 anim_time = 2.5 252 logging.warning( 253 'Unhandled item_id in on_purchase_process_end: %s', item_id 254 ) 255 256 assert babase.app.classic is not None 257 effects: list[bacommon.bs.ClientEffect] = [ 258 bacommon.bs.ClientEffectTokensAnimation( 259 duration=anim_time, 260 startvalue=self._last_tokens_value, 261 endvalue=self._last_tokens_value + tokens, 262 ), 263 bacommon.bs.ClientEffectDelay(anim_time), 264 bacommon.bs.ClientEffectScreenMessage( 265 message='You got ${COUNT} tokens!', 266 subs=['${COUNT}', tokens_str], 267 color=(0, 1, 0), 268 ), 269 bacommon.bs.ClientEffectSound( 270 sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER 271 ), 272 ] 273 babase.app.classic.run_bs_client_effects(effects) 274 275 elif item_id.startswith('gold_pass'): 276 babase.screenmessage( 277 babase.Lstr( 278 translate=('serverResponses', 'You got a ${ITEM}!'), 279 subs=[ 280 ( 281 '${ITEM}', 282 babase.Lstr(resource='goldPass.goldPassText'), 283 ) 284 ], 285 ), 286 color=(0, 1, 0), 287 ) 288 if babase.asset_loads_allowed(): 289 babase.getsimplesound('cashRegister').play() 290 291 else: 292 293 # Fallback: simply announce item id. 294 logging.warning( 295 'on_purchase_process_end got unexpected item_id: %s.', item_id 296 ) 297 babase.screenmessage( 298 babase.Lstr( 299 translate=('serverResponses', 'You got a ${ITEM}!'), 300 subs=[('${ITEM}', item_id)], 301 ), 302 color=(0, 1, 0), 303 ) 304 if babase.asset_loads_allowed(): 305 babase.getsimplesound('cashRegister').play() 306 307 def on_engine_will_reset(self) -> None: 308 """Called just before classic resets the engine. 309 310 This happens at various times such as session switches. 311 """ 312 313 self._save_account_display_state() 314 315 def on_engine_did_reset(self) -> None: 316 """Called just after classic resets the engine. 317 318 This happens at various times such as session switches. 319 """ 320 321 # Restore any old league vis state we had; this allows the user 322 # to see animations for league improvements or other changes 323 # that have occurred since the last time we were visible. 324 self._restore_account_display_state() 325 326 def _update_for_primary_account( 327 self, account: babase.AccountV2Handle | None 328 ) -> None: 329 """Update subscriptions/etc. for a new primary account state.""" 330 assert babase.in_logic_thread() 331 plus = babase.app.plus 332 333 assert plus is not None 334 335 classic = babase.app.classic 336 assert classic is not None 337 338 if account is not None: 339 self._current_account_id = account.accountid 340 babase.set_ui_account_state(True, account.tag) 341 self._should_restore_account_display_state = True 342 else: 343 # If we had an account, save any existing league vis state 344 # so we'll properly animate to new values the next time we 345 # sign in. 346 self._save_account_display_state() 347 348 self._current_account_id = None 349 babase.set_ui_account_state(False) 350 self._should_restore_account_display_state = False 351 352 # For testing subscription functionality. 353 if os.environ.get('BA_SUBSCRIPTION_TEST') == '1': 354 if account is None: 355 self._test_sub = None 356 else: 357 with account: 358 self._test_sub = plus.cloud.subscribe_test( 359 self._on_sub_test_update 360 ) 361 else: 362 self._test_sub = None 363 364 if account is None: 365 classic.gold_pass = False 366 classic.tokens = 0 367 classic.chest_dock_full = False 368 classic.remove_ads = False 369 self._account_data_sub = None 370 _baclassic.set_root_ui_account_values( 371 tickets=-1, 372 tokens=-1, 373 league_type='', 374 league_number=-1, 375 league_rank=-1, 376 achievements_percent_text='', 377 level_text='', 378 xp_text='', 379 inbox_count=-1, 380 inbox_count_is_max=False, 381 gold_pass=False, 382 chest_0_appearance='', 383 chest_1_appearance='', 384 chest_2_appearance='', 385 chest_3_appearance='', 386 chest_0_unlock_time=-1.0, 387 chest_1_unlock_time=-1.0, 388 chest_2_unlock_time=-1.0, 389 chest_3_unlock_time=-1.0, 390 chest_0_ad_allow_time=-1.0, 391 chest_1_ad_allow_time=-1.0, 392 chest_2_ad_allow_time=-1.0, 393 chest_3_ad_allow_time=-1.0, 394 ) 395 self._have_account_values = False 396 self._update_ui_live_state() 397 398 else: 399 with account: 400 self._account_data_sub = ( 401 plus.cloud.subscribe_classic_account_data( 402 self._on_classic_account_data_change 403 ) 404 ) 405 406 def _update_for_connectivity_change(self, connected: bool) -> None: 407 """Update when the app's connectivity state changes.""" 408 self._have_connectivity = connected 409 self._update_ui_live_state() 410 411 def _update_ui_live_state(self) -> None: 412 # We want to show ui elements faded if we don't have a live 413 # connection to the master-server OR if we haven't received a 414 # set of account values from them yet. If we just plug in raw 415 # connectivity state here we get UI stuff un-fading a moment or 416 # two before values appear (since the subscriptions have not 417 # sent us any values yet) which looks odd. 418 _baclassic.set_have_live_account_values( 419 self._have_connectivity and self._have_account_values 420 ) 421 422 def _on_sub_test_update(self, val: int | None) -> None: 423 print(f'GOT SUB TEST UPDATE: {val}') 424 425 def _on_classic_account_data_change( 426 self, val: bacommon.bs.ClassicAccountLiveData 427 ) -> None: 428 achp = round(val.achievements / max(val.achievements_total, 1) * 100.0) 429 # ibc = str(val.inbox_count) 430 # if val.inbox_count_is_max: 431 # ibc += '+' 432 433 chest0 = val.chests.get('0') 434 chest1 = val.chests.get('1') 435 chest2 = val.chests.get('2') 436 chest3 = val.chests.get('3') 437 438 # Keep a few handy values on classic updated with the latest 439 # data. 440 classic = babase.app.classic 441 assert classic is not None 442 classic.remove_ads = val.remove_ads 443 classic.gold_pass = val.gold_pass 444 classic.tokens = val.tokens 445 classic.chest_dock_full = ( 446 chest0 is not None 447 and chest1 is not None 448 and chest2 is not None 449 and chest3 is not None 450 ) 451 452 _baclassic.set_root_ui_account_values( 453 tickets=val.tickets, 454 tokens=val.tokens, 455 league_type=( 456 '' if val.league_type is None else val.league_type.value 457 ), 458 league_number=(-1 if val.league_num is None else val.league_num), 459 league_rank=(-1 if val.league_rank is None else val.league_rank), 460 achievements_percent_text=f'{achp}%', 461 level_text=str(val.level), 462 xp_text=f'{val.xp}/{val.xpmax}', 463 inbox_count=val.inbox_count, 464 inbox_count_is_max=val.inbox_count_is_max, 465 gold_pass=val.gold_pass, 466 chest_0_appearance=( 467 '' if chest0 is None else chest0.appearance.value 468 ), 469 chest_1_appearance=( 470 '' if chest1 is None else chest1.appearance.value 471 ), 472 chest_2_appearance=( 473 '' if chest2 is None else chest2.appearance.value 474 ), 475 chest_3_appearance=( 476 '' if chest3 is None else chest3.appearance.value 477 ), 478 chest_0_unlock_time=( 479 -1.0 if chest0 is None else chest0.unlock_time.timestamp() 480 ), 481 chest_1_unlock_time=( 482 -1.0 if chest1 is None else chest1.unlock_time.timestamp() 483 ), 484 chest_2_unlock_time=( 485 -1.0 if chest2 is None else chest2.unlock_time.timestamp() 486 ), 487 chest_3_unlock_time=( 488 -1.0 if chest3 is None else chest3.unlock_time.timestamp() 489 ), 490 chest_0_ad_allow_time=( 491 -1.0 492 if chest0 is None or chest0.ad_allow_time is None 493 else chest0.ad_allow_time.timestamp() 494 ), 495 chest_1_ad_allow_time=( 496 -1.0 497 if chest1 is None or chest1.ad_allow_time is None 498 else chest1.ad_allow_time.timestamp() 499 ), 500 chest_2_ad_allow_time=( 501 -1.0 502 if chest2 is None or chest2.ad_allow_time is None 503 else chest2.ad_allow_time.timestamp() 504 ), 505 chest_3_ad_allow_time=( 506 -1.0 507 if chest3 is None or chest3.ad_allow_time is None 508 else chest3.ad_allow_time.timestamp() 509 ), 510 ) 511 if self._should_restore_account_display_state: 512 # If we have a previous display-state for this account, 513 # restore it. This will cause us to animate or otherwise 514 # display league changes that have occurred since we were 515 # last visible. Note we need to do this *after* setting real 516 # vals so there is a current state to animate to. 517 self._restore_account_display_state() 518 self._should_restore_account_display_state = False 519 520 # Note that we have values and updated faded state accordingly. 521 self._have_account_values = True 522 self._update_ui_live_state() 523 524 def _root_ui_menu_press(self) -> None: 525 from babase import push_back_press 526 527 ui = babase.app.ui_v1 528 529 # If *any* main-window is up, kill it and resume play. 530 old_window = ui.get_main_window() 531 if old_window is not None: 532 533 classic = babase.app.classic 534 assert classic is not None 535 classic.resume() 536 537 ui.clear_main_window() 538 return 539 540 # Otherwise 541 push_back_press() 542 543 def _root_ui_account_press(self) -> None: 544 from bauiv1lib.account.settings import AccountSettingsWindow 545 546 self._auxiliary_window_nav( 547 win_type=AccountSettingsWindow, 548 win_create_call=lambda: AccountSettingsWindow( 549 origin_widget=bauiv1.get_special_widget('account_button') 550 ), 551 ) 552 553 def _root_ui_squad_press(self) -> None: 554 btn = bauiv1.get_special_widget('squad_button') 555 center = btn.get_screen_space_center() 556 if bauiv1.app.classic is not None: 557 bauiv1.app.classic.party_icon_activate(center) 558 else: 559 logging.warning('party_icon_activate: no classic.') 560 561 def _root_ui_settings_press(self) -> None: 562 from bauiv1lib.settings.allsettings import AllSettingsWindow 563 564 self._auxiliary_window_nav( 565 win_type=AllSettingsWindow, 566 win_create_call=lambda: AllSettingsWindow( 567 origin_widget=bauiv1.get_special_widget('settings_button') 568 ), 569 ) 570 571 def _auxiliary_window_nav( 572 self, 573 win_type: type[bauiv1.MainWindow], 574 win_create_call: Callable[[], bauiv1.MainWindow], 575 ) -> None: 576 """Navigate to or away from an Auxiliary window. 577 578 Auxiliary windows can be thought of as 'side quests' in the 579 window hierarchy; places such as settings windows or league 580 ranking windows that the user might want to visit without losing 581 their place in the regular hierarchy. 582 """ 583 # pylint: disable=unidiomatic-typecheck 584 585 ui = babase.app.ui_v1 586 587 current_main_window = ui.get_main_window() 588 589 # Scan our ancestors for auxiliary states matching our type as 590 # well as auxiliary states in general. 591 aux_matching_state: bauiv1.MainWindowState | None = None 592 aux_state: bauiv1.MainWindowState | None = None 593 594 if current_main_window is None: 595 raise RuntimeError( 596 'Not currently handling no-top-level-window case.' 597 ) 598 599 state = current_main_window.main_window_back_state 600 while state is not None: 601 assert state.window_type is not None 602 if state.is_auxiliary: 603 if state.window_type is win_type: 604 aux_matching_state = state 605 else: 606 aux_state = state 607 608 state = state.parent 609 610 # If there's an ancestor auxiliary window-state matching our 611 # type, back out past it (example: poking settings, navigating 612 # down a level or two, and then poking settings again should 613 # back out of settings). 614 if aux_matching_state is not None: 615 current_main_window.main_window_back_state = ( 616 aux_matching_state.parent 617 ) 618 current_main_window.main_window_back() 619 return 620 621 # If there's an ancestory auxiliary state *not* matching our 622 # type, crop the state and swap in our new auxiliary UI 623 # (example: poking settings, then poking account, then poking 624 # back should end up where things were before the settings 625 # poke). 626 if aux_state is not None: 627 # Blow away the window stack and build a fresh one. 628 ui.clear_main_window() 629 ui.set_main_window( 630 win_create_call(), 631 from_window=False, # Disable from-check. 632 back_state=aux_state.parent, 633 suppress_warning=True, 634 is_auxiliary=True, 635 ) 636 return 637 638 # Ok, no auxiliary states found. Now if current window is 639 # auxiliary and the type matches, simply do a back. 640 if ( 641 current_main_window.main_window_is_auxiliary 642 and type(current_main_window) is win_type 643 ): 644 current_main_window.main_window_back() 645 return 646 647 # If current window is auxiliary but type doesn't match, 648 # swap it out for our new auxiliary UI. 649 if current_main_window.main_window_is_auxiliary: 650 ui.clear_main_window() 651 ui.set_main_window( 652 win_create_call(), 653 from_window=False, # Disable from-check. 654 back_state=current_main_window.main_window_back_state, 655 suppress_warning=True, 656 is_auxiliary=True, 657 ) 658 return 659 660 # Ok, no existing auxiliary stuff was found period. Just 661 # navigate forward to this UI. 662 current_main_window.main_window_replace( 663 win_create_call(), is_auxiliary=True 664 ) 665 666 def _root_ui_achievements_press(self) -> None: 667 from bauiv1lib.achievements import AchievementsWindow 668 669 if not self._ensure_signed_in_v1(): 670 return 671 672 wait_for_connectivity( 673 on_connected=lambda: self._auxiliary_window_nav( 674 win_type=AchievementsWindow, 675 win_create_call=lambda: AchievementsWindow( 676 origin_widget=bauiv1.get_special_widget( 677 'achievements_button' 678 ) 679 ), 680 ) 681 ) 682 683 def _root_ui_inbox_press(self) -> None: 684 from bauiv1lib.inbox import InboxWindow 685 686 if not self._ensure_signed_in(): 687 return 688 689 wait_for_connectivity( 690 on_connected=lambda: self._auxiliary_window_nav( 691 win_type=InboxWindow, 692 win_create_call=lambda: InboxWindow( 693 origin_widget=bauiv1.get_special_widget('inbox_button') 694 ), 695 ) 696 ) 697 698 def _root_ui_store_press(self) -> None: 699 from bauiv1lib.store.browser import StoreBrowserWindow 700 701 if not self._ensure_signed_in_v1(): 702 return 703 704 wait_for_connectivity( 705 on_connected=lambda: self._auxiliary_window_nav( 706 win_type=StoreBrowserWindow, 707 win_create_call=lambda: StoreBrowserWindow( 708 origin_widget=bauiv1.get_special_widget('store_button') 709 ), 710 ) 711 ) 712 713 def _root_ui_tickets_meter_press(self) -> None: 714 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 715 716 ResourceTypeInfoWindow( 717 'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter') 718 ) 719 720 def _root_ui_tokens_meter_press(self) -> None: 721 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 722 723 ResourceTypeInfoWindow( 724 'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter') 725 ) 726 727 def _root_ui_trophy_meter_press(self) -> None: 728 from bauiv1lib.league.rankwindow import LeagueRankWindow 729 730 if not self._ensure_signed_in_v1(): 731 return 732 733 self._auxiliary_window_nav( 734 win_type=LeagueRankWindow, 735 win_create_call=lambda: LeagueRankWindow( 736 origin_widget=bauiv1.get_special_widget('trophy_meter') 737 ), 738 ) 739 740 def _root_ui_level_meter_press(self) -> None: 741 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 742 743 ResourceTypeInfoWindow( 744 'xp', origin_widget=bauiv1.get_special_widget('level_meter') 745 ) 746 747 def _root_ui_inventory_press(self) -> None: 748 from bauiv1lib.inventory import InventoryWindow 749 750 if not self._ensure_signed_in_v1(): 751 return 752 753 self._auxiliary_window_nav( 754 win_type=InventoryWindow, 755 win_create_call=lambda: InventoryWindow( 756 origin_widget=bauiv1.get_special_widget('inventory_button') 757 ), 758 ) 759 760 def _ensure_signed_in(self) -> bool: 761 """Make sure we're signed in (requiring modern v2 accounts).""" 762 plus = bauiv1.app.plus 763 if plus is None: 764 bauiv1.screenmessage('This requires plus.', color=(1, 0, 0)) 765 bauiv1.getsound('error').play() 766 return False 767 if plus.accounts.primary is None: 768 show_sign_in_prompt() 769 return False 770 return True 771 772 def _ensure_signed_in_v1(self) -> bool: 773 """Make sure we're signed in (allowing legacy v1-only accounts).""" 774 plus = bauiv1.app.plus 775 if plus is None: 776 bauiv1.screenmessage('This requires plus.', color=(1, 0, 0)) 777 bauiv1.getsound('error').play() 778 return False 779 if plus.get_v1_account_state() != 'signed_in': 780 show_sign_in_prompt() 781 return False 782 return True 783 784 def _root_ui_get_tokens_press(self) -> None: 785 from bauiv1lib.gettokens import GetTokensWindow 786 787 if not self._ensure_signed_in(): 788 return 789 790 self._auxiliary_window_nav( 791 win_type=GetTokensWindow, 792 win_create_call=lambda: GetTokensWindow( 793 origin_widget=bauiv1.get_special_widget('get_tokens_button') 794 ), 795 ) 796 797 def _root_ui_chest_slot_pressed(self, index: int) -> None: 798 from bauiv1lib.chest import ( 799 ChestWindow0, 800 ChestWindow1, 801 ChestWindow2, 802 ChestWindow3, 803 ) 804 805 widgetid: Literal[ 806 'chest_0_button', 807 'chest_1_button', 808 'chest_2_button', 809 'chest_3_button', 810 ] 811 winclass: type[ChestWindow] 812 if index == 0: 813 widgetid = 'chest_0_button' 814 winclass = ChestWindow0 815 elif index == 1: 816 widgetid = 'chest_1_button' 817 winclass = ChestWindow1 818 elif index == 2: 819 widgetid = 'chest_2_button' 820 winclass = ChestWindow2 821 elif index == 3: 822 widgetid = 'chest_3_button' 823 winclass = ChestWindow3 824 else: 825 raise RuntimeError(f'Invalid index {index}') 826 827 wait_for_connectivity( 828 on_connected=lambda: self._auxiliary_window_nav( 829 win_type=winclass, 830 win_create_call=lambda: winclass( 831 index=index, 832 origin_widget=bauiv1.get_special_widget(widgetid), 833 ), 834 ) 835 ) 836 837 def _save_account_display_state(self) -> None: 838 839 # If we currently have an account, save the state of what we're 840 # currently displaying for it in the root ui/etc. We'll then 841 # restore that state as a starting point the next time we are 842 # active. This allows things like league rank changes to be 843 # properly animated even if they occurred while we were offline 844 # or while the UI was hidden. 845 846 if self._current_account_id is not None: 847 vals = _baclassic.get_account_display_state() 848 if vals is not None: 849 # Stuff our account id in there and save it to our 850 # config. 851 assert 'a' not in vals 852 vals['a'] = self._current_account_id 853 cfg = babase.app.config 854 cfg[self._LEAGUE_VIS_VALS_CONFIG_KEY] = vals 855 cfg.commit() 856 857 def _restore_account_display_state(self) -> None: 858 859 # If we currently have an account and it matches the 860 # display-state we have stored in the config, restore the state. 861 if self._current_account_id is not None: 862 cfg = babase.app.config 863 vals = cfg.get(self._LEAGUE_VIS_VALS_CONFIG_KEY) 864 if isinstance(vals, dict): 865 valsaccount = vals.get('a') 866 if ( 867 isinstance(valsaccount, str) 868 and valsaccount == self._current_account_id 869 ): 870 _baclassic.set_account_display_state(vals)
AppMode for the classic BombSquad experience.
53 @override 54 @classmethod 55 def get_app_experience(cls) -> AppExperience: 56 return AppExperience.MELEE
Return the overall experience provided by this mode.
66 @override 67 def handle_intent(self, intent: babase.AppIntent) -> None: 68 if isinstance(intent, babase.AppIntentExec): 69 _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) 70 return 71 assert isinstance(intent, babase.AppIntentDefault) 72 _baclassic.classic_app_mode_handle_app_intent_default()
Handle an intent.
74 @override 75 def on_activate(self) -> None: 76 77 # Let the native layer do its thing. 78 _baclassic.classic_app_mode_activate() 79 80 app = babase.app 81 plus = app.plus 82 assert plus is not None 83 84 # Wire up the root ui to do what we want. 85 ui = app.ui_v1 86 ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = ( 87 self._root_ui_account_press 88 ) 89 ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = ( 90 self._root_ui_menu_press 91 ) 92 ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = ( 93 self._root_ui_squad_press 94 ) 95 ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = ( 96 self._root_ui_settings_press 97 ) 98 ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = ( 99 self._root_ui_store_press 100 ) 101 ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = ( 102 self._root_ui_inventory_press 103 ) 104 ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = ( 105 self._root_ui_get_tokens_press 106 ) 107 ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = ( 108 self._root_ui_inbox_press 109 ) 110 ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = ( 111 self._root_ui_tickets_meter_press 112 ) 113 ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = ( 114 self._root_ui_tokens_meter_press 115 ) 116 ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = ( 117 self._root_ui_trophy_meter_press 118 ) 119 ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = ( 120 self._root_ui_level_meter_press 121 ) 122 ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = ( 123 self._root_ui_achievements_press 124 ) 125 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial( 126 self._root_ui_chest_slot_pressed, 0 127 ) 128 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial( 129 self._root_ui_chest_slot_pressed, 1 130 ) 131 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial( 132 self._root_ui_chest_slot_pressed, 2 133 ) 134 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial( 135 self._root_ui_chest_slot_pressed, 3 136 ) 137 138 # We want to be informed when connectivity changes. 139 self._on_connectivity_changed_callback = ( 140 plus.cloud.on_connectivity_changed_callbacks.register( 141 self._update_for_connectivity_change 142 ) 143 ) 144 # We want to be informed when primary account changes. 145 self._on_primary_account_changed_callback = ( 146 plus.accounts.on_primary_account_changed_callbacks.register( 147 self._update_for_primary_account 148 ) 149 ) 150 # Establish subscriptions/etc. for any current primary account. 151 self._update_for_primary_account(plus.accounts.primary) 152 self._have_connectivity = plus.cloud.is_connected() 153 self._update_for_connectivity_change(self._have_connectivity)
Called when the mode is becoming the active one fro the app.
155 @override 156 def on_deactivate(self) -> None: 157 158 classic = babase.app.classic 159 160 # Store latest league vis vals for any active account. 161 self._save_account_display_state() 162 163 # Stop being informed of account changes. 164 self._on_primary_account_changed_callback = None 165 166 # Cancel any ui-pause we may have had going. 167 self._purchase_ui_pause = None 168 169 # Remove anything following any current account. 170 self._update_for_primary_account(None) 171 172 # Save where we were in the UI so we return there next time. 173 if classic is not None: 174 classic.save_ui_state() 175 176 # Let the native layer do its thing. 177 _baclassic.classic_app_mode_deactivate()
Called when the mode stops being the active one for the app.
On platforms where the app is explicitly exited (such as desktop PC) this will also be called at app shutdown.
To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).
179 @override 180 def on_app_active_changed(self) -> None: 181 if not babase.app.active: 182 # If we've gone inactive, bring up the main menu, which has the 183 # side effect of pausing the action (when possible). 184 babase.invoke_main_menu() 185 186 # Also store any league vis state for the active account. 187 # this may be our last chance to do this on mobile. 188 self._save_account_display_state()
Called when app active state changes while in this app-mode.
This corresponds to babase.App.active
. App-active state
becomes false when the app is hidden, minimized, backgrounded,
etc. The app-mode may want to take action such as pausing a
running game or saving state when this occurs.
On platforms such as mobile where apps get suspended and later silently terminated by the OS, this is likely to be the last reliable place to save state/etc.
To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).
190 @override 191 def on_purchase_process_begin( 192 self, item_id: str, user_initiated: bool 193 ) -> None: 194 195 # Do the default thing (announces 'updating account...') 196 super().on_purchase_process_begin( 197 item_id=item_id, user_initiated=user_initiated 198 ) 199 200 # Pause the root ui so stuff like token counts don't change 201 # automatically, allowing us to animate them. Note that we 202 # need to explicitly kill this pause if we are deactivated since 203 # we wouldn't get the on_purchase_process_end() call; the next 204 # app-mode would. 205 self._purchase_ui_pause = bauiv1.RootUIUpdatePause() 206 207 # Also grab our last known token count here to plug into animations. 208 # We need to do this here before the purchase gets submitted so that 209 # we know we're seeing the old value. 210 assert babase.app.classic is not None 211 self._last_tokens_value = babase.app.classic.tokens
Called when in-app-purchase processing is beginning.
This call happens after a purchase has been completed locally but before its receipt/info is sent to the master-server to apply to the account.
213 @override 214 def on_purchase_process_end( 215 self, item_id: str, user_initiated: bool, applied: bool 216 ) -> None: 217 218 # Let the UI auto-update again after any animations we may apply 219 # here. 220 self._purchase_ui_pause = None 221 222 # Ignore user_initiated; we want to announce newly applied stuff 223 # even if it was from a different launch or client or whatever. 224 del user_initiated 225 226 # If the purchase wasn't applied, do nothing. This likely means it 227 # was redundant or something else harmless. 228 if not applied: 229 return 230 231 if item_id.startswith('tokens'): 232 if item_id == 'tokens1': 233 tokens = bacommon.bs.TOKENS1_COUNT 234 tokens_str = str(tokens) 235 anim_time = 2.0 236 elif item_id == 'tokens2': 237 tokens = bacommon.bs.TOKENS2_COUNT 238 tokens_str = str(tokens) 239 anim_time = 2.5 240 elif item_id == 'tokens3': 241 tokens = bacommon.bs.TOKENS3_COUNT 242 tokens_str = str(tokens) 243 anim_time = 3.0 244 elif item_id == 'tokens4': 245 tokens = bacommon.bs.TOKENS4_COUNT 246 tokens_str = str(tokens) 247 anim_time = 3.5 248 else: 249 tokens = 0 250 tokens_str = '???' 251 anim_time = 2.5 252 logging.warning( 253 'Unhandled item_id in on_purchase_process_end: %s', item_id 254 ) 255 256 assert babase.app.classic is not None 257 effects: list[bacommon.bs.ClientEffect] = [ 258 bacommon.bs.ClientEffectTokensAnimation( 259 duration=anim_time, 260 startvalue=self._last_tokens_value, 261 endvalue=self._last_tokens_value + tokens, 262 ), 263 bacommon.bs.ClientEffectDelay(anim_time), 264 bacommon.bs.ClientEffectScreenMessage( 265 message='You got ${COUNT} tokens!', 266 subs=['${COUNT}', tokens_str], 267 color=(0, 1, 0), 268 ), 269 bacommon.bs.ClientEffectSound( 270 sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER 271 ), 272 ] 273 babase.app.classic.run_bs_client_effects(effects) 274 275 elif item_id.startswith('gold_pass'): 276 babase.screenmessage( 277 babase.Lstr( 278 translate=('serverResponses', 'You got a ${ITEM}!'), 279 subs=[ 280 ( 281 '${ITEM}', 282 babase.Lstr(resource='goldPass.goldPassText'), 283 ) 284 ], 285 ), 286 color=(0, 1, 0), 287 ) 288 if babase.asset_loads_allowed(): 289 babase.getsimplesound('cashRegister').play() 290 291 else: 292 293 # Fallback: simply announce item id. 294 logging.warning( 295 'on_purchase_process_end got unexpected item_id: %s.', item_id 296 ) 297 babase.screenmessage( 298 babase.Lstr( 299 translate=('serverResponses', 'You got a ${ITEM}!'), 300 subs=[('${ITEM}', item_id)], 301 ), 302 color=(0, 1, 0), 303 ) 304 if babase.asset_loads_allowed(): 305 babase.getsimplesound('cashRegister').play()
Called when in-app-purchase processing completes.
Each call to on_purchase_process_begin will be followed up by a call to this method. If the purchase was found to be valid and was applied to the account, applied will be True. In the case of redundant or invalid purchases or communication failures it will be False.
307 def on_engine_will_reset(self) -> None: 308 """Called just before classic resets the engine. 309 310 This happens at various times such as session switches. 311 """ 312 313 self._save_account_display_state()
Called just before classic resets the engine.
This happens at various times such as session switches.
315 def on_engine_did_reset(self) -> None: 316 """Called just after classic resets the engine. 317 318 This happens at various times such as session switches. 319 """ 320 321 # Restore any old league vis state we had; this allows the user 322 # to see animations for league improvements or other changes 323 # that have occurred since the last time we were visible. 324 self._restore_account_display_state()
Called just after classic resets the engine.
This happens at various times such as session switches.
38class ClassicAppSubsystem(babase.AppSubsystem): 39 """Subsystem for classic functionality in the app. 40 41 The single shared instance of this app can be accessed at 42 babase.app.classic. Note that it is possible for babase.app.classic to 43 be None if the classic package is not present, and code should handle 44 that case gracefully. 45 """ 46 47 # pylint: disable=too-many-public-methods 48 49 from baclassic._music import MusicPlayMode 50 51 def __init__(self) -> None: 52 super().__init__() 53 self._env = babase.env() 54 55 self.accounts = AccountV1Subsystem() 56 self.ads = AdsSubsystem() 57 self.ach = AchievementSubsystem() 58 self.store = StoreSubsystem() 59 self.music = MusicSubsystem() 60 61 # Co-op Campaigns. 62 self.campaigns: dict[str, bascenev1.Campaign] = {} 63 self.custom_coop_practice_games: list[str] = [] 64 65 # Lobby. 66 self.lobby_random_profile_index: int = 1 67 self.lobby_random_char_index_offset = random.randrange(1000) 68 self.lobby_account_profile_device_id: int | None = None 69 70 # Misc. 71 self.tips: list[str] = [] 72 self.stress_test_update_timer: babase.AppTimer | None = None 73 self.stress_test_update_timer_2: babase.AppTimer | None = None 74 self.value_test_defaults: dict = {} 75 self.ping_thread_count = 0 76 self.allow_ticket_purchases: bool = True 77 78 # Classic-specific account state. 79 self.remove_ads = False 80 self.gold_pass = False 81 self.tokens = 0 82 self.chest_dock_full = False 83 84 # Main Menu. 85 self.main_menu_did_initial_transition = False 86 self.main_menu_last_news_fetch_time: float | None = None 87 88 # Spaz. 89 self.spaz_appearances: dict[str, spazappearance.Appearance] = {} 90 self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0) 91 92 # Server Mode. 93 self.server: ServerController | None = None 94 95 self.log_have_new = False 96 self.log_upload_timer_started = False 97 self.printed_live_object_warning = False 98 99 # We include this extra hash with shared input-mapping names so 100 # that we don't share mappings between differently-configured 101 # systems. For instance, different android devices may give 102 # different key values for the same controller type so we keep 103 # their mappings distinct. 104 self.input_map_hash: str | None = None 105 106 # Maps. 107 self.maps: dict[str, type[bascenev1.Map]] = {} 108 109 # Gameplay. 110 self.teams_series_length = 7 # Deprecated, left for old mods. 111 self.ffa_series_length = 24 # Deprecated, left for old mods. 112 self.coop_session_args: dict = {} 113 114 # UI. 115 self.first_main_menu = True # FIXME: Move to mainmenu class. 116 self.did_menu_intro = False # FIXME: Move to mainmenu class. 117 self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. 118 self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. 119 self.party_window: weakref.ref[PartyWindow] | None = None 120 self.main_menu_resume_callbacks: list = [] 121 self.saved_ui_state: bauiv1.MainWindowState | None = None 122 123 # Store. 124 self.store_layout: dict[str, list[dict[str, Any]]] | None = None 125 self.store_items: dict[str, dict] | None = None 126 self.pro_sale_start_time: int | None = None 127 self.pro_sale_start_val: int | None = None 128 129 def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: 130 """(internal)""" 131 132 # If there's no main window up, just call immediately. 133 if not babase.app.ui_v1.has_main_window(): 134 with babase.ContextRef.empty(): 135 call() 136 else: 137 self.main_menu_resume_callbacks.append(call) 138 139 @property 140 def platform(self) -> str: 141 """Name of the current platform. 142 143 Examples are: 'mac', 'windows', android'. 144 """ 145 assert isinstance(self._env['platform'], str) 146 return self._env['platform'] 147 148 def scene_v1_protocol_version(self) -> int: 149 """(internal)""" 150 return bascenev1.protocol_version() 151 152 @property 153 def subplatform(self) -> str: 154 """String for subplatform. 155 156 Can be empty. For the 'android' platform, subplatform may 157 be 'google', 'amazon', etc. 158 """ 159 assert isinstance(self._env['subplatform'], str) 160 return self._env['subplatform'] 161 162 @property 163 def legacy_user_agent_string(self) -> str: 164 """String containing various bits of info about OS/device/etc.""" 165 assert isinstance(self._env['legacy_user_agent_string'], str) 166 return self._env['legacy_user_agent_string'] 167 168 @override 169 def on_app_loading(self) -> None: 170 from bascenev1lib.actor import spazappearance 171 from bascenev1lib import maps as stdmaps 172 173 plus = babase.app.plus 174 assert plus is not None 175 176 env = babase.app.env 177 cfg = babase.app.config 178 179 self.music.on_app_loading() 180 181 # Non-test, non-debug builds should generally be blessed; warn 182 # if not (so I don't accidentally release a build that can't 183 # play tourneys). 184 if not env.debug and not env.test and not plus.is_blessed(): 185 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 186 187 stdmaps.register_all_maps() 188 189 spazappearance.register_appearances() 190 bascenev1.init_campaigns() 191 192 launch_count = cfg.get('launchCount', 0) 193 launch_count += 1 194 195 # So we know how many times we've run the game at various 196 # version milestones. 197 for key in ('lc14173', 'lc14292'): 198 cfg.setdefault(key, launch_count) 199 200 cfg['launchCount'] = launch_count 201 cfg.commit() 202 203 # If there's a leftover log file, attempt to upload it to the 204 # master-server and/or get rid of it. 205 babase.handle_leftover_v1_cloud_log_file() 206 207 self.accounts.on_app_loading() 208 209 @override 210 def on_app_suspend(self) -> None: 211 self.accounts.on_app_suspend() 212 213 @override 214 def on_app_unsuspend(self) -> None: 215 self.accounts.on_app_unsuspend() 216 self.music.on_app_unsuspend() 217 218 @override 219 def on_app_shutdown(self) -> None: 220 self.music.on_app_shutdown() 221 222 def pause(self) -> None: 223 """Pause the game due to a user request or menu popping up. 224 225 If there's a foreground host-activity that says it's pausable, tell it 226 to pause. Note: we now no longer pause if there are connected clients. 227 """ 228 activity: bascenev1.Activity | None = ( 229 bascenev1.get_foreground_host_activity() 230 ) 231 if ( 232 activity is not None 233 and activity.allow_pausing 234 and not bascenev1.have_connected_clients() 235 ): 236 from babase import Lstr 237 from bascenev1 import NodeActor 238 239 # FIXME: Shouldn't be touching scene stuff here; should just 240 # pass the request on to the host-session. 241 with activity.context: 242 globs = activity.globalsnode 243 if not globs.paused: 244 bascenev1.getsound('refWhistle').play() 245 globs.paused = True 246 247 # FIXME: This should not be an attr on Actor. 248 activity.paused_text = NodeActor( 249 bascenev1.newnode( 250 'text', 251 attrs={ 252 'text': Lstr(resource='pausedByHostText'), 253 'client_only': True, 254 'flatness': 1.0, 255 'h_align': 'center', 256 }, 257 ) 258 ) 259 260 def resume(self) -> None: 261 """Resume the game due to a user request or menu closing. 262 263 If there's a foreground host-activity that's currently paused, tell it 264 to resume. 265 """ 266 267 # FIXME: Shouldn't be touching scene stuff here; should just 268 # pass the request on to the host-session. 269 activity = bascenev1.get_foreground_host_activity() 270 if activity is not None: 271 with activity.context: 272 globs = activity.globalsnode 273 if globs.paused: 274 bascenev1.getsound('refWhistle').play() 275 globs.paused = False 276 277 # FIXME: This should not be an actor attr. 278 activity.paused_text = None 279 280 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 281 """Adds an individual level to the 'practice' section in Co-op.""" 282 283 # Assign this level to our catch-all campaign. 284 self.campaigns['Challenges'].addlevel(level) 285 286 # Make note to add it to our challenges UI. 287 self.custom_coop_practice_games.append(f'Challenges:{level.name}') 288 289 def launch_coop_game( 290 self, game: str, force: bool = False, args: dict | None = None 291 ) -> bool: 292 """High level way to launch a local co-op session.""" 293 # pylint: disable=cyclic-import 294 from bauiv1lib.coop.level import CoopLevelLockedWindow 295 296 assert babase.app.classic is not None 297 298 if args is None: 299 args = {} 300 if game == '': 301 raise ValueError('empty game name') 302 campaignname, levelname = game.split(':') 303 campaign = babase.app.classic.getcampaign(campaignname) 304 305 # If this campaign is sequential, make sure we've completed the 306 # one before this. 307 if campaign.sequential and not force: 308 for level in campaign.levels: 309 if level.name == levelname: 310 break 311 if not level.complete: 312 CoopLevelLockedWindow( 313 campaign.getlevel(levelname).displayname, 314 campaign.getlevel(level.name).displayname, 315 ) 316 return False 317 318 # Save where we are in the UI to come back to when done. 319 babase.app.classic.save_ui_state() 320 321 # Ok, we're good to go. 322 self.coop_session_args = { 323 'campaign': campaignname, 324 'level': levelname, 325 } 326 for arg_name, arg_val in list(args.items()): 327 self.coop_session_args[arg_name] = arg_val 328 329 def _fade_end() -> None: 330 from bascenev1 import CoopSession 331 332 try: 333 bascenev1.new_host_session(CoopSession) 334 except Exception: 335 logging.exception('Error creating coopsession after fade end.') 336 from bascenev1lib.mainmenu import MainMenuSession 337 338 bascenev1.new_host_session(MainMenuSession) 339 340 babase.fade_screen(False, endcall=_fade_end) 341 return True 342 343 def return_to_main_menu_session_gracefully( 344 self, reset_ui: bool = True 345 ) -> None: 346 """Attempt to cleanly get back to the main menu.""" 347 # pylint: disable=cyclic-import 348 from baclassic import _benchmark 349 from bascenev1lib.mainmenu import MainMenuSession 350 351 plus = babase.app.plus 352 assert plus is not None 353 354 if reset_ui: 355 babase.app.ui_v1.clear_main_window() 356 357 if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession): 358 # It may be possible we're on the main menu but the screen 359 # is faded so fade back in. 360 babase.fade_screen(True) 361 return 362 363 _benchmark.stop_stress_test() # Stop stress-test if in progress. 364 365 # If we're in a host-session, tell them to end. This lets them 366 # tear themselves down gracefully. 367 host_session: bascenev1.Session | None = ( 368 bascenev1.get_foreground_host_session() 369 ) 370 if host_session is not None: 371 # Kick off a little transaction so we'll hopefully have all 372 # the latest account state when we get back to the menu. 373 plus.add_v1_account_transaction( 374 {'type': 'END_SESSION', 'sType': str(type(host_session))} 375 ) 376 plus.run_v1_account_transactions() 377 378 host_session.end() 379 380 # Otherwise just force the issue. 381 else: 382 babase.pushcall( 383 babase.Call(bascenev1.new_host_session, MainMenuSession) 384 ) 385 386 def getmaps(self, playtype: str) -> list[str]: 387 """Return a list of bascenev1.Map types supporting a playtype str. 388 389 Maps supporting a given playtype must provide a particular set of 390 features and lend themselves to a certain style of play. 391 392 Play Types: 393 394 'melee' 395 General fighting map. 396 Has one or more 'spawn' locations. 397 398 'team_flag' 399 For games such as Capture The Flag where each team spawns by a flag. 400 Has two or more 'spawn' locations, each with a corresponding 'flag' 401 location (based on index). 402 403 'single_flag' 404 For games such as King of the Hill or Keep Away where multiple teams 405 are fighting over a single flag. 406 Has two or more 'spawn' locations and 1 'flag_default' location. 407 408 'conquest' 409 For games such as Conquest where flags are spread throughout the map 410 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 411 412 'king_of_the_hill' - has 2+ 'spawn' locations, 413 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 414 415 'hockey' 416 For hockey games. 417 Has two 'goal' locations, corresponding 'spawn' locations, and one 418 'flag_default' location (for where puck spawns) 419 420 'football' 421 For football games. 422 Has two 'goal' locations, corresponding 'spawn' locations, and one 423 'flag_default' location (for where flag/ball/etc. spawns) 424 425 'race' 426 For racing games where players much touch each region in order. 427 Has two or more 'race_point' locations. 428 """ 429 return sorted( 430 key 431 for key, val in self.maps.items() 432 if playtype in val.get_play_types() 433 ) 434 435 def game_begin_analytics(self) -> None: 436 """(internal)""" 437 from baclassic import _analytics 438 439 _analytics.game_begin_analytics() 440 441 @classmethod 442 def json_prep(cls, data: Any) -> Any: 443 """Return a json-friendly version of the provided data. 444 445 This converts any tuples to lists and any bytes to strings 446 (interpreted as utf-8, ignoring errors). Logs errors (just once) 447 if any data is modified/discarded/unsupported. 448 """ 449 450 if isinstance(data, dict): 451 return dict( 452 (cls.json_prep(key), cls.json_prep(value)) 453 for key, value in list(data.items()) 454 ) 455 if isinstance(data, list): 456 return [cls.json_prep(element) for element in data] 457 if isinstance(data, tuple): 458 logging.exception('json_prep encountered tuple') 459 return [cls.json_prep(element) for element in data] 460 if isinstance(data, bytes): 461 try: 462 return data.decode(errors='ignore') 463 except Exception: 464 logging.exception('json_prep encountered utf-8 decode error') 465 return data.decode(errors='ignore') 466 if not isinstance(data, (str, float, bool, type(None), int)): 467 logging.exception( 468 'got unsupported type in json_prep: %s', type(data) 469 ) 470 return data 471 472 def master_server_v1_get( 473 self, 474 request: str, 475 data: dict[str, Any], 476 callback: MasterServerCallback | None = None, 477 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 478 ) -> None: 479 """Make a call to the master server via a http GET.""" 480 481 MasterServerV1CallThread( 482 request, 'get', data, callback, response_type 483 ).start() 484 485 def master_server_v1_post( 486 self, 487 request: str, 488 data: dict[str, Any], 489 callback: MasterServerCallback | None = None, 490 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 491 ) -> None: 492 """Make a call to the master server via a http POST.""" 493 MasterServerV1CallThread( 494 request, 'post', data, callback, response_type 495 ).start() 496 497 def set_tournament_prize_image( 498 self, entry: dict[str, Any], index: int, image: bauiv1.Widget 499 ) -> None: 500 """Given a tournament entry, return strings for its prize levels.""" 501 from baclassic import _tournament 502 503 return _tournament.set_tournament_prize_chest_image(entry, index, image) 504 505 def create_in_game_tournament_prize_image( 506 self, 507 entry: dict[str, Any], 508 index: int, 509 position: tuple[float, float], 510 ) -> None: 511 """Given a tournament entry, return strings for its prize levels.""" 512 from baclassic import _tournament 513 514 _tournament.create_in_game_tournament_prize_image( 515 entry, index, position 516 ) 517 518 def get_tournament_prize_strings( 519 self, entry: dict[str, Any], include_tickets: bool 520 ) -> list[str]: 521 """Given a tournament entry, return strings for its prize levels.""" 522 from baclassic import _tournament 523 524 return _tournament.get_tournament_prize_strings( 525 entry, include_tickets=include_tickets 526 ) 527 528 def getcampaign(self, name: str) -> bascenev1.Campaign: 529 """Return a campaign by name.""" 530 return self.campaigns[name] 531 532 def get_next_tip(self) -> str: 533 """Returns the next tip to be displayed.""" 534 if not self.tips: 535 for tip in get_all_tips(): 536 self.tips.insert(random.randint(0, len(self.tips)), tip) 537 tip = self.tips.pop() 538 return tip 539 540 def run_cpu_benchmark(self) -> None: 541 """Kick off a benchmark to test cpu speeds.""" 542 from baclassic._benchmark import run_cpu_benchmark 543 544 run_cpu_benchmark() 545 546 def run_media_reload_benchmark(self) -> None: 547 """Kick off a benchmark to test media reloading speeds.""" 548 from baclassic._benchmark import run_media_reload_benchmark 549 550 run_media_reload_benchmark() 551 552 def run_stress_test( 553 self, 554 *, 555 playlist_type: str = 'Random', 556 playlist_name: str = '__default__', 557 player_count: int = 8, 558 round_duration: int = 30, 559 attract_mode: bool = False, 560 ) -> None: 561 """Run a stress test.""" 562 from baclassic._benchmark import run_stress_test 563 564 run_stress_test( 565 playlist_type=playlist_type, 566 playlist_name=playlist_name, 567 player_count=player_count, 568 round_duration=round_duration, 569 attract_mode=attract_mode, 570 ) 571 572 def get_input_device_mapped_value( 573 self, 574 device: bascenev1.InputDevice, 575 name: str, 576 default: bool = False, 577 ) -> Any: 578 """Return a mapped value for an input device. 579 580 This checks the user config and falls back to default values 581 where available. 582 """ 583 return _input.get_input_device_mapped_value( 584 device.name, device.unique_identifier, name, default 585 ) 586 587 def get_input_device_map_hash( 588 self, inputdevice: bascenev1.InputDevice 589 ) -> str: 590 """Given an input device, return hash based on its raw input values.""" 591 del inputdevice # unused currently 592 return _input.get_input_device_map_hash() 593 594 def get_input_device_config( 595 self, inputdevice: bascenev1.InputDevice, default: bool 596 ) -> tuple[dict, str]: 597 """Given an input device, return its config dict in the app config. 598 599 The dict will be created if it does not exist. 600 """ 601 return _input.get_input_device_config( 602 inputdevice.name, inputdevice.unique_identifier, default 603 ) 604 605 def get_player_colors(self) -> list[tuple[float, float, float]]: 606 """Return user-selectable player colors.""" 607 return bascenev1.get_player_colors() 608 609 def get_player_profile_icon(self, profilename: str) -> str: 610 """Given a profile name, returns an icon string for it. 611 612 (non-account profiles only) 613 """ 614 return bascenev1.get_player_profile_icon(profilename) 615 616 def get_player_profile_colors( 617 self, 618 profilename: str | None, 619 profiles: dict[str, dict[str, Any]] | None = None, 620 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 621 """Given a profile, return colors for them.""" 622 return bascenev1.get_player_profile_colors(profilename, profiles) 623 624 def get_foreground_host_session(self) -> bascenev1.Session | None: 625 """(internal)""" 626 return bascenev1.get_foreground_host_session() 627 628 def get_foreground_host_activity(self) -> bascenev1.Activity | None: 629 """(internal)""" 630 return bascenev1.get_foreground_host_activity() 631 632 def value_test( 633 self, 634 arg: str, 635 change: float | None = None, 636 absolute: float | None = None, 637 ) -> float: 638 """(internal)""" 639 return _baclassic.value_test(arg, change, absolute) 640 641 def set_master_server_source(self, source: int) -> None: 642 """(internal)""" 643 bascenev1.set_master_server_source(source) 644 645 def get_game_port(self) -> int: 646 """(internal)""" 647 return bascenev1.get_game_port() 648 649 def v2_upgrade_window(self, login_name: str, code: str) -> None: 650 """(internal)""" 651 652 from bauiv1lib.v2upgrade import V2UpgradeWindow 653 654 V2UpgradeWindow(login_name, code) 655 656 def account_link_code_window(self, data: dict[str, Any]) -> None: 657 """(internal)""" 658 from bauiv1lib.account.link import AccountLinkCodeWindow 659 660 AccountLinkCodeWindow(data) 661 662 def server_dialog(self, delay: float, data: dict[str, Any]) -> None: 663 """(internal)""" 664 from bauiv1lib.serverdialog import ( 665 ServerDialogData, 666 ServerDialogWindow, 667 ) 668 669 try: 670 sddata = dataclass_from_dict(ServerDialogData, data) 671 except Exception: 672 sddata = None 673 logging.warning( 674 'Got malformatted ServerDialogData: %s', 675 data, 676 ) 677 if sddata is not None: 678 babase.apptimer( 679 delay, 680 babase.Call(ServerDialogWindow, sddata), 681 ) 682 683 def show_url_window(self, address: str) -> None: 684 """(internal)""" 685 from bauiv1lib.url import ShowURLWindow 686 687 ShowURLWindow(address) 688 689 def quit_window(self, quit_type: babase.QuitType) -> None: 690 """(internal)""" 691 from bauiv1lib.confirm import QuitWindow 692 693 QuitWindow(quit_type) 694 695 def tournament_entry_window( 696 self, 697 tournament_id: str, 698 *, 699 tournament_activity: bascenev1.Activity | None = None, 700 position: tuple[float, float] = (0.0, 0.0), 701 delegate: Any = None, 702 scale: float | None = None, 703 offset: tuple[float, float] = (0.0, 0.0), 704 on_close_call: Callable[[], Any] | None = None, 705 ) -> None: 706 """(internal)""" 707 from bauiv1lib.tournamententry import TournamentEntryWindow 708 709 TournamentEntryWindow( 710 tournament_id, 711 tournament_activity, 712 position, 713 delegate, 714 scale, 715 offset, 716 on_close_call, 717 ) 718 719 def get_main_menu_session(self) -> type[bascenev1.Session]: 720 """(internal)""" 721 from bascenev1lib.mainmenu import MainMenuSession 722 723 return MainMenuSession 724 725 def profile_browser_window( 726 self, 727 transition: str = 'in_right', 728 origin_widget: bauiv1.Widget | None = None, 729 selected_profile: str | None = None, 730 ) -> None: 731 """(internal)""" 732 from bauiv1lib.profile.browser import ProfileBrowserWindow 733 734 main_window = babase.app.ui_v1.get_main_window() 735 if main_window is not None: 736 logging.warning( 737 'profile_browser_window()' 738 ' called with existing main window; should not happen.' 739 ) 740 return 741 742 babase.app.ui_v1.set_main_window( 743 ProfileBrowserWindow( 744 transition=transition, 745 selected_profile=selected_profile, 746 origin_widget=origin_widget, 747 minimal_toolbar=True, 748 ), 749 is_top_level=True, 750 suppress_warning=True, 751 ) 752 753 def preload_map_preview_media(self) -> None: 754 """Preload media needed for map preview UIs.""" 755 try: 756 bauiv1.getmesh('level_select_button_opaque') 757 bauiv1.getmesh('level_select_button_transparent') 758 for maptype in list(self.maps.values()): 759 map_tex_name = maptype.get_preview_texture_name() 760 if map_tex_name is not None: 761 bauiv1.gettexture(map_tex_name) 762 except Exception: 763 logging.exception('Error preloading map preview media.') 764 765 def party_icon_activate(self, origin: Sequence[float]) -> None: 766 """(internal)""" 767 from bauiv1lib.party import PartyWindow 768 from babase import app 769 770 assert app.env.gui 771 772 # Play explicit swish sound so it occurs due to keypresses/etc. 773 # This means we have to disable it for any button or else we get 774 # double. 775 bauiv1.getsound('swish').play() 776 777 # If it exists, dismiss it; otherwise make a new one. 778 party_window = ( 779 None if self.party_window is None else self.party_window() 780 ) 781 if party_window is not None: 782 party_window.close() 783 else: 784 self.party_window = weakref.ref(PartyWindow(origin=origin)) 785 786 def device_menu_press(self, device_id: int | None) -> None: 787 """(internal)""" 788 from bauiv1lib.ingamemenu import InGameMenuWindow 789 from bauiv1 import set_ui_input_device 790 791 assert babase.app is not None 792 in_main_menu = babase.app.ui_v1.has_main_window() 793 if not in_main_menu: 794 set_ui_input_device(device_id) 795 796 # Hack(ish). We play swish sound here so it happens for 797 # device presses, but this means we need to disable default 798 # swish sounds for any menu buttons or we'll get double. 799 if babase.app.env.gui: 800 bauiv1.getsound('swish').play() 801 802 # Pause gameplay. 803 self.pause() 804 805 babase.app.ui_v1.set_main_window( 806 InGameMenuWindow(), is_top_level=True, suppress_warning=True 807 ) 808 809 def save_ui_state(self) -> None: 810 """Store our current place in the UI.""" 811 ui = babase.app.ui_v1 812 mainwindow = ui.get_main_window() 813 if mainwindow is not None: 814 self.saved_ui_state = ui.save_main_window_state(mainwindow) 815 else: 816 self.saved_ui_state = None 817 818 def invoke_main_menu_ui(self) -> None: 819 """Bring up main menu ui.""" 820 821 # Bring up the last place we were, or start at the main menu 822 # otherwise. 823 app = bauiv1.app 824 env = app.env 825 with bascenev1.ContextRef.empty(): 826 827 assert app.classic is not None 828 if app.env.headless: 829 # UI stuff fails now in headless builds; avoid it. 830 pass 831 else: 832 833 # When coming back from a kiosk-mode game, jump to the 834 # kiosk start screen. 835 if env.demo or env.arcade: 836 # pylint: disable=cyclic-import 837 from bauiv1lib.kiosk import KioskWindow 838 839 app.ui_v1.set_main_window( 840 KioskWindow(), is_top_level=True, suppress_warning=True 841 ) 842 else: 843 # If there's a saved ui state, restore that. 844 if self.saved_ui_state is not None: 845 app.ui_v1.restore_main_window_state(self.saved_ui_state) 846 else: 847 # Otherwise start fresh at the main menu. 848 from bauiv1lib.mainmenu import MainMenuWindow 849 850 app.ui_v1.set_main_window( 851 MainMenuWindow(transition=None), 852 is_top_level=True, 853 suppress_warning=True, 854 ) 855 856 @staticmethod 857 def run_bs_client_effects( 858 effects: list[bacommon.bs.ClientEffect], delay: float = 0.0 859 ) -> None: 860 """Run client effects sent from the master server.""" 861 from baclassic._clienteffect import run_bs_client_effects 862 863 run_bs_client_effects(effects, delay=delay) 864 865 @staticmethod 866 def basic_client_ui_button_label_str( 867 label: bacommon.bs.BasicClientUI.ButtonLabel, 868 ) -> babase.Lstr: 869 """Given a client-ui label, return an Lstr.""" 870 import bacommon.bs 871 872 cls = bacommon.bs.BasicClientUI.ButtonLabel 873 if label is cls.UNKNOWN: 874 # Server should not be sending us unknown stuff; make noise 875 # if they do. 876 logging.error( 877 'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.' 878 ) 879 return babase.Lstr(value='<error>') 880 881 rsrc: str | None = None 882 if label is cls.OK: 883 rsrc = 'okText' 884 elif label is cls.APPLY: 885 rsrc = 'applyText' 886 elif label is cls.CANCEL: 887 rsrc = 'cancelText' 888 elif label is cls.ACCEPT: 889 rsrc = 'gatherWindow.partyInviteAcceptText' 890 elif label is cls.DECLINE: 891 rsrc = 'gatherWindow.partyInviteDeclineText' 892 elif label is cls.IGNORE: 893 rsrc = 'gatherWindow.partyInviteIgnoreText' 894 elif label is cls.CLAIM: 895 rsrc = 'claimText' 896 elif label is cls.DISCARD: 897 rsrc = 'discardText' 898 else: 899 assert_never(label) 900 901 return babase.Lstr(resource=rsrc) 902 903 def required_purchases_for_game(self, game: str) -> list[str]: 904 """Return which purchase (if any) is required for a game.""" 905 # pylint: disable=too-many-return-statements 906 907 if game in ( 908 'Challenges:Infinite Runaround', 909 'Challenges:Tournament Infinite Runaround', 910 ): 911 # Special case: Pro used to unlock this. 912 return ( 913 [] 914 if self.accounts.have_pro() 915 else ['upgrades.infinite_runaround'] 916 ) 917 if game in ( 918 'Challenges:Infinite Onslaught', 919 'Challenges:Tournament Infinite Onslaught', 920 ): 921 # Special case: Pro used to unlock this. 922 return ( 923 [] 924 if self.accounts.have_pro() 925 else ['upgrades.infinite_onslaught'] 926 ) 927 if game in ( 928 'Challenges:Meteor Shower', 929 'Challenges:Epic Meteor Shower', 930 ): 931 return ['games.meteor_shower'] 932 933 if game in ( 934 'Challenges:Target Practice', 935 'Challenges:Target Practice B', 936 ): 937 return ['games.target_practice'] 938 939 if game in ( 940 'Challenges:Ninja Fight', 941 'Challenges:Pro Ninja Fight', 942 ): 943 return ['games.ninja_fight'] 944 945 if game in ('Challenges:Race', 'Challenges:Pro Race'): 946 return ['games.race'] 947 948 if game in ('Challenges:Lake Frigid Race',): 949 return ['games.race', 'maps.lake_frigid'] 950 951 if game in ( 952 'Challenges:Easter Egg Hunt', 953 'Challenges:Pro Easter Egg Hunt', 954 ): 955 return ['games.easter_egg_hunt'] 956 957 return [] 958 959 def is_game_unlocked(self, game: str) -> bool: 960 """Is a particular game unlocked?""" 961 plus = babase.app.plus 962 assert plus is not None 963 964 purchases = self.required_purchases_for_game(game) 965 if not purchases: 966 return True 967 968 for purchase in purchases: 969 if not plus.get_v1_account_product_purchased(purchase): 970 return False 971 return True
Subsystem for classic functionality in the app.
The single shared instance of this app can be accessed at babase.app.classic. Note that it is possible for babase.app.classic to be None if the classic package is not present, and code should handle that case gracefully.
139 @property 140 def platform(self) -> str: 141 """Name of the current platform. 142 143 Examples are: 'mac', 'windows', android'. 144 """ 145 assert isinstance(self._env['platform'], str) 146 return self._env['platform']
Name of the current platform.
Examples are: 'mac', 'windows', android'.
152 @property 153 def subplatform(self) -> str: 154 """String for subplatform. 155 156 Can be empty. For the 'android' platform, subplatform may 157 be 'google', 'amazon', etc. 158 """ 159 assert isinstance(self._env['subplatform'], str) 160 return self._env['subplatform']
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
162 @property 163 def legacy_user_agent_string(self) -> str: 164 """String containing various bits of info about OS/device/etc.""" 165 assert isinstance(self._env['legacy_user_agent_string'], str) 166 return self._env['legacy_user_agent_string']
String containing various bits of info about OS/device/etc.
168 @override 169 def on_app_loading(self) -> None: 170 from bascenev1lib.actor import spazappearance 171 from bascenev1lib import maps as stdmaps 172 173 plus = babase.app.plus 174 assert plus is not None 175 176 env = babase.app.env 177 cfg = babase.app.config 178 179 self.music.on_app_loading() 180 181 # Non-test, non-debug builds should generally be blessed; warn 182 # if not (so I don't accidentally release a build that can't 183 # play tourneys). 184 if not env.debug and not env.test and not plus.is_blessed(): 185 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 186 187 stdmaps.register_all_maps() 188 189 spazappearance.register_appearances() 190 bascenev1.init_campaigns() 191 192 launch_count = cfg.get('launchCount', 0) 193 launch_count += 1 194 195 # So we know how many times we've run the game at various 196 # version milestones. 197 for key in ('lc14173', 'lc14292'): 198 cfg.setdefault(key, launch_count) 199 200 cfg['launchCount'] = launch_count 201 cfg.commit() 202 203 # If there's a leftover log file, attempt to upload it to the 204 # master-server and/or get rid of it. 205 babase.handle_leftover_v1_cloud_log_file() 206 207 self.accounts.on_app_loading()
Called when the app reaches the loading state.
Note that subsystems created after the app switches to the loading state will not receive this callback. Subsystems created by plugins are an example of this.
213 @override 214 def on_app_unsuspend(self) -> None: 215 self.accounts.on_app_unsuspend() 216 self.music.on_app_unsuspend()
Called when the app exits the suspended state.
222 def pause(self) -> None: 223 """Pause the game due to a user request or menu popping up. 224 225 If there's a foreground host-activity that says it's pausable, tell it 226 to pause. Note: we now no longer pause if there are connected clients. 227 """ 228 activity: bascenev1.Activity | None = ( 229 bascenev1.get_foreground_host_activity() 230 ) 231 if ( 232 activity is not None 233 and activity.allow_pausing 234 and not bascenev1.have_connected_clients() 235 ): 236 from babase import Lstr 237 from bascenev1 import NodeActor 238 239 # FIXME: Shouldn't be touching scene stuff here; should just 240 # pass the request on to the host-session. 241 with activity.context: 242 globs = activity.globalsnode 243 if not globs.paused: 244 bascenev1.getsound('refWhistle').play() 245 globs.paused = True 246 247 # FIXME: This should not be an attr on Actor. 248 activity.paused_text = NodeActor( 249 bascenev1.newnode( 250 'text', 251 attrs={ 252 'text': Lstr(resource='pausedByHostText'), 253 'client_only': True, 254 'flatness': 1.0, 255 'h_align': 'center', 256 }, 257 ) 258 )
Pause the game due to a user request or menu popping up.
If there's a foreground host-activity that says it's pausable, tell it to pause. Note: we now no longer pause if there are connected clients.
260 def resume(self) -> None: 261 """Resume the game due to a user request or menu closing. 262 263 If there's a foreground host-activity that's currently paused, tell it 264 to resume. 265 """ 266 267 # FIXME: Shouldn't be touching scene stuff here; should just 268 # pass the request on to the host-session. 269 activity = bascenev1.get_foreground_host_activity() 270 if activity is not None: 271 with activity.context: 272 globs = activity.globalsnode 273 if globs.paused: 274 bascenev1.getsound('refWhistle').play() 275 globs.paused = False 276 277 # FIXME: This should not be an actor attr. 278 activity.paused_text = None
Resume the game due to a user request or menu closing.
If there's a foreground host-activity that's currently paused, tell it to resume.
280 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 281 """Adds an individual level to the 'practice' section in Co-op.""" 282 283 # Assign this level to our catch-all campaign. 284 self.campaigns['Challenges'].addlevel(level) 285 286 # Make note to add it to our challenges UI. 287 self.custom_coop_practice_games.append(f'Challenges:{level.name}')
Adds an individual level to the 'practice' section in Co-op.
289 def launch_coop_game( 290 self, game: str, force: bool = False, args: dict | None = None 291 ) -> bool: 292 """High level way to launch a local co-op session.""" 293 # pylint: disable=cyclic-import 294 from bauiv1lib.coop.level import CoopLevelLockedWindow 295 296 assert babase.app.classic is not None 297 298 if args is None: 299 args = {} 300 if game == '': 301 raise ValueError('empty game name') 302 campaignname, levelname = game.split(':') 303 campaign = babase.app.classic.getcampaign(campaignname) 304 305 # If this campaign is sequential, make sure we've completed the 306 # one before this. 307 if campaign.sequential and not force: 308 for level in campaign.levels: 309 if level.name == levelname: 310 break 311 if not level.complete: 312 CoopLevelLockedWindow( 313 campaign.getlevel(levelname).displayname, 314 campaign.getlevel(level.name).displayname, 315 ) 316 return False 317 318 # Save where we are in the UI to come back to when done. 319 babase.app.classic.save_ui_state() 320 321 # Ok, we're good to go. 322 self.coop_session_args = { 323 'campaign': campaignname, 324 'level': levelname, 325 } 326 for arg_name, arg_val in list(args.items()): 327 self.coop_session_args[arg_name] = arg_val 328 329 def _fade_end() -> None: 330 from bascenev1 import CoopSession 331 332 try: 333 bascenev1.new_host_session(CoopSession) 334 except Exception: 335 logging.exception('Error creating coopsession after fade end.') 336 from bascenev1lib.mainmenu import MainMenuSession 337 338 bascenev1.new_host_session(MainMenuSession) 339 340 babase.fade_screen(False, endcall=_fade_end) 341 return True
High level way to launch a local co-op session.
386 def getmaps(self, playtype: str) -> list[str]: 387 """Return a list of bascenev1.Map types supporting a playtype str. 388 389 Maps supporting a given playtype must provide a particular set of 390 features and lend themselves to a certain style of play. 391 392 Play Types: 393 394 'melee' 395 General fighting map. 396 Has one or more 'spawn' locations. 397 398 'team_flag' 399 For games such as Capture The Flag where each team spawns by a flag. 400 Has two or more 'spawn' locations, each with a corresponding 'flag' 401 location (based on index). 402 403 'single_flag' 404 For games such as King of the Hill or Keep Away where multiple teams 405 are fighting over a single flag. 406 Has two or more 'spawn' locations and 1 'flag_default' location. 407 408 'conquest' 409 For games such as Conquest where flags are spread throughout the map 410 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 411 412 'king_of_the_hill' - has 2+ 'spawn' locations, 413 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 414 415 'hockey' 416 For hockey games. 417 Has two 'goal' locations, corresponding 'spawn' locations, and one 418 'flag_default' location (for where puck spawns) 419 420 'football' 421 For football games. 422 Has two 'goal' locations, corresponding 'spawn' locations, and one 423 'flag_default' location (for where flag/ball/etc. spawns) 424 425 'race' 426 For racing games where players much touch each region in order. 427 Has two or more 'race_point' locations. 428 """ 429 return sorted( 430 key 431 for key, val in self.maps.items() 432 if playtype in val.get_play_types() 433 )
Return a list of bascenev1.Map types supporting a playtype str.
Maps supporting a given playtype must provide a particular set of features and lend themselves to a certain style of play.
Play Types:
'melee' General fighting map. Has one or more 'spawn' locations.
'team_flag' For games such as Capture The Flag where each team spawns by a flag. Has two or more 'spawn' locations, each with a corresponding 'flag' location (based on index).
'single_flag' For games such as King of the Hill or Keep Away where multiple teams are fighting over a single flag. Has two or more 'spawn' locations and 1 'flag_default' location.
'conquest' For games such as Conquest where flags are spread throughout the map
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
'hockey' For hockey games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where puck spawns)
'football' For football games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where flag/ball/etc. spawns)
'race' For racing games where players much touch each region in order. Has two or more 'race_point' locations.
441 @classmethod 442 def json_prep(cls, data: Any) -> Any: 443 """Return a json-friendly version of the provided data. 444 445 This converts any tuples to lists and any bytes to strings 446 (interpreted as utf-8, ignoring errors). Logs errors (just once) 447 if any data is modified/discarded/unsupported. 448 """ 449 450 if isinstance(data, dict): 451 return dict( 452 (cls.json_prep(key), cls.json_prep(value)) 453 for key, value in list(data.items()) 454 ) 455 if isinstance(data, list): 456 return [cls.json_prep(element) for element in data] 457 if isinstance(data, tuple): 458 logging.exception('json_prep encountered tuple') 459 return [cls.json_prep(element) for element in data] 460 if isinstance(data, bytes): 461 try: 462 return data.decode(errors='ignore') 463 except Exception: 464 logging.exception('json_prep encountered utf-8 decode error') 465 return data.decode(errors='ignore') 466 if not isinstance(data, (str, float, bool, type(None), int)): 467 logging.exception( 468 'got unsupported type in json_prep: %s', type(data) 469 ) 470 return data
Return a json-friendly version of the provided data.
This converts any tuples to lists and any bytes to strings (interpreted as utf-8, ignoring errors). Logs errors (just once) if any data is modified/discarded/unsupported.
472 def master_server_v1_get( 473 self, 474 request: str, 475 data: dict[str, Any], 476 callback: MasterServerCallback | None = None, 477 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 478 ) -> None: 479 """Make a call to the master server via a http GET.""" 480 481 MasterServerV1CallThread( 482 request, 'get', data, callback, response_type 483 ).start()
Make a call to the master server via a http GET.
485 def master_server_v1_post( 486 self, 487 request: str, 488 data: dict[str, Any], 489 callback: MasterServerCallback | None = None, 490 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 491 ) -> None: 492 """Make a call to the master server via a http POST.""" 493 MasterServerV1CallThread( 494 request, 'post', data, callback, response_type 495 ).start()
Make a call to the master server via a http POST.
497 def set_tournament_prize_image( 498 self, entry: dict[str, Any], index: int, image: bauiv1.Widget 499 ) -> None: 500 """Given a tournament entry, return strings for its prize levels.""" 501 from baclassic import _tournament 502 503 return _tournament.set_tournament_prize_chest_image(entry, index, image)
Given a tournament entry, return strings for its prize levels.
505 def create_in_game_tournament_prize_image( 506 self, 507 entry: dict[str, Any], 508 index: int, 509 position: tuple[float, float], 510 ) -> None: 511 """Given a tournament entry, return strings for its prize levels.""" 512 from baclassic import _tournament 513 514 _tournament.create_in_game_tournament_prize_image( 515 entry, index, position 516 )
Given a tournament entry, return strings for its prize levels.
518 def get_tournament_prize_strings( 519 self, entry: dict[str, Any], include_tickets: bool 520 ) -> list[str]: 521 """Given a tournament entry, return strings for its prize levels.""" 522 from baclassic import _tournament 523 524 return _tournament.get_tournament_prize_strings( 525 entry, include_tickets=include_tickets 526 )
Given a tournament entry, return strings for its prize levels.
528 def getcampaign(self, name: str) -> bascenev1.Campaign: 529 """Return a campaign by name.""" 530 return self.campaigns[name]
Return a campaign by name.
532 def get_next_tip(self) -> str: 533 """Returns the next tip to be displayed.""" 534 if not self.tips: 535 for tip in get_all_tips(): 536 self.tips.insert(random.randint(0, len(self.tips)), tip) 537 tip = self.tips.pop() 538 return tip
Returns the next tip to be displayed.
540 def run_cpu_benchmark(self) -> None: 541 """Kick off a benchmark to test cpu speeds.""" 542 from baclassic._benchmark import run_cpu_benchmark 543 544 run_cpu_benchmark()
Kick off a benchmark to test cpu speeds.
546 def run_media_reload_benchmark(self) -> None: 547 """Kick off a benchmark to test media reloading speeds.""" 548 from baclassic._benchmark import run_media_reload_benchmark 549 550 run_media_reload_benchmark()
Kick off a benchmark to test media reloading speeds.
552 def run_stress_test( 553 self, 554 *, 555 playlist_type: str = 'Random', 556 playlist_name: str = '__default__', 557 player_count: int = 8, 558 round_duration: int = 30, 559 attract_mode: bool = False, 560 ) -> None: 561 """Run a stress test.""" 562 from baclassic._benchmark import run_stress_test 563 564 run_stress_test( 565 playlist_type=playlist_type, 566 playlist_name=playlist_name, 567 player_count=player_count, 568 round_duration=round_duration, 569 attract_mode=attract_mode, 570 )
Run a stress test.
572 def get_input_device_mapped_value( 573 self, 574 device: bascenev1.InputDevice, 575 name: str, 576 default: bool = False, 577 ) -> Any: 578 """Return a mapped value for an input device. 579 580 This checks the user config and falls back to default values 581 where available. 582 """ 583 return _input.get_input_device_mapped_value( 584 device.name, device.unique_identifier, name, default 585 )
Return a mapped value for an input device.
This checks the user config and falls back to default values where available.
587 def get_input_device_map_hash( 588 self, inputdevice: bascenev1.InputDevice 589 ) -> str: 590 """Given an input device, return hash based on its raw input values.""" 591 del inputdevice # unused currently 592 return _input.get_input_device_map_hash()
Given an input device, return hash based on its raw input values.
594 def get_input_device_config( 595 self, inputdevice: bascenev1.InputDevice, default: bool 596 ) -> tuple[dict, str]: 597 """Given an input device, return its config dict in the app config. 598 599 The dict will be created if it does not exist. 600 """ 601 return _input.get_input_device_config( 602 inputdevice.name, inputdevice.unique_identifier, default 603 )
Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
605 def get_player_colors(self) -> list[tuple[float, float, float]]: 606 """Return user-selectable player colors.""" 607 return bascenev1.get_player_colors()
Return user-selectable player colors.
609 def get_player_profile_icon(self, profilename: str) -> str: 610 """Given a profile name, returns an icon string for it. 611 612 (non-account profiles only) 613 """ 614 return bascenev1.get_player_profile_icon(profilename)
Given a profile name, returns an icon string for it.
(non-account profiles only)
616 def get_player_profile_colors( 617 self, 618 profilename: str | None, 619 profiles: dict[str, dict[str, Any]] | None = None, 620 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 621 """Given a profile, return colors for them.""" 622 return bascenev1.get_player_profile_colors(profilename, profiles)
Given a profile, return colors for them.
753 def preload_map_preview_media(self) -> None: 754 """Preload media needed for map preview UIs.""" 755 try: 756 bauiv1.getmesh('level_select_button_opaque') 757 bauiv1.getmesh('level_select_button_transparent') 758 for maptype in list(self.maps.values()): 759 map_tex_name = maptype.get_preview_texture_name() 760 if map_tex_name is not None: 761 bauiv1.gettexture(map_tex_name) 762 except Exception: 763 logging.exception('Error preloading map preview media.')
Preload media needed for map preview UIs.
809 def save_ui_state(self) -> None: 810 """Store our current place in the UI.""" 811 ui = babase.app.ui_v1 812 mainwindow = ui.get_main_window() 813 if mainwindow is not None: 814 self.saved_ui_state = ui.save_main_window_state(mainwindow) 815 else: 816 self.saved_ui_state = None
Store our current place in the UI.
856 @staticmethod 857 def run_bs_client_effects( 858 effects: list[bacommon.bs.ClientEffect], delay: float = 0.0 859 ) -> None: 860 """Run client effects sent from the master server.""" 861 from baclassic._clienteffect import run_bs_client_effects 862 863 run_bs_client_effects(effects, delay=delay)
Run client effects sent from the master server.
903 def required_purchases_for_game(self, game: str) -> list[str]: 904 """Return which purchase (if any) is required for a game.""" 905 # pylint: disable=too-many-return-statements 906 907 if game in ( 908 'Challenges:Infinite Runaround', 909 'Challenges:Tournament Infinite Runaround', 910 ): 911 # Special case: Pro used to unlock this. 912 return ( 913 [] 914 if self.accounts.have_pro() 915 else ['upgrades.infinite_runaround'] 916 ) 917 if game in ( 918 'Challenges:Infinite Onslaught', 919 'Challenges:Tournament Infinite Onslaught', 920 ): 921 # Special case: Pro used to unlock this. 922 return ( 923 [] 924 if self.accounts.have_pro() 925 else ['upgrades.infinite_onslaught'] 926 ) 927 if game in ( 928 'Challenges:Meteor Shower', 929 'Challenges:Epic Meteor Shower', 930 ): 931 return ['games.meteor_shower'] 932 933 if game in ( 934 'Challenges:Target Practice', 935 'Challenges:Target Practice B', 936 ): 937 return ['games.target_practice'] 938 939 if game in ( 940 'Challenges:Ninja Fight', 941 'Challenges:Pro Ninja Fight', 942 ): 943 return ['games.ninja_fight'] 944 945 if game in ('Challenges:Race', 'Challenges:Pro Race'): 946 return ['games.race'] 947 948 if game in ('Challenges:Lake Frigid Race',): 949 return ['games.race', 'maps.lake_frigid'] 950 951 if game in ( 952 'Challenges:Easter Egg Hunt', 953 'Challenges:Pro Easter Egg Hunt', 954 ): 955 return ['games.easter_egg_hunt'] 956 957 return []
Return which purchase (if any) is required for a game.
959 def is_game_unlocked(self, game: str) -> bool: 960 """Is a particular game unlocked?""" 961 plus = babase.app.plus 962 assert plus is not None 963 964 purchases = self.required_purchases_for_game(game) 965 if not purchases: 966 return True 967 968 for purchase in purchases: 969 if not plus.get_v1_account_product_purchased(purchase): 970 return False 971 return True
Is a particular game unlocked?
23class MusicPlayMode(Enum): 24 """Influences behavior when playing music.""" 25 26 REGULAR = 'regular' 27 TEST = 'test'
Influences behavior when playing music.
531class Achievement: 532 """Represents attributes and state for an individual achievement.""" 533 534 def __init__( 535 self, 536 name: str, 537 icon_name: str, 538 icon_color: tuple[float, float, float], 539 level_name: str, 540 *, 541 award: int, 542 hard_mode_only: bool = False, 543 ): 544 self._name = name 545 self._icon_name = icon_name 546 assert len(icon_color) == 3 547 self._icon_color = icon_color + (1.0,) 548 self._level_name = level_name 549 self._completion_banner_slot: int | None = None 550 self._award = award 551 self._hard_mode_only = hard_mode_only 552 553 @property 554 def name(self) -> str: 555 """The name of this achievement.""" 556 return self._name 557 558 @property 559 def level_name(self) -> str: 560 """The name of the level this achievement applies to.""" 561 return self._level_name 562 563 def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture: 564 """Return the icon texture to display for this achievement""" 565 return bauiv1.gettexture( 566 self._icon_name if complete else 'achievementEmpty' 567 ) 568 569 def get_icon_texture(self, complete: bool) -> bascenev1.Texture: 570 """Return the icon texture to display for this achievement""" 571 return bascenev1.gettexture( 572 self._icon_name if complete else 'achievementEmpty' 573 ) 574 575 def get_icon_color(self, complete: bool) -> Sequence[float]: 576 """Return the color tint for this Achievement's icon.""" 577 if complete: 578 return self._icon_color 579 return 1.0, 1.0, 1.0, 0.6 580 581 @property 582 def hard_mode_only(self) -> bool: 583 """Whether this Achievement is only unlockable in hard-mode.""" 584 return self._hard_mode_only 585 586 @property 587 def complete(self) -> bool: 588 """Whether this Achievement is currently complete.""" 589 val: bool = self._getconfig()['Complete'] 590 assert isinstance(val, bool) 591 return val 592 593 def announce_completion(self, sound: bool = True) -> None: 594 """Kick off an announcement for this achievement's completion.""" 595 596 app = babase.app 597 plus = app.plus 598 classic = app.classic 599 if plus is None or classic is None: 600 logging.warning('ach account_completion not available.') 601 return 602 603 ach_ss = classic.ach 604 605 # Even though there are technically achievements when we're not 606 # signed in, lets not show them (otherwise we tend to get 607 # confusing 'controller connected' achievements popping up while 608 # waiting to sign in which can be confusing). 609 if plus.get_v1_account_state() != 'signed_in': 610 return 611 612 # If we're being freshly complete, display/report it and whatnot. 613 if (self, sound) not in ach_ss.achievements_to_display: 614 ach_ss.achievements_to_display.append((self, sound)) 615 616 # If there's no achievement display timer going, kick one off 617 # (if one's already running it will pick this up before it dies). 618 619 # Need to check last time too; its possible our timer wasn't able to 620 # clear itself if an activity died and took it down with it. 621 if ( 622 ach_ss.achievement_display_timer is None 623 or babase.apptime() - ach_ss.last_achievement_display_time > 2.0 624 ) and bascenev1.getactivity(doraise=False) is not None: 625 ach_ss.achievement_display_timer = bascenev1.BaseTimer( 626 1.0, _display_next_achievement, repeat=True 627 ) 628 629 # Show the first immediately. 630 _display_next_achievement() 631 632 def set_complete(self, complete: bool = True) -> None: 633 """Set an achievement's completed state. 634 635 note this only sets local state; use a transaction to 636 actually award achievements. 637 """ 638 config = self._getconfig() 639 if complete != config['Complete']: 640 config['Complete'] = complete 641 642 @property 643 def display_name(self) -> babase.Lstr: 644 """Return a babase.Lstr for this Achievement's name.""" 645 name: babase.Lstr | str 646 try: 647 if self._level_name != '': 648 campaignname, campaign_level = self._level_name.split(':') 649 classic = babase.app.classic 650 assert classic is not None 651 name = ( 652 classic.getcampaign(campaignname) 653 .getlevel(campaign_level) 654 .displayname 655 ) 656 else: 657 name = '' 658 except Exception: 659 name = '' 660 logging.exception('Error calcing achievement display-name.') 661 return babase.Lstr( 662 resource='achievements.' + self._name + '.name', 663 subs=[('${LEVEL}', name)], 664 ) 665 666 @property 667 def description(self) -> babase.Lstr: 668 """Get a babase.Lstr for the Achievement's brief description.""" 669 if ( 670 'description' 671 in babase.app.lang.get_resource('achievements')[self._name] 672 ): 673 return babase.Lstr( 674 resource='achievements.' + self._name + '.description' 675 ) 676 return babase.Lstr( 677 resource='achievements.' + self._name + '.descriptionFull' 678 ) 679 680 @property 681 def description_complete(self) -> babase.Lstr: 682 """Get a babase.Lstr for the Achievement's description when complete.""" 683 if ( 684 'descriptionComplete' 685 in babase.app.lang.get_resource('achievements')[self._name] 686 ): 687 return babase.Lstr( 688 resource='achievements.' + self._name + '.descriptionComplete' 689 ) 690 return babase.Lstr( 691 resource='achievements.' + self._name + '.descriptionFullComplete' 692 ) 693 694 @property 695 def description_full(self) -> babase.Lstr: 696 """Get a babase.Lstr for the Achievement's full description.""" 697 return babase.Lstr( 698 resource='achievements.' + self._name + '.descriptionFull', 699 subs=[ 700 ( 701 '${LEVEL}', 702 babase.Lstr( 703 translate=( 704 'coopLevelNames', 705 ACH_LEVEL_NAMES.get(self._name, '?'), 706 ) 707 ), 708 ) 709 ], 710 ) 711 712 @property 713 def description_full_complete(self) -> babase.Lstr: 714 """Get a babase.Lstr for the Achievement's full desc. when completed.""" 715 return babase.Lstr( 716 resource='achievements.' + self._name + '.descriptionFullComplete', 717 subs=[ 718 ( 719 '${LEVEL}', 720 babase.Lstr( 721 translate=( 722 'coopLevelNames', 723 ACH_LEVEL_NAMES.get(self._name, '?'), 724 ) 725 ), 726 ) 727 ], 728 ) 729 730 def get_award_chest_type(self) -> ClassicChestAppearance: 731 """Return the type of chest given for this achievement.""" 732 733 # For now just map our old ticket values to chest types. 734 # Can add distinct values if need be later. 735 plus = babase.app.plus 736 assert plus is not None 737 t = plus.get_v1_account_misc_read_val( 738 f'achAward.{self.name}', self._award 739 ) 740 return ( 741 ClassicChestAppearance.L6 742 if t >= 30 743 else ( 744 ClassicChestAppearance.L5 745 if t >= 25 746 else ( 747 ClassicChestAppearance.L4 748 if t >= 20 749 else ( 750 ClassicChestAppearance.L3 751 if t >= 15 752 else ( 753 ClassicChestAppearance.L2 754 if t >= 10 755 else ClassicChestAppearance.L1 756 ) 757 ) 758 ) 759 ) 760 ) 761 762 # def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: 763 # """Get the ticket award value for this achievement.""" 764 # plus = babase.app.plus 765 # if plus is None: 766 # return 0 767 # val: int = plus.get_v1_account_misc_read_val( 768 # 'achAward.' + self._name, self._award 769 # ) * _get_ach_mult(include_pro_bonus) 770 # assert isinstance(val, int) 771 # return val 772 773 @property 774 def power_ranking_value(self) -> int: 775 """Get the power-ranking award value for this achievement.""" 776 plus = babase.app.plus 777 if plus is None: 778 return 0 779 val: int = plus.get_v1_account_misc_read_val( 780 'achLeaguePoints.' + self._name, self._award 781 ) 782 assert isinstance(val, int) 783 return val 784 785 def create_display( 786 self, 787 x: float, 788 y: float, 789 delay: float, 790 *, 791 outdelay: float | None = None, 792 color: Sequence[float] | None = None, 793 style: str = 'post_game', 794 ) -> list[bascenev1.Actor]: 795 """Create a display for the Achievement. 796 797 Shows the Achievement icon, name, and description. 798 """ 799 # pylint: disable=cyclic-import 800 from bascenev1 import CoopSession 801 from bascenev1lib.actor.image import Image 802 from bascenev1lib.actor.text import Text 803 804 # Yeah this needs cleaning up. 805 if style == 'post_game': 806 in_game_colors = False 807 in_main_menu = False 808 h_attach = Text.HAttach.CENTER 809 v_attach = Text.VAttach.CENTER 810 attach = Image.Attach.CENTER 811 elif style == 'in_game': 812 in_game_colors = True 813 in_main_menu = False 814 h_attach = Text.HAttach.LEFT 815 v_attach = Text.VAttach.TOP 816 attach = Image.Attach.TOP_LEFT 817 elif style == 'news': 818 in_game_colors = True 819 in_main_menu = True 820 h_attach = Text.HAttach.CENTER 821 v_attach = Text.VAttach.TOP 822 attach = Image.Attach.TOP_CENTER 823 else: 824 raise ValueError('invalid style "' + style + '"') 825 826 # Attempt to determine what campaign we're in 827 # (so we know whether to show "hard mode only"). 828 if in_main_menu: 829 hmo = False 830 else: 831 try: 832 session = bascenev1.getsession() 833 if isinstance(session, CoopSession): 834 campaign = session.campaign 835 assert campaign is not None 836 hmo = self._hard_mode_only and campaign.name == 'Easy' 837 else: 838 hmo = False 839 except Exception: 840 logging.exception('Error determining campaign.') 841 hmo = False 842 843 objs: list[bascenev1.Actor] 844 845 if in_game_colors: 846 objs = [] 847 out_delay_fin = (delay + outdelay) if outdelay is not None else None 848 if color is not None: 849 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 850 cl2 = color 851 else: 852 cl1 = (1.5, 1.5, 2, 1.0) 853 cl2 = (0.8, 0.8, 1.0, 1.0) 854 855 if hmo: 856 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 857 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 858 859 objs.append( 860 Image( 861 self.get_icon_texture(False), 862 host_only=True, 863 color=cl1, 864 position=(x - 25, y + 5), 865 attach=attach, 866 transition=Image.Transition.FADE_IN, 867 transition_delay=delay, 868 vr_depth=4, 869 transition_out_delay=out_delay_fin, 870 scale=(40, 40), 871 ).autoretain() 872 ) 873 txt = self.display_name 874 txt_s = 0.85 875 txt_max_w = 300 876 objs.append( 877 Text( 878 txt, 879 host_only=True, 880 maxwidth=txt_max_w, 881 position=(x, y + 2), 882 transition=Text.Transition.FADE_IN, 883 scale=txt_s, 884 flatness=0.6, 885 shadow=0.5, 886 h_attach=h_attach, 887 v_attach=v_attach, 888 color=cl2, 889 transition_delay=delay + 0.05, 890 transition_out_delay=out_delay_fin, 891 ).autoretain() 892 ) 893 txt2_s = 0.62 894 txt2_max_w = 400 895 objs.append( 896 Text( 897 self.description_full if in_main_menu else self.description, 898 host_only=True, 899 maxwidth=txt2_max_w, 900 position=(x, y - 14), 901 transition=Text.Transition.FADE_IN, 902 vr_depth=-5, 903 h_attach=h_attach, 904 v_attach=v_attach, 905 scale=txt2_s, 906 flatness=1.0, 907 shadow=0.5, 908 color=cl2, 909 transition_delay=delay + 0.1, 910 transition_out_delay=out_delay_fin, 911 ).autoretain() 912 ) 913 914 if hmo: 915 txtactor = Text( 916 babase.Lstr(resource='difficultyHardOnlyText'), 917 host_only=True, 918 maxwidth=txt2_max_w * 0.7, 919 position=(x + 60, y + 5), 920 transition=Text.Transition.FADE_IN, 921 vr_depth=-5, 922 h_attach=h_attach, 923 v_attach=v_attach, 924 h_align=Text.HAlign.CENTER, 925 v_align=Text.VAlign.CENTER, 926 scale=txt_s * 0.8, 927 flatness=1.0, 928 shadow=0.5, 929 color=(1, 1, 0.6, 1), 930 transition_delay=delay + 0.1, 931 transition_out_delay=out_delay_fin, 932 ).autoretain() 933 txtactor.node.rotate = 10 934 objs.append(txtactor) 935 936 # Chest award. 937 award_x = -100 938 chesttype = self.get_award_chest_type() 939 chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( 940 chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT 941 ) 942 objs.append( 943 Image( 944 # Provide magical extended dict version of texture 945 # that Image actor supports. 946 texture={ 947 'texture': bascenev1.gettexture( 948 chestdisplayinfo.texclosed 949 ), 950 'tint_texture': bascenev1.gettexture( 951 chestdisplayinfo.texclosedtint 952 ), 953 'tint_color': chestdisplayinfo.tint, 954 'tint2_color': chestdisplayinfo.tint2, 955 'mask_texture': None, 956 }, 957 color=chestdisplayinfo.color + (0.5 if hmo else 1.0,), 958 position=(x + award_x + 37, y + 12), 959 scale=(32.0, 32.0), 960 transition=Image.Transition.FADE_IN, 961 transition_delay=delay + 0.05, 962 transition_out_delay=out_delay_fin, 963 host_only=True, 964 attach=Image.Attach.TOP_LEFT, 965 ).autoretain() 966 ) 967 968 # objs.append( 969 # Text( 970 # babase.charstr(babase.SpecialChar.TICKET), 971 # host_only=True, 972 # position=(x + award_x + 33, y + 7), 973 # transition=Text.Transition.FADE_IN, 974 # scale=1.5, 975 # h_attach=h_attach, 976 # v_attach=v_attach, 977 # h_align=Text.HAlign.CENTER, 978 # v_align=Text.VAlign.CENTER, 979 # color=(1, 1, 1, 0.2 if hmo else 0.4), 980 # transition_delay=delay + 0.05, 981 # transition_out_delay=out_delay_fin, 982 # ).autoretain() 983 # ) 984 # objs.append( 985 # Text( 986 # '+' + str(self.get_award_ticket_value()), 987 # host_only=True, 988 # position=(x + award_x + 28, y + 16), 989 # transition=Text.Transition.FADE_IN, 990 # scale=0.7, 991 # flatness=1, 992 # h_attach=h_attach, 993 # v_attach=v_attach, 994 # h_align=Text.HAlign.CENTER, 995 # v_align=Text.VAlign.CENTER, 996 # color=cl2, 997 # transition_delay=delay + 0.05, 998 # transition_out_delay=out_delay_fin, 999 # ).autoretain() 1000 # ) 1001 1002 else: 1003 complete = self.complete 1004 objs = [] 1005 c_icon = self.get_icon_color(complete) 1006 if hmo and not complete: 1007 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1008 objs.append( 1009 Image( 1010 self.get_icon_texture(complete), 1011 host_only=True, 1012 color=c_icon, 1013 position=(x - 25, y + 5), 1014 attach=attach, 1015 vr_depth=4, 1016 transition=Image.Transition.IN_RIGHT, 1017 transition_delay=delay, 1018 transition_out_delay=None, 1019 scale=(40, 40), 1020 ).autoretain() 1021 ) 1022 if complete: 1023 objs.append( 1024 Image( 1025 bascenev1.gettexture('achievementOutline'), 1026 host_only=True, 1027 mesh_transparent=bascenev1.getmesh( 1028 'achievementOutline' 1029 ), 1030 color=(2, 1.4, 0.4, 1), 1031 vr_depth=8, 1032 position=(x - 25, y + 5), 1033 attach=attach, 1034 transition=Image.Transition.IN_RIGHT, 1035 transition_delay=delay, 1036 transition_out_delay=None, 1037 scale=(40, 40), 1038 ).autoretain() 1039 ) 1040 else: 1041 if not complete: 1042 award_x = -100 1043 # objs.append( 1044 # Text( 1045 # babase.charstr(babase.SpecialChar.TICKET), 1046 # host_only=True, 1047 # position=(x + award_x + 33, y + 7), 1048 # transition=Text.Transition.IN_RIGHT, 1049 # scale=1.5, 1050 # h_attach=h_attach, 1051 # v_attach=v_attach, 1052 # h_align=Text.HAlign.CENTER, 1053 # v_align=Text.VAlign.CENTER, 1054 # color=(1, 1, 1, (0.1 if hmo else 0.2)), 1055 # transition_delay=delay + 0.05, 1056 # transition_out_delay=None, 1057 # ).autoretain() 1058 # ) 1059 chesttype = self.get_award_chest_type() 1060 chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( 1061 chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT 1062 ) 1063 objs.append( 1064 Image( 1065 # Provide magical extended dict version of texture 1066 # that Image actor supports. 1067 texture={ 1068 'texture': bascenev1.gettexture( 1069 chestdisplayinfo.texclosed 1070 ), 1071 'tint_texture': bascenev1.gettexture( 1072 chestdisplayinfo.texclosedtint 1073 ), 1074 'tint_color': chestdisplayinfo.tint, 1075 'tint2_color': chestdisplayinfo.tint2, 1076 'mask_texture': None, 1077 }, 1078 color=chestdisplayinfo.color 1079 + (0.5 if hmo else 1.0,), 1080 position=(x + award_x + 38, y + 14), 1081 scale=(32.0, 32.0), 1082 transition=Image.Transition.IN_RIGHT, 1083 transition_delay=delay + 0.05, 1084 transition_out_delay=None, 1085 host_only=True, 1086 attach=attach, 1087 ).autoretain() 1088 ) 1089 1090 # objs.append( 1091 # Text( 1092 # '+' + str(self.get_award_ticket_value()), 1093 # host_only=True, 1094 # position=(x + award_x + 28, y + 16), 1095 # transition=Text.Transition.IN_RIGHT, 1096 # scale=0.7, 1097 # flatness=1, 1098 # h_attach=h_attach, 1099 # v_attach=v_attach, 1100 # h_align=Text.HAlign.CENTER, 1101 # v_align=Text.VAlign.CENTER, 1102 # color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), 1103 # transition_delay=delay + 0.05, 1104 # transition_out_delay=None, 1105 # ).autoretain() 1106 # ) 1107 1108 # Show 'hard-mode-only' only over incomplete achievements 1109 # when that's the case. 1110 if hmo: 1111 txtactor = Text( 1112 babase.Lstr(resource='difficultyHardOnlyText'), 1113 host_only=True, 1114 maxwidth=300 * 0.7, 1115 position=(x + 60, y + 5), 1116 transition=Text.Transition.FADE_IN, 1117 vr_depth=-5, 1118 h_attach=h_attach, 1119 v_attach=v_attach, 1120 h_align=Text.HAlign.CENTER, 1121 v_align=Text.VAlign.CENTER, 1122 scale=0.85 * 0.8, 1123 flatness=1.0, 1124 shadow=0.5, 1125 color=(1, 1, 0.6, 1), 1126 transition_delay=delay + 0.05, 1127 transition_out_delay=None, 1128 ).autoretain() 1129 assert txtactor.node 1130 txtactor.node.rotate = 10 1131 objs.append(txtactor) 1132 1133 objs.append( 1134 Text( 1135 self.display_name, 1136 host_only=True, 1137 maxwidth=300, 1138 position=(x, y + 2), 1139 transition=Text.Transition.IN_RIGHT, 1140 scale=0.85, 1141 flatness=0.6, 1142 h_attach=h_attach, 1143 v_attach=v_attach, 1144 color=( 1145 (0.8, 0.93, 0.8, 1.0) 1146 if complete 1147 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1148 ), 1149 transition_delay=delay + 0.05, 1150 transition_out_delay=None, 1151 ).autoretain() 1152 ) 1153 objs.append( 1154 Text( 1155 self.description_complete if complete else self.description, 1156 host_only=True, 1157 maxwidth=400, 1158 position=(x, y - 14), 1159 transition=Text.Transition.IN_RIGHT, 1160 vr_depth=-5, 1161 h_attach=h_attach, 1162 v_attach=v_attach, 1163 scale=0.62, 1164 flatness=1.0, 1165 color=( 1166 (0.6, 0.6, 0.6, 1.0) 1167 if complete 1168 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1169 ), 1170 transition_delay=delay + 0.1, 1171 transition_out_delay=None, 1172 ).autoretain() 1173 ) 1174 return objs 1175 1176 def _getconfig(self) -> dict[str, Any]: 1177 """ 1178 Return the sub-dict in settings where this achievement's 1179 state is stored, creating it if need be. 1180 """ 1181 val: dict[str, Any] = babase.app.config.setdefault( 1182 'Achievements', {} 1183 ).setdefault(self._name, {'Complete': False}) 1184 assert isinstance(val, dict) 1185 return val 1186 1187 def _remove_banner_slot(self) -> None: 1188 classic = babase.app.classic 1189 assert classic is not None 1190 assert self._completion_banner_slot is not None 1191 classic.ach.achievement_completion_banner_slots.remove( 1192 self._completion_banner_slot 1193 ) 1194 self._completion_banner_slot = None 1195 1196 def show_completion_banner(self, sound: bool = True) -> None: 1197 """Create the banner/sound for an acquired achievement announcement.""" 1198 from bascenev1lib.actor.text import Text 1199 from bascenev1lib.actor.image import Image 1200 1201 app = babase.app 1202 assert app.classic is not None 1203 app.classic.ach.last_achievement_display_time = babase.apptime() 1204 1205 # Just piggy-back onto any current activity 1206 # (should we use the session instead?..) 1207 activity = bascenev1.getactivity(doraise=False) 1208 1209 # If this gets called while this achievement is occupying a slot 1210 # already, ignore it. (probably should never happen in real 1211 # life but whatevs). 1212 if self._completion_banner_slot is not None: 1213 return 1214 1215 if activity is None: 1216 print('show_completion_banner() called with no current activity!') 1217 return 1218 1219 if sound: 1220 bascenev1.getsound('achievement').play(host_only=True) 1221 else: 1222 bascenev1.timer( 1223 0.5, lambda: bascenev1.getsound('ding').play(host_only=True) 1224 ) 1225 1226 in_time = 0.300 1227 out_time = 3.5 1228 1229 base_vr_depth = 200 1230 1231 # Find the first free slot. 1232 i = 0 1233 while True: 1234 if i not in app.classic.ach.achievement_completion_banner_slots: 1235 app.classic.ach.achievement_completion_banner_slots.add(i) 1236 self._completion_banner_slot = i 1237 1238 # Remove us from that slot when we close. 1239 # Use an app-timer in an empty context so the removal 1240 # runs even if our activity/session dies. 1241 with babase.ContextRef.empty(): 1242 babase.apptimer( 1243 in_time + out_time, self._remove_banner_slot 1244 ) 1245 break 1246 i += 1 1247 assert self._completion_banner_slot is not None 1248 y_offs = 110 * self._completion_banner_slot 1249 objs: list[bascenev1.Actor] = [] 1250 obj = Image( 1251 bascenev1.gettexture('shadow'), 1252 position=(-30, 30 + y_offs), 1253 front=True, 1254 attach=Image.Attach.BOTTOM_CENTER, 1255 transition=Image.Transition.IN_BOTTOM, 1256 vr_depth=base_vr_depth - 100, 1257 transition_delay=in_time, 1258 transition_out_delay=out_time, 1259 color=(0.0, 0.1, 0, 1), 1260 scale=(1000, 300), 1261 ).autoretain() 1262 objs.append(obj) 1263 assert obj.node 1264 obj.node.host_only = True 1265 obj = Image( 1266 bascenev1.gettexture('light'), 1267 position=(-180, 60 + y_offs), 1268 front=True, 1269 attach=Image.Attach.BOTTOM_CENTER, 1270 vr_depth=base_vr_depth, 1271 transition=Image.Transition.IN_BOTTOM, 1272 transition_delay=in_time, 1273 transition_out_delay=out_time, 1274 color=(1.8, 1.8, 1.0, 0.0), 1275 scale=(40, 300), 1276 ).autoretain() 1277 objs.append(obj) 1278 assert obj.node 1279 obj.node.host_only = True 1280 obj.node.premultiplied = True 1281 combine = bascenev1.newnode( 1282 'combine', owner=obj.node, attrs={'size': 2} 1283 ) 1284 bascenev1.animate( 1285 combine, 1286 'input0', 1287 { 1288 in_time: 0, 1289 in_time + 0.4: 30, 1290 in_time + 0.5: 40, 1291 in_time + 0.6: 30, 1292 in_time + 2.0: 0, 1293 }, 1294 ) 1295 bascenev1.animate( 1296 combine, 1297 'input1', 1298 { 1299 in_time: 0, 1300 in_time + 0.4: 200, 1301 in_time + 0.5: 500, 1302 in_time + 0.6: 200, 1303 in_time + 2.0: 0, 1304 }, 1305 ) 1306 combine.connectattr('output', obj.node, 'scale') 1307 bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True) 1308 obj = Image( 1309 self.get_icon_texture(True), 1310 position=(-180, 60 + y_offs), 1311 attach=Image.Attach.BOTTOM_CENTER, 1312 front=True, 1313 vr_depth=base_vr_depth - 10, 1314 transition=Image.Transition.IN_BOTTOM, 1315 transition_delay=in_time, 1316 transition_out_delay=out_time, 1317 scale=(100, 100), 1318 ).autoretain() 1319 objs.append(obj) 1320 assert obj.node 1321 obj.node.host_only = True 1322 1323 # Flash. 1324 color = self.get_icon_color(True) 1325 combine = bascenev1.newnode( 1326 'combine', owner=obj.node, attrs={'size': 3} 1327 ) 1328 keys = { 1329 in_time: 1.0 * color[0], 1330 in_time + 0.4: 1.5 * color[0], 1331 in_time + 0.5: 6.0 * color[0], 1332 in_time + 0.6: 1.5 * color[0], 1333 in_time + 2.0: 1.0 * color[0], 1334 } 1335 bascenev1.animate(combine, 'input0', keys) 1336 keys = { 1337 in_time: 1.0 * color[1], 1338 in_time + 0.4: 1.5 * color[1], 1339 in_time + 0.5: 6.0 * color[1], 1340 in_time + 0.6: 1.5 * color[1], 1341 in_time + 2.0: 1.0 * color[1], 1342 } 1343 bascenev1.animate(combine, 'input1', keys) 1344 keys = { 1345 in_time: 1.0 * color[2], 1346 in_time + 0.4: 1.5 * color[2], 1347 in_time + 0.5: 6.0 * color[2], 1348 in_time + 0.6: 1.5 * color[2], 1349 in_time + 2.0: 1.0 * color[2], 1350 } 1351 bascenev1.animate(combine, 'input2', keys) 1352 combine.connectattr('output', obj.node, 'color') 1353 1354 obj = Image( 1355 bascenev1.gettexture('achievementOutline'), 1356 mesh_transparent=bascenev1.getmesh('achievementOutline'), 1357 position=(-180, 60 + y_offs), 1358 front=True, 1359 attach=Image.Attach.BOTTOM_CENTER, 1360 vr_depth=base_vr_depth, 1361 transition=Image.Transition.IN_BOTTOM, 1362 transition_delay=in_time, 1363 transition_out_delay=out_time, 1364 scale=(100, 100), 1365 ).autoretain() 1366 assert obj.node 1367 obj.node.host_only = True 1368 1369 # Flash. 1370 color = (2, 1.4, 0.4, 1) 1371 combine = bascenev1.newnode( 1372 'combine', owner=obj.node, attrs={'size': 3} 1373 ) 1374 keys = { 1375 in_time: 1.0 * color[0], 1376 in_time + 0.4: 1.5 * color[0], 1377 in_time + 0.5: 6.0 * color[0], 1378 in_time + 0.6: 1.5 * color[0], 1379 in_time + 2.0: 1.0 * color[0], 1380 } 1381 bascenev1.animate(combine, 'input0', keys) 1382 keys = { 1383 in_time: 1.0 * color[1], 1384 in_time + 0.4: 1.5 * color[1], 1385 in_time + 0.5: 6.0 * color[1], 1386 in_time + 0.6: 1.5 * color[1], 1387 in_time + 2.0: 1.0 * color[1], 1388 } 1389 bascenev1.animate(combine, 'input1', keys) 1390 keys = { 1391 in_time: 1.0 * color[2], 1392 in_time + 0.4: 1.5 * color[2], 1393 in_time + 0.5: 6.0 * color[2], 1394 in_time + 0.6: 1.5 * color[2], 1395 in_time + 2.0: 1.0 * color[2], 1396 } 1397 bascenev1.animate(combine, 'input2', keys) 1398 combine.connectattr('output', obj.node, 'color') 1399 objs.append(obj) 1400 1401 objt = Text( 1402 babase.Lstr( 1403 value='${A}:', 1404 subs=[('${A}', babase.Lstr(resource='achievementText'))], 1405 ), 1406 position=(-120, 91 + y_offs), 1407 front=True, 1408 v_attach=Text.VAttach.BOTTOM, 1409 vr_depth=base_vr_depth - 10, 1410 transition=Text.Transition.IN_BOTTOM, 1411 flatness=0.5, 1412 transition_delay=in_time, 1413 transition_out_delay=out_time, 1414 color=(1, 1, 1, 0.8), 1415 scale=0.65, 1416 ).autoretain() 1417 objs.append(objt) 1418 assert objt.node 1419 objt.node.host_only = True 1420 1421 objt = Text( 1422 self.display_name, 1423 position=(-120, 50 + y_offs), 1424 front=True, 1425 v_attach=Text.VAttach.BOTTOM, 1426 transition=Text.Transition.IN_BOTTOM, 1427 vr_depth=base_vr_depth, 1428 flatness=0.5, 1429 transition_delay=in_time, 1430 transition_out_delay=out_time, 1431 flash=True, 1432 color=(1, 0.8, 0, 1.0), 1433 scale=1.5, 1434 ).autoretain() 1435 objs.append(objt) 1436 assert objt.node 1437 objt.node.host_only = True 1438 1439 # objt = Text( 1440 # babase.charstr(babase.SpecialChar.TICKET), 1441 # position=(-120 - 170 + 5, 75 + y_offs - 20), 1442 # front=True, 1443 # v_attach=Text.VAttach.BOTTOM, 1444 # h_align=Text.HAlign.CENTER, 1445 # v_align=Text.VAlign.CENTER, 1446 # transition=Text.Transition.IN_BOTTOM, 1447 # vr_depth=base_vr_depth, 1448 # transition_delay=in_time, 1449 # transition_out_delay=out_time, 1450 # flash=True, 1451 # color=(0.5, 0.5, 0.5, 1), 1452 # scale=3.0, 1453 # ).autoretain() 1454 # objs.append(objt) 1455 # assert objt.node 1456 # objt.node.host_only = True 1457 1458 # print('FIXME SHOW ACH CHEST3') 1459 # objt = Text( 1460 # '+' + str(self.get_award_ticket_value()), 1461 # position=(-120 - 180 + 5, 80 + y_offs - 20), 1462 # v_attach=Text.VAttach.BOTTOM, 1463 # front=True, 1464 # h_align=Text.HAlign.CENTER, 1465 # v_align=Text.VAlign.CENTER, 1466 # transition=Text.Transition.IN_BOTTOM, 1467 # vr_depth=base_vr_depth, 1468 # flatness=0.5, 1469 # shadow=1.0, 1470 # transition_delay=in_time, 1471 # transition_out_delay=out_time, 1472 # flash=True, 1473 # color=(0, 1, 0, 1), 1474 # scale=1.5, 1475 # ).autoretain() 1476 # objs.append(objt) 1477 1478 assert objt.node 1479 objt.node.host_only = True 1480 1481 # Add the 'x 2' if we've got pro. 1482 # if app.classic.accounts.have_pro(): 1483 # objt = Text( 1484 # 'x 2', 1485 # position=(-120 - 180 + 45, 80 + y_offs - 50), 1486 # v_attach=Text.VAttach.BOTTOM, 1487 # front=True, 1488 # h_align=Text.HAlign.CENTER, 1489 # v_align=Text.VAlign.CENTER, 1490 # transition=Text.Transition.IN_BOTTOM, 1491 # vr_depth=base_vr_depth, 1492 # flatness=0.5, 1493 # shadow=1.0, 1494 # transition_delay=in_time, 1495 # transition_out_delay=out_time, 1496 # flash=True, 1497 # color=(0.4, 0, 1, 1), 1498 # scale=0.9, 1499 # ).autoretain() 1500 # objs.append(objt) 1501 # assert objt.node 1502 # objt.node.host_only = True 1503 1504 objt = Text( 1505 self.description_complete, 1506 position=(-120, 30 + y_offs), 1507 front=True, 1508 v_attach=Text.VAttach.BOTTOM, 1509 transition=Text.Transition.IN_BOTTOM, 1510 vr_depth=base_vr_depth - 10, 1511 flatness=0.5, 1512 transition_delay=in_time, 1513 transition_out_delay=out_time, 1514 color=(1.0, 0.7, 0.5, 1.0), 1515 scale=0.8, 1516 ).autoretain() 1517 objs.append(objt) 1518 assert objt.node 1519 objt.node.host_only = True 1520 1521 for actor in objs: 1522 bascenev1.timer( 1523 out_time + 1.000, 1524 babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()), 1525 )
Represents attributes and state for an individual achievement.
534 def __init__( 535 self, 536 name: str, 537 icon_name: str, 538 icon_color: tuple[float, float, float], 539 level_name: str, 540 *, 541 award: int, 542 hard_mode_only: bool = False, 543 ): 544 self._name = name 545 self._icon_name = icon_name 546 assert len(icon_color) == 3 547 self._icon_color = icon_color + (1.0,) 548 self._level_name = level_name 549 self._completion_banner_slot: int | None = None 550 self._award = award 551 self._hard_mode_only = hard_mode_only
553 @property 554 def name(self) -> str: 555 """The name of this achievement.""" 556 return self._name
The name of this achievement.
558 @property 559 def level_name(self) -> str: 560 """The name of the level this achievement applies to.""" 561 return self._level_name
The name of the level this achievement applies to.
563 def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture: 564 """Return the icon texture to display for this achievement""" 565 return bauiv1.gettexture( 566 self._icon_name if complete else 'achievementEmpty' 567 )
Return the icon texture to display for this achievement
569 def get_icon_texture(self, complete: bool) -> bascenev1.Texture: 570 """Return the icon texture to display for this achievement""" 571 return bascenev1.gettexture( 572 self._icon_name if complete else 'achievementEmpty' 573 )
Return the icon texture to display for this achievement
575 def get_icon_color(self, complete: bool) -> Sequence[float]: 576 """Return the color tint for this Achievement's icon.""" 577 if complete: 578 return self._icon_color 579 return 1.0, 1.0, 1.0, 0.6
Return the color tint for this Achievement's icon.
581 @property 582 def hard_mode_only(self) -> bool: 583 """Whether this Achievement is only unlockable in hard-mode.""" 584 return self._hard_mode_only
Whether this Achievement is only unlockable in hard-mode.
586 @property 587 def complete(self) -> bool: 588 """Whether this Achievement is currently complete.""" 589 val: bool = self._getconfig()['Complete'] 590 assert isinstance(val, bool) 591 return val
Whether this Achievement is currently complete.
593 def announce_completion(self, sound: bool = True) -> None: 594 """Kick off an announcement for this achievement's completion.""" 595 596 app = babase.app 597 plus = app.plus 598 classic = app.classic 599 if plus is None or classic is None: 600 logging.warning('ach account_completion not available.') 601 return 602 603 ach_ss = classic.ach 604 605 # Even though there are technically achievements when we're not 606 # signed in, lets not show them (otherwise we tend to get 607 # confusing 'controller connected' achievements popping up while 608 # waiting to sign in which can be confusing). 609 if plus.get_v1_account_state() != 'signed_in': 610 return 611 612 # If we're being freshly complete, display/report it and whatnot. 613 if (self, sound) not in ach_ss.achievements_to_display: 614 ach_ss.achievements_to_display.append((self, sound)) 615 616 # If there's no achievement display timer going, kick one off 617 # (if one's already running it will pick this up before it dies). 618 619 # Need to check last time too; its possible our timer wasn't able to 620 # clear itself if an activity died and took it down with it. 621 if ( 622 ach_ss.achievement_display_timer is None 623 or babase.apptime() - ach_ss.last_achievement_display_time > 2.0 624 ) and bascenev1.getactivity(doraise=False) is not None: 625 ach_ss.achievement_display_timer = bascenev1.BaseTimer( 626 1.0, _display_next_achievement, repeat=True 627 ) 628 629 # Show the first immediately. 630 _display_next_achievement()
Kick off an announcement for this achievement's completion.
632 def set_complete(self, complete: bool = True) -> None: 633 """Set an achievement's completed state. 634 635 note this only sets local state; use a transaction to 636 actually award achievements. 637 """ 638 config = self._getconfig() 639 if complete != config['Complete']: 640 config['Complete'] = complete
Set an achievement's completed state.
note this only sets local state; use a transaction to actually award achievements.
642 @property 643 def display_name(self) -> babase.Lstr: 644 """Return a babase.Lstr for this Achievement's name.""" 645 name: babase.Lstr | str 646 try: 647 if self._level_name != '': 648 campaignname, campaign_level = self._level_name.split(':') 649 classic = babase.app.classic 650 assert classic is not None 651 name = ( 652 classic.getcampaign(campaignname) 653 .getlevel(campaign_level) 654 .displayname 655 ) 656 else: 657 name = '' 658 except Exception: 659 name = '' 660 logging.exception('Error calcing achievement display-name.') 661 return babase.Lstr( 662 resource='achievements.' + self._name + '.name', 663 subs=[('${LEVEL}', name)], 664 )
Return a babase.Lstr for this Achievement's name.
666 @property 667 def description(self) -> babase.Lstr: 668 """Get a babase.Lstr for the Achievement's brief description.""" 669 if ( 670 'description' 671 in babase.app.lang.get_resource('achievements')[self._name] 672 ): 673 return babase.Lstr( 674 resource='achievements.' + self._name + '.description' 675 ) 676 return babase.Lstr( 677 resource='achievements.' + self._name + '.descriptionFull' 678 )
Get a babase.Lstr for the Achievement's brief description.
680 @property 681 def description_complete(self) -> babase.Lstr: 682 """Get a babase.Lstr for the Achievement's description when complete.""" 683 if ( 684 'descriptionComplete' 685 in babase.app.lang.get_resource('achievements')[self._name] 686 ): 687 return babase.Lstr( 688 resource='achievements.' + self._name + '.descriptionComplete' 689 ) 690 return babase.Lstr( 691 resource='achievements.' + self._name + '.descriptionFullComplete' 692 )
Get a babase.Lstr for the Achievement's description when complete.
694 @property 695 def description_full(self) -> babase.Lstr: 696 """Get a babase.Lstr for the Achievement's full description.""" 697 return babase.Lstr( 698 resource='achievements.' + self._name + '.descriptionFull', 699 subs=[ 700 ( 701 '${LEVEL}', 702 babase.Lstr( 703 translate=( 704 'coopLevelNames', 705 ACH_LEVEL_NAMES.get(self._name, '?'), 706 ) 707 ), 708 ) 709 ], 710 )
Get a babase.Lstr for the Achievement's full description.
712 @property 713 def description_full_complete(self) -> babase.Lstr: 714 """Get a babase.Lstr for the Achievement's full desc. when completed.""" 715 return babase.Lstr( 716 resource='achievements.' + self._name + '.descriptionFullComplete', 717 subs=[ 718 ( 719 '${LEVEL}', 720 babase.Lstr( 721 translate=( 722 'coopLevelNames', 723 ACH_LEVEL_NAMES.get(self._name, '?'), 724 ) 725 ), 726 ) 727 ], 728 )
Get a babase.Lstr for the Achievement's full desc. when completed.
730 def get_award_chest_type(self) -> ClassicChestAppearance: 731 """Return the type of chest given for this achievement.""" 732 733 # For now just map our old ticket values to chest types. 734 # Can add distinct values if need be later. 735 plus = babase.app.plus 736 assert plus is not None 737 t = plus.get_v1_account_misc_read_val( 738 f'achAward.{self.name}', self._award 739 ) 740 return ( 741 ClassicChestAppearance.L6 742 if t >= 30 743 else ( 744 ClassicChestAppearance.L5 745 if t >= 25 746 else ( 747 ClassicChestAppearance.L4 748 if t >= 20 749 else ( 750 ClassicChestAppearance.L3 751 if t >= 15 752 else ( 753 ClassicChestAppearance.L2 754 if t >= 10 755 else ClassicChestAppearance.L1 756 ) 757 ) 758 ) 759 ) 760 )
Return the type of chest given for this achievement.
773 @property 774 def power_ranking_value(self) -> int: 775 """Get the power-ranking award value for this achievement.""" 776 plus = babase.app.plus 777 if plus is None: 778 return 0 779 val: int = plus.get_v1_account_misc_read_val( 780 'achLeaguePoints.' + self._name, self._award 781 ) 782 assert isinstance(val, int) 783 return val
Get the power-ranking award value for this achievement.
785 def create_display( 786 self, 787 x: float, 788 y: float, 789 delay: float, 790 *, 791 outdelay: float | None = None, 792 color: Sequence[float] | None = None, 793 style: str = 'post_game', 794 ) -> list[bascenev1.Actor]: 795 """Create a display for the Achievement. 796 797 Shows the Achievement icon, name, and description. 798 """ 799 # pylint: disable=cyclic-import 800 from bascenev1 import CoopSession 801 from bascenev1lib.actor.image import Image 802 from bascenev1lib.actor.text import Text 803 804 # Yeah this needs cleaning up. 805 if style == 'post_game': 806 in_game_colors = False 807 in_main_menu = False 808 h_attach = Text.HAttach.CENTER 809 v_attach = Text.VAttach.CENTER 810 attach = Image.Attach.CENTER 811 elif style == 'in_game': 812 in_game_colors = True 813 in_main_menu = False 814 h_attach = Text.HAttach.LEFT 815 v_attach = Text.VAttach.TOP 816 attach = Image.Attach.TOP_LEFT 817 elif style == 'news': 818 in_game_colors = True 819 in_main_menu = True 820 h_attach = Text.HAttach.CENTER 821 v_attach = Text.VAttach.TOP 822 attach = Image.Attach.TOP_CENTER 823 else: 824 raise ValueError('invalid style "' + style + '"') 825 826 # Attempt to determine what campaign we're in 827 # (so we know whether to show "hard mode only"). 828 if in_main_menu: 829 hmo = False 830 else: 831 try: 832 session = bascenev1.getsession() 833 if isinstance(session, CoopSession): 834 campaign = session.campaign 835 assert campaign is not None 836 hmo = self._hard_mode_only and campaign.name == 'Easy' 837 else: 838 hmo = False 839 except Exception: 840 logging.exception('Error determining campaign.') 841 hmo = False 842 843 objs: list[bascenev1.Actor] 844 845 if in_game_colors: 846 objs = [] 847 out_delay_fin = (delay + outdelay) if outdelay is not None else None 848 if color is not None: 849 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 850 cl2 = color 851 else: 852 cl1 = (1.5, 1.5, 2, 1.0) 853 cl2 = (0.8, 0.8, 1.0, 1.0) 854 855 if hmo: 856 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 857 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 858 859 objs.append( 860 Image( 861 self.get_icon_texture(False), 862 host_only=True, 863 color=cl1, 864 position=(x - 25, y + 5), 865 attach=attach, 866 transition=Image.Transition.FADE_IN, 867 transition_delay=delay, 868 vr_depth=4, 869 transition_out_delay=out_delay_fin, 870 scale=(40, 40), 871 ).autoretain() 872 ) 873 txt = self.display_name 874 txt_s = 0.85 875 txt_max_w = 300 876 objs.append( 877 Text( 878 txt, 879 host_only=True, 880 maxwidth=txt_max_w, 881 position=(x, y + 2), 882 transition=Text.Transition.FADE_IN, 883 scale=txt_s, 884 flatness=0.6, 885 shadow=0.5, 886 h_attach=h_attach, 887 v_attach=v_attach, 888 color=cl2, 889 transition_delay=delay + 0.05, 890 transition_out_delay=out_delay_fin, 891 ).autoretain() 892 ) 893 txt2_s = 0.62 894 txt2_max_w = 400 895 objs.append( 896 Text( 897 self.description_full if in_main_menu else self.description, 898 host_only=True, 899 maxwidth=txt2_max_w, 900 position=(x, y - 14), 901 transition=Text.Transition.FADE_IN, 902 vr_depth=-5, 903 h_attach=h_attach, 904 v_attach=v_attach, 905 scale=txt2_s, 906 flatness=1.0, 907 shadow=0.5, 908 color=cl2, 909 transition_delay=delay + 0.1, 910 transition_out_delay=out_delay_fin, 911 ).autoretain() 912 ) 913 914 if hmo: 915 txtactor = Text( 916 babase.Lstr(resource='difficultyHardOnlyText'), 917 host_only=True, 918 maxwidth=txt2_max_w * 0.7, 919 position=(x + 60, y + 5), 920 transition=Text.Transition.FADE_IN, 921 vr_depth=-5, 922 h_attach=h_attach, 923 v_attach=v_attach, 924 h_align=Text.HAlign.CENTER, 925 v_align=Text.VAlign.CENTER, 926 scale=txt_s * 0.8, 927 flatness=1.0, 928 shadow=0.5, 929 color=(1, 1, 0.6, 1), 930 transition_delay=delay + 0.1, 931 transition_out_delay=out_delay_fin, 932 ).autoretain() 933 txtactor.node.rotate = 10 934 objs.append(txtactor) 935 936 # Chest award. 937 award_x = -100 938 chesttype = self.get_award_chest_type() 939 chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( 940 chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT 941 ) 942 objs.append( 943 Image( 944 # Provide magical extended dict version of texture 945 # that Image actor supports. 946 texture={ 947 'texture': bascenev1.gettexture( 948 chestdisplayinfo.texclosed 949 ), 950 'tint_texture': bascenev1.gettexture( 951 chestdisplayinfo.texclosedtint 952 ), 953 'tint_color': chestdisplayinfo.tint, 954 'tint2_color': chestdisplayinfo.tint2, 955 'mask_texture': None, 956 }, 957 color=chestdisplayinfo.color + (0.5 if hmo else 1.0,), 958 position=(x + award_x + 37, y + 12), 959 scale=(32.0, 32.0), 960 transition=Image.Transition.FADE_IN, 961 transition_delay=delay + 0.05, 962 transition_out_delay=out_delay_fin, 963 host_only=True, 964 attach=Image.Attach.TOP_LEFT, 965 ).autoretain() 966 ) 967 968 # objs.append( 969 # Text( 970 # babase.charstr(babase.SpecialChar.TICKET), 971 # host_only=True, 972 # position=(x + award_x + 33, y + 7), 973 # transition=Text.Transition.FADE_IN, 974 # scale=1.5, 975 # h_attach=h_attach, 976 # v_attach=v_attach, 977 # h_align=Text.HAlign.CENTER, 978 # v_align=Text.VAlign.CENTER, 979 # color=(1, 1, 1, 0.2 if hmo else 0.4), 980 # transition_delay=delay + 0.05, 981 # transition_out_delay=out_delay_fin, 982 # ).autoretain() 983 # ) 984 # objs.append( 985 # Text( 986 # '+' + str(self.get_award_ticket_value()), 987 # host_only=True, 988 # position=(x + award_x + 28, y + 16), 989 # transition=Text.Transition.FADE_IN, 990 # scale=0.7, 991 # flatness=1, 992 # h_attach=h_attach, 993 # v_attach=v_attach, 994 # h_align=Text.HAlign.CENTER, 995 # v_align=Text.VAlign.CENTER, 996 # color=cl2, 997 # transition_delay=delay + 0.05, 998 # transition_out_delay=out_delay_fin, 999 # ).autoretain() 1000 # ) 1001 1002 else: 1003 complete = self.complete 1004 objs = [] 1005 c_icon = self.get_icon_color(complete) 1006 if hmo and not complete: 1007 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1008 objs.append( 1009 Image( 1010 self.get_icon_texture(complete), 1011 host_only=True, 1012 color=c_icon, 1013 position=(x - 25, y + 5), 1014 attach=attach, 1015 vr_depth=4, 1016 transition=Image.Transition.IN_RIGHT, 1017 transition_delay=delay, 1018 transition_out_delay=None, 1019 scale=(40, 40), 1020 ).autoretain() 1021 ) 1022 if complete: 1023 objs.append( 1024 Image( 1025 bascenev1.gettexture('achievementOutline'), 1026 host_only=True, 1027 mesh_transparent=bascenev1.getmesh( 1028 'achievementOutline' 1029 ), 1030 color=(2, 1.4, 0.4, 1), 1031 vr_depth=8, 1032 position=(x - 25, y + 5), 1033 attach=attach, 1034 transition=Image.Transition.IN_RIGHT, 1035 transition_delay=delay, 1036 transition_out_delay=None, 1037 scale=(40, 40), 1038 ).autoretain() 1039 ) 1040 else: 1041 if not complete: 1042 award_x = -100 1043 # objs.append( 1044 # Text( 1045 # babase.charstr(babase.SpecialChar.TICKET), 1046 # host_only=True, 1047 # position=(x + award_x + 33, y + 7), 1048 # transition=Text.Transition.IN_RIGHT, 1049 # scale=1.5, 1050 # h_attach=h_attach, 1051 # v_attach=v_attach, 1052 # h_align=Text.HAlign.CENTER, 1053 # v_align=Text.VAlign.CENTER, 1054 # color=(1, 1, 1, (0.1 if hmo else 0.2)), 1055 # transition_delay=delay + 0.05, 1056 # transition_out_delay=None, 1057 # ).autoretain() 1058 # ) 1059 chesttype = self.get_award_chest_type() 1060 chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( 1061 chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT 1062 ) 1063 objs.append( 1064 Image( 1065 # Provide magical extended dict version of texture 1066 # that Image actor supports. 1067 texture={ 1068 'texture': bascenev1.gettexture( 1069 chestdisplayinfo.texclosed 1070 ), 1071 'tint_texture': bascenev1.gettexture( 1072 chestdisplayinfo.texclosedtint 1073 ), 1074 'tint_color': chestdisplayinfo.tint, 1075 'tint2_color': chestdisplayinfo.tint2, 1076 'mask_texture': None, 1077 }, 1078 color=chestdisplayinfo.color 1079 + (0.5 if hmo else 1.0,), 1080 position=(x + award_x + 38, y + 14), 1081 scale=(32.0, 32.0), 1082 transition=Image.Transition.IN_RIGHT, 1083 transition_delay=delay + 0.05, 1084 transition_out_delay=None, 1085 host_only=True, 1086 attach=attach, 1087 ).autoretain() 1088 ) 1089 1090 # objs.append( 1091 # Text( 1092 # '+' + str(self.get_award_ticket_value()), 1093 # host_only=True, 1094 # position=(x + award_x + 28, y + 16), 1095 # transition=Text.Transition.IN_RIGHT, 1096 # scale=0.7, 1097 # flatness=1, 1098 # h_attach=h_attach, 1099 # v_attach=v_attach, 1100 # h_align=Text.HAlign.CENTER, 1101 # v_align=Text.VAlign.CENTER, 1102 # color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), 1103 # transition_delay=delay + 0.05, 1104 # transition_out_delay=None, 1105 # ).autoretain() 1106 # ) 1107 1108 # Show 'hard-mode-only' only over incomplete achievements 1109 # when that's the case. 1110 if hmo: 1111 txtactor = Text( 1112 babase.Lstr(resource='difficultyHardOnlyText'), 1113 host_only=True, 1114 maxwidth=300 * 0.7, 1115 position=(x + 60, y + 5), 1116 transition=Text.Transition.FADE_IN, 1117 vr_depth=-5, 1118 h_attach=h_attach, 1119 v_attach=v_attach, 1120 h_align=Text.HAlign.CENTER, 1121 v_align=Text.VAlign.CENTER, 1122 scale=0.85 * 0.8, 1123 flatness=1.0, 1124 shadow=0.5, 1125 color=(1, 1, 0.6, 1), 1126 transition_delay=delay + 0.05, 1127 transition_out_delay=None, 1128 ).autoretain() 1129 assert txtactor.node 1130 txtactor.node.rotate = 10 1131 objs.append(txtactor) 1132 1133 objs.append( 1134 Text( 1135 self.display_name, 1136 host_only=True, 1137 maxwidth=300, 1138 position=(x, y + 2), 1139 transition=Text.Transition.IN_RIGHT, 1140 scale=0.85, 1141 flatness=0.6, 1142 h_attach=h_attach, 1143 v_attach=v_attach, 1144 color=( 1145 (0.8, 0.93, 0.8, 1.0) 1146 if complete 1147 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1148 ), 1149 transition_delay=delay + 0.05, 1150 transition_out_delay=None, 1151 ).autoretain() 1152 ) 1153 objs.append( 1154 Text( 1155 self.description_complete if complete else self.description, 1156 host_only=True, 1157 maxwidth=400, 1158 position=(x, y - 14), 1159 transition=Text.Transition.IN_RIGHT, 1160 vr_depth=-5, 1161 h_attach=h_attach, 1162 v_attach=v_attach, 1163 scale=0.62, 1164 flatness=1.0, 1165 color=( 1166 (0.6, 0.6, 0.6, 1.0) 1167 if complete 1168 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1169 ), 1170 transition_delay=delay + 0.1, 1171 transition_out_delay=None, 1172 ).autoretain() 1173 ) 1174 return objs
Create a display for the Achievement.
Shows the Achievement icon, name, and description.
74class AchievementSubsystem: 75 """Subsystem for achievement handling. 76 77 Access the single shared instance of this class at 'ba.app.ach'. 78 """ 79 80 def __init__(self) -> None: 81 self.achievements: list[Achievement] = [] 82 self.achievements_to_display: list[ 83 tuple[baclassic.Achievement, bool] 84 ] = [] 85 self.achievement_display_timer: bascenev1.BaseTimer | None = None 86 self.last_achievement_display_time: float = 0.0 87 self.achievement_completion_banner_slots: set[int] = set() 88 self._init_achievements() 89 90 def _init_achievements(self) -> None: 91 """Fill in available achievements.""" 92 93 self.achievements += [ 94 Achievement( 95 'In Control', 96 'achievementInControl', 97 (1, 1, 1), 98 '', 99 award=5, 100 ), 101 Achievement( 102 'Sharing is Caring', 103 'achievementSharingIsCaring', 104 (1, 1, 1), 105 '', 106 award=15, 107 ), 108 Achievement( 109 'Dual Wielding', 110 'achievementDualWielding', 111 (1, 1, 1), 112 '', 113 award=10, 114 ), 115 Achievement( 116 'Free Loader', 117 'achievementFreeLoader', 118 (1, 1, 1), 119 '', 120 award=10, 121 ), 122 Achievement( 123 'Team Player', 124 'achievementTeamPlayer', 125 (1, 1, 1), 126 '', 127 award=20, 128 ), 129 Achievement( 130 'Onslaught Training Victory', 131 'achievementOnslaught', 132 (1, 1, 1), 133 'Default:Onslaught Training', 134 award=5, 135 ), 136 Achievement( 137 'Off You Go Then', 138 'achievementOffYouGo', 139 (1, 1.1, 1.3), 140 'Default:Onslaught Training', 141 award=5, 142 ), 143 Achievement( 144 'Boxer', 145 'achievementBoxer', 146 (1, 0.6, 0.6), 147 'Default:Onslaught Training', 148 award=10, 149 hard_mode_only=True, 150 ), 151 Achievement( 152 'Rookie Onslaught Victory', 153 'achievementOnslaught', 154 (0.5, 1.4, 0.6), 155 'Default:Rookie Onslaught', 156 award=10, 157 ), 158 Achievement( 159 'Mine Games', 160 'achievementMine', 161 (1, 1, 1.4), 162 'Default:Rookie Onslaught', 163 award=10, 164 ), 165 Achievement( 166 'Flawless Victory', 167 'achievementFlawlessVictory', 168 (1, 1, 1), 169 'Default:Rookie Onslaught', 170 award=15, 171 hard_mode_only=True, 172 ), 173 Achievement( 174 'Rookie Football Victory', 175 'achievementFootballVictory', 176 (1.0, 1, 0.6), 177 'Default:Rookie Football', 178 award=10, 179 ), 180 Achievement( 181 'Super Punch', 182 'achievementSuperPunch', 183 (1, 1, 1.8), 184 'Default:Rookie Football', 185 award=10, 186 ), 187 Achievement( 188 'Rookie Football Shutout', 189 'achievementFootballShutout', 190 (1, 1, 1), 191 'Default:Rookie Football', 192 award=15, 193 hard_mode_only=True, 194 ), 195 Achievement( 196 'Pro Onslaught Victory', 197 'achievementOnslaught', 198 (0.3, 1, 2.0), 199 'Default:Pro Onslaught', 200 award=15, 201 ), 202 Achievement( 203 'Boom Goes the Dynamite', 204 'achievementTNT', 205 (1.4, 1.2, 0.8), 206 'Default:Pro Onslaught', 207 award=15, 208 ), 209 Achievement( 210 'Pro Boxer', 211 'achievementBoxer', 212 (2, 2, 0), 213 'Default:Pro Onslaught', 214 award=20, 215 hard_mode_only=True, 216 ), 217 Achievement( 218 'Pro Football Victory', 219 'achievementFootballVictory', 220 (1.3, 1.3, 2.0), 221 'Default:Pro Football', 222 award=15, 223 ), 224 Achievement( 225 'Super Mega Punch', 226 'achievementSuperPunch', 227 (2, 1, 0.6), 228 'Default:Pro Football', 229 award=15, 230 ), 231 Achievement( 232 'Pro Football Shutout', 233 'achievementFootballShutout', 234 (0.7, 0.7, 2.0), 235 'Default:Pro Football', 236 award=20, 237 hard_mode_only=True, 238 ), 239 Achievement( 240 'Pro Runaround Victory', 241 'achievementRunaround', 242 (1, 1, 1), 243 'Default:Pro Runaround', 244 award=15, 245 ), 246 Achievement( 247 'Precision Bombing', 248 'achievementCrossHair', 249 (1, 1, 1.3), 250 'Default:Pro Runaround', 251 award=20, 252 hard_mode_only=True, 253 ), 254 Achievement( 255 'The Wall', 256 'achievementWall', 257 (1, 0.7, 0.7), 258 'Default:Pro Runaround', 259 award=25, 260 hard_mode_only=True, 261 ), 262 Achievement( 263 'Uber Onslaught Victory', 264 'achievementOnslaught', 265 (2, 2, 1), 266 'Default:Uber Onslaught', 267 award=30, 268 ), 269 Achievement( 270 'Gold Miner', 271 'achievementMine', 272 (2, 1.6, 0.2), 273 'Default:Uber Onslaught', 274 award=30, 275 hard_mode_only=True, 276 ), 277 Achievement( 278 'TNT Terror', 279 'achievementTNT', 280 (2, 1.8, 0.3), 281 'Default:Uber Onslaught', 282 award=30, 283 hard_mode_only=True, 284 ), 285 Achievement( 286 'Uber Football Victory', 287 'achievementFootballVictory', 288 (1.8, 1.4, 0.3), 289 'Default:Uber Football', 290 award=30, 291 ), 292 Achievement( 293 'Got the Moves', 294 'achievementGotTheMoves', 295 (2, 1, 0), 296 'Default:Uber Football', 297 award=30, 298 hard_mode_only=True, 299 ), 300 Achievement( 301 'Uber Football Shutout', 302 'achievementFootballShutout', 303 (2, 2, 0), 304 'Default:Uber Football', 305 award=40, 306 hard_mode_only=True, 307 ), 308 Achievement( 309 'Uber Runaround Victory', 310 'achievementRunaround', 311 (1.5, 1.2, 0.2), 312 'Default:Uber Runaround', 313 award=30, 314 ), 315 Achievement( 316 'The Great Wall', 317 'achievementWall', 318 (2, 1.7, 0.4), 319 'Default:Uber Runaround', 320 award=40, 321 hard_mode_only=True, 322 ), 323 Achievement( 324 'Stayin\' Alive', 325 'achievementStayinAlive', 326 (2, 2, 1), 327 'Default:Uber Runaround', 328 award=40, 329 hard_mode_only=True, 330 ), 331 Achievement( 332 'Last Stand Master', 333 'achievementMedalSmall', 334 (2, 1.5, 0.3), 335 'Default:The Last Stand', 336 award=20, 337 hard_mode_only=True, 338 ), 339 Achievement( 340 'Last Stand Wizard', 341 'achievementMedalMedium', 342 (2, 1.5, 0.3), 343 'Default:The Last Stand', 344 award=40, 345 hard_mode_only=True, 346 ), 347 Achievement( 348 'Last Stand God', 349 'achievementMedalLarge', 350 (2, 1.5, 0.3), 351 'Default:The Last Stand', 352 award=60, 353 hard_mode_only=True, 354 ), 355 Achievement( 356 'Onslaught Master', 357 'achievementMedalSmall', 358 (0.7, 1, 0.7), 359 'Challenges:Infinite Onslaught', 360 award=5, 361 ), 362 Achievement( 363 'Onslaught Wizard', 364 'achievementMedalMedium', 365 (0.7, 1.0, 0.7), 366 'Challenges:Infinite Onslaught', 367 award=15, 368 ), 369 Achievement( 370 'Onslaught God', 371 'achievementMedalLarge', 372 (0.7, 1.0, 0.7), 373 'Challenges:Infinite Onslaught', 374 award=30, 375 ), 376 Achievement( 377 'Runaround Master', 378 'achievementMedalSmall', 379 (1.0, 1.0, 1.2), 380 'Challenges:Infinite Runaround', 381 award=5, 382 ), 383 Achievement( 384 'Runaround Wizard', 385 'achievementMedalMedium', 386 (1.0, 1.0, 1.2), 387 'Challenges:Infinite Runaround', 388 award=15, 389 ), 390 Achievement( 391 'Runaround God', 392 'achievementMedalLarge', 393 (1.0, 1.0, 1.2), 394 'Challenges:Infinite Runaround', 395 award=30, 396 ), 397 ] 398 399 def award_local_achievement(self, achname: str) -> None: 400 """For non-game-based achievements such as controller-connection.""" 401 plus = babase.app.plus 402 if plus is None: 403 logging.warning('achievements require plus feature-set') 404 return 405 try: 406 ach = self.get_achievement(achname) 407 if not ach.complete: 408 # Report new achievements to the game-service. 409 plus.report_achievement(achname) 410 411 # And to our account. 412 plus.add_v1_account_transaction( 413 {'type': 'ACHIEVEMENT', 'name': achname} 414 ) 415 416 # Now attempt to show a banner. 417 self.display_achievement_banner(achname) 418 419 except Exception: 420 logging.exception('Error in award_local_achievement.') 421 422 def display_achievement_banner(self, achname: str) -> None: 423 """Display a completion banner for an achievement. 424 425 (internal) 426 427 Used for server-driven achievements. 428 """ 429 try: 430 # FIXME: Need to get these using the UI context or some other 431 # purely local context somehow instead of trying to inject these 432 # into whatever activity happens to be active 433 # (since that won't work while in client mode). 434 activity = bascenev1.get_foreground_host_activity() 435 if activity is not None: 436 with activity.context: 437 self.get_achievement(achname).announce_completion() 438 except Exception: 439 logging.exception('Error in display_achievement_banner.') 440 441 def set_completed_achievements(self, achs: Sequence[str]) -> None: 442 """Set the current state of completed achievements. 443 444 (internal) 445 446 All achievements not included here will be set incomplete. 447 """ 448 449 # Note: This gets called whenever game-center/game-circle/etc tells 450 # us which achievements we currently have. We always defer to them, 451 # even if that means we have to un-set an achievement we think we have. 452 453 cfg = babase.app.config 454 cfg['Achievements'] = {} 455 for a_name in achs: 456 self.get_achievement(a_name).set_complete(True) 457 cfg.commit() 458 459 def get_achievement(self, name: str) -> Achievement: 460 """Return an Achievement by name.""" 461 achs = [a for a in self.achievements if a.name == name] 462 assert len(achs) < 2 463 if not achs: 464 raise ValueError("Invalid achievement name: '" + name + "'") 465 return achs[0] 466 467 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 468 """Given a level name, return achievements available for it.""" 469 470 # For the Easy campaign we return achievements for the Default 471 # campaign too. (want the user to see what achievements are part of the 472 # level even if they can't unlock them all on easy mode). 473 return [ 474 a 475 for a in self.achievements 476 if a.level_name 477 in (level_name, level_name.replace('Easy', 'Default')) 478 ] 479 480 def _test(self) -> None: 481 """For testing achievement animations.""" 482 483 def testcall1() -> None: 484 self.achievements[0].announce_completion() 485 self.achievements[1].announce_completion() 486 self.achievements[2].announce_completion() 487 488 def testcall2() -> None: 489 self.achievements[3].announce_completion() 490 self.achievements[4].announce_completion() 491 self.achievements[5].announce_completion() 492 493 bascenev1.basetimer(3.0, testcall1) 494 bascenev1.basetimer(7.0, testcall2)
Subsystem for achievement handling.
Access the single shared instance of this class at 'ba.app.ach'.
399 def award_local_achievement(self, achname: str) -> None: 400 """For non-game-based achievements such as controller-connection.""" 401 plus = babase.app.plus 402 if plus is None: 403 logging.warning('achievements require plus feature-set') 404 return 405 try: 406 ach = self.get_achievement(achname) 407 if not ach.complete: 408 # Report new achievements to the game-service. 409 plus.report_achievement(achname) 410 411 # And to our account. 412 plus.add_v1_account_transaction( 413 {'type': 'ACHIEVEMENT', 'name': achname} 414 ) 415 416 # Now attempt to show a banner. 417 self.display_achievement_banner(achname) 418 419 except Exception: 420 logging.exception('Error in award_local_achievement.')
For non-game-based achievements such as controller-connection.
459 def get_achievement(self, name: str) -> Achievement: 460 """Return an Achievement by name.""" 461 achs = [a for a in self.achievements if a.name == name] 462 assert len(achs) < 2 463 if not achs: 464 raise ValueError("Invalid achievement name: '" + name + "'") 465 return achs[0]
Return an Achievement by name.
467 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 468 """Given a level name, return achievements available for it.""" 469 470 # For the Easy campaign we return achievements for the Default 471 # campaign too. (want the user to see what achievements are part of the 472 # level even if they can't unlock them all on easy mode). 473 return [ 474 a 475 for a in self.achievements 476 if a.level_name 477 in (level_name, level_name.replace('Easy', 'Default')) 478 ]
Given a level name, return achievements available for it.
18def show_display_item( 19 itemwrapper: bacommon.bs.DisplayItemWrapper, 20 parent: bauiv1.Widget, 21 pos: tuple[float, float], 22 width: float, 23) -> None: 24 """Create ui to depict a display-item.""" 25 26 height = width * 0.666 27 28 # Silent no-op if our parent ui is dead. 29 if not parent: 30 return 31 32 img: str | None = None 33 img_y_offs = 0.0 34 text_y_offs = 0.0 35 show_text = True 36 37 if isinstance(itemwrapper.item, bacommon.bs.TicketsDisplayItem): 38 img = 'tickets' 39 img_y_offs = width * 0.11 40 text_y_offs = width * -0.15 41 elif isinstance(itemwrapper.item, bacommon.bs.TokensDisplayItem): 42 img = 'coin' 43 img_y_offs = width * 0.11 44 text_y_offs = width * -0.15 45 elif isinstance(itemwrapper.item, bacommon.bs.ChestDisplayItem): 46 from baclassic._chest import ( 47 CHEST_APPEARANCE_DISPLAY_INFOS, 48 CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, 49 ) 50 51 img = None 52 show_text = False 53 c_info = CHEST_APPEARANCE_DISPLAY_INFOS.get( 54 itemwrapper.item.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT 55 ) 56 c_size = width * 0.85 57 bauiv1.imagewidget( 58 parent=parent, 59 position=(pos[0] - c_size * 0.5, pos[1] - c_size * 0.5), 60 color=c_info.color, 61 size=(c_size, c_size), 62 texture=bauiv1.gettexture(c_info.texclosed), 63 tint_texture=bauiv1.gettexture(c_info.texclosedtint), 64 tint_color=c_info.tint, 65 tint2_color=c_info.tint2, 66 ) 67 68 # Enable this for testing spacing. 69 if bool(False): 70 bauiv1.imagewidget( 71 parent=parent, 72 position=( 73 pos[0] - width * 0.5, 74 pos[1] - height * 0.5, 75 ), 76 size=(width, height), 77 texture=bauiv1.gettexture('white'), 78 color=(0, 1, 0), 79 opacity=0.1, 80 ) 81 82 imgsize = width * 0.33 83 if img is not None: 84 bauiv1.imagewidget( 85 parent=parent, 86 position=( 87 pos[0] - imgsize * 0.5, 88 pos[1] + img_y_offs - imgsize * 0.5, 89 ), 90 size=(imgsize, imgsize), 91 texture=bauiv1.gettexture(img), 92 ) 93 if show_text: 94 subs = itemwrapper.description_subs 95 if subs is None: 96 subs = [] 97 bauiv1.textwidget( 98 parent=parent, 99 position=(pos[0], pos[1] + text_y_offs), 100 scale=width * 0.006, 101 size=(0, 0), 102 text=bauiv1.Lstr( 103 translate=('displayItemNames', itemwrapper.description), 104 subs=pairs_from_flat(subs), 105 ), 106 maxwidth=width * 0.9, 107 color=(0.0, 1.0, 0.0), 108 h_align='center', 109 v_align='center', 110 )
Create ui to depict a display-item.