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# The stuff we expose here at the top level is our 'public' api for use 13# from other modules/packages. Code *within* this package should import 14# things from this package's submodules directly to reduce the chance of 15# dependency loops. The exception is TYPE_CHECKING blocks and 16# annotations since those aren't evaluated at runtime. 17 18from efro.util import set_canonical_module_names 19 20 21import _babase 22from _babase import ( 23 add_clean_frame_callback, 24 allows_ticket_sales, 25 android_get_external_files_dir, 26 appname, 27 appnameupper, 28 apptime, 29 apptimer, 30 AppTimer, 31 asset_loads_allowed, 32 fullscreen_control_available, 33 fullscreen_control_get, 34 fullscreen_control_key_shortcut, 35 fullscreen_control_set, 36 charstr, 37 clipboard_get_text, 38 clipboard_has_text, 39 clipboard_is_supported, 40 clipboard_set_text, 41 ContextCall, 42 ContextRef, 43 displaytime, 44 displaytimer, 45 DisplayTimer, 46 do_once, 47 env, 48 Env, 49 fade_screen, 50 fatal_error, 51 get_display_resolution, 52 get_immediate_return_code, 53 get_input_idle_time, 54 get_low_level_config_value, 55 get_max_graphics_quality, 56 get_replays_dir, 57 get_string_height, 58 get_string_width, 59 get_ui_scale, 60 get_v1_cloud_log_file_path, 61 getsimplesound, 62 has_user_run_commands, 63 have_chars, 64 have_permission, 65 in_logic_thread, 66 in_main_menu, 67 increment_analytics_count, 68 invoke_main_menu, 69 is_os_playing_music, 70 is_xcode_build, 71 lock_all_input, 72 mac_music_app_get_playlists, 73 mac_music_app_get_volume, 74 mac_music_app_init, 75 mac_music_app_play_playlist, 76 mac_music_app_set_volume, 77 mac_music_app_stop, 78 music_player_play, 79 music_player_set_volume, 80 music_player_shutdown, 81 music_player_stop, 82 native_review_request, 83 native_review_request_supported, 84 native_stack_trace, 85 open_file_externally, 86 open_url, 87 overlay_web_browser_close, 88 overlay_web_browser_is_open, 89 overlay_web_browser_is_supported, 90 overlay_web_browser_open_url, 91 print_load_info, 92 push_back_press, 93 pushcall, 94 quit, 95 reload_media, 96 request_permission, 97 safecolor, 98 screenmessage, 99 set_analytics_screen, 100 set_low_level_config_value, 101 set_thread_name, 102 set_ui_input_device, 103 set_ui_scale, 104 show_progress_bar, 105 shutdown_suppress_begin, 106 shutdown_suppress_end, 107 shutdown_suppress_count, 108 SimpleSound, 109 supports_max_fps, 110 supports_vsync, 111 unlock_all_input, 112 user_agent_string, 113 Vec3, 114 workspaces_in_use, 115) 116 117from babase._accountv2 import AccountV2Handle, AccountV2Subsystem 118from babase._app import App 119from babase._appconfig import commit_app_config 120from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec 121from babase._appmode import AppMode 122from babase._appsubsystem import AppSubsystem 123from babase._appmodeselector import AppModeSelector 124from babase._appconfig import AppConfig 125from babase._apputils import ( 126 handle_leftover_v1_cloud_log_file, 127 is_browser_likely_available, 128 garbage_collect, 129 get_remote_app_name, 130 AppHealthMonitor, 131) 132from babase._devconsole import ( 133 DevConsoleTab, 134 DevConsoleTabEntry, 135 DevConsoleSubsystem, 136) 137from babase._emptyappmode import EmptyAppMode 138from babase._error import ( 139 print_exception, 140 print_error, 141 ContextError, 142 NotFoundError, 143 PlayerNotFoundError, 144 SessionPlayerNotFoundError, 145 NodeNotFoundError, 146 ActorNotFoundError, 147 InputDeviceNotFoundError, 148 WidgetNotFoundError, 149 ActivityNotFoundError, 150 TeamNotFoundError, 151 MapNotFoundError, 152 SessionTeamNotFoundError, 153 SessionNotFoundError, 154 DelegateNotFoundError, 155) 156from babase._general import ( 157 utf8_all, 158 DisplayTime, 159 AppTime, 160 WeakCall, 161 Call, 162 existing, 163 Existable, 164 verify_object_death, 165 storagename, 166 getclass, 167 get_type_name, 168) 169from babase._language import Lstr, LanguageSubsystem 170from babase._login import LoginAdapter, LoginInfo 171 172# noinspection PyProtectedMember 173# (PyCharm inspection bug?) 174from babase._mgen.enums import ( 175 Permission, 176 SpecialChar, 177 InputType, 178 UIScale, 179 QuitType, 180) 181from babase._math import normalized_color, is_point_in_box, vec3validate 182from babase._meta import MetadataSubsystem 183from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS 184from babase._plugin import PluginSpec, Plugin, PluginSubsystem 185from babase._stringedit import StringEditAdapter, StringEditSubsystem 186from babase._text import timestring 187 188_babase.app = app = App() 189app.postinit() 190 191__all__ = [ 192 'AccountV2Handle', 193 'AccountV2Subsystem', 194 'ActivityNotFoundError', 195 'ActorNotFoundError', 196 'allows_ticket_sales', 197 'add_clean_frame_callback', 198 'android_get_external_files_dir', 199 'app', 200 'app', 201 'App', 202 'AppConfig', 203 'AppHealthMonitor', 204 'AppIntent', 205 'AppIntentDefault', 206 'AppIntentExec', 207 'AppMode', 208 'appname', 209 'appnameupper', 210 'AppModeSelector', 211 'AppSubsystem', 212 'apptime', 213 'AppTime', 214 'apptime', 215 'apptimer', 216 'AppTimer', 217 'asset_loads_allowed', 218 'Call', 219 'fullscreen_control_available', 220 'fullscreen_control_get', 221 'fullscreen_control_key_shortcut', 222 'fullscreen_control_set', 223 'charstr', 224 'clipboard_get_text', 225 'clipboard_has_text', 226 'clipboard_is_supported', 227 'clipboard_set_text', 228 'commit_app_config', 229 'ContextCall', 230 'ContextError', 231 'ContextRef', 232 'DelegateNotFoundError', 233 'DevConsoleTab', 234 'DevConsoleTabEntry', 235 'DevConsoleSubsystem', 236 'DisplayTime', 237 'displaytime', 238 'displaytimer', 239 'DisplayTimer', 240 'do_once', 241 'EmptyAppMode', 242 'env', 243 'Env', 244 'Existable', 245 'existing', 246 'fade_screen', 247 'fatal_error', 248 'garbage_collect', 249 'get_display_resolution', 250 'get_immediate_return_code', 251 'get_input_idle_time', 252 'get_ip_address_type', 253 'get_low_level_config_value', 254 'get_max_graphics_quality', 255 'get_remote_app_name', 256 'get_replays_dir', 257 'get_string_height', 258 'get_string_width', 259 'get_type_name', 260 'get_ui_scale', 261 'get_v1_cloud_log_file_path', 262 'getclass', 263 'getsimplesound', 264 'handle_leftover_v1_cloud_log_file', 265 'has_user_run_commands', 266 'have_chars', 267 'have_permission', 268 'in_logic_thread', 269 'in_main_menu', 270 'increment_analytics_count', 271 'InputDeviceNotFoundError', 272 'InputType', 273 'invoke_main_menu', 274 'is_browser_likely_available', 275 'is_browser_likely_available', 276 'is_os_playing_music', 277 'is_point_in_box', 278 'is_xcode_build', 279 'LanguageSubsystem', 280 'lock_all_input', 281 'LoginAdapter', 282 'LoginInfo', 283 'Lstr', 284 'mac_music_app_get_playlists', 285 'mac_music_app_get_volume', 286 'mac_music_app_init', 287 'mac_music_app_play_playlist', 288 'mac_music_app_set_volume', 289 'mac_music_app_stop', 290 'MapNotFoundError', 291 'MetadataSubsystem', 292 'music_player_play', 293 'music_player_set_volume', 294 'music_player_shutdown', 295 'music_player_stop', 296 'native_review_request', 297 'native_review_request_supported', 298 'native_stack_trace', 299 'NodeNotFoundError', 300 'normalized_color', 301 'NotFoundError', 302 'open_file_externally', 303 'open_url', 304 'overlay_web_browser_close', 305 'overlay_web_browser_is_open', 306 'overlay_web_browser_is_supported', 307 'overlay_web_browser_open_url', 308 'Permission', 309 'PlayerNotFoundError', 310 'Plugin', 311 'PluginSubsystem', 312 'PluginSpec', 313 'print_error', 314 'print_exception', 315 'print_load_info', 316 'push_back_press', 317 'pushcall', 318 'quit', 319 'QuitType', 320 'reload_media', 321 'request_permission', 322 'safecolor', 323 'screenmessage', 324 'SessionNotFoundError', 325 'SessionPlayerNotFoundError', 326 'SessionTeamNotFoundError', 327 'set_analytics_screen', 328 'set_low_level_config_value', 329 'set_thread_name', 330 'set_ui_input_device', 331 'set_ui_scale', 332 'show_progress_bar', 333 'shutdown_suppress_begin', 334 'shutdown_suppress_end', 335 'shutdown_suppress_count', 336 'SimpleSound', 337 'SpecialChar', 338 'storagename', 339 'StringEditAdapter', 340 'StringEditSubsystem', 341 'supports_max_fps', 342 'supports_vsync', 343 'TeamNotFoundError', 344 'timestring', 345 'UIScale', 346 'unlock_all_input', 347 'user_agent_string', 348 'utf8_all', 349 'Vec3', 350 'vec3validate', 351 'verify_object_death', 352 'WeakCall', 353 'WidgetNotFoundError', 354 'workspaces_in_use', 355 'DEFAULT_REQUEST_TIMEOUT_SECONDS', 356] 357 358# We want stuff to show up as babase.Foo instead of babase._sub.Foo. 359set_canonical_module_names(globals()) 360 361# Allow the native layer to wrap a few things up. 362_babase.reached_end_of_babase() 363 364# Marker we pop down at the very end so other modules can run sanity 365# checks to make sure we aren't importing them reciprocally when they 366# import us. 367_REACHED_END_OF_MODULE = True
426class AccountV2Handle: 427 """Handle for interacting with a V2 account. 428 429 This class supports the 'with' statement, which is how it is 430 used with some operations such as cloud messaging. 431 """ 432 433 accountid: str 434 tag: str 435 workspacename: str | None 436 workspaceid: str | None 437 logins: dict[LoginType, LoginInfo] 438 439 def __enter__(self) -> None: 440 """Support for "with" statement. 441 442 This allows cloud messages to be sent on our behalf. 443 """ 444 445 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: 446 """Support for "with" statement. 447 448 This allows cloud messages to be sent on our behalf. 449 """
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.
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 from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter 36 37 # Whether or not everything related to an initial login 38 # (or lack thereof) has completed. This includes things like 39 # workspace syncing. Completion of this is what flips the app 40 # into 'running' state. 41 self._initial_sign_in_completed = False 42 43 self._kicked_off_workspace_load = False 44 45 self.login_adapters: dict[LoginType, LoginAdapter] = {} 46 47 self._implicit_signed_in_adapter: LoginAdapter | None = None 48 self._implicit_state_changed = False 49 self._can_do_auto_sign_in = True 50 51 adapter: LoginAdapter 52 if _babase.using_google_play_game_services(): 53 adapter = LoginAdapterGPGS() 54 self.login_adapters[adapter.login_type] = adapter 55 if _babase.using_game_center(): 56 adapter = LoginAdapterGameCenter() 57 self.login_adapters[adapter.login_type] = adapter 58 59 def on_app_loading(self) -> None: 60 """Should be called at standard on_app_loading time.""" 61 62 for adapter in self.login_adapters.values(): 63 adapter.on_app_loading() 64 65 def have_primary_credentials(self) -> bool: 66 """Are credentials currently set for the primary app account? 67 68 Note that this does not mean these credentials have been checked 69 for validity; only that they exist. If/when credentials are 70 validated, the 'primary' account handle will be set. 71 """ 72 raise NotImplementedError() 73 74 @property 75 def primary(self) -> AccountV2Handle | None: 76 """The primary account for the app, or None if not logged in.""" 77 return self.do_get_primary() 78 79 def on_primary_account_changed( 80 self, account: AccountV2Handle | None 81 ) -> None: 82 """Callback run after the primary account changes. 83 84 Will be called with None on log-outs and when new credentials 85 are set but have not yet been verified. 86 """ 87 assert _babase.in_logic_thread() 88 89 # Currently don't do anything special on sign-outs. 90 if account is None: 91 return 92 93 # If this new account has a workspace, update it and ask to be 94 # informed when that process completes. 95 if account.workspaceid is not None: 96 assert account.workspacename is not None 97 if ( 98 not self._initial_sign_in_completed 99 and not self._kicked_off_workspace_load 100 ): 101 self._kicked_off_workspace_load = True 102 _babase.app.workspaces.set_active_workspace( 103 account=account, 104 workspaceid=account.workspaceid, 105 workspacename=account.workspacename, 106 on_completed=self._on_set_active_workspace_completed, 107 ) 108 else: 109 # Don't activate workspaces if we've already told the 110 # game that initial-log-in is done or if we've already 111 # kicked off a workspace load. 112 _babase.screenmessage( 113 f'\'{account.workspacename}\'' 114 f' will be activated at next app launch.', 115 color=(1, 1, 0), 116 ) 117 _babase.getsimplesound('error').play() 118 return 119 120 # Ok; no workspace to worry about; carry on. 121 if not self._initial_sign_in_completed: 122 self._initial_sign_in_completed = True 123 _babase.app.on_initial_sign_in_complete() 124 125 def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None: 126 """Should be called when logins for the active account change.""" 127 128 for adapter in self.login_adapters.values(): 129 adapter.set_active_logins(logins) 130 131 def on_implicit_sign_in( 132 self, login_type: LoginType, login_id: str, display_name: str 133 ) -> None: 134 """An implicit sign-in happened (called by native layer).""" 135 from babase._login import LoginAdapter 136 137 assert _babase.in_logic_thread() 138 139 with _babase.ContextRef.empty(): 140 self.login_adapters[login_type].set_implicit_login_state( 141 LoginAdapter.ImplicitLoginState( 142 login_id=login_id, display_name=display_name 143 ) 144 ) 145 146 def on_implicit_sign_out(self, login_type: LoginType) -> None: 147 """An implicit sign-out happened (called by native layer).""" 148 assert _babase.in_logic_thread() 149 with _babase.ContextRef.empty(): 150 self.login_adapters[login_type].set_implicit_login_state(None) 151 152 def on_no_initial_primary_account(self) -> None: 153 """Callback run if the app has no primary account after launch. 154 155 Either this callback or on_primary_account_changed will be called 156 within a few seconds of app launch; the app can move forward 157 with the startup sequence at that point. 158 """ 159 if not self._initial_sign_in_completed: 160 self._initial_sign_in_completed = True 161 _babase.app.on_initial_sign_in_complete() 162 163 @staticmethod 164 def _hashstr(val: str) -> str: 165 md5 = hashlib.md5() 166 md5.update(val.encode()) 167 return md5.hexdigest() 168 169 def on_implicit_login_state_changed( 170 self, 171 login_type: LoginType, 172 state: LoginAdapter.ImplicitLoginState | None, 173 ) -> None: 174 """Called when implicit login state changes. 175 176 Login systems that tend to sign themselves in/out in the 177 background are considered implicit. We may choose to honor or 178 ignore their states, allowing the user to opt for other login 179 types even if the default implicit one can't be explicitly 180 logged out or otherwise controlled. 181 """ 182 from babase._language import Lstr 183 184 assert _babase.in_logic_thread() 185 186 cfg = _babase.app.config 187 cfgkey = 'ImplicitLoginStates' 188 cfgdict = _babase.app.config.setdefault(cfgkey, {}) 189 190 # Store which (if any) adapter is currently implicitly signed 191 # in. Making the assumption there will only ever be one implicit 192 # adapter at a time; may need to revisit this logic if that 193 # changes. 194 prev_state = cfgdict.get(login_type.value) 195 if state is None: 196 self._implicit_signed_in_adapter = None 197 new_state = cfgdict[login_type.value] = None 198 else: 199 self._implicit_signed_in_adapter = self.login_adapters[login_type] 200 new_state = cfgdict[login_type.value] = self._hashstr( 201 state.login_id 202 ) 203 204 # Special case: if the user is already signed in but not 205 # with this implicit login, let them know that the 'Welcome 206 # back FOO' they likely just saw is not actually accurate. 207 if ( 208 self.primary is not None 209 and not self.login_adapters[login_type].is_back_end_active() 210 ): 211 service_str: Lstr | None 212 if login_type is LoginType.GPGS: 213 service_str = Lstr(resource='googlePlayText') 214 elif login_type is LoginType.GAME_CENTER: 215 # Note: Apparently Game Center is just called 'Game 216 # Center' in all languages. Can revisit if not true. 217 # https://developer.apple.com/forums/thread/725779 218 service_str = Lstr(value='Game Center') 219 elif login_type is LoginType.EMAIL: 220 # Not possible; just here for exhaustive coverage. 221 service_str = None 222 else: 223 assert_never(login_type) 224 if service_str is not None: 225 _babase.apptimer( 226 2.0, 227 partial( 228 _babase.screenmessage, 229 Lstr( 230 resource='notUsingAccountText', 231 subs=[ 232 ('${ACCOUNT}', state.display_name), 233 ('${SERVICE}', service_str), 234 ], 235 ), 236 (1, 0.5, 0), 237 ), 238 ) 239 240 cfg.commit() 241 242 # We want to respond any time the implicit state changes; 243 # generally this means the user has explicitly signed in/out or 244 # switched accounts within that back-end. 245 if prev_state != new_state: 246 if DEBUG_LOG: 247 logging.debug( 248 'AccountV2: Implicit state changed (%s -> %s);' 249 ' will update app sign-in state accordingly.', 250 prev_state, 251 new_state, 252 ) 253 self._implicit_state_changed = True 254 255 # We may want to auto-sign-in based on this new state. 256 self._update_auto_sign_in() 257 258 def on_cloud_connectivity_changed(self, connected: bool) -> None: 259 """Should be called with cloud connectivity changes.""" 260 del connected # Unused. 261 assert _babase.in_logic_thread() 262 263 # We may want to auto-sign-in based on this new state. 264 self._update_auto_sign_in() 265 266 def do_get_primary(self) -> AccountV2Handle | None: 267 """Internal - should be overridden by subclass.""" 268 raise NotImplementedError() 269 270 def set_primary_credentials(self, credentials: str | None) -> None: 271 """Set credentials for the primary app account.""" 272 raise NotImplementedError() 273 274 def _update_auto_sign_in(self) -> None: 275 plus = _babase.app.plus 276 assert plus is not None 277 278 # If implicit state has changed, try to respond. 279 if self._implicit_state_changed: 280 if self._implicit_signed_in_adapter is None: 281 # If implicit back-end has signed out, we follow suit 282 # immediately; no need to wait for network connectivity. 283 if DEBUG_LOG: 284 logging.debug( 285 'AccountV2: Signing out as result' 286 ' of implicit state change...', 287 ) 288 plus.accounts.set_primary_credentials(None) 289 self._implicit_state_changed = False 290 291 # Once we've made a move here we don't want to 292 # do any more automatic stuff. 293 self._can_do_auto_sign_in = False 294 295 else: 296 # Ok; we've got a new implicit state. If we've got 297 # connectivity, let's attempt to sign in with it. 298 # Consider this an 'explicit' sign in because the 299 # implicit-login state change presumably was triggered 300 # by some user action (signing in, signing out, or 301 # switching accounts via the back-end). NOTE: should 302 # test case where we don't have connectivity here. 303 if plus.cloud.is_connected(): 304 if DEBUG_LOG: 305 logging.debug( 306 'AccountV2: Signing in as result' 307 ' of implicit state change...', 308 ) 309 self._implicit_signed_in_adapter.sign_in( 310 self._on_explicit_sign_in_completed, 311 description='implicit state change', 312 ) 313 self._implicit_state_changed = False 314 315 # Once we've made a move here we don't want to 316 # do any more automatic stuff. 317 self._can_do_auto_sign_in = False 318 319 if not self._can_do_auto_sign_in: 320 return 321 322 # If we're not currently signed in, we have connectivity, and 323 # we have an available implicit login, auto-sign-in with it once. 324 # The implicit-state-change logic above should keep things 325 # mostly in-sync, but that might not always be the case due to 326 # connectivity or other issues. We prefer to keep people signed 327 # in as a rule, even if there are corner cases where this might 328 # not be what they want (A user signing out and then restarting 329 # may be auto-signed back in). 330 connected = plus.cloud.is_connected() 331 signed_in_v1 = plus.get_v1_account_state() == 'signed_in' 332 signed_in_v2 = plus.accounts.have_primary_credentials() 333 if ( 334 connected 335 and not signed_in_v1 336 and not signed_in_v2 337 and self._implicit_signed_in_adapter is not None 338 ): 339 if DEBUG_LOG: 340 logging.debug( 341 'AccountV2: Signing in due to on-launch-auto-sign-in...', 342 ) 343 self._can_do_auto_sign_in = False # Only ATTEMPT once 344 self._implicit_signed_in_adapter.sign_in( 345 self._on_implicit_sign_in_completed, description='auto-sign-in' 346 ) 347 348 def _on_explicit_sign_in_completed( 349 self, 350 adapter: LoginAdapter, 351 result: LoginAdapter.SignInResult | Exception, 352 ) -> None: 353 """A sign-in has completed that the user asked for explicitly.""" 354 from babase._language import Lstr 355 356 del adapter # Unused. 357 358 plus = _babase.app.plus 359 assert plus is not None 360 361 # Make some noise on errors since the user knows a 362 # sign-in attempt is happening in this case (the 'explicit' part). 363 if isinstance(result, Exception): 364 # We expect the occasional communication errors; 365 # Log a full exception for anything else though. 366 if not isinstance(result, CommunicationError): 367 logging.warning( 368 'Error on explicit accountv2 sign in attempt.', 369 exc_info=result, 370 ) 371 372 # For now just show 'error'. Should do better than this. 373 _babase.screenmessage( 374 Lstr(resource='internal.signInErrorText'), 375 color=(1, 0, 0), 376 ) 377 _babase.getsimplesound('error').play() 378 379 # Also I suppose we should sign them out in this case since 380 # it could be misleading to be still signed in with the old 381 # account. 382 plus.accounts.set_primary_credentials(None) 383 return 384 385 plus.accounts.set_primary_credentials(result.credentials) 386 387 def _on_implicit_sign_in_completed( 388 self, 389 adapter: LoginAdapter, 390 result: LoginAdapter.SignInResult | Exception, 391 ) -> None: 392 """A sign-in has completed that the user didn't ask for explicitly.""" 393 plus = _babase.app.plus 394 assert plus is not None 395 396 del adapter # Unused. 397 398 # Log errors but don't inform the user; they're not aware of this 399 # attempt and ignorance is bliss. 400 if isinstance(result, Exception): 401 # We expect the occasional communication errors; 402 # Log a full exception for anything else though. 403 if not isinstance(result, CommunicationError): 404 logging.warning( 405 'Error on implicit accountv2 sign in attempt.', 406 exc_info=result, 407 ) 408 return 409 410 # If we're still connected and still not signed in, 411 # plug in the credentials we got. We want to be extra cautious 412 # in case the user has since explicitly signed in since we 413 # kicked off. 414 connected = plus.cloud.is_connected() 415 signed_in_v1 = plus.get_v1_account_state() == 'signed_in' 416 signed_in_v2 = plus.accounts.have_primary_credentials() 417 if connected and not signed_in_v1 and not signed_in_v2: 418 plus.accounts.set_primary_credentials(result.credentials) 419 420 def _on_set_active_workspace_completed(self) -> None: 421 if not self._initial_sign_in_completed: 422 self._initial_sign_in_completed = True 423 _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'.
59 def on_app_loading(self) -> None: 60 """Should be called at standard on_app_loading time.""" 61 62 for adapter in self.login_adapters.values(): 63 adapter.on_app_loading()
Should be called at standard on_app_loading time.
65 def have_primary_credentials(self) -> bool: 66 """Are credentials currently set for the primary app account? 67 68 Note that this does not mean these credentials have been checked 69 for validity; only that they exist. If/when credentials are 70 validated, the 'primary' account handle will be set. 71 """ 72 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.
74 @property 75 def primary(self) -> AccountV2Handle | None: 76 """The primary account for the app, or None if not logged in.""" 77 return self.do_get_primary()
The primary account for the app, or None if not logged in.
79 def on_primary_account_changed( 80 self, account: AccountV2Handle | None 81 ) -> None: 82 """Callback run after the primary account changes. 83 84 Will be called with None on log-outs and when new credentials 85 are set but have not yet been verified. 86 """ 87 assert _babase.in_logic_thread() 88 89 # Currently don't do anything special on sign-outs. 90 if account is None: 91 return 92 93 # If this new account has a workspace, update it and ask to be 94 # informed when that process completes. 95 if account.workspaceid is not None: 96 assert account.workspacename is not None 97 if ( 98 not self._initial_sign_in_completed 99 and not self._kicked_off_workspace_load 100 ): 101 self._kicked_off_workspace_load = True 102 _babase.app.workspaces.set_active_workspace( 103 account=account, 104 workspaceid=account.workspaceid, 105 workspacename=account.workspacename, 106 on_completed=self._on_set_active_workspace_completed, 107 ) 108 else: 109 # Don't activate workspaces if we've already told the 110 # game that initial-log-in is done or if we've already 111 # kicked off a workspace load. 112 _babase.screenmessage( 113 f'\'{account.workspacename}\'' 114 f' will be activated at next app launch.', 115 color=(1, 1, 0), 116 ) 117 _babase.getsimplesound('error').play() 118 return 119 120 # Ok; no workspace to worry about; carry on. 121 if not self._initial_sign_in_completed: 122 self._initial_sign_in_completed = True 123 _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.
125 def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None: 126 """Should be called when logins for the active account change.""" 127 128 for adapter in self.login_adapters.values(): 129 adapter.set_active_logins(logins)
Should be called when logins for the active account change.
131 def on_implicit_sign_in( 132 self, login_type: LoginType, login_id: str, display_name: str 133 ) -> None: 134 """An implicit sign-in happened (called by native layer).""" 135 from babase._login import LoginAdapter 136 137 assert _babase.in_logic_thread() 138 139 with _babase.ContextRef.empty(): 140 self.login_adapters[login_type].set_implicit_login_state( 141 LoginAdapter.ImplicitLoginState( 142 login_id=login_id, display_name=display_name 143 ) 144 )
An implicit sign-in happened (called by native layer).
146 def on_implicit_sign_out(self, login_type: LoginType) -> None: 147 """An implicit sign-out happened (called by native layer).""" 148 assert _babase.in_logic_thread() 149 with _babase.ContextRef.empty(): 150 self.login_adapters[login_type].set_implicit_login_state(None)
An implicit sign-out happened (called by native layer).
152 def on_no_initial_primary_account(self) -> None: 153 """Callback run if the app has no primary account after launch. 154 155 Either this callback or on_primary_account_changed will be called 156 within a few seconds of app launch; the app can move forward 157 with the startup sequence at that point. 158 """ 159 if not self._initial_sign_in_completed: 160 self._initial_sign_in_completed = True 161 _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.
169 def on_implicit_login_state_changed( 170 self, 171 login_type: LoginType, 172 state: LoginAdapter.ImplicitLoginState | None, 173 ) -> None: 174 """Called when implicit login state changes. 175 176 Login systems that tend to sign themselves in/out in the 177 background are considered implicit. We may choose to honor or 178 ignore their states, allowing the user to opt for other login 179 types even if the default implicit one can't be explicitly 180 logged out or otherwise controlled. 181 """ 182 from babase._language import Lstr 183 184 assert _babase.in_logic_thread() 185 186 cfg = _babase.app.config 187 cfgkey = 'ImplicitLoginStates' 188 cfgdict = _babase.app.config.setdefault(cfgkey, {}) 189 190 # Store which (if any) adapter is currently implicitly signed 191 # in. Making the assumption there will only ever be one implicit 192 # adapter at a time; may need to revisit this logic if that 193 # changes. 194 prev_state = cfgdict.get(login_type.value) 195 if state is None: 196 self._implicit_signed_in_adapter = None 197 new_state = cfgdict[login_type.value] = None 198 else: 199 self._implicit_signed_in_adapter = self.login_adapters[login_type] 200 new_state = cfgdict[login_type.value] = self._hashstr( 201 state.login_id 202 ) 203 204 # Special case: if the user is already signed in but not 205 # with this implicit login, let them know that the 'Welcome 206 # back FOO' they likely just saw is not actually accurate. 207 if ( 208 self.primary is not None 209 and not self.login_adapters[login_type].is_back_end_active() 210 ): 211 service_str: Lstr | None 212 if login_type is LoginType.GPGS: 213 service_str = Lstr(resource='googlePlayText') 214 elif login_type is LoginType.GAME_CENTER: 215 # Note: Apparently Game Center is just called 'Game 216 # Center' in all languages. Can revisit if not true. 217 # https://developer.apple.com/forums/thread/725779 218 service_str = Lstr(value='Game Center') 219 elif login_type is LoginType.EMAIL: 220 # Not possible; just here for exhaustive coverage. 221 service_str = None 222 else: 223 assert_never(login_type) 224 if service_str is not None: 225 _babase.apptimer( 226 2.0, 227 partial( 228 _babase.screenmessage, 229 Lstr( 230 resource='notUsingAccountText', 231 subs=[ 232 ('${ACCOUNT}', state.display_name), 233 ('${SERVICE}', service_str), 234 ], 235 ), 236 (1, 0.5, 0), 237 ), 238 ) 239 240 cfg.commit() 241 242 # We want to respond any time the implicit state changes; 243 # generally this means the user has explicitly signed in/out or 244 # switched accounts within that back-end. 245 if prev_state != new_state: 246 if DEBUG_LOG: 247 logging.debug( 248 'AccountV2: Implicit state changed (%s -> %s);' 249 ' will update app sign-in state accordingly.', 250 prev_state, 251 new_state, 252 ) 253 self._implicit_state_changed = True 254 255 # We may want to auto-sign-in based on this new state. 256 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.
258 def on_cloud_connectivity_changed(self, connected: bool) -> None: 259 """Should be called with cloud connectivity changes.""" 260 del connected # Unused. 261 assert _babase.in_logic_thread() 262 263 # We may want to auto-sign-in based on this new state. 264 self._update_auto_sign_in()
Should be called with cloud connectivity changes.
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
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
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
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
49class App: 50 """A class for high level app functionality and state. 51 52 Category: **App Classes** 53 54 Use babase.app to access the single shared instance of this class. 55 56 Note that properties not documented here should be considered internal 57 and subject to change without warning. 58 """ 59 60 # pylint: disable=too-many-public-methods 61 62 # A few things defined as non-optional values but not actually 63 # available until the app starts. 64 plugins: PluginSubsystem 65 lang: LanguageSubsystem 66 health_monitor: AppHealthMonitor 67 68 # How long we allow shutdown tasks to run before killing them. 69 # Currently the entire app hard-exits if shutdown takes 10 seconds, 70 # so we need to keep it under that. 71 SHUTDOWN_TASK_TIMEOUT_SECONDS = 5 72 73 class State(Enum): 74 """High level state the app can be in.""" 75 76 # The app has not yet begun starting and should not be used in 77 # any way. 78 NOT_STARTED = 0 79 80 # The native layer is spinning up its machinery (screens, 81 # renderers, etc.). Nothing should happen in the Python layer 82 # until this completes. 83 NATIVE_BOOTSTRAPPING = 1 84 85 # Python app subsystems are being inited but should not yet 86 # interact or do any work. 87 INITING = 2 88 89 # Python app subsystems are inited and interacting, but the app 90 # has not yet embarked on a high level course of action. It is 91 # doing initial account logins, workspace & asset downloads, 92 # etc. 93 LOADING = 3 94 95 # All pieces are in place and the app is now doing its thing. 96 RUNNING = 4 97 98 # Used on platforms such as mobile where the app basically needs 99 # to shut down while backgrounded. In this state, all event 100 # loops are suspended and all graphics and audio must cease 101 # completely. Be aware that the suspended state can be entered 102 # from any other state including NATIVE_BOOTSTRAPPING and 103 # SHUTTING_DOWN. 104 SUSPENDED = 5 105 106 # The app is shutting down. This process may involve sending 107 # network messages or other things that can take up to a few 108 # seconds, so ideally graphics and audio should remain 109 # functional (with fades or spinners or whatever to show 110 # something is happening). 111 SHUTTING_DOWN = 6 112 113 # The app has completed shutdown. Any code running here should 114 # be basically immediate. 115 SHUTDOWN_COMPLETE = 7 116 117 class DefaultAppModeSelector(AppModeSelector): 118 """Decides which AppModes to use to handle AppIntents. 119 120 This default version is generated by the project updater based 121 on the 'default_app_modes' value in the projectconfig. 122 123 It is also possible to modify app mode selection behavior by 124 setting app.mode_selector to an instance of a custom 125 AppModeSelector subclass. This is a good way to go if you are 126 modifying app behavior dynamically via a plugin instead of 127 statically in a spinoff project. 128 """ 129 130 @override 131 def app_mode_for_intent( 132 self, intent: AppIntent 133 ) -> type[AppMode] | None: 134 # pylint: disable=cyclic-import 135 136 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 137 # This section generated by batools.appmodule; do not edit. 138 139 # Ask our default app modes to handle it. 140 # (generated from 'default_app_modes' in projectconfig). 141 import baclassic 142 import babase 143 144 for appmode in [ 145 baclassic.ClassicAppMode, 146 babase.EmptyAppMode, 147 ]: 148 if appmode.can_handle_intent(intent): 149 return appmode 150 151 return None 152 153 # __DEFAULT_APP_MODE_SELECTION_END__ 154 155 @override 156 def testable_app_modes(self) -> list[type[AppMode]]: 157 # pylint: disable=cyclic-import 158 159 # __DEFAULT_TESTABLE_APP_MODES_BEGIN__ 160 # This section generated by batools.appmodule; do not edit. 161 162 # Return all our default_app_modes as testable. 163 # (generated from 'default_app_modes' in projectconfig). 164 import baclassic 165 import babase 166 167 return [ 168 baclassic.ClassicAppMode, 169 babase.EmptyAppMode, 170 ] 171 # __DEFAULT_TESTABLE_APP_MODES_END__ 172 173 def __init__(self) -> None: 174 """(internal) 175 176 Do not instantiate this class. You can access the single shared 177 instance of it through various high level packages: 'babase.app', 178 'bascenev1.app', 'bauiv1.app', etc. 179 """ 180 181 # Hack for docs-generation: we can be imported with dummy modules 182 # instead of our actual binary ones, but we don't function. 183 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 184 return 185 186 self.env: babase.Env = _babase.Env() 187 self.state = self.State.NOT_STARTED 188 189 # Default executor which can be used for misc background 190 # processing. It should also be passed to any additional asyncio 191 # loops we create so that everything shares the same single set 192 # of worker threads. 193 self.threadpool = ThreadPoolExecutor( 194 thread_name_prefix='baworker', 195 initializer=self._thread_pool_thread_init, 196 ) 197 198 self.meta = MetadataSubsystem() 199 self.net = NetworkSubsystem() 200 self.workspaces = WorkspaceSubsystem() 201 self.components = AppComponentSubsystem() 202 self.stringedit = StringEditSubsystem() 203 self.devconsole = DevConsoleSubsystem() 204 205 # This is incremented any time the app is backgrounded or 206 # foregrounded; can be a simple way to determine if network data 207 # should be refreshed/etc. 208 self.fg_state = 0 209 210 self._subsystems: list[AppSubsystem] = [] 211 self._native_bootstrapping_completed = False 212 self._init_completed = False 213 self._meta_scan_completed = False 214 self._native_start_called = False 215 self._native_suspended = False 216 self._native_shutdown_called = False 217 self._native_shutdown_complete_called = False 218 self._initial_sign_in_completed = False 219 self._called_on_initing = False 220 self._called_on_loading = False 221 self._called_on_running = False 222 self._subsystem_registration_ended = False 223 self._pending_apply_app_config = False 224 self._asyncio_loop: asyncio.AbstractEventLoop | None = None 225 self._asyncio_tasks: set[asyncio.Task] = set() 226 self._asyncio_timer: babase.AppTimer | None = None 227 self._config: babase.AppConfig | None = None 228 self._pending_intent: AppIntent | None = None 229 self._intent: AppIntent | None = None 230 self._mode_selector: babase.AppModeSelector | None = None 231 self._mode_instances: dict[type[AppMode], AppMode] = {} 232 self._mode: AppMode | None = None 233 self._shutdown_task: asyncio.Task[None] | None = None 234 self._shutdown_tasks: list[Coroutine[None, None, None]] = [ 235 self._wait_for_shutdown_suppressions(), 236 self._fade_and_shutdown_graphics(), 237 self._fade_and_shutdown_audio(), 238 ] 239 self._pool_thread_count = 0 240 241 # We hold a lock while lazy-loading our subsystem properties so 242 # we don't spin up any subsystem more than once, but the lock is 243 # recursive so that the subsystems can instantiate other 244 # subsystems. 245 self._subsystem_property_lock = RLock() 246 self._subsystem_property_data: dict[str, AppSubsystem | bool] = {} 247 248 def postinit(self) -> None: 249 """Called after we've been inited and assigned to babase.app. 250 251 Anything that accesses babase.app as part of its init process 252 must go here instead of __init__. 253 """ 254 255 # Hack for docs-generation: We can be imported with dummy 256 # modules instead of our actual binary ones, but we don't 257 # function. 258 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 259 return 260 261 self.lang = LanguageSubsystem() 262 self.plugins = PluginSubsystem() 263 264 @property 265 def active(self) -> bool: 266 """Whether the app is currently front and center. 267 268 This will be False when the app is hidden, other activities 269 are covering it, etc. (depending on the platform). 270 """ 271 return _babase.app_is_active() 272 273 @property 274 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 275 """The logic thread's asyncio event loop. 276 277 This allow async tasks to be run in the logic thread. 278 279 Generally you should call App.create_async_task() to schedule 280 async code to run instead of using this directly. That will 281 handle retaining the task and logging errors automatically. 282 Only schedule tasks onto asyncio_loop yourself when you intend 283 to hold on to the returned task and await its results. Releasing 284 the task reference can lead to subtle bugs such as unreported 285 errors and garbage-collected tasks disappearing before their 286 work is done. 287 288 Note that, at this time, the asyncio loop is encapsulated 289 and explicitly stepped by the engine's logic thread loop and 290 thus things like asyncio.get_running_loop() will unintuitively 291 *not* return this loop from most places in the logic thread; 292 only from within a task explicitly created in this loop. 293 Hopefully this situation will be improved in the future with a 294 unified event loop. 295 """ 296 assert _babase.in_logic_thread() 297 assert self._asyncio_loop is not None 298 return self._asyncio_loop 299 300 def create_async_task( 301 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 302 ) -> None: 303 """Create a fully managed async task. 304 305 This will automatically retain and release a reference to the task 306 and log any exceptions that occur in it. If you need to await a task 307 or otherwise need more control, schedule a task directly using 308 App.asyncio_loop. 309 """ 310 assert _babase.in_logic_thread() 311 312 # We hold a strong reference to the task until it is done. 313 # Otherwise it is possible for it to be garbage collected and 314 # disappear midway if the caller does not hold on to the 315 # returned task, which seems like a great way to introduce 316 # hard-to-track bugs. 317 task = self.asyncio_loop.create_task(coro, name=name) 318 self._asyncio_tasks.add(task) 319 task.add_done_callback(self._on_task_done) 320 321 def _on_task_done(self, task: asyncio.Task) -> None: 322 # Report any errors that occurred. 323 try: 324 exc = task.exception() 325 if exc is not None: 326 logging.error( 327 "Error in async task '%s'.", task.get_name(), exc_info=exc 328 ) 329 except Exception: 330 logging.exception('Error reporting async task error.') 331 332 self._asyncio_tasks.remove(task) 333 334 @property 335 def config(self) -> babase.AppConfig: 336 """The babase.AppConfig instance representing the app's config state.""" 337 assert self._config is not None 338 return self._config 339 340 @property 341 def mode_selector(self) -> babase.AppModeSelector: 342 """Controls which app-modes are used for handling given intents. 343 344 Plugins can override this to change high level app behavior and 345 spinoff projects can change the default implementation for the 346 same effect. 347 """ 348 if self._mode_selector is None: 349 raise RuntimeError( 350 'mode_selector cannot be used until the app reaches' 351 ' the running state.' 352 ) 353 return self._mode_selector 354 355 @mode_selector.setter 356 def mode_selector(self, selector: babase.AppModeSelector) -> None: 357 self._mode_selector = selector 358 359 def _get_subsystem_property( 360 self, ssname: str, create_call: Callable[[], AppSubsystem | None] 361 ) -> AppSubsystem | None: 362 363 # Quick-out: if a subsystem is present, just return it; no 364 # locking necessary. 365 val = self._subsystem_property_data.get(ssname) 366 if val is not None: 367 if val is False: 368 # False means subsystem is confirmed as unavailable. 369 return None 370 if val is not True: 371 # A subsystem has been set. Return it. 372 return val 373 374 # Anything else (no val present or val True) requires locking. 375 with self._subsystem_property_lock: 376 val = self._subsystem_property_data.get(ssname) 377 if val is not None: 378 if val is False: 379 # False means confirmed as not present. 380 return None 381 if val is True: 382 # True means this property is already being loaded, 383 # and the fact that we're holding the lock means 384 # we're doing the loading, so this is a dependency 385 # loop. Not good. 386 raise RuntimeError( 387 f'Subsystem dependency loop detected for {ssname}' 388 ) 389 # Must be an instantiated subsystem. Noice. 390 return val 391 392 # Ok, there's nothing here for it. Instantiate and set it 393 # while we hold the lock. Set a placeholder value of True 394 # while we load so we can error if something we're loading 395 # tries to recursively load us. 396 self._subsystem_property_data[ssname] = True 397 398 # Do our one attempt to create the singleton. 399 val = create_call() 400 self._subsystem_property_data[ssname] = ( 401 False if val is None else val 402 ) 403 404 return val 405 406 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__ 407 # This section generated by batools.appmodule; do not edit. 408 409 @property 410 def classic(self) -> ClassicAppSubsystem | None: 411 """Our classic subsystem (if available).""" 412 return self._get_subsystem_property( 413 'classic', self._create_classic_subsystem 414 ) # type: ignore 415 416 @staticmethod 417 def _create_classic_subsystem() -> ClassicAppSubsystem | None: 418 # pylint: disable=cyclic-import 419 try: 420 from baclassic import ClassicAppSubsystem 421 422 return ClassicAppSubsystem() 423 except ImportError: 424 return None 425 except Exception: 426 logging.exception('Error importing baclassic.') 427 return None 428 429 @property 430 def plus(self) -> PlusAppSubsystem | None: 431 """Our plus subsystem (if available).""" 432 return self._get_subsystem_property( 433 'plus', self._create_plus_subsystem 434 ) # type: ignore 435 436 @staticmethod 437 def _create_plus_subsystem() -> PlusAppSubsystem | None: 438 # pylint: disable=cyclic-import 439 try: 440 from baplus import PlusAppSubsystem 441 442 return PlusAppSubsystem() 443 except ImportError: 444 return None 445 except Exception: 446 logging.exception('Error importing baplus.') 447 return None 448 449 @property 450 def ui_v1(self) -> UIV1AppSubsystem: 451 """Our ui_v1 subsystem (always available).""" 452 return self._get_subsystem_property( 453 'ui_v1', self._create_ui_v1_subsystem 454 ) # type: ignore 455 456 @staticmethod 457 def _create_ui_v1_subsystem() -> UIV1AppSubsystem: 458 # pylint: disable=cyclic-import 459 460 from bauiv1 import UIV1AppSubsystem 461 462 return UIV1AppSubsystem() 463 464 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__ 465 466 def register_subsystem(self, subsystem: AppSubsystem) -> None: 467 """Called by the AppSubsystem class. Do not use directly.""" 468 469 # We only allow registering new subsystems if we've not yet 470 # reached the 'running' state. This ensures that all subsystems 471 # receive a consistent set of callbacks starting with 472 # on_app_running(). 473 474 if self._subsystem_registration_ended: 475 raise RuntimeError( 476 'Subsystems can no longer be registered at this point.' 477 ) 478 self._subsystems.append(subsystem) 479 480 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 481 """Add a task to be run on app shutdown. 482 483 Note that shutdown tasks will be canceled after 484 App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running. 485 """ 486 if ( 487 self.state is self.State.SHUTTING_DOWN 488 or self.state is self.State.SHUTDOWN_COMPLETE 489 ): 490 stname = self.state.name 491 raise RuntimeError( 492 f'Cannot add shutdown tasks with current state {stname}.' 493 ) 494 self._shutdown_tasks.append(coro) 495 496 def run(self) -> None: 497 """Run the app to completion. 498 499 Note that this only works on builds where Ballistica manages 500 its own event loop. 501 """ 502 _babase.run_app() 503 504 def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None: 505 """Submit a call to the app threadpool where result is not needed. 506 507 Normally, doing work in a thread-pool involves creating a future 508 and waiting for its result, which is an important step because it 509 propagates any Exceptions raised by the submitted work. When the 510 result in not important, however, this call can be used. The app 511 will log any exceptions that occur. 512 """ 513 fut = self.threadpool.submit(call) 514 fut.add_done_callback(self._threadpool_no_wait_done) 515 516 def set_intent(self, intent: AppIntent) -> None: 517 """Set the intent for the app. 518 519 Intent defines what the app is trying to do at a given time. 520 This call is asynchronous; the intent switch will happen in the 521 logic thread in the near future. If set_intent is called 522 repeatedly before the change takes place, the final intent to be 523 set will be used. 524 """ 525 526 # Mark this one as pending. We do this synchronously so that the 527 # last one marked actually takes effect if there is overlap 528 # (doing this in the bg thread could result in race conditions). 529 self._pending_intent = intent 530 531 # Do the actual work of calcing our app-mode/etc. in a bg thread 532 # since it may block for a moment to load modules/etc. 533 self.threadpool_submit_no_wait(partial(self._set_intent, intent)) 534 535 def push_apply_app_config(self) -> None: 536 """Internal. Use app.config.apply() to apply app config changes.""" 537 # To be safe, let's run this by itself in the event loop. 538 # This avoids potential trouble if this gets called mid-draw or 539 # something like that. 540 self._pending_apply_app_config = True 541 _babase.pushcall(self._apply_app_config, raw=True) 542 543 def on_native_start(self) -> None: 544 """Called by the native layer when the app is being started.""" 545 assert _babase.in_logic_thread() 546 assert not self._native_start_called 547 self._native_start_called = True 548 self._update_state() 549 550 def on_native_bootstrapping_complete(self) -> None: 551 """Called by the native layer once its ready to rock.""" 552 assert _babase.in_logic_thread() 553 assert not self._native_bootstrapping_completed 554 self._native_bootstrapping_completed = True 555 self._update_state() 556 557 def on_native_suspend(self) -> None: 558 """Called by the native layer when the app is suspended.""" 559 assert _babase.in_logic_thread() 560 assert not self._native_suspended # Should avoid redundant calls. 561 self._native_suspended = True 562 self._update_state() 563 564 def on_native_unsuspend(self) -> None: 565 """Called by the native layer when the app suspension ends.""" 566 assert _babase.in_logic_thread() 567 assert self._native_suspended # Should avoid redundant calls. 568 self._native_suspended = False 569 self._update_state() 570 571 def on_native_shutdown(self) -> None: 572 """Called by the native layer when the app starts shutting down.""" 573 assert _babase.in_logic_thread() 574 self._native_shutdown_called = True 575 self._update_state() 576 577 def on_native_shutdown_complete(self) -> None: 578 """Called by the native layer when the app is done shutting down.""" 579 assert _babase.in_logic_thread() 580 self._native_shutdown_complete_called = True 581 self._update_state() 582 583 def on_native_active_changed(self) -> None: 584 """Called by the native layer when the app active state changes.""" 585 assert _babase.in_logic_thread() 586 if self._mode is not None: 587 self._mode.on_app_active_changed() 588 589 def read_config(self) -> None: 590 """(internal)""" 591 from babase._appconfig import read_app_config 592 593 self._config = read_app_config() 594 595 def handle_deep_link(self, url: str) -> None: 596 """Handle a deep link URL.""" 597 from babase._language import Lstr 598 599 assert _babase.in_logic_thread() 600 601 appname = _babase.appname() 602 if url.startswith(f'{appname}://code/'): 603 code = url.replace(f'{appname}://code/', '') 604 if self.classic is not None: 605 self.classic.accounts.add_pending_promo_code(code) 606 else: 607 try: 608 _babase.screenmessage( 609 Lstr(resource='errorText'), color=(1, 0, 0) 610 ) 611 _babase.getsimplesound('error').play() 612 except ImportError: 613 pass 614 615 def on_initial_sign_in_complete(self) -> None: 616 """Called when initial sign-in (or lack thereof) completes. 617 618 This normally gets called by the plus subsystem. The 619 initial-sign-in process may include tasks such as syncing 620 account workspaces or other data so it may take a substantial 621 amount of time. 622 """ 623 assert _babase.in_logic_thread() 624 assert not self._initial_sign_in_completed 625 626 # Tell meta it can start scanning extra stuff that just showed 627 # up (namely account workspaces). 628 self.meta.start_extra_scan() 629 630 self._initial_sign_in_completed = True 631 self._update_state() 632 633 def set_ui_scale(self, scale: babase.UIScale) -> None: 634 """Change ui-scale on the fly. 635 636 Currently this is mainly for debugging and will not 637 be called as part of normal app operation. 638 """ 639 assert _babase.in_logic_thread() 640 641 # Apply to the native layer. 642 _babase.set_ui_scale(scale.name.lower()) 643 644 # Inform all subsystems that something screen-related has 645 # changed. We assume subsystems won't be added at this point so 646 # we can use the list directly. 647 assert self._subsystem_registration_ended 648 for subsystem in self._subsystems: 649 try: 650 subsystem.on_screen_change() 651 except Exception: 652 logging.exception( 653 'Error in on_screen_change() for subsystem %s.', subsystem 654 ) 655 656 def _set_intent(self, intent: AppIntent) -> None: 657 from babase._appmode import AppMode 658 659 # This should be happening in a bg thread. 660 assert not _babase.in_logic_thread() 661 try: 662 # Ask the selector what app-mode to use for this intent. 663 if self.mode_selector is None: 664 raise RuntimeError('No AppModeSelector set.') 665 666 modetype: type[AppMode] | None 667 668 # Special case - for testing we may force a specific 669 # app-mode to handle this intent instead of going through our 670 # usual selector. 671 forced_mode_type = getattr(intent, '_force_app_mode_handler', None) 672 if isinstance(forced_mode_type, type) and issubclass( 673 forced_mode_type, AppMode 674 ): 675 modetype = forced_mode_type 676 else: 677 modetype = self.mode_selector.app_mode_for_intent(intent) 678 679 # NOTE: Since intents are somewhat high level things, 680 # perhaps we should do some universal thing like a 681 # screenmessage saying 'The app cannot handle the request' 682 # on failure. 683 684 if modetype is None: 685 raise RuntimeError( 686 f'No app-mode found to handle app-intent' 687 f' type {type(intent)}.' 688 ) 689 690 # Make sure the app-mode the selector gave us *actually* 691 # supports the intent. 692 if not modetype.can_handle_intent(intent): 693 raise RuntimeError( 694 f'Intent {intent} cannot be handled by AppMode type' 695 f' {modetype} (selector {self.mode_selector}' 696 f' incorrectly thinks that it can be).' 697 ) 698 699 # Ok; seems legit. Now instantiate the mode if necessary and 700 # kick back to the logic thread to apply. 701 mode = self._mode_instances.get(modetype) 702 if mode is None: 703 self._mode_instances[modetype] = mode = modetype() 704 _babase.pushcall( 705 partial(self._apply_intent, intent, mode), 706 from_other_thread=True, 707 ) 708 except Exception: 709 logging.exception('Error setting app intent to %s.', intent) 710 _babase.pushcall( 711 partial(self._display_set_intent_error, intent), 712 from_other_thread=True, 713 ) 714 715 def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None: 716 assert _babase.in_logic_thread() 717 718 # ONLY apply this intent if it is still the most recent one 719 # submitted. 720 if intent is not self._pending_intent: 721 return 722 723 # If the app-mode for this intent is different than the active 724 # one, switch modes. 725 if type(mode) is not type(self._mode): 726 if self._mode is None: 727 is_initial_mode = True 728 else: 729 is_initial_mode = False 730 try: 731 self._mode.on_deactivate() 732 except Exception: 733 logging.exception( 734 'Error deactivating app-mode %s.', self._mode 735 ) 736 737 # Reset all subsystems. We assume subsystems won't be added 738 # at this point so we can use the list directly. 739 assert self._subsystem_registration_ended 740 for subsystem in self._subsystems: 741 try: 742 subsystem.reset() 743 except Exception: 744 logging.exception( 745 'Error in reset() for subsystem %s.', subsystem 746 ) 747 748 self._mode = mode 749 try: 750 mode.on_activate() 751 except Exception: 752 # Hmm; what should we do in this case?... 753 logging.exception('Error activating app-mode %s.', mode) 754 755 # Let the world know when we first have an app-mode; certain 756 # app stuff such as input processing can proceed at that 757 # point. 758 if is_initial_mode: 759 _babase.on_initial_app_mode_set() 760 761 try: 762 mode.handle_intent(intent) 763 except Exception: 764 logging.exception( 765 'Error handling intent %s in app-mode %s.', intent, mode 766 ) 767 768 def _display_set_intent_error(self, intent: AppIntent) -> None: 769 """Show the *user* something went wrong setting an intent.""" 770 from babase._language import Lstr 771 772 del intent 773 _babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) 774 _babase.getsimplesound('error').play() 775 776 def _on_initing(self) -> None: 777 """Called when the app enters the initing state. 778 779 Here we can put together subsystems and other pieces for the 780 app, but most things should not be doing any work yet. 781 """ 782 # pylint: disable=cyclic-import 783 from babase import _asyncio 784 from babase import _appconfig 785 from babase._apputils import AppHealthMonitor 786 from babase import _env 787 788 assert _babase.in_logic_thread() 789 790 _env.on_app_state_initing() 791 792 self._asyncio_loop = _asyncio.setup_asyncio() 793 self.health_monitor = AppHealthMonitor() 794 795 # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__ 796 # This section generated by batools.appmodule; do not edit. 797 798 # Poke these attrs to create all our subsystems. 799 _ = self.plus 800 _ = self.classic 801 _ = self.ui_v1 802 803 # __FEATURESET_APP_SUBSYSTEM_CREATE_END__ 804 805 # We're a pretty short-lived state. This should flip us to 806 # 'loading'. 807 self._init_completed = True 808 self._update_state() 809 810 def _on_loading(self) -> None: 811 """Called when we enter the loading state. 812 813 At this point, all built-in pieces of the app should be in place 814 and can start talking to each other and doing work. Though at a 815 high level, the goal of the app at this point is only to sign in 816 to initial accounts, download workspaces, and otherwise prepare 817 itself to really 'run'. 818 """ 819 assert _babase.in_logic_thread() 820 821 # Get meta-system scanning built-in stuff in the bg. 822 self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete) 823 824 # Inform all app subsystems in the same order they were inited. 825 # Operate on a copy of the list here because subsystems can 826 # still be added at this point. 827 for subsystem in self._subsystems.copy(): 828 try: 829 subsystem.on_app_loading() 830 except Exception: 831 logging.exception( 832 'Error in on_app_loading() for subsystem %s.', subsystem 833 ) 834 835 # Normally plus tells us when initial sign-in is done. If plus 836 # is not present, however, we just do it ourself so we can 837 # proceed on to the running state. 838 if self.plus is None: 839 _babase.pushcall(self.on_initial_sign_in_complete) 840 841 def _on_meta_scan_complete(self) -> None: 842 """Called when meta-scan is done doing its thing.""" 843 assert _babase.in_logic_thread() 844 845 # Now that we know what's out there, build our final plugin set. 846 self.plugins.on_meta_scan_complete() 847 848 assert not self._meta_scan_completed 849 self._meta_scan_completed = True 850 self._update_state() 851 852 def _on_running(self) -> None: 853 """Called when we enter the running state. 854 855 At this point, all workspaces, initial accounts, etc. are in place 856 and we can actually get started doing whatever we're gonna do. 857 """ 858 assert _babase.in_logic_thread() 859 860 # Let our native layer know. 861 _babase.on_app_running() 862 863 # Set a default app-mode-selector if none has been set yet 864 # by a plugin or whatnot. 865 if self._mode_selector is None: 866 self._mode_selector = self.DefaultAppModeSelector() 867 868 # Inform all app subsystems in the same order they were 869 # registered. Operate on a copy here because subsystems can 870 # still be added at this point. 871 # 872 # NOTE: Do we need to allow registering still at this point? If 873 # something gets registered here, it won't have its 874 # on_app_running callback called. Hmm; I suppose that's the only 875 # way that plugins can register subsystems though. 876 for subsystem in self._subsystems.copy(): 877 try: 878 subsystem.on_app_running() 879 except Exception: 880 logging.exception( 881 'Error in on_app_running() for subsystem %s.', subsystem 882 ) 883 884 # Cut off new subsystem additions at this point. 885 self._subsystem_registration_ended = True 886 887 # If 'exec' code was provided to the app, always kick that off 888 # here as an intent. 889 exec_cmd = _babase.exec_arg() 890 if exec_cmd is not None: 891 self.set_intent(AppIntentExec(exec_cmd)) 892 elif self._pending_intent is None: 893 # Otherwise tell the app to do its default thing *only* if a 894 # plugin hasn't already told it to do something. 895 self.set_intent(AppIntentDefault()) 896 897 def _apply_app_config(self) -> None: 898 assert _babase.in_logic_thread() 899 900 _babase.lifecyclelog('apply-app-config') 901 902 # If multiple apply calls have been made, only actually apply 903 # once. 904 if not self._pending_apply_app_config: 905 return 906 907 _pending_apply_app_config = False 908 909 # Inform all app subsystems in the same order they were inited. 910 # Operate on a copy here because subsystems may still be able to 911 # be added at this point. 912 for subsystem in self._subsystems.copy(): 913 try: 914 subsystem.do_apply_app_config() 915 except Exception: 916 logging.exception( 917 'Error in do_apply_app_config() for subsystem %s.', 918 subsystem, 919 ) 920 921 # Let the native layer do its thing. 922 _babase.do_apply_app_config() 923 924 def _update_state(self) -> None: 925 # pylint: disable=too-many-branches 926 assert _babase.in_logic_thread() 927 928 # Shutdown-complete trumps absolutely all. 929 if self._native_shutdown_complete_called: 930 if self.state is not self.State.SHUTDOWN_COMPLETE: 931 self.state = self.State.SHUTDOWN_COMPLETE 932 _babase.lifecyclelog('app state shutdown complete') 933 self._on_shutdown_complete() 934 935 # Shutdown trumps all. Though we can't start shutting down until 936 # init is completed since we need our asyncio stuff to exist for 937 # the shutdown process. 938 elif self._native_shutdown_called and self._init_completed: 939 # Entering shutdown state: 940 if self.state is not self.State.SHUTTING_DOWN: 941 self.state = self.State.SHUTTING_DOWN 942 _babase.lifecyclelog('app state shutting down') 943 self._on_shutting_down() 944 945 elif self._native_suspended: 946 # Entering suspended state: 947 if self.state is not self.State.SUSPENDED: 948 self.state = self.State.SUSPENDED 949 self._on_suspend() 950 else: 951 # Leaving suspended state: 952 if self.state is self.State.SUSPENDED: 953 self._on_unsuspend() 954 955 # Entering or returning to running state 956 if self._initial_sign_in_completed and self._meta_scan_completed: 957 if self.state != self.State.RUNNING: 958 self.state = self.State.RUNNING 959 _babase.lifecyclelog('app state running') 960 if not self._called_on_running: 961 self._called_on_running = True 962 self._on_running() 963 # Entering or returning to loading state: 964 elif self._init_completed: 965 if self.state is not self.State.LOADING: 966 self.state = self.State.LOADING 967 _babase.lifecyclelog('app state loading') 968 if not self._called_on_loading: 969 self._called_on_loading = True 970 self._on_loading() 971 972 # Entering or returning to initing state: 973 elif self._native_bootstrapping_completed: 974 if self.state is not self.State.INITING: 975 self.state = self.State.INITING 976 _babase.lifecyclelog('app state initing') 977 if not self._called_on_initing: 978 self._called_on_initing = True 979 self._on_initing() 980 981 # Entering or returning to native bootstrapping: 982 elif self._native_start_called: 983 if self.state is not self.State.NATIVE_BOOTSTRAPPING: 984 self.state = self.State.NATIVE_BOOTSTRAPPING 985 _babase.lifecyclelog('app state native bootstrapping') 986 else: 987 # Only logical possibility left is NOT_STARTED, in which 988 # case we should not be getting called. 989 logging.warning( 990 'App._update_state called while in %s state;' 991 ' should not happen.', 992 self.state.value, 993 stack_info=True, 994 ) 995 996 async def _shutdown(self) -> None: 997 import asyncio 998 999 _babase.lock_all_input() 1000 try: 1001 async with asyncio.TaskGroup() as task_group: 1002 for task_coro in self._shutdown_tasks: 1003 # Note: Mypy currently complains if we don't take 1004 # this return value, but we don't actually need to. 1005 # https://github.com/python/mypy/issues/15036 1006 _ = task_group.create_task( 1007 self._run_shutdown_task(task_coro) 1008 ) 1009 except* Exception: 1010 logging.exception('Unexpected error(s) in shutdown.') 1011 1012 # Note: ideally we should run this directly here, but currently 1013 # it does some legacy stuff which blocks, so running it here 1014 # gives us asyncio task-took-too-long warnings. If we can 1015 # convert those to nice graceful async tasks we should revert 1016 # this to a direct call. 1017 _babase.pushcall(_babase.complete_shutdown) 1018 1019 async def _run_shutdown_task( 1020 self, coro: Coroutine[None, None, None] 1021 ) -> None: 1022 """Run a shutdown task; report errors and abort if taking too long.""" 1023 import asyncio 1024 1025 task = asyncio.create_task(coro) 1026 try: 1027 await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS) 1028 except Exception: 1029 logging.exception('Error in shutdown task (%s).', coro) 1030 1031 def _on_suspend(self) -> None: 1032 """Called when the app goes to a suspended state.""" 1033 assert _babase.in_logic_thread() 1034 1035 # Suspend all app subsystems in the opposite order they were inited. 1036 for subsystem in reversed(self._subsystems): 1037 try: 1038 subsystem.on_app_suspend() 1039 except Exception: 1040 logging.exception( 1041 'Error in on_app_suspend() for subsystem %s.', subsystem 1042 ) 1043 1044 def _on_unsuspend(self) -> None: 1045 """Called when unsuspending.""" 1046 assert _babase.in_logic_thread() 1047 self.fg_state += 1 1048 1049 # Unsuspend all app subsystems in the same order they were inited. 1050 for subsystem in self._subsystems: 1051 try: 1052 subsystem.on_app_unsuspend() 1053 except Exception: 1054 logging.exception( 1055 'Error in on_app_unsuspend() for subsystem %s.', subsystem 1056 ) 1057 1058 def _on_shutting_down(self) -> None: 1059 """(internal)""" 1060 assert _babase.in_logic_thread() 1061 1062 # Inform app subsystems that we're shutting down in the opposite 1063 # order they were inited. 1064 for subsystem in reversed(self._subsystems): 1065 try: 1066 subsystem.on_app_shutdown() 1067 except Exception: 1068 logging.exception( 1069 'Error in on_app_shutdown() for subsystem %s.', subsystem 1070 ) 1071 1072 # Now kick off any async shutdown task(s). 1073 assert self._asyncio_loop is not None 1074 self._shutdown_task = self._asyncio_loop.create_task(self._shutdown()) 1075 1076 def _on_shutdown_complete(self) -> None: 1077 """(internal)""" 1078 assert _babase.in_logic_thread() 1079 1080 # Inform app subsystems that we're done shutting down in the opposite 1081 # order they were inited. 1082 for subsystem in reversed(self._subsystems): 1083 try: 1084 subsystem.on_app_shutdown_complete() 1085 except Exception: 1086 logging.exception( 1087 'Error in on_app_shutdown_complete() for subsystem %s.', 1088 subsystem, 1089 ) 1090 1091 async def _wait_for_shutdown_suppressions(self) -> None: 1092 import asyncio 1093 1094 # Spin and wait for anything blocking shutdown to complete. 1095 starttime = _babase.apptime() 1096 _babase.lifecyclelog('shutdown-suppress wait begin') 1097 while _babase.shutdown_suppress_count() > 0: 1098 await asyncio.sleep(0.001) 1099 _babase.lifecyclelog('shutdown-suppress wait end') 1100 duration = _babase.apptime() - starttime 1101 if duration > 1.0: 1102 logging.warning( 1103 'Shutdown-suppressions lasted longer than ideal ' 1104 '(%.2f seconds).', 1105 duration, 1106 ) 1107 1108 async def _fade_and_shutdown_graphics(self) -> None: 1109 import asyncio 1110 1111 # Kick off a short fade and give it time to complete. 1112 _babase.lifecyclelog('fade-and-shutdown-graphics begin') 1113 _babase.fade_screen(False, time=0.15) 1114 await asyncio.sleep(0.15) 1115 1116 # Now tell the graphics system to go down and wait until 1117 # it has done so. 1118 _babase.graphics_shutdown_begin() 1119 while not _babase.graphics_shutdown_is_complete(): 1120 await asyncio.sleep(0.01) 1121 _babase.lifecyclelog('fade-and-shutdown-graphics end') 1122 1123 async def _fade_and_shutdown_audio(self) -> None: 1124 import asyncio 1125 1126 # Tell the audio system to go down and give it a bit of 1127 # time to do so gracefully. 1128 _babase.lifecyclelog('fade-and-shutdown-audio begin') 1129 _babase.audio_shutdown_begin() 1130 await asyncio.sleep(0.15) 1131 while not _babase.audio_shutdown_is_complete(): 1132 await asyncio.sleep(0.01) 1133 _babase.lifecyclelog('fade-and-shutdown-audio end') 1134 1135 def _threadpool_no_wait_done(self, fut: Future) -> None: 1136 try: 1137 fut.result() 1138 except Exception: 1139 logging.exception( 1140 'Error in work submitted via threadpool_submit_no_wait()' 1141 ) 1142 1143 def _thread_pool_thread_init(self) -> None: 1144 # Help keep things clear in profiling tools/etc. 1145 self._pool_thread_count += 1 1146 _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.
248 def postinit(self) -> None: 249 """Called after we've been inited and assigned to babase.app. 250 251 Anything that accesses babase.app as part of its init process 252 must go here instead of __init__. 253 """ 254 255 # Hack for docs-generation: We can be imported with dummy 256 # modules instead of our actual binary ones, but we don't 257 # function. 258 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 259 return 260 261 self.lang = LanguageSubsystem() 262 self.plugins = PluginSubsystem()
Called after we've been inited and assigned to babase.app.
Anything that accesses babase.app as part of its init process must go here instead of __init__.
264 @property 265 def active(self) -> bool: 266 """Whether the app is currently front and center. 267 268 This will be False when the app is hidden, other activities 269 are covering it, etc. (depending on the platform). 270 """ 271 return _babase.app_is_active()
Whether the app is currently front and center.
This will be False when the app is hidden, other activities are covering it, etc. (depending on the platform).
273 @property 274 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 275 """The logic thread's asyncio event loop. 276 277 This allow async tasks to be run in the logic thread. 278 279 Generally you should call App.create_async_task() to schedule 280 async code to run instead of using this directly. That will 281 handle retaining the task and logging errors automatically. 282 Only schedule tasks onto asyncio_loop yourself when you intend 283 to hold on to the returned task and await its results. Releasing 284 the task reference can lead to subtle bugs such as unreported 285 errors and garbage-collected tasks disappearing before their 286 work is done. 287 288 Note that, at this time, the asyncio loop is encapsulated 289 and explicitly stepped by the engine's logic thread loop and 290 thus things like asyncio.get_running_loop() will unintuitively 291 *not* return this loop from most places in the logic thread; 292 only from within a task explicitly created in this loop. 293 Hopefully this situation will be improved in the future with a 294 unified event loop. 295 """ 296 assert _babase.in_logic_thread() 297 assert self._asyncio_loop is not None 298 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.
300 def create_async_task( 301 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 302 ) -> None: 303 """Create a fully managed async task. 304 305 This will automatically retain and release a reference to the task 306 and log any exceptions that occur in it. If you need to await a task 307 or otherwise need more control, schedule a task directly using 308 App.asyncio_loop. 309 """ 310 assert _babase.in_logic_thread() 311 312 # We hold a strong reference to the task until it is done. 313 # Otherwise it is possible for it to be garbage collected and 314 # disappear midway if the caller does not hold on to the 315 # returned task, which seems like a great way to introduce 316 # hard-to-track bugs. 317 task = self.asyncio_loop.create_task(coro, name=name) 318 self._asyncio_tasks.add(task) 319 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.
334 @property 335 def config(self) -> babase.AppConfig: 336 """The babase.AppConfig instance representing the app's config state.""" 337 assert self._config is not None 338 return self._config
The babase.AppConfig instance representing the app's config state.
340 @property 341 def mode_selector(self) -> babase.AppModeSelector: 342 """Controls which app-modes are used for handling given intents. 343 344 Plugins can override this to change high level app behavior and 345 spinoff projects can change the default implementation for the 346 same effect. 347 """ 348 if self._mode_selector is None: 349 raise RuntimeError( 350 'mode_selector cannot be used until the app reaches' 351 ' the running state.' 352 ) 353 return self._mode_selector
Controls which app-modes are used for handling given intents.
Plugins can override this to change high level app behavior and spinoff projects can change the default implementation for the same effect.
409 @property 410 def classic(self) -> ClassicAppSubsystem | None: 411 """Our classic subsystem (if available).""" 412 return self._get_subsystem_property( 413 'classic', self._create_classic_subsystem 414 ) # type: ignore
Our classic subsystem (if available).
429 @property 430 def plus(self) -> PlusAppSubsystem | None: 431 """Our plus subsystem (if available).""" 432 return self._get_subsystem_property( 433 'plus', self._create_plus_subsystem 434 ) # type: ignore
Our plus subsystem (if available).
449 @property 450 def ui_v1(self) -> UIV1AppSubsystem: 451 """Our ui_v1 subsystem (always available).""" 452 return self._get_subsystem_property( 453 'ui_v1', self._create_ui_v1_subsystem 454 ) # type: ignore
Our ui_v1 subsystem (always available).
466 def register_subsystem(self, subsystem: AppSubsystem) -> None: 467 """Called by the AppSubsystem class. Do not use directly.""" 468 469 # We only allow registering new subsystems if we've not yet 470 # reached the 'running' state. This ensures that all subsystems 471 # receive a consistent set of callbacks starting with 472 # on_app_running(). 473 474 if self._subsystem_registration_ended: 475 raise RuntimeError( 476 'Subsystems can no longer be registered at this point.' 477 ) 478 self._subsystems.append(subsystem)
Called by the AppSubsystem class. Do not use directly.
480 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 481 """Add a task to be run on app shutdown. 482 483 Note that shutdown tasks will be canceled after 484 App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running. 485 """ 486 if ( 487 self.state is self.State.SHUTTING_DOWN 488 or self.state is self.State.SHUTDOWN_COMPLETE 489 ): 490 stname = self.state.name 491 raise RuntimeError( 492 f'Cannot add shutdown tasks with current state {stname}.' 493 ) 494 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.
496 def run(self) -> None: 497 """Run the app to completion. 498 499 Note that this only works on builds where Ballistica manages 500 its own event loop. 501 """ 502 _babase.run_app()
Run the app to completion.
Note that this only works on builds where Ballistica manages its own event loop.
504 def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None: 505 """Submit a call to the app threadpool where result is not needed. 506 507 Normally, doing work in a thread-pool involves creating a future 508 and waiting for its result, which is an important step because it 509 propagates any Exceptions raised by the submitted work. When the 510 result in not important, however, this call can be used. The app 511 will log any exceptions that occur. 512 """ 513 fut = self.threadpool.submit(call) 514 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.
516 def set_intent(self, intent: AppIntent) -> None: 517 """Set the intent for the app. 518 519 Intent defines what the app is trying to do at a given time. 520 This call is asynchronous; the intent switch will happen in the 521 logic thread in the near future. If set_intent is called 522 repeatedly before the change takes place, the final intent to be 523 set will be used. 524 """ 525 526 # Mark this one as pending. We do this synchronously so that the 527 # last one marked actually takes effect if there is overlap 528 # (doing this in the bg thread could result in race conditions). 529 self._pending_intent = intent 530 531 # Do the actual work of calcing our app-mode/etc. in a bg thread 532 # since it may block for a moment to load modules/etc. 533 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.
535 def push_apply_app_config(self) -> None: 536 """Internal. Use app.config.apply() to apply app config changes.""" 537 # To be safe, let's run this by itself in the event loop. 538 # This avoids potential trouble if this gets called mid-draw or 539 # something like that. 540 self._pending_apply_app_config = True 541 _babase.pushcall(self._apply_app_config, raw=True)
Internal. Use app.config.apply() to apply app config changes.
543 def on_native_start(self) -> None: 544 """Called by the native layer when the app is being started.""" 545 assert _babase.in_logic_thread() 546 assert not self._native_start_called 547 self._native_start_called = True 548 self._update_state()
Called by the native layer when the app is being started.
550 def on_native_bootstrapping_complete(self) -> None: 551 """Called by the native layer once its ready to rock.""" 552 assert _babase.in_logic_thread() 553 assert not self._native_bootstrapping_completed 554 self._native_bootstrapping_completed = True 555 self._update_state()
Called by the native layer once its ready to rock.
557 def on_native_suspend(self) -> None: 558 """Called by the native layer when the app is suspended.""" 559 assert _babase.in_logic_thread() 560 assert not self._native_suspended # Should avoid redundant calls. 561 self._native_suspended = True 562 self._update_state()
Called by the native layer when the app is suspended.
564 def on_native_unsuspend(self) -> None: 565 """Called by the native layer when the app suspension ends.""" 566 assert _babase.in_logic_thread() 567 assert self._native_suspended # Should avoid redundant calls. 568 self._native_suspended = False 569 self._update_state()
Called by the native layer when the app suspension ends.
571 def on_native_shutdown(self) -> None: 572 """Called by the native layer when the app starts shutting down.""" 573 assert _babase.in_logic_thread() 574 self._native_shutdown_called = True 575 self._update_state()
Called by the native layer when the app starts shutting down.
577 def on_native_shutdown_complete(self) -> None: 578 """Called by the native layer when the app is done shutting down.""" 579 assert _babase.in_logic_thread() 580 self._native_shutdown_complete_called = True 581 self._update_state()
Called by the native layer when the app is done shutting down.
583 def on_native_active_changed(self) -> None: 584 """Called by the native layer when the app active state changes.""" 585 assert _babase.in_logic_thread() 586 if self._mode is not None: 587 self._mode.on_app_active_changed()
Called by the native layer when the app active state changes.
595 def handle_deep_link(self, url: str) -> None: 596 """Handle a deep link URL.""" 597 from babase._language import Lstr 598 599 assert _babase.in_logic_thread() 600 601 appname = _babase.appname() 602 if url.startswith(f'{appname}://code/'): 603 code = url.replace(f'{appname}://code/', '') 604 if self.classic is not None: 605 self.classic.accounts.add_pending_promo_code(code) 606 else: 607 try: 608 _babase.screenmessage( 609 Lstr(resource='errorText'), color=(1, 0, 0) 610 ) 611 _babase.getsimplesound('error').play() 612 except ImportError: 613 pass
Handle a deep link URL.
615 def on_initial_sign_in_complete(self) -> None: 616 """Called when initial sign-in (or lack thereof) completes. 617 618 This normally gets called by the plus subsystem. The 619 initial-sign-in process may include tasks such as syncing 620 account workspaces or other data so it may take a substantial 621 amount of time. 622 """ 623 assert _babase.in_logic_thread() 624 assert not self._initial_sign_in_completed 625 626 # Tell meta it can start scanning extra stuff that just showed 627 # up (namely account workspaces). 628 self.meta.start_extra_scan() 629 630 self._initial_sign_in_completed = True 631 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.
633 def set_ui_scale(self, scale: babase.UIScale) -> None: 634 """Change ui-scale on the fly. 635 636 Currently this is mainly for debugging and will not 637 be called as part of normal app operation. 638 """ 639 assert _babase.in_logic_thread() 640 641 # Apply to the native layer. 642 _babase.set_ui_scale(scale.name.lower()) 643 644 # Inform all subsystems that something screen-related has 645 # changed. We assume subsystems won't be added at this point so 646 # we can use the list directly. 647 assert self._subsystem_registration_ended 648 for subsystem in self._subsystems: 649 try: 650 subsystem.on_screen_change() 651 except Exception: 652 logging.exception( 653 'Error in on_screen_change() for subsystem %s.', subsystem 654 )
Change ui-scale on the fly.
Currently this is mainly for debugging and will not be called as part of normal app operation.
73 class State(Enum): 74 """High level state the app can be in.""" 75 76 # The app has not yet begun starting and should not be used in 77 # any way. 78 NOT_STARTED = 0 79 80 # The native layer is spinning up its machinery (screens, 81 # renderers, etc.). Nothing should happen in the Python layer 82 # until this completes. 83 NATIVE_BOOTSTRAPPING = 1 84 85 # Python app subsystems are being inited but should not yet 86 # interact or do any work. 87 INITING = 2 88 89 # Python app subsystems are inited and interacting, but the app 90 # has not yet embarked on a high level course of action. It is 91 # doing initial account logins, workspace & asset downloads, 92 # etc. 93 LOADING = 3 94 95 # All pieces are in place and the app is now doing its thing. 96 RUNNING = 4 97 98 # Used on platforms such as mobile where the app basically needs 99 # to shut down while backgrounded. In this state, all event 100 # loops are suspended and all graphics and audio must cease 101 # completely. Be aware that the suspended state can be entered 102 # from any other state including NATIVE_BOOTSTRAPPING and 103 # SHUTTING_DOWN. 104 SUSPENDED = 5 105 106 # The app is shutting down. This process may involve sending 107 # network messages or other things that can take up to a few 108 # seconds, so ideally graphics and audio should remain 109 # functional (with fades or spinners or whatever to show 110 # something is happening). 111 SHUTTING_DOWN = 6 112 113 # The app has completed shutdown. Any code running here should 114 # be basically immediate. 115 SHUTDOWN_COMPLETE = 7
High level state the app can be in.
Inherited Members
- enum.Enum
- name
- value
117 class DefaultAppModeSelector(AppModeSelector): 118 """Decides which AppModes to use to handle AppIntents. 119 120 This default version is generated by the project updater based 121 on the 'default_app_modes' value in the projectconfig. 122 123 It is also possible to modify app mode selection behavior by 124 setting app.mode_selector to an instance of a custom 125 AppModeSelector subclass. This is a good way to go if you are 126 modifying app behavior dynamically via a plugin instead of 127 statically in a spinoff project. 128 """ 129 130 @override 131 def app_mode_for_intent( 132 self, intent: AppIntent 133 ) -> type[AppMode] | None: 134 # pylint: disable=cyclic-import 135 136 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 137 # This section generated by batools.appmodule; do not edit. 138 139 # Ask our default app modes to handle it. 140 # (generated from 'default_app_modes' in projectconfig). 141 import baclassic 142 import babase 143 144 for appmode in [ 145 baclassic.ClassicAppMode, 146 babase.EmptyAppMode, 147 ]: 148 if appmode.can_handle_intent(intent): 149 return appmode 150 151 return None 152 153 # __DEFAULT_APP_MODE_SELECTION_END__ 154 155 @override 156 def testable_app_modes(self) -> list[type[AppMode]]: 157 # pylint: disable=cyclic-import 158 159 # __DEFAULT_TESTABLE_APP_MODES_BEGIN__ 160 # This section generated by batools.appmodule; do not edit. 161 162 # Return all our default_app_modes as testable. 163 # (generated from 'default_app_modes' in projectconfig). 164 import baclassic 165 import babase 166 167 return [ 168 baclassic.ClassicAppMode, 169 babase.EmptyAppMode, 170 ] 171 # __DEFAULT_TESTABLE_APP_MODES_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.
130 @override 131 def app_mode_for_intent( 132 self, intent: AppIntent 133 ) -> type[AppMode] | None: 134 # pylint: disable=cyclic-import 135 136 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 137 # This section generated by batools.appmodule; do not edit. 138 139 # Ask our default app modes to handle it. 140 # (generated from 'default_app_modes' in projectconfig). 141 import baclassic 142 import babase 143 144 for appmode in [ 145 baclassic.ClassicAppMode, 146 babase.EmptyAppMode, 147 ]: 148 if appmode.can_handle_intent(intent): 149 return appmode 150 151 return None 152 153 # __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.
155 @override 156 def testable_app_modes(self) -> list[type[AppMode]]: 157 # pylint: disable=cyclic-import 158 159 # __DEFAULT_TESTABLE_APP_MODES_BEGIN__ 160 # This section generated by batools.appmodule; do not edit. 161 162 # Return all our default_app_modes as testable. 163 # (generated from 'default_app_modes' in projectconfig). 164 import baclassic 165 import babase 166 167 return [ 168 baclassic.ClassicAppMode, 169 babase.EmptyAppMode, 170 ] 171 # __DEFAULT_TESTABLE_APP_MODES_END__
Return a list of modes to appear in the dev-console app-mode ui.
The user can switch between these app modes for testing. App-modes will be passed an AppIntentDefault when selected by the user.
Note that in normal circumstances AppModes should never be selected explicitly by the user but rather determined implicitly based on AppIntents.
18class AppConfig(dict): 19 """A special dict that holds the game's persistent configuration values. 20 21 Category: **App Classes** 22 23 It also provides methods for fetching values with app-defined fallback 24 defaults, applying contained values to the game, and committing the 25 config to storage. 26 27 Call babase.appconfig() to get the single shared instance of this class. 28 29 AppConfig data is stored as json on disk on so make sure to only place 30 json-friendly values in it (dict, list, str, float, int, bool). 31 Be aware that tuples will be quietly converted to lists when stored. 32 """ 33 34 def resolve(self, key: str) -> Any: 35 """Given a string key, return a config value (type varies). 36 37 This will substitute application defaults for values not present in 38 the config dict, filter some invalid values, etc. Note that these 39 values do not represent the state of the app; simply the state of its 40 config. Use babase.App to access actual live state. 41 42 Raises an Exception for unrecognized key names. To get the list of keys 43 supported by this method, use babase.AppConfig.builtin_keys(). Note 44 that it is perfectly legal to store other data in the config; it just 45 needs to be accessed through standard dict methods and missing values 46 handled manually. 47 """ 48 return _babase.resolve_appconfig_value(key) 49 50 def default_value(self, key: str) -> Any: 51 """Given a string key, return its predefined default value. 52 53 This is the value that will be returned by babase.AppConfig.resolve() 54 if the key is not present in the config dict or of an incompatible 55 type. 56 57 Raises an Exception for unrecognized key names. To get the list of keys 58 supported by this method, use babase.AppConfig.builtin_keys(). Note 59 that it is perfectly legal to store other data in the config; it just 60 needs to be accessed through standard dict methods and missing values 61 handled manually. 62 """ 63 return _babase.get_appconfig_default_value(key) 64 65 def builtin_keys(self) -> list[str]: 66 """Return the list of valid key names recognized by babase.AppConfig. 67 68 This set of keys can be used with resolve(), default_value(), etc. 69 It does not vary across platforms and may include keys that are 70 obsolete or not relevant on the current running version. (for instance, 71 VR related keys on non-VR platforms). This is to minimize the amount 72 of platform checking necessary) 73 74 Note that it is perfectly legal to store arbitrary named data in the 75 config, but in that case it is up to the user to test for the existence 76 of the key in the config dict, fall back to consistent defaults, etc. 77 """ 78 return _babase.get_appconfig_builtin_keys() 79 80 def apply(self) -> None: 81 """Apply config values to the running app. 82 83 This call is thread-safe and asynchronous; changes will happen 84 in the next logic event loop cycle. 85 """ 86 _babase.app.push_apply_app_config() 87 88 def commit(self) -> None: 89 """Commits the config to local storage. 90 91 Note that this call is asynchronous so the actual write to disk may not 92 occur immediately. 93 """ 94 commit_app_config() 95 96 def apply_and_commit(self) -> None: 97 """Run apply() followed by commit(); for convenience. 98 99 (This way the commit() will not occur if apply() hits invalid data) 100 """ 101 self.apply() 102 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.
34 def resolve(self, key: str) -> Any: 35 """Given a string key, return a config value (type varies). 36 37 This will substitute application defaults for values not present in 38 the config dict, filter some invalid values, etc. Note that these 39 values do not represent the state of the app; simply the state of its 40 config. Use babase.App to access actual live state. 41 42 Raises an Exception for unrecognized key names. To get the list of keys 43 supported by this method, use babase.AppConfig.builtin_keys(). Note 44 that it is perfectly legal to store other data in the config; it just 45 needs to be accessed through standard dict methods and missing values 46 handled manually. 47 """ 48 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.
50 def default_value(self, key: str) -> Any: 51 """Given a string key, return its predefined default value. 52 53 This is the value that will be returned by babase.AppConfig.resolve() 54 if the key is not present in the config dict or of an incompatible 55 type. 56 57 Raises an Exception for unrecognized key names. To get the list of keys 58 supported by this method, use babase.AppConfig.builtin_keys(). Note 59 that it is perfectly legal to store other data in the config; it just 60 needs to be accessed through standard dict methods and missing values 61 handled manually. 62 """ 63 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.
65 def builtin_keys(self) -> list[str]: 66 """Return the list of valid key names recognized by babase.AppConfig. 67 68 This set of keys can be used with resolve(), default_value(), etc. 69 It does not vary across platforms and may include keys that are 70 obsolete or not relevant on the current running version. (for instance, 71 VR related keys on non-VR platforms). This is to minimize the amount 72 of platform checking necessary) 73 74 Note that it is perfectly legal to store arbitrary named data in the 75 config, but in that case it is up to the user to test for the existence 76 of the key in the config dict, fall back to consistent defaults, etc. 77 """ 78 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.
80 def apply(self) -> None: 81 """Apply config values to the running app. 82 83 This call is thread-safe and asynchronous; changes will happen 84 in the next logic event loop cycle. 85 """ 86 _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.
88 def commit(self) -> None: 89 """Commits the config to local storage. 90 91 Note that this call is asynchronous so the actual write to disk may not 92 occur immediately. 93 """ 94 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.
96 def apply_and_commit(self) -> None: 97 """Run apply() followed by commit(); for convenience. 98 99 (This way the commit() will not occur if apply() hits invalid data) 100 """ 101 self.apply() 102 self.commit()
Run apply() followed by commit(); for convenience.
(This way the commit() will not occur if apply() hits invalid data)
Inherited Members
- builtins.dict
- get
- setdefault
- pop
- popitem
- keys
- items
- values
- update
- fromkeys
- clear
- copy
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.
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.
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.
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
Tells the app to simply run in its default mode.
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.
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
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.
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.
47 def handle_intent(self, intent: AppIntent) -> None: 48 """Handle an intent.""" 49 raise NotImplementedError('AppMode subclasses must override this.')
Handle an intent.
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.
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() 34 35 def testable_app_modes(self) -> list[type[AppMode]]: 36 """Return a list of modes to appear in the dev-console app-mode ui. 37 38 The user can switch between these app modes for testing. App-modes 39 will be passed an AppIntentDefault when selected by the user. 40 41 Note that in normal circumstances AppModes should never be 42 selected explicitly by the user but rather determined implicitly 43 based on AppIntents. 44 """ 45 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.
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.
35 def testable_app_modes(self) -> list[type[AppMode]]: 36 """Return a list of modes to appear in the dev-console app-mode ui. 37 38 The user can switch between these app modes for testing. App-modes 39 will be passed an AppIntentDefault when selected by the user. 40 41 Note that in normal circumstances AppModes should never be 42 selected explicitly by the user but rather determined implicitly 43 based on AppIntents. 44 """ 45 raise NotImplementedError()
Return a list of modes to appear in the dev-console app-mode ui.
The user can switch between these app modes for testing. App-modes will be passed an AppIntentDefault when selected by the user.
Note that in normal circumstances AppModes should never be selected explicitly by the user but rather determined implicitly based on AppIntents.
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.
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.
52 def on_app_shutdown_complete(self) -> None: 53 """Called when the app completes shutting down."""
Called when the app completes shutting down.
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.
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.
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!'))
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)
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.
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.
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.
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.
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.
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))
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.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
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()
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.
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
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
21class DevConsoleTab: 22 """Defines behavior for a tab in the dev-console.""" 23 24 def refresh(self) -> None: 25 """Called when the tab should refresh itself.""" 26 27 def request_refresh(self) -> None: 28 """The tab can call this to request that it be refreshed.""" 29 _babase.dev_console_request_refresh() 30 31 def button( 32 self, 33 label: str, 34 pos: tuple[float, float], 35 size: tuple[float, float], 36 call: Callable[[], Any] | None = None, 37 *, 38 h_anchor: Literal['left', 'center', 'right'] = 'center', 39 label_scale: float = 1.0, 40 corner_radius: float = 8.0, 41 style: Literal['normal', 'light'] = 'normal', 42 ) -> None: 43 """Add a button to the tab being refreshed.""" 44 assert _babase.app.devconsole.is_refreshing 45 _babase.dev_console_add_button( 46 label, 47 pos[0], 48 pos[1], 49 size[0], 50 size[1], 51 call, 52 h_anchor, 53 label_scale, 54 corner_radius, 55 style, 56 ) 57 58 def text( 59 self, 60 text: str, 61 pos: tuple[float, float], 62 *, 63 h_anchor: Literal['left', 'center', 'right'] = 'center', 64 h_align: Literal['left', 'center', 'right'] = 'center', 65 v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', 66 scale: float = 1.0, 67 ) -> None: 68 """Add a button to the tab being refreshed.""" 69 assert _babase.app.devconsole.is_refreshing 70 _babase.dev_console_add_text( 71 text, pos[0], pos[1], h_anchor, h_align, v_align, scale 72 ) 73 74 def python_terminal(self) -> None: 75 """Add a Python Terminal to the tab being refreshed.""" 76 assert _babase.app.devconsole.is_refreshing 77 _babase.dev_console_add_python_terminal() 78 79 @property 80 def width(self) -> float: 81 """Return the current tab width. Only call during refreshes.""" 82 assert _babase.app.devconsole.is_refreshing 83 return _babase.dev_console_tab_width() 84 85 @property 86 def height(self) -> float: 87 """Return the current tab height. Only call during refreshes.""" 88 assert _babase.app.devconsole.is_refreshing 89 return _babase.dev_console_tab_height() 90 91 @property 92 def base_scale(self) -> float: 93 """A scale value set depending on the app's UI scale. 94 95 Dev-console tabs can incorporate this into their UI sizes and 96 positions if they desire. This must be done manually however. 97 """ 98 assert _babase.app.devconsole.is_refreshing 99 return _babase.dev_console_base_scale()
Defines behavior for a tab in the dev-console.
27 def request_refresh(self) -> None: 28 """The tab can call this to request that it be refreshed.""" 29 _babase.dev_console_request_refresh()
The tab can call this to request that it be refreshed.
58 def text( 59 self, 60 text: str, 61 pos: tuple[float, float], 62 *, 63 h_anchor: Literal['left', 'center', 'right'] = 'center', 64 h_align: Literal['left', 'center', 'right'] = 'center', 65 v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', 66 scale: float = 1.0, 67 ) -> None: 68 """Add a button to the tab being refreshed.""" 69 assert _babase.app.devconsole.is_refreshing 70 _babase.dev_console_add_text( 71 text, pos[0], pos[1], h_anchor, h_align, v_align, scale 72 )
Add a button to the tab being refreshed.
74 def python_terminal(self) -> None: 75 """Add a Python Terminal to the tab being refreshed.""" 76 assert _babase.app.devconsole.is_refreshing 77 _babase.dev_console_add_python_terminal()
Add a Python Terminal to the tab being refreshed.
79 @property 80 def width(self) -> float: 81 """Return the current tab width. Only call during refreshes.""" 82 assert _babase.app.devconsole.is_refreshing 83 return _babase.dev_console_tab_width()
Return the current tab width. Only call during refreshes.
85 @property 86 def height(self) -> float: 87 """Return the current tab height. Only call during refreshes.""" 88 assert _babase.app.devconsole.is_refreshing 89 return _babase.dev_console_tab_height()
Return the current tab height. Only call during refreshes.
91 @property 92 def base_scale(self) -> float: 93 """A scale value set depending on the app's UI scale. 94 95 Dev-console tabs can incorporate this into their UI sizes and 96 positions if they desire. This must be done manually however. 97 """ 98 assert _babase.app.devconsole.is_refreshing 99 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.
247@dataclass 248class DevConsoleTabEntry: 249 """Represents a distinct tab in the dev-console.""" 250 251 name: str 252 factory: Callable[[], DevConsoleTab]
Represents a distinct tab in the dev-console.
255class DevConsoleSubsystem: 256 """Subsystem for wrangling the dev console. 257 258 The single instance of this class can be found at 259 babase.app.devconsole. The dev-console is a simple always-available 260 UI intended for use by developers; not end users. Traditionally it 261 is available by typing a backtick (`) key on a keyboard, but now can 262 be accessed via an on-screen button (see settings/advanced to enable 263 said button). 264 """ 265 266 def __init__(self) -> None: 267 # All tabs in the dev-console. Add your own stuff here via 268 # plugins or whatnot. 269 self.tabs: list[DevConsoleTabEntry] = [ 270 DevConsoleTabEntry('Python', DevConsoleTabPython), 271 DevConsoleTabEntry('AppModes', DevConsoleTabAppModes), 272 DevConsoleTabEntry('UI', DevConsoleTabUI), 273 ] 274 if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1': 275 self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest)) 276 self.is_refreshing = False 277 278 def do_refresh_tab(self, tabname: str) -> None: 279 """Called by the C++ layer when a tab should be filled out.""" 280 assert _babase.in_logic_thread() 281 282 # FIXME: We currently won't handle multiple tabs with the same 283 # name. We should give a clean error or something in that case. 284 tab: DevConsoleTab | None = None 285 for tabentry in self.tabs: 286 if tabentry.name == tabname: 287 tab = tabentry.factory() 288 break 289 290 if tab is None: 291 logging.error( 292 'DevConsole got refresh request for tab' 293 " '%s' which does not exist.", 294 tabname, 295 ) 296 return 297 298 self.is_refreshing = True 299 try: 300 tab.refresh() 301 finally: 302 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).
278 def do_refresh_tab(self, tabname: str) -> None: 279 """Called by the C++ layer when a tab should be filled out.""" 280 assert _babase.in_logic_thread() 281 282 # FIXME: We currently won't handle multiple tabs with the same 283 # name. We should give a clean error or something in that case. 284 tab: DevConsoleTab | None = None 285 for tabentry in self.tabs: 286 if tabentry.name == tabname: 287 tab = tabentry.factory() 288 break 289 290 if tab is None: 291 logging.error( 292 'DevConsole got refresh request for tab' 293 " '%s' which does not exist.", 294 tabname, 295 ) 296 return 297 298 self.is_refreshing = True 299 try: 300 tab.refresh() 301 finally: 302 self.is_refreshing = False
Called by the C++ layer when a tab should be filled out.
761def displaytime() -> babase.DisplayTime: 762 """Return the current display-time in seconds. 763 764 Category: **General Utility Functions** 765 766 Display-time is a time value intended to be used for animation and other 767 visual purposes. It will generally increment by a consistent amount each 768 frame. It will pass at an overall similar rate to AppTime, but trades 769 accuracy for smoothness. 770 771 Note that the value returned here is simply a float; it just has a 772 unique type in the type-checker's eyes to help prevent it from being 773 accidentally used with time functionality expecting other time types. 774 """ 775 import babase # pylint: disable=cyclic-import 776 777 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.
780def displaytimer(time: float, call: Callable[[], Any]) -> None: 781 """Schedule a callable object to run based on display-time. 782 783 Category: **General Utility Functions** 784 785 This function creates a one-off timer which cannot be canceled or 786 modified once created. If you require the ability to do so, or need 787 a repeating timer, use the babase.DisplayTimer class instead. 788 789 Display-time is a time value intended to be used for animation and other 790 visual purposes. It will generally increment by a consistent amount each 791 frame. It will pass at an overall similar rate to AppTime, but trades 792 accuracy for smoothness. 793 794 ##### Arguments 795 ###### time (float) 796 > Length of time in seconds that the timer will wait before firing. 797 798 ###### call (Callable[[], Any]) 799 > A callable Python object. Note that the timer will retain a 800 strong reference to the callable for as long as the timer exists, so you 801 may want to look into concepts such as babase.WeakCall if that is not 802 desired. 803 804 ##### Examples 805 Print some stuff through time: 806 >>> babase.screenmessage('hello from now!') 807 >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage, 808 ... 'hello from the future!')) 809 >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage, 810 ... 'hello from the future 2!')) 811 """ 812 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!'))
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)
820def do_once() -> bool: 821 """Return whether this is the first time running a line of code. 822 823 Category: **General Utility Functions** 824 825 This is used by 'print_once()' type calls to keep from overflowing 826 logs. The call functions by registering the filename and line where 827 The call is made from. Returns True if this location has not been 828 registered already, and False if it has. 829 830 ##### Example 831 This print will only fire for the first loop iteration: 832 >>> for i in range(10): 833 ... if babase.do_once(): 834 ... print('HelloWorld once from loop!') 835 """ 836 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!')
19class EmptyAppMode(AppMode): 20 """An AppMode that does not do much at all.""" 21 22 @override 23 @classmethod 24 def get_app_experience(cls) -> AppExperience: 25 return AppExperience.EMPTY 26 27 @override 28 @classmethod 29 def _supports_intent(cls, intent: AppIntent) -> bool: 30 # We support default and exec intents currently. 31 return isinstance(intent, AppIntentExec | AppIntentDefault) 32 33 @override 34 def handle_intent(self, intent: AppIntent) -> None: 35 if isinstance(intent, AppIntentExec): 36 _babase.empty_app_mode_handle_app_intent_exec(intent.code) 37 return 38 assert isinstance(intent, AppIntentDefault) 39 _babase.empty_app_mode_handle_app_intent_default() 40 41 @override 42 def on_activate(self) -> None: 43 # Let the native layer do its thing. 44 _babase.empty_app_mode_activate() 45 46 @override 47 def on_deactivate(self) -> None: 48 # Let the native layer do its thing. 49 _babase.empty_app_mode_deactivate()
An AppMode that does not do much at all.
22 @override 23 @classmethod 24 def get_app_experience(cls) -> AppExperience: 25 return AppExperience.EMPTY
Return the overall experience provided by this mode.
33 @override 34 def handle_intent(self, intent: AppIntent) -> None: 35 if isinstance(intent, AppIntentExec): 36 _babase.empty_app_mode_handle_app_intent_exec(intent.code) 37 return 38 assert isinstance(intent, AppIntentDefault) 39 _babase.empty_app_mode_handle_app_intent_default()
Handle an intent.
41 @override 42 def on_activate(self) -> None: 43 # Let the native layer do its thing. 44 _babase.empty_app_mode_activate()
Called when the mode is being activated.
46 @override 47 def on_deactivate(self) -> None: 48 # Let the native layer do its thing. 49 _babase.empty_app_mode_deactivate()
Called when the mode is being deactivated.
Inherited Members
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: