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            )
class ClassicAppMode(babase._appmode.AppMode):
 29class ClassicAppMode(AppMode):
 30    """AppMode for the classic BombSquad experience."""
 31
 32    @override
 33    @classmethod
 34    def get_app_experience(cls) -> AppExperience:
 35        return AppExperience.MELEE
 36
 37    @override
 38    @classmethod
 39    def _supports_intent(cls, intent: AppIntent) -> bool:
 40        # We support default and exec intents currently.
 41        return isinstance(intent, AppIntentExec | AppIntentDefault)
 42
 43    @override
 44    def handle_intent(self, intent: AppIntent) -> None:
 45        if isinstance(intent, AppIntentExec):
 46            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
 47            return
 48        assert isinstance(intent, AppIntentDefault)
 49        _baclassic.classic_app_mode_handle_app_intent_default()
 50
 51    @override
 52    def on_activate(self) -> None:
 53        print('CLASSIC ACTIVATING')
 54
 55        # Let the native layer do its thing.
 56        _baclassic.classic_app_mode_activate()
 57
 58        # Wire up the root ui to do what we want.
 59        ui = app.ui_v1
 60        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 61            self._root_ui_account_press
 62        )
 63        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 64            self._root_ui_menu_press
 65        )
 66        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 67            self._root_ui_squad_press
 68        )
 69        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 70            self._root_ui_settings_press
 71        )
 72        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 73            self._root_ui_store_press
 74        )
 75        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 76            self._root_ui_inventory_press
 77        )
 78        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
 79            self._root_ui_get_tokens_press
 80        )
 81        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
 82            self._root_ui_inbox_press
 83        )
 84        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
 85            self._root_ui_tickets_meter_press
 86        )
 87        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
 88            self._root_ui_tokens_meter_press
 89        )
 90        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
 91            self._root_ui_trophy_meter_press
 92        )
 93        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
 94            self._root_ui_level_meter_press
 95        )
 96        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
 97            self._root_ui_achievements_press
 98        )
 99        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
100            self._root_ui_chest_slot_pressed, 1
101        )
102        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
103            self._root_ui_chest_slot_pressed, 2
104        )
105        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
106            self._root_ui_chest_slot_pressed, 3
107        )
108        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial(
109            self._root_ui_chest_slot_pressed, 4
110        )
111
112    @override
113    def on_deactivate(self) -> None:
114        print('CLASSIC DEACTIVATING')
115        # Let the native layer do its thing.
116        _baclassic.classic_app_mode_deactivate()
117
118    @override
119    def on_app_active_changed(self) -> None:
120        # If we've gone inactive, bring up the main menu, which has the
121        # side effect of pausing the action (when possible).
122        if not app.active:
123            invoke_main_menu()
124
125    def _jump_to_main_window(self, window: MainWindow) -> None:
126        """Jump to a window with the main menu as its parent."""
127        from bauiv1lib.mainmenu import MainMenuWindow
128        from bauiv1lib.ingamemenu import InGameMenuWindow
129
130        ui = app.ui_v1
131
132        old_window = ui.get_main_window()
133
134        if isinstance(old_window, (MainMenuWindow, InGameMenuWindow)):
135            # If we're currently in the top level menu window, just push
136            # our mainwindow on to the end.
137            old_window.main_window_replace(window)
138        else:
139            # Blow away the window stack and build a fresh one.
140            ui.clear_main_window()
141
142            back_state = (
143                MainMenuWindow.do_get_main_window_state()
144                if in_main_menu()
145                else InGameMenuWindow.do_get_main_window_state()
146            )
147            # set_main_window() needs this to be set.
148            back_state.is_top_level = True
149
150            ui.set_main_window(
151                window,
152                from_window=False,  # Disable from-check.
153                back_state=back_state,
154                suppress_warning=True,
155            )
156
157    def _root_ui_menu_press(self) -> None:
158        from babase import push_back_press
159
160        ui = app.ui_v1
161
162        # If *any* main-window is up, kill it.
163        old_window = ui.get_main_window()
164        if old_window is not None:
165            ui.clear_main_window()
166            return
167
168        push_back_press()
169
170    def _root_ui_account_press(self) -> None:
171        import bauiv1
172        from bauiv1lib.account.settings import AccountSettingsWindow
173
174        ui = app.ui_v1
175
176        # If the window is already showing, back out of it.
177        current_main_window = ui.get_main_window()
178        if isinstance(current_main_window, AccountSettingsWindow):
179            current_main_window.main_window_back()
180            return
181
182        self._jump_to_main_window(
183            AccountSettingsWindow(
184                origin_widget=bauiv1.get_special_widget('account_button')
185            )
186        )
187
188    def _root_ui_squad_press(self) -> None:
189        import bauiv1
190
191        btn = bauiv1.get_special_widget('squad_button')
192        center = btn.get_screen_space_center()
193        if bauiv1.app.classic is not None:
194            bauiv1.app.classic.party_icon_activate(center)
195        else:
196            logging.warning('party_icon_activate: no classic.')
197
198    def _root_ui_settings_press(self) -> None:
199        import bauiv1
200        from bauiv1lib.settings.allsettings import AllSettingsWindow
201
202        ui = app.ui_v1
203
204        # If the window is already showing, back out of it.
205        current_main_window = ui.get_main_window()
206        if isinstance(current_main_window, AllSettingsWindow):
207            current_main_window.main_window_back()
208            return
209
210        self._jump_to_main_window(
211            AllSettingsWindow(
212                origin_widget=bauiv1.get_special_widget('settings_button')
213            )
214        )
215
216    def _root_ui_achievements_press(self) -> None:
217        import bauiv1
218        from bauiv1lib.achievements import AchievementsWindow
219
220        btn = bauiv1.get_special_widget('achievements_button')
221
222        AchievementsWindow(position=btn.get_screen_space_center())
223
224    def _root_ui_inbox_press(self) -> None:
225        import bauiv1
226        from bauiv1lib.inbox import InboxWindow
227
228        btn = bauiv1.get_special_widget('inbox_button')
229
230        InboxWindow(position=btn.get_screen_space_center())
231
232    def _root_ui_store_press(self) -> None:
233        import bauiv1
234        from bauiv1lib.store.browser import StoreBrowserWindow
235
236        ui = app.ui_v1
237
238        # If the window is already showing, back out of it.
239        current_main_window = ui.get_main_window()
240        if isinstance(current_main_window, StoreBrowserWindow):
241            current_main_window.main_window_back()
242            return
243
244        self._jump_to_main_window(
245            StoreBrowserWindow(
246                origin_widget=bauiv1.get_special_widget('store_button')
247            )
248        )
249
250    def _root_ui_tickets_meter_press(self) -> None:
251        import bauiv1
252        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
253
254        ResourceTypeInfoWindow(
255            'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
256        )
257
258    def _root_ui_tokens_meter_press(self) -> None:
259        import bauiv1
260        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
261
262        ResourceTypeInfoWindow(
263            'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
264        )
265
266    def _root_ui_trophy_meter_press(self) -> None:
267        import bauiv1
268        from bauiv1lib.account import show_sign_in_prompt
269        from bauiv1lib.league.rankwindow import LeagueRankWindow
270
271        ui = app.ui_v1
272
273        # If the window is already showing, back out of it.
274        current_main_window = ui.get_main_window()
275        if isinstance(current_main_window, LeagueRankWindow):
276            current_main_window.main_window_back()
277            return
278
279        plus = bauiv1.app.plus
280        assert plus is not None
281
282        if plus.get_v1_account_state() != 'signed_in':
283            show_sign_in_prompt()
284            return
285
286        self._jump_to_main_window(
287            LeagueRankWindow(
288                origin_widget=bauiv1.get_special_widget('trophy_meter')
289            )
290        )
291
292    def _root_ui_level_meter_press(self) -> None:
293        import bauiv1
294        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
295
296        ResourceTypeInfoWindow(
297            'xp', origin_widget=bauiv1.get_special_widget('level_meter')
298        )
299
300    def _root_ui_inventory_press(self) -> None:
301        import bauiv1
302        from bauiv1lib.inventory import InventoryWindow
303
304        ui = app.ui_v1
305
306        # If the window is already showing, back out of it.
307        current_main_window = ui.get_main_window()
308        if isinstance(current_main_window, InventoryWindow):
309            current_main_window.main_window_back()
310            return
311
312        self._jump_to_main_window(
313            InventoryWindow(
314                origin_widget=bauiv1.get_special_widget('inventory_button')
315            )
316        )
317
318    def _root_ui_get_tokens_press(self) -> None:
319        import bauiv1
320        from bauiv1lib.gettokens import GetTokensWindow
321
322        GetTokensWindow(
323            origin_widget=bauiv1.get_special_widget('get_tokens_button')
324        )
325
326    def _root_ui_chest_slot_pressed(self, index: int) -> None:
327        print(f'CHEST {index} PRESSED')
328        screenmessage('UNDER CONSTRUCTION.')

AppMode for the classic BombSquad experience.

@override
@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
32    @override
33    @classmethod
34    def get_app_experience(cls) -> AppExperience:
35        return AppExperience.MELEE

Return the overall experience provided by this mode.

@override
def handle_intent(self, intent: babase.AppIntent) -> None:
43    @override
44    def handle_intent(self, intent: AppIntent) -> None:
45        if isinstance(intent, AppIntentExec):
46            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
47            return
48        assert isinstance(intent, AppIntentDefault)
49        _baclassic.classic_app_mode_handle_app_intent_default()

Handle an intent.

@override
def on_activate(self) -> None:
 51    @override
 52    def on_activate(self) -> None:
 53        print('CLASSIC ACTIVATING')
 54
 55        # Let the native layer do its thing.
 56        _baclassic.classic_app_mode_activate()
 57
 58        # Wire up the root ui to do what we want.
 59        ui = app.ui_v1
 60        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 61            self._root_ui_account_press
 62        )
 63        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 64            self._root_ui_menu_press
 65        )
 66        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 67            self._root_ui_squad_press
 68        )
 69        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 70            self._root_ui_settings_press
 71        )
 72        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 73            self._root_ui_store_press
 74        )
 75        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 76            self._root_ui_inventory_press
 77        )
 78        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
 79            self._root_ui_get_tokens_press
 80        )
 81        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
 82            self._root_ui_inbox_press
 83        )
 84        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
 85            self._root_ui_tickets_meter_press
 86        )
 87        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
 88            self._root_ui_tokens_meter_press
 89        )
 90        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
 91            self._root_ui_trophy_meter_press
 92        )
 93        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
 94            self._root_ui_level_meter_press
 95        )
 96        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
 97            self._root_ui_achievements_press
 98        )
 99        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
100            self._root_ui_chest_slot_pressed, 1
101        )
102        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
103            self._root_ui_chest_slot_pressed, 2
104        )
105        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
106            self._root_ui_chest_slot_pressed, 3
107        )
108        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial(
109            self._root_ui_chest_slot_pressed, 4
110        )

Called when the mode is being activated.

@override
def on_deactivate(self) -> None:
112    @override
113    def on_deactivate(self) -> None:
114        print('CLASSIC DEACTIVATING')
115        # Let the native layer do its thing.
116        _baclassic.classic_app_mode_deactivate()

Called when the mode is being deactivated.

@override
def on_app_active_changed(self) -> None:
118    @override
119    def on_app_active_changed(self) -> None:
120        # If we've gone inactive, bring up the main menu, which has the
121        # side effect of pausing the action (when possible).
122        if not app.active:
123            invoke_main_menu()

Called when babase.app.active changes.

The app-mode may want to take action such as pausing a running game in such cases.

Inherited Members
babase._appmode.AppMode
can_handle_intent
class ClassicAppSubsystem(babase._appsubsystem.AppSubsystem):
 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
