babase

Common shared Ballistica components.

For modding purposes, this package should generally not be used directly. Instead one should use purpose-built packages such as bascenev1 or bauiv1 which themselves import various functionality from here and reexpose it in a more focused way.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Common shared Ballistica components.
  4
  5For modding purposes, this package should generally not be used
  6directly. Instead one should use purpose-built packages such as
  7:mod:`bascenev1` or :mod:`bauiv1` which themselves import various
  8functionality from here and reexpose it in a more focused way.
  9"""
 10# pylint: disable=redefined-builtin
 11
 12# ba_meta require api 9
 13
 14# The stuff we expose here at the top level is our 'public' api for use
 15# from other modules/packages. Code *within* this package should import
 16# things from this package's submodules directly to reduce the chance of
 17# dependency loops. The exception is TYPE_CHECKING blocks and
 18# annotations since those aren't evaluated at runtime.
 19
 20from efro.util import set_canonical_module_names
 21
 22import _babase
 23from _babase import (
 24    add_clean_frame_callback,
 25    allows_ticket_sales,
 26    android_get_external_files_dir,
 27    app_instance_uuid,
 28    appname,
 29    appnameupper,
 30    apptime,
 31    apptimer,
 32    AppTimer,
 33    asset_loads_allowed,
 34    fullscreen_control_available,
 35    fullscreen_control_get,
 36    fullscreen_control_key_shortcut,
 37    fullscreen_control_set,
 38    charstr,
 39    clipboard_get_text,
 40    clipboard_has_text,
 41    clipboard_is_supported,
 42    clipboard_set_text,
 43    ContextCall,
 44    ContextRef,
 45    displaytime,
 46    displaytimer,
 47    DisplayTimer,
 48    do_once,
 49    env,
 50    Env,
 51    fade_screen,
 52    fatal_error,
 53    get_display_resolution,
 54    get_immediate_return_code,
 55    get_input_idle_time,
 56    get_low_level_config_value,
 57    get_max_graphics_quality,
 58    get_replays_dir,
 59    get_string_height,
 60    get_string_width,
 61    get_ui_scale,
 62    get_v1_cloud_log_file_path,
 63    get_virtual_safe_area_size,
 64    get_virtual_screen_size,
 65    getsimplesound,
 66    has_user_run_commands,
 67    have_chars,
 68    have_permission,
 69    in_logic_thread,
 70    in_main_menu,
 71    increment_analytics_count,
 72    invoke_main_menu,
 73    is_os_playing_music,
 74    is_xcode_build,
 75    lock_all_input,
 76    mac_music_app_get_playlists,
 77    mac_music_app_get_volume,
 78    mac_music_app_init,
 79    mac_music_app_play_playlist,
 80    mac_music_app_set_volume,
 81    mac_music_app_stop,
 82    music_player_play,
 83    music_player_set_volume,
 84    music_player_shutdown,
 85    music_player_stop,
 86    native_review_request,
 87    native_review_request_supported,
 88    native_stack_trace,
 89    open_file_externally,
 90    open_url,
 91    overlay_web_browser_close,
 92    overlay_web_browser_is_open,
 93    overlay_web_browser_is_supported,
 94    overlay_web_browser_open_url,
 95    print_load_info,
 96    push_back_press,
 97    pushcall,
 98    quit,
 99    reload_media,
100    request_permission,
101    safecolor,
102    screenmessage,
103    set_analytics_screen,
104    set_low_level_config_value,
105    set_thread_name,
106    set_ui_account_state,
107    set_ui_input_device,
108    set_ui_scale,
109    show_progress_bar,
110    shutdown_suppress_begin,
111    shutdown_suppress_end,
112    shutdown_suppress_count,
113    SimpleSound,
114    supports_max_fps,
115    supports_vsync,
116    supports_unicode_display,
117    unlock_all_input,
118    update_internal_logger_levels,
119    user_agent_string,
120    user_ran_commands,
121    Vec3,
122    workspaces_in_use,
123)
124
125from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
126from babase._app import App
127from babase._appconfig import commit_app_config
128from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
129from babase._appmode import AppMode
130from babase._appsubsystem import AppSubsystem
131from babase._appmodeselector import AppModeSelector
132from babase._appconfig import AppConfig
133from babase._apputils import (
134    handle_leftover_v1_cloud_log_file,
135    is_browser_likely_available,
136    garbage_collect,
137    get_remote_app_name,
138    AppHealthMonitor,
139    utc_now_cloud,
140)
141from babase._cloud import CloudSubscription
142from babase._devconsole import (
143    DevConsoleTab,
144    DevConsoleTabEntry,
145    DevConsoleSubsystem,
146)
147from babase._emptyappmode import EmptyAppMode
148from babase._error import (
149    print_exception,
150    print_error,
151    ContextError,
152    NotFoundError,
153    PlayerNotFoundError,
154    SessionPlayerNotFoundError,
155    NodeNotFoundError,
156    ActorNotFoundError,
157    InputDeviceNotFoundError,
158    WidgetNotFoundError,
159    ActivityNotFoundError,
160    TeamNotFoundError,
161    MapNotFoundError,
162    SessionTeamNotFoundError,
163    SessionNotFoundError,
164    DelegateNotFoundError,
165)
166from babase._general import (
167    utf8_all,
168    DisplayTime,
169    AppTime,
170    WeakCall,
171    Call,
172    existing,
173    Existable,
174    verify_object_death,
175    storagename,
176    getclass,
177    get_type_name,
178)
179from babase._language import Lstr, LanguageSubsystem
180from babase._logging import balog, applog, lifecyclelog
181from babase._login import LoginAdapter, LoginInfo
182
183from babase._mgen.enums import (
184    Permission,
185    SpecialChar,
186    InputType,
187    UIScale,
188    QuitType,
189)
190from babase._math import normalized_color, is_point_in_box, vec3validate
191from babase._meta import MetadataSubsystem
192from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS
193from babase._plugin import PluginSpec, Plugin, PluginSubsystem
194from babase._stringedit import StringEditAdapter, StringEditSubsystem
195from babase._text import timestring
196
197_babase.app = app = App()
198app.postinit()
199
200__all__ = [
201    'AccountV2Handle',
202    'AccountV2Subsystem',
203    'ActivityNotFoundError',
204    'ActorNotFoundError',
205    'allows_ticket_sales',
206    'add_clean_frame_callback',
207    'android_get_external_files_dir',
208    'app',
209    'App',
210    'AppConfig',
211    'AppHealthMonitor',
212    'AppIntent',
213    'AppIntentDefault',
214    'AppIntentExec',
215    'AppMode',
216    'app_instance_uuid',
217    'applog',
218    'appname',
219    'appnameupper',
220    'AppModeSelector',
221    'AppSubsystem',
222    'apptime',
223    'AppTime',
224    'apptime',
225    'apptimer',
226    'AppTimer',
227    'asset_loads_allowed',
228    'balog',
229    'Call',
230    'fullscreen_control_available',
231    'fullscreen_control_get',
232    'fullscreen_control_key_shortcut',
233    'fullscreen_control_set',
234    'charstr',
235    'clipboard_get_text',
236    'clipboard_has_text',
237    'clipboard_is_supported',
238    'CloudSubscription',
239    'clipboard_set_text',
240    'commit_app_config',
241    'ContextCall',
242    'ContextError',
243    'ContextRef',
244    'DelegateNotFoundError',
245    'DevConsoleTab',
246    'DevConsoleTabEntry',
247    'DevConsoleSubsystem',
248    'DisplayTime',
249    'displaytime',
250    'displaytimer',
251    'DisplayTimer',
252    'do_once',
253    'EmptyAppMode',
254    'env',
255    'Env',
256    'Existable',
257    'existing',
258    'fade_screen',
259    'fatal_error',
260    'garbage_collect',
261    'get_display_resolution',
262    'get_immediate_return_code',
263    'get_input_idle_time',
264    'get_ip_address_type',
265    'get_low_level_config_value',
266    'get_max_graphics_quality',
267    'get_remote_app_name',
268    'get_replays_dir',
269    'get_string_height',
270    'get_string_width',
271    'get_type_name',
272    'get_ui_scale',
273    'get_virtual_safe_area_size',
274    'get_virtual_screen_size',
275    'get_v1_cloud_log_file_path',
276    'getclass',
277    'getsimplesound',
278    'handle_leftover_v1_cloud_log_file',
279    'has_user_run_commands',
280    'have_chars',
281    'have_permission',
282    'in_logic_thread',
283    'in_main_menu',
284    'increment_analytics_count',
285    'InputDeviceNotFoundError',
286    'InputType',
287    'invoke_main_menu',
288    'is_browser_likely_available',
289    'is_browser_likely_available',
290    'is_os_playing_music',
291    'is_point_in_box',
292    'is_xcode_build',
293    'LanguageSubsystem',
294    'lifecyclelog',
295    'lock_all_input',
296    'LoginAdapter',
297    'LoginInfo',
298    'Lstr',
299    'mac_music_app_get_playlists',
300    'mac_music_app_get_volume',
301    'mac_music_app_init',
302    'mac_music_app_play_playlist',
303    'mac_music_app_set_volume',
304    'mac_music_app_stop',
305    'MapNotFoundError',
306    'MetadataSubsystem',
307    'music_player_play',
308    'music_player_set_volume',
309    'music_player_shutdown',
310    'music_player_stop',
311    'native_review_request',
312    'native_review_request_supported',
313    'native_stack_trace',
314    'NodeNotFoundError',
315    'normalized_color',
316    'NotFoundError',
317    'open_file_externally',
318    'open_url',
319    'overlay_web_browser_close',
320    'overlay_web_browser_is_open',
321    'overlay_web_browser_is_supported',
322    'overlay_web_browser_open_url',
323    'Permission',
324    'PlayerNotFoundError',
325    'Plugin',
326    'PluginSubsystem',
327    'PluginSpec',
328    'print_error',
329    'print_exception',
330    'print_load_info',
331    'push_back_press',
332    'pushcall',
333    'quit',
334    'QuitType',
335    'reload_media',
336    'request_permission',
337    'safecolor',
338    'screenmessage',
339    'SessionNotFoundError',
340    'SessionPlayerNotFoundError',
341    'SessionTeamNotFoundError',
342    'set_analytics_screen',
343    'set_low_level_config_value',
344    'set_thread_name',
345    'set_ui_account_state',
346    'set_ui_input_device',
347    'set_ui_scale',
348    'show_progress_bar',
349    'shutdown_suppress_begin',
350    'shutdown_suppress_end',
351    'shutdown_suppress_count',
352    'SimpleSound',
353    'SpecialChar',
354    'storagename',
355    'StringEditAdapter',
356    'StringEditSubsystem',
357    'supports_max_fps',
358    'supports_vsync',
359    'supports_unicode_display',
360    'TeamNotFoundError',
361    'timestring',
362    'UIScale',
363    'unlock_all_input',
364    'update_internal_logger_levels',
365    'user_agent_string',
366    'user_ran_commands',
367    'utc_now_cloud',
368    'utf8_all',
369    'Vec3',
370    'vec3validate',
371    'verify_object_death',
372    'WeakCall',
373    'WidgetNotFoundError',
374    'workspaces_in_use',
375    'DEFAULT_REQUEST_TIMEOUT_SECONDS',
376]
377
378# We want stuff to show up as babase.Foo instead of babase._sub.Foo.
379set_canonical_module_names(globals())
380
381# Allow the native layer to wrap a few things up.
382_babase.reached_end_of_babase()
383
384# Marker we pop down at the very end so other modules can run sanity
385# checks to make sure we aren't importing them reciprocally when they
386# import us.
387_REACHED_END_OF_MODULE = True
class AccountV2Handle:
443class AccountV2Handle:
444    """Handle for interacting with a V2 account.
445
446    This class supports the 'with' statement, which is how it is
447    used with some operations such as cloud messaging.
448    """
449
450    accountid: str
451    tag: str
452    workspacename: str | None
453    workspaceid: str | None
454    logins: dict[LoginType, LoginInfo]
455
456    def __enter__(self) -> None:
457        """Support for "with" statement.
458
459        This allows cloud messages to be sent on our behalf.
460        """
461
462    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
463        """Support for "with" statement.
464
465        This allows cloud messages to be sent on our behalf.
466        """

Handle for interacting with a V2 account.

This class supports the 'with' statement, which is how it is used with some operations such as cloud messaging.

accountid: str
tag: str
workspacename: str | None
workspaceid: str | None
class AccountV2Subsystem:
 26class AccountV2Subsystem:
 27    """Subsystem for modern account handling in the app.
 28
 29    Access the single shared instance of this class at 'ba.app.plus.accounts'.
 30    """
 31
 32    def __init__(self) -> None:
 33        assert _babase.in_logic_thread()
 34
 35        from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter
 36
 37        # Register to be informed when connectivity changes.
 38        plus = _babase.app.plus
 39        self._connectivity_changed_cb = (
 40            None
 41            if plus is None
 42            else plus.cloud.on_connectivity_changed_callbacks.register(
 43                self._on_cloud_connectivity_changed
 44            )
 45        )
 46
 47        # Whether or not everything related to an initial sign in (or
 48        # lack thereof) has completed. This includes things like
 49        # workspace syncing. Completion of this is what flips the app
 50        # into 'running' state.
 51        self._initial_sign_in_completed = False
 52
 53        self._kicked_off_workspace_load = False
 54
 55        self.login_adapters: dict[LoginType, LoginAdapter] = {}
 56
 57        self._implicit_signed_in_adapter: LoginAdapter | None = None
 58        self._implicit_state_changed = False
 59        self._can_do_auto_sign_in = True
 60        self.on_primary_account_changed_callbacks: CallbackSet[
 61            Callable[[AccountV2Handle | None], None]
 62        ] = CallbackSet()
 63
 64        adapter: LoginAdapter
 65        if _babase.using_google_play_game_services():
 66            adapter = LoginAdapterGPGS()
 67            self.login_adapters[adapter.login_type] = adapter
 68        if _babase.using_game_center():
 69            adapter = LoginAdapterGameCenter()
 70            self.login_adapters[adapter.login_type] = adapter
 71
 72    def on_app_loading(self) -> None:
 73        """Should be called at standard on_app_loading time."""
 74
 75        for adapter in self.login_adapters.values():
 76            adapter.on_app_loading()
 77
 78    def have_primary_credentials(self) -> bool:
 79        """Are credentials currently set for the primary app account?
 80
 81        Note that this does not mean these credentials have been checked
 82        for validity; only that they exist. If/when credentials are
 83        validated, the 'primary' account handle will be set.
 84        """
 85        raise NotImplementedError()
 86
 87    @property
 88    def primary(self) -> AccountV2Handle | None:
 89        """The primary account for the app, or None if not logged in."""
 90        return self.do_get_primary()
 91
 92    def on_primary_account_changed(
 93        self, account: AccountV2Handle | None
 94    ) -> None:
 95        """Callback run after the primary account changes.
 96
 97        Will be called with None on log-outs and when new credentials
 98        are set but have not yet been verified.
 99        """
100        assert _babase.in_logic_thread()
101
102        # Fire any registered callbacks.
103        for call in self.on_primary_account_changed_callbacks.getcalls():
104            try:
105                call(account)
106            except Exception:
107                logging.exception('Error in primary-account-changed callback.')
108
109        # Currently don't do anything special on sign-outs.
110        if account is None:
111            return
112
113        # If this new account has a workspace, update it and ask to be
114        # informed when that process completes.
115        if account.workspaceid is not None:
116            assert account.workspacename is not None
117            if (
118                not self._initial_sign_in_completed
119                and not self._kicked_off_workspace_load
120            ):
121                self._kicked_off_workspace_load = True
122                _babase.app.workspaces.set_active_workspace(
123                    account=account,
124                    workspaceid=account.workspaceid,
125                    workspacename=account.workspacename,
126                    on_completed=self._on_set_active_workspace_completed,
127                )
128            else:
129                # Don't activate workspaces if we've already told the
130                # game that initial-log-in is done or if we've already
131                # kicked off a workspace load.
132                _babase.screenmessage(
133                    f'\'{account.workspacename}\''
134                    f' will be activated at next app launch.',
135                    color=(1, 1, 0),
136                )
137                _babase.getsimplesound('error').play()
138            return
139
140        # Ok; no workspace to worry about; carry on.
141        if not self._initial_sign_in_completed:
142            self._initial_sign_in_completed = True
143            _babase.app.on_initial_sign_in_complete()
144
145    def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
146        """Should be called when logins for the active account change."""
147
148        for adapter in self.login_adapters.values():
149            adapter.set_active_logins(logins)
150
151    def on_implicit_sign_in(
152        self, login_type: LoginType, login_id: str, display_name: str
153    ) -> None:
154        """An implicit sign-in happened (called by native layer)."""
155        from babase._login import LoginAdapter
156
157        assert _babase.in_logic_thread()
158
159        with _babase.ContextRef.empty():
160            self.login_adapters[login_type].set_implicit_login_state(
161                LoginAdapter.ImplicitLoginState(
162                    login_id=login_id, display_name=display_name
163                )
164            )
165
166    def on_implicit_sign_out(self, login_type: LoginType) -> None:
167        """An implicit sign-out happened (called by native layer)."""
168        assert _babase.in_logic_thread()
169        with _babase.ContextRef.empty():
170            self.login_adapters[login_type].set_implicit_login_state(None)
171
172    def on_no_initial_primary_account(self) -> None:
173        """Callback run if the app has no primary account after launch.
174
175        Either this callback or on_primary_account_changed will be called
176        within a few seconds of app launch; the app can move forward
177        with the startup sequence at that point.
178        """
179        if not self._initial_sign_in_completed:
180            self._initial_sign_in_completed = True
181            _babase.app.on_initial_sign_in_complete()
182
183    @staticmethod
184    def _hashstr(val: str) -> str:
185        md5 = hashlib.md5()
186        md5.update(val.encode())
187        return md5.hexdigest()
188
189    def on_implicit_login_state_changed(
190        self,
191        login_type: LoginType,
192        state: LoginAdapter.ImplicitLoginState | None,
193    ) -> None:
194        """Called when implicit login state changes.
195
196        Login systems that tend to sign themselves in/out in the
197        background are considered implicit. We may choose to honor or
198        ignore their states, allowing the user to opt for other login
199        types even if the default implicit one can't be explicitly
200        logged out or otherwise controlled.
201        """
202        from babase._language import Lstr
203
204        assert _babase.in_logic_thread()
205
206        cfg = _babase.app.config
207        cfgkey = 'ImplicitLoginStates'
208        cfgdict = _babase.app.config.setdefault(cfgkey, {})
209
210        # Store which (if any) adapter is currently implicitly signed
211        # in. Making the assumption there will only ever be one implicit
212        # adapter at a time; may need to revisit this logic if that
213        # changes.
214        prev_state = cfgdict.get(login_type.value)
215        if state is None:
216            self._implicit_signed_in_adapter = None
217            new_state = cfgdict[login_type.value] = None
218        else:
219            self._implicit_signed_in_adapter = self.login_adapters[login_type]
220            new_state = cfgdict[login_type.value] = self._hashstr(
221                state.login_id
222            )
223
224            # Special case: if the user is already signed in but not
225            # with this implicit login, let them know that the 'Welcome
226            # back FOO' they likely just saw is not actually accurate.
227            if (
228                self.primary is not None
229                and not self.login_adapters[login_type].is_back_end_active()
230            ):
231                service_str: Lstr | None
232                if login_type is LoginType.GPGS:
233                    service_str = Lstr(resource='googlePlayText')
234                elif login_type is LoginType.GAME_CENTER:
235                    # Note: Apparently Game Center is just called 'Game
236                    # Center' in all languages. Can revisit if not true.
237                    # https://developer.apple.com/forums/thread/725779
238                    service_str = Lstr(value='Game Center')
239                elif login_type is LoginType.EMAIL:
240                    # Not possible; just here for exhaustive coverage.
241                    service_str = None
242                else:
243                    assert_never(login_type)
244                if service_str is not None:
245                    _babase.apptimer(
246                        2.0,
247                        partial(
248                            _babase.screenmessage,
249                            Lstr(
250                                resource='notUsingAccountText',
251                                subs=[
252                                    ('${ACCOUNT}', state.display_name),
253                                    ('${SERVICE}', service_str),
254                                ],
255                            ),
256                            (1, 0.5, 0),
257                        ),
258                    )
259
260        cfg.commit()
261
262        # We want to respond any time the implicit state changes;
263        # generally this means the user has explicitly signed in/out or
264        # switched accounts within that back-end.
265        if prev_state != new_state:
266            logger.debug(
267                'Implicit state changed (%s -> %s);'
268                ' will update app sign-in state accordingly.',
269                prev_state,
270                new_state,
271            )
272            self._implicit_state_changed = True
273
274        # We may want to auto-sign-in based on this new state.
275        self._update_auto_sign_in()
276
277    def _on_cloud_connectivity_changed(self, connected: bool) -> None:
278        """Should be called with cloud connectivity changes."""
279        del connected  # Unused.
280        assert _babase.in_logic_thread()
281
282        # We may want to auto-sign-in based on this new state.
283        self._update_auto_sign_in()
284
285    def do_get_primary(self) -> AccountV2Handle | None:
286        """Internal; should be overridden by subclass.
287
288        :meta private:
289        """
290        raise NotImplementedError()
291
292    def set_primary_credentials(self, credentials: str | None) -> None:
293        """Set credentials for the primary app account."""
294        raise NotImplementedError()
295
296    def _update_auto_sign_in(self) -> None:
297        plus = _babase.app.plus
298        assert plus is not None
299
300        # If implicit state has changed, try to respond.
301        if self._implicit_state_changed:
302            if self._implicit_signed_in_adapter is None:
303                # If implicit back-end has signed out, we follow suit
304                # immediately; no need to wait for network connectivity.
305                logger.debug(
306                    'Signing out as result of implicit state change...',
307                )
308                plus.accounts.set_primary_credentials(None)
309                self._implicit_state_changed = False
310
311                # Once we've made a move here we don't want to
312                # do any more automatic stuff.
313                self._can_do_auto_sign_in = False
314
315            else:
316                # Ok; we've got a new implicit state. If we've got
317                # connectivity, let's attempt to sign in with it.
318                # Consider this an 'explicit' sign in because the
319                # implicit-login state change presumably was triggered
320                # by some user action (signing in, signing out, or
321                # switching accounts via the back-end). NOTE: should
322                # test case where we don't have connectivity here.
323                if plus.cloud.is_connected():
324                    logger.debug(
325                        'Signing in as result of implicit state change...',
326                    )
327                    self._implicit_signed_in_adapter.sign_in(
328                        self._on_explicit_sign_in_completed,
329                        description='implicit state change',
330                    )
331                    self._implicit_state_changed = False
332
333                    # Once we've made a move here we don't want to
334                    # do any more automatic stuff.
335                    self._can_do_auto_sign_in = False
336
337        if not self._can_do_auto_sign_in:
338            return
339
340        # If we're not currently signed in, we have connectivity, and
341        # we have an available implicit login, auto-sign-in with it once.
342        # The implicit-state-change logic above should keep things
343        # mostly in-sync, but that might not always be the case due to
344        # connectivity or other issues. We prefer to keep people signed
345        # in as a rule, even if there are corner cases where this might
346        # not be what they want (A user signing out and then restarting
347        # may be auto-signed back in).
348        connected = plus.cloud.is_connected()
349        signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
350        signed_in_v2 = plus.accounts.have_primary_credentials()
351        if (
352            connected
353            and not signed_in_v1
354            and not signed_in_v2
355            and self._implicit_signed_in_adapter is not None
356        ):
357            logger.debug(
358                'Signing in due to on-launch-auto-sign-in...',
359            )
360            self._can_do_auto_sign_in = False  # Only ATTEMPT once
361            self._implicit_signed_in_adapter.sign_in(
362                self._on_implicit_sign_in_completed, description='auto-sign-in'
363            )
364
365    def _on_explicit_sign_in_completed(
366        self,
367        adapter: LoginAdapter,
368        result: LoginAdapter.SignInResult | Exception,
369    ) -> None:
370        """A sign-in has completed that the user asked for explicitly."""
371        from babase._language import Lstr
372
373        del adapter  # Unused.
374
375        plus = _babase.app.plus
376        assert plus is not None
377
378        # Make some noise on errors since the user knows a
379        # sign-in attempt is happening in this case (the 'explicit' part).
380        if isinstance(result, Exception):
381            # We expect the occasional communication errors;
382            # Log a full exception for anything else though.
383            if not isinstance(result, CommunicationError):
384                logging.warning(
385                    'Error on explicit accountv2 sign in attempt.',
386                    exc_info=result,
387                )
388
389            # For now just show 'error'. Should do better than this.
390            _babase.screenmessage(
391                Lstr(resource='internal.signInErrorText'),
392                color=(1, 0, 0),
393            )
394            _babase.getsimplesound('error').play()
395
396            # Also I suppose we should sign them out in this case since
397            # it could be misleading to be still signed in with the old
398            # account.
399            plus.accounts.set_primary_credentials(None)
400            return
401
402        plus.accounts.set_primary_credentials(result.credentials)
403
404    def _on_implicit_sign_in_completed(
405        self,
406        adapter: LoginAdapter,
407        result: LoginAdapter.SignInResult | Exception,
408    ) -> None:
409        """A sign-in has completed that the user didn't ask for explicitly."""
410        plus = _babase.app.plus
411        assert plus is not None
412
413        del adapter  # Unused.
414
415        # Log errors but don't inform the user; they're not aware of this
416        # attempt and ignorance is bliss.
417        if isinstance(result, Exception):
418            # We expect the occasional communication errors;
419            # Log a full exception for anything else though.
420            if not isinstance(result, CommunicationError):
421                logging.warning(
422                    'Error on implicit accountv2 sign in attempt.',
423                    exc_info=result,
424                )
425            return
426
427        # If we're still connected and still not signed in,
428        # plug in the credentials we got. We want to be extra cautious
429        # in case the user has since explicitly signed in since we
430        # kicked off.
431        connected = plus.cloud.is_connected()
432        signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
433        signed_in_v2 = plus.accounts.have_primary_credentials()
434        if connected and not signed_in_v1 and not signed_in_v2:
435            plus.accounts.set_primary_credentials(result.credentials)
436
437    def _on_set_active_workspace_completed(self) -> None:
438        if not self._initial_sign_in_completed:
439            self._initial_sign_in_completed = True
440            _babase.app.on_initial_sign_in_complete()

Subsystem for modern account handling in the app.

Access the single shared instance of this class at 'ba.app.plus.accounts'.

login_adapters: dict[bacommon.login.LoginType, LoginAdapter]
on_primary_account_changed_callbacks: efro.call.CallbackSet[typing.Callable[[AccountV2Handle | None], NoneType]]
def on_app_loading(self) -> None:
72    def on_app_loading(self) -> None:
73        """Should be called at standard on_app_loading time."""
74
75        for adapter in self.login_adapters.values():
76            adapter.on_app_loading()

Should be called at standard on_app_loading time.

def have_primary_credentials(self) -> bool:
78    def have_primary_credentials(self) -> bool:
79        """Are credentials currently set for the primary app account?
80
81        Note that this does not mean these credentials have been checked
82        for validity; only that they exist. If/when credentials are
83        validated, the 'primary' account handle will be set.
84        """
85        raise NotImplementedError()

Are credentials currently set for the primary app account?

Note that this does not mean these credentials have been checked for validity; only that they exist. If/when credentials are validated, the 'primary' account handle will be set.

primary: AccountV2Handle | None
87    @property
88    def primary(self) -> AccountV2Handle | None:
89        """The primary account for the app, or None if not logged in."""
90        return self.do_get_primary()

The primary account for the app, or None if not logged in.

def on_primary_account_changed(self, account: AccountV2Handle | None) -> None:
 92    def on_primary_account_changed(
 93        self, account: AccountV2Handle | None
 94    ) -> None:
 95        """Callback run after the primary account changes.
 96
 97        Will be called with None on log-outs and when new credentials
 98        are set but have not yet been verified.
 99        """
100        assert _babase.in_logic_thread()
101
102        # Fire any registered callbacks.
103        for call in self.on_primary_account_changed_callbacks.getcalls():
104            try:
105                call(account)
106            except Exception:
107                logging.exception('Error in primary-account-changed callback.')
108
109        # Currently don't do anything special on sign-outs.
110        if account is None:
111            return
112
113        # If this new account has a workspace, update it and ask to be
114        # informed when that process completes.
115        if account.workspaceid is not None:
116            assert account.workspacename is not None
117            if (
118                not self._initial_sign_in_completed
119                and not self._kicked_off_workspace_load
120            ):
121                self._kicked_off_workspace_load = True
122                _babase.app.workspaces.set_active_workspace(
123                    account=account,
124                    workspaceid=account.workspaceid,
125                    workspacename=account.workspacename,
126                    on_completed=self._on_set_active_workspace_completed,
127                )
128            else:
129                # Don't activate workspaces if we've already told the
130                # game that initial-log-in is done or if we've already
131                # kicked off a workspace load.
132                _babase.screenmessage(
133                    f'\'{account.workspacename}\''
134                    f' will be activated at next app launch.',
135                    color=(1, 1, 0),
136                )
137                _babase.getsimplesound('error').play()
138            return
139
140        # Ok; no workspace to worry about; carry on.
141        if not self._initial_sign_in_completed:
142            self._initial_sign_in_completed = True
143            _babase.app.on_initial_sign_in_complete()

Callback run after the primary account changes.

Will be called with None on log-outs and when new credentials are set but have not yet been verified.

def on_active_logins_changed(self, logins: dict[bacommon.login.LoginType, str]) -> None:
145    def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
146        """Should be called when logins for the active account change."""
147
148        for adapter in self.login_adapters.values():
149            adapter.set_active_logins(logins)

Should be called when logins for the active account change.

def on_implicit_sign_in( self, login_type: bacommon.login.LoginType, login_id: str, display_name: str) -> None:
151    def on_implicit_sign_in(
152        self, login_type: LoginType, login_id: str, display_name: str
153    ) -> None:
154        """An implicit sign-in happened (called by native layer)."""
155        from babase._login import LoginAdapter
156
157        assert _babase.in_logic_thread()
158
159        with _babase.ContextRef.empty():
160            self.login_adapters[login_type].set_implicit_login_state(
161                LoginAdapter.ImplicitLoginState(
162                    login_id=login_id, display_name=display_name
163                )
164            )

An implicit sign-in happened (called by native layer).

def on_implicit_sign_out(self, login_type: bacommon.login.LoginType) -> None:
166    def on_implicit_sign_out(self, login_type: LoginType) -> None:
167        """An implicit sign-out happened (called by native layer)."""
168        assert _babase.in_logic_thread()
169        with _babase.ContextRef.empty():
170            self.login_adapters[login_type].set_implicit_login_state(None)

An implicit sign-out happened (called by native layer).

def on_no_initial_primary_account(self) -> None:
172    def on_no_initial_primary_account(self) -> None:
173        """Callback run if the app has no primary account after launch.
174
175        Either this callback or on_primary_account_changed will be called
176        within a few seconds of app launch; the app can move forward
177        with the startup sequence at that point.
178        """
179        if not self._initial_sign_in_completed:
180            self._initial_sign_in_completed = True
181            _babase.app.on_initial_sign_in_complete()

Callback run if the app has no primary account after launch.

Either this callback or on_primary_account_changed will be called within a few seconds of app launch; the app can move forward with the startup sequence at that point.

def on_implicit_login_state_changed( self, login_type: bacommon.login.LoginType, state: LoginAdapter.ImplicitLoginState | None) -> None:
189    def on_implicit_login_state_changed(
190        self,
191        login_type: LoginType,
192        state: LoginAdapter.ImplicitLoginState | None,
193    ) -> None:
194        """Called when implicit login state changes.
195
196        Login systems that tend to sign themselves in/out in the
197        background are considered implicit. We may choose to honor or
198        ignore their states, allowing the user to opt for other login
199        types even if the default implicit one can't be explicitly
200        logged out or otherwise controlled.
201        """
202        from babase._language import Lstr
203
204        assert _babase.in_logic_thread()
205
206        cfg = _babase.app.config
207        cfgkey = 'ImplicitLoginStates'
208        cfgdict = _babase.app.config.setdefault(cfgkey, {})
209
210        # Store which (if any) adapter is currently implicitly signed
211        # in. Making the assumption there will only ever be one implicit
212        # adapter at a time; may need to revisit this logic if that
213        # changes.
214        prev_state = cfgdict.get(login_type.value)
215        if state is None:
216            self._implicit_signed_in_adapter = None
217            new_state = cfgdict[login_type.value] = None
218        else:
219            self._implicit_signed_in_adapter = self.login_adapters[login_type]
220            new_state = cfgdict[login_type.value] = self._hashstr(
221                state.login_id
222            )
223
224            # Special case: if the user is already signed in but not
225            # with this implicit login, let them know that the 'Welcome
226            # back FOO' they likely just saw is not actually accurate.
227            if (
228                self.primary is not None
229                and not self.login_adapters[login_type].is_back_end_active()
230            ):
231                service_str: Lstr | None
232                if login_type is LoginType.GPGS:
233                    service_str = Lstr(resource='googlePlayText')
234                elif login_type is LoginType.GAME_CENTER:
235                    # Note: Apparently Game Center is just called 'Game
236                    # Center' in all languages. Can revisit if not true.
237                    # https://developer.apple.com/forums/thread/725779
238                    service_str = Lstr(value='Game Center')
239                elif login_type is LoginType.EMAIL:
240                    # Not possible; just here for exhaustive coverage.
241                    service_str = None
242                else:
243                    assert_never(login_type)
244                if service_str is not None:
245                    _babase.apptimer(
246                        2.0,
247                        partial(
248                            _babase.screenmessage,
249                            Lstr(
250                                resource='notUsingAccountText',
251                                subs=[
252                                    ('${ACCOUNT}', state.display_name),
253                                    ('${SERVICE}', service_str),
254                                ],
255                            ),
256                            (1, 0.5, 0),
257                        ),
258                    )
259
260        cfg.commit()
261
262        # We want to respond any time the implicit state changes;
263        # generally this means the user has explicitly signed in/out or
264        # switched accounts within that back-end.
265        if prev_state != new_state:
266            logger.debug(
267                'Implicit state changed (%s -> %s);'
268                ' will update app sign-in state accordingly.',
269                prev_state,
270                new_state,
271            )
272            self._implicit_state_changed = True
273
274        # We may want to auto-sign-in based on this new state.
275        self._update_auto_sign_in()

Called when implicit login state changes.

Login systems that tend to sign themselves in/out in the background are considered implicit. We may choose to honor or ignore their states, allowing the user to opt for other login types even if the default implicit one can't be explicitly logged out or otherwise controlled.

def do_get_primary(self) -> AccountV2Handle | None:
285    def do_get_primary(self) -> AccountV2Handle | None:
286        """Internal; should be overridden by subclass.
287
288        :meta private:
289        """
290        raise NotImplementedError()

Internal; should be overridden by subclass.

:meta private:

def set_primary_credentials(self, credentials: str | None) -> None:
292    def set_primary_credentials(self, credentials: str | None) -> None:
293        """Set credentials for the primary app account."""
294        raise NotImplementedError()

Set credentials for the primary app account.

class ActivityNotFoundError(babase.NotFoundError):
61class ActivityNotFoundError(NotFoundError):
62    """Exception raised when an expected bascenev1.Activity does not exist."""

Exception raised when an expected bascenev1.Activity does not exist.

class ActorNotFoundError(babase.NotFoundError):
57class ActorNotFoundError(NotFoundError):
58    """Exception raised when an expected actor does not exist."""

Exception raised when an expected actor does not exist.

def allows_ticket_sales() -> bool:
506def allows_ticket_sales() -> bool:
507    """:meta private:"""
508    return bool()

:meta private:

