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

AppMode for the classic BombSquad experience.

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

Return the overall experience provided by this mode.

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

Handle an intent.

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

Called when the mode is being activated.

@override
def on_deactivate(self) -> None:
109    @override
110    def on_deactivate(self) -> None:
111        # Let the native layer do its thing.
112        _baclassic.classic_app_mode_deactivate()

Called when the mode is being deactivated.

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

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
selecting_private_party_playlist: bool
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
137    @property
138    def platform(self) -> str:
139        """Name of the current platform.
140
141        Examples are: 'mac', 'windows', android'.
142        """
143        assert isinstance(self._env['platform'], str)
144        return self._env['platform']

Name of the current platform.

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

subplatform: str
150    @property
151    def subplatform(self) -> str:
152        """String for subplatform.
153
154        Can be empty. For the 'android' platform, subplatform may
155        be 'google', 'amazon', etc.
156        """
157        assert isinstance(self._env['subplatform'], str)
158        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
160    @property
161    def legacy_user_agent_string(self) -> str:
162        """String containing various bits of info about OS/device/etc."""
163        assert isinstance(self._env['legacy_user_agent_string'], str)
164        return self._env['legacy_user_agent_string']

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

@override
def on_app_loading(self) -> None:
166    @override
167    def on_app_loading(self) -> None:
168        from bascenev1lib.actor import spazappearance
169        from bascenev1lib import maps as stdmaps
170
171        plus = babase.app.plus
172        assert plus is not None
173
174        env = babase.app.env
175        cfg = babase.app.config
176
177        self.music.on_app_loading()
178
179        # Non-test, non-debug builds should generally be blessed; warn if not.
180        # (so I don't accidentally release a build that can't play tourneys)
181        if not env.debug and not env.test and not plus.is_blessed():
182            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
183
184        # FIXME: This should not be hard-coded.
185        for maptype in [
186            stdmaps.HockeyStadium,
187            stdmaps.FootballStadium,
188            stdmaps.Bridgit,
189            stdmaps.BigG,
190            stdmaps.Roundabout,
191            stdmaps.MonkeyFace,
192            stdmaps.ZigZag,
193            stdmaps.ThePad,
194            stdmaps.DoomShroom,
195            stdmaps.LakeFrigid,
196            stdmaps.TipTop,
197            stdmaps.CragCastle,
198            stdmaps.TowerD,
199            stdmaps.HappyThoughts,
200            stdmaps.StepRightUp,
201            stdmaps.Courtyard,
202            stdmaps.Rampage,
203        ]:
204            bascenev1.register_map(maptype)
205
206        spazappearance.register_appearances()
207        bascenev1.init_campaigns()
208
209        launch_count = cfg.get('launchCount', 0)
210        launch_count += 1
211
212        # So we know how many times we've run the game at various
213        # version milestones.
214        for key in ('lc14173', 'lc14292'):
215            cfg.setdefault(key, launch_count)
216
217        cfg['launchCount'] = launch_count
218        cfg.commit()
219
220        # Run a test in a few seconds to see if we should pop up an existing
221        # pending special offer.
222        def check_special_offer() -> None:
223            assert plus is not None
224
225            from bauiv1lib.specialoffer import show_offer
226
227            if (
228                'pendingSpecialOffer' in cfg
229                and plus.get_v1_account_public_login_id()
230                == cfg['pendingSpecialOffer']['a']
231            ):
232                self.special_offer = cfg['pendingSpecialOffer']['o']
233                show_offer()
234
235        if babase.app.env.gui:
236            babase.apptimer(3.0, check_special_offer)
237
238        # If there's a leftover log file, attempt to upload it to the
239        # master-server and/or get rid of it.
240        babase.handle_leftover_v1_cloud_log_file()
241
242        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:
244    @override
245    def on_app_suspend(self) -> None:
246        self.accounts.on_app_suspend()

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
248    @override
249    def on_app_unsuspend(self) -> None:
250        self.accounts.on_app_unsuspend()
251        self.music.on_app_unsuspend()

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
253    @override
254    def on_app_shutdown(self) -> None:
255        self.music.on_app_shutdown()

Called when the app begins shutting down.

