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 directly.
  6Instead one should use purpose-built packages such as bascenev1 or bauiv1
  7which themselves import various functionality from here and reexpose it in
  8a 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    getsimplesound,
 64    has_user_run_commands,
 65    have_chars,
 66    have_permission,
 67    in_logic_thread,
 68    in_main_menu,
 69    increment_analytics_count,
 70    invoke_main_menu,
 71    is_os_playing_music,
 72    is_xcode_build,
 73    lock_all_input,
 74    mac_music_app_get_playlists,
 75    mac_music_app_get_volume,
 76    mac_music_app_init,
 77    mac_music_app_play_playlist,
 78    mac_music_app_set_volume,
 79    mac_music_app_stop,
 80    music_player_play,
 81    music_player_set_volume,
 82    music_player_shutdown,
 83    music_player_stop,
 84    native_review_request,
 85    native_review_request_supported,
 86    native_stack_trace,
 87    open_file_externally,
 88    open_url,
 89    overlay_web_browser_close,
 90    overlay_web_browser_is_open,
 91    overlay_web_browser_is_supported,
 92    overlay_web_browser_open_url,
 93    print_load_info,
 94    push_back_press,
 95    pushcall,
 96    quit,
 97    reload_media,
 98    request_permission,
 99    safecolor,
100    screenmessage,
101    set_analytics_screen,
102    set_low_level_config_value,
103    set_thread_name,
104    set_ui_account_state,
105    set_ui_input_device,
106    set_ui_scale,
107    show_progress_bar,
108    shutdown_suppress_begin,
109    shutdown_suppress_end,
110    shutdown_suppress_count,
111    SimpleSound,
112    supports_max_fps,
113    supports_vsync,
114    supports_unicode_display,
115    unlock_all_input,
116    update_internal_logger_levels,
117    user_agent_string,
118    user_ran_commands,
119    Vec3,
120    workspaces_in_use,
121)
122
123from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
124from babase._app import App
125from babase._appconfig import commit_app_config
126from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
127from babase._appmode import AppMode
128from babase._appsubsystem import AppSubsystem
129from babase._appmodeselector import AppModeSelector
130from babase._appconfig import AppConfig
131from babase._apputils import (
132    handle_leftover_v1_cloud_log_file,
133    is_browser_likely_available,
134    garbage_collect,
135    get_remote_app_name,
136    AppHealthMonitor,
137    utc_now_cloud,
138)
139from babase._cloud import CloudSubscription
140from babase._devconsole import (
141    DevConsoleTab,
142    DevConsoleTabEntry,
143    DevConsoleSubsystem,
144)
145from babase._emptyappmode import EmptyAppMode
146from babase._error import (
147    print_exception,
148    print_error,
149    ContextError,
150    NotFoundError,
151    PlayerNotFoundError,
152    SessionPlayerNotFoundError,
153    NodeNotFoundError,
154    ActorNotFoundError,
155    InputDeviceNotFoundError,
156    WidgetNotFoundError,
157    ActivityNotFoundError,
158    TeamNotFoundError,
159    MapNotFoundError,
160    SessionTeamNotFoundError,
161    SessionNotFoundError,
162    DelegateNotFoundError,
163)
164from babase._general import (
165    utf8_all,
166    DisplayTime,
167    AppTime,
168    WeakCall,
169    Call,
170    existing,
171    Existable,
172    verify_object_death,
173    storagename,
174    getclass,
175    get_type_name,
176)
177from babase._language import Lstr, LanguageSubsystem
178from babase._logging import balog, applog, lifecyclelog
179from babase._login import LoginAdapter, LoginInfo
180
181from babase._mgen.enums import (
182    Permission,
183    SpecialChar,
184    InputType,
185    UIScale,
186    QuitType,
187)
188from babase._math import normalized_color, is_point_in_box, vec3validate
189from babase._meta import MetadataSubsystem
190from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS
191from babase._plugin import PluginSpec, Plugin, PluginSubsystem
192from babase._stringedit import StringEditAdapter, StringEditSubsystem
193from babase._text import timestring
194
195
196_babase.app = app = App()
197app.postinit()
198
199__all__ = [
200    'AccountV2Handle',
201    'AccountV2Subsystem',
202    'ActivityNotFoundError',
203    'ActorNotFoundError',
204    'allows_ticket_sales',
205    'add_clean_frame_callback',
206    'android_get_external_files_dir',
207    'app',
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_v1_cloud_log_file_path',
274    'getclass',
275    'getsimplesound',
276    'handle_leftover_v1_cloud_log_file',
277    'has_user_run_commands',
278    'have_chars',
279    'have_permission',
280    'in_logic_thread',
281    'in_main_menu',
282    'increment_analytics_count',
283    'InputDeviceNotFoundError',
284    'InputType',
285    'invoke_main_menu',
286    'is_browser_likely_available',
287    'is_browser_likely_available',
288    'is_os_playing_music',
289    'is_point_in_box',
290    'is_xcode_build',
291    'LanguageSubsystem',
292    'lifecyclelog',
293    'lock_all_input',
294    'LoginAdapter',
295    'LoginInfo',
296    'Lstr',
297    'mac_music_app_get_playlists',
298    'mac_music_app_get_volume',
299    'mac_music_app_init',
300    'mac_music_app_play_playlist',
301    'mac_music_app_set_volume',
302    'mac_music_app_stop',
303    'MapNotFoundError',
304    'MetadataSubsystem',
305    'music_player_play',
306    'music_player_set_volume',
307    'music_player_shutdown',
308    'music_player_stop',
309    'native_review_request',
310    'native_review_request_supported',
311    'native_stack_trace',
312    'NodeNotFoundError',
313    'normalized_color',
314    'NotFoundError',
315    'open_file_externally',
316    'open_url',
317    'overlay_web_browser_close',
318    'overlay_web_browser_is_open',
319    'overlay_web_browser_is_supported',
320    'overlay_web_browser_open_url',
321    'Permission',
322    'PlayerNotFoundError',
323    'Plugin',
324    'PluginSubsystem',
325    'PluginSpec',
326    'print_error',
327    'print_exception',
328    'print_load_info',
329    'push_back_press',
330    'pushcall',
331    'quit',
332    'QuitType',
333    'reload_media',
334    'request_permission',
335    'safecolor',
336    'screenmessage',
337    'SessionNotFoundError',
338    'SessionPlayerNotFoundError',
339    'SessionTeamNotFoundError',
340    'set_analytics_screen',
341    'set_low_level_config_value',
342    'set_thread_name',
343    'set_ui_account_state',
344    'set_ui_input_device',
345    'set_ui_scale',
346    'show_progress_bar',
347    'shutdown_suppress_begin',
348    'shutdown_suppress_end',
349    'shutdown_suppress_count',
350    'SimpleSound',
351    'SpecialChar',
352    'storagename',
353    'StringEditAdapter',
354    'StringEditSubsystem',
355    'supports_max_fps',
356    'supports_vsync',
357    'supports_unicode_display',
358    'TeamNotFoundError',
359    'timestring',
360    'UIScale',
361    'unlock_all_input',
362    'update_internal_logger_levels',
363    'user_agent_string',
364    'user_ran_commands',
365    'utc_now_cloud',
366    'utf8_all',
367    'Vec3',
368    'vec3validate',
369    'verify_object_death',
370    'WeakCall',
371    'WidgetNotFoundError',
372    'workspaces_in_use',
373    'DEFAULT_REQUEST_TIMEOUT_SECONDS',
374]
375
376# We want stuff to show up as babase.Foo instead of babase._sub.Foo.
377set_canonical_module_names(globals())
378
379# Allow the native layer to wrap a few things up.
380_babase.reached_end_of_babase()
381
382# Marker we pop down at the very end so other modules can run sanity
383# checks to make sure we aren't importing them reciprocally when they
384# import us.
385_REACHED_END_OF_MODULE = True
class AccountV2Handle:
442class AccountV2Handle:
443    """Handle for interacting with a V2 account.
444
445    This class supports the 'with' statement, which is how it is
446    used with some operations such as cloud messaging.
447    """
448
449    accountid: str
450    tag: str
451    workspacename: str | None
452    workspaceid: str | None
453    logins: dict[LoginType, LoginInfo]
454
455    def __enter__(self) -> None:
456        """Support for "with" statement.
457
458        This allows cloud messages to be sent on our behalf.
459        """
460
461    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
462        """Support for "with" statement.
463
464        This allows cloud messages to be sent on our behalf.
465        """

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

Subsystem for modern account handling in the app.

Category: App Classes

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:
74    def on_app_loading(self) -> None:
75        """Should be called at standard on_app_loading time."""
76
77        for adapter in self.login_adapters.values():
78            adapter.on_app_loading()

Should be called at standard on_app_loading time.

