baclassic

Classic ballistica components.

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

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.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.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]
delegate: baclassic._appdelegate.AppDelegate | None
party_window: weakref.ReferenceType[bauiv1lib.party.PartyWindow] | None
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
124    @property
125    def platform(self) -> str:
126        """Name of the current platform.
127
128        Examples are: 'mac', 'windows', android'.
129        """
130        assert isinstance(self._env['platform'], str)
131        return self._env['platform']

Name of the current platform.

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

subplatform: str
137    @property
138    def subplatform(self) -> str:
139        """String for subplatform.
140
141        Can be empty. For the 'android' platform, subplatform may
142        be 'google', 'amazon', etc.
143        """
144        assert isinstance(self._env['subplatform'], str)
145        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
147    @property
148    def legacy_user_agent_string(self) -> str:
149        """String containing various bits of info about OS/device/etc."""
150        assert isinstance(self._env['legacy_user_agent_string'], str)
151        return self._env['legacy_user_agent_string']

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

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

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
239    @override
240    def on_app_unsuspend(self) -> None:
241        self.accounts.on_app_unsuspend()
242        self.music.on_app_unsuspend()

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
244    @override
245    def on_app_shutdown(self) -> None:
246        self.music.on_app_shutdown()

Called when the app begins shutting down.

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

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

Attempt to cleanly get back to the main menu.

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

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

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

def getcampaign(self, name: str) -> bascenev1._campaign.Campaign:
528    def getcampaign(self, name: str) -> bascenev1.Campaign:
529        """Return a campaign by name."""
530        return self.campaigns[name]

Return a campaign by name.

def get_next_tip(self) -> str:
532    def get_next_tip(self) -> str:
533        """Returns the next tip to be displayed."""
534        if not self.tips:
535            for tip in get_all_tips():
536                self.tips.insert(random.randint(0, len(self.tips)), tip)
537        tip = self.tips.pop()
538        return tip

Returns the next tip to be displayed.

def run_gpu_benchmark(self) -> None:
540    def run_gpu_benchmark(self) -> None:
541        """Kick off a benchmark to test gpu speeds."""
542        from baclassic._benchmark import run_gpu_benchmark as run
543
544        run()

Kick off a benchmark to test gpu speeds.

def run_cpu_benchmark(self) -> None:
546    def run_cpu_benchmark(self) -> None:
547        """Kick off a benchmark to test cpu speeds."""
548        from baclassic._benchmark import run_cpu_benchmark as run
549
550        run()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
552    def run_media_reload_benchmark(self) -> None:
553        """Kick off a benchmark to test media reloading speeds."""
554        from baclassic._benchmark import run_media_reload_benchmark as run
555
556        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:
558    def run_stress_test(
559        self,
560        playlist_type: str = 'Random',
561        playlist_name: str = '__default__',
562        player_count: int = 8,
563        round_duration: int = 30,
564        attract_mode: bool = False,
565    ) -> None:
566        """Run a stress test."""
567        from baclassic._benchmark import run_stress_test as run
568
569        run(
570            playlist_type=playlist_type,
571            playlist_name=playlist_name,
572            player_count=player_count,
573            round_duration=round_duration,
574            attract_mode=attract_mode,
575        )

Run a stress test.

def get_input_device_mapped_value( self, device: _bascenev1.InputDevice, name: str, default: bool = False) -> Any:
577    def get_input_device_mapped_value(
578        self,
579        device: bascenev1.InputDevice,
580        name: str,
581        default: bool = False,
582    ) -> Any:
583        """Return a mapped value for an input device.
584
585        This checks the user config and falls back to default values
586        where available.
587        """
588        return _input.get_input_device_mapped_value(
589            device.name, device.unique_identifier, name, default
590        )

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:
592    def get_input_device_map_hash(
593        self, inputdevice: bascenev1.InputDevice
594    ) -> str:
595        """Given an input device, return hash based on its raw input values."""
596        del inputdevice  # unused currently
597        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]:
599    def get_input_device_config(
600        self, inputdevice: bascenev1.InputDevice, default: bool
601    ) -> tuple[dict, str]:
602        """Given an input device, return its config dict in the app config.
603
604        The dict will be created if it does not exist.
605        """
606        return _input.get_input_device_config(
607            inputdevice.name, inputdevice.unique_identifier, default
608        )

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

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
614    def get_player_profile_icon(self, profilename: str) -> str:
615        """Given a profile name, returns an icon string for it.
616
617        (non-account profiles only)
618        """
619        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]]:
621    def get_player_profile_colors(
622        self,
623        profilename: str | None,
624        profiles: dict[str, dict[str, Any]] | None = None,
625    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
626        """Given a profile, return colors for them."""
627        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

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

Preload media needed for map preview UIs.

Category: Asset Functions

Inherited Members
babase._appsubsystem.AppSubsystem
on_app_running
on_app_shutdown_complete
do_apply_app_config
class ClassicSubsystem.MusicPlayMode(enum.Enum):
21class MusicPlayMode(Enum):
22    """Influences behavior when playing music.
23
24    Category: **Enums**
25    """
26
27    REGULAR = 'regular'
28    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._language.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._language.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._language.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._language.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._language.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.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.