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