def have_primary_credentials(self) -> bool:
80    def have_primary_credentials(self) -> bool:
81        """Are credentials currently set for the primary app account?
82
83        Note that this does not mean these credentials have been checked
84        for validity; only that they exist. If/when credentials are
85        validated, the 'primary' account handle will be set.
86        """
87        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
89    @property
90    def primary(self) -> AccountV2Handle | None:
91        """The primary account for the app, or None if not logged in."""
92        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:
 94    def on_primary_account_changed(
 95        self, account: AccountV2Handle | None
 96    ) -> None:
 97        """Callback run after the primary account changes.
 98
 99        Will be called with None on log-outs and when new credentials
100        are set but have not yet been verified.
101        """
102        assert _babase.in_logic_thread()
103
104        # Fire any registered callbacks.
105        for call in self.on_primary_account_changed_callbacks.getcalls():
106            try:
107                call(account)
108            except Exception:
109                logging.exception('Error in primary-account-changed callback.')
110
111        # Currently don't do anything special on sign-outs.
112        if account is None:
113            return
114
115        # If this new account has a workspace, update it and ask to be
116        # informed when that process completes.
117        if account.workspaceid is not None:
118            assert account.workspacename is not None
119            if (
120                not self._initial_sign_in_completed
121                and not self._kicked_off_workspace_load
122            ):
123                self._kicked_off_workspace_load = True
124                _babase.app.workspaces.set_active_workspace(
125                    account=account,
126                    workspaceid=account.workspaceid,
127                    workspacename=account.workspacename,
128                    on_completed=self._on_set_active_workspace_completed,
129                )
130            else:
131                # Don't activate workspaces if we've already told the
132                # game that initial-log-in is done or if we've already
133                # kicked off a workspace load.
134                _babase.screenmessage(
135                    f'\'{account.workspacename}\''
136                    f' will be activated at next app launch.',
137                    color=(1, 1, 0),
138                )
139                _babase.getsimplesound('error').play()
140            return
141
142        # Ok; no workspace to worry about; carry on.
143        if not self._initial_sign_in_completed:
144            self._initial_sign_in_completed = True
145            _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:
147    def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
148        """Should be called when logins for the active account change."""
149
150        for adapter in self.login_adapters.values():
151            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:
153    def on_implicit_sign_in(
154        self, login_type: LoginType, login_id: str, display_name: str
155    ) -> None:
156        """An implicit sign-in happened (called by native layer)."""
157        from babase._login import LoginAdapter
158
159        assert _babase.in_logic_thread()
160
161        with _babase.ContextRef.empty():
162            self.login_adapters[login_type].set_implicit_login_state(
163                LoginAdapter.ImplicitLoginState(
164                    login_id=login_id, display_name=display_name
165                )
166            )

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

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

Internal - should be overridden by subclass.

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

Set credentials for the primary app account.

class ActivityNotFoundError(babase.NotFoundError):
89class ActivityNotFoundError(NotFoundError):
90    """Exception raised when an expected bascenev1.Activity does not exist.
91
92    Category: **Exception Classes**
93    """

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

Category: Exception Classes

class ActorNotFoundError(babase.NotFoundError):
82class ActorNotFoundError(NotFoundError):
83    """Exception raised when an expected actor does not exist.
84
85    Category: **Exception Classes**
86    """

Exception raised when an expected actor does not exist.

Category: Exception Classes

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

A class for high level app functionality and state.

Category: App Classes

Use babase.app to access the single shared instance of this class.

Note that properties not documented here should be considered internal and subject to change without warning.

plugins: PluginSubsystem
health_monitor: AppHealthMonitor
SHUTDOWN_TASK_TIMEOUT_SECONDS = 5
config
env: _babase.Env
state
threadpool
meta
net
workspaces
components
stringedit
devconsole
fg_state
def postinit(self) -> None:
237    def postinit(self) -> None:
238        """Called after we've been inited and assigned to babase.app.
239
240        Anything that accesses babase.app as part of its init process
241        must go here instead of __init__.
242        """
243
244        # Hack for docs-generation: We can be imported with dummy
245        # modules instead of our actual binary ones, but we don't
246        # function.
247        if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
248            return
249
250        self.lang = LanguageSubsystem()
251        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
253    @property
254    def active(self) -> bool:
255        """Whether the app is currently front and center.
256
257        This will be False when the app is hidden, other activities
258        are covering it, etc. (depending on the platform).
259        """
260        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
262    @property
263    def mode(self) -> AppMode | None:
264        """The app's current mode."""
265        assert _babase.in_logic_thread()
266        return self._mode

The app's current mode.

asyncio_loop: asyncio.events.AbstractEventLoop
268    @property
269    def asyncio_loop(self) -> asyncio.AbstractEventLoop:
270        """The logic thread's asyncio event loop.
271
272        This allow async tasks to be run in the logic thread.
273
274        Generally you should call App.create_async_task() to schedule
275        async code to run instead of using this directly. That will
276        handle retaining the task and logging errors automatically.
277        Only schedule tasks onto asyncio_loop yourself when you intend
278        to hold on to the returned task and await its results. Releasing
279        the task reference can lead to subtle bugs such as unreported
280        errors and garbage-collected tasks disappearing before their
281        work is done.
282
283        Note that, at this time, the asyncio loop is encapsulated
284        and explicitly stepped by the engine's logic thread loop and
285        thus things like asyncio.get_running_loop() will unintuitively
286        *not* return this loop from most places in the logic thread;
287        only from within a task explicitly created in this loop.
288        Hopefully this situation will be improved in the future with a
289        unified event loop.
290        """
291        assert _babase.in_logic_thread()
292        assert self._asyncio_loop is not None
293        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:
295    def create_async_task(
296        self, coro: Coroutine[Any, Any, T], *, name: str | None = None
297    ) -> None:
298        """Create a fully managed async task.
299
300        This will automatically retain and release a reference to the task
301        and log any exceptions that occur in it. If you need to await a task
302        or otherwise need more control, schedule a task directly using
303        App.asyncio_loop.
304        """
305        assert _babase.in_logic_thread()
306
307        # We hold a strong reference to the task until it is done.
308        # Otherwise it is possible for it to be garbage collected and
309        # disappear midway if the caller does not hold on to the
310        # returned task, which seems like a great way to introduce
311        # hard-to-track bugs.
312        task = self.asyncio_loop.create_task(coro, name=name)
313        self._asyncio_tasks.add(task)
314        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
329    @property
330    def mode_selector(self) -> babase.AppModeSelector:
331        """Controls which app-modes are used for handling given intents.
332
333        Plugins can override this to change high level app behavior and
334        spinoff projects can change the default implementation for the
335        same effect.
336        """
337        if self._mode_selector is None:
338            raise RuntimeError(
339                'mode_selector cannot be used until the app reaches'
340                ' the running state.'
341            )
342        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
398    @property
399    def classic(self) -> ClassicAppSubsystem | None:
400        """Our classic subsystem (if available)."""
401        return self._get_subsystem_property(
402            'classic', self._create_classic_subsystem
403        )  # type: ignore

Our classic subsystem (if available).

plus: baplus.PlusAppSubsystem | None
418    @property
419    def plus(self) -> PlusAppSubsystem | None:
420        """Our plus subsystem (if available)."""
421        return self._get_subsystem_property(
422            'plus', self._create_plus_subsystem
423        )  # type: ignore

Our plus subsystem (if available).

ui_v1: bauiv1.UIV1AppSubsystem
438    @property
439    def ui_v1(self) -> UIV1AppSubsystem:
440        """Our ui_v1 subsystem (always available)."""
441        return self._get_subsystem_property(
442            'ui_v1', self._create_ui_v1_subsystem
443        )  # type: ignore

Our ui_v1 subsystem (always available).

def register_subsystem(self, subsystem: AppSubsystem) -> None:
455    def register_subsystem(self, subsystem: AppSubsystem) -> None:
456        """Called by the AppSubsystem class. Do not use directly."""
457
458        # We only allow registering new subsystems if we've not yet
459        # reached the 'running' state. This ensures that all subsystems
460        # receive a consistent set of callbacks starting with
461        # on_app_running().
462
463        if self._subsystem_registration_ended:
464            raise RuntimeError(
465                'Subsystems can no longer be registered at this point.'
466            )
467        self._subsystems.append(subsystem)

Called by the AppSubsystem class. Do not use directly.

def add_shutdown_task(self, coro: Coroutine[NoneType, NoneType, NoneType]) -> None:
469    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
470        """Add a task to be run on app shutdown.
471
472        Note that shutdown tasks will be canceled after
473        App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.
474        """
475        if (
476            self.state is self.State.SHUTTING_DOWN
477            or self.state is self.State.SHUTDOWN_COMPLETE
478        ):
479            stname = self.state.name
480            raise RuntimeError(
481                f'Cannot add shutdown tasks with current state {stname}.'
482            )
483        self._shutdown_tasks.append(coro)

Add a task to be run on app shutdown.

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

def run(self) -> None:
485    def run(self) -> None:
486        """Run the app to completion.
487
488        Note that this only works on builds where Ballistica manages
489        its own event loop.
490        """
491        _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:
493    def set_intent(self, intent: AppIntent) -> None:
494        """Set the intent for the app.
495
496        Intent defines what the app is trying to do at a given time.
497        This call is asynchronous; the intent switch will happen in the
498        logic thread in the near future. If set_intent is called
499        repeatedly before the change takes place, the final intent to be
500        set will be used.
501        """
502
503        # Mark this one as pending. We do this synchronously so that the
504        # last one marked actually takes effect if there is overlap
505        # (doing this in the bg thread could result in race conditions).
506        self._pending_intent = intent
507
508        # Do the actual work of calcing our app-mode/etc. in a bg thread
509        # since it may block for a moment to load modules/etc.
510        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:
512    def push_apply_app_config(self) -> None:
513        """Internal. Use app.config.apply() to apply app config changes."""
514        # To be safe, let's run this by itself in the event loop.
515        # This avoids potential trouble if this gets called mid-draw or
516        # something like that.
517        self._pending_apply_app_config = True
518        _babase.pushcall(self._apply_app_config, raw=True)

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

