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

The app's current mode.

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

Our classic subsystem (if available).

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

Our plus subsystem (if available).

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

Our ui_v1 subsystem (always available).

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

Called by the AppSubsystem class. Do not use directly.

def add_shutdown_task(self, coro: Coroutine[NoneType, NoneType, NoneType]) -> None:
470    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
471        """Add a task to be run on app shutdown.
472
473        Note that shutdown tasks will be canceled after
474        App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.
475        """
476        if (
477            self.state is self.State.SHUTTING_DOWN
478            or self.state is self.State.SHUTDOWN_COMPLETE
479        ):
480            stname = self.state.name
481            raise RuntimeError(
482                f'Cannot add shutdown tasks with current state {stname}.'
483            )
484        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:
486    def run(self) -> None:
487        """Run the app to completion.
488
489        Note that this only works on builds where Ballistica manages
490        its own event loop.
491        """
492        _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:
494    def set_intent(self, intent: AppIntent) -> None:
495        """Set the intent for the app.
496
497        Intent defines what the app is trying to do at a given time.
498        This call is asynchronous; the intent switch will happen in the
499        logic thread in the near future. If set_intent is called
500        repeatedly before the change takes place, the final intent to be
501        set will be used.
502        """
503
504        # Mark this one as pending. We do this synchronously so that the
505        # last one marked actually takes effect if there is overlap
506        # (doing this in the bg thread could result in race conditions).
507        self._pending_intent = intent
508
509        # Do the actual work of calcing our app-mode/etc. in a bg thread
510        # since it may block for a moment to load modules/etc.
511        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:
513    def push_apply_app_config(self) -> None:
514        """Internal. Use app.config.apply() to apply app config changes."""
515        # To be safe, let's run this by itself in the event loop.
516        # This avoids potential trouble if this gets called mid-draw or
517        # something like that.
518        self._pending_apply_app_config = True
519        _babase.pushcall(self._apply_app_config, raw=True)

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

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

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

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

Called by the native layer once its ready to rock.

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

Called by the native layer when the app is suspended.

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

Called by the native layer when the app suspension ends.

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

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

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

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

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

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):
 77    class State(Enum):
 78        """High level state the app can be in."""
 79
 80        # The app has not yet begun starting and should not be used in
 81        # any way.
 82        NOT_STARTED = 0
 83
 84        # The native layer is spinning up its machinery (screens,
 85        # renderers, etc.). Nothing should happen in the Python layer
 86        # until this completes.
 87        NATIVE_BOOTSTRAPPING = 1
 88
 89        # Python app subsystems are being inited but should not yet
 90        # interact or do any work.
 91        INITING = 2
 92
 93        # Python app subsystems are inited and interacting, but the app
 94        # has not yet embarked on a high level course of action. It is
 95        # doing initial account logins, workspace & asset downloads,
 96        # etc.
 97        LOADING = 3
 98
 99        # All pieces are in place and the app is now doing its thing.
