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
 20import logging
 21from efro.util import set_canonical_module_names
 22
 23
 24import _babase
 25from _babase import (
 26    add_clean_frame_callback,
 27    allows_ticket_sales,
 28    android_get_external_files_dir,
 29    appname,
 30    appnameupper,
 31    apptime,
 32    apptimer,
 33    AppTimer,
 34    asset_loads_allowed,
 35    fullscreen_control_available,
 36    fullscreen_control_get,
 37    fullscreen_control_key_shortcut,
 38    fullscreen_control_set,
 39    charstr,
 40    clipboard_get_text,
 41    clipboard_has_text,
 42    clipboard_is_supported,
 43    clipboard_set_text,
 44    ContextCall,
 45    ContextRef,
 46    displaytime,
 47    displaytimer,
 48    DisplayTimer,
 49    do_once,
 50    env,
 51    Env,
 52    fade_screen,
 53    fatal_error,
 54    get_display_resolution,
 55    get_immediate_return_code,
 56    get_input_idle_time,
 57    get_low_level_config_value,
 58    get_max_graphics_quality,
 59    get_replays_dir,
 60    get_string_height,
 61    get_string_width,
 62    get_ui_scale,
 63    get_v1_cloud_log_file_path,
 64    getsimplesound,
 65    has_user_run_commands,
 66    have_chars,
 67    have_permission,
 68    in_logic_thread,
 69    in_main_menu,
 70    increment_analytics_count,
 71    invoke_main_menu,
 72    is_os_playing_music,
 73    is_xcode_build,
 74    lock_all_input,
 75    mac_music_app_get_playlists,
 76    mac_music_app_get_volume,
 77    mac_music_app_init,
 78    mac_music_app_play_playlist,
 79    mac_music_app_set_volume,
 80    mac_music_app_stop,
 81    music_player_play,
 82    music_player_set_volume,
 83    music_player_shutdown,
 84    music_player_stop,
 85    native_review_request,
 86    native_review_request_supported,
 87    native_stack_trace,
 88    open_file_externally,
 89    open_url,
 90    overlay_web_browser_close,
 91    overlay_web_browser_is_open,
 92    overlay_web_browser_is_supported,
 93    overlay_web_browser_open_url,
 94    print_load_info,
 95    push_back_press,
 96    pushcall,
 97    quit,
 98    reload_media,
 99    request_permission,
100    safecolor,
101    screenmessage,
102    set_analytics_screen,
103    set_low_level_config_value,
104    set_thread_name,
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    unlock_all_input,
115    update_internal_logger_levels,
116    user_agent_string,
117    Vec3,
118    workspaces_in_use,
119)
120
121from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
122from babase._app import App
123from babase._appconfig import commit_app_config
124from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
125from babase._appmode import AppMode
126from babase._appsubsystem import AppSubsystem
127from babase._appmodeselector import AppModeSelector
128from babase._appconfig import AppConfig
129from babase._apputils import (
130    handle_leftover_v1_cloud_log_file,
131    is_browser_likely_available,
132    garbage_collect,
133    get_remote_app_name,
134    AppHealthMonitor,
135)
136from babase._cloud import CloudSubscription
137from babase._devconsole import (
138    DevConsoleTab,
139    DevConsoleTabEntry,
140    DevConsoleSubsystem,
141)
142from babase._emptyappmode import EmptyAppMode
143from babase._error import (
144    print_exception,
145    print_error,
146    ContextError,
147    NotFoundError,
148    PlayerNotFoundError,
149    SessionPlayerNotFoundError,
150    NodeNotFoundError,
151    ActorNotFoundError,
152    InputDeviceNotFoundError,
153    WidgetNotFoundError,
154    ActivityNotFoundError,
155    TeamNotFoundError,
156    MapNotFoundError,
157    SessionTeamNotFoundError,
158    SessionNotFoundError,
159    DelegateNotFoundError,
160)
161from babase._general import (
162    utf8_all,
163    DisplayTime,
164    AppTime,
165    WeakCall,
166    Call,
167    existing,
168    Existable,
169    verify_object_death,
170    storagename,
171    getclass,
172    get_type_name,
173)
174from babase._language import Lstr, LanguageSubsystem
175from babase._login import LoginAdapter, LoginInfo
176
177# noinspection PyProtectedMember
178# (PyCharm inspection bug?)
179from babase._mgen.enums import (
180    Permission,
181    SpecialChar,
182    InputType,
183    UIScale,
184    QuitType,
185)
186from babase._math import normalized_color, is_point_in_box, vec3validate
187from babase._meta import MetadataSubsystem
188from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS
189from babase._plugin import PluginSpec, Plugin, PluginSubsystem
190from babase._stringedit import StringEditAdapter, StringEditSubsystem
191from babase._text import timestring
192
193# Our standard set of loggers.
194balog = logging.getLogger('ba')
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    'appname',
217    'appnameupper',
218    'AppModeSelector',
219    'AppSubsystem',
220    'apptime',
221    'AppTime',
222    'apptime',
223    'apptimer',
224    'AppTimer',
225    'asset_loads_allowed',
226    'balog',
227    'Call',
228    'fullscreen_control_available',
229    'fullscreen_control_get',
230    'fullscreen_control_key_shortcut',
231    'fullscreen_control_set',
232    'charstr',
233    'clipboard_get_text',
234    'clipboard_has_text',
235    'clipboard_is_supported',
236    'CloudSubscription',
237    'clipboard_set_text',
238    'commit_app_config',
239    'ContextCall',
240    'ContextError',
241    'ContextRef',
242    'DelegateNotFoundError',
243    'DevConsoleTab',
244    'DevConsoleTabEntry',
245    'DevConsoleSubsystem',
246    'DisplayTime',
247    'displaytime',
248    'displaytimer',
249    'DisplayTimer',
250    'do_once',
251    'EmptyAppMode',
252    'env',
253    'Env',
254    'Existable',
255    'existing',
256    'fade_screen',
257    'fatal_error',
258    'garbage_collect',
259    'get_display_resolution',
260    'get_immediate_return_code',
261    'get_input_idle_time',
262    'get_ip_address_type',
263    'get_low_level_config_value',
264    'get_max_graphics_quality',
265    'get_remote_app_name',
266    'get_replays_dir',
267    'get_string_height',
268    'get_string_width',
269    'get_type_name',
270    'get_ui_scale',
271    'get_v1_cloud_log_file_path',
272    'getclass',
273    'getsimplesound',
274    'handle_leftover_v1_cloud_log_file',
275    'has_user_run_commands',
276    'have_chars',
277    'have_permission',
278    'in_logic_thread',
279    'in_main_menu',
280    'increment_analytics_count',
281    'InputDeviceNotFoundError',
282    'InputType',
283    'invoke_main_menu',
284    'is_browser_likely_available',
285    'is_browser_likely_available',
286    'is_os_playing_music',
287    'is_point_in_box',
288    'is_xcode_build',
289    'LanguageSubsystem',
290    'lock_all_input',
291    'LoginAdapter',
292    'LoginInfo',
293    'Lstr',
294    'mac_music_app_get_playlists',
295    'mac_music_app_get_volume',
296    'mac_music_app_init',
297    'mac_music_app_play_playlist',
298    'mac_music_app_set_volume',
299    'mac_music_app_stop',
300    'MapNotFoundError',
301    'MetadataSubsystem',
302    'music_player_play',
303    'music_player_set_volume',
304    'music_player_shutdown',
305    'music_player_stop',
306    'native_review_request',
307    'native_review_request_supported',
308    'native_stack_trace',
309    'NodeNotFoundError',
310    'normalized_color',
311    'NotFoundError',
312    'open_file_externally',
313    'open_url',
314    'overlay_web_browser_close',
315    'overlay_web_browser_is_open',
316    'overlay_web_browser_is_supported',
317    'overlay_web_browser_open_url',
318    'Permission',
319    'PlayerNotFoundError',
320    'Plugin',
321    'PluginSubsystem',
322    'PluginSpec',
323    'print_error',
324    'print_exception',
325    'print_load_info',
326    'push_back_press',
327    'pushcall',
328    'quit',
329    'QuitType',
330    'reload_media',
331    'request_permission',
332    'safecolor',
333    'screenmessage',
334    'SessionNotFoundError',
335    'SessionPlayerNotFoundError',
336    'SessionTeamNotFoundError',
337    'set_analytics_screen',
338    'set_low_level_config_value',
339    'set_thread_name',
340    'set_ui_input_device',
341    'set_ui_scale',
342    'show_progress_bar',
343    'shutdown_suppress_begin',
344    'shutdown_suppress_end',
345    'shutdown_suppress_count',
346    'SimpleSound',
347    'SpecialChar',
348    'storagename',
349    'StringEditAdapter',
350    'StringEditSubsystem',
351    'supports_max_fps',
352    'supports_vsync',
353    'TeamNotFoundError',
354    'timestring',
355    'UIScale',
356    'unlock_all_input',
357    'update_internal_logger_levels',
358    'user_agent_string',
359    'utf8_all',
360    'Vec3',
361    'vec3validate',
362    'verify_object_death',
363    'WeakCall',
364    'WidgetNotFoundError',
365    'workspaces_in_use',
366    'DEFAULT_REQUEST_TIMEOUT_SECONDS',
367]
368
369# We want stuff to show up as babase.Foo instead of babase._sub.Foo.
370set_canonical_module_names(globals())
371
372# Allow the native layer to wrap a few things up.
373_babase.reached_end_of_babase()
374
375# Marker we pop down at the very end so other modules can run sanity
376# checks to make sure we aren't importing them reciprocally when they
377# import us.
378_REACHED_END_OF_MODULE = True
class AccountV2Handle:
432class AccountV2Handle:
433    """Handle for interacting with a V2 account.
434
435    This class supports the 'with' statement, which is how it is
436    used with some operations such as cloud messaging.
437    """
438
439    accountid: str
440    tag: str
441    workspacename: str | None
442    workspaceid: str | None
443    logins: dict[LoginType, LoginInfo]
444
445    def __enter__(self) -> None:
446        """Support for "with" statement.
447
448        This allows cloud messages to be sent on our behalf.
449        """
450
451    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
452        """Support for "with" statement.
453
454        This allows cloud messages to be sent on our behalf.
455        """

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