117        # Store.
118        self.store_layout: dict[str, list[dict[str, Any]]] | None = None
119        self.store_items: dict[str, dict] | None = None
120        self.pro_sale_start_time: int | None = None
121        self.pro_sale_start_val: int | None = None
122
123    def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
124        """(internal)"""
125
126        # If there's no main window up, just call immediately.
127        if not babase.app.ui_v1.has_main_window():
128            with babase.ContextRef.empty():
129                call()
130        else:
131            self.main_menu_resume_callbacks.append(call)
132
133    @property
134    def platform(self) -> str:
135        """Name of the current platform.
136
137        Examples are: 'mac', 'windows', android'.
138        """
139        assert isinstance(self._env['platform'], str)
140        return self._env['platform']
141
142    def scene_v1_protocol_version(self) -> int:
143        """(internal)"""
144        return bascenev1.protocol_version()
145
146    @property
147    def subplatform(self) -> str:
148        """String for subplatform.
149
150        Can be empty. For the 'android' platform, subplatform may
151        be 'google', 'amazon', etc.
152        """
153        assert isinstance(self._env['subplatform'], str)
154        return self._env['subplatform']
155
156    @property
157    def legacy_user_agent_string(self) -> str:
158        """String containing various bits of info about OS/device/etc."""
159        assert isinstance(self._env['legacy_user_agent_string'], str)
160        return self._env['legacy_user_agent_string']
161
162    @override
163    def on_app_loading(self) -> None:
164        from bascenev1lib.actor import spazappearance
165        from bascenev1lib import maps as stdmaps
166
167        plus = babase.app.plus
168        assert plus is not None
169
170        env = babase.app.env
171        cfg = babase.app.config
172
173        self.music.on_app_loading()
174
175        # Non-test, non-debug builds should generally be blessed; warn if not.
176        # (so I don't accidentally release a build that can't play tourneys)
177        if not env.debug and not env.test and not plus.is_blessed():
178            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
179
180        # FIXME: This should not be hard-coded.
181        for maptype in [
182            stdmaps.HockeyStadium,
183            stdmaps.FootballStadium,
184            stdmaps.Bridgit,
185            stdmaps.BigG,
186            stdmaps.Roundabout,
187            stdmaps.MonkeyFace,
188            stdmaps.ZigZag,
189            stdmaps.ThePad,
190            stdmaps.DoomShroom,
191            stdmaps.LakeFrigid,
192            stdmaps.TipTop,
193            stdmaps.CragCastle,
194            stdmaps.TowerD,
195            stdmaps.HappyThoughts,
196            stdmaps.StepRightUp,
197            stdmaps.Courtyard,
198            stdmaps.Rampage,
199        ]:
200            bascenev1.register_map(maptype)
201
202        spazappearance.register_appearances()
203        bascenev1.init_campaigns()
204
205        launch_count = cfg.get('launchCount', 0)
206        launch_count += 1
207
208        # So we know how many times we've run the game at various
209        # version milestones.
210        for key in ('lc14173', 'lc14292'):
211            cfg.setdefault(key, launch_count)
212
213        cfg['launchCount'] = launch_count
214        cfg.commit()
215
216        # If there's a leftover log file, attempt to upload it to the
217        # master-server and/or get rid of it.
218        babase.handle_leftover_v1_cloud_log_file()
219
220        self.accounts.on_app_loading()
221
222    @override
223    def on_app_suspend(self) -> None:
224        self.accounts.on_app_suspend()
225
226    @override
227    def on_app_unsuspend(self) -> None:
228        self.accounts.on_app_unsuspend()
229        self.music.on_app_unsuspend()
230
231    @override
232    def on_app_shutdown(self) -> None:
233        self.music.on_app_shutdown()
234
235    def pause(self) -> None:
236        """Pause the game due to a user request or menu popping up.
237
238        If there's a foreground host-activity that says it's pausable, tell it
239        to pause. Note: we now no longer pause if there are connected clients.
240        """
241        activity: bascenev1.Activity | None = (
242            bascenev1.get_foreground_host_activity()
243        )
244        if (
245            activity is not None
246            and activity.allow_pausing
247            and not bascenev1.have_connected_clients()
248        ):
249            from babase import Lstr
250            from bascenev1 import NodeActor
251
252            # FIXME: Shouldn't be touching scene stuff here;
253            #  should just pass the request on to the host-session.
254            with activity.context:
255                globs = activity.globalsnode
256                if not globs.paused:
257                    bascenev1.getsound('refWhistle').play()
258                    globs.paused = True
259
260                # FIXME: This should not be an attr on Actor.
261                activity.paused_text = NodeActor(
262                    bascenev1.newnode(
263                        'text',
264                        attrs={
265                            'text': Lstr(resource='pausedByHostText'),
266                            'client_only': True,
267                            'flatness': 1.0,
268                            'h_align': 'center',
269                        },
270                    )
271                )
272
273    def resume(self) -> None:
274        """Resume the game due to a user request or menu closing.
275
276        If there's a foreground host-activity that's currently paused, tell it
277        to resume.
278        """
279
280        # FIXME: Shouldn't be touching scene stuff here;
281        #  should just pass the request on to the host-session.
282        activity = bascenev1.get_foreground_host_activity()
283        if activity is not None:
284            with activity.context:
285                globs = activity.globalsnode
286                if globs.paused:
287                    bascenev1.getsound('refWhistle').play()
288                    globs.paused = False
289
290                    # FIXME: This should not be an actor attr.
291                    activity.paused_text = None
292
293    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
294        """Adds an individual level to the 'practice' section in Co-op."""
295
296        # Assign this level to our catch-all campaign.
297        self.campaigns['Challenges'].addlevel(level)
298
299        # Make note to add it to our challenges UI.
300        self.custom_coop_practice_games.append(f'Challenges:{level.name}')
301
302    def launch_coop_game(
303        self, game: str, force: bool = False, args: dict | None = None
304    ) -> bool:
305        """High level way to launch a local co-op session."""
306        # pylint: disable=cyclic-import
307        from bauiv1lib.coop.level import CoopLevelLockedWindow
308
309        assert babase.app.classic is not None
310
311        if args is None:
312            args = {}
313        if game == '':
314            raise ValueError('empty game name')
315        campaignname, levelname = game.split(':')
316        campaign = babase.app.classic.getcampaign(campaignname)
317
318        # If this campaign is sequential, make sure we've completed the
319        # one before this.
320        if campaign.sequential and not force:
321            for level in campaign.levels:
322                if level.name == levelname:
323                    break
324                if not level.complete:
325                    CoopLevelLockedWindow(
326                        campaign.getlevel(levelname).displayname,
327                        campaign.getlevel(level.name).displayname,
328                    )
329                    return False
330
331        # Ok, we're good to go.
332        self.coop_session_args = {
333            'campaign': campaignname,
334            'level': levelname,
335        }
336        for arg_name, arg_val in list(args.items()):
337            self.coop_session_args[arg_name] = arg_val
338
339        def _fade_end() -> None:
340            from bascenev1 import CoopSession
341
342            try:
343                bascenev1.new_host_session(CoopSession)
344            except Exception:
345                logging.exception('Error creating coopsession after fade end.')
346                from bascenev1lib.mainmenu import MainMenuSession
347
348                bascenev1.new_host_session(MainMenuSession)
349
350        babase.fade_screen(False, endcall=_fade_end)
351        return True
352
353    def return_to_main_menu_session_gracefully(
354        self, reset_ui: bool = True
355    ) -> None:
356        """Attempt to cleanly get back to the main menu."""
357        # pylint: disable=cyclic-import
358        from baclassic import _benchmark
359        from bascenev1lib.mainmenu import MainMenuSession
360
361        plus = babase.app.plus
362        assert plus is not None
363
364        if reset_ui:
365            babase.app.ui_v1.clear_main_window()
366
367        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
368            # It may be possible we're on the main menu but the screen is faded
369            # so fade back in.
370            babase.fade_screen(True)
371            return
372
373        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
374
375        # If we're in a host-session, tell them to end.
376        # This lets them tear themselves down gracefully.
377        host_session: bascenev1.Session | None = (
378            bascenev1.get_foreground_host_session()
379        )
380        if host_session is not None:
381            # Kick off a little transaction so we'll hopefully have all the
382            # latest account state when we get back to the menu.
383            plus.add_v1_account_transaction(
384                {'type': 'END_SESSION', 'sType': str(type(host_session))}
385            )
386            plus.run_v1_account_transactions()
387
388            host_session.end()
389
390        # Otherwise just force the issue.
391        else:
392            babase.pushcall(
393                babase.Call(bascenev1.new_host_session, MainMenuSession)
394            )
395
396    def getmaps(self, playtype: str) -> list[str]:
397        """Return a list of bascenev1.Map types supporting a playtype str.
398
399        Category: **Asset Functions**
400
401        Maps supporting a given playtype must provide a particular set of
402        features and lend themselves to a certain style of play.
403
404        Play Types:
405
406        'melee'
407          General fighting map.
408          Has one or more 'spawn' locations.
409
410        'team_flag'
411          For games such as Capture The Flag where each team spawns by a flag.
412          Has two or more 'spawn' locations, each with a corresponding 'flag'
413          location (based on index).
414
415        'single_flag'
416          For games such as King of the Hill or Keep Away where multiple teams
417          are fighting over a single flag.
418          Has two or more 'spawn' locations and 1 'flag_default' location.
419
420        'conquest'
421          For games such as Conquest where flags are spread throughout the map
422          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
423
424        'king_of_the_hill' - has 2+ 'spawn' locations,
425           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
426
427        'hockey'
428          For hockey games.
429          Has two 'goal' locations, corresponding 'spawn' locations, and one
430          'flag_default' location (for where puck spawns)
431
432        'football'
433          For football games.
434          Has two 'goal' locations, corresponding 'spawn' locations, and one
435          'flag_default' location (for where flag/ball/etc. spawns)
436
437        'race'
438          For racing games where players much touch each region in order.
439          Has two or more 'race_point' locations.
440        """
441        return sorted(
442            key
443            for key, val in self.maps.items()
444            if playtype in val.get_play_types()
445        )
446
447    def game_begin_analytics(self) -> None:
448        """(internal)"""
449        from baclassic import _analytics
450
451        _analytics.game_begin_analytics()
452
453    @classmethod
454    def json_prep(cls, data: Any) -> Any:
455        """Return a json-friendly version of the provided data.
456
457        This converts any tuples to lists and any bytes to strings
458        (interpreted as utf-8, ignoring errors). Logs errors (just once)
459        if any data is modified/discarded/unsupported.
460        """
461
462        if isinstance(data, dict):
463            return dict(
464                (cls.json_prep(key), cls.json_prep(value))
465                for key, value in list(data.items())
466            )
467        if isinstance(data, list):
468            return [cls.json_prep(element) for element in data]
469        if isinstance(data, tuple):
470            logging.exception('json_prep encountered tuple')
471            return [cls.json_prep(element) for element in data]
472        if isinstance(data, bytes):
473            try:
474                return data.decode(errors='ignore')
475            except Exception:
476                logging.exception('json_prep encountered utf-8 decode error')
477                return data.decode(errors='ignore')
478        if not isinstance(data, (str, float, bool, type(None), int)):
479            logging.exception(
480                'got unsupported type in json_prep: %s', type(data)
481            )
482        return data
483
484    def master_server_v1_get(
485        self,
486        request: str,
487        data: dict[str, Any],
488        callback: MasterServerCallback | None = None,
489        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
490    ) -> None:
491        """Make a call to the master server via a http GET."""
492
493        MasterServerV1CallThread(
494            request, 'get', data, callback, response_type
495        ).start()
496
497    def master_server_v1_post(
498        self,
499        request: str,
500        data: dict[str, Any],
501        callback: MasterServerCallback | None = None,
502        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
503    ) -> None:
504        """Make a call to the master server via a http POST."""
505        MasterServerV1CallThread(
506            request, 'post', data, callback, response_type
507        ).start()
508
509    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
510        """Given a tournament entry, return strings for its prize levels."""
511        from baclassic import _tournament
512
513        return _tournament.get_tournament_prize_strings(entry)
514
515    def getcampaign(self, name: str) -> bascenev1.Campaign:
516        """Return a campaign by name."""
517        return self.campaigns[name]
518
519    def get_next_tip(self) -> str:
520        """Returns the next tip to be displayed."""
521        if not self.tips:
522            for tip in get_all_tips():
523                self.tips.insert(random.randint(0, len(self.tips)), tip)
524        tip = self.tips.pop()
525        return tip
526
527    def run_gpu_benchmark(self) -> None:
528        """Kick off a benchmark to test gpu speeds."""
529        from baclassic._benchmark import run_gpu_benchmark as run
530
531        run()
532
533    def run_cpu_benchmark(self) -> None:
534        """Kick off a benchmark to test cpu speeds."""
535        from baclassic._benchmark import run_cpu_benchmark as run
536
537        run()
538
539    def run_media_reload_benchmark(self) -> None:
540        """Kick off a benchmark to test media reloading speeds."""
541        from baclassic._benchmark import run_media_reload_benchmark as run
542
543        run()
544
545    def run_stress_test(
546        self,
547        playlist_type: str = 'Random',
548        playlist_name: str = '__default__',
549        player_count: int = 8,
550        round_duration: int = 30,
551        attract_mode: bool = False,
552    ) -> None:
553        """Run a stress test."""
554        from baclassic._benchmark import run_stress_test as run
555
556        run(
557            playlist_type=playlist_type,
558            playlist_name=playlist_name,
559            player_count=player_count,
560            round_duration=round_duration,
561            attract_mode=attract_mode,
562        )
563
564    def get_input_device_mapped_value(
565        self,
566        device: bascenev1.InputDevice,
567        name: str,
568        default: bool = False,
569    ) -> Any:
570        """Return a mapped value for an input device.
571
572        This checks the user config and falls back to default values
573        where available.
574        """
575        return _input.get_input_device_mapped_value(
576            device.name, device.unique_identifier, name, default
577        )
578
579    def get_input_device_map_hash(
580        self, inputdevice: bascenev1.InputDevice
581    ) -> str:
582        """Given an input device, return hash based on its raw input values."""
583        del inputdevice  # unused currently
584        return _input.get_input_device_map_hash()
585
586    def get_input_device_config(
587        self, inputdevice: bascenev1.InputDevice, default: bool
588    ) -> tuple[dict, str]:
589        """Given an input device, return its config dict in the app config.
590
591        The dict will be created if it does not exist.
592        """
593        return _input.get_input_device_config(
594            inputdevice.name, inputdevice.unique_identifier, default
595        )
596
597    def get_player_colors(self) -> list[tuple[float, float, float]]:
598        """Return user-selectable player colors."""
599        return bascenev1.get_player_colors()
600
601    def get_player_profile_icon(self, profilename: str) -> str:
602        """Given a profile name, returns an icon string for it.
603
604        (non-account profiles only)
605        """
606        return bascenev1.get_player_profile_icon(profilename)
607
608    def get_player_profile_colors(
609        self,
610        profilename: str | None,
611        profiles: dict[str, dict[str, Any]] | None = None,
612    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
613        """Given a profile, return colors for them."""
614        return bascenev1.get_player_profile_colors(profilename, profiles)
615
616    def get_foreground_host_session(self) -> bascenev1.Session | None:
617        """(internal)"""
618        return bascenev1.get_foreground_host_session()
619
620    def get_foreground_host_activity(self) -> bascenev1.Activity | None:
621        """(internal)"""
622        return bascenev1.get_foreground_host_activity()
623
624    def value_test(
625        self,
626        arg: str,
627        change: float | None = None,
628        absolute: float | None = None,
629    ) -> float:
630        """(internal)"""
631        return _baclassic.value_test(arg, change, absolute)
632
633    def set_master_server_source(self, source: int) -> None:
634        """(internal)"""
635        bascenev1.set_master_server_source(source)
636
637    def get_game_port(self) -> int:
638        """(internal)"""
639        return bascenev1.get_game_port()
640
641    def v2_upgrade_window(self, login_name: str, code: str) -> None:
642        """(internal)"""
643
644        from bauiv1lib.v2upgrade import V2UpgradeWindow
645
646        V2UpgradeWindow(login_name, code)
647
648    def account_link_code_window(self, data: dict[str, Any]) -> None:
649        """(internal)"""
650        from bauiv1lib.account.link import AccountLinkCodeWindow
651
652        AccountLinkCodeWindow(data)
653
654    def server_dialog(self, delay: float, data: dict[str, Any]) -> None:
655        """(internal)"""
656        from bauiv1lib.serverdialog import (
657            ServerDialogData,
658            ServerDialogWindow,
659        )
660
661        try:
662            sddata = dataclass_from_dict(ServerDialogData, data)
663        except Exception:
664            sddata = None
665            logging.warning(
666                'Got malformatted ServerDialogData: %s',
667                data,
668            )
669        if sddata is not None:
670            babase.apptimer(
671                delay,
672                babase.Call(ServerDialogWindow, sddata),
673            )
674
675    # def root_ui_ticket_icon_press(self) -> None:
676    #     """(internal)"""
677    #     from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
678
679    #     ResourceTypeInfoWindow(
680    #         origin_widget=bauiv1.get_special_widget('tickets_meter')
681    #     )
682
683    def show_url_window(self, address: str) -> None:
684        """(internal)"""
685        from bauiv1lib.url import ShowURLWindow
686
687        ShowURLWindow(address)
688
689    def quit_window(self, quit_type: babase.QuitType) -> None:
690        """(internal)"""
691        from bauiv1lib.confirm import QuitWindow
692
693        QuitWindow(quit_type)
694
695    def tournament_entry_window(
696        self,
697        tournament_id: str,
698        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 continues_window(
725        self,
726        activity: bascenev1.Activity,
727        cost: int,
728        continue_call: Callable[[], Any],
729        cancel_call: Callable[[], Any],
730    ) -> None:
731        """(internal)"""
732        from bauiv1lib.continues import ContinuesWindow
733
734        ContinuesWindow(activity, cost, continue_call, cancel_call)
735
736    def profile_browser_window(
737        self,
738        transition: str = 'in_right',
739        origin_widget: bauiv1.Widget | None = None,
740        # in_main_menu: bool = True,
741        selected_profile: str | None = None,
742    ) -> None:
743        """(internal)"""
744        from bauiv1lib.profile.browser import ProfileBrowserWindow
745
746        main_window = babase.app.ui_v1.get_main_window()
747        if main_window is not None:
748            logging.warning(
749                'profile_browser_window()'
750                ' called with existing main window; should not happen.'
751            )
752            return
753
754        babase.app.ui_v1.set_main_window(
755            ProfileBrowserWindow(
756                transition=transition,
757                selected_profile=selected_profile,
758                origin_widget=origin_widget,
759                minimal_toolbar=True,
760            ),
761            is_top_level=True,
762            suppress_warning=True,
763        )
764
765    def preload_map_preview_media(self) -> None:
766        """Preload media needed for map preview UIs.
767
768        Category: **Asset Functions**
769        """
770        try:
771            bauiv1.getmesh('level_select_button_opaque')
772            bauiv1.getmesh('level_select_button_transparent')
773            for maptype in list(self.maps.values()):
774                map_tex_name = maptype.get_preview_texture_name()
775                if map_tex_name is not None:
776                    bauiv1.gettexture(map_tex_name)
777        except Exception:
778            logging.exception('Error preloading map preview media.')
779
780    def party_icon_activate(self, origin: Sequence[float]) -> None:
781        """(internal)"""
782        from bauiv1lib.party import PartyWindow
783        from babase import app
784
785        assert app.env.gui
786
787        # Play explicit swish sound so it occurs due to keypresses/etc.
788        # This means we have to disable it for any button or else we get
789        # double.
790        bauiv1.getsound('swish').play()
791
792        # If it exists, dismiss it; otherwise make a new one.
793        party_window = (
794            None if self.party_window is None else self.party_window()
795        )
796        if party_window is not None:
797            party_window.close()
798        else:
799            self.party_window = weakref.ref(PartyWindow(origin=origin))
800
801    def device_menu_press(self, device_id: int | None) -> None:
802        """(internal)"""
803        from bauiv1lib.ingamemenu import InGameMenuWindow
804        from bauiv1 import set_ui_input_device
805
806        assert babase.app is not None
807        in_main_menu = babase.app.ui_v1.has_main_window()
808        if not in_main_menu:
809            set_ui_input_device(device_id)
810
811            # Hack(ish). We play swish sound here so it happens for
812            # device presses, but this means we need to disable default
813            # swish sounds for any menu buttons or we'll get double.
814            if babase.app.env.gui:
815                bauiv1.getsound('swish').play()
816
817            babase.app.ui_v1.set_main_window(
818                InGameMenuWindow(), is_top_level=True, suppress_warning=True
819            )
820
821    def invoke_main_menu_ui(self) -> None:
822        """Bring up main menu ui."""
823        print('INVOKING MAIN MENU UI')
824
825        # Bring up the last place we were, or start at the main menu otherwise.
826        app = bauiv1.app
827        env = app.env
828        with bascenev1.ContextRef.empty():
829            # from bauiv1lib import specialoffer
830
831            assert app.classic is not None
832            if app.env.headless:
833                # UI stuff fails now in headless builds; avoid it.
834                pass
835            else:
836
837                # When coming back from a kiosk-mode game, jump to the
838                # kiosk start screen.
839                if env.demo or env.arcade:
840                    # pylint: disable=cyclic-import
841                    from bauiv1lib.kiosk import KioskWindow
842
843                    app.ui_v1.set_main_window(
844                        KioskWindow(), is_top_level=True, suppress_warning=True
845                    )
846                # ..or in normal cases go back to the main menu
847                else:
848                    # if main_menu_location == 'Gather':
849                    #     # pylint: disable=cyclic-import
850                    #     from bauiv1lib.gather import GatherWindow
851
852                    #     app.ui_v1.set_main_window(
853                    #         GatherWindow(transition=None),
854                    #         from_window=False,  # Disable check here.
855                    #     )
856                    # elif main_menu_location == 'Watch':
857                    #     # pylint: disable=cyclic-import
858                    #     from bauiv1lib.watch import WatchWindow
859
860                    #     app.ui_v1.set_main_window(
861                    #         WatchWindow(transition=None),
862                    #         from_window=False,  # Disable check here.
863                    #     )
864                    # elif main_menu_location == 'Team Game Select':
865                    #     # pylint: disable=cyclic-import
866                    #     from bauiv1lib.playlist.browser import (
867                    #         PlaylistBrowserWindow,
868                    #     )
869
870                    #     app.ui_v1.set_main_window(
871                    #         PlaylistBrowserWindow(
872                    #             sessiontype=bascenev1.DualTeamSession,
873                    #             transition=None,
874                    #         ),
875                    #         from_window=False,  # Disable check here.
876                    #     )
877                    # elif main_menu_location == 'Free-for-All Game Select':
878                    #     # pylint: disable=cyclic-import
879                    #     from bauiv1lib.playlist.browser import (
880                    #         PlaylistBrowserWindow,
881                    #     )
882
883                    #     app.ui_v1.set_main_window(
884                    #         PlaylistBrowserWindow(
885                    #             sessiontype=bascenev1.FreeForAllSession,
886                    #             transition=None,
887                    #         ),
888                    #         from_window=False,  # Disable check here.
889                    #     )
890                    # elif main_menu_location == 'Coop Select':
891                    #     # pylint: disable=cyclic-import
892                    #     from bauiv1lib.coop.browser import CoopBrowserWindow
893
894                    #     app.ui_v1.set_main_window(
895                    #         CoopBrowserWindow(transition=None),
896                    #         from_window=False,  # Disable check here.
897                    #     )
898                    # elif main_menu_location == 'Benchmarks & Stress Tests':
899                    #     # pylint: disable=cyclic-import
900                    #     from bauiv1lib.debug import DebugWindow
901
902                    #     app.ui_v1.set_main_window(
903                    #         DebugWindow(transition=None),
904                    #         from_window=False,  # Disable check here.
905                    #     )
906                    # else:
907                    # pylint: disable=cyclic-import
908                    from bauiv1lib.mainmenu import MainMenuWindow
909
910                    app.ui_v1.set_main_window(
911                        MainMenuWindow(transition=None),
912                        is_top_level=True,
913                        suppress_warning=True,
914                    )

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.

accounts
ads
ach
store
music
campaigns: dict[str, bascenev1.Campaign]
custom_coop_practice_games: list[str]
lobby_random_profile_index: int
lobby_random_char_index_offset
lobby_account_profile_device_id: int | None
tips: list[str]
stress_test_update_timer: _babase.AppTimer | None
stress_test_update_timer_2: _babase.AppTimer | None
value_test_defaults: dict
special_offer: dict | None
ping_thread_count
allow_ticket_purchases: bool
main_menu_did_initial_transition
main_menu_last_news_fetch_time: float | None
spaz_appearances: dict[str, bascenev1lib.actor.spazappearance.Appearance]
last_spaz_turbo_warn_time
server: baclassic._servermode.ServerController | None
log_have_new
log_upload_timer_started
printed_live_object_warning
input_map_hash: str | None
maps: dict[str, type[bascenev1.Map]]
teams_series_length
ffa_series_length
coop_session_args: dict
first_main_menu
did_menu_intro
main_menu_window_refresh_check_count
invite_confirm_windows: list[typing.Any]
party_window: weakref.ReferenceType[bauiv1lib.party.PartyWindow] | None
main_menu_resume_callbacks: list
store_layout: dict[str, list[dict[str, typing.Any]]] | None
store_items: dict[str, dict] | None
pro_sale_start_time: int | None
pro_sale_start_val: int | None
platform: str
133    @property
134    def platform(self) -> str:
135        """Name of the current platform.
136
137        Examples are: 'mac', 'windows', android'.
138        """
139        assert isinstance(self._env['platform'], str)
140        return self._env['platform']

Name of the current platform.

Examples are: 'mac', 'windows', android'.

subplatform: str
146    @property
147    def subplatform(self) -> str:
148        """String for subplatform.
149
150        Can be empty. For the 'android' platform, subplatform may
151        be 'google', 'amazon', etc.
152        """
153        assert isinstance(self._env['subplatform'], str)
154        return self._env['subplatform']

String for subplatform.

Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.

legacy_user_agent_string: str
156    @property
157    def legacy_user_agent_string(self) -> str:
158        """String containing various bits of info about OS/device/etc."""
159        assert isinstance(self._env['legacy_user_agent_string'], str)
160        return self._env['legacy_user_agent_string']

String containing various bits of info about OS/device/etc.

@override
def on_app_loading(self) -> None:
162    @override
163    def on_app_loading(self) -> None:
164        from bascenev1lib.actor import spazappearance
165        from bascenev1lib import maps as stdmaps
166
167        plus = babase.app.plus
168        assert plus is not None
169
170        env = babase.app.env
171        cfg = babase.app.config
172
173        self.music.on_app_loading()
174
175        # Non-test, non-debug builds should generally be blessed; warn if not.
176        # (so I don't accidentally release a build that can't play tourneys)
177        if not env.debug and not env.test and not plus.is_blessed():
178            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
179
180        # FIXME: This should not be hard-coded.
181        for maptype in [
182            stdmaps.HockeyStadium,
183            stdmaps.FootballStadium,
184            stdmaps.Bridgit,
185            stdmaps.BigG,
186            stdmaps.Roundabout,
187            stdmaps.MonkeyFace,
188            stdmaps.ZigZag,
189            stdmaps.ThePad,
190            stdmaps.DoomShroom,
191            stdmaps.LakeFrigid,
192            stdmaps.TipTop,
193            stdmaps.CragCastle,
194            stdmaps.TowerD,
195            stdmaps.HappyThoughts,
196            stdmaps.StepRightUp,
197            stdmaps.Courtyard,
198            stdmaps.Rampage,
199        ]:
200            bascenev1.register_map(maptype)
201
202        spazappearance.register_appearances()
203        bascenev1.init_campaigns()
204
205        launch_count = cfg.get('launchCount', 0)
206        launch_count += 1
207
208        # So we know how many times we've run the game at various
209        # version milestones.
210        for key in ('lc14173', 'lc14292'):
211            cfg.setdefault(key, launch_count)
212
213        cfg['launchCount'] = launch_count
214        cfg.commit()
215
216        # If there's a leftover log file, attempt to upload it to the
217        # master-server and/or get rid of it.
218        babase.handle_leftover_v1_cloud_log_file()
219
220        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.

@override
def on_app_suspend(self) -> None:
222    @override
223    def on_app_suspend(self) -> None:
224        self.accounts.on_app_suspend()

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
226    @override
227    def on_app_unsuspend(self) -> None:
228        self.accounts.on_app_unsuspend()
229        self.music.on_app_unsuspend()

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
231    @override
232    def on_app_shutdown(self) -> None:
233        self.music.on_app_shutdown()

Called when the app begins shutting down.

def pause(self) -> None:
235    def pause(self) -> None:
236        """Pause the game due to a user request or menu popping up.
237
238        If there's a foreground host-activity that says it's pausable, tell it
239        to pause. Note: we now no longer pause if there are connected clients.
240        """
241        activity: bascenev1.Activity | None = (
242            bascenev1.get_foreground_host_activity()
243        )
244        if (
245            activity is not None
246            and activity.allow_pausing
247            and not bascenev1.have_connected_clients()
248        ):
249            from babase import Lstr
250            from bascenev1 import NodeActor
251
252            # FIXME: Shouldn't be touching scene stuff here;
253            #  should just pass the request on to the host-session.
254            with activity.context:
255                globs = activity.globalsnode
256                if not globs.paused:
257                    bascenev1.getsound('refWhistle').play()
258                    globs.paused = True
259
260                # FIXME: This should not be an attr on Actor.
261                activity.paused_text = NodeActor(
262                    bascenev1.newnode(
263                        'text',
264                        attrs={
265                            'text': Lstr(resource='pausedByHostText'),
266                            'client_only': True,
267                            'flatness': 1.0,
268                            'h_align': 'center',
269                        },
270                    )
271                )

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.

def resume(self) -> None:
273    def resume(self) -> None:
274        """Resume the game due to a user request or menu closing.
275
276        If there's a foreground host-activity that's currently paused, tell it
277        to resume.
278        """
279
280        # FIXME: Shouldn't be touching scene stuff here;
281        #  should just pass the request on to the host-session.
282        activity = bascenev1.get_foreground_host_activity()
283        if activity is not None:
284            with activity.context:
285                globs = activity.globalsnode
286                if globs.paused:
287                    bascenev1.getsound('refWhistle').play()
288                    globs.paused = False
289
290                    # FIXME: This should not be an actor attr.
291                    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.

def add_coop_practice_level(self, level: bascenev1.Level) -> None:
293    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
294        """Adds an individual level to the 'practice' section in Co-op."""
295
296        # Assign this level to our catch-all campaign.
297        self.campaigns['Challenges'].addlevel(level)
298
299        # Make note to add it to our challenges UI.
300        self.custom_coop_practice_games.append(f'Challenges:{level.name}')

Adds an individual level to the 'practice' section in Co-op.

def launch_coop_game(self, game: str, force: bool = False, args: dict | None = None) -> bool:
302    def launch_coop_game(
303        self, game: str, force: bool = False, args: dict | None = None
304    ) -> bool:
305        """High level way to launch a local co-op session."""
306        # pylint: disable=cyclic-import
307        from bauiv1lib.coop.level import CoopLevelLockedWindow
308
309        assert babase.app.classic is not None
310
311        if args is None:
312            args = {}
313        if game == '':
314            raise ValueError('empty game name')
315        campaignname, levelname = game.split(':')
316        campaign = babase.app.classic.getcampaign(campaignname)
317
318        # If this campaign is sequential, make sure we've completed the
319        # one before this.
320        if campaign.sequential and not force:
321            for level in campaign.levels:
322                if level.name == levelname:
323                    break
324                if not level.complete:
325                    CoopLevelLockedWindow(
326                        campaign.getlevel(levelname).displayname,
327                        campaign.getlevel(level.name).displayname,
328                    )
329                    return False
330
331        # Ok, we're good to go.
332        self.coop_session_args = {
333            'campaign': campaignname,
334            'level': levelname,
335        }
336        for arg_name, arg_val in list(args.items()):
337            self.coop_session_args[arg_name] = arg_val
338
339        def _fade_end() -> None:
340            from bascenev1 import CoopSession
341
342            try:
343                bascenev1.new_host_session(CoopSession)
344            except Exception:
345                logging.exception('Error creating coopsession after fade end.')
346                from bascenev1lib.mainmenu import MainMenuSession
347
348                bascenev1.new_host_session(MainMenuSession)
349
350        babase.fade_screen(False, endcall=_fade_end)
351        return True

High level way to launch a local co-op session.

def return_to_main_menu_session_gracefully(self, reset_ui: bool = True) -> None:
353    def return_to_main_menu_session_gracefully(
354        self, reset_ui: bool = True
355    ) -> None:
356        """Attempt to cleanly get back to the main menu."""
357        # pylint: disable=cyclic-import
358        from baclassic import _benchmark
359        from bascenev1lib.mainmenu import MainMenuSession
360
361        plus = babase.app.plus
362        assert plus is not None
363
364        if reset_ui:
365            babase.app.ui_v1.clear_main_window()
366
367        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
368            # It may be possible we're on the main menu but the screen is faded
369            # so fade back in.
370            babase.fade_screen(True)
371            return
372
373        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
374
375        # If we're in a host-session, tell them to end.
376        # This lets them tear themselves down gracefully.
377        host_session: bascenev1.Session | None = (
378            bascenev1.get_foreground_host_session()
379        )
380        if host_session is not None:
381            # Kick off a little transaction so we'll hopefully have all the
382            # latest account state when we get back to the menu.
383            plus.add_v1_account_transaction(
384                {'type': 'END_SESSION', 'sType': str(type(host_session))}
385            )
386            plus.run_v1_account_transactions()
387
388            host_session.end()
389
390        # Otherwise just force the issue.
391        else:
392            babase.pushcall(
393                babase.Call(bascenev1.new_host_session, MainMenuSession)
394            )

Attempt to cleanly get back to the main menu.

def getmaps(self, playtype: str) -> list[str]:
396    def getmaps(self, playtype: str) -> list[str]:
397        """Return a list of bascenev1.Map types supporting a playtype str.
398
399        Category: **Asset Functions**
400
401        Maps supporting a given playtype must provide a particular set of
402        features and lend themselves to a certain style of play.
403
404        Play Types:
405
406        'melee'
407          General fighting map.
408          Has one or more 'spawn' locations.
409
410        'team_flag'
411          For games such as Capture The Flag where each team spawns by a flag.
412          Has two or more 'spawn' locations, each with a corresponding 'flag'
413          location (based on index).
414
415        'single_flag'
416          For games such as King of the Hill or Keep Away where multiple teams
417          are fighting over a single flag.
418          Has two or more 'spawn' locations and 1 'flag_default' location.
419
420        'conquest'
421          For games such as Conquest where flags are spread throughout the map
422          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
423
424        'king_of_the_hill' - has 2+ 'spawn' locations,
425           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
426
427        'hockey'
428          For hockey games.
429          Has two 'goal' locations, corresponding 'spawn' locations, and one
430          'flag_default' location (for where puck spawns)
431
432        'football'
433          For football games.
434          Has two 'goal' locations, corresponding 'spawn' locations, and one
435          'flag_default' location (for where flag/ball/etc. spawns)
436
437        'race'
438          For racing games where players much touch each region in order.
439          Has two or more 'race_point' locations.
440        """
441        return sorted(
442            key
443            for key, val in self.maps.items()
444            if playtype in val.get_play_types()
445        )

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.

@classmethod
def json_prep(cls, data: Any) -> Any:
453    @classmethod
454    def json_prep(cls, data: Any) -> Any:
455        """Return a json-friendly version of the provided data.
456
457        This converts any tuples to lists and any bytes to strings
458        (interpreted as utf-8, ignoring errors). Logs errors (just once)
459        if any data is modified/discarded/unsupported.
460        """
461
462        if isinstance(data, dict):
463            return dict(
464                (cls.json_prep(key), cls.json_prep(value))
465                for key, value in list(data.items())
466            )
467        if isinstance(data, list):
468            return [cls.json_prep(element) for element in data]
469        if isinstance(data, tuple):
470            logging.exception('json_prep encountered tuple')
471            return [cls.json_prep(element) for element in data]
472        if isinstance(data, bytes):
473            try:
474                return data.decode(errors='ignore')
475            except Exception:
476                logging.exception('json_prep encountered utf-8 decode error')
477                return data.decode(errors='ignore')
478        if not isinstance(data, (str, float, bool, type(None), int)):
479            logging.exception(
480                'got unsupported type in json_prep: %s', type(data)
481            )
482        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.

def master_server_v1_get( self, request: str, data: dict[str, typing.Any], callback: Optional[Callable[[None | dict[str, Any]], NoneType]] = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
484    def master_server_v1_get(
485        self,
486        request: str,
487        data: dict[str, Any],
488        callback: MasterServerCallback | None = None,
489        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
490    ) -> None:
491        """Make a call to the master server via a http GET."""
492
493        MasterServerV1CallThread(
494            request, 'get', data, callback, response_type
495        ).start()

Make a call to the master server via a http GET.

def master_server_v1_post( self, request: str, data: dict[str, typing.Any], callback: Optional[Callable[[None | dict[str, Any]], NoneType]] = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
497    def master_server_v1_post(
498        self,
499        request: str,
500        data: dict[str, Any],
501        callback: MasterServerCallback | None = None,
502        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
503    ) -> None:
504        """Make a call to the master server via a http POST."""
505        MasterServerV1CallThread(
506            request, 'post', data, callback, response_type
507        ).start()

Make a call to the master server via a http POST.

def get_tournament_prize_strings(self, entry: dict[str, typing.Any]) -> list[str]:
509    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
510        """Given a tournament entry, return strings for its prize levels."""
511        from baclassic import _tournament
512
513        return _tournament.get_tournament_prize_strings(entry)

Given a tournament entry, return strings for its prize levels.

def getcampaign(self, name: str) -> bascenev1.Campaign:
515    def getcampaign(self, name: str) -> bascenev1.Campaign:
516        """Return a campaign by name."""
517        return self.campaigns[name]

Return a campaign by name.

def get_next_tip(self) -> str:
519    def get_next_tip(self) -> str:
520        """Returns the next tip to be displayed."""
521        if not self.tips:
522            for tip in get_all_tips():
523                self.tips.insert(random.randint(0, len(self.tips)), tip)
524        tip = self.tips.pop()
525        return tip

Returns the next tip to be displayed.

def run_gpu_benchmark(self) -> None:
527    def run_gpu_benchmark(self) -> None:
528        """Kick off a benchmark to test gpu speeds."""
529        from baclassic._benchmark import run_gpu_benchmark as run
530
531        run()

Kick off a benchmark to test gpu speeds.

def run_cpu_benchmark(self) -> None:
533    def run_cpu_benchmark(self) -> None:
534        """Kick off a benchmark to test cpu speeds."""
535        from baclassic._benchmark import run_cpu_benchmark as run
536
537        run()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
539    def run_media_reload_benchmark(self) -> None:
540        """Kick off a benchmark to test media reloading speeds."""
541        from baclassic._benchmark import run_media_reload_benchmark as run
542
543        run()

Kick off a benchmark to test media reloading speeds.

def run_stress_test( self, playlist_type: str = 'Random', playlist_name: str = '__default__', player_count: int = 8, round_duration: int = 30, attract_mode: bool = False) -> None:
545    def run_stress_test(
546        self,
547        playlist_type: str = 'Random',
548        playlist_name: str = '__default__',
549        player_count: int = 8,
550        round_duration: int = 30,
551        attract_mode: bool = False,
552    ) -> None:
553        """Run a stress test."""
554        from baclassic._benchmark import run_stress_test as run
555
556        run(
557            playlist_type=playlist_type,
558            playlist_name=playlist_name,
559            player_count=player_count,
560            round_duration=round_duration,
561            attract_mode=attract_mode,
562        )

Run a stress test.

def get_input_device_mapped_value( self, device: _bascenev1.InputDevice, name: str, default: bool = False) -> Any:
564    def get_input_device_mapped_value(
565        self,
566        device: bascenev1.InputDevice,
567        name: str,
568        default: bool = False,
569    ) -> Any:
570        """Return a mapped value for an input device.
571
572        This checks the user config and falls back to default values
573        where available.
574        """
575        return _input.get_input_device_mapped_value(
576            device.name, device.unique_identifier, name, default
577        )

Return a mapped value for an input device.

This checks the user config and falls back to default values where available.

def get_input_device_map_hash(self, inputdevice: _bascenev1.InputDevice) -> str:
579    def get_input_device_map_hash(
580        self, inputdevice: bascenev1.InputDevice
581    ) -> str:
582        """Given an input device, return hash based on its raw input values."""
583        del inputdevice  # unused currently
584        return _input.get_input_device_map_hash()

Given an input device, return hash based on its raw input values.

def get_input_device_config( self, inputdevice: _bascenev1.InputDevice, default: bool) -> tuple[dict, str]:
586    def get_input_device_config(
587        self, inputdevice: bascenev1.InputDevice, default: bool
588    ) -> tuple[dict, str]:
589        """Given an input device, return its config dict in the app config.
590
591        The dict will be created if it does not exist.
592        """
593        return _input.get_input_device_config(
594            inputdevice.name, inputdevice.unique_identifier, default
595        )

Given an input device, return its config dict in the app config.

The dict will be created if it does not exist.

def get_player_colors(self) -> list[tuple[float, float, float]]:
597    def get_player_colors(self) -> list[tuple[float, float, float]]:
598        """Return user-selectable player colors."""
599        return bascenev1.get_player_colors()

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
601    def get_player_profile_icon(self, profilename: str) -> str:
602        """Given a profile name, returns an icon string for it.
603
604        (non-account profiles only)
605        """
606        return bascenev1.get_player_profile_icon(profilename)

Given a profile name, returns an icon string for it.

(non-account profiles only)

def get_player_profile_colors( self, profilename: str | None, profiles: dict[str, dict[str, typing.Any]] | None = None) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
608    def get_player_profile_colors(
609        self,
610        profilename: str | None,
611        profiles: dict[str, dict[str, Any]] | None = None,
612    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
613        """Given a profile, return colors for them."""
614        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

def preload_map_preview_media(self) -> None:
765    def preload_map_preview_media(self) -> None:
766        """Preload media needed for map preview UIs.
767
768        Category: **Asset Functions**
769        """
770        try:
771            bauiv1.getmesh('level_select_button_opaque')
772            bauiv1.getmesh('level_select_button_transparent')
773            for maptype in list(self.maps.values()):
774                map_tex_name = maptype.get_preview_texture_name()
775                if map_tex_name is not None:
776                    bauiv1.gettexture(map_tex_name)
777        except Exception:
778            logging.exception('Error preloading map preview media.')

Preload media needed for map preview UIs.

Category: Asset Functions

def invoke_main_menu_ui(self) -> None:
821    def invoke_main_menu_ui(self) -> None:
822        """Bring up main menu ui."""
823        print('INVOKING MAIN MENU UI')
824
825        # Bring up the last place we were, or start at the main menu otherwise.
826        app = bauiv1.app
827        env = app.env
828        with bascenev1.ContextRef.empty():
829            # from bauiv1lib import specialoffer
830
831            assert app.classic is not None
832            if app.env.headless:
833                # UI stuff fails now in headless builds; avoid it.
834                pass
835            else:
836
837                # When coming back from a kiosk-mode game, jump to the
838                # kiosk start screen.
839                if env.demo or env.arcade:
840                    # pylint: disable=cyclic-import
841                    from bauiv1lib.kiosk import KioskWindow
842
843                    app.ui_v1.set_main_window(
844                        KioskWindow(), is_top_level=True, suppress_warning=True
845                    )
846                # ..or in normal cases go back to the main menu
847                else:
848                    # if main_menu_location == 'Gather':
849                    #     # pylint: disable=cyclic-import
850                    #     from bauiv1lib.gather import GatherWindow
851
852                    #     app.ui_v1.set_main_window(
853                    #         GatherWindow(transition=None),
854                    #         from_window=False,  # Disable check here.
855                    #     )
856                    # elif main_menu_location == 'Watch':
857                    #     # pylint: disable=cyclic-import
858                    #     from bauiv1lib.watch import WatchWindow
859
860                    #     app.ui_v1.set_main_window(
861                    #         WatchWindow(transition=None),
862                    #         from_window=False,  # Disable check here.
863                    #     )
864                    # elif main_menu_location == 'Team Game Select':
865                    #     # pylint: disable=cyclic-import
866                    #     from bauiv1lib.playlist.browser import (
867                    #         PlaylistBrowserWindow,
868                    #     )
869
870                    #     app.ui_v1.set_main_window(
871                    #         PlaylistBrowserWindow(
872                    #             sessiontype=bascenev1.DualTeamSession,
873                    #             transition=None,
874                    #         ),
875                    #         from_window=False,  # Disable check here.
876                    #     )
877                    # elif main_menu_location == 'Free-for-All Game Select':
878                    #     # pylint: disable=cyclic-import
879                    #     from bauiv1lib.playlist.browser import (
880                    #         PlaylistBrowserWindow,
881                    #     )
882
883                    #     app.ui_v1.set_main_window(
884                    #         PlaylistBrowserWindow(
885                    #             sessiontype=bascenev1.FreeForAllSession,
886                    #             transition=None,
887                    #         ),
888                    #         from_window=False,  # Disable check here.
889                    #     )
890                    # elif main_menu_location == 'Coop Select':
891                    #     # pylint: disable=cyclic-import
892                    #     from bauiv1lib.coop.browser import CoopBrowserWindow
893
894                    #     app.ui_v1.set_main_window(
895                    #         CoopBrowserWindow(transition=None),
896                    #         from_window=False,  # Disable check here.
897                    #     )
898                    # elif main_menu_location == 'Benchmarks & Stress Tests':
899                    #     # pylint: disable=cyclic-import
900                    #     from bauiv1lib.debug import DebugWindow
901
902                    #     app.ui_v1.set_main_window(
903                    #         DebugWindow(transition=None),
904                    #         from_window=False,  # Disable check here.
905                    #     )
906                    # else:
907                    # pylint: disable=cyclic-import
908                    from bauiv1lib.mainmenu import MainMenuWindow
909
910                    app.ui_v1.set_main_window(
911                        MainMenuWindow(transition=None),
912                        is_top_level=True,
913                        suppress_warning=True,
914                    )

Bring up main menu ui.

Inherited Members
babase._appsubsystem.AppSubsystem
on_app_running
on_app_shutdown_complete
do_apply_app_config
on_screen_change
reset
class ClassicAppSubsystem.MusicPlayMode(enum.Enum):
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

Inherited Members
baclassic._music.MusicPlayMode
REGULAR
TEST
enum.Enum
name
value
class Achievement:
 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        self._name = name
 662        self._icon_name = icon_name
 663        self._icon_color: Sequence[float] = list(icon_color) + [1]
 664        self._level_name = level_name
 665        self._completion_banner_slot: int | None = None
 666        self._award = award
 667        self._hard_mode_only = hard_mode_only
 668
 669    @property
 670    def name(self) -> str:
 671        """The name of this achievement."""
 672        return self._name
 673
 674    @property
 675    def level_name(self) -> str:
 676        """The name of the level this achievement applies to."""
 677        return self._level_name
 678
 679    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
 680        """Return the icon texture to display for this achievement"""
 681        return bauiv1.gettexture(
 682            self._icon_name if complete else 'achievementEmpty'
 683        )
 684
 685    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
 686        """Return the icon texture to display for this achievement"""
 687        return bascenev1.gettexture(
 688            self._icon_name if complete else 'achievementEmpty'
 689        )
 690
 691    def get_icon_color(self, complete: bool) -> Sequence[float]:
 692        """Return the color tint for this Achievement's icon."""
 693        if complete:
 694            return self._icon_color
 695        return 1.0, 1.0, 1.0, 0.6
 696
 697    @property
 698    def hard_mode_only(self) -> bool:
 699        """Whether this Achievement is only unlockable in hard-mode."""
 700        return self._hard_mode_only
 701
 702    @property
 703    def complete(self) -> bool:
 704        """Whether this Achievement is currently complete."""
 705        val: bool = self._getconfig()['Complete']
 706        assert isinstance(val, bool)
 707        return val
 708
 709    def announce_completion(self, sound: bool = True) -> None:
 710        """Kick off an announcement for this achievement's completion."""
 711
 712        app = babase.app
 713        plus = app.plus
 714        classic = app.classic
 715        if plus is None or classic is None:
 716            logging.warning('ach account_completion not available.')
 717            return
 718
 719        ach_ss = classic.ach
 720
 721        # Even though there are technically achievements when we're not
 722        # signed in, lets not show them (otherwise we tend to get
 723        # confusing 'controller connected' achievements popping up while
 724        # waiting to sign in which can be confusing).
 725        if plus.get_v1_account_state() != 'signed_in':
 726            return
 727
 728        # If we're being freshly complete, display/report it and whatnot.
 729        if (self, sound) not in ach_ss.achievements_to_display:
 730            ach_ss.achievements_to_display.append((self, sound))
 731
 732        # If there's no achievement display timer going, kick one off
 733        # (if one's already running it will pick this up before it dies).
 734
 735        # Need to check last time too; its possible our timer wasn't able to
 736        # clear itself if an activity died and took it down with it.
 737        if (
 738            ach_ss.achievement_display_timer is None
 739            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
 740        ) and bascenev1.getactivity(doraise=False) is not None:
 741            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
 742                1.0, _display_next_achievement, repeat=True
 743            )
 744
 745            # Show the first immediately.
 746            _display_next_achievement()
 747
 748    def set_complete(self, complete: bool = True) -> None:
 749        """Set an achievement's completed state.
 750
 751        note this only sets local state; use a transaction to
 752        actually award achievements.
 753        """
 754        config = self._getconfig()
 755        if complete != config['Complete']:
 756            config['Complete'] = complete
 757
 758    @property
 759    def display_name(self) -> babase.Lstr:
 760        """Return a babase.Lstr for this Achievement's name."""
 761        name: babase.Lstr | str
 762        try:
 763            if self._level_name != '':
 764                campaignname, campaign_level = self._level_name.split(':')
 765                classic = babase.app.classic
 766                assert classic is not None
 767                name = (
 768                    classic.getcampaign(campaignname)
 769                    .getlevel(campaign_level)
 770                    .displayname
 771                )
 772            else:
 773                name = ''
 774        except Exception:
 775            name = ''
 776            logging.exception('Error calcing achievement display-name.')
 777        return babase.Lstr(
 778            resource='achievements.' + self._name + '.name',
 779            subs=[('${LEVEL}', name)],
 780        )
 781
 782    @property
 783    def description(self) -> babase.Lstr:
 784        """Get a babase.Lstr for the Achievement's brief description."""
 785        if (
 786            'description'
 787            in babase.app.lang.get_resource('achievements')[self._name]
 788        ):
 789            return babase.Lstr(
 790                resource='achievements.' + self._name + '.description'
 791            )
 792        return babase.Lstr(
 793            resource='achievements.' + self._name + '.descriptionFull'
 794        )
 795
 796    @property
 797    def description_complete(self) -> babase.Lstr:
 798        """Get a babase.Lstr for the Achievement's description when complete."""
 799        if (
 800            'descriptionComplete'
 801            in babase.app.lang.get_resource('achievements')[self._name]
 802        ):
 803            return babase.Lstr(
 804                resource='achievements.' + self._name + '.descriptionComplete'
 805            )
 806        return babase.Lstr(
 807            resource='achievements.' + self._name + '.descriptionFullComplete'
 808        )
 809
 810    @property
 811    def description_full(self) -> babase.Lstr:
 812        """Get a babase.Lstr for the Achievement's full description."""
 813        return babase.Lstr(
 814            resource='achievements.' + self._name + '.descriptionFull',
 815            subs=[
 816                (
 817                    '${LEVEL}',
 818                    babase.Lstr(
 819                        translate=(
 820                            'coopLevelNames',
 821                            ACH_LEVEL_NAMES.get(self._name, '?'),
 822                        )
 823                    ),
 824                )
 825            ],
 826        )
 827
 828    @property
 829    def description_full_complete(self) -> babase.Lstr:
 830        """Get a babase.Lstr for the Achievement's full desc. when completed."""
 831        return babase.Lstr(
 832            resource='achievements.' + self._name + '.descriptionFullComplete',
 833            subs=[
 834                (
 835                    '${LEVEL}',
 836                    babase.Lstr(
 837                        translate=(
 838                            'coopLevelNames',
 839                            ACH_LEVEL_NAMES.get(self._name, '?'),
 840                        )
 841                    ),
 842                )
 843            ],
 844        )
 845
 846    def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
 847        """Get the ticket award value for this achievement."""
 848        plus = babase.app.plus
 849        if plus is None:
 850            return 0
 851        val: int = plus.get_v1_account_misc_read_val(
 852            'achAward.' + self._name, self._award
 853        ) * _get_ach_mult(include_pro_bonus)
 854        assert isinstance(val, int)
 855        return val
 856
 857    @property
 858    def power_ranking_value(self) -> int:
 859        """Get the power-ranking award value for this achievement."""
 860        plus = babase.app.plus
 861        if plus is None:
 862            return 0
 863        val: int = plus.get_v1_account_misc_read_val(
 864            'achLeaguePoints.' + self._name, self._award
 865        )
 866        assert isinstance(val, int)
 867        return val
 868
 869    def create_display(
 870        self,
 871        x: float,
 872        y: float,
 873        delay: float,
 874        outdelay: float | None = None,
 875        color: Sequence[float] | None = None,
 876        style: str = 'post_game',
 877    ) -> list[bascenev1.Actor]:
 878        """Create a display for the Achievement.
 879
 880        Shows the Achievement icon, name, and description.
 881        """
 882        # pylint: disable=cyclic-import
 883        from bascenev1 import CoopSession
 884        from bascenev1lib.actor.image import Image
 885        from bascenev1lib.actor.text import Text
 886
 887        # Yeah this needs cleaning up.
 888        if style == 'post_game':
 889            in_game_colors = False
 890            in_main_menu = False
 891            h_attach = Text.HAttach.CENTER
 892            v_attach = Text.VAttach.CENTER
 893            attach = Image.Attach.CENTER
 894        elif style == 'in_game':
 895            in_game_colors = True
 896            in_main_menu = False
 897            h_attach = Text.HAttach.LEFT
 898            v_attach = Text.VAttach.TOP
 899            attach = Image.Attach.TOP_LEFT
 900        elif style == 'news':
 901            in_game_colors = True
 902            in_main_menu = True
 903            h_attach = Text.HAttach.CENTER
 904            v_attach = Text.VAttach.TOP
 905            attach = Image.Attach.TOP_CENTER
 906        else:
 907            raise ValueError('invalid style "' + style + '"')
 908
 909        # Attempt to determine what campaign we're in
 910        # (so we know whether to show "hard mode only").
 911        if in_main_menu:
 912            hmo = False
 913        else:
 914            try:
 915                session = bascenev1.getsession()
 916                if isinstance(session, CoopSession):
 917                    campaign = session.campaign
 918                    assert campaign is not None
 919                    hmo = self._hard_mode_only and campaign.name == 'Easy'
 920                else:
 921                    hmo = False
 922            except Exception:
 923                logging.exception('Error determining campaign.')
 924                hmo = False
 925
 926        objs: list[bascenev1.Actor]
 927
 928        if in_game_colors:
 929            objs = []
 930            out_delay_fin = (delay + outdelay) if outdelay is not None else None
 931            if color is not None:
 932                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3])
 933                cl2 = color
 934            else:
 935                cl1 = (1.5, 1.5, 2, 1.0)
 936                cl2 = (0.8, 0.8, 1.0, 1.0)
 937
 938            if hmo:
 939                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
 940                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
 941
 942            objs.append(
 943                Image(
 944                    self.get_icon_texture(False),
 945                    host_only=True,
 946                    color=cl1,
 947                    position=(x - 25, y + 5),
 948                    attach=attach,
 949                    transition=Image.Transition.FADE_IN,
 950                    transition_delay=delay,
 951                    vr_depth=4,
 952                    transition_out_delay=out_delay_fin,
 953                    scale=(40, 40),
 954                ).autoretain()
 955            )
 956            txt = self.display_name
 957            txt_s = 0.85
 958            txt_max_w = 300
 959            objs.append(
 960                Text(
 961                    txt,
 962                    host_only=True,
 963                    maxwidth=txt_max_w,
 964                    position=(x, y + 2),
 965                    transition=Text.Transition.FADE_IN,
 966                    scale=txt_s,
 967                    flatness=0.6,
 968                    shadow=0.5,
 969                    h_attach=h_attach,
 970                    v_attach=v_attach,
 971                    color=cl2,
 972                    transition_delay=delay + 0.05,
 973                    transition_out_delay=out_delay_fin,
 974                ).autoretain()
 975            )
 976            txt2_s = 0.62
 977            txt2_max_w = 400
 978            objs.append(
 979                Text(
 980                    self.description_full if in_main_menu else self.description,
 981                    host_only=True,
 982                    maxwidth=txt2_max_w,
 983                    position=(x, y - 14),
 984                    transition=Text.Transition.FADE_IN,
 985                    vr_depth=-5,
 986                    h_attach=h_attach,
 987                    v_attach=v_attach,
 988                    scale=txt2_s,
 989                    flatness=1.0,
 990                    shadow=0.5,
 991                    color=cl2,
 992                    transition_delay=delay + 0.1,
 993                    transition_out_delay=out_delay_fin,
 994                ).autoretain()
 995            )
 996
 997            if hmo:
 998                txtactor = Text(
 999                    babase.Lstr(resource='difficultyHardOnlyText'),
1000                    host_only=True,
1001                    maxwidth=txt2_max_w * 0.7,
1002                    position=(x + 60, y + 5),
1003                    transition=Text.Transition.FADE_IN,
1004                    vr_depth=-5,
1005                    h_attach=h_attach,
1006                    v_attach=v_attach,
1007                    h_align=Text.HAlign.CENTER,
1008                    v_align=Text.VAlign.CENTER,
1009                    scale=txt_s * 0.8,
1010                    flatness=1.0,
1011                    shadow=0.5,
1012                    color=(1, 1, 0.6, 1),
1013                    transition_delay=delay + 0.1,
1014                    transition_out_delay=out_delay_fin,
1015                ).autoretain()
1016                txtactor.node.rotate = 10
1017                objs.append(txtactor)
1018
1019            # Ticket-award.
1020            award_x = -100
1021            objs.append(
1022                Text(
1023                    babase.charstr(babase.SpecialChar.TICKET),
1024                    host_only=True,
1025                    position=(x + award_x + 33, y + 7),
1026                    transition=Text.Transition.FADE_IN,
1027                    scale=1.5,
1028                    h_attach=h_attach,
1029                    v_attach=v_attach,
1030                    h_align=Text.HAlign.CENTER,
1031                    v_align=Text.VAlign.CENTER,
1032                    color=(1, 1, 1, 0.2 if hmo else 0.4),
1033                    transition_delay=delay + 0.05,
1034                    transition_out_delay=out_delay_fin,
1035                ).autoretain()
1036            )
1037            objs.append(
1038                Text(
1039                    '+' + str(self.get_award_ticket_value()),
1040                    host_only=True,
1041                    position=(x + award_x + 28, y + 16),
1042                    transition=Text.Transition.FADE_IN,
1043                    scale=0.7,
1044                    flatness=1,
1045                    h_attach=h_attach,
1046                    v_attach=v_attach,
1047                    h_align=Text.HAlign.CENTER,
1048                    v_align=Text.VAlign.CENTER,
1049                    color=cl2,
1050                    transition_delay=delay + 0.05,
1051                    transition_out_delay=out_delay_fin,
1052                ).autoretain()
1053            )
1054
1055        else:
1056            complete = self.complete
1057            objs = []
1058            c_icon = self.get_icon_color(complete)
1059            if hmo and not complete:
1060                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
1061            objs.append(
1062                Image(
1063                    self.get_icon_texture(complete),
1064                    host_only=True,
1065                    color=c_icon,
1066                    position=(x - 25, y + 5),
1067                    attach=attach,
1068                    vr_depth=4,
1069                    transition=Image.Transition.IN_RIGHT,
1070                    transition_delay=delay,
1071                    transition_out_delay=None,
1072                    scale=(40, 40),
1073                ).autoretain()
1074            )
1075            if complete:
1076                objs.append(
1077                    Image(
1078                        bascenev1.gettexture('achievementOutline'),
1079                        host_only=True,
1080                        mesh_transparent=bascenev1.getmesh(
1081                            'achievementOutline'
1082                        ),
1083                        color=(2, 1.4, 0.4, 1),
1084                        vr_depth=8,
1085                        position=(x - 25, y + 5),
1086                        attach=attach,
1087                        transition=Image.Transition.IN_RIGHT,
1088                        transition_delay=delay,
1089                        transition_out_delay=None,
1090                        scale=(40, 40),
1091                    ).autoretain()
1092                )
1093            else:
1094                if not complete:
1095                    award_x = -100
1096                    objs.append(
1097                        Text(
1098                            babase.charstr(babase.SpecialChar.TICKET),
1099                            host_only=True,
1100                            position=(x + award_x + 33, y + 7),
1101                            transition=Text.Transition.IN_RIGHT,
1102                            scale=1.5,
1103                            h_attach=h_attach,
1104                            v_attach=v_attach,
1105                            h_align=Text.HAlign.CENTER,
1106                            v_align=Text.VAlign.CENTER,
1107                            color=(1, 1, 1, (0.1 if hmo else 0.2)),
1108                            transition_delay=delay + 0.05,
1109                            transition_out_delay=None,
1110                        ).autoretain()
1111                    )
1112                    objs.append(
1113                        Text(
1114                            '+' + str(self.get_award_ticket_value()),
1115                            host_only=True,
1116                            position=(x + award_x + 28, y + 16),
1117                            transition=Text.Transition.IN_RIGHT,
1118                            scale=0.7,
1119                            flatness=1,
1120                            h_attach=h_attach,
1121                            v_attach=v_attach,
1122                            h_align=Text.HAlign.CENTER,
1123                            v_align=Text.VAlign.CENTER,
1124                            color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
1125                            transition_delay=delay + 0.05,
1126                            transition_out_delay=None,
1127                        ).autoretain()
1128                    )
1129
1130                    # Show 'hard-mode-only' only over incomplete achievements
1131                    # when that's the case.
1132                    if hmo:
1133                        txtactor = Text(
1134                            babase.Lstr(resource='difficultyHardOnlyText'),
1135                            host_only=True,
1136                            maxwidth=300 * 0.7,
1137                            position=(x + 60, y + 5),
1138                            transition=Text.Transition.FADE_IN,
1139                            vr_depth=-5,
1140                            h_attach=h_attach,
1141                            v_attach=v_attach,
1142                            h_align=Text.HAlign.CENTER,
1143                            v_align=Text.VAlign.CENTER,
1144                            scale=0.85 * 0.8,
1145                            flatness=1.0,
1146                            shadow=0.5,
1147                            color=(1, 1, 0.6, 1),
1148                            transition_delay=delay + 0.05,
1149                            transition_out_delay=None,
1150                        ).autoretain()
1151                        assert txtactor.node
1152                        txtactor.node.rotate = 10
1153                        objs.append(txtactor)
1154
1155            objs.append(
1156                Text(
1157                    self.display_name,
1158                    host_only=True,
1159                    maxwidth=300,
1160                    position=(x, y + 2),
1161                    transition=Text.Transition.IN_RIGHT,
1162                    scale=0.85,
1163                    flatness=0.6,
1164                    h_attach=h_attach,
1165                    v_attach=v_attach,
1166                    color=(
1167                        (0.8, 0.93, 0.8, 1.0)
1168                        if complete
1169                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1170                    ),
1171                    transition_delay=delay + 0.05,
1172                    transition_out_delay=None,
1173                ).autoretain()
1174            )
1175            objs.append(
1176                Text(
1177                    self.description_complete if complete else self.description,
1178                    host_only=True,
1179                    maxwidth=400,
1180                    position=(x, y - 14),
1181                    transition=Text.Transition.IN_RIGHT,
1182                    vr_depth=-5,
1183                    h_attach=h_attach,
1184                    v_attach=v_attach,
1185                    scale=0.62,
1186                    flatness=1.0,
1187                    color=(
1188                        (0.6, 0.6, 0.6, 1.0)
1189                        if complete
1190                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1191                    ),
1192                    transition_delay=delay + 0.1,
1193                    transition_out_delay=None,
1194                ).autoretain()
1195            )
1196        return objs
1197
1198    def _getconfig(self) -> dict[str, Any]:
1199        """
1200        Return the sub-dict in settings where this achievement's
1201        state is stored, creating it if need be.
1202        """
1203        val: dict[str, Any] = babase.app.config.setdefault(
1204            'Achievements', {}
1205        ).setdefault(self._name, {'Complete': False})
1206        assert isinstance(val, dict)
1207        return val
1208
1209    def _remove_banner_slot(self) -> None:
1210        classic = babase.app.classic
1211        assert classic is not None
1212        assert self._completion_banner_slot is not None
1213        classic.ach.achievement_completion_banner_slots.remove(
1214            self._completion_banner_slot
1215        )
1216        self._completion_banner_slot = None
1217
1218    def show_completion_banner(self, sound: bool = True) -> None:
1219        """Create the banner/sound for an acquired achievement announcement."""
1220        from bascenev1lib.actor.text import Text
1221        from bascenev1lib.actor.image import Image
1222
1223        app = babase.app
1224        assert app.classic is not None
1225        app.classic.ach.last_achievement_display_time = babase.apptime()
1226
1227        # Just piggy-back onto any current activity
1228        # (should we use the session instead?..)
1229        activity = bascenev1.getactivity(doraise=False)
1230
1231        # If this gets called while this achievement is occupying a slot
1232        # already, ignore it. (probably should never happen in real
1233        # life but whatevs).
1234        if self._completion_banner_slot is not None:
1235            return
1236
1237        if activity is None:
1238            print('show_completion_banner() called with no current activity!')
1239            return
1240
1241        if sound:
1242            bascenev1.getsound('achievement').play(host_only=True)
1243        else:
1244            bascenev1.timer(
1245                0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
1246            )
1247
1248        in_time = 0.300
1249        out_time = 3.5
1250
1251        base_vr_depth = 200
1252
1253        # Find the first free slot.
1254        i = 0
1255        while True:
1256            if i not in app.classic.ach.achievement_completion_banner_slots:
1257                app.classic.ach.achievement_completion_banner_slots.add(i)
1258                self._completion_banner_slot = i
1259
1260                # Remove us from that slot when we close.
1261                # Use an app-timer in an empty context so the removal
1262                # runs even if our activity/session dies.
1263                with babase.ContextRef.empty():
1264                    babase.apptimer(
1265                        in_time + out_time, self._remove_banner_slot
1266                    )
1267                break
1268            i += 1
1269        assert self._completion_banner_slot is not None
1270        y_offs = 110 * self._completion_banner_slot
1271        objs: list[bascenev1.Actor] = []
1272        obj = Image(
1273            bascenev1.gettexture('shadow'),
1274            position=(-30, 30 + y_offs),
1275            front=True,
1276            attach=Image.Attach.BOTTOM_CENTER,
1277            transition=Image.Transition.IN_BOTTOM,
1278            vr_depth=base_vr_depth - 100,
1279            transition_delay=in_time,
1280            transition_out_delay=out_time,
1281            color=(0.0, 0.1, 0, 1),
1282            scale=(1000, 300),
1283        ).autoretain()
1284        objs.append(obj)
1285        assert obj.node
1286        obj.node.host_only = True
1287        obj = Image(
1288            bascenev1.gettexture('light'),
1289            position=(-180, 60 + y_offs),
1290            front=True,
1291            attach=Image.Attach.BOTTOM_CENTER,
1292            vr_depth=base_vr_depth,
1293            transition=Image.Transition.IN_BOTTOM,
1294            transition_delay=in_time,
1295            transition_out_delay=out_time,
1296            color=(1.8, 1.8, 1.0, 0.0),
1297            scale=(40, 300),
1298        ).autoretain()
1299        objs.append(obj)
1300        assert obj.node
1301        obj.node.host_only = True
1302        obj.node.premultiplied = True
1303        combine = bascenev1.newnode(
1304            'combine', owner=obj.node, attrs={'size': 2}
1305        )
1306        bascenev1.animate(
1307            combine,
1308            'input0',
1309            {
1310                in_time: 0,
1311                in_time + 0.4: 30,
1312                in_time + 0.5: 40,
1313                in_time + 0.6: 30,
1314                in_time + 2.0: 0,
1315            },
1316        )
1317        bascenev1.animate(
1318            combine,
1319            'input1',
1320            {
1321                in_time: 0,
1322                in_time + 0.4: 200,
1323                in_time + 0.5: 500,
1324                in_time + 0.6: 200,
1325                in_time + 2.0: 0,
1326            },
1327        )
1328        combine.connectattr('output', obj.node, 'scale')
1329        bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
1330        obj = Image(
1331            self.get_icon_texture(True),
1332            position=(-180, 60 + y_offs),
1333            attach=Image.Attach.BOTTOM_CENTER,
1334            front=True,
1335            vr_depth=base_vr_depth - 10,
1336            transition=Image.Transition.IN_BOTTOM,
1337            transition_delay=in_time,
1338            transition_out_delay=out_time,
1339            scale=(100, 100),
1340        ).autoretain()
1341        objs.append(obj)
1342        assert obj.node
1343        obj.node.host_only = True
1344
1345        # Flash.
1346        color = self.get_icon_color(True)
1347        combine = bascenev1.newnode(
1348            'combine', owner=obj.node, attrs={'size': 3}
1349        )
1350        keys = {
1351            in_time: 1.0 * color[0],
1352            in_time + 0.4: 1.5 * color[0],
1353            in_time + 0.5: 6.0 * color[0],
1354            in_time + 0.6: 1.5 * color[0],
1355            in_time + 2.0: 1.0 * color[0],
1356        }
1357        bascenev1.animate(combine, 'input0', keys)
1358        keys = {
1359            in_time: 1.0 * color[1],
1360            in_time + 0.4: 1.5 * color[1],
1361            in_time + 0.5: 6.0 * color[1],
1362            in_time + 0.6: 1.5 * color[1],
1363            in_time + 2.0: 1.0 * color[1],
1364        }
1365        bascenev1.animate(combine, 'input1', keys)
1366        keys = {
1367            in_time: 1.0 * color[2],
1368            in_time + 0.4: 1.5 * color[2],
1369            in_time + 0.5: 6.0 * color[2],
1370            in_time + 0.6: 1.5 * color[2],
1371            in_time + 2.0: 1.0 * color[2],
1372        }
1373        bascenev1.animate(combine, 'input2', keys)
1374        combine.connectattr('output', obj.node, 'color')
1375
1376        obj = Image(
1377            bascenev1.gettexture('achievementOutline'),
1378            mesh_transparent=bascenev1.getmesh('achievementOutline'),
1379            position=(-180, 60 + y_offs),
1380            front=True,
1381            attach=Image.Attach.BOTTOM_CENTER,
1382            vr_depth=base_vr_depth,
1383            transition=Image.Transition.IN_BOTTOM,
1384            transition_delay=in_time,
1385            transition_out_delay=out_time,
1386            scale=(100, 100),
1387        ).autoretain()
1388        assert obj.node
1389        obj.node.host_only = True
1390
1391        # Flash.
1392        color = (2, 1.4, 0.4, 1)
1393        combine = bascenev1.newnode(
1394            'combine', owner=obj.node, attrs={'size': 3}
1395        )
1396        keys = {
1397            in_time: 1.0 * color[0],
1398            in_time + 0.4: 1.5 * color[0],
1399            in_time + 0.5: 6.0 * color[0],
1400            in_time + 0.6: 1.5 * color[0],
1401            in_time + 2.0: 1.0 * color[0],
1402        }
1403        bascenev1.animate(combine, 'input0', keys)
1404        keys = {
1405            in_time: 1.0 * color[1],
1406            in_time + 0.4: 1.5 * color[1],
1407            in_time + 0.5: 6.0 * color[1],
1408            in_time + 0.6: 1.5 * color[1],
1409            in_time + 2.0: 1.0 * color[1],
1410        }
1411        bascenev1.animate(combine, 'input1', keys)
1412        keys = {
1413            in_time: 1.0 * color[2],
1414            in_time + 0.4: 1.5 * color[2],
1415            in_time + 0.5: 6.0 * color[2],
1416            in_time + 0.6: 1.5 * color[2],
1417            in_time + 2.0: 1.0 * color[2],
1418        }
1419        bascenev1.animate(combine, 'input2', keys)
1420        combine.connectattr('output', obj.node, 'color')
1421        objs.append(obj)
1422
1423        objt = Text(
1424            babase.Lstr(
1425                value='${A}:',
1426                subs=[('${A}', babase.Lstr(resource='achievementText'))],
1427            ),
1428            position=(-120, 91 + y_offs),
1429            front=True,
1430            v_attach=Text.VAttach.BOTTOM,
1431            vr_depth=base_vr_depth - 10,
1432            transition=Text.Transition.IN_BOTTOM,
1433            flatness=0.5,
1434            transition_delay=in_time,
1435            transition_out_delay=out_time,
1436            color=(1, 1, 1, 0.8),
1437            scale=0.65,
1438        ).autoretain()
1439        objs.append(objt)
1440        assert objt.node
1441        objt.node.host_only = True
1442
1443        objt = Text(
1444            self.display_name,
1445            position=(-120, 50 + y_offs),
1446            front=True,
1447            v_attach=Text.VAttach.BOTTOM,
1448            transition=Text.Transition.IN_BOTTOM,
1449            vr_depth=base_vr_depth,
1450            flatness=0.5,
1451            transition_delay=in_time,
1452            transition_out_delay=out_time,
1453            flash=True,
1454            color=(1, 0.8, 0, 1.0),
1455            scale=1.5,
1456        ).autoretain()
1457        objs.append(objt)
1458        assert objt.node
1459        objt.node.host_only = True
1460
1461        objt = Text(
1462            babase.charstr(babase.SpecialChar.TICKET),
1463            position=(-120 - 170 + 5, 75 + y_offs - 20),
1464            front=True,
1465            v_attach=Text.VAttach.BOTTOM,
1466            h_align=Text.HAlign.CENTER,
1467            v_align=Text.VAlign.CENTER,
1468            transition=Text.Transition.IN_BOTTOM,
1469            vr_depth=base_vr_depth,
1470            transition_delay=in_time,
1471            transition_out_delay=out_time,
1472            flash=True,
1473            color=(0.5, 0.5, 0.5, 1),
1474            scale=3.0,
1475        ).autoretain()
1476        objs.append(objt)
1477        assert objt.node
1478        objt.node.host_only = True
1479
1480        objt = Text(
1481            '+' + str(self.get_award_ticket_value()),
1482            position=(-120 - 180 + 5, 80 + y_offs - 20),
1483            v_attach=Text.VAttach.BOTTOM,
1484            front=True,
1485            h_align=Text.HAlign.CENTER,
1486            v_align=Text.VAlign.CENTER,
1487            transition=Text.Transition.IN_BOTTOM,
1488            vr_depth=base_vr_depth,
1489            flatness=0.5,
1490            shadow=1.0,
1491            transition_delay=in_time,
1492            transition_out_delay=out_time,
1493            flash=True,
1494            color=(0, 1, 0, 1),
1495            scale=1.5,
1496        ).autoretain()
1497        objs.append(objt)
1498        assert objt.node
1499        objt.node.host_only = True
1500
1501        # Add the 'x 2' if we've got pro.
1502        if app.classic.accounts.have_pro():
1503            objt = Text(
1504                'x 2',
1505                position=(-120 - 180 + 45, 80 + y_offs - 50),
1506                v_attach=Text.VAttach.BOTTOM,
1507                front=True,
1508                h_align=Text.HAlign.CENTER,
1509                v_align=Text.VAlign.CENTER,
1510                transition=Text.Transition.IN_BOTTOM,
1511                vr_depth=base_vr_depth,
1512                flatness=0.5,
1513                shadow=1.0,
1514                transition_delay=in_time,
1515                transition_out_delay=out_time,
1516                flash=True,
1517                color=(0.4, 0, 1, 1),
1518                scale=0.9,
1519            ).autoretain()
1520            objs.append(objt)
1521            assert objt.node
1522            objt.node.host_only = True
1523
1524        objt = Text(
1525            self.description_complete,
1526            position=(-120, 30 + y_offs),
1527            front=True,
1528            v_attach=Text.VAttach.BOTTOM,
1529            transition=Text.Transition.IN_BOTTOM,
1530            vr_depth=base_vr_depth - 10,
1531            flatness=0.5,
1532            transition_delay=in_time,
1533            transition_out_delay=out_time,
1534            color=(1.0, 0.7, 0.5, 1.0),
1535            scale=0.8,
1536        ).autoretain()
1537        objs.append(objt)
1538        assert objt.node
1539        objt.node.host_only = True
1540
1541        for actor in objs:
1542            bascenev1.timer(
1543                out_time + 1.000,
1544                babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()),
1545            )

Represents attributes and state for an individual achievement.

Category: App Classes

Achievement( name: str, icon_name: str, icon_color: Sequence[float], level_name: str, award: int, hard_mode_only: bool = False)
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        self._name = name
662        self._icon_name = icon_name
663        self._icon_color: Sequence[float] = list(icon_color) + [1]
664        self._level_name = level_name
665        self._completion_banner_slot: int | None = None
666        self._award = award
667        self._hard_mode_only = hard_mode_only
name: str
669    @property
670    def name(self) -> str:
671        """The name of this achievement."""
672        return self._name

The name of this achievement.

level_name: str
674    @property
675    def level_name(self) -> str:
676        """The name of the level this achievement applies to."""
677        return self._level_name

The name of the level this achievement applies to.

def get_icon_ui_texture(self, complete: bool) -> _bauiv1.Texture:
679    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
680        """Return the icon texture to display for this achievement"""
681        return bauiv1.gettexture(
682            self._icon_name if complete else 'achievementEmpty'
683        )

Return the icon texture to display for this achievement

def get_icon_texture(self, complete: bool) -> _bascenev1.Texture:
685    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
686        """Return the icon texture to display for this achievement"""
687        return bascenev1.gettexture(
688            self._icon_name if complete else 'achievementEmpty'
689        )

Return the icon texture to display for this achievement

def get_icon_color(self, complete: bool) -> Sequence[float]:
691    def get_icon_color(self, complete: bool) -> Sequence[float]:
692        """Return the color tint for this Achievement's icon."""
693        if complete:
694            return self._icon_color
695        return 1.0, 1.0, 1.0, 0.6

Return the color tint for this Achievement's icon.

hard_mode_only: bool
697    @property
698    def hard_mode_only(self) -> bool:
699        """Whether this Achievement is only unlockable in hard-mode."""
700        return self._hard_mode_only

Whether this Achievement is only unlockable in hard-mode.

complete: bool
702    @property
703    def complete(self) -> bool:
704        """Whether this Achievement is currently complete."""
705        val: bool = self._getconfig()['Complete']
706        assert isinstance(val, bool)
707        return val

Whether this Achievement is currently complete.

def announce_completion(self, sound: bool = True) -> None:
709    def announce_completion(self, sound: bool = True) -> None:
710        """Kick off an announcement for this achievement's completion."""
711
712        app = babase.app
713        plus = app.plus
714        classic = app.classic
715        if plus is None or classic is None:
716            logging.warning('ach account_completion not available.')
717            return
718
719        ach_ss = classic.ach
720
721        # Even though there are technically achievements when we're not
722        # signed in, lets not show them (otherwise we tend to get
723        # confusing 'controller connected' achievements popping up while
724        # waiting to sign in which can be confusing).
725        if plus.get_v1_account_state() != 'signed_in':
726            return
727
728        # If we're being freshly complete, display/report it and whatnot.
729        if (self, sound) not in ach_ss.achievements_to_display:
730            ach_ss.achievements_to_display.append((self, sound))
731
732        # If there's no achievement display timer going, kick one off
733        # (if one's already running it will pick this up before it dies).
734
735        # Need to check last time too; its possible our timer wasn't able to
736        # clear itself if an activity died and took it down with it.
737        if (
738            ach_ss.achievement_display_timer is None
739            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
740        ) and bascenev1.getactivity(doraise=False) is not None:
741            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
742                1.0, _display_next_achievement, repeat=True
743            )
744
745            # Show the first immediately.
746            _display_next_achievement()

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
748    def set_complete(self, complete: bool = True) -> None:
749        """Set an achievement's completed state.
750
751        note this only sets local state; use a transaction to
752        actually award achievements.
753        """
754        config = self._getconfig()
755        if complete != config['Complete']:
756            config['Complete'] = complete