def on_native_start(self) -> None:
520    def on_native_start(self) -> None:
521        """Called by the native layer when the app is being started."""
522        assert _babase.in_logic_thread()
523        assert not self._native_start_called
524        self._native_start_called = True
525        self._update_state()

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

def on_native_bootstrapping_complete(self) -> None:
527    def on_native_bootstrapping_complete(self) -> None:
528        """Called by the native layer once its ready to rock."""
529        assert _babase.in_logic_thread()
530        assert not self._native_bootstrapping_completed
531        self._native_bootstrapping_completed = True
532        self._update_state()

Called by the native layer once its ready to rock.

def on_native_suspend(self) -> None:
534    def on_native_suspend(self) -> None:
535        """Called by the native layer when the app is suspended."""
536        assert _babase.in_logic_thread()
537        assert not self._native_suspended  # Should avoid redundant calls.
538        self._native_suspended = True
539        self._update_state()

Called by the native layer when the app is suspended.

def on_native_unsuspend(self) -> None:
541    def on_native_unsuspend(self) -> None:
542        """Called by the native layer when the app suspension ends."""
543        assert _babase.in_logic_thread()
544        assert self._native_suspended  # Should avoid redundant calls.
545        self._native_suspended = False
546        self._update_state()

Called by the native layer when the app suspension ends.

def on_native_shutdown(self) -> None:
548    def on_native_shutdown(self) -> None:
549        """Called by the native layer when the app starts shutting down."""
550        assert _babase.in_logic_thread()
551        self._native_shutdown_called = True
552        self._update_state()

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

def on_native_shutdown_complete(self) -> None:
554    def on_native_shutdown_complete(self) -> None:
555        """Called by the native layer when the app is done shutting down."""
556        assert _babase.in_logic_thread()
557        self._native_shutdown_complete_called = True
558        self._update_state()

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

def on_native_active_changed(self) -> None:
560    def on_native_active_changed(self) -> None:
561        """Called by the native layer when the app active state changes."""
562        assert _babase.in_logic_thread()
563        if self._mode is not None:
564            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:
586    def on_initial_sign_in_complete(self) -> None:
587        """Called when initial sign-in (or lack thereof) completes.
588
589        This normally gets called by the plus subsystem. The
590        initial-sign-in process may include tasks such as syncing
591        account workspaces or other data so it may take a substantial
592        amount of time.
593        """
594        assert _babase.in_logic_thread()
595        assert not self._initial_sign_in_completed
596
597        # Tell meta it can start scanning extra stuff that just showed
598        # up (namely account workspaces).
599        self.meta.start_extra_scan()
600
601        self._initial_sign_in_completed = True
602        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:
604    def set_ui_scale(self, scale: babase.UIScale) -> None:
605        """Change ui-scale on the fly.
606
607        Currently this is mainly for debugging and will not
608        be called as part of normal app operation.
609        """
610        assert _babase.in_logic_thread()
611
612        # Apply to the native layer.
613        _babase.set_ui_scale(scale.name.lower())
614
615        # Inform all subsystems that something screen-related has
616        # changed. We assume subsystems won't be added at this point so
617        # we can use the list directly.
618        assert self._subsystem_registration_ended
619        for subsystem in self._subsystems:
620            try:
621                subsystem.on_screen_change()
622            except Exception:
623                logging.exception(
624                    'Error in on_screen_change() for subsystem %s.', subsystem
625                )

Change ui-scale on the fly.

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

class App.State(enum.Enum):
 76    class State(Enum):
 77        """High level state the app can be in."""
 78
 79        # The app has not yet begun starting and should not be used in
 80        # any way.
 81        NOT_STARTED = 0
 82
 83        # The native layer is spinning up its machinery (screens,
 84        # renderers, etc.). Nothing should happen in the Python layer
 85        # until this completes.
 86        NATIVE_BOOTSTRAPPING = 1
 87
 88        # Python app subsystems are being inited but should not yet
 89        # interact or do any work.
 90        INITING = 2
 91
 92        # Python app subsystems are inited and interacting, but the app
 93        # has not yet embarked on a high level course of action. It is
 94        # doing initial account logins, workspace & asset downloads,
 95        # etc.
 96        LOADING = 3
 97
 98        # All pieces are in place and the app is now doing its thing.
 99        RUNNING = 4
100
101        # Used on platforms such as mobile where the app basically needs
102        # to shut down while backgrounded. In this state, all event
103        # loops are suspended and all graphics and audio must cease
104        # completely. Be aware that the suspended state can be entered
105        # from any other state including NATIVE_BOOTSTRAPPING and
106        # SHUTTING_DOWN.
107        SUSPENDED = 5
108
109        # The app is shutting down. This process may involve sending
110        # network messages or other things that can take up to a few
111        # seconds, so ideally graphics and audio should remain
112        # functional (with fades or spinners or whatever to show
113        # something is happening).
114        SHUTTING_DOWN = 6
115
116        # The app has completed shutdown. Any code running here should
117        # be basically immediate.
118        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):
120    class DefaultAppModeSelector(AppModeSelector):
121        """Decides which AppModes to use to handle AppIntents.
122
123        This default version is generated by the project updater based
124        on the 'default_app_modes' value in the projectconfig.
125
126        It is also possible to modify app mode selection behavior by
127        setting app.mode_selector to an instance of a custom
128        AppModeSelector subclass. This is a good way to go if you are
129        modifying app behavior dynamically via a plugin instead of
130        statically in a spinoff project.
131        """
132
133        @override
134        def app_mode_for_intent(
135            self, intent: AppIntent
136        ) -> type[AppMode] | None:
137            # pylint: disable=cyclic-import
138
139            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
140            # This section generated by batools.appmodule; do not edit.
141
142            # Ask our default app modes to handle it.
143            # (generated from 'default_app_modes' in projectconfig).
144            import baclassic
145            import babase
146
147            for appmode in [
148                baclassic.ClassicAppMode,
149                babase.EmptyAppMode,
150            ]:
151                if appmode.can_handle_intent(intent):
152                    return appmode
153
154            return None
155
156            # __DEFAULT_APP_MODE_SELECTION_END__

Decides which AppModes 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:
133        @override
134        def app_mode_for_intent(
135            self, intent: AppIntent
136        ) -> type[AppMode] | None:
137            # pylint: disable=cyclic-import
138
139            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
140            # This section generated by batools.appmodule; do not edit.
141
142            # Ask our default app modes to handle it.
143            # (generated from 'default_app_modes' in projectconfig).
144            import baclassic
145            import babase
146
147            for appmode in [
148                baclassic.ClassicAppMode,
149                babase.EmptyAppMode,
150            ]:
151                if appmode.can_handle_intent(intent):
152                    return appmode
153
154            return None
155
156            # __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 the game's persistent configuration values.
 19
 20    Category: **App Classes**
 21
 22    It also provides methods for fetching values with app-defined fallback
 23    defaults, applying contained values to the game, and committing the
 24    config to storage.
 25
 26    Call babase.appconfig() to get the single shared instance of this class.
 27
 28    AppConfig data is stored as json on disk on so make sure to only place
 29    json-friendly values in it (dict, list, str, float, int, bool).
 30    Be aware that tuples will be quietly converted to lists when 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 the game's persistent configuration values.

Category: App Classes

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

Logs things like app-not-responding issues.

@override
def on_app_loading(self) -> None:
402    @override
403    def on_app_loading(self) -> None:
404        # If any traceback dumps happened last run, log and clear them.
405        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:
466    @override
467    def on_app_suspend(self) -> None:
468        assert _babase.in_logic_thread()
469        self._running = False

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

class AppIntent:
13class AppIntent:
14    """A high level directive given to the app.
15
16    Category: **App Classes**
17    """

A high level directive given to the app.

Category: App Classes

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

Tells the app to simply run in its default mode.

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

Tells the app to exec some Python code.

AppIntentExec(code: str)
27    def __init__(self, code: str):
28        self.code = code
code
class AppMode:
14class AppMode:
15    """A high level mode for the app.
16
17    Category: **App Classes**
18
19    """
20
21    @classmethod
22    def get_app_experience(cls) -> AppExperience:
23        """Return the overall experience provided by this mode."""
24        raise NotImplementedError('AppMode subclasses must override this.')
25
26    @classmethod
27    def can_handle_intent(cls, intent: AppIntent) -> bool:
28        """Return whether this mode can handle the provided intent.
29
30        For this to return True, the AppMode must claim to support the
31        provided intent (via its _can_handle_intent() method) AND the
32        AppExperience associated with the AppMode must be supported by
33        the current app and runtime environment.
34        """
35        # TODO: check AppExperience against current environment.
36        return cls._can_handle_intent(intent)
37
38    @classmethod
39    def _can_handle_intent(cls, intent: AppIntent) -> bool:
40        """Return whether our mode can handle the provided intent.
41
42        AppModes should override this to communicate what they can
43        handle. Note that AppExperience does not have to be considered
44        here; that is handled automatically by the can_handle_intent()
45        call.
46        """
47        raise NotImplementedError('AppMode subclasses must override this.')
48
49    def handle_intent(self, intent: AppIntent) -> None:
50        """Handle an intent."""
51        raise NotImplementedError('AppMode subclasses must override this.')
52
53    def on_activate(self) -> None:
54        """Called when the mode is being activated."""
55
56    def on_deactivate(self) -> None:
57        """Called when the mode is being deactivated."""
58
59    def on_app_active_changed(self) -> None:
60        """Called when ba*.app.active changes while this mode is active.
61
62        The app-mode may want to take action such as pausing a running
63        game in such cases.
64        """

