Merge pull request #112 from imayushsaini/api9

Api9
This commit is contained in:
Ayush Saini 2025-04-12 19:17:26 +05:30 committed by GitHub
commit 6669428403
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 2959 additions and 2196 deletions

View file

@ -1,9 +1,9 @@
# Bombsquad-Ballistica-Modded-Server
Modded server scripts to host ballistica (Bombsquad) server. Running on BS1.7.37
Modded server scripts to host ballistica (BombSquad) server. Running on BS1.7.39 (API 9)
``
Migrated from API 7 TO API 8 , this might be unstable and missing some features. Use API 7 from this tag
Migrated from API 7 TO API 9 , this might be unstable and missing some features. Use API 7 from this tag
``
[API7 ](https://github.com/imayushsaini/Bombsquad-Ballistica-Modded-Server/releases/tag/1.7.26)
@ -90,7 +90,7 @@ Here you can ban players, mute them, or disable their kick votes.
- Allow server owners to join even when server is full by looking owner IP address which was used earlier(don't join by queue).
- Auto kick fake accounts (unsigned/not verified by master server).
- Auto enable/disable public queue when server is full.
- Auto night mode .
- Auto night mode.
- Transparent Kickvote , can see who started kick vote for whom.
- Kickvote msg to chat/screen , can choose to show kickvote start msg either as screen message or chat message.
- Players IP Address and Device UUID tracking and banning.
@ -120,4 +120,4 @@ Here you can ban players, mute them, or disable their kick votes.
- set 2d plane with _ba.set_2d_plane(z) - beta , not works with spaz.fly = true.
- New Splitted Team in game score screen.
- New final score screen , StumbledScoreScreen.
- other small small feature improvement here there find yourself.
- other small small feature improvement here there find yourself.

View file

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

View file

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

View file

@ -11,7 +11,7 @@ from functools import partial
from typing import TYPE_CHECKING, TypeVar, override
from threading import RLock
from efro.threadpool import ThreadPoolExecutorPlus
from efro.threadpool import ThreadPoolExecutorEx
import _babase
from babase._language import LanguageSubsystem
@ -34,7 +34,7 @@ if TYPE_CHECKING:
import babase
from babase import AppIntent, AppMode, AppSubsystem
from babase._apputils import AppHealthMonitor
from babase._apputils import AppHealthSubsystem
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
# This section generated by batools.appmodule; do not edit.
@ -49,112 +49,34 @@ T = TypeVar('T')
class App:
"""A class for high level app functionality and state.
"""High level Ballistica app functionality and state.
Category: **App Classes**
Use babase.app to access the single shared instance of this class.
Note that properties not documented here should be considered internal
and subject to change without warning.
Access the single shared instance of this class via the ``app`` attr
available on various high level modules such as :mod:`babase`,
:mod:`bauiv1`, and :mod:`bascenev1`.
"""
# pylint: disable=too-many-public-methods
# 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
#: Language subsystem.
lang: LanguageSubsystem
health_monitor: AppHealthMonitor
# 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.
#: Subsystem for keeping tabs on app health.
health: AppHealthSubsystem
#: 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
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:
"""(internal)
@ -168,34 +90,47 @@ class App:
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
return
# Wrap our raw app config in our special wrapper and pass it to
# the native layer.
self.config = AppConfig(_babase.get_initial_app_config())
#: Config values for the app.
self.config: AppConfig = AppConfig(_babase.get_initial_app_config())
_babase.set_app_config(self.config)
#: Static environment values for the app.
self.env: babase.Env = _babase.Env()
self.state = self.State.NOT_STARTED
# Default executor which can be used for misc background
# 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 = ThreadPoolExecutorPlus(
#: Current app state.
self.state: AppState = AppState.NOT_STARTED
#: Default executor which can be used for misc background
#: 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',
initializer=self._thread_pool_thread_init,
)
self.meta = MetadataSubsystem()
self.net = NetworkSubsystem()
self.workspaces = WorkspaceSubsystem()
self.components = AppComponentSubsystem()
self.stringedit = StringEditSubsystem()
self.devconsole = DevConsoleSubsystem()
#: Subsystem for wrangling metadata.
self.meta: MetadataSubsystem = MetadataSubsystem()
# This is incremented any time the app is backgrounded or
# foregrounded; can be a simple way to determine if network data
# should be refreshed/etc.
self.fg_state = 0
#: Subsystem for network functionality.
self.net: NetworkSubsystem = NetworkSubsystem()
#: 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._native_bootstrapping_completed = False
@ -235,9 +170,9 @@ class App:
self._subsystem_property_data: dict[str, AppSubsystem | bool] = {}
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__.
"""
@ -267,26 +202,27 @@ class App:
@property
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
async code to run instead of using this directly. That will
handle retaining the task and logging errors automatically.
Only schedule tasks onto asyncio_loop yourself when you intend
to hold on to the returned task and await its results. Releasing
Generally you should call
:meth:`~babase.App.create_async_task()` to schedule async code
to run instead of using this directly. That will handle
retaining the task and logging errors automatically. Only
schedule tasks onto ``asyncio_loop`` yourself when you intend to
hold on to the returned task and await its results. Releasing
the task reference can lead to subtle bugs such as unreported
errors and garbage-collected tasks disappearing before their
work is done.
Note that, at this time, the asyncio loop is encapsulated
and explicitly stepped by the engine's logic thread loop and
thus things like asyncio.get_running_loop() will unintuitively
*not* return this loop from most places in the logic thread;
only from within a task explicitly created in this loop.
Hopefully this situation will be improved in the future with a
unified event loop.
Note that, at this time, the asyncio loop is encapsulated and
explicitly stepped by the engine's logic thread loop and thus
things like :meth:`asyncio.get_running_loop()` will
unintuitively *not* return this loop from most places in the
logic thread; only from within a task explicitly created in this
loop. Hopefully this situation will be improved in the future
with a unified event loop.
"""
assert _babase.in_logic_thread()
assert self._asyncio_loop is not None
@ -295,12 +231,12 @@ class App:
def create_async_task(
self, coro: Coroutine[Any, Any, T], *, name: str | 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
and log any exceptions that occur in it. If you need to await a task
or otherwise need more control, schedule a task directly using
App.asyncio_loop.
:attr:`asyncio_loop`.
"""
assert _babase.in_logic_thread()
@ -453,7 +389,10 @@ class App:
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
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
# reached the 'running' state. This ensures that all subsystems
@ -470,11 +409,12 @@ class App:
"""Add a task to be run on app shutdown.
Note that shutdown tasks will be canceled after
App.SHUTDOWN_TASK_TIMEOUT_SECONDS if they are still running.
:py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still
running.
"""
if (
self.state is self.State.SHUTTING_DOWN
or self.state is self.State.SHUTDOWN_COMPLETE
self.state is AppState.SHUTTING_DOWN
or self.state is AppState.SHUTDOWN_COMPLETE
):
stname = self.state.name
raise RuntimeError(
@ -485,8 +425,8 @@ class App:
def run(self) -> None:
"""Run the app to completion.
Note that this only works on builds where Ballistica manages
its own event loop.
Note that this only works on builds/runs where Ballistica is
managing its own event loop.
"""
_babase.run_app()
@ -495,9 +435,9 @@ class App:
Intent defines what the app is trying to do at a given time.
This call is asynchronous; the intent switch will happen in the
logic thread in the near future. If set_intent is called
repeatedly before the change takes place, the final intent to be
set will be used.
logic thread in the near future. If this is called repeatedly
before the change takes place, the final intent to be set will
be used.
"""
# 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)
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.
# This avoids potential trouble if this gets called mid-draw or
# something like that.
@ -518,47 +461,68 @@ class App:
_babase.pushcall(self._apply_app_config, raw=True)
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 not self._native_start_called
self._native_start_called = True
self._update_state()
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 not self._native_bootstrapping_completed
self._native_bootstrapping_completed = True
self._update_state()
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 not self._native_suspended # Should avoid redundant calls.
self._native_suspended = True
self._update_state()
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 self._native_suspended # Should avoid redundant calls.
self._native_suspended = False
self._update_state()
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()
self._native_shutdown_called = True
self._update_state()
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()
self._native_shutdown_complete_called = True
self._update_state()
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()
if self._mode is not None:
self._mode.on_app_active_changed()
@ -590,6 +554,8 @@ class App:
initial-sign-in process may include tasks such as syncing
account workspaces or other data so it may take a substantial
amount of time.
:meta private:
"""
assert _babase.in_logic_thread()
assert not self._initial_sign_in_completed
@ -604,8 +570,11 @@ class App:
def set_ui_scale(self, scale: babase.UIScale) -> None:
"""Change ui-scale on the fly.
Currently this is mainly for debugging and will not be called as
part of normal app operation.
Currently this is mainly for testing/debugging and will not be
called as part of normal app operation, though this may change
in the future.
:meta private:
"""
assert _babase.in_logic_thread()
@ -625,7 +594,10 @@ class App:
)
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.
# Operate on a copy of the list here because this can be called
@ -768,7 +740,7 @@ class App:
# pylint: disable=cyclic-import
from babase import _asyncio
from babase import _appconfig
from babase._apputils import AppHealthMonitor
from babase._apputils import AppHealthSubsystem
from babase import _env
assert _babase.in_logic_thread()
@ -776,7 +748,7 @@ class App:
_env.on_app_state_initing()
self._asyncio_loop = _asyncio.setup_asyncio()
self.health_monitor = AppHealthMonitor()
self.health = AppHealthSubsystem()
# __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
# 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
# by a plugin or whatnot.
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
# registered. Operate on a copy here because subsystems can
@ -913,8 +885,8 @@ class App:
# Shutdown-complete trumps absolutely all.
if self._native_shutdown_complete_called:
if self.state is not self.State.SHUTDOWN_COMPLETE:
self.state = self.State.SHUTDOWN_COMPLETE
if self.state is not AppState.SHUTDOWN_COMPLETE:
self.state = AppState.SHUTDOWN_COMPLETE
lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutdown_complete()
@ -923,26 +895,26 @@ class App:
# the shutdown process.
elif self._native_shutdown_called and self._init_completed:
# Entering shutdown state:
if self.state is not self.State.SHUTTING_DOWN:
self.state = self.State.SHUTTING_DOWN
if self.state is not AppState.SHUTTING_DOWN:
self.state = AppState.SHUTTING_DOWN
applog.info('Shutting down...')
lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutting_down()
elif self._native_suspended:
# Entering suspended state:
if self.state is not self.State.SUSPENDED:
self.state = self.State.SUSPENDED
if self.state is not AppState.SUSPENDED:
self.state = AppState.SUSPENDED
self._on_suspend()
else:
# Leaving suspended state:
if self.state is self.State.SUSPENDED:
if self.state is AppState.SUSPENDED:
self._on_unsuspend()
# Entering or returning to running state
if self._initial_sign_in_completed and self._meta_scan_completed:
if self.state != self.State.RUNNING:
self.state = self.State.RUNNING
if self.state != AppState.RUNNING:
self.state = AppState.RUNNING
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_running:
self._called_on_running = True
@ -950,8 +922,8 @@ class App:
# Entering or returning to loading state:
elif self._init_completed:
if self.state is not self.State.LOADING:
self.state = self.State.LOADING
if self.state is not AppState.LOADING:
self.state = AppState.LOADING
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_loading:
self._called_on_loading = True
@ -959,8 +931,8 @@ class App:
# Entering or returning to initing state:
elif self._native_bootstrapping_completed:
if self.state is not self.State.INITING:
self.state = self.State.INITING
if self.state is not AppState.INITING:
self.state = AppState.INITING
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_initing:
self._called_on_initing = True
@ -968,8 +940,8 @@ class App:
# Entering or returning to native bootstrapping:
elif self._native_start_called:
if self.state is not self.State.NATIVE_BOOTSTRAPPING:
self.state = self.State.NATIVE_BOOTSTRAPPING
if self.state is not AppState.NATIVE_BOOTSTRAPPING:
self.state = AppState.NATIVE_BOOTSTRAPPING
lifecyclelog.info('app-state is now %s', self.state.name)
else:
# Only logical possibility left is NOT_STARTED, in which
@ -1065,6 +1037,19 @@ class App:
"""(internal)"""
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
# order they were inited.
for subsystem in reversed(self._subsystems):
@ -1124,3 +1109,86 @@ class App:
# Help keep things clear in profiling tools/etc.
self._pool_thread_count += 1
_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:
"""Subsystem for wrangling AppComponents.
Category: **App Classes**
This subsystem acts as a registry for classes providing particular
functionality for the app, and allows plugins or other custom code
to easily override said functionality.

View file

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

View file

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

View file

@ -3,18 +3,19 @@
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, final
if TYPE_CHECKING:
from bacommon.app import AppExperience
from babase._appintent import AppIntent
from babase import AppIntent
class AppMode:
"""A high level mode for the app.
Category: **App Classes**
"""A low level mode the app can be in.
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
@ -22,26 +23,27 @@ class AppMode:
"""Return the overall experience provided by this mode."""
raise NotImplementedError('AppMode subclasses must override this.')
@final
@classmethod
def can_handle_intent(cls, intent: AppIntent) -> bool:
"""Return whether this mode can handle the provided intent.
For this to return True, the AppMode must claim to support the
provided intent (via its _can_handle_intent() method) AND the
AppExperience associated with the AppMode must be supported by
the current app and runtime environment.
For this to return True, the app-mode must claim to support the
provided intent (via its :meth:`can_handle_intent_impl()`
method) *AND* the :class:`~bacommon.app.AppExperience` associated
with the app-mode must be supported by the current app and
runtime environment.
"""
# TODO: check AppExperience against current environment.
return cls._can_handle_intent(intent)
return cls.can_handle_intent_impl(intent)
@classmethod
def _can_handle_intent(cls, intent: AppIntent) -> bool:
"""Return whether our mode can handle the provided intent.
def can_handle_intent_impl(cls, intent: AppIntent) -> bool:
"""Override this to define indent handling for an app-mode.
AppModes should override this to communicate what they can
handle. Note that AppExperience does not have to be considered
here; that is handled automatically by the can_handle_intent()
call.
Note that :class:`~bacommon.app.AppExperience` does not have to
be considered here; that is handled automatically by the
:meth:`can_handle_intent()` call.
"""
raise NotImplementedError('AppMode subclasses must override this.')
@ -50,14 +52,101 @@ class AppMode:
raise NotImplementedError('AppMode subclasses must override this.')
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:
"""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:
"""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
game in such cases.
This corresponds to the app's :attr:`~babase.App.active` attr.
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
if TYPE_CHECKING:
from babase._appintent import AppIntent
from babase._appmode import AppMode
from babase import AppMode, AppIntent
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 AppIntent to
determine which AppMode to use to handle the intent. Plugins or
spinoff projects can modify high level app behavior by replacing or
modifying the app's mode-selector.
The app calls an instance of this class when passed an
:class:`~babase.AppIntent` to determine which
:class:`~babase.AppMode` to use to handle it. Plugins 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 settings used to construct the default one.
"""
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
limited to logic thread use/etc.

View file

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

View file

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

View file

@ -15,10 +15,13 @@ if TYPE_CHECKING:
class DevConsoleTab:
"""Defines behavior for a tab in the dev-console."""
"""Base class for a :class:`~babase.DevConsoleSubsystem` tab."""
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:
"""The tab can call this to request that it be refreshed."""
@ -91,22 +94,23 @@ class DevConsoleTab:
@property
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
return _babase.dev_console_tab_width()
@property
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
return _babase.dev_console_tab_height()
@property
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
positions if they desire. This must be done manually however.
Dev-console tabs can manually incorporate this into their UI
sizes and positions if they desire. By default, dev-console tabs
are uniform across all ui-scales.
"""
assert _babase.app.devconsole.is_refreshing
return _babase.dev_console_base_scale()
@ -114,20 +118,21 @@ class DevConsoleTab:
@dataclass
class DevConsoleTabEntry:
"""Represents a distinct tab in the dev-console."""
"""Represents a distinct tab in the :class:`~babase.DevConsoleSubsystem`."""
name: str
factory: Callable[[], DevConsoleTab]
class DevConsoleSubsystem:
"""Subsystem for wrangling the dev console.
"""Subsystem for wrangling the dev-console.
The single instance of this class can be found at
babase.app.devconsole. The dev-console is a simple always-available
UI intended for use by developers; not end users. Traditionally it
is available by typing a backtick (`) key on a keyboard, but now can
be accessed via an on-screen button (see settings/advanced to enable
Access the single shared instance of this class via the
:attr:`~babase.App.devconsole` attr on the :class:`~babase.App`
class. The dev-console is a simple always-available UI intended for
use by developers; not end users. Traditionally it is available by
typing a backtick (`) key on a keyboard, but can also be accessed
via an on-screen button (see settings/advanced/dev-tools to enable
said button).
"""
@ -141,8 +146,8 @@ class DevConsoleSubsystem:
DevConsoleTabTest,
)
# All tabs in the dev-console. Add your own stuff here via
# plugins or whatnot.
#: All tabs in the dev-console. Add your own stuff here via
#: plugins or whatnot to customize the console.
self.tabs: list[DevConsoleTabEntry] = [
DevConsoleTabEntry('Python', DevConsoleTabPython),
DevConsoleTabEntry('AppModes', DevConsoleTabAppModes),
@ -155,7 +160,10 @@ class DevConsoleSubsystem:
self._tab_instances: dict[str, DevConsoleTab] = {}
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()
# 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
class EmptyAppMode(AppMode):
"""An AppMode that does not do much at all."""
"""An AppMode that does not do much at all.
:meta private:
"""
@override
@classmethod
@ -26,7 +29,7 @@ class EmptyAppMode(AppMode):
@override
@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.
return isinstance(intent, AppIntentExec | AppIntentDefault)

View file

@ -6,183 +6,66 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
from typing import Any
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 game context.
Examples of this include calling UI functions within an activity
context or calling scene manipulation functions outside of a scene
context.
"""
class NotFoundError(Exception):
"""Exception raised when a referenced object does not exist.
Category: **Exception Classes**
"""
"""Raised when a referenced object does not exist."""
class PlayerNotFoundError(NotFoundError):
"""Exception raised when an expected player does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected player does not exist."""
class SessionPlayerNotFoundError(NotFoundError):
"""Exception raised when an expected session-player does not exist.
Category: **Exception Classes**
"""
"""Exception raised when an expected session-player does not exist."""
class TeamNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Team does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected team does not exist."""
class MapNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Map does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected map does not exist."""
class DelegateNotFoundError(NotFoundError):
"""Exception raised when an expected delegate object does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected delegate object does not exist."""
class SessionTeamNotFoundError(NotFoundError):
"""Exception raised when an expected session-team does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected session-team does not exist."""
class NodeNotFoundError(NotFoundError):
"""Exception raised when an expected Node does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected node does not exist."""
class ActorNotFoundError(NotFoundError):
"""Exception raised when an expected actor does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected actor does not exist."""
class ActivityNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Activity does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected activity does not exist."""
class SessionNotFoundError(NotFoundError):
"""Exception raised when an expected session does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected session does not exist."""
class InputDeviceNotFoundError(NotFoundError):
"""Exception raised when an expected input-device does not exist.
Category: **Exception Classes**
"""
"""Raised when an expected input-device does not exist."""
class WidgetNotFoundError(NotFoundError):
"""Exception 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()
"""Raised when an expected widget does not exist."""

View file

@ -34,10 +34,7 @@ DisplayTime = NewType('DisplayTime', float)
class Existable(Protocol):
"""A Protocol for objects supporting an exists() method.
Category: **Protocols**
"""
"""A Protocol for objects supporting an exists() method."""
def exists(self) -> bool:
"""Whether this object exists."""
@ -50,15 +47,13 @@ T = TypeVar('T')
def existing(obj: ExistableT | None) -> ExistableT | None:
"""Convert invalid references to None for any babase.Existable object.
Category: **Gameplay Functions**
To best support type checking, it is important that invalid references
not be passed around and instead get converted to values of None.
That way the type checker can properly flag attempts to pass possibly-dead
objects (FooType | None) into functions expecting only live ones
(FooType), etc. This call can be used on any 'existable' object
(one with an exists() method) and will convert it to a None value
if it does not exist.
To best support type checking, it is important that invalid
references not be passed around and instead get converted to values
of None. That way the type checker can properly flag attempts to
pass possibly-dead objects (``FooType | None``) into functions
expecting only live ones (``FooType``), etc. This call can be used
on any 'existable' object (one with an ``exists()`` method) and will
convert it to a ``None`` value if it does not exist.
For more info, see notes on 'existables' here:
https://ballistica.net/wiki/Coding-Style-Guide
@ -70,12 +65,11 @@ def existing(obj: ExistableT | None) -> ExistableT | None:
def getclass(
name: str, subclassof: type[T], check_sdlib_modulename_clash: bool = False
) -> 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 TypeError will be raised if not.
The class will be checked to make sure it is a subclass of the
provided 'subclassof' class, and a :class:`TypeError` will be raised
if not.
"""
import importlib
@ -92,68 +86,54 @@ def getclass(
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:
"""Return a full type name including module for a class."""
return f'{cls.__module__}.{cls.__name__}'
"""Return a fully qualified type name for a class."""
return f'{cls.__module__}.{cls.__qualname__}'
class _WeakCall:
"""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
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 WeakCall is simply a no-op.
Think of this as a handy way to tell an object to do something at
some point in the future if it happens to still exist.
Think of this as a handy way to tell an object to do something
at some point in the future if it happens to still exist.
**EXAMPLE A:** This code will create a ``FooClass`` instance and
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
**EXAMPLE A:** this code will create a FooClass instance and 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
>>> foo = FooClass()
... babase.apptimer(5.0, foo.bar)
... foo = None
foo = FooClass()
babase.apptimer(5.0, foo.bar)
foo = None
**EXAMPLE B:** This code will *not* keep our object alive; it will die
when we overwrite it with None and the timer will be a no-op when it
fires
>>> foo = FooClass()
... babase.apptimer(5.0, ba.WeakCall(foo.bar))
... foo = None
**EXAMPLE B:** This code will *not* keep our object alive; it will
die when we overwrite it with ``None`` and the timer will be a no-op
when it fires::
**EXAMPLE C:** Wrap a method call with some positional and keyword args:
>>> 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()
foo = FooClass()
babase.apptimer(5.0, ba.WeakCall(foo.bar))
foo = None
Note: additional args and keywords you provide to the WeakCall()
constructor are stored as regular strong-references; you'll need
to wrap them in weakrefs manually if desired.
**EXAMPLE C:** Wrap a method call with some positional and keyword
args::
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.
@ -162,11 +142,6 @@ class _WeakCall:
_did_invalid_call_warning = False
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__'):
self._call = WeakMethod(args[0])
else:
@ -203,37 +178,27 @@ class _WeakCall:
class _Call:
"""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
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
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
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.
__slots__ = ['_call', '_args', '_keywds']
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._args = args[1:]
self._keywds = keywds
@ -309,8 +274,6 @@ class WeakMethod:
def verify_object_death(obj: object) -> None:
"""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.
"""
@ -351,27 +314,27 @@ def _verify_object_death(wref: weakref.ref) -> None:
def storagename(suffix: str | None = None) -> str:
"""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
places such as 'customdata' dicts, this minimizes the chance of
collisions with other similarly named classes.
Note that this will function even if called in the class definition.
##### Examples
Generate a unique name for storage purposes:
>>> class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'.
... _STORENAME = babase.storagename('data')
...
... # Use that name to store some data in the Activity we were
... # passed.
... def __init__(self, activity):
... activity.customdata[self._STORENAME] = {}
Example: Generate a unique name for storage purposes::
class MyThingie:
# This will give something like
# '_mymodule_submodule_mythingie_data'.
_STORENAME = babase.storagename('data')
# Use that name to store some data in the Activity we were
# passed.
def __init__(self, activity):
activity.customdata[self._STORENAME] = {}
"""
frame = inspect.currentframe()
if frame is None:

View file

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

View file

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

View file

@ -14,14 +14,13 @@ if TYPE_CHECKING:
def vec3validate(value: Sequence[float]) -> Sequence[float]:
"""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.
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.
:meta private:
"""
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:
"""Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
:meta private:
"""
return (
(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, ...]:
"""Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
"""
"""Scale a color so its largest value is 1.0; useful for coloring lights."""
color_biased = tuple(max(c, 0.01) for c in color) # account for black
mult = 1.0 / max(color_biased)
return tuple(c * mult for c in color_biased)

View file

@ -48,9 +48,8 @@ class ScanResults:
class MetadataSubsystem:
"""Subsystem for working with script metadata in the app.
Category: **App Classes**
Access the single shared instance of this class at 'babase.app.meta'.
Access the single shared instance of this class via the
:attr:`~babase.App.meta` attr on the :class:`~babase.App` class.
"""
def __init__(self) -> None:
@ -68,8 +67,10 @@ class MetadataSubsystem:
"""Begin the overall scan.
This will start scanning built in directories (which for vanilla
installs should be the vast majority of the work). This should only
be called once.
installs should be the vast majority of the work). This should
only be called once.
:meta private:
"""
assert self._scan_complete_cb 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
workspace sync completion or other such events. This must be
called exactly once.
:meta private:
"""
assert self._scan is not None
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
regardless.
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(
target=partial(

View file

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

View file

@ -42,7 +42,10 @@ class NetworkSubsystem:
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
if version == 4:

View file

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

View file

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

View file

@ -15,18 +15,18 @@ def timestring(
timeval: float | int,
centi: bool = True,
) -> 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 babase.Lstr with:
Given a time value, returns a localized string with:
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
WARNING: the underlying Lstr value is somewhat large so don't use this
to rapidly update Node text values for an onscreen timer or you may
consume significant network bandwidth. For that purpose you should
use a 'timedisplay' Node and attribute connections.
.. warning::
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

View file

@ -26,9 +26,8 @@ if TYPE_CHECKING:
class WorkspaceSubsystem:
"""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:

View file

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

View file

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

View file

@ -17,9 +17,8 @@ if TYPE_CHECKING:
class AccountV1Subsystem:
"""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:
@ -202,7 +201,7 @@ class AccountV1Subsystem:
# If the short version of our account name currently cant be
# displayed by the game, cancel.
if not babase.have_chars(
if not babase.can_display_chars(
plus.get_v1_account_display_string(full=False)
):
return

View file

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

View file

@ -18,8 +18,6 @@ if TYPE_CHECKING:
class AdsSubsystem:
"""Subsystem for ads functionality in the app.
Category: **App Classes**
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 bacommon.app import AppExperience
import bacommon.bs
import babase
import bauiv1
from bauiv1lib.connectivity import wait_for_connectivity
@ -28,6 +29,8 @@ if TYPE_CHECKING:
class ClassicAppMode(babase.AppMode):
"""AppMode for the classic BombSquad experience."""
_LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals'
def __init__(self) -> None:
self._on_primary_account_changed_callback: (
CallbackRegistration | None
@ -40,6 +43,11 @@ class ClassicAppMode(babase.AppMode):
self._have_account_values = 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
@classmethod
@ -48,7 +56,7 @@ class ClassicAppMode(babase.AppMode):
@override
@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.
return isinstance(
intent, babase.AppIntentExec | babase.AppIntentDefault
@ -148,9 +156,15 @@ class ClassicAppMode(babase.AppMode):
classic = babase.app.classic
# Store latest league vis vals for any active account.
self._save_account_display_state()
# Stop being informed of account changes.
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.
self._update_for_primary_account(None)
@ -163,11 +177,151 @@ class ClassicAppMode(babase.AppMode):
@override
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 we've gone inactive, bring up the main menu, which has the
# side effect of pausing the action (when possible).
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(
self, account: babase.AccountV2Handle | None
) -> None:
@ -181,9 +335,18 @@ class ClassicAppMode(babase.AppMode):
assert classic is not None
if account is not None:
self._current_account_id = account.accountid
babase.set_ui_account_state(True, account.tag)
self._should_restore_account_display_state = True
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)
self._should_restore_account_display_state = False
# For testing subscription functionality.
if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
@ -199,27 +362,39 @@ class ClassicAppMode(babase.AppMode):
if account is None:
classic.gold_pass = False
classic.tokens = 0
classic.chest_dock_full = False
classic.remove_ads = False
self._account_data_sub = None
_baclassic.set_root_ui_account_values(
tickets=-1,
tokens=-1,
league_rank=-1,
league_type='',
league_number=-1,
league_rank=-1,
achievements_percent_text='',
level_text='',
xp_text='',
inbox_count_text='',
inbox_count=-1,
inbox_count_is_max=False,
inbox_announce_text='',
gold_pass=False,
chest_0_appearance='',
chest_1_appearance='',
chest_2_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_1_unlock_time=-1.0,
chest_2_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_1_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
# two before values appear (since the subscriptions have not
# 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
)
@ -258,11 +433,10 @@ class ClassicAppMode(babase.AppMode):
def _on_classic_account_data_change(
self, val: bacommon.bs.ClassicAccountLiveData
) -> None:
# print('ACCOUNT CHANGED:', val)
achp = round(val.achievements / max(val.achievements_total, 1) * 100.0)
ibc = str(val.inbox_count)
if val.inbox_count_is_max:
ibc += '+'
# ibc = str(val.inbox_count)
# if val.inbox_count_is_max:
# ibc += '+'
chest0 = val.chests.get('0')
chest1 = val.chests.get('1')
@ -275,6 +449,7 @@ class ClassicAppMode(babase.AppMode):
assert classic is not None
classic.remove_ads = val.remove_ads
classic.gold_pass = val.gold_pass
classic.tokens = val.tokens
classic.chest_dock_full = (
chest0 is not None
and chest1 is not None
@ -285,14 +460,21 @@ class ClassicAppMode(babase.AppMode):
_baclassic.set_root_ui_account_values(
tickets=val.tickets,
tokens=val.tokens,
league_rank=(-1 if val.league_rank is None else val.league_rank),
league_type=(
'' 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}%',
level_text=str(val.level),
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,
chest_0_appearance=(
'' if chest0 is None else chest0.appearance.value
@ -306,6 +488,18 @@ class ClassicAppMode(babase.AppMode):
chest_3_appearance=(
'' 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=(
-1.0 if chest0 is None else chest0.unlock_time.timestamp()
),
@ -318,6 +512,18 @@ class ClassicAppMode(babase.AppMode):
chest_3_unlock_time=(
-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=(
-1.0
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()
),
)
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.
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.
self.remove_ads = False
self.gold_pass = False
self.tokens = 0
self.chest_dock_full = False
# Main Menu.
@ -384,8 +385,6 @@ class ClassicAppSubsystem(babase.AppSubsystem):
def getmaps(self, playtype: str) -> list[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
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:
"""Preload media needed for map preview UIs.
Category: **Asset Functions**
"""
"""Preload media needed for map preview UIs."""
try:
bauiv1.getmesh('level_select_button_opaque')
bauiv1.getmesh('level_select_button_transparent')
@ -802,6 +798,9 @@ class ClassicAppSubsystem(babase.AppSubsystem):
if babase.app.env.gui:
bauiv1.getsound('swish').play()
# Pause gameplay.
self.pause()
babase.app.ui_v1.set_main_window(
InGameMenuWindow(), is_top_level=True, suppress_warning=True
)
@ -854,11 +853,13 @@ class ClassicAppSubsystem(babase.AppSubsystem):
)
@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."""
from baclassic._clienteffect import run_bs_client_effects
run_bs_client_effects(effects)
run_bs_client_effects(effects, delay=delay)
@staticmethod
def basic_client_ui_button_label_str(

View file

@ -12,17 +12,23 @@ from efro.util import strict_partial
import bacommon.bs
import bauiv1
import _baclassic
if TYPE_CHECKING:
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."""
# pylint: disable=too-many-branches
from bacommon.bs import ClientEffectTypeID
delay = 0.0
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(
translate=('serverResponses', effect.message)
).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
soundfile: str | None = None
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
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
# some noise if it happens.
logging.error(
'Got unrecognized bacommon.bs.ClientEffect;'
' 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):
"""Influences behavior when playing music.
Category: **Enums**
"""
"""Influences behavior when playing music."""
REGULAR = 'regular'
TEST = 'test'
@ -31,10 +28,7 @@ class MusicPlayMode(Enum):
@dataclass
class AssetSoundtrackEntry:
"""A music entry using an internal asset.
Category: **App Classes**
"""
"""A music entry using an internal asset."""
assetname: str
volume: float = 1.0
@ -81,8 +75,6 @@ ASSET_SOUNDTRACK_ENTRIES: dict[MusicType, AssetSoundtrackEntry] = {
class MusicSubsystem:
"""Subsystem for music playback in the app.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.music'.
"""
@ -374,8 +366,6 @@ class MusicSubsystem:
class MusicPlayer:
"""Wrangles soundtrack music playback.
Category: **App Classes**
Music can be played either through the game itself
or via a platform-specific external player.
"""

View file

@ -96,7 +96,7 @@ class MasterServerV1CallThread(threading.Thread):
try:
classic = babase.app.classic
assert classic is not None
self._data = babase.utf8_all(self._data)
self._data = _utf8_all(self._data)
babase.set_thread_name('BA_ServerCallThread')
if self._request_type == 'get':
msaddr = plus.get_master_server_address()
@ -164,3 +164,19 @@ class MasterServerV1CallThread(threading.Thread):
babase.Call(self._run_callback, response_data),
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:
"""Overall controller for the app in server mode.
Category: **App Classes**
"""
"""Overall controller for the app in server mode."""
def __init__(self, config: ServerConfig) -> None:
self._config = config
@ -390,7 +387,7 @@ class ServerController:
f' ({app.env.engine_build_number})'
f' entering server-mode {curtimestr}{Clr.RST}'
)
print(startupmsg)
logging.info(startupmsg)
if sessiontype is bascenev1.FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name

View file

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

View file

@ -1,3 +1,6 @@
# 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.
#
"""Common high level values/functionality related to apps."""
"""Common high level values/functionality related to Ballistica apps."""
from __future__ import annotations
@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
from bacommon.locale import Locale
if TYPE_CHECKING:
pass
@ -17,19 +19,41 @@ if TYPE_CHECKING:
class AppInterfaceIdiom(Enum):
"""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).
"""
PHONE = 'phone'
TABLET = 'tablet'
DESKTOP = 'desktop'
#: Small screen; assumed to have touch as primary input.
PHONE = 'phn'
#: 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'
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):
"""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
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
targeting one experience. Cloud components such as leagues are
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
# empty AppMode when starting the app, etc.
EMPTY = 'empty'
#: An experience that is supported everywhere. Used for the default
#: empty AppMode when starting the app, etc.
EMPTY = 'empt'
# The traditional BombSquad experience: multiple players using
# traditional game controllers (or touch screen equivalents) in a
# single arena small enough for all action to be viewed on a single
# screen.
MELEE = 'melee'
#: The traditional BombSquad experience - multiple players using
#: game controllers (or touch screen equivalents) in a single arena
#: small enough for all action to be viewed on a single screen.
MELEE = 'mlee'
# The traditional BombSquad Remote experience; buttons on a
# touch-screen allowing a mobile device to be used as a game
# controller.
REMOTE = 'remote'
#: The traditional BombSquad Remote experience; buttons on a
#: touch-screen allowing a mobile device to be used as a game
#: controller.
REMOTE = 'rmt'
class AppArchitecture(Enum):
"""Processor architecture the App is running on."""
"""Processor architecture an app can be running on."""
ARM = 'arm'
ARM64 = 'arm64'
X86 = 'x86'
X86_64 = 'x86_64'
X86_64 = 'x64'
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
AppPlatform and AppVariant. Generally platform describes a set of
@ -82,9 +106,9 @@ class AppPlatform(Enum):
"""
MAC = 'mac'
WINDOWS = 'windows'
LINUX = 'linux'
ANDROID = 'android'
WINDOWS = 'win'
LINUX = 'lin'
ANDROID = 'andr'
IOS = 'ios'
TVOS = 'tvos'
@ -98,43 +122,58 @@ class AppVariant(Enum):
build.
"""
# Default builds.
GENERIC = 'generic'
#: Default builds.
GENERIC = 'gen'
# Builds intended for public testing (may have some extra checks
# or logging enabled).
TEST = 'test'
#: Builds intended for public testing (may have some extra checks
#: or logging enabled).
TEST = 'tst'
# Various stores.
AMAZON_APPSTORE = 'amazon_appstore'
GOOGLE_PLAY = 'google_play'
APP_STORE = 'app_store'
WINDOWS_STORE = 'windows_store'
STEAM = 'steam'
AMAZON_APPSTORE = 'amzn'
GOOGLE_PLAY = 'gpl'
APPLE_APP_STORE = 'appl'
WINDOWS_STORE = 'wins'
STEAM = 'stm'
META = 'meta'
EPIC_GAMES_STORE = 'epic_games_store'
EPIC_GAMES_STORE = 'epic'
# Other.
ARCADE = 'arcade'
ARCADE = 'arcd'
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
@dataclass
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_build = Annotated[int, IOAttrs('eb')]
engine_version: Annotated[str, IOAttrs('evrs')]
engine_build: Annotated[int, IOAttrs('ebld')]
platform = Annotated[AppPlatform, IOAttrs('p')]
variant = Annotated[AppVariant, IOAttrs('va')]
architecture = Annotated[AppArchitecture, IOAttrs('a')]
os_version = Annotated[str | None, IOAttrs('o')]
platform: Annotated[AppPlatform, IOAttrs('plat')]
variant: Annotated[AppVariant, IOAttrs('vrnt')]
architecture: Annotated[AppArchitecture, IOAttrs('arch')]
os_version: Annotated[str | None, IOAttrs('osvr')]
interface_idiom: Annotated[AppInterfaceIdiom, IOAttrs('i')]
locale: Annotated[str, IOAttrs('l')]
interface_idiom: Annotated[AppInterfaceIdiom, IOAttrs('intf')]
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
@dataclass
class ResponseData:
"""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.
"""
"""Response sent from the bacloud server to the client."""
@ioprepped
@dataclass
@ -101,62 +64,114 @@ class ResponseData:
"""Individual download."""
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')]
# TODO: could add a hash here if we want the client to
# 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')]
# Server command that should be called for each download. The
# server command is expected to respond with a downloads_inline
# containing a single 'default' entry. In the future this may
# be expanded to a more streaming-friendly process.
#: Server command that should be called for each download. The
#: server command is expected to respond with a downloads_inline
#: containing a single 'default' entry. In the future this may
#: be expanded to a more streaming-friendly process.
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')]
# Everything that should be downloaded.
#: Everything that should be downloaded.
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
#: End arg for message print() call.
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
#: 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
#: 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
#: If True, any existing client-side token should be discarded.
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)] = (
None
)
#: If present, client should upload the requested files (arg1)
#: individually to a server command (arg2) with provided args (arg3).
uploads: Annotated[
tuple[list[str], str, dict] | None, IOAttrs('u', store_default=False)
] = 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[
list[str] | None, IOAttrs('uinl', store_default=False)
] = None
#: If present, file paths that should be deleted on the client.
deletes: Annotated[
list[str] | None, IOAttrs('dlt', store_default=False)
] = None
#: If present, describes files the client should individually
#: request from the server if not already present on the client.
downloads: Annotated[
Downloads | None, IOAttrs('dl', store_default=False)
] = 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[
dict[str, bytes] | None, IOAttrs('dinl', store_default=False)
] = None
#: If present, all empty dirs under this one should be removed.
dir_prune_empty: Annotated[
str | None, IOAttrs('dpe', store_default=False)
] = None
#: If present, url to display to the user.
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[
tuple[str, bool] | None, IOAttrs('inp', store_default=False)
] = 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)] = (
None
)
#: End arg for end_message print() call.
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[
tuple[str, dict] | None, IOAttrs('ec', store_default=False)
] = None