Set an achievement's completed state.

note this only sets local state; use a transaction to actually award achievements.

display_name: babase.Lstr
758    @property
759    def display_name(self) -> babase.Lstr:
760        """Return a babase.Lstr for this Achievement's name."""
761        name: babase.Lstr | str
762        try:
763            if self._level_name != '':
764                campaignname, campaign_level = self._level_name.split(':')
765                classic = babase.app.classic
766                assert classic is not None
767                name = (
768                    classic.getcampaign(campaignname)
769                    .getlevel(campaign_level)
770                    .displayname
771                )
772            else:
773                name = ''
774        except Exception:
775            name = ''
776            logging.exception('Error calcing achievement display-name.')
777        return babase.Lstr(
778            resource='achievements.' + self._name + '.name',
779            subs=[('${LEVEL}', name)],
780        )

Return a babase.Lstr for this Achievement's name.

description: babase.Lstr
782    @property
783    def description(self) -> babase.Lstr:
784        """Get a babase.Lstr for the Achievement's brief description."""
785        if (
786            'description'
787            in babase.app.lang.get_resource('achievements')[self._name]
788        ):
789            return babase.Lstr(
790                resource='achievements.' + self._name + '.description'
791            )
792        return babase.Lstr(
793            resource='achievements.' + self._name + '.descriptionFull'
794        )

Get a babase.Lstr for the Achievement's brief description.

description_complete: babase.Lstr
796    @property
797    def description_complete(self) -> babase.Lstr:
798        """Get a babase.Lstr for the Achievement's description when complete."""
799        if (
800            'descriptionComplete'
801            in babase.app.lang.get_resource('achievements')[self._name]
802        ):
803            return babase.Lstr(
804                resource='achievements.' + self._name + '.descriptionComplete'
805            )
806        return babase.Lstr(
807            resource='achievements.' + self._name + '.descriptionFullComplete'
808        )

Get a babase.Lstr for the Achievement's description when complete.

description_full: babase.Lstr
810    @property
811    def description_full(self) -> babase.Lstr:
812        """Get a babase.Lstr for the Achievement's full description."""
813        return babase.Lstr(
814            resource='achievements.' + self._name + '.descriptionFull',
815            subs=[
816                (
817                    '${LEVEL}',
818                    babase.Lstr(
819                        translate=(
820                            'coopLevelNames',
821                            ACH_LEVEL_NAMES.get(self._name, '?'),
822                        )
823                    ),
824                )
825            ],
826        )

Get a babase.Lstr for the Achievement's full description.

description_full_complete: babase.Lstr
828    @property
829    def description_full_complete(self) -> babase.Lstr:
830        """Get a babase.Lstr for the Achievement's full desc. when completed."""
831        return babase.Lstr(
832            resource='achievements.' + self._name + '.descriptionFullComplete',
833            subs=[
834                (
835                    '${LEVEL}',
836                    babase.Lstr(
837                        translate=(
838                            'coopLevelNames',
839                            ACH_LEVEL_NAMES.get(self._name, '?'),
840                        )
841                    ),
842                )
843            ],
844        )

Get a babase.Lstr for the Achievement's full desc. when completed.

def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
846    def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
847        """Get the ticket award value for this achievement."""
848        plus = babase.app.plus
849        if plus is None:
850            return 0
851        val: int = plus.get_v1_account_misc_read_val(
852            'achAward.' + self._name, self._award
853        ) * _get_ach_mult(include_pro_bonus)
854        assert isinstance(val, int)
855        return val

Get the ticket award value for this achievement.

power_ranking_value: int
857    @property
858    def power_ranking_value(self) -> int:
859        """Get the power-ranking award value for this achievement."""
860        plus = babase.app.plus
861        if plus is None:
862            return 0
863        val: int = plus.get_v1_account_misc_read_val(
864            'achLeaguePoints.' + self._name, self._award
865        )
866        assert isinstance(val, int)
867        return val

Get the power-ranking award value for this achievement.

def create_display( self, x: float, y: float, delay: float, outdelay: float | None = None, color: Optional[Sequence[float]] = None, style: str = 'post_game') -> list[bascenev1.Actor]:
 869    def create_display(
 870        self,
 871        x: float,
 872        y: float,
 873        delay: float,
 874        outdelay: float | None = None,
 875        color: Sequence[float] | None = None,
 876        style: str = 'post_game',
 877    ) -> list[bascenev1.Actor]:
 878        """Create a display for the Achievement.
 879
 880        Shows the Achievement icon, name, and description.
 881        """
 882        # pylint: disable=cyclic-import
 883        from bascenev1 import CoopSession
 884        from bascenev1lib.actor.image import Image
 885        from bascenev1lib.actor.text import Text
 886
 887        # Yeah this needs cleaning up.
 888        if style == 'post_game':
 889            in_game_colors = False
 890            in_main_menu = False
 891            h_attach = Text.HAttach.CENTER
 892            v_attach = Text.VAttach.CENTER
 893            attach = Image.Attach.CENTER
 894        elif style == 'in_game':
 895            in_game_colors = True
 896            in_main_menu = False
 897            h_attach = Text.HAttach.LEFT
 898            v_attach = Text.VAttach.TOP
 899            attach = Image.Attach.TOP_LEFT
 900        elif style == 'news':
 901            in_game_colors = True
 902            in_main_menu = True
 903            h_attach = Text.HAttach.CENTER
 904            v_attach = Text.VAttach.TOP
 905            attach = Image.Attach.TOP_CENTER
 906        else:
 907            raise ValueError('invalid style "' + style + '"')
 908
 909        # Attempt to determine what campaign we're in
 910        # (so we know whether to show "hard mode only").
 911        if in_main_menu:
 912            hmo = False
 913        else:
 914            try:
 915                session = bascenev1.getsession()
 916                if isinstance(session, CoopSession):
 917                    campaign = session.campaign
 918                    assert campaign is not None
 919                    hmo = self._hard_mode_only and campaign.name == 'Easy'
 920                else:
 921                    hmo = False
 922            except Exception:
 923                logging.exception('Error determining campaign.')
 924                hmo = False
 925
 926        objs: list[bascenev1.Actor]
 927
 928        if in_game_colors:
 929            objs = []
 930            out_delay_fin = (delay + outdelay) if outdelay is not None else None
 931            if color is not None:
 932                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3])
 933                cl2 = color
 934            else:
 935                cl1 = (1.5, 1.5, 2, 1.0)
 936                cl2 = (0.8, 0.8, 1.0, 1.0)
 937
 938            if hmo:
 939                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
 940                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
 941
 942            objs.append(
 943                Image(
 944                    self.get_icon_texture(False),
 945                    host_only=True,
 946                    color=cl1,
 947                    position=(x - 25, y + 5),
 948                    attach=attach,
 949                    transition=Image.Transition.FADE_IN,
 950                    transition_delay=delay,
 951                    vr_depth=4,
 952                    transition_out_delay=out_delay_fin,
 953                    scale=(40, 40),
 954                ).autoretain()
 955            )
 956            txt = self.display_name
 957            txt_s = 0.85
 958            txt_max_w = 300
 959            objs.append(
 960                Text(
 961                    txt,
 962                    host_only=True,
 963                    maxwidth=txt_max_w,
 964                    position=(x, y + 2),
 965                    transition=Text.Transition.FADE_IN,
 966                    scale=txt_s,
 967                    flatness=0.6,
 968                    shadow=0.5,
 969                    h_attach=h_attach,
 970                    v_attach=v_attach,
 971                    color=cl2,
 972                    transition_delay=delay + 0.05,
 973                    transition_out_delay=out_delay_fin,
 974                ).autoretain()
 975            )
 976            txt2_s = 0.62
 977            txt2_max_w = 400
 978            objs.append(
 979                Text(
 980                    self.description_full if in_main_menu else self.description,
 981                    host_only=True,
 982                    maxwidth=txt2_max_w,
 983                    position=(x, y - 14),
 984                    transition=Text.Transition.FADE_IN,
 985                    vr_depth=-5,
 986                    h_attach=h_attach,
 987                    v_attach=v_attach,
 988                    scale=txt2_s,
 989                    flatness=1.0,
 990                    shadow=0.5,
 991                    color=cl2,
 992                    transition_delay=delay + 0.1,
 993                    transition_out_delay=out_delay_fin,
 994                ).autoretain()
 995            )
 996
 997            if hmo:
 998                txtactor = Text(
 999                    babase.Lstr(resource='difficultyHardOnlyText'),
1000                    host_only=True,
1001                    maxwidth=txt2_max_w * 0.7,
1002                    position=(x + 60, y + 5),
1003                    transition=Text.Transition.FADE_IN,
1004                    vr_depth=-5,
1005                    h_attach=h_attach,
1006                    v_attach=v_attach,
1007                    h_align=Text.HAlign.CENTER,
1008                    v_align=Text.VAlign.CENTER,
1009                    scale=txt_s * 0.8,
1010                    flatness=1.0,
1011                    shadow=0.5,
1012                    color=(1, 1, 0.6, 1),
1013                    transition_delay=delay + 0.1,
1014                    transition_out_delay=out_delay_fin,
1015                ).autoretain()
1016                txtactor.node.rotate = 10
1017                objs.append(txtactor)
1018
1019            # Ticket-award.
1020            award_x = -100
1021            objs.append(
1022                Text(
1023                    babase.charstr(babase.SpecialChar.TICKET),
1024                    host_only=True,
1025                    position=(x + award_x + 33, y + 7),
1026                    transition=Text.Transition.FADE_IN,
1027                    scale=1.5,
1028                    h_attach=h_attach,
1029                    v_attach=v_attach,
1030                    h_align=Text.HAlign.CENTER,
1031                    v_align=Text.VAlign.CENTER,
1032                    color=(1, 1, 1, 0.2 if hmo else 0.4),
1033                    transition_delay=delay + 0.05,
1034                    transition_out_delay=out_delay_fin,
1035                ).autoretain()
1036            )
1037            objs.append(
1038                Text(
1039                    '+' + str(self.get_award_ticket_value()),
1040                    host_only=True,
1041                    position=(x + award_x + 28, y + 16),
1042                    transition=Text.Transition.FADE_IN,
1043                    scale=0.7,
1044                    flatness=1,
1045                    h_attach=h_attach,
1046                    v_attach=v_attach,
1047                    h_align=Text.HAlign.CENTER,
1048                    v_align=Text.VAlign.CENTER,
1049                    color=cl2,
1050                    transition_delay=delay + 0.05,
1051                    transition_out_delay=out_delay_fin,
1052                ).autoretain()
1053            )
1054
1055        else:
1056            complete = self.complete
1057            objs = []
1058            c_icon = self.get_icon_color(complete)
1059            if hmo and not complete:
1060                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
1061            objs.append(
1062                Image(
1063                    self.get_icon_texture(complete),
1064                    host_only=True,
1065                    color=c_icon,
1066                    position=(x - 25, y + 5),
1067                    attach=attach,
1068                    vr_depth=4,
1069                    transition=Image.Transition.IN_RIGHT,
1070                    transition_delay=delay,
1071                    transition_out_delay=None,
1072                    scale=(40, 40),
1073                ).autoretain()
1074            )
1075            if complete:
1076                objs.append(
1077                    Image(
1078                        bascenev1.gettexture('achievementOutline'),
1079                        host_only=True,
1080                        mesh_transparent=bascenev1.getmesh(
1081                            'achievementOutline'
1082                        ),
1083                        color=(2, 1.4, 0.4, 1),
1084                        vr_depth=8,
1085                        position=(x - 25, y + 5),
1086                        attach=attach,
1087                        transition=Image.Transition.IN_RIGHT,
1088                        transition_delay=delay,
1089                        transition_out_delay=None,
1090                        scale=(40, 40),
1091                    ).autoretain()
1092                )
1093            else:
1094                if not complete:
1095                    award_x = -100
1096                    objs.append(
1097                        Text(
1098                            babase.charstr(babase.SpecialChar.TICKET),
1099                            host_only=True,
1100                            position=(x + award_x + 33, y + 7),
1101                            transition=Text.Transition.IN_RIGHT,
1102                            scale=1.5,
1103                            h_attach=h_attach,
1104                            v_attach=v_attach,
1105                            h_align=Text.HAlign.CENTER,
1106                            v_align=Text.VAlign.CENTER,
1107                            color=(1, 1, 1, (0.1 if hmo else 0.2)),
1108                            transition_delay=delay + 0.05,
1109                            transition_out_delay=None,
1110                        ).autoretain()
1111                    )
1112                    objs.append(
1113                        Text(
1114                            '+' + str(self.get_award_ticket_value()),
1115                            host_only=True,
1116                            position=(x + award_x + 28, y + 16),
1117                            transition=Text.Transition.IN_RIGHT,
1118                            scale=0.7,
1119                            flatness=1,
1120                            h_attach=h_attach,
1121                            v_attach=v_attach,
1122                            h_align=Text.HAlign.CENTER,
1123                            v_align=Text.VAlign.CENTER,
1124                            color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
1125                            transition_delay=delay + 0.05,
1126                            transition_out_delay=None,
1127                        ).autoretain()
1128                    )
1129
1130                    # Show 'hard-mode-only' only over incomplete achievements
1131                    # when that's the case.
1132                    if hmo:
1133                        txtactor = Text(
1134                            babase.Lstr(resource='difficultyHardOnlyText'),
1135                            host_only=True,
1136                            maxwidth=300 * 0.7,
1137                            position=(x + 60, y + 5),
1138                            transition=Text.Transition.FADE_IN,
1139                            vr_depth=-5,
1140                            h_attach=h_attach,
1141                            v_attach=v_attach,
1142                            h_align=Text.HAlign.CENTER,
1143                            v_align=Text.VAlign.CENTER,
1144                            scale=0.85 * 0.8,
1145                            flatness=1.0,
1146                            shadow=0.5,
1147                            color=(1, 1, 0.6, 1),
1148                            transition_delay=delay + 0.05,
1149                            transition_out_delay=None,
1150                        ).autoretain()
1151                        assert txtactor.node
1152                        txtactor.node.rotate = 10
1153                        objs.append(txtactor)
1154
1155            objs.append(
1156                Text(
1157                    self.display_name,
1158                    host_only=True,
1159                    maxwidth=300,
1160                    position=(x, y + 2),
1161                    transition=Text.Transition.IN_RIGHT,
1162                    scale=0.85,
1163                    flatness=0.6,
1164                    h_attach=h_attach,
1165                    v_attach=v_attach,
1166                    color=(
1167                        (0.8, 0.93, 0.8, 1.0)
1168                        if complete
1169                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1170                    ),
1171                    transition_delay=delay + 0.05,
1172                    transition_out_delay=None,
1173                ).autoretain()
1174            )
1175            objs.append(
1176                Text(
1177                    self.description_complete if complete else self.description,
1178                    host_only=True,
1179                    maxwidth=400,
1180                    position=(x, y - 14),
1181                    transition=Text.Transition.IN_RIGHT,
1182                    vr_depth=-5,
1183                    h_attach=h_attach,
1184                    v_attach=v_attach,
1185                    scale=0.62,
1186                    flatness=1.0,
1187                    color=(
1188                        (0.6, 0.6, 0.6, 1.0)
1189                        if complete
1190                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1191                    ),
1192                    transition_delay=delay + 0.1,
1193                    transition_out_delay=None,
1194                ).autoretain()
1195            )
1196        return objs

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