app = <App object>
class App:
  52class App:
  53    """High level Ballistica app functionality and state.
  54
  55    Access the single shared instance of this class via the "app" attr
  56    available on various high level modules such as :mod:`bauiv1` and
  57    :mod:`bascenev1`.
  58    """
  59
  60    # pylint: disable=too-many-public-methods
  61
  62    class State(Enum):
  63        """High level state the app can be in."""
  64
  65        #: The app has not yet begun starting and should not be used in
  66        #: any way.
  67        NOT_STARTED = 0
  68
  69        #: The native layer is spinning up its machinery (screens,
  70        #: renderers, etc.). Nothing should happen in the Python layer
  71        #: until this completes.
  72        NATIVE_BOOTSTRAPPING = 1
  73
  74        #: Python app subsystems are being inited but should not yet
  75        #: interact or do any work.
  76        INITING = 2
  77
  78        #: Python app subsystems are inited and interacting, but the app
  79        #: has not yet embarked on a high level course of action. It is
  80        #: doing initial account logins, workspace & asset downloads,
  81        #: etc.
  82        LOADING = 3
  83
  84        #: All pieces are in place and the app is now doing its thing.
  85        RUNNING = 4
  86
  87        #: Used on platforms such as mobile where the app basically needs
  88        #: to shut down while backgrounded. In this state, all event
  89        #: loops are suspended and all graphics and audio must cease
  90        #: completely. Be aware that the suspended state can be entered
  91        #: from any other state including NATIVE_BOOTSTRAPPING and
  92        #: SHUTTING_DOWN.
  93        SUSPENDED = 5
  94
  95        #: The app is shutting down. This process may involve sending
  96        #: network messages or other things that can take up to a few
  97        #: seconds, so ideally graphics and audio should remain
  98        #: functional (with fades or spinners or whatever to show
  99        #: something is happening).
 100        SHUTTING_DOWN = 6
 101
 102        #: The app has completed shutdown. Any code running here should
 103        #: be basically immediate.
 104        SHUTDOWN_COMPLETE = 7
 105
 106    class DefaultAppModeSelector(AppModeSelector):
 107        """Decides which AppMode to use to handle AppIntents.
 108
 109        This default version is generated by the project updater based
 110        on the 'default_app_modes' value in the projectconfig.
 111
 112        It is also possible to modify app mode selection behavior by
 113        setting app.mode_selector to an instance of a custom
 114        AppModeSelector subclass. This is a good way to go if you are
 115        modifying app behavior dynamically via a plugin instead of
 116        statically in a spinoff project.
 117        """
 118
 119        @override
 120        def app_mode_for_intent(
 121            self, intent: AppIntent
 122        ) -> type[AppMode] | None:
 123            # pylint: disable=cyclic-import
 124
 125            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
 126            # This section generated by batools.appmodule; do not edit.
 127
 128            # Ask our default app modes to handle it.
 129            # (generated from 'default_app_modes' in projectconfig).
 130            import baclassic
 131            import babase
 132
 133            for appmode in [
 134                baclassic.ClassicAppMode,
 135                babase.EmptyAppMode,
 136            ]:
 137                if appmode.can_handle_intent(intent):
 138                    return appmode
 139
 140            return None
 141
 142            # __DEFAULT_APP_MODE_SELECTION_END__
 143
 144    # A few things defined as non-optional values but not actually
 145    # available until the app starts.
 146    plugins: PluginSubsystem
 147    lang: LanguageSubsystem
 148    health_monitor: AppHealthMonitor
 149
 150    # Define some other types here in the class-def so docs-generators
 151    # are more likely to know about them.
 152    config: AppConfig
 153    env: babase.Env
 154    state: State
 155    threadpool: ThreadPoolExecutorPlus
 156    meta: MetadataSubsystem
 157    net: NetworkSubsystem
 158    workspaces: WorkspaceSubsystem
 159    components: AppComponentSubsystem
 160    stringedit: StringEditSubsystem
 161    devconsole: DevConsoleSubsystem
 162    fg_state: int
 163
 164    #: How long we allow shutdown tasks to run before killing them.
 165    #: Currently the entire app hard-exits if shutdown takes 15 seconds,
 166    #: so we need to keep it under that. Staying above 10 should allow
 167    #: 10 second network timeouts to happen though.
 168    SHUTDOWN_TASK_TIMEOUT_SECONDS = 12
 169
 170    def __init__(self) -> None:
 171        """(internal)
 172
 173        Do not instantiate this class. You can access the single shared
 174        instance of it through various high level packages: 'babase.app',
 175        'bascenev1.app', 'bauiv1.app', etc.
 176        """
 177
 178        # Hack for docs-generation: we can be imported with dummy modules
 179        # instead of our actual binary ones, but we don't function.
 180        if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
 181            return
 182
 183        # Wrap our raw app config in our special wrapper and pass it to
 184        # the native layer.
 185        self.config = AppConfig(_babase.get_initial_app_config())
 186        _babase.set_app_config(self.config)
 187
 188        self.env = _babase.Env()
 189        self.state = self.State.NOT_STARTED
 190
 191        # Default executor which can be used for misc background
 192        # processing. It should also be passed to any additional asyncio
 193        # loops we create so that everything shares the same single set
 194        # of worker threads.
 195        self.threadpool = ThreadPoolExecutorPlus(
 196            thread_name_prefix='baworker',
 197            initializer=self._thread_pool_thread_init,
 198        )
 199        self.meta = MetadataSubsystem()
 200        self.net = NetworkSubsystem()
 201        self.workspaces = WorkspaceSubsystem()
 202        self.components = AppComponentSubsystem()
 203        self.stringedit = StringEditSubsystem()
 204        self.devconsole = DevConsoleSubsystem()
 205
 206        # This is incremented any time the app is backgrounded or
 207        # foregrounded; can be a simple way to determine if network data
 208        # should be refreshed/etc.
 209        self.fg_state = 0
 210
 211        self._subsystems: list[AppSubsystem] = []
 212        self._native_bootstrapping_completed = False
 213        self._init_completed = False
 214        self._meta_scan_completed = False
 215        self._native_start_called = False
 216        self._native_suspended = False
 217        self._native_shutdown_called = False
 218        self._native_shutdown_complete_called = False
 219        self._initial_sign_in_completed = False
 220        self._called_on_initing = False
 221        self._called_on_loading = False
 222        self._called_on_running = False
 223        self._subsystem_registration_ended = False
 224        self._pending_apply_app_config = False
 225        self._asyncio_loop: asyncio.AbstractEventLoop | None = None
 226        self._asyncio_tasks: set[asyncio.Task] = set()
 227        self._asyncio_timer: babase.AppTimer | None = None
 228        self._pending_intent: AppIntent | None = None
 229        self._intent: AppIntent | None = None
 230        self._mode_selector: babase.AppModeSelector | None = None
 231        self._mode_instances: dict[type[AppMode], AppMode] = {}
 232        self._mode: AppMode | None = None
 233        self._shutdown_task: asyncio.Task[None] | None = None
 234        self._shutdown_tasks: list[Coroutine[None, None, None]] = [
 235            self._wait_for_shutdown_suppressions(),
 236            self._fade_and_shutdown_graphics(),
 237            self._fade_and_shutdown_audio(),
 238        ]
 239        self._pool_thread_count = 0
 240
 241        # We hold a lock while lazy-loading our subsystem properties so
 242        # we don't spin up any subsystem more than once, but the lock is
 243        # recursive so that the subsystems can instantiate other
 244        # subsystems.
 245        self._subsystem_property_lock = RLock()
 246        self._subsystem_property_data: dict[str, AppSubsystem | bool] = {}
 247
 248    def postinit(self) -> None:
 249        """Called after we've been inited and assigned to babase.app.
 250
 251        Anything that accesses babase.app as part of its init process
 252        must go here instead of __init__.
 253        """
 254
 255        # Hack for docs-generation: We can be imported with dummy
 256        # modules instead of our actual binary ones, but we don't
 257        # function.
 258        if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
 259            return
 260
 261        self.lang = LanguageSubsystem()
 262        self.plugins = PluginSubsystem()
 263
 264    @property
 265    def active(self) -> bool:
 266        """Whether the app is currently front and center.
 267
 268        This will be False when the app is hidden, other activities
 269        are covering it, etc. (depending on the platform).
 270        """
 271        return _babase.app_is_active()
 272
 273    @property
 274    def mode(self) -> AppMode | None:
 275        """The app's current mode."""
 276        assert _babase.in_logic_thread()
 277        return self._mode
 278
 279    @property
 280    def asyncio_loop(self) -> asyncio.AbstractEventLoop:
 281        """The logic thread's asyncio event loop.
 282
 283        This allow async tasks to be run in the logic thread.
 284
 285        Generally you should call App.create_async_task() to schedule
 286        async code to run instead of using this directly. That will
 287        handle retaining the task and logging errors automatically.
 288        Only schedule tasks onto asyncio_loop yourself when you intend
 289        to hold on to the returned task and await its results. Releasing
 290        the task reference can lead to subtle bugs such as unreported
 291        errors and garbage-collected tasks disappearing before their
 292        work is done.
 293
 294        Note that, at this time, the asyncio loop is encapsulated
 295        and explicitly stepped by the engine's logic thread loop and
 296        thus things like asyncio.get_running_loop() will unintuitively
 297        *not* return this loop from most places in the logic thread;
 298        only from within a task explicitly created in this loop.
 299        Hopefully this situation will be improved in the future with a
 300        unified event loop.
 301        """
 302        assert _babase.in_logic_thread()
 303        assert self._asyncio_loop is not None
 304        return self._asyncio_loop
 305
 306    def create_async_task(
 307        self, coro: Coroutine[Any, Any, T], *, name: str | None = None
 308    ) -> None:
 309        """Create a fully managed async task.
 310
 311        This will automatically retain and release a reference to the task
 312        and log any exceptions that occur in it. If you need to await a task
 313        or otherwise need more control, schedule a task directly using
 314        App.asyncio_loop.
 315        """
 316        assert _babase.in_logic_thread()
 317
 318        # We hold a strong reference to the task until it is done.
 319        # Otherwise it is possible for it to be garbage collected and
 320        # disappear midway if the caller does not hold on to the
 321        # returned task, which seems like a great way to introduce
 322        # hard-to-track bugs.
 323        task = self.asyncio_loop.create_task(coro, name=name)
 324        self._asyncio_tasks.add(task)
 325        task.add_done_callback(self._on_task_done)
 326
 327    def _on_task_done(self, task: asyncio.Task) -> None:
 328        # Report any errors that occurred.
 329        try:
 330            exc = task.exception()
 331            if exc is not None:
 332                logging.error(
 333                    "Error in async task '%s'.", task.get_name(), exc_info=exc
 334                )
 335        except Exception:
 336            logging.exception('Error reporting async task error.')
 337
 338        self._asyncio_tasks.remove(task)
 339
 340    @property
 341    def mode_selector(self) -> babase.AppModeSelector:
 342        """Controls which app-modes are used for handling given intents.
 343
 344        Plugins can override this to change high level app behavior and
 345        spinoff projects can change the default implementation for the
 346        same effect.
 347        """
 348        if self._mode_selector is None:
 349            raise RuntimeError(
 350                'mode_selector cannot be used until the app reaches'
 351                ' the running state.'
 352            )
 353        return self._mode_selector
 354
 355    @mode_selector.setter
 356    def mode_selector(self, selector: babase.AppModeSelector) -> None:
 357        self._mode_selector = selector
 358
 359    def _get_subsystem_property(
 360        self, ssname: str, create_call: Callable[[], AppSubsystem | None]
 361    ) -> AppSubsystem | None:
 362
 363        # Quick-out: if a subsystem is present, just return it; no
 364        # locking necessary.
 365        val = self._subsystem_property_data.get(ssname)
 366        if val is not None:
 367            if val is False:
 368                # False means subsystem is confirmed as unavailable.
 369                return None
 370            if val is not True:
 371                # A subsystem has been set. Return it.
 372                return val
 373
 374        # Anything else (no val present or val True) requires locking.
 375        with self._subsystem_property_lock:
 376            val = self._subsystem_property_data.get(ssname)
 377            if val is not None:
 378                if val is False:
 379                    # False means confirmed as not present.
 380                    return None
 381                if val is True:
 382                    # True means this property is already being loaded,
 383                    # and the fact that we're holding the lock means
 384                    # we're doing the loading, so this is a dependency
 385                    # loop. Not good.
 386                    raise RuntimeError(
 387                        f'Subsystem dependency loop detected for {ssname}'
 388                    )
 389                # Must be an instantiated subsystem. Noice.
 390                return val
 391
 392            # Ok, there's nothing here for it. Instantiate and set it
 393            # while we hold the lock. Set a placeholder value of True
 394            # while we load so we can error if something we're loading
 395            # tries to recursively load us.
 396            self._subsystem_property_data[ssname] = True
 397
 398            # Do our one attempt to create the singleton.
 399            val = create_call()
 400            self._subsystem_property_data[ssname] = (
 401                False if val is None else val
 402            )
 403
 404        return val
 405
 406    # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__
 407    # This section generated by batools.appmodule; do not edit.
 408
 409    @property
 410    def classic(self) -> ClassicAppSubsystem | None:
 411        """Our classic subsystem (if available)."""
 412        return self._get_subsystem_property(
 413            'classic', self._create_classic_subsystem
 414        )  # type: ignore
 415
 416    @staticmethod
 417    def _create_classic_subsystem() -> ClassicAppSubsystem | None:
 418        # pylint: disable=cyclic-import
 419        try:
 420            from baclassic import ClassicAppSubsystem
 421
 422            return ClassicAppSubsystem()
 423        except ImportError:
 424            return None
 425        except Exception:
 426            logging.exception('Error importing baclassic.')
 427            return None
 428
 429    @property
 430    def plus(self) -> PlusAppSubsystem | None:
 431        """Our plus subsystem (if available)."""
 432        return self._get_subsystem_property(
 433            'plus', self._create_plus_subsystem
 434        )  # type: ignore
 435
 436    @staticmethod
 437    def _create_plus_subsystem() -> PlusAppSubsystem | None:
 438        # pylint: disable=cyclic-import
 439        try:
 440            from baplus import PlusAppSubsystem
 441
 442            return PlusAppSubsystem()
 443        except ImportError:
 444            return None
 445        except Exception:
 446            logging.exception('Error importing baplus.')
 447            return None
 448
 449    @property
 450    def ui_v1(self) -> UIV1AppSubsystem:
 451        """Our ui_v1 subsystem (always available)."""
 452        return self._get_subsystem_property(
 453            'ui_v1', self._create_ui_v1_subsystem
 454        )  # type: ignore
 455
 456    @staticmethod
 457    def _create_ui_v1_subsystem() -> UIV1AppSubsystem:
 458        # pylint: disable=cyclic-import
 459
 460        from bauiv1 import UIV1AppSubsystem
 461
 462        return UIV1AppSubsystem()
 463
 464    # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
 465
 466    def register_subsystem(self, subsystem: AppSubsystem) -> None:
 467        """Called by the AppSubsystem class. Do not use directly."""
 468
 469        # We only allow registering new subsystems if we've not yet
 470        # reached the 'running' state. This ensures that all subsystems
 471        # receive a consistent set of callbacks starting with
 472        # on_app_running().
 473
 474        if self._subsystem_registration_ended:
 475            raise RuntimeError(
 476                'Subsystems can no longer be registered at this point.'
 477            )
 478        self._subsystems.append(subsystem)
 479
 480    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
 481        """Add a task to be run on app shutdown.
 482
 483        Note that shutdown tasks will be canceled after
 484        :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still
 485        running.
 486        """
 487        if (
 488            self.state is self.State.SHUTTING_DOWN
 489            or self.state is self.State.SHUTDOWN_COMPLETE
 490        ):
 491            stname = self.state.name
 492            raise RuntimeError(
 493                f'Cannot add shutdown tasks with current state {stname}.'
 494            )
 495        self._shutdown_tasks.append(coro)
 496
 497    def run(self) -> None:
 498        """Run the app to completion.
 499
 500        Note that this only works on builds where Ballistica manages
 501        its own event loop.
 502        """
 503        _babase.run_app()
 504
 505    def set_intent(self, intent: AppIntent) -> None:
 506        """Set the intent for the app.
 507
 508        Intent defines what the app is trying to do at a given time.
 509        This call is asynchronous; the intent switch will happen in the
 510        logic thread in the near future. If set_intent is called
 511        repeatedly before the change takes place, the final intent to be
 512        set will be used.
 513        """
 514
 515        # Mark this one as pending. We do this synchronously so that the
 516        # last one marked actually takes effect if there is overlap
 517        # (doing this in the bg thread could result in race conditions).
 518        self._pending_intent = intent
 519
 520        # Do the actual work of calcing our app-mode/etc. in a bg thread
 521        # since it may block for a moment to load modules/etc.
 522        self.threadpool.submit_no_wait(self._set_intent, intent)
 523
 524    def push_apply_app_config(self) -> None:
 525        """Internal. Use app.config.apply() to apply app config changes."""
 526        # To be safe, let's run this by itself in the event loop.
 527        # This avoids potential trouble if this gets called mid-draw or
 528        # something like that.
 529        self._pending_apply_app_config = True
 530        _babase.pushcall(self._apply_app_config, raw=True)
 531
 532    def on_native_start(self) -> None:
 533        """Called by the native layer when the app is being started."""
 534        assert _babase.in_logic_thread()
 535        assert not self._native_start_called
 536        self._native_start_called = True
 537        self._update_state()
 538
 539    def on_native_bootstrapping_complete(self) -> None:
 540        """Called by the native layer once its ready to rock."""
 541        assert _babase.in_logic_thread()
 542        assert not self._native_bootstrapping_completed
 543        self._native_bootstrapping_completed = True
 544        self._update_state()
 545
 546    def on_native_suspend(self) -> None:
 547        """Called by the native layer when the app is suspended."""
 548        assert _babase.in_logic_thread()
 549        assert not self._native_suspended  # Should avoid redundant calls.
 550        self._native_suspended = True
 551        self._update_state()
 552
 553    def on_native_unsuspend(self) -> None:
 554        """Called by the native layer when the app suspension ends."""
 555        assert _babase.in_logic_thread()
 556        assert self._native_suspended  # Should avoid redundant calls.
 557        self._native_suspended = False
 558        self._update_state()
 559
 560    def on_native_shutdown(self) -> None:
 561        """Called by the native layer when the app starts shutting down."""
 562        assert _babase.in_logic_thread()
 563        self._native_shutdown_called = True
 564        self._update_state()
 565
 566    def on_native_shutdown_complete(self) -> None:
 567        """Called by the native layer when the app is done shutting down."""
 568        assert _babase.in_logic_thread()
 569        self._native_shutdown_complete_called = True
 570        self._update_state()
 571
 572    def on_native_active_changed(self) -> None:
 573        """Called by the native layer when the app active state changes."""
 574        assert _babase.in_logic_thread()
 575        if self._mode is not None:
 576            self._mode.on_app_active_changed()
 577
 578    def handle_deep_link(self, url: str) -> None:
 579        """Handle a deep link URL."""
 580        from babase._language import Lstr
 581
 582        assert _babase.in_logic_thread()
 583
 584        appname = _babase.appname()
 585        if url.startswith(f'{appname}://code/'):
 586            code = url.replace(f'{appname}://code/', '')
 587            if self.classic is not None:
 588                self.classic.accounts.add_pending_promo_code(code)
 589        else:
 590            try:
 591                _babase.screenmessage(
 592                    Lstr(resource='errorText'), color=(1, 0, 0)
 593                )
 594                _babase.getsimplesound('error').play()
 595            except ImportError:
 596                pass
 597
 598    def on_initial_sign_in_complete(self) -> None:
 599        """Called when initial sign-in (or lack thereof) completes.
 600
 601        This normally gets called by the plus subsystem. The
 602        initial-sign-in process may include tasks such as syncing
 603        account workspaces or other data so it may take a substantial
 604        amount of time.
 605        """
 606        assert _babase.in_logic_thread()
 607        assert not self._initial_sign_in_completed
 608
 609        # Tell meta it can start scanning extra stuff that just showed
 610        # up (namely account workspaces).
 611        self.meta.start_extra_scan()
 612
 613        self._initial_sign_in_completed = True
 614        self._update_state()
 615
 616    def set_ui_scale(self, scale: babase.UIScale) -> None:
 617        """Change ui-scale on the fly.
 618
 619        Currently this is mainly for debugging and will not be called as
 620        part of normal app operation.
 621        """
 622        assert _babase.in_logic_thread()
 623
 624        # Apply to the native layer.
 625        _babase.set_ui_scale(scale.name.lower())
 626
 627        # Inform all subsystems that something screen-related has
 628        # changed. We assume subsystems won't be added at this point so
 629        # we can use the list directly.
 630        assert self._subsystem_registration_ended
 631        for subsystem in self._subsystems:
 632            try:
 633                subsystem.on_ui_scale_change()
 634            except Exception:
 635                logging.exception(
 636                    'Error in on_ui_scale_change() for subsystem %s.', subsystem
 637                )
 638
 639    def on_screen_size_change(self) -> None:
 640        """Screen size has changed."""
 641
 642        # Inform all app subsystems in the same order they were inited.
 643        # Operate on a copy of the list here because this can be called
 644        # while subsystems are still being added.
 645        for subsystem in self._subsystems.copy():
 646            try:
 647                subsystem.on_screen_size_change()
 648            except Exception:
 649                logging.exception(
 650                    'Error in on_screen_size_change() for subsystem %s.',
 651                    subsystem,
 652                )
 653
 654    def _set_intent(self, intent: AppIntent) -> None:
 655        from babase._appmode import AppMode
 656
 657        # This should be happening in a bg thread.
 658        assert not _babase.in_logic_thread()
 659        try:
 660            # Ask the selector what app-mode to use for this intent.
 661            if self.mode_selector is None:
 662                raise RuntimeError('No AppModeSelector set.')
 663
 664            modetype: type[AppMode] | None
 665
 666            # Special case - for testing we may force a specific
 667            # app-mode to handle this intent instead of going through our
 668            # usual selector.
 669            forced_mode_type = getattr(intent, '_force_app_mode_handler', None)
 670            if isinstance(forced_mode_type, type) and issubclass(
 671                forced_mode_type, AppMode
 672            ):
 673                modetype = forced_mode_type
 674            else:
 675                modetype = self.mode_selector.app_mode_for_intent(intent)
 676
 677            # NOTE: Since intents are somewhat high level things,
 678            # perhaps we should do some universal thing like a
 679            # screenmessage saying 'The app cannot handle the request'
 680            # on failure.
 681
 682            if modetype is None:
 683                raise RuntimeError(
 684                    f'No app-mode found to handle app-intent'
 685                    f' type {type(intent)}.'
 686                )
 687
 688            # Make sure the app-mode the selector gave us *actually*
 689            # supports the intent.
 690            if not modetype.can_handle_intent(intent):
 691                raise RuntimeError(
 692                    f'Intent {intent} cannot be handled by AppMode type'
 693                    f' {modetype} (selector {self.mode_selector}'
 694                    f' incorrectly thinks that it can be).'
 695                )
 696
 697            # Ok; seems legit. Now instantiate the mode if necessary and
 698            # kick back to the logic thread to apply.
 699            mode = self._mode_instances.get(modetype)
 700            if mode is None:
 701                self._mode_instances[modetype] = mode = modetype()
 702            _babase.pushcall(
 703                partial(self._apply_intent, intent, mode),
 704                from_other_thread=True,
 705            )
 706        except Exception:
 707            logging.exception('Error setting app intent to %s.', intent)
 708            _babase.pushcall(
 709                partial(self._display_set_intent_error, intent),
 710                from_other_thread=True,
 711            )
 712
 713    def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None:
 714        assert _babase.in_logic_thread()
 715
 716        # ONLY apply this intent if it is still the most recent one
 717        # submitted.
 718        if intent is not self._pending_intent:
 719            return
 720
 721        # If the app-mode for this intent is different than the active
 722        # one, switch modes.
 723        if type(mode) is not type(self._mode):
 724            if self._mode is None:
 725                is_initial_mode = True
 726            else:
 727                is_initial_mode = False
 728                try:
 729                    self._mode.on_deactivate()
 730                except Exception:
 731                    logging.exception(
 732                        'Error deactivating app-mode %s.', self._mode
 733                    )
 734
 735            # Reset all subsystems. We assume subsystems won't be added
 736            # at this point so we can use the list directly.
 737            assert self._subsystem_registration_ended
 738            for subsystem in self._subsystems:
 739                try:
 740                    subsystem.reset()
 741                except Exception:
 742                    logging.exception(
 743                        'Error in reset() for subsystem %s.', subsystem
 744                    )
 745
 746            self._mode = mode
 747            try:
 748                mode.on_activate()
 749            except Exception:
 750                # Hmm; what should we do in this case?...
 751                logging.exception('Error activating app-mode %s.', mode)
 752
 753            # Let the world know when we first have an app-mode; certain
 754            # app stuff such as input processing can proceed at that
 755            # point.
 756            if is_initial_mode:
 757                _babase.on_initial_app_mode_set()
 758
 759        try:
 760            mode.handle_intent(intent)
 761        except Exception:
 762            logging.exception(
 763                'Error handling intent %s in app-mode %s.', intent, mode
 764            )
 765
 766    def _display_set_intent_error(self, intent: AppIntent) -> None:
 767        """Show the *user* something went wrong setting an intent."""
 768        from babase._language import Lstr
 769
 770        del intent
 771        _babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
 772        _babase.getsimplesound('error').play()
 773
 774    def _on_initing(self) -> None:
 775        """Called when the app enters the initing state.
 776
 777        Here we can put together subsystems and other pieces for the
 778        app, but most things should not be doing any work yet.
 779        """
 780        # pylint: disable=cyclic-import
 781        from babase import _asyncio
 782        from babase import _appconfig
 783        from babase._apputils import AppHealthMonitor
 784        from babase import _env
 785
 786        assert _babase.in_logic_thread()
 787
 788        _env.on_app_state_initing()
 789
 790        self._asyncio_loop = _asyncio.setup_asyncio()
 791        self.health_monitor = AppHealthMonitor()
 792
 793        # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
 794        # This section generated by batools.appmodule; do not edit.
 795
 796        # Poke these attrs to create all our subsystems.
 797        _ = self.plus
 798        _ = self.classic
 799        _ = self.ui_v1
 800
 801        # __FEATURESET_APP_SUBSYSTEM_CREATE_END__
 802
 803        # We're a pretty short-lived state. This should flip us to
 804        # 'loading'.
 805        self._init_completed = True
 806        self._update_state()
 807
 808    def _on_loading(self) -> None:
 809        """Called when we enter the loading state.
 810
 811        At this point, all built-in pieces of the app should be in place
 812        and can start talking to each other and doing work. Though at a
 813        high level, the goal of the app at this point is only to sign in
 814        to initial accounts, download workspaces, and otherwise prepare
 815        itself to really 'run'.
 816        """
 817        assert _babase.in_logic_thread()
 818
 819        # Get meta-system scanning built-in stuff in the bg.
 820        self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete)
 821
 822        # Inform all app subsystems in the same order they were inited.
 823        # Operate on a copy of the list here because subsystems can
 824        # still be added at this point.
 825        for subsystem in self._subsystems.copy():
 826            try:
 827                subsystem.on_app_loading()
 828            except Exception:
 829                logging.exception(
 830                    'Error in on_app_loading() for subsystem %s.', subsystem
 831                )
 832
 833        # Normally plus tells us when initial sign-in is done. If plus
 834        # is not present, however, we just do it ourself so we can
 835        # proceed on to the running state.
 836        if self.plus is None:
 837            _babase.pushcall(self.on_initial_sign_in_complete)
 838
 839    def _on_meta_scan_complete(self) -> None:
 840        """Called when meta-scan is done doing its thing."""
 841        assert _babase.in_logic_thread()
 842
 843        # Now that we know what's out there, build our final plugin set.
 844        self.plugins.on_meta_scan_complete()
 845
 846        assert not self._meta_scan_completed
 847        self._meta_scan_completed = True
 848        self._update_state()
 849
 850    def _on_running(self) -> None:
 851        """Called when we enter the running state.
 852
 853        At this point, all workspaces, initial accounts, etc. are in place
 854        and we can actually get started doing whatever we're gonna do.
 855        """
 856        assert _babase.in_logic_thread()
 857
 858        # Let our native layer know.
 859        _babase.on_app_running()
 860
 861        # Set a default app-mode-selector if none has been set yet
 862        # by a plugin or whatnot.
 863        if self._mode_selector is None:
 864            self._mode_selector = self.DefaultAppModeSelector()
 865
 866        # Inform all app subsystems in the same order they were
 867        # registered. Operate on a copy here because subsystems can
 868        # still be added at this point.
 869        #
 870        # NOTE: Do we need to allow registering still at this point? If
 871        # something gets registered here, it won't have its
 872        # on_app_running callback called. Hmm; I suppose that's the only
 873        # way that plugins can register subsystems though.
 874        for subsystem in self._subsystems.copy():
 875            try:
 876                subsystem.on_app_running()
 877            except Exception:
 878                logging.exception(
 879                    'Error in on_app_running() for subsystem %s.', subsystem
 880                )
 881
 882        # Cut off new subsystem additions at this point.
 883        self._subsystem_registration_ended = True
 884
 885        # If 'exec' code was provided to the app, always kick that off
 886        # here as an intent.
 887        exec_cmd = _babase.exec_arg()
 888        if exec_cmd is not None:
 889            self.set_intent(AppIntentExec(exec_cmd))
 890        elif self._pending_intent is None:
 891            # Otherwise tell the app to do its default thing *only* if a
 892            # plugin hasn't already told it to do something.
 893            self.set_intent(AppIntentDefault())
 894
 895    def _apply_app_config(self) -> None:
 896        assert _babase.in_logic_thread()
 897
 898        lifecyclelog.info('apply-app-config')
 899
 900        # If multiple apply calls have been made, only actually apply
 901        # once.
 902        if not self._pending_apply_app_config:
 903            return
 904
 905        _pending_apply_app_config = False
 906
 907        # Inform all app subsystems in the same order they were inited.
 908        # Operate on a copy here because subsystems may still be able to
 909        # be added at this point.
 910        for subsystem in self._subsystems.copy():
 911            try:
 912                subsystem.do_apply_app_config()
 913            except Exception:
 914                logging.exception(
 915                    'Error in do_apply_app_config() for subsystem %s.',
 916                    subsystem,
 917                )
 918
 919        # Let the native layer do its thing.
 920        _babase.do_apply_app_config()
 921
 922    def _update_state(self) -> None:
 923        # pylint: disable=too-many-branches
 924        assert _babase.in_logic_thread()
 925
 926        # Shutdown-complete trumps absolutely all.
 927        if self._native_shutdown_complete_called:
 928            if self.state is not self.State.SHUTDOWN_COMPLETE:
 929                self.state = self.State.SHUTDOWN_COMPLETE
 930                lifecyclelog.info('app-state is now %s', self.state.name)
 931                self._on_shutdown_complete()
 932
 933        # Shutdown trumps all. Though we can't start shutting down until
 934        # init is completed since we need our asyncio stuff to exist for
 935        # the shutdown process.
 936        elif self._native_shutdown_called and self._init_completed:
 937            # Entering shutdown state:
 938            if self.state is not self.State.SHUTTING_DOWN:
 939                self.state = self.State.SHUTTING_DOWN
 940                applog.info('Shutting down...')
 941                lifecyclelog.info('app-state is now %s', self.state.name)
 942                self._on_shutting_down()
 943
 944        elif self._native_suspended:
 945            # Entering suspended state:
 946            if self.state is not self.State.SUSPENDED:
 947                self.state = self.State.SUSPENDED
 948                self._on_suspend()
 949        else:
 950            # Leaving suspended state:
 951            if self.state is self.State.SUSPENDED:
 952                self._on_unsuspend()
 953
 954            # Entering or returning to running state
 955            if self._initial_sign_in_completed and self._meta_scan_completed:
 956                if self.state != self.State.RUNNING:
 957                    self.state = self.State.RUNNING
 958                    lifecyclelog.info('app-state is now %s', self.state.name)
 959                    if not self._called_on_running:
 960                        self._called_on_running = True
 961                        self._on_running()
 962
 963            # Entering or returning to loading state:
 964            elif self._init_completed:
 965                if self.state is not self.State.LOADING:
 966                    self.state = self.State.LOADING
 967                    lifecyclelog.info('app-state is now %s', self.state.name)
 968                    if not self._called_on_loading:
 969                        self._called_on_loading = True
 970                        self._on_loading()
 971
 972            # Entering or returning to initing state:
 973            elif self._native_bootstrapping_completed:
 974                if self.state is not self.State.INITING:
 975                    self.state = self.State.INITING
 976                    lifecyclelog.info('app-state is now %s', self.state.name)
 977                    if not self._called_on_initing:
 978                        self._called_on_initing = True
 979                        self._on_initing()
 980
 981            # Entering or returning to native bootstrapping:
 982            elif self._native_start_called:
 983                if self.state is not self.State.NATIVE_BOOTSTRAPPING:
 984                    self.state = self.State.NATIVE_BOOTSTRAPPING
 985                    lifecyclelog.info('app-state is now %s', self.state.name)
 986            else:
 987                # Only logical possibility left is NOT_STARTED, in which
 988                # case we should not be getting called.
 989                logging.warning(
 990                    'App._update_state called while in %s state;'
 991                    ' should not happen.',
 992                    self.state.value,
 993                    stack_info=True,
 994                )
 995
 996    async def _shutdown(self) -> None:
 997        import asyncio
 998
 999        _babase.lock_all_input()
1000        try:
1001            async with asyncio.TaskGroup() as task_group:
1002                for task_coro in self._shutdown_tasks:
1003                    # Note: Mypy currently complains if we don't take
1004                    # this return value, but we don't actually need to.
1005                    # https://github.com/python/mypy/issues/15036
1006                    _ = task_group.create_task(
1007                        self._run_shutdown_task(task_coro)
1008                    )
1009        except* Exception:
1010            logging.exception('Unexpected error(s) in shutdown.')
1011
1012        # Note: ideally we should run this directly here, but currently
1013        # it does some legacy stuff which blocks, so running it here
1014        # gives us asyncio task-took-too-long warnings. If we can
1015        # convert those to nice graceful async tasks we should revert
1016        # this to a direct call.
1017        _babase.pushcall(_babase.complete_shutdown)
1018
1019    async def _run_shutdown_task(
1020        self, coro: Coroutine[None, None, None]
1021    ) -> None:
1022        """Run a shutdown task; report errors and abort if taking too long."""
1023        import asyncio
1024
1025        task = asyncio.create_task(coro)
1026        try:
1027            await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS)
1028        except Exception:
1029            logging.exception('Error in shutdown task (%s).', coro)
1030
1031    def _on_suspend(self) -> None:
1032        """Called when the app goes to a suspended state."""
1033        assert _babase.in_logic_thread()
1034
1035        # Suspend all app subsystems in the opposite order they were inited.
1036        for subsystem in reversed(self._subsystems):
1037            try:
1038                subsystem.on_app_suspend()
1039            except Exception:
1040                logging.exception(
1041                    'Error in on_app_suspend() for subsystem %s.', subsystem
1042                )
1043
1044    def _on_unsuspend(self) -> None:
1045        """Called when unsuspending."""
1046        assert _babase.in_logic_thread()
1047        self.fg_state += 1
1048
1049        # Unsuspend all app subsystems in the same order they were inited.
1050        for subsystem in self._subsystems:
1051            try:
1052                subsystem.on_app_unsuspend()
1053            except Exception:
1054                logging.exception(
1055                    'Error in on_app_unsuspend() for subsystem %s.', subsystem
1056                )
1057
1058    def _on_shutting_down(self) -> None:
1059        """(internal)"""
1060        assert _babase.in_logic_thread()
1061
1062        # Inform app subsystems that we're shutting down in the opposite
1063        # order they were inited.
1064        for subsystem in reversed(self._subsystems):
1065            try:
1066                subsystem.on_app_shutdown()
1067            except Exception:
1068                logging.exception(
1069                    'Error in on_app_shutdown() for subsystem %s.', subsystem
1070                )
1071
1072        # Now kick off any async shutdown task(s).
1073        assert self._asyncio_loop is not None
1074        self._shutdown_task = self._asyncio_loop.create_task(self._shutdown())
1075
1076    def _on_shutdown_complete(self) -> None:
1077        """(internal)"""
1078        assert _babase.in_logic_thread()
1079
1080        # Deactivate any active app-mode. This allows things like saving
1081        # state to happen naturally without needing to handle
1082        # app-shutdown as a special case.
1083        if self._mode is not None:
1084            try:
1085                self._mode.on_deactivate()
1086            except Exception:
1087                logging.exception(
1088                    'Error deactivating app-mode %s at app shutdown.',
1089                    self._mode,
1090                )
1091            self._mode = None
1092
1093        # Inform app subsystems that we're done shutting down in the opposite
1094        # order they were inited.
1095        for subsystem in reversed(self._subsystems):
1096            try:
1097                subsystem.on_app_shutdown_complete()
1098            except Exception:
1099                logging.exception(
1100                    'Error in on_app_shutdown_complete() for subsystem %s.',
1101                    subsystem,
1102                )
1103
1104    async def _wait_for_shutdown_suppressions(self) -> None:
1105        import asyncio
1106
1107        # Spin and wait for anything blocking shutdown to complete.
1108        starttime = _babase.apptime()
1109        lifecyclelog.info('shutdown-suppress-wait begin')
1110        while _babase.shutdown_suppress_count() > 0:
1111            await asyncio.sleep(0.001)
1112        lifecyclelog.info('shutdown-suppress-wait end')
1113        duration = _babase.apptime() - starttime
1114        if duration > 1.0:
1115            logging.warning(
1116                'Shutdown-suppressions lasted longer than ideal '
1117                '(%.2f seconds).',
1118                duration,
1119            )
1120
1121    async def _fade_and_shutdown_graphics(self) -> None:
1122        import asyncio
1123
1124        # Kick off a short fade and give it time to complete.
1125        lifecyclelog.info('fade-and-shutdown-graphics begin')
1126        _babase.fade_screen(False, time=0.15)
1127        await asyncio.sleep(0.15)
1128
1129        # Now tell the graphics system to go down and wait until
1130        # it has done so.
1131        _babase.graphics_shutdown_begin()
1132        while not _babase.graphics_shutdown_is_complete():
1133            await asyncio.sleep(0.01)
1134        lifecyclelog.info('fade-and-shutdown-graphics end')
1135
1136    async def _fade_and_shutdown_audio(self) -> None:
1137        import asyncio
1138
1139        # Tell the audio system to go down and give it a bit of
1140        # time to do so gracefully.
1141        lifecyclelog.info('fade-and-shutdown-audio begin')
1142        _babase.audio_shutdown_begin()
1143        await asyncio.sleep(0.15)
1144        while not _babase.audio_shutdown_is_complete():
1145            await asyncio.sleep(0.01)
1146        lifecyclelog.info('fade-and-shutdown-audio end')
1147
1148    def _thread_pool_thread_init(self) -> None:
1149        # Help keep things clear in profiling tools/etc.
1150        self._pool_thread_count += 1
1151        _babase.set_thread_name(f'ballistica worker-{self._pool_thread_count}')

High level Ballistica app functionality and state.

Access the single shared instance of this class via the "app" attr available on various high level modules such as bauiv1 and bascenev1.

