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 android_get_external_files_dir, 25 appname, 26 appnameupper, 27 apptime, 28 apptimer, 29 AppTimer, 30 asset_loads_allowed, 31 fullscreen_control_available, 32 fullscreen_control_get, 33 fullscreen_control_key_shortcut, 34 fullscreen_control_set, 35 charstr, 36 clipboard_get_text, 37 clipboard_has_text, 38 clipboard_is_supported, 39 clipboard_set_text, 40 ContextCall, 41 ContextRef, 42 displaytime, 43 displaytimer, 44 DisplayTimer, 45 do_once, 46 env, 47 Env, 48 fade_screen, 49 fatal_error, 50 get_display_resolution, 51 get_immediate_return_code, 52 get_input_idle_time, 53 get_low_level_config_value, 54 get_max_graphics_quality, 55 get_replays_dir, 56 get_string_height, 57 get_string_width, 58 get_v1_cloud_log_file_path, 59 getsimplesound, 60 has_user_run_commands, 61 have_chars, 62 have_permission, 63 in_logic_thread, 64 increment_analytics_count, 65 invoke_main_menu, 66 is_os_playing_music, 67 is_xcode_build, 68 lock_all_input, 69 mac_music_app_get_playlists, 70 mac_music_app_get_volume, 71 mac_music_app_init, 72 mac_music_app_play_playlist, 73 mac_music_app_set_volume, 74 mac_music_app_stop, 75 music_player_play, 76 music_player_set_volume, 77 music_player_shutdown, 78 music_player_stop, 79 native_review_request, 80 native_review_request_supported, 81 native_stack_trace, 82 open_file_externally, 83 open_url, 84 overlay_web_browser_close, 85 overlay_web_browser_is_open, 86 overlay_web_browser_is_supported, 87 overlay_web_browser_open_url, 88 print_load_info, 89 pushcall, 90 quit, 91 reload_media, 92 request_permission, 93 safecolor, 94 screenmessage, 95 set_analytics_screen, 96 set_low_level_config_value, 97 set_thread_name, 98 set_ui_input_device, 99 show_progress_bar, 100 shutdown_suppress_begin, 101 shutdown_suppress_end, 102 shutdown_suppress_count, 103 SimpleSound, 104 supports_max_fps, 105 supports_vsync, 106 unlock_all_input, 107 user_agent_string, 108 Vec3, 109 workspaces_in_use, 110) 111 112from babase._accountv2 import AccountV2Handle, AccountV2Subsystem 113from babase._app import App 114from babase._appconfig import commit_app_config 115from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec 116from babase._appmode import AppMode 117from babase._appsubsystem import AppSubsystem 118from babase._appmodeselector import AppModeSelector 119from babase._appconfig import AppConfig 120from babase._apputils import ( 121 handle_leftover_v1_cloud_log_file, 122 is_browser_likely_available, 123 garbage_collect, 124 get_remote_app_name, 125 AppHealthMonitor, 126) 127from babase._devconsole import ( 128 DevConsoleTab, 129 DevConsoleTabEntry, 130 DevConsoleSubsystem, 131) 132from babase._emptyappmode import EmptyAppMode 133from babase._error import ( 134 print_exception, 135 print_error, 136 ContextError, 137 NotFoundError, 138 PlayerNotFoundError, 139 SessionPlayerNotFoundError, 140 NodeNotFoundError, 141 ActorNotFoundError, 142 InputDeviceNotFoundError, 143 WidgetNotFoundError, 144 ActivityNotFoundError, 145 TeamNotFoundError, 146 MapNotFoundError, 147 SessionTeamNotFoundError, 148 SessionNotFoundError, 149 DelegateNotFoundError, 150) 151from babase._general import ( 152 utf8_all, 153 DisplayTime, 154 AppTime, 155 WeakCall, 156 Call, 157 existing, 158 Existable, 159 verify_object_death, 160 storagename, 161 getclass, 162 get_type_name, 163) 164from babase._language import Lstr, LanguageSubsystem 165from babase._login import LoginAdapter, LoginInfo 166 167# noinspection PyProtectedMember 168# (PyCharm inspection bug?) 169from babase._mgen.enums import ( 170 Permission, 171 SpecialChar, 172 InputType, 173 UIScale, 174 QuitType, 175) 176from babase._math import normalized_color, is_point_in_box, vec3validate 177from babase._meta import MetadataSubsystem 178from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS 179from babase._plugin import PluginSpec, Plugin, PluginSubsystem 180from babase._stringedit import StringEditAdapter, StringEditSubsystem 181from babase._text import timestring 182 183_babase.app = app = App() 184app.postinit() 185 186__all__ = [ 187 'AccountV2Handle', 188 'AccountV2Subsystem', 189 'ActivityNotFoundError', 190 'ActorNotFoundError', 191 'add_clean_frame_callback', 192 'android_get_external_files_dir', 193 'app', 194 'app', 195 'App', 196 'AppConfig', 197 'AppHealthMonitor', 198 'AppIntent', 199 'AppIntentDefault', 200 'AppIntentExec', 201 'AppMode', 202 'appname', 203 'appnameupper', 204 'AppModeSelector', 205 'AppSubsystem', 206 'apptime', 207 'AppTime', 208 'apptime', 209 'apptimer', 210 'AppTimer', 211 'asset_loads_allowed', 212 'Call', 213 'fullscreen_control_available', 214 'fullscreen_control_get', 215 'fullscreen_control_key_shortcut', 216 'fullscreen_control_set', 217 'charstr', 218 'clipboard_get_text', 219 'clipboard_has_text', 220 'clipboard_is_supported', 221 'clipboard_set_text', 222 'commit_app_config', 223 'ContextCall', 224 'ContextError', 225 'ContextRef', 226 'DelegateNotFoundError', 227 'DevConsoleTab', 228 'DevConsoleTabEntry', 229 'DevConsoleSubsystem', 230 'DisplayTime', 231 'displaytime', 232 'displaytimer', 233 'DisplayTimer', 234 'do_once', 235 'EmptyAppMode', 236 'env', 237 'Env', 238 'Existable', 239 'existing', 240 'fade_screen', 241 'fatal_error', 242 'garbage_collect', 243 'get_display_resolution', 244 'get_immediate_return_code', 245 'get_input_idle_time', 246 'get_ip_address_type', 247 'get_low_level_config_value', 248 'get_max_graphics_quality', 249 'get_remote_app_name', 250 'get_replays_dir', 251 'get_string_height', 252 'get_string_width', 253 'get_v1_cloud_log_file_path', 254 'get_type_name', 255 'getclass', 256 'getsimplesound', 257 'handle_leftover_v1_cloud_log_file', 258 'has_user_run_commands', 259 'have_chars', 260 'have_permission', 261 'in_logic_thread', 262 'increment_analytics_count', 263 'InputDeviceNotFoundError', 264 'InputType', 265 'invoke_main_menu', 266 'is_browser_likely_available', 267 'is_browser_likely_available', 268 'is_os_playing_music', 269 'is_point_in_box', 270 'is_xcode_build', 271 'LanguageSubsystem', 272 'lock_all_input', 273 'LoginAdapter', 274 'LoginInfo', 275 'Lstr', 276 'mac_music_app_get_playlists', 277 'mac_music_app_get_volume', 278 'mac_music_app_init', 279 'mac_music_app_play_playlist', 280 'mac_music_app_set_volume', 281 'mac_music_app_stop', 282 'MapNotFoundError', 283 'MetadataSubsystem', 284 'music_player_play', 285 'music_player_set_volume', 286 'music_player_shutdown', 287 'music_player_stop', 288 'native_review_request', 289 'native_review_request_supported', 290 'native_stack_trace', 291 'NodeNotFoundError', 292 'normalized_color', 293 'NotFoundError', 294 'open_file_externally', 295 'open_url', 296 'overlay_web_browser_close', 297 'overlay_web_browser_is_open', 298 'overlay_web_browser_is_supported', 299 'overlay_web_browser_open_url', 300 'Permission', 301 'PlayerNotFoundError', 302 'Plugin', 303 'PluginSubsystem', 304 'PluginSpec', 305 'print_error', 306 'print_exception', 307 'print_load_info', 308 'pushcall', 309 'quit', 310 'QuitType', 311 'reload_media', 312 'request_permission', 313 'safecolor', 314 'screenmessage', 315 'SessionNotFoundError', 316 'SessionPlayerNotFoundError', 317 'SessionTeamNotFoundError', 318 'set_analytics_screen', 319 'set_low_level_config_value', 320 'set_thread_name', 321 'set_ui_input_device', 322 'show_progress_bar', 323 'shutdown_suppress_begin', 324 'shutdown_suppress_end', 325 'shutdown_suppress_count', 326 'SimpleSound', 327 'SpecialChar', 328 'storagename', 329 'StringEditAdapter', 330 'StringEditSubsystem', 331 'supports_max_fps', 332 'supports_vsync', 333 'TeamNotFoundError', 334 'timestring', 335 'UIScale', 336 'unlock_all_input', 337 'user_agent_string', 338 'utf8_all', 339 'Vec3', 340 'vec3validate', 341 'verify_object_death', 342 'WeakCall', 343 'WidgetNotFoundError', 344 'workspaces_in_use', 345 'DEFAULT_REQUEST_TIMEOUT_SECONDS', 346] 347 348# We want stuff to show up as babase.Foo instead of babase._sub.Foo. 349set_canonical_module_names(globals()) 350 351# Allow the native layer to wrap a few things up. 352_babase.reached_end_of_babase() 353 354# Marker we pop down at the very end so other modules can run sanity 355# checks to make sure we aren't importing them reciprocally when they 356# import us. 357_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 are currently valid; 69 only that they exist. If/when credentials are validated, the 'primary' 70 account handle will be set. 71 """ 72 raise NotImplementedError('This should be overridden.') 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 game 110 # that initial-log-in is done or if we've already kicked 111 # 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('This should be overridden.') 269 270 def set_primary_credentials(self, credentials: str | None) -> None: 271 """Set credentials for the primary app account.""" 272 raise NotImplementedError('This should be overridden.') 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 are currently valid; 69 only that they exist. If/when credentials are validated, the 'primary' 70 account handle will be set. 71 """ 72 raise NotImplementedError('This should be overridden.')
Are credentials currently set for the primary app account?
Note that this does not mean these credentials are currently valid; 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 game 110 # that initial-log-in is done or if we've already kicked 111 # 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 bascenev1 142 import babase 143 144 for appmode in [ 145 bascenev1.SceneV1AppMode, 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 def __init__(self) -> None: 156 """(internal) 157 158 Do not instantiate this class. You can access the single shared 159 instance of it through various high level packages: 'babase.app', 160 'bascenev1.app', 'bauiv1.app', etc. 161 """ 162 163 # Hack for docs-generation: we can be imported with dummy modules 164 # instead of our actual binary ones, but we don't function. 165 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 166 return 167 168 self.env: babase.Env = _babase.Env() 169 self.state = self.State.NOT_STARTED 170 171 # Default executor which can be used for misc background 172 # processing. It should also be passed to any additional asyncio 173 # loops we create so that everything shares the same single set 174 # of worker threads. 175 self.threadpool = ThreadPoolExecutor( 176 thread_name_prefix='baworker', 177 initializer=self._thread_pool_thread_init, 178 ) 179 180 self.meta = MetadataSubsystem() 181 self.net = NetworkSubsystem() 182 self.workspaces = WorkspaceSubsystem() 183 self.components = AppComponentSubsystem() 184 self.stringedit = StringEditSubsystem() 185 self.devconsole = DevConsoleSubsystem() 186 187 # This is incremented any time the app is backgrounded or 188 # foregrounded; can be a simple way to determine if network data 189 # should be refreshed/etc. 190 self.fg_state = 0 191 192 self._subsystems: list[AppSubsystem] = [] 193 self._native_bootstrapping_completed = False 194 self._init_completed = False 195 self._meta_scan_completed = False 196 self._native_start_called = False 197 self._native_suspended = False 198 self._native_shutdown_called = False 199 self._native_shutdown_complete_called = False 200 self._initial_sign_in_completed = False 201 self._called_on_initing = False 202 self._called_on_loading = False 203 self._called_on_running = False 204 self._subsystem_registration_ended = False 205 self._pending_apply_app_config = False 206 self._asyncio_loop: asyncio.AbstractEventLoop | None = None 207 self._asyncio_tasks: set[asyncio.Task] = set() 208 self._asyncio_timer: babase.AppTimer | None = None 209 self._config: babase.AppConfig | None = None 210 self._pending_intent: AppIntent | None = None 211 self._intent: AppIntent | None = None 212 self._mode: AppMode | None = None 213 self._mode_selector: babase.AppModeSelector | None = None 214 self._shutdown_task: asyncio.Task[None] | None = None 215 self._shutdown_tasks: list[Coroutine[None, None, None]] = [ 216 self._wait_for_shutdown_suppressions(), 217 self._fade_and_shutdown_graphics(), 218 self._fade_and_shutdown_audio(), 219 ] 220 self._pool_thread_count = 0 221 222 # We hold a lock while lazy-loading our subsystem properties so 223 # we don't spin up any subsystem more than once, but the lock is 224 # recursive so that the subsystems can instantiate other 225 # subsystems. 226 self._subsystem_property_lock = RLock() 227 self._subsystem_property_data: dict[str, AppSubsystem | bool] = {} 228 229 def postinit(self) -> None: 230 """Called after we've been inited and assigned to babase.app. 231 232 Anything that accesses babase.app as part of its init process 233 must go here instead of __init__. 234 """ 235 236 # Hack for docs-generation: We can be imported with dummy 237 # modules instead of our actual binary ones, but we don't 238 # function. 239 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 240 return 241 242 self.lang = LanguageSubsystem() 243 self.plugins = PluginSubsystem() 244 245 @property 246 def active(self) -> bool: 247 """Whether the app is currently front and center. 248 249 This will be False when the app is hidden, other activities 250 are covering it, etc. (depending on the platform). 251 """ 252 return _babase.app_is_active() 253 254 @property 255 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 256 """The logic thread's asyncio event loop. 257 258 This allow async tasks to be run in the logic thread. 259 260 Generally you should call App.create_async_task() to schedule 261 async code to run instead of using this directly. That will 262 handle retaining the task and logging errors automatically. 263 Only schedule tasks onto asyncio_loop yourself when you intend 264 to hold on to the returned task and await its results. Releasing 265 the task reference can lead to subtle bugs such as unreported 266 errors and garbage-collected tasks disappearing before their 267 work is done. 268 269 Note that, at this time, the asyncio loop is encapsulated 270 and explicitly stepped by the engine's logic thread loop and 271 thus things like asyncio.get_running_loop() will unintuitively 272 *not* return this loop from most places in the logic thread; 273 only from within a task explicitly created in this loop. 274 Hopefully this situation will be improved in the future with a 275 unified event loop. 276 """ 277 assert _babase.in_logic_thread() 278 assert self._asyncio_loop is not None 279 return self._asyncio_loop 280 281 def create_async_task( 282 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 283 ) -> None: 284 """Create a fully managed async task. 285 286 This will automatically retain and release a reference to the task 287 and log any exceptions that occur in it. If you need to await a task 288 or otherwise need more control, schedule a task directly using 289 App.asyncio_loop. 290 """ 291 assert _babase.in_logic_thread() 292 293 # Hold a strong reference to the task until it is done. 294 # Otherwise it is possible for it to be garbage collected and 295 # disappear midway if the caller does not hold on to the 296 # returned task, which seems like a great way to introduce 297 # hard-to-track bugs. 298 task = self.asyncio_loop.create_task(coro, name=name) 299 self._asyncio_tasks.add(task) 300 task.add_done_callback(self._on_task_done) 301 302 def _on_task_done(self, task: asyncio.Task) -> None: 303 # Report any errors that occurred. 304 try: 305 exc = task.exception() 306 if exc is not None: 307 logging.error( 308 "Error in async task '%s'.", task.get_name(), exc_info=exc 309 ) 310 except Exception: 311 logging.exception('Error reporting async task error.') 312 313 self._asyncio_tasks.remove(task) 314 315 @property 316 def config(self) -> babase.AppConfig: 317 """The babase.AppConfig instance representing the app's config state.""" 318 assert self._config is not None 319 return self._config 320 321 @property 322 def mode_selector(self) -> babase.AppModeSelector: 323 """Controls which app-modes are used for handling given intents. 324 325 Plugins can override this to change high level app behavior and 326 spinoff projects can change the default implementation for the 327 same effect. 328 """ 329 if self._mode_selector is None: 330 raise RuntimeError( 331 'mode_selector cannot be used until the app reaches' 332 ' the running state.' 333 ) 334 return self._mode_selector 335 336 @mode_selector.setter 337 def mode_selector(self, selector: babase.AppModeSelector) -> None: 338 self._mode_selector = selector 339 340 def _get_subsystem_property( 341 self, ssname: str, create_call: Callable[[], AppSubsystem | None] 342 ) -> AppSubsystem | None: 343 344 # Quick-out: if a subsystem is present, just return it; no 345 # locking necessary. 346 val = self._subsystem_property_data.get(ssname) 347 if val is not None: 348 if val is False: 349 # False means subsystem is confirmed as unavailable. 350 return None 351 if val is not True: 352 # A subsystem has been set. Return it. 353 return val 354 355 # Anything else (no val present or val True) requires locking. 356 with self._subsystem_property_lock: 357 val = self._subsystem_property_data.get(ssname) 358 if val is not None: 359 if val is False: 360 # False means confirmed as not present. 361 return None 362 if val is True: 363 # True means this property is already being loaded, 364 # and the fact that we're holding the lock means 365 # we're doing the loading, so this is a dependency 366 # loop. Not good. 367 raise RuntimeError( 368 f'Subsystem dependency loop detected for {ssname}' 369 ) 370 # Must be an instantiated subsystem. Noice. 371 return val 372 373 # Ok, there's nothing here for it. Instantiate and set it 374 # while we hold the lock. Set a placeholder value of True 375 # while we load so we can error if something we're loading 376 # tries to recursively load us. 377 self._subsystem_property_data[ssname] = True 378 379 # Do our one attempt to create the singleton. 380 val = create_call() 381 self._subsystem_property_data[ssname] = ( 382 False if val is None else val 383 ) 384 385 return val 386 387 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__ 388 # This section generated by batools.appmodule; do not edit. 389 390 @property 391 def classic(self) -> ClassicSubsystem | None: 392 """Our classic subsystem (if available).""" 393 return self._get_subsystem_property( 394 'classic', self._create_classic_subsystem 395 ) # type: ignore 396 397 @staticmethod 398 def _create_classic_subsystem() -> ClassicSubsystem | None: 399 # pylint: disable=cyclic-import 400 try: 401 from baclassic import ClassicSubsystem 402 403 return ClassicSubsystem() 404 except ImportError: 405 return None 406 except Exception: 407 logging.exception('Error importing baclassic.') 408 return None 409 410 @property 411 def plus(self) -> PlusSubsystem | None: 412 """Our plus subsystem (if available).""" 413 return self._get_subsystem_property( 414 'plus', self._create_plus_subsystem 415 ) # type: ignore 416 417 @staticmethod 418 def _create_plus_subsystem() -> PlusSubsystem | None: 419 # pylint: disable=cyclic-import 420 try: 421 from baplus import PlusSubsystem 422 423 return PlusSubsystem() 424 except ImportError: 425 return None 426 except Exception: 427 logging.exception('Error importing baplus.') 428 return None 429 430 @property 431 def ui_v1(self) -> UIV1Subsystem: 432 """Our ui_v1 subsystem (always available).""" 433 return self._get_subsystem_property( 434 'ui_v1', self._create_ui_v1_subsystem 435 ) # type: ignore 436 437 @staticmethod 438 def _create_ui_v1_subsystem() -> UIV1Subsystem: 439 # pylint: disable=cyclic-import 440 441 from bauiv1 import UIV1Subsystem 442 443 return UIV1Subsystem() 444 445 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__ 446 447 def register_subsystem(self, subsystem: AppSubsystem) -> None: 448 """Called by the AppSubsystem class. Do not use directly.""" 449 450 # We only allow registering new subsystems if we've not yet 451 # reached the 'running' state. This ensures that all subsystems 452 # receive a consistent set of callbacks starting with 453 # on_app_running(). 454 455 if self._subsystem_registration_ended: 456 raise RuntimeError( 457 'Subsystems can no longer be registered at this point.' 458 ) 459 self._subsystems.append(subsystem) 460 461 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 462 """Add a task to be run on app shutdown. 463 464 Note that shutdown tasks will be canceled after 465 App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running. 466 """ 467 if ( 468 self.state is self.State.SHUTTING_DOWN 469 or self.state is self.State.SHUTDOWN_COMPLETE 470 ): 471 stname = self.state.name 472 raise RuntimeError( 473 f'Cannot add shutdown tasks with current state {stname}.' 474 ) 475 self._shutdown_tasks.append(coro) 476 477 def run(self) -> None: 478 """Run the app to completion. 479 480 Note that this only works on builds where Ballistica manages 481 its own event loop. 482 """ 483 _babase.run_app() 484 485 def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None: 486 """Submit a call to the app threadpool where result is not needed. 487 488 Normally, doing work in a thread-pool involves creating a future 489 and waiting for its result, which is an important step because it 490 propagates any Exceptions raised by the submitted work. When the 491 result in not important, however, this call can be used. The app 492 will log any exceptions that occur. 493 """ 494 fut = self.threadpool.submit(call) 495 fut.add_done_callback(self._threadpool_no_wait_done) 496 497 def set_intent(self, intent: AppIntent) -> None: 498 """Set the intent for the app. 499 500 Intent defines what the app is trying to do at a given time. 501 This call is asynchronous; the intent switch will happen in the 502 logic thread in the near future. If set_intent is called 503 repeatedly before the change takes place, the final intent to be 504 set will be used. 505 """ 506 507 # Mark this one as pending. We do this synchronously so that the 508 # last one marked actually takes effect if there is overlap 509 # (doing this in the bg thread could result in race conditions). 510 self._pending_intent = intent 511 512 # Do the actual work of calcing our app-mode/etc. in a bg thread 513 # since it may block for a moment to load modules/etc. 514 self.threadpool_submit_no_wait(partial(self._set_intent, intent)) 515 516 def push_apply_app_config(self) -> None: 517 """Internal. Use app.config.apply() to apply app config changes.""" 518 # To be safe, let's run this by itself in the event loop. 519 # This avoids potential trouble if this gets called mid-draw or 520 # something like that. 521 self._pending_apply_app_config = True 522 _babase.pushcall(self._apply_app_config, raw=True) 523 524 def on_native_start(self) -> None: 525 """Called by the native layer when the app is being started.""" 526 assert _babase.in_logic_thread() 527 assert not self._native_start_called 528 self._native_start_called = True 529 self._update_state() 530 531 def on_native_bootstrapping_complete(self) -> None: 532 """Called by the native layer once its ready to rock.""" 533 assert _babase.in_logic_thread() 534 assert not self._native_bootstrapping_completed 535 self._native_bootstrapping_completed = True 536 self._update_state() 537 538 def on_native_suspend(self) -> None: 539 """Called by the native layer when the app is suspended.""" 540 assert _babase.in_logic_thread() 541 assert not self._native_suspended # Should avoid redundant calls. 542 self._native_suspended = True 543 self._update_state() 544 545 def on_native_unsuspend(self) -> None: 546 """Called by the native layer when the app suspension ends.""" 547 assert _babase.in_logic_thread() 548 assert self._native_suspended # Should avoid redundant calls. 549 self._native_suspended = False 550 self._update_state() 551 552 def on_native_shutdown(self) -> None: 553 """Called by the native layer when the app starts shutting down.""" 554 assert _babase.in_logic_thread() 555 self._native_shutdown_called = True 556 self._update_state() 557 558 def on_native_shutdown_complete(self) -> None: 559 """Called by the native layer when the app is done shutting down.""" 560 assert _babase.in_logic_thread() 561 self._native_shutdown_complete_called = True 562 self._update_state() 563 564 def on_native_active_changed(self) -> None: 565 """Called by the native layer when the app active state changes.""" 566 assert _babase.in_logic_thread() 567 if self._mode is not None: 568 self._mode.on_app_active_changed() 569 570 def read_config(self) -> None: 571 """(internal)""" 572 from babase._appconfig import read_app_config 573 574 self._config = read_app_config() 575 576 def handle_deep_link(self, url: str) -> None: 577 """Handle a deep link URL.""" 578 from babase._language import Lstr 579 580 assert _babase.in_logic_thread() 581 582 appname = _babase.appname() 583 if url.startswith(f'{appname}://code/'): 584 code = url.replace(f'{appname}://code/', '') 585 if self.classic is not None: 586 self.classic.accounts.add_pending_promo_code(code) 587 else: 588 try: 589 _babase.screenmessage( 590 Lstr(resource='errorText'), color=(1, 0, 0) 591 ) 592 _babase.getsimplesound('error').play() 593 except ImportError: 594 pass 595 596 def on_initial_sign_in_complete(self) -> None: 597 """Called when initial sign-in (or lack thereof) completes. 598 599 This normally gets called by the plus subsystem. The 600 initial-sign-in process may include tasks such as syncing 601 account workspaces or other data so it may take a substantial 602 amount of time. 603 """ 604 assert _babase.in_logic_thread() 605 assert not self._initial_sign_in_completed 606 607 # Tell meta it can start scanning extra stuff that just showed 608 # up (namely account workspaces). 609 self.meta.start_extra_scan() 610 611 self._initial_sign_in_completed = True 612 self._update_state() 613 614 def _set_intent(self, intent: AppIntent) -> None: 615 # This should be happening in a bg thread. 616 assert not _babase.in_logic_thread() 617 try: 618 # Ask the selector what app-mode to use for this intent. 619 if self.mode_selector is None: 620 raise RuntimeError('No AppModeSelector set.') 621 modetype = self.mode_selector.app_mode_for_intent(intent) 622 623 # NOTE: Since intents are somewhat high level things, should 624 # we do some universal thing like a screenmessage saying 625 # 'The app cannot handle that request' on failure? 626 627 if modetype is None: 628 raise RuntimeError( 629 f'No app-mode found to handle app-intent' 630 f' type {type(intent)}.' 631 ) 632 633 # Make sure the app-mode the selector gave us *actually* 634 # supports the intent. 635 if not modetype.can_handle_intent(intent): 636 raise RuntimeError( 637 f'Intent {intent} cannot be handled by AppMode type' 638 f' {modetype} (selector {self.mode_selector}' 639 f' incorrectly thinks that it can be).' 640 ) 641 642 # Ok; seems legit. Now instantiate the mode if necessary and 643 # kick back to the logic thread to apply. 644 mode = modetype() 645 _babase.pushcall( 646 partial(self._apply_intent, intent, mode), 647 from_other_thread=True, 648 ) 649 except Exception: 650 logging.exception('Error setting app intent to %s.', intent) 651 _babase.pushcall( 652 partial(self._display_set_intent_error, intent), 653 from_other_thread=True, 654 ) 655 656 def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None: 657 assert _babase.in_logic_thread() 658 659 # ONLY apply this intent if it is still the most recent one 660 # submitted. 661 if intent is not self._pending_intent: 662 return 663 664 # If the app-mode for this intent is different than the active 665 # one, switch. 666 if type(mode) is not type(self._mode): 667 if self._mode is None: 668 is_initial_mode = True 669 else: 670 is_initial_mode = False 671 try: 672 self._mode.on_deactivate() 673 except Exception: 674 logging.exception( 675 'Error deactivating app-mode %s.', self._mode 676 ) 677 self._mode = mode 678 try: 679 mode.on_activate() 680 except Exception: 681 # Hmm; what should we do in this case?... 682 logging.exception('Error activating app-mode %s.', mode) 683 684 # Let the world know when we first have an app-mode; certain 685 # app stuff such as input processing can proceed at that 686 # point. 687 if is_initial_mode: 688 _babase.on_initial_app_mode_set() 689 690 try: 691 mode.handle_intent(intent) 692 except Exception: 693 logging.exception( 694 'Error handling intent %s in app-mode %s.', intent, mode 695 ) 696 697 def _display_set_intent_error(self, intent: AppIntent) -> None: 698 """Show the *user* something went wrong setting an intent.""" 699 from babase._language import Lstr 700 701 del intent 702 _babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) 703 _babase.getsimplesound('error').play() 704 705 def _on_initing(self) -> None: 706 """Called when the app enters the initing state. 707 708 Here we can put together subsystems and other pieces for the 709 app, but most things should not be doing any work yet. 710 """ 711 # pylint: disable=cyclic-import 712 from babase import _asyncio 713 from babase import _appconfig 714 from babase._apputils import AppHealthMonitor 715 from babase import _env 716 717 assert _babase.in_logic_thread() 718 719 _env.on_app_state_initing() 720 721 self._asyncio_loop = _asyncio.setup_asyncio() 722 self.health_monitor = AppHealthMonitor() 723 724 # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__ 725 # This section generated by batools.appmodule; do not edit. 726 727 # Poke these attrs to create all our subsystems. 728 _ = self.plus 729 _ = self.classic 730 _ = self.ui_v1 731 732 # __FEATURESET_APP_SUBSYSTEM_CREATE_END__ 733 734 # We're a pretty short-lived state. This should flip us to 735 # 'loading'. 736 self._init_completed = True 737 self._update_state() 738 739 def _on_loading(self) -> None: 740 """Called when we enter the loading state. 741 742 At this point, all built-in pieces of the app should be in place 743 and can start talking to each other and doing work. Though at a 744 high level, the goal of the app at this point is only to sign in 745 to initial accounts, download workspaces, and otherwise prepare 746 itself to really 'run'. 747 """ 748 assert _babase.in_logic_thread() 749 750 # Get meta-system scanning built-in stuff in the bg. 751 self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete) 752 753 # Inform all app subsystems in the same order they were inited. 754 # Operate on a copy here because subsystems can still be added 755 # at this point. 756 for subsystem in self._subsystems.copy(): 757 try: 758 subsystem.on_app_loading() 759 except Exception: 760 logging.exception( 761 'Error in on_app_loading for subsystem %s.', subsystem 762 ) 763 764 # Normally plus tells us when initial sign-in is done. If plus 765 # is not present, however, we just do it ourself so we can 766 # proceed on to the running state. 767 if self.plus is None: 768 _babase.pushcall(self.on_initial_sign_in_complete) 769 770 def _on_meta_scan_complete(self) -> None: 771 """Called when meta-scan is done doing its thing.""" 772 assert _babase.in_logic_thread() 773 774 # Now that we know what's out there, build our final plugin set. 775 self.plugins.on_meta_scan_complete() 776 777 assert not self._meta_scan_completed 778 self._meta_scan_completed = True 779 self._update_state() 780 781 def _on_running(self) -> None: 782 """Called when we enter the running state. 783 784 At this point, all workspaces, initial accounts, etc. are in place 785 and we can actually get started doing whatever we're gonna do. 786 """ 787 assert _babase.in_logic_thread() 788 789 # Let our native layer know. 790 _babase.on_app_running() 791 792 # Set a default app-mode-selector if none has been set yet 793 # by a plugin or whatnot. 794 if self._mode_selector is None: 795 self._mode_selector = self.DefaultAppModeSelector() 796 797 # Inform all app subsystems in the same order they were 798 # registered. Operate on a copy here because subsystems can 799 # still be added at this point. 800 # 801 # NOTE: Do we need to allow registering still at this point? If 802 # something gets registered here, it won't have its 803 # on_app_running callback called. Hmm; I suppose that's the only 804 # way that plugins can register subsystems though. 805 for subsystem in self._subsystems.copy(): 806 try: 807 subsystem.on_app_running() 808 except Exception: 809 logging.exception( 810 'Error in on_app_running for subsystem %s.', subsystem 811 ) 812 813 # Cut off new subsystem additions at this point. 814 self._subsystem_registration_ended = True 815 816 # If 'exec' code was provided to the app, always kick that off 817 # here as an intent. 818 exec_cmd = _babase.exec_arg() 819 if exec_cmd is not None: 820 self.set_intent(AppIntentExec(exec_cmd)) 821 elif self._pending_intent is None: 822 # Otherwise tell the app to do its default thing *only* if a 823 # plugin hasn't already told it to do something. 824 self.set_intent(AppIntentDefault()) 825 826 def _apply_app_config(self) -> None: 827 assert _babase.in_logic_thread() 828 829 _babase.lifecyclelog('apply-app-config') 830 831 # If multiple apply calls have been made, only actually apply 832 # once. 833 if not self._pending_apply_app_config: 834 return 835 836 _pending_apply_app_config = False 837 838 # Inform all app subsystems in the same order they were inited. 839 # Operate on a copy here because subsystems may still be able to 840 # be added at this point. 841 for subsystem in self._subsystems.copy(): 842 try: 843 subsystem.do_apply_app_config() 844 except Exception: 845 logging.exception( 846 'Error in do_apply_app_config for subsystem %s.', subsystem 847 ) 848 849 # Let the native layer do its thing. 850 _babase.do_apply_app_config() 851 852 def _update_state(self) -> None: 853 # pylint: disable=too-many-branches 854 assert _babase.in_logic_thread() 855 856 # Shutdown-complete trumps absolutely all. 857 if self._native_shutdown_complete_called: 858 if self.state is not self.State.SHUTDOWN_COMPLETE: 859 self.state = self.State.SHUTDOWN_COMPLETE 860 _babase.lifecyclelog('app state shutdown complete') 861 self._on_shutdown_complete() 862 863 # Shutdown trumps all. Though we can't start shutting down until 864 # init is completed since we need our asyncio stuff to exist for 865 # the shutdown process. 866 elif self._native_shutdown_called and self._init_completed: 867 # Entering shutdown state: 868 if self.state is not self.State.SHUTTING_DOWN: 869 self.state = self.State.SHUTTING_DOWN 870 _babase.lifecyclelog('app state shutting down') 871 self._on_shutting_down() 872 873 elif self._native_suspended: 874 # Entering suspended state: 875 if self.state is not self.State.SUSPENDED: 876 self.state = self.State.SUSPENDED 877 self._on_suspend() 878 else: 879 # Leaving suspended state: 880 if self.state is self.State.SUSPENDED: 881 self._on_unsuspend() 882 883 # Entering or returning to running state 884 if self._initial_sign_in_completed and self._meta_scan_completed: 885 if self.state != self.State.RUNNING: 886 self.state = self.State.RUNNING 887 _babase.lifecyclelog('app state running') 888 if not self._called_on_running: 889 self._called_on_running = True 890 self._on_running() 891 # Entering or returning to loading state: 892 elif self._init_completed: 893 if self.state is not self.State.LOADING: 894 self.state = self.State.LOADING 895 _babase.lifecyclelog('app state loading') 896 if not self._called_on_loading: 897 self._called_on_loading = True 898 self._on_loading() 899 900 # Entering or returning to initing state: 901 elif self._native_bootstrapping_completed: 902 if self.state is not self.State.INITING: 903 self.state = self.State.INITING 904 _babase.lifecyclelog('app state initing') 905 if not self._called_on_initing: 906 self._called_on_initing = True 907 self._on_initing() 908 909 # Entering or returning to native bootstrapping: 910 elif self._native_start_called: 911 if self.state is not self.State.NATIVE_BOOTSTRAPPING: 912 self.state = self.State.NATIVE_BOOTSTRAPPING 913 _babase.lifecyclelog('app state native bootstrapping') 914 else: 915 # Only logical possibility left is NOT_STARTED, in which 916 # case we should not be getting called. 917 logging.warning( 918 'App._update_state called while in %s state;' 919 ' should not happen.', 920 self.state.value, 921 stack_info=True, 922 ) 923 924 async def _shutdown(self) -> None: 925 import asyncio 926 927 _babase.lock_all_input() 928 try: 929 async with asyncio.TaskGroup() as task_group: 930 for task_coro in self._shutdown_tasks: 931 # Note: Mypy currently complains if we don't take 932 # this return value, but we don't actually need to. 933 # https://github.com/python/mypy/issues/15036 934 _ = task_group.create_task( 935 self._run_shutdown_task(task_coro) 936 ) 937 except* Exception: 938 logging.exception('Unexpected error(s) in shutdown.') 939 940 # Note: ideally we should run this directly here, but currently 941 # it does some legacy stuff which blocks, so running it here 942 # gives us asyncio task-took-too-long warnings. If we can 943 # convert those to nice graceful async tasks we should revert 944 # this to a direct call. 945 _babase.pushcall(_babase.complete_shutdown) 946 947 async def _run_shutdown_task( 948 self, coro: Coroutine[None, None, None] 949 ) -> None: 950 """Run a shutdown task; report errors and abort if taking too long.""" 951 import asyncio 952 953 task = asyncio.create_task(coro) 954 try: 955 await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS) 956 except Exception: 957 logging.exception('Error in shutdown task (%s).', coro) 958 959 def _on_suspend(self) -> None: 960 """Called when the app goes to a suspended state.""" 961 assert _babase.in_logic_thread() 962 963 # Suspend all app subsystems in the opposite order they were inited. 964 for subsystem in reversed(self._subsystems): 965 try: 966 subsystem.on_app_suspend() 967 except Exception: 968 logging.exception( 969 'Error in on_app_suspend for subsystem %s.', subsystem 970 ) 971 972 def _on_unsuspend(self) -> None: 973 """Called when unsuspending.""" 974 assert _babase.in_logic_thread() 975 self.fg_state += 1 976 977 # Unsuspend all app subsystems in the same order they were inited. 978 for subsystem in self._subsystems: 979 try: 980 subsystem.on_app_unsuspend() 981 except Exception: 982 logging.exception( 983 'Error in on_app_unsuspend for subsystem %s.', subsystem 984 ) 985 986 def _on_shutting_down(self) -> None: 987 """(internal)""" 988 assert _babase.in_logic_thread() 989 990 # Inform app subsystems that we're shutting down in the opposite 991 # order they were inited. 992 for subsystem in reversed(self._subsystems): 993 try: 994 subsystem.on_app_shutdown() 995 except Exception: 996 logging.exception( 997 'Error in on_app_shutdown for subsystem %s.', subsystem 998 ) 999 1000 # Now kick off any async shutdown task(s). 1001 assert self._asyncio_loop is not None 1002 self._shutdown_task = self._asyncio_loop.create_task(self._shutdown()) 1003 1004 def _on_shutdown_complete(self) -> None: 1005 """(internal)""" 1006 assert _babase.in_logic_thread() 1007 1008 # Inform app subsystems that we're done shutting down in the opposite 1009 # order they were inited. 1010 for subsystem in reversed(self._subsystems): 1011 try: 1012 subsystem.on_app_shutdown_complete() 1013 except Exception: 1014 logging.exception( 1015 'Error in on_app_shutdown_complete for subsystem %s.', 1016 subsystem, 1017 ) 1018 1019 async def _wait_for_shutdown_suppressions(self) -> None: 1020 import asyncio 1021 1022 # Spin and wait for anything blocking shutdown to complete. 1023 starttime = _babase.apptime() 1024 _babase.lifecyclelog('shutdown-suppress wait begin') 1025 while _babase.shutdown_suppress_count() > 0: 1026 await asyncio.sleep(0.001) 1027 _babase.lifecyclelog('shutdown-suppress wait end') 1028 duration = _babase.apptime() - starttime 1029 if duration > 1.0: 1030 logging.warning( 1031 'Shutdown-suppressions lasted longer than ideal ' 1032 '(%.2f seconds).', 1033 duration, 1034 ) 1035 1036 async def _fade_and_shutdown_graphics(self) -> None: 1037 import asyncio 1038 1039 # Kick off a short fade and give it time to complete. 1040 _babase.lifecyclelog('fade-and-shutdown-graphics begin') 1041 _babase.fade_screen(False, time=0.15) 1042 await asyncio.sleep(0.15) 1043 1044 # Now tell the graphics system to go down and wait until 1045 # it has done so. 1046 _babase.graphics_shutdown_begin() 1047 while not _babase.graphics_shutdown_is_complete(): 1048 await asyncio.sleep(0.01) 1049 _babase.lifecyclelog('fade-and-shutdown-graphics end') 1050 1051 async def _fade_and_shutdown_audio(self) -> None: 1052 import asyncio 1053 1054 # Tell the audio system to go down and give it a bit of 1055 # time to do so gracefully. 1056 _babase.lifecyclelog('fade-and-shutdown-audio begin') 1057 _babase.audio_shutdown_begin() 1058 await asyncio.sleep(0.15) 1059 while not _babase.audio_shutdown_is_complete(): 1060 await asyncio.sleep(0.01) 1061 _babase.lifecyclelog('fade-and-shutdown-audio end') 1062 1063 def _threadpool_no_wait_done(self, fut: Future) -> None: 1064 try: 1065 fut.result() 1066 except Exception: 1067 logging.exception( 1068 'Error in work submitted via threadpool_submit_no_wait()' 1069 ) 1070 1071 def _thread_pool_thread_init(self) -> None: 1072 # Help keep things clear in profiling tools/etc. 1073 self._pool_thread_count += 1 1074 _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.
229 def postinit(self) -> None: 230 """Called after we've been inited and assigned to babase.app. 231 232 Anything that accesses babase.app as part of its init process 233 must go here instead of __init__. 234 """ 235 236 # Hack for docs-generation: We can be imported with dummy 237 # modules instead of our actual binary ones, but we don't 238 # function. 239 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 240 return 241 242 self.lang = LanguageSubsystem() 243 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__.
245 @property 246 def active(self) -> bool: 247 """Whether the app is currently front and center. 248 249 This will be False when the app is hidden, other activities 250 are covering it, etc. (depending on the platform). 251 """ 252 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).
254 @property 255 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 256 """The logic thread's asyncio event loop. 257 258 This allow async tasks to be run in the logic thread. 259 260 Generally you should call App.create_async_task() to schedule 261 async code to run instead of using this directly. That will 262 handle retaining the task and logging errors automatically. 263 Only schedule tasks onto asyncio_loop yourself when you intend 264 to hold on to the returned task and await its results. Releasing 265 the task reference can lead to subtle bugs such as unreported 266 errors and garbage-collected tasks disappearing before their 267 work is done. 268 269 Note that, at this time, the asyncio loop is encapsulated 270 and explicitly stepped by the engine's logic thread loop and 271 thus things like asyncio.get_running_loop() will unintuitively 272 *not* return this loop from most places in the logic thread; 273 only from within a task explicitly created in this loop. 274 Hopefully this situation will be improved in the future with a 275 unified event loop. 276 """ 277 assert _babase.in_logic_thread() 278 assert self._asyncio_loop is not None 279 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.
281 def create_async_task( 282 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 283 ) -> None: 284 """Create a fully managed async task. 285 286 This will automatically retain and release a reference to the task 287 and log any exceptions that occur in it. If you need to await a task 288 or otherwise need more control, schedule a task directly using 289 App.asyncio_loop. 290 """ 291 assert _babase.in_logic_thread() 292 293 # Hold a strong reference to the task until it is done. 294 # Otherwise it is possible for it to be garbage collected and 295 # disappear midway if the caller does not hold on to the 296 # returned task, which seems like a great way to introduce 297 # hard-to-track bugs. 298 task = self.asyncio_loop.create_task(coro, name=name) 299 self._asyncio_tasks.add(task) 300 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.
315 @property 316 def config(self) -> babase.AppConfig: 317 """The babase.AppConfig instance representing the app's config state.""" 318 assert self._config is not None 319 return self._config
The babase.AppConfig instance representing the app's config state.
321 @property 322 def mode_selector(self) -> babase.AppModeSelector: 323 """Controls which app-modes are used for handling given intents. 324 325 Plugins can override this to change high level app behavior and 326 spinoff projects can change the default implementation for the 327 same effect. 328 """ 329 if self._mode_selector is None: 330 raise RuntimeError( 331 'mode_selector cannot be used until the app reaches' 332 ' the running state.' 333 ) 334 return self._mode_selector
Controls which app-modes are used for handling given intents.
Plugins can override this to change high level app behavior and spinoff projects can change the default implementation for the same effect.
390 @property 391 def classic(self) -> ClassicSubsystem | None: 392 """Our classic subsystem (if available).""" 393 return self._get_subsystem_property( 394 'classic', self._create_classic_subsystem 395 ) # type: ignore
Our classic subsystem (if available).
410 @property 411 def plus(self) -> PlusSubsystem | None: 412 """Our plus subsystem (if available).""" 413 return self._get_subsystem_property( 414 'plus', self._create_plus_subsystem 415 ) # type: ignore
Our plus subsystem (if available).
430 @property 431 def ui_v1(self) -> UIV1Subsystem: 432 """Our ui_v1 subsystem (always available).""" 433 return self._get_subsystem_property( 434 'ui_v1', self._create_ui_v1_subsystem 435 ) # type: ignore
Our ui_v1 subsystem (always available).
447 def register_subsystem(self, subsystem: AppSubsystem) -> None: 448 """Called by the AppSubsystem class. Do not use directly.""" 449 450 # We only allow registering new subsystems if we've not yet 451 # reached the 'running' state. This ensures that all subsystems 452 # receive a consistent set of callbacks starting with 453 # on_app_running(). 454 455 if self._subsystem_registration_ended: 456 raise RuntimeError( 457 'Subsystems can no longer be registered at this point.' 458 ) 459 self._subsystems.append(subsystem)
Called by the AppSubsystem class. Do not use directly.
461 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 462 """Add a task to be run on app shutdown. 463 464 Note that shutdown tasks will be canceled after 465 App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running. 466 """ 467 if ( 468 self.state is self.State.SHUTTING_DOWN 469 or self.state is self.State.SHUTDOWN_COMPLETE 470 ): 471 stname = self.state.name 472 raise RuntimeError( 473 f'Cannot add shutdown tasks with current state {stname}.' 474 ) 475 self._shutdown_tasks.append(coro)
Add a task to be run on app shutdown.
Note that shutdown tasks will be canceled after App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.
477 def run(self) -> None: 478 """Run the app to completion. 479 480 Note that this only works on builds where Ballistica manages 481 its own event loop. 482 """ 483 _babase.run_app()
Run the app to completion.
Note that this only works on builds where Ballistica manages its own event loop.
485 def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None: 486 """Submit a call to the app threadpool where result is not needed. 487 488 Normally, doing work in a thread-pool involves creating a future 489 and waiting for its result, which is an important step because it 490 propagates any Exceptions raised by the submitted work. When the 491 result in not important, however, this call can be used. The app 492 will log any exceptions that occur. 493 """ 494 fut = self.threadpool.submit(call) 495 fut.add_done_callback(self._threadpool_no_wait_done)
Submit a call to the app threadpool where result is not needed.
Normally, doing work in a thread-pool involves creating a future and waiting for its result, which is an important step because it propagates any Exceptions raised by the submitted work. When the result in not important, however, this call can be used. The app will log any exceptions that occur.
497 def set_intent(self, intent: AppIntent) -> None: 498 """Set the intent for the app. 499 500 Intent defines what the app is trying to do at a given time. 501 This call is asynchronous; the intent switch will happen in the 502 logic thread in the near future. If set_intent is called 503 repeatedly before the change takes place, the final intent to be 504 set will be used. 505 """ 506 507 # Mark this one as pending. We do this synchronously so that the 508 # last one marked actually takes effect if there is overlap 509 # (doing this in the bg thread could result in race conditions). 510 self._pending_intent = intent 511 512 # Do the actual work of calcing our app-mode/etc. in a bg thread 513 # since it may block for a moment to load modules/etc. 514 self.threadpool_submit_no_wait(partial(self._set_intent, intent))
Set the intent for the app.
Intent defines what the app is trying to do at a given time. This call is asynchronous; the intent switch will happen in the logic thread in the near future. If set_intent is called repeatedly before the change takes place, the final intent to be set will be used.
516 def push_apply_app_config(self) -> None: 517 """Internal. Use app.config.apply() to apply app config changes.""" 518 # To be safe, let's run this by itself in the event loop. 519 # This avoids potential trouble if this gets called mid-draw or 520 # something like that. 521 self._pending_apply_app_config = True 522 _babase.pushcall(self._apply_app_config, raw=True)
Internal. Use app.config.apply() to apply app config changes.
524 def on_native_start(self) -> None: 525 """Called by the native layer when the app is being started.""" 526 assert _babase.in_logic_thread() 527 assert not self._native_start_called 528 self._native_start_called = True 529 self._update_state()
Called by the native layer when the app is being started.
531 def on_native_bootstrapping_complete(self) -> None: 532 """Called by the native layer once its ready to rock.""" 533 assert _babase.in_logic_thread() 534 assert not self._native_bootstrapping_completed 535 self._native_bootstrapping_completed = True 536 self._update_state()
Called by the native layer once its ready to rock.
538 def on_native_suspend(self) -> None: 539 """Called by the native layer when the app is suspended.""" 540 assert _babase.in_logic_thread() 541 assert not self._native_suspended # Should avoid redundant calls. 542 self._native_suspended = True 543 self._update_state()
Called by the native layer when the app is suspended.
545 def on_native_unsuspend(self) -> None: 546 """Called by the native layer when the app suspension ends.""" 547 assert _babase.in_logic_thread() 548 assert self._native_suspended # Should avoid redundant calls. 549 self._native_suspended = False 550 self._update_state()
Called by the native layer when the app suspension ends.
552 def on_native_shutdown(self) -> None: 553 """Called by the native layer when the app starts shutting down.""" 554 assert _babase.in_logic_thread() 555 self._native_shutdown_called = True 556 self._update_state()
Called by the native layer when the app starts shutting down.
558 def on_native_shutdown_complete(self) -> None: 559 """Called by the native layer when the app is done shutting down.""" 560 assert _babase.in_logic_thread() 561 self._native_shutdown_complete_called = True 562 self._update_state()
Called by the native layer when the app is done shutting down.
564 def on_native_active_changed(self) -> None: 565 """Called by the native layer when the app active state changes.""" 566 assert _babase.in_logic_thread() 567 if self._mode is not None: 568 self._mode.on_app_active_changed()
Called by the native layer when the app active state changes.
576 def handle_deep_link(self, url: str) -> None: 577 """Handle a deep link URL.""" 578 from babase._language import Lstr 579 580 assert _babase.in_logic_thread() 581 582 appname = _babase.appname() 583 if url.startswith(f'{appname}://code/'): 584 code = url.replace(f'{appname}://code/', '') 585 if self.classic is not None: 586 self.classic.accounts.add_pending_promo_code(code) 587 else: 588 try: 589 _babase.screenmessage( 590 Lstr(resource='errorText'), color=(1, 0, 0) 591 ) 592 _babase.getsimplesound('error').play() 593 except ImportError: 594 pass
Handle a deep link URL.
596 def on_initial_sign_in_complete(self) -> None: 597 """Called when initial sign-in (or lack thereof) completes. 598 599 This normally gets called by the plus subsystem. The 600 initial-sign-in process may include tasks such as syncing 601 account workspaces or other data so it may take a substantial 602 amount of time. 603 """ 604 assert _babase.in_logic_thread() 605 assert not self._initial_sign_in_completed 606 607 # Tell meta it can start scanning extra stuff that just showed 608 # up (namely account workspaces). 609 self.meta.start_extra_scan() 610 611 self._initial_sign_in_completed = True 612 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.
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 bascenev1 142 import babase 143 144 for appmode in [ 145 bascenev1.SceneV1AppMode, 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__
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 bascenev1 142 import babase 143 144 for appmode in [ 145 bascenev1.SceneV1AppMode, 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.
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.
460 @override 461 def on_app_unsuspend(self) -> None: 462 assert _babase.in_logic_thread() 463 self._running = True
Called when the app exits the suspended state.
Inherited Members
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 # FIXME: 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 babase.app.active changes. 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 # FIXME: 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 babase.app.active changes. 59 60 The app-mode may want to take action such as pausing a running 61 game in such cases. 62 """
Called when babase.app.active changes.
The app-mode may want to take action such as pausing a running game in such cases.
14class AppModeSelector: 15 """Defines which AppModes to use 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('app_mode_for_intent() should be overridden.')
Defines which AppModes to use 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('app_mode_for_intent() should be overridden.')
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.
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."""
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.
547def apptime() -> babase.AppTime: 548 """Return the current app-time in seconds. 549 550 Category: **General Utility Functions** 551 552 App-time is a monotonic time value; it starts at 0.0 when the app 553 launches and will never jump by large amounts or go backwards, even if 554 the system time changes. Its progression will pause when the app is in 555 a suspended state. 556 557 Note that the AppTime returned here is simply float; it just has a 558 unique type in the type-checker's eyes to help prevent it from being 559 accidentally used with time functionality expecting other time types. 560 """ 561 import babase # pylint: disable=cyclic-import 562 563 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.
566def apptimer(time: float, call: Callable[[], Any]) -> None: 567 """Schedule a callable object to run based on app-time. 568 569 Category: **General Utility Functions** 570 571 This function creates a one-off timer which cannot be canceled or 572 modified once created. If you require the ability to do so, or need 573 a repeating timer, use the babase.AppTimer class instead. 574 575 ##### Arguments 576 ###### time (float) 577 > Length of time in seconds that the timer will wait before firing. 578 579 ###### call (Callable[[], Any]) 580 > A callable Python object. Note that the timer will retain a 581 strong reference to the callable for as long as the timer exists, so you 582 may want to look into concepts such as babase.WeakCall if that is not 583 desired. 584 585 ##### Examples 586 Print some stuff through time: 587 >>> babase.screenmessage('hello from now!') 588 >>> babase.apptimer(1.0, babase.Call(babase.screenmessage, 589 'hello from the future!')) 590 >>> babase.apptimer(2.0, babase.Call(babase.screenmessage, 591 ... 'hello from the future 2!')) 592 """ 593 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)
616def charstr(char_id: babase.SpecialChar) -> str: 617 """Get a unicode string representing a special character. 618 619 Category: **General Utility Functions** 620 621 Note that these utilize the private-use block of unicode characters 622 (U+E000-U+F8FF) and are specific to the game; exporting or rendering 623 them elsewhere will be meaningless. 624 625 See babase.SpecialChar for the list of available characters. 626 """ 627 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.
630def clipboard_get_text() -> str: 631 """Return text currently on the system clipboard. 632 633 Category: **General Utility Functions** 634 635 Ensure that babase.clipboard_has_text() returns True before calling 636 this function. 637 """ 638 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.
641def clipboard_has_text() -> bool: 642 """Return whether there is currently text on the clipboard. 643 644 Category: **General Utility Functions** 645 646 This will return False if no system clipboard is available; no need 647 to call babase.clipboard_is_supported() separately. 648 """ 649 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.
652def clipboard_is_supported() -> bool: 653 """Return whether this platform supports clipboard operations at all. 654 655 Category: **General Utility Functions** 656 657 If this returns False, UIs should not show 'copy to clipboard' 658 buttons, etc. 659 """ 660 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.
663def clipboard_set_text(value: str) -> None: 664 """Copy a string to the system clipboard. 665 666 Category: **General Utility Functions** 667 668 Ensure that babase.clipboard_is_supported() returns True before adding 669 buttons/etc. that make use of this functionality. 670 """ 671 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
18class DevConsoleTab: 19 """Defines behavior for a tab in the dev-console.""" 20 21 def refresh(self) -> None: 22 """Called when the tab should refresh itself.""" 23 24 def request_refresh(self) -> None: 25 """The tab can call this to request that it be refreshed.""" 26 _babase.dev_console_request_refresh() 27 28 def button( 29 self, 30 label: str, 31 pos: tuple[float, float], 32 size: tuple[float, float], 33 call: Callable[[], Any] | None = None, 34 h_anchor: Literal['left', 'center', 'right'] = 'center', 35 label_scale: float = 1.0, 36 corner_radius: float = 8.0, 37 style: Literal['normal', 'dark'] = 'normal', 38 ) -> None: 39 """Add a button to the tab being refreshed.""" 40 assert _babase.app.devconsole.is_refreshing 41 _babase.dev_console_add_button( 42 label, 43 pos[0], 44 pos[1], 45 size[0], 46 size[1], 47 call, 48 h_anchor, 49 label_scale, 50 corner_radius, 51 style, 52 ) 53 54 def text( 55 self, 56 text: str, 57 pos: tuple[float, float], 58 h_anchor: Literal['left', 'center', 'right'] = 'center', 59 h_align: Literal['left', 'center', 'right'] = 'center', 60 v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', 61 scale: float = 1.0, 62 ) -> None: 63 """Add a button to the tab being refreshed.""" 64 assert _babase.app.devconsole.is_refreshing 65 _babase.dev_console_add_text( 66 text, pos[0], pos[1], h_anchor, h_align, v_align, scale 67 ) 68 69 def python_terminal(self) -> None: 70 """Add a Python Terminal to the tab being refreshed.""" 71 assert _babase.app.devconsole.is_refreshing 72 _babase.dev_console_add_python_terminal() 73 74 @property 75 def width(self) -> float: 76 """Return the current tab width. Only call during refreshes.""" 77 assert _babase.app.devconsole.is_refreshing 78 return _babase.dev_console_tab_width() 79 80 @property 81 def height(self) -> float: 82 """Return the current tab height. Only call during refreshes.""" 83 assert _babase.app.devconsole.is_refreshing 84 return _babase.dev_console_tab_height() 85 86 @property 87 def base_scale(self) -> float: 88 """A scale value set depending on the app's UI scale. 89 90 Dev-console tabs can incorporate this into their UI sizes and 91 positions if they desire. This must be done manually however. 92 """ 93 assert _babase.app.devconsole.is_refreshing 94 return _babase.dev_console_base_scale()
Defines behavior for a tab in the dev-console.
24 def request_refresh(self) -> None: 25 """The tab can call this to request that it be refreshed.""" 26 _babase.dev_console_request_refresh()
The tab can call this to request that it be refreshed.
54 def text( 55 self, 56 text: str, 57 pos: tuple[float, float], 58 h_anchor: Literal['left', 'center', 'right'] = 'center', 59 h_align: Literal['left', 'center', 'right'] = 'center', 60 v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', 61 scale: float = 1.0, 62 ) -> None: 63 """Add a button to the tab being refreshed.""" 64 assert _babase.app.devconsole.is_refreshing 65 _babase.dev_console_add_text( 66 text, pos[0], pos[1], h_anchor, h_align, v_align, scale 67 )
Add a button to the tab being refreshed.
69 def python_terminal(self) -> None: 70 """Add a Python Terminal to the tab being refreshed.""" 71 assert _babase.app.devconsole.is_refreshing 72 _babase.dev_console_add_python_terminal()
Add a Python Terminal to the tab being refreshed.
74 @property 75 def width(self) -> float: 76 """Return the current tab width. Only call during refreshes.""" 77 assert _babase.app.devconsole.is_refreshing 78 return _babase.dev_console_tab_width()
Return the current tab width. Only call during refreshes.
80 @property 81 def height(self) -> float: 82 """Return the current tab height. Only call during refreshes.""" 83 assert _babase.app.devconsole.is_refreshing 84 return _babase.dev_console_tab_height()
Return the current tab height. Only call during refreshes.
86 @property 87 def base_scale(self) -> float: 88 """A scale value set depending on the app's UI scale. 89 90 Dev-console tabs can incorporate this into their UI sizes and 91 positions if they desire. This must be done manually however. 92 """ 93 assert _babase.app.devconsole.is_refreshing 94 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.
138@dataclass 139class DevConsoleTabEntry: 140 """Represents a distinct tab in the dev-console.""" 141 142 name: str 143 factory: Callable[[], DevConsoleTab]
Represents a distinct tab in the dev-console.
146class DevConsoleSubsystem: 147 """Subsystem for wrangling the dev console. 148 149 The single instance of this class can be found at 150 babase.app.devconsole. The dev-console is a simple always-available 151 UI intended for use by developers; not end users. Traditionally it 152 is available by typing a backtick (`) key on a keyboard, but now can 153 be accessed via an on-screen button (see settings/advanced to enable 154 said button). 155 """ 156 157 def __init__(self) -> None: 158 # All tabs in the dev-console. Add your own stuff here via 159 # plugins or whatnot. 160 self.tabs: list[DevConsoleTabEntry] = [ 161 DevConsoleTabEntry('Python', DevConsoleTabPython) 162 ] 163 if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1': 164 self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest)) 165 self.is_refreshing = False 166 167 def do_refresh_tab(self, tabname: str) -> None: 168 """Called by the C++ layer when a tab should be filled out.""" 169 assert _babase.in_logic_thread() 170 171 # FIXME: We currently won't handle multiple tabs with the same 172 # name. We should give a clean error or something in that case. 173 tab: DevConsoleTab | None = None 174 for tabentry in self.tabs: 175 if tabentry.name == tabname: 176 tab = tabentry.factory() 177 break 178 179 if tab is None: 180 logging.error( 181 'DevConsole got refresh request for tab' 182 " '%s' which does not exist.", 183 tabname, 184 ) 185 return 186 187 self.is_refreshing = True 188 try: 189 tab.refresh() 190 finally: 191 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).
167 def do_refresh_tab(self, tabname: str) -> None: 168 """Called by the C++ layer when a tab should be filled out.""" 169 assert _babase.in_logic_thread() 170 171 # FIXME: We currently won't handle multiple tabs with the same 172 # name. We should give a clean error or something in that case. 173 tab: DevConsoleTab | None = None 174 for tabentry in self.tabs: 175 if tabentry.name == tabname: 176 tab = tabentry.factory() 177 break 178 179 if tab is None: 180 logging.error( 181 'DevConsole got refresh request for tab' 182 " '%s' which does not exist.", 183 tabname, 184 ) 185 return 186 187 self.is_refreshing = True 188 try: 189 tab.refresh() 190 finally: 191 self.is_refreshing = False
Called by the C++ layer when a tab should be filled out.
756def displaytime() -> babase.DisplayTime: 757 """Return the current display-time in seconds. 758 759 Category: **General Utility Functions** 760 761 Display-time is a time value intended to be used for animation and other 762 visual purposes. It will generally increment by a consistent amount each 763 frame. It will pass at an overall similar rate to AppTime, but trades 764 accuracy for smoothness. 765 766 Note that the value returned here is simply a float; it just has a 767 unique type in the type-checker's eyes to help prevent it from being 768 accidentally used with time functionality expecting other time types. 769 """ 770 import babase # pylint: disable=cyclic-import 771 772 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.
775def displaytimer(time: float, call: Callable[[], Any]) -> None: 776 """Schedule a callable object to run based on display-time. 777 778 Category: **General Utility Functions** 779 780 This function creates a one-off timer which cannot be canceled or 781 modified once created. If you require the ability to do so, or need 782 a repeating timer, use the babase.DisplayTimer class instead. 783 784 Display-time is a time value intended to be used for animation and other 785 visual purposes. It will generally increment by a consistent amount each 786 frame. It will pass at an overall similar rate to AppTime, but trades 787 accuracy for smoothness. 788 789 ##### Arguments 790 ###### time (float) 791 > Length of time in seconds that the timer will wait before firing. 792 793 ###### call (Callable[[], Any]) 794 > A callable Python object. Note that the timer will retain a 795 strong reference to the callable for as long as the timer exists, so you 796 may want to look into concepts such as babase.WeakCall if that is not 797 desired. 798 799 ##### Examples 800 Print some stuff through time: 801 >>> babase.screenmessage('hello from now!') 802 >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage, 803 ... 'hello from the future!')) 804 >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage, 805 ... 'hello from the future 2!')) 806 """ 807 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)
815def do_once() -> bool: 816 """Return whether this is the first time running a line of code. 817 818 Category: **General Utility Functions** 819 820 This is used by 'print_once()' type calls to keep from overflowing 821 logs. The call functions by registering the filename and line where 822 The call is made from. Returns True if this location has not been 823 registered already, and False if it has. 824 825 ##### Example 826 This print will only fire for the first loop iteration: 827 >>> for i in range(10): 828 ... if babase.do_once(): 829 ... print('HelloWorld once from loop!') 830 """ 831 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 empty app mode that can be used as a fallback/etc.""" 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_intent_exec(intent.code) 37 return 38 assert isinstance(intent, AppIntentDefault) 39 _babase.empty_app_mode_handle_intent_default() 40 41 @override 42 def on_activate(self) -> None: 43 # Let the native layer do its thing. 44 _babase.on_empty_app_mode_activate() 45 46 @override 47 def on_deactivate(self) -> None: 48 # Let the native layer do its thing. 49 _babase.on_empty_app_mode_deactivate()
An empty app mode that can be used as a fallback/etc.
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_intent_exec(intent.code) 37 return 38 assert isinstance(intent, AppIntentDefault) 39 _babase.empty_app_mode_handle_intent_default()
Handle an intent.
41 @override 42 def on_activate(self) -> None: 43 # Let the native layer do its thing. 44 _babase.on_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.on_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: str | None 349 """Path where the app expects its user scripts (mods) to live. 350 351 Be aware that this value may be None if Ballistica is running in 352 a non-standard environment, and that python-path modifications may 353 cause modules to be loaded from other locations.""" 354 355 supports_soft_quit: bool 356 """Whether the running app supports 'soft' quit options. 357 358 This generally applies to mobile derived OSs, where an act of 359 'quitting' may leave the app running in the background waiting 360 in case it is used again.""" 361 362 test: bool 363 """Whether the app is running in test mode. 364 365 Test mode enables extra checks and features that are useful for 366 release testing but which do not slow the game down significantly.""" 367 368 tv: bool 369 """Whether the app is targeting a TV-centric experience.""" 370 371 vr: bool 372 """Whether the app is currently running in VR.""" 373 374 pass
Unchanging values for the current running app instance.
Access the single shared instance of this class at babase.app.env
.
The app's api version.
Only Python modules and packages associated with the current API version number will be detected by the game (see the ba_meta tag). This value will change whenever substantial backward-incompatible changes are introduced to Ballistica APIs. When that happens, modules/packages should be updated accordingly and set to target the newer API version number.
Whether the app is running in debug mode.
Debug builds generally run substantially slower than non-debug builds due to compiler optimizations being disabled and extra checks being run.
Integer build number for the engine.
This value increases by at least 1 with each release of the engine.
It is independent of the human readable version
string.
Human-readable version string for the engine; something like '1.3.24'.
This should not be interpreted as a number; it may contain
string elements such as 'alpha', 'beta', 'test', etc.
If a numeric version is needed, use build_number
.
Path where the app expects its bundled modules to live.
Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.
Path where the app expects its bundled pip modules to live.
Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.
Path where the app expects its user scripts (mods) to live.
Be aware that this value may be None if Ballistica is running in a non-standard environment, and that python-path modifications may cause modules to be loaded from other locations.
Whether the running app supports 'soft' quit options.
This generally applies to mobile derived OSs, where an act of 'quitting' may leave the app running in the background waiting in case it is used again.
37class Existable(Protocol): 38 """A Protocol for objects supporting an exists() method. 39 40 Category: **Protocols** 41 """ 42 43 def exists(self) -> bool: 44 """Whether this object exists."""
A Protocol for objects supporting an exists() method.
Category: Protocols
1758def _no_init_or_replace_init(self, *args, **kwargs): 1759 cls = type(self) 1760 1761 if cls._is_protocol: 1762 raise TypeError('Protocols cannot be instantiated') 1763 1764 # Already using a custom `__init__`. No need to calculate correct 1765 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1766 if cls.__init__ is not _no_init_or_replace_init: 1767 return 1768 1769 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1770 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1771 # searches for a proper new `__init__` in the MRO. The new `__init__` 1772 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1773 # instantiation of the protocol subclass will thus use the new 1774 # `__init__` and no longer call `_no_init_or_replace_init`. 1775 for base in cls.__mro__: 1776 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1777 if init is not _no_init_or_replace_init: 1778 cls.__init__ = init 1779 break 1780 else: 1781 # should not happen 1782 cls.__init__ = object.__init__ 1783 1784 cls.__init__(self, *args, **kwargs)
51def existing(obj: ExistableT | None) -> ExistableT | None: 52 """Convert invalid references to None for any babase.Existable object. 53 54 Category: **Gameplay Functions** 55 56 To best support type checking, it is important that invalid references 57 not be passed around and instead get converted to values of None. 58 That way the type checker can properly flag attempts to pass possibly-dead 59 objects (FooType | None) into functions expecting only live ones 60 (FooType), etc. This call can be used on any 'existable' object 61 (one with an exists() method) and will convert it to a None value 62 if it does not exist. 63 64 For more info, see notes on 'existables' here: 65 https://ballistica.net/wiki/Coding-Style-Guide 66 """ 67 assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}' 68 return obj if obj is not None and obj.exists() else None
Convert invalid references to None for any babase.Existable object.
Category: Gameplay Functions
To best support type checking, it is important that invalid references not be passed around and instead get converted to values of None. That way the type checker can properly flag attempts to pass possibly-dead objects (FooType | None) into functions expecting only live ones (FooType), etc. This call can be used on any 'existable' object (one with an exists() method) and will convert it to a None value if it does not exist.
For more info, see notes on 'existables' here: https://ballistica.net/wiki/Coding-Style-Guide
893def fatal_error(message: str) -> None: 894 """Trigger a fatal error. Use this in situations where it is not possible 895 for the engine to continue on in a useful way. This can sometimes 896 help provide more clear information at the exact source of a problem 897 as compared to raising an Exception. In the vast majority of cases, 898 however, Exceptions should be preferred. 899 """ 900 return None
Trigger a fatal error. Use this in situations where it is not possible for the engine to continue on in a useful way. This can sometimes help provide more clear information at the exact source of a problem as compared to raising an Exception. In the vast majority of cases, however, Exceptions should be preferred.
214def garbage_collect() -> None: 215 """Run an explicit pass of garbage collection. 216 217 category: General Utility Functions 218 219 May also print warnings/etc. if collection takes too long or if 220 uncollectible objects are found (so use this instead of simply 221 gc.collect(). 222 """ 223 gc.collect()
Run an explicit pass of garbage collection.
category: General Utility Functions
May also print warnings/etc. if collection takes too long or if uncollectible objects are found (so use this instead of simply gc.collect().
980def get_input_idle_time() -> float: 981 """Return seconds since any local input occurred (touch, keypress, etc.).""" 982 return float()
Return seconds since any local input occurred (touch, keypress, etc.).
56def get_ip_address_type(addr: str) -> socket.AddressFamily: 57 """Return socket.AF_INET6 or socket.AF_INET4 for the provided address.""" 58 import socket 59 60 socket_type = None 61 62 # First try it as an ipv4 address. 63 try: 64 socket.inet_pton(socket.AF_INET, addr) 65 socket_type = socket.AF_INET 66 except OSError: 67 pass 68 69 # Hmm apparently not ipv4; try ipv6. 70 if socket_type is None: 71 try: 72 socket.inet_pton(socket.AF_INET6, addr) 73 socket_type = socket.AF_INET6 74 except OSError: 75 pass 76 if socket_type is None: 77 raise ValueError(f'addr seems to be neither v4 or v6: {addr}') 78 return socket_type
Return socket.AF_INET6 or socket.AF_INET4 for the provided address.
112def get_type_name(cls: type) -> str: 113 """Return a full type name including module for a class.""" 114 return f'{cls.__module__}.{cls.__name__}'
Return a full type name including module for a class.
71def getclass( 72 name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False 73) -> type[T]: 74 """Given a full class name such as foo.bar.MyClass, return the class. 75 76 Category: **General Utility Functions** 77 78 The class will be checked to make sure it is a subclass of the provided 79 'subclassof' class, and a TypeError will be raised if not. 80 """ 81 import importlib 82 83 splits = name.split('.') 84 modulename = '.'.join(splits[:-1]) 85 classname = splits[-1] 86 if modulename in sys.stdlib_module_names and check_sdlib_modulename_clash: 87 raise Exception(f'{modulename} is an inbuilt module.') 88 module = importlib.import_module(modulename) 89 cls: type = getattr(module, classname) 90 91 if not issubclass(cls, subclassof): 92 raise TypeError(f'{name} is not a subclass of {subclassof}.') 93 return cls
Given a full class name such as foo.bar.MyClass, return the class.
Category: General Utility Functions
The class will be checked to make sure it is a subclass of the provided 'subclassof' class, and a TypeError will be raised if not.
152def handle_leftover_v1_cloud_log_file() -> None: 153 """Handle an un-uploaded v1-cloud-log from a previous run.""" 154 155 # Only applies with classic present. 156 if _babase.app.classic is None: 157 return 158 try: 159 import json 160 161 if os.path.exists(_babase.get_v1_cloud_log_file_path()): 162 with open( 163 _babase.get_v1_cloud_log_file_path(), encoding='utf-8' 164 ) as infile: 165 info = json.loads(infile.read()) 166 infile.close() 167 do_send = should_submit_debug_info() 168 if do_send: 169 170 def response(data: Any) -> None: 171 # Non-None response means we were successful; 172 # lets kill it. 173 if data is not None: 174 try: 175 os.remove(_babase.get_v1_cloud_log_file_path()) 176 except FileNotFoundError: 177 # Saw this in the wild. The file just existed 178 # a moment ago but I suppose something could have 179 # killed it since. ¯\_(ツ)_/¯ 180 pass 181 182 _babase.app.classic.master_server_v1_post( 183 'bsLog', info, response 184 ) 185 else: 186 # If they don't want logs uploaded just kill it. 187 os.remove(_babase.get_v1_cloud_log_file_path()) 188 except Exception: 189 from babase import _error 190 191 _error.print_exception('Error handling leftover log file.')
Handle an un-uploaded v1-cloud-log from a previous run.
103class InputDeviceNotFoundError(NotFoundError): 104 """Exception raised when an expected input-device does not exist. 105 106 Category: **Exception Classes** 107 """
Exception raised when an expected input-device does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
8class InputType(Enum): 9 """Types of input a controller can send to the game. 10 11 Category: Enums 12 13 """ 14 15 UP_DOWN = 2 16 LEFT_RIGHT = 3 17 JUMP_PRESS = 4 18 JUMP_RELEASE = 5 19 PUNCH_PRESS = 6 20 PUNCH_RELEASE = 7 21 BOMB_PRESS = 8 22 BOMB_RELEASE = 9 23 PICK_UP_PRESS = 10 24 PICK_UP_RELEASE = 11 25 RUN = 12 26 FLY_PRESS = 13 27 FLY_RELEASE = 14 28 START_PRESS = 15 29 START_RELEASE = 16 30 HOLD_POSITION_PRESS = 17 31 HOLD_POSITION_RELEASE = 18 32 LEFT_PRESS = 19 33 LEFT_RELEASE = 20 34 RIGHT_PRESS = 21 35 RIGHT_RELEASE = 22 36 UP_PRESS = 23 37 UP_RELEASE = 24 38 DOWN_PRESS = 25 39 DOWN_RELEASE = 26
Types of input a controller can send to the game.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
27def is_browser_likely_available() -> bool: 28 """Return whether a browser likely exists on the current device. 29 30 category: General Utility Functions 31 32 If this returns False you may want to avoid calling babase.show_url() 33 with any lengthy addresses. (ba.show_url() will display an address 34 as a string in a window if unable to bring up a browser, but that 35 is only useful for simple URLs.) 36 """ 37 app = _babase.app 38 39 if app.classic is None: 40 logging.warning( 41 'is_browser_likely_available() needs to be updated' 42 ' to work without classic.' 43 ) 44 return True 45 46 platform = app.classic.platform 47 hastouchscreen = _babase.hastouchscreen() 48 49 # If we're on a vr device or an android device with no touchscreen, 50 # assume no browser. 51 # FIXME: Might not be the case anymore; should make this definable 52 # at the platform level. 53 if app.env.vr or (platform == 'android' and not hastouchscreen): 54 return False 55 56 # Anywhere else assume we've got one. 57 return True
Return whether a browser likely exists on the current device.
category: General Utility Functions
If this returns False you may want to avoid calling babase.show_url() with any lengthy addresses. (ba.show_url() will display an address as a string in a window if unable to bring up a browser, but that is only useful for simple URLs.)
38def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool: 39 """Return whether a given point is within a given box. 40 41 category: General Utility Functions 42 43 For use with standard def boxes (position|rotate|scale). 44 """ 45 return ( 46 (abs(pnt[0] - box[0]) <= box[6] * 0.5) 47 and (abs(pnt[1] - box[1]) <= box[7] * 0.5) 48 and (abs(pnt[2] - box[2]) <= box[8] * 0.5) 49 )
Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
21class LanguageSubsystem(AppSubsystem): 22 """Language functionality for the app. 23 24 Category: **App Classes** 25 26 Access the single instance of this class at 'babase.app.lang'. 27 """ 28 29 def __init__(self) -> None: 30 super().__init__() 31 self.default_language: str = self._get_default_language() 32 33 self._language: str | None = None 34 self._language_target: AttrDict | None = None 35 self._language_merged: AttrDict | None = None 36 37 @property 38 def locale(self) -> str: 39 """Raw country/language code detected by the game (such as 'en_US'). 40 41 Generally for language-specific code you should look at 42 babase.App.language, which is the language the game is using 43 (which may differ from locale if the user sets a language, etc.) 44 """ 45 env = _babase.env() 46 assert isinstance(env['locale'], str) 47 return env['locale'] 48 49 @property 50 def language(self) -> str: 51 """The current active language for the app. 52 53 This can be selected explicitly by the user or may be set 54 automatically based on locale or other factors. 55 """ 56 if self._language is None: 57 raise RuntimeError('App language is not yet set.') 58 return self._language 59 60 @property 61 def available_languages(self) -> list[str]: 62 """A list of all available languages. 63 64 Note that languages that may be present in game assets but which 65 are not displayable on the running version of the game are not 66 included here. 67 """ 68 langs = set() 69 try: 70 names = os.listdir( 71 os.path.join( 72 _babase.app.env.data_directory, 73 'ba_data', 74 'data', 75 'languages', 76 ) 77 ) 78 names = [n.replace('.json', '').capitalize() for n in names] 79 80 # FIXME: our simple capitalization fails on multi-word names; 81 # should handle this in a better way... 82 for i, name in enumerate(names): 83 if name == 'Chinesetraditional': 84 names[i] = 'ChineseTraditional' 85 except Exception: 86 from babase import _error 87 88 _error.print_exception() 89 names = [] 90 for name in names: 91 if self._can_display_language(name): 92 langs.add(name) 93 return sorted( 94 name for name in names if self._can_display_language(name) 95 ) 96 97 def setlanguage( 98 self, 99 language: str | None, 100 print_change: bool = True, 101 store_to_config: bool = True, 102 ) -> None: 103 """Set the active app language. 104 105 Pass None to use OS default language. 106 """ 107 # pylint: disable=too-many-locals 108 # pylint: disable=too-many-statements 109 # pylint: disable=too-many-branches 110 assert _babase.in_logic_thread() 111 cfg = _babase.app.config 112 cur_language = cfg.get('Lang', None) 113 114 # Store this in the config if its changing. 115 if language != cur_language and store_to_config: 116 if language is None: 117 if 'Lang' in cfg: 118 del cfg['Lang'] # Clear it out for default. 119 else: 120 cfg['Lang'] = language 121 cfg.commit() 122 switched = True 123 else: 124 switched = False 125 126 with open( 127 os.path.join( 128 _babase.app.env.data_directory, 129 'ba_data', 130 'data', 131 'languages', 132 'english.json', 133 ), 134 encoding='utf-8', 135 ) as infile: 136 lenglishvalues = json.loads(infile.read()) 137 138 # None implies default. 139 if language is None: 140 language = self.default_language 141 try: 142 if language == 'English': 143 lmodvalues = None 144 else: 145 lmodfile = os.path.join( 146 _babase.app.env.data_directory, 147 'ba_data', 148 'data', 149 'languages', 150 language.lower() + '.json', 151 ) 152 with open(lmodfile, encoding='utf-8') as infile: 153 lmodvalues = json.loads(infile.read()) 154 except Exception: 155 logging.exception("Error importing language '%s'.", language) 156 _babase.screenmessage( 157 f"Error setting language to '{language}'; see log for details.", 158 color=(1, 0, 0), 159 ) 160 switched = False 161 lmodvalues = None 162 163 self._language = language 164 165 # Create an attrdict of *just* our target language. 166 self._language_target = AttrDict() 167 langtarget = self._language_target 168 assert langtarget is not None 169 _add_to_attr_dict( 170 langtarget, lmodvalues if lmodvalues is not None else lenglishvalues 171 ) 172 173 # Create an attrdict of our target language overlaid on our base 174 # (english). 175 languages = [lenglishvalues] 176 if lmodvalues is not None: 177 languages.append(lmodvalues) 178 lfull = AttrDict() 179 for lmod in languages: 180 _add_to_attr_dict(lfull, lmod) 181 self._language_merged = lfull 182 183 # Pass some keys/values in for low level code to use; start with 184 # everything in their 'internal' section. 185 internal_vals = [ 186 v for v in list(lfull['internal'].items()) if isinstance(v[1], str) 187 ] 188 189 # Cherry-pick various other values to include. 190 # (should probably get rid of the 'internal' section 191 # and do everything this way) 192 for value in [ 193 'replayNameDefaultText', 194 'replayWriteErrorText', 195 'replayVersionErrorText', 196 'replayReadErrorText', 197 ]: 198 internal_vals.append((value, lfull[value])) 199 internal_vals.append( 200 ('axisText', lfull['configGamepadWindow']['axisText']) 201 ) 202 internal_vals.append(('buttonText', lfull['buttonText'])) 203 lmerged = self._language_merged 204 assert lmerged is not None 205 random_names = [ 206 n.strip() for n in lmerged['randomPlayerNamesText'].split(',') 207 ] 208 random_names = [n for n in random_names if n != ''] 209 _babase.set_internal_language_keys(internal_vals, random_names) 210 if switched and print_change: 211 _babase.screenmessage( 212 Lstr( 213 resource='languageSetText', 214 subs=[ 215 ('${LANGUAGE}', Lstr(translate=('languages', language))) 216 ], 217 ), 218 color=(0, 1, 0), 219 ) 220 221 @override 222 def do_apply_app_config(self) -> None: 223 assert _babase.in_logic_thread() 224 assert isinstance(_babase.app.config, dict) 225 lang = _babase.app.config.get('Lang', self.default_language) 226 if lang != self._language: 227 self.setlanguage(lang, print_change=False, store_to_config=False) 228 229 def get_resource( 230 self, 231 resource: str, 232 fallback_resource: str | None = None, 233 fallback_value: Any = None, 234 ) -> Any: 235 """Return a translation resource by name. 236 237 DEPRECATED; use babase.Lstr functionality for these purposes. 238 """ 239 try: 240 # If we have no language set, try and set it to english. 241 # Also make a fuss because we should try to avoid this. 242 if self._language_merged is None: 243 try: 244 if _babase.do_once(): 245 logging.warning( 246 'get_resource() called before language' 247 ' set; falling back to english.' 248 ) 249 self.setlanguage( 250 'English', print_change=False, store_to_config=False 251 ) 252 except Exception: 253 logging.exception( 254 'Error setting fallback english language.' 255 ) 256 raise 257 258 # If they provided a fallback_resource value, try the 259 # target-language-only dict first and then fall back to 260 # trying the fallback_resource value in the merged dict. 261 if fallback_resource is not None: 262 try: 263 values = self._language_target 264 splits = resource.split('.') 265 dicts = splits[:-1] 266 key = splits[-1] 267 for dct in dicts: 268 assert values is not None 269 values = values[dct] 270 assert values is not None 271 val = values[key] 272 return val 273 except Exception: 274 # FIXME: Shouldn't we try the fallback resource in 275 # the merged dict AFTER we try the main resource in 276 # the merged dict? 277 try: 278 values = self._language_merged 279 splits = fallback_resource.split('.') 280 dicts = splits[:-1] 281 key = splits[-1] 282 for dct in dicts: 283 assert values is not None 284 values = values[dct] 285 assert values is not None 286 val = values[key] 287 return val 288 289 except Exception: 290 # If we got nothing for fallback_resource, 291 # default to the normal code which checks or 292 # primary value in the merge dict; there's a 293 # chance we can get an english value for it 294 # (which we weren't looking for the first time 295 # through). 296 pass 297 298 values = self._language_merged 299 splits = resource.split('.') 300 dicts = splits[:-1] 301 key = splits[-1] 302 for dct in dicts: 303 assert values is not None 304 values = values[dct] 305 assert values is not None 306 val = values[key] 307 return val 308 309 except Exception: 310 # Ok, looks like we couldn't find our main or fallback 311 # resource anywhere. Now if we've been given a fallback 312 # value, return it; otherwise fail. 313 from babase import _error 314 315 if fallback_value is not None: 316 return fallback_value 317 raise _error.NotFoundError( 318 f"Resource not found: '{resource}'" 319 ) from None 320 321 def translate( 322 self, 323 category: str, 324 strval: str, 325 raise_exceptions: bool = False, 326 print_errors: bool = False, 327 ) -> str: 328 """Translate a value (or return the value if no translation available) 329 330 DEPRECATED; use babase.Lstr functionality for these purposes. 331 """ 332 try: 333 translated = self.get_resource('translations')[category][strval] 334 except Exception as exc: 335 if raise_exceptions: 336 raise 337 if print_errors: 338 print( 339 ( 340 'Translate error: category=\'' 341 + category 342 + '\' name=\'' 343 + strval 344 + '\' exc=' 345 + str(exc) 346 + '' 347 ) 348 ) 349 translated = None 350 translated_out: str 351 if translated is None: 352 translated_out = strval 353 else: 354 translated_out = translated 355 assert isinstance(translated_out, str) 356 return translated_out 357 358 def is_custom_unicode_char(self, char: str) -> bool: 359 """Return whether a char is in the custom unicode range we use.""" 360 assert isinstance(char, str) 361 if len(char) != 1: 362 raise ValueError('Invalid Input; must be length 1') 363 return 0xE000 <= ord(char) <= 0xF8FF 364 365 def _can_display_language(self, language: str) -> bool: 366 """Tell whether we can display a particular language. 367 368 On some platforms we don't have unicode rendering yet which 369 limits the languages we can draw. 370 """ 371 372 # We don't yet support full unicode display on windows or linux :-(. 373 if ( 374 language 375 in { 376 'Chinese', 377 'ChineseTraditional', 378 'Persian', 379 'Korean', 380 'Arabic', 381 'Hindi', 382 'Vietnamese', 383 'Thai', 384 'Tamil', 385 } 386 and not _babase.can_display_full_unicode() 387 ): 388 return False 389 return True 390 391 def _get_default_language(self) -> str: 392 languages = { 393 'ar': 'Arabic', 394 'be': 'Belarussian', 395 'zh': 'Chinese', 396 'hr': 'Croatian', 397 'cs': 'Czech', 398 'da': 'Danish', 399 'nl': 'Dutch', 400 'eo': 'Esperanto', 401 'fil': 'Filipino', 402 'fr': 'French', 403 'de': 'German', 404 'el': 'Greek', 405 'hi': 'Hindi', 406 'hu': 'Hungarian', 407 'id': 'Indonesian', 408 'it': 'Italian', 409 'ko': 'Korean', 410 'ms': 'Malay', 411 'fa': 'Persian', 412 'pl': 'Polish', 413 'pt': 'Portuguese', 414 'ro': 'Romanian', 415 'ru': 'Russian', 416 'sr': 'Serbian', 417 'es': 'Spanish', 418 'sk': 'Slovak', 419 'sv': 'Swedish', 420 'ta': 'Tamil', 421 'th': 'Thai', 422 'tr': 'Turkish', 423 'uk': 'Ukrainian', 424 'vec': 'Venetian', 425 'vi': 'Vietnamese', 426 } 427 428 # Special case for Chinese: map specific variations to 429 # traditional. (otherwise will map to 'Chinese' which is 430 # simplified) 431 if self.locale in ('zh_HANT', 'zh_TW'): 432 language = 'ChineseTraditional' 433 else: 434 language = languages.get(self.locale[:2], 'English') 435 if not self._can_display_language(language): 436 language = 'English' 437 return language
Language functionality for the app.
Category: App Classes
Access the single instance of this class at 'babase.app.lang'.
37 @property 38 def locale(self) -> str: 39 """Raw country/language code detected by the game (such as 'en_US'). 40 41 Generally for language-specific code you should look at 42 babase.App.language, which is the language the game is using 43 (which may differ from locale if the user sets a language, etc.) 44 """ 45 env = _babase.env() 46 assert isinstance(env['locale'], str) 47 return env['locale']
Raw country/language code detected by the game (such as 'en_US').
Generally for language-specific code you should look at babase.App.language, which is the language the game is using (which may differ from locale if the user sets a language, etc.)
49 @property 50 def language(self) -> str: 51 """The current active language for the app. 52 53 This can be selected explicitly by the user or may be set 54 automatically based on locale or other factors. 55 """ 56 if self._language is None: 57 raise RuntimeError('App language is not yet set.') 58 return self._language
The current active language for the app.
This can be selected explicitly by the user or may be set automatically based on locale or other factors.
60 @property 61 def available_languages(self) -> list[str]: 62 """A list of all available languages. 63 64 Note that languages that may be present in game assets but which 65 are not displayable on the running version of the game are not 66 included here. 67 """ 68 langs = set() 69 try: 70 names = os.listdir( 71 os.path.join( 72 _babase.app.env.data_directory, 73 'ba_data', 74 'data', 75 'languages', 76 ) 77 ) 78 names = [n.replace('.json', '').capitalize() for n in names] 79 80 # FIXME: our simple capitalization fails on multi-word names; 81 # should handle this in a better way... 82 for i, name in enumerate(names): 83 if name == 'Chinesetraditional': 84 names[i] = 'ChineseTraditional' 85 except Exception: 86 from babase import _error 87 88 _error.print_exception() 89 names = [] 90 for name in names: 91 if self._can_display_language(name): 92 langs.add(name) 93 return sorted( 94 name for name in names if self._can_display_language(name) 95 )
A list of all available languages.
Note that languages that may be present in game assets but which are not displayable on the running version of the game are not included here.
97 def setlanguage( 98 self, 99 language: str | None, 100 print_change: bool = True, 101 store_to_config: bool = True, 102 ) -> None: 103 """Set the active app language. 104 105 Pass None to use OS default language. 106 """ 107 # pylint: disable=too-many-locals 108 # pylint: disable=too-many-statements 109 # pylint: disable=too-many-branches 110 assert _babase.in_logic_thread() 111 cfg = _babase.app.config 112 cur_language = cfg.get('Lang', None) 113 114 # Store this in the config if its changing. 115 if language != cur_language and store_to_config: 116 if language is None: 117 if 'Lang' in cfg: 118 del cfg['Lang'] # Clear it out for default. 119 else: 120 cfg['Lang'] = language 121 cfg.commit() 122 switched = True 123 else: 124 switched = False 125 126 with open( 127 os.path.join( 128 _babase.app.env.data_directory, 129 'ba_data', 130 'data', 131 'languages', 132 'english.json', 133 ), 134 encoding='utf-8', 135 ) as infile: 136 lenglishvalues = json.loads(infile.read()) 137 138 # None implies default. 139 if language is None: 140 language = self.default_language 141 try: 142 if language == 'English': 143 lmodvalues = None 144 else: 145 lmodfile = os.path.join( 146 _babase.app.env.data_directory, 147 'ba_data', 148 'data', 149 'languages', 150 language.lower() + '.json', 151 ) 152 with open(lmodfile, encoding='utf-8') as infile: 153 lmodvalues = json.loads(infile.read()) 154 except Exception: 155 logging.exception("Error importing language '%s'.", language) 156 _babase.screenmessage( 157 f"Error setting language to '{language}'; see log for details.", 158 color=(1, 0, 0), 159 ) 160 switched = False 161 lmodvalues = None 162 163 self._language = language 164 165 # Create an attrdict of *just* our target language. 166 self._language_target = AttrDict() 167 langtarget = self._language_target 168 assert langtarget is not None 169 _add_to_attr_dict( 170 langtarget, lmodvalues if lmodvalues is not None else lenglishvalues 171 ) 172 173 # Create an attrdict of our target language overlaid on our base 174 # (english). 175 languages = [lenglishvalues] 176 if lmodvalues is not None: 177 languages.append(lmodvalues) 178 lfull = AttrDict() 179 for lmod in languages: 180 _add_to_attr_dict(lfull, lmod) 181 self._language_merged = lfull 182 183 # Pass some keys/values in for low level code to use; start with 184 # everything in their 'internal' section. 185 internal_vals = [ 186 v for v in list(lfull['internal'].items()) if isinstance(v[1], str) 187 ] 188 189 # Cherry-pick various other values to include. 190 # (should probably get rid of the 'internal' section 191 # and do everything this way) 192 for value in [ 193 'replayNameDefaultText', 194 'replayWriteErrorText', 195 'replayVersionErrorText', 196 'replayReadErrorText', 197 ]: 198 internal_vals.append((value, lfull[value])) 199 internal_vals.append( 200 ('axisText', lfull['configGamepadWindow']['axisText']) 201 ) 202 internal_vals.append(('buttonText', lfull['buttonText'])) 203 lmerged = self._language_merged 204 assert lmerged is not None 205 random_names = [ 206 n.strip() for n in lmerged['randomPlayerNamesText'].split(',') 207 ] 208 random_names = [n for n in random_names if n != ''] 209 _babase.set_internal_language_keys(internal_vals, random_names) 210 if switched and print_change: 211 _babase.screenmessage( 212 Lstr( 213 resource='languageSetText', 214 subs=[ 215 ('${LANGUAGE}', Lstr(translate=('languages', language))) 216 ], 217 ), 218 color=(0, 1, 0), 219 )
Set the active app language.
Pass None to use OS default language.
221 @override 222 def do_apply_app_config(self) -> None: 223 assert _babase.in_logic_thread() 224 assert isinstance(_babase.app.config, dict) 225 lang = _babase.app.config.get('Lang', self.default_language) 226 if lang != self._language: 227 self.setlanguage(lang, print_change=False, store_to_config=False)
Called when the app config should be applied.
229 def get_resource( 230 self, 231 resource: str, 232 fallback_resource: str | None = None, 233 fallback_value: Any = None, 234 ) -> Any: 235 """Return a translation resource by name. 236 237 DEPRECATED; use babase.Lstr functionality for these purposes. 238 """ 239 try: 240 # If we have no language set, try and set it to english. 241 # Also make a fuss because we should try to avoid this. 242 if self._language_merged is None: 243 try: 244 if _babase.do_once(): 245 logging.warning( 246 'get_resource() called before language' 247 ' set; falling back to english.' 248 ) 249 self.setlanguage( 250 'English', print_change=False, store_to_config=False 251 ) 252 except Exception: 253 logging.exception( 254 'Error setting fallback english language.' 255 ) 256 raise 257 258 # If they provided a fallback_resource value, try the 259 # target-language-only dict first and then fall back to 260 # trying the fallback_resource value in the merged dict. 261 if fallback_resource is not None: 262 try: 263 values = self._language_target 264 splits = resource.split('.') 265 dicts = splits[:-1] 266 key = splits[-1] 267 for dct in dicts: 268 assert values is not None 269 values = values[dct] 270 assert values is not None 271 val = values[key] 272 return val 273 except Exception: 274 # FIXME: Shouldn't we try the fallback resource in 275 # the merged dict AFTER we try the main resource in 276 # the merged dict? 277 try: 278 values = self._language_merged 279 splits = fallback_resource.split('.') 280 dicts = splits[:-1] 281 key = splits[-1] 282 for dct in dicts: 283 assert values is not None 284 values = values[dct] 285 assert values is not None 286 val = values[key] 287 return val 288 289 except Exception: 290 # If we got nothing for fallback_resource, 291 # default to the normal code which checks or 292 # primary value in the merge dict; there's a 293 # chance we can get an english value for it 294 # (which we weren't looking for the first time 295 # through). 296 pass 297 298 values = self._language_merged 299 splits = resource.split('.') 300 dicts = splits[:-1] 301 key = splits[-1] 302 for dct in dicts: 303 assert values is not None 304 values = values[dct] 305 assert values is not None 306 val = values[key] 307 return val 308 309 except Exception: 310 # Ok, looks like we couldn't find our main or fallback 311 # resource anywhere. Now if we've been given a fallback 312 # value, return it; otherwise fail. 313 from babase import _error 314 315 if fallback_value is not None: 316 return fallback_value 317 raise _error.NotFoundError( 318 f"Resource not found: '{resource}'" 319 ) from None
Return a translation resource by name.
DEPRECATED; use babase.Lstr functionality for these purposes.
321 def translate( 322 self, 323 category: str, 324 strval: str, 325 raise_exceptions: bool = False, 326 print_errors: bool = False, 327 ) -> str: 328 """Translate a value (or return the value if no translation available) 329 330 DEPRECATED; use babase.Lstr functionality for these purposes. 331 """ 332 try: 333 translated = self.get_resource('translations')[category][strval] 334 except Exception as exc: 335 if raise_exceptions: 336 raise 337 if print_errors: 338 print( 339 ( 340 'Translate error: category=\'' 341 + category 342 + '\' name=\'' 343 + strval 344 + '\' exc=' 345 + str(exc) 346 + '' 347 ) 348 ) 349 translated = None 350 translated_out: str 351 if translated is None: 352 translated_out = strval 353 else: 354 translated_out = translated 355 assert isinstance(translated_out, str) 356 return translated_out
Translate a value (or return the value if no translation available)
DEPRECATED; use babase.Lstr functionality for these purposes.
358 def is_custom_unicode_char(self, char: str) -> bool: 359 """Return whether a char is in the custom unicode range we use.""" 360 assert isinstance(char, str) 361 if len(char) != 1: 362 raise ValueError('Invalid Input; must be length 1') 363 return 0xE000 <= ord(char) <= 0xF8FF
Return whether a char is in the custom unicode range we use.
32class LoginAdapter: 33 """Allows using implicit login types in an explicit way. 34 35 Some login types such as Google Play Game Services or Game Center are 36 basically always present and often do not provide a way to log out 37 from within a running app, so this adapter exists to use them in a 38 flexible manner by 'attaching' and 'detaching' from an always-present 39 login, allowing for its use alongside other login types. It also 40 provides common functionality for server-side account verification and 41 other handy bits. 42 """ 43 44 @dataclass 45 class SignInResult: 46 """Describes the final result of a sign-in attempt.""" 47 48 credentials: str 49 50 @dataclass 51 class ImplicitLoginState: 52 """Describes the current state of an implicit login.""" 53 54 login_id: str 55 display_name: str 56 57 def __init__(self, login_type: LoginType): 58 assert _babase.in_logic_thread() 59 self.login_type = login_type 60 self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = ( 61 None 62 ) 63 self._on_app_loading_called = False 64 self._implicit_login_state_dirty = False 65 self._back_end_active = False 66 67 # Which login of our type (if any) is associated with the 68 # current active primary account. 69 self._active_login_id: str | None = None 70 71 self._last_sign_in_time: float | None = None 72 self._last_sign_in_desc: str | None = None 73 74 def on_app_loading(self) -> None: 75 """Should be called for each adapter in on_app_loading.""" 76 77 assert not self._on_app_loading_called 78 self._on_app_loading_called = True 79 80 # Any implicit state we received up until now needs to be pushed 81 # to the app account subsystem. 82 self._update_implicit_login_state() 83 84 def set_implicit_login_state( 85 self, state: ImplicitLoginState | None 86 ) -> None: 87 """Keep the adapter informed of implicit login states. 88 89 This should be called by the adapter back-end when an account 90 of their associated type gets logged in or out. 91 """ 92 assert _babase.in_logic_thread() 93 94 # Ignore redundant sets. 95 if state == self._implicit_login_state: 96 return 97 98 if DEBUG_LOG: 99 if state is None: 100 logging.debug( 101 'LoginAdapter: %s implicit state changed;' 102 ' now signed out.', 103 self.login_type.name, 104 ) 105 else: 106 logging.debug( 107 'LoginAdapter: %s implicit state changed;' 108 ' now signed in as %s.', 109 self.login_type.name, 110 state.display_name, 111 ) 112 113 self._implicit_login_state = state 114 self._implicit_login_state_dirty = True 115 116 # (possibly) push it to the app for handling. 117 self._update_implicit_login_state() 118 119 # This might affect whether we consider that back-end as 'active'. 120 self._update_back_end_active() 121 122 def set_active_logins(self, logins: dict[LoginType, str]) -> None: 123 """Keep the adapter informed of actively used logins. 124 125 This should be called by the app's account subsystem to 126 keep adapters up to date on the full set of logins attached 127 to the currently-in-use account. 128 Note that the logins dict passed in should be immutable as 129 only a reference to it is stored, not a copy. 130 """ 131 assert _babase.in_logic_thread() 132 if DEBUG_LOG: 133 logging.debug( 134 'LoginAdapter: %s adapter got active logins %s.', 135 self.login_type.name, 136 {k: v[:4] + '...' + v[-4:] for k, v in logins.items()}, 137 ) 138 139 self._active_login_id = logins.get(self.login_type) 140 self._update_back_end_active() 141 142 def on_back_end_active_change(self, active: bool) -> None: 143 """Called when active state for the back-end is (possibly) changing. 144 145 Meant to be overridden by subclasses. 146 Being active means that the implicit login provided by the back-end 147 is actually being used by the app. It should therefore register 148 unlocked achievements, leaderboard scores, allow viewing native 149 UIs, etc. When not active it should ignore everything and behave 150 as if signed out, even if it technically is still signed in. 151 """ 152 assert _babase.in_logic_thread() 153 del active # Unused. 154 155 @final 156 def sign_in( 157 self, 158 result_cb: Callable[[LoginAdapter, SignInResult | Exception], None], 159 description: str, 160 ) -> None: 161 """Attempt to sign in via this adapter. 162 163 This can be called even if the back-end is not implicitly signed in; 164 the adapter will attempt to sign in if possible. An exception will 165 be returned if the sign-in attempt fails. 166 """ 167 168 assert _babase.in_logic_thread() 169 170 # Have been seeing multiple sign-in attempts come through 171 # nearly simultaneously which can be problematic server-side. 172 # Let's error if a sign-in attempt is made within a few seconds 173 # of the last one to try and address this. 174 now = time.monotonic() 175 appnow = _babase.apptime() 176 if self._last_sign_in_time is not None: 177 since_last = now - self._last_sign_in_time 178 if since_last < 1.0: 179 logging.warning( 180 'LoginAdapter: %s adapter sign_in() called too soon' 181 ' (%.2fs) after last; this-desc="%s", last-desc="%s",' 182 ' ba-app-time=%.2f.', 183 self.login_type.name, 184 since_last, 185 description, 186 self._last_sign_in_desc, 187 appnow, 188 ) 189 _babase.pushcall( 190 partial( 191 result_cb, 192 self, 193 RuntimeError('sign_in called too soon after last.'), 194 ) 195 ) 196 return 197 198 self._last_sign_in_desc = description 199 self._last_sign_in_time = now 200 201 if DEBUG_LOG: 202 logging.debug( 203 'LoginAdapter: %s adapter sign_in() called;' 204 ' fetching sign-in-token...', 205 self.login_type.name, 206 ) 207 208 def _got_sign_in_token_result(result: str | None) -> None: 209 import bacommon.cloud 210 211 # Failed to get a sign-in-token. 212 if result is None: 213 if DEBUG_LOG: 214 logging.debug( 215 'LoginAdapter: %s adapter sign-in-token fetch failed;' 216 ' aborting sign-in.', 217 self.login_type.name, 218 ) 219 _babase.pushcall( 220 partial( 221 result_cb, 222 self, 223 RuntimeError('fetch-sign-in-token failed.'), 224 ) 225 ) 226 return 227 228 # Got a sign-in token! Now pass it to the cloud which will use 229 # it to verify our identity and give us app credentials on 230 # success. 231 if DEBUG_LOG: 232 logging.debug( 233 'LoginAdapter: %s adapter sign-in-token fetch succeeded;' 234 ' passing to cloud for verification...', 235 self.login_type.name, 236 ) 237 238 def _got_sign_in_response( 239 response: bacommon.cloud.SignInResponse | Exception, 240 ) -> None: 241 # This likely means we couldn't communicate with the server. 242 if isinstance(response, Exception): 243 if DEBUG_LOG: 244 logging.debug( 245 'LoginAdapter: %s adapter got error' 246 ' sign-in response: %s', 247 self.login_type.name, 248 response, 249 ) 250 _babase.pushcall(partial(result_cb, self, response)) 251 else: 252 # This means our credentials were explicitly rejected. 253 if response.credentials is None: 254 result2: LoginAdapter.SignInResult | Exception = ( 255 RuntimeError('Sign-in-token was rejected.') 256 ) 257 else: 258 if DEBUG_LOG: 259 logging.debug( 260 'LoginAdapter: %s adapter got successful' 261 ' sign-in response', 262 self.login_type.name, 263 ) 264 result2 = self.SignInResult( 265 credentials=response.credentials 266 ) 267 _babase.pushcall(partial(result_cb, self, result2)) 268 269 assert _babase.app.plus is not None 270 _babase.app.plus.cloud.send_message_cb( 271 bacommon.cloud.SignInMessage( 272 self.login_type, 273 result, 274 description=description, 275 apptime=appnow, 276 ), 277 on_response=_got_sign_in_response, 278 ) 279 280 # Kick off the sign-in process by fetching a sign-in token. 281 self.get_sign_in_token(completion_cb=_got_sign_in_token_result) 282 283 def is_back_end_active(self) -> bool: 284 """Is this adapter's back-end currently active?""" 285 return self._back_end_active 286 287 def get_sign_in_token( 288 self, completion_cb: Callable[[str | None], None] 289 ) -> None: 290 """Get a sign-in token from the adapter back end. 291 292 This token is then passed to the master-server to complete the 293 sign-in process. The adapter can use this opportunity to bring 294 up account creation UI, call its internal sign_in function, etc. 295 as needed. The provided completion_cb should then be called with 296 either a token or None if sign in failed or was cancelled. 297 """ 298 299 # Default implementation simply fails immediately. 300 _babase.pushcall(partial(completion_cb, None)) 301 302 def _update_implicit_login_state(self) -> None: 303 # If we've received an implicit login state, schedule it to be 304 # sent along to the app. We wait until on-app-loading has been 305 # called so that account-client-v2 has had a chance to load 306 # any existing state so it can properly respond to this. 307 if self._implicit_login_state_dirty and self._on_app_loading_called: 308 309 if DEBUG_LOG: 310 logging.debug( 311 'LoginAdapter: %s adapter sending' 312 ' implicit-state-changed to app.', 313 self.login_type.name, 314 ) 315 316 assert _babase.app.plus is not None 317 _babase.pushcall( 318 partial( 319 _babase.app.plus.accounts.on_implicit_login_state_changed, 320 self.login_type, 321 self._implicit_login_state, 322 ) 323 ) 324 self._implicit_login_state_dirty = False 325 326 def _update_back_end_active(self) -> None: 327 was_active = self._back_end_active 328 if self._implicit_login_state is None: 329 is_active = False 330 else: 331 is_active = ( 332 self._implicit_login_state.login_id == self._active_login_id 333 ) 334 if was_active != is_active: 335 if DEBUG_LOG: 336 logging.debug( 337 'LoginAdapter: %s adapter back-end-active is now %s.', 338 self.login_type.name, 339 is_active, 340 ) 341 self.on_back_end_active_change(is_active) 342 self._back_end_active = is_active
Allows using implicit login types in an explicit way.
Some login types such as Google Play Game Services or Game Center are basically always present and often do not provide a way to log out from within a running app, so this adapter exists to use them in a flexible manner by 'attaching' and 'detaching' from an always-present login, allowing for its use alongside other login types. It also provides common functionality for server-side account verification and other handy bits.
57 def __init__(self, login_type: LoginType): 58 assert _babase.in_logic_thread() 59 self.login_type = login_type 60 self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = ( 61 None 62 ) 63 self._on_app_loading_called = False 64 self._implicit_login_state_dirty = False 65 self._back_end_active = False 66 67 # Which login of our type (if any) is associated with the 68 # current active primary account. 69 self._active_login_id: str | None = None 70 71 self._last_sign_in_time: float | None = None 72 self._last_sign_in_desc: str | None = None
74 def on_app_loading(self) -> None: 75 """Should be called for each adapter in on_app_loading.""" 76 77 assert not self._on_app_loading_called 78 self._on_app_loading_called = True 79 80 # Any implicit state we received up until now needs to be pushed 81 # to the app account subsystem. 82 self._update_implicit_login_state()
Should be called for each adapter in on_app_loading.
84 def set_implicit_login_state( 85 self, state: ImplicitLoginState | None 86 ) -> None: 87 """Keep the adapter informed of implicit login states. 88 89 This should be called by the adapter back-end when an account 90 of their associated type gets logged in or out. 91 """ 92 assert _babase.in_logic_thread() 93 94 # Ignore redundant sets. 95 if state == self._implicit_login_state: 96 return 97 98 if DEBUG_LOG: 99 if state is None: 100 logging.debug( 101 'LoginAdapter: %s implicit state changed;' 102 ' now signed out.', 103 self.login_type.name, 104 ) 105 else: 106 logging.debug( 107 'LoginAdapter: %s implicit state changed;' 108 ' now signed in as %s.', 109 self.login_type.name, 110 state.display_name, 111 ) 112 113 self._implicit_login_state = state 114 self._implicit_login_state_dirty = True 115 116 # (possibly) push it to the app for handling. 117 self._update_implicit_login_state() 118 119 # This might affect whether we consider that back-end as 'active'. 120 self._update_back_end_active()
Keep the adapter informed of implicit login states.
This should be called by the adapter back-end when an account of their associated type gets logged in or out.
122 def set_active_logins(self, logins: dict[LoginType, str]) -> None: 123 """Keep the adapter informed of actively used logins. 124 125 This should be called by the app's account subsystem to 126 keep adapters up to date on the full set of logins attached 127 to the currently-in-use account. 128 Note that the logins dict passed in should be immutable as 129 only a reference to it is stored, not a copy. 130 """ 131 assert _babase.in_logic_thread() 132 if DEBUG_LOG: 133 logging.debug( 134 'LoginAdapter: %s adapter got active logins %s.', 135 self.login_type.name, 136 {k: v[:4] + '...' + v[-4:] for k, v in logins.items()}, 137 ) 138 139 self._active_login_id = logins.get(self.login_type) 140 self._update_back_end_active()
Keep the adapter informed of actively used logins.
This should be called by the app's account subsystem to keep adapters up to date on the full set of logins attached to the currently-in-use account. Note that the logins dict passed in should be immutable as only a reference to it is stored, not a copy.
142 def on_back_end_active_change(self, active: bool) -> None: 143 """Called when active state for the back-end is (possibly) changing. 144 145 Meant to be overridden by subclasses. 146 Being active means that the implicit login provided by the back-end 147 is actually being used by the app. It should therefore register 148 unlocked achievements, leaderboard scores, allow viewing native 149 UIs, etc. When not active it should ignore everything and behave 150 as if signed out, even if it technically is still signed in. 151 """ 152 assert _babase.in_logic_thread() 153 del active # Unused.
Called when active state for the back-end is (possibly) changing.
Meant to be overridden by subclasses. Being active means that the implicit login provided by the back-end is actually being used by the app. It should therefore register unlocked achievements, leaderboard scores, allow viewing native UIs, etc. When not active it should ignore everything and behave as if signed out, even if it technically is still signed in.
155 @final 156 def sign_in( 157 self, 158 result_cb: Callable[[LoginAdapter, SignInResult | Exception], None], 159 description: str, 160 ) -> None: 161 """Attempt to sign in via this adapter. 162 163 This can be called even if the back-end is not implicitly signed in; 164 the adapter will attempt to sign in if possible. An exception will 165 be returned if the sign-in attempt fails. 166 """ 167 168 assert _babase.in_logic_thread() 169 170 # Have been seeing multiple sign-in attempts come through 171 # nearly simultaneously which can be problematic server-side. 172 # Let's error if a sign-in attempt is made within a few seconds 173 # of the last one to try and address this. 174 now = time.monotonic() 175 appnow = _babase.apptime() 176 if self._last_sign_in_time is not None: 177 since_last = now - self._last_sign_in_time 178 if since_last < 1.0: 179 logging.warning( 180 'LoginAdapter: %s adapter sign_in() called too soon' 181 ' (%.2fs) after last; this-desc="%s", last-desc="%s",' 182 ' ba-app-time=%.2f.', 183 self.login_type.name, 184 since_last, 185 description, 186 self._last_sign_in_desc, 187 appnow, 188 ) 189 _babase.pushcall( 190 partial( 191 result_cb, 192 self, 193 RuntimeError('sign_in called too soon after last.'), 194 ) 195 ) 196 return 197 198 self._last_sign_in_desc = description 199 self._last_sign_in_time = now 200 201 if DEBUG_LOG: 202 logging.debug( 203 'LoginAdapter: %s adapter sign_in() called;' 204 ' fetching sign-in-token...', 205 self.login_type.name, 206 ) 207 208 def _got_sign_in_token_result(result: str | None) -> None: 209 import bacommon.cloud 210 211 # Failed to get a sign-in-token. 212 if result is None: 213 if DEBUG_LOG: 214 logging.debug( 215 'LoginAdapter: %s adapter sign-in-token fetch failed;' 216 ' aborting sign-in.', 217 self.login_type.name, 218 ) 219 _babase.pushcall( 220 partial( 221 result_cb, 222 self, 223 RuntimeError('fetch-sign-in-token failed.'), 224 ) 225 ) 226 return 227 228 # Got a sign-in token! Now pass it to the cloud which will use 229 # it to verify our identity and give us app credentials on 230 # success. 231 if DEBUG_LOG: 232 logging.debug( 233 'LoginAdapter: %s adapter sign-in-token fetch succeeded;' 234 ' passing to cloud for verification...', 235 self.login_type.name, 236 ) 237 238 def _got_sign_in_response( 239 response: bacommon.cloud.SignInResponse | Exception, 240 ) -> None: 241 # This likely means we couldn't communicate with the server. 242 if isinstance(response, Exception): 243 if DEBUG_LOG: 244 logging.debug( 245 'LoginAdapter: %s adapter got error' 246 ' sign-in response: %s', 247 self.login_type.name, 248 response, 249 ) 250 _babase.pushcall(partial(result_cb, self, response)) 251 else: 252 # This means our credentials were explicitly rejected. 253 if response.credentials is None: 254 result2: LoginAdapter.SignInResult | Exception = ( 255 RuntimeError('Sign-in-token was rejected.') 256 ) 257 else: 258 if DEBUG_LOG: 259 logging.debug( 260 'LoginAdapter: %s adapter got successful' 261 ' sign-in response', 262 self.login_type.name, 263 ) 264 result2 = self.SignInResult( 265 credentials=response.credentials 266 ) 267 _babase.pushcall(partial(result_cb, self, result2)) 268 269 assert _babase.app.plus is not None 270 _babase.app.plus.cloud.send_message_cb( 271 bacommon.cloud.SignInMessage( 272 self.login_type, 273 result, 274 description=description, 275 apptime=appnow, 276 ), 277 on_response=_got_sign_in_response, 278 ) 279 280 # Kick off the sign-in process by fetching a sign-in token. 281 self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
Attempt to sign in via this adapter.
This can be called even if the back-end is not implicitly signed in; the adapter will attempt to sign in if possible. An exception will be returned if the sign-in attempt fails.
283 def is_back_end_active(self) -> bool: 284 """Is this adapter's back-end currently active?""" 285 return self._back_end_active
Is this adapter's back-end currently active?
287 def get_sign_in_token( 288 self, completion_cb: Callable[[str | None], None] 289 ) -> None: 290 """Get a sign-in token from the adapter back end. 291 292 This token is then passed to the master-server to complete the 293 sign-in process. The adapter can use this opportunity to bring 294 up account creation UI, call its internal sign_in function, etc. 295 as needed. The provided completion_cb should then be called with 296 either a token or None if sign in failed or was cancelled. 297 """ 298 299 # Default implementation simply fails immediately. 300 _babase.pushcall(partial(completion_cb, None))
Get a sign-in token from the adapter back end.
This token is then passed to the master-server to complete the sign-in process. The adapter can use this opportunity to bring up account creation UI, call its internal sign_in function, etc. as needed. The provided completion_cb should then be called with either a token or None if sign in failed or was cancelled.
44 @dataclass 45 class SignInResult: 46 """Describes the final result of a sign-in attempt.""" 47 48 credentials: str
Describes the final result of a sign-in attempt.
50 @dataclass 51 class ImplicitLoginState: 52 """Describes the current state of an implicit login.""" 53 54 login_id: str 55 display_name: str
Describes the current state of an implicit login.
25@dataclass 26class LoginInfo: 27 """Basic info about a login available in the app.plus.accounts section.""" 28 29 name: str
Basic info about a login available in the app.plus.accounts section.
440class Lstr: 441 """Used to define strings in a language-independent way. 442 443 Category: **General Utility Classes** 444 445 These should be used whenever possible in place of hard-coded 446 strings so that in-game or UI elements show up correctly on all 447 clients in their currently-active language. 448 449 To see available resource keys, look at any of the bs_language_*.py 450 files in the game or the translations pages at 451 legacy.ballistica.net/translate. 452 453 ##### Examples 454 EXAMPLE 1: specify a string from a resource path 455 >>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText') 456 457 EXAMPLE 2: specify a translated string via a category and english 458 value; if a translated value is available, it will be used; otherwise 459 the english value will be. To see available translation categories, 460 look under the 'translations' resource section. 461 >>> mynode.text = babase.Lstr(translate=('gameDescriptions', 462 ... 'Defeat all enemies')) 463 464 EXAMPLE 3: specify a raw value and some substitutions. Substitutions 465 can be used with resource and translate modes as well. 466 >>> mynode.text = babase.Lstr(value='${A} / ${B}', 467 ... subs=[('${A}', str(score)), ('${B}', str(total))]) 468 469 EXAMPLE 4: babase.Lstr's can be nested. This example would display the 470 resource at res_a but replace ${NAME} with the value of the 471 resource at res_b 472 >>> mytextnode.text = babase.Lstr( 473 ... resource='res_a', 474 ... subs=[('${NAME}', babase.Lstr(resource='res_b'))]) 475 """ 476 477 # pylint: disable=dangerous-default-value 478 # noinspection PyDefaultArgument 479 @overload 480 def __init__( 481 self, 482 *, 483 resource: str, 484 fallback_resource: str = '', 485 fallback_value: str = '', 486 subs: Sequence[tuple[str, str | Lstr]] = [], 487 ) -> None: 488 """Create an Lstr from a string resource.""" 489 490 # noinspection PyShadowingNames,PyDefaultArgument 491 @overload 492 def __init__( 493 self, 494 *, 495 translate: tuple[str, str], 496 subs: Sequence[tuple[str, str | Lstr]] = [], 497 ) -> None: 498 """Create an Lstr by translating a string in a category.""" 499 500 # noinspection PyDefaultArgument 501 @overload 502 def __init__( 503 self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = [] 504 ) -> None: 505 """Create an Lstr from a raw string value.""" 506 507 # pylint: enable=redefined-outer-name, dangerous-default-value 508 509 def __init__(self, *args: Any, **keywds: Any) -> None: 510 """Instantiate a Lstr. 511 512 Pass a value for either 'resource', 'translate', 513 or 'value'. (see Lstr help for examples). 514 'subs' can be a sequence of 2-member sequences consisting of values 515 and replacements. 516 'fallback_resource' can be a resource key that will be used if the 517 main one is not present for 518 the current language in place of falling back to the english value 519 ('resource' mode only). 520 'fallback_value' can be a literal string that will be used if neither 521 the resource nor the fallback resource is found ('resource' mode only). 522 """ 523 # pylint: disable=too-many-branches 524 if args: 525 raise TypeError('Lstr accepts only keyword arguments') 526 527 # Basically just store the exact args they passed. 528 # However if they passed any Lstr values for subs, 529 # replace them with that Lstr's dict. 530 self.args = keywds 531 our_type = type(self) 532 533 if isinstance(self.args.get('value'), our_type): 534 raise TypeError("'value' must be a regular string; not an Lstr") 535 536 if 'subs' in self.args: 537 subs_new = [] 538 for key, value in keywds['subs']: 539 if isinstance(value, our_type): 540 subs_new.append((key, value.args)) 541 else: 542 subs_new.append((key, value)) 543 self.args['subs'] = subs_new 544 545 # As of protocol 31 we support compact key names 546 # ('t' instead of 'translate', etc). Convert as needed. 547 if 'translate' in keywds: 548 keywds['t'] = keywds['translate'] 549 del keywds['translate'] 550 if 'resource' in keywds: 551 keywds['r'] = keywds['resource'] 552 del keywds['resource'] 553 if 'value' in keywds: 554 keywds['v'] = keywds['value'] 555 del keywds['value'] 556 if 'fallback' in keywds: 557 from babase import _error 558 559 _error.print_error( 560 'deprecated "fallback" arg passed to Lstr(); use ' 561 'either "fallback_resource" or "fallback_value"', 562 once=True, 563 ) 564 keywds['f'] = keywds['fallback'] 565 del keywds['fallback'] 566 if 'fallback_resource' in keywds: 567 keywds['f'] = keywds['fallback_resource'] 568 del keywds['fallback_resource'] 569 if 'subs' in keywds: 570 keywds['s'] = keywds['subs'] 571 del keywds['subs'] 572 if 'fallback_value' in keywds: 573 keywds['fv'] = keywds['fallback_value'] 574 del keywds['fallback_value'] 575 576 def evaluate(self) -> str: 577 """Evaluate the Lstr and returns a flat string in the current language. 578 579 You should avoid doing this as much as possible and instead pass 580 and store Lstr values. 581 """ 582 return _babase.evaluate_lstr(self._get_json()) 583 584 def is_flat_value(self) -> bool: 585 """Return whether the Lstr is a 'flat' value. 586 587 This is defined as a simple string value incorporating no 588 translations, resources, or substitutions. In this case it may 589 be reasonable to replace it with a raw string value, perform 590 string manipulation on it, etc. 591 """ 592 return bool('v' in self.args and not self.args.get('s', [])) 593 594 def _get_json(self) -> str: 595 try: 596 return json.dumps(self.args, separators=(',', ':')) 597 except Exception: 598 from babase import _error 599 600 _error.print_exception('_get_json failed for', self.args) 601 return 'JSON_ERR' 602 603 @override 604 def __str__(self) -> str: 605 return '<ba.Lstr: ' + self._get_json() + '>' 606 607 @override 608 def __repr__(self) -> str: 609 return '<ba.Lstr: ' + self._get_json() + '>' 610 611 @staticmethod 612 def from_json(json_string: str) -> babase.Lstr: 613 """Given a json string, returns a babase.Lstr. Does no validation.""" 614 lstr = Lstr(value='') 615 lstr.args = json.loads(json_string) 616 return lstr
Used to define strings in a language-independent way.
Category: General Utility Classes
These should be used whenever possible in place of hard-coded strings so that in-game or UI elements show up correctly on all clients in their currently-active language.
To see available resource keys, look at any of the bs_language_*.py files in the game or the translations pages at legacy.ballistica.net/translate.
Examples
EXAMPLE 1: specify a string from a resource path
>>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
EXAMPLE 2: specify a translated string via a category and english value; if a translated value is available, it will be used; otherwise the english value will be. To see available translation categories, look under the 'translations' resource section.
>>> mynode.text = babase.Lstr(translate=('gameDescriptions',
... 'Defeat all enemies'))
EXAMPLE 3: specify a raw value and some substitutions. Substitutions can be used with resource and translate modes as well.
>>> mynode.text = babase.Lstr(value='${A} / ${B}',
... subs=[('${A}', str(score)), ('${B}', str(total))])
EXAMPLE 4: babase.Lstr's can be nested. This example would display the resource at res_a but replace ${NAME} with the value of the resource at res_b
>>> mytextnode.text = babase.Lstr(
... resource='res_a',
... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
509 def __init__(self, *args: Any, **keywds: Any) -> None: 510 """Instantiate a Lstr. 511 512 Pass a value for either 'resource', 'translate', 513 or 'value'. (see Lstr help for examples). 514 'subs' can be a sequence of 2-member sequences consisting of values 515 and replacements. 516 'fallback_resource' can be a resource key that will be used if the 517 main one is not present for 518 the current language in place of falling back to the english value 519 ('resource' mode only). 520 'fallback_value' can be a literal string that will be used if neither 521 the resource nor the fallback resource is found ('resource' mode only). 522 """ 523 # pylint: disable=too-many-branches 524 if args: 525 raise TypeError('Lstr accepts only keyword arguments') 526 527 # Basically just store the exact args they passed. 528 # However if they passed any Lstr values for subs, 529 # replace them with that Lstr's dict. 530 self.args = keywds 531 our_type = type(self) 532 533 if isinstance(self.args.get('value'), our_type): 534 raise TypeError("'value' must be a regular string; not an Lstr") 535 536 if 'subs' in self.args: 537 subs_new = [] 538 for key, value in keywds['subs']: 539 if isinstance(value, our_type): 540 subs_new.append((key, value.args)) 541 else: 542 subs_new.append((key, value)) 543 self.args['subs'] = subs_new 544 545 # As of protocol 31 we support compact key names 546 # ('t' instead of 'translate', etc). Convert as needed. 547 if 'translate' in keywds: 548 keywds['t'] = keywds['translate'] 549 del keywds['translate'] 550 if 'resource' in keywds: 551 keywds['r'] = keywds['resource'] 552 del keywds['resource'] 553 if 'value' in keywds: 554 keywds['v'] = keywds['value'] 555 del keywds['value'] 556 if 'fallback' in keywds: 557 from babase import _error 558 559 _error.print_error( 560 'deprecated "fallback" arg passed to Lstr(); use ' 561 'either "fallback_resource" or "fallback_value"', 562 once=True, 563 ) 564 keywds['f'] = keywds['fallback'] 565 del keywds['fallback'] 566 if 'fallback_resource' in keywds: 567 keywds['f'] = keywds['fallback_resource'] 568 del keywds['fallback_resource'] 569 if 'subs' in keywds: 570 keywds['s'] = keywds['subs'] 571 del keywds['subs'] 572 if 'fallback_value' in keywds: 573 keywds['fv'] = keywds['fallback_value'] 574 del keywds['fallback_value']
Instantiate a Lstr.
Pass a value for either 'resource', 'translate', or 'value'. (see Lstr help for examples). 'subs' can be a sequence of 2-member sequences consisting of values and replacements. 'fallback_resource' can be a resource key that will be used if the main one is not present for the current language in place of falling back to the english value ('resource' mode only). 'fallback_value' can be a literal string that will be used if neither the resource nor the fallback resource is found ('resource' mode only).
576 def evaluate(self) -> str: 577 """Evaluate the Lstr and returns a flat string in the current language. 578 579 You should avoid doing this as much as possible and instead pass 580 and store Lstr values. 581 """ 582 return _babase.evaluate_lstr(self._get_json())
Evaluate the Lstr and returns a flat string in the current language.
You should avoid doing this as much as possible and instead pass and store Lstr values.
584 def is_flat_value(self) -> bool: 585 """Return whether the Lstr is a 'flat' value. 586 587 This is defined as a simple string value incorporating no 588 translations, resources, or substitutions. In this case it may 589 be reasonable to replace it with a raw string value, perform 590 string manipulation on it, etc. 591 """ 592 return bool('v' in self.args and not self.args.get('s', []))
Return whether the Lstr is a 'flat' value.
This is defined as a simple string value incorporating no translations, resources, or substitutions. In this case it may be reasonable to replace it with a raw string value, perform string manipulation on it, etc.
54class MapNotFoundError(NotFoundError): 55 """Exception raised when an expected bascenev1.Map does not exist. 56 57 Category: **Exception Classes** 58 """
Exception raised when an expected bascenev1.Map does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
49class MetadataSubsystem: 50 """Subsystem for working with script metadata in the app. 51 52 Category: **App Classes** 53 54 Access the single shared instance of this class at 'babase.app.meta'. 55 """ 56 57 def __init__(self) -> None: 58 self._scan: DirectoryScan | None = None 59 60 # Can be populated before starting the scan. 61 self.extra_scan_dirs: list[str] = [] 62 63 # Results populated once scan is complete. 64 self.scanresults: ScanResults | None = None 65 66 self._scan_complete_cb: Callable[[], None] | None = None 67 68 def start_scan(self, scan_complete_cb: Callable[[], None]) -> None: 69 """Begin the overall scan. 70 71 This will start scanning built in directories (which for vanilla 72 installs should be the vast majority of the work). This should only 73 be called once. 74 """ 75 assert self._scan_complete_cb is None 76 assert self._scan is None 77 env = _babase.app.env 78 79 self._scan_complete_cb = scan_complete_cb 80 self._scan = DirectoryScan( 81 [ 82 path 83 for path in [ 84 env.python_directory_app, 85 env.python_directory_user, 86 ] 87 if path is not None 88 ] 89 ) 90 91 Thread(target=self._run_scan_in_bg, daemon=True).start() 92 93 def start_extra_scan(self) -> None: 94 """Proceed to the extra_scan_dirs portion of the scan. 95 96 This is for parts of the scan that must be delayed until 97 workspace sync completion or other such events. This must be 98 called exactly once. 99 """ 100 assert self._scan is not None 101 self._scan.set_extras(self.extra_scan_dirs) 102 103 def load_exported_classes( 104 self, 105 cls: type[T], 106 completion_cb: Callable[[list[type[T]]], None], 107 completion_cb_in_bg_thread: bool = False, 108 ) -> None: 109 """High level function to load meta-exported classes. 110 111 Will wait for scanning to complete if necessary, and will load all 112 registered classes of a particular type in a background thread before 113 calling the passed callback in the logic thread. Errors may be logged 114 to messaged to the user in some way but the callback will be called 115 regardless. 116 To run the completion callback directly in the bg thread where the 117 loading work happens, pass completion_cb_in_bg_thread=True. 118 """ 119 Thread( 120 target=partial( 121 self._load_exported_classes, 122 cls, 123 completion_cb, 124 completion_cb_in_bg_thread, 125 ), 126 daemon=True, 127 ).start() 128 129 def _load_exported_classes( 130 self, 131 cls: type[T], 132 completion_cb: Callable[[list[type[T]]], None], 133 completion_cb_in_bg_thread: bool, 134 ) -> None: 135 from babase._general import getclass 136 137 classes: list[type[T]] = [] 138 try: 139 classnames = self._wait_for_scan_results().exports_of_class(cls) 140 for classname in classnames: 141 try: 142 classes.append(getclass(classname, cls)) 143 except Exception: 144 logging.exception('error importing %s', classname) 145 146 except Exception: 147 logging.exception('Error loading exported classes.') 148 149 completion_call = partial(completion_cb, classes) 150 if completion_cb_in_bg_thread: 151 completion_call() 152 else: 153 _babase.pushcall(completion_call, from_other_thread=True) 154 155 def _wait_for_scan_results(self) -> ScanResults: 156 """Return scan results, blocking if the scan is not yet complete.""" 157 if self.scanresults is None: 158 if _babase.in_logic_thread(): 159 logging.warning( 160 'babase.meta._wait_for_scan_results()' 161 ' called in logic thread before scan completed;' 162 ' this can cause hitches.' 163 ) 164 165 # Now wait a bit for the scan to complete. 166 # Eventually error though if it doesn't. 167 starttime = time.time() 168 while self.scanresults is None: 169 time.sleep(0.05) 170 if time.time() - starttime > 10.0: 171 raise TimeoutError( 172 'timeout waiting for meta scan to complete.' 173 ) 174 return self.scanresults 175 176 def _run_scan_in_bg(self) -> None: 177 """Runs a scan (for use in background thread).""" 178 try: 179 assert self._scan is not None 180 self._scan.run() 181 results = self._scan.results 182 self._scan = None 183 except Exception: 184 logging.exception('metascan: Error running scan in bg.') 185 results = ScanResults(announce_errors_occurred=True) 186 187 # Place results and tell the logic thread they're ready. 188 self.scanresults = results 189 _babase.pushcall(self._handle_scan_results, from_other_thread=True) 190 191 def _handle_scan_results(self) -> None: 192 """Called in the logic thread with results of a completed scan.""" 193 from babase._language import Lstr 194 195 assert _babase.in_logic_thread() 196 197 results = self.scanresults 198 assert results is not None 199 200 do_play_error_sound = False 201 202 # If we found modules needing to be updated to the newer api version, 203 # mention that specifically. 204 if results.incorrect_api_modules: 205 if len(results.incorrect_api_modules) > 1: 206 msg = Lstr( 207 resource='scanScriptsMultipleModulesNeedUpdatesText', 208 subs=[ 209 ('${PATH}', results.incorrect_api_modules[0]), 210 ( 211 '${NUM}', 212 str(len(results.incorrect_api_modules) - 1), 213 ), 214 ('${API}', str(_babase.app.env.api_version)), 215 ], 216 ) 217 else: 218 msg = Lstr( 219 resource='scanScriptsSingleModuleNeedsUpdatesText', 220 subs=[ 221 ('${PATH}', results.incorrect_api_modules[0]), 222 ('${API}', str(_babase.app.env.api_version)), 223 ], 224 ) 225 _babase.screenmessage(msg, color=(1, 0, 0)) 226 do_play_error_sound = True 227 228 # Let the user know if there's warning/errors in their log 229 # they may want to look at. 230 if results.announce_errors_occurred: 231 _babase.screenmessage( 232 Lstr(resource='scanScriptsErrorText'), color=(1, 0, 0) 233 ) 234 do_play_error_sound = True 235 236 if do_play_error_sound: 237 _babase.getsimplesound('error').play() 238 239 # Let the game know we're done. 240 assert self._scan_complete_cb is not None 241 self._scan_complete_cb()
Subsystem for working with script metadata in the app.
Category: App Classes
Access the single shared instance of this class at 'babase.app.meta'.
68 def start_scan(self, scan_complete_cb: Callable[[], None]) -> None: 69 """Begin the overall scan. 70 71 This will start scanning built in directories (which for vanilla 72 installs should be the vast majority of the work). This should only 73 be called once. 74 """ 75 assert self._scan_complete_cb is None 76 assert self._scan is None 77 env = _babase.app.env 78 79 self._scan_complete_cb = scan_complete_cb 80 self._scan = DirectoryScan( 81 [ 82 path 83 for path in [ 84 env.python_directory_app, 85 env.python_directory_user, 86 ] 87 if path is not None 88 ] 89 ) 90 91 Thread(target=self._run_scan_in_bg, daemon=True).start()
Begin the overall scan.
This will start scanning built in directories (which for vanilla installs should be the vast majority of the work). This should only be called once.
93 def start_extra_scan(self) -> None: 94 """Proceed to the extra_scan_dirs portion of the scan. 95 96 This is for parts of the scan that must be delayed until 97 workspace sync completion or other such events. This must be 98 called exactly once. 99 """ 100 assert self._scan is not None 101 self._scan.set_extras(self.extra_scan_dirs)
Proceed to the extra_scan_dirs portion of the scan.
This is for parts of the scan that must be delayed until workspace sync completion or other such events. This must be called exactly once.
103 def load_exported_classes( 104 self, 105 cls: type[T], 106 completion_cb: Callable[[list[type[T]]], None], 107 completion_cb_in_bg_thread: bool = False, 108 ) -> None: 109 """High level function to load meta-exported classes. 110 111 Will wait for scanning to complete if necessary, and will load all 112 registered classes of a particular type in a background thread before 113 calling the passed callback in the logic thread. Errors may be logged 114 to messaged to the user in some way but the callback will be called 115 regardless. 116 To run the completion callback directly in the bg thread where the 117 loading work happens, pass completion_cb_in_bg_thread=True. 118 """ 119 Thread( 120 target=partial( 121 self._load_exported_classes, 122 cls, 123 completion_cb, 124 completion_cb_in_bg_thread, 125 ), 126 daemon=True, 127 ).start()
High level function to load meta-exported classes.
Will wait for scanning to complete if necessary, and will load all registered classes of a particular type in a background thread before calling the passed callback in the logic thread. Errors may be logged to messaged to the user in some way but the callback will be called regardless. To run the completion callback directly in the bg thread where the loading work happens, pass completion_cb_in_bg_thread=True.
1252def native_stack_trace() -> str | None: 1253 """Return a native stack trace as a string, or None if not available. 1254 1255 Category: **General Utility Functions** 1256 1257 Stack traces contain different data and formatting across platforms. 1258 Only use them for debugging. 1259 """ 1260 return ''
Return a native stack trace as a string, or None if not available.
Category: General Utility Functions
Stack traces contain different data and formatting across platforms. Only use them for debugging.
75class NodeNotFoundError(NotFoundError): 76 """Exception raised when an expected Node does not exist. 77 78 Category: **Exception Classes** 79 """
Exception raised when an expected Node does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
52def normalized_color(color: Sequence[float]) -> tuple[float, ...]: 53 """Scale a color so its largest value is 1; useful for coloring lights. 54 55 category: General Utility Functions 56 """ 57 color_biased = tuple(max(c, 0.01) for c in color) # account for black 58 mult = 1.0 / max(color_biased) 59 return tuple(c * mult for c in color_biased)
Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
26class NotFoundError(Exception): 27 """Exception raised when a referenced object does not exist. 28 29 Category: **Exception Classes** 30 """
Exception raised when a referenced object does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
1299def open_url(address: str, force_fallback: bool = False) -> None: 1300 """Open the provided URL. 1301 1302 Category: **General Utility Functions** 1303 1304 Attempts to open the provided url in a web-browser. If that is not 1305 possible (or force_fallback is True), instead displays the url as 1306 a string and/or qrcode. 1307 """ 1308 return None
Open the provided URL.
Category: General Utility Functions
Attempts to open the provided url in a web-browser. If that is not possible (or force_fallback is True), instead displays the url as a string and/or qrcode.
1311def overlay_web_browser_close() -> bool: 1312 """Close any open overlay web browser. 1313 1314 Category: **General Utility Functions** 1315 """ 1316 return bool()
Close any open overlay web browser.
Category: General Utility Functions
1319def overlay_web_browser_is_open() -> bool: 1320 """Return whether an overlay web browser is open currently. 1321 1322 Category: **General Utility Functions** 1323 """ 1324 return bool()
Return whether an overlay web browser is open currently.
Category: General Utility Functions
1327def overlay_web_browser_is_supported() -> bool: 1328 """Return whether an overlay web browser is supported here. 1329 1330 Category: **General Utility Functions** 1331 1332 An overlay web browser is a small dialog that pops up over the top 1333 of the main engine window. It can be used for performing simple 1334 tasks such as sign-ins. 1335 """ 1336 return bool()
Return whether an overlay web browser is supported here.
Category: General Utility Functions
An overlay web browser is a small dialog that pops up over the top of the main engine window. It can be used for performing simple tasks such as sign-ins.
1339def overlay_web_browser_open_url(address: str) -> None: 1340 """Open the provided URL in an overlayw web browser. 1341 1342 Category: **General Utility Functions** 1343 1344 An overlay web browser is a small dialog that pops up over the top 1345 of the main engine window. It can be used for performing simple 1346 tasks such as sign-ins. 1347 """ 1348 return None
Open the provided URL in an overlayw web browser.
Category: General Utility Functions
An overlay web browser is a small dialog that pops up over the top of the main engine window. It can be used for performing simple tasks such as sign-ins.
89class Permission(Enum): 90 """Permissions that can be requested from the OS. 91 92 Category: Enums 93 """ 94 95 STORAGE = 0
Permissions that can be requested from the OS.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
33class PlayerNotFoundError(NotFoundError): 34 """Exception raised when an expected player does not exist. 35 36 Category: **Exception Classes** 37 """
Exception raised when an expected player does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
322class Plugin: 323 """A plugin to alter app behavior in some way. 324 325 Category: **App Classes** 326 327 Plugins are discoverable by the meta-tag system 328 and the user can select which ones they want to enable. 329 Enabled plugins are then called at specific times as the 330 app is running in order to modify its behavior in some way. 331 """ 332 333 def on_app_running(self) -> None: 334 """Called when the app reaches the running state.""" 335 336 def on_app_suspend(self) -> None: 337 """Called when the app enters the suspended state.""" 338 339 def on_app_unsuspend(self) -> None: 340 """Called when the app exits the suspended state.""" 341 342 def on_app_shutdown(self) -> None: 343 """Called when the app is beginning the shutdown process.""" 344 345 def on_app_shutdown_complete(self) -> None: 346 """Called when the app has completed the shutdown process.""" 347 348 def has_settings_ui(self) -> bool: 349 """Called to ask if we have settings UI we can show.""" 350 return False 351 352 def show_settings_ui(self, source_widget: Any | None) -> None: 353 """Called to show our settings UI."""
A plugin to alter app behavior in some way.
Category: App Classes
Plugins are discoverable by the meta-tag system and the user can select which ones they want to enable. Enabled plugins are then called at specific times as the app is running in order to modify its behavior in some way.
342 def on_app_shutdown(self) -> None: 343 """Called when the app is beginning the shutdown process."""
Called when the app is beginning the shutdown process.
345 def on_app_shutdown_complete(self) -> None: 346 """Called when the app has completed the shutdown process."""
Called when the app has completed the shutdown process.
21class PluginSubsystem(AppSubsystem): 22 """Subsystem for plugin handling in the app. 23 24 Category: **App Classes** 25 26 Access the single shared instance of this class at `ba.app.plugins`. 27 """ 28 29 AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins' 30 AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True 31 32 def __init__(self) -> None: 33 super().__init__() 34 35 # Info about plugins that we are aware of. This may include 36 # plugins discovered through meta-scanning as well as plugins 37 # registered in the app-config. This may include plugins that 38 # cannot be loaded for various reasons or that have been 39 # intentionally disabled. 40 self.plugin_specs: dict[str, babase.PluginSpec] = {} 41 42 # The set of live active plugin objects. 43 self.active_plugins: list[babase.Plugin] = [] 44 45 def on_meta_scan_complete(self) -> None: 46 """Called when meta-scanning is complete.""" 47 from babase._language import Lstr 48 49 config_changed = False 50 found_new = False 51 plugstates: dict[str, dict] = _babase.app.config.setdefault( 52 'Plugins', {} 53 ) 54 assert isinstance(plugstates, dict) 55 56 results = _babase.app.meta.scanresults 57 assert results is not None 58 59 auto_enable_new_plugins = ( 60 _babase.app.config.get( 61 self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY, 62 self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT, 63 ) 64 is True 65 ) 66 67 assert not self.plugin_specs 68 assert not self.active_plugins 69 70 # Create a plugin-spec for each plugin class we found in the 71 # meta-scan. 72 for class_path in results.exports_of_class(Plugin): 73 assert class_path not in self.plugin_specs 74 plugspec = self.plugin_specs[class_path] = PluginSpec( 75 class_path=class_path, loadable=True 76 ) 77 78 # Auto-enable new ones if desired. 79 if auto_enable_new_plugins: 80 if class_path not in plugstates: 81 plugspec.enabled = True 82 config_changed = True 83 found_new = True 84 85 # If we're *not* auto-enabling, simply let the user know if we 86 # found new ones. 87 if found_new and not auto_enable_new_plugins: 88 _babase.screenmessage( 89 Lstr(resource='pluginsDetectedText'), color=(0, 1, 0) 90 ) 91 _babase.getsimplesound('ding').play() 92 93 # Ok, now go through all plugins registered in the app-config 94 # that weren't covered by the meta stuff above, either creating 95 # plugin-specs for them or clearing them out. This covers 96 # plugins with api versions not matching ours, plugins without 97 # ba_meta tags, and plugins that have since disappeared. 98 assert isinstance(plugstates, dict) 99 wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules] 100 101 disappeared_plugs: set[str] = set() 102 103 for class_path in sorted(plugstates.keys()): 104 # Already have a spec for it; nothing to be done. 105 if class_path in self.plugin_specs: 106 continue 107 108 # If this plugin corresponds to any modules that we've 109 # identified as having incorrect api versions, we'll take 110 # note of its existence but we won't try to load it. 111 if any( 112 class_path.startswith(prefix) for prefix in wrong_api_prefixes 113 ): 114 plugspec = self.plugin_specs[class_path] = PluginSpec( 115 class_path=class_path, loadable=False 116 ) 117 continue 118 119 # Ok, it seems to be a class we have no metadata for. Look 120 # to see if it appears to be an actual class we could 121 # theoretically load. If so, we'll try. If not, we consider 122 # the plugin to have disappeared and inform the user as 123 # such. 124 try: 125 spec = importlib.util.find_spec( 126 '.'.join(class_path.split('.')[:-1]) 127 ) 128 except Exception: 129 spec = None 130 131 if spec is None: 132 disappeared_plugs.add(class_path) 133 continue 134 135 # If plugins disappeared, let the user know gently and remove 136 # them from the config so we'll again let the user know if they 137 # later reappear. This makes it much smoother to switch between 138 # users or workspaces. 139 if disappeared_plugs: 140 _babase.getsimplesound('shieldDown').play() 141 _babase.screenmessage( 142 Lstr( 143 resource='pluginsRemovedText', 144 subs=[('${NUM}', str(len(disappeared_plugs)))], 145 ), 146 color=(1, 1, 0), 147 ) 148 149 plugnames = ', '.join(disappeared_plugs) 150 logging.info( 151 '%d plugin(s) no longer found: %s.', 152 len(disappeared_plugs), 153 plugnames, 154 ) 155 for goneplug in disappeared_plugs: 156 del _babase.app.config['Plugins'][goneplug] 157 _babase.app.config.commit() 158 159 if config_changed: 160 _babase.app.config.commit() 161 162 @override 163 def on_app_running(self) -> None: 164 # Load up our plugins and go ahead and call their on_app_running 165 # calls. 166 self.load_plugins() 167 for plugin in self.active_plugins: 168 try: 169 plugin.on_app_running() 170 except Exception: 171 from babase import _error 172 173 _error.print_exception('Error in plugin on_app_running()') 174 175 @override 176 def on_app_suspend(self) -> None: 177 for plugin in self.active_plugins: 178 try: 179 plugin.on_app_suspend() 180 except Exception: 181 from babase import _error 182 183 _error.print_exception('Error in plugin on_app_suspend()') 184 185 @override 186 def on_app_unsuspend(self) -> None: 187 for plugin in self.active_plugins: 188 try: 189 plugin.on_app_unsuspend() 190 except Exception: 191 from babase import _error 192 193 _error.print_exception('Error in plugin on_app_unsuspend()') 194 195 @override 196 def on_app_shutdown(self) -> None: 197 for plugin in self.active_plugins: 198 try: 199 plugin.on_app_shutdown() 200 except Exception: 201 from babase import _error 202 203 _error.print_exception('Error in plugin on_app_shutdown()') 204 205 @override 206 def on_app_shutdown_complete(self) -> None: 207 for plugin in self.active_plugins: 208 try: 209 plugin.on_app_shutdown_complete() 210 except Exception: 211 from babase import _error 212 213 _error.print_exception( 214 'Error in plugin on_app_shutdown_complete()' 215 ) 216 217 def load_plugins(self) -> None: 218 """(internal)""" 219 220 # Load plugins from any specs that are enabled & able to. 221 for _class_path, plug_spec in sorted(self.plugin_specs.items()): 222 plugin = plug_spec.attempt_load_if_enabled() 223 if plugin is not None: 224 self.active_plugins.append(plugin)
Subsystem for plugin handling in the app.
Category: App Classes
Access the single shared instance of this class at ba.app.plugins
.
45 def on_meta_scan_complete(self) -> None: 46 """Called when meta-scanning is complete.""" 47 from babase._language import Lstr 48 49 config_changed = False 50 found_new = False 51 plugstates: dict[str, dict] = _babase.app.config.setdefault( 52 'Plugins', {} 53 ) 54 assert isinstance(plugstates, dict) 55 56 results = _babase.app.meta.scanresults 57 assert results is not None 58 59 auto_enable_new_plugins = ( 60 _babase.app.config.get( 61 self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY, 62 self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT, 63 ) 64 is True 65 ) 66 67 assert not self.plugin_specs 68 assert not self.active_plugins 69 70 # Create a plugin-spec for each plugin class we found in the 71 # meta-scan. 72 for class_path in results.exports_of_class(Plugin): 73 assert class_path not in self.plugin_specs 74 plugspec = self.plugin_specs[class_path] = PluginSpec( 75 class_path=class_path, loadable=True 76 ) 77 78 # Auto-enable new ones if desired. 79 if auto_enable_new_plugins: 80 if class_path not in plugstates: 81 plugspec.enabled = True 82 config_changed = True 83 found_new = True 84 85 # If we're *not* auto-enabling, simply let the user know if we 86 # found new ones. 87 if found_new and not auto_enable_new_plugins: 88 _babase.screenmessage( 89 Lstr(resource='pluginsDetectedText'), color=(0, 1, 0) 90 ) 91 _babase.getsimplesound('ding').play() 92 93 # Ok, now go through all plugins registered in the app-config 94 # that weren't covered by the meta stuff above, either creating 95 # plugin-specs for them or clearing them out. This covers 96 # plugins with api versions not matching ours, plugins without 97 # ba_meta tags, and plugins that have since disappeared. 98 assert isinstance(plugstates, dict) 99 wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules] 100 101 disappeared_plugs: set[str] = set() 102 103 for class_path in sorted(plugstates.keys()): 104 # Already have a spec for it; nothing to be done. 105 if class_path in self.plugin_specs: 106 continue 107 108 # If this plugin corresponds to any modules that we've 109 # identified as having incorrect api versions, we'll take 110 # note of its existence but we won't try to load it. 111 if any( 112 class_path.startswith(prefix) for prefix in wrong_api_prefixes 113 ): 114 plugspec = self.plugin_specs[class_path] = PluginSpec( 115 class_path=class_path, loadable=False 116 ) 117 continue 118 119 # Ok, it seems to be a class we have no metadata for. Look 120 # to see if it appears to be an actual class we could 121 # theoretically load. If so, we'll try. If not, we consider 122 # the plugin to have disappeared and inform the user as 123 # such. 124 try: 125 spec = importlib.util.find_spec( 126 '.'.join(class_path.split('.')[:-1]) 127 ) 128 except Exception: 129 spec = None 130 131 if spec is None: 132 disappeared_plugs.add(class_path) 133 continue 134 135 # If plugins disappeared, let the user know gently and remove 136 # them from the config so we'll again let the user know if they 137 # later reappear. This makes it much smoother to switch between 138 # users or workspaces. 139 if disappeared_plugs: 140 _babase.getsimplesound('shieldDown').play() 141 _babase.screenmessage( 142 Lstr( 143 resource='pluginsRemovedText', 144 subs=[('${NUM}', str(len(disappeared_plugs)))], 145 ), 146 color=(1, 1, 0), 147 ) 148 149 plugnames = ', '.join(disappeared_plugs) 150 logging.info( 151 '%d plugin(s) no longer found: %s.', 152 len(disappeared_plugs), 153 plugnames, 154 ) 155 for goneplug in disappeared_plugs: 156 del _babase.app.config['Plugins'][goneplug] 157 _babase.app.config.commit() 158 159 if config_changed: 160 _babase.app.config.commit()
Called when meta-scanning is complete.
162 @override 163 def on_app_running(self) -> None: 164 # Load up our plugins and go ahead and call their on_app_running 165 # calls. 166 self.load_plugins() 167 for plugin in self.active_plugins: 168 try: 169 plugin.on_app_running() 170 except Exception: 171 from babase import _error 172 173 _error.print_exception('Error in plugin on_app_running()')
Called when the app reaches the running state.
175 @override 176 def on_app_suspend(self) -> None: 177 for plugin in self.active_plugins: 178 try: 179 plugin.on_app_suspend() 180 except Exception: 181 from babase import _error 182 183 _error.print_exception('Error in plugin on_app_suspend()')
Called when the app enters the suspended state.
185 @override 186 def on_app_unsuspend(self) -> None: 187 for plugin in self.active_plugins: 188 try: 189 plugin.on_app_unsuspend() 190 except Exception: 191 from babase import _error 192 193 _error.print_exception('Error in plugin on_app_unsuspend()')
Called when the app exits the suspended state.
195 @override 196 def on_app_shutdown(self) -> None: 197 for plugin in self.active_plugins: 198 try: 199 plugin.on_app_shutdown() 200 except Exception: 201 from babase import _error 202 203 _error.print_exception('Error in plugin on_app_shutdown()')
Called when the app begins shutting down.
205 @override 206 def on_app_shutdown_complete(self) -> None: 207 for plugin in self.active_plugins: 208 try: 209 plugin.on_app_shutdown_complete() 210 except Exception: 211 from babase import _error 212 213 _error.print_exception( 214 'Error in plugin on_app_shutdown_complete()' 215 )
Called when the app completes shutting down.
Inherited Members
227class PluginSpec: 228 """Represents a plugin the engine knows about. 229 230 Category: **App Classes** 231 232 The 'enabled' attr represents whether this plugin is set to load. 233 Getting or setting that attr affects the corresponding app-config 234 key. Remember to commit the app-config after making any changes. 235 236 The 'attempted_load' attr will be True if the engine has attempted 237 to load the plugin. If 'attempted_load' is True for a PluginSpec 238 but the 'plugin' attr is None, it means there was an error loading 239 the plugin. If a plugin's api-version does not match the running 240 app, if a new plugin is detected with auto-enable-plugins disabled, 241 or if the user has explicitly disabled a plugin, the engine will not 242 even attempt to load it. 243 """ 244 245 def __init__(self, class_path: str, loadable: bool): 246 self.class_path = class_path 247 self.loadable = loadable 248 self.attempted_load = False 249 self.plugin: Plugin | None = None 250 251 @property 252 def enabled(self) -> bool: 253 """Whether the user wants this plugin to load.""" 254 plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {}) 255 assert isinstance(plugstates, dict) 256 val = plugstates.get(self.class_path, {}).get('enabled', False) is True 257 return val 258 259 @enabled.setter 260 def enabled(self, val: bool) -> None: 261 plugstates: dict[str, dict] = _babase.app.config.setdefault( 262 'Plugins', {} 263 ) 264 assert isinstance(plugstates, dict) 265 plugstate = plugstates.setdefault(self.class_path, {}) 266 plugstate['enabled'] = val 267 268 def attempt_load_if_enabled(self) -> Plugin | None: 269 """Possibly load the plugin and log any errors.""" 270 from babase._general import getclass 271 from babase._language import Lstr 272 273 assert not self.attempted_load 274 assert self.plugin is None 275 276 if not self.enabled: 277 return None 278 self.attempted_load = True 279 if not self.loadable: 280 return None 281 try: 282 cls = getclass(self.class_path, Plugin, True) 283 except Exception as exc: 284 _babase.getsimplesound('error').play() 285 _babase.screenmessage( 286 Lstr( 287 resource='pluginClassLoadErrorText', 288 subs=[ 289 ('${PLUGIN}', self.class_path), 290 ('${ERROR}', str(exc)), 291 ], 292 ), 293 color=(1, 0, 0), 294 ) 295 logging.exception( 296 "Error loading plugin class '%s'.", self.class_path 297 ) 298 return None 299 try: 300 self.plugin = cls() 301 return self.plugin 302 except Exception as exc: 303 from babase import _error 304 305 _babase.getsimplesound('error').play() 306 _babase.screenmessage( 307 Lstr( 308 resource='pluginInitErrorText', 309 subs=[ 310 ('${PLUGIN}', self.class_path), 311 ('${ERROR}', str(exc)), 312 ], 313 ), 314 color=(1, 0, 0), 315 ) 316 logging.exception( 317 "Error initing plugin class: '%s'.", self.class_path 318 ) 319 return None
Represents a plugin the engine knows about.
Category: App Classes
The 'enabled' attr represents whether this plugin is set to load. Getting or setting that attr affects the corresponding app-config key. Remember to commit the app-config after making any changes.
The 'attempted_load' attr will be True if the engine has attempted to load the plugin. If 'attempted_load' is True for a PluginSpec but the 'plugin' attr is None, it means there was an error loading the plugin. If a plugin's api-version does not match the running app, if a new plugin is detected with auto-enable-plugins disabled, or if the user has explicitly disabled a plugin, the engine will not even attempt to load it.
251 @property 252 def enabled(self) -> bool: 253 """Whether the user wants this plugin to load.""" 254 plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {}) 255 assert isinstance(plugstates, dict) 256 val = plugstates.get(self.class_path, {}).get('enabled', False) is True 257 return val
Whether the user wants this plugin to load.
268 def attempt_load_if_enabled(self) -> Plugin | None: 269 """Possibly load the plugin and log any errors.""" 270 from babase._general import getclass 271 from babase._language import Lstr 272 273 assert not self.attempted_load 274 assert self.plugin is None 275 276 if not self.enabled: 277 return None 278 self.attempted_load = True 279 if not self.loadable: 280 return None 281 try: 282 cls = getclass(self.class_path, Plugin, True) 283 except Exception as exc: 284 _babase.getsimplesound('error').play() 285 _babase.screenmessage( 286 Lstr( 287 resource='pluginClassLoadErrorText', 288 subs=[ 289 ('${PLUGIN}', self.class_path), 290 ('${ERROR}', str(exc)), 291 ], 292 ), 293 color=(1, 0, 0), 294 ) 295 logging.exception( 296 "Error loading plugin class '%s'.", self.class_path 297 ) 298 return None 299 try: 300 self.plugin = cls() 301 return self.plugin 302 except Exception as exc: 303 from babase import _error 304 305 _babase.getsimplesound('error').play() 306 _babase.screenmessage( 307 Lstr( 308 resource='pluginInitErrorText', 309 subs=[ 310 ('${PLUGIN}', self.class_path), 311 ('${ERROR}', str(exc)), 312 ], 313 ), 314 color=(1, 0, 0), 315 ) 316 logging.exception( 317 "Error initing plugin class: '%s'.", self.class_path 318 ) 319 return None
Possibly load the plugin and log any errors.
163def print_error(err_str: str, once: bool = False) -> None: 164 """Print info about an error along with pertinent context state. 165 166 Category: **General Utility Functions** 167 168 Prints all positional arguments provided along with various info about the 169 current context. 170 Pass the keyword 'once' as True if you want the call to only happen 171 one time from an exact calling location. 172 """ 173 import traceback 174 175 try: 176 # If we're only printing once and already have, bail. 177 if once: 178 if not _babase.do_once(): 179 return 180 181 print('ERROR:', err_str) 182 _babase.print_context() 183 184 # Basically the output of traceback.print_stack() 185 stackstr = ''.join(traceback.format_stack()) 186 print(stackstr, end='') 187 except Exception: 188 print('ERROR: exception in babase.print_error():') 189 traceback.print_exc()
Print info about an error along with pertinent context state.
Category: General Utility Functions
Prints all positional arguments provided along with various info about the current context. Pass the keyword 'once' as True if you want the call to only happen one time from an exact calling location.
122def print_exception(*args: Any, **keywds: Any) -> None: 123 """Print info about an exception along with pertinent context state. 124 125 Category: **General Utility Functions** 126 127 Prints all arguments provided along with various info about the 128 current context and the outstanding exception. 129 Pass the keyword 'once' as True if you want the call to only happen 130 one time from an exact calling location. 131 """ 132 import traceback 133 134 if keywds: 135 allowed_keywds = ['once'] 136 if any(keywd not in allowed_keywds for keywd in keywds): 137 raise TypeError('invalid keyword(s)') 138 try: 139 # If we're only printing once and already have, bail. 140 if keywds.get('once', False): 141 if not _babase.do_once(): 142 return 143 144 err_str = ' '.join([str(a) for a in args]) 145 print('ERROR:', err_str) 146 _babase.print_context() 147 print('PRINTED-FROM:') 148 149 # Basically the output of traceback.print_stack() 150 stackstr = ''.join(traceback.format_stack()) 151 print(stackstr, end='') 152 print('EXCEPTION:') 153 154 # Basically the output of traceback.print_exc() 155 excstr = traceback.format_exc() 156 print('\n'.join(' ' + l for l in excstr.splitlines())) 157 except Exception: 158 # I suppose using print_exception here would be a bad idea. 159 print('ERROR: exception in babase.print_exception():') 160 traceback.print_exc()
Print info about an exception along with pertinent context state.
Category: General Utility Functions
Prints all arguments provided along with various info about the current context and the outstanding exception. Pass the keyword 'once' as True if you want the call to only happen one time from an exact calling location.
1378def pushcall( 1379 call: Callable, 1380 from_other_thread: bool = False, 1381 suppress_other_thread_warning: bool = False, 1382 other_thread_use_fg_context: bool = False, 1383 raw: bool = False, 1384) -> None: 1385 """Push a call to the logic event-loop. 1386 Category: **General Utility Functions** 1387 1388 This call expects to be used in the logic thread, and will automatically 1389 save and restore the babase.Context to behave seamlessly. 1390 1391 If you want to push a call from outside of the logic thread, 1392 however, you can pass 'from_other_thread' as True. In this case 1393 the call will always run in the UI context_ref on the logic thread 1394 or whichever context_ref is in the foreground if 1395 other_thread_use_fg_context is True. 1396 Passing raw=True will disable thread checks and context_ref sets/restores. 1397 """ 1398 return None
Push a call to the logic event-loop. Category: General Utility Functions
This call expects to be used in the logic thread, and will automatically save and restore the babase.Context to behave seamlessly.
If you want to push a call from outside of the logic thread, however, you can pass 'from_other_thread' as True. In this case the call will always run in the UI context_ref on the logic thread or whichever context_ref is in the foreground if other_thread_use_fg_context is True. Passing raw=True will disable thread checks and context_ref sets/restores.
1402def quit( 1403 confirm: bool = False, quit_type: babase.QuitType | None = None 1404) -> None: 1405 """Quit the app. 1406 1407 Category: **General Utility Functions** 1408 1409 If 'confirm' is True, a confirm dialog will be presented if conditions 1410 allow; otherwise the quit will still be immediate. 1411 See docs for babase.QuitType for explanations of the optional 1412 'quit_type' arg. 1413 """ 1414 return None
Quit the app.
Category: General Utility Functions
If 'confirm' is True, a confirm dialog will be presented if conditions allow; otherwise the quit will still be immediate. See docs for babase.QuitType for explanations of the optional 'quit_type' arg.
42class QuitType(Enum): 43 """Types of input a controller can send to the game. 44 45 Category: Enums 46 47 'soft' may hide/reset the app but keep the process running, depending 48 on the platform. 49 50 'back' is a variant of 'soft' which may give 'back-button-pressed' 51 behavior depending on the platform. (returning to some previous 52 activity instead of dumping to the home screen, etc.) 53 54 'hard' leads to the process exiting. This generally should be avoided 55 on platforms such as mobile. 56 """ 57 58 SOFT = 0 59 BACK = 1 60 HARD = 2
Types of input a controller can send to the game.
Category: Enums
'soft' may hide/reset the app but keep the process running, depending on the platform.
'back' is a variant of 'soft' which may give 'back-button-pressed' behavior depending on the platform. (returning to some previous activity instead of dumping to the home screen, etc.)
'hard' leads to the process exiting. This generally should be avoided on platforms such as mobile.
Inherited Members
- enum.Enum
- name
- value
1452def safecolor( 1453 color: Sequence[float], target_intensity: float = 0.6 1454) -> tuple[float, ...]: 1455 """Given a color tuple, return a color safe to display as text. 1456 1457 Category: **General Utility Functions** 1458 1459 Accepts tuples of length 3 or 4. This will slightly brighten very 1460 dark colors, etc. 1461 """ 1462 return (0.0, 0.0, 0.0)
Given a color tuple, return a color safe to display as text.
Category: General Utility Functions
Accepts tuples of length 3 or 4. This will slightly brighten very dark colors, etc.
1465def screenmessage( 1466 message: str | babase.Lstr, 1467 color: Sequence[float] | None = None, 1468 log: bool = False, 1469) -> None: 1470 """Print a message to the local client's screen, in a given color. 1471 1472 Category: **General Utility Functions** 1473 1474 Note that this version of the function is purely for local display. 1475 To broadcast screen messages in network play, look for methods such as 1476 broadcastmessage() provided by the scene-version packages. 1477 """ 1478 return None
Print a message to the local client's screen, in a given color.
Category: General Utility Functions
Note that this version of the function is purely for local display. To broadcast screen messages in network play, look for methods such as broadcastmessage() provided by the scene-version packages.
96class SessionNotFoundError(NotFoundError): 97 """Exception raised when an expected session does not exist. 98 99 Category: **Exception Classes** 100 """
Exception raised when an expected session does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
40class SessionPlayerNotFoundError(NotFoundError): 41 """Exception raised when an expected session-player does not exist. 42 43 Category: **Exception Classes** 44 """
Exception raised when an expected session-player does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
68class SessionTeamNotFoundError(NotFoundError): 69 """Exception raised when an expected session-team does not exist. 70 71 Category: **Exception Classes** 72 """
Exception raised when an expected session-team does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
1481def set_analytics_screen(screen: str) -> None: 1482 """Used for analytics to see where in the app players spend their time. 1483 1484 Category: **General Utility Functions** 1485 1486 Generally called when opening a new window or entering some UI. 1487 'screen' should be a string description of an app location 1488 ('Main Menu', etc.) 1489 """ 1490 return None
Used for analytics to see where in the app players spend their time.
Category: General Utility Functions
Generally called when opening a new window or entering some UI. 'screen' should be a string description of an app location ('Main Menu', etc.)
383class SimpleSound: 384 """A simple sound wrapper for internal use. 385 386 Do not use for gameplay code as it will only play locally. 387 """ 388 389 def play(self) -> None: 390 """Play the sound locally.""" 391 return None
A simple sound wrapper for internal use.
Do not use for gameplay code as it will only play locally.
98class SpecialChar(Enum): 99 """Special characters the game can print. 100 101 Category: Enums 102 """ 103 104 DOWN_ARROW = 0 105 UP_ARROW = 1 106 LEFT_ARROW = 2 107 RIGHT_ARROW = 3 108 TOP_BUTTON = 4 109 LEFT_BUTTON = 5 110 RIGHT_BUTTON = 6 111 BOTTOM_BUTTON = 7 112 DELETE = 8 113 SHIFT = 9 114 BACK = 10 115 LOGO_FLAT = 11 116 REWIND_BUTTON = 12 117 PLAY_PAUSE_BUTTON = 13 118 FAST_FORWARD_BUTTON = 14 119 DPAD_CENTER_BUTTON = 15 120 PLAY_STATION_CROSS_BUTTON = 16 121 PLAY_STATION_CIRCLE_BUTTON = 17 122 PLAY_STATION_TRIANGLE_BUTTON = 18 123 PLAY_STATION_SQUARE_BUTTON = 19 124 PLAY_BUTTON = 20 125 PAUSE_BUTTON = 21 126 OUYA_BUTTON_O = 22 127 OUYA_BUTTON_U = 23 128 OUYA_BUTTON_Y = 24 129 OUYA_BUTTON_A = 25 130 TOKEN = 26 131 LOGO = 27 132 TICKET = 28 133 GOOGLE_PLAY_GAMES_LOGO = 29 134 GAME_CENTER_LOGO = 30 135 DICE_BUTTON1 = 31 136 DICE_BUTTON2 = 32 137 DICE_BUTTON3 = 33 138 DICE_BUTTON4 = 34 139 GAME_CIRCLE_LOGO = 35 140 PARTY_ICON = 36 141 TEST_ACCOUNT = 37 142 TICKET_BACKING = 38 143 TROPHY1 = 39 144 TROPHY2 = 40 145 TROPHY3 = 41 146 TROPHY0A = 42 147 TROPHY0B = 43 148 TROPHY4 = 44 149 LOCAL_ACCOUNT = 45 150 EXPLODINARY_LOGO = 46 151 FLAG_UNITED_STATES = 47 152 FLAG_MEXICO = 48 153 FLAG_GERMANY = 49 154 FLAG_BRAZIL = 50 155 FLAG_RUSSIA = 51 156 FLAG_CHINA = 52 157 FLAG_UNITED_KINGDOM = 53 158 FLAG_CANADA = 54 159 FLAG_INDIA = 55 160 FLAG_JAPAN = 56 161 FLAG_FRANCE = 57 162 FLAG_INDONESIA = 58 163 FLAG_ITALY = 59 164 FLAG_SOUTH_KOREA = 60 165 FLAG_NETHERLANDS = 61 166 FEDORA = 62 167 HAL = 63 168 CROWN = 64 169 YIN_YANG = 65 170 EYE_BALL = 66 171 SKULL = 67 172 HEART = 68 173 DRAGON = 69 174 HELMET = 70 175 MUSHROOM = 71 176 NINJA_STAR = 72 177 VIKING_HELMET = 73 178 MOON = 74 179 SPIDER = 75 180 FIREBALL = 76 181 FLAG_UNITED_ARAB_EMIRATES = 77 182 FLAG_QATAR = 78 183 FLAG_EGYPT = 79 184 FLAG_KUWAIT = 80 185 FLAG_ALGERIA = 81 186 FLAG_SAUDI_ARABIA = 82 187 FLAG_MALAYSIA = 83 188 FLAG_CZECH_REPUBLIC = 84 189 FLAG_AUSTRALIA = 85 190 FLAG_SINGAPORE = 86 191 OCULUS_LOGO = 87 192 STEAM_LOGO = 88 193 NVIDIA_LOGO = 89 194 FLAG_IRAN = 90 195 FLAG_POLAND = 91 196 FLAG_ARGENTINA = 92 197 FLAG_PHILIPPINES = 93 198 FLAG_CHILE = 94 199 MIKIROG = 95 200 V2_LOGO = 96
Special characters the game can print.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
334def storagename(suffix: str | None = None) -> str: 335 """Generate a unique name for storing class data in shared places. 336 337 Category: **General Utility Functions** 338 339 This consists of a leading underscore, the module path at the 340 call site with dots replaced by underscores, the containing class's 341 qualified name, and the provided suffix. When storing data in public 342 places such as 'customdata' dicts, this minimizes the chance of 343 collisions with other similarly named classes. 344 345 Note that this will function even if called in the class definition. 346 347 ##### Examples 348 Generate a unique name for storage purposes: 349 >>> class MyThingie: 350 ... # This will give something like 351 ... # '_mymodule_submodule_mythingie_data'. 352 ... _STORENAME = babase.storagename('data') 353 ... 354 ... # Use that name to store some data in the Activity we were 355 ... # passed. 356 ... def __init__(self, activity): 357 ... activity.customdata[self._STORENAME] = {} 358 """ 359 frame = inspect.currentframe() 360 if frame is None: 361 raise RuntimeError('Cannot get current stack frame.') 362 fback = frame.f_back 363 364 # Note: We need to explicitly clear frame here to avoid a ref-loop 365 # that keeps all function-dicts in the stack alive until the next 366 # full GC cycle (the stack frame refers to this function's dict, 367 # which refers to the stack frame). 368 del frame 369 370 if fback is None: 371 raise RuntimeError('Cannot get parent stack frame.') 372 modulepath = fback.f_globals.get('__name__') 373 if modulepath is None: 374 raise RuntimeError('Cannot get parent stack module path.') 375 assert isinstance(modulepath, str) 376 qualname = fback.f_locals.get('__qualname__') 377 if qualname is not None: 378 assert isinstance(qualname, str) 379 fullpath = f'_{modulepath}_{qualname.lower()}' 380 else: 381 fullpath = f'_{modulepath}' 382 if suffix is not None: 383 fullpath = f'{fullpath}_{suffix}' 384 return fullpath.replace('.', '_')
Generate a unique name for storing class data in shared places.
Category: General Utility Functions
This consists of a leading underscore, the module path at the call site with dots replaced by underscores, the containing class's qualified name, and the provided suffix. When storing data in public places such as 'customdata' dicts, this minimizes the chance of collisions with other similarly named classes.
Note that this will function even if called in the class definition.
Examples
Generate a unique name for storage purposes:
>>> class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'.
... _STORENAME = babase.storagename('data')
...
... # Use that name to store some data in the Activity we were
... # passed.
... def __init__(self, activity):
... activity.customdata[self._STORENAME] = {}
32class StringEditAdapter: 33 """Represents a string editing operation on some object. 34 35 Editable objects such as text widgets or in-app-consoles can 36 subclass this to make their contents editable on all platforms. 37 38 There can only be one string-edit at a time for the app. New 39 StringEdits will attempt to register themselves as the globally 40 active one in their constructor, but this may not succeed. When 41 creating a StringEditAdapter, always check its 'is_valid()' value after 42 creating it. If this is False, it was not able to set itself as 43 the global active one and should be discarded. 44 """ 45 46 def __init__( 47 self, 48 description: str, 49 initial_text: str, 50 max_length: int | None, 51 screen_space_center: tuple[float, float] | None, 52 ) -> None: 53 if not _babase.in_logic_thread(): 54 raise RuntimeError('This must be called from the logic thread.') 55 56 self.create_time = time.monotonic() 57 58 # Note: these attr names are hard-coded in C++ code so don't 59 # change them willy-nilly. 60 self.description = description 61 self.initial_text = initial_text 62 self.max_length = max_length 63 self.screen_space_center = screen_space_center 64 65 # Attempt to register ourself as the active edit. 66 subsys = _babase.app.stringedit 67 current_edit = subsys.active_adapter() 68 if current_edit is None or current_edit.can_be_replaced(): 69 subsys.active_adapter = weakref.ref(self) 70 71 @final 72 def can_be_replaced(self) -> bool: 73 """Return whether this adapter can be replaced by a new one. 74 75 This is mainly a safeguard to allow adapters whose drivers have 76 gone away without calling apply or cancel to time out and be 77 replaced with new ones. 78 """ 79 if not _babase.in_logic_thread(): 80 raise RuntimeError('This must be called from the logic thread.') 81 82 # Allow ourself to be replaced after a bit. 83 if time.monotonic() - self.create_time > 5.0: 84 if _babase.do_once(): 85 logging.warning( 86 'StringEditAdapter can_be_replaced() check for %s' 87 ' yielding True due to timeout; ideally this should' 88 ' not be possible as the StringEditAdapter driver' 89 ' should be blocking anything else from kicking off' 90 ' new edits.', 91 self, 92 ) 93 return True 94 95 # We also are always considered replaceable if we're not the 96 # active global adapter. 97 current_edit = _babase.app.stringedit.active_adapter() 98 if current_edit is not self: 99 return True 100 101 return False 102 103 @final 104 def apply(self, new_text: str) -> None: 105 """Should be called by the owner when editing is complete. 106 107 Note that in some cases this call may be a no-op (such as if 108 this StringEditAdapter is no longer the globally active one). 109 """ 110 if not _babase.in_logic_thread(): 111 raise RuntimeError('This must be called from the logic thread.') 112 113 # Make sure whoever is feeding this adapter is honoring max-length. 114 if self.max_length is not None and len(new_text) > self.max_length: 115 logging.warning( 116 'apply() on %s was passed a string of length %d,' 117 ' but adapter max_length is %d; this should not happen' 118 ' (will truncate).', 119 self, 120 len(new_text), 121 self.max_length, 122 stack_info=True, 123 ) 124 new_text = new_text[: self.max_length] 125 126 self._do_apply(new_text) 127 128 @final 129 def cancel(self) -> None: 130 """Should be called by the owner when editing is cancelled.""" 131 if not _babase.in_logic_thread(): 132 raise RuntimeError('This must be called from the logic thread.') 133 self._do_cancel() 134 135 def _do_apply(self, new_text: str) -> None: 136 """Should be overridden by subclasses to handle apply. 137 138 Will always be called in the logic thread. 139 """ 140 raise NotImplementedError('Subclasses must override this.') 141 142 def _do_cancel(self) -> None: 143 """Should be overridden by subclasses to handle cancel. 144 145 Will always be called in the logic thread. 146 """ 147 raise NotImplementedError('Subclasses must override this.')
Represents a string editing operation on some object.
Editable objects such as text widgets or in-app-consoles can subclass this to make their contents editable on all platforms.
There can only be one string-edit at a time for the app. New StringEdits will attempt to register themselves as the globally active one in their constructor, but this may not succeed. When creating a StringEditAdapter, always check its 'is_valid()' value after creating it. If this is False, it was not able to set itself as the global active one and should be discarded.
46 def __init__( 47 self, 48 description: str, 49 initial_text: str, 50 max_length: int | None, 51 screen_space_center: tuple[float, float] | None, 52 ) -> None: 53 if not _babase.in_logic_thread(): 54 raise RuntimeError('This must be called from the logic thread.') 55 56 self.create_time = time.monotonic() 57 58 # Note: these attr names are hard-coded in C++ code so don't 59 # change them willy-nilly. 60 self.description = description 61 self.initial_text = initial_text 62 self.max_length = max_length 63 self.screen_space_center = screen_space_center 64 65 # Attempt to register ourself as the active edit. 66 subsys = _babase.app.stringedit 67 current_edit = subsys.active_adapter() 68 if current_edit is None or current_edit.can_be_replaced(): 69 subsys.active_adapter = weakref.ref(self)
71 @final 72 def can_be_replaced(self) -> bool: 73 """Return whether this adapter can be replaced by a new one. 74 75 This is mainly a safeguard to allow adapters whose drivers have 76 gone away without calling apply or cancel to time out and be 77 replaced with new ones. 78 """ 79 if not _babase.in_logic_thread(): 80 raise RuntimeError('This must be called from the logic thread.') 81 82 # Allow ourself to be replaced after a bit. 83 if time.monotonic() - self.create_time > 5.0: 84 if _babase.do_once(): 85 logging.warning( 86 'StringEditAdapter can_be_replaced() check for %s' 87 ' yielding True due to timeout; ideally this should' 88 ' not be possible as the StringEditAdapter driver' 89 ' should be blocking anything else from kicking off' 90 ' new edits.', 91 self, 92 ) 93 return True 94 95 # We also are always considered replaceable if we're not the 96 # active global adapter. 97 current_edit = _babase.app.stringedit.active_adapter() 98 if current_edit is not self: 99 return True 100 101 return False
Return whether this adapter can be replaced by a new one.
This is mainly a safeguard to allow adapters whose drivers have gone away without calling apply or cancel to time out and be replaced with new ones.
103 @final 104 def apply(self, new_text: str) -> None: 105 """Should be called by the owner when editing is complete. 106 107 Note that in some cases this call may be a no-op (such as if 108 this StringEditAdapter is no longer the globally active one). 109 """ 110 if not _babase.in_logic_thread(): 111 raise RuntimeError('This must be called from the logic thread.') 112 113 # Make sure whoever is feeding this adapter is honoring max-length. 114 if self.max_length is not None and len(new_text) > self.max_length: 115 logging.warning( 116 'apply() on %s was passed a string of length %d,' 117 ' but adapter max_length is %d; this should not happen' 118 ' (will truncate).', 119 self, 120 len(new_text), 121 self.max_length, 122 stack_info=True, 123 ) 124 new_text = new_text[: self.max_length] 125 126 self._do_apply(new_text)
Should be called by the owner when editing is complete.
Note that in some cases this call may be a no-op (such as if this StringEditAdapter is no longer the globally active one).
128 @final 129 def cancel(self) -> None: 130 """Should be called by the owner when editing is cancelled.""" 131 if not _babase.in_logic_thread(): 132 raise RuntimeError('This must be called from the logic thread.') 133 self._do_cancel()
Should be called by the owner when editing is cancelled.
25class StringEditSubsystem: 26 """Full string-edit state for the app.""" 27 28 def __init__(self) -> None: 29 self.active_adapter = empty_weakref(StringEditAdapter)
Full string-edit state for the app.
47class TeamNotFoundError(NotFoundError): 48 """Exception raised when an expected bascenev1.Team does not exist. 49 50 Category: **Exception Classes** 51 """
Exception raised when an expected bascenev1.Team does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args
15def timestring( 16 timeval: float | int, 17 centi: bool = True, 18) -> babase.Lstr: 19 """Generate a babase.Lstr for displaying a time value. 20 21 Category: **General Utility Functions** 22 23 Given a time value, returns a babase.Lstr with: 24 (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True). 25 26 WARNING: the underlying Lstr value is somewhat large so don't use this 27 to rapidly update Node text values for an onscreen timer or you may 28 consume significant network bandwidth. For that purpose you should 29 use a 'timedisplay' Node and attribute connections. 30 31 """ 32 from babase._language import Lstr 33 34 # We take float seconds but operate on int milliseconds internally. 35 timeval = int(1000 * timeval) 36 bits = [] 37 subs = [] 38 hval = (timeval // 1000) // (60 * 60) 39 if hval != 0: 40 bits.append('${H}') 41 subs.append( 42 ( 43 '${H}', 44 Lstr( 45 resource='timeSuffixHoursText', 46 subs=[('${COUNT}', str(hval))], 47 ), 48 ) 49 ) 50 mval = ((timeval // 1000) // 60) % 60 51 if mval != 0: 52 bits.append('${M}') 53 subs.append( 54 ( 55 '${M}', 56 Lstr( 57 resource='timeSuffixMinutesText', 58 subs=[('${COUNT}', str(mval))], 59 ), 60 ) 61 ) 62 63 # We add seconds if its non-zero *or* we haven't added anything else. 64 if centi: 65 # pylint: disable=consider-using-f-string 66 sval = timeval / 1000.0 % 60.0 67 if sval >= 0.005 or not bits: 68 bits.append('${S}') 69 subs.append( 70 ( 71 '${S}', 72 Lstr( 73 resource='timeSuffixSecondsText', 74 subs=[('${COUNT}', ('%.2f' % sval))], 75 ), 76 ) 77 ) 78 else: 79 sval = timeval // 1000 % 60 80 if sval != 0 or not bits: 81 bits.append('${S}') 82 subs.append( 83 ( 84 '${S}', 85 Lstr( 86 resource='timeSuffixSecondsText', 87 subs=[('${COUNT}', str(sval))], 88 ), 89 ) 90 ) 91 return Lstr(value=' '.join(bits), subs=subs)
Generate a babase.Lstr for displaying a time value.
Category: General Utility Functions
Given a time value, returns a babase.Lstr with: (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
WARNING: the underlying Lstr value is somewhat large so don't use this to rapidly update Node text values for an onscreen timer or you may consume significant network bandwidth. For that purpose you should use a 'timedisplay' Node and attribute connections.
63class UIScale(Enum): 64 """The overall scale the UI is being rendered for. Note that this is 65 independent of pixel resolution. For example, a phone and a desktop PC 66 might render the game at similar pixel resolutions but the size they 67 display content at will vary significantly. 68 69 Category: Enums 70 71 'large' is used for devices such as desktop PCs where fine details can 72 be clearly seen. UI elements are generally smaller on the screen 73 and more content can be seen at once. 74 75 'medium' is used for devices such as tablets, TVs, or VR headsets. 76 This mode strikes a balance between clean readability and amount of 77 content visible. 78 79 'small' is used primarily for phones or other small devices where 80 content needs to be presented as large and clear in order to remain 81 readable from an average distance. 82 """ 83 84 LARGE = 0 85 MEDIUM = 1 86 SMALL = 2
The overall scale the UI is being rendered for. Note that this is independent of pixel resolution. For example, a phone and a desktop PC might render the game at similar pixel resolutions but the size they display content at will vary significantly.
Category: Enums
'large' is used for devices such as desktop PCs where fine details can be clearly seen. UI elements are generally smaller on the screen and more content can be seen at once.
'medium' is used for devices such as tablets, TVs, or VR headsets. This mode strikes a balance between clean readability and amount of content visible.
'small' is used primarily for phones or other small devices where content needs to be presented as large and clear in order to remain readable from an average distance.
Inherited Members
- enum.Enum
- name
- value
96def utf8_all(data: Any) -> Any: 97 """Convert any unicode data in provided sequence(s) to utf8 bytes.""" 98 if isinstance(data, dict): 99 return dict( 100 (utf8_all(key), utf8_all(value)) 101 for key, value in list(data.items()) 102 ) 103 if isinstance(data, list): 104 return [utf8_all(element) for element in data] 105 if isinstance(data, tuple): 106 return tuple(utf8_all(element) for element in data) 107 if isinstance(data, str): 108 return data.encode('utf-8', errors='ignore') 109 return data
Convert any unicode data in provided sequence(s) to utf8 bytes.
394class Vec3(Sequence[float]): 395 """A vector of 3 floats. 396 397 Category: **General Utility Classes** 398 399 These can be created the following ways (checked in this order): 400 - with no args, all values are set to 0 401 - with a single numeric arg, all values are set to that value 402 - with a single three-member sequence arg, sequence values are copied 403 - otherwise assumes individual x/y/z args (positional or keywords) 404 """ 405 406 x: float 407 """The vector's X component.""" 408 409 y: float 410 """The vector's Y component.""" 411 412 z: float 413 """The vector's Z component.""" 414 415 # pylint: disable=function-redefined 416 417 @overload 418 def __init__(self) -> None: 419 pass 420 421 @overload 422 def __init__(self, value: float): 423 pass 424 425 @overload 426 def __init__(self, values: Sequence[float]): 427 pass 428 429 @overload 430 def __init__(self, x: float, y: float, z: float): 431 pass 432 433 def __init__(self, *args: Any, **kwds: Any): 434 pass 435 436 def __add__(self, other: Vec3) -> Vec3: 437 return self 438 439 def __sub__(self, other: Vec3) -> Vec3: 440 return self 441 442 @overload 443 def __mul__(self, other: float) -> Vec3: 444 return self 445 446 @overload 447 def __mul__(self, other: Sequence[float]) -> Vec3: 448 return self 449 450 def __mul__(self, other: Any) -> Any: 451 return self 452 453 @overload 454 def __rmul__(self, other: float) -> Vec3: 455 return self 456 457 @overload 458 def __rmul__(self, other: Sequence[float]) -> Vec3: 459 return self 460 461 def __rmul__(self, other: Any) -> Any: 462 return self 463 464 # (for index access) 465 @override 466 def __getitem__(self, typeargs: Any) -> Any: 467 return 0.0 468 469 @override 470 def __len__(self) -> int: 471 return 3 472 473 # (for iterator access) 474 @override 475 def __iter__(self) -> Any: 476 return self 477 478 def __next__(self) -> float: 479 return 0.0 480 481 def __neg__(self) -> Vec3: 482 return self 483 484 def __setitem__(self, index: int, val: float) -> None: 485 pass 486 487 def cross(self, other: Vec3) -> Vec3: 488 """Returns the cross product of this vector and another.""" 489 return Vec3() 490 491 def dot(self, other: Vec3) -> float: 492 """Returns the dot product of this vector and another.""" 493 return float() 494 495 def length(self) -> float: 496 """Returns the length of the vector.""" 497 return float() 498 499 def normalized(self) -> Vec3: 500 """Returns a normalized version of the vector.""" 501 return Vec3()
A vector of 3 floats.
Category: General Utility Classes
These can be created the following ways (checked in this order):
- with no args, all values are set to 0
- with a single numeric arg, all values are set to that value
- with a single three-member sequence arg, sequence values are copied
- otherwise assumes individual x/y/z args (positional or keywords)
487 def cross(self, other: Vec3) -> Vec3: 488 """Returns the cross product of this vector and another.""" 489 return Vec3()
Returns the cross product of this vector and another.
491 def dot(self, other: Vec3) -> float: 492 """Returns the dot product of this vector and another.""" 493 return float()
Returns the dot product of this vector and another.
499 def normalized(self) -> Vec3: 500 """Returns a normalized version of the vector.""" 501 return Vec3()
Returns a normalized version of the vector.
Inherited Members
- collections.abc.Sequence
- index
- count
15def vec3validate(value: Sequence[float]) -> Sequence[float]: 16 """Ensure a value is valid for use as a Vec3. 17 18 category: General Utility Functions 19 20 Raises a TypeError exception if not. 21 Valid values include any type of sequence consisting of 3 numeric values. 22 Returns the same value as passed in (but with a definite type 23 so this can be used to disambiguate 'Any' types). 24 Generally this should be used in 'if __debug__' or assert clauses 25 to keep runtime overhead minimal. 26 """ 27 from numbers import Number 28 29 if not isinstance(value, abc.Sequence): 30 raise TypeError(f"Expected a sequence; got {type(value)}") 31 if len(value) != 3: 32 raise TypeError(f"Expected a length-3 sequence (got {len(value)})") 33 if not all(isinstance(i, Number) for i in value): 34 raise TypeError(f"Non-numeric value passed for vec3: {value}") 35 return value
Ensure a value is valid for use as a Vec3.
category: General Utility Functions
Raises a TypeError exception if not. Valid values include any type of sequence consisting of 3 numeric values. Returns the same value as passed in (but with a definite type so this can be used to disambiguate 'Any' types). Generally this should be used in 'if __debug__' or assert clauses to keep runtime overhead minimal.
292def verify_object_death(obj: object) -> None: 293 """Warn if an object does not get freed within a short period. 294 295 Category: **General Utility Functions** 296 297 This can be handy to detect and prevent memory/resource leaks. 298 """ 299 300 try: 301 ref = weakref.ref(obj) 302 except Exception: 303 logging.exception('Unable to create weak-ref in verify_object_death') 304 return 305 306 # Use a slight range for our checks so they don't all land at once 307 # if we queue a lot of them. 308 delay = random.uniform(2.0, 5.5) 309 310 # Make this timer in an empty context; don't want it dying with the 311 # scene/etc. 312 with _babase.ContextRef.empty(): 313 _babase.apptimer(delay, Call(_verify_object_death, ref))
Warn if an object does not get freed within a short period.
Category: General Utility Functions
This can be handy to detect and prevent memory/resource leaks.
110class WidgetNotFoundError(NotFoundError): 111 """Exception raised when an expected widget does not exist. 112 113 Category: **Exception Classes** 114 """
Exception raised when an expected widget does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
- args