def show_completion_banner(self, sound: bool = True) -> None:
1218    def show_completion_banner(self, sound: bool = True) -> None:
1219        """Create the banner/sound for an acquired achievement announcement."""
1220        from bascenev1lib.actor.text import Text
1221        from bascenev1lib.actor.image import Image
1222
1223        app = babase.app
1224        assert app.classic is not None
1225        app.classic.ach.last_achievement_display_time = babase.apptime()
1226
1227        # Just piggy-back onto any current activity
1228        # (should we use the session instead?..)
1229        activity = bascenev1.getactivity(doraise=False)
1230
1231        # If this gets called while this achievement is occupying a slot
1232        # already, ignore it. (probably should never happen in real
1233        # life but whatevs).
1234        if self._completion_banner_slot is not None:
1235            return
1236
1237        if activity is None:
1238            print('show_completion_banner() called with no current activity!')
1239            return
1240
1241        if sound:
1242            bascenev1.getsound('achievement').play(host_only=True)
1243        else:
1244            bascenev1.timer(
1245                0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
1246            )
1247
1248        in_time = 0.300
1249        out_time = 3.5
1250
1251        base_vr_depth = 200
1252
1253        # Find the first free slot.
1254        i = 0
1255        while True:
1256            if i not in app.classic.ach.achievement_completion_banner_slots:
1257                app.classic.ach.achievement_completion_banner_slots.add(i)
1258                self._completion_banner_slot = i
1259
1260                # Remove us from that slot when we close.
1261                # Use an app-timer in an empty context so the removal
1262                # runs even if our activity/session dies.
1263                with babase.ContextRef.empty():
1264                    babase.apptimer(
1265                        in_time + out_time, self._remove_banner_slot
1266                    )
1267                break
1268            i += 1
1269        assert self._completion_banner_slot is not None
1270        y_offs = 110 * self._completion_banner_slot
1271        objs: list[bascenev1.Actor] = []
1272        obj = Image(
1273            bascenev1.gettexture('shadow'),
1274            position=(-30, 30 + y_offs),
1275            front=True,
1276            attach=Image.Attach.BOTTOM_CENTER,
1277            transition=Image.Transition.IN_BOTTOM,
1278            vr_depth=base_vr_depth - 100,
1279            transition_delay=in_time,
1280            transition_out_delay=out_time,
1281            color=(0.0, 0.1, 0, 1),
1282            scale=(1000, 300),
1283        ).autoretain()
1284        objs.append(obj)
1285        assert obj.node
1286        obj.node.host_only = True
1287        obj = Image(
1288            bascenev1.gettexture('light'),
1289            position=(-180, 60 + y_offs),
1290            front=True,
1291            attach=Image.Attach.BOTTOM_CENTER,
1292            vr_depth=base_vr_depth,
1293            transition=Image.Transition.IN_BOTTOM,
1294            transition_delay=in_time,
1295            transition_out_delay=out_time,
1296            color=(1.8, 1.8, 1.0, 0.0),
1297            scale=(40, 300),
1298        ).autoretain()
1299        objs.append(obj)
1300        assert obj.node
1301        obj.node.host_only = True
1302        obj.node.premultiplied = True
1303        combine = bascenev1.newnode(
1304            'combine', owner=obj.node, attrs={'size': 2}
1305        )
1306        bascenev1.animate(
1307            combine,
1308            'input0',
1309            {
1310                in_time: 0,
1311                in_time + 0.4: 30,
1312                in_time + 0.5: 40,
1313                in_time + 0.6: 30,
1314                in_time + 2.0: 0,
1315            },
1316        )
1317        bascenev1.animate(
1318            combine,
1319            'input1',
1320            {
1321                in_time: 0,
1322                in_time + 0.4: 200,
1323                in_time + 0.5: 500,
1324                in_time + 0.6: 200,
1325                in_time + 2.0: 0,
1326            },
1327        )
1328        combine.connectattr('output', obj.node, 'scale')
1329        bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
1330        obj = Image(
1331            self.get_icon_texture(True),
1332            position=(-180, 60 + y_offs),
1333            attach=Image.Attach.BOTTOM_CENTER,
1334            front=True,
1335            vr_depth=base_vr_depth - 10,
1336            transition=Image.Transition.IN_BOTTOM,
1337            transition_delay=in_time,
1338            transition_out_delay=out_time,
1339            scale=(100, 100),
1340        ).autoretain()
1341        objs.append(obj)
1342        assert obj.node
1343        obj.node.host_only = True
1344
1345        # Flash.
1346        color = self.get_icon_color(True)
1347        combine = bascenev1.newnode(
1348            'combine', owner=obj.node, attrs={'size': 3}
1349        )
1350        keys = {
1351            in_time: 1.0 * color[0],
1352            in_time + 0.4: 1.5 * color[0],
1353            in_time + 0.5: 6.0 * color[0],
1354            in_time + 0.6: 1.5 * color[0],
1355            in_time + 2.0: 1.0 * color[0],
1356        }
1357        bascenev1.animate(combine, 'input0', keys)
1358        keys = {
1359            in_time: 1.0 * color[1],
1360            in_time + 0.4: 1.5 * color[1],
1361            in_time + 0.5: 6.0 * color[1],
1362            in_time + 0.6: 1.5 * color[1],
1363            in_time + 2.0: 1.0 * color[1],
1364        }
1365        bascenev1.animate(combine, 'input1', keys)
1366        keys = {
1367            in_time: 1.0 * color[2],
1368            in_time + 0.4: 1.5 * color[2],
1369            in_time + 0.5: 6.0 * color[2],
1370            in_time + 0.6: 1.5 * color[2],
1371            in_time + 2.0: 1.0 * color[2],
1372        }
1373        bascenev1.animate(combine, 'input2', keys)
1374        combine.connectattr('output', obj.node, 'color')
1375
1376        obj = Image(
1377            bascenev1.gettexture('achievementOutline'),
1378            mesh_transparent=bascenev1.getmesh('achievementOutline'),
1379            position=(-180, 60 + y_offs),
1380            front=True,
1381            attach=Image.Attach.BOTTOM_CENTER,
1382            vr_depth=base_vr_depth,
1383            transition=Image.Transition.IN_BOTTOM,
1384            transition_delay=in_time,
1385            transition_out_delay=out_time,
1386            scale=(100, 100),
1387        ).autoretain()
1388        assert obj.node
1389        obj.node.host_only = True
1390
1391        # Flash.
1392        color = (2, 1.4, 0.4, 1)
1393        combine = bascenev1.newnode(
1394            'combine', owner=obj.node, attrs={'size': 3}
1395        )
1396        keys = {
1397            in_time: 1.0 * color[0],
1398            in_time + 0.4: 1.5 * color[0],
1399            in_time + 0.5: 6.0 * color[0],
1400            in_time + 0.6: 1.5 * color[0],
1401            in_time + 2.0: 1.0 * color[0],
1402        }
1403        bascenev1.animate(combine, 'input0', keys)
1404        keys = {
1405            in_time: 1.0 * color[1],
1406            in_time + 0.4: 1.5 * color[1],
1407            in_time + 0.5: 6.0 * color[1],
1408            in_time + 0.6: 1.5 * color[1],
1409            in_time + 2.0: 1.0 * color[1],
1410        }
1411        bascenev1.animate(combine, 'input1', keys)
1412        keys = {
1413            in_time: 1.0 * color[2],
1414            in_time + 0.4: 1.5 * color[2],
1415            in_time + 0.5: 6.0 * color[2],
1416            in_time + 0.6: 1.5 * color[2],
1417            in_time + 2.0: 1.0 * color[2],
1418        }
1419        bascenev1.animate(combine, 'input2', keys)
1420        combine.connectattr('output', obj.node, 'color')
1421        objs.append(obj)
1422
1423        objt = Text(
1424            babase.Lstr(
1425                value='${A}:',
1426                subs=[('${A}', babase.Lstr(resource='achievementText'))],
1427            ),
1428            position=(-120, 91 + y_offs),
1429            front=True,
1430            v_attach=Text.VAttach.BOTTOM,
1431            vr_depth=base_vr_depth - 10,
1432            transition=Text.Transition.IN_BOTTOM,
1433            flatness=0.5,
1434            transition_delay=in_time,
1435            transition_out_delay=out_time,
1436            color=(1, 1, 1, 0.8),
1437            scale=0.65,
1438        ).autoretain()
1439        objs.append(objt)
1440        assert objt.node
1441        objt.node.host_only = True
1442
1443        objt = Text(
1444            self.display_name,
1445            position=(-120, 50 + y_offs),
1446            front=True,
1447            v_attach=Text.VAttach.BOTTOM,
1448            transition=Text.Transition.IN_BOTTOM,
1449            vr_depth=base_vr_depth,
1450            flatness=0.5,
1451            transition_delay=in_time,
1452            transition_out_delay=out_time,
1453            flash=True,
1454            color=(1, 0.8, 0, 1.0),
1455            scale=1.5,
1456        ).autoretain()
1457        objs.append(objt)
1458        assert objt.node
1459        objt.node.host_only = True
1460
1461        objt = Text(
1462            babase.charstr(babase.SpecialChar.TICKET),
1463            position=(-120 - 170 + 5, 75 + y_offs - 20),
1464            front=True,
1465            v_attach=Text.VAttach.BOTTOM,
1466            h_align=Text.HAlign.CENTER,
1467            v_align=Text.VAlign.CENTER,
1468            transition=Text.Transition.IN_BOTTOM,
1469            vr_depth=base_vr_depth,
1470            transition_delay=in_time,
1471            transition_out_delay=out_time,
1472            flash=True,
1473            color=(0.5, 0.5, 0.5, 1),
1474            scale=3.0,
1475        ).autoretain()
1476        objs.append(objt)
1477        assert objt.node
1478        objt.node.host_only = True
1479
1480        objt = Text(
1481            '+' + str(self.get_award_ticket_value()),
1482            position=(-120 - 180 + 5, 80 + y_offs - 20),
1483            v_attach=Text.VAttach.BOTTOM,
1484            front=True,
1485            h_align=Text.HAlign.CENTER,
1486            v_align=Text.VAlign.CENTER,
1487            transition=Text.Transition.IN_BOTTOM,
1488            vr_depth=base_vr_depth,
1489            flatness=0.5,
1490            shadow=1.0,
1491            transition_delay=in_time,
1492            transition_out_delay=out_time,
1493            flash=True,
1494            color=(0, 1, 0, 1),
1495            scale=1.5,
1496        ).autoretain()
1497        objs.append(objt)
1498        assert objt.node
1499        objt.node.host_only = True
1500
1501        # Add the 'x 2' if we've got pro.
1502        if app.classic.accounts.have_pro():
1503            objt = Text(
1504                'x 2',
1505                position=(-120 - 180 + 45, 80 + y_offs - 50),
1506                v_attach=Text.VAttach.BOTTOM,
1507                front=True,
1508                h_align=Text.HAlign.CENTER,
1509                v_align=Text.VAlign.CENTER,
1510                transition=Text.Transition.IN_BOTTOM,
1511                vr_depth=base_vr_depth,
1512                flatness=0.5,
1513                shadow=1.0,
1514                transition_delay=in_time,
1515                transition_out_delay=out_time,
1516                flash=True,
1517                color=(0.4, 0, 1, 1),
1518                scale=0.9,
1519            ).autoretain()
1520            objs.append(objt)
1521            assert objt.node
1522            objt.node.host_only = True
1523
1524        objt = Text(
1525            self.description_complete,
1526            position=(-120, 30 + y_offs),
1527            front=True,
1528            v_attach=Text.VAttach.BOTTOM,
1529            transition=Text.Transition.IN_BOTTOM,
1530            vr_depth=base_vr_depth - 10,
1531            flatness=0.5,
1532            transition_delay=in_time,
1533            transition_out_delay=out_time,
1534            color=(1.0, 0.7, 0.5, 1.0),
1535            scale=0.8,
1536        ).autoretain()
1537        objs.append(objt)
1538        assert objt.node
1539        objt.node.host_only = True
1540
1541        for actor in objs:
1542            bascenev1.timer(
1543                out_time + 1.000,
1544                babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()),
1545            )

Create the banner/sound for an acquired achievement announcement.

class AchievementSubsystem:
 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'.

achievements: list[Achievement]
achievements_to_display: list[tuple[Achievement, bool]]
achievement_display_timer: _bascenev1.BaseTimer | None
last_achievement_display_time: float
achievement_completion_banner_slots: set[int]
def award_local_achievement(self, achname: str) -> None:
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.

def get_achievement(self, name: str) -> Achievement:
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.

def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
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.