plugins: PluginSubsystem
health_monitor: AppHealthMonitor
config: AppConfig
env: _babase.Env
state: App.State
net: babase._net.NetworkSubsystem
workspaces: babase._workspace.WorkspaceSubsystem
components: babase._appcomponent.AppComponentSubsystem
stringedit: StringEditSubsystem
devconsole: DevConsoleSubsystem
fg_state: int
SHUTDOWN_TASK_TIMEOUT_SECONDS = 12
def postinit(self) -> None:
248    def postinit(self) -> None:
249        """Called after we've been inited and assigned to babase.app.
250
251        Anything that accesses babase.app as part of its init process
252        must go here instead of __init__.
253        """
254
255        # Hack for docs-generation: We can be imported with dummy
256        # modules instead of our actual binary ones, but we don't
257        # function.
258        if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
259            return
260
261        self.lang = LanguageSubsystem()
262        self.plugins = PluginSubsystem()

Called after we've been inited and assigned to babase.app.

Anything that accesses babase.app as part of its init process must go here instead of __init__.

active: bool
264    @property
265    def active(self) -> bool:
266        """Whether the app is currently front and center.
267
268        This will be False when the app is hidden, other activities
269        are covering it, etc. (depending on the platform).
270        """
271        return _babase.app_is_active()

Whether the app is currently front and center.

This will be False when the app is hidden, other activities are covering it, etc. (depending on the platform).

mode: AppMode | None
273    @property
274    def mode(self) -> AppMode | None:
275        """The app's current mode."""
276        assert _babase.in_logic_thread()
277        return self._mode

The app's current mode.

asyncio_loop: asyncio.events.AbstractEventLoop
279    @property
280    def asyncio_loop(self) -> asyncio.AbstractEventLoop:
281        """The logic thread's asyncio event loop.
282
283        This allow async tasks to be run in the logic thread.
284
285        Generally you should call App.create_async_task() to schedule
286        async code to run instead of using this directly. That will
287        handle retaining the task and logging errors automatically.
288        Only schedule tasks onto asyncio_loop yourself when you intend
289        to hold on to the returned task and await its results. Releasing
290        the task reference can lead to subtle bugs such as unreported
291        errors and garbage-collected tasks disappearing before their
292        work is done.
293
294        Note that, at this time, the asyncio loop is encapsulated
295        and explicitly stepped by the engine's logic thread loop and
296        thus things like asyncio.get_running_loop() will unintuitively
297        *not* return this loop from most places in the logic thread;
298        only from within a task explicitly created in this loop.
299        Hopefully this situation will be improved in the future with a
300        unified event loop.
301        """
302        assert _babase.in_logic_thread()
303        assert self._asyncio_loop is not None
304        return self._asyncio_loop

The logic thread's asyncio event loop.

This allow async tasks to be run in the logic thread.

Generally you should call App.create_async_task() to schedule async code to run instead of using this directly. That will handle retaining the task and logging errors automatically. Only schedule tasks onto asyncio_loop yourself when you intend to hold on to the returned task and await its results. Releasing the task reference can lead to subtle bugs such as unreported errors and garbage-collected tasks disappearing before their work is done.

Note that, at this time, the asyncio loop is encapsulated and explicitly stepped by the engine's logic thread loop and thus things like asyncio.get_running_loop() will unintuitively not return this loop from most places in the logic thread; only from within a task explicitly created in this loop. Hopefully this situation will be improved in the future with a unified event loop.

def create_async_task(self, coro: Coroutine[Any, Any, ~T], *, name: str | None = None) -> None:
306    def create_async_task(
307        self, coro: Coroutine[Any, Any, T], *, name: str | None = None
308    ) -> None:
309        """Create a fully managed async task.
310
311        This will automatically retain and release a reference to the task
312        and log any exceptions that occur in it. If you need to await a task
313        or otherwise need more control, schedule a task directly using
314        App.asyncio_loop.
315        """
316        assert _babase.in_logic_thread()
317
318        # We hold a strong reference to the task until it is done.
319        # Otherwise it is possible for it to be garbage collected and
320        # disappear midway if the caller does not hold on to the
321        # returned task, which seems like a great way to introduce
322        # hard-to-track bugs.
323        task = self.asyncio_loop.create_task(coro, name=name)
324        self._asyncio_tasks.add(task)
325        task.add_done_callback(self._on_task_done)

Create a fully managed async task.

This will automatically retain and release a reference to the task and log any exceptions that occur in it. If you need to await a task or otherwise need more control, schedule a task directly using App.asyncio_loop.

mode_selector: AppModeSelector
340    @property
341    def mode_selector(self) -> babase.AppModeSelector:
342        """Controls which app-modes are used for handling given intents.
343
344        Plugins can override this to change high level app behavior and
345        spinoff projects can change the default implementation for the
346        same effect.
347        """
348        if self._mode_selector is None:
349            raise RuntimeError(
350                'mode_selector cannot be used until the app reaches'
351                ' the running state.'
352            )
353        return self._mode_selector

Controls which app-modes are used for handling given intents.

Plugins can override this to change high level app behavior and spinoff projects can change the default implementation for the same effect.

classic: baclassic.ClassicAppSubsystem | None
409    @property
410    def classic(self) -> ClassicAppSubsystem | None:
411        """Our classic subsystem (if available)."""
412        return self._get_subsystem_property(
413            'classic', self._create_classic_subsystem
414        )  # type: ignore

Our classic subsystem (if available).

plus: baplus.PlusAppSubsystem | None
429    @property
430    def plus(self) -> PlusAppSubsystem | None:
431        """Our plus subsystem (if available)."""
432        return self._get_subsystem_property(
433            'plus', self._create_plus_subsystem
434        )  # type: ignore

Our plus subsystem (if available).

ui_v1: bauiv1.UIV1AppSubsystem
449    @property
450    def ui_v1(self) -> UIV1AppSubsystem:
451        """Our ui_v1 subsystem (always available)."""
452        return self._get_subsystem_property(
453            'ui_v1', self._create_ui_v1_subsystem
454        )  # type: ignore

Our ui_v1 subsystem (always available).

def register_subsystem(self, subsystem: AppSubsystem) -> None:
466    def register_subsystem(self, subsystem: AppSubsystem) -> None:
467        """Called by the AppSubsystem class. Do not use directly."""
468
469        # We only allow registering new subsystems if we've not yet
470        # reached the 'running' state. This ensures that all subsystems
471        # receive a consistent set of callbacks starting with
472        # on_app_running().
473
474        if self._subsystem_registration_ended:
475            raise RuntimeError(
476                'Subsystems can no longer be registered at this point.'
477            )
478        self._subsystems.append(subsystem)

Called by the AppSubsystem class. Do not use directly.

def add_shutdown_task(self, coro: Coroutine[NoneType, NoneType, NoneType]) -> None:
480    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
481        """Add a task to be run on app shutdown.
482
483        Note that shutdown tasks will be canceled after
484        :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still
485        running.
486        """
487        if (
488            self.state is self.State.SHUTTING_DOWN
489            or self.state is self.State.SHUTDOWN_COMPLETE
490        ):
491            stname = self.state.name
492            raise RuntimeError(
493                f'Cannot add shutdown tasks with current state {stname}.'
494            )
495        self._shutdown_tasks.append(coro)

Add a task to be run on app shutdown.

Note that shutdown tasks will be canceled after SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.

def run(self) -> None:
497    def run(self) -> None:
498        """Run the app to completion.
499
500        Note that this only works on builds where Ballistica manages
501        its own event loop.
502        """
503        _babase.run_app()

Run the app to completion.

Note that this only works on builds where Ballistica manages its own event loop.

def set_intent(self, intent: AppIntent) -> None:
505    def set_intent(self, intent: AppIntent) -> None:
506        """Set the intent for the app.
507
508        Intent defines what the app is trying to do at a given time.
509        This call is asynchronous; the intent switch will happen in the
510        logic thread in the near future. If set_intent is called
511        repeatedly before the change takes place, the final intent to be
512        set will be used.
513        """
514
515        # Mark this one as pending. We do this synchronously so that the
516        # last one marked actually takes effect if there is overlap
517        # (doing this in the bg thread could result in race conditions).
518        self._pending_intent = intent
519
520        # Do the actual work of calcing our app-mode/etc. in a bg thread
521        # since it may block for a moment to load modules/etc.
522        self.threadpool.submit_no_wait(self._set_intent, intent)

Set the intent for the app.

Intent defines what the app is trying to do at a given time. This call is asynchronous; the intent switch will happen in the logic thread in the near future. If set_intent is called repeatedly before the change takes place, the final intent to be set will be used.

def push_apply_app_config(self) -> None:
524    def push_apply_app_config(self) -> None:
525        """Internal. Use app.config.apply() to apply app config changes."""
526        # To be safe, let's run this by itself in the event loop.
527        # This avoids potential trouble if this gets called mid-draw or
528        # something like that.
529        self._pending_apply_app_config = True
530        _babase.pushcall(self._apply_app_config, raw=True)

Internal. Use app.config.apply() to apply app config changes.

def on_native_start(self) -> None:
532    def on_native_start(self) -> None:
533        """Called by the native layer when the app is being started."""
534        assert _babase.in_logic_thread()
535        assert not self._native_start_called
536        self._native_start_called = True
537        self._update_state()

Called by the native layer when the app is being started.

def on_native_bootstrapping_complete(self) -> None:
539    def on_native_bootstrapping_complete(self) -> None:
540        """Called by the native layer once its ready to rock."""
541        assert _babase.in_logic_thread()
542        assert not self._native_bootstrapping_completed
543        self._native_bootstrapping_completed = True
544        self._update_state()

Called by the native layer once its ready to rock.

def on_native_suspend(self) -> None:
546    def on_native_suspend(self) -> None:
547        """Called by the native layer when the app is suspended."""
548        assert _babase.in_logic_thread()
549        assert not self._native_suspended  # Should avoid redundant calls.
550        self._native_suspended = True
551        self._update_state()

Called by the native layer when the app is suspended.

def on_native_unsuspend(self) -> None:
553    def on_native_unsuspend(self) -> None:
554        """Called by the native layer when the app suspension ends."""
555        assert _babase.in_logic_thread()
556        assert self._native_suspended  # Should avoid redundant calls.
557        self._native_suspended = False
558        self._update_state()

Called by the native layer when the app suspension ends.

def on_native_shutdown(self) -> None:
560    def on_native_shutdown(self) -> None:
561        """Called by the native layer when the app starts shutting down."""
562        assert _babase.in_logic_thread()
563        self._native_shutdown_called = True
564        self._update_state()

Called by the native layer when the app starts shutting down.

def on_native_shutdown_complete(self) -> None:
566    def on_native_shutdown_complete(self) -> None:
567        """Called by the native layer when the app is done shutting down."""
568        assert _babase.in_logic_thread()
569        self._native_shutdown_complete_called = True
570        self._update_state()

Called by the native layer when the app is done shutting down.

def on_native_active_changed(self) -> None:
572    def on_native_active_changed(self) -> None:
573        """Called by the native layer when the app active state changes."""
574        assert _babase.in_logic_thread()
575        if self._mode is not None:
576            self._mode.on_app_active_changed()

Called by the native layer when the app active state changes.

def on_initial_sign_in_complete(self) -> None:
598    def on_initial_sign_in_complete(self) -> None:
599        """Called when initial sign-in (or lack thereof) completes.
600
601        This normally gets called by the plus subsystem. The
602        initial-sign-in process may include tasks such as syncing
603        account workspaces or other data so it may take a substantial
604        amount of time.
605        """
606        assert _babase.in_logic_thread()
607        assert not self._initial_sign_in_completed
608
609        # Tell meta it can start scanning extra stuff that just showed
610        # up (namely account workspaces).
611        self.meta.start_extra_scan()
612
613        self._initial_sign_in_completed = True
614        self._update_state()

Called when initial sign-in (or lack thereof) completes.

This normally gets called by the plus subsystem. The initial-sign-in process may include tasks such as syncing account workspaces or other data so it may take a substantial amount of time.

def set_ui_scale(self, scale: UIScale) -> None:
616    def set_ui_scale(self, scale: babase.UIScale) -> None:
617        """Change ui-scale on the fly.
618
619        Currently this is mainly for debugging and will not be called as
620        part of normal app operation.
621        """
622        assert _babase.in_logic_thread()
623
624        # Apply to the native layer.
625        _babase.set_ui_scale(scale.name.lower())
626
627        # Inform all subsystems that something screen-related has
628        # changed. We assume subsystems won't be added at this point so
629        # we can use the list directly.
630        assert self._subsystem_registration_ended
631        for subsystem in self._subsystems:
632            try:
633                subsystem.on_ui_scale_change()
634            except Exception:
635                logging.exception(
636                    'Error in on_ui_scale_change() for subsystem %s.', subsystem
637                )

Change ui-scale on the fly.

Currently this is mainly for debugging and will not be called as part of normal app operation.

def on_screen_size_change(self) -> None:
639    def on_screen_size_change(self) -> None:
640        """Screen size has changed."""
641
642        # Inform all app subsystems in the same order they were inited.
643        # Operate on a copy of the list here because this can be called
644        # while subsystems are still being added.
645        for subsystem in self._subsystems.copy():
646            try:
647                subsystem.on_screen_size_change()
648            except Exception:
649                logging.exception(
650                    'Error in on_screen_size_change() for subsystem %s.',
651                    subsystem,
652                )

Screen size has changed.

class App.State(enum.Enum):
 62    class State(Enum):
 63        """High level state the app can be in."""
 64
 65        #: The app has not yet begun starting and should not be used in
 66        #: any way.
 67        NOT_STARTED = 0
 68
 69        #: The native layer is spinning up its machinery (screens,
 70        #: renderers, etc.). Nothing should happen in the Python layer
 71        #: until this completes.
 72        NATIVE_BOOTSTRAPPING = 1
 73
 74        #: Python app subsystems are being inited but should not yet
 75        #: interact or do any work.
 76        INITING = 2
 77
 78        #: Python app subsystems are inited and interacting, but the app
 79        #: has not yet embarked on a high level course of action. It is
 80        #: doing initial account logins, workspace & asset downloads,
 81        #: etc.
 82        LOADING = 3
 83
 84        #: All pieces are in place and the app is now doing its thing.
 85        RUNNING = 4
 86
 87        #: Used on platforms such as mobile where the app basically needs
 88        #: to shut down while backgrounded. In this state, all event
 89        #: loops are suspended and all graphics and audio must cease
 90        #: completely. Be aware that the suspended state can be entered
 91        #: from any other state including NATIVE_BOOTSTRAPPING and
 92        #: SHUTTING_DOWN.
 93        SUSPENDED = 5
 94
 95        #: The app is shutting down. This process may involve sending
 96        #: network messages or other things that can take up to a few
 97        #: seconds, so ideally graphics and audio should remain
 98        #: functional (with fades or spinners or whatever to show
 99        #: something is happening).
100        SHUTTING_DOWN = 6
101
102        #: The app has completed shutdown. Any code running here should
103        #: be basically immediate.
104        SHUTDOWN_COMPLETE = 7

High level state the app can be in.

NOT_STARTED = <State.NOT_STARTED: 0>
NATIVE_BOOTSTRAPPING = <State.NATIVE_BOOTSTRAPPING: 1>
INITING = <State.INITING: 2>
LOADING = <State.LOADING: 3>
RUNNING = <State.RUNNING: 4>
SUSPENDED = <State.SUSPENDED: 5>
SHUTTING_DOWN = <State.SHUTTING_DOWN: 6>
SHUTDOWN_COMPLETE = <State.SHUTDOWN_COMPLETE: 7>
class App.DefaultAppModeSelector(babase.AppModeSelector):
106    class DefaultAppModeSelector(AppModeSelector):
107        """Decides which AppMode to use to handle AppIntents.
108
109        This default version is generated by the project updater based
110        on the 'default_app_modes' value in the projectconfig.
111
112        It is also possible to modify app mode selection behavior by
113        setting app.mode_selector to an instance of a custom
114        AppModeSelector subclass. This is a good way to go if you are
115        modifying app behavior dynamically via a plugin instead of
116        statically in a spinoff project.
117        """
118
119        @override
120        def app_mode_for_intent(
121            self, intent: AppIntent
122        ) -> type[AppMode] | None:
123            # pylint: disable=cyclic-import
124
125            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
126            # This section generated by batools.appmodule; do not edit.
127
128            # Ask our default app modes to handle it.
129            # (generated from 'default_app_modes' in projectconfig).
130            import baclassic
131            import babase
132
133            for appmode in [
134                baclassic.ClassicAppMode,
135                babase.EmptyAppMode,
136            ]:
137                if appmode.can_handle_intent(intent):
138                    return appmode
139
140            return None
141
142            # __DEFAULT_APP_MODE_SELECTION_END__

Decides which AppMode to use to handle AppIntents.

This default version is generated by the project updater based on the 'default_app_modes' value in the projectconfig.

It is also possible to modify app mode selection behavior by setting app.mode_selector to an instance of a custom AppModeSelector subclass. This is a good way to go if you are modifying app behavior dynamically via a plugin instead of statically in a spinoff project.

@override
def app_mode_for_intent( self, intent: AppIntent) -> type[AppMode] | None:
119        @override
120        def app_mode_for_intent(
121            self, intent: AppIntent
122        ) -> type[AppMode] | None:
123            # pylint: disable=cyclic-import
124
125            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
126            # This section generated by batools.appmodule; do not edit.
127
128            # Ask our default app modes to handle it.
129            # (generated from 'default_app_modes' in projectconfig).
130            import baclassic
131            import babase
132
133            for appmode in [
134                baclassic.ClassicAppMode,
135                babase.EmptyAppMode,
136            ]:
137                if appmode.can_handle_intent(intent):
138                    return appmode
139
140            return None
141
142            # __DEFAULT_APP_MODE_SELECTION_END__

Given an AppIntent, return the AppMode that should handle it.

If None is returned, the AppIntent will be ignored.

This may be called in a background thread, so avoid any calls limited to logic thread use/etc.

class AppConfig(builtins.dict):
 17class AppConfig(dict):
 18    """A special dict that holds persistent app configuration values.
 19
 20    It also provides methods for fetching values with app-defined
 21    fallback defaults, applying contained values to the game, and
 22    committing the config to storage.
 23
 24    Call babase.appconfig() to get the single shared instance of this
 25    class.
 26
 27    AppConfig data is stored as json on disk on so make sure to only
 28    place json-friendly values in it (dict, list, str, float, int,
 29    bool). Be aware that tuples will be quietly converted to lists when
 30    stored.
 31    """
 32
 33    def resolve(self, key: str) -> Any:
 34        """Given a string key, return a config value (type varies).
 35
 36        This will substitute application defaults for values not present in
 37        the config dict, filter some invalid values, etc.  Note that these
 38        values do not represent the state of the app; simply the state of its
 39        config. Use babase.App to access actual live state.
 40
 41        Raises an Exception for unrecognized key names. To get the list of keys
 42        supported by this method, use babase.AppConfig.builtin_keys(). Note
 43        that it is perfectly legal to store other data in the config; it just
 44        needs to be accessed through standard dict methods and missing values
 45        handled manually.
 46        """
 47        return _babase.resolve_appconfig_value(key)
 48
 49    def default_value(self, key: str) -> Any:
 50        """Given a string key, return its predefined default value.
 51
 52        This is the value that will be returned by babase.AppConfig.resolve()
 53        if the key is not present in the config dict or of an incompatible
 54        type.
 55
 56        Raises an Exception for unrecognized key names. To get the list of keys
 57        supported by this method, use babase.AppConfig.builtin_keys(). Note
 58        that it is perfectly legal to store other data in the config; it just
 59        needs to be accessed through standard dict methods and missing values
 60        handled manually.
 61        """
 62        return _babase.get_appconfig_default_value(key)
 63
 64    def builtin_keys(self) -> list[str]:
 65        """Return the list of valid key names recognized by babase.AppConfig.
 66
 67        This set of keys can be used with resolve(), default_value(), etc.
 68        It does not vary across platforms and may include keys that are
 69        obsolete or not relevant on the current running version. (for instance,
 70        VR related keys on non-VR platforms). This is to minimize the amount
 71        of platform checking necessary)
 72
 73        Note that it is perfectly legal to store arbitrary named data in the
 74        config, but in that case it is up to the user to test for the existence
 75        of the key in the config dict, fall back to consistent defaults, etc.
 76        """
 77        return _babase.get_appconfig_builtin_keys()
 78
 79    def apply(self) -> None:
 80        """Apply config values to the running app.
 81
 82        This call is thread-safe and asynchronous; changes will happen
 83        in the next logic event loop cycle.
 84        """
 85        _babase.app.push_apply_app_config()
 86
 87    def commit(self) -> None:
 88        """Commits the config to local storage.
 89
 90        Note that this call is asynchronous so the actual write to disk may not
 91        occur immediately.
 92        """
 93        commit_app_config()
 94
 95    def apply_and_commit(self) -> None:
 96        """Run apply() followed by commit(); for convenience.
 97
 98        (This way the commit() will not occur if apply() hits invalid data)
 99        """
100        self.apply()
101        self.commit()

A special dict that holds persistent app configuration values.

It also provides methods for fetching values with app-defined fallback defaults, applying contained values to the game, and committing the config to storage.

Call babase.appconfig() to get the single shared instance of this class.

AppConfig data is stored as json on disk on so make sure to only place json-friendly values in it (dict, list, str, float, int, bool). Be aware that tuples will be quietly converted to lists when stored.

def resolve(self, key: str) -> Any:
33    def resolve(self, key: str) -> Any:
34        """Given a string key, return a config value (type varies).
35
36        This will substitute application defaults for values not present in
37        the config dict, filter some invalid values, etc.  Note that these
38        values do not represent the state of the app; simply the state of its
39        config. Use babase.App to access actual live state.
40
41        Raises an Exception for unrecognized key names. To get the list of keys
42        supported by this method, use babase.AppConfig.builtin_keys(). Note
43        that it is perfectly legal to store other data in the config; it just
44        needs to be accessed through standard dict methods and missing values
45        handled manually.
46        """
47        return _babase.resolve_appconfig_value(key)

Given a string key, return a config value (type varies).

This will substitute application defaults for values not present in the config dict, filter some invalid values, etc. Note that these values do not represent the state of the app; simply the state of its config. Use babase.App to access actual live state.

Raises an Exception for unrecognized key names. To get the list of keys supported by this method, use babase.AppConfig.builtin_keys(). Note that it is perfectly legal to store other data in the config; it just needs to be accessed through standard dict methods and missing values handled manually.

def default_value(self, key: str) -> Any:
49    def default_value(self, key: str) -> Any:
50        """Given a string key, return its predefined default value.
51
52        This is the value that will be returned by babase.AppConfig.resolve()
53        if the key is not present in the config dict or of an incompatible
54        type.
55
56        Raises an Exception for unrecognized key names. To get the list of keys
57        supported by this method, use babase.AppConfig.builtin_keys(). Note
58        that it is perfectly legal to store other data in the config; it just
59        needs to be accessed through standard dict methods and missing values
60        handled manually.
61        """
62        return _babase.get_appconfig_default_value(key)

Given a string key, return its predefined default value.

This is the value that will be returned by babase.AppConfig.resolve() if the key is not present in the config dict or of an incompatible type.

Raises an Exception for unrecognized key names. To get the list of keys supported by this method, use babase.AppConfig.builtin_keys(). Note that it is perfectly legal to store other data in the config; it just needs to be accessed through standard dict methods and missing values handled manually.

def builtin_keys(self) -> list[str]:
64    def builtin_keys(self) -> list[str]:
65        """Return the list of valid key names recognized by babase.AppConfig.
66
67        This set of keys can be used with resolve(), default_value(), etc.
68        It does not vary across platforms and may include keys that are
69        obsolete or not relevant on the current running version. (for instance,
70        VR related keys on non-VR platforms). This is to minimize the amount
71        of platform checking necessary)
72
73        Note that it is perfectly legal to store arbitrary named data in the
74        config, but in that case it is up to the user to test for the existence
75        of the key in the config dict, fall back to consistent defaults, etc.
76        """
77        return _babase.get_appconfig_builtin_keys()

Return the list of valid key names recognized by babase.AppConfig.

This set of keys can be used with resolve(), default_value(), etc. It does not vary across platforms and may include keys that are obsolete or not relevant on the current running version. (for instance, VR related keys on non-VR platforms). This is to minimize the amount of platform checking necessary)

Note that it is perfectly legal to store arbitrary named data in the config, but in that case it is up to the user to test for the existence of the key in the config dict, fall back to consistent defaults, etc.

def apply(self) -> None:
79    def apply(self) -> None:
80        """Apply config values to the running app.
81
82        This call is thread-safe and asynchronous; changes will happen
83        in the next logic event loop cycle.
84        """
85        _babase.app.push_apply_app_config()

Apply config values to the running app.

This call is thread-safe and asynchronous; changes will happen in the next logic event loop cycle.

def commit(self) -> None:
87    def commit(self) -> None:
88        """Commits the config to local storage.
89
90        Note that this call is asynchronous so the actual write to disk may not
91        occur immediately.
92        """
93        commit_app_config()

Commits the config to local storage.

Note that this call is asynchronous so the actual write to disk may not occur immediately.

def apply_and_commit(self) -> None:
 95    def apply_and_commit(self) -> None:
 96        """Run apply() followed by commit(); for convenience.
 97
 98        (This way the commit() will not occur if apply() hits invalid data)
 99        """
100        self.apply()
101        self.commit()

Run apply() followed by commit(); for convenience.

(This way the commit() will not occur if apply() hits invalid data)

class AppHealthMonitor(babase.AppSubsystem):
392class AppHealthMonitor(AppSubsystem):
393    """Logs things like app-not-responding issues."""
394
395    def __init__(self) -> None:
396        assert _babase.in_logic_thread()
397        super().__init__()
398        self._running = True
399        self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
400        self._thread.start()
401        self._response = False
402        self._first_check = True
403
404    @override
405    def on_app_loading(self) -> None:
406        # If any traceback dumps happened last run, log and clear them.
407        log_dumped_app_state(from_previous_run=True)
408
409    def _app_monitor_thread_main(self) -> None:
410        _babase.set_thread_name('ballistica app-monitor')
411        try:
412            self._monitor_app()
413        except Exception:
414            logging.exception('Error in AppHealthMonitor thread.')
415
416    def _set_response(self) -> None:
417        assert _babase.in_logic_thread()
418        self._response = True
419
420    def _check_running(self) -> bool:
421        # Workaround for the fact that mypy assumes _running
422        # doesn't change during the course of a function.
423        return self._running
424
425    def _monitor_app(self) -> None:
426        import time
427
428        while bool(True):
429            # Always sleep a bit between checks.
430            time.sleep(1.234)
431
432            # Do nothing while backgrounded.
433            while not self._running:
434                time.sleep(2.3456)
435
436            # Wait for the logic thread to run something we send it.
437            starttime = time.monotonic()
438            self._response = False
439            _babase.pushcall(self._set_response, raw=True)
440            while not self._response:
441                # Abort this check if we went into the background.
442                if not self._check_running():
443                    break
444
445                # Wait a bit longer the first time through since the app
446                # could still be starting up; we generally don't want to
447                # report that.
448                threshold = 10 if self._first_check else 5
449
450                # If we've been waiting too long (and the app is running)
451                # dump the app state and bail. Make an exception for the
452                # first check though since the app could just be taking
453                # a while to get going; we don't want to report that.
454                duration = time.monotonic() - starttime
455                if duration > threshold:
456                    dump_app_state(
457                        reason=f'Logic thread unresponsive'
458                        f' for {threshold} seconds.'
459                    )
460
461                    # We just do one alert for now.
462                    return
463
464                time.sleep(1.042)
465
466            self._first_check = False
467
468    @override
469    def on_app_suspend(self) -> None:
470        assert _babase.in_logic_thread()
471        self._running = False
472
473    @override
474    def on_app_unsuspend(self) -> None:
475        assert _babase.in_logic_thread()
476        self._running = True

Logs things like app-not-responding issues.

@override
def on_app_loading(self) -> None:
404    @override
405    def on_app_loading(self) -> None:
406        # If any traceback dumps happened last run, log and clear them.
407        log_dumped_app_state(from_previous_run=True)

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:
468    @override
469    def on_app_suspend(self) -> None:
470        assert _babase.in_logic_thread()
471        self._running = False

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
473    @override
474    def on_app_unsuspend(self) -> None:
475        assert _babase.in_logic_thread()
476        self._running = True

Called when the app exits the suspended state.

class AppIntent:
13class AppIntent:
14    """A high level directive given to the app."""

A high level directive given to the app.

class AppIntentDefault(babase.AppIntent):
17class AppIntentDefault(AppIntent):
18    """Tells the app to simply run in its default mode."""

Tells the app to simply run in its default mode.

class AppIntentExec(babase.AppIntent):
21class AppIntentExec(AppIntent):
22    """Tells the app to exec some Python code."""
23
24    def __init__(self, code: str):
25        self.code = code

Tells the app to exec some Python code.

AppIntentExec(code: str)
24    def __init__(self, code: str):
25        self.code = code
code
class AppMode:
 14class AppMode:
 15    """A high level mode for the app."""
 16
 17    @classmethod
 18    def get_app_experience(cls) -> AppExperience:
 19        """Return the overall experience provided by this mode."""
 20        raise NotImplementedError('AppMode subclasses must override this.')
 21
 22    @classmethod
 23    def can_handle_intent(cls, intent: AppIntent) -> bool:
 24        """Return whether this mode can handle the provided intent.
 25
 26        For this to return True, the AppMode must claim to support the
 27        provided intent (via its _can_handle_intent() method) AND the
 28        AppExperience associated with the AppMode must be supported by
 29        the current app and runtime environment.
 30        """
 31        # TODO: check AppExperience against current environment.
 32        return cls._can_handle_intent(intent)
 33
 34    @classmethod
 35    def _can_handle_intent(cls, intent: AppIntent) -> bool:
 36        """Return whether our mode can handle the provided intent.
 37
 38        AppModes should override this to communicate what they can
 39        handle. Note that AppExperience does not have to be considered
 40        here; that is handled automatically by the can_handle_intent()
 41        call.
 42        """
 43        raise NotImplementedError('AppMode subclasses must override this.')
 44
 45    def handle_intent(self, intent: AppIntent) -> None:
 46        """Handle an intent."""
 47        raise NotImplementedError('AppMode subclasses must override this.')
 48
 49    def on_activate(self) -> None:
 50        """Called when the mode is becoming the active one fro the app."""
 51
 52    def on_deactivate(self) -> None:
 53        """Called when the mode stops being the active one for the app.
 54
 55        On platforms where the app is explicitly exited (such as desktop
 56        PC) this will also be called at app shutdown.
 57
 58        To best cover both mobile and desktop style platforms, actions
 59        such as saving state should generally happen in response to both
 60        on_deactivate() and on_app_active_changed() (when active is
 61        False).
 62        """
 63
 64    def on_app_active_changed(self) -> None:
 65        """Called when app active state changes while in this app-mode.
 66
 67        This corresponds to :attr:`babase.App.active`. App-active state
 68        becomes false when the app is hidden, minimized, backgrounded,
 69        etc. The app-mode may want to take action such as pausing a
 70        running game or saving state when this occurs.
 71
 72        On platforms such as mobile where apps get suspended and later
 73        silently terminated by the OS, this is likely to be the last
 74        reliable place to save state/etc.
 75
 76        To best cover both mobile and desktop style platforms, actions
 77        such as saving state should generally happen in response to both
 78        on_deactivate() and on_app_active_changed() (when active is
 79        False).
 80        """
 81
 82    def on_purchase_process_begin(
 83        self, item_id: str, user_initiated: bool
 84    ) -> None:
 85        """Called when in-app-purchase processing is beginning.
 86
 87        This call happens after a purchase has been completed locally
 88        but before its receipt/info is sent to the master-server to
 89        apply to the account.
 90        """
 91        # pylint: disable=cyclic-import
 92        import babase
 93
 94        del item_id  # Unused.
 95
 96        # Show nothing for stuff not directly kicked off by the user.
 97        if not user_initiated:
 98            return
 99
100        babase.screenmessage(
101            babase.Lstr(resource='updatingAccountText'),
102            color=(0, 1, 0),
103        )
104        # Ick; we can be called early in the bootstrapping process
105        # before we're allowed to load assets. Guard against that.
106        if babase.asset_loads_allowed():
107            babase.getsimplesound('click01').play()
108
109    def on_purchase_process_end(
110        self, item_id: str, user_initiated: bool, applied: bool
111    ) -> None:
112        """Called when in-app-purchase processing completes.
113
114        Each call to on_purchase_process_begin will be followed up by a
115        call to this method. If the purchase was found to be valid and
116        was applied to the account, applied will be True. In the case of
117        redundant or invalid purchases or communication failures it will
118        be False.
119        """
120        # pylint: disable=cyclic-import
121        import babase
122
123        # Ignore this; we want to announce newly applied stuff even if
124        # it was from a different launch or client or whatever.
125        del user_initiated
126
127        # If the purchase wasn't applied, do nothing. This likely means it
128        # was redundant or something else harmless.
129        if not applied:
130            return
131
132        # By default just announce the item id we got. Real app-modes
133        # probably want to do something more specific based on item-id.
134        babase.screenmessage(
135            babase.Lstr(
136                translate=('serverResponses', 'You got a ${ITEM}!'),
137                subs=[('${ITEM}', item_id)],
138            ),
139            color=(0, 1, 0),
140        )
141        if babase.asset_loads_allowed():
142            babase.getsimplesound('cashRegister').play()

A high level mode for the app.

@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
17    @classmethod
18    def get_app_experience(cls) -> AppExperience:
19        """Return the overall experience provided by this mode."""
20        raise NotImplementedError('AppMode subclasses must override this.')

Return the overall experience provided by this mode.

@classmethod
def can_handle_intent(cls, intent: AppIntent) -> bool:
22    @classmethod
23    def can_handle_intent(cls, intent: AppIntent) -> bool:
24        """Return whether this mode can handle the provided intent.
25
26        For this to return True, the AppMode must claim to support the
27        provided intent (via its _can_handle_intent() method) AND the
28        AppExperience associated with the AppMode must be supported by
29        the current app and runtime environment.
30        """
31        # TODO: check AppExperience against current environment.
32        return cls._can_handle_intent(intent)

Return whether this mode can handle the provided intent.

For this to return True, the AppMode must claim to support the provided intent (via its _can_handle_intent() method) AND the AppExperience associated with the AppMode must be supported by the current app and runtime environment.

def handle_intent(self, intent: AppIntent) -> None:
45    def handle_intent(self, intent: AppIntent) -> None:
46        """Handle an intent."""
47        raise NotImplementedError('AppMode subclasses must override this.')

Handle an intent.

def on_activate(self) -> None:
49    def on_activate(self) -> None:
50        """Called when the mode is becoming the active one fro the app."""

Called when the mode is becoming the active one fro the app.

def on_deactivate(self) -> None:
52    def on_deactivate(self) -> None:
53        """Called when the mode stops being the active one for the app.
54
55        On platforms where the app is explicitly exited (such as desktop
56        PC) this will also be called at app shutdown.
57
58        To best cover both mobile and desktop style platforms, actions
59        such as saving state should generally happen in response to both
60        on_deactivate() and on_app_active_changed() (when active is
61        False).
62        """

Called when the mode stops being the active one for the app.

On platforms where the app is explicitly exited (such as desktop PC) this will also be called at app shutdown.

To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).

def on_app_active_changed(self) -> None:
64    def on_app_active_changed(self) -> None:
65        """Called when app active state changes while in this app-mode.
66
67        This corresponds to :attr:`babase.App.active`. App-active state
68        becomes false when the app is hidden, minimized, backgrounded,
69        etc. The app-mode may want to take action such as pausing a
70        running game or saving state when this occurs.
71
72        On platforms such as mobile where apps get suspended and later
73        silently terminated by the OS, this is likely to be the last
74        reliable place to save state/etc.
75
76        To best cover both mobile and desktop style platforms, actions
77        such as saving state should generally happen in response to both
78        on_deactivate() and on_app_active_changed() (when active is
79        False).
80        """

Called when app active state changes while in this app-mode.

This corresponds to babase.App.active. App-active state becomes false when the app is hidden, minimized, backgrounded, etc. The app-mode may want to take action such as pausing a running game or saving state when this occurs.

On platforms such as mobile where apps get suspended and later silently terminated by the OS, this is likely to be the last reliable place to save state/etc.

To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).

def on_purchase_process_begin(self, item_id: str, user_initiated: bool) -> None:
 82    def on_purchase_process_begin(
 83        self, item_id: str, user_initiated: bool
 84    ) -> None:
 85        """Called when in-app-purchase processing is beginning.
 86
 87        This call happens after a purchase has been completed locally
 88        but before its receipt/info is sent to the master-server to
 89        apply to the account.
 90        """
 91        # pylint: disable=cyclic-import
 92        import babase
 93
 94        del item_id  # Unused.
 95
 96        # Show nothing for stuff not directly kicked off by the user.
 97        if not user_initiated:
 98            return
 99
100        babase.screenmessage(
101            babase.Lstr(resource='updatingAccountText'),
102            color=(0, 1, 0),
103        )
104        # Ick; we can be called early in the bootstrapping process
105        # before we're allowed to load assets. Guard against that.
106        if babase.asset_loads_allowed():
107            babase.getsimplesound('click01').play()

Called when in-app-purchase processing is beginning.

This call happens after a purchase has been completed locally but before its receipt/info is sent to the master-server to apply to the account.

def on_purchase_process_end(self, item_id: str, user_initiated: bool, applied: bool) -> None:
109    def on_purchase_process_end(
110        self, item_id: str, user_initiated: bool, applied: bool
111    ) -> None:
112        """Called when in-app-purchase processing completes.
113
114        Each call to on_purchase_process_begin will be followed up by a
115        call to this method. If the purchase was found to be valid and
116        was applied to the account, applied will be True. In the case of
117        redundant or invalid purchases or communication failures it will
118        be False.
119        """
120        # pylint: disable=cyclic-import
121        import babase
122
123        # Ignore this; we want to announce newly applied stuff even if
124        # it was from a different launch or client or whatever.
125        del user_initiated
126
127        # If the purchase wasn't applied, do nothing. This likely means it
128        # was redundant or something else harmless.
129        if not applied:
130            return
131
132        # By default just announce the item id we got. Real app-modes
133        # probably want to do something more specific based on item-id.
134        babase.screenmessage(
135            babase.Lstr(
136                translate=('serverResponses', 'You got a ${ITEM}!'),
137                subs=[('${ITEM}', item_id)],
138            ),
139            color=(0, 1, 0),
140        )
141        if babase.asset_loads_allowed():
142            babase.getsimplesound('cashRegister').play()

Called when in-app-purchase processing completes.

Each call to on_purchase_process_begin will be followed up by a call to this method. If the purchase was found to be valid and was applied to the account, applied will be True. In the case of redundant or invalid purchases or communication failures it will be False.

applog = <Logger ba.app (WARNING)>
def appname() -> str:
530def appname() -> str:
531    """Return current app name (all lowercase)."""
532    return str()

Return current app name (all lowercase).

def appnameupper() -> str:
535def appnameupper() -> str:
536    """Return current app name with capitalized characters."""
537    return str()

Return current app name with capitalized characters.

class AppModeSelector:
14class AppModeSelector:
15    """Defines which AppModes are available or used to handle given AppIntents.
16
17    The app calls an instance of this class when passed an AppIntent to
18    determine which AppMode to use to handle the intent. Plugins or
19    spinoff projects can modify high level app behavior by replacing or
20    modifying the app's mode-selector.
21    """
22
23    def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
24        """Given an AppIntent, return the AppMode that should handle it.
25
26        If None is returned, the AppIntent will be ignored.
27
28        This may be called in a background thread, so avoid any calls
29        limited to logic thread use/etc.
30        """
31        raise NotImplementedError()

Defines which AppModes are available or used to handle given AppIntents.

The app calls an instance of this class when passed an AppIntent to determine which AppMode to use to handle the intent. Plugins or spinoff projects can modify high level app behavior by replacing or modifying the app's mode-selector.

def app_mode_for_intent( self, intent: AppIntent) -> type[AppMode] | None:
23    def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
24        """Given an AppIntent, return the AppMode that should handle it.
25
26        If None is returned, the AppIntent will be ignored.
27
28        This may be called in a background thread, so avoid any calls
29        limited to logic thread use/etc.
30        """
31        raise NotImplementedError()

Given an AppIntent, return the AppMode that should handle it.

If None is returned, the AppIntent will be ignored.

This may be called in a background thread, so avoid any calls limited to logic thread use/etc.

class AppSubsystem:
15class AppSubsystem:
16    """Base class for an app subsystem.
17
18    An app 'subsystem' is a bit of a vague term, as pieces of the app
19    can technically be any class and are not required to use this, but
20    building one out of this base class provides conveniences such as
21    predefined callbacks during app state changes.
22
23    Subsystems must be registered with the app before it completes its
24    transition to the 'running' state.
25    """
26
27    def __init__(self) -> None:
28        _babase.app.register_subsystem(self)
29
30    def on_app_loading(self) -> None:
31        """Called when the app reaches the loading state.
32
33        Note that subsystems created after the app switches to the
34        loading state will not receive this callback. Subsystems created
35        by plugins are an example of this.
36        """
37
38    def on_app_running(self) -> None:
39        """Called when the app reaches the running state."""
40
41    def on_app_suspend(self) -> None:
42        """Called when the app enters the suspended state."""
43
44    def on_app_unsuspend(self) -> None:
45        """Called when the app exits the suspended state."""
46
47    def on_app_shutdown(self) -> None:
48        """Called when the app begins shutting down."""
49
50    def on_app_shutdown_complete(self) -> None:
51        """Called when the app completes shutting down."""
52
53    def do_apply_app_config(self) -> None:
54        """Called when the app config should be applied."""
55
56    def on_ui_scale_change(self) -> None:
57        """Called when screen ui-scale changes.
58
59        Will not be called for the initial ui scale.
60        """
61
62    def on_screen_size_change(self) -> None:
63        """Called when the screen size changes.
64
65        Will not be called for the initial screen size.
66        """
67
68    def reset(self) -> None:
69        """Reset the subsystem to a default state.
70
71        This is called when switching app modes, but may be called
72        at other times too.
73        """

Base class for an app subsystem.

An app 'subsystem' is a bit of a vague term, as pieces of the app can technically be any class and are not required to use this, but building one out of this base class provides conveniences such as predefined callbacks during app state changes.

Subsystems must be registered with the app before it completes its transition to the 'running' state.

def on_app_loading(self) -> None:
30    def on_app_loading(self) -> None:
31        """Called when the app reaches the loading state.
32
33        Note that subsystems created after the app switches to the
34        loading state will not receive this callback. Subsystems created
35        by plugins are an example of this.
36        """

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.

def on_app_running(self) -> None:
38    def on_app_running(self) -> None:
39        """Called when the app reaches the running state."""

Called when the app reaches the running state.

def on_app_suspend(self) -> None:
41    def on_app_suspend(self) -> None:
42        """Called when the app enters the suspended state."""

Called when the app enters the suspended state.

def on_app_unsuspend(self) -> None:
44    def on_app_unsuspend(self) -> None:
45        """Called when the app exits the suspended state."""

Called when the app exits the suspended state.

def on_app_shutdown(self) -> None:
47    def on_app_shutdown(self) -> None:
48        """Called when the app begins shutting down."""

Called when the app begins shutting down.

def on_app_shutdown_complete(self) -> None:
50    def on_app_shutdown_complete(self) -> None:
51        """Called when the app completes shutting down."""

Called when the app completes shutting down.

def do_apply_app_config(self) -> None:
53    def do_apply_app_config(self) -> None:
54        """Called when the app config should be applied."""

Called when the app config should be applied.

def on_ui_scale_change(self) -> None:
56    def on_ui_scale_change(self) -> None:
57        """Called when screen ui-scale changes.
58
59        Will not be called for the initial ui scale.
60        """

Called when screen ui-scale changes.

Will not be called for the initial ui scale.

def on_screen_size_change(self) -> None:
62    def on_screen_size_change(self) -> None:
63        """Called when the screen size changes.
64
65        Will not be called for the initial screen size.
66        """

Called when the screen size changes.

Will not be called for the initial screen size.

def reset(self) -> None:
68    def reset(self) -> None:
69        """Reset the subsystem to a default state.
70
71        This is called when switching app modes, but may be called
72        at other times too.
73        """

Reset the subsystem to a default state.

This is called when switching app modes, but may be called at other times too.

def apptime() -> AppTime:
540def apptime() -> babase.AppTime:
541    """Return the current app-time in seconds.
542
543    App-time is a monotonic time value; it starts at 0.0 when the app
544    launches and will never jump by large amounts or go backwards, even if
545    the system time changes. Its progression will pause when the app is in
546    a suspended state.
547
548    Note that the AppTime returned here is simply float; it just has a
549    unique type in the type-checker's eyes to help prevent it from being
550    accidentally used with time functionality expecting other time types.
551    """
552    import babase  # pylint: disable=cyclic-import
553
554    return babase.AppTime(0.0)

Return the current app-time in seconds.

App-time is a monotonic time value; it starts at 0.0 when the app launches and will never jump by large amounts or go backwards, even if the system time changes. Its progression will pause when the app is in a suspended state.

Note that the AppTime returned here is simply float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.

AppTime = AppTime
def apptimer(time: float, call: Callable[[], Any]) -> None:
557def apptimer(time: float, call: Callable[[], Any]) -> None:
558    """Schedule a callable object to run based on app-time.
559
560    This function creates a one-off timer which cannot be canceled or
561    modified once created. If you require the ability to do so, or need
562    a repeating timer, use the babase.AppTimer class instead.
563
564    Args:
565        time: Length of time in seconds that the timer will wait before
566            firing.
567
568        call: A callable Python object. Note that the timer will retain a
569            strong reference to the callable for as long as the timer
570            exists, so you may want to look into concepts such as
571            babase.WeakCall if that is not desired.
572
573    Example: Print some stuff through time:
574      >>> babase.screenmessage('hello from now!')
575      >>> babase.apptimer(1.0, babase.Call(babase.screenmessage,
576      ...                 'hello from the future!'))
577      >>> babase.apptimer(2.0, babase.Call(babase.screenmessage,
578      ...                 'hello from the future 2!'))
579    """
580    return None

Schedule a callable object to run based on app-time.

This function creates a one-off timer which cannot be canceled or modified once created. If you require the ability to do so, or need a repeating timer, use the babase.AppTimer class instead.

Args: time: Length of time in seconds that the timer will wait before firing.

call: A callable Python object. Note that the timer will retain a
    strong reference to the callable for as long as the timer
    exists, so you may want to look into concepts such as
    babase.WeakCall if that is not desired.

Example: Print some stuff through time:

>>> babase.screenmessage('hello from now!')
>>> babase.apptimer(1.0, babase.Call(babase.screenmessage,
...                 'hello from the future!'))
>>> babase.apptimer(2.0, babase.Call(babase.screenmessage,
...                 'hello from the future 2!'))
class AppTimer:
55class AppTimer:
56    """Timers are used to run code at later points in time.
57
58    This class encapsulates a timer based on app-time.
59    The underlying timer will be destroyed when this object is no longer
60    referenced. If you do not want to worry about keeping a reference to
61    your timer around, use the babase.apptimer() function instead to get a
62    one-off timer.
63
64    ##### Arguments
65    ###### time
66    > Length of time in seconds that the timer will wait before firing.
67
68    ###### call
69    > A callable Python object. Remember that the timer will retain a
70    strong reference to the callable for as long as it exists, so you
71    may want to look into concepts such as babase.WeakCall if that is not
72    desired.
73
74    ###### repeat
75    > If True, the timer will fire repeatedly, with each successive
76    firing having the same delay as the first.
77
78    ##### Example
79
80    Use a Timer object to print repeatedly for a few seconds:
81    ... def say_it():
82    ...     babase.screenmessage('BADGER!')
83    ... def stop_saying_it():
84    ...     global g_timer
85    ...     g_timer = None
86    ...     babase.screenmessage('MUSHROOM MUSHROOM!')
87    ... # Create our timer; it will run as long as we have the self.t ref.
88    ... g_timer = babase.AppTimer(0.3, say_it, repeat=True)
89    ... # Now fire off a one-shot timer to kill it.
90    ... babase.apptimer(3.89, stop_saying_it)
91    """
92
93    def __init__(
94        self, time: float, call: Callable[[], Any], repeat: bool = False
95    ) -> None:
96        pass

Timers are used to run code at later points in time.

This class encapsulates a timer based on app-time. The underlying timer will be destroyed when this object is no longer referenced. If you do not want to worry about keeping a reference to your timer around, use the babase.apptimer() function instead to get a one-off timer.

Arguments
time

Length of time in seconds that the timer will wait before firing.

call

A callable Python object. Remember that the timer will retain a strong reference to the callable for as long as it exists, so you may want to look into concepts such as babase.WeakCall if that is not desired.

repeat

If True, the timer will fire repeatedly, with each successive firing having the same delay as the first.

Example

Use a Timer object to print repeatedly for a few seconds: ... def say_it(): ... babase.screenmessage('BADGER!') ... def stop_saying_it(): ... global g_timer ... g_timer = None ... babase.screenmessage('MUSHROOM MUSHROOM!') ... # Create our timer; it will run as long as we have the self.t ref. ... g_timer = babase.AppTimer(0.3, say_it, repeat=True) ... # Now fire off a one-shot timer to kill it. ... babase.apptimer(3.89, stop_saying_it)

AppTimer(time: float, call: Callable[[], Any], repeat: bool = False)
93    def __init__(
94        self, time: float, call: Callable[[], Any], repeat: bool = False
95    ) -> None:
96        pass
balog = <Logger ba (WARNING)>
Call = <class 'babase._general._Call'>
def charstr(char_id: SpecialChar) -> str:
598def charstr(char_id: babase.SpecialChar) -> str:
599    """Get a unicode string representing a special character.
600
601    Note that these utilize the private-use block of unicode characters
602    (U+E000-U+F8FF) and are specific to the game; exporting or rendering
603    them elsewhere will be meaningless.
604
605    See babase.SpecialChar for the list of available characters.
606    """
607    return str()

Get a unicode string representing a special character.

Note that these utilize the private-use block of unicode characters (U+E000-U+F8FF) and are specific to the game; exporting or rendering them elsewhere will be meaningless.

See babase.SpecialChar for the list of available characters.

def clipboard_get_text() -> str:
610def clipboard_get_text() -> str:
611    """Return text currently on the system clipboard.
612
613    Ensure that babase.clipboard_has_text() returns True before calling
614     this function.
615    """
616    return str()

Return text currently on the system clipboard.

Ensure that babase.clipboard_has_text() returns True before calling this function.

def clipboard_has_text() -> bool:
619def clipboard_has_text() -> bool:
620    """Return whether there is currently text on the clipboard.
621
622    This will return False if no system clipboard is available; no need
623     to call babase.clipboard_is_supported() separately.
624    """
625    return bool()

Return whether there is currently text on the clipboard.

This will return False if no system clipboard is available; no need to call babase.clipboard_is_supported() separately.

def clipboard_is_supported() -> bool:
628def clipboard_is_supported() -> bool:
629    """Return whether this platform supports clipboard operations at all.
630
631    If this returns False, UIs should not show 'copy to clipboard'
632    buttons, etc.
633    """
634    return bool()

Return whether this platform supports clipboard operations at all.

If this returns False, UIs should not show 'copy to clipboard' buttons, etc.

class CloudSubscription:
15class CloudSubscription:
16    """User handle to a subscription to some cloud data.
17
18    Do not instantiate these directly; use the subscribe methods in
19    :mod:`babase.app.plus.cloud` to create them.
20    """
21
22    def __init__(self, subscription_id: int) -> None:
23        self._subscription_id = subscription_id
24
25    def __del__(self) -> None:
26        if _babase.app.plus is not None:
27            _babase.app.plus.cloud.unsubscribe(self._subscription_id)

User handle to a subscription to some cloud data.

Do not instantiate these directly; use the subscribe methods in babase.app.plus.cloud to create them.

CloudSubscription(subscription_id: int)
22    def __init__(self, subscription_id: int) -> None:
23        self._subscription_id = subscription_id
def clipboard_set_text(value: str) -> None:
637def clipboard_set_text(value: str) -> None:
638    """Copy a string to the system clipboard.
639
640    Ensure that babase.clipboard_is_supported() returns True before adding
641     buttons/etc. that make use of this functionality.
642    """
643    return None

Copy a string to the system clipboard.

Ensure that babase.clipboard_is_supported() returns True before adding buttons/etc. that make use of this functionality.

class ContextCall:
 99class ContextCall:
100    """A context-preserving callable.
101
102    A ContextCall wraps a callable object along with a reference
103    to the current context (see babase.ContextRef); it handles restoring
104    the context when run and automatically clears itself if the context
105    it belongs to dies.
106
107    Generally you should not need to use this directly; all standard
108    Ballistica callbacks involved with timers, materials, UI functions,
109    etc. handle this under-the-hood so you don't have to worry about it.
110    The only time it may be necessary is if you are implementing your
111    own callbacks, such as a worker thread that does some action and then
112    runs some game code when done. By wrapping said callback in one of
113    these, you can ensure that you will not inadvertently be keeping the
114    current activity alive or running code in a torn-down (expired)
115    context_ref.
116
117    You can also use babase.WeakCall for similar functionality, but
118    ContextCall has the added bonus that it will not run during context_ref
119    shutdown, whereas babase.WeakCall simply looks at whether the target
120    object instance still exists.
121
122    ##### Examples
123    **Example A:** code like this can inadvertently prevent our activity
124    (self) from ending until the operation completes, since the bound
125    method we're passing (self.dosomething) contains a strong-reference
126    to self).
127    >>> start_some_long_action(callback_when_done=self.dosomething)
128
129    **Example B:** in this case our activity (self) can still die
130    properly; the callback will clear itself when the activity starts
131    shutting down, becoming a harmless no-op and releasing the reference
132    to our activity.
133
134    >>> start_long_action(
135    ...     callback_when_done=babase.ContextCall(self.mycallback))
136    """
137
138    def __init__(self, call: Callable) -> None:
139        pass
140
141    def __call__(self) -> None:
142        """Support for calling."""
143        pass

A context-preserving callable.

A ContextCall wraps a callable object along with a reference to the current context (see babase.ContextRef); it handles restoring the context when run and automatically clears itself if the context it belongs to dies.

Generally you should not need to use this directly; all standard Ballistica callbacks involved with timers, materials, UI functions, etc. handle this under-the-hood so you don't have to worry about it. The only time it may be necessary is if you are implementing your own callbacks, such as a worker thread that does some action and then runs some game code when done. By wrapping said callback in one of these, you can ensure that you will not inadvertently be keeping the current activity alive or running code in a torn-down (expired) context_ref.

You can also use babase.WeakCall for similar functionality, but ContextCall has the added bonus that it will not run during context_ref shutdown, whereas babase.WeakCall simply looks at whether the target object instance still exists.

Examples

Example A: code like this can inadvertently prevent our activity (self) from ending until the operation completes, since the bound method we're passing (self.dosomething) contains a strong-reference to self).

>>> start_some_long_action(callback_when_done=self.dosomething)

Example B: in this case our activity (self) can still die properly; the callback will clear itself when the activity starts shutting down, becoming a harmless no-op and releasing the reference to our activity.

>>> start_long_action(
...     callback_when_done=babase.ContextCall(self.mycallback))
ContextCall(call: Callable)
138    def __init__(self, call: Callable) -> None:
139        pass
class ContextError(builtins.Exception):
16class ContextError(Exception):
17    """Exception raised when a call is made in an invalid context.
18
19    Examples of this include calling UI functions within an Activity
20    context or calling scene manipulation functions outside of a game
21    context.
22    """

Exception raised when a call is made in an invalid context.

Examples of this include calling UI functions within an Activity context or calling scene manipulation functions outside of a game context.

class ContextRef:
146class ContextRef:
147    """Store or use a ballistica context.
148
149    Many operations such as bascenev1.newnode() or bascenev1.gettexture()
150    operate implicitly on a current 'context'. A context is some sort of
151    state that functionality can implicitly use. Context determines, for
152    example, which scene nodes or textures get added to without having to
153    specify it explicitly in the newnode()/gettexture() call. Contexts can
154    also affect object lifecycles; for example a babase.ContextCall will
155    become a no-op when the context it was created in is destroyed.
156
157    In general, if you are a modder, you should not need to worry about
158    contexts; mod code should mostly be getting run in the correct
159    context and timers and other callbacks will take care of saving
160    and restoring contexts automatically. There may be rare cases,
161    however, where you need to deal directly with contexts, and that is
162    where this class comes in.
163
164    Creating a babase.ContextRef() will capture a reference to the current
165    context. Other modules may provide ways to access their contexts; for
166    example a bascenev1.Activity instance has a 'context' attribute. You
167    can also use babase.ContextRef.empty() to create a reference to *no*
168    context. Some code such as UI calls may expect this and may complain
169    if you try to use them within a context.
170
171    ##### Usage
172    ContextRefs are generally used with the Python 'with' statement, which
173    sets the context they point to as current on entry and resets it to
174    the previous value on exit.
175
176    ##### Example
177    Explicitly create a few UI bits with no context set.
178    (UI stuff may complain if called within a context):
179    >>> with bui.ContextRef.empty():
180    ...     my_container = bui.containerwidget()
181    """
182
183    def __init__(
184        self,
185    ) -> None:
186        pass
187
188    def __enter__(self) -> None:
189        """Support for "with" statement."""
190        pass
191
192    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
193        """Support for "with" statement."""
194        pass
195
196    @classmethod
197    def empty(cls) -> ContextRef:
198        """Return a ContextRef pointing to no context.
199
200        This is useful when code should be run free of a context.
201        For example, UI code generally insists on being run this way.
202        Otherwise, callbacks set on the UI could inadvertently stop working
203        due to a game activity ending, which would be unintuitive behavior.
204        """
205        return ContextRef()
206
207    def is_empty(self) -> bool:
208        """Whether the context was created as empty."""
209        return bool()
210
211    def is_expired(self) -> bool:
212        """Whether the context has expired."""
213        return bool()

Store or use a ballistica context.

Many operations such as bascenev1.newnode() or bascenev1.gettexture() operate implicitly on a current 'context'. A context is some sort of state that functionality can implicitly use. Context determines, for example, which scene nodes or textures get added to without having to specify it explicitly in the newnode()/gettexture() call. Contexts can also affect object lifecycles; for example a babase.ContextCall will become a no-op when the context it was created in is destroyed.

In general, if you are a modder, you should not need to worry about contexts; mod code should mostly be getting run in the correct context and timers and other callbacks will take care of saving and restoring contexts automatically. There may be rare cases, however, where you need to deal directly with contexts, and that is where this class comes in.

Creating a babase.ContextRef() will capture a reference to the current context. Other modules may provide ways to access their contexts; for example a bascenev1.Activity instance has a 'context' attribute. You can also use babase.ContextRef.empty() to create a reference to no context. Some code such as UI calls may expect this and may complain if you try to use them within a context.

Usage

ContextRefs are generally used with the Python 'with' statement, which sets the context they point to as current on entry and resets it to the previous value on exit.

Example

Explicitly create a few UI bits with no context set. (UI stuff may complain if called within a context):

>>> with bui.ContextRef.empty():
...     my_container = bui.containerwidget()
@classmethod
def empty(cls) -> _babase.ContextRef:
196    @classmethod
197    def empty(cls) -> ContextRef:
198        """Return a ContextRef pointing to no context.
199
200        This is useful when code should be run free of a context.
201        For example, UI code generally insists on being run this way.
202        Otherwise, callbacks set on the UI could inadvertently stop working
203        due to a game activity ending, which would be unintuitive behavior.
204        """
205        return ContextRef()

Return a ContextRef pointing to no context.

This is useful when code should be run free of a context. For example, UI code generally insists on being run this way. Otherwise, callbacks set on the UI could inadvertently stop working due to a game activity ending, which would be unintuitive behavior.

def is_empty(self) -> bool:
207    def is_empty(self) -> bool:
208        """Whether the context was created as empty."""
209        return bool()

Whether the context was created as empty.

def is_expired(self) -> bool:
211    def is_expired(self) -> bool:
212        """Whether the context has expired."""
213        return bool()

Whether the context has expired.

class DelegateNotFoundError(babase.NotFoundError):
45class DelegateNotFoundError(NotFoundError):
46    """Exception raised when an expected delegate object does not exist."""

Exception raised when an expected delegate object does not exist.

class DevConsoleTab:
 18class DevConsoleTab:
 19    """Defines behavior for a tab in the dev-console."""
 20
 21    def refresh(self) -> None:
 22        """Called when the tab should refresh itself."""
 23
 24    def request_refresh(self) -> None:
 25        """The tab can call this to request that it be refreshed."""
 26        _babase.dev_console_request_refresh()
 27
 28    def button(
 29        self,
 30        label: str,
 31        pos: tuple[float, float],
 32        size: tuple[float, float],
 33        call: Callable[[], Any] | None = None,
 34        *,
 35        h_anchor: Literal['left', 'center', 'right'] = 'center',
 36        label_scale: float = 1.0,
 37        corner_radius: float = 8.0,
 38        style: Literal[
 39            'normal',
 40            'bright',
 41            'red',
 42            'red_bright',
 43            'purple',
 44            'purple_bright',
 45            'yellow',
 46            'yellow_bright',
 47            'blue',
 48            'blue_bright',
 49            'white',
 50            'white_bright',
 51            'black',
 52            'black_bright',
 53        ] = 'normal',
 54        disabled: bool = False,
 55    ) -> None:
 56        """Add a button to the tab being refreshed."""
 57        assert _babase.app.devconsole.is_refreshing
 58        _babase.dev_console_add_button(
 59            label,
 60            pos[0],
 61            pos[1],
 62            size[0],
 63            size[1],
 64            call,
 65            h_anchor,
 66            label_scale,
 67            corner_radius,
 68            style,
 69            disabled,
 70        )
 71
 72    def text(
 73        self,
 74        text: str,
 75        pos: tuple[float, float],
 76        *,
 77        h_anchor: Literal['left', 'center', 'right'] = 'center',
 78        h_align: Literal['left', 'center', 'right'] = 'center',
 79        v_align: Literal['top', 'center', 'bottom', 'none'] = 'center',
 80        scale: float = 1.0,
 81    ) -> None:
 82        """Add a button to the tab being refreshed."""
 83        assert _babase.app.devconsole.is_refreshing
 84        _babase.dev_console_add_text(
 85            text, pos[0], pos[1], h_anchor, h_align, v_align, scale
 86        )
 87
 88    def python_terminal(self) -> None:
 89        """Add a Python Terminal to the tab being refreshed."""
 90        assert _babase.app.devconsole.is_refreshing
 91        _babase.dev_console_add_python_terminal()
 92
 93    @property
 94    def width(self) -> float:
 95        """Return the current tab width. Only call during refreshes."""
 96        assert _babase.app.devconsole.is_refreshing
 97        return _babase.dev_console_tab_width()
 98
 99    @property
100    def height(self) -> float:
101        """Return the current tab height. Only call during refreshes."""
102        assert _babase.app.devconsole.is_refreshing
103        return _babase.dev_console_tab_height()
104
105    @property
106    def base_scale(self) -> float:
107        """A scale value set depending on the app's UI scale.
108
109        Dev-console tabs can incorporate this into their UI sizes and
110        positions if they desire. This must be done manually however.
111        """
112        assert _babase.app.devconsole.is_refreshing
113        return _babase.dev_console_base_scale()

Defines behavior for a tab in the dev-console.

def refresh(self) -> None:
21    def refresh(self) -> None:
22        """Called when the tab should refresh itself."""

Called when the tab should refresh itself.

def request_refresh(self) -> None:
24    def request_refresh(self) -> None:
25        """The tab can call this to request that it be refreshed."""
26        _babase.dev_console_request_refresh()

The tab can call this to request that it be refreshed.

def button( self, label: str, pos: tuple[float, float], size: tuple[float, float], call: Optional[Callable[[], Any]] = None, *, h_anchor: Literal['left', 'center', 'right'] = 'center', label_scale: float = 1.0, corner_radius: float = 8.0, style: Literal['normal', 'bright', 'red', 'red_bright', 'purple', 'purple_bright', 'yellow', 'yellow_bright', 'blue', 'blue_bright', 'white', 'white_bright', 'black', 'black_bright'] = 'normal', disabled: bool = False) -> None:
28    def button(
29        self,
30        label: str,
31        pos: tuple[float, float],
32        size: tuple[float, float],
33        call: Callable[[], Any] | None = None,
34        *,
35        h_anchor: Literal['left', 'center', 'right'] = 'center',
36        label_scale: float = 1.0,
37        corner_radius: float = 8.0,
38        style: Literal[
39            'normal',
40            'bright',
41            'red',
42            'red_bright',
43            'purple',
44            'purple_bright',
45            'yellow',
46            'yellow_bright',
47            'blue',
48            'blue_bright',
49            'white',
50            'white_bright',
51            'black',
52            'black_bright',
53        ] = 'normal',
54        disabled: bool = False,
55    ) -> None:
56        """Add a button to the tab being refreshed."""
57        assert _babase.app.devconsole.is_refreshing
58        _babase.dev_console_add_button(
59            label,
60            pos[0],
61            pos[1],
62            size[0],
63            size[1],
64            call,
65            h_anchor,
66            label_scale,
67            corner_radius,
68            style,
69            disabled,
70        )

Add a button to the tab being refreshed.

def text( self, text: str, pos: tuple[float, float], *, h_anchor: Literal['left', 'center', 'right'] = 'center', h_align: Literal['left', 'center', 'right'] = 'center', v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', scale: float = 1.0) -> None:
72    def text(
73        self,
74        text: str,
75        pos: tuple[float, float],
76        *,
77        h_anchor: Literal['left', 'center', 'right'] = 'center',
78        h_align: Literal['left', 'center', 'right'] = 'center',
79        v_align: Literal['top', 'center', 'bottom', 'none'] = 'center',
80        scale: float = 1.0,
81    ) -> None:
82        """Add a button to the tab being refreshed."""
83        assert _babase.app.devconsole.is_refreshing
84        _babase.dev_console_add_text(
85            text, pos[0], pos[1], h_anchor, h_align, v_align, scale
86        )

Add a button to the tab being refreshed.

def python_terminal(self) -> None:
88    def python_terminal(self) -> None:
89        """Add a Python Terminal to the tab being refreshed."""
90        assert _babase.app.devconsole.is_refreshing
91        _babase.dev_console_add_python_terminal()

Add a Python Terminal to the tab being refreshed.

width: float
93    @property
94    def width(self) -> float:
95        """Return the current tab width. Only call during refreshes."""
96        assert _babase.app.devconsole.is_refreshing
97        return _babase.dev_console_tab_width()

Return the current tab width. Only call during refreshes.

height: float
 99    @property
100    def height(self) -> float:
101        """Return the current tab height. Only call during refreshes."""
102        assert _babase.app.devconsole.is_refreshing
103        return _babase.dev_console_tab_height()

Return the current tab height. Only call during refreshes.

base_scale: float
105    @property
106    def base_scale(self) -> float:
107        """A scale value set depending on the app's UI scale.
108
109        Dev-console tabs can incorporate this into their UI sizes and
110        positions if they desire. This must be done manually however.
111        """
112        assert _babase.app.devconsole.is_refreshing
113        return _babase.dev_console_base_scale()

A scale value set depending on the app's UI scale.

Dev-console tabs can incorporate this into their UI sizes and positions if they desire. This must be done manually however.

@dataclass
class DevConsoleTabEntry:
116@dataclass
117class DevConsoleTabEntry:
118    """Represents a distinct tab in the dev-console."""
119
120    name: str
121    factory: Callable[[], DevConsoleTab]

Represents a distinct tab in the dev-console.

DevConsoleTabEntry(name: str, factory: Callable[[], DevConsoleTab])
name: str
factory: Callable[[], DevConsoleTab]
class DevConsoleSubsystem:
124class DevConsoleSubsystem:
125    """Subsystem for wrangling the dev console.
126
127    The single instance of this class can be found at
128    babase.app.devconsole. The dev-console is a simple always-available
129    UI intended for use by developers; not end users. Traditionally it
130    is available by typing a backtick (`) key on a keyboard, but now can
131    be accessed via an on-screen button (see settings/advanced to enable
132    said button).
133    """
134
135    def __init__(self) -> None:
136        # pylint: disable=cyclic-import
137        from babase._devconsoletabs import (
138            DevConsoleTabPython,
139            DevConsoleTabAppModes,
140            DevConsoleTabUI,
141            DevConsoleTabLogging,
142            DevConsoleTabTest,
143        )
144
145        # All tabs in the dev-console. Add your own stuff here via
146        # plugins or whatnot.
147        self.tabs: list[DevConsoleTabEntry] = [
148            DevConsoleTabEntry('Python', DevConsoleTabPython),
149            DevConsoleTabEntry('AppModes', DevConsoleTabAppModes),
150            DevConsoleTabEntry('UI', DevConsoleTabUI),
151            DevConsoleTabEntry('Logging', DevConsoleTabLogging),
152        ]
153        if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1':
154            self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest))
155        self.is_refreshing = False
156        self._tab_instances: dict[str, DevConsoleTab] = {}
157
158    def do_refresh_tab(self, tabname: str) -> None:
159        """Called by the C++ layer when a tab should be filled out."""
160        assert _babase.in_logic_thread()
161
162        # Make noise if we have repeating tab names, as that breaks our
163        # logic.
164        if __debug__:
165            alltabnames = set[str](tabentry.name for tabentry in self.tabs)
166            if len(alltabnames) != len(self.tabs):
167                logging.error(
168                    'Duplicate dev-console tab names found;'
169                    ' tabs may behave unpredictably.'
170                )
171
172        tab: DevConsoleTab | None = self._tab_instances.get(tabname)
173
174        # If we haven't instantiated this tab yet, do so.
175        if tab is None:
176            for tabentry in self.tabs:
177                if tabentry.name == tabname:
178                    tab = self._tab_instances[tabname] = tabentry.factory()
179                    break
180
181        if tab is None:
182            logging.error(
183                'DevConsole got refresh request for tab'
184                " '%s' which does not exist.",
185                tabname,
186            )
187            return
188
189        self.is_refreshing = True
190        try:
191            tab.refresh()
192        finally:
193            self.is_refreshing = False

Subsystem for wrangling the dev console.

The single instance of this class can be found at babase.app.devconsole. The dev-console is a simple always-available UI intended for use by developers; not end users. Traditionally it is available by typing a backtick (`) key on a keyboard, but now can be accessed via an on-screen button (see settings/advanced to enable said button).

tabs: list[DevConsoleTabEntry]
is_refreshing
def do_refresh_tab(self, tabname: str) -> None:
158    def do_refresh_tab(self, tabname: str) -> None:
159        """Called by the C++ layer when a tab should be filled out."""
160        assert _babase.in_logic_thread()
161
162        # Make noise if we have repeating tab names, as that breaks our
163        # logic.
164        if __debug__:
165            alltabnames = set[str](tabentry.name for tabentry in self.tabs)
166            if len(alltabnames) != len(self.tabs):
167                logging.error(
168                    'Duplicate dev-console tab names found;'
169                    ' tabs may behave unpredictably.'
170                )
171
172        tab: DevConsoleTab | None = self._tab_instances.get(tabname)
173
174        # If we haven't instantiated this tab yet, do so.
175        if tab is None:
176            for tabentry in self.tabs:
177                if tabentry.name == tabname:
178                    tab = self._tab_instances[tabname] = tabentry.factory()
179                    break
180
181        if tab is None:
182            logging.error(
183                'DevConsole got refresh request for tab'
184                " '%s' which does not exist.",
185                tabname,
186            )
187            return
188
189        self.is_refreshing = True
190        try:
191            tab.refresh()
192        finally:
193            self.is_refreshing = False

Called by the C++ layer when a tab should be filled out.

DisplayTime = DisplayTime
def displaytime() -> DisplayTime:
729def displaytime() -> babase.DisplayTime:
730    """Return the current display-time in seconds.
731
732    Display-time is a time value intended to be used for animation and other
733    visual purposes. It will generally increment by a consistent amount each
734    frame. It will pass at an overall similar rate to AppTime, but trades
735    accuracy for smoothness.
736
737    Note that the value returned here is simply a float; it just has a
738    unique type in the type-checker's eyes to help prevent it from being
739    accidentally used with time functionality expecting other time types.
740    """
741    import babase  # pylint: disable=cyclic-import
742
743    return babase.DisplayTime(0.0)

Return the current display-time in seconds.

Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.

Note that the value returned here is simply a float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.

def displaytimer(time: float, call: Callable[[], Any]) -> None:
746def displaytimer(time: float, call: Callable[[], Any]) -> None:
747    """Schedule a callable object to run based on display-time.
748
749    This function creates a one-off timer which cannot be canceled or
750    modified once created. If you require the ability to do so, or need
751    a repeating timer, use the babase.DisplayTimer class instead.
752
753    Display-time is a time value intended to be used for animation and other
754    visual purposes. It will generally increment by a consistent amount each
755    frame. It will pass at an overall similar rate to AppTime, but trades
756    accuracy for smoothness.
757
758    ##### Arguments
759    ###### time (float)
760    > Length of time in seconds that the timer will wait before firing.
761
762    ###### call (Callable[[], Any])
763    > A callable Python object. Note that the timer will retain a
764    strong reference to the callable for as long as the timer exists, so you
765    may want to look into concepts such as babase.WeakCall if that is not
766    desired.
767
768    ##### Examples
769    Print some stuff through time:
770    >>> babase.screenmessage('hello from now!')
771    >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage,
772    ...                       'hello from the future!'))
773    >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage,
774    ...                       'hello from the future 2!'))
775    """
776    return None

Schedule a callable object to run based on display-time.

This function creates a one-off timer which cannot be canceled or modified once created. If you require the ability to do so, or need a repeating timer, use the babase.DisplayTimer class instead.

Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.

Arguments
time (float)

Length of time in seconds that the timer will wait before firing.

call (Callable[[], Any])

A callable Python object. Note that the timer will retain a strong reference to the callable for as long as the timer exists, so you may want to look into concepts such as babase.WeakCall if that is not desired.

Examples

Print some stuff through time:

>>> babase.screenmessage('hello from now!')
>>> babase.displaytimer(1.0, babase.Call(babase.screenmessage,
...                       'hello from the future!'))
>>> babase.displaytimer(2.0, babase.Call(babase.screenmessage,
...                       'hello from the future 2!'))
class DisplayTimer:
216class DisplayTimer:
217    """Timers are used to run code at later points in time.
218
219    This class encapsulates a timer based on display-time.
220    The underlying timer will be destroyed when this object is no longer
221    referenced. If you do not want to worry about keeping a reference to
222    your timer around, use the babase.displaytimer() function instead to get a
223    one-off timer.
224
225    Display-time is a time value intended to be used for animation and
226    other visual purposes. It will generally increment by a consistent
227    amount each frame. It will pass at an overall similar rate to AppTime,
228    but trades accuracy for smoothness.
229
230    ##### Arguments
231    ###### time
232    > Length of time in seconds that the timer will wait before firing.
233
234    ###### call
235    > A callable Python object. Remember that the timer will retain a
236    strong reference to the callable for as long as it exists, so you
237    may want to look into concepts such as babase.WeakCall if that is not
238    desired.
239
240    ###### repeat
241    > If True, the timer will fire repeatedly, with each successive
242    firing having the same delay as the first.
243
244    ##### Example
245
246    Use a Timer object to print repeatedly for a few seconds:
247    ... def say_it():
248    ...     babase.screenmessage('BADGER!')
249    ... def stop_saying_it():
250    ...     global g_timer
251    ...     g_timer = None
252    ...     babase.screenmessage('MUSHROOM MUSHROOM!')
253    ... # Create our timer; it will run as long as we have the self.t ref.
254    ... g_timer = babase.DisplayTimer(0.3, say_it, repeat=True)
255    ... # Now fire off a one-shot timer to kill it.
256    ... babase.displaytimer(3.89, stop_saying_it)
257    """
258
259    def __init__(
260        self, time: float, call: Callable[[], Any], repeat: bool = False
261    ) -> None:
262        pass

Timers are used to run code at later points in time.

This class encapsulates a timer based on display-time. The underlying timer will be destroyed when this object is no longer referenced. If you do not want to worry about keeping a reference to your timer around, use the babase.displaytimer() function instead to get a one-off timer.

Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.

Arguments
time

Length of time in seconds that the timer will wait before firing.

call

A callable Python object. Remember that the timer will retain a strong reference to the callable for as long as it exists, so you may want to look into concepts such as babase.WeakCall if that is not desired.

repeat

If True, the timer will fire repeatedly, with each successive firing having the same delay as the first.

Example

Use a Timer object to print repeatedly for a few seconds: ... def say_it(): ... babase.screenmessage('BADGER!') ... def stop_saying_it(): ... global g_timer ... g_timer = None ... babase.screenmessage('MUSHROOM MUSHROOM!') ... # Create our timer; it will run as long as we have the self.t ref. ... g_timer = babase.DisplayTimer(0.3, say_it, repeat=True) ... # Now fire off a one-shot timer to kill it. ... babase.displaytimer(3.89, stop_saying_it)

DisplayTimer(time: float, call: Callable[[], Any], repeat: bool = False)
259    def __init__(
260        self, time: float, call: Callable[[], Any], repeat: bool = False
261    ) -> None:
262        pass
def do_once() -> bool:
784def do_once() -> bool:
785    """Return whether this is the first time running a line of code.
786
787    This is used by 'print_once()' type calls to keep from overflowing
788    logs. The call functions by registering the filename and line where
789    The call is made from.  Returns True if this location has not been
790    registered already, and False if it has.
791
792    ##### Example
793    This print will only fire for the first loop iteration:
794    >>> for i in range(10):
795    ... if babase.do_once():
796    ...     print('HelloWorld once from loop!')
797    """
798    return bool()

Return whether this is the first time running a line of code.

This is used by 'print_once()' type calls to keep from overflowing logs. The call functions by registering the filename and line where The call is made from. Returns True if this location has not been registered already, and False if it has.

Example

This print will only fire for the first loop iteration:

>>> for i in range(10):
... if babase.do_once():
...     print('HelloWorld once from loop!')
class EmptyAppMode(babase.AppMode):
20class EmptyAppMode(AppMode):
21    """An AppMode that does not do much at all."""
22
23    @override
24    @classmethod
25    def get_app_experience(cls) -> AppExperience:
26        return AppExperience.EMPTY
27
28    @override
29    @classmethod
30    def _can_handle_intent(cls, intent: AppIntent) -> bool:
31        # We support default and exec intents currently.
32        return isinstance(intent, AppIntentExec | AppIntentDefault)
33
34    @override
35    def handle_intent(self, intent: AppIntent) -> None:
36        if isinstance(intent, AppIntentExec):
37            _babase.empty_app_mode_handle_app_intent_exec(intent.code)
38            return
39        assert isinstance(intent, AppIntentDefault)
40        _babase.empty_app_mode_handle_app_intent_default()
41
42    @override
43    def on_activate(self) -> None:
44        # Let the native layer do its thing.
45        _babase.empty_app_mode_activate()
46
47    @override
48    def on_deactivate(self) -> None:
49        # Let the native layer do its thing.
50        _babase.empty_app_mode_deactivate()

An AppMode that does not do much at all.

@override
@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
23    @override
24    @classmethod
25    def get_app_experience(cls) -> AppExperience:
26        return AppExperience.EMPTY

Return the overall experience provided by this mode.

@override
def handle_intent(self, intent: AppIntent) -> None:
34    @override
35    def handle_intent(self, intent: AppIntent) -> None:
36        if isinstance(intent, AppIntentExec):
37            _babase.empty_app_mode_handle_app_intent_exec(intent.code)
38            return
39        assert isinstance(intent, AppIntentDefault)
40        _babase.empty_app_mode_handle_app_intent_default()

Handle an intent.

@override
def on_activate(self) -> None:
42    @override
43    def on_activate(self) -> None:
44        # Let the native layer do its thing.
45        _babase.empty_app_mode_activate()

Called when the mode is becoming the active one fro the app.

@override
def on_deactivate(self) -> None:
47    @override
48    def on_deactivate(self) -> None:
49        # Let the native layer do its thing.
50        _babase.empty_app_mode_deactivate()

Called when the mode stops being the active one for the app.

On platforms where the app is explicitly exited (such as desktop PC) this will also be called at app shutdown.

To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).

class Env:
265class Env:
266    """Unchanging values for the current running app instance.
267    Access the single shared instance of this class at `babase.app.env`.
268    """
269
270    android: bool
271    """Is this build targeting an Android based OS?"""
272
273    api_version: int
274    """The app's api version.
275
276       Only Python modules and packages associated with the current API
277       version number will be detected by the game (see the ba_meta tag).
278       This value will change whenever substantial backward-incompatible
279       changes are introduced to Ballistica APIs. When that happens,
280       modules/packages should be updated accordingly and set to target
281       the newer API version number."""
282
283    arcade: bool
284    """Whether the app is targeting an arcade-centric experience."""
285
286    config_file_path: str
287    """Where the app's config file is stored on disk."""
288
289    data_directory: str
290    """Where bundled static app data lives."""
291
292    debug: bool
293    """Whether the app is running in debug mode.
294
295       Debug builds generally run substantially slower than non-debug
296       builds due to compiler optimizations being disabled and extra
297       checks being run."""
298
299    demo: bool
300    """Whether the app is targeting a demo experience."""
301
302    device_name: str
303    """Human readable name of the device running this app."""
304
305    engine_build_number: int
306    """Integer build number for the engine.
307
308       This value increases by at least 1 with each release of the engine.
309       It is independent of the human readable `version` string."""
310
311    engine_version: str
312    """Human-readable version string for the engine; something like '1.3.24'.
313
314       This should not be interpreted as a number; it may contain
315       string elements such as 'alpha', 'beta', 'test', etc.
316       If a numeric version is needed, use `build_number`."""
317
318    gui: bool
319    """Whether the app is running with a gui.
320
321       This is the opposite of `headless`."""
322
323    headless: bool
324    """Whether the app is running headlessly (without a gui).
325
326       This is the opposite of `gui`."""
327
328    python_directory_app: str | None
329    """Path where the app expects its bundled modules to live.
330
331       Be aware that this value may be None if Ballistica is running in
332       a non-standard environment, and that python-path modifications may
333       cause modules to be loaded from other locations."""
334
335    python_directory_app_site: str | None
336    """Path where the app expects its bundled pip modules to live.
337
338       Be aware that this value may be None if Ballistica is running in
339       a non-standard environment, and that python-path modifications may
340       cause modules to be loaded from other locations."""
341
342    python_directory_user: str | None
343    """Path where the app expects its user scripts (mods) to live.
344
345       Be aware that this value may be None if Ballistica is running in
346       a non-standard environment, and that python-path modifications may
347       cause modules to be loaded from other locations."""
348
349    supports_soft_quit: bool
350    """Whether the running app supports 'soft' quit options.
351
352       This generally applies to mobile derived OSs, where an act of
353       'quitting' may leave the app running in the background waiting
354       in case it is used again."""
355
356    test: bool
357    """Whether the app is running in test mode.
358
359       Test mode enables extra checks and features that are useful for
360       release testing but which do not slow the game down significantly."""
361
362    tv: bool
363    """Whether the app is targeting a TV-centric experience."""
364
365    vr: bool
366    """Whether the app is currently running in VR."""
367
368    pass

Unchanging values for the current running app instance. Access the single shared instance of this class at babase.app.env.

android: bool

Is this build targeting an Android based OS?

api_version: int

The app's api version.

Only Python modules and packages associated with the current API version number will be detected by the game (see the ba_meta tag). This value will change whenever substantial backward-incompatible changes are introduced to Ballistica APIs. When that happens, modules/packages should be updated accordingly and set to target the newer API version number.

arcade: bool

Whether the app is targeting an arcade-centric experience.

config_file_path: str

Where the app's config file is stored on disk.

data_directory: str

Where bundled static app data lives.

debug: bool

Whether the app is running in debug mode.

Debug builds generally run substantially slower than non-debug builds due to compiler optimizations being disabled and extra checks being run.

demo: bool

Whether the app is targeting a demo experience.

device_name: str

Human readable name of the device running this app.

engine_build_number: int

Integer build number for the engine.

This value increases by at least 1 with each release of the engine. It is independent of the human readable version string.

engine_version: str

Human-readable version string for the engine; something like '1.3.24'.

This should not be interpreted as a number; it may contain string elements such as 'alpha', 'beta', 'test', etc. If a numeric version is needed, use build_number.

gui: bool

Whether the app is running with a gui.

This is the opposite of headless.

headless: bool

Whether the app is running headlessly (without a gui).

This is the opposite of gui.

python_directory_app: str | None

Path where the app expects its bundled modules to live.

Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.

python_directory_app_site: str | None

Path where the app expects its bundled pip modules to live.

Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.

python_directory_user: str | None

Path where the app expects its user scripts (mods) to live.

Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.

supports_soft_quit: bool

Whether the running app supports 'soft' quit options.

This generally applies to mobile derived OSs, where an act of 'quitting' may leave the app running in the background waiting in case it is used again.

test: bool

Whether the app is running in test mode.

Test mode enables extra checks and features that are useful for release testing but which do not slow the game down significantly.

tv: bool

Whether the app is targeting a TV-centric experience.

vr: bool

Whether the app is currently running in VR.

class Existable(typing.Protocol):
37class Existable(Protocol):
38    """A Protocol for objects supporting an exists() method."""
39
40    def exists(self) -> bool:
41        """Whether this object exists."""

A Protocol for objects supporting an exists() method.

Existable(*args, **kwargs)
1771def _no_init_or_replace_init(self, *args, **kwargs):
1772    cls = type(self)
1773
1774    if cls._is_protocol:
1775        raise TypeError('Protocols cannot be instantiated')
1776
1777    # Already using a custom `__init__`. No need to calculate correct
1778    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1779    if cls.__init__ is not _no_init_or_replace_init:
1780        return
1781
1782    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1783    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1784    # searches for a proper new `__init__` in the MRO. The new `__init__`
1785    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1786    # instantiation of the protocol subclass will thus use the new
1787    # `__init__` and no longer call `_no_init_or_replace_init`.
1788    for base in cls.__mro__:
1789        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1790        if init is not _no_init_or_replace_init:
1791            cls.__init__ = init
1792            break
1793    else:
1794        # should not happen
1795        cls.__init__ = object.__init__
1796
1797    cls.__init__(self, *args, **kwargs)
def exists(self) -> bool:
40    def exists(self) -> bool:
41        """Whether this object exists."""

Whether this object exists.

def existing(obj: Optional[~ExistableT]) -> Optional[~ExistableT]:
48def existing(obj: ExistableT | None) -> ExistableT | None:
49    """Convert invalid references to None for any babase.Existable object.
50
51    To best support type checking, it is important that invalid references
52    not be passed around and instead get converted to values of None.
53    That way the type checker can properly flag attempts to pass possibly-dead
54    objects (FooType | None) into functions expecting only live ones
55    (FooType), etc. This call can be used on any 'existable' object
56    (one with an exists() method) and will convert it to a None value
57    if it does not exist.
58
59    For more info, see notes on 'existables' here:
60    https://ballistica.net/wiki/Coding-Style-Guide
61    """
62    assert obj is None or hasattr(obj, 'exists'), f'No "exists" attr on {obj}.'
63    return obj if obj is not None and obj.exists() else None

Convert invalid references to None for any babase.Existable object.

To best support type checking, it is important that invalid references not be passed around and instead get converted to values of None. That way the type checker can properly flag attempts to pass possibly-dead objects (FooType | None) into functions expecting only live ones (FooType), etc. This call can be used on any 'existable' object (one with an exists() method) and will convert it to a None value if it does not exist.

For more info, see notes on 'existables' here: https://ballistica.net/wiki/Coding-Style-Guide

def fatal_error(message: str) -> None:
870def fatal_error(message: str) -> None:
871    """Trigger a fatal error. Use this in situations where it is not possible
872    for the engine to continue on in a useful way. This can sometimes
873    help provide more clear information at the exact source of a problem
874    as compared to raising an Exception. In the vast majority of cases,
875    however, Exceptions should be preferred.
876    """
877    return None

Trigger a fatal error. Use this in situations where it is not possible for the engine to continue on in a useful way. This can sometimes help provide more clear information at the exact source of a problem as compared to raising an Exception. In the vast majority of cases, however, Exceptions should be preferred.

def garbage_collect() -> None:
227def garbage_collect() -> None:
228    """Run an explicit pass of garbage collection.
229
230    category: General Utility Functions
231
232    May also print warnings/etc. if collection takes too long or if
233    uncollectible objects are found (so use this instead of simply
234    gc.collect().
235    """
236    gc.collect()

Run an explicit pass of garbage collection.

category: General Utility Functions

May also print warnings/etc. if collection takes too long or if uncollectible objects are found (so use this instead of simply gc.collect().

def get_input_idle_time() -> float:
967def get_input_idle_time() -> float:
968    """Return seconds since any local input occurred (touch, keypress, etc.)."""
969    return float()

Return seconds since any local input occurred (touch, keypress, etc.).

def get_ip_address_type(addr: str) -> socket.AddressFamily:
45def get_ip_address_type(addr: str) -> socket.AddressFamily:
46    """Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
47
48    version = ipaddress.ip_address(addr).version
49    if version == 4:
50        return socket.AF_INET
51    assert version == 6
52    return socket.AF_INET6

Return socket.AF_INET6 or socket.AF_INET4 for the provided address.

def get_type_name(cls: type) -> str:
105def get_type_name(cls: type) -> str:
106    """Return a full type name including module for a class."""
107    return f'{cls.__module__}.{cls.__name__}'

Return a full type name including module for a class.

def getclass( name: str, subclassof: type[~T], check_sdlib_modulename_clash: bool = False) -> type[~T]:
66def getclass(
67    name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False
68) -> type[T]:
69    """Given a full class name such as foo.bar.MyClass, return the class.
70
71    The class will be checked to make sure it is a subclass of the provided
72    'subclassof' class, and a TypeError will be raised if not.
73    """
74    import importlib
75
76    splits = name.split('.')
77    modulename = '.'.join(splits[:-1])
78    classname = splits[-1]
79    if modulename in sys.stdlib_module_names and check_sdlib_modulename_clash:
80        raise Exception(f'{modulename} is an inbuilt module.')
81    module = importlib.import_module(modulename)
82    cls: type = getattr(module, classname)
83
84    if not issubclass(cls, subclassof):
85        raise TypeError(f'{name} is not a subclass of {subclassof}.')
86    return cls

Given a full class name such as foo.bar.MyClass, return the class.

The class will be checked to make sure it is a subclass of the provided 'subclassof' class, and a TypeError will be raised if not.

def handle_leftover_v1_cloud_log_file() -> None:
165def handle_leftover_v1_cloud_log_file() -> None:
166    """Handle an un-uploaded v1-cloud-log from a previous run."""
167
168    # Only applies with classic present.
169    if _babase.app.classic is None:
170        return
171    try:
172        import json
173
174        if os.path.exists(_babase.get_v1_cloud_log_file_path()):
175            with open(
176                _babase.get_v1_cloud_log_file_path(), encoding='utf-8'
177            ) as infile:
178                info = json.loads(infile.read())
179            infile.close()
180            do_send = should_submit_debug_info()
181            if do_send:
182
183                def response(data: Any) -> None:
184                    # Non-None response means we were successful;
185                    # lets kill it.
186                    if data is not None:
187                        try:
188                            os.remove(_babase.get_v1_cloud_log_file_path())
189                        except FileNotFoundError:
190                            # Saw this in the wild. The file just existed
191                            # a moment ago but I suppose something could have
192                            # killed it since. ¯\_(ツ)_/¯
193                            pass
194
195                _babase.app.classic.master_server_v1_post(
196                    'bsLog', info, response
197                )
198            else:
199                # If they don't want logs uploaded just kill it.
200                os.remove(_babase.get_v1_cloud_log_file_path())
201    except Exception:
202        from babase import _error
203
204        _error.print_exception('Error handling leftover log file.')

Handle an un-uploaded v1-cloud-log from a previous run.

class InputDeviceNotFoundError(babase.NotFoundError):
69class InputDeviceNotFoundError(NotFoundError):
70    """Exception raised when an expected input-device does not exist."""

Exception raised when an expected input-device does not exist.

class InputType(enum.Enum):
 8class InputType(Enum):
 9    """Types of input a controller can send to the game.
10
11    """
12
13    UP_DOWN = 2
14    LEFT_RIGHT = 3
15    JUMP_PRESS = 4
16    JUMP_RELEASE = 5
17    PUNCH_PRESS = 6
18    PUNCH_RELEASE = 7
19    BOMB_PRESS = 8
20    BOMB_RELEASE = 9
21    PICK_UP_PRESS = 10
22    PICK_UP_RELEASE = 11
23    RUN = 12
24    FLY_PRESS = 13
25    FLY_RELEASE = 14
26    START_PRESS = 15
27    START_RELEASE = 16
28    HOLD_POSITION_PRESS = 17
29    HOLD_POSITION_RELEASE = 18
30    LEFT_PRESS = 19
31    LEFT_RELEASE = 20
32    RIGHT_PRESS = 21
33    RIGHT_RELEASE = 22
34    UP_PRESS = 23
35    UP_RELEASE = 24
36    DOWN_PRESS = 25
37    DOWN_RELEASE = 26

Types of input a controller can send to the game.

UP_DOWN = <InputType.UP_DOWN: 2>
LEFT_RIGHT = <InputType.LEFT_RIGHT: 3>
JUMP_PRESS = <InputType.JUMP_PRESS: 4>
JUMP_RELEASE = <InputType.JUMP_RELEASE: 5>
PUNCH_PRESS = <InputType.PUNCH_PRESS: 6>
PUNCH_RELEASE = <InputType.PUNCH_RELEASE: 7>
BOMB_PRESS = <InputType.BOMB_PRESS: 8>
BOMB_RELEASE = <InputType.BOMB_RELEASE: 9>
PICK_UP_PRESS = <InputType.PICK_UP_PRESS: 10>
PICK_UP_RELEASE = <InputType.PICK_UP_RELEASE: 11>
RUN = <InputType.RUN: 12>
FLY_PRESS = <InputType.FLY_PRESS: 13>
FLY_RELEASE = <InputType.FLY_RELEASE: 14>
START_PRESS = <InputType.START_PRESS: 15>
START_RELEASE = <InputType.START_RELEASE: 16>
HOLD_POSITION_PRESS = <InputType.HOLD_POSITION_PRESS: 17>
HOLD_POSITION_RELEASE = <InputType.HOLD_POSITION_RELEASE: 18>
LEFT_PRESS = <InputType.LEFT_PRESS: 19>
LEFT_RELEASE = <InputType.LEFT_RELEASE: 20>
RIGHT_PRESS = <InputType.RIGHT_PRESS: 21>
RIGHT_RELEASE = <InputType.RIGHT_RELEASE: 22>
UP_PRESS = <InputType.UP_PRESS: 23>
UP_RELEASE = <InputType.UP_RELEASE: 24>
DOWN_PRESS = <InputType.DOWN_PRESS: 25>
DOWN_RELEASE = <InputType.DOWN_RELEASE: 26>
def invoke_main_menu() -> None:
1141def invoke_main_menu() -> None:
1142    """High level call to bring up the main menu if it is not present.
1143
1144    This is essentially the same as pressing the menu button on a controller.
1145    """
1146    return None

High level call to bring up the main menu if it is not present.

This is essentially the same as pressing the menu button on a controller.

def is_browser_likely_available() -> bool:
40def is_browser_likely_available() -> bool:
41    """Return whether a browser likely exists on the current device.
42
43    category: General Utility Functions
44
45    If this returns False you may want to avoid calling babase.open_url()
46    with any lengthy addresses. (babase.open_url() will display an address
47    as a string in a window if unable to bring up a browser, but that
48    is only useful for simple URLs.)
49    """
50    app = _babase.app
51
52    if app.classic is None:
53        logging.warning(
54            'is_browser_likely_available() needs to be updated'
55            ' to work without classic.'
56        )
57        return True
58
59    platform = app.classic.platform
60    hastouchscreen = _babase.hastouchscreen()
61
62    # If we're on a vr device or an android device with no touchscreen,
63    # assume no browser.
64    # FIXME: Might not be the case anymore; should make this definable
65    #  at the platform level.
66    if app.env.vr or (platform == 'android' and not hastouchscreen):
67        return False
68
69    # Anywhere else assume we've got one.
70    return True

Return whether a browser likely exists on the current device.

category: General Utility Functions

If this returns False you may want to avoid calling babase.open_url() with any lengthy addresses. (babase.open_url() will display an address as a string in a window if unable to bring up a browser, but that is only useful for simple URLs.)

def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
38def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
39    """Return whether a given point is within a given box.
40
41    category: General Utility Functions
42
43    For use with standard def boxes (position|rotate|scale).
44    """
45    return (
46        (abs(pnt[0] - box[0]) <= box[6] * 0.5)
47        and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
48        and (abs(pnt[2] - box[2]) <= box[8] * 0.5)
49    )

Return whether a given point is within a given box.

category: General Utility Functions

For use with standard def boxes (position|rotate|scale).

class LanguageSubsystem(babase.AppSubsystem):
 22class LanguageSubsystem(AppSubsystem):
 23    """Language functionality for the app.
 24
 25    Access the single instance of this class at 'babase.app.lang'.
 26    """
 27
 28    def __init__(self) -> None:
 29        super().__init__()
 30        self.default_language: str = self._get_default_language()
 31
 32        self._language: str | None = None
 33        self._language_target: AttrDict | None = None
 34        self._language_merged: AttrDict | None = None
 35        self._test_timer: babase.AppTimer | None = None
 36
 37    @property
 38    def locale(self) -> str:
 39        """Raw country/language code detected by the game (such as 'en_US').
 40
 41        Generally for language-specific code you should look at
 42        babase.App.language, which is the language the game is using
 43        (which may differ from locale if the user sets a language, etc.)
 44        """
 45        env = _babase.env()
 46        locale = env.get('locale')
 47        if not isinstance(locale, str):
 48            logging.warning(
 49                'Seem to be running in a dummy env; returning en_US locale.'
 50            )
 51            locale = 'en_US'
 52        return locale
 53
 54    @property
 55    def language(self) -> str:
 56        """The current active language for the app.
 57
 58        This can be selected explicitly by the user or may be set
 59        automatically based on locale or other factors.
 60        """
 61        if self._language is None:
 62            raise RuntimeError('App language is not yet set.')
 63        return self._language
 64
 65    @property
 66    def available_languages(self) -> list[str]:
 67        """A list of all available languages.
 68
 69        Note that languages that may be present in game assets but which
 70        are not displayable on the running version of the game are not
 71        included here.
 72        """
 73        langs = set()
 74        try:
 75            names = os.listdir(
 76                os.path.join(
 77                    _babase.app.env.data_directory,
 78                    'ba_data',
 79                    'data',
 80                    'languages',
 81                )
 82            )
 83            names = [n.replace('.json', '').capitalize() for n in names]
 84
 85            # FIXME: our simple capitalization fails on multi-word names;
 86            # should handle this in a better way...
 87            for i, name in enumerate(names):
 88                if name == 'Chinesetraditional':
 89                    names[i] = 'ChineseTraditional'
 90                elif name == 'Piratespeak':
 91                    names[i] = 'PirateSpeak'
 92        except Exception:
 93            from babase import _error
 94
 95            _error.print_exception()
 96            names = []
 97        for name in names:
 98            if self._can_display_language(name):
 99                langs.add(name)
100        return sorted(
101            name for name in names if self._can_display_language(name)
102        )
103
104    def testlanguage(self, langid: str) -> None:
105        """Set the app to test an in-progress language.
106
107        Pass a language id from the translation editor website as 'langid';
108        something like 'Gibberish_3263'. Once set to testing, the engine
109        will repeatedly download and apply that same test language, so
110        changes can be made to it and observed live.
111        """
112        print(
113            f'Language test mode enabled.'
114            f' Will fetch and apply \'{langid}\' every 5 seconds,'
115            f' so you can see your changes live.'
116        )
117        self._test_timer = _babase.AppTimer(
118            5.0, partial(self._update_test_language, langid), repeat=True
119        )
120        self._update_test_language(langid)
121
122    def _on_test_lang_response(
123        self, langid: str, response: None | dict[str, Any]
124    ) -> None:
125        if response is None:
126            return
127        self.setlanguage(response)
128        print(f'Fetched and applied {langid}.')
129
130    def _update_test_language(self, langid: str) -> None:
131        if _babase.app.classic is None:
132            raise RuntimeError('This requires classic.')
133        _babase.app.classic.master_server_v1_get(
134            'bsLangGet',
135            {'lang': langid, 'format': 'json'},
136            partial(self._on_test_lang_response, langid),
137        )
138
139    def setlanguage(
140        self,
141        language: str | dict | None,
142        print_change: bool = True,
143        store_to_config: bool = True,
144    ) -> None:
145        """Set the active app language.
146
147        Pass None to use OS default language.
148        """
149        # pylint: disable=too-many-locals
150        # pylint: disable=too-many-statements
151        # pylint: disable=too-many-branches
152        assert _babase.in_logic_thread()
153        cfg = _babase.app.config
154        cur_language = cfg.get('Lang', None)
155
156        with open(
157            os.path.join(
158                _babase.app.env.data_directory,
159                'ba_data',
160                'data',
161                'languages',
162                'english.json',
163            ),
164            encoding='utf-8',
165        ) as infile:
166            lenglishvalues = json.loads(infile.read())
167
168        # Special case - passing a complete dict for testing.
169        if isinstance(language, dict):
170            self._language = 'Custom'
171            lmodvalues = language
172            switched = False
173            print_change = False
174            store_to_config = False
175        else:
176            # Ok, we're setting a real language.
177
178            # Store this in the config if its changing.
179            if language != cur_language and store_to_config:
180                if language is None:
181                    if 'Lang' in cfg:
182                        del cfg['Lang']  # Clear it out for default.
183                else:
184                    cfg['Lang'] = language
185                cfg.commit()
186                switched = True
187            else:
188                switched = False
189
190            # None implies default.
191            if language is None:
192                language = self.default_language
193            try:
194                if language == 'English':
195                    lmodvalues = None
196                else:
197                    lmodfile = os.path.join(
198                        _babase.app.env.data_directory,
199                        'ba_data',
200                        'data',
201                        'languages',
202                        language.lower() + '.json',
203                    )
204                    with open(lmodfile, encoding='utf-8') as infile:
205                        lmodvalues = json.loads(infile.read())
206            except Exception:
207                logging.exception("Error importing language '%s'.", language)
208                _babase.screenmessage(
209                    f"Error setting language to '{language}';"
210                    f' see log for details.',
211                    color=(1, 0, 0),
212                )
213                switched = False
214                lmodvalues = None
215
216            self._language = language
217
218        # Create an attrdict of *just* our target language.
219        self._language_target = AttrDict()
220        langtarget = self._language_target
221        assert langtarget is not None
222        _add_to_attr_dict(
223            langtarget, lmodvalues if lmodvalues is not None else lenglishvalues
224        )
225
226        # Create an attrdict of our target language overlaid on our base
227        # (english).
228        languages = [lenglishvalues]
229        if lmodvalues is not None:
230            languages.append(lmodvalues)
231        lfull = AttrDict()
232        for lmod in languages:
233            _add_to_attr_dict(lfull, lmod)
234        self._language_merged = lfull
235
236        # Pass some keys/values in for low level code to use; start with
237        # everything in their 'internal' section.
238        internal_vals = [
239            v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
240        ]
241
242        # Cherry-pick various other values to include.
243        # (should probably get rid of the 'internal' section
244        # and do everything this way)
245        for value in [
246            'replayNameDefaultText',
247            'replayWriteErrorText',
248            'replayVersionErrorText',
249            'replayReadErrorText',
250        ]:
251            internal_vals.append((value, lfull[value]))
252        internal_vals.append(
253            ('axisText', lfull['configGamepadWindow']['axisText'])
254        )
255        internal_vals.append(('buttonText', lfull['buttonText']))
256        lmerged = self._language_merged
257        assert lmerged is not None
258        random_names = [
259            n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
260        ]
261        random_names = [n for n in random_names if n != '']
262        _babase.set_internal_language_keys(internal_vals, random_names)
263        if switched and print_change:
264            assert isinstance(language, str)
265            _babase.screenmessage(
266                Lstr(
267                    resource='languageSetText',
268                    subs=[
269                        ('${LANGUAGE}', Lstr(translate=('languages', language)))
270                    ],
271                ),
272                color=(0, 1, 0),
273            )
274
275    @override
276    def do_apply_app_config(self) -> None:
277        assert _babase.in_logic_thread()
278        assert isinstance(_babase.app.config, dict)
279        lang = _babase.app.config.get('Lang', self.default_language)
280        if lang != self._language:
281            self.setlanguage(lang, print_change=False, store_to_config=False)
282
283    def get_resource(
284        self,
285        resource: str,
286        fallback_resource: str | None = None,
287        fallback_value: Any = None,
288    ) -> Any:
289        """Return a translation resource by name.
290
291        DEPRECATED; use babase.Lstr functionality for these purposes.
292        """
293        try:
294            # If we have no language set, try and set it to english.
295            # Also make a fuss because we should try to avoid this.
296            if self._language_merged is None:
297                try:
298                    if _babase.do_once():
299                        logging.warning(
300                            'get_resource() called before language'
301                            ' set; falling back to english.'
302                        )
303                    self.setlanguage(
304                        'English', print_change=False, store_to_config=False
305                    )
306                except Exception:
307                    logging.exception(
308                        'Error setting fallback english language.'
309                    )
310                    raise
311
312            # If they provided a fallback_resource value, try the
313            # target-language-only dict first and then fall back to
314            # trying the fallback_resource value in the merged dict.
315            if fallback_resource is not None:
316                try:
317                    values = self._language_target
318                    splits = resource.split('.')
319                    dicts = splits[:-1]
320                    key = splits[-1]
321                    for dct in dicts:
322                        assert values is not None
323                        values = values[dct]
324                    assert values is not None
325                    val = values[key]
326                    return val
327                except Exception:
328                    # FIXME: Shouldn't we try the fallback resource in
329                    #  the merged dict AFTER we try the main resource in
330                    #  the merged dict?
331                    try:
332                        values = self._language_merged
333                        splits = fallback_resource.split('.')
334                        dicts = splits[:-1]
335                        key = splits[-1]
336                        for dct in dicts:
337                            assert values is not None
338                            values = values[dct]
339                        assert values is not None
340                        val = values[key]
341                        return val
342
343                    except Exception:
344                        # If we got nothing for fallback_resource,
345                        # default to the normal code which checks or
346                        # primary value in the merge dict; there's a
347                        # chance we can get an english value for it
348                        # (which we weren't looking for the first time
349                        # through).
350                        pass
351
352            values = self._language_merged
353            splits = resource.split('.')
354            dicts = splits[:-1]
355            key = splits[-1]
356            for dct in dicts:
357                assert values is not None
358                values = values[dct]
359            assert values is not None
360            val = values[key]
361            return val
362
363        except Exception:
364            # Ok, looks like we couldn't find our main or fallback
365            # resource anywhere. Now if we've been given a fallback
366            # value, return it; otherwise fail.
367            from babase import _error
368
369            if fallback_value is not None:
370                return fallback_value
371            raise _error.NotFoundError(
372                f"Resource not found: '{resource}'"
373            ) from None
374
375    def translate(
376        self,
377        category: str,
378        strval: str,
379        raise_exceptions: bool = False,
380        print_errors: bool = False,
381    ) -> str:
382        """Translate a value (or return the value if no translation available)
383
384        DEPRECATED; use babase.Lstr functionality for these purposes.
385        """
386        try:
387            translated = self.get_resource('translations')[category][strval]
388        except Exception as exc:
389            if raise_exceptions:
390                raise
391            if print_errors:
392                print(
393                    (
394                        'Translate error: category=\''
395                        + category
396                        + '\' name=\''
397                        + strval
398                        + '\' exc='
399                        + str(exc)
400                        + ''
401                    )
402                )
403            translated = None
404        translated_out: str
405        if translated is None:
406            translated_out = strval
407        else:
408            translated_out = translated
409        assert isinstance(translated_out, str)
410        return translated_out
411
412    def is_custom_unicode_char(self, char: str) -> bool:
413        """Return whether a char is in the custom unicode range we use."""
414        assert isinstance(char, str)
415        if len(char) != 1:
416            raise ValueError('Invalid Input; must be length 1')
417        return 0xE000 <= ord(char) <= 0xF8FF
418
419    def _can_display_language(self, language: str) -> bool:
420        """Tell whether we can display a particular language.
421
422        On some platforms we don't have unicode rendering yet which
423        limits the languages we can draw.
424        """
425
426        # We don't yet support full unicode display on windows or linux :-(.
427        if (
428            language
429            in {
430                'Chinese',
431                'ChineseTraditional',
432                'Persian',
433                'Korean',
434                'Arabic',
435                'Hindi',
436                'Vietnamese',
437                'Thai',
438                'Tamil',
439            }
440            and not _babase.supports_unicode_display()
441        ):
442            return False
443        return True
444
445    def _get_default_language(self) -> str:
446        languages = {
447            'ar': 'Arabic',
448            'be': 'Belarussian',
449            'zh': 'Chinese',
450            'hr': 'Croatian',
451            'cs': 'Czech',
452            'da': 'Danish',
453            'nl': 'Dutch',
454            'eo': 'Esperanto',
455            'fil': 'Filipino',
456            'fr': 'French',
457            'de': 'German',
458            'el': 'Greek',
459            'hi': 'Hindi',
460            'hu': 'Hungarian',
461            'id': 'Indonesian',
462            'it': 'Italian',
463            'ko': 'Korean',
464            'ms': 'Malay',
465            'fa': 'Persian',
466            'pl': 'Polish',
467            'pt': 'Portuguese',
468            'ro': 'Romanian',
469            'ru': 'Russian',
470            'sr': 'Serbian',
471            'es': 'Spanish',
472            'sk': 'Slovak',
473            'sv': 'Swedish',
474            'ta': 'Tamil',
475            'th': 'Thai',
476            'tr': 'Turkish',
477            'uk': 'Ukrainian',
478            'vec': 'Venetian',
479            'vi': 'Vietnamese',
480        }
481
482        # Special case for Chinese: map specific variations to
483        # traditional. (otherwise will map to 'Chinese' which is
484        # simplified)
485        if self.locale in ('zh_HANT', 'zh_TW'):
486            language = 'ChineseTraditional'
487        else:
488            language = languages.get(self.locale[:2], 'English')
489        if not self._can_display_language(language):
490            language = 'English'
491        return language

Language functionality for the app.

Access the single instance of this class at 'babase.app.lang'.

default_language: str
locale: str
37    @property
38    def locale(self) -> str:
39        """Raw country/language code detected by the game (such as 'en_US').
40
41        Generally for language-specific code you should look at
42        babase.App.language, which is the language the game is using
43        (which may differ from locale if the user sets a language, etc.)
44        """
45        env = _babase.env()
46        locale = env.get('locale')
47        if not isinstance(locale, str):
48            logging.warning(
49                'Seem to be running in a dummy env; returning en_US locale.'
50            )
51            locale = 'en_US'
52        return locale

Raw country/language code detected by the game (such as 'en_US').

Generally for language-specific code you should look at babase.App.language, which is the language the game is using (which may differ from locale if the user sets a language, etc.)

language: str
54    @property
55    def language(self) -> str:
56        """The current active language for the app.
57
58        This can be selected explicitly by the user or may be set
59        automatically based on locale or other factors.
60        """
61        if self._language is None:
62            raise RuntimeError('App language is not yet set.')
63        return self._language

The current active language for the app.

This can be selected explicitly by the user or may be set automatically based on locale or other factors.

available_languages: list[str]
 65    @property
 66    def available_languages(self) -> list[str]:
 67        """A list of all available languages.
 68
 69        Note that languages that may be present in game assets but which
 70        are not displayable on the running version of the game are not
 71        included here.
 72        """
 73        langs = set()
 74        try:
 75            names = os.listdir(
 76                os.path.join(
 77                    _babase.app.env.data_directory,
 78                    'ba_data',
 79                    'data',
 80                    'languages',
 81                )
 82            )
 83            names = [n.replace('.json', '').capitalize() for n in names]
 84
 85            # FIXME: our simple capitalization fails on multi-word names;
 86            # should handle this in a better way...
 87            for i, name in enumerate(names):
 88                if name == 'Chinesetraditional':
 89                    names[i] = 'ChineseTraditional'
 90                elif name == 'Piratespeak':
 91                    names[i] = 'PirateSpeak'
 92        except Exception:
 93            from babase import _error
 94
 95            _error.print_exception()
 96            names = []
 97        for name in names:
 98            if self._can_display_language(name):
 99                langs.add(name)
100        return sorted(
101            name for name in names if self._can_display_language(name)
102        )

A list of all available languages.

Note that languages that may be present in game assets but which are not displayable on the running version of the game are not included here.

def testlanguage(self, langid: str) -> None:
104    def testlanguage(self, langid: str) -> None:
105        """Set the app to test an in-progress language.
106
107        Pass a language id from the translation editor website as 'langid';
108        something like 'Gibberish_3263'. Once set to testing, the engine
109        will repeatedly download and apply that same test language, so
110        changes can be made to it and observed live.
111        """
112        print(
113            f'Language test mode enabled.'
114            f' Will fetch and apply \'{langid}\' every 5 seconds,'
115            f' so you can see your changes live.'
116        )
117        self._test_timer = _babase.AppTimer(
118            5.0, partial(self._update_test_language, langid), repeat=True
119        )
120        self._update_test_language(langid)

Set the app to test an in-progress language.

Pass a language id from the translation editor website as 'langid'; something like 'Gibberish_3263'. Once set to testing, the engine will repeatedly download and apply that same test language, so changes can be made to it and observed live.

def setlanguage( self, language: str | dict | None, print_change: bool = True, store_to_config: bool = True) -> None:
139    def setlanguage(
140        self,
141        language: str | dict | None,
142        print_change: bool = True,
143        store_to_config: bool = True,
144    ) -> None:
145        """Set the active app language.
146
147        Pass None to use OS default language.
148        """
149        # pylint: disable=too-many-locals
150        # pylint: disable=too-many-statements
151        # pylint: disable=too-many-branches
152        assert _babase.in_logic_thread()
153        cfg = _babase.app.config
154        cur_language = cfg.get('Lang', None)
155
156        with open(
157            os.path.join(
158                _babase.app.env.data_directory,
159                'ba_data',
160                'data',
161                'languages',
162                'english.json',
163            ),
164            encoding='utf-8',
165        ) as infile:
166            lenglishvalues = json.loads(infile.read())
167
168        # Special case - passing a complete dict for testing.
169        if isinstance(language, dict):
170            self._language = 'Custom'
171            lmodvalues = language
172            switched = False
173            print_change = False
174            store_to_config = False
175        else:
176            # Ok, we're setting a real language.
177
178            # Store this in the config if its changing.
179            if language != cur_language and store_to_config:
180                if language is None:
181                    if 'Lang' in cfg:
182                        del cfg['Lang']  # Clear it out for default.
183                else:
184                    cfg['Lang'] = language
185                cfg.commit()
186                switched = True
187            else:
188                switched = False
189
190            # None implies default.
191            if language is None:
192                language = self.default_language
193            try:
194                if language == 'English':
195                    lmodvalues = None
196                else:
197                    lmodfile = os.path.join(
198                        _babase.app.env.data_directory,
199                        'ba_data',
200                        'data',
201                        'languages',
202                        language.lower() + '.json',
203                    )
204                    with open(lmodfile, encoding='utf-8') as infile:
205                        lmodvalues = json.loads(infile.read())
206            except Exception:
207                logging.exception("Error importing language '%s'.", language)
208                _babase.screenmessage(
209                    f"Error setting language to '{language}';"
210                    f' see log for details.',
211                    color=(1, 0, 0),
212                )
213                switched = False
214                lmodvalues = None
215
216            self._language = language
217
218        # Create an attrdict of *just* our target language.
219        self._language_target = AttrDict()
220        langtarget = self._language_target
221        assert langtarget is not None
222        _add_to_attr_dict(
223            langtarget, lmodvalues if lmodvalues is not None else lenglishvalues
224        )
225
226        # Create an attrdict of our target language overlaid on our base
227        # (english).
228        languages = [lenglishvalues]
229        if lmodvalues is not None:
230            languages.append(lmodvalues)
231        lfull = AttrDict()
232        for lmod in languages:
233            _add_to_attr_dict(lfull, lmod)
234        self._language_merged = lfull
235
236        # Pass some keys/values in for low level code to use; start with
237        # everything in their 'internal' section.
238        internal_vals = [
239            v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
240        ]
241
242        # Cherry-pick various other values to include.
243        # (should probably get rid of the 'internal' section
244        # and do everything this way)
245        for value in [
246            'replayNameDefaultText',
247            'replayWriteErrorText',
248            'replayVersionErrorText',
249            'replayReadErrorText',
250        ]:
251            internal_vals.append((value, lfull[value]))
252        internal_vals.append(
253            ('axisText', lfull['configGamepadWindow']['axisText'])
254        )
255        internal_vals.append(('buttonText', lfull['buttonText']))
256        lmerged = self._language_merged
257        assert lmerged is not None
258        random_names = [
259            n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
260        ]
261        random_names = [n for n in random_names if n != '']
262        _babase.set_internal_language_keys(internal_vals, random_names)
263        if switched and print_change:
264            assert isinstance(language, str)
265            _babase.screenmessage(
266                Lstr(
267                    resource='languageSetText',
268                    subs=[
269                        ('${LANGUAGE}', Lstr(translate=('languages', language)))
270                    ],
271                ),
272                color=(0, 1, 0),
273            )

Set the active app language.

Pass None to use OS default language.

@override
def do_apply_app_config(self) -> None:
275    @override
276    def do_apply_app_config(self) -> None:
277        assert _babase.in_logic_thread()
278        assert isinstance(_babase.app.config, dict)
279        lang = _babase.app.config.get('Lang', self.default_language)
280        if lang != self._language:
281            self.setlanguage(lang, print_change=False, store_to_config=False)

Called when the app config should be applied.

def get_resource( self, resource: str, fallback_resource: str | None = None, fallback_value: Any = None) -> Any:
283    def get_resource(
284        self,
285        resource: str,
286        fallback_resource: str | None = None,
287        fallback_value: Any = None,
288    ) -> Any:
289        """Return a translation resource by name.
290
291        DEPRECATED; use babase.Lstr functionality for these purposes.
292        """
293        try:
294            # If we have no language set, try and set it to english.
295            # Also make a fuss because we should try to avoid this.
296            if self._language_merged is None:
297                try:
298                    if _babase.do_once():
299                        logging.warning(
300                            'get_resource() called before language'
301                            ' set; falling back to english.'
302                        )
303                    self.setlanguage(
304                        'English', print_change=False, store_to_config=False
305                    )
306                except Exception:
307                    logging.exception(
308                        'Error setting fallback english language.'
309                    )
310                    raise
311
312            # If they provided a fallback_resource value, try the
313            # target-language-only dict first and then fall back to
314            # trying the fallback_resource value in the merged dict.
315            if fallback_resource is not None:
316                try:
317                    values = self._language_target
318                    splits = resource.split('.')
319                    dicts = splits[:-1]
320                    key = splits[-1]
321                    for dct in dicts:
322                        assert values is not None
323                        values = values[dct]
324                    assert values is not None
325                    val = values[key]
326                    return val
327                except Exception:
328                    # FIXME: Shouldn't we try the fallback resource in
329                    #  the merged dict AFTER we try the main resource in
330                    #  the merged dict?
331                    try:
332                        values = self._language_merged
333                        splits = fallback_resource.split('.')
334                        dicts = splits[:-1]
335                        key = splits[-1]
336                        for dct in dicts:
337                            assert values is not None
338                            values = values[dct]
339                        assert values is not None
340                        val = values[key]
341                        return val
342
343                    except Exception:
344                        # If we got nothing for fallback_resource,
345                        # default to the normal code which checks or
346                        # primary value in the merge dict; there's a
347                        # chance we can get an english value for it
348                        # (which we weren't looking for the first time
349                        # through).
350                        pass
351
352            values = self._language_merged
353            splits = resource.split('.')
354            dicts = splits[:-1]
355            key = splits[-1]
356            for dct in dicts:
357                assert values is not None
358                values = values[dct]
359            assert values is not None
360            val = values[key]
361            return val
362
363        except Exception:
364            # Ok, looks like we couldn't find our main or fallback
365            # resource anywhere. Now if we've been given a fallback
366            # value, return it; otherwise fail.
367            from babase import _error
368
369            if fallback_value is not None:
370                return fallback_value
371            raise _error.NotFoundError(
372                f"Resource not found: '{resource}'"
373            ) from None

Return a translation resource by name.

DEPRECATED; use babase.Lstr functionality for these purposes.

def translate( self, category: str, strval: str, raise_exceptions: bool = False, print_errors: bool = False) -> str:
375    def translate(
376        self,
377        category: str,
378        strval: str,
379        raise_exceptions: bool = False,
380        print_errors: bool = False,
381    ) -> str:
382        """Translate a value (or return the value if no translation available)
383
384        DEPRECATED; use babase.Lstr functionality for these purposes.
385        """
386        try:
387            translated = self.get_resource('translations')[category][strval]
388        except Exception as exc:
389            if raise_exceptions:
390                raise
391            if print_errors:
392                print(
393                    (
394                        'Translate error: category=\''
395                        + category
396                        + '\' name=\''
397                        + strval
398                        + '\' exc='
399                        + str(exc)
400                        + ''
401                    )
402                )
403            translated = None
404        translated_out: str
405        if translated is None:
406            translated_out = strval
407        else:
408            translated_out = translated
409        assert isinstance(translated_out, str)
410        return translated_out

Translate a value (or return the value if no translation available)

DEPRECATED; use babase.Lstr functionality for these purposes.

def is_custom_unicode_char(self, char: str) -> bool:
412    def is_custom_unicode_char(self, char: str) -> bool:
413        """Return whether a char is in the custom unicode range we use."""
414        assert isinstance(char, str)
415        if len(char) != 1:
416            raise ValueError('Invalid Input; must be length 1')
417        return 0xE000 <= ord(char) <= 0xF8FF

Return whether a char is in the custom unicode range we use.

lifecyclelog = <Logger ba.lifecycle (WARNING)>
class LoginAdapter:
 31class LoginAdapter:
 32    """Allows using implicit login types in an explicit way.
 33
 34    Some login types such as Google Play Game Services or Game Center are
 35    basically always present and often do not provide a way to log out
 36    from within a running app, so this adapter exists to use them in a
 37    flexible manner by 'attaching' and 'detaching' from an always-present
 38    login, allowing for its use alongside other login types. It also
 39    provides common functionality for server-side account verification and
 40    other handy bits.
 41    """
 42
 43    @dataclass
 44    class SignInResult:
 45        """Describes the final result of a sign-in attempt."""
 46
 47        credentials: str
 48
 49    @dataclass
 50    class ImplicitLoginState:
 51        """Describes the current state of an implicit login."""
 52
 53        login_id: str
 54        display_name: str
 55
 56    def __init__(self, login_type: LoginType):
 57        assert _babase.in_logic_thread()
 58        self.login_type = login_type
 59        self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = (
 60            None
 61        )
 62        self._on_app_loading_called = False
 63        self._implicit_login_state_dirty = False
 64        self._back_end_active = False
 65
 66        # Which login of our type (if any) is associated with the
 67        # current active primary account.
 68        self._active_login_id: str | None = None
 69
 70        self._last_sign_in_time: float | None = None
 71        self._last_sign_in_desc: str | None = None
 72
 73    def on_app_loading(self) -> None:
 74        """Should be called for each adapter in on_app_loading."""
 75
 76        assert not self._on_app_loading_called
 77        self._on_app_loading_called = True
 78
 79        # Any implicit state we received up until now needs to be pushed
 80        # to the app account subsystem.
 81        self._update_implicit_login_state()
 82
 83    def set_implicit_login_state(
 84        self, state: ImplicitLoginState | None
 85    ) -> None:
 86        """Keep the adapter informed of implicit login states.
 87
 88        This should be called by the adapter back-end when an account
 89        of their associated type gets logged in or out.
 90        """
 91        assert _babase.in_logic_thread()
 92
 93        # Ignore redundant sets.
 94        if state == self._implicit_login_state:
 95            return
 96
 97        if state is None:
 98            logger.debug(
 99                '%s implicit state changed; now signed out.',
100                self.login_type.name,
101            )
102        else:
103            logger.debug(
104                '%s implicit state changed; now signed in as %s.',
105                self.login_type.name,
106                state.display_name,
107            )
108
109        self._implicit_login_state = state
110        self._implicit_login_state_dirty = True
111
112        # (possibly) push it to the app for handling.
113        self._update_implicit_login_state()
114
115        # This might affect whether we consider that back-end as 'active'.
116        self._update_back_end_active()
117
118    def set_active_logins(self, logins: dict[LoginType, str]) -> None:
119        """Keep the adapter informed of actively used logins.
120
121        This should be called by the app's account subsystem to
122        keep adapters up to date on the full set of logins attached
123        to the currently-in-use account.
124        Note that the logins dict passed in should be immutable as
125        only a reference to it is stored, not a copy.
126        """
127        assert _babase.in_logic_thread()
128        logger.debug(
129            '%s adapter got active logins %s.',
130            self.login_type.name,
131            {k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
132        )
133
134        self._active_login_id = logins.get(self.login_type)
135        self._update_back_end_active()
136
137    def on_back_end_active_change(self, active: bool) -> None:
138        """Called when active state for the back-end is (possibly) changing.
139
140        Meant to be overridden by subclasses.
141        Being active means that the implicit login provided by the back-end
142        is actually being used by the app. It should therefore register
143        unlocked achievements, leaderboard scores, allow viewing native
144        UIs, etc. When not active it should ignore everything and behave
145        as if signed out, even if it technically is still signed in.
146        """
147        assert _babase.in_logic_thread()
148        del active  # Unused.
149
150    @final
151    def sign_in(
152        self,
153        result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
154        description: str,
155    ) -> None:
156        """Attempt to sign in via this adapter.
157
158        This can be called even if the back-end is not implicitly signed in;
159        the adapter will attempt to sign in if possible. An exception will
160        be returned if the sign-in attempt fails.
161        """
162
163        assert _babase.in_logic_thread()
164
165        # Have been seeing multiple sign-in attempts come through
166        # nearly simultaneously which can be problematic server-side.
167        # Let's error if a sign-in attempt is made within a few seconds
168        # of the last one to try and address this.
169        now = time.monotonic()
170        appnow = _babase.apptime()
171        if self._last_sign_in_time is not None:
172            since_last = now - self._last_sign_in_time
173            if since_last < 1.0:
174                logging.warning(
175                    'LoginAdapter: %s adapter sign_in() called too soon'
176                    ' (%.2fs) after last; this-desc="%s", last-desc="%s",'
177                    ' ba-app-time=%.2f.',
178                    self.login_type.name,
179                    since_last,
180                    description,
181                    self._last_sign_in_desc,
182                    appnow,
183                )
184                _babase.pushcall(
185                    partial(
186                        result_cb,
187                        self,
188                        RuntimeError('sign_in called too soon after last.'),
189                    )
190                )
191                return
192
193        self._last_sign_in_desc = description
194        self._last_sign_in_time = now
195
196        logger.debug(
197            '%s adapter sign_in() called; fetching sign-in-token...',
198            self.login_type.name,
199        )
200
201        def _got_sign_in_token_result(result: str | None) -> None:
202            import bacommon.cloud
203
204            # Failed to get a sign-in-token.
205            if result is None:
206                logger.debug(
207                    '%s adapter sign-in-token fetch failed;'
208                    ' aborting sign-in.',
209                    self.login_type.name,
210                )
211                _babase.pushcall(
212                    partial(
213                        result_cb,
214                        self,
215                        RuntimeError('fetch-sign-in-token failed.'),
216                    )
217                )
218                return
219
220            # Got a sign-in token! Now pass it to the cloud which will use
221            # it to verify our identity and give us app credentials on
222            # success.
223            logger.debug(
224                '%s adapter sign-in-token fetch succeeded;'
225                ' passing to cloud for verification...',
226                self.login_type.name,
227            )
228
229            def _got_sign_in_response(
230                response: bacommon.cloud.SignInResponse | Exception,
231            ) -> None:
232                # This likely means we couldn't communicate with the server.
233                if isinstance(response, Exception):
234                    logger.debug(
235                        '%s adapter got error sign-in response: %s',
236                        self.login_type.name,
237                        response,
238                    )
239                    _babase.pushcall(partial(result_cb, self, response))
240                else:
241                    # This means our credentials were explicitly rejected.
242                    if response.credentials is None:
243                        result2: LoginAdapter.SignInResult | Exception = (
244                            RuntimeError('Sign-in-token was rejected.')
245                        )
246                    else:
247                        logger.debug(
248                            '%s adapter got successful sign-in response',
249                            self.login_type.name,
250                        )
251                        result2 = self.SignInResult(
252                            credentials=response.credentials
253                        )
254                    _babase.pushcall(partial(result_cb, self, result2))
255
256            assert _babase.app.plus is not None
257            _babase.app.plus.cloud.send_message_cb(
258                bacommon.cloud.SignInMessage(
259                    self.login_type,
260                    result,
261                    description=description,
262                    apptime=appnow,
263                ),
264                on_response=_got_sign_in_response,
265            )
266
267        # Kick off the sign-in process by fetching a sign-in token.
268        self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
269
270    def is_back_end_active(self) -> bool:
271        """Is this adapter's back-end currently active?"""
272        return self._back_end_active
273
274    def get_sign_in_token(
275        self, completion_cb: Callable[[str | None], None]
276    ) -> None:
277        """Get a sign-in token from the adapter back end.
278
279        This token is then passed to the master-server to complete the
280        sign-in process. The adapter can use this opportunity to bring
281        up account creation UI, call its internal sign_in function, etc.
282        as needed. The provided completion_cb should then be called with
283        either a token or None if sign in failed or was cancelled.
284        """
285
286        # Default implementation simply fails immediately.
287        _babase.pushcall(partial(completion_cb, None))
288
289    def _update_implicit_login_state(self) -> None:
290        # If we've received an implicit login state, schedule it to be
291        # sent along to the app. We wait until on-app-loading has been
292        # called so that account-client-v2 has had a chance to load
293        # any existing state so it can properly respond to this.
294        if self._implicit_login_state_dirty and self._on_app_loading_called:
295
296            logger.debug(
297                '%s adapter sending implicit-state-changed to app.',
298                self.login_type.name,
299            )
300
301            assert _babase.app.plus is not None
302            _babase.pushcall(
303                partial(
304                    _babase.app.plus.accounts.on_implicit_login_state_changed,
305                    self.login_type,
306                    self._implicit_login_state,
307                )
308            )
309            self._implicit_login_state_dirty = False
310
311    def _update_back_end_active(self) -> None:
312        was_active = self._back_end_active
313        if self._implicit_login_state is None:
314            is_active = False
315        else:
316            is_active = (
317                self._implicit_login_state.login_id == self._active_login_id
318            )
319        if was_active != is_active:
320            logger.debug(
321                '%s adapter back-end-active is now %s.',
322                self.login_type.name,
323                is_active,
324            )
325            self.on_back_end_active_change(is_active)
326            self._back_end_active = is_active

Allows using implicit login types in an explicit way.

Some login types such as Google Play Game Services or Game Center are basically always present and often do not provide a way to log out from within a running app, so this adapter exists to use them in a flexible manner by 'attaching' and 'detaching' from an always-present login, allowing for its use alongside other login types. It also provides common functionality for server-side account verification and other handy bits.

LoginAdapter(login_type: bacommon.login.LoginType)
56    def __init__(self, login_type: LoginType):
57        assert _babase.in_logic_thread()
58        self.login_type = login_type
59        self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = (
60            None
61        )
62        self._on_app_loading_called = False
63        self._implicit_login_state_dirty = False
64        self._back_end_active = False
65
66        # Which login of our type (if any) is associated with the
67        # current active primary account.
68        self._active_login_id: str | None = None
69
70        self._last_sign_in_time: float | None = None
71        self._last_sign_in_desc: str | None = None
login_type
def on_app_loading(self) -> None:
73    def on_app_loading(self) -> None:
74        """Should be called for each adapter in on_app_loading."""
75
76        assert not self._on_app_loading_called
77        self._on_app_loading_called = True
78
79        # Any implicit state we received up until now needs to be pushed
80        # to the app account subsystem.
81        self._update_implicit_login_state()

Should be called for each adapter in on_app_loading.

def set_implicit_login_state( self, state: LoginAdapter.ImplicitLoginState | None) -> None:
 83    def set_implicit_login_state(
 84        self, state: ImplicitLoginState | None
 85    ) -> None:
 86        """Keep the adapter informed of implicit login states.
 87
 88        This should be called by the adapter back-end when an account
 89        of their associated type gets logged in or out.
 90        """
 91        assert _babase.in_logic_thread()
 92
 93        # Ignore redundant sets.
 94        if state == self._implicit_login_state:
 95            return
 96
 97        if state is None:
 98            logger.debug(
 99                '%s implicit state changed; now signed out.',
100                self.login_type.name,
101            )
102        else:
103            logger.debug(
104                '%s implicit state changed; now signed in as %s.',
105                self.login_type.name,
106                state.display_name,
107            )
108
109        self._implicit_login_state = state
110        self._implicit_login_state_dirty = True
111
112        # (possibly) push it to the app for handling.
113        self._update_implicit_login_state()
114
115        # This might affect whether we consider that back-end as 'active'.
116        self._update_back_end_active()

Keep the adapter informed of implicit login states.

This should be called by the adapter back-end when an account of their associated type gets logged in or out.

def set_active_logins(self, logins: dict[bacommon.login.LoginType, str]) -> None:
118    def set_active_logins(self, logins: dict[LoginType, str]) -> None:
119        """Keep the adapter informed of actively used logins.
120
121        This should be called by the app's account subsystem to
122        keep adapters up to date on the full set of logins attached
123        to the currently-in-use account.
124        Note that the logins dict passed in should be immutable as
125        only a reference to it is stored, not a copy.
126        """
127        assert _babase.in_logic_thread()
128        logger.debug(
129            '%s adapter got active logins %s.',
130            self.login_type.name,
131            {k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
132        )
133
134        self._active_login_id = logins.get(self.login_type)
135        self._update_back_end_active()

Keep the adapter informed of actively used logins.

This should be called by the app's account subsystem to keep adapters up to date on the full set of logins attached to the currently-in-use account. Note that the logins dict passed in should be immutable as only a reference to it is stored, not a copy.

def on_back_end_active_change(self, active: bool) -> None:
137    def on_back_end_active_change(self, active: bool) -> None:
138        """Called when active state for the back-end is (possibly) changing.
139
140        Meant to be overridden by subclasses.
141        Being active means that the implicit login provided by the back-end
142        is actually being used by the app. It should therefore register
143        unlocked achievements, leaderboard scores, allow viewing native
144        UIs, etc. When not active it should ignore everything and behave
145        as if signed out, even if it technically is still signed in.
146        """
147        assert _babase.in_logic_thread()
148        del active  # Unused.

Called when active state for the back-end is (possibly) changing.

Meant to be overridden by subclasses. Being active means that the implicit login provided by the back-end is actually being used by the app. It should therefore register unlocked achievements, leaderboard scores, allow viewing native UIs, etc. When not active it should ignore everything and behave as if signed out, even if it technically is still signed in.

@final
def sign_in( self, result_cb: 'Callable[[LoginAdapter, SignInResult | Exception], None]', description: str) -> None:
150    @final
151    def sign_in(
152        self,
153        result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
154        description: str,
155    ) -> None:
156        """Attempt to sign in via this adapter.
157
158        This can be called even if the back-end is not implicitly signed in;
159        the adapter will attempt to sign in if possible. An exception will
160        be returned if the sign-in attempt fails.
161        """
162
163        assert _babase.in_logic_thread()
164
165        # Have been seeing multiple sign-in attempts come through
166        # nearly simultaneously which can be problematic server-side.
167        # Let's error if a sign-in attempt is made within a few seconds
168        # of the last one to try and address this.
169        now = time.monotonic()
170        appnow = _babase.apptime()
171        if self._last_sign_in_time is not None:
172            since_last = now - self._last_sign_in_time
173            if since_last < 1.0:
174                logging.warning(
175                    'LoginAdapter: %s adapter sign_in() called too soon'
176                    ' (%.2fs) after last; this-desc="%s", last-desc="%s",'
177                    ' ba-app-time=%.2f.',
178                    self.login_type.name,
179                    since_last,
180                    description,
181                    self._last_sign_in_desc,
182                    appnow,
183                )
184                _babase.pushcall(
185                    partial(
186                        result_cb,
187                        self,
188                        RuntimeError('sign_in called too soon after last.'),
189                    )
190                )
191                return
192
193        self._last_sign_in_desc = description
194        self._last_sign_in_time = now
195
196        logger.debug(
197            '%s adapter sign_in() called; fetching sign-in-token...',
198            self.login_type.name,
199        )
200
201        def _got_sign_in_token_result(result: str | None) -> None:
202            import bacommon.cloud
203
204            # Failed to get a sign-in-token.
205            if result is None:
206                logger.debug(
207                    '%s adapter sign-in-token fetch failed;'
208                    ' aborting sign-in.',
209                    self.login_type.name,
210                )
211                _babase.pushcall(
212                    partial(
213                        result_cb,
214                        self,
215                        RuntimeError('fetch-sign-in-token failed.'),
216                    )
217                )
218                return
219
220            # Got a sign-in token! Now pass it to the cloud which will use
221            # it to verify our identity and give us app credentials on
222            # success.
223            logger.debug(
224                '%s adapter sign-in-token fetch succeeded;'
225                ' passing to cloud for verification...',
226                self.login_type.name,
227            )
228
229            def _got_sign_in_response(
230                response: bacommon.cloud.SignInResponse | Exception,
231            ) -> None:
232                # This likely means we couldn't communicate with the server.
233                if isinstance(response, Exception):
234                    logger.debug(
235                        '%s adapter got error sign-in response: %s',
236                        self.login_type.name,
237                        response,
238                    )
239                    _babase.pushcall(partial(result_cb, self, response))
240                else:
241                    # This means our credentials were explicitly rejected.
242                    if response.credentials is None:
243                        result2: LoginAdapter.SignInResult | Exception = (
244                            RuntimeError('Sign-in-token was rejected.')
245                        )
246                    else:
247                        logger.debug(
248                            '%s adapter got successful sign-in response',
249                            self.login_type.name,
250                        )
251                        result2 = self.SignInResult(
252                            credentials=response.credentials
253                        )
254                    _babase.pushcall(partial(result_cb, self, result2))
255
256            assert _babase.app.plus is not None
257            _babase.app.plus.cloud.send_message_cb(
258                bacommon.cloud.SignInMessage(
259                    self.login_type,
260                    result,
261                    description=description,
262                    apptime=appnow,
263                ),
264                on_response=_got_sign_in_response,
265            )
266
267        # Kick off the sign-in process by fetching a sign-in token.
268        self.get_sign_in_token(completion_cb=_got_sign_in_token_result)

Attempt to sign in via this adapter.

This can be called even if the back-end is not implicitly signed in; the adapter will attempt to sign in if possible. An exception will be returned if the sign-in attempt fails.

def is_back_end_active(self) -> bool:
270    def is_back_end_active(self) -> bool:
271        """Is this adapter's back-end currently active?"""
272        return self._back_end_active

Is this adapter's back-end currently active?

def get_sign_in_token(self, completion_cb: Callable[[str | None], NoneType]) -> None:
274    def get_sign_in_token(
275        self, completion_cb: Callable[[str | None], None]
276    ) -> None:
277        """Get a sign-in token from the adapter back end.
278
279        This token is then passed to the master-server to complete the
280        sign-in process. The adapter can use this opportunity to bring
281        up account creation UI, call its internal sign_in function, etc.
282        as needed. The provided completion_cb should then be called with
283        either a token or None if sign in failed or was cancelled.
284        """
285
286        # Default implementation simply fails immediately.
287        _babase.pushcall(partial(completion_cb, None))

Get a sign-in token from the adapter back end.

This token is then passed to the master-server to complete the sign-in process. The adapter can use this opportunity to bring up account creation UI, call its internal sign_in function, etc. as needed. The provided completion_cb should then be called with either a token or None if sign in failed or was cancelled.

@dataclass
class LoginAdapter.SignInResult:
43    @dataclass
44    class SignInResult:
45        """Describes the final result of a sign-in attempt."""
46
47        credentials: str

Describes the final result of a sign-in attempt.

LoginAdapter.SignInResult(credentials: str)
credentials: str
@dataclass
class LoginAdapter.ImplicitLoginState:
49    @dataclass
50    class ImplicitLoginState:
51        """Describes the current state of an implicit login."""
52
53        login_id: str
54        display_name: str

Describes the current state of an implicit login.

LoginAdapter.ImplicitLoginState(login_id: str, display_name: str)
login_id: str
display_name: str
@dataclass
class LoginInfo:
24@dataclass
25class LoginInfo:
26    """Basic info about a login available in the app.plus.accounts section."""
27
28    name: str

Basic info about a login available in the app.plus.accounts section.

LoginInfo(name: str)
name: str
class Lstr:
494class Lstr:
495    """Used to define strings in a language-independent way.
496
497    These should be used whenever possible in place of hard-coded
498    strings so that in-game or UI elements show up correctly on all
499    clients in their currently active language.
500
501    To see available resource keys, look at any of the
502    ``bs_language_*.py`` files in the game or the translations pages at
503    `legacy.ballistica.net/translate
504    <https://legacy.ballistica.net/translate>`.
505
506    Examples
507    --------
508
509    **Example 1: Specify a String from a Resource Path**::
510
511        mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
512
513    **Example 2: Specify a Translated String via a Category and English Value**
514
515    If a translated value is available, it will be used; otherwise, the
516    English value will be. To see available translation categories, look
517    under the ``translations`` resource section::
518
519        mynode.text = babase.Lstr(translate=('gameDescriptions',
520                                             'Defeat all enemies'))
521
522    **Example 3: Specify a Raw Value with Substitutions**
523
524    Substitutions can be used with ``resource`` and ``translate`` modes
525    as well::
526
527        mynode.text = babase.Lstr(value='${A} / ${B}',
528                                  subs=[('${A}', str(score)),
529                                        ('${B}', str(total))])
530
531    **Example 4: Nesting**
532
533    :class:`~babase.Lstr` instances can be nested. This example would display
534    the resource at ``res_a`` but replace ``${NAME}`` with the value of
535    the resource at ``res_b``::
536
537        mytextnode.text = babase.Lstr(
538            resource='res_a',
539            subs=[('${NAME}', babase.Lstr(resource='res_b'))])
540    """
541
542    # This class is used a lot in UI stuff and doesn't need to be
543    # flexible, so let's optimize its performance a bit.
544    __slots__ = ['args']
545
546    @overload
547    def __init__(
548        self,
549        *,
550        resource: str,
551        fallback_resource: str = '',
552        fallback_value: str = '',
553        subs: Sequence[tuple[str, str | Lstr]] | None = None,
554    ) -> None:
555        """Create an Lstr from a string resource."""
556
557    @overload
558    def __init__(
559        self,
560        *,
561        translate: tuple[str, str],
562        subs: Sequence[tuple[str, str | Lstr]] | None = None,
563    ) -> None:
564        """Create an Lstr by translating a string in a category."""
565
566    @overload
567    def __init__(
568        self,
569        *,
570        value: str,
571        subs: Sequence[tuple[str, str | Lstr]] | None = None,
572    ) -> None:
573        """Create an Lstr from a raw string value."""
574
575    def __init__(self, *args: Any, **keywds: Any) -> None:
576        """Instantiate a Lstr.
577
578        Pass a value for either 'resource', 'translate',
579        or 'value'. (see Lstr help for examples).
580        'subs' can be a sequence of 2-member sequences consisting of values
581        and replacements.
582        'fallback_resource' can be a resource key that will be used if the
583        main one is not present for
584        the current language in place of falling back to the english value
585        ('resource' mode only).
586        'fallback_value' can be a literal string that will be used if neither
587        the resource nor the fallback resource is found ('resource' mode only).
588        """
589        # pylint: disable=too-many-branches
590        if args:
591            raise TypeError('Lstr accepts only keyword arguments')
592
593        # Basically just store the exact args they passed. However if
594        # they passed any Lstr values for subs, replace them with that
595        # Lstr's dict.
596        self.args = keywds
597        our_type = type(self)
598
599        if isinstance(self.args.get('value'), our_type):
600            raise TypeError("'value' must be a regular string; not an Lstr")
601
602        if 'subs' in keywds:
603            subs = keywds.get('subs')
604            subs_filtered = []
605            if subs is not None:
606                for key, value in keywds['subs']:
607                    if isinstance(value, our_type):
608                        subs_filtered.append((key, value.args))
609                    else:
610                        subs_filtered.append((key, value))
611            self.args['subs'] = subs_filtered
612
613        # As of protocol 31 we support compact key names ('t' instead of
614        # 'translate', etc). Convert as needed.
615        if 'translate' in keywds:
616            keywds['t'] = keywds['translate']
617            del keywds['translate']
618        if 'resource' in keywds:
619            keywds['r'] = keywds['resource']
620            del keywds['resource']
621        if 'value' in keywds:
622            keywds['v'] = keywds['value']
623            del keywds['value']
624        if 'fallback' in keywds:
625            from babase import _error
626
627            _error.print_error(
628                'deprecated "fallback" arg passed to Lstr(); use '
629                'either "fallback_resource" or "fallback_value"',
630                once=True,
631            )
632            keywds['f'] = keywds['fallback']
633            del keywds['fallback']
634        if 'fallback_resource' in keywds:
635            keywds['f'] = keywds['fallback_resource']
636            del keywds['fallback_resource']
637        if 'subs' in keywds:
638            keywds['s'] = keywds['subs']
639            del keywds['subs']
640        if 'fallback_value' in keywds:
641            keywds['fv'] = keywds['fallback_value']
642            del keywds['fallback_value']
643
644    def evaluate(self) -> str:
645        """Evaluate the Lstr and returns a flat string in the current language.
646
647        You should avoid doing this as much as possible and instead pass
648        and store Lstr values.
649        """
650        return _babase.evaluate_lstr(self._get_json())
651
652    def is_flat_value(self) -> bool:
653        """Return whether the Lstr is a 'flat' value.
654
655        This is defined as a simple string value incorporating no
656        translations, resources, or substitutions. In this case it may
657        be reasonable to replace it with a raw string value, perform
658        string manipulation on it, etc.
659        """
660        return bool('v' in self.args and not self.args.get('s', []))
661
662    def _get_json(self) -> str:
663        try:
664            return json.dumps(self.args, separators=(',', ':'))
665        except Exception:
666            from babase import _error
667
668            _error.print_exception('_get_json failed for', self.args)
669            return 'JSON_ERR'
670
671    @override
672    def __str__(self) -> str:
673        return '<ba.Lstr: ' + self._get_json() + '>'
674
675    @override
676    def __repr__(self) -> str:
677        return '<ba.Lstr: ' + self._get_json() + '>'
678
679    @staticmethod
680    def from_json(json_string: str) -> babase.Lstr:
681        """Given a json string, returns a babase.Lstr. Does no validation."""
682        lstr = Lstr(value='')
683        lstr.args = json.loads(json_string)
684        return lstr

Used to define strings in a language-independent way.

These should be used whenever possible in place of hard-coded strings so that in-game or UI elements show up correctly on all clients in their currently active language.

To see available resource keys, look at any of the bs_language_*.py files in the game or the translations pages at legacy.ballistica.net/translate <https://legacy.ballistica.net/translate>.

Examples

Example 1: Specify a String from a Resource Path::

mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')

Example 2: Specify a Translated String via a Category and English Value

If a translated value is available, it will be used; otherwise, the English value will be. To see available translation categories, look under the translations resource section::

mynode.text = babase.Lstr(translate=('gameDescriptions',
                                     'Defeat all enemies'))

Example 3: Specify a Raw Value with Substitutions

Substitutions can be used with resource and translate modes as well::

mynode.text = babase.Lstr(value='${A} / ${B}',
                          subs=[('${A}', str(score)),
                                ('${B}', str(total))])

Example 4: Nesting

~babase.Lstr instances can be nested. This example would display the resource at res_a but replace ${NAME} with the value of the resource at res_b::

mytextnode.text = babase.Lstr(
    resource='res_a',
    subs=[('${NAME}', babase.Lstr(resource='res_b'))])
Lstr(*args: Any, **keywds: Any)
575    def __init__(self, *args: Any, **keywds: Any) -> None:
576        """Instantiate a Lstr.
577
578        Pass a value for either 'resource', 'translate',
579        or 'value'. (see Lstr help for examples).
580        'subs' can be a sequence of 2-member sequences consisting of values
581        and replacements.
582        'fallback_resource' can be a resource key that will be used if the
583        main one is not present for
584        the current language in place of falling back to the english value
585        ('resource' mode only).
586        'fallback_value' can be a literal string that will be used if neither
587        the resource nor the fallback resource is found ('resource' mode only).
588        """
589        # pylint: disable=too-many-branches
590        if args:
591            raise TypeError('Lstr accepts only keyword arguments')
592
593        # Basically just store the exact args they passed. However if
594        # they passed any Lstr values for subs, replace them with that
595        # Lstr's dict.
596        self.args = keywds
597        our_type = type(self)
598
599        if isinstance(self.args.get('value'), our_type):
600            raise TypeError("'value' must be a regular string; not an Lstr")
601
602        if 'subs' in keywds:
603            subs = keywds.get('subs')
604            subs_filtered = []
605            if subs is not None:
606                for key, value in keywds['subs']:
607                    if isinstance(value, our_type):
608                        subs_filtered.append((key, value.args))
609                    else:
610                        subs_filtered.append((key, value))
611            self.args['subs'] = subs_filtered
612
613        # As of protocol 31 we support compact key names ('t' instead of
614        # 'translate', etc). Convert as needed.
615        if 'translate' in keywds:
616            keywds['t'] = keywds['translate']
617            del keywds['translate']
618        if 'resource' in keywds:
619            keywds['r'] = keywds['resource']
620            del keywds['resource']
621        if 'value' in keywds:
622            keywds['v'] = keywds['value']
623            del keywds['value']
624        if 'fallback' in keywds:
625            from babase import _error
626
627            _error.print_error(
628                'deprecated "fallback" arg passed to Lstr(); use '
629                'either "fallback_resource" or "fallback_value"',
630                once=True,
631            )
632            keywds['f'] = keywds['fallback']
633            del keywds['fallback']
634        if 'fallback_resource' in keywds:
635            keywds['f'] = keywds['fallback_resource']
636            del keywds['fallback_resource']
637        if 'subs' in keywds:
638            keywds['s'] = keywds['subs']
639            del keywds['subs']
640        if 'fallback_value' in keywds:
641            keywds['fv'] = keywds['fallback_value']
642            del keywds['fallback_value']

Instantiate a Lstr.

Pass a value for either 'resource', 'translate', or 'value'. (see Lstr help for examples). 'subs' can be a sequence of 2-member sequences consisting of values and replacements. 'fallback_resource' can be a resource key that will be used if the main one is not present for the current language in place of falling back to the english value ('resource' mode only). 'fallback_value' can be a literal string that will be used if neither the resource nor the fallback resource is found ('resource' mode only).

args
def evaluate(self) -> str:
644    def evaluate(self) -> str:
645        """Evaluate the Lstr and returns a flat string in the current language.
646
647        You should avoid doing this as much as possible and instead pass
648        and store Lstr values.
649        """
650        return _babase.evaluate_lstr(self._get_json())

Evaluate the Lstr and returns a flat string in the current language.

You should avoid doing this as much as possible and instead pass and store Lstr values.

def is_flat_value(self) -> bool:
652    def is_flat_value(self) -> bool:
653        """Return whether the Lstr is a 'flat' value.
654
655        This is defined as a simple string value incorporating no
656        translations, resources, or substitutions. In this case it may
657        be reasonable to replace it with a raw string value, perform
658        string manipulation on it, etc.
659        """
660        return bool('v' in self.args and not self.args.get('s', []))

Return whether the Lstr is a 'flat' value.

This is defined as a simple string value incorporating no translations, resources, or substitutions. In this case it may be reasonable to replace it with a raw string value, perform string manipulation on it, etc.

@staticmethod
def from_json(json_string: str) -> Lstr:
679    @staticmethod
680    def from_json(json_string: str) -> babase.Lstr:
681        """Given a json string, returns a babase.Lstr. Does no validation."""
682        lstr = Lstr(value='')
683        lstr.args = json.loads(json_string)
684        return lstr

Given a json string, returns a babase.Lstr. Does no validation.

class MapNotFoundError(babase.NotFoundError):
41class MapNotFoundError(NotFoundError):
42    """Exception raised when an expected bascenev1.Map does not exist."""

Exception raised when an expected bascenev1.Map does not exist.

class MetadataSubsystem:
 49class MetadataSubsystem:
 50    """Subsystem for working with script metadata in the app.
 51
 52    Access the single shared instance of this class at
 53    'babase.app.meta'.
 54    """
 55
 56    def __init__(self) -> None:
 57        self._scan: DirectoryScan | None = None
 58
 59        # Can be populated before starting the scan.
 60        self.extra_scan_dirs: list[str] = []
 61
 62        # Results populated once scan is complete.
 63        self.scanresults: ScanResults | None = None
 64
 65        self._scan_complete_cb: Callable[[], None] | None = None
 66
 67    def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
 68        """Begin the overall scan.
 69
 70        This will start scanning built in directories (which for vanilla
 71        installs should be the vast majority of the work). This should only
 72        be called once.
 73        """
 74        assert self._scan_complete_cb is None
 75        assert self._scan is None
 76        env = _babase.app.env
 77
 78        self._scan_complete_cb = scan_complete_cb
 79        self._scan = DirectoryScan(
 80            [
 81                path
 82                for path in [
 83                    env.python_directory_app,
 84                    env.python_directory_user,
 85                ]
 86                if path is not None
 87            ]
 88        )
 89
 90        Thread(target=self._run_scan_in_bg, daemon=True).start()
 91
 92    def start_extra_scan(self) -> None:
 93        """Proceed to the extra_scan_dirs portion of the scan.
 94
 95        This is for parts of the scan that must be delayed until
 96        workspace sync completion or other such events. This must be
 97        called exactly once.
 98        """
 99        assert self._scan is not None
100        self._scan.set_extras(self.extra_scan_dirs)
101
102    def load_exported_classes(
103        self,
104        cls: type[T],
105        completion_cb: Callable[[list[type[T]]], None],
106        completion_cb_in_bg_thread: bool = False,
107    ) -> None:
108        """High level function to load meta-exported classes.
109
110        Will wait for scanning to complete if necessary, and will load all
111        registered classes of a particular type in a background thread before
112        calling the passed callback in the logic thread. Errors may be logged
113        to messaged to the user in some way but the callback will be called
114        regardless.
115        To run the completion callback directly in the bg thread where the
116        loading work happens, pass completion_cb_in_bg_thread=True.
117        """
118        Thread(
119            target=partial(
120                self._load_exported_classes,
121                cls,
122                completion_cb,
123                completion_cb_in_bg_thread,
124            ),
125            daemon=True,
126        ).start()
127
128    def _load_exported_classes(
129        self,
130        cls: type[T],
131        completion_cb: Callable[[list[type[T]]], None],
132        completion_cb_in_bg_thread: bool,
133    ) -> None:
134        from babase._general import getclass
135
136        classes: list[type[T]] = []
137        try:
138            classnames = self._wait_for_scan_results().exports_of_class(cls)
139            for classname in classnames:
140                try:
141                    classes.append(getclass(classname, cls))
142                except Exception:
143                    logging.exception('error importing %s', classname)
144
145        except Exception:
146            logging.exception('Error loading exported classes.')
147
148        completion_call = partial(completion_cb, classes)
149        if completion_cb_in_bg_thread:
150            completion_call()
151        else:
152            _babase.pushcall(completion_call, from_other_thread=True)
153
154    def _wait_for_scan_results(self) -> ScanResults:
155        """Return scan results, blocking if the scan is not yet complete."""
156        if self.scanresults is None:
157            if _babase.in_logic_thread():
158                logging.warning(
159                    'babase.meta._wait_for_scan_results()'
160                    ' called in logic thread before scan completed;'
161                    ' this can cause hitches.'
162                )
163
164            # Now wait a bit for the scan to complete.
165            # Eventually error though if it doesn't.
166            starttime = time.time()
167            while self.scanresults is None:
168                time.sleep(0.05)
169                if time.time() - starttime > 10.0:
170                    raise TimeoutError(
171                        'timeout waiting for meta scan to complete.'
172                    )
173        return self.scanresults
174
175    def _run_scan_in_bg(self) -> None:
176        """Runs a scan (for use in background thread)."""
177        try:
178            assert self._scan is not None
179            self._scan.run()
180            results = self._scan.results
181            self._scan = None
182        except Exception:
183            logging.exception('metascan: Error running scan in bg.')
184            results = ScanResults(announce_errors_occurred=True)
185
186        # Place results and tell the logic thread they're ready.
187        self.scanresults = results
188        _babase.pushcall(self._handle_scan_results, from_other_thread=True)
189
190    def _handle_scan_results(self) -> None:
191        """Called in the logic thread with results of a completed scan."""
192        from babase._language import Lstr
193
194        assert _babase.in_logic_thread()
195
196        results = self.scanresults
197        assert results is not None
198
199        do_play_error_sound = False
200
201        # If we found modules needing to be updated to the newer api version,
202        # mention that specifically.
203        if results.incorrect_api_modules:
204            if len(results.incorrect_api_modules) > 1:
205                msg = Lstr(
206                    resource='scanScriptsMultipleModulesNeedUpdatesText',
207                    subs=[
208                        ('${PATH}', results.incorrect_api_modules[0]),
209                        (
210                            '${NUM}',
211                            str(len(results.incorrect_api_modules) - 1),
212                        ),
213                        ('${API}', str(_babase.app.env.api_version)),
214                    ],
215                )
216            else:
217                msg = Lstr(
218                    resource='scanScriptsSingleModuleNeedsUpdatesText',
219                    subs=[
220                        ('${PATH}', results.incorrect_api_modules[0]),
221                        ('${API}', str(_babase.app.env.api_version)),
222                    ],
223                )
224            _babase.screenmessage(msg, color=(1, 0, 0))
225            do_play_error_sound = True
226
227        # Let the user know if there's warning/errors in their log
228        # they may want to look at.
229        if results.announce_errors_occurred:
230            _babase.screenmessage(
231                Lstr(resource='scanScriptsErrorText'), color=(1, 0, 0)
232            )
233            do_play_error_sound = True
234
235        if do_play_error_sound:
236            _babase.getsimplesound('error').play()
237
238        # Let the game know we're done.
239        assert self._scan_complete_cb is not None
240        self._scan_complete_cb()

Subsystem for working with script metadata in the app.

Access the single shared instance of this class at 'babase.app.meta'.

extra_scan_dirs: list[str]
scanresults: babase._meta.ScanResults | None
def start_scan(self, scan_complete_cb: Callable[[], NoneType]) -> None:
67    def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
68        """Begin the overall scan.
69
70        This will start scanning built in directories (which for vanilla
71        installs should be the vast majority of the work). This should only
72        be called once.
73        """
74        assert self._scan_complete_cb is None
75        assert self._scan is None
76        env = _babase.app.env
77
78        self._scan_complete_cb = scan_complete_cb
79        self._scan = DirectoryScan(
80            [
81                path
82                for path in [
83                    env.python_directory_app,
84                    env.python_directory_user,
85                ]
86                if path is not None
87            ]
88        )
89
90        Thread(target=self._run_scan_in_bg, daemon=True).start()

Begin the overall scan.

This will start scanning built in directories (which for vanilla installs should be the vast majority of the work). This should only be called once.

def start_extra_scan(self) -> None:
 92    def start_extra_scan(self) -> None:
 93        """Proceed to the extra_scan_dirs portion of the scan.
 94
 95        This is for parts of the scan that must be delayed until
 96        workspace sync completion or other such events. This must be
 97        called exactly once.
 98        """
 99        assert self._scan is not None
100        self._scan.set_extras(self.extra_scan_dirs)

Proceed to the extra_scan_dirs portion of the scan.

This is for parts of the scan that must be delayed until workspace sync completion or other such events. This must be called exactly once.

def load_exported_classes( self, cls: type[~T], completion_cb: Callable[[list[type[~T]]], NoneType], completion_cb_in_bg_thread: bool = False) -> None:
102    def load_exported_classes(
103        self,
104        cls: type[T],
105        completion_cb: Callable[[list[type[T]]], None],
106        completion_cb_in_bg_thread: bool = False,
107    ) -> None:
108        """High level function to load meta-exported classes.
109
110        Will wait for scanning to complete if necessary, and will load all
111        registered classes of a particular type in a background thread before
112        calling the passed callback in the logic thread. Errors may be logged
113        to messaged to the user in some way but the callback will be called
114        regardless.
115        To run the completion callback directly in the bg thread where the
116        loading work happens, pass completion_cb_in_bg_thread=True.
117        """
118        Thread(
119            target=partial(
120                self._load_exported_classes,
121                cls,
122                completion_cb,
123                completion_cb_in_bg_thread,
124            ),
125            daemon=True,
126        ).start()

High level function to load meta-exported classes.

Will wait for scanning to complete if necessary, and will load all registered classes of a particular type in a background thread before calling the passed callback in the logic thread. Errors may be logged to messaged to the user in some way but the callback will be called regardless. To run the completion callback directly in the bg thread where the loading work happens, pass completion_cb_in_bg_thread=True.

def native_stack_trace() -> str | None:
1264def native_stack_trace() -> str | None:
1265    """Return a native stack trace as a string, or None if not available.
1266
1267    Stack traces contain different data and formatting across platforms.
1268    Only use them for debugging.
1269    """
1270    return ''

Return a native stack trace as a string, or None if not available.

Stack traces contain different data and formatting across platforms. Only use them for debugging.

class NodeNotFoundError(babase.NotFoundError):
53class NodeNotFoundError(NotFoundError):
54    """Exception raised when an expected Node does not exist."""

Exception raised when an expected Node does not exist.

def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
52def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
53    """Scale a color so its largest value is 1; useful for coloring lights.
54
55    category: General Utility Functions
56    """
57    color_biased = tuple(max(c, 0.01) for c in color)  # account for black
58    mult = 1.0 / max(color_biased)
59    return tuple(c * mult for c in color_biased)

Scale a color so its largest value is 1; useful for coloring lights.

category: General Utility Functions

class NotFoundError(builtins.Exception):
25class NotFoundError(Exception):
26    """Exception raised when a referenced object does not exist."""

Exception raised when a referenced object does not exist.

def open_url(address: str, force_fallback: bool = False) -> None:
1299def open_url(address: str, force_fallback: bool = False) -> None:
1300    """Open the provided URL.
1301
1302    Attempts to open the provided url in a web-browser. If that is not
1303    possible (or force_fallback is True), instead displays the url as
1304    a string and/or qrcode.
1305    """
1306    return None

Open the provided URL.

Attempts to open the provided url in a web-browser. If that is not possible (or force_fallback is True), instead displays the url as a string and/or qrcode.

def overlay_web_browser_close() -> bool:
1309def overlay_web_browser_close() -> bool:
1310    """Close any open overlay web browser."""
1311    return bool()

Close any open overlay web browser.

def overlay_web_browser_is_open() -> bool:
1314def overlay_web_browser_is_open() -> bool:
1315    """Return whether an overlay web browser is open currently."""
1316    return bool()

Return whether an overlay web browser is open currently.

def overlay_web_browser_is_supported() -> bool:
1319def overlay_web_browser_is_supported() -> bool:
1320    """Return whether an overlay web browser is supported here.
1321
1322    An overlay web browser is a small dialog that pops up over the top
1323    of the main engine window. It can be used for performing simple
1324    tasks such as sign-ins.
1325    """
1326    return bool()

Return whether an overlay web browser is supported here.

An overlay web browser is a small dialog that pops up over the top of the main engine window. It can be used for performing simple tasks such as sign-ins.

def overlay_web_browser_open_url(address: str) -> None:
1329def overlay_web_browser_open_url(address: str) -> None:
1330    """Open the provided URL in an overlayw web browser.
1331
1332    An overlay web browser is a small dialog that pops up over the top
1333    of the main engine window. It can be used for performing simple
1334    tasks such as sign-ins.
1335    """
1336    return None

Open the provided URL in an overlayw web browser.

An overlay web browser is a small dialog that pops up over the top of the main engine window. It can be used for performing simple tasks such as sign-ins.

class Permission(enum.Enum):
83class Permission(Enum):
84    """Permissions that can be requested from the OS."""
85    STORAGE = 0

Permissions that can be requested from the OS.

STORAGE = <Permission.STORAGE: 0>
class PlayerNotFoundError(babase.NotFoundError):
29class PlayerNotFoundError(NotFoundError):
30    """Exception raised when an expected player does not exist."""

Exception raised when an expected player does not exist.

class Plugin:
317class Plugin:
318    """A plugin to alter app behavior in some way.
319
320    Plugins are discoverable by the meta-tag system
321    and the user can select which ones they want to enable.
322    Enabled plugins are then called at specific times as the
323    app is running in order to modify its behavior in some way.
324    """
325
326    def on_app_running(self) -> None:
327        """Called when the app reaches the running state."""
328
329    def on_app_suspend(self) -> None:
330        """Called when the app enters the suspended state."""
331
332    def on_app_unsuspend(self) -> None:
333        """Called when the app exits the suspended state."""
334
335    def on_app_shutdown(self) -> None:
336        """Called when the app is beginning the shutdown process."""
337
338    def on_app_shutdown_complete(self) -> None:
339        """Called when the app has completed the shutdown process."""
340
341    def has_settings_ui(self) -> bool:
342        """Called to ask if we have settings UI we can show."""
343        return False
344
345    def show_settings_ui(self, source_widget: Any | None) -> None:
346        """Called to show our settings UI."""

A plugin to alter app behavior in some way.

Plugins are discoverable by the meta-tag system and the user can select which ones they want to enable. Enabled plugins are then called at specific times as the app is running in order to modify its behavior in some way.

def on_app_running(self) -> None:
326    def on_app_running(self) -> None:
327        """Called when the app reaches the running state."""

Called when the app reaches the running state.

def on_app_suspend(self) -> None:
329    def on_app_suspend(self) -> None:
330        """Called when the app enters the suspended state."""

Called when the app enters the suspended state.

def on_app_unsuspend(self) -> None:
332    def on_app_unsuspend(self) -> None:
333        """Called when the app exits the suspended state."""

Called when the app exits the suspended state.

def on_app_shutdown(self) -> None:
335    def on_app_shutdown(self) -> None:
336        """Called when the app is beginning the shutdown process."""

Called when the app is beginning the shutdown process.

def on_app_shutdown_complete(self) -> None:
338    def on_app_shutdown_complete(self) -> None:
339        """Called when the app has completed the shutdown process."""

Called when the app has completed the shutdown process.

def has_settings_ui(self) -> bool:
341    def has_settings_ui(self) -> bool:
342        """Called to ask if we have settings UI we can show."""
343        return False

Called to ask if we have settings UI we can show.

def show_settings_ui(self, source_widget: typing.Any | None) -> None:
345    def show_settings_ui(self, source_widget: Any | None) -> None:
346        """Called to show our settings UI."""

Called to show our settings UI.

class PluginSubsystem(babase.AppSubsystem):
 21class PluginSubsystem(AppSubsystem):
 22    """Subsystem for plugin handling in the app.
 23
 24    Access the single shared instance of this class at `ba.app.plugins`.
 25    """
 26
 27    AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
 28    AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
 29
 30    def __init__(self) -> None:
 31        super().__init__()
 32
 33        # Info about plugins that we are aware of. This may include
 34        # plugins discovered through meta-scanning as well as plugins
 35        # registered in the app-config. This may include plugins that
 36        # cannot be loaded for various reasons or that have been
 37        # intentionally disabled.
 38        self.plugin_specs: dict[str, babase.PluginSpec] = {}
 39
 40        # The set of live active plugin objects.
 41        self.active_plugins: list[babase.Plugin] = []
 42
 43    def on_meta_scan_complete(self) -> None:
 44        """Called when meta-scanning is complete."""
 45        from babase._language import Lstr
 46
 47        config_changed = False
 48        found_new = False
 49        plugstates: dict[str, dict] = _babase.app.config.setdefault(
 50            'Plugins', {}
 51        )
 52        assert isinstance(plugstates, dict)
 53
 54        results = _babase.app.meta.scanresults
 55        assert results is not None
 56
 57        auto_enable_new_plugins = (
 58            _babase.app.config.get(
 59                self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
 60                self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
 61            )
 62            is True
 63        )
 64
 65        assert not self.plugin_specs
 66        assert not self.active_plugins
 67
 68        # Create a plugin-spec for each plugin class we found in the
 69        # meta-scan.
 70        for class_path in results.exports_of_class(Plugin):
 71            assert class_path not in self.plugin_specs
 72            plugspec = self.plugin_specs[class_path] = PluginSpec(
 73                class_path=class_path, loadable=True
 74            )
 75
 76            # Auto-enable new ones if desired.
 77            if auto_enable_new_plugins:
 78                if class_path not in plugstates:
 79                    plugspec.enabled = True
 80                    config_changed = True
 81                    found_new = True
 82
 83        # If we're *not* auto-enabling, simply let the user know if we
 84        # found new ones.
 85        if found_new and not auto_enable_new_plugins:
 86            _babase.screenmessage(
 87                Lstr(resource='pluginsDetectedText'), color=(0, 1, 0)
 88            )
 89            _babase.getsimplesound('ding').play()
 90
 91        # Ok, now go through all plugins registered in the app-config
 92        # that weren't covered by the meta stuff above, either creating
 93        # plugin-specs for them or clearing them out. This covers
 94        # plugins with api versions not matching ours, plugins without
 95        # ba_*meta tags, and plugins that have since disappeared.
 96        assert isinstance(plugstates, dict)
 97        wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]
 98
 99        disappeared_plugs: set[str] = set()
100
101        for class_path in sorted(plugstates.keys()):
102            # Already have a spec for it; nothing to be done.
103            if class_path in self.plugin_specs:
104                continue
105
106            # If this plugin corresponds to any modules that we've
107            # identified as having incorrect api versions, we'll take
108            # note of its existence but we won't try to load it.
109            if any(
110                class_path.startswith(prefix) for prefix in wrong_api_prefixes
111            ):
112                plugspec = self.plugin_specs[class_path] = PluginSpec(
113                    class_path=class_path, loadable=False
114                )
115                continue
116
117            # Ok, it seems to be a class we have no metadata for. Look
118            # to see if it appears to be an actual class we could
119            # theoretically load. If so, we'll try. If not, we consider
120            # the plugin to have disappeared and inform the user as
121            # such.
122            try:
123                spec = importlib.util.find_spec(
124                    '.'.join(class_path.split('.')[:-1])
125                )
126            except Exception:
127                spec = None
128
129            if spec is None:
130                disappeared_plugs.add(class_path)
131                continue
132
133        # If plugins disappeared, let the user know gently and remove
134        # them from the config so we'll again let the user know if they
135        # later reappear. This makes it much smoother to switch between
136        # users or workspaces.
137        if disappeared_plugs:
138            _babase.getsimplesound('shieldDown').play()
139            _babase.screenmessage(
140                Lstr(
141                    resource='pluginsRemovedText',
142                    subs=[('${NUM}', str(len(disappeared_plugs)))],
143                ),
144                color=(1, 1, 0),
145            )
146
147            plugnames = ', '.join(disappeared_plugs)
148            logging.info(
149                '%d plugin(s) no longer found: %s.',
150                len(disappeared_plugs),
151                plugnames,
152            )
153            for goneplug in disappeared_plugs:
154                del _babase.app.config['Plugins'][goneplug]
155            _babase.app.config.commit()
156
157        if config_changed:
158            _babase.app.config.commit()
159
160    @override
161    def on_app_running(self) -> None:
162        # Load up our plugins and go ahead and call their on_app_running
163        # calls.
164        self._load_plugins()
165        for plugin in self.active_plugins:
166            try:
167                plugin.on_app_running()
168            except Exception:
169                from babase import _error
170
171                _error.print_exception('Error in plugin on_app_running()')
172
173    @override
174    def on_app_suspend(self) -> None:
175        for plugin in self.active_plugins:
176            try:
177                plugin.on_app_suspend()
178            except Exception:
179                from babase import _error
180
181                _error.print_exception('Error in plugin on_app_suspend()')
182
183    @override
184    def on_app_unsuspend(self) -> None:
185        for plugin in self.active_plugins:
186            try:
187                plugin.on_app_unsuspend()
188            except Exception:
189                from babase import _error
190
191                _error.print_exception('Error in plugin on_app_unsuspend()')
192
193    @override
194    def on_app_shutdown(self) -> None:
195        for plugin in self.active_plugins:
196            try:
197                plugin.on_app_shutdown()
198            except Exception:
199                from babase import _error
200
201                _error.print_exception('Error in plugin on_app_shutdown()')
202
203    @override
204    def on_app_shutdown_complete(self) -> None:
205        for plugin in self.active_plugins:
206            try:
207                plugin.on_app_shutdown_complete()
208            except Exception:
209                from babase import _error
210
211                _error.print_exception(
212                    'Error in plugin on_app_shutdown_complete()'
213                )
214
215    def _load_plugins(self) -> None:
216
217        # Load plugins from any specs that are enabled & able to.
218        for _class_path, plug_spec in sorted(self.plugin_specs.items()):
219            plugin = plug_spec.attempt_load_if_enabled()
220            if plugin is not None:
221                self.active_plugins.append(plugin)

Subsystem for plugin handling in the app.

Access the single shared instance of this class at ba.app.plugins.

AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
plugin_specs: dict[str, PluginSpec]
active_plugins: list[Plugin]
def on_meta_scan_complete(self) -> None:
 43    def on_meta_scan_complete(self) -> None:
 44        """Called when meta-scanning is complete."""
 45        from babase._language import Lstr
 46
 47        config_changed = False
 48        found_new = False
 49        plugstates: dict[str, dict] = _babase.app.config.setdefault(
 50            'Plugins', {}
 51        )
 52        assert isinstance(plugstates, dict)
 53
 54        results = _babase.app.meta.scanresults
 55        assert results is not None
 56
 57        auto_enable_new_plugins = (
 58            _babase.app.config.get(
 59                self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
 60                self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
 61            )
 62            is True
 63        )
 64
 65        assert not self.plugin_specs
 66        assert not self.active_plugins
 67
 68        # Create a plugin-spec for each plugin class we found in the
 69        # meta-scan.
 70        for class_path in results.exports_of_class(Plugin):
 71            assert class_path not in self.plugin_specs
 72            plugspec = self.plugin_specs[class_path] = PluginSpec(
 73                class_path=class_path, loadable=True
 74            )
 75
 76            # Auto-enable new ones if desired.
 77            if auto_enable_new_plugins:
 78                if class_path not in plugstates:
 79                    plugspec.enabled = True
 80                    config_changed = True
 81                    found_new = True
 82
 83        # If we're *not* auto-enabling, simply let the user know if we
 84        # found new ones.
 85        if found_new and not auto_enable_new_plugins:
 86            _babase.screenmessage(
 87                Lstr(resource='pluginsDetectedText'), color=(0, 1, 0)
 88            )
 89            _babase.getsimplesound('ding').play()
 90
 91        # Ok, now go through all plugins registered in the app-config
 92        # that weren't covered by the meta stuff above, either creating
 93        # plugin-specs for them or clearing them out. This covers
 94        # plugins with api versions not matching ours, plugins without
 95        # ba_*meta tags, and plugins that have since disappeared.
 96        assert isinstance(plugstates, dict)
 97        wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]
 98
 99        disappeared_plugs: set[str] = set()
100
101        for class_path in sorted(plugstates.keys()):
102            # Already have a spec for it; nothing to be done.
103            if class_path in self.plugin_specs:
104                continue
105
106            # If this plugin corresponds to any modules that we've
107            # identified as having incorrect api versions, we'll take
108            # note of its existence but we won't try to load it.
109            if any(
110                class_path.startswith(prefix) for prefix in wrong_api_prefixes
111            ):
112                plugspec = self.plugin_specs[class_path] = PluginSpec(
113                    class_path=class_path, loadable=False
114                )
115                continue
116
117            # Ok, it seems to be a class we have no metadata for. Look
118            # to see if it appears to be an actual class we could
119            # theoretically load. If so, we'll try. If not, we consider
120            # the plugin to have disappeared and inform the user as
121            # such.
122            try:
123                spec = importlib.util.find_spec(
124                    '.'.join(class_path.split('.')[:-1])
125                )
126            except Exception:
127                spec = None
128
129            if spec is None:
130                disappeared_plugs.add(class_path)
131                continue
132
133        # If plugins disappeared, let the user know gently and remove
134        # them from the config so we'll again let the user know if they
135        # later reappear. This makes it much smoother to switch between
136        # users or workspaces.
137        if disappeared_plugs:
138            _babase.getsimplesound('shieldDown').play()
139            _babase.screenmessage(
140                Lstr(
141                    resource='pluginsRemovedText',
142                    subs=[('${NUM}', str(len(disappeared_plugs)))],
143                ),
144                color=(1, 1, 0),
145            )
146
147            plugnames = ', '.join(disappeared_plugs)
148            logging.info(
149                '%d plugin(s) no longer found: %s.',
150                len(disappeared_plugs),
151                plugnames,
152            )
153            for goneplug in disappeared_plugs:
154                del _babase.app.config['Plugins'][goneplug]
155            _babase.app.config.commit()
156
157        if config_changed:
158            _babase.app.config.commit()

Called when meta-scanning is complete.

@override
def on_app_running(self) -> None:
160    @override
161    def on_app_running(self) -> None:
162        # Load up our plugins and go ahead and call their on_app_running
163        # calls.
164        self._load_plugins()
165        for plugin in self.active_plugins:
166            try:
167                plugin.on_app_running()
168            except Exception:
169                from babase import _error
170
171                _error.print_exception('Error in plugin on_app_running()')

Called when the app reaches the running state.

@override
def on_app_suspend(self) -> None:
173    @override
174    def on_app_suspend(self) -> None:
175        for plugin in self.active_plugins:
176            try:
177                plugin.on_app_suspend()
178            except Exception:
179                from babase import _error
180
181                _error.print_exception('Error in plugin on_app_suspend()')

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
183    @override
184    def on_app_unsuspend(self) -> None:
185        for plugin in self.active_plugins:
186            try:
187                plugin.on_app_unsuspend()
188            except Exception:
189                from babase import _error
190
191                _error.print_exception('Error in plugin on_app_unsuspend()')

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
193    @override
194    def on_app_shutdown(self) -> None:
195        for plugin in self.active_plugins:
196            try:
197                plugin.on_app_shutdown()
198            except Exception:
199                from babase import _error
200
201                _error.print_exception('Error in plugin on_app_shutdown()')

Called when the app begins shutting down.

@override
def on_app_shutdown_complete(self) -> None:
203    @override
204    def on_app_shutdown_complete(self) -> None:
205        for plugin in self.active_plugins:
206            try:
207                plugin.on_app_shutdown_complete()
208            except Exception:
209                from babase import _error
210
211                _error.print_exception(
212                    'Error in plugin on_app_shutdown_complete()'
213                )

Called when the app completes shutting down.

class PluginSpec:
224class PluginSpec:
225    """Represents a plugin the engine knows about.
226
227    The 'enabled' attr represents whether this plugin is set to load.
228    Getting or setting that attr affects the corresponding app-config
229    key. Remember to commit the app-config after making any changes.
230
231    The 'attempted_load' attr will be True if the engine has attempted
232    to load the plugin. If 'attempted_load' is True for a PluginSpec but
233    the 'plugin' attr is None, it means there was an error loading the
234    plugin. If a plugin's api-version does not match the running app, if
235    a new plugin is detected with auto-enable-plugins disabled, or if
236    the user has explicitly disabled a plugin, the engine will not even
237    attempt to load it.
238    """
239
240    def __init__(self, class_path: str, loadable: bool):
241        self.class_path = class_path
242        self.loadable = loadable
243        self.attempted_load = False
244        self.plugin: Plugin | None = None
245
246    @property
247    def enabled(self) -> bool:
248        """Whether the user wants this plugin to load."""
249        plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
250        assert isinstance(plugstates, dict)
251        val = plugstates.get(self.class_path, {}).get('enabled', False) is True
252        return val
253
254    @enabled.setter
255    def enabled(self, val: bool) -> None:
256        plugstates: dict[str, dict] = _babase.app.config.setdefault(
257            'Plugins', {}
258        )
259        assert isinstance(plugstates, dict)
260        plugstate = plugstates.setdefault(self.class_path, {})
261        plugstate['enabled'] = val
262
263    def attempt_load_if_enabled(self) -> Plugin | None:
264        """Possibly load the plugin and log any errors."""
265        from babase._general import getclass
266        from babase._language import Lstr
267
268        assert not self.attempted_load
269        assert self.plugin is None
270
271        if not self.enabled:
272            return None
273        self.attempted_load = True
274        if not self.loadable:
275            return None
276        try:
277            cls = getclass(self.class_path, Plugin, True)
278        except Exception as exc:
279            _babase.getsimplesound('error').play()
280            _babase.screenmessage(
281                Lstr(
282                    resource='pluginClassLoadErrorText',
283                    subs=[
284                        ('${PLUGIN}', self.class_path),
285                        ('${ERROR}', str(exc)),
286                    ],
287                ),
288                color=(1, 0, 0),
289            )
290            logging.exception(
291                "Error loading plugin class '%s'.", self.class_path
292            )
293            return None
294        try:
295            self.plugin = cls()
296            return self.plugin
297        except Exception as exc:
298            from babase import _error
299
300            _babase.getsimplesound('error').play()
301            _babase.screenmessage(
302                Lstr(
303                    resource='pluginInitErrorText',
304                    subs=[
305                        ('${PLUGIN}', self.class_path),
306                        ('${ERROR}', str(exc)),
307                    ],
308                ),
309                color=(1, 0, 0),
310            )
311            logging.exception(
312                "Error initing plugin class: '%s'.", self.class_path
313            )
314        return None

Represents a plugin the engine knows about.

The 'enabled' attr represents whether this plugin is set to load. Getting or setting that attr affects the corresponding app-config key. Remember to commit the app-config after making any changes.

The 'attempted_load' attr will be True if the engine has attempted to load the plugin. If 'attempted_load' is True for a PluginSpec but the 'plugin' attr is None, it means there was an error loading the plugin. If a plugin's api-version does not match the running app, if a new plugin is detected with auto-enable-plugins disabled, or if the user has explicitly disabled a plugin, the engine will not even attempt to load it.

PluginSpec(class_path: str, loadable: bool)
240    def __init__(self, class_path: str, loadable: bool):
241        self.class_path = class_path
242        self.loadable = loadable
243        self.attempted_load = False
244        self.plugin: Plugin | None = None
class_path
loadable
attempted_load
plugin: Plugin | None
enabled: bool
246    @property
247    def enabled(self) -> bool:
248        """Whether the user wants this plugin to load."""
249        plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
250        assert isinstance(plugstates, dict)
251        val = plugstates.get(self.class_path, {}).get('enabled', False) is True
252        return val

Whether the user wants this plugin to load.

def attempt_load_if_enabled(self) -> Plugin | None:
263    def attempt_load_if_enabled(self) -> Plugin | None:
264        """Possibly load the plugin and log any errors."""
265        from babase._general import getclass
266        from babase._language import Lstr
267
268        assert not self.attempted_load
269        assert self.plugin is None
270
271        if not self.enabled:
272            return None
273        self.attempted_load = True
274        if not self.loadable:
275            return None
276        try:
277            cls = getclass(self.class_path, Plugin, True)
278        except Exception as exc:
279            _babase.getsimplesound('error').play()
280            _babase.screenmessage(
281                Lstr(
282                    resource='pluginClassLoadErrorText',
283                    subs=[
284                        ('${PLUGIN}', self.class_path),
285                        ('${ERROR}', str(exc)),
286                    ],
287                ),
288                color=(1, 0, 0),
289            )
290            logging.exception(
291                "Error loading plugin class '%s'.", self.class_path
292            )
293            return None
294        try:
295            self.plugin = cls()
296            return self.plugin
297        except Exception as exc:
298            from babase import _error
299
300            _babase.getsimplesound('error').play()
301            _babase.screenmessage(
302                Lstr(
303                    resource='pluginInitErrorText',
304                    subs=[
305                        ('${PLUGIN}', self.class_path),
306                        ('${ERROR}', str(exc)),
307                    ],
308                ),
309                color=(1, 0, 0),
310            )
311            logging.exception(
312                "Error initing plugin class: '%s'.", self.class_path
313            )
314        return None

Possibly load the plugin and log any errors.

def pushcall( call: Callable, from_other_thread: bool = False, suppress_other_thread_warning: bool = False, other_thread_use_fg_context: bool = False, raw: bool = False) -> None:
1368def pushcall(
1369    call: Callable,
1370    from_other_thread: bool = False,
1371    suppress_other_thread_warning: bool = False,
1372    other_thread_use_fg_context: bool = False,
1373    raw: bool = False,
1374) -> None:
1375    """Push a call to the logic event-loop.
1376
1377    This call expects to be used in the logic thread, and will automatically
1378    save and restore the babase.Context to behave seamlessly.
1379
1380    If you want to push a call from outside of the logic thread,
1381    however, you can pass 'from_other_thread' as True. In this case
1382    the call will always run in the UI context_ref on the logic thread
1383    or whichever context_ref is in the foreground if
1384    other_thread_use_fg_context is True.
1385    Passing raw=True will disable thread checks and context_ref sets/restores.
1386    """
1387    return None

Push a call to the logic event-loop.

This call expects to be used in the logic thread, and will automatically save and restore the babase.Context to behave seamlessly.

If you want to push a call from outside of the logic thread, however, you can pass 'from_other_thread' as True. In this case the call will always run in the UI context_ref on the logic thread or whichever context_ref is in the foreground if other_thread_use_fg_context is True. Passing raw=True will disable thread checks and context_ref sets/restores.

def quit( confirm: bool = False, quit_type: QuitType | None = None) -> None:
1391def quit(
1392    confirm: bool = False, quit_type: babase.QuitType | None = None
1393) -> None:
1394    """Quit the app.
1395
1396    If 'confirm' is True, a confirm dialog will be presented if conditions
1397    allow; otherwise the quit will still be immediate.
1398    See docs for babase.QuitType for explanations of the optional
1399    'quit_type' arg.
1400    """
1401    return None

Quit the app.

If 'confirm' is True, a confirm dialog will be presented if conditions allow; otherwise the quit will still be immediate. See docs for babase.QuitType for explanations of the optional 'quit_type' arg.

class QuitType(enum.Enum):
40class QuitType(Enum):
41    """Types of input a controller can send to the game.
42
43    'soft' may hide/reset the app but keep the process running, depending
44       on the platform.
45
46    'back' is a variant of 'soft' which may give 'back-button-pressed'
47       behavior depending on the platform. (returning to some previous
48       activity instead of dumping to the home screen, etc.)
49
50    'hard' leads to the process exiting. This generally should be avoided
51       on platforms such as mobile.
52    """
53
54    SOFT = 0
55    BACK = 1
56    HARD = 2

Types of input a controller can send to the game.

'soft' may hide/reset the app but keep the process running, depending on the platform.

'back' is a variant of 'soft' which may give 'back-button-pressed' behavior depending on the platform. (returning to some previous activity instead of dumping to the home screen, etc.)

'hard' leads to the process exiting. This generally should be avoided on platforms such as mobile.

SOFT = <QuitType.SOFT: 0>
BACK = <QuitType.BACK: 1>
HARD = <QuitType.HARD: 2>
def safecolor( color: Sequence[float], target_intensity: float = 0.6) -> tuple[float, ...]:
1439def safecolor(
1440    color: Sequence[float], target_intensity: float = 0.6
1441) -> tuple[float, ...]:
1442    """Given a color tuple, return a color safe to display as text.
1443
1444    Accepts tuples of length 3 or 4. This will slightly brighten very
1445    dark colors, etc.
1446    """
1447    return (0.0, 0.0, 0.0)

Given a color tuple, return a color safe to display as text.

Accepts tuples of length 3 or 4. This will slightly brighten very dark colors, etc.

def screenmessage( message: str | Lstr, color: Optional[Sequence[float]] = None, log: bool = False) -> None:
1450def screenmessage(
1451    message: str | babase.Lstr,
1452    color: Sequence[float] | None = None,
1453    log: bool = False,
1454) -> None:
1455    """Print a message to the local client's screen, in a given color.
1456
1457    Note that this version of the function is purely for local display.
1458    To broadcast screen messages in network play, look for methods such as
1459    broadcastmessage() provided by the scene-version packages.
1460    """
1461    return None

Print a message to the local client's screen, in a given color.

Note that this version of the function is purely for local display. To broadcast screen messages in network play, look for methods such as broadcastmessage() provided by the scene-version packages.

class SessionNotFoundError(babase.NotFoundError):
65class SessionNotFoundError(NotFoundError):
66    """Exception raised when an expected session does not exist."""

Exception raised when an expected session does not exist.

class SessionPlayerNotFoundError(babase.NotFoundError):
33class SessionPlayerNotFoundError(NotFoundError):
34    """Exception raised when an expected session-player does not exist."""

Exception raised when an expected session-player does not exist.

class SessionTeamNotFoundError(babase.NotFoundError):
49class SessionTeamNotFoundError(NotFoundError):
50    """Exception raised when an expected session-team does not exist."""

Exception raised when an expected session-team does not exist.

def set_analytics_screen(screen: str) -> None:
1464def set_analytics_screen(screen: str) -> None:
1465    """Used for analytics to see where in the app players spend their time.
1466
1467    Generally called when opening a new window or entering some UI.
1468    'screen' should be a string description of an app location
1469    ('Main Menu', etc.)
1470    """
1471    return None

Used for analytics to see where in the app players spend their time.

Generally called when opening a new window or entering some UI. 'screen' should be a string description of an app location ('Main Menu', etc.)

class SimpleSound:
377class SimpleSound:
378    """A simple sound wrapper for internal use.
379
380    Do not use for gameplay code as it will only play locally.
381    """
382
383    def play(self) -> None:
384        """Play the sound locally."""
385        return None

A simple sound wrapper for internal use.

Do not use for gameplay code as it will only play locally.

def play(self) -> None:
383    def play(self) -> None:
384        """Play the sound locally."""
385        return None

Play the sound locally.

class SpecialChar(enum.Enum):
 88class SpecialChar(Enum):
 89    """Special characters the game can print."""
 90    DOWN_ARROW = 0
 91    UP_ARROW = 1
 92    LEFT_ARROW = 2
 93    RIGHT_ARROW = 3
 94    TOP_BUTTON = 4
 95    LEFT_BUTTON = 5
 96    RIGHT_BUTTON = 6
 97    BOTTOM_BUTTON = 7
 98    DELETE = 8
 99    SHIFT = 9
100    BACK = 10
101    LOGO_FLAT = 11
102    REWIND_BUTTON = 12
103    PLAY_PAUSE_BUTTON = 13
104    FAST_FORWARD_BUTTON = 14
105    DPAD_CENTER_BUTTON = 15
106    PLAY_STATION_CROSS_BUTTON = 16
107    PLAY_STATION_CIRCLE_BUTTON = 17
108    PLAY_STATION_TRIANGLE_BUTTON = 18
109    PLAY_STATION_SQUARE_BUTTON = 19
110    PLAY_BUTTON = 20
111    PAUSE_BUTTON = 21
112    OUYA_BUTTON_O = 22
113    OUYA_BUTTON_U = 23
114    OUYA_BUTTON_Y = 24
115    OUYA_BUTTON_A = 25
116    TOKEN = 26
117    LOGO = 27
118    TICKET = 28
119    GOOGLE_PLAY_GAMES_LOGO = 29
120    GAME_CENTER_LOGO = 30
121    DICE_BUTTON1 = 31
122    DICE_BUTTON2 = 32
123    DICE_BUTTON3 = 33
124    DICE_BUTTON4 = 34
125    GAME_CIRCLE_LOGO = 35
126    PARTY_ICON = 36
127    TEST_ACCOUNT = 37
128    TICKET_BACKING = 38
129    TROPHY1 = 39
130    TROPHY2 = 40
131    TROPHY3 = 41
132    TROPHY0A = 42
133    TROPHY0B = 43
134    TROPHY4 = 44
135    LOCAL_ACCOUNT = 45
136    EXPLODINARY_LOGO = 46
137    FLAG_UNITED_STATES = 47
138    FLAG_MEXICO = 48
139    FLAG_GERMANY = 49
140    FLAG_BRAZIL = 50
141    FLAG_RUSSIA = 51
142    FLAG_CHINA = 52
143    FLAG_UNITED_KINGDOM = 53
144    FLAG_CANADA = 54
145    FLAG_INDIA = 55
146    FLAG_JAPAN = 56
147    FLAG_FRANCE = 57
148    FLAG_INDONESIA = 58
149    FLAG_ITALY = 59
150    FLAG_SOUTH_KOREA = 60
151    FLAG_NETHERLANDS = 61
152    FEDORA = 62
153    HAL = 63
154    CROWN = 64
155    YIN_YANG = 65
156    EYE_BALL = 66
157    SKULL = 67
158    HEART = 68
159    DRAGON = 69
160    HELMET = 70
161    MUSHROOM = 71
162    NINJA_STAR = 72
163    VIKING_HELMET = 73
164    MOON = 74
165    SPIDER = 75
166    FIREBALL = 76
167    FLAG_UNITED_ARAB_EMIRATES = 77
168    FLAG_QATAR = 78
169    FLAG_EGYPT = 79
170    FLAG_KUWAIT = 80
171    FLAG_ALGERIA = 81
172    FLAG_SAUDI_ARABIA = 82
173    FLAG_MALAYSIA = 83
174    FLAG_CZECH_REPUBLIC = 84
175    FLAG_AUSTRALIA = 85
176    FLAG_SINGAPORE = 86
177    OCULUS_LOGO = 87
178    STEAM_LOGO = 88
179    NVIDIA_LOGO = 89
180    FLAG_IRAN = 90
181    FLAG_POLAND = 91
182    FLAG_ARGENTINA = 92
183    FLAG_PHILIPPINES = 93
184    FLAG_CHILE = 94
185    MIKIROG = 95
186    V2_LOGO = 96

Special characters the game can print.

DOWN_ARROW = <SpecialChar.DOWN_ARROW: 0>
UP_ARROW = <SpecialChar.UP_ARROW: 1>
LEFT_ARROW = <SpecialChar.LEFT_ARROW: 2>
RIGHT_ARROW = <SpecialChar.RIGHT_ARROW: 3>
TOP_BUTTON = <SpecialChar.TOP_BUTTON: 4>
LEFT_BUTTON = <SpecialChar.LEFT_BUTTON: 5>
RIGHT_BUTTON = <SpecialChar.RIGHT_BUTTON: 6>
BOTTOM_BUTTON = <SpecialChar.BOTTOM_BUTTON: 7>
DELETE = <SpecialChar.DELETE: 8>
SHIFT = <SpecialChar.SHIFT: 9>
BACK = <SpecialChar.BACK: 10>
LOGO_FLAT = <SpecialChar.LOGO_FLAT: 11>
REWIND_BUTTON = <SpecialChar.REWIND_BUTTON: 12>
PLAY_PAUSE_BUTTON = <SpecialChar.PLAY_PAUSE_BUTTON: 13>
FAST_FORWARD_BUTTON = <SpecialChar.FAST_FORWARD_BUTTON: 14>
DPAD_CENTER_BUTTON = <SpecialChar.DPAD_CENTER_BUTTON: 15>
PLAY_STATION_CROSS_BUTTON = <SpecialChar.PLAY_STATION_CROSS_BUTTON: 16>
PLAY_STATION_CIRCLE_BUTTON = <SpecialChar.PLAY_STATION_CIRCLE_BUTTON: 17>
PLAY_STATION_TRIANGLE_BUTTON = <SpecialChar.PLAY_STATION_TRIANGLE_BUTTON: 18>
PLAY_STATION_SQUARE_BUTTON = <SpecialChar.PLAY_STATION_SQUARE_BUTTON: 19>
PLAY_BUTTON = <SpecialChar.PLAY_BUTTON: 20>
PAUSE_BUTTON = <SpecialChar.PAUSE_BUTTON: 21>
OUYA_BUTTON_O = <SpecialChar.OUYA_BUTTON_O: 22>
OUYA_BUTTON_U = <SpecialChar.OUYA_BUTTON_U: 23>
OUYA_BUTTON_Y = <SpecialChar.OUYA_BUTTON_Y: 24>
OUYA_BUTTON_A = <SpecialChar.OUYA_BUTTON_A: 25>
TOKEN = <SpecialChar.TOKEN: 26>
TICKET = <SpecialChar.TICKET: 28>
DICE_BUTTON1 = <SpecialChar.DICE_BUTTON1: 31>
DICE_BUTTON2 = <SpecialChar.DICE_BUTTON2: 32>
DICE_BUTTON3 = <SpecialChar.DICE_BUTTON3: 33>
DICE_BUTTON4 = <SpecialChar.DICE_BUTTON4: 34>
PARTY_ICON = <SpecialChar.PARTY_ICON: 36>
TEST_ACCOUNT = <SpecialChar.TEST_ACCOUNT: 37>
TICKET_BACKING = <SpecialChar.TICKET_BACKING: 38>
TROPHY1 = <SpecialChar.TROPHY1: 39>
TROPHY2 = <SpecialChar.TROPHY2: 40>
TROPHY3 = <SpecialChar.TROPHY3: 41>
TROPHY0A = <SpecialChar.TROPHY0A: 42>
TROPHY0B = <SpecialChar.TROPHY0B: 43>
TROPHY4 = <SpecialChar.TROPHY4: 44>
LOCAL_ACCOUNT = <SpecialChar.LOCAL_ACCOUNT: 45>
FLAG_UNITED_STATES = <SpecialChar.FLAG_UNITED_STATES: 47>
FLAG_MEXICO = <SpecialChar.FLAG_MEXICO: 48>
FLAG_GERMANY = <SpecialChar.FLAG_GERMANY: 49>
FLAG_BRAZIL = <SpecialChar.FLAG_BRAZIL: 50>
FLAG_RUSSIA = <SpecialChar.FLAG_RUSSIA: 51>
FLAG_CHINA = <SpecialChar.FLAG_CHINA: 52>
FLAG_UNITED_KINGDOM = <SpecialChar.FLAG_UNITED_KINGDOM: 53>
FLAG_CANADA = <SpecialChar.FLAG_CANADA: 54>
FLAG_INDIA = <SpecialChar.FLAG_INDIA: 55>
FLAG_JAPAN = <SpecialChar.FLAG_JAPAN: 56>
FLAG_FRANCE = <SpecialChar.FLAG_FRANCE: 57>
FLAG_INDONESIA = <SpecialChar.FLAG_INDONESIA: 58>
FLAG_ITALY = <SpecialChar.FLAG_ITALY: 59>
FLAG_SOUTH_KOREA = <SpecialChar.FLAG_SOUTH_KOREA: 60>
FLAG_NETHERLANDS = <SpecialChar.FLAG_NETHERLANDS: 61>
FEDORA = <SpecialChar.FEDORA: 62>
HAL = <SpecialChar.HAL: 63>
CROWN = <SpecialChar.CROWN: 64>
YIN_YANG = <SpecialChar.YIN_YANG: 65>
EYE_BALL = <SpecialChar.EYE_BALL: 66>
SKULL = <SpecialChar.SKULL: 67>
HEART = <SpecialChar.HEART: 68>
DRAGON = <SpecialChar.DRAGON: 69>
HELMET = <SpecialChar.HELMET: 70>
MUSHROOM = <SpecialChar.MUSHROOM: 71>
NINJA_STAR = <SpecialChar.NINJA_STAR: 72>
VIKING_HELMET = <SpecialChar.VIKING_HELMET: 73>
MOON = <SpecialChar.MOON: 74>
SPIDER = <SpecialChar.SPIDER: 75>
FIREBALL = <SpecialChar.FIREBALL: 76>
FLAG_UNITED_ARAB_EMIRATES = <SpecialChar.FLAG_UNITED_ARAB_EMIRATES: 77>
FLAG_QATAR = <SpecialChar.FLAG_QATAR: 78>
FLAG_EGYPT = <SpecialChar.FLAG_EGYPT: 79>
FLAG_KUWAIT = <SpecialChar.FLAG_KUWAIT: 80>
FLAG_ALGERIA = <SpecialChar.FLAG_ALGERIA: 81>
FLAG_SAUDI_ARABIA = <SpecialChar.FLAG_SAUDI_ARABIA: 82>
FLAG_MALAYSIA = <SpecialChar.FLAG_MALAYSIA: 83>
FLAG_CZECH_REPUBLIC = <SpecialChar.FLAG_CZECH_REPUBLIC: 84>
FLAG_AUSTRALIA = <SpecialChar.FLAG_AUSTRALIA: 85>
FLAG_SINGAPORE = <SpecialChar.FLAG_SINGAPORE: 86>
FLAG_IRAN = <SpecialChar.FLAG_IRAN: 90>
FLAG_POLAND = <SpecialChar.FLAG_POLAND: 91>
FLAG_ARGENTINA = <SpecialChar.FLAG_ARGENTINA: 92>
FLAG_PHILIPPINES = <SpecialChar.FLAG_PHILIPPINES: 93>
FLAG_CHILE = <SpecialChar.FLAG_CHILE: 94>
MIKIROG = <SpecialChar.MIKIROG: 95>
def storagename(suffix: str | None = None) -> str:
339def storagename(suffix: str | None = None) -> str:
340    """Generate a unique name for storing class data in shared places.
341
342    This consists of a leading underscore, the module path at the
343    call site with dots replaced by underscores, the containing class's
344    qualified name, and the provided suffix. When storing data in public
345    places such as 'customdata' dicts, this minimizes the chance of
346    collisions with other similarly named classes.
347
348    Note that this will function even if called in the class definition.
349
350    ##### Examples
351    Generate a unique name for storage purposes:
352    >>> class MyThingie:
353    ...     # This will give something like
354    ...     # '_mymodule_submodule_mythingie_data'.
355    ...     _STORENAME = babase.storagename('data')
356    ...
357    ...     # Use that name to store some data in the Activity we were
358    ...     # passed.
359    ...     def __init__(self, activity):
360    ...         activity.customdata[self._STORENAME] = {}
361    """
362    frame = inspect.currentframe()
363    if frame is None:
364        raise RuntimeError('Cannot get current stack frame.')
365    fback = frame.f_back
366
367    # Note: We need to explicitly clear frame here to avoid a ref-loop
368    # that keeps all function-dicts in the stack alive until the next
369    # full GC cycle (the stack frame refers to this function's dict,
370    # which refers to the stack frame).
371    del frame
372
373    if fback is None:
374        raise RuntimeError('Cannot get parent stack frame.')
375    modulepath = fback.f_globals.get('__name__')
376    if modulepath is None:
377        raise RuntimeError('Cannot get parent stack module path.')
378    assert isinstance(modulepath, str)
379    qualname = fback.f_locals.get('__qualname__')
380    if qualname is not None:
381        assert isinstance(qualname, str)
382        fullpath = f'_{modulepath}_{qualname.lower()}'
383    else:
384        fullpath = f'_{modulepath}'
385    if suffix is not None:
386        fullpath = f'{fullpath}_{suffix}'
387    return fullpath.replace('.', '_')

Generate a unique name for storing class data in shared places.

This consists of a leading underscore, the module path at the call site with dots replaced by underscores, the containing class's qualified name, and the provided suffix. When storing data in public places such as 'customdata' dicts, this minimizes the chance of collisions with other similarly named classes.

Note that this will function even if called in the class definition.

Examples

Generate a unique name for storage purposes:

>>> class MyThingie:
...     # This will give something like
...     # '_mymodule_submodule_mythingie_data'.
...     _STORENAME = babase.storagename('data')
...
...     # Use that name to store some data in the Activity we were
...     # passed.
...     def __init__(self, activity):
...         activity.customdata[self._STORENAME] = {}
class StringEditAdapter:
 32class StringEditAdapter:
 33    """Represents a string editing operation on some object.
 34
 35    Editable objects such as text widgets or in-app-consoles can
 36    subclass this to make their contents editable on all platforms.
 37
 38    There can only be one string-edit at a time for the app. New
 39    StringEdits will attempt to register themselves as the globally
 40    active one in their constructor, but this may not succeed. When
 41    creating a StringEditAdapter, always check its 'is_valid()' value after
 42    creating it. If this is False, it was not able to set itself as
 43    the global active one and should be discarded.
 44    """
 45
 46    def __init__(
 47        self,
 48        description: str,
 49        initial_text: str,
 50        max_length: int | None,
 51        screen_space_center: tuple[float, float] | None,
 52    ) -> None:
 53        if not _babase.in_logic_thread():
 54            raise RuntimeError('This must be called from the logic thread.')
 55
 56        self.create_time = time.monotonic()
 57
 58        # Note: these attr names are hard-coded in C++ code so don't
 59        # change them willy-nilly.
 60        self.description = description
 61        self.initial_text = initial_text
 62        self.max_length = max_length
 63        self.screen_space_center = screen_space_center
 64
 65        # Attempt to register ourself as the active edit.
 66        subsys = _babase.app.stringedit
 67        current_edit = subsys.active_adapter()
 68        if current_edit is None or current_edit.can_be_replaced():
 69            subsys.active_adapter = weakref.ref(self)
 70
 71    @final
 72    def can_be_replaced(self) -> bool:
 73        """Return whether this adapter can be replaced by a new one.
 74
 75        This is mainly a safeguard to allow adapters whose drivers have
 76        gone away without calling apply or cancel to time out and be
 77        replaced with new ones.
 78        """
 79        if not _babase.in_logic_thread():
 80            raise RuntimeError('This must be called from the logic thread.')
 81
 82        # Allow ourself to be replaced after a bit.
 83        if time.monotonic() - self.create_time > 5.0:
 84            if _babase.do_once():
 85                logging.warning(
 86                    'StringEditAdapter can_be_replaced() check for %s'
 87                    ' yielding True due to timeout; ideally this should'
 88                    ' not be possible as the StringEditAdapter driver'
 89                    ' should be blocking anything else from kicking off'
 90                    ' new edits.',
 91                    self,
 92                )
 93            return True
 94
 95        # We also are always considered replaceable if we're not the
 96        # active global adapter.
 97        current_edit = _babase.app.stringedit.active_adapter()
 98        if current_edit is not self:
 99            return True
100
101        return False
102
103    @final
104    def apply(self, new_text: str) -> None:
105        """Should be called by the owner when editing is complete.
106
107        Note that in some cases this call may be a no-op (such as if
108        this StringEditAdapter is no longer the globally active one).
109        """
110        if not _babase.in_logic_thread():
111            raise RuntimeError('This must be called from the logic thread.')
112
113        # Make sure whoever is feeding this adapter is honoring max-length.
114        if self.max_length is not None and len(new_text) > self.max_length:
115            logging.warning(
116                'apply() on %s was passed a string of length %d,'
117                ' but adapter max_length is %d; this should not happen'
118                ' (will truncate).',
119                self,
120                len(new_text),
121                self.max_length,
122                stack_info=True,
123            )
124            new_text = new_text[: self.max_length]
125
126        self._do_apply(new_text)
127
128    @final
129    def cancel(self) -> None:
130        """Should be called by the owner when editing is cancelled."""
131        if not _babase.in_logic_thread():
132            raise RuntimeError('This must be called from the logic thread.')
133        self._do_cancel()
134
135    def _do_apply(self, new_text: str) -> None:
136        """Should be overridden by subclasses to handle apply.
137
138        Will always be called in the logic thread.
139        """
140        raise NotImplementedError('Subclasses must override this.')
141
142    def _do_cancel(self) -> None:
143        """Should be overridden by subclasses to handle cancel.
144
145        Will always be called in the logic thread.
146        """
147        raise NotImplementedError('Subclasses must override this.')

Represents a string editing operation on some object.

Editable objects such as text widgets or in-app-consoles can subclass this to make their contents editable on all platforms.

There can only be one string-edit at a time for the app. New StringEdits will attempt to register themselves as the globally active one in their constructor, but this may not succeed. When creating a StringEditAdapter, always check its 'is_valid()' value after creating it. If this is False, it was not able to set itself as the global active one and should be discarded.

StringEditAdapter( description: str, initial_text: str, max_length: int | None, screen_space_center: tuple[float, float] | None)
46    def __init__(
47        self,
48        description: str,
49        initial_text: str,
50        max_length: int | None,
51        screen_space_center: tuple[float, float] | None,
52    ) -> None:
53        if not _babase.in_logic_thread():
54            raise RuntimeError('This must be called from the logic thread.')
55
56        self.create_time = time.monotonic()
57
58        # Note: these attr names are hard-coded in C++ code so don't
59        # change them willy-nilly.
60        self.description = description
61        self.initial_text = initial_text
62        self.max_length = max_length
63        self.screen_space_center = screen_space_center
64
65        # Attempt to register ourself as the active edit.
66        subsys = _babase.app.stringedit
67        current_edit = subsys.active_adapter()
68        if current_edit is None or current_edit.can_be_replaced():
69            subsys.active_adapter = weakref.ref(self)
create_time
description
initial_text
max_length
screen_space_center
@final
def can_be_replaced(self) -> bool:
 71    @final
 72    def can_be_replaced(self) -> bool:
 73        """Return whether this adapter can be replaced by a new one.
 74
 75        This is mainly a safeguard to allow adapters whose drivers have
 76        gone away without calling apply or cancel to time out and be
 77        replaced with new ones.
 78        """
 79        if not _babase.in_logic_thread():
 80            raise RuntimeError('This must be called from the logic thread.')
 81
 82        # Allow ourself to be replaced after a bit.
 83        if time.monotonic() - self.create_time > 5.0:
 84            if _babase.do_once():
 85                logging.warning(
 86                    'StringEditAdapter can_be_replaced() check for %s'
 87                    ' yielding True due to timeout; ideally this should'
 88                    ' not be possible as the StringEditAdapter driver'
 89                    ' should be blocking anything else from kicking off'
 90                    ' new edits.',
 91                    self,
 92                )
 93            return True
 94
 95        # We also are always considered replaceable if we're not the
 96        # active global adapter.
 97        current_edit = _babase.app.stringedit.active_adapter()
 98        if current_edit is not self:
 99            return True
100
101        return False

Return whether this adapter can be replaced by a new one.

This is mainly a safeguard to allow adapters whose drivers have gone away without calling apply or cancel to time out and be replaced with new ones.

@final
def apply(self, new_text: str) -> None:
103    @final
104    def apply(self, new_text: str) -> None:
105        """Should be called by the owner when editing is complete.
106
107        Note that in some cases this call may be a no-op (such as if
108        this StringEditAdapter is no longer the globally active one).
109        """
110        if not _babase.in_logic_thread():
111            raise RuntimeError('This must be called from the logic thread.')
112
113        # Make sure whoever is feeding this adapter is honoring max-length.
114        if self.max_length is not None and len(new_text) > self.max_length:
115            logging.warning(
116                'apply() on %s was passed a string of length %d,'
117                ' but adapter max_length is %d; this should not happen'
118                ' (will truncate).',
119                self,
120                len(new_text),
121                self.max_length,
122                stack_info=True,
123            )
124            new_text = new_text[: self.max_length]
125
126        self._do_apply(new_text)

Should be called by the owner when editing is complete.

Note that in some cases this call may be a no-op (such as if this StringEditAdapter is no longer the globally active one).

@final
def cancel(self) -> None:
128    @final
129    def cancel(self) -> None:
130        """Should be called by the owner when editing is cancelled."""
131        if not _babase.in_logic_thread():
132            raise RuntimeError('This must be called from the logic thread.')
133        self._do_cancel()

Should be called by the owner when editing is cancelled.

class StringEditSubsystem:
25class StringEditSubsystem:
26    """Full string-edit state for the app."""
27
28    def __init__(self) -> None:
29        self.active_adapter = empty_weakref(StringEditAdapter)

Full string-edit state for the app.

active_adapter
def supports_unicode_display() -> bool:
1608def supports_unicode_display() -> bool:
1609    """Return whether we can display all unicode characters in the gui."""
1610    return bool()

Return whether we can display all unicode characters in the gui.

class TeamNotFoundError(babase.NotFoundError):
37class TeamNotFoundError(NotFoundError):
38    """Exception raised when an expected bascenev1.Team does not exist."""

Exception raised when an expected bascenev1.Team does not exist.

def timestring(timeval: float | int, centi: bool = True) -> Lstr:
15def timestring(
16    timeval: float | int,
17    centi: bool = True,
18) -> babase.Lstr:
19    """Generate a babase.Lstr for displaying a time value.
20
21    Given a time value, returns a babase.Lstr with:
22    (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
23
24    WARNING: the underlying Lstr value is somewhat large so don't use this
25    to rapidly update Node text values for an onscreen timer or you may
26    consume significant network bandwidth.  For that purpose you should
27    use a 'timedisplay' Node and attribute connections.
28
29    """
30    from babase._language import Lstr
31
32    # We take float seconds but operate on int milliseconds internally.
33    timeval = int(1000 * timeval)
34    bits = []
35    subs = []
36    hval = (timeval // 1000) // (60 * 60)
37    if hval != 0:
38        bits.append('${H}')
39        subs.append(
40            (
41                '${H}',
42                Lstr(
43                    resource='timeSuffixHoursText',
44                    subs=[('${COUNT}', str(hval))],
45                ),
46            )
47        )
48    mval = ((timeval // 1000) // 60) % 60
49    if mval != 0:
50        bits.append('${M}')
51        subs.append(
52            (
53                '${M}',
54                Lstr(
55                    resource='timeSuffixMinutesText',
56                    subs=[('${COUNT}', str(mval))],
57                ),
58            )
59        )
60
61    # We add seconds if its non-zero *or* we haven't added anything else.
62    if centi:
63        # pylint: disable=consider-using-f-string
64        sval = timeval / 1000.0 % 60.0
65        if sval >= 0.005 or not bits:
66            bits.append('${S}')
67            subs.append(
68                (
69                    '${S}',
70                    Lstr(
71                        resource='timeSuffixSecondsText',
72                        subs=[('${COUNT}', ('%.2f' % sval))],
73                    ),
74                )
75            )
76    else:
77        sval = timeval // 1000 % 60
78        if sval != 0 or not bits:
79            bits.append('${S}')
80            subs.append(
81                (
82                    '${S}',
83                    Lstr(
84                        resource='timeSuffixSecondsText',
85                        subs=[('${COUNT}', str(sval))],
86                    ),
87                )
88            )
89    return Lstr(value=' '.join(bits), subs=subs)

Generate a babase.Lstr for displaying a time value.

Given a time value, returns a babase.Lstr with: (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).

WARNING: the underlying Lstr value is somewhat large so don't use this to rapidly update Node text values for an onscreen timer or you may consume significant network bandwidth. For that purpose you should use a 'timedisplay' Node and attribute connections.

class UIScale(enum.Enum):
59class UIScale(Enum):
60    """The overall scale the UI is being rendered for. Note that this is
61    independent of pixel resolution. For example, a phone and a desktop PC
62    might render the game at similar pixel resolutions but the size they
63    display content at will vary significantly.
64
65    'large' is used for devices such as desktop PCs where fine details can
66       be clearly seen. UI elements are generally smaller on the screen
67       and more content can be seen at once.
68
69    'medium' is used for devices such as tablets, TVs, or VR headsets.
70       This mode strikes a balance between clean readability and amount of
71       content visible.
72
73    'small' is used primarily for phones or other small devices where
74       content needs to be presented as large and clear in order to remain
75       readable from an average distance.
76    """
77
78    SMALL = 0
79    MEDIUM = 1
80    LARGE = 2

The overall scale the UI is being rendered for. Note that this is independent of pixel resolution. For example, a phone and a desktop PC might render the game at similar pixel resolutions but the size they display content at will vary significantly.

'large' is used for devices such as desktop PCs where fine details can be clearly seen. UI elements are generally smaller on the screen and more content can be seen at once.

'medium' is used for devices such as tablets, TVs, or VR headsets. This mode strikes a balance between clean readability and amount of content visible.

'small' is used primarily for phones or other small devices where content needs to be presented as large and clear in order to remain readable from an average distance.

SMALL = <UIScale.SMALL: 0>
MEDIUM = <UIScale.MEDIUM: 1>
LARGE = <UIScale.LARGE: 2>
def update_internal_logger_levels() -> None:
1631def update_internal_logger_levels() -> None:
1632    """Update the native layer to re-cache Python logger levels.
1633
1634    The native layer caches logger levels so it can efficiently
1635    avoid making Python log calls for disabled logger levels. If any
1636    logger levels are changed at runtime, call this method after to
1637    instruct the native layer to regenerate its cache so the change
1638    is properly reflected in logs originating from the native layer.
1639    """
1640    return None

Update the native layer to re-cache Python logger levels.

The native layer caches logger levels so it can efficiently avoid making Python log calls for disabled logger levels. If any logger levels are changed at runtime, call this method after to instruct the native layer to regenerate its cache so the change is properly reflected in logs originating from the native layer.

def utc_now_cloud() -> datetime.datetime:
29def utc_now_cloud() -> datetime.datetime:
30    """Returns estimated utc time regardless of local clock settings.
31
32    Applies offsets pulled from server communication/etc.
33    """
34    # TODO: wire this up. Just using local time for now. Make sure that
35    # BaseFeatureSet::TimeSinceEpochCloudSeconds() and this are synced
36    # up.
37    return utc_now()

Returns estimated utc time regardless of local clock settings.

Applies offsets pulled from server communication/etc.

def utf8_all(data: Any) -> Any:
 89def utf8_all(data: Any) -> Any:
 90    """Convert any unicode data in provided sequence(s) to utf8 bytes."""
 91    if isinstance(data, dict):
 92        return dict(
 93            (utf8_all(key), utf8_all(value))
 94            for key, value in list(data.items())
 95        )
 96    if isinstance(data, list):
 97        return [utf8_all(element) for element in data]
 98    if isinstance(data, tuple):
 99        return tuple(utf8_all(element) for element in data)
100    if isinstance(data, str):
101        return data.encode('utf-8', errors='ignore')
102    return data

Convert any unicode data in provided sequence(s) to utf8 bytes.

class Vec3(typing.Sequence[float]):
388class Vec3(Sequence[float]):
389    """A vector of 3 floats.
390
391    These can be created the following ways (checked in this order):
392    - with no args, all values are set to 0
393    - with a single numeric arg, all values are set to that value
394    - with a single three-member sequence arg, sequence values are copied
395    - otherwise assumes individual x/y/z args (positional or keywords)
396    """
397
398    x: float
399    """The vector's X component."""
400
401    y: float
402    """The vector's Y component."""
403
404    z: float
405    """The vector's Z component."""
406
407    # pylint: disable=function-redefined
408
409    @overload
410    def __init__(self) -> None:
411        pass
412
413    @overload
414    def __init__(self, value: float):
415        pass
416
417    @overload
418    def __init__(self, values: Sequence[float]):
419        pass
420
421    @overload
422    def __init__(self, x: float, y: float, z: float):
423        pass
424
425    def __init__(self, *args: Any, **kwds: Any):
426        pass
427
428    def __add__(self, other: Vec3) -> Vec3:
429        return self
430
431    def __sub__(self, other: Vec3) -> Vec3:
432        return self
433
434    @overload
435    def __mul__(self, other: float) -> Vec3:
436        return self
437
438    @overload
439    def __mul__(self, other: Sequence[float]) -> Vec3:
440        return self
441
442    def __mul__(self, other: Any) -> Any:
443        return self
444
445    @overload
446    def __rmul__(self, other: float) -> Vec3:
447        return self
448
449    @overload
450    def __rmul__(self, other: Sequence[float]) -> Vec3:
451        return self
452
453    def __rmul__(self, other: Any) -> Any:
454        return self
455
456    # (for index access)
457    @override
458    def __getitem__(self, typeargs: Any) -> Any:
459        return 0.0
460
461    @override
462    def __len__(self) -> int:
463        return 3
464
465    # (for iterator access)
466    @override
467    def __iter__(self) -> Any:
468        return self
469
470    def __next__(self) -> float:
471        return 0.0
472
473    def __neg__(self) -> Vec3:
474        return self
475
476    def __setitem__(self, index: int, val: float) -> None:
477        pass
478
479    def cross(self, other: Vec3) -> Vec3:
480        """Returns the cross product of this vector and another."""
481        return Vec3()
482
483    def dot(self, other: Vec3) -> float:
484        """Returns the dot product of this vector and another."""
485        return float()
486
487    def length(self) -> float:
488        """Returns the length of the vector."""
489        return float()
490
491    def normalized(self) -> Vec3:
492        """Returns a normalized version of the vector."""
493        return Vec3()

A vector of 3 floats.

These can be created the following ways (checked in this order):

  • with no args, all values are set to 0
  • with a single numeric arg, all values are set to that value
  • with a single three-member sequence arg, sequence values are copied
  • otherwise assumes individual x/y/z args (positional or keywords)
Vec3(*args: Any, **kwds: Any)
425    def __init__(self, *args: Any, **kwds: Any):
426        pass
x: float

The vector's X component.

y: float

The vector's Y component.

z: float

The vector's Z component.

def cross(self, other: _babase.Vec3) -> _babase.Vec3:
479    def cross(self, other: Vec3) -> Vec3:
480        """Returns the cross product of this vector and another."""
481        return Vec3()

Returns the cross product of this vector and another.

def dot(self, other: _babase.Vec3) -> float:
483    def dot(self, other: Vec3) -> float:
484        """Returns the dot product of this vector and another."""
485        return float()

Returns the dot product of this vector and another.

def length(self) -> float:
487    def length(self) -> float:
488        """Returns the length of the vector."""
489        return float()

Returns the length of the vector.

def normalized(self) -> _babase.Vec3:
491    def normalized(self) -> Vec3:
492        """Returns a normalized version of the vector."""
493        return Vec3()

Returns a normalized version of the vector.

def vec3validate(value: Sequence[float]) -> Sequence[float]:
15def vec3validate(value: Sequence[float]) -> Sequence[float]:
16    """Ensure a value is valid for use as a Vec3.
17
18    category: General Utility Functions
19
20    Raises a TypeError exception if not.
21    Valid values include any type of sequence consisting of 3 numeric values.
22    Returns the same value as passed in (but with a definite type
23    so this can be used to disambiguate 'Any' types).
24    Generally this should be used in 'if __debug__' or assert clauses
25    to keep runtime overhead minimal.
26    """
27    from numbers import Number
28
29    if not isinstance(value, abc.Sequence):
30        raise TypeError(f"Expected a sequence; got {type(value)}")
31    if len(value) != 3:
32        raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
33    if not all(isinstance(i, Number) for i in value):
34        raise TypeError(f"Non-numeric value passed for vec3: {value}")
35    return value

Ensure a value is valid for use as a Vec3.

category: General Utility Functions

Raises a TypeError exception if not. Valid values include any type of sequence consisting of 3 numeric values. Returns the same value as passed in (but with a definite type so this can be used to disambiguate 'Any' types). Generally this should be used in 'if __debug__' or assert clauses to keep runtime overhead minimal.

def verify_object_death(obj: object) -> None:
299def verify_object_death(obj: object) -> None:
300    """Warn if an object does not get freed within a short period.
301
302    This can be handy to detect and prevent memory/resource leaks.
303    """
304
305    try:
306        ref = weakref.ref(obj)
307    except Exception:
308        logging.exception('Unable to create weak-ref in verify_object_death')
309        return
310
311    # Use a slight range for our checks so they don't all land at once
312    # if we queue a lot of them.
313    delay = random.uniform(2.0, 5.5)
314
315    # Make this timer in an empty context; don't want it dying with the
316    # scene/etc.
317    with _babase.ContextRef.empty():
318        _babase.apptimer(delay, Call(_verify_object_death, ref))

Warn if an object does not get freed within a short period.

This can be handy to detect and prevent memory/resource leaks.

WeakCall = <class 'babase._general._WeakCall'>
class WidgetNotFoundError(babase.NotFoundError):
73class WidgetNotFoundError(NotFoundError):
74    """Exception raised when an expected widget does not exist."""

Exception raised when an expected widget does not exist.

DEFAULT_REQUEST_TIMEOUT_SECONDS = 60