100        RUNNING = 4
101
102        # Used on platforms such as mobile where the app basically needs
103        # to shut down while backgrounded. In this state, all event
104        # loops are suspended and all graphics and audio must cease
105        # completely. Be aware that the suspended state can be entered
106        # from any other state including NATIVE_BOOTSTRAPPING and
107        # SHUTTING_DOWN.
108        SUSPENDED = 5
109
110        # The app is shutting down. This process may involve sending
111        # network messages or other things that can take up to a few
112        # seconds, so ideally graphics and audio should remain
113        # functional (with fades or spinners or whatever to show
114        # something is happening).
115        SHUTTING_DOWN = 6
116
117        # The app has completed shutdown. Any code running here should
118        # be basically immediate.
119        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):
121    class DefaultAppModeSelector(AppModeSelector):
122        """Decides which AppModes to use to handle AppIntents.
123
124        This default version is generated by the project updater based
125        on the 'default_app_modes' value in the projectconfig.
126
127        It is also possible to modify app mode selection behavior by
128        setting app.mode_selector to an instance of a custom
129        AppModeSelector subclass. This is a good way to go if you are
130        modifying app behavior dynamically via a plugin instead of
131        statically in a spinoff project.
132        """
133
134        @override
135        def app_mode_for_intent(
136            self, intent: AppIntent
137        ) -> type[AppMode] | None:
138            # pylint: disable=cyclic-import
139
140            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
141            # This section generated by batools.appmodule; do not edit.
142
143            # Ask our default app modes to handle it.
144            # (generated from 'default_app_modes' in projectconfig).
145            import baclassic
146            import babase
147
148            for appmode in [
149                baclassic.ClassicAppMode,
150                babase.EmptyAppMode,
151            ]:
152                if appmode.can_handle_intent(intent):
153                    return appmode
154
155            return None
156
157            # __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:
134        @override
135        def app_mode_for_intent(
136            self, intent: AppIntent
137        ) -> type[AppMode] | None:
138            # pylint: disable=cyclic-import
139
140            # __DEFAULT_APP_MODE_SELECTION_BEGIN__
141            # This section generated by batools.appmodule; do not edit.
142
143            # Ask our default app modes to handle it.
144            # (generated from 'default_app_modes' in projectconfig).
145            import baclassic
146            import babase
147
148            for appmode in [
149                baclassic.ClassicAppMode,
150                babase.EmptyAppMode,
151            ]:
152                if appmode.can_handle_intent(intent):
153                    return appmode
154
155            return None
156
157            # __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):
392class AppHealthMonitor(AppSubsystem):
393    """Logs things like app-not-responding issues."""
394
395    def __init__(self) -> None:
396        assert _babase.in_logic_thread()
397        super().__init__()
398        self._running = True
399        self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
400        self._thread.start()
401        self._response = False
402        self._first_check = True
403
404    @override
405    def on_app_loading(self) -> None:
406        # If any traceback dumps happened last run, log and clear them.
407        log_dumped_app_state(from_previous_run=True)
408
409    def _app_monitor_thread_main(self) -> None:
410        _babase.set_thread_name('ballistica app-monitor')
411        try:
412            self._monitor_app()
413        except Exception:
414            logging.exception('Error in AppHealthMonitor thread.')
415
416    def _set_response(self) -> None:
417        assert _babase.in_logic_thread()
418        self._response = True
419
420    def _check_running(self) -> bool:
421        # Workaround for the fact that mypy assumes _running
422        # doesn't change during the course of a function.
423        return self._running
424
425    def _monitor_app(self) -> None:
426        import time
427
428        while bool(True):
429            # Always sleep a bit between checks.
430            time.sleep(1.234)
431
432            # Do nothing while backgrounded.
433            while not self._running:
434                time.sleep(2.3456)
435
436            # Wait for the logic thread to run something we send it.
437            starttime = time.monotonic()
438            self._response = False
439            _babase.pushcall(self._set_response, raw=True)
440            while not self._response:
441                # Abort this check if we went into the background.
442                if not self._check_running():
443                    break
444
445                # Wait a bit longer the first time through since the app
446                # could still be starting up; we generally don't want to
447                # report that.
448                threshold = 10 if self._first_check else 5
449
450                # If we've been waiting too long (and the app is running)
451                # dump the app state and bail. Make an exception for the
452                # first check though since the app could just be taking
453                # a while to get going; we don't want to report that.
454                duration = time.monotonic() - starttime
455                if duration > threshold:
456                    dump_app_state(
457                        reason=f'Logic thread unresponsive'
458                        f' for {threshold} seconds.'
459                    )
460
461                    # We just do one alert for now.
462                    return
463
464                time.sleep(1.042)
465
466            self._first_check = False
467
468    @override
469    def on_app_suspend(self) -> None:
470        assert _babase.in_logic_thread()
471        self._running = False
472
473    @override
474    def on_app_unsuspend(self) -> None:
475        assert _babase.in_logic_thread()
476        self._running = True

Logs things like app-not-responding issues.

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

Called when the app reaches the loading state.

Note that subsystems created after the app switches to the loading state will not receive this callback. Subsystems created by plugins are an example of this.

@override
def on_app_suspend(self) -> None:
468    @override
469    def on_app_suspend(self) -> None:
470        assert _babase.in_logic_thread()
471        self._running = False

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

class AppIntent:
13class AppIntent:
14    """A high level directive given to the app.
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" attr 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:
227def garbage_collect() -> None:
228    """Run an explicit pass of garbage collection.
229
230    category: General Utility Functions
231
232    May also print warnings/etc. if collection takes too long or if
233    uncollectible objects are found (so use this instead of simply
234    gc.collect().
235    """
236    gc.collect()

Run an explicit pass of garbage collection.

category: General Utility Functions

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

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

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

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

Return whether a browser likely exists on the current device.

category: General Utility Functions

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

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

Return whether a given point is within a given box.

category: General Utility Functions

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

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

Returns estimated utc time regardless of local clock settings.

Applies offsets pulled from server communication/etc.

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