syncing with ballisitca/master

This commit is contained in:
Ayush Saini 2025-04-06 17:17:13 +05:30
parent 9c57ee13f5
commit a748699245
132 changed files with 2955 additions and 2192 deletions

View file

@ -2,10 +2,10 @@
# #
"""Common shared Ballistica components. """Common shared Ballistica components.
For modding purposes, this package should generally not be used directly. For modding purposes, this package should generally not be used
Instead one should use purpose-built packages such as bascenev1 or bauiv1 directly. Instead one should use purpose-built packages such as
which themselves import various functionality from here and reexpose it in :mod:`bascenev1` or :mod:`bauiv1` which themselves import various
a more focused way. functionality from here and reexpose it in a more focused way.
""" """
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
@ -35,6 +35,7 @@ from _babase import (
fullscreen_control_get, fullscreen_control_get,
fullscreen_control_key_shortcut, fullscreen_control_key_shortcut,
fullscreen_control_set, fullscreen_control_set,
can_display_chars,
charstr, charstr,
clipboard_get_text, clipboard_get_text,
clipboard_has_text, clipboard_has_text,
@ -64,7 +65,6 @@ from _babase import (
get_virtual_screen_size, get_virtual_screen_size,
getsimplesound, getsimplesound,
has_user_run_commands, has_user_run_commands,
have_chars,
have_permission, have_permission,
in_logic_thread, in_logic_thread,
in_main_menu, in_main_menu,
@ -123,7 +123,7 @@ from _babase import (
) )
from babase._accountv2 import AccountV2Handle, AccountV2Subsystem from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
from babase._app import App from babase._app import App, AppState
from babase._appconfig import commit_app_config from babase._appconfig import commit_app_config
from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
from babase._appmode import AppMode from babase._appmode import AppMode
@ -135,7 +135,7 @@ from babase._apputils import (
is_browser_likely_available, is_browser_likely_available,
garbage_collect, garbage_collect,
get_remote_app_name, get_remote_app_name,
AppHealthMonitor, AppHealthSubsystem,
utc_now_cloud, utc_now_cloud,
) )
from babase._cloud import CloudSubscription from babase._cloud import CloudSubscription
@ -146,8 +146,6 @@ from babase._devconsole import (
) )
from babase._emptyappmode import EmptyAppMode from babase._emptyappmode import EmptyAppMode
from babase._error import ( from babase._error import (
print_exception,
print_error,
ContextError, ContextError,
NotFoundError, NotFoundError,
PlayerNotFoundError, PlayerNotFoundError,
@ -164,7 +162,6 @@ from babase._error import (
DelegateNotFoundError, DelegateNotFoundError,
) )
from babase._general import ( from babase._general import (
utf8_all,
DisplayTime, DisplayTime,
AppTime, AppTime,
WeakCall, WeakCall,
@ -189,12 +186,15 @@ from babase._mgen.enums import (
) )
from babase._math import normalized_color, is_point_in_box, vec3validate from babase._math import normalized_color, is_point_in_box, vec3validate
from babase._meta import MetadataSubsystem from babase._meta import MetadataSubsystem
from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS from babase._net import (
get_ip_address_type,
DEFAULT_REQUEST_TIMEOUT_SECONDS,
NetworkSubsystem,
)
from babase._plugin import PluginSpec, Plugin, PluginSubsystem from babase._plugin import PluginSpec, Plugin, PluginSubsystem
from babase._stringedit import StringEditAdapter, StringEditSubsystem from babase._stringedit import StringEditAdapter, StringEditSubsystem
from babase._text import timestring from babase._text import timestring
_babase.app = app = App() _babase.app = app = App()
app.postinit() app.postinit()
@ -207,14 +207,14 @@ __all__ = [
'add_clean_frame_callback', 'add_clean_frame_callback',
'android_get_external_files_dir', 'android_get_external_files_dir',
'app', 'app',
'app',
'App', 'App',
'AppConfig', 'AppConfig',
'AppHealthMonitor', 'AppHealthSubsystem',
'AppIntent', 'AppIntent',
'AppIntentDefault', 'AppIntentDefault',
'AppIntentExec', 'AppIntentExec',
'AppMode', 'AppMode',
'AppState',
'app_instance_uuid', 'app_instance_uuid',
'applog', 'applog',
'appname', 'appname',
@ -233,6 +233,7 @@ __all__ = [
'fullscreen_control_get', 'fullscreen_control_get',
'fullscreen_control_key_shortcut', 'fullscreen_control_key_shortcut',
'fullscreen_control_set', 'fullscreen_control_set',
'can_display_chars',
'charstr', 'charstr',
'clipboard_get_text', 'clipboard_get_text',
'clipboard_has_text', 'clipboard_has_text',
@ -279,7 +280,6 @@ __all__ = [
'getsimplesound', 'getsimplesound',
'handle_leftover_v1_cloud_log_file', 'handle_leftover_v1_cloud_log_file',
'has_user_run_commands', 'has_user_run_commands',
'have_chars',
'have_permission', 'have_permission',
'in_logic_thread', 'in_logic_thread',
'in_main_menu', 'in_main_menu',
@ -313,6 +313,7 @@ __all__ = [
'native_review_request', 'native_review_request',
'native_review_request_supported', 'native_review_request_supported',
'native_stack_trace', 'native_stack_trace',
'NetworkSubsystem',
'NodeNotFoundError', 'NodeNotFoundError',
'normalized_color', 'normalized_color',
'NotFoundError', 'NotFoundError',
@ -327,8 +328,6 @@ __all__ = [
'Plugin', 'Plugin',
'PluginSubsystem', 'PluginSubsystem',
'PluginSpec', 'PluginSpec',
'print_error',
'print_exception',
'print_load_info', 'print_load_info',
'push_back_press', 'push_back_press',
'pushcall', 'pushcall',
@ -367,7 +366,6 @@ __all__ = [
'user_agent_string', 'user_agent_string',
'user_ran_commands', 'user_ran_commands',
'utc_now_cloud', 'utc_now_cloud',
'utf8_all',
'Vec3', 'Vec3',
'vec3validate', 'vec3validate',
'verify_object_death', 'verify_object_death',

View file

@ -25,9 +25,9 @@ logger = logging.getLogger('ba.accountv2')
class AccountV2Subsystem: class AccountV2Subsystem:
"""Subsystem for modern account handling in the app. """Subsystem for modern account handling in the app.
Category: **App Classes** Access the single shared instance of this class via the
:attr:`~baplus.PlusAppSubsystem.accounts` attr on the
Access the single shared instance of this class at 'ba.app.plus.accounts'. :class:`~baplus.PlusAppSubsystem` class.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -71,8 +71,10 @@ class AccountV2Subsystem:
self.login_adapters[adapter.login_type] = adapter self.login_adapters[adapter.login_type] = adapter
def on_app_loading(self) -> None: def on_app_loading(self) -> None:
"""Should be called at standard on_app_loading time.""" """Internal; Called at standard on_app_loading time.
:meta private:
"""
for adapter in self.login_adapters.values(): for adapter in self.login_adapters.values():
adapter.on_app_loading() adapter.on_app_loading()
@ -81,7 +83,7 @@ class AccountV2Subsystem:
Note that this does not mean these credentials have been checked Note that this does not mean these credentials have been checked
for validity; only that they exist. If/when credentials are for validity; only that they exist. If/when credentials are
validated, the 'primary' account handle will be set. validated, the :attr:`primary` account handle will be set.
""" """
raise NotImplementedError() raise NotImplementedError()
@ -97,6 +99,8 @@ class AccountV2Subsystem:
Will be called with None on log-outs and when new credentials Will be called with None on log-outs and when new credentials
are set but have not yet been verified. are set but have not yet been verified.
:meta private:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -144,15 +148,20 @@ class AccountV2Subsystem:
_babase.app.on_initial_sign_in_complete() _babase.app.on_initial_sign_in_complete()
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None: def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
"""Should be called when logins for the active account change.""" """Called when logins for the active account change.
:meta private:
"""
for adapter in self.login_adapters.values(): for adapter in self.login_adapters.values():
adapter.set_active_logins(logins) adapter.set_active_logins(logins)
def on_implicit_sign_in( def on_implicit_sign_in(
self, login_type: LoginType, login_id: str, display_name: str self, login_type: LoginType, login_id: str, display_name: str
) -> None: ) -> None:
"""An implicit sign-in happened (called by native layer).""" """An implicit sign-in happened (called by native layer).
:meta private:
"""
from babase._login import LoginAdapter from babase._login import LoginAdapter
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -165,17 +174,22 @@ class AccountV2Subsystem:
) )
def on_implicit_sign_out(self, login_type: LoginType) -> None: def on_implicit_sign_out(self, login_type: LoginType) -> None:
"""An implicit sign-out happened (called by native layer).""" """An implicit sign-out happened (called by native layer).
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
with _babase.ContextRef.empty(): with _babase.ContextRef.empty():
self.login_adapters[login_type].set_implicit_login_state(None) self.login_adapters[login_type].set_implicit_login_state(None)
def on_no_initial_primary_account(self) -> None: def on_no_initial_primary_account(self) -> None:
"""Callback run if the app has no primary account after launch. """Internal; run if the app has no primary account after launch.
Either this callback or on_primary_account_changed will be called Either this callback or on_primary_account_changed will be
within a few seconds of app launch; the app can move forward called within a few seconds of app launch; the app can move
with the startup sequence at that point. forward with the startup sequence at that point.
:meta private:
""" """
if not self._initial_sign_in_completed: if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True self._initial_sign_in_completed = True
@ -192,13 +206,15 @@ class AccountV2Subsystem:
login_type: LoginType, login_type: LoginType,
state: LoginAdapter.ImplicitLoginState | None, state: LoginAdapter.ImplicitLoginState | None,
) -> None: ) -> None:
"""Called when implicit login state changes. """Internal; Called when implicit login state changes.
Login systems that tend to sign themselves in/out in the Login systems that tend to sign themselves in/out in the
background are considered implicit. We may choose to honor or background are considered implicit. We may choose to honor or
ignore their states, allowing the user to opt for other login ignore their states, allowing the user to opt for other login
types even if the default implicit one can't be explicitly types even if the default implicit one can't be explicitly
logged out or otherwise controlled. logged out or otherwise controlled.
:meta private:
""" """
from babase._language import Lstr from babase._language import Lstr
@ -284,11 +300,19 @@ class AccountV2Subsystem:
self._update_auto_sign_in() self._update_auto_sign_in()
def do_get_primary(self) -> AccountV2Handle | None: def do_get_primary(self) -> AccountV2Handle | None:
"""Internal - should be overridden by subclass.""" """Internal; should be overridden by subclass.
:meta private:
"""
raise NotImplementedError() raise NotImplementedError()
def set_primary_credentials(self, credentials: str | None) -> None: def set_primary_credentials(self, credentials: str | None) -> None:
"""Set credentials for the primary app account.""" """Set credentials for the primary app account.
Once credentials are set, they will be verified in the cloud
asynchronously. If verification is successful, the
:attr:`primary` attr will be set to the resulting account.
"""
raise NotImplementedError() raise NotImplementedError()
def _update_auto_sign_in(self) -> None: def _update_auto_sign_in(self) -> None:
@ -441,14 +465,23 @@ class AccountV2Subsystem:
class AccountV2Handle: class AccountV2Handle:
"""Handle for interacting with a V2 account. """Handle for interacting with a V2 account.
This class supports the 'with' statement, which is how it is This class supports the ``with`` statement, which is how it is
used with some operations such as cloud messaging. used with some operations such as cloud messaging.
""" """
#: The id of this account.
accountid: str accountid: str
#: The last known tag for this account.
tag: str tag: str
#: The name of the workspace being synced to this client.
workspacename: str | None workspacename: str | None
#: The id of the workspace being synced to this client, if any.
workspaceid: str | None workspaceid: str | None
#: Info about last known logins associated with this account.
logins: dict[LoginType, LoginInfo] logins: dict[LoginType, LoginInfo]
def __enter__(self) -> None: def __enter__(self) -> None:

View file

@ -11,7 +11,7 @@ from functools import partial
from typing import TYPE_CHECKING, TypeVar, override from typing import TYPE_CHECKING, TypeVar, override
from threading import RLock from threading import RLock
from efro.threadpool import ThreadPoolExecutorPlus from efro.threadpool import ThreadPoolExecutorEx
import _babase import _babase
from babase._language import LanguageSubsystem from babase._language import LanguageSubsystem
@ -34,7 +34,7 @@ if TYPE_CHECKING:
import babase import babase
from babase import AppIntent, AppMode, AppSubsystem from babase import AppIntent, AppMode, AppSubsystem
from babase._apputils import AppHealthMonitor from babase._apputils import AppHealthSubsystem
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__ # __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
# This section generated by batools.appmodule; do not edit. # This section generated by batools.appmodule; do not edit.
@ -49,112 +49,34 @@ T = TypeVar('T')
class App: class App:
"""A class for high level app functionality and state. """High level Ballistica app functionality and state.
Category: **App Classes** Access the single shared instance of this class via the ``app`` attr
available on various high level modules such as :mod:`babase`,
Use babase.app to access the single shared instance of this class. :mod:`bauiv1`, and :mod:`bascenev1`.
Note that properties not documented here should be considered internal
and subject to change without warning.
""" """
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
# A few things defined as non-optional values but not actually # A few things defined as non-optional values but not actually
# available until the app starts. # available until the app starts (so we need to predeclare them
# here).
#: Subsystem for wrangling plugins.
plugins: PluginSubsystem plugins: PluginSubsystem
#: Language subsystem.
lang: LanguageSubsystem lang: LanguageSubsystem
health_monitor: AppHealthMonitor
# How long we allow shutdown tasks to run before killing them. #: Subsystem for keeping tabs on app health.
# Currently the entire app hard-exits if shutdown takes 15 seconds, health: AppHealthSubsystem
# so we need to keep it under that. Staying above 10 should allow
# 10 second network timeouts to happen though. #: How long we allow shutdown tasks to run before killing them.
#: Currently the entire app hard-exits if shutdown takes 15 seconds,
#: so we need to keep it under that. Staying above 10 should allow
#: 10 second network timeouts to happen though.
SHUTDOWN_TASK_TIMEOUT_SECONDS = 12 SHUTDOWN_TASK_TIMEOUT_SECONDS = 12
class State(Enum):
"""High level state the app can be in."""
# The app has not yet begun starting and should not be used in
# any way.
NOT_STARTED = 0
# The native layer is spinning up its machinery (screens,
# renderers, etc.). Nothing should happen in the Python layer
# until this completes.
NATIVE_BOOTSTRAPPING = 1
# Python app subsystems are being inited but should not yet
# interact or do any work.
INITING = 2
# Python app subsystems are inited and interacting, but the app
# has not yet embarked on a high level course of action. It is
# doing initial account logins, workspace & asset downloads,
# etc.
LOADING = 3
# All pieces are in place and the app is now doing its thing.
RUNNING = 4
# Used on platforms such as mobile where the app basically needs
# to shut down while backgrounded. In this state, all event
# loops are suspended and all graphics and audio must cease
# completely. Be aware that the suspended state can be entered
# from any other state including NATIVE_BOOTSTRAPPING and
# SHUTTING_DOWN.
SUSPENDED = 5
# The app is shutting down. This process may involve sending
# network messages or other things that can take up to a few
# seconds, so ideally graphics and audio should remain
# functional (with fades or spinners or whatever to show
# something is happening).
SHUTTING_DOWN = 6
# The app has completed shutdown. Any code running here should
# be basically immediate.
SHUTDOWN_COMPLETE = 7
class DefaultAppModeSelector(AppModeSelector):
"""Decides which AppModes to use to handle AppIntents.
This default version is generated by the project updater based
on the 'default_app_modes' value in the projectconfig.
It is also possible to modify app mode selection behavior by
setting app.mode_selector to an instance of a custom
AppModeSelector subclass. This is a good way to go if you are
modifying app behavior dynamically via a plugin instead of
statically in a spinoff project.
"""
@override
def app_mode_for_intent(
self, intent: AppIntent
) -> type[AppMode] | None:
# pylint: disable=cyclic-import
# __DEFAULT_APP_MODE_SELECTION_BEGIN__
# This section generated by batools.appmodule; do not edit.
# Ask our default app modes to handle it.
# (generated from 'default_app_modes' in projectconfig).
import baclassic
import babase
for appmode in [
baclassic.ClassicAppMode,
babase.EmptyAppMode,
]:
if appmode.can_handle_intent(intent):
return appmode
return None
# __DEFAULT_APP_MODE_SELECTION_END__
def __init__(self) -> None: def __init__(self) -> None:
"""(internal) """(internal)
@ -168,34 +90,47 @@ class App:
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
return return
# Wrap our raw app config in our special wrapper and pass it to #: Config values for the app.
# the native layer. self.config: AppConfig = AppConfig(_babase.get_initial_app_config())
self.config = AppConfig(_babase.get_initial_app_config())
_babase.set_app_config(self.config) _babase.set_app_config(self.config)
#: Static environment values for the app.
self.env: babase.Env = _babase.Env() self.env: babase.Env = _babase.Env()
self.state = self.State.NOT_STARTED
# Default executor which can be used for misc background #: Current app state.
# processing. It should also be passed to any additional asyncio self.state: AppState = AppState.NOT_STARTED
# loops we create so that everything shares the same single set
# of worker threads. #: Default executor which can be used for misc background
self.threadpool = ThreadPoolExecutorPlus( #: processing. It should also be passed to any additional asyncio
#: loops we create so that everything shares the same single set
#: of worker threads.
self.threadpool: ThreadPoolExecutorEx = ThreadPoolExecutorEx(
thread_name_prefix='baworker', thread_name_prefix='baworker',
initializer=self._thread_pool_thread_init, initializer=self._thread_pool_thread_init,
) )
self.meta = MetadataSubsystem() #: Subsystem for wrangling metadata.
self.net = NetworkSubsystem() self.meta: MetadataSubsystem = MetadataSubsystem()
self.workspaces = WorkspaceSubsystem()
self.components = AppComponentSubsystem()
self.stringedit = StringEditSubsystem()
self.devconsole = DevConsoleSubsystem()
# This is incremented any time the app is backgrounded or #: Subsystem for network functionality.
# foregrounded; can be a simple way to determine if network data self.net: NetworkSubsystem = NetworkSubsystem()
# should be refreshed/etc.
self.fg_state = 0 #: Subsystem for wrangling workspaces.
self.workspaces: WorkspaceSubsystem = WorkspaceSubsystem()
# (not actually in use yet)
self.components: AppComponentSubsystem = AppComponentSubsystem()
#: Subsystem for wrangling text input from various sources.
self.stringedit: StringEditSubsystem = StringEditSubsystem()
#: Subsystem for wrangling the dev-console UI.
self.devconsole: DevConsoleSubsystem = DevConsoleSubsystem()
#: Incremented each time the app leaves the
#: :attr:`~babase.AppState.SUSPENDED` state. This can be a simple
#: way to determine if network data should be refreshed/etc.
self.fg_state: int = 0
self._subsystems: list[AppSubsystem] = [] self._subsystems: list[AppSubsystem] = []
self._native_bootstrapping_completed = False self._native_bootstrapping_completed = False
@ -235,9 +170,9 @@ class App:
self._subsystem_property_data: dict[str, AppSubsystem | bool] = {} self._subsystem_property_data: dict[str, AppSubsystem | bool] = {}
def postinit(self) -> None: def postinit(self) -> None:
"""Called after we've been inited and assigned to babase.app. """Called after we've been inited and assigned to ``babase.app``.
Anything that accesses babase.app as part of its init process Anything that accesses ``babase.app`` as part of its init process
must go here instead of __init__. must go here instead of __init__.
""" """
@ -267,26 +202,27 @@ class App:
@property @property
def asyncio_loop(self) -> asyncio.AbstractEventLoop: def asyncio_loop(self) -> asyncio.AbstractEventLoop:
"""The logic thread's asyncio event loop. """The logic thread's :mod:`asyncio` event-loop.
This allow async tasks to be run in the logic thread. This allows :mod:`asyncio` tasks to be run in the logic thread.
Generally you should call App.create_async_task() to schedule Generally you should call
async code to run instead of using this directly. That will :meth:`~babase.App.create_async_task()` to schedule async code
handle retaining the task and logging errors automatically. to run instead of using this directly. That will handle
Only schedule tasks onto asyncio_loop yourself when you intend retaining the task and logging errors automatically. Only
to hold on to the returned task and await its results. Releasing 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 the task reference can lead to subtle bugs such as unreported
errors and garbage-collected tasks disappearing before their errors and garbage-collected tasks disappearing before their
work is done. work is done.
Note that, at this time, the asyncio loop is encapsulated Note that, at this time, the asyncio loop is encapsulated and
and explicitly stepped by the engine's logic thread loop and explicitly stepped by the engine's logic thread loop and thus
thus things like asyncio.get_running_loop() will unintuitively things like :meth:`asyncio.get_running_loop()` will
*not* return this loop from most places in the logic thread; unintuitively *not* return this loop from most places in the
only from within a task explicitly created in this loop. logic thread; only from within a task explicitly created in this
Hopefully this situation will be improved in the future with a loop. Hopefully this situation will be improved in the future
unified event loop. with a unified event loop.
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert self._asyncio_loop is not None assert self._asyncio_loop is not None
@ -295,12 +231,12 @@ class App:
def create_async_task( def create_async_task(
self, coro: Coroutine[Any, Any, T], *, name: str | None = None self, coro: Coroutine[Any, Any, T], *, name: str | None = None
) -> None: ) -> None:
"""Create a fully managed async task. """Create a fully managed :mod:`asyncio` task.
This will automatically retain and release a reference to the 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 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 or otherwise need more control, schedule a task directly using
App.asyncio_loop. :attr:`asyncio_loop`.
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -453,7 +389,10 @@ class App:
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__ # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
def register_subsystem(self, subsystem: AppSubsystem) -> None: def register_subsystem(self, subsystem: AppSubsystem) -> None:
"""Called by the AppSubsystem class. Do not use directly.""" """Called by the AppSubsystem class. Do not use directly.
:meta private:
"""
# We only allow registering new subsystems if we've not yet # We only allow registering new subsystems if we've not yet
# reached the 'running' state. This ensures that all subsystems # reached the 'running' state. This ensures that all subsystems
@ -470,11 +409,12 @@ class App:
"""Add a task to be run on app shutdown. """Add a task to be run on app shutdown.
Note that shutdown tasks will be canceled after Note that shutdown tasks will be canceled after
App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running. :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still
running.
""" """
if ( if (
self.state is self.State.SHUTTING_DOWN self.state is AppState.SHUTTING_DOWN
or self.state is self.State.SHUTDOWN_COMPLETE or self.state is AppState.SHUTDOWN_COMPLETE
): ):
stname = self.state.name stname = self.state.name
raise RuntimeError( raise RuntimeError(
@ -485,8 +425,8 @@ class App:
def run(self) -> None: def run(self) -> None:
"""Run the app to completion. """Run the app to completion.
Note that this only works on builds where Ballistica manages Note that this only works on builds/runs where Ballistica is
its own event loop. managing its own event loop.
""" """
_babase.run_app() _babase.run_app()
@ -495,9 +435,9 @@ class App:
Intent defines what the app is trying to do at a given time. Intent defines what the app is trying to do at a given time.
This call is asynchronous; the intent switch will happen in the This call is asynchronous; the intent switch will happen in the
logic thread in the near future. If set_intent is called logic thread in the near future. If this is called repeatedly
repeatedly before the change takes place, the final intent to be before the change takes place, the final intent to be set will
set will be used. be used.
""" """
# Mark this one as pending. We do this synchronously so that the # Mark this one as pending. We do this synchronously so that the
@ -510,7 +450,10 @@ class App:
self.threadpool.submit_no_wait(self._set_intent, intent) self.threadpool.submit_no_wait(self._set_intent, intent)
def push_apply_app_config(self) -> None: def push_apply_app_config(self) -> None:
"""Internal. Use app.config.apply() to apply app config changes.""" """Internal. Use app.config.apply() to apply app config changes.
:meta private:
"""
# To be safe, let's run this by itself in the event loop. # To be safe, let's run this by itself in the event loop.
# This avoids potential trouble if this gets called mid-draw or # This avoids potential trouble if this gets called mid-draw or
# something like that. # something like that.
@ -518,47 +461,68 @@ class App:
_babase.pushcall(self._apply_app_config, raw=True) _babase.pushcall(self._apply_app_config, raw=True)
def on_native_start(self) -> None: def on_native_start(self) -> None:
"""Called by the native layer when the app is being started.""" """Called by the native layer when the app is being started.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert not self._native_start_called assert not self._native_start_called
self._native_start_called = True self._native_start_called = True
self._update_state() self._update_state()
def on_native_bootstrapping_complete(self) -> None: def on_native_bootstrapping_complete(self) -> None:
"""Called by the native layer once its ready to rock.""" """Called by the native layer once its ready to rock.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert not self._native_bootstrapping_completed assert not self._native_bootstrapping_completed
self._native_bootstrapping_completed = True self._native_bootstrapping_completed = True
self._update_state() self._update_state()
def on_native_suspend(self) -> None: def on_native_suspend(self) -> None:
"""Called by the native layer when the app is suspended.""" """Called by the native layer when the app is suspended.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert not self._native_suspended # Should avoid redundant calls. assert not self._native_suspended # Should avoid redundant calls.
self._native_suspended = True self._native_suspended = True
self._update_state() self._update_state()
def on_native_unsuspend(self) -> None: def on_native_unsuspend(self) -> None:
"""Called by the native layer when the app suspension ends.""" """Called by the native layer when the app suspension ends.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert self._native_suspended # Should avoid redundant calls. assert self._native_suspended # Should avoid redundant calls.
self._native_suspended = False self._native_suspended = False
self._update_state() self._update_state()
def on_native_shutdown(self) -> None: def on_native_shutdown(self) -> None:
"""Called by the native layer when the app starts shutting down.""" """Called by the native layer when the app starts shutting down.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
self._native_shutdown_called = True self._native_shutdown_called = True
self._update_state() self._update_state()
def on_native_shutdown_complete(self) -> None: def on_native_shutdown_complete(self) -> None:
"""Called by the native layer when the app is done shutting down.""" """Called by the native layer when the app is done shutting down.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
self._native_shutdown_complete_called = True self._native_shutdown_complete_called = True
self._update_state() self._update_state()
def on_native_active_changed(self) -> None: def on_native_active_changed(self) -> None:
"""Called by the native layer when the app active state changes.""" """Called by the native layer when the app active state changes.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
if self._mode is not None: if self._mode is not None:
self._mode.on_app_active_changed() self._mode.on_app_active_changed()
@ -590,6 +554,8 @@ class App:
initial-sign-in process may include tasks such as syncing initial-sign-in process may include tasks such as syncing
account workspaces or other data so it may take a substantial account workspaces or other data so it may take a substantial
amount of time. amount of time.
:meta private:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert not self._initial_sign_in_completed assert not self._initial_sign_in_completed
@ -604,8 +570,11 @@ class App:
def set_ui_scale(self, scale: babase.UIScale) -> None: def set_ui_scale(self, scale: babase.UIScale) -> None:
"""Change ui-scale on the fly. """Change ui-scale on the fly.
Currently this is mainly for debugging and will not be called as Currently this is mainly for testing/debugging and will not be
part of normal app operation. called as part of normal app operation, though this may change
in the future.
:meta private:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -625,7 +594,10 @@ class App:
) )
def on_screen_size_change(self) -> None: def on_screen_size_change(self) -> None:
"""Screen size has changed.""" """Screen size has changed.
:meta private:
"""
# Inform all app subsystems in the same order they were inited. # Inform all app subsystems in the same order they were inited.
# Operate on a copy of the list here because this can be called # Operate on a copy of the list here because this can be called
@ -768,7 +740,7 @@ class App:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from babase import _asyncio from babase import _asyncio
from babase import _appconfig from babase import _appconfig
from babase._apputils import AppHealthMonitor from babase._apputils import AppHealthSubsystem
from babase import _env from babase import _env
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -776,7 +748,7 @@ class App:
_env.on_app_state_initing() _env.on_app_state_initing()
self._asyncio_loop = _asyncio.setup_asyncio() self._asyncio_loop = _asyncio.setup_asyncio()
self.health_monitor = AppHealthMonitor() self.health = AppHealthSubsystem()
# __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__ # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
# This section generated by batools.appmodule; do not edit. # This section generated by batools.appmodule; do not edit.
@ -849,7 +821,7 @@ class App:
# Set a default app-mode-selector if none has been set yet # Set a default app-mode-selector if none has been set yet
# by a plugin or whatnot. # by a plugin or whatnot.
if self._mode_selector is None: if self._mode_selector is None:
self._mode_selector = self.DefaultAppModeSelector() self._mode_selector = DefaultAppModeSelector()
# Inform all app subsystems in the same order they were # Inform all app subsystems in the same order they were
# registered. Operate on a copy here because subsystems can # registered. Operate on a copy here because subsystems can
@ -913,8 +885,8 @@ class App:
# Shutdown-complete trumps absolutely all. # Shutdown-complete trumps absolutely all.
if self._native_shutdown_complete_called: if self._native_shutdown_complete_called:
if self.state is not self.State.SHUTDOWN_COMPLETE: if self.state is not AppState.SHUTDOWN_COMPLETE:
self.state = self.State.SHUTDOWN_COMPLETE self.state = AppState.SHUTDOWN_COMPLETE
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutdown_complete() self._on_shutdown_complete()
@ -923,26 +895,26 @@ class App:
# the shutdown process. # the shutdown process.
elif self._native_shutdown_called and self._init_completed: elif self._native_shutdown_called and self._init_completed:
# Entering shutdown state: # Entering shutdown state:
if self.state is not self.State.SHUTTING_DOWN: if self.state is not AppState.SHUTTING_DOWN:
self.state = self.State.SHUTTING_DOWN self.state = AppState.SHUTTING_DOWN
applog.info('Shutting down...') applog.info('Shutting down...')
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutting_down() self._on_shutting_down()
elif self._native_suspended: elif self._native_suspended:
# Entering suspended state: # Entering suspended state:
if self.state is not self.State.SUSPENDED: if self.state is not AppState.SUSPENDED:
self.state = self.State.SUSPENDED self.state = AppState.SUSPENDED
self._on_suspend() self._on_suspend()
else: else:
# Leaving suspended state: # Leaving suspended state:
if self.state is self.State.SUSPENDED: if self.state is AppState.SUSPENDED:
self._on_unsuspend() self._on_unsuspend()
# Entering or returning to running state # Entering or returning to running state
if self._initial_sign_in_completed and self._meta_scan_completed: if self._initial_sign_in_completed and self._meta_scan_completed:
if self.state != self.State.RUNNING: if self.state != AppState.RUNNING:
self.state = self.State.RUNNING self.state = AppState.RUNNING
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_running: if not self._called_on_running:
self._called_on_running = True self._called_on_running = True
@ -950,8 +922,8 @@ class App:
# Entering or returning to loading state: # Entering or returning to loading state:
elif self._init_completed: elif self._init_completed:
if self.state is not self.State.LOADING: if self.state is not AppState.LOADING:
self.state = self.State.LOADING self.state = AppState.LOADING
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_loading: if not self._called_on_loading:
self._called_on_loading = True self._called_on_loading = True
@ -959,8 +931,8 @@ class App:
# Entering or returning to initing state: # Entering or returning to initing state:
elif self._native_bootstrapping_completed: elif self._native_bootstrapping_completed:
if self.state is not self.State.INITING: if self.state is not AppState.INITING:
self.state = self.State.INITING self.state = AppState.INITING
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_initing: if not self._called_on_initing:
self._called_on_initing = True self._called_on_initing = True
@ -968,8 +940,8 @@ class App:
# Entering or returning to native bootstrapping: # Entering or returning to native bootstrapping:
elif self._native_start_called: elif self._native_start_called:
if self.state is not self.State.NATIVE_BOOTSTRAPPING: if self.state is not AppState.NATIVE_BOOTSTRAPPING:
self.state = self.State.NATIVE_BOOTSTRAPPING self.state = AppState.NATIVE_BOOTSTRAPPING
lifecyclelog.info('app-state is now %s', self.state.name) lifecyclelog.info('app-state is now %s', self.state.name)
else: else:
# Only logical possibility left is NOT_STARTED, in which # Only logical possibility left is NOT_STARTED, in which
@ -1065,6 +1037,19 @@ class App:
"""(internal)""" """(internal)"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
# Deactivate any active app-mode. This allows things like saving
# state to happen naturally without needing to handle
# app-shutdown as a special case.
if self._mode is not None:
try:
self._mode.on_deactivate()
except Exception:
logging.exception(
'Error deactivating app-mode %s at app shutdown.',
self._mode,
)
self._mode = None
# Inform app subsystems that we're done shutting down in the opposite # Inform app subsystems that we're done shutting down in the opposite
# order they were inited. # order they were inited.
for subsystem in reversed(self._subsystems): for subsystem in reversed(self._subsystems):
@ -1124,3 +1109,86 @@ class App:
# Help keep things clear in profiling tools/etc. # Help keep things clear in profiling tools/etc.
self._pool_thread_count += 1 self._pool_thread_count += 1
_babase.set_thread_name(f'ballistica worker-{self._pool_thread_count}') _babase.set_thread_name(f'ballistica worker-{self._pool_thread_count}')
class AppState(Enum):
"""High level state the app can be in."""
#: The app has not yet begun starting and should not be used in
#: any way.
NOT_STARTED = 0
#: The native layer is spinning up its machinery (screens,
#: renderers, etc.). Nothing should happen in the Python layer
#: until this completes.
NATIVE_BOOTSTRAPPING = 1
#: Python app subsystems are being inited but should not yet
#: interact or do any work.
INITING = 2
#: Python app subsystems are inited and interacting, but the app
#: has not yet embarked on a high level course of action. It is
#: doing initial account logins, workspace & asset downloads,
#: etc.
LOADING = 3
#: All pieces are in place and the app is now doing its thing.
RUNNING = 4
#: Used on platforms such as mobile where the app basically needs
#: to shut down while backgrounded. In this state, all event
#: loops are suspended and all graphics and audio must cease
#: completely. Be aware that the suspended state can be entered
#: from any other state including :attr:`NATIVE_BOOTSTRAPPING` and
#: :attr:`SHUTTING_DOWN`.
SUSPENDED = 5
#: The app is shutting down. This process may involve sending
#: network messages or other things that can take up to a few
#: seconds, so ideally graphics and audio should remain
#: functional (with fades or spinners or whatever to show
#: something is happening).
SHUTTING_DOWN = 6
#: The app has completed shutdown. Any code running here should
#: be basically immediate.
SHUTDOWN_COMPLETE = 7
class DefaultAppModeSelector(AppModeSelector):
"""Selects an :class:`AppMode` to handle an :class:`AppIntent`.
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 the app's :attr:`~babase.App.mode_selector` to an
instance of a custom :class:`~babase.AppModeSelector` subclass.
This is a good way to go if you are modifying app behavior
dynamically via a :class:`~babase.Plugin` instead of statically
in a spinoff project.
"""
@override
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
# pylint: disable=cyclic-import
# __DEFAULT_APP_MODE_SELECTION_BEGIN__
# This section generated by batools.appmodule; do not edit.
# Ask our default app modes to handle it.
# (generated from 'default_app_modes' in projectconfig).
import baclassic
import babase
for appmode in [
baclassic.ClassicAppMode,
babase.EmptyAppMode,
]:
if appmode.can_handle_intent(intent):
return appmode
return None
# __DEFAULT_APP_MODE_SELECTION_END__

View file

@ -16,8 +16,6 @@ T = TypeVar('T', bound=type)
class AppComponentSubsystem: class AppComponentSubsystem:
"""Subsystem for wrangling AppComponents. """Subsystem for wrangling AppComponents.
Category: **App Classes**
This subsystem acts as a registry for classes providing particular This subsystem acts as a registry for classes providing particular
functionality for the app, and allows plugins or other custom code functionality for the app, and allows plugins or other custom code
to easily override said functionality. to easily override said functionality.

View file

@ -14,41 +14,42 @@ _g_pending_apply = False # pylint: disable=invalid-name
class AppConfig(dict): class AppConfig(dict):
"""A special dict that holds the game's persistent configuration values. """A special dict that holds persistent app 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.
It also provides methods for fetching values with app-defined fallback Access the single shared instance of this config via the
defaults, applying contained values to the game, and committing the :attr:`~babase.App.config` attr on the :class:`~babase.App` class.
config to storage.
Call babase.appconfig() to get the single shared instance of this class. App-config data is stored as json on disk on so make sure to only
place json-friendly values in it (``dict``, ``list``, ``str``,
AppConfig data is stored as json on disk on so make sure to only place ``float``, ``int``, ``bool``). Be aware that tuples will be quietly
json-friendly values in it (dict, list, str, float, int, bool). converted to lists when stored.
Be aware that tuples will be quietly converted to lists when stored.
""" """
def resolve(self, key: str) -> Any: def resolve(self, key: str) -> Any:
"""Given a string key, return a config value (type varies). """Given a string key, return a config value (type varies).
This will substitute application defaults for values not present in This will substitute application defaults for values not present
the config dict, filter some invalid values, etc. Note that these in the config dict, filter some invalid values, etc. Note that
values do not represent the state of the app; simply the state of its these values do not represent the state of the app; simply the
config. Use babase.App to access actual live state. state of its config. Use the :class:`~babase.App` class to
access actual live state.
Raises an Exception for unrecognized key names. To get the list of keys Raises an :class:`KeyError` for unrecognized key names. To get
supported by this method, use babase.AppConfig.builtin_keys(). Note the list of keys supported by this method, use
that it is perfectly legal to store other data in the config; it just :meth:`builtin_keys()`. Note that it is perfectly legal to store
needs to be accessed through standard dict methods and missing values other data in the config; it just needs to be accessed through
handled manually. standard dict methods and missing values handled manually.
""" """
return _babase.resolve_appconfig_value(key) return _babase.resolve_appconfig_value(key)
def default_value(self, key: str) -> Any: def default_value(self, key: str) -> Any:
"""Given a string key, return its predefined default value. """Given a string key, return its predefined default value.
This is the value that will be returned by babase.AppConfig.resolve() This is the value that will be returned by :meth:`resolve()`
if the key is not present in the config dict or of an incompatible if the key is not present in the config dict or of an incompatible
type. type.
@ -61,17 +62,19 @@ class AppConfig(dict):
return _babase.get_appconfig_default_value(key) return _babase.get_appconfig_default_value(key)
def builtin_keys(self) -> list[str]: def builtin_keys(self) -> list[str]:
"""Return the list of valid key names recognized by babase.AppConfig. """Return the list of valid key names recognized by this class.
This set of keys can be used with resolve(), default_value(), etc. This set of keys can be used with :meth:`resolve()`,
It does not vary across platforms and may include keys that are :meth:`default_value()`, etc. It does not vary across platforms
obsolete or not relevant on the current running version. (for instance, and may include keys that are obsolete or not relevant on the
VR related keys on non-VR platforms). This is to minimize the amount current running version. (for instance, VR related keys on
of platform checking necessary) 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 Note that it is perfectly legal to store arbitrary named data in
config, but in that case it is up to the user to test for the existence the config, but in that case it is up to the user to test for
of the key in the config dict, fall back to consistent defaults, etc. the existence of the key in the config dict, fall back to
consistent defaults, etc.
""" """
return _babase.get_appconfig_builtin_keys() return _babase.get_appconfig_builtin_keys()
@ -92,9 +95,10 @@ class AppConfig(dict):
commit_app_config() commit_app_config()
def apply_and_commit(self) -> None: def apply_and_commit(self) -> None:
"""Run apply() followed by commit(); for convenience. """Shortcut to run :meth:`apply()` followed by :meth:`commit()`.
(This way the commit() will not occur if apply() hits invalid data) This way the :meth:`commit()` will not occur if :meth:`apply()`
hits invalid data, which is generally desirable.
""" """
self.apply() self.apply()
self.commit() self.commit()
@ -103,9 +107,7 @@ class AppConfig(dict):
def commit_app_config() -> None: def commit_app_config() -> None:
"""Commit the config to persistent storage. """Commit the config to persistent storage.
Category: **General Utility Functions** :meta private:
(internal)
""" """
# FIXME - this should not require plus. # FIXME - this should not require plus.
plus = _babase.app.plus plus = _babase.app.plus

View file

@ -10,10 +10,7 @@ if TYPE_CHECKING:
class AppIntent: class AppIntent:
"""A high level directive given to the app. """Base class for high level directives given to the app."""
Category: **App Classes**
"""
class AppIntentDefault(AppIntent): class AppIntentDefault(AppIntent):

View file

@ -3,18 +3,19 @@
"""Provides AppMode functionality.""" """Provides AppMode functionality."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, final
if TYPE_CHECKING: if TYPE_CHECKING:
from bacommon.app import AppExperience from bacommon.app import AppExperience
from babase._appintent import AppIntent from babase import AppIntent
class AppMode: class AppMode:
"""A high level mode for the app. """A low level mode the app can be in.
Category: **App Classes**
App-modes fundamentally change app behavior related to input
handling, networking, graphics, and more. In a way, different
app-modes can almost be considered different apps.
""" """
@classmethod @classmethod
@ -22,26 +23,27 @@ class AppMode:
"""Return the overall experience provided by this mode.""" """Return the overall experience provided by this mode."""
raise NotImplementedError('AppMode subclasses must override this.') raise NotImplementedError('AppMode subclasses must override this.')
@final
@classmethod @classmethod
def can_handle_intent(cls, intent: AppIntent) -> bool: def can_handle_intent(cls, intent: AppIntent) -> bool:
"""Return whether this mode can handle the provided intent. """Return whether this mode can handle the provided intent.
For this to return True, the AppMode must claim to support the For this to return True, the app-mode must claim to support the
provided intent (via its _can_handle_intent() method) AND the provided intent (via its :meth:`can_handle_intent_impl()`
AppExperience associated with the AppMode must be supported by method) *AND* the :class:`~bacommon.app.AppExperience` associated
the current app and runtime environment. with the app-mode must be supported by the current app and
runtime environment.
""" """
# TODO: check AppExperience against current environment. # TODO: check AppExperience against current environment.
return cls._can_handle_intent(intent) return cls.can_handle_intent_impl(intent)
@classmethod @classmethod
def _can_handle_intent(cls, intent: AppIntent) -> bool: def can_handle_intent_impl(cls, intent: AppIntent) -> bool:
"""Return whether our mode can handle the provided intent. """Override this to define indent handling for an app-mode.
AppModes should override this to communicate what they can Note that :class:`~bacommon.app.AppExperience` does not have to
handle. Note that AppExperience does not have to be considered be considered here; that is handled automatically by the
here; that is handled automatically by the can_handle_intent() :meth:`can_handle_intent()` call.
call.
""" """
raise NotImplementedError('AppMode subclasses must override this.') raise NotImplementedError('AppMode subclasses must override this.')
@ -50,14 +52,101 @@ class AppMode:
raise NotImplementedError('AppMode subclasses must override this.') raise NotImplementedError('AppMode subclasses must override this.')
def on_activate(self) -> None: def on_activate(self) -> None:
"""Called when the mode is being activated.""" """Called when the mode is becoming the active one fro the app."""
def on_deactivate(self) -> None: def on_deactivate(self) -> None:
"""Called when the mode is being deactivated.""" """Called when the mode stops being the active one for the app.
On platforms where the app is explicitly exited (such as desktop
PC) this will also be called at app shutdown.
To best cover both mobile and desktop style platforms, actions
such as saving state should generally happen in response to both
:meth:`on_deactivate()` and :meth:`on_app_active_changed()`
(when active is False).
"""
def on_app_active_changed(self) -> None: def on_app_active_changed(self) -> None:
"""Called when ba*.app.active changes while this mode is active. """Called when the app's active state changes while in this app-mode.
The app-mode may want to take action such as pausing a running This corresponds to the app's :attr:`~babase.App.active` attr.
game in such cases. App-active state becomes false when the app is hidden,
minimized, backgrounded, etc. The app-mode may want to take
action such as pausing a running game or saving state when this
occurs.
On platforms such as mobile where apps get suspended and later
silently terminated by the OS, this is likely to be the last
reliable place to save state/etc.
To best cover both mobile and desktop style platforms, actions
such as saving state should generally happen in response to both
:meth:`on_deactivate()` and :meth:`on_app_active_changed()`
(when active is False).
""" """
def on_purchase_process_begin(
self, item_id: str, user_initiated: bool
) -> None:
"""Called when in-app-purchase processing is beginning.
This call happens after a purchase has been completed locally
but before its receipt/info is sent to the master-server to
apply to the account.
:meta private:
"""
# pylint: disable=cyclic-import
import babase
del item_id # Unused.
# Show nothing for stuff not directly kicked off by the user.
if not user_initiated:
return
babase.screenmessage(
babase.Lstr(resource='updatingAccountText'),
color=(0, 1, 0),
)
# Ick; we can be called early in the bootstrapping process
# before we're allowed to load assets. Guard against that.
if babase.asset_loads_allowed():
babase.getsimplesound('click01').play()
def on_purchase_process_end(
self, item_id: str, user_initiated: bool, applied: bool
) -> None:
"""Called when in-app-purchase processing completes.
Each call to :meth:`on_purchase_process_begin()` will be
followed up by a call to this method. If the purchase was found
to be valid and was applied to the account, applied will be
True. In the case of redundant or invalid purchases or
communication failures it will be False.
:meta private:
"""
# pylint: disable=cyclic-import
import babase
# Ignore this; we want to announce newly applied stuff even if
# it was from a different launch or client or whatever.
del user_initiated
# If the purchase wasn't applied, do nothing. This likely means it
# was redundant or something else harmless.
if not applied:
return
# By default just announce the item id we got. Real app-modes
# probably want to do something more specific based on item-id.
babase.screenmessage(
babase.Lstr(
translate=('serverResponses', 'You got a ${ITEM}!'),
subs=[('${ITEM}', item_id)],
),
color=(0, 1, 0),
)
if babase.asset_loads_allowed():
babase.getsimplesound('cashRegister').play()

View file

@ -6,25 +6,24 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from babase._appintent import AppIntent from babase import AppMode, AppIntent
from babase._appmode import AppMode
class AppModeSelector: class AppModeSelector:
"""Defines which AppModes are available or used to handle given AppIntents. """Defines which app-modes should handle which app-intents.
Category: **App Classes** The app calls an instance of this class when passed an
:class:`~babase.AppIntent` to determine which
The app calls an instance of this class when passed an AppIntent to :class:`~babase.AppMode` to use to handle it. Plugins or spinoff
determine which AppMode to use to handle the intent. Plugins or projects can modify high level app behavior by replacing or
spinoff projects can modify high level app behavior by replacing or modifying the app's :attr:`~babase.App.mode_selector` attr or by
modifying the app's mode-selector. modifying settings used to construct the default one.
""" """
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None: def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
"""Given an AppIntent, return the AppMode that should handle it. """Given an app-intent, return the app-mode that should handle it.
If None is returned, the AppIntent will be ignored. If None is returned, the intent will be ignored.
This may be called in a background thread, so avoid any calls This may be called in a background thread, so avoid any calls
limited to logic thread use/etc. limited to logic thread use/etc.

View file

@ -14,8 +14,6 @@ if TYPE_CHECKING:
class AppSubsystem: class AppSubsystem:
"""Base class for an app subsystem. """Base class for an app subsystem.
Category: **App Classes**
An app 'subsystem' is a bit of a vague term, as pieces of the app 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 can technically be any class and are not required to use this, but
building one out of this base class provides conveniences such as building one out of this base class provides conveniences such as
@ -37,19 +35,19 @@ class AppSubsystem:
""" """
def on_app_running(self) -> None: def on_app_running(self) -> None:
"""Called when the app reaches the running state.""" """Called when app enters :attr:`~AppState.RUNNING` state."""
def on_app_suspend(self) -> None: def on_app_suspend(self) -> None:
"""Called when the app enters the suspended state.""" """Called when app enters :attr:`~AppState.SUSPENDED` state."""
def on_app_unsuspend(self) -> None: def on_app_unsuspend(self) -> None:
"""Called when the app exits the suspended state.""" """Called when app exits :attr:`~AppState.SUSPENDED` state."""
def on_app_shutdown(self) -> None: def on_app_shutdown(self) -> None:
"""Called when the app begins shutting down.""" """Called when app enters :attr:`~AppState.SHUTTING_DOWN` state."""
def on_app_shutdown_complete(self) -> None: def on_app_shutdown_complete(self) -> None:
"""Called when the app completes shutting down.""" """Called when app enters :attr:`~AppState.SHUTDOWN_COMPLETE` state."""
def do_apply_app_config(self) -> None: def do_apply_app_config(self) -> None:
"""Called when the app config should be applied.""" """Called when the app config should be applied."""
@ -69,6 +67,6 @@ class AppSubsystem:
def reset(self) -> None: def reset(self) -> None:
"""Reset the subsystem to a default state. """Reset the subsystem to a default state.
This is called when switching app modes, but may be called This is called when switching app modes, but may be called at
at other times too. other times too.
""" """

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import gc import gc
import os import os
import logging
from threading import Thread from threading import Thread
from functools import partial from functools import partial
from dataclasses import dataclass from dataclasses import dataclass
@ -17,6 +16,7 @@ from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _babase import _babase
from babase._appsubsystem import AppSubsystem from babase._appsubsystem import AppSubsystem
from babase._logging import balog
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime import datetime
@ -39,17 +39,16 @@ def utc_now_cloud() -> datetime.datetime:
def is_browser_likely_available() -> bool: def is_browser_likely_available() -> bool:
"""Return whether a browser likely exists on the current device. """Return whether a browser likely exists on the current device.
category: General Utility Functions If this returns False, you may want to avoid calling
:meth:`~babase.open_url()` with any lengthy addresses.
If this returns False you may want to avoid calling babase.open_url() (:meth:`~babase.open_url()` will display an address as a
with any lengthy addresses. (babase.open_url() will display an address string/qr-code in a window if unable to bring up a browser, but that
as a string in a window if unable to bring up a browser, but that is only reasonable for small-ish URLs.)
is only useful for simple URLs.)
""" """
app = _babase.app app = _babase.app
if app.classic is None: if app.classic is None:
logging.warning( balog.warning(
'is_browser_likely_available() needs to be updated' 'is_browser_likely_available() needs to be updated'
' to work without classic.' ' to work without classic.'
) )
@ -70,14 +69,14 @@ def is_browser_likely_available() -> bool:
def get_remote_app_name() -> babase.Lstr: def get_remote_app_name() -> babase.Lstr:
"""(internal)""" """:meta private:"""
from babase import _language from babase import _language
return _language.Lstr(resource='remote_app.app_name') return _language.Lstr(resource='remote_app.app_name')
def should_submit_debug_info() -> bool: def should_submit_debug_info() -> bool:
"""(internal)""" """:meta private:"""
val = _babase.app.config.get('Submit Debug Info', True) val = _babase.app.config.get('Submit Debug Info', True)
assert isinstance(val, bool) assert isinstance(val, bool)
return val return val
@ -96,7 +95,7 @@ def handle_v1_cloud_log() -> None:
if classic is None or plus is None: if classic is None or plus is None:
if _babase.do_once(): if _babase.do_once():
logging.warning( balog.warning(
'handle_v1_cloud_log should not be getting called' 'handle_v1_cloud_log should not be getting called'
' without classic and plus present.' ' without classic and plus present.'
) )
@ -133,9 +132,8 @@ def handle_v1_cloud_log() -> None:
def response(data: Any) -> None: def response(data: Any) -> None:
assert classic is not None assert classic is not None
# A non-None response means success; lets # A non-None response means success; lets take note that
# take note that we don't need to report further # we don't need to report further log info this run
# log info this run
if data is not None: if data is not None:
classic.log_have_new = False classic.log_have_new = False
_babase.mark_log_sent() _babase.mark_log_sent()
@ -144,8 +142,8 @@ def handle_v1_cloud_log() -> None:
classic.log_upload_timer_started = True classic.log_upload_timer_started = True
# Delay our log upload slightly in case other # Delay our log upload slightly in case other pertinent info
# pertinent info gets printed between now and then. # gets printed between now and then.
with _babase.ContextRef.empty(): with _babase.ContextRef.empty():
_babase.apptimer(3.0, _put_log) _babase.apptimer(3.0, _put_log)
@ -162,7 +160,10 @@ def handle_v1_cloud_log() -> None:
def handle_leftover_v1_cloud_log_file() -> None: def handle_leftover_v1_cloud_log_file() -> None:
"""Handle an un-uploaded v1-cloud-log from a previous run.""" """Handle an un-uploaded v1-cloud-log from a previous run.
:meta private:
"""
# Only applies with classic present. # Only applies with classic present.
if _babase.app.classic is None: if _babase.app.classic is None:
@ -180,8 +181,8 @@ def handle_leftover_v1_cloud_log_file() -> None:
if do_send: if do_send:
def response(data: Any) -> None: def response(data: Any) -> None:
# Non-None response means we were successful; # Non-None response means we were successful; lets
# lets kill it. # kill it.
if data is not None: if data is not None:
try: try:
os.remove(_babase.get_v1_cloud_log_file_path()) os.remove(_babase.get_v1_cloud_log_file_path())
@ -197,10 +198,9 @@ def handle_leftover_v1_cloud_log_file() -> None:
else: else:
# If they don't want logs uploaded just kill it. # If they don't want logs uploaded just kill it.
os.remove(_babase.get_v1_cloud_log_file_path()) os.remove(_babase.get_v1_cloud_log_file_path())
except Exception:
from babase import _error
_error.print_exception('Error handling leftover log file.') except Exception:
balog.exception('Error handling leftover log file.')
def garbage_collect_session_end() -> None: def garbage_collect_session_end() -> None:
@ -219,6 +219,7 @@ def garbage_collect_session_end() -> None:
# running them with an explicit flag passed, but we should never # running them with an explicit flag passed, but we should never
# run them by default because gc.get_objects() can mess up the app. # run them by default because gc.get_objects() can mess up the app.
# See notes at top of efro.debug. # See notes at top of efro.debug.
# if bool(False): # if bool(False):
# print_live_object_warnings('after session shutdown') # print_live_object_warnings('after session shutdown')
@ -226,11 +227,11 @@ def garbage_collect_session_end() -> None:
def garbage_collect() -> None: def garbage_collect() -> None:
"""Run an explicit pass of garbage collection. """Run an explicit pass of garbage collection.
category: General Utility Functions
May also print warnings/etc. if collection takes too long or if May also print warnings/etc. if collection takes too long or if
uncollectible objects are found (so use this instead of simply uncollectible objects are found (so use this instead of simply
gc.collect(). :meth:`gc.collect()`.
:meta private:
""" """
gc.collect() gc.collect()
@ -309,7 +310,7 @@ def dump_app_state(
) )
except Exception: except Exception:
# Abandon whole dump if we can't write metadata. # Abandon whole dump if we can't write metadata.
logging.exception('Error writing app state dump metadata.') balog.exception('Error writing app state dump metadata.')
return return
tbpath = os.path.join( tbpath = os.path.join(
@ -383,13 +384,18 @@ def log_dumped_app_state(from_previous_run: bool = False) -> None:
with open(tbpath, 'r', encoding='utf-8') as infile: with open(tbpath, 'r', encoding='utf-8') as infile:
out += '\nPython tracebacks:\n' + infile.read() out += '\nPython tracebacks:\n' + infile.read()
os.unlink(tbpath) os.unlink(tbpath)
logging.log(metadata.log_level.python_logging_level, out) balog.log(metadata.log_level.python_logging_level, out)
except Exception: except Exception:
logging.exception('Error logging dumped app state.') balog.exception('Error logging dumped app state.')
class AppHealthMonitor(AppSubsystem): class AppHealthSubsystem(AppSubsystem):
"""Logs things like app-not-responding issues.""" """Subsystem for monitoring app health; logs not-responding issues, etc.
The single shared instance of this class can be found on the
:attr:`~babase.App.health` attr on the :class:`~babase.App`
class.
"""
def __init__(self) -> None: def __init__(self) -> None:
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -402,15 +408,28 @@ class AppHealthMonitor(AppSubsystem):
@override @override
def on_app_loading(self) -> None: def on_app_loading(self) -> None:
""":meta private:"""
# If any traceback dumps happened last run, log and clear them. # If any traceback dumps happened last run, log and clear them.
log_dumped_app_state(from_previous_run=True) log_dumped_app_state(from_previous_run=True)
@override
def on_app_suspend(self) -> None:
""":meta private:"""
assert _babase.in_logic_thread()
self._running = False
@override
def on_app_unsuspend(self) -> None:
""":meta private:"""
assert _babase.in_logic_thread()
self._running = True
def _app_monitor_thread_main(self) -> None: def _app_monitor_thread_main(self) -> None:
_babase.set_thread_name('ballistica app-monitor') _babase.set_thread_name('ballistica app-monitor')
try: try:
self._monitor_app() self._monitor_app()
except Exception: except Exception:
logging.exception('Error in AppHealthMonitor thread.') balog.exception('Error in AppHealthSubsystem thread.')
def _set_response(self) -> None: def _set_response(self) -> None:
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -463,13 +482,3 @@ class AppHealthMonitor(AppSubsystem):
time.sleep(1.042) time.sleep(1.042)
self._first_check = False self._first_check = False
@override
def on_app_suspend(self) -> None:
assert _babase.in_logic_thread()
self._running = False
@override
def on_app_unsuspend(self) -> None:
assert _babase.in_logic_thread()
self._running = True

View file

@ -14,8 +14,8 @@ if TYPE_CHECKING:
class CloudSubscription: class CloudSubscription:
"""User handle to a subscription to some cloud data. """User handle to a subscription to some cloud data.
Do not instantiate these directly; use the subscribe methods Do not instantiate these directly; use the subscribe methods in
in *.app.plus.cloud to create them. :class:`~baplus.CloudSubsystem` to create them.
""" """
def __init__(self, subscription_id: int) -> None: def __init__(self, subscription_id: int) -> None:

View file

@ -15,10 +15,13 @@ if TYPE_CHECKING:
class DevConsoleTab: class DevConsoleTab:
"""Defines behavior for a tab in the dev-console.""" """Base class for a :class:`~babase.DevConsoleSubsystem` tab."""
def refresh(self) -> None: def refresh(self) -> None:
"""Called when the tab should refresh itself.""" """Called when the tab should refresh itself.
Overridden by subclasses to implement tab behavior.
"""
def request_refresh(self) -> None: def request_refresh(self) -> None:
"""The tab can call this to request that it be refreshed.""" """The tab can call this to request that it be refreshed."""
@ -91,22 +94,23 @@ class DevConsoleTab:
@property @property
def width(self) -> float: def width(self) -> float:
"""Return the current tab width. Only call during refreshes.""" """The current tab width. Only valid during refreshes."""
assert _babase.app.devconsole.is_refreshing assert _babase.app.devconsole.is_refreshing
return _babase.dev_console_tab_width() return _babase.dev_console_tab_width()
@property @property
def height(self) -> float: def height(self) -> float:
"""Return the current tab height. Only call during refreshes.""" """The current tab height. Only valid during refreshes."""
assert _babase.app.devconsole.is_refreshing assert _babase.app.devconsole.is_refreshing
return _babase.dev_console_tab_height() return _babase.dev_console_tab_height()
@property @property
def base_scale(self) -> float: def base_scale(self) -> float:
"""A scale value set depending on the app's UI scale. """A scale value based on the app's current :class:`~babase.UIScale`.
Dev-console tabs can incorporate this into their UI sizes and Dev-console tabs can manually incorporate this into their UI
positions if they desire. This must be done manually however. sizes and positions if they desire. By default, dev-console tabs
are uniform across all ui-scales.
""" """
assert _babase.app.devconsole.is_refreshing assert _babase.app.devconsole.is_refreshing
return _babase.dev_console_base_scale() return _babase.dev_console_base_scale()
@ -114,20 +118,21 @@ class DevConsoleTab:
@dataclass @dataclass
class DevConsoleTabEntry: class DevConsoleTabEntry:
"""Represents a distinct tab in the dev-console.""" """Represents a distinct tab in the :class:`~babase.DevConsoleSubsystem`."""
name: str name: str
factory: Callable[[], DevConsoleTab] factory: Callable[[], DevConsoleTab]
class DevConsoleSubsystem: class DevConsoleSubsystem:
"""Subsystem for wrangling the dev console. """Subsystem for wrangling the dev-console.
The single instance of this class can be found at Access the single shared instance of this class via the
babase.app.devconsole. The dev-console is a simple always-available :attr:`~babase.App.devconsole` attr on the :class:`~babase.App`
UI intended for use by developers; not end users. Traditionally it class. The dev-console is a simple always-available UI intended for
is available by typing a backtick (`) key on a keyboard, but now can use by developers; not end users. Traditionally it is available by
be accessed via an on-screen button (see settings/advanced to enable typing a backtick (`) key on a keyboard, but can also be accessed
via an on-screen button (see settings/advanced/dev-tools to enable
said button). said button).
""" """
@ -141,8 +146,8 @@ class DevConsoleSubsystem:
DevConsoleTabTest, DevConsoleTabTest,
) )
# All tabs in the dev-console. Add your own stuff here via #: All tabs in the dev-console. Add your own stuff here via
# plugins or whatnot. #: plugins or whatnot to customize the console.
self.tabs: list[DevConsoleTabEntry] = [ self.tabs: list[DevConsoleTabEntry] = [
DevConsoleTabEntry('Python', DevConsoleTabPython), DevConsoleTabEntry('Python', DevConsoleTabPython),
DevConsoleTabEntry('AppModes', DevConsoleTabAppModes), DevConsoleTabEntry('AppModes', DevConsoleTabAppModes),
@ -155,7 +160,10 @@ class DevConsoleSubsystem:
self._tab_instances: dict[str, DevConsoleTab] = {} self._tab_instances: dict[str, DevConsoleTab] = {}
def do_refresh_tab(self, tabname: str) -> None: def do_refresh_tab(self, tabname: str) -> None:
"""Called by the C++ layer when a tab should be filled out.""" """Called by the C++ layer when a tab should be filled out.
:meta private:
"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
# Make noise if we have repeating tab names, as that breaks our # Make noise if we have repeating tab names, as that breaks our

View file

@ -17,7 +17,10 @@ if TYPE_CHECKING:
# ba_meta export babase.AppMode # ba_meta export babase.AppMode
class EmptyAppMode(AppMode): class EmptyAppMode(AppMode):
"""An AppMode that does not do much at all.""" """An AppMode that does not do much at all.
:meta private:
"""
@override @override
@classmethod @classmethod
@ -26,7 +29,7 @@ class EmptyAppMode(AppMode):
@override @override
@classmethod @classmethod
def _can_handle_intent(cls, intent: AppIntent) -> bool: def can_handle_intent_impl(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently. # We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault) return isinstance(intent, AppIntentExec | AppIntentDefault)

View file

@ -6,183 +6,66 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
class ContextError(Exception): class ContextError(Exception):
"""Exception raised when a call is made in an invalid context. """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 scene
Examples of this include calling UI functions within an Activity context context.
or calling scene manipulation functions outside of a game context.
""" """
class NotFoundError(Exception): class NotFoundError(Exception):
"""Exception raised when a referenced object does not exist. """Raised when a referenced object does not exist."""
Category: **Exception Classes**
"""
class PlayerNotFoundError(NotFoundError): class PlayerNotFoundError(NotFoundError):
"""Exception raised when an expected player does not exist. """Raised when an expected player does not exist."""
Category: **Exception Classes**
"""
class SessionPlayerNotFoundError(NotFoundError): class SessionPlayerNotFoundError(NotFoundError):
"""Exception raised when an expected session-player does not exist. """Exception raised when an expected session-player does not exist."""
Category: **Exception Classes**
"""
class TeamNotFoundError(NotFoundError): class TeamNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Team does not exist. """Raised when an expected team does not exist."""
Category: **Exception Classes**
"""
class MapNotFoundError(NotFoundError): class MapNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Map does not exist. """Raised when an expected map does not exist."""
Category: **Exception Classes**
"""
class DelegateNotFoundError(NotFoundError): class DelegateNotFoundError(NotFoundError):
"""Exception raised when an expected delegate object does not exist. """Raised when an expected delegate object does not exist."""
Category: **Exception Classes**
"""
class SessionTeamNotFoundError(NotFoundError): class SessionTeamNotFoundError(NotFoundError):
"""Exception raised when an expected session-team does not exist. """Raised when an expected session-team does not exist."""
Category: **Exception Classes**
"""
class NodeNotFoundError(NotFoundError): class NodeNotFoundError(NotFoundError):
"""Exception raised when an expected Node does not exist. """Raised when an expected node does not exist."""
Category: **Exception Classes**
"""
class ActorNotFoundError(NotFoundError): class ActorNotFoundError(NotFoundError):
"""Exception raised when an expected actor does not exist. """Raised when an expected actor does not exist."""
Category: **Exception Classes**
"""
class ActivityNotFoundError(NotFoundError): class ActivityNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Activity does not exist. """Raised when an expected activity does not exist."""
Category: **Exception Classes**
"""
class SessionNotFoundError(NotFoundError): class SessionNotFoundError(NotFoundError):
"""Exception raised when an expected session does not exist. """Raised when an expected session does not exist."""
Category: **Exception Classes**
"""
class InputDeviceNotFoundError(NotFoundError): class InputDeviceNotFoundError(NotFoundError):
"""Exception raised when an expected input-device does not exist. """Raised when an expected input-device does not exist."""
Category: **Exception Classes**
"""
class WidgetNotFoundError(NotFoundError): class WidgetNotFoundError(NotFoundError):
"""Exception raised when an expected widget does not exist. """Raised when an expected widget does not exist."""
Category: **Exception Classes**
"""
# TODO: Should integrate some sort of context printing into our
# log handling so we can just use logging.exception() and kill these
# two functions.
def print_exception(*args: Any, **keywds: Any) -> None:
"""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.
"""
import traceback
if keywds:
allowed_keywds = ['once']
if any(keywd not in allowed_keywds for keywd in keywds):
raise TypeError('invalid keyword(s)')
try:
# If we're only printing once and already have, bail.
if keywds.get('once', False):
if not _babase.do_once():
return
err_str = ' '.join([str(a) for a in args])
print('ERROR:', err_str)
_babase.print_context()
print('PRINTED-FROM:')
# Basically the output of traceback.print_stack()
stackstr = ''.join(traceback.format_stack())
print(stackstr, end='')
print('EXCEPTION:')
# Basically the output of traceback.print_exc()
excstr = traceback.format_exc()
print('\n'.join(' ' + l for l in excstr.splitlines()))
except Exception:
# I suppose using print_exception here would be a bad idea.
print('ERROR: exception in babase.print_exception():')
traceback.print_exc()
def print_error(err_str: str, once: bool = False) -> None:
"""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.
"""
import traceback
try:
# If we're only printing once and already have, bail.
if once:
if not _babase.do_once():
return
print('ERROR:', err_str)
_babase.print_context()
# Basically the output of traceback.print_stack()
stackstr = ''.join(traceback.format_stack())
print(stackstr, end='')
except Exception:
print('ERROR: exception in babase.print_error():')
traceback.print_exc()

View file

@ -34,10 +34,7 @@ DisplayTime = NewType('DisplayTime', float)
class Existable(Protocol): class Existable(Protocol):
"""A Protocol for objects supporting an exists() method. """A Protocol for objects supporting an exists() method."""
Category: **Protocols**
"""
def exists(self) -> bool: def exists(self) -> bool:
"""Whether this object exists.""" """Whether this object exists."""
@ -50,15 +47,13 @@ T = TypeVar('T')
def existing(obj: ExistableT | None) -> ExistableT | None: def existing(obj: ExistableT | None) -> ExistableT | None:
"""Convert invalid references to None for any babase.Existable object. """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
To best support type checking, it is important that invalid references of None. That way the type checker can properly flag attempts to
not be passed around and instead get converted to values of None. pass possibly-dead objects (``FooType | None``) into functions
That way the type checker can properly flag attempts to pass possibly-dead expecting only live ones (``FooType``), etc. This call can be used
objects (FooType | None) into functions expecting only live ones on any 'existable' object (one with an ``exists()`` method) and will
(FooType), etc. This call can be used on any 'existable' object convert it to a ``None`` value if it does not exist.
(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: For more info, see notes on 'existables' here:
https://ballistica.net/wiki/Coding-Style-Guide https://ballistica.net/wiki/Coding-Style-Guide
@ -70,12 +65,11 @@ def existing(obj: ExistableT | None) -> ExistableT | None:
def getclass( def getclass(
name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False
) -> type[T]: ) -> type[T]:
"""Given a full class name such as foo.bar.MyClass, return the class. """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 :class:`TypeError` will be raised
The class will be checked to make sure it is a subclass of the provided if not.
'subclassof' class, and a TypeError will be raised if not.
""" """
import importlib import importlib
@ -92,68 +86,54 @@ def getclass(
return cls return cls
def utf8_all(data: Any) -> Any:
"""Convert any unicode data in provided sequence(s) to utf8 bytes."""
if isinstance(data, dict):
return dict(
(utf8_all(key), utf8_all(value))
for key, value in list(data.items())
)
if isinstance(data, list):
return [utf8_all(element) for element in data]
if isinstance(data, tuple):
return tuple(utf8_all(element) for element in data)
if isinstance(data, str):
return data.encode('utf-8', errors='ignore')
return data
def get_type_name(cls: type) -> str: def get_type_name(cls: type) -> str:
"""Return a full type name including module for a class.""" """Return a fully qualified type name for a class."""
return f'{cls.__module__}.{cls.__name__}' return f'{cls.__module__}.{cls.__qualname__}'
class _WeakCall: class _WeakCall:
"""Wrap a callable and arguments into a single callable object. """Wrap a callable and arguments into a single callable object.
Category: **General Utility Classes** When passed a bound method as the callable, the instance portion of
it is weak-referenced, meaning the underlying instance is free to
die if all other references to it go away. Should this occur,
calling the weak-call is simply a no-op.
When passed a bound method as the callable, the instance portion Think of this as a handy way to tell an object to do something at
of it is weak-referenced, meaning the underlying instance is some point in the future if it happens to still exist.
free to die if all other references to it go away. Should this
occur, calling the WeakCall is simply a no-op.
Think of this as a handy way to tell an object to do something **EXAMPLE A:** This code will create a ``FooClass`` instance and
at some point in the future if it happens to still exist. call its ``bar()`` method 5 seconds later; it will be kept alive
even though we overwrite its variable with None because the bound
method we pass as a timer callback (``foo.bar``) strong-references
it::
##### Examples foo = FooClass()
**EXAMPLE A:** this code will create a FooClass instance and call its babase.apptimer(5.0, foo.bar)
bar() method 5 seconds later; it will be kept alive even though foo = None
we overwrite its variable with None because the bound method
we pass as a timer callback (foo.bar) strong-references it
>>> foo = FooClass()
... babase.apptimer(5.0, foo.bar)
... foo = None
**EXAMPLE B:** This code will *not* keep our object alive; it will die **EXAMPLE B:** This code will *not* keep our object alive; it will
when we overwrite it with None and the timer will be a no-op when it die when we overwrite it with ``None`` and the timer will be a no-op
fires when it fires::
>>> foo = FooClass()
... babase.apptimer(5.0, ba.WeakCall(foo.bar))
... foo = None
**EXAMPLE C:** Wrap a method call with some positional and keyword args: foo = FooClass()
>>> myweakcall = babase.WeakCall(self.dostuff, argval1, babase.apptimer(5.0, ba.WeakCall(foo.bar))
... namedarg=argval2) foo = None
... # Now we have a single callable to run that whole mess.
... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
... # (provided my_obj still exists; this will do nothing
... # otherwise).
... myweakcall()
Note: additional args and keywords you provide to the WeakCall() **EXAMPLE C:** Wrap a method call with some positional and keyword
constructor are stored as regular strong-references; you'll need args::
to wrap them in weakrefs manually if desired.
myweakcall = babase.WeakCall(self.dostuff, argval1,
namedarg=argval2)
# Now we have a single callable to run that whole mess.
# The same as calling myobj.dostuff(argval1, namedarg=argval2)
# (provided my_obj still exists; this will do nothing otherwise).
myweakcall()
Note: additional args and keywords you provide to the weak-call
constructor are stored as regular strong-references; you'll need to
wrap them in weakrefs manually if desired.
""" """
# Optimize performance a bit; we shouldn't need to be super dynamic. # Optimize performance a bit; we shouldn't need to be super dynamic.
@ -162,11 +142,6 @@ class _WeakCall:
_did_invalid_call_warning = False _did_invalid_call_warning = False
def __init__(self, *args: Any, **keywds: Any) -> None: def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a WeakCall.
Pass a callable as the first arg, followed by any number of
arguments or keywords.
"""
if hasattr(args[0], '__func__'): if hasattr(args[0], '__func__'):
self._call = WeakMethod(args[0]) self._call = WeakMethod(args[0])
else: else:
@ -203,37 +178,27 @@ class _WeakCall:
class _Call: class _Call:
"""Wraps a callable and arguments into a single callable object. """Wraps a callable and arguments into a single callable object.
Category: **General Utility Classes**
The callable is strong-referenced so it won't die until this The callable is strong-referenced so it won't die until this
object does. object does.
WARNING: This is exactly the same as Python's built in functools.partial().
Use functools.partial instead of this for new code, as this will probably
be deprecated at some point.
Note that a bound method (ex: ``myobj.dosomething``) contains a reference Note that a bound method (ex: ``myobj.dosomething``) contains a reference
to ``self`` (``myobj`` in that case), so you will be keeping that object to ``self`` (``myobj`` in that case), so you will be keeping that object
alive too. Use babase.WeakCall if you want to pass a method to a callback alive too. Use babase.WeakCall if you want to pass a method to a callback
without keeping its object alive. without keeping its object alive.
Example: Wrap a method call with 1 positional and 1 keyword arg::
mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2)
# Now we have a single callable to run that whole mess.
# ..the same as calling myobj.dostuff(argval, namedarg=argval2)
mycall()
""" """
# Optimize performance a bit; we shouldn't need to be super dynamic. # Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_call', '_args', '_keywds'] __slots__ = ['_call', '_args', '_keywds']
def __init__(self, *args: Any, **keywds: Any): def __init__(self, *args: Any, **keywds: Any):
"""Instantiate a Call.
Pass a callable as the first arg, followed by any number of
arguments or keywords.
##### Example
Wrap a method call with 1 positional and 1 keyword arg:
>>> mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2)
... # Now we have a single callable to run that whole mess.
... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
... mycall()
"""
self._call = args[0] self._call = args[0]
self._args = args[1:] self._args = args[1:]
self._keywds = keywds self._keywds = keywds
@ -309,8 +274,6 @@ class WeakMethod:
def verify_object_death(obj: object) -> None: def verify_object_death(obj: object) -> None:
"""Warn if an object does not get freed within a short period. """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. This can be handy to detect and prevent memory/resource leaks.
""" """
@ -351,27 +314,27 @@ def _verify_object_death(wref: weakref.ref) -> None:
def storagename(suffix: str | None = None) -> str: def storagename(suffix: str | None = None) -> str:
"""Generate a unique name for storing class data in shared places. """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
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 qualified name, and the provided suffix. When storing data in public
places such as 'customdata' dicts, this minimizes the chance of places such as 'customdata' dicts, this minimizes the chance of
collisions with other similarly named classes. collisions with other similarly named classes.
Note that this will function even if called in the class definition. Note that this will function even if called in the class definition.
##### Examples Example: Generate a unique name for storage purposes::
Generate a unique name for storage purposes:
>>> class MyThingie: class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'. # This will give something like
... _STORENAME = babase.storagename('data') # '_mymodule_submodule_mythingie_data'.
... _STORENAME = babase.storagename('data')
... # Use that name to store some data in the Activity we were
... # passed. # Use that name to store some data in the Activity we were
... def __init__(self, activity): # passed.
... activity.customdata[self._STORENAME] = {} def __init__(self, activity):
activity.customdata[self._STORENAME] = {}
""" """
frame = inspect.currentframe() frame = inspect.currentframe()
if frame is None: if frame is None:

View file

@ -5,12 +5,12 @@ from __future__ import annotations
import os import os
import json import json
import logging
from functools import partial from functools import partial
from typing import TYPE_CHECKING, overload, override from typing import TYPE_CHECKING, overload, override
import _babase import _babase
from babase._appsubsystem import AppSubsystem from babase._appsubsystem import AppSubsystem
from babase._logging import applog
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Sequence from typing import Any, Sequence
@ -21,8 +21,6 @@ if TYPE_CHECKING:
class LanguageSubsystem(AppSubsystem): class LanguageSubsystem(AppSubsystem):
"""Language functionality for the app. """Language functionality for the app.
Category: **App Classes**
Access the single instance of this class at 'babase.app.lang'. Access the single instance of this class at 'babase.app.lang'.
""" """
@ -37,16 +35,16 @@ class LanguageSubsystem(AppSubsystem):
@property @property
def locale(self) -> str: def locale(self) -> str:
"""Raw country/language code detected by the game (such as 'en_US'). """Raw country/language code detected by the game (such as "en_US").
Generally for language-specific code you should look at Generally for language-specific code you should look at
babase.App.language, which is the language the game is using :attr:`language`, which is the language the game is using (which
(which may differ from locale if the user sets a language, etc.) may differ from locale if the user sets a language, etc.)
""" """
env = _babase.env() env = _babase.env()
locale = env.get('locale') locale = env.get('locale')
if not isinstance(locale, str): if not isinstance(locale, str):
logging.warning( applog.warning(
'Seem to be running in a dummy env; returning en_US locale.' 'Seem to be running in a dummy env; returning en_US locale.'
) )
locale = 'en_US' locale = 'en_US'
@ -83,18 +81,17 @@ class LanguageSubsystem(AppSubsystem):
) )
names = [n.replace('.json', '').capitalize() for n in names] names = [n.replace('.json', '').capitalize() for n in names]
# FIXME: our simple capitalization fails on multi-word names; # FIXME: our simple capitalization fails on multi-word
# should handle this in a better way... # names; should handle this in a better way...
for i, name in enumerate(names): for i, name in enumerate(names):
if name == 'Chinesetraditional': if name == 'Chinesetraditional':
names[i] = 'ChineseTraditional' names[i] = 'ChineseTraditional'
elif name == 'Piratespeak': elif name == 'Piratespeak':
names[i] = 'PirateSpeak' names[i] = 'PirateSpeak'
except Exception: except Exception:
from babase import _error applog.exception('Error building available language list.')
_error.print_exception()
names = [] names = []
for name in names: for name in names:
if self._can_display_language(name): if self._can_display_language(name):
langs.add(name) langs.add(name)
@ -205,7 +202,7 @@ class LanguageSubsystem(AppSubsystem):
with open(lmodfile, encoding='utf-8') as infile: with open(lmodfile, encoding='utf-8') as infile:
lmodvalues = json.loads(infile.read()) lmodvalues = json.loads(infile.read())
except Exception: except Exception:
logging.exception("Error importing language '%s'.", language) applog.exception("Error importing language '%s'.", language)
_babase.screenmessage( _babase.screenmessage(
f"Error setting language to '{language}';" f"Error setting language to '{language}';"
f' see log for details.', f' see log for details.',
@ -275,6 +272,7 @@ class LanguageSubsystem(AppSubsystem):
@override @override
def do_apply_app_config(self) -> None: def do_apply_app_config(self) -> None:
""":meta private:"""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
assert isinstance(_babase.app.config, dict) assert isinstance(_babase.app.config, dict)
lang = _babase.app.config.get('Lang', self.default_language) lang = _babase.app.config.get('Lang', self.default_language)
@ -289,7 +287,11 @@ class LanguageSubsystem(AppSubsystem):
) -> Any: ) -> Any:
"""Return a translation resource by name. """Return a translation resource by name.
DEPRECATED; use babase.Lstr functionality for these purposes. .. warning::
Use :class:`~babase.Lstr` instead of this function whenever
possible, as it will gracefully handle displaying correctly
across multiple clients in multiple languages simultaneously.
""" """
try: try:
# If we have no language set, try and set it to english. # If we have no language set, try and set it to english.
@ -297,7 +299,7 @@ class LanguageSubsystem(AppSubsystem):
if self._language_merged is None: if self._language_merged is None:
try: try:
if _babase.do_once(): if _babase.do_once():
logging.warning( applog.warning(
'get_resource() called before language' 'get_resource() called before language'
' set; falling back to english.' ' set; falling back to english.'
) )
@ -305,9 +307,7 @@ class LanguageSubsystem(AppSubsystem):
'English', print_change=False, store_to_config=False 'English', print_change=False, store_to_config=False
) )
except Exception: except Exception:
logging.exception( applog.exception('Error setting fallback english language.')
'Error setting fallback english language.'
)
raise raise
# If they provided a fallback_resource value, try the # If they provided a fallback_resource value, try the
@ -382,7 +382,11 @@ class LanguageSubsystem(AppSubsystem):
) -> str: ) -> str:
"""Translate a value (or return the value if no translation available) """Translate a value (or return the value if no translation available)
DEPRECATED; use babase.Lstr functionality for these purposes. .. warning::
Use :class:`~babase.Lstr` instead of this function whenever
possible, as it will gracefully handle displaying correctly
across multiple clients in multiple languages simultaneously.
""" """
try: try:
translated = self.get_resource('translations')[category][strval] translated = self.get_resource('translations')[category][strval]
@ -495,38 +499,75 @@ class LanguageSubsystem(AppSubsystem):
class Lstr: class Lstr:
"""Used to define strings in a language-independent way. """Used to define strings in a language-independent way.
Category: **General Utility Classes**
These should be used whenever possible in place of hard-coded These should be used whenever possible in place of hard-coded
strings so that in-game or UI elements show up correctly on all strings so that in-game or UI elements show up correctly on all
clients in their currently-active language. clients in their currently active language.
To see available resource keys, look at any of the bs_language_*.py To see available resource keys, look at any of the
files in the game or the translations pages at ``ba_data/data/languages/*.json`` files in the game or the
legacy.ballistica.net/translate. translations pages at `legacy.ballistica.net/translate
<https://legacy.ballistica.net/translate>`_.
##### Examples Args:
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 resource:
value; if a translated value is available, it will be used; otherwise Pass a string to look up a translation by resource key.
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 translate:
can be used with resource and translate modes as well. Pass a tuple consisting of a translation category and
>>> mynode.text = babase.Lstr(value='${A} / ${B}', untranslated value. Any matching translation found in that
... subs=[('${A}', str(score)), ('${B}', str(total))]) category will be used. Otherwise the untranslated value will
be.
EXAMPLE 4: babase.Lstr's can be nested. This example would display the value:
resource at res_a but replace ${NAME} with the value of the Pass a regular string value to be used as-is.
resource at res_b
>>> mytextnode.text = babase.Lstr( subs:
... resource='res_a', A sequence of 2-member tuples consisting of values and
... subs=[('${NAME}', babase.Lstr(resource='res_b'))]) replacements. Replacements can be regular strings or other ``Lstr``
values.
fallback_resource:
A resource key that will be used if the main one is not present for
the current language instead of falling back to the english value
('resource' mode only).
fallback_value:
A regular string that will be used if neither the resource nor the
fallback resource is found ('resource' mode only).
**Example 1: Resource path** ::
mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
**Example 2: Translation**
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: Substitutions**
Substitutions can be used with ``resource`` and ``translate`` modes
as well as the ``value`` shown here. ::
mynode.text = babase.Lstr(value='${A} / ${B}',
subs=[('${A}', str(score)),
('${B}', str(total))])
**Example 4: Nesting**
``Lstr`` instances can be nested. This example would display
the translated resource at ``'res_a'`` but replace any instances of
``'${NAME}'`` it contains with the translated resource at ``'res_b'``. ::
mytextnode.text = babase.Lstr(
resource='res_a',
subs=[('${NAME}', babase.Lstr(resource='res_b'))])
""" """
# This class is used a lot in UI stuff and doesn't need to be # This class is used a lot in UI stuff and doesn't need to be
@ -563,26 +604,13 @@ class Lstr:
"""Create an Lstr from a raw string value.""" """Create an Lstr from a raw string value."""
def __init__(self, *args: Any, **keywds: Any) -> None: def __init__(self, *args: Any, **keywds: Any) -> None:
"""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).
"""
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
if args: if args:
raise TypeError('Lstr accepts only keyword arguments') raise TypeError('Lstr accepts only keyword arguments')
# Basically just store the exact args they passed. #: Basically just stores the exact args passed. However if Lstr
# However if they passed any Lstr values for subs, #: values were passed for subs, they are replaced with that
# replace them with that Lstr's dict. #: Lstr's dict.
self.args = keywds self.args = keywds
our_type = type(self) our_type = type(self)
@ -600,8 +628,8 @@ class Lstr:
subs_filtered.append((key, value)) subs_filtered.append((key, value))
self.args['subs'] = subs_filtered self.args['subs'] = subs_filtered
# As of protocol 31 we support compact key names # As of protocol 31 we support compact key names ('t' instead of
# ('t' instead of 'translate', etc). Convert as needed. # 'translate', etc). Convert as needed.
if 'translate' in keywds: if 'translate' in keywds:
keywds['t'] = keywds['translate'] keywds['t'] = keywds['translate']
del keywds['translate'] del keywds['translate']
@ -612,13 +640,11 @@ class Lstr:
keywds['v'] = keywds['value'] keywds['v'] = keywds['value']
del keywds['value'] del keywds['value']
if 'fallback' in keywds: if 'fallback' in keywds:
from babase import _error if _babase.do_once():
applog.error(
_error.print_error( 'Deprecated "fallback" arg passed to Lstr(); use '
'deprecated "fallback" arg passed to Lstr(); use ' 'either "fallback_resource" or "fallback_value".'
'either "fallback_resource" or "fallback_value"', )
once=True,
)
keywds['f'] = keywds['fallback'] keywds['f'] = keywds['fallback']
del keywds['fallback'] del keywds['fallback']
if 'fallback_resource' in keywds: if 'fallback_resource' in keywds:
@ -632,15 +658,15 @@ class Lstr:
del keywds['fallback_value'] del keywds['fallback_value']
def evaluate(self) -> str: def evaluate(self) -> str:
"""Evaluate the Lstr and returns a flat string in the current language. """Evaluate to a flat string in the current language.
You should avoid doing this as much as possible and instead pass You should avoid doing this as much as possible and instead pass
and store Lstr values. and store ``Lstr`` values.
""" """
return _babase.evaluate_lstr(self._get_json()) return _babase.evaluate_lstr(self._get_json())
def is_flat_value(self) -> bool: def is_flat_value(self) -> bool:
"""Return whether the Lstr is a 'flat' value. """Return whether this instance represents a 'flat' value.
This is defined as a simple string value incorporating no This is defined as a simple string value incorporating no
translations, resources, or substitutions. In this case it may translations, resources, or substitutions. In this case it may
@ -655,20 +681,23 @@ class Lstr:
except Exception: except Exception:
from babase import _error from babase import _error
_error.print_exception('_get_json failed for', self.args) applog.exception('_get_json failed for %s.', self.args)
return 'JSON_ERR' return 'JSON_ERR'
@override @override
def __str__(self) -> str: def __str__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>' return f'<ba.Lstr: {self._get_json()}>'
@override @override
def __repr__(self) -> str: def __repr__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>' return f'<ba.Lstr: {self._get_json()}>'
@staticmethod @staticmethod
def from_json(json_string: str) -> babase.Lstr: def from_json(json_string: str) -> babase.Lstr:
"""Given a json string, returns a babase.Lstr. Does no validation.""" """Given a json string, returns a ``Lstr``.
Does no validation.
"""
lstr = Lstr(value='') lstr = Lstr(value='')
lstr.args = json.loads(json_string) lstr.args = json.loads(json_string)
return lstr return lstr

View file

@ -22,7 +22,7 @@ logger = logging.getLogger('ba.loginadapter')
@dataclass @dataclass
class LoginInfo: class LoginInfo:
"""Basic info about a login available in the app.plus.accounts section.""" """Info for a login used by :class:`~babase.AccountV2Handle`."""
name: str name: str
@ -70,7 +70,10 @@ class LoginAdapter:
self._last_sign_in_desc: str | None = None self._last_sign_in_desc: str | None = None
def on_app_loading(self) -> None: def on_app_loading(self) -> None:
"""Should be called for each adapter in on_app_loading.""" """Should be called for each adapter in on_app_loading.
:meta private:
"""
assert not self._on_app_loading_called assert not self._on_app_loading_called
self._on_app_loading_called = True self._on_app_loading_called = True
@ -122,6 +125,8 @@ class LoginAdapter:
to the currently-in-use account. to the currently-in-use account.
Note that the logins dict passed in should be immutable as Note that the logins dict passed in should be immutable as
only a reference to it is stored, not a copy. only a reference to it is stored, not a copy.
:meta private:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
logger.debug( logger.debug(
@ -136,12 +141,12 @@ class LoginAdapter:
def on_back_end_active_change(self, active: bool) -> None: def on_back_end_active_change(self, active: bool) -> None:
"""Called when active state for the back-end is (possibly) changing. """Called when active state for the back-end is (possibly) changing.
Meant to be overridden by subclasses. Meant to be overridden by subclasses. Being active means that
Being active means that the implicit login provided by the back-end the implicit login provided by the back-end is actually being
is actually being used by the app. It should therefore register used by the app. It should therefore register unlocked
unlocked achievements, leaderboard scores, allow viewing native achievements, leaderboard scores, allow viewing native UIs, etc.
UIs, etc. When not active it should ignore everything and behave When not active it should ignore everything and behave as if
as if signed out, even if it technically is still signed in. signed out, even if it technically is still signed in.
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
del active # Unused. del active # Unused.
@ -156,7 +161,7 @@ class LoginAdapter:
This can be called even if the back-end is not implicitly signed in; 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 the adapter will attempt to sign in if possible. An exception will
be returned if the sign-in attempt fails. be passed to the callback if the sign-in attempt fails.
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -275,11 +280,12 @@ class LoginAdapter:
) -> None: ) -> None:
"""Get a sign-in token from the adapter back end. """Get a sign-in token from the adapter back end.
This token is then passed to the master-server to complete the This token is then passed to the cloud to complete the sign-in
sign-in process. The adapter can use this opportunity to bring process. The adapter can use this opportunity to bring up
up account creation UI, call its internal sign_in function, etc. account creation UI, call its internal sign-in function, etc. as
as needed. The provided completion_cb should then be called with needed. The provided ``completion_cb`` should then be called
either a token or None if sign in failed or was cancelled. with either a token or with ``None`` if sign in failed or was
cancelled.
""" """
# Default implementation simply fails immediately. # Default implementation simply fails immediately.

View file

@ -14,14 +14,13 @@ if TYPE_CHECKING:
def vec3validate(value: Sequence[float]) -> Sequence[float]: def vec3validate(value: Sequence[float]) -> Sequence[float]:
"""Ensure a value is valid for use as a Vec3. """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.
Raises a TypeError exception if not. :meta private:
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.
""" """
from numbers import Number from numbers import Number
@ -37,9 +36,9 @@ def vec3validate(value: Sequence[float]) -> Sequence[float]:
def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool: def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
"""Return whether a given point is within a given box. """Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale). For use with standard def boxes (position|rotate|scale).
:meta private:
""" """
return ( return (
(abs(pnt[0] - box[0]) <= box[6] * 0.5) (abs(pnt[0] - box[0]) <= box[6] * 0.5)
@ -49,10 +48,7 @@ def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
def normalized_color(color: Sequence[float]) -> tuple[float, ...]: def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
"""Scale a color so its largest value is 1; useful for coloring lights. """Scale a color so its largest value is 1.0; useful for coloring lights."""
category: General Utility Functions
"""
color_biased = tuple(max(c, 0.01) for c in color) # account for black color_biased = tuple(max(c, 0.01) for c in color) # account for black
mult = 1.0 / max(color_biased) mult = 1.0 / max(color_biased)
return tuple(c * mult for c in color_biased) return tuple(c * mult for c in color_biased)

View file

@ -48,9 +48,8 @@ class ScanResults:
class MetadataSubsystem: class MetadataSubsystem:
"""Subsystem for working with script metadata in the app. """Subsystem for working with script metadata in the app.
Category: **App Classes** Access the single shared instance of this class via the
:attr:`~babase.App.meta` attr on the :class:`~babase.App` class.
Access the single shared instance of this class at 'babase.app.meta'.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -68,8 +67,10 @@ class MetadataSubsystem:
"""Begin the overall scan. """Begin the overall scan.
This will start scanning built in directories (which for vanilla This will start scanning built in directories (which for vanilla
installs should be the vast majority of the work). This should only installs should be the vast majority of the work). This should
be called once. only be called once.
:meta private:
""" """
assert self._scan_complete_cb is None assert self._scan_complete_cb is None
assert self._scan is None assert self._scan is None
@ -95,6 +96,8 @@ class MetadataSubsystem:
This is for parts of the scan that must be delayed until This is for parts of the scan that must be delayed until
workspace sync completion or other such events. This must be workspace sync completion or other such events. This must be
called exactly once. called exactly once.
:meta private:
""" """
assert self._scan is not None assert self._scan is not None
self._scan.set_extras(self.extra_scan_dirs) self._scan.set_extras(self.extra_scan_dirs)
@ -113,7 +116,7 @@ class MetadataSubsystem:
to messaged to the user in some way but the callback will be called to messaged to the user in some way but the callback will be called
regardless. regardless.
To run the completion callback directly in the bg thread where the To run the completion callback directly in the bg thread where the
loading work happens, pass completion_cb_in_bg_thread=True. loading work happens, pass ``completion_cb_in_bg_thread=True``.
""" """
Thread( Thread(
target=partial( target=partial(

View file

@ -5,11 +5,7 @@ from enum import Enum
class InputType(Enum): class InputType(Enum):
"""Types of input a controller can send to the game. """Types of input a controller can send to the game."""
Category: Enums
"""
UP_DOWN = 2 UP_DOWN = 2
LEFT_RIGHT = 3 LEFT_RIGHT = 3
@ -39,19 +35,18 @@ class InputType(Enum):
class QuitType(Enum): class QuitType(Enum):
"""Types of input a controller can send to the game. """Types of quit behavior that can be requested from the app.
Category: Enums
'soft' may hide/reset the app but keep the process running, depending 'soft' may hide/reset the app but keep the process running, depending
on the platform. on the platform (generally a thing on mobile).
'back' is a variant of 'soft' which may give 'back-button-pressed' 'back' is a variant of 'soft' which may give 'back-button-pressed'
behavior depending on the platform. (returning to some previous behavior depending on the platform. (returning to some previous
activity instead of dumping to the home screen, etc.) activity instead of dumping to the home screen, etc.)
'hard' leads to the process exiting. This generally should be avoided 'hard' leads to the process exiting. This generally should be avoided
on platforms such as mobile. on platforms such as mobile where apps are expected to keep running
until killed by the OS.
""" """
SOFT = 0 SOFT = 0
@ -65,8 +60,6 @@ class UIScale(Enum):
might render the game at similar pixel resolutions but the size they might render the game at similar pixel resolutions but the size they
display content at will vary significantly. display content at will vary significantly.
Category: Enums
'large' is used for devices such as desktop PCs where fine details can 'large' is used for devices such as desktop PCs where fine details can
be clearly seen. UI elements are generally smaller on the screen be clearly seen. UI elements are generally smaller on the screen
and more content can be seen at once. and more content can be seen at once.
@ -86,19 +79,13 @@ class UIScale(Enum):
class Permission(Enum): class Permission(Enum):
"""Permissions that can be requested from the OS. """Permissions that can be requested from the OS."""
Category: Enums
"""
STORAGE = 0 STORAGE = 0
class SpecialChar(Enum): class SpecialChar(Enum):
"""Special characters the game can print. """Special characters the game can print."""
Category: Enums
"""
DOWN_ARROW = 0 DOWN_ARROW = 0
UP_ARROW = 1 UP_ARROW = 1

View file

@ -42,7 +42,10 @@ class NetworkSubsystem:
def get_ip_address_type(addr: str) -> socket.AddressFamily: def get_ip_address_type(addr: str) -> socket.AddressFamily:
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address.""" """Return an address-type given an address.
Can be :attr:`socket.AF_INET` or :attr:`socket.AF_INET6`.
"""
version = ipaddress.ip_address(addr).version version = ipaddress.ip_address(addr).version
if version == 4: if version == 4:

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, override
import _babase import _babase
from babase._appsubsystem import AppSubsystem from babase._appsubsystem import AppSubsystem
from babase._logging import balog
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
@ -18,31 +19,36 @@ if TYPE_CHECKING:
class PluginSubsystem(AppSubsystem): class PluginSubsystem(AppSubsystem):
"""Subsystem for plugin handling in the app. """Subsystem for wrangling plugins.
Category: **App Classes** Access the single shared instance of this class via the
:attr:`~babase.App.plugins` attr on the :class:`~babase.App` class.
Access the single shared instance of this class at `ba.app.plugins`.
""" """
#: :meta private:
AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins' AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
#: :meta private:
AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# Info about plugins that we are aware of. This may include #: Info about plugins that we are aware of. This may include
# plugins discovered through meta-scanning as well as plugins #: plugins discovered through meta-scanning as well as plugins
# registered in the app-config. This may include plugins that #: registered in the app-config. This may include plugins that
# cannot be loaded for various reasons or that have been #: cannot be loaded for various reasons or that have been
# intentionally disabled. #: intentionally disabled.
self.plugin_specs: dict[str, babase.PluginSpec] = {} self.plugin_specs: dict[str, babase.PluginSpec] = {}
# The set of live active plugin objects. #: The set of live active plugin instances.
self.active_plugins: list[babase.Plugin] = [] self.active_plugins: list[babase.Plugin] = []
def on_meta_scan_complete(self) -> None: def on_meta_scan_complete(self) -> None:
"""Called when meta-scanning is complete.""" """Called when meta-scanning is complete.
:meta private:
"""
from babase._language import Lstr from babase._language import Lstr
config_changed = False config_changed = False
@ -160,61 +166,53 @@ class PluginSubsystem(AppSubsystem):
@override @override
def on_app_running(self) -> None: def on_app_running(self) -> None:
""":meta private:"""
# Load up our plugins and go ahead and call their on_app_running # Load up our plugins and go ahead and call their on_app_running
# calls. # calls.
self.load_plugins() self._load_plugins()
for plugin in self.active_plugins: for plugin in self.active_plugins:
try: try:
plugin.on_app_running() plugin.on_app_running()
except Exception: except Exception:
from babase import _error balog.exception('Error in plugin on_app_running().')
_error.print_exception('Error in plugin on_app_running()')
@override @override
def on_app_suspend(self) -> None: def on_app_suspend(self) -> None:
""":meta private:"""
for plugin in self.active_plugins: for plugin in self.active_plugins:
try: try:
plugin.on_app_suspend() plugin.on_app_suspend()
except Exception: except Exception:
from babase import _error balog.exception('Error in plugin on_app_suspend().')
_error.print_exception('Error in plugin on_app_suspend()')
@override @override
def on_app_unsuspend(self) -> None: def on_app_unsuspend(self) -> None:
""":meta private:"""
for plugin in self.active_plugins: for plugin in self.active_plugins:
try: try:
plugin.on_app_unsuspend() plugin.on_app_unsuspend()
except Exception: except Exception:
from babase import _error balog.exception('Error in plugin on_app_unsuspend().')
_error.print_exception('Error in plugin on_app_unsuspend()')
@override @override
def on_app_shutdown(self) -> None: def on_app_shutdown(self) -> None:
""":meta private:"""
for plugin in self.active_plugins: for plugin in self.active_plugins:
try: try:
plugin.on_app_shutdown() plugin.on_app_shutdown()
except Exception: except Exception:
from babase import _error balog.exception('Error in plugin on_app_shutdown().')
_error.print_exception('Error in plugin on_app_shutdown()')
@override @override
def on_app_shutdown_complete(self) -> None: def on_app_shutdown_complete(self) -> None:
""":meta private:"""
for plugin in self.active_plugins: for plugin in self.active_plugins:
try: try:
plugin.on_app_shutdown_complete() plugin.on_app_shutdown_complete()
except Exception: except Exception:
from babase import _error balog.exception('Error in plugin on_app_shutdown_complete().')
_error.print_exception( def _load_plugins(self) -> None:
'Error in plugin on_app_shutdown_complete()'
)
def load_plugins(self) -> None:
"""(internal)"""
# Load plugins from any specs that are enabled & able to. # Load plugins from any specs that are enabled & able to.
for _class_path, plug_spec in sorted(self.plugin_specs.items()): for _class_path, plug_spec in sorted(self.plugin_specs.items()):
@ -224,32 +222,36 @@ class PluginSubsystem(AppSubsystem):
class PluginSpec: class PluginSpec:
"""Represents a plugin the engine knows about. """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.
"""
def __init__(self, class_path: str, loadable: bool): def __init__(self, class_path: str, loadable: bool):
#: Fully qualified class path for the plugin.
self.class_path = class_path self.class_path = class_path
#: Can we attempt to load the plugin?
self.loadable = loadable self.loadable = loadable
#: Whether the engine has attempted to load the plugin. If this
#: is True but the value of :attr:`plugin` 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.
self.attempted_load = False self.attempted_load = False
#: The associated :class:`~babase.Plugin`, if any.
self.plugin: Plugin | None = None self.plugin: Plugin | None = None
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
"""Whether the user wants this plugin to load.""" """Whether this plugin is set to load.
Getting or setting this attr affects the corresponding
app-config key. Remember to commit the app-config after making any
changes.
"""
plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {}) plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
assert isinstance(plugstates, dict) assert isinstance(plugstates, dict)
val = plugstates.get(self.class_path, {}).get('enabled', False) is True val = plugstates.get(self.class_path, {}).get('enabled', False) is True
@ -321,12 +323,10 @@ class PluginSpec:
class Plugin: class Plugin:
"""A plugin to alter app behavior in some way. """A plugin to alter app behavior in some way.
Category: **App Classes** Plugins are discoverable by the :class:`~babase.MetadataSubsystem`
system and the user can select which ones they want to enable.
Plugins are discoverable by the meta-tag system Enabled plugins are then called at specific times as the app is
and the user can select which ones they want to enable. running in order to modify its behavior in some way.
Enabled plugins are then called at specific times as the
app is running in order to modify its behavior in some way.
""" """
def on_app_running(self) -> None: def on_app_running(self) -> None:

View file

@ -22,7 +22,12 @@ if TYPE_CHECKING:
class StringEditSubsystem: class StringEditSubsystem:
"""Full string-edit state for the app.""" """Full string-edit state for the app.
Access the single shared instance of this class via the
:attr:`~babase.App.stringedit` attr on the :class:`~babase.App`
class.
"""
def __init__(self) -> None: def __init__(self) -> None:
self.active_adapter = empty_weakref(StringEditAdapter) self.active_adapter = empty_weakref(StringEditAdapter)
@ -35,11 +40,11 @@ class StringEditAdapter:
subclass this to make their contents editable on all platforms. subclass this to make their contents editable on all platforms.
There can only be one string-edit at a time for the app. New There can only be one string-edit at a time for the app. New
StringEdits will attempt to register themselves as the globally string-edits will attempt to register themselves as the globally
active one in their constructor, but this may not succeed. When active one in their constructor, but this may not succeed. If
creating a StringEditAdapter, always check its 'is_valid()' value after :meth:`can_be_replaced()` returns ``True`` for an adapter
creating it. If this is False, it was not able to set itself as immediately after creating it, that means it was not able to set
the global active one and should be discarded. itself as the global one.
""" """
def __init__( def __init__(
@ -72,8 +77,8 @@ class StringEditAdapter:
"""Return whether this adapter can be replaced by a new one. """Return whether this adapter can be replaced by a new one.
This is mainly a safeguard to allow adapters whose drivers have This is mainly a safeguard to allow adapters whose drivers have
gone away without calling apply or cancel to time out and be gone away without calling :meth:`apply` or :meth:`cancel` to
replaced with new ones. time out and be replaced with new ones.
""" """
if not _babase.in_logic_thread(): if not _babase.in_logic_thread():
raise RuntimeError('This must be called from the logic thread.') raise RuntimeError('This must be called from the logic thread.')
@ -104,7 +109,7 @@ class StringEditAdapter:
"""Should be called by the owner when editing is complete. """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 Note that in some cases this call may be a no-op (such as if
this StringEditAdapter is no longer the globally active one). this adapter is no longer the globally active one).
""" """
if not _babase.in_logic_thread(): if not _babase.in_logic_thread():
raise RuntimeError('This must be called from the logic thread.') raise RuntimeError('This must be called from the logic thread.')

View file

@ -15,18 +15,18 @@ def timestring(
timeval: float | int, timeval: float | int,
centi: bool = True, centi: bool = True,
) -> babase.Lstr: ) -> babase.Lstr:
"""Generate a babase.Lstr for displaying a time value. """Generate a localized string for displaying a time value.
Category: **General Utility Functions** Given a time value, returns a localized string with:
Given a time value, returns a babase.Lstr with:
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True). (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
WARNING: the underlying Lstr value is somewhat large so don't use this .. warning::
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.
the underlying localized-string value is somewhat large, so don't
use this to rapidly update text values for an in-game timer or you
may consume significant network bandwidth. For that sort of thing
you should use things like 'timedisplay' nodes and attribute
connections.
""" """
from babase._language import Lstr from babase._language import Lstr

View file

@ -26,9 +26,8 @@ if TYPE_CHECKING:
class WorkspaceSubsystem: class WorkspaceSubsystem:
"""Subsystem for workspace handling in the app. """Subsystem for workspace handling in the app.
Category: **App Classes** Access the single shared instance of this class at
`ba.app.workspaces`.
Access the single shared instance of this class at `ba.app.workspaces`.
""" """
def __init__(self) -> None: def __init__(self) -> None:

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
import os import os
import _babase import _babase
from babase._logging import applog
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Sequence from typing import Sequence
@ -95,14 +96,11 @@ def show_user_scripts() -> None:
with open(file_name, 'w', encoding='utf-8') as outfile: with open(file_name, 'w', encoding='utf-8') as outfile:
outfile.write( outfile.write(
'You can drop files in here to mod the game.' 'You can drop files in here to mod the game.'
' See settings/advanced' ' See settings/advanced in the game for more info.'
' in the game for more info.'
) )
except Exception: except Exception:
from babase import _error applog.exception('Error writing about_this_folder stuff.')
_error.print_exception('error writing about_this_folder stuff')
# On platforms that support it, open the dir in the UI. # On platforms that support it, open the dir in the UI.
if _babase.supports_open_dir_externally(): if _babase.supports_open_dir_externally():

View file

@ -5,8 +5,8 @@
This package/feature-set contains functionality related to the classic This package/feature-set contains functionality related to the classic
BombSquad experience. Note that much legacy BombSquad code is still a BombSquad experience. Note that much legacy BombSquad code is still a
bit tangled and thus this feature-set is largely inseperable from bit tangled and thus this feature-set is largely inseperable from
scenev1 and uiv1. Future feature-sets will be designed in a more modular :mod:`bascenev1` and :mod:`bauiv1`. Future feature-sets will be
way. designed in a more modular way.
""" """
# ba_meta require api 9 # ba_meta require api 9

View file

@ -17,9 +17,8 @@ if TYPE_CHECKING:
class AccountV1Subsystem: class AccountV1Subsystem:
"""Subsystem for legacy account handling in the app. """Subsystem for legacy account handling in the app.
Category: **App Classes** Access the single instance of this class at
'ba.app.classic.accounts'.
Access the single instance of this class at 'ba.app.classic.accounts'.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -202,7 +201,7 @@ class AccountV1Subsystem:
# If the short version of our account name currently cant be # If the short version of our account name currently cant be
# displayed by the game, cancel. # displayed by the game, cancel.
if not babase.have_chars( if not babase.can_display_chars(
plus.get_v1_account_display_string(full=False) plus.get_v1_account_display_string(full=False)
): ):
return return

View file

@ -73,8 +73,6 @@ ACH_LEVEL_NAMES = {
class AchievementSubsystem: class AchievementSubsystem:
"""Subsystem for achievement handling. """Subsystem for achievement handling.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.ach'. Access the single shared instance of this class at 'ba.app.ach'.
""" """
@ -530,10 +528,7 @@ def _display_next_achievement() -> None:
class Achievement: class Achievement:
"""Represents attributes and state for an individual achievement. """Represents attributes and state for an individual achievement."""
Category: **App Classes**
"""
def __init__( def __init__(
self, self,

View file

@ -18,8 +18,6 @@ if TYPE_CHECKING:
class AdsSubsystem: class AdsSubsystem:
"""Subsystem for ads functionality in the app. """Subsystem for ads functionality in the app.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.ads'. Access the single shared instance of this class at 'ba.app.ads'.
""" """

View file

@ -9,6 +9,7 @@ from functools import partial
from typing import TYPE_CHECKING, override from typing import TYPE_CHECKING, override
from bacommon.app import AppExperience from bacommon.app import AppExperience
import bacommon.bs
import babase import babase
import bauiv1 import bauiv1
from bauiv1lib.connectivity import wait_for_connectivity from bauiv1lib.connectivity import wait_for_connectivity
@ -28,6 +29,8 @@ if TYPE_CHECKING:
class ClassicAppMode(babase.AppMode): class ClassicAppMode(babase.AppMode):
"""AppMode for the classic BombSquad experience.""" """AppMode for the classic BombSquad experience."""
_LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals'
def __init__(self) -> None: def __init__(self) -> None:
self._on_primary_account_changed_callback: ( self._on_primary_account_changed_callback: (
CallbackRegistration | None CallbackRegistration | None
@ -40,6 +43,11 @@ class ClassicAppMode(babase.AppMode):
self._have_account_values = False self._have_account_values = False
self._have_connectivity = False self._have_connectivity = False
self._current_account_id: str | None = None
self._should_restore_account_display_state = False
self._purchase_ui_pause: bauiv1.RootUIUpdatePause | None = None
self._last_tokens_value = 0
@override @override
@classmethod @classmethod
@ -48,7 +56,7 @@ class ClassicAppMode(babase.AppMode):
@override @override
@classmethod @classmethod
def _can_handle_intent(cls, intent: babase.AppIntent) -> bool: def can_handle_intent_impl(cls, intent: babase.AppIntent) -> bool:
# We support default and exec intents currently. # We support default and exec intents currently.
return isinstance( return isinstance(
intent, babase.AppIntentExec | babase.AppIntentDefault intent, babase.AppIntentExec | babase.AppIntentDefault
@ -148,9 +156,15 @@ class ClassicAppMode(babase.AppMode):
classic = babase.app.classic classic = babase.app.classic
# Store latest league vis vals for any active account.
self._save_account_display_state()
# Stop being informed of account changes. # Stop being informed of account changes.
self._on_primary_account_changed_callback = None self._on_primary_account_changed_callback = None
# Cancel any ui-pause we may have had going.
self._purchase_ui_pause = None
# Remove anything following any current account. # Remove anything following any current account.
self._update_for_primary_account(None) self._update_for_primary_account(None)
@ -163,11 +177,151 @@ class ClassicAppMode(babase.AppMode):
@override @override
def on_app_active_changed(self) -> None: def on_app_active_changed(self) -> None:
# If we've gone inactive, bring up the main menu, which has the
# side effect of pausing the action (when possible).
if not babase.app.active: if not babase.app.active:
# If we've gone inactive, bring up the main menu, which has the
# side effect of pausing the action (when possible).
babase.invoke_main_menu() babase.invoke_main_menu()
# Also store any league vis state for the active account.
# this may be our last chance to do this on mobile.
self._save_account_display_state()
@override
def on_purchase_process_begin(
self, item_id: str, user_initiated: bool
) -> None:
# Do the default thing (announces 'updating account...')
super().on_purchase_process_begin(
item_id=item_id, user_initiated=user_initiated
)
# Pause the root ui so stuff like token counts don't change
# automatically, allowing us to animate them. Note that we
# need to explicitly kill this pause if we are deactivated since
# we wouldn't get the on_purchase_process_end() call; the next
# app-mode would.
self._purchase_ui_pause = bauiv1.RootUIUpdatePause()
# Also grab our last known token count here to plug into animations.
# We need to do this here before the purchase gets submitted so that
# we know we're seeing the old value.
assert babase.app.classic is not None
self._last_tokens_value = babase.app.classic.tokens
@override
def on_purchase_process_end(
self, item_id: str, user_initiated: bool, applied: bool
) -> None:
# Let the UI auto-update again after any animations we may apply
# here.
self._purchase_ui_pause = None
# Ignore user_initiated; we want to announce newly applied stuff
# even if it was from a different launch or client or whatever.
del user_initiated
# If the purchase wasn't applied, do nothing. This likely means it
# was redundant or something else harmless.
if not applied:
return
if item_id.startswith('tokens'):
if item_id == 'tokens1':
tokens = bacommon.bs.TOKENS1_COUNT
tokens_str = str(tokens)
anim_time = 2.0
elif item_id == 'tokens2':
tokens = bacommon.bs.TOKENS2_COUNT
tokens_str = str(tokens)
anim_time = 2.5
elif item_id == 'tokens3':
tokens = bacommon.bs.TOKENS3_COUNT
tokens_str = str(tokens)
anim_time = 3.0
elif item_id == 'tokens4':
tokens = bacommon.bs.TOKENS4_COUNT
tokens_str = str(tokens)
anim_time = 3.5
else:
tokens = 0
tokens_str = '???'
anim_time = 2.5
logging.warning(
'Unhandled item_id in on_purchase_process_end: %s', item_id
)
assert babase.app.classic is not None
effects: list[bacommon.bs.ClientEffect] = [
bacommon.bs.ClientEffectTokensAnimation(
duration=anim_time,
startvalue=self._last_tokens_value,
endvalue=self._last_tokens_value + tokens,
),
bacommon.bs.ClientEffectDelay(anim_time),
bacommon.bs.ClientEffectScreenMessage(
message='You got ${COUNT} tokens!',
subs=['${COUNT}', tokens_str],
color=(0, 1, 0),
),
bacommon.bs.ClientEffectSound(
sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER
),
]
babase.app.classic.run_bs_client_effects(effects)
elif item_id.startswith('gold_pass'):
babase.screenmessage(
babase.Lstr(
translate=('serverResponses', 'You got a ${ITEM}!'),
subs=[
(
'${ITEM}',
babase.Lstr(resource='goldPass.goldPassText'),
)
],
),
color=(0, 1, 0),
)
if babase.asset_loads_allowed():
babase.getsimplesound('cashRegister').play()
else:
# Fallback: simply announce item id.
logging.warning(
'on_purchase_process_end got unexpected item_id: %s.', item_id
)
babase.screenmessage(
babase.Lstr(
translate=('serverResponses', 'You got a ${ITEM}!'),
subs=[('${ITEM}', item_id)],
),
color=(0, 1, 0),
)
if babase.asset_loads_allowed():
babase.getsimplesound('cashRegister').play()
def on_engine_will_reset(self) -> None:
"""Called just before classic resets the engine.
This happens at various times such as session switches.
"""
self._save_account_display_state()
def on_engine_did_reset(self) -> None:
"""Called just after classic resets the engine.
This happens at various times such as session switches.
"""
# Restore any old league vis state we had; this allows the user
# to see animations for league improvements or other changes
# that have occurred since the last time we were visible.
self._restore_account_display_state()
def _update_for_primary_account( def _update_for_primary_account(
self, account: babase.AccountV2Handle | None self, account: babase.AccountV2Handle | None
) -> None: ) -> None:
@ -181,9 +335,18 @@ class ClassicAppMode(babase.AppMode):
assert classic is not None assert classic is not None
if account is not None: if account is not None:
self._current_account_id = account.accountid
babase.set_ui_account_state(True, account.tag) babase.set_ui_account_state(True, account.tag)
self._should_restore_account_display_state = True
else: else:
# If we had an account, save any existing league vis state
# so we'll properly animate to new values the next time we
# sign in.
self._save_account_display_state()
self._current_account_id = None
babase.set_ui_account_state(False) babase.set_ui_account_state(False)
self._should_restore_account_display_state = False
# For testing subscription functionality. # For testing subscription functionality.
if os.environ.get('BA_SUBSCRIPTION_TEST') == '1': if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
@ -199,27 +362,39 @@ class ClassicAppMode(babase.AppMode):
if account is None: if account is None:
classic.gold_pass = False classic.gold_pass = False
classic.tokens = 0
classic.chest_dock_full = False classic.chest_dock_full = False
classic.remove_ads = False classic.remove_ads = False
self._account_data_sub = None self._account_data_sub = None
_baclassic.set_root_ui_account_values( _baclassic.set_root_ui_account_values(
tickets=-1, tickets=-1,
tokens=-1, tokens=-1,
league_rank=-1,
league_type='', league_type='',
league_number=-1,
league_rank=-1,
achievements_percent_text='', achievements_percent_text='',
level_text='', level_text='',
xp_text='', xp_text='',
inbox_count_text='', inbox_count=-1,
inbox_count_is_max=False,
inbox_announce_text='',
gold_pass=False, gold_pass=False,
chest_0_appearance='', chest_0_appearance='',
chest_1_appearance='', chest_1_appearance='',
chest_2_appearance='', chest_2_appearance='',
chest_3_appearance='', chest_3_appearance='',
chest_0_create_time=-1.0,
chest_1_create_time=-1.0,
chest_2_create_time=-1.0,
chest_3_create_time=-1.0,
chest_0_unlock_time=-1.0, chest_0_unlock_time=-1.0,
chest_1_unlock_time=-1.0, chest_1_unlock_time=-1.0,
chest_2_unlock_time=-1.0, chest_2_unlock_time=-1.0,
chest_3_unlock_time=-1.0, chest_3_unlock_time=-1.0,
chest_0_unlock_tokens=-1,
chest_1_unlock_tokens=-1,
chest_2_unlock_tokens=-1,
chest_3_unlock_tokens=-1,
chest_0_ad_allow_time=-1.0, chest_0_ad_allow_time=-1.0,
chest_1_ad_allow_time=-1.0, chest_1_ad_allow_time=-1.0,
chest_2_ad_allow_time=-1.0, chest_2_ad_allow_time=-1.0,
@ -248,7 +423,7 @@ class ClassicAppMode(babase.AppMode):
# connectivity state here we get UI stuff un-fading a moment or # connectivity state here we get UI stuff un-fading a moment or
# two before values appear (since the subscriptions have not # two before values appear (since the subscriptions have not
# sent us any values yet) which looks odd. # sent us any values yet) which looks odd.
_baclassic.set_root_ui_have_live_values( _baclassic.set_have_live_account_values(
self._have_connectivity and self._have_account_values self._have_connectivity and self._have_account_values
) )
@ -258,11 +433,10 @@ class ClassicAppMode(babase.AppMode):
def _on_classic_account_data_change( def _on_classic_account_data_change(
self, val: bacommon.bs.ClassicAccountLiveData self, val: bacommon.bs.ClassicAccountLiveData
) -> None: ) -> None:
# print('ACCOUNT CHANGED:', val)
achp = round(val.achievements / max(val.achievements_total, 1) * 100.0) achp = round(val.achievements / max(val.achievements_total, 1) * 100.0)
ibc = str(val.inbox_count) # ibc = str(val.inbox_count)
if val.inbox_count_is_max: # if val.inbox_count_is_max:
ibc += '+' # ibc += '+'
chest0 = val.chests.get('0') chest0 = val.chests.get('0')
chest1 = val.chests.get('1') chest1 = val.chests.get('1')
@ -275,6 +449,7 @@ class ClassicAppMode(babase.AppMode):
assert classic is not None assert classic is not None
classic.remove_ads = val.remove_ads classic.remove_ads = val.remove_ads
classic.gold_pass = val.gold_pass classic.gold_pass = val.gold_pass
classic.tokens = val.tokens
classic.chest_dock_full = ( classic.chest_dock_full = (
chest0 is not None chest0 is not None
and chest1 is not None and chest1 is not None
@ -285,14 +460,21 @@ class ClassicAppMode(babase.AppMode):
_baclassic.set_root_ui_account_values( _baclassic.set_root_ui_account_values(
tickets=val.tickets, tickets=val.tickets,
tokens=val.tokens, tokens=val.tokens,
league_rank=(-1 if val.league_rank is None else val.league_rank),
league_type=( league_type=(
'' if val.league_type is None else val.league_type.value '' if val.league_type is None else val.league_type.value
), ),
league_number=(-1 if val.league_num is None else val.league_num),
league_rank=(-1 if val.league_rank is None else val.league_rank),
achievements_percent_text=f'{achp}%', achievements_percent_text=f'{achp}%',
level_text=str(val.level), level_text=str(val.level),
xp_text=f'{val.xp}/{val.xpmax}', xp_text=f'{val.xp}/{val.xpmax}',
inbox_count_text=ibc, inbox_count=val.inbox_count,
inbox_count_is_max=val.inbox_count_is_max,
inbox_announce_text=(
babase.Lstr(resource='unclaimedPrizesText').evaluate()
if val.inbox_contains_prize
else ''
),
gold_pass=val.gold_pass, gold_pass=val.gold_pass,
chest_0_appearance=( chest_0_appearance=(
'' if chest0 is None else chest0.appearance.value '' if chest0 is None else chest0.appearance.value
@ -306,6 +488,18 @@ class ClassicAppMode(babase.AppMode):
chest_3_appearance=( chest_3_appearance=(
'' if chest3 is None else chest3.appearance.value '' if chest3 is None else chest3.appearance.value
), ),
chest_0_create_time=(
-1.0 if chest0 is None else chest0.create_time.timestamp()
),
chest_1_create_time=(
-1.0 if chest1 is None else chest1.create_time.timestamp()
),
chest_2_create_time=(
-1.0 if chest2 is None else chest2.create_time.timestamp()
),
chest_3_create_time=(
-1.0 if chest3 is None else chest3.create_time.timestamp()
),
chest_0_unlock_time=( chest_0_unlock_time=(
-1.0 if chest0 is None else chest0.unlock_time.timestamp() -1.0 if chest0 is None else chest0.unlock_time.timestamp()
), ),
@ -318,6 +512,18 @@ class ClassicAppMode(babase.AppMode):
chest_3_unlock_time=( chest_3_unlock_time=(
-1.0 if chest3 is None else chest3.unlock_time.timestamp() -1.0 if chest3 is None else chest3.unlock_time.timestamp()
), ),
chest_0_unlock_tokens=(
-1 if chest0 is None else chest0.unlock_tokens
),
chest_1_unlock_tokens=(
-1 if chest1 is None else chest1.unlock_tokens
),
chest_2_unlock_tokens=(
-1 if chest2 is None else chest2.unlock_tokens
),
chest_3_unlock_tokens=(
-1 if chest3 is None else chest3.unlock_tokens
),
chest_0_ad_allow_time=( chest_0_ad_allow_time=(
-1.0 -1.0
if chest0 is None or chest0.ad_allow_time is None if chest0 is None or chest0.ad_allow_time is None
@ -339,6 +545,14 @@ class ClassicAppMode(babase.AppMode):
else chest3.ad_allow_time.timestamp() else chest3.ad_allow_time.timestamp()
), ),
) )
if self._should_restore_account_display_state:
# If we have a previous display-state for this account,
# restore it. This will cause us to animate or otherwise
# display league changes that have occurred since we were
# last visible. Note we need to do this *after* setting real
# vals so there is a current state to animate to.
self._restore_account_display_state()
self._should_restore_account_display_state = False
# Note that we have values and updated faded state accordingly. # Note that we have values and updated faded state accordingly.
self._have_account_values = True self._have_account_values = True
@ -656,3 +870,38 @@ class ClassicAppMode(babase.AppMode):
), ),
) )
) )
def _save_account_display_state(self) -> None:
# If we currently have an account, save the state of what we're
# currently displaying for it in the root ui/etc. We'll then
# restore that state as a starting point the next time we are
# active. This allows things like league rank changes to be
# properly animated even if they occurred while we were offline
# or while the UI was hidden.
if self._current_account_id is not None:
vals = _baclassic.get_account_display_state()
if vals is not None:
# Stuff our account id in there and save it to our
# config.
assert 'a' not in vals
vals['a'] = self._current_account_id
cfg = babase.app.config
cfg[self._LEAGUE_VIS_VALS_CONFIG_KEY] = vals
cfg.commit()
def _restore_account_display_state(self) -> None:
# If we currently have an account and it matches the
# display-state we have stored in the config, restore the state.
if self._current_account_id is not None:
cfg = babase.app.config
vals = cfg.get(self._LEAGUE_VIS_VALS_CONFIG_KEY)
if isinstance(vals, dict):
valsaccount = vals.get('a')
if (
isinstance(valsaccount, str)
and valsaccount == self._current_account_id
):
_baclassic.set_account_display_state(vals)

View file

@ -77,6 +77,7 @@ class ClassicAppSubsystem(babase.AppSubsystem):
# Classic-specific account state. # Classic-specific account state.
self.remove_ads = False self.remove_ads = False
self.gold_pass = False self.gold_pass = False
self.tokens = 0
self.chest_dock_full = False self.chest_dock_full = False
# Main Menu. # Main Menu.
@ -384,8 +385,6 @@ class ClassicAppSubsystem(babase.AppSubsystem):
def getmaps(self, playtype: str) -> list[str]: def getmaps(self, playtype: str) -> list[str]:
"""Return a list of bascenev1.Map types supporting a playtype str. """Return a list of bascenev1.Map types supporting a playtype str.
Category: **Asset Functions**
Maps supporting a given playtype must provide a particular set of Maps supporting a given playtype must provide a particular set of
features and lend themselves to a certain style of play. features and lend themselves to a certain style of play.
@ -751,10 +750,7 @@ class ClassicAppSubsystem(babase.AppSubsystem):
) )
def preload_map_preview_media(self) -> None: def preload_map_preview_media(self) -> None:
"""Preload media needed for map preview UIs. """Preload media needed for map preview UIs."""
Category: **Asset Functions**
"""
try: try:
bauiv1.getmesh('level_select_button_opaque') bauiv1.getmesh('level_select_button_opaque')
bauiv1.getmesh('level_select_button_transparent') bauiv1.getmesh('level_select_button_transparent')
@ -802,6 +798,9 @@ class ClassicAppSubsystem(babase.AppSubsystem):
if babase.app.env.gui: if babase.app.env.gui:
bauiv1.getsound('swish').play() bauiv1.getsound('swish').play()
# Pause gameplay.
self.pause()
babase.app.ui_v1.set_main_window( babase.app.ui_v1.set_main_window(
InGameMenuWindow(), is_top_level=True, suppress_warning=True InGameMenuWindow(), is_top_level=True, suppress_warning=True
) )
@ -854,11 +853,13 @@ class ClassicAppSubsystem(babase.AppSubsystem):
) )
@staticmethod @staticmethod
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None: def run_bs_client_effects(
effects: list[bacommon.bs.ClientEffect], delay: float = 0.0
) -> None:
"""Run client effects sent from the master server.""" """Run client effects sent from the master server."""
from baclassic._clienteffect import run_bs_client_effects from baclassic._clienteffect import run_bs_client_effects
run_bs_client_effects(effects) run_bs_client_effects(effects, delay=delay)
@staticmethod @staticmethod
def basic_client_ui_button_label_str( def basic_client_ui_button_label_str(

View file

@ -12,17 +12,23 @@ from efro.util import strict_partial
import bacommon.bs import bacommon.bs
import bauiv1 import bauiv1
import _baclassic
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None: def run_bs_client_effects(
effects: list[bacommon.bs.ClientEffect], delay: float = 0.0
) -> None:
"""Run effects.""" """Run effects."""
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
from bacommon.bs import ClientEffectTypeID
delay = 0.0
for effect in effects: for effect in effects:
if isinstance(effect, bacommon.bs.ClientEffectScreenMessage): effecttype = effect.get_type_id()
if effecttype is ClientEffectTypeID.SCREEN_MESSAGE:
assert isinstance(effect, bacommon.bs.ClientEffectScreenMessage)
textfin = bauiv1.Lstr( textfin = bauiv1.Lstr(
translate=('serverResponses', effect.message) translate=('serverResponses', effect.message)
).evaluate() ).evaluate()
@ -41,7 +47,8 @@ def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
), ),
) )
elif isinstance(effect, bacommon.bs.ClientEffectSound): elif effecttype is ClientEffectTypeID.SOUND:
assert isinstance(effect, bacommon.bs.ClientEffectSound)
smcls = bacommon.bs.ClientEffectSound.Sound smcls = bacommon.bs.ClientEffectSound.Sound
soundfile: str | None = None soundfile: str | None = None
if effect.sound is smcls.UNKNOWN: if effect.sound is smcls.UNKNOWN:
@ -66,12 +73,63 @@ def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
), ),
) )
elif isinstance(effect, bacommon.bs.ClientEffectDelay): elif effecttype is ClientEffectTypeID.DELAY:
assert isinstance(effect, bacommon.bs.ClientEffectDelay)
delay += effect.seconds delay += effect.seconds
else:
elif effecttype is ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION:
assert isinstance(
effect, bacommon.bs.ClientEffectChestWaitTimeAnimation
)
bauiv1.apptimer(
delay,
strict_partial(
_baclassic.animate_root_ui_chest_unlock_time,
chestid=effect.chestid,
duration=effect.duration,
startvalue=effect.startvalue.timestamp(),
endvalue=effect.endvalue.timestamp(),
),
)
elif effecttype is ClientEffectTypeID.TICKETS_ANIMATION:
assert isinstance(effect, bacommon.bs.ClientEffectTicketsAnimation)
bauiv1.apptimer(
delay,
strict_partial(
_baclassic.animate_root_ui_tickets,
duration=effect.duration,
startvalue=effect.startvalue,
endvalue=effect.endvalue,
),
)
elif effecttype is ClientEffectTypeID.TOKENS_ANIMATION:
assert isinstance(effect, bacommon.bs.ClientEffectTokensAnimation)
bauiv1.apptimer(
delay,
strict_partial(
_baclassic.animate_root_ui_tokens,
duration=effect.duration,
startvalue=effect.startvalue,
endvalue=effect.endvalue,
),
)
elif effecttype is ClientEffectTypeID.UNKNOWN:
# Server should not send us stuff we can't digest. Make # Server should not send us stuff we can't digest. Make
# some noise if it happens. # some noise if it happens.
logging.error( logging.error(
'Got unrecognized bacommon.bs.ClientEffect;' 'Got unrecognized bacommon.bs.ClientEffect;'
' should not happen.' ' should not happen.'
) )
else:
# For type-checking purposes to remind us to implement new
# types; should this this in real life.
assert_never(effecttype)
# Lastly, put a pause on root ui auto-updates so that everything we
# just scheduled is free to muck with it freely.
bauiv1.root_ui_pause_updates()
bauiv1.apptimer(delay + 0.25, bauiv1.root_ui_resume_updates)

38
dist/ba_data/python/baclassic/_hooks.py vendored Normal file
View file

@ -0,0 +1,38 @@
# Released under the MIT License. See LICENSE for details.
#
"""Hooks for C++ layer to use for ClassicAppMode."""
from __future__ import annotations
import logging
import babase
def on_engine_will_reset() -> None:
"""Called just before classic resets the engine."""
from baclassic._appmode import ClassicAppMode
appmode = babase.app.mode
# Just pass this along to our mode instance for handling.
if isinstance(appmode, ClassicAppMode):
appmode.on_engine_will_reset()
else:
logging.error(
'on_engine_will_reset called without ClassicAppMode active.'
)
def on_engine_did_reset() -> None:
"""Called just after classic resets the engine."""
from baclassic._appmode import ClassicAppMode
appmode = babase.app.mode
# Just pass this along to our mode instance for handling.
if isinstance(appmode, ClassicAppMode):
appmode.on_engine_did_reset()
else:
logging.error(
'on_engine_did_reset called without ClassicAppMode active.'
)

View file

@ -20,10 +20,7 @@ if TYPE_CHECKING:
class MusicPlayMode(Enum): class MusicPlayMode(Enum):
"""Influences behavior when playing music. """Influences behavior when playing music."""
Category: **Enums**
"""
REGULAR = 'regular' REGULAR = 'regular'
TEST = 'test' TEST = 'test'
@ -31,10 +28,7 @@ class MusicPlayMode(Enum):
@dataclass @dataclass
class AssetSoundtrackEntry: class AssetSoundtrackEntry:
"""A music entry using an internal asset. """A music entry using an internal asset."""
Category: **App Classes**
"""
assetname: str assetname: str
volume: float = 1.0 volume: float = 1.0
@ -81,8 +75,6 @@ ASSET_SOUNDTRACK_ENTRIES: dict[MusicType, AssetSoundtrackEntry] = {
class MusicSubsystem: class MusicSubsystem:
"""Subsystem for music playback in the app. """Subsystem for music playback in the app.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.music'. Access the single shared instance of this class at 'ba.app.music'.
""" """
@ -374,8 +366,6 @@ class MusicSubsystem:
class MusicPlayer: class MusicPlayer:
"""Wrangles soundtrack music playback. """Wrangles soundtrack music playback.
Category: **App Classes**
Music can be played either through the game itself Music can be played either through the game itself
or via a platform-specific external player. or via a platform-specific external player.
""" """

View file

@ -96,7 +96,7 @@ class MasterServerV1CallThread(threading.Thread):
try: try:
classic = babase.app.classic classic = babase.app.classic
assert classic is not None assert classic is not None
self._data = babase.utf8_all(self._data) self._data = _utf8_all(self._data)
babase.set_thread_name('BA_ServerCallThread') babase.set_thread_name('BA_ServerCallThread')
if self._request_type == 'get': if self._request_type == 'get':
msaddr = plus.get_master_server_address() msaddr = plus.get_master_server_address()
@ -164,3 +164,19 @@ class MasterServerV1CallThread(threading.Thread):
babase.Call(self._run_callback, response_data), babase.Call(self._run_callback, response_data),
from_other_thread=True, from_other_thread=True,
) )
def _utf8_all(data: Any) -> Any:
"""Convert any unicode data in provided sequence(s) to utf8 bytes."""
if isinstance(data, dict):
return dict(
(_utf8_all(key), _utf8_all(value))
for key, value in list(data.items())
)
if isinstance(data, list):
return [_utf8_all(element) for element in data]
if isinstance(data, tuple):
return tuple(_utf8_all(element) for element in data)
if isinstance(data, str):
return data.encode('utf-8', errors='ignore')
return data

View file

@ -87,10 +87,7 @@ def _cmd(command_data: bytes) -> None:
class ServerController: class ServerController:
"""Overall controller for the app in server mode. """Overall controller for the app in server mode."""
Category: **App Classes**
"""
def __init__(self, config: ServerConfig) -> None: def __init__(self, config: ServerConfig) -> None:
self._config = config self._config = config
@ -390,7 +387,7 @@ class ServerController:
f' ({app.env.engine_build_number})' f' ({app.env.engine_build_number})'
f' entering server-mode {curtimestr}{Clr.RST}' f' entering server-mode {curtimestr}{Clr.RST}'
) )
print(startupmsg) logging.info(startupmsg)
if sessiontype is bascenev1.FreeForAllSession: if sessiontype is bascenev1.FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name appcfg['Free-for-All Playlist Selection'] = self._playlist_name

View file

@ -565,10 +565,7 @@ class StoreSubsystem:
return None return None
def get_unowned_maps(self) -> list[str]: def get_unowned_maps(self) -> list[str]:
"""Return the list of local maps not owned by the current account. """Return the list of local maps not owned by the current account."""
Category: **Asset Functions**
"""
plus = babase.app.plus plus = babase.app.plus
unowned_maps: set[str] = set() unowned_maps: set[str] = set()
if babase.app.env.gui: if babase.app.env.gui:

View file

@ -1,3 +1,6 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Functionality and data common to ballistica client and server components.""" """Functionality and data shared by all Ballistica components.
This includes clients, various servers, tools, etc.
"""

View file

@ -1,6 +1,6 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Common high level values/functionality related to apps.""" """Common high level values/functionality related to Ballistica apps."""
from __future__ import annotations from __future__ import annotations
@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs from efro.dataclassio import ioprepped, IOAttrs
from bacommon.locale import Locale
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@ -17,19 +19,41 @@ if TYPE_CHECKING:
class AppInterfaceIdiom(Enum): class AppInterfaceIdiom(Enum):
"""A general form-factor or method of experiencing a Ballistica app. """A general form-factor or method of experiencing a Ballistica app.
Note that it is possible for a running app to switch idioms (for Note that it may be possible for a running app to switch idioms (for
instance if a mobile device or computer is connected to a TV). instance if a mobile device or computer is connected to a TV).
""" """
PHONE = 'phone' #: Small screen; assumed to have touch as primary input.
TABLET = 'tablet' PHONE = 'phn'
DESKTOP = 'desktop'
#: Medium size screen; assumed to have touch as primary input.
TABLET = 'tab'
#: Medium size screen; assumed to have game controller as primary
#: input.
HANDHELD = 'hnd'
#: Large screen with high amount of detail visible; assumed to have
#: keyboard/mouse as primary input.
DESKTOP = 'dsk'
#: Large screen with medium amount of detail visible; assumed to have
#: game controller as primary input.
TV = 'tv' TV = 'tv'
XR = 'xr'
#: Displayed over or in place of of the real world on a headset;
#: assumed to have hand tracking or spacial controllers as primary
#: input.
XR_HEADSET = 'xrh'
#: Displayed over or instead of the real world on a screen; assumed
#: to have device movement augmented by physical or touchscreen
#: controls as primary input.
XR_SCREEN = 'xrs'
class AppExperience(Enum): class AppExperience(Enum):
"""A particular experience that can be provided by a Ballistica app. """A particular experience provided by a Ballistica app.
This is one metric used to isolate different playerbases from each This is one metric used to isolate different playerbases from each
other where there might be no technical barriers doing so. For other where there might be no technical barriers doing so. For
@ -44,36 +68,36 @@ class AppExperience(Enum):
support multiple experiences, or there may be multiple apps support multiple experiences, or there may be multiple apps
targeting one experience. Cloud components such as leagues are targeting one experience. Cloud components such as leagues are
generally associated with an AppExperience so that they are only generally associated with an AppExperience so that they are only
visible to client apps designed for that play style. visible to client apps designed for that play style, and the same is
true for games joinable over the local network, bluetooth, etc.
""" """
# An experience that is supported everywhere. Used for the default #: An experience that is supported everywhere. Used for the default
# empty AppMode when starting the app, etc. #: empty AppMode when starting the app, etc.
EMPTY = 'empty' EMPTY = 'empt'
# The traditional BombSquad experience: multiple players using #: The traditional BombSquad experience - multiple players using
# traditional game controllers (or touch screen equivalents) in a #: game controllers (or touch screen equivalents) in a single arena
# single arena small enough for all action to be viewed on a single #: small enough for all action to be viewed on a single screen.
# screen. MELEE = 'mlee'
MELEE = 'melee'
# The traditional BombSquad Remote experience; buttons on a #: The traditional BombSquad Remote experience; buttons on a
# touch-screen allowing a mobile device to be used as a game #: touch-screen allowing a mobile device to be used as a game
# controller. #: controller.
REMOTE = 'remote' REMOTE = 'rmt'
class AppArchitecture(Enum): class AppArchitecture(Enum):
"""Processor architecture the App is running on.""" """Processor architecture an app can be running on."""
ARM = 'arm' ARM = 'arm'
ARM64 = 'arm64' ARM64 = 'arm64'
X86 = 'x86' X86 = 'x86'
X86_64 = 'x86_64' X86_64 = 'x64'
class AppPlatform(Enum): class AppPlatform(Enum):
"""Overall platform a Ballistica build is targeting. """Overall platform a build can target.
Each distinct flavor of an app has a unique combination of Each distinct flavor of an app has a unique combination of
AppPlatform and AppVariant. Generally platform describes a set of AppPlatform and AppVariant. Generally platform describes a set of
@ -82,9 +106,9 @@ class AppPlatform(Enum):
""" """
MAC = 'mac' MAC = 'mac'
WINDOWS = 'windows' WINDOWS = 'win'
LINUX = 'linux' LINUX = 'lin'
ANDROID = 'android' ANDROID = 'andr'
IOS = 'ios' IOS = 'ios'
TVOS = 'tvos' TVOS = 'tvos'
@ -98,43 +122,58 @@ class AppVariant(Enum):
build. build.
""" """
# Default builds. #: Default builds.
GENERIC = 'generic' GENERIC = 'gen'
# Builds intended for public testing (may have some extra checks #: Builds intended for public testing (may have some extra checks
# or logging enabled). #: or logging enabled).
TEST = 'test' TEST = 'tst'
# Various stores. # Various stores.
AMAZON_APPSTORE = 'amazon_appstore' AMAZON_APPSTORE = 'amzn'
GOOGLE_PLAY = 'google_play' GOOGLE_PLAY = 'gpl'
APP_STORE = 'app_store' APPLE_APP_STORE = 'appl'
WINDOWS_STORE = 'windows_store' WINDOWS_STORE = 'wins'
STEAM = 'steam' STEAM = 'stm'
META = 'meta' META = 'meta'
EPIC_GAMES_STORE = 'epic_games_store' EPIC_GAMES_STORE = 'epic'
# Other. # Other.
ARCADE = 'arcade' ARCADE = 'arcd'
DEMO = 'demo' DEMO = 'demo'
class AppName(Enum):
"""A predefined Ballistica app name.
This encompasses official or well-known apps. Other app projects
should set this to CUSTOM and provide a 'name_custom' value.
"""
BOMBSQUAD = 'bs'
CUSTOM = 'c'
@ioprepped @ioprepped
@dataclass @dataclass
class AppInstanceInfo: class AppInstanceInfo:
"""General info about an individual running app.""" """General info about an individual running ballistica app."""
name = Annotated[str, IOAttrs('n')] name: Annotated[str, IOAttrs('name')]
name_custom: Annotated[
str | None, IOAttrs('namc', soft_default=None, store_default=False)
]
engine_version = Annotated[str, IOAttrs('ev')] engine_version: Annotated[str, IOAttrs('evrs')]
engine_build = Annotated[int, IOAttrs('eb')] engine_build: Annotated[int, IOAttrs('ebld')]
platform = Annotated[AppPlatform, IOAttrs('p')] platform: Annotated[AppPlatform, IOAttrs('plat')]
variant = Annotated[AppVariant, IOAttrs('va')] variant: Annotated[AppVariant, IOAttrs('vrnt')]
architecture = Annotated[AppArchitecture, IOAttrs('a')] architecture: Annotated[AppArchitecture, IOAttrs('arch')]
os_version = Annotated[str | None, IOAttrs('o')] os_version: Annotated[str | None, IOAttrs('osvr')]
interface_idiom: Annotated[AppInterfaceIdiom, IOAttrs('i')] interface_idiom: Annotated[AppInterfaceIdiom, IOAttrs('intf')]
locale: Annotated[str, IOAttrs('l')] locale: Annotated[Locale, IOAttrs('loc')]
device: Annotated[str | None, IOAttrs('d')] #: OS-specific string describing the device running the app.
device: Annotated[str | None, IOAttrs('devc')]

View file

@ -51,44 +51,7 @@ class RequestData:
@ioprepped @ioprepped
@dataclass @dataclass
class ResponseData: class ResponseData:
"""Response sent from the bacloud server to the client. """Response sent from the bacloud server to the client."""
Attributes:
message: If present, client should print this message before any other
response processing (including error handling) occurs.
message_end: end arg for message print() call.
error: If present, client should abort with this error message.
delay_seconds: How long to wait before proceeding with remaining
response (can be useful when waiting for server progress in a loop).
login: If present, a token that should be stored client-side and passed
with subsequent commands.
logout: If True, any existing client-side token should be discarded.
dir_manifest: If present, client should generate a manifest of this dir.
It should be added to end_command args as 'manifest'.
uploads: If present, client should upload the requested files (arg1)
individually to a server command (arg2) with provided args (arg3).
uploads_inline: If present, a list of pathnames that should be gzipped
and uploaded to an 'uploads_inline' bytes dict in end_command args.
This should be limited to relatively small files.
deletes: If present, file paths that should be deleted on the client.
downloads: If present, describes files the client should individually
request from the server if not already present on the client.
downloads_inline: If present, pathnames mapped to gzipped data to
be written to the client. This should only be used for relatively
small files as they are all included inline as part of the response.
dir_prune_empty: If present, all empty dirs under this one should be
removed.
open_url: If present, url to display to the user.
input_prompt: If present, a line of input is read and placed into
end_command args as 'input'. The first value is the prompt printed
before reading and the second is whether it should be read as a
password (without echoing to the terminal).
end_message: If present, a message that should be printed after all other
response processing is done.
end_message_end: end arg for end_message print() call.
end_command: If present, this command is run with these args at the end
of response processing.
"""
@ioprepped @ioprepped
@dataclass @dataclass
@ -101,62 +64,114 @@ class ResponseData:
"""Individual download.""" """Individual download."""
path: Annotated[str, IOAttrs('p')] path: Annotated[str, IOAttrs('p')]
# Args include with this particular request (combined with
# baseargs). #: Args include with this particular request (combined with
#: baseargs).
args: Annotated[dict[str, str], IOAttrs('a')] args: Annotated[dict[str, str], IOAttrs('a')]
# TODO: could add a hash here if we want the client to # TODO: could add a hash here if we want the client to
# verify hashes. # verify hashes.
# If present, will be prepended to all entry paths via os.path.join. #: If present, will be prepended to all entry paths via os.path.join.
basepath: Annotated[str | None, IOAttrs('p')] basepath: Annotated[str | None, IOAttrs('p')]
# Server command that should be called for each download. The #: Server command that should be called for each download. The
# server command is expected to respond with a downloads_inline #: server command is expected to respond with a downloads_inline
# containing a single 'default' entry. In the future this may #: containing a single 'default' entry. In the future this may
# be expanded to a more streaming-friendly process. #: be expanded to a more streaming-friendly process.
cmd: Annotated[str, IOAttrs('c')] cmd: Annotated[str, IOAttrs('c')]
# Args that should be included with all download requests. #: Args that should be included with all download requests.
baseargs: Annotated[dict[str, str], IOAttrs('a')] baseargs: Annotated[dict[str, str], IOAttrs('a')]
# Everything that should be downloaded. #: Everything that should be downloaded.
entries: Annotated[list[Entry], IOAttrs('e')] entries: Annotated[list[Entry], IOAttrs('e')]
#: If present, client should print this message before any other
#: response processing (including error handling) occurs.
message: Annotated[str | None, IOAttrs('m', store_default=False)] = None message: Annotated[str | None, IOAttrs('m', store_default=False)] = None
#: End arg for message print() call.
message_end: Annotated[str, IOAttrs('m_end', store_default=False)] = '\n' message_end: Annotated[str, IOAttrs('m_end', store_default=False)] = '\n'
#: If present, client should abort with this error message.
error: Annotated[str | None, IOAttrs('e', store_default=False)] = None error: Annotated[str | None, IOAttrs('e', store_default=False)] = None
#: How long to wait before proceeding with remaining response (can
#: be useful when waiting for server progress in a loop).
delay_seconds: Annotated[float, IOAttrs('d', store_default=False)] = 0.0 delay_seconds: Annotated[float, IOAttrs('d', store_default=False)] = 0.0
#: If present, a token that should be stored client-side and passed
#: with subsequent commands.
login: Annotated[str | None, IOAttrs('l', store_default=False)] = None login: Annotated[str | None, IOAttrs('l', store_default=False)] = None
#: If True, any existing client-side token should be discarded.
logout: Annotated[bool, IOAttrs('lo', store_default=False)] = False logout: Annotated[bool, IOAttrs('lo', store_default=False)] = False
#: If present, client should generate a manifest of this dir.
#: It should be added to end_command args as 'manifest'.
dir_manifest: Annotated[str | None, IOAttrs('man', store_default=False)] = ( dir_manifest: Annotated[str | None, IOAttrs('man', store_default=False)] = (
None None
) )
#: If present, client should upload the requested files (arg1)
#: individually to a server command (arg2) with provided args (arg3).
uploads: Annotated[ uploads: Annotated[
tuple[list[str], str, dict] | None, IOAttrs('u', store_default=False) tuple[list[str], str, dict] | None, IOAttrs('u', store_default=False)
] = None ] = None
#: If present, a list of pathnames that should be gzipped
#: and uploaded to an 'uploads_inline' bytes dict in end_command args.
#: This should be limited to relatively small files.
uploads_inline: Annotated[ uploads_inline: Annotated[
list[str] | None, IOAttrs('uinl', store_default=False) list[str] | None, IOAttrs('uinl', store_default=False)
] = None ] = None
#: If present, file paths that should be deleted on the client.
deletes: Annotated[ deletes: Annotated[
list[str] | None, IOAttrs('dlt', store_default=False) list[str] | None, IOAttrs('dlt', store_default=False)
] = None ] = None
#: If present, describes files the client should individually
#: request from the server if not already present on the client.
downloads: Annotated[ downloads: Annotated[
Downloads | None, IOAttrs('dl', store_default=False) Downloads | None, IOAttrs('dl', store_default=False)
] = None ] = None
#: If present, pathnames mapped to gzipped data to
#: be written to the client. This should only be used for relatively
#: small files as they are all included inline as part of the response.
downloads_inline: Annotated[ downloads_inline: Annotated[
dict[str, bytes] | None, IOAttrs('dinl', store_default=False) dict[str, bytes] | None, IOAttrs('dinl', store_default=False)
] = None ] = None
#: If present, all empty dirs under this one should be removed.
dir_prune_empty: Annotated[ dir_prune_empty: Annotated[
str | None, IOAttrs('dpe', store_default=False) str | None, IOAttrs('dpe', store_default=False)
] = None ] = None
#: If present, url to display to the user.
open_url: Annotated[str | None, IOAttrs('url', store_default=False)] = None open_url: Annotated[str | None, IOAttrs('url', store_default=False)] = None
#: If present, a line of input is read and placed into
#: end_command args as 'input'. The first value is the prompt printed
#: before reading and the second is whether it should be read as a
#: password (without echoing to the terminal).
input_prompt: Annotated[ input_prompt: Annotated[
tuple[str, bool] | None, IOAttrs('inp', store_default=False) tuple[str, bool] | None, IOAttrs('inp', store_default=False)
] = None ] = None
#: If present, a message that should be printed after all other
#: response processing is done.
end_message: Annotated[str | None, IOAttrs('em', store_default=False)] = ( end_message: Annotated[str | None, IOAttrs('em', store_default=False)] = (
None None
) )
#: End arg for end_message print() call.
end_message_end: Annotated[str, IOAttrs('eme', store_default=False)] = '\n' end_message_end: Annotated[str, IOAttrs('eme', store_default=False)] = '\n'
#: If present, this command is run with these args at the end
#: of response processing.
end_command: Annotated[ end_command: Annotated[
tuple[str, dict] | None, IOAttrs('ec', store_default=False) tuple[str, dict] | None, IOAttrs('ec', store_default=False)
] = None ] = None

View file

@ -13,6 +13,12 @@ from efro.util import pairs_to_flat
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from efro.message import Message, Response from efro.message import Message, Response
# Token counts for our various packs.
TOKENS1_COUNT = 50
TOKENS2_COUNT = 500
TOKENS3_COUNT = 1200
TOKENS4_COUNT = 2600
@ioprepped @ioprepped
@dataclass @dataclass
@ -89,7 +95,9 @@ class ClassicAccountLiveData:
ClassicChestAppearance, ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
] ]
create_time: Annotated[datetime.datetime, IOAttrs('c')]
unlock_time: Annotated[datetime.datetime, IOAttrs('t')] unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
unlock_tokens: Annotated[int, IOAttrs('k')]
ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')] ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')]
class LeagueType(Enum): class LeagueType(Enum):
@ -119,6 +127,7 @@ class ClassicAccountLiveData:
inbox_count: Annotated[int, IOAttrs('ibc')] inbox_count: Annotated[int, IOAttrs('ibc')]
inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')] inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')]
inbox_contains_prize: Annotated[bool, IOAttrs('icp')]
chests: Annotated[dict[str, Chest], IOAttrs('c')] chests: Annotated[dict[str, Chest], IOAttrs('c')]
@ -341,64 +350,6 @@ class ChestInfoResponse(Response):
user_tokens: Annotated[int | None, IOAttrs('t')] user_tokens: Annotated[int | None, IOAttrs('t')]
@ioprepped
@dataclass
class ChestActionMessage(Message):
"""Request action about a chest."""
class Action(Enum):
"""Types of actions we can request."""
# Unlocking (for free or with tokens).
UNLOCK = 'u'
# Watched an ad to reduce wait.
AD = 'ad'
action: Annotated[Action, IOAttrs('a')]
# Tokens we are paying (only applies to unlock).
token_payment: Annotated[int, IOAttrs('t')]
chest_id: Annotated[str, IOAttrs('i')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ChestActionResponse]
@ioprepped
@dataclass
class ChestActionResponse(Response):
"""Here's the results of that action you asked for, boss."""
# Tokens that were actually charged.
tokens_charged: Annotated[int, IOAttrs('t')] = 0
# If present, signifies the chest has been opened and we should show
# the user this stuff that was in it.
contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None
# If contents are present, which of the chest's prize-sets they
# represent.
prizeindex: Annotated[int, IOAttrs('i')] = 0
# Printable error if something goes wrong.
error: Annotated[str | None, IOAttrs('e')] = None
# Printable warning. Shown in orange with an error sound. Does not
# mean the action failed; only that there's something to tell the
# users such as 'It looks like you are faking ad views; stop it or
# you won't have ad options anymore.'
warning: Annotated[str | None, IOAttrs('w')] = None
# Printable success message. Shown in green with a cash-register
# sound. Can be used for things like successful wait reductions via
# ad views.
success_msg: Annotated[str | None, IOAttrs('s')] = None
class ClientUITypeID(Enum): class ClientUITypeID(Enum):
"""Type ID for each of our subclasses.""" """Type ID for each of our subclasses."""
@ -717,6 +668,9 @@ class ClientEffectTypeID(Enum):
SCREEN_MESSAGE = 'm' SCREEN_MESSAGE = 'm'
SOUND = 's' SOUND = 's'
DELAY = 'd' DELAY = 'd'
CHEST_WAIT_TIME_ANIMATION = 't'
TICKETS_ANIMATION = 'ta'
TOKENS_ANIMATION = 'toa'
class ClientEffect(IOMultiType[ClientEffectTypeID]): class ClientEffect(IOMultiType[ClientEffectTypeID]):
@ -738,6 +692,7 @@ class ClientEffect(IOMultiType[ClientEffectTypeID]):
def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]: def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]:
"""Return the subclass for each of our type-ids.""" """Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
# pylint: disable=too-many-return-statements
t = ClientEffectTypeID t = ClientEffectTypeID
if type_id is t.UNKNOWN: if type_id is t.UNKNOWN:
@ -748,6 +703,12 @@ class ClientEffect(IOMultiType[ClientEffectTypeID]):
return ClientEffectSound return ClientEffectSound
if type_id is t.DELAY: if type_id is t.DELAY:
return ClientEffectDelay return ClientEffectDelay
if type_id is t.CHEST_WAIT_TIME_ANIMATION:
return ClientEffectChestWaitTimeAnimation
if type_id is t.TICKETS_ANIMATION:
return ClientEffectTicketsAnimation
if type_id is t.TOKENS_ANIMATION:
return ClientEffectTokensAnimation
# Important to make sure we provide all types. # Important to make sure we provide all types.
assert_never(type_id) assert_never(type_id)
@ -809,6 +770,52 @@ class ClientEffectSound(ClientEffect):
return ClientEffectTypeID.SOUND return ClientEffectTypeID.SOUND
@ioprepped
@dataclass
class ClientEffectChestWaitTimeAnimation(ClientEffect):
"""Animate chest wait time changing."""
chestid: Annotated[str, IOAttrs('c')]
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[datetime.datetime, IOAttrs('o')]
endvalue: Annotated[datetime.datetime, IOAttrs('n')]
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.CHEST_WAIT_TIME_ANIMATION
@ioprepped
@dataclass
class ClientEffectTicketsAnimation(ClientEffect):
"""Animate tickets count."""
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[int, IOAttrs('s')]
endvalue: Annotated[int, IOAttrs('e')]
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.TICKETS_ANIMATION
@ioprepped
@dataclass
class ClientEffectTokensAnimation(ClientEffect):
"""Animate tokens count."""
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[int, IOAttrs('s')]
endvalue: Annotated[int, IOAttrs('e')]
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.TOKENS_ANIMATION
@ioprepped @ioprepped
@dataclass @dataclass
class ClientEffectDelay(ClientEffect): class ClientEffectDelay(ClientEffect):
@ -885,3 +892,68 @@ class ScoreSubmitResponse(Response):
# Things we should show on our end. # Things we should show on our end.
effects: Annotated[list[ClientEffect], IOAttrs('fx')] effects: Annotated[list[ClientEffect], IOAttrs('fx')]
@ioprepped
@dataclass
class ChestActionMessage(Message):
"""Request action about a chest."""
class Action(Enum):
"""Types of actions we can request."""
# Unlocking (for free or with tokens).
UNLOCK = 'u'
# Watched an ad to reduce wait.
AD = 'ad'
action: Annotated[Action, IOAttrs('a')]
# Tokens we are paying (only applies to unlock).
token_payment: Annotated[int, IOAttrs('t')]
chest_id: Annotated[str, IOAttrs('i')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ChestActionResponse]
@ioprepped
@dataclass
class ChestActionResponse(Response):
"""Here's the results of that action you asked for, boss."""
# Tokens that were actually charged.
tokens_charged: Annotated[int, IOAttrs('t')] = 0
# If present, signifies the chest has been opened and we should show
# the user this stuff that was in it.
contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None
# If contents are present, which of the chest's prize-sets they
# represent.
prizeindex: Annotated[int, IOAttrs('i')] = 0
# Printable error if something goes wrong.
error: Annotated[str | None, IOAttrs('e')] = None
# Printable warning. Shown in orange with an error sound. Does not
# mean the action failed; only that there's something to tell the
# users such as 'It looks like you are faking ad views; stop it or
# you won't have ad options anymore.'
warning: Annotated[str | None, IOAttrs('w', store_default=False)] = None
# Printable success message. Shown in green with a cash-register
# sound. Can be used for things like successful wait reductions via
# ad views. Used in builds earlier than 22311; can remove once
# 22311+ is ubiquitous.
success_msg: Annotated[str | None, IOAttrs('s', store_default=False)] = None
# Effects to show on the client. Replaces warning and success_msg in
# build 22311 or newer.
effects: Annotated[
list[ClientEffect], IOAttrs('fx', store_default=False)
] = field(default_factory=list)

View file

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Annotated, override
from efro.message import Message, Response from efro.message import Message, Response
from efro.dataclassio import ioprepped, IOAttrs from efro.dataclassio import ioprepped, IOAttrs
from bacommon.securedata import SecureDataChecker
from bacommon.transfer import DirectoryManifest from bacommon.transfer import DirectoryManifest
from bacommon.login import LoginType from bacommon.login import LoginType
@ -300,3 +301,45 @@ class StoreQueryResponse(Response):
available_purchases: Annotated[list[Purchase], IOAttrs('p')] available_purchases: Annotated[list[Purchase], IOAttrs('p')]
token_info_url: Annotated[str, IOAttrs('tiu')] token_info_url: Annotated[str, IOAttrs('tiu')]
@ioprepped
@dataclass
class SecureDataCheckMessage(Message):
"""Was this data signed by the master-server?."""
data: Annotated[bytes, IOAttrs('d')]
signature: Annotated[bytes, IOAttrs('s')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [SecureDataCheckResponse]
@ioprepped
@dataclass
class SecureDataCheckResponse(Response):
"""Here's the result of that data check, boss."""
# Whether the data signature was valid.
result: Annotated[bool, IOAttrs('v')]
@ioprepped
@dataclass
class SecureDataCheckerRequest(Message):
"""Can I get a checker over here?."""
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [SecureDataCheckerResponse]
@ioprepped
@dataclass
class SecureDataCheckerResponse(Response):
"""Here's that checker ya asked for, boss."""
checker: Annotated[SecureDataChecker, IOAttrs('c')]

23
dist/ba_data/python/bacommon/locale.py vendored Normal file
View file

@ -0,0 +1,23 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality for wrangling locale info."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class Locale(Enum):
"""A distinct combination of language and possibly country/etc.
Note that some locales here may be superseded by other more specific
ones (for instance PORTUGUESE -> PORTUGUESE_BRAZIL), but the
originals must continue to exist here since they may remain in use
in the wild.
"""
ENGLISH = 'en'

View file

@ -20,18 +20,18 @@ if TYPE_CHECKING:
class LoginType(Enum): class LoginType(Enum):
"""Types of logins available.""" """Types of logins available."""
# Email/password #: Email/password
EMAIL = 'email' EMAIL = 'email'
# Google Play Game Services #: Google Play Game Services
GPGS = 'gpgs' GPGS = 'gpgs'
# Apple's Game Center #: Apple's Game Center
GAME_CENTER = 'game_center' GAME_CENTER = 'game_center'
@property @property
def displayname(self) -> str: def displayname(self) -> str:
"""Human readable name for this value.""" """A human readable name for this value."""
cls = type(self) cls = type(self)
match self: match self:
case cls.EMAIL: case cls.EMAIL:
@ -43,7 +43,7 @@ class LoginType(Enum):
@property @property
def displaynameshort(self) -> str: def displaynameshort(self) -> str:
"""Human readable name for this value.""" """A short human readable name for this value."""
cls = type(self) cls = type(self)
match self: match self:
case cls.EMAIL: case cls.EMAIL:

View file

@ -0,0 +1,56 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to verifying ballistica server generated data."""
import datetime
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from efro.util import utc_now
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class SecureDataChecker:
"""Verifies data as being signed by our master server."""
# Time period this checker is valid for.
starttime: Annotated[datetime.datetime, IOAttrs('s')]
endtime: Annotated[datetime.datetime, IOAttrs('e')]
# Current set of public keys.
publickeys: Annotated[list[bytes], IOAttrs('k')]
def check(self, data: bytes, signature: bytes) -> bool:
"""Verify data, returning True if successful.
Note that this call imports and uses the cryptography module and
can be slow; it generally should be done in a background thread
or on a server.
"""
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.exceptions import InvalidSignature
now = utc_now()
# Make sure we seem valid based on local time.
if now < self.starttime:
raise RuntimeError('SecureDataChecker starttime is in the future.')
if now > self.endtime:
raise RuntimeError('SecureDataChecker endtime is in the past.')
# Try our keys from newest to oldest. Most stuff will be using
# the newest key so this should be most efficient.
for key in reversed(self.publickeys):
try:
publickey = ed25519.Ed25519PublicKey.from_public_bytes(key)
publickey.verify(signature, data)
return True
except InvalidSignature:
pass
return False

View file

@ -1,3 +1,3 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Workspace functionality.""" """Functionality related to ballistica.net workspaces."""

View file

@ -1,11 +1,11 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Manage ballistica execution environment. """Manage Ballistica execution environment.
This module is used to set up and/or check the global Python environment This module is used to set up and/or check the global Python environment
before running a ballistica app. This includes things such as paths, before running a Ballistica app. This includes things such as paths,
logging, and app-dirs. Because these things are global in nature, this logging, and app-dirs. Because these things are global in nature, this
should be done before any ballistica modules are imported. should be done before any Ballistica modules are imported.
This module can also be exec'ed directly to set up a default environment This module can also be exec'ed directly to set up a default environment
and then run the app. and then run the app.
@ -53,46 +53,46 @@ if TYPE_CHECKING:
# Build number and version of the ballistica binary we expect to be # Build number and version of the ballistica binary we expect to be
# using. # using.
TARGET_BALLISTICA_BUILD = 22278 TARGET_BALLISTICA_BUILD = 22350
TARGET_BALLISTICA_VERSION = '1.7.37' TARGET_BALLISTICA_VERSION = '1.7.39'
@dataclass @dataclass
class EnvConfig: class EnvConfig:
"""Final config values we provide to the engine.""" """Final config values we provide to the engine."""
# Where app config/state data lives. #: Where app config/state data lives.
config_dir: str config_dir: str
# Directory containing ba_data and any other platform-specific data. #: Directory containing ba_data and any other platform-specific data.
data_dir: str data_dir: str
# Where the app's built-in Python stuff lives. #: Where the app's built-in Python stuff lives.
app_python_dir: str | None app_python_dir: str | None
# Where the app's built-in Python stuff lives in the default case. #: Where the app's built-in Python stuff lives in the default case.
standard_app_python_dir: str standard_app_python_dir: str
# Where the app's bundled third party Python stuff lives. #: Where the app's bundled third party Python stuff lives.
site_python_dir: str | None site_python_dir: str | None
# Custom Python provided by the user (mods). #: Custom Python provided by the user (mods).
user_python_dir: str | None user_python_dir: str | None
# We have a mechanism allowing app scripts to be overridden by #: We have a mechanism allowing app scripts to be overridden by
# placing a specially named directory in a user-scripts dir. #: placing a specially named directory in a user-scripts dir. This is
# This is true if that is enabled. #: true if that is enabled.
is_user_app_python_dir: bool is_user_app_python_dir: bool
# Our fancy app log handler. This handles feeding logs, stdout, and #: Our fancy app log handler. This handles feeding logs, stdout, and
# stderr into the engine so they show up on in-app consoles, etc. #: stderr into the engine so they show up on in-app consoles, etc.
log_handler: LogHandler | None log_handler: LogHandler | None
# Initial data from the config.json file in the config dir. The #: Initial data from the config.json file in the config dir. The
# config file is parsed by #: config file is parsed by
initial_app_config: Any initial_app_config: Any
# Timestamp when we first started doing stuff. #: Timestamp when we first started doing stuff.
launch_time: float launch_time: float
@ -100,10 +100,9 @@ class EnvConfig:
class _EnvGlobals: class _EnvGlobals:
"""Globals related to baenv's operation. """Globals related to baenv's operation.
We store this in __main__ instead of in our own module because it We store this in __main__ instead of in our own module because it is
is likely that multiple versions of our module will be spun up likely that multiple versions of our module will be spun up and we
and we want a single set of globals (see notes at top of our module want a single set of globals (see notes at top of our module code).
code).
""" """
config: EnvConfig | None = None config: EnvConfig | None = None

View file

@ -3,10 +3,10 @@
"""Closed-source bits of ballistica. """Closed-source bits of ballistica.
This code concerns sensitive things like accounts and master-server This code concerns sensitive things like accounts and master-server
communication so the native C++ parts of it remain closed. Native communication, so the native C++ parts of it remain closed. Native
precompiled static libraries of this portion are provided for those who precompiled static libraries of this portion are provided for those who
want to compile the rest of the engine, or a fully open-source app want to compile the rest of the engine, or a fully open-source app can
can also be built by removing this feature-set. also be built by removing this feature-set.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -21,10 +21,10 @@ if TYPE_CHECKING:
class PlusAppSubsystem(AppSubsystem): class PlusAppSubsystem(AppSubsystem):
"""Subsystem for plus functionality in the app. """Subsystem for plus functionality in the app.
The single shared instance of this app can be accessed at Access the single shared instance of this class via the
babase.app.plus. Note that it is possible for this to be None if the :attr:`~babase.App.plus` attr on the :class:`~babase.App` class.
plus package is not present, and code should handle that case Note that it is possible for this to be ``None`` if the plus package
gracefully. is not present, so code should handle that case gracefully.
""" """
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
@ -38,6 +38,7 @@ class PlusAppSubsystem(AppSubsystem):
@override @override
def on_app_loading(self) -> None: def on_app_loading(self) -> None:
""":meta private:"""
_baplus.on_app_loading() _baplus.on_app_loading()
self.accounts.on_app_loading() self.accounts.on_app_loading()
@ -45,173 +46,164 @@ class PlusAppSubsystem(AppSubsystem):
def add_v1_account_transaction( def add_v1_account_transaction(
transaction: dict, callback: Callable | None = None transaction: dict, callback: Callable | None = None
) -> None: ) -> None:
"""(internal)""" """:meta private:"""
return _baplus.add_v1_account_transaction(transaction, callback) return _baplus.add_v1_account_transaction(transaction, callback)
@staticmethod @staticmethod
def game_service_has_leaderboard(game: str, config: str) -> bool: def game_service_has_leaderboard(game: str, config: str) -> bool:
"""(internal) """Given a game and config string, returns whether there is a
leaderboard for it on the game service.
Given a game and config string, returns whether there is a leaderboard :meta private:
for it on the game service.
""" """
return _baplus.game_service_has_leaderboard(game, config) return _baplus.game_service_has_leaderboard(game, config)
@staticmethod @staticmethod
def get_master_server_address(source: int = -1, version: int = 1) -> str: def get_master_server_address(source: int = -1, version: int = 1) -> str:
"""(internal) """Return the address of the master server.
Return the address of the master server. :meta private:
""" """
return _baplus.get_master_server_address(source, version) return _baplus.get_master_server_address(source, version)
@staticmethod @staticmethod
def get_classic_news_show() -> str: def get_classic_news_show() -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_classic_news_show() return _baplus.get_classic_news_show()
@staticmethod @staticmethod
def get_price(item: str) -> str | None: def get_price(item: str) -> str | None:
"""(internal)""" """:meta private:"""
return _baplus.get_price(item) return _baplus.get_price(item)
@staticmethod @staticmethod
def get_v1_account_product_purchased(item: str) -> bool: def get_v1_account_product_purchased(item: str) -> bool:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_product_purchased(item) return _baplus.get_v1_account_product_purchased(item)
@staticmethod @staticmethod
def get_v1_account_product_purchases_state() -> int: def get_v1_account_product_purchases_state() -> int:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_product_purchases_state() return _baplus.get_v1_account_product_purchases_state()
@staticmethod @staticmethod
def get_v1_account_display_string(full: bool = True) -> str: def get_v1_account_display_string(full: bool = True) -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_display_string(full) return _baplus.get_v1_account_display_string(full)
@staticmethod @staticmethod
def get_v1_account_misc_read_val(name: str, default_value: Any) -> Any: def get_v1_account_misc_read_val(name: str, default_value: Any) -> Any:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_misc_read_val(name, default_value) return _baplus.get_v1_account_misc_read_val(name, default_value)
@staticmethod @staticmethod
def get_v1_account_misc_read_val_2(name: str, default_value: Any) -> Any: def get_v1_account_misc_read_val_2(name: str, default_value: Any) -> Any:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_misc_read_val_2(name, default_value) return _baplus.get_v1_account_misc_read_val_2(name, default_value)
@staticmethod @staticmethod
def get_v1_account_misc_val(name: str, default_value: Any) -> Any: def get_v1_account_misc_val(name: str, default_value: Any) -> Any:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_misc_val(name, default_value) return _baplus.get_v1_account_misc_val(name, default_value)
@staticmethod @staticmethod
def get_v1_account_name() -> str: def get_v1_account_name() -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_name() return _baplus.get_v1_account_name()
@staticmethod @staticmethod
def get_v1_account_public_login_id() -> str | None: def get_v1_account_public_login_id() -> str | None:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_public_login_id() return _baplus.get_v1_account_public_login_id()
@staticmethod @staticmethod
def get_v1_account_state() -> str: def get_v1_account_state() -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_state() return _baplus.get_v1_account_state()
@staticmethod @staticmethod
def get_v1_account_state_num() -> int: def get_v1_account_state_num() -> int:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_state_num() return _baplus.get_v1_account_state_num()
@staticmethod @staticmethod
def get_v1_account_ticket_count() -> int: def get_v1_account_ticket_count() -> int:
"""(internal) """Return the number of tickets for the current account.
Return the number of tickets for the current account. :meta private:
""" """
return _baplus.get_v1_account_ticket_count() return _baplus.get_v1_account_ticket_count()
@staticmethod @staticmethod
def get_v1_account_type() -> str: def get_v1_account_type() -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_v1_account_type() return _baplus.get_v1_account_type()
@staticmethod @staticmethod
def get_v2_fleet() -> str: def get_v2_fleet() -> str:
"""(internal)""" """:meta private:"""
return _baplus.get_v2_fleet() return _baplus.get_v2_fleet()
@staticmethod @staticmethod
def have_outstanding_v1_account_transactions() -> bool: def have_outstanding_v1_account_transactions() -> bool:
"""(internal)""" """:meta private:"""
return _baplus.have_outstanding_v1_account_transactions() return _baplus.have_outstanding_v1_account_transactions()
@staticmethod @staticmethod
def in_game_purchase(item: str, price: int) -> None: def in_game_purchase(item: str, price: int) -> None:
"""(internal)""" """:meta private:"""
return _baplus.in_game_purchase(item, price) return _baplus.in_game_purchase(item, price)
@staticmethod @staticmethod
def is_blessed() -> bool: def is_blessed() -> bool:
"""(internal)""" """:meta private:"""
return _baplus.is_blessed() return _baplus.is_blessed()
@staticmethod @staticmethod
def mark_config_dirty() -> None: def mark_config_dirty() -> None:
"""(internal) """:meta private:"""
Category: General Utility Functions
"""
return _baplus.mark_config_dirty() return _baplus.mark_config_dirty()
@staticmethod @staticmethod
def power_ranking_query(callback: Callable, season: Any = None) -> None: def power_ranking_query(callback: Callable, season: Any = None) -> None:
"""(internal)""" """:meta private:"""
return _baplus.power_ranking_query(callback, season) return _baplus.power_ranking_query(callback, season)
@staticmethod @staticmethod
def purchase(item: str) -> None: def purchase(item: str) -> None:
"""(internal)""" """:meta private:"""
return _baplus.purchase(item) return _baplus.purchase(item)
@staticmethod @staticmethod
def report_achievement( def report_achievement(
achievement: str, pass_to_account: bool = True achievement: str, pass_to_account: bool = True
) -> None: ) -> None:
"""(internal)""" """:meta private:"""
return _baplus.report_achievement(achievement, pass_to_account) return _baplus.report_achievement(achievement, pass_to_account)
@staticmethod @staticmethod
def reset_achievements() -> None: def reset_achievements() -> None:
"""(internal)""" """:meta private:"""
return _baplus.reset_achievements() return _baplus.reset_achievements()
@staticmethod @staticmethod
def restore_purchases() -> None: def restore_purchases() -> None:
"""(internal)""" """:meta private:"""
return _baplus.restore_purchases() return _baplus.restore_purchases()
@staticmethod @staticmethod
def run_v1_account_transactions() -> None: def run_v1_account_transactions() -> None:
"""(internal)""" """:meta private:"""
return _baplus.run_v1_account_transactions() return _baplus.run_v1_account_transactions()
@staticmethod @staticmethod
def sign_in_v1(account_type: str) -> None: def sign_in_v1(account_type: str) -> None:
"""(internal) """:meta private:"""
Category: General Utility Functions
"""
return _baplus.sign_in_v1(account_type) return _baplus.sign_in_v1(account_type)
@staticmethod @staticmethod
def sign_out_v1(v2_embedded: bool = False) -> None: def sign_out_v1(v2_embedded: bool = False) -> None:
"""(internal) """:meta private:"""
Category: General Utility Functions
"""
return _baplus.sign_out_v1(v2_embedded) return _baplus.sign_out_v1(v2_embedded)
@staticmethod @staticmethod
@ -228,12 +220,14 @@ class PlusAppSubsystem(AppSubsystem):
campaign: str | None = None, campaign: str | None = None,
level: str | None = None, level: str | None = None,
) -> None: ) -> None:
"""(internal) """Submit a score to the server.
Submit a score to the server; callback will be called with the results. Callback will be called with the results. As a courtesy, please
As a courtesy, please don't send fake scores to the server. I'd prefer don't send fake scores to the server. I'd prefer to devote my
to devote my time to improving the game instead of trying to make the time to improving the game instead of trying to make the score
score server more mischief-proof. server more mischief-proof.
:meta private:
""" """
return _baplus.submit_score( return _baplus.submit_score(
game, game,
@ -252,41 +246,59 @@ class PlusAppSubsystem(AppSubsystem):
def tournament_query( def tournament_query(
callback: Callable[[dict | None], None], args: dict callback: Callable[[dict | None], None], args: dict
) -> None: ) -> None:
"""(internal)""" """:meta private:"""
return _baplus.tournament_query(callback, args) return _baplus.tournament_query(callback, args)
@staticmethod @staticmethod
def supports_purchases() -> bool: def supports_purchases() -> bool:
"""Does this platform support in-app-purchases?""" """Does this platform support in-app-purchases?
:meta private:
"""
return _baplus.supports_purchases() return _baplus.supports_purchases()
@staticmethod @staticmethod
def have_incentivized_ad() -> bool: def have_incentivized_ad() -> bool:
"""Is an incentivized ad available?""" """Is an incentivized ad available?
:meta private:
"""
return _baplus.have_incentivized_ad() return _baplus.have_incentivized_ad()
@staticmethod @staticmethod
def has_video_ads() -> bool: def has_video_ads() -> bool:
"""Are video ads available?""" """Are video ads available?
:meta private:
"""
return _baplus.has_video_ads() return _baplus.has_video_ads()
@staticmethod @staticmethod
def can_show_ad() -> bool: def can_show_ad() -> bool:
"""Can we show an ad?""" """Can we show an ad?
:meta private:
"""
return _baplus.can_show_ad() return _baplus.can_show_ad()
@staticmethod @staticmethod
def show_ad( def show_ad(
purpose: str, on_completion_call: Callable[[], None] | None = None purpose: str, on_completion_call: Callable[[], None] | None = None
) -> None: ) -> None:
"""Show an ad.""" """Show an ad.
:meta private:
"""
_baplus.show_ad(purpose, on_completion_call) _baplus.show_ad(purpose, on_completion_call)
@staticmethod @staticmethod
def show_ad_2( def show_ad_2(
purpose: str, on_completion_call: Callable[[bool], None] | None = None purpose: str, on_completion_call: Callable[[bool], None] | None = None
) -> None: ) -> None:
"""Show an ad.""" """Show an ad.
:meta private:
"""
_baplus.show_ad_2(purpose, on_completion_call) _baplus.show_ad_2(purpose, on_completion_call)
@staticmethod @staticmethod
@ -295,5 +307,8 @@ class PlusAppSubsystem(AppSubsystem):
game: str | None = None, game: str | None = None,
game_version: str | None = None, game_version: str | None = None,
) -> None: ) -> None:
"""Show game-service provided UI.""" """Show game-service provided UI.
:meta private:
"""
_baplus.show_game_service_ui(show, game, game_version) _baplus.show_game_service_ui(show, game, game_version)

View file

@ -24,7 +24,12 @@ if TYPE_CHECKING:
class CloudSubsystem(babase.AppSubsystem): class CloudSubsystem(babase.AppSubsystem):
"""Manages communication with cloud components.""" """Manages communication with cloud components.
Access the shared single instance of this class via the
:attr:`~baplus.PlusAppSubsystem.cloud` attr on the
:class:`~baplus.PlusAppSubsystem` class.
"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -34,19 +39,25 @@ class CloudSubsystem(babase.AppSubsystem):
@property @property
def connected(self) -> bool: def connected(self) -> bool:
"""Property equivalent of CloudSubsystem.is_connected().""" """Whether a connection to the cloud is present.
return self.is_connected()
def is_connected(self) -> bool:
"""Return whether a connection to the cloud is present.
This is a good indicator (though not for certain) that sending This is a good indicator (though not for certain) that sending
messages will succeed. messages will succeed.
""" """
return False # Needs to be overridden return self.is_connected()
def is_connected(self) -> bool:
"""Implementation for connected attr.
:meta private:
"""
raise NotImplementedError()
def on_connectivity_changed(self, connected: bool) -> None: def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes.""" """Called when cloud connectivity state changes.
:meta private:
"""
babase.balog.debug('Connectivity is now %s.', connected) babase.balog.debug('Connectivity is now %s.', connected)
plus = babase.app.plus plus = babase.app.plus
@ -172,6 +183,24 @@ class CloudSubsystem(babase.AppSubsystem):
], ],
) -> None: ... ) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.SecureDataCheckMessage,
on_response: Callable[
[bacommon.cloud.SecureDataCheckResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.SecureDataCheckerRequest,
on_response: Callable[
[bacommon.cloud.SecureDataCheckerResponse | Exception], None
],
) -> None: ...
def send_message_cb( def send_message_cb(
self, self,
msg: Message, msg: Message,
@ -179,7 +208,7 @@ class CloudSubsystem(babase.AppSubsystem):
) -> None: ) -> None:
"""Asynchronously send a message to the cloud from the logic thread. """Asynchronously send a message to the cloud from the logic thread.
The provided on_response call will be run in the logic thread The provided ``on_response`` call will be run in the logic thread
and passed either the response or the error that occurred. and passed either the response or the error that occurred.
""" """
raise NotImplementedError( raise NotImplementedError(
@ -221,7 +250,7 @@ class CloudSubsystem(babase.AppSubsystem):
) -> bacommon.cloud.TestResponse: ... ) -> bacommon.cloud.TestResponse: ...
async def send_message_async(self, msg: Message) -> Response | None: async def send_message_async(self, msg: Message) -> Response | None:
"""Synchronously send a message to the cloud. """Asynchronously send a message to the cloud.
Must be called from the logic thread. Must be called from the logic thread.
""" """
@ -232,7 +261,10 @@ class CloudSubsystem(babase.AppSubsystem):
def subscribe_test( def subscribe_test(
self, updatecall: Callable[[int | None], None] self, updatecall: Callable[[int | None], None]
) -> babase.CloudSubscription: ) -> babase.CloudSubscription:
"""Subscribe to some test data.""" """Subscribe to some test data.
:meta private:
"""
raise NotImplementedError( raise NotImplementedError(
'Cloud functionality is not present in this build.' 'Cloud functionality is not present in this build.'
) )
@ -250,6 +282,8 @@ class CloudSubsystem(babase.AppSubsystem):
"""Unsubscribe from some subscription. """Unsubscribe from some subscription.
Do not call this manually; it is called by CloudSubscription. Do not call this manually; it is called by CloudSubscription.
:meta private:
""" """
raise NotImplementedError( raise NotImplementedError(
'Cloud functionality is not present in this build.' 'Cloud functionality is not present in this build.'

View file

@ -18,12 +18,15 @@ import logging
from efro.util import set_canonical_module_names from efro.util import set_canonical_module_names
from babase import ( from babase import (
ActivityNotFoundError,
add_clean_frame_callback, add_clean_frame_callback,
app, app,
App,
AppIntent, AppIntent,
AppIntentDefault, AppIntentDefault,
AppIntentExec, AppIntentExec,
AppMode, AppMode,
AppState,
apptime, apptime,
AppTime, AppTime,
apptimer, apptimer,
@ -52,6 +55,7 @@ from babase import (
safecolor, safecolor,
screenmessage, screenmessage,
set_analytics_screen, set_analytics_screen,
SessionNotFoundError,
storagename, storagename,
timestring, timestring,
UIScale, UIScale,
@ -164,7 +168,7 @@ from bascenev1._dependency import (
from bascenev1._dualteamsession import DualTeamSession from bascenev1._dualteamsession import DualTeamSession
from bascenev1._freeforallsession import FreeForAllSession from bascenev1._freeforallsession import FreeForAllSession
from bascenev1._gameactivity import GameActivity from bascenev1._gameactivity import GameActivity
from bascenev1._gameresults import GameResults from bascenev1._gameresults import GameResults, WinnerGroup
from bascenev1._gameutils import ( from bascenev1._gameutils import (
animate, animate,
animate_array, animate_array,
@ -246,15 +250,18 @@ from bascenev1._teamgame import TeamGameActivity
__all__ = [ __all__ = [
'Activity', 'Activity',
'ActivityData', 'ActivityData',
'ActivityNotFoundError',
'Actor', 'Actor',
'animate', 'animate',
'animate_array', 'animate_array',
'add_clean_frame_callback', 'add_clean_frame_callback',
'app', 'app',
'App',
'AppIntent', 'AppIntent',
'AppIntentDefault', 'AppIntentDefault',
'AppIntentExec', 'AppIntentExec',
'AppMode', 'AppMode',
'AppState',
'AppTime', 'AppTime',
'apptime', 'apptime',
'apptimer', 'apptimer',
@ -414,6 +421,7 @@ __all__ = [
'ScoreConfig', 'ScoreConfig',
'ScoreScreenActivity', 'ScoreScreenActivity',
'ScoreType', 'ScoreType',
'SessionNotFoundError',
'broadcastmessage', 'broadcastmessage',
'Session', 'Session',
'SessionData', 'SessionData',
@ -462,6 +470,7 @@ __all__ = [
'unlock_all_input', 'unlock_all_input',
'Vec3', 'Vec3',
'WeakCall', 'WeakCall',
'WinnerGroup',
] ]
# We want stuff here to show up as bascenev1.Foo instead of # We want stuff here to show up as bascenev1.Foo instead of

View file

@ -23,99 +23,98 @@ TeamT = TypeVar('TeamT', bound=Team)
class Activity(DependencyComponent, Generic[PlayerT, TeamT]): class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
"""Units of execution wrangled by a bascenev1.Session. """Units of execution wrangled by a :class:`bascenev1.Session`.
Category: Gameplay Classes Examples of activities include games, score-screens, cutscenes, etc.
A :class:`bascenev1.Session` has one 'current' activity at any time,
Examples of Activities include games, score-screens, cutscenes, etc. though their existence can overlap during transitions.
A bascenev1.Session has one 'current' Activity at any time, though
their existence can overlap during transitions.
""" """
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
#: The settings dict passed in when the activity was made. This
#: attribute is deprecated and should be avoided when possible;
#: activities should pull all values they need from the ``settings``
#: arg passed to the activity's __init__ call.
settings_raw: dict[str, Any] settings_raw: dict[str, Any]
"""The settings dict passed in when the activity was made.
This attribute is deprecated and should be avoided when possible;
activities should pull all values they need from the 'settings' arg
passed to the Activity __init__ call."""
#: The list of teams in the activity. This gets populated just before
#: on_begin() is called and is updated automatically as players join
#: or leave the game. (at least in free-for-all mode where every
#: player gets their own team; in teams mode there are always 2 teams
#: regardless of the player count).
teams: list[TeamT] teams: list[TeamT]
"""The list of bascenev1.Team-s in the Activity. This gets populated just
before on_begin() is called and is updated automatically as players
join or leave the game. (at least in free-for-all mode where every
player gets their own team; in teams mode there are always 2 teams
regardless of the player count)."""
#: The list of players in the activity. This gets populated just
#: before :meth:`~bascenev1.Activity.on_begin()` is called and is
#: updated automatically as players join or leave the game.
players: list[PlayerT] players: list[PlayerT]
"""The list of bascenev1.Player-s in the Activity. This gets populated
just before on_begin() is called and is updated automatically as
players join or leave the game."""
#: Whether to print every time a player dies. This can be pertinent
#: in games such as Death-Match but can be annoying in games where it
#: doesn't matter.
announce_player_deaths = False announce_player_deaths = False
"""Whether to print every time a player dies. This can be pertinent
in games such as Death-Match but can be annoying in games where it
doesn't matter."""
#: Joining activities are for waiting for initial player joins. They
#: are treated slightly differently than regular activities, mainly
#: in that all players are passed to the activity at once instead of
#: as each joins.
is_joining_activity = False is_joining_activity = False
"""Joining activities are for waiting for initial player joins.
They are treated slightly differently than regular activities,
mainly in that all players are passed to the activity at once
instead of as each joins."""
#: Whether scene-time should still progress when in menus/etc.
allow_pausing = False allow_pausing = False
"""Whether game-time should still progress when in menus/etc."""
#: Whether idle players can potentially be kicked (should not happen
#: in menus/etc).
allow_kick_idle_players = True allow_kick_idle_players = True
"""Whether idle players can potentially be kicked (should not happen in
menus/etc)."""
#: In vr mode, this determines whether overlay nodes (text, images,
#: etc) are created at a fixed position in space or one that moves
#: based on the current map. Generally this should be on for games
#: and off for transitions/score-screens/etc. that persist between
#: maps.
use_fixed_vr_overlay = False use_fixed_vr_overlay = False
"""In vr mode, this determines whether overlay nodes (text, images, etc)
are created at a fixed position in space or one that moves based on
the current map. Generally this should be on for games and off for
transitions/score-screens/etc. that persist between maps."""
#: If True, runs in slow motion and turns down sound pitch.
slow_motion = False slow_motion = False
"""If True, runs in slow motion and turns down sound pitch."""
#: Set this to True to inherit slow motion setting from previous
#: activity (useful for transitions to avoid hitches).
inherits_slow_motion = False inherits_slow_motion = False
"""Set this to True to inherit slow motion setting from previous
activity (useful for transitions to avoid hitches)."""
#: Set this to True to keep playing the music from the previous
#: activity (without even restarting it).
inherits_music = False inherits_music = False
"""Set this to True to keep playing the music from the previous activity
(without even restarting it)."""
#: Set this to true to inherit VR camera offsets from the previous
#: activity (useful for preventing sporadic camera movement during
#: transitions).
inherits_vr_camera_offset = False inherits_vr_camera_offset = False
"""Set this to true to inherit VR camera offsets from the previous
activity (useful for preventing sporadic camera movement
during transitions)."""
#: Set this to true to inherit (non-fixed) VR overlay positioning
#: from the previous activity (useful for prevent sporadic overlay
#: jostling during transitions).
inherits_vr_overlay_center = False inherits_vr_overlay_center = False
"""Set this to true to inherit (non-fixed) VR overlay positioning from
the previous activity (useful for prevent sporadic overlay jostling
during transitions)."""
#: Set this to true to inherit screen tint/vignette colors from the
#: previous activity (useful to prevent sudden color changes during
#: transitions).
inherits_tint = False inherits_tint = False
"""Set this to true to inherit screen tint/vignette colors from the
previous activity (useful to prevent sudden color changes during
transitions)."""
#: Whether players should be allowed to join in the middle of this
#: activity. Note that a :class:`bascenev1.Session` may not allow
#: mid-activity-joins even if the activity says it is ok.
allow_mid_activity_joins: bool = True allow_mid_activity_joins: bool = True
"""Whether players should be allowed to join in the middle of this
activity. Note that Sessions may not allow mid-activity-joins even
if the activity says its ok."""
#: If the activity fades or transitions in, it should set the length
#: of time here so that previous activities will be kept alive for
#: that long (avoiding 'holes' in the screen) This value is given in
#: real-time seconds.
transition_time = 0.0 transition_time = 0.0
"""If the activity fades or transitions in, it should set the length of
time here so that previous activities will be kept alive for that
long (avoiding 'holes' in the screen)
This value is given in real-time seconds."""
#: Is it ok to show an ad after this activity ends before showing the
#: next activity?
can_show_ad_on_death = False can_show_ad_on_death = False
"""Is it ok to show an ad after this activity ends before showing
the next activity?"""
def __init__(self, settings: dict): def __init__(self, settings: dict):
"""Creates an Activity in the current bascenev1.Session. """Creates an Activity in the current bascenev1.Session.
@ -149,7 +148,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._session = weakref.ref(_bascenev1.getsession()) self._session = weakref.ref(_bascenev1.getsession())
# Preloaded data for actors, maps, etc; indexed by type. #: Preloaded data for actors, maps, etc; indexed by type.
self.preloads: dict[type, Any] = {} self.preloads: dict[type, Any] = {}
# Hopefully can eventually kill this; activities should # Hopefully can eventually kill this; activities should
@ -208,8 +207,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property @property
def globalsnode(self) -> bascenev1.Node: def globalsnode(self) -> bascenev1.Node:
"""The 'globals' bascenev1.Node for the activity. This contains various """The 'globals' :class:`~bascenev1.Node` for the activity.
global controls and values.
This contains various global controls and values.
""" """
node = self._globalsnode node = self._globalsnode
if not node: if not node:
@ -221,7 +221,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
"""The stats instance accessible while the activity is running. """The stats instance accessible while the activity is running.
If access is attempted before or after, raises a If access is attempted before or after, raises a
bascenev1.NotFoundError. :class:`~bascenev1.NotFoundError`.
""" """
if self._stats is None: if self._stats is None:
raise babase.NotFoundError() raise babase.NotFoundError()
@ -260,22 +260,24 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property @property
def playertype(self) -> type[PlayerT]: def playertype(self) -> type[PlayerT]:
"""The type of bascenev1.Player this Activity is using.""" """The :class:`~bascenev1.Player` subclass this activity uses."""
return self._playertype return self._playertype
@property @property
def teamtype(self) -> type[TeamT]: def teamtype(self) -> type[TeamT]:
"""The type of bascenev1.Team this Activity is using.""" """The :class:`~bascenev1.Team` subclass this activity uses."""
return self._teamtype return self._teamtype
def set_has_ended(self, val: bool) -> None: def set_has_ended(self, val: bool) -> None:
"""(internal)""" """Internal - used by session.
:meta private:"""
self._has_ended = val self._has_ended = val
def expire(self) -> None: def expire(self) -> None:
"""Begin the process of tearing down the activity. """Internal; Begin the process of tearing down the activity.
(internal) :meta private:
""" """
# Create an app-timer that watches a weak-ref of this activity # Create an app-timer that watches a weak-ref of this activity
@ -304,11 +306,12 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
) )
def retain_actor(self, actor: bascenev1.Actor) -> None: def retain_actor(self, actor: bascenev1.Actor) -> None:
"""Add a strong-reference to a bascenev1.Actor to this Activity. """Add a strong-ref to a :class:`bascenev1.Actor` to this activity.
The reference will be lazily released once bascenev1.Actor.exists() The reference will be lazily released once
returns False for the Actor. The bascenev1.Actor.autoretain() method :meth:`bascenev1.Actor.exists()` returns False for the actor.
is a convenient way to access this same functionality. The :meth:`bascenev1.Actor.autoretain()` method is a convenient
way to access this same functionality.
""" """
if __debug__: if __debug__:
from bascenev1._actor import Actor from bascenev1._actor import Actor
@ -317,9 +320,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._actor_refs.append(actor) self._actor_refs.append(actor)
def add_actor_weak_ref(self, actor: bascenev1.Actor) -> None: def add_actor_weak_ref(self, actor: bascenev1.Actor) -> None:
"""Add a weak-reference to a bascenev1.Actor to the bascenev1.Activity. """Add a weak-ref to a :class:`bascenev1.Actor` to the activity.
(called by the bascenev1.Actor base class) (called by the :class:`bascenev1.Actor` base class)
""" """
if __debug__: if __debug__:
from bascenev1._actor import Actor from bascenev1._actor import Actor
@ -329,9 +332,10 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property @property
def session(self) -> bascenev1.Session: def session(self) -> bascenev1.Session:
"""The bascenev1.Session this bascenev1.Activity belongs to. """The session this activity belongs to.
Raises a babase.SessionNotFoundError if the Session no longer exists. Raises a :class:`~bascenev1.SessionNotFoundError` if the session
no longer exists.
""" """
session = self._session() session = self._session()
if session is None: if session is None:
@ -339,44 +343,44 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return session return session
def on_player_join(self, player: PlayerT) -> None: def on_player_join(self, player: PlayerT) -> None:
"""Called when a new bascenev1.Player has joined the Activity. """Called when a player joins the activity.
(including the initial set of Players) (including the initial set of players)
""" """
def on_player_leave(self, player: PlayerT) -> None: def on_player_leave(self, player: PlayerT) -> None:
"""Called when a bascenev1.Player is leaving the Activity.""" """Called when a player is leaving the Activity."""
def on_team_join(self, team: TeamT) -> None: def on_team_join(self, team: TeamT) -> None:
"""Called when a new bascenev1.Team joins the Activity. """Called when a new team joins the activity.
(including the initial set of Teams) (including the initial set of teams)
""" """
def on_team_leave(self, team: TeamT) -> None: def on_team_leave(self, team: TeamT) -> None:
"""Called when a bascenev1.Team leaves the Activity.""" """Called when a team leaves the activity."""
def on_transition_in(self) -> None: def on_transition_in(self) -> None:
"""Called when the Activity is first becoming visible. """Called when the activity is first becoming visible.
Upon this call, the Activity should fade in backgrounds, Upon this call, the activity should fade in backgrounds, start
start playing music, etc. It does not yet have access to players playing music, etc. It does not yet have access to players or
or teams, however. They remain owned by the previous Activity teams, however. They remain owned by the previous activity up
up until bascenev1.Activity.on_begin() is called. until :meth:`~bascenev1.Activity.on_begin()` is called.
""" """
def on_transition_out(self) -> None: def on_transition_out(self) -> None:
"""Called when your activity begins transitioning out. """Called when your activity begins transitioning out.
Note that this may happen at any time even if bascenev1.Activity.end() Note that this may happen at any time even if
has not been called. :meth:`bascenev1.Activity.end()` has not been called.
""" """
def on_begin(self) -> None: def on_begin(self) -> None:
"""Called once the previous Activity has finished transitioning out. """Called once the previous activity has finished transitioning out.
At this point the activity's initial players and teams are filled in At this point the activity's initial players and teams are
and it should begin its actual game logic. filled in and it should begin its actual game logic.
""" """
def handlemessage(self, msg: Any) -> Any: def handlemessage(self, msg: Any) -> Any:
@ -385,11 +389,11 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return UNHANDLED return UNHANDLED
def has_transitioned_in(self) -> bool: def has_transitioned_in(self) -> bool:
"""Return whether bascenev1.Activity.on_transition_in() has run.""" """Whether :meth:`~bascenev1.Activity.on_transition_in()` has run."""
return self._has_transitioned_in return self._has_transitioned_in
def has_begun(self) -> bool: def has_begun(self) -> bool:
"""Return whether bascenev1.Activity.on_begin() has run.""" """Whether :meth:`~bascenev1.Activity.on_begin()` has run."""
return self._has_begun return self._has_begun
def has_ended(self) -> bool: def has_ended(self) -> bool:
@ -397,13 +401,13 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return self._has_ended return self._has_ended
def is_transitioning_out(self) -> bool: def is_transitioning_out(self) -> bool:
"""Return whether bascenev1.Activity.on_transition_out() has run.""" """Whether :meth:`~bascenev1.Activity.on_transition_out()` has run."""
return self._transitioning_out return self._transitioning_out
def transition_in(self, prev_globals: bascenev1.Node | None) -> None: def transition_in(self, prev_globals: bascenev1.Node | None) -> None:
"""Called by Session to kick off transition-in. """Internal; called by session to kick off transition-in.
(internal) :meta private:
""" """
assert not self._has_transitioned_in assert not self._has_transitioned_in
self._has_transitioned_in = True self._has_transitioned_in = True
@ -459,7 +463,10 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._activity_data.make_foreground() self._activity_data.make_foreground()
def transition_out(self) -> None: def transition_out(self) -> None:
"""Called by the Session to start us transitioning out.""" """Internal; called by session to start us transitioning out.
:meta private:
"""
assert not self._transitioning_out assert not self._transitioning_out
self._transitioning_out = True self._transitioning_out = True
with self.context: with self.context:
@ -469,9 +476,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_transition_out for %s.', self) logging.exception('Error in on_transition_out for %s.', self)
def begin(self, session: bascenev1.Session) -> None: def begin(self, session: bascenev1.Session) -> None:
"""Begin the activity. """Internal; Begin the activity.
(internal) :meta private:
""" """
assert not self._has_begun assert not self._has_begun
@ -499,45 +506,46 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
def end( def end(
self, results: Any = None, delay: float = 0.0, force: bool = False self, results: Any = None, delay: float = 0.0, force: bool = False
) -> None: ) -> None:
"""Commences Activity shutdown and delivers results to the Session. """Commence activity shutdown and delivers results to the session.
'delay' is the time delay before the Activity actually ends 'delay' is the time delay before the Activity actually ends (in
(in seconds). Further calls to end() will be ignored up until seconds). Further end calls will be ignored up until this time,
this time, unless 'force' is True, in which case the new results unless 'force' is True, in which case the new results will
will replace the old. replace the old.
""" """
# Ask the session to end us. # Ask the session to end us.
self.session.end_activity(self, results, delay, force) self.session.end_activity(self, results, delay, force)
def create_player(self, sessionplayer: bascenev1.SessionPlayer) -> PlayerT: def create_player(self, sessionplayer: bascenev1.SessionPlayer) -> PlayerT:
"""Create the Player instance for this Activity. """Create a :class:`bascenev1.Player` instance for this activity.
Subclasses can override this if the activity's player class Note that the player object should not be used at this point as
requires a custom constructor; otherwise it will be called with it is not yet fully wired up; wait for
no args. Note that the player object should not be used at this :meth:`bascenev1.Activity.on_player_join()` for that.
point as it is not yet fully wired up; wait for
bascenev1.Activity.on_player_join() for that.
""" """
del sessionplayer # Unused. del sessionplayer # Unused.
player = self._playertype() player = self._playertype()
return player return player
def create_team(self, sessionteam: bascenev1.SessionTeam) -> TeamT: def create_team(self, sessionteam: bascenev1.SessionTeam) -> TeamT:
"""Create the Team instance for this Activity. """Create a :class:`bascenev1.Team` instance for this activity.
Subclasses can override this if the activity's team class Subclasses can override this if the activity's team class
requires a custom constructor; otherwise it will be called with requires a custom constructor; otherwise it will be called with
no args. Note that the team object should not be used at this no args. Note that the team object should not be used at this
point as it is not yet fully wired up; wait for on_team_join() point as it is not yet fully wired up; wait for
for that. :meth:`bascenev1.Activity.on_team_join()` for that.
""" """
del sessionteam # Unused. del sessionteam # Unused.
team = self._teamtype() team = self._teamtype()
return team return team
def add_player(self, sessionplayer: bascenev1.SessionPlayer) -> None: def add_player(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""(internal)""" """Internal
:meta private:
"""
assert sessionplayer.sessionteam is not None assert sessionplayer.sessionteam is not None
sessionplayer.resetinput() sessionplayer.resetinput()
sessionteam = sessionplayer.sessionteam sessionteam = sessionplayer.sessionteam
@ -565,7 +573,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_player_join for %s.', self) logging.exception('Error in on_player_join for %s.', self)
def remove_player(self, sessionplayer: bascenev1.SessionPlayer) -> None: def remove_player(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""Remove a player from the Activity while it is running. """Remove a player from the activity while it is running.
(internal) (internal)
""" """
@ -584,10 +592,6 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self.players.remove(player) self.players.remove(player)
assert player not in self.players assert player not in self.players
# This should allow our bascenev1.Player instance to die.
# Complain if that doesn't happen.
# verify_object_death(player)
with self.context: with self.context:
try: try:
self.on_player_leave(player) self.on_player_leave(player)
@ -608,9 +612,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._players_that_left.append(weakref.ref(player)) self._players_that_left.append(weakref.ref(player))
def add_team(self, sessionteam: bascenev1.SessionTeam) -> None: def add_team(self, sessionteam: bascenev1.SessionTeam) -> None:
"""Add a team to the Activity """Internal; Add a team to the activity
(internal) :meta private:
""" """
assert not self.expired assert not self.expired
@ -624,9 +628,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_team_join for %s.', self) logging.exception('Error in on_team_join for %s.', self)
def remove_team(self, sessionteam: bascenev1.SessionTeam) -> None: def remove_team(self, sessionteam: bascenev1.SessionTeam) -> None:
"""Remove a team from a Running Activity """Internal; remove a team from a running activity
(internal) :meta private:
""" """
assert not self.expired assert not self.expired
assert sessionteam.activityteam is not None assert sessionteam.activityteam is not None

View file

@ -27,53 +27,57 @@ ActorT = TypeVar('ActorT', bound='Actor')
class Actor: class Actor:
"""High level logical entities in a bascenev1.Activity. """High level logical entities in an :class:`~bascenev1.Activity`.
Category: **Gameplay Classes** Actors act as controllers, combining some number of
:class:`~bascenev1.Node`, :class:`~bascenev1.Texture`,
:class:`~bascenev1.Sound`, and other type objects into a high-level
cohesive unit.
Actors act as controllers, combining some number of Nodes, Textures, Some example actors include the :class:`~bascenev1lib.actor.bomb.Bomb`,
Sounds, etc. into a high-level cohesive unit. :class:`~bascenev1lib.actor.flag.Flag`, and
:class:`~bascenev1lib.actor.spaz.Spaz`, classes that live in the
:mod:`bascenev1lib.actor` package.
Some example actors include the Bomb, Flag, and Spaz classes that One key feature of actors is that they generally 'die' (killing off
live in the bascenev1lib.actor.* modules. or transitioning out their nodes) when the last Python reference to
them disappears, so you can use logic such as::
One key feature of Actors is that they generally 'die' # Create a flag actor in our game activity (self):
(killing off or transitioning out their nodes) when the last Python from bascenev1lib.actor.flag import Flag
reference to them disappears, so you can use logic such as:
##### Example self.flag = Flag(position=(0, 10, 0))
>>> # Create a flag Actor in our game activity:
... from bascenev1lib.actor.flag import Flag # Later, destroy the flag (provided nothing else is holding a
... self.flag = Flag(position=(0, 10, 0)) # reference to it). We could also just assign a new flag to this
... # value. Either way, the old flag should disappear.
... # Later, destroy the flag. self.flag = None
... # (provided nothing else is holding a reference to it)
... # We could also just assign a new flag to this value.
... # Either way, the old flag disappears.
... self.flag = None
This is in contrast to the behavior of the more low level This is in contrast to the behavior of the more low level
bascenev1.Node, which is always explicitly created and destroyed :class:`~bascenev1.Node` class, which is always explicitly created
and doesn't care how many Python references to it exist. and destroyed and doesn't care how many Python references to it
exist.
Note, however, that you can use the bascenev1.Actor.autoretain() method Note, however, that you can use the :meth:`~bascenev1.Actor.autoretain()`
if you want an Actor to stick around until explicitly killed method if you want an actor to stick around until explicitly killed
regardless of references. regardless of references.
Another key feature of bascenev1.Actor is its Another key feature of actors is their
bascenev1.Actor.handlemessage() method, which takes a single arbitrary :meth:`~bascenev1.Actor.handlemessage()` method, which takes a single
object as an argument. This provides a safe way to communicate between arbitrary object as an argument. This provides a safe way to communicate
bascenev1.Actor, bascenev1.Activity, bascenev1.Session, and any other between :class:`~bascenev1.Actor`, :class:`~bascenev1.Activity`,
class providing a handlemessage() method. The most universally handled :class:`~bascenev1.Session`, and any other class providing a
message type for Actors is the bascenev1.DieMessage. ``handlemessage()`` method. The most universally handled
message type for actors is the :class:`~bascenev1.DieMessage`.
Another way to kill the flag from the example above: Another way to kill the flag from the example above:
We can safely call this on any type with a 'handlemessage' method We can safely call this on any type with a ``handlemessage`` method
(though its not guaranteed to always have a meaningful effect). (though its not guaranteed to always have a meaningful effect).
In this case the Actor instance will still be around, but its In this case the actor instance will still be around, but its
bascenev1.Actor.exists() and bascenev1.Actor.is_alive() methods will :meth:`~bascenev1.Actor.exists()` and :meth:`~bascenev1.Actor.is_alive()`
both return False. methods will both return False::
>>> self.flag.handlemessage(bascenev1.DieMessage())
self.flag.handlemessage(bascenev1.DieMessage())
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -108,17 +112,17 @@ class Actor:
return UNHANDLED return UNHANDLED
def autoretain(self: ActorT) -> ActorT: def autoretain(self: ActorT) -> ActorT:
"""Keep this Actor alive without needing to hold a reference to it. """Keep this actor alive without needing to hold a reference to it.
This keeps the bascenev1.Actor in existence by storing a reference This keeps the actor in existence by storing a reference to it
to it with the bascenev1.Activity it was created in. The reference with the :class:`~bascenev1.Activity` it was created in. The
is lazily released once bascenev1.Actor.exists() returns False for reference is lazily released once
it or when the Activity is set as expired. This can be a convenient :meth:`~bascenev1.Actor.exists()` returns False for the actor or
alternative to storing references explicitly just to keep a when the :class:`~bascenev1.Activity` is set as expired. This
bascenev1.Actor from dying. can be a convenient alternative to storing references explicitly
For convenience, this method returns the bascenev1.Actor it is called just to keep an actor from dying. For convenience, this method
with, enabling chained statements such as: returns the actor it is called with, enabling chained statements
myflag = bascenev1.Flag().autoretain() such as: ``myflag = bascenev1.Flag().autoretain()``
""" """
activity = self._activity() activity = self._activity()
if activity is None: if activity is None:
@ -127,43 +131,44 @@ class Actor:
return self return self
def on_expire(self) -> None: def on_expire(self) -> None:
"""Called for remaining `bascenev1.Actor`s when their activity dies. """Called for remaining actors when their activity dies.
Actors can use this opportunity to clear callbacks or other Actors can use this opportunity to clear callbacks or other
references which have the potential of keeping the bascenev1.Activity references which have the potential of keeping the
alive inadvertently (Activities can not exit cleanly while :class:`~bascenev1.Activity` alive inadvertently (activities can
any Python references to them remain.) not exit cleanly while any Python references to them remain.)
Once an actor is expired (see bascenev1.Actor.is_expired()) it should Once an actor is expired (see :attr:`~bascenev1.Actor.expired`)
no longer perform any game-affecting operations (creating, modifying, it should no longer perform any game-affecting operations
or deleting nodes, media, timers, etc.) Attempts to do so will (creating, modifying, or deleting nodes, media, timers, etc.)
likely result in errors. Attempts to do so will likely result in errors.
""" """
@property @property
def expired(self) -> bool: def expired(self) -> bool:
"""Whether the Actor is expired. """Whether the actor is expired.
(see bascenev1.Actor.on_expire()) (see :meth:`~bascenev1.Actor.on_expire()`)
""" """
activity = self.getactivity(doraise=False) activity = self.getactivity(doraise=False)
return True if activity is None else activity.expired return True if activity is None else activity.expired
def exists(self) -> bool: def exists(self) -> bool:
"""Returns whether the Actor is still present in a meaningful way. """Returns whether the actor is still present in a meaningful way.
Note that a dying character should still return True here as long as Note that a dying character should still return True here as long as
their corpse is visible; this is about presence, not being 'alive' their corpse is visible; this is about presence, not being 'alive'
(see bascenev1.Actor.is_alive() for that). (see :meth:`~bascenev1.Actor.is_alive()` for that).
If this returns False, it is assumed the Actor can be completely If this returns False, it is assumed the actor can be completely
deleted without affecting the game; this call is often used deleted without affecting the game; this call is often used when
when pruning lists of Actors, such as with bascenev1.Actor.autoretain() pruning lists of actors, such as with
:meth:`bascenev1.Actor.autoretain()`
The default implementation of this method always return True. The default implementation of this method always return True.
Note that the boolean operator for the Actor class calls this method, Note that the boolean operator for the actor class calls this method,
so a simple "if myactor" test will conveniently do the right thing so a simple ``if myactor`` test will conveniently do the right thing
even if myactor is set to None. even if myactor is set to None.
""" """
return True return True
@ -173,22 +178,21 @@ class Actor:
return self.exists() return self.exists()
def is_alive(self) -> bool: def is_alive(self) -> bool:
"""Returns whether the Actor is 'alive'. """Returns whether the actor is 'alive'.
What this means is up to the Actor. What this means is up to the actor. It is not a requirement for
It is not a requirement for Actors to be able to die; actors to be able to die; just that they report whether they
just that they report whether they consider themselves consider themselves to be alive or not. In cases where
to be alive or not. In cases where dead/alive is dead/alive is irrelevant, True should be returned.
irrelevant, True should be returned.
""" """
return True return True
@property @property
def activity(self) -> bascenev1.Activity: def activity(self) -> bascenev1.Activity:
"""The Activity this Actor was created in. """The activity this actor was created in.
Raises a bascenev1.ActivityNotFoundError if the Activity no longer Raises a :class:`~bascenev1.ActivityNotFoundError` if the
exists. activity no longer exists.
""" """
activity = self._activity() activity = self._activity()
if activity is None: if activity is None:
@ -208,11 +212,11 @@ class Actor:
) -> bascenev1.Activity | None: ... ) -> bascenev1.Activity | None: ...
def getactivity(self, doraise: bool = True) -> bascenev1.Activity | None: def getactivity(self, doraise: bool = True) -> bascenev1.Activity | None:
"""Return the bascenev1.Activity this Actor is associated with. """Return the activity this actor is associated with.
If the Activity no longer exists, raises a If the activity no longer exists, raises a
bascenev1.ActivityNotFoundError or returns None depending on whether :class:`~bascenev1.ActivityNotFoundError` or returns None
'doraise' is True. depending on whether ``doraise`` is True.
""" """
activity = self._activity() activity = self._activity()
if activity is None and doraise: if activity is None and doraise:

View file

@ -19,10 +19,7 @@ def register_campaign(campaign: bascenev1.Campaign) -> None:
class Campaign: class Campaign:
"""Represents a unique set or series of baclassic.Level-s. """Represents a unique set of :class:`~bascenev1.Level` instances."""
Category: **App Classes**
"""
def __init__( def __init__(
self, self,
@ -39,18 +36,18 @@ class Campaign:
@property @property
def name(self) -> str: def name(self) -> str:
"""The name of the Campaign.""" """The name of the campaign."""
return self._name return self._name
@property @property
def sequential(self) -> bool: def sequential(self) -> bool:
"""Whether this Campaign's levels must be played in sequence.""" """Whether this campaign's levels must be played in sequence."""
return self._sequential return self._sequential
def addlevel( def addlevel(
self, level: bascenev1.Level, index: int | None = None self, level: bascenev1.Level, index: int | None = None
) -> None: ) -> None:
"""Adds a baclassic.Level to the Campaign.""" """Add a level to the campaign."""
if level.campaign is not None: if level.campaign is not None:
raise RuntimeError('Level already belongs to a campaign.') raise RuntimeError('Level already belongs to a campaign.')
level.set_campaign(self, len(self._levels)) level.set_campaign(self, len(self._levels))
@ -61,11 +58,11 @@ class Campaign:
@property @property
def levels(self) -> list[bascenev1.Level]: def levels(self) -> list[bascenev1.Level]:
"""The list of baclassic.Level-s in the Campaign.""" """The list of levels in the campaign."""
return self._levels return self._levels
def getlevel(self, name: str) -> bascenev1.Level: def getlevel(self, name: str) -> bascenev1.Level:
"""Return a contained baclassic.Level by name.""" """Return a contained level by name."""
for level in self._levels: for level in self._levels:
if level.name == name: if level.name == name:
@ -75,11 +72,11 @@ class Campaign:
) )
def reset(self) -> None: def reset(self) -> None:
"""Reset state for the Campaign.""" """Reset state for the campaign."""
babase.app.config.setdefault('Campaigns', {})[self._name] = {} babase.app.config.setdefault('Campaigns', {})[self._name] = {}
# FIXME should these give/take baclassic.Level instances instead # FIXME: should these give/take baclassic.Level instances instead of
# of level names?.. # level names?..
def set_selected_level(self, levelname: str) -> None: def set_selected_level(self, levelname: str) -> None:
"""Set the Level currently selected in the UI (by name).""" """Set the Level currently selected in the UI (by name)."""
self.configdict['Selection'] = levelname self.configdict['Selection'] = levelname

View file

@ -14,10 +14,7 @@ if TYPE_CHECKING:
class Collision: class Collision:
"""A class providing info about occurring collisions. """A class providing info about occurring collisions."""
Category: **Gameplay Classes**
"""
@property @property
def position(self) -> bascenev1.Vec3: def position(self) -> bascenev1.Vec3:
@ -28,9 +25,9 @@ class Collision:
def sourcenode(self) -> bascenev1.Node: def sourcenode(self) -> bascenev1.Node:
"""The node containing the material triggering the current callback. """The node containing the material triggering the current callback.
Throws a bascenev1.NodeNotFoundError if the node does not exist, Throws a :class:`~babase.NodeNotFoundError` if the node does
though the node should always exist (at least at the start of the not exist, though the node should always exist (at least at the
collision callback). start of the collision callback).
""" """
node = _bascenev1.get_collision_info('sourcenode') node = _bascenev1.get_collision_info('sourcenode')
assert isinstance(node, (_bascenev1.Node, type(None))) assert isinstance(node, (_bascenev1.Node, type(None)))
@ -42,9 +39,10 @@ class Collision:
def opposingnode(self) -> bascenev1.Node: def opposingnode(self) -> bascenev1.Node:
"""The node the current callback material node is hitting. """The node the current callback material node is hitting.
Throws a bascenev1.NodeNotFoundError if the node does not exist. Throws a :class:`~babase.NodeNotFoundError` if the node does
This can be expected in some cases such as in 'disconnect' not exist. This can be expected in some cases such as in
callbacks triggered by deleting a currently-colliding node. 'disconnect' callbacks triggered by deleting a
currently-colliding node.
""" """
node = _bascenev1.get_collision_info('opposingnode') node = _bascenev1.get_collision_info('opposingnode')
assert isinstance(node, (_bascenev1.Node, type(None))) assert isinstance(node, (_bascenev1.Node, type(None)))
@ -65,8 +63,5 @@ _collision = Collision()
def getcollision() -> Collision: def getcollision() -> Collision:
"""Return the in-progress collision. """Return the in-progress collision."""
Category: **Gameplay Functions**
"""
return _collision return _collision

View file

@ -23,10 +23,7 @@ TeamT = TypeVar('TeamT', bound='bascenev1.Team')
class CoopGameActivity(GameActivity[PlayerT, TeamT]): class CoopGameActivity(GameActivity[PlayerT, TeamT]):
"""Base class for cooperative-mode games. """Base class for cooperative-mode games."""
Category: **Gameplay Classes**
"""
# We can assume our session is a CoopSession. # We can assume our session is a CoopSession.
session: bascenev1.CoopSession session: bascenev1.CoopSession

View file

@ -20,13 +20,10 @@ TEAM_NAMES = ['Good Guys']
class CoopSession(Session): class CoopSession(Session):
"""A bascenev1.Session which runs cooperative-mode games. """A session which runs cooperative-mode games.
Category: **Gameplay Classes** These generally consist of 1-4 players against the computer and
include functionality such as high score lists.
These generally consist of 1-4 players against
the computer and include functionality such as
high score lists.
""" """
use_teams = True use_teams = True

View file

@ -22,8 +22,6 @@ T = TypeVar('T', bound='DependencyComponent')
class Dependency(Generic[T]): class Dependency(Generic[T]):
"""A dependency on a DependencyComponent (with an optional config). """A dependency on a DependencyComponent (with an optional config).
Category: **Dependency Classes**
This class is used to request and access functionality provided This class is used to request and access functionality provided
by other DependencyComponent classes from a DependencyComponent class. by other DependencyComponent classes from a DependencyComponent class.
The class functions as a descriptor, allowing dependencies to The class functions as a descriptor, allowing dependencies to
@ -92,10 +90,7 @@ class Dependency(Generic[T]):
class DependencyComponent: class DependencyComponent:
"""Base class for all classes that can act as or use dependencies. """Base class for all classes that can act as or use dependencies."""
Category: **Dependency Classes**
"""
_dep_entry: weakref.ref[DependencyEntry] _dep_entry: weakref.ref[DependencyEntry]
@ -173,8 +168,6 @@ class DependencyEntry:
class DependencySet(Generic[T]): class DependencySet(Generic[T]):
"""Set of resolved dependencies and their associated data. """Set of resolved dependencies and their associated data.
Category: **Dependency Classes**
To use DependencyComponents, a set must be created, resolved, and then To use DependencyComponents, a set must be created, resolved, and then
loaded. The DependencyComponents are only valid while the set remains loaded. The DependencyComponents are only valid while the set remains
in existence. in existence.
@ -296,10 +289,7 @@ class DependencySet(Generic[T]):
class AssetPackage(DependencyComponent): class AssetPackage(DependencyComponent):
"""bascenev1.DependencyComponent representing a bundled package of assets. """bascenev1.DependencyComponent representing a package of assets."""
Category: **Asset Classes**
"""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -430,9 +420,7 @@ def test_depset() -> None:
class DependencyError(Exception): class DependencyError(Exception):
"""Exception raised when one or more bascenev1.Dependency items are missing. """:class:`Exception` raised when bascenev1.Dependency items are missing.
Category: **Exception Classes**
(this will generally be missing assets). (this will generally be missing assets).
""" """

View file

@ -15,10 +15,7 @@ if TYPE_CHECKING:
class DualTeamSession(MultiTeamSession): class DualTeamSession(MultiTeamSession):
"""bascenev1.Session type for teams mode games. """bascenev1.Session type for teams mode games."""
Category: **Gameplay Classes**
"""
# Base class overrides: # Base class overrides:
use_teams = True use_teams = True

View file

@ -16,10 +16,7 @@ if TYPE_CHECKING:
class FreeForAllSession(MultiTeamSession): class FreeForAllSession(MultiTeamSession):
"""bascenev1.Session type for free-for-all mode games. """bascenev1.Session type for free-for-all mode games."""
Category: **Gameplay Classes**
"""
use_teams = False use_teams = False
use_team_colors = False use_team_colors = False

View file

@ -31,10 +31,7 @@ TeamT = TypeVar('TeamT', bound='bascenev1.Team')
class GameActivity(Activity[PlayerT, TeamT]): class GameActivity(Activity[PlayerT, TeamT]):
"""Common base class for all game bascenev1.Activities. """Common base class for all game bascenev1.Activities."""
Category: **Gameplay Classes**
"""
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
@ -198,7 +195,7 @@ class GameActivity(Activity[PlayerT, TeamT]):
def supports_session_type( def supports_session_type(
cls, sessiontype: type[bascenev1.Session] cls, sessiontype: type[bascenev1.Session]
) -> bool: ) -> bool:
"""Return whether this game supports the provided Session type.""" """Return whether this game supports the provided session type."""
from bascenev1._multiteamsession import MultiTeamSession from bascenev1._multiteamsession import MultiTeamSession
# By default, games support any versus mode # By default, games support any versus mode
@ -208,8 +205,8 @@ class GameActivity(Activity[PlayerT, TeamT]):
"""Instantiate the Activity.""" """Instantiate the Activity."""
super().__init__(settings) super().__init__(settings)
# Holds some flattened info about the player set at the point #: Holds some flattened info about the player set at the point
# when on_begin() is called. #: when :meth:`on_begin()` is called.
self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None
# Go ahead and get our map loading. # Go ahead and get our map loading.
@ -794,9 +791,10 @@ class GameActivity(Activity[PlayerT, TeamT]):
self.spawn_player(player) self.spawn_player(player)
def spawn_player(self, player: PlayerT) -> bascenev1.Actor: def spawn_player(self, player: PlayerT) -> bascenev1.Actor:
"""Spawn *something* for the provided bascenev1.Player. """Spawn *something* for the provided player.
The default implementation simply calls spawn_player_spaz(). The default implementation simply calls
:meth:`spawn_player_spaz()`.
""" """
assert player # Dead references should never be passed as args. assert player # Dead references should never be passed as args.
@ -808,7 +806,7 @@ class GameActivity(Activity[PlayerT, TeamT]):
position: Sequence[float] = (0, 0, 0), position: Sequence[float] = (0, 0, 0),
angle: float | None = None, angle: float | None = None,
) -> PlayerSpaz: ) -> PlayerSpaz:
"""Create and wire up a bascenev1.PlayerSpaz for the provided Player.""" """Create and wire up a player-spaz for the provided player."""
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bascenev1._gameutils import animate from bascenev1._gameutils import animate

View file

@ -21,20 +21,17 @@ if TYPE_CHECKING:
@dataclass @dataclass
class WinnerGroup: class WinnerGroup:
"""Entry for a winning team or teams calculated by game-results.""" """Winning team or teams as calculated by a :class:`GameResults`."""
score: int | None score: int | None
teams: Sequence[bascenev1.SessionTeam] teams: list[bascenev1.SessionTeam]
class GameResults: class GameResults:
""" """Results for a completed game.
Results for a completed game.
Category: **Gameplay Classes** Upon completion, a game should fill one of these out and pass it to
its :meth:`~bascenev1.Activity.end()` call.
Upon completion, a game should fill one of these out and pass it to its
bascenev1.Activity.end call.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -69,8 +66,8 @@ class GameResults:
def set_team_score(self, team: bascenev1.Team, score: int | None) -> None: def set_team_score(self, team: bascenev1.Team, score: int | None) -> None:
"""Set the score for a given team. """Set the score for a given team.
This can be a number or None. This can be a number or None (see the ``none_is_winner`` arg in
(see the none_is_winner arg in the constructor) the constructor).
""" """
assert isinstance(team, Team) assert isinstance(team, Team)
sessionteam = team.sessionteam sessionteam = team.sessionteam
@ -79,7 +76,7 @@ class GameResults:
def get_sessionteam_score( def get_sessionteam_score(
self, sessionteam: bascenev1.SessionTeam self, sessionteam: bascenev1.SessionTeam
) -> int | None: ) -> int | None:
"""Return the score for a given bascenev1.SessionTeam.""" """Return the score for a given team."""
assert isinstance(sessionteam, SessionTeam) assert isinstance(sessionteam, SessionTeam)
for score in list(self._scores.values()): for score in list(self._scores.values()):
if score[0]() is sessionteam: if score[0]() is sessionteam:
@ -90,7 +87,7 @@ class GameResults:
@property @property
def sessionteams(self) -> list[bascenev1.SessionTeam]: def sessionteams(self) -> list[bascenev1.SessionTeam]:
"""Return all bascenev1.SessionTeams in the results.""" """Return all teams in the results."""
if not self._game_set: if not self._game_set:
raise RuntimeError("Can't get teams until game is set.") raise RuntimeError("Can't get teams until game is set.")
teams = [] teams = []
@ -104,13 +101,13 @@ class GameResults:
def has_score_for_sessionteam( def has_score_for_sessionteam(
self, sessionteam: bascenev1.SessionTeam self, sessionteam: bascenev1.SessionTeam
) -> bool: ) -> bool:
"""Return whether there is a score for a given session-team.""" """Return whether there is a score for a given team."""
return any(s[0]() is sessionteam for s in self._scores.values()) return any(s[0]() is sessionteam for s in self._scores.values())
def get_sessionteam_score_str( def get_sessionteam_score_str(
self, sessionteam: bascenev1.SessionTeam self, sessionteam: bascenev1.SessionTeam
) -> babase.Lstr: ) -> babase.Lstr:
"""Return the score for the given session-team as an Lstr. """Return the score for the given team as an :class:`~bascenev1.Lstr`.
(properly formatted for the score type.) (properly formatted for the score type.)
""" """
@ -163,7 +160,7 @@ class GameResults:
@property @property
def winning_sessionteam(self) -> bascenev1.SessionTeam | None: def winning_sessionteam(self) -> bascenev1.SessionTeam | None:
"""The winning SessionTeam if there is exactly one, or else None.""" """The winning team if there is exactly one, or else None."""
if not self._game_set: if not self._game_set:
raise RuntimeError("Can't get winners until game is set.") raise RuntimeError("Can't get winners until game is set.")
winners = self.winnergroups winners = self.winnergroups
@ -173,7 +170,7 @@ class GameResults:
@property @property
def winnergroups(self) -> list[WinnerGroup]: def winnergroups(self) -> list[WinnerGroup]:
"""Get an ordered list of winner groups.""" """The ordered list of winner-groups."""
if not self._game_set: if not self._game_set:
raise RuntimeError("Can't get winners until game is set.") raise RuntimeError("Can't get winners until game is set.")

View file

@ -31,10 +31,7 @@ TROPHY_CHARS = {
@dataclass @dataclass
class GameTip: class GameTip:
"""Defines a tip presentable to the user at the start of a game. """Defines a tip presentable to the user at the start of a game."""
Category: **Gameplay Classes**
"""
text: str text: str
icon: bascenev1.Texture | None = None icon: bascenev1.Texture | None = None
@ -57,8 +54,6 @@ def animate(
) -> bascenev1.Node: ) -> bascenev1.Node:
"""Animate values on a target bascenev1.Node. """Animate values on a target bascenev1.Node.
Category: **Gameplay Functions**
Creates an 'animcurve' node with the provided values and time as an input, Creates an 'animcurve' node with the provided values and time as an input,
connect it to the provided attribute, and set it to die with the target. connect it to the provided attribute, and set it to die with the target.
Key values are provided as time:value dictionary pairs. Time values are Key values are provided as time:value dictionary pairs. Time values are
@ -118,8 +113,6 @@ def animate_array(
) -> None: ) -> None:
"""Animate an array of values on a target bascenev1.Node. """Animate an array of values on a target bascenev1.Node.
Category: **Gameplay Functions**
Like bs.animate, but operates on array attributes. Like bs.animate, but operates on array attributes.
""" """
combine = _bascenev1.newnode('combine', owner=node, attrs={'size': size}) combine = _bascenev1.newnode('combine', owner=node, attrs={'size': size})
@ -176,10 +169,7 @@ def animate_array(
def show_damage_count( def show_damage_count(
damage: str, position: Sequence[float], direction: Sequence[float] damage: str, position: Sequence[float], direction: Sequence[float]
) -> None: ) -> None:
"""Pop up a damage count at a position in space. """Pop up a damage count at a position in space."""
Category: **Gameplay Functions**
"""
lifespan = 1.0 lifespan = 1.0
app = babase.app app = babase.app
@ -239,8 +229,6 @@ def show_damage_count(
def cameraflash(duration: float = 999.0) -> None: def cameraflash(duration: float = 999.0) -> None:
"""Create a strobing camera flash effect. """Create a strobing camera flash effect.
Category: **Gameplay Functions**
(as seen when a team wins a game) (as seen when a team wins a game)
Duration is in seconds. Duration is in seconds.
""" """

View file

@ -16,10 +16,7 @@ if TYPE_CHECKING:
class Level: class Level:
"""An entry in a bascenev1.Campaign. """An entry in a :class:`~bascenev1.Campaign`."""
Category: **Gameplay Classes**
"""
def __init__( def __init__(
self, self,
@ -46,7 +43,7 @@ class Level:
@property @property
def name(self) -> str: def name(self) -> str:
"""The unique name for this Level.""" """The unique name for this level."""
return self._name return self._name
def get_settings(self) -> dict[str, Any]: def get_settings(self) -> dict[str, Any]:
@ -60,16 +57,12 @@ class Level:
@property @property
def preview_texture_name(self) -> str: def preview_texture_name(self) -> str:
"""The preview texture name for this Level.""" """The preview texture name for this level."""
return self._preview_texture_name return self._preview_texture_name
# def get_preview_texture(self) -> bauiv1.Texture:
# """Load/return the preview Texture for this Level."""
# return _bauiv1.gettexture(self._preview_texture_name)
@property @property
def displayname(self) -> bascenev1.Lstr: def displayname(self) -> bascenev1.Lstr:
"""The localized name for this Level.""" """The localized name for this level."""
return babase.Lstr( return babase.Lstr(
translate=( translate=(
'coopLevelNames', 'coopLevelNames',
@ -86,20 +79,20 @@ class Level:
@property @property
def gametype(self) -> type[bascenev1.GameActivity]: def gametype(self) -> type[bascenev1.GameActivity]:
"""The type of game used for this Level.""" """The type of game used for this level."""
return self._gametype return self._gametype
@property @property
def campaign(self) -> bascenev1.Campaign | None: def campaign(self) -> bascenev1.Campaign | None:
"""The baclassic.Campaign this Level is associated with, or None.""" """The campaign this level is associated with, or None."""
return None if self._campaign is None else self._campaign() return None if self._campaign is None else self._campaign()
@property @property
def index(self) -> int: def index(self) -> int:
"""The zero-based index of this Level in its baclassic.Campaign. """The zero-based index of this level in its campaign.
Access results in a RuntimeError if the Level is not assigned to a Access results in a RuntimeError if the level is not assigned to
Campaign. a campaign.
""" """
if self._index is None: if self._index is None:
raise RuntimeError('Level is not part of a Campaign') raise RuntimeError('Level is not part of a Campaign')
@ -107,7 +100,7 @@ class Level:
@property @property
def complete(self) -> bool: def complete(self) -> bool:
"""Whether this Level has been completed.""" """Whether this level has been completed."""
config = self._get_config_dict() config = self._get_config_dict()
val = config.get('Complete', False) val = config.get('Complete', False)
assert isinstance(val, bool) assert isinstance(val, bool)
@ -123,7 +116,7 @@ class Level:
config['Complete'] = val config['Complete'] = val
def get_high_scores(self) -> dict: def get_high_scores(self) -> dict:
"""Return the current high scores for this Level.""" """Return the current high scores for this level."""
config = self._get_config_dict() config = self._get_config_dict()
high_scores_key = 'High Scores' + self.get_score_version_string() high_scores_key = 'High Scores' + self.get_score_version_string()
if high_scores_key not in config: if high_scores_key not in config:
@ -137,10 +130,11 @@ class Level:
config[high_scores_key] = high_scores config[high_scores_key] = high_scores
def get_score_version_string(self) -> str: def get_score_version_string(self) -> str:
"""Return the score version string for this Level. """Return the score version string for this level.
If a Level's gameplay changes significantly, its version string If a level's gameplay changes significantly, its version string
can be changed to separate its new high score lists/etc. from the old. can be changed to separate its new high score lists/etc. from
the old.
""" """
if self._score_version_string is None: if self._score_version_string is None:
scorever = self._gametype.getscoreconfig().version scorever = self._gametype.getscoreconfig().version
@ -152,13 +146,13 @@ class Level:
@property @property
def rating(self) -> float: def rating(self) -> float:
"""The current rating for this Level.""" """The current rating for this level."""
val = self._get_config_dict().get('Rating', 0.0) val = self._get_config_dict().get('Rating', 0.0)
assert isinstance(val, float) assert isinstance(val, float)
return val return val
def set_rating(self, rating: float) -> None: def set_rating(self, rating: float) -> None:
"""Set a rating for this Level, replacing the old ONLY IF higher.""" """Set a rating for this level, replacing the old ONLY IF higher."""
old_rating = self.rating old_rating = self.rating
config = self._get_config_dict() config = self._get_config_dict()
config['Rating'] = max(old_rating, rating) config['Rating'] = max(old_rating, rating)
@ -166,8 +160,9 @@ class Level:
def _get_config_dict(self) -> dict[str, Any]: def _get_config_dict(self) -> dict[str, Any]:
"""Return/create the persistent state dict for this level. """Return/create the persistent state dict for this level.
The referenced dict exists under the game's config dict and The referenced dict exists under the game's config dict and can
can be modified in place.""" be modified in place.
"""
campaign = self.campaign campaign = self.campaign
if campaign is None: if campaign is None:
raise RuntimeError('Level is not in a campaign.') raise RuntimeError('Level is not in a campaign.')
@ -179,8 +174,9 @@ class Level:
return val return val
def set_campaign(self, campaign: bascenev1.Campaign, index: int) -> None: def set_campaign(self, campaign: bascenev1.Campaign, index: int) -> None:
"""For use by baclassic.Campaign when adding levels to itself. """Internal: Used by campaign when adding levels to itself.
(internal)""" :meta private:
"""
self._campaign = weakref.ref(campaign) self._campaign = weakref.ref(campaign)
self._index = index self._index = index

View file

@ -178,10 +178,7 @@ class ChangeMessage:
class Chooser: class Chooser:
"""A character/team selector for a bascenev1.Player. """A character/team selector for a player."""
Category: Gameplay Classes
"""
def __del__(self) -> None: def __del__(self) -> None:
# Just kill off our base node; the rest should go down with it. # Just kill off our base node; the rest should go down with it.
@ -364,7 +361,7 @@ class Chooser:
@property @property
def sessionplayer(self) -> bascenev1.SessionPlayer: def sessionplayer(self) -> bascenev1.SessionPlayer:
"""The bascenev1.SessionPlayer associated with this chooser.""" """The session-player associated with this chooser."""
return self._sessionplayer return self._sessionplayer
@property @property
@ -373,11 +370,17 @@ class Chooser:
return self._ready return self._ready
def set_vpos(self, vpos: float) -> None: def set_vpos(self, vpos: float) -> None:
"""(internal)""" """(internal)
:meta private:
"""
self._vpos = vpos self._vpos = vpos
def set_dead(self, val: bool) -> None: def set_dead(self, val: bool) -> None:
"""(internal)""" """(internal)
:meta private:
"""
self._dead = val self._dead = val
@property @property
@ -387,7 +390,7 @@ class Chooser:
@property @property
def lobby(self) -> bascenev1.Lobby: def lobby(self) -> bascenev1.Lobby:
"""The chooser's baclassic.Lobby.""" """The chooser's lobby."""
lobby = self._lobby() lobby = self._lobby()
if lobby is None: if lobby is None:
raise babase.NotFoundError('Lobby does not exist.') raise babase.NotFoundError('Lobby does not exist.')
@ -940,10 +943,7 @@ class Chooser:
class Lobby: class Lobby:
"""Container for baclassic.Choosers. """Environment where players can selecting characters, etc."""
Category: Gameplay Classes
"""
def __del__(self) -> None: def __del__(self) -> None:
# Reset any players that still have a chooser in us. # Reset any players that still have a chooser in us.
@ -987,7 +987,7 @@ class Lobby:
@property @property
def use_team_colors(self) -> bool: def use_team_colors(self) -> bool:
"""A bool for whether this lobby is using team colors. """Whether this lobby is using team colors.
If False, inidividual player colors are used instead. If False, inidividual player colors are used instead.
""" """
@ -995,7 +995,7 @@ class Lobby:
@property @property
def sessionteams(self) -> list[bascenev1.SessionTeam]: def sessionteams(self) -> list[bascenev1.SessionTeam]:
"""bascenev1.SessionTeams available in this lobby.""" """The teams available in this lobby."""
allteams = [] allteams = []
for tref in self._sessionteams: for tref in self._sessionteams:
team = tref() team = tref()
@ -1004,7 +1004,7 @@ class Lobby:
return allteams return allteams
def get_choosers(self) -> list[Chooser]: def get_choosers(self) -> list[Chooser]:
"""Return the lobby's current choosers.""" """The current choosers present."""
return self.choosers return self.choosers
def create_join_info(self) -> JoinInfo: def create_join_info(self) -> JoinInfo:
@ -1070,8 +1070,8 @@ class Lobby:
found = True found = True
# Mark it as dead since there could be more # Mark it as dead since there could be more
# change-commands/etc coming in still for it; # change-commands/etc coming in still for it; want to
# want to avoid duplicate player-adds/etc. # avoid duplicate player-adds/etc.
chooser.set_dead(True) chooser.set_dead(True)
self.choosers.remove(chooser) self.choosers.remove(chooser)
break break

View file

@ -20,8 +20,6 @@ if TYPE_CHECKING:
def get_filtered_map_name(name: str) -> str: def get_filtered_map_name(name: str) -> str:
"""Filter a map name to account for name changes, etc. """Filter a map name to account for name changes, etc.
Category: **Asset Functions**
This can be used to support old playlists, etc. This can be used to support old playlists, etc.
""" """
# Some legacy name fallbacks... can remove these eventually. # Some legacy name fallbacks... can remove these eventually.
@ -33,18 +31,12 @@ def get_filtered_map_name(name: str) -> str:
def get_map_display_string(name: str) -> babase.Lstr: def get_map_display_string(name: str) -> babase.Lstr:
"""Return a babase.Lstr for displaying a given map\'s name. """Return a babase.Lstr for displaying a given map's name."""
Category: **Asset Functions**
"""
return babase.Lstr(translate=('mapsNames', name)) return babase.Lstr(translate=('mapsNames', name))
def get_map_class(name: str) -> type[Map]: def get_map_class(name: str) -> type[Map]:
"""Return a map type given a name. """Return a map type given a name."""
Category: **Asset Functions**
"""
assert babase.app.classic is not None assert babase.app.classic is not None
name = get_filtered_map_name(name) name = get_filtered_map_name(name)
try: try:
@ -57,8 +49,6 @@ def get_map_class(name: str) -> type[Map]:
class Map(Actor): class Map(Actor):
"""A game map. """A game map.
Category: **Gameplay Classes**
Consists of a collection of terrain nodes, metadata, and other Consists of a collection of terrain nodes, metadata, and other
functionality comprising a game map. functionality comprising a game map.
""" """

View file

@ -28,17 +28,11 @@ UNHANDLED = _UnhandledType()
@dataclass @dataclass
class OutOfBoundsMessage: class OutOfBoundsMessage:
"""A message telling an object that it is out of bounds. """A message telling an object that it is out of bounds."""
Category: Message Classes
"""
class DeathType(Enum): class DeathType(Enum):
"""A reason for a death. """A reason for a death."""
Category: Enums
"""
GENERIC = 'generic' GENERIC = 'generic'
OUT_OF_BOUNDS = 'out_of_bounds' OUT_OF_BOUNDS = 'out_of_bounds'
@ -52,29 +46,24 @@ class DeathType(Enum):
class DieMessage: class DieMessage:
"""A message telling an object to die. """A message telling an object to die.
Category: **Message Classes**
Most bascenev1.Actor-s respond to this. Most bascenev1.Actor-s respond to this.
""" """
#: If this is set to True, the actor should disappear immediately.
#: This is for 'removing' stuff from the game more so than 'killing'
#: it. If False, the actor should die a 'normal' death and can take
#: its time with lingering corpses, sound effects, etc.
immediate: bool = False immediate: bool = False
"""If this is set to True, the actor should disappear immediately.
This is for 'removing' stuff from the game more so than 'killing'
it. If False, the actor should die a 'normal' death and can take
its time with lingering corpses, sound effects, etc."""
#: The particular reason for death.
how: DeathType = DeathType.GENERIC how: DeathType = DeathType.GENERIC
"""The particular reason for death."""
PlayerT = TypeVar('PlayerT', bound='bascenev1.Player') PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
class PlayerDiedMessage: class PlayerDiedMessage:
"""A message saying a bascenev1.Player has died. """A message saying a bascenev1.Player has died."""
Category: **Message Classes**
"""
killed: bool killed: bool
"""If True, the player was killed; """If True, the player was killed;
@ -129,8 +118,6 @@ class PlayerDiedMessage:
class StandMessage: class StandMessage:
"""A message telling an object to move to a position in space. """A message telling an object to move to a position in space.
Category: **Message Classes**
Used when teleporting players to home base, etc. Used when teleporting players to home base, etc.
""" """
@ -143,10 +130,7 @@ class StandMessage:
@dataclass @dataclass
class PickUpMessage: class PickUpMessage:
"""Tells an object that it has picked something up. """Tells an object that it has picked something up."""
Category: **Message Classes**
"""
node: bascenev1.Node node: bascenev1.Node
"""The bascenev1.Node that is getting picked up.""" """The bascenev1.Node that is getting picked up."""
@ -154,18 +138,12 @@ class PickUpMessage:
@dataclass @dataclass
class DropMessage: class DropMessage:
"""Tells an object that it has dropped what it was holding. """Tells an object that it has dropped what it was holding."""
Category: **Message Classes**
"""
@dataclass @dataclass
class PickedUpMessage: class PickedUpMessage:
"""Tells an object that it has been picked up by something. """Tells an object that it has been picked up by something."""
Category: **Message Classes**
"""
node: bascenev1.Node node: bascenev1.Node
"""The bascenev1.Node doing the picking up.""" """The bascenev1.Node doing the picking up."""
@ -173,10 +151,7 @@ class PickedUpMessage:
@dataclass @dataclass
class DroppedMessage: class DroppedMessage:
"""Tells an object that it has been dropped. """Tells an object that it has been dropped."""
Category: **Message Classes**
"""
node: bascenev1.Node node: bascenev1.Node
"""The bascenev1.Node doing the dropping.""" """The bascenev1.Node doing the dropping."""
@ -184,18 +159,12 @@ class DroppedMessage:
@dataclass @dataclass
class ShouldShatterMessage: class ShouldShatterMessage:
"""Tells an object that it should shatter. """Tells an object that it should shatter."""
Category: **Message Classes**
"""
@dataclass @dataclass
class ImpactDamageMessage: class ImpactDamageMessage:
"""Tells an object that it has been jarred violently. """Tells an object that it has been jarred violently."""
Category: **Message Classes**
"""
intensity: float intensity: float
"""The intensity of the impact.""" """The intensity of the impact."""
@ -205,26 +174,21 @@ class ImpactDamageMessage:
class FreezeMessage: class FreezeMessage:
"""Tells an object to become frozen. """Tells an object to become frozen.
Category: **Message Classes**
As seen in the effects of an ice bascenev1.Bomb. As seen in the effects of an ice bascenev1.Bomb.
""" """
time: float = 5.0
"""The amount of time the object will be frozen."""
@dataclass @dataclass
class ThawMessage: class ThawMessage:
"""Tells an object to stop being frozen. """Tells an object to stop being frozen."""
Category: **Message Classes**
"""
@dataclass @dataclass
class CelebrateMessage: class CelebrateMessage:
"""Tells an object to celebrate. """Tells an object to celebrate."""
Category: **Message Classes**
"""
duration: float = 10.0 duration: float = 10.0
"""Amount of time to celebrate in seconds.""" """Amount of time to celebrate in seconds."""
@ -233,10 +197,8 @@ class CelebrateMessage:
class HitMessage: class HitMessage:
"""Tells an object it has been hit in some way. """Tells an object it has been hit in some way.
Category: **Message Classes** This is used by punches, explosions, etc to convey their effect to a
target.
This is used by punches, explosions, etc to convey
their effect to a target.
""" """
def __init__( def __init__(

View file

@ -25,8 +25,6 @@ DEFAULT_TEAM_NAMES = ('Blue', 'Red')
class MultiTeamSession(Session): class MultiTeamSession(Session):
"""Common base for DualTeamSession and FreeForAllSession. """Common base for DualTeamSession and FreeForAllSession.
Category: **Gameplay Classes**
Free-for-all-mode is essentially just teams-mode with each Free-for-all-mode is essentially just teams-mode with each
bascenev1.Player having their own bascenev1.Team, so there is much bascenev1.Player having their own bascenev1.Team, so there is much
overlap in functionality. overlap in functionality.

View file

@ -16,8 +16,6 @@ if TYPE_CHECKING:
class MusicType(Enum): class MusicType(Enum):
"""Types of music available to play in-game. """Types of music available to play in-game.
Category: **Enums**
These do not correspond to specific pieces of music, but rather to These do not correspond to specific pieces of music, but rather to
'situations'. The actual music played for each type can be overridden 'situations'. The actual music played for each type can be overridden
by the game or by the user. by the game or by the user.
@ -50,16 +48,15 @@ class MusicType(Enum):
def setmusic(musictype: MusicType | None, continuous: bool = False) -> None: def setmusic(musictype: MusicType | None, continuous: bool = False) -> None:
"""Set the app to play (or stop playing) a certain type of music. """Set the app to play (or stop playing) a certain type of music.
category: **Gameplay Functions** This function will handle loading and playing sound assets as
necessary, and also supports custom user soundtracks on specific
This function will handle loading and playing sound assets as necessary, platforms so the user can override particular game music with their
and also supports custom user soundtracks on specific platforms so the own.
user can override particular game music with their own.
Pass None to stop music. Pass None to stop music.
if 'continuous' is True and musictype is the same as what is already if ``continuous`` is True and musictype is the same as what is
playing, the playing track will not be restarted. already playing, the playing track will not be restarted.
""" """
# All we do here now is set a few music attrs on the current globals # All we do here now is set a few music attrs on the current globals

View file

@ -18,8 +18,6 @@ if TYPE_CHECKING:
class NodeActor(Actor): class NodeActor(Actor):
"""A simple bascenev1.Actor type that wraps a single bascenev1.Node. """A simple bascenev1.Actor type that wraps a single bascenev1.Node.
Category: **Gameplay Classes**
This Actor will delete its Node when told to die, and it's This Actor will delete its Node when told to die, and it's
exists() call will return whether the Node still exists or not. exists() call will return whether the Node still exists or not.
""" """

View file

@ -24,10 +24,7 @@ TeamT = TypeVar('TeamT', bound='bascenev1.Team')
@dataclass @dataclass
class PlayerInfo: class PlayerInfo:
"""Holds basic info about a player. """Holds basic info about a player."""
Category: Gameplay Classes
"""
name: str name: str
character: str character: str
@ -35,10 +32,7 @@ class PlayerInfo:
@dataclass @dataclass
class StandLocation: class StandLocation:
"""Describes a point in space and an angle to face. """Describes a point in space and an angle to face."""
Category: Gameplay Classes
"""
position: babase.Vec3 position: babase.Vec3
angle: float | None = None angle: float | None = None
@ -47,8 +41,6 @@ class StandLocation:
class Player(Generic[TeamT]): class Player(Generic[TeamT]):
"""A player in a specific bascenev1.Activity. """A player in a specific bascenev1.Activity.
Category: Gameplay Classes
These correspond to bascenev1.SessionPlayer objects, but are associated These correspond to bascenev1.SessionPlayer objects, but are associated
with a single bascenev1.Activity instance. This allows activities to with a single bascenev1.Activity instance. This allows activities to
specify their own custom bascenev1.Player types. specify their own custom bascenev1.Player types.
@ -282,8 +274,6 @@ class Player(Generic[TeamT]):
class EmptyPlayer(Player['bascenev1.EmptyTeam']): class EmptyPlayer(Player['bascenev1.EmptyTeam']):
"""An empty player for use by Activities that don't need to define one. """An empty player for use by Activities that don't need to define one.
Category: Gameplay Classes
bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing
those top level classes as type arguments when defining a those top level classes as type arguments when defining a
bascenev1.Activity reduces type safety. For example, bascenev1.Activity reduces type safety. For example,
@ -306,8 +296,6 @@ class EmptyPlayer(Player['bascenev1.EmptyTeam']):
def playercast(totype: type[PlayerT], player: bascenev1.Player) -> PlayerT: def playercast(totype: type[PlayerT], player: bascenev1.Player) -> PlayerT:
"""Cast a bascenev1.Player to a specific bascenev1.Player subclass. """Cast a bascenev1.Player to a specific bascenev1.Player subclass.
Category: Gameplay Functions
When writing type-checked code, sometimes code will deal with raw When writing type-checked code, sometimes code will deal with raw
bascenev1.Player objects which need to be cast back to a game's actual bascenev1.Player objects which need to be cast back to a game's actual
player type so that access can be properly type-checked. This function player type so that access can be properly type-checked. This function
@ -324,9 +312,6 @@ def playercast(totype: type[PlayerT], player: bascenev1.Player) -> PlayerT:
def playercast_o( def playercast_o(
totype: type[PlayerT], player: bascenev1.Player | None totype: type[PlayerT], player: bascenev1.Player | None
) -> PlayerT | None: ) -> PlayerT | None:
"""A variant of bascenev1.playercast() for use with optional Player values. """A variant of bascenev1.playercast() for optional Player values."""
Category: Gameplay Functions
"""
assert isinstance(player, (totype, type(None))) assert isinstance(player, (totype, type(None)))
return player return player

View file

@ -17,9 +17,8 @@ if TYPE_CHECKING:
class PowerupMessage: class PowerupMessage:
"""A message telling an object to accept a powerup. """A message telling an object to accept a powerup.
Category: **Message Classes** This message is normally received by touching a
bascenev1.PowerupBox.
This message is normally received by touching a bascenev1.PowerupBox.
""" """
poweruptype: str poweruptype: str
@ -38,10 +37,8 @@ class PowerupMessage:
class PowerupAcceptMessage: class PowerupAcceptMessage:
"""A message informing a bascenev1.Powerup that it was accepted. """A message informing a bascenev1.Powerup that it was accepted.
Category: **Message Classes** This is generally sent in response to a bascenev1.PowerupMessage to
inform the box (or whoever granted it) that it can go away.
This is generally sent in response to a bascenev1.PowerupMessage
to inform the box (or whoever granted it) that it can go away.
""" """

View file

@ -14,10 +14,7 @@ if TYPE_CHECKING:
@unique @unique
class ScoreType(Enum): class ScoreType(Enum):
"""Type of scores. """Type of scores."""
Category: **Enums**
"""
SECONDS = 's' SECONDS = 's'
MILLISECONDS = 'ms' MILLISECONDS = 'ms'
@ -26,10 +23,7 @@ class ScoreType(Enum):
@dataclass @dataclass
class ScoreConfig: class ScoreConfig:
"""Settings for how a game handles scores. """Settings for how a game handles scores."""
Category: **Gameplay Classes**
"""
label: str = 'Score' label: str = 'Score'
"""A label show to the user for scores; 'Score', 'Time Survived', etc.""" """A label show to the user for scores; 'Score', 'Time Survived', etc."""

View file

@ -40,58 +40,59 @@ def set_max_players_override(max_players: int | None) -> None:
class Session: class Session:
"""Defines a high level series of bascenev1.Activity-es. """Wrangles a series of :class:`~bascenev1.Activity` instances.
Category: **Gameplay Classes** Examples of sessions are :class:`bascenev1.FreeForAllSession`,
:class:`bascenev1.DualTeamSession`, and
:class:`bascenev1.CoopSession`.
Examples of sessions are bascenev1.FreeForAllSession, A session is responsible for wrangling and transitioning between
bascenev1.DualTeamSession, and bascenev1.CoopSession. various activity instances such as mini-games and score-screens, and
for maintaining state between them (players, teams, score tallies,
A Session is responsible for wrangling and transitioning between various etc).
bascenev1.Activity instances such as mini-games and score-screens, and for
maintaining state between them (players, teams, score tallies, etc).
""" """
#: Whether this session groups players into an explicit set of teams.
#: If this is off, a unique team is generated for each player that
#: joins.
use_teams: bool = False use_teams: bool = False
"""Whether this session groups players into an explicit set of
teams. If this is off, a unique team is generated for each
player that joins."""
#: Whether players on a team should all adopt the colors of that team
#: instead of their own profile colors. This only applies if
#: :attr:`use_teams` is enabled.
use_team_colors: bool = True use_team_colors: bool = True
"""Whether players on a team should all adopt the colors of that
team instead of their own profile colors. This only applies if
use_teams is enabled."""
# Note: even though these are instance vars, we annotate and document them # Note: even though these are instance vars, we annotate and
# at the class level so that looks better and nobody get lost while # document them at the class level so that looks better and nobody
# reading large __init__ # get lost while reading large __init__
#: The lobby instance where new players go to select a
#: profile/team/etc. before being added to games. Be aware this value
#: may be None if a session does not allow any such selection.
lobby: bascenev1.Lobby lobby: bascenev1.Lobby
"""The baclassic.Lobby instance where new bascenev1.Player-s go to select
a Profile/Team/etc. before being added to games.
Be aware this value may be None if a Session does not allow
any such selection."""
#: The maximum number of players allowed in the Session.
max_players: int max_players: int
"""The maximum number of players allowed in the Session."""
#: The minimum number of players who must be present for the Session
#: to proceed past the initial joining screen
min_players: int min_players: int
"""The minimum number of players who must be present for the Session
to proceed past the initial joining screen"""
#: All players in the session. Note that most things should use the
#: list of :class:`~bascenev1.Player` instances found in the
#: :class:`~bascenev1.Activity`; not this. Some players, such as
#: those who have not yet selected a character, will only be found on
#: this list.
sessionplayers: list[bascenev1.SessionPlayer] sessionplayers: list[bascenev1.SessionPlayer]
"""All bascenev1.SessionPlayers in the Session. Most things should use
the list of bascenev1.Player-s in bascenev1.Activity; not this. Some
players, such as those who have not yet selected a character, will
only be found on this list."""
#: A shared dictionary for objects to use as storage on this session.
#: Ensure that keys here are unique to avoid collisions.
customdata: dict customdata: dict
"""A shared dictionary for objects to use as storage on this session.
Ensure that keys here are unique to avoid collisions."""
#: All the teams in the session. Most things will operate on the list
#: of :class:`~bascenev1.Team` instances found in an
#: :class:`~bascenev1.Activity`; not this.
sessionteams: list[bascenev1.SessionTeam] sessionteams: list[bascenev1.SessionTeam]
"""All the bascenev1.SessionTeams in the Session. Most things should
use the list of bascenev1.Team-s in bascenev1.Activity; not this."""
def __init__( def __init__(
self, self,
@ -243,7 +244,7 @@ class Session:
@property @property
def sessionglobalsnode(self) -> bascenev1.Node: def sessionglobalsnode(self) -> bascenev1.Node:
"""The sessionglobals bascenev1.Node for the session.""" """The sessionglobals node for the session."""
node = self._sessionglobalsnode node = self._sessionglobalsnode
if not node: if not node:
raise babase.NodeNotFoundError() raise babase.NodeNotFoundError()
@ -254,15 +255,15 @@ class Session:
) -> bool: ) -> bool:
"""Ask ourself if we should allow joins during an Activity. """Ask ourself if we should allow joins during an Activity.
Note that for a join to be allowed, both the Session and Activity Note that for a join to be allowed, both the session and
have to be ok with it (via this function and the activity have to be ok with it (via this function and the
Activity.allow_mid_activity_joins property. :attr:`bascenev1.Activity.allow_mid_activity_joins` property.
""" """
del activity # Unused. del activity # Unused.
return True return True
def on_player_request(self, player: bascenev1.SessionPlayer) -> bool: def on_player_request(self, player: bascenev1.SessionPlayer) -> bool:
"""Called when a new bascenev1.Player wants to join the Session. """Called when a new player wants to join the session.
This should return True or False to accept/reject. This should return True or False to accept/reject.
""" """
@ -426,10 +427,10 @@ class Session:
) )
def end(self) -> None: def end(self) -> None:
"""Initiates an end to the session and a return to the main menu. """Initiate an end to the session and a return to the main menu.
Note that this happens asynchronously, allowing the Note that this happens asynchronously, allowing the session and
session and its activities to shut down gracefully. its activities to shut down gracefully.
""" """
self._wants_to_end = True self._wants_to_end = True
if self._next_activity is None: if self._next_activity is None:
@ -457,10 +458,10 @@ class Session:
self._ending = True # Prevent further actions. self._ending = True # Prevent further actions.
def on_team_join(self, team: bascenev1.SessionTeam) -> None: def on_team_join(self, team: bascenev1.SessionTeam) -> None:
"""Called when a new bascenev1.Team joins the session.""" """Called when a new team joins the session."""
def on_team_leave(self, team: bascenev1.SessionTeam) -> None: def on_team_leave(self, team: bascenev1.SessionTeam) -> None:
"""Called when a bascenev1.Team is leaving the session.""" """Called when a team is leaving the session."""
def end_activity( def end_activity(
self, self,
@ -469,12 +470,12 @@ class Session:
delay: float, delay: float,
force: bool, force: bool,
) -> None: ) -> None:
"""Commence shutdown of a bascenev1.Activity (if not already occurring). """Commence shutdown of an activity (if not already occurring).
'delay' is the time delay before the Activity actually ends 'delay' is the time delay before the activity actually ends (in
(in seconds). Further calls to end() will be ignored up until seconds). Further calls to end the activity will be ignored up
this time, unless 'force' is True, in which case the new results until this time, unless 'force' is True, in which case the new
will replace the old. results will replace the old.
""" """
# Only pay attention if this is coming from our current activity. # Only pay attention if this is coming from our current activity.
if activity is not self._activity_retained: if activity is not self._activity_retained:
@ -530,13 +531,13 @@ class Session:
self._session._in_set_activity = False self._session._in_set_activity = False
def setactivity(self, activity: bascenev1.Activity) -> None: def setactivity(self, activity: bascenev1.Activity) -> None:
"""Assign a new current bascenev1.Activity for the session. """Assign a new current activity for the session.
Note that this will not change the current context to the new Note that this will not change the current context to the new
Activity's. Code must be run in the new activity's methods activity's. Code must be run in the new activity's methods
(on_transition_in, etc) to get it. (so you can't do (:meth:`~bascenev1.Activity.on_transition_in()`, etc) to get it.
session.setactivity(foo) and then bascenev1.newnode() to add a node (so you can't do ``session.setactivity(foo)`` and then
to foo) ``bascenev1.newnode()`` to add a node to foo).
""" """
# Make sure we don't get called recursively. # Make sure we don't get called recursively.
@ -656,16 +657,16 @@ class Session:
def on_activity_end( def on_activity_end(
self, activity: bascenev1.Activity, results: Any self, activity: bascenev1.Activity, results: Any
) -> None: ) -> None:
"""Called when the current bascenev1.Activity has ended. """Called when the current activity has ended.
The bascenev1.Session should look at the results and start The session should look at the results and start another
another bascenev1.Activity. activity.
""" """
def begin_next_activity(self) -> None: def begin_next_activity(self) -> None:
"""Called once the previous activity has been totally torn down. """Called once the previous activity has been totally torn down.
This means we're ready to begin the next one This means we're ready to begin the next one.
""" """
if self._next_activity is None: if self._next_activity is None:
# Should this ever happen? # Should this ever happen?
@ -742,7 +743,10 @@ class Session:
def transitioning_out_activity_was_freed( def transitioning_out_activity_was_freed(
self, can_show_ad_on_death: bool self, can_show_ad_on_death: bool
) -> None: ) -> None:
"""(internal)""" """(internal)
:meta private:
"""
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
# Since things should be generally still right now, it's a good time # Since things should be generally still right now, it's a good time

View file

@ -13,10 +13,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class Setting: class Setting:
"""Defines a user-controllable setting for a game or other entity. """Defines a user-controllable setting for a game or other entity."""
Category: Gameplay Classes
"""
name: str name: str
default: Any default: Any
@ -24,20 +21,14 @@ class Setting:
@dataclass @dataclass
class BoolSetting(Setting): class BoolSetting(Setting):
"""A boolean game setting. """A boolean game setting."""
Category: Settings Classes
"""
default: bool default: bool
@dataclass @dataclass
class IntSetting(Setting): class IntSetting(Setting):
"""An integer game setting. """An integer game setting."""
Category: Settings Classes
"""
default: int default: int
min_value: int = 0 min_value: int = 0
@ -47,10 +38,7 @@ class IntSetting(Setting):
@dataclass @dataclass
class FloatSetting(Setting): class FloatSetting(Setting):
"""A floating point game setting. """A floating point game setting."""
Category: Settings Classes
"""
default: float default: float
min_value: float = 0.0 min_value: float = 0.0
@ -60,20 +48,14 @@ class FloatSetting(Setting):
@dataclass @dataclass
class ChoiceSetting(Setting): class ChoiceSetting(Setting):
"""A setting with multiple choices. """A setting with multiple choices."""
Category: Settings Classes
"""
choices: list[tuple[str, Any]] choices: list[tuple[str, Any]]
@dataclass @dataclass
class IntChoiceSetting(ChoiceSetting): class IntChoiceSetting(ChoiceSetting):
"""An int setting with multiple choices. """An int setting with multiple choices."""
Category: Settings Classes
"""
default: int default: int
choices: list[tuple[str, int]] choices: list[tuple[str, int]]
@ -81,10 +63,7 @@ class IntChoiceSetting(ChoiceSetting):
@dataclass @dataclass
class FloatChoiceSetting(ChoiceSetting): class FloatChoiceSetting(ChoiceSetting):
"""A float setting with multiple choices. """A float setting with multiple choices."""
Category: Settings Classes
"""
default: float default: float
choices: list[tuple[str, float]] choices: list[tuple[str, float]]

View file

@ -22,10 +22,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class PlayerScoredMessage: class PlayerScoredMessage:
"""Informs something that a bascenev1.Player scored. """Informs something that a bascenev1.Player scored."""
Category: **Message Classes**
"""
score: int score: int
"""The score value.""" """The score value."""
@ -34,8 +31,6 @@ class PlayerScoredMessage:
class PlayerRecord: class PlayerRecord:
"""Stats for an individual player in a bascenev1.Stats object. """Stats for an individual player in a bascenev1.Stats object.
Category: **Gameplay Classes**
This does not necessarily correspond to a bascenev1.Player that is This does not necessarily correspond to a bascenev1.Player that is
still present (stats may be retained for players that leave still present (stats may be retained for players that leave
mid-game) mid-game)
@ -253,10 +248,7 @@ class PlayerRecord:
class Stats: class Stats:
"""Manages scores and statistics for a bascenev1.Session. """Manages scores and statistics for a bascenev1.Session."""
Category: **Gameplay Classes**
"""
def __init__(self) -> None: def __init__(self) -> None:
self._activity: weakref.ref[bascenev1.Activity] | None = None self._activity: weakref.ref[bascenev1.Activity] | None = None

View file

@ -17,34 +17,32 @@ if TYPE_CHECKING:
class SessionTeam: class SessionTeam:
"""A team of one or more bascenev1.SessionPlayers. """A team of one or more :class:`~bascenev1.SessionPlayer`.
Category: **Gameplay Classes** Note that a player will *always* have a team. in some cases, such as
free-for-all :class:`~bascenev1.Sessions`, each team consists of
Note that a SessionPlayer *always* has a SessionTeam; just one player.
in some cases, such as free-for-all bascenev1.Sessions,
each SessionTeam consists of just one SessionPlayer.
""" """
# Annotate our attr types at the class level so they're introspectable. # We annotate our attr types at the class level so they're more
# introspectable by docs tools/etc.
#: The team's name.
name: babase.Lstr | str name: babase.Lstr | str
"""The team's name."""
#: The team's color.
color: tuple[float, ...] # FIXME: can't we make this fixed len? color: tuple[float, ...] # FIXME: can't we make this fixed len?
"""The team's color."""
#: The list of players on the team.
players: list[bascenev1.SessionPlayer] players: list[bascenev1.SessionPlayer]
"""The list of bascenev1.SessionPlayer-s on the team."""
#: A dict for use by the current :class:`~bascenev1.Session` for
#: storing data associated with this team. Unlike customdata, this
#: persists for the duration of the session.
customdata: dict customdata: dict
"""A dict for use by the current bascenev1.Session for
storing data associated with this team.
Unlike customdata, this persists for the duration
of the session."""
#: The unique numeric id of the team.
id: int id: int
"""The unique numeric id of the team."""
def __init__( def __init__(
self, self,
@ -52,12 +50,6 @@ class SessionTeam:
name: babase.Lstr | str = '', name: babase.Lstr | str = '',
color: Sequence[float] = (1.0, 1.0, 1.0), color: Sequence[float] = (1.0, 1.0, 1.0),
): ):
"""Instantiate a bascenev1.SessionTeam.
In most cases, all teams are provided to you by the bascenev1.Session,
bascenev1.Session, so calling this shouldn't be necessary.
"""
self.id = team_id self.id = team_id
self.name = name self.name = name
self.color = tuple(color) self.color = tuple(color)
@ -66,7 +58,10 @@ class SessionTeam:
self.activityteam: Team | None = None self.activityteam: Team | None = None
def leave(self) -> None: def leave(self) -> None:
"""(internal)""" """(internal)
:meta private:
"""
self.customdata = {} self.customdata = {}
@ -74,12 +69,11 @@ PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
class Team(Generic[PlayerT]): class Team(Generic[PlayerT]):
"""A team in a specific bascenev1.Activity. """A team in a specific :class:`~bascenev1.Activity`.
Category: **Gameplay Classes** These correspond to :class:`~bascenev1.SessionTeam` objects, but are
created per activity so that the activity can use its own custom
These correspond to bascenev1.SessionTeam objects, but are created team subclass.
per activity so that the activity can use its own custom team subclass.
""" """
# Defining these types at the class level instead of in __init__ so # Defining these types at the class level instead of in __init__ so
@ -97,9 +91,9 @@ class Team(Generic[PlayerT]):
# get called by default if a dataclass inherits from us. # get called by default if a dataclass inherits from us.
def postinit(self, sessionteam: SessionTeam) -> None: def postinit(self, sessionteam: SessionTeam) -> None:
"""Wire up a newly created SessionTeam. """Internal: Wire up a newly created SessionTeam.
(internal) :meta private:
""" """
# Sanity check; if a dataclass is created that inherits from us, # Sanity check; if a dataclass is created that inherits from us,
@ -136,22 +130,23 @@ class Team(Generic[PlayerT]):
@property @property
def customdata(self) -> dict: def customdata(self) -> dict:
"""Arbitrary values associated with the team. """Arbitrary values associated with the team. Though it is
Though it is encouraged that most player values be properly defined encouraged that most player values be properly defined on the
on the bascenev1.Team subclass, it may be useful for player-agnostic :class:`~bascenev1.Team` subclass, it may be useful for
objects to store values here. This dict is cleared when the team player-agnostic objects to store values here. This dict is
leaves or expires so objects stored here will be disposed of at cleared when the team leaves or expires so objects stored here
the expected time, unlike the Team instance itself which may will be disposed of at the expected time, unlike the
continue to be referenced after it is no longer part of the game. :class:`~bascenev1.Team` instance itself which may continue to
be referenced after it is no longer part of the game.
""" """
assert self._postinited assert self._postinited
assert not self._expired assert not self._expired
return self._customdata return self._customdata
def leave(self) -> None: def leave(self) -> None:
"""Called when the Team leaves a running game. """Internal: Called when the team leaves a running game.
(internal) :meta private:
""" """
assert self._postinited assert self._postinited
assert not self._expired assert not self._expired
@ -159,9 +154,9 @@ class Team(Generic[PlayerT]):
del self.players del self.players
def expire(self) -> None: def expire(self) -> None:
"""Called when the Team is expiring (due to the Activity expiring). """Internal: Called when team is expiring (due to its activity).
(internal) :meta private:
""" """
assert self._postinited assert self._postinited
assert not self._expired assert not self._expired
@ -180,9 +175,10 @@ class Team(Generic[PlayerT]):
@property @property
def sessionteam(self) -> SessionTeam: def sessionteam(self) -> SessionTeam:
"""Return the bascenev1.SessionTeam corresponding to this Team. """The :class:`~bascenev1.SessionTeam` corresponding to this team.
Throws a babase.SessionTeamNotFoundError if there is none. Throws a :class:`~babase.SessionTeamNotFoundError` if there is
none.
""" """
assert self._postinited assert self._postinited
if self._sessionteam is not None: if self._sessionteam is not None:
@ -194,18 +190,16 @@ class Team(Generic[PlayerT]):
class EmptyTeam(Team['bascenev1.EmptyPlayer']): class EmptyTeam(Team['bascenev1.EmptyPlayer']):
"""An empty player for use by Activities that don't need to define one. """An empty player for use by Activities that don't define one.
Category: **Gameplay Classes** bascenev1.Player and bascenev1.Team are 'Generic' types, and so
passing those top level classes as type arguments when defining a
bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing
those top level classes as type arguments when defining a
bascenev1.Activity reduces type safety. For example, bascenev1.Activity reduces type safety. For example,
activity.teams[0].player will have type 'Any' in that case. For that activity.teams[0].player will have type 'Any' in that case. For that
reason, it is better to pass EmptyPlayer and EmptyTeam when defining reason, it is better to pass EmptyPlayer and EmptyTeam when defining
a bascenev1.Activity that does not need custom types of its own. a bascenev1.Activity that does not need custom types of its own.
Note that EmptyPlayer defines its team type as EmptyTeam and vice versa, Note that EmptyPlayer defines its team type as EmptyTeam and vice
so if you want to define your own class for one of them you should do so versa, so if you want to define your own class for one of them you
for both. should do so for both.
""" """

View file

@ -29,10 +29,8 @@ TeamT = TypeVar('TeamT', bound='bascenev1.Team')
class TeamGameActivity(GameActivity[PlayerT, TeamT]): class TeamGameActivity(GameActivity[PlayerT, TeamT]):
"""Base class for teams and free-for-all mode games. """Base class for teams and free-for-all mode games.
Category: **Gameplay Classes** (Free-for-all is essentially just a special case where every player
has their own team)
(Free-for-all is essentially just a special case where every
bascenev1.Player has their own bascenev1.Team)
""" """
@override @override
@ -40,11 +38,7 @@ class TeamGameActivity(GameActivity[PlayerT, TeamT]):
def supports_session_type( def supports_session_type(
cls, sessiontype: type[bascenev1.Session] cls, sessiontype: type[bascenev1.Session]
) -> bool: ) -> bool:
""" # By default, team games support dual-teams and ffa.
Class method override;
returns True for ba.DualTeamSessions and ba.FreeForAllSessions;
False otherwise.
"""
return issubclass(sessiontype, DualTeamSession) or issubclass( return issubclass(sessiontype, DualTeamSession) or issubclass(
sessiontype, FreeForAllSession sessiontype, FreeForAllSession
) )
@ -52,9 +46,9 @@ class TeamGameActivity(GameActivity[PlayerT, TeamT]):
def __init__(self, settings: dict): def __init__(self, settings: dict):
super().__init__(settings) super().__init__(settings)
# By default we don't show kill-points in free-for-all sessions. # By default we don't show kill-points in free-for-all sessions
# (there's usually some activity-specific score and we don't # (there's usually some activity-specific score and we don't
# wanna confuse things) # wanna confuse things).
if isinstance(self.session, FreeForAllSession): if isinstance(self.session, FreeForAllSession):
self.show_kill_points = False self.show_kill_points = False
@ -66,9 +60,9 @@ class TeamGameActivity(GameActivity[PlayerT, TeamT]):
super().on_transition_in() super().on_transition_in()
# On the first game, show the controls UI momentarily. # On the first game, show the controls UI momentarily (unless
# (unless we're being run in co-op mode, in which case we leave # we're being run in co-op mode, in which case we leave it up to
# it up to them) # them).
if not isinstance(self.session, CoopSession) and getattr( if not isinstance(self.session, CoopSession) and getattr(
self, 'show_controls_guide', True self, 'show_controls_guide', True
): ):
@ -114,12 +108,13 @@ class TeamGameActivity(GameActivity[PlayerT, TeamT]):
position: Sequence[float] | None = None, position: Sequence[float] | None = None,
angle: float | None = None, angle: float | None = None,
) -> PlayerSpaz: ) -> PlayerSpaz:
""" """Override to spawn and wire up a standard
Method override; spawns and wires up a standard bascenev1.PlayerSpaz :class:`~bascenev1lib.actor.playerspaz.PlayerSpaz` for a
for a bascenev1.Player. :class:`~bascenev1.Player`.
If position or angle is not supplied, a default will be chosen based If position or angle is not supplied, a default will be chosen
on the bascenev1.Player and their bascenev1.Team. based on the :class:`~bascenev1.Player` and their
:class:`~bascenev1.Team`.
""" """
if position is None: if position is None:
# In teams-mode get our team-start-location. # In teams-mode get our team-start-location.

View file

@ -17,115 +17,115 @@ from bascenev1lib.gameutils import SharedObjects
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Sequence, Callable from typing import Any, Sequence, Callable
import bascenev1
PlayerT = TypeVar('PlayerT', bound='bs.Player') PlayerT = TypeVar('PlayerT', bound='bs.Player')
class BombFactory: class BombFactory:
"""Wraps up media and other resources used by bs.Bombs. """Wraps up media and other resources used by bs.Bombs.
Category: **Gameplay Classes**
A single instance of this is shared between all bombs A single instance of this is shared between all bombs
and can be retrieved via bascenev1lib.actor.bomb.get_factory(). and can be retrieved via bascenev1lib.actor.bomb.get_factory().
""" """
bomb_mesh: bs.Mesh bomb_mesh: bascenev1.Mesh
"""The bs.Mesh of a standard or ice bomb.""" """The mesh used for standard or ice bombs."""
sticky_bomb_mesh: bs.Mesh sticky_bomb_mesh: bascenev1.Mesh
"""The bs.Mesh of a sticky-bomb.""" """The mesh used for sticky-bombs."""
impact_bomb_mesh: bs.Mesh impact_bomb_mesh: bascenev1.Mesh
"""The bs.Mesh of an impact-bomb.""" """The mesh used for impact-bombs."""
land_mine_mesh: bs.Mesh land_mine_mesh: bascenev1.Mesh
"""The bs.Mesh of a land-mine.""" """The mesh used for land-mines."""
tnt_mesh: bs.Mesh tnt_mesh: bascenev1.Mesh
"""The bs.Mesh of a tnt box.""" """The mesh used of a tnt box."""
regular_tex: bs.Texture regular_tex: bascenev1.Texture
"""The bs.Texture for regular bombs.""" """The texture used for regular bombs."""
ice_tex: bs.Texture ice_tex: bascenev1.Texture
"""The bs.Texture for ice bombs.""" """The bs.Texture for ice bombs."""
sticky_tex: bs.Texture sticky_tex: bascenev1.Texture
"""The bs.Texture for sticky bombs.""" """The bs.Texture for sticky bombs."""
impact_tex: bs.Texture impact_tex: bascenev1.Texture
"""The bs.Texture for impact bombs.""" """The bs.Texture for impact bombs."""
impact_lit_tex: bs.Texture impact_lit_tex: bascenev1.Texture
"""The bs.Texture for impact bombs with lights lit.""" """The bs.Texture for impact bombs with lights lit."""
land_mine_tex: bs.Texture land_mine_tex: bascenev1.Texture
"""The bs.Texture for land-mines.""" """The bs.Texture for land-mines."""
land_mine_lit_tex: bs.Texture land_mine_lit_tex: bascenev1.Texture
"""The bs.Texture for land-mines with the light lit.""" """The bs.Texture for land-mines with the light lit."""
tnt_tex: bs.Texture tnt_tex: bascenev1.Texture
"""The bs.Texture for tnt boxes.""" """The bs.Texture for tnt boxes."""
hiss_sound: bs.Sound hiss_sound: bascenev1.Sound
"""The bs.Sound for the hiss sound an ice bomb makes.""" """The sound for the hiss sound an ice bomb makes."""
debris_fall_sound: bs.Sound debris_fall_sound: bascenev1.Sound
"""The bs.Sound for random falling debris after an explosion.""" """The sound for random falling debris after an explosion."""
wood_debris_fall_sound: bs.Sound wood_debris_fall_sound: bascenev1.Sound
"""A bs.Sound for random wood debris falling after an explosion.""" """A sound for random wood debris falling after an explosion."""
explode_sounds: Sequence[bs.Sound] explode_sounds: Sequence[bascenev1.Sound]
"""A tuple of bs.Sound-s for explosions.""" """A tuple of sounds for explosions."""
freeze_sound: bs.Sound freeze_sound: bascenev1.Sound
"""A bs.Sound of an ice bomb freezing something.""" """A sound of an ice bomb freezing something."""
fuse_sound: bs.Sound fuse_sound: bascenev1.Sound
"""A bs.Sound of a burning fuse.""" """A sound of a burning fuse."""
activate_sound: bs.Sound activate_sound: bascenev1.Sound
"""A bs.Sound for an activating impact bomb.""" """A sound for an activating impact bomb."""
warn_sound: bs.Sound warn_sound: bascenev1.Sound
"""A bs.Sound for an impact bomb about to explode due to time-out.""" """A sound for an impact bomb about to explode due to time-out."""
bomb_material: bs.Material bomb_material: bascenev1.Material
"""A bs.Material applied to all bombs.""" """A bs.Material applied to all bombs."""
normal_sound_material: bs.Material normal_sound_material: bascenev1.Material
"""A bs.Material that generates standard bomb noises on impacts, etc.""" """A bs.Material that generates standard bomb noises on impacts, etc."""
sticky_material: bs.Material sticky_material: bascenev1.Material
"""A bs.Material that makes 'splat' sounds and makes collisions softer.""" """A bs.Material that makes 'splat' sounds and makes collisions softer."""
land_mine_no_explode_material: bs.Material land_mine_no_explode_material: bascenev1.Material
"""A bs.Material that keeps land-mines from blowing up. """A bs.Material that keeps land-mines from blowing up.
Applied to land-mines when they are created to allow land-mines to Applied to land-mines when they are created to allow land-mines to
touch without exploding.""" touch without exploding."""
land_mine_blast_material: bs.Material land_mine_blast_material: bascenev1.Material
"""A bs.Material applied to activated land-mines that causes them to """A bs.Material applied to activated land-mines that causes them to
explode on impact.""" explode on impact."""
impact_blast_material: bs.Material impact_blast_material: bascenev1.Material
"""A bs.Material applied to activated impact-bombs that causes them to """A bs.Material applied to activated impact-bombs that causes them to
explode on impact.""" explode on impact."""
blast_material: bs.Material blast_material: bascenev1.Material
"""A bs.Material applied to bomb blast geometry which triggers impact """A bs.Material applied to bomb blast geometry which triggers impact
events with what it touches.""" events with what it touches."""
dink_sounds: Sequence[bs.Sound] dink_sounds: Sequence[bascenev1.Sound]
"""A tuple of bs.Sound-s for when bombs hit the ground.""" """A tuple of sounds for when bombs hit the ground."""
sticky_impact_sound: bs.Sound sticky_impact_sound: bascenev1.Sound
"""The bs.Sound for a squish made by a sticky bomb hitting something.""" """The sound for a squish made by a sticky bomb hitting something."""
roll_sound: bs.Sound roll_sound: bascenev1.Sound
"""bs.Sound for a rolling bomb.""" """The sound for a rolling bomb."""
_STORENAME = bs.storagename() _STORENAME = bs.storagename()
@ -140,8 +140,8 @@ class BombFactory:
assert isinstance(factory, BombFactory) assert isinstance(factory, BombFactory)
return factory return factory
def random_explode_sound(self) -> bs.Sound: def random_explode_sound(self) -> bascenev1.Sound:
"""Return a random explosion bs.Sound from the factory.""" """Return a random explosion sound from the factory."""
return self.explode_sounds[random.randrange(len(self.explode_sounds))] return self.explode_sounds[random.randrange(len(self.explode_sounds))]
def __init__(self) -> None: def __init__(self) -> None:

View file

@ -16,38 +16,31 @@ if TYPE_CHECKING:
class FlagFactory: class FlagFactory:
"""Wraps up media and other resources used by `Flag`s. """Wraps up media and resources used by :class:`Flag`.
Category: **Gameplay Classes** A single instance of this is shared between all flags and can be
retrieved via :meth:`FlagFactory.get()`.
A single instance of this is shared between all flags
and can be retrieved via FlagFactory.get().
""" """
#: The material applied to all flags.
flagmaterial: bs.Material flagmaterial: bs.Material
"""The bs.Material applied to all `Flag`s."""
#: The sound used when a flag hits the ground.
impact_sound: bs.Sound impact_sound: bs.Sound
"""The bs.Sound used when a `Flag` hits the ground."""
#: The sound used when a flag skids along the ground.
skid_sound: bs.Sound skid_sound: bs.Sound
"""The bs.Sound used when a `Flag` skids along the ground."""
#: A material that prevents contact with most objects.
#: This gets applied to 'non-touchable' flags.
no_hit_material: bs.Material no_hit_material: bs.Material
"""A bs.Material that prevents contact with most objects;
applied to 'non-touchable' flags."""
flag_texture: bs.Texture flag_texture: bs.Texture
"""The bs.Texture for flags.""" """The texture for flags."""
_STORENAME = bs.storagename() _STORENAME = bs.storagename()
def __init__(self) -> None: def __init__(self) -> None:
"""Instantiate a `FlagFactory`.
You shouldn't need to do this; call FlagFactory.get() to
get a shared instance.
"""
shared = SharedObjects.get() shared = SharedObjects.get()
self.flagmaterial = bs.Material() self.flagmaterial = bs.Material()
self.flagmaterial.add_actions( self.flagmaterial.add_actions(
@ -110,7 +103,7 @@ class FlagFactory:
@classmethod @classmethod
def get(cls) -> FlagFactory: def get(cls) -> FlagFactory:
"""Get/create a shared `FlagFactory` instance.""" """Get/create a shared flag-factory instance."""
activity = bs.getactivity() activity = bs.getactivity()
factory = activity.customdata.get(cls._STORENAME) factory = activity.customdata.get(cls._STORENAME)
if factory is None: if factory is None:
@ -122,51 +115,40 @@ class FlagFactory:
@dataclass @dataclass
class FlagPickedUpMessage: class FlagPickedUpMessage:
"""A message saying a `Flag` has been picked up. """A message saying a flag has been picked up."""
Category: **Message Classes**
"""
#: The flag that has been picked up.
flag: Flag flag: Flag
"""The `Flag` that has been picked up."""
#: The bs.Node doing the picking up.
node: bs.Node node: bs.Node
"""The bs.Node doing the picking up."""
@dataclass @dataclass
class FlagDiedMessage: class FlagDiedMessage:
"""A message saying a `Flag` has died. """A message saying a `Flag` has died."""
Category: **Message Classes**
"""
#: The flag that died.
flag: Flag flag: Flag
"""The `Flag` that died."""
#: Whether the flag killed itself.
self_kill: bool = False self_kill: bool = False
"""If the `Flag` killed itself or not."""
@dataclass @dataclass
class FlagDroppedMessage: class FlagDroppedMessage:
"""A message saying a `Flag` has been dropped. """A message saying a `Flag` has been dropped."""
Category: **Message Classes**
"""
#: The flag that was dropped.
flag: Flag flag: Flag
"""The `Flag` that was dropped."""
#: The node that was holding the flag.
node: bs.Node node: bs.Node
"""The bs.Node that was holding it."""
class Flag(bs.Actor): class Flag(bs.Actor):
"""A flag; used in games such as capture-the-flag or king-of-the-hill. """A flag; used in games such as capture-the-flag or king-of-the-hill.
Category: **Gameplay Classes**
Can be stationary or carry-able by players. Can be stationary or carry-able by players.
""" """
@ -382,7 +364,7 @@ class Flag(bs.Actor):
@staticmethod @staticmethod
def project_stand(pos: Sequence[float]) -> None: def project_stand(pos: Sequence[float]) -> None:
"""Project a flag-stand onto the ground at the given position. """Project a flag-stand onto the ground from a position.
Useful for games such as capture-the-flag to show where a Useful for games such as capture-the-flag to show where a
movable flag originated from. movable flag originated from.

View file

@ -15,8 +15,6 @@ if TYPE_CHECKING:
class OnScreenCountdown(bs.Actor): class OnScreenCountdown(bs.Actor):
"""A Handy On-Screen Timer. """A Handy On-Screen Timer.
category: Gameplay Classes
Useful for time-based games that count down to zero. Useful for time-based games that count down to zero.
""" """

View file

@ -15,8 +15,6 @@ if TYPE_CHECKING:
class OnScreenTimer(bs.Actor): class OnScreenTimer(bs.Actor):
"""A handy on-screen timer. """A handy on-screen timer.
category: Gameplay Classes
Useful for time-based games where time increases. Useful for time-based games where time increases.
""" """

View file

@ -17,10 +17,7 @@ PlayerT = TypeVar('PlayerT', bound=bs.Player)
class PlayerSpazHurtMessage: class PlayerSpazHurtMessage:
"""A message saying a PlayerSpaz was hurt. """A message saying a PlayerSpaz was hurt."""
Category: **Message Classes**
"""
spaz: PlayerSpaz spaz: PlayerSpaz
"""The PlayerSpaz that was hurt""" """The PlayerSpaz that was hurt"""
@ -33,8 +30,6 @@ class PlayerSpazHurtMessage:
class PlayerSpaz(Spaz): class PlayerSpaz(Spaz):
"""A Spaz subclass meant to be controlled by a bascenev1.Player. """A Spaz subclass meant to be controlled by a bascenev1.Player.
Category: **Gameplay Classes**
When a PlayerSpaz dies, it delivers a bascenev1.PlayerDiedMessage When a PlayerSpaz dies, it delivers a bascenev1.PlayerDiedMessage
to the current bascenev1.Activity. (unless the death was the result to the current bascenev1.Activity. (unless the death was the result
of the player leaving the game, in which case no message is sent) of the player leaving the game, in which case no message is sent)

View file

@ -24,8 +24,6 @@ class _TouchedMessage:
class PowerupBoxFactory: class PowerupBoxFactory:
"""A collection of media and other resources used by bs.Powerups. """A collection of media and other resources used by bs.Powerups.
Category: **Gameplay Classes**
A single instance of this is shared between all powerups A single instance of this is shared between all powerups
and can be retrieved via bs.Powerup.get_factory(). and can be retrieved via bs.Powerup.get_factory().
""" """
@ -190,19 +188,18 @@ class PowerupBoxFactory:
class PowerupBox(bs.Actor): class PowerupBox(bs.Actor):
"""A box that grants a powerup. """A box that grants a powerup.
category: Gameplay Classes This will deliver a :class:`~bascenev1.PowerupMessage` to anything
that touches it which has the
This will deliver a bs.PowerupMessage to anything that touches it :class:`~PowerupBoxFactory.powerup_accept_material` applied.
which has the bs.PowerupBoxFactory.powerup_accept_material applied.
""" """
#: The string powerup type. This can be 'triple_bombs', 'punch',
#: 'ice_bombs', 'impact_bombs', 'land_mines', 'sticky_bombs',
#: 'shield', 'health', or 'curse'.
poweruptype: str poweruptype: str
"""The string powerup type. This can be 'triple_bombs', 'punch',
'ice_bombs', 'impact_bombs', 'land_mines', 'sticky_bombs', 'shield',
'health', or 'curse'."""
node: bs.Node node: bs.Node
"""The 'prop' bs.Node representing this box.""" """The 'prop' node representing this box."""
def __init__( def __init__(
self, self,

View file

@ -12,9 +12,7 @@ import bascenev1 as bs
class RespawnIcon: class RespawnIcon:
"""An icon with a countdown that appears alongside the screen. """An icon with a countdown that appears alongside the screen.
category: Gameplay Classes This is used to indicate that a player is waiting to respawn.
This is used to indicate that a bascenev1.Player is waiting to respawn.
""" """
_MASKTEXSTORENAME = bs.storagename('masktex') _MASKTEXSTORENAME = bs.storagename('masktex')

View file

@ -16,17 +16,12 @@ if TYPE_CHECKING:
class Spawner: class Spawner:
"""Utility for delayed spawning of objects. """Utility for delayed spawning of objects.
Category: **Gameplay Classes** Creates a light flash and sends a Spawner.SpawnMessage to the
current activity after a delay.
Creates a light flash and sends a Spawner.SpawnMessage
to the current activity after a delay.
""" """
class SpawnMessage: class SpawnMessage:
"""Spawn message sent by a Spawner after its delay has passed. """Spawn message sent by a Spawner after its delay has passed."""
Category: **Message Classes**
"""
spawner: Spawner spawner: Spawner
"""The bascenev1.Spawner we came from.""" """The bascenev1.Spawner we came from."""

View file

@ -46,8 +46,6 @@ class Spaz(bs.Actor):
""" """
Base class for various Spazzes. Base class for various Spazzes.
Category: **Gameplay Classes**
A Spaz is the standard little humanoid character in the game. A Spaz is the standard little humanoid character in the game.
It can be controlled by a player or by AI, and can have It can be controlled by a player or by AI, and can have
various different appearances. The name 'Spaz' is not to be various different appearances. The name 'Spaz' is not to be
@ -886,7 +884,9 @@ class Spaz(bs.Actor):
if not self.frozen: if not self.frozen:
self.frozen = True self.frozen = True
self.node.frozen = True self.node.frozen = True
bs.timer(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage())) bs.timer(
msg.time, bs.WeakCall(self.handlemessage, bs.ThawMessage())
)
# Instantly shatter if we're already dead. # Instantly shatter if we're already dead.
# (otherwise its hard to tell we're dead). # (otherwise its hard to tell we're dead).
if self.hitpoints <= 0: if self.hitpoints <= 0:

View file

@ -26,10 +26,7 @@ PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
class SpazBotPunchedMessage: class SpazBotPunchedMessage:
"""A message saying a bs.SpazBot got punched. """A message saying a bs.SpazBot got punched."""
Category: **Message Classes**
"""
spazbot: SpazBot spazbot: SpazBot
"""The bs.SpazBot that got punched.""" """The bs.SpazBot that got punched."""
@ -44,10 +41,7 @@ class SpazBotPunchedMessage:
class SpazBotDiedMessage: class SpazBotDiedMessage:
"""A message saying a bs.SpazBot has died. """A message saying a bs.SpazBot has died."""
Category: **Message Classes**
"""
spazbot: SpazBot spazbot: SpazBot
"""The SpazBot that was killed.""" """The SpazBot that was killed."""
@ -73,8 +67,6 @@ class SpazBotDiedMessage:
class SpazBot(Spaz): class SpazBot(Spaz):
"""A really dumb AI version of bs.Spaz. """A really dumb AI version of bs.Spaz.
Category: **Bot Classes**
Add these to a bs.BotSet to use them. Add these to a bs.BotSet to use them.
Note: currently the AI has no real ability to Note: currently the AI has no real ability to

View file

@ -16,8 +16,6 @@ if TYPE_CHECKING:
class SpazFactory: class SpazFactory:
"""Wraps up media and other resources used by bs.Spaz instances. """Wraps up media and other resources used by bs.Spaz instances.
Category: **Gameplay Classes**
Generally one of these is created per bascenev1.Activity and shared Generally one of these is created per bascenev1.Activity and shared
between all spaz instances. Use bs.Spaz.get_factory() to return between all spaz instances. Use bs.Spaz.get_factory() to return
the shared factory for the current activity. the shared factory for the current activity.

View file

@ -17,8 +17,6 @@ if TYPE_CHECKING:
class ZoomText(bs.Actor): class ZoomText(bs.Actor):
"""Big Zooming Text. """Big Zooming Text.
Category: Gameplay Classes
Used for things such as the 'BOB WINS' victory messages. Used for things such as the 'BOB WINS' victory messages.
""" """

View file

@ -29,8 +29,14 @@ class Team(bs.Team[Player]):
"""Our team type for this game.""" """Our team type for this game."""
def __init__(self, base_pos: Sequence[float], flag: Flag) -> None: def __init__(self, base_pos: Sequence[float], flag: Flag) -> None:
#: Where our base is.
self.base_pos = base_pos self.base_pos = base_pos
#: Flag for this team.
self.flag = flag self.flag = flag
#: Current score.
self.score = 0 self.score = 0

View file

@ -15,8 +15,6 @@ if TYPE_CHECKING:
class SharedObjects: class SharedObjects:
"""Various common components for use in games. """Various common components for use in games.
Category: Gameplay Classes
Objects contained here are created on-demand as accessed and shared Objects contained here are created on-demand as accessed and shared
by everything in the current activity. This includes things such as by everything in the current activity. This includes things such as
standard materials. standard materials.

View file

@ -12,8 +12,8 @@ if TYPE_CHECKING:
class TemplateFsAppSubsystem: class TemplateFsAppSubsystem:
"""Subsystem for TemplateFs functionality in the app. """Subsystem for TemplateFs functionality in the app.
The single shared instance of this class can be accessed at If :attr:`~batools.featureset.FeatureSet.has_python_app_subsystem`
ba*.app.templatefs. Note that it is possible for ba*.app.templatefs is enabled for our feature-set, the single shared instance of this
to be None if the TemplateFs feature-set is not enabled, and code class can be accessed as `template_fs` on the :class:`~babase.App`
should handle that case gracefully. instance.
""" """

View file

@ -21,6 +21,7 @@ from babase import (
add_clean_frame_callback, add_clean_frame_callback,
allows_ticket_sales, allows_ticket_sales,
app, app,
App,
AppIntent, AppIntent,
AppIntentDefault, AppIntentDefault,
AppIntentExec, AppIntentExec,
@ -28,6 +29,7 @@ from babase import (
appname, appname,
appnameupper, appnameupper,
apptime, apptime,
AppState,
AppTime, AppTime,
apptimer, apptimer,
AppTimer, AppTimer,
@ -137,6 +139,7 @@ from bauiv1._uitypes import (
BasicMainWindowState, BasicMainWindowState,
uicleanupcheck, uicleanupcheck,
MainWindow, MainWindow,
RootUIUpdatePause,
) )
from bauiv1._appsubsystem import UIV1AppSubsystem from bauiv1._appsubsystem import UIV1AppSubsystem
@ -144,6 +147,7 @@ __all__ = [
'add_clean_frame_callback', 'add_clean_frame_callback',
'allows_ticket_sales', 'allows_ticket_sales',
'app', 'app',
'App',
'AppIntent', 'AppIntent',
'AppIntentDefault', 'AppIntentDefault',
'AppIntentExec', 'AppIntentExec',
@ -151,6 +155,7 @@ __all__ = [
'appname', 'appname',
'appnameupper', 'appnameupper',
'appnameupper', 'appnameupper',
'AppState',
'apptime', 'apptime',
'AppTime', 'AppTime',
'apptimer', 'apptimer',
@ -229,6 +234,7 @@ __all__ = [
'request_permission', 'request_permission',
'root_ui_pause_updates', 'root_ui_pause_updates',
'root_ui_resume_updates', 'root_ui_resume_updates',
'RootUIUpdatePause',
'rowwidget', 'rowwidget',
'safecolor', 'safecolor',
'screenmessage', 'screenmessage',

View file

@ -32,8 +32,6 @@ if TYPE_CHECKING:
class UIV1AppSubsystem(babase.AppSubsystem): class UIV1AppSubsystem(babase.AppSubsystem):
"""Consolidated UI functionality for the app. """Consolidated UI functionality for the app.
Category: **App Classes**
To use this class, access the single instance of it at 'ba.app.ui'. To use this class, access the single instance of it at 'ba.app.ui'.
""" """
@ -86,9 +84,12 @@ class UIV1AppSubsystem(babase.AppSubsystem):
self.heading_color = (0.72, 0.7, 0.75) self.heading_color = (0.72, 0.7, 0.75)
self.infotextcolor = (0.7, 0.9, 0.7) self.infotextcolor = (0.7, 0.9, 0.7)
self._last_win_recreate_size: tuple[float, float] | None = None self.window_auto_recreate_suppress_count = 0
self._last_screen_size_win_recreate_time: float | None = None
self._screen_size_win_recreate_timer: babase.AppTimer | None = None self._last_win_recreate_screen_size: tuple[float, float] | None = None
self._last_win_recreate_uiscale: bauiv1.UIScale | None = None
self._last_win_recreate_time: float | None = None
self._win_recreate_timer: babase.AppTimer | None = None
# Elements in our root UI will call anything here when # Elements in our root UI will call anything here when
# activated. # activated.
@ -196,6 +197,15 @@ class UIV1AppSubsystem(babase.AppSubsystem):
# pylint: disable=too-many-statements # pylint: disable=too-many-statements
from bauiv1._uitypes import MainWindow from bauiv1._uitypes import MainWindow
# If we haven't grabbed initial uiscale or screen size for recreate
# comparision purposes, this is a good time to do so.
if self._last_win_recreate_screen_size is None:
self._last_win_recreate_screen_size = (
babase.get_virtual_screen_size()
)
if self._last_win_recreate_uiscale is None:
self._last_win_recreate_uiscale = babase.app.ui_v1.uiscale
# Encourage migration to the new higher level nav calls. # Encourage migration to the new higher level nav calls.
if not suppress_warning: if not suppress_warning:
warnings.warn( warnings.warn(
@ -398,6 +408,23 @@ class UIV1AppSubsystem(babase.AppSubsystem):
suppress_warning=True, suppress_warning=True,
) )
def should_suppress_window_recreates(self) -> bool:
"""Should we avoid auto-recreating windows at the current time?"""
# This is slightly hack-ish and ideally we can get to the point
# where we never need this and can remove it.
# Currently string-edits grab a weak-ref to the exact text
# widget they're targeting. So we need to suppress recreates
# while edits are in progress. Ideally we should change that to
# use ids or something that would survive a recreate.
if babase.app.stringedit.active_adapter() is not None:
return True
# Suppress if anything else is requesting suppression (such as
# generic Windows that don't handle being recreated).
return babase.app.ui_v1.window_auto_recreate_suppress_count > 0
@override @override
def on_ui_scale_change(self) -> None: def on_ui_scale_change(self) -> None:
# Update our stored UIScale. # Update our stored UIScale.
@ -406,64 +433,82 @@ class UIV1AppSubsystem(babase.AppSubsystem):
# Update native bits (allow root widget to rebuild itself/etc.) # Update native bits (allow root widget to rebuild itself/etc.)
_bauiv1.on_ui_scale_change() _bauiv1.on_ui_scale_change()
# Lastly, if we have a main window, recreate it to pick up the self._schedule_main_win_recreate()
# new UIScale/etc.
mainwindow = self.get_main_window()
if mainwindow is not None:
winstate = self.save_main_window_state(mainwindow)
self.clear_main_window(transition='instant')
self.restore_main_window_state(winstate)
# Store the size we created this for to avoid redundant
# future recreates.
self._last_win_recreate_size = babase.get_virtual_screen_size()
@override @override
def on_screen_size_change(self) -> None: def on_screen_size_change(self) -> None:
# Recreating a MainWindow is a kinda heavy thing and it doesn't self._schedule_main_win_recreate()
# seem like we should be doing it at 120hz during a live window
# resize, so let's limit the max rate we do it.
now = time.monotonic()
# 4 refreshes per second seems reasonable. def _schedule_main_win_recreate(self) -> None:
interval = 0.25
# If there is a timer set already, do nothing. # If there is a timer set already, do nothing.
if self._screen_size_win_recreate_timer is not None: if self._win_recreate_timer is not None:
return return
# Recreating a MainWindow is a kinda heavy thing and it doesn't
# seem like we should be doing it at 120hz during a live window
# resize, so let's limit the max rate we do it. We also use the
# same mechanism to defer window recreates while anything is
# suppressing them.
now = time.monotonic()
# Up to 4 refreshes per second seems reasonable.
interval = 0.25
# Ok; there's no timer. Schedule one. # Ok; there's no timer. Schedule one.
till_update = ( till_update = (
0.0 interval
if self._last_screen_size_win_recreate_time is None if self.should_suppress_window_recreates()
else max( else (
0.0, self._last_screen_size_win_recreate_time + interval - now 0.0
if self._last_win_recreate_time is None
else max(0.0, self._last_win_recreate_time + interval - now)
) )
) )
self._screen_size_win_recreate_timer = babase.AppTimer( self._win_recreate_timer = babase.AppTimer(
till_update, self._do_screen_size_win_recreate till_update, self._do_main_win_recreate
) )
def _do_screen_size_win_recreate(self) -> None: def _do_main_win_recreate(self) -> None:
self._last_screen_size_win_recreate_time = time.monotonic() self._last_win_recreate_time = time.monotonic()
self._screen_size_win_recreate_timer = None self._win_recreate_timer = None
# Avoid recreating if we're already at this size. This prevents # If win-recreates are currently suppressed, just kick off
# a redundant recreate when ui scale changes. # another timer. We'll do our actual thing once suppression
virtual_screen_size = babase.get_virtual_screen_size() # finally ends.
if virtual_screen_size == self._last_win_recreate_size: if self.should_suppress_window_recreates():
self._schedule_main_win_recreate()
return return
mainwindow = self.get_main_window() mainwindow = self.get_main_window()
if (
mainwindow is not None
and mainwindow.refreshes_on_screen_size_changes
):
winstate = self.save_main_window_state(mainwindow)
self.clear_main_window(transition='instant')
self.restore_main_window_state(winstate)
# Store the size we created this for to avoid redundant # Can't recreate what doesn't exist.
# future recreates. if mainwindow is None:
self._last_win_recreate_size = virtual_screen_size return
virtual_screen_size = babase.get_virtual_screen_size()
uiscale = babase.app.ui_v1.uiscale
# These should always get actual values when a main-window is
# assigned so should never still be None here.
assert self._last_win_recreate_uiscale is not None
assert self._last_win_recreate_screen_size is not None
# If uiscale hasn't changed and our screen-size hasn't either
# (or it has but we don't care) then we're done.
if uiscale is self._last_win_recreate_uiscale and (
virtual_screen_size == self._last_win_recreate_screen_size
or not mainwindow.refreshes_on_screen_size_changes
):
return
# Do the recreate.
winstate = self.save_main_window_state(mainwindow)
self.clear_main_window(transition='instant')
self.restore_main_window_state(winstate)
# Store the size we created this for to avoid redundant
# future recreates.
self._last_win_recreate_uiscale = uiscale
self._last_win_recreate_screen_size = virtual_screen_size

View file

@ -13,11 +13,9 @@ if TYPE_CHECKING:
class Keyboard: class Keyboard:
"""Chars definitions for on-screen keyboard. """Chars definitions for on-screen keyboard.
Category: **App Classes** Keyboards are discoverable by the meta-tag system and the user can
select which one they want to use. On-screen keyboard uses chars
Keyboards are discoverable by the meta-tag system from active babase.Keyboard.
and the user can select which one they want to use.
On-screen keyboard uses chars from active babase.Keyboard.
""" """
name: str name: str

View file

@ -27,19 +27,37 @@ DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1'
class Window: class Window:
"""A basic window. """A basic window.
Category: User Interface Classes
Essentially wraps a ContainerWidget with some higher level Essentially wraps a ContainerWidget with some higher level
functionality. functionality.
""" """
def __init__(self, root_widget: bauiv1.Widget, cleanupcheck: bool = True): def __init__(
self,
root_widget: bauiv1.Widget,
cleanupcheck: bool = True,
prevent_main_window_auto_recreate: bool = True,
):
self._root_widget = root_widget self._root_widget = root_widget
# Complain if we outlive our root widget. # By default, the presence of any generic windows prevents the
# app from running its fancy main-window-auto-recreate mechanism
# on screen-resizes and whatnot. This avoids things like
# temporary popup windows getting stuck under auto-re-created
# main-windows.
self._prevent_main_window_auto_recreate = (
prevent_main_window_auto_recreate
)
if prevent_main_window_auto_recreate:
babase.app.ui_v1.window_auto_recreate_suppress_count += 1
# Generally we complain if we outlive our root widget.
if cleanupcheck: if cleanupcheck:
uicleanupcheck(self, root_widget) uicleanupcheck(self, root_widget)
def __del__(self) -> None:
if self._prevent_main_window_auto_recreate:
babase.app.ui_v1.window_auto_recreate_suppress_count -= 1
def get_root_widget(self) -> bauiv1.Widget: def get_root_widget(self) -> bauiv1.Widget:
"""Return the root widget.""" """Return the root widget."""
return self._root_widget return self._root_widget
@ -66,8 +84,8 @@ class MainWindow(Window):
): ):
"""Create a MainWindow given a root widget and transition info. """Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget, Automatically handles in and out transitions on the provided
so there is no need to set transitions when creating it. widget, so there is no need to set transitions when creating it.
""" """
# A back-state supplied by the ui system. # A back-state supplied by the ui system.
self.main_window_back_state: MainWindowState | None = None self.main_window_back_state: MainWindowState | None = None
@ -89,7 +107,11 @@ class MainWindow(Window):
self._main_window_transition = transition self._main_window_transition = transition
self._main_window_origin_widget = origin_widget self._main_window_origin_widget = origin_widget
super().__init__(root_widget, cleanupcheck) super().__init__(
root_widget,
cleanupcheck=cleanupcheck,
prevent_main_window_auto_recreate=False,
)
scale_origin: tuple[float, float] | None scale_origin: tuple[float, float] | None
if origin_widget is not None: if origin_widget is not None:
@ -120,7 +142,7 @@ class MainWindow(Window):
# Note: normally transition of None means instant, but we use # Note: normally transition of None means instant, but we use
# that to mean 'do the default' so we support a special # that to mean 'do the default' so we support a special
# 'instant' string.. # 'instant' string.
if transition == 'instant': if transition == 'instant':
self._root_widget.delete() self._root_widget.delete()
else: else:
@ -304,8 +326,6 @@ class UICleanupCheck:
def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None: def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None:
"""Checks to ensure a widget-owning object gets cleaned up properly. """Checks to ensure a widget-owning object gets cleaned up properly.
Category: User Interface Functions
This adds a check which will print an error message if the provided This adds a check which will print an error message if the provided
object still exists ~5 seconds after the provided bauiv1.Widget dies. object still exists ~5 seconds after the provided bauiv1.Widget dies.
@ -407,3 +427,13 @@ class TextWidgetStringEditAdapter(babase.StringEditAdapter):
def _do_cancel(self) -> None: def _do_cancel(self) -> None:
if self.widget: if self.widget:
_bauiv1.textwidget(edit=self.widget, adapter_finished=True) _bauiv1.textwidget(edit=self.widget, adapter_finished=True)
class RootUIUpdatePause:
"""Pauses updates to the root-ui while in existence."""
def __init__(self) -> None:
_bauiv1.root_ui_pause_updates()
def __del__(self) -> None:
_bauiv1.root_ui_resume_updates()

View file

@ -338,8 +338,8 @@ class AccountSettingsWindow(bui.MainWindow):
deprecated_space = 60 deprecated_space = 60
# Game Center currently has a single UI for everything. # Game Center currently has a single UI for everything.
show_game_service_button = game_center_active show_game_center_button = game_center_active
game_service_button_space = 60.0 game_center_button_space = 60.0
# Phasing this out (for V2 accounts at least). # Phasing this out (for V2 accounts at least).
show_linked_accounts_text = ( show_linked_accounts_text = (
@ -431,8 +431,8 @@ class AccountSettingsWindow(bui.MainWindow):
self._sub_height += sign_in_button_space self._sub_height += sign_in_button_space
if show_device_sign_in_button: if show_device_sign_in_button:
self._sub_height += sign_in_button_space + deprecated_space self._sub_height += sign_in_button_space + deprecated_space
if show_game_service_button: if show_game_center_button:
self._sub_height += game_service_button_space self._sub_height += game_center_button_space
if show_linked_accounts_text: if show_linked_accounts_text:
self._sub_height += linked_accounts_text_space self._sub_height += linked_accounts_text_space
if show_achievements_text: if show_achievements_text:
@ -880,14 +880,14 @@ class AccountSettingsWindow(bui.MainWindow):
bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, left_widget=bbtn)
# the button to go to OS-Specific leaderboards/high-score-lists/etc. # the button to go to OS-Specific leaderboards/high-score-lists/etc.
if show_game_service_button: if show_game_center_button:
button_width = 300 button_width = 300
v -= game_service_button_space * 0.6 v -= game_center_button_space * 1.0
if game_center_active: if game_center_active:
# Note: Apparently Game Center is just called 'Game Center' # Note: Apparently Game Center is just called 'Game Center'
# in all languages. Can revisit if not true. # in all languages. Can revisit if not true.
# https://developer.apple.com/forums/thread/725779 # https://developer.apple.com/forums/thread/725779
game_service_button_label = bui.Lstr( game_center_button_label = bui.Lstr(
value=bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO) value=bui.charstr(bui.SpecialChar.GAME_CENTER_LOGO)
+ 'Game Center' + 'Game Center'
) )
@ -895,15 +895,15 @@ class AccountSettingsWindow(bui.MainWindow):
raise ValueError( raise ValueError(
"unknown account type: '" + str(v1_account_type) + "'" "unknown account type: '" + str(v1_account_type) + "'"
) )
self._game_service_button = btn = bui.buttonwidget( self._game_center_button = btn = bui.buttonwidget(
parent=self._subcontainer, parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v), position=((self._sub_width - button_width) * 0.5, v),
color=(0.55, 0.5, 0.6), color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8), textcolor=(0.75, 0.7, 0.8),
autoselect=True, autoselect=True,
on_activate_call=self._on_game_service_button_press, on_activate_call=self._on_game_center_button_press,
size=(button_width, 50), size=(button_width, 50),
label=game_service_button_label, label=game_center_button_label,
) )
if first_selectable is None: if first_selectable is None:
first_selectable = btn first_selectable = btn
@ -911,9 +911,9 @@ class AccountSettingsWindow(bui.MainWindow):
edit=btn, right_widget=bui.get_special_widget('squad_button') edit=btn, right_widget=bui.get_special_widget('squad_button')
) )
bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, left_widget=bbtn)
v -= game_service_button_space * 0.4 v -= game_center_button_space * 0.4
else: else:
self.game_service_button = None self.game_center_button = None
self._achievements_text: bui.Widget | None self._achievements_text: bui.Widget | None
if show_achievements_text: if show_achievements_text:
@ -1214,7 +1214,7 @@ class AccountSettingsWindow(bui.MainWindow):
) )
self._needs_refresh = False self._needs_refresh = False
def _on_game_service_button_press(self) -> None: def _on_game_center_button_press(self) -> None:
if bui.app.plus is not None: if bui.app.plus is not None:
bui.app.plus.show_game_service_ui() bui.app.plus.show_game_service_ui()
else: else:

View file

@ -41,6 +41,12 @@ class V2ProxySignInWindow(bui.Window):
) )
) )
self._loading_spinner = bui.spinnerwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.5),
size=60,
style='bomb',
)
self._state_text = bui.textwidget( self._state_text = bui.textwidget(
parent=self._root_widget, parent=self._root_widget,
position=(self._width * 0.5, self._height * 0.6), position=(self._width * 0.5, self._height * 0.6),
@ -49,10 +55,11 @@ class V2ProxySignInWindow(bui.Window):
size=(0, 0), size=(0, 0),
scale=1.4, scale=1.4,
maxwidth=0.9 * self._width, maxwidth=0.9 * self._width,
text=bui.Lstr( # text=bui.Lstr(
value='${A}...', # value='${A}...',
subs=[('${A}', bui.Lstr(resource='loadingText'))], # subs=[('${A}', bui.Lstr(resource='loadingText'))],
), # ),
text='',
color=(1, 1, 1), color=(1, 1, 1),
) )
self._sub_state_text = bui.textwidget( self._sub_state_text = bui.textwidget(
@ -141,6 +148,7 @@ class V2ProxySignInWindow(bui.Window):
def _set_error_state(self, error_location: str) -> None: def _set_error_state(self, error_location: str) -> None:
msaddress = self._get_server_address() msaddress = self._get_server_address()
addr = msaddress.removeprefix('https://') addr = msaddress.removeprefix('https://')
bui.spinnerwidget(edit=self._loading_spinner, visible=False)
bui.textwidget( bui.textwidget(
edit=self._state_text, edit=self._state_text,
text=f'Unable to connect to {addr}.', text=f'Unable to connect to {addr}.',
@ -190,6 +198,7 @@ class V2ProxySignInWindow(bui.Window):
self._complete = True self._complete = True
# Clear out stuff we use to show progress/errors. # Clear out stuff we use to show progress/errors.
self._loading_spinner.delete()
self._sub_state_text.delete() self._sub_state_text.delete()
self._sub_state_text2.delete() self._sub_state_text2.delete()

Some files were not shown because too many files have changed in this diff Show more