A high level mode for the app.

Category: App Classes

@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
21    @classmethod
22    def get_app_experience(cls) -> AppExperience:
23        """Return the overall experience provided by this mode."""
24        raise NotImplementedError('AppMode subclasses must override this.')

Return the overall experience provided by this mode.

@classmethod
def can_handle_intent(cls, intent: AppIntent) -> bool:
26    @classmethod
27    def can_handle_intent(cls, intent: AppIntent) -> bool:
28        """Return whether this mode can handle the provided intent.
29
30        For this to return True, the AppMode must claim to support the
31        provided intent (via its _can_handle_intent() method) AND the
32        AppExperience associated with the AppMode must be supported by
33        the current app and runtime environment.
34        """
35        # TODO: check AppExperience against current environment.
36        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:
49    def handle_intent(self, intent: AppIntent) -> None:
50        """Handle an intent."""
51        raise NotImplementedError('AppMode subclasses must override this.')

Handle an intent.

def on_activate(self) -> None:
53    def on_activate(self) -> None:
54        """Called when the mode is being activated."""

Called when the mode is being activated.

def on_deactivate(self) -> None:
56    def on_deactivate(self) -> None:
57        """Called when the mode is being deactivated."""

Called when the mode is being deactivated.

def on_app_active_changed(self) -> None:
59    def on_app_active_changed(self) -> None:
60        """Called when ba*.app.active changes while this mode is active.
61
62        The app-mode may want to take action such as pausing a running
63        game in such cases.
64        """

Called when ba*.app.active changes while this mode is active.

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

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

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

