baclassic
Components for the classic BombSquad experience.
This package is used as a dumping ground for functionality that is necessary to keep classic BombSquad working, but which may no longer be the best way to do things going forward.
New code should try to avoid using code from here when possible.
Functionality in this package should be exposed through the ClassicAppSubsystem. This allows type-checked code to go through the babase.app.classic singleton which forces it to explicitly handle the possibility of babase.app.classic being None. When code instead imports classic submodules directly, it is much harder to make it cleanly handle classic not being present.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Components for the classic BombSquad experience. 4 5This package is used as a dumping ground for functionality that is 6necessary to keep classic BombSquad working, but which may no longer be 7the best way to do things going forward. 8 9New code should try to avoid using code from here when possible. 10 11Functionality in this package should be exposed through the 12ClassicAppSubsystem. This allows type-checked code to go through the 13babase.app.classic singleton which forces it to explicitly handle the 14possibility of babase.app.classic being None. When code instead imports 15classic submodules directly, it is much harder to make it cleanly handle 16classic not being present. 17""" 18 19# ba_meta require api 9 20 21# Note: Code relying on classic should import things from here *only* 22# for type-checking and use the versions in ba*.app.classic at runtime; 23# that way type-checking will cleanly cover the classic-not-present case 24# (ba*.app.classic being None). 25import logging 26 27from efro.util import set_canonical_module_names 28 29from baclassic._appmode import ClassicAppMode 30from baclassic._appsubsystem import ClassicAppSubsystem 31from baclassic._achievement import Achievement, AchievementSubsystem 32 33__all__ = [ 34 'ClassicAppMode', 35 'ClassicAppSubsystem', 36 'Achievement', 37 'AchievementSubsystem', 38] 39 40# We want stuff here to show up as packagename.Foo instead of 41# packagename._submodule.Foo. 42set_canonical_module_names(globals()) 43 44# Sanity check: we want to keep ballistica's dependencies and 45# bootstrapping order clearly defined; let's check a few particular 46# modules to make sure they never directly or indirectly import us 47# before their own execs complete. 48if __debug__: 49 for _mdl in 'babase', '_babase': 50 if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): 51 logging.warning( 52 '%s was imported before %s finished importing;' 53 ' should not happen.', 54 __name__, 55 _mdl, 56 )
33class ClassicAppMode(AppMode): 34 """AppMode for the classic BombSquad experience.""" 35 36 def __init__(self) -> None: 37 self._on_primary_account_changed_callback: ( 38 CallbackRegistration | None 39 ) = None 40 self._test_sub: CloudSubscription | None = None 41 42 @override 43 @classmethod 44 def get_app_experience(cls) -> AppExperience: 45 return AppExperience.MELEE 46 47 @override 48 @classmethod 49 def _supports_intent(cls, intent: AppIntent) -> bool: 50 # We support default and exec intents currently. 51 return isinstance(intent, AppIntentExec | AppIntentDefault) 52 53 @override 54 def handle_intent(self, intent: AppIntent) -> None: 55 if isinstance(intent, AppIntentExec): 56 _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) 57 return 58 assert isinstance(intent, AppIntentDefault) 59 _baclassic.classic_app_mode_handle_app_intent_default() 60 61 @override 62 def on_activate(self) -> None: 63 64 # Let the native layer do its thing. 65 _baclassic.classic_app_mode_activate() 66 67 assert app.plus is not None 68 69 # Wire up the root ui to do what we want. 70 ui = app.ui_v1 71 ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = ( 72 self._root_ui_account_press 73 ) 74 ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = ( 75 self._root_ui_menu_press 76 ) 77 ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = ( 78 self._root_ui_squad_press 79 ) 80 ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = ( 81 self._root_ui_settings_press 82 ) 83 ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = ( 84 self._root_ui_store_press 85 ) 86 ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = ( 87 self._root_ui_inventory_press 88 ) 89 ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = ( 90 self._root_ui_get_tokens_press 91 ) 92 ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = ( 93 self._root_ui_inbox_press 94 ) 95 ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = ( 96 self._root_ui_tickets_meter_press 97 ) 98 ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = ( 99 self._root_ui_tokens_meter_press 100 ) 101 ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = ( 102 self._root_ui_trophy_meter_press 103 ) 104 ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = ( 105 self._root_ui_level_meter_press 106 ) 107 ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = ( 108 self._root_ui_achievements_press 109 ) 110 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial( 111 self._root_ui_chest_slot_pressed, 1 112 ) 113 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial( 114 self._root_ui_chest_slot_pressed, 2 115 ) 116 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial( 117 self._root_ui_chest_slot_pressed, 3 118 ) 119 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial( 120 self._root_ui_chest_slot_pressed, 4 121 ) 122 123 # We want to be informed when primary account changes. 124 self._on_primary_account_changed_callback = ( 125 app.plus.accounts.on_primary_account_changed_callbacks.add( 126 self.update_for_primary_account 127 ) 128 ) 129 # Establish subscriptions/etc. for any current primary account. 130 self.update_for_primary_account(app.plus.accounts.primary) 131 132 @override 133 def on_deactivate(self) -> None: 134 135 # Stop being informed of account changes. 136 self._on_primary_account_changed_callback = None 137 138 # Remove any listeners for any current primary account. 139 self.update_for_primary_account(None) 140 141 # Save where we were in the UI so we return there next time. 142 if app.classic is not None: 143 app.classic.save_ui_state() 144 145 # Let the native layer do its thing. 146 _baclassic.classic_app_mode_deactivate() 147 148 @override 149 def on_app_active_changed(self) -> None: 150 # If we've gone inactive, bring up the main menu, which has the 151 # side effect of pausing the action (when possible). 152 if not app.active: 153 invoke_main_menu() 154 155 def update_for_primary_account( 156 self, account: AccountV2Handle | None 157 ) -> None: 158 """Update subscriptions/etc. for a new primary account state.""" 159 assert in_logic_thread() 160 161 # Test subscription. 162 if bool(False): 163 assert app.plus is not None 164 if account is None: 165 self._test_sub = None 166 else: 167 with account: 168 self._test_sub = app.plus.cloud.subscribe_test( 169 self._on_sub_test_update 170 ) 171 else: 172 self._test_sub = None 173 174 def _on_sub_test_update(self, val: int | None) -> None: 175 print(f'GOT SUB TEST UPDATE: {val}') 176 177 def _root_ui_menu_press(self) -> None: 178 from babase import push_back_press 179 180 ui = app.ui_v1 181 182 # If *any* main-window is up, kill it and resume play. 183 old_window = ui.get_main_window() 184 if old_window is not None: 185 186 classic = app.classic 187 188 assert classic is not None 189 classic.resume() 190 191 ui.clear_main_window() 192 return 193 194 push_back_press() 195 196 def _root_ui_account_press(self) -> None: 197 import bauiv1 198 from bauiv1lib.account.settings import AccountSettingsWindow 199 200 self._auxiliary_window_nav( 201 win_type=AccountSettingsWindow, 202 win_create_call=lambda: AccountSettingsWindow( 203 origin_widget=bauiv1.get_special_widget('account_button') 204 ), 205 ) 206 207 def _root_ui_squad_press(self) -> None: 208 import bauiv1 209 210 btn = bauiv1.get_special_widget('squad_button') 211 center = btn.get_screen_space_center() 212 if bauiv1.app.classic is not None: 213 bauiv1.app.classic.party_icon_activate(center) 214 else: 215 logging.warning('party_icon_activate: no classic.') 216 217 def _root_ui_settings_press(self) -> None: 218 import bauiv1 219 from bauiv1lib.settings.allsettings import AllSettingsWindow 220 221 self._auxiliary_window_nav( 222 win_type=AllSettingsWindow, 223 win_create_call=lambda: AllSettingsWindow( 224 origin_widget=bauiv1.get_special_widget('settings_button') 225 ), 226 ) 227 228 def _auxiliary_window_nav( 229 self, 230 win_type: type[MainWindow], 231 win_create_call: Callable[[], MainWindow], 232 ) -> None: 233 """Navigate to or away from a particular type of Auxiliary window.""" 234 # pylint: disable=unidiomatic-typecheck 235 236 ui = app.ui_v1 237 238 current_main_window = ui.get_main_window() 239 240 # Scan our ancestors for auxiliary states matching our type as 241 # well as auxiliary states in general. 242 aux_matching_state: MainWindowState | None = None 243 aux_state: MainWindowState | None = None 244 245 if current_main_window is None: 246 raise RuntimeError( 247 'Not currently handling no-top-level-window case.' 248 ) 249 250 state = current_main_window.main_window_back_state 251 while state is not None: 252 assert state.window_type is not None 253 if state.is_auxiliary: 254 if state.window_type is win_type: 255 aux_matching_state = state 256 else: 257 aux_state = state 258 259 state = state.parent 260 261 # If there's an ancestor auxiliary window-state matching our 262 # type, back out past it (example: poking settings, navigating 263 # down a level or two, and then poking settings again should 264 # back out of settings). 265 if aux_matching_state is not None: 266 current_main_window.main_window_back_state = ( 267 aux_matching_state.parent 268 ) 269 current_main_window.main_window_back() 270 return 271 272 # If there's an ancestory auxiliary state *not* matching our 273 # type, crop the state and swap in our new auxiliary UI 274 # (example: poking settings, then poking account, then poking 275 # back should end up where things were before the settings 276 # poke). 277 if aux_state is not None: 278 # Blow away the window stack and build a fresh one. 279 ui.clear_main_window() 280 ui.set_main_window( 281 win_create_call(), 282 from_window=False, # Disable from-check. 283 back_state=aux_state.parent, 284 suppress_warning=True, 285 is_auxiliary=True, 286 ) 287 return 288 289 # Ok, no auxiliary states found. Now if current window is auxiliary 290 # and the type matches, simply do a back. 291 if ( 292 current_main_window.main_window_is_auxiliary 293 and type(current_main_window) is win_type 294 ): 295 current_main_window.main_window_back() 296 return 297 298 # If current window is auxiliary but type doesn't match, 299 # swap it out for our new auxiliary UI. 300 if current_main_window.main_window_is_auxiliary: 301 ui.clear_main_window() 302 ui.set_main_window( 303 win_create_call(), 304 from_window=False, # Disable from-check. 305 back_state=current_main_window.main_window_back_state, 306 suppress_warning=True, 307 is_auxiliary=True, 308 ) 309 return 310 311 # Ok, no existing auxiliary stuff was found period. Just 312 # navigate forward to this UI. 313 current_main_window.main_window_replace( 314 win_create_call(), is_auxiliary=True 315 ) 316 317 def _root_ui_achievements_press(self) -> None: 318 import bauiv1 319 from bauiv1lib.achievements import AchievementsWindow 320 321 self._auxiliary_window_nav( 322 win_type=AchievementsWindow, 323 win_create_call=lambda: AchievementsWindow( 324 origin_widget=bauiv1.get_special_widget('achievements_button') 325 ), 326 ) 327 328 def _root_ui_inbox_press(self) -> None: 329 import bauiv1 330 from bauiv1lib.inbox import InboxWindow 331 332 self._auxiliary_window_nav( 333 win_type=InboxWindow, 334 win_create_call=lambda: InboxWindow( 335 origin_widget=bauiv1.get_special_widget('inbox_button') 336 ), 337 ) 338 339 def _root_ui_store_press(self) -> None: 340 import bauiv1 341 from bauiv1lib.store.browser import StoreBrowserWindow 342 343 self._auxiliary_window_nav( 344 win_type=StoreBrowserWindow, 345 win_create_call=lambda: StoreBrowserWindow( 346 origin_widget=bauiv1.get_special_widget('store_button') 347 ), 348 ) 349 350 def _root_ui_tickets_meter_press(self) -> None: 351 import bauiv1 352 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 353 354 ResourceTypeInfoWindow( 355 'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter') 356 ) 357 358 def _root_ui_tokens_meter_press(self) -> None: 359 import bauiv1 360 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 361 362 ResourceTypeInfoWindow( 363 'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter') 364 ) 365 366 def _root_ui_trophy_meter_press(self) -> None: 367 import bauiv1 368 from bauiv1lib.account import show_sign_in_prompt 369 from bauiv1lib.league.rankwindow import LeagueRankWindow 370 371 plus = bauiv1.app.plus 372 assert plus is not None 373 if plus.get_v1_account_state() != 'signed_in': 374 show_sign_in_prompt() 375 return 376 377 self._auxiliary_window_nav( 378 win_type=LeagueRankWindow, 379 win_create_call=lambda: LeagueRankWindow( 380 origin_widget=bauiv1.get_special_widget('trophy_meter') 381 ), 382 ) 383 384 def _root_ui_level_meter_press(self) -> None: 385 import bauiv1 386 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 387 388 ResourceTypeInfoWindow( 389 'xp', origin_widget=bauiv1.get_special_widget('level_meter') 390 ) 391 392 def _root_ui_inventory_press(self) -> None: 393 import bauiv1 394 from bauiv1lib.inventory import InventoryWindow 395 396 self._auxiliary_window_nav( 397 win_type=InventoryWindow, 398 win_create_call=lambda: InventoryWindow( 399 origin_widget=bauiv1.get_special_widget('inventory_button') 400 ), 401 ) 402 403 def _root_ui_get_tokens_press(self) -> None: 404 import bauiv1 405 from bauiv1lib.gettokens import GetTokensWindow 406 407 self._auxiliary_window_nav( 408 win_type=GetTokensWindow, 409 win_create_call=lambda: GetTokensWindow( 410 origin_widget=bauiv1.get_special_widget('get_tokens_button') 411 ), 412 ) 413 414 def _root_ui_chest_slot_pressed(self, index: int) -> None: 415 print(f'CHEST {index} PRESSED') 416 screenmessage('UNDER CONSTRUCTION.')
AppMode for the classic BombSquad experience.
42 @override 43 @classmethod 44 def get_app_experience(cls) -> AppExperience: 45 return AppExperience.MELEE
Return the overall experience provided by this mode.
53 @override 54 def handle_intent(self, intent: AppIntent) -> None: 55 if isinstance(intent, AppIntentExec): 56 _baclassic.classic_app_mode_handle_app_intent_exec(intent.code) 57 return 58 assert isinstance(intent, AppIntentDefault) 59 _baclassic.classic_app_mode_handle_app_intent_default()
Handle an intent.
61 @override 62 def on_activate(self) -> None: 63 64 # Let the native layer do its thing. 65 _baclassic.classic_app_mode_activate() 66 67 assert app.plus is not None 68 69 # Wire up the root ui to do what we want. 70 ui = app.ui_v1 71 ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = ( 72 self._root_ui_account_press 73 ) 74 ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = ( 75 self._root_ui_menu_press 76 ) 77 ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = ( 78 self._root_ui_squad_press 79 ) 80 ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = ( 81 self._root_ui_settings_press 82 ) 83 ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = ( 84 self._root_ui_store_press 85 ) 86 ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = ( 87 self._root_ui_inventory_press 88 ) 89 ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = ( 90 self._root_ui_get_tokens_press 91 ) 92 ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = ( 93 self._root_ui_inbox_press 94 ) 95 ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = ( 96 self._root_ui_tickets_meter_press 97 ) 98 ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = ( 99 self._root_ui_tokens_meter_press 100 ) 101 ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = ( 102 self._root_ui_trophy_meter_press 103 ) 104 ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = ( 105 self._root_ui_level_meter_press 106 ) 107 ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = ( 108 self._root_ui_achievements_press 109 ) 110 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial( 111 self._root_ui_chest_slot_pressed, 1 112 ) 113 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial( 114 self._root_ui_chest_slot_pressed, 2 115 ) 116 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial( 117 self._root_ui_chest_slot_pressed, 3 118 ) 119 ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial( 120 self._root_ui_chest_slot_pressed, 4 121 ) 122 123 # We want to be informed when primary account changes. 124 self._on_primary_account_changed_callback = ( 125 app.plus.accounts.on_primary_account_changed_callbacks.add( 126 self.update_for_primary_account 127 ) 128 ) 129 # Establish subscriptions/etc. for any current primary account. 130 self.update_for_primary_account(app.plus.accounts.primary)
Called when the mode is being activated.
132 @override 133 def on_deactivate(self) -> None: 134 135 # Stop being informed of account changes. 136 self._on_primary_account_changed_callback = None 137 138 # Remove any listeners for any current primary account. 139 self.update_for_primary_account(None) 140 141 # Save where we were in the UI so we return there next time. 142 if app.classic is not None: 143 app.classic.save_ui_state() 144 145 # Let the native layer do its thing. 146 _baclassic.classic_app_mode_deactivate()
Called when the mode is being deactivated.
148 @override 149 def on_app_active_changed(self) -> None: 150 # If we've gone inactive, bring up the main menu, which has the 151 # side effect of pausing the action (when possible). 152 if not app.active: 153 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.
155 def update_for_primary_account( 156 self, account: AccountV2Handle | None 157 ) -> None: 158 """Update subscriptions/etc. for a new primary account state.""" 159 assert in_logic_thread() 160 161 # Test subscription. 162 if bool(False): 163 assert app.plus is not None 164 if account is None: 165 self._test_sub = None 166 else: 167 with account: 168 self._test_sub = app.plus.cloud.subscribe_test( 169 self._on_sub_test_update 170 ) 171 else: 172 self._test_sub = None
Update subscriptions/etc. for a new primary account state.
37class ClassicAppSubsystem(babase.AppSubsystem): 38 """Subsystem for classic functionality in the app. 39 40 The single shared instance of this app can be accessed at 41 babase.app.classic. Note that it is possible for babase.app.classic to 42 be None if the classic package is not present, and code should handle 43 that case gracefully. 44 """ 45 46 # pylint: disable=too-many-public-methods 47 48 # noinspection PyUnresolvedReferences 49 from baclassic._music import MusicPlayMode 50 51 def __init__(self) -> None: 52 super().__init__() 53 self._env = babase.env() 54 55 self.accounts = AccountV1Subsystem() 56 self.ads = AdsSubsystem() 57 self.ach = AchievementSubsystem() 58 self.store = StoreSubsystem() 59 self.music = MusicSubsystem() 60 61 # Co-op Campaigns. 62 self.campaigns: dict[str, bascenev1.Campaign] = {} 63 self.custom_coop_practice_games: list[str] = [] 64 65 # Lobby. 66 self.lobby_random_profile_index: int = 1 67 self.lobby_random_char_index_offset = random.randrange(1000) 68 self.lobby_account_profile_device_id: int | None = None 69 70 # Misc. 71 self.tips: list[str] = [] 72 self.stress_test_update_timer: babase.AppTimer | None = None 73 self.stress_test_update_timer_2: babase.AppTimer | None = None 74 self.value_test_defaults: dict = {} 75 self.special_offer: dict | None = None 76 self.ping_thread_count = 0 77 self.allow_ticket_purchases: bool = True 78 79 # Main Menu. 80 self.main_menu_did_initial_transition = False 81 self.main_menu_last_news_fetch_time: float | None = None 82 83 # Spaz. 84 self.spaz_appearances: dict[str, spazappearance.Appearance] = {} 85 self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0) 86 87 # Server Mode. 88 self.server: ServerController | None = None 89 90 self.log_have_new = False 91 self.log_upload_timer_started = False 92 self.printed_live_object_warning = False 93 94 # We include this extra hash with shared input-mapping names so 95 # that we don't share mappings between differently-configured 96 # systems. For instance, different android devices may give different 97 # key values for the same controller type so we keep their mappings 98 # distinct. 99 self.input_map_hash: str | None = None 100 101 # Maps. 102 self.maps: dict[str, type[bascenev1.Map]] = {} 103 104 # Gameplay. 105 self.teams_series_length = 7 # deprecated, left for old mods 106 self.ffa_series_length = 24 # deprecated, left for old mods 107 self.coop_session_args: dict = {} 108 109 # UI. 110 self.first_main_menu = True # FIXME: Move to mainmenu class. 111 self.did_menu_intro = False # FIXME: Move to mainmenu class. 112 self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. 113 self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. 114 self.party_window: weakref.ref[PartyWindow] | None = None 115 self.main_menu_resume_callbacks: list = [] 116 self.saved_ui_state: bauiv1.MainWindowState | None = None 117 118 # Store. 119 self.store_layout: dict[str, list[dict[str, Any]]] | None = None 120 self.store_items: dict[str, dict] | None = None 121 self.pro_sale_start_time: int | None = None 122 self.pro_sale_start_val: int | None = None 123 124 def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: 125 """(internal)""" 126 127 # If there's no main window up, just call immediately. 128 if not babase.app.ui_v1.has_main_window(): 129 with babase.ContextRef.empty(): 130 call() 131 else: 132 self.main_menu_resume_callbacks.append(call) 133 134 @property 135 def platform(self) -> str: 136 """Name of the current platform. 137 138 Examples are: 'mac', 'windows', android'. 139 """ 140 assert isinstance(self._env['platform'], str) 141 return self._env['platform'] 142 143 def scene_v1_protocol_version(self) -> int: 144 """(internal)""" 145 return bascenev1.protocol_version() 146 147 @property 148 def subplatform(self) -> str: 149 """String for subplatform. 150 151 Can be empty. For the 'android' platform, subplatform may 152 be 'google', 'amazon', etc. 153 """ 154 assert isinstance(self._env['subplatform'], str) 155 return self._env['subplatform'] 156 157 @property 158 def legacy_user_agent_string(self) -> str: 159 """String containing various bits of info about OS/device/etc.""" 160 assert isinstance(self._env['legacy_user_agent_string'], str) 161 return self._env['legacy_user_agent_string'] 162 163 @override 164 def on_app_loading(self) -> None: 165 from bascenev1lib.actor import spazappearance 166 from bascenev1lib import maps as stdmaps 167 168 plus = babase.app.plus 169 assert plus is not None 170 171 env = babase.app.env 172 cfg = babase.app.config 173 174 self.music.on_app_loading() 175 176 # Non-test, non-debug builds should generally be blessed; warn if not. 177 # (so I don't accidentally release a build that can't play tourneys) 178 if not env.debug and not env.test and not plus.is_blessed(): 179 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 180 181 # FIXME: This should not be hard-coded. 182 for maptype in [ 183 stdmaps.HockeyStadium, 184 stdmaps.FootballStadium, 185 stdmaps.Bridgit, 186 stdmaps.BigG, 187 stdmaps.Roundabout, 188 stdmaps.MonkeyFace, 189 stdmaps.ZigZag, 190 stdmaps.ThePad, 191 stdmaps.DoomShroom, 192 stdmaps.LakeFrigid, 193 stdmaps.TipTop, 194 stdmaps.CragCastle, 195 stdmaps.TowerD, 196 stdmaps.HappyThoughts, 197 stdmaps.StepRightUp, 198 stdmaps.Courtyard, 199 stdmaps.Rampage, 200 ]: 201 bascenev1.register_map(maptype) 202 203 spazappearance.register_appearances() 204 bascenev1.init_campaigns() 205 206 launch_count = cfg.get('launchCount', 0) 207 launch_count += 1 208 209 # So we know how many times we've run the game at various 210 # version milestones. 211 for key in ('lc14173', 'lc14292'): 212 cfg.setdefault(key, launch_count) 213 214 cfg['launchCount'] = launch_count 215 cfg.commit() 216 217 # If there's a leftover log file, attempt to upload it to the 218 # master-server and/or get rid of it. 219 babase.handle_leftover_v1_cloud_log_file() 220 221 self.accounts.on_app_loading() 222 223 @override 224 def on_app_suspend(self) -> None: 225 self.accounts.on_app_suspend() 226 227 @override 228 def on_app_unsuspend(self) -> None: 229 self.accounts.on_app_unsuspend() 230 self.music.on_app_unsuspend() 231 232 @override 233 def on_app_shutdown(self) -> None: 234 self.music.on_app_shutdown() 235 236 def pause(self) -> None: 237 """Pause the game due to a user request or menu popping up. 238 239 If there's a foreground host-activity that says it's pausable, tell it 240 to pause. Note: we now no longer pause if there are connected clients. 241 """ 242 activity: bascenev1.Activity | None = ( 243 bascenev1.get_foreground_host_activity() 244 ) 245 if ( 246 activity is not None 247 and activity.allow_pausing 248 and not bascenev1.have_connected_clients() 249 ): 250 from babase import Lstr 251 from bascenev1 import NodeActor 252 253 # FIXME: Shouldn't be touching scene stuff here; 254 # should just pass the request on to the host-session. 255 with activity.context: 256 globs = activity.globalsnode 257 if not globs.paused: 258 bascenev1.getsound('refWhistle').play() 259 globs.paused = True 260 261 # FIXME: This should not be an attr on Actor. 262 activity.paused_text = NodeActor( 263 bascenev1.newnode( 264 'text', 265 attrs={ 266 'text': Lstr(resource='pausedByHostText'), 267 'client_only': True, 268 'flatness': 1.0, 269 'h_align': 'center', 270 }, 271 ) 272 ) 273 274 def resume(self) -> None: 275 """Resume the game due to a user request or menu closing. 276 277 If there's a foreground host-activity that's currently paused, tell it 278 to resume. 279 """ 280 281 # FIXME: Shouldn't be touching scene stuff here; 282 # should just pass the request on to the host-session. 283 activity = bascenev1.get_foreground_host_activity() 284 if activity is not None: 285 with activity.context: 286 globs = activity.globalsnode 287 if globs.paused: 288 bascenev1.getsound('refWhistle').play() 289 globs.paused = False 290 291 # FIXME: This should not be an actor attr. 292 activity.paused_text = None 293 294 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 295 """Adds an individual level to the 'practice' section in Co-op.""" 296 297 # Assign this level to our catch-all campaign. 298 self.campaigns['Challenges'].addlevel(level) 299 300 # Make note to add it to our challenges UI. 301 self.custom_coop_practice_games.append(f'Challenges:{level.name}') 302 303 def launch_coop_game( 304 self, game: str, force: bool = False, args: dict | None = None 305 ) -> bool: 306 """High level way to launch a local co-op session.""" 307 # pylint: disable=cyclic-import 308 from bauiv1lib.coop.level import CoopLevelLockedWindow 309 310 assert babase.app.classic is not None 311 312 if args is None: 313 args = {} 314 if game == '': 315 raise ValueError('empty game name') 316 campaignname, levelname = game.split(':') 317 campaign = babase.app.classic.getcampaign(campaignname) 318 319 # If this campaign is sequential, make sure we've completed the 320 # one before this. 321 if campaign.sequential and not force: 322 for level in campaign.levels: 323 if level.name == levelname: 324 break 325 if not level.complete: 326 CoopLevelLockedWindow( 327 campaign.getlevel(levelname).displayname, 328 campaign.getlevel(level.name).displayname, 329 ) 330 return False 331 332 # Save where we are in the UI to come back to when done. 333 babase.app.classic.save_ui_state() 334 335 # Ok, we're good to go. 336 self.coop_session_args = { 337 'campaign': campaignname, 338 'level': levelname, 339 } 340 for arg_name, arg_val in list(args.items()): 341 self.coop_session_args[arg_name] = arg_val 342 343 def _fade_end() -> None: 344 from bascenev1 import CoopSession 345 346 try: 347 bascenev1.new_host_session(CoopSession) 348 except Exception: 349 logging.exception('Error creating coopsession after fade end.') 350 from bascenev1lib.mainmenu import MainMenuSession 351 352 bascenev1.new_host_session(MainMenuSession) 353 354 babase.fade_screen(False, endcall=_fade_end) 355 return True 356 357 def return_to_main_menu_session_gracefully( 358 self, reset_ui: bool = True 359 ) -> None: 360 """Attempt to cleanly get back to the main menu.""" 361 # pylint: disable=cyclic-import 362 from baclassic import _benchmark 363 from bascenev1lib.mainmenu import MainMenuSession 364 365 plus = babase.app.plus 366 assert plus is not None 367 368 if reset_ui: 369 babase.app.ui_v1.clear_main_window() 370 371 if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession): 372 # It may be possible we're on the main menu but the screen is faded 373 # so fade back in. 374 babase.fade_screen(True) 375 return 376 377 _benchmark.stop_stress_test() # Stop stress-test if in progress. 378 379 # If we're in a host-session, tell them to end. 380 # This lets them tear themselves down gracefully. 381 host_session: bascenev1.Session | None = ( 382 bascenev1.get_foreground_host_session() 383 ) 384 if host_session is not None: 385 # Kick off a little transaction so we'll hopefully have all the 386 # latest account state when we get back to the menu. 387 plus.add_v1_account_transaction( 388 {'type': 'END_SESSION', 'sType': str(type(host_session))} 389 ) 390 plus.run_v1_account_transactions() 391 392 host_session.end() 393 394 # Otherwise just force the issue. 395 else: 396 babase.pushcall( 397 babase.Call(bascenev1.new_host_session, MainMenuSession) 398 ) 399 400 def getmaps(self, playtype: str) -> list[str]: 401 """Return a list of bascenev1.Map types supporting a playtype str. 402 403 Category: **Asset Functions** 404 405 Maps supporting a given playtype must provide a particular set of 406 features and lend themselves to a certain style of play. 407 408 Play Types: 409 410 'melee' 411 General fighting map. 412 Has one or more 'spawn' locations. 413 414 'team_flag' 415 For games such as Capture The Flag where each team spawns by a flag. 416 Has two or more 'spawn' locations, each with a corresponding 'flag' 417 location (based on index). 418 419 'single_flag' 420 For games such as King of the Hill or Keep Away where multiple teams 421 are fighting over a single flag. 422 Has two or more 'spawn' locations and 1 'flag_default' location. 423 424 'conquest' 425 For games such as Conquest where flags are spread throughout the map 426 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 427 428 'king_of_the_hill' - has 2+ 'spawn' locations, 429 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 430 431 'hockey' 432 For hockey games. 433 Has two 'goal' locations, corresponding 'spawn' locations, and one 434 'flag_default' location (for where puck spawns) 435 436 'football' 437 For football games. 438 Has two 'goal' locations, corresponding 'spawn' locations, and one 439 'flag_default' location (for where flag/ball/etc. spawns) 440 441 'race' 442 For racing games where players much touch each region in order. 443 Has two or more 'race_point' locations. 444 """ 445 return sorted( 446 key 447 for key, val in self.maps.items() 448 if playtype in val.get_play_types() 449 ) 450 451 def game_begin_analytics(self) -> None: 452 """(internal)""" 453 from baclassic import _analytics 454 455 _analytics.game_begin_analytics() 456 457 @classmethod 458 def json_prep(cls, data: Any) -> Any: 459 """Return a json-friendly version of the provided data. 460 461 This converts any tuples to lists and any bytes to strings 462 (interpreted as utf-8, ignoring errors). Logs errors (just once) 463 if any data is modified/discarded/unsupported. 464 """ 465 466 if isinstance(data, dict): 467 return dict( 468 (cls.json_prep(key), cls.json_prep(value)) 469 for key, value in list(data.items()) 470 ) 471 if isinstance(data, list): 472 return [cls.json_prep(element) for element in data] 473 if isinstance(data, tuple): 474 logging.exception('json_prep encountered tuple') 475 return [cls.json_prep(element) for element in data] 476 if isinstance(data, bytes): 477 try: 478 return data.decode(errors='ignore') 479 except Exception: 480 logging.exception('json_prep encountered utf-8 decode error') 481 return data.decode(errors='ignore') 482 if not isinstance(data, (str, float, bool, type(None), int)): 483 logging.exception( 484 'got unsupported type in json_prep: %s', type(data) 485 ) 486 return data 487 488 def master_server_v1_get( 489 self, 490 request: str, 491 data: dict[str, Any], 492 callback: MasterServerCallback | None = None, 493 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 494 ) -> None: 495 """Make a call to the master server via a http GET.""" 496 497 MasterServerV1CallThread( 498 request, 'get', data, callback, response_type 499 ).start() 500 501 def master_server_v1_post( 502 self, 503 request: str, 504 data: dict[str, Any], 505 callback: MasterServerCallback | None = None, 506 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 507 ) -> None: 508 """Make a call to the master server via a http POST.""" 509 MasterServerV1CallThread( 510 request, 'post', data, callback, response_type 511 ).start() 512 513 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 514 """Given a tournament entry, return strings for its prize levels.""" 515 from baclassic import _tournament 516 517 return _tournament.get_tournament_prize_strings(entry) 518 519 def getcampaign(self, name: str) -> bascenev1.Campaign: 520 """Return a campaign by name.""" 521 return self.campaigns[name] 522 523 def get_next_tip(self) -> str: 524 """Returns the next tip to be displayed.""" 525 if not self.tips: 526 for tip in get_all_tips(): 527 self.tips.insert(random.randint(0, len(self.tips)), tip) 528 tip = self.tips.pop() 529 return tip 530 531 def run_cpu_benchmark(self) -> None: 532 """Kick off a benchmark to test cpu speeds.""" 533 from baclassic._benchmark import run_cpu_benchmark 534 535 run_cpu_benchmark() 536 537 def run_media_reload_benchmark(self) -> None: 538 """Kick off a benchmark to test media reloading speeds.""" 539 from baclassic._benchmark import run_media_reload_benchmark 540 541 run_media_reload_benchmark() 542 543 def run_stress_test( 544 self, 545 *, 546 playlist_type: str = 'Random', 547 playlist_name: str = '__default__', 548 player_count: int = 8, 549 round_duration: int = 30, 550 attract_mode: bool = False, 551 ) -> None: 552 """Run a stress test.""" 553 from baclassic._benchmark import run_stress_test 554 555 run_stress_test( 556 playlist_type=playlist_type, 557 playlist_name=playlist_name, 558 player_count=player_count, 559 round_duration=round_duration, 560 attract_mode=attract_mode, 561 ) 562 563 def get_input_device_mapped_value( 564 self, 565 device: bascenev1.InputDevice, 566 name: str, 567 default: bool = False, 568 ) -> Any: 569 """Return a mapped value for an input device. 570 571 This checks the user config and falls back to default values 572 where available. 573 """ 574 return _input.get_input_device_mapped_value( 575 device.name, device.unique_identifier, name, default 576 ) 577 578 def get_input_device_map_hash( 579 self, inputdevice: bascenev1.InputDevice 580 ) -> str: 581 """Given an input device, return hash based on its raw input values.""" 582 del inputdevice # unused currently 583 return _input.get_input_device_map_hash() 584 585 def get_input_device_config( 586 self, inputdevice: bascenev1.InputDevice, default: bool 587 ) -> tuple[dict, str]: 588 """Given an input device, return its config dict in the app config. 589 590 The dict will be created if it does not exist. 591 """ 592 return _input.get_input_device_config( 593 inputdevice.name, inputdevice.unique_identifier, default 594 ) 595 596 def get_player_colors(self) -> list[tuple[float, float, float]]: 597 """Return user-selectable player colors.""" 598 return bascenev1.get_player_colors() 599 600 def get_player_profile_icon(self, profilename: str) -> str: 601 """Given a profile name, returns an icon string for it. 602 603 (non-account profiles only) 604 """ 605 return bascenev1.get_player_profile_icon(profilename) 606 607 def get_player_profile_colors( 608 self, 609 profilename: str | None, 610 profiles: dict[str, dict[str, Any]] | None = None, 611 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 612 """Given a profile, return colors for them.""" 613 return bascenev1.get_player_profile_colors(profilename, profiles) 614 615 def get_foreground_host_session(self) -> bascenev1.Session | None: 616 """(internal)""" 617 return bascenev1.get_foreground_host_session() 618 619 def get_foreground_host_activity(self) -> bascenev1.Activity | None: 620 """(internal)""" 621 return bascenev1.get_foreground_host_activity() 622 623 def value_test( 624 self, 625 arg: str, 626 change: float | None = None, 627 absolute: float | None = None, 628 ) -> float: 629 """(internal)""" 630 return _baclassic.value_test(arg, change, absolute) 631 632 def set_master_server_source(self, source: int) -> None: 633 """(internal)""" 634 bascenev1.set_master_server_source(source) 635 636 def get_game_port(self) -> int: 637 """(internal)""" 638 return bascenev1.get_game_port() 639 640 def v2_upgrade_window(self, login_name: str, code: str) -> None: 641 """(internal)""" 642 643 from bauiv1lib.v2upgrade import V2UpgradeWindow 644 645 V2UpgradeWindow(login_name, code) 646 647 def account_link_code_window(self, data: dict[str, Any]) -> None: 648 """(internal)""" 649 from bauiv1lib.account.link import AccountLinkCodeWindow 650 651 AccountLinkCodeWindow(data) 652 653 def server_dialog(self, delay: float, data: dict[str, Any]) -> None: 654 """(internal)""" 655 from bauiv1lib.serverdialog import ( 656 ServerDialogData, 657 ServerDialogWindow, 658 ) 659 660 try: 661 sddata = dataclass_from_dict(ServerDialogData, data) 662 except Exception: 663 sddata = None 664 logging.warning( 665 'Got malformatted ServerDialogData: %s', 666 data, 667 ) 668 if sddata is not None: 669 babase.apptimer( 670 delay, 671 babase.Call(ServerDialogWindow, sddata), 672 ) 673 674 # def root_ui_ticket_icon_press(self) -> None: 675 # """(internal)""" 676 # from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 677 678 # ResourceTypeInfoWindow( 679 # origin_widget=bauiv1.get_special_widget('tickets_meter') 680 # ) 681 682 def show_url_window(self, address: str) -> None: 683 """(internal)""" 684 from bauiv1lib.url import ShowURLWindow 685 686 ShowURLWindow(address) 687 688 def quit_window(self, quit_type: babase.QuitType) -> None: 689 """(internal)""" 690 from bauiv1lib.confirm import QuitWindow 691 692 QuitWindow(quit_type) 693 694 def tournament_entry_window( 695 self, 696 tournament_id: str, 697 *, 698 tournament_activity: bascenev1.Activity | None = None, 699 position: tuple[float, float] = (0.0, 0.0), 700 delegate: Any = None, 701 scale: float | None = None, 702 offset: tuple[float, float] = (0.0, 0.0), 703 on_close_call: Callable[[], Any] | None = None, 704 ) -> None: 705 """(internal)""" 706 from bauiv1lib.tournamententry import TournamentEntryWindow 707 708 TournamentEntryWindow( 709 tournament_id, 710 tournament_activity, 711 position, 712 delegate, 713 scale, 714 offset, 715 on_close_call, 716 ) 717 718 def get_main_menu_session(self) -> type[bascenev1.Session]: 719 """(internal)""" 720 from bascenev1lib.mainmenu import MainMenuSession 721 722 return MainMenuSession 723 724 def profile_browser_window( 725 self, 726 transition: str = 'in_right', 727 origin_widget: bauiv1.Widget | None = None, 728 # in_main_menu: bool = True, 729 selected_profile: str | None = None, 730 ) -> None: 731 """(internal)""" 732 from bauiv1lib.profile.browser import ProfileBrowserWindow 733 734 main_window = babase.app.ui_v1.get_main_window() 735 if main_window is not None: 736 logging.warning( 737 'profile_browser_window()' 738 ' called with existing main window; should not happen.' 739 ) 740 return 741 742 babase.app.ui_v1.set_main_window( 743 ProfileBrowserWindow( 744 transition=transition, 745 selected_profile=selected_profile, 746 origin_widget=origin_widget, 747 minimal_toolbar=True, 748 ), 749 is_top_level=True, 750 suppress_warning=True, 751 ) 752 753 def preload_map_preview_media(self) -> None: 754 """Preload media needed for map preview UIs. 755 756 Category: **Asset Functions** 757 """ 758 try: 759 bauiv1.getmesh('level_select_button_opaque') 760 bauiv1.getmesh('level_select_button_transparent') 761 for maptype in list(self.maps.values()): 762 map_tex_name = maptype.get_preview_texture_name() 763 if map_tex_name is not None: 764 bauiv1.gettexture(map_tex_name) 765 except Exception: 766 logging.exception('Error preloading map preview media.') 767 768 def party_icon_activate(self, origin: Sequence[float]) -> None: 769 """(internal)""" 770 from bauiv1lib.party import PartyWindow 771 from babase import app 772 773 assert app.env.gui 774 775 # Play explicit swish sound so it occurs due to keypresses/etc. 776 # This means we have to disable it for any button or else we get 777 # double. 778 bauiv1.getsound('swish').play() 779 780 # If it exists, dismiss it; otherwise make a new one. 781 party_window = ( 782 None if self.party_window is None else self.party_window() 783 ) 784 if party_window is not None: 785 party_window.close() 786 else: 787 self.party_window = weakref.ref(PartyWindow(origin=origin)) 788 789 def device_menu_press(self, device_id: int | None) -> None: 790 """(internal)""" 791 from bauiv1lib.ingamemenu import InGameMenuWindow 792 from bauiv1 import set_ui_input_device 793 794 assert babase.app is not None 795 in_main_menu = babase.app.ui_v1.has_main_window() 796 if not in_main_menu: 797 set_ui_input_device(device_id) 798 799 # Hack(ish). We play swish sound here so it happens for 800 # device presses, but this means we need to disable default 801 # swish sounds for any menu buttons or we'll get double. 802 if babase.app.env.gui: 803 bauiv1.getsound('swish').play() 804 805 babase.app.ui_v1.set_main_window( 806 InGameMenuWindow(), is_top_level=True, suppress_warning=True 807 ) 808 809 def save_ui_state(self) -> None: 810 """Store our current place in the UI.""" 811 ui = babase.app.ui_v1 812 mainwindow = ui.get_main_window() 813 if mainwindow is not None: 814 self.saved_ui_state = ui.save_main_window_state(mainwindow) 815 else: 816 self.saved_ui_state = None 817 818 def invoke_main_menu_ui(self) -> None: 819 """Bring up main menu ui.""" 820 821 # Bring up the last place we were, or start at the main menu 822 # otherwise. 823 app = bauiv1.app 824 env = app.env 825 with bascenev1.ContextRef.empty(): 826 # from bauiv1lib import specialoffer 827 828 assert app.classic is not None 829 if app.env.headless: 830 # UI stuff fails now in headless builds; avoid it. 831 pass 832 else: 833 834 # When coming back from a kiosk-mode game, jump to the 835 # kiosk start screen. 836 if env.demo or env.arcade: 837 # pylint: disable=cyclic-import 838 from bauiv1lib.kiosk import KioskWindow 839 840 app.ui_v1.set_main_window( 841 KioskWindow(), is_top_level=True, suppress_warning=True 842 ) 843 else: 844 # If there's a saved ui state, restore that. 845 if self.saved_ui_state is not None: 846 app.ui_v1.restore_main_window_state(self.saved_ui_state) 847 else: 848 # Otherwise start fresh at the main menu. 849 from bauiv1lib.mainmenu import MainMenuWindow 850 851 app.ui_v1.set_main_window( 852 MainMenuWindow(transition=None), 853 is_top_level=True, 854 suppress_warning=True, 855 )
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.
134 @property 135 def platform(self) -> str: 136 """Name of the current platform. 137 138 Examples are: 'mac', 'windows', android'. 139 """ 140 assert isinstance(self._env['platform'], str) 141 return self._env['platform']
Name of the current platform.
Examples are: 'mac', 'windows', android'.
147 @property 148 def subplatform(self) -> str: 149 """String for subplatform. 150 151 Can be empty. For the 'android' platform, subplatform may 152 be 'google', 'amazon', etc. 153 """ 154 assert isinstance(self._env['subplatform'], str) 155 return self._env['subplatform']
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
157 @property 158 def legacy_user_agent_string(self) -> str: 159 """String containing various bits of info about OS/device/etc.""" 160 assert isinstance(self._env['legacy_user_agent_string'], str) 161 return self._env['legacy_user_agent_string']
String containing various bits of info about OS/device/etc.
163 @override 164 def on_app_loading(self) -> None: 165 from bascenev1lib.actor import spazappearance 166 from bascenev1lib import maps as stdmaps 167 168 plus = babase.app.plus 169 assert plus is not None 170 171 env = babase.app.env 172 cfg = babase.app.config 173 174 self.music.on_app_loading() 175 176 # Non-test, non-debug builds should generally be blessed; warn if not. 177 # (so I don't accidentally release a build that can't play tourneys) 178 if not env.debug and not env.test and not plus.is_blessed(): 179 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 180 181 # FIXME: This should not be hard-coded. 182 for maptype in [ 183 stdmaps.HockeyStadium, 184 stdmaps.FootballStadium, 185 stdmaps.Bridgit, 186 stdmaps.BigG, 187 stdmaps.Roundabout, 188 stdmaps.MonkeyFace, 189 stdmaps.ZigZag, 190 stdmaps.ThePad, 191 stdmaps.DoomShroom, 192 stdmaps.LakeFrigid, 193 stdmaps.TipTop, 194 stdmaps.CragCastle, 195 stdmaps.TowerD, 196 stdmaps.HappyThoughts, 197 stdmaps.StepRightUp, 198 stdmaps.Courtyard, 199 stdmaps.Rampage, 200 ]: 201 bascenev1.register_map(maptype) 202 203 spazappearance.register_appearances() 204 bascenev1.init_campaigns() 205 206 launch_count = cfg.get('launchCount', 0) 207 launch_count += 1 208 209 # So we know how many times we've run the game at various 210 # version milestones. 211 for key in ('lc14173', 'lc14292'): 212 cfg.setdefault(key, launch_count) 213 214 cfg['launchCount'] = launch_count 215 cfg.commit() 216 217 # If there's a leftover log file, attempt to upload it to the 218 # master-server and/or get rid of it. 219 babase.handle_leftover_v1_cloud_log_file() 220 221 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.
227 @override 228 def on_app_unsuspend(self) -> None: 229 self.accounts.on_app_unsuspend() 230 self.music.on_app_unsuspend()
Called when the app exits the suspended state.
236 def pause(self) -> None: 237 """Pause the game due to a user request or menu popping up. 238 239 If there's a foreground host-activity that says it's pausable, tell it 240 to pause. Note: we now no longer pause if there are connected clients. 241 """ 242 activity: bascenev1.Activity | None = ( 243 bascenev1.get_foreground_host_activity() 244 ) 245 if ( 246 activity is not None 247 and activity.allow_pausing 248 and not bascenev1.have_connected_clients() 249 ): 250 from babase import Lstr 251 from bascenev1 import NodeActor 252 253 # FIXME: Shouldn't be touching scene stuff here; 254 # should just pass the request on to the host-session. 255 with activity.context: 256 globs = activity.globalsnode 257 if not globs.paused: 258 bascenev1.getsound('refWhistle').play() 259 globs.paused = True 260 261 # FIXME: This should not be an attr on Actor. 262 activity.paused_text = NodeActor( 263 bascenev1.newnode( 264 'text', 265 attrs={ 266 'text': Lstr(resource='pausedByHostText'), 267 'client_only': True, 268 'flatness': 1.0, 269 'h_align': 'center', 270 }, 271 ) 272 )
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.
274 def resume(self) -> None: 275 """Resume the game due to a user request or menu closing. 276 277 If there's a foreground host-activity that's currently paused, tell it 278 to resume. 279 """ 280 281 # FIXME: Shouldn't be touching scene stuff here; 282 # should just pass the request on to the host-session. 283 activity = bascenev1.get_foreground_host_activity() 284 if activity is not None: 285 with activity.context: 286 globs = activity.globalsnode 287 if globs.paused: 288 bascenev1.getsound('refWhistle').play() 289 globs.paused = False 290 291 # FIXME: This should not be an actor attr. 292 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.
294 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 295 """Adds an individual level to the 'practice' section in Co-op.""" 296 297 # Assign this level to our catch-all campaign. 298 self.campaigns['Challenges'].addlevel(level) 299 300 # Make note to add it to our challenges UI. 301 self.custom_coop_practice_games.append(f'Challenges:{level.name}')
Adds an individual level to the 'practice' section in Co-op.
303 def launch_coop_game( 304 self, game: str, force: bool = False, args: dict | None = None 305 ) -> bool: 306 """High level way to launch a local co-op session.""" 307 # pylint: disable=cyclic-import 308 from bauiv1lib.coop.level import CoopLevelLockedWindow 309 310 assert babase.app.classic is not None 311 312 if args is None: 313 args = {} 314 if game == '': 315 raise ValueError('empty game name') 316 campaignname, levelname = game.split(':') 317 campaign = babase.app.classic.getcampaign(campaignname) 318 319 # If this campaign is sequential, make sure we've completed the 320 # one before this. 321 if campaign.sequential and not force: 322 for level in campaign.levels: 323 if level.name == levelname: 324 break 325 if not level.complete: 326 CoopLevelLockedWindow( 327 campaign.getlevel(levelname).displayname, 328 campaign.getlevel(level.name).displayname, 329 ) 330 return False 331 332 # Save where we are in the UI to come back to when done. 333 babase.app.classic.save_ui_state() 334 335 # Ok, we're good to go. 336 self.coop_session_args = { 337 'campaign': campaignname, 338 'level': levelname, 339 } 340 for arg_name, arg_val in list(args.items()): 341 self.coop_session_args[arg_name] = arg_val 342 343 def _fade_end() -> None: 344 from bascenev1 import CoopSession 345 346 try: 347 bascenev1.new_host_session(CoopSession) 348 except Exception: 349 logging.exception('Error creating coopsession after fade end.') 350 from bascenev1lib.mainmenu import MainMenuSession 351 352 bascenev1.new_host_session(MainMenuSession) 353 354 babase.fade_screen(False, endcall=_fade_end) 355 return True
High level way to launch a local co-op session.
400 def getmaps(self, playtype: str) -> list[str]: 401 """Return a list of bascenev1.Map types supporting a playtype str. 402 403 Category: **Asset Functions** 404 405 Maps supporting a given playtype must provide a particular set of 406 features and lend themselves to a certain style of play. 407 408 Play Types: 409 410 'melee' 411 General fighting map. 412 Has one or more 'spawn' locations. 413 414 'team_flag' 415 For games such as Capture The Flag where each team spawns by a flag. 416 Has two or more 'spawn' locations, each with a corresponding 'flag' 417 location (based on index). 418 419 'single_flag' 420 For games such as King of the Hill or Keep Away where multiple teams 421 are fighting over a single flag. 422 Has two or more 'spawn' locations and 1 'flag_default' location. 423 424 'conquest' 425 For games such as Conquest where flags are spread throughout the map 426 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 427 428 'king_of_the_hill' - has 2+ 'spawn' locations, 429 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 430 431 'hockey' 432 For hockey games. 433 Has two 'goal' locations, corresponding 'spawn' locations, and one 434 'flag_default' location (for where puck spawns) 435 436 'football' 437 For football games. 438 Has two 'goal' locations, corresponding 'spawn' locations, and one 439 'flag_default' location (for where flag/ball/etc. spawns) 440 441 'race' 442 For racing games where players much touch each region in order. 443 Has two or more 'race_point' locations. 444 """ 445 return sorted( 446 key 447 for key, val in self.maps.items() 448 if playtype in val.get_play_types() 449 )
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.
457 @classmethod 458 def json_prep(cls, data: Any) -> Any: 459 """Return a json-friendly version of the provided data. 460 461 This converts any tuples to lists and any bytes to strings 462 (interpreted as utf-8, ignoring errors). Logs errors (just once) 463 if any data is modified/discarded/unsupported. 464 """ 465 466 if isinstance(data, dict): 467 return dict( 468 (cls.json_prep(key), cls.json_prep(value)) 469 for key, value in list(data.items()) 470 ) 471 if isinstance(data, list): 472 return [cls.json_prep(element) for element in data] 473 if isinstance(data, tuple): 474 logging.exception('json_prep encountered tuple') 475 return [cls.json_prep(element) for element in data] 476 if isinstance(data, bytes): 477 try: 478 return data.decode(errors='ignore') 479 except Exception: 480 logging.exception('json_prep encountered utf-8 decode error') 481 return data.decode(errors='ignore') 482 if not isinstance(data, (str, float, bool, type(None), int)): 483 logging.exception( 484 'got unsupported type in json_prep: %s', type(data) 485 ) 486 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.
488 def master_server_v1_get( 489 self, 490 request: str, 491 data: dict[str, Any], 492 callback: MasterServerCallback | None = None, 493 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 494 ) -> None: 495 """Make a call to the master server via a http GET.""" 496 497 MasterServerV1CallThread( 498 request, 'get', data, callback, response_type 499 ).start()
Make a call to the master server via a http GET.
501 def master_server_v1_post( 502 self, 503 request: str, 504 data: dict[str, Any], 505 callback: MasterServerCallback | None = None, 506 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 507 ) -> None: 508 """Make a call to the master server via a http POST.""" 509 MasterServerV1CallThread( 510 request, 'post', data, callback, response_type 511 ).start()
Make a call to the master server via a http POST.
513 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 514 """Given a tournament entry, return strings for its prize levels.""" 515 from baclassic import _tournament 516 517 return _tournament.get_tournament_prize_strings(entry)
Given a tournament entry, return strings for its prize levels.
519 def getcampaign(self, name: str) -> bascenev1.Campaign: 520 """Return a campaign by name.""" 521 return self.campaigns[name]
Return a campaign by name.
523 def get_next_tip(self) -> str: 524 """Returns the next tip to be displayed.""" 525 if not self.tips: 526 for tip in get_all_tips(): 527 self.tips.insert(random.randint(0, len(self.tips)), tip) 528 tip = self.tips.pop() 529 return tip
Returns the next tip to be displayed.
531 def run_cpu_benchmark(self) -> None: 532 """Kick off a benchmark to test cpu speeds.""" 533 from baclassic._benchmark import run_cpu_benchmark 534 535 run_cpu_benchmark()
Kick off a benchmark to test cpu speeds.
537 def run_media_reload_benchmark(self) -> None: 538 """Kick off a benchmark to test media reloading speeds.""" 539 from baclassic._benchmark import run_media_reload_benchmark 540 541 run_media_reload_benchmark()
Kick off a benchmark to test media reloading speeds.
543 def run_stress_test( 544 self, 545 *, 546 playlist_type: str = 'Random', 547 playlist_name: str = '__default__', 548 player_count: int = 8, 549 round_duration: int = 30, 550 attract_mode: bool = False, 551 ) -> None: 552 """Run a stress test.""" 553 from baclassic._benchmark import run_stress_test 554 555 run_stress_test( 556 playlist_type=playlist_type, 557 playlist_name=playlist_name, 558 player_count=player_count, 559 round_duration=round_duration, 560 attract_mode=attract_mode, 561 )
Run a stress test.
563 def get_input_device_mapped_value( 564 self, 565 device: bascenev1.InputDevice, 566 name: str, 567 default: bool = False, 568 ) -> Any: 569 """Return a mapped value for an input device. 570 571 This checks the user config and falls back to default values 572 where available. 573 """ 574 return _input.get_input_device_mapped_value( 575 device.name, device.unique_identifier, name, default 576 )
Return a mapped value for an input device.
This checks the user config and falls back to default values where available.
578 def get_input_device_map_hash( 579 self, inputdevice: bascenev1.InputDevice 580 ) -> str: 581 """Given an input device, return hash based on its raw input values.""" 582 del inputdevice # unused currently 583 return _input.get_input_device_map_hash()
Given an input device, return hash based on its raw input values.
585 def get_input_device_config( 586 self, inputdevice: bascenev1.InputDevice, default: bool 587 ) -> tuple[dict, str]: 588 """Given an input device, return its config dict in the app config. 589 590 The dict will be created if it does not exist. 591 """ 592 return _input.get_input_device_config( 593 inputdevice.name, inputdevice.unique_identifier, default 594 )
Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
596 def get_player_colors(self) -> list[tuple[float, float, float]]: 597 """Return user-selectable player colors.""" 598 return bascenev1.get_player_colors()
Return user-selectable player colors.
600 def get_player_profile_icon(self, profilename: str) -> str: 601 """Given a profile name, returns an icon string for it. 602 603 (non-account profiles only) 604 """ 605 return bascenev1.get_player_profile_icon(profilename)
Given a profile name, returns an icon string for it.
(non-account profiles only)
607 def get_player_profile_colors( 608 self, 609 profilename: str | None, 610 profiles: dict[str, dict[str, Any]] | None = None, 611 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 612 """Given a profile, return colors for them.""" 613 return bascenev1.get_player_profile_colors(profilename, profiles)
Given a profile, return colors for them.
753 def preload_map_preview_media(self) -> None: 754 """Preload media needed for map preview UIs. 755 756 Category: **Asset Functions** 757 """ 758 try: 759 bauiv1.getmesh('level_select_button_opaque') 760 bauiv1.getmesh('level_select_button_transparent') 761 for maptype in list(self.maps.values()): 762 map_tex_name = maptype.get_preview_texture_name() 763 if map_tex_name is not None: 764 bauiv1.gettexture(map_tex_name) 765 except Exception: 766 logging.exception('Error preloading map preview media.')
Preload media needed for map preview UIs.
Category: Asset Functions
809 def save_ui_state(self) -> None: 810 """Store our current place in the UI.""" 811 ui = babase.app.ui_v1 812 mainwindow = ui.get_main_window() 813 if mainwindow is not None: 814 self.saved_ui_state = ui.save_main_window_state(mainwindow) 815 else: 816 self.saved_ui_state = None
Store our current place in the UI.
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.