def pause(self) -> None:
257    def pause(self) -> None:
258        """Pause the game due to a user request or menu popping up.
259
260        If there's a foreground host-activity that says it's pausable, tell it
261        to pause. Note: we now no longer pause if there are connected clients.
262        """
263        activity: bascenev1.Activity | None = (
264            bascenev1.get_foreground_host_activity()
265        )
266        if (
267            activity is not None
268            and activity.allow_pausing
269            and not bascenev1.have_connected_clients()
270        ):
271            from babase import Lstr
272            from bascenev1 import NodeActor
273
274            # FIXME: Shouldn't be touching scene stuff here;
275            #  should just pass the request on to the host-session.
276            with activity.context:
277                globs = activity.globalsnode
278                if not globs.paused:
279                    bascenev1.getsound('refWhistle').play()
280                    globs.paused = True
281
282                # FIXME: This should not be an attr on Actor.
283                activity.paused_text = NodeActor(
284                    bascenev1.newnode(
285                        'text',
286                        attrs={
287                            'text': Lstr(resource='pausedByHostText'),
288                            'client_only': True,
289                            'flatness': 1.0,
290                            'h_align': 'center',
291                        },
292                    )
293                )

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:
295    def resume(self) -> None:
296        """Resume the game due to a user request or menu closing.
297
298        If there's a foreground host-activity that's currently paused, tell it
299        to resume.
300        """
301
302        # FIXME: Shouldn't be touching scene stuff here;
303        #  should just pass the request on to the host-session.
304        activity = bascenev1.get_foreground_host_activity()
305        if activity is not None:
306            with activity.context:
307                globs = activity.globalsnode
308                if globs.paused:
309                    bascenev1.getsound('refWhistle').play()
310                    globs.paused = False
311
312                    # FIXME: This should not be an actor attr.
313                    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:
315    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
316        """Adds an individual level to the 'practice' section in Co-op."""
317
318        # Assign this level to our catch-all campaign.
319        self.campaigns['Challenges'].addlevel(level)
320
321        # Make note to add it to our challenges UI.
322        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:
324    def launch_coop_game(
325        self, game: str, force: bool = False, args: dict | None = None
326    ) -> bool:
327        """High level way to launch a local co-op session."""
328        # pylint: disable=cyclic-import
329        from bauiv1lib.coop.level import CoopLevelLockedWindow
330
331        assert babase.app.classic is not None
332
333        if args is None:
334            args = {}
335        if game == '':
336            raise ValueError('empty game name')
337        campaignname, levelname = game.split(':')
338        campaign = babase.app.classic.getcampaign(campaignname)
339
340        # If this campaign is sequential, make sure we've completed the
341        # one before this.
342        if campaign.sequential and not force:
343            for level in campaign.levels:
344                if level.name == levelname:
345                    break
346                if not level.complete:
347                    CoopLevelLockedWindow(
348                        campaign.getlevel(levelname).displayname,
349                        campaign.getlevel(level.name).displayname,
350                    )
351                    return False
352
353        # Ok, we're good to go.
354        self.coop_session_args = {
355            'campaign': campaignname,
356            'level': levelname,
357        }
358        for arg_name, arg_val in list(args.items()):
359            self.coop_session_args[arg_name] = arg_val
360
361        def _fade_end() -> None:
362            from bascenev1 import CoopSession
363
364            try:
365                bascenev1.new_host_session(CoopSession)
366            except Exception:
367                logging.exception('Error creating coopsession after fade end.')
368                from bascenev1lib.mainmenu import MainMenuSession
369
370                bascenev1.new_host_session(MainMenuSession)
371
372        babase.fade_screen(False, endcall=_fade_end)
373        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:
375    def return_to_main_menu_session_gracefully(
376        self, reset_ui: bool = True
377    ) -> None:
378        """Attempt to cleanly get back to the main menu."""
379        # pylint: disable=cyclic-import
380        from baclassic import _benchmark
381        from bascenev1lib.mainmenu import MainMenuSession
382
383        plus = babase.app.plus
384        assert plus is not None
385
386        if reset_ui:
387            babase.app.ui_v1.clear_main_window()
388
389        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
390            # It may be possible we're on the main menu but the screen is faded
391            # so fade back in.
392            babase.fade_screen(True)
393            return
394
395        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
396
397        # If we're in a host-session, tell them to end.
398        # This lets them tear themselves down gracefully.
399        host_session: bascenev1.Session | None = (
400            bascenev1.get_foreground_host_session()
401        )
402        if host_session is not None:
403            # Kick off a little transaction so we'll hopefully have all the
404            # latest account state when we get back to the menu.
405            plus.add_v1_account_transaction(
406                {'type': 'END_SESSION', 'sType': str(type(host_session))}
407            )
408            plus.run_v1_account_transactions()
409
410            host_session.end()
411
412        # Otherwise just force the issue.
413        else:
414            babase.pushcall(
415                babase.Call(bascenev1.new_host_session, MainMenuSession)
416            )

