Merge pull request #107 from imayushsaini/api9

syncing changes from ballistica/master
This commit is contained in:
Ayush Saini 2025-04-06 15:32:03 +05:30 committed by GitHub
commit f45bd5e31e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
240 changed files with 16025 additions and 12980 deletions

View file

@ -4,7 +4,7 @@ on:
push:
branches:
- public-server
- api8
- api9
jobs:
run_server_binary:
@ -32,7 +32,7 @@ jobs:
if grep -E "Exception|RuntimeError" server-output.log; then
echo "Error message found. Check server-output.log for details."
exit 1
elif ! grep -q "entering server-mode" server-output.log; then
elif ! grep -q "Server started" server-output.log; then
echo "Success message not found in server's output."
exit 1
fi

View file

@ -11,16 +11,16 @@ Migrated from API 7 TO API 8 , this might be unstable and missing some features.
- Basic knowledge of Linux
- A VPS (e.g. [Amazon Web Services](https://aws.amazon.com/), [Microsoft Azure](https://portal.azure.com/))
- Any Linux distribution.
- It is recommended to use Ubuntu.
- Python 3.10
- It is recommended to use Ubuntu (minimum Ubuntu 22).
- Python 3.12
- 1 GB free Memory (Recommended 2 GB)
## Getting Started
This assumes you are on Ubuntu or an Ubuntu based distribution.
Update and install `software-properties-common`
Install `software-properties-common`
```
sudo apt update; sudo apt install software-properties-common -y
sudo apt install software-properties-common -y
```
Add python Deadsnakes PPA
```
@ -30,6 +30,10 @@ Install Python 3.12
```
sudo apt install python3-pip python3.12-dev python3.12-venv
```
Update installed and existing packages
```
sudo apt update && sudo apt upgrade
```
Create a tmux session.
```
tmux new -s 43210
@ -44,6 +48,7 @@ Making the server files executable.
```
chmod 777 bombsquad_server
chmod 777 dist/bombsquad_headless
chmod 777 dist/bombsquad_headless_aarch64
```
Starting the server
```
@ -115,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

@ -18,10 +18,11 @@ party_name = "BombSquad Community Server"
# internet connection.
#authenticate_clients = true
# IDs of server admins. Server admins are not kickable through the default
# kick vote system and they are able to kick players without a vote. To get
# your account id, enter 'getaccountid' in settings->advanced->enter-code.
admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"]
# IDs of server admins. Server admins are not kickable through the
# default kick vote system and they are able to kick players without
# a vote. To get your account id, enter 'getaccountid' in
# settings->advanced->enter-code.
#admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"]
# Whether the default kick-voting system is enabled.
#enable_default_kick_voting = true
@ -66,12 +67,11 @@ admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"]
# (see below).
#session_type = "ffa"
# Playlist-code for teams or free-for-all mode sessions.
# To host your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game.
# This will give you a numeric code you can enter here to host that
# playlist.
playlist_code = 12345
# Playlist-code for teams or free-for-all mode sessions. To host
# your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game. This will give
# you a numeric code you can enter here to host that playlist.
#playlist_code = 12345
# Alternately, you can embed playlist data here instead of using
# codes. Make sure to set session_type to the correct type for the
@ -106,11 +106,11 @@ playlist_code = 12345
# Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins)
teams_series_length = 7
#teams_series_length = 7
# Points to win in free-for-all mode (Points are awarded per game based on
# performance)
ffa_series_length = 24
# Points to win in free-for-all mode (Points are awarded per game
# based on performance)
#ffa_series_length = 24
# If you have a custom stats webpage for your server, you can use
# this to provide a convenient in-game link to it in the
@ -166,3 +166,10 @@ team_colors = [[0.8, 0.0, 0.6], [0, 1, 0.8]]
# before rejoining the game. This can help suppress exploits
# involving leaving and rejoining or switching teams rapidly.
#player_rejoin_cooldown = 10.0
# Log levels for particular loggers, overriding the engine's
# defaults. Valid values are NOTSET, DEBUG, INFO, WARNING, ERROR, or
# CRITICAL.
#[log_levels]
#"ba.lifecycle" = "INFO"
#"ba.assets" = "INFO"

View file

@ -9,6 +9,8 @@ a more focused way.
"""
# pylint: disable=redefined-builtin
# ba_meta require api 9
# The stuff we expose here at the top level is our 'public' api for use
# from other modules/packages. Code *within* this package should import
# things from this package's submodules directly to reduce the chance of
@ -17,11 +19,12 @@ a more focused way.
from efro.util import set_canonical_module_names
import _babase
from _babase import (
add_clean_frame_callback,
allows_ticket_sales,
android_get_external_files_dir,
app_instance_uuid,
appname,
appnameupper,
apptime,
@ -55,12 +58,16 @@ from _babase import (
get_replays_dir,
get_string_height,
get_string_width,
get_ui_scale,
get_v1_cloud_log_file_path,
get_virtual_safe_area_size,
get_virtual_screen_size,
getsimplesound,
has_user_run_commands,
have_chars,
have_permission,
in_logic_thread,
in_main_menu,
increment_analytics_count,
invoke_main_menu,
is_os_playing_music,
@ -86,6 +93,7 @@ from _babase import (
overlay_web_browser_is_supported,
overlay_web_browser_open_url,
print_load_info,
push_back_press,
pushcall,
quit,
reload_media,
@ -95,7 +103,9 @@ from _babase import (
set_analytics_screen,
set_low_level_config_value,
set_thread_name,
set_ui_account_state,
set_ui_input_device,
set_ui_scale,
show_progress_bar,
shutdown_suppress_begin,
shutdown_suppress_end,
@ -103,8 +113,11 @@ from _babase import (
SimpleSound,
supports_max_fps,
supports_vsync,
supports_unicode_display,
unlock_all_input,
update_internal_logger_levels,
user_agent_string,
user_ran_commands,
Vec3,
workspaces_in_use,
)
@ -123,7 +136,9 @@ from babase._apputils import (
garbage_collect,
get_remote_app_name,
AppHealthMonitor,
utc_now_cloud,
)
from babase._cloud import CloudSubscription
from babase._devconsole import (
DevConsoleTab,
DevConsoleTabEntry,
@ -162,10 +177,9 @@ from babase._general import (
get_type_name,
)
from babase._language import Lstr, LanguageSubsystem
from babase._logging import balog, applog, lifecyclelog
from babase._login import LoginAdapter, LoginInfo
# noinspection PyProtectedMember
# (PyCharm inspection bug?)
from babase._mgen.enums import (
Permission,
SpecialChar,
@ -180,6 +194,7 @@ from babase._plugin import PluginSpec, Plugin, PluginSubsystem
from babase._stringedit import StringEditAdapter, StringEditSubsystem
from babase._text import timestring
_babase.app = app = App()
app.postinit()
@ -188,6 +203,7 @@ __all__ = [
'AccountV2Subsystem',
'ActivityNotFoundError',
'ActorNotFoundError',
'allows_ticket_sales',
'add_clean_frame_callback',
'android_get_external_files_dir',
'app',
@ -199,6 +215,8 @@ __all__ = [
'AppIntentDefault',
'AppIntentExec',
'AppMode',
'app_instance_uuid',
'applog',
'appname',
'appnameupper',
'AppModeSelector',
@ -209,6 +227,7 @@ __all__ = [
'apptimer',
'AppTimer',
'asset_loads_allowed',
'balog',
'Call',
'fullscreen_control_available',
'fullscreen_control_get',
@ -218,6 +237,7 @@ __all__ = [
'clipboard_get_text',
'clipboard_has_text',
'clipboard_is_supported',
'CloudSubscription',
'clipboard_set_text',
'commit_app_config',
'ContextCall',
@ -250,8 +270,11 @@ __all__ = [
'get_replays_dir',
'get_string_height',
'get_string_width',
'get_v1_cloud_log_file_path',
'get_type_name',
'get_ui_scale',
'get_virtual_safe_area_size',
'get_virtual_screen_size',
'get_v1_cloud_log_file_path',
'getclass',
'getsimplesound',
'handle_leftover_v1_cloud_log_file',
@ -259,6 +282,7 @@ __all__ = [
'have_chars',
'have_permission',
'in_logic_thread',
'in_main_menu',
'increment_analytics_count',
'InputDeviceNotFoundError',
'InputType',
@ -269,6 +293,7 @@ __all__ = [
'is_point_in_box',
'is_xcode_build',
'LanguageSubsystem',
'lifecyclelog',
'lock_all_input',
'LoginAdapter',
'LoginInfo',
@ -305,6 +330,7 @@ __all__ = [
'print_error',
'print_exception',
'print_load_info',
'push_back_press',
'pushcall',
'quit',
'QuitType',
@ -318,7 +344,9 @@ __all__ = [
'set_analytics_screen',
'set_low_level_config_value',
'set_thread_name',
'set_ui_account_state',
'set_ui_input_device',
'set_ui_scale',
'show_progress_bar',
'shutdown_suppress_begin',
'shutdown_suppress_end',
@ -330,11 +358,15 @@ __all__ = [
'StringEditSubsystem',
'supports_max_fps',
'supports_vsync',
'supports_unicode_display',
'TeamNotFoundError',
'timestring',
'UIScale',
'unlock_all_input',
'update_internal_logger_levels',
'user_agent_string',
'user_ran_commands',
'utc_now_cloud',
'utf8_all',
'Vec3',
'vec3validate',

View file

@ -10,16 +10,16 @@ from functools import partial
from typing import TYPE_CHECKING, assert_never
from efro.error import CommunicationError
from efro.call import CallbackSet
from bacommon.login import LoginType
import _babase
if TYPE_CHECKING:
from typing import Any
from typing import Any, Callable
from babase._login import LoginAdapter, LoginInfo
DEBUG_LOG = False
logger = logging.getLogger('ba.accountv2')
class AccountV2Subsystem:
@ -31,10 +31,22 @@ class AccountV2Subsystem:
"""
def __init__(self) -> None:
assert _babase.in_logic_thread()
from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter
# Whether or not everything related to an initial login
# (or lack thereof) has completed. This includes things like
# Register to be informed when connectivity changes.
plus = _babase.app.plus
self._connectivity_changed_cb = (
None
if plus is None
else plus.cloud.on_connectivity_changed_callbacks.register(
self._on_cloud_connectivity_changed
)
)
# Whether or not everything related to an initial sign in (or
# lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app
# into 'running' state.
self._initial_sign_in_completed = False
@ -46,6 +58,9 @@ class AccountV2Subsystem:
self._implicit_signed_in_adapter: LoginAdapter | None = None
self._implicit_state_changed = False
self._can_do_auto_sign_in = True
self.on_primary_account_changed_callbacks: CallbackSet[
Callable[[AccountV2Handle | None], None]
] = CallbackSet()
adapter: LoginAdapter
if _babase.using_google_play_game_services():
@ -64,11 +79,11 @@ class AccountV2Subsystem:
def have_primary_credentials(self) -> bool:
"""Are credentials currently set for the primary app account?
Note that this does not mean these credentials are currently valid;
only that they exist. If/when credentials are validated, the 'primary'
account handle will be set.
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.
"""
raise NotImplementedError('This should be overridden.')
raise NotImplementedError()
@property
def primary(self) -> AccountV2Handle | None:
@ -85,6 +100,13 @@ class AccountV2Subsystem:
"""
assert _babase.in_logic_thread()
# Fire any registered callbacks.
for call in self.on_primary_account_changed_callbacks.getcalls():
try:
call(account)
except Exception:
logging.exception('Error in primary-account-changed callback.')
# Currently don't do anything special on sign-outs.
if account is None:
return
@ -105,9 +127,9 @@ class AccountV2Subsystem:
on_completed=self._on_set_active_workspace_completed,
)
else:
# Don't activate workspaces if we've already told the game
# that initial-log-in is done or if we've already kicked
# off a workspace load.
# Don't activate workspaces if we've already told the
# game that initial-log-in is done or if we've already
# kicked off a workspace load.
_babase.screenmessage(
f'\'{account.workspacename}\''
f' will be activated at next app launch.',
@ -242,19 +264,18 @@ class AccountV2Subsystem:
# generally this means the user has explicitly signed in/out or
# switched accounts within that back-end.
if prev_state != new_state:
if DEBUG_LOG:
logging.debug(
'AccountV2: Implicit state changed (%s -> %s);'
' will update app sign-in state accordingly.',
prev_state,
new_state,
)
logger.debug(
'Implicit state changed (%s -> %s);'
' will update app sign-in state accordingly.',
prev_state,
new_state,
)
self._implicit_state_changed = True
# We may want to auto-sign-in based on this new state.
self._update_auto_sign_in()
def on_cloud_connectivity_changed(self, connected: bool) -> None:
def _on_cloud_connectivity_changed(self, connected: bool) -> None:
"""Should be called with cloud connectivity changes."""
del connected # Unused.
assert _babase.in_logic_thread()
@ -264,11 +285,11 @@ class AccountV2Subsystem:
def do_get_primary(self) -> AccountV2Handle | None:
"""Internal - should be overridden by subclass."""
raise NotImplementedError('This should be overridden.')
raise NotImplementedError()
def set_primary_credentials(self, credentials: str | None) -> None:
"""Set credentials for the primary app account."""
raise NotImplementedError('This should be overridden.')
raise NotImplementedError()
def _update_auto_sign_in(self) -> None:
plus = _babase.app.plus
@ -279,11 +300,9 @@ class AccountV2Subsystem:
if self._implicit_signed_in_adapter is None:
# If implicit back-end has signed out, we follow suit
# immediately; no need to wait for network connectivity.
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing out as result'
' of implicit state change...',
)
logger.debug(
'Signing out as result of implicit state change...',
)
plus.accounts.set_primary_credentials(None)
self._implicit_state_changed = False
@ -300,11 +319,9 @@ class AccountV2Subsystem:
# switching accounts via the back-end). NOTE: should
# test case where we don't have connectivity here.
if plus.cloud.is_connected():
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in as result'
' of implicit state change...',
)
logger.debug(
'Signing in as result of implicit state change...',
)
self._implicit_signed_in_adapter.sign_in(
self._on_explicit_sign_in_completed,
description='implicit state change',
@ -335,10 +352,9 @@ class AccountV2Subsystem:
and not signed_in_v2
and self._implicit_signed_in_adapter is not None
):
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in due to on-launch-auto-sign-in...',
)
logger.debug(
'Signing in due to on-launch-auto-sign-in...',
)
self._can_do_auto_sign_in = False # Only ATTEMPT once
self._implicit_signed_in_adapter.sign_in(
self._on_implicit_sign_in_completed, description='auto-sign-in'

View file

@ -9,9 +9,10 @@ import logging
from enum import Enum
from functools import partial
from typing import TYPE_CHECKING, TypeVar, override
from concurrent.futures import ThreadPoolExecutor
from threading import RLock
from efro.threadpool import ThreadPoolExecutorPlus
import _babase
from babase._language import LanguageSubsystem
from babase._plugin import PluginSubsystem
@ -23,6 +24,8 @@ from babase._appmodeselector import AppModeSelector
from babase._appintent import AppIntentDefault, AppIntentExec
from babase._stringedit import StringEditSubsystem
from babase._devconsole import DevConsoleSubsystem
from babase._appconfig import AppConfig
from babase._logging import lifecyclelog, applog
if TYPE_CHECKING:
import asyncio
@ -36,9 +39,9 @@ if TYPE_CHECKING:
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
# This section generated by batools.appmodule; do not edit.
from baclassic import ClassicSubsystem
from baplus import PlusSubsystem
from bauiv1 import UIV1Subsystem
from baclassic import ClassicAppSubsystem
from baplus import PlusAppSubsystem
from bauiv1 import UIV1AppSubsystem
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__
@ -65,9 +68,10 @@ class App:
health_monitor: AppHealthMonitor
# How long we allow shutdown tasks to run before killing them.
# Currently the entire app hard-exits if shutdown takes 10 seconds,
# so we need to keep it under that.
SHUTDOWN_TASK_TIMEOUT_SECONDS = 5
# 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."""
@ -137,11 +141,11 @@ class App:
# Ask our default app modes to handle it.
# (generated from 'default_app_modes' in projectconfig).
import bascenev1
import baclassic
import babase
for appmode in [
bascenev1.SceneV1AppMode,
baclassic.ClassicAppMode,
babase.EmptyAppMode,
]:
if appmode.can_handle_intent(intent):
@ -164,6 +168,11 @@ 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())
_babase.set_app_config(self.config)
self.env: babase.Env = _babase.Env()
self.state = self.State.NOT_STARTED
@ -171,7 +180,7 @@ class App:
# 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 = ThreadPoolExecutor(
self.threadpool = ThreadPoolExecutorPlus(
thread_name_prefix='baworker',
initializer=self._thread_pool_thread_init,
)
@ -205,11 +214,11 @@ class App:
self._asyncio_loop: asyncio.AbstractEventLoop | None = None
self._asyncio_tasks: set[asyncio.Task] = set()
self._asyncio_timer: babase.AppTimer | None = None
self._config: babase.AppConfig | None = None
self._pending_intent: AppIntent | None = None
self._intent: AppIntent | None = None
self._mode: AppMode | None = None
self._mode_selector: babase.AppModeSelector | None = None
self._mode_instances: dict[type[AppMode], AppMode] = {}
self._mode: AppMode | None = None
self._shutdown_task: asyncio.Task[None] | None = None
self._shutdown_tasks: list[Coroutine[None, None, None]] = [
self._wait_for_shutdown_suppressions(),
@ -250,6 +259,12 @@ class App:
"""
return _babase.app_is_active()
@property
def mode(self) -> AppMode | None:
"""The app's current mode."""
assert _babase.in_logic_thread()
return self._mode
@property
def asyncio_loop(self) -> asyncio.AbstractEventLoop:
"""The logic thread's asyncio event loop.
@ -289,7 +304,7 @@ class App:
"""
assert _babase.in_logic_thread()
# Hold a strong reference to the task until it is done.
# We hold a strong reference to the task until it is done.
# Otherwise it is possible for it to be garbage collected and
# disappear midway if the caller does not hold on to the
# returned task, which seems like a great way to introduce
@ -311,12 +326,6 @@ class App:
self._asyncio_tasks.remove(task)
@property
def config(self) -> babase.AppConfig:
"""The babase.AppConfig instance representing the app's config state."""
assert self._config is not None
return self._config
@property
def mode_selector(self) -> babase.AppModeSelector:
"""Controls which app-modes are used for handling given intents.
@ -387,19 +396,19 @@ class App:
# This section generated by batools.appmodule; do not edit.
@property
def classic(self) -> ClassicSubsystem | None:
def classic(self) -> ClassicAppSubsystem | None:
"""Our classic subsystem (if available)."""
return self._get_subsystem_property(
'classic', self._create_classic_subsystem
) # type: ignore
@staticmethod
def _create_classic_subsystem() -> ClassicSubsystem | None:
def _create_classic_subsystem() -> ClassicAppSubsystem | None:
# pylint: disable=cyclic-import
try:
from baclassic import ClassicSubsystem
from baclassic import ClassicAppSubsystem
return ClassicSubsystem()
return ClassicAppSubsystem()
except ImportError:
return None
except Exception:
@ -407,19 +416,19 @@ class App:
return None
@property
def plus(self) -> PlusSubsystem | None:
def plus(self) -> PlusAppSubsystem | None:
"""Our plus subsystem (if available)."""
return self._get_subsystem_property(
'plus', self._create_plus_subsystem
) # type: ignore
@staticmethod
def _create_plus_subsystem() -> PlusSubsystem | None:
def _create_plus_subsystem() -> PlusAppSubsystem | None:
# pylint: disable=cyclic-import
try:
from baplus import PlusSubsystem
from baplus import PlusAppSubsystem
return PlusSubsystem()
return PlusAppSubsystem()
except ImportError:
return None
except Exception:
@ -427,19 +436,19 @@ class App:
return None
@property
def ui_v1(self) -> UIV1Subsystem:
def ui_v1(self) -> UIV1AppSubsystem:
"""Our ui_v1 subsystem (always available)."""
return self._get_subsystem_property(
'ui_v1', self._create_ui_v1_subsystem
) # type: ignore
@staticmethod
def _create_ui_v1_subsystem() -> UIV1Subsystem:
def _create_ui_v1_subsystem() -> UIV1AppSubsystem:
# pylint: disable=cyclic-import
from bauiv1 import UIV1Subsystem
from bauiv1 import UIV1AppSubsystem
return UIV1Subsystem()
return UIV1AppSubsystem()
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
@ -481,18 +490,6 @@ class App:
"""
_babase.run_app()
def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
"""Submit a call to the app threadpool where result is not needed.
Normally, doing work in a thread-pool involves creating a future
and waiting for its result, which is an important step because it
propagates any Exceptions raised by the submitted work. When the
result in not important, however, this call can be used. The app
will log any exceptions that occur.
"""
fut = self.threadpool.submit(call)
fut.add_done_callback(self._threadpool_no_wait_done)
def set_intent(self, intent: AppIntent) -> None:
"""Set the intent for the app.
@ -510,7 +507,7 @@ class App:
# Do the actual work of calcing our app-mode/etc. in a bg thread
# since it may block for a moment to load modules/etc.
self.threadpool_submit_no_wait(partial(self._set_intent, intent))
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."""
@ -566,12 +563,6 @@ class App:
if self._mode is not None:
self._mode.on_app_active_changed()
def read_config(self) -> None:
"""(internal)"""
from babase._appconfig import read_app_config
self._config = read_app_config()
def handle_deep_link(self, url: str) -> None:
"""Handle a deep link URL."""
from babase._language import Lstr
@ -610,18 +601,71 @@ class App:
self._initial_sign_in_completed = True
self._update_state()
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.
"""
assert _babase.in_logic_thread()
# Apply to the native layer.
_babase.set_ui_scale(scale.name.lower())
# Inform all subsystems that something screen-related has
# changed. We assume subsystems won't be added at this point so
# we can use the list directly.
assert self._subsystem_registration_ended
for subsystem in self._subsystems:
try:
subsystem.on_ui_scale_change()
except Exception:
logging.exception(
'Error in on_ui_scale_change() for subsystem %s.', subsystem
)
def on_screen_size_change(self) -> None:
"""Screen size has changed."""
# Inform all app subsystems in the same order they were inited.
# Operate on a copy of the list here because this can be called
# while subsystems are still being added.
for subsystem in self._subsystems.copy():
try:
subsystem.on_screen_size_change()
except Exception:
logging.exception(
'Error in on_screen_size_change() for subsystem %s.',
subsystem,
)
def _set_intent(self, intent: AppIntent) -> None:
from babase._appmode import AppMode
# This should be happening in a bg thread.
assert not _babase.in_logic_thread()
try:
# Ask the selector what app-mode to use for this intent.
if self.mode_selector is None:
raise RuntimeError('No AppModeSelector set.')
modetype = self.mode_selector.app_mode_for_intent(intent)
# NOTE: Since intents are somewhat high level things, should
# we do some universal thing like a screenmessage saying
# 'The app cannot handle that request' on failure?
modetype: type[AppMode] | None
# Special case - for testing we may force a specific
# app-mode to handle this intent instead of going through our
# usual selector.
forced_mode_type = getattr(intent, '_force_app_mode_handler', None)
if isinstance(forced_mode_type, type) and issubclass(
forced_mode_type, AppMode
):
modetype = forced_mode_type
else:
modetype = self.mode_selector.app_mode_for_intent(intent)
# NOTE: Since intents are somewhat high level things,
# perhaps we should do some universal thing like a
# screenmessage saying 'The app cannot handle the request'
# on failure.
if modetype is None:
raise RuntimeError(
@ -640,7 +684,9 @@ class App:
# Ok; seems legit. Now instantiate the mode if necessary and
# kick back to the logic thread to apply.
mode = modetype()
mode = self._mode_instances.get(modetype)
if mode is None:
self._mode_instances[modetype] = mode = modetype()
_babase.pushcall(
partial(self._apply_intent, intent, mode),
from_other_thread=True,
@ -661,7 +707,7 @@ class App:
return
# If the app-mode for this intent is different than the active
# one, switch.
# one, switch modes.
if type(mode) is not type(self._mode):
if self._mode is None:
is_initial_mode = True
@ -673,6 +719,18 @@ class App:
logging.exception(
'Error deactivating app-mode %s.', self._mode
)
# Reset all subsystems. We assume subsystems won't be added
# at this point so we can use the list directly.
assert self._subsystem_registration_ended
for subsystem in self._subsystems:
try:
subsystem.reset()
except Exception:
logging.exception(
'Error in reset() for subsystem %s.', subsystem
)
self._mode = mode
try:
mode.on_activate()
@ -750,14 +808,14 @@ class App:
self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete)
# Inform all app subsystems in the same order they were inited.
# Operate on a copy here because subsystems can still be added
# at this point.
# Operate on a copy of the list here because subsystems can
# still be added at this point.
for subsystem in self._subsystems.copy():
try:
subsystem.on_app_loading()
except Exception:
logging.exception(
'Error in on_app_loading for subsystem %s.', subsystem
'Error in on_app_loading() for subsystem %s.', subsystem
)
# Normally plus tells us when initial sign-in is done. If plus
@ -806,7 +864,7 @@ class App:
subsystem.on_app_running()
except Exception:
logging.exception(
'Error in on_app_running for subsystem %s.', subsystem
'Error in on_app_running() for subsystem %s.', subsystem
)
# Cut off new subsystem additions at this point.
@ -825,7 +883,7 @@ class App:
def _apply_app_config(self) -> None:
assert _babase.in_logic_thread()
_babase.lifecyclelog('apply-app-config')
lifecyclelog.info('apply-app-config')
# If multiple apply calls have been made, only actually apply
# once.
@ -842,7 +900,8 @@ class App:
subsystem.do_apply_app_config()
except Exception:
logging.exception(
'Error in do_apply_app_config for subsystem %s.', subsystem
'Error in do_apply_app_config() for subsystem %s.',
subsystem,
)
# Let the native layer do its thing.
@ -856,7 +915,7 @@ class App:
if self._native_shutdown_complete_called:
if self.state is not self.State.SHUTDOWN_COMPLETE:
self.state = self.State.SHUTDOWN_COMPLETE
_babase.lifecyclelog('app state shutdown complete')
lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutdown_complete()
# Shutdown trumps all. Though we can't start shutting down until
@ -866,7 +925,8 @@ class App:
# Entering shutdown state:
if self.state is not self.State.SHUTTING_DOWN:
self.state = self.State.SHUTTING_DOWN
_babase.lifecyclelog('app state shutting down')
applog.info('Shutting down...')
lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutting_down()
elif self._native_suspended:
@ -883,15 +943,16 @@ class App:
if self._initial_sign_in_completed and self._meta_scan_completed:
if self.state != self.State.RUNNING:
self.state = self.State.RUNNING
_babase.lifecyclelog('app state running')
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_running:
self._called_on_running = True
self._on_running()
# Entering or returning to loading state:
elif self._init_completed:
if self.state is not self.State.LOADING:
self.state = self.State.LOADING
_babase.lifecyclelog('app state loading')
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_loading:
self._called_on_loading = True
self._on_loading()
@ -900,7 +961,7 @@ class App:
elif self._native_bootstrapping_completed:
if self.state is not self.State.INITING:
self.state = self.State.INITING
_babase.lifecyclelog('app state initing')
lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_initing:
self._called_on_initing = True
self._on_initing()
@ -909,7 +970,7 @@ class App:
elif self._native_start_called:
if self.state is not self.State.NATIVE_BOOTSTRAPPING:
self.state = self.State.NATIVE_BOOTSTRAPPING
_babase.lifecyclelog('app state native bootstrapping')
lifecyclelog.info('app-state is now %s', self.state.name)
else:
# Only logical possibility left is NOT_STARTED, in which
# case we should not be getting called.
@ -965,7 +1026,7 @@ class App:
subsystem.on_app_suspend()
except Exception:
logging.exception(
'Error in on_app_suspend for subsystem %s.', subsystem
'Error in on_app_suspend() for subsystem %s.', subsystem
)
def _on_unsuspend(self) -> None:
@ -979,7 +1040,7 @@ class App:
subsystem.on_app_unsuspend()
except Exception:
logging.exception(
'Error in on_app_unsuspend for subsystem %s.', subsystem
'Error in on_app_unsuspend() for subsystem %s.', subsystem
)
def _on_shutting_down(self) -> None:
@ -993,7 +1054,7 @@ class App:
subsystem.on_app_shutdown()
except Exception:
logging.exception(
'Error in on_app_shutdown for subsystem %s.', subsystem
'Error in on_app_shutdown() for subsystem %s.', subsystem
)
# Now kick off any async shutdown task(s).
@ -1011,7 +1072,7 @@ class App:
subsystem.on_app_shutdown_complete()
except Exception:
logging.exception(
'Error in on_app_shutdown_complete for subsystem %s.',
'Error in on_app_shutdown_complete() for subsystem %s.',
subsystem,
)
@ -1020,10 +1081,10 @@ class App:
# Spin and wait for anything blocking shutdown to complete.
starttime = _babase.apptime()
_babase.lifecyclelog('shutdown-suppress wait begin')
lifecyclelog.info('shutdown-suppress-wait begin')
while _babase.shutdown_suppress_count() > 0:
await asyncio.sleep(0.001)
_babase.lifecyclelog('shutdown-suppress wait end')
lifecyclelog.info('shutdown-suppress-wait end')
duration = _babase.apptime() - starttime
if duration > 1.0:
logging.warning(
@ -1036,7 +1097,7 @@ class App:
import asyncio
# Kick off a short fade and give it time to complete.
_babase.lifecyclelog('fade-and-shutdown-graphics begin')
lifecyclelog.info('fade-and-shutdown-graphics begin')
_babase.fade_screen(False, time=0.15)
await asyncio.sleep(0.15)
@ -1045,27 +1106,19 @@ class App:
_babase.graphics_shutdown_begin()
while not _babase.graphics_shutdown_is_complete():
await asyncio.sleep(0.01)
_babase.lifecyclelog('fade-and-shutdown-graphics end')
lifecyclelog.info('fade-and-shutdown-graphics end')
async def _fade_and_shutdown_audio(self) -> None:
import asyncio
# Tell the audio system to go down and give it a bit of
# time to do so gracefully.
_babase.lifecyclelog('fade-and-shutdown-audio begin')
lifecyclelog.info('fade-and-shutdown-audio begin')
_babase.audio_shutdown_begin()
await asyncio.sleep(0.15)
while not _babase.audio_shutdown_is_complete():
await asyncio.sleep(0.01)
_babase.lifecyclelog('fade-and-shutdown-audio end')
def _threadpool_no_wait_done(self, fut: Future) -> None:
try:
fut.result()
except Exception:
logging.exception(
'Error in work submitted via threadpool_submit_no_wait()'
)
lifecyclelog.info('fade-and-shutdown-audio end')
def _thread_pool_thread_init(self) -> None:
# Help keep things clear in profiling tools/etc.

View file

@ -3,7 +3,6 @@
"""Provides the AppConfig class."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import _babase
@ -101,43 +100,6 @@ class AppConfig(dict):
self.commit()
def read_app_config() -> AppConfig:
"""Read the app config."""
import os
import json
# NOTE: it is assumed that this only gets called once and the config
# object will not change from here on out
config_file_path = _babase.app.env.config_file_path
config_contents = ''
try:
if os.path.exists(config_file_path):
with open(config_file_path, encoding='utf-8') as infile:
config_contents = infile.read()
config = AppConfig(json.loads(config_contents))
else:
config = AppConfig()
except Exception:
logging.exception(
"Error reading config file '%s' at time %.3f.\n"
"Backing up broken config to'%s.broken'.",
config_file_path,
_babase.apptime(),
config_file_path,
)
try:
import shutil
shutil.copyfile(config_file_path, config_file_path + '.broken')
except Exception:
logging.exception('Error copying broken config.')
config = AppConfig()
return config
def commit_app_config() -> None:
"""Commit the config to persistent storage.
@ -145,6 +107,7 @@ def commit_app_config() -> None:
(internal)
"""
# FIXME - this should not require plus.
plus = _babase.app.plus
assert plus is not None

View file

@ -27,20 +27,22 @@ class AppMode:
"""Return whether this mode can handle the provided intent.
For this to return True, the AppMode must claim to support the
provided intent (via its _supports_intent() method) AND the
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.
"""
# FIXME: check AppExperience.
return cls._supports_intent(intent)
# TODO: check AppExperience against current environment.
return cls._can_handle_intent(intent)
@classmethod
def _supports_intent(cls, intent: AppIntent) -> bool:
def _can_handle_intent(cls, intent: AppIntent) -> bool:
"""Return whether our mode can handle the provided intent.
AppModes should override this to define what they can handle.
Note that AppExperience does not have to be considered here; that
is handled automatically by the can_handle_intent() call."""
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.
"""
raise NotImplementedError('AppMode subclasses must override this.')
def handle_intent(self, intent: AppIntent) -> None:
@ -54,7 +56,7 @@ class AppMode:
"""Called when the mode is being deactivated."""
def on_app_active_changed(self) -> None:
"""Called when babase.app.active changes.
"""Called when ba*.app.active changes while this mode is active.
The app-mode may want to take action such as pausing a running
game in such cases.

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class AppModeSelector:
"""Defines which AppModes to use to handle given AppIntents.
"""Defines which AppModes are available or used to handle given AppIntents.
Category: **App Classes**
@ -29,4 +29,4 @@ class AppModeSelector:
This may be called in a background thread, so avoid any calls
limited to logic thread use/etc.
"""
raise NotImplementedError('app_mode_for_intent() should be overridden.')
raise NotImplementedError()

View file

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
pass
from babase import UIScale
class AppSubsystem:
@ -53,3 +53,22 @@ class AppSubsystem:
def do_apply_app_config(self) -> None:
"""Called when the app config should be applied."""
def on_ui_scale_change(self) -> None:
"""Called when screen ui-scale changes.
Will not be called for the initial ui scale.
"""
def on_screen_size_change(self) -> None:
"""Called when the screen size changes.
Will not be called for the initial screen size.
"""
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.
"""

View file

@ -11,25 +11,38 @@ from functools import partial
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
from efro.log import LogLevel
from efro.util import utc_now
from efro.logging import LogLevel
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
import datetime
from typing import Any, TextIO, Callable
import babase
def utc_now_cloud() -> datetime.datetime:
"""Returns estimated utc time regardless of local clock settings.
Applies offsets pulled from server communication/etc.
"""
# TODO: wire this up. Just using local time for now. Make sure that
# BaseFeatureSet::TimeSinceEpochCloudSeconds() and this are synced
# up.
return utc_now()
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.show_url()
with any lengthy addresses. (ba.show_url() will display an address
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.)
"""
@ -115,7 +128,7 @@ def handle_v1_cloud_log() -> None:
'userRanCommands': _babase.has_user_run_commands(),
'time': _babase.apptime(),
'userModded': _babase.workspaces_in_use(),
'newsShow': plus.get_news_show(),
'newsShow': plus.get_classic_news_show(),
}
def response(data: Any) -> None:

View file

@ -1,197 +1,26 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the cloud."""
"""Cloud related functionality."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, overload
from typing import TYPE_CHECKING
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Callable, Any
from efro.message import Message, Response
import bacommon.cloud
DEBUG_LOG = False
# TODO: Should make it possible to define a protocol in bacommon.cloud and
# autogenerate this. That would give us type safety between this and
# internal protocols.
pass
class CloudSubsystem(AppSubsystem):
"""Manages communication with cloud components."""
class CloudSubscription:
"""User handle to a subscription to some cloud data.
@property
def connected(self) -> bool:
"""Property equivalent of CloudSubsystem.is_connected()."""
return self.is_connected()
Do not instantiate these directly; use the subscribe methods
in *.app.plus.cloud to create them.
"""
def is_connected(self) -> bool:
"""Return whether a connection to the cloud is present.
def __init__(self, subscription_id: int) -> None:
self._subscription_id = subscription_id
This is a good indicator (though not for certain) that sending
messages will succeed.
"""
return False # Needs to be overridden
def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes."""
if DEBUG_LOG:
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
plus = _babase.app.plus
assert plus is not None
# Inform things that use this.
# (TODO: should generalize this into some sort of registration system)
plus.accounts.on_cloud_connectivity_changed(connected)
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyRequestMessage,
on_response: Callable[
[bacommon.cloud.LoginProxyRequestResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyStateQueryMessage,
on_response: Callable[
[bacommon.cloud.LoginProxyStateQueryResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyCompleteMessage,
on_response: Callable[[None | Exception], None],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.PingMessage,
on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.SignInMessage,
on_response: Callable[
[bacommon.cloud.SignInResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.ManageAccountMessage,
on_response: Callable[
[bacommon.cloud.ManageAccountResponse | Exception], None
],
) -> None:
...
def send_message_cb(
self,
msg: Message,
on_response: Callable[[Any], None],
) -> None:
"""Asynchronously send a message to the cloud from 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.
"""
from babase._general import Call
del msg # Unused.
_babase.pushcall(
Call(
on_response,
RuntimeError('Cloud functionality is not available.'),
)
)
@overload
def send_message(
self, msg: bacommon.cloud.WorkspaceFetchMessage
) -> bacommon.cloud.WorkspaceFetchResponse:
...
@overload
def send_message(
self, msg: bacommon.cloud.MerchAvailabilityMessage
) -> bacommon.cloud.MerchAvailabilityResponse:
...
@overload
def send_message(
self, msg: bacommon.cloud.TestMessage
) -> bacommon.cloud.TestResponse:
...
def send_message(self, msg: Message) -> Response | None:
"""Synchronously send a message to the cloud.
Must be called from a background thread.
"""
raise RuntimeError('Cloud functionality is not available.')
def cloud_console_exec(code: str) -> None:
"""Called by the cloud console to run code in the logic thread."""
import sys
import __main__
try:
# First try it as eval.
try:
evalcode = compile(code, '<console>', 'eval')
except SyntaxError:
evalcode = None
except Exception:
# hmm; when we can't compile it as eval will we always get
# syntax error?
logging.exception(
'unexpected error compiling code for cloud-console eval.'
)
evalcode = None
if evalcode is not None:
# pylint: disable=eval-used
value = eval(evalcode, vars(__main__), vars(__main__))
# For eval-able statements, print the resulting value if
# it is not None (just like standard Python interpreter).
if value is not None:
print(repr(value), file=sys.stderr)
# Fall back to exec if we couldn't compile it as eval.
else:
execcode = compile(code, '<console>', 'exec')
# pylint: disable=exec-used
exec(execcode, vars(__main__), vars(__main__))
except Exception:
import traceback
apptime = _babase.apptime()
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
traceback.print_exc()
# This helps the logging system ship stderr back to the
# cloud promptly.
sys.stderr.flush()
def __del__(self) -> None:
if _babase.app.plus is not None:
_babase.app.plus.cloud.unsubscribe(self._subscription_id)

View file

@ -4,9 +4,9 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING, override
from dataclasses import dataclass
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
import _babase
@ -30,10 +30,27 @@ class DevConsoleTab:
pos: tuple[float, float],
size: tuple[float, float],
call: Callable[[], Any] | None = None,
*,
h_anchor: Literal['left', 'center', 'right'] = 'center',
label_scale: float = 1.0,
corner_radius: float = 8.0,
style: Literal['normal', 'dark'] = 'normal',
style: Literal[
'normal',
'bright',
'red',
'red_bright',
'purple',
'purple_bright',
'yellow',
'yellow_bright',
'blue',
'blue_bright',
'white',
'white_bright',
'black',
'black_bright',
] = 'normal',
disabled: bool = False,
) -> None:
"""Add a button to the tab being refreshed."""
assert _babase.app.devconsole.is_refreshing
@ -48,12 +65,14 @@ class DevConsoleTab:
label_scale,
corner_radius,
style,
disabled,
)
def text(
self,
text: str,
pos: tuple[float, float],
*,
h_anchor: Literal['left', 'center', 'right'] = 'center',
h_align: Literal['left', 'center', 'right'] = 'center',
v_align: Literal['top', 'center', 'bottom', 'none'] = 'center',
@ -93,47 +112,6 @@ class DevConsoleTab:
return _babase.dev_console_base_scale()
class DevConsoleTabPython(DevConsoleTab):
"""The Python dev-console tab."""
@override
def refresh(self) -> None:
self.python_terminal()
class DevConsoleTabTest(DevConsoleTab):
"""Test dev-console tab."""
@override
def refresh(self) -> None:
import random
self.button(
f'FLOOP-{random.randrange(200)}',
pos=(10, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
call=self.request_refresh,
)
self.button(
f'FLOOP2-{random.randrange(200)}',
pos=(120, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
style='dark',
)
self.text(
'TestText',
scale=0.8,
pos=(15, 50),
h_anchor='left',
h_align='left',
v_align='none',
)
@dataclass
class DevConsoleTabEntry:
"""Represents a distinct tab in the dev-console."""
@ -154,26 +132,50 @@ class DevConsoleSubsystem:
"""
def __init__(self) -> None:
# pylint: disable=cyclic-import
from babase._devconsoletabs import (
DevConsoleTabPython,
DevConsoleTabAppModes,
DevConsoleTabUI,
DevConsoleTabLogging,
DevConsoleTabTest,
)
# All tabs in the dev-console. Add your own stuff here via
# plugins or whatnot.
self.tabs: list[DevConsoleTabEntry] = [
DevConsoleTabEntry('Python', DevConsoleTabPython)
DevConsoleTabEntry('Python', DevConsoleTabPython),
DevConsoleTabEntry('AppModes', DevConsoleTabAppModes),
DevConsoleTabEntry('UI', DevConsoleTabUI),
DevConsoleTabEntry('Logging', DevConsoleTabLogging),
]
if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1':
self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest))
self.is_refreshing = False
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."""
assert _babase.in_logic_thread()
# FIXME: We currently won't handle multiple tabs with the same
# name. We should give a clean error or something in that case.
tab: DevConsoleTab | None = None
for tabentry in self.tabs:
if tabentry.name == tabname:
tab = tabentry.factory()
break
# Make noise if we have repeating tab names, as that breaks our
# logic.
if __debug__:
alltabnames = set[str](tabentry.name for tabentry in self.tabs)
if len(alltabnames) != len(self.tabs):
logging.error(
'Duplicate dev-console tab names found;'
' tabs may behave unpredictably.'
)
tab: DevConsoleTab | None = self._tab_instances.get(tabname)
# If we haven't instantiated this tab yet, do so.
if tab is None:
for tabentry in self.tabs:
if tabentry.name == tabname:
tab = self._tab_instances[tabname] = tabentry.factory()
break
if tab is None:
logging.error(

View file

@ -0,0 +1,671 @@
# Released under the MIT License. See LICENSE for details.
#
"""Predefined tabs for the dev console."""
from __future__ import annotations
import math
import random
import logging
from functools import partial
from typing import TYPE_CHECKING, override, TypeVar, Generic
import _babase
from babase._devconsole import DevConsoleTab
if TYPE_CHECKING:
from typing import Callable, Literal
from bacommon.loggercontrol import LoggerControlConfig
from babase import AppMode
T = TypeVar('T')
class DevConsoleTabPython(DevConsoleTab):
"""The Python dev-console tab."""
@override
def refresh(self) -> None:
self.python_terminal()
class DevConsoleTabAppModes(DevConsoleTab):
"""Tab to switch app modes."""
def __init__(self) -> None:
self._app_modes: list[type[AppMode]] | None = None
self._app_modes_loading = False
def _on_app_modes_loaded(self, modes: list[type[AppMode]]) -> None:
from babase._appintent import AppIntentDefault
intent = AppIntentDefault()
# Limit to modes that can handle default intents since that's
# what we use.
self._app_modes = [
mode for mode in modes if mode.can_handle_intent(intent)
]
self.request_refresh()
@override
def refresh(self) -> None:
from babase import AppMode
# Kick off a load if applicable.
if self._app_modes is None and not self._app_modes_loading:
_babase.app.meta.load_exported_classes(
AppMode, self._on_app_modes_loaded
)
# Just say 'loading' if we don't have app-modes yet.
if self._app_modes is None:
self.text(
'Loading...', pos=(0, 30), h_anchor='center', h_align='center'
)
return
bwidth = 260
bpadding = 5
xoffs = -0.5 * bwidth * len(self._app_modes)
self.text(
'Available AppModes:',
scale=0.8,
pos=(0, 75),
h_align='center',
v_align='center',
)
# pylint: disable=protected-access
for i, mode in enumerate(self._app_modes):
self.button(
f'{mode.__module__}.{mode.__qualname__}',
pos=(xoffs + i * bwidth + bpadding, 10),
size=(bwidth - 2.0 * bpadding, 40),
label_scale=0.6,
call=partial(self._set_app_mode, mode),
style=(
'bright'
if isinstance(_babase.app._mode, mode)
else 'normal'
),
)
def _set_app_mode(self, mode: type[AppMode]) -> None:
from babase._appintent import AppIntentDefault
intent = AppIntentDefault()
# Use private functionality to force a specific app-mode to
# handle this intent. Note that this should never be done
# outside of this explicit testing case. It is the app's job to
# determine which app-mode should be used to handle a given
# intent.
setattr(intent, '_force_app_mode_handler', mode)
_babase.app.set_intent(intent)
# Slight hackish: need to wait a moment before refreshing to
# pick up the newly current mode, as mode switches are
# asynchronous.
_babase.apptimer(0.1, self.request_refresh)
class DevConsoleTabUI(DevConsoleTab):
"""Tab to debug/test UI stuff."""
@override
def refresh(self) -> None:
from babase._mgen.enums import UIScale
xoffs = -375
self.text(
'Make sure all UIs either fit in the virtual safe area'
' or dynamically respond to screen size changes.',
scale=0.6,
pos=(xoffs + 15, 70),
h_align='left',
v_align='center',
)
ui_overlay = _babase.get_draw_virtual_safe_area_bounds()
self.button(
'Virtual Safe Area ON' if ui_overlay else 'Virtual Safe Area OFF',
pos=(xoffs + 10, 10),
size=(200, 30),
label_scale=0.6,
call=self.toggle_ui_overlay,
style='bright' if ui_overlay else 'normal',
)
x = 300
self.text(
'UI-Scale',
pos=(xoffs + x - 5, 15),
h_align='right',
v_align='none',
scale=0.6,
)
bwidth = 100
for scale in UIScale:
self.button(
scale.name.capitalize(),
pos=(xoffs + x, 10),
size=(bwidth, 30),
label_scale=0.6,
call=partial(_babase.app.set_ui_scale, scale),
style=(
'bright'
if scale.name.lower() == _babase.get_ui_scale()
else 'normal'
),
)
x += bwidth + 2
def toggle_ui_overlay(self) -> None:
"""Toggle UI overlay drawing."""
_babase.set_draw_virtual_safe_area_bounds(
not _babase.get_draw_virtual_safe_area_bounds()
)
self.request_refresh()
class Table(Generic[T]):
"""Used to show controls for arbitrarily large data in a grid form."""
def __init__(
self,
title: str,
entries: list[T],
draw_entry_call: Callable[
[int, T, DevConsoleTab, float, float, float, float], None
],
*,
entry_width: float = 300.0,
entry_height: float = 40.0,
margin_left_right: float = 60.0,
debug_bounds: bool = False,
max_columns: int | None = None,
) -> None:
self._title = title
self._entry_width = entry_width
self._entry_height = entry_height
self._margin_left_right = margin_left_right
self._focus_entry_index = 0
self._entries_per_page = 1
self._debug_bounds = debug_bounds
self._entries = entries
self._draw_entry_call = draw_entry_call
self._max_columns = max_columns
# Values updated on refresh (for aligning other custom
# widgets/etc.)
self.top_left: tuple[float, float] = (0.0, 0.0)
self.top_right: tuple[float, float] = (0.0, 0.0)
def set_entries(self, entries: list[T]) -> None:
"""Update table entries."""
self._entries = entries
# Clamp focus to new entries.
self._focus_entry_index = max(
0, min(len(self._entries) - 1, self._focus_entry_index)
)
def set_focus_entry_index(self, index: int) -> None:
"""Explicitly set the focused entry.
This affects which page is shown at the next refresh.
"""
self._focus_entry_index = max(0, min(len(self._entries) - 1, index))
def refresh(self, tab: DevConsoleTab) -> None:
"""Call to refresh the data."""
# pylint: disable=too-many-locals
margin_top = 50.0
margin_bottom = 10.0
# Update how much we can fit on a page based on our current size.
max_entry_area_width = tab.width - (self._margin_left_right * 2.0)
max_entry_area_height = tab.height - (margin_top + margin_bottom)
columns = max(1, int(max_entry_area_width / self._entry_width))
if self._max_columns is not None:
columns = min(columns, self._max_columns)
rows = max(1, int(max_entry_area_height / self._entry_height))
self._entries_per_page = rows * columns
# See which page our focus index falls in.
pagemax = math.ceil(len(self._entries) / self._entries_per_page)
page = self._focus_entry_index // self._entries_per_page
entry_offset = page * self._entries_per_page
entries_on_this_page = min(
self._entries_per_page, len(self._entries) - entry_offset
)
columns_on_this_page = math.ceil(entries_on_this_page / rows)
rows_on_this_page = min(entries_on_this_page, rows)
# We attach things to the center so resizes are smooth but we do
# some math in a left-centric way.
center_to_left = tab.width * -0.5
# Center our columns.
xoffs = 0.5 * (
max_entry_area_width - columns_on_this_page * self._entry_width
)
# Align everything to the bottom of the dev-console.
#
# UPDATE: Nevermind; top feels better. Keeping this code around
# in case we ever want to make it an option though.
if bool(False):
yoffs = -1.0 * (
tab.height
- (
rows_on_this_page * self._entry_height
+ margin_top
+ margin_bottom
)
)
else:
yoffs = 0
# Keep our corners up to date for user use.
self.top_left = (center_to_left + xoffs, tab.height + yoffs)
self.top_right = (
self.top_left[0]
+ self._margin_left_right * 2.0
+ columns_on_this_page * self._entry_width,
self.top_left[1],
)
# Page left/right buttons.
tab.button(
'<',
pos=(
center_to_left + xoffs,
yoffs + tab.height - margin_top - rows * self._entry_height,
),
size=(
self._margin_left_right,
rows * self._entry_height,
),
call=partial(self._page_left, tab),
disabled=entry_offset == 0,
)
tab.button(
'>',
pos=(
center_to_left
+ xoffs
+ self._margin_left_right
+ columns_on_this_page * self._entry_width,
yoffs + tab.height - margin_top - rows * self._entry_height,
),
size=(
self._margin_left_right,
rows * self._entry_height,
),
call=partial(self._page_right, tab),
disabled=(
entry_offset + entries_on_this_page >= len(self._entries)
),
)
for column in range(columns):
for row in range(rows):
entry_index = entry_offset + column * rows + row
if entry_index >= len(self._entries):
break
xpos = (
xoffs + self._margin_left_right + self._entry_width * column
)
ypos = (
yoffs
+ tab.height
- margin_top
- self._entry_height * (row + 1.0)
)
# Draw debug bounds.
if self._debug_bounds:
tab.button(
str(entry_index),
pos=(
center_to_left + xpos,
ypos,
),
size=(self._entry_width, self._entry_height),
# h_anchor='left',
)
# Run user drawing.
self._draw_entry_call(
entry_index,
self._entries[entry_index],
tab,
center_to_left + xpos,
ypos,
self._entry_width,
self._entry_height,
)
if entry_index >= len(self._entries):
break
tab.text(
f'{self._title} ({page + 1}/{pagemax})',
scale=0.8,
pos=(0, yoffs + tab.height - margin_top * 0.5),
h_align='center',
v_align='center',
)
def _page_right(self, tab: DevConsoleTab) -> None:
# Set focus on the first entry in the page before the current.
page = self._focus_entry_index // self._entries_per_page
page += 1
self.set_focus_entry_index(page * self._entries_per_page)
tab.request_refresh()
def _page_left(self, tab: DevConsoleTab) -> None:
# Set focus on the first entry in the page after the current.
page = self._focus_entry_index // self._entries_per_page
page -= 1
self.set_focus_entry_index(page * self._entries_per_page)
tab.request_refresh()
class DevConsoleTabLogging(DevConsoleTab):
"""Tab to wrangle logging levels."""
def __init__(self) -> None:
self._table = Table(
title='Logging Levels',
entry_width=800,
entry_height=42,
debug_bounds=False,
entries=list[str](),
draw_entry_call=self._draw_entry,
max_columns=1,
)
@override
def refresh(self) -> None:
assert self._table is not None
# Update table entries with the latest set of loggers (this can
# change over time).
self._table.set_entries(
['root'] + sorted(logging.root.manager.loggerDict)
)
# Draw the table.
self._table.refresh(self)
# Draw our control buttons in the corners.
tl = self._table.top_left
tr = self._table.top_right
bwidth = 140.0
bheight = 30.0
bvpad = 10.0
self.button(
'Reset',
pos=(tl[0], tl[1] - bheight - bvpad),
size=(bwidth, bheight),
label_scale=0.6,
call=self._reset,
disabled=(
not self._get_reset_logger_control_config().would_make_changes()
),
)
self.button(
'Cloud Control OFF',
pos=(tr[0] - bwidth, tl[1] - bheight - bvpad),
size=(bwidth, bheight),
label_scale=0.6,
disabled=True,
)
def _get_reset_logger_control_config(self) -> LoggerControlConfig:
from bacommon.logging import get_base_logger_control_config_client
return get_base_logger_control_config_client()
def _reset(self) -> None:
self._get_reset_logger_control_config().apply()
# Let the native layer know that levels changed.
_babase.update_internal_logger_levels()
# Blow away any existing values in app-config.
appconfig = _babase.app.config
if 'Log Levels' in appconfig:
del appconfig['Log Levels']
appconfig.commit()
self.request_refresh()
def _set_entry_val(self, entry_index: int, entry: str, val: int) -> None:
from bacommon.logging import get_base_logger_control_config_client
from bacommon.loggercontrol import LoggerControlConfig
# Focus on this entry with any interaction, so if we get resized
# it'll still be visible.
self._table.set_focus_entry_index(entry_index)
logging.getLogger(entry).setLevel(val)
# Let the native layer know that levels changed.
_babase.update_internal_logger_levels()
# Store only changes compared to the base config.
baseconfig = get_base_logger_control_config_client()
config = LoggerControlConfig.from_current_loggers().diff(baseconfig)
appconfig = _babase.app.config
appconfig['Log Levels'] = config.levels
appconfig.commit()
self.request_refresh()
def _draw_entry(
self,
entry_index: int,
entry: str,
tab: DevConsoleTab,
x: float,
y: float,
width: float,
height: float,
) -> None:
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-locals
xoffs = -15.0
bwidth = 80.0
btextscale = 0.5
tab.text(
entry,
(
x + width - bwidth * 6.5 - 10.0 + xoffs,
y + height * 0.5,
),
h_align='right',
scale=0.7,
)
logger = logging.getLogger(entry)
level = logger.level
index = 0
effectivelevel = logger.getEffectiveLevel()
# if entry != 'root' and level == logging.NOTSET:
# # Show the level being inherited in NOTSET cases.
# notsetlevelname = logging.getLevelName(logger.getEffectiveLevel())
# if notsetlevelname == 'NOTSET':
# notsetname = 'Not Set'
# else:
# notsetname = f'Not Set ({notsetlevelname.capitalize()})'
# else:
notsetname = 'Not Set'
tab.button(
notsetname,
pos=(x + width - bwidth * 6.5 + xoffs + 1.0, y + 5.0),
size=(bwidth * 1.0 - 2.0, height - 10),
label_scale=btextscale,
style='white_bright' if level == logging.NOTSET else 'black',
call=partial(
self._set_entry_val, entry_index, entry, logging.NOTSET
),
)
index += 1
tab.button(
'Debug',
pos=(x + width - bwidth * 5 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.DEBUG
else 'blue' if effectivelevel <= logging.DEBUG else 'black'
),
# style='bright' if level == logging.DEBUG else 'normal',
call=partial(
self._set_entry_val, entry_index, entry, logging.DEBUG
),
)
index += 1
tab.button(
'Info',
pos=(x + width - bwidth * 4 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.INFO
else 'white' if effectivelevel <= logging.INFO else 'black'
),
# style='bright' if level == logging.INFO else 'normal',
call=partial(self._set_entry_val, entry_index, entry, logging.INFO),
)
index += 1
tab.button(
'Warning',
pos=(x + width - bwidth * 3 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.WARNING
else 'yellow' if effectivelevel <= logging.WARNING else 'black'
),
call=partial(
self._set_entry_val, entry_index, entry, logging.WARNING
),
)
index += 1
tab.button(
'Error',
pos=(x + width - bwidth * 2 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.ERROR
else 'red' if effectivelevel <= logging.ERROR else 'black'
),
call=partial(
self._set_entry_val, entry_index, entry, logging.ERROR
),
)
index += 1
tab.button(
'Critical',
pos=(x + width - bwidth * 1 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.CRITICAL
else (
'purple' if effectivelevel <= logging.CRITICAL else 'black'
)
),
call=partial(
self._set_entry_val, entry_index, entry, logging.CRITICAL
),
)
class DevConsoleTabTest(DevConsoleTab):
"""Test dev-console tab."""
@override
def refresh(self) -> None:
self.button(
f'FLOOP-{random.randrange(200)}',
pos=(10, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
call=self.request_refresh,
)
self.button(
f'FLOOP2-{random.randrange(200)}',
pos=(120, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
style='bright',
)
self.text(
'TestText',
scale=0.8,
pos=(15, 50),
h_anchor='left',
h_align='left',
v_align='none',
)
# Throw little bits of text in the corners to make sure
# widths/heights are correct.
self.text(
'BL',
scale=0.25,
pos=(0, 0),
h_anchor='left',
h_align='left',
v_align='bottom',
)
self.text(
'BR',
scale=0.25,
pos=(self.width, 0),
h_anchor='left',
h_align='right',
v_align='bottom',
)
self.text(
'TL',
scale=0.25,
pos=(0, self.height),
h_anchor='left',
h_align='left',
v_align='top',
)
self.text(
'TR',
scale=0.25,
pos=(self.width, self.height),
h_anchor='left',
h_align='right',
v_align='top',
)

View file

@ -15,8 +15,9 @@ if TYPE_CHECKING:
from babase import AppIntent
# ba_meta export babase.AppMode
class EmptyAppMode(AppMode):
"""An empty app mode that can be used as a fallback/etc."""
"""An AppMode that does not do much at all."""
@override
@classmethod
@ -25,24 +26,24 @@ class EmptyAppMode(AppMode):
@override
@classmethod
def _supports_intent(cls, intent: AppIntent) -> bool:
def _can_handle_intent(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault)
@override
def handle_intent(self, intent: AppIntent) -> None:
if isinstance(intent, AppIntentExec):
_babase.empty_app_mode_handle_intent_exec(intent.code)
_babase.empty_app_mode_handle_app_intent_exec(intent.code)
return
assert isinstance(intent, AppIntentDefault)
_babase.empty_app_mode_handle_intent_default()
_babase.empty_app_mode_handle_app_intent_default()
@override
def on_activate(self) -> None:
# Let the native layer do its thing.
_babase.on_empty_app_mode_activate()
_babase.empty_app_mode_activate()
@override
def on_deactivate(self) -> None:
# Let the native layer do its thing.
_babase.on_empty_app_mode_deactivate()
_babase.empty_app_mode_deactivate()

View file

@ -9,11 +9,11 @@ import logging
import warnings
from typing import TYPE_CHECKING, override
from efro.log import LogLevel
from efro.logging import LogLevel
if TYPE_CHECKING:
from typing import Any
from efro.log import LogEntry, LogHandler
from efro.logging import LogEntry, LogHandler
_g_babase_imported = False # pylint: disable=invalid-name
_g_babase_app_started = False # pylint: disable=invalid-name
@ -186,7 +186,10 @@ def _feed_logs_to_babase(log_handler: LogHandler) -> None:
# Forward this along to the engine to display in the in-app
# console, in the Android log, etc.
_babase.emit_log(
name=entry.name, level=entry.level.name, message=entry.message
name=entry.name,
level=entry.level.name,
timestamp=entry.time.timestamp(),
message=entry.message,
)
# We also want to feed some logs to the old v1-cloud-log system.

View file

@ -63,7 +63,7 @@ def existing(obj: ExistableT | None) -> ExistableT | None:
For more info, see notes on 'existables' here:
https://ballistica.net/wiki/Coding-Style-Guide
"""
assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}'
assert obj is None or hasattr(obj, 'exists'), f'No "exists" attr on {obj}.'
return obj if obj is not None and obj.exists() else None
@ -156,6 +156,9 @@ class _WeakCall:
to wrap them in weakrefs manually if desired.
"""
# Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_call', '_args', '_keywds']
_did_invalid_call_warning = False
def __init__(self, *args: Any, **keywds: Any) -> None:
@ -173,9 +176,10 @@ class _WeakCall:
'Warning: callable passed to babase.WeakCall() is not'
' weak-referencable (%s); use functools.partial instead'
' to avoid this warning.',
args[0],
stack_info=True,
)
self._did_invalid_call_warning = True
type(self)._did_invalid_call_warning = True
self._call = args[0]
self._args = args[1:]
self._keywds = keywds
@ -214,6 +218,9 @@ class _Call:
without keeping its object alive.
"""
# 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.
@ -252,6 +259,14 @@ if TYPE_CHECKING:
# type checking on both positional and keyword arguments (as of mypy
# 1.11).
# FIXME: Actually, currently (as of Dec 2024) mypy doesn't fully
# type check partial. The partial() call itself is checked, but the
# resulting callable seems to be essentially untyped. We should
# probably revise this stuff so that Call and WeakCall are for 100%
# complete calls so we can fully type check them using ParamSpecs or
# whatnot. We could then write a weak_partial() call if we actually
# need that particular combination of functionality.
# Note: Something here is wonky with pylint, possibly related to our
# custom pylint plugin. Disabling all checks seems to fix it.
# pylint: disable=all
@ -272,6 +287,9 @@ class WeakMethod:
free to die. If called with a dead target, is simply a no-op.
"""
# Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_func', '_obj']
def __init__(self, call: types.MethodType):
assert isinstance(call, types.MethodType)
self._func = call.__func__

View file

@ -430,3 +430,40 @@ def unsupported_controller_message(name: str) -> None:
Lstr(resource='unsupportedControllerText', subs=[('${NAME}', name)]),
color=(1, 0, 0),
)
def copy_dev_console_history() -> None:
"""Copy log history from the dev console."""
import baenv
from babase._language import Lstr
if not _babase.clipboard_is_supported():
_babase.getsimplesound('error').play()
_babase.screenmessage(
'Clipboard not supported on this build.',
color=(1, 0, 0),
)
return
# This requires us to be running with a log-handler set up.
envconfig = baenv.get_config()
if envconfig.log_handler is None:
_babase.getsimplesound('error').play()
_babase.screenmessage(
'Not available; standard engine logging is not enabled.',
color=(1, 0, 0),
)
return
# Just dump everything that's in the log-handler's cache.
archive = envconfig.log_handler.get_cached()
lines: list[str] = []
stdnames = ('stdout', 'stderr')
for entry in archive.entries:
reltime = entry.time.timestamp() - envconfig.launch_time
level_ex = '' if entry.name in stdnames else f' {entry.level.name}'
lines.append(f'{reltime:.3f}{level_ex} {entry.name}: {entry.message}')
_babase.clipboard_set_text('\n'.join(lines))
_babase.screenmessage(Lstr(resource='copyConfirmText'), color=(0, 1, 0))
_babase.getsimplesound('gunCocking').play()

View file

@ -1,33 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""On-screen Keyboard related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
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.
"""
name: str
"""Displays when user selecting this keyboard."""
chars: list[tuple[str, ...]]
"""Used for row/column lengths."""
pages: dict[str, tuple[str, ...]]
"""Extra chars like emojis."""
nums: tuple[str, ...]
"""The 'num' page."""

View file

@ -44,8 +44,13 @@ class LanguageSubsystem(AppSubsystem):
(which may differ from locale if the user sets a language, etc.)
"""
env = _babase.env()
assert isinstance(env['locale'], str)
return env['locale']
locale = env.get('locale')
if not isinstance(locale, str):
logging.warning(
'Seem to be running in a dummy env; returning en_US locale.'
)
locale = 'en_US'
return locale
@property
def language(self) -> str:
@ -83,6 +88,8 @@ class LanguageSubsystem(AppSubsystem):
for i, name in enumerate(names):
if name == 'Chinesetraditional':
names[i] = 'ChineseTraditional'
elif name == 'Piratespeak':
names[i] = 'PirateSpeak'
except Exception:
from babase import _error
@ -431,7 +438,7 @@ class LanguageSubsystem(AppSubsystem):
'Thai',
'Tamil',
}
and not _babase.can_display_full_unicode()
and not _babase.supports_unicode_display()
):
return False
return True
@ -522,8 +529,10 @@ class Lstr:
... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
"""
# pylint: disable=dangerous-default-value
# noinspection PyDefaultArgument
# This class is used a lot in UI stuff and doesn't need to be
# flexible, so let's optimize its performance a bit.
__slots__ = ['args']
@overload
def __init__(
self,
@ -531,29 +540,28 @@ class Lstr:
resource: str,
fallback_resource: str = '',
fallback_value: str = '',
subs: Sequence[tuple[str, str | Lstr]] = [],
subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None:
"""Create an Lstr from a string resource."""
# noinspection PyShadowingNames,PyDefaultArgument
@overload
def __init__(
self,
*,
translate: tuple[str, str],
subs: Sequence[tuple[str, str | Lstr]] = [],
subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None:
"""Create an Lstr by translating a string in a category."""
# noinspection PyDefaultArgument
@overload
def __init__(
self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = []
self,
*,
value: str,
subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None:
"""Create an Lstr from a raw string value."""
# pylint: enable=redefined-outer-name, dangerous-default-value
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a Lstr.
@ -581,14 +589,16 @@ class Lstr:
if isinstance(self.args.get('value'), our_type):
raise TypeError("'value' must be a regular string; not an Lstr")
if 'subs' in self.args:
subs_new = []
for key, value in keywds['subs']:
if isinstance(value, our_type):
subs_new.append((key, value.args))
else:
subs_new.append((key, value))
self.args['subs'] = subs_new
if 'subs' in keywds:
subs = keywds.get('subs')
subs_filtered = []
if subs is not None:
for key, value in keywds['subs']:
if isinstance(value, our_type):
subs_filtered.append((key, value.args))
else:
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.

12
dist/ba_data/python/babase/_logging.py vendored Normal file
View file

@ -0,0 +1,12 @@
# Released under the MIT License. See LICENSE for details.
#
"""Logging functionality."""
from __future__ import annotations
import logging
# Our standard set of loggers.
balog = logging.getLogger('ba')
applog = logging.getLogger('ba.app')
lifecyclelog = logging.getLogger('ba.lifecycle')

View file

@ -17,8 +17,7 @@ import _babase
if TYPE_CHECKING:
from typing import Callable
DEBUG_LOG = False
logger = logging.getLogger('ba.loginadapter')
@dataclass
@ -94,20 +93,17 @@ class LoginAdapter:
if state == self._implicit_login_state:
return
if DEBUG_LOG:
if state is None:
logging.debug(
'LoginAdapter: %s implicit state changed;'
' now signed out.',
self.login_type.name,
)
else:
logging.debug(
'LoginAdapter: %s implicit state changed;'
' now signed in as %s.',
self.login_type.name,
state.display_name,
)
if state is None:
logger.debug(
'%s implicit state changed; now signed out.',
self.login_type.name,
)
else:
logger.debug(
'%s implicit state changed; now signed in as %s.',
self.login_type.name,
state.display_name,
)
self._implicit_login_state = state
self._implicit_login_state_dirty = True
@ -128,12 +124,11 @@ class LoginAdapter:
only a reference to it is stored, not a copy.
"""
assert _babase.in_logic_thread()
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got active logins %s.',
self.login_type.name,
{k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
)
logger.debug(
'%s adapter got active logins %s.',
self.login_type.name,
{k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
)
self._active_login_id = logins.get(self.login_type)
self._update_back_end_active()
@ -197,24 +192,21 @@ class LoginAdapter:
self._last_sign_in_desc = description
self._last_sign_in_time = now
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign_in() called;'
' fetching sign-in-token...',
self.login_type.name,
)
logger.debug(
'%s adapter sign_in() called; fetching sign-in-token...',
self.login_type.name,
)
def _got_sign_in_token_result(result: str | None) -> None:
import bacommon.cloud
# Failed to get a sign-in-token.
if result is None:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign-in-token fetch failed;'
' aborting sign-in.',
self.login_type.name,
)
logger.debug(
'%s adapter sign-in-token fetch failed;'
' aborting sign-in.',
self.login_type.name,
)
_babase.pushcall(
partial(
result_cb,
@ -227,25 +219,22 @@ class LoginAdapter:
# Got a sign-in token! Now pass it to the cloud which will use
# it to verify our identity and give us app credentials on
# success.
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign-in-token fetch succeeded;'
' passing to cloud for verification...',
self.login_type.name,
)
logger.debug(
'%s adapter sign-in-token fetch succeeded;'
' passing to cloud for verification...',
self.login_type.name,
)
def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception,
) -> None:
# This likely means we couldn't communicate with the server.
if isinstance(response, Exception):
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got error'
' sign-in response: %s',
self.login_type.name,
response,
)
logger.debug(
'%s adapter got error sign-in response: %s',
self.login_type.name,
response,
)
_babase.pushcall(partial(result_cb, self, response))
else:
# This means our credentials were explicitly rejected.
@ -254,12 +243,10 @@ class LoginAdapter:
RuntimeError('Sign-in-token was rejected.')
)
else:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got successful'
' sign-in response',
self.login_type.name,
)
logger.debug(
'%s adapter got successful sign-in response',
self.login_type.name,
)
result2 = self.SignInResult(
credentials=response.credentials
)
@ -305,12 +292,10 @@ class LoginAdapter:
# any existing state so it can properly respond to this.
if self._implicit_login_state_dirty and self._on_app_loading_called:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sending'
' implicit-state-changed to app.',
self.login_type.name,
)
logger.debug(
'%s adapter sending implicit-state-changed to app.',
self.login_type.name,
)
assert _babase.app.plus is not None
_babase.pushcall(
@ -331,12 +316,11 @@ class LoginAdapter:
self._implicit_login_state.login_id == self._active_login_id
)
if was_active != is_active:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter back-end-active is now %s.',
self.login_type.name,
is_active,
)
logger.debug(
'%s adapter back-end-active is now %s.',
self.login_type.name,
is_active,
)
self.on_back_end_active_change(is_active)
self._back_end_active = is_active

View file

@ -279,7 +279,7 @@ class DirectoryScan:
except Exception:
logging.exception("metascan: Error scanning '%s'.", subpath)
# Sort our results
# Sort our results.
for exportlist in self.results.exports.values():
exportlist.sort()
@ -327,7 +327,11 @@ class DirectoryScan:
meta_lines = {
lnum: l[1:].split()
for lnum, l in enumerate(flines)
if '# ba_meta ' in l
# Do a simple 'in' check for speed but then make sure its
# also at the beginning of the line. This allows disabling
# meta-lines and avoids false positives from code that
# wrangles them.
if ('# ba_meta' in l and l.strip().startswith('# ba_meta '))
}
is_top_level = len(subpath.parts) <= 1
required_api = self._get_api_requirement(
@ -384,12 +388,16 @@ class DirectoryScan:
# meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place.
if mline[0] != 'ba_meta':
logging.warning(
'metascan: %s:%d: malformed ba_meta statement.',
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
# Make an exception for this specific file, otherwise we
# get lots of warnings about ba_meta showing up in weird
# places here.
if subpath.as_posix() != 'babase/_meta.py':
logging.warning(
'metascan: %s:%d: malformed ba_meta statement.',
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
elif (
len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'
):

View file

@ -80,9 +80,9 @@ class UIScale(Enum):
readable from an average distance.
"""
LARGE = 0
SMALL = 0
MEDIUM = 1
SMALL = 2
LARGE = 2
class Permission(Enum):

View file

@ -33,7 +33,7 @@ class NetworkSubsystem:
# that a nearby server has been pinged.
self.zone_pings: dict[str, float] = {}
# For debugging.
# For debugging/progress.
self.v1_test_log: str = ''
self.v1_ctest_results: dict[int, str] = {}
self.connectivity_state = 'uninited'

View file

@ -93,7 +93,7 @@ class PluginSubsystem(AppSubsystem):
# that weren't covered by the meta stuff above, either creating
# plugin-specs for them or clearing them out. This covers
# plugins with api versions not matching ours, plugins without
# ba_meta tags, and plugins that have since disappeared.
# ba_*meta tags, and plugins that have since disappeared.
assert isinstance(plugstates, dict)
wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]

View file

@ -1,38 +1,49 @@
# Released under the MIT License. See LICENSE for details.
#
"""Classic ballistica components.
"""Components for the classic BombSquad experience.
This package is used as a 'dumping ground' for functionality that is
necessary to keep legacy parts of the app working, but which may no
longer be the best way to do things going forward.
New code should try to avoid using code from here when possible.
Functionality in this package should be exposed through the
ClassicSubsystem. This allows type-checked code to go through the
babase.app.classic singleton which forces it to explicitly handle the
possibility of babase.app.classic being None. When code instead imports
classic submodules directly, it is much harder to make it cleanly handle
classic not being present.
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.
"""
# ba_meta require api 8
# ba_meta require api 9
# Note: Code relying on classic should import things from here *only*
# for type-checking and use the versions in app.classic at runtime; that
# way type-checking will cleanly cover the classic-not-present case
# (app.classic being None).
# for type-checking and use the versions in ba*.app.classic at runtime;
# that way type-checking will cleanly cover the classic-not-present case
# (ba*.app.classic being None).
import logging
from baclassic._subsystem import ClassicSubsystem
from efro.util import set_canonical_module_names
from baclassic._appmode import ClassicAppMode
from baclassic._appsubsystem import ClassicAppSubsystem
from baclassic._achievement import Achievement, AchievementSubsystem
from baclassic._chest import (
ChestAppearanceDisplayInfo,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
CHEST_APPEARANCE_DISPLAY_INFOS,
)
from baclassic._displayitem import show_display_item
__all__ = [
'ClassicSubsystem',
'ChestAppearanceDisplayInfo',
'CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT',
'CHEST_APPEARANCE_DISPLAY_INFOS',
'ClassicAppMode',
'ClassicAppSubsystem',
'Achievement',
'AchievementSubsystem',
'show_display_item',
]
# We want stuff here to show up as packagename.Foo instead of
# packagename._submodule.Foo.
set_canonical_module_names(globals())
# Sanity check: we want to keep ballistica's dependencies and
# bootstrapping order clearly defined; let's check a few particular
# modules to make sure they never directly or indirectly import us

View file

@ -125,7 +125,11 @@ class AccountV1Subsystem:
if subset is not None:
raise ValueError('invalid subset value: ' + str(subset))
if data['p']:
# We used to give this bonus for pro, but on recent versions of
# the game give it for everyone (since we are phasing out Pro).
# if data['p']:
if bool(True):
if babase.app.plus is None:
pro_mult = 1.0
else:
@ -176,7 +180,9 @@ class AccountV1Subsystem:
else {}
)
for item_name, item in list(store_items.items()):
if item_name.startswith('icons.') and plus.get_purchased(item_name):
if item_name.startswith(
'icons.'
) and plus.get_v1_account_product_purchased(item_name):
icons.append(item['icon'])
return icons
@ -227,13 +233,12 @@ class AccountV1Subsystem:
if plus is None:
return False
# Check our tickets-based pro upgrade and our two real-IAP based
# upgrades. Also always unlock this stuff in ballistica-core builds.
# Check various server-side purchases that mean we have pro.
return bool(
plus.get_purchased('upgrades.pro')
or plus.get_purchased('static.pro')
or plus.get_purchased('static.pro_sale')
or 'ballistica' + 'kit' == babase.appname()
plus.get_v1_account_product_purchased('gold_pass')
or plus.get_v1_account_product_purchased('upgrades.pro')
or plus.get_v1_account_product_purchased('static.pro')
or plus.get_v1_account_product_purchased('static.pro_sale')
)
def have_pro_options(self) -> bool:
@ -247,10 +252,9 @@ class AccountV1Subsystem:
if plus is None:
return False
# We expose pro options if the server tells us to
# (which is generally just when we own pro),
# or also if we've been grandfathered in
# or are using ballistica-core builds.
# We expose pro options if the server tells us to (which is
# generally just when we own pro), or also if we've been
# grandfathered in.
return self.have_pro() or bool(
plus.get_v1_account_misc_read_val_2('proOptionsUnlocked', False)
or babase.app.config.get('lc14292', 0) > 1

View file

@ -6,6 +6,11 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
import babase
import bascenev1
import bauiv1
@ -86,429 +91,311 @@ class AchievementSubsystem:
def _init_achievements(self) -> None:
"""Fill in available achievements."""
achs = self.achievements
# 5
achs.append(
Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5)
)
# 15
achs.append(
self.achievements += [
Achievement(
'In Control',
'achievementInControl',
(1, 1, 1),
'',
award=5,
),
Achievement(
'Sharing is Caring',
'achievementSharingIsCaring',
(1, 1, 1),
'',
15,
)
)
# 10
achs.append(
award=15,
),
Achievement(
'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', 10
)
)
# 10
achs.append(
'Dual Wielding',
'achievementDualWielding',
(1, 1, 1),
'',
award=10,
),
Achievement(
'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10
)
)
# 20
achs.append(
'Free Loader',
'achievementFreeLoader',
(1, 1, 1),
'',
award=10,
),
Achievement(
'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20
)
)
# 5
achs.append(
'Team Player',
'achievementTeamPlayer',
(1, 1, 1),
'',
award=20,
),
Achievement(
'Onslaught Training Victory',
'achievementOnslaught',
(1, 1, 1),
'Default:Onslaught Training',
5,
)
)
# 5
achs.append(
award=5,
),
Achievement(
'Off You Go Then',
'achievementOffYouGo',
(1, 1.1, 1.3),
'Default:Onslaught Training',
5,
)
)
# 10
achs.append(
award=5,
),
Achievement(
'Boxer',
'achievementBoxer',
(1, 0.6, 0.6),
'Default:Onslaught Training',
10,
award=10,
hard_mode_only=True,
)
)
# 10
achs.append(
),
Achievement(
'Rookie Onslaught Victory',
'achievementOnslaught',
(0.5, 1.4, 0.6),
'Default:Rookie Onslaught',
10,
)
)
# 10
achs.append(
award=10,
),
Achievement(
'Mine Games',
'achievementMine',
(1, 1, 1.4),
'Default:Rookie Onslaught',
10,
)
)
# 15
achs.append(
award=10,
),
Achievement(
'Flawless Victory',
'achievementFlawlessVictory',
(1, 1, 1),
'Default:Rookie Onslaught',
15,
award=15,
hard_mode_only=True,
)
)
# 10
achs.append(
),
Achievement(
'Rookie Football Victory',
'achievementFootballVictory',
(1.0, 1, 0.6),
'Default:Rookie Football',
10,
)
)
# 10
achs.append(
award=10,
),
Achievement(
'Super Punch',
'achievementSuperPunch',
(1, 1, 1.8),
'Default:Rookie Football',
10,
)
)
# 15
achs.append(
award=10,
),
Achievement(
'Rookie Football Shutout',
'achievementFootballShutout',
(1, 1, 1),
'Default:Rookie Football',
15,
award=15,
hard_mode_only=True,
)
)
# 15
achs.append(
),
Achievement(
'Pro Onslaught Victory',
'achievementOnslaught',
(0.3, 1, 2.0),
'Default:Pro Onslaught',
15,
)
)
# 15
achs.append(
award=15,
),
Achievement(
'Boom Goes the Dynamite',
'achievementTNT',
(1.4, 1.2, 0.8),
'Default:Pro Onslaught',
15,
)
)
# 20
achs.append(
award=15,
),
Achievement(
'Pro Boxer',
'achievementBoxer',
(2, 2, 0),
'Default:Pro Onslaught',
20,
award=20,
hard_mode_only=True,
)
)
# 15
achs.append(
),
Achievement(
'Pro Football Victory',
'achievementFootballVictory',
(1.3, 1.3, 2.0),
'Default:Pro Football',
15,
)
)
# 15
achs.append(
award=15,
),
Achievement(
'Super Mega Punch',
'achievementSuperPunch',
(2, 1, 0.6),
'Default:Pro Football',
15,
)
)
# 20
achs.append(
award=15,
),
Achievement(
'Pro Football Shutout',
'achievementFootballShutout',
(0.7, 0.7, 2.0),
'Default:Pro Football',
20,
award=20,
hard_mode_only=True,
)
)
# 15
achs.append(
),
Achievement(
'Pro Runaround Victory',
'achievementRunaround',
(1, 1, 1),
'Default:Pro Runaround',
15,
)
)
# 20
achs.append(
award=15,
),
Achievement(
'Precision Bombing',
'achievementCrossHair',
(1, 1, 1.3),
'Default:Pro Runaround',
20,
award=20,
hard_mode_only=True,
)
)
# 25
achs.append(
),
Achievement(
'The Wall',
'achievementWall',
(1, 0.7, 0.7),
'Default:Pro Runaround',
25,
award=25,
hard_mode_only=True,
)
)
# 30
achs.append(
),
Achievement(
'Uber Onslaught Victory',
'achievementOnslaught',
(2, 2, 1),
'Default:Uber Onslaught',
30,
)
)
# 30
achs.append(
award=30,
),
Achievement(
'Gold Miner',
'achievementMine',
(2, 1.6, 0.2),
'Default:Uber Onslaught',
30,
award=30,
hard_mode_only=True,
)
)
# 30
achs.append(
),
Achievement(
'TNT Terror',
'achievementTNT',
(2, 1.8, 0.3),
'Default:Uber Onslaught',
30,
award=30,
hard_mode_only=True,
)
)
# 30
achs.append(
),
Achievement(
'Uber Football Victory',
'achievementFootballVictory',
(1.8, 1.4, 0.3),
'Default:Uber Football',
30,
)
)
# 30
achs.append(
award=30,
),
Achievement(
'Got the Moves',
'achievementGotTheMoves',
(2, 1, 0),
'Default:Uber Football',
30,
award=30,
hard_mode_only=True,
)
)
# 40
achs.append(
),
Achievement(
'Uber Football Shutout',
'achievementFootballShutout',
(2, 2, 0),
'Default:Uber Football',
40,
award=40,
hard_mode_only=True,
)
)
# 30
achs.append(
),
Achievement(
'Uber Runaround Victory',
'achievementRunaround',
(1.5, 1.2, 0.2),
'Default:Uber Runaround',
30,
)
)
# 40
achs.append(
award=30,
),
Achievement(
'The Great Wall',
'achievementWall',
(2, 1.7, 0.4),
'Default:Uber Runaround',
40,
award=40,
hard_mode_only=True,
)
)
# 40
achs.append(
),
Achievement(
'Stayin\' Alive',
'achievementStayinAlive',
(2, 2, 1),
'Default:Uber Runaround',
40,
award=40,
hard_mode_only=True,
)
)
# 20
achs.append(
),
Achievement(
'Last Stand Master',
'achievementMedalSmall',
(2, 1.5, 0.3),
'Default:The Last Stand',
20,
award=20,
hard_mode_only=True,
)
)
# 40
achs.append(
),
Achievement(
'Last Stand Wizard',
'achievementMedalMedium',
(2, 1.5, 0.3),
'Default:The Last Stand',
40,
award=40,
hard_mode_only=True,
)
)
# 60
achs.append(
),
Achievement(
'Last Stand God',
'achievementMedalLarge',
(2, 1.5, 0.3),
'Default:The Last Stand',
60,
award=60,
hard_mode_only=True,
)
)
# 5
achs.append(
),
Achievement(
'Onslaught Master',
'achievementMedalSmall',
(0.7, 1, 0.7),
'Challenges:Infinite Onslaught',
5,
)
)
# 15
achs.append(
award=5,
),
Achievement(
'Onslaught Wizard',
'achievementMedalMedium',
(0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught',
15,
)
)
# 30
achs.append(
award=15,
),
Achievement(
'Onslaught God',
'achievementMedalLarge',
(0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught',
30,
)
)
# 5
achs.append(
award=30,
),
Achievement(
'Runaround Master',
'achievementMedalSmall',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
5,
)
)
# 15
achs.append(
award=5,
),
Achievement(
'Runaround Wizard',
'achievementMedalMedium',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
15,
)
)
# 30
achs.append(
award=15,
),
Achievement(
'Runaround God',
'achievementMedalLarge',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
30,
)
)
award=30,
),
]
def award_local_achievement(self, achname: str) -> None:
"""For non-game-based achievements such as controller-connection."""
@ -652,14 +539,16 @@ class Achievement:
self,
name: str,
icon_name: str,
icon_color: Sequence[float],
icon_color: tuple[float, float, float],
level_name: str,
*,
award: int,
hard_mode_only: bool = False,
):
self._name = name
self._icon_name = icon_name
self._icon_color: Sequence[float] = list(icon_color) + [1]
assert len(icon_color) == 3
self._icon_color = icon_color + (1.0,)
self._level_name = level_name
self._completion_banner_slot: int | None = None
self._award = award
@ -842,16 +731,48 @@ class Achievement:
],
)
def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
"""Get the ticket award value for this achievement."""
def get_award_chest_type(self) -> ClassicChestAppearance:
"""Return the type of chest given for this achievement."""
# For now just map our old ticket values to chest types.
# Can add distinct values if need be later.
plus = babase.app.plus
if plus is None:
return 0
val: int = plus.get_v1_account_misc_read_val(
'achAward.' + self._name, self._award
) * _get_ach_mult(include_pro_bonus)
assert isinstance(val, int)
return val
assert plus is not None
t = plus.get_v1_account_misc_read_val(
f'achAward.{self.name}', self._award
)
return (
ClassicChestAppearance.L6
if t >= 30
else (
ClassicChestAppearance.L5
if t >= 25
else (
ClassicChestAppearance.L4
if t >= 20
else (
ClassicChestAppearance.L3
if t >= 15
else (
ClassicChestAppearance.L2
if t >= 10
else ClassicChestAppearance.L1
)
)
)
)
)
# def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
# """Get the ticket award value for this achievement."""
# plus = babase.app.plus
# if plus is None:
# return 0
# val: int = plus.get_v1_account_misc_read_val(
# 'achAward.' + self._name, self._award
# ) * _get_ach_mult(include_pro_bonus)
# assert isinstance(val, int)
# return val
@property
def power_ranking_value(self) -> int:
@ -870,6 +791,7 @@ class Achievement:
x: float,
y: float,
delay: float,
*,
outdelay: float | None = None,
color: Sequence[float] | None = None,
style: str = 'post_game',
@ -1015,42 +937,72 @@ class Achievement:
txtactor.node.rotate = 10
objs.append(txtactor)
# Ticket-award.
# Chest award.
award_x = -100
objs.append(
Text(
babase.charstr(babase.SpecialChar.TICKET),
host_only=True,
position=(x + award_x + 33, y + 7),
transition=Text.Transition.FADE_IN,
scale=1.5,
h_attach=h_attach,
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(1, 1, 1, 0.2 if hmo else 0.4),
transition_delay=delay + 0.05,
transition_out_delay=out_delay_fin,
).autoretain()
chesttype = self.get_award_chest_type()
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
objs.append(
Text(
'+' + str(self.get_award_ticket_value()),
host_only=True,
position=(x + award_x + 28, y + 16),
transition=Text.Transition.FADE_IN,
scale=0.7,
flatness=1,
h_attach=h_attach,
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=cl2,
Image(
# Provide magical extended dict version of texture
# that Image actor supports.
texture={
'texture': bascenev1.gettexture(
chestdisplayinfo.texclosed
),
'tint_texture': bascenev1.gettexture(
chestdisplayinfo.texclosedtint
),
'tint_color': chestdisplayinfo.tint,
'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color + (0.5 if hmo else 1.0,),
position=(x + award_x + 37, y + 12),
scale=(32.0, 32.0),
transition=Image.Transition.FADE_IN,
transition_delay=delay + 0.05,
transition_out_delay=out_delay_fin,
host_only=True,
attach=Image.Attach.TOP_LEFT,
).autoretain()
)
# objs.append(
# Text(
# babase.charstr(babase.SpecialChar.TICKET),
# host_only=True,
# position=(x + award_x + 33, y + 7),
# transition=Text.Transition.FADE_IN,
# scale=1.5,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=(1, 1, 1, 0.2 if hmo else 0.4),
# transition_delay=delay + 0.05,
# transition_out_delay=out_delay_fin,
# ).autoretain()
# )
# objs.append(
# Text(
# '+' + str(self.get_award_ticket_value()),
# host_only=True,
# position=(x + award_x + 28, y + 16),
# transition=Text.Transition.FADE_IN,
# scale=0.7,
# flatness=1,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=cl2,
# transition_delay=delay + 0.05,
# transition_out_delay=out_delay_fin,
# ).autoretain()
# )
else:
complete = self.complete
objs = []
@ -1092,40 +1044,71 @@ class Achievement:
else:
if not complete:
award_x = -100
objs.append(
Text(
babase.charstr(babase.SpecialChar.TICKET),
host_only=True,
position=(x + award_x + 33, y + 7),
transition=Text.Transition.IN_RIGHT,
scale=1.5,
h_attach=h_attach,
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(1, 1, 1, (0.1 if hmo else 0.2)),
transition_delay=delay + 0.05,
transition_out_delay=None,
).autoretain()
# objs.append(
# Text(
# babase.charstr(babase.SpecialChar.TICKET),
# host_only=True,
# position=(x + award_x + 33, y + 7),
# transition=Text.Transition.IN_RIGHT,
# scale=1.5,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=(1, 1, 1, (0.1 if hmo else 0.2)),
# transition_delay=delay + 0.05,
# transition_out_delay=None,
# ).autoretain()
# )
chesttype = self.get_award_chest_type()
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
objs.append(
Text(
'+' + str(self.get_award_ticket_value()),
host_only=True,
position=(x + award_x + 28, y + 16),
transition=Text.Transition.IN_RIGHT,
scale=0.7,
flatness=1,
h_attach=h_attach,
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
Image(
# Provide magical extended dict version of texture
# that Image actor supports.
texture={
'texture': bascenev1.gettexture(
chestdisplayinfo.texclosed
),
'tint_texture': bascenev1.gettexture(
chestdisplayinfo.texclosedtint
),
'tint_color': chestdisplayinfo.tint,
'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color
+ (0.5 if hmo else 1.0,),
position=(x + award_x + 38, y + 14),
scale=(32.0, 32.0),
transition=Image.Transition.IN_RIGHT,
transition_delay=delay + 0.05,
transition_out_delay=None,
host_only=True,
attach=attach,
).autoretain()
)
# objs.append(
# Text(
# '+' + str(self.get_award_ticket_value()),
# host_only=True,
# position=(x + award_x + 28, y + 16),
# transition=Text.Transition.IN_RIGHT,
# scale=0.7,
# flatness=1,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
# transition_delay=delay + 0.05,
# transition_out_delay=None,
# ).autoretain()
# )
# Show 'hard-mode-only' only over incomplete achievements
# when that's the case.
if hmo:
@ -1457,68 +1440,70 @@ class Achievement:
assert objt.node
objt.node.host_only = True
objt = Text(
babase.charstr(babase.SpecialChar.TICKET),
position=(-120 - 170 + 5, 75 + y_offs - 20),
front=True,
v_attach=Text.VAttach.BOTTOM,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth,
transition_delay=in_time,
transition_out_delay=out_time,
flash=True,
color=(0.5, 0.5, 0.5, 1),
scale=3.0,
).autoretain()
objs.append(objt)
assert objt.node
objt.node.host_only = True
# objt = Text(
# babase.charstr(babase.SpecialChar.TICKET),
# position=(-120 - 170 + 5, 75 + y_offs - 20),
# front=True,
# v_attach=Text.VAttach.BOTTOM,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# transition=Text.Transition.IN_BOTTOM,
# vr_depth=base_vr_depth,
# transition_delay=in_time,
# transition_out_delay=out_time,
# flash=True,
# color=(0.5, 0.5, 0.5, 1),
# scale=3.0,
# ).autoretain()
# objs.append(objt)
# assert objt.node
# objt.node.host_only = True
# print('FIXME SHOW ACH CHEST3')
# objt = Text(
# '+' + str(self.get_award_ticket_value()),
# position=(-120 - 180 + 5, 80 + y_offs - 20),
# v_attach=Text.VAttach.BOTTOM,
# front=True,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# transition=Text.Transition.IN_BOTTOM,
# vr_depth=base_vr_depth,
# flatness=0.5,
# shadow=1.0,
# transition_delay=in_time,
# transition_out_delay=out_time,
# flash=True,
# color=(0, 1, 0, 1),
# scale=1.5,
# ).autoretain()
# objs.append(objt)
objt = Text(
'+' + str(self.get_award_ticket_value()),
position=(-120 - 180 + 5, 80 + y_offs - 20),
v_attach=Text.VAttach.BOTTOM,
front=True,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth,
flatness=0.5,
shadow=1.0,
transition_delay=in_time,
transition_out_delay=out_time,
flash=True,
color=(0, 1, 0, 1),
scale=1.5,
).autoretain()
objs.append(objt)
assert objt.node
objt.node.host_only = True
# Add the 'x 2' if we've got pro.
if app.classic.accounts.have_pro():
objt = Text(
'x 2',
position=(-120 - 180 + 45, 80 + y_offs - 50),
v_attach=Text.VAttach.BOTTOM,
front=True,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth,
flatness=0.5,
shadow=1.0,
transition_delay=in_time,
transition_out_delay=out_time,
flash=True,
color=(0.4, 0, 1, 1),
scale=0.9,
).autoretain()
objs.append(objt)
assert objt.node
objt.node.host_only = True
# if app.classic.accounts.have_pro():
# objt = Text(
# 'x 2',
# position=(-120 - 180 + 45, 80 + y_offs - 50),
# v_attach=Text.VAttach.BOTTOM,
# front=True,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# transition=Text.Transition.IN_BOTTOM,
# vr_depth=base_vr_depth,
# flatness=0.5,
# shadow=1.0,
# transition_delay=in_time,
# transition_out_delay=out_time,
# flash=True,
# color=(0.4, 0, 1, 1),
# scale=0.9,
# ).autoretain()
# objs.append(objt)
# assert objt.node
# objt.node.host_only = True
objt = Text(
self.description_complete,

View file

@ -48,19 +48,7 @@ class AdsSubsystem:
1.0,
lambda: babase.screenmessage(
babase.Lstr(
resource='removeInGameAdsText',
subs=[
(
'${PRO}',
babase.Lstr(
resource='store.bombSquadProNameText'
),
),
(
'${APP_NAME}',
babase.Lstr(resource='titleText'),
),
],
resource='removeInGameAdsTokenPurchaseText'
),
color=(1, 1, 0),
),
@ -100,8 +88,14 @@ class AdsSubsystem:
# No ads without net-connections, etc.
if not plus.can_show_ad():
show = False
if classic.accounts.have_pro():
show = False # Pro disables interstitials.
# Pro or other upgrades disable interstitials.
if (
classic.accounts.have_pro()
or classic.gold_pass
or classic.remove_ads
):
show = False
try:
session = bascenev1.get_foreground_host_session()
assert session is not None

View file

@ -1,46 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines AppDelegate class for handling high level app functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import babase
if TYPE_CHECKING:
from typing import Callable
import bascenev1
class AppDelegate:
"""Defines handlers for high level app functionality.
Category: App Classes
"""
def create_default_game_settings_ui(
self,
gameclass: type[bascenev1.GameActivity],
sessiontype: type[bascenev1.Session],
settings: dict | None,
completion_call: Callable[[dict | None], None],
) -> None:
"""Launch a UI to configure the given game config.
It should manipulate the contents of config and call completion_call
when done.
"""
# Replace the main window once we come up successfully.
from bauiv1lib.playlist.editgame import PlaylistEditGameWindow
assert babase.app.classic is not None
babase.app.ui_v1.clear_main_menu_window(transition='out_left')
babase.app.ui_v1.set_main_menu_window(
PlaylistEditGameWindow(
gameclass,
sessiontype,
settings,
completion_call=completion_call,
).get_root_widget(),
from_window=False, # Disable check since we don't know.
)

View file

@ -0,0 +1,658 @@
# Released under the MIT License. See LICENSE for details.
#
"""Contains ClassicAppMode."""
from __future__ import annotations
import os
import logging
from functools import partial
from typing import TYPE_CHECKING, override
from bacommon.app import AppExperience
import babase
import bauiv1
from bauiv1lib.connectivity import wait_for_connectivity
from bauiv1lib.account.signin import show_sign_in_prompt
import _baclassic
if TYPE_CHECKING:
from typing import Callable, Any, Literal
from efro.call import CallbackRegistration
import bacommon.cloud
from bauiv1lib.chest import ChestWindow
# ba_meta export babase.AppMode
class ClassicAppMode(babase.AppMode):
"""AppMode for the classic BombSquad experience."""
def __init__(self) -> None:
self._on_primary_account_changed_callback: (
CallbackRegistration | None
) = None
self._on_connectivity_changed_callback: CallbackRegistration | None = (
None
)
self._test_sub: babase.CloudSubscription | None = None
self._account_data_sub: babase.CloudSubscription | None = None
self._have_account_values = False
self._have_connectivity = False
@override
@classmethod
def get_app_experience(cls) -> AppExperience:
return AppExperience.MELEE
@override
@classmethod
def _can_handle_intent(cls, intent: babase.AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(
intent, babase.AppIntentExec | babase.AppIntentDefault
)
@override
def handle_intent(self, intent: babase.AppIntent) -> None:
if isinstance(intent, babase.AppIntentExec):
_baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
return
assert isinstance(intent, babase.AppIntentDefault)
_baclassic.classic_app_mode_handle_app_intent_default()
@override
def on_activate(self) -> None:
# Let the native layer do its thing.
_baclassic.classic_app_mode_activate()
app = babase.app
plus = app.plus
assert plus is not None
# Wire up the root ui to do what we want.
ui = app.ui_v1
ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
self._root_ui_account_press
)
ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
self._root_ui_menu_press
)
ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
self._root_ui_squad_press
)
ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
self._root_ui_settings_press
)
ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
self._root_ui_store_press
)
ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
self._root_ui_inventory_press
)
ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
self._root_ui_get_tokens_press
)
ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
self._root_ui_inbox_press
)
ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
self._root_ui_tickets_meter_press
)
ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
self._root_ui_tokens_meter_press
)
ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
self._root_ui_trophy_meter_press
)
ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
self._root_ui_level_meter_press
)
ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
self._root_ui_achievements_press
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
self._root_ui_chest_slot_pressed, 0
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
self._root_ui_chest_slot_pressed, 1
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
self._root_ui_chest_slot_pressed, 2
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
self._root_ui_chest_slot_pressed, 3
)
# We want to be informed when connectivity changes.
self._on_connectivity_changed_callback = (
plus.cloud.on_connectivity_changed_callbacks.register(
self._update_for_connectivity_change
)
)
# We want to be informed when primary account changes.
self._on_primary_account_changed_callback = (
plus.accounts.on_primary_account_changed_callbacks.register(
self._update_for_primary_account
)
)
# Establish subscriptions/etc. for any current primary account.
self._update_for_primary_account(plus.accounts.primary)
self._have_connectivity = plus.cloud.is_connected()
self._update_for_connectivity_change(self._have_connectivity)
@override
def on_deactivate(self) -> None:
classic = babase.app.classic
# Stop being informed of account changes.
self._on_primary_account_changed_callback = None
# Remove anything following any current account.
self._update_for_primary_account(None)
# Save where we were in the UI so we return there next time.
if classic is not None:
classic.save_ui_state()
# Let the native layer do its thing.
_baclassic.classic_app_mode_deactivate()
@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:
babase.invoke_main_menu()
def _update_for_primary_account(
self, account: babase.AccountV2Handle | None
) -> None:
"""Update subscriptions/etc. for a new primary account state."""
assert babase.in_logic_thread()
plus = babase.app.plus
assert plus is not None
classic = babase.app.classic
assert classic is not None
if account is not None:
babase.set_ui_account_state(True, account.tag)
else:
babase.set_ui_account_state(False)
# For testing subscription functionality.
if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
if account is None:
self._test_sub = None
else:
with account:
self._test_sub = plus.cloud.subscribe_test(
self._on_sub_test_update
)
else:
self._test_sub = None
if account is None:
classic.gold_pass = False
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='',
achievements_percent_text='',
level_text='',
xp_text='',
inbox_count_text='',
gold_pass=False,
chest_0_appearance='',
chest_1_appearance='',
chest_2_appearance='',
chest_3_appearance='',
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_ad_allow_time=-1.0,
chest_1_ad_allow_time=-1.0,
chest_2_ad_allow_time=-1.0,
chest_3_ad_allow_time=-1.0,
)
self._have_account_values = False
self._update_ui_live_state()
else:
with account:
self._account_data_sub = (
plus.cloud.subscribe_classic_account_data(
self._on_classic_account_data_change
)
)
def _update_for_connectivity_change(self, connected: bool) -> None:
"""Update when the app's connectivity state changes."""
self._have_connectivity = connected
self._update_ui_live_state()
def _update_ui_live_state(self) -> None:
# We want to show ui elements faded if we don't have a live
# connection to the master-server OR if we haven't received a
# set of account values from them yet. If we just plug in raw
# 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(
self._have_connectivity and self._have_account_values
)
def _on_sub_test_update(self, val: int | None) -> None:
print(f'GOT SUB TEST UPDATE: {val}')
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 += '+'
chest0 = val.chests.get('0')
chest1 = val.chests.get('1')
chest2 = val.chests.get('2')
chest3 = val.chests.get('3')
# Keep a few handy values on classic updated with the latest
# data.
classic = babase.app.classic
assert classic is not None
classic.remove_ads = val.remove_ads
classic.gold_pass = val.gold_pass
classic.chest_dock_full = (
chest0 is not None
and chest1 is not None
and chest2 is not None
and chest3 is not None
)
_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
),
achievements_percent_text=f'{achp}%',
level_text=str(val.level),
xp_text=f'{val.xp}/{val.xpmax}',
inbox_count_text=ibc,
gold_pass=val.gold_pass,
chest_0_appearance=(
'' if chest0 is None else chest0.appearance.value
),
chest_1_appearance=(
'' if chest1 is None else chest1.appearance.value
),
chest_2_appearance=(
'' if chest2 is None else chest2.appearance.value
),
chest_3_appearance=(
'' if chest3 is None else chest3.appearance.value
),
chest_0_unlock_time=(
-1.0 if chest0 is None else chest0.unlock_time.timestamp()
),
chest_1_unlock_time=(
-1.0 if chest1 is None else chest1.unlock_time.timestamp()
),
chest_2_unlock_time=(
-1.0 if chest2 is None else chest2.unlock_time.timestamp()
),
chest_3_unlock_time=(
-1.0 if chest3 is None else chest3.unlock_time.timestamp()
),
chest_0_ad_allow_time=(
-1.0
if chest0 is None or chest0.ad_allow_time is None
else chest0.ad_allow_time.timestamp()
),
chest_1_ad_allow_time=(
-1.0
if chest1 is None or chest1.ad_allow_time is None
else chest1.ad_allow_time.timestamp()
),
chest_2_ad_allow_time=(
-1.0
if chest2 is None or chest2.ad_allow_time is None
else chest2.ad_allow_time.timestamp()
),
chest_3_ad_allow_time=(
-1.0
if chest3 is None or chest3.ad_allow_time is None
else chest3.ad_allow_time.timestamp()
),
)
# Note that we have values and updated faded state accordingly.
self._have_account_values = True
self._update_ui_live_state()
def _root_ui_menu_press(self) -> None:
from babase import push_back_press
ui = babase.app.ui_v1
# If *any* main-window is up, kill it and resume play.
old_window = ui.get_main_window()
if old_window is not None:
classic = babase.app.classic
assert classic is not None
classic.resume()
ui.clear_main_window()
return
# Otherwise
push_back_press()
def _root_ui_account_press(self) -> None:
from bauiv1lib.account.settings import AccountSettingsWindow
self._auxiliary_window_nav(
win_type=AccountSettingsWindow,
win_create_call=lambda: AccountSettingsWindow(
origin_widget=bauiv1.get_special_widget('account_button')
),
)
def _root_ui_squad_press(self) -> None:
btn = bauiv1.get_special_widget('squad_button')
center = btn.get_screen_space_center()
if bauiv1.app.classic is not None:
bauiv1.app.classic.party_icon_activate(center)
else:
logging.warning('party_icon_activate: no classic.')
def _root_ui_settings_press(self) -> None:
from bauiv1lib.settings.allsettings import AllSettingsWindow
self._auxiliary_window_nav(
win_type=AllSettingsWindow,
win_create_call=lambda: AllSettingsWindow(
origin_widget=bauiv1.get_special_widget('settings_button')
),
)
def _auxiliary_window_nav(
self,
win_type: type[bauiv1.MainWindow],
win_create_call: Callable[[], bauiv1.MainWindow],
) -> None:
"""Navigate to or away from an Auxiliary window.
Auxiliary windows can be thought of as 'side quests' in the
window hierarchy; places such as settings windows or league
ranking windows that the user might want to visit without losing
their place in the regular hierarchy.
"""
# pylint: disable=unidiomatic-typecheck
ui = babase.app.ui_v1
current_main_window = ui.get_main_window()
# Scan our ancestors for auxiliary states matching our type as
# well as auxiliary states in general.
aux_matching_state: bauiv1.MainWindowState | None = None
aux_state: bauiv1.MainWindowState | None = None
if current_main_window is None:
raise RuntimeError(
'Not currently handling no-top-level-window case.'
)
state = current_main_window.main_window_back_state
while state is not None:
assert state.window_type is not None
if state.is_auxiliary:
if state.window_type is win_type:
aux_matching_state = state
else:
aux_state = state
state = state.parent
# If there's an ancestor auxiliary window-state matching our
# type, back out past it (example: poking settings, navigating
# down a level or two, and then poking settings again should
# back out of settings).
if aux_matching_state is not None:
current_main_window.main_window_back_state = (
aux_matching_state.parent
)
current_main_window.main_window_back()
return
# If there's an ancestory auxiliary state *not* matching our
# type, crop the state and swap in our new auxiliary UI
# (example: poking settings, then poking account, then poking
# back should end up where things were before the settings
# poke).
if aux_state is not None:
# Blow away the window stack and build a fresh one.
ui.clear_main_window()
ui.set_main_window(
win_create_call(),
from_window=False, # Disable from-check.
back_state=aux_state.parent,
suppress_warning=True,
is_auxiliary=True,
)
return
# Ok, no auxiliary states found. Now if current window is
# auxiliary and the type matches, simply do a back.
if (
current_main_window.main_window_is_auxiliary
and type(current_main_window) is win_type
):
current_main_window.main_window_back()
return
# If current window is auxiliary but type doesn't match,
# swap it out for our new auxiliary UI.
if current_main_window.main_window_is_auxiliary:
ui.clear_main_window()
ui.set_main_window(
win_create_call(),
from_window=False, # Disable from-check.
back_state=current_main_window.main_window_back_state,
suppress_warning=True,
is_auxiliary=True,
)
return
# Ok, no existing auxiliary stuff was found period. Just
# navigate forward to this UI.
current_main_window.main_window_replace(
win_create_call(), is_auxiliary=True
)
def _root_ui_achievements_press(self) -> None:
from bauiv1lib.achievements import AchievementsWindow
if not self._ensure_signed_in_v1():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=AchievementsWindow,
win_create_call=lambda: AchievementsWindow(
origin_widget=bauiv1.get_special_widget(
'achievements_button'
)
),
)
)
def _root_ui_inbox_press(self) -> None:
from bauiv1lib.inbox import InboxWindow
if not self._ensure_signed_in():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=InboxWindow,
win_create_call=lambda: InboxWindow(
origin_widget=bauiv1.get_special_widget('inbox_button')
),
)
)
def _root_ui_store_press(self) -> None:
from bauiv1lib.store.browser import StoreBrowserWindow
if not self._ensure_signed_in_v1():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=StoreBrowserWindow,
win_create_call=lambda: StoreBrowserWindow(
origin_widget=bauiv1.get_special_widget('store_button')
),
)
)
def _root_ui_tickets_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
)
def _root_ui_tokens_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
)
def _root_ui_trophy_meter_press(self) -> None:
from bauiv1lib.league.rankwindow import LeagueRankWindow
if not self._ensure_signed_in_v1():
return
self._auxiliary_window_nav(
win_type=LeagueRankWindow,
win_create_call=lambda: LeagueRankWindow(
origin_widget=bauiv1.get_special_widget('trophy_meter')
),
)
def _root_ui_level_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'xp', origin_widget=bauiv1.get_special_widget('level_meter')
)
def _root_ui_inventory_press(self) -> None:
from bauiv1lib.inventory import InventoryWindow
if not self._ensure_signed_in_v1():
return
self._auxiliary_window_nav(
win_type=InventoryWindow,
win_create_call=lambda: InventoryWindow(
origin_widget=bauiv1.get_special_widget('inventory_button')
),
)
def _ensure_signed_in(self) -> bool:
"""Make sure we're signed in (requiring modern v2 accounts)."""
plus = bauiv1.app.plus
if plus is None:
bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
bauiv1.getsound('error').play()
return False
if plus.accounts.primary is None:
show_sign_in_prompt()
return False
return True
def _ensure_signed_in_v1(self) -> bool:
"""Make sure we're signed in (allowing legacy v1-only accounts)."""
plus = bauiv1.app.plus
if plus is None:
bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
bauiv1.getsound('error').play()
return False
if plus.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return False
return True
def _root_ui_get_tokens_press(self) -> None:
from bauiv1lib.gettokens import GetTokensWindow
if not self._ensure_signed_in():
return
self._auxiliary_window_nav(
win_type=GetTokensWindow,
win_create_call=lambda: GetTokensWindow(
origin_widget=bauiv1.get_special_widget('get_tokens_button')
),
)
def _root_ui_chest_slot_pressed(self, index: int) -> None:
from bauiv1lib.chest import (
ChestWindow0,
ChestWindow1,
ChestWindow2,
ChestWindow3,
)
widgetid: Literal[
'chest_0_button',
'chest_1_button',
'chest_2_button',
'chest_3_button',
]
winclass: type[ChestWindow]
if index == 0:
widgetid = 'chest_0_button'
winclass = ChestWindow0
elif index == 1:
widgetid = 'chest_1_button'
winclass = ChestWindow1
elif index == 2:
widgetid = 'chest_2_button'
winclass = ChestWindow2
elif index == 3:
widgetid = 'chest_3_button'
winclass = ChestWindow3
else:
raise RuntimeError(f'Invalid index {index}')
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=winclass,
win_create_call=lambda: winclass(
index=index,
origin_widget=bauiv1.get_special_widget(widgetid),
),
)
)

View file

@ -3,10 +3,10 @@
"""Provides classic app subsystem."""
from __future__ import annotations
from typing import TYPE_CHECKING, override
import random
import logging
import weakref
from typing import TYPE_CHECKING, override, assert_never
from efro.dataclassio import dataclass_from_dict
import babase
@ -26,15 +26,15 @@ from baclassic import _input
if TYPE_CHECKING:
from typing import Callable, Any, Sequence
import bacommon.bs
from bascenev1lib.actor import spazappearance
from bauiv1lib.party import PartyWindow
from baclassic._appdelegate import AppDelegate
from baclassic._servermode import ServerController
from baclassic._net import MasterServerCallback
class ClassicSubsystem(babase.AppSubsystem):
class ClassicAppSubsystem(babase.AppSubsystem):
"""Subsystem for classic functionality in the app.
The single shared instance of this app can be accessed at
@ -45,7 +45,6 @@ class ClassicSubsystem(babase.AppSubsystem):
# pylint: disable=too-many-public-methods
# noinspection PyUnresolvedReferences
from baclassic._music import MusicPlayMode
def __init__(self) -> None:
@ -72,10 +71,14 @@ class ClassicSubsystem(babase.AppSubsystem):
self.stress_test_update_timer: babase.AppTimer | None = None
self.stress_test_update_timer_2: babase.AppTimer | None = None
self.value_test_defaults: dict = {}
self.special_offer: dict | None = None
self.ping_thread_count = 0
self.allow_ticket_purchases: bool = True
# Classic-specific account state.
self.remove_ads = False
self.gold_pass = False
self.chest_dock_full = False
# Main Menu.
self.main_menu_did_initial_transition = False
self.main_menu_last_news_fetch_time: float | None = None
@ -93,17 +96,17 @@ class ClassicSubsystem(babase.AppSubsystem):
# We include this extra hash with shared input-mapping names so
# that we don't share mappings between differently-configured
# systems. For instance, different android devices may give different
# key values for the same controller type so we keep their mappings
# distinct.
# systems. For instance, different android devices may give
# different key values for the same controller type so we keep
# their mappings distinct.
self.input_map_hash: str | None = None
# Maps.
self.maps: dict[str, type[bascenev1.Map]] = {}
# Gameplay.
self.teams_series_length = 7 # deprecated, left for old mods
self.ffa_series_length = 24 # deprecated, left for old mods
self.teams_series_length = 7 # Deprecated, left for old mods.
self.ffa_series_length = 24 # Deprecated, left for old mods.
self.coop_session_args: dict = {}
# UI.
@ -111,8 +114,9 @@ class ClassicSubsystem(babase.AppSubsystem):
self.did_menu_intro = False # FIXME: Move to mainmenu class.
self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu.
self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any.
self.delegate: AppDelegate | None = None
self.party_window: weakref.ref[PartyWindow] | None = None
self.main_menu_resume_callbacks: list = []
self.saved_ui_state: bauiv1.MainWindowState | None = None
# Store.
self.store_layout: dict[str, list[dict[str, Any]]] | None = None
@ -120,6 +124,16 @@ class ClassicSubsystem(babase.AppSubsystem):
self.pro_sale_start_time: int | None = None
self.pro_sale_start_val: int | None = None
def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
"""(internal)"""
# If there's no main window up, just call immediately.
if not babase.app.ui_v1.has_main_window():
with babase.ContextRef.empty():
call()
else:
self.main_menu_resume_callbacks.append(call)
@property
def platform(self) -> str:
"""Name of the current platform.
@ -154,8 +168,6 @@ class ClassicSubsystem(babase.AppSubsystem):
from bascenev1lib.actor import spazappearance
from bascenev1lib import maps as stdmaps
from baclassic._appdelegate import AppDelegate
plus = babase.app.plus
assert plus is not None
@ -164,34 +176,13 @@ class ClassicSubsystem(babase.AppSubsystem):
self.music.on_app_loading()
self.delegate = AppDelegate()
# Non-test, non-debug builds should generally be blessed; warn if not.
# (so I don't accidentally release a build that can't play tourneys)
# Non-test, non-debug builds should generally be blessed; warn
# if not (so I don't accidentally release a build that can't
# play tourneys).
if not env.debug and not env.test and not plus.is_blessed():
babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
# FIXME: This should not be hard-coded.
for maptype in [
stdmaps.HockeyStadium,
stdmaps.FootballStadium,
stdmaps.Bridgit,
stdmaps.BigG,
stdmaps.Roundabout,
stdmaps.MonkeyFace,
stdmaps.ZigZag,
stdmaps.ThePad,
stdmaps.DoomShroom,
stdmaps.LakeFrigid,
stdmaps.TipTop,
stdmaps.CragCastle,
stdmaps.TowerD,
stdmaps.HappyThoughts,
stdmaps.StepRightUp,
stdmaps.Courtyard,
stdmaps.Rampage,
]:
bascenev1.register_map(maptype)
stdmaps.register_all_maps()
spazappearance.register_appearances()
bascenev1.init_campaigns()
@ -207,24 +198,6 @@ class ClassicSubsystem(babase.AppSubsystem):
cfg['launchCount'] = launch_count
cfg.commit()
# Run a test in a few seconds to see if we should pop up an existing
# pending special offer.
def check_special_offer() -> None:
assert plus is not None
from bauiv1lib.specialoffer import show_offer
if (
'pendingSpecialOffer' in cfg
and plus.get_v1_account_public_login_id()
== cfg['pendingSpecialOffer']['a']
):
self.special_offer = cfg['pendingSpecialOffer']['o']
show_offer()
if babase.app.env.gui:
babase.apptimer(3.0, check_special_offer)
# If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it.
babase.handle_leftover_v1_cloud_log_file()
@ -261,8 +234,8 @@ class ClassicSubsystem(babase.AppSubsystem):
from babase import Lstr
from bascenev1 import NodeActor
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
# FIXME: Shouldn't be touching scene stuff here; should just
# pass the request on to the host-session.
with activity.context:
globs = activity.globalsnode
if not globs.paused:
@ -289,8 +262,8 @@ class ClassicSubsystem(babase.AppSubsystem):
to resume.
"""
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
# FIXME: Shouldn't be touching scene stuff here; should just
# pass the request on to the host-session.
activity = bascenev1.get_foreground_host_activity()
if activity is not None:
with activity.context:
@ -340,6 +313,9 @@ class ClassicSubsystem(babase.AppSubsystem):
)
return False
# Save where we are in the UI to come back to when done.
babase.app.classic.save_ui_state()
# Ok, we're good to go.
self.coop_session_args = {
'campaign': campaignname,
@ -374,24 +350,24 @@ class ClassicSubsystem(babase.AppSubsystem):
assert plus is not None
if reset_ui:
babase.app.ui_v1.clear_main_menu_window()
babase.app.ui_v1.clear_main_window()
if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
# It may be possible we're on the main menu but the screen is faded
# so fade back in.
# It may be possible we're on the main menu but the screen
# is faded so fade back in.
babase.fade_screen(True)
return
_benchmark.stop_stress_test() # Stop stress-test if in progress.
# If we're in a host-session, tell them to end.
# This lets them tear themselves down gracefully.
# If we're in a host-session, tell them to end. This lets them
# tear themselves down gracefully.
host_session: bascenev1.Session | None = (
bascenev1.get_foreground_host_session()
)
if host_session is not None:
# Kick off a little transaction so we'll hopefully have all the
# latest account state when we get back to the menu.
# Kick off a little transaction so we'll hopefully have all
# the latest account state when we get back to the menu.
plus.add_v1_account_transaction(
{'type': 'END_SESSION', 'sType': str(type(host_session))}
)
@ -518,11 +494,36 @@ class ClassicSubsystem(babase.AppSubsystem):
request, 'post', data, callback, response_type
).start()
def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
def set_tournament_prize_image(
self, entry: dict[str, Any], index: int, image: bauiv1.Widget
) -> None:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
return _tournament.get_tournament_prize_strings(entry)
return _tournament.set_tournament_prize_chest_image(entry, index, image)
def create_in_game_tournament_prize_image(
self,
entry: dict[str, Any],
index: int,
position: tuple[float, float],
) -> None:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
_tournament.create_in_game_tournament_prize_image(
entry, index, position
)
def get_tournament_prize_strings(
self, entry: dict[str, Any], include_tickets: bool
) -> list[str]:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
return _tournament.get_tournament_prize_strings(
entry, include_tickets=include_tickets
)
def getcampaign(self, name: str) -> bascenev1.Campaign:
"""Return a campaign by name."""
@ -536,26 +537,21 @@ class ClassicSubsystem(babase.AppSubsystem):
tip = self.tips.pop()
return tip
def run_gpu_benchmark(self) -> None:
"""Kick off a benchmark to test gpu speeds."""
from baclassic._benchmark import run_gpu_benchmark as run
run()
def run_cpu_benchmark(self) -> None:
"""Kick off a benchmark to test cpu speeds."""
from baclassic._benchmark import run_cpu_benchmark as run
from baclassic._benchmark import run_cpu_benchmark
run()
run_cpu_benchmark()
def run_media_reload_benchmark(self) -> None:
"""Kick off a benchmark to test media reloading speeds."""
from baclassic._benchmark import run_media_reload_benchmark as run
from baclassic._benchmark import run_media_reload_benchmark
run()
run_media_reload_benchmark()
def run_stress_test(
self,
*,
playlist_type: str = 'Random',
playlist_name: str = '__default__',
player_count: int = 8,
@ -563,9 +559,9 @@ class ClassicSubsystem(babase.AppSubsystem):
attract_mode: bool = False,
) -> None:
"""Run a stress test."""
from baclassic._benchmark import run_stress_test as run
from baclassic._benchmark import run_stress_test
run(
run_stress_test(
playlist_type=playlist_type,
playlist_name=playlist_name,
player_count=player_count,
@ -684,14 +680,6 @@ class ClassicSubsystem(babase.AppSubsystem):
babase.Call(ServerDialogWindow, sddata),
)
def ticket_icon_press(self) -> None:
"""(internal)"""
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
origin_widget=bauiv1.get_special_widget('tickets_info_button')
)
def show_url_window(self, address: str) -> None:
"""(internal)"""
from bauiv1lib.url import ShowURLWindow
@ -707,6 +695,7 @@ class ClassicSubsystem(babase.AppSubsystem):
def tournament_entry_window(
self,
tournament_id: str,
*,
tournament_activity: bascenev1.Activity | None = None,
position: tuple[float, float] = (0.0, 0.0),
delegate: Any = None,
@ -733,30 +722,32 @@ class ClassicSubsystem(babase.AppSubsystem):
return MainMenuSession
def continues_window(
self,
activity: bascenev1.Activity,
cost: int,
continue_call: Callable[[], Any],
cancel_call: Callable[[], Any],
) -> None:
"""(internal)"""
from bauiv1lib.continues import ContinuesWindow
ContinuesWindow(activity, cost, continue_call, cancel_call)
def profile_browser_window(
self,
transition: str = 'in_right',
in_main_menu: bool = True,
selected_profile: str | None = None,
origin_widget: bauiv1.Widget | None = None,
selected_profile: str | None = None,
) -> None:
"""(internal)"""
from bauiv1lib.profile.browser import ProfileBrowserWindow
ProfileBrowserWindow(
transition, in_main_menu, selected_profile, origin_widget
main_window = babase.app.ui_v1.get_main_window()
if main_window is not None:
logging.warning(
'profile_browser_window()'
' called with existing main window; should not happen.'
)
return
babase.app.ui_v1.set_main_window(
ProfileBrowserWindow(
transition=transition,
selected_profile=selected_profile,
origin_widget=origin_widget,
minimal_toolbar=True,
),
is_top_level=True,
suppress_warning=True,
)
def preload_map_preview_media(self) -> None:
@ -781,6 +772,9 @@ class ClassicSubsystem(babase.AppSubsystem):
assert app.env.gui
# Play explicit swish sound so it occurs due to keypresses/etc.
# This means we have to disable it for any button or else we get
# double.
bauiv1.getsound('swish').play()
# If it exists, dismiss it; otherwise make a new one.
@ -794,18 +788,182 @@ class ClassicSubsystem(babase.AppSubsystem):
def device_menu_press(self, device_id: int | None) -> None:
"""(internal)"""
from bauiv1lib.mainmenu import MainMenuWindow
from bauiv1lib.ingamemenu import InGameMenuWindow
from bauiv1 import set_ui_input_device
assert babase.app is not None
in_main_menu = babase.app.ui_v1.has_main_menu_window()
in_main_menu = babase.app.ui_v1.has_main_window()
if not in_main_menu:
set_ui_input_device(device_id)
# Hack(ish). We play swish sound here so it happens for
# device presses, but this means we need to disable default
# swish sounds for any menu buttons or we'll get double.
if babase.app.env.gui:
bauiv1.getsound('swish').play()
babase.app.ui_v1.set_main_menu_window(
MainMenuWindow().get_root_widget(),
from_window=False, # Disable check here.
babase.app.ui_v1.set_main_window(
InGameMenuWindow(), is_top_level=True, suppress_warning=True
)
def save_ui_state(self) -> None:
"""Store our current place in the UI."""
ui = babase.app.ui_v1
mainwindow = ui.get_main_window()
if mainwindow is not None:
self.saved_ui_state = ui.save_main_window_state(mainwindow)
else:
self.saved_ui_state = None
def invoke_main_menu_ui(self) -> None:
"""Bring up main menu ui."""
# Bring up the last place we were, or start at the main menu
# otherwise.
app = bauiv1.app
env = app.env
with bascenev1.ContextRef.empty():
assert app.classic is not None
if app.env.headless:
# UI stuff fails now in headless builds; avoid it.
pass
else:
# When coming back from a kiosk-mode game, jump to the
# kiosk start screen.
if env.demo or env.arcade:
# pylint: disable=cyclic-import
from bauiv1lib.kiosk import KioskWindow
app.ui_v1.set_main_window(
KioskWindow(), is_top_level=True, suppress_warning=True
)
else:
# If there's a saved ui state, restore that.
if self.saved_ui_state is not None:
app.ui_v1.restore_main_window_state(self.saved_ui_state)
else:
# Otherwise start fresh at the main menu.
from bauiv1lib.mainmenu import MainMenuWindow
app.ui_v1.set_main_window(
MainMenuWindow(transition=None),
is_top_level=True,
suppress_warning=True,
)
@staticmethod
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
"""Run client effects sent from the master server."""
from baclassic._clienteffect import run_bs_client_effects
run_bs_client_effects(effects)
@staticmethod
def basic_client_ui_button_label_str(
label: bacommon.bs.BasicClientUI.ButtonLabel,
) -> babase.Lstr:
"""Given a client-ui label, return an Lstr."""
import bacommon.bs
cls = bacommon.bs.BasicClientUI.ButtonLabel
if label is cls.UNKNOWN:
# Server should not be sending us unknown stuff; make noise
# if they do.
logging.error(
'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.'
)
return babase.Lstr(value='<error>')
rsrc: str | None = None
if label is cls.OK:
rsrc = 'okText'
elif label is cls.APPLY:
rsrc = 'applyText'
elif label is cls.CANCEL:
rsrc = 'cancelText'
elif label is cls.ACCEPT:
rsrc = 'gatherWindow.partyInviteAcceptText'
elif label is cls.DECLINE:
rsrc = 'gatherWindow.partyInviteDeclineText'
elif label is cls.IGNORE:
rsrc = 'gatherWindow.partyInviteIgnoreText'
elif label is cls.CLAIM:
rsrc = 'claimText'
elif label is cls.DISCARD:
rsrc = 'discardText'
else:
assert_never(label)
return babase.Lstr(resource=rsrc)
def required_purchases_for_game(self, game: str) -> list[str]:
"""Return which purchase (if any) is required for a game."""
# pylint: disable=too-many-return-statements
if game in (
'Challenges:Infinite Runaround',
'Challenges:Tournament Infinite Runaround',
):
# Special case: Pro used to unlock this.
return (
[]
if self.accounts.have_pro()
else ['upgrades.infinite_runaround']
)
if game in (
'Challenges:Infinite Onslaught',
'Challenges:Tournament Infinite Onslaught',
):
# Special case: Pro used to unlock this.
return (
[]
if self.accounts.have_pro()
else ['upgrades.infinite_onslaught']
)
if game in (
'Challenges:Meteor Shower',
'Challenges:Epic Meteor Shower',
):
return ['games.meteor_shower']
if game in (
'Challenges:Target Practice',
'Challenges:Target Practice B',
):
return ['games.target_practice']
if game in (
'Challenges:Ninja Fight',
'Challenges:Pro Ninja Fight',
):
return ['games.ninja_fight']
if game in ('Challenges:Race', 'Challenges:Pro Race'):
return ['games.race']
if game in ('Challenges:Lake Frigid Race',):
return ['games.race', 'maps.lake_frigid']
if game in (
'Challenges:Easter Egg Hunt',
'Challenges:Pro Easter Egg Hunt',
):
return ['games.easter_egg_hunt']
return []
def is_game_unlocked(self, game: str) -> bool:
"""Is a particular game unlocked?"""
plus = babase.app.plus
assert plus is not None
purchases = self.required_purchases_for_game(game)
if not purchases:
return True
for purchase in purchases:
if not plus.get_v1_account_product_purchased(purchase):
return False
return True

View file

@ -20,11 +20,14 @@ def run_cpu_benchmark() -> None:
# pylint: disable=cyclic-import
from bascenev1lib import tutorial
# Save our UI state that we'll return to when done.
if babase.app.classic is not None:
babase.app.classic.save_ui_state()
class BenchmarkSession(bascenev1.Session):
"""Session type for cpu benchmark."""
def __init__(self) -> None:
# print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.')
depsets: Sequence[bascenev1.DependencySet] = []
super().__init__(depsets)
@ -99,7 +102,9 @@ def _start_stress_test(args: _StressTestArgs) -> None:
"""(internal)"""
from bascenev1 import DualTeamSession, FreeForAllSession
assert babase.app.classic is not None
classic = babase.app.classic
assert classic is not None
appconfig = babase.app.config
playlist_type = args.playlist_type
@ -116,6 +121,10 @@ def _start_stress_test(args: _StressTestArgs) -> None:
+ args.playlist_name
+ '")...'
)
# Save where we are in the UI so we'll return there when done.
classic.save_ui_state()
if playlist_type == 'Teams':
appconfig['Team Tournament Playlist Selection'] = args.playlist_name
appconfig['Team Tournament Playlist Randomize'] = 1
@ -137,11 +146,11 @@ def _start_stress_test(args: _StressTestArgs) -> None:
),
)
_baclassic.set_stress_testing(True, args.player_count, args.attract_mode)
babase.app.classic.stress_test_update_timer = babase.AppTimer(
classic.stress_test_update_timer = babase.AppTimer(
args.round_duration, babase.Call(_reset_stress_test, args)
)
if args.attract_mode:
babase.app.classic.stress_test_update_timer_2 = babase.AppTimer(
classic.stress_test_update_timer_2 = babase.AppTimer(
0.48, babase.Call(_update_attract_mode_test, args), repeat=True
)
@ -170,12 +179,6 @@ def _reset_stress_test(args: _StressTestArgs) -> None:
babase.apptimer(1.0, babase.Call(_start_stress_test, args))
def run_gpu_benchmark() -> None:
"""Kick off a benchmark to test gpu speeds."""
# FIXME: Not wired up yet.
babase.screenmessage('Not wired up yet.', color=(1, 0, 0))
def run_media_reload_benchmark() -> None:
"""Kick off a benchmark to test media reloading speeds."""
babase.reload_media()
@ -199,6 +202,6 @@ def run_media_reload_benchmark() -> None:
babase.add_clean_frame_callback(babase.Call(doit, start_time))
# The reload starts (should add a completion callback to the
# reload func to fix this).
# The reload starts (should add a completion callback to the reload
# func to fix this).
babase.apptimer(0.05, babase.Call(delay_add, babase.apptime()))

91
dist/ba_data/python/baclassic/_chest.py vendored Normal file
View file

@ -0,0 +1,91 @@
# Released under the MIT License. See LICENSE for details.
#
"""Chest related functionality."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
if TYPE_CHECKING:
pass
@dataclass
class ChestAppearanceDisplayInfo:
"""Info about how to locally display chest appearances."""
# NOTE TO SELF: Don't rename these attrs; the C++ layer is hard
# coded to look for them.
texclosed: str
texclosedtint: str
texopen: str
texopentint: str
color: tuple[float, float, float]
tint: tuple[float, float, float]
tint2: tuple[float, float, float]
# Info for chest types we know how to draw. Anything not found in here
# should fall back to the DEFAULT entry.
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT = ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(1, 1, 1),
tint=(1, 1, 1),
tint2=(1, 1, 1),
)
CHEST_APPEARANCE_DISPLAY_INFOS: dict[
ClassicChestAppearance, ChestAppearanceDisplayInfo
] = {
ClassicChestAppearance.L2: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.8, 1.0, 0.93),
tint=(0.65, 1.0, 0.8),
tint2=(0.65, 1.0, 0.8),
),
ClassicChestAppearance.L3: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.75, 0.9, 1.3),
tint=(0.7, 1, 1.9),
tint2=(0.7, 1, 1.9),
),
ClassicChestAppearance.L4: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.7, 1.0, 1.4),
tint=(1.4, 1.6, 2.0),
tint2=(1.4, 1.6, 2.0),
),
ClassicChestAppearance.L5: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.75, 0.5, 2.4),
tint=(1.0, 0.8, 0.0),
tint2=(1.0, 0.8, 0.0),
),
ClassicChestAppearance.L6: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(1.1, 0.8, 0.0),
tint=(2, 2, 2),
tint2=(2, 2, 2),
),
}

View file

@ -0,0 +1,77 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to running client-effects from the master server."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, assert_never
from efro.util import strict_partial
import bacommon.bs
import bauiv1
if TYPE_CHECKING:
pass
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
"""Run effects."""
# pylint: disable=too-many-branches
delay = 0.0
for effect in effects:
if isinstance(effect, bacommon.bs.ClientEffectScreenMessage):
textfin = bauiv1.Lstr(
translate=('serverResponses', effect.message)
).evaluate()
if effect.subs is not None:
# Should always be even.
assert len(effect.subs) % 2 == 0
for j in range(0, len(effect.subs) - 1, 2):
textfin = textfin.replace(
effect.subs[j],
effect.subs[j + 1],
)
bauiv1.apptimer(
delay,
strict_partial(
bauiv1.screenmessage, textfin, color=effect.color
),
)
elif isinstance(effect, bacommon.bs.ClientEffectSound):
smcls = bacommon.bs.ClientEffectSound.Sound
soundfile: str | None = None
if effect.sound is smcls.UNKNOWN:
# Server should avoid sending us sounds we don't
# support. Make some noise if it happens.
logging.error('Got unrecognized bacommon.bs.ClientEffectSound.')
elif effect.sound is smcls.CASH_REGISTER:
soundfile = 'cashRegister'
elif effect.sound is smcls.ERROR:
soundfile = 'error'
elif effect.sound is smcls.POWER_DOWN:
soundfile = 'powerdown01'
elif effect.sound is smcls.GUN_COCKING:
soundfile = 'gunCocking'
else:
assert_never(effect.sound)
if soundfile is not None:
bauiv1.apptimer(
delay,
strict_partial(
bauiv1.getsound(soundfile).play, volume=effect.volume
),
)
elif isinstance(effect, bacommon.bs.ClientEffectDelay):
delay += effect.seconds
else:
# 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.'
)

View file

@ -0,0 +1,109 @@
# Released under the MIT License. See LICENSE for details.
#
"""Display-item related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from efro.util import pairs_from_flat
import bacommon.bs
import bauiv1
if TYPE_CHECKING:
pass
def show_display_item(
itemwrapper: bacommon.bs.DisplayItemWrapper,
parent: bauiv1.Widget,
pos: tuple[float, float],
width: float,
) -> None:
"""Create ui to depict a display-item."""
height = width * 0.666
# Silent no-op if our parent ui is dead.
if not parent:
return
img: str | None = None
img_y_offs = 0.0
text_y_offs = 0.0
show_text = True
if isinstance(itemwrapper.item, bacommon.bs.TicketsDisplayItem):
img = 'tickets'
img_y_offs = width * 0.11
text_y_offs = width * -0.15
elif isinstance(itemwrapper.item, bacommon.bs.TokensDisplayItem):
img = 'coin'
img_y_offs = width * 0.11
text_y_offs = width * -0.15
elif isinstance(itemwrapper.item, bacommon.bs.ChestDisplayItem):
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
img = None
show_text = False
c_info = CHEST_APPEARANCE_DISPLAY_INFOS.get(
itemwrapper.item.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
c_size = width * 0.85
bauiv1.imagewidget(
parent=parent,
position=(pos[0] - c_size * 0.5, pos[1] - c_size * 0.5),
color=c_info.color,
size=(c_size, c_size),
texture=bauiv1.gettexture(c_info.texclosed),
tint_texture=bauiv1.gettexture(c_info.texclosedtint),
tint_color=c_info.tint,
tint2_color=c_info.tint2,
)
# Enable this for testing spacing.
if bool(False):
bauiv1.imagewidget(
parent=parent,
position=(
pos[0] - width * 0.5,
pos[1] - height * 0.5,
),
size=(width, height),
texture=bauiv1.gettexture('white'),
color=(0, 1, 0),
opacity=0.1,
)
imgsize = width * 0.33
if img is not None:
bauiv1.imagewidget(
parent=parent,
position=(
pos[0] - imgsize * 0.5,
pos[1] + img_y_offs - imgsize * 0.5,
),
size=(imgsize, imgsize),
texture=bauiv1.gettexture(img),
)
if show_text:
subs = itemwrapper.description_subs
if subs is None:
subs = []
bauiv1.textwidget(
parent=parent,
position=(pos[0], pos[1] + text_y_offs),
scale=width * 0.006,
size=(0, 0),
text=bauiv1.Lstr(
translate=('displayItemNames', itemwrapper.description),
subs=pairs_from_flat(subs),
),
maxwidth=width * 0.9,
color=(0.0, 1.0, 0.0),
h_align='center',
v_align='center',
)

View file

@ -16,6 +16,8 @@ from bascenev1 import MusicType
if TYPE_CHECKING:
from typing import Callable, Any
import bauiv1
class MusicPlayMode(Enum):
"""Influences behavior when playing music.
@ -389,7 +391,7 @@ class MusicPlayer:
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
) -> bauiv1.MainWindow:
"""Summons a UI to select a new soundtrack entry."""
return self.on_select_entry(
callback, current_entry, selection_target_name
@ -432,11 +434,12 @@ class MusicPlayer:
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
) -> bauiv1.MainWindow:
"""Present a GUI to select an entry.
The callback should be called with a valid entry or None to
signify that the default soundtrack should be used.."""
raise NotImplementedError()
# Subclasses should override the following:

View file

@ -35,6 +35,8 @@ class MasterServerV1CallThread(threading.Thread):
callback: MasterServerCallback | None,
response_type: MasterServerResponseType,
):
# pylint: disable=too-many-positional-arguments
# Set daemon=True so long-running requests don't keep us from
# quitting the app.
super().__init__(daemon=True)
@ -52,8 +54,9 @@ class MasterServerV1CallThread(threading.Thread):
self._activity = weakref.ref(activity) if activity is not None else None
def _run_callback(self, arg: None | dict[str, Any]) -> None:
# If we were created in an activity context and that activity has
# since died, do nothing.
# If we were created in an activity context and that activity
# has since died, do nothing.
# FIXME: Should we just be using a ContextCall instead of doing
# this check manually?
if self._activity is not None:

View file

@ -390,7 +390,7 @@ class ServerController:
f' ({app.env.engine_build_number})'
f' entering server-mode {curtimestr}{Clr.RST}'
)
logging.info(startupmsg)
print(startupmsg)
if sessiontype is bascenev1.FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name

View file

@ -26,6 +26,7 @@ class StoreSubsystem:
def get_store_item_name_translated(self, item_name: str) -> babase.Lstr:
"""Return a babase.Lstr for a store item name."""
# pylint: disable=cyclic-import
# pylint: disable=too-many-return-statements
item_info = self.get_store_item(item_name)
if item_name.startswith('characters.'):
return babase.Lstr(
@ -46,6 +47,14 @@ class StoreSubsystem:
return gametype.get_display_string()
if item_name.startswith('icons.'):
return babase.Lstr(resource='editProfileWindow.iconText')
if item_name == 'upgrades.infinite_runaround':
return babase.Lstr(
translate=('coopLevelNames', 'Infinite Runaround')
)
if item_name == 'upgrades.infinite_onslaught':
return babase.Lstr(
translate=('coopLevelNames', 'Infinite Onslaught')
)
raise ValueError('unrecognized item: ' + item_name)
def get_store_item_display_size(
@ -81,14 +90,17 @@ class StoreSubsystem:
assert babase.app.classic is not None
if babase.app.classic.store_items is None:
from bascenev1lib.game import ninjafight
from bascenev1lib.game import meteorshower
from bascenev1lib.game import targetpractice
from bascenev1lib.game import easteregghunt
from bascenev1lib.game.race import RaceGame
from bascenev1lib.game.ninjafight import NinjaFightGame
from bascenev1lib.game.meteorshower import MeteorShowerGame
from bascenev1lib.game.targetpractice import TargetPracticeGame
from bascenev1lib.game.easteregghunt import EasterEggHuntGame
# IMPORTANT - need to keep this synced with the master server.
# (doing so manually for now)
babase.app.classic.store_items = {
'upgrades.infinite_runaround': {},
'upgrades.infinite_onslaught': {},
'characters.kronk': {'character': 'Kronk'},
'characters.zoe': {'character': 'Zoe'},
'characters.jackmorgan': {'character': 'Jack Morgan'},
@ -111,20 +123,28 @@ class StoreSubsystem:
'merch': {},
'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.race': {
'gametype': RaceGame,
'previewTex': 'bigGPreview',
},
'games.ninja_fight': {
'gametype': ninjafight.NinjaFightGame,
'gametype': NinjaFightGame,
'previewTex': 'courtyardPreview',
},
'games.meteor_shower': {
'gametype': meteorshower.MeteorShowerGame,
'gametype': MeteorShowerGame,
'previewTex': 'rampagePreview',
},
'games.infinite_onslaught': {
'gametype': MeteorShowerGame,
'previewTex': 'rampagePreview',
},
'games.target_practice': {
'gametype': targetpractice.TargetPracticeGame,
'gametype': TargetPracticeGame,
'previewTex': 'doomShroomPreview',
},
'games.easter_egg_hunt': {
'gametype': easteregghunt.EasterEggHuntGame,
'gametype': EasterEggHuntGame,
'previewTex': 'towerDPreview',
},
'icons.flag_us': {
@ -365,9 +385,12 @@ class StoreSubsystem:
store_layout['minigames'] = [
{
'items': [
'games.race',
'games.ninja_fight',
'games.meteor_shower',
'games.target_practice',
'upgrades.infinite_onslaught',
'upgrades.infinite_runaround',
]
}
]
@ -446,8 +469,9 @@ class StoreSubsystem:
'price.' + item, None
)
if ticket_cost is not None:
if our_tickets >= ticket_cost and not plus.get_purchased(
item
if (
our_tickets >= ticket_cost
and not plus.get_v1_account_product_purchased(item)
):
count += 1
return count
@ -522,7 +546,7 @@ class StoreSubsystem:
for section in store_layout[tab]:
for item in section['items']:
if item in sales_raw:
if not plus.get_purchased(item):
if not plus.get_v1_account_product_purchased(item):
to_end = (
datetime.datetime.fromtimestamp(
sales_raw[item]['e'], datetime.UTC
@ -550,7 +574,10 @@ class StoreSubsystem:
if babase.app.env.gui:
for map_section in self.get_store_layout()['maps']:
for mapitem in map_section['items']:
if plus is None or not plus.get_purchased(mapitem):
if (
plus is None
or not plus.get_v1_account_product_purchased(mapitem)
):
m_info = self.get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
@ -563,7 +590,14 @@ class StoreSubsystem:
if babase.app.env.gui:
for section in self.get_store_layout()['minigames']:
for mname in section['items']:
if plus is None or not plus.get_purchased(mname):
if mname.startswith('upgrades.'):
# Ignore things like infinite onslaught which
# aren't actually game types.
continue
if (
plus is None
or not plus.get_v1_account_product_purchased(mname)
):
m_info = self.get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games

View file

@ -6,13 +6,23 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
import babase
import bauiv1
import bascenev1
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
if TYPE_CHECKING:
from typing import Any
def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
def get_tournament_prize_strings(
entry: dict[str, Any], include_tickets: bool
) -> list[str]:
"""Given a tournament entry, return strings for its prize levels."""
# pylint: disable=too-many-locals
from bascenev1 import get_trophy_string
@ -27,7 +37,7 @@ def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
trophy_type_2 = entry.get('prizeTrophy2')
trophy_type_3 = entry.get('prizeTrophy3')
out_vals = []
for rng, prize, trophy_type in (
for rng, ticket_prize, trophy_type in (
(range1, prize1, trophy_type_1),
(range2, prize2, trophy_type_2),
(range3, prize3, trophy_type_3),
@ -45,14 +55,100 @@ def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
if trophy_type is not None:
pvval += get_trophy_string(trophy_type)
# If we've got trophies but not for this entry, throw some space
# in to compensate so the ticket counts line up.
if prize is not None:
if ticket_prize is not None and include_tickets:
pvval = (
babase.charstr(babase.SpecialChar.TICKET_BACKING)
+ str(prize)
+ str(ticket_prize)
+ pvval
)
out_vals.append(prval)
out_vals.append(pvval)
return out_vals
def set_tournament_prize_chest_image(
entry: dict[str, Any], index: int, image: bauiv1.Widget
) -> None:
"""Set image attrs representing a tourney prize chest."""
ranges = [
entry.get('prizeRange1'),
entry.get('prizeRange2'),
entry.get('prizeRange3'),
]
chests = [
entry.get('prizeChest1'),
entry.get('prizeChest2'),
entry.get('prizeChest3'),
]
assert 0 <= index < 3
# If tourney doesn't include this prize, just hide the image.
if ranges[index] is None:
bauiv1.imagewidget(edit=image, opacity=0.0)
return
try:
appearance = ClassicChestAppearance(chests[index])
except ValueError:
appearance = ClassicChestAppearance.DEFAULT
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
bauiv1.imagewidget(
edit=image,
opacity=1.0,
color=chestdisplayinfo.color,
texture=bauiv1.gettexture(chestdisplayinfo.texclosed),
tint_texture=bauiv1.gettexture(chestdisplayinfo.texclosedtint),
tint_color=chestdisplayinfo.tint,
tint2_color=chestdisplayinfo.tint2,
)
def create_in_game_tournament_prize_image(
entry: dict[str, Any], index: int, position: tuple[float, float]
) -> None:
"""Create a display for the prize chest (if any) in-game."""
from bascenev1lib.actor.image import Image
ranges = [
entry.get('prizeRange1'),
entry.get('prizeRange2'),
entry.get('prizeRange3'),
]
chests = [
entry.get('prizeChest1'),
entry.get('prizeChest2'),
entry.get('prizeChest3'),
]
# If tourney doesn't include this prize, no-op.
if ranges[index] is None:
return
try:
appearance = ClassicChestAppearance(chests[index])
except ValueError:
appearance = ClassicChestAppearance.DEFAULT
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
Image(
# Provide magical extended dict version of texture that Image
# actor supports.
texture={
'texture': bascenev1.gettexture(chestdisplayinfo.texclosed),
'tint_texture': bascenev1.gettexture(
chestdisplayinfo.texclosedtint
),
'tint_color': chestdisplayinfo.tint,
'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color + (1.0,),
position=position,
scale=(48.0, 48.0),
transition=Image.Transition.FADE_IN,
transition_delay=2.0,
).autoretain()

View file

@ -15,6 +15,8 @@ from baclassic._music import MusicPlayer
if TYPE_CHECKING:
from typing import Callable, Any
import bauiv1
class MacMusicAppMusicPlayer(MusicPlayer):
"""A music-player that utilizes the macOS Music.app for playback.
@ -33,7 +35,7 @@ class MacMusicAppMusicPlayer(MusicPlayer):
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
) -> bauiv1.MainWindow:
# pylint: disable=cyclic-import
from bauiv1lib.soundtrack import entrytypeselect as etsel

View file

@ -16,6 +16,8 @@ from baclassic._music import MusicPlayer
if TYPE_CHECKING:
from typing import Callable, Any
import bauiv1
class OSMusicPlayer(MusicPlayer):
"""Music player that talks to internal C++ layer for functionality.
@ -39,7 +41,7 @@ class OSMusicPlayer(MusicPlayer):
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
) -> bauiv1.MainWindow:
# pylint: disable=cyclic-import
from bauiv1lib.soundtrack.entrytypeselect import (
SoundtrackEntryTypeSelectWindow,

View file

@ -31,8 +31,8 @@ class AppInterfaceIdiom(Enum):
class AppExperience(Enum):
"""A particular experience that can be 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
This is one metric used to isolate different playerbases from each
other where there might be no technical barriers doing so. For
example, a casual one-hand-playable phone game and an augmented
reality tabletop game may both use the same scene-versions and
networking-protocols and whatnot, but it would make no sense to
@ -75,10 +75,10 @@ class AppArchitecture(Enum):
class AppPlatform(Enum):
"""Overall platform a Ballistica build is targeting.
Each distinct flavor of an app has a unique combination
of AppPlatform and AppVariant. Generally platform describes
a set of hardware, while variant describes a destination or
purpose for the build.
Each distinct flavor of an app has a unique combination of
AppPlatform and AppVariant. Generally platform describes a set of
hardware, while variant describes a destination or purpose for the
build.
"""
MAC = 'mac'
@ -92,10 +92,10 @@ class AppPlatform(Enum):
class AppVariant(Enum):
"""A unique Ballistica build type within a single platform.
Each distinct flavor of an app has a unique combination
of AppPlatform and AppVariant. Generally platform describes
a set of hardware, while variant describes a destination or
purpose for the build.
Each distinct flavor of an app has a unique combination of
AppPlatform and AppVariant. Generally platform describes a set of
hardware, while variant describes a destination or purpose for the
build.
"""
# Default builds.

887
dist/ba_data/python/bacommon/bs.py vendored Normal file
View file

@ -0,0 +1,887 @@
# Released under the MIT License. See LICENSE for details.
#
"""BombSquad specific bits."""
from __future__ import annotations
import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never
from efro.util import pairs_to_flat
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from efro.message import Message, Response
@ioprepped
@dataclass
class PrivatePartyMessage(Message):
"""Message asking about info we need for private-party UI."""
need_datacode: Annotated[bool, IOAttrs('d')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [PrivatePartyResponse]
@ioprepped
@dataclass
class PrivatePartyResponse(Response):
"""Here's that private party UI info you asked for, boss."""
success: Annotated[bool, IOAttrs('s')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
datacode: Annotated[str | None, IOAttrs('d')]
class ClassicChestAppearance(Enum):
"""Appearances bombsquad classic chests can have."""
UNKNOWN = 'u'
DEFAULT = 'd'
L1 = 'l1'
L2 = 'l2'
L3 = 'l3'
L4 = 'l4'
L5 = 'l5'
L6 = 'l6'
@property
def pretty_name(self) -> str:
"""Pretty name for the chest in English."""
# pylint: disable=too-many-return-statements
cls = type(self)
if self is cls.UNKNOWN:
return 'Unknown Chest'
if self is cls.DEFAULT:
return 'Chest'
if self is cls.L1:
return 'L1 Chest'
if self is cls.L2:
return 'L2 Chest'
if self is cls.L3:
return 'L3 Chest'
if self is cls.L4:
return 'L4 Chest'
if self is cls.L5:
return 'L5 Chest'
if self is cls.L6:
return 'L6 Chest'
assert_never(self)
@ioprepped
@dataclass
class ClassicAccountLiveData:
"""Live account data fed to the client in the bs classic app mode."""
@dataclass
class Chest:
"""A lovely chest."""
appearance: Annotated[
ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
]
unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')]
class LeagueType(Enum):
"""Type of league we are in."""
BRONZE = 'b'
SILVER = 's'
GOLD = 'g'
DIAMOND = 'd'
tickets: Annotated[int, IOAttrs('ti')]
tokens: Annotated[int, IOAttrs('to')]
gold_pass: Annotated[bool, IOAttrs('g')]
remove_ads: Annotated[bool, IOAttrs('r')]
achievements: Annotated[int, IOAttrs('a')]
achievements_total: Annotated[int, IOAttrs('at')]
league_type: Annotated[LeagueType | None, IOAttrs('lt')]
league_num: Annotated[int | None, IOAttrs('ln')]
league_rank: Annotated[int | None, IOAttrs('lr')]
level: Annotated[int, IOAttrs('lv')]
xp: Annotated[int, IOAttrs('xp')]
xpmax: Annotated[int, IOAttrs('xpm')]
inbox_count: Annotated[int, IOAttrs('ibc')]
inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')]
chests: Annotated[dict[str, Chest], IOAttrs('c')]
class DisplayItemTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
TICKETS = 't'
TOKENS = 'k'
TEST = 's'
CHEST = 'c'
class DisplayItem(IOMultiType[DisplayItemTypeID]):
"""Some amount of something that can be shown or described.
Used to depict chest contents or other rewards or prices.
"""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: DisplayItemTypeID) -> type[DisplayItem]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = DisplayItemTypeID
if type_id is t.UNKNOWN:
return UnknownDisplayItem
if type_id is t.TICKETS:
return TicketsDisplayItem
if type_id is t.TOKENS:
return TokensDisplayItem
if type_id is t.TEST:
return TestDisplayItem
if type_id is t.CHEST:
return ChestDisplayItem
# Important to make sure we provide all types.
assert_never(type_id)
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
"""Return a string description and subs for the item.
These decriptions are baked into the DisplayItemWrapper and
should be accessed from there when available. This allows
clients to give descriptions even for newer display items they
don't recognize.
"""
raise NotImplementedError()
# Implement fallbacks so client can digest item lists even if they
# contain unrecognized stuff. DisplayItemWrapper contains basic
# baked down info that they can still use in such cases.
@override
@classmethod
def get_unknown_type_fallback(cls) -> DisplayItem:
return UnknownDisplayItem()
@ioprepped
@dataclass
class UnknownDisplayItem(DisplayItem):
"""Something we don't know how to display."""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.UNKNOWN
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
import logging
# Make noise but don't break.
logging.exception(
'UnknownDisplayItem.get_description() should never be called.'
' Always access descriptions on the DisplayItemWrapper.'
)
return 'Unknown', []
@ioprepped
@dataclass
class TicketsDisplayItem(DisplayItem):
"""Some amount of tickets."""
count: Annotated[int, IOAttrs('c')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TICKETS
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return '${C} Tickets', [('${C}', str(self.count))]
@ioprepped
@dataclass
class TokensDisplayItem(DisplayItem):
"""Some amount of tokens."""
count: Annotated[int, IOAttrs('c')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TOKENS
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return '${C} Tokens', [('${C}', str(self.count))]
@ioprepped
@dataclass
class TestDisplayItem(DisplayItem):
"""Fills usable space for a display-item - good for calibration."""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TEST
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return 'Test Display Item Here', []
@ioprepped
@dataclass
class ChestDisplayItem(DisplayItem):
"""Display a chest."""
appearance: Annotated[ClassicChestAppearance, IOAttrs('a')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.CHEST
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return self.appearance.pretty_name, []
@ioprepped
@dataclass
class DisplayItemWrapper:
"""Wraps a DisplayItem and common info."""
item: Annotated[DisplayItem, IOAttrs('i')]
description: Annotated[str, IOAttrs('d')]
description_subs: Annotated[list[str] | None, IOAttrs('s')]
@classmethod
def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper:
"""Convenience method to wrap a DisplayItem."""
desc, subs = item.get_description()
return DisplayItemWrapper(item, desc, pairs_to_flat(subs))
@ioprepped
@dataclass
class ChestInfoMessage(Message):
"""Request info about a chest."""
chest_id: Annotated[str, IOAttrs('i')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ChestInfoResponse]
@ioprepped
@dataclass
class ChestInfoResponse(Response):
"""Here's that chest info you asked for, boss."""
@dataclass
class Chest:
"""A lovely chest."""
@dataclass
class PrizeSet:
"""A possible set of prizes for this chest."""
weight: Annotated[float, IOAttrs('w')]
contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')]
appearance: Annotated[
ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
]
# How much it costs to unlock *now*.
unlock_tokens: Annotated[int, IOAttrs('tk')]
# When it unlocks on its own.
unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
# Possible prizes we contain.
prizesets: Annotated[list[PrizeSet], IOAttrs('p')]
# Are ads allowed now?
ad_allow: Annotated[bool, IOAttrs('aa')]
chest: Annotated[Chest | None, IOAttrs('c')]
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."""
UNKNOWN = 'u'
BASIC = 'b'
class ClientUI(IOMultiType[ClientUITypeID]):
"""Defines some user interface on the client."""
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: ClientUITypeID) -> type[ClientUI]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
out: type[ClientUI]
t = ClientUITypeID
if type_id is t.UNKNOWN:
out = UnknownClientUI
elif type_id is t.BASIC:
out = BasicClientUI
else:
# Important to make sure we provide all types.
assert_never(type_id)
return out
@override
@classmethod
def get_unknown_type_fallback(cls) -> ClientUI:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return UnknownClientUI()
@ioprepped
@dataclass
class UnknownClientUI(ClientUI):
"""Fallback type for unrecognized entries."""
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
return ClientUITypeID.UNKNOWN
class BasicClientUIComponentTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
TEXT = 't'
LINK = 'l'
BS_CLASSIC_TOURNEY_RESULT = 'ct'
DISPLAY_ITEMS = 'di'
EXPIRE_TIME = 'd'
class BasicClientUIComponent(IOMultiType[BasicClientUIComponentTypeID]):
"""Top level class for our multitype."""
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(
cls, type_id: BasicClientUIComponentTypeID
) -> type[BasicClientUIComponent]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = BasicClientUIComponentTypeID
if type_id is t.UNKNOWN:
return BasicClientUIComponentUnknown
if type_id is t.TEXT:
return BasicClientUIComponentText
if type_id is t.LINK:
return BasicClientUIComponentLink
if type_id is t.BS_CLASSIC_TOURNEY_RESULT:
return BasicClientUIBsClassicTourneyResult
if type_id is t.DISPLAY_ITEMS:
return BasicClientUIDisplayItems
if type_id is t.EXPIRE_TIME:
return BasicClientUIExpireTime
# Important to make sure we provide all types.
assert_never(type_id)
@override
@classmethod
def get_unknown_type_fallback(cls) -> BasicClientUIComponent:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return BasicClientUIComponentUnknown()
@ioprepped
@dataclass
class BasicClientUIComponentUnknown(BasicClientUIComponent):
"""An unknown basic client component type.
In practice these should never show up since the master-server
generates these on the fly for the client and so should not send
clients one they can't digest.
"""
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.UNKNOWN
@ioprepped
@dataclass
class BasicClientUIComponentText(BasicClientUIComponent):
"""Show some text in the inbox message."""
text: Annotated[str, IOAttrs('t')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0
color: Annotated[
tuple[float, float, float, float], IOAttrs('c', store_default=False)
] = (1.0, 1.0, 1.0, 1.0)
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.TEXT
@ioprepped
@dataclass
class BasicClientUIComponentLink(BasicClientUIComponent):
"""Show a link in the inbox message."""
url: Annotated[str, IOAttrs('u')]
label: Annotated[str, IOAttrs('l')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.LINK
@ioprepped
@dataclass
class BasicClientUIBsClassicTourneyResult(BasicClientUIComponent):
"""Show info about a classic tourney."""
tournament_id: Annotated[str, IOAttrs('t')]
game: Annotated[str, IOAttrs('g')]
players: Annotated[int, IOAttrs('p')]
rank: Annotated[int, IOAttrs('r')]
trophy: Annotated[str | None, IOAttrs('tr')]
prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')]
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.BS_CLASSIC_TOURNEY_RESULT
@ioprepped
@dataclass
class BasicClientUIDisplayItems(BasicClientUIComponent):
"""Show some display-items."""
items: Annotated[list[DisplayItemWrapper], IOAttrs('d')]
width: Annotated[float, IOAttrs('w')] = 100.0
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.DISPLAY_ITEMS
@ioprepped
@dataclass
class BasicClientUIExpireTime(BasicClientUIComponent):
"""Show expire-time."""
time: Annotated[datetime.datetime, IOAttrs('d')]
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.EXPIRE_TIME
@ioprepped
@dataclass
class BasicClientUI(ClientUI):
"""A basic UI for the client."""
class ButtonLabel(Enum):
"""Distinct button labels we support."""
UNKNOWN = 'u'
OK = 'o'
APPLY = 'a'
CANCEL = 'c'
ACCEPT = 'ac'
DECLINE = 'dn'
IGNORE = 'ig'
CLAIM = 'cl'
DISCARD = 'd'
class InteractionStyle(Enum):
"""Overall interaction styles we support."""
UNKNOWN = 'u'
BUTTON_POSITIVE = 'p'
BUTTON_POSITIVE_NEGATIVE = 'pn'
components: Annotated[list[BasicClientUIComponent], IOAttrs('s')]
interaction_style: Annotated[
InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN)
] = InteractionStyle.BUTTON_POSITIVE
button_label_positive: Annotated[
ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN)
] = ButtonLabel.OK
button_label_negative: Annotated[
ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN)
] = ButtonLabel.CANCEL
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
return ClientUITypeID.BASIC
def contains_unknown_elements(self) -> bool:
"""Whether something within us is an unknown type or enum."""
return (
self.interaction_style is self.InteractionStyle.UNKNOWN
or self.button_label_positive is self.ButtonLabel.UNKNOWN
or self.button_label_negative is self.ButtonLabel.UNKNOWN
or any(
c.get_type_id() is BasicClientUIComponentTypeID.UNKNOWN
for c in self.components
)
)
@ioprepped
@dataclass
class ClientUIWrapper:
"""Wrapper for a ClientUI and its common data."""
id: Annotated[str, IOAttrs('i')]
createtime: Annotated[datetime.datetime, IOAttrs('c')]
ui: Annotated[ClientUI, IOAttrs('e')]
@ioprepped
@dataclass
class InboxRequestMessage(Message):
"""Message requesting our inbox."""
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [InboxRequestResponse]
@ioprepped
@dataclass
class InboxRequestResponse(Response):
"""Here's that inbox contents you asked for, boss."""
wrappers: Annotated[list[ClientUIWrapper], IOAttrs('w')]
# Printable error if something goes wrong.
error: Annotated[str | None, IOAttrs('e')] = None
class ClientUIAction(Enum):
"""Types of actions we can run."""
BUTTON_PRESS_POSITIVE = 'p'
BUTTON_PRESS_NEGATIVE = 'n'
class ClientEffectTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
SCREEN_MESSAGE = 'm'
SOUND = 's'
DELAY = 'd'
class ClientEffect(IOMultiType[ClientEffectTypeID]):
"""Something that can happen on the client.
This can include screen messages, sounds, visual effects, etc.
"""
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = ClientEffectTypeID
if type_id is t.UNKNOWN:
return ClientEffectUnknown
if type_id is t.SCREEN_MESSAGE:
return ClientEffectScreenMessage
if type_id is t.SOUND:
return ClientEffectSound
if type_id is t.DELAY:
return ClientEffectDelay
# Important to make sure we provide all types.
assert_never(type_id)
@override
@classmethod
def get_unknown_type_fallback(cls) -> ClientEffect:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return ClientEffectUnknown()
@ioprepped
@dataclass
class ClientEffectUnknown(ClientEffect):
"""Fallback substitute for types we don't recognize."""
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.UNKNOWN
@ioprepped
@dataclass
class ClientEffectScreenMessage(ClientEffect):
"""Display a screen-message."""
message: Annotated[str, IOAttrs('m')]
subs: Annotated[list[str], IOAttrs('s')]
color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0)
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.SCREEN_MESSAGE
@ioprepped
@dataclass
class ClientEffectSound(ClientEffect):
"""Play a sound."""
class Sound(Enum):
"""Sounds that can be made alongside the message."""
UNKNOWN = 'u'
CASH_REGISTER = 'c'
ERROR = 'e'
POWER_DOWN = 'p'
GUN_COCKING = 'g'
sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)]
volume: Annotated[float, IOAttrs('v')] = 1.0
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.SOUND
@ioprepped
@dataclass
class ClientEffectDelay(ClientEffect):
"""Delay effect processing."""
seconds: Annotated[float, IOAttrs('s')]
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.DELAY
@ioprepped
@dataclass
class ClientUIActionMessage(Message):
"""Do something to a client ui."""
id: Annotated[str, IOAttrs('i')]
action: Annotated[ClientUIAction, IOAttrs('a')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ClientUIActionResponse]
@ioprepped
@dataclass
class ClientUIActionResponse(Response):
"""Did something to that inbox entry, boss."""
class ErrorType(Enum):
"""Types of errors that may have occurred."""
# Probably a future error type we don't recognize.
UNKNOWN = 'u'
# Something went wrong on the server, but specifics are not
# relevant.
INTERNAL = 'i'
# The entry expired on the server. In various cases such as 'ok'
# buttons this can generally be ignored.
EXPIRED = 'e'
error_type: Annotated[
ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN)
]
# User facing error message in the case of errors.
error_message: Annotated[str | None, IOAttrs('em')]
effects: Annotated[list[ClientEffect], IOAttrs('fx')]
@ioprepped
@dataclass
class ScoreSubmitMessage(Message):
"""Let the server know we got some score in something."""
score_token: Annotated[str, IOAttrs('t')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ScoreSubmitResponse]
@ioprepped
@dataclass
class ScoreSubmitResponse(Response):
"""Did something to that inbox entry, boss."""
# Things we should show on our end.
effects: Annotated[list[ClientEffect], IOAttrs('fx')]

View file

@ -3,9 +3,10 @@
"""Functionality related to cloud functionality."""
from __future__ import annotations
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override
from enum import Enum
from efro.message import Message, Response
from efro.dataclassio import ioprepped, IOAttrs
@ -299,27 +300,3 @@ class StoreQueryResponse(Response):
available_purchases: Annotated[list[Purchase], IOAttrs('p')]
token_info_url: Annotated[str, IOAttrs('tiu')]
@ioprepped
@dataclass
class BSPrivatePartyMessage(Message):
"""Message asking about info we need for private-party UI."""
need_datacode: Annotated[bool, IOAttrs('d')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [BSPrivatePartyResponse]
@ioprepped
@dataclass
class BSPrivatePartyResponse(Response):
"""Here's that private party UI info you asked for, boss."""
success: Annotated[bool, IOAttrs('s')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
datacode: Annotated[str | None, IOAttrs('d')]

View file

@ -0,0 +1,212 @@
# Released under the MIT License. See LICENSE for details.
#
"""System for managing loggers."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Annotated
from dataclasses import dataclass, field
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
from typing import Self, Sequence
@ioprepped
@dataclass
class LoggerControlConfig:
"""A logging level configuration that applies to all loggers.
Any loggers not explicitly contained in the configuration will be
set to NOTSET.
"""
# Logger names mapped to log-level values (from system logging
# module).
levels: Annotated[dict[str, int], IOAttrs('l', store_default=False)] = (
field(default_factory=dict)
)
def apply(
self,
*,
warn_unexpected_loggers: bool = False,
warn_missing_loggers: bool = False,
ignore_log_prefixes: list[str] | None = None,
) -> None:
"""Apply the config to all Python loggers.
If 'warn_unexpected_loggers' is True, warnings will be issues for
any loggers not explicitly covered by the config. This is useful
to help ensure controls for all possible loggers are present in
a UI/etc.
If 'warn_missing_loggers' is True, warnings will be issued for
any loggers present in the config that are not found at apply time.
This can be useful for pruning settings for no longer used loggers.
Warnings for any log names beginning with any strings in
'ignore_log_prefixes' will be suppressed. This can allow
ignoring loggers associated with submodules for a given package
and instead presenting only a top level logger (or none at all).
"""
if ignore_log_prefixes is None:
ignore_log_prefixes = []
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# First issue any warnings they want.
if warn_unexpected_loggers:
for logname in sorted(existinglognames):
if logname not in self.levels and not any(
logname.startswith(pre) for pre in ignore_log_prefixes
):
logging.warning(
'Found a logger not covered by LoggerControlConfig:'
" '%s'.",
logname,
)
if warn_missing_loggers:
for logname in sorted(self.levels.keys()):
if logname not in existinglognames and not any(
logname.startswith(pre) for pre in ignore_log_prefixes
):
logging.warning(
'Logger covered by LoggerControlConfig does not exist:'
' %s.',
logname,
)
# First, update levels for all existing loggers.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
logger.setLevel(level)
# Next, assign levels to any loggers that don't exist.
for logname, level in self.levels.items():
if logname not in existinglognames:
logging.getLogger(logname).setLevel(level)
def sanity_check_effective_levels(self) -> None:
"""Checks existing loggers to make sure they line up with us.
This can be called periodically to ensure that a control-config
is properly driving log levels and that nothing else is changing
them behind our back.
"""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
for logname in existinglognames:
logger = logging.getLogger(logname)
if logger.getEffectiveLevel() != self.get_effective_level(logname):
logging.error(
'loggercontrol effective-level sanity check failed;'
' expected logger %s to have effective level %s'
' but it has %s.',
logname,
logging.getLevelName(self.get_effective_level(logname)),
logging.getLevelName(logger.getEffectiveLevel()),
)
def get_effective_level(self, logname: str) -> int:
"""Given a log name, predict its level if this config is applied."""
splits = logname.split('.')
splen = len(splits)
for i in range(splen):
subname = '.'.join(splits[: splen - i])
thisval = self.levels.get(subname)
if thisval is not None and thisval != logging.NOTSET:
return thisval
# Haven't found anything; just return root value.
thisval = self.levels.get('root')
return (
logging.DEBUG
if thisval is None
else logging.DEBUG if thisval == logging.NOTSET else thisval
)
def would_make_changes(self) -> bool:
"""Return whether calling apply would change anything."""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# Return True if we contain any nonexistent loggers. Even if
# we wouldn't change their level, the fact that we'd create
# them still counts as a difference.
if any(
logname not in existinglognames for logname in self.levels.keys()
):
return True
# Now go through all existing loggers and return True if we
# would change their level.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
if logger.level != level:
return True
return False
def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig:
"""Return a config containing only changes compared to a base config.
Note that this omits all NOTSET values that resolve to NOTSET in
the base config.
This diffed config can later be used with apply_diff() against the
base config to recreate the state represented by self.
"""
cls = type(self)
config = cls()
for loggername, level in self.levels.items():
baselevel = baseconfig.levels.get(loggername, logging.NOTSET)
if level != baselevel:
config.levels[loggername] = level
return config
def apply_diff(
self, diffconfig: LoggerControlConfig
) -> LoggerControlConfig:
"""Apply a diff config to ourself.
Note that values that resolve to NOTSET are left intact in the
output config. This is so all loggers expected by either the
base or diff config to exist can be created if desired/etc.
"""
cls = type(self)
# Create a new config (with an indepenent levels dict copy).
config = cls(levels=dict(self.levels))
# Overlay the diff levels dict onto our new one.
config.levels.update(diffconfig.levels)
# Note: we do NOT prune NOTSET values here. This is so all
# loggers mentioned in the base config get created if we are
# applied, even if they are assigned a default level.
return config
@classmethod
def from_current_loggers(cls) -> Self:
"""Build a config from the current set of loggers."""
lognames = ['root'] + sorted(logging.root.manager.loggerDict)
config = cls()
for logname in lognames:
config.levels[logname] = logging.getLogger(logname).level
return config

27
dist/ba_data/python/bacommon/logging.py vendored Normal file
View file

@ -0,0 +1,27 @@
# Released under the MIT License. See LICENSE for details.
#
"""Logging functionality."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from bacommon.loggercontrol import LoggerControlConfig
if TYPE_CHECKING:
pass
def get_base_logger_control_config_client() -> LoggerControlConfig:
"""Return the logger-control-config used by the ballistica client.
This should remain consistent since local logger configurations
are stored relative to this.
"""
# By default, go with WARNING on everything to keep things mostly
# clean but show INFO for ba.app to get basic app startup messages
# and whatnot.
return LoggerControlConfig(
levels={'root': logging.WARNING, 'ba.app': logging.INFO}
)

View file

@ -186,6 +186,11 @@ class ServerConfig:
# involving leaving and rejoining or switching teams rapidly.
player_rejoin_cooldown: float = 10.0
# Log levels for particular loggers, overriding the engine's
# defaults. Valid values are NOTSET, DEBUG, INFO, WARNING, ERROR, or
# CRITICAL.
log_levels: dict[str, str] | None = None
# NOTE: as much as possible, communication from the server-manager to
# the child-process should go through these and not ad-hoc Python string

View file

@ -18,6 +18,7 @@ from __future__ import annotations
import os
import sys
import time
import logging
from pathlib import Path
from dataclasses import dataclass
@ -27,7 +28,7 @@ import __main__
if TYPE_CHECKING:
from typing import Any
from efro.log import LogHandler
from efro.logging import LogHandler
# IMPORTANT - It is likely (and in some cases expected) that this
# module's code will be exec'ed multiple times. This is because it is
@ -52,7 +53,7 @@ if TYPE_CHECKING:
# Build number and version of the ballistica binary we expect to be
# using.
TARGET_BALLISTICA_BUILD = 21949
TARGET_BALLISTICA_BUILD = 22278
TARGET_BALLISTICA_VERSION = '1.7.37'
@ -87,14 +88,13 @@ class EnvConfig:
# stderr into the engine so they show up on in-app consoles, etc.
log_handler: LogHandler | None
# Initial data from the ballisticakit-config.json file. This is
# passed mostly as an optimization to avoid reading the same config
# file twice, since config data is first needed in baenv and next in
# the engine. It will be cleared after passing it to the app's
# config management subsystem and should not be accessed by any
# other code.
# 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.
launch_time: float
@dataclass
class _EnvGlobals:
@ -156,6 +156,7 @@ def get_config() -> EnvConfig:
def configure(
*,
config_dir: str | None = None,
data_dir: str | None = None,
user_python_dir: str | None = None,
@ -171,6 +172,11 @@ def configure(
are imported; the environment is locked in as soon as that happens.
"""
# Measure when we start doing this stuff. We plug this in to show
# relative times in our log timestamp displays and also pass this to
# the engine to do the same there.
launch_time = time.time()
envglobals = _EnvGlobals.get()
# Keep track of whether we've been *called*, not whether a config
@ -205,11 +211,19 @@ def configure(
config_dir,
)
# The second thing we do is set up our logging system and pipe
# Python's stdout/stderr into it. At this point we can at least
# debug problems on systems where native stdout/stderr is not easily
# accessible such as Android.
log_handler = _setup_logging() if setup_logging else None
# Set up our log-handler and pipe Python's stdout/stderr into it.
# Later, once the engine comes up, the handler will feed its logs
# (including cached history) to the os-specific output location.
# This means anything printed or logged at this point forward should
# be visible on all platforms.
log_handler = _create_log_handler(launch_time) if setup_logging else None
# Load the raw app-config dict.
app_config = _read_app_config(os.path.join(config_dir, 'config.json'))
# Set logging levels to stored values or defaults.
if setup_logging:
_set_log_levels(app_config)
# We want to always be run in UTF-8 mode; complain if we're not.
if sys.flags.utf8_mode != 1:
@ -234,10 +248,46 @@ def configure(
site_python_dir=site_python_dir,
log_handler=log_handler,
is_user_app_python_dir=is_user_app_python_dir,
initial_app_config=None,
initial_app_config=app_config,
launch_time=launch_time,
)
def _read_app_config(config_file_path: str) -> dict:
"""Read the app config."""
import json
config: dict | Any
config_contents = ''
try:
if os.path.exists(config_file_path):
with open(config_file_path, encoding='utf-8') as infile:
config_contents = infile.read()
config = json.loads(config_contents)
if not isinstance(config, dict):
raise RuntimeError('Got non-dict for config root.')
else:
config = {}
except Exception:
logging.exception(
"Error reading config file '%s'.\n"
"Backing up broken config to'%s.broken'.",
config_file_path,
config_file_path,
)
try:
import shutil
shutil.copyfile(config_file_path, config_file_path + '.broken')
except Exception:
logging.exception('Error copying broken config.')
config = {}
return config
def _calc_data_dir(data_dir: str | None) -> str:
if data_dir is None:
# To calc default data_dir, we assume this module was imported
@ -261,23 +311,58 @@ def _calc_data_dir(data_dir: str | None) -> str:
return data_dir
def _setup_logging() -> LogHandler:
from efro.log import setup_logging, LogLevel
def _create_log_handler(launch_time: float) -> LogHandler:
from efro.logging import setup_logging, LogLevel
# TODO: should set this up with individual loggers under a top level
# 'ba' logger, and at that point we can kill off the
# suppress_non_root_debug option since we'll only ever need to set
# 'ba' to DEBUG at most.
log_handler = setup_logging(
log_path=None,
level=LogLevel.DEBUG,
suppress_non_root_debug=True,
level=LogLevel.INFO,
log_stdout_stderr=True,
cache_size_limit=1024 * 1024,
launch_time=launch_time,
)
return log_handler
def _set_log_levels(app_config: dict) -> None:
from bacommon.logging import get_base_logger_control_config_client
from bacommon.loggercontrol import LoggerControlConfig
try:
config = app_config.get('Log Levels', None)
if config is None:
get_base_logger_control_config_client().apply()
return
# Make sure data is expected types/values since this is user
# editable.
valid_levels = {
logging.NOTSET,
logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
logging.CRITICAL,
}
for logname, loglevel in config.items():
if (
not isinstance(logname, str)
or not logname
or not isinstance(loglevel, int)
or not loglevel in valid_levels
):
raise ValueError("Invalid 'Log Levels' data read from config.")
get_base_logger_control_config_client().apply_diff(
LoggerControlConfig(levels=config)
).apply()
except Exception:
logging.exception('Error setting log levels.')
def _setup_certs(contains_python_dist: bool) -> None:
# In situations where we're bringing our own Python, let's also
# provide our own root certs so ssl works. We can consider

View file

@ -5,23 +5,23 @@
This code concerns sensitive things like accounts and master-server
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, and a fully open-source engine
can also be built by removing this 'plus' 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
# Note: there's not much here.
# All comms with this feature-set should go through app.plus.
# Note: there's not much here. Most interaction with this feature-set
# should go through ba*.app.plus.
import logging
from baplus._cloud import CloudSubsystem
from baplus._subsystem import PlusSubsystem
from baplus._appsubsystem import PlusAppSubsystem
__all__ = [
'CloudSubsystem',
'PlusSubsystem',
'PlusAppSubsystem',
]
# Sanity check: we want to keep ballistica's dependencies and

View file

@ -12,12 +12,13 @@ import _baplus
if TYPE_CHECKING:
from typing import Callable, Any
import bacommon.bs
from babase import AccountV2Subsystem
from baplus._cloud import CloudSubsystem
class PlusSubsystem(AppSubsystem):
class PlusAppSubsystem(AppSubsystem):
"""Subsystem for plus functionality in the app.
The single shared instance of this app can be accessed at
@ -40,7 +41,6 @@ class PlusSubsystem(AppSubsystem):
_baplus.on_app_loading()
self.accounts.on_app_loading()
# noinspection PyUnresolvedReferences
@staticmethod
def add_v1_account_transaction(
transaction: dict, callback: Callable | None = None
@ -66,9 +66,9 @@ class PlusSubsystem(AppSubsystem):
return _baplus.get_master_server_address(source, version)
@staticmethod
def get_news_show() -> str:
def get_classic_news_show() -> str:
"""(internal)"""
return _baplus.get_news_show()
return _baplus.get_classic_news_show()
@staticmethod
def get_price(item: str) -> str | None:
@ -76,14 +76,14 @@ class PlusSubsystem(AppSubsystem):
return _baplus.get_price(item)
@staticmethod
def get_purchased(item: str) -> bool:
def get_v1_account_product_purchased(item: str) -> bool:
"""(internal)"""
return _baplus.get_purchased(item)
return _baplus.get_v1_account_product_purchased(item)
@staticmethod
def get_purchases_state() -> int:
def get_v1_account_product_purchases_state() -> int:
"""(internal)"""
return _baplus.get_purchases_state()
return _baplus.get_v1_account_product_purchases_state()
@staticmethod
def get_v1_account_display_string(full: bool = True) -> str:
@ -129,7 +129,7 @@ class PlusSubsystem(AppSubsystem):
def get_v1_account_ticket_count() -> int:
"""(internal)
Returns the number of tickets for the current account.
Return the number of tickets for the current account.
"""
return _baplus.get_v1_account_ticket_count()
@ -221,6 +221,7 @@ class PlusSubsystem(AppSubsystem):
name: Any,
score: int | None,
callback: Callable,
*,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, overload
from efro.call import CallbackSet
import babase
if TYPE_CHECKING:
@ -14,8 +15,8 @@ if TYPE_CHECKING:
from efro.message import Message, Response
import bacommon.cloud
import bacommon.bs
DEBUG_LOG = False
# TODO: Should make it possible to define a protocol in bacommon.cloud and
# autogenerate this. That would give us type safety between this and
@ -25,6 +26,12 @@ DEBUG_LOG = False
class CloudSubsystem(babase.AppSubsystem):
"""Manages communication with cloud components."""
def __init__(self) -> None:
super().__init__()
self.on_connectivity_changed_callbacks: CallbackSet[
Callable[[bool], None]
] = CallbackSet()
@property
def connected(self) -> bool:
"""Property equivalent of CloudSubsystem.is_connected()."""
@ -40,15 +47,17 @@ class CloudSubsystem(babase.AppSubsystem):
def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes."""
if DEBUG_LOG:
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
babase.balog.debug('Connectivity is now %s.', connected)
plus = babase.app.plus
assert plus is not None
# Inform things that use this.
# (TODO: should generalize this into some sort of registration system)
plus.accounts.on_cloud_connectivity_changed(connected)
# Fire any registered callbacks for this.
for call in self.on_connectivity_changed_callbacks.getcalls():
try:
call(connected)
except Exception:
logging.exception('Error in connectivity-changed callback.')
@overload
def send_message_cb(
@ -112,9 +121,54 @@ class CloudSubsystem(babase.AppSubsystem):
@overload
def send_message_cb(
self,
msg: bacommon.cloud.BSPrivatePartyMessage,
msg: bacommon.bs.PrivatePartyMessage,
on_response: Callable[
[bacommon.cloud.BSPrivatePartyResponse | Exception], None
[bacommon.bs.PrivatePartyResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.InboxRequestMessage,
on_response: Callable[
[bacommon.bs.InboxRequestResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ClientUIActionMessage,
on_response: Callable[
[bacommon.bs.ClientUIActionResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ChestInfoMessage,
on_response: Callable[
[bacommon.bs.ChestInfoResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ChestActionMessage,
on_response: Callable[
[bacommon.bs.ChestActionResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ScoreSubmitMessage,
on_response: Callable[
[bacommon.bs.ScoreSubmitResponse | Exception], None
],
) -> None: ...
@ -128,14 +182,8 @@ class CloudSubsystem(babase.AppSubsystem):
The provided on_response call will be run in the logic thread
and passed either the response or the error that occurred.
"""
del msg # Unused.
babase.pushcall(
babase.Call(
on_response,
RuntimeError('Cloud functionality is not available.'),
)
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
@overload
@ -158,7 +206,9 @@ class CloudSubsystem(babase.AppSubsystem):
Must be called from a background thread.
"""
raise RuntimeError('Cloud functionality is not available.')
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
@overload
async def send_message_async(
@ -175,7 +225,35 @@ class CloudSubsystem(babase.AppSubsystem):
Must be called from the logic thread.
"""
raise RuntimeError('Cloud functionality is not available.')
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def subscribe_test(
self, updatecall: Callable[[int | None], None]
) -> babase.CloudSubscription:
"""Subscribe to some test data."""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def subscribe_classic_account_data(
self,
updatecall: Callable[[bacommon.bs.ClassicAccountLiveData], None],
) -> babase.CloudSubscription:
"""Subscribe to classic account data."""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def unsubscribe(self, subscription_id: int) -> None:
"""Unsubscribe from some subscription.
Do not call this manually; it is called by CloudSubscription.
"""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def cloud_console_exec(code: str) -> None:

View file

@ -1,8 +1,8 @@
# Released under the MIT License. See LICENSE for details.
#
"""Ballistica scene api version 1. Basically all gameplay related code."""
"""Gameplay-centric api for classic BombSquad."""
# ba_meta require api 8
# ba_meta require api 9
# The stuff we expose here at the top level is our 'public' api for use
# from other modules/packages. Code *within* this package should import
@ -18,6 +18,7 @@ import logging
from efro.util import set_canonical_module_names
from babase import (
add_clean_frame_callback,
app,
AppIntent,
AppIntentDefault,
@ -149,7 +150,6 @@ from _bascenev1 import (
from bascenev1._activity import Activity
from bascenev1._activitytypes import JoinActivity, ScoreScreenActivity
from bascenev1._actor import Actor
from bascenev1._appmode import SceneV1AppMode
from bascenev1._campaign import init_campaigns, Campaign
from bascenev1._collision import Collision, getcollision
from bascenev1._coopgame import CoopGameActivity
@ -249,6 +249,7 @@ __all__ = [
'Actor',
'animate',
'animate_array',
'add_clean_frame_callback',
'app',
'AppIntent',
'AppIntentDefault',
@ -410,7 +411,6 @@ __all__ = [
'seek_replay',
'safecolor',
'screenmessage',
'SceneV1AppMode',
'ScoreConfig',
'ScoreScreenActivity',
'ScoreType',

View file

@ -1,60 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING, override
from bacommon.app import AppExperience
from babase import (
app,
AppMode,
AppIntentExec,
AppIntentDefault,
invoke_main_menu,
)
import _bascenev1
if TYPE_CHECKING:
from babase import AppIntent
class SceneV1AppMode(AppMode):
"""Our app-mode."""
@override
@classmethod
def get_app_experience(cls) -> AppExperience:
return AppExperience.MELEE
@override
@classmethod
def _supports_intent(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault)
@override
def handle_intent(self, intent: AppIntent) -> None:
if isinstance(intent, AppIntentExec):
_bascenev1.handle_app_intent_exec(intent.code)
return
assert isinstance(intent, AppIntentDefault)
_bascenev1.handle_app_intent_default()
@override
def on_activate(self) -> None:
# Let the native layer do its thing.
_bascenev1.on_app_mode_activate()
@override
def on_deactivate(self) -> None:
# Let the native layer do its thing.
_bascenev1.on_app_mode_deactivate()
@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 app.active:
invoke_main_menu()

View file

@ -64,36 +64,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
# (unless overridden by the map).
default_music: bascenev1.MusicType | None = None
@classmethod
def create_settings_ui(
cls,
sessiontype: type[bascenev1.Session],
settings: dict | None,
completion_call: Callable[[dict | None], None],
) -> None:
"""Launch an in-game UI to configure settings for a game type.
'sessiontype' should be the bascenev1.Session class the game will
be used in.
'settings' should be an existing settings dict (implies 'edit'
ui mode) or None (implies 'add' ui mode).
'completion_call' will be called with a filled-out settings dict on
success or None on cancel.
Generally subclasses don't need to override this; if they override
bascenev1.GameActivity.get_available_settings() and
bascenev1.GameActivity.get_supported_maps() they can just rely on
the default implementation here which calls those methods.
"""
assert babase.app.classic is not None
delegate = babase.app.classic.delegate
assert delegate is not None
delegate.create_default_game_settings_ui(
cls, sessiontype, settings, completion_call
)
@classmethod
def getscoreconfig(cls) -> bascenev1.ScoreConfig:
"""Return info about game scoring setup; can be overridden by games."""
@ -238,8 +208,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
"""Instantiate the Activity."""
super().__init__(settings)
plus = babase.app.plus
# Holds some flattened info about the player set at the point
# when on_begin() is called.
self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None
@ -269,23 +237,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
None
)
self._zoom_message_times: dict[int, float] = {}
self._is_waiting_for_continue = False
self._continue_cost = (
25
if plus is None
else plus.get_v1_account_misc_read_val('continueStartCost', 25)
)
self._continue_cost_mult = (
2
if plus is None
else plus.get_v1_account_misc_read_val('continuesMult', 2)
)
self._continue_cost_offset = (
0
if plus is None
else plus.get_v1_account_misc_read_val('continuesOffset', 0)
)
@property
def map(self) -> _map.Map:
@ -392,103 +343,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
if music is not None:
_music.setmusic(music)
def on_continue(self) -> None:
"""
This is called if a game supports and offers a continue and the player
accepts. In this case the player should be given an extra life or
whatever is relevant to keep the game going.
"""
def _continue_choice(self, do_continue: bool) -> None:
plus = babase.app.plus
assert plus is not None
self._is_waiting_for_continue = False
if self.has_ended():
return
with self.context:
if do_continue:
_bascenev1.getsound('shieldUp').play()
_bascenev1.getsound('cashRegister').play()
plus.add_v1_account_transaction(
{'type': 'CONTINUE', 'cost': self._continue_cost}
)
if plus is not None:
plus.run_v1_account_transactions()
self._continue_cost = (
self._continue_cost * self._continue_cost_mult
+ self._continue_cost_offset
)
self.on_continue()
else:
self.end_game()
def is_waiting_for_continue(self) -> bool:
"""Returns whether or not this activity is currently waiting for the
player to continue (or timeout)"""
return self._is_waiting_for_continue
def continue_or_end_game(self) -> None:
"""If continues are allowed, prompts the player to purchase a continue
and calls either end_game or continue_game depending on the result
"""
# pylint: disable=too-many-nested-blocks
# pylint: disable=cyclic-import
from bascenev1._coopsession import CoopSession
classic = babase.app.classic
assert classic is not None
continues_window = classic.continues_window
# Turning these off. I want to migrate towards monetization that
# feels less pay-to-win-ish.
allow_continues = False
plus = babase.app.plus
try:
if (
plus is not None
and plus.get_v1_account_misc_read_val('enableContinues', False)
and allow_continues
):
session = self.session
# We only support continuing in non-tournament games.
tournament_id = session.tournament_id
if tournament_id is None:
# We currently only support continuing in sequential
# co-op campaigns.
if isinstance(session, CoopSession):
assert session.campaign is not None
if session.campaign.sequential:
gnode = self.globalsnode
# Only attempt this if we're not currently paused
# and there appears to be no UI.
assert babase.app.classic is not None
hmmw = babase.app.ui_v1.has_main_menu_window()
if not gnode.paused and not hmmw:
self._is_waiting_for_continue = True
with babase.ContextRef.empty():
babase.apptimer(
0.5,
lambda: continues_window(
self,
self._continue_cost,
continue_call=babase.WeakCall(
self._continue_choice, True
),
cancel_call=babase.WeakCall(
self._continue_choice, False
),
),
)
return
except Exception:
logging.exception('Error handling continues.')
self.end_game()
@override
def on_begin(self) -> None:
super().on_begin()
@ -1277,6 +1131,7 @@ class GameActivity(Activity[PlayerT, TeamT]):
def show_zoom_message(
self,
message: babase.Lstr,
*,
color: Sequence[float] = (0.9, 0.4, 0.0),
scale: float = 0.8,
duration: float = 2.0,

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING, NewType
@ -111,6 +112,7 @@ def animate_array(
attr: str,
size: int,
keys: dict[float, Sequence[float]],
*,
loop: bool = False,
offset: float = 0,
) -> None:
@ -243,7 +245,6 @@ def cameraflash(duration: float = 999.0) -> None:
Duration is in seconds.
"""
# pylint: disable=too-many-locals
import random
from bascenev1._nodeactor import NodeActor
x_spread = 10

View file

@ -27,6 +27,7 @@ class Level:
gametype: type[bascenev1.GameActivity],
settings: dict,
preview_texture_name: str,
*,
displayname: str | None = None,
):
self._name = name

View file

@ -588,10 +588,11 @@ class Chooser:
# Handle '_edit' as a special case.
if profilename == '_edit' and ready:
with babase.ContextRef.empty():
classic.profile_browser_window(in_main_menu=False)
# Give their input-device UI ownership too
# (prevent someone else from snatching it in crowded games)
classic.profile_browser_window()
# Give their input-device UI ownership too (prevent
# someone else from snatching it in crowded games).
babase.set_ui_input_device(self._sessionplayer.inputdevice.id)
return

View file

@ -241,6 +241,7 @@ class HitMessage:
def __init__(
self,
*,
srcnode: bascenev1.Node | None = None,
pos: Sequence[float] | None = None,
velocity: Sequence[float] | None = None,

View file

@ -21,6 +21,7 @@ PlaylistType = list[dict[str, Any]]
def filter_playlist(
playlist: PlaylistType,
sessiontype: type[Session],
*,
add_resolved_type: bool = False,
remove_unowned: bool = True,
mark_unowned: bool = False,

View file

@ -96,6 +96,7 @@ class Session:
def __init__(
self,
depsets: Sequence[bascenev1.DependencySet],
*,
team_names: Sequence[str] | None = None,
team_colors: Sequence[Sequence[float]] | None = None,
min_players: int = 1,

View file

@ -196,6 +196,7 @@ class PlayerRecord:
scale2: float,
sound2: bascenev1.Sound | None,
) -> None:
# pylint: disable=too-many-positional-arguments
from bascenev1lib.actor.popuptext import PopupText
# Only award this if they're still alive and we can get
@ -341,6 +342,7 @@ class Stats:
self,
player: bascenev1.Player,
base_points: int = 1,
*,
target: Sequence[float] | None = None,
kill: bool = False,
victim_player: bascenev1.Player | None = None,

View file

@ -2,4 +2,4 @@
#
"""Library of stuff using the bascenev1 api: games, actors, etc."""
# ba_meta require api 8
# ba_meta require api 9

View file

@ -9,6 +9,8 @@ import random
import logging
from typing import TYPE_CHECKING, override
from efro.util import strict_partial
import bacommon.bs
from bacommon.login import LoginType
import bascenev1 as bs
import bauiv1 as bui
@ -19,9 +21,6 @@ from bascenev1lib.actor.zoomtext import ZoomText
if TYPE_CHECKING:
from typing import Any, Sequence
from bauiv1lib.store.button import StoreButton
from bauiv1lib.league.rankbutton import LeagueRankButton
class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
"""Score screen showing the results of a cooperative game."""
@ -105,10 +104,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
# Ui bits.
self._corner_button_offs: tuple[float, float] | None = None
self._league_rank_button: LeagueRankButton | None = None
self._store_button_instance: StoreButton | None = None
self._restart_button: bui.Widget | None = None
self._update_corner_button_positions_timer: bui.AppTimer | None = None
self._next_level_error: bs.Actor | None = None
# Score/gameplay bits.
@ -207,20 +203,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
)
def _ui_menu(self) -> None:
from bauiv1lib import specialoffer
if specialoffer.show_offer():
return
bui.containerwidget(edit=self._root_ui, transition='out_left')
with self.context:
bs.timer(0.1, bs.Call(bs.WeakCall(self.session.end)))
def _ui_restart(self) -> None:
from bauiv1lib.tournamententry import TournamentEntryWindow
from bauiv1lib import specialoffer
if specialoffer.show_offer():
return
# If we're in a tournament and it looks like there's no time left,
# disallow.
@ -268,10 +256,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
self.end({'outcome': 'restart'})
def _ui_next(self) -> None:
from bauiv1lib.specialoffer import show_offer
if show_offer():
return
# If we didn't just complete this level but are choosing to play the
# next one, set it as current (this won't happen otherwise).
@ -331,23 +315,30 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
)
def _should_show_worlds_best_button(self) -> bool:
# Old high score lists webpage for tourneys seems broken
# (looking at meteor shower at least).
if self.session.tournament_id is not None:
return False
# Link is too complicated to display with no browser.
return bui.is_browser_likely_available()
def request_ui(self) -> None:
"""Set up a callback to show our UI at the next opportune time."""
assert bui.app.classic is not None
classic = bui.app.classic
assert classic is not None
# We don't want to just show our UI in case the user already has the
# main menu up, so instead we add a callback for when the menu
# closes; if we're still alive, we'll come up then.
# If there's no main menu this gets called immediately.
bui.app.ui_v1.add_main_menu_close_callback(bui.WeakCall(self.show_ui))
classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui))
def show_ui(self) -> None:
"""Show the UI for restarting, playing the next Level, etc."""
# pylint: disable=too-many-locals
from bauiv1lib.store.button import StoreButton
from bauiv1lib.league.rankbutton import LeagueRankButton
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
assert bui.app.classic is not None
@ -361,11 +352,14 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
return
rootc = self._root_ui = bui.containerwidget(
size=(0, 0), transition='in_right'
size=(0, 0),
transition='in_right',
toolbar_visibility='no_menu_minimal',
)
h_offs = 7.0
v_offs = -280.0
v_offs2 = -236.0
# We wanna prevent controllers users from popping up browsers
# or game-center widgets in cases where they can't easily get back
@ -393,7 +387,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
bui.buttonwidget(
parent=rootc,
color=(0.45, 0.4, 0.5),
position=(160, v_offs + 480),
position=(240, v_offs2 + 439),
size=(350, 62),
label=(
bui.Lstr(resource='tournamentStandingsText')
@ -415,40 +409,85 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
show_next_button = self._is_more_levels and not (env.demo or env.arcade)
if not show_next_button:
h_offs += 70
h_offs += 60
menu_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 22, v_offs + 14),
size=(60, 60),
texture=self._menu_icon_texture,
opacity=0.8,
)
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 19, v_offs + 7),
size=(70, 70),
texture=self._replay_icon_texture,
opacity=0.8,
)
# Due to virtual-bounds changes, have to squish buttons a bit to
# avoid overlapping with tips at bottom. Could look nicer to
# rework things in the middle to get more space, but would
# rather not touch this old code more than necessary.
small_buttons = False
if small_buttons:
menu_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 130 - 45, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 43, v_offs + 43),
size=(45, 45),
texture=self._menu_icon_texture,
opacity=0.8,
)
else:
menu_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 22, v_offs + 14),
size=(60, 60),
texture=self._menu_icon_texture,
opacity=0.8,
)
if small_buttons:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 25, v_offs + 42),
size=(47, 47),
texture=self._replay_icon_texture,
opacity=0.8,
)
else:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 19, v_offs + 7),
size=(70, 70),
texture=self._replay_icon_texture,
opacity=0.8,
)
next_button: bui.Widget | None = None
@ -465,58 +504,53 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
button_sound = False
image_opacity = 0.2
color = (0.3, 0.3, 0.3)
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 5),
size=(80, 80),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
if small_buttons:
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 75, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 40),
size=(50, 50),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
else:
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 5),
size=(80, 80),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
x_offs_extra = 0 if show_next_button else -100
self._corner_button_offs = (
h_offs + 300.0 + 100.0 + x_offs_extra,
v_offs + 560.0,
h_offs + 300.0 + x_offs_extra,
v_offs + 519.0,
)
if env.demo or env.arcade:
self._league_rank_button = None
self._store_button_instance = None
else:
self._league_rank_button = LeagueRankButton(
parent=rootc,
position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560),
size=(100, 60),
scale=0.9,
color=(0.4, 0.4, 0.9),
textcolor=(0.9, 0.9, 2.0),
transition_delay=0.0,
smooth_update_delay=5.0,
)
self._store_button_instance = StoreButton(
parent=rootc,
position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560),
show_tickets=True,
sale_scale=0.85,
size=(100, 60),
scale=0.9,
button_type='square',
color=(0.35, 0.25, 0.45),
textcolor=(0.9, 0.7, 1.0),
transition_delay=0.0,
)
bui.containerwidget(
edit=rootc,
selected_child=(
@ -527,26 +561,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
on_cancel_call=menu_button.activate,
)
self._update_corner_button_positions()
self._update_corner_button_positions_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True
)
def _update_corner_button_positions(self) -> None:
offs = -55 if bui.is_party_icon_visible() else 0
assert self._corner_button_offs is not None
pos_x = self._corner_button_offs[0] + offs
pos_y = self._corner_button_offs[1]
if self._league_rank_button is not None:
self._league_rank_button.set_position((pos_x, pos_y))
if self._store_button_instance is not None:
self._store_button_instance.set_position((pos_x + 100, pos_y))
def _player_press(self) -> None:
# (Only for headless builds).
# If this activity is a good 'end point', ask server-mode just once if
# it wants to do anything special like switch sessions or kill the app.
# If this activity is a good 'end point', ask server-mode just
# once if it wants to do anything special like switch sessions
# or kill the app.
if (
self._allow_server_transition
and bs.app.classic is not None
@ -597,7 +617,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
@override
def on_begin(self) -> None:
# FIXME: Clean this up.
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
@ -865,7 +884,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
# If we're not doing the world's-best button, just show a title
# instead.
ts_height = 300
ts_h_offs = 210
ts_h_offs = 290
v_offs = 40
txt = Text(
(
@ -939,7 +958,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
if display_scores[i][1] is None:
name_str = '-'
else:
# noinspection PyUnresolvedReferences
name_str = ', '.join(
[p['name'] for p in display_scores[i][1]['players']]
)
@ -1008,9 +1026,8 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
ts_h_offs = -480
v_offs = 40
# Only make this if we don't have the button
# (never want clients to see it so no need for client-only
# version, etc).
# Only make this if we don't have the button (never want clients
# to see it so no need for client-only version, etc).
if self._have_achievements:
if not self._account_has_achievements:
Text(
@ -1052,7 +1069,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
).autoretain()
def _got_friend_score_results(self, results: list[Any] | None) -> None:
# FIXME: tidy this up
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
@ -1187,35 +1203,77 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=tdelay2,
).autoretain()
def _on_v2_score_results(
self, response: bacommon.bs.ScoreSubmitResponse | Exception
) -> None:
if isinstance(response, Exception):
logging.debug('Got error score-submit response: %s', response)
return
assert isinstance(response, bacommon.bs.ScoreSubmitResponse)
# Aim to have these effects run shortly after the final rating
# hit happens.
with self.context:
assert self._begin_time is not None
delay = max(0, 5.5 - (bs.time() - self._begin_time))
assert bui.app.classic is not None
bs.timer(
delay,
strict_partial(
bui.app.classic.run_bs_client_effects, response.effects
),
)
def _got_score_results(self, results: dict[str, Any] | None) -> None:
# FIXME: tidy this up
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
plus = bs.app.plus
assert plus is not None
classic = bs.app.classic
assert classic is not None
# We need to manually run this in the context of our activity
# and only if we aren't shutting down.
# (really should make the submit_score call handle that stuff itself)
if self.expired:
return
with self.context:
# Delay a bit if results come in too fast.
assert self._begin_time is not None
base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
v_offs = 20
# v_offs = 20
v_offs = 64
if results is None:
self._score_loading_status = Text(
bs.Lstr(resource='worldScoresUnavailableText'),
position=(230, 150 + v_offs),
position=(280, 130 + v_offs),
color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN,
transition_delay=base_delay + 0.3,
scale=0.7,
)
else:
# If there's a score-uuid bundled, ship it along to the
# v2 master server to ask about any rewards from that
# end.
score_token = results.get('token')
if (
isinstance(score_token, str)
and plus.accounts.primary is not None
):
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.bs.ScoreSubmitMessage(score_token),
on_response=bui.WeakCall(self._on_v2_score_results),
)
self._score_link = results['link']
assert self._score_link is not None
# Prepend our master-server addr if its a relative addr.
@ -1254,7 +1312,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
(1.5 + base_delay),
bs.WeakCall(self._show_world_rank, offs_x),
)
ts_h_offs = 200
ts_h_offs = 280
ts_height = 300
# Show world tops.
@ -1274,7 +1332,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
),
position=(
ts_h_offs - 35 + 95,
ts_height / 2 + 6 + v_offs,
ts_height / 2 + 6 + v_offs - 41,
),
color=(0.4, 0.4, 0.4, 1.0),
scale=0.7,
@ -1282,7 +1340,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=base_delay + 0.3,
).autoretain()
else:
v_offs += 20
v_offs += 40
h_offs_extra = 0
v_offs_names = 0
@ -1309,6 +1367,37 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
random.randrange(0, len(times) + 1),
(base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
)
# Conundrum: We want to place line numbers to the
# left of our score column based on the largest
# score width. However scores may use Lstrs and thus
# may have different widths in different languages.
# We don't want to bake down the Lstrs we display
# because then clients can't view scores in their
# own language. So as a compromise lets measure
# max-width based on baked down Lstrs but then
# display regular Lstrs with max-width set based on
# that. Hopefully that'll look reasonable for most
# languages.
max_score_width = 10.0
for tval in self._show_info['tops']:
score = int(tval[0])
name_str = tval[1]
if name_str != '-':
max_score_width = max(
max_score_width,
bui.get_string_width(
(
str(score)
if self._score_type == 'points'
else bs.timestring(
(score * 10) / 1000.0
).evaluate()
),
suppress_warning=True,
),
)
for i, tval in enumerate(self._show_info['tops']):
score = int(tval[0])
name_str = tval[1]
@ -1330,19 +1419,45 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
tdelay2 = times[i][1]
if name_str != '-':
sstr = (
str(score)
if self._score_type == 'points'
else bs.timestring((score * 10) / 1000.0)
)
# Line number.
Text(
(
str(score)
if self._score_type == 'points'
else bs.timestring((score * 10) / 1000.0)
str(i + 1),
position=(
ts_h_offs
+ 20
+ h_offs_extra
- max_score_width
- 8.0,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
- 30.0,
),
scale=0.5,
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=(0.3, 0.3, 0.3),
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1,
).autoretain()
# Score.
Text(
sstr,
position=(
ts_h_offs + 20 + h_offs_extra,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
+ 11.0,
- 30.0,
),
maxwidth=max_score_width,
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=color0,
@ -1350,6 +1465,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1,
).autoretain()
# Player name.
Text(
bs.Lstr(value=name_str),
position=(
@ -1358,7 +1474,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
+ -ts_height * (i + 1) / 10
+ v_offs_names
+ v_offs
+ 11.0,
- 30.0,
),
maxwidth=80.0 + 100.0 * len(self._playerinfos),
v_align=Text.VAlign.CENTER,
@ -1453,16 +1569,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
]
# pylint: disable=useless-suppression
# pylint: disable=unbalanced-tuple-unpacking
(
pr1,
pv1,
pr2,
pv2,
pr3,
pv3,
) = bs.app.classic.get_tournament_prize_strings(
tourney_info
(pr1, pv1, pr2, pv2, pr3, pv3) = (
bs.app.classic.get_tournament_prize_strings(
tourney_info, include_tickets=False
)
)
# pylint: enable=unbalanced-tuple-unpacking
# pylint: enable=useless-suppression
@ -1478,10 +1590,14 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=2.0,
).autoretain()
vval = -107 + 70
for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)):
for i, rng, val in (
(0, pr1, pv1),
(1, pr2, pv2),
(2, pr3, pv3),
):
Text(
rng,
position=(-410 + 10, vval),
position=(-430 + 10, vval),
color=(1, 1, 1, 0.7),
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
@ -1492,7 +1608,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
).autoretain()
Text(
val,
position=(-390 + 10, vval),
position=(-410 + 10, vval),
color=(0.7, 0.7, 0.7, 1.0),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
@ -1501,6 +1617,9 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
maxwidth=300,
transition_delay=2.0,
).autoretain()
bs.app.classic.create_in_game_tournament_prize_image(
tourney_info, i, (-410 + 70, vval)
)
vval -= 35
except Exception:
logging.exception('Error showing prize ranges.')
@ -1573,6 +1692,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=1.0,
).autoretain()
else:
assert rating is not None
ZoomText(
(
f'{rating:.1f}'

View file

@ -145,6 +145,7 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
kill_delay: float,
shiftdelay: float,
) -> None:
# pylint: disable=too-many-positional-arguments
del kill_delay # Unused arg.
ZoomText(
str(sessionteam.customdata['score']),

View file

@ -87,6 +87,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
extrascale: float,
flash: bool = False,
) -> Text:
# pylint: disable=too-many-positional-arguments
return Text(
text,
position=(

View file

@ -57,6 +57,7 @@ class MultiTeamScoreScreenActivity(bs.ScoreScreenActivity):
def show_player_scores(
self,
*,
delay: float = 2.5,
results: bs.GameResults | None = None,
scale: float = 1.0,
@ -134,6 +135,7 @@ class MultiTeamScoreScreenActivity(bs.ScoreScreenActivity):
xoffs: float,
yoffs: float,
text: bs.Lstr,
*,
h_align: Text.HAlign = Text.HAlign.RIGHT,
extrascale: float = 1.0,
maxwidth: float | None = 120.0,

View file

@ -4,12 +4,15 @@
from __future__ import annotations
from typing import override
from typing import override, TYPE_CHECKING
import bascenev1 as bs
from bascenev1lib.activity.multiteamscore import MultiTeamScoreScreenActivity
if TYPE_CHECKING:
from typing import Any
class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
"""Final score screen for a team series."""
@ -24,6 +27,8 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
self._allow_server_transition = True
self._tips_text = None
self._default_show_tips = False
self._ffa_top_player_info: list[Any] | None = None
self._ffa_top_player_rec: bs.PlayerRecord | None = None
@override
def on_begin(self) -> None:
@ -70,6 +75,16 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
)
)
player_entries.sort(reverse=True, key=lambda x: x[0])
if len(player_entries) > 0:
# Store some info for the top ffa player so we can
# show winner info even if they leave.
self._ffa_top_player_info = list(player_entries[0])
self._ffa_top_player_info[1] = self._ffa_top_player_info[
2
].getname()
self._ffa_top_player_info[2] = self._ffa_top_player_info[
2
].get_icon()
else:
for _pkey, prec in self.stats.get_records().items():
player_entries.append((prec.score, prec.name_full, prec))
@ -308,7 +323,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
most_killed = entry[2].killed_count
if mkp is not None:
Text(
bs.Lstr(resource='mostViolatedPlayerText'),
bs.Lstr(resource='mostDestroyedPlayerText'),
color=(0.5, 0.5, 0.5, 1.0),
v_align=Text.VAlign.CENTER,
maxwidth=300,
@ -433,25 +448,42 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
maxwidth=250,
).autoretain()
else:
offs_v = -80.0
offs_v = -80
assert isinstance(self.session, bs.MultiTeamSession)
series_length = self.session.get_ffa_series_length()
icon: dict | None
# Pull live player info if they're still around.
if len(team.players) == 1:
icon = team.players[0].get_icon()
player_name = team.players[0].getname(full=True, icon=False)
# Otherwise use the special info we stored when we came in.
elif (
self._ffa_top_player_info is not None
and self._ffa_top_player_info[0] >= series_length
):
icon = self._ffa_top_player_info[2]
player_name = self._ffa_top_player_info[1]
else:
icon = None
player_name = 'Player Not Found'
if icon is not None:
i = Image(
team.players[0].get_icon(),
icon,
position=(0, 143),
scale=(100, 100),
).autoretain()
assert i.node
bs.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0})
ZoomText(
bs.Lstr(
value=team.players[0].getname(full=True, icon=False)
),
position=(0, 97 + offs_v),
color=team.color,
scale=1.15,
jitter=1.0,
maxwidth=250,
).autoretain()
ZoomText(
bs.Lstr(value=player_name),
position=(0, 97 + offs_v + (0 if icon is not None else 60)),
color=team.color,
scale=1.15,
jitter=1.0,
maxwidth=250,
).autoretain()
s_extra = 1.0 if self._is_ffa else 1.0

View file

@ -333,6 +333,7 @@ class Blast(bs.Actor):
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0),
blast_radius: float = 2.0,
@ -715,6 +716,7 @@ class Bomb(bs.Actor):
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0),
bomb_type: str = 'normal',

View file

@ -24,6 +24,7 @@ class ControlsGuide(bs.Actor):
def __init__(
self,
*,
position: tuple[float, float] = (390.0, 120.0),
scale: float = 1.0,
delay: float = 0.0,

View file

@ -144,6 +144,9 @@ class FlagDiedMessage:
flag: Flag
"""The `Flag` that died."""
self_kill: bool = False
"""If the `Flag` killed itself or not."""
@dataclass
class FlagDroppedMessage:
@ -169,6 +172,7 @@ class Flag(bs.Actor):
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
color: Sequence[float] = (1.0, 1.0, 1.0),
materials: Sequence[bs.Material] | None = None,
@ -282,7 +286,9 @@ class Flag(bs.Actor):
)
self._counter.text = str(self._count)
if self._count < 1:
self.handlemessage(bs.DieMessage())
self.handlemessage(
bs.DieMessage(how=bs.DeathType.LEFT_GAME)
)
else:
assert self._counter
self._counter.text = ''
@ -336,7 +342,11 @@ class Flag(bs.Actor):
if self.node:
self.node.delete()
if not msg.immediate:
self.activity.handlemessage(FlagDiedMessage(self))
self.activity.handlemessage(
FlagDiedMessage(
self, (msg.how is bs.DeathType.LEFT_GAME)
)
)
elif isinstance(msg, bs.HitMessage):
assert self.node
assert msg.force_direction is not None

View file

@ -37,6 +37,7 @@ class Image(bs.Actor):
def __init__(
self,
texture: bs.Texture | dict[str, Any],
*,
position: tuple[float, float] = (0, 0),
transition: Transition | None = None,
transition_delay: float = 0.0,
@ -55,15 +56,21 @@ class Image(bs.Actor):
# pylint: disable=too-many-locals
super().__init__()
# If they provided a dict as texture, assume its an icon.
# otherwise its just a texture value itself.
# If they provided a dict as texture, use it to wire up extended
# stuff like tints and masks.
mask_texture: bs.Texture | None
if isinstance(texture, dict):
tint_color = texture['tint_color']
tint2_color = texture['tint2_color']
tint_texture = texture['tint_texture']
# Assume we're dealing with a character icon but allow
# overriding.
mask_tex_name = texture.get('mask_texture', 'characterIconMask')
mask_texture = (
None if mask_tex_name is None else bs.gettexture(mask_tex_name)
)
texture = texture['texture']
mask_texture = bs.gettexture('characterIconMask')
else:
tint_color = (1, 1, 1)
tint2_color = None

View file

@ -46,6 +46,7 @@ class PlayerSpaz(Spaz):
def __init__(
self,
player: bs.Player,
*,
color: Sequence[float] = (1.0, 1.0, 1.0),
highlight: Sequence[float] = (0.5, 0.5, 0.5),
character: str = 'Spaz',
@ -102,6 +103,7 @@ class PlayerSpaz(Spaz):
def connect_controls_to_player(
self,
*,
enable_jump: bool = True,
enable_punch: bool = True,
enable_pickup: bool = True,

View file

@ -22,6 +22,7 @@ class PopupText(bs.Actor):
def __init__(
self,
text: str | bs.Lstr,
*,
position: Sequence[float] = (0.0, 0.0, 0.0),
color: Sequence[float] = (1.0, 1.0, 1.0, 1.0),
random_offset: float = 0.5,

View file

@ -22,14 +22,18 @@ class _Entry:
scale: float,
label: bs.Lstr | None,
flash_length: float,
width: float | None = None,
height: float | None = None,
):
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-positional-arguments
self._scoreboard = weakref.ref(scoreboard)
self._do_cover = do_cover
self._scale = scale
self._flash_length = flash_length
self._width = 140.0 * self._scale
self._height = 32.0 * self._scale
self._width = (140.0 if width is None else width) * self._scale
self._height = (32.0 if height is None else height) * self._scale
self._bar_width = 2.0 * self._scale
self._bar_height = 32.0 * self._scale
self._bar_tex = self._backing_tex = bs.gettexture('bar')
@ -277,6 +281,7 @@ class _Entry:
def set_value(
self,
score: float,
*,
max_score: float | None = None,
countdown: bool = False,
flash: bool = True,
@ -368,16 +373,26 @@ class Scoreboard:
_ENTRYSTORENAME = bs.storagename('entry')
def __init__(self, label: bs.Lstr | None = None, score_split: float = 0.7):
def __init__(
self,
label: bs.Lstr | None = None,
score_split: float = 0.7,
pos: Sequence[float] | None = None,
width: float | None = None,
height: float | None = None,
):
"""Instantiate a scoreboard.
Label can be something like 'points' and will
show up on boards if provided.
"""
# pylint: disable=too-many-positional-arguments
self._flat_tex = bs.gettexture('null')
self._entries: dict[int, _Entry] = {}
self._label = label
self.score_split = score_split
self._width = width
self._height = height
# For free-for-all we go simpler since we have one per player.
self._pos: Sequence[float]
@ -393,12 +408,14 @@ class Scoreboard:
self._pos = (20.0, -70.0)
self._scale = 1.0
self._flash_length = 1.0
self._pos = self._pos if pos is None else pos
def set_team_value(
self,
team: bs.Team,
score: float,
max_score: float | None = None,
*,
countdown: bool = False,
flash: bool = True,
show_value: bool = True,
@ -430,6 +447,8 @@ class Scoreboard:
do_cover=self._do_cover,
scale=self._scale,
label=self._label,
width=self._width,
height=self._height,
flash_length=self._flash_length,
)
self._update_teams()

View file

@ -50,6 +50,7 @@ class Spawner:
def __init__(
self,
*,
data: Any = None,
pt: Sequence[float] = (0, 0, 0),
spawn_time: float = 1.0,

View file

@ -71,6 +71,7 @@ class Spaz(bs.Actor):
def __init__(
self,
*,
color: Sequence[float] = (1.0, 1.0, 1.0),
highlight: Sequence[float] = (0.5, 0.5, 0.5),
character: str = 'Spaz',
@ -1195,11 +1196,12 @@ class Spaz(bs.Actor):
if self.node:
self.node.delete()
elif self.node:
self.node.hurt = 1.0
if self.play_big_death_sound and not wasdead:
SpazFactory.get().single_player_death_sound.play()
self.node.dead = True
bs.timer(2.0, self.node.delete)
if not wasdead:
self.node.hurt = 1.0
if self.play_big_death_sound:
SpazFactory.get().single_player_death_sound.play()
self.node.dead = True
bs.timer(2.0, self.node.delete)
elif isinstance(msg, bs.OutOfBoundsMessage):
# By default we just die here.

View file

@ -14,7 +14,7 @@ def get_appearances(include_locked: bool = False) -> list[str]:
assert plus is not None
assert bs.app.classic is not None
get_purchased = plus.get_purchased
get_purchased = plus.get_v1_account_product_purchased
disallowed = []
if not include_locked:
# Hmm yeah this'll be tough to hack...

View file

@ -947,9 +947,9 @@ class SpazBotSet:
on_spawn_call: Callable[[SpazBot], Any] | None = None,
) -> None:
"""Spawn a bot from this set."""
from bascenev1lib.actor import spawner
from bascenev1lib.actor.spawner import Spawner
spawner.Spawner(
Spawner(
pt=pos,
spawn_time=spawn_time,
send_spawn_message=False,

View file

@ -56,6 +56,7 @@ class Text(bs.Actor):
def __init__(
self,
text: str | bs.Lstr,
*,
position: tuple[float, float] = (0.0, 0.0),
h_align: HAlign = HAlign.LEFT,
v_align: VAlign = VAlign.NONE,

View file

@ -26,6 +26,7 @@ class ZoomText(bs.Actor):
self,
text: str | bs.Lstr,
position: tuple[float, float] = (0.0, 0.0),
*,
shiftposition: tuple[float, float] | None = None,
shiftdelay: float | None = None,
lifespan: float | None = None,

View file

@ -2,7 +2,7 @@
#
"""Defines assault minigame."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Defines a capture-the-flag game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -32,6 +32,7 @@ class CTFFlag(Flag):
activity: CaptureTheFlagGame
def __init__(self, team: Team):
assert team.flagmaterial is not None
super().__init__(
materials=[team.flagmaterial],
@ -73,6 +74,7 @@ class Team(bs.Team[Player]):
def __init__(
self,
*,
base_pos: Sequence[float],
base_region_material: bs.Material,
base_region: bs.Node,

View file

@ -2,7 +2,7 @@
#
"""Provides the chosen-one mini-game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Provides the Conquest game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""DeathMatch game and support classes."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Provides an easter egg hunt game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Elimination mini-game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -27,6 +27,7 @@ class Icon(bs.Actor):
player: Player,
position: tuple[float, float],
scale: float,
*,
show_lives: bool = True,
show_death: bool = True,
name_scale: float = 1.0,

View file

@ -1,9 +1,8 @@
# Released under the MIT License. See LICENSE for details.
#
# pylint: disable=too-many-lines
"""Implements football games (both co-op and teams varieties)."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -332,7 +331,10 @@ class FootballTeamGame(bs.TeamGameActivity[Player, Team]):
# Respawn dead flags.
elif isinstance(msg, FlagDiedMessage):
if not self.has_ended():
self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
if msg.self_kill:
self._spawn_flag()
else:
self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
self._flag_respawn_light = bs.NodeActor(
bs.newnode(
'light',
@ -655,11 +657,6 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
for bot in bots:
bot.target_flag = None
# If we're waiting on a continue, stop here so they don't keep scoring.
if self.is_waiting_for_continue():
self._bots.stop_moving()
return
# If we've got a flag and no player are holding it, find the closest
# bot to it, and make them the designated flag-bearer.
assert self._flag is not None
@ -816,14 +813,6 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
self._bots.final_celebrate()
bs.timer(0.001, bs.Call(self.do_end, 'defeat'))
@override
def on_continue(self) -> None:
# Subtract one touchdown from the bots and get them moving again.
assert self._bot_team is not None
self._bot_team.score -= 7
self._bots.start_moving()
self.update_scores()
def update_scores(self) -> None:
"""update scoreboard and check for winners"""
# FIXME: tidy this up
@ -838,7 +827,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
if not have_scoring_team:
self._scoring_team = team
if team is self._bot_team:
self.continue_or_end_game()
self.end_game()
else:
bs.setmusic(bs.MusicType.VICTORY)
@ -893,8 +882,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
)
self._time_text_input.node.timemax = self._final_time_ms
# FIXME: Does this still need to be deferred?
bs.pushcall(bs.Call(self.do_end, 'victory'))
self.do_end('victory')
def do_end(self, outcome: str) -> None:
"""End the game with the specified outcome."""
@ -945,7 +933,10 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
# Respawn dead flags.
elif isinstance(msg, FlagDiedMessage):
assert isinstance(msg.flag, FootballFlag)
msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
if msg.self_kill:
self._spawn_flag()
else:
msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
self._flag_respawn_light = bs.NodeActor(
bs.newnode(
'light',
@ -962,7 +953,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
self._flag_respawn_light.node,
'intensity',
{0: 0, 0.25: 0.15, 0.5: 0},
loop=True,
loop=(not msg.self_kill),
)
bs.timer(3.0, self._flag_respawn_light.node.delete)
else:

View file

@ -2,7 +2,7 @@
#
"""Hockey game and support classes."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Defines a keep-away game type."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Defines the King of the Hill game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Defines a bomb-dodging mini-game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -2,7 +2,7 @@
#
"""Provides Ninja Fight mini-game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

View file

@ -5,7 +5,7 @@
# Yes this is a long one..
# pylint: disable=too-many-lines
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -805,6 +805,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
max_level: int,
) -> list[list[tuple[int, int]]]:
"""Calculate a distribution of bad guys given some params."""
# pylint: disable=too-many-positional-arguments
max_iterations = 10 + max_dudes * 2
groups: list[list[tuple[int, int]]] = []
@ -1194,7 +1195,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
def _respawn_players_for_wave(self) -> None:
# Respawn applicable players.
if self._wavenum > 1 and not self.is_waiting_for_continue():
if self._wavenum > 1:
for player in self.players:
if (
not player.is_alive()
@ -1641,19 +1642,9 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
self.do_end('defeat', delay=2.0)
bs.setmusic(None)
@override
def on_continue(self) -> None:
for player in self.players:
if not player.is_alive():
self.spawn_player(player)
def _checkroundover(self) -> None:
"""Potentially end the round based on the state of the game."""
if self.has_ended():
return
if not any(player.is_alive() for player in self.teams[0].players):
# Allow continuing after wave 1.
if self._wavenum > 1:
self.continue_or_end_game()
else:
self.end_game()
self.end_game()

View file

@ -2,7 +2,7 @@
#
"""Defines Race mini-game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -138,7 +138,9 @@ class RaceGame(bs.TeamGameActivity[Player, Team]):
@override
@classmethod
def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
return issubclass(sessiontype, bs.MultiTeamSession)
return issubclass(sessiontype, bs.MultiTeamSession) or issubclass(
sessiontype, bs.CoopSession
)
@override
@classmethod

View file

@ -5,7 +5,7 @@
# We wear the cone of shame.
# pylint: disable=too-many-lines
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
@ -487,9 +487,9 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
assert bs.app.classic is not None
uiscale = bs.app.ui_v1.uiscale
l_offs = (
-80
-120
if uiscale is bs.UIScale.SMALL
else -40 if uiscale is bs.UIScale.MEDIUM else 0
else -60 if uiscale is bs.UIScale.MEDIUM else -30
)
self._lives_bg = bs.NodeActor(
@ -550,7 +550,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
self._lives -= 1
if self._lives == 0:
self._bots.stop_moving()
self.continue_or_end_game()
self.end_game()
# Heartbeat behavior
if self._lives < 5:
@ -613,14 +613,6 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
),
)
@override
def on_continue(self) -> None:
self._lives = 3
assert self._lives_text is not None
assert self._lives_text.node
self._lives_text.node.text = str(self._lives)
self._bots.start_moving()
@override
def spawn_player(self, player: Player) -> bs.Actor:
pos = (

View file

@ -2,7 +2,7 @@
#
"""Implements Target Practice game."""
# ba_meta require api 8
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations

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