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