Should be called at standard on_app_loading time.

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

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

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

Should be called with cloud connectivity changes.

def do_get_primary(self) -> AccountV2Handle | None:
277    def do_get_primary(self) -> AccountV2Handle | None:
278        """Internal - should be overridden by subclass."""
279        raise NotImplementedError()

Internal - should be overridden by subclass.

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

A class for high level app functionality and state.

Category: App Classes

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

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

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

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

Our classic subsystem (if available).

plus: baplus.PlusAppSubsystem | None
410    @property
411    def plus(self) -> PlusAppSubsystem | None:
412        """Our plus subsystem (if available)."""
413        return self._get_subsystem_property(
414            'plus', self._create_plus_subsystem
415        )  # type: ignore

Our plus subsystem (if available).

ui_v1: bauiv1.UIV1AppSubsystem
430    @property
431    def ui_v1(self) -> UIV1AppSubsystem:
432        """Our ui_v1 subsystem (always available)."""
433        return self._get_subsystem_property(
434            'ui_v1', self._create_ui_v1_subsystem
435        )  # type: ignore

Our ui_v1 subsystem (always available).

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

Called by the AppSubsystem class. Do not use directly.

def add_shutdown_task(self, coro: Coroutine[NoneType, NoneType, NoneType]) -> None:
461    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
462        """Add a task to be run on app shutdown.
463
464        Note that shutdown tasks will be canceled after
465        App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.
466        """
467        if (
468            self.state is self.State.SHUTTING_DOWN
469            or self.state is self.State.SHUTDOWN_COMPLETE
470        ):
471            stname = self.state.name
472            raise RuntimeError(
473                f'Cannot add shutdown tasks with current state {stname}.'
474            )
475        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:
477    def run(self) -> None:
478        """Run the app to completion.
479
480        Note that this only works on builds where Ballistica manages
481        its own event loop.
482        """
483        _babase.run_app()

Run the app to completion.

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

def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
485    def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
486        """Submit a call to the app threadpool where result is not needed.
487
488        Normally, doing work in a thread-pool involves creating a future
489        and waiting for its result, which is an important step because it
490        propagates any Exceptions raised by the submitted work. When the
491        result in not important, however, this call can be used. The app
492        will log any exceptions that occur.
493        """
494        fut = self.threadpool.submit(call)
495        fut.add_done_callback(self._threadpool_no_wait_done)

Submit a call to the app threadpool where result is not needed.

Normally, doing work in a thread-pool involves creating a future and waiting for its result, which is an important step because it propagates any Exceptions raised by the submitted work. When the result in not important, however, this call can be used. The app will log any exceptions that occur.

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

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

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

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

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

Called by the native layer once its ready to rock.

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

Called by the native layer when the app is suspended.

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

Called by the native layer when the app suspension ends.

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

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

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

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

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

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

Logs things like app-not-responding issues.

@override
def on_app_loading(self) -> None:
391    @override
392    def on_app_loading(self) -> None:
393        # If any traceback dumps happened last run, log and clear them.
394        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:
455    @override
456    def on_app_suspend(self) -> None:
457        assert _babase.in_logic_thread()
458        self._running = False

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
460    @override
461    def on_app_unsuspend(self) -> None:
462        assert _babase.in_logic_thread()
463        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 _supports_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.
36        return cls._supports_intent(intent)
37
38    @classmethod
39    def _supports_intent(cls, intent: AppIntent) -> bool:
40        """Return whether our mode can handle the provided intent.
41
42        AppModes should override this to define what they can handle.
43        Note that AppExperience does not have to be considered here; that
44        is handled automatically by the can_handle_intent() call."""
45        raise NotImplementedError('AppMode subclasses must override this.')
46
47    def handle_intent(self, intent: AppIntent) -> None:
48        """Handle an intent."""
49        raise NotImplementedError('AppMode subclasses must override this.')
50
51    def on_activate(self) -> None:
52        """Called when the mode is being activated."""
53
54    def on_deactivate(self) -> None:
55        """Called when the mode is being deactivated."""
56
57    def on_app_active_changed(self) -> None:
58        """Called when ba*.app.active changes while this mode is active.
59
60        The app-mode may want to take action such as pausing a running
61        game in such cases.
62        """

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 _supports_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.
36        return cls._supports_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 _supports_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:
47    def handle_intent(self, intent: AppIntent) -> None:
48        """Handle an intent."""
49        raise NotImplementedError('AppMode subclasses must override this.')

Handle an intent.

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

Called when the mode is being activated.

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