View file

@ -13,6 +13,12 @@ from efro.util import pairs_to_flat
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
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
@dataclass
@ -89,7 +95,9 @@ class ClassicAccountLiveData:
ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
]
create_time: Annotated[datetime.datetime, IOAttrs('c')]
unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
unlock_tokens: Annotated[int, IOAttrs('k')]
ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')]
class LeagueType(Enum):
@ -119,6 +127,7 @@ class ClassicAccountLiveData:
inbox_count: Annotated[int, IOAttrs('ibc')]
inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')]
inbox_contains_prize: Annotated[bool, IOAttrs('icp')]
chests: Annotated[dict[str, Chest], IOAttrs('c')]
@ -341,64 +350,6 @@ class ChestInfoResponse(Response):
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):
"""Type ID for each of our subclasses."""
@ -717,6 +668,9 @@ class ClientEffectTypeID(Enum):
SCREEN_MESSAGE = 'm'
SOUND = 's'
DELAY = 'd'
CHEST_WAIT_TIME_ANIMATION = 't'
TICKETS_ANIMATION = 'ta'
TOKENS_ANIMATION = 'toa'
class ClientEffect(IOMultiType[ClientEffectTypeID]):
@ -738,6 +692,7 @@ class ClientEffect(IOMultiType[ClientEffectTypeID]):
def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
# pylint: disable=too-many-return-statements
t = ClientEffectTypeID
if type_id is t.UNKNOWN:
@ -748,6 +703,12 @@ class ClientEffect(IOMultiType[ClientEffectTypeID]):
return ClientEffectSound
if type_id is t.DELAY:
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.
assert_never(type_id)
@ -809,6 +770,52 @@ class ClientEffectSound(ClientEffect):
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
@dataclass
class ClientEffectDelay(ClientEffect):
@ -885,3 +892,68 @@ class ScoreSubmitResponse(Response):
# Things we should show on our end.
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.dataclassio import ioprepped, IOAttrs
from bacommon.securedata import SecureDataChecker
from bacommon.transfer import DirectoryManifest
from bacommon.login import LoginType
@ -300,3 +301,45 @@ class StoreQueryResponse(Response):
available_purchases: Annotated[list[Purchase], IOAttrs('p')]
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):
"""Types of logins available."""
# Email/password
#: Email/password
EMAIL = 'email'
# Google Play Game Services
#: Google Play Game Services
GPGS = 'gpgs'
# Apple's Game Center
#: Apple's Game Center
GAME_CENTER = 'game_center'
@property
def displayname(self) -> str:
"""Human readable name for this value."""
"""A human readable name for this value."""
cls = type(self)
match self:
case cls.EMAIL:
@ -43,7 +43,7 @@ class LoginType(Enum):
@property
def displaynameshort(self) -> str:
"""Human readable name for this value."""
"""A short human readable name for this value."""
cls = type(self)
match self:
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.
#
"""Workspace functionality."""
"""Functionality related to ballistica.net workspaces."""