Category: App Classes

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:
25    def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
26        """Given an AppIntent, return the AppMode that should handle it.
27
28        If None is returned, the AppIntent will be ignored.
29
30        This may be called in a background thread, so avoid any calls
31        limited to logic thread use/etc.
32        """
33        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    Category: **App Classes**
19
20    An app 'subsystem' is a bit of a vague term, as pieces of the app
21    can technically be any class and are not required to use this, but
22    building one out of this base class provides conveniences such as
23    predefined callbacks during app state changes.
24
25    Subsystems must be registered with the app before it completes its
26    transition to the 'running' state.
27    """
28
29    def __init__(self) -> None:
30        _babase.app.register_subsystem(self)
31
32    def on_app_loading(self) -> None:
33        """Called when the app reaches the loading state.
34
35        Note that subsystems created after the app switches to the
36        loading state will not receive this callback. Subsystems created
37        by plugins are an example of this.
38        """
39
40    def on_app_running(self) -> None:
41        """Called when the app reaches the running state."""
42
43    def on_app_suspend(self) -> None:
44        """Called when the app enters the suspended state."""
45
46    def on_app_unsuspend(self) -> None:
47        """Called when the app exits the suspended state."""
48
49    def on_app_shutdown(self) -> None:
50        """Called when the app begins shutting down."""
51
52    def on_app_shutdown_complete(self) -> None:
53        """Called when the app completes shutting down."""
54
55    def do_apply_app_config(self) -> None:
56        """Called when the app config should be applied."""
57
58    def on_screen_change(self) -> None:
59        """Called when screen dimensions or ui-scale changes."""
60
61    def reset(self) -> None:
62        """Reset the subsystem to a default state.
63
64        This is called when switching app modes, but may be called
65        at other times too.
66        """

Base class for an app subsystem.

Category: App Classes

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

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:
40    def on_app_running(self) -> None:
41        """Called when the app reaches the running state."""

Called when the app reaches the running state.

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

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

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

Called when the app begins shutting down.

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

Called when the app completes shutting down.

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

Called when the app config should be applied.

def on_screen_change(self) -> None:
58    def on_screen_change(self) -> None:
59        """Called when screen dimensions or ui-scale changes."""

Called when screen dimensions or ui-scale changes.

def reset(self) -> None:
61    def reset(self) -> None:
62        """Reset the subsystem to a default state.
63
64        This is called when switching app modes, but may be called
65        at other times too.
66        """

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:
554def apptime() -> babase.AppTime:
555    """Return the current app-time in seconds.
556
557    Category: **General Utility Functions**
558
559    App-time is a monotonic time value; it starts at 0.0 when the app
560    launches and will never jump by large amounts or go backwards, even if
561    the system time changes. Its progression will pause when the app is in
562    a suspended state.
563
564    Note that the AppTime returned here is simply float; it just has a
565    unique type in the type-checker's eyes to help prevent it from being
566    accidentally used with time functionality expecting other time types.
567    """
568    import babase  # pylint: disable=cyclic-import
569
570    return babase.AppTime(0.0)

Return the current app-time in seconds.

Category: General Utility Functions

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:
573def apptimer(time: float, call: Callable[[], Any]) -> None:
574    """Schedule a callable object to run based on app-time.
575
576    Category: **General Utility Functions**
577
578    This function creates a one-off timer which cannot be canceled or
579    modified once created. If you require the ability to do so, or need
580    a repeating timer, use the babase.AppTimer class instead.
581
582    ##### Arguments
583    ###### time (float)
584    > Length of time in seconds that the timer will wait before firing.
585
586    ###### call (Callable[[], Any])
587    > A callable Python object. Note that the timer will retain a
588    strong reference to the callable for as long as the timer exists, so you
589    may want to look into concepts such as babase.WeakCall if that is not
590    desired.
591
592    ##### Examples
593    Print some stuff through time:
594    >>> babase.screenmessage('hello from now!')
595    >>> babase.apptimer(1.0, babase.Call(babase.screenmessage,
596                              'hello from the future!'))
597    >>> babase.apptimer(2.0, babase.Call(babase.screenmessage,
598    ...                       'hello from the future 2!'))
599    """
600    return None

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

Category: General Utility Functions

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.

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

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

Category: General Utility Classes

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)
95    def __init__(
96        self, time: float, call: Callable[[], Any], repeat: bool = False
97    ) -> None:
98        pass
balog = <Logger ba (WARNING)>
Call = <class 'babase._general._Call'>
def charstr(char_id: SpecialChar) -> str:
618def charstr(char_id: babase.SpecialChar) -> str:
619    """Get a unicode string representing a special character.
620
621    Category: **General Utility Functions**
622
623    Note that these utilize the private-use block of unicode characters
624    (U+E000-U+F8FF) and are specific to the game; exporting or rendering
625    them elsewhere will be meaningless.
626
627    See babase.SpecialChar for the list of available characters.
628    """
629    return str()

Get a unicode string representing a special character.

Category: General Utility Functions

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:
632def clipboard_get_text() -> str:
633    """Return text currently on the system clipboard.
634
635    Category: **General Utility Functions**
636
637    Ensure that babase.clipboard_has_text() returns True before calling
638     this function.
639    """
640    return str()

Return text currently on the system clipboard.

Category: General Utility Functions

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

def clipboard_has_text() -> bool:
643def clipboard_has_text() -> bool:
644    """Return whether there is currently text on the clipboard.
645
646    Category: **General Utility Functions**
647
648    This will return False if no system clipboard is available; no need
649     to call babase.clipboard_is_supported() separately.
650    """
651    return bool()

Return whether there is currently text on the clipboard.

Category: General Utility Functions

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

def clipboard_is_supported() -> bool:
654def clipboard_is_supported() -> bool:
655    """Return whether this platform supports clipboard operations at all.
656
657    Category: **General Utility Functions**
658
659    If this returns False, UIs should not show 'copy to clipboard'
660    buttons, etc.
661    """
662    return bool()

Return whether this platform supports clipboard operations at all.

Category: General Utility Functions

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
19    in *.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 *.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:
665def clipboard_set_text(value: str) -> None:
666    """Copy a string to the system clipboard.
667
668    Category: **General Utility Functions**
669
670    Ensure that babase.clipboard_is_supported() returns True before adding
671     buttons/etc. that make use of this functionality.
672    """
673    return None

Copy a string to the system clipboard.

Category: General Utility Functions

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

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

A context-preserving callable.

Category: General Utility Classes

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)
142    def __init__(self, call: Callable) -> None:
143        pass
class ContextError(builtins.Exception):
16class ContextError(Exception):
17    """Exception raised when a call is made in an invalid context.
18
19    Category: **Exception Classes**
20
21    Examples of this include calling UI functions within an Activity context
22    or calling scene manipulation functions outside of a game context.
23    """

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

Category: Exception Classes

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

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

Store or use a ballistica context.

Category: General Utility Classes

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:
202    @classmethod
203    def empty(cls) -> ContextRef:
204        """Return a ContextRef pointing to no context.
205
206        This is useful when code should be run free of a context.
207        For example, UI code generally insists on being run this way.
208        Otherwise, callbacks set on the UI could inadvertently stop working
209        due to a game activity ending, which would be unintuitive behavior.
210        """
211        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:
213    def is_empty(self) -> bool:
214        """Whether the context was created as empty."""
215        return bool()

Whether the context was created as empty.

def is_expired(self) -> bool:
217    def is_expired(self) -> bool:
218        """Whether the context has expired."""
219        return bool()

Whether the context has expired.

class DelegateNotFoundError(babase.NotFoundError):
61class DelegateNotFoundError(NotFoundError):
62    """Exception raised when an expected delegate object does not exist.
63
64    Category: **Exception Classes**
65    """

Exception raised when an expected delegate object does not exist.

Category: Exception Classes

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:
759def displaytime() -> babase.DisplayTime:
760    """Return the current display-time in seconds.
761
762    Category: **General Utility Functions**
763
764    Display-time is a time value intended to be used for animation and other
765    visual purposes. It will generally increment by a consistent amount each
766    frame. It will pass at an overall similar rate to AppTime, but trades
767    accuracy for smoothness.
768
769    Note that the value returned here is simply a float; it just has a
770    unique type in the type-checker's eyes to help prevent it from being
771    accidentally used with time functionality expecting other time types.
772    """
773    import babase  # pylint: disable=cyclic-import
774
775    return babase.DisplayTime(0.0)

Return the current display-time in seconds.

Category: General Utility Functions

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:
778def displaytimer(time: float, call: Callable[[], Any]) -> None:
779    """Schedule a callable object to run based on display-time.
780
781    Category: **General Utility Functions**
782
783    This function creates a one-off timer which cannot be canceled or
784    modified once created. If you require the ability to do so, or need
785    a repeating timer, use the babase.DisplayTimer class instead.
786
787    Display-time is a time value intended to be used for animation and other
788    visual purposes. It will generally increment by a consistent amount each
789    frame. It will pass at an overall similar rate to AppTime, but trades
790    accuracy for smoothness.
791
792    ##### Arguments
793    ###### time (float)
794    > Length of time in seconds that the timer will wait before firing.
795
796    ###### call (Callable[[], Any])
797    > A callable Python object. Note that the timer will retain a
798    strong reference to the callable for as long as the timer exists, so you
799    may want to look into concepts such as babase.WeakCall if that is not
800    desired.
801
802    ##### Examples
803    Print some stuff through time:
804    >>> babase.screenmessage('hello from now!')
805    >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage,
806    ...                       'hello from the future!'))
807    >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage,
808    ...                       'hello from the future 2!'))
809    """
810    return None

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

Category: General Utility Functions

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

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

Category: General Utility Classes

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)
267    def __init__(
268        self, time: float, call: Callable[[], Any], repeat: bool = False
269    ) -> None:
270        pass
def do_once() -> bool:
818def do_once() -> bool:
819    """Return whether this is the first time running a line of code.
820
821    Category: **General Utility Functions**
822
823    This is used by 'print_once()' type calls to keep from overflowing
824    logs. The call functions by registering the filename and line where
825    The call is made from.  Returns True if this location has not been
826    registered already, and False if it has.
827
828    ##### Example
829    This print will only fire for the first loop iteration:
830    >>> for i in range(10):
831    ... if babase.do_once():
832    ...     print('HelloWorld once from loop!')
833    """
834    return bool()

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

Category: General Utility Functions

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 being activated.

@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 is being deactivated.

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

A Protocol for objects supporting an exists() method.

Category: Protocols

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

Whether this object exists.

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

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

Category: Gameplay Functions

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:
906def fatal_error(message: str) -> None:
907    """Trigger a fatal error. Use this in situations where it is not possible
908    for the engine to continue on in a useful way. This can sometimes
909    help provide more clear information at the exact source of a problem
910    as compared to raising an Exception. In the vast majority of cases,
911    however, Exceptions should be preferred.
912    """
913    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:
225def garbage_collect() -> None:
226    """Run an explicit pass of garbage collection.
227
228    category: General Utility Functions
229
230    May also print warnings/etc. if collection takes too long or if
231    uncollectible objects are found (so use this instead of simply
232    gc.collect().
233    """
234    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:
1003def get_input_idle_time() -> float:
1004    """Return seconds since any local input occurred (touch, keypress, etc.)."""
1005    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:
112def get_type_name(cls: type) -> str:
113    """Return a full type name including module for a class."""
114    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]:
71def getclass(
72    name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False
73) -> type[T]:
74    """Given a full class name such as foo.bar.MyClass, return the class.
75
76    Category: **General Utility Functions**
77
78    The class will be checked to make sure it is a subclass of the provided
79    'subclassof' class, and a TypeError will be raised if not.
80    """
81    import importlib
82
83    splits = name.split('.')
84    modulename = '.'.join(splits[:-1])
85    classname = splits[-1]
86    if modulename in sys.stdlib_module_names and check_sdlib_modulename_clash:
87        raise Exception(f'{modulename} is an inbuilt module.')
88    module = importlib.import_module(modulename)
89    cls: type = getattr(module, classname)
90
91    if not issubclass(cls, subclassof):
92        raise TypeError(f'{name} is not a subclass of {subclassof}.')
93    return cls

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

Category: General Utility Functions

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

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

class InputDeviceNotFoundError(babase.NotFoundError):
103class InputDeviceNotFoundError(NotFoundError):
104    """Exception raised when an expected input-device does not exist.
105
106    Category: **Exception Classes**
107    """

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

Category: Exception Classes

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

Types of input a controller can send to the game.

Category: Enums

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

Language functionality for the app.

Category: App Classes

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

default_language: str
locale: str
39    @property
40    def locale(self) -> str:
41        """Raw country/language code detected by the game (such as 'en_US').
42
43        Generally for language-specific code you should look at
44        babase.App.language, which is the language the game is using
45        (which may differ from locale if the user sets a language, etc.)
46        """
47        env = _babase.env()
48        locale = env.get('locale')
49        if not isinstance(locale, str):
50            logging.warning(
51                'Seem to be running in a dummy env; returning en_US locale.'
52            )
53            locale = 'en_US'
54        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
56    @property
57    def language(self) -> str:
58        """The current active language for the app.
59
60        This can be selected explicitly by the user or may be set
61        automatically based on locale or other factors.
62        """
63        if self._language is None:
64            raise RuntimeError('App language is not yet set.')
65        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]
 67    @property
 68    def available_languages(self) -> list[str]:
 69        """A list of all available languages.
 70
 71        Note that languages that may be present in game assets but which
 72        are not displayable on the running version of the game are not
 73        included here.
 74        """
 75        langs = set()
 76        try:
 77            names = os.listdir(
 78                os.path.join(
 79                    _babase.app.env.data_directory,
 80                    'ba_data',
 81                    'data',
 82                    'languages',
 83                )
 84            )
 85            names = [n.replace('.json', '').capitalize() for n in names]
 86
 87            # FIXME: our simple capitalization fails on multi-word names;
 88            # should handle this in a better way...
 89            for i, name in enumerate(names):
 90                if name == 'Chinesetraditional':
 91                    names[i] = 'ChineseTraditional'
 92                elif name == 'Piratespeak':
 93                    names[i] = 'PirateSpeak'
 94        except Exception:
 95            from babase import _error
 96
 97            _error.print_exception()
 98            names = []
 99        for name in names:
100            if self._can_display_language(name):
101                langs.add(name)
102        return sorted(
103            name for name in names if self._can_display_language(name)
104        )

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

Set the active app language.

Pass None to use OS default language.

@override
def do_apply_app_config(self) -> None:
277    @override
278    def do_apply_app_config(self) -> None:
279        assert _babase.in_logic_thread()
280        assert isinstance(_babase.app.config, dict)
281        lang = _babase.app.config.get('Lang', self.default_language)
282        if lang != self._language:
283            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:
285    def get_resource(
286        self,
287        resource: str,
288        fallback_resource: str | None = None,
289        fallback_value: Any = None,
290    ) -> Any:
291        """Return a translation resource by name.
292
293        DEPRECATED; use babase.Lstr functionality for these purposes.
294        """
295        try:
296            # If we have no language set, try and set it to english.
297            # Also make a fuss because we should try to avoid this.
298            if self._language_merged is None:
299                try:
300                    if _babase.do_once():
301                        logging.warning(
302                            'get_resource() called before language'
303                            ' set; falling back to english.'
304                        )
305                    self.setlanguage(
306                        'English', print_change=False, store_to_config=False
307                    )
308                except Exception:
309                    logging.exception(
310                        'Error setting fallback english language.'
311                    )
312                    raise
313
314            # If they provided a fallback_resource value, try the
315            # target-language-only dict first and then fall back to
316            # trying the fallback_resource value in the merged dict.
317            if fallback_resource is not None:
318                try:
319                    values = self._language_target
320                    splits = resource.split('.')
321                    dicts = splits[:-1]
322                    key = splits[-1]
323                    for dct in dicts:
324                        assert values is not None
325                        values = values[dct]
326                    assert values is not None
327                    val = values[key]
328                    return val
329                except Exception:
330                    # FIXME: Shouldn't we try the fallback resource in
331                    #  the merged dict AFTER we try the main resource in
332                    #  the merged dict?
333                    try:
334                        values = self._language_merged
335                        splits = fallback_resource.split('.')
336                        dicts = splits[:-1]
337                        key = splits[-1]
338                        for dct in dicts:
339                            assert values is not None
340                            values = values[dct]
341                        assert values is not None
342                        val = values[key]
343                        return val
344
345                    except Exception:
346                        # If we got nothing for fallback_resource,
347                        # default to the normal code which checks or
348                        # primary value in the merge dict; there's a
349                        # chance we can get an english value for it
350                        # (which we weren't looking for the first time
351                        # through).
352                        pass
353
354            values = self._language_merged
355            splits = resource.split('.')
356            dicts = splits[:-1]
357            key = splits[-1]
358            for dct in dicts:
359                assert values is not None
360                values = values[dct]
361            assert values is not None
362            val = values[key]
363            return val
364
365        except Exception:
366            # Ok, looks like we couldn't find our main or fallback
367            # resource anywhere. Now if we've been given a fallback
368            # value, return it; otherwise fail.
369            from babase import _error
370
371            if fallback_value is not None:
372                return fallback_value
373            raise _error.NotFoundError(
374                f"Resource not found: '{resource}'"
375            ) 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:
377    def translate(
378        self,
379        category: str,
380        strval: str,
381        raise_exceptions: bool = False,
382        print_errors: bool = False,
383    ) -> str:
384        """Translate a value (or return the value if no translation available)
385
386        DEPRECATED; use babase.Lstr functionality for these purposes.
387        """
388        try:
389            translated = self.get_resource('translations')[category][strval]
390        except Exception as exc:
391            if raise_exceptions:
392                raise
393            if print_errors:
394                print(
395                    (
396                        'Translate error: category=\''
397                        + category
398                        + '\' name=\''
399                        + strval
400                        + '\' exc='
401                        + str(exc)
402                        + ''
403                    )
404                )
405            translated = None
406        translated_out: str
407        if translated is None:
408            translated_out = strval
409        else:
410            translated_out = translated
411        assert isinstance(translated_out, str)
412        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:
414    def is_custom_unicode_char(self, char: str) -> bool:
415        """Return whether a char is in the custom unicode range we use."""
416        assert isinstance(char, str)
417        if len(char) != 1:
418            raise ValueError('Invalid Input; must be length 1')
419        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:
496class Lstr:
497    """Used to define strings in a language-independent way.
498
499    Category: **General Utility Classes**
500
501    These should be used whenever possible in place of hard-coded
502    strings so that in-game or UI elements show up correctly on all
503    clients in their currently-active language.
504
505    To see available resource keys, look at any of the bs_language_*.py
506    files in the game or the translations pages at
507    legacy.ballistica.net/translate.
508
509    ##### Examples
510    EXAMPLE 1: specify a string from a resource path
511    >>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
512
513    EXAMPLE 2: specify a translated string via a category and english
514    value; if a translated value is available, it will be used; otherwise
515    the english value will be. To see available translation categories,
516    look under the 'translations' resource section.
517    >>> mynode.text = babase.Lstr(translate=('gameDescriptions',
518    ...                                  'Defeat all enemies'))
519
520    EXAMPLE 3: specify a raw value and some substitutions. Substitutions
521    can be used with resource and translate modes as well.
522    >>> mynode.text = babase.Lstr(value='${A} / ${B}',
523    ...               subs=[('${A}', str(score)), ('${B}', str(total))])
524
525    EXAMPLE 4: babase.Lstr's can be nested. This example would display the
526    resource at res_a but replace ${NAME} with the value of the
527    resource at res_b
528    >>> mytextnode.text = babase.Lstr(
529    ...     resource='res_a',
530    ...     subs=[('${NAME}', babase.Lstr(resource='res_b'))])
531    """
532
533    # This class is used a lot in UI stuff and doesn't need to be
534    # flexible, so let's optimize its performance a bit.
535    __slots__ = ['args']
536
537    @overload
538    def __init__(
539        self,
540        *,
541        resource: str,
542        fallback_resource: str = '',
543        fallback_value: str = '',
544        subs: Sequence[tuple[str, str | Lstr]] | None = None,
545    ) -> None:
546        """Create an Lstr from a string resource."""
547
548    @overload
549    def __init__(
550        self,
551        *,
552        translate: tuple[str, str],
553        subs: Sequence[tuple[str, str | Lstr]] | None = None,
554    ) -> None:
555        """Create an Lstr by translating a string in a category."""
556
557    @overload
558    def __init__(
559        self,
560        *,
561        value: str,
562        subs: Sequence[tuple[str, str | Lstr]] | None = None,
563    ) -> None:
564        """Create an Lstr from a raw string value."""
565
566    def __init__(self, *args: Any, **keywds: Any) -> None:
567        """Instantiate a Lstr.
568
569        Pass a value for either 'resource', 'translate',
570        or 'value'. (see Lstr help for examples).
571        'subs' can be a sequence of 2-member sequences consisting of values
572        and replacements.
573        'fallback_resource' can be a resource key that will be used if the
574        main one is not present for
575        the current language in place of falling back to the english value
576        ('resource' mode only).
577        'fallback_value' can be a literal string that will be used if neither
578        the resource nor the fallback resource is found ('resource' mode only).
579        """
580        # pylint: disable=too-many-branches
581        if args:
582            raise TypeError('Lstr accepts only keyword arguments')
583
584        # Basically just store the exact args they passed.
585        # However if they passed any Lstr values for subs,
586        # replace them with that Lstr's dict.
587        self.args = keywds
588        our_type = type(self)
589
590        if isinstance(self.args.get('value'), our_type):
591            raise TypeError("'value' must be a regular string; not an Lstr")
592
593        if 'subs' in keywds:
594            subs = keywds.get('subs')
595            subs_filtered = []
596            if subs is not None:
597                for key, value in keywds['subs']:
598                    if isinstance(value, our_type):
599                        subs_filtered.append((key, value.args))
600                    else:
601                        subs_filtered.append((key, value))
602            self.args['subs'] = subs_filtered
603
604        # As of protocol 31 we support compact key names
605        # ('t' instead of 'translate', etc). Convert as needed.
606        if 'translate' in keywds:
607            keywds['t'] = keywds['translate']
608            del keywds['translate']
609        if 'resource' in keywds:
610            keywds['r'] = keywds['resource']
611            del keywds['resource']
612        if 'value' in keywds:
613            keywds['v'] = keywds['value']
614            del keywds['value']
615        if 'fallback' in keywds:
616            from babase import _error
617
618            _error.print_error(
619                'deprecated "fallback" arg passed to Lstr(); use '
620                'either "fallback_resource" or "fallback_value"',
621                once=True,
622            )
623            keywds['f'] = keywds['fallback']
624            del keywds['fallback']
625        if 'fallback_resource' in keywds:
626            keywds['f'] = keywds['fallback_resource']
627            del keywds['fallback_resource']
628        if 'subs' in keywds:
629            keywds['s'] = keywds['subs']
630            del keywds['subs']
631        if 'fallback_value' in keywds:
632            keywds['fv'] = keywds['fallback_value']
633            del keywds['fallback_value']
634
635    def evaluate(self) -> str:
636        """Evaluate the Lstr and returns a flat string in the current language.
637
638        You should avoid doing this as much as possible and instead pass
639        and store Lstr values.
640        """
641        return _babase.evaluate_lstr(self._get_json())
642
643    def is_flat_value(self) -> bool:
644        """Return whether the Lstr is a 'flat' value.
645
646        This is defined as a simple string value incorporating no
647        translations, resources, or substitutions. In this case it may
648        be reasonable to replace it with a raw string value, perform
649        string manipulation on it, etc.
650        """
651        return bool('v' in self.args and not self.args.get('s', []))
652
653    def _get_json(self) -> str:
654        try:
655            return json.dumps(self.args, separators=(',', ':'))
656        except Exception:
657            from babase import _error
658
659            _error.print_exception('_get_json failed for', self.args)
660            return 'JSON_ERR'
661
662    @override
663    def __str__(self) -> str:
664        return '<ba.Lstr: ' + self._get_json() + '>'
665
666    @override
667    def __repr__(self) -> str:
668        return '<ba.Lstr: ' + self._get_json() + '>'
669
670    @staticmethod
671    def from_json(json_string: str) -> babase.Lstr:
672        """Given a json string, returns a babase.Lstr. Does no validation."""
673        lstr = Lstr(value='')
674        lstr.args = json.loads(json_string)
675        return lstr

Used to define strings in a language-independent way.

Category: General Utility Classes

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.

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 and some 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: babase.Lstr's 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)
566    def __init__(self, *args: Any, **keywds: Any) -> None:
567        """Instantiate a Lstr.
568
569        Pass a value for either 'resource', 'translate',
570        or 'value'. (see Lstr help for examples).
571        'subs' can be a sequence of 2-member sequences consisting of values
572        and replacements.
573        'fallback_resource' can be a resource key that will be used if the
574        main one is not present for
575        the current language in place of falling back to the english value
576        ('resource' mode only).
577        'fallback_value' can be a literal string that will be used if neither
578        the resource nor the fallback resource is found ('resource' mode only).
579        """
580        # pylint: disable=too-many-branches
581        if args:
582            raise TypeError('Lstr accepts only keyword arguments')
583
584        # Basically just store the exact args they passed.
585        # However if they passed any Lstr values for subs,
586        # replace them with that Lstr's dict.
587        self.args = keywds
588        our_type = type(self)
589
590        if isinstance(self.args.get('value'), our_type):
591            raise TypeError("'value' must be a regular string; not an Lstr")
592
593        if 'subs' in keywds:
594            subs = keywds.get('subs')
595            subs_filtered = []
596            if subs is not None:
597                for key, value in keywds['subs']:
598                    if isinstance(value, our_type):
599                        subs_filtered.append((key, value.args))
600                    else:
601                        subs_filtered.append((key, value))
602            self.args['subs'] = subs_filtered
603
604        # As of protocol 31 we support compact key names
605        # ('t' instead of 'translate', etc). Convert as needed.
606        if 'translate' in keywds:
607            keywds['t'] = keywds['translate']
608            del keywds['translate']
609        if 'resource' in keywds:
610            keywds['r'] = keywds['resource']
611            del keywds['resource']
612        if 'value' in keywds:
613            keywds['v'] = keywds['value']
614            del keywds['value']
615        if 'fallback' in keywds:
616            from babase import _error
617
618            _error.print_error(
619                'deprecated "fallback" arg passed to Lstr(); use '
620                'either "fallback_resource" or "fallback_value"',
621                once=True,
622            )
623            keywds['f'] = keywds['fallback']
624            del keywds['fallback']
625        if 'fallback_resource' in keywds:
626            keywds['f'] = keywds['fallback_resource']
627            del keywds['fallback_resource']
628        if 'subs' in keywds:
629            keywds['s'] = keywds['subs']
630            del keywds['subs']
631        if 'fallback_value' in keywds:
632            keywds['fv'] = keywds['fallback_value']
633            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:
635    def evaluate(self) -> str:
636        """Evaluate the Lstr and returns a flat string in the current language.
637
638        You should avoid doing this as much as possible and instead pass
639        and store Lstr values.
640        """
641        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:
643    def is_flat_value(self) -> bool:
644        """Return whether the Lstr is a 'flat' value.
645
646        This is defined as a simple string value incorporating no
647        translations, resources, or substitutions. In this case it may
648        be reasonable to replace it with a raw string value, perform
649        string manipulation on it, etc.
650        """
651        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:
670    @staticmethod
671    def from_json(json_string: str) -> babase.Lstr:
672        """Given a json string, returns a babase.Lstr. Does no validation."""
673        lstr = Lstr(value='')
674        lstr.args = json.loads(json_string)
675        return lstr

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

class MapNotFoundError(babase.NotFoundError):
54class MapNotFoundError(NotFoundError):
55    """Exception raised when an expected bascenev1.Map does not exist.
56
57    Category: **Exception Classes**
58    """

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

Category: Exception Classes

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

Subsystem for working with script metadata in the app.

Category: App Classes

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:
68    def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
69        """Begin the overall scan.
70
71        This will start scanning built in directories (which for vanilla
72        installs should be the vast majority of the work). This should only
73        be called once.
74        """
75        assert self._scan_complete_cb is None
76        assert self._scan is None
77        env = _babase.app.env
78
79        self._scan_complete_cb = scan_complete_cb
80        self._scan = DirectoryScan(
81            [
82                path
83                for path in [
84                    env.python_directory_app,
85                    env.python_directory_user,
86                ]
87                if path is not None
88            ]
89        )
90
91        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:
 93    def start_extra_scan(self) -> None:
 94        """Proceed to the extra_scan_dirs portion of the scan.
 95
 96        This is for parts of the scan that must be delayed until
 97        workspace sync completion or other such events. This must be
 98        called exactly once.
 99        """
100        assert self._scan is not None
101        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:
103    def load_exported_classes(
104        self,
105        cls: type[T],
106        completion_cb: Callable[[list[type[T]]], None],
107        completion_cb_in_bg_thread: bool = False,
108    ) -> None:
109        """High level function to load meta-exported classes.
110
111        Will wait for scanning to complete if necessary, and will load all
112        registered classes of a particular type in a background thread before
113        calling the passed callback in the logic thread. Errors may be logged
114        to messaged to the user in some way but the callback will be called
115        regardless.
116        To run the completion callback directly in the bg thread where the
117        loading work happens, pass completion_cb_in_bg_thread=True.
118        """
119        Thread(
120            target=partial(
121                self._load_exported_classes,
122                cls,
123                completion_cb,
124                completion_cb_in_bg_thread,
125            ),
126            daemon=True,
127        ).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:
1284def native_stack_trace() -> str | None:
1285    """Return a native stack trace as a string, or None if not available.
1286
1287    Category: **General Utility Functions**
1288
1289    Stack traces contain different data and formatting across platforms.
1290    Only use them for debugging.
1291    """
1292    return ''

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

Category: General Utility Functions

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

class NodeNotFoundError(babase.NotFoundError):
75class NodeNotFoundError(NotFoundError):
76    """Exception raised when an expected Node does not exist.
77
78    Category: **Exception Classes**
79    """

Exception raised when an expected Node does not exist.

Category: Exception Classes

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):
26class NotFoundError(Exception):
27    """Exception raised when a referenced object does not exist.
28
29    Category: **Exception Classes**
30    """

Exception raised when a referenced object does not exist.

Category: Exception Classes

def open_url(address: str, force_fallback: bool = False) -> None:
1321def open_url(address: str, force_fallback: bool = False) -> None:
1322    """Open the provided URL.
1323
1324    Category: **General Utility Functions**
1325
1326    Attempts to open the provided url in a web-browser. If that is not
1327    possible (or force_fallback is True), instead displays the url as
1328    a string and/or qrcode.
1329    """
1330    return None

Open the provided URL.

Category: General Utility Functions

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:
1333def overlay_web_browser_close() -> bool:
1334    """Close any open overlay web browser.
1335
1336    Category: **General Utility Functions**
1337    """
1338    return bool()

Close any open overlay web browser.

Category: General Utility Functions

def overlay_web_browser_is_open() -> bool:
1341def overlay_web_browser_is_open() -> bool:
1342    """Return whether an overlay web browser is open currently.
1343
1344    Category: **General Utility Functions**
1345    """
1346    return bool()

Return whether an overlay web browser is open currently.

Category: General Utility Functions

def overlay_web_browser_is_supported() -> bool:
1349def overlay_web_browser_is_supported() -> bool:
1350    """Return whether an overlay web browser is supported here.
1351
1352    Category: **General Utility Functions**
1353
1354    An overlay web browser is a small dialog that pops up over the top
1355    of the main engine window. It can be used for performing simple
1356    tasks such as sign-ins.
1357    """
1358    return bool()

Return whether an overlay web browser is supported here.

Category: General Utility Functions

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:
1361def overlay_web_browser_open_url(address: str) -> None:
1362    """Open the provided URL in an overlayw web browser.
1363
1364    Category: **General Utility Functions**
1365
1366    An overlay web browser is a small dialog that pops up over the top
1367    of the main engine window. It can be used for performing simple
1368    tasks such as sign-ins.
1369    """
1370    return None

Open the provided URL in an overlayw web browser.

Category: General Utility Functions

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):
89class Permission(Enum):
90    """Permissions that can be requested from the OS.
91
92    Category: Enums
93    """
94
95    STORAGE = 0

Permissions that can be requested from the OS.

Category: Enums

STORAGE = <Permission.STORAGE: 0>
class PlayerNotFoundError(babase.NotFoundError):
33class PlayerNotFoundError(NotFoundError):
34    """Exception raised when an expected player does not exist.
35
36    Category: **Exception Classes**
37    """

Exception raised when an expected player does not exist.

Category: Exception Classes

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

A plugin to alter app behavior in some way.

Category: App Classes

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:
333    def on_app_running(self) -> None:
334        """Called when the app reaches the running state."""

Called when the app reaches the running state.

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

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

def on_app_shutdown(self) -> None:
342    def on_app_shutdown(self) -> None:
343        """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:
345    def on_app_shutdown_complete(self) -> None:
346        """Called when the app has completed the shutdown process."""

Called when the app has completed the shutdown process.

def has_settings_ui(self) -> bool:
348    def has_settings_ui(self) -> bool:
349        """Called to ask if we have settings UI we can show."""
350        return False

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

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

Subsystem for plugin handling in the app.

Category: App Classes

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

Called when meta-scanning is complete.

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

Called when the app reaches the running state.

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

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

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

Called when the app begins shutting down.

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

Called when the app completes shutting down.

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

Represents a plugin the engine knows about.

Category: App Classes

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)
245    def __init__(self, class_path: str, loadable: bool):
246        self.class_path = class_path
247        self.loadable = loadable
248        self.attempted_load = False
249        self.plugin: Plugin | None = None
class_path
loadable
attempted_load
plugin: Plugin | None
enabled: bool
251    @property
252    def enabled(self) -> bool:
253        """Whether the user wants this plugin to load."""
254        plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
255        assert isinstance(plugstates, dict)
256        val = plugstates.get(self.class_path, {}).get('enabled', False) is True
257        return val