Called when the mode is being deactivated.

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

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.

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:
552def apptime() -> babase.AppTime:
553    """Return the current app-time in seconds.
554
555    Category: **General Utility Functions**
556
557    App-time is a monotonic time value; it starts at 0.0 when the app
558    launches and will never jump by large amounts or go backwards, even if
559    the system time changes. Its progression will pause when the app is in
560    a suspended state.
561
562    Note that the AppTime returned here is simply float; it just has a
563    unique type in the type-checker's eyes to help prevent it from being
564    accidentally used with time functionality expecting other time types.
565    """
566    import babase  # pylint: disable=cyclic-import
567
568    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:
571def apptimer(time: float, call: Callable[[], Any]) -> None:
572    """Schedule a callable object to run based on app-time.
573
574    Category: **General Utility Functions**
575
576    This function creates a one-off timer which cannot be canceled or
577    modified once created. If you require the ability to do so, or need
578    a repeating timer, use the babase.AppTimer class instead.
579
580    ##### Arguments
581    ###### time (float)
582    > Length of time in seconds that the timer will wait before firing.
583
584    ###### call (Callable[[], Any])
585    > A callable Python object. Note that the timer will retain a
586    strong reference to the callable for as long as the timer exists, so you
587    may want to look into concepts such as babase.WeakCall if that is not
588    desired.
589
590    ##### Examples
591    Print some stuff through time:
592    >>> babase.screenmessage('hello from now!')
593    >>> babase.apptimer(1.0, babase.Call(babase.screenmessage,
594                              'hello from the future!'))
595    >>> babase.apptimer(2.0, babase.Call(babase.screenmessage,
596    ...                       'hello from the future 2!'))
597    """
598    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:
53class AppTimer:
54    """Timers are used to run code at later points in time.
55
56    Category: **General Utility Classes**
57
58    This class encapsulates a timer based on app-time.
59    The underlying timer will be destroyed when this object is no longer
60    referenced. If you do not want to worry about keeping a reference to
61    your timer around, use the babase.apptimer() function instead to get a
62    one-off timer.
63
64    ##### Arguments
65    ###### time
66    > Length of time in seconds that the timer will wait before firing.
67
68    ###### call
69    > A callable Python object. Remember that the timer will retain a
70    strong reference to the callable for as long as it exists, so you
71    may want to look into concepts such as babase.WeakCall if that is not
72    desired.
73
74    ###### repeat
75    > If True, the timer will fire repeatedly, with each successive
76    firing having the same delay as the first.
77
78    ##### Example
79
80    Use a Timer object to print repeatedly for a few seconds:
81    ... def say_it():
82    ...     babase.screenmessage('BADGER!')
83    ... def stop_saying_it():
84    ...     global g_timer
85    ...     g_timer = None
86    ...     babase.screenmessage('MUSHROOM MUSHROOM!')
87    ... # Create our timer; it will run as long as we have the self.t ref.
88    ... g_timer = babase.AppTimer(0.3, say_it, repeat=True)
89    ... # Now fire off a one-shot timer to kill it.
90    ... babase.apptimer(3.89, stop_saying_it)
91    """
92
93    def __init__(
94        self, time: float, call: Callable[[], Any], repeat: bool = False
95    ) -> None:
96        pass

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

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)
93    def __init__(
94        self, time: float, call: Callable[[], Any], repeat: bool = False
95    ) -> None:
96        pass
balog = <Logger ba (WARNING)>
Call = <class 'babase._general._Call'>
def charstr(char_id: SpecialChar) -> str:
621def charstr(char_id: babase.SpecialChar) -> str:
622    """Get a unicode string representing a special character.
623
624    Category: **General Utility Functions**
625
626    Note that these utilize the private-use block of unicode characters
627    (U+E000-U+F8FF) and are specific to the game; exporting or rendering
628    them elsewhere will be meaningless.
629
630    See babase.SpecialChar for the list of available characters.
631    """
632    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:
635def clipboard_get_text() -> str:
636    """Return text currently on the system clipboard.
637
638    Category: **General Utility Functions**
639
640    Ensure that babase.clipboard_has_text() returns True before calling
641     this function.
642    """
643    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:
646def clipboard_has_text() -> bool:
647    """Return whether there is currently text on the clipboard.
648
649    Category: **General Utility Functions**
650
651    This will return False if no system clipboard is available; no need
652     to call babase.clipboard_is_supported() separately.
653    """
654    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:
657def clipboard_is_supported() -> bool:
658    """Return whether this platform supports clipboard operations at all.
659
660    Category: **General Utility Functions**
661
662    If this returns False, UIs should not show 'copy to clipboard'
663    buttons, etc.
664    """
665    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:
668def clipboard_set_text(value: str) -> None:
669    """Copy a string to the system clipboard.
670
671    Category: **General Utility Functions**
672
673    Ensure that babase.clipboard_is_supported() returns True before adding
674     buttons/etc. that make use of this functionality.
675    """
676    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:
 99class ContextCall:
100    """A context-preserving callable.
101
102    Category: **General Utility Classes**
103
104    A ContextCall wraps a callable object along with a reference
105    to the current context (see babase.ContextRef); it handles restoring
106    the context when run and automatically clears itself if the context
107    it belongs to dies.
108
109    Generally you should not need to use this directly; all standard
110    Ballistica callbacks involved with timers, materials, UI functions,
111    etc. handle this under-the-hood so you don't have to worry about it.
112    The only time it may be necessary is if you are implementing your
113    own callbacks, such as a worker thread that does some action and then
114    runs some game code when done. By wrapping said callback in one of
115    these, you can ensure that you will not inadvertently be keeping the
116    current activity alive or running code in a torn-down (expired)
117    context_ref.
118
119    You can also use babase.WeakCall for similar functionality, but
120    ContextCall has the added bonus that it will not run during context_ref
121    shutdown, whereas babase.WeakCall simply looks at whether the target
122    object instance still exists.
123
124    ##### Examples
125    **Example A:** code like this can inadvertently prevent our activity
126    (self) from ending until the operation completes, since the bound
127    method we're passing (self.dosomething) contains a strong-reference
128    to self).
129    >>> start_some_long_action(callback_when_done=self.dosomething)
130
131    **Example B:** in this case our activity (self) can still die
132    properly; the callback will clear itself when the activity starts
133    shutting down, becoming a harmless no-op and releasing the reference
134    to our activity.
135
136    >>> start_long_action(
137    ...     callback_when_done=babase.ContextCall(self.mycallback))
138    """
139
140    def __init__(self, call: Callable) -> None:
141        pass
142
143    def __call__(self) -> None:
144        """Support for calling."""
145        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)
140    def __init__(self, call: Callable) -> None:
141        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:
148class ContextRef:
149    """Store or use a ballistica context.
150
151    Category: **General Utility Classes**
152
153    Many operations such as bascenev1.newnode() or bascenev1.gettexture()
154    operate implicitly on a current 'context'. A context is some sort of
155    state that functionality can implicitly use. Context determines, for
156    example, which scene nodes or textures get added to without having to
157    specify it explicitly in the newnode()/gettexture() call. Contexts can
158    also affect object lifecycles; for example a babase.ContextCall will
159    become a no-op when the context it was created in is destroyed.
160
161    In general, if you are a modder, you should not need to worry about
162    contexts; mod code should mostly be getting run in the correct
163    context and timers and other callbacks will take care of saving
164    and restoring contexts automatically. There may be rare cases,
165    however, where you need to deal directly with contexts, and that is
166    where this class comes in.
167
168    Creating a babase.ContextRef() will capture a reference to the current
169    context. Other modules may provide ways to access their contexts; for
170    example a bascenev1.Activity instance has a 'context' attribute. You
171    can also use babase.ContextRef.empty() to create a reference to *no*
172    context. Some code such as UI calls may expect this and may complain
173    if you try to use them within a context.
174
175    ##### Usage
176    ContextRefs are generally used with the Python 'with' statement, which
177    sets the context they point to as current on entry and resets it to
178    the previous value on exit.
179
180    ##### Example
181    Explicitly create a few UI bits with no context set.
182    (UI stuff may complain if called within a context):
183    >>> with bui.ContextRef.empty():
184    ...     my_container = bui.containerwidget()
185    """
186
187    def __init__(
188        self,
189    ) -> None:
190        pass
191
192    def __enter__(self) -> None:
193        """Support for "with" statement."""
194        pass
195
196    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
197        """Support for "with" statement."""
198        pass
199
200    @classmethod
201    def empty(cls) -> ContextRef:
202        """Return a ContextRef pointing to no context.
203
204        This is useful when code should be run free of a context.
205        For example, UI code generally insists on being run this way.
206        Otherwise, callbacks set on the UI could inadvertently stop working
207        due to a game activity ending, which would be unintuitive behavior.
208        """
209        return ContextRef()
210
211    def is_empty(self) -> bool:
212        """Whether the context was created as empty."""
213        return bool()
214
215    def is_expired(self) -> bool:
216        """Whether the context has expired."""
217        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:
200    @classmethod
201    def empty(cls) -> ContextRef:
202        """Return a ContextRef pointing to no context.
203
204        This is useful when code should be run free of a context.
205        For example, UI code generally insists on being run this way.
206        Otherwise, callbacks set on the UI could inadvertently stop working
207        due to a game activity ending, which would be unintuitive behavior.
208        """
209        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:
211    def is_empty(self) -> bool:
212        """Whether the context was created as empty."""
213        return bool()