View file

@ -1,11 +1,11 @@
# 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
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
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
and then run the app.
@ -53,46 +53,46 @@ if TYPE_CHECKING:
# Build number and version of the ballistica binary we expect to be
# using.
TARGET_BALLISTICA_BUILD = 22278
TARGET_BALLISTICA_VERSION = '1.7.37'
TARGET_BALLISTICA_BUILD = 22350
TARGET_BALLISTICA_VERSION = '1.7.39'
@dataclass
class EnvConfig:
"""Final config values we provide to the engine."""
# Where app config/state data lives.
#: Where app config/state data lives.
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
# Where the app's built-in Python stuff lives.
#: Where the app's built-in Python stuff lives.
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
# 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
# Custom Python provided by the user (mods).
#: Custom Python provided by the user (mods).
user_python_dir: str | None
# We have a mechanism allowing app scripts to be overridden by
# placing a specially named directory in a user-scripts dir.
# This is true if that is enabled.
#: We have a mechanism allowing app scripts to be overridden by
#: placing a specially named directory in a user-scripts dir. This is
#: true if that is enabled.
is_user_app_python_dir: bool
# Our fancy app log handler. This handles feeding logs, stdout, and
# stderr into the engine so they show up on in-app consoles, etc.
#: Our fancy app log handler. This handles feeding logs, stdout, and
#: stderr into the engine so they show up on in-app consoles, etc.
log_handler: LogHandler | None
# Initial data from the config.json file in the config dir. The
# config file is parsed by
#: Initial data from the config.json file in the config dir. The
#: config file is parsed by
initial_app_config: Any
# Timestamp when we first started doing stuff.
#: Timestamp when we first started doing stuff.
launch_time: float
@ -100,10 +100,9 @@ class EnvConfig:
class _EnvGlobals:
"""Globals related to baenv's operation.
We store this in __main__ instead of in our own module because it
is likely that multiple versions of our module will be spun up
and we want a single set of globals (see notes at top of our module
code).
We store this in __main__ instead of in our own module because it is
likely that multiple versions of our module will be spun up and we
want a single set of globals (see notes at top of our module code).
"""
config: EnvConfig | None = None

