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