Whether the context was created as empty.

def is_expired(self) -> bool:
215    def is_expired(self) -> bool:
216        """Whether the context has expired."""
217        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:
762def displaytime() -> babase.DisplayTime:
763    """Return the current display-time in seconds.
764
765    Category: **General Utility Functions**
766
767    Display-time is a time value intended to be used for animation and other
768    visual purposes. It will generally increment by a consistent amount each
769    frame. It will pass at an overall similar rate to AppTime, but trades
770    accuracy for smoothness.
771
772    Note that the value returned here is simply a float; it just has a
773    unique type in the type-checker's eyes to help prevent it from being
774    accidentally used with time functionality expecting other time types.
775    """
776    import babase  # pylint: disable=cyclic-import
777
778    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:
781def displaytimer(time: float, call: Callable[[], Any]) -> None:
782    """Schedule a callable object to run based on display-time.
783
784    Category: **General Utility Functions**
785
786    This function creates a one-off timer which cannot be canceled or
787    modified once created. If you require the ability to do so, or need
788    a repeating timer, use the babase.DisplayTimer class instead.
789
790    Display-time is a time value intended to be used for animation and other
791    visual purposes. It will generally increment by a consistent amount each
792    frame. It will pass at an overall similar rate to AppTime, but trades
793    accuracy for smoothness.
794
795    ##### Arguments
796    ###### time (float)
797    > Length of time in seconds that the timer will wait before firing.
798
799    ###### call (Callable[[], Any])
800    > A callable Python object. Note that the timer will retain a
801    strong reference to the callable for as long as the timer exists, so you
802    may want to look into concepts such as babase.WeakCall if that is not
803    desired.
804
805    ##### Examples
806    Print some stuff through time:
807    >>> babase.screenmessage('hello from now!')
808    >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage,
809    ...                       'hello from the future!'))
810    >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage,
811    ...                       'hello from the future 2!'))
812    """
813    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:
220class DisplayTimer:
221    """Timers are used to run code at later points in time.
222
223    Category: **General Utility Classes**
224
225    This class encapsulates a timer based on display-time.
226    The underlying timer will be destroyed when this object is no longer
227    referenced. If you do not want to worry about keeping a reference to
228    your timer around, use the babase.displaytimer() function instead to get a
229    one-off timer.
230
231    Display-time is a time value intended to be used for animation and
232    other visual purposes. It will generally increment by a consistent
233    amount each frame. It will pass at an overall similar rate to AppTime,
234    but trades accuracy for smoothness.
235
236    ##### Arguments
237    ###### time
238    > Length of time in seconds that the timer will wait before firing.
239
240    ###### call
241    > A callable Python object. Remember that the timer will retain a
242    strong reference to the callable for as long as it exists, so you
243    may want to look into concepts such as babase.WeakCall if that is not
244    desired.
245
246    ###### repeat
247    > If True, the timer will fire repeatedly, with each successive
248    firing having the same delay as the first.
249
250    ##### Example
251
252    Use a Timer object to print repeatedly for a few seconds:
253    ... def say_it():
254    ...     babase.screenmessage('BADGER!')
255    ... def stop_saying_it():
256    ...     global g_timer
257    ...     g_timer = None
258    ...     babase.screenmessage('MUSHROOM MUSHROOM!')
259    ... # Create our timer; it will run as long as we have the self.t ref.
260    ... g_timer = babase.DisplayTimer(0.3, say_it, repeat=True)
261    ... # Now fire off a one-shot timer to kill it.
262    ... babase.displaytimer(3.89, stop_saying_it)
263    """
264
265    def __init__(
266        self, time: float, call: Callable[[], Any], repeat: bool = False
267    ) -> None:
268        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)
265    def __init__(
266        self, time: float, call: Callable[[], Any], repeat: bool = False
267    ) -> None:
268        pass
def do_once() -> bool:
821def do_once() -> bool:
822    """Return whether this is the first time running a line of code.
823
824    Category: **General Utility Functions**
825
826    This is used by 'print_once()' type calls to keep from overflowing
827    logs. The call functions by registering the filename and line where
828    The call is made from.  Returns True if this location has not been
829    registered already, and False if it has.
830
831    ##### Example
832    This print will only fire for the first loop iteration:
833    >>> for i in range(10):
834    ... if babase.do_once():
835    ...     print('HelloWorld once from loop!')
836    """
837    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 _supports_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:
271class Env:
272    """Unchanging values for the current running app instance.
273    Access the single shared instance of this class at `babase.app.env`.
274    """
275
276    android: bool
277    """Is this build targeting an Android based OS?"""
278
279    api_version: int
280    """The app's api version.
281
282       Only Python modules and packages associated with the current API
283       version number will be detected by the game (see the ba_meta tag).
284       This value will change whenever substantial backward-incompatible
285       changes are introduced to Ballistica APIs. When that happens,
286       modules/packages should be updated accordingly and set to target
287       the newer API version number."""
288
289    arcade: bool
290    """Whether the app is targeting an arcade-centric experience."""
291
292    config_file_path: str
293    """Where the app's config file is stored on disk."""
294
295    data_directory: str
296    """Where bundled static app data lives."""
297
298    debug: bool
299    """Whether the app is running in debug mode.
300
301       Debug builds generally run substantially slower than non-debug
302       builds due to compiler optimizations being disabled and extra
303       checks being run."""
304
305    demo: bool
306    """Whether the app is targeting a demo experience."""
307
308    device_name: str
309    """Human readable name of the device running this app."""
310
311    engine_build_number: int
312    """Integer build number for the engine.
313
314       This value increases by at least 1 with each release of the engine.
315       It is independent of the human readable `version` string."""
316
317    engine_version: str
318    """Human-readable version string for the engine; something like '1.3.24'.
319
320       This should not be interpreted as a number; it may contain
321       string elements such as 'alpha', 'beta', 'test', etc.
322       If a numeric version is needed, use `build_number`."""
323
324    gui: bool
325    """Whether the app is running with a gui.
326
327       This is the opposite of `headless`."""
328
329    headless: bool
330    """Whether the app is running headlessly (without a gui).
331
332       This is the opposite of `gui`."""
333
334    python_directory_app: str | None
335    """Path where the app expects its bundled modules to live.
336
337       Be aware that this value may be None if Ballistica is running in
338       a non-standard environment, and that python-path modifications may
339       cause modules to be loaded from other locations."""
340
341    python_directory_app_site: str | None
342    """Path where the app expects its bundled pip modules to live.
343
344       Be aware that this value may be None if Ballistica is running in
345       a non-standard environment, and that python-path modifications may
346       cause modules to be loaded from other locations."""
347
348    python_directory_user: str | None
349    """Path where the app expects its user scripts (mods) to live.
350
351       Be aware that this value may be None if Ballistica is running in
352       a non-standard environment, and that python-path modifications may
353       cause modules to be loaded from other locations."""
354
355    supports_soft_quit: bool
356    """Whether the running app supports 'soft' quit options.
357
358       This generally applies to mobile derived OSs, where an act of
359       'quitting' may leave the app running in the background waiting
360       in case it is used again."""
361
362    test: bool
363    """Whether the app is running in test mode.
364
365       Test mode enables extra checks and features that are useful for
366       release testing but which do not slow the game down significantly."""
367
368    tv: bool
369    """Whether the app is targeting a TV-centric experience."""
370
371    vr: bool
372    """Whether the app is currently running in VR."""
373
374    pass

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