Whether the user wants this plugin to load.

def attempt_load_if_enabled(self) -> Plugin | None:
268    def attempt_load_if_enabled(self) -> Plugin | None:
269        """Possibly load the plugin and log any errors."""
270        from babase._general import getclass
271        from babase._language import Lstr
272
273        assert not self.attempted_load
274        assert self.plugin is None
275
276        if not self.enabled:
277            return None
278        self.attempted_load = True
279        if not self.loadable:
280            return None
281        try:
282            cls = getclass(self.class_path, Plugin, True)
283        except Exception as exc:
284            _babase.getsimplesound('error').play()
285            _babase.screenmessage(
286                Lstr(
287                    resource='pluginClassLoadErrorText',
288                    subs=[
289                        ('${PLUGIN}', self.class_path),
290                        ('${ERROR}', str(exc)),
291                    ],
292                ),
293                color=(1, 0, 0),
294            )
295            logging.exception(
296                "Error loading plugin class '%s'.", self.class_path
297            )
298            return None
299        try:
300            self.plugin = cls()
301            return self.plugin
302        except Exception as exc:
303            from babase import _error
304
305            _babase.getsimplesound('error').play()
306            _babase.screenmessage(
307                Lstr(
308                    resource='pluginInitErrorText',
309                    subs=[
310                        ('${PLUGIN}', self.class_path),
311                        ('${ERROR}', str(exc)),
312                    ],
313                ),
314                color=(1, 0, 0),
315            )
316            logging.exception(
317                "Error initing plugin class: '%s'.", self.class_path
318            )
319        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:
1405def pushcall(
1406    call: Callable,
1407    from_other_thread: bool = False,
1408    suppress_other_thread_warning: bool = False,
1409    other_thread_use_fg_context: bool = False,
1410    raw: bool = False,
1411) -> None:
1412    """Push a call to the logic event-loop.
1413    Category: **General Utility Functions**
1414
1415    This call expects to be used in the logic thread, and will automatically
1416    save and restore the babase.Context to behave seamlessly.
1417
1418    If you want to push a call from outside of the logic thread,
1419    however, you can pass 'from_other_thread' as True. In this case
1420    the call will always run in the UI context_ref on the logic thread
1421    or whichever context_ref is in the foreground if
1422    other_thread_use_fg_context is True.
1423    Passing raw=True will disable thread checks and context_ref sets/restores.
1424    """
1425    return None