View file

@ -3,10 +3,10 @@
"""Closed-source bits of ballistica.
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
want to compile the rest of the engine, or a fully open-source app
can also be built by removing this feature-set.
want to compile the rest of the engine, or a fully open-source app can
also be built by removing this feature-set.
"""
from __future__ import annotations

View file

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

View file

@ -24,7 +24,12 @@ if TYPE_CHECKING:
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:
super().__init__()
@ -34,19 +39,25 @@ class CloudSubsystem(babase.AppSubsystem):
@property
def connected(self) -> bool:
"""Property equivalent of CloudSubsystem.is_connected()."""
return self.is_connected()
def is_connected(self) -> bool:
"""Return whether a connection to the cloud is present.
"""Whether a connection to the cloud is present.
This is a good indicator (though not for certain) that sending
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:
"""Called when cloud connectivity state changes."""
"""Called when cloud connectivity state changes.
:meta private:
"""
babase.balog.debug('Connectivity is now %s.', connected)
plus = babase.app.plus
@ -172,6 +183,24 @@ class CloudSubsystem(babase.AppSubsystem):
],
) -> 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(
self,
msg: Message,
@ -179,7 +208,7 @@ class CloudSubsystem(babase.AppSubsystem):
) -> None:
"""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.
"""
raise NotImplementedError(
@ -221,7 +250,7 @@ class CloudSubsystem(babase.AppSubsystem):
) -> bacommon.cloud.TestResponse: ...
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.
"""
@ -232,7 +261,10 @@ class CloudSubsystem(babase.AppSubsystem):
def subscribe_test(
self, updatecall: Callable[[int | None], None]
) -> babase.CloudSubscription:
"""Subscribe to some test data."""
"""Subscribe to some test data.
:meta private:
"""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
@ -250,6 +282,8 @@ class CloudSubsystem(babase.AppSubsystem):
"""Unsubscribe from some subscription.
Do not call this manually; it is called by CloudSubscription.
:meta private:
"""
raise NotImplementedError(
'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 babase import (
ActivityNotFoundError,
add_clean_frame_callback,
app,
App,
AppIntent,
AppIntentDefault,
AppIntentExec,
AppMode,
AppState,
apptime,
AppTime,
apptimer,
@ -52,6 +55,7 @@ from babase import (
safecolor,
screenmessage,
set_analytics_screen,
SessionNotFoundError,
storagename,
timestring,
UIScale,
@ -164,7 +168,7 @@ from bascenev1._dependency import (
from bascenev1._dualteamsession import DualTeamSession
from bascenev1._freeforallsession import FreeForAllSession
from bascenev1._gameactivity import GameActivity
from bascenev1._gameresults import GameResults
from bascenev1._gameresults import GameResults, WinnerGroup
from bascenev1._gameutils import (
animate,
animate_array,
@ -246,15 +250,18 @@ from bascenev1._teamgame import TeamGameActivity
__all__ = [
'Activity',
'ActivityData',
'ActivityNotFoundError',
'Actor',
'animate',
'animate_array',
'add_clean_frame_callback',
'app',
'App',
'AppIntent',
'AppIntentDefault',
'AppIntentExec',
'AppMode',
'AppState',
'AppTime',
'apptime',
'apptimer',
@ -414,6 +421,7 @@ __all__ = [
'ScoreConfig',
'ScoreScreenActivity',
'ScoreType',
'SessionNotFoundError',
'broadcastmessage',
'Session',
'SessionData',
@ -462,6 +470,7 @@ __all__ = [
'unlock_all_input',
'Vec3',
'WeakCall',
'WinnerGroup',
]
# 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]):
"""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 bascenev1.Session has one 'current' Activity at any time, though
their existence can overlap during transitions.
Examples of activities include games, score-screens, cutscenes, etc.
A :class:`bascenev1.Session` has one 'current' activity at any time,
though their existence can overlap during transitions.
"""
# 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]
"""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]
"""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]
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""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
"""Is it ok to show an ad after this activity ends before showing
the next activity?"""
def __init__(self, settings: dict):
"""Creates an Activity in the current bascenev1.Session.
@ -149,7 +148,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
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] = {}
# Hopefully can eventually kill this; activities should
@ -208,8 +207,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property
def globalsnode(self) -> bascenev1.Node:
"""The 'globals' bascenev1.Node for the activity. This contains various
global controls and values.
"""The 'globals' :class:`~bascenev1.Node` for the activity.
This contains various global controls and values.
"""
node = self._globalsnode
if not node:
@ -221,7 +221,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
"""The stats instance accessible while the activity is running.
If access is attempted before or after, raises a
bascenev1.NotFoundError.
:class:`~bascenev1.NotFoundError`.
"""
if self._stats is None:
raise babase.NotFoundError()
@ -260,22 +260,24 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property
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
@property
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
def set_has_ended(self, val: bool) -> None:
"""(internal)"""
"""Internal - used by session.
:meta private:"""
self._has_ended = val
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
@ -304,11 +306,12 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
)
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()
returns False for the Actor. The bascenev1.Actor.autoretain() method
is a convenient way to access this same functionality.
The reference will be lazily released once
:meth:`bascenev1.Actor.exists()` returns False for the actor.
The :meth:`bascenev1.Actor.autoretain()` method is a convenient
way to access this same functionality.
"""
if __debug__:
from bascenev1._actor import Actor
@ -317,9 +320,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._actor_refs.append(actor)
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__:
from bascenev1._actor import Actor
@ -329,9 +332,10 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
@property
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()
if session is None:
@ -339,44 +343,44 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return session
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:
"""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:
"""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:
"""Called when a bascenev1.Team leaves the Activity."""
"""Called when a team leaves the activity."""
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,
start playing music, etc. It does not yet have access to players
or teams, however. They remain owned by the previous Activity
up until bascenev1.Activity.on_begin() is called.
Upon this call, the activity should fade in backgrounds, start
playing music, etc. It does not yet have access to players or
teams, however. They remain owned by the previous activity up
until :meth:`~bascenev1.Activity.on_begin()` is called.
"""
def on_transition_out(self) -> None:
"""Called when your activity begins transitioning out.
Note that this may happen at any time even if bascenev1.Activity.end()
has not been called.
Note that this may happen at any time even if
:meth:`bascenev1.Activity.end()` has not been called.
"""
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
and it should begin its actual game logic.
At this point the activity's initial players and teams are
filled in and it should begin its actual game logic.
"""
def handlemessage(self, msg: Any) -> Any:
@ -385,11 +389,11 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return UNHANDLED
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
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
def has_ended(self) -> bool:
@ -397,13 +401,13 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
return self._has_ended
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
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
self._has_transitioned_in = True
@ -459,7 +463,10 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._activity_data.make_foreground()
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
self._transitioning_out = True
with self.context:
@ -469,9 +476,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_transition_out for %s.', self)
def begin(self, session: bascenev1.Session) -> None:
"""Begin the activity.
"""Internal; Begin the activity.
(internal)
:meta private:
"""
assert not self._has_begun
@ -499,45 +506,46 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
def end(
self, results: Any = None, delay: float = 0.0, force: bool = False
) -> 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
(in seconds). Further calls to end() will be ignored up until
this time, unless 'force' is True, in which case the new results
will replace the old.
'delay' is the time delay before the Activity actually ends (in
seconds). Further end calls will be ignored up until this time,
unless 'force' is True, in which case the new results will
replace the old.
"""
# Ask the session to end us.
self.session.end_activity(self, results, delay, force)
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
requires a custom constructor; otherwise it will be called with
no args. Note that the player object should not be used at this
point as it is not yet fully wired up; wait for
bascenev1.Activity.on_player_join() for that.
Note that the player object should not be used at this point as
it is not yet fully wired up; wait for
:meth:`bascenev1.Activity.on_player_join()` for that.
"""
del sessionplayer # Unused.
player = self._playertype()
return player
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
requires a custom constructor; otherwise it will be called with
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()
for that.
point as it is not yet fully wired up; wait for
:meth:`bascenev1.Activity.on_team_join()` for that.
"""
del sessionteam # Unused.
team = self._teamtype()
return team
def add_player(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""(internal)"""
"""Internal
:meta private:
"""
assert sessionplayer.sessionteam is not None
sessionplayer.resetinput()
sessionteam = sessionplayer.sessionteam
@ -565,7 +573,7 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_player_join for %s.', self)
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)
"""
@ -584,10 +592,6 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self.players.remove(player)
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:
try:
self.on_player_leave(player)
@ -608,9 +612,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
self._players_that_left.append(weakref.ref(player))
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
@ -624,9 +628,9 @@ class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
logging.exception('Error in on_team_join for %s.', self)
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 sessionteam.activityteam is not None

View file

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

View file

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

View file

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

View file

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

View file

@ -20,13 +20,10 @@ TEAM_NAMES = ['Good Guys']
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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,20 +21,17 @@ if TYPE_CHECKING:
@dataclass
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
teams: Sequence[bascenev1.SessionTeam]
teams: list[bascenev1.SessionTeam]
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
bascenev1.Activity.end call.
Upon completion, a game should fill one of these out and pass it to
its :meth:`~bascenev1.Activity.end()` call.
"""
def __init__(self) -> None:
@ -69,8 +66,8 @@ class GameResults:
def set_team_score(self, team: bascenev1.Team, score: int | None) -> None:
"""Set the score for a given team.
This can be a number or None.
(see the none_is_winner arg in the constructor)
This can be a number or None (see the ``none_is_winner`` arg in
the constructor).
"""
assert isinstance(team, Team)
sessionteam = team.sessionteam
@ -79,7 +76,7 @@ class GameResults:
def get_sessionteam_score(
self, sessionteam: bascenev1.SessionTeam
) -> int | None:
"""Return the score for a given bascenev1.SessionTeam."""
"""Return the score for a given team."""
assert isinstance(sessionteam, SessionTeam)
for score in list(self._scores.values()):
if score[0]() is sessionteam:
@ -90,7 +87,7 @@ class GameResults:
@property
def sessionteams(self) -> list[bascenev1.SessionTeam]:
"""Return all bascenev1.SessionTeams in the results."""
"""Return all teams in the results."""
if not self._game_set:
raise RuntimeError("Can't get teams until game is set.")
teams = []
@ -104,13 +101,13 @@ class GameResults:
def has_score_for_sessionteam(
self, sessionteam: bascenev1.SessionTeam
) -> 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())
def get_sessionteam_score_str(
self, sessionteam: bascenev1.SessionTeam
) -> 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.)
"""
@ -163,7 +160,7 @@ class GameResults:
@property
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:
raise RuntimeError("Can't get winners until game is set.")
winners = self.winnergroups
@ -173,7 +170,7 @@ class GameResults:
@property
def winnergroups(self) -> list[WinnerGroup]:
"""Get an ordered list of winner groups."""
"""The ordered list of winner-groups."""
if not self._game_set:
raise RuntimeError("Can't get winners until game is set.")

View file

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

View file

@ -16,10 +16,7 @@ if TYPE_CHECKING:
class Level:
"""An entry in a bascenev1.Campaign.
Category: **Gameplay Classes**
"""
"""An entry in a :class:`~bascenev1.Campaign`."""
def __init__(
self,
@ -46,7 +43,7 @@ class Level:
@property
def name(self) -> str:
"""The unique name for this Level."""
"""The unique name for this level."""
return self._name
def get_settings(self) -> dict[str, Any]:
@ -60,16 +57,12 @@ class Level:
@property
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
# def get_preview_texture(self) -> bauiv1.Texture:
# """Load/return the preview Texture for this Level."""
# return _bauiv1.gettexture(self._preview_texture_name)
@property
def displayname(self) -> bascenev1.Lstr:
"""The localized name for this Level."""
"""The localized name for this level."""
return babase.Lstr(
translate=(
'coopLevelNames',
@ -86,20 +79,20 @@ class Level:
@property
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
@property
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()
@property
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
Campaign.
Access results in a RuntimeError if the level is not assigned to
a campaign.
"""
if self._index is None:
raise RuntimeError('Level is not part of a Campaign')
@ -107,7 +100,7 @@ class Level:
@property
def complete(self) -> bool:
"""Whether this Level has been completed."""
"""Whether this level has been completed."""
config = self._get_config_dict()
val = config.get('Complete', False)
assert isinstance(val, bool)
@ -123,7 +116,7 @@ class Level:
config['Complete'] = val
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()
high_scores_key = 'High Scores' + self.get_score_version_string()
if high_scores_key not in config:
@ -137,10 +130,11 @@ class Level:
config[high_scores_key] = high_scores
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
can be changed to separate its new high score lists/etc. from the old.
If a level's gameplay changes significantly, its version string
can be changed to separate its new high score lists/etc. from
the old.
"""
if self._score_version_string is None:
scorever = self._gametype.getscoreconfig().version
@ -152,13 +146,13 @@ class Level:
@property
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)
assert isinstance(val, float)
return val
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
config = self._get_config_dict()
config['Rating'] = max(old_rating, rating)
@ -166,8 +160,9 @@ class Level:
def _get_config_dict(self) -> dict[str, Any]:
"""Return/create the persistent state dict for this level.
The referenced dict exists under the game's config dict and
can be modified in place."""
The referenced dict exists under the game's config dict and can
be modified in place.
"""
campaign = self.campaign
if campaign is None:
raise RuntimeError('Level is not in a campaign.')
@ -179,8 +174,9 @@ class Level:
return val
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._index = index

View file

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

View file

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

View file

@ -28,17 +28,11 @@ UNHANDLED = _UnhandledType()
@dataclass
class OutOfBoundsMessage:
"""A message telling an object that it is out of bounds.
Category: Message Classes
"""
"""A message telling an object that it is out of bounds."""
class DeathType(Enum):
"""A reason for a death.
Category: Enums
"""
"""A reason for a death."""
GENERIC = 'generic'
OUT_OF_BOUNDS = 'out_of_bounds'
@ -52,29 +46,24 @@ class DeathType(Enum):
class DieMessage:
"""A message telling an object to die.
Category: **Message Classes**
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
"""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
"""The particular reason for death."""
PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
class PlayerDiedMessage:
"""A message saying a bascenev1.Player has died.
Category: **Message Classes**
"""
"""A message saying a bascenev1.Player has died."""
killed: bool
"""If True, the player was killed;
@ -129,8 +118,6 @@ class PlayerDiedMessage:
class StandMessage:
"""A message telling an object to move to a position in space.
Category: **Message Classes**
Used when teleporting players to home base, etc.
"""
@ -143,10 +130,7 @@ class StandMessage:
@dataclass
class PickUpMessage:
"""Tells an object that it has picked something up.
Category: **Message Classes**
"""
"""Tells an object that it has picked something up."""
node: bascenev1.Node
"""The bascenev1.Node that is getting picked up."""
@ -154,18 +138,12 @@ class PickUpMessage:
@dataclass
class DropMessage:
"""Tells an object that it has dropped what it was holding.
Category: **Message Classes**
"""
"""Tells an object that it has dropped what it was holding."""
@dataclass
class PickedUpMessage:
"""Tells an object that it has been picked up by something.
Category: **Message Classes**
"""
"""Tells an object that it has been picked up by something."""
node: bascenev1.Node
"""The bascenev1.Node doing the picking up."""
@ -173,10 +151,7 @@ class PickedUpMessage:
@dataclass
class DroppedMessage:
"""Tells an object that it has been dropped.
Category: **Message Classes**
"""
"""Tells an object that it has been dropped."""
node: bascenev1.Node
"""The bascenev1.Node doing the dropping."""
@ -184,18 +159,12 @@ class DroppedMessage:
@dataclass
class ShouldShatterMessage:
"""Tells an object that it should shatter.
Category: **Message Classes**
"""
"""Tells an object that it should shatter."""
@dataclass
class ImpactDamageMessage:
"""Tells an object that it has been jarred violently.
Category: **Message Classes**
"""
"""Tells an object that it has been jarred violently."""
intensity: float
"""The intensity of the impact."""
@ -205,26 +174,21 @@ class ImpactDamageMessage:
class FreezeMessage:
"""Tells an object to become frozen.
Category: **Message Classes**
As seen in the effects of an ice bascenev1.Bomb.
"""
time: float = 5.0
"""The amount of time the object will be frozen."""
@dataclass
class ThawMessage:
"""Tells an object to stop being frozen.
Category: **Message Classes**
"""
"""Tells an object to stop being frozen."""
@dataclass
class CelebrateMessage:
"""Tells an object to celebrate.
Category: **Message Classes**
"""
"""Tells an object to celebrate."""
duration: float = 10.0
"""Amount of time to celebrate in seconds."""
@ -233,10 +197,8 @@ class CelebrateMessage:
class HitMessage:
"""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__(

View file

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

View file

@ -16,8 +16,6 @@ if TYPE_CHECKING:
class MusicType(Enum):
"""Types of music available to play in-game.
Category: **Enums**
These do not correspond to specific pieces of music, but rather to
'situations'. The actual music played for each type can be overridden
by the game or by the user.
@ -50,16 +48,15 @@ class MusicType(Enum):
def setmusic(musictype: MusicType | None, continuous: bool = False) -> None:
"""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 platforms so the
user can override particular game music with their own.
This function will handle loading and playing sound assets as
necessary, and also supports custom user soundtracks on specific
platforms so the user can override particular game music with their
own.
Pass None to stop music.
if 'continuous' is True and musictype is the same as what is already
playing, the playing track will not be restarted.
if ``continuous`` is True and musictype is the same as what is
already playing, the playing track will not be restarted.
"""
# 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):
"""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
exists() call will return whether the Node still exists or not.
"""

View file

@ -24,10 +24,7 @@ TeamT = TypeVar('TeamT', bound='bascenev1.Team')
@dataclass
class PlayerInfo:
"""Holds basic info about a player.
Category: Gameplay Classes
"""
"""Holds basic info about a player."""
name: str
character: str
@ -35,10 +32,7 @@ class PlayerInfo:
@dataclass
class StandLocation:
"""Describes a point in space and an angle to face.
Category: Gameplay Classes
"""
"""Describes a point in space and an angle to face."""
position: babase.Vec3
angle: float | None = None
@ -47,8 +41,6 @@ class StandLocation:
class Player(Generic[TeamT]):
"""A player in a specific bascenev1.Activity.
Category: Gameplay Classes
These correspond to bascenev1.SessionPlayer objects, but are associated
with a single bascenev1.Activity instance. This allows activities to
specify their own custom bascenev1.Player types.
@ -282,8 +274,6 @@ class Player(Generic[TeamT]):
class EmptyPlayer(Player['bascenev1.EmptyTeam']):
"""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
those top level classes as type arguments when defining a
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:
"""Cast a bascenev1.Player to a specific bascenev1.Player subclass.
Category: Gameplay Functions
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
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(
totype: type[PlayerT], player: bascenev1.Player | None
) -> PlayerT | None:
"""A variant of bascenev1.playercast() for use with optional Player values.
Category: Gameplay Functions
"""
"""A variant of bascenev1.playercast() for optional Player values."""
assert isinstance(player, (totype, type(None)))
return player

View file

@ -17,9 +17,8 @@ if TYPE_CHECKING:
class PowerupMessage:
"""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
@ -38,10 +37,8 @@ class PowerupMessage:
class PowerupAcceptMessage:
"""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
class ScoreType(Enum):
"""Type of scores.
Category: **Enums**
"""
"""Type of scores."""
SECONDS = 's'
MILLISECONDS = 'ms'
@ -26,10 +23,7 @@ class ScoreType(Enum):
@dataclass
class ScoreConfig:
"""Settings for how a game handles scores.
Category: **Gameplay Classes**
"""
"""Settings for how a game handles scores."""
label: str = 'Score'
"""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:
"""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,
bascenev1.DualTeamSession, and bascenev1.CoopSession.
A Session is responsible for wrangling and transitioning between various
bascenev1.Activity instances such as mini-games and score-screens, and for
maintaining state between them (players, teams, score tallies, etc).
A session is responsible for wrangling and transitioning between
various 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
"""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
"""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
# at the class level so that looks better and nobody get lost while
# reading large __init__
# Note: even though these are instance vars, we annotate and
# document them at the class level so that looks better and nobody
# 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
"""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
"""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
"""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]
"""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
"""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]
"""All the bascenev1.SessionTeams in the Session. Most things should
use the list of bascenev1.Team-s in bascenev1.Activity; not this."""
def __init__(
self,
@ -243,7 +244,7 @@ class Session:
@property
def sessionglobalsnode(self) -> bascenev1.Node:
"""The sessionglobals bascenev1.Node for the session."""
"""The sessionglobals node for the session."""
node = self._sessionglobalsnode
if not node:
raise babase.NodeNotFoundError()
@ -254,15 +255,15 @@ class Session:
) -> bool:
"""Ask ourself if we should allow joins during an Activity.
Note that for a join to be allowed, both the Session and Activity
have to be ok with it (via this function and the
Activity.allow_mid_activity_joins property.
Note that for a join to be allowed, both the session and
activity have to be ok with it (via this function and the
:attr:`bascenev1.Activity.allow_mid_activity_joins` property.
"""
del activity # Unused.
return True
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.
"""
@ -426,10 +427,10 @@ class Session:
)
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
session and its activities to shut down gracefully.
Note that this happens asynchronously, allowing the session and
its activities to shut down gracefully.
"""
self._wants_to_end = True
if self._next_activity is None:
@ -457,10 +458,10 @@ class Session:
self._ending = True # Prevent further actions.
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:
"""Called when a bascenev1.Team is leaving the session."""
"""Called when a team is leaving the session."""
def end_activity(
self,
@ -469,12 +470,12 @@ class Session:
delay: float,
force: bool,
) -> 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
(in seconds). Further calls to end() will be ignored up until
this time, unless 'force' is True, in which case the new results
will replace the old.
'delay' is the time delay before the activity actually ends (in
seconds). Further calls to end the activity will be ignored up
until this time, unless 'force' is True, in which case the new
results will replace the old.
"""
# Only pay attention if this is coming from our current activity.
if activity is not self._activity_retained:
@ -530,13 +531,13 @@ class Session:
self._session._in_set_activity = False
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
Activity's. Code must be run in the new activity's methods
(on_transition_in, etc) to get it. (so you can't do
session.setactivity(foo) and then bascenev1.newnode() to add a node
to foo)
activity's. Code must be run in the new activity's methods
(:meth:`~bascenev1.Activity.on_transition_in()`, etc) to get it.
(so you can't do ``session.setactivity(foo)`` and then
``bascenev1.newnode()`` to add a node to foo).
"""
# Make sure we don't get called recursively.
@ -656,16 +657,16 @@ class Session:
def on_activity_end(
self, activity: bascenev1.Activity, results: Any
) -> 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
another bascenev1.Activity.
The session should look at the results and start another
activity.
"""
def begin_next_activity(self) -> None:
"""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:
# Should this ever happen?
@ -742,7 +743,10 @@ class Session:
def transitioning_out_activity_was_freed(
self, can_show_ad_on_death: bool
) -> None:
"""(internal)"""
"""(internal)
:meta private:
"""
# pylint: disable=cyclic-import
# Since things should be generally still right now, it's a good time

View file

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

View file

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

View file

@ -17,34 +17,32 @@ if TYPE_CHECKING:
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 SessionPlayer *always* has a SessionTeam;
in some cases, such as free-for-all bascenev1.Sessions,
each SessionTeam consists of just one SessionPlayer.
Note that a player will *always* have a team. in some cases, such as
free-for-all :class:`~bascenev1.Sessions`, each team consists of
just one player.
"""
# 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
"""The team's name."""
#: The team's color.
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]
"""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
"""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
"""The unique numeric id of the team."""
def __init__(
self,
@ -52,12 +50,6 @@ class SessionTeam:
name: babase.Lstr | str = '',
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.name = name
self.color = tuple(color)
@ -66,7 +58,10 @@ class SessionTeam:
self.activityteam: Team | None = None
def leave(self) -> None:
"""(internal)"""
"""(internal)
:meta private:
"""
self.customdata = {}
@ -74,12 +69,11 @@ PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
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 bascenev1.SessionTeam objects, but are created
per activity so that the activity can use its own custom team subclass.
These correspond to :class:`~bascenev1.SessionTeam` objects, but are
created 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
@ -97,9 +91,9 @@ class Team(Generic[PlayerT]):
# get called by default if a dataclass inherits from us.
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,
@ -136,22 +130,23 @@ class Team(Generic[PlayerT]):
@property
def customdata(self) -> dict:
"""Arbitrary values associated with the team.
Though it is encouraged that most player values be properly defined
on the bascenev1.Team subclass, it may be useful for player-agnostic
objects to store values here. This dict is cleared when the team
leaves or expires so objects stored here will be disposed of at
the expected time, unlike the Team instance itself which may
continue to be referenced after it is no longer part of the game.
"""Arbitrary values associated with the team. Though it is
encouraged that most player values be properly defined on the
:class:`~bascenev1.Team` subclass, it may be useful for
player-agnostic objects to store values here. This dict is
cleared when the team leaves or expires so objects stored here
will be disposed of at the expected time, unlike the
:class:`~bascenev1.Team` instance itself which may continue to
be referenced after it is no longer part of the game.
"""
assert self._postinited
assert not self._expired
return self._customdata
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 not self._expired
@ -159,9 +154,9 @@ class Team(Generic[PlayerT]):
del self.players
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 not self._expired
@ -180,9 +175,10 @@ class Team(Generic[PlayerT]):
@property
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
if self._sessionteam is not None:
@ -194,18 +190,16 @@ class Team(Generic[PlayerT]):
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,
activity.teams[0].player will have type 'Any' in that case. For that
reason, it is better to pass EmptyPlayer and EmptyTeam when defining
a bascenev1.Activity that does not need custom types of its own.
Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
so if you want to define your own class for one of them you should do so
for both.
Note that EmptyPlayer defines its team type as EmptyTeam and vice
versa, so if you want to define your own class for one of them you
should do so for both.
"""

View file

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

View file

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

View file

@ -16,38 +16,31 @@ if TYPE_CHECKING:
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 FlagFactory.get().
A single instance of this is shared between all flags and can be
retrieved via :meth:`FlagFactory.get()`.
"""
#: The material applied to all flags.
flagmaterial: bs.Material
"""The bs.Material applied to all `Flag`s."""
#: The sound used when a flag hits the ground.
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
"""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
"""A bs.Material that prevents contact with most objects;
applied to 'non-touchable' flags."""
flag_texture: bs.Texture
"""The bs.Texture for flags."""
"""The texture for flags."""
_STORENAME = bs.storagename()
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()
self.flagmaterial = bs.Material()
self.flagmaterial.add_actions(
@ -110,7 +103,7 @@ class FlagFactory:
@classmethod
def get(cls) -> FlagFactory:
"""Get/create a shared `FlagFactory` instance."""
"""Get/create a shared flag-factory instance."""
activity = bs.getactivity()
factory = activity.customdata.get(cls._STORENAME)
if factory is None:
@ -122,51 +115,40 @@ class FlagFactory:
@dataclass
class FlagPickedUpMessage:
"""A message saying a `Flag` has been picked up.
Category: **Message Classes**
"""
"""A message saying a flag has been picked up."""
#: The flag that has been picked up.
flag: Flag
"""The `Flag` that has been picked up."""
#: The bs.Node doing the picking up.
node: bs.Node
"""The bs.Node doing the picking up."""
@dataclass
class FlagDiedMessage:
"""A message saying a `Flag` has died.
Category: **Message Classes**
"""
"""A message saying a `Flag` has died."""
#: The flag that died.
flag: Flag
"""The `Flag` that died."""
#: Whether the flag killed itself.
self_kill: bool = False
"""If the `Flag` killed itself or not."""
@dataclass
class FlagDroppedMessage:
"""A message saying a `Flag` has been dropped.
Category: **Message Classes**
"""
"""A message saying a `Flag` has been dropped."""
#: The flag that was dropped.
flag: Flag
"""The `Flag` that was dropped."""
#: The node that was holding the flag.
node: bs.Node
"""The bs.Node that was holding it."""
class Flag(bs.Actor):
"""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.
"""
@ -382,7 +364,7 @@ class Flag(bs.Actor):
@staticmethod
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
movable flag originated from.

View file

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

View file

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

View file

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

View file

@ -24,8 +24,6 @@ class _TouchedMessage:
class PowerupBoxFactory:
"""A collection of media and other resources used by bs.Powerups.
Category: **Gameplay Classes**
A single instance of this is shared between all powerups
and can be retrieved via bs.Powerup.get_factory().
"""
@ -190,19 +188,18 @@ class PowerupBoxFactory:
class PowerupBox(bs.Actor):
"""A box that grants a powerup.
category: Gameplay Classes
This will deliver a bs.PowerupMessage to anything that touches it
which has the bs.PowerupBoxFactory.powerup_accept_material applied.
This will deliver a :class:`~bascenev1.PowerupMessage` to anything
that touches it which has the
:class:`~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
"""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
"""The 'prop' bs.Node representing this box."""
"""The 'prop' node representing this box."""
def __init__(
self,

View file

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

View file

@ -16,17 +16,12 @@ if TYPE_CHECKING:
class Spawner:
"""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:
"""Spawn message sent by a Spawner after its delay has passed.
Category: **Message Classes**
"""
"""Spawn message sent by a Spawner after its delay has passed."""
spawner: Spawner
"""The bascenev1.Spawner we came from."""

View file

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

View file

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

View file

@ -16,8 +16,6 @@ if TYPE_CHECKING:
class SpazFactory:
"""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
between all spaz instances. Use bs.Spaz.get_factory() to return
the shared factory for the current activity.

View file

@ -17,8 +17,6 @@ if TYPE_CHECKING:
class ZoomText(bs.Actor):
"""Big Zooming Text.
Category: Gameplay Classes
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."""
def __init__(self, base_pos: Sequence[float], flag: Flag) -> None:
#: Where our base is.
self.base_pos = base_pos
#: Flag for this team.
self.flag = flag
#: Current score.
self.score = 0

View file

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

View file

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

View file

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

View file

@ -32,8 +32,6 @@ if TYPE_CHECKING:
class UIV1AppSubsystem(babase.AppSubsystem):
"""Consolidated UI functionality for the app.
Category: **App Classes**
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.infotextcolor = (0.7, 0.9, 0.7)
self._last_win_recreate_size: tuple[float, float] | None = None
self._last_screen_size_win_recreate_time: float | None = None
self._screen_size_win_recreate_timer: babase.AppTimer | None = None
self.window_auto_recreate_suppress_count = 0
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
# activated.
@ -196,6 +197,15 @@ class UIV1AppSubsystem(babase.AppSubsystem):
# pylint: disable=too-many-statements
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.
if not suppress_warning:
warnings.warn(
@ -398,6 +408,23 @@ class UIV1AppSubsystem(babase.AppSubsystem):
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
def on_ui_scale_change(self) -> None:
# Update our stored UIScale.
@ -406,64 +433,82 @@ class UIV1AppSubsystem(babase.AppSubsystem):
# Update native bits (allow root widget to rebuild itself/etc.)
_bauiv1.on_ui_scale_change()
# Lastly, if we have a main window, recreate it to pick up the
# 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()
self._schedule_main_win_recreate()
@override
def on_screen_size_change(self) -> None:
# 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.
now = time.monotonic()
self._schedule_main_win_recreate()
# 4 refreshes per second seems reasonable.
interval = 0.25
def _schedule_main_win_recreate(self) -> None:
# 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
# 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.
till_update = (
0.0
if self._last_screen_size_win_recreate_time is None
else max(
0.0, self._last_screen_size_win_recreate_time + interval - now
interval
if self.should_suppress_window_recreates()
else (
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(
till_update, self._do_screen_size_win_recreate
self._win_recreate_timer = babase.AppTimer(
till_update, self._do_main_win_recreate
)
def _do_screen_size_win_recreate(self) -> None:
self._last_screen_size_win_recreate_time = time.monotonic()
self._screen_size_win_recreate_timer = None
def _do_main_win_recreate(self) -> None:
self._last_win_recreate_time = time.monotonic()
self._win_recreate_timer = None
# Avoid recreating if we're already at this size. This prevents
# a redundant recreate when ui scale changes.
virtual_screen_size = babase.get_virtual_screen_size()
if virtual_screen_size == self._last_win_recreate_size:
# If win-recreates are currently suppressed, just kick off
# another timer. We'll do our actual thing once suppression
# finally ends.
if self.should_suppress_window_recreates():
self._schedule_main_win_recreate()
return
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
# future recreates.
self._last_win_recreate_size = virtual_screen_size
# Can't recreate what doesn't exist.
if mainwindow is None:
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:
"""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 from active babase.Keyboard.
Keyboards are discoverable by the meta-tag system and the user can
select which one they want to use. On-screen keyboard uses chars
from active babase.Keyboard.
"""
name: str

View file

@ -27,19 +27,37 @@ DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1'
class Window:
"""A basic window.
Category: User Interface Classes
Essentially wraps a ContainerWidget with some higher level
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
# 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:
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:
"""Return the root widget."""
return self._root_widget
@ -66,8 +84,8 @@ class MainWindow(Window):
):
"""Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget,
so there is no need to set transitions when creating it.
Automatically handles in and out transitions on the provided
widget, so there is no need to set transitions when creating it.
"""
# A back-state supplied by the ui system.
self.main_window_back_state: MainWindowState | None = None
@ -89,7 +107,11 @@ class MainWindow(Window):
self._main_window_transition = transition
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
if origin_widget is not None:
@ -120,7 +142,7 @@ class MainWindow(Window):
# Note: normally transition of None means instant, but we use
# that to mean 'do the default' so we support a special
# 'instant' string..
# 'instant' string.
if transition == 'instant':
self._root_widget.delete()
else:
@ -304,8 +326,6 @@ class UICleanupCheck:
def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None:
"""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
object still exists ~5 seconds after the provided bauiv1.Widget dies.
@ -407,3 +427,13 @@ class TextWidgetStringEditAdapter(babase.StringEditAdapter):
def _do_cancel(self) -> None:
if self.widget:
_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
# Game Center currently has a single UI for everything.
show_game_service_button = game_center_active
game_service_button_space = 60.0
show_game_center_button = game_center_active
game_center_button_space = 60.0
# Phasing this out (for V2 accounts at least).
show_linked_accounts_text = (
@ -431,8 +431,8 @@ class AccountSettingsWindow(bui.MainWindow):
self._sub_height += sign_in_button_space
if show_device_sign_in_button:
self._sub_height += sign_in_button_space + deprecated_space
if show_game_service_button:
self._sub_height += game_service_button_space
if show_game_center_button:
self._sub_height += game_center_button_space
if show_linked_accounts_text:
self._sub_height += linked_accounts_text_space
if show_achievements_text:
@ -880,14 +880,14 @@ class AccountSettingsWindow(bui.MainWindow):
bui.widget(edit=btn, left_widget=bbtn)
# 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
v -= game_service_button_space * 0.6
v -= game_center_button_space * 1.0
if game_center_active:
# Note: Apparently Game Center is just called 'Game Center'
# in all languages. Can revisit if not true.
# 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)
+ 'Game Center'
)
@ -895,15 +895,15 @@ class AccountSettingsWindow(bui.MainWindow):
raise ValueError(
"unknown account type: '" + str(v1_account_type) + "'"
)
self._game_service_button = btn = bui.buttonwidget(
self._game_center_button = btn = bui.buttonwidget(
parent=self._subcontainer,
position=((self._sub_width - button_width) * 0.5, v),
color=(0.55, 0.5, 0.6),
textcolor=(0.75, 0.7, 0.8),
autoselect=True,
on_activate_call=self._on_game_service_button_press,
on_activate_call=self._on_game_center_button_press,
size=(button_width, 50),
label=game_service_button_label,
label=game_center_button_label,
)
if first_selectable is None:
first_selectable = btn
@ -911,9 +911,9 @@ class AccountSettingsWindow(bui.MainWindow):
edit=btn, right_widget=bui.get_special_widget('squad_button')
)
bui.widget(edit=btn, left_widget=bbtn)
v -= game_service_button_space * 0.4
v -= game_center_button_space * 0.4
else:
self.game_service_button = None
self.game_center_button = None
self._achievements_text: bui.Widget | None
if show_achievements_text:
@ -1214,7 +1214,7 @@ class AccountSettingsWindow(bui.MainWindow):
)
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:
bui.app.plus.show_game_service_ui()
else:

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