Attempt to cleanly get back to the main menu.

def getmaps(self, playtype: str) -> list[str]:
418    def getmaps(self, playtype: str) -> list[str]:
419        """Return a list of bascenev1.Map types supporting a playtype str.
420
421        Category: **Asset Functions**
422
423        Maps supporting a given playtype must provide a particular set of
424        features and lend themselves to a certain style of play.
425
426        Play Types:
427
428        'melee'
429          General fighting map.
430          Has one or more 'spawn' locations.
431
432        'team_flag'
433          For games such as Capture The Flag where each team spawns by a flag.
434          Has two or more 'spawn' locations, each with a corresponding 'flag'
435          location (based on index).
436
437        'single_flag'
438          For games such as King of the Hill or Keep Away where multiple teams
439          are fighting over a single flag.
440          Has two or more 'spawn' locations and 1 'flag_default' location.
441
442        'conquest'
443          For games such as Conquest where flags are spread throughout the map
444          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
445
446        'king_of_the_hill' - has 2+ 'spawn' locations,
447           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
448
449        'hockey'
450          For hockey games.
451          Has two 'goal' locations, corresponding 'spawn' locations, and one
452          'flag_default' location (for where puck spawns)
453
454        'football'
455          For football games.
456          Has two 'goal' locations, corresponding 'spawn' locations, and one
457          'flag_default' location (for where flag/ball/etc. spawns)
458
459        'race'
460          For racing games where players much touch each region in order.
461          Has two or more 'race_point' locations.
462        """
463        return sorted(
464            key
465            for key, val in self.maps.items()
466            if playtype in val.get_play_types()
467        )

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:
475    @classmethod
476    def json_prep(cls, data: Any) -> Any:
477        """Return a json-friendly version of the provided data.
478
479        This converts any tuples to lists and any bytes to strings
480        (interpreted as utf-8, ignoring errors). Logs errors (just once)
481        if any data is modified/discarded/unsupported.
482        """
483
484        if isinstance(data, dict):
485            return dict(
486                (cls.json_prep(key), cls.json_prep(value))
487                for key, value in list(data.items())
488            )
489        if isinstance(data, list):
490            return [cls.json_prep(element) for element in data]
491        if isinstance(data, tuple):
492            logging.exception('json_prep encountered tuple')
493            return [cls.json_prep(element) for element in data]
494        if isinstance(data, bytes):
495            try:
496                return data.decode(errors='ignore')
497            except Exception:
498                logging.exception('json_prep encountered utf-8 decode error')
499                return data.decode(errors='ignore')
500        if not isinstance(data, (str, float, bool, type(None), int)):
501            logging.exception(
502                'got unsupported type in json_prep: %s', type(data)
503            )
504        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:
506    def master_server_v1_get(
507        self,
508        request: str,
509        data: dict[str, Any],
510        callback: MasterServerCallback | None = None,
511        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
512    ) -> None:
513        """Make a call to the master server via a http GET."""
514
515        MasterServerV1CallThread(
516            request, 'get', data, callback, response_type
517        ).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:
519    def master_server_v1_post(
520        self,
521        request: str,
522        data: dict[str, Any],
523        callback: MasterServerCallback | None = None,
524        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
525    ) -> None:
526        """Make a call to the master server via a http POST."""
527        MasterServerV1CallThread(
528            request, 'post', data, callback, response_type
529        ).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]:
531    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
532        """Given a tournament entry, return strings for its prize levels."""
533        from baclassic import _tournament
534
535        return _tournament.get_tournament_prize_strings(entry)

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