Push a call to the logic event-loop. Category: General Utility Functions

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:
1429def quit(
1430    confirm: bool = False, quit_type: babase.QuitType | None = None
1431) -> None:
1432    """Quit the app.
1433
1434    Category: **General Utility Functions**
1435
1436    If 'confirm' is True, a confirm dialog will be presented if conditions
1437    allow; otherwise the quit will still be immediate.
1438    See docs for babase.QuitType for explanations of the optional
1439    'quit_type' arg.
1440    """
1441    return None

Quit the app.

Category: General Utility Functions

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

Types of input a controller can send to the game.

Category: Enums

'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, ...]:
1479def safecolor(
1480    color: Sequence[float], target_intensity: float = 0.6
1481) -> tuple[float, ...]:
1482    """Given a color tuple, return a color safe to display as text.
1483
1484    Category: **General Utility Functions**
1485
1486    Accepts tuples of length 3 or 4. This will slightly brighten very
1487    dark colors, etc.
1488    """
1489    return (0.0, 0.0, 0.0)

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

Category: General Utility Functions

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:
1492def screenmessage(
1493    message: str | babase.Lstr,
1494    color: Sequence[float] | None = None,
1495    log: bool = False,
1496) -> None:
1497    """Print a message to the local client's screen, in a given color.
1498
1499    Category: **General Utility Functions**
1500
1501    Note that this version of the function is purely for local display.
1502    To broadcast screen messages in network play, look for methods such as
1503    broadcastmessage() provided by the scene-version packages.
1504    """
1505    return None

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