android: bool

Is this build targeting an Android based OS?

api_version: int

The app's api version.

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

arcade: bool

Whether the app is targeting an arcade-centric experience.

config_file_path: str

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

data_directory: str

Where bundled static app data lives.

debug: bool

Whether the app is running in debug mode.

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

demo: bool

Whether the app is targeting a demo experience.

device_name: str

Human readable name of the device running this app.

engine_build_number: int

Integer build number for the engine.

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

engine_version: str

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

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

gui: bool

Whether the app is running with a gui.

This is the opposite of headless.

headless: bool

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

This is the opposite of gui.

python_directory_app: str | None

Path where the app expects its bundled modules to live.

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

python_directory_app_site: str | None

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

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

python_directory_user: str | None

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

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

supports_soft_quit: bool

Whether the running app supports 'soft' quit options.

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

test: bool

Whether the app is running in test mode.

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

tv: bool

Whether the app is targeting a TV-centric experience.

vr: bool

Whether the app is currently running in VR.

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

A Protocol for objects supporting an exists() method.

Category: Protocols

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

Whether this object exists.

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

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

Category: Gameplay Functions

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

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

def fatal_error(message: str) -> None:
909def fatal_error(message: str) -> None:
910    """Trigger a fatal error. Use this in situations where it is not possible
911    for the engine to continue on in a useful way. This can sometimes
912    help provide more clear information at the exact source of a problem
913    as compared to raising an Exception. In the vast majority of cases,
914    however, Exceptions should be preferred.
915    """
916    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:
214def garbage_collect() -> None:
215    """Run an explicit pass of garbage collection.
216
217    category: General Utility Functions
218
219    May also print warnings/etc. if collection takes too long or if
220    uncollectible objects are found (so use this instead of simply
221    gc.collect().
222    """
223    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:
1006def get_input_idle_time() -> float:
1007    """Return seconds since any local input occurred (touch, keypress, etc.)."""
1008    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:
152def handle_leftover_v1_cloud_log_file() -> None:
153    """Handle an un-uploaded v1-cloud-log from a previous run."""
154
155    # Only applies with classic present.
156    if _babase.app.classic is None:
157        return
158    try:
159        import json
160
161        if os.path.exists(_babase.get_v1_cloud_log_file_path()):
162            with open(
163                _babase.get_v1_cloud_log_file_path(), encoding='utf-8'
164            ) as infile:
165                info = json.loads(infile.read())
166            infile.close()
167            do_send = should_submit_debug_info()
168            if do_send:
169
170                def response(data: Any) -> None:
171                    # Non-None response means we were successful;
172                    # lets kill it.
173                    if data is not None:
174                        try:
175                            os.remove(_babase.get_v1_cloud_log_file_path())
176                        except FileNotFoundError:
177                            # Saw this in the wild. The file just existed
178                            # a moment ago but I suppose something could have
179                            # killed it since. ¯\_(ツ)_/¯
180                            pass
181
182                _babase.app.classic.master_server_v1_post(
183                    'bsLog', info, response
184                )
185            else:
186                # If they don't want logs uploaded just kill it.
187                os.remove(_babase.get_v1_cloud_log_file_path())
188    except Exception:
189        from babase import _error
190
191        _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:
1164def invoke_main_menu() -> None:
1165    """High level call to bring up the main menu if it is not present.
1166
1167    This is essentially the same as pressing the menu button on a controller.
1168    """
1169    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:
27def is_browser_likely_available() -> bool:
28    """Return whether a browser likely exists on the current device.
29
30    category: General Utility Functions
31
32    If this returns False you may want to avoid calling babase.show_url()
33    with any lengthy addresses. (ba.show_url() will display an address
34    as a string in a window if unable to bring up a browser, but that
35    is only useful for simple URLs.)
36    """
37    app = _babase.app
38
39    if app.classic is None:
40        logging.warning(
41            'is_browser_likely_available() needs to be updated'
42            ' to work without classic.'
43        )
44        return True
45
46    platform = app.classic.platform
47    hastouchscreen = _babase.hastouchscreen()
48
49    # If we're on a vr device or an android device with no touchscreen,
50    # assume no browser.
51    # FIXME: Might not be the case anymore; should make this definable
52    #  at the platform level.
53    if app.env.vr or (platform == 'android' and not hastouchscreen):
54        return False
55
56    # Anywhere else assume we've got one.
57    return True

Return whether a browser likely exists on the current device.

category: General Utility Functions

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

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

Return whether a given point is within a given box.

category: General Utility Functions

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

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

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

Set the active app language.

Pass None to use OS default language.