def getcampaign(self, name: str) -> bascenev1.Campaign:
537    def getcampaign(self, name: str) -> bascenev1.Campaign:
538        """Return a campaign by name."""
539        return self.campaigns[name]

Return a campaign by name.

def get_next_tip(self) -> str:
541    def get_next_tip(self) -> str:
542        """Returns the next tip to be displayed."""
543        if not self.tips:
544            for tip in get_all_tips():
545                self.tips.insert(random.randint(0, len(self.tips)), tip)
546        tip = self.tips.pop()
547        return tip

Returns the next tip to be displayed.

def run_gpu_benchmark(self) -> None:
549    def run_gpu_benchmark(self) -> None:
550        """Kick off a benchmark to test gpu speeds."""
551        from baclassic._benchmark import run_gpu_benchmark as run
552
553        run()

Kick off a benchmark to test gpu speeds.

def run_cpu_benchmark(self) -> None:
555    def run_cpu_benchmark(self) -> None:
556        """Kick off a benchmark to test cpu speeds."""
557        from baclassic._benchmark import run_cpu_benchmark as run
558
559        run()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
561    def run_media_reload_benchmark(self) -> None:
562        """Kick off a benchmark to test media reloading speeds."""
563        from baclassic._benchmark import run_media_reload_benchmark as run
564
565        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:
567    def run_stress_test(
568        self,
569        playlist_type: str = 'Random',
570        playlist_name: str = '__default__',
571        player_count: int = 8,
572        round_duration: int = 30,
573        attract_mode: bool = False,
574    ) -> None:
575        """Run a stress test."""
576        from baclassic._benchmark import run_stress_test as run
577
578        run(
579            playlist_type=playlist_type,
580            playlist_name=playlist_name,
581            player_count=player_count,
582            round_duration=round_duration,
583            attract_mode=attract_mode,
584        )

Run a stress test.