Category: General Utility Functions

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):
 96class SessionNotFoundError(NotFoundError):
 97    """Exception raised when an expected session does not exist.
 98
 99    Category: **Exception Classes**
100    """

Exception raised when an expected session does not exist.

Category: Exception Classes

class SessionPlayerNotFoundError(babase.NotFoundError):
40class SessionPlayerNotFoundError(NotFoundError):
41    """Exception raised when an expected session-player does not exist.
42
43    Category: **Exception Classes**
44    """

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

Category: Exception Classes

class SessionTeamNotFoundError(babase.NotFoundError):
68class SessionTeamNotFoundError(NotFoundError):
69    """Exception raised when an expected session-team does not exist.
70
71    Category: **Exception Classes**
72    """

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

Category: Exception Classes

def set_analytics_screen(screen: str) -> None:
1508def set_analytics_screen(screen: str) -> None:
1509    """Used for analytics to see where in the app players spend their time.
1510
1511    Category: **General Utility Functions**
1512
1513    Generally called when opening a new window or entering some UI.
1514    'screen' should be a string description of an app location
1515    ('Main Menu', etc.)
1516    """
1517    return None

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

Category: General Utility Functions

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:
385class SimpleSound:
386    """A simple sound wrapper for internal use.
387
388    Do not use for gameplay code as it will only play locally.
389    """
390
391    def play(self) -> None:
392        """Play the sound locally."""
393        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:
391    def play(self) -> None:
392        """Play the sound locally."""
393        return None

Play the sound locally.

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

Special characters the game can print.

Category: Enums

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:
351def storagename(suffix: str | None = None) -> str:
352    """Generate a unique name for storing class data in shared places.
353
354    Category: **General Utility Functions**
355
356    This consists of a leading underscore, the module path at the
357    call site with dots replaced by underscores, the containing class's
358    qualified name, and the provided suffix. When storing data in public
359    places such as 'customdata' dicts, this minimizes the chance of
360    collisions with other similarly named classes.
361
362    Note that this will function even if called in the class definition.
363
364    ##### Examples
365    Generate a unique name for storage purposes:
366    >>> class MyThingie:
367    ...     # This will give something like
368    ...     # '_mymodule_submodule_mythingie_data'.
369    ...     _STORENAME = babase.storagename('data')
370    ...
371    ...     # Use that name to store some data in the Activity we were
372    ...     # passed.
373    ...     def __init__(self, activity):
374    ...         activity.customdata[self._STORENAME] = {}
375    """
376    frame = inspect.currentframe()
377    if frame is None:
378        raise RuntimeError('Cannot get current stack frame.')
379    fback = frame.f_back
380
381    # Note: We need to explicitly clear frame here to avoid a ref-loop
382    # that keeps all function-dicts in the stack alive until the next
383    # full GC cycle (the stack frame refers to this function's dict,
384    # which refers to the stack frame).
385    del frame
386
387    if fback is None:
388        raise RuntimeError('Cannot get parent stack frame.')
389    modulepath = fback.f_globals.get('__name__')
390    if modulepath is None:
391        raise RuntimeError('Cannot get parent stack module path.')
392    assert isinstance(modulepath, str)
393    qualname = fback.f_locals.get('__qualname__')
394    if qualname is not None:
395        assert isinstance(qualname, str)
396        fullpath = f'_{modulepath}_{qualname.lower()}'
397    else:
398        fullpath = f'_{modulepath}'
399    if suffix is not None:
400        fullpath = f'{fullpath}_{suffix}'
401    return fullpath.replace('.', '_')

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

Category: General Utility Functions

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:
1657def supports_unicode_display() -> bool:
1658    """Return whether we can display all unicode characters in the gui."""
1659    return bool()

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

class TeamNotFoundError(babase.NotFoundError):
47class TeamNotFoundError(NotFoundError):
48    """Exception raised when an expected bascenev1.Team does not exist.
49
50    Category: **Exception Classes**
51    """

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

Category: Exception Classes

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

Generate a babase.Lstr for displaying a time value.

Category: General Utility Functions

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

Category: Enums

'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:
1680def update_internal_logger_levels() -> None:
1681    """Update the native layer to re-cache Python logger levels.
1682
1683    The native layer caches logger levels so it can efficiently
1684    avoid making Python log calls for disabled logger levels. If any
1685    logger levels are changed at runtime, call this method after to
1686    instruct the native layer to regenerate its cache so the change
1687    is properly reflected in logs originating from the native layer.
1688    """
1689    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    # FIXME - do something smart here.
35    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:
 96def utf8_all(data: Any) -> Any:
 97    """Convert any unicode data in provided sequence(s) to utf8 bytes."""
 98    if isinstance(data, dict):
 99        return dict(
100            (utf8_all(key), utf8_all(value))
101            for key, value in list(data.items())
102        )
103    if isinstance(data, list):
104        return [utf8_all(element) for element in data]
105    if isinstance(data, tuple):
106        return tuple(utf8_all(element) for element in data)
107    if isinstance(data, str):
108        return data.encode('utf-8', errors='ignore')
109    return data

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

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

A vector of 3 floats.

Category: General Utility Classes

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)
435    def __init__(self, *args: Any, **kwds: Any):
436        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:
489    def cross(self, other: Vec3) -> Vec3:
490        """Returns the cross product of this vector and another."""
491        return Vec3()

Returns the cross product of this vector and another.

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

Returns the dot product of this vector and another.

def length(self) -> float:
497    def length(self) -> float:
498        """Returns the length of the vector."""
499        return float()

Returns the length of the vector.

def normalized(self) -> _babase.Vec3:
501    def normalized(self) -> Vec3:
502        """Returns a normalized version of the vector."""
503        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:
309def verify_object_death(obj: object) -> None:
310    """Warn if an object does not get freed within a short period.
311
312    Category: **General Utility Functions**
313
314    This can be handy to detect and prevent memory/resource leaks.
315    """
316
317    try:
318        ref = weakref.ref(obj)
319    except Exception:
320        logging.exception('Unable to create weak-ref in verify_object_death')
321        return
322
323    # Use a slight range for our checks so they don't all land at once
324    # if we queue a lot of them.
325    delay = random.uniform(2.0, 5.5)
326
327    # Make this timer in an empty context; don't want it dying with the
328    # scene/etc.
329    with _babase.ContextRef.empty():
330        _babase.apptimer(delay, Call(_verify_object_death, ref))

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

Category: General Utility Functions

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

WeakCall = <class 'babase._general._WeakCall'>
class WidgetNotFoundError(babase.NotFoundError):
110class WidgetNotFoundError(NotFoundError):
111    """Exception raised when an expected widget does not exist.
112
113    Category: **Exception Classes**
114    """

Exception raised when an expected widget does not exist.

Category: Exception Classes

DEFAULT_REQUEST_TIMEOUT_SECONDS = 60