@override
def do_apply_app_config(self) -> None:
272    @override
273    def do_apply_app_config(self) -> None:
274        assert _babase.in_logic_thread()
275        assert isinstance(_babase.app.config, dict)
276        lang = _babase.app.config.get('Lang', self.default_language)
277        if lang != self._language:
278            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:
280    def get_resource(
281        self,
282        resource: str,
283        fallback_resource: str | None = None,
284        fallback_value: Any = None,
285    ) -> Any:
286        """Return a translation resource by name.
287
288        DEPRECATED; use babase.Lstr functionality for these purposes.
289        """
290        try:
291            # If we have no language set, try and set it to english.
292            # Also make a fuss because we should try to avoid this.
293            if self._language_merged is None:
294                try:
295                    if _babase.do_once():
296                        logging.warning(
297                            'get_resource() called before language'
298                            ' set; falling back to english.'
299                        )
300                    self.setlanguage(
301                        'English', print_change=False, store_to_config=False
302                    )
303                except Exception:
304                    logging.exception(
305                        'Error setting fallback english language.'
306                    )
307                    raise
308
309            # If they provided a fallback_resource value, try the
310            # target-language-only dict first and then fall back to
311            # trying the fallback_resource value in the merged dict.
312            if fallback_resource is not None:
313                try:
314                    values = self._language_target
315                    splits = resource.split('.')
316                    dicts = splits[:-1]
317                    key = splits[-1]
318                    for dct in dicts:
319                        assert values is not None
320                        values = values[dct]
321                    assert values is not None
322                    val = values[key]
323                    return val
324                except Exception:
325                    # FIXME: Shouldn't we try the fallback resource in
326                    #  the merged dict AFTER we try the main resource in
327                    #  the merged dict?
328                    try:
329                        values = self._language_merged
330                        splits = fallback_resource.split('.')
331                        dicts = splits[:-1]
332                        key = splits[-1]
333                        for dct in dicts:
334                            assert values is not None
335                            values = values[dct]
336                        assert values is not None
337                        val = values[key]
338                        return val
339
340                    except Exception:
341                        # If we got nothing for fallback_resource,
342                        # default to the normal code which checks or
343                        # primary value in the merge dict; there's a
344                        # chance we can get an english value for it
345                        # (which we weren't looking for the first time
346                        # through).
347                        pass
348
349            values = self._language_merged
350            splits = resource.split('.')
351            dicts = splits[:-1]
352            key = splits[-1]
353            for dct in dicts:
354                assert values is not None
355                values = values[dct]
356            assert values is not None
357            val = values[key]
358            return val
359
360        except Exception:
361            # Ok, looks like we couldn't find our main or fallback
362            # resource anywhere. Now if we've been given a fallback
363            # value, return it; otherwise fail.
364            from babase import _error
365
366            if fallback_value is not None:
367                return fallback_value
368            raise _error.NotFoundError(
369                f"Resource not found: '{resource}'"
370            ) 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:
372    def translate(
373        self,
374        category: str,
375        strval: str,
376        raise_exceptions: bool = False,
377        print_errors: bool = False,
378    ) -> str:
379        """Translate a value (or return the value if no translation available)
380
381        DEPRECATED; use babase.Lstr functionality for these purposes.
382        """
383        try:
384            translated = self.get_resource('translations')[category][strval]
385        except Exception as exc:
386            if raise_exceptions:
387                raise
388            if print_errors:
389                print(
390                    (
391                        'Translate error: category=\''
392                        + category
393                        + '\' name=\''
394                        + strval
395                        + '\' exc='
396                        + str(exc)
397                        + ''
398                    )
399                )
400            translated = None
401        translated_out: str
402        if translated is None:
403            translated_out = strval
404        else:
405            translated_out = translated
406        assert isinstance(translated_out, str)
407        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:
409    def is_custom_unicode_char(self, char: str) -> bool:
410        """Return whether a char is in the custom unicode range we use."""
411        assert isinstance(char, str)
412        if len(char) != 1:
413            raise ValueError('Invalid Input; must be length 1')
414        return 0xE000 <= ord(char) <= 0xF8FF

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

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:
491class Lstr:
492    """Used to define strings in a language-independent way.
493
494    Category: **General Utility Classes**
495
496    These should be used whenever possible in place of hard-coded
497    strings so that in-game or UI elements show up correctly on all
498    clients in their currently-active language.
499
500    To see available resource keys, look at any of the bs_language_*.py
501    files in the game or the translations pages at
502    legacy.ballistica.net/translate.
503
504    ##### Examples
505    EXAMPLE 1: specify a string from a resource path
506    >>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
507
508    EXAMPLE 2: specify a translated string via a category and english
509    value; if a translated value is available, it will be used; otherwise
510    the english value will be. To see available translation categories,
511    look under the 'translations' resource section.
512    >>> mynode.text = babase.Lstr(translate=('gameDescriptions',
513    ...                                  'Defeat all enemies'))
514
515    EXAMPLE 3: specify a raw value and some substitutions. Substitutions
516    can be used with resource and translate modes as well.
517    >>> mynode.text = babase.Lstr(value='${A} / ${B}',
518    ...               subs=[('${A}', str(score)), ('${B}', str(total))])
519
520    EXAMPLE 4: babase.Lstr's can be nested. This example would display the
521    resource at res_a but replace ${NAME} with the value of the
522    resource at res_b
523    >>> mytextnode.text = babase.Lstr(
524    ...     resource='res_a',
525    ...     subs=[('${NAME}', babase.Lstr(resource='res_b'))])
526    """
527
528    # pylint: disable=dangerous-default-value
529    # noinspection PyDefaultArgument
530    @overload
531    def __init__(
532        self,
533        *,
534        resource: str,
535        fallback_resource: str = '',
536        fallback_value: str = '',
537        subs: Sequence[tuple[str, str | Lstr]] = [],
538    ) -> None:
539        """Create an Lstr from a string resource."""
540
541    # noinspection PyShadowingNames,PyDefaultArgument
542    @overload
543    def __init__(
544        self,
545        *,
546        translate: tuple[str, str],
547        subs: Sequence[tuple[str, str | Lstr]] = [],
548    ) -> None:
549        """Create an Lstr by translating a string in a category."""
550
551    # noinspection PyDefaultArgument
552    @overload
553    def __init__(
554        self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = []
555    ) -> None:
556        """Create an Lstr from a raw string value."""
557
558    # pylint: enable=redefined-outer-name, dangerous-default-value
559
560    def __init__(self, *args: Any, **keywds: Any) -> None:
561        """Instantiate a Lstr.
562
563        Pass a value for either 'resource', 'translate',
564        or 'value'. (see Lstr help for examples).
565        'subs' can be a sequence of 2-member sequences consisting of values
566        and replacements.
567        'fallback_resource' can be a resource key that will be used if the
568        main one is not present for
569        the current language in place of falling back to the english value
570        ('resource' mode only).
571        'fallback_value' can be a literal string that will be used if neither
572        the resource nor the fallback resource is found ('resource' mode only).
573        """
574        # pylint: disable=too-many-branches
575        if args:
576            raise TypeError('Lstr accepts only keyword arguments')
577
578        # Basically just store the exact args they passed.
579        # However if they passed any Lstr values for subs,
580        # replace them with that Lstr's dict.
581        self.args = keywds
582        our_type = type(self)
583
584        if isinstance(self.args.get('value'), our_type):
585            raise TypeError("'value' must be a regular string; not an Lstr")
586
587        if 'subs' in self.args:
588            subs_new = []
589            for key, value in keywds['subs']:
590                if isinstance(value, our_type):
591                    subs_new.append((key, value.args))
592                else:
593                    subs_new.append((key, value))
594            self.args['subs'] = subs_new
595
596        # As of protocol 31 we support compact key names
597        # ('t' instead of 'translate', etc). Convert as needed.
598        if 'translate' in keywds:
599            keywds['t'] = keywds['translate']
600            del keywds['translate']
601        if 'resource' in keywds:
602            keywds['r'] = keywds['resource']
603            del keywds['resource']
604        if 'value' in keywds:
605            keywds['v'] = keywds['value']
606            del keywds['value']
607        if 'fallback' in keywds:
608            from babase import _error
609
610            _error.print_error(
611                'deprecated "fallback" arg passed to Lstr(); use '
612                'either "fallback_resource" or "fallback_value"',
613                once=True,
614            )
615            keywds['f'] = keywds['fallback']
616            del keywds['fallback']
617        if 'fallback_resource' in keywds:
618            keywds['f'] = keywds['fallback_resource']
619            del keywds['fallback_resource']
620        if 'subs' in keywds:
621            keywds['s'] = keywds['subs']
622            del keywds['subs']
623        if 'fallback_value' in keywds:
624            keywds['fv'] = keywds['fallback_value']
625            del keywds['fallback_value']
626
627    def evaluate(self) -> str:
628        """Evaluate the Lstr and returns a flat string in the current language.
629
630        You should avoid doing this as much as possible and instead pass
631        and store Lstr values.
632        """
633        return _babase.evaluate_lstr(self._get_json())
634
635    def is_flat_value(self) -> bool:
636        """Return whether the Lstr is a 'flat' value.
637
638        This is defined as a simple string value incorporating no
639        translations, resources, or substitutions. In this case it may
640        be reasonable to replace it with a raw string value, perform
641        string manipulation on it, etc.
642        """
643        return bool('v' in self.args and not self.args.get('s', []))
644
645    def _get_json(self) -> str:
646        try:
647            return json.dumps(self.args, separators=(',', ':'))
648        except Exception:
649            from babase import _error
650
651            _error.print_exception('_get_json failed for', self.args)
652            return 'JSON_ERR'
653
654    @override
655    def __str__(self) -> str:
656        return '<ba.Lstr: ' + self._get_json() + '>'
657
658    @override
659    def __repr__(self) -> str:
660        return '<ba.Lstr: ' + self._get_json() + '>'
661
662    @staticmethod
663    def from_json(json_string: str) -> babase.Lstr:
664        """Given a json string, returns a babase.Lstr. Does no validation."""
665        lstr = Lstr(value='')
666        lstr.args = json.loads(json_string)
667        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)
560    def __init__(self, *args: Any, **keywds: Any) -> None:
561        """Instantiate a Lstr.
562
563        Pass a value for either 'resource', 'translate',
564        or 'value'. (see Lstr help for examples).
565        'subs' can be a sequence of 2-member sequences consisting of values
566        and replacements.
567        'fallback_resource' can be a resource key that will be used if the
568        main one is not present for
569        the current language in place of falling back to the english value
570        ('resource' mode only).
571        'fallback_value' can be a literal string that will be used if neither
572        the resource nor the fallback resource is found ('resource' mode only).
573        """
574        # pylint: disable=too-many-branches
575        if args:
576            raise TypeError('Lstr accepts only keyword arguments')
577
578        # Basically just store the exact args they passed.
579        # However if they passed any Lstr values for subs,
580        # replace them with that Lstr's dict.
581        self.args = keywds
582        our_type = type(self)
583
584        if isinstance(self.args.get('value'), our_type):
585            raise TypeError("'value' must be a regular string; not an Lstr")
586
587        if 'subs' in self.args:
588            subs_new = []
589            for key, value in keywds['subs']:
590                if isinstance(value, our_type):
591                    subs_new.append((key, value.args))
592                else:
593                    subs_new.append((key, value))
594            self.args['subs'] = subs_new
595
596        # As of protocol 31 we support compact key names
597        # ('t' instead of 'translate', etc). Convert as needed.
598        if 'translate' in keywds:
599            keywds['t'] = keywds['translate']
600            del keywds['translate']
601        if 'resource' in keywds:
602            keywds['r'] = keywds['resource']
603            del keywds['resource']
604        if 'value' in keywds:
605            keywds['v'] = keywds['value']
606            del keywds['value']
607        if 'fallback' in keywds:
608            from babase import _error
609
610            _error.print_error(
611                'deprecated "fallback" arg passed to Lstr(); use '
612                'either "fallback_resource" or "fallback_value"',
613                once=True,
614            )
615            keywds['f'] = keywds['fallback']
616            del keywds['fallback']
617        if 'fallback_resource' in keywds:
618            keywds['f'] = keywds['fallback_resource']
619            del keywds['fallback_resource']
620        if 'subs' in keywds:
621            keywds['s'] = keywds['subs']
622            del keywds['subs']
623        if 'fallback_value' in keywds:
624            keywds['fv'] = keywds['fallback_value']
625            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:
627    def evaluate(self) -> str:
628        """Evaluate the Lstr and returns a flat string in the current language.
629
630        You should avoid doing this as much as possible and instead pass
631        and store Lstr values.
632        """
633        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:
635    def is_flat_value(self) -> bool:
636        """Return whether the Lstr is a 'flat' value.
637
638        This is defined as a simple string value incorporating no
639        translations, resources, or substitutions. In this case it may
640        be reasonable to replace it with a raw string value, perform
641        string manipulation on it, etc.
642        """
643        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:
662    @staticmethod
663    def from_json(json_string: str) -> babase.Lstr:
664        """Given a json string, returns a babase.Lstr. Does no validation."""
665        lstr = Lstr(value='')
666        lstr.args = json.loads(json_string)
667        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:
1292def native_stack_trace() -> str | None:
1293    """Return a native stack trace as a string, or None if not available.
1294
1295    Category: **General Utility Functions**
1296
1297    Stack traces contain different data and formatting across platforms.
1298    Only use them for debugging.
1299    """
1300    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:
1329def open_url(address: str, force_fallback: bool = False) -> None:
1330    """Open the provided URL.
1331
1332    Category: **General Utility Functions**
1333
1334    Attempts to open the provided url in a web-browser. If that is not
1335    possible (or force_fallback is True), instead displays the url as
1336    a string and/or qrcode.
1337    """
1338    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:
1341def overlay_web_browser_close() -> bool:
1342    """Close any open overlay web browser.
1343
1344    Category: **General Utility Functions**
1345    """
1346    return bool()