def get_input_device_mapped_value( self, device: _bascenev1.InputDevice, name: str, default: bool = False) -> Any:
586    def get_input_device_mapped_value(
587        self,
588        device: bascenev1.InputDevice,
589        name: str,
590        default: bool = False,
591    ) -> Any:
592        """Return a mapped value for an input device.
593
594        This checks the user config and falls back to default values
595        where available.
596        """
597        return _input.get_input_device_mapped_value(
598            device.name, device.unique_identifier, name, default
599        )

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:
601    def get_input_device_map_hash(
602        self, inputdevice: bascenev1.InputDevice
603    ) -> str:
604        """Given an input device, return hash based on its raw input values."""
605        del inputdevice  # unused currently
606        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]:
608    def get_input_device_config(
609        self, inputdevice: bascenev1.InputDevice, default: bool
610    ) -> tuple[dict, str]:
611        """Given an input device, return its config dict in the app config.
612
613        The dict will be created if it does not exist.
614        """
615        return _input.get_input_device_config(
616            inputdevice.name, inputdevice.unique_identifier, default
617        )

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]]:
619    def get_player_colors(self) -> list[tuple[float, float, float]]:
620        """Return user-selectable player colors."""
621        return bascenev1.get_player_colors()

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
623    def get_player_profile_icon(self, profilename: str) -> str:
624        """Given a profile name, returns an icon string for it.
625
626        (non-account profiles only)
627        """
628        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]]:
630    def get_player_profile_colors(
631        self,
632        profilename: str | None,
633        profiles: dict[str, dict[str, Any]] | None = None,
634    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
635        """Given a profile, return colors for them."""
636        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

def preload_map_preview_media(self) -> None:
772    def preload_map_preview_media(self) -> None:
773        """Preload media needed for map preview UIs.
774
775        Category: **Asset Functions**
776        """
777        try:
778            bauiv1.getmesh('level_select_button_opaque')
779            bauiv1.getmesh('level_select_button_transparent')
780            for maptype in list(self.maps.values()):
781                map_tex_name = maptype.get_preview_texture_name()
782                if map_tex_name is not None:
783                    bauiv1.gettexture(map_tex_name)
784        except Exception:
785            logging.exception('Error preloading map preview media.')

Preload media needed for map preview UIs.

Category: Asset Functions

def invoke_main_menu_ui(self) -> None:
830    def invoke_main_menu_ui(self) -> None:
831        """Bring up main menu ui."""
832        # Bring up the last place we were, or start at the main menu otherwise.
833        app = bauiv1.app
834        env = app.env
835        with bascenev1.ContextRef.empty():
836            from bauiv1lib import specialoffer
837
838            assert app.classic is not None
839            if app.env.headless:
840                # UI stuff fails now in headless builds; avoid it.
841                pass
842            else:
843                # main_menu_location = (
844                #     bascenev1.app.ui_v1.get_main_menu_location()
845                # )
846
847                # When coming back from a kiosk-mode game, jump to
848                # the kiosk start screen.
849                if env.demo or env.arcade:
850                    # pylint: disable=cyclic-import
851                    from bauiv1lib.kiosk import KioskWindow
852
853                    app.ui_v1.set_main_window(
854                        KioskWindow(), from_window=False  # Disable check here.
855                    )
856                # ..or in normal cases go back to the main menu
857                else:
858                    # if main_menu_location == 'Gather':
859                    #     # pylint: disable=cyclic-import
860                    #     from bauiv1lib.gather import GatherWindow
861
862                    #     app.ui_v1.set_main_window(
863                    #         GatherWindow(transition=None),
864                    #         from_window=False,  # Disable check here.
865                    #     )
866                    # elif main_menu_location == 'Watch':
867                    #     # pylint: disable=cyclic-import
868                    #     from bauiv1lib.watch import WatchWindow
869
870                    #     app.ui_v1.set_main_window(
871                    #         WatchWindow(transition=None),
872                    #         from_window=False,  # Disable check here.
873                    #     )
874                    # elif main_menu_location == 'Team Game Select':
875                    #     # pylint: disable=cyclic-import
876                    #     from bauiv1lib.playlist.browser import (
877                    #         PlaylistBrowserWindow,
878                    #     )
879
880                    #     app.ui_v1.set_main_window(
881                    #         PlaylistBrowserWindow(
882                    #             sessiontype=bascenev1.DualTeamSession,
883                    #             transition=None,
884                    #         ),
885                    #         from_window=False,  # Disable check here.
886                    #     )
887                    # elif main_menu_location == 'Free-for-All Game Select':
888                    #     # pylint: disable=cyclic-import
889                    #     from bauiv1lib.playlist.browser import (
890                    #         PlaylistBrowserWindow,
891                    #     )
892
893                    #     app.ui_v1.set_main_window(
894                    #         PlaylistBrowserWindow(
895                    #             sessiontype=bascenev1.FreeForAllSession,
896                    #             transition=None,
897                    #         ),
898                    #         from_window=False,  # Disable check here.
899                    #     )
900                    # elif main_menu_location == 'Coop Select':
901                    #     # pylint: disable=cyclic-import
902                    #     from bauiv1lib.coop.browser import CoopBrowserWindow
903
904                    #     app.ui_v1.set_main_window(
905                    #         CoopBrowserWindow(transition=None),
906                    #         from_window=False,  # Disable check here.
907                    #     )
908                    # elif main_menu_location == 'Benchmarks & Stress Tests':
909                    #     # pylint: disable=cyclic-import
910                    #     from bauiv1lib.debug import DebugWindow
911
912                    #     app.ui_v1.set_main_window(
913                    #         DebugWindow(transition=None),
914                    #         from_window=False,  # Disable check here.
915                    #     )
916                    # else:
917                    # pylint: disable=cyclic-import
918                    from bauiv1lib.mainmenu import MainMenuWindow
919
920                    app.ui_v1.set_main_window(
921                        MainMenuWindow(transition=None),
922                        from_window=False,  # Disable check.
923                        is_top_level=True,
924                    )
925
926                # attempt to show any pending offers immediately.
927                # If that doesn't work, try again in a few seconds
928                # (we may not have heard back from the server)
929                # ..if that doesn't work they'll just have to wait
930                # until the next opportunity.
931                if not specialoffer.show_offer():
932
933                    def try_again() -> None:
934                        if not specialoffer.show_offer():
935                            # Try one last time..
936                            bauiv1.apptimer(2.0, specialoffer.show_offer)
937
938                    bauiv1.apptimer(2.0, try_again)

Bring up main menu ui.

Inherited Members
babase._appsubsystem.AppSubsystem
on_app_running
on_app_shutdown_complete
do_apply_app_config
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.