Close any open overlay web browser.

Category: General Utility Functions

def overlay_web_browser_is_open() -> bool:
1349def overlay_web_browser_is_open() -> bool:
1350    """Return whether an overlay web browser is open currently.
1351
1352    Category: **General Utility Functions**
1353    """
1354    return bool()

Return whether an overlay web browser is open currently.

Category: General Utility Functions

def overlay_web_browser_is_supported() -> bool:
1357def overlay_web_browser_is_supported() -> bool:
1358    """Return whether an overlay web browser is supported here.
1359
1360    Category: **General Utility Functions**
1361
1362    An overlay web browser is a small dialog that pops up over the top
1363    of the main engine window. It can be used for performing simple
1364    tasks such as sign-ins.
1365    """
1366    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:
1369def overlay_web_browser_open_url(address: str) -> None:
1370    """Open the provided URL in an overlayw web browser.
1371
1372    Category: **General Utility Functions**
1373
1374    An overlay web browser is a small dialog that pops up over the top
1375    of the main engine window. It can be used for performing simple
1376    tasks such as sign-ins.
1377    """
1378    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:
1413def pushcall(
1414    call: Callable,
1415    from_other_thread: bool = False,
1416    suppress_other_thread_warning: bool = False,
1417    other_thread_use_fg_context: bool = False,
1418    raw: bool = False,
1419) -> None:
1420    """Push a call to the logic event-loop.
1421    Category: **General Utility Functions**
1422
1423    This call expects to be used in the logic thread, and will automatically
1424    save and restore the babase.Context to behave seamlessly.
1425
1426    If you want to push a call from outside of the logic thread,
1427    however, you can pass 'from_other_thread' as True. In this case
1428    the call will always run in the UI context_ref on the logic thread
1429    or whichever context_ref is in the foreground if
1430    other_thread_use_fg_context is True.
1431    Passing raw=True will disable thread checks and context_ref sets/restores.
1432    """
1433    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:
1437def quit(
1438    confirm: bool = False, quit_type: babase.QuitType | None = None
1439) -> None:
1440    """Quit the app.
1441
1442    Category: **General Utility Functions**
1443
1444    If 'confirm' is True, a confirm dialog will be presented if conditions
1445    allow; otherwise the quit will still be immediate.
1446    See docs for babase.QuitType for explanations of the optional
1447    'quit_type' arg.
1448    """
1449    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, ...]:
1487def safecolor(
1488    color: Sequence[float], target_intensity: float = 0.6
1489) -> tuple[float, ...]:
1490    """Given a color tuple, return a color safe to display as text.
1491
1492    Category: **General Utility Functions**
1493
1494    Accepts tuples of length 3 or 4. This will slightly brighten very
1495    dark colors, etc.
1496    """
1497    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:
1500def screenmessage(
1501    message: str | babase.Lstr,
1502    color: Sequence[float] | None = None,
1503    log: bool = False,
1504) -> None:
1505    """Print a message to the local client's screen, in a given color.
1506
1507    Category: **General Utility Functions**
1508
1509    Note that this version of the function is purely for local display.
1510    To broadcast screen messages in network play, look for methods such as
1511    broadcastmessage() provided by the scene-version packages.
1512    """
1513    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:
1516def set_analytics_screen(screen: str) -> None:
1517    """Used for analytics to see where in the app players spend their time.
1518
1519    Category: **General Utility Functions**
1520
1521    Generally called when opening a new window or entering some UI.
1522    'screen' should be a string description of an app location
1523    ('Main Menu', etc.)
1524    """
1525    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:
383class SimpleSound:
384    """A simple sound wrapper for internal use.
385
386    Do not use for gameplay code as it will only play locally.
387    """
388
389    def play(self) -> None:
390        """Play the sound locally."""
391        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:
389    def play(self) -> None:
390        """Play the sound locally."""
391        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:
334def storagename(suffix: str | None = None) -> str:
335    """Generate a unique name for storing class data in shared places.
336
337    Category: **General Utility Functions**
338
339    This consists of a leading underscore, the module path at the
340    call site with dots replaced by underscores, the containing class's
341    qualified name, and the provided suffix. When storing data in public
342    places such as 'customdata' dicts, this minimizes the chance of
343    collisions with other similarly named classes.
344
345    Note that this will function even if called in the class definition.
346
347    ##### Examples
348    Generate a unique name for storage purposes:
349    >>> class MyThingie:
350    ...     # This will give something like
351    ...     # '_mymodule_submodule_mythingie_data'.
352    ...     _STORENAME = babase.storagename('data')
353    ...
354    ...     # Use that name to store some data in the Activity we were
355    ...     # passed.
356    ...     def __init__(self, activity):
357    ...         activity.customdata[self._STORENAME] = {}
358    """
359    frame = inspect.currentframe()
360    if frame is None:
361        raise RuntimeError('Cannot get current stack frame.')
362    fback = frame.f_back
363
364    # Note: We need to explicitly clear frame here to avoid a ref-loop
365    # that keeps all function-dicts in the stack alive until the next
366    # full GC cycle (the stack frame refers to this function's dict,
367    # which refers to the stack frame).
368    del frame
369
370    if fback is None:
371        raise RuntimeError('Cannot get parent stack frame.')
372    modulepath = fback.f_globals.get('__name__')
373    if modulepath is None:
374        raise RuntimeError('Cannot get parent stack module path.')
375    assert isinstance(modulepath, str)
376    qualname = fback.f_locals.get('__qualname__')
377    if qualname is not None:
378        assert isinstance(qualname, str)
379        fullpath = f'_{modulepath}_{qualname.lower()}'
380    else:
381        fullpath = f'_{modulepath}'
382    if suffix is not None:
383        fullpath = f'{fullpath}_{suffix}'
384    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
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:
1678def update_internal_logger_levels() -> None:
1679    """Update the native layer to re-cache Python logger levels.
1680
1681    The native layer caches logger levels so it can efficiently
1682    avoid making Python log calls for disabled logger levels. If any
1683    logger levels are changed at runtime, call this method after to
1684    instruct the native layer to regenerate its cache so the change
1685    is properly reflected in logs originating from the native layer.
1686    """
1687    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 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]):
394class Vec3(Sequence[float]):
395    """A vector of 3 floats.
396
397    Category: **General Utility Classes**
398
399    These can be created the following ways (checked in this order):
400    - with no args, all values are set to 0
401    - with a single numeric arg, all values are set to that value
402    - with a single three-member sequence arg, sequence values are copied
403    - otherwise assumes individual x/y/z args (positional or keywords)
404    """
405
406    x: float
407    """The vector's X component."""
408
409    y: float
410    """The vector's Y component."""
411
412    z: float
413    """The vector's Z component."""
414
415    # pylint: disable=function-redefined
416
417    @overload
418    def __init__(self) -> None:
419        pass
420
421    @overload
422    def __init__(self, value: float):
423        pass
424
425    @overload
426    def __init__(self, values: Sequence[float]):
427        pass
428
429    @overload
430    def __init__(self, x: float, y: float, z: float):
431        pass
432
433    def __init__(self, *args: Any, **kwds: Any):
434        pass
435
436    def __add__(self, other: Vec3) -> Vec3:
437        return self
438
439    def __sub__(self, other: Vec3) -> Vec3:
440        return self
441
442    @overload
443    def __mul__(self, other: float) -> Vec3:
444        return self
445
446    @overload
447    def __mul__(self, other: Sequence[float]) -> Vec3:
448        return self
449
450    def __mul__(self, other: Any) -> Any:
451        return self
452
453    @overload
454    def __rmul__(self, other: float) -> Vec3:
455        return self
456
457    @overload
458    def __rmul__(self, other: Sequence[float]) -> Vec3:
459        return self
460
461    def __rmul__(self, other: Any) -> Any:
462        return self
463
464    # (for index access)
465    @override
466    def __getitem__(self, typeargs: Any) -> Any:
467        return 0.0
468
469    @override
470    def __len__(self) -> int:
471        return 3
472
473    # (for iterator access)
474    @override
475    def __iter__(self) -> Any:
476        return self
477
478    def __next__(self) -> float:
479        return 0.0
480
481    def __neg__(self) -> Vec3:
482        return self
483
484    def __setitem__(self, index: int, val: float) -> None:
485        pass
486
487    def cross(self, other: Vec3) -> Vec3:
488        """Returns the cross product of this vector and another."""
489        return Vec3()
490
491    def dot(self, other: Vec3) -> float:
492        """Returns the dot product of this vector and another."""
493        return float()
494
495    def length(self) -> float:
496        """Returns the length of the vector."""
497        return float()
498
499    def normalized(self) -> Vec3:
500        """Returns a normalized version of the vector."""
501        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)
433    def __init__(self, *args: Any, **kwds: Any):
434        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:
487    def cross(self, other: Vec3) -> Vec3:
488        """Returns the cross product of this vector and another."""
489        return Vec3()

Returns the cross product of this vector and another.

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

Returns the dot product of this vector and another.

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

Returns the length of the vector.

def normalized(self) -> _babase.Vec3:
499    def normalized(self) -> Vec3:
500        """Returns a normalized version of the vector."""
501        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:
292def verify_object_death(obj: object) -> None:
293    """Warn if an object does not get freed within a short period.
294
295    Category: **General Utility Functions**
296
297    This can be handy to detect and prevent memory/resource leaks.
298    """
299
300    try:
301        ref = weakref.ref(obj)
302    except Exception:
303        logging.exception('Unable to create weak-ref in verify_object_death')
304        return
305
306    # Use a slight range for our checks so they don't all land at once
307    # if we queue a lot of them.
308    delay = random.uniform(2.0, 5.5)
309
310    # Make this timer in an empty context; don't want it dying with the
311    # scene/etc.
312    with _babase.ContextRef.empty():
313        _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