diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 559737f..5293f60 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/README.md b/README.md index 5a3ca20..04d79bc 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/config.toml b/config.toml index a64cdc3..ed5a203 100644 --- a/config.toml +++ b/config.toml @@ -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" \ No newline at end of file diff --git a/dist/ba_data/python/babase/__init__.py b/dist/ba_data/python/babase/__init__.py index 61b75ae..fb1a414 100644 --- a/dist/ba_data/python/babase/__init__.py +++ b/dist/ba_data/python/babase/__init__.py @@ -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', diff --git a/dist/ba_data/python/babase/_accountv2.py b/dist/ba_data/python/babase/_accountv2.py index f6544a5..b04bec6 100644 --- a/dist/ba_data/python/babase/_accountv2.py +++ b/dist/ba_data/python/babase/_accountv2.py @@ -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' diff --git a/dist/ba_data/python/babase/_app.py b/dist/ba_data/python/babase/_app.py index 58ba2fc..6aa4d02 100644 --- a/dist/ba_data/python/babase/_app.py +++ b/dist/ba_data/python/babase/_app.py @@ -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. diff --git a/dist/ba_data/python/babase/_appconfig.py b/dist/ba_data/python/babase/_appconfig.py index 92efc73..aca886b 100644 --- a/dist/ba_data/python/babase/_appconfig.py +++ b/dist/ba_data/python/babase/_appconfig.py @@ -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 diff --git a/dist/ba_data/python/babase/_appmode.py b/dist/ba_data/python/babase/_appmode.py index cfe01f7..6de1d2b 100644 --- a/dist/ba_data/python/babase/_appmode.py +++ b/dist/ba_data/python/babase/_appmode.py @@ -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. diff --git a/dist/ba_data/python/babase/_appmodeselector.py b/dist/ba_data/python/babase/_appmodeselector.py index 9dff2cb..82acc48 100644 --- a/dist/ba_data/python/babase/_appmodeselector.py +++ b/dist/ba_data/python/babase/_appmodeselector.py @@ -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() diff --git a/dist/ba_data/python/babase/_appsubsystem.py b/dist/ba_data/python/babase/_appsubsystem.py index 78ba01d..8f086ff 100644 --- a/dist/ba_data/python/babase/_appsubsystem.py +++ b/dist/ba_data/python/babase/_appsubsystem.py @@ -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. + """ diff --git a/dist/ba_data/python/babase/_apputils.py b/dist/ba_data/python/babase/_apputils.py index 05d2b3d..0ff3960 100644 --- a/dist/ba_data/python/babase/_apputils.py +++ b/dist/ba_data/python/babase/_apputils.py @@ -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: diff --git a/dist/ba_data/python/babase/_cloud.py b/dist/ba_data/python/babase/_cloud.py index 3f5643d..bb96662 100644 --- a/dist/ba_data/python/babase/_cloud.py +++ b/dist/ba_data/python/babase/_cloud.py @@ -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, '', '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, '', '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) diff --git a/dist/ba_data/python/babase/_devconsole.py b/dist/ba_data/python/babase/_devconsole.py index d4e7099..66632ba 100644 --- a/dist/ba_data/python/babase/_devconsole.py +++ b/dist/ba_data/python/babase/_devconsole.py @@ -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( diff --git a/dist/ba_data/python/babase/_devconsoletabs.py b/dist/ba_data/python/babase/_devconsoletabs.py new file mode 100644 index 0000000..6429fa6 --- /dev/null +++ b/dist/ba_data/python/babase/_devconsoletabs.py @@ -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', + ) diff --git a/dist/ba_data/python/babase/_emptyappmode.py b/dist/ba_data/python/babase/_emptyappmode.py index 6745d21..f1a7b8f 100644 --- a/dist/ba_data/python/babase/_emptyappmode.py +++ b/dist/ba_data/python/babase/_emptyappmode.py @@ -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() diff --git a/dist/ba_data/python/babase/_env.py b/dist/ba_data/python/babase/_env.py index 5efc253..41b1234 100644 --- a/dist/ba_data/python/babase/_env.py +++ b/dist/ba_data/python/babase/_env.py @@ -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. diff --git a/dist/ba_data/python/babase/_general.py b/dist/ba_data/python/babase/_general.py index d9c2185..209589f 100644 --- a/dist/ba_data/python/babase/_general.py +++ b/dist/ba_data/python/babase/_general.py @@ -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__ diff --git a/dist/ba_data/python/babase/_hooks.py b/dist/ba_data/python/babase/_hooks.py index c344d26..ed16fe0 100644 --- a/dist/ba_data/python/babase/_hooks.py +++ b/dist/ba_data/python/babase/_hooks.py @@ -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() diff --git a/dist/ba_data/python/babase/_keyboard.py b/dist/ba_data/python/babase/_keyboard.py deleted file mode 100644 index 6d18ac6..0000000 --- a/dist/ba_data/python/babase/_keyboard.py +++ /dev/null @@ -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.""" diff --git a/dist/ba_data/python/babase/_language.py b/dist/ba_data/python/babase/_language.py index 4e118b5..0f7af39 100644 --- a/dist/ba_data/python/babase/_language.py +++ b/dist/ba_data/python/babase/_language.py @@ -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. diff --git a/dist/ba_data/python/babase/_logging.py b/dist/ba_data/python/babase/_logging.py new file mode 100644 index 0000000..42d063d --- /dev/null +++ b/dist/ba_data/python/babase/_logging.py @@ -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') diff --git a/dist/ba_data/python/babase/_login.py b/dist/ba_data/python/babase/_login.py index 0f3d1c4..eb0cc89 100644 --- a/dist/ba_data/python/babase/_login.py +++ b/dist/ba_data/python/babase/_login.py @@ -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 diff --git a/dist/ba_data/python/babase/_meta.py b/dist/ba_data/python/babase/_meta.py index 9a66d77..9354874 100644 --- a/dist/ba_data/python/babase/_meta.py +++ b/dist/ba_data/python/babase/_meta.py @@ -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' ): diff --git a/dist/ba_data/python/babase/_mgen/enums.py b/dist/ba_data/python/babase/_mgen/enums.py index 9eb72b0..99b0f10 100644 --- a/dist/ba_data/python/babase/_mgen/enums.py +++ b/dist/ba_data/python/babase/_mgen/enums.py @@ -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): diff --git a/dist/ba_data/python/babase/_net.py b/dist/ba_data/python/babase/_net.py index 93522ad..f871a4f 100644 --- a/dist/ba_data/python/babase/_net.py +++ b/dist/ba_data/python/babase/_net.py @@ -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' diff --git a/dist/ba_data/python/babase/_plugin.py b/dist/ba_data/python/babase/_plugin.py index 5e5f675..3aad9bc 100644 --- a/dist/ba_data/python/babase/_plugin.py +++ b/dist/ba_data/python/babase/_plugin.py @@ -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] diff --git a/dist/ba_data/python/baclassic/__init__.py b/dist/ba_data/python/baclassic/__init__.py index 51f50da..7b5a049 100644 --- a/dist/ba_data/python/baclassic/__init__.py +++ b/dist/ba_data/python/baclassic/__init__.py @@ -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 diff --git a/dist/ba_data/python/baclassic/_accountv1.py b/dist/ba_data/python/baclassic/_accountv1.py index 47b7dae..10406f4 100644 --- a/dist/ba_data/python/baclassic/_accountv1.py +++ b/dist/ba_data/python/baclassic/_accountv1.py @@ -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 diff --git a/dist/ba_data/python/baclassic/_achievement.py b/dist/ba_data/python/baclassic/_achievement.py index 3ef3bed..b00f410 100644 --- a/dist/ba_data/python/baclassic/_achievement.py +++ b/dist/ba_data/python/baclassic/_achievement.py @@ -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, diff --git a/dist/ba_data/python/baclassic/_ads.py b/dist/ba_data/python/baclassic/_ads.py index 5cd454d..b2f6917 100644 --- a/dist/ba_data/python/baclassic/_ads.py +++ b/dist/ba_data/python/baclassic/_ads.py @@ -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 diff --git a/dist/ba_data/python/baclassic/_appdelegate.py b/dist/ba_data/python/baclassic/_appdelegate.py deleted file mode 100644 index 1dd1a6b..0000000 --- a/dist/ba_data/python/baclassic/_appdelegate.py +++ /dev/null @@ -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. - ) diff --git a/dist/ba_data/python/baclassic/_appmode.py b/dist/ba_data/python/baclassic/_appmode.py new file mode 100644 index 0000000..50fa333 --- /dev/null +++ b/dist/ba_data/python/baclassic/_appmode.py @@ -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), + ), + ) + ) diff --git a/dist/ba_data/python/baclassic/_subsystem.py b/dist/ba_data/python/baclassic/_appsubsystem.py similarity index 71% rename from dist/ba_data/python/baclassic/_subsystem.py rename to dist/ba_data/python/baclassic/_appsubsystem.py index 16611ec..5879496 100644 --- a/dist/ba_data/python/baclassic/_subsystem.py +++ b/dist/ba_data/python/baclassic/_appsubsystem.py @@ -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='') + + 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 diff --git a/dist/ba_data/python/baclassic/_benchmark.py b/dist/ba_data/python/baclassic/_benchmark.py index 0c25f59..b110e25 100644 --- a/dist/ba_data/python/baclassic/_benchmark.py +++ b/dist/ba_data/python/baclassic/_benchmark.py @@ -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())) diff --git a/dist/ba_data/python/baclassic/_chest.py b/dist/ba_data/python/baclassic/_chest.py new file mode 100644 index 0000000..b5e18ce --- /dev/null +++ b/dist/ba_data/python/baclassic/_chest.py @@ -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), + ), +} diff --git a/dist/ba_data/python/baclassic/_clienteffect.py b/dist/ba_data/python/baclassic/_clienteffect.py new file mode 100644 index 0000000..38b0d9c --- /dev/null +++ b/dist/ba_data/python/baclassic/_clienteffect.py @@ -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.' + ) diff --git a/dist/ba_data/python/baclassic/_displayitem.py b/dist/ba_data/python/baclassic/_displayitem.py new file mode 100644 index 0000000..da8215c --- /dev/null +++ b/dist/ba_data/python/baclassic/_displayitem.py @@ -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', + ) diff --git a/dist/ba_data/python/baclassic/_music.py b/dist/ba_data/python/baclassic/_music.py index 8f439d7..5824637 100644 --- a/dist/ba_data/python/baclassic/_music.py +++ b/dist/ba_data/python/baclassic/_music.py @@ -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: diff --git a/dist/ba_data/python/baclassic/_net.py b/dist/ba_data/python/baclassic/_net.py index 359c442..185f24b 100644 --- a/dist/ba_data/python/baclassic/_net.py +++ b/dist/ba_data/python/baclassic/_net.py @@ -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: diff --git a/dist/ba_data/python/baclassic/_servermode.py b/dist/ba_data/python/baclassic/_servermode.py index a9ce949..67b5fbe 100644 --- a/dist/ba_data/python/baclassic/_servermode.py +++ b/dist/ba_data/python/baclassic/_servermode.py @@ -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 diff --git a/dist/ba_data/python/baclassic/_store.py b/dist/ba_data/python/baclassic/_store.py index f469012..ed5098b 100644 --- a/dist/ba_data/python/baclassic/_store.py +++ b/dist/ba_data/python/baclassic/_store.py @@ -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 diff --git a/dist/ba_data/python/baclassic/_tournament.py b/dist/ba_data/python/baclassic/_tournament.py index 242ba52..e71d960 100644 --- a/dist/ba_data/python/baclassic/_tournament.py +++ b/dist/ba_data/python/baclassic/_tournament.py @@ -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() diff --git a/dist/ba_data/python/baclassic/macmusicapp.py b/dist/ba_data/python/baclassic/macmusicapp.py index 6b4b9b4..48a41e5 100644 --- a/dist/ba_data/python/baclassic/macmusicapp.py +++ b/dist/ba_data/python/baclassic/macmusicapp.py @@ -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 diff --git a/dist/ba_data/python/baclassic/osmusic.py b/dist/ba_data/python/baclassic/osmusic.py index 919df49..8a79bf7 100644 --- a/dist/ba_data/python/baclassic/osmusic.py +++ b/dist/ba_data/python/baclassic/osmusic.py @@ -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, diff --git a/dist/ba_data/python/bacommon/app.py b/dist/ba_data/python/bacommon/app.py index 5f42c89..7822811 100644 --- a/dist/ba_data/python/bacommon/app.py +++ b/dist/ba_data/python/bacommon/app.py @@ -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. diff --git a/dist/ba_data/python/bacommon/bs.py b/dist/ba_data/python/bacommon/bs.py new file mode 100644 index 0000000..37897a1 --- /dev/null +++ b/dist/ba_data/python/bacommon/bs.py @@ -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')] diff --git a/dist/ba_data/python/bacommon/cloud.py b/dist/ba_data/python/bacommon/cloud.py index 223fb71..0d8aaf2 100644 --- a/dist/ba_data/python/bacommon/cloud.py +++ b/dist/ba_data/python/bacommon/cloud.py @@ -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')] diff --git a/dist/ba_data/python/bacommon/loggercontrol.py b/dist/ba_data/python/bacommon/loggercontrol.py new file mode 100644 index 0000000..e07e811 --- /dev/null +++ b/dist/ba_data/python/bacommon/loggercontrol.py @@ -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 diff --git a/dist/ba_data/python/bacommon/logging.py b/dist/ba_data/python/bacommon/logging.py new file mode 100644 index 0000000..723c9fc --- /dev/null +++ b/dist/ba_data/python/bacommon/logging.py @@ -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} + ) diff --git a/dist/ba_data/python/bacommon/servermanager.py b/dist/ba_data/python/bacommon/servermanager.py index 31f235f..20b9f4e 100644 --- a/dist/ba_data/python/bacommon/servermanager.py +++ b/dist/ba_data/python/bacommon/servermanager.py @@ -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 diff --git a/dist/ba_data/python/baenv.py b/dist/ba_data/python/baenv.py index a087524..b7da38f 100644 --- a/dist/ba_data/python/baenv.py +++ b/dist/ba_data/python/baenv.py @@ -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 diff --git a/dist/ba_data/python/baplus/__init__.py b/dist/ba_data/python/baplus/__init__.py index 4040f7b..9b2dba2 100644 --- a/dist/ba_data/python/baplus/__init__.py +++ b/dist/ba_data/python/baplus/__init__.py @@ -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 diff --git a/dist/ba_data/python/baplus/_subsystem.py b/dist/ba_data/python/baplus/_appsubsystem.py similarity index 94% rename from dist/ba_data/python/baplus/_subsystem.py rename to dist/ba_data/python/baplus/_appsubsystem.py index c5ef5ff..809f387 100644 --- a/dist/ba_data/python/baplus/_subsystem.py +++ b/dist/ba_data/python/baplus/_appsubsystem.py @@ -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', diff --git a/dist/ba_data/python/baplus/_cloud.py b/dist/ba_data/python/baplus/_cloud.py index 0fcc0ae..c0d030d 100644 --- a/dist/ba_data/python/baplus/_cloud.py +++ b/dist/ba_data/python/baplus/_cloud.py @@ -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: diff --git a/dist/ba_data/python/bascenev1/__init__.py b/dist/ba_data/python/bascenev1/__init__.py index b351348..873e4f7 100644 --- a/dist/ba_data/python/bascenev1/__init__.py +++ b/dist/ba_data/python/bascenev1/__init__.py @@ -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', diff --git a/dist/ba_data/python/bascenev1/_appmode.py b/dist/ba_data/python/bascenev1/_appmode.py deleted file mode 100644 index 72a77ae..0000000 --- a/dist/ba_data/python/bascenev1/_appmode.py +++ /dev/null @@ -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() diff --git a/dist/ba_data/python/bascenev1/_gameactivity.py b/dist/ba_data/python/bascenev1/_gameactivity.py index 1517733..d0e3652 100644 --- a/dist/ba_data/python/bascenev1/_gameactivity.py +++ b/dist/ba_data/python/bascenev1/_gameactivity.py @@ -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, diff --git a/dist/ba_data/python/bascenev1/_gameutils.py b/dist/ba_data/python/bascenev1/_gameutils.py index 814e4bb..c67f066 100644 --- a/dist/ba_data/python/bascenev1/_gameutils.py +++ b/dist/ba_data/python/bascenev1/_gameutils.py @@ -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 diff --git a/dist/ba_data/python/bascenev1/_level.py b/dist/ba_data/python/bascenev1/_level.py index 6da0a7e..ce8ddf5 100644 --- a/dist/ba_data/python/bascenev1/_level.py +++ b/dist/ba_data/python/bascenev1/_level.py @@ -27,6 +27,7 @@ class Level: gametype: type[bascenev1.GameActivity], settings: dict, preview_texture_name: str, + *, displayname: str | None = None, ): self._name = name diff --git a/dist/ba_data/python/bascenev1/_lobby.py b/dist/ba_data/python/bascenev1/_lobby.py index 001aa2d..68a51a6 100644 --- a/dist/ba_data/python/bascenev1/_lobby.py +++ b/dist/ba_data/python/bascenev1/_lobby.py @@ -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 diff --git a/dist/ba_data/python/bascenev1/_messages.py b/dist/ba_data/python/bascenev1/_messages.py index cecf8db..45f83c8 100644 --- a/dist/ba_data/python/bascenev1/_messages.py +++ b/dist/ba_data/python/bascenev1/_messages.py @@ -241,6 +241,7 @@ class HitMessage: def __init__( self, + *, srcnode: bascenev1.Node | None = None, pos: Sequence[float] | None = None, velocity: Sequence[float] | None = None, diff --git a/dist/ba_data/python/bascenev1/_playlist.py b/dist/ba_data/python/bascenev1/_playlist.py index 013f6e5..c06199d 100644 --- a/dist/ba_data/python/bascenev1/_playlist.py +++ b/dist/ba_data/python/bascenev1/_playlist.py @@ -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, diff --git a/dist/ba_data/python/bascenev1/_session.py b/dist/ba_data/python/bascenev1/_session.py index dd75734..85c341f 100644 --- a/dist/ba_data/python/bascenev1/_session.py +++ b/dist/ba_data/python/bascenev1/_session.py @@ -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, diff --git a/dist/ba_data/python/bascenev1/_stats.py b/dist/ba_data/python/bascenev1/_stats.py index 4f3a0db..59e02bf 100644 --- a/dist/ba_data/python/bascenev1/_stats.py +++ b/dist/ba_data/python/bascenev1/_stats.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/__init__.py b/dist/ba_data/python/bascenev1lib/__init__.py index 4e8f537..b1fa37e 100644 --- a/dist/ba_data/python/bascenev1lib/__init__.py +++ b/dist/ba_data/python/bascenev1lib/__init__.py @@ -2,4 +2,4 @@ # """Library of stuff using the bascenev1 api: games, actors, etc.""" -# ba_meta require api 8 +# ba_meta require api 9 diff --git a/dist/ba_data/python/bascenev1lib/activity/coopscore.py b/dist/ba_data/python/bascenev1lib/activity/coopscore.py index 4b92b69..2eca62a 100644 --- a/dist/ba_data/python/bascenev1lib/activity/coopscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/coopscore.py @@ -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}' diff --git a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py index dc01ac5..6929a10 100644 --- a/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/dualteamscore.py @@ -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']), diff --git a/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py b/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py index 5f2189f..3f88e3b 100644 --- a/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py +++ b/dist/ba_data/python/bascenev1lib/activity/freeforallvictory.py @@ -87,6 +87,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): extrascale: float, flash: bool = False, ) -> Text: + # pylint: disable=too-many-positional-arguments return Text( text, position=( diff --git a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py index 9feb385..b398f7d 100644 --- a/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py +++ b/dist/ba_data/python/bascenev1lib/activity/multiteamscore.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py b/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py index a8ecc28..058ffd4 100644 --- a/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py +++ b/dist/ba_data/python/bascenev1lib/activity/multiteamvictory.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/actor/bomb.py b/dist/ba_data/python/bascenev1lib/actor/bomb.py index c73eceb..126f0f2 100644 --- a/dist/ba_data/python/bascenev1lib/actor/bomb.py +++ b/dist/ba_data/python/bascenev1lib/actor/bomb.py @@ -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', diff --git a/dist/ba_data/python/bascenev1lib/actor/controlsguide.py b/dist/ba_data/python/bascenev1lib/actor/controlsguide.py index a750dfd..40b3f18 100644 --- a/dist/ba_data/python/bascenev1lib/actor/controlsguide.py +++ b/dist/ba_data/python/bascenev1lib/actor/controlsguide.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/actor/flag.py b/dist/ba_data/python/bascenev1lib/actor/flag.py index 0347203..2e5120f 100644 --- a/dist/ba_data/python/bascenev1lib/actor/flag.py +++ b/dist/ba_data/python/bascenev1lib/actor/flag.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/actor/image.py b/dist/ba_data/python/bascenev1lib/actor/image.py index daa648b..4e38f6b 100644 --- a/dist/ba_data/python/bascenev1lib/actor/image.py +++ b/dist/ba_data/python/bascenev1lib/actor/image.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/actor/playerspaz.py b/dist/ba_data/python/bascenev1lib/actor/playerspaz.py index 07caae5..1aa7ed5 100644 --- a/dist/ba_data/python/bascenev1lib/actor/playerspaz.py +++ b/dist/ba_data/python/bascenev1lib/actor/playerspaz.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/actor/popuptext.py b/dist/ba_data/python/bascenev1lib/actor/popuptext.py index ca375fc..9c0d09c 100644 --- a/dist/ba_data/python/bascenev1lib/actor/popuptext.py +++ b/dist/ba_data/python/bascenev1lib/actor/popuptext.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py index 05a1ba5..18ba89d 100644 --- a/dist/ba_data/python/bascenev1lib/actor/scoreboard.py +++ b/dist/ba_data/python/bascenev1lib/actor/scoreboard.py @@ -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() diff --git a/dist/ba_data/python/bascenev1lib/actor/spawner.py b/dist/ba_data/python/bascenev1lib/actor/spawner.py index 78fa56c..95cc283 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spawner.py +++ b/dist/ba_data/python/bascenev1lib/actor/spawner.py @@ -50,6 +50,7 @@ class Spawner: def __init__( self, + *, data: Any = None, pt: Sequence[float] = (0, 0, 0), spawn_time: float = 1.0, diff --git a/dist/ba_data/python/bascenev1lib/actor/spaz.py b/dist/ba_data/python/bascenev1lib/actor/spaz.py index f70383f..e1e0040 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spaz.py +++ b/dist/ba_data/python/bascenev1lib/actor/spaz.py @@ -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. diff --git a/dist/ba_data/python/bascenev1lib/actor/spazappearance.py b/dist/ba_data/python/bascenev1lib/actor/spazappearance.py index 7cff495..a2122d8 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spazappearance.py +++ b/dist/ba_data/python/bascenev1lib/actor/spazappearance.py @@ -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... diff --git a/dist/ba_data/python/bascenev1lib/actor/spazbot.py b/dist/ba_data/python/bascenev1lib/actor/spazbot.py index 56118af..86d1620 100644 --- a/dist/ba_data/python/bascenev1lib/actor/spazbot.py +++ b/dist/ba_data/python/bascenev1lib/actor/spazbot.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/actor/text.py b/dist/ba_data/python/bascenev1lib/actor/text.py index cdea37d..42611e8 100644 --- a/dist/ba_data/python/bascenev1lib/actor/text.py +++ b/dist/ba_data/python/bascenev1lib/actor/text.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/actor/zoomtext.py b/dist/ba_data/python/bascenev1lib/actor/zoomtext.py index 46525aa..573c600 100644 --- a/dist/ba_data/python/bascenev1lib/actor/zoomtext.py +++ b/dist/ba_data/python/bascenev1lib/actor/zoomtext.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/game/assault.py b/dist/ba_data/python/bascenev1lib/game/assault.py index 051cb7f..4f8fb25 100644 --- a/dist/ba_data/python/bascenev1lib/game/assault.py +++ b/dist/ba_data/python/bascenev1lib/game/assault.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/capturetheflag.py b/dist/ba_data/python/bascenev1lib/game/capturetheflag.py index a5e65ed..de2ea8a 100644 --- a/dist/ba_data/python/bascenev1lib/game/capturetheflag.py +++ b/dist/ba_data/python/bascenev1lib/game/capturetheflag.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/game/chosenone.py b/dist/ba_data/python/bascenev1lib/game/chosenone.py index 1716010..1617f87 100644 --- a/dist/ba_data/python/bascenev1lib/game/chosenone.py +++ b/dist/ba_data/python/bascenev1lib/game/chosenone.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/conquest.py b/dist/ba_data/python/bascenev1lib/game/conquest.py index 7c14011..0002fab 100644 --- a/dist/ba_data/python/bascenev1lib/game/conquest.py +++ b/dist/ba_data/python/bascenev1lib/game/conquest.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/deathmatch.py b/dist/ba_data/python/bascenev1lib/game/deathmatch.py index 85ff142..ee331ed 100644 --- a/dist/ba_data/python/bascenev1lib/game/deathmatch.py +++ b/dist/ba_data/python/bascenev1lib/game/deathmatch.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/easteregghunt.py b/dist/ba_data/python/bascenev1lib/game/easteregghunt.py index 260628a..50a1f00 100644 --- a/dist/ba_data/python/bascenev1lib/game/easteregghunt.py +++ b/dist/ba_data/python/bascenev1lib/game/easteregghunt.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/elimination.py b/dist/ba_data/python/bascenev1lib/game/elimination.py index c0434fb..ef8627a 100644 --- a/dist/ba_data/python/bascenev1lib/game/elimination.py +++ b/dist/ba_data/python/bascenev1lib/game/elimination.py @@ -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, diff --git a/dist/ba_data/python/bascenev1lib/game/football.py b/dist/ba_data/python/bascenev1lib/game/football.py index 3555794..0fd603f 100644 --- a/dist/ba_data/python/bascenev1lib/game/football.py +++ b/dist/ba_data/python/bascenev1lib/game/football.py @@ -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: diff --git a/dist/ba_data/python/bascenev1lib/game/hockey.py b/dist/ba_data/python/bascenev1lib/game/hockey.py index c8b8449..63b4cef 100644 --- a/dist/ba_data/python/bascenev1lib/game/hockey.py +++ b/dist/ba_data/python/bascenev1lib/game/hockey.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/keepaway.py b/dist/ba_data/python/bascenev1lib/game/keepaway.py index 874b03e..555e262 100644 --- a/dist/ba_data/python/bascenev1lib/game/keepaway.py +++ b/dist/ba_data/python/bascenev1lib/game/keepaway.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/kingofthehill.py b/dist/ba_data/python/bascenev1lib/game/kingofthehill.py index 24244f2..083e1b1 100644 --- a/dist/ba_data/python/bascenev1lib/game/kingofthehill.py +++ b/dist/ba_data/python/bascenev1lib/game/kingofthehill.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/meteorshower.py b/dist/ba_data/python/bascenev1lib/game/meteorshower.py index 7596632..f2a01ec 100644 --- a/dist/ba_data/python/bascenev1lib/game/meteorshower.py +++ b/dist/ba_data/python/bascenev1lib/game/meteorshower.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/ninjafight.py b/dist/ba_data/python/bascenev1lib/game/ninjafight.py index 67658cf..28a0e4d 100644 --- a/dist/ba_data/python/bascenev1lib/game/ninjafight.py +++ b/dist/ba_data/python/bascenev1lib/game/ninjafight.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/onslaught.py b/dist/ba_data/python/bascenev1lib/game/onslaught.py index 6933a0c..f8d2182 100644 --- a/dist/ba_data/python/bascenev1lib/game/onslaught.py +++ b/dist/ba_data/python/bascenev1lib/game/onslaught.py @@ -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() diff --git a/dist/ba_data/python/bascenev1lib/game/race.py b/dist/ba_data/python/bascenev1lib/game/race.py index c555616..766afa7 100644 --- a/dist/ba_data/python/bascenev1lib/game/race.py +++ b/dist/ba_data/python/bascenev1lib/game/race.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/game/runaround.py b/dist/ba_data/python/bascenev1lib/game/runaround.py index 8d0b993..5cdf850 100644 --- a/dist/ba_data/python/bascenev1lib/game/runaround.py +++ b/dist/ba_data/python/bascenev1lib/game/runaround.py @@ -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 = ( diff --git a/dist/ba_data/python/bascenev1lib/game/targetpractice.py b/dist/ba_data/python/bascenev1lib/game/targetpractice.py index 18d9410..b78ad81 100644 --- a/dist/ba_data/python/bascenev1lib/game/targetpractice.py +++ b/dist/ba_data/python/bascenev1lib/game/targetpractice.py @@ -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 diff --git a/dist/ba_data/python/bascenev1lib/mainmenu.py b/dist/ba_data/python/bascenev1lib/mainmenu.py index 4e90293..eb2215a 100644 --- a/dist/ba_data/python/bascenev1lib/mainmenu.py +++ b/dist/ba_data/python/bascenev1lib/mainmenu.py @@ -1,7 +1,6 @@ # Released under the MIT License. See LICENSE for details. # """Session and Activity for displaying the main menu bg.""" -# pylint: disable=too-many-lines from __future__ import annotations @@ -16,6 +15,8 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any + import bacommon.bs + class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): """Activity showing the rotating main menu bg stuff.""" @@ -43,55 +44,23 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): self._update_timer: bs.Timer | None = None self._news: NewsDisplay | None = None self._attract_mode_timer: bs.Timer | None = None + self._logo_rotate_timer: bs.Timer | None = None @override def on_transition_in(self) -> None: # pylint: disable=too-many-locals # pylint: disable=too-many-statements - # pylint: disable=too-many-branches super().on_transition_in() random.seed(123) app = bs.app env = app.env assert app.classic is not None - plus = bui.app.plus + plus = bs.app.plus assert plus is not None - # FIXME: We shouldn't be doing things conditionally based on whether - # the host is VR mode or not (clients may differ in that regard). - # Any differences need to happen at the engine level so everyone - # sees things in their own optimal way. - vr_mode = bs.app.env.vr - - if not bs.app.ui_v1.use_toolbars: - color = (1.0, 1.0, 1.0, 1.0) if vr_mode else (0.5, 0.6, 0.5, 0.6) - - # FIXME: Need a node attr for vr-specific-scale. - scale = ( - 0.9 - if (app.ui_v1.uiscale is bs.UIScale.SMALL or vr_mode) - else 0.7 - ) - self.my_name = bs.NodeActor( - bs.newnode( - 'text', - attrs={ - 'v_attach': 'bottom', - 'h_align': 'center', - 'color': color, - 'flatness': 1.0, - 'shadow': 1.0 if vr_mode else 0.5, - 'scale': scale, - 'position': (0, 10), - 'vr_depth': -10, - 'text': '\xa9 2011-2024 Eric Froemling', - }, - ) - ) - - # Throw up some text that only clients can see so they know that the - # host is navigating menus while they're just staring at an + # Throw up some text that only clients can see so they know that + # the host is navigating menus while they're just staring at an # empty-ish screen. tval = bs.Lstr( resource='hostIsNavigatingMenusText', @@ -109,73 +78,16 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): }, ) ) - if not app.classic.main_menu_did_initial_transition and hasattr( - self, 'my_name' + if ( + not app.classic.main_menu_did_initial_transition + and self.my_name is not None ): - assert self.my_name is not None assert self.my_name.node bs.animate(self.my_name.node, 'opacity', {2.3: 0, 3.0: 1.0}) - # FIXME: We shouldn't be doing things conditionally based on whether - # the host is vr mode or not (clients may not be or vice versa). - # Any differences need to happen at the engine level so everyone sees - # things in their own optimal way. - vr_mode = app.env.vr - uiscale = app.ui_v1.uiscale - - # In cases where we're doing lots of dev work lets always show the - # build number. - force_show_build_number = False - - if not bs.app.ui_v1.use_toolbars: - if env.debug or env.test or force_show_build_number: - if env.debug: - text = bs.Lstr( - value='${V} (${B}) (${D})', - subs=[ - ('${V}', app.env.engine_version), - ('${B}', str(app.env.engine_build_number)), - ('${D}', bs.Lstr(resource='debugText')), - ], - ) - else: - text = bs.Lstr( - value='${V} (${B})', - subs=[ - ('${V}', app.env.engine_version), - ('${B}', str(app.env.engine_build_number)), - ], - ) - else: - text = bs.Lstr( - value='${V}', subs=[('${V}', app.env.engine_version)] - ) - scale = 0.9 if (uiscale is bs.UIScale.SMALL or vr_mode) else 0.7 - color = (1, 1, 1, 1) if vr_mode else (0.5, 0.6, 0.5, 0.7) - self.version = bs.NodeActor( - bs.newnode( - 'text', - attrs={ - 'v_attach': 'bottom', - 'h_attach': 'right', - 'h_align': 'right', - 'flatness': 1.0, - 'vr_depth': -10, - 'shadow': 1.0 if vr_mode else 0.5, - 'color': color, - 'scale': scale, - 'position': (-260, 10) if vr_mode else (-10, 10), - 'text': text, - }, - ) - ) - if not app.classic.main_menu_did_initial_transition: - assert self.version.node - bs.animate(self.version.node, 'opacity', {2.3: 0, 3.0: 1.0}) - # Throw in test build info. self.beta_info = self.beta_info_2 = None - if env.test and not (env.demo or env.arcade): + if env.test: pos = (230, 35) self.beta_info = bs.NodeActor( bs.newnode( @@ -292,125 +204,20 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): self._update() # Hopefully this won't hitch but lets space these out anyway. - bui.add_clean_frame_callback(bs.WeakCall(self._start_preloads)) + bs.add_clean_frame_callback(bs.WeakCall(self._start_preloads)) random.seed() - if not (env.demo or env.arcade) and not app.ui_v1.use_toolbars: - self._news = NewsDisplay(self) + # Need to update this for toolbar mode; currenly doesn't fit. + if bool(False): + if not (env.demo or env.arcade): + self._news = NewsDisplay(self) self._attract_mode_timer = bs.Timer( 3.12, self._update_attract_mode, repeat=True ) - # Bring up the last place we were, or start at the main menu otherwise. - with bs.ContextRef.empty(): - from bauiv1lib import specialoffer - - assert bs.app.classic is not None - if bui.app.env.headless: - # UI stuff fails now in headless builds; avoid it. - pass - elif bool(False): - uicontroller = bs.app.ui_v1.controller - assert uicontroller is not None - uicontroller.show_main_menu() - else: - main_menu_location = bs.app.ui_v1.get_main_menu_location() - - # 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 - - bs.app.ui_v1.set_main_menu_window( - KioskWindow().get_root_widget(), - from_window=False, # Disable check here. - ) - # ..or in normal cases go back to the main menu - else: - if main_menu_location == 'Gather': - # pylint: disable=cyclic-import - from bauiv1lib.gather import GatherWindow - - bs.app.ui_v1.set_main_menu_window( - GatherWindow(transition=None).get_root_widget(), - from_window=False, # Disable check here. - ) - elif main_menu_location == 'Watch': - # pylint: disable=cyclic-import - from bauiv1lib.watch import WatchWindow - - bs.app.ui_v1.set_main_menu_window( - WatchWindow(transition=None).get_root_widget(), - from_window=False, # Disable check here. - ) - elif main_menu_location == 'Team Game Select': - # pylint: disable=cyclic-import - from bauiv1lib.playlist.browser import ( - PlaylistBrowserWindow, - ) - - bs.app.ui_v1.set_main_menu_window( - PlaylistBrowserWindow( - sessiontype=bs.DualTeamSession, transition=None - ).get_root_widget(), - from_window=False, # Disable check here. - ) - elif main_menu_location == 'Free-for-All Game Select': - # pylint: disable=cyclic-import - from bauiv1lib.playlist.browser import ( - PlaylistBrowserWindow, - ) - - bs.app.ui_v1.set_main_menu_window( - PlaylistBrowserWindow( - sessiontype=bs.FreeForAllSession, - transition=None, - ).get_root_widget(), - from_window=False, # Disable check here. - ) - elif main_menu_location == 'Coop Select': - # pylint: disable=cyclic-import - from bauiv1lib.coop.browser import CoopBrowserWindow - - bs.app.ui_v1.set_main_menu_window( - CoopBrowserWindow( - transition=None - ).get_root_widget(), - from_window=False, # Disable check here. - ) - elif main_menu_location == 'Benchmarks & Stress Tests': - # pylint: disable=cyclic-import - from bauiv1lib.debug import DebugWindow - - bs.app.ui_v1.set_main_menu_window( - DebugWindow(transition=None).get_root_widget(), - from_window=False, # Disable check here. - ) - else: - # pylint: disable=cyclic-import - from bauiv1lib.mainmenu import MainMenuWindow - - bs.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition=None).get_root_widget(), - from_window=False, # Disable check here. - ) - - # attempt to show any pending offers immediately. - # If that doesn't work, try again in a few seconds - # (we may not have heard back from the server) - # ..if that doesn't work they'll just have to wait - # until the next opportunity. - if not specialoffer.show_offer(): - - def try_again() -> None: - if not specialoffer.show_offer(): - # Try one last time.. - bui.apptimer(2.0, specialoffer.show_offer) - - bui.apptimer(2.0, try_again) + app.classic.invoke_main_menu_ui() app.classic.main_menu_did_initial_transition = True @@ -445,7 +252,7 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): y = 20 base_scale = 1.1 self._word_actors = [] - base_delay = 1.0 + base_delay = 0.8 delay = base_delay delay_inc = 0.02 @@ -628,6 +435,7 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): word: str, x: float, y: float, + *, scale: float = 1.0, delay: float = 0.0, vr_depth_offset: float = 0.0, @@ -676,9 +484,8 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): ) self._word_actors.append(word_obj) - # Add a bit of stop-motion-y jitter to the logo - # (unless we're in VR mode in which case its best to - # leave things still). + # Add a bit of stop-motion-y jitter to the logo (unless we're in + # VR mode in which case its best to leave things still). if not bs.app.env.vr: cmb: bs.Node | None cmb2: bs.Node | None @@ -757,13 +564,13 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): y: float, scale: float, delay: float, + *, custom_texture: str | None = None, jitter_scale: float = 1.0, rotate: float = 0.0, vr_depth_offset: float = 0.0, ) -> None: # pylint: disable=too-many-locals - # Temp easter goodness. if custom_texture is None: custom_texture = self._get_custom_logo_tex_name() self._custom_logo_tex_name = custom_texture @@ -776,60 +583,92 @@ class MainMenuActivity(bs.Activity[bs.Player, bs.Team]): if custom_texture is not None else bs.getmesh('logoTransparent') ) - logo = bs.NodeActor( - bs.newnode( - 'image', - attrs={ - 'texture': ltex, - 'mesh_opaque': mopaque, - 'mesh_transparent': mtrans, - 'vr_depth': -10 + vr_depth_offset, - 'rotate': rotate, - 'attach': 'center', - 'tilt_translate': 0.21, - 'absolute_scale': True, - }, - ) - ) + logo_attrs = { + 'position': (x, y), + 'texture': ltex, + 'mesh_opaque': mopaque, + 'mesh_transparent': mtrans, + 'vr_depth': -10 + vr_depth_offset, + 'rotate': rotate, + 'attach': 'center', + 'tilt_translate': 0.21, + 'absolute_scale': True, + } + if custom_texture is None: + logo_attrs['scale'] = (2000.0, 2000.0) + logo = bs.NodeActor(bs.newnode('image', attrs=logo_attrs)) self._logo_node = logo.node self._word_actors.append(logo) - # Add a bit of stop-motion-y jitter to the logo - # (unless we're in VR mode in which case its best to - # leave things still). + # Add a bit of stop-motion-y jitter to the logo (unless we're in + # VR mode in which case its best to leave things still). assert logo.node - if not bs.app.env.vr: + + def jitter() -> None: + if not bs.app.env.vr: + cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2}) + cmb.connectattr('output', logo.node, 'position') + keys = {} + time_v = 0.0 + + # Gen some random keys for that stop-motion-y look + for _i in range(10): + keys[time_v] = ( + x + (random.random() - 0.5) * 0.7 * jitter_scale + ) + time_v += random.random() * 0.1 + bs.animate(cmb, 'input0', keys, loop=True) + keys = {} + time_v = 0.0 + for _i in range(10): + keys[time_v * self._ts] = ( + y + (random.random() - 0.5) * 0.7 * jitter_scale + ) + time_v += random.random() * 0.1 + bs.animate(cmb, 'input1', keys, loop=True) + + # Do a fun spinny animation on the logo the first time in. + if ( + custom_texture is None + and bs.app.classic is not None + and not bs.app.classic.main_menu_did_initial_transition + ): + jitter() cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2}) - cmb.connectattr('output', logo.node, 'position') - keys = {} - time_v = 0.0 - # Gen some random keys for that stop-motion-y look - for _i in range(10): - keys[time_v] = x + (random.random() - 0.5) * 0.7 * jitter_scale - time_v += random.random() * 0.1 - bs.animate(cmb, 'input0', keys, loop=True) - keys = {} - time_v = 0.0 - for _i in range(10): - keys[time_v * self._ts] = ( - y + (random.random() - 0.5) * 0.7 * jitter_scale - ) - time_v += random.random() * 0.1 - bs.animate(cmb, 'input1', keys, loop=True) + delay = 0.0 + keys = { + delay: 5000.0 * scale, + delay + 0.4: 530.0 * scale, + delay + 0.45: 620.0 * scale, + delay + 0.5: 590.0 * scale, + delay + 0.55: 605.0 * scale, + delay + 0.6: 600.0 * scale, + } + bs.animate(cmb, 'input0', keys) + bs.animate(cmb, 'input1', keys) + cmb.connectattr('output', logo.node, 'scale') + + keys = { + delay: 100.0, + delay + 0.4: 370.0, + delay + 0.45: 357.0, + delay + 0.5: 360.0, + } + bs.animate(logo.node, 'rotate', keys) else: - logo.node.position = (x, y) + # For all other cases do a simple scale up animation. + jitter() + cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2}) - cmb = bs.newnode('combine', owner=logo.node, attrs={'size': 2}) - - keys = { - delay: 0.0, - delay + 0.1: 700.0 * scale, - delay + 0.2: 600.0 * scale, - } - bs.animate(cmb, 'input0', keys) - bs.animate(cmb, 'input1', keys) - cmb.connectattr('output', logo.node, 'scale') + keys = { + delay: 0.0, + delay + 0.1: 700.0 * scale, + delay + 0.2: 600.0 * scale, + } + bs.animate(cmb, 'input0', keys) + bs.animate(cmb, 'input1', keys) + cmb.connectattr('output', logo.node, 'scale') def _start_preloads(self) -> None: # FIXME: The func that calls us back doesn't save/restore state @@ -879,8 +718,8 @@ class NewsDisplay: self._used_phrases: list[str] = [] self._phrase_change_timer: bs.Timer | None = None - # If we're signed in, fetch news immediately. - # Otherwise wait until we are signed in. + # If we're signed in, fetch news immediately. Otherwise wait + # until we are signed in. self._fetch_timer: bs.Timer | None = bs.Timer( 1.0, bs.WeakCall(self._try_fetching_news), repeat=True ) @@ -913,8 +752,8 @@ class NewsDisplay: app = bs.app assert app.classic is not None - # If our news is way out of date, lets re-request it; - # otherwise, rotate our phrase. + # If our news is way out of date, lets re-request it; otherwise, + # rotate our phrase. assert app.classic.main_menu_last_news_fetch_time is not None if time.time() - app.classic.main_menu_last_news_fetch_time > 600.0: self._fetch_news() @@ -981,17 +820,16 @@ class NewsDisplay: self._text.node.text = val def _got_news(self, news: str) -> None: - # Run this stuff in the context of our activity since we - # need to make nodes and stuff.. should fix the serverget - # call so it. + # Run this stuff in the context of our activity since we need to + # make nodes and stuff.. should fix the serverget call so it. activity = self._activity() if activity is None or activity.expired: return with activity.context: self._phrases.clear() - # Show upcoming achievements in non-vr versions - # (currently too hard to read in vr). + # Show upcoming achievements in non-vr versions (currently + # too hard to read in vr). self._used_phrases = (['__ACH__'] if not bs.app.env.vr else []) + [ s for s in news.split('
\n') if s != '' ] diff --git a/dist/ba_data/python/bascenev1lib/maps.py b/dist/ba_data/python/bascenev1lib/maps.py index 04f5b36..6646b72 100644 --- a/dist/ba_data/python/bascenev1lib/maps.py +++ b/dist/ba_data/python/bascenev1lib/maps.py @@ -15,6 +15,30 @@ if TYPE_CHECKING: from typing import Any +def register_all_maps() -> None: + """Registering all maps.""" + for maptype in [ + HockeyStadium, + FootballStadium, + Bridgit, + BigG, + Roundabout, + MonkeyFace, + ZigZag, + ThePad, + DoomShroom, + LakeFrigid, + TipTop, + CragCastle, + TowerD, + HappyThoughts, + StepRightUp, + Courtyard, + Rampage, + ]: + bs.register_map(maptype) + + class HockeyStadium(bs.Map): """Stadium map used for ice hockey games.""" diff --git a/dist/ba_data/python/bascenev1lib/tutorial.py b/dist/ba_data/python/bascenev1lib/tutorial.py index 541e611..d9b6e29 100644 --- a/dist/ba_data/python/bascenev1lib/tutorial.py +++ b/dist/ba_data/python/bascenev1lib/tutorial.py @@ -515,6 +515,7 @@ class TutorialActivity(bs.Activity[Player, Team]): self, num: int, position: Sequence[float], + *, color: Sequence[float] = (1.0, 1.0, 1.0), make_current: bool = False, relative_to: int | None = None, @@ -577,6 +578,7 @@ class TutorialActivity(bs.Activity[Player, Team]): self, num: int, position: Sequence[float], + *, color: Sequence[float] = (1.0, 1.0, 1.0), make_current: bool = False, relative_to: int | None = None, diff --git a/dist/ba_data/python/batemplatefs/__init__.py b/dist/ba_data/python/batemplatefs/__init__.py index 58ad2cd..ed09764 100644 --- a/dist/ba_data/python/batemplatefs/__init__.py +++ b/dist/ba_data/python/batemplatefs/__init__.py @@ -2,14 +2,14 @@ # """Ballistica Template Feature Set - just an example.""" -# ba_meta require api 8 +# ba_meta require api 9 # Package up various private bits (including stuff from our native # module) into a nice clean public API. from _batemplatefs import hello_again_world -from batemplatefs._subsystem import TemplateFsSubsystem +from batemplatefs._appsubsystem import TemplateFsAppSubsystem __all__ = [ - 'TemplateFsSubsystem', + 'TemplateFsAppSubsystem', 'hello_again_world', ] diff --git a/dist/ba_data/python/batemplatefs/_subsystem.py b/dist/ba_data/python/batemplatefs/_appsubsystem.py similarity index 61% rename from dist/ba_data/python/batemplatefs/_subsystem.py rename to dist/ba_data/python/batemplatefs/_appsubsystem.py index 2645fbf..2bc9fe3 100644 --- a/dist/ba_data/python/batemplatefs/_subsystem.py +++ b/dist/ba_data/python/batemplatefs/_appsubsystem.py @@ -1,6 +1,6 @@ # Released under the MIT License. See LICENSE for details. # -"""Provides the TemplateFs subsystem.""" +"""Provides the TemplateFs App-Subsystem.""" from __future__ import annotations from typing import TYPE_CHECKING @@ -9,11 +9,11 @@ if TYPE_CHECKING: pass -class TemplateFsSubsystem: +class TemplateFsAppSubsystem: """Subsystem for TemplateFs functionality in the app. - The single shared instance of this app can be accessed at - babase.app.templatefs. Note that it is possible for babase.app.templatefs + The single shared instance of this class can be accessed at + ba*.app.templatefs. Note that it is possible for ba*.app.templatefs to be None if the TemplateFs feature-set is not enabled, and code should handle that case gracefully. """ diff --git a/dist/ba_data/python/bauiv1/__init__.py b/dist/ba_data/python/bauiv1/__init__.py index 2e3144b..49a9503 100644 --- a/dist/ba_data/python/bauiv1/__init__.py +++ b/dist/ba_data/python/bauiv1/__init__.py @@ -2,7 +2,7 @@ # """Ballistica user interface api version 1""" -# ba_meta require api 8 +# ba_meta require api 9 # The stuff we expose here at the top level is our 'public' api. # It should only be imported by code outside of this package or @@ -19,6 +19,7 @@ import logging from efro.util import set_canonical_module_names from babase import ( add_clean_frame_callback, + allows_ticket_sales, app, AppIntent, AppIntentDefault, @@ -45,6 +46,7 @@ from babase import ( displaytimer, DisplayTimer, do_once, + existing, fade_screen, get_display_resolution, get_input_idle_time, @@ -56,9 +58,12 @@ from babase import ( get_string_height, get_string_width, get_type_name, + get_virtual_safe_area_size, + get_virtual_screen_size, getclass, have_permission, in_logic_thread, + in_main_menu, increment_analytics_count, is_browser_likely_available, is_xcode_build, @@ -90,9 +95,11 @@ from babase import ( SpecialChar, supports_max_fps, supports_vsync, + supports_unicode_display, timestring, UIScale, unlock_all_input, + utc_now_cloud, WeakCall, workspaces_in_use, ) @@ -109,12 +116,13 @@ from _bauiv1 import ( gettexture, hscrollwidget, imagewidget, - is_party_icon_visible, Mesh, + root_ui_pause_updates, + root_ui_resume_updates, rowwidget, scrollwidget, - set_party_icon_always_visible, set_party_window_open, + spinnerwidget, Sound, Texture, textwidget, @@ -123,11 +131,18 @@ from _bauiv1 import ( widget, ) from bauiv1._keyboard import Keyboard -from bauiv1._uitypes import Window, uicleanupcheck -from bauiv1._subsystem import UIV1Subsystem +from bauiv1._uitypes import ( + Window, + MainWindowState, + BasicMainWindowState, + uicleanupcheck, + MainWindow, +) +from bauiv1._appsubsystem import UIV1AppSubsystem __all__ = [ 'add_clean_frame_callback', + 'allows_ticket_sales', 'app', 'AppIntent', 'AppIntentDefault', @@ -140,6 +155,7 @@ __all__ = [ 'AppTime', 'apptimer', 'AppTimer', + 'BasicMainWindowState', 'buttonwidget', 'Call', 'fullscreen_control_available', @@ -159,6 +175,7 @@ __all__ = [ 'displaytimer', 'DisplayTimer', 'do_once', + 'existing', 'fade_screen', 'get_display_resolution', 'get_input_idle_time', @@ -172,6 +189,8 @@ __all__ = [ 'get_string_height', 'get_string_width', 'get_type_name', + 'get_virtual_safe_area_size', + 'get_virtual_screen_size', 'getclass', 'getmesh', 'getsound', @@ -180,15 +199,17 @@ __all__ = [ 'hscrollwidget', 'imagewidget', 'in_logic_thread', + 'in_main_menu', 'increment_analytics_count', 'is_browser_likely_available', - 'is_party_icon_visible', 'is_xcode_build', 'Keyboard', 'lock_all_input', 'LoginAdapter', 'LoginInfo', 'Lstr', + 'MainWindow', + 'MainWindowState', 'Mesh', 'native_review_request', 'native_review_request_supported', @@ -206,27 +227,31 @@ __all__ = [ 'quit', 'QuitType', 'request_permission', + 'root_ui_pause_updates', + 'root_ui_resume_updates', 'rowwidget', 'safecolor', 'screenmessage', 'scrollwidget', 'set_analytics_screen', 'set_low_level_config_value', - 'set_party_icon_always_visible', 'set_party_window_open', 'set_ui_input_device', 'Sound', 'SpecialChar', + 'spinnerwidget', 'supports_max_fps', 'supports_vsync', + 'supports_unicode_display', 'Texture', 'textwidget', 'timestring', 'uibounds', 'uicleanupcheck', 'UIScale', - 'UIV1Subsystem', + 'UIV1AppSubsystem', 'unlock_all_input', + 'utc_now_cloud', 'WeakCall', 'widget', 'Widget', diff --git a/dist/ba_data/python/bauiv1/_appsubsystem.py b/dist/ba_data/python/bauiv1/_appsubsystem.py new file mode 100644 index 0000000..8e7e743 --- /dev/null +++ b/dist/ba_data/python/bauiv1/_appsubsystem.py @@ -0,0 +1,469 @@ +# Released under the MIT License. See LICENSE for details. +# +"""User interface related functionality.""" + +from __future__ import annotations + +import time +import logging +import inspect +import weakref +import warnings +from enum import Enum +from typing import TYPE_CHECKING, override + +from efro.util import empty_weakref +import babase + +import _bauiv1 + +if TYPE_CHECKING: + from typing import Any, Callable + + from bauiv1._uitypes import ( + UICleanupCheck, + Window, + MainWindow, + MainWindowState, + ) + import bauiv1 + + +class UIV1AppSubsystem(babase.AppSubsystem): + """Consolidated UI functionality for the app. + + Category: **App Classes** + + To use this class, access the single instance of it at 'ba.app.ui'. + """ + + class RootUIElement(Enum): + """Stuff provided by the root ui.""" + + MENU_BUTTON = 'menu_button' + SQUAD_BUTTON = 'squad_button' + ACCOUNT_BUTTON = 'account_button' + SETTINGS_BUTTON = 'settings_button' + INBOX_BUTTON = 'inbox_button' + STORE_BUTTON = 'store_button' + INVENTORY_BUTTON = 'inventory_button' + ACHIEVEMENTS_BUTTON = 'achievements_button' + GET_TOKENS_BUTTON = 'get_tokens_button' + TICKETS_METER = 'tickets_meter' + TOKENS_METER = 'tokens_meter' + TROPHY_METER = 'trophy_meter' + LEVEL_METER = 'level_meter' + CHEST_SLOT_0 = 'chest_slot_0' + CHEST_SLOT_1 = 'chest_slot_1' + CHEST_SLOT_2 = 'chest_slot_2' + CHEST_SLOT_3 = 'chest_slot_3' + + def __init__(self) -> None: + from bauiv1._uitypes import MainWindow + + super().__init__() + + # We hold only a weak ref to the current main Window; we want it + # to be able to disappear on its own. That being said, we do + # expect MainWindows to keep themselves alive until replaced by + # another MainWindow and we complain if they don't. + self._main_window = empty_weakref(MainWindow) + self._main_window_widget: bauiv1.Widget | None = None + + self.quit_window: bauiv1.Widget | None = None + + # For storing arbitrary class-level state data for Windows or + # other UI related classes. + self.window_states: dict[type, Any] = {} + + self._uiscale: babase.UIScale + self._update_ui_scale() + + self.cleanupchecks: list[UICleanupCheck] = [] + self.upkeeptimer: babase.AppTimer | None = None + + self.title_color = (0.72, 0.7, 0.75) + self.heading_color = (0.72, 0.7, 0.75) + self.infotextcolor = (0.7, 0.9, 0.7) + + self._last_win_recreate_size: tuple[float, float] | None = None + self._last_screen_size_win_recreate_time: float | None = None + self._screen_size_win_recreate_timer: babase.AppTimer | None = None + + # Elements in our root UI will call anything here when + # activated. + self.root_ui_calls: dict[ + UIV1AppSubsystem.RootUIElement, Callable[[], None] + ] = {} + + def _update_ui_scale(self) -> None: + uiscalestr = babase.get_ui_scale() + if uiscalestr == 'large': + self._uiscale = babase.UIScale.LARGE + elif uiscalestr == 'medium': + self._uiscale = babase.UIScale.MEDIUM + elif uiscalestr == 'small': + self._uiscale = babase.UIScale.SMALL + else: + logging.error("Invalid UIScale '%s'.", uiscalestr) + self._uiscale = babase.UIScale.MEDIUM + + @property + def available(self) -> bool: + """Can uiv1 currently be used? + + Code that may run in headless mode, before the UI has been spun up, + while other ui systems are active, etc. can check this to avoid + likely erroring. + """ + return _bauiv1.is_available() + + @override + def reset(self) -> None: + from bauiv1._uitypes import MainWindow + + self.root_ui_calls.clear() + self._main_window = empty_weakref(MainWindow) + self._main_window_widget = None + + @property + def uiscale(self) -> babase.UIScale: + """Current ui scale for the app.""" + return self._uiscale + + @override + def on_app_loading(self) -> None: + from bauiv1._uitypes import ui_upkeep + + # IMPORTANT: If tweaking UI stuff, make sure it behaves for + # small, medium, and large UI modes. (doesn't run off screen, + # etc). The overrides below can be used to test with different + # sizes. Generally small is used on phones, medium is used on + # tablets/tvs, and large is on desktop computers or perhaps + # large tablets. When possible, run in windowed mode and resize + # the window to assure this holds true at all aspect ratios. + + # UPDATE: A better way to test this is now by setting the + # environment variable BA_UI_SCALE to "small", "medium", or + # "large". This will affect system UIs not covered by the values + # below such as screen-messages. The below values remain + # functional, however, for cases such as Android where + # environment variables can't be set easily. + + if bool(False): # force-test ui scale + self._uiscale = babase.UIScale.SMALL + with babase.ContextRef.empty(): + babase.pushcall( + lambda: babase.screenmessage( + f'FORCING UISCALE {self._uiscale.name} FOR TESTING', + color=(1, 0, 1), + log=True, + ) + ) + + # Kick off our periodic UI upkeep. + + # FIXME: Can probably kill this if we do immediate UI death + # checks. + self.upkeeptimer = babase.AppTimer(2.6543, ui_upkeep, repeat=True) + + def get_main_window(self) -> bauiv1.MainWindow | None: + """Return main window, if any.""" + return self._main_window() + + def set_main_window( + self, + window: bauiv1.MainWindow, + *, + from_window: bauiv1.MainWindow | None | bool = True, + is_back: bool = False, + is_top_level: bool = False, + is_auxiliary: bool = False, + back_state: MainWindowState | None = None, + suppress_warning: bool = False, + ) -> None: + """Set the current 'main' window. + + Generally this should not be called directly; The high level + MainWindow methods main_window_replace() and main_window_back() + should be used whenever possible to implement navigation. + + The caller is responsible for cleaning up any previous main + window. + """ + # pylint: disable=too-many-locals + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + from bauiv1._uitypes import MainWindow + + # Encourage migration to the new higher level nav calls. + if not suppress_warning: + warnings.warn( + 'set_main_window() should usually not be called directly;' + ' use the main_window_replace() or main_window_back()' + ' methods on MainWindow objects for navigation instead.' + ' If you truly need to use set_main_window(),' + ' pass suppress_warning=True to silence this warning.', + DeprecationWarning, + stacklevel=2, + ) + + # We used to accept Widgets but now want MainWindows. + if not isinstance(window, MainWindow): + raise RuntimeError( + f'set_main_window() now takes a MainWindow as its "window" arg.' + f' You passed a {type(window)}.', + ) + window_weakref = weakref.ref(window) + window_widget = window.get_root_widget() + + if not isinstance(from_window, MainWindow): + if from_window is not None and not isinstance(from_window, bool): + raise RuntimeError( + f'set_main_window() now takes a MainWindow or bool or None' + f'as its "from_window" arg.' + f' You passed a {type(from_window)}.', + ) + + existing = self._main_window() + + # If they passed a back-state, make sure it is fully filled out. + if back_state is not None: + if ( + back_state.is_top_level is None + or back_state.is_auxiliary is None + or back_state.window_type is None + ): + raise RuntimeError( + 'Provided back_state is incomplete.' + ' Make sure to only pass fully-filled-out MainWindowStates.' + ) + + # If a top-level main-window is being set, complain if there already + # is a main-window. + if is_top_level: + if existing: + logging.warning( + 'set_main_window() called with top-level window %s' + ' but found existing main-window %s.', + window, + existing, + ) + else: + # In other cases, sanity-check that the window asking for + # this switch is the one we're switching away from. + try: + if isinstance(from_window, bool): + # For default val True we warn that the arg wasn't + # passed. False can be explicitly passed to disable + # this check. + if from_window is True: + caller_frame = inspect.stack()[1] + caller_filename = caller_frame.filename + caller_line_number = caller_frame.lineno + logging.warning( + 'set_main_window() should be passed a' + " 'from_window' value to help ensure proper" + ' UI behavior (%s line %i).', + caller_filename, + caller_line_number, + ) + else: + # For everything else, warn if what they passed + # wasn't the previous main menu widget. + if from_window is not existing: + caller_frame = inspect.stack()[1] + caller_filename = caller_frame.filename + caller_line_number = caller_frame.lineno + logging.warning( + "set_main_window() was passed 'from_window' %s" + ' but existing main-menu-window is %s.' + ' (%s line %i).', + from_window, + existing, + caller_filename, + caller_line_number, + ) + except Exception: + # Prevent any bugs in these checks from causing problems. + logging.exception('Error checking from_window') + + if is_back: + # These values should only be passed for forward navigation. + assert not is_top_level + assert not is_auxiliary + # Make sure back state is complete. + assert back_state is not None + assert back_state.is_top_level is not None + assert back_state.is_auxiliary is not None + assert back_state.window_type is type(window) + window.main_window_back_state = back_state.parent + window.main_window_is_top_level = back_state.is_top_level + window.main_window_is_auxiliary = back_state.is_auxiliary + else: + # Store if the window is top-level so we won't complain later if + # we go back from it and there's nowhere to go to. + window.main_window_is_top_level = is_top_level + + window.main_window_is_auxiliary = is_auxiliary + + # When navigating forward, generate a back-window-state from + # the outgoing window. + if is_top_level: + # Top level windows don't have or expect anywhere to + # go back to. + window.main_window_back_state = None + elif back_state is not None: + window.main_window_back_state = back_state + else: + oldwin = self._main_window() + if oldwin is None: + # We currenty only hold weak refs to windows so that + # they are free to die on their own, but we expect + # the main menu window to keep itself alive as long + # as its the main one. Holler if that seems to not + # be happening. + logging.warning( + 'set_main_window: No old MainWindow found' + ' and is_top_level is False;' + ' this should not happen.' + ) + window.main_window_back_state = None + else: + window.main_window_back_state = self.save_main_window_state( + oldwin + ) + + self._main_window = window_weakref + self._main_window_widget = window_widget + + def has_main_window(self) -> bool: + """Return whether a main menu window is present.""" + return bool(self._main_window_widget) + + def clear_main_window(self, transition: str | None = None) -> None: + """Clear any existing main window.""" + from bauiv1._uitypes import MainWindow + + main_window = self._main_window() + if main_window: + main_window.main_window_close(transition=transition) + else: + # Fallback; if we have a widget but no window, nuke the widget. + if self._main_window_widget: + logging.error( + 'Have _main_window_widget but no main_window' + ' on clear_main_window; unexpected.' + ) + self._main_window_widget.delete() + + self._main_window = empty_weakref(MainWindow) + self._main_window_widget = None + + def save_main_window_state(self, window: MainWindow) -> MainWindowState: + """Fully initialize a window-state from a window. + + Use this to get a complete state for later restoration purposes. + Calling the window's get_main_window_state() directly is + insufficient. + """ + winstate = window.get_main_window_state() + + # Store some common window stuff on its state. + winstate.parent = window.main_window_back_state + winstate.is_top_level = window.main_window_is_top_level + winstate.is_auxiliary = window.main_window_is_auxiliary + winstate.window_type = type(window) + + return winstate + + def restore_main_window_state(self, state: MainWindowState) -> None: + """Restore UI to a saved state.""" + existing = self.get_main_window() + if existing is not None: + raise RuntimeError('There is already a MainWindow.') + + # Valid states should have a value here. + assert state.is_top_level is not None + assert state.is_auxiliary is not None + assert state.window_type is not None + + win = state.create_window(transition=None) + self.set_main_window( + win, + from_window=False, # disable check + is_top_level=state.is_top_level, + is_auxiliary=state.is_auxiliary, + back_state=state.parent, + suppress_warning=True, + ) + + @override + def on_ui_scale_change(self) -> None: + # Update our stored UIScale. + self._update_ui_scale() + + # Update native bits (allow root widget to rebuild itself/etc.) + _bauiv1.on_ui_scale_change() + + # Lastly, if we have a main window, recreate it to pick up the + # new UIScale/etc. + mainwindow = self.get_main_window() + if mainwindow is not None: + winstate = self.save_main_window_state(mainwindow) + self.clear_main_window(transition='instant') + self.restore_main_window_state(winstate) + + # Store the size we created this for to avoid redundant + # future recreates. + self._last_win_recreate_size = babase.get_virtual_screen_size() + + @override + def on_screen_size_change(self) -> None: + + # Recreating a MainWindow is a kinda heavy thing and it doesn't + # seem like we should be doing it at 120hz during a live window + # resize, so let's limit the max rate we do it. + now = time.monotonic() + + # 4 refreshes per second seems reasonable. + interval = 0.25 + + # If there is a timer set already, do nothing. + if self._screen_size_win_recreate_timer is not None: + return + + # Ok; there's no timer. Schedule one. + till_update = ( + 0.0 + if self._last_screen_size_win_recreate_time is None + else max( + 0.0, self._last_screen_size_win_recreate_time + interval - now + ) + ) + self._screen_size_win_recreate_timer = babase.AppTimer( + till_update, self._do_screen_size_win_recreate + ) + + def _do_screen_size_win_recreate(self) -> None: + self._last_screen_size_win_recreate_time = time.monotonic() + self._screen_size_win_recreate_timer = None + + # Avoid recreating if we're already at this size. This prevents + # a redundant recreate when ui scale changes. + virtual_screen_size = babase.get_virtual_screen_size() + if virtual_screen_size == self._last_win_recreate_size: + return + + mainwindow = self.get_main_window() + if ( + mainwindow is not None + and mainwindow.refreshes_on_screen_size_changes + ): + winstate = self.save_main_window_state(mainwindow) + self.clear_main_window(transition='instant') + self.restore_main_window_state(winstate) + + # Store the size we created this for to avoid redundant + # future recreates. + self._last_win_recreate_size = virtual_screen_size diff --git a/dist/ba_data/python/bauiv1/_hooks.py b/dist/ba_data/python/bauiv1/_hooks.py index b65c139..644ecd3 100644 --- a/dist/ba_data/python/bauiv1/_hooks.py +++ b/dist/ba_data/python/bauiv1/_hooks.py @@ -15,49 +15,130 @@ if TYPE_CHECKING: from typing import Sequence import babase - - -def ticket_icon_press() -> None: - from babase import app - - if app.classic is None: - logging.exception('Classic not present.') - return - - app.classic.ticket_icon_press() - - -def trophy_icon_press() -> None: - print('TROPHY ICON PRESSED') - - -def level_icon_press() -> None: - print('LEVEL ICON PRESSED') - - -def coin_icon_press() -> None: - print('COIN ICON PRESSED') + import bauiv1 def empty_call() -> None: pass -def back_button_press() -> None: - _bauiv1.back_press() +def _root_ui_button_press( + rootuitype: bauiv1.UIV1AppSubsystem.RootUIElement, +) -> None: + import babase + + ui = babase.app.ui_v1 + call = ui.root_ui_calls.get(rootuitype) + if call is not None: + call() -def friends_button_press() -> None: - print('FRIEND BUTTON PRESSED!') +def root_ui_account_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.ACCOUNT_BUTTON) -def party_icon_activate(origin: Sequence[float]) -> None: - from babase import app +def root_ui_inbox_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem - if app.classic is not None: - app.classic.party_icon_activate(origin) - else: - logging.warning('party_icon_activate: no classic.') + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.INBOX_BUTTON) + + +def root_ui_settings_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.SETTINGS_BUTTON) + + +def root_ui_achievements_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.ACHIEVEMENTS_BUTTON) + + +def root_ui_store_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.STORE_BUTTON) + + +def root_ui_chest_slot_0_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.CHEST_SLOT_0) + + +def root_ui_chest_slot_1_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.CHEST_SLOT_1) + + +def root_ui_chest_slot_2_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.CHEST_SLOT_2) + + +def root_ui_chest_slot_3_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.CHEST_SLOT_3) + + +def root_ui_inventory_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.INVENTORY_BUTTON) + + +def root_ui_ticket_icon_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.TICKETS_METER) + + +def root_ui_get_tokens_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.GET_TOKENS_BUTTON) + + +def root_ui_tokens_meter_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.TOKENS_METER) + + +def root_ui_trophy_meter_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.TROPHY_METER) + + +def root_ui_level_icon_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.LEVEL_METER) + + +def root_ui_menu_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.MENU_BUTTON) + + +def root_ui_back_button_press() -> None: + # Native layer handles this directly. (technically we could wire + # this up to not even come through Python). + _bauiv1.root_ui_back_press() + + +def root_ui_squad_button_press() -> None: + from bauiv1._appsubsystem import UIV1AppSubsystem + + _root_ui_button_press(UIV1AppSubsystem.RootUIElement.SQUAD_BUTTON) def on_button_press_x() ->None: diff --git a/dist/ba_data/python/bauiv1/_subsystem.py b/dist/ba_data/python/bauiv1/_subsystem.py deleted file mode 100644 index c9bd7f5..0000000 --- a/dist/ba_data/python/bauiv1/_subsystem.py +++ /dev/null @@ -1,250 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""User interface related functionality.""" - -from __future__ import annotations - -import logging -import inspect -from typing import TYPE_CHECKING, override - -import babase - -import _bauiv1 - -if TYPE_CHECKING: - from typing import Any, Callable - - from bauiv1._uitypes import UICleanupCheck, UIController - import bauiv1 - - -class UIV1Subsystem(babase.AppSubsystem): - """Consolidated UI functionality for the app. - - Category: **App Classes** - - To use this class, access the single instance of it at 'ba.app.ui'. - """ - - def __init__(self) -> None: - super().__init__() - env = babase.env() - - self.controller: UIController | None = None - - self._main_menu_window: bauiv1.Widget | None = None - self._main_menu_location: str | None = None - self.quit_window: bauiv1.Widget | None = None - - # From classic. - self.main_menu_resume_callbacks: list = [] # Can probably go away. - - self._uiscale: babase.UIScale - - interfacetype = babase.app.config.get('UI Scale', env['ui_scale']) - if interfacetype == 'auto': - interfacetype = env['ui_scale'] - - if interfacetype == 'large': - self._uiscale = babase.UIScale.LARGE - elif interfacetype == 'medium': - self._uiscale = babase.UIScale.MEDIUM - elif interfacetype == 'small': - self._uiscale = babase.UIScale.SMALL - else: - raise RuntimeError(f'Invalid UIScale value: {interfacetype}') - - self.window_states: dict[type, Any] = {} # FIXME: Kill this. - self.main_menu_selection: str | None = None # FIXME: Kill this. - self.have_party_queue_window = False - self.cleanupchecks: list[UICleanupCheck] = [] - self.upkeeptimer: babase.AppTimer | None = None - self.use_toolbars = _bauiv1.toolbar_test() - - self.title_color = (0.72, 0.7, 0.75) - self.heading_color = (0.72, 0.7, 0.75) - self.infotextcolor = (0.7, 0.9, 0.7) - - # Switch our overall game selection UI flow between Play and - # Private-party playlist selection modes; should do this in - # a more elegant way once we revamp high level UI stuff a bit. - self.selecting_private_party_playlist: bool = False - - @property - def available(self) -> bool: - """Can uiv1 currently be used? - - Code that may run in headless mode, before the UI has been spun up, - while other ui systems are active, etc. can check this to avoid - likely erroring. - """ - return _bauiv1.is_available() - - @property - def uiscale(self) -> babase.UIScale: - """Current ui scale for the app.""" - return self._uiscale - - @override - def on_app_loading(self) -> None: - from bauiv1._uitypes import UIController, ui_upkeep - - # IMPORTANT: If tweaking UI stuff, make sure it behaves for small, - # medium, and large UI modes. (doesn't run off screen, etc). - # The overrides below can be used to test with different sizes. - # Generally small is used on phones, medium is used on tablets/tvs, - # and large is on desktop computers or perhaps large tablets. When - # possible, run in windowed mode and resize the window to assure - # this holds true at all aspect ratios. - - # UPDATE: A better way to test this is now by setting the environment - # variable BA_UI_SCALE to "small", "medium", or "large". - # This will affect system UIs not covered by the values below such - # as screen-messages. The below values remain functional, however, - # for cases such as Android where environment variables can't be set - # easily. - - if bool(False): # force-test ui scale - self._uiscale = babase.UIScale.SMALL - with babase.ContextRef.empty(): - babase.pushcall( - lambda: babase.screenmessage( - f'FORCING UISCALE {self._uiscale.name} FOR TESTING', - color=(1, 0, 1), - log=True, - ) - ) - - self.controller = UIController() - - # Kick off our periodic UI upkeep. - # FIXME: Can probably kill this if we do immediate UI death checks. - self.upkeeptimer = babase.AppTimer(2.6543, ui_upkeep, repeat=True) - - def set_main_menu_window( - self, - window: bauiv1.Widget, - from_window: bauiv1.Widget | None | bool = True, - ) -> None: - """Set the current 'main' window, replacing any existing. - - If 'from_window' is passed as a bauiv1.Widget or None, a warning - will be issued if it that value does not match the current main - window. This can help clean up flawed code that can lead to bad - UI states. A value of False will disable the check. - """ - - existing = self._main_menu_window - - try: - if isinstance(from_window, bool): - # For default val True we warn that the arg wasn't - # passed. False can be explicitly passed to disable this - # check. - if from_window is True: - caller_frame = inspect.stack()[1] - caller_filename = caller_frame.filename - caller_line_number = caller_frame.lineno - logging.warning( - 'set_main_menu_window() should be passed a' - " 'from_window' value to help ensure proper UI behavior" - ' (%s line %i).', - caller_filename, - caller_line_number, - ) - else: - # For everything else, warn if what they passed wasn't - # the previous main menu widget. - if from_window is not existing: - caller_frame = inspect.stack()[1] - caller_filename = caller_frame.filename - caller_line_number = caller_frame.lineno - logging.warning( - "set_main_menu_window() was passed 'from_window' %s" - ' but existing main-menu-window is %s. (%s line %i).', - from_window, - existing, - caller_filename, - caller_line_number, - ) - except Exception: - # Prevent any bugs in these checks from causing problems. - logging.exception('Error checking from_window') - - # Once the above code leads to us fixing all leftover window bugs - # at the source, we can kill the code below. - - # Let's grab the location where we were called from to report - # if we have to force-kill the existing window (which normally - # should not happen). - frameline = None - try: - frame = inspect.currentframe() - if frame is not None: - frame = frame.f_back - if frame is not None: - frameinfo = inspect.getframeinfo(frame) - frameline = f'{frameinfo.filename} {frameinfo.lineno}' - except Exception: - logging.exception('Error calcing line for set_main_menu_window') - - # With our legacy main-menu system, the caller is responsible for - # clearing out the old main menu window when assigning the new. - # However there are corner cases where that doesn't happen and we get - # old windows stuck under the new main one. So let's guard against - # that. However, we can't simply delete the existing main window when - # a new one is assigned because the user may transition the old out - # *after* the assignment. Sigh. So, as a happy medium, let's check in - # on the old after a short bit of time and kill it if its still alive. - # That will be a bit ugly on screen but at least should un-break - # things. - def _delay_kill() -> None: - import time - - if existing: - print( - f'Killing old main_menu_window' - f' when called at: {frameline} t={time.time():.3f}' - ) - existing.delete() - - babase.apptimer(1.0, _delay_kill) - self._main_menu_window = window - - def clear_main_menu_window(self, transition: str | None = None) -> None: - """Clear any existing 'main' window with the provided transition.""" - assert transition is None or not transition.endswith('_in') - if self._main_menu_window: - if ( - transition is not None - and not self._main_menu_window.transitioning_out - ): - _bauiv1.containerwidget( - edit=self._main_menu_window, transition=transition - ) - else: - self._main_menu_window.delete() - self._main_menu_window = None - - def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: - """(internal)""" - - # If there's no main menu up, just call immediately. - if not self.has_main_menu_window(): - with babase.ContextRef.empty(): - call() - else: - self.main_menu_resume_callbacks.append(call) - - def has_main_menu_window(self) -> bool: - """Return whether a main menu window is present.""" - return bool(self._main_menu_window) - - def set_main_menu_location(self, location: str) -> None: - """Set the location represented by the current main menu window.""" - self._main_menu_location = location - - def get_main_menu_location(self) -> str | None: - """Return the current named main menu location, if any.""" - return self._main_menu_location diff --git a/dist/ba_data/python/bauiv1/_uitypes.py b/dist/ba_data/python/bauiv1/_uitypes.py index 6431a55..3dd8d89 100644 --- a/dist/ba_data/python/bauiv1/_uitypes.py +++ b/dist/ba_data/python/bauiv1/_uitypes.py @@ -6,6 +6,7 @@ from __future__ import annotations import os import weakref +import logging from dataclasses import dataclass from typing import TYPE_CHECKING, override @@ -14,7 +15,7 @@ import babase import _bauiv1 if TYPE_CHECKING: - from typing import Any, Type + from typing import Any, Type, Literal, Callable import bauiv1 @@ -27,6 +28,9 @@ class Window: """A basic window. Category: User Interface Classes + + Essentially wraps a ContainerWidget with some higher level + functionality. """ def __init__(self, root_widget: bauiv1.Widget, cleanupcheck: bool = True): @@ -41,6 +45,253 @@ class Window: return self._root_widget +class MainWindow(Window): + """A special type of window that can be set as 'main'. + + The UI system has at most one main window at any given time. + MainWindows support high level functionality such as saving and + restoring states, allowing them to be automatically recreated when + navigating back from other locations or when something like ui-scale + changes. + """ + + def __init__( + self, + root_widget: bauiv1.Widget, + *, + transition: str | None, + origin_widget: bauiv1.Widget | None, + cleanupcheck: bool = True, + refresh_on_screen_size_changes: bool = False, + ): + """Create a MainWindow given a root widget and transition info. + + Automatically handles in and out transitions on the provided widget, + so there is no need to set transitions when creating it. + """ + # A back-state supplied by the ui system. + self.main_window_back_state: MainWindowState | None = None + + self.main_window_is_top_level: bool = False + + # Windows that size tailor themselves to exact screen dimensions + # can pass True for this. Generally this only applies to small + # ui scale and at larger scales windows simply fit in the + # virtual safe area. + self.refreshes_on_screen_size_changes = refresh_on_screen_size_changes + + # Windows can be flagged as auxiliary when not related to the + # main UI task at hand. UI code may choose to handle auxiliary + # windows in special ways, such as by implicitly replacing + # existing auxiliary windows with new ones instead of keeping + # old ones as back targets. + self.main_window_is_auxiliary: bool = False + + self._main_window_transition = transition + self._main_window_origin_widget = origin_widget + super().__init__(root_widget, cleanupcheck) + + scale_origin: tuple[float, float] | None + if origin_widget is not None: + self._main_window_transition_out = 'out_scale' + scale_origin = origin_widget.get_screen_space_center() + transition = 'in_scale' + else: + self._main_window_transition_out = 'out_right' + scale_origin = None + _bauiv1.containerwidget( + edit=root_widget, + transition=transition, + scale_origin_stack_offset=scale_origin, + ) + + def main_window_close(self, transition: str | None = None) -> None: + """Get window transitioning out if still alive.""" + + # no-op if our underlying widget is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + + # Transition ourself out. + try: + self.on_main_window_close() + except Exception: + logging.exception('Error in on_main_window_close() for %s.', self) + + # Note: normally transition of None means instant, but we use + # that to mean 'do the default' so we support a special + # 'instant' string.. + if transition == 'instant': + self._root_widget.delete() + else: + _bauiv1.containerwidget( + edit=self._root_widget, + transition=( + self._main_window_transition_out + if transition is None + else transition + ), + ) + + def main_window_has_control(self) -> bool: + """Is this MainWindow allowed to change the global main window? + + It is a good idea to make sure this is True before calling + main_window_replace(). This prevents fluke UI breakage such as + multiple simultaneous events causing a MainWindow to spawn + multiple replacements for itself. + """ + # We are allowed to change main windows if we are the current one + # AND our underlying widget is still alive and not transitioning out. + return ( + babase.app.ui_v1.get_main_window() is self + and bool(self._root_widget) + and not self._root_widget.transitioning_out + ) + + def main_window_back(self) -> None: + """Move back in the main window stack. + + Is a no-op if the main window does not have control; + no need to check main_window_has_control() first. + """ + + # Users should always check main_window_has_control() before + # calling us. Error if it seems they did not. + if not self.main_window_has_control(): + return + + uiv1 = babase.app.ui_v1 + + # Get the 'back' window coming in. + if not self.main_window_is_top_level: + + back_state = self.main_window_back_state + if back_state is None: + raise RuntimeError( + f'Main window {self} provides no back-state.' + ) + + # Valid states should have values here. + assert back_state.is_top_level is not None + assert back_state.is_auxiliary is not None + assert back_state.window_type is not None + + backwin = back_state.create_window(transition='in_left') + + uiv1.set_main_window( + backwin, + from_window=self, + is_back=True, + back_state=back_state, + suppress_warning=True, + ) + + # Transition ourself out. + self.main_window_close() + + def main_window_replace( + self, + new_window: MainWindow, + back_state: MainWindowState | None = None, + is_auxiliary: bool = False, + ) -> None: + """Replace ourself with a new MainWindow.""" + + # Users should always check main_window_has_control() *before* + # creating new MainWindows and passing them in here. Kill the + # passed window and Error if it seems they did not. + if not self.main_window_has_control(): + new_window.get_root_widget().delete() + raise RuntimeError( + f'main_window_replace() called on a not-in-control window' + f' ({self}); always check main_window_has_control() before' + f' calling main_window_replace().' + ) + + # Just shove the old out the left to give the feel that we're + # adding to the nav stack. + transition = 'out_left' + + # Transition ourself out. + try: + self.on_main_window_close() + except Exception: + logging.exception('Error in on_main_window_close() for %s.', self) + + _bauiv1.containerwidget(edit=self._root_widget, transition=transition) + babase.app.ui_v1.set_main_window( + new_window, + from_window=self, + back_state=back_state, + is_auxiliary=is_auxiliary, + suppress_warning=True, + ) + + def on_main_window_close(self) -> None: + """Called before transitioning out a main window. + + A good opportunity to save window state/etc. + """ + + def get_main_window_state(self) -> MainWindowState: + """Return a WindowState to recreate this window, if supported.""" + raise NotImplementedError() + + +class MainWindowState: + """Persistent state for a specific MainWindow. + + This allows MainWindows to be automatically recreated for back-button + purposes, when switching app-modes, etc. + """ + + def __init__(self) -> None: + # The window that back/cancel navigation should take us to. + self.parent: MainWindowState | None = None + self.is_top_level: bool | None = None + self.is_auxiliary: bool | None = None + self.window_type: type[MainWindow] | None = None + self.selection: str | None = None + + def create_window( + self, + transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, + origin_widget: bauiv1.Widget | None = None, + ) -> MainWindow: + """Create a window based on this state. + + WindowState child classes should override this to recreate their + particular type of window. + """ + raise NotImplementedError() + + +class BasicMainWindowState(MainWindowState): + """A basic MainWindowState holding a lambda to recreate a MainWindow.""" + + def __init__( + self, + create_call: Callable[ + [ + Literal['in_right', 'in_left', 'in_scale'] | None, + bauiv1.Widget | None, + ], + bauiv1.MainWindow, + ], + ) -> None: + super().__init__() + self.create_call = create_call + + @override + def create_window( + self, + transition: Literal['in_right', 'in_left', 'in_scale'] | None = None, + origin_widget: bauiv1.Widget | None = None, + ) -> bauiv1.MainWindow: + return self.create_call(transition, origin_widget) + + @dataclass class UICleanupCheck: """Holds info about a uicleanupcheck target.""" @@ -50,127 +301,8 @@ class UICleanupCheck: widget_death_time: float | None -class UILocation: - """Defines a specific 'place' in the UI the user can navigate to. - - Category: User Interface Classes - """ - - def __init__(self) -> None: - pass - - def save_state(self) -> None: - """Serialize this instance's state to a dict.""" - - def restore_state(self) -> None: - """Restore this instance's state from a dict.""" - - def push_location(self, location: str) -> None: - """Push a new location to the stack and transition to it.""" - - -class UILocationWindow(UILocation): - """A UILocation consisting of a single root window widget. - - Category: User Interface Classes - """ - - def __init__(self) -> None: - super().__init__() - self._root_widget: bauiv1.Widget | None = None - - def get_root_widget(self) -> bauiv1.Widget: - """Return the root widget for this window.""" - assert self._root_widget is not None - return self._root_widget - - -class UIEntry: - """State for a UILocation on the stack.""" - - def __init__(self, name: str, controller: UIController): - self._name = name - self._state = None - self._args = None - self._instance: UILocation | None = None - self._controller = weakref.ref(controller) - - def create(self) -> None: - """Create an instance of our UI.""" - cls = self._get_class() - self._instance = cls() - - def destroy(self) -> None: - """Transition out our UI if it exists.""" - if self._instance is None: - return - print('WOULD TRANSITION OUT', self._name) - - def _get_class(self) -> Type[UILocation]: - """Returns the UI class our name points to.""" - # pylint: disable=cyclic-import - - # TEMP HARD CODED - WILL REPLACE THIS WITH BA_META LOOKUPS. - if self._name == 'mainmenu': - # Shut pylint up. - if bool(False): - return UILocation - raise RuntimeError('FIXME UNIMPLEMENTED') - # from bauiv1lib import mainmenu - # return cast(Type[UILocation], mainmenu.MainMenuWindow) - - raise ValueError('unknown ui class ' + str(self._name)) - - -class UIController: - """Wrangles bauiv1.UILocations. - - Category: User Interface Classes - """ - - def __init__(self) -> None: - # FIXME: document why we have separate stacks for game and menu... - self._main_stack_game: list[UIEntry] = [] - self._main_stack_menu: list[UIEntry] = [] - - # This points at either the game or menu stack. - self._main_stack: list[UIEntry] | None = None - - # There's only one of these since we don't need to preserve its state - # between sessions. - self._dialog_stack: list[UIEntry] = [] - - def show_main_menu(self, in_game: bool = True) -> None: - """Show the main menu, clearing other UIs from location stacks.""" - self._main_stack = [] - self._dialog_stack = [] - self._main_stack = ( - self._main_stack_game if in_game else self._main_stack_menu - ) - self._main_stack.append(UIEntry('mainmenu', self)) - self._update_ui() - - def _update_ui(self) -> None: - """Instantiate the topmost ui in our stacks.""" - - # First tell any existing UIs to get outta here. - for stack in (self._dialog_stack, self._main_stack): - assert stack is not None - for entry in stack: - entry.destroy() - - # Now create the topmost one if there is one. - entrynew = ( - self._dialog_stack[-1] - if self._dialog_stack - else self._main_stack[-1] if self._main_stack else None - ) - if entrynew is not None: - entrynew.create() - - def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None: - """Add a check to ensure a widget-owning object gets cleaned up properly. + """Checks to ensure a widget-owning object gets cleaned up properly. Category: User Interface Functions @@ -232,9 +364,11 @@ def ui_upkeep() -> None: print( 'WARNING:', obj, - 'is still alive 5 second after its widget died;' - ' you might have a memory leak. See efro.debug module' - ' for tools to help debug this.', + 'is still alive 5 second after its Widget died;' + ' you might have a memory leak. Look for circular' + ' references or outside things referencing your Window' + ' class instance. See efro.debug module' + ' for tools that can help debug this sort of thing.', ) else: remainingchecks.append(check) diff --git a/dist/ba_data/python/bauiv1lib/__init__.py b/dist/ba_data/python/bauiv1lib/__init__.py index f7720bd..454e03f 100644 --- a/dist/ba_data/python/bauiv1lib/__init__.py +++ b/dist/ba_data/python/bauiv1lib/__init__.py @@ -2,4 +2,4 @@ # """Library of stuff using the bauiv1 api: windows, custom controls, etc.""" -# ba_meta require api 8 +# ba_meta require api 9 diff --git a/dist/ba_data/python/bauiv1lib/account/__init__.py b/dist/ba_data/python/bauiv1lib/account/__init__.py index 5ce2650..1d25ed0 100644 --- a/dist/ba_data/python/bauiv1lib/account/__init__.py +++ b/dist/ba_data/python/bauiv1lib/account/__init__.py @@ -1,38 +1,3 @@ # Released under the MIT License. See LICENSE for details. # """UI functionality related to accounts.""" - -from __future__ import annotations - -import bauiv1 as bui - - -def show_sign_in_prompt(account_type: str | None = None) -> None: - """Bring up a prompt telling the user they must sign in.""" - from bauiv1lib.confirm import ConfirmWindow - from bauiv1lib.account import settings - - if account_type == 'Google Play': - - def _do_sign_in() -> None: - plus = bui.app.plus - assert plus is not None - plus.sign_in_v1('Google Play') - - ConfirmWindow( - bui.Lstr(resource='notSignedInGooglePlayErrorText'), - _do_sign_in, - ok_text=bui.Lstr(resource='accountSettingsWindow.signInText'), - width=460, - height=130, - ) - else: - ConfirmWindow( - bui.Lstr(resource='notSignedInErrorText'), - lambda: settings.AccountSettingsWindow( - modal=True, close_once_signed_in=True - ), - ok_text=bui.Lstr(resource='accountSettingsWindow.signInText'), - width=460, - height=130, - ) diff --git a/dist/ba_data/python/bauiv1lib/account/link.py b/dist/ba_data/python/bauiv1lib/account/link.py index 58828ad..d19ed06 100644 --- a/dist/ba_data/python/bauiv1lib/account/link.py +++ b/dist/ba_data/python/bauiv1lib/account/link.py @@ -105,13 +105,13 @@ class AccountLinkWindow(bui.Window): ) def _generate_press(self) -> None: - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() return bui.screenmessage( bui.Lstr(resource='gatherWindow.requestingAPromoCodeText'), diff --git a/dist/ba_data/python/bauiv1lib/account/settings.py b/dist/ba_data/python/bauiv1lib/account/settings.py index 9476f32..b2d02b2 100644 --- a/dist/ba_data/python/bauiv1lib/account/settings.py +++ b/dist/ba_data/python/bauiv1lib/account/settings.py @@ -7,12 +7,14 @@ from __future__ import annotations import time import logging +from typing import override from bacommon.cloud import WebLocation from bacommon.login import LoginType import bacommon.cloud import bauiv1 as bui +from bauiv1lib.connectivity import wait_for_connectivity # These days we're directing people to the web based account settings # for V2 account linking and trying to get them to disconnect remaining @@ -20,17 +22,17 @@ import bauiv1 as bui FORCE_ENABLE_V1_LINKING = False -class AccountSettingsWindow(bui.Window): +class AccountSettingsWindow(bui.MainWindow): """Window for account related functionality.""" def __init__( self, - transition: str = 'in_right', - modal: bool = False, + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, close_once_signed_in: bool = False, ): # pylint: disable=too-many-statements + # pylint: disable=too-many-locals plus = bui.app.plus assert plus is not None @@ -46,18 +48,7 @@ class AccountSettingsWindow(bui.Window): self._explicitly_signed_out_of_gpgs = False - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self._r = 'accountSettingsWindow' - self._modal = modal self._needs_refresh = False self._v1_signed_in = plus.get_v1_account_state() == 'signed_in' self._v1_account_state_num = plus.get_v1_account_state_num() @@ -71,19 +62,39 @@ class AccountSettingsWindow(bui.Window): assert app.classic is not None uiscale = app.ui_v1.uiscale - self._width = 860 if uiscale is bui.UIScale.SMALL else 660 - x_offs = 100 if uiscale is bui.UIScale.SMALL else 0 + self._width = 980 if uiscale is bui.UIScale.SMALL else 660 self._height = ( - 390 + 600 if uiscale is bui.UIScale.SMALL else 430 if uiscale is bui.UIScale.MEDIUM else 490 ) + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + + scale = ( + 1.9 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 80, screensize[0] / scale) + target_height = min(self._height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 33 + scroll_bottom = yoffs - 61 - self._scroll_height + self._sign_in_button = None self._sign_in_text = None - self._scroll_width = self._width - (100 + x_offs * 2) - self._scroll_height = self._height - 120 self._sub_width = self._scroll_width - 20 # Determine which sign-in/sign-out buttons we should show. @@ -98,62 +109,63 @@ class AccountSettingsWindow(bui.Window): # Always want to show our web-based v2 login option. self._show_sign_in_buttons.append('V2Proxy') - # Legacy v1 device accounts available only if the user - # has explicitly enabled deprecated login types. + # Legacy v1 device accounts available only if the user has + # explicitly enabled deprecated login types. if bui.app.config.resolve('Show Deprecated Login Types'): self._show_sign_in_buttons.append('Device') - top_extra = 15 if uiscale is bui.UIScale.SMALL else 0 super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.09 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -19) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: self._back_button = None bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back + edit=self._root_widget, on_cancel_call=self.main_window_back ) else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(51 + x_offs, self._height - 62), + position=(51, yoffs - 52.0), size=(120, 60), scale=0.8, text_scale=1.2, autoselect=True, - label=bui.Lstr( - resource='doneText' if self._modal else 'backText' - ), - button_type='regular' if self._modal else 'back', - on_activate_call=self._back, + label=bui.Lstr(resource='backText'), + button_type='back', + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) - if not self._modal: - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 56), - label=bui.charstr(bui.SpecialChar.BACK), - ) + bui.buttonwidget( + edit=btn, + button_type='backSmall', + size=(60, 56), + label=bui.charstr(bui.SpecialChar.BACK), + ) + titleyoffs = -45.0 if uiscale is bui.UIScale.SMALL else -28.0 + titlescale = 0.7 if uiscale is bui.UIScale.SMALL else 1.0 bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 41), + position=( + self._width * 0.5, + yoffs + titleyoffs, + ), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.titleText'), color=app.ui_v1.title_color, + scale=titlescale, maxwidth=self._width - 340, h_align='center', v_align='center', @@ -162,31 +174,45 @@ class AccountSettingsWindow(bui.Window): self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, - position=( - (self._width - self._scroll_width) * 0.5, - self._height - 65 - self._scroll_height, - ), size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, + border_opacity=0.4, ) self._subcontainer: bui.Widget | None = None self._refresh() self._restore_state() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _update(self) -> None: plus = bui.app.plus assert plus is not None # If they want us to close once we're signed in, do so. if self._close_once_signed_in and self._v1_signed_in: - self._back() + self.main_window_back() return # Hmm should update this to use get_account_state_num. - # Theoretically if we switch from one signed-in account to another - # in the background this would break. + # Theoretically if we switch from one signed-in account to + # another in the background this would break. v1_account_state_num = plus.get_v1_account_state_num() v1_account_state = plus.get_v1_account_state() show_legacy_unlink_button = self._should_show_legacy_unlink_button() @@ -201,8 +227,8 @@ class AccountSettingsWindow(bui.Window): self._show_legacy_unlink_button = show_legacy_unlink_button self._refresh() - # Go ahead and refresh some individual things - # that may change under us. + # Go ahead and refresh some individual things that may change + # under us. self._update_linked_accounts_text() self._update_unlink_accounts_button() self._refresh_campaign_progress_text() @@ -215,7 +241,6 @@ class AccountSettingsWindow(bui.Window): # pylint: disable=too-many-branches # pylint: disable=too-many-locals # pylint: disable=cyclic-import - from bauiv1lib import confirm plus = bui.app.plus assert plus is not None @@ -316,54 +341,57 @@ class AccountSettingsWindow(bui.Window): show_game_service_button = game_center_active game_service_button_space = 60.0 - # Phasing this out. - show_what_is_v2 = False - # show_what_is_v2 = self._v1_signed_in and v1_account_type == 'V2' - # Phasing this out (for V2 accounts at least). show_linked_accounts_text = ( self._v1_signed_in and v1_account_type != 'V2' ) linked_accounts_text_space = 60.0 - # Always show achievements except in the game-center case where - # its unified UI covers them. - show_achievements_button = self._v1_signed_in and not game_center_active - achievements_button_space = 60.0 - - show_achievements_text = ( - self._v1_signed_in and not show_achievements_button - ) + # Update: No longer showing this since its visible on main + # toolbar. + show_achievements_text = False achievements_text_space = 27.0 show_leaderboards_button = self._v1_signed_in and gpgs_active leaderboards_button_space = 60.0 - show_campaign_progress = self._v1_signed_in + # Update: No longer showing this; trying to get progress type + # stuff out of the account panel. + # show_campaign_progress = self._v1_signed_in + show_campaign_progress = False campaign_progress_space = 27.0 - show_tickets = self._v1_signed_in + # show_tickets = self._v1_signed_in + show_tickets = False tickets_space = 27.0 - show_reset_progress_button = False - reset_progress_button_space = 70.0 + show_manage_account_button = primary_v2_account is not None + manage_account_button_space = 70.0 - show_manage_v2_account_button = primary_v2_account is not None - manage_v2_account_button_space = 100.0 + show_create_account_button = show_v2_proxy_sign_in_button + create_account_button_space = 70.0 - show_delete_account_button = primary_v2_account is not None - delete_account_button_space = 80.0 - - show_player_profiles_button = self._v1_signed_in - player_profiles_button_space = ( - 70.0 if show_manage_v2_account_button else 100.0 + # Apple asks us to make a delete-account button directly + # available in the UI. Currently disabling this elsewhere + # however as I feel that poking 'Manage Account' and scrolling + # down to 'Delete Account' is not hard to find. + show_delete_account_button = primary_v2_account is not None and ( + bui.app.classic is not None + and bui.app.classic.platform == 'mac' + and bui.app.classic.subplatform == 'appstore' ) + delete_account_button_space = 70.0 show_link_accounts_button = self._v1_signed_in and ( primary_v2_account is None or FORCE_ENABLE_V1_LINKING ) link_accounts_button_space = 70.0 + show_v1_obsolete_note = self._v1_signed_in and ( + primary_v2_account is None + ) + v1_obsolete_note_space = 80.0 + show_unlink_accounts_button = show_link_accounts_button unlink_accounts_button_space = 90.0 @@ -376,7 +404,7 @@ class AccountSettingsWindow(bui.Window): show_sign_out_button = primary_v2_account is not None or ( self._v1_signed_in and v1_account_type == 'Local' ) - sign_out_button_space = 80.0 + sign_out_button_space = 70.0 # We can show cancel if we're either waiting on an adapter to # provide us with v2 credentials or waiting for those @@ -409,8 +437,6 @@ class AccountSettingsWindow(bui.Window): self._sub_height += linked_accounts_text_space if show_achievements_text: self._sub_height += achievements_text_space - if show_achievements_button: - self._sub_height += achievements_button_space if show_leaderboards_button: self._sub_height += leaderboards_button_space if show_campaign_progress: @@ -419,14 +445,14 @@ class AccountSettingsWindow(bui.Window): self._sub_height += tickets_space if show_sign_in_benefits: self._sub_height += sign_in_benefits_space - if show_reset_progress_button: - self._sub_height += reset_progress_button_space - if show_manage_v2_account_button: - self._sub_height += manage_v2_account_button_space - if show_player_profiles_button: - self._sub_height += player_profiles_button_space + if show_manage_account_button: + self._sub_height += manage_account_button_space + if show_create_account_button: + self._sub_height += create_account_button_space if show_link_accounts_button: self._sub_height += link_accounts_button_space + if show_v1_obsolete_note: + self._sub_height += v1_obsolete_note_space if show_unlink_accounts_button: self._sub_height += unlink_accounts_button_space if show_v2_link_info: @@ -444,7 +470,6 @@ class AccountSettingsWindow(bui.Window): size=(self._sub_width, self._sub_height), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) @@ -452,8 +477,6 @@ class AccountSettingsWindow(bui.Window): v = self._sub_height - 10.0 assert bui.app.classic is not None - self._account_name_what_is_text: bui.Widget | None - self._account_name_what_is_y = 0.0 self._account_name_text: bui.Widget | None if show_signed_in_as: v -= signed_in_as_space * 0.2 @@ -485,32 +508,6 @@ class AccountSettingsWindow(bui.Window): v_align='center', ) - if show_what_is_v2: - self._account_name_what_is_y = v - 23.0 - self._account_name_what_is_text = bui.textwidget( - parent=self._subcontainer, - position=(0.0, self._account_name_what_is_y), - size=(220.0, 60), - text=bui.Lstr( - value='${WHAT} -->', - subs=[('${WHAT}', bui.Lstr(resource='whatIsThisText'))], - ), - scale=0.6, - color=(0.3, 0.7, 0.05), - maxwidth=130.0, - h_align='right', - v_align='center', - autoselect=True, - selectable=True, - on_activate_call=show_what_is_v2_page, - click_activate=True, - glow_type='uniform', - ) - if first_selectable is None: - first_selectable = self._account_name_what_is_text - else: - self._account_name_what_is_text = None - self._refresh_account_name_text() v -= signed_in_as_space * 0.4 @@ -565,7 +562,6 @@ class AccountSettingsWindow(bui.Window): else: self._account_name_text = None - self._account_name_what_is_text = None if self._back_button is None: bbtn = bui.get_special_widget('back_button') @@ -641,11 +637,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None @@ -683,11 +677,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None @@ -752,11 +744,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None @@ -821,21 +811,39 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) bui.widget(edit=btn, show_buffer_bottom=40, show_buffer_top=100) self._sign_in_text = None - if show_manage_v2_account_button: - button_width = 300 - v -= manage_v2_account_button_space - self._manage_v2_button = btn = bui.buttonwidget( + if show_v1_obsolete_note: + v -= v1_obsolete_note_space + bui.textwidget( parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v + 30), + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v + 35.0), + text=( + 'YOU ARE SIGNED IN WITH A V1 ACCOUNT.\n' + 'THESE ARE NO LONGER SUPPORTED AND MANY\n' + 'FEATURES WILL NOT WORK. PLEASE SWITCH TO\n' + 'A V2 ACCOUNT OR UPGRADE THIS ONE.' + ), + maxwidth=self._sub_width * 0.8, + color=(1, 0, 0), + shadow=1.0, + flatness=1.0, + ) + + if show_manage_account_button: + button_width = 300 + v -= manage_account_button_space + self._manage_button = btn = bui.buttonwidget( + parent=self._subcontainer, + position=((self._sub_width - button_width) * 0.5, v), autoselect=True, size=(button_width, 60), label=bui.Lstr(resource=f'{self._r}.manageAccountText'), @@ -846,35 +854,30 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) - if show_player_profiles_button: + if show_create_account_button: button_width = 300 - v -= player_profiles_button_space - self._player_profiles_button = btn = bui.buttonwidget( + v -= create_account_button_space + self._create_button = btn = bui.buttonwidget( parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v + 30), + position=((self._sub_width - button_width) * 0.5, v - 30), autoselect=True, size=(button_width, 60), - label=bui.Lstr(resource='playerProfilesWindow.titleText'), + label=bui.Lstr(resource=f'{self._r}.createAnAccountText'), color=(0.55, 0.5, 0.6), - icon=bui.gettexture('cuteSpaz'), textcolor=(0.75, 0.7, 0.8), - on_activate_call=self._player_profiles_press, + on_activate_call=bui.WeakCall(self._on_create_account_press), ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) - bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=0) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) + bui.widget(edit=btn, left_widget=bbtn) # the button to go to OS-Specific leaderboards/high-score-lists/etc. if show_game_service_button: @@ -904,11 +907,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) v -= game_service_button_space * 0.4 else: @@ -931,51 +932,13 @@ class AccountSettingsWindow(bui.Window): else: self._achievements_text = None - self._achievements_button: bui.Widget | None - if show_achievements_button: - button_width = 300 - v -= achievements_button_space * 0.85 - self._achievements_button = btn = bui.buttonwidget( - parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v), - color=(0.55, 0.5, 0.6), - textcolor=(0.75, 0.7, 0.8), - autoselect=True, - icon=bui.gettexture( - 'googlePlayAchievementsIcon' - if gpgs_active - else 'achievementsIcon' - ), - icon_color=( - (0.8, 0.95, 0.7) if gpgs_active else (0.85, 0.8, 0.9) - ), - on_activate_call=( - self._on_custom_achievements_press - if gpgs_active - else self._on_achievements_press - ), - size=(button_width, 50), - label='', - ) - if first_selectable is None: - first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) - bui.widget(edit=btn, left_widget=bbtn) - v -= achievements_button_space * 0.15 - else: - self._achievements_button = None - - if show_achievements_text or show_achievements_button: + if show_achievements_text: self._refresh_achievements() self._leaderboards_button: bui.Widget | None if show_leaderboards_button: button_width = 300 - v -= leaderboards_button_space * 0.85 + v -= leaderboards_button_space self._leaderboards_button = btn = bui.buttonwidget( parent=self._subcontainer, position=((self._sub_width - button_width) * 0.5, v), @@ -990,13 +953,10 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn) - v -= leaderboards_button_space * 0.15 else: self._leaderboards_button = None @@ -1039,41 +999,9 @@ class AccountSettingsWindow(bui.Window): self._tickets_text = None # bit of spacing before the reset/sign-out section - v -= 5 + # v -= 5 - button_width = 250 - if show_reset_progress_button: - confirm_text = ( - bui.Lstr(resource=f'{self._r}.resetProgressConfirmText') - if self._can_reset_achievements - else bui.Lstr( - resource=f'{self._r}.resetProgressConfirmNoAchievementsText' - ) - ) - v -= reset_progress_button_space - self._reset_progress_button = btn = bui.buttonwidget( - parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v), - color=(0.55, 0.5, 0.6), - textcolor=(0.75, 0.7, 0.8), - autoselect=True, - size=(button_width, 60), - label=bui.Lstr(resource=f'{self._r}.resetProgressText'), - on_activate_call=lambda: confirm.ConfirmWindow( - text=confirm_text, - width=500, - height=200, - action=self._reset_progress, - ), - ) - if first_selectable is None: - first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) - bui.widget(edit=btn, left_widget=bbtn) + button_width = 300 self._linked_accounts_text: bui.Widget | None if show_linked_accounts_text: @@ -1133,11 +1061,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50) self._unlink_accounts_button: bui.Widget | None @@ -1165,11 +1091,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=50) self._update_unlink_accounts_button() else: @@ -1235,11 +1159,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15) if show_cancel_sign_in_button: @@ -1256,11 +1178,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15) if show_delete_account_button: @@ -1277,11 +1197,9 @@ class AccountSettingsWindow(bui.Window): ) if first_selectable is None: first_selectable = btn - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, right_widget=bui.get_special_widget('squad_button') + ) bui.widget(edit=btn, left_widget=bbtn, show_buffer_bottom=15) # Whatever the topmost selectable thing is, we want it to scroll all @@ -1313,38 +1231,27 @@ class AccountSettingsWindow(bui.Window): else: logging.warning('show_game_service_ui requires plus feature-set.') - def _on_achievements_press(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib import achievements - - assert self._achievements_button is not None - achievements.AchievementsWindow( - position=self._achievements_button.get_screen_space_center() - ) - - def _on_what_is_v2_press(self) -> None: - show_what_is_v2_page() - def _on_manage_account_press(self) -> None: self._do_manage_account_press(WebLocation.ACCOUNT_EDITOR) + def _on_create_account_press(self) -> None: + bui.open_url('https://ballistica.net/createaccount') + def _on_delete_account_press(self) -> None: self._do_manage_account_press(WebLocation.ACCOUNT_DELETE_SECTION) def _do_manage_account_press(self, weblocation: WebLocation) -> None: + # If we're still waiting for our master-server connection, + # keep the user informed of this instead of rushing in and + # failing immediately. + wait_for_connectivity( + on_connected=lambda: self._do_manage_account(weblocation) + ) + + def _do_manage_account(self, weblocation: WebLocation) -> None: plus = bui.app.plus assert plus is not None - # Preemptively fail if it looks like we won't be able to talk to - # the server anyway. - if not plus.cloud.connected: - bui.screenmessage( - bui.Lstr(resource='internal.unavailableNoConnectionText'), - color=(1, 0, 0), - ) - bui.getsound('error').play() - return - bui.screenmessage(bui.Lstr(resource='oneMomentText')) # We expect to have a v2 account signed in if we get here. @@ -1386,8 +1293,8 @@ class AccountSettingsWindow(bui.Window): plus = bui.app.plus assert plus is not None - # if this is not present, we haven't had contact from the server so - # let's not proceed.. + # If this is not present, we haven't had contact from the server + # so let's not proceed. if plus.get_v1_account_public_login_id() is None: return False accounts = plus.get_v1_account_misc_read_val_2('linkedAccounts', []) @@ -1404,7 +1311,8 @@ class AccountSettingsWindow(bui.Window): def _should_show_legacy_unlink_button(self) -> bool: plus = bui.app.plus - assert plus is not None + if plus is None: + return False # Only show this when fully signed in to a v2 account. if not self._v1_signed_in or plus.accounts.primary is None: @@ -1502,23 +1410,10 @@ class AccountSettingsWindow(bui.Window): name_str = '??' bui.textwidget(edit=self._account_name_text, text=name_str) - if self._account_name_what_is_text is not None: - swidth = bui.get_string_width(name_str, suppress_warning=True) - # Eww; number-fudging. Need to recalibrate this if - # account name scaling changes. - x = self._sub_width * 0.5 - swidth * 0.75 - 190 - - bui.textwidget( - edit=self._account_name_what_is_text, - position=(x, self._account_name_what_is_y), - ) def _refresh_achievements(self) -> None: assert bui.app.classic is not None - if ( - self._achievements_text is None - and self._achievements_button is None - ): + if self._achievements_text is None: return complete = sum( 1 if a.complete else 0 for a in bui.app.classic.ach.achievements @@ -1531,8 +1426,6 @@ class AccountSettingsWindow(bui.Window): if self._achievements_text is not None: bui.textwidget(edit=self._achievements_text, text=txt_final) - if self._achievements_button is not None: - bui.buttonwidget(edit=self._achievements_button, label=txt_final) def _link_accounts_press(self) -> None: # pylint: disable=cyclic-import @@ -1550,23 +1443,6 @@ class AccountSettingsWindow(bui.Window): AccountUnlinkWindow(origin_widget=self._unlink_accounts_button) - def _player_profiles_press(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.profile.browser import ProfileBrowserWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.app.ui_v1.set_main_menu_window( - ProfileBrowserWindow( - origin_widget=self._player_profiles_button - ).get_root_widget(), - from_window=self._root_widget, - ) - def _cancel_sign_in_press(self) -> None: # If we're waiting on an adapter to give us credentials, abort. self._signing_in_adapter = None @@ -1611,8 +1487,6 @@ class AccountSettingsWindow(bui.Window): bui.apptimer(0.1, bui.WeakCall(self._update)) def _sign_in_press(self, login_type: str | LoginType) -> None: - from bauiv1lib.connectivity import wait_for_connectivity - # If we're still waiting for our master-server connection, # keep the user informed of this instead of rushing in and # failing immediately. @@ -1674,14 +1548,14 @@ class AccountSettingsWindow(bui.Window): bui.getsound('error').play() else: # Success! Plug in these credentials which will begin - # verifying them and set our primary account-handle - # when finished. + # verifying them and set our primary account-handle when + # finished. plus = bui.app.plus assert plus is not None plus.accounts.set_primary_credentials(result.credentials) - # Special case - if the user has explicitly logged out and - # logged in again with GPGS via this button, warn them that + # Special case - if the user has explicitly signed out and + # signed in again with GPGS via this button, warn them that # they need to use the app if they want to switch to a # different GPGS account. if ( @@ -1706,12 +1580,9 @@ class AccountSettingsWindow(bui.Window): bui.apptimer(0.1, bui.WeakCall(self._update)) def _v2_proxy_sign_in_press(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.connectivity import wait_for_connectivity - - # If we're still waiting for our master-server connection, - # keep the user informed of this instead of rushing in and - # failing immediately. + # If we're still waiting for our master-server connection, keep + # the user informed of this instead of rushing in and failing + # immediately. wait_for_connectivity(on_connected=self._v2_proxy_sign_in) def _v2_proxy_sign_in(self) -> None: @@ -1721,44 +1592,6 @@ class AccountSettingsWindow(bui.Window): assert self._sign_in_v2_proxy_button is not None V2ProxySignInWindow(origin_widget=self._sign_in_v2_proxy_button) - def _reset_progress(self) -> None: - try: - assert bui.app.classic is not None - # FIXME: This would need to happen server-side these days. - if self._can_reset_achievements: - logging.warning('ach resets not wired up.') - # bui.app.config['Achievements'] = {} - # bui.reset_achievements() - campaign = bui.app.classic.getcampaign('Default') - campaign.reset() # also writes the config.. - campaign = bui.app.classic.getcampaign('Challenges') - campaign.reset() # also writes the config.. - except Exception: - logging.exception('Error resetting co-op campaign progress.') - - bui.getsound('shieldDown').play() - self._refresh() - - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - def _save_state(self) -> None: try: sel = self._root_widget.get_selected_child() @@ -1788,15 +1621,6 @@ class AccountSettingsWindow(bui.Window): logging.exception('Error restoring state for %s.', self) -def show_what_is_v2_page() -> None: - """Show the webpage describing V2 accounts.""" - plus = bui.app.plus - assert plus is not None - - bamasteraddr = plus.get_master_server_address(version=2) - bui.open_url(f'{bamasteraddr}/whatisv2') - - def show_what_is_legacy_unlinking_page() -> None: """Show the webpage describing legacy unlinking.""" plus = bui.app.plus diff --git a/dist/ba_data/python/bauiv1lib/account/signin.py b/dist/ba_data/python/bauiv1lib/account/signin.py new file mode 100644 index 0000000..0b22723 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/account/signin.py @@ -0,0 +1,51 @@ +# Released under the MIT License. See LICENSE for details. +# +"""UI functionality related to accounts.""" + +from __future__ import annotations + +import bauiv1 as bui + + +def show_sign_in_prompt() -> None: + """Bring up a prompt telling the user they must sign in.""" + from bauiv1lib.confirm import ConfirmWindow + + ConfirmWindow( + bui.Lstr(resource='notSignedInErrorText'), + _show_account_settings, + ok_text=bui.Lstr(resource='accountSettingsWindow.signInText'), + width=460, + height=130, + ) + + +def _show_account_settings() -> None: + from bauiv1lib.account.settings import AccountSettingsWindow + + # NOTE TO USERS: The code below is not the proper way to do things; + # whenever possible one should use a MainWindow's + # main_window_replace() or main_window_back() methods. We just need + # to do things a bit more manually in this case. + + prev_main_window = bui.app.ui_v1.get_main_window() + + # Special-case: If it seems we're already in the account window, do + # nothing. + if isinstance(prev_main_window, AccountSettingsWindow): + return + + # Set our new main window. + bui.app.ui_v1.set_main_window( + AccountSettingsWindow( + close_once_signed_in=True, + origin_widget=bui.get_special_widget('account_button'), + ), + from_window=False, + is_auxiliary=True, + suppress_warning=True, + ) + + # Transition out any previous main window. + if prev_main_window is not None: + prev_main_window.main_window_close() diff --git a/dist/ba_data/python/bauiv1lib/account/v2proxy.py b/dist/ba_data/python/bauiv1lib/account/v2proxy.py index d91f1f0..f84b3b3 100644 --- a/dist/ba_data/python/bauiv1lib/account/v2proxy.py +++ b/dist/ba_data/python/bauiv1lib/account/v2proxy.py @@ -34,9 +34,9 @@ class V2ProxySignInWindow(bui.Window): origin_widget.get_screen_space_center() ), scale=( - 1.25 + 1.16 if uiscale is bui.UIScale.SMALL - else 1.05 if uiscale is bui.UIScale.MEDIUM else 0.9 + else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.9 ), ) ) diff --git a/dist/ba_data/python/bauiv1lib/account/viewer.py b/dist/ba_data/python/bauiv1lib/account/viewer.py index 2dbb1de..c97973c 100644 --- a/dist/ba_data/python/bauiv1lib/account/viewer.py +++ b/dist/ba_data/python/bauiv1lib/account/viewer.py @@ -23,6 +23,7 @@ class AccountViewerWindow(PopupWindow): def __init__( self, account_id: str, + *, profile_id: str | None = None, position: tuple[float, float] = (0.0, 0.0), scale: float | None = None, @@ -87,7 +88,7 @@ class AccountViewerWindow(PopupWindow): scale=0.6, text=bui.Lstr(resource='playerInfoText'), maxwidth=200, - color=(0.7, 0.7, 0.7, 0.7), + color=bui.app.ui_v1.title_color, ) self._scrollwidget = bui.scrollwidget( @@ -96,20 +97,26 @@ class AccountViewerWindow(PopupWindow): position=(30, 30), capture_arrows=True, simple_culling_v=10, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, autoselect=True) + # Note to self: Make sure to always update loading text and + # spinner visibility together. self._loading_text = bui.textwidget( parent=self._scrollwidget, scale=0.5, - text=bui.Lstr( - value='${A}...', - subs=[('${A}', bui.Lstr(resource='loadingText'))], - ), + text='', size=(self._width - 60, 100), h_align='center', v_align='center', ) + self._loading_spinner = bui.spinnerwidget( + parent=self.root_widget, + position=(self._width * 0.5, self._height * 0.5), + style='bomb', + size=48, + ) # In cases where the user most likely has a browser/email, lets # offer a 'report this user' button. @@ -226,9 +233,11 @@ class AccountViewerWindow(PopupWindow): edit=self._loading_text, text=bui.Lstr(resource='internal.unavailableNoConnectionText'), ) + bui.spinnerwidget(edit=self._loading_spinner, visible=False) else: try: self._loading_text.delete() + self._loading_spinner.delete() trophystr = '' try: trophystr = data['trophies'] diff --git a/dist/ba_data/python/bauiv1lib/achievements.py b/dist/ba_data/python/bauiv1lib/achievements.py index 228e773..b5e2f3f 100644 --- a/dist/ba_data/python/bauiv1lib/achievements.py +++ b/dist/ba_data/python/bauiv1lib/achievements.py @@ -6,54 +6,92 @@ from __future__ import annotations from typing import override -from bauiv1lib.popup import PopupWindow import bauiv1 as bui -class AchievementsWindow(PopupWindow): +class AchievementsWindow(bui.MainWindow): """Popup window to view achievements.""" def __init__( - self, position: tuple[float, float], scale: float | None = None + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # pylint: disable=cyclic-import + from baclassic import ( + CHEST_APPEARANCE_DISPLAY_INFOS, + CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, + ) + assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - if scale is None: - scale = ( - 2.3 - if uiscale is bui.UIScale.SMALL - else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 - ) - self._transitioning_out = False - self._width = 450 + + self._width = 700 if uiscale is bui.UIScale.SMALL else 550 self._height = ( - 300 + 450 if uiscale is bui.UIScale.SMALL else 370 if uiscale is bui.UIScale.MEDIUM else 450 ) - bg_color = (0.5, 0.4, 0.6) - # creates our _root_widget + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.7 + if uiscale is bui.UIScale.SMALL + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.2 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + scroll_width = target_width + scroll_height = target_height - 25 + scroll_bottom = yoffs - 54 - scroll_height + super().__init__( - position=position, - size=(self._width, self._height), - scale=scale, - bg_color=bg_color, + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' + if uiscale is bui.UIScale.SMALL + else 'menu_full' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._cancel_button = bui.buttonwidget( - parent=self.root_widget, - position=(50, self._height - 30), - size=(50, 50), - scale=0.5, - label='', - color=bg_color, - on_activate_call=self._on_cancel_press, - autoselect=True, - icon=bui.gettexture('crossOut'), - iconscale=1.2, - ) + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(50, yoffs - 48), + size=(60, 60), + scale=0.6, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) achievements = bui.app.classic.ach.achievements num_complete = len([a for a in achievements if a.complete]) @@ -66,32 +104,41 @@ class AchievementsWindow(PopupWindow): ], ) self._title_text = bui.textwidget( - parent=self.root_widget, - position=(self._width * 0.5, self._height - 20), + parent=self._root_widget, + position=( + self._width * 0.5, + yoffs - (42 if uiscale is bui.UIScale.SMALL else 30), + ), size=(0, 0), h_align='center', v_align='center', scale=0.6, text=txt_final, maxwidth=200, - color=(1, 1, 1, 0.4), + color=bui.app.ui_v1.title_color, ) self._scrollwidget = bui.scrollwidget( - parent=self.root_widget, - size=(self._width - 60, self._height - 70), - position=(30, 30), + parent=self._root_widget, + size=(scroll_width, scroll_height), + position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), capture_arrows=True, simple_culling_v=10, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, autoselect=True) + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._scrollwidget, + left_widget=bui.get_special_widget('back_button'), + ) bui.containerwidget( - edit=self.root_widget, cancel_button=self._cancel_button + edit=self._root_widget, cancel_button=self._back_button ) incr = 36 - sub_width = self._width - 90 + sub_width = scroll_width - 25 sub_height = 40 + len(achievements) * incr eq_rsrc = 'coopSelectWindow.powerRankingPointsEqualsText' @@ -174,6 +221,25 @@ class AchievementsWindow(PopupWindow): h_align='left', v_align='center', ) + chest_type = ach.get_award_chest_type() + chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( + chest_type, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT + ) + chestsize = 24.0 + bui.imagewidget( + parent=self._subcontainer, + opacity=0.0 if complete else 1.0, + position=( + sub_width * 0.92 - 40.0 - chestsize * 0.5, + sub_height - 20 - incr * i - chestsize * 0.5, + ), + size=(chestsize, chestsize), + color=chestdisplayinfo.color, + texture=bui.gettexture(chestdisplayinfo.texclosed), + tint_texture=bui.gettexture(chestdisplayinfo.texclosedtint), + tint_color=chestdisplayinfo.tint, + tint2_color=chestdisplayinfo.tint2, + ) pts = ach.power_ranking_value bui.textwidget( @@ -223,15 +289,12 @@ class AchievementsWindow(PopupWindow): v_align='center', ) - def _on_cancel_press(self) -> None: - self._transition_out() - - def _transition_out(self) -> None: - if not self._transitioning_out: - self._transitioning_out = True - bui.containerwidget(edit=self.root_widget, transition='out_scale') - @override - def on_popup_cancel(self) -> None: - bui.getsound('swish').play() - self._transition_out() + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) diff --git a/dist/ba_data/python/bauiv1lib/appinvite.py b/dist/ba_data/python/bauiv1lib/appinvite.py index 4bcbbcb..48857c3 100644 --- a/dist/ba_data/python/bauiv1lib/appinvite.py +++ b/dist/ba_data/python/bauiv1lib/appinvite.py @@ -29,7 +29,7 @@ class ShowFriendCodeWindow(bui.Window): color=(0.45, 0.63, 0.15), transition='in_scale', scale=( - 1.7 + 1.5 if uiscale is bui.UIScale.SMALL else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ), diff --git a/dist/ba_data/python/bauiv1lib/characterpicker.py b/dist/ba_data/python/bauiv1lib/characterpicker.py index 5e6ad30..835f3f0 100644 --- a/dist/ba_data/python/bauiv1lib/characterpicker.py +++ b/dist/ba_data/python/bauiv1lib/characterpicker.py @@ -14,6 +14,18 @@ if TYPE_CHECKING: from typing import Any, Sequence +class CharacterPickerDelegate: + """Delegate for character-picker.""" + + def on_character_picker_pick(self, character: str) -> None: + """Called when a character is selected.""" + raise NotImplementedError() + + def on_character_picker_get_more_press(self) -> None: + """Called when the 'get more characters' button is pressed.""" + raise NotImplementedError() + + class CharacterPicker(PopupWindow): """Popup window for selecting characters.""" @@ -21,7 +33,7 @@ class CharacterPicker(PopupWindow): self, parent: bui.Widget, position: tuple[float, float] = (0.0, 0.0), - delegate: Any = None, + delegate: CharacterPickerDelegate | None = None, scale: float | None = None, offset: tuple[float, float] = (0.0, 0.0), tint_color: Sequence[float] = (1.0, 1.0, 1.0), @@ -29,6 +41,7 @@ class CharacterPicker(PopupWindow): selected_character: str | None = None, ): # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments from bascenev1lib.actor import spazappearance assert bui.app.classic is not None @@ -180,8 +193,7 @@ class CharacterPicker(PopupWindow): bui.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30) def _on_store_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt - from bauiv1lib.store.browser import StoreBrowserWindow + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None @@ -189,12 +201,11 @@ class CharacterPicker(PopupWindow): if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return + + if self._delegate is not None: + self._delegate.on_character_picker_get_more_press() + self._transition_out() - StoreBrowserWindow( - modal=True, - show_tab=StoreBrowserWindow.TabID.CHARACTERS, - origin_widget=self._get_more_characters_button, - ) def _select_character(self, character: str) -> None: if self._delegate is not None: diff --git a/dist/ba_data/python/bauiv1lib/chest.py b/dist/ba_data/python/bauiv1lib/chest.py new file mode 100644 index 0000000..16762e4 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/chest.py @@ -0,0 +1,1096 @@ +# Released under the MIT License. See LICENSE for details. +# +# pylint: disable=too-many-lines +"""Provides chest related ui.""" + +from __future__ import annotations + +import math +import random +from typing import override, TYPE_CHECKING + +from efro.util import strict_partial +import bacommon.bs +import bauiv1 as bui + +if TYPE_CHECKING: + import datetime + + import baclassic + +_g_open_voices: list[tuple[float, str, float]] = [] + + +class ChestWindow(bui.MainWindow): + """Allows viewing and performing operations on a chest.""" + + def __init__( + self, + index: int, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + self._index = index + + assert bui.app.classic is not None + uiscale = bui.app.ui_v1.uiscale + self._width = 1200 if uiscale is bui.UIScale.SMALL else 650 + self._height = 800 if uiscale is bui.UIScale.SMALL else 450 + self._action_in_flight = False + self._open_now_button: bui.Widget | None = None + self._open_now_spinner: bui.Widget | None = None + self._open_now_texts: list[bui.Widget] = [] + self._open_now_images: list[bui.Widget] = [] + self._watch_ad_button: bui.Widget | None = None + self._time_string_timer: bui.AppTimer | None = None + self._time_string_text: bui.Widget | None = None + self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = [] + self._prizeindex = -1 + self._prizesettxts: dict[int, list[bui.Widget]] = {} + self._prizesetimgs: dict[int, list[bui.Widget]] = {} + self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = ( + None + ) + + # The set of widgets we keep when doing a clear. + self._core_widgets: list[bui.Widget] = [] + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.8 + if uiscale is bui.UIScale.SMALL + else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_height = min(self._height - 120, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18 + + # Offset for stuff we want centered. + self._yoffs = 0.5 * self._height + ( + 220 if uiscale is bui.UIScale.SMALL else 190 + ) + + self._chest_yoffs = self._yoffs - 223 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility='menu_full', + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + # Tell the root-ui to stop updating toolbar values immediately; + # this allows it to run animations based on the results of our + # chest opening. + bui.root_ui_pause_updates() + self._root_ui_updates_paused = True + + self._title_text = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10), + ), + size=(0, 0), + text=bui.Lstr( + resource='chests.slotText', + subs=[('${NUM}', str(index + 1))], + ), + color=bui.app.ui_v1.title_color, + maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200, + scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1, + h_align='center', + v_align='center', + ) + self._core_widgets.append(self._title_text) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + btn = bui.buttonwidget( + parent=self._root_widget, + position=(50, self._yoffs - 44), + size=(60, 55), + scale=0.8, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + extra_touch_border_scale=2.0, + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + self._core_widgets.append(btn) + + # Note: Don't need to explicitly clean this up. Just not adding + # it to core_widgets so it will go away on next reset. + self._loadingspinner = bui.spinnerwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + size=48, + style='bomb', + ) + + self._infotext = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._yoffs - 200), + size=(0, 0), + text='', + maxwidth=700, + scale=0.8, + color=(0.6, 0.5, 0.6), + h_align='center', + v_align='center', + ) + self._core_widgets.append(self._infotext) + + plus = bui.app.plus + if plus is None: + self._error('Plus feature-set is not present.') + return + + if plus.accounts.primary is None: + self._error(bui.Lstr(resource='notSignedInText')) + return + + # Start by showing info/options for our target chest. Note that + # we always ask the server for these values even though we may + # have them through our appmode subscription which updates the + # chest UI. This is because the wait_for_connectivity() + # mechanism will often bring our window up a split second before + # the chest subscription receives its first values which would + # lead us to incorrectly think there is no chest there. If we + # want to optimize this in the future we could perhaps use local + # values only if there is a chest present in them. + assert not self._action_in_flight + self._action_in_flight = True + with plus.accounts.primary: + plus.cloud.send_message_cb( + bacommon.bs.ChestInfoMessage(chest_id=str(self._index)), + on_response=bui.WeakCall(self._on_chest_info_response), + ) + + def __del__(self) -> None: + + # Make sure UI updates are resumed if we haven't done so. + if self._root_ui_updates_paused: + bui.root_ui_resume_updates() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull anything we need from self out here; if we do it in the + # lambda we keep self alive which is bad. + index = self._index + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + index=index, transition=transition, origin_widget=origin_widget + ) + ) + + def _update_time_display(self, unlock_time: datetime.datetime) -> None: + # Once text disappears, kill our timer. + if not self._time_string_text: + self._time_string_timer = None + return + now = bui.utc_now_cloud() + secs_till_open = max(0.0, (unlock_time - now).total_seconds()) + tstr = ( + bui.timestring(secs_till_open, centi=False) + if secs_till_open > 0 + else '' + ) + bui.textwidget(edit=self._time_string_text, text=tstr) + + def _on_chest_info_response( + self, response: bacommon.bs.ChestInfoResponse | Exception + ) -> None: + assert self._action_in_flight # Should be us. + self._action_in_flight = False + + if isinstance(response, Exception): + self._error( + bui.Lstr(resource='internal.unableToCompleteTryAgainText'), + minor=True, + ) + return + + if response.chest is None: + self._show_about_chest_slots() + return + + assert response.user_tokens is not None + self._show_chest_actions(response.user_tokens, response.chest) + + def _on_chest_action_response( + self, response: bacommon.bs.ChestActionResponse | Exception + ) -> None: + assert self._action_in_flight # Should be us. + self._action_in_flight = False + + # Communication/local error: + if isinstance(response, Exception): + self._error( + bui.Lstr(resource='internal.unableToCompleteTryAgainText'), + minor=True, + ) + return + + # Server-side error: + if response.error is not None: + self._error(bui.Lstr(translate=('serverResponses', response.error))) + return + + # Show any bundled success message. + if response.success_msg is not None: + bui.screenmessage( + bui.Lstr(translate=('serverResponses', response.success_msg)), + color=(0, 1.0, 0), + ) + bui.getsound('cashRegister').play() + + # Show any bundled warning. + if response.warning is not None: + bui.screenmessage( + bui.Lstr(translate=('serverResponses', response.warning)), + color=(1, 0.5, 0), + ) + bui.getsound('error').play() + + # If we just paid for something, make a sound accordingly. + if bool(False): # Hmm maybe this feels odd. + if response.tokens_charged > 0: + bui.getsound('cashRegister').play() + + # If there's contents listed in the response, show them. + if response.contents is not None: + self._show_chest_contents(response) + else: + # Otherwise we're done here; just close out our UI. + self.main_window_back() + + def _show_chest_actions( + self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest + ) -> None: + """Show state for our chest.""" + # pylint: disable=too-many-locals + # pylint: disable=cyclic-import + from baclassic import ( + ClassicAppMode, + CHEST_APPEARANCE_DISPLAY_INFOS, + CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT, + ) + + plus = bui.app.plus + assert plus is not None + + # We expect to be run under classic app mode. + mode = bui.app.mode + if not isinstance(mode, ClassicAppMode): + self._error('Classic app mode not active.') + return + + self._reset() + + self._chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( + chest.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT + ) + + bui.textwidget( + edit=self._title_text, + text=bui.Lstr( + translate=('displayItemNames', chest.appearance.pretty_name) + ), + ) + + imgsize = 145 + bui.imagewidget( + parent=self._root_widget, + position=( + self._width * 0.5 - imgsize * 0.5, + # self._height - 223 + self._yoffs, + self._chest_yoffs, + ), + color=self._chestdisplayinfo.color, + size=(imgsize, imgsize), + texture=bui.gettexture(self._chestdisplayinfo.texclosed), + tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint), + tint_color=self._chestdisplayinfo.tint, + tint2_color=self._chestdisplayinfo.tint2, + ) + + # Store the prize-sets so we can display odds/etc. Sort them + # with largest weights first. + self._prizesets = sorted( + chest.prizesets, key=lambda s: s.weight, reverse=True + ) + + if chest.unlock_tokens > 0: + lsize = 30 + bui.imagewidget( + parent=self._root_widget, + position=( + self._width * 0.5 - imgsize * 0.4 - lsize * 0.5, + # self._height - 223 + 27.0 + self._yoffs, + self._chest_yoffs + 27.0, + ), + size=(lsize, lsize), + texture=bui.gettexture('lock'), + ) + + # Time string. + if chest.unlock_tokens != 0: + self._time_string_text = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + # self._height - 85 + self._yoffs + self._yoffs - 85, + ), + size=(0, 0), + text='', + maxwidth=700, + scale=0.6, + color=(0.6, 1.0, 0.6), + h_align='center', + v_align='center', + ) + self._update_time_display(chest.unlock_time) + self._time_string_timer = bui.AppTimer( + 1.0, + repeat=True, + call=bui.WeakCall(self._update_time_display, chest.unlock_time), + ) + + # Allow watching an ad IF the server tells us we can AND we have + # an ad ready to show. + show_ad_button = ( + chest.unlock_tokens > 0 + and chest.ad_allow + and plus.have_incentivized_ad() + ) + + bwidth = 130 + bheight = 90 + bposy = -330 if chest.unlock_tokens == 0 else -340 + hspace = 20 + boffsx = (hspace * -0.5 - bwidth * 0.5) if show_ad_button else 0.0 + + self._open_now_button = bui.buttonwidget( + parent=self._root_widget, + position=( + self._width * 0.5 - bwidth * 0.5 + boffsx, + self._yoffs + bposy, + ), + size=(bwidth, bheight), + label='', + button_type='square', + autoselect=True, + on_activate_call=bui.WeakCall( + self._open_press, user_tokens, chest.unlock_tokens + ), + enable_sound=False, + ) + self._open_now_images = [] + self._open_now_texts = [] + + iconsize = 50 + if chest.unlock_tokens == 0: + self._open_now_texts.append( + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='openText'), + position=( + self._width * 0.5 + boffsx, + self._yoffs + bposy + bheight * 0.5, + ), + color=(0, 1, 0), + draw_controller=self._open_now_button, + scale=0.7, + maxwidth=bwidth * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + ) + ) + else: + self._open_now_texts.append( + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='openNowText'), + position=( + self._width * 0.5 + boffsx, + self._yoffs + bposy + bheight * 1.15, + ), + maxwidth=bwidth * 0.8, + scale=0.7, + color=(0.7, 1, 0.7), + size=(0, 0), + h_align='center', + v_align='center', + ) + ) + self._open_now_images.append( + bui.imagewidget( + parent=self._root_widget, + size=(iconsize, iconsize), + position=( + self._width * 0.5 - iconsize * 0.5 + boffsx, + self._yoffs + bposy + bheight * 0.35, + ), + draw_controller=self._open_now_button, + texture=bui.gettexture('coin'), + ) + ) + self._open_now_texts.append( + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr( + resource='tokens.numTokensText', + subs=[('${COUNT}', str(chest.unlock_tokens))], + ), + position=( + self._width * 0.5 + boffsx, + self._yoffs + bposy + bheight * 0.25, + ), + scale=0.65, + color=(0, 1, 0), + draw_controller=self._open_now_button, + maxwidth=bwidth * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + ) + ) + self._open_now_spinner = bui.spinnerwidget( + parent=self._root_widget, + position=( + self._width * 0.5 + boffsx, + self._yoffs + bposy + 0.5 * bheight, + ), + visible=False, + ) + + if show_ad_button: + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='chests.reduceWaitText'), + position=( + self._width * 0.5 + hspace * 0.5 + bwidth * 0.5, + self._yoffs + bposy + bheight * 1.15, + ), + maxwidth=bwidth * 0.8, + scale=0.7, + color=(0.7, 1, 0.7), + size=(0, 0), + h_align='center', + v_align='center', + ) + self._watch_ad_button = bui.buttonwidget( + parent=self._root_widget, + position=( + self._width * 0.5 + hspace * 0.5, + self._yoffs + bposy, + ), + size=(bwidth, bheight), + label='', + button_type='square', + autoselect=True, + on_activate_call=bui.WeakCall(self._watch_ad_press), + enable_sound=False, + ) + bui.imagewidget( + parent=self._root_widget, + size=(iconsize, iconsize), + position=( + self._width * 0.5 + + hspace * 0.5 + + bwidth * 0.5 + - iconsize * 0.5, + self._yoffs + bposy + bheight * 0.35, + ), + draw_controller=self._watch_ad_button, + color=(1.5, 1.0, 2.0), + texture=bui.gettexture('tv'), + ) + # Note to self: AdMob requires rewarded ad usage + # specifically says 'Ad' in it. + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='watchAnAdText'), + position=( + self._width * 0.5 + hspace * 0.5 + bwidth * 0.5, + self._yoffs + bposy + bheight * 0.25, + ), + scale=0.65, + color=(0, 1, 0), + draw_controller=self._watch_ad_button, + maxwidth=bwidth * 0.8, + size=(0, 0), + h_align='center', + v_align='center', + ) + + self._show_odds(initial_highlighted_row=-1) + + def _highlight_odds_row(self, row: int, extra: bool = False) -> None: + + for rindex, imgs in self._prizesetimgs.items(): + opacity = ( + (0.9 if extra else 0.75) + if rindex == row + else (0.4 if extra else 0.5) + ) + for img in imgs: + if img: + bui.imagewidget(edit=img, opacity=opacity) + + for rindex, txts in self._prizesettxts.items(): + opacity = ( + (0.9 if extra else 0.75) + if rindex == row + else (0.4 if extra else 0.5) + ) + for txt in txts: + if txt: + bui.textwidget(edit=txt, color=(0.7, 0.65, 1, opacity)) + + def _show_odds( + self, + *, + initial_highlighted_row: int, + initial_highlighted_extra: bool = False, + ) -> None: + # pylint: disable=too-many-locals + xoffs = 110 + + totalweight = max(0.001, sum(t.weight for t in self._prizesets)) + + rowheight = 25 + totalheight = (len(self._prizesets) + 1) * rowheight + x = self._width * 0.5 + xoffs + y = self._yoffs - 150.0 + totalheight * 0.5 + + # Title. + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='chests.prizeOddsText'), + color=(0.7, 0.65, 1, 0.5), + flatness=1.0, + shadow=1.0, + position=(x, y), + scale=0.55, + size=(0, 0), + h_align='left', + v_align='center', + ) + y -= 5.0 + + prizesettxts: list[bui.Widget] + prizesetimgs: list[bui.Widget] + + def _mkicon(img: str) -> None: + iconsize = 20.0 + nonlocal x + nonlocal prizesetimgs + prizesetimgs.append( + bui.imagewidget( + parent=self._root_widget, + size=(iconsize, iconsize), + position=(x, y - iconsize * 0.5), + texture=bui.gettexture(img), + opacity=0.4, + ) + ) + x += iconsize + + def _mktxt(txt: str, advance: bool = True) -> None: + tscale = 0.45 + nonlocal x + nonlocal prizesettxts + prizesettxts.append( + bui.textwidget( + parent=self._root_widget, + text=txt, + flatness=1.0, + shadow=1.0, + position=(x, y), + scale=tscale, + size=(0, 0), + h_align='left', + v_align='center', + ) + ) + if advance: + x += (bui.get_string_width(txt, suppress_warning=True)) * tscale + + self._prizesettxts = {} + self._prizesetimgs = {} + + for i, p in enumerate(self._prizesets): + prizesettxts = self._prizesettxts.setdefault(i, []) + prizesetimgs = self._prizesetimgs.setdefault(i, []) + x = self._width * 0.5 + xoffs + y -= rowheight + percent = 100.0 * p.weight / totalweight + + # Show decimals only if we get very small percentages (looks + # better than rounding as '0%'). + percenttxt = ( + f'{percent:.2f}%:' + if percent < 0.095 + else ( + f'{percent:.1f}%:' + if percent < 0.95 + else f'{round(percent)}%:' + ) + ) + + # We advance manually here to keep values lined up + # (otherwise single digit percent rows don't line up with + # double digit ones). + _mktxt(percenttxt, advance=False) + x += 35.0 + + for item in p.contents: + x += 5.0 + if isinstance(item.item, bacommon.bs.TicketsDisplayItem): + _mktxt(str(item.item.count)) + _mkicon('tickets') + elif isinstance(item.item, bacommon.bs.TokensDisplayItem): + _mktxt(str(item.item.count)) + _mkicon('coin') + else: + # For other cases just fall back on text desc. + # + # Translate the wrapper description and apply any subs. + descfin = bui.Lstr( + translate=('serverResponses', item.description) + ).evaluate() + subs = ( + [] + if item.description_subs is None + else item.description_subs + ) + assert len(subs) % 2 == 0 # Should always be even. + for j in range(0, len(subs) - 1, 2): + descfin = descfin.replace(subs[j], subs[j + 1]) + _mktxt(descfin) + self._highlight_odds_row( + initial_highlighted_row, extra=initial_highlighted_extra + ) + + def _open_press(self, user_tokens: int, token_payment: int) -> None: + from bauiv1lib.gettokens import show_get_tokens_prompt + + bui.getsound('click01').play() + + # Allow only one in-flight action at once. + if self._action_in_flight: + bui.screenmessage( + bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + plus = bui.app.plus + assert plus is not None + + if plus.accounts.primary is None: + self._error(bui.Lstr(resource='notSignedInText')) + return + + # Offer to purchase tokens if they don't have enough. + if user_tokens < token_payment: + # Hack: We disable normal swish for the open button and it + # seems weird without a swish here, so explicitly do one. + bui.getsound('swish').play() + show_get_tokens_prompt() + return + + self._action_in_flight = True + with plus.accounts.primary: + plus.cloud.send_message_cb( + bacommon.bs.ChestActionMessage( + chest_id=str(self._index), + action=bacommon.bs.ChestActionMessage.Action.UNLOCK, + token_payment=token_payment, + ), + on_response=bui.WeakCall(self._on_chest_action_response), + ) + + # Convey that something is in progress. + if self._open_now_button: + bui.spinnerwidget(edit=self._open_now_spinner, visible=True) + for twidget in self._open_now_texts: + bui.textwidget(edit=twidget, color=(1, 1, 1, 0.2)) + for iwidget in self._open_now_images: + bui.imagewidget(edit=iwidget, opacity=0.2) + + def _watch_ad_press(self) -> None: + + bui.getsound('click01').play() + + # Allow only one in-flight action at once. + if self._action_in_flight: + bui.screenmessage( + bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + assert bui.app.classic is not None + + self._action_in_flight = True + bui.app.classic.ads.show_ad_2( + 'reduce_chest_wait', + on_completion_call=bui.WeakCall(self._watch_ad_complete), + ) + + # Convey that something is in progress. + if self._watch_ad_button: + bui.buttonwidget(edit=self._watch_ad_button, color=(0.4, 0.4, 0.4)) + + def _watch_ad_complete(self, actually_showed: bool) -> None: + + assert self._action_in_flight # Should be ad view. + self._action_in_flight = False + + if not actually_showed: + return + + # Allow only one in-flight action at once. + if self._action_in_flight: + bui.screenmessage( + bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + plus = bui.app.plus + assert plus is not None + + if plus.accounts.primary is None: + self._error(bui.Lstr(resource='notSignedInText')) + return + + self._action_in_flight = True + with plus.accounts.primary: + plus.cloud.send_message_cb( + bacommon.bs.ChestActionMessage( + chest_id=str(self._index), + action=bacommon.bs.ChestActionMessage.Action.AD, + token_payment=0, + ), + on_response=bui.WeakCall(self._on_chest_action_response), + ) + + def _reset(self) -> None: + """Clear all non-permanent widgets and clear infotext.""" + for widget in self._root_widget.get_children(): + if widget not in self._core_widgets: + widget.delete() + bui.textwidget(edit=self._infotext, text='', color=(1, 1, 1)) + + def _error(self, msg: str | bui.Lstr, minor: bool = False) -> None: + """Put ourself in an error state with a visible error message.""" + self._reset() + bui.textwidget( + edit=self._infotext, + text=msg, + color=(1, 0.5, 0.5) if minor else (1, 0, 0), + ) + + def _show_about_chest_slots(self) -> None: + # No-op if our ui is dead. + if not self._root_widget: + return + + self._reset() + bui.textwidget( + edit=self._infotext, + text=bui.Lstr(resource='chests.slotDescriptionText'), + color=(1, 1, 1), + ) + + def _show_chest_contents( + self, response: bacommon.bs.ChestActionResponse + ) -> None: + # pylint: disable=too-many-locals + + from baclassic import show_display_item + + # No-op if our ui is dead. + if not self._root_widget: + return + + assert response.contents is not None + + # Insert test items for testing. + if bool(False): + response.contents += [ + bacommon.bs.DisplayItemWrapper.for_display_item( + bacommon.bs.TestDisplayItem() + ) + ] + + tincr = 0.4 + tendoffs = tincr * 4.0 + toffs = 0.0 + + bui.getsound('revUp').play(volume=2.0) + + # Show nothing but the chest icon and animate it shaking. + self._reset() + imgsize = 145 + assert self._chestdisplayinfo is not None + img = bui.imagewidget( + parent=self._root_widget, + color=self._chestdisplayinfo.color, + texture=bui.gettexture(self._chestdisplayinfo.texclosed), + tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint), + tint_color=self._chestdisplayinfo.tint, + tint2_color=self._chestdisplayinfo.tint2, + ) + + def _set_img(x: float, scale: float) -> None: + if not img: + return + bui.imagewidget( + edit=img, + position=( + self._width * 0.5 - imgsize * scale * 0.5 + x, + self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5, + ), + size=(imgsize * scale, imgsize * scale), + ) + + # Set initial place. + _set_img(0.0, 1.0) + + sign = 1.0 + while toffs < tendoffs: + toffs += 0.03 * random.uniform(0.5, 1.5) + sign = -sign + bui.apptimer( + toffs, + bui.Call( + _set_img, + x=( + 20.0 + * random.uniform(0.3, 1.0) + * math.pow(toffs / tendoffs, 2.0) + * sign + ), + scale=1.0 - 0.2 * math.pow(toffs / tendoffs, 2.0), + ), + ) + + xspacing = 100 + xoffs = -0.5 * (len(response.contents) - 1) * xspacing + bui.apptimer( + toffs - 0.2, lambda: bui.getsound('corkPop2').play(volume=4.0) + ) + # Play a variety of voice sounds. + + # We keep a global list of voice options which we randomly pull + # from and refill when empty. This ensures everything gets + # played somewhat frequently and minimizes annoying repeats. + global _g_open_voices # pylint: disable=global-statement + if not _g_open_voices: + _g_open_voices = [ + (0.3, 'woo3', 2.5), + (0.1, 'gasp', 1.3), + (0.2, 'woo2', 2.0), + (0.2, 'wow', 2.0), + (0.2, 'kronk2', 2.0), + (0.2, 'mel03', 2.0), + (0.2, 'aww', 2.0), + (0.4, 'nice', 2.0), + (0.3, 'yeah', 1.5), + (0.2, 'woo', 1.0), + (0.5, 'ooh', 0.8), + ] + + voicetimeoffs, voicename, volume = _g_open_voices.pop( + random.randrange(len(_g_open_voices)) + ) + bui.apptimer( + toffs + voicetimeoffs, + lambda: bui.getsound(voicename).play(volume=volume), + ) + + toffsopen = toffs + bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening)) + toffs += tincr * 1.0 + width = xspacing * 0.95 + + for item in response.contents: + toffs += tincr + bui.apptimer( + toffs - 0.1, lambda: bui.getsound('cashRegister').play() + ) + bui.apptimer( + toffs, + strict_partial( + show_display_item, + item, + self._root_widget, + pos=( + self._width * 0.5 + xoffs, + self._yoffs - 250.0, + ), + width=width, + ), + ) + xoffs += xspacing + toffs += tincr + bui.apptimer(toffs, bui.WeakCall(self._show_done_button)) + + self._show_odds(initial_highlighted_row=-1) + + # Store this for later + self._prizeindex = response.prizeindex + + # The final result was already randomly selected on the server, + # but we want to give the illusion of randomness here, so cycle + # through highlighting our options and stop on the winner when + # the chest opens. To do this, we start at the end at the prize + # and work backwards setting timers. + if self._prizesets: + toffs2 = toffsopen - 0.01 + amt = 0.02 + i = self._prizeindex + while toffs2 > 0.0: + bui.apptimer( + toffs2, + bui.WeakCall(self._highlight_odds_row, i), + ) + toffs2 -= amt + amt *= 1.05 * random.uniform(0.9, 1.1) + i = (i - 1) % len(self._prizesets) + + def _show_chest_opening(self) -> None: + + # No-op if our ui is dead. + if not self._root_widget: + return + + self._reset() + imgsize = 145 + bui.getsound('hiss').play() + assert self._chestdisplayinfo is not None + img = bui.imagewidget( + parent=self._root_widget, + color=self._chestdisplayinfo.color, + texture=bui.gettexture(self._chestdisplayinfo.texopen), + tint_texture=bui.gettexture(self._chestdisplayinfo.texopentint), + tint_color=self._chestdisplayinfo.tint, + tint2_color=self._chestdisplayinfo.tint2, + ) + tincr = 0.8 + tendoffs = tincr * 2.0 + toffs = 0.0 + + def _set_img(x: float, scale: float) -> None: + if not img: + return + bui.imagewidget( + edit=img, + position=( + self._width * 0.5 - imgsize * scale * 0.5 + x, + self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5, + ), + size=(imgsize * scale, imgsize * scale), + ) + + # Set initial place. + _set_img(0.0, 1.0) + + sign = 1.0 + while toffs < tendoffs: + toffs += 0.03 * random.uniform(0.5, 1.5) + sign = -sign + # Note: we speed x along here (multing toffs) so position + # comes to rest before scale. + bui.apptimer( + toffs, + bui.Call( + _set_img, + x=( + 1.0 + * random.uniform(0.3, 1.0) + * ( + 1.0 + - math.pow(min(1.0, 3.0 * toffs / tendoffs), 2.0) + ) + * sign + ), + scale=1.0 - 0.1 * math.pow(toffs / tendoffs, 0.5), + ), + ) + + self._show_odds( + initial_highlighted_row=self._prizeindex, + initial_highlighted_extra=True, + ) + + def _show_done_button(self) -> None: + # No-op if our ui is dead. + if not self._root_widget: + return + + bwidth = 200 + bheight = 60 + + btn = bui.buttonwidget( + parent=self._root_widget, + position=( + self._width * 0.5 - bwidth * 0.5, + self._yoffs - 350, + ), + size=(bwidth, bheight), + label=bui.Lstr(resource='doneText'), + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, start_button=btn) + + +# Slight hack: we define window different classes for our different +# chest slots so that the default UI behavior is to replace each other +# when different ones are pressed. If they are all the same window class +# then the default behavior for such presses is to toggle the existing +# one back off. + + +class ChestWindow0(ChestWindow): + """Child class of ChestWindow for slighty hackish reasons.""" + + +class ChestWindow1(ChestWindow): + """Child class of ChestWindow for slighty hackish reasons.""" + + +class ChestWindow2(ChestWindow): + """Child class of ChestWindow for slighty hackish reasons.""" + + +class ChestWindow3(ChestWindow): + """Child class of ChestWindow for slighty hackish reasons.""" diff --git a/dist/ba_data/python/bauiv1lib/colorpicker.py b/dist/ba_data/python/bauiv1lib/colorpicker.py index 2896427..622f431 100644 --- a/dist/ba_data/python/bauiv1lib/colorpicker.py +++ b/dist/ba_data/python/bauiv1lib/colorpicker.py @@ -12,6 +12,8 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Sequence +REQUIRE_PRO = False + class ColorPicker(PopupWindow): """A popup UI to select from a set of colors. @@ -23,6 +25,7 @@ class ColorPicker(PopupWindow): self, parent: bui.Widget, position: tuple[float, float], + *, initial_color: Sequence[float] = (1.0, 1.0, 1.0), delegate: Any = None, scale: float | None = None, @@ -105,9 +108,8 @@ class ColorPicker(PopupWindow): on_activate_call=bui.WeakCall(self._select_other), ) - # Custom colors are limited to pro currently. assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): bui.imagewidget( parent=self.root_widget, position=(50, 12), @@ -137,7 +139,7 @@ class ColorPicker(PopupWindow): # Requires pro. assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): purchase.PurchaseWindow(items=['pro']) self._transition_out() return @@ -183,6 +185,7 @@ class ColorPickerExact(PopupWindow): self, parent: bui.Widget, position: tuple[float, float], + *, initial_color: Sequence[float] = (1.0, 1.0, 1.0), delegate: Any = None, scale: float | None = None, @@ -245,7 +248,6 @@ class ColorPickerExact(PopupWindow): editable=True, maxwidth=70, allow_clear_button=False, - force_internal_editing=True, glow_type='uniform', ) diff --git a/dist/ba_data/python/bauiv1lib/config.py b/dist/ba_data/python/bauiv1lib/config.py index 337a25f..fd46303 100644 --- a/dist/ba_data/python/bauiv1lib/config.py +++ b/dist/ba_data/python/bauiv1lib/config.py @@ -28,6 +28,7 @@ class ConfigCheckBox: configkey: str, position: tuple[float, float], size: tuple[float, float], + *, displayname: str | bui.Lstr | None = None, scale: float | None = None, maxwidth: float | None = None, @@ -85,6 +86,7 @@ class ConfigNumberEdit: parent: bui.Widget, configkey: str, position: tuple[float, float], + *, minval: float = 0.0, maxval: float = 100.0, increment: float = 1.0, diff --git a/dist/ba_data/python/bauiv1lib/configerror.py b/dist/ba_data/python/bauiv1lib/configerror.py deleted file mode 100644 index c64a31d..0000000 --- a/dist/ba_data/python/bauiv1lib/configerror.py +++ /dev/null @@ -1,80 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI for dealing with broken config files.""" - -from __future__ import annotations - -import bauiv1 as bui - - -class ConfigErrorWindow(bui.Window): - """Window for dealing with a broken config.""" - - def __init__(self) -> None: - self._config_file_path = bui.app.env.config_file_path - width = 800 - super().__init__( - bui.containerwidget(size=(width, 400), transition='in_right') - ) - padding = 20 - bui.textwidget( - parent=self._root_widget, - position=(padding, 220 + 60), - size=(width - 2 * padding, 100 - 2 * padding), - h_align='center', - v_align='top', - scale=0.73, - text=( - f'Error reading {bui.appnameupper()} config file' - ':\n\n\nCheck the console' - ' (press ~ twice) for details.\n\nWould you like to quit and' - ' try to fix it by hand\nor overwrite it with defaults?\n\n' - '(high scores, player profiles, etc will be lost if you' - ' overwrite)' - ), - ) - bui.textwidget( - parent=self._root_widget, - position=(padding, 198 + 60), - size=(width - 2 * padding, 100 - 2 * padding), - h_align='center', - v_align='top', - scale=0.5, - text=self._config_file_path, - ) - quit_button = bui.buttonwidget( - parent=self._root_widget, - position=(35, 30), - size=(240, 54), - label='Quit and Edit', - on_activate_call=self._quit, - ) - bui.buttonwidget( - parent=self._root_widget, - position=(width - 370, 30), - size=(330, 54), - label='Overwrite with Defaults', - on_activate_call=self._defaults, - ) - bui.containerwidget( - edit=self._root_widget, - cancel_button=quit_button, - selected_child=quit_button, - ) - - def _quit(self) -> None: - bui.apptimer(0.001, self._edit_and_quit) - bui.lock_all_input() - - def _edit_and_quit(self) -> None: - bui.open_file_externally(self._config_file_path) - bui.apptimer(0.1, bui.quit) - - def _defaults(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.getsound('gunCocking').play() - bui.screenmessage('settings reset.', color=(1, 1, 0)) - - # At this point settings are already set; lets just commit them - # to disk. - bui.commit_app_config(force=True) diff --git a/dist/ba_data/python/bauiv1lib/confirm.py b/dist/ba_data/python/bauiv1lib/confirm.py index 394f2fe..3aff09d 100644 --- a/dist/ba_data/python/bauiv1lib/confirm.py +++ b/dist/ba_data/python/bauiv1lib/confirm.py @@ -22,6 +22,7 @@ class ConfirmWindow: action: Callable[[], Any] | None = None, width: float = 360.0, height: float = 100.0, + *, cancel_button: bool = True, cancel_is_selected: bool = False, color: tuple[float, float, float] = (1, 1, 1), @@ -61,7 +62,7 @@ class ConfirmWindow: toolbar_visibility='menu_minimal_no_back', parent=bui.get_special_widget('overlay_stack'), scale=( - 2.1 + 1.9 if uiscale is bui.UIScale.SMALL else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ), diff --git a/dist/ba_data/python/bauiv1lib/connectivity.py b/dist/ba_data/python/bauiv1lib/connectivity.py index 41062f8..8d52609 100644 --- a/dist/ba_data/python/bauiv1lib/connectivity.py +++ b/dist/ba_data/python/bauiv1lib/connectivity.py @@ -4,7 +4,6 @@ from __future__ import annotations -import time from typing import TYPE_CHECKING import bauiv1 as bui @@ -49,12 +48,6 @@ class WaitForConnectivityWindow(bui.Window): self._on_cancel = on_cancel self._width = 650 self._height = 300 - self._infos: list[str | bui.Lstr] = [ - 'This can take a few moments, especially on first launch.', - 'Make sure your internet connection is working.', - ] - self._last_info_switch_time = time.monotonic() - self._info_index = 0 super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), @@ -64,26 +57,36 @@ class WaitForConnectivityWindow(bui.Window): ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.65), + position=(self._width * 0.5, self._height * 0.7), size=(0, 0), scale=1.2, h_align='center', v_align='center', - text='Locating nearest regional servers...', + text=bui.Lstr(resource='internal.connectingToPartyText'), maxwidth=self._width * 0.9, ) + + self._spinner = bui.spinnerwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.54), + style='bomb', + size=48, + ) + self._info_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.45), + position=(self._width * 0.5, self._height * 0.4), size=(0, 0), - color=(0.7, 0.6, 0.7), + color=(0.6, 0.5, 0.6), flatness=1.0, - scale=0.8, + shadow=0.0, + scale=0.75, h_align='center', v_align='center', - text=self._infos[0], + text='', maxwidth=self._width * 0.9, ) + self._info_text_str = '' cancel_button = bui.buttonwidget( parent=self._root_widget, autoselect=True, @@ -98,7 +101,6 @@ class WaitForConnectivityWindow(bui.Window): ) def _update(self) -> None: - now = time.monotonic() plus = bui.app.plus assert plus is not None @@ -107,16 +109,29 @@ class WaitForConnectivityWindow(bui.Window): self._connected() return - if now - self._last_info_switch_time > 5.0: - self._info_index = (self._info_index + 1) % len(self._infos) - bui.textwidget( - edit=self._info_text, text=self._infos[self._info_index] - ) - self._last_info_switch_time = now + # Show what connectivity is up to if we don't have any published + # zone-pings yet (or if we do but there's no transport state to + # show yet). + if not bui.app.net.zone_pings or not bui.app.net.transport_state: + infotext = bui.app.net.connectivity_state + else: + infotext = bui.app.net.transport_state + if infotext != self._info_text_str: + self._info_text_str = infotext + bui.textwidget(edit=self._info_text, text=infotext) def _connected(self) -> None: if not self._root_widget or self._root_widget.transitioning_out: return + + # Show 'connected.' and kill the spinner for the brief moment + # we're visible on our way out. + bui.textwidget( + edit=self._info_text, text=bui.Lstr(resource='remote_app.connected') + ) + if self._spinner: + self._spinner.delete() + bui.containerwidget( edit=self._root_widget, transition=('out_scale'), diff --git a/dist/ba_data/python/bauiv1lib/continues.py b/dist/ba_data/python/bauiv1lib/continues.py deleted file mode 100644 index 366fcbb..0000000 --- a/dist/ba_data/python/bauiv1lib/continues.py +++ /dev/null @@ -1,256 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Provides a popup window to continue a game.""" - -from __future__ import annotations - -import weakref -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any, Callable - - import bascenev1 as bs - - -class ContinuesWindow(bui.Window): - """A window to continue a game.""" - - def __init__( - self, - activity: bs.Activity, - cost: int, - continue_call: Callable[[], Any], - cancel_call: Callable[[], Any], - ): - assert bui.app.classic is not None - self._activity = weakref.ref(activity) - self._cost = cost - self._continue_call = continue_call - self._cancel_call = cancel_call - self._start_count = self._count = 20 - self._width = 300 - self._height = 200 - self._transitioning_out = False - super().__init__( - bui.containerwidget( - size=(self._width, self._height), - background=False, - toolbar_visibility='menu_currency', - transition='in_scale', - scale=1.5, - ) - ) - txt = ( - bui.Lstr(resource='continuePurchaseText') - .evaluate() - .split('${PRICE}') - ) - t_left = txt[0] - t_left_width = bui.get_string_width(t_left, suppress_warning=True) - t_price = bui.charstr(bui.SpecialChar.TICKET) + str(self._cost) - t_price_width = bui.get_string_width(t_price, suppress_warning=True) - t_right = txt[-1] - t_right_width = bui.get_string_width(t_right, suppress_warning=True) - width_total_half = (t_left_width + t_price_width + t_right_width) * 0.5 - - bui.textwidget( - parent=self._root_widget, - text=t_left, - flatness=1.0, - shadow=1.0, - size=(0, 0), - h_align='left', - v_align='center', - position=(self._width * 0.5 - width_total_half, self._height - 30), - ) - bui.textwidget( - parent=self._root_widget, - text=t_price, - flatness=1.0, - shadow=1.0, - color=(0.2, 1.0, 0.2), - size=(0, 0), - position=( - self._width * 0.5 - width_total_half + t_left_width, - self._height - 30, - ), - h_align='left', - v_align='center', - ) - bui.textwidget( - parent=self._root_widget, - text=t_right, - flatness=1.0, - shadow=1.0, - size=(0, 0), - h_align='left', - v_align='center', - position=( - self._width * 0.5 - - width_total_half - + t_left_width - + t_price_width - + 5, - self._height - 30, - ), - ) - - self._tickets_text_base: str | None - self._tickets_text: bui.Widget | None - if not bui.app.ui_v1.use_toolbars: - self._tickets_text_base = bui.Lstr( - resource='getTicketsWindow.youHaveShortText', - fallback_resource='getTicketsWindow.youHaveText', - ).evaluate() - self._tickets_text = bui.textwidget( - parent=self._root_widget, - text='', - flatness=1.0, - color=(0.2, 1.0, 0.2), - shadow=1.0, - position=( - self._width * 0.5 + width_total_half, - self._height - 50, - ), - size=(0, 0), - scale=0.35, - h_align='right', - v_align='center', - ) - else: - self._tickets_text_base = None - self._tickets_text = None - - self._counter_text = bui.textwidget( - parent=self._root_widget, - text=str(self._count), - color=(0.7, 0.7, 0.7), - scale=1.2, - size=(0, 0), - big=True, - position=(self._width * 0.5, self._height - 80), - flatness=1.0, - shadow=1.0, - h_align='center', - v_align='center', - ) - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - position=(30, 30), - size=(120, 50), - label=bui.Lstr(resource='endText', fallback_resource='cancelText'), - autoselect=True, - enable_sound=False, - on_activate_call=self._on_cancel_press, - ) - self._continue_button = bui.buttonwidget( - parent=self._root_widget, - label=bui.Lstr(resource='continueText'), - autoselect=True, - position=(self._width - 130, 30), - size=(120, 50), - on_activate_call=self._on_continue_press, - ) - bui.containerwidget( - edit=self._root_widget, - cancel_button=self._cancel_button, - start_button=self._continue_button, - selected_child=self._cancel_button, - ) - - self._counting_down = True - self._countdown_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._tick), repeat=True - ) - - # If there is foreground activity, suspend it. - bui.app.classic.pause() - self._tick() - - def __del__(self) -> None: - # If there is suspended foreground activity, resume it. - assert bui.app.classic is not None - bui.app.classic.resume() - - def _tick(self) -> None: - plus = bui.app.plus - assert plus is not None - - # if our target activity is gone or has ended, go away - activity = self._activity() - if activity is None or activity.has_ended(): - self._on_cancel() - return - - if plus.get_v1_account_state() == 'signed_in': - sval = bui.charstr(bui.SpecialChar.TICKET) + str( - plus.get_v1_account_ticket_count() - ) - else: - sval = '?' - if self._tickets_text is not None: - assert self._tickets_text_base is not None - bui.textwidget( - edit=self._tickets_text, - text=self._tickets_text_base.replace('${COUNT}', sval), - ) - - if self._counting_down: - self._count -= 1 - bui.getsound('tick').play() - if self._count <= 0: - self._on_cancel() - else: - bui.textwidget(edit=self._counter_text, text=str(self._count)) - - def _on_cancel_press(self) -> None: - # disallow for first second - if self._start_count - self._count < 2: - bui.getsound('error').play() - else: - self._on_cancel() - - def _on_continue_press(self) -> None: - from bauiv1lib import gettickets - - plus = bui.app.plus - assert plus is not None - - # Disallow for first second. - if self._start_count - self._count < 2: - bui.getsound('error').play() - else: - # If somehow we got signed out... - if plus.get_v1_account_state() != 'signed_in': - bui.screenmessage( - bui.Lstr(resource='notSignedInText'), color=(1, 0, 0) - ) - bui.getsound('error').play() - return - - # If it appears we don't have enough tickets, offer to buy more. - tickets = plus.get_v1_account_ticket_count() - if tickets < self._cost: - # FIXME: Should we start the timer back up again after? - self._counting_down = False - bui.textwidget(edit=self._counter_text, text='') - bui.getsound('error').play() - gettickets.show_get_tickets_prompt() - return - if not self._transitioning_out: - bui.getsound('swish').play() - self._transitioning_out = True - bui.containerwidget( - edit=self._root_widget, transition='out_scale' - ) - self._continue_call() - - def _on_cancel(self) -> None: - if not self._transitioning_out: - bui.getsound('swish').play() - self._transitioning_out = True - bui.containerwidget(edit=self._root_widget, transition='out_scale') - self._cancel_call() diff --git a/dist/ba_data/python/bauiv1lib/coop/browser.py b/dist/ba_data/python/bauiv1lib/coop/browser.py index 81a936e..22411f7 100644 --- a/dist/ba_data/python/bauiv1lib/coop/browser.py +++ b/dist/ba_data/python/bauiv1lib/coop/browser.py @@ -7,12 +7,9 @@ from __future__ import annotations import logging -from threading import Thread -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override + -from bauiv1lib.store.button import StoreButton -from bauiv1lib.league.rankbutton import LeagueRankButton -from bauiv1lib.store.browser import StoreBrowserWindow import bauiv1 as bui if TYPE_CHECKING: @@ -20,8 +17,10 @@ if TYPE_CHECKING: from bauiv1lib.coop.tournamentbutton import TournamentButton +HARD_REQUIRES_PRO = False -class CoopBrowserWindow(bui.Window): + +class CoopBrowserWindow(bui.MainWindow): """Window for browsing co-op levels/games/etc.""" def __init__( @@ -30,6 +29,7 @@ class CoopBrowserWindow(bui.Window): origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements + # pylint: disable=too-many-locals # pylint: disable=cyclic-import plus = bui.app.plus @@ -37,17 +37,19 @@ class CoopBrowserWindow(bui.Window): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - Thread(target=self._preload_modules).start() + bui.app.threadpool.submit_no_wait(self._preload_modules) bui.set_analytics_screen('Coop Window') app = bui.app - assert app.classic is not None + classic = app.classic + assert classic is not None cfg = app.config # Quick note to players that tourneys won't work in ballistica - # core builds. (need to split the word so it won't get subbed out) - if 'ballistica' + 'kit' == bui.appname(): + # core builds. (need to split the word so it won't get subbed + # out) + if 'ballistica' + 'kit' == bui.appname() and bui.do_once(): bui.apptimer( 1.0, lambda: bui.screenmessage( @@ -56,16 +58,6 @@ class CoopBrowserWindow(bui.Window): ), ) - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - # Try to recreate the same number of buttons we had last time so our # re-selection code works. self._tournament_button_count = app.config.get('Tournament Rows', 0) @@ -83,18 +75,15 @@ class CoopBrowserWindow(bui.Window): self._hard_button_lock_image: bui.Widget | None = None self._campaign_percent_text: bui.Widget | None = None - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - self._width = 1520 if uiscale is bui.UIScale.SMALL else 1120 - self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0 + uiscale = app.ui_v1.uiscale + self._width = 1600 if uiscale is bui.UIScale.SMALL else 1120 self._height = ( - 657 + 1200 if uiscale is bui.UIScale.SMALL else 730 if uiscale is bui.UIScale.MEDIUM else 800 ) - app.ui_v1.set_main_menu_location('Coop Select') self._r = 'coopSelectWindow' - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 + top_extra = 0 if uiscale is bui.UIScale.SMALL else 0 self._tourney_data_up_to_date = False @@ -104,7 +93,8 @@ class CoopBrowserWindow(bui.Window): if ( self._campaign_difficulty == 'hard' - and not app.classic.accounts.have_pro_options() + and HARD_REQUIRES_PRO + and not classic.accounts.have_pro_options() ): plus.add_v1_account_transaction( { @@ -115,103 +105,59 @@ class CoopBrowserWindow(bui.Window): ) self._campaign_difficulty = 'easy' + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 120, screensize[0] / scale) + target_height = min(self._height - 120, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 40 + self._scroll_bottom = yoffs - 70 - self._scroll_height + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height + top_extra), toolbar_visibility='menu_full', - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -15) - if uiscale is bui.UIScale.SMALL - else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - transition=transition, - scale=( - 1.2 - if uiscale is bui.UIScale.SMALL - else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75 - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: - self._back_button = None + if uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) else: self._back_button = bui.buttonwidget( parent=self._root_widget, - position=( - 75 + x_inset, - self._height - - 87 - - (4 if uiscale is bui.UIScale.SMALL else 0), - ), - size=(120, 60), + position=(75, yoffs - 48.0), + size=(60, 50), scale=1.2, autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) - - self._league_rank_button: LeagueRankButton | None - self._store_button: StoreButton | None - self._store_button_widget: bui.Widget | None - self._league_rank_button_widget: bui.Widget | None - - if not app.ui_v1.use_toolbars: - prb = self._league_rank_button = LeagueRankButton( - parent=self._root_widget, - position=( - self._width - (282 + x_inset), - self._height - - 85 - - (4 if uiscale is bui.UIScale.SMALL else 0), - ), - size=(100, 60), - color=(0.4, 0.4, 0.9), - textcolor=(0.9, 0.9, 2.0), - scale=1.05, - on_activate_call=bui.WeakCall(self._switch_to_league_rankings), + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button ) - self._league_rank_button_widget = prb.get_button() - - sbtn = self._store_button = StoreButton( - parent=self._root_widget, - position=( - self._width - (170 + x_inset), - self._height - - 85 - - (4 if uiscale is bui.UIScale.SMALL else 0), - ), - size=(100, 60), - color=(0.6, 0.4, 0.7), - show_tickets=True, - button_type='square', - sale_scale=0.85, - textcolor=(0.9, 0.7, 1.0), - scale=1.05, - on_activate_call=bui.WeakCall(self._switch_to_score, None), - ) - self._store_button_widget = sbtn.get_button() - bui.widget( - edit=self._back_button, - right_widget=self._league_rank_button_widget, - ) - bui.widget( - edit=self._league_rank_button_widget, - left_widget=self._back_button, - ) - else: - self._league_rank_button = None - self._store_button = None - self._store_button_widget = None - self._league_rank_button_widget = None - - # Move our corner buttons dynamically to keep them out of the way of - # the party icon :-( - self._update_corner_button_positions() - self._update_corner_button_positions_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True - ) self._last_tournament_query_time: float | None = None self._last_tournament_query_response_time: float | None = None @@ -224,14 +170,18 @@ class CoopBrowserWindow(bui.Window): 'Selected Coop Custom Level', None ) + if uiscale is bui.UIScale.SMALL: + tmaxw = 130 if bui.get_virtual_screen_size()[0] < 1320 else 175 + else: + tmaxw = 300 + # Don't want initial construction affecting our last-selected. self._do_selection_callbacks = False - v = self._height - 95 - txt = bui.textwidget( + bui.textwidget( parent=self._root_widget, position=( self._width * 0.5, - v + 40 - (0 if uiscale is bui.UIScale.SMALL else 0), + yoffs - (50 if uiscale is bui.UIScale.SMALL else 24), ), size=(0, 0), text=bui.Lstr( @@ -240,55 +190,66 @@ class CoopBrowserWindow(bui.Window): ), h_align='center', color=app.ui_v1.title_color, - scale=1.5, - maxwidth=500, + scale=0.85 if uiscale is bui.UIScale.SMALL else 1.5, + maxwidth=tmaxw, v_align='center', ) - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: - bui.textwidget(edit=txt, text='') - - if self._back_button is not None: - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 50), - position=( - 75 + x_inset, - self._height - - 87 - - (4 if uiscale is bui.UIScale.SMALL else 0) - + 6, - ), - label=bui.charstr(bui.SpecialChar.BACK), - ) - self._selected_row = cfg.get('Selected Coop Row', None) - self._scroll_width = self._width - (130 + 2 * x_inset) - self._scroll_height = self._height - ( - 190 - if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars - else 160 - ) - self._subcontainerwidth = 800.0 self._subcontainerheight = 1400.0 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, - position=( - (65 + x_inset, 120) - if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars - else (65 + x_inset, 70) - ), size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), simple_culling_v=10.0, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, + border_opacity=0.4, ) + + if uiscale is bui.UIScale.SMALL: + blotchwidth = 500.0 + blotchheight = 200.0 + bimg = bui.imagewidget( + parent=self._root_widget, + texture=bui.gettexture('uiAtlas'), + mesh_transparent=bui.getmesh('windowBGBlotch'), + position=( + self._width * 0.5 + - self._scroll_width * 0.5 + + 60.0 + - blotchwidth * 0.5, + self._scroll_bottom - blotchheight * 0.5, + ), + size=(blotchwidth, blotchheight), + color=(0.4, 0.37, 0.49), + # color=(1, 0, 0), + ) + bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + bimg = bui.imagewidget( + parent=self._root_widget, + texture=bui.gettexture('uiAtlas'), + mesh_transparent=bui.getmesh('windowBGBlotch'), + position=( + self._width * 0.5 + + self._scroll_width * 0.5 + - 60.0 + - blotchwidth * 0.5, + self._scroll_bottom - blotchheight * 0.5, + ), + size=(blotchwidth, blotchheight), + color=(0.4, 0.37, 0.49), + # color=(1, 0, 0), + ) + bui.widget(edit=bimg, depth_range=(0.9, 1.0)) + self._subcontainer: bui.Widget | None = None # Take note of our account state; we'll refresh later if this changes. @@ -309,17 +270,17 @@ class CoopBrowserWindow(bui.Window): # each one of those tournaments, go ahead and display it as a # starting point. if ( - app.classic.accounts.account_tournament_list is not None - and app.classic.accounts.account_tournament_list[0] + classic.accounts.account_tournament_list is not None + and classic.accounts.account_tournament_list[0] == plus.get_v1_account_state_num() and all( - t_id in app.classic.accounts.tournament_info - for t_id in app.classic.accounts.account_tournament_list[1] + t_id in classic.accounts.tournament_info + for t_id in classic.accounts.account_tournament_list[1] ) ): tourney_data = [ - app.classic.accounts.tournament_info[t_id] - for t_id in app.classic.accounts.account_tournament_list[1] + classic.accounts.tournament_info[t_id] + for t_id in classic.accounts.account_tournament_list[1] ] self._update_for_data(tourney_data) @@ -329,37 +290,24 @@ class CoopBrowserWindow(bui.Window): ) self._update() - def _update_corner_button_positions(self) -> None: - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - offs = ( - -55 - if uiscale is bui.UIScale.SMALL and bui.is_party_icon_visible() - else 0 + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) ) - if self._league_rank_button is not None: - self._league_rank_button.set_position( - ( - self._width - 282 + offs - self._x_inset, - self._height - - 85 - - (4 if uiscale is bui.UIScale.SMALL else 0), - ) - ) - if self._store_button is not None: - self._store_button.set_position( - ( - self._width - 170 + offs - self._x_inset, - self._height - - 85 - - (4 if uiscale is bui.UIScale.SMALL else 0), - ) - ) - # noinspection PyUnresolvedReferences + @override + def on_main_window_close(self) -> None: + self._save_state() + @staticmethod def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" + # pylint: disable=cyclic-import import bauiv1lib.purchase as _unused1 import bauiv1lib.coop.gamebutton as _unused2 import bauiv1lib.confirm as _unused3 @@ -382,9 +330,9 @@ class CoopBrowserWindow(bui.Window): cur_time = bui.apptime() - # If its been a while since we got a tournament update, consider the - # data invalid (prevents us from joining tournaments if our internet - # connection goes down for a while). + # If its been a while since we got a tournament update, consider + # the data invalid (prevents us from joining tournaments if our + # internet connection goes down for a while). if ( self._last_tournament_query_response_time is None or bui.apptime() - self._last_tournament_query_response_time @@ -392,8 +340,10 @@ class CoopBrowserWindow(bui.Window): ): self._tourney_data_up_to_date = False - # If our account state has changed, do a full request. + # If our account login state has changed, do a + # full request. account_state_num = plus.get_v1_account_state_num() + if account_state_num != self._account_state_num: self._account_state_num = account_state_num self._save_state() @@ -459,15 +409,21 @@ class CoopBrowserWindow(bui.Window): bui.imagewidget( edit=self._hard_button_lock_image, opacity=( - 0.0 if bui.app.classic.accounts.have_pro_options() else 1.0 + 0.0 + if ( + (not HARD_REQUIRES_PRO) + or bui.app.classic.accounts.have_pro_options() + ) + else 1.0 ), ) except Exception: logging.exception('Error updating campaign lock.') def _update_for_data(self, data: list[dict[str, Any]] | None) -> None: - # If the number of tournaments or challenges in the data differs from - # our current arrangement, refresh with the new number. + + # If the number of tournaments or challenges in the data differs + # from our current arrangement, refresh with the new number. if (data is None and self._tournament_button_count != 0) or ( data is not None and (len(data) != self._tournament_button_count) ): @@ -519,6 +475,7 @@ class CoopBrowserWindow(bui.Window): if difficulty != self._campaign_difficulty: if ( difficulty == 'hard' + and HARD_REQUIRES_PRO and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) @@ -541,6 +498,7 @@ class CoopBrowserWindow(bui.Window): def _refresh_campaign_row(self) -> None: # pylint: disable=too-many-locals + # pylint: disable=too-many-statements # pylint: disable=cyclic-import from bauiv1lib.coop.gamebutton import GameButton @@ -660,14 +618,19 @@ class CoopBrowserWindow(bui.Window): bui.widget(edit=campaign_buttons[0], left_widget=self._easy_button) - if self._back_button is not None: - bui.widget(edit=self._easy_button, up_widget=self._back_button) - for btn in campaign_buttons: - bui.widget( - edit=btn, - up_widget=self._back_button, - down_widget=next_widget_down, - ) + bui.widget( + edit=self._easy_button, + left_widget=self._back_button, + up_widget=self._back_button, + ) + bui.widget(edit=self._hard_button, left_widget=self._back_button) + for btn in campaign_buttons: + bui.widget( + edit=btn, + up_widget=self._back_button, + ) + for btn in campaign_buttons: + bui.widget(edit=btn, down_widget=next_widget_down) # Update our existing percent-complete text. assert bui.app.classic is not None @@ -721,7 +684,7 @@ class CoopBrowserWindow(bui.Window): tourney_row_height = 200 self._subcontainerheight = ( - 620 + self._tournament_button_count * tourney_row_height + 700 + self._tournament_button_count * tourney_row_height ) self._subcontainer = bui.containerwidget( @@ -729,22 +692,17 @@ class CoopBrowserWindow(bui.Window): size=(self._subcontainerwidth, self._subcontainerheight), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) bui.containerwidget( edit=self._root_widget, selected_child=self._scrollwidget ) - if self._back_button is not None: - bui.containerwidget( - edit=self._root_widget, cancel_button=self._back_button - ) w_parent = self._subcontainer h_base = 6 - v = self._subcontainerheight - 73 + v = self._subcontainerheight - 90 self._campaign_percent_text = bui.textwidget( parent=w_parent, @@ -757,12 +715,12 @@ class CoopBrowserWindow(bui.Window): scale=1.1, ) - row_v_show_buffer = 100 + row_v_show_buffer = 80 v -= 198 h_scroll = bui.hscrollwidget( parent=w_parent, - size=(self._scroll_width - 10, 205), + size=(self._scroll_width, 205), position=(-5, v), simple_culling_h=70, highlight=False, @@ -817,17 +775,17 @@ class CoopBrowserWindow(bui.Window): textcolor=(0.7, 0.6, 0.75), autoselect=True, up_widget=self._campaign_h_scroll, + left_widget=self._back_button, on_activate_call=self._on_tournament_info_press, ) bui.widget( edit=self._tournament_info_button, - left_widget=self._tournament_info_button, right_widget=self._tournament_info_button, ) - # Say 'unavailable' if there are zero tournaments, and if we're not - # signed in add that as well (that's probably why we see - # no tournaments). + # Say 'unavailable' if there are zero tournaments, and if we're + # not signed in add that as well (that's probably why we see no + # tournaments). if self._tournament_button_count == 0: unavailable_text = bui.Lstr(resource='unavailableText') if plus.get_v1_account_state() != 'signed_in': @@ -856,7 +814,7 @@ class CoopBrowserWindow(bui.Window): for i in range(self._tournament_button_count): tournament_h_scroll = h_scroll = bui.hscrollwidget( parent=w_parent, - size=(self._scroll_width - 10, 205), + size=(self._scroll_width, 205), position=(-5, v), highlight=False, border_opacity=0.0, @@ -926,7 +884,7 @@ class CoopBrowserWindow(bui.Window): # Show easter-egg-hunt either if its easter or we own it. if plus.get_v1_account_misc_read_val( 'easter', False - ) or plus.get_purchased('games.easter_egg_hunt'): + ) or plus.get_v1_account_product_purchased('games.easter_egg_hunt'): items = [ 'Challenges:Easter Egg Hunt', 'Challenges:Pro Easter Egg Hunt', @@ -938,7 +896,7 @@ class CoopBrowserWindow(bui.Window): self._custom_h_scroll = custom_h_scroll = h_scroll = bui.hscrollwidget( parent=w_parent, - size=(self._scroll_width - 10, 205), + size=(self._scroll_width, 205), position=(-5, v), highlight=False, border_opacity=0.0, @@ -989,6 +947,7 @@ class CoopBrowserWindow(bui.Window): if i + 1 < len(self._tournament_buttons) else custom_h_scroll ), + left_widget=self._back_button, ) bui.widget( edit=tbutton.more_scores_button, @@ -1007,7 +966,7 @@ class CoopBrowserWindow(bui.Window): ), ) - for btn in self._custom_buttons: + for i, btn in enumerate(self._custom_buttons): try: bui.widget( edit=btn.get_button(), @@ -1017,18 +976,14 @@ class CoopBrowserWindow(bui.Window): else self._tournament_info_button ), ) + if i == 0: + bui.widget( + edit=btn.get_button(), left_widget=self._back_button + ) + except Exception: logging.exception('Error wiring up custom buttons.') - if self._back_button is not None: - bui.buttonwidget( - edit=self._back_button, on_activate_call=self._back - ) - else: - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back - ) - # There's probably several 'onSelected' callbacks pushed onto the # event queue.. we need to push ours too so we're enabled *after* them. bui.pushcall(self._enable_selectable_callback) @@ -1041,75 +996,42 @@ class CoopBrowserWindow(bui.Window): def _enable_selectable_callback(self) -> None: self._do_selection_callbacks = True - def _switch_to_league_rankings(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account import show_sign_in_prompt - from bauiv1lib.league.rankwindow import LeagueRankWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert self._league_rank_button is not None - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - LeagueRankWindow( - origin_widget=self._league_rank_button.get_button() - ).get_root_widget(), - from_window=self._root_widget, - ) - - def _switch_to_score( - self, - show_tab: ( - StoreBrowserWindow.TabID | None - ) = StoreBrowserWindow.TabID.EXTRAS, - ) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account import show_sign_in_prompt - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert self._store_button is not None - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - StoreBrowserWindow( - origin_widget=self._store_button.get_button(), - show_tab=show_tab, - back_location='CoopBrowserWindow', - ).get_root_widget(), - from_window=self._root_widget, - ) - def is_tourney_data_up_to_date(self) -> bool: """Return whether our tourney data is up to date.""" return self._tourney_data_up_to_date - def run_game(self, game: str) -> None: + def run_game( + self, game: str, origin_widget: bui.Widget | None = None + ) -> None: + """Run the provided game.""" + from efro.util import strict_partial + from bauiv1lib.confirm import ConfirmWindow + + classic = bui.app.classic + assert classic is not None + + if classic.chest_dock_full: + ConfirmWindow( + bui.Lstr(resource='chests.slotsFullWarningText'), + width=550, + height=140, + ok_text=bui.Lstr(resource='continueText'), + origin_widget=origin_widget, + action=strict_partial( + self._run_game, game=game, origin_widget=origin_widget + ), + ) + else: + self._run_game(game=game, origin_widget=origin_widget) + + def _run_game( + self, game: str, origin_widget: bui.Widget | None = None + ) -> None: """Run the provided game.""" - # pylint: disable=too-many-branches # pylint: disable=cyclic-import from bauiv1lib.confirm import ConfirmWindow from bauiv1lib.purchase import PurchaseWindow - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None @@ -1130,50 +1052,18 @@ class CoopBrowserWindow(bui.Window): ) return - # Infinite onslaught/runaround require pro; bring up a store link - # if need be. - if ( - game - in ( - 'Challenges:Infinite Runaround', - 'Challenges:Infinite Onslaught', - ) - and not bui.app.classic.accounts.have_pro() - ): - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - else: - PurchaseWindow(items=['pro']) - return + required_purchases = bui.app.classic.required_purchases_for_game(game) - required_purchase: str | None - if game in ['Challenges:Meteor Shower']: - required_purchase = 'games.meteor_shower' - elif game in [ - 'Challenges:Target Practice', - 'Challenges:Target Practice B', - ]: - required_purchase = 'games.target_practice' - elif game in ['Challenges:Ninja Fight']: - required_purchase = 'games.ninja_fight' - elif game in ['Challenges:Pro Ninja Fight']: - required_purchase = 'games.ninja_fight' - elif game in [ - 'Challenges:Easter Egg Hunt', - 'Challenges:Pro Easter Egg Hunt', - ]: - required_purchase = 'games.easter_egg_hunt' - else: - required_purchase = None - - if required_purchase is not None and not plus.get_purchased( - required_purchase - ): - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - else: - PurchaseWindow(items=[required_purchase]) - return + # Show pop-up to allow purchasing any required stuff we don't have. + for purchase in required_purchases: + if not plus.get_v1_account_product_purchased(purchase): + if plus.get_v1_account_state() != 'signed_in': + show_sign_in_prompt() + else: + PurchaseWindow( + items=[purchase], origin_widget=origin_widget + ) + return self._save_state() @@ -1182,12 +1072,18 @@ class CoopBrowserWindow(bui.Window): def run_tournament(self, tournament_button: TournamentButton) -> None: """Run the provided tournament game.""" - from bauiv1lib.account import show_sign_in_prompt + # pylint: disable=too-many-return-statements + + from bauiv1lib.purchase import PurchaseWindow + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.tournamententry import TournamentEntryWindow plus = bui.app.plus assert plus is not None + classic = bui.app.classic + assert classic is not None + if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return @@ -1237,6 +1133,38 @@ class CoopBrowserWindow(bui.Window): bui.getsound('error').play() return + if tournament_button.game is not None and not classic.is_game_unlocked( + tournament_button.game + ): + required_purchases = classic.required_purchases_for_game( + tournament_button.game + ) + # We gotta be missing *something* if its locked. + assert required_purchases + + for purchase in required_purchases: + if not plus.get_v1_account_product_purchased(purchase): + if plus.get_v1_account_state() != 'signed_in': + show_sign_in_prompt() + else: + PurchaseWindow( + items=[purchase], + origin_widget=tournament_button.button, + ) + return + + # assert required_purchases + # if plus.get_v1_account_state() != 'signed_in': + # show_sign_in_prompt() + # else: + # # Hmm; just show the first requirement. They can come + # # back to see more after they purchase the first. + # PurchaseWindow( + # items=[required_purchases[0]], + # origin_widget=tournament_button.button, + # ) + # return + if tournament_button.time_remaining <= 0: bui.screenmessage( bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0) @@ -1252,35 +1180,12 @@ class CoopBrowserWindow(bui.Window): position=tournament_button.button.get_screen_space_center(), ) - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.play import PlayWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - # If something is selected, store it. - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlayWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - def _save_state(self) -> None: cfg = bui.app.config try: sel = self._root_widget.get_selected_child() if sel == self._back_button: sel_name = 'Back' - elif sel == self._store_button_widget: - sel_name = 'Store' - elif sel == self._league_rank_button_widget: - sel_name = 'PowerRanking' elif sel == self._scrollwidget: sel_name = 'Scroll' else: @@ -1305,10 +1210,6 @@ class CoopBrowserWindow(bui.Window): sel = self._back_button elif sel_name == 'Scroll': sel = self._scrollwidget - elif sel_name == 'PowerRanking': - sel = self._league_rank_button_widget - elif sel_name == 'Store': - sel = self._store_button_widget else: sel = self._scrollwidget bui.containerwidget(edit=self._root_widget, selected_child=sel) diff --git a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py index 5172e3a..55a06d7 100644 --- a/dist/ba_data/python/bauiv1lib/coop/gamebutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/gamebutton.py @@ -5,6 +5,7 @@ from __future__ import annotations import random +import weakref from typing import TYPE_CHECKING import bauiv1 as bui @@ -26,6 +27,7 @@ class GameButton: select: bool, row: str, ): + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-statements # pylint: disable=too-many-locals @@ -58,12 +60,15 @@ class GameButton: else: stars = 1 + self._window = weakref.ref(window) + self._game = game + self._button = btn = bui.buttonwidget( parent=parent, position=(x + 23, y + 4), size=(sclx, scly), label='', - on_activate_call=bui.Call(window.run_game, game), + on_activate_call=self._on_press, button_type='square', autoselect=True, on_select_call=bui.Call(window.sel_change, row, game), @@ -186,12 +191,16 @@ class GameButton: ) self._update() + def _on_press(self) -> None: + window = self._window() + if window is not None: + window.run_game(self._game, origin_widget=self._button) + def get_button(self) -> bui.Widget: """Return the underlying button bui.Widget.""" return self._button def _update(self) -> None: - # pylint: disable=too-many-boolean-expressions plus = bui.app.plus assert plus is not None @@ -231,44 +240,7 @@ class GameButton: # Hard-code games we haven't unlocked. assert bui.app.classic is not None - if ( - ( - game - in ( - 'Challenges:Infinite Runaround', - 'Challenges:Infinite Onslaught', - ) - and not bui.app.classic.accounts.have_pro() - ) - or ( - game in ('Challenges:Meteor Shower',) - and not plus.get_purchased('games.meteor_shower') - ) - or ( - game - in ( - 'Challenges:Target Practice', - 'Challenges:Target Practice B', - ) - and not plus.get_purchased('games.target_practice') - ) - or ( - game in ('Challenges:Ninja Fight',) - and not plus.get_purchased('games.ninja_fight') - ) - or ( - game in ('Challenges:Pro Ninja Fight',) - and not plus.get_purchased('games.ninja_fight') - ) - or ( - game - in ( - 'Challenges:Easter Egg Hunt', - 'Challenges:Pro Easter Egg Hunt', - ) - and not plus.get_purchased('games.easter_egg_hunt') - ) - ): + if not bui.app.classic.is_game_unlocked(game): unlocked = False # Let's tint levels a slightly different color when easy mode diff --git a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py index 1c5a926..f24d370 100644 --- a/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py +++ b/dist/ba_data/python/bauiv1lib/coop/tournamentbutton.py @@ -12,6 +12,11 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Callable +# As of 1.7.37, no longer charging entry fees for tourneys (tourneys now +# reward chests and the game now makes its money from tokens/ads used to +# speed up chest openings). +USE_ENTRY_FEES = False + class TournamentButton: """Button showing a tournament in coop window.""" @@ -24,6 +29,8 @@ class TournamentButton: select: bool, on_pressed: Callable[[TournamentButton], None], ) -> None: + # pylint: disable=too-many-positional-arguments + # pylint: disable=too-many-statements self._r = 'coopSelectWindow' sclx = 300 scly = 195.0 @@ -32,10 +39,12 @@ class TournamentButton: self.lsbo = bui.getmesh('level_select_button_opaque') self.allow_ads = False self.tournament_id: str | None = None + self.game: str | None = None self.time_remaining: int = 0 self.has_time_remaining: bool = False self.leader: Any = None self.required_league: str | None = None + self._base_x_offs = 0 if USE_ENTRY_FEES else -45.0 self.button = btn = bui.buttonwidget( parent=parent, position=(x + 23, y + 4), @@ -73,8 +82,8 @@ class TournamentButton: self.lock_image = bui.imagewidget( parent=parent, draw_controller=btn, - position=(x + 21 + sclx * 0.5 - image_width * 0.25, y + scly - 150), - size=(image_width * 0.5, image_width * 0.5), + position=(x + 21 + sclx * 0.5 - image_width * 0.15, y + scly - 130), + size=(image_width * 0.3, image_width * 0.3), texture=bui.gettexture('lock'), opacity=0.0, ) @@ -95,69 +104,72 @@ class TournamentButton: header_color = (0.43, 0.4, 0.5, 1) value_color = (0.6, 0.6, 0.6, 1) - x_offs = 0 - bui.textwidget( - parent=parent, - draw_controller=btn, - position=(x + 360, y + scly - 20), - size=(0, 0), - h_align='center', - text=bui.Lstr(resource=f'{self._r}.entryFeeText'), - v_align='center', - maxwidth=100, - scale=0.9, - color=header_color, - flatness=1.0, - ) + x_offs = self._base_x_offs - self.entry_fee_text_top = bui.textwidget( - parent=parent, - draw_controller=btn, - position=(x + 360, y + scly - 60), - size=(0, 0), - h_align='center', - text='-', - v_align='center', - maxwidth=60, - scale=1.3, - color=value_color, - flatness=1.0, - ) - self.entry_fee_text_or = bui.textwidget( - parent=parent, - draw_controller=btn, - position=(x + 360, y + scly - 90), - size=(0, 0), - h_align='center', - text='', - v_align='center', - maxwidth=60, - scale=0.5, - color=value_color, - flatness=1.0, - ) - self.entry_fee_text_remaining = bui.textwidget( - parent=parent, - draw_controller=btn, - position=(x + 360, y + scly - 90), - size=(0, 0), - h_align='center', - text='', - v_align='center', - maxwidth=60, - scale=0.5, - color=value_color, - flatness=1.0, - ) + # No longer using entry fees. + if USE_ENTRY_FEES: + bui.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 360, y + scly - 20), + size=(0, 0), + h_align='center', + text=bui.Lstr(resource=f'{self._r}.entryFeeText'), + v_align='center', + maxwidth=100, + scale=0.9, + color=header_color, + flatness=1.0, + ) - self.entry_fee_ad_image = bui.imagewidget( - parent=parent, - size=(40, 40), - draw_controller=btn, - position=(x + 360 - 20, y + scly - 140), - opacity=0.0, - texture=bui.gettexture('tv'), - ) + self.entry_fee_text_top = bui.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 360, y + scly - 60), + size=(0, 0), + h_align='center', + text='-', + v_align='center', + maxwidth=60, + scale=1.3, + color=value_color, + flatness=1.0, + ) + self.entry_fee_text_or = bui.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 360, y + scly - 90), + size=(0, 0), + h_align='center', + text='', + v_align='center', + maxwidth=60, + scale=0.5, + color=value_color, + flatness=1.0, + ) + self.entry_fee_text_remaining = bui.textwidget( + parent=parent, + draw_controller=btn, + position=(x + 360, y + scly - 90), + size=(0, 0), + h_align='center', + text='', + v_align='center', + maxwidth=60, + scale=0.5, + color=value_color, + flatness=1.0, + ) + + self.entry_fee_ad_image = bui.imagewidget( + parent=parent, + size=(40, 40), + draw_controller=btn, + position=(x + 360 - 20, y + scly - 140), + opacity=0.0, + texture=bui.gettexture('tv'), + ) x_offs += 50 @@ -179,8 +191,8 @@ class TournamentButton: self.button_y = y self.button_scale_y = scly - xo2 = 0 - prize_value_scale = 1.5 + # Offset for prize range/values. + xo2 = 0.0 self.prize_range_1_text = bui.textwidget( parent=parent, @@ -190,7 +202,7 @@ class TournamentButton: h_align='right', v_align='center', maxwidth=50, - text='-', + text='', scale=0.8, color=header_color, flatness=1.0, @@ -201,13 +213,21 @@ class TournamentButton: position=(x + 380 + xo2 + x_offs, y + scly - 93), size=(0, 0), h_align='left', - text='-', + text='', v_align='center', maxwidth=100, - scale=prize_value_scale, color=value_color, flatness=1.0, ) + self._chestsz = 50 + self.prize_chest_1_image = bui.imagewidget( + parent=parent, + draw_controller=btn, + texture=bui.gettexture('white'), + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(self._chestsz, self._chestsz), + opacity=0.0, + ) self.prize_range_2_text = bui.textwidget( parent=parent, @@ -215,6 +235,7 @@ class TournamentButton: position=(x + 355 + xo2 + x_offs, y + scly - 93), size=(0, 0), h_align='right', + text='', v_align='center', maxwidth=50, scale=0.8, @@ -230,10 +251,17 @@ class TournamentButton: text='', v_align='center', maxwidth=100, - scale=prize_value_scale, color=value_color, flatness=1.0, ) + self.prize_chest_2_image = bui.imagewidget( + parent=parent, + draw_controller=btn, + texture=bui.gettexture('white'), + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(self._chestsz, self._chestsz), + opacity=0.0, + ) self.prize_range_3_text = bui.textwidget( parent=parent, @@ -241,6 +269,7 @@ class TournamentButton: position=(x + 355 + xo2 + x_offs, y + scly - 93), size=(0, 0), h_align='right', + text='', v_align='center', maxwidth=50, scale=0.8, @@ -256,15 +285,22 @@ class TournamentButton: text='', v_align='center', maxwidth=100, - scale=prize_value_scale, color=value_color, flatness=1.0, ) + self.prize_chest_3_image = bui.imagewidget( + parent=parent, + draw_controller=btn, + texture=bui.gettexture('white'), + position=(x + 380 + xo2 + x_offs, y + scly - 93), + size=(self._chestsz, self._chestsz), + opacity=0.0, + ) bui.textwidget( parent=parent, draw_controller=btn, - position=(x + 620 + x_offs, y + scly - 20), + position=(x + 625 + x_offs, y + scly - 20), size=(0, 0), h_align='center', text=bui.Lstr(resource=f'{self._r}.currentBestText'), @@ -278,7 +314,7 @@ class TournamentButton: parent=parent, draw_controller=btn, position=( - x + 620 + x_offs - (170 / 1.4) * 0.5, + x + 625 + x_offs - (170 / 1.4) * 0.5, y + scly - 60 - 40 * 0.5, ), selectable=True, @@ -298,7 +334,7 @@ class TournamentButton: self.current_leader_score_text = bui.textwidget( parent=parent, draw_controller=btn, - position=(x + 620 + x_offs, y + scly - 113 + 10), + position=(x + 625 + x_offs, y + scly - 113 + 10), size=(0, 0), h_align='center', text='-', @@ -311,7 +347,7 @@ class TournamentButton: self.more_scores_button = bui.buttonwidget( parent=parent, - position=(x + 620 + x_offs - 60, y + scly - 50 - 125), + position=(x + 625 + x_offs - 60, y + scly - 50 - 125), color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), label='-', @@ -329,7 +365,7 @@ class TournamentButton: bui.textwidget( parent=parent, draw_controller=btn, - position=(x + 820 + x_offs, y + scly - 20), + position=(x + 840 + x_offs, y + scly - 20), size=(0, 0), h_align='center', text=bui.Lstr(resource=f'{self._r}.timeRemainingText'), @@ -342,7 +378,7 @@ class TournamentButton: self.time_remaining_value_text = bui.textwidget( parent=parent, draw_controller=btn, - position=(x + 820 + x_offs, y + scly - 68), + position=(x + 840 + x_offs, y + scly - 68), size=(0, 0), h_align='center', text='-', @@ -355,7 +391,7 @@ class TournamentButton: self.time_remaining_out_of_text = bui.textwidget( parent=parent, draw_controller=btn, - position=(x + 820 + x_offs, y + scly - 110), + position=(x + 840 + x_offs, y + scly - 110), size=(0, 0), h_align='center', text='-', @@ -365,6 +401,9 @@ class TournamentButton: color=(0.4, 0.4, 0.5), flatness=1.0, ) + self._lock_update_timer = bui.AppTimer( + 1.03, bui.WeakCall(self._update_lock_state), repeat=True + ) def _pressed(self) -> None: self.on_pressed(self) @@ -405,6 +444,33 @@ class TournamentButton: position=self.more_scores_button.get_screen_space_center(), ) + def _update_lock_state(self) -> None: + + if self.game is None: + return + + assert bui.app.classic is not None + + campaignname, levelname = self.game.split(':') + campaign = bui.app.classic.getcampaign(campaignname) + + enabled = ( + self.required_league is None + and bui.app.classic.is_game_unlocked(self.game) + ) + bui.buttonwidget( + edit=self.button, + color=(0.5, 0.7, 0.2) if enabled else (0.5, 0.5, 0.5), + ) + bui.imagewidget(edit=self.lock_image, opacity=0.0 if enabled else 1.0) + bui.imagewidget( + edit=self.image, + texture=bui.gettexture( + campaign.getlevel(levelname).preview_texture_name + ), + opacity=1.0 if enabled else 0.5, + ) + def update_for_data(self, entry: dict[str, Any]) -> None: """Update for new incoming data.""" # pylint: disable=too-many-statements @@ -414,105 +480,132 @@ class TournamentButton: plus = bui.app.plus assert plus is not None - assert bui.app.classic is not None + classic = bui.app.classic + assert classic is not None + prize_y_offs = ( 34 if 'prizeRange3' in entry else 20 if 'prizeRange2' in entry else 12 ) - x_offs = 90 + x_offs = self._base_x_offs + 90 - # pylint: disable=useless-suppression - # pylint: disable=unbalanced-tuple-unpacking - ( - pr1, - pv1, - pr2, - pv2, - pr3, - pv3, - ) = bui.app.classic.get_tournament_prize_strings(entry) - # pylint: enable=unbalanced-tuple-unpacking - # pylint: enable=useless-suppression + # Special offset for prize ranges/vals. + x_offs2 = x_offs - 20.0 - enabled = 'requiredLeague' not in entry - bui.buttonwidget( - edit=self.button, - color=(0.5, 0.7, 0.2) if enabled else (0.5, 0.5, 0.5), + # Special offset for prize chests. + x_offs2c = x_offs2 + 50 + + # Fetch prize range and trophy strings. + (pr1, pv1, pr2, pv2, pr3, pv3) = classic.get_tournament_prize_strings( + entry, include_tickets=False ) - bui.imagewidget(edit=self.lock_image, opacity=0.0 if enabled else 1.0) + + self.time_remaining = entry['timeRemaining'] + self.has_time_remaining = entry is not None + self.tournament_id = entry['tournamentID'] + self.required_league = entry.get('requiredLeague') + + assert bui.app.classic is not None + self.game = bui.app.classic.accounts.tournament_info[ + self.tournament_id + ]['game'] + assert isinstance(self.game, str) + + campaignname, levelname = self.game.split(':') + campaign = bui.app.classic.getcampaign(campaignname) + + self._update_lock_state() + bui.textwidget( edit=self.prize_range_1_text, text='-' if pr1 == '' else pr1, position=( - self.button_x + 365 + x_offs, + self.button_x + 365 + x_offs2, self.button_y + self.button_scale_y - 93 + prize_y_offs, ), ) - # We want to draw values containing tickets a bit smaller - # (scratch that; we now draw medals a bit bigger). - ticket_char = bui.charstr(bui.SpecialChar.TICKET_BACKING) - prize_value_scale_large = 1.0 - prize_value_scale_small = 1.0 - bui.textwidget( edit=self.prize_value_1_text, text='-' if pv1 == '' else pv1, - scale=( - prize_value_scale_large - if ticket_char not in pv1 - else prize_value_scale_small - ), position=( - self.button_x + 380 + x_offs, + self.button_x + 380 + x_offs2, self.button_y + self.button_scale_y - 93 + prize_y_offs, ), ) + bui.imagewidget( + edit=self.prize_chest_1_image, + position=( + self.button_x + 380 + x_offs2c, + self.button_y + + self.button_scale_y + - 93 + + prize_y_offs + - 0.5 * self._chestsz, + ), + ) + classic.set_tournament_prize_image(entry, 0, self.prize_chest_1_image) bui.textwidget( edit=self.prize_range_2_text, text=pr2, position=( - self.button_x + 365 + x_offs, + self.button_x + 365 + x_offs2, self.button_y + self.button_scale_y - 93 - 45 + prize_y_offs, ), ) bui.textwidget( edit=self.prize_value_2_text, text=pv2, - scale=( - prize_value_scale_large - if ticket_char not in pv2 - else prize_value_scale_small - ), position=( - self.button_x + 380 + x_offs, + self.button_x + 380 + x_offs2, self.button_y + self.button_scale_y - 93 - 45 + prize_y_offs, ), ) + bui.imagewidget( + edit=self.prize_chest_2_image, + position=( + self.button_x + 380 + x_offs2c, + self.button_y + + self.button_scale_y + - 93 + - 45 + + prize_y_offs + - 0.5 * self._chestsz, + ), + ) + classic.set_tournament_prize_image(entry, 1, self.prize_chest_2_image) bui.textwidget( edit=self.prize_range_3_text, text=pr3, position=( - self.button_x + 365 + x_offs, + self.button_x + 365 + x_offs2, self.button_y + self.button_scale_y - 93 - 90 + prize_y_offs, ), ) bui.textwidget( edit=self.prize_value_3_text, text=pv3, - scale=( - prize_value_scale_large - if ticket_char not in pv3 - else prize_value_scale_small - ), position=( - self.button_x + 380 + x_offs, + self.button_x + 380 + x_offs2, self.button_y + self.button_scale_y - 93 - 90 + prize_y_offs, ), ) + bui.imagewidget( + edit=self.prize_chest_3_image, + position=( + self.button_x + 380 + x_offs2c, + self.button_y + + self.button_scale_y + - 93 + - 90 + + prize_y_offs + - 0.5 * self._chestsz, + ), + ) + classic.set_tournament_prize_image(entry, 2, self.prize_chest_3_image) leader_name = '-' leader_score: str | bui.Lstr = '-' @@ -552,52 +645,33 @@ class TournamentButton: edit=self.time_remaining_out_of_text, text=out_of_time_text ) - self.time_remaining = entry['timeRemaining'] - self.has_time_remaining = entry is not None - self.tournament_id = entry['tournamentID'] - self.required_league = ( - None if 'requiredLeague' not in entry else entry['requiredLeague'] - ) + # if self.game is None: + # bui.textwidget(edit=self.button_text, text='-') + # bui.imagewidget( + # edit=self.image, texture=bui.gettexture('black'), opacity=0.2 + # ) + # else: + max_players = bui.app.classic.accounts.tournament_info[ + self.tournament_id + ]['maxPlayers'] - assert bui.app.classic is not None - game = bui.app.classic.accounts.tournament_info[self.tournament_id][ - 'game' - ] - - if game is None: - bui.textwidget(edit=self.button_text, text='-') - bui.imagewidget( - edit=self.image, texture=bui.gettexture('black'), opacity=0.2 - ) - else: - campaignname, levelname = game.split(':') - campaign = bui.app.classic.getcampaign(campaignname) - max_players = bui.app.classic.accounts.tournament_info[ - self.tournament_id - ]['maxPlayers'] - txt = bui.Lstr( - value='${A} ${B}', - subs=[ - ('${A}', campaign.getlevel(levelname).displayname), - ( - '${B}', - bui.Lstr( - resource='playerCountAbbreviatedText', - subs=[('${COUNT}', str(max_players))], - ), + txt = bui.Lstr( + value='${A} ${B}', + subs=[ + ('${A}', campaign.getlevel(levelname).displayname), + ( + '${B}', + bui.Lstr( + resource='playerCountAbbreviatedText', + subs=[('${COUNT}', str(max_players))], ), - ], - ) - bui.textwidget(edit=self.button_text, text=txt) - bui.imagewidget( - edit=self.image, - texture=bui.gettexture( - campaign.getlevel(levelname).preview_texture_name ), - opacity=1.0 if enabled else 0.5, - ) + ], + ) + bui.textwidget(edit=self.button_text, text=txt) fee = entry['fee'] + assert isinstance(fee, int | None) if fee is None: fee_var = None @@ -609,18 +683,23 @@ class TournamentButton: fee_var = 'price.tournament_entry_2' elif fee == 1: fee_var = 'price.tournament_entry_1' + elif fee == -1: + fee_var = None else: if fee != 0: print('Unknown fee value:', fee) fee_var = 'price.tournament_entry_0' - self.allow_ads = allow_ads = entry['allowAds'] + self.allow_ads = allow_ads = ( + entry['allowAds'] if USE_ENTRY_FEES else False + ) - final_fee: int | None = ( + final_fee = ( None if fee_var is None else plus.get_v1_account_misc_read_val(fee_var, '?') ) + assert isinstance(final_fee, int | None) final_fee_str: str | bui.Lstr if fee_var is None: @@ -637,72 +716,77 @@ class TournamentButton: ad_tries_remaining = bui.app.classic.accounts.tournament_info[ self.tournament_id ]['adTriesRemaining'] + assert isinstance(ad_tries_remaining, int | None) free_tries_remaining = bui.app.classic.accounts.tournament_info[ self.tournament_id ]['freeTriesRemaining'] + assert isinstance(free_tries_remaining, int | None) - # Now, if this fee allows ads and we support video ads, show - # the 'or ad' version. - if allow_ads and plus.has_video_ads(): - ads_enabled = plus.have_incentivized_ad() - bui.imagewidget( - edit=self.entry_fee_ad_image, - opacity=1.0 if ads_enabled else 0.25, - ) - or_text = ( - bui.Lstr(resource='orText', subs=[('${A}', ''), ('${B}', '')]) - .evaluate() - .strip() - ) - bui.textwidget(edit=self.entry_fee_text_or, text=or_text) - bui.textwidget( - edit=self.entry_fee_text_top, - position=( - self.button_x + 360, - self.button_y + self.button_scale_y - 60, - ), - scale=1.3, - text=final_fee_str, - ) + # Now, if this fee allows ads and we support video ads, show the + # 'or ad' version. + if USE_ENTRY_FEES: + if allow_ads and plus.has_video_ads(): + ads_enabled = plus.have_incentivized_ad() + bui.imagewidget( + edit=self.entry_fee_ad_image, + opacity=1.0 if ads_enabled else 0.25, + ) + or_text = ( + bui.Lstr( + resource='orText', subs=[('${A}', ''), ('${B}', '')] + ) + .evaluate() + .strip() + ) + bui.textwidget(edit=self.entry_fee_text_or, text=or_text) + bui.textwidget( + edit=self.entry_fee_text_top, + position=( + self.button_x + 360, + self.button_y + self.button_scale_y - 60, + ), + scale=1.3, + text=final_fee_str, + ) - # Possibly show number of ad-plays remaining. - bui.textwidget( - edit=self.entry_fee_text_remaining, - position=( - self.button_x + 360, - self.button_y + self.button_scale_y - 146, - ), - text=( - '' - if ad_tries_remaining in [None, 0] - else ('' + str(ad_tries_remaining)) - ), - color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2), - ) - else: - bui.imagewidget(edit=self.entry_fee_ad_image, opacity=0.0) - bui.textwidget(edit=self.entry_fee_text_or, text='') - bui.textwidget( - edit=self.entry_fee_text_top, - position=( - self.button_x + 360, - self.button_y + self.button_scale_y - 80, - ), - scale=1.3, - text=final_fee_str, - ) + # Possibly show number of ad-plays remaining. + bui.textwidget( + edit=self.entry_fee_text_remaining, + position=( + self.button_x + 360, + self.button_y + self.button_scale_y - 146, + ), + text=( + '' + if ad_tries_remaining in [None, 0] + else ('' + str(ad_tries_remaining)) + ), + color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2), + ) + else: + bui.imagewidget(edit=self.entry_fee_ad_image, opacity=0.0) + bui.textwidget(edit=self.entry_fee_text_or, text='') + bui.textwidget( + edit=self.entry_fee_text_top, + position=( + self.button_x + 360, + self.button_y + self.button_scale_y - 80, + ), + scale=1.3, + text=final_fee_str, + ) - # Possibly show number of free-plays remaining. - bui.textwidget( - edit=self.entry_fee_text_remaining, - position=( - self.button_x + 360, - self.button_y + self.button_scale_y - 100, - ), - text=( - '' - if (free_tries_remaining in [None, 0] or final_fee != 0) - else ('' + str(free_tries_remaining)) - ), - color=(0.6, 0.6, 0.6, 1), - ) + # Possibly show number of free-plays remaining. + bui.textwidget( + edit=self.entry_fee_text_remaining, + position=( + self.button_x + 360, + self.button_y + self.button_scale_y - 100, + ), + text=( + '' + if (free_tries_remaining in [None, 0] or final_fee != 0) + else ('' + str(free_tries_remaining)) + ), + color=(0.6, 0.6, 0.6, 1), + ) diff --git a/dist/ba_data/python/bauiv1lib/creditslist.py b/dist/ba_data/python/bauiv1lib/credits.py similarity index 76% rename from dist/ba_data/python/bauiv1lib/creditslist.py rename to dist/ba_data/python/bauiv1lib/credits.py index 711d3ff..c605c04 100644 --- a/dist/ba_data/python/bauiv1lib/creditslist.py +++ b/dist/ba_data/python/bauiv1lib/credits.py @@ -5,8 +5,9 @@ from __future__ import annotations import os +import json import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -14,113 +15,116 @@ if TYPE_CHECKING: from typing import Sequence -class CreditsListWindow(bui.Window): +class CreditsWindow(bui.MainWindow): """Window for displaying game credits.""" - def __init__(self, origin_widget: bui.Widget | None = None): + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): # pylint: disable=too-many-locals # pylint: disable=too-many-statements - import json bui.set_analytics_screen('Credits Window') - # if they provided an origin-widget, scale up from that - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' - assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - width = 870 if uiscale is bui.UIScale.SMALL else 670 - x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 - height = 398 if uiscale is bui.UIScale.SMALL else 500 + width = 990 if uiscale is bui.UIScale.SMALL else 670 + height = 750 if uiscale is bui.UIScale.SMALL else 500 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.0 + if uiscale is bui.UIScale.SMALL + else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(width - 80, screensize[0] / scale) + target_height = min(height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + + scroll_width = target_width + scroll_height = target_height - 29 + scroll_y = yoffs - 58 - scroll_height self._r = 'creditsWindow' super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.0 + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -8) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back + edit=self._root_widget, on_cancel_call=self.main_window_back ) else: btn = bui.buttonwidget( parent=self._root_widget, - position=( - 40 + x_inset, - height - (68 if uiscale is bui.UIScale.SMALL else 62), - ), - size=(140, 60), + position=(40, yoffs - 46), + size=(60, 48), scale=0.8, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, autoselect=True, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - position=( - 40 + x_inset, - height - (68 if uiscale is bui.UIScale.SMALL else 62) + 5, - ), - size=(60, 48), - label=bui.charstr(bui.SpecialChar.BACK), - ) - bui.textwidget( parent=self._root_widget, - position=(0, height - (59 if uiscale is bui.UIScale.SMALL else 54)), - size=(width, 30), + position=( + width * 0.5, + yoffs - (44 if uiscale is bui.UIScale.SMALL else 28), + ), + size=(0, 0), + scale=0.8 if uiscale is bui.UIScale.SMALL else 1.0, text=bui.Lstr( resource=f'{self._r}.titleText', subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], ), h_align='center', - color=bui.app.ui_v1.title_color, - maxwidth=330, v_align='center', + color=bui.app.ui_v1.title_color, + maxwidth=scroll_width * 0.7, ) scroll = bui.scrollwidget( parent=self._root_widget, - position=(40 + x_inset, 35), - size=(width - (80 + 2 * x_inset), height - 100), + size=(scroll_width, scroll_height), + position=(width * 0.5 - scroll_width * 0.5, scroll_y), capture_arrows=True, + border_opacity=0.4, + center_small_content_horizontally=True, ) - if bui.app.ui_v1.use_toolbars: + bui.widget( + edit=scroll, + right_widget=bui.get_special_widget('squad_button'), + ) + if uiscale is bui.UIScale.SMALL: bui.widget( edit=scroll, - right_widget=bui.get_special_widget('party_button'), + left_widget=bui.get_special_widget('back_button'), ) - if uiscale is bui.UIScale.SMALL: - bui.widget( - edit=scroll, - left_widget=bui.get_special_widget('back_button'), - ) def _format_names(names2: Sequence[str], inset: float) -> str: sval = '' @@ -327,7 +331,7 @@ class CreditsListWindow(bui.Window): line_height = 20 scale = 0.55 - self._sub_width = width - 80 + self._sub_width = min(700, width - 80) self._sub_height = line_height * len(lines) + 40 container = self._subcontainer = bui.containerwidget( @@ -335,7 +339,6 @@ class CreditsListWindow(bui.Window): size=(self._sub_width, self._sub_height), background=False, claims_left_right=False, - claims_tab=False, ) voffs = 0 @@ -354,18 +357,12 @@ class CreditsListWindow(bui.Window): ) voffs -= line_height - def _back(self) -> None: - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) ) diff --git a/dist/ba_data/python/bauiv1lib/discord.py b/dist/ba_data/python/bauiv1lib/discord.py index de891b2..40aa34f 100644 --- a/dist/ba_data/python/bauiv1lib/discord.py +++ b/dist/ba_data/python/bauiv1lib/discord.py @@ -51,7 +51,7 @@ class DiscordWindow(bui.Window): ) ) - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( edit=self._root_widget, on_cancel_call=self._do_back ) diff --git a/dist/ba_data/python/bauiv1lib/fileselector.py b/dist/ba_data/python/bauiv1lib/fileselector.py index a2547b2..b99164f 100644 --- a/dist/ba_data/python/bauiv1lib/fileselector.py +++ b/dist/ba_data/python/bauiv1lib/fileselector.py @@ -16,23 +16,26 @@ if TYPE_CHECKING: from typing import Any, Callable, Sequence -class FileSelectorWindow(bui.Window): +class FileSelectorWindow(bui.MainWindow): """Window for selecting files.""" def __init__( self, path: str, callback: Callable[[str | None], Any] | None = None, + *, show_base_path: bool = True, valid_file_extensions: Sequence[str] | None = None, allow_folders: bool = False, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): if valid_file_extensions is None: valid_file_extensions = [] assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 700 if uiscale is bui.UIScale.SMALL else 600 - self._x_inset = x_inset = 50 if uiscale is bui.UIScale.SMALL else 0 + self._width = 850 if uiscale is bui.UIScale.SMALL else 600 + self._x_inset = x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 self._height = 365 if uiscale is bui.UIScale.SMALL else 418 self._callback = callback self._base_path = path @@ -51,16 +54,17 @@ class FileSelectorWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition='in_right', scale=( - 2.23 + 1.93 if uiscale is bui.UIScale.SMALL else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( (0, -35) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) bui.textwidget( parent=self._root_widget, @@ -135,6 +139,31 @@ class FileSelectorWindow(bui.Window): ) self._set_path(path) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull everything out of self here. If we do it below in the lambda, + # we'll keep self alive which is bad. + path = self._base_path + callback = self._callback + show_base_path = self._show_base_path + valid_file_extensions = self._valid_file_extensions + allow_folders = self._allow_folders + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + path=path, + callback=callback, + show_base_path=show_base_path, + valid_file_extensions=valid_file_extensions, + allow_folders=allow_folders, + ) + ) + def _on_up_press(self) -> None: self._on_entry_activated('..') @@ -147,7 +176,6 @@ class FileSelectorWindow(bui.Window): bui.getsound('error').play() def _on_folder_entry_activated(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') if self._callback is not None: assert self._path is not None self._callback(self._path) @@ -176,9 +204,6 @@ class FileSelectorWindow(bui.Window): elif os.path.isfile(test_path): if self._is_valid_file_path(test_path): bui.getsound('swish').play() - bui.containerwidget( - edit=self._root_widget, transition='out_right' - ) if self._callback is not None: self._callback(test_path) else: @@ -362,12 +387,10 @@ class FileSelectorWindow(bui.Window): bui.containerwidget( edit=self._scrollwidget, claims_left_right=False, - claims_tab=False, ) bui.containerwidget( edit=self._subcontainer, claims_left_right=False, - claims_tab=False, selection_loops=False, print_list_exit_instructions=False, ) @@ -458,6 +481,6 @@ class FileSelectorWindow(bui.Window): ) def _cancel(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') + self.main_window_back() if self._callback is not None: self._callback(None) diff --git a/dist/ba_data/python/bauiv1lib/gather/__init__.py b/dist/ba_data/python/bauiv1lib/gather/__init__.py index 6e35d6a..93c9073 100644 --- a/dist/ba_data/python/bauiv1lib/gather/__init__.py +++ b/dist/ba_data/python/bauiv1lib/gather/__init__.py @@ -7,10 +7,14 @@ from __future__ import annotations import weakref import logging from enum import Enum +from typing import override, TYPE_CHECKING from bauiv1lib.tabs import TabRow import bauiv1 as bui +if TYPE_CHECKING: + from bauiv1lib.play import PlaylistSelectContext + class GatherTab: """Defines a tab for use in the gather UI.""" @@ -40,6 +44,7 @@ class GatherTab: The tab should create and return a container widget covering the specified region. """ + # pylint: disable=too-many-positional-arguments raise RuntimeError('Should not get here.') def on_deactivate(self) -> None: @@ -52,7 +57,7 @@ class GatherTab: """Called when the parent window is restoring state.""" -class GatherWindow(bui.Window): +class GatherWindow(bui.MainWindow): """Window for joining/inviting friends.""" class TabID(Enum): @@ -69,7 +74,6 @@ class GatherWindow(bui.Window): transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): - # pylint: disable=too-many-statements # pylint: disable=too-many-locals # pylint: disable=cyclic-import from bauiv1lib.gather.abouttab import AboutGatherTab @@ -82,96 +86,99 @@ class GatherWindow(bui.Window): assert plus is not None bui.set_analytics_screen('Gather Window') - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_location('Gather') - bui.set_party_icon_always_visible(True) uiscale = bui.app.ui_v1.uiscale - self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040 - x_offs = 200 if uiscale is bui.UIScale.SMALL else 0 - self._height = ( - 582 + self._width = ( + 1640 if uiscale is bui.UIScale.SMALL - else 680 if uiscale is bui.UIScale.MEDIUM else 800 + else 1100 if uiscale is bui.UIScale.MEDIUM else 1200 + ) + self._height = ( + 1000 + if uiscale is bui.UIScale.SMALL + else 730 if uiscale is bui.UIScale.MEDIUM else 900 ) self._current_tab: GatherWindow.TabID | None = None - extra_top = 20 if uiscale is bui.UIScale.SMALL else 0 self._r = 'gatherWindow' + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.4 + if uiscale is bui.UIScale.SMALL + else 0.88 if uiscale is bui.UIScale.MEDIUM else 0.66 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 130, screensize[0] / scale) + target_height = min(self._height - 130, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 65 + self._scroll_bottom = yoffs - 93 - self._scroll_height + self._scroll_left = (self._width - self._scroll_width) * 0.5 + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + extra_top), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 1.3 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_tokens' if uiscale is bui.UIScale.SMALL - else 0.97 if uiscale is bui.UIScale.MEDIUM else 0.8 + else 'menu_full' ), - stack_offset=( - (0, -11) - if uiscale is bui.UIScale.SMALL - else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back + edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(70 + x_offs, self._height - 74), - size=(140, 60), + position=(70, yoffs - 43), + size=(60, 60), scale=1.1, autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - position=(70 + x_offs, self._height - 78), - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - condensed = uiscale is not bui.UIScale.LARGE - t_offs_y = ( - 0 if not condensed else 25 if uiscale is bui.UIScale.MEDIUM else 17 - ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 42 + t_offs_y), + position=( + ( + self._width * 0.5 + + ( + (self._scroll_width * -0.5 + 170.0 - 70.0) + if uiscale is bui.UIScale.SMALL + else 0.0 + ) + ), + yoffs - (64 if uiscale is bui.UIScale.SMALL else 4), + ), size=(0, 0), color=bui.app.ui_v1.title_color, - scale=( - 1.5 - if not condensed - else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.6 - ), - h_align='center', + scale=1.3 if uiscale is bui.UIScale.SMALL else 1.0, + h_align='left' if uiscale is bui.UIScale.SMALL else 'center', v_align='center', - text=bui.Lstr(resource=f'{self._r}.titleText'), - maxwidth=550, + text=(bui.Lstr(resource=f'{self._r}.titleText')), + maxwidth=135 if uiscale is bui.UIScale.SMALL else 320, ) - scroll_buffer_h = 130 + 2 * x_offs - tab_buffer_h = (320 if condensed else 250) + 2 * x_offs - # Build up the set of tabs we want. tabdefs: list[tuple[GatherWindow.TabID, bui.Lstr]] = [ (self.TabID.ABOUT, bui.Lstr(resource=f'{self._r}.aboutText')) @@ -193,14 +200,16 @@ class GatherWindow(bui.Window): (self.TabID.MANUAL, bui.Lstr(resource=f'{self._r}.manualText')) ) - # On small UI, push our tabs up closer to the top of the screen to - # save a bit of space. - tabs_top_extra = 42 if condensed else 0 + tab_inset = 250.0 if uiscale is bui.UIScale.SMALL else 100.0 + self._tab_row = TabRow( self._root_widget, tabdefs, - pos=(tab_buffer_h * 0.5, self._height - 130 + tabs_top_extra), - size=(self._width - tab_buffer_h, 50), + size=(self._scroll_width - 2.0 * tab_inset, 50), + pos=( + self._scroll_left + tab_inset, + self._scroll_bottom + self._scroll_height - 4.0, + ), on_select_call=bui.WeakCall(self._set_tab), ) @@ -218,64 +227,70 @@ class GatherWindow(bui.Window): if tabtype is not None: self._tabs[tab_id] = tabtype(self) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=self._tab_row.tabs[tabdefs[-1][0]].button, - right_widget=bui.get_special_widget('party_button'), - ) - if uiscale is bui.UIScale.SMALL: - bui.widget( - edit=self._tab_row.tabs[tabdefs[0][0]].button, - left_widget=bui.get_special_widget('back_button'), - ) - - self._scroll_width = self._width - scroll_buffer_h - self._scroll_height = self._height - 180.0 + tabs_top_extra - - self._scroll_left = (self._width - self._scroll_width) * 0.5 - self._scroll_bottom = ( - self._height - self._scroll_height - 79 - 48 + tabs_top_extra + # Eww; tokens meter may or may not be here; should be smarter + # about this. + bui.widget( + edit=self._tab_row.tabs[tabdefs[-1][0]].button, + right_widget=bui.get_special_widget('tokens_meter'), ) - buffer_h = 10 - buffer_v = 4 + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._tab_row.tabs[tabdefs[0][0]].button, + left_widget=bui.get_special_widget('back_button'), + up_widget=bui.get_special_widget('back_button'), + ) # Not actually using a scroll widget anymore; just an image. bui.imagewidget( parent=self._root_widget, + size=(self._scroll_width, self._scroll_height), position=( - self._scroll_left - buffer_h, - self._scroll_bottom - buffer_v, - ), - size=( - self._scroll_width + 2 * buffer_h, - self._scroll_height + 2 * buffer_v, + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, ), texture=bui.gettexture('scrollWidget'), mesh_transparent=bui.getmesh('softEdgeOutside'), + opacity=0.4, ) self._tab_container: bui.Widget | None = None self._restore_state() - def __del__(self) -> None: - bui.set_party_icon_always_visible(False) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) - def playlist_select(self, origin_widget: bui.Widget) -> None: + @override + def on_main_window_close(self) -> None: + self._save_state() + + def playlist_select( + self, + origin_widget: bui.Widget, + context: PlaylistSelectContext, + ) -> None: """Called by the private-hosting tab to select a playlist.""" from bauiv1lib.play import PlayWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # Avoid redundant window spawns. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.selecting_private_party_playlist = True - bui.app.ui_v1.set_main_menu_window( - PlayWindow(origin_widget=origin_widget).get_root_widget(), - from_window=self._root_widget, + playwindow = PlayWindow( + origin_widget=origin_widget, playlist_select_context=context ) + self.main_window_replace(playwindow) + + # Grab the newly-set main-window's back-state; that will lead us + # back here once we're done going down our main-window + # rabbit-hole for playlist selection. + context.back_state = playwindow.main_window_back_state def _set_tab(self, tab_id: TabID) -> None: if self._current_tab is tab_id: @@ -340,8 +355,6 @@ class GatherWindow(bui.Window): logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: - from efro.util import enum_by_value - try: for tab in self._tabs.values(): tab.restore_state() @@ -354,7 +367,7 @@ class GatherWindow(bui.Window): current_tab = self.TabID.ABOUT gather_tab_val = bui.app.config.get('Gather Tab') try: - stored_tab = enum_by_value(self.TabID, gather_tab_val) + stored_tab = self.TabID(gather_tab_val) if stored_tab in self._tab_row.tabs: current_tab = stored_tab except ValueError: @@ -366,9 +379,7 @@ class GatherWindow(bui.Window): sel = self._tab_container elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): try: - sel_tab_id = enum_by_value( - self.TabID, sel_name.split(':')[-1] - ) + sel_tab_id = self.TabID(sel_name.split(':')[-1]) except ValueError: sel_tab_id = self.TabID.ABOUT sel = self._tab_row.tabs[sel_tab_id].button @@ -378,20 +389,3 @@ class GatherWindow(bui.Window): except Exception: logging.exception('Error restoring state for %s.', self) - - def _back(self) -> None: - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/gather/abouttab.py b/dist/ba_data/python/bauiv1lib/gather/abouttab.py index 086471e..b677637 100644 --- a/dist/ba_data/python/bauiv1lib/gather/abouttab.py +++ b/dist/ba_data/python/bauiv1lib/gather/abouttab.py @@ -27,6 +27,7 @@ class AboutGatherTab(GatherTab): region_bottom: float, ) -> bui.Widget: # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments plus = bui.app.plus assert plus is not None @@ -157,6 +158,7 @@ class AboutGatherTab(GatherTab): autoselect=True, on_activate_call=bui.WeakCall(self._invite_to_try_press), up_widget=tab_button, + show_buffer_top=500, ) y -= invite_height else: @@ -198,7 +200,7 @@ class AboutGatherTab(GatherTab): return scroll_widget def _invite_to_try_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.appinvite import handle_app_invites_press plus = bui.app.plus diff --git a/dist/ba_data/python/bauiv1lib/gather/manualtab.py b/dist/ba_data/python/bauiv1lib/gather/manualtab.py index 8c889ef..05924f8 100644 --- a/dist/ba_data/python/bauiv1lib/gather/manualtab.py +++ b/dist/ba_data/python/bauiv1lib/gather/manualtab.py @@ -89,10 +89,10 @@ class ManualGatherTab(GatherTab): self._container: bui.Widget | None = None self._join_by_address_text: bui.Widget | None = None self._favorites_text: bui.Widget | None = None - self._width: int | None = None - self._height: int | None = None - self._scroll_width: int | None = None - self._scroll_height: int | None = None + self._width: float | None = None + self._height: float | None = None + self._scroll_width: float | None = None + self._scroll_height: float | None = None self._favorites_scroll_width: int | None = None self._favorites_connect_button: bui.Widget | None = None self._scrollwidget: bui.Widget | None = None @@ -114,6 +114,7 @@ class ManualGatherTab(GatherTab): region_left: float, region_bottom: float, ) -> bui.Widget: + # pylint: disable=too-many-positional-arguments c_width = region_width c_height = region_height - 20 @@ -241,7 +242,7 @@ class ManualGatherTab(GatherTab): self._build_join_by_address_tab(region_width, region_height) if value is SubTabType.FAVORITES: - self._build_favorites_tab(region_height) + self._build_favorites_tab(region_width, region_height) # The old manual tab def _build_join_by_address_tab( @@ -276,7 +277,9 @@ class ManualGatherTab(GatherTab): maxwidth=380, size=(420, 60), ) + assert self._join_by_address_text is not None bui.widget(edit=self._join_by_address_text, down_widget=txt) + assert self._favorites_text is not None bui.widget(edit=self._favorites_text, down_widget=txt) bui.textwidget( parent=self._container, @@ -349,13 +352,16 @@ class ManualGatherTab(GatherTab): bui.widget(edit=self._check_button, up_widget=btn) # Tab containing saved favorite addresses - def _build_favorites_tab(self, region_height: float) -> None: + def _build_favorites_tab( + self, region_width: float, region_height: float + ) -> None: c_height = region_height - 20 v = c_height - 35 - 25 - 30 assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 1240 if uiscale is bui.UIScale.SMALL else 1040 + # self._width = 1240 if uiscale is bui.UIScale.SMALL else 1040 + self._width = region_width x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 self._height = ( 578 @@ -400,7 +406,7 @@ class ManualGatherTab(GatherTab): self._favorites_connect_button = btn1 = bui.buttonwidget( parent=self._container, size=(b_width, b_height), - position=(40 if uiscale is bui.UIScale.SMALL else 40, btnv), + position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', color=(0.6, 0.53, 0.63), textcolor=(0.75, 0.7, 0.8), @@ -409,7 +415,7 @@ class ManualGatherTab(GatherTab): label=bui.Lstr(resource='gatherWindow.manualConnectText'), autoselect=True, ) - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: bui.widget( edit=btn1, left_widget=bui.get_special_widget('back_button'), @@ -418,7 +424,7 @@ class ManualGatherTab(GatherTab): bui.buttonwidget( parent=self._container, size=(b_width, b_height), - position=(40 if uiscale is bui.UIScale.SMALL else 40, btnv), + position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', color=(0.6, 0.53, 0.63), textcolor=(0.75, 0.7, 0.8), @@ -431,7 +437,7 @@ class ManualGatherTab(GatherTab): bui.buttonwidget( parent=self._container, size=(b_width, b_height), - position=(40 if uiscale is bui.UIScale.SMALL else 40, btnv), + position=(140 if uiscale is bui.UIScale.SMALL else 40, btnv), button_type='square', color=(0.6, 0.53, 0.63), textcolor=(0.75, 0.7, 0.8), @@ -444,7 +450,7 @@ class ManualGatherTab(GatherTab): v -= sub_scroll_height + 23 self._scrollwidget = scrlw = bui.scrollwidget( parent=self._container, - position=(190 if uiscale is bui.UIScale.SMALL else 225, v), + position=(290 if uiscale is bui.UIScale.SMALL else 225, v), size=(sub_scroll_width, sub_scroll_height), claims_left_right=True, ) @@ -469,7 +475,7 @@ class ManualGatherTab(GatherTab): scale=1.2, position=( ( - (190 if uiscale is bui.UIScale.SMALL else 225) + (240 if uiscale is bui.UIScale.SMALL else 225) + sub_scroll_width * 0.5 ), v + sub_scroll_height * 0.5, @@ -760,6 +766,7 @@ class ManualGatherTab(GatherTab): claims_left_right=bool(servers), claims_up_down=bool(servers), ) + assert self._scrollwidget is not None bui.widget( edit=self._scrollwidget, up_widget=self._favorites_text, @@ -864,6 +871,11 @@ class ManualGatherTab(GatherTab): config['Last Manual Party Connect Address'] = resolved_address config['Last Manual Party Connect Port'] = port config.commit() + + # Store UI location to return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + bs.connect_to_party(resolved_address, port=port) def _run_addr_fetch(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py index 3301204..5a6b5a9 100644 --- a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py +++ b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py @@ -53,6 +53,11 @@ class NetScanner: self._last_selected_host = host def _on_activate(self, host: dict[str, Any]) -> None: + + # Store UI location to return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + bs.connect_to_party(host['address']) def update(self) -> None: @@ -115,6 +120,7 @@ class NearbyGatherTab(GatherTab): region_left: float, region_bottom: float, ) -> bui.Widget: + # pylint: disable=too-many-positional-arguments c_width = region_width c_height = region_height - 20 sub_scroll_height = c_height - 85 diff --git a/dist/ba_data/python/bauiv1lib/gather/privatetab.py b/dist/ba_data/python/bauiv1lib/gather/privatetab.py index 223d6d2..a274429 100644 --- a/dist/ba_data/python/bauiv1lib/gather/privatetab.py +++ b/dist/ba_data/python/bauiv1lib/gather/privatetab.py @@ -22,8 +22,9 @@ from bacommon.net import ( PrivatePartyConnectResult, ) from bauiv1lib.gather import GatherTab +from bauiv1lib.play import PlaylistSelectContext -from bauiv1lib.gettokens import GetTokensWindow, show_get_tokens_prompt +from bauiv1lib.gettokens import show_get_tokens_prompt import bascenev1 as bs import bauiv1 as bui @@ -49,6 +50,7 @@ class State: """Our core state that persists while the app is running.""" sub_tab: SubTabType = SubTabType.JOIN + playlist_select_context: PlaylistSelectContext | None = None class PrivateGatherTab(GatherTab): @@ -60,7 +62,7 @@ class PrivateGatherTab(GatherTab): self._state: State = State() self._last_datacode_refresh_time: float | None = None self._hostingstate = PrivateHostingState() - self._v2state: bacommon.cloud.BSPrivatePartyResponse | None = None + self._v2state: bacommon.bs.PrivatePartyResponse | None = None self._join_sub_tab_text: bui.Widget | None = None self._host_sub_tab_text: bui.Widget | None = None self._update_timer: bui.AppTimer | None = None @@ -97,6 +99,7 @@ class PrivateGatherTab(GatherTab): region_left: float, region_bottom: float, ) -> bui.Widget: + # pylint: disable=too-many-positional-arguments self._c_width = region_width self._c_height = region_height - 20 self._container = bui.containerwidget( @@ -336,7 +339,7 @@ class PrivateGatherTab(GatherTab): if plus.accounts.primary is not None: with plus.accounts.primary: plus.cloud.send_message_cb( - bacommon.cloud.BSPrivatePartyMessage( + bacommon.bs.PrivatePartyMessage( need_datacode=( self._last_datacode_refresh_time is None or time.monotonic() @@ -352,7 +355,7 @@ class PrivateGatherTab(GatherTab): self._last_v2_state_query_time = now def _on_private_party_query_response( - self, response: bacommon.cloud.BSPrivatePartyResponse | Exception + self, response: bacommon.bs.PrivatePartyResponse | Exception ) -> None: if isinstance(response, Exception): self._debug_server_comm('got pp v2 state response (err)') @@ -548,18 +551,19 @@ class PrivateGatherTab(GatherTab): edit=self._join_party_code_text, on_return_press_call=btn.activate ) - def _on_get_tokens_press(self) -> None: - if self._waiting_for_start_stop_response: - return + # def _on_get_tokens_press(self) -> None: + # if self._waiting_for_start_stop_response: + # return - # Bring up get-tickets window and then kill ourself (we're on - # the overlay layer so we'd show up above it). - GetTokensWindow(origin_widget=self._get_tokens_button) + # # Bring up get-tickets window and then kill ourself (we're on + # # the overlay layer so we'd show up above it). + # GetTokensWindow(origin_widget=self._get_tokens_button) def _build_host_tab(self) -> None: # pylint: disable=too-many-branches # pylint: disable=too-many-statements - assert bui.app.classic is not None + classic = bui.app.classic + assert classic is not None plus = bui.app.plus assert plus is not None @@ -636,41 +640,7 @@ class PrivateGatherTab(GatherTab): and hostingstate.tickets_to_host_now != 0 and not havegoldpass ): - if not bui.app.ui_v1.use_toolbars: - - # Currently have no allow_token_purchases value like - # we had with tickets; just assuming we always allow. - if bool(True): - # if bui.app.classic.allow_ticket_purchases: - self._get_tokens_button = bui.buttonwidget( - parent=self._container, - position=( - self._c_width - 210 + 125, - self._c_height - 44, - ), - autoselect=True, - scale=0.6, - size=(120, 60), - textcolor=(1.0, 0.6, 0.0), - label=bui.charstr(bui.SpecialChar.TOKEN), - color=(0.65, 0.5, 0.8), - on_activate_call=self._on_get_tokens_press, - ) - else: - self._token_count_text = bui.textwidget( - parent=self._container, - scale=0.6, - position=( - self._c_width - 210 + 125, - self._c_height - 44, - ), - color=(1.0, 0.6, 0.0), - h_align='center', - v_align='center', - ) - - # Set initial token count. - self._update_currency_ui() + pass v = self._c_height - 90 if hostingstate.party_code is None: @@ -689,7 +659,7 @@ class PrivateGatherTab(GatherTab): ), ) - v -= 100 + v -= 90 if hostingstate.party_code is None: # We've got no current party running; show options to set # one up. @@ -718,12 +688,13 @@ class PrivateGatherTab(GatherTab): # If it appears we're coming back from playlist selection, # re-select our playlist button. - if bui.app.ui_v1.selecting_private_party_playlist: + if self._state.playlist_select_context is not None: + self._state.playlist_select_context = None bui.containerwidget( edit=self._container, selected_child=self._host_playlist_button, ) - bui.app.ui_v1.selecting_private_party_playlist = False + else: # We've got a current party; show its info. bui.textwidget( @@ -785,7 +756,7 @@ class PrivateGatherTab(GatherTab): autoselect=True, ) - v -= 120 + v -= 110 # Line above the main action button: @@ -952,7 +923,13 @@ class PrivateGatherTab(GatherTab): def _playlist_press(self) -> None: assert self._host_playlist_button is not None - self.window.playlist_select(origin_widget=self._host_playlist_button) + + self._state.playlist_select_context = PlaylistSelectContext() + + self.window.playlist_select( + origin_widget=self._host_playlist_button, + context=self._state.playlist_select_context, + ) def _host_copy_press(self) -> None: assert self._hostingstate.party_code is not None @@ -1104,6 +1081,11 @@ class PrivateGatherTab(GatherTab): return self._debug_server_comm('got valid connect response') assert cresult.address4 is not None and cresult.port is not None + + # Store UI location to return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + bs.connect_to_party(cresult.address4, port=cresult.port) except Exception: self._debug_server_comm('got connect response error') diff --git a/dist/ba_data/python/bauiv1lib/gather/publictab.py b/dist/ba_data/python/bauiv1lib/gather/publictab.py index d8b2f14..28b9376 100644 --- a/dist/ba_data/python/bauiv1lib/gather/publictab.py +++ b/dist/ba_data/python/bauiv1lib/gather/publictab.py @@ -96,6 +96,7 @@ class UIRow: ) -> None: """Update for the given data.""" # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments plus = bui.app.plus assert plus is not None @@ -161,7 +162,7 @@ class UIRow: Selection(party.get_key(), SelectionComponent.STATS_BUTTON), ), size=(120, 40), - position=(sub_scroll_width * 0.66 + hpos, 1 + vpos), + position=(sub_scroll_width - 270.0, 1 + vpos), scale=0.9, ) if existing_selection == Selection( @@ -175,7 +176,7 @@ class UIRow: text=str(party.size) + '/' + str(party.size_max), parent=columnwidget, size=(0, 0), - position=(sub_scroll_width * 0.86 + hpos, 20 + vpos), + position=(sub_scroll_width - 90, 20 + vpos), scale=0.7, color=(0.8, 0.8, 0.8), h_align='right', @@ -190,7 +191,7 @@ class UIRow: self._ping_widget = bui.textwidget( parent=columnwidget, size=(0, 0), - position=(sub_scroll_width * 0.94 + hpos, 20 + vpos), + position=(sub_scroll_width - 25.0, 20 + vpos), scale=0.7, h_align='right', v_align='center', @@ -366,10 +367,12 @@ class PublicGatherTab(GatherTab): self._last_server_list_query_time: float | None = None self._join_list_column: bui.Widget | None = None self._join_status_text: bui.Widget | None = None + self._join_status_spinner: bui.Widget | None = None self._no_servers_found_text: bui.Widget | None = None self._host_max_party_size_value: bui.Widget | None = None self._host_max_party_size_minus_button: bui.Widget | None = None self._host_max_party_size_plus_button: bui.Widget | None = None + self._join_sub_scroll_width: float | None = None self._host_status_text: bui.Widget | None = None self._signed_in = False self._ui_rows: list[UIRow] = [] @@ -404,6 +407,7 @@ class PublicGatherTab(GatherTab): region_left: float, region_bottom: float, ) -> bui.Widget: + # pylint: disable=too-many-positional-arguments c_width = region_width c_height = region_height - 20 self._container = bui.containerwidget( @@ -471,7 +475,8 @@ class PublicGatherTab(GatherTab): ) bui.widget(edit=self._join_text, right_widget=self._host_text) - # Attempt to fetch our local address so we have it for error messages. + # Attempt to fetch our local address so we have it for error + # messages. if self._local_address is None: AddrFetchThread(bui.WeakCall(self._fetch_local_addr_cb)).start() @@ -488,9 +493,9 @@ class PublicGatherTab(GatherTab): @override def save_state(self) -> None: # Save off a small number of parties with the lowest ping; we'll - # display these immediately when our UI comes back up which should - # be enough to make things feel nice and crisp while we do a full - # server re-query or whatnot. + # display these immediately when our UI comes back up which + # should be enough to make things feel nice and crisp while we + # do a full server re-query or whatnot. assert bui.app.classic is not None bui.app.ui_v1.window_states[type(self)] = State( sub_tab=self._sub_tab, @@ -520,7 +525,7 @@ class PublicGatherTab(GatherTab): self._next_entry_index = state.next_entry_index - # FIXME: should save/restore these too?.. + # FIXME: should save/restore these too? self._have_server_list_response = state.have_server_list_response self._have_valid_server_list = state.have_valid_server_list self._filter_value = state.filter_value @@ -536,9 +541,8 @@ class PublicGatherTab(GatherTab): if playsound: bui.getsound('click01').play() - # Reset our selection. - # (prevents selecting something way down the list if we switched away - # and came back) + # Reset our selection (prevents selecting something way down the + # list if we switched away and came back). self._selection = None self._have_user_selected_row = False @@ -576,7 +580,9 @@ class PublicGatherTab(GatherTab): c_width = region_width c_height = region_height - 20 sub_scroll_height = c_height - 125 - sub_scroll_width = 830 + self._join_sub_scroll_width = sub_scroll_width = min( + 1200, region_width - 80 + ) v = c_height - 35 v -= 60 filter_txt = bui.Lstr(resource='filterText') @@ -584,7 +590,7 @@ class PublicGatherTab(GatherTab): parent=self._container, text=self._filter_value, size=(350, 45), - position=(290, v - 10), + position=(c_width * 0.5 - 150, v - 10), h_align='left', v_align='center', editable=True, @@ -596,7 +602,7 @@ class PublicGatherTab(GatherTab): text=filter_txt, parent=self._container, size=(0, 0), - position=(270, v + 13), + position=(c_width * 0.5 - 170, v + 13), maxwidth=150, scale=0.8, color=(0.5, 0.46, 0.5), @@ -609,7 +615,7 @@ class PublicGatherTab(GatherTab): text=bui.Lstr(resource='nameText'), parent=self._container, size=(0, 0), - position=(90, v - 8), + position=((c_width - sub_scroll_width) * 0.5 + 50, v - 8), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), @@ -621,7 +627,10 @@ class PublicGatherTab(GatherTab): text=bui.Lstr(resource='gatherWindow.partySizeText'), parent=self._container, size=(0, 0), - position=(755, v - 8), + position=( + c_width * 0.5 + sub_scroll_width * 0.5 - 110, + v - 8, + ), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), @@ -633,7 +642,10 @@ class PublicGatherTab(GatherTab): text=bui.Lstr(resource='gatherWindow.pingText'), parent=self._container, size=(0, 0), - position=(825, v - 8), + position=( + c_width * 0.5 + sub_scroll_width * 0.5 - 30, + v - 8, + ), maxwidth=60, scale=0.6, color=(0.5, 0.46, 0.5), @@ -657,6 +669,9 @@ class PublicGatherTab(GatherTab): size=(400, 400), claims_left_right=True, ) + + # Create join status text and join spinner. Always make sure to + # update both of these together. self._join_status_text = bui.textwidget( parent=self._container, text='', @@ -670,6 +685,13 @@ class PublicGatherTab(GatherTab): color=(0.6, 0.6, 0.6), position=(c_width * 0.5, c_height * 0.5), ) + self._join_status_spinner = bui.spinnerwidget( + parent=self._container, + position=(c_width * 0.5, c_height * 0.5), + style='bomb', + size=64, + ) + self._no_servers_found_text = bui.textwidget( parent=self._container, text='', @@ -707,6 +729,9 @@ class PublicGatherTab(GatherTab): ) v -= 30 + # Nudge party name and size values to be mostly centered. + xoffs = region_width * 0.5 - 500 + party_name_text = bui.Lstr( resource='gatherWindow.partyNameText', fallback_resource='editGameListWindow.nameText', @@ -720,14 +745,14 @@ class PublicGatherTab(GatherTab): maxwidth=200, scale=0.8, color=bui.app.ui_v1.infotextcolor, - position=(210, v - 9), + position=(210 + xoffs, v - 9), text=party_name_text, ) self._host_name_text = bui.textwidget( parent=self._container, editable=True, size=(535, 40), - position=(230, v - 30), + position=(230 + xoffs, v - 30), text=bui.app.config.get('Public Party Name', ''), maxwidth=494, shadow=0.3, @@ -747,7 +772,7 @@ class PublicGatherTab(GatherTab): maxwidth=200, scale=0.8, color=bui.app.ui_v1.infotextcolor, - position=(210, v - 9), + position=(210 + xoffs, v - 9), text=bui.Lstr( resource='maxPartySizeText', fallback_resource='maxConnectionsText', @@ -760,7 +785,7 @@ class PublicGatherTab(GatherTab): v_align='center', scale=1.2, color=(1, 1, 1), - position=(240, v - 9), + position=(240 + xoffs, v - 9), text=str(bs.get_public_party_max_size()), ) btn1 = self._host_max_party_size_minus_button = bui.buttonwidget( @@ -769,7 +794,7 @@ class PublicGatherTab(GatherTab): on_activate_call=bui.WeakCall( self._on_max_public_party_size_minus_press ), - position=(280, v - 26), + position=(280 + xoffs, v - 26), label='-', autoselect=True, ) @@ -779,7 +804,7 @@ class PublicGatherTab(GatherTab): on_activate_call=bui.WeakCall( self._on_max_public_party_size_plus_press ), - position=(350, v - 26), + position=(350 + xoffs, v - 26), label='+', autoselect=True, ) @@ -811,6 +836,7 @@ class PublicGatherTab(GatherTab): bui.widget(edit=self._host_name_text, down_widget=btn2) bui.widget(edit=btn2, up_widget=self._host_name_text) bui.widget(edit=btn1, up_widget=self._host_name_text) + assert self._join_text is not None bui.widget(edit=self._join_text, down_widget=self._host_name_text) v -= 10 self._host_status_text = bui.textwidget( @@ -839,8 +865,8 @@ class PublicGatherTab(GatherTab): position=(c_width * 0.5, v), ) - # If public sharing is already on, - # launch a status-check immediately. + # If public sharing is already on, launch a status-check + # immediately. if bs.get_public_party_enabled(): self._do_status_check() @@ -864,9 +890,9 @@ class PublicGatherTab(GatherTab): self._pending_party_infos += parties_in # To avoid causing a stutter here, we do most processing of - # these entries incrementally in our _update() method. - # The one thing we do here is prune parties not contained in - # this result. + # these entries incrementally in our _update() method. The one + # thing we do here is prune parties not contained in this + # result. for partyval in list(self._parties.values()): partyval.claimed = False for party_in in parties_in: @@ -884,7 +910,6 @@ class PublicGatherTab(GatherTab): self._parties_sorted = [p for p in self._parties_sorted if p[1].claimed] self._party_lists_dirty = True - # self._update_server_list() if DEBUG_PROCESSING: print( f'Handled public party query results in ' @@ -897,11 +922,6 @@ class PublicGatherTab(GatherTab): plus = bui.app.plus assert plus is not None - # Special case: if a party-queue window is up, don't do any of this - # (keeps things smoother). - # if bui.app.ui.have_party_queue_window: - # return - if self._sub_tab is SubTabType.JOIN: # Keep our filter-text up to date from the UI. text = self._filter_text @@ -911,10 +931,10 @@ class PublicGatherTab(GatherTab): self._filter_value = filter_value self._party_lists_dirty = True - # Also wipe out party clean-row states. - # (otherwise if a party disappears from a row due to - # filtering and then reappears on that same row when - # the filter is removed it may not update) + # Also wipe out party clean-row states (otherwise if + # a party disappears from a row due to filtering and + # then reappears on that same row when the filter is + # removed it may not update). for party in self._parties.values(): party.clean_display_index = None @@ -940,37 +960,38 @@ class PublicGatherTab(GatherTab): name = cast(str, bui.textwidget(query=self._host_name_text)) bs.set_public_party_name(name) - # Update status text. - status_text = self._join_status_text - if status_text: + # Update status text and loading spinner. + if self._join_status_text: + assert self._join_status_spinner if not signed_in: bui.textwidget( - edit=status_text, text=bui.Lstr(resource='notSignedInText') + edit=self._join_status_text, + text=bui.Lstr(resource='notSignedInText'), ) + bui.spinnerwidget(edit=self._join_status_spinner, visible=False) else: - # If we have a valid list, show no status; just the list. - # Otherwise show either 'loading...' or 'error' depending - # on whether this is our first go-round. + # If we have a valid list, show no status; just the + # list. Otherwise show either 'loading...' or 'error' + # depending on whether this is our first go-round. if self._have_valid_server_list: - bui.textwidget(edit=status_text, text='') + bui.textwidget(edit=self._join_status_text, text='') + bui.spinnerwidget( + edit=self._join_status_spinner, visible=False + ) else: if self._have_server_list_response: bui.textwidget( - edit=status_text, + edit=self._join_status_text, text=bui.Lstr(resource='errorText'), ) + bui.spinnerwidget( + edit=self._join_status_spinner, visible=False + ) else: - bui.textwidget( - edit=status_text, - text=bui.Lstr( - value='${A}...', - subs=[ - ( - '${A}', - bui.Lstr(resource='store.loadingText'), - ) - ], - ), + # Show our loading spinner. + bui.textwidget(edit=self._join_status_text, text='') + bui.spinnerwidget( + edit=self._join_status_spinner, visible=True ) self._update_party_rows() @@ -1001,19 +1022,15 @@ class PublicGatherTab(GatherTab): self._ui_rows = self._ui_rows[:-clipcount] # If we have no parties to show, we're done. - if not self._parties_displayed: - text = self._join_status_text - if ( - plus.get_v1_account_state() == 'signed_in' - and cast(str, bui.textwidget(query=text)) == '' - ): - bui.textwidget( - edit=self._no_servers_found_text, - text=bui.Lstr(resource='noServersFoundText'), - ) + if self._have_valid_server_list and not self._parties_displayed: + bui.textwidget( + edit=self._no_servers_found_text, + text=bui.Lstr(resource='noServersFoundText'), + ) return - sub_scroll_width = 830 + assert self._join_sub_scroll_width is not None + sub_scroll_width = self._join_sub_scroll_width lineheight = 42 sub_scroll_height = lineheight * len(self._parties_displayed) + 50 bui.containerwidget( @@ -1021,27 +1038,27 @@ class PublicGatherTab(GatherTab): ) # Any time our height changes, reset the refresh back to the top - # so we don't see ugly empty spaces appearing during initial list - # filling. + # so we don't see ugly empty spaces appearing during initial + # list filling. if sub_scroll_height != self._last_sub_scroll_height: self._refresh_ui_row = 0 self._last_sub_scroll_height = sub_scroll_height - # Also note that we need to redisplay everything since its pos - # will have changed.. :( + # Also note that we need to redisplay everything since its + # pos will have changed.. :( for party in self._parties.values(): party.clean_display_index = None - # Ew; this rebuilding generates deferred selection callbacks - # so we need to push deferred notices so we know to ignore them. + # Ew; this rebuilding generates deferred selection callbacks so + # we need to push deferred notices so we know to ignore them. def refresh_on() -> None: self._refreshing_list = True bui.pushcall(refresh_on) - # Ok, now here's the deal: we want to avoid creating/updating this - # entire list at one time because it will lead to hitches. So we - # refresh individual rows quickly in a loop. + # Ok, now here's the deal: we want to avoid creating/updating + # this entire list at one time because it will lead to hitches. + # So we refresh individual rows quickly in a loop. rowcount = min(12, len(self._parties_displayed)) party_vals_displayed = list(self._parties_displayed.values()) @@ -1051,9 +1068,10 @@ class PublicGatherTab(GatherTab): self._ui_rows.append(UIRow()) refresh_row = len(self._ui_rows) - 1 - # For the first few seconds after getting our first server-list, - # refresh only the top section of the list; this allows the lowest - # ping servers to show up more quickly. + # For the first few seconds after getting our first + # server-list, refresh only the top section of the list; + # this allows the lowest ping servers to show up more + # quickly. if self._first_valid_server_list_time is not None: if time.time() - self._first_valid_server_list_time < 4.0: if refresh_row > 40: @@ -1083,7 +1101,8 @@ class PublicGatherTab(GatherTab): def _process_pending_party_infos(self) -> None: starttime = time.time() - # We want to do this in small enough pieces to not cause UI hitches. + # We want to do this in small enough pieces to not cause UI + # hitches. chunksize = 30 parties_in = self._pending_party_infos[:chunksize] self._pending_party_infos = self._pending_party_infos[chunksize:] @@ -1167,16 +1186,16 @@ class PublicGatherTab(GatherTab): else: self._parties_displayed = dict(self._parties_sorted) - # Any time our selection disappears from the displayed list, go back to - # auto-selecting the top entry. + # Any time our selection disappears from the displayed list, go + # back to auto-selecting the top entry. if ( self._selection is not None and self._selection.entry_key not in self._parties_displayed ): self._have_user_selected_row = False - # Whenever the user hasn't selected something, keep the first visible - # row selected. + # Whenever the user hasn't selected something, keep the first + # visible row selected. if not self._have_user_selected_row and self._parties_displayed: firstpartykey = next(iter(self._parties_displayed)) self._selection = Selection(firstpartykey, SelectionComponent.NAME) @@ -1228,8 +1247,8 @@ class PublicGatherTab(GatherTab): party.next_ping_time <= now and bui.app.classic.ping_thread_count < 15 ): - # Crank the interval up for high-latency or non-responding - # parties to save us some useless work. + # Crank the interval up for high-latency or + # non-responding parties to save us some useless work. mult = 1 if party.ping_responses == 0: if party.ping_attempts > 4: @@ -1259,16 +1278,16 @@ class PublicGatherTab(GatherTab): def _ping_callback( self, address: str, port: int | None, result: float | None ) -> None: - # Look for a widget corresponding to this target. - # If we find one, update our list. + # Look for a widget corresponding to this target. If we find + # one, update our list. party_key = f'{address}_{port}' party = self._parties.get(party_key) if party is not None: if result is not None: party.ping_responses += 1 - # We now smooth ping a bit to reduce jumping around in the list - # (only where pings are relatively good). + # We now smooth ping a bit to reduce jumping around in the + # list (only where pings are relatively good). current_ping = party.ping if current_ping is not None and result is not None and result < 150: smoothing = 0.7 @@ -1371,7 +1390,7 @@ class PublicGatherTab(GatherTab): ) def _on_start_advertizing_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None @@ -1395,8 +1414,8 @@ class PublicGatherTab(GatherTab): bui.getsound('shieldUp').play() bs.set_public_party_enabled(True) - # In GUI builds we want to authenticate clients only when hosting - # public parties. + # In GUI builds we want to authenticate clients only when + # hosting public parties. bs.set_authenticate_clients(True) self._do_status_check() @@ -1412,8 +1431,8 @@ class PublicGatherTab(GatherTab): def _on_stop_advertising_press(self) -> None: bs.set_public_party_enabled(False) - # In GUI builds we want to authenticate clients only when hosting - # public parties. + # In GUI builds we want to authenticate clients only when + # hosting public parties. bs.set_authenticate_clients(False) bui.getsound('shieldDown').play() text = self._host_status_text @@ -1446,6 +1465,10 @@ class PublicGatherTab(GatherTab): address = party.address port = party.port + # Store UI location to return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + # Rate limit this a bit. now = time.time() last_connect_time = self._last_connect_attempt_time diff --git a/dist/ba_data/python/bauiv1lib/getcurrency.py b/dist/ba_data/python/bauiv1lib/getcurrency.py deleted file mode 100644 index 7883f61..0000000 --- a/dist/ba_data/python/bauiv1lib/getcurrency.py +++ /dev/null @@ -1,796 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for purchasing/acquiring currency.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from efro.util import utc_now - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class GetCurrencyWindow(bui.Window): - """Window for purchasing/acquiring currency.""" - - def __init__( - self, - transition: str = 'in_right', - from_modal_store: bool = False, - modal: bool = False, - origin_widget: bui.Widget | None = None, - store_back_location: str | None = None, - ): - # pylint: disable=too-many-statements - # pylint: disable=too-many-locals - - plus = bui.app.plus - assert plus is not None - - bui.set_analytics_screen('Get Tickets Window') - - self._transitioning_out = False - self._store_back_location = store_back_location # ew. - - self._ad_button_greyed = False - self._smooth_update_timer: bui.AppTimer | None = None - self._ad_button = None - self._ad_label = None - self._ad_image = None - self._ad_time_text = None - - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - self._width = 1000.0 if uiscale is bui.UIScale.SMALL else 800.0 - x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 - self._height = 480.0 - - self._modal = modal - self._from_modal_store = from_modal_store - self._r = 'getTicketsWindow' - - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - scale_origin_stack_offset=scale_origin, - color=(0.4, 0.37, 0.55), - scale=( - 1.63 - if uiscale is bui.UIScale.SMALL - else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -3) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) - ) - - btn = bui.buttonwidget( - parent=self._root_widget, - position=(55 + x_inset, self._height - 79), - size=(140, 60), - scale=1.0, - autoselect=True, - label=bui.Lstr(resource='doneText' if modal else 'backText'), - button_type='regular' if modal else 'back', - on_activate_call=self._back, - ) - - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 55), - size=(0, 0), - color=bui.app.ui_v1.title_color, - scale=1.2, - h_align='center', - v_align='center', - text=bui.Lstr(resource=self._r + '.titleText'), - maxwidth=290, - ) - - if not modal: - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - b_size = (220.0, 180.0) - v = self._height - b_size[1] - 80 - spacing = 1 - - self._ad_button = None - - def _add_button( - item: str, - position: tuple[float, float], - size: tuple[float, float], - label: bui.Lstr, - price: str | None = None, - tex_name: str | None = None, - tex_opacity: float = 1.0, - tex_scale: float = 1.0, - enabled: bool = True, - text_scale: float = 1.0, - ) -> bui.Widget: - btn2 = bui.buttonwidget( - parent=self._root_widget, - position=position, - button_type='square', - size=size, - label='', - autoselect=True, - color=None if enabled else (0.5, 0.5, 0.5), - on_activate_call=( - bui.Call(self._purchase, item) - if enabled - else self._disabled_press - ), - ) - txt = bui.textwidget( - parent=self._root_widget, - text=label, - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.3, - ), - scale=text_scale, - maxwidth=size[0] * 0.75, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.7, 0.9, 0.7, 1.0 if enabled else 0.2), - ) - if price is not None and enabled: - bui.textwidget( - parent=self._root_widget, - text=price, - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.17, - ), - scale=0.7, - maxwidth=size[0] * 0.75, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.4, 0.9, 0.4, 1.0), - ) - i = None - if tex_name is not None: - tex_size = 90.0 * tex_scale - i = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture(tex_name), - position=( - position[0] + size[0] * 0.5 - tex_size * 0.5, - position[1] + size[1] * 0.66 - tex_size * 0.5, - ), - size=(tex_size, tex_size), - draw_controller=btn2, - opacity=tex_opacity * (1.0 if enabled else 0.25), - ) - if item == 'ad': - self._ad_button = btn2 - self._ad_label = txt - assert i is not None - self._ad_image = i - self._ad_time_text = bui.textwidget( - parent=self._root_widget, - text='1m 10s', - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.5, - ), - scale=text_scale * 1.2, - maxwidth=size[0] * 0.85, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.4, 0.9, 0.4, 1.0), - ) - return btn2 - - rsrc = self._r + '.ticketsText' - - c2txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val('tickets2Amount', 500) - ), - ) - ], - ) - c3txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets3Amount', 1500 - ) - ), - ) - ], - ) - c4txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets4Amount', 5000 - ) - ), - ) - ], - ) - c5txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets5Amount', 15000 - ) - ), - ) - ], - ) - - h = 110.0 - - # Enable buttons if we have prices. - tickets2_price = plus.get_price('tickets2') - tickets3_price = plus.get_price('tickets3') - tickets4_price = plus.get_price('tickets4') - tickets5_price = plus.get_price('tickets5') - - # TEMP - # tickets1_price = '$0.99' - # tickets2_price = '$4.99' - # tickets3_price = '$9.99' - # tickets4_price = '$19.99' - # tickets5_price = '$49.99' - - _add_button( - 'tickets2', - enabled=(tickets2_price is not None), - position=( - self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h, - v, - ), - size=b_size, - label=c2txt, - price=tickets2_price, - tex_name='ticketsMore', - ) # 0.99-ish - _add_button( - 'tickets3', - enabled=(tickets3_price is not None), - position=( - self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h, - v, - ), - size=b_size, - label=c3txt, - price=tickets3_price, - tex_name='ticketRoll', - ) # 4.99-ish - v -= b_size[1] - 5 - _add_button( - 'tickets4', - enabled=(tickets4_price is not None), - position=( - self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h, - v, - ), - size=b_size, - label=c4txt, - price=tickets4_price, - tex_name='ticketRollBig', - tex_scale=1.2, - ) # 9.99-ish - _add_button( - 'tickets5', - enabled=(tickets5_price is not None), - position=( - self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h, - v, - ), - size=b_size, - label=c5txt, - price=tickets5_price, - tex_name='ticketRolls', - tex_scale=1.2, - ) # 19.99-ish - - self._enable_ad_button = plus.has_video_ads() - h = self._width * 0.5 + 110.0 - v = self._height - b_size[1] - 115.0 - - if self._enable_ad_button: - h_offs = 35 - b_size_3 = (150, 120) - cdb = _add_button( - 'ad', - position=(h + h_offs, v), - size=b_size_3, - label=bui.Lstr( - resource=self._r + '.ticketsFromASponsorText', - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'sponsorTickets', 5 - ) - ), - ) - ], - ), - tex_name='ticketsMore', - enabled=self._enable_ad_button, - tex_opacity=0.6, - tex_scale=0.7, - text_scale=0.7, - ) - bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7)) - - self._ad_free_text = bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource=self._r + '.freeText'), - position=( - h + h_offs + b_size_3[0] * 0.5, - v + b_size_3[1] * 0.5 + 25, - ), - size=(0, 0), - color=(1, 1, 0, 1.0), - draw_controller=cdb, - rotate=15, - shadow=1.0, - maxwidth=150, - h_align='center', - v_align='center', - scale=1.0, - ) - v -= 125 - else: - v -= 20 - - if bool(True): - h_offs = 35 - b_size_3 = (150, 120) - cdb = _add_button( - 'app_invite', - position=(h + h_offs, v), - size=b_size_3, - label=bui.Lstr( - resource='gatherWindow.earnTicketsForRecommendingText', - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'sponsorTickets', 5 - ) - ), - ) - ], - ), - tex_name='ticketsMore', - enabled=True, - tex_opacity=0.6, - tex_scale=0.7, - text_scale=0.7, - ) - bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7)) - - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource=self._r + '.freeText'), - position=( - h + h_offs + b_size_3[0] * 0.5, - v + b_size_3[1] * 0.5 + 25, - ), - size=(0, 0), - color=(1, 1, 0, 1.0), - draw_controller=cdb, - rotate=15, - shadow=1.0, - maxwidth=150, - h_align='center', - v_align='center', - scale=1.0, - ) - tc_y_offs = 0 - else: - tc_y_offs = 0 - - h = self._width - (185 + x_inset) - v = self._height - 95 + tc_y_offs - - txt1 = ( - bui.Lstr(resource=self._r + '.youHaveText') - .evaluate() - .partition('${COUNT}')[0] - .strip() - ) - txt2 = ( - bui.Lstr(resource=self._r + '.youHaveText') - .evaluate() - .rpartition('${COUNT}')[-1] - .strip() - ) - - bui.textwidget( - parent=self._root_widget, - text=txt1, - position=(h, v), - size=(0, 0), - color=(0.5, 0.5, 0.6), - maxwidth=200, - h_align='center', - v_align='center', - scale=0.8, - ) - v -= 30 - self._ticket_count_text = bui.textwidget( - parent=self._root_widget, - position=(h, v), - size=(0, 0), - color=(0.2, 1.0, 0.2), - maxwidth=200, - h_align='center', - v_align='center', - scale=1.6, - ) - v -= 30 - bui.textwidget( - parent=self._root_widget, - text=txt2, - position=(h, v), - size=(0, 0), - color=(0.5, 0.5, 0.6), - maxwidth=200, - h_align='center', - v_align='center', - scale=0.8, - ) - - self._ticking_sound: bui.Sound | None = None - self._smooth_ticket_count: float | None = None - self._ticket_count = 0 - self._update() - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - self._smooth_increase_speed = 1.0 - - def __del__(self) -> None: - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - - def _smooth_update(self) -> None: - if not self._ticket_count_text: - self._smooth_update_timer = None - return - - finished = False - - # If we're going down, do it immediately. - assert self._smooth_ticket_count is not None - if int(self._smooth_ticket_count) >= self._ticket_count: - self._smooth_ticket_count = float(self._ticket_count) - finished = True - else: - # We're going up; start a sound if need be. - self._smooth_ticket_count = min( - self._smooth_ticket_count + 1.0 * self._smooth_increase_speed, - self._ticket_count, - ) - if int(self._smooth_ticket_count) >= self._ticket_count: - finished = True - self._smooth_ticket_count = float(self._ticket_count) - elif self._ticking_sound is None: - self._ticking_sound = bui.getsound('scoreIncrease') - self._ticking_sound.play() - - bui.textwidget( - edit=self._ticket_count_text, - text=str(int(self._smooth_ticket_count)), - ) - - # If we've reached the target, kill the timer/sound/etc. - if finished: - self._smooth_update_timer = None - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - bui.getsound('cashRegister2').play() - - def _update(self) -> None: - import datetime - - plus = bui.app.plus - assert plus is not None - - # If we somehow get signed out, just die. - if plus.get_v1_account_state() != 'signed_in': - self._back() - return - - self._ticket_count = plus.get_v1_account_ticket_count() - - # Update our incentivized ad button depending on whether ads are - # available. - if self._ad_button is not None: - next_reward_ad_time = plus.get_v1_account_misc_read_val_2( - 'nextRewardAdTime', None - ) - if next_reward_ad_time is not None: - next_reward_ad_time = datetime.datetime.fromtimestamp( - next_reward_ad_time, datetime.UTC - ) - now = utc_now() - if plus.have_incentivized_ad() and ( - next_reward_ad_time is None or next_reward_ad_time <= now - ): - self._ad_button_greyed = False - bui.buttonwidget(edit=self._ad_button, color=(0.65, 0.5, 0.7)) - bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 1.0)) - bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 1)) - bui.imagewidget(edit=self._ad_image, opacity=0.6) - bui.textwidget(edit=self._ad_time_text, text='') - else: - self._ad_button_greyed = True - bui.buttonwidget(edit=self._ad_button, color=(0.5, 0.5, 0.5)) - bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 0.2)) - bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 0.2)) - bui.imagewidget(edit=self._ad_image, opacity=0.6 * 0.25) - sval: str | bui.Lstr - if ( - next_reward_ad_time is not None - and next_reward_ad_time > now - ): - sval = bui.timestring( - (next_reward_ad_time - now).total_seconds(), centi=False - ) - else: - sval = '' - bui.textwidget(edit=self._ad_time_text, text=sval) - - # If this is our first update, assign immediately; otherwise kick - # off a smooth transition if the value has changed. - if self._smooth_ticket_count is None: - self._smooth_ticket_count = float(self._ticket_count) - self._smooth_update() # will set the text widget - - elif ( - self._ticket_count != int(self._smooth_ticket_count) - and self._smooth_update_timer is None - ): - self._smooth_update_timer = bui.AppTimer( - 0.05, bui.WeakCall(self._smooth_update), repeat=True - ) - diff = abs(float(self._ticket_count) - self._smooth_ticket_count) - self._smooth_increase_speed = ( - diff / 100.0 - if diff >= 5000 - else ( - diff / 50.0 - if diff >= 1500 - else diff / 30.0 if diff >= 500 else diff / 15.0 - ) - ) - - def _disabled_press(self) -> None: - plus = bui.app.plus - assert plus is not None - - # If we're on a platform without purchases, inform the user they - # can link their accounts and buy stuff elsewhere. - app = bui.app - assert app.classic is not None - if ( - app.env.test - or ( - app.classic.platform == 'android' - and app.classic.subplatform in ['oculus', 'cardboard'] - ) - ) and plus.get_v1_account_misc_read_val('allowAccountLinking2', False): - bui.screenmessage( - bui.Lstr(resource=self._r + '.unavailableLinkAccountText'), - color=(1, 0.5, 0), - ) - else: - bui.screenmessage( - bui.Lstr(resource=self._r + '.unavailableText'), - color=(1, 0.5, 0), - ) - bui.getsound('error').play() - - def _purchase(self, item: str) -> None: - from bauiv1lib import account - from bauiv1lib import appinvite - - plus = bui.app.plus - assert plus is not None - - if bui.app.classic is None: - raise RuntimeError('This requires classic support.') - - if item == 'app_invite': - if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() - return - appinvite.handle_app_invites_press() - return - - # Here we ping the server to ask if it's valid for us to - # purchase this.. (better to fail now than after we've paid - # locally). - app = bui.app - assert app.classic is not None - bui.app.classic.master_server_v1_get( - 'bsAccountPurchaseCheck', - { - 'item': item, - 'platform': app.classic.platform, - 'subplatform': app.classic.subplatform, - 'version': app.env.engine_version, - 'buildNumber': app.env.engine_build_number, - }, - callback=bui.WeakCall(self._purchase_check_result, item), - ) - - def _purchase_check_result( - self, item: str, result: dict[str, Any] | None - ) -> None: - if result is None: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='internal.unavailableNoConnectionText'), - color=(1, 0, 0), - ) - else: - if result['allow']: - self._do_purchase(item) - else: - if result['reason'] == 'versionTooOld': - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='getTicketsWindow.versionTooOldText'), - color=(1, 0, 0), - ) - else: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='getTicketsWindow.unavailableText'), - color=(1, 0, 0), - ) - - # Actually start the purchase locally. - def _do_purchase(self, item: str) -> None: - plus = bui.app.plus - assert plus is not None - - if item == 'ad': - import datetime - - # If ads are disabled until some time, error. - next_reward_ad_time = plus.get_v1_account_misc_read_val_2( - 'nextRewardAdTime', None - ) - if next_reward_ad_time is not None: - next_reward_ad_time = datetime.datetime.fromtimestamp( - next_reward_ad_time, datetime.UTC - ) - now = utc_now() - if ( - next_reward_ad_time is not None and next_reward_ad_time > now - ) or self._ad_button_greyed: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.unavailableTemporarilyText' - ), - color=(1, 0, 0), - ) - elif self._enable_ad_button: - assert bui.app.classic is not None - bui.app.classic.ads.show_ad('tickets') - else: - plus.purchase(item) - - def _back(self) -> None: - from bauiv1lib.store import browser - - # No-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - if self._transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - window = browser.StoreBrowserWindow( - transition='in_left', - modal=self._from_modal_store, - back_location=self._store_back_location, - ).get_root_widget() - if not self._from_modal_store: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - window, from_window=self._root_widget - ) - self._transitioning_out = True - - -def show_get_tickets_prompt() -> None: - """Show a 'not enough tickets' prompt with an option to purchase more. - - Note that the purchase option may not always be available - depending on the build of the game. - """ - from bauiv1lib.confirm import ConfirmWindow - - assert bui.app.classic is not None - if bui.app.classic.allow_ticket_purchases: - ConfirmWindow( - bui.Lstr( - translate=( - 'serverResponses', - 'You don\'t have enough tickets for this!', - ) - ), - lambda: GetCurrencyWindow(modal=True), - ok_text=bui.Lstr(resource='getTicketsWindow.titleText'), - width=460, - height=130, - ) - else: - ConfirmWindow( - bui.Lstr( - translate=( - 'serverResponses', - 'You don\'t have enough tickets for this!', - ) - ), - cancel_button=False, - width=460, - height=130, - ) diff --git a/dist/ba_data/python/bauiv1lib/gettickets.py b/dist/ba_data/python/bauiv1lib/gettickets.py deleted file mode 100644 index 3d40a4a..0000000 --- a/dist/ba_data/python/bauiv1lib/gettickets.py +++ /dev/null @@ -1,881 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for purchasing/acquiring currency.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from efro.util import utc_now - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class GetTicketsWindow(bui.Window): - """Window for purchasing/acquiring classic tickets.""" - - def __init__( - self, - transition: str = 'in_right', - from_modal_store: bool = False, - modal: bool = False, - origin_widget: bui.Widget | None = None, - store_back_location: str | None = None, - ): - # pylint: disable=too-many-statements - # pylint: disable=too-many-locals - - plus = bui.app.plus - assert plus is not None - - bui.set_analytics_screen('Get Tickets Window') - - self._transitioning_out = False - self._store_back_location = store_back_location # ew. - - self._ad_button_greyed = False - self._smooth_update_timer: bui.AppTimer | None = None - self._ad_button = None - self._ad_label = None - self._ad_image = None - self._ad_time_text = None - - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - self._width = 1000.0 if uiscale is bui.UIScale.SMALL else 800.0 - x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 - self._height = 480.0 - - self._modal = modal - self._from_modal_store = from_modal_store - self._r = 'getTicketsWindow' - - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 - - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - scale_origin_stack_offset=scale_origin, - color=(0.4, 0.37, 0.55), - scale=( - 1.63 - if uiscale is bui.UIScale.SMALL - else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -3) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) - ) - - btn = bui.buttonwidget( - parent=self._root_widget, - position=(55 + x_inset, self._height - 79), - size=(140, 60), - scale=1.0, - autoselect=True, - label=bui.Lstr(resource='doneText' if modal else 'backText'), - button_type='regular' if modal else 'back', - on_activate_call=self._back, - ) - - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - - bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5 - 15, self._height - 47), - size=(0, 0), - color=bui.app.ui_v1.title_color, - scale=1.2, - h_align='right', - v_align='center', - text=bui.Lstr(resource=f'{self._r}.titleText'), - # text='Testing really long text here blah blah', - maxwidth=260, - ) - - # Get Tokens button - bui.buttonwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 72), - color=(0.65, 0.5, 0.7), - textcolor=bui.app.ui_v1.title_color, - size=(190, 50), - autoselect=True, - label=bui.Lstr(resource='tokens.getTokensText'), - on_activate_call=self._get_tokens_press, - ) - - # 'New!' by tokens button - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource='newExclaimText'), - position=(self._width * 0.5 + 25, self._height - 32), - size=(0, 0), - color=(1, 1, 0, 1.0), - rotate=22, - shadow=1.0, - maxwidth=150, - h_align='center', - v_align='center', - scale=0.7, - ) - - if not modal: - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - b_size = (220.0, 180.0) - v = self._height - b_size[1] - 80 - spacing = 1 - - self._ad_button = None - - def _add_button( - item: str, - position: tuple[float, float], - size: tuple[float, float], - label: bui.Lstr, - price: str | None = None, - tex_name: str | None = None, - tex_opacity: float = 1.0, - tex_scale: float = 1.0, - enabled: bool = True, - text_scale: float = 1.0, - ) -> bui.Widget: - btn2 = bui.buttonwidget( - parent=self._root_widget, - position=position, - button_type='square', - size=size, - label='', - autoselect=True, - color=None if enabled else (0.5, 0.5, 0.5), - on_activate_call=( - bui.Call(self._purchase, item) - if enabled - else self._disabled_press - ), - ) - txt = bui.textwidget( - parent=self._root_widget, - text=label, - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.3, - ), - scale=text_scale, - maxwidth=size[0] * 0.75, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.7, 0.9, 0.7, 1.0 if enabled else 0.2), - ) - if price is not None and enabled: - bui.textwidget( - parent=self._root_widget, - text=price, - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.17, - ), - scale=0.7, - maxwidth=size[0] * 0.75, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.4, 0.9, 0.4, 1.0), - ) - i = None - if tex_name is not None: - tex_size = 90.0 * tex_scale - i = bui.imagewidget( - parent=self._root_widget, - texture=bui.gettexture(tex_name), - position=( - position[0] + size[0] * 0.5 - tex_size * 0.5, - position[1] + size[1] * 0.66 - tex_size * 0.5, - ), - size=(tex_size, tex_size), - draw_controller=btn2, - opacity=tex_opacity * (1.0 if enabled else 0.25), - ) - if item == 'ad': - self._ad_button = btn2 - self._ad_label = txt - assert i is not None - self._ad_image = i - self._ad_time_text = bui.textwidget( - parent=self._root_widget, - text='1m 10s', - position=( - position[0] + size[0] * 0.5, - position[1] + size[1] * 0.5, - ), - scale=text_scale * 1.2, - maxwidth=size[0] * 0.85, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=btn2, - color=(0.4, 0.9, 0.4, 1.0), - ) - return btn2 - - rsrc = f'{self._r}.ticketsText' - - c2txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val('tickets2Amount', 500) - ), - ) - ], - ) - c3txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets3Amount', 1500 - ) - ), - ) - ], - ) - c4txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets4Amount', 5000 - ) - ), - ) - ], - ) - c5txt = bui.Lstr( - resource=rsrc, - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'tickets5Amount', 15000 - ) - ), - ) - ], - ) - - h = 110.0 - - # Enable buttons if we have prices. - tickets2_price = plus.get_price('tickets2') - tickets3_price = plus.get_price('tickets3') - tickets4_price = plus.get_price('tickets4') - tickets5_price = plus.get_price('tickets5') - - # TEMP - # tickets1_price = '$0.99' - # tickets2_price = '$4.99' - # tickets3_price = '$9.99' - # tickets4_price = '$19.99' - # tickets5_price = '$49.99' - - _add_button( - 'tickets2', - enabled=(tickets2_price is not None), - position=( - self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h, - v, - ), - size=b_size, - label=c2txt, - price=tickets2_price, - tex_name='ticketsMore', - ) # 0.99-ish - _add_button( - 'tickets3', - enabled=(tickets3_price is not None), - position=( - self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h, - v, - ), - size=b_size, - label=c3txt, - price=tickets3_price, - tex_name='ticketRoll', - ) # 4.99-ish - v -= b_size[1] - 5 - _add_button( - 'tickets4', - enabled=(tickets4_price is not None), - position=( - self._width * 0.5 - spacing * 1.5 - b_size[0] * 2.0 + h, - v, - ), - size=b_size, - label=c4txt, - price=tickets4_price, - tex_name='ticketRollBig', - tex_scale=1.2, - ) # 9.99-ish - _add_button( - 'tickets5', - enabled=(tickets5_price is not None), - position=( - self._width * 0.5 - spacing * 0.5 - b_size[0] * 1.0 + h, - v, - ), - size=b_size, - label=c5txt, - price=tickets5_price, - tex_name='ticketRolls', - tex_scale=1.2, - ) # 19.99-ish - - self._enable_ad_button = plus.has_video_ads() - h = self._width * 0.5 + 110.0 - v = self._height - b_size[1] - 115.0 - - if self._enable_ad_button: - h_offs = 35 - b_size_3 = (150, 120) - cdb = _add_button( - 'ad', - position=(h + h_offs, v), - size=b_size_3, - label=bui.Lstr( - resource=f'{self._r}.ticketsFromASponsorText', - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'sponsorTickets', 5 - ) - ), - ) - ], - ), - tex_name='ticketsMore', - enabled=self._enable_ad_button, - tex_opacity=0.6, - tex_scale=0.7, - text_scale=0.7, - ) - bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7)) - - self._ad_free_text = bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource=f'{self._r}.freeText'), - position=( - h + h_offs + b_size_3[0] * 0.5, - v + b_size_3[1] * 0.5 + 25, - ), - size=(0, 0), - color=(1, 1, 0, 1.0), - draw_controller=cdb, - rotate=15, - shadow=1.0, - maxwidth=150, - h_align='center', - v_align='center', - scale=1.0, - ) - v -= 125 - else: - v -= 20 - - if bool(True): - h_offs = 35 - b_size_3 = (150, 120) - cdb = _add_button( - 'app_invite', - position=(h + h_offs, v), - size=b_size_3, - label=bui.Lstr( - resource='gatherWindow.earnTicketsForRecommendingText', - subs=[ - ( - '${COUNT}', - str( - plus.get_v1_account_misc_read_val( - 'sponsorTickets', 5 - ) - ), - ) - ], - ), - tex_name='ticketsMore', - enabled=True, - tex_opacity=0.6, - tex_scale=0.7, - text_scale=0.7, - ) - bui.buttonwidget(edit=cdb, color=(0.65, 0.5, 0.7)) - - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource=f'{self._r}.freeText'), - position=( - h + h_offs + b_size_3[0] * 0.5, - v + b_size_3[1] * 0.5 + 25, - ), - size=(0, 0), - color=(1, 1, 0, 1.0), - draw_controller=cdb, - rotate=15, - shadow=1.0, - maxwidth=150, - h_align='center', - v_align='center', - scale=1.0, - ) - tc_y_offs = 0 - else: - tc_y_offs = 0 - - h = self._width - (185 + x_inset) - v = self._height - 105 + tc_y_offs - - txt1 = ( - bui.Lstr(resource=f'{self._r}.youHaveText') - .evaluate() - .partition('${COUNT}')[0] - .strip() - ) - txt2 = ( - bui.Lstr(resource=f'{self._r}.youHaveText') - .evaluate() - .rpartition('${COUNT}')[-1] - .strip() - ) - - bui.textwidget( - parent=self._root_widget, - text=txt1, - position=(h, v), - size=(0, 0), - color=(0.5, 0.5, 0.6), - maxwidth=200, - h_align='center', - v_align='center', - scale=0.8, - ) - v -= 30 - self._ticket_count_text = bui.textwidget( - parent=self._root_widget, - position=(h, v), - size=(0, 0), - color=(0.2, 1.0, 0.2), - maxwidth=200, - h_align='center', - v_align='center', - scale=1.6, - ) - v -= 30 - bui.textwidget( - parent=self._root_widget, - text=txt2, - position=(h, v), - size=(0, 0), - color=(0.5, 0.5, 0.6), - maxwidth=200, - h_align='center', - v_align='center', - scale=0.8, - ) - - self._ticking_sound: bui.Sound | None = None - self._smooth_ticket_count: float | None = None - self._ticket_count = 0 - self._update() - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - self._smooth_increase_speed = 1.0 - - def __del__(self) -> None: - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - - def _smooth_update(self) -> None: - if not self._ticket_count_text: - self._smooth_update_timer = None - return - - finished = False - - # If we're going down, do it immediately. - assert self._smooth_ticket_count is not None - if int(self._smooth_ticket_count) >= self._ticket_count: - self._smooth_ticket_count = float(self._ticket_count) - finished = True - else: - # We're going up; start a sound if need be. - self._smooth_ticket_count = min( - self._smooth_ticket_count + 1.0 * self._smooth_increase_speed, - self._ticket_count, - ) - if int(self._smooth_ticket_count) >= self._ticket_count: - finished = True - self._smooth_ticket_count = float(self._ticket_count) - elif self._ticking_sound is None: - self._ticking_sound = bui.getsound('scoreIncrease') - self._ticking_sound.play() - - bui.textwidget( - edit=self._ticket_count_text, - text=str(int(self._smooth_ticket_count)), - ) - - # If we've reached the target, kill the timer/sound/etc. - if finished: - self._smooth_update_timer = None - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - bui.getsound('cashRegister2').play() - - def _update(self) -> None: - import datetime - - plus = bui.app.plus - assert plus is not None - - # If we somehow get signed out, just die. - if plus.get_v1_account_state() != 'signed_in': - self._back() - return - - self._ticket_count = plus.get_v1_account_ticket_count() - - # Update our incentivized ad button depending on whether ads are - # available. - if self._ad_button is not None: - next_reward_ad_time = plus.get_v1_account_misc_read_val_2( - 'nextRewardAdTime', None - ) - if next_reward_ad_time is not None: - next_reward_ad_time = datetime.datetime.fromtimestamp( - next_reward_ad_time, datetime.UTC - ) - now = utc_now() - if plus.have_incentivized_ad() and ( - next_reward_ad_time is None or next_reward_ad_time <= now - ): - self._ad_button_greyed = False - bui.buttonwidget(edit=self._ad_button, color=(0.65, 0.5, 0.7)) - bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 1.0)) - bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 1)) - bui.imagewidget(edit=self._ad_image, opacity=0.6) - bui.textwidget(edit=self._ad_time_text, text='') - else: - self._ad_button_greyed = True - bui.buttonwidget(edit=self._ad_button, color=(0.5, 0.5, 0.5)) - bui.textwidget(edit=self._ad_label, color=(0.7, 0.9, 0.7, 0.2)) - bui.textwidget(edit=self._ad_free_text, color=(1, 1, 0, 0.2)) - bui.imagewidget(edit=self._ad_image, opacity=0.6 * 0.25) - sval: str | bui.Lstr - if ( - next_reward_ad_time is not None - and next_reward_ad_time > now - ): - sval = bui.timestring( - (next_reward_ad_time - now).total_seconds(), centi=False - ) - else: - sval = '' - bui.textwidget(edit=self._ad_time_text, text=sval) - - # If this is our first update, assign immediately; otherwise kick - # off a smooth transition if the value has changed. - if self._smooth_ticket_count is None: - self._smooth_ticket_count = float(self._ticket_count) - self._smooth_update() # will set the text widget - - elif ( - self._ticket_count != int(self._smooth_ticket_count) - and self._smooth_update_timer is None - ): - self._smooth_update_timer = bui.AppTimer( - 0.05, bui.WeakCall(self._smooth_update), repeat=True - ) - diff = abs(float(self._ticket_count) - self._smooth_ticket_count) - self._smooth_increase_speed = ( - diff / 100.0 - if diff >= 5000 - else ( - diff / 50.0 - if diff >= 1500 - else diff / 30.0 if diff >= 500 else diff / 15.0 - ) - ) - - def _disabled_press(self) -> None: - plus = bui.app.plus - assert plus is not None - - # If we're on a platform without purchases, inform the user they - # can link their accounts and buy stuff elsewhere. - app = bui.app - assert app.classic is not None - if ( - app.env.test - or ( - app.classic.platform == 'android' - and app.classic.subplatform in ['oculus', 'cardboard'] - ) - ) and plus.get_v1_account_misc_read_val('allowAccountLinking2', False): - bui.screenmessage( - bui.Lstr(resource=f'{self._r}.unavailableLinkAccountText'), - color=(1, 0.5, 0), - ) - else: - bui.screenmessage( - bui.Lstr(resource=f'{self._r}.unavailableText'), - color=(1, 0.5, 0), - ) - bui.getsound('error').play() - - def _purchase(self, item: str) -> None: - from bauiv1lib import account - from bauiv1lib import appinvite - - plus = bui.app.plus - assert plus is not None - - if bui.app.classic is None: - raise RuntimeError('This requires classic support.') - - if item == 'app_invite': - if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() - return - appinvite.handle_app_invites_press() - return - - # Here we ping the server to ask if it's valid for us to - # purchase this.. (better to fail now than after we've paid - # locally). - app = bui.app - assert app.classic is not None - bui.app.classic.master_server_v1_get( - 'bsAccountPurchaseCheck', - { - 'item': item, - 'platform': app.classic.platform, - 'subplatform': app.classic.subplatform, - 'version': app.env.engine_version, - 'buildNumber': app.env.engine_build_number, - }, - callback=bui.WeakCall(self._purchase_check_result, item), - ) - - def _purchase_check_result( - self, item: str, result: dict[str, Any] | None - ) -> None: - if result is None: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='internal.unavailableNoConnectionText'), - color=(1, 0, 0), - ) - else: - if result['allow']: - self._do_purchase(item) - else: - if result['reason'] == 'versionTooOld': - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='getTicketsWindow.versionTooOldText'), - color=(1, 0, 0), - ) - else: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr(resource='getTicketsWindow.unavailableText'), - color=(1, 0, 0), - ) - - # Actually start the purchase locally. - def _do_purchase(self, item: str) -> None: - plus = bui.app.plus - assert plus is not None - - if item == 'ad': - import datetime - - # If ads are disabled until some time, error. - next_reward_ad_time = plus.get_v1_account_misc_read_val_2( - 'nextRewardAdTime', None - ) - if next_reward_ad_time is not None: - next_reward_ad_time = datetime.datetime.fromtimestamp( - next_reward_ad_time, datetime.UTC - ) - now = utc_now() - if ( - next_reward_ad_time is not None and next_reward_ad_time > now - ) or self._ad_button_greyed: - bui.getsound('error').play() - bui.screenmessage( - bui.Lstr( - resource='getTicketsWindow.unavailableTemporarilyText' - ), - color=(1, 0, 0), - ) - elif self._enable_ad_button: - assert bui.app.classic is not None - bui.app.classic.ads.show_ad('tickets') - else: - plus.purchase(item) - - def _get_tokens_press(self) -> None: - from functools import partial - - from bauiv1lib.gettokens import GetTokensWindow - - # No-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - if self._transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_left') - - # Note: Make sure we don't pass anything here that would - # capture 'self'. (a lambda would implicitly do this by capturing - # the stack frame). - restorecall = partial( - _restore_get_tickets_window, - self._modal, - self._from_modal_store, - self._store_back_location, - ) - - window = GetTokensWindow( - transition='in_right', - restore_previous_call=restorecall, - ).get_root_widget() - if not self._modal and not self._from_modal_store: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - window, from_window=self._root_widget - ) - self._transitioning_out = True - - def _back(self) -> None: - from bauiv1lib.store import browser - - # No-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - if self._transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - window = browser.StoreBrowserWindow( - transition='in_left', - modal=self._from_modal_store, - back_location=self._store_back_location, - ).get_root_widget() - if not self._from_modal_store: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - window, from_window=self._root_widget - ) - self._transitioning_out = True - - -# A call we can bundle up and pass to windows we open; allows them to -# get back to us without having to explicitly know about us. -def _restore_get_tickets_window( - modal: bool, - from_modal_store: bool, - store_back_location: str | None, - from_window: bui.Widget, -) -> None: - restored = GetTicketsWindow( - transition='in_left', - modal=modal, - from_modal_store=from_modal_store, - store_back_location=store_back_location, - ) - if not modal and not from_modal_store: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - restored.get_root_widget(), from_window=from_window - ) - - -def show_get_tickets_prompt() -> None: - """Show a 'not enough tickets' prompt with an option to purchase more. - - Note that the purchase option may not always be available - depending on the build of the game. - """ - from bauiv1lib.confirm import ConfirmWindow - - assert bui.app.classic is not None - - if bui.app.classic.allow_ticket_purchases: - ConfirmWindow( - bui.Lstr( - translate=( - 'serverResponses', - 'You don\'t have enough tickets for this!', - ) - ), - lambda: GetTicketsWindow(modal=True), - ok_text=bui.Lstr(resource='getTicketsWindow.titleText'), - width=460, - height=130, - ) - else: - ConfirmWindow( - bui.Lstr( - translate=( - 'serverResponses', - 'You don\'t have enough tickets for this!', - ) - ), - cancel_button=False, - width=460, - height=130, - ) diff --git a/dist/ba_data/python/bauiv1lib/gettokens.py b/dist/ba_data/python/bauiv1lib/gettokens.py index 22bff44..aa61bea 100644 --- a/dist/ba_data/python/bauiv1lib/gettokens.py +++ b/dist/ba_data/python/bauiv1lib/gettokens.py @@ -8,7 +8,7 @@ import time from enum import Enum from functools import partial from dataclasses import dataclass -from typing import TYPE_CHECKING, assert_never +from typing import TYPE_CHECKING, assert_never, override import bacommon.cloud import bauiv1 as bui @@ -54,7 +54,7 @@ class _TxtDef: rotate: float | None = None -class GetTokensWindow(bui.Window): +class GetTokensWindow(bui.MainWindow): """Window for purchasing/acquiring classic tickets.""" class State(Enum): @@ -67,12 +67,10 @@ class GetTokensWindow(bui.Window): def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, - restore_previous_call: Callable[[bui.Widget], None] | None = None, ): # pylint: disable=too-many-locals - bwidthstd = 170 bwidthwide = 300 ycolor = (0, 0, 0.3) @@ -304,7 +302,6 @@ class GetTokensWindow(bui.Window): ] self._transitioning_out = False - self._restore_previous_call = restore_previous_call self._textcolor = (0.92, 0.92, 2.0) self._query_in_flight = False @@ -313,68 +310,72 @@ class GetTokensWindow(bui.Window): None ) - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - uiscale = bui.app.ui_v1.uiscale - self._width = 1000.0 if uiscale is bui.UIScale.SMALL else 800.0 - self._x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 - self._height = 480.0 + self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 1070.0 + self._height = 800 if uiscale is bui.UIScale.SMALL else 520.0 self._r = 'getTokensWindow' - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.95 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + self._yoffs = 0.5 * self._height + 0.5 * target_height + 20.0 + + self._scroll_width = target_width super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - scale_origin_stack_offset=scale_origin, + size=(self._width, self._height), color=(0.3, 0.23, 0.36), - scale=( - 1.63 + scale=scale, + toolbar_visibility=( + 'get_tokens' if uiscale is bui.UIScale.SMALL - else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -3) if uiscale is bui.UIScale.SMALL else (0, 0) + else 'menu_full' ), + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = bui.get_special_widget('back_button') + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(60, self._yoffs - 90), + size=((60, 60)), + scale=1.0, + autoselect=True, + label=(bui.charstr(bui.SpecialChar.BACK)), + button_type=('backSmall'), + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button ) - ) - - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(45 + self._x_inset, self._height - 80), - size=( - (140, 60) if self._restore_previous_call is None else (60, 60) - ), - scale=1.0, - autoselect=True, - label=( - bui.Lstr(resource='doneText') - if self._restore_previous_call is None - else bui.charstr(bui.SpecialChar.BACK) - ), - button_type=( - 'regular' - if self._restore_previous_call is None - else 'backSmall' - ), - on_activate_call=self._back, - ) - - bui.containerwidget(edit=self._root_widget, cancel_button=btn) self._title_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 47), + position=(self._width * 0.5, self._yoffs - 42), size=(0, 0), color=self._textcolor, flatness=0.0, @@ -394,7 +395,15 @@ class GetTokensWindow(bui.Window): v_align='center', color=(0.6, 0.6, 0.6), scale=0.75, - text=bui.Lstr(resource='store.loadingText'), + text='', + ) + # Create a spinner - it will get cleared when state changes from + # LOADING. + bui.spinnerwidget( + parent=self._root_widget, + size=60, + position=(self._width * 0.5, self._height * 0.5), + style='bomb', ) self._core_widgets = [ @@ -403,13 +412,6 @@ class GetTokensWindow(bui.Window): self._status_text, ] - self._token_count_widget: bui.Widget | None = None - self._smooth_update_timer: bui.AppTimer | None = None - self._smooth_token_count: float | None = None - self._token_count: int = 0 - self._smooth_increase_speed = 1.0 - self._ticking_sound: bui.Sound | None = None - # Get all textures used by our buttons preloading so hopefully # they'll be in place by the time we show them. for bdef in self._buttondefs: @@ -423,10 +425,15 @@ class GetTokensWindow(bui.Window): ) self._update() - def __del__(self) -> None: - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) def _update(self) -> None: # No-op if our underlying widget is dead or on its way out. @@ -475,7 +482,7 @@ class GetTokensWindow(bui.Window): return # Ok, state is changing. Start by resetting to a blank slate. - self._token_count_widget = None + # self._token_count_widget = None for widget in self._root_widget.get_children(): if widget not in self._core_widgets: widget.delete() @@ -526,43 +533,68 @@ class GetTokensWindow(bui.Window): ) -> None: # pylint: disable=too-many-locals plus = bui.app.plus + classic = bui.app.classic + + uiscale = bui.app.ui_v1.uiscale bui.textwidget(edit=self._status_text, text='') - xinset = 40 - - scrollwidth = self._width - 2 * (self._x_inset + xinset) scrollheight = 280 buttonpadding = -5 yoffs = 5 - # We currently don't handle the zero-button case. - assert self._buttondefs + available_purchases = { + p.purchaseid for p in response.available_purchases + } + buttondefs_shown = [ + b for b in self._buttondefs if b.itemid in available_purchases + ] - total_button_width = sum( - b.width + b.prepad for b in self._buttondefs - ) + buttonpadding * (len(self._buttondefs) - 1) + # Fail if something errored server-side or they didn't send us + # anything we can show. + if ( + response.result is not response.Result.SUCCESS + or not buttondefs_shown + ): + self._on_load_error() + return + + sidepad = 10.0 + xfudge = 6.0 + total_button_width = ( + sum(b.width + b.prepad for b in buttondefs_shown) + + buttonpadding * (len(buttondefs_shown) - 1) + + 2 * sidepad + ) h_scroll = bui.hscrollwidget( parent=self._root_widget, - size=(scrollwidth, scrollheight), - position=(self._x_inset + xinset, 45), + size=(self._scroll_width, scrollheight), + position=( + self._width * 0.5 - 0.5 * self._scroll_width, + self._height * 0.5 - 0.5 * scrollheight - 40, + ), claims_left_right=True, highlight=False, - border_opacity=0.25, + border_opacity=0.15, + center_small_content=True, ) subcontainer = bui.containerwidget( parent=h_scroll, background=False, - size=(max(total_button_width, scrollwidth), scrollheight), + size=(total_button_width, scrollheight), ) tinfobtn = bui.buttonwidget( parent=self._root_widget, autoselect=True, label=bui.Lstr(resource='learnMoreText'), - position=(self._width * 0.5 - 75, self._height * 0.703), - size=(180, 43), + text_scale=0.7, + position=( + self._width * 0.5 - 75, + self._yoffs - 100, + ), + size=(180, 40), scale=0.8, color=(0.4, 0.25, 0.5), textcolor=self._textcolor, @@ -570,15 +602,26 @@ class GetTokensWindow(bui.Window): self._on_learn_more_press, response.token_info_url ), ) + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=tinfobtn, + left_widget=bui.get_special_widget('back_button'), + up_widget=bui.get_special_widget('back_button'), + ) - x = 0.0 + bui.widget( + edit=tinfobtn, + right_widget=bui.get_special_widget('tokens_meter'), + ) + + x = sidepad + xfudge bwidgets: list[bui.Widget] = [] - for i, buttondef in enumerate(self._buttondefs): + for i, buttondef in enumerate(buttondefs_shown): price = None if plus is None else plus.get_price(buttondef.itemid) x += buttondef.prepad - tdelay = 0.3 - i / len(self._buttondefs) * 0.25 + tdelay = 0.3 - i / len(buttondefs_shown) * 0.25 btn = bui.buttonwidget( autoselect=True, label='', @@ -594,6 +637,10 @@ class GetTokensWindow(bui.Window): ), ) bwidgets.append(btn) + + if i == 0: + bui.widget(edit=btn, left_widget=self._back_button) + for imgdef in buttondef.imgdefs: _img = bui.imagewidget( parent=subcontainer, @@ -643,40 +690,43 @@ class GetTokensWindow(bui.Window): x += buttondef.width + buttonpadding bui.containerwidget(edit=subcontainer, visible_child=bwidgets[0]) - _tinfotxt = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.812), - color=self._textcolor, - shadow=1.0, - scale=0.7, - size=(0, 0), - h_align='center', - v_align='center', - text=bui.Lstr(resource='tokens.shinyNewCurrencyText'), - ) - self._token_count_widget = bui.textwidget( - parent=self._root_widget, - position=(self._width - self._x_inset - 120.0, self._height - 48), - color=(2.0, 0.7, 0.0), - shadow=1.0, - flatness=0.0, - size=(0, 0), - h_align='left', - v_align='center', - text='', - ) - self._token_count = response.tokens - self._smooth_token_count = float(self._token_count) - self._smooth_update() # will set the text widget. + if bool(False): + _tinfotxt = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + self._yoffs - 70, + ), + color=self._textcolor, + shadow=1.0, + scale=0.7, + size=(0, 0), + h_align='center', + v_align='center', + text=bui.Lstr(resource='tokens.shinyNewCurrencyText'), + ) - _tlabeltxt = bui.textwidget( - parent=self._root_widget, - position=(self._width - self._x_inset - 123.0, self._height - 48), - size=(0, 0), - h_align='right', - v_align='center', - text=bui.charstr(bui.SpecialChar.TOKEN), + has_removed_ads = classic is not None and ( + classic.gold_pass + or classic.remove_ads + or classic.accounts.have_pro() ) + if plus is not None and plus.has_video_ads() and not has_removed_ads: + _tinfotxt = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + self._yoffs - 120, + ), + color=(0.4, 1.0, 0.4), + shadow=1.0, + scale=0.5, + size=(0, 0), + h_align='center', + v_align='center', + maxwidth=self._scroll_width * 0.9, + text=bui.Lstr(resource='removeInGameAdsTokenPurchaseText'), + ) def _purchase_press(self, itemid: str) -> None: plus = bui.app.plus @@ -700,83 +750,8 @@ class GetTokensWindow(bui.Window): def _update_store_state(self) -> None: """Called to make minor updates to an already shown store.""" - assert self._token_count_widget is not None assert self._last_query_response is not None - self._token_count = self._last_query_response.tokens - - # Kick off new smooth update if need be. - assert self._smooth_token_count is not None - if ( - self._token_count != int(self._smooth_token_count) - and self._smooth_update_timer is None - ): - self._smooth_update_timer = bui.AppTimer( - 0.05, bui.WeakCall(self._smooth_update), repeat=True - ) - diff = abs(float(self._token_count) - self._smooth_token_count) - self._smooth_increase_speed = ( - diff / 100.0 - if diff >= 5000 - else ( - diff / 50.0 - if diff >= 1500 - else diff / 30.0 if diff >= 500 else diff / 15.0 - ) - ) - - def _smooth_update(self) -> None: - - # Stop if the count widget disappears. - if not self._token_count_widget: - self._smooth_update_timer = None - return - - finished = False - - # If we're going down, do it immediately. - assert self._smooth_token_count is not None - if int(self._smooth_token_count) >= self._token_count: - self._smooth_token_count = float(self._token_count) - finished = True - else: - # We're going up; start a sound if need be. - self._smooth_token_count = min( - self._smooth_token_count + 1.0 * self._smooth_increase_speed, - self._token_count, - ) - if int(self._smooth_token_count) >= self._token_count: - finished = True - self._smooth_token_count = float(self._token_count) - elif self._ticking_sound is None: - self._ticking_sound = bui.getsound('scoreIncrease') - self._ticking_sound.play() - - bui.textwidget( - edit=self._token_count_widget, - text=str(int(self._smooth_token_count)), - ) - - # If we've reached the target, kill the timer/sound/etc. - if finished: - self._smooth_update_timer = None - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - bui.getsound('cashRegister2').play() - - def _back(self) -> None: - - # No-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if self._restore_previous_call is not None: - self._restore_previous_call(self._root_widget) - def _on_learn_more_press(self, url: str) -> None: bui.open_url(url) @@ -795,7 +770,7 @@ def show_get_tokens_prompt() -> None: if bool(True): ConfirmWindow( bui.Lstr(resource='tokens.notEnoughTokensText'), - GetTokensWindow, + show_get_tokens_window, ok_text=bui.Lstr(resource='tokens.getTokensText'), width=460, height=130, @@ -807,3 +782,30 @@ def show_get_tokens_prompt() -> None: width=460, height=130, ) + + +def show_get_tokens_window(origin_widget: bui.Widget | None = None) -> None: + """Transition to the get-tokens main-window from anywhere.""" + + # NOTE TO USERS: The code below is not the proper way to do things; + # whenever possible one should use a MainWindow's + # main_window_replace() or main_window_back() methods. We just need + # to do things a bit more manually in this particular case. + + prev_main_window = bui.app.ui_v1.get_main_window() + + # Special-case: If it seems we're already in the window, do nothing. + if isinstance(prev_main_window, GetTokensWindow): + return + + # Set our new main window. + bui.app.ui_v1.set_main_window( + GetTokensWindow(origin_widget=origin_widget), + from_window=False, + is_auxiliary=True, + suppress_warning=True, + ) + + # Transition out any previous main window. + if prev_main_window is not None: + prev_main_window.main_window_close() diff --git a/dist/ba_data/python/bauiv1lib/helpui.py b/dist/ba_data/python/bauiv1lib/help.py similarity index 81% rename from dist/ba_data/python/bauiv1lib/helpui.py rename to dist/ba_data/python/bauiv1lib/help.py index 887f005..abb6370 100644 --- a/dist/ba_data/python/bauiv1lib/helpui.py +++ b/dist/ba_data/python/bauiv1lib/help.py @@ -4,137 +4,139 @@ from __future__ import annotations +from typing import override + +import random + import bauiv1 as bui -class HelpWindow(bui.Window): +class HelpWindow(bui.MainWindow): """A window providing help on how to play.""" def __init__( - self, main_menu: bool = False, origin_widget: bui.Widget | None = None + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals bui.set_analytics_screen('Help Window') - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' - self._r = 'helpWindow' getres = bui.app.lang.get_resource - self._main_menu = main_menu assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale width = 1050 if uiscale is bui.UIScale.SMALL else 750 - x_offs = 150 if uiscale is bui.UIScale.SMALL else 0 + height = ( - 460 + 700 if uiscale is bui.UIScale.SMALL else 530 if uiscale is bui.UIScale.MEDIUM else 600 ) + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.8 + if uiscale is bui.UIScale.SMALL + else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(width - 90, screensize[0] / scale) + target_height = min(height - 90, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + + scroll_width = target_width + scroll_height = target_height - 36 + scroll_bottom = yoffs - 64 - scroll_height + super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 1.77 + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.25 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -30) - if uiscale is bui.UIScale.SMALL - else (0, 15) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - ) - ) - - bui.textwidget( - parent=self._root_widget, - position=(0, height - (50 if uiscale is bui.UIScale.SMALL else 45)), - size=(width, 25), - text=bui.Lstr( - resource=f'{self._r}.titleText', - subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], + scale=scale, ), - color=bui.app.ui_v1.title_color, - h_align='center', - v_align='top', + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._scrollwidget = bui.scrollwidget( - parent=self._root_widget, - position=(44 + x_offs, 55 if uiscale is bui.UIScale.SMALL else 55), - simple_culling_v=100.0, - size=( - width - (88 + 2 * x_offs), - height - 120 + (5 if uiscale is bui.UIScale.SMALL else 0), - ), - capture_arrows=True, - ) - - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=self._scrollwidget, - right_widget=bui.get_special_widget('party_button'), - ) - bui.containerwidget( - edit=self._root_widget, selected_child=self._scrollwidget - ) - - # ugly: create this last so it gets first dibs at touch events (since - # we have it close to the scroll widget) - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._close - ) - bui.widget( - edit=self._scrollwidget, - left_widget=bui.get_special_widget('back_button'), + edit=self._root_widget, on_cancel_call=self.main_window_back ) else: btn = bui.buttonwidget( parent=self._root_widget, - position=( - x_offs + (40 + 0 if uiscale is bui.UIScale.SMALL else 70), - height - (59 if uiscale is bui.UIScale.SMALL else 50), - ), - size=(140, 60), - scale=0.7 if uiscale is bui.UIScale.SMALL else 0.8, - label=( - bui.Lstr(resource='backText') - if self._main_menu - else 'Close' - ), - button_type='back' if self._main_menu else None, + position=(50, yoffs - 45), + size=(60, 55), + scale=0.8, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', extra_touch_border_scale=2.0, autoselect=True, - on_activate_call=self._close, + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) - if self._main_menu: - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 55), - label=bui.charstr(bui.SpecialChar.BACK), - ) + bui.textwidget( + parent=self._root_widget, + position=( + width * 0.5, + yoffs - (47 if uiscale is bui.UIScale.SMALL else 25), + ), + size=(0, 0), + text=bui.Lstr( + resource=f'{self._r}.titleText', + subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], + ), + scale=0.9, + maxwidth=scroll_width * 0.7, + color=bui.app.ui_v1.title_color, + h_align='center', + v_align='center', + ) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + size=(scroll_width, scroll_height), + position=(width * 0.5 - scroll_width * 0.5, scroll_bottom), + simple_culling_v=100.0, + capture_arrows=True, + border_opacity=0.4, + center_small_content_horizontally=True, + ) + + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._scrollwidget, + left_widget=bui.get_special_widget('back_button'), + ) + + bui.widget( + edit=self._scrollwidget, + right_widget=bui.get_special_widget('squad_button'), + ) + bui.containerwidget( + edit=self._root_widget, selected_child=self._scrollwidget + ) + + # self._sub_width = 810 if uiscale is bui.UIScale.SMALL else 660 self._sub_width = 660 self._sub_height = ( 1590 @@ -149,7 +151,6 @@ class HelpWindow(bui.Window): size=(self._sub_width, self._sub_height), background=False, claims_left_right=False, - claims_tab=False, ) spacing = 1.0 @@ -405,12 +406,16 @@ class HelpWindow(bui.Window): # icon_size_2 = 30 hval2 = h - sep vval2 = v - bui.imagewidget( + bui.buttonwidget( parent=self._subcontainer, + label='', size=(icon_size, icon_size), position=(hval2 - 0.5 * icon_size, vval2 - 0.5 * icon_size), texture=bui.gettexture('buttonPunch'), color=(1, 0.7, 0.3), + selectable=False, + enable_sound=False, + on_activate_call=bui.WeakCall(self._play_sound, 'spazAttack0', 4), ) txt_scale = getres(f'{self._r}.punchInfoTextScale') @@ -429,12 +434,16 @@ class HelpWindow(bui.Window): hval2 = h + sep vval2 = v - bui.imagewidget( + bui.buttonwidget( parent=self._subcontainer, + label='', size=(icon_size, icon_size), position=(hval2 - 0.5 * icon_size, vval2 - 0.5 * icon_size), texture=bui.gettexture('buttonBomb'), color=(1, 0.3, 0.3), + selectable=False, + enable_sound=False, + on_activate_call=bui.WeakCall(self._play_sound, 'explosion0', 5), ) txt = bui.Lstr(resource=f'{self._r}.bombInfoText').evaluate() @@ -454,12 +463,16 @@ class HelpWindow(bui.Window): hval2 = h vval2 = v + sep - bui.imagewidget( + bui.buttonwidget( parent=self._subcontainer, + label='', size=(icon_size, icon_size), position=(hval2 - 0.5 * icon_size, vval2 - 0.5 * icon_size), texture=bui.gettexture('buttonPickUp'), color=(0.5, 0.5, 1), + selectable=False, + enable_sound=False, + on_activate_call=bui.WeakCall(self._play_sound, 'spazPickup0', 1), ) txtl = bui.Lstr(resource=f'{self._r}.pickUpInfoText') @@ -478,12 +491,16 @@ class HelpWindow(bui.Window): hval2 = h vval2 = v - sep - bui.imagewidget( + bui.buttonwidget( parent=self._subcontainer, + label='', size=(icon_size, icon_size), position=(hval2 - 0.5 * icon_size, vval2 - 0.5 * icon_size), texture=bui.gettexture('buttonJump'), color=(0.4, 1, 0.4), + selectable=False, + enable_sound=False, + on_activate_call=bui.WeakCall(self._play_sound, 'spazJump0', 4), ) txt = bui.Lstr(resource=f'{self._r}.jumpInfoText').evaluate() @@ -639,20 +656,15 @@ class HelpWindow(bui.Window): res_scale=0.5, ) - def _close(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.mainmenu import MainMenuWindow + def _play_sound(self, text: str, num: int) -> None: + bui.getsound(text + str(random.randint(1, num))).play() - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if self._main_menu: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget ) + ) diff --git a/dist/ba_data/python/bauiv1lib/iconpicker.py b/dist/ba_data/python/bauiv1lib/iconpicker.py index 968b9c1..0a9bd4b 100644 --- a/dist/ba_data/python/bauiv1lib/iconpicker.py +++ b/dist/ba_data/python/bauiv1lib/iconpicker.py @@ -14,6 +14,18 @@ if TYPE_CHECKING: from typing import Any, Sequence +class IconPickerDelegate: + """Delegate for character-picker.""" + + def on_icon_picker_pick(self, icon: str) -> None: + """Called when a character is selected.""" + raise NotImplementedError() + + def on_icon_picker_get_more_press(self) -> None: + """Called when the 'get more characters' button is pressed.""" + raise NotImplementedError() + + class IconPicker(PopupWindow): """Picker for icons.""" @@ -21,8 +33,9 @@ class IconPicker(PopupWindow): self, parent: bui.Widget, position: tuple[float, float] = (0.0, 0.0), - delegate: Any = None, + delegate: IconPickerDelegate | None = None, scale: float | None = None, + *, offset: tuple[float, float] = (0.0, 0.0), tint_color: Sequence[float] = (1.0, 1.0, 1.0), tint2_color: Sequence[float] = (1.0, 1.0, 1.0), @@ -158,8 +171,7 @@ class IconPicker(PopupWindow): bui.widget(edit=btn, show_buffer_top=30, show_buffer_bottom=30) def _on_store_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt - from bauiv1lib.store.browser import StoreBrowserWindow + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None @@ -167,12 +179,11 @@ class IconPicker(PopupWindow): if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return + + if self._delegate is not None: + self._delegate.on_icon_picker_get_more_press() + self._transition_out() - StoreBrowserWindow( - modal=True, - show_tab=StoreBrowserWindow.TabID.ICONS, - origin_widget=self._get_more_icons_button, - ) def _select_icon(self, icon: str) -> None: if self._delegate is not None: diff --git a/dist/ba_data/python/bauiv1lib/inbox.py b/dist/ba_data/python/bauiv1lib/inbox.py new file mode 100644 index 0000000..08dc923 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/inbox.py @@ -0,0 +1,1129 @@ +# Released under the MIT License. See LICENSE for details. +# +# pylint: disable=too-many-lines +"""Provides a popup window to view achievements.""" + +from __future__ import annotations + +import weakref +from functools import partial +from dataclasses import dataclass +from typing import override, assert_never, TYPE_CHECKING + +from efro.util import strict_partial, pairs_from_flat +from efro.error import CommunicationError +import bacommon.bs +import bauiv1 as bui + +if TYPE_CHECKING: + import datetime + from typing import Callable + + +class _Section: + def get_height(self) -> float: + """Return section height.""" + raise NotImplementedError() + + def get_button_row(self) -> list[bui.Widget]: + """Return rows of selectable controls.""" + return [] + + def emit(self, subcontainer: bui.Widget, y: float) -> None: + """Emit the section.""" + + +class _TextSection(_Section): + + def __init__( + self, + *, + sub_width: float, + text: bui.Lstr | str, + spacing_top: float = 0.0, + spacing_bottom: float = 0.0, + scale: float = 0.6, + color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), + ) -> None: + self.sub_width = sub_width + self.spacing_top = spacing_top + self.spacing_bottom = spacing_bottom + self.color = color + + # We need to bake this down since we plug its final size into + # our math. + self.textbaked = text.evaluate() if isinstance(text, bui.Lstr) else text + + # Calc scale to fit width and then see what height we need at + # that scale. + t_width = max( + 10.0, + bui.get_string_width(self.textbaked, suppress_warning=True) * scale, + ) + self.text_scale = scale * min(1.0, (sub_width * 0.9) / t_width) + + self.text_height = ( + 0.0 + if not self.textbaked + else bui.get_string_height(self.textbaked, suppress_warning=True) + ) * self.text_scale + + self.full_height = self.text_height + spacing_top + spacing_bottom + + @override + def get_height(self) -> float: + return self.full_height + + @override + def emit(self, subcontainer: bui.Widget, y: float) -> None: + bui.textwidget( + parent=subcontainer, + position=( + self.sub_width * 0.5, + y - self.spacing_top - self.text_height * 0.5, + ), + color=self.color, + scale=self.text_scale, + flatness=1.0, + shadow=1.0, + text=self.textbaked, + size=(0, 0), + h_align='center', + v_align='center', + ) + + +class _ButtonSection(_Section): + + def __init__( + self, + *, + sub_width: float, + label: bui.Lstr | str, + color: tuple[float, float, float], + label_color: tuple[float, float, float], + call: Callable[[_ButtonSection], None], + spacing_top: float = 0.0, + spacing_bottom: float = 0.0, + ) -> None: + self.sub_width = sub_width + self.spacing_top = spacing_top + self.spacing_bottom = spacing_bottom + self.color = color + self.label_color = label_color + self.button: bui.Widget | None = None + self.call = call + self.labelfin = label + self.button_width = 130 + self.button_height = 30 + self.full_height = self.button_height + spacing_top + spacing_bottom + + @override + def get_height(self) -> float: + return self.full_height + + @staticmethod + def weak_call(section: weakref.ref[_ButtonSection]) -> None: + """Call button section call if section still exists.""" + section_strong = section() + if section_strong is None: + return + + section_strong.call(section_strong) + + @override + def emit(self, subcontainer: bui.Widget, y: float) -> None: + self.button = bui.buttonwidget( + parent=subcontainer, + position=( + self.sub_width * 0.5 - self.button_width * 0.5, + y - self.spacing_top - self.button_height, + ), + autoselect=True, + label=self.labelfin, + textcolor=self.label_color, + text_scale=0.55, + size=(self.button_width, self.button_height), + color=self.color, + on_activate_call=strict_partial(self.weak_call, weakref.ref(self)), + ) + bui.widget(edit=self.button, depth_range=(0.1, 1.0)) + + @override + def get_button_row(self) -> list[bui.Widget]: + """Return rows of selectable controls.""" + assert self.button is not None + return [self.button] + + +class _DisplayItemsSection(_Section): + + def __init__( + self, + *, + sub_width: float, + items: list[bacommon.bs.DisplayItemWrapper], + width: float = 100.0, + spacing_top: float = 0.0, + spacing_bottom: float = 0.0, + ) -> None: + self.display_item_width = width + + # FIXME - ask for this somewhere in case it changes. + self.display_item_height = self.display_item_width * 0.666 + self.items = items + self.sub_width = sub_width + self.spacing_top = spacing_top + self.spacing_bottom = spacing_bottom + self.full_height = ( + self.display_item_height + spacing_top + spacing_bottom + ) + + @override + def get_height(self) -> float: + return self.full_height + + @override + def emit(self, subcontainer: bui.Widget, y: float) -> None: + # pylint: disable=cyclic-import + from baclassic import show_display_item + + xspacing = 1.1 * self.display_item_width + total_width = ( + 0 if not self.items else ((len(self.items) - 1) * xspacing) + ) + x = -0.5 * total_width + for item in self.items: + show_display_item( + item, + subcontainer, + pos=( + self.sub_width * 0.5 + x, + y - self.spacing_top - self.display_item_height * 0.5, + ), + width=self.display_item_width, + ) + x += xspacing + + +class _ExpireTimeSection(_Section): + + def __init__( + self, + *, + sub_width: float, + time: datetime.datetime, + spacing_top: float = 0.0, + spacing_bottom: float = 0.0, + ) -> None: + self.time = time + self.sub_width = sub_width + self.spacing_top = spacing_top + self.spacing_bottom = spacing_bottom + self.color = (1.0, 0.0, 1.0) + self._timer: bui.AppTimer | None = None + self._widget: bui.Widget | None = None + self.text_scale = 0.4 + self.text_height = 30.0 * self.text_scale + self.full_height = self.text_height + spacing_top + spacing_bottom + + @override + def get_height(self) -> float: + return self.full_height + + def _update(self) -> None: + if not self._widget: + return + + now = bui.utc_now_cloud() + + val: bui.Lstr + if now < self.time: + color = (1.0, 1.0, 1.0, 0.3) + val = bui.Lstr( + resource='expiresInText', + subs=[ + ( + '${T}', + bui.timestring( + (self.time - now).total_seconds(), centi=False + ), + ), + ], + ) + else: + color = (1.0, 0.3, 0.3, 0.5) + val = bui.Lstr( + resource='expiredAgoText', + subs=[ + ( + '${T}', + bui.timestring( + (now - self.time).total_seconds(), centi=False + ), + ), + ], + ) + bui.textwidget(edit=self._widget, text=val, color=color) + + @override + def emit(self, subcontainer: bui.Widget, y: float) -> None: + self._widget = bui.textwidget( + parent=subcontainer, + position=( + self.sub_width * 0.5, + y - self.spacing_top - self.text_height * 0.5, + ), + color=self.color, + scale=self.text_scale, + flatness=1.0, + shadow=1.0, + text='', + maxwidth=self.sub_width * 0.7, + size=(0, 0), + h_align='center', + v_align='center', + ) + self._timer = bui.AppTimer(1.0, bui.WeakCall(self._update), repeat=True) + self._update() + + +@dataclass +class _EntryDisplay: + interaction_style: bacommon.bs.BasicClientUI.InteractionStyle + button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel + button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel + sections: list[_Section] + id: str + total_height: float + color: tuple[float, float, float] + backing: bui.Widget | None = None + button_positive: bui.Widget | None = None + button_spinner_positive: bui.Widget | None = None + button_negative: bui.Widget | None = None + button_spinner_negative: bui.Widget | None = None + processing_complete: bool = False + + +class InboxWindow(bui.MainWindow): + """Popup window to show account messages.""" + + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + + assert bui.app.classic is not None + uiscale = bui.app.ui_v1.uiscale + + self._entry_displays: list[_EntryDisplay] = [] + + self._width = 900 if uiscale is bui.UIScale.SMALL else 500 + self._height = ( + 600 + if uiscale is bui.UIScale.SMALL + else 460 if uiscale is bui.UIScale.MEDIUM else 600 + ) + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.9 + if uiscale is bui.UIScale.SMALL + else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and offset + # by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + scroll_width = target_width + scroll_height = target_height - 31 + scroll_bottom = yoffs - 59 - scroll_height + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility=( + 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(50, yoffs - 48), + size=(60, 60), + scale=0.6, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + + self._title_text = bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), + ), + size=(0, 0), + h_align='center', + v_align='center', + scale=0.6 if uiscale is bui.UIScale.SMALL else 0.8, + text=bui.Lstr(resource='inboxText'), + maxwidth=200, + color=bui.app.ui_v1.title_color, + ) + + # Shows 'loading', 'no messages', etc. + self._infotext = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + maxwidth=self._width * 0.7, + scale=0.5, + flatness=1.0, + color=(0.4, 0.4, 0.5), + shadow=0.0, + text='', + size=(0, 0), + h_align='center', + v_align='center', + ) + self._loading_spinner = bui.spinnerwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.5), + style='bomb', + size=48, + ) + self._scrollwidget = bui.scrollwidget( + parent=self._root_widget, + size=(scroll_width, scroll_height), + position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), + capture_arrows=True, + simple_culling_v=200, + claims_left_right=True, + claims_up_down=True, + center_small_content_horizontally=True, + border_opacity=0.4, + ) + bui.widget(edit=self._scrollwidget, autoselect=True) + if uiscale is bui.UIScale.SMALL: + bui.widget( + edit=self._scrollwidget, + left_widget=bui.get_special_widget('back_button'), + ) + + bui.containerwidget( + edit=self._root_widget, + cancel_button=self._back_button, + single_depth=True, + ) + + # Kick off request. + plus = bui.app.plus + if plus is None or plus.accounts.primary is None: + self._error(bui.Lstr(resource='notSignedInText')) + return + + with plus.accounts.primary: + plus.cloud.send_message_cb( + bacommon.bs.InboxRequestMessage(), + on_response=bui.WeakCall(self._on_inbox_request_response), + ) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + def _error(self, errmsg: bui.Lstr | str) -> None: + """Put ourself in a permanent error state.""" + bui.spinnerwidget(edit=self._loading_spinner, visible=False) + bui.textwidget( + edit=self._infotext, + color=(1, 0, 0), + text=errmsg, + ) + + def _on_entry_display_press( + self, + display_weak: weakref.ReferenceType[_EntryDisplay], + action: bacommon.bs.ClientUIAction, + ) -> None: + display = display_weak() + if display is None: + return + + bui.getsound('click01').play() + + self._neuter_entry_display(display) + + # We currently only recognize basic entries and their possible + # interaction types. + if ( + display.interaction_style + is bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN + ): + display.processing_complete = True + self._close_soon_if_all_processed() + return + + # Error if we're somehow signed out now. + plus = bui.app.plus + if plus is None or plus.accounts.primary is None: + bui.screenmessage( + bui.Lstr(resource='notSignedInText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + # Ask the master-server to run our action. + with plus.accounts.primary: + plus.cloud.send_message_cb( + bacommon.bs.ClientUIActionMessage(display.id, action), + on_response=bui.WeakCall( + self._on_client_ui_action_response, + display_weak, + action, + ), + ) + + # Tweak the UI to show that things are in motion. + button = ( + display.button_positive + if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + else display.button_negative + ) + button_spinner = ( + display.button_spinner_positive + if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + else display.button_spinner_negative + ) + if button is not None: + bui.buttonwidget(edit=button, label='') + if button_spinner is not None: + bui.spinnerwidget(edit=button_spinner, visible=True) + + def _close_soon_if_all_processed(self) -> None: + bui.apptimer(0.25, bui.WeakCall(self._close_if_all_processed)) + + def _close_if_all_processed(self) -> None: + if not all(m.processing_complete for m in self._entry_displays): + return + + self.main_window_back() + + def _neuter_entry_display(self, entry: _EntryDisplay) -> None: + errsound = bui.getsound('error') + if entry.button_positive is not None: + bui.buttonwidget( + edit=entry.button_positive, + color=(0.5, 0.5, 0.5), + textcolor=(0.4, 0.4, 0.4), + on_activate_call=errsound.play, + ) + if entry.button_negative is not None: + bui.buttonwidget( + edit=entry.button_negative, + color=(0.5, 0.5, 0.5), + textcolor=(0.4, 0.4, 0.4), + on_activate_call=errsound.play, + ) + if entry.backing is not None: + bui.imagewidget(edit=entry.backing, color=(0.4, 0.4, 0.4)) + + def _on_client_ui_action_response( + self, + display_weak: weakref.ReferenceType[_EntryDisplay], + action: bacommon.bs.ClientUIAction, + response: bacommon.bs.ClientUIActionResponse | Exception, + ) -> None: + # pylint: disable=too-many-branches + display = display_weak() + if display is None: + return + + assert not display.processing_complete + display.processing_complete = True + self._close_soon_if_all_processed() + + # No-op if our UI is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + + # Tweak the button to show results. + button = ( + display.button_positive + if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + else display.button_negative + ) + button_spinner = ( + display.button_spinner_positive + if action is bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE + else display.button_spinner_negative + ) + # Always hide spinner at this point. + if button_spinner is not None: + bui.spinnerwidget(edit=button_spinner, visible=False) + + # See if we should show an error message. + if isinstance(response, Exception): + if isinstance(response, CommunicationError): + error_message = bui.Lstr( + resource='internal.unavailableNoConnectionText' + ) + else: + error_message = bui.Lstr(resource='errorText') + elif response.error_type is not None: + # If error_type is set, error should be also. + assert response.error_message is not None + error_message = bui.Lstr( + translate=('serverResponses', response.error_message) + ) + else: + error_message = None + + # Show error message if so. + if error_message is not None: + bui.screenmessage(error_message, color=(1, 0, 0)) + bui.getsound('error').play() + if button is not None: + bui.buttonwidget( + edit=button, label=bui.Lstr(resource='errorText') + ) + return + + # Success! + assert not isinstance(response, Exception) + + # Run any bundled effects. + assert bui.app.classic is not None + bui.app.classic.run_bs_client_effects(response.effects) + + # Whee; no error. Mark as done. + if button is not None: + # If we have full unicode, just show a checkmark in all cases. + label: str | bui.Lstr + if bui.supports_unicode_display(): + label = '✓' + else: + label = bui.Lstr(resource='doneText') + bui.buttonwidget(edit=button, label=label) + + def _on_inbox_request_response( + self, response: bacommon.bs.InboxRequestResponse | Exception + ) -> None: + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + + # No-op if our UI is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + + errmsg: str | bui.Lstr + if isinstance(response, Exception): + errmsg = bui.Lstr(resource='internal.unavailableNoConnectionText') + is_error = True + else: + is_error = response.error is not None + errmsg = ( + '' + if response.error is None + else bui.Lstr(translate=('serverResponses', response.error)) + ) + + if is_error: + self._error(errmsg) + return + + assert isinstance(response, bacommon.bs.InboxRequestResponse) + + # If we got no messages, don't touch anything. This keeps + # keyboard control working in the empty case. + if not response.wrappers: + bui.spinnerwidget(edit=self._loading_spinner, visible=False) + bui.textwidget( + edit=self._infotext, + color=(0.4, 0.4, 0.5), + text=bui.Lstr(resource='noMessagesText'), + ) + return + + bui.scrollwidget(edit=self._scrollwidget, highlight=False) + + bui.spinnerwidget(edit=self._loading_spinner, visible=False) + bui.textwidget(edit=self._infotext, text='') + + uiscale = bui.app.ui_v1.uiscale + + margin_top = 0.0 if uiscale is bui.UIScale.SMALL else 10.0 + margin_v = 0.0 if uiscale is bui.UIScale.SMALL else 5.0 + + # Need this to avoid the dock blocking access to buttons on our + # bottom message. + margin_bottom = 60.0 if uiscale is bui.UIScale.SMALL else 10.0 + + # Even though our window size varies with uiscale, we want + # notifications to target a fixed width. + sub_width = 400.0 + sub_height = margin_top + + # Construct entries for everything we'll display. + for i, wrapper in enumerate(response.wrappers): + + # We need to flatten text here so we can measure it. + # textfin: str + color: tuple[float, float, float] + + interaction_style: bacommon.bs.BasicClientUI.InteractionStyle + button_label_positive: bacommon.bs.BasicClientUI.ButtonLabel + button_label_negative: bacommon.bs.BasicClientUI.ButtonLabel + + sections: list[_Section] = [] + total_height = 80.0 + + # Display only entries where we recognize all style/label + # values and ui component types. + if ( + isinstance(wrapper.ui, bacommon.bs.BasicClientUI) + and not wrapper.ui.contains_unknown_elements() + ): + color = (0.55, 0.5, 0.7) + interaction_style = wrapper.ui.interaction_style + button_label_positive = wrapper.ui.button_label_positive + button_label_negative = wrapper.ui.button_label_negative + + idcls = bacommon.bs.BasicClientUIComponentTypeID + for component in wrapper.ui.components: + ctypeid = component.get_type_id() + section: _Section + + if ctypeid is idcls.TEXT: + assert isinstance( + component, bacommon.bs.BasicClientUIComponentText + ) + section = _TextSection( + sub_width=sub_width, + text=bui.Lstr( + translate=('serverResponses', component.text), + subs=pairs_from_flat(component.subs), + ), + color=component.color, + scale=component.scale, + spacing_top=component.spacing_top, + spacing_bottom=component.spacing_bottom, + ) + total_height += section.get_height() + sections.append(section) + + elif ctypeid is idcls.LINK: + assert isinstance( + component, bacommon.bs.BasicClientUIComponentLink + ) + + def _do_open_url(url: str, sec: _ButtonSection) -> None: + del sec # Unused. + bui.open_url(url) + + section = _ButtonSection( + sub_width=sub_width, + label=bui.Lstr( + translate=('serverResponses', component.label), + subs=pairs_from_flat(component.subs), + ), + color=color, + call=partial(_do_open_url, component.url), + label_color=(0.5, 0.7, 0.6), + spacing_top=component.spacing_top, + spacing_bottom=component.spacing_bottom, + ) + total_height += section.get_height() + sections.append(section) + + elif ctypeid is idcls.DISPLAY_ITEMS: + assert isinstance( + component, + bacommon.bs.BasicClientUIDisplayItems, + ) + section = _DisplayItemsSection( + sub_width=sub_width, + items=component.items, + width=component.width, + spacing_top=component.spacing_top, + spacing_bottom=component.spacing_bottom, + ) + total_height += section.get_height() + sections.append(section) + + elif ctypeid is idcls.BS_CLASSIC_TOURNEY_RESULT: + from bascenev1 import get_trophy_string + + assert isinstance( + component, + bacommon.bs.BasicClientUIBsClassicTourneyResult, + ) + campaignname, levelname = component.game.split(':') + assert bui.app.classic is not None + campaign = bui.app.classic.getcampaign(campaignname) + + tourney_name = bui.Lstr( + value='${A} ${B}', + subs=[ + ( + '${A}', + campaign.getlevel(levelname).displayname, + ), + ( + '${B}', + bui.Lstr( + resource='playerCountAbbreviatedText', + subs=[ + ('${COUNT}', str(component.players)) + ], + ), + ), + ], + ) + + if component.trophy is not None: + trophy_prefix = ( + get_trophy_string(component.trophy) + ' ' + ) + else: + trophy_prefix = '' + + section = _TextSection( + sub_width=sub_width, + text=bui.Lstr( + value='${P}${V}', + subs=[ + ('${P}', trophy_prefix), + ( + '${V}', + bui.Lstr( + translate=( + 'serverResponses', + 'You placed #${RANK}' + ' in a tournament!', + ), + subs=[ + ('${RANK}', str(component.rank)) + ], + ), + ), + ], + ), + color=(1.0, 1.0, 1.0, 1.0), + scale=0.6, + ) + total_height += section.get_height() + sections.append(section) + + section = _TextSection( + sub_width=sub_width, + text=tourney_name, + spacing_top=5, + color=(0.7, 0.7, 1.0, 1.0), + scale=0.7, + ) + total_height += section.get_height() + sections.append(section) + + def _do_tourney_scores( + tournament_id: str, sec: _ButtonSection + ) -> None: + from bauiv1lib.tournamentscores import ( + TournamentScoresWindow, + ) + + assert sec.button is not None + _ = ( + TournamentScoresWindow( + tournament_id=tournament_id, + position=( + sec.button + ).get_screen_space_center(), + ), + ) + + section = _ButtonSection( + sub_width=sub_width, + label=bui.Lstr( + resource='tournamentFinalStandingsText' + ), + color=color, + call=partial( + _do_tourney_scores, component.tournament_id + ), + label_color=(0.5, 0.7, 0.6), + spacing_top=7.0, + spacing_bottom=0.0 if component.prizes else 7.0, + ) + total_height += section.get_height() + sections.append(section) + + if component.prizes: + section = _TextSection( + sub_width=sub_width, + text=bui.Lstr(resource='yourPrizeText'), + spacing_top=6, + color=(1.0, 1.0, 1.0, 0.4), + scale=0.35, + ) + total_height += section.get_height() + sections.append(section) + + section = _DisplayItemsSection( + sub_width=sub_width, + items=component.prizes, + width=70.0, + spacing_top=0.0, + spacing_bottom=0.0, + ) + total_height += section.get_height() + sections.append(section) + + elif ctypeid is idcls.EXPIRE_TIME: + assert isinstance( + component, bacommon.bs.BasicClientUIExpireTime + ) + section = _ExpireTimeSection( + sub_width=sub_width, + time=component.time, + spacing_top=component.spacing_top, + spacing_bottom=component.spacing_bottom, + ) + total_height += section.get_height() + sections.append(section) + + elif ctypeid is idcls.UNKNOWN: + raise RuntimeError('Should not get here.') + + else: + # Make sure we handle all types. + assert_never(ctypeid) + else: + + # Display anything with unknown components as an + # 'upgrade your app to see this' message. + color = (0.6, 0.6, 0.6) + interaction_style = ( + bacommon.bs.BasicClientUI.InteractionStyle.UNKNOWN + ) + button_label_positive = bacommon.bs.BasicClientUI.ButtonLabel.OK + button_label_negative = ( + bacommon.bs.BasicClientUI.ButtonLabel.CANCEL + ) + + section = _TextSection( + sub_width=sub_width, + text=bui.Lstr( + value='You must update the app to view this.' + ), + ) + total_height += section.get_height() + sections.append(section) + + self._entry_displays.append( + _EntryDisplay( + interaction_style=interaction_style, + button_label_positive=button_label_positive, + button_label_negative=button_label_negative, + id=wrapper.id, + sections=sections, + total_height=total_height, + color=color, + ) + ) + sub_height += margin_v + total_height + + sub_height += margin_bottom + + subcontainer = bui.containerwidget( + id='inboxsub', + parent=self._scrollwidget, + size=(sub_width, sub_height), + background=False, + single_depth=True, + claims_left_right=True, + claims_up_down=True, + ) + + backing_tex = bui.gettexture('buttonSquareWide') + + assert bui.app.classic is not None + + buttonrows: list[list[bui.Widget]] = [] + y = sub_height - margin_top + for i, _wrapper in enumerate(response.wrappers): + entry_display = self._entry_displays[i] + entry_display_weak = weakref.ref(entry_display) + bwidth = 140 + bheight = 40 + + ysection = y - 23.0 + + # Backing. + entry_display.backing = img = bui.imagewidget( + parent=subcontainer, + position=( + -0.022 * sub_width, + y - entry_display.total_height * 1.09, + ), + texture=backing_tex, + size=(sub_width * 1.07, entry_display.total_height * 1.15), + color=entry_display.color, + opacity=0.9, + ) + bui.widget(edit=img, depth_range=(0, 0.1)) + + # Section contents. + for sec in entry_display.sections: + sec.emit(subcontainer, ysection) + # Wire up any widgets created by this section. + sec_button_row = sec.get_button_row() + if sec_button_row: + buttonrows.append(sec_button_row) + ysection -= sec.get_height() + + buttonrow: list[bui.Widget] = [] + have_negative_button = ( + entry_display.interaction_style + is ( + bacommon.bs.BasicClientUI + ).InteractionStyle.BUTTON_POSITIVE_NEGATIVE + ) + + bpos = ( + ( + (sub_width - bwidth - 25) + if have_negative_button + else ((sub_width - bwidth) * 0.5) + ), + y - entry_display.total_height + 15.0, + ) + entry_display.button_positive = btn = bui.buttonwidget( + parent=subcontainer, + position=bpos, + autoselect=True, + size=(bwidth, bheight), + label=bui.app.classic.basic_client_ui_button_label_str( + entry_display.button_label_positive + ), + color=entry_display.color, + textcolor=(0, 1, 0), + on_activate_call=bui.WeakCall( + self._on_entry_display_press, + entry_display_weak, + bacommon.bs.ClientUIAction.BUTTON_PRESS_POSITIVE, + ), + enable_sound=False, + ) + bui.widget(edit=btn, depth_range=(0.1, 1.0)) + buttonrow.append(btn) + spinner = entry_display.button_spinner_positive = bui.spinnerwidget( + parent=subcontainer, + position=( + bpos[0] + 0.5 * bwidth, + bpos[1] + 0.5 * bheight, + ), + visible=False, + ) + bui.widget(edit=spinner, depth_range=(0.1, 1.0)) + + if have_negative_button: + bpos = (25, y - entry_display.total_height + 15.0) + entry_display.button_negative = btn2 = bui.buttonwidget( + parent=subcontainer, + position=bpos, + autoselect=True, + size=(bwidth, bheight), + label=bui.app.classic.basic_client_ui_button_label_str( + entry_display.button_label_negative + ), + color=(0.85, 0.5, 0.7), + textcolor=(1, 0.4, 0.4), + on_activate_call=bui.WeakCall( + self._on_entry_display_press, + entry_display_weak, + (bacommon.bs.ClientUIAction).BUTTON_PRESS_NEGATIVE, + ), + enable_sound=False, + ) + bui.widget(edit=btn2, depth_range=(0.1, 1.0)) + buttonrow.append(btn2) + spinner = entry_display.button_spinner_negative = ( + bui.spinnerwidget( + parent=subcontainer, + position=( + bpos[0] + 0.5 * bwidth, + bpos[1] + 0.5 * bheight, + ), + visible=False, + ) + ) + bui.widget(edit=spinner, depth_range=(0.1, 1.0)) + + buttonrows.append(buttonrow) + + y -= margin_v + entry_display.total_height + + uiscale = bui.app.ui_v1.uiscale + above_widget = ( + bui.get_special_widget('back_button') + if uiscale is bui.UIScale.SMALL + else self._back_button + ) + assert above_widget is not None + for i, buttons in enumerate(buttonrows): + if i < len(buttonrows) - 1: + below_widget = buttonrows[i + 1][0] + else: + below_widget = None + + assert buttons # We should never have an empty row. + for j, button in enumerate(buttons): + bui.widget( + edit=button, + up_widget=above_widget, + down_widget=below_widget, + # down_widget=( + # button if below_widget is None else below_widget + # ), + right_widget=buttons[max(j - 1, 0)], + left_widget=buttons[min(j + 1, len(buttons) - 1)], + ) + + above_widget = buttons[0] + + +def _get_bs_classic_tourney_results_sections() -> list[_Section]: + return [] diff --git a/dist/ba_data/python/bauiv1lib/ingamemenu.py b/dist/ba_data/python/bauiv1lib/ingamemenu.py new file mode 100644 index 0000000..2dd7354 --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/ingamemenu.py @@ -0,0 +1,592 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Implements the in-gmae menu window.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override +import logging + +import bauiv1 as bui +import bascenev1 as bs + +if TYPE_CHECKING: + from typing import Any, Callable + + +class InGameMenuWindow(bui.MainWindow): + """The menu that can be invoked while in a game.""" + + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + + # Make a vanilla container; we'll modify it to our needs in + # refresh. + super().__init__( + root_widget=bui.containerwidget( + toolbar_visibility=('menu_in_game') + ), + transition=transition, + origin_widget=origin_widget, + ) + + # Grab this stuff in case it changes. + self._is_demo = bui.app.env.demo + self._is_arcade = bui.app.env.arcade + + self._p_index = 0 + self._use_autoselect = True + self._button_width = 200.0 + self._button_height = 45.0 + self._width = 100.0 + self._height = 100.0 + + self._refresh() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + def _refresh(self) -> None: + + # Clear everything that was there. + children = self._root_widget.get_children() + for child in children: + child.delete() + + self._r = 'mainMenu' + + self._input_device = input_device = bs.get_ui_input_device() + + # Are we connected to a local player? + self._input_player = input_device.player if input_device else None + + # Are we connected to a remote player?. + self._connected_to_remote_player = ( + input_device.is_attached_to_player() + if (input_device and self._input_player is None) + else False + ) + + positions: list[tuple[float, float, float]] = [] + self._p_index = 0 + + self._refresh_in_game(positions) + + h, v, scale = positions[self._p_index] + self._p_index += 1 + + # If we're in a replay, we have a 'Leave Replay' button. + if bs.is_in_replay(): + bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=bui.Lstr(resource='replayEndText'), + on_activate_call=self._confirm_end_replay, + ) + elif bs.get_foreground_host_session() is not None: + bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=bui.Lstr( + resource=self._r + + ( + '.endTestText' + if self._is_benchmark() + else '.endGameText' + ) + ), + on_activate_call=( + self._confirm_end_test + if self._is_benchmark() + else self._confirm_end_game + ), + ) + else: + # Assume we're in a client-session. + bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width * 0.5 * scale, v), + scale=scale, + size=(self._button_width, self._button_height), + autoselect=self._use_autoselect, + label=bui.Lstr(resource=f'{self._r}.leavePartyText'), + on_activate_call=self._confirm_leave_party, + ) + + # Add speed-up/slow-down buttons for replays. Ideally this + # should be part of a fading-out playback bar like most media + # players but this works for now. + if bs.is_in_replay(): + b_size = 50.0 + b_buffer_1 = 50.0 + b_buffer_2 = 10.0 + t_scale = 0.75 + assert bui.app.classic is not None + uiscale = bui.app.ui_v1.uiscale + if uiscale is bui.UIScale.SMALL: + b_size *= 0.6 + b_buffer_1 *= 0.8 + b_buffer_2 *= 1.0 + v_offs = -40 + t_scale = 0.5 + elif uiscale is bui.UIScale.MEDIUM: + v_offs = -70 + else: + v_offs = -100 + self._replay_speed_text = bui.textwidget( + parent=self._root_widget, + text=bui.Lstr( + resource='watchWindow.playbackSpeedText', + subs=[('${SPEED}', str(1.23))], + ), + position=(h, v + v_offs + 15 * t_scale), + h_align='center', + v_align='center', + size=(0, 0), + scale=t_scale, + ) + + # Update to current value. + self._change_replay_speed(0) + + # Keep updating in a timer in case it gets changed elsewhere. + self._change_replay_speed_timer = bui.AppTimer( + 0.25, bui.WeakCall(self._change_replay_speed, 0), repeat=True + ) + btn = bui.buttonwidget( + parent=self._root_widget, + position=( + h - b_size - b_buffer_1, + v - b_size - b_buffer_2 + v_offs, + ), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=bui.Call(self._change_replay_speed, -1), + ) + bui.textwidget( + parent=self._root_widget, + draw_controller=btn, + text='-', + position=( + h - b_size * 0.5 - b_buffer_1, + v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, + ), + h_align='center', + v_align='center', + size=(0, 0), + scale=3.0 * t_scale, + ) + btn = bui.buttonwidget( + parent=self._root_widget, + position=(h + b_buffer_1, v - b_size - b_buffer_2 + v_offs), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=bui.Call(self._change_replay_speed, 1), + ) + bui.textwidget( + parent=self._root_widget, + draw_controller=btn, + text='+', + position=( + h + b_size * 0.5 + b_buffer_1, + v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, + ), + h_align='center', + v_align='center', + size=(0, 0), + scale=3.0 * t_scale, + ) + self._pause_resume_button = btn = bui.buttonwidget( + parent=self._root_widget, + position=(h - b_size * 0.5, v - b_size - b_buffer_2 + v_offs), + button_type='square', + size=(b_size, b_size), + label=bui.charstr( + bui.SpecialChar.PLAY_BUTTON + if bs.is_replay_paused() + else bui.SpecialChar.PAUSE_BUTTON + ), + autoselect=True, + on_activate_call=bui.Call(self._pause_or_resume_replay), + ) + btn = bui.buttonwidget( + parent=self._root_widget, + position=( + h - b_size * 1.5 - b_buffer_1 * 2, + v - b_size - b_buffer_2 + v_offs, + ), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=bui.WeakCall(self._rewind_replay), + ) + bui.textwidget( + parent=self._root_widget, + draw_controller=btn, + # text='<<', + text=bui.charstr(bui.SpecialChar.REWIND_BUTTON), + position=( + h - b_size - b_buffer_1 * 2, + v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, + ), + h_align='center', + v_align='center', + size=(0, 0), + scale=2.0 * t_scale, + ) + btn = bui.buttonwidget( + parent=self._root_widget, + position=( + h + b_size * 0.5 + b_buffer_1 * 2, + v - b_size - b_buffer_2 + v_offs, + ), + button_type='square', + size=(b_size, b_size), + label='', + autoselect=True, + on_activate_call=bui.WeakCall(self._forward_replay), + ) + bui.textwidget( + parent=self._root_widget, + draw_controller=btn, + # text='>>', + text=bui.charstr(bui.SpecialChar.FAST_FORWARD_BUTTON), + position=( + h + b_size + b_buffer_1 * 2, + v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, + ), + h_align='center', + v_align='center', + size=(0, 0), + scale=2.0 * t_scale, + ) + + def _rewind_replay(self) -> None: + bs.seek_replay(-2 * pow(2, bs.get_replay_speed_exponent())) + + def _forward_replay(self) -> None: + bs.seek_replay(2 * pow(2, bs.get_replay_speed_exponent())) + + def _refresh_in_game( + self, positions: list[tuple[float, float, float]] + ) -> tuple[float, float, float]: + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + assert bui.app.classic is not None + custom_menu_entries: list[dict[str, Any]] = [] + session = bs.get_foreground_host_session() + if session is not None: + try: + custom_menu_entries = session.get_custom_menu_entries() + for cme in custom_menu_entries: + cme_any: Any = cme # Type check may not hold true. + if ( + not isinstance(cme_any, dict) + or 'label' not in cme + or not isinstance(cme['label'], (str, bui.Lstr)) + or 'call' not in cme + or not callable(cme['call']) + ): + raise ValueError( + 'invalid custom menu entry: ' + str(cme) + ) + except Exception: + custom_menu_entries = [] + logging.exception( + 'Error getting custom menu entries for %s.', session + ) + self._width = 250.0 + self._height = 250.0 if self._input_player else 180.0 + if (self._is_demo or self._is_arcade) and self._input_player: + self._height -= 40 + # if not self._have_settings_button: + self._height -= 50 + if self._connected_to_remote_player: + # In this case we have a leave *and* a disconnect button. + self._height += 50 + self._height += 50 * (len(custom_menu_entries)) + uiscale = bui.app.ui_v1.uiscale + bui.containerwidget( + edit=self._root_widget, + size=(self._width, self._height), + scale=( + 2.15 + if uiscale is bui.UIScale.SMALL + else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0 + ), + ) + h = 125.0 + v = self._height - 80.0 if self._input_player else self._height - 60 + h_offset = 0 + d_h_offset = 0 + v_offset = -50 + for _i in range(6 + len(custom_menu_entries)): + positions.append((h, v, 1.0)) + v += v_offset + h += h_offset + h_offset += d_h_offset + # self._play_button = None + bui.app.classic.pause() + + # Player name if applicable. + if self._input_player: + player_name = self._input_player.getname() + h, v, scale = positions[self._p_index] + v += 35 + bui.textwidget( + parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + color=(1, 1, 1, 0.5), + scale=0.7, + h_align='center', + text=bui.Lstr(value=player_name), + ) + else: + player_name = '' + h, v, scale = positions[self._p_index] + self._p_index += 1 + btn = bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + scale=scale, + label=bui.Lstr(resource=f'{self._r}.resumeText'), + autoselect=self._use_autoselect, + on_activate_call=self._resume, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + # Add any custom options defined by the current game. + for entry in custom_menu_entries: + h, v, scale = positions[self._p_index] + self._p_index += 1 + + # Ask the entry whether we should resume when we call + # it (defaults to true). + resume = bool(entry.get('resume_on_call', True)) + + if resume: + call = bui.Call(self._resume_and_call, entry['call']) + else: + call = bui.Call(entry['call'], bui.WeakCall(self._resume)) + + bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + scale=scale, + on_activate_call=call, + label=entry['label'], + autoselect=self._use_autoselect, + ) + + # Add a 'leave' button if the menu-owner has a player. + if (self._input_player or self._connected_to_remote_player) and not ( + self._is_demo or self._is_arcade + ): + h, v, scale = positions[self._p_index] + self._p_index += 1 + btn = bui.buttonwidget( + parent=self._root_widget, + position=(h - self._button_width / 2, v), + size=(self._button_width, self._button_height), + scale=scale, + on_activate_call=self._leave, + label='', + autoselect=self._use_autoselect, + ) + + if ( + player_name != '' + and player_name[0] != '<' + and player_name[-1] != '>' + ): + txt = bui.Lstr( + resource=f'{self._r}.justPlayerText', + subs=[('${NAME}', player_name)], + ) + else: + txt = bui.Lstr(value=player_name) + bui.textwidget( + parent=self._root_widget, + position=( + h, + v + + self._button_height + * (0.64 if player_name != '' else 0.5), + ), + size=(0, 0), + text=bui.Lstr(resource=f'{self._r}.leaveGameText'), + scale=(0.83 if player_name != '' else 1.0), + color=(0.75, 1.0, 0.7), + h_align='center', + v_align='center', + draw_controller=btn, + maxwidth=self._button_width * 0.9, + ) + bui.textwidget( + parent=self._root_widget, + position=(h, v + self._button_height * 0.27), + size=(0, 0), + text=txt, + color=(0.75, 1.0, 0.7), + h_align='center', + v_align='center', + draw_controller=btn, + scale=0.45, + maxwidth=self._button_width * 0.9, + ) + return h, v, scale + + def _change_replay_speed(self, offs: int) -> None: + if not self._replay_speed_text: + if bui.do_once(): + print('_change_replay_speed called without widget') + return + bs.set_replay_speed_exponent(bs.get_replay_speed_exponent() + offs) + actual_speed = pow(2.0, bs.get_replay_speed_exponent()) + bui.textwidget( + edit=self._replay_speed_text, + text=bui.Lstr( + resource='watchWindow.playbackSpeedText', + subs=[('${SPEED}', str(actual_speed))], + ), + ) + + def _pause_or_resume_replay(self) -> None: + if bs.is_replay_paused(): + bs.resume_replay() + bui.buttonwidget( + edit=self._pause_resume_button, + label=bui.charstr(bui.SpecialChar.PAUSE_BUTTON), + ) + else: + bs.pause_replay() + bui.buttonwidget( + edit=self._pause_resume_button, + label=bui.charstr(bui.SpecialChar.PLAY_BUTTON), + ) + + def _is_benchmark(self) -> bool: + session = bs.get_foreground_host_session() + return getattr(session, 'benchmark_type', None) == 'cpu' or ( + bui.app.classic is not None + and bui.app.classic.stress_test_update_timer is not None + ) + + def _confirm_end_game(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.confirm import ConfirmWindow + + # FIXME: Currently we crash calling this on client-sessions. + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + ConfirmWindow( + bui.Lstr(resource=f'{self._r}.exitToMenuText'), + self._end_game, + cancel_is_selected=True, + ) + + def _confirm_end_test(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.confirm import ConfirmWindow + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + ConfirmWindow( + bui.Lstr(resource=f'{self._r}.exitToMenuText'), + self._end_game, + cancel_is_selected=True, + ) + + def _confirm_end_replay(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.confirm import ConfirmWindow + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + ConfirmWindow( + bui.Lstr(resource=f'{self._r}.exitToMenuText'), + self._end_game, + cancel_is_selected=True, + ) + + def _confirm_leave_party(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.confirm import ConfirmWindow + + # Select cancel by default; this occasionally gets called by accident + # in a fit of button mashing and this will help reduce damage. + ConfirmWindow( + bui.Lstr(resource=f'{self._r}.leavePartyConfirmText'), + self._leave_party, + cancel_is_selected=True, + ) + + def _leave_party(self) -> None: + bs.disconnect_from_host() + + def _end_game(self) -> None: + assert bui.app.classic is not None + + # no-op if our underlying widget is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + + bui.containerwidget(edit=self._root_widget, transition='out_left') + bui.app.classic.return_to_main_menu_session_gracefully(reset_ui=False) + + def _leave(self) -> None: + if self._input_player: + self._input_player.remove_from_game() + elif self._connected_to_remote_player: + if self._input_device: + self._input_device.detach_from_player() + self._resume() + + def _resume_and_call(self, call: Callable[[], Any]) -> None: + self._resume() + call() + + def _resume(self) -> None: + classic = bui.app.classic + + assert classic is not None + classic.resume() + + bui.app.ui_v1.clear_main_window() + + # If there's callbacks waiting for us to resume, call them. + for call in classic.main_menu_resume_callbacks: + try: + call() + except Exception: + logging.exception('Error in classic resume callback.') + + classic.main_menu_resume_callbacks.clear() + + # def __del__(self) -> None: + # self._resume() diff --git a/dist/ba_data/python/bauiv1lib/inventory.py b/dist/ba_data/python/bauiv1lib/inventory.py new file mode 100644 index 0000000..f2904cf --- /dev/null +++ b/dist/ba_data/python/bauiv1lib/inventory.py @@ -0,0 +1,143 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Provides help related ui.""" + +from __future__ import annotations + +from typing import override + +import bauiv1 as bui + + +class InventoryWindow(bui.MainWindow): + """Shows what you got.""" + + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + + bui.set_analytics_screen('Help Window') + + assert bui.app.classic is not None + uiscale = bui.app.ui_v1.uiscale + self._width = 1400 if uiscale is bui.UIScale.SMALL else 750 + self._height = ( + 1200 + if uiscale is bui.UIScale.SMALL + else 530 if uiscale is bui.UIScale.MEDIUM else 600 + ) + # xoffs = 70 if uiscale is bui.UIScale.SMALL else 0 + # yoffs = -45 if uiscale is bui.UIScale.SMALL else 0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.55 + if uiscale is bui.UIScale.SMALL + else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 100, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + super().__init__( + root_widget=bui.containerwidget( + size=(self._width, self._height), + toolbar_visibility=( + 'menu_full' if uiscale is bui.UIScale.SMALL else 'menu_full' + ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + bui.textwidget( + parent=self._root_widget, + position=( + self._width * 0.5, + yoffs - (50 if uiscale is bui.UIScale.SMALL else 30), + ), + size=(0, 0), + text=bui.Lstr(resource='inventoryText'), + color=bui.app.ui_v1.title_color, + scale=0.9 if uiscale is bui.UIScale.SMALL else 1.0, + maxwidth=(130 if uiscale is bui.UIScale.SMALL else 200), + h_align='center', + v_align='center', + ) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + btn = bui.buttonwidget( + parent=self._root_widget, + position=(50, yoffs - 50), + size=(60, 55), + scale=0.8, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + extra_touch_border_scale=2.0, + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + button_width = 300 + self._player_profiles_button = btn = bui.buttonwidget( + parent=self._root_widget, + position=(self._width * 0.5 - button_width * 0.5, yoffs - 200), + autoselect=True, + size=(button_width, 60), + label=bui.Lstr(resource='playerProfilesWindow.titleText'), + color=(0.55, 0.5, 0.6), + icon=bui.gettexture('cuteSpaz'), + textcolor=(0.75, 0.7, 0.8), + on_activate_call=self._player_profiles_press, + ) + bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, yoffs - 250), + size=(0, 0), + text=bui.Lstr(resource='moreSoonText'), + scale=0.7, + maxwidth=self._width * 0.9, + h_align='center', + v_align='center', + ) + + def _player_profiles_press(self) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.profile.browser import ProfileBrowserWindow + + # no-op if our underlying widget is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + + self.main_window_replace( + ProfileBrowserWindow(origin_widget=self._player_profiles_button) + ) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) diff --git a/dist/ba_data/python/bauiv1lib/keyboard/englishkeyboard.py b/dist/ba_data/python/bauiv1lib/keyboard/englishkeyboard.py index 3de74f8..e8b6507 100644 --- a/dist/ba_data/python/bauiv1lib/keyboard/englishkeyboard.py +++ b/dist/ba_data/python/bauiv1lib/keyboard/englishkeyboard.py @@ -2,7 +2,7 @@ # """Defines a default keyboards.""" -# ba_meta require api 8 +# ba_meta require api 9 # (see https://ballistica.net/wiki/meta-tag-system) from __future__ import annotations diff --git a/dist/ba_data/python/bauiv1lib/kiosk.py b/dist/ba_data/python/bauiv1lib/kiosk.py index 4647563..c3814cd 100644 --- a/dist/ba_data/python/bauiv1lib/kiosk.py +++ b/dist/ba_data/python/bauiv1lib/kiosk.py @@ -4,14 +4,20 @@ from __future__ import annotations +from typing import override + import bascenev1 as bs import bauiv1 as bui -class KioskWindow(bui.Window): +class KioskWindow(bui.MainWindow): """Kiosk mode window.""" - def __init__(self, transition: str = 'in_right'): + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): # pylint: disable=too-many-locals, too-many-statements from bauiv1lib.confirm import QuitWindow @@ -26,11 +32,13 @@ class KioskWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition=transition, + # transition=transition, on_cancel_call=_do_cancel, background=False, stack_offset=(0, -130), - ) + ), + transition=transition, + origin_widget=origin_widget, ) self._r = 'kioskWindow' @@ -353,6 +361,20 @@ class KioskWindow(bui.Window): 1.0, bui.WeakCall(self._update), repeat=True ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _restore_state(self) -> None: assert bui.app.classic is not None sel_name = bui.app.ui_v1.window_states.get(type(self)) @@ -501,17 +523,16 @@ class KioskWindow(bui.Window): bui.containerwidget(edit=self._root_widget, transition='out_left') def _do_full_menu(self) -> None: + # pylint: disable=cyclic-import from bauiv1lib.mainmenu import MainMenuWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return assert bui.app.classic is not None self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') bui.app.classic.did_menu_intro = True # prevent delayed transition-in - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow().get_root_widget(), from_window=self._root_widget - ) + + self.main_window_replace(MainMenuWindow()) diff --git a/dist/ba_data/python/bauiv1lib/league/rankbutton.py b/dist/ba_data/python/bauiv1lib/league/rankbutton.py deleted file mode 100644 index d952827..0000000 --- a/dist/ba_data/python/bauiv1lib/league/rankbutton.py +++ /dev/null @@ -1,422 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""Provides a button showing league rank.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any, Callable - - -class LeagueRankButton: - """Button showing league rank.""" - - def __init__( - self, - parent: bui.Widget, - position: tuple[float, float], - size: tuple[float, float], - scale: float, - on_activate_call: Callable[[], Any] | None = None, - transition_delay: float | None = None, - color: tuple[float, float, float] | None = None, - textcolor: tuple[float, float, float] | None = None, - smooth_update_delay: float | None = None, - ): - if on_activate_call is None: - on_activate_call = bui.WeakCall(self._default_on_activate_call) - self._on_activate_call = on_activate_call - if smooth_update_delay is None: - smooth_update_delay = 1.0 - self._smooth_update_delay = smooth_update_delay - self._size = size - self._scale = scale - if color is None: - color = (0.5, 0.6, 0.5) - if textcolor is None: - textcolor = (1, 1, 1) - self._color = color - self._textcolor = textcolor - self._header_color = (0.8, 0.8, 2.0) - self._parent = parent - self._position: tuple[float, float] = (0.0, 0.0) - - self._button = bui.buttonwidget( - parent=parent, - size=size, - label='', - button_type='square', - scale=scale, - autoselect=True, - on_activate_call=self._on_activate, - transition_delay=transition_delay, - color=color, - ) - - self._title_text = bui.textwidget( - parent=parent, - size=(0, 0), - draw_controller=self._button, - h_align='center', - v_align='center', - maxwidth=size[0] * scale * 0.85, - text=bui.Lstr( - resource='league.leagueRankText', - fallback_resource='coopSelectWindow.powerRankingText', - ), - color=self._header_color, - flatness=1.0, - shadow=1.0, - scale=scale * 0.5, - transition_delay=transition_delay, - ) - - self._value_text = bui.textwidget( - parent=parent, - size=(0, 0), - h_align='center', - v_align='center', - maxwidth=size[0] * scale * 0.85, - text='-', - draw_controller=self._button, - big=True, - scale=scale, - transition_delay=transition_delay, - color=textcolor, - ) - - plus = bui.app.plus - assert plus is not None - - self._smooth_percent: float | None = None - self._percent: int | None = None - self._smooth_rank: float | None = None - self._rank: int | None = None - self._ticking_sound: bui.Sound | None = None - self._smooth_increase_speed = 1.0 - self._league: str | None = None - self._improvement_text: str | None = None - - self._smooth_update_timer: bui.AppTimer | None = None - - # Take note of our account state; we'll refresh later if this changes. - self._account_state_num = plus.get_v1_account_state_num() - self._last_power_ranking_query_time: float | None = None - self._doing_power_ranking_query = False - self.set_position(position) - self._bg_flash = False - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - self._update() - - # If we've got cached power-ranking data already, apply it. - assert bui.app.classic is not None - data = bui.app.classic.accounts.get_cached_league_rank_data() - if data is not None: - self._update_for_league_rank_data(data) - - def _on_activate(self) -> None: - bui.increment_analytics_count('League rank button press') - self._on_activate_call() - - def __del__(self) -> None: - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - - def _start_smooth_update(self) -> None: - self._smooth_update_timer = bui.AppTimer( - 0.05, bui.WeakCall(self._smooth_update), repeat=True - ) - - def _smooth_update(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - try: - if not self._button: - return - if self._ticking_sound is None: - self._ticking_sound = bui.getsound('scoreIncrease') - self._ticking_sound.play() - self._bg_flash = not self._bg_flash - color_used = ( - (self._color[0] * 2, self._color[1] * 2, self._color[2] * 2) - if self._bg_flash - else self._color - ) - textcolor_used = (1, 1, 1) if self._bg_flash else self._textcolor - header_color_used = ( - (1, 1, 1) if self._bg_flash else self._header_color - ) - - if self._rank is not None: - assert self._smooth_rank is not None - self._smooth_rank -= 1.0 * self._smooth_increase_speed - finished = int(self._smooth_rank) <= self._rank - elif self._smooth_percent is not None: - self._smooth_percent += 1.0 * self._smooth_increase_speed - assert self._percent is not None - finished = int(self._smooth_percent) >= self._percent - else: - finished = True - if finished: - if self._rank is not None: - self._smooth_rank = float(self._rank) - elif self._percent is not None: - self._smooth_percent = float(self._percent) - color_used = self._color - textcolor_used = self._textcolor - self._smooth_update_timer = None - if self._ticking_sound is not None: - self._ticking_sound.stop() - self._ticking_sound = None - bui.getsound('cashRegister2').play() - assert self._improvement_text is not None - diff_text = bui.textwidget( - parent=self._parent, - size=(0, 0), - h_align='center', - v_align='center', - text='+' + self._improvement_text + '!', - position=( - self._position[0] + self._size[0] * 0.5 * self._scale, - self._position[1] + self._size[1] * -0.2 * self._scale, - ), - color=(0, 1, 0), - flatness=1.0, - shadow=0.0, - scale=self._scale * 0.7, - ) - - def safe_delete(widget: bui.Widget) -> None: - if widget: - widget.delete() - - bui.apptimer(2.0, bui.Call(safe_delete, diff_text)) - status_text: str | bui.Lstr - if self._rank is not None: - assert self._smooth_rank is not None - status_text = bui.Lstr( - resource='numberText', - subs=[('${NUMBER}', str(int(self._smooth_rank)))], - ) - elif self._smooth_percent is not None: - status_text = str(int(self._smooth_percent)) + '%' - else: - status_text = '-' - bui.textwidget( - edit=self._value_text, text=status_text, color=textcolor_used - ) - bui.textwidget(edit=self._title_text, color=header_color_used) - bui.buttonwidget(edit=self._button, color=color_used) - - except Exception: - logging.exception('Error doing smooth update.') - self._smooth_update_timer = None - - def _update_for_league_rank_data(self, data: dict[str, Any] | None) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - plus = bui.app.plus - assert plus is not None - - # If our button has died, ignore. - if not self._button: - return - - status_text: str | bui.Lstr - - in_top = data is not None and data['rank'] is not None - do_percent = False - if data is None or plus.get_v1_account_state() != 'signed_in': - self._percent = self._rank = None - status_text = '-' - elif in_top: - self._percent = None - self._rank = data['rank'] - prev_league = self._league - self._league = data['l'] - - # If this is the first set, league has changed, or rank has gotten - # worse, snap the smooth value immediately. - assert self._rank is not None - if ( - self._smooth_rank is None - or prev_league != self._league - or self._rank > int(self._smooth_rank) - ): - self._smooth_rank = float(self._rank) - status_text = bui.Lstr( - resource='numberText', - subs=[('${NUMBER}', str(int(self._smooth_rank)))], - ) - else: - try: - if not data['scores'] or data['scores'][-1][1] <= 0: - self._percent = self._rank = None - status_text = '-' - else: - assert bui.app.classic is not None - our_points = ( - bui.app.classic.accounts.get_league_rank_points(data) - ) - progress = float(our_points) / data['scores'][-1][1] - self._percent = int(progress * 100.0) - self._rank = None - do_percent = True - prev_league = self._league - self._league = data['l'] - - # If this is the first set, league has changed, or percent - # has decreased, snap the smooth value immediately. - if ( - self._smooth_percent is None - or prev_league != self._league - or self._percent < int(self._smooth_percent) - ): - self._smooth_percent = float(self._percent) - status_text = str(int(self._smooth_percent)) + '%' - - except Exception: - logging.exception('Error updating power ranking.') - self._percent = self._rank = None - status_text = '-' - - # If we're doing a smooth update, set a timer. - if ( - self._rank is not None - and self._smooth_rank is not None - and int(self._smooth_rank) != self._rank - ): - self._improvement_text = str( - -(int(self._rank) - int(self._smooth_rank)) - ) - diff = abs(self._rank - self._smooth_rank) - if diff > 100: - self._smooth_increase_speed = diff / 80.0 - elif diff > 50: - self._smooth_increase_speed = diff / 70.0 - elif diff > 25: - self._smooth_increase_speed = diff / 55.0 - else: - self._smooth_increase_speed = diff / 40.0 - self._smooth_increase_speed = max(0.4, self._smooth_increase_speed) - bui.apptimer( - self._smooth_update_delay, - bui.WeakCall(self._start_smooth_update), - ) - - if ( - self._percent is not None - and self._smooth_percent is not None - and int(self._smooth_percent) != self._percent - ): - self._improvement_text = str( - (int(self._percent) - int(self._smooth_percent)) - ) - self._smooth_increase_speed = 0.3 - bui.apptimer( - self._smooth_update_delay, - bui.WeakCall(self._start_smooth_update), - ) - - if do_percent: - bui.textwidget( - edit=self._title_text, - text=bui.Lstr(resource='coopSelectWindow.toRankedText'), - ) - else: - try: - assert data is not None - txt = bui.Lstr( - resource='league.leagueFullText', - subs=[ - ( - '${NAME}', - bui.Lstr(translate=('leagueNames', data['l']['n'])), - ), - ], - ) - t_color = data['l']['c'] - except Exception: - txt = bui.Lstr( - resource='league.leagueRankText', - fallback_resource='coopSelectWindow.powerRankingText', - ) - assert bui.app.classic is not None - t_color = bui.app.ui_v1.title_color - bui.textwidget(edit=self._title_text, text=txt, color=t_color) - bui.textwidget(edit=self._value_text, text=status_text) - - def _on_power_ranking_query_response( - self, data: dict[str, Any] | None - ) -> None: - self._doing_power_ranking_query = False - assert bui.app.classic is not None - bui.app.classic.accounts.cache_league_rank_data(data) - self._update_for_league_rank_data(data) - - def _update(self) -> None: - cur_time = bui.apptime() - - plus = bui.app.plus - assert plus is not None - - # If our account state has changed, refresh our UI. - account_state_num = plus.get_v1_account_state_num() - if account_state_num != self._account_state_num: - self._account_state_num = account_state_num - - # And power ranking too... - if not self._doing_power_ranking_query: - self._last_power_ranking_query_time = None - - # Send off a new power-ranking query if its been - # long enough or whatnot. - if not self._doing_power_ranking_query and ( - self._last_power_ranking_query_time is None - or cur_time - self._last_power_ranking_query_time > 30.0 - ): - self._last_power_ranking_query_time = cur_time - self._doing_power_ranking_query = True - plus.power_ranking_query( - callback=bui.WeakCall(self._on_power_ranking_query_response) - ) - - def _default_on_activate_call(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.league.rankwindow import LeagueRankWindow - - LeagueRankWindow(modal=True, origin_widget=self._button) - - def set_position(self, position: tuple[float, float]) -> None: - """Set the button's position.""" - self._position = position - if not self._button: - return - bui.buttonwidget(edit=self._button, position=self._position) - bui.textwidget( - edit=self._title_text, - position=( - self._position[0] + self._size[0] * 0.5 * self._scale, - self._position[1] + self._size[1] * 0.82 * self._scale, - ), - ) - bui.textwidget( - edit=self._value_text, - position=( - self._position[0] + self._size[0] * 0.5 * self._scale, - self._position[1] + self._size[1] * 0.36 * self._scale, - ), - ) - - def get_button(self) -> bui.Widget: - """Return the underlying button bui.Widget>""" - return self._button diff --git a/dist/ba_data/python/bauiv1lib/league/rankwindow.py b/dist/ba_data/python/bauiv1lib/league/rankwindow.py index 7e6d8d1..331e08e 100644 --- a/dist/ba_data/python/bauiv1lib/league/rankwindow.py +++ b/dist/ba_data/python/bauiv1lib/league/rankwindow.py @@ -7,7 +7,7 @@ from __future__ import annotations import copy import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from bauiv1lib.popup import PopupMenu import bauiv1 as bui @@ -16,13 +16,12 @@ if TYPE_CHECKING: from typing import Any -class LeagueRankWindow(bui.Window): +class LeagueRankWindow(bui.MainWindow): """Window for showing league rank.""" def __init__( self, - transition: str = 'in_right', - modal: bool = False, + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements @@ -32,83 +31,112 @@ class LeagueRankWindow(bui.Window): bui.set_analytics_screen('League Rank Window') self._league_rank_data: dict[str, Any] | None = None - self._modal = modal self._power_ranking_achievements_button: bui.Widget | None = None - self._pro_mult_button: bui.Widget | None = None + self._up_to_date_bonus_button: bui.Widget | None = None self._power_ranking_trophies_button: bui.Widget | None = None self._league_title_text: bui.Widget | None = None self._league_text: bui.Widget | None = None self._league_number_text: bui.Widget | None = None self._your_power_ranking_text: bui.Widget | None = None + self._loading_spinner: bui.Widget | None = None self._season_ends_text: bui.Widget | None = None self._power_ranking_rank_text: bui.Widget | None = None self._to_ranked_text: bui.Widget | None = None self._trophy_counts_reset_text: bui.Widget | None = None - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 1320 if uiscale is bui.UIScale.SMALL else 1120 + self._width = 1500 if uiscale is bui.UIScale.SMALL else 1120 x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 self._height = ( - 657 + 1000 if uiscale is bui.UIScale.SMALL else 710 if uiscale is bui.UIScale.MEDIUM else 800 ) self._r = 'coopSelectWindow' self._rdict = bui.app.lang.get_resource(self._r) - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 + # top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 + + # self._xoffs = 80.0 if uiscale is bui.UIScale.SMALL else 0 + self._xoffs = 40 self._league_url_arg = '' self._is_current_season = False self._can_do_more_button = True + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.3 + if uiscale is bui.UIScale.SMALL + else 0.93 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 130, screensize[0] / scale) + target_height = min(self._height - 130, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 35 + scroll_bottom = yoffs - 80 - self._scroll_height + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), + size=(self._width, self._height), stack_offset=( - (0, -15) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 10) if uiscale is bui.UIScale.MEDIUM else (0, 0) ), - transition=transition, - scale_origin_stack_offset=scale_origin, - scale=( - 1.2 + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 0.93 if uiscale is bui.UIScale.MEDIUM else 0.8 + else 'menu_full' ), - ) + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=( - 75 + x_inset, - self._height - 87 - (4 if uiscale is bui.UIScale.SMALL else 0), - ), - size=(120, 60), - scale=1.2, - autoselect=True, - label=bui.Lstr(resource='doneText' if self._modal else 'backText'), - button_type=None if self._modal else 'back', - on_activate_call=self._back, - ) + if uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(75 + x_inset, yoffs - 60), + size=(60, 55), + scale=1.2, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, + cancel_button=self._back_button, + selected_child=self._back_button, + ) self._title_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 56), + position=( + self._width * 0.5, + yoffs - (55 if uiscale is bui.UIScale.SMALL else 30), + ), size=(0, 0), text=bui.Lstr( resource='league.leagueRankText', @@ -116,44 +144,31 @@ class LeagueRankWindow(bui.Window): ), h_align='center', color=bui.app.ui_v1.title_color, - scale=1.4, + scale=1.2 if uiscale is bui.UIScale.SMALL else 1.3, maxwidth=600, v_align='center', ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - position=( - 75 + x_inset, - self._height - 87 - (2 if uiscale is bui.UIScale.SMALL else 0), - ), - size=(60, 55), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - self._scroll_width = self._width - (130 + 2 * x_inset) - self._scroll_height = self._height - 160 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, - position=(65 + x_inset, 70), size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), center_small_content=True, + center_small_content_horizontally=True, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, autoselect=True) bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) - bui.containerwidget( - edit=self._root_widget, - cancel_button=self._back_button, - selected_child=self._back_button, - ) self._last_power_ranking_query_time: float | None = None self._doing_power_ranking_query = False self._subcontainer: bui.Widget | None = None - self._subcontainerwidth = 800 + self._subcontainerwidth = 1024 self._subcontainerheight = 483 self._power_ranking_score_widgets: list[bui.Widget] = [] @@ -161,13 +176,14 @@ class LeagueRankWindow(bui.Window): self._requested_season: str | None = None self._season: str | None = None - # take note of our account state; we'll refresh later if this changes + # Take note of our account state; we'll refresh later if this + # changes. self._account_state = plus.get_v1_account_state() self._refresh() self._restore_state() - # if we've got cached power-ranking data already, display it + # If we've got cached power-ranking data already, display it. assert bui.app.classic is not None info = bui.app.classic.accounts.get_cached_league_rank_data() if info is not None: @@ -178,17 +194,30 @@ class LeagueRankWindow(bui.Window): ) self._update(show=info is None) - def _on_achievements_press(self) -> None: - from bauiv1lib import achievements + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) - # only allow this for all-time or the current season - # (we currently don't keep specific achievement data for old seasons) + @override + def on_main_window_close(self) -> None: + self._save_state() + + def _on_achievements_press(self) -> None: + from bauiv1lib.achievements import AchievementsWindow + + # Only allow this for all-time or the current season (we + # currently don't keep specific achievement data for old + # seasons). if self._season == 'a' or self._is_current_season: prab = self._power_ranking_achievements_button assert prab is not None - achievements.AchievementsWindow( - position=prab.get_screen_space_center() - ) + self.main_window_replace(AchievementsWindow(origin_widget=prab)) else: bui.screenmessage( bui.Lstr( @@ -228,14 +257,14 @@ class LeagueRankWindow(bui.Window): origin_widget=self._activity_mult_button, ) - def _on_pro_mult_press(self) -> None: + def _on_up_to_date_bonus_press(self) -> None: from bauiv1lib import confirm plus = bui.app.plus assert plus is not None txt = bui.Lstr( - resource='coopSelectWindow.proMultInfoText', + resource='league.upToDateBonusDescriptionText', subs=[ ( '${PERCENT}', @@ -245,13 +274,6 @@ class LeagueRankWindow(bui.Window): ) ), ), - ( - '${PRO}', - bui.Lstr( - resource='store.bombSquadProNameText', - subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], - ), - ), ], ) confirm.ConfirmWindow( @@ -259,7 +281,7 @@ class LeagueRankWindow(bui.Window): cancel_button=False, width=460, height=130, - origin_widget=self._pro_mult_button, + origin_widget=self._up_to_date_bonus_button, ) def _on_trophies_press(self) -> None: @@ -281,7 +303,8 @@ class LeagueRankWindow(bui.Window): ) -> None: self._doing_power_ranking_query = False - # Important: *only* cache this if we requested the current season. + # Important: *only* cache this if we requested the current + # season. if data is not None and data.get('s', None) is None: assert bui.app.classic is not None bui.app.classic.accounts.cache_league_rank_data(data) @@ -310,8 +333,8 @@ class LeagueRankWindow(bui.Window): if not self._doing_power_ranking_query: self._last_power_ranking_query_time = None - # Send off a new power-ranking query if its been long enough or our - # requested season has changed or whatnot. + # Send off a new power-ranking query if its been long enough or + # our requested season has changed or whatnot. if not self._doing_power_ranking_query and ( self._last_power_ranking_query_time is None or cur_time - self._last_power_ranking_query_time > 30.0 @@ -321,13 +344,8 @@ class LeagueRankWindow(bui.Window): bui.textwidget(edit=self._league_title_text, text='') bui.textwidget(edit=self._league_text, text='') bui.textwidget(edit=self._league_number_text, text='') - bui.textwidget( - edit=self._your_power_ranking_text, - text=bui.Lstr( - value='${A}...', - subs=[('${A}', bui.Lstr(resource='loadingText'))], - ), - ) + bui.textwidget(edit=self._your_power_ranking_text, text='') + bui.spinnerwidget(edit=self._loading_spinner, visible=True) bui.textwidget(edit=self._to_ranked_text, text='') bui.textwidget(edit=self._power_ranking_rank_text, text='') bui.textwidget(edit=self._season_ends_text, text='') @@ -348,7 +366,7 @@ class LeagueRankWindow(bui.Window): plus = bui.app.plus assert plus is not None - # (re)create the sub-container if need be.. + # (Re)create the sub-container if need be. if self._subcontainer is not None: self._subcontainer.delete() self._subcontainer = bui.containerwidget( @@ -374,7 +392,7 @@ class LeagueRankWindow(bui.Window): bui.textwidget( parent=w_parent, - position=(h2 - 60, v2 + 106), + position=(self._xoffs + h2 - 60, v2 + 106), size=(0, 0), flatness=1.0, shadow=0.0, @@ -388,7 +406,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_achievements_button = bui.buttonwidget( parent=w_parent, - position=(h2 - 60, v2 + 10), + position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 80), icon=bui.gettexture('achievementsIcon'), autoselect=True, @@ -402,7 +420,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_achievement_total_text = bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally, v2 + 45), + position=(self._xoffs + h2 + h_offs_tally, v2 + 45), size=(0, 0), flatness=1.0, shadow=0.0, @@ -418,7 +436,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_trophies_button = bui.buttonwidget( parent=w_parent, - position=(h2 - 60, v2 + 10), + position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 80), icon=bui.gettexture('medalSilver'), autoselect=True, @@ -430,7 +448,7 @@ class LeagueRankWindow(bui.Window): ) self._power_ranking_trophies_total_text = bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally, v2 + 45), + position=(self._xoffs + h2 + h_offs_tally, v2 + 45), size=(0, 0), flatness=1.0, shadow=0.0, @@ -446,7 +464,7 @@ class LeagueRankWindow(bui.Window): bui.textwidget( parent=w_parent, - position=(h2 - 60, v2 + 86), + position=(self._xoffs + h2 - 60, v2 + 86), size=(0, 0), flatness=1.0, shadow=0.0, @@ -462,7 +480,7 @@ class LeagueRankWindow(bui.Window): if plus.get_v1_account_misc_read_val('act', False): self._activity_mult_button = bui.buttonwidget( parent=w_parent, - position=(h2 - 60, v2 + 10), + position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 60), icon=bui.gettexture('heart'), icon_color=(0.5, 0, 0.5), @@ -476,7 +494,7 @@ class LeagueRankWindow(bui.Window): self._activity_mult_text = bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally, v2 + 40), + position=(self._xoffs + h2 + h_offs_tally, v2 + 40), size=(0, 0), flatness=1.0, shadow=0.0, @@ -491,26 +509,23 @@ class LeagueRankWindow(bui.Window): else: self._activity_mult_button = None - self._pro_mult_button = bui.buttonwidget( + self._up_to_date_bonus_button = bui.buttonwidget( parent=w_parent, - position=(h2 - 60, v2 + 10), + position=(self._xoffs + h2 - 60, v2 + 10), size=(200, 60), icon=bui.gettexture('logo'), icon_color=(0.3, 0, 0.3), - label=bui.Lstr( - resource='store.bombSquadProNameText', - subs=[('${APP_NAME}', bui.Lstr(resource='titleText'))], - ), + label=bui.Lstr(resource='league.upToDateBonusText'), autoselect=True, - on_activate_call=bui.WeakCall(self._on_pro_mult_press), + on_activate_call=bui.WeakCall(self._on_up_to_date_bonus_press), left_widget=self._back_button, color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), ) - self._pro_mult_text = bui.textwidget( + self._up_to_date_bonus_text = bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally, v2 + 40), + position=(self._xoffs + h2 + h_offs_tally, v2 + 40), size=(0, 0), flatness=1.0, shadow=0.0, @@ -526,7 +541,7 @@ class LeagueRankWindow(bui.Window): v2 -= spc bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally - 10 - 40, v2 + 35), + position=(self._xoffs + h2 + h_offs_tally - 10 - 40, v2 + 35), size=(0, 0), flatness=1.0, shadow=0.0, @@ -539,7 +554,7 @@ class LeagueRankWindow(bui.Window): ) self._power_ranking_total_text = bui.textwidget( parent=w_parent, - position=(h2 + h_offs_tally - 40, v2 + 35), + position=(self._xoffs + h2 + h_offs_tally - 40, v2 + 35), size=(0, 0), flatness=1.0, shadow=0.0, @@ -553,7 +568,7 @@ class LeagueRankWindow(bui.Window): self._season_show_text = bui.textwidget( parent=w_parent, - position=(390 - 15, v - 20), + position=(self._xoffs + 390 - 15, v - 20), size=(0, 0), color=(0.6, 0.6, 0.7), maxwidth=200, @@ -567,7 +582,7 @@ class LeagueRankWindow(bui.Window): self._league_title_text = bui.textwidget( parent=w_parent, - position=(470, v - 97), + position=(self._xoffs + 470, v - 97), size=(0, 0), color=(0.6, 0.6, 0.7), maxwidth=230, @@ -583,7 +598,7 @@ class LeagueRankWindow(bui.Window): self._league_text_maxwidth = 210 self._league_text = bui.textwidget( parent=w_parent, - position=(470, v - 140), + position=(self._xoffs + 470, v - 140), size=(0, 0), color=(1, 1, 1), maxwidth=self._league_text_maxwidth, @@ -597,7 +612,7 @@ class LeagueRankWindow(bui.Window): self._league_number_base_pos = (470, v - 140) self._league_number_text = bui.textwidget( parent=w_parent, - position=(470, v - 140), + position=(self._xoffs + 470, v - 140), size=(0, 0), color=(1, 1, 1), maxwidth=100, @@ -609,9 +624,18 @@ class LeagueRankWindow(bui.Window): flatness=1.0, ) + self._loading_spinner = bui.spinnerwidget( + parent=w_parent, + position=( + self._subcontainerwidth * 0.5, + self._subcontainerheight * 0.5, + ), + style='bomb', + size=64, + ) self._your_power_ranking_text = bui.textwidget( parent=w_parent, - position=(470, v - 142 - 70), + position=(self._xoffs + 470, v - 142 - 70), size=(0, 0), color=(0.6, 0.6, 0.7), maxwidth=230, @@ -625,7 +649,7 @@ class LeagueRankWindow(bui.Window): self._to_ranked_text = bui.textwidget( parent=w_parent, - position=(470, v - 250 - 70), + position=(self._xoffs + 470, v - 250 - 70), size=(0, 0), color=(0.6, 0.6, 0.7), maxwidth=230, @@ -639,7 +663,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_rank_text = bui.textwidget( parent=w_parent, - position=(473, v - 210 - 70), + position=(self._xoffs + 473, v - 210 - 70), size=(0, 0), big=False, text='-', @@ -650,7 +674,7 @@ class LeagueRankWindow(bui.Window): self._season_ends_text = bui.textwidget( parent=w_parent, - position=(470, v - 380), + position=(self._xoffs + 470, v - 380), size=(0, 0), color=(0.6, 0.6, 0.6), maxwidth=230, @@ -663,7 +687,7 @@ class LeagueRankWindow(bui.Window): ) self._trophy_counts_reset_text = bui.textwidget( parent=w_parent, - position=(470, v - 410), + position=(self._xoffs + 470, v - 410), size=(0, 0), color=(0.5, 0.5, 0.5), maxwidth=230, @@ -685,7 +709,7 @@ class LeagueRankWindow(bui.Window): self._see_more_button = bui.buttonwidget( parent=w_parent, label=self._rdict.seeMoreText, - position=(h, v), + position=(self._xoffs + h, v), color=(0.5, 0.5, 0.6), textcolor=(0.7, 0.7, 0.8), size=(230, 60), @@ -698,8 +722,6 @@ class LeagueRankWindow(bui.Window): assert plus is not None our_login_id = plus.get_v1_account_public_login_id() - # our_login_id = _bs.get_account_misc_read_val_2( - # 'resolvedAccountID', None) if not self._can_do_more_button or our_login_id is None: bui.getsound('error').play() bui.screenmessage( @@ -732,6 +754,7 @@ class LeagueRankWindow(bui.Window): if not self._root_widget: return plus = bui.app.plus + uiscale = bui.app.ui_v1.uiscale assert plus is not None assert bui.app.classic is not None accounts = bui.app.classic.accounts @@ -752,8 +775,8 @@ class LeagueRankWindow(bui.Window): status_text = num_text.replace('${NUMBER}', str(data['rank'])) elif data is not None: try: - # handle old seasons where we didn't wind up ranked - # at the end.. + # Handle old seasons where we didn't wind up ranked at + # the end. if not data['scores']: status_text = ( self._rdict.powerRankingFinishedSeasonUnrankedText @@ -796,7 +819,7 @@ class LeagueRankWindow(bui.Window): did_first = False self._is_current_season = False if data is not None: - # build our list of seasons we have available + # Build our list of seasons we have available. for ssn in data['sl']: season_choices.append(ssn) if ssn != 'a' and not did_first: @@ -807,8 +830,9 @@ class LeagueRankWindow(bui.Window): ) ) did_first = True - # if we either did not specify a season or specified the - # first, we're looking at the current.. + + # If we either did not specify a season or specified + # the first, we're looking at the current. if self._season in [ssn, None]: self._is_current_season = True elif ssn == 'a': @@ -825,7 +849,7 @@ class LeagueRankWindow(bui.Window): assert self._subcontainer self._season_popup_menu = PopupMenu( parent=self._subcontainer, - position=(390, v - 45), + position=(self._xoffs + 390, v - 45), width=150, button_size=(200, 50), choices=season_choices, @@ -843,11 +867,12 @@ class LeagueRankWindow(bui.Window): edit=self._season_popup_menu.get_button(), up_widget=self._back_button, ) - bui.widget( - edit=self._back_button, - down_widget=self._power_ranking_achievements_button, - right_widget=self._season_popup_menu.get_button(), - ) + if uiscale is not bui.UIScale.SMALL: + bui.widget( + edit=self._back_button, + down_widget=self._power_ranking_achievements_button, + right_widget=self._season_popup_menu.get_button(), + ) bui.textwidget( edit=self._league_title_text, @@ -928,7 +953,10 @@ class LeagueRankWindow(bui.Window): text=lnum, color=lcolor, position=( - self._league_number_base_pos[0] + l_text_width * 0.5 + 8, + self._xoffs + + self._league_number_base_pos[0] + + l_text_width * 0.5 + + 8, self._league_number_base_pos[1] + 10, ), ) @@ -954,10 +982,11 @@ class LeagueRankWindow(bui.Window): else '' ), ) + bui.spinnerwidget(edit=self._loading_spinner, visible=False) bui.textwidget( edit=self._power_ranking_rank_text, - position=(473, v - 70 - (170 if do_percent else 220)), + position=(self._xoffs + 473, v - 70 - (170 if do_percent else 220)), text=status_text, big=(in_top or do_percent), scale=( @@ -981,13 +1010,16 @@ class LeagueRankWindow(bui.Window): textcolor=(0.7, 0.7, 0.8, 1.0), icon_color=(0.5, 0, 0.5, 1.0), ) - # pylint: disable=consider-using-f-string bui.textwidget( edit=self._activity_mult_text, - text='x ' + ('%.2f' % data['act']), + text=f'x {data['act']:.2f}', ) - have_pro = False if data is None else data['p'] + # This used to be a bonus for 'BombSquad Pro' holders, but since + # we're transitioning away from that it is now a bonus for + # everyone running a recent-ish version of the game. + + have_up_to_date_bonus = data is not None pro_mult = ( 1.0 + float( @@ -995,19 +1027,20 @@ class LeagueRankWindow(bui.Window): ) * 0.01 ) - # pylint: disable=consider-using-f-string bui.textwidget( - edit=self._pro_mult_text, + edit=self._up_to_date_bonus_text, text=( ' -' - if (data is None or not have_pro) - else 'x ' + ('%.2f' % pro_mult) + if (data is None or not have_up_to_date_bonus) + else f'x {pro_mult:.2f}' ), ) bui.buttonwidget( - edit=self._pro_mult_button, - textcolor=(0.7, 0.7, 0.8, (1.0 if have_pro else 0.5)), - icon_color=(0.5, 0, 0.5) if have_pro else (0.5, 0, 0.5, 0.2), + edit=self._up_to_date_bonus_button, + textcolor=(0.7, 0.7, 0.8, (1.0 if have_up_to_date_bonus else 0.5)), + icon_color=( + (0.5, 0, 0.5) if have_up_to_date_bonus else (0.5, 0, 0.5, 0.2) + ), ) bui.buttonwidget( edit=self._power_ranking_achievements_button, @@ -1015,8 +1048,8 @@ class LeagueRankWindow(bui.Window): + bui.Lstr(resource='achievementsText').evaluate(), ) - # for the achievement value, use the number they gave us for - # non-current seasons; otherwise calc our own + # For the achievement value, use the number they gave us for + # non-current seasons; otherwise calc our own. total_ach_value = 0 for ach in bui.app.classic.ach.achievements: if ach.complete: @@ -1080,7 +1113,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_score_widgets.append( bui.textwidget( parent=w_parent, - position=(h2 - 20, v2), + position=(self._xoffs + h2 - 20, v2), size=(0, 0), color=(1, 1, 1) if is_us else (0.6, 0.6, 0.7), maxwidth=40, @@ -1095,7 +1128,7 @@ class LeagueRankWindow(bui.Window): self._power_ranking_score_widgets.append( bui.textwidget( parent=w_parent, - position=(h2 + 20, v2), + position=(self._xoffs + h2 + 20, v2), size=(0, 0), color=(1, 1, 1) if is_us else tally_color, maxwidth=60, @@ -1109,7 +1142,7 @@ class LeagueRankWindow(bui.Window): ) txt = bui.textwidget( parent=w_parent, - position=(h2 + 60, v2 - (28 * 0.5) / 0.9), + position=(self._xoffs + h2 + 60, v2 - (28 * 0.5) / 0.9), size=(210 / 0.9, 28), color=(1, 1, 1) if is_us else (0.6, 0.6, 0.6), maxwidth=210, @@ -1139,35 +1172,17 @@ class LeagueRankWindow(bui.Window): def _show_account_info( self, account_id: str, textwidget: bui.Widget ) -> None: - from bauiv1lib.account import viewer + from bauiv1lib.account.viewer import AccountViewerWindow bui.getsound('swish').play() - viewer.AccountViewerWindow( + AccountViewerWindow( account_id=account_id, position=textwidget.get_screen_space_center() ) def _on_season_change(self, value: str) -> None: self._requested_season = value - self._last_power_ranking_query_time = None # make sure we update asap + self._last_power_ranking_query_time = None # Update asap. self._update(show=True) def _save_state(self) -> None: pass - - def _back(self) -> None: - from bauiv1lib.coop.browser import CoopBrowserWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - CoopBrowserWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/mainmenu.py b/dist/ba_data/python/bauiv1lib/mainmenu.py index cc79347..f0d6275 100644 --- a/dist/ba_data/python/bauiv1lib/mainmenu.py +++ b/dist/ba_data/python/bauiv1lib/mainmenu.py @@ -1,11 +1,10 @@ # Released under the MIT License. See LICENSE for details. # """Implements the main menu window.""" -# pylint: disable=too-many-lines from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import logging import bauiv1 as bui @@ -15,41 +14,34 @@ if TYPE_CHECKING: from typing import Any, Callable -class MainMenuWindow(bui.Window): - """The main menu window, both in-game and in the main menu session.""" +class MainMenuWindow(bui.MainWindow): + """The main menu window.""" - def __init__(self, transition: str | None = 'in_right'): - # pylint: disable=cyclic-import - import threading - from bascenev1lib.mainmenu import MainMenuSession - - plus = bui.app.plus - assert plus is not None - - self._in_game = not isinstance( - bs.get_foreground_host_session(), - MainMenuSession, - ) + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - threading.Thread(target=self._preload_modules).start() + bui.app.threadpool.submit_no_wait(self._preload_modules) - if not self._in_game: - bui.set_analytics_screen('Main Menu') - self._show_remote_app_info_on_first_launch() + bui.set_analytics_screen('Main Menu') + self._show_remote_app_info_on_first_launch() + + uiscale = bui.app.ui_v1.uiscale # Make a vanilla container; we'll modify it to our needs in # refresh. super().__init__( root_widget=bui.containerwidget( - transition=transition, - toolbar_visibility=( - 'menu_minimal_no_back' - if self._in_game - else 'menu_minimal_no_back' - ), - ) + toolbar_visibility=('menu_full_no_back') + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) # Grab this stuff in case it changes. @@ -59,7 +51,6 @@ class MainMenuWindow(bui.Window): self._tdelay = 0.0 self._t_delay_inc = 0.02 self._t_delay_play = 1.7 - self._p_index = 0 self._use_autoselect = True self._button_width = 200.0 self._button_height = 45.0 @@ -67,42 +58,39 @@ class MainMenuWindow(bui.Window): self._height = 100.0 self._demo_menu_button: bui.Widget | None = None self._gather_button: bui.Widget | None = None - self._start_button: bui.Widget | None = None + self._play_button: bui.Widget | None = None self._watch_button: bui.Widget | None = None - self._account_button: bui.Widget | None = None self._how_to_play_button: bui.Widget | None = None self._credits_button: bui.Widget | None = None - self._settings_button: bui.Widget | None = None - self._next_refresh_allow_time = 0.0 - - self._store_char_tex = self._get_store_char_tex() self._refresh() + self._restore_state() - # Keep an eye on a few things and refresh if they change. - self._account_state = plus.get_v1_account_state() - self._account_state_num = plus.get_v1_account_state_num() - self._account_type = ( - plus.get_v1_account_type() - if self._account_state == 'signed_in' - else None - ) - self._refresh_timer = bui.AppTimer( - 0.27, bui.WeakCall(self._check_refresh), repeat=True + @override + def on_main_window_close(self) -> None: + self._save_state() + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) ) @staticmethod def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" + # pylint: disable=cyclic-import import bauiv1lib.getremote as _unused import bauiv1lib.confirm as _unused2 - import bauiv1lib.store.button as _unused3 - import bauiv1lib.kiosk as _unused4 import bauiv1lib.account.settings as _unused5 import bauiv1lib.store.browser as _unused6 - import bauiv1lib.creditslist as _unused7 - import bauiv1lib.helpui as _unused8 + import bauiv1lib.credits as _unused7 + import bauiv1lib.help as _unused8 import bauiv1lib.settings.allsettings as _unused9 import bauiv1lib.gather as _unused10 import bauiv1lib.watch as _unused11 @@ -111,8 +99,9 @@ class MainMenuWindow(bui.Window): def _show_remote_app_info_on_first_launch(self) -> None: app = bui.app assert app.classic is not None - # The first time the non-in-game menu pops up, we might wanna show - # a 'get-remote-app' dialog in front of it. + + # The first time the non-in-game menu pops up, we might wanna + # show a 'get-remote-app' dialog in front of it. if app.classic.first_main_menu: app.classic.first_main_menu = False try: @@ -138,67 +127,16 @@ class MainMenuWindow(bui.Window): except Exception: logging.exception('Error showing get-remote-app info.') - def _get_store_char_tex(self) -> str: - plus = bui.app.plus - assert plus is not None - return ( - 'storeCharacterXmas' - if plus.get_v1_account_misc_read_val('xmas', False) - else ( - 'storeCharacterEaster' - if plus.get_v1_account_misc_read_val('easter', False) - else 'storeCharacter' - ) - ) - - def _check_refresh(self) -> None: - plus = bui.app.plus - assert plus is not None - - if not self._root_widget: - return - - now = bui.apptime() - if now < self._next_refresh_allow_time: - return - - # Don't refresh for the first few seconds the game is up so we - # don't interrupt the transition in. - - # bui.app.main_menu_window_refresh_check_count += 1 - # if bui.app.main_menu_window_refresh_check_count < 4: - # return - - store_char_tex = self._get_store_char_tex() - account_state_num = plus.get_v1_account_state_num() - if ( - account_state_num != self._account_state_num - or store_char_tex != self._store_char_tex - ): - self._store_char_tex = store_char_tex - self._account_state_num = account_state_num - account_state = self._account_state = plus.get_v1_account_state() - self._account_type = ( - plus.get_v1_account_type() - if account_state == 'signed_in' - else None - ) - self._save_state() - self._refresh() - self._restore_state() - def get_play_button(self) -> bui.Widget | None: """Return the play button.""" - return self._start_button + return self._play_button def _refresh(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals # pylint: disable=too-many-statements - from bauiv1lib.store.button import StoreButton + # pylint: disable=too-many-locals - plus = bui.app.plus - assert plus is not None + classic = bui.app.classic + assert classic is not None # Clear everything that was there. children = self._root_widget.get_children() @@ -215,492 +153,130 @@ class MainMenuWindow(bui.Window): app = bui.app assert app.classic is not None - self._have_quit_button = app.ui_v1.uiscale is bui.UIScale.LARGE or ( - app.classic.platform == 'windows' - and app.classic.subplatform == 'oculus' - ) + uiscale = app.ui_v1.uiscale - self._have_store_button = not self._in_game - - self._have_settings_button = ( - not self._in_game or not app.ui_v1.use_toolbars - ) and not (self._is_demo or self._is_arcade) - - self._input_device = input_device = bs.get_ui_input_device() - - # Are we connected to a local player? - self._input_player = input_device.player if input_device else None - - # Are we connected to a remote player?. - self._connected_to_remote_player = ( - input_device.is_attached_to_player() - if (input_device and self._input_player is None) - else False - ) - - positions: list[tuple[float, float, float]] = [] - self._p_index = 0 - - if self._in_game: - h, v, scale = self._refresh_in_game(positions) - else: - h, v, scale = self._refresh_not_in_game(positions) - - if self._have_settings_button: - h, v, scale = positions[self._p_index] - self._p_index += 1 - self._settings_button = bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - size=(self._button_width, self._button_height), - scale=scale, - autoselect=self._use_autoselect, - label=bui.Lstr(resource=f'{self._r}.settingsText'), - transition_delay=self._tdelay, - on_activate_call=self._settings, - ) - - # Scattered eggs on easter. - if ( - plus.get_v1_account_misc_read_val('easter', False) - and not self._in_game - ): - icon_size = 34 - bui.imagewidget( + # Temp note about UI changes. + if bool(False): + bui.textwidget( parent=self._root_widget, position=( - h - icon_size * 0.5 - 15, - v + self._button_height * scale - icon_size * 0.24 + 1.5, - ), - transition_delay=self._tdelay, - size=(icon_size, icon_size), - texture=bui.gettexture('egg3'), - tilt_scale=0.0, - ) - - self._tdelay += self._t_delay_inc - - if self._in_game: - h, v, scale = positions[self._p_index] - self._p_index += 1 - - # If we're in a replay, we have a 'Leave Replay' button. - if bs.is_in_replay(): - bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - scale=scale, - size=(self._button_width, self._button_height), - autoselect=self._use_autoselect, - label=bui.Lstr(resource='replayEndText'), - on_activate_call=self._confirm_end_replay, - ) - elif bs.get_foreground_host_session() is not None: - bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - scale=scale, - size=(self._button_width, self._button_height), - autoselect=self._use_autoselect, - label=bui.Lstr( - resource=self._r - + ( - '.endTestText' - if self._is_benchmark() - else '.endGameText' - ) - ), - on_activate_call=( - self._confirm_end_test - if self._is_benchmark() - else self._confirm_end_game - ), - ) - else: - # Assume we're in a client-session. - bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - scale=scale, - size=(self._button_width, self._button_height), - autoselect=self._use_autoselect, - label=bui.Lstr(resource=f'{self._r}.leavePartyText'), - on_activate_call=self._confirm_leave_party, - ) - - self._store_button: bui.Widget | None - if self._have_store_button: - this_b_width = self._button_width - h, v, scale = positions[self._p_index] - self._p_index += 1 - - sbtn = self._store_button_instance = StoreButton( - parent=self._root_widget, - position=(h - this_b_width * 0.5 * scale, v), - size=(this_b_width, self._button_height), - scale=scale, - on_activate_call=bui.WeakCall(self._on_store_pressed), - sale_scale=1.3, - transition_delay=self._tdelay, - ) - self._store_button = store_button = sbtn.get_button() - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - icon_size = ( - 55 - if uiscale is bui.UIScale.SMALL - else 55 if uiscale is bui.UIScale.MEDIUM else 70 - ) - bui.imagewidget( - parent=self._root_widget, - position=( - h - icon_size * 0.5, - v + self._button_height * scale - icon_size * 0.23, - ), - transition_delay=self._tdelay, - size=(icon_size, icon_size), - texture=bui.gettexture(self._store_char_tex), - tilt_scale=0.0, - draw_controller=store_button, - ) - self._tdelay += self._t_delay_inc - else: - self._store_button = None - - self._quit_button: bui.Widget | None - if not self._in_game and self._have_quit_button: - h, v, scale = positions[self._p_index] - self._p_index += 1 - self._quit_button = quit_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=self._use_autoselect, - position=(h - self._button_width * 0.5 * scale, v), - size=(self._button_width, self._button_height), - scale=scale, - label=bui.Lstr( - resource=self._r - + ( - '.quitText' - if 'Mac' in app.classic.legacy_user_agent_string - else '.exitGameText' + (-400, 400) + if uiscale is bui.UIScale.LARGE + else ( + (-270, 320) + if uiscale is bui.UIScale.MEDIUM + else (-280, 280) ) ), - on_activate_call=self._quit, - transition_delay=self._tdelay, - ) - - # Scattered eggs on easter. - if plus.get_v1_account_misc_read_val('easter', False): - icon_size = 30 - bui.imagewidget( - parent=self._root_widget, - position=( - h - icon_size * 0.5 + 25, - v - + self._button_height * scale - - icon_size * 0.24 - + 1.5, - ), - transition_delay=self._tdelay, - size=(icon_size, icon_size), - texture=bui.gettexture('egg1'), - tilt_scale=0.0, - ) - - bui.containerwidget( - edit=self._root_widget, cancel_button=quit_button - ) - self._tdelay += self._t_delay_inc - else: - self._quit_button = None - - # If we're not in-game, have no quit button, and this is - # android, we want back presses to quit our activity. - if ( - not self._in_game - and not self._have_quit_button - and app.classic.platform == 'android' - ): - - def _do_quit() -> None: - bui.quit(confirm=True, quit_type=bui.QuitType.BACK) - - bui.containerwidget( - edit=self._root_widget, on_cancel_call=_do_quit - ) - - # Add speed-up/slow-down buttons for replays. Ideally this - # should be part of a fading-out playback bar like most media - # players but this works for now. - if bs.is_in_replay(): - b_size = 50.0 - b_buffer_1 = 50.0 - b_buffer_2 = 10.0 - t_scale = 0.75 - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - if uiscale is bui.UIScale.SMALL: - b_size *= 0.6 - b_buffer_1 *= 0.8 - b_buffer_2 *= 1.0 - v_offs = -40 - t_scale = 0.5 - elif uiscale is bui.UIScale.MEDIUM: - v_offs = -70 - else: - v_offs = -100 - self._replay_speed_text = bui.textwidget( - parent=self._root_widget, - text=bui.Lstr( - resource='watchWindow.playbackSpeedText', - subs=[('${SPEED}', str(1.23))], - ), - position=(h, v + v_offs + 15 * t_scale), - h_align='center', - v_align='center', size=(0, 0), - scale=t_scale, - ) - - # Update to current value. - self._change_replay_speed(0) - - # Keep updating in a timer in case it gets changed elsewhere. - self._change_replay_speed_timer = bui.AppTimer( - 0.25, bui.WeakCall(self._change_replay_speed, 0), repeat=True - ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=( - h - b_size - b_buffer_1, - v - b_size - b_buffer_2 + v_offs, + scale=0.4, + flatness=1.0, + text=( + 'WARNING: This build contains a revamped UI\n' + 'which is still a work-in-progress. A number\n' + 'of features are not currently functional or\n' + 'contain bugs. To go back to the stable legacy UI,\n' + 'grab version 1.7.36 from ballistica.net' ), - button_type='square', - size=(b_size, b_size), - label='', - autoselect=True, - on_activate_call=bui.Call(self._change_replay_speed, -1), - ) - bui.textwidget( - parent=self._root_widget, - draw_controller=btn, - text='-', - position=( - h - b_size * 0.5 - b_buffer_1, - v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, - ), - h_align='center', - v_align='center', - size=(0, 0), - scale=3.0 * t_scale, - ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=(h + b_buffer_1, v - b_size - b_buffer_2 + v_offs), - button_type='square', - size=(b_size, b_size), - label='', - autoselect=True, - on_activate_call=bui.Call(self._change_replay_speed, 1), - ) - bui.textwidget( - parent=self._root_widget, - draw_controller=btn, - text='+', - position=( - h + b_size * 0.5 + b_buffer_1, - v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, - ), - h_align='center', - v_align='center', - size=(0, 0), - scale=3.0 * t_scale, - ) - self._pause_resume_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(h - b_size * 0.5, v - b_size - b_buffer_2 + v_offs), - button_type='square', - size=(b_size, b_size), - label=bui.charstr( - bui.SpecialChar.PLAY_BUTTON - if bs.is_replay_paused() - else bui.SpecialChar.PAUSE_BUTTON - ), - autoselect=True, - on_activate_call=bui.Call(self._pause_or_resume_replay), - ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=( - h - b_size * 1.5 - b_buffer_1 * 2, - v - b_size - b_buffer_2 + v_offs, - ), - button_type='square', - size=(b_size, b_size), - label='', - autoselect=True, - on_activate_call=bui.WeakCall(self._rewind_replay), - ) - bui.textwidget( - parent=self._root_widget, - draw_controller=btn, - # text='<<', - text=bui.charstr(bui.SpecialChar.REWIND_BUTTON), - position=( - h - b_size - b_buffer_1 * 2, - v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, - ), - h_align='center', - v_align='center', - size=(0, 0), - scale=2.0 * t_scale, - ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=( - h + b_size * 0.5 + b_buffer_1 * 2, - v - b_size - b_buffer_2 + v_offs, - ), - button_type='square', - size=(b_size, b_size), - label='', - autoselect=True, - on_activate_call=bui.WeakCall(self._forward_replay), - ) - bui.textwidget( - parent=self._root_widget, - draw_controller=btn, - # text='>>', - text=bui.charstr(bui.SpecialChar.FAST_FORWARD_BUTTON), - position=( - h + b_size + b_buffer_1 * 2, - v - b_size * 0.5 - b_buffer_2 + 5 * t_scale + v_offs, - ), - h_align='center', - v_align='center', - size=(0, 0), - scale=2.0 * t_scale, + h_align='left', + v_align='top', ) - def _rewind_replay(self) -> None: - bs.seek_replay(-2 * pow(2, bs.get_replay_speed_exponent())) + self._have_quit_button = app.classic.platform in ( + 'windows', + 'mac', + 'linux', + ) - def _forward_replay(self) -> None: - bs.seek_replay(2 * pow(2, bs.get_replay_speed_exponent())) + if not classic.did_menu_intro: + self._tdelay = 1.6 + self._t_delay_inc = 0.03 + classic.did_menu_intro = True - def _refresh_not_in_game( - self, positions: list[tuple[float, float, float]] - ) -> tuple[float, float, float]: - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - plus = bui.app.plus - assert plus is not None + td1 = 2 + td2 = 1 + td3 = 0 + td4 = -1 + td5 = -2 - assert bui.app.classic is not None - if not bui.app.classic.did_menu_intro: - self._tdelay = 2.0 - self._t_delay_inc = 0.02 - self._t_delay_play = 1.7 - - def _set_allow_time() -> None: - self._next_refresh_allow_time = bui.apptime() + 2.5 - - # Slight hack: widget transitions currently only progress - # when frames are being drawn, but this tends to get called - # before frame drawing even starts, meaning we don't know - # exactly how long we should wait before refreshing to avoid - # interrupting the transition. To make things a bit better, - # let's do a redundant set of the time in a deferred call - # which hopefully happens closer to actual frame draw times. - _set_allow_time() - bui.pushcall(_set_allow_time) - - bui.app.classic.did_menu_intro = True self._width = 400.0 self._height = 200.0 - enable_account_button = True - account_type_name: str | bui.Lstr - if plus.get_v1_account_state() == 'signed_in': - account_type_name = plus.get_v1_account_display_string() - account_type_icon = None - account_textcolor = (1.0, 1.0, 1.0) - else: - account_type_name = bui.Lstr( - resource='notSignedInText', - fallback_resource='accountSettingsWindow.titleText', - ) - account_type_icon = None - account_textcolor = (1.0, 0.2, 0.2) - account_type_icon_color = (1.0, 1.0, 1.0) - account_type_call = self._show_account_window - account_type_enable_button_sound = True - b_count = 3 # play, help, credits - if self._have_settings_button: - b_count += 1 - if enable_account_button: - b_count += 1 - if self._have_quit_button: - b_count += 1 - if self._have_store_button: - b_count += 1 - uiscale = bui.app.ui_v1.uiscale + + play_button_width = self._button_width * 0.65 + play_button_height = self._button_height * 1.1 + play_button_scale = 1.7 + hspace = 20.0 + side_button_width = self._button_width * 0.4 + side_button_height = side_button_width + side_button_scale = 0.95 + side_button_y_offs = 5.0 + hspace2 = 15.0 + side_button_2_width = self._button_width * 1.0 + side_button_2_height = side_button_2_width * 0.3 + side_button_2_y_offs = 10.0 + side_button_2_scale = 0.5 + if uiscale is bui.UIScale.SMALL: - root_widget_scale = 1.6 - play_button_width = self._button_width * 0.65 - play_button_height = self._button_height * 1.1 - small_button_scale = 0.51 if b_count > 6 else 0.63 + # We're a generally widescreen shaped window, so bump our + # overall scale up a bit when screen width is wider than safe + # bounds to take advantage of the extra space. + screensize = bui.get_virtual_screen_size() + safesize = bui.get_virtual_safe_area_size() + root_widget_scale = min(1.55, 1.3 * screensize[0] / safesize[0]) button_y_offs = -20.0 - button_y_offs2 = -60.0 self._button_height *= 1.3 - button_spacing = 1.04 elif uiscale is bui.UIScale.MEDIUM: root_widget_scale = 1.3 - play_button_width = self._button_width * 0.65 - play_button_height = self._button_height * 1.1 - small_button_scale = 0.6 button_y_offs = -55.0 - button_y_offs2 = -75.0 self._button_height *= 1.25 - button_spacing = 1.1 else: root_widget_scale = 1.0 - play_button_width = self._button_width * 0.65 - play_button_height = self._button_height * 1.1 - small_button_scale = 0.75 - button_y_offs = -80.0 - button_y_offs2 = -100.0 + button_y_offs = -90.0 self._button_height *= 1.2 - button_spacing = 1.1 - spc = self._button_width * small_button_scale * button_spacing + bui.containerwidget( edit=self._root_widget, size=(self._width, self._height), background=False, scale=root_widget_scale, ) - assert not positions - positions.append((self._width * 0.5, button_y_offs, 1.7)) - x_offs = self._width * 0.5 - (spc * (b_count - 1) * 0.5) + (spc * 0.5) - for i in range(b_count - 1): - positions.append( - ( - x_offs + spc * i - 1.0, - button_y_offs + button_y_offs2, - small_button_scale, - ) - ) + + # Version/copyright info. + thistdelay = self._tdelay + td3 * self._t_delay_inc + bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, button_y_offs - 10), + size=(0, 0), + scale=0.4, + flatness=1.0, + color=(1, 1, 1, 0.3), + text=( + f'{app.env.engine_version}' + f' build {app.env.engine_build_number}.' + f' Copyright 2025 Eric Froemling.' + ), + h_align='center', + v_align='center', + # transition_delay=self._t_delay_play, + transition_delay=thistdelay, + ) + # In kiosk mode, provide a button to get back to the kiosk menu. if bui.app.env.demo or bui.app.env.arcade: - h, v, scale = positions[self._p_index] + # h, v, scale = positions[self._p_index] + h = self._width * 0.5 + v = button_y_offs + scale = 1.0 this_b_width = self._button_width * 0.4 * scale - demo_menu_delay = ( - 0.0 - if self._t_delay_play == 0.0 - else max(0, self._t_delay_play + 0.1) - ) + # demo_menu_delay = ( + # 0.0 + # if self._t_delay_play == 0.0 + # else max(0, self._t_delay_play + 0.1) + # ) + demo_menu_delay = 0.0 self._demo_menu_button = bui.buttonwidget( parent=self._root_widget, + id='demo', position=(self._width * 0.5 - this_b_width * 0.5, v + 90), size=(this_b_width, 45), autoselect=True, @@ -714,73 +290,98 @@ class MainMenuWindow(bui.Window): ) ), transition_delay=demo_menu_delay, - on_activate_call=self._demo_menu_press, + on_activate_call=self.main_window_back, ) else: self._demo_menu_button = None - uiscale = bui.app.ui_v1.uiscale - foof = ( - -1 - if uiscale is bui.UIScale.SMALL - else 1 if uiscale is bui.UIScale.MEDIUM else 3 + + # Gather button + h = self._width * 0.5 + h = ( + self._width * 0.5 + - play_button_width * play_button_scale * 0.5 + - hspace + - side_button_width * side_button_scale * 0.5 ) - h, v, scale = positions[self._p_index] - v = v + foof - gather_delay = ( - 0.0 - if self._t_delay_play == 0.0 - else max(0.0, self._t_delay_play + 0.1) - ) - assert play_button_width is not None - assert play_button_height is not None - this_h = h - play_button_width * 0.5 * scale - 40 * scale - this_b_width = self._button_width * 0.25 * scale - this_b_height = self._button_height * 0.82 * scale + v = button_y_offs + side_button_y_offs + + thistdelay = self._tdelay + td2 * self._t_delay_inc self._gather_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(this_h - this_b_width * 0.5, v), - size=(this_b_width, this_b_height), + position=(h - side_button_width * side_button_scale * 0.5, v), + size=(side_button_width, side_button_height), + scale=side_button_scale, autoselect=self._use_autoselect, button_type='square', label='', - transition_delay=gather_delay, + transition_delay=thistdelay, on_activate_call=self._gather_press, ) bui.textwidget( parent=self._root_widget, - position=(this_h, v + self._button_height * 0.33), + position=(h, v + side_button_height * side_button_scale * 0.25), size=(0, 0), scale=0.75, - transition_delay=gather_delay, + transition_delay=thistdelay, draw_controller=btn, color=(0.75, 1.0, 0.7), - maxwidth=self._button_width * 0.33, + maxwidth=side_button_width * side_button_scale * 0.8, text=bui.Lstr(resource='gatherWindow.titleText'), h_align='center', v_align='center', ) - icon_size = this_b_width * 0.6 + icon_size = side_button_width * side_button_scale * 0.63 bui.imagewidget( parent=self._root_widget, size=(icon_size, icon_size), draw_controller=btn, - transition_delay=gather_delay, - position=(this_h - 0.5 * icon_size, v + 0.31 * this_b_height), + transition_delay=thistdelay, + position=( + h - 0.5 * icon_size, + v + + 0.65 * side_button_height * side_button_scale + - 0.5 * icon_size, + ), texture=bui.gettexture('usersButton'), ) + thistdelay = self._tdelay + td1 * self._t_delay_inc + + h -= ( + side_button_width * side_button_scale * 0.5 + + hspace2 + + side_button_2_width * side_button_2_scale + ) + v = button_y_offs + side_button_2_y_offs + + btn = bui.buttonwidget( + parent=self._root_widget, + id='howtoplay', + position=(h, v), + autoselect=self._use_autoselect, + size=(side_button_2_width, side_button_2_height * 2.0), + button_type='square', + scale=side_button_2_scale, + label=bui.Lstr(resource=f'{self._r}.howToPlayText'), + transition_delay=thistdelay, + on_activate_call=self._howtoplay, + ) + self._how_to_play_button = btn # Play button. - h, v, scale = positions[self._p_index] - self._p_index += 1 - self._start_button = start_button = bui.buttonwidget( + h = self._width * 0.5 + v = button_y_offs + assert play_button_width is not None + assert play_button_height is not None + thistdelay = self._tdelay + td3 * self._t_delay_inc + self._play_button = start_button = bui.buttonwidget( parent=self._root_widget, - position=(h - play_button_width * 0.5 * scale, v), + position=(h - play_button_width * 0.5 * play_button_scale, v), size=(play_button_width, play_button_height), autoselect=self._use_autoselect, - scale=scale, + scale=play_button_scale, text_res_scale=2.0, label=bui.Lstr(resource='playText'), - transition_delay=self._t_delay_play, + transition_delay=thistdelay, on_activate_call=self._play_press, ) bui.containerwidget( @@ -788,346 +389,127 @@ class MainMenuWindow(bui.Window): start_button=start_button, selected_child=start_button, ) - v = v + foof - watch_delay = ( - 0.0 - if self._t_delay_play == 0.0 - else max(0.0, self._t_delay_play - 0.1) + + # self._tdelay += self._t_delay_inc + + h = ( + self._width * 0.5 + + play_button_width * play_button_scale * 0.5 + + hspace + + side_button_width * side_button_scale * 0.5 ) - this_h = h + play_button_width * 0.5 * scale + 40 * scale - this_b_width = self._button_width * 0.25 * scale - this_b_height = self._button_height * 0.82 * scale + v = button_y_offs + side_button_y_offs + thistdelay = self._tdelay + td4 * self._t_delay_inc self._watch_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(this_h - this_b_width * 0.5, v), - size=(this_b_width, this_b_height), + position=(h - side_button_width * side_button_scale * 0.5, v), + size=(side_button_width, side_button_height), + scale=side_button_scale, autoselect=self._use_autoselect, button_type='square', label='', - transition_delay=watch_delay, + transition_delay=thistdelay, on_activate_call=self._watch_press, ) bui.textwidget( parent=self._root_widget, - position=(this_h, v + self._button_height * 0.33), + position=(h, v + side_button_height * side_button_scale * 0.25), size=(0, 0), scale=0.75, - transition_delay=watch_delay, + transition_delay=thistdelay, color=(0.75, 1.0, 0.7), draw_controller=btn, - maxwidth=self._button_width * 0.33, + maxwidth=side_button_width * side_button_scale * 0.8, text=bui.Lstr(resource='watchWindow.titleText'), h_align='center', v_align='center', ) - icon_size = this_b_width * 0.55 + icon_size = side_button_width * side_button_scale * 0.63 bui.imagewidget( parent=self._root_widget, size=(icon_size, icon_size), draw_controller=btn, - transition_delay=watch_delay, - position=(this_h - 0.5 * icon_size, v + 0.33 * this_b_height), + transition_delay=thistdelay, + position=( + h - 0.5 * icon_size, + v + + 0.65 * side_button_height * side_button_scale + - 0.5 * icon_size, + ), texture=bui.gettexture('tv'), ) - if not self._in_game and enable_account_button: - this_b_width = self._button_width - h, v, scale = positions[self._p_index] - self._p_index += 1 - self._account_button = bui.buttonwidget( - parent=self._root_widget, - position=(h - this_b_width * 0.5 * scale, v), - size=(this_b_width, self._button_height), - scale=scale, - label=account_type_name, - autoselect=self._use_autoselect, - on_activate_call=account_type_call, - textcolor=account_textcolor, - icon=account_type_icon, - icon_color=account_type_icon_color, - transition_delay=self._tdelay, - enable_sound=account_type_enable_button_sound, - ) - # Scattered eggs on easter. - if ( - plus.get_v1_account_misc_read_val('easter', False) - and not self._in_game - ): - icon_size = 32 - bui.imagewidget( - parent=self._root_widget, - position=( - h - icon_size * 0.5 + 35, - v - + self._button_height * scale - - icon_size * 0.24 - + 1.5, - ), - transition_delay=self._tdelay, - size=(icon_size, icon_size), - texture=bui.gettexture('egg2'), - tilt_scale=0.0, - ) - self._tdelay += self._t_delay_inc - else: - self._account_button = None - - # How-to-play button. - h, v, scale = positions[self._p_index] - self._p_index += 1 - btn = bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - scale=scale, - autoselect=self._use_autoselect, - size=(self._button_width, self._button_height), - label=bui.Lstr(resource=f'{self._r}.howToPlayText'), - transition_delay=self._tdelay, - on_activate_call=self._howtoplay, - ) - self._how_to_play_button = btn - - # Scattered eggs on easter. - if ( - plus.get_v1_account_misc_read_val('easter', False) - and not self._in_game - ): - icon_size = 28 - bui.imagewidget( - parent=self._root_widget, - position=( - h - icon_size * 0.5 + 30, - v + self._button_height * scale - icon_size * 0.24 + 1.5, - ), - transition_delay=self._tdelay, - size=(icon_size, icon_size), - texture=bui.gettexture('egg4'), - tilt_scale=0.0, - ) # Credits button. - self._tdelay += self._t_delay_inc - h, v, scale = positions[self._p_index] - self._p_index += 1 + thistdelay = self._tdelay + td5 * self._t_delay_inc + + h += side_button_width * side_button_scale * 0.5 + hspace2 + v = button_y_offs + side_button_2_y_offs + + if self._have_quit_button: + v += 1.17 * side_button_2_height * side_button_2_scale + self._credits_button = bui.buttonwidget( parent=self._root_widget, - position=(h - self._button_width * 0.5 * scale, v), - size=(self._button_width, self._button_height), + position=(h, v), + button_type=None if self._have_quit_button else 'square', + size=( + side_button_2_width, + side_button_2_height * (1.0 if self._have_quit_button else 2.0), + ), + scale=side_button_2_scale, autoselect=self._use_autoselect, label=bui.Lstr(resource=f'{self._r}.creditsText'), - scale=scale, - transition_delay=self._tdelay, + transition_delay=thistdelay, on_activate_call=self._credits, ) - self._tdelay += self._t_delay_inc - return h, v, scale - def _refresh_in_game( - self, positions: list[tuple[float, float, float]] - ) -> tuple[float, float, float]: - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - # pylint: disable=too-many-statements - assert bui.app.classic is not None - custom_menu_entries: list[dict[str, Any]] = [] - session = bs.get_foreground_host_session() - if session is not None: - try: - custom_menu_entries = session.get_custom_menu_entries() - for cme in custom_menu_entries: - cme_any: Any = cme # Type check may not hold true. - if ( - not isinstance(cme_any, dict) - or 'label' not in cme - or not isinstance(cme['label'], (str, bui.Lstr)) - or 'call' not in cme - or not callable(cme['call']) - ): - raise ValueError( - 'invalid custom menu entry: ' + str(cme) - ) - except Exception: - custom_menu_entries = [] - logging.exception( - 'Error getting custom menu entries for %s.', session - ) - self._width = 250.0 - self._height = 250.0 if self._input_player else 180.0 - if (self._is_demo or self._is_arcade) and self._input_player: - self._height -= 40 - if not self._have_settings_button: - self._height -= 50 - if self._connected_to_remote_player: - # In this case we have a leave *and* a disconnect button. - self._height += 50 - self._height += 50 * (len(custom_menu_entries)) - uiscale = bui.app.ui_v1.uiscale - bui.containerwidget( - edit=self._root_widget, - size=(self._width, self._height), - scale=( - 2.15 - if uiscale is bui.UIScale.SMALL - else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - ) - h = 125.0 - v = self._height - 80.0 if self._input_player else self._height - 60 - h_offset = 0 - d_h_offset = 0 - v_offset = -50 - for _i in range(6 + len(custom_menu_entries)): - positions.append((h, v, 1.0)) - v += v_offset - h += h_offset - h_offset += d_h_offset - self._start_button = None - bui.app.classic.pause() - - # Player name if applicable. - if self._input_player: - player_name = self._input_player.getname() - h, v, scale = positions[self._p_index] - v += 35 - bui.textwidget( + self._quit_button: bui.Widget | None + if self._have_quit_button: + v -= 1.1 * side_button_2_height * side_button_2_scale + # Nudge this a tiny bit right so we can press right from the + # credits button to get to it. + self._quit_button = quit_button = bui.buttonwidget( parent=self._root_widget, - position=(h - self._button_width / 2, v), - size=(self._button_width, self._button_height), - color=(1, 1, 1, 0.5), - scale=0.7, - h_align='center', - text=bui.Lstr(value=player_name), - ) - else: - player_name = '' - h, v, scale = positions[self._p_index] - self._p_index += 1 - btn = bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width / 2, v), - size=(self._button_width, self._button_height), - scale=scale, - label=bui.Lstr(resource=f'{self._r}.resumeText'), - autoselect=self._use_autoselect, - on_activate_call=self._resume, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - - # Add any custom options defined by the current game. - for entry in custom_menu_entries: - h, v, scale = positions[self._p_index] - self._p_index += 1 - - # Ask the entry whether we should resume when we call - # it (defaults to true). - resume = bool(entry.get('resume_on_call', True)) - - if resume: - call = bui.Call(self._resume_and_call, entry['call']) - else: - call = bui.Call(entry['call'], bui.WeakCall(self._resume)) - - bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width / 2, v), - size=(self._button_width, self._button_height), - scale=scale, - on_activate_call=call, - label=entry['label'], autoselect=self._use_autoselect, - ) - # Add a 'leave' button if the menu-owner has a player. - if (self._input_player or self._connected_to_remote_player) and not ( - self._is_demo or self._is_arcade - ): - h, v, scale = positions[self._p_index] - self._p_index += 1 - btn = bui.buttonwidget( - parent=self._root_widget, - position=(h - self._button_width / 2, v), - size=(self._button_width, self._button_height), - scale=scale, - on_activate_call=self._leave, - label='', - autoselect=self._use_autoselect, - ) - - if ( - player_name != '' - and player_name[0] != '<' - and player_name[-1] != '>' - ): - txt = bui.Lstr( - resource=f'{self._r}.justPlayerText', - subs=[('${NAME}', player_name)], - ) - else: - txt = bui.Lstr(value=player_name) - bui.textwidget( - parent=self._root_widget, - position=( - h, - v - + self._button_height - * (0.64 if player_name != '' else 0.5), + position=(h + 4.0, v), + size=(side_button_2_width, side_button_2_height), + scale=side_button_2_scale, + label=bui.Lstr( + resource=self._r + + ( + '.quitText' + if 'Mac' in app.classic.legacy_user_agent_string + else '.exitGameText' + ) ), - size=(0, 0), - text=bui.Lstr(resource=f'{self._r}.leaveGameText'), - scale=(0.83 if player_name != '' else 1.0), - color=(0.75, 1.0, 0.7), - h_align='center', - v_align='center', - draw_controller=btn, - maxwidth=self._button_width * 0.9, + on_activate_call=self._quit, + transition_delay=thistdelay, ) - bui.textwidget( - parent=self._root_widget, - position=(h, v + self._button_height * 0.27), - size=(0, 0), - text=txt, - color=(0.75, 1.0, 0.7), - h_align='center', - v_align='center', - draw_controller=btn, - scale=0.45, - maxwidth=self._button_width * 0.9, - ) - return h, v, scale - def _change_replay_speed(self, offs: int) -> None: - if not self._replay_speed_text: - if bui.do_once(): - print('_change_replay_speed called without widget') - return - bs.set_replay_speed_exponent(bs.get_replay_speed_exponent() + offs) - actual_speed = pow(2.0, bs.get_replay_speed_exponent()) - bui.textwidget( - edit=self._replay_speed_text, - text=bui.Lstr( - resource='watchWindow.playbackSpeedText', - subs=[('${SPEED}', str(actual_speed))], - ), - ) - - def _pause_or_resume_replay(self) -> None: - if bs.is_replay_paused(): - bs.resume_replay() - bui.buttonwidget( - edit=self._pause_resume_button, - label=bui.charstr(bui.SpecialChar.PAUSE_BUTTON), + bui.containerwidget( + edit=self._root_widget, cancel_button=quit_button ) + # self._tdelay += self._t_delay_inc else: - bs.pause_replay() - bui.buttonwidget( - edit=self._pause_resume_button, - label=bui.charstr(bui.SpecialChar.PLAY_BUTTON), - ) + self._quit_button = None + + # If we're not in-game, have no quit button, and this is + # android, we want back presses to quit our activity. + if app.classic.platform == 'android': + + def _do_quit() -> None: + bui.quit(confirm=True, quit_type=bui.QuitType.BACK) + + bui.containerwidget( + edit=self._root_widget, on_cancel_call=_do_quit + ) def _quit(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.confirm import QuitWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return # Note: Normally we should go through bui.quit(confirm=True) but @@ -1135,334 +517,115 @@ class MainMenuWindow(bui.Window): # button. QuitWindow(origin_widget=self._quit_button) - def _demo_menu_press(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.kiosk import KioskWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - KioskWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - - def _show_account_window(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account.settings import AccountSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AccountSettingsWindow( - origin_widget=self._account_button - ).get_root_widget(), - from_window=self._root_widget, - ) - - def _on_store_pressed(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.store.browser import StoreBrowserWindow - from bauiv1lib.account import show_sign_in_prompt - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - StoreBrowserWindow( - origin_widget=self._store_button - ).get_root_widget(), - from_window=self._root_widget, - ) - - def _is_benchmark(self) -> bool: - session = bs.get_foreground_host_session() - return getattr(session, 'benchmark_type', None) == 'cpu' or ( - bui.app.classic is not None - and bui.app.classic.stress_test_update_timer is not None - ) - - def _confirm_end_game(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.confirm import ConfirmWindow - - # FIXME: Currently we crash calling this on client-sessions. - - # Select cancel by default; this occasionally gets called by accident - # in a fit of button mashing and this will help reduce damage. - ConfirmWindow( - bui.Lstr(resource=f'{self._r}.exitToMenuText'), - self._end_game, - cancel_is_selected=True, - ) - - def _confirm_end_test(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.confirm import ConfirmWindow - - # Select cancel by default; this occasionally gets called by accident - # in a fit of button mashing and this will help reduce damage. - ConfirmWindow( - bui.Lstr(resource=f'{self._r}.exitToMenuText'), - self._end_game, - cancel_is_selected=True, - ) - - def _confirm_end_replay(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.confirm import ConfirmWindow - - # Select cancel by default; this occasionally gets called by accident - # in a fit of button mashing and this will help reduce damage. - ConfirmWindow( - bui.Lstr(resource=f'{self._r}.exitToMenuText'), - self._end_game, - cancel_is_selected=True, - ) - - def _confirm_leave_party(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.confirm import ConfirmWindow - - # Select cancel by default; this occasionally gets called by accident - # in a fit of button mashing and this will help reduce damage. - ConfirmWindow( - bui.Lstr(resource=f'{self._r}.leavePartyConfirmText'), - self._leave_party, - cancel_is_selected=True, - ) - - def _leave_party(self) -> None: - bs.disconnect_from_host() - - def _end_game(self) -> None: - assert bui.app.classic is not None - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.app.classic.return_to_main_menu_session_gracefully(reset_ui=False) - - def _leave(self) -> None: - if self._input_player: - self._input_player.remove_from_game() - elif self._connected_to_remote_player: - if self._input_device: - self._input_device.detach_from_player() - self._resume() - def _credits(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.creditslist import CreditsListWindow + from bauiv1lib.credits import CreditsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - CreditsListWindow( - origin_widget=self._credits_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + CreditsWindow(origin_widget=self._credits_button), ) def _howtoplay(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.helpui import HelpWindow + from bauiv1lib.help import HelpWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - HelpWindow( - main_menu=True, origin_widget=self._how_to_play_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + HelpWindow(origin_widget=self._how_to_play_button), ) - def _settings(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.allsettings import AllSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AllSettingsWindow( - origin_widget=self._settings_button - ).get_root_widget(), - from_window=self._root_widget, - ) - - def _resume_and_call(self, call: Callable[[], Any]) -> None: - self._resume() - call() - - def _do_game_service_press(self) -> None: - self._save_state() - if bui.app.plus is not None: - bui.app.plus.show_game_service_ui() - else: - logging.warning( - 'plus feature-set is required to show game service ui' - ) - def _save_state(self) -> None: - # Don't do this for the in-game menu. - if self._in_game: - return - assert bui.app.classic is not None - ui = bui.app.ui_v1 - sel = self._root_widget.get_selected_child() - if sel == self._start_button: - ui.main_menu_selection = 'Start' - elif sel == self._gather_button: - ui.main_menu_selection = 'Gather' - elif sel == self._watch_button: - ui.main_menu_selection = 'Watch' - elif sel == self._how_to_play_button: - ui.main_menu_selection = 'HowToPlay' - elif sel == self._credits_button: - ui.main_menu_selection = 'Credits' - elif sel == self._settings_button: - ui.main_menu_selection = 'Settings' - elif sel == self._account_button: - ui.main_menu_selection = 'Account' - elif sel == self._store_button: - ui.main_menu_selection = 'Store' - elif sel == self._quit_button: - ui.main_menu_selection = 'Quit' - elif sel == self._demo_menu_button: - ui.main_menu_selection = 'DemoMenu' - else: - print('unknown widget in main menu store selection:', sel) - ui.main_menu_selection = 'Start' + try: + sel = self._root_widget.get_selected_child() + if sel == self._play_button: + sel_name = 'Start' + elif sel == self._gather_button: + sel_name = 'Gather' + elif sel == self._watch_button: + sel_name = 'Watch' + elif sel == self._how_to_play_button: + sel_name = 'HowToPlay' + elif sel == self._credits_button: + sel_name = 'Credits' + elif sel == self._quit_button: + sel_name = 'Quit' + elif sel == self._demo_menu_button: + sel_name = 'DemoMenu' + else: + print(f'Unknown widget in main menu selection: {sel}.') + sel_name = 'Start' + bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name} + except Exception: + logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: - # pylint: disable=too-many-branches + try: - # Don't do this for the in-game menu. - if self._in_game: - return - assert bui.app.classic is not None - sel_name = bui.app.ui_v1.main_menu_selection - sel: bui.Widget | None - if sel_name is None: - sel_name = 'Start' - if sel_name == 'HowToPlay': - sel = self._how_to_play_button - elif sel_name == 'Gather': - sel = self._gather_button - elif sel_name == 'Watch': - sel = self._watch_button - elif sel_name == 'Credits': - sel = self._credits_button - elif sel_name == 'Settings': - sel = self._settings_button - elif sel_name == 'Account': - sel = self._account_button - elif sel_name == 'Store': - sel = self._store_button - elif sel_name == 'Quit': - sel = self._quit_button - elif sel_name == 'DemoMenu': - sel = self._demo_menu_button - else: - sel = self._start_button - if sel is not None: - bui.containerwidget(edit=self._root_widget, selected_child=sel) + sel: bui.Widget | None + + sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( + 'sel_name' + ) + assert isinstance(sel_name, (str, type(None))) + if sel_name is None: + sel_name = 'Start' + if sel_name == 'HowToPlay': + sel = self._how_to_play_button + elif sel_name == 'Gather': + sel = self._gather_button + elif sel_name == 'Watch': + sel = self._watch_button + elif sel_name == 'Credits': + sel = self._credits_button + elif sel_name == 'Quit': + sel = self._quit_button + elif sel_name == 'DemoMenu': + sel = self._demo_menu_button + else: + sel = self._play_button + if sel is not None: + bui.containerwidget(edit=self._root_widget, selected_child=sel) + + except Exception: + logging.exception('Error restoring state for %s.', self) def _gather_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.gather import GatherWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - GatherWindow(origin_widget=self._gather_button).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + GatherWindow(origin_widget=self._gather_button) ) def _watch_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.watch import WatchWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - WatchWindow(origin_widget=self._watch_button).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + WatchWindow(origin_widget=self._watch_button), ) def _play_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.play import PlayWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - - assert bui.app.classic is not None - bui.app.ui_v1.selecting_private_party_playlist = False - bui.app.ui_v1.set_main_menu_window( - PlayWindow(origin_widget=self._start_button).get_root_widget(), - from_window=self._root_widget, - ) - - def _resume(self) -> None: - assert bui.app.classic is not None - bui.app.classic.resume() - # if self._root_widget: - # bui.containerwidget(edit=self._root_widget, - # transition='out_right') - bui.app.ui_v1.clear_main_menu_window(transition='out_right') - - # If there's callbacks waiting for this window to go away, call them. - for call in bui.app.ui_v1.main_menu_resume_callbacks: - call() - del bui.app.ui_v1.main_menu_resume_callbacks[:] + self.main_window_replace(PlayWindow(origin_widget=self._play_button)) diff --git a/dist/ba_data/python/bauiv1lib/party.py b/dist/ba_data/python/bauiv1lib/party.py index 199db42..effa450 100644 --- a/dist/ba_data/python/bauiv1lib/party.py +++ b/dist/ba_data/python/bauiv1lib/party.py @@ -48,15 +48,15 @@ class PartyWindow(bui.Window): on_outside_click_call=self.close_with_sound, scale_origin_stack_offset=origin, scale=( - 2.0 + 1.8 if uiscale is bui.UIScale.SMALL - else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9 ), stack_offset=( - (0, -10) + (200, -10) if uiscale is bui.UIScale.SMALL else ( - (240, 0) if uiscale is bui.UIScale.MEDIUM else (330, 20) + (260, 0) if uiscale is bui.UIScale.MEDIUM else (370, 60) ) ), ) @@ -112,9 +112,22 @@ class PartyWindow(bui.Window): self._empty_str = bui.textwidget( parent=self._root_widget, - scale=0.75, + scale=0.6, size=(0, 0), - position=(self._width * 0.5, self._height - 65), + # color=(0.5, 1.0, 0.5), + shadow=0.3, + position=(self._width * 0.5, self._height - 57), + maxwidth=self._width * 0.85, + h_align='center', + v_align='center', + ) + self._empty_str_2 = bui.textwidget( + parent=self._root_widget, + scale=0.5, + size=(0, 0), + color=(0.5, 1.0, 0.5), + shadow=0.1, + position=(self._width * 0.5, self._height - 75), maxwidth=self._width * 0.85, h_align='center', v_align='center', @@ -126,6 +139,7 @@ class PartyWindow(bui.Window): size=(self._scroll_width, self._height - 200), position=(30, 80), color=(0.4, 0.6, 0.3), + border_opacity=0.6, ) self._columnwidget = bui.columnwidget( parent=self._scrollwidget, border=2, left_border=-200, margin=0 @@ -296,6 +310,10 @@ class PartyWindow(bui.Window): edit=self._empty_str, text=bui.Lstr(resource=f'{self._r}.emptyText'), ) + bui.textwidget( + edit=self._empty_str_2, + text=bui.Lstr(resource='gatherWindow.descriptionShortText'), + ) bui.scrollwidget( edit=self._scrollwidget, size=( @@ -436,6 +454,7 @@ class PartyWindow(bui.Window): ) ) bui.textwidget(edit=self._empty_str, text='') + bui.textwidget(edit=self._empty_str_2, text='') bui.scrollwidget( edit=self._scrollwidget, size=( diff --git a/dist/ba_data/python/bauiv1lib/partyqueue.py b/dist/ba_data/python/bauiv1lib/partyqueue.py index ef51429..dbcfd7a 100644 --- a/dist/ba_data/python/bauiv1lib/partyqueue.py +++ b/dist/ba_data/python/bauiv1lib/partyqueue.py @@ -33,6 +33,7 @@ class PartyQueueWindow(bui.Window): account_id: str, name: str, ): + # pylint: disable=too-many-positional-arguments self.claimed = False self._line_left = parent.get_line_left() self._line_width = parent.get_line_width() @@ -223,7 +224,6 @@ class PartyQueueWindow(bui.Window): def __init__(self, queue_id: str, address: str, port: int): assert bui.app.classic is not None - bui.app.ui_v1.have_party_queue_window = True self._address = address self._port = port self._queue_id = queue_id @@ -329,8 +329,6 @@ class PartyQueueWindow(bui.Window): plus = bui.app.plus assert plus is not None - assert bui.app.classic is not None - bui.app.ui_v1.have_party_queue_window = False plus.add_v1_account_transaction( {'type': 'PARTY_QUEUE_REMOVE', 'q': self._queue_id} ) @@ -354,12 +352,12 @@ class PartyQueueWindow(bui.Window): self, account_id: str | None, origin_widget: bui.Widget ) -> None: """A dude was clicked so we should show his account info.""" - from bauiv1lib.account import viewer + from bauiv1lib.account.viewer import AccountViewerWindow if account_id is None: bui.getsound('error').play() return - viewer.AccountViewerWindow( + AccountViewerWindow( account_id=account_id, position=origin_widget.get_screen_space_center(), ) @@ -554,6 +552,11 @@ class PartyQueueWindow(bui.Window): self._last_connect_attempt_time is None or now - self._last_connect_attempt_time > 10.0 ): + + # Store UI location to return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + bs.connect_to_party( address=self._address, port=self._port, @@ -563,19 +566,24 @@ class PartyQueueWindow(bui.Window): def on_boost_press(self) -> None: """Boost was pressed.""" - from bauiv1lib import account - from bauiv1lib import gettickets + from bauiv1lib.account.signin import show_sign_in_prompt + + # from bauiv1lib import gettickets plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() return if plus.get_v1_account_ticket_count() < self._boost_tickets: bui.getsound('error').play() - gettickets.show_get_tickets_prompt() + bui.screenmessage( + bui.Lstr(resource='notEnoughTicketsText'), + color=(1, 0, 0), + ) + # gettickets.show_get_tickets_prompt() return bui.getsound('laserReverse').play() diff --git a/dist/ba_data/python/bauiv1lib/play.py b/dist/ba_data/python/bauiv1lib/play.py index f33ffe6..647a9d8 100644 --- a/dist/ba_data/python/bauiv1lib/play.py +++ b/dist/ba_data/python/bauiv1lib/play.py @@ -5,116 +5,156 @@ from __future__ import annotations import logging +from typing import override, TYPE_CHECKING import bascenev1 as bs import bauiv1 as bui +if TYPE_CHECKING: + from bauiv1 import MainWindowState -class PlayWindow(bui.Window): + +class PlaylistSelectContext: + """For using PlayWindow to select a playlist instead of running game.""" + + back_state: MainWindowState | None = None + + +class PlayWindow(bui.MainWindow): """Window for selecting overall play type.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + playlist_select_context: PlaylistSelectContext | None = None, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals - import threading # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - threading.Thread(target=self._preload_modules).start() + bui.app.threadpool.submit_no_wait(self._preload_modules) - # We can currently be used either for main menu duty or for selecting - # playlists (should make this more elegant/general). - assert bui.app.classic is not None - self._is_main_menu = not bui.app.ui_v1.selecting_private_party_playlist + classic = bui.app.classic + assert classic is not None + + self._playlist_select_context = playlist_select_context uiscale = bui.app.ui_v1.uiscale - width = 1100 if uiscale is bui.UIScale.SMALL else 800 - x_offs = 150 if uiscale is bui.UIScale.SMALL else 0 - height = 550 - button_width = 400 + width = 1300 if uiscale is bui.UIScale.SMALL else 1000 + height = 1000 if uiscale is bui.UIScale.SMALL else 550 + + button_width = 400.0 + button_height = 360.0 + button_spacing = 3.0 - scale_origin: tuple[float, float] | None if origin_widget is not None: + + # Need to store this ourself since we can function as a + # non-main window. self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' else: self._transition_out = 'out_right' - scale_origin = None self._r = 'playWindow' + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + safesize = bui.get_virtual_safe_area_size() + + # We're a generally widescreen shaped window, so bump our + # overall scale up a bit when screen width is wider than safe + # bounds to take advantage of the extra space. + smallscale = min(1.6, 1.35 * screensize[0] / safesize[0]) + + scale = ( + smallscale + if uiscale is bui.UIScale.SMALL + else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_height = min(height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - toolbar_visibility='menu_full', - scale_origin_stack_offset=scale_origin, - scale=( - 1.6 - if uiscale is bui.UIScale.SMALL - else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + toolbar_visibility=( + 'menu_full' + if playlist_select_context is None + else 'menu_minimal' ), - stack_offset=(0, 0) if uiscale is bui.UIScale.SMALL else (0, 0), - ) - ) - self._back_button = back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(55 + x_offs, height - 132), - size=(120, 60), - scale=1.1, - text_res_scale=1.5, - text_scale=1.2, - autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - txt = bui.textwidget( + self._back_button: bui.Widget | None + if uiscale is bui.UIScale.SMALL: + self._back_button = None + bui.containerwidget( + edit=self._root_widget, + on_cancel_call=self.main_window_back, + ) + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(50, yoffs - 100), + size=(60, 60), + scale=1.1, + text_res_scale=1.5, + text_scale=1.2, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + + bui.textwidget( parent=self._root_widget, - position=(width * 0.5, height - 101), - # position=(width * 0.5, height - - # (101 if main_menu else 61)), + position=( + width * 0.5, + yoffs - (50 if uiscale is bui.UIScale.SMALL else 70), + ), size=(0, 0), text=bui.Lstr( resource=( (f'{self._r}.titleText') - if self._is_main_menu + if self._playlist_select_context is None else 'playlistsText' ) ), - scale=1.7, + scale=1.2 if uiscale is bui.UIScale.SMALL else 1.7, res_scale=2.0, - maxwidth=400, + maxwidth=250, color=bui.app.ui_v1.heading_color, h_align='center', v_align='center', ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: - bui.textwidget(edit=txt, text='') - - v = height - (110 if self._is_main_menu else 90) - v -= 100 + scl = 0.75 if self._playlist_select_context is None else 0.68 + v = height * 0.5 - button_height * scl * 0.5 - 20.0 clr = (0.6, 0.7, 0.6, 1.0) - v -= 280 if self._is_main_menu else 180 - v += ( - 30 - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL - else 0 + + bcount = 3 if self._playlist_select_context is None else 2 + + total_b_width = ( + bcount * button_width * scl + (bcount - 1) * button_spacing ) - hoffs = x_offs + 80 if self._is_main_menu else x_offs - 100 - scl = 1.13 if self._is_main_menu else 0.68 + hoffs = (width - total_b_width) * 0.5 self._lineup_tex = bui.gettexture('playerLineup') angry_computer_transparent_mesh = bui.getmesh( @@ -136,24 +176,23 @@ class PlayWindow(bui.Window): self._coop_button: bui.Widget | None = None - # Only show coop button in main-menu variant. - if self._is_main_menu: + # Only show coop button in regular variant. + if self._playlist_select_context is None: self._coop_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(hoffs, v + (scl * 15)), + position=(hoffs, v), size=( scl * button_width, - scl * 300, + scl * button_height, ), extra_touch_border_scale=0.1, autoselect=True, label='', button_type='square', - text_scale=1.13, on_activate_call=self._coop, ) - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), @@ -220,7 +259,7 @@ class PlayWindow(bui.Window): h_align='center', v_align='center', color=(0.7, 0.9, 0.7, 1.0), - scale=scl * 2.3, + scale=scl * 1.5, ) bui.textwidget( @@ -237,32 +276,22 @@ class PlayWindow(bui.Window): color=clr, ) - scl = 0.5 if self._is_main_menu else 0.68 - hoffs += 440 if self._is_main_menu else 216 - v += 180 if self._is_main_menu else -68 + hoffs += scl * button_width + button_spacing self._teams_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(hoffs, v + (scl * 15 if self._is_main_menu else 0)), + position=(hoffs, v), size=( scl * button_width, - scl * (300 if self._is_main_menu else 360), + scl * button_height, ), extra_touch_border_scale=0.1, autoselect=True, label='', button_type='square', - text_scale=1.13, on_activate_call=self._team_tourney, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - up_widget=bui.get_special_widget('tickets_plus_button'), - right_widget=bui.get_special_widget('party_button'), - ) - xxx = -14 self._draw_dude( 2, @@ -352,7 +381,7 @@ class PlayWindow(bui.Window): h_align='center', v_align='center', color=(0.7, 0.9, 0.7, 1.0), - scale=scl * 2.3, + scale=scl * 1.5, ) bui.textwidget( parent=self._root_widget, @@ -363,26 +392,21 @@ class PlayWindow(bui.Window): h_align='center', v_align='center', res_scale=1.5, - scale=0.9 * scl, + scale=0.83 * scl, flatness=1.0, maxwidth=scl * button_width * 0.7, color=clr, ) - hoffs += 0 if self._is_main_menu else 300 - v -= 155 if self._is_main_menu else 0 + hoffs += scl * button_width + button_spacing self._free_for_all_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(hoffs, v + (scl * 15 if self._is_main_menu else 0)), - size=( - scl * button_width, - scl * (300 if self._is_main_menu else 360), - ), + position=(hoffs, v), + size=(scl * button_width, scl * button_height), extra_touch_border_scale=0.1, autoselect=True, label='', button_type='square', - text_scale=1.13, on_activate_call=self._free_for_all, ) @@ -473,7 +497,7 @@ class PlayWindow(bui.Window): h_align='center', v_align='center', color=(0.7, 0.9, 0.7, 1.0), - scale=scl * 1.9, + scale=scl * 1.5, ) bui.textwidget( parent=self._root_widget, @@ -483,38 +507,53 @@ class PlayWindow(bui.Window): text=bui.Lstr(resource=f'{self._r}.twoToEightPlayersText'), h_align='center', v_align='center', - scale=0.9 * scl, + scale=0.83 * scl, flatness=1.0, maxwidth=scl * button_width * 0.7, color=clr, ) - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: - back_button.delete() + if uiscale is bui.UIScale.SMALL: bui.containerwidget( edit=self._root_widget, - on_cancel_call=self._back, selected_child=( self._coop_button - if self._is_main_menu + if self._playlist_select_context is None else self._teams_button ), ) else: - bui.buttonwidget(edit=back_button, on_activate_call=self._back) bui.containerwidget( edit=self._root_widget, - cancel_button=back_button, selected_child=( self._coop_button - if self._is_main_menu + if self._playlist_select_context is None else self._teams_button ), ) self._restore_state() - # noinspection PyUnresolvedReferences + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull any values out of self here; if we do it in the lambda + # we'll keep our window alive inadvertantly. + playlist_select_context = self._playlist_select_context + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + playlist_select_context=playlist_select_context, + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + @staticmethod def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" @@ -523,45 +562,13 @@ class PlayWindow(bui.Window): import bauiv1lib.coop.browser as _unused3 import bauiv1lib.playlist.browser as _unused4 - def _back(self) -> None: - # pylint: disable=cyclic-import - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - if self._is_main_menu: - from bauiv1lib.mainmenu import MainMenuWindow - - self._save_state() - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - else: - from bauiv1lib.gather import GatherWindow - - self._save_state() - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - GatherWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - def _coop(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.coop.browser import CoopBrowserWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return plus = bui.app.plus @@ -570,51 +577,41 @@ class PlayWindow(bui.Window): if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - CoopBrowserWindow( - origin_widget=self._coop_button - ).get_root_widget(), - from_window=self._root_widget, + + self.main_window_replace( + CoopBrowserWindow(origin_widget=self._coop_button) ) def _team_tourney(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.playlist.browser import PlaylistBrowserWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + self.main_window_replace( PlaylistBrowserWindow( - origin_widget=self._teams_button, sessiontype=bs.DualTeamSession - ).get_root_widget(), - from_window=self._root_widget, + origin_widget=self._teams_button, + sessiontype=bs.DualTeamSession, + playlist_select_context=self._playlist_select_context, + ) ) def _free_for_all(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.playlist.browser import PlaylistBrowserWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not currently in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + self.main_window_replace( PlaylistBrowserWindow( origin_widget=self._free_for_all_button, sessiontype=bs.FreeForAllSession, - ).get_root_widget(), - from_window=self._root_widget, + playlist_select_context=self._playlist_select_context, + ) ) def _draw_dude( @@ -627,6 +624,7 @@ class PlayWindow(bui.Window): position: tuple[float, float], color: tuple[float, float, float], ) -> None: + # pylint: disable=too-many-positional-arguments h_extra = -100 v_extra = 130 eye_color = ( @@ -763,7 +761,7 @@ class PlayWindow(bui.Window): sel = self._coop_button elif sel_name == 'Free-for-All Games': sel = self._free_for_all_button - elif sel_name == 'Back': + elif sel_name == 'Back' and self._back_button is not None: sel = self._back_button else: sel = ( diff --git a/dist/ba_data/python/bauiv1lib/playlist/addgame.py b/dist/ba_data/python/bauiv1lib/playlist/addgame.py index 317c9e9..0173b82 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/addgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/addgame.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bascenev1 as bs import bauiv1 as bui @@ -13,55 +13,78 @@ if TYPE_CHECKING: from bauiv1lib.playlist.editcontroller import PlaylistEditController -class PlaylistAddGameWindow(bui.Window): +class PlaylistAddGameWindow(bui.MainWindow): """Window for selecting a game type to add to a playlist.""" def __init__( self, editcontroller: PlaylistEditController, - transition: str = 'in_right', + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): self._editcontroller = editcontroller self._r = 'addGameWindow' assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 750 if uiscale is bui.UIScale.SMALL else 650 - x_inset = 50 if uiscale is bui.UIScale.SMALL else 0 + self._width = 900 if uiscale is bui.UIScale.SMALL else 650 + self._height = ( - 346 + 1200.0 if uiscale is bui.UIScale.SMALL - else 380 if uiscale is bui.UIScale.MEDIUM else 440 + else 450.0 if uiscale is bui.UIScale.MEDIUM else 500.0 ) - top_extra = 30 if uiscale is bui.UIScale.SMALL else 20 self._scroll_width = 210 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.4 + if uiscale is bui.UIScale.SMALL + else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 50, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 5.0 + x_inset = 0.5 * self._width - 0.5 * target_width + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - scale=( - 2.17 - if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=(0, 1) if uiscale is bui.UIScale.SMALL else (0, 0), - ) + size=(self._width, self._height), + scale=scale, + toolbar_visibility='menu_minimal', + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button = bui.buttonwidget( - parent=self._root_widget, - position=(58 + x_inset, self._height - 53), - size=(165, 70), - scale=0.75, - text_scale=1.2, - label=bui.Lstr(resource='backText'), - autoselect=True, - button_type='back', - on_activate_call=self._back, - ) + if uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(58 + x_inset, yoffs - 53), + size=(60, 48), + label=bui.charstr(bui.SpecialChar.BACK), + autoselect=True, + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + self._select_button = select_button = bui.buttonwidget( parent=self._root_widget, - position=(self._width - (172 + x_inset), self._height - 50), + position=( + x_inset + target_width - 172, + yoffs - 50, + ), autoselect=True, size=(160, 60), scale=0.75, @@ -70,15 +93,14 @@ class PlaylistAddGameWindow(bui.Window): on_activate_call=self._add, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=select_button, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=select_button, + right_widget=bui.get_special_widget('squad_button'), + ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 28), + position=(self._width * 0.5, yoffs - 28), size=(0, 0), scale=1.0, text=bui.Lstr(resource=f'{self._r}.titleText'), @@ -87,7 +109,7 @@ class PlaylistAddGameWindow(bui.Window): maxwidth=250, v_align='center', ) - v = self._height - 64 + v = yoffs - 64 self._selected_title_text = bui.textwidget( parent=self._root_widget, @@ -111,15 +133,16 @@ class PlaylistAddGameWindow(bui.Window): h_align='left', ) - scroll_height = self._height - 100 + scroll_height = target_height - 60 - v = self._height - 60 + v = yoffs - 60 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, position=(x_inset + 61, v - scroll_height), size=(self._scroll_width, scroll_height), highlight=False, + border_opacity=0.4, ) bui.widget( edit=self._scrollwidget, @@ -130,11 +153,18 @@ class PlaylistAddGameWindow(bui.Window): self._column: bui.Widget | None = None v -= 35 - bui.containerwidget( - edit=self._root_widget, - cancel_button=self._back_button, - start_button=select_button, - ) + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + bui.containerwidget( + edit=self._root_widget, + cancel_button=self._back_button, + ) + bui.containerwidget(edit=self._root_widget, start_button=select_button) + self._selected_game_type: type[bs.GameActivity] | None = None bui.containerwidget( @@ -154,6 +184,23 @@ class PlaylistAddGameWindow(bui.Window): # game loading is complete. self._refresh() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Avoid dereferencing self from the lambda or we'll keep + # ourself alive indefinitely. + editcontroller = self._editcontroller + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + editcontroller=editcontroller, + ) + ) + def _on_game_types_loaded( self, gametypes: list[type[bs.GameActivity]] ) -> None: @@ -225,30 +272,35 @@ class PlaylistAddGameWindow(bui.Window): ) def _on_get_more_games_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.store.browser import StoreBrowserWindow + # No-op if we're not in control. + if not self.main_window_has_control(): + return + plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() return - StoreBrowserWindow( - modal=True, - show_tab=StoreBrowserWindow.TabID.MINIGAMES, - on_close_call=self._on_store_close, - origin_widget=self._get_more_games_button, - ) - def _on_store_close(self) -> None: - self._refresh(select_get_more_games_button=True) + self.main_window_replace( + StoreBrowserWindow( + show_tab=StoreBrowserWindow.TabID.MINIGAMES, + origin_widget=self._get_more_games_button, + minimal_toolbars=True, + ) + ) def _add(self) -> None: bui.lock_all_input() # Make sure no more commands happen. bui.apptimer(0.1, bui.unlock_all_input) assert self._selected_game_type is not None - self._editcontroller.add_game_type_selected(self._selected_game_type) + self._editcontroller.add_game_type_selected( + self._selected_game_type, from_window=self + ) def _set_selected_game_type(self, gametype: type[bs.GameActivity]) -> None: self._selected_game_type = gametype @@ -261,6 +313,3 @@ class PlaylistAddGameWindow(bui.Window): self._editcontroller.get_session_type() ), ) - - def _back(self) -> None: - self._editcontroller.add_game_cancelled() diff --git a/dist/ba_data/python/bauiv1lib/playlist/browser.py b/dist/ba_data/python/bauiv1lib/playlist/browser.py index f8b1a73..917289b 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/browser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/browser.py @@ -4,15 +4,19 @@ from __future__ import annotations -import logging import copy import math +import logging +from typing import override, TYPE_CHECKING import bascenev1 as bs import bauiv1 as bui +if TYPE_CHECKING: + from bauiv1lib.play import PlaylistSelectContext -class PlaylistBrowserWindow(bui.Window): + +class PlaylistBrowserWindow(bui.MainWindow): """Window for starting teams games.""" def __init__( @@ -20,29 +24,15 @@ class PlaylistBrowserWindow(bui.Window): sessiontype: type[bs.Session], transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + playlist_select_context: PlaylistSelectContext | None = None, ): - # pylint: disable=too-many-statements # pylint: disable=cyclic-import from bauiv1lib.playlist import PlaylistTypeVars - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - - assert bui.app.classic is not None - # Store state for when we exit the next game. if issubclass(sessiontype, bs.DualTeamSession): - bui.app.ui_v1.set_main_menu_location('Team Game Select') bui.set_analytics_screen('Teams Window') elif issubclass(sessiontype, bs.FreeForAllSession): - bui.app.ui_v1.set_main_menu_location('Free-for-All Game Select') bui.set_analytics_screen('FreeForAll Window') else: raise TypeError(f'Invalid sessiontype: {sessiontype}.') @@ -53,6 +43,7 @@ class PlaylistBrowserWindow(bui.Window): self._customize_button: bui.Widget | None = None self._sub_width: float | None = None self._sub_height: float | None = None + self._playlist_select_context = playlist_select_context self._ensure_standard_playlists_exist() @@ -62,104 +53,143 @@ class PlaylistBrowserWindow(bui.Window): ) uiscale = bui.app.ui_v1.uiscale - self._width = 1100.0 if uiscale is bui.UIScale.SMALL else 800.0 - x_inset = 150 if uiscale is bui.UIScale.SMALL else 0 - self._height = ( - 480 + self._width = ( + 1100.0 if uiscale is bui.UIScale.SMALL - else 510 if uiscale is bui.UIScale.MEDIUM else 580 + else 800.0 if uiscale is bui.UIScale.MEDIUM else 1040 + ) + self._height = ( + 600 + if uiscale is bui.UIScale.SMALL + else 550 if uiscale is bui.UIScale.MEDIUM else 700 ) - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.85 + if uiscale is bui.UIScale.SMALL + else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 100, screensize[0] / scale) + target_height = min(self._height - 100, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 31 + scroll_bottom = yoffs - 60 - self._scroll_height super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - toolbar_visibility='menu_full', - scale_origin_stack_offset=scale_origin, - scale=( - 1.69 - if uiscale is bui.UIScale.SMALL - else 1.05 if uiscale is bui.UIScale.MEDIUM else 0.9 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' + if ( + uiscale is bui.UIScale.SMALL + or playlist_select_context is not None + ) + else 'menu_full' ), - stack_offset=( - (0, -26) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button: bui.Widget | None = bui.buttonwidget( + self._back_button: bui.Widget | None + if uiscale is bui.UIScale.SMALL: + self._back_button = None + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self._on_back_press + ) + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(59, yoffs - 45), + size=(60, 54), + scale=1.0, + on_activate_call=self._on_back_press, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + + self._title_text = bui.textwidget( parent=self._root_widget, - position=(59 + x_inset, self._height - 70), - size=(120, 60), - scale=1.0, - on_activate_call=self._on_back_press, - autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._back_button - ) - txt = self._title_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 41), + position=( + self._width * 0.5, + yoffs - (45 if uiscale is bui.UIScale.SMALL else 20), + ), size=(0, 0), text=self._pvars.window_title_name, - scale=1.3, + scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.3), res_scale=1.5, color=bui.app.ui_v1.heading_color, h_align='center', v_align='center', ) - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: - bui.textwidget(edit=txt, text='') - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 54), - position=(59 + x_inset, self._height - 67), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: - self._back_button.delete() - self._back_button = None - bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._on_back_press - ) - scroll_offs = 33 - else: - scroll_offs = 0 - self._scroll_width = self._width - (100 + 2 * x_inset) - self._scroll_height = self._height - ( - 146 - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars - else 136 - ) self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, size=(self._scroll_width, self._scroll_height), position=( - (self._width - self._scroll_width) * 0.5, - 65 + scroll_offs, + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, ), + border_opacity=0.4, + center_small_content_horizontally=True, ) bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) self._subcontainer: bui.Widget | None = None self._config_name_full = self._pvars.config_name + ' Playlists' self._last_config = None - # Update now and once per second. - # (this should do our initial refresh) + # Update now and once per second (this should do our initial + # refresh). self._update() self._update_timer = bui.AppTimer( 1.0, bui.WeakCall(self._update), repeat=True ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull things out of self here; if we do it below in the lambda + # then we keep self alive. + sessiontype = self._sessiontype + + # Pull anything out of self here; if we do it in the lambda + # we'll inadvertanly keep self alive. + playlist_select_context = self._playlist_select_context + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + sessiontype=sessiontype, + playlist_select_context=playlist_select_context, + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _ensure_standard_playlists_exist(self) -> None: plus = bui.app.plus assert plus is not None @@ -348,15 +378,22 @@ class PlaylistBrowserWindow(bui.Window): items.sort(key=lambda x2: asserttype(x2[0], str).lower()) items = [['__default__', None]] + items # default is always first - count = len(items) - columns = 3 - rows = int(math.ceil(float(count) / columns)) button_width = 230 button_height = 230 button_buffer_h = -3 button_buffer_v = 0 - self._sub_width = self._scroll_width + count = len(items) + columns = max( + 1, + math.floor( + self._scroll_width / (button_width + 2 * button_buffer_h) + ), + ) + rows = int(math.ceil(float(count) / columns)) + + self._sub_width = columns * button_width + 2 * button_buffer_h + self._sub_height = ( 40.0 + rows * (button_height + 2 * button_buffer_v) + 90 ) @@ -372,11 +409,20 @@ class PlaylistBrowserWindow(bui.Window): for child in children: child.delete() + # On small ui-scale, nudge 'Playlists' text to the right when + # we're small enough so that the back button doesn't partly + # obscure it. + uiscale = bui.app.ui_v1.uiscale + screensize = bui.get_virtual_screen_size() + xoffs = ( + 40 if uiscale is bui.UIScale.SMALL and screensize[0] < 1400 else 0 + ) + assert bui.app.classic is not None bui.textwidget( parent=self._subcontainer, text=bui.Lstr(resource='playlistsText'), - position=(40, self._sub_height - 26), + position=(40 + xoffs, self._sub_height - 26), size=(0, 0), scale=1.0, maxwidth=400, @@ -392,7 +438,8 @@ class PlaylistBrowserWindow(bui.Window): mesh_transparent = bui.getmesh('level_select_button_transparent') mask_tex = bui.gettexture('mapPreviewMask') - h_offs = 225 if count == 1 else 115 if count == 2 else 0 + # h_offs = 225 if count == 1 else 115 if count == 2 else 0 + h_offs = 2 h_offs_bottom = 0 uiscale = bui.app.ui_v1.uiscale @@ -418,23 +465,15 @@ class PlaylistBrowserWindow(bui.Window): position=pos, ) - if ( - x == 0 - and bui.app.ui_v1.use_toolbars - and uiscale is bui.UIScale.SMALL - ): + if x == 0 and uiscale is bui.UIScale.SMALL: bui.widget( edit=btn, left_widget=bui.get_special_widget('back_button'), ) - if ( - x == columns - 1 - and bui.app.ui_v1.use_toolbars - and uiscale is bui.UIScale.SMALL - ): + if x == columns - 1 and uiscale is bui.UIScale.SMALL: bui.widget( edit=btn, - right_widget=bui.get_special_widget('party_button'), + right_widget=bui.get_special_widget('squad_button'), ) bui.buttonwidget( edit=btn, @@ -443,7 +482,16 @@ class PlaylistBrowserWindow(bui.Window): ), on_select_call=bui.Call(self._on_playlist_select, name), ) - bui.widget(edit=btn, show_buffer_top=50, show_buffer_bottom=50) + + # Top row biases things up more to show header above it. + if y == 0: + bui.widget( + edit=btn, show_buffer_top=60, show_buffer_bottom=5 + ) + else: + bui.widget( + edit=btn, show_buffer_top=30, show_buffer_bottom=30 + ) if self._selected_playlist == name: bui.containerwidget( @@ -635,15 +683,35 @@ class PlaylistBrowserWindow(bui.Window): def on_play_options_window_run_game(self) -> None: """(internal)""" - if not self._root_widget: + + # No-op if we're not in control. + if not self.main_window_has_control(): + # if not self._root_widget: return - bui.containerwidget(edit=self._root_widget, transition='out_left') + + if self._playlist_select_context is not None: + # Done doing a playlist selection; now back all the way out + # of our selection windows to our stored starting point. + if self._playlist_select_context.back_state is None: + logging.error( + 'No back state found' + ' after playlist select context completion.' + ) + else: + self.main_window_back_state = ( + self._playlist_select_context.back_state + ) + self.main_window_back() + else: + # Launching a regular game session; simply get our window + # transitioning out. + self.main_window_close(transition='out_left') def _on_playlist_select(self, playlist_name: str) -> None: self._selected_playlist = playlist_name def _update(self) -> None: - # make sure config exists + # Make sure config exists. if self._config_name_full not in bui.app.config: bui.app.config[self._config_name_full] = {} @@ -672,6 +740,7 @@ class PlaylistBrowserWindow(bui.Window): scale_origin=button.get_screen_space_center(), playlist=playlist_name, delegate=self, + playlist_select_context=self._playlist_select_context, ) def _on_customize_press(self) -> None: @@ -680,27 +749,23 @@ class PlaylistBrowserWindow(bui.Window): PlaylistCustomizeBrowserWindow, ) - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + self.main_window_replace( PlaylistCustomizeBrowserWindow( origin_widget=self._customize_button, sessiontype=self._sessiontype, - ).get_root_widget(), - from_window=self._root_widget, + ) ) def _on_back_press(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.play import PlayWindow + # from bauiv1lib.play import PlayWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return # Store our selected playlist if that's changed. @@ -715,15 +780,7 @@ class PlaylistBrowserWindow(bui.Window): ) cfg.commit() - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlayWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() def _save_state(self) -> None: try: diff --git a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py index 789e53f..5a76d5f 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py +++ b/dist/ba_data/python/bauiv1lib/playlist/customizebrowser.py @@ -6,86 +6,115 @@ from __future__ import annotations import copy import time -import logging -from typing import TYPE_CHECKING -import bascenev1 as bs +from typing import TYPE_CHECKING, override + import bauiv1 as bui if TYPE_CHECKING: - from typing import Any + from typing import Any, Callable + + import bascenev1 as bs + +REQUIRE_PRO = False -class PlaylistCustomizeBrowserWindow(bui.Window): +class PlaylistCustomizeBrowserWindow(bui.MainWindow): """Window for viewing a playlist.""" def __init__( self, sessiontype: type[bs.Session], - transition: str = 'in_right', - select_playlist: str | None = None, + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + select_playlist: str | None = None, ): - # Yes this needs tidying. # pylint: disable=too-many-locals # pylint: disable=too-many-statements # pylint: disable=cyclic-import from bauiv1lib import playlist - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self._sessiontype = sessiontype self._pvars = playlist.PlaylistTypeVars(sessiontype) self._max_playlists = 30 self._r = 'gameListWindow' assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 850.0 if uiscale is bui.UIScale.SMALL else 650.0 - x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 + self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 650.0 self._height = ( - 380.0 + 800.0 if uiscale is bui.UIScale.SMALL else 420.0 if uiscale is bui.UIScale.MEDIUM else 500.0 ) - top_extra = 20.0 if uiscale is bui.UIScale.SMALL else 0.0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.8 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 70, screensize[0] / scale) + target_height = min(self._height - 40, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = ( + 0.5 * self._height + + 0.5 * target_height + + (30.0 if uiscale is bui.UIScale.SMALL else 50) + ) + + self._button_width = 90 + self._x_inset = 10 + self._scroll_width = ( + target_width - self._button_width - 2.0 * self._x_inset + ) + self._scroll_height = target_height - 75 + self._scroll_bottom = yoffs - 98 - self._scroll_height + self._button_height = self._scroll_height / 6.0 super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - scale_origin_stack_offset=scale_origin, - scale=( - 2.05 + size=(self._width, self._height), + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -10) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button = back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(43 + x_inset, self._height - 60), - size=(160, 68), - scale=0.77, - autoselect=True, - text_scale=1.3, - label=bui.Lstr(resource='backText'), - button_type='back', - ) + self._back_button: bui.Widget | None + if uiscale is bui.UIScale.SMALL: + self._back_button = None + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(43, yoffs - 87), + size=(60, 60), + scale=0.77, + autoselect=True, + text_scale=1.3, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + ) bui.textwidget( parent=self._root_widget, - position=(0, self._height - 47), + position=(0, yoffs - (77 if uiscale is bui.UIScale.SMALL else 77)), size=(self._width, 25), text=bui.Lstr( resource=f'{self._r}.titleText', @@ -97,171 +126,92 @@ class PlaylistCustomizeBrowserWindow(bui.Window): v_align='center', ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - v = self._height - 59.0 - h = 41 + x_inset + h = self._width * 0.5 - (self._scroll_width + self._button_width) * 0.5 b_color = (0.6, 0.53, 0.63) b_textcolor = (0.75, 0.7, 0.8) self._lock_images: list[bui.Widget] = [] - lock_tex = bui.gettexture('lock') + xmargin = 0.06 + ymargin = 0.05 - scl = ( - 1.1 - if uiscale is bui.UIScale.SMALL - else 1.27 if uiscale is bui.UIScale.MEDIUM else 1.57 - ) - scl *= 0.63 - v -= 65.0 * scl - new_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._new_playlist, - color=b_color, - autoselect=True, - button_type='square', - textcolor=b_textcolor, - text_scale=0.7, - label=bui.Lstr( + def _make_button( + i: int, label: bui.Lstr, call: Callable[[], None] + ) -> bui.Widget: + v = self._scroll_bottom + self._button_height * i + return bui.buttonwidget( + parent=self._root_widget, + position=( + h + xmargin * self._button_width, + v + ymargin * self._button_height, + ), + size=( + self._button_width * (1.0 - 2.0 * xmargin), + self._button_height * (1.0 - 2.0 * ymargin), + ), + on_activate_call=call, + color=b_color, + autoselect=True, + button_type='square', + textcolor=b_textcolor, + text_scale=0.7, + label=label, + ) + + new_button = _make_button( + 5, + bui.Lstr( resource='newText', fallback_resource=f'{self._r}.newText' ), + self._new_playlist, ) - self._lock_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(30, 30), - draw_controller=btn, - position=(h - 10, v + 58.0 * scl - 28), - texture=lock_tex, - ) - ) - - v -= 65.0 * scl - self._edit_button = edit_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._edit_playlist, - color=b_color, - autoselect=True, - textcolor=b_textcolor, - button_type='square', - text_scale=0.7, - label=bui.Lstr( - resource='editText', fallback_resource=f'{self._r}.editText' + self._edit_button = _make_button( + 4, + bui.Lstr( + resource='editText', + fallback_resource=f'{self._r}.editText', ), - ) - self._lock_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(30, 30), - draw_controller=btn, - position=(h - 10, v + 58.0 * scl - 28), - texture=lock_tex, - ) + self._edit_playlist, ) - v -= 65.0 * scl - duplicate_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._duplicate_playlist, - color=b_color, - autoselect=True, - textcolor=b_textcolor, - button_type='square', - text_scale=0.7, - label=bui.Lstr( + duplicate_button = _make_button( + 3, + bui.Lstr( resource='duplicateText', fallback_resource=f'{self._r}.duplicateText', ), - ) - self._lock_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(30, 30), - draw_controller=btn, - position=(h - 10, v + 58.0 * scl - 28), - texture=lock_tex, - ) + self._duplicate_playlist, ) - v -= 65.0 * scl - delete_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._delete_playlist, - color=b_color, - autoselect=True, - textcolor=b_textcolor, - button_type='square', - text_scale=0.7, - label=bui.Lstr( + delete_button = _make_button( + 2, + bui.Lstr( resource='deleteText', fallback_resource=f'{self._r}.deleteText' ), - ) - self._lock_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(30, 30), - draw_controller=btn, - position=(h - 10, v + 58.0 * scl - 28), - texture=lock_tex, - ) - ) - v -= 65.0 * scl - self._import_button = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._import_playlist, - color=b_color, - autoselect=True, - textcolor=b_textcolor, - button_type='square', - text_scale=0.7, - label=bui.Lstr(resource='importText'), - ) - v -= 65.0 * scl - btn = bui.buttonwidget( - parent=self._root_widget, - position=(h, v), - size=(90, 58.0 * scl), - on_activate_call=self._share_playlist, - color=b_color, - autoselect=True, - textcolor=b_textcolor, - button_type='square', - text_scale=0.7, - label=bui.Lstr(resource='shareText'), - ) - self._lock_images.append( - bui.imagewidget( - parent=self._root_widget, - size=(30, 30), - draw_controller=btn, - position=(h - 10, v + 58.0 * scl - 28), - texture=lock_tex, - ) + self._delete_playlist, + ) + + self._import_button = _make_button( + 1, bui.Lstr(resource='importText'), self._import_playlist + ) + + share_button = _make_button( + 0, bui.Lstr(resource='shareText'), self._share_playlist ) - v = self._height - 75 - self._scroll_height = self._height - 119 scrollwidget = bui.scrollwidget( parent=self._root_widget, - position=(140 + x_inset, v - self._scroll_height), - size=(self._width - (180 + 2 * x_inset), self._scroll_height + 10), + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 + - (self._scroll_width + self._button_width) * 0.5 + + self._button_width, + self._scroll_bottom, + ), highlight=False, + border_opacity=0.4, ) - bui.widget(edit=back_button, right_widget=scrollwidget) + if self._back_button is not None: + bui.widget(edit=self._back_button, right_widget=scrollwidget) + self._columnwidget = bui.columnwidget( parent=scrollwidget, border=2, margin=0 ) @@ -274,20 +224,23 @@ class PlaylistCustomizeBrowserWindow(bui.Window): h += 210 - for btn in [new_button, delete_button, edit_button, duplicate_button]: + for btn in [ + new_button, + delete_button, + self._edit_button, + duplicate_button, + self._import_button, + share_button, + ]: bui.widget(edit=btn, right_widget=scrollwidget) bui.widget( edit=scrollwidget, left_widget=new_button, - right_widget=( - bui.get_special_widget('party_button') - if bui.app.ui_v1.use_toolbars - else None - ), + right_widget=bui.get_special_widget('squad_button'), ) - # make sure config exists - self._config_name_full = self._pvars.config_name + ' Playlists' + # Make sure config exists. + self._config_name_full = f'{self._pvars.config_name} Playlists' if self._config_name_full not in bui.app.config: bui.app.config[self._config_name_full] = {} @@ -298,8 +251,13 @@ class PlaylistCustomizeBrowserWindow(bui.Window): self._refresh(select_playlist=select_playlist) - bui.buttonwidget(edit=back_button, on_activate_call=self._back) - bui.containerwidget(edit=self._root_widget, cancel_button=back_button) + if self._back_button is not None: + bui.buttonwidget( + edit=self._back_button, on_activate_call=self.main_window_back + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) bui.containerwidget(edit=self._root_widget, selected_child=scrollwidget) @@ -309,63 +267,44 @@ class PlaylistCustomizeBrowserWindow(bui.Window): ) self._update() - def _update(self) -> None: - assert bui.app.classic is not None - have = bui.app.classic.accounts.have_pro_options() - for lock in self._lock_images: - bui.imagewidget(edit=lock, opacity=0.0 if have else 1.0) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.playlist import browser + # Avoid dereferencing self within the lambda or we'll keep + # ourself alive indefinitely. + stype = self._sessiontype - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + sessiontype=stype, + ) + ) + @override + def on_main_window_close(self) -> None: if self._selected_playlist_name is not None: cfg = bui.app.config - cfg[self._pvars.config_name + ' Playlist Selection'] = ( + cfg[f'{self._pvars.config_name} Playlist Selection'] = ( self._selected_playlist_name ) cfg.commit() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) + def _update(self) -> None: assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - browser.PlaylistBrowserWindow( - transition='in_left', sessiontype=self._sessiontype - ).get_root_widget(), - from_window=self._root_widget, - ) + have = bui.app.classic.accounts.have_pro_options() + for lock in self._lock_images: + bui.imagewidget( + edit=lock, opacity=0.0 if (have or not REQUIRE_PRO) else 1.0 + ) def _select(self, name: str, index: int) -> None: self._selected_playlist_name = name self._selected_playlist_index = index - def _run_selected_playlist(self) -> None: - # pylint: disable=cyclic-import - bui.unlock_all_input() - try: - bs.new_host_session(self._sessiontype) - except Exception: - from bascenev1lib import mainmenu - - logging.exception('Error running session %s.', self._sessiontype) - - # Drop back into a main menu session. - bs.new_host_session(mainmenu.MainMenuSession) - - def _choose_playlist(self) -> None: - if self._selected_playlist_name is None: - return - self._save_playlist_selection() - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.fade_screen(False, endcall=self._run_selected_playlist) - bui.lock_all_input() - def _refresh(self, select_playlist: str | None = None) -> None: from efro.util import asserttype @@ -416,9 +355,16 @@ class PlaylistCustomizeBrowserWindow(bui.Window): ) bui.widget(edit=txtw, show_buffer_top=50, show_buffer_bottom=50) - # Hitting up from top widget should jump to 'back' + # Hitting up from top widget should jump to 'back'. if index == 0: - bui.widget(edit=txtw, up_widget=self._back_button) + bui.widget( + edit=txtw, + up_widget=( + self._back_button + if self._back_button is not None + else bui.get_special_widget('back_button') + ), + ) self._playlist_widgets.append(txtw) @@ -431,8 +377,8 @@ class PlaylistCustomizeBrowserWindow(bui.Window): visible_child=txtw, ) else: - # Select this one if it was previously selected. - # Go by index if there's one. + # Select this one if it was previously selected. Go by + # index if there's one. if old_selection_index is not None: if index == old_selection_index: bui.columnwidget( @@ -451,10 +397,10 @@ class PlaylistCustomizeBrowserWindow(bui.Window): index += 1 def _save_playlist_selection(self) -> None: - # Store the selected playlist in prefs. - # This serves dual purposes of letting us re-select it next time - # if we want and also lets us pass it to the game (since we reset - # the whole python environment that's not actually easy). + # Store the selected playlist in prefs. This serves dual + # purposes of letting us re-select it next time if we want and + # also lets us pass it to the game (since we reset the whole + # python environment that's not actually easy). cfg = bui.app.config cfg[self._pvars.config_name + ' Playlist Selection'] = ( self._selected_playlist_name @@ -469,8 +415,12 @@ class PlaylistCustomizeBrowserWindow(bui.Window): from bauiv1lib.playlist.editcontroller import PlaylistEditController from bauiv1lib.purchase import PurchaseWindow + # No-op if we're not in control. + if not self.main_window_has_control(): + return + assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro_options(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): PurchaseWindow(items=['pro']) return @@ -492,8 +442,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): self._save_playlist_selection() # Kick off the edit UI. - PlaylistEditController(sessiontype=self._sessiontype) - bui.containerwidget(edit=self._root_widget, transition='out_left') + PlaylistEditController(sessiontype=self._sessiontype, from_window=self) def _edit_playlist(self) -> None: # pylint: disable=cyclic-import @@ -501,7 +450,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): from bauiv1lib.purchase import PurchaseWindow assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro_options(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): PurchaseWindow(items=['pro']) return if self._selected_playlist_name is None: @@ -516,8 +465,8 @@ class PlaylistCustomizeBrowserWindow(bui.Window): PlaylistEditController( existing_playlist_name=self._selected_playlist_name, sessiontype=self._sessiontype, + from_window=self, ) - bui.containerwidget(edit=self._root_widget, transition='out_left') def _do_delete_playlist(self) -> None: plus = bui.app.plus @@ -584,7 +533,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): assert plus is not None assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro_options(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): PurchaseWindow(items=['pro']) return @@ -626,7 +575,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): from bauiv1lib.confirm import ConfirmWindow assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro_options(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): PurchaseWindow(items=['pro']) return @@ -666,7 +615,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): assert plus is not None assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro_options(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro_options(): PurchaseWindow(items=['pro']) return if self._selected_playlist_name is None: @@ -682,7 +631,7 @@ class PlaylistCustomizeBrowserWindow(bui.Window): bui.getsound('error').play() return - # clamp at our max playlist number + # Clamp at our max playlist number. if len(bui.app.config[self._config_name_full]) > self._max_playlists: bui.screenmessage( bui.Lstr( @@ -697,10 +646,11 @@ class PlaylistCustomizeBrowserWindow(bui.Window): return copy_text = bui.Lstr(resource='copyOfText').evaluate() - # get just 'Copy' or whatnot - copy_word = copy_text.replace('${NAME}', '').strip() - # find a valid dup name that doesn't exist + # Get just 'Copy' or whatnot. + copy_word = copy_text.replace('${NAME}', '').strip() + + # Find a valid dup name that doesn't exist. test_index = 1 base_name = self._get_playlist_display_name( self._selected_playlist_name diff --git a/dist/ba_data/python/bauiv1lib/playlist/edit.py b/dist/ba_data/python/bauiv1lib/playlist/edit.py index 3a3fcca..afb26ac 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/edit.py +++ b/dist/ba_data/python/bauiv1lib/playlist/edit.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override import bascenev1 as bs import bauiv1 as bui @@ -14,13 +14,14 @@ if TYPE_CHECKING: from bauiv1lib.playlist.editcontroller import PlaylistEditController -class PlaylistEditWindow(bui.Window): +class PlaylistEditWindow(bui.MainWindow): """Window for editing an individual game playlist.""" def __init__( self, editcontroller: PlaylistEditController, - transition: str = 'in_right', + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals @@ -34,29 +35,30 @@ class PlaylistEditWindow(bui.Window): self._width = 870 if uiscale is bui.UIScale.SMALL else 670 x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 self._height = ( - 400 + 500 if uiscale is bui.UIScale.SMALL else 470 if uiscale is bui.UIScale.MEDIUM else 540 ) + yoffs = -68 if uiscale is bui.UIScale.SMALL else 0 - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, + size=(self._width, self._height), scale=( 2.0 if uiscale is bui.UIScale.SMALL else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( - (0, -16) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) cancel_button = bui.buttonwidget( parent=self._root_widget, - position=(35 + x_inset, self._height - 60), + position=(35 + x_inset, self._height - 60 + yoffs), scale=0.8, size=(175, 60), autoselect=True, @@ -65,7 +67,7 @@ class PlaylistEditWindow(bui.Window): ) save_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(self._width - (195 + x_inset), self._height - 60), + position=(self._width - (195 + x_inset), self._height - 60 + yoffs), scale=0.8, size=(190, 60), autoselect=True, @@ -74,11 +76,10 @@ class PlaylistEditWindow(bui.Window): text_scale=1.2, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, + right_widget=bui.get_special_widget('squad_button'), + ) bui.widget( edit=cancel_button, @@ -88,7 +89,7 @@ class PlaylistEditWindow(bui.Window): bui.textwidget( parent=self._root_widget, - position=(-10, self._height - 50), + position=(-10, self._height - 50 + yoffs), size=(self._width, 25), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, @@ -98,7 +99,7 @@ class PlaylistEditWindow(bui.Window): maxwidth=270, ) - v = self._height - 115.0 + v = self._height - 115.0 + yoffs self._scroll_width = self._width - (205 + 2 * x_inset) @@ -135,7 +136,7 @@ class PlaylistEditWindow(bui.Window): self._list_widgets: list[bui.Widget] = [] h = 40 + x_inset - v = self._height - 172.0 + v = self._height - 172.0 + yoffs b_color = (0.6, 0.53, 0.63) b_textcolor = (0.75, 0.7, 0.8) @@ -221,14 +222,17 @@ class PlaylistEditWindow(bui.Window): repeat=True, ) - v = self._height - 100 - scroll_height = self._height - 155 + v = self._height - 100 + yoffs + scroll_height = self._height - ( + 250 if uiscale is bui.UIScale.SMALL else 155 + ) scrollwidget = bui.scrollwidget( parent=self._root_widget, position=(160 + x_inset, v - scroll_height), highlight=False, on_select_call=bui.Call(self._set_ui_selection, 'gameList'), size=(self._scroll_width, (scroll_height - 15)), + border_opacity=0.4, ) bui.widget( edit=scrollwidget, @@ -270,50 +274,52 @@ class PlaylistEditWindow(bui.Window): edit=self._root_widget, selected_child=scrollwidget ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + editcontroller = self._editcontroller + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + editcontroller=editcontroller, + ) + ) + def _set_ui_selection(self, selection: str) -> None: self._editcontroller.set_edit_ui_selection(selection) def _cancel(self) -> None: - from bauiv1lib.playlist.customizebrowser import ( - PlaylistCustomizeBrowserWindow, - ) # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: return bui.getsound('powerdown01').play() - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlaylistCustomizeBrowserWindow( - transition='in_left', - sessiontype=self._editcontroller.get_session_type(), - select_playlist=( - self._editcontroller.get_existing_playlist_name() - ), - ).get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() def _add(self) -> None: # Store list name then tell the session to perform an add. self._editcontroller.setname( cast(str, bui.textwidget(query=self._text_field)) ) - self._editcontroller.add_game_pressed() + self._editcontroller.add_game_pressed(from_window=self) def _edit(self) -> None: # Store list name then tell the session to perform an add. self._editcontroller.setname( cast(str, bui.textwidget(query=self._text_field)) ) - self._editcontroller.edit_game_pressed() + self._editcontroller.edit_game_pressed(from_window=self) def _save_press(self) -> None: - from bauiv1lib.playlist.customizebrowser import ( - PlaylistCustomizeBrowserWindow, - ) + + # No-op if we're not in control. + if not self.main_window_has_control(): + return # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -376,17 +382,9 @@ class PlaylistEditWindow(bui.Window): ) plus.run_v1_account_transactions() - bui.containerwidget(edit=self._root_widget, transition='out_right') bui.getsound('gunCocking').play() - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlaylistCustomizeBrowserWindow( - transition='in_left', - sessiontype=self._editcontroller.get_session_type(), - select_playlist=new_name, - ).get_root_widget(), - from_window=self._root_widget, - ) + + self.main_window_back() def _save_press_with_sound(self) -> None: bui.getsound('swish').play() diff --git a/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py b/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py index 7ed9a92..7126022 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py +++ b/dist/ba_data/python/bauiv1lib/playlist/editcontroller.py @@ -11,7 +11,7 @@ import bascenev1 as bs import bauiv1 as bui if TYPE_CHECKING: - from typing import Any + from typing import Any, Callable class PlaylistEditController: @@ -20,8 +20,9 @@ class PlaylistEditController: def __init__( self, sessiontype: type[bs.Session], + from_window: bui.MainWindow, + *, existing_playlist_name: str | None = None, - transition: str = 'in_right', playlist: list[dict[str, Any]] | None = None, playlist_name: str | None = None, ): @@ -44,6 +45,9 @@ class PlaylistEditController: self._existing_playlist_name = existing_playlist_name self._config_name_full = self._pvars.config_name + ' Playlists' + self._pre_game_add_state: bui.MainWindowState | None = None + self._pre_game_edit_state: bui.MainWindowState | None = None + # Make sure config exists. if self._config_name_full not in appconfig: appconfig[self._config_name_full] = {} @@ -88,13 +92,12 @@ class PlaylistEditController: # and that's all they can do. self._edit_ui_selection = 'add_button' - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlaylistEditWindow( - editcontroller=self, transition=transition - ).get_root_widget(), - from_window=False, # Disable this check. - ) + editwindow = PlaylistEditWindow(editcontroller=self) + from_window.main_window_replace(editwindow) + + # Once we've set our start window, store the back state. We'll + # skip back to there once we're fully done. + self._back_state = editwindow.main_window_back_state def get_config_name(self) -> str: """(internal)""" @@ -144,83 +147,88 @@ class PlaylistEditController: """Sets the selected playlist index.""" self._selected_index = index - def add_game_pressed(self) -> None: + def add_game_pressed(self, from_window: bui.MainWindow) -> None: """(internal)""" from bauiv1lib.playlist.addgame import PlaylistAddGameWindow - assert bui.app.classic is not None - bui.app.ui_v1.clear_main_menu_window(transition='out_left') - bui.app.ui_v1.set_main_menu_window( - PlaylistAddGameWindow(editcontroller=self).get_root_widget(), - from_window=None, - ) + # assert bui.app.classic is not None - def edit_game_pressed(self) -> None: + # No op if we're not in control. + if not from_window.main_window_has_control(): + return + + addwindow = PlaylistAddGameWindow(editcontroller=self) + from_window.main_window_replace(addwindow) + + # Once we're there, store the back state. We'll use that to jump + # back to our current location once the edit is done. + assert self._pre_game_add_state is None + self._pre_game_add_state = addwindow.main_window_back_state + + def edit_game_pressed(self, from_window: bui.MainWindow) -> None: """Should be called by supplemental UIs when a game is to be edited.""" if not self._playlist: return + self._show_edit_ui( gametype=bui.getclass( self._playlist[self._selected_index]['type'], subclassof=bs.GameActivity, ), settings=self._playlist[self._selected_index], - ) - - def add_game_cancelled(self) -> None: - """(internal)""" - from bauiv1lib.playlist.edit import PlaylistEditWindow - - assert bui.app.classic is not None - bui.app.ui_v1.clear_main_menu_window(transition='out_right') - bui.app.ui_v1.set_main_menu_window( - PlaylistEditWindow( - editcontroller=self, transition='in_left' - ).get_root_widget(), - from_window=None, + from_window=from_window, ) def _show_edit_ui( - self, gametype: type[bs.GameActivity], settings: dict[str, Any] | None + self, + gametype: type[bs.GameActivity], + settings: dict[str, Any] | None, + from_window: bui.MainWindow, ) -> None: + # pylint: disable=cyclic-import + from bauiv1lib.playlist.editgame import PlaylistEditGameWindow + + if not from_window.main_window_has_control(): + return + self._editing_game = settings is not None self._editing_game_type = gametype assert self._sessiontype is not None - gametype.create_settings_ui( - self._sessiontype, copy.deepcopy(settings), self._edit_game_done + + # Jump into an edit window. + editwindow = PlaylistEditGameWindow( + gametype, + self._sessiontype, + copy.deepcopy(settings), + completion_call=self._edit_game_done, + ) + from_window.main_window_replace(editwindow) + + # Once we're there, store the back state. We'll use that to jump + # back to our current location once the edit is done. + assert self._pre_game_edit_state is None + self._pre_game_edit_state = editwindow.main_window_back_state + + def add_game_type_selected( + self, gametype: type[bs.GameActivity], from_window: bui.MainWindow + ) -> None: + """(internal)""" + self._show_edit_ui( + gametype=gametype, settings=None, from_window=from_window ) - def add_game_type_selected(self, gametype: type[bs.GameActivity]) -> None: - """(internal)""" - self._show_edit_ui(gametype=gametype, settings=None) + def _edit_game_done( + self, config: dict[str, Any] | None, from_window: bui.MainWindow + ) -> None: - def _edit_game_done(self, config: dict[str, Any] | None) -> None: - from bauiv1lib.playlist.edit import PlaylistEditWindow - from bauiv1lib.playlist.addgame import PlaylistAddGameWindow + # No-op if provided window isn't in charge. + if not from_window.main_window_has_control(): + return assert bui.app.classic is not None if config is None: - # If we were editing, go back to our list. - if self._editing_game: - bui.getsound('powerdown01').play() - bui.app.ui_v1.clear_main_menu_window(transition='out_right') - bui.app.ui_v1.set_main_menu_window( - PlaylistEditWindow( - editcontroller=self, transition='in_left' - ).get_root_widget(), - from_window=None, - ) - - # Otherwise we were adding; go back to the add type choice list. - else: - bui.app.ui_v1.clear_main_menu_window(transition='out_right') - bui.app.ui_v1.set_main_menu_window( - PlaylistAddGameWindow( - editcontroller=self, transition='in_left' - ).get_root_widget(), - from_window=None, - ) + bui.getsound('powerdown01').play() else: # Make sure type is in there. assert self._editing_game_type is not None @@ -237,10 +245,17 @@ class PlaylistEditController: self._selected_index = insert_index bui.getsound('gunCocking').play() - bui.app.ui_v1.clear_main_menu_window(transition='out_right') - bui.app.ui_v1.set_main_menu_window( - PlaylistEditWindow( - editcontroller=self, transition='in_left' - ).get_root_widget(), - from_window=None, - ) + + # If we're adding, jump to before the add started. + # Otherwise jump to before the edit started. + assert ( + self._pre_game_edit_state is not None + or self._pre_game_add_state is not None + ) + if self._pre_game_add_state is not None: + from_window.main_window_back_state = self._pre_game_add_state + elif self._pre_game_edit_state is not None: + from_window.main_window_back_state = self._pre_game_edit_state + from_window.main_window_back() + self._pre_game_edit_state = None + self._pre_game_add_state = None diff --git a/dist/ba_data/python/bauiv1lib/playlist/editgame.py b/dist/ba_data/python/bauiv1lib/playlist/editgame.py index 993aac3..9fac932 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/editgame.py +++ b/dist/ba_data/python/bauiv1lib/playlist/editgame.py @@ -7,7 +7,7 @@ from __future__ import annotations import copy import random import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override import bascenev1 as bs import bauiv1 as bui @@ -16,7 +16,7 @@ if TYPE_CHECKING: from typing import Any, Callable -class PlaylistEditGameWindow(bui.Window): +class PlaylistEditGameWindow(bui.MainWindow): """Window for editing a game config.""" def __init__( @@ -24,12 +24,14 @@ class PlaylistEditGameWindow(bui.Window): gametype: type[bs.GameActivity], sessiontype: type[bs.Session], config: dict[str, Any] | None, - completion_call: Callable[[dict[str, Any] | None], Any], + completion_call: Callable[[dict[str, Any] | None, bui.MainWindow], Any], default_selection: str | None = None, - transition: str = 'in_right', + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, edit_info: dict[str, Any] | None = None, ): # pylint: disable=too-many-branches + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-statements # pylint: disable=too-many-locals from bascenev1 import ( @@ -64,6 +66,7 @@ class PlaylistEditGameWindow(bui.Window): bui.screenmessage(bui.Lstr(resource='noValidMapsErrorText')) raise RuntimeError('No valid maps found.') + self._config = config self._settings_defs = gametype.get_available_settings(sessiontype) self._completion_call = completion_call @@ -106,13 +109,14 @@ class PlaylistEditGameWindow(bui.Window): width = 820 if uiscale is bui.UIScale.SMALL else 620 x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 height = ( - 365 + 400 if uiscale is bui.UIScale.SMALL else 460 if uiscale is bui.UIScale.MEDIUM else 550 ) spacing = 52 y_extra = 15 y_extra2 = 21 + yoffs = -30 if uiscale is bui.UIScale.SMALL else 0 map_tex_name = get_map_class(self._map).get_preview_texture_name() if map_tex_name is None: @@ -123,30 +127,31 @@ class PlaylistEditGameWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(width, height + top_extra), - transition=transition, scale=( - 2.19 + 2.3 if uiscale is bui.UIScale.SMALL else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( - (0, -17) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) btn = bui.buttonwidget( parent=self._root_widget, - position=(45 + x_inset, height - 82 + y_extra2), - size=(180, 70) if is_add else (180, 65), + position=(45 + x_inset, height - 82 + y_extra2 + yoffs), + size=(60, 48) if is_add else (180, 65), label=( - bui.Lstr(resource='backText') + bui.charstr(bui.SpecialChar.BACK) if is_add else bui.Lstr(resource='cancelText') ), - button_type='back' if is_add else None, + button_type='backSmall' if is_add else None, autoselect=True, - scale=0.75, + scale=1.0 if is_add else 0.75, text_scale=1.3, on_activate_call=bui.Call(self._cancel), ) @@ -154,24 +159,23 @@ class PlaylistEditGameWindow(bui.Window): add_button = bui.buttonwidget( parent=self._root_widget, - position=(width - (193 + x_inset), height - 82 + y_extra2), + position=(width - (193 + x_inset), height - 82 + y_extra2 + yoffs), size=(200, 65), scale=0.75, text_scale=1.3, label=( bui.Lstr(resource=f'{self._r}.addGameText') if is_add - else bui.Lstr(resource='doneText') + else bui.Lstr(resource='applyText') ), ) - if bui.app.ui_v1.use_toolbars: - pbtn = bui.get_special_widget('party_button') - bui.widget(edit=add_button, right_widget=pbtn, up_widget=pbtn) + pbtn = bui.get_special_widget('squad_button') + bui.widget(edit=add_button, right_widget=pbtn, up_widget=pbtn) bui.textwidget( parent=self._root_widget, - position=(-8, height - 70 + y_extra2), + position=(-8, height - 70 + y_extra2 + yoffs), size=(width, 25), text=gametype.get_display_string(), color=bui.app.ui_v1.title_color, @@ -191,19 +195,24 @@ class PlaylistEditGameWindow(bui.Window): scroll_width = width - (86 + 2 * x_inset) self._scrollwidget = bui.scrollwidget( parent=self._root_widget, - position=(44 + x_inset, 35 + y_extra), - size=(scroll_width, height - 116), + position=( + 44 + x_inset, + (80 if uiscale is bui.UIScale.SMALL else 35) + y_extra + yoffs, + ), + size=( + scroll_width, + height - (166 if uiscale is bui.UIScale.SMALL else 116), + ), highlight=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, + border_opacity=0.4, ) self._subcontainer = bui.containerwidget( parent=self._scrollwidget, size=(scroll_width, scroll_height), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) @@ -483,11 +492,11 @@ class PlaylistEditGameWindow(bui.Window): if prev_widgets is not None: # Wire our rightmost to their rightmost. bui.widget(edit=prev_widgets[-1], down_widget=cwdg[-1]) - bui.widget(cwdg[-1], up_widget=prev_widgets[-1]) + bui.widget(edit=cwdg[-1], up_widget=prev_widgets[-1]) # Wire our leftmost to their leftmost. bui.widget(edit=prev_widgets[0], down_widget=cwdg[0]) - bui.widget(cwdg[0], up_widget=prev_widgets[0]) + bui.widget(edit=cwdg[0], up_widget=prev_widgets[0]) prev_widgets = cwdg except Exception: logging.exception( @@ -509,6 +518,29 @@ class PlaylistEditGameWindow(bui.Window): edit=self._subcontainer, selected_child=map_button ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull things out of self here so we don't refer to self in the + # lambda below which would keep us alive. + gametype = self._gametype + sessiontype = self._sessiontype + config = self._config + completion_call = self._completion_call + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + gametype=gametype, + sessiontype=sessiontype, + config=config, + completion_call=completion_call, + ) + ) + def _get_localized_setting_name(self, name: str) -> bui.Lstr: return bui.Lstr(translate=('settingNames', name)) @@ -516,22 +548,21 @@ class PlaylistEditGameWindow(bui.Window): # pylint: disable=cyclic-import from bauiv1lib.playlist.mapselect import PlaylistMapSelectWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # No-op if we're not in control. + if not self.main_window_has_control(): return + self._config = self._getconfig() + # Replace ourself with the map-select UI. - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + self.main_window_replace( PlaylistMapSelectWindow( self._gametype, self._sessiontype, - copy.deepcopy(self._getconfig()), + self._config, self._edit_info, self._completion_call, - ).get_root_widget(), - from_window=self._root_widget, + ) ) def _choice_inc( @@ -561,7 +592,7 @@ class PlaylistEditGameWindow(bui.Window): ][1] def _cancel(self) -> None: - self._completion_call(None) + self._completion_call(None, self) def _check_value_change( self, setting_name: str, widget: bui.Widget, value: int @@ -582,7 +613,7 @@ class PlaylistEditGameWindow(bui.Window): return {'settings': settings} def _add(self) -> None: - self._completion_call(copy.deepcopy(self._getconfig())) + self._completion_call(self._getconfig(), self) def _inc( self, @@ -593,6 +624,7 @@ class PlaylistEditGameWindow(bui.Window): setting_type: type, setting_name: str, ) -> None: + # pylint: disable=too-many-positional-arguments if setting_type == float: val = float(cast(str, bui.textwidget(query=ctrl))) else: diff --git a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py index 8e2cc8e..935accd 100644 --- a/dist/ba_data/python/bauiv1lib/playlist/mapselect.py +++ b/dist/ba_data/python/bauiv1lib/playlist/mapselect.py @@ -5,7 +5,7 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -15,7 +15,7 @@ if TYPE_CHECKING: import bascenev1 as bs -class PlaylistMapSelectWindow(bui.Window): +class PlaylistMapSelectWindow(bui.MainWindow): """Window to select a map.""" def __init__( @@ -24,9 +24,14 @@ class PlaylistMapSelectWindow(bui.Window): sessiontype: type[bs.Session], config: dict[str, Any], edit_info: dict[str, Any], - completion_call: Callable[[dict[str, Any] | None], Any], - transition: str = 'in_right', + completion_call: Callable[[dict[str, Any] | None, bui.MainWindow], Any], + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + select_get_more_maps_button: bool = False, ): + # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments + from bascenev1 import get_filtered_map_name self._gametype = gametype @@ -35,6 +40,7 @@ class PlaylistMapSelectWindow(bui.Window): self._completion_call = completion_call self._edit_info = edit_info self._maps: list[tuple[str, bui.Texture]] = [] + self._selected_get_more_maps = False try: self._previous_map = get_filtered_map_name( config['settings']['map'] @@ -47,42 +53,43 @@ class PlaylistMapSelectWindow(bui.Window): width = 815 if uiscale is bui.UIScale.SMALL else 615 x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 height = ( - 400 + 420 if uiscale is bui.UIScale.SMALL else 480 if uiscale is bui.UIScale.MEDIUM else 600 ) + yoffs = -37 if uiscale is bui.UIScale.SMALL else 0 - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 super().__init__( root_widget=bui.containerwidget( - size=(width, height + top_extra), - transition=transition, + size=(width, height), scale=( - 2.17 + 2.3 if uiscale is bui.UIScale.SMALL else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( - (0, -27) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) self._cancel_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(38 + x_inset, height - 67), + position=(38 + x_inset, height - 67 + yoffs), size=(140, 50), scale=0.9, text_scale=1.0, autoselect=True, label=bui.Lstr(resource='cancelText'), - on_activate_call=self._cancel, + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) bui.textwidget( parent=self._root_widget, - position=(width * 0.5, height - 46), + position=(width * 0.5, height - 46 + yoffs), size=(0, 0), maxwidth=260, scale=1.1, @@ -94,14 +101,17 @@ class PlaylistMapSelectWindow(bui.Window): h_align='center', v_align='center', ) - v = height - 70 + v = height - 70 + yoffs self._scroll_width = width - (80 + 2 * x_inset) - self._scroll_height = height - 140 + self._scroll_height = height - ( + 170 if uiscale is bui.UIScale.SMALL else 140 + ) self._scrollwidget = bui.scrollwidget( parent=self._root_widget, position=(40 + x_inset, v - self._scroll_height), size=(self._scroll_width, self._scroll_height), + border_opacity=0.4, ) bui.containerwidget( edit=self._root_widget, selected_child=self._scrollwidget @@ -109,7 +119,34 @@ class PlaylistMapSelectWindow(bui.Window): bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) self._subcontainer: bui.Widget | None = None - self._refresh() + self._refresh(select_get_more_maps_button=select_get_more_maps_button) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull things out of self here; if we do it in the lambda we'll + # keep ourself alive. + gametype = self._gametype + sessiontype = self._sessiontype + config = self._config + edit_info = self._edit_info + completion_call = self._completion_call + select_get_more_maps = self._selected_get_more_maps + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + gametype=gametype, + sessiontype=sessiontype, + config=config, + edit_info=edit_info, + completion_call=completion_call, + select_get_more_maps_button=select_get_more_maps, + ) + ) def _refresh(self, select_get_more_maps_button: bool = False) -> None: # pylint: disable=too-many-statements @@ -198,10 +235,10 @@ class PlaylistMapSelectWindow(bui.Window): bui.widget(edit=btn, left_widget=self._cancel_button) if y == 0: bui.widget(edit=btn, up_widget=self._cancel_button) - if x == columns - 1 and bui.app.ui_v1.use_toolbars: + if x == columns - 1: bui.widget( edit=btn, - right_widget=bui.get_special_widget('party_button'), + right_widget=bui.get_special_widget('squad_button'), ) bui.widget(edit=btn, show_buffer_top=60, show_buffer_bottom=60) @@ -247,71 +284,40 @@ class PlaylistMapSelectWindow(bui.Window): ) def _on_store_press(self) -> None: - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.store.browser import StoreBrowserWindow + # No-op if we're not in control. + if not self.main_window_has_control(): + return + plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() return - StoreBrowserWindow( - modal=True, - show_tab=StoreBrowserWindow.TabID.MAPS, - on_close_call=self._on_store_close, - origin_widget=self._get_more_maps_button, + + self._selected_get_more_maps = True + + self.main_window_replace( + StoreBrowserWindow( + show_tab=StoreBrowserWindow.TabID.MAPS, + origin_widget=self._get_more_maps_button, + minimal_toolbars=True, + ) ) - def _on_store_close(self) -> None: - self._refresh(select_get_more_maps_button=True) - def _select(self, map_name: str) -> None: - from bauiv1lib.playlist.editgame import PlaylistEditGameWindow # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + if not self.main_window_has_control(): return self._config['settings']['map'] = map_name - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlaylistEditGameWindow( - self._gametype, - self._sessiontype, - self._config, - self._completion_call, - default_selection='map', - transition='in_left', - edit_info=self._edit_info, - ).get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() def _select_with_delay(self, map_name: str) -> None: bui.lock_all_input() bui.apptimer(0.1, bui.unlock_all_input) bui.apptimer(0.1, bui.WeakCall(self._select, map_name)) - - def _cancel(self) -> None: - from bauiv1lib.playlist.editgame import PlaylistEditGameWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PlaylistEditGameWindow( - self._gametype, - self._sessiontype, - self._config, - self._completion_call, - default_selection='map', - transition='in_left', - edit_info=self._edit_info, - ).get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/playoptions.py b/dist/ba_data/python/bauiv1lib/playoptions.py index cc69b3d..0bf2028 100644 --- a/dist/ba_data/python/bauiv1lib/playoptions.py +++ b/dist/ba_data/python/bauiv1lib/playoptions.py @@ -15,16 +15,22 @@ from bauiv1lib.popup import PopupWindow if TYPE_CHECKING: from typing import Any + from bauiv1lib.play import PlaylistSelectContext + +REQUIRE_PRO = False + class PlayOptionsWindow(PopupWindow): """A popup window for configuring play options.""" def __init__( self, + *, sessiontype: type[bs.Session], playlist: str, scale_origin: tuple[float, float], delegate: Any = None, + playlist_select_context: PlaylistSelectContext | None = None, ): # FIXME: Tidy this up. # pylint: disable=too-many-branches @@ -39,10 +45,7 @@ class PlayOptionsWindow(PopupWindow): self._pvars = PlaylistTypeVars(sessiontype) self._transitioning_out = False - # We behave differently if we're being used for playlist selection - # vs starting a game directly (should make this more elegant). - assert bui.app.classic is not None - self._selecting_mode = bui.app.ui_v1.selecting_private_party_playlist + self._playlist_select_context = playlist_select_context self._do_randomize_val = bui.app.config.get( self._pvars.config_name + ' Playlist Randomize', 0 @@ -65,7 +68,8 @@ class PlayOptionsWindow(PopupWindow): mesh_transparent = bui.getmesh('level_select_button_transparent') mask_tex = bui.gettexture('mapPreviewMask') - # Poke into this playlist and see if we can display some of its maps. + # Poke into this playlist and see if we can display some of its + # maps. map_textures = [] map_texture_entries = [] rows = 0 @@ -314,7 +318,7 @@ class PlayOptionsWindow(PopupWindow): label=bui.Lstr(resource='teamNamesColorText'), ) assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): bui.imagewidget( parent=self.root_widget, size=(30, 30), @@ -404,7 +408,11 @@ class PlayOptionsWindow(PopupWindow): on_activate_call=self._on_ok_press, autoselect=True, label=bui.Lstr( - resource='okText' if self._selecting_mode else 'playText' + resource=( + 'okText' + if self._playlist_select_context is not None + else 'playText' + ) ), ) @@ -426,7 +434,7 @@ class PlayOptionsWindow(PopupWindow): self._update() def _custom_colors_names_press(self) -> None: - from bauiv1lib.account import show_sign_in_prompt + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.teamnamescolors import TeamNamesColorsWindow from bauiv1lib.purchase import PurchaseWindow @@ -434,7 +442,7 @@ class PlayOptionsWindow(PopupWindow): assert plus is not None assert bui.app.classic is not None - if not bui.app.classic.accounts.have_pro(): + if REQUIRE_PRO and not bui.app.classic.accounts.have_pro(): if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() else: @@ -495,10 +503,10 @@ class PlayOptionsWindow(PopupWindow): cfg = bui.app.config cfg[self._pvars.config_name + ' Playlist Selection'] = self._playlist - # Head back to the gather window in playlist-select mode - # or start the game in regular mode. - if self._selecting_mode: - from bauiv1lib.gather import GatherWindow + # Head back to the gather window in playlist-select mode or + # start the game in regular mode. + if self._playlist_select_context is not None: + # from bauiv1lib.gather import GatherWindow if self._sessiontype is bs.FreeForAllSession: typename = 'ffa' @@ -508,14 +516,7 @@ class PlayOptionsWindow(PopupWindow): raise RuntimeError('Only teams and ffa currently supported') cfg['Private Party Host Session Type'] = typename bui.getsound('gunCocking').play() - assert bui.app.classic is not None - # Note: this is a wonky situation where we aren't actually - # the main window but we set it on behalf of the main window - # that popped us up. - bui.app.ui_v1.set_main_menu_window( - GatherWindow(transition='in_right').get_root_widget(), - from_window=False, # Disable this test. - ) + self._transition_out(transition='out_left') if self._delegate is not None: self._delegate.on_play_options_window_run_game() @@ -530,6 +531,11 @@ class PlayOptionsWindow(PopupWindow): def _run_selected_playlist(self) -> None: bui.unlock_all_input() + + # Save our place in the UI that we'll return to when done. + if bs.app.classic is not None: + bs.app.classic.save_ui_state() + try: bs.new_host_session(self._sessiontype) except Exception: diff --git a/dist/ba_data/python/bauiv1lib/popup.py b/dist/ba_data/python/bauiv1lib/popup.py index ef830f8..57e371d 100644 --- a/dist/ba_data/python/bauiv1lib/popup.py +++ b/dist/ba_data/python/bauiv1lib/popup.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, override import bauiv1 as bui if TYPE_CHECKING: - from typing import Any, Sequence, Callable + from typing import Any, Sequence, Callable, Literal class PopupWindow: @@ -23,11 +23,17 @@ class PopupWindow: position: tuple[float, float], size: tuple[float, float], scale: float = 1.0, + *, offset: tuple[float, float] = (0, 0), bg_color: tuple[float, float, float] = (0.35, 0.55, 0.15), focus_position: tuple[float, float] = (0, 0), focus_size: tuple[float, float] | None = None, - toolbar_visibility: str = 'menu_minimal_no_back', + toolbar_visibility: Literal[ + 'inherit', + 'menu_minimal_no_back', + 'menu_store_no_back', + ] = 'menu_minimal_no_back', + edge_buffer_scale: float = 1.0, ): # pylint: disable=too-many-locals if focus_size is None: @@ -45,7 +51,7 @@ class PopupWindow: # we now need to ensure that we're all onscreen by scaling down if # need be and clamping it to the UI bounds. bounds = bui.uibounds() - edge_buffer = 15 + edge_buffer = 15 * edge_buffer_scale bounds_width = bounds[1] - bounds[0] - edge_buffer * 2 bounds_height = bounds[3] - bounds[2] - edge_buffer * 2 @@ -111,6 +117,7 @@ class PopupMenuWindow(PopupWindow): position: tuple[float, float], choices: Sequence[str], current_choice: str, + *, delegate: Any = None, width: float = 230.0, maxwidth: float | None = None, @@ -196,6 +203,7 @@ class PopupMenuWindow(PopupWindow): highlight=False, color=(0.35, 0.55, 0.15), size=(self._width - 40, self._height - 40), + border_opacity=0.5, ) self._columnwidget = bui.columnwidget( parent=self._scrollwidget, border=2, margin=0 @@ -296,6 +304,7 @@ class PopupMenu: parent: bui.Widget, position: tuple[float, float], choices: Sequence[str], + *, current_choice: str | None = None, on_value_change_call: Callable[[str], Any] | None = None, opening_call: Callable[[], Any] | None = None, diff --git a/dist/ba_data/python/bauiv1lib/profile/browser.py b/dist/ba_data/python/bauiv1lib/profile/browser.py index 4529cdb..649dc2c 100644 --- a/dist/ba_data/python/bauiv1lib/profile/browser.py +++ b/dist/ba_data/python/bauiv1lib/profile/browser.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui import bascenev1 as bs @@ -14,23 +14,19 @@ if TYPE_CHECKING: from typing import Any -class ProfileBrowserWindow(bui.Window): +class ProfileBrowserWindow(bui.MainWindow): """Window for browsing player profiles.""" def __init__( self, - transition: str = 'in_right', - in_main_menu: bool = True, + transition: str | None = 'in_right', + # in_main_menu: bool = True, selected_profile: str | None = None, origin_widget: bui.Widget | None = None, + minimal_toolbar: bool = False, ): - # pylint: disable=too-many-statements - # pylint: disable=too-many-locals - self._in_main_menu = in_main_menu - if self._in_main_menu: - back_label = bui.Lstr(resource='backText') - else: - back_label = bui.Lstr(resource='doneText') + self._minimal_toolbar = minimal_toolbar + back_label = bui.Lstr(resource='backText') assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale self._width = 800.0 if uiscale is bui.UIScale.SMALL else 600.0 @@ -41,20 +37,11 @@ class ProfileBrowserWindow(bui.Window): else 385.0 if uiscale is bui.UIScale.MEDIUM else 410.0 ) - # If we're being called up standalone, handle pause/resume ourself. - if not self._in_main_menu: - assert bui.app.classic is not None - bui.app.classic.pause() - - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None + # Need to handle out-transitions ourself for modal mode. if origin_widget is not None: self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' else: self._transition_out = 'out_right' - scale_origin = None self._r = 'playerProfilesWindow' @@ -67,30 +54,47 @@ class ProfileBrowserWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height + top_extra), - transition=transition, - scale_origin_stack_offset=scale_origin, + toolbar_visibility=( + 'menu_minimal' + if (uiscale is bui.UIScale.SMALL or minimal_toolbar) + else 'menu_full' + ), scale=( - 2.2 + 2.5 if uiscale is bui.UIScale.SMALL - else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( (0, -14) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(40 + x_inset, self._height - 59), - size=(120, 60), - scale=0.8, - label=back_label, - button_type='back' if self._in_main_menu else None, - autoselect=True, - on_activate_call=self._back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + if bui.app.ui_v1.uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + self._back_button = btn = bui.buttonwidget( + parent=self._root_widget, + position=(40 + x_inset, self._height - 59), + size=(120, 60), + scale=0.8, + label=back_label, + button_type='back', + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + bui.buttonwidget( + edit=btn, + button_type='backSmall', + size=(60, 60), + label=bui.charstr(bui.SpecialChar.BACK), + ) bui.textwidget( parent=self._root_widget, @@ -104,14 +108,6 @@ class ProfileBrowserWindow(bui.Window): v_align='center', ) - if self._in_main_menu: - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - scroll_height = self._height - 140.0 self._scroll_width = self._width - (188 + x_inset * 2) v = self._height - 84.0 @@ -203,13 +199,32 @@ class ProfileBrowserWindow(bui.Window): self._refresh() self._restore_state() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + minimal_toolbar = self._minimal_toolbar + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + minimal_toolbar=minimal_toolbar, + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _new_profile(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.profile.edit import EditProfileWindow from bauiv1lib.purchase import PurchaseWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # No-op if we're not the in-control main window. + if not self.main_window_has_control(): return plus = bui.app.plus @@ -220,7 +235,8 @@ class ProfileBrowserWindow(bui.Window): assert self._profiles is not None assert bui.app.classic is not None if ( - not bui.app.classic.accounts.have_pro_options() + bool(False) # Phasing out pro. + and not bui.app.classic.accounts.have_pro_options() and len(self._profiles) >= max_non_pro_profiles ): PurchaseWindow( @@ -247,14 +263,7 @@ class ProfileBrowserWindow(bui.Window): bui.getsound('error').play() return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.app.ui_v1.set_main_menu_window( - EditProfileWindow( - existing_profile=None, in_main_menu=self._in_main_menu - ).get_root_widget(), - from_window=self._root_widget if self._in_main_menu else False, - ) + self.main_window_replace(EditProfileWindow(existing_profile=None)) def _delete_profile(self) -> None: # pylint: disable=cyclic-import @@ -302,8 +311,8 @@ class ProfileBrowserWindow(bui.Window): # pylint: disable=cyclic-import from bauiv1lib.profile.edit import EditProfileWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # No-op if we're not in control. + if not self.main_window_has_control(): return if self._selected_profile is None: @@ -312,45 +321,13 @@ class ProfileBrowserWindow(bui.Window): bui.Lstr(resource='nothingIsSelectedErrorText'), color=(1, 0, 0) ) return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - EditProfileWindow( - self._selected_profile, in_main_menu=self._in_main_menu - ).get_root_widget(), - from_window=self._root_widget if self._in_main_menu else False, - ) + + self.main_window_replace(EditProfileWindow(self._selected_profile)) def _select(self, name: str, index: int) -> None: del index # Unused. self._selected_profile = name - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account.settings import AccountSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - assert bui.app.classic is not None - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if self._in_main_menu: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AccountSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - - # If we're being called up standalone, handle pause/resume ourself. - else: - bui.app.classic.resume() - def _refresh(self) -> None: # pylint: disable=too-many-locals # pylint: disable=too-many-statements diff --git a/dist/ba_data/python/bauiv1lib/profile/edit.py b/dist/ba_data/python/bauiv1lib/profile/edit.py index b474776..2cfe76c 100644 --- a/dist/ba_data/python/bauiv1lib/profile/edit.py +++ b/dist/ba_data/python/bauiv1lib/profile/edit.py @@ -5,49 +5,54 @@ from __future__ import annotations import random -from typing import cast +from typing import cast, override from bauiv1lib.colorpicker import ColorPicker +from bauiv1lib.characterpicker import CharacterPickerDelegate +from bauiv1lib.iconpicker import IconPickerDelegate import bauiv1 as bui import bascenev1 as bs -class EditProfileWindow(bui.Window): +class EditProfileWindow( + bui.MainWindow, CharacterPickerDelegate, IconPickerDelegate +): """Window for editing a player profile.""" - # FIXME: WILL NEED TO CHANGE THIS FOR UILOCATION. def reload_window(self) -> None: """Transitions out and recreates ourself.""" - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - EditProfileWindow( - self.getname(), self._in_main_menu - ).get_root_widget(), - from_window=self._root_widget, + # Replace ourself with ourself, but keep the same back location. + assert self.main_window_back_state is not None + self.main_window_replace( + EditProfileWindow(self.getname()), + back_state=self.main_window_back_state, ) + # def __del__(self) -> None: + # print(f'~EditProfileWindow({id(self)})') + def __init__( self, existing_profile: str | None, - in_main_menu: bool, - transition: str = 'in_right', + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): # FIXME: Tidy this up a bit. # pylint: disable=too-many-branches # pylint: disable=too-many-statements # pylint: disable=too-many-locals + assert bui.app.classic is not None + # print(f'EditProfileWindow({id(self)})') plus = bui.app.plus assert plus is not None - self._in_main_menu = in_main_menu self._existing_profile = existing_profile self._r = 'editProfileWindow' self._spazzes: list[str] = [] @@ -63,30 +68,35 @@ class EditProfileWindow(bui.Window): self._width = width = 880.0 if uiscale is bui.UIScale.SMALL else 680.0 self._x_inset = x_inset = 100.0 if uiscale is bui.UIScale.SMALL else 0.0 self._height = height = ( - 350.0 + 500.0 if uiscale is bui.UIScale.SMALL else 400.0 if uiscale is bui.UIScale.MEDIUM else 450.0 ) + yoffs = -42 if uiscale is bui.UIScale.SMALL else 0 spacing = 40 self._base_scale = ( - 2.05 + 2.0 if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ) - top_extra = 15 if uiscale is bui.UIScale.SMALL else 15 + top_extra = 70 if uiscale is bui.UIScale.SMALL else 15 super().__init__( root_widget=bui.containerwidget( size=(width, height + top_extra), - transition=transition, scale=self._base_scale, stack_offset=( - (0, 15) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) ), - ) + toolbar_visibility=( + None if uiscale is bui.UIScale.SMALL else 'menu_full' + ), + ), + transition=transition, + origin_widget=origin_widget, ) cancel_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(52 + x_inset, height - 60), + position=(52 + x_inset, height - 60 + yoffs), size=(155, 60), scale=0.8, autoselect=True, @@ -96,7 +106,7 @@ class EditProfileWindow(bui.Window): bui.containerwidget(edit=self._root_widget, cancel_button=btn) save_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(width - (177 + x_inset), height - 60), + position=(width - (177 + x_inset), height - 60 + yoffs), size=(155, 60), autoselect=True, scale=0.8, @@ -107,7 +117,7 @@ class EditProfileWindow(bui.Window): bui.containerwidget(edit=self._root_widget, start_button=btn) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, height - 38), + position=(self._width * 0.5, height - 38 + yoffs), size=(0, 0), text=( bui.Lstr(resource=f'{self._r}.titleNewText') @@ -157,7 +167,7 @@ class EditProfileWindow(bui.Window): self._icon_index = icon_index bui.buttonwidget(edit=save_button, on_activate_call=self.save) - v = height - 115.0 + v = height - 115.0 + yoffs self._name = ( '' if self._existing_profile is None else self._existing_profile ) @@ -512,6 +522,23 @@ class EditProfileWindow(bui.Window): ) self._update_character() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull things out of self here; if we do it within the lambda + # we'll keep ourself alive which is bad. + + existing_profile = self._existing_profile + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + existing_profile=existing_profile, + ) + ) + def assign_random_name(self) -> None: """Assigning a random name to the player.""" names = bs.get_random_names() @@ -523,14 +550,23 @@ class EditProfileWindow(bui.Window): def upgrade_profile(self) -> None: """Attempt to upgrade the profile to global.""" - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.profile import upgrade as pupgrade + new_name = self.getname().strip() + + if self._existing_profile and self._existing_profile != new_name: + bui.screenmessage( + 'Unsaved changes found; you must save first.', color=(1, 0, 0) + ) + bui.getsound('error').play() + return + plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() return pupgrade.ProfileUpgradeWindow(self) @@ -609,23 +645,55 @@ class EditProfileWindow(bui.Window): for s in self._spazzes ] + @override def on_icon_picker_pick(self, icon: str) -> None: """An icon has been selected by the picker.""" self._icon = icon self._update_icon() + @override + def on_icon_picker_get_more_press(self) -> None: + """User wants to get more icons.""" + from bauiv1lib.store.browser import StoreBrowserWindow + + if not self.main_window_has_control(): + return + + self.main_window_replace( + StoreBrowserWindow( + minimal_toolbars=True, + show_tab=StoreBrowserWindow.TabID.ICONS, + ) + ) + + @override def on_character_picker_pick(self, character: str) -> None: """A character has been selected by the picker.""" if not self._root_widget: return - # The player could have bought a new one while the picker was up. + # The player could have bought a new one while the picker was + # up. self.refresh_characters() self._icon_index = ( self._spazzes.index(character) if character in self._spazzes else 0 ) self._update_character() + @override + def on_character_picker_get_more_press(self) -> None: + from bauiv1lib.store.browser import StoreBrowserWindow + + if not self.main_window_has_control(): + return + + self.main_window_replace( + StoreBrowserWindow( + minimal_toolbars=True, + show_tab=StoreBrowserWindow.TabID.CHARACTERS, + ) + ) + def _on_character_press(self) -> None: from bauiv1lib import characterpicker @@ -672,22 +740,7 @@ class EditProfileWindow(bui.Window): ) def _cancel(self) -> None: - from bauiv1lib.profile.browser import ProfileBrowserWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ProfileBrowserWindow( - 'in_left', - selected_profile=self._existing_profile, - in_main_menu=self._in_main_menu, - ).get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() def _set_color(self, color: tuple[float, float, float]) -> None: self._color = color @@ -783,7 +836,6 @@ class EditProfileWindow(bui.Window): def save(self, transition_out: bool = True) -> bool: """Save has been selected.""" - from bauiv1lib.profile.browser import ProfileBrowserWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -820,8 +872,8 @@ class EditProfileWindow(bui.Window): } ) - # Also lets be aware we're no longer global if we're taking a - # new name (will need to re-request it). + # Also lets be aware we're no longer global if we're taking + # a new name (will need to re-request it). self._global = False plus.add_v1_account_transaction( @@ -840,14 +892,6 @@ class EditProfileWindow(bui.Window): if transition_out: plus.run_v1_account_transactions() - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ProfileBrowserWindow( - 'in_left', - selected_profile=new_name, - in_main_menu=self._in_main_menu, - ).get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() + return True diff --git a/dist/ba_data/python/bauiv1lib/profile/upgrade.py b/dist/ba_data/python/bauiv1lib/profile/upgrade.py index 89340bb..cb2bc96 100644 --- a/dist/ba_data/python/bauiv1lib/profile/upgrade.py +++ b/dist/ba_data/python/bauiv1lib/profile/upgrade.py @@ -32,15 +32,16 @@ class ProfileUpgradeWindow(bui.Window): self._r = 'editProfileWindow' - self._width = 680 - self._height = 350 - assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale + self._width = 750 if uiscale is bui.UIScale.SMALL else 680 + self._height = 450 if uiscale is bui.UIScale.SMALL else 350 + assert bui.app.classic is not None self._base_scale = ( - 2.05 + 1.92 if uiscale is bui.UIScale.SMALL else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.2 ) + yoffs = -60.0 if uiscale is bui.UIScale.SMALL else 0 self._upgrade_start_time: float | None = None self._name = edit_profile_window.getname() self._edit_profile_window = weakref.ref(edit_profile_window) @@ -49,17 +50,17 @@ class ProfileUpgradeWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height + top_extra), - toolbar_visibility='menu_currency', + toolbar_visibility='menu_store_no_back', transition=transition, scale=self._base_scale, stack_offset=( - (0, 15) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, 0) if uiscale is bui.UIScale.SMALL else (0, 0) ), ) ) cancel_button = bui.buttonwidget( parent=self._root_widget, - position=(52, 30), + position=(52, self._height - 290 + yoffs), size=(155, 60), scale=0.8, autoselect=True, @@ -68,7 +69,7 @@ class ProfileUpgradeWindow(bui.Window): ) self._upgrade_button = bui.buttonwidget( parent=self._root_widget, - position=(self._width - 190, 30), + position=(self._width - 190, self._height - 290 + yoffs), size=(155, 60), scale=0.8, autoselect=True, @@ -85,7 +86,7 @@ class ProfileUpgradeWindow(bui.Window): assert bui.app.classic is not None bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 38), + position=(self._width * 0.5, self._height - 38 + yoffs), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.upgradeToGlobalProfileText'), color=bui.app.ui_v1.title_color, @@ -98,7 +99,7 @@ class ProfileUpgradeWindow(bui.Window): assert bui.app.classic is not None bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 100), + position=(self._width * 0.5, self._height - 100 + yoffs), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.upgradeProfileInfoText'), color=bui.app.ui_v1.infotextcolor, @@ -110,7 +111,7 @@ class ProfileUpgradeWindow(bui.Window): self._status_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 160), + position=(self._width * 0.5, self._height - 160 + yoffs), size=(0, 0), text=bui.Lstr( resource=f'{self._r}.checkingAvailabilityText', @@ -125,7 +126,7 @@ class ProfileUpgradeWindow(bui.Window): self._price_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 230), + position=(self._width * 0.5, self._height - 230 + yoffs), size=(0, 0), text='', color=(0.2, 1, 0.2), @@ -135,22 +136,6 @@ class ProfileUpgradeWindow(bui.Window): v_align='center', ) - self._tickets_text: bui.Widget | None - if not bui.app.ui_v1.use_toolbars: - self._tickets_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.9 - 5, self._height - 30), - size=(0, 0), - text=bui.charstr(bui.SpecialChar.TICKET) + '123', - color=(0.2, 1, 0.2), - maxwidth=100, - scale=0.5, - h_align='right', - v_align='center', - ) - else: - self._tickets_text = None - bui.app.classic.master_server_v1_get( 'bsGlobalProfileCheck', {'name': self._name, 'b': bui.app.env.engine_build_number}, @@ -161,7 +146,7 @@ class ProfileUpgradeWindow(bui.Window): ) self._status: str | None = 'waiting' self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True + 1.023, bui.WeakCall(self._update), repeat=True ) self._update() @@ -210,7 +195,7 @@ class ProfileUpgradeWindow(bui.Window): ) def _on_upgrade_press(self) -> None: - from bauiv1lib import gettickets + # from bauiv1lib import gettickets if self._status is None: plus = bui.app.plus @@ -220,7 +205,11 @@ class ProfileUpgradeWindow(bui.Window): tickets = plus.get_v1_account_ticket_count() if tickets < self._cost: bui.getsound('error').play() - gettickets.show_get_tickets_prompt() + bui.screenmessage( + bui.Lstr(resource='notEnoughTicketsText'), + color=(1, 0, 0), + ) + # gettickets.show_get_tickets_prompt() return bui.screenmessage( bui.Lstr(resource='purchasingText'), color=(0, 1, 0) @@ -255,23 +244,11 @@ class ProfileUpgradeWindow(bui.Window): plus = bui.app.plus assert plus is not None - try: - t_str = str(plus.get_v1_account_ticket_count()) - except Exception: - t_str = '?' - if self._tickets_text is not None: - bui.textwidget( - edit=self._tickets_text, - text=bui.Lstr( - resource='getTicketsWindow.youHaveShortText', - subs=[ - ( - '${COUNT}', - bui.charstr(bui.SpecialChar.TICKET) + t_str, - ) - ], - ), - ) + # If our originating window dies at any point, cancel. + edit_profile_window = self._edit_profile_window() + if edit_profile_window is None: + self._cancel() + return # Once we've kicked off an upgrade attempt and all transactions go # through, we're done. diff --git a/dist/ba_data/python/bauiv1lib/promocode.py b/dist/ba_data/python/bauiv1lib/promocode.py deleted file mode 100644 index 09bcf86..0000000 --- a/dist/ba_data/python/bauiv1lib/promocode.py +++ /dev/null @@ -1,223 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for entering promo codes.""" - -from __future__ import annotations - -import time -import logging -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class PromoCodeWindow(bui.Window): - """Window for entering promo codes.""" - - def __init__( - self, modal: bool = False, origin_widget: bui.Widget | None = None - ): - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' - - width = 450 - height = 330 - - self._modal = modal - self._r = 'promoCodeWindow' - - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - super().__init__( - root_widget=bui.containerwidget( - size=(width, height), - transition=transition, - toolbar_visibility='menu_minimal_no_back', - scale_origin_stack_offset=scale_origin, - scale=( - 2.0 - if uiscale is bui.UIScale.SMALL - else 1.5 - if uiscale is bui.UIScale.MEDIUM - else 1.0 - ), - ) - ) - - btn = bui.buttonwidget( - parent=self._root_widget, - scale=0.5, - position=(40, height - 40), - size=(60, 60), - label='', - on_activate_call=self._do_back, - autoselect=True, - color=(0.55, 0.5, 0.6), - icon=bui.gettexture('crossOut'), - iconscale=1.2, - ) - - v = height - 74 - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource='codesExplainText'), - maxwidth=width * 0.9, - position=(width * 0.5, v), - color=(0.7, 0.7, 0.7, 1.0), - size=(0, 0), - scale=0.8, - h_align='center', - v_align='center', - ) - v -= 60 - - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr( - resource='supportEmailText', - subs=[('${EMAIL}', 'support@froemling.net')], - ), - maxwidth=width * 0.9, - position=(width * 0.5, v), - color=(0.7, 0.7, 0.7, 1.0), - size=(0, 0), - scale=0.65, - h_align='center', - v_align='center', - ) - - v -= 80 - - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource=self._r + '.codeText'), - position=(22, v), - color=(0.8, 0.8, 0.8, 1.0), - size=(90, 30), - h_align='right', - ) - v -= 8 - - self._text_field = bui.textwidget( - parent=self._root_widget, - position=(125, v), - size=(280, 46), - text='', - h_align='left', - v_align='center', - max_chars=64, - color=(0.9, 0.9, 0.9, 1.0), - description=bui.Lstr(resource=self._r + '.codeText'), - editable=True, - padding=4, - on_return_press_call=self._activate_enter_button, - ) - bui.widget(edit=btn, down_widget=self._text_field) - - v -= 79 - b_width = 200 - self._enter_button = btn2 = bui.buttonwidget( - parent=self._root_widget, - position=(width * 0.5 - b_width * 0.5, v), - size=(b_width, 60), - scale=1.0, - label=bui.Lstr( - resource='submitText', fallback_resource=self._r + '.enterText' - ), - on_activate_call=self._do_enter, - ) - bui.containerwidget( - edit=self._root_widget, - cancel_button=btn, - start_button=btn2, - selected_child=self._text_field, - ) - - def _do_back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - - def _activate_enter_button(self) -> None: - self._enter_button.activate() - - def _do_enter(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - - code: Any = bui.textwidget(query=self._text_field) - assert isinstance(code, str) - - bui.app.create_async_task(_run_code(code)) - - -async def _run_code(code: str) -> None: - from bacommon.cloud import PromoCodeMessage - - plus = bui.app.plus - assert plus is not None - - try: - # If we're signed in with a V2 account, ship this to V2 server. - if plus.accounts.primary is not None: - with plus.accounts.primary: - response = await plus.cloud.send_message_async( - PromoCodeMessage(code) - ) - # If V2 handled it, we're done. - if response.valid: - # Support simple message printing from v2 server. - if response.message is not None: - bui.screenmessage(response.message, color=(0, 1, 0)) - return - - # If V2 didn't accept it (or isn't signed in) kick it over to V1. - plus.add_v1_account_transaction( - { - 'type': 'PROMO_CODE', - 'expire_time': time.time() + 5, - 'code': code, - } - ) - plus.run_v1_account_transactions() - except Exception: - logging.exception('Error sending promo code.') - bui.screenmessage('Error sending code (see log).', color=(1, 0, 0)) - bui.getsound('error').play() diff --git a/dist/ba_data/python/bauiv1lib/purchase.py b/dist/ba_data/python/bauiv1lib/purchase.py index 60f97b0..68c231b 100644 --- a/dist/ba_data/python/bauiv1lib/purchase.py +++ b/dist/ba_data/python/bauiv1lib/purchase.py @@ -18,7 +18,7 @@ class PurchaseWindow(bui.Window): def __init__( self, items: list[str], - transition: str = 'in_right', + origin_widget: bui.Widget | None = None, header_text: bui.Lstr | None = None, ): from bauiv1lib.store.item import instantiate_store_item_display @@ -40,16 +40,24 @@ class PurchaseWindow(bui.Window): self._width = 580 self._height = 520 uiscale = bui.app.ui_v1.uiscale + + if origin_widget is not None: + scale_origin = origin_widget.get_screen_space_center() + else: + scale_origin = None + super().__init__( root_widget=bui.containerwidget( + parent=bui.get_special_widget('overlay_stack'), size=(self._width, self._height), - transition=transition, - toolbar_visibility='menu_currency', + transition='in_scale', + toolbar_visibility='menu_store', scale=( 1.2 if uiscale is bui.UIScale.SMALL else 1.1 if uiscale is bui.UIScale.MEDIUM else 1.0 ), + scale_origin_stack_offset=scale_origin, stack_offset=( (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0) ), @@ -155,14 +163,13 @@ class PurchaseWindow(bui.Window): if bui.app.classic.accounts.have_pro(): can_die = True else: - if plus.get_purchased(self._items[0]): + if plus.get_v1_account_product_purchased(self._items[0]): can_die = True if can_die: - bui.containerwidget(edit=self._root_widget, transition='out_left') + bui.containerwidget(edit=self._root_widget, transition='out_scale') def _purchase(self) -> None: - from bauiv1lib import gettickets plus = bui.app.plus assert plus is not None @@ -176,8 +183,12 @@ class PurchaseWindow(bui.Window): except Exception: ticket_count = None if ticket_count is not None and ticket_count < self._price: - gettickets.show_get_tickets_prompt() bui.getsound('error').play() + bui.screenmessage( + bui.Lstr(resource='notEnoughTicketsText'), + color=(1, 0, 0), + ) + # gettickets.show_get_tickets_prompt() return def do_it() -> None: @@ -189,4 +200,4 @@ class PurchaseWindow(bui.Window): do_it() def _cancel(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') + bui.containerwidget(edit=self._root_widget, transition='out_scale') diff --git a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py index a3e39eb..eb92e28 100644 --- a/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py +++ b/dist/ba_data/python/bauiv1lib/resourcetypeinfo.py @@ -4,26 +4,34 @@ from __future__ import annotations -from typing import override +from typing import override, TYPE_CHECKING, assert_never from bauiv1lib.popup import PopupWindow import bauiv1 as bui +if TYPE_CHECKING: + from typing import Literal + class ResourceTypeInfoWindow(PopupWindow): """Popup window providing info about resource types.""" - def __init__(self, origin_widget: bui.Widget): + def __init__( + self, + resource_type: Literal['tickets', 'tokens', 'trophies', 'xp'], + origin_widget: bui.Widget, + ): assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale scale = ( - 2.3 + 2.0 if uiscale is bui.UIScale.SMALL - else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 + else 1.4 if uiscale is bui.UIScale.MEDIUM else 0.8 ) self._transitioning_out = False self._width = 570 - self._height = 350 + self._height = 400 + self._get_tokens_button: bui.Widget | None = None bg_color = (0.5, 0.4, 0.6) super().__init__( size=(self._width, self._height), @@ -31,12 +39,13 @@ class ResourceTypeInfoWindow(PopupWindow): scale=scale, bg_color=bg_color, position=origin_widget.get_screen_space_center(), + edge_buffer_scale=4.0, ) self._cancel_button = bui.buttonwidget( parent=self.root_widget, - position=(50, self._height - 30), + position=(40, self._height - 40), size=(50, 50), - scale=0.5, + scale=0.7, label='', color=bg_color, on_activate_call=self._on_cancel_press, @@ -45,6 +54,77 @@ class ResourceTypeInfoWindow(PopupWindow): iconscale=1.2, ) + yoffs = self._height - 145 + + max_rdesc_height = 160 + + rdesc: bui.Lstr | str + + if resource_type == 'tickets': + yoffs -= 20 + rdesc = bui.Lstr(resource='ticketsDescriptionText') + texname = 'tickets' + elif resource_type == 'tokens': + rdesc = bui.Lstr(resource='tokens.tokensDescriptionText') + texname = 'coin' + bwidth = 200 + bheight = 50 + + # Show 'Get Tokens' button if we don't have a gold pass + # (in case a user doesn't notice the '+' button or we have + # it disabled for some reason). + if not bui.app.classic.gold_pass: + self._get_tokens_button = bui.buttonwidget( + parent=self.root_widget, + position=( + self._width * 0.5 - bwidth * 0.5, + yoffs - 15.0 - bheight - max_rdesc_height, + ), + color=bg_color, + textcolor=(0.8, 0.8, 0.8), + label=bui.Lstr(resource='tokens.getTokensText'), + size=(bwidth, bheight), + autoselect=True, + on_activate_call=bui.WeakCall(self._on_get_tokens_press), + ) + + elif resource_type == 'trophies': + rdesc = 'TODO: Will show trophies & league rankings.' + texname = 'crossOut' + elif resource_type == 'xp': + rdesc = 'TODO: Will describe xp/levels.' + texname = 'crossOut' + else: + assert_never(resource_type) + + imgsize = 100.0 + bui.imagewidget( + parent=self.root_widget, + position=(self._width * 0.5 - imgsize * 0.5, yoffs + 5.0), + size=(imgsize, imgsize), + texture=bui.gettexture(texname), + ) + + bui.textwidget( + parent=self.root_widget, + h_align='center', + v_align='top', + size=(0, 0), + maxwidth=self._width * 0.8, + max_height=max_rdesc_height, + position=(self._width * 0.5, yoffs - 5.0), + text=rdesc, + scale=0.8, + ) + + def _on_get_tokens_press(self) -> None: + from bauiv1lib.gettokens import show_get_tokens_window + + self._transition_out() + show_get_tokens_window( + origin_widget=bui.existing(self._get_tokens_button) + ) + def _on_cancel_press(self) -> None: self._transition_out() diff --git a/dist/ba_data/python/bauiv1lib/sendinfo.py b/dist/ba_data/python/bauiv1lib/sendinfo.py index 0a1cf59..b1b8808 100644 --- a/dist/ba_data/python/bauiv1lib/sendinfo.py +++ b/dist/ba_data/python/bauiv1lib/sendinfo.py @@ -6,7 +6,7 @@ from __future__ import annotations import time import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -14,26 +14,23 @@ if TYPE_CHECKING: from typing import Any -class SendInfoWindow(bui.Window): +class SendInfoWindow(bui.MainWindow): """Window for sending info to the developer.""" def __init__( self, modal: bool = False, legacy_code_mode: bool = False, + transition: str | None = 'in_scale', origin_widget: bui.Widget | None = None, ): self._legacy_code_mode = legacy_code_mode - scale_origin: tuple[float, float] | None + # Need to wrangle our own transition-out in modal mode. if origin_widget is not None: self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' else: self._transition_out = 'out_right' - scale_origin = None - transition = 'in_right' width = 450 if legacy_code_mode else 600 height = 200 if legacy_code_mode else 300 @@ -46,15 +43,19 @@ class SendInfoWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - toolbar_visibility='menu_minimal_no_back', - scale_origin_stack_offset=scale_origin, + toolbar_visibility=( + 'menu_minimal_no_back' + if uiscale is bui.UIScale.SMALL or modal + else 'menu_full' + ), scale=( 2.0 if uiscale is bui.UIScale.SMALL else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ), - ) + ), + transition=transition, + origin_widget=origin_widget, ) btn = bui.buttonwidget( @@ -163,9 +164,33 @@ class SendInfoWindow(bui.Window): selected_child=self._text_field, ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + assert not self._modal + + # Pull stuff out of self here; if we do it in the lambda we'll + # keep self alive which we don't want. + legacy_code_mode = self._legacy_code_mode + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + legacy_code_mode=legacy_code_mode, + transition=transition, + origin_widget=origin_widget, + ) + ) + def _do_back(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow + + if not self._modal: + self.main_window_back() + return + + # Handle modal case: # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -174,40 +199,33 @@ class SendInfoWindow(bui.Window): bui.containerwidget( edit=self._root_widget, transition=self._transition_out ) - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) def _activate_enter_button(self) -> None: self._enter_button.activate() def _do_enter(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow + # from bauiv1lib.settings.advanced import AdvancedSettingsWindow plus = bui.app.plus assert plus is not None - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - description: Any = bui.textwidget(query=self._text_field) assert isinstance(description, str) + if self._modal: + # no-op if our underlying widget is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + bui.containerwidget( + edit=self._root_widget, transition=self._transition_out + ) + else: + # no-op if we're not in control. + if not self.main_window_has_control(): + return + self.main_window_back() + # Used for things like unlocking shared playlists or linking # accounts: talk directly to V1 server via transactions. if self._legacy_code_mode: diff --git a/dist/ba_data/python/bauiv1lib/settings/advanced.py b/dist/ba_data/python/bauiv1lib/settings/advanced.py index 521762a..a497a83 100644 --- a/dist/ba_data/python/bauiv1lib/settings/advanced.py +++ b/dist/ba_data/python/bauiv1lib/settings/advanced.py @@ -1,12 +1,14 @@ # Released under the MIT License. See LICENSE for details. # +# pylint: disable=too-many-lines + """UI functionality for advanced settings.""" from __future__ import annotations import os import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from bauiv1lib.popup import PopupMenu import bauiv1 as bui @@ -15,66 +17,75 @@ if TYPE_CHECKING: from typing import Any -class AdvancedSettingsWindow(bui.Window): +class AdvancedSettingsWindow(bui.MainWindow): """Window for editing advanced app settings.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements - import threading if bui.app.classic is None: raise RuntimeError('This requires classic support.') # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - threading.Thread(target=self._preload_modules).start() + bui.app.threadpool.submit_no_wait(self._preload_modules) app = bui.app assert app.classic is not None - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - uiscale = bui.app.ui_v1.uiscale - self._width = 970.0 if uiscale is bui.UIScale.SMALL else 670.0 - x_inset = 150 if uiscale is bui.UIScale.SMALL else 0 + self._width = 1030.0 if uiscale is bui.UIScale.SMALL else 670.0 self._height = ( - 390.0 + 490.0 if uiscale is bui.UIScale.SMALL - else 450.0 if uiscale is bui.UIScale.MEDIUM else 520.0 + else 450.0 if uiscale is bui.UIScale.MEDIUM else 600.0 ) self._lang_status_text: bui.Widget | None = None self._spacing = 32 self._menu_open = False - top_extra = 10 if uiscale is bui.UIScale.SMALL else 0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.2 + if uiscale is bui.UIScale.SMALL + else 1.3 if uiscale is bui.UIScale.MEDIUM else 0.9 + ) + + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 80, screensize[0] / scale) + target_height = min(self._height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 25 + scroll_bottom = yoffs - 56 - self._scroll_height super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.06 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -25) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) self._prev_lang = '' @@ -87,9 +98,7 @@ class AdvancedSettingsWindow(bui.Window): # so no need to show this. self._show_always_use_internal_keyboard = not app.env.vr - self._scroll_width = self._width - (100 + 2 * x_inset) - self._scroll_height = self._height - 115.0 - self._sub_width = self._scroll_width * 0.95 + self._sub_width = min(550, self._scroll_width * 0.95) self._sub_height = 870.0 if self._show_always_use_internal_keyboard: @@ -99,6 +108,10 @@ class AdvancedSettingsWindow(bui.Window): if self._show_disable_gyro: self._sub_height += 42 + self._show_use_insecure_connections = True + if self._show_use_insecure_connections: + self._sub_height += 82 + self._do_vr_test_button = app.env.vr self._do_net_test_button = True self._extra_button_spacing = self._spacing * 2.5 @@ -112,21 +125,21 @@ class AdvancedSettingsWindow(bui.Window): self._r = 'settingsWindowAdvanced' - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._do_back + edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = bui.buttonwidget( parent=self._root_widget, - position=(53 + x_inset, self._height - 60), - size=(140, 60), + position=(50, yoffs - 48), + size=(60, 60), scale=0.8, autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._do_back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button @@ -134,29 +147,30 @@ class AdvancedSettingsWindow(bui.Window): self._title_text = bui.textwidget( parent=self._root_widget, - position=(0, self._height - 52), - size=(self._width, 25), + position=( + self._width * 0.5, + yoffs - (43 if uiscale is bui.UIScale.SMALL else 25), + ), + size=(0, 0), + scale=0.75 if uiscale is bui.UIScale.SMALL else 1.0, text=bui.Lstr(resource=f'{self._r}.titleText'), color=app.ui_v1.title_color, h_align='center', - v_align='top', + v_align='center', ) - if self._back_button is not None: - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - self._scrollwidget = bui.scrollwidget( parent=self._root_widget, - position=(50 + x_inset, 50), + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), simple_culling_v=20.0, highlight=False, - size=(self._scroll_width, self._scroll_height), + center_small_content_horizontally=True, selection_loops_to_parent=True, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) self._subcontainer = bui.containerwidget( @@ -180,10 +194,23 @@ class AdvancedSettingsWindow(bui.Window): callback=bui.WeakCall(self._completed_langs_cb), ) - # noinspection PyUnresolvedReferences + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + @staticmethod def _preload_modules() -> None: - """Preload modules we use; avoids hitches (called in bg thread).""" + """Preload stuff in bg thread to avoid hitches in logic thread""" from babase import modutils as _unused2 from bauiv1lib import config as _unused1 from bauiv1lib.settings import vrtesting as _unused3 @@ -191,7 +218,7 @@ class AdvancedSettingsWindow(bui.Window): from bauiv1lib import appinvite as _unused5 from bauiv1lib import account as _unused6 from bauiv1lib import sendinfo as _unused7 - from bauiv1lib import debug as _unused8 + from bauiv1lib.settings import benchmarks as _unused8 from bauiv1lib.settings import plugins as _unused9 from bauiv1lib.settings import devtools as _unused10 @@ -515,6 +542,47 @@ class AdvancedSettingsWindow(bui.Window): maxwidth=430, ) + self._use_insecure_connections_check_box: ConfigCheckBox | None + if self._show_use_insecure_connections: + v -= 42 + self._use_insecure_connections_check_box = ConfigCheckBox( + parent=self._subcontainer, + position=(50, v), + size=(self._sub_width - 100, 30), + configkey='Use Insecure Connections', + autoselect=True, + # displayname='USE INSECURE CONNECTIONS', + displayname=bui.Lstr( + resource=(f'{self._r}.insecureConnectionsText') + ), + # displayname=bui.Lstr( + # resource=f'{self._r}.alwaysUseInternalKeyboardText' + # ), + scale=1.0, + maxwidth=430, + ) + bui.textwidget( + parent=self._subcontainer, + position=(90, v - 20), + size=(0, 0), + # text=( + # 'not recommended, but may allow online play\n' + # 'from restricted countries or networks' + # ), + text=bui.Lstr( + resource=(f'{self._r}.insecureConnectionsDescriptionText') + ), + maxwidth=400, + flatness=1.0, + scale=0.65, + color=(0.4, 0.9, 0.4, 0.8), + h_align='left', + v_align='center', + ) + v -= 40 + else: + self._use_insecure_connections_check_box = None + self._always_use_internal_keyboard_check_box: ConfigCheckBox | None if self._show_always_use_internal_keyboard: v -= 42 @@ -684,14 +752,13 @@ class AdvancedSettingsWindow(bui.Window): for child in self._subcontainer.get_children(): bui.widget(edit=child, show_buffer_bottom=30, show_buffer_top=20) - if bui.app.ui_v1.use_toolbars: - pbtn = bui.get_special_widget('party_button') - bui.widget(edit=self._scrollwidget, right_widget=pbtn) - if self._back_button is None: - bui.widget( - edit=self._scrollwidget, - left_widget=bui.get_special_widget('back_button'), - ) + pbtn = bui.get_special_widget('squad_button') + bui.widget(edit=self._scrollwidget, right_widget=pbtn) + if self._back_button is None: + bui.widget( + edit=self._scrollwidget, + left_widget=bui.get_special_widget('back_button'), + ) self._restore_state() @@ -712,113 +779,76 @@ class AdvancedSettingsWindow(bui.Window): def _on_vr_test_press(self) -> None: from bauiv1lib.settings.vrtesting import VRTestingWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - VRTestingWindow(transition='in_right').get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(VRTestingWindow(transition='in_right')) def _on_net_test_press(self) -> None: - plus = bui.app.plus - assert plus is not None from bauiv1lib.settings.nettesting import NetTestingWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - NetTestingWindow(transition='in_right').get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(NetTestingWindow(transition='in_right')) def _on_friend_promo_code_press(self) -> None: from bauiv1lib import appinvite - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() return appinvite.handle_app_invites_press() def _on_plugins_button_press(self) -> None: from bauiv1lib.settings.plugins import PluginWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PluginWindow(origin_widget=self._plugins_button).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + PluginWindow(origin_widget=self._plugins_button) ) def _on_dev_tools_button_press(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.devtools import DevToolsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - DevToolsWindow( - origin_widget=self._dev_tools_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + DevToolsWindow(origin_widget=self._dev_tools_button) ) def _on_send_info_press(self) -> None: from bauiv1lib.sendinfo import SendInfoWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - plus = bui.app.plus - assert plus is not None - - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - SendInfoWindow( - origin_widget=self._send_info_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + SendInfoWindow(origin_widget=self._send_info_button) ) def _on_benchmark_press(self) -> None: - from bauiv1lib.debug import DebugWindow + from bauiv1lib.settings.benchmarks import BenchmarksAndStressTestsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - DebugWindow(transition='in_right').get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + BenchmarksAndStressTestsWindow(transition='in_right') ) def _save_state(self) -> None: @@ -852,6 +882,11 @@ class AdvancedSettingsWindow(bui.Window): == self._always_use_internal_keyboard_check_box.widget ): sel_name = 'AlwaysUseInternalKeyboard' + elif ( + self._use_insecure_connections_check_box is not None + and sel == self._use_insecure_connections_check_box.widget + ): + sel_name = 'UseInsecureConnections' elif ( self._disable_gyro_check_box is not None and sel == self._disable_gyro_check_box.widget @@ -888,6 +923,7 @@ class AdvancedSettingsWindow(bui.Window): def _restore_state(self) -> None: # pylint: disable=too-many-branches + # pylint: disable=too-many-statements try: assert bui.app.classic is not None sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( @@ -922,6 +958,11 @@ class AdvancedSettingsWindow(bui.Window): and self._always_use_internal_keyboard_check_box is not None ): sel = self._always_use_internal_keyboard_check_box.widget + elif ( + sel_name == 'UseInsecureConnections' + and self._use_insecure_connections_check_box is not None + ): + sel = self._use_insecure_connections_check_box.widget elif ( sel_name == 'DisableGyro' and self._disable_gyro_check_box is not None @@ -973,20 +1014,3 @@ class AdvancedSettingsWindow(bui.Window): self._complete_langs_list = None self._complete_langs_error = True bui.apptimer(0.001, bui.WeakCall(self._update_lang_status)) - - def _do_back(self) -> None: - from bauiv1lib.settings.allsettings import AllSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AllSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/allsettings.py b/dist/ba_data/python/bauiv1lib/settings/allsettings.py index 746000a..fb18c2f 100644 --- a/dist/ba_data/python/bauiv1lib/settings/allsettings.py +++ b/dist/ba_data/python/bauiv1lib/settings/allsettings.py @@ -4,222 +4,235 @@ from __future__ import annotations -from typing import TYPE_CHECKING -from threading import Thread +from typing import TYPE_CHECKING, override import logging import bauiv1 as bui if TYPE_CHECKING: - pass + from typing import Callable -class AllSettingsWindow(bui.Window): +class AllSettingsWindow(bui.MainWindow): """Window for selecting a settings category.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): - # pylint: disable=too-many-statements # pylint: disable=too-many-locals # Preload some modules we use in a background thread so we won't # have a visual hitch when the user taps them. - Thread(target=self._preload_modules).start() + bui.app.threadpool.submit_no_wait(self._preload_modules) bui.set_analytics_screen('Settings Window') - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - width = 1000 if uiscale is bui.UIScale.SMALL else 580 - x_inset = 125 if uiscale is bui.UIScale.SMALL else 0 - height = 435 + width = 1000 if uiscale is bui.UIScale.SMALL else 900 + height = 800 if uiscale is bui.UIScale.SMALL else 450 self._r = 'settingsWindow' - top_extra = 20 if uiscale is bui.UIScale.SMALL else 0 uiscale = bui.app.ui_v1.uiscale + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + safesize = bui.get_virtual_safe_area_size() + + # We're a generally widescreen shaped window, so bump our + # overall scale up a bit when screen width is wider than safe + # bounds to take advantage of the extra space. + smallscale = min(2.0, 1.5 * screensize[0] / safesize[0]) + + scale = ( + smallscale + if uiscale is bui.UIScale.SMALL + else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_height = min(height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + + # scroll_width = target_width + # scroll_height = target_height - 25 + # scroll_bottom = yoffs - 54 - scroll_height + super().__init__( root_widget=bui.containerwidget( - size=(width, height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 1.75 + size=(width, height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -8) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: self._back_button = None bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._do_back + edit=self._root_widget, on_cancel_call=self.main_window_back ) else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, autoselect=True, - position=(40 + x_inset, height - 55), - size=(130, 60), + position=(50, yoffs - 80.0), + size=(70, 70), scale=0.8, text_scale=1.2, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._do_back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) bui.textwidget( parent=self._root_widget, - position=(0, height - 44), + position=(0, yoffs - (70 if uiscale is bui.UIScale.SMALL else 60)), size=(width, 25), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, h_align='center', v_align='center', + scale=1.1, maxwidth=130, ) - if self._back_button is not None: - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), + bwidth = 200 + bheight = 230 + margin = 1 + all_buttons_width = 4.0 * bwidth + 3.0 * margin + + x = width * 0.5 - all_buttons_width * 0.5 + y = height * 0.5 - bheight * 0.5 - 20.0 + + def _button( + position: tuple[float, float], + label: bui.Lstr, + call: Callable[[], None], + texture: bui.Texture, + imgsize: float, + *, + color: tuple[float, float, float] = (1.0, 1.0, 1.0), + imgoffs: tuple[float, float] = (0.0, 0.0), + ) -> bui.Widget: + x, y = position + btn = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(x, y), + size=(bwidth, bheight), + button_type='square', + label='', + on_activate_call=call, ) - - v = height - 80 - v -= 145 - - basew = 280 if uiscale is bui.UIScale.SMALL else 230 - baseh = 170 - x_offs = ( - x_inset + (105 if uiscale is bui.UIScale.SMALL else 72) - basew - ) # now unused - x_offs2 = x_offs + basew - 7 - x_offs3 = x_offs + 2 * (basew - 7) - x_offs4 = x_offs2 - x_offs5 = x_offs3 - - def _b_title( - x: float, y: float, button: bui.Widget, text: str | bui.Lstr - ) -> None: bui.textwidget( parent=self._root_widget, - text=text, - position=(x + basew * 0.47, y + baseh * 0.22), - maxwidth=basew * 0.7, + text=label, + position=(x + bwidth * 0.5, y + bheight * 0.25), + maxwidth=bwidth * 0.7, size=(0, 0), h_align='center', v_align='center', - draw_controller=button, + draw_controller=btn, color=(0.7, 0.9, 0.7, 1.0), ) + bui.imagewidget( + parent=self._root_widget, + position=( + x + bwidth * 0.5 - imgsize * 0.5 + imgoffs[0], + y + bheight * 0.56 - imgsize * 0.5 + imgoffs[1], + ), + size=(imgsize, imgsize), + texture=texture, + draw_controller=btn, + color=color, + ) + return btn - ctb = self._controllers_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(x_offs2, v), - size=(basew, baseh), - button_type='square', - label='', - on_activate_call=self._do_controllers, - ) - if bui.app.ui_v1.use_toolbars and self._back_button is None: - bbtn = bui.get_special_widget('back_button') - bui.widget(edit=ctb, left_widget=bbtn) - _b_title( - x_offs2, v, ctb, bui.Lstr(resource=f'{self._r}.controllersText') - ) - imgw = imgh = 130 - bui.imagewidget( - parent=self._root_widget, - position=(x_offs2 + basew * 0.49 - imgw * 0.5, v + 35), - size=(imgw, imgh), + self._controllers_button = _button( + position=(x, y), + label=bui.Lstr(resource=f'{self._r}.controllersText'), + call=self._do_controllers, texture=bui.gettexture('controllerIcon'), - draw_controller=ctb, + imgsize=150, + imgoffs=(-2.0, 2.0), ) + x += bwidth + margin - gfxb = self._graphics_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(x_offs3, v), - size=(basew, baseh), - button_type='square', - label='', - on_activate_call=self._do_graphics, - ) - if bui.app.ui_v1.use_toolbars: - pbtn = bui.get_special_widget('party_button') - bui.widget(edit=gfxb, up_widget=pbtn, right_widget=pbtn) - _b_title(x_offs3, v, gfxb, bui.Lstr(resource=f'{self._r}.graphicsText')) - imgw = imgh = 110 - bui.imagewidget( - parent=self._root_widget, - position=(x_offs3 + basew * 0.49 - imgw * 0.5, v + 42), - size=(imgw, imgh), + self._graphics_button = _button( + position=(x, y), + label=bui.Lstr(resource=f'{self._r}.graphicsText'), + call=self._do_graphics, texture=bui.gettexture('graphicsIcon'), - draw_controller=gfxb, + imgsize=135, + imgoffs=(0, 4.0), ) + x += bwidth + margin - v -= baseh - 5 - - abtn = self._audio_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(x_offs4, v), - size=(basew, baseh), - button_type='square', - label='', - on_activate_call=self._do_audio, - ) - _b_title(x_offs4, v, abtn, bui.Lstr(resource=f'{self._r}.audioText')) - imgw = imgh = 120 - bui.imagewidget( - parent=self._root_widget, - position=(x_offs4 + basew * 0.49 - imgw * 0.5 + 5, v + 35), - size=(imgw, imgh), - color=(1, 1, 0), + self._audio_button = _button( + position=(x, y), + label=bui.Lstr(resource=f'{self._r}.audioText'), + call=self._do_audio, texture=bui.gettexture('audioIcon'), - draw_controller=abtn, + imgsize=150, + color=(1, 1, 0), + ) + x += bwidth + margin + + self._advanced_button = _button( + position=(x, y), + label=bui.Lstr(resource=f'{self._r}.advancedText'), + call=self._do_advanced, + texture=bui.gettexture('advancedIcon'), + imgsize=150, + color=(0.8, 0.95, 1), + imgoffs=(0, 5.0), ) - avb = self._advanced_button = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(x_offs5, v), - size=(basew, baseh), - button_type='square', - label='', - on_activate_call=self._do_advanced, - ) - _b_title(x_offs5, v, avb, bui.Lstr(resource=f'{self._r}.advancedText')) - imgw = imgh = 120 - bui.imagewidget( - parent=self._root_widget, - position=(x_offs5 + basew * 0.49 - imgw * 0.5 + 5, v + 35), - size=(imgw, imgh), - color=(0.8, 0.95, 1), - texture=bui.gettexture('advancedIcon'), - draw_controller=avb, - ) + # Hmm; we're now wide enough that being limited to pressing up + # might be ok. + if bool(False): + # Left from our leftmost button should go to back button. + if self._back_button is None: + bbtn = bui.get_special_widget('back_button') + bui.widget(edit=self._controllers_button, left_widget=bbtn) + + # Right from our rightmost widget should go to squad button. + bui.widget( + edit=self._advanced_button, + right_widget=bui.get_special_widget('squad_button'), + ) + self._restore_state() - # noinspection PyUnresolvedReferences + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + @staticmethod def _preload_modules() -> None: """Preload modules we use; avoids hitches (called in bg thread).""" @@ -229,94 +242,52 @@ class AllSettingsWindow(bui.Window): import bauiv1lib.settings.audio as _unused4 import bauiv1lib.settings.advanced as _unused5 - def _do_back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - def _do_controllers(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.controls import ControlsSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ControlsSettingsWindow( - origin_widget=self._controllers_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + ControlsSettingsWindow(origin_widget=self._controllers_button) ) def _do_graphics(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.graphics import GraphicsSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - GraphicsSettingsWindow( - origin_widget=self._graphics_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + GraphicsSettingsWindow(origin_widget=self._graphics_button) ) def _do_audio(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.audio import AudioSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AudioSettingsWindow( - origin_widget=self._audio_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + AudioSettingsWindow(origin_widget=self._audio_button) ) def _do_advanced(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.advanced import AdvancedSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow( - origin_widget=self._advanced_button - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + AdvancedSettingsWindow(origin_widget=self._advanced_button) ) def _save_state(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/settings/audio.py b/dist/ba_data/python/bauiv1lib/settings/audio.py index d04cdf6..3589431 100644 --- a/dist/ba_data/python/bauiv1lib/settings/audio.py +++ b/dist/ba_data/python/bauiv1lib/settings/audio.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import logging import bauiv1 as bui @@ -13,89 +13,94 @@ if TYPE_CHECKING: pass -class AudioSettingsWindow(bui.Window): +class AudioSettingsWindow(bui.MainWindow): """Window for editing audio settings.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): - # pylint: disable=too-many-statements # pylint: disable=too-many-locals # pylint: disable=cyclic-import - from bauiv1lib.popup import PopupMenu from bauiv1lib.config import ConfigNumberEdit assert bui.app.classic is not None music = bui.app.classic.music - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self._r = 'audioSettingsWindow' spacing = 50.0 - width = 460.0 - height = 210.0 + uiscale = bui.app.ui_v1.uiscale - # Update: hard-coding head-relative audio to true now, - # so not showing options. - # show_vr_head_relative_audio = True if bui.app.vr_mode else False - show_vr_head_relative_audio = False - - if show_vr_head_relative_audio: - height += 70 + width = 1200.0 if uiscale is bui.UIScale.SMALL else 500.0 + height = 800.0 if uiscale is bui.UIScale.SMALL else 350.0 show_soundtracks = False if music.have_music_player(): show_soundtracks = True - height += spacing * 2.0 - uiscale = bui.app.ui_v1.uiscale - base_scale = ( - 2.05 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.2 if uiscale is bui.UIScale.SMALL - else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ) - popup_menu_scale = base_scale * 1.2 + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(width - 60, screensize[0] / scale) + target_height = min(height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - scale=base_scale, - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -20) if uiscale is bui.UIScale.SMALL else (0, 0) + scale=scale, + toolbar_visibility=( + 'menu_minimal' + if uiscale is bui.UIScale.SMALL + else 'menu_full' ), - ) + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button = back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(35, height - 55), - size=(120, 60), - scale=0.8, - text_scale=1.2, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._back, - autoselect=True, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - v = height - 60 - v -= spacing * 1.0 + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(35, yoffs - 55), + size=(60, 60), + scale=0.8, + text_scale=1.2, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + autoselect=True, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) + bui.textwidget( parent=self._root_widget, - position=(width * 0.5, height - 32), + position=( + width * 0.5, + yoffs - (48 if uiscale is bui.UIScale.SMALL else 32), + ), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, @@ -104,16 +109,14 @@ class AudioSettingsWindow(bui.Window): v_align='center', ) - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) + # Roughly center everything else in our window. + x = width * 0.5 - 160 + y = height * 0.5 + (100 if show_soundtracks else 70) + y -= spacing * 1.0 self._sound_volume_numedit = svne = ConfigNumberEdit( parent=self._root_widget, - position=(40, v), + position=(x, y), xoffset=10, configkey='Sound Volume', displayname=bui.Lstr(resource=f'{self._r}.soundVolumeText'), @@ -122,15 +125,14 @@ class AudioSettingsWindow(bui.Window): increment=0.05, as_percent=True, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=svne.plusbutton, - right_widget=bui.get_special_widget('party_button'), - ) - v -= spacing + bui.widget( + edit=svne.plusbutton, + right_widget=bui.get_special_widget('squad_button'), + ) + y -= spacing self._music_volume_numedit = ConfigNumberEdit( parent=self._root_widget, - position=(40, v), + position=(x, y), xoffset=10, configkey='Music Volume', displayname=bui.Lstr(resource=f'{self._r}.musicVolumeText'), @@ -142,105 +144,70 @@ class AudioSettingsWindow(bui.Window): as_percent=True, ) - v -= 0.5 * spacing - - self._vr_head_relative_audio_button: bui.Widget | None - if show_vr_head_relative_audio: - v -= 40 - bui.textwidget( - parent=self._root_widget, - position=(40, v + 24), - size=(0, 0), - text=bui.Lstr(resource=f'{self._r}.headRelativeVRAudioText'), - color=(0.8, 0.8, 0.8), - maxwidth=230, - h_align='left', - v_align='center', - ) - - popup = PopupMenu( - parent=self._root_widget, - position=(290, v), - width=120, - button_size=(135, 50), - scale=popup_menu_scale, - choices=['Auto', 'On', 'Off'], - choices_display=[ - bui.Lstr(resource='autoText'), - bui.Lstr(resource='onText'), - bui.Lstr(resource='offText'), - ], - current_choice=bui.app.config.resolve('VR Head Relative Audio'), - on_value_change_call=self._set_vr_head_relative_audio, - ) - self._vr_head_relative_audio_button = popup.get_button() - bui.textwidget( - parent=self._root_widget, - position=(width * 0.5, v - 11), - size=(0, 0), - text=bui.Lstr( - resource=f'{self._r}.headRelativeVRAudioInfoText' - ), - scale=0.5, - color=(0.7, 0.8, 0.7), - maxwidth=400, - flatness=1.0, - h_align='center', - v_align='center', - ) - v -= 30 - else: - self._vr_head_relative_audio_button = None + y -= 0.5 * spacing self._soundtrack_button: bui.Widget | None if show_soundtracks: - v -= 1.2 * spacing + y -= 1.2 * spacing self._soundtrack_button = bui.buttonwidget( parent=self._root_widget, - position=((width - 310) / 2, v), + position=(width * 0.5 - 155, y), size=(310, 50), autoselect=True, label=bui.Lstr(resource=f'{self._r}.soundtrackButtonText'), on_activate_call=self._do_soundtracks, ) - v -= spacing * 0.5 + y -= spacing * 0.3 bui.textwidget( parent=self._root_widget, - position=(0, v), - size=(width, 20), + position=(0.5 * width, y), + size=(0.0, 0.0), text=bui.Lstr(resource=f'{self._r}.soundtrackDescriptionText'), flatness=1.0, h_align='center', + v_align='center', + maxwidth=400, scale=0.5, color=(0.7, 0.8, 0.7, 1.0), - maxwidth=400, ) else: self._soundtrack_button = None # Tweak a few navigation bits. - try: - bui.widget(edit=back_button, down_widget=svne.minusbutton) - except Exception: - logging.exception('Error wiring AudioSettingsWindow.') + if self._back_button is not None: + bui.widget(edit=self._back_button, down_widget=svne.minusbutton) + else: + spback = bui.get_special_widget('back_button') + bui.widget( + edit=svne.minusbutton, up_widget=spback, left_widget=spback + ) self._restore_state() - def _set_vr_head_relative_audio(self, val: str) -> None: - cfg = bui.app.config - cfg['VR Head Relative Audio'] = val - cfg.apply_and_commit() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() def _do_soundtracks(self) -> None: # pylint: disable=cyclic-import - from bauiv1lib.soundtrack import browser as stb + from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - # We require disk access for soundtracks; - # if we don't have it, request it. + # We require disk access for soundtracks; request it if we don't + # have it. if not bui.have_permission(bui.Permission.STORAGE): bui.getsound('ding').play() bui.screenmessage( @@ -252,34 +219,8 @@ class AudioSettingsWindow(bui.Window): ) return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - stb.SoundtrackBrowserWindow( - origin_widget=self._soundtrack_button - ).get_root_widget(), - from_window=self._root_widget, - ) - - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings import allsettings - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - allsettings.AllSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + SoundtrackBrowserWindow(origin_widget=self._soundtrack_button) ) def _save_state(self) -> None: @@ -297,8 +238,6 @@ class AudioSettingsWindow(bui.Window): sel_name = 'Soundtrack' elif sel == self._back_button: sel_name = 'Back' - elif sel == self._vr_head_relative_audio_button: - sel_name = 'VRHeadRelative' else: raise ValueError(f'unrecognized selection \'{sel}\'') assert bui.app.classic is not None @@ -319,8 +258,6 @@ class AudioSettingsWindow(bui.Window): sel = self._music_volume_numedit.minusbutton elif sel_name == 'MusicPlus': sel = self._music_volume_numedit.plusbutton - elif sel_name == 'VRHeadRelative': - sel = self._vr_head_relative_audio_button elif sel_name == 'Soundtrack': sel = self._soundtrack_button elif sel_name == 'Back': diff --git a/dist/ba_data/python/bauiv1lib/debug.py b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py similarity index 70% rename from dist/ba_data/python/bauiv1lib/debug.py rename to dist/ba_data/python/bauiv1lib/settings/benchmarks.py index e57ae07..2399601 100644 --- a/dist/ba_data/python/bauiv1lib/debug.py +++ b/dist/ba_data/python/bauiv1lib/settings/benchmarks.py @@ -5,83 +5,124 @@ from __future__ import annotations import logging -from typing import cast +from typing import cast, override import bauiv1 as bui +import bascenev1 as bs -class DebugWindow(bui.Window): - """Window for debugging internal values.""" +class BenchmarksAndStressTestsWindow(bui.MainWindow): + """Window for launching benchmarks or stress tests.""" - def __init__(self, transition: str | None = 'in_right'): + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + # pylint: disable=too-many-locals # pylint: disable=too-many-statements # pylint: disable=cyclic-import from bauiv1lib import popup - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_location('Benchmarks & Stress Tests') uiscale = bui.app.ui_v1.uiscale - self._width = width = 580 + self._width = width = 1200 if uiscale is bui.UIScale.SMALL else 580 self._height = height = ( - 350 + 900 if uiscale is bui.UIScale.SMALL else 420 if uiscale is bui.UIScale.MEDIUM else 520 ) - self._scroll_width = self._width - 100 - self._scroll_height = self._height - 120 - - self._sub_width = self._scroll_width * 0.95 - self._sub_height = 520 - self._stress_test_game_type = 'Random' self._stress_test_playlist = '__default__' self._stress_test_player_count = 8 self._stress_test_round_duration = 30 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.32 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 70, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 31 + self._scroll_bottom = yoffs - 60 - self._scroll_height + + self._sub_width = min(510.0, self._scroll_width) + self._sub_height = 520 + self._r = 'debugWindow' uiscale = bui.app.ui_v1.uiscale super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - scale=( - 2.35 + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.55 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -30) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._done_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(40, height - 67), - size=(120, 60), - scale=0.8, - autoselect=True, - label=bui.Lstr(resource='doneText'), - on_activate_call=self._done, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + if bui.app.ui_v1.uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = bui.get_special_widget('back_button') + else: + self._back_button = btn = bui.buttonwidget( + parent=self._root_widget, + position=(40, yoffs - 53), + size=(60, 60), + scale=0.8, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + bui.textwidget( parent=self._root_widget, - position=(0, height - 60), - size=(width, 30), + position=( + self._width * 0.5, + yoffs - (45 if uiscale is bui.UIScale.SMALL else 30), + ), + size=(0, 0), + maxwidth=360, + scale=0.8 if uiscale is bui.UIScale.SMALL else 1.0, text=bui.Lstr(resource=f'{self._r}.titleText'), h_align='center', - color=bui.app.ui_v1.title_color, v_align='center', - maxwidth=260, + color=bui.app.ui_v1.title_color, ) self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, size=(self._scroll_width, self._scroll_height), - position=((self._width - self._scroll_width) * 0.5, 50), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), + border_opacity=0.4, + center_small_content_horizontally=True, ) bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) @@ -102,17 +143,7 @@ class DebugWindow(bui.Window): on_activate_call=self._run_cpu_benchmark_pressed, ) bui.widget( - edit=btn, up_widget=self._done_button, left_widget=self._done_button - ) - v -= 60 - - bui.buttonwidget( - parent=self._subcontainer, - position=((self._sub_width - button_width) * 0.5, v), - size=(button_width, 60), - autoselect=True, - label=bui.Lstr(resource=f'{self._r}.runGPUBenchmarkText'), - on_activate_call=self._run_gpu_benchmark_pressed, + edit=btn, up_widget=self._back_button, left_widget=self._back_button ) v -= 60 @@ -301,7 +332,17 @@ class DebugWindow(bui.Window): label=bui.Lstr(resource=f'{self._r}.runStressTestText'), on_activate_call=self._stress_test_pressed, ) - bui.widget(btn, show_buffer_bottom=50) + bui.widget(edit=btn, show_buffer_bottom=50) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) def _stress_test_player_count_decrement(self) -> None: self._stress_test_player_count = max( @@ -344,12 +385,6 @@ class DebugWindow(bui.Window): return bui.app.classic.run_cpu_benchmark() - def _run_gpu_benchmark_pressed(self) -> None: - if bui.app.classic is None: - logging.warning('run-gpu-benchmark requires classic') - return - bui.app.classic.run_gpu_benchmark() - def _run_media_reload_benchmark_pressed(self) -> None: if bui.app.classic is None: logging.warning('run-media-reload-benchmark requires classic') @@ -357,31 +392,25 @@ class DebugWindow(bui.Window): bui.app.classic.run_media_reload_benchmark() def _stress_test_pressed(self) -> None: + from bascenev1lib.mainmenu import MainMenuActivity + if bui.app.classic is None: logging.warning('stress-test requires classic') return - bui.app.classic.run_stress_test( - playlist_type=self._stress_test_game_type, - playlist_name=cast( - str, bui.textwidget(query=self._stress_test_playlist_name_field) - ), - player_count=self._stress_test_player_count, - round_duration=self._stress_test_round_duration, - ) - bui.containerwidget(edit=self._root_widget, transition='out_right') - - def _done(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) + activity = bs.get_foreground_host_activity() + if isinstance(activity, MainMenuActivity): + bui.app.classic.run_stress_test( + playlist_type=self._stress_test_game_type, + playlist_name=cast( + str, + bui.textwidget(query=self._stress_test_playlist_name_field), + ), + player_count=self._stress_test_player_count, + round_duration=self._stress_test_round_duration, + ) + bui.containerwidget(edit=self._root_widget, transition='out_right') + else: + bui.screenmessage( + bui.Lstr(value='Already present in another activity.') + ) diff --git a/dist/ba_data/python/bauiv1lib/settings/controls.py b/dist/ba_data/python/bauiv1lib/settings/controls.py index 6a85dfd..3e6fbf6 100644 --- a/dist/ba_data/python/bauiv1lib/settings/controls.py +++ b/dist/ba_data/python/bauiv1lib/settings/controls.py @@ -4,17 +4,18 @@ from __future__ import annotations -from bauiv1lib.popup import PopupMenu +from typing import override + import bascenev1 as bs import bauiv1 as bui -class ControlsSettingsWindow(bui.Window): +class ControlsSettingsWindow(bui.MainWindow): """Top level control settings window.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # FIXME: should tidy up here. @@ -25,30 +26,24 @@ class ControlsSettingsWindow(bui.Window): self._have_selected_child = False - scale_origin: tuple[float, float] | None - - # If they provided an origin-widget, scale up from that. - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self._r = 'configControllersWindow' + uiscale = bui.app.ui_v1.uiscale app = bui.app assert app.classic is not None spacing = 50.0 button_width = 350.0 - width = 460.0 - height = 130.0 + width = 1200.0 if uiscale is bui.UIScale.SMALL else 560.0 + height = 800 if uiscale is bui.UIScale.SMALL else 400.0 + # yoffs = -60 if uiscale is bui.UIScale.SMALL else 0 space_height = spacing * 0.3 - # FIXME: should create vis settings under platform or app-adapter - # to determine whether to show this stuff; not hard code it. + buttons_height = 0.0 + + # FIXME: should create vis settings under platform or + # app-adapter to determine whether to show this stuff; not + # hard-code it. show_gamepads = False platform = app.classic.platform @@ -58,88 +53,101 @@ class ControlsSettingsWindow(bui.Window): ) if platform in ('linux', 'android', 'mac') or non_vr_windows: show_gamepads = True - height += spacing + buttons_height += spacing show_touch = False if bs.have_touchscreen_input(): show_touch = True - height += spacing + buttons_height += spacing show_space_1 = False if show_gamepads or show_touch: show_space_1 = True - height += space_height + buttons_height += space_height show_keyboard = False if bs.getinputdevice('Keyboard', '#1', doraise=False) is not None: show_keyboard = True - height += spacing + buttons_height += spacing show_keyboard_p2 = False if app.env.vr else show_keyboard if show_keyboard_p2: - height += spacing + buttons_height += spacing show_space_2 = False if show_keyboard: show_space_2 = True - height += space_height + buttons_height += space_height if bool(True): show_remote = True - height += spacing + buttons_height += spacing else: show_remote = False - # On windows (outside of oculus/vr), show an option to disable xinput. + # On windows (outside of oculus/vr), show an option to disable + # xinput. show_xinput_toggle = False if platform == 'windows' and not app.env.vr: show_xinput_toggle = True - # On mac builds, show an option to switch between generic and - # made-for-iOS/Mac systems - # (we can run into problems where devices register as one of each - # type otherwise).. - # UPDATE: We always use the apple system these days (which should - # support older controllers). So no need for a switch. - show_mac_controller_subsystem = False - # if platform == 'mac' and bui.is_xcode_build(): - # show_mac_controller_subsystem = True - - if show_mac_controller_subsystem: - height += spacing * 1.5 - if show_xinput_toggle: - height += spacing + buttons_height += spacing assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - smallscale = 1.7 if show_keyboard else 2.2 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.0 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(width - 60, screensize[0] / scale) + target_height = min(height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -10) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - scale=( - smallscale + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + self._back_button: bui.Widget | None + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back ) - ) - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - position=(35, height - 60), - size=(140, 65), - scale=0.8, - text_scale=1.2, - autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + self._back_button = None + else: + self._back_button = btn = bui.buttonwidget( + parent=self._root_widget, + position=(35, height - 60), + size=(60, 60), + scale=0.8, + text_scale=1.2, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) # We need these vars to exist even if the buttons don't. self._gamepads_button: bui.Widget | None = None @@ -150,21 +158,20 @@ class ControlsSettingsWindow(bui.Window): bui.textwidget( parent=self._root_widget, - position=(0, height - 49), - size=(width, 25), + position=( + width * 0.5, + yoffs - (52 if uiscale is bui.UIScale.SMALL else 32), + ), + maxwidth=260, + size=(0, 0), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, h_align='center', - v_align='top', - ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), + v_align='center', ) - v = height - 75 + # Roughly center the rest of our stuff. + v = height * 0.5 + buttons_height * 0.5 - 10 v -= spacing if show_touch: @@ -176,18 +183,18 @@ class ControlsSettingsWindow(bui.Window): label=bui.Lstr(resource=f'{self._r}.configureTouchText'), on_activate_call=self._do_touchscreen, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, + right_widget=bui.get_special_widget('squad_button'), + ) if not self._have_selected_child: bui.containerwidget( edit=self._root_widget, selected_child=self._touch_button ) - bui.widget( - edit=self._back_button, down_widget=self._touch_button - ) + if self._back_button is not None: + bui.widget( + edit=self._back_button, down_widget=self._touch_button + ) self._have_selected_child = True v -= spacing @@ -200,18 +207,19 @@ class ControlsSettingsWindow(bui.Window): label=bui.Lstr(resource=f'{self._r}.configureControllersText'), on_activate_call=self._do_gamepads, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, + right_widget=bui.get_special_widget('squad_button'), + ) if not self._have_selected_child: bui.containerwidget( edit=self._root_widget, selected_child=self._gamepads_button ) - bui.widget( - edit=self._back_button, down_widget=self._gamepads_button - ) + if self._back_button is not None: + bui.widget( + edit=self._back_button, + down_widget=self._gamepads_button, + ) self._have_selected_child = True v -= spacing else: @@ -232,18 +240,19 @@ class ControlsSettingsWindow(bui.Window): bui.widget( edit=self._keyboard_button, left_widget=self._keyboard_button ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, + right_widget=bui.get_special_widget('squad_button'), + ) if not self._have_selected_child: bui.containerwidget( edit=self._root_widget, selected_child=self._keyboard_button ) - bui.widget( - edit=self._back_button, down_widget=self._keyboard_button - ) + if self._back_button is not None: + bui.widget( + edit=self._back_button, + down_widget=self._keyboard_button, + ) self._have_selected_child = True v -= spacing if show_keyboard_p2: @@ -274,18 +283,19 @@ class ControlsSettingsWindow(bui.Window): bui.widget( edit=self._idevices_button, left_widget=self._idevices_button ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=btn, - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=btn, + right_widget=bui.get_special_widget('squad_button'), + ) if not self._have_selected_child: bui.containerwidget( edit=self._root_widget, selected_child=self._idevices_button ) - bui.widget( - edit=self._back_button, down_widget=self._idevices_button - ) + if self._back_button is not None: + bui.widget( + edit=self._back_button, + down_widget=self._idevices_button, + ) self._have_selected_child = True v -= spacing @@ -301,7 +311,10 @@ class ControlsSettingsWindow(bui.Window): xinput_checkbox = bui.checkboxwidget( parent=self._root_widget, - position=(100, v + 3), + position=( + width * (0.35 if uiscale is bui.UIScale.SMALL else 0.25), + v + 3, + ), size=(120, 30), value=(not bui.get_low_level_config_value('enablexinput', 1)), maxwidth=200, @@ -327,50 +340,22 @@ class ControlsSettingsWindow(bui.Window): ) v -= spacing - if show_mac_controller_subsystem: - PopupMenu( - parent=self._root_widget, - position=(260, v - 10), - width=160, - button_size=(150, 50), - scale=1.5, - choices=['Classic', 'MFi', 'Both'], - choices_display=[ - bui.Lstr(resource='macControllerSubsystemClassicText'), - bui.Lstr(resource='macControllerSubsystemMFiText'), - bui.Lstr(resource='macControllerSubsystemBothText'), - ], - current_choice=bui.app.config.resolve( - 'Mac Controller Subsystem' - ), - on_value_change_call=self._set_mac_controller_subsystem, - ) - bui.textwidget( - parent=self._root_widget, - position=(245, v + 13), - size=(0, 0), - text=bui.Lstr(resource='macControllerSubsystemTitleText'), - scale=1.0, - h_align='right', - v_align='center', - color=bui.app.ui_v1.infotextcolor, - maxwidth=180, - ) - bui.textwidget( - parent=self._root_widget, - position=(width * 0.5, v - 20), - size=(0, 0), - text=bui.Lstr(resource='macControllerSubsystemDescriptionText'), - scale=0.5, - h_align='center', - v_align='center', - color=bui.app.ui_v1.infotextcolor, - maxwidth=width * 0.8, - ) - v -= spacing * 1.5 - self._restore_state() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _set_mac_controller_subsystem(self, val: str) -> None: cfg = bui.app.config cfg['Mac Controller Subsystem'] = val @@ -380,85 +365,55 @@ class ControlsSettingsWindow(bui.Window): # pylint: disable=cyclic-import from bauiv1lib.settings.keyboard import ConfigKeyboardWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ConfigKeyboardWindow( - bs.getinputdevice('Keyboard', '#1') - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#1')) ) def _config_keyboard2(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.keyboard import ConfigKeyboardWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ConfigKeyboardWindow( - bs.getinputdevice('Keyboard', '#2') - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + ConfigKeyboardWindow(bs.getinputdevice('Keyboard', '#2')) ) def _do_mobile_devices(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.remoteapp import RemoteAppSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - RemoteAppSettingsWindow().get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(RemoteAppSettingsWindow()) def _do_gamepads(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.gamepadselect import GamepadSelectWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - GamepadSelectWindow().get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(GamepadSelectWindow()) def _do_touchscreen(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.touchscreen import TouchscreenSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - TouchscreenSettingsWindow().get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(TouchscreenSettingsWindow()) def _save_state(self) -> None: sel = self._root_widget.get_selected_child() @@ -499,21 +454,3 @@ class ControlsSettingsWindow(bui.Window): else self._back_button ) bui.containerwidget(edit=self._root_widget, selected_child=sel) - - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.allsettings import AllSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AllSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/devtools.py b/dist/ba_data/python/bauiv1lib/settings/devtools.py index 2db6e9c..93a381b 100644 --- a/dist/ba_data/python/bauiv1lib/settings/devtools.py +++ b/dist/ba_data/python/bauiv1lib/settings/devtools.py @@ -4,6 +4,8 @@ from __future__ import annotations +from typing import override + import babase import bauiv1 as bui from bauiv1lib.popup import PopupMenu @@ -11,79 +13,85 @@ from bauiv1lib.confirm import ConfirmWindow from bauiv1lib.config import ConfigCheckBox -class DevToolsWindow(bui.Window): +class DevToolsWindow(bui.MainWindow): """Window for accessing modding tools.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): app = bui.app assert app.classic is not None - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - uiscale = app.ui_v1.uiscale - self._width = 970.0 if uiscale is bui.UIScale.SMALL else 670.0 - x_inset = 150 if uiscale is bui.UIScale.SMALL else 0 + self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 670.0 self._height = ( - 390.0 + 800 if uiscale is bui.UIScale.SMALL else 450.0 if uiscale is bui.UIScale.MEDIUM else 520.0 ) - self._spacing = 32 - top_extra = 10 if uiscale is bui.UIScale.SMALL else 0 - self._scroll_width = self._width - (100 + 2 * x_inset) - self._scroll_height = self._height - 115.0 + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.13 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 80, screensize[0] / scale) + target_height = min(self._height - 90, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 35 + self._scroll_bottom = yoffs - 64 - self._scroll_height + self._sub_width = self._scroll_width * 0.95 - self._sub_height = 350.0 + self._sub_height = 300.0 super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.06 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -25) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) self._r = 'settingsDevTools' - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._do_back + edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = bui.buttonwidget( parent=self._root_widget, - position=(53 + x_inset, self._height - 60), + position=(53, yoffs - 50), size=(140, 60), scale=0.8, autoselect=True, label=bui.Lstr(resource='backText'), button_type='back', - on_activate_call=self._do_back, + on_activate_call=self.main_window_back, ) bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button @@ -91,8 +99,12 @@ class DevToolsWindow(bui.Window): self._title_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 48), + position=( + self._width * 0.5, + yoffs - (60 if uiscale is bui.UIScale.SMALL else 42), + ), size=(0, 25), + scale=(0.8 if uiscale is bui.UIScale.SMALL else 1.0), maxwidth=self._width - 200, text=bui.Lstr(resource='settingsWindowAdvanced.devToolsText'), color=app.ui_v1.title_color, @@ -110,11 +122,15 @@ class DevToolsWindow(bui.Window): self._scrollwidget = bui.scrollwidget( parent=self._root_widget, - position=(50 + x_inset, 50), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), simple_culling_v=20.0, highlight=False, size=(self._scroll_width, self._scroll_height), selection_loops_to_parent=True, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) self._subcontainer = bui.containerwidget( @@ -169,36 +185,50 @@ class DevToolsWindow(bui.Window): ), ) - v -= self._spacing * 2.5 - bui.textwidget( - parent=self._subcontainer, - position=(170, v + 10), - size=(0, 0), - text=bui.Lstr(resource='uiScaleText'), - color=app.ui_v1.title_color, - h_align='center', - v_align='center', - ) + # Currently this is not wired up. The current official way to test + # UIScales is either to use the switcher in the dev-console or to + # set the BA_UI_SCALE env var. + if bool(False): + v -= self._spacing * 2.5 + bui.textwidget( + parent=self._subcontainer, + position=(170, v + 10), + size=(0, 0), + text=bui.Lstr(resource='uiScaleText'), + color=app.ui_v1.title_color, + h_align='center', + v_align='center', + ) - PopupMenu( - parent=self._subcontainer, - position=(230, v - 20), - button_size=(200.0, 60.0), - width=100.0, - choices=[ - 'auto', - 'small', - 'medium', - 'large', - ], - choices_display=[ - bui.Lstr(resource='autoText'), - bui.Lstr(resource='sizeSmallText'), - bui.Lstr(resource='sizeMediumText'), - bui.Lstr(resource='sizeLargeText'), - ], - current_choice=app.config.get('UI Scale', 'auto'), - on_value_change_call=self._set_uiscale, + PopupMenu( + parent=self._subcontainer, + position=(230, v - 20), + button_size=(200.0, 60.0), + width=100.0, + choices=[ + 'auto', + 'small', + 'medium', + 'large', + ], + choices_display=[ + bui.Lstr(resource='autoText'), + bui.Lstr(resource='sizeSmallText'), + bui.Lstr(resource='sizeMediumText'), + bui.Lstr(resource='sizeLargeText'), + ], + current_choice=app.config.get('UI Scale', 'auto'), + on_value_change_call=self._set_uiscale, + ) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) ) def _set_uiscale(self, val: str) -> None: @@ -210,19 +240,3 @@ class DevToolsWindow(bui.Window): bui.Lstr(resource='settingsWindowAdvanced.mustRestartText'), color=(1.0, 0.5, 0.0), ) - - def _do_back(self) -> None: - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepad.py b/dist/ba_data/python/bauiv1lib/settings/gamepad.py index 390af52..d8b5de6 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepad.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepad.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from bauiv1lib.popup import PopupMenuWindow import bascenev1 as bs @@ -14,29 +14,32 @@ import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Callable + from bauiv1lib.popup import PopupWindow -class GamepadSettingsWindow(bui.Window): +class GamepadSettingsWindow(bui.MainWindow): """Window for configuring a gamepad.""" # pylint: disable=too-many-public-methods def __init__( self, - gamepad: bs.InputDevice, - is_main_menu: bool = True, - transition: str = 'in_right', + inputdevice: bs.InputDevice, + *, + modal: bool = False, + transition: str | None = 'in_right', transition_out: str = 'out_right', + origin_widget: bui.Widget | None = None, settings: dict | None = None, ): - self._input = gamepad + self._inputdevice = inputdevice # If our input-device went away, just return an empty zombie. - if not self._input: + if not self._inputdevice: return - self._name = self._input.name + self._name = self._inputdevice.name self._r = 'configGamepadWindow' self._transition_out = transition_out @@ -44,7 +47,7 @@ class GamepadSettingsWindow(bui.Window): # We're a secondary gamepad if supplied with settings. self._is_secondary = settings is not None self._ext = '_B' if self._is_secondary else '' - self._is_main_menu = is_main_menu + self._modal = modal self._displayname = self._name self._width = 700 if self._is_secondary else 730 self._height = 440 if self._is_secondary else 450 @@ -55,23 +58,45 @@ class GamepadSettingsWindow(bui.Window): root_widget=bui.containerwidget( size=(self._width, self._height), scale=( - 1.63 + 1.4 if uiscale is bui.UIScale.SMALL - else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=( - (-20, -16) if uiscale is bui.UIScale.SMALL else (0, 0) + (0, -10) if uiscale is bui.UIScale.SMALL else (0, 0) ), - transition=transition, - ) + ), + transition=transition, + origin_widget=origin_widget, ) self._settings: dict[str, int] = {} if not self._is_secondary: self._get_config_mapping() + # Don't ask to config joysticks while we're in here. self._rebuild_ui() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull stuff out of self here; if we do it in the lambda we keep + # self alive which we don't want. + assert not self._is_secondary + assert not self._modal + + inputdevice = self._inputdevice + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + inputdevice=inputdevice, + transition=transition, + origin_widget=origin_widget, + ) + ) + def _get_config_mapping(self, default: bool = False) -> None: for button in [ 'buttonJump', @@ -134,7 +159,7 @@ class GamepadSettingsWindow(bui.Window): ]: assert bui.app.classic is not None val = bui.app.classic.get_input_device_mapped_value( - self._input, button, default + self._inputdevice, button, default ) if val != -1: self._settings[button] = val @@ -416,7 +441,7 @@ class GamepadSettingsWindow(bui.Window): def get_input(self) -> bs.InputDevice: """(internal)""" - return self._input + return self._inputdevice def _do_advanced(self) -> None: # pylint: disable=cyclic-import @@ -532,8 +557,8 @@ class GamepadSettingsWindow(bui.Window): def show_secondary_editor(self) -> None: """(internal)""" GamepadSettingsWindow( - self._input, - is_main_menu=False, + self._inputdevice, + modal=True, settings=self._settings, transition='in_scale', transition_out='out_scale', @@ -559,16 +584,16 @@ class GamepadSettingsWindow(bui.Window): assert isinstance(sval2, (int, type(None))) if sval1 is not None and sval2 is not None: return ( - self._input.get_axis_name(sval1) + self._inputdevice.get_axis_name(sval1) + ' / ' - + self._input.get_axis_name(sval2) + + self._inputdevice.get_axis_name(sval2) ) return bui.Lstr(resource=f'{self._r}.unsetText') # If they're looking for triggers. if control in ['triggerRun1' + self._ext, 'triggerRun2' + self._ext]: if control in self._settings: - return self._input.get_axis_name(self._settings[control]) + return self._inputdevice.get_axis_name(self._settings[control]) return bui.Lstr(resource=f'{self._r}.unsetText') # Dead-zone. @@ -589,7 +614,9 @@ class GamepadSettingsWindow(bui.Window): # If *any* dpad buttons are assigned, show only button assignments. if any(b in self._settings for b in dpad_buttons): if control in self._settings: - return self._input.get_button_name(self._settings[control]) + return self._inputdevice.get_button_name( + self._settings[control] + ) return bui.Lstr(resource=f'{self._r}.unsetText') # No dpad buttons - show the dpad number for all 4. @@ -614,7 +641,7 @@ class GamepadSettingsWindow(bui.Window): # Other buttons. if control in self._settings: - return self._input.get_button_name(self._settings[control]) + return self._inputdevice.get_button_name(self._settings[control]) return bui.Lstr(resource=f'{self._r}.unsetText') def _gamepad_event( @@ -691,7 +718,7 @@ class GamepadSettingsWindow(bui.Window): # Now launch the up/down listener. AwaitGamepadInputWindow( - self._input, + self._inputdevice, 'analogStickUD' + ext, self._gamepad_event, bui.Lstr(resource=f'{self._r}.pressUpDownText'), @@ -739,6 +766,7 @@ class GamepadSettingsWindow(bui.Window): color: tuple[float, float, float], texture: bui.Texture, button: str, + *, scale: float = 1.0, message: bui.Lstr | None = None, message2: bui.Lstr | None = None, @@ -780,7 +808,7 @@ class GamepadSettingsWindow(bui.Window): edit=btn, on_activate_call=bui.Call( AwaitGamepadInputWindow, - self._input, + self._inputdevice, button, self._gamepad_event, message, @@ -792,21 +820,16 @@ class GamepadSettingsWindow(bui.Window): return btn def _cancel(self) -> None: - from bauiv1lib.settings.controls import ControlsSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if self._is_main_menu: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ControlsSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, + if self._modal: + # no-op if our underlying widget is dead or on its way out. + if not self._root_widget or self._root_widget.transitioning_out: + return + bui.containerwidget( + edit=self._root_widget, transition=self._transition_out ) + else: + self.main_window_back() def _reset(self) -> None: from bauiv1lib.confirm import ConfirmWindow @@ -894,18 +917,20 @@ class GamepadSettingsWindow(bui.Window): if not self._root_widget or self._root_widget.transitioning_out: return - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - # If we're a secondary editor we just go away (we were editing our # parent's settings dict). if self._is_secondary: + assert self._modal + bui.containerwidget( + edit=self._root_widget, transition=self._transition_out + ) return assert self._settings is not None - if self._input: - dst = classic.get_input_device_config(self._input, default=True) + if self._inputdevice: + dst = classic.get_input_device_config( + self._inputdevice, default=True + ) dst2: dict[str, Any] = dst[0][dst[1]] dst2.clear() @@ -916,7 +941,7 @@ class GamepadSettingsWindow(bui.Window): # If we're allowed to phone home, send this config so we can # generate more defaults in the future. - inputhash = classic.get_input_device_map_hash(self._input) + inputhash = classic.get_input_device_map_hash(self._inputdevice) classic.master_server_v1_post( 'controllerConfig', { @@ -933,14 +958,13 @@ class GamepadSettingsWindow(bui.Window): else: bui.getsound('error').play() - if self._is_main_menu: - from bauiv1lib.settings.controls import ControlsSettingsWindow - - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ControlsSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, + if self._modal: + bui.containerwidget( + edit=self._root_widget, transition=self._transition_out ) + else: + assert self.main_window_has_control() + self.main_window_back() class AwaitGamepadInputWindow(bui.Window): @@ -954,6 +978,7 @@ class AwaitGamepadInputWindow(bui.Window): message: bui.Lstr | None = None, message2: bui.Lstr | None = None, ): + # pylint: disable=too-many-positional-arguments if message is None: print('AwaitGamepadInputWindow message is None!') # Shouldn't get here. diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py b/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py index 7ac9b80..ab06216 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepadadvanced.py @@ -40,7 +40,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): size=(self._width, self._height), scale=1.06 * ( - 1.85 + 1.6 if uiscale is bui.UIScale.SMALL else 1.35 if uiscale is bui.UIScale.MEDIUM else 1.0 ), @@ -99,7 +99,6 @@ class GamepadAdvancedSettingsWindow(bui.Window): ), size=(self._scroll_width, self._scroll_height), claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) self._subcontainer = bui.containerwidget( @@ -107,7 +106,6 @@ class GamepadAdvancedSettingsWindow(bui.Window): size=(self._sub_width, self._sub_height), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) bui.containerwidget( @@ -473,6 +471,7 @@ class GamepadAdvancedSettingsWindow(bui.Window): name: bui.Lstr, control: str, position: tuple[float, float], + *, min_val: float = 0.0, max_val: float = 100.0, increment: float = 1.0, diff --git a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py index ab0e7e1..79c4abb 100644 --- a/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py +++ b/dist/ba_data/python/bauiv1lib/settings/gamepadselect.py @@ -4,8 +4,7 @@ from __future__ import annotations -import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bascenev1 as bs import bauiv1 as bui @@ -14,98 +13,14 @@ if TYPE_CHECKING: from typing import Any -def gamepad_configure_callback(event: dict[str, Any]) -> None: - """Respond to a gamepad button press during config selection.""" - from bauiv1lib.settings import gamepad - - # Ignore all but button-presses. - if event['type'] not in ['BUTTONDOWN', 'HATMOTION']: - return - bs.release_gamepad_input() - assert bui.app.classic is not None - try: - bui.app.ui_v1.clear_main_menu_window(transition='out_left') - except Exception: - logging.exception('Error transitioning out main_menu_window.') - bui.getsound('activateBeep').play() - bui.getsound('swish').play() - device = event['input_device'] - assert isinstance(device, bs.InputDevice) - if device.allows_configuring: - bui.app.ui_v1.set_main_menu_window( - gamepad.GamepadSettingsWindow(device).get_root_widget(), - from_window=None, - ) - else: - width = 700 - height = 200 - button_width = 80 - uiscale = bui.app.ui_v1.uiscale - dlg = bui.containerwidget( - scale=( - 1.7 - if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - size=(width, height), - transition='in_right', - ) - bui.app.ui_v1.set_main_menu_window(dlg, from_window=None) - - if device.allows_configuring_in_system_settings: - msg = bui.Lstr( - resource='configureDeviceInSystemSettingsText', - subs=[('${DEVICE}', device.name)], - ) - elif device.is_controller_app: - msg = bui.Lstr( - resource='bsRemoteConfigureInAppText', - subs=[('${REMOTE_APP_NAME}', bui.get_remote_app_name())], - ) - else: - msg = bui.Lstr( - resource='cantConfigureDeviceText', - subs=[('${DEVICE}', device.name)], - ) - bui.textwidget( - parent=dlg, - position=(0, height - 80), - size=(width, 25), - text=msg, - scale=0.8, - h_align='center', - v_align='top', - ) - - def _ok() -> None: - from bauiv1lib.settings import controls - - # no-op if our underlying widget is dead or on its way out. - if not dlg or dlg.transitioning_out: - return - - bui.containerwidget(edit=dlg, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - controls.ControlsSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=dlg, - ) - - bui.buttonwidget( - parent=dlg, - position=((width - button_width) / 2, 20), - size=(button_width, 60), - label=bui.Lstr(resource='okText'), - on_activate_call=_ok, - ) - - -class GamepadSelectWindow(bui.Window): +class GamepadSelectWindow(bui.MainWindow): """Window for selecting a gamepad to configure.""" - def __init__(self) -> None: + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ) -> None: from typing import cast width = 480 @@ -123,8 +38,9 @@ class GamepadSelectWindow(bui.Window): else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ), size=(width, height), - transition='in_right', - ) + ), + transition=transition, + origin_widget=origin_widget, ) btn = bui.buttonwidget( @@ -134,10 +50,11 @@ class GamepadSelectWindow(bui.Window): label=bui.Lstr(resource='backText'), button_type='back', scale=0.8, - on_activate_call=self._back, + on_activate_call=self.main_window_back, ) - # Let's not have anything selected by default; its misleading looking - # for the controller getting configured. + + # Let's not have anything selected by default; its misleading + # looking for the controller getting configured. bui.containerwidget( edit=self._root_widget, cancel_button=btn, @@ -188,21 +105,133 @@ class GamepadSelectWindow(bui.Window): v_align='top', ) - bs.capture_gamepad_input(gamepad_configure_callback) + bs.capture_gamepad_input(bui.WeakCall(self.gamepad_configure_callback)) - def _back(self) -> None: - from bauiv1lib.settings import controls + def __del__(self) -> None: + bs.release_gamepad_input() - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + def gamepad_configure_callback(self, event: dict[str, Any]) -> None: + """Respond to a gamepad button press during config selection.""" + from bauiv1lib.settings.gamepad import GamepadSettingsWindow + + if not self.main_window_has_control(): return + # Ignore all but button-presses. + if event['type'] not in ['BUTTONDOWN', 'HATMOTION']: + return bs.release_gamepad_input() - bui.containerwidget(edit=self._root_widget, transition='out_right') + assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - controls.ControlsSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=self._root_widget, + + bui.getsound('activateBeep').play() + bui.getsound('swish').play() + device = event['input_device'] + assert isinstance(device, bs.InputDevice) + + # No matter where we redirect to, we want their back + # functionality to skip over us and go to our parent. + assert self.main_window_back_state is not None + back_state = self.main_window_back_state + + if device.allows_configuring: + self.main_window_replace( + GamepadSettingsWindow(device), back_state=back_state + ) + else: + self.main_window_replace( + _NotConfigurableWindow(device), back_state=back_state + ) + + +class _NotConfigurableWindow(bui.MainWindow): + + def __init__( + self, + device: bs.InputDevice, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ) -> None: + width = 700 + height = 200 + button_width = 80 + uiscale = bui.app.ui_v1.uiscale + super().__init__( + root_widget=bui.containerwidget( + scale=( + 1.7 + if uiscale is bui.UIScale.SMALL + else (1.4 if uiscale is bui.UIScale.MEDIUM else 1.0) + ), + size=(width, height), + ), + transition=transition, + origin_widget=origin_widget, + ) + self.device = device + + if device.allows_configuring_in_system_settings: + msg = bui.Lstr( + resource='configureDeviceInSystemSettingsText', + subs=[('${DEVICE}', device.name)], + ) + elif device.is_controller_app: + msg = bui.Lstr( + resource='bsRemoteConfigureInAppText', + subs=[ + ( + '${REMOTE_APP_NAME}', + bui.get_remote_app_name(), + ) + ], + ) + else: + msg = bui.Lstr( + resource='cantConfigureDeviceText', + subs=[('${DEVICE}', device.name)], + ) + bui.textwidget( + parent=self._root_widget, + position=(0, height - 80), + size=(width, 25), + text=msg, + scale=0.8, + h_align='center', + v_align='top', + ) + + btn = bui.buttonwidget( + parent=self._root_widget, + position=((width - button_width) / 2, 20), + size=(button_width, 60), + label=bui.Lstr(resource='okText'), + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull stuff out of self here; if we do it in the lambda we'll + # keep self alive which we don't want. + device = self.device + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + device=device, + transition=transition, + origin_widget=origin_widget, + ) ) diff --git a/dist/ba_data/python/bauiv1lib/settings/graphics.py b/dist/ba_data/python/bauiv1lib/settings/graphics.py index 650d3cd..5c85d50 100644 --- a/dist/ba_data/python/bauiv1lib/settings/graphics.py +++ b/dist/ba_data/python/bauiv1lib/settings/graphics.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override from bauiv1lib.popup import PopupMenu from bauiv1lib.config import ConfigCheckBox @@ -14,28 +14,18 @@ if TYPE_CHECKING: from typing import Any -class GraphicsSettingsWindow(bui.Window): +class GraphicsSettingsWindow(bui.MainWindow): """Window for graphics settings.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements - # if they provided an origin-widget, scale up from that - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self._r = 'graphicsSettingsWindow' app = bui.app assert app.classic is not None @@ -43,8 +33,8 @@ class GraphicsSettingsWindow(bui.Window): spacing = 32 self._have_selected_child = False uiscale = app.ui_v1.uiscale - width = 450.0 - height = 302.0 + width = 1200 if uiscale is bui.UIScale.SMALL else 450.0 + height = 900 if uiscale is bui.UIScale.SMALL else 302.0 self._max_fps_dirty = False self._last_max_fps_set_time = bui.apptime() self._last_max_fps_str = '' @@ -69,52 +59,80 @@ class GraphicsSettingsWindow(bui.Window): app.classic.platform == 'android' and app.classic.subplatform == 'cardboard' ) - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - base_scale = ( - 2.0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.9 if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 ) - popup_menu_scale = base_scale * 1.2 - v = height - 50 - v -= spacing * 1.15 + popup_menu_scale = scale * 1.2 + + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(width - 80, screensize[0] / scale) + target_height = min(height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition=transition, - scale_origin_stack_offset=scale_origin, - scale=base_scale, - stack_offset=( - (0, -30) if uiscale is bui.UIScale.SMALL else (0, 0) + scale=scale, + toolbar_visibility=( + 'menu_minimal' + if uiscale is bui.UIScale.SMALL + else 'menu_full' ), + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + # Center most of our content in the middle of the window. + v = height * 0.5 + 85 + h_offs = width * 0.5 - 220 + + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + back_button = None + else: + back_button = bui.buttonwidget( + parent=self._root_widget, + position=(35, yoffs - 50), + size=(60, 60), + scale=0.8, + text_scale=1.2, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=back_button ) - ) - - back_button = bui.buttonwidget( - parent=self._root_widget, - position=(35, height - 50), - # size=(120, 60), - size=(60, 60), - scale=0.8, - text_scale=1.2, - autoselect=True, - label=bui.charstr(bui.SpecialChar.BACK), - button_type='backSmall', - on_activate_call=self._back, - ) - - bui.containerwidget(edit=self._root_widget, cancel_button=back_button) bui.textwidget( parent=self._root_widget, - position=(0, height - 44), - size=(width, 25), + position=( + width * 0.5, + yoffs - (53 if uiscale is bui.UIScale.SMALL else 25), + ), + size=(0, 0), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, h_align='center', - v_align='top', + v_align='center', ) self._fullscreen_checkbox: bui.Widget | None = None @@ -135,7 +153,7 @@ class GraphicsSettingsWindow(bui.Window): ) self._fullscreen_checkbox = bui.checkboxwidget( parent=self._root_widget, - position=(100, v), + position=(h_offs + 100, v), value=bui.fullscreen_control_get(), on_value_change_call=bui.fullscreen_control_set, maxwidth=250, @@ -157,7 +175,7 @@ class GraphicsSettingsWindow(bui.Window): # Quality bui.textwidget( parent=self._root_widget, - position=(60, v), + position=(h_offs + 60, v), size=(160, 25), text=bui.Lstr(resource=f'{self._r}.visualsText'), color=bui.app.ui_v1.heading_color, @@ -168,7 +186,7 @@ class GraphicsSettingsWindow(bui.Window): ) PopupMenu( parent=self._root_widget, - position=(60, v - 50), + position=(h_offs + 60, v - 50), width=150, scale=popup_menu_scale, choices=['Auto', 'Higher', 'High', 'Medium', 'Low'], @@ -191,7 +209,7 @@ class GraphicsSettingsWindow(bui.Window): # Texture controls bui.textwidget( parent=self._root_widget, - position=(230, v), + position=(h_offs + 230, v), size=(160, 25), text=bui.Lstr(resource=f'{self._r}.texturesText'), color=bui.app.ui_v1.heading_color, @@ -202,7 +220,7 @@ class GraphicsSettingsWindow(bui.Window): ) textures_popup = PopupMenu( parent=self._root_widget, - position=(230, v - 50), + position=(h_offs + 230, v - 50), width=150, scale=popup_menu_scale, choices=['Auto', 'High', 'Medium', 'Low'], @@ -215,15 +233,12 @@ class GraphicsSettingsWindow(bui.Window): current_choice=bui.app.config.resolve('Texture Quality'), on_value_change_call=self._set_textures, ) - if bui.app.ui_v1.use_toolbars: - bui.widget( - edit=textures_popup.get_button(), - right_widget=bui.get_special_widget('party_button'), - ) + bui.widget( + edit=textures_popup.get_button(), + right_widget=bui.get_special_widget('squad_button'), + ) v -= 80 - h_offs = 0 - resolution_popup: PopupMenu | None = None if show_resolution: @@ -317,7 +332,7 @@ class GraphicsSettingsWindow(bui.Window): if show_vsync: bui.textwidget( parent=self._root_widget, - position=(230, v), + position=(h_offs + 230, v), size=(160, 25), text=bui.Lstr(resource=f'{self._r}.verticalSyncText'), color=bui.app.ui_v1.heading_color, @@ -328,7 +343,7 @@ class GraphicsSettingsWindow(bui.Window): ) vsync_popup = PopupMenu( parent=self._root_widget, - position=(230, v - 50), + position=(h_offs + 230, v - 50), width=150, scale=popup_menu_scale, choices=['Auto', 'Always', 'Never'], @@ -358,7 +373,7 @@ class GraphicsSettingsWindow(bui.Window): v -= 5 bui.textwidget( parent=self._root_widget, - position=(155, v + 10), + position=(h_offs + 155, v + 10), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.maxFPSText'), color=bui.app.ui_v1.heading_color, @@ -372,7 +387,7 @@ class GraphicsSettingsWindow(bui.Window): self._last_max_fps_str = max_fps_str self._max_fps_text = bui.textwidget( parent=self._root_widget, - position=(170, v - 5), + position=(h_offs + 170, v - 5), size=(105, 30), text=max_fps_str, max_chars=5, @@ -395,7 +410,7 @@ class GraphicsSettingsWindow(bui.Window): fpsc = ConfigCheckBox( parent=self._root_widget, - position=(69, v - 6), + position=(h_offs + 69, v - 6), size=(210, 30), scale=0.86, configkey='Show FPS', @@ -415,7 +430,7 @@ class GraphicsSettingsWindow(bui.Window): if show_tv_mode: tvc = ConfigCheckBox( parent=self._root_widget, - position=(240, v - 6), + position=(h_offs + 240, v - 6), size=(210, 30), scale=0.86, configkey='TV Border', @@ -433,28 +448,20 @@ class GraphicsSettingsWindow(bui.Window): 0.25, bui.WeakCall(self._update_controls), repeat=True ) - def _back(self) -> None: - from bauiv1lib.settings import allsettings + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - # Applying max-fps takes a few moments. Apply if it hasn't been - # yet. + @override + def on_main_window_close(self) -> None: self._apply_max_fps() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - allsettings.AllSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=self._root_widget, - ) - def _set_quality(self, quality: str) -> None: cfg = bui.app.config cfg['Graphics Quality'] = quality diff --git a/dist/ba_data/python/bauiv1lib/settings/keyboard.py b/dist/ba_data/python/bauiv1lib/settings/keyboard.py index 9bda0fb..0225a6f 100644 --- a/dist/ba_data/python/bauiv1lib/settings/keyboard.py +++ b/dist/ba_data/python/bauiv1lib/settings/keyboard.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from bauiv1lib.popup import PopupMenuWindow import bauiv1 as bui @@ -12,13 +12,19 @@ import bascenev1 as bs if TYPE_CHECKING: from typing import Any + from bauiv1lib.popup import PopupWindow -class ConfigKeyboardWindow(bui.Window): +class ConfigKeyboardWindow(bui.MainWindow): """Window for configuring keyboards.""" - def __init__(self, c: bs.InputDevice, transition: str = 'in_right'): + def __init__( + self, + c: bs.InputDevice, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): self._r = 'configKeyboardWindow' self._input = c self._name = self._input.name @@ -39,13 +45,15 @@ class ConfigKeyboardWindow(bui.Window): root_widget=bui.containerwidget( size=(self._width, self._height), scale=( - 1.6 + 1.4 if uiscale is bui.UIScale.SMALL else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 ), stack_offset=(0, 5) if uiscale is bui.UIScale.SMALL else (0, 0), transition=transition, - ) + ), + transition=transition, + origin_widget=origin_widget, ) self._settings: dict[str, int] = {} @@ -53,6 +61,23 @@ class ConfigKeyboardWindow(bui.Window): self._rebuild_ui() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull things from self here; if we do it within the lambda + # we'll keep self alive which is bad. + inputdevice = self._input + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + c=inputdevice, + ) + ) + def _get_config_mapping(self, default: bool = False) -> None: for button in [ 'buttonJump', @@ -87,7 +112,7 @@ class ConfigKeyboardWindow(bui.Window): size=(170, 60), label=bui.Lstr(resource='cancelText'), scale=0.9, - on_activate_call=self._cancel, + on_activate_call=self.main_window_back, ) save_button = bui.buttonwidget( parent=self._root_widget, @@ -246,6 +271,7 @@ class ConfigKeyboardWindow(bui.Window): button: str, scale: float = 1.0, ) -> None: + # pylint: disable=too-many-positional-arguments base_size = 79 btn = bui.buttonwidget( parent=self._root_widget, @@ -287,20 +313,6 @@ class ConfigKeyboardWindow(bui.Window): bui.pushcall(doit) - def _cancel(self) -> None: - from bauiv1lib.settings.controls import ControlsSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - ControlsSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - def _reset(self) -> None: from bauiv1lib.confirm import ConfirmWindow @@ -366,17 +378,16 @@ class ConfigKeyboardWindow(bui.Window): """Called when the popup is closing.""" def _save(self) -> None: - from bauiv1lib.settings.controls import ControlsSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return assert bui.app.classic is not None - bui.containerwidget(edit=self._root_widget, transition='out_right') bui.getsound('gunCocking').play() - # There's a chance the device disappeared; handle that gracefully. + # There's a chance the device disappeared; handle that + # gracefully. if not self._input: return @@ -391,8 +402,8 @@ class ConfigKeyboardWindow(bui.Window): if val != -1: dst2[key] = val - # Send this config to the master-server so we can generate - # more defaults in the future. + # Send this config to the master-server so we can generate more + # defaults in the future. if bui.app.classic is not None: bui.app.classic.master_server_v1_post( 'controllerConfig', @@ -405,10 +416,8 @@ class ConfigKeyboardWindow(bui.Window): }, ) bui.app.config.apply_and_commit() - bui.app.ui_v1.set_main_menu_window( - ControlsSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) + + self.main_window_back() class AwaitKeyboardInputWindow(bui.Window): diff --git a/dist/ba_data/python/bauiv1lib/settings/nettesting.py b/dist/ba_data/python/bauiv1lib/settings/nettesting.py index 2e7f7e8..4d36f5e 100644 --- a/dist/ba_data/python/bauiv1lib/settings/nettesting.py +++ b/dist/ba_data/python/bauiv1lib/settings/nettesting.py @@ -8,7 +8,7 @@ import time import copy import weakref from threading import Thread -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from efro.error import CleanError from bauiv1lib.settings.testing import TestingWindow @@ -22,40 +22,94 @@ if TYPE_CHECKING: MAX_TEST_SECONDS = 60 * 2 -class NetTestingWindow(bui.Window): +class NetTestingWindow(bui.MainWindow): """Window that runs a networking test suite to help diagnose issues.""" - def __init__(self, transition: str = 'in_right'): - self._width = 820 - self._height = 500 + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): + uiscale = bui.app.ui_v1.uiscale + self._width = 1200 if uiscale is bui.UIScale.SMALL else 820 + self._height = ( + 800 + if uiscale is bui.UIScale.SMALL + else 550 if uiscale is bui.UIScale.MEDIUM else 650 + ) + self._printed_lines: list[str] = [] assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.75 + if uiscale is bui.UIScale.SMALL + else 1.0 if uiscale is bui.UIScale.MEDIUM else 0.75 + ) + + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 90, screensize[0] / scale) + target_height = min(self._height - 90, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + scroll_width = target_width + scroll_height = target_height - 52 + scroll_bottom = yoffs - 82 - scroll_height + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - scale=( - 1.56 + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.2 if uiscale is bui.UIScale.MEDIUM else 0.8 + else 'menu_full' ), - stack_offset=(0.0, -7 if uiscale is bui.UIScale.SMALL else 0.0), - transition=transition, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + self._back_button: bui.Widget | None + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + self._back_button = None + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(46, yoffs - 77), + size=(60, 60), + scale=0.9, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button ) - ) - self._done_button = bui.buttonwidget( - parent=self._root_widget, - position=(40, self._height - 77), - size=(120, 60), - scale=0.8, - autoselect=True, - label=bui.Lstr(resource='doneText'), - on_activate_call=self._done, - ) + # Avoid squads button on small mode. + # xinset = -50 if uiscale is bui.UIScale.SMALL else 0 + + xextra = -80 if uiscale is bui.UIScale.SMALL else 0 self._copy_button = bui.buttonwidget( parent=self._root_widget, - position=(self._width - 200, self._height - 77), + position=( + self._width * 0.5 + scroll_width * 0.5 - 210 + 80 + xextra, + yoffs - 79, + ), size=(100, 60), scale=0.8, autoselect=True, @@ -65,7 +119,10 @@ class NetTestingWindow(bui.Window): self._settings_button = bui.buttonwidget( parent=self._root_widget, - position=(self._width - 100, self._height - 77), + position=( + self._width * 0.5 + scroll_width * 0.5 - 110 + 80 + xextra, + yoffs - 77, + ), size=(60, 60), scale=0.8, autoselect=True, @@ -73,31 +130,27 @@ class NetTestingWindow(bui.Window): on_activate_call=self._show_val_testing, ) - twidth = self._width - 450 bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 55), + position=(self._width * 0.5, yoffs - 55), size=(0, 0), text=bui.Lstr(resource='settingsWindowAdvanced.netTestingText'), color=(0.8, 0.8, 0.8, 1.0), h_align='center', v_align='center', - maxwidth=twidth, + maxwidth=250, ) self._scroll = bui.scrollwidget( parent=self._root_widget, - position=(50, 50), - size=(self._width - 100, self._height - 140), + size=(scroll_width, scroll_height), + position=(self._width * 0.5 - scroll_width * 0.5, scroll_bottom), capture_arrows=True, autoselect=True, + border_opacity=0.4, ) self._rows = bui.columnwidget(parent=self._scroll) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._done_button - ) - # Now kick off the tests. # Pass a weak-ref to this window so we don't keep it alive # if we back out before it completes. Also set is as daemon @@ -107,6 +160,16 @@ class NetTestingWindow(bui.Window): target=bui.Call(_run_diagnostics, weakref.ref(self)), ).start() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + def print(self, text: str, color: tuple[float, float, float]) -> None: """Print text to our console thingie.""" for line in text.splitlines(): @@ -134,30 +197,11 @@ class NetTestingWindow(bui.Window): def _show_val_testing(self) -> None: assert bui.app.classic is not None - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - bui.app.ui_v1.set_main_menu_window( - NetValTestingWindow().get_root_widget(), - from_window=self._root_widget, - ) - bui.containerwidget(edit=self._root_widget, transition='out_left') - - def _done(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - bui.containerwidget(edit=self._root_widget, transition='out_right') + self.main_window_replace(get_net_val_testing_window()) def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None: @@ -449,27 +493,48 @@ def _test_nearby_zone_ping(nearest_zone: tuple[str, float] | None) -> None: raise RuntimeError('Ping too high.') -class NetValTestingWindow(TestingWindow): - """Window to test network related settings.""" +def get_net_val_testing_window() -> TestingWindow: + """Create a window for testing net values.""" + entries = [ + {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0}, + { + 'name': 'delaySampling', + 'label': 'Delay Sampling', + 'increment': 1.0, + }, + { + 'name': 'dynamicsSyncTime', + 'label': 'Dynamics Sync Time', + 'increment': 10, + }, + {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1}, + ] + return TestingWindow( + title=bui.Lstr(resource='settingsWindowAdvanced.netTestingText'), + entries=entries, + ) - def __init__(self, transition: str = 'in_right'): - entries = [ - {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0}, - { - 'name': 'delaySampling', - 'label': 'Delay Sampling', - 'increment': 1.0, - }, - { - 'name': 'dynamicsSyncTime', - 'label': 'Dynamics Sync Time', - 'increment': 10, - }, - {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1}, - ] - super().__init__( - title=bui.Lstr(resource='settingsWindowAdvanced.netTestingText'), - entries=entries, - transition=transition, - back_call=lambda: NetTestingWindow(transition='in_left'), - ) + +# class NetValTestingWindow(TestingWindow): +# """Window to test network related settings.""" + +# def __init__(self, transition: str = 'in_right'): +# entries = [ +# {'name': 'bufferTime', 'label': 'Buffer Time', 'increment': 1.0}, +# { +# 'name': 'delaySampling', +# 'label': 'Delay Sampling', +# 'increment': 1.0, +# }, +# { +# 'name': 'dynamicsSyncTime', +# 'label': 'Dynamics Sync Time', +# 'increment': 10, +# }, +# {'name': 'showNetInfo', 'label': 'Show Net Info', 'increment': 1}, +# ] +# super().__init__( +# title=bui.Lstr(resource='settingsWindowAdvanced.netTestingText'), +# entries=entries, +# transition=transition, +# ) diff --git a/dist/ba_data/python/bauiv1lib/settings/plugins.py b/dist/ba_data/python/bauiv1lib/settings/plugins.py index b33216b..283e371 100644 --- a/dist/ba_data/python/bauiv1lib/settings/plugins.py +++ b/dist/ba_data/python/bauiv1lib/settings/plugins.py @@ -4,9 +4,9 @@ from __future__ import annotations -from enum import Enum import logging -from typing import TYPE_CHECKING, assert_never +from enum import Enum +from typing import TYPE_CHECKING, assert_never, override import bauiv1 as bui from bauiv1lib import popup @@ -28,77 +28,85 @@ class Category(Enum): return f'{self.value}Text' -class PluginWindow(bui.Window): +class PluginWindow(bui.MainWindow): """Window for configuring plugins.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): - # pylint: disable=too-many-statements + # pylint: disable=too-many-locals app = bui.app self._category = Category.ALL - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 870.0 if uiscale is bui.UIScale.SMALL else 670.0 - x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 + self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 670.0 self._height = ( - 390.0 + 900.0 if uiscale is bui.UIScale.SMALL else 450.0 if uiscale is bui.UIScale.MEDIUM else 520.0 ) - top_extra = 10 if uiscale is bui.UIScale.SMALL else 0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.9 + if uiscale is bui.UIScale.SMALL + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 80, screensize[0] / scale) + target_height = min(self._height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 20.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 40 + self._scroll_bottom = yoffs - 64 - self._scroll_height + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.06 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -25) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._scroll_width = self._width - (100 + 2 * x_inset) - self._scroll_height = self._height - 115.0 self._sub_width = self._scroll_width * 0.95 self._sub_height = 724.0 assert app.classic is not None - if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._do_back + edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = bui.buttonwidget( parent=self._root_widget, - position=(53 + x_inset, self._height - 60), - size=(140, 60), + position=(53, yoffs - 49), + size=(60, 60), scale=0.8, autoselect=True, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._do_back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button @@ -106,7 +114,10 @@ class PluginWindow(bui.Window): self._title_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 41), + position=( + self._width * 0.5, + yoffs - (42 if uiscale is bui.UIScale.SMALL else 30), + ), size=(0, 0), text=bui.Lstr(resource='pluginsText'), color=app.ui_v1.title_color, @@ -115,19 +126,16 @@ class PluginWindow(bui.Window): v_align='center', ) - if self._back_button is not None: - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - settings_button_x = 670 if uiscale is bui.UIScale.SMALL else 570 + settings_button_x = ( + self._width * 0.5 + + self._scroll_width * 0.5 + - (100 if uiscale is bui.UIScale.SMALL else 40) + ) + button_row_yoffs = yoffs + (-2 if uiscale is bui.UIScale.SMALL else 10) self._num_plugins_text = bui.textwidget( parent=self._root_widget, - position=(settings_button_x - 130, self._height - 41), + position=(settings_button_x - 130, button_row_yoffs - 41), size=(0, 0), text='', h_align='center', @@ -137,7 +145,7 @@ class PluginWindow(bui.Window): self._category_button = bui.buttonwidget( parent=self._root_widget, scale=0.7, - position=(settings_button_x - 105, self._height - 60), + position=(settings_button_x - 105, button_row_yoffs - 60), size=(130, 60), label=bui.Lstr(resource='allText'), autoselect=True, @@ -148,7 +156,7 @@ class PluginWindow(bui.Window): self._settings_button = bui.buttonwidget( parent=self._root_widget, - position=(settings_button_x, self._height - 58), + position=(settings_button_x, button_row_yoffs - 58), size=(40, 40), label='', on_activate_call=self._open_settings, @@ -156,7 +164,7 @@ class PluginWindow(bui.Window): bui.imagewidget( parent=self._root_widget, - position=(settings_button_x + 3, self._height - 57), + position=(settings_button_x + 3, button_row_yoffs - 57), draw_controller=self._settings_button, size=(35, 35), texture=bui.gettexture('settingsIcon'), @@ -170,12 +178,16 @@ class PluginWindow(bui.Window): self._scrollwidget = bui.scrollwidget( parent=self._root_widget, - position=(50 + x_inset, 50), + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), simple_culling_v=20.0, highlight=False, - size=(self._scroll_width, self._scroll_height), selection_loops_to_parent=True, claims_left_right=True, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) @@ -213,6 +225,20 @@ class PluginWindow(bui.Window): ) self._restore_state() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _check_value_changed(self, plug: bui.PluginSpec, value: bool) -> None: bui.screenmessage( bui.Lstr(resource='settingsWindowAdvanced.mustRestartText'), @@ -228,17 +254,11 @@ class PluginWindow(bui.Window): # pylint: disable=cyclic-import from bauiv1lib.settings.pluginsettings import PluginSettingsWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we don't have control. + if not self.main_window_has_control(): return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PluginSettingsWindow(transition='in_right').get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_replace(PluginSettingsWindow(transition='in_right')) def _show_category_options(self) -> None: uiscale = bui.app.ui_v1.uiscale @@ -451,21 +471,3 @@ class PluginWindow(bui.Window): bui.containerwidget(edit=self._root_widget, selected_child=sel) except Exception: logging.exception('Error restoring state for %s.', self) - - def _do_back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AdvancedSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py b/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py index d59554f..9d3344c 100644 --- a/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py +++ b/dist/ba_data/python/bauiv1lib/settings/pluginsettings.py @@ -4,73 +4,101 @@ from __future__ import annotations +from typing import override + import bauiv1 as bui from bauiv1lib.confirm import ConfirmWindow -class PluginSettingsWindow(bui.Window): +class PluginSettingsWindow(bui.MainWindow): """Plugin Settings Window""" - def __init__(self, transition: str = 'in_right'): - scale_origin: tuple[float, float] | None - self._transition_out = 'out_right' - scale_origin = None + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ): assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - width = 470.0 if uiscale is bui.UIScale.SMALL else 470.0 - height = ( - 365.0 + self._width = 1200.0 if uiscale is bui.UIScale.SMALL else 470.0 + self._height = 900.0 if uiscale is bui.UIScale.SMALL else 360.0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.06 if uiscale is bui.UIScale.SMALL - else 300.0 if uiscale is bui.UIScale.MEDIUM else 370.0 + else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 ) - top_extra = 10 if uiscale is bui.UIScale.SMALL else 0 + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 100, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + self._yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 super().__init__( root_widget=bui.containerwidget( - size=(width, height + top_extra), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.06 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.4 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -25) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - self._back_button = bui.buttonwidget( - parent=self._root_widget, - position=(53, height - 60), - size=(60, 60), - scale=0.8, - autoselect=True, - label=bui.charstr(bui.SpecialChar.BACK), - button_type='backSmall', - on_activate_call=self._do_back, - ) - bui.containerwidget( - edit=self._root_widget, cancel_button=self._back_button - ) + if uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + self._back_button = bui.buttonwidget( + parent=self._root_widget, + position=(55, self._yoffs - 33), + size=(60, 60), + scale=0.8, + autoselect=True, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget( + edit=self._root_widget, cancel_button=self._back_button + ) self._title_text = bui.textwidget( parent=self._root_widget, - position=(0, height - 52), - size=(width, 25), + position=( + self._width * 0.5, + self._yoffs - (55 if uiscale is bui.UIScale.SMALL else 10), + ), + size=(0, 0), text=bui.Lstr(resource='pluginSettingsText'), + maxwidth=230, color=bui.app.ui_v1.title_color, h_align='center', - v_align='top', + v_align='center', ) - self._y_position = 170 if uiscale is bui.UIScale.MEDIUM else 205 + # Roughly center our few bits of content. + x = self._width * 0.5 - 175 + y = self._height * 0.5 + 30 + self._enable_plugins_button = bui.buttonwidget( parent=self._root_widget, - position=(65, self._y_position), + position=(x, y), size=(350, 60), autoselect=True, label=bui.Lstr(resource='pluginsEnableAllText'), @@ -80,10 +108,10 @@ class PluginSettingsWindow(bui.Window): ), ) - self._y_position -= 70 + y -= 70 self._disable_plugins_button = bui.buttonwidget( parent=self._root_widget, - position=(65, self._y_position), + position=(x, y), size=(350, 60), autoselect=True, label=bui.Lstr(resource='pluginsDisableAllText'), @@ -93,10 +121,10 @@ class PluginSettingsWindow(bui.Window): ), ) - self._y_position -= 70 + y -= 70 self._enable_new_plugins_check_box = bui.checkboxwidget( parent=self._root_widget, - position=(65, self._y_position), + position=(x, y), size=(350, 60), value=bui.app.config.get( bui.app.plugins.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY, @@ -108,9 +136,10 @@ class PluginSettingsWindow(bui.Window): on_value_change_call=self._update_value, ) - bui.widget( - edit=self._back_button, down_widget=self._enable_plugins_button - ) + if uiscale is not bui.UIScale.SMALL: + bui.widget( + edit=self._back_button, down_widget=self._enable_plugins_button + ) bui.widget( edit=self._disable_plugins_button, @@ -124,6 +153,16 @@ class PluginSettingsWindow(bui.Window): down_widget=self._enable_new_plugins_check_box, ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + def _enable_all_plugins(self) -> None: cfg = bui.app.config plugs: dict[str, dict] = cfg.setdefault('Plugins', {}) @@ -152,20 +191,3 @@ class PluginSettingsWindow(bui.Window): cfg = bui.app.config cfg[bui.app.plugins.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY] = val cfg.apply_and_commit() - - def _do_back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.plugins import PluginWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - PluginWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/remoteapp.py b/dist/ba_data/python/bauiv1lib/settings/remoteapp.py index f6a8216..0c124d7 100644 --- a/dist/ba_data/python/bauiv1lib/settings/remoteapp.py +++ b/dist/ba_data/python/bauiv1lib/settings/remoteapp.py @@ -4,49 +4,86 @@ from __future__ import annotations +from typing import override + import bauiv1 as bui -class RemoteAppSettingsWindow(bui.Window): +class RemoteAppSettingsWindow(bui.MainWindow): """Window showing info/settings related to the remote app.""" - def __init__(self) -> None: + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ) -> None: self._r = 'connectMobileDevicesWindow' - width = 700 - height = 390 + app = bui.app + uiscale = app.ui_v1.uiscale + width = 1200 if uiscale is bui.UIScale.SMALL else 700 + height = 800 if uiscale is bui.UIScale.SMALL else 390 + # yoffs = -48 if uiscale is bui.UIScale.SMALL else 0 spacing = 40 assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.75 + if uiscale is bui.UIScale.SMALL + else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(width - 60, screensize[0] / scale) + target_height = min(height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * height + 0.5 * target_height + 30.0 + super().__init__( root_widget=bui.containerwidget( size=(width, height), - transition='in_right', - scale=( - 1.85 + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.3 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (-10, 0) if uiscale is bui.UIScale.SMALL else (0, 0) + else 'menu_full' ), + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self.get_root_widget(), + on_cancel_call=self.main_window_back, ) - ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=(40, height - 67), - size=(140, 65), - scale=0.8, - label=bui.Lstr(resource='backText'), - button_type='back', - text_scale=1.1, - autoselect=True, - on_activate_call=self._back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + else: + btn = bui.buttonwidget( + parent=self._root_widget, + position=(40, yoffs - 67), + size=(60, 60), + scale=0.8, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + text_scale=1.1, + autoselect=True, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) bui.textwidget( parent=self._root_widget, - position=(width * 0.5, height - 42), + position=( + width * 0.5, + yoffs - (62 if uiscale is bui.UIScale.SMALL else 42), + ), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.titleText'), maxwidth=370, @@ -56,14 +93,8 @@ class RemoteAppSettingsWindow(bui.Window): v_align='center', ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - - v = height - 70.0 + # Generally center the rest of our contents vertically. + v = height * 0.5 + 140.0 v -= spacing * 1.2 bui.textwidget( parent=self._root_widget, @@ -108,7 +139,7 @@ class RemoteAppSettingsWindow(bui.Window): scale=0.65, text=bui.Lstr(resource=f'{self._r}.bestResultsText'), maxwidth=width * 0.95, - max_height=height * 0.19, + max_height=100, h_align='center', v_align='center', ) @@ -125,23 +156,17 @@ class RemoteAppSettingsWindow(bui.Window): on_value_change_call=self._on_check_changed, ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + def _on_check_changed(self, value: bool) -> None: cfg = bui.app.config cfg['Enable Remote App'] = not value cfg.apply_and_commit() - - def _back(self) -> None: - from bauiv1lib.settings import controls - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - controls.ControlsSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/bauiv1lib/settings/testing.py b/dist/ba_data/python/bauiv1lib/settings/testing.py index 5af029b..712ce93 100644 --- a/dist/ba_data/python/bauiv1lib/settings/testing.py +++ b/dist/ba_data/python/bauiv1lib/settings/testing.py @@ -5,7 +5,7 @@ from __future__ import annotations import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -13,83 +13,120 @@ if TYPE_CHECKING: from typing import Any, Callable -class TestingWindow(bui.Window): +class TestingWindow(bui.MainWindow): """Window for conveniently testing various settings.""" def __init__( self, title: bui.Lstr, entries: list[dict[str, Any]], - transition: str = 'in_right', - back_call: Callable[[], bui.Window] | None = None, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): + # pylint: disable=too-many-locals assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 600 - self._height = 324 if uiscale is bui.UIScale.SMALL else 400 + self._width = 1200 if uiscale is bui.UIScale.SMALL else 600 + self._height = 800 if uiscale is bui.UIScale.SMALL else 400 + self._entries_orig = copy.deepcopy(entries) self._entries = copy.deepcopy(entries) - self._back_call = back_call + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.27 + if uiscale is bui.UIScale.SMALL + else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 47 + self._scroll_bottom = yoffs - 78 - self._scroll_height + super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition=transition, - scale=( - 2.5 + scale=scale, + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.2 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -28) if uiscale is bui.UIScale.SMALL else (0, 0) + else 'menu_full' ), + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, + ) + + if uiscale is bui.UIScale.SMALL: + self._back_button = bui.get_special_widget('back_button') + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back ) - ) - self._back_button = btn = bui.buttonwidget( - parent=self._root_widget, - autoselect=True, - position=(65, self._height - 59), - size=(130, 60), - scale=0.8, - text_scale=1.2, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._do_back, - ) + else: + self._back_button = btn = bui.buttonwidget( + parent=self._root_widget, + autoselect=True, + position=(35, yoffs - 59), + size=(60, 60), + scale=0.8, + text_scale=1.2, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) + + self.title = title bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 35), + position=( + self._width * 0.5, + yoffs - (43 if uiscale is bui.UIScale.SMALL else 35), + ), size=(0, 0), + scale=0.7 if uiscale is bui.UIScale.SMALL else 1.0, color=bui.app.ui_v1.title_color, h_align='center', v_align='center', maxwidth=245, - text=title, - ) - - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), + text=self.title, ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 75), + position=( + self._width * 0.5, + yoffs - 65, + ), size=(0, 0), + scale=0.5, color=bui.app.ui_v1.infotextcolor, h_align='center', v_align='center', - maxwidth=self._width * 0.75, + maxwidth=self._scroll_width * 0.75, text=bui.Lstr(resource='settingsWindowAdvanced.forTestingText'), ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - self._scroll_width = self._width - 130 - self._scroll_height = self._height - 140 self._scrollwidget = bui.scrollwidget( parent=self._root_widget, size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), highlight=False, - position=((self._width - self._scroll_width) * 0.5, 40), + border_opacity=0.4, ) bui.containerwidget(edit=self._scrollwidget, claims_left_right=True) @@ -109,8 +146,8 @@ class TestingWindow(bui.Window): for i, entry in enumerate(self._entries): entry_name = entry['name'] - # If we haven't yet, record the default value for this name so - # we can reset if we want.. + # If we haven't yet, record the default value for this name + # so we can reset if we want.. if entry_name not in bui.app.classic.value_test_defaults: bui.app.classic.value_test_defaults[entry_name] = ( bui.app.classic.value_test(entry_name) @@ -138,7 +175,6 @@ class TestingWindow(bui.Window): ) if i == 0: bui.widget(edit=btn, up_widget=self._back_button) - # pylint: disable=consider-using-f-string entry['widget'] = bui.textwidget( parent=self._subcontainer, position=(h + 100, v), @@ -146,7 +182,7 @@ class TestingWindow(bui.Window): h_align='center', v_align='center', maxwidth=60, - text='%.4g' % bui.app.classic.value_test(entry_name), + text=f'{bui.app.classic.value_test(entry_name):.4g}', ) btn = bui.buttonwidget( parent=self._subcontainer, @@ -185,10 +221,9 @@ class TestingWindow(bui.Window): entry['name'], absolute=bui.app.classic.value_test_defaults[entry['name']], ) - # pylint: disable=consider-using-f-string bui.textwidget( edit=entry['widget'], - text='%.4g' % bui.app.classic.value_test(entry['name']), + text=f'{bui.app.classic.value_test(entry['name']):.4g}', ) def _on_minus_press(self, entry_name: str) -> None: @@ -211,21 +246,21 @@ class TestingWindow(bui.Window): text='%.4g' % bui.app.classic.value_test(entry['name']), ) - def _do_back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.advanced import AdvancedSettingsWindow + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return + # Pull values from self here; if we do it in the lambda we'll + # keep self alive which we don't want. + title = self.title + entries = self._entries_orig - bui.containerwidget(edit=self._root_widget, transition='out_right') - backwin = ( - self._back_call() - if self._back_call is not None - else AdvancedSettingsWindow(transition='in_left') - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - backwin.get_root_widget(), from_window=self._root_widget + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + title=title, + entries=entries, + transition=transition, + origin_widget=origin_widget, + ) ) diff --git a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py index 38aec5f..7dcfdf1 100644 --- a/dist/ba_data/python/bauiv1lib/settings/touchscreen.py +++ b/dist/ba_data/python/bauiv1lib/settings/touchscreen.py @@ -3,25 +3,25 @@ """UI settings functionality related to touchscreens.""" from __future__ import annotations +from typing import override + import bauiv1 as bui import bascenev1 as bs -class TouchscreenSettingsWindow(bui.Window): +class TouchscreenSettingsWindow(bui.MainWindow): """Settings window for touchscreens.""" def __del__(self) -> None: - # Note - this happens in 'back' too; - # we just do it here too in case the window is closed by other means. - - # FIXME: Could switch to a UI destroy callback now that those are a - # thing that exists. bs.set_touchscreen_editing(False) - def __init__(self) -> None: - self._width = 650 + def __init__( + self, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, + ) -> None: + self._width = 780 self._height = 380 - self._spacing = 40 self._r = 'configTouchscreenWindow' bs.set_touchscreen_editing(True) @@ -31,29 +31,43 @@ class TouchscreenSettingsWindow(bui.Window): super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition='in_right', scale=( 1.9 if uiscale is bui.UIScale.SMALL else 1.55 if uiscale is bui.UIScale.MEDIUM else 1.2 ), - ) + toolbar_visibility=( + 'menu_minimal' + if uiscale is bui.UIScale.SMALL + else 'menu_full' + ), + stack_offset=( + (0, -20) if uiscale is bui.UIScale.SMALL else (0, 0) + ), + ), + transition=transition, + origin_widget=origin_widget, ) - btn = bui.buttonwidget( - parent=self._root_widget, - position=(55, self._height - 60), - size=(120, 60), - label=bui.Lstr(resource='backText'), - button_type='back', - scale=0.8, - on_activate_call=self._back, - ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) + if uiscale is bui.UIScale.SMALL: + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back + ) + else: + btn = bui.buttonwidget( + parent=self._root_widget, + position=(55, self._height - 60), + size=(60, 60), + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + scale=0.8, + on_activate_call=self.main_window_back, + ) + bui.containerwidget(edit=self._root_widget, cancel_button=btn) bui.textwidget( parent=self._root_widget, - position=(25, self._height - 50), + position=(25, self._height - 57), size=(self._width, 25), text=bui.Lstr(resource=f'{self._r}.titleText'), color=bui.app.ui_v1.title_color, @@ -62,13 +76,6 @@ class TouchscreenSettingsWindow(bui.Window): v_align='center', ) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) - self._scroll_width = self._width - 100 self._scroll_height = self._height - 110 self._sub_width = self._scroll_width - 20 @@ -82,7 +89,6 @@ class TouchscreenSettingsWindow(bui.Window): ), size=(self._scroll_width, self._scroll_height), claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) self._subcontainer = bui.containerwidget( @@ -90,12 +96,22 @@ class TouchscreenSettingsWindow(bui.Window): size=(self._sub_width, self._sub_height), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) self._build_gui() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + def _build_gui(self) -> None: + # pylint: disable=too-many-locals from bauiv1lib.config import ConfigNumberEdit, ConfigCheckBox from bauiv1lib.radiogroup import make_radio_group @@ -104,13 +120,16 @@ class TouchscreenSettingsWindow(bui.Window): for child in children: child.delete() h = 30 + hoffs = 100 + hoffs2 = 70 + hoffs3 = 320 v = self._sub_height - 85 clr = (0.8, 0.8, 0.8, 1.0) clr2 = (0.8, 0.8, 0.8) bui.textwidget( parent=self._subcontainer, - position=(-10, v + 43), - size=(self._sub_width, 25), + position=(self._sub_width * 0.5, v + 63), + size=(0, 0), text=bui.Lstr(resource=f'{self._r}.swipeInfoText'), flatness=1.0, color=(0, 0.9, 0.1, 0.7), @@ -131,7 +150,7 @@ class TouchscreenSettingsWindow(bui.Window): ) cb1 = bui.checkboxwidget( parent=self._subcontainer, - position=(h + 220, v), + position=(h + hoffs + 220, v), size=(170, 30), text=bui.Lstr(resource=f'{self._r}.joystickText'), maxwidth=100, @@ -140,7 +159,7 @@ class TouchscreenSettingsWindow(bui.Window): ) cb2 = bui.checkboxwidget( parent=self._subcontainer, - position=(h + 357, v), + position=(h + hoffs + 357, v), size=(170, 30), text=bui.Lstr(resource=f'{self._r}.swipeText'), maxwidth=100, @@ -155,7 +174,7 @@ class TouchscreenSettingsWindow(bui.Window): ConfigNumberEdit( parent=self._subcontainer, position=(h, v), - xoffset=65, + xoffset=hoffs2 + 65, configkey='Touch Controls Scale Movement', displayname=bui.Lstr( resource=f'{self._r}.movementControlScaleText' @@ -178,7 +197,7 @@ class TouchscreenSettingsWindow(bui.Window): ) cb1 = bui.checkboxwidget( parent=self._subcontainer, - position=(h + 220, v), + position=(h + hoffs + 220, v), size=(170, 30), text=bui.Lstr(resource=f'{self._r}.buttonsText'), maxwidth=100, @@ -187,7 +206,7 @@ class TouchscreenSettingsWindow(bui.Window): ) cb2 = bui.checkboxwidget( parent=self._subcontainer, - position=(h + 357, v), + position=(h + hoffs + 357, v), size=(170, 30), text=bui.Lstr(resource=f'{self._r}.swipeText'), maxwidth=100, @@ -201,7 +220,7 @@ class TouchscreenSettingsWindow(bui.Window): ConfigNumberEdit( parent=self._subcontainer, position=(h, v), - xoffset=65, + xoffset=hoffs2 + 65, configkey='Touch Controls Scale Actions', displayname=bui.Lstr(resource=f'{self._r}.actionControlScaleText'), changesound=False, @@ -211,13 +230,23 @@ class TouchscreenSettingsWindow(bui.Window): ) v -= 50 + bui.textwidget( + parent=self._subcontainer, + position=(h, v - 2), + size=(0, 30), + text=bui.Lstr(resource=f'{self._r}.swipeControlsHiddenText'), + maxwidth=190, + color=clr, + v_align='center', + ) + ConfigCheckBox( parent=self._subcontainer, - position=(h, v), - size=(400, 30), + position=(h + hoffs3, v), + size=(100, 30), maxwidth=400, configkey='Touch Controls Swipe Hidden', - displayname=bui.Lstr(resource=f'{self._r}.swipeControlsHiddenText'), + displayname='', ) v -= 65 @@ -270,20 +299,3 @@ class TouchscreenSettingsWindow(bui.Window): del cfg[cfgkey] cfg.apply_and_commit() bui.apptimer(0, self._build_gui) - - def _back(self) -> None: - from bauiv1lib.settings import controls - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - bui.containerwidget(edit=self._root_widget, transition='out_right') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - controls.ControlsSettingsWindow( - transition='in_left' - ).get_root_widget(), - from_window=self._root_widget, - ) - bs.set_touchscreen_editing(False) diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py index f16073b..fbfc17c 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/browser.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/browser.py @@ -6,88 +6,86 @@ from __future__ import annotations import copy import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui if TYPE_CHECKING: from typing import Any +REQUIRE_PRO = False -class SoundtrackBrowserWindow(bui.Window): + +class SoundtrackBrowserWindow(bui.MainWindow): """Window for browsing soundtracks.""" def __init__( self, - transition: str = 'in_right', + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, ): - # pylint: disable=too-many-locals # pylint: disable=too-many-statements - - # If they provided an origin-widget, scale up from that. - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None + # pylint: disable=too-many-locals self._r = 'editSoundtrackWindow' assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 800 if uiscale is bui.UIScale.SMALL else 600 - x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 - self._height = ( - 340 + self._width = 1200 if uiscale is bui.UIScale.SMALL else 650 + self._height = 800 if uiscale is bui.UIScale.SMALL else 400 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 2.1 if uiscale is bui.UIScale.SMALL - else 370 if uiscale is bui.UIScale.MEDIUM else 440 + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 ) - spacing = 40.0 - v = self._height - 40.0 - v -= spacing * 1.0 + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + # target_width = min(self._width - 60, screensize[0] / scale) + target_height = min(self._height - 70, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 2.3 + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 1.6 if uiscale is bui.UIScale.MEDIUM else 1.0 + else 'menu_full' ), - stack_offset=( - (0, -18) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) assert bui.app.classic is not None - if bui.app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL: + if uiscale is bui.UIScale.SMALL: self._back_button = None else: self._back_button = bui.buttonwidget( parent=self._root_widget, - position=(45 + x_inset, self._height - 60), - size=(120, 60), - scale=0.8, - label=bui.Lstr(resource='backText'), - button_type='back', - autoselect=True, - ) - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', + position=(50, yoffs - 60), size=(60, 60), + scale=0.8, label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + autoselect=True, ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 35), + position=( + self._width * 0.5, + yoffs - (55 if uiscale is bui.UIScale.SMALL else 35), + ), size=(0, 0), maxwidth=300, text=bui.Lstr(resource=f'{self._r}.titleText'), @@ -96,18 +94,17 @@ class SoundtrackBrowserWindow(bui.Window): v_align='center', ) + # Generally center all other content + x_inset = self._width * 0.5 - 320 + vbase = v = self._height * 0.5 + 130 + h = 43 + x_inset - v = self._height - 60 b_color = (0.6, 0.53, 0.63) b_textcolor = (0.75, 0.7, 0.8) lock_tex = bui.gettexture('lock') self._lock_images: list[bui.Widget] = [] - scl = ( - 1.0 - if uiscale is bui.UIScale.SMALL - else 1.13 if uiscale is bui.UIScale.MEDIUM else 1.4 - ) + scl = 1.2 v -= 60.0 * scl self._new_button = btn = bui.buttonwidget( parent=self._root_widget, @@ -227,23 +224,20 @@ class SoundtrackBrowserWindow(bui.Window): ) self._update() - v = self._height - 65 - scroll_height = self._height - 105 + v = vbase - 6 + scroll_height = 280 v -= scroll_height self._scrollwidget = scrollwidget = bui.scrollwidget( parent=self._root_widget, position=(152 + x_inset, v), highlight=False, - size=(self._width - (205 + 2 * x_inset), scroll_height), + size=(450, scroll_height), + border_opacity=0.4, ) bui.widget( edit=self._scrollwidget, left_widget=self._new_button, - right_widget=( - bui.get_special_widget('party_button') - if bui.app.ui_v1.use_toolbars - else self._scrollwidget - ), + right_widget=bui.get_special_widget('squad_button'), ) self._col = bui.columnwidget(parent=scrollwidget, border=2, margin=0) @@ -255,23 +249,39 @@ class SoundtrackBrowserWindow(bui.Window): self._refresh() if self._back_button is not None: bui.buttonwidget( - edit=self._back_button, on_activate_call=self._back + edit=self._back_button, on_activate_call=self.main_window_back ) bui.containerwidget( edit=self._root_widget, cancel_button=self._back_button ) else: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back + edit=self._root_widget, on_cancel_call=self.main_window_back ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _update(self) -> None: - have = ( + have_pro = ( bui.app.classic is None or bui.app.classic.accounts.have_pro_options() ) for lock in self._lock_images: - bui.imagewidget(edit=lock, opacity=0.0 if have else 1.0) + bui.imagewidget( + edit=lock, opacity=0.0 if (have_pro or not REQUIRE_PRO) else 1.0 + ) def _do_delete_soundtrack(self) -> None: cfg = bui.app.config @@ -292,7 +302,7 @@ class SoundtrackBrowserWindow(bui.Window): from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.confirm import ConfirmWindow - if ( + if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): @@ -321,7 +331,7 @@ class SoundtrackBrowserWindow(bui.Window): # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow - if ( + if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): @@ -387,29 +397,11 @@ class SoundtrackBrowserWindow(bui.Window): music.music_types[bui.app.classic.MusicPlayMode.REGULAR] ) - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.settings.audio import AudioSettingsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - AudioSettingsWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - def _edit_soundtrack_with_sound(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.purchase import PurchaseWindow - if ( + if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): @@ -423,18 +415,20 @@ class SoundtrackBrowserWindow(bui.Window): from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we don't have control. + if not self.main_window_has_control(): return - if ( + if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return + if self._selected_soundtrack is None: return + if self._selected_soundtrack == '__default__': bui.getsound('error').play() bui.screenmessage( @@ -443,14 +437,8 @@ class SoundtrackBrowserWindow(bui.Window): ) return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - SoundtrackEditWindow( - existing_soundtrack=self._selected_soundtrack - ).get_root_widget(), - from_window=self._root_widget, + self.main_window_replace( + SoundtrackEditWindow(existing_soundtrack=self._selected_soundtrack) ) def _get_soundtrack_display_name(self, soundtrack: str) -> bui.Lstr: @@ -541,18 +529,18 @@ class SoundtrackBrowserWindow(bui.Window): from bauiv1lib.purchase import PurchaseWindow from bauiv1lib.soundtrack.edit import SoundtrackEditWindow - if ( + # no-op if we're not in control. + if not self.main_window_has_control(): + return + + if REQUIRE_PRO and ( bui.app.classic is not None and not bui.app.classic.accounts.have_pro_options() ): PurchaseWindow(items=['pro']) return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.app.ui_v1.set_main_menu_window( - SoundtrackEditWindow(existing_soundtrack=None).get_root_widget(), - from_window=self._root_widget, - ) + + self.main_window_replace(SoundtrackEditWindow(existing_soundtrack=None)) def _create_done(self, new_soundtrack: str) -> None: if new_soundtrack is not None: diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py index 9a155a5..8d02e7f 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/edit.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/edit.py @@ -6,7 +6,7 @@ from __future__ import annotations import copy import os -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override import bascenev1 as bs import bauiv1 as bui @@ -15,47 +15,71 @@ if TYPE_CHECKING: from typing import Any -class SoundtrackEditWindow(bui.Window): +class SoundtrackEditWindow(bui.MainWindow): """Window for editing a soundtrack.""" def __init__( self, existing_soundtrack: str | dict[str, Any] | None, - transition: str = 'in_right', + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-statements + # pylint: disable=too-many-locals + appconfig = bui.app.config self._r = 'editSoundtrackWindow' self._folder_tex = bui.gettexture('folder') self._file_tex = bui.gettexture('file') assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - self._width = 848 if uiscale is bui.UIScale.SMALL else 648 - x_inset = 100 if uiscale is bui.UIScale.SMALL else 0 + self._width = 1200 if uiscale is bui.UIScale.SMALL else 648 self._height = ( - 395 + 800 if uiscale is bui.UIScale.SMALL else 450 if uiscale is bui.UIScale.MEDIUM else 560 ) + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.8 + if uiscale is bui.UIScale.SMALL + else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 70, screensize[0] / scale) + target_height = min(self._height - 80, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = ( + 0.5 * self._height + + 0.5 * target_height + + (10.0 if uiscale is bui.UIScale.SMALL else 20) + ) + + self._scroll_width = target_width + self._scroll_height = target_height - 113 + scroll_bottom = yoffs - 120 - self._scroll_height + + x_inset = self._width * 0.5 - 0.5 * self._scroll_width + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height), - transition=transition, - scale=( - 2.08 - if uiscale is bui.UIScale.SMALL - else 1.5 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -48) - if uiscale is bui.UIScale.SMALL - else (0, 15) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - ) + size=(self._width, self._height), scale=scale + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) cancel_button = bui.buttonwidget( parent=self._root_widget, - position=(38 + x_inset, self._height - 60), + position=(x_inset + 10, yoffs - 60), size=(160, 60), autoselect=True, label=bui.Lstr(resource='cancelText'), @@ -63,7 +87,12 @@ class SoundtrackEditWindow(bui.Window): ) save_button = bui.buttonwidget( parent=self._root_widget, - position=(self._width - (168 + x_inset), self._height - 60), + position=( + self._width * 0.5 + + self._scroll_width * 0.5 + - (190 if uiscale is bui.UIScale.SMALL else 140), + yoffs - 60, + ), autoselect=True, size=(160, 60), label=bui.Lstr(resource='saveText'), @@ -73,7 +102,7 @@ class SoundtrackEditWindow(bui.Window): bui.widget(edit=cancel_button, right_widget=save_button) bui.textwidget( parent=self._root_widget, - position=(0, self._height - 50), + position=(0, yoffs - 50), size=(self._width, 25), text=bui.Lstr( resource=self._r @@ -86,13 +115,14 @@ class SoundtrackEditWindow(bui.Window): color=bui.app.ui_v1.title_color, h_align='center', v_align='center', - maxwidth=280, + maxwidth=270, ) - v = self._height - 110 + v = yoffs - 110 if 'Soundtracks' not in appconfig: appconfig['Soundtracks'] = {} self._soundtrack_name: str | None + self._existing_soundtrack = existing_soundtrack self._existing_soundtrack_name: str | None if existing_soundtrack is not None: # if they passed just a name, pull info from that soundtrack @@ -104,7 +134,7 @@ class SoundtrackEditWindow(bui.Window): self._existing_soundtrack_name = existing_soundtrack self._last_edited_song_type = None else: - # otherwise they can pass info on an in-progress edit + # Otherwise they can pass info on an in-progress edit. self._soundtrack = existing_soundtrack['soundtrack'] self._soundtrack_name = existing_soundtrack['name'] self._existing_soundtrack_name = existing_soundtrack[ @@ -161,22 +191,23 @@ class SoundtrackEditWindow(bui.Window): on_return_press_call=self._do_it_with_sound, ) - scroll_height = self._height - 180 self._scrollwidget = scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, - position=(40 + x_inset, v - (scroll_height + 10)), - size=(self._width - (80 + 2 * x_inset), scroll_height), + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + scroll_bottom, + ), simple_culling_v=10, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, + border_opacity=0.4, ) bui.widget(edit=self._text_field, down_widget=self._scrollwidget) self._col = bui.columnwidget( parent=scrollwidget, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) @@ -189,6 +220,28 @@ class SoundtrackEditWindow(bui.Window): bui.widget(edit=self._text_field, up_widget=cancel_button) bui.widget(edit=cancel_button, down_widget=self._text_field) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull this out of self here; if we do it in the lambda we'll + # keep our window alive due to the 'self' reference. + existing_soundtrack = { + 'name': self._soundtrack_name, + 'existing_name': self._existing_soundtrack_name, + 'soundtrack': self._soundtrack, + 'last_edited_song_type': self._last_edited_song_type, + } + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + existing_soundtrack=existing_soundtrack, + ) + ) + def _refresh(self) -> None: for widget in self._col.get_children(): widget.delete() @@ -224,21 +277,20 @@ class SoundtrackEditWindow(bui.Window): for index, song_type in enumerate(types): row = bui.rowwidget( parent=self._col, - size=(self._width - 40, 40), + size=(self._scroll_width, 40), claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) type_name = type_names_translated.get(song_type, song_type) bui.textwidget( parent=row, - size=(230, 25), + size=(self._scroll_width - 350, 25), always_highlight=True, text=type_name, scale=0.7, h_align='left', v_align='center', - maxwidth=190, + maxwidth=self._scroll_width - 360, ) if song_type in self._soundtrack: @@ -346,10 +398,11 @@ class SoundtrackEditWindow(bui.Window): else: soundtrack[musictype] = entry - bui.app.ui_v1.set_main_menu_window( - cls(state, transition='in_left').get_root_widget(), - from_window=False, # Disable check here. - ) + mainwindow = bui.app.ui_v1.get_main_window() + assert mainwindow is not None + + mainwindow.main_window_back_state = state['back_state'] + mainwindow.main_window_back() def _get_entry( self, song_type: str, entry: Any, selection_target_name: str @@ -357,8 +410,8 @@ class SoundtrackEditWindow(bui.Window): assert bui.app.classic is not None music = bui.app.classic.music - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return if selection_target_name != '': @@ -369,17 +422,17 @@ class SoundtrackEditWindow(bui.Window): 'soundtrack': self._soundtrack, 'last_edited_song_type': song_type, } - bui.containerwidget(edit=self._root_widget, transition='out_left') - bui.app.ui_v1.set_main_menu_window( - music.get_music_player() - .select_entry( - bui.Call(self._restore_editor, state, song_type), - entry, - selection_target_name, - ) - .get_root_widget(), - from_window=self._root_widget, + new_win = music.get_music_player().select_entry( + bui.Call(self._restore_editor, state, song_type), + entry, + selection_target_name, ) + self.main_window_replace(new_win) + + # Once we've set the new window, grab the back-state; we'll use + # that to jump back here after selection completes. + assert new_win.main_window_back_state is not None + state['back_state'] = new_win.main_window_back_state def _test(self, song_type: bs.MusicType) -> None: assert bui.app.classic is not None @@ -423,7 +476,7 @@ class SoundtrackEditWindow(bui.Window): return None def _cancel(self) -> None: - from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow + # from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -434,14 +487,10 @@ class SoundtrackEditWindow(bui.Window): # Resets music back to normal. music.set_music_play_mode(bui.app.classic.MusicPlayMode.REGULAR) - bui.containerwidget(edit=self._root_widget, transition='out_right') - bui.app.ui_v1.set_main_menu_window( - SoundtrackBrowserWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) + + self.main_window_back() def _do_it(self) -> None: - from bauiv1lib.soundtrack.browser import SoundtrackBrowserWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -487,17 +536,13 @@ class SoundtrackEditWindow(bui.Window): cfg.commit() bui.getsound('gunCocking').play() - bui.containerwidget(edit=self._root_widget, transition='out_right') # Resets music back to normal. music.set_music_play_mode( bui.app.classic.MusicPlayMode.REGULAR, force_restart=True ) - bui.app.ui_v1.set_main_menu_window( - SoundtrackBrowserWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) + self.main_window_back() def _do_it_with_sound(self) -> None: bui.getsound('swish').play() diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py index d0e6547..ff47eac 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/entrytypeselect.py @@ -4,7 +4,7 @@ from __future__ import annotations import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -12,7 +12,7 @@ if TYPE_CHECKING: from typing import Any, Callable -class SoundtrackEntryTypeSelectWindow(bui.Window): +class SoundtrackEntryTypeSelectWindow(bui.MainWindow): """Window for selecting a soundtrack entry type.""" def __init__( @@ -20,12 +20,16 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): callback: Callable[[Any], Any], current_entry: Any, selection_target_name: str, - transition: str = 'in_right', + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): + # pylint: disable=too-many-locals assert bui.app.classic is not None music = bui.app.classic.music self._r = 'editSoundtrackWindow' + self._selection_target_name = selection_target_name self._callback = callback self._current_entry = copy.deepcopy(current_entry) @@ -53,13 +57,12 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): # NOTE: When something is selected, we close our UI and kick off # another window which then calls us back when its done, so the - # standard UI-cleanup-check complains that something is holding on - # to our instance after its ui is gone. Should restructure in a - # cleaner way, but just disabling that check for now. + # standard UI-cleanup-check complains that something is holding + # on to our instance after its ui is gone. Should restructure in + # a cleaner way, but just disabling that check for now. super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), - transition=transition, scale=( 1.7 if uiscale is bui.UIScale.SMALL @@ -67,6 +70,8 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): ), ), cleanupcheck=False, + transition=transition, + origin_widget=origin_widget, ) btn = bui.buttonwidget( parent=self._root_widget, @@ -157,6 +162,27 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): bui.containerwidget(edit=self._root_widget, selected_child=btn) v -= spacing + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull these out of self here; if we reference self in the + # lambda we'll keep our window alive which is bad. + current_entry = self._current_entry + callback = self._callback + selection_target_name = self._selection_target_name + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, + origin_widget=origin_widget, + current_entry=current_entry, + callback=callback, + selection_target_name=selection_target_name, + ) + ) + def _on_mac_music_app_playlist_press(self) -> None: assert bui.app.classic is not None music = bui.app.classic.music @@ -164,12 +190,10 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): MacMusicAppPlaylistSelectWindow, ) - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - bui.containerwidget(edit=self._root_widget, transition='out_left') - current_playlist_entry: str | None if ( music.get_soundtrack_entry_type(self._current_entry) @@ -180,11 +204,11 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): ) else: current_playlist_entry = None - bui.app.ui_v1.set_main_menu_window( + + self.main_window_replace( MacMusicAppPlaylistSelectWindow( self._callback, current_playlist_entry, self._current_entry - ).get_root_widget(), - from_window=self._root_widget, + ) ) def _on_music_file_press(self) -> None: @@ -192,14 +216,14 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): from baclassic.osmusic import OSMusicPlayer from bauiv1lib.fileselector import FileSelectorWindow - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - bui.containerwidget(edit=self._root_widget, transition='out_left') base_path = android_get_external_files_dir() assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + + self.main_window_replace( FileSelectorWindow( base_path, callback=self._music_file_selector_cb, @@ -208,30 +232,28 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): OSMusicPlayer.get_valid_music_file_extensions() ), allow_folders=False, - ).get_root_widget(), - from_window=self._root_widget, + ), ) def _on_music_folder_press(self) -> None: from bauiv1lib.fileselector import FileSelectorWindow from babase import android_get_external_files_dir - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: + # no-op if we're not in control. + if not self.main_window_has_control(): return - bui.containerwidget(edit=self._root_widget, transition='out_left') base_path = android_get_external_files_dir() assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( + + self.main_window_replace( FileSelectorWindow( base_path, callback=self._music_folder_selector_cb, show_base_path=False, valid_file_extensions=[], allow_folders=True, - ).get_root_widget(), - from_window=self._root_widget, + ), ) def _music_file_selector_cb(self, result: str | None) -> None: @@ -247,9 +269,9 @@ class SoundtrackEntryTypeSelectWindow(bui.Window): self._callback({'type': 'musicFolder', 'name': result}) def _on_default_press(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') + self.main_window_back() self._callback(None) def _on_cancel_press(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') + self.main_window_back() self._callback(self._current_entry) diff --git a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py index 99c60a6..ee21dfe 100644 --- a/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py +++ b/dist/ba_data/python/bauiv1lib/soundtrack/macmusicapp.py @@ -5,7 +5,7 @@ from __future__ import annotations import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override import bauiv1 as bui @@ -13,7 +13,7 @@ if TYPE_CHECKING: from typing import Any, Callable -class MacMusicAppPlaylistSelectWindow(bui.Window): +class MacMusicAppPlaylistSelectWindow(bui.MainWindow): """Window for selecting an iTunes playlist.""" def __init__( @@ -21,6 +21,9 @@ class MacMusicAppPlaylistSelectWindow(bui.Window): callback: Callable[[Any], Any], existing_playlist: str | None, existing_entry: Any, + *, + transition: str | None = 'in_right', + origin_widget: bui.Widget | None = None, ): from baclassic.macmusicapp import MacMusicAppMusicPlayer @@ -34,9 +37,9 @@ class MacMusicAppPlaylistSelectWindow(bui.Window): v = self._height - 90.0 v -= self._spacing * 1.0 super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), transition='in_right' - ) + root_widget=bui.containerwidget(size=(self._width, self._height)), + transition=transition, + origin_widget=origin_widget, ) btn = bui.buttonwidget( parent=self._root_widget, @@ -62,13 +65,11 @@ class MacMusicAppPlaylistSelectWindow(bui.Window): parent=self._root_widget, position=(40, v - 340), size=(self._width - 80, 400), - claims_tab=True, selection_loops_to_parent=True, ) bui.widget(edit=self._scrollwidget, right_widget=self._scrollwidget) self._column = bui.columnwidget( parent=self._scrollwidget, - claims_tab=True, selection_loops_to_parent=True, ) @@ -87,6 +88,27 @@ class MacMusicAppPlaylistSelectWindow(bui.Window): edit=self._root_widget, selected_child=self._scrollwidget ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + + # Pull stuff out of self here; if we do it in the lambda we wind + # up keeping self alive which we don't want. + callback = self._callback + existing_playlist = self._existing_playlist + existing_entry = self._existing_entry + + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + callback=callback, + existing_playlist=existing_playlist, + existing_entry=existing_entry, + transition=transition, + origin_widget=origin_widget, + ) + ) + def _playlists_cb(self, playlists: list[str]) -> None: if self._column: for widget in self._column.get_children(): @@ -112,9 +134,12 @@ class MacMusicAppPlaylistSelectWindow(bui.Window): def _sel(self, selection: str) -> None: if self._root_widget: - bui.containerwidget(edit=self._root_widget, transition='out_right') + # bui.containerwidget( + # edit=self._root_widget, transition='out_right') self._callback({'type': 'iTunesPlaylist', 'name': selection}) + self.main_window_back() def _back(self) -> None: - bui.containerwidget(edit=self._root_widget, transition='out_right') + # bui.containerwidget(edit=self._root_widget, transition='out_right') + self.main_window_back() self._callback(self._existing_entry) diff --git a/dist/ba_data/python/bauiv1lib/specialoffer.py b/dist/ba_data/python/bauiv1lib/specialoffer.py deleted file mode 100644 index 3385342..0000000 --- a/dist/ba_data/python/bauiv1lib/specialoffer.py +++ /dev/null @@ -1,569 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI for presenting sales/etc.""" - -from __future__ import annotations - -import copy -import logging -from typing import TYPE_CHECKING - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any - - -class SpecialOfferWindow(bui.Window): - """Window for presenting sales/etc.""" - - def __init__(self, offer: dict[str, Any], transition: str = 'in_right'): - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - # pylint: disable=too-many-locals - from babase import SpecialChar - from bauiv1lib.store import item as storeitemui - - plus = bui.app.plus - assert plus is not None - - assert bui.app.classic is not None - store = bui.app.classic.store - - self._cancel_delay = offer.get('cancelDelay', 0) - - # First thing: if we're offering pro or an IAP, see if we have a - # price for it. - # If not, abort and go into zombie mode (the user should never see - # us that way). - - real_price: str | None - - # Misnomer: 'pro' actually means offer 'pro_sale'. - if offer['item'] in ['pro', 'pro_fullprice']: - real_price = plus.get_price( - 'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale' - ) - if real_price is None and bui.app.env.debug: - print('NOTE: Faking prices for debug build.') - real_price = '$1.23' - zombie = real_price is None - elif isinstance(offer['price'], str): - # (a string price implies IAP id) - real_price = plus.get_price(offer['price']) - if real_price is None and bui.app.env.debug: - print('NOTE: Faking price for debug build.') - real_price = '$1.23' - zombie = real_price is None - else: - real_price = None - zombie = False - if real_price is None: - real_price = '?' - - if offer['item'] in ['pro', 'pro_fullprice']: - self._offer_item = 'pro' - else: - self._offer_item = offer['item'] - - # If we wanted a real price but didn't find one, go zombie. - if zombie: - return - - # This can pop up suddenly, so lets block input for 1 second. - bui.lock_all_input() - bui.apptimer(1.0, bui.unlock_all_input) - bui.getsound('ding').play() - bui.apptimer(0.3, bui.getsound('ooh').play) - self._offer = copy.deepcopy(offer) - self._width = 580 - self._height = 590 - uiscale = bui.app.ui_v1.uiscale - super().__init__( - root_widget=bui.containerwidget( - size=(self._width, self._height), - transition=transition, - scale=( - 1.2 - if uiscale is bui.UIScale.SMALL - else 1.15 if uiscale is bui.UIScale.MEDIUM else 1.0 - ), - stack_offset=( - (0, -15) if uiscale is bui.UIScale.SMALL else (0, 0) - ), - ) - ) - self._is_bundle_sale = False - try: - if offer['item'] in ['pro', 'pro_fullprice']: - original_price_str = plus.get_price('pro') - if original_price_str is None: - original_price_str = '?' - new_price_str = plus.get_price('pro_sale') - if new_price_str is None: - new_price_str = '?' - percent_off_text = '' - else: - # If the offer includes bonus tickets, it's a bundle-sale. - if ( - 'bonusTickets' in offer - and offer['bonusTickets'] is not None - ): - self._is_bundle_sale = True - original_price = plus.get_v1_account_misc_read_val( - 'price.' + self._offer_item, 9999 - ) - - # For pure ticket prices we can show a percent-off. - if isinstance(offer['price'], int): - new_price = offer['price'] - tchar = bui.charstr(SpecialChar.TICKET) - original_price_str = tchar + str(original_price) - new_price_str = tchar + str(new_price) - percent_off = int( - round( - 100.0 - (float(new_price) / original_price) * 100.0 - ) - ) - percent_off_text = ' ' + bui.Lstr( - resource='store.salePercentText' - ).evaluate().replace('${PERCENT}', str(percent_off)) - else: - original_price_str = new_price_str = '?' - percent_off_text = '' - - except Exception: - logging.exception('Error setting up special-offer: %s.', offer) - original_price_str = new_price_str = '?' - percent_off_text = '' - - # If its a bundle sale, change the title. - if self._is_bundle_sale: - sale_text = bui.Lstr( - resource='store.saleBundleText', - fallback_resource='store.saleText', - ).evaluate() - else: - # For full pro we say 'Upgrade?' since its not really a sale. - if offer['item'] == 'pro_fullprice': - sale_text = bui.Lstr( - resource='store.upgradeQuestionText', - fallback_resource='store.saleExclaimText', - ).evaluate() - else: - sale_text = bui.Lstr( - resource='store.saleExclaimText', - fallback_resource='store.saleText', - ).evaluate() - - self._title_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height - 40), - size=(0, 0), - text=sale_text - + ( - (' ' + bui.Lstr(resource='store.oneTimeOnlyText').evaluate()) - if self._offer['oneTimeOnly'] - else '' - ) - + percent_off_text, - h_align='center', - v_align='center', - maxwidth=self._width * 0.9 - 220, - scale=1.4, - color=(0.3, 1, 0.3), - ) - - self._flash_on = False - self._flashing_timer: bui.AppTimer | None = bui.AppTimer( - 0.05, bui.WeakCall(self._flash_cycle), repeat=True - ) - bui.apptimer(0.6, bui.WeakCall(self._stop_flashing)) - - size = store.get_store_item_display_size(self._offer_item) - display: dict[str, Any] = {} - storeitemui.instantiate_store_item_display( - self._offer_item, - display, - parent_widget=self._root_widget, - b_pos=( - self._width * 0.5 - - size[0] * 0.5 - + 10 - - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), - self._height * 0.5 - - size[1] * 0.5 - + 20 - + (20 if self._is_bundle_sale else 0), - ), - b_width=size[0], - b_height=size[1], - button=not self._is_bundle_sale, - ) - - # Wire up the parts we need. - if self._is_bundle_sale: - self._plus_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.5 + 50), - size=(0, 0), - text='+', - h_align='center', - v_align='center', - maxwidth=self._width * 0.9, - scale=1.4, - color=(0.5, 0.5, 0.5), - ) - self._plus_tickets = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5 + 120, self._height * 0.5 + 50), - size=(0, 0), - text=bui.charstr(SpecialChar.TICKET_BACKING) - + str(offer['bonusTickets']), - h_align='center', - v_align='center', - maxwidth=self._width * 0.9, - scale=2.5, - color=(0.2, 1, 0.2), - ) - self._price_text = bui.textwidget( - parent=self._root_widget, - position=(self._width * 0.5, 150), - size=(0, 0), - text=real_price, - h_align='center', - v_align='center', - maxwidth=self._width * 0.9, - scale=1.4, - color=(0.2, 1, 0.2), - ) - # Total-value if they supplied it. - total_worth_item = offer.get('valueItem', None) - if total_worth_item is not None: - price = plus.get_price(total_worth_item) - total_worth_price = ( - store.get_clean_price(price) if price is not None else None - ) - if total_worth_price is not None: - total_worth_text = bui.Lstr( - resource='store.totalWorthText', - subs=[('${TOTAL_WORTH}', total_worth_price)], - ) - self._total_worth_text = bui.textwidget( - parent=self._root_widget, - text=total_worth_text, - position=(self._width * 0.5, 210), - scale=0.9, - maxwidth=self._width * 0.7, - size=(0, 0), - h_align='center', - v_align='center', - shadow=1.0, - flatness=1.0, - color=(0.3, 1, 1), - ) - - elif offer['item'] == 'pro_fullprice': - # for full-price pro we simply show full price - bui.textwidget(edit=display['price_widget'], text=real_price) - bui.buttonwidget( - edit=display['button'], on_activate_call=self._purchase - ) - else: - # Show old/new prices otherwise (for pro sale). - bui.buttonwidget( - edit=display['button'], on_activate_call=self._purchase - ) - bui.imagewidget(edit=display['price_slash_widget'], opacity=1.0) - bui.textwidget( - edit=display['price_widget_left'], text=original_price_str - ) - bui.textwidget( - edit=display['price_widget_right'], text=new_price_str - ) - - # Add ticket button only if this is ticket-purchasable. - if isinstance(offer.get('price'), int): - self._get_tickets_button = bui.buttonwidget( - parent=self._root_widget, - position=(self._width - 125, self._height - 68), - size=(90, 55), - scale=1.0, - button_type='square', - color=(0.7, 0.5, 0.85), - textcolor=(0.2, 1, 0.2), - autoselect=True, - label=bui.Lstr(resource='getTicketsWindow.titleText'), - on_activate_call=self._on_get_more_tickets_press, - ) - - self._ticket_text_update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update_tickets_text), repeat=True - ) - self._update_tickets_text() - - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - - self._cancel_button = bui.buttonwidget( - parent=self._root_widget, - position=( - (50, 40) - if self._is_bundle_sale - else (self._width * 0.5 - 75, 40) - ), - size=(150, 60), - scale=1.0, - on_activate_call=self._cancel, - autoselect=True, - label=bui.Lstr(resource='noThanksText'), - ) - self._cancel_countdown_text = bui.textwidget( - parent=self._root_widget, - text='', - position=( - (50 + 150 + 20, 40 + 27) - if self._is_bundle_sale - else (self._width * 0.5 - 75 + 150 + 20, 40 + 27) - ), - scale=1.1, - size=(0, 0), - h_align='left', - v_align='center', - shadow=1.0, - flatness=1.0, - color=(0.6, 0.5, 0.5), - ) - self._update_cancel_button_graphics() - - if self._is_bundle_sale: - self._purchase_button = bui.buttonwidget( - parent=self._root_widget, - position=(self._width - 200, 40), - size=(150, 60), - scale=1.0, - on_activate_call=self._purchase, - autoselect=True, - label=bui.Lstr(resource='store.purchaseText'), - ) - - bui.containerwidget( - edit=self._root_widget, - cancel_button=self._cancel_button, - start_button=( - self._purchase_button if self._is_bundle_sale else None - ), - selected_child=( - self._purchase_button - if self._is_bundle_sale - else display['button'] - ), - ) - - def _stop_flashing(self) -> None: - self._flashing_timer = None - bui.textwidget(edit=self._title_text, color=(0.3, 1, 0.3)) - - def _flash_cycle(self) -> None: - if not self._root_widget: - return - self._flash_on = not self._flash_on - bui.textwidget( - edit=self._title_text, - color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0), - ) - - def _update_cancel_button_graphics(self) -> None: - bui.buttonwidget( - edit=self._cancel_button, - color=( - (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.7, 0.4, 0.34) - ), - textcolor=( - (0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.9, 0.9, 1.0) - ), - ) - bui.textwidget( - edit=self._cancel_countdown_text, - text=str(self._cancel_delay) if self._cancel_delay > 0 else '', - ) - - def _update(self) -> None: - plus = bui.app.plus - assert plus is not None - - # If we've got seconds left on our countdown, update it. - if self._cancel_delay > 0: - self._cancel_delay = max(0, self._cancel_delay - 1) - self._update_cancel_button_graphics() - - can_die = False - - # We go away if we see that our target item is owned. - if self._offer_item == 'pro': - assert bui.app.classic is not None - if bui.app.classic.accounts.have_pro(): - can_die = True - else: - if plus.get_purchased(self._offer_item): - can_die = True - - if can_die: - self._transition_out('out_left') - - def _transition_out(self, transition: str = 'out_left') -> None: - # Also clear any pending-special-offer we've stored at this point. - cfg = bui.app.config - if 'pendingSpecialOffer' in cfg: - del cfg['pendingSpecialOffer'] - cfg.commit() - - bui.containerwidget(edit=self._root_widget, transition=transition) - - def _update_tickets_text(self) -> None: - from babase import SpecialChar - - plus = bui.app.plus - assert plus is not None - - if not self._root_widget: - return - sval: str | bui.Lstr - if plus.get_v1_account_state() == 'signed_in': - sval = bui.charstr(SpecialChar.TICKET) + str( - plus.get_v1_account_ticket_count() - ) - else: - sval = bui.Lstr(resource='getTicketsWindow.titleText') - bui.buttonwidget(edit=self._get_tickets_button, label=sval) - - def _on_get_more_tickets_press(self) -> None: - from bauiv1lib import account - from bauiv1lib import gettickets - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() - return - gettickets.GetTicketsWindow(modal=True).get_root_widget() - - def _purchase(self) -> None: - from bauiv1lib import gettickets - from bauiv1lib import confirm - - plus = bui.app.plus - assert plus is not None - - assert bui.app.classic is not None - store = bui.app.classic.store - - if self._offer['item'] == 'pro': - plus.purchase('pro_sale') - elif self._offer['item'] == 'pro_fullprice': - plus.purchase('pro') - elif self._is_bundle_sale: - # With bundle sales, the price is the name of the IAP. - plus.purchase(self._offer['price']) - else: - ticket_count: int | None - try: - ticket_count = plus.get_v1_account_ticket_count() - except Exception: - ticket_count = None - if ticket_count is not None and ticket_count < self._offer['price']: - gettickets.show_get_tickets_prompt() - bui.getsound('error').play() - return - - def do_it() -> None: - assert plus is not None - - plus.in_game_purchase( - 'offer:' + str(self._offer['id']), self._offer['price'] - ) - - bui.getsound('swish').play() - confirm.ConfirmWindow( - bui.Lstr( - resource='store.purchaseConfirmText', - subs=[ - ( - '${ITEM}', - store.get_store_item_name_translated( - self._offer['item'] - ), - ) - ], - ), - width=400, - height=120, - action=do_it, - ok_text=bui.Lstr( - resource='store.purchaseText', fallback_resource='okText' - ), - ) - - def _cancel(self) -> None: - if self._cancel_delay > 0: - bui.getsound('error').play() - return - self._transition_out('out_right') - - -def show_offer() -> bool: - """(internal)""" - try: - from bauiv1lib import feedback - - plus = bui.app.plus - assert plus is not None - - app = bui.app - if app.classic is None: - raise RuntimeError( - 'Classic feature-set is required to show offers.' - ) - - # Space things out a bit so we don't hit the poor user with an - # ad and then an in-game offer. - has_been_long_enough_since_ad = True - if app.classic.ads.last_ad_completion_time is not None and ( - bui.apptime() - app.classic.ads.last_ad_completion_time < 30.0 - ): - has_been_long_enough_since_ad = False - - if ( - app.classic.special_offer is not None - and has_been_long_enough_since_ad - ): - # Special case: for pro offers, store this in our prefs so - # we can re-show it if the user kills us (set phasers to - # 'NAG'!!!). - if app.classic.special_offer.get('item') == 'pro_fullprice': - cfg = app.config - cfg['pendingSpecialOffer'] = { - 'a': plus.get_v1_account_public_login_id(), - 'o': app.classic.special_offer, - } - cfg.commit() - - if app.classic.special_offer['item'] == 'rating': - # Go with a native thing if we've got one. - if bui.native_review_request_supported(): - bui.native_review_request() - else: - if app.ui_v1.available: - feedback.ask_for_rating() - else: - if app.ui_v1.available: - SpecialOfferWindow(app.classic.special_offer) - - app.classic.special_offer = None - return True - except Exception: - logging.exception('Error showing offer.') - - return False diff --git a/dist/ba_data/python/bauiv1lib/store/browser.py b/dist/ba_data/python/bauiv1lib/store/browser.py index 8ab5210..5a4baf8 100644 --- a/dist/ba_data/python/bauiv1lib/store/browser.py +++ b/dist/ba_data/python/bauiv1lib/store/browser.py @@ -13,7 +13,7 @@ import weakref import datetime from enum import Enum from threading import Thread -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from efro.util import utc_now from efro.error import CommunicationError @@ -26,13 +26,13 @@ if TYPE_CHECKING: MERCH_LINK_KEY = 'Merch Link' -class StoreBrowserWindow(bui.Window): +class StoreBrowserWindow(bui.MainWindow): """Window for browsing the store.""" class TabID(Enum): """Our available tab types.""" - EXTRAS = 'extras' + # EXTRAS = 'extras' MAPS = 'maps' MINIGAMES = 'minigames' CHARACTERS = 'characters' @@ -40,12 +40,10 @@ class StoreBrowserWindow(bui.Window): def __init__( self, - transition: str = 'in_right', - modal: bool = False, - show_tab: StoreBrowserWindow.TabID | None = None, - on_close_call: Callable[[], Any] | None = None, - back_location: str | None = None, + transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, + show_tab: StoreBrowserWindow.TabID | None = None, + minimal_toolbars: bool = False, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals @@ -58,110 +56,85 @@ class StoreBrowserWindow(bui.Window): bui.set_analytics_screen('Store Window') - scale_origin: tuple[float, float] | None - - # If they provided an origin-widget, scale up from that. - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - self.button_infos: dict[str, dict[str, Any]] | None = None self.update_buttons_timer: bui.AppTimer | None = None self._status_textwidget_update_timer = None - self._back_location = back_location - self._on_close_call = on_close_call self._show_tab = show_tab - self._modal = modal - self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040 - self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0 - self._height = ( - 578 + self._width = ( + 1800 if uiscale is bui.UIScale.SMALL - else 645 if uiscale is bui.UIScale.MEDIUM else 800 + else 1000 if uiscale is bui.UIScale.MEDIUM else 1120 + ) + self._height = ( + 1200 + if uiscale is bui.UIScale.SMALL + else 700 if uiscale is bui.UIScale.MEDIUM else 800 ) self._current_tab: StoreBrowserWindow.TabID | None = None - extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 + # extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 self.request: Any = None self._r = 'store' self._last_buy_time: float | None = None + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + ) + + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 120, screensize[0] / scale) + target_height = min(self._height - 140, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 59 + self._scroll_bottom = yoffs - 87 - self._scroll_height + super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + extra_top), - transition=transition, - toolbar_visibility='menu_full', - scale=( - 1.3 - if uiscale is bui.UIScale.SMALL - else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_store' + if (uiscale is bui.UIScale.SMALL or minimal_toolbars) + else 'menu_full' ), - scale_origin_stack_offset=scale_origin, - stack_offset=( - (0, -5) - if uiscale is bui.UIScale.SMALL - else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) self._back_button = btn = bui.buttonwidget( parent=self._root_widget, - position=(70 + x_inset, self._height - 74), - size=(140, 60), + position=(70, yoffs - 37), + size=(60, 60), scale=1.1, autoselect=True, - label=bui.Lstr(resource='doneText' if self._modal else 'backText'), - button_type=None if self._modal else 'back', - on_activate_call=self._back, + label=bui.charstr(SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) - bui.containerwidget(edit=self._root_widget, cancel_button=btn) - self._ticket_count_text: bui.Widget | None = None - self._get_tickets_button: bui.Widget | None = None - - if app.classic.allow_ticket_purchases: - self._get_tickets_button = bui.buttonwidget( - parent=self._root_widget, - size=(210, 65), - on_activate_call=self._on_get_more_tickets_press, - autoselect=True, - scale=0.9, - text_scale=1.4, - left_widget=self._back_button, - color=(0.7, 0.5, 0.85), - textcolor=(0.2, 1.0, 0.2), - label=bui.Lstr(resource='getTicketsWindow.titleText'), + if uiscale is bui.UIScale.SMALL: + self._back_button.delete() + bui.containerwidget( + edit=self._root_widget, on_cancel_call=self.main_window_back ) else: - self._ticket_count_text = bui.textwidget( - parent=self._root_widget, - size=(210, 64), - color=(0.2, 1.0, 0.2), - h_align='center', - v_align='center', - ) - - # Move this dynamically to keep it out of the way of the party icon. - self._update_get_tickets_button_pos() - self._get_ticket_pos_update_timer = bui.AppTimer( - 1.0, - bui.WeakCall(self._update_get_tickets_button_pos), - repeat=True, - ) - if self._get_tickets_button: - bui.widget( - edit=self._back_button, right_widget=self._get_tickets_button - ) - self._ticket_text_update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update_tickets_text), repeat=True - ) - self._update_tickets_text() + bui.containerwidget(edit=self._root_widget, cancel_button=btn) if ( app.classic.platform in ['mac', 'ios'] @@ -183,29 +156,28 @@ class StoreBrowserWindow(bui.Window): bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 44), + position=( + ( + self._width * 0.5 + + ( + (self._scroll_width * -0.5 + 90.0) + if uiscale is bui.UIScale.SMALL + else 0.0 + ) + ), + yoffs - (62 if uiscale is bui.UIScale.SMALL else -3.0), + ), size=(0, 0), color=app.ui_v1.title_color, - scale=1.5, - h_align='center', + scale=1.1 if uiscale is bui.UIScale.SMALL else 1.3, + h_align='left' if uiscale is bui.UIScale.SMALL else 'center', v_align='center', text=bui.Lstr(resource='storeText'), - maxwidth=420, + maxwidth=100 if uiscale is bui.UIScale.SMALL else 290, ) - if not self._modal: - bui.buttonwidget( - edit=self._back_button, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(SpecialChar.BACK), - ) - - scroll_buffer_h = 130 + 2 * x_inset - tab_buffer_h = 250 + 2 * x_inset - tabs_def = [ - (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), + # (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), ( self.TabID.MINIGAMES, @@ -218,11 +190,15 @@ class StoreBrowserWindow(bui.Window): (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), ] + tab_inset = 200 if uiscale is bui.UIScale.SMALL else 100 self._tab_row = TabRow( self._root_widget, tabs_def, - pos=(tab_buffer_h * 0.5, self._height - 130), - size=(self._width - tab_buffer_h, 50), + size=(self._scroll_width - 2.0 * tab_inset, 50), + pos=( + self._width * 0.5 - self._scroll_width * 0.5 + tab_inset, + self._scroll_bottom + self._scroll_height - 4.0, + ), on_select_call=self._set_tab, ) @@ -239,8 +215,8 @@ class StoreBrowserWindow(bui.Window): center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) img = bui.imagewidget( parent=self._root_widget, - position=(center[0] - rad * 1.04, center[1] - rad * 1.15), - size=(rad * 2.2, rad * 2.2), + position=(center[0] - rad * 1.1, center[1] - rad * 1.2), + size=(rad * 2.4, rad * 2.4), texture=bui.gettexture('circleShadow'), color=(1, 0, 0), ) @@ -302,53 +278,34 @@ class StoreBrowserWindow(bui.Window): ) self._update_tabs() - if self._get_tickets_button: + if uiscale is bui.UIScale.SMALL: + first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button bui.widget( - edit=self._get_tickets_button, down_widget=last_tab_button + edit=first_tab_button, + left_widget=bui.get_special_widget('back_button'), + up_widget=bui.get_special_widget('back_button'), ) bui.widget( edit=last_tab_button, - up_widget=self._get_tickets_button, - right_widget=self._get_tickets_button, + up_widget=bui.get_special_widget('tickets_meter'), + right_widget=bui.get_special_widget('tickets_meter'), ) - self._scroll_width = self._width - scroll_buffer_h - self._scroll_height = self._height - 180 + # self._scroll_width = self._width - scroll_buffer_h + # self._scroll_height = self._height - 180 self._scrollwidget: bui.Widget | None = None self._status_textwidget: bui.Widget | None = None self._restore_state() - def _update_get_tickets_button_pos(self) -> None: - assert bui.app.classic is not None - uiscale = bui.app.ui_v1.uiscale - pos = ( - self._width - - 252 - - ( - self._x_inset - + ( - 47 - if uiscale is bui.UIScale.SMALL - and bui.is_party_icon_visible() - else 0 - ) - ), - self._height - 70, - ) - if self._get_tickets_button: - bui.buttonwidget(edit=self._get_tickets_button, position=pos) - if self._ticket_count_text: - bui.textwidget(edit=self._ticket_count_text, position=pos) - def _restore_purchases(self) -> None: - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None if plus.accounts.primary is None: - account.show_sign_in_prompt() + show_sign_in_prompt() else: plus.restore_purchases() @@ -385,25 +342,6 @@ class StoreBrowserWindow(bui.Window): bui.textwidget(edit=tab_data['text'], text='') bui.imagewidget(edit=tab_data['img'], opacity=0.0) - def _update_tickets_text(self) -> None: - from bauiv1 import SpecialChar - - if not self._root_widget: - return - plus = bui.app.plus - assert plus is not None - sval: str | bui.Lstr - if plus.get_v1_account_state() == 'signed_in': - sval = bui.charstr(SpecialChar.TICKET) + str( - plus.get_v1_account_ticket_count() - ) - else: - sval = bui.Lstr(resource='getTicketsWindow.titleText') - if self._get_tickets_button: - bui.buttonwidget(edit=self._get_tickets_button, label=sval) - if self._ticket_count_text: - bui.textwidget(edit=self._ticket_count_text, text=sval) - def _set_tab(self, tab_id: TabID) -> None: if self._current_tab is tab_id: return @@ -424,14 +362,14 @@ class StoreBrowserWindow(bui.Window): self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, - position=( - (self._width - self._scroll_width) * 0.5, - self._height - self._scroll_height - 79 - 48, - ), size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_bottom, + ), claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, + border_opacity=0.4, ) # NOTE: this stuff is modified by the _Store class. @@ -465,7 +403,6 @@ class StoreBrowserWindow(bui.Window): window = self._window() if window is not None and (window.request is self): window.request = None - # noinspection PyProtectedMember window._on_response(data) # Kick off a server request. @@ -572,9 +509,8 @@ class StoreBrowserWindow(bui.Window): def buy(self, item: str) -> None: """Attempt to purchase the provided item.""" - from bauiv1lib import account + from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.confirm import ConfirmWindow - from bauiv1lib import gettickets assert bui.app.classic is not None store = bui.app.classic.store @@ -592,7 +528,7 @@ class StoreBrowserWindow(bui.Window): bui.getsound('error').play() else: if plus.get_v1_account_state() != 'signed_in': - account.show_sign_in_prompt() + show_sign_in_prompt() else: self._last_buy_time = curtime @@ -620,7 +556,11 @@ class StoreBrowserWindow(bui.Window): our_tickets = plus.get_v1_account_ticket_count() if price is not None and our_tickets < price: bui.getsound('error').play() - gettickets.show_get_tickets_prompt() + bui.screenmessage( + bui.Lstr(resource='notEnoughTicketsText'), + color=(1, 0, 0), + ) + # gettickets.show_get_tickets_prompt() else: def do_it() -> None: @@ -703,12 +643,13 @@ class StoreBrowserWindow(bui.Window): assert bui.app.classic is not None purchased = bui.app.classic.accounts.have_pro() else: - purchased = plus.get_purchased(b_type) + purchased = plus.get_v1_account_product_purchased(b_type) sale_opacity = 0.0 sale_title_text: str | bui.Lstr = '' sale_time_text: str | bui.Lstr = '' + call: Callable | None if purchased: title_color = (0.8, 0.7, 0.9, 1.0) color = (0.63, 0.55, 0.78) @@ -915,11 +856,12 @@ class StoreBrowserWindow(bui.Window): ) if 'title' not in section: section['title'] = '' - section['x_offs'] = ( - 130 - if self._tab == 'extras' - else 270 if self._tab == 'maps' else 0 - ) + section['x_offs'] = 0.0 + # section['x_offs'] = ( + # 130 + # if self._tab == 'extras' + # else 270 if self._tab == 'maps' else 0 + # ) section['y_offs'] = ( 20 if ( @@ -951,7 +893,7 @@ class StoreBrowserWindow(bui.Window): title_spacing = 40 button_border = 20 button_spacing = 4 - boffs_h = 40 + boffs_h = 0.0 self._height = 80.0 # Calc total height. @@ -960,17 +902,16 @@ class StoreBrowserWindow(bui.Window): assert self._height is not None self._height += title_spacing b_width, b_height = section['button_size'] - b_column_count = int( - math.floor( - (self._width - boffs_h - 20) - / (b_width + button_spacing) - ) - ) - b_row_count = int( - math.ceil( - float(len(section['items'])) / b_column_count - ) + b_count = len(section['items']) + b_column_count = min( + b_count, + int( + math.floor( + self._width / (b_width + button_spacing) + ) + ), ) + b_row_count = int(math.ceil(b_count / b_column_count)) b_height_total = ( 2 * button_border + b_row_count * b_height @@ -985,7 +926,6 @@ class StoreBrowserWindow(bui.Window): size=(self._width, self._height), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) v = self._height - 20 @@ -996,11 +936,7 @@ class StoreBrowserWindow(bui.Window): subs=[ ( '${SETTINGS}', - bui.Lstr( - resource=( - 'accountSettingsWindow.titleText' - ) - ), + bui.Lstr(resource='inventoryText'), ), ( '${PLAYER_PROFILES}', @@ -1100,12 +1036,15 @@ class StoreBrowserWindow(bui.Window): v -= button_border b_width, b_height = section['button_size'] b_count = len(section['items']) - b_column_count = int( - math.floor( - (self._width - boffs_h - 20) - / (b_width + button_spacing) - ) + b_column_count = min( + b_count, + int( + math.floor( + self._width / (b_width + button_spacing) + ) + ), ) + col = 0 item: dict[str, Any] assert self._store_window.button_infos is not None @@ -1116,15 +1055,17 @@ class StoreBrowserWindow(bui.Window): item['call'] = bui.WeakCall( self._store_window.buy, item_name ) - if 'x_offs' in section: - boffs_h2 = section['x_offs'] - else: - boffs_h2 = 0 + boffs_h2 = section.get('x_offs', 0.0) + boffs_v2 = section.get('y_offs', 0.0) + + # Calc the diff between the space we use and + # the space available and nudge us right by + # half that to center things. + boffs_h2 += 0.5 * ( + self._width + - ((b_width + button_spacing) * b_column_count) + ) - if 'y_offs' in section: - boffs_v2 = section['y_offs'] - else: - boffs_v2 = 0 b_pos = ( boffs_h + boffs_h2 @@ -1204,7 +1145,7 @@ class StoreBrowserWindow(bui.Window): self._store_window.update_buttons() if self._current_tab in ( - self.TabID.EXTRAS, + # self.TabID.EXTRAS, self.TabID.MINIGAMES, self.TabID.CHARACTERS, self.TabID.MAPS, @@ -1223,7 +1164,6 @@ class StoreBrowserWindow(bui.Window): size=(self._scroll_width, self._scroll_height * 0.95), background=False, claims_left_right=True, - claims_tab=True, selection_loops_to_parent=True, ) self._status_textwidget = bui.textwidget( @@ -1242,6 +1182,20 @@ class StoreBrowserWindow(bui.Window): maxwidth=self._scroll_width * 0.9, ) + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _save_state(self) -> None: try: sel = self._root_widget.get_selected_child() @@ -1250,9 +1204,7 @@ class StoreBrowserWindow(bui.Window): for tab_id, tab in self._tab_row.tabs.items() if sel == tab.button ] - if sel == self._get_tickets_button: - sel_name = 'GetTickets' - elif sel == self._scrollwidget: + if sel == self._scrollwidget: sel_name = 'Scroll' elif sel == self._back_button: sel_name = 'Back' @@ -1269,7 +1221,6 @@ class StoreBrowserWindow(bui.Window): logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: - from efro.util import enum_by_value try: sel: bui.Widget | None @@ -1280,32 +1231,26 @@ class StoreBrowserWindow(bui.Window): assert isinstance(sel_name, (str, type(None))) try: - current_tab = enum_by_value( - self.TabID, bui.app.config.get('Store Tab') - ) + current_tab = self.TabID(bui.app.config.get('Store Tab')) except ValueError: current_tab = self.TabID.CHARACTERS if self._show_tab is not None: current_tab = self._show_tab - if sel_name == 'GetTickets' and self._get_tickets_button: - sel = self._get_tickets_button - elif sel_name == 'Back': + if sel_name == 'Back': sel = self._back_button elif sel_name == 'Scroll': sel = self._scrollwidget elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): try: - sel_tab_id = enum_by_value( - self.TabID, sel_name.split(':')[-1] - ) + sel_tab_id = self.TabID(sel_name.split(':')[-1]) except ValueError: sel_tab_id = self.TabID.CHARACTERS sel = self._tab_row.tabs[sel_tab_id].button else: sel = self._tab_row.tabs[current_tab].button - # If we were requested to show a tab, select it too.. + # If we were requested to show a tab, select it too. if ( self._show_tab is not None and self._show_tab in self._tab_row.tabs @@ -1317,68 +1262,13 @@ class StoreBrowserWindow(bui.Window): except Exception: logging.exception('Error restoring state for %s.', self) - def _on_get_more_tickets_press(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account import show_sign_in_prompt - from bauiv1lib.gettickets import GetTicketsWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - plus = bui.app.plus - assert plus is not None - - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - self._save_state() - bui.containerwidget(edit=self._root_widget, transition='out_left') - window = GetTicketsWindow( - from_modal_store=self._modal, - store_back_location=self._back_location, - ).get_root_widget() - if not self._modal: - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - window, from_window=self._root_widget - ) - - def _back(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.coop.browser import CoopBrowserWindow - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - if not self._modal: - assert bui.app.classic is not None - if self._back_location == 'CoopBrowserWindow': - bui.app.ui_v1.set_main_menu_window( - CoopBrowserWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - else: - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) - if self._on_close_call is not None: - self._on_close_call() - def _check_merch_availability_in_bg_thread() -> None: # pylint: disable=cell-var-from-loop - # Merch is available from some countries only. - # Make a reasonable check to ask the master-server about this at - # launch and store the results. + # Merch is available from some countries only. Make a reasonable + # check to ask the master-server about this at launch and store the + # results. plus = bui.app.plus assert plus is not None diff --git a/dist/ba_data/python/bauiv1lib/store/button.py b/dist/ba_data/python/bauiv1lib/store/button.py deleted file mode 100644 index f205035..0000000 --- a/dist/ba_data/python/bauiv1lib/store/button.py +++ /dev/null @@ -1,324 +0,0 @@ -# Released under the MIT License. See LICENSE for details. -# -"""UI functionality for a button leading to the store.""" -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from efro.util import utc_now - -import bauiv1 as bui - -if TYPE_CHECKING: - from typing import Any, Sequence, Callable - - -class StoreButton: - """A button leading to the store.""" - - def __init__( - self, - parent: bui.Widget, - position: Sequence[float], - size: Sequence[float], - scale: float, - on_activate_call: Callable[[], Any] | None = None, - transition_delay: float | None = None, - color: Sequence[float] | None = None, - textcolor: Sequence[float] | None = None, - show_tickets: bool = False, - button_type: str | None = None, - sale_scale: float = 1.0, - ): - self._position = position - self._size = size - self._scale = scale - - if on_activate_call is None: - on_activate_call = bui.WeakCall(self._default_on_activate_call) - self._on_activate_call = on_activate_call - - self._button = bui.buttonwidget( - parent=parent, - size=size, - label='' if show_tickets else bui.Lstr(resource='storeText'), - scale=scale, - autoselect=True, - on_activate_call=self._on_activate, - transition_delay=transition_delay, - color=color, - button_type=button_type, - ) - - self._title_text: bui.Widget | None - self._ticket_text: bui.Widget | None - - if show_tickets: - self._title_text = bui.textwidget( - parent=parent, - position=( - position[0] + size[0] * 0.5 * scale, - position[1] + size[1] * 0.65 * scale, - ), - size=(0, 0), - h_align='center', - v_align='center', - maxwidth=size[0] * scale * 0.65, - text=bui.Lstr(resource='storeText'), - draw_controller=self._button, - scale=scale, - transition_delay=transition_delay, - color=textcolor, - ) - self._ticket_text = bui.textwidget( - parent=parent, - size=(0, 0), - h_align='center', - v_align='center', - maxwidth=size[0] * scale * 0.85, - text='', - color=(0.2, 1.0, 0.2), - flatness=1.0, - shadow=0.0, - scale=scale * 0.6, - transition_delay=transition_delay, - ) - else: - self._title_text = None - self._ticket_text = None - - self._circle_rad = 12 * scale - self._circle_center = (0.0, 0.0) - self._sale_circle_center = (0.0, 0.0) - - self._available_purchase_backing = bui.imagewidget( - parent=parent, - color=(1, 0, 0), - draw_controller=self._button, - size=(2.2 * self._circle_rad, 2.2 * self._circle_rad), - texture=bui.gettexture('circleShadow'), - transition_delay=transition_delay, - ) - self._available_purchase_text = bui.textwidget( - parent=parent, - size=(0, 0), - h_align='center', - v_align='center', - text='', - draw_controller=self._button, - color=(1, 1, 1), - flatness=1.0, - shadow=1.0, - scale=0.6 * scale, - maxwidth=self._circle_rad * 1.4, - transition_delay=transition_delay, - ) - - self._sale_circle_rad = 18 * scale * sale_scale - self._sale_backing = bui.imagewidget( - parent=parent, - color=(0.5, 0, 1.0), - draw_controller=self._button, - size=(2 * self._sale_circle_rad, 2 * self._sale_circle_rad), - texture=bui.gettexture('circleZigZag'), - transition_delay=transition_delay, - ) - self._sale_title_text = bui.textwidget( - parent=parent, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=self._button, - color=(0, 1, 0), - flatness=1.0, - shadow=0.0, - scale=0.5 * scale * sale_scale, - maxwidth=self._sale_circle_rad * 1.5, - transition_delay=transition_delay, - ) - self._sale_time_text = bui.textwidget( - parent=parent, - size=(0, 0), - h_align='center', - v_align='center', - draw_controller=self._button, - color=(0, 1, 0), - flatness=1.0, - shadow=0.0, - scale=0.4 * scale * sale_scale, - maxwidth=self._sale_circle_rad * 1.5, - transition_delay=transition_delay, - ) - - self.set_position(position) - self._update_timer = bui.AppTimer( - 1.0, bui.WeakCall(self._update), repeat=True - ) - self._update() - - def _on_activate(self) -> None: - bui.increment_analytics_count('Store button press') - self._on_activate_call() - - def set_position(self, position: Sequence[float]) -> None: - """Set the button position.""" - self._position = position - self._circle_center = ( - position[0] + 0.1 * self._size[0] * self._scale, - position[1] + self._size[1] * self._scale * 0.8, - ) - self._sale_circle_center = ( - position[0] + 0.07 * self._size[0] * self._scale, - position[1] + self._size[1] * self._scale * 0.8, - ) - - if not self._button: - return - bui.buttonwidget(edit=self._button, position=self._position) - if self._title_text is not None: - bui.textwidget( - edit=self._title_text, - position=( - self._position[0] + self._size[0] * 0.5 * self._scale, - self._position[1] + self._size[1] * 0.65 * self._scale, - ), - ) - if self._ticket_text is not None: - bui.textwidget( - edit=self._ticket_text, - position=( - position[0] + self._size[0] * 0.5 * self._scale, - position[1] + self._size[1] * 0.28 * self._scale, - ), - size=(0, 0), - ) - bui.imagewidget( - edit=self._available_purchase_backing, - position=( - self._circle_center[0] - self._circle_rad * 1.02, - self._circle_center[1] - self._circle_rad * 1.13, - ), - ) - bui.textwidget( - edit=self._available_purchase_text, position=self._circle_center - ) - - bui.imagewidget( - edit=self._sale_backing, - position=( - self._sale_circle_center[0] - self._sale_circle_rad, - self._sale_circle_center[1] - self._sale_circle_rad, - ), - ) - bui.textwidget( - edit=self._sale_title_text, - position=( - self._sale_circle_center[0], - self._sale_circle_center[1] + self._sale_circle_rad * 0.3, - ), - ) - bui.textwidget( - edit=self._sale_time_text, - position=( - self._sale_circle_center[0], - self._sale_circle_center[1] - self._sale_circle_rad * 0.3, - ), - ) - - def _default_on_activate_call(self) -> None: - # pylint: disable=cyclic-import - from bauiv1lib.account import show_sign_in_prompt - from bauiv1lib.store.browser import StoreBrowserWindow - - plus = bui.app.plus - assert plus is not None - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - StoreBrowserWindow(modal=True, origin_widget=self._button) - - def get_button(self) -> bui.Widget: - """Return the underlying button widget.""" - return self._button - - def _update(self) -> None: - # pylint: disable=too-many-branches - # pylint: disable=cyclic-import - from babase import SpecialChar - - plus = bui.app.plus - assert plus is not None - assert bui.app.classic is not None - store = bui.app.classic.store - - if not self._button: - return # Our instance may outlive our UI objects. - - if self._ticket_text is not None: - if plus.get_v1_account_state() == 'signed_in': - sval = bui.charstr(SpecialChar.TICKET) + str( - plus.get_v1_account_ticket_count() - ) - else: - sval = '-' - bui.textwidget(edit=self._ticket_text, text=sval) - available_purchases = store.get_available_purchase_count() - - # Old pro sale stuff.. - sale_time = store.get_available_sale_time('extras') - - # ..also look for new style sales. - if sale_time is None: - import datetime - - sales_raw = plus.get_v1_account_misc_read_val('sales', {}) - sale_times = [] - try: - # Look at the current set of sales; filter any with time - # remaining that we don't own. - for sale_item, sale_info in list(sales_raw.items()): - if not plus.get_purchased(sale_item): - to_end = ( - datetime.datetime.fromtimestamp( - sale_info['e'], datetime.UTC - ) - - utc_now() - ).total_seconds() - if to_end > 0: - sale_times.append(to_end) - except Exception: - logging.exception('Error parsing sales.') - if sale_times: - sale_time = int(min(sale_times) * 1000) - - if sale_time is not None: - bui.textwidget( - edit=self._sale_title_text, - text=bui.Lstr(resource='store.saleText'), - ) - bui.textwidget( - edit=self._sale_time_text, - text=bui.timestring(sale_time / 1000.0, centi=False), - ) - bui.imagewidget(edit=self._sale_backing, opacity=1.0) - bui.imagewidget(edit=self._available_purchase_backing, opacity=1.0) - bui.textwidget(edit=self._available_purchase_text, text='') - bui.imagewidget(edit=self._available_purchase_backing, opacity=0.0) - else: - bui.imagewidget(edit=self._sale_backing, opacity=0.0) - bui.textwidget(edit=self._sale_time_text, text='') - bui.textwidget(edit=self._sale_title_text, text='') - if available_purchases > 0: - bui.textwidget( - edit=self._available_purchase_text, - text=str(available_purchases), - ) - bui.imagewidget( - edit=self._available_purchase_backing, opacity=1.0 - ) - else: - bui.textwidget(edit=self._available_purchase_text, text='') - bui.imagewidget( - edit=self._available_purchase_backing, opacity=0.0 - ) diff --git a/dist/ba_data/python/bauiv1lib/store/item.py b/dist/ba_data/python/bauiv1lib/store/item.py index ee779cb..ca27e16 100644 --- a/dist/ba_data/python/bauiv1lib/store/item.py +++ b/dist/ba_data/python/bauiv1lib/store/item.py @@ -19,6 +19,7 @@ def instantiate_store_item_display( b_pos: tuple[float, float], b_width: float, b_height: float, + *, boffs_h: float = 0.0, boffs_h2: float = 0.0, boffs_v2: float = 0, @@ -26,6 +27,7 @@ def instantiate_store_item_display( button: bool = True, ) -> None: """(internal)""" + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals @@ -74,7 +76,7 @@ def instantiate_store_item_display( tint_color = None tint2_color = None tex_name: str | None = None - desc: str | None = None + desc: bui.Lstr | None = None modes: bui.Lstr | None = None if item_name.startswith('characters.'): @@ -149,6 +151,30 @@ def instantiate_store_item_display( base_text_scale = 0.8 title_v = 0.48 price_v = 0.17 + elif item_name == 'upgrades.infinite_runaround': + base_text_scale = 0.8 + desc = bui.Lstr( + translate=( + 'gameDescriptions', + 'Prevent enemies from reaching the exit.', + ) + ) + modes = bui.Lstr(resource='playModes.coopText') + tex_name = 'towerDPreview' + title_v = 0.48 + price_v = 0.17 + elif item_name == 'upgrades.infinite_onslaught': + base_text_scale = 0.8 + desc = bui.Lstr( + translate=( + 'gameDescriptions', + 'Defeat all enemies.', + ) + ) + modes = bui.Lstr(resource='playModes.coopText') + tex_name = 'doomShroomPreview' + title_v = 0.48 + price_v = 0.17 elif item_name.startswith('icons.'): base_text_scale = 1.5 @@ -510,7 +536,10 @@ def instantiate_store_item_display( texture=bui.gettexture(tex_name), ) - if item_name.startswith('games.'): + if item_name.startswith('games.') or item_name in ( + 'upgrades.infinite_runaround', + 'upgrades.infinite_onslaught', + ): frame_size = b_width * 0.8 im_dim = frame_size * (100.0 / 113.0) im_pos = ( diff --git a/dist/ba_data/python/bauiv1lib/tabs.py b/dist/ba_data/python/bauiv1lib/tabs.py index ffb0cff..c5bfef8 100644 --- a/dist/ba_data/python/bauiv1lib/tabs.py +++ b/dist/ba_data/python/bauiv1lib/tabs.py @@ -37,6 +37,7 @@ class TabRow(Generic[T]): tabdefs: list[tuple[T, bui.Lstr]], pos: tuple[float, float], size: tuple[float, float], + *, on_select_call: Callable[[T], None] | None = None, ) -> None: if not tabdefs: @@ -71,12 +72,12 @@ class TabRow(Generic[T]): bui.buttonwidget( edit=tab.button, color=(0.5, 0.4, 0.93), - textcolor=(0.85, 0.75, 0.95), + textcolor=(0.82, 0.72, 0.92), ) # lit else: bui.buttonwidget( edit=tab.button, - color=(0.52, 0.48, 0.63), + color=(0.50, 0.44, 0.63), textcolor=(0.65, 0.6, 0.7), ) # unlit diff --git a/dist/ba_data/python/bauiv1lib/tournamententry.py b/dist/ba_data/python/bauiv1lib/tournamententry.py index 972fddc..550beaf 100644 --- a/dist/ba_data/python/bauiv1lib/tournamententry.py +++ b/dist/ba_data/python/bauiv1lib/tournamententry.py @@ -28,11 +28,13 @@ class TournamentEntryWindow(PopupWindow): offset: tuple[float, float] = (0.0, 0.0), on_close_call: Callable[[], Any] | None = None, ): - # Needs some tidying. + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements + from bauiv1lib.coop.tournamentbutton import USE_ENTRY_FEES + assert bui.app.classic is not None assert bui.app.plus bui.set_analytics_screen('Tournament Entry Window') @@ -42,9 +44,15 @@ class TournamentEntryWindow(PopupWindow): self._tournament_id ] + self._purchase_name: str | None + self._purchase_price_name: str | None + # Set a few vars depending on the tourney fee. self._fee = self._tournament_info['fee'] - self._allow_ads = self._tournament_info['allowAds'] + assert isinstance(self._fee, int | None) + self._allow_ads = ( + self._tournament_info['allowAds'] if USE_ENTRY_FEES else False + ) if self._fee == 4: self._purchase_name = 'tournament_entry_4' self._purchase_price_name = 'price.tournament_entry_4' @@ -57,6 +65,9 @@ class TournamentEntryWindow(PopupWindow): elif self._fee == 1: self._purchase_name = 'tournament_entry_1' self._purchase_price_name = 'price.tournament_entry_1' + elif self._fee is None or self._fee == -1: + self._purchase_name = None + self._purchase_price_name = 'FREE-WOOT' else: if self._fee != 0: raise ValueError('invalid fee: ' + str(self._fee)) @@ -100,7 +111,7 @@ class TournamentEntryWindow(PopupWindow): scale=scale, bg_color=bg_color, offset=offset, - toolbar_visibility='menu_currency', + toolbar_visibility='menu_store_no_back', ) self._last_ad_press_time = -9999.0 @@ -135,7 +146,8 @@ class TournamentEntryWindow(PopupWindow): scale=0.6, text=bui.Lstr(resource='tournamentEntryText'), maxwidth=180, - color=(1, 1, 1, 0.4), + # color=(1, 1, 1, 0.4), + color=bui.app.ui_v1.title_color, ) btn = self._pay_with_tickets_button = bui.buttonwidget( @@ -218,7 +230,7 @@ class TournamentEntryWindow(PopupWindow): h_align='center', v_align='center', scale=0.6, - # Note: AdMob now requires rewarded ad usage + # Note to self: AdMob requires rewarded ad usage # specifically says 'Ad' in it. text=bui.Lstr(resource='watchAnAdText'), maxwidth=95, @@ -273,28 +285,6 @@ class TournamentEntryWindow(PopupWindow): self._get_tickets_button: bui.Widget | None = None self._ticket_count_text: bui.Widget | None = None - if not bui.app.ui_v1.use_toolbars: - if bui.app.classic.allow_ticket_purchases: - self._get_tickets_button = bui.buttonwidget( - parent=self.root_widget, - position=(self._width - 190 + 105, self._height - 34), - autoselect=True, - scale=0.5, - size=(120, 60), - textcolor=(0.2, 1, 0.2), - label=bui.charstr(bui.SpecialChar.TICKET), - color=(0.65, 0.5, 0.8), - on_activate_call=self._on_get_tickets_press, - ) - else: - self._ticket_count_text = bui.textwidget( - parent=self.root_widget, - scale=0.5, - position=(self._width - 190 + 125, self._height - 34), - color=(0.2, 1, 0.2), - h_align='center', - v_align='center', - ) self._seconds_remaining = None @@ -461,29 +451,52 @@ class TournamentEntryWindow(PopupWindow): ) # Keep price up-to-date and update the button with it. - self._purchase_price = plus.get_v1_account_misc_read_val( - self._purchase_price_name, None - ) + if self._purchase_price_name is not None: + self._purchase_price = ( + 0 + if self._purchase_price_name == 'FREE-WOOT' + else plus.get_v1_account_misc_read_val( + self._purchase_price_name, None + ) + ) + # HACK - this is always free now, so just have this say 'PLAY' bui.textwidget( edit=self._ticket_cost_text, text=( - bui.Lstr(resource='getTicketsWindow.freeText') - if self._purchase_price == 0 - else bui.Lstr( - resource='getTicketsWindow.ticketsText', - subs=[ - ( - '${COUNT}', - ( - str(self._purchase_price) - if self._purchase_price is not None - else '?' - ), - ) - ], - ) + bui.Lstr(resource='playText') + # if self._purchase_price == 0 + # else bui.Lstr( + # resource='getTicketsWindow.ticketsText', + # subs=[ + # ( + # '${COUNT}', + # ( + # str(self._purchase_price) + # if self._purchase_price is not None + # else '?' + # ), + # ) + # ], + # ) ), + # text=( + # bui.Lstr(resource='getTicketsWindow.freeText') + # if self._purchase_price == 0 + # else bui.Lstr( + # resource='getTicketsWindow.ticketsText', + # subs=[ + # ( + # '${COUNT}', + # ( + # str(self._purchase_price) + # if self._purchase_price is not None + # else '?' + # ), + # ) + # ], + # ) + # ), position=( self._ticket_cost_text_position_free if self._purchase_price == 0 @@ -494,19 +507,20 @@ class TournamentEntryWindow(PopupWindow): bui.textwidget( edit=self._free_plays_remaining_text, - text=( - '' - if ( - self._tournament_info['freeTriesRemaining'] in [None, 0] - or self._purchase_price != 0 - ) - else '' + str(self._tournament_info['freeTriesRemaining']) - ), + # text=( + # '' + # if ( + # self._tournament_info['freeTriesRemaining'] in [None, 0] + # or self._purchase_price != 0 + # ) + # else '' + str(self._tournament_info['freeTriesRemaining']) + # ), + text='', # No longer relevant. ) bui.imagewidget( edit=self._ticket_img, - opacity=0.2 if self._purchase_price == 0 else 1.0, + opacity=0.0 if self._purchase_price == 0 else 1.0, position=( self._ticket_img_pos_free if self._purchase_price == 0 @@ -569,15 +583,16 @@ class TournamentEntryWindow(PopupWindow): self._launched = True launched = False - # If they gave us an existing, non-consistent - # practice activity, just restart it. + # If they gave us an existing, non-consistent practice activity, + # just restart it. if ( self._tournament_activity is not None and not practice == self._tournament_activity.session.submit_score ): try: if not practice: - bui.apptimer(0.1, bui.getsound('cashRegister').play) + bui.apptimer(0.1, bui.getsound('drumRollShort').play) + # bui.apptimer(0.1, bui.getsound('cashRegister').play) bui.screenmessage( bui.Lstr( translate=( @@ -606,7 +621,8 @@ class TournamentEntryWindow(PopupWindow): # launch a new session. if not launched: if not practice: - bui.apptimer(0.1, bui.getsound('cashRegister').play) + bui.apptimer(0.1, bui.getsound('drumRollShort').play) + # bui.apptimer(0.1, bui.getsound('cashRegister').play) bui.screenmessage( bui.Lstr( translate=('serverResponses', 'Entering tournament...') @@ -632,7 +648,7 @@ class TournamentEntryWindow(PopupWindow): bui.apptimer(0 if practice else 1.25, self._transition_out) def _on_pay_with_tickets_press(self) -> None: - from bauiv1lib import gettickets + # from bauiv1lib import gettickets plus = bui.app.plus assert plus is not None @@ -675,15 +691,21 @@ class TournamentEntryWindow(PopupWindow): ticket_count = None ticket_cost = self._purchase_price if ticket_count is not None and ticket_count < ticket_cost: - gettickets.show_get_tickets_prompt() bui.getsound('error').play() + bui.screenmessage( + bui.Lstr(resource='notEnoughTicketsText'), + color=(1, 0, 0), + ) + # gettickets.show_get_tickets_prompt() self._transition_out() return cur_time = bui.apptime() self._last_ticket_press_time = cur_time - assert isinstance(ticket_cost, int) - plus.in_game_purchase(self._purchase_name, ticket_cost) + + if self._purchase_name is not None: + assert isinstance(ticket_cost, int) + plus.in_game_purchase(self._purchase_name, ticket_cost) self._entering = True plus.add_v1_account_transaction( @@ -763,7 +785,7 @@ class TournamentEntryWindow(PopupWindow): # This should have awarded us the tournament_entry_ad purchase; # make sure that's present. # (otherwise the server will ignore our tournament entry anyway) - if not plus.get_purchased('tournament_entry_ad'): + if not plus.get_v1_account_product_purchased('tournament_entry_ad'): print('no tournament_entry_ad purchase present in _on_ad_complete') bui.screenmessage(bui.Lstr(resource='errorText'), color=(1, 0, 0)) bui.getsound('error').play() @@ -780,30 +802,20 @@ class TournamentEntryWindow(PopupWindow): plus.run_v1_account_transactions() self._launch() - def _on_get_tickets_press(self) -> None: - from bauiv1lib import gettickets - - # If we're already entering, ignore presses. - if self._entering: - return - - # Bring up get-tickets window and then kill ourself (we're on the - # overlay layer so we'd show up above it). - gettickets.GetTicketsWindow( - modal=True, origin_widget=self._get_tickets_button - ) - self._transition_out() - def _on_cancel(self) -> None: plus = bui.app.plus assert plus is not None # Don't allow canceling for several seconds after poking an enter # button if it looks like we're waiting on a purchase or entering # the tournament. - if (bui.apptime() - self._last_ticket_press_time < 6.0) and ( - plus.have_outstanding_v1_account_transactions() - or plus.get_purchased(self._purchase_name) - or self._entering + if ( + (bui.apptime() - self._last_ticket_press_time < 6.0) + and self._purchase_name is not None + and ( + plus.have_outstanding_v1_account_transactions() + or plus.get_v1_account_product_purchased(self._purchase_name) + or self._entering + ) ): bui.getsound('error').play() return diff --git a/dist/ba_data/python/bauiv1lib/tournamentscores.py b/dist/ba_data/python/bauiv1lib/tournamentscores.py index e4be2db..b504c1d 100644 --- a/dist/ba_data/python/bauiv1lib/tournamentscores.py +++ b/dist/ba_data/python/bauiv1lib/tournamentscores.py @@ -21,33 +21,21 @@ class TournamentScoresWindow(PopupWindow): def __init__( self, tournament_id: str, - tournament_activity: bs.GameActivity | None = None, + *, position: tuple[float, float] = (0.0, 0.0), - scale: float | None = None, - offset: tuple[float, float] = (0.0, 0.0), - tint_color: Sequence[float] = (1.0, 1.0, 1.0), - tint2_color: Sequence[float] = (1.0, 1.0, 1.0), - selected_character: str | None = None, - on_close_call: Callable[[], Any] | None = None, ): plus = bui.app.plus assert plus is not None - del tournament_activity # unused arg - del tint_color # unused arg - del tint2_color # unused arg - del selected_character # unused arg self._tournament_id = tournament_id self._subcontainer: bui.Widget | None = None - self._on_close_call = on_close_call assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale - if scale is None: - scale = ( - 2.3 - if uiscale is bui.UIScale.SMALL - else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 - ) + scale = ( + 2.3 + if uiscale is bui.UIScale.SMALL + else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 + ) self._transitioning_out = False self._width = 400 @@ -59,13 +47,12 @@ class TournamentScoresWindow(PopupWindow): bg_color = (0.5, 0.4, 0.6) - # creates our _root_widget + # Creates our _root_widget. super().__init__( position=position, size=(self._width, self._height), scale=scale, bg_color=bg_color, - offset=offset, ) self._cancel_button = bui.buttonwidget( @@ -90,7 +77,7 @@ class TournamentScoresWindow(PopupWindow): scale=0.6, text=bui.Lstr(resource='tournamentStandingsText'), maxwidth=200, - color=(1, 1, 1, 0.4), + color=bui.app.ui_v1.title_color, ) self._scrollwidget = bui.scrollwidget( @@ -99,16 +86,20 @@ class TournamentScoresWindow(PopupWindow): position=(30, 30), highlight=False, simple_culling_v=10, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, autoselect=True) + self._loading_spinner = bui.spinnerwidget( + parent=self.root_widget, + position=(self._width * 0.5, self._height * 0.5), + style='bomb', + size=48, + ) self._loading_text = bui.textwidget( parent=self._scrollwidget, scale=0.5, - text=bui.Lstr( - value='${A}...', - subs=[('${A}', bui.Lstr(resource='loadingText'))], - ), + text='', size=(self._width - 60, 100), h_align='center', v_align='center', @@ -131,10 +122,12 @@ class TournamentScoresWindow(PopupWindow): self, data: dict[str, Any] | None ) -> None: if data is not None: - # this used to be the whole payload + # This used to be the whole payload. data_t: list[dict[str, Any]] = data['t'] - # kill our loading text if we've got scores.. otherwise just - # replace it with 'no scores yet' + + # Kill our loading text if we've got scores; otherwise just + # replace it with 'no scores yet'. + bui.spinnerwidget(edit=self._loading_spinner, visible=False) if data_t[0]['scores']: self._loading_text.delete() else: @@ -218,7 +211,8 @@ class TournamentScoresWindow(PopupWindow): def _show_player_info(self, entry: Any, textwidget: bui.Widget) -> None: from bauiv1lib.account.viewer import AccountViewerWindow - # for the moment we only work if a single player-info is present.. + # For the moment we only work if a single player-info is + # present. if len(entry[2]) != 1: bui.getsound('error').play() return @@ -237,8 +231,6 @@ class TournamentScoresWindow(PopupWindow): if not self._transitioning_out: self._transitioning_out = True bui.containerwidget(edit=self.root_widget, transition='out_scale') - if self._on_close_call is not None: - self._on_close_call() @override def on_popup_cancel(self) -> None: diff --git a/dist/ba_data/python/bauiv1lib/trophies.py b/dist/ba_data/python/bauiv1lib/trophies.py index a7982f6..25cea2a 100644 --- a/dist/ba_data/python/bauiv1lib/trophies.py +++ b/dist/ba_data/python/bauiv1lib/trophies.py @@ -32,8 +32,8 @@ class TrophiesWindow(popup.PopupWindow): else 1.65 if uiscale is bui.UIScale.MEDIUM else 1.23 ) self._transitioning_out = False - self._width = 300 - self._height = 300 + self._width = 310 + self._height = 310 bg_color = (0.5, 0.4, 0.6) super().__init__( @@ -65,7 +65,8 @@ class TrophiesWindow(popup.PopupWindow): scale=0.6, text=bui.Lstr(resource='trophiesText'), maxwidth=200, - color=(1, 1, 1, 0.4), + # color=(1, 1, 1, 0.4), + color=bui.app.ui_v1.title_color, ) self._scrollwidget = bui.scrollwidget( @@ -73,6 +74,7 @@ class TrophiesWindow(popup.PopupWindow): size=(self._width - 60, self._height - 70), position=(30, 30), capture_arrows=True, + border_opacity=0.4, ) bui.widget(edit=self._scrollwidget, autoselect=True) @@ -134,6 +136,7 @@ class TrophiesWindow(popup.PopupWindow): sub_width: int, trophy_types: list[list[str]], ) -> int: + # pylint: disable=too-many-positional-arguments from bascenev1 import get_trophy_string total_pts = 0 diff --git a/dist/ba_data/python/bauiv1lib/v2upgrade.py b/dist/ba_data/python/bauiv1lib/v2upgrade.py index 1ce5b3b..381500e 100644 --- a/dist/ba_data/python/bauiv1lib/v2upgrade.py +++ b/dist/ba_data/python/bauiv1lib/v2upgrade.py @@ -11,7 +11,6 @@ class V2UpgradeWindow(bui.Window): """A window presenting a URL to the user visually.""" def __init__(self, login_name: str, code: str): - from bauiv1lib.account.settings import show_what_is_v2_page app = bui.app assert app.classic is not None @@ -116,3 +115,12 @@ class V2UpgradeWindow(bui.Window): def _done(self) -> None: bui.containerwidget(edit=self._root_widget, transition='out_left') + + +def show_what_is_v2_page() -> None: + """Show the webpage describing V2 accounts.""" + plus = bui.app.plus + assert plus is not None + + bamasteraddr = plus.get_master_server_address(version=2) + bui.open_url(f'{bamasteraddr}/whatisv2') diff --git a/dist/ba_data/python/bauiv1lib/watch.py b/dist/ba_data/python/bauiv1lib/watch.py index 84ef3c8..ae4a280 100644 --- a/dist/ba_data/python/bauiv1lib/watch.py +++ b/dist/ba_data/python/bauiv1lib/watch.py @@ -7,7 +7,7 @@ from __future__ import annotations import os import logging from enum import Enum -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, cast, override import bascenev1 as bs import bauiv1 as bui @@ -16,7 +16,7 @@ if TYPE_CHECKING: from typing import Any -class WatchWindow(bui.Window): +class WatchWindow(bui.MainWindow): """Window for watching replays.""" class TabID(Enum): @@ -31,20 +31,9 @@ class WatchWindow(bui.Window): origin_widget: bui.Widget | None = None, ): # pylint: disable=too-many-locals - # pylint: disable=too-many-statements from bauiv1lib.tabs import TabRow bui.set_analytics_screen('Watch Window') - scale_origin: tuple[float, float] | None - if origin_widget is not None: - self._transition_out = 'out_scale' - scale_origin = origin_widget.get_screen_space_center() - transition = 'in_scale' - else: - self._transition_out = 'out_right' - scale_origin = None - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_location('Watch') self._tab_data: dict[str, Any] = {} self._my_replays_scroll_width: float | None = None self._my_replays_watch_replay_button: bui.Widget | None = None @@ -56,68 +45,89 @@ class WatchWindow(bui.Window): self._r = 'watchWindow' uiscale = bui.app.ui_v1.uiscale self._width = 1440 if uiscale is bui.UIScale.SMALL else 1040 - x_inset = 200 if uiscale is bui.UIScale.SMALL else 0 self._height = ( - 578 + 900 if uiscale is bui.UIScale.SMALL else 670 if uiscale is bui.UIScale.MEDIUM else 800 ) self._current_tab: WatchWindow.TabID | None = None - extra_top = 20 if uiscale is bui.UIScale.SMALL else 0 + + # Do some fancy math to fill all available screen area up to the + # size of our backing container. This lets us fit to the exact + # screen shape at small ui scale. + screensize = bui.get_virtual_screen_size() + scale = ( + 1.5 + if uiscale is bui.UIScale.SMALL + else 0.85 if uiscale is bui.UIScale.MEDIUM else 0.65 + ) + # Calc screen size in our local container space and clamp to a + # bit smaller than our container size. + target_width = min(self._width - 120, screensize[0] / scale) + target_height = min(self._height - 120, screensize[1] / scale) + + # To get top/left coords, go to the center of our window and + # offset by half the width/height of our target area. + self.yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 + + self._scroll_width = target_width + self._scroll_height = target_height - 55 + self._scroll_y = self.yoffs - 85 - self._scroll_height super().__init__( root_widget=bui.containerwidget( - size=(self._width, self._height + extra_top), - transition=transition, - toolbar_visibility='menu_minimal', - scale_origin_stack_offset=scale_origin, - scale=( - 1.3 + size=(self._width, self._height), + toolbar_visibility=( + 'menu_minimal' if uiscale is bui.UIScale.SMALL - else 0.97 if uiscale is bui.UIScale.MEDIUM else 0.8 + else 'menu_full' ), - stack_offset=( - (0, -10) - if uiscale is bui.UIScale.SMALL - else (0, 15) if uiscale is bui.UIScale.MEDIUM else (0, 0) - ), - ) + scale=scale, + ), + transition=transition, + origin_widget=origin_widget, + # We're affected by screen size only at small ui-scale. + refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: bui.containerwidget( - edit=self._root_widget, on_cancel_call=self._back + edit=self._root_widget, on_cancel_call=self.main_window_back ) self._back_button = None else: self._back_button = btn = bui.buttonwidget( parent=self._root_widget, autoselect=True, - position=(70 + x_inset, self._height - 74), - size=(140, 60), + position=(70, self.yoffs - 50), + size=(60, 60), scale=1.1, - label=bui.Lstr(resource='backText'), - button_type='back', - on_activate_call=self._back, + label=bui.charstr(bui.SpecialChar.BACK), + button_type='backSmall', + on_activate_call=self.main_window_back, ) bui.containerwidget(edit=self._root_widget, cancel_button=btn) - bui.buttonwidget( - edit=btn, - button_type='backSmall', - size=(60, 60), - label=bui.charstr(bui.SpecialChar.BACK), - ) bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height - 38), + position=( + ( + self._width * 0.5 + + ( + (self._scroll_width * -0.5 + 93) + if uiscale is bui.UIScale.SMALL + else 0 + ) + ), + self.yoffs - (63 if uiscale is bui.UIScale.SMALL else 10), + ), size=(0, 0), color=bui.app.ui_v1.title_color, - scale=1.5, - h_align='center', + scale=1.3 if uiscale is bui.UIScale.SMALL else 1.5, + h_align='left' if uiscale is bui.UIScale.SMALL else 'center', v_align='center', text=bui.Lstr(resource=f'{self._r}.titleText'), - maxwidth=400, + maxwidth=200, ) tabdefs = [ @@ -125,55 +135,62 @@ class WatchWindow(bui.Window): self.TabID.MY_REPLAYS, bui.Lstr(resource=f'{self._r}.myReplaysText'), ), - # (self.TabID.TEST_TAB, bui.Lstr(value='Testing')), ] - scroll_buffer_h = 130 + 2 * x_inset - tab_buffer_h = 750 + 2 * x_inset + tab_bar_width = 200.0 * len(tabdefs) + tab_bar_inset = (self._scroll_width - tab_bar_width) * 0.5 self._tab_row = TabRow( self._root_widget, tabdefs, - pos=(tab_buffer_h * 0.5, self._height - 130), - size=(self._width - tab_buffer_h, 50), + pos=( + self._width * 0.5 - self._scroll_width * 0.5 + tab_bar_inset, + self._scroll_y + self._scroll_height - 4.0, + ), + size=(self._scroll_width - 2.0 * tab_bar_inset, 50), on_select_call=self._set_tab, ) - if bui.app.ui_v1.use_toolbars: - first_tab = self._tab_row.tabs[tabdefs[0][0]] - last_tab = self._tab_row.tabs[tabdefs[-1][0]] - bui.widget( - edit=last_tab.button, - right_widget=bui.get_special_widget('party_button'), - ) - if uiscale is bui.UIScale.SMALL: - bbtn = bui.get_special_widget('back_button') - bui.widget( - edit=first_tab.button, up_widget=bbtn, left_widget=bbtn - ) - - self._scroll_width = self._width - scroll_buffer_h - self._scroll_height = self._height - 180 + first_tab = self._tab_row.tabs[tabdefs[0][0]] + last_tab = self._tab_row.tabs[tabdefs[-1][0]] + bui.widget( + edit=last_tab.button, + right_widget=bui.get_special_widget('squad_button'), + ) + if uiscale is bui.UIScale.SMALL: + bbtn = bui.get_special_widget('back_button') + bui.widget(edit=first_tab.button, up_widget=bbtn, left_widget=bbtn) # Not actually using a scroll widget anymore; just an image. - scroll_left = (self._width - self._scroll_width) * 0.5 - scroll_bottom = self._height - self._scroll_height - 79 - 48 - buffer_h = 10 - buffer_v = 4 bui.imagewidget( parent=self._root_widget, - position=(scroll_left - buffer_h, scroll_bottom - buffer_v), - size=( - self._scroll_width + 2 * buffer_h, - self._scroll_height + 2 * buffer_v, + size=(self._scroll_width, self._scroll_height), + position=( + self._width * 0.5 - self._scroll_width * 0.5, + self._scroll_y, ), texture=bui.gettexture('scrollWidget'), mesh_transparent=bui.getmesh('softEdgeOutside'), + opacity=0.4, ) self._tab_container: bui.Widget | None = None self._restore_state() + @override + def get_main_window_state(self) -> bui.MainWindowState: + # Support recreating our window for back/refresh purposes. + cls = type(self) + return bui.BasicMainWindowState( + create_call=lambda transition, origin_widget: cls( + transition=transition, origin_widget=origin_widget + ) + ) + + @override + def on_main_window_close(self) -> None: + self._save_state() + def _set_tab(self, tab_id: TabID) -> None: # pylint: disable=too-many-locals @@ -193,7 +210,7 @@ class WatchWindow(bui.Window): if self._tab_container: self._tab_container.delete() scroll_left = (self._width - self._scroll_width) * 0.5 - scroll_bottom = self._height - self._scroll_height - 79 - 48 + scroll_bottom = self._scroll_y # A place where tabs can store data to get cleared when # switching to a different tab @@ -243,9 +260,9 @@ class WatchWindow(bui.Window): b_width = 140 if uiscale is bui.UIScale.SMALL else 178 b_height = ( - 107 + 110 if uiscale is bui.UIScale.SMALL - else 142 if uiscale is bui.UIScale.MEDIUM else 190 + else 142 if uiscale is bui.UIScale.MEDIUM else 180 ) b_space_extra = ( 0 @@ -258,14 +275,18 @@ class WatchWindow(bui.Window): btnv = ( c_height - ( - 48 + 40 if uiscale is bui.UIScale.SMALL - else 45 if uiscale is bui.UIScale.MEDIUM else 40 + else 40 if uiscale is bui.UIScale.MEDIUM else 40 ) - b_height ) - btnh = 40 if uiscale is bui.UIScale.SMALL else 40 - smlh = 190 if uiscale is bui.UIScale.SMALL else 225 + # Roughly center buttons and scroll-widget in the middle. + xextra = ( + self._scroll_width - (sub_scroll_width + b_width) + ) * 0.5 - 50.0 + btnh = (40 if uiscale is bui.UIScale.SMALL else 40) + xextra + smlh = (190 if uiscale is bui.UIScale.SMALL else 225) + xextra tscl = 1.0 if uiscale is bui.UIScale.SMALL else 1.2 self._my_replays_watch_replay_button = btn1 = bui.buttonwidget( parent=cnt, @@ -281,7 +302,7 @@ class WatchWindow(bui.Window): ) bui.widget(edit=btn1, up_widget=self._tab_row.tabs[tab_id].button) assert bui.app.classic is not None - if uiscale is bui.UIScale.SMALL and bui.app.ui_v1.use_toolbars: + if uiscale is bui.UIScale.SMALL: bui.widget( edit=btn1, left_widget=bui.get_special_widget('back_button'), @@ -350,6 +371,10 @@ class WatchWindow(bui.Window): return bui.increment_analytics_count('Replay watch') + # Save our place in the UI so we return there when done. + if bui.app.classic is not None: + bui.app.classic.save_ui_state() + def do_it() -> None: try: # Reset to normal speed. @@ -357,7 +382,7 @@ class WatchWindow(bui.Window): bui.fade_screen(True) assert self._my_replay_selected is not None bs.new_replay_session( - bui.get_replays_dir() + '/' + self._my_replay_selected + f'{bui.get_replays_dir()}/{self._my_replay_selected}' ) except Exception: logging.exception('Error running replay session.') @@ -611,8 +636,6 @@ class WatchWindow(bui.Window): logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: - from efro.util import enum_by_value - try: sel: bui.Widget | None assert bui.app.classic is not None @@ -621,9 +644,7 @@ class WatchWindow(bui.Window): ) assert isinstance(sel_name, (str, type(None))) try: - current_tab = enum_by_value( - self.TabID, bui.app.config.get('Watch Tab') - ) + current_tab = self.TabID(bui.app.config.get('Watch Tab')) except ValueError: current_tab = self.TabID.MY_REPLAYS self._set_tab(current_tab) @@ -634,9 +655,7 @@ class WatchWindow(bui.Window): sel = self._tab_container elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): try: - sel_tab_id = enum_by_value( - self.TabID, sel_name.split(':')[-1] - ) + sel_tab_id = self.TabID(sel_name.split(':')[-1]) except ValueError: sel_tab_id = self.TabID.MY_REPLAYS sel = self._tab_row.tabs[sel_tab_id].button @@ -648,20 +667,3 @@ class WatchWindow(bui.Window): bui.containerwidget(edit=self._root_widget, selected_child=sel) except Exception: logging.exception('Error restoring state for %s.', self) - - def _back(self) -> None: - from bauiv1lib.mainmenu import MainMenuWindow - - # no-op if our underlying widget is dead or on its way out. - if not self._root_widget or self._root_widget.transitioning_out: - return - - self._save_state() - bui.containerwidget( - edit=self._root_widget, transition=self._transition_out - ) - assert bui.app.classic is not None - bui.app.ui_v1.set_main_menu_window( - MainMenuWindow(transition='in_left').get_root_widget(), - from_window=self._root_widget, - ) diff --git a/dist/ba_data/python/efro/call.py b/dist/ba_data/python/efro/call.py index 66b0f01..11a5dbd 100644 --- a/dist/ba_data/python/efro/call.py +++ b/dist/ba_data/python/efro/call.py @@ -4,14 +4,94 @@ from __future__ import annotations -import functools -from typing import TYPE_CHECKING +import weakref +import threading +from typing import TYPE_CHECKING, TypeVar, Generic + +T = TypeVar('T') if TYPE_CHECKING: - pass + from typing import Callable -# TODO: should deprecate tpartial since it nowadays simply wraps -# functools.partial (mypy added support for functools.partial in 1.11 so -# there's no benefit to rolling our own type-safe version anymore). -# Perhaps we can use Python 13's @warnings.deprecated() stuff for this. -tpartial = functools.partial + +class CallbackSet(Generic[T]): + """A simple way to manage a set of callbacks. + + Any number of calls can be registered with a callback set. Each + registration results in a Registration object that can be used to + deregister the call from the set later. Callbacks are also + implicitly deregistered when an entry is deallocated, so make sure + to hold on to the return value when adding. + + CallbackSet instances should be used from a single thread only + (this will be checked in debug mode). + """ + + def __init__(self) -> None: + self._entries: list[weakref.ref[CallbackRegistration[T]]] = [] + self.thread: threading.Thread + if __debug__: + self.thread = threading.current_thread() + + def register(self, call: T) -> CallbackRegistration[T]: + """Register a new callback.""" + assert threading.current_thread() == self.thread + + self._prune() + + entry = CallbackRegistration(call, self) + self._entries.append(weakref.ref(entry)) + return entry + + def getcalls(self) -> list[T]: + """Return the current set of registered calls. + + Note that this returns a flattened list of calls; generally this + should protect against calls which themselves add or remove + callbacks. + """ + assert threading.current_thread() == self.thread + + self._prune() + + # Ignore calls that have been deallocated or explicitly + # deregistered. + entries = [e() for e in self._entries] + return [e.call for e in entries if e is not None and e.call is not None] + + def _prune(self) -> None: + + # Quick-out if all our entries are intact. + needs_prune = False + for entry in self._entries: + entrytarget = entry() + if entrytarget is None or entrytarget.call is None: + needs_prune = True + break + if not needs_prune: + return + + # Ok; something needs pruning. Rebuild the entries list. + newentries: list[weakref.ref[CallbackRegistration[T]]] = [] + for entry in self._entries: + entrytarget = entry() + if entrytarget is not None and entrytarget.call is not None: + newentries.append(entry) + self._entries = newentries + + +class CallbackRegistration(Generic[T]): + """An entry for a callback set.""" + + def __init__(self, call: T, callbackset: CallbackSet[T]) -> None: + self.call: T | None = call + self.callbackset: CallbackSet[T] | None = callbackset + + def deregister(self) -> None: + """Explicitly remove a callback from a CallbackSet.""" + assert ( + self.callbackset is None + or threading.current_thread() == self.callbackset.thread + ) + # Simply clear the call to mark us as dead. + self.call = None diff --git a/dist/ba_data/python/efro/dataclassio/_api.py b/dist/ba_data/python/efro/dataclassio/_api.py index 7475cba..d494c6d 100644 --- a/dist/ba_data/python/efro/dataclassio/_api.py +++ b/dist/ba_data/python/efro/dataclassio/_api.py @@ -99,18 +99,20 @@ def dataclass_to_json( def dataclass_from_dict( cls: type[T], values: dict, + *, codec: Codec = Codec.JSON, coerce_to_float: bool = True, allow_unknown_attrs: bool = True, discard_unknown_attrs: bool = False, + lossy: bool = False, ) -> T: """Given a dict, return a dataclass of a given type. The dict must be formatted to match the specified codec (generally json-friendly object types). This means that sequence values such as tuples or sets should be passed as lists, enums should be passed as - their associated values, nested dataclasses should be passed as dicts, - etc. + their associated values, nested dataclasses should be passed as + dicts, etc. All values are checked to ensure their types/values are valid. @@ -120,14 +122,22 @@ def dataclass_from_dict( (as this would break the ability to do a lossless round-trip with data). - If coerce_to_float is True, int values passed for float typed fields - will be converted to float values. Otherwise, a TypeError is raised. + If `coerce_to_float` is True, int values passed for float typed + fields will be converted to float values. Otherwise, a TypeError is + raised. - If `allow_unknown_attrs` is False, AttributeErrors will be raised for - attributes present in the dict but not on the data class. Otherwise, - they will be preserved as part of the instance and included if it is - exported back to a dict, unless `discard_unknown_attrs` is True, in - which case they will simply be discarded. + If 'allow_unknown_attrs' is False, AttributeErrors will be raised + for attributes present in the dict but not on the data class. + Otherwise, they will be preserved as part of the instance and + included if it is exported back to a dict, unless + `discard_unknown_attrs` is True, in which case they will simply be + discarded. + + If `lossy` is True, Enum attrs and IOMultiType types are allowed to + use any fallbacks defined for them. This can allow older schemas to + successfully load newer data, but this can fundamentally modify the + data, so the resulting object is flagged as 'lossy' and prevented + from being serialized back out by default. """ val = _Inputter( cls, @@ -135,6 +145,7 @@ def dataclass_from_dict( coerce_to_float=coerce_to_float, allow_unknown_attrs=allow_unknown_attrs, discard_unknown_attrs=discard_unknown_attrs, + lossy=lossy, ).run(values) assert isinstance(val, cls) return val @@ -143,9 +154,11 @@ def dataclass_from_dict( def dataclass_from_json( cls: type[T], json_str: str, + *, coerce_to_float: bool = True, allow_unknown_attrs: bool = True, discard_unknown_attrs: bool = False, + lossy: bool = False, ) -> T: """Return a dataclass instance given a json string. @@ -158,6 +171,7 @@ def dataclass_from_json( coerce_to_float=coerce_to_float, allow_unknown_attrs=allow_unknown_attrs, discard_unknown_attrs=discard_unknown_attrs, + lossy=lossy, ) diff --git a/dist/ba_data/python/efro/dataclassio/_base.py b/dist/ba_data/python/efro/dataclassio/_base.py index 246b04e..191119d 100644 --- a/dist/ba_data/python/efro/dataclassio/_base.py +++ b/dist/ba_data/python/efro/dataclassio/_base.py @@ -24,6 +24,11 @@ SIMPLE_TYPES = {int, bool, str, float, type(None)} # present. EXTRA_ATTRS_ATTR = '_DCIOEXATTRS' +# Attr name for a bool attr for flagging data as lossy, which means it +# may have been modified in some way during load and should generally not +# be written back out. +LOSSY_ATTR = '_DCIOLOSSY' + class Codec(Enum): """Specifies expected data format exported to or imported from.""" @@ -127,42 +132,68 @@ class IOMultiType(Generic[EnumT]): The default is an obscure value so that it does not conflict with members of individual type attrs, but in some cases one - might prefer to serialize it to something simpler like 'type' - by overriding this call. One just needs to make sure that no + might prefer to serialize it to something simpler like 'type' by + overriding this call. One just needs to make sure that no encompassed types serialize anything to 'type' themself. """ return '_dciotype' + # NOTE: Currently (Jan 2025) mypy complains if overrides annotate + # return type of 'Self | None'. Substituting their own explicit type + # works though (see test_dataclassio). + @classmethod + def get_unknown_type_fallback(cls) -> Self | None: + """Return a fallback object in cases of unrecognized types. + + This can allow newer data to remain readable in older + environments. Use caution with this option, however, as it + effectively modifies data. + """ + return None + class IOAttrs: """For specifying io behavior in annotations. 'storagename', if passed, is the name used when storing to json/etc. - 'store_default' can be set to False to avoid writing values when equal - to the default value. Note that this requires the dataclass field - to define a default or default_factory or for its IOAttrs to - define a soft_default value. + + 'store_default' can be set to False to avoid writing values when + equal to the default value. Note that this requires the + dataclass field to define a default or default_factory or for + its IOAttrs to define a soft_default value. + 'whole_days', if True, requires datetime values to be exactly on day boundaries (see efro.util.utc_today()). - 'whole_hours', if True, requires datetime values to lie exactly on hour - boundaries (see efro.util.utc_this_hour()). - 'whole_minutes', if True, requires datetime values to lie exactly on minute - boundaries (see efro.util.utc_this_minute()). + + 'whole_hours', if True, requires datetime values to lie exactly on + hour boundaries (see efro.util.utc_this_hour()). + + 'whole_minutes', if True, requires datetime values to lie exactly on + minute boundaries (see efro.util.utc_this_minute()). + 'soft_default', if passed, injects a default value into dataclass instantiation when the field is not present in the input data. This allows dataclasses to add new non-optional fields while - gracefully 'upgrading' old data. Note that when a soft_default is - present it will take precedence over field defaults when determining - whether to store a value for a field with store_default=False - (since the soft_default value is what we'll get when reading that - same data back in when the field is omitted). + gracefully 'upgrading' old data. Note that when a soft_default + is present it will take precedence over field defaults when + determining whether to store a value for a field with + store_default=False (since the soft_default value is what we'll + get when reading that same data back in when the field is + omitted). + 'soft_default_factory' is similar to 'default_factory' in dataclass - fields; it should be used instead of 'soft_default' for mutable types - such as lists to prevent a single default object from unintentionally - changing over time. + fields; it should be used instead of 'soft_default' for mutable + types such as lists to prevent a single default object from + unintentionally changing over time. + + 'enum_fallback', if provided, specifies an enum value that can be + substituted in the case of unrecognized input values. This can + allow newer data to remain loadable in older environments. Note + that 'lossy' must be enabled in the top level load call for this + to apply, since it can fundamentally modify data. """ - # A sentinel object to detect if a parameter is supplied or not. Use + # A sentinel object to detect if a parameter is supplied or not. Use # a class to give it a better repr. class _MissingType: pass @@ -176,16 +207,19 @@ class IOAttrs: whole_minutes: bool = False soft_default: Any = MISSING soft_default_factory: Callable[[], Any] | _MissingType = MISSING + enum_fallback: Enum | None = None def __init__( self, storagename: str | None = storagename, + *, store_default: bool = store_default, whole_days: bool = whole_days, whole_hours: bool = whole_hours, whole_minutes: bool = whole_minutes, soft_default: Any = MISSING, soft_default_factory: Callable[[], Any] | _MissingType = MISSING, + enum_fallback: Enum | None = None, ): # Only store values that differ from class defaults to keep # our instances nice and lean. @@ -215,6 +249,8 @@ class IOAttrs: raise ValueError( 'Cannot set both soft_default and soft_default_factory' ) + if enum_fallback is not cls.enum_fallback: + self.enum_fallback = enum_fallback def validate_for_field(self, cls: type, field: dataclasses.Field) -> None: """Ensure the IOAttrs instance is ok to use with the provided field.""" diff --git a/dist/ba_data/python/efro/dataclassio/_inputter.py b/dist/ba_data/python/efro/dataclassio/_inputter.py index e913816..597d2f9 100644 --- a/dist/ba_data/python/efro/dataclassio/_inputter.py +++ b/dist/ba_data/python/efro/dataclassio/_inputter.py @@ -15,11 +15,12 @@ import types import datetime from typing import TYPE_CHECKING -from efro.util import enum_by_value, check_utc +from efro.util import check_utc from efro.dataclassio._base import ( Codec, _parse_annotated, EXTRA_ATTRS_ATTR, + LOSSY_ATTR, _is_valid_for_codec, _get_origin, SIMPLE_TYPES, @@ -41,10 +42,12 @@ class _Inputter: def __init__( self, cls: type[Any], + *, codec: Codec, coerce_to_float: bool, allow_unknown_attrs: bool = True, discard_unknown_attrs: bool = False, + lossy: bool = False, ): self._cls = cls self._codec = codec @@ -52,6 +55,7 @@ class _Inputter: self._allow_unknown_attrs = allow_unknown_attrs self._discard_unknown_attrs = discard_unknown_attrs self._soft_default_validator: _Outputter | None = None + self._lossy = lossy if not allow_unknown_attrs and discard_unknown_attrs: raise ValueError( @@ -65,11 +69,12 @@ class _Inputter: outcls: type[Any] # If we're dealing with a multi-type subclass which is NOT a - # dataclass, we must rely on its stored type to figure out - # what type of dataclass we're going to. If we are a dataclass - # then we already know what type we're going to so we can - # survive without this, which is often necessary when reading - # old data that doesn't have a type id attr yet. + # dataclass (generally a custom multitype base class), then we + # must rely on its stored type enum to figure out what type of + # dataclass we're going to create. If we *are* dealing with a + # dataclass then we already know what type we're going to so we + # can survive without this, which is often necessary when + # reading old data that doesn't have a type id attr yet. if issubclass(self._cls, IOMultiType) and not dataclasses.is_dataclass( self._cls ): @@ -80,7 +85,40 @@ class _Inputter: f' {values}.' ) type_id_enum = self._cls.get_type_id_type() - enum_val = type_id_enum(type_id_val) + try: + enum_val = type_id_enum(type_id_val) + except ValueError as exc: + + # Check the fallback even if not in lossy mode, as we + # inform the user of its existence in errors in that + # case. + fallback = self._cls.get_unknown_type_fallback() + + # Sanity check that fallback is correct type. + assert isinstance(fallback, self._cls | None) + + # If we're in lossy mode, provide the fallback value. + if self._lossy: + if fallback is not None: + # Ok; they provided a fallback. Flag it as lossy + # to prevent it from being written back out by + # default, and return it. + setattr(fallback, LOSSY_ATTR, True) + return fallback + else: + # If we're *not* in lossy mode, inform the user if + # we *would* have succeeded if we were. This is + # useful for debugging these sorts of situations. + if fallback is not None: + raise ValueError( + 'Failed loading unrecognized multitype object.' + ' Note that the multitype provides a fallback' + ' and thus would succeed in lossy mode.' + ) from exc + + # Otherwise the error stands as-is. + raise + outcls = self._cls.get_type(enum_val) else: outcls = self._cls @@ -99,6 +137,17 @@ class _Inputter: if is_ext: out.did_input() + # If we're running in lossy mode, flag the object as such so we + # don't allow writing it back out and potentially accidentally + # losing data. + # + # FIXME - We are currently only flagging this at the top level, + # but this will not prevent sub-objects from being written out. + # Is that worth worrying about? Though perfect is the enemy of + # good I suppose. + if self._lossy: + setattr(out, LOSSY_ATTR, True) + return out def _value_from_input( @@ -110,6 +159,7 @@ class _Inputter: ioattrs: IOAttrs | None, ) -> Any: """Convert an assigned value to what a dataclass field expects.""" + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches @@ -181,14 +231,29 @@ class _Inputter: # dataclass (all dataclasses inheriting from the multi-type # should just be processed as dataclasses). if issubclass(origin, IOMultiType): - return self._dataclass_from_input( - _get_multitype_type(anntype, fieldpath, value), - fieldpath, - value, - ) + return self._multitype_obj(anntype, fieldpath, value) if issubclass(origin, Enum): - return enum_by_value(origin, value) + try: + return origin(value) + except ValueError as exc: + # If a fallback enum was provided in ioattrs AND we're + # in lossy mode, return that for unrecognized values. If + # one was provided but we're *not* in lossy mode, note + # that we could have loaded it if lossy mode was + # enabled. + if ioattrs is not None and ioattrs.enum_fallback is not None: + # Sanity check; make sure fallback is valid. + assert type(ioattrs.enum_fallback) is origin + if self._lossy: + return ioattrs.enum_fallback + raise ValueError( + 'Failed to load Enum. Note that it has a fallback' + ' value and thus would succeed in lossy mode.' + ) from exc + + # Otherwise the error stands as-is. + raise if issubclass(origin, datetime.datetime): return self._datetime_from_input(cls, fieldpath, value, ioattrs) @@ -232,16 +297,17 @@ class _Inputter: """Given a dict, instantiates a dataclass of the given type. The dict must be in the json-friendly format as emitted from - dataclass_to_dict. This means that sequence values such as tuples or - sets should be passed as lists, enums should be passed as their - associated values, and nested dataclasses should be passed as dicts. + dataclass_to_dict. This means that sequence values such as + tuples or sets should be passed as lists, enums should be passed + as their associated values, and nested dataclasses should be + passed as dicts. """ try: return self._do_dataclass_from_input(cls, fieldpath, values) except Exception as exc: - # Extended data types can choose to sub default data in case - # of failures (generally not a good idea but occasionally - # useful). + # Extended data types can choose to substitute default data + # in case of failures (generally not a good idea but + # occasionally useful). if issubclass(cls, IOExtendedData): fallback = cls.handle_input_error(exc) if fallback is None: @@ -293,8 +359,8 @@ class _Inputter: # However we do want to make sure the class we're loading # doesn't itself use this same name, as this could lead to # tricky breakage. We can't verify this for types at prep - # time because IOMultiTypes are lazy-loaded, so this is - # the best we can do. + # time because IOMultiTypes are lazy-loaded, so this is the + # best we can do. if type_id_store_name in fields_by_name: raise RuntimeError( f"{cls} contains a '{type_id_store_name}' field" @@ -413,6 +479,7 @@ class _Inputter: value: Any, ioattrs: IOAttrs | None, ) -> Any: + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-branches # pylint: disable=too-many-locals @@ -491,7 +558,7 @@ class _Inputter: if enumvaltype is str: for key, val in value.items(): try: - enumval = enum_by_value(keyanntype, key) + enumval = keyanntype(key) except ValueError as exc: raise ValueError( f'Got invalid key value {key} for' @@ -506,7 +573,7 @@ class _Inputter: else: for key, val in value.items(): try: - enumval = enum_by_value(keyanntype, int(key)) + enumval = keyanntype(int(key)) except (ValueError, TypeError) as exc: raise ValueError( f'Got invalid key value {key} for' @@ -533,6 +600,7 @@ class _Inputter: seqtype: type, ioattrs: IOAttrs | None, ) -> Any: + # pylint: disable=too-many-positional-arguments # Because we are json-centric, we expect a list for all sequences. if type(value) is not list: raise TypeError( @@ -561,12 +629,7 @@ class _Inputter: # values to determine which type to load for each element. if issubclass(childanntype, IOMultiType): return seqtype( - self._dataclass_from_input( - _get_multitype_type(childanntype, fieldpath, i), - fieldpath, - i, - ) - for i in value + self._multitype_obj(childanntype, fieldpath, i) for i in value ) return seqtype( @@ -574,6 +637,21 @@ class _Inputter: for i in value ) + def _multitype_obj(self, anntype: Any, fieldpath: str, value: Any) -> Any: + try: + mttype = _get_multitype_type(anntype, fieldpath, value) + except ValueError: + if self._lossy: + out = anntype.get_unknown_type_fallback() + if out is not None: + # Ok; they provided a fallback. Make sure its of our + # expected type and return it. + assert isinstance(out, anntype) + return out + raise + + return self._dataclass_from_input(mttype, fieldpath, value) + def _tuple_from_input( self, cls: type, @@ -582,6 +660,7 @@ class _Inputter: value: Any, ioattrs: IOAttrs | None, ) -> Any: + # pylint: disable=too-many-positional-arguments out: list = [] # Because we are json-centric, we expect a list for all sequences. diff --git a/dist/ba_data/python/efro/dataclassio/_outputter.py b/dist/ba_data/python/efro/dataclassio/_outputter.py index 1ca3118..e3404bd 100644 --- a/dist/ba_data/python/efro/dataclassio/_outputter.py +++ b/dist/ba_data/python/efro/dataclassio/_outputter.py @@ -21,6 +21,7 @@ from efro.dataclassio._base import ( Codec, _parse_annotated, EXTRA_ATTRS_ATTR, + LOSSY_ATTR, _is_valid_for_codec, _get_origin, SIMPLE_TYPES, @@ -40,6 +41,7 @@ class _Outputter: def __init__( self, obj: Any, + *, create: bool, codec: Codec, coerce_to_float: bool, @@ -60,6 +62,14 @@ class _Outputter: # isinstance call below fails. assert dataclasses.is_dataclass(self._obj) + # If this data has been flagged as lossy, don't allow outputting + # it. This hopefully helps avoid unintentional data + # modification/loss. + if getattr(obj, LOSSY_ATTR, False): + raise ValueError( + 'Object has been flagged as lossy; output is disallowed.' + ) + # For special extended data types, call their 'will_output' callback. # FIXME - should probably move this into _process_dataclass so it # can work on nested values. @@ -192,6 +202,7 @@ class _Outputter: value: Any, ioattrs: IOAttrs | None, ) -> Any: + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches # pylint: disable=too-many-statements @@ -393,7 +404,8 @@ class _Outputter: ), key=( None - if childanntypes[0] in [str, int, float, bool] + if childanntypes[0] + in [str, int, float, bool, datetime.datetime] else lambda v: json.dumps(v, sort_keys=True) ), ) @@ -511,6 +523,7 @@ class _Outputter: value: dict, ioattrs: IOAttrs | None, ) -> Any: + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-branches if not isinstance(value, dict): raise TypeError( diff --git a/dist/ba_data/python/efro/dataclassio/_prep.py b/dist/ba_data/python/efro/dataclassio/_prep.py index 8321dbd..1f1e41c 100644 --- a/dist/ba_data/python/efro/dataclassio/_prep.py +++ b/dist/ba_data/python/efro/dataclassio/_prep.py @@ -185,7 +185,7 @@ class PrepSession: # which allows us to pick up nested classes, etc. resolved_annotations = get_type_hints( cls, - localns=vars(cls), # type: ignore[arg-type] + localns=vars(cls), globalns=self.globalns, include_extras=True, ) @@ -261,6 +261,7 @@ class PrepSession: recursion_level: int, ) -> None: """Run prep on a dataclass.""" + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-return-statements # pylint: disable=too-many-branches # pylint: disable=too-many-statements @@ -361,7 +362,7 @@ class PrepSession: pass elif issubclass(childtypes[0], Enum): # Allow our usual str or int enum types as keys. - self.prep_enum(childtypes[0]) + self.prep_enum(childtypes[0], ioattrs=None) else: raise TypeError( f'Dict key type {childtypes[0]} for \'{attrname}\'' @@ -411,7 +412,7 @@ class PrepSession: return if issubclass(origin, Enum): - self.prep_enum(origin) + self.prep_enum(origin, ioattrs=ioattrs) return # We allow datetime objects (and google's extended subclass of @@ -461,7 +462,11 @@ class PrepSession: recursion_level=recursion_level + 1, ) - def prep_enum(self, enumtype: type[Enum]) -> None: + def prep_enum( + self, + enumtype: type[Enum], + ioattrs: IOAttrs | None, + ) -> None: """Run prep on an enum type.""" valtype: Any = None @@ -484,3 +489,13 @@ class PrepSession: f' value types; dataclassio requires' f' them to be uniform.' ) + + if ioattrs is not None: + # If they provided a fallback enum value, make sure it + # is the correct type. + if ioattrs.enum_fallback is not None: + if type(ioattrs.enum_fallback) is not enumtype: + raise TypeError( + f'enum_fallback {ioattrs.enum_fallback} does not' + f' match the field type ({enumtype}.' + ) diff --git a/dist/ba_data/python/efro/dataclassio/templatemultitype.py b/dist/ba_data/python/efro/dataclassio/templatemultitype.py new file mode 100644 index 0000000..9e91853 --- /dev/null +++ b/dist/ba_data/python/efro/dataclassio/templatemultitype.py @@ -0,0 +1,63 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Template for an IOMultitype setup. + +To use this template, simply copy the contents of this module somewhere +and then replace 'TemplateMultiType' with 'YourType'. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, assert_never, override + +from enum import Enum +from dataclasses import dataclass + +from efro.dataclassio import ioprepped, IOMultiType + +if TYPE_CHECKING: + pass + + +class TemplateMultiTypeTypeID(Enum): + """Type ID for each of our subclasses.""" + + TEST = 'test' + + +class TemplateMultiType(IOMultiType[TemplateMultiTypeTypeID]): + """Top level class for our multitype.""" + + @override + @classmethod + def get_type_id(cls) -> TemplateMultiTypeTypeID: + # 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: TemplateMultiTypeTypeID + ) -> type[TemplateMultiType]: + """Return the subclass for each of our type-ids.""" + # pylint: disable=cyclic-import + + t = TemplateMultiTypeTypeID + if type_id is t.TEST: + return Test + + # Important to make sure we provide all types. + assert_never(type_id) + + +@ioprepped +@dataclass +class Test(TemplateMultiType): + """Just a test.""" + + @override + @classmethod + def get_type_id(cls) -> TemplateMultiTypeTypeID: + return TemplateMultiTypeTypeID.TEST diff --git a/dist/ba_data/python/efro/debug.py b/dist/ba_data/python/efro/debug.py index 01a3f77..6afeb65 100644 --- a/dist/ba_data/python/efro/debug.py +++ b/dist/ba_data/python/efro/debug.py @@ -312,6 +312,7 @@ def _desc(obj: Any) -> str: def _printrefs( obj: Any, + *, level: int, max_level: int, exclude_objs: list, diff --git a/dist/ba_data/python/efro/error.py b/dist/ba_data/python/efro/error.py index fed4252..791fe2c 100644 --- a/dist/ba_data/python/efro/error.py +++ b/dist/ba_data/python/efro/error.py @@ -9,6 +9,7 @@ import errno if TYPE_CHECKING: from typing import Any + import urllib3.response from efro.terminal import ClrBase @@ -105,6 +106,79 @@ class AuthenticationError(Exception): """ +class _Urllib3HttpError(Exception): + """Exception raised for non-200 html codes.""" + + def __init__(self, code: int) -> None: + self.code = code + + # So we can see code in tracebacks. + @override + def __str__(self) -> str: + from http import HTTPStatus + + try: + desc = HTTPStatus(self.code).description + except ValueError: + desc = 'Unknown HTTP Status Code' + return f'{self.code}: {desc}' + + +def raise_for_urllib3_status( + response: urllib3.response.BaseHTTPResponse, +) -> None: + """Raise an exception for html error codes aside from 200.""" + if response.status != 200: + raise _Urllib3HttpError(code=response.status) + + +def is_urllib3_communication_error(exc: BaseException, url: str | None) -> bool: + """Is the provided exception from urllib3 a communication-related error? + + Url, if provided, can provide extra context for when to treat an error + as such an error. + + This should be passed an exception which resulted from making + requests with urllib3. It returns True for any errors that could + conceivably arise due to unavailable/poor network connections, + firewall/connectivity issues, or other issues out of our control. + These errors can often be safely ignored or presented to the user as + general 'network-unavailable' states. + """ + # Need to start building these up. For now treat everything as a + # real error. + import urllib3.exceptions + + # If this error is from hitting max-retries, look at the underlying + # error instead. + if isinstance(exc, urllib3.exceptions.MaxRetryError): + # Hmm; will a max-retry error ever not have an underlying error? + if exc.reason is None: + return False + exc = exc.reason + + if isinstance(exc, _Urllib3HttpError): + # Special sub-case: appspot.com hosting seems to give 403 errors + # (forbidden) to some countries. I'm assuming for legal reasons?.. + # Let's consider that a communication error since its out of our + # control so we don't fill up logs with it. + if exc.code == 403 and url is not None and '.appspot.com' in url: + return True + + elif isinstance(exc, urllib3.exceptions.ReadTimeoutError): + return True + + elif isinstance(exc, urllib3.exceptions.ProtocolError): + # Most protocol errors quality as CommunicationErrors, but some + # may be due to server misconfigurations or whatnot so let's + # take it on a case by case basis. + excstr = str(exc) + if 'Connection aborted.' in excstr: + return True + + return False + + def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool: """Is the provided exception from urllib a communication-related error? @@ -251,6 +325,11 @@ def is_asyncio_streams_communication_error(exc: BaseException) -> bool: if 'SSL: WRONG_VERSION_NUMBER' in excstr: return True + # Also getting this sometimes which sounds like corrupt SSL data + # or something. + if 'SSL: BAD_RECORD_TYPE' in excstr: + return True + # And seeing this very rarely; assuming its just data corruption? if 'SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC' in excstr: return True diff --git a/dist/ba_data/python/efro/log.py b/dist/ba_data/python/efro/logging.py similarity index 81% rename from dist/ba_data/python/efro/log.py rename to dist/ba_data/python/efro/logging.py index 3e1326c..40fd984 100644 --- a/dist/ba_data/python/efro/log.py +++ b/dist/ba_data/python/efro/logging.py @@ -17,12 +17,12 @@ from typing import TYPE_CHECKING, Annotated, override from threading import Thread, current_thread, Lock from efro.util import utc_now -from efro.terminal import Clr +from efro.terminal import Clr, color_enabled from efro.dataclassio import ioprepped, IOAttrs, dataclass_to_json if TYPE_CHECKING: from pathlib import Path - from typing import Any, Callable, TextIO + from typing import Any, Callable, TextIO, Literal class LogLevel(Enum): @@ -126,23 +126,26 @@ class LogHandler(logging.Handler): def __init__( self, + *, path: str | Path | None, echofile: TextIO | None, - suppress_non_root_debug: bool, cache_size_limit: int, cache_time_limit: datetime.timedelta | None, + echofile_timestamp_format: Literal['default', 'relative'] = 'default', + launch_time: float | None = None, ): super().__init__() # pylint: disable=consider-using-with self._file = None if path is None else open(path, 'w', encoding='utf-8') self._echofile = echofile + self._echofile_timestamp_format = echofile_timestamp_format self._callbacks: list[Callable[[LogEntry], None]] = [] - self._suppress_non_root_debug = suppress_non_root_debug self._file_chunks: dict[str, list[str]] = {'stdout': [], 'stderr': []} self._file_chunk_ship_task: dict[str, asyncio.Task | None] = { 'stdout': None, 'stderr': None, } + self._launch_time = time.time() if launch_time is None else launch_time self._cache_size = 0 assert cache_size_limit >= 0 self._cache_size_limit = cache_size_limit @@ -158,8 +161,8 @@ class LogHandler(logging.Handler): self._thread.start() # Spin until our thread is up and running; otherwise we could - # wind up trying to push stuff to our event loop before the - # loop exists. + # wind up trying to push stuff to our event loop before the loop + # exists. while not self._thread_bootstrapped: time.sleep(0.001) @@ -234,8 +237,8 @@ class LogHandler(logging.Handler): await asyncio.sleep(61.27) now = utc_now() with self._cache_lock: - # Prune the oldest entry as long as there is a first one that - # is too old. + # Prune the oldest entry as long as there is a first one + # that is too old. while ( self._cache and (now - self._cache[0][1].time) >= self._cache_time_limit @@ -251,9 +254,9 @@ class LogHandler(logging.Handler): This will only include entries that have been processed by the background thread, so may not include just-submitted logs or - entries for partially written stdout/stderr lines. - Entries from the range [start_index:start_index+max_entries] - which are still present in the cache will be returned. + entries for partially written stdout/stderr lines. Entries from + the range [start_index:start_index+max_entries] which are still + present in the cache will be returned. """ assert start_index >= 0 @@ -282,11 +285,11 @@ class LogHandler(logging.Handler): def _cache_slice( self, start: int, end: int, step: int = 1 ) -> list[LogEntry]: - # Deque doesn't natively support slicing but we can do it manually. - # It sounds like rotating the deque and pulling from the beginning - # is the most efficient way to do this. The downside is the deque - # gets temporarily modified in the process so we need to make sure - # we're holding the lock. + # Deque doesn't natively support slicing but we can do it + # manually. It sounds like rotating the deque and pulling from + # the beginning is the most efficient way to do this. The + # downside is the deque gets temporarily modified in the process + # so we need to make sure we're holding the lock. assert self._cache_lock.locked() cache = self._cache cache.rotate(-start) @@ -309,25 +312,19 @@ class LogHandler(logging.Handler): @override def emit(self, record: logging.LogRecord) -> None: # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + if __debug__: starttime = time.monotonic() # Called by logging to send us records. - # TODO - kill this. - if ( - self._suppress_non_root_debug - and record.name != 'root' - and record.levelname == 'DEBUG' - ): - return - # Optimization: if our log args are all simple immutable values, - # we can just kick the whole thing over to our background thread to - # be formatted there at our leisure. If anything is mutable and - # thus could possibly change between now and then or if we want - # to do immediate file echoing then we need to bite the bullet - # and do that stuff here at the call site. + # we can just kick the whole thing over to our background thread + # to be formatted there at our leisure. If anything is mutable + # and thus could possibly change between now and then or if we + # want to do immediate file echoing then we need to bite the + # bullet and do that stuff here at the call site. fast_path = self._echofile is None and self._is_immutable_log_data( record.args ) @@ -359,37 +356,39 @@ class LogHandler(logging.Handler): if __debug__: formattime = time.monotonic() - # Also immediately print pretty colored output to our echo file - # (generally stderr). We do this part here instead of in our bg - # thread because the delay can throw off command line prompts or - # make tight debugging harder. + # Also immediately print pretty colored output to our echo + # file (generally stderr). We do this part here instead of + # in our bg thread because the delay can throw off command + # line prompts or make tight debugging harder. if self._echofile is not None: - # try: - # if self._report_blocking_io_on_echo_error: - # premsg = ( - # 'WARNING: BlockingIOError ON LOG ECHO OUTPUT;' - # ' YOU ARE PROBABLY MISSING LOGS\n' - # ) - # self._report_blocking_io_on_echo_error = False - # else: - # premsg = '' - ends = LEVELNO_COLOR_CODES.get(record.levelno) - namepre = f'{Clr.WHT}{record.name}:{Clr.RST} ' - if ends is not None: - self._echofile.write( - f'{namepre}{ends[0]}' - f'{msg}{ends[1]}\n' - # f'{namepre}{ends[0]}' f'{premsg}{msg}{ends[1]}\n' - ) + if self._echofile_timestamp_format == 'relative': + timestamp = f'{record.created - self._launch_time:.3f}' else: - self._echofile.write(f'{namepre}{msg}\n') + timestamp = ( + datetime.datetime.fromtimestamp( + record.created, tz=datetime.UTC + ).strftime('%H:%M:%S') + + f'.{int(record.msecs):03d}' + ) + + # If color printing is disabled, show level through text + # instead of color. + lvlnameex = ( + '' + if color_enabled + else f' {logging.getLevelName(record.levelno)}' + ) + + preinfo = ( + f'{Clr.WHT}{timestamp}{lvlnameex} {record.name}:' + f'{Clr.RST} ' + ) + ends = LEVELNO_COLOR_CODES.get(record.levelno) + if ends is not None: + self._echofile.write(f'{preinfo}{ends[0]}{msg}{ends[1]}\n') + else: + self._echofile.write(f'{preinfo}{msg}\n') self._echofile.flush() - # except BlockingIOError: - # # Ran into this when doing a bunch of logging; assuming - # # this is asyncio's doing?.. For now trying to survive - # # the error but telling the user something is probably - # # missing in their output. - # self._report_blocking_io_on_echo_error = True if __debug__: echotime = time.monotonic() @@ -407,30 +406,29 @@ class LogHandler(logging.Handler): if __debug__: # pylint: disable=used-before-assignment - # Make noise if we're taking a significant amount of time here. - # Limit the noise to once every so often though; otherwise we - # could get a feedback loop where every log emit results in a - # warning log which results in another, etc. + # + # Make noise if we're taking a significant amount of time + # here. Limit the noise to once every so often though; + # otherwise we could get a feedback loop where every log + # emit results in a warning log which results in another, + # etc. now = time.monotonic() - # noinspection PyUnboundLocalVariable - duration = now - starttime # pyright: ignore - # noinspection PyUnboundLocalVariable - format_duration = formattime - starttime # pyright: ignore - # noinspection PyUnboundLocalVariable - echo_duration = echotime - formattime # pyright: ignore + duration = now - starttime + format_duration = formattime - starttime + echo_duration = echotime - formattime if duration > 0.05 and ( self._last_slow_emit_warning_time is None or now > self._last_slow_emit_warning_time + 10.0 ): - # Logging calls from *within* a logging handler - # sounds sketchy, so let's just kick this over to - # the bg event loop thread we've already got. + # Logging calls from *within* a logging handler sounds + # sketchy, so let's just kick this over to the bg event + # loop thread we've already got. self._last_slow_emit_warning_time = now self._event_loop.call_soon_threadsafe( partial( logging.warning, - 'efro.log.LogHandler emit took too long' - ' (%.2fs total; %.2fs format, %.2fs echo,' + 'efro.logging.LogHandler emit took too long' + ' (%.3fs total; %.3fs format, %.3fs echo,' ' fast_path=%s).', duration, format_duration, @@ -447,6 +445,7 @@ class LogHandler(logging.Handler): message: str | logging.LogRecord, labels: dict[str, str], ) -> None: + # pylint: disable=too-many-positional-arguments try: # If they passed a raw record here, bake it down to a string. if isinstance(message, logging.LogRecord): @@ -489,17 +488,17 @@ class LogHandler(logging.Handler): self._file_chunks[name].append(output) - # Individual parts of a print come across as separate writes, - # and the end of a print will be a standalone '\n' by default. - # Let's use that as a hint that we're likely at the end of - # a full print statement and ship what we've got. + # Individual parts of a print come across as separate + # writes, and the end of a print will be a standalone '\n' + # by default. Let's use that as a hint that we're likely at + # the end of a full print statement and ship what we've got. if output == '\n': self._ship_file_chunks(name, cancel_ship_task=True) else: - # By default just keep adding chunks. - # However we keep a timer running anytime we've got - # unshipped chunks so that we can ship what we've got - # after a short bit if we never get a newline. + # By default just keep adding chunks. However we keep a + # timer running anytime we've got unshipped chunks so + # that we can ship what we've got after a short bit if + # we never get a newline. ship_task = self._file_chunk_ship_task[name] if ship_task is None: self._file_chunk_ship_task[name] = ( @@ -610,6 +609,7 @@ class LogHandler(logging.Handler): self._run_callback_on_entry(call, entry) # Dump to our structured log file. + # # TODO: should set a timer for flushing; don't flush every line. if self._file is not None: entry_s = dataclass_to_json(entry) @@ -642,23 +642,9 @@ class FileLogEcho: self._name = name self._handler = handler - # Think this was a result of setting non-blocking stdin somehow; - # probably not needed. - # self._report_blocking_io_error = False - def write(self, output: Any) -> None: """Override standard write call.""" - # try: - # if self._report_blocking_io_error: - # self._report_blocking_io_error = False - # self._original.write( - # 'WARNING: BlockingIOError ENCOUNTERED;' - # ' OUTPUT IS PROBABLY MISSING' - # ) - self._original.write(output) - # except BlockingIOError: - # self._report_blocking_io_error = True self._handler.file_write(self._name, output) def flush(self) -> None: @@ -678,11 +664,12 @@ class FileLogEcho: def setup_logging( log_path: str | Path | None, level: LogLevel, - suppress_non_root_debug: bool = False, + *, log_stdout_stderr: bool = False, echo_to_stderr: bool = True, cache_size_limit: int = 0, cache_time_limit: datetime.timedelta | None = None, + launch_time: float | None = None, ) -> LogHandler: """Set up our logging environment. @@ -698,34 +685,35 @@ def setup_logging( LogLevel.CRITICAL: logging.CRITICAL, } - # Wire logger output to go to a structured log file. - # Also echo it to stderr IF we're running in a terminal. - # UPDATE: Actually gonna always go to stderr. Is there a - # reason we shouldn't? This makes debugging possible if all - # we have is access to a non-interactive terminal or file dump. - # We could add a '--quiet' arg or whatnot to change this behavior. + # Wire logger output to go to a structured log file. Also echo it to + # stderr IF we're running in a terminal. + # + # UPDATE: Actually gonna always go to stderr. Is there a reason we + # shouldn't? This makes debugging possible if all we have is access + # to a non-interactive terminal or file dump. We could add a + # '--quiet' arg or whatnot to change this behavior. # Note: by passing in the *original* stderr here before we - # (potentially) replace it, we ensure that our log echos - # won't themselves be intercepted and sent to the logger - # which would create an infinite loop. + # (potentially) replace it, we ensure that our log echos won't + # themselves be intercepted and sent to the logger which would + # create an infinite loop. loghandler = LogHandler( - log_path, + path=log_path, echofile=sys.stderr if echo_to_stderr else None, - suppress_non_root_debug=suppress_non_root_debug, + echofile_timestamp_format='relative', cache_size_limit=cache_size_limit, cache_time_limit=cache_time_limit, + launch_time=launch_time, ) # Note: going ahead with force=True here so that we replace any - # existing logger. Though we warn if it looks like we are doing - # that so we can try to avoid creating the first one. + # existing logger. Though we warn if it looks like we are doing that + # so we can try to avoid creating the first one. had_previous_handlers = bool(logging.root.handlers) logging.basicConfig( level=lmap[level], - # format='%(name)s: %(message)s', - # We dump *only* the message here. We pass various log record bits - # around and format things fancier where they end up. + # We dump *only* the message here. We pass various log record + # bits around so we can write rich logs or format things later. format='%(message)s', handlers=[loghandler], force=True, diff --git a/dist/ba_data/python/efro/message/_module.py b/dist/ba_data/python/efro/message/_module.py index 2fa91fa..db96f3b 100644 --- a/dist/ba_data/python/efro/message/_module.py +++ b/dist/ba_data/python/efro/message/_module.py @@ -19,6 +19,7 @@ def create_sender_module( protocol_create_code: str, enable_sync_sends: bool, enable_async_sends: bool, + *, private: bool = False, protocol_module_level_import_code: str | None = None, build_time_protocol_create_code: str | None = None, @@ -61,6 +62,7 @@ def create_receiver_module( basename: str, protocol_create_code: str, is_async: bool, + *, private: bool = False, protocol_module_level_import_code: str | None = None, build_time_protocol_create_code: str | None = None, diff --git a/dist/ba_data/python/efro/message/_protocol.py b/dist/ba_data/python/efro/message/_protocol.py index 928d1fa..8f0c3c3 100644 --- a/dist/ba_data/python/efro/message/_protocol.py +++ b/dist/ba_data/python/efro/message/_protocol.py @@ -42,10 +42,12 @@ class MessageProtocol: self, message_types: dict[int, type[Message]], response_types: dict[int, type[Response]], + *, forward_communication_errors: bool = False, forward_clean_errors: bool = False, remote_errors_include_stack_traces: bool = False, log_errors_on_receiver: bool = True, + log_response_decode_errors: bool = True, ) -> None: """Create a protocol with a given configuration. @@ -77,14 +79,22 @@ class MessageProtocol: goal is usually to avoid returning opaque RemoteErrors and to instead return something meaningful as part of the expected response type (even if that value itself represents a logical - error state). If 'log_errors_on_receiver' is False, however, such - exceptions will *not* be logged on the receiver. This can be - useful in combination with 'remote_errors_include_stack_traces' - and 'forward_clean_errors' in situations where all error - logging/management will be happening on the sender end. Be - aware, however, that in that case it may be possible for - communication errors to prevent such error messages from - ever being seen. + error state). If 'log_errors_on_receiver' is False, however, + such exceptions will *not* be logged on the receiver. This can + be useful in combination with + 'remote_errors_include_stack_traces' and 'forward_clean_errors' + in situations where all error logging/management will be + happening on the sender end. Be aware, however, that in that + case it may be possible for communication errors to prevent some + errors from ever being acknowledged. + + If an error occurs when decoding a message response, a + RuntimeError is generated locally. However, in practice it is + likely for such errors to be silently ignored by message + handling code alongside more common communication type errors, + meaning serious protocol breakage could go unnoticed. To avoid + this, a log message is also printed in such cases. Pass + 'log_response_decode_errors' as False to disable this logging. """ # pylint: disable=too-many-locals self.message_types_by_id: dict[int, type[Message]] = {} @@ -169,6 +179,7 @@ class MessageProtocol: remote_errors_include_stack_traces ) self.log_errors_on_receiver = log_errors_on_receiver + self.log_response_decode_errors = log_response_decode_errors @staticmethod def encode_dict(obj: dict) -> str: @@ -251,7 +262,7 @@ class MessageProtocol: return out def message_from_dict(self, data: dict) -> Message: - """Decode a message from a json string.""" + """Decode a message from a dict.""" out = self._from_dict(data, self.message_types_by_id, 'message') assert isinstance(out, Message) return out @@ -283,7 +294,14 @@ class MessageProtocol: raise UnregisteredMessageIDError( f'Got unregistered {opname} id of {m_id}.' ) - return dataclass_from_dict(msgtype, msgdict) + + # Explicitly allow any fallbacks we define for our enums and + # multitypes. This allows us to build message types that remain + # loadable even when containing unrecognized future + # enums/multitype data. Be aware that this flags the object as + # 'lossy' however which prevents it from being reserialized by + # default. + return dataclass_from_dict(msgtype, msgdict, lossy=True) def _get_module_header( self, @@ -414,6 +432,7 @@ class MessageProtocol: protocol_module_level_import_code: str | None = None, ) -> str: """Used by create_sender_module(); do not call directly.""" + # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-locals # pylint: disable=too-many-branches import textwrap @@ -531,6 +550,7 @@ class MessageProtocol: ) -> str: """Used by create_receiver_module(); do not call directly.""" # pylint: disable=too-many-locals + # pylint: disable=too-many-positional-arguments import textwrap desc = 'asynchronous' if is_async else 'synchronous' diff --git a/dist/ba_data/python/efro/message/_sender.py b/dist/ba_data/python/efro/message/_sender.py index 93cd38f..189c218 100644 --- a/dist/ba_data/python/efro/message/_sender.py +++ b/dist/ba_data/python/efro/message/_sender.py @@ -6,6 +6,7 @@ Supports static typing for message types and possible return types. from __future__ import annotations +import logging from typing import TYPE_CHECKING from efro.error import CleanError, RemoteError, CommunicationError @@ -369,6 +370,19 @@ class MessageSender: bound_obj, message, response_dict, response ) except Exception as exc: + + # We pragmatically log by default if decoding fails. This + # means a message type was likely changed in a way that + # breaks the protocol, but individual message handlers are + # likely to lump all errors together (communication and + # otherwise) which could cause such breakage to go + # unnoticed. + if self.protocol.log_response_decode_errors: + logging.exception( + 'Error decoding message response;' + ' protocol might be broken.', + ) + response = ErrorSysResponse( error_message='Error decoding raw response.', error_type=ErrorSysResponse.ErrorType.LOCAL, diff --git a/dist/ba_data/python/efro/rpc.py b/dist/ba_data/python/efro/rpc.py index 9acfab0..e4477cb 100644 --- a/dist/ba_data/python/efro/rpc.py +++ b/dist/ba_data/python/efro/rpc.py @@ -180,6 +180,7 @@ class RPCEndpoint: reader: asyncio.StreamReader, writer: asyncio.StreamWriter, label: str, + *, debug_print: bool = False, debug_print_io: bool = False, debug_print_call: Callable[[str], None] | None = None, @@ -426,6 +427,7 @@ class RPCEndpoint: bytes_awaitable: asyncio.Task[bytes], message_id: int, ) -> bytes: + # pylint: disable=too-many-positional-arguments # We need to know their protocol, so if we haven't gotten a handshake # from them yet, just wait. while self._peer_info is None: diff --git a/dist/ba_data/python/efro/threadpool.py b/dist/ba_data/python/efro/threadpool.py new file mode 100644 index 0000000..e10a94a --- /dev/null +++ b/dist/ba_data/python/efro/threadpool.py @@ -0,0 +1,87 @@ +# Released under the MIT License. See LICENSE for details. +# +"""Thread pool functionality.""" + +from __future__ import annotations + +import time +import logging +import threading +from typing import TYPE_CHECKING, ParamSpec +from concurrent.futures import ThreadPoolExecutor + +if TYPE_CHECKING: + from typing import Any, Callable + from concurrent.futures import Future + +P = ParamSpec('P') + +logger = logging.getLogger(__name__) + + +class ThreadPoolExecutorPlus(ThreadPoolExecutor): + """A ThreadPoolExecutor with additional functionality added.""" + + def __init__( + self, + max_workers: int | None = None, + thread_name_prefix: str = '', + initializer: Callable[[], None] | None = None, + max_no_wait_count: int | None = None, + ) -> None: + super().__init__( + max_workers=max_workers, + thread_name_prefix=thread_name_prefix, + initializer=initializer, + ) + self.no_wait_count = 0 + + self._max_no_wait_count = ( + max_no_wait_count + if max_no_wait_count is not None + else 50 if max_workers is None else max_workers * 2 + ) + self._last_no_wait_warn_time: float | None = None + self._no_wait_count_lock = threading.Lock() + + def submit_no_wait( + self, call: Callable[P, Any], *args: P.args, **keywds: P.kwargs + ) -> None: + """Submit work to the threadpool with no expectation of waiting. + + Any errors occurring in the passed callable will be logged. This + call will block and log a warning if the threadpool reaches its + max queued no-wait call count. + """ + # If we're too backlogged, issue a warning and block until we + # aren't. We don't bother with the lock here since this can be + # slightly inexact. In general we should aim to not hit this + # limit but it is good to have backpressure to avoid runaway + # queues in cases of network outages/etc. + if self.no_wait_count > self._max_no_wait_count: + now = time.monotonic() + if ( + self._last_no_wait_warn_time is None + or now - self._last_no_wait_warn_time > 10.0 + ): + logger.warning( + 'ThreadPoolExecutorPlus hit max no-wait limit of %s;' + ' blocking.', + self._max_no_wait_count, + ) + self._last_no_wait_warn_time = now + while self.no_wait_count > self._max_no_wait_count: + time.sleep(0.01) + + fut = self.submit(call, *args, **keywds) + with self._no_wait_count_lock: + self.no_wait_count += 1 + fut.add_done_callback(self._no_wait_done) + + def _no_wait_done(self, fut: Future) -> None: + with self._no_wait_count_lock: + self.no_wait_count -= 1 + try: + fut.result() + except Exception: + logger.exception('Error in work submitted via submit_no_wait().') diff --git a/dist/ba_data/python/efro/util.py b/dist/ba_data/python/efro/util.py index a771cb4..f3731d8 100644 --- a/dist/ba_data/python/efro/util.py +++ b/dist/ba_data/python/efro/util.py @@ -1,19 +1,22 @@ # Released under the MIT License. See LICENSE for details. # +# pylint: disable=too-many-lines """Small handy bits of functionality.""" from __future__ import annotations import os import time +import random import weakref +import functools import datetime from enum import Enum -from typing import TYPE_CHECKING, cast, TypeVar, Generic, overload +from typing import TYPE_CHECKING, cast, TypeVar, Generic, overload, ParamSpec if TYPE_CHECKING: import asyncio - from typing import Any, Callable, Literal + from typing import Any, Callable, Literal, Sequence T = TypeVar('T') ValT = TypeVar('ValT') @@ -22,6 +25,8 @@ SelfT = TypeVar('SelfT') RetT = TypeVar('RetT') EnumT = TypeVar('EnumT', bound=Enum) +P = ParamSpec('P') + class _EmptyObj: pass @@ -32,6 +37,36 @@ class _EmptyObj: _g_empty_weak_ref = weakref.ref(_EmptyObj()) assert _g_empty_weak_ref() is None +# Note to self: adding a special form of partial for when we don't need +# to pass further args/kwargs (which I think is most cases). Even though +# partial is now type-checked in Mypy (as of Nov 2024) there are still some +# pitfalls that this avoids (see func docs below). Perhaps it would make +# sense to simply define a Call class for this purpose; it might be more +# efficient than wrapping partial anyway (should test this). +if TYPE_CHECKING: + + def strict_partial( + func: Callable[P, T], *args: P.args, **kwargs: P.kwargs + ) -> Callable[[], T]: + """A version of functools.partial requiring all args to be passed. + + This helps avoid pitfalls where a function is wrapped in a + partial but then an extra required arg is added to the function + but no type checking error is triggered at usage sites because + vanilla partial assumes that extra arg will be provided at call + time. + + Note: it would seem like this pitfall could also be avoided on + the back end by ensuring that the thing accepting the partial + asks for Callable[[], None] instead of just Callable, but as of + Nov 2024 it seems that Mypy does not support this; it in fact + allows partials to be passed for any callable signature(!). + """ + ... + +else: + strict_partial = functools.partial + def explicit_bool(val: bool) -> bool: """Return a non-inferable boolean value. @@ -42,8 +77,6 @@ def explicit_bool(val: bool) -> bool: # pylint: disable=no-else-return if TYPE_CHECKING: # infer this! - import random - return random.random() < 0.5 else: return val @@ -62,35 +95,6 @@ def snake_case_to_camel_case(val: str) -> str: return val.replace('_', ' ').title().replace(' ', '') -def enum_by_value(cls: type[EnumT], value: Any) -> EnumT: - """Create an enum from a value. - - This is basically the same as doing 'obj = EnumType(value)' except - that it works around an issue where a reference loop is created - if an exception is thrown due to an invalid value. Since we disable - the cyclic garbage collector for most of the time, such loops can lead - to our objects sticking around longer than we want. - This issue has been submitted to Python as a bug so hopefully we can - remove this eventually if it gets fixed: https://bugs.python.org/issue42248 - UPDATE: This has been fixed as of later 3.8 builds, so we can kill this - off once we are 3.9+ across the board. - """ - - # Note: we don't recreate *ALL* the functionality of the Enum constructor - # such as the _missing_ hook; but this should cover our basic needs. - value2member_map = getattr(cls, '_value2member_map_') - assert value2member_map is not None - try: - out = value2member_map[value] - assert isinstance(out, cls) - return out - except KeyError: - # pylint: disable=consider-using-f-string - raise ValueError( - '%r is not a valid %s' % (value, cls.__name__) - ) from None - - def check_utc(value: datetime.datetime) -> None: """Ensure a datetime value is timezone-aware utc.""" if value.tzinfo is not datetime.UTC: @@ -114,11 +118,24 @@ def utc_now_naive() -> datetime.datetime: This can be used to replace datetime.utcnow(), which is now deprecated. Most all code should migrate to use timezone-aware times instead of - this. + relying on this. """ return datetime.datetime.now(datetime.UTC).replace(tzinfo=None) +def utc_from_timestamp_naive(timestamp: float) -> datetime.datetime: + """Get a naive utc time from a timestamp. + + This can be used to replace datetime.utcfromtimestamp(), which is now + deprecated. Most all code should migrate to use timezone-aware times + instead of relying on this. + """ + + return datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC).replace( + tzinfo=None + ) + + def utc_today() -> datetime.datetime: """Get offset-aware midnight in the utc time zone.""" now = datetime.datetime.now(datetime.UTC) @@ -198,27 +215,33 @@ def data_size_str(bytecount: int, compact: bool = False) -> str: class DirtyBit: - """Manages whether a thing is dirty and regulates attempts to clean it. + """Manages whether a thing is dirty and regulates cleaning it. + + To use, simply set the 'dirty' value on this object to True when + some update is needed, and then check the 'should_update' value to + regulate when the actual update should occur. Set 'dirty' back to + False after a successful update. + + If 'use_lock' is True, an asyncio Lock will be created and + incorporated into update attempts to prevent simultaneous updates + (should_update will only return True when the lock is unlocked). + Note that It is up to the user to lock/unlock the lock during the + actual update attempt. + + If a value is passed for 'auto_dirty_seconds', the dirtybit will + flip itself back to dirty after being clean for the given amount of + time. - To use, simply set the 'dirty' value on this object to True when some - action is needed, and then check the 'should_update' value to regulate - when attempts to clean it should be made. Set 'dirty' back to False after - a successful update. - If 'use_lock' is True, an asyncio Lock will be created and incorporated - into update attempts to prevent simultaneous updates (should_update will - only return True when the lock is unlocked). Note that It is up to the user - to lock/unlock the lock during the actual update attempt. - If a value is passed for 'auto_dirty_seconds', the dirtybit will flip - itself back to dirty after being clean for the given amount of time. 'min_update_interval' can be used to enforce a minimum update - interval even when updates are successful (retry_interval only applies - when updates fail) + interval even when updates are successful (retry_interval only + applies when updates fail) """ def __init__( self, dirty: bool = False, retry_interval: float = 5.0, + *, use_lock: bool = False, auto_dirty_seconds: float | None = None, min_update_interval: float | None = None, @@ -313,7 +336,7 @@ class DispatchMethodWrapper(Generic[ArgT, RetT]): @staticmethod def register( - func: Callable[[Any, Any], RetT] + func: Callable[[Any, Any], RetT], ) -> Callable[[Any, Any], RetT]: """Register a new dispatch handler for this dispatch-method.""" raise RuntimeError('Should not get here') @@ -323,7 +346,7 @@ class DispatchMethodWrapper(Generic[ArgT, RetT]): # noinspection PyProtectedMember,PyTypeHints def dispatchmethod( - func: Callable[[Any, ArgT], RetT] + func: Callable[[Any, ArgT], RetT], ) -> DispatchMethodWrapper[ArgT, RetT]: """A variation of functools.singledispatch for methods. @@ -407,7 +430,7 @@ class ValueDispatcher(Generic[ValT, RetT]): def valuedispatch1arg( - call: Callable[[ValT, ArgT], RetT] + call: Callable[[ValT, ArgT], RetT], ) -> ValueDispatcher1Arg[ValT, ArgT, RetT]: """Like valuedispatch but for functions taking an extra argument.""" return ValueDispatcher1Arg(call) @@ -458,7 +481,7 @@ if TYPE_CHECKING: def valuedispatchmethod( - call: Callable[[SelfT, ValT], RetT] + call: Callable[[SelfT, ValT], RetT], ) -> ValueDispatcherMethod[ValT, RetT]: """Like valuedispatch but works with methods instead of functions.""" @@ -959,3 +982,30 @@ def extract_arg( del args[argindex : argindex + 2] return val + + +def pairs_to_flat(pairs: Sequence[tuple[T, T]]) -> list[T]: + """Given a sequence of same-typed pairs, flattens to a list.""" + return [item for pair in pairs for item in pair] + + +def pairs_from_flat(flat: Sequence[T]) -> list[tuple[T, T]]: + """Given a flat even numbered sequence, returns pairs.""" + if len(flat) % 2 != 0: + raise ValueError('Provided sequence has an odd number of elements.') + out: list[tuple[T, T]] = [] + for i in range(0, len(flat) - 1, 2): + out.append((flat[i], flat[i + 1])) + return out + + +def weighted_choice(*args: tuple[T, float]) -> T: + """Given object/weight pairs as args, returns a random object. + + Intended as a shorthand way to call random.choices on a few explicit + options. + """ + items: tuple[T] + weights: tuple[float] + items, weights = zip(*args) + return random.choices(items, weights=weights)[0] diff --git a/dist/ba_root/config.json b/dist/ba_root/config.json index 316be79..8c40815 100644 --- a/dist/ba_root/config.json +++ b/dist/ba_root/config.json @@ -127,21 +127,39 @@ "Auto Account State": "Server", "Auto Balance Teams": true, "Campaigns": {}, - "Default Player Profiles": {}, + "Custom Team Colors": [ + [ + 0.8, + 0.0, + 0.6 + ], + [ + 0.0, + 1.0, + 0.8 + ] + ], + "Custom Team Names": [ + "ladoo", + "barfi" + ], + "Default Player Profiles": { + "Client Input Device #1": "__account__" + }, "FFA Series Length": 24, - "Fleet Zone Ping Last Flush Time": 1716129094, + "Fleet Zone Ping Last Flush Time": 1739040124, "Fleet Zone Pings": { "prod": { - "delhi.v4": 14.10211762590956, - "hyderabad.v4": 37.66881892027117, - "mumbai.v4": 60.45953764687152 + "delhi.v4": 14.569117998262962, + "hyderabad.v4": 41.93780171261867, + "mumbai.v4": 34.57919811762258 } }, "Free-for-All Playlist Randomize": true, "Free-for-All Playlist Selection": "__default__", "Free-for-All Playlists": {}, "Idle Exit Minutes": null, - "Local Account Name": "Server17290774", + "Local Account Name": "Server20640263", "Player Profiles": { "__account__": { "character": "Spaz", @@ -175,7 +193,7 @@ "Team Tournament Playlist Selection": "\u041a\u043e\u043f\u0438\u044f \u0421\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 \u043f\u043b\u0435\u0439-\u043b\u0438\u0441\u0442 \u0440\u0435\u0436\u0438\u043c\u0430 \u041a\u043e\u043c\u0430\u043d\u0434\u044b", "Team Tournament Playlists": {}, "Teams Series Length": 7, - "launchCount": 17, + "launchCount": 59, "lc14173": 1, "lc14292": 1 } \ No newline at end of file diff --git a/dist/ba_root/config.json.prev b/dist/ba_root/config.json.prev index 5531361..a96d430 100644 --- a/dist/ba_root/config.json.prev +++ b/dist/ba_root/config.json.prev @@ -127,21 +127,39 @@ "Auto Account State": "Server", "Auto Balance Teams": true, "Campaigns": {}, - "Default Player Profiles": {}, + "Custom Team Colors": [ + [ + 0.8, + 0.0, + 0.6 + ], + [ + 0.0, + 1.0, + 0.8 + ] + ], + "Custom Team Names": [ + "ladoo", + "barfi" + ], + "Default Player Profiles": { + "Client Input Device #1": "__account__" + }, "FFA Series Length": 24, - "Fleet Zone Ping Last Flush Time": 1716129094, + "Fleet Zone Ping Last Flush Time": 1739040124, "Fleet Zone Pings": { "prod": { - "delhi.v4": 11.398733474327724, - "hyderabad.v4": 37.2009791625173, - "mumbai.v4": 73.59297864995176 + "delhi.v4": 14.569117998262962, + "hyderabad.v4": 40.82055802804358, + "mumbai.v4": 35.66394542322196 } }, "Free-for-All Playlist Randomize": true, "Free-for-All Playlist Selection": "__default__", "Free-for-All Playlists": {}, "Idle Exit Minutes": null, - "Local Account Name": "Server17290774", + "Local Account Name": "Server20640263", "Player Profiles": { "__account__": { "character": "Spaz", @@ -175,7 +193,7 @@ "Team Tournament Playlist Selection": "\u041a\u043e\u043f\u0438\u044f \u0421\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 \u043f\u043b\u0435\u0439-\u043b\u0438\u0441\u0442 \u0440\u0435\u0436\u0438\u043c\u0430 \u041a\u043e\u043c\u0430\u043d\u0434\u044b", "Team Tournament Playlists": {}, "Teams Series Length": 7, - "launchCount": 17, + "launchCount": 59, "lc14173": 1, "lc14292": 1 } \ No newline at end of file diff --git a/dist/ba_root/mods/changelogs.json b/dist/ba_root/mods/changelogs.json index db8d621..dd78ab5 100644 --- a/dist/ba_root/mods/changelogs.json +++ b/dist/ba_root/mods/changelogs.json @@ -58,6 +58,10 @@ "80": { "log": "finally releasing API 8 after 1 year", "time": "22 Nov 2024" + }, + "81": { + "log": "api 9 , latest changes for 1.7.37 from master", + "time": "09 Feb 2025" } } \ No newline at end of file diff --git a/dist/ba_root/mods/chathandle/chatcommands/commands/management.py b/dist/ba_root/mods/chathandle/chatcommands/commands/management.py index b221a83..28161d4 100644 --- a/dist/ba_root/mods/chathandle/chatcommands/commands/management.py +++ b/dist/ba_root/mods/chathandle/chatcommands/commands/management.py @@ -14,7 +14,7 @@ import bascenev1 as bs from tools import logger -Commands = ['recents', 'info', 'createteam', 'showid', 'hideid', 'lm', 'gp', +Commands = ['unban', 'recents', 'info', 'createteam', 'showid', 'hideid', 'lm', 'gp', 'party', 'quit', 'kickvote', 'maxplayers', 'playlist', 'ban', 'kick', 'remove', 'end', 'quit', 'mute', 'unmute', 'slowmo', 'nv', 'dv', 'pause', 'tint', @@ -41,108 +41,83 @@ def ExcelCommand(command, arguments, clientid, accountid): Returns: None """ - if command in ['recents']: - get_recents(clientid) - if command in ['info']: - get_player_info(arguments, clientid) - if command in ['maxplayers', 'max']: - changepartysize(arguments) - if command in ['createteam']: - create_team(arguments) - elif command == 'playlist': - changeplaylist(arguments) - elif command == 'kick': - kick(arguments) - elif command == 'ban': - ban(arguments) - elif command in ['end', 'next']: - end(arguments) - elif command == 'kickvote': - kikvote(arguments, clientid) - elif command == 'hideid': - hide_player_spec() - elif command == "showid": - show_player_spec() - elif command == 'lm': - last_msgs(clientid) - - elif command == 'gp': - get_profiles(arguments, clientid) - - elif command == 'party': - party_toggle(arguments) - - elif command in ['quit', 'restart']: - quit(arguments) - - elif command in ['mute', 'mutechat']: - mute(arguments) - - elif command in ['unmute', 'unmutechat']: - un_mute(arguments) - - elif command in ['remove', 'rm']: - remove(arguments) - - elif command in ['sm', 'slow', 'slowmo']: - slow_motion() - - elif command in ['nv', 'night']: - nv(arguments) - - elif command in ['dv', 'day']: - dv(arguments) - - elif command == 'tint': - tint(arguments) - - elif command in ['pause', 'pausegame']: - pause() - - elif command in ['cameraMode', 'camera_mode', 'rotate_camera']: - rotate_camera() - - elif command == 'createrole': - create_role(arguments) - - elif command == 'addrole': - add_role_to_player(arguments) - - elif command == 'removerole': - remove_role_from_player(arguments) - - elif command == 'getroles': - get_roles_of_player(arguments, clientid) - - elif command in ['addcommand', 'addcmd']: - add_command_to_role(arguments) - - elif command in ['removecommand', 'removecmd']: - remove_command_to_role(arguments) - - elif command == 'changetag': - change_role_tag(arguments) - - elif command == 'customtag': - set_custom_tag(arguments) - - elif command in ['customeffect', 'effect']: - set_custom_effect(arguments) - - elif command in ['removetag']: - remove_custom_tag(arguments) - - elif command in ['removeeffect']: - remove_custom_effect(arguments) - - # elif command in ['add', 'whitelist']: - # whitelst_it(accountid, arguments) - - elif command == 'spectators': - spectators(arguments) - - elif command == 'lobbytime': - change_lobby_check_time(arguments) + match command: + case 'unban': + unban(arguments) + case 'recents': + get_recents(clientid) + case 'info': + get_player_info(arguments, clientid) + case 'maxplayers' | 'max': + changepartysize(arguments) + case 'createteam': + create_team(arguments) + case 'playlist': + changeplaylist(arguments) + case 'kick': + kick(arguments) + case 'ban': + ban(arguments) + case 'end' | 'next': + end(arguments) + case 'kickvote': + kikvote(arguments, clientid) + case 'hideid': + hide_player_spec() + case 'showid': + show_player_spec() + case 'lm': + last_msgs(clientid) + case 'gp': + get_profiles(arguments, clientid) + case 'party': + party_toggle(arguments) + case 'quit' | 'restart': + quit(arguments) + case 'mute' | 'mutechat': + mute(arguments) + case 'unmute' | 'unmutechat': + un_mute(arguments) + case 'remove' | 'rm': + remove(arguments) + case 'sm' | 'slow' | 'slowmo': + slow_motion() + case 'nv' | 'night': + nv(arguments) + case 'tint': + tint(arguments) + case 'pause' | 'pausegame': + pause() + case 'cameraMode' | 'camera_mode' | 'rotate_camera': + rotate_camera() + case 'createrole': + create_role(arguments) + case 'addrole': + add_role_to_player(arguments) + case 'removerole': + remove_role_from_player(arguments) + case 'getroles': + get_roles_of_player(arguments, clientid) + case 'addcommand' | 'addcmd': + add_command_to_role(arguments) + case 'removecommand' | 'removecmd': + remove_command_to_role(arguments) + case 'changetag': + change_role_tag(arguments) + case 'customtag': + set_custom_tag(arguments) + case 'customeffect' | 'effect': + set_custom_effect(arguments) + case 'removetag': + remove_custom_tag(arguments) + case 'removeeffect': + remove_custom_effect(arguments) + case 'spectators': + spectators(arguments) + case 'lobbytime': + change_lobby_check_time(arguments) + case _: + pass def create_team(arguments): @@ -287,8 +262,9 @@ def party_toggle(arguments): def end(arguments): if arguments == [] or arguments == ['']: try: - with _babase.Context(_babase.get_foreground_host_activity()): - _babase.get_foreground_host_activity().end_game() + game = bs.get_foreground_host_activity() + with game.context: + game.end_game() except: pass @@ -314,6 +290,19 @@ def ban(arguments): pass +def unban(arguments): + try: + for account in serverdata.recents: + if account['client_id'] == int(arguments[0]): + pdata.unban_player( + account["pbid"]) + logger.log( + f'unbanned {account["pbid"]} by chat command, recents') + + except: + pass + + def quit(arguments): if arguments == [] or arguments == ['']: babase.quit() @@ -389,49 +378,39 @@ def slow_motion(): def nv(arguments): - activity = _babase.get_foreground_host_activity() + def is_close(a, b, tol=1e-5): + return all(abs(x - y) < tol for x, y in zip(a, b)) - if arguments == [] or arguments == ['']: + try: + activity = bs.get_foreground_host_activity() + nv_tint = (0.5, 0.5, 1.0) + nv_ambient = (1.5, 1.5, 1.5) - if activity.globalsnode.tint != (0.5, 0.7, 1.0): - activity.globalsnode.tint = (0.5, 0.7, 1.0) - else: - # will fix this soon - pass - - elif arguments[0] == 'off': - if activity.globalsnode.tint != (0.5, 0.7, 1.0): - return - else: - pass - - -def dv(arguments): - activity = _babase.get_foreground_host_activity() - - if arguments == [] or arguments == ['']: - - if activity.globalsnode.tint != (1, 1, 1): + if is_close(activity.globalsnode.tint, nv_tint): activity.globalsnode.tint = (1, 1, 1) + # adding ambient color to imitate moonlight reflection on objects + activity.globalsnode.ambient_color = (1, 1, 1) + # print(activity.globalsnode.tint) else: - # will fix this soon - pass - - elif arguments[0] == 'off': - if activity.globalsnode.tint != (1, 1, 1): - return - else: - pass + activity.globalsnode.tint = nv_tint + activity.globalsnode.ambient_color = nv_ambient + # print(activity.globalsnode.tint) + except: + return def tint(arguments): - activity = _babase.get_foreground_host_activity() - if len(arguments) == 3: - if all(isinstance(val, (int, float)) for val in arguments): - activity.globalsnode.tint = ( - arguments[0], arguments[1], arguments[2]) + args = arguments + r, g, b = float(args[0]), float(args[1]), float(args[2]) + try: + # print(dir(activity.globalsnode)) + + activity = bs.get_foreground_host_activity() + activity.globalsnode.tint = (r, g, b) + except: + return def pause(): @@ -493,7 +472,7 @@ def get_roles_of_player(arguments, clientid): for i in session.sessionplayers: if i.inputdevice.client_id == int(arguments[0]): roles = pdata.get_player_roles(i.get_v1_account_id()) - print(roles) + for role in roles: reply = reply + role + "," send(reply, clientid) diff --git a/dist/ba_root/mods/chathandle/handlechat.py b/dist/ba_root/mods/chathandle/handlechat.py index 9179d2e..90d901f 100644 --- a/dist/ba_root/mods/chathandle/handlechat.py +++ b/dist/ba_root/mods/chathandle/handlechat.py @@ -17,6 +17,7 @@ settings = setting.get_settings_data() def filter_chat_message(msg, client_id): now = datetime.now() + # bypassing chat filter for host if client_id == -1: if msg.startswith("/"): command_executor.execute(msg, client_id) @@ -37,6 +38,11 @@ def filter_chat_message(msg, client_id): displaystring = i['display_string'] if acid: msg = chatfilter.filter(msg, acid, client_id) + else: + bs.broadcastmessage("Fetching your account info , please wait", + transient=True, clients=[client_id]) + return + if msg == None: return logger.log(f'{acid} | {displaystring}| {currentname} | {msg}', "chat") @@ -56,16 +62,16 @@ def filter_chat_message(msg, client_id): return elif acid in pdata.get_blacklist()[ - "muted-ids"] and now < datetime.strptime( - pdata.get_blacklist()["muted-ids"][acid]["till"], - "%Y-%m-%d %H:%M:%S"): + "muted-ids"] and now < datetime.strptime( + pdata.get_blacklist()["muted-ids"][acid]["till"], + "%Y-%m-%d %H:%M:%S"): bs.broadcastmessage( "You are on mute, maybe try after some time", transient=True, clients=[client_id]) return None elif servercheck.get_account_age( - serverdata.clients[acid]["accountAge"]) < settings[ - 'minAgeToChatInHours']: + serverdata.clients[acid]["accountAge"]) < settings[ + 'minAgeToChatInHours']: bs.broadcastmessage("New accounts not allowed to chat here", transient=True, clients=[client_id]) return None diff --git a/dist/ba_root/mods/custom_hooks.py b/dist/ba_root/mods/custom_hooks.py index 3161a71..3461b58 100644 --- a/dist/ba_root/mods/custom_hooks.py +++ b/dist/ba_root/mods/custom_hooks.py @@ -1,6 +1,6 @@ """Custom hooks to pull of the in-game functions.""" -# ba_meta require api 8 +# ba_meta require api 9 # (see https://ballistica.net/wiki/meta-tag-system) # pylint: disable=import-error @@ -21,6 +21,8 @@ from typing import TYPE_CHECKING import babase import bascenev1 as bs +import _bascenev1 +from baclassic._appmode import ClassicAppMode import bauiv1 as bui import setting from baclassic._servermode import ServerController @@ -70,7 +72,7 @@ class modSetup(babase.Plugin): if (settings["useV2Account"]): if (plus.get_v1_account_state() == - 'signed_in' and plus.get_v1_account_type() == 'V2'): + 'signed_in' and plus.get_v1_account_type() == 'V2'): logging.debug("Account V2 is active") else: logging.warning("Account V2 login require ....stay tuned.") @@ -130,11 +132,6 @@ def bootstraping(): """Bootstarps the server.""" logging.warning("Bootstraping mods...") # server related - # _bascenev1.set_server_name(settings["HostName"]) - # _bascenev1.set_transparent_kickvote(settings["ShowKickVoteStarterName"]) - # _bascenev1.set_kickvote_msg_type(settings["KickVoteMsgType"]) - # bs.hide_player_device_id(settings["Anti-IdRevealer"]) TODO add call in - # cpp # check for auto update stats _thread.start_new_thread(mystats.refreshStats, ()) @@ -337,21 +334,21 @@ def shutdown(func) -> None: "Server will restart on next opportunity. (series end)") _babase.restart_scheduled = True bs.get_foreground_host_activity().restart_msg = bs.newnode('text', - attrs={ - 'text': "Server going to restart after this series.", - 'flatness': 1.0, - 'h_align': 'right', - 'v_attach': 'bottom', - 'h_attach': 'right', - 'scale': 0.5, - 'position': ( - -25, - 54), - 'color': ( - 1, - 0.5, - 0.7) - }) + attrs={ + 'text': "Server going to restart after this series.", + 'flatness': 1.0, + 'h_align': 'right', + 'v_attach': 'bottom', + 'h_attach': 'right', + 'scale': 0.5, + 'position': ( + -25, + 54), + 'color': ( + 1, + 0.5, + 0.7) + }) func(*args, **kwargs) return wrapper @@ -373,9 +370,9 @@ def on_player_request(func) -> bool: count += 1 if count >= settings["maxPlayersPerDevice"]: bs.broadcastmessage("Reached max players limit per device", - clients=[ - player.inputdevice.client_id], - transient=True, ) + clients=[ + player.inputdevice.client_id], + transient=True, ) return False return func(*args, **kwargs) @@ -417,3 +414,25 @@ def wrap_player_spaz_init(original_class): playerspaz.PlayerSpaz = wrap_player_spaz_init(playerspaz.PlayerSpaz) + +original_classic_app_mode_activate = ClassicAppMode.on_activate + + +def new_classic_app_mode_activate(*args, **kwargs): + # Call the original function + result = original_classic_app_mode_activate(*args, **kwargs) + + # Perform additional actions after the original function call + on_classic_app_mode_active() + + return result + + +ClassicAppMode.on_activate = new_classic_app_mode_activate + + +def on_classic_app_mode_active(): + _bascenev1.set_server_name(settings["HostName"]) + _bascenev1.set_transparent_kickvote(settings["ShowKickVoteStarterName"]) + _bascenev1.set_kickvote_msg_type(settings["KickVoteMsgType"]) + _bascenev1.hide_player_device_id(settings["Anti-IdRevealer"]) diff --git a/dist/ba_root/mods/features/votingmachine.py b/dist/ba_root/mods/features/votingmachine.py index 74c96d2..1c4e3c7 100644 --- a/dist/ba_root/mods/features/votingmachine.py +++ b/dist/ba_root/mods/features/votingmachine.py @@ -58,7 +58,7 @@ def vote(pb_id, client_id, vote_type): else: activity = bs.get_foreground_host_activity() if activity is not None: - with _babase.Context(activity): + with activity.context: bs.broadcastmessage( f"{max_votes_required(len(active_players)) - len(voters)} votes required for {vote_type}", image={"texture": bs.gettexture( @@ -75,7 +75,8 @@ def vote(pb_id, client_id, vote_type): vote_machine[vote_type]["voters"] = [] if vote_type == "end": try: - with _babase.Context(bs.get_foreground_host_activity()): + activity = bs.get_foreground_host_activity() + with activity.context: bs.get_foreground_host_activity().end_game() except: pass @@ -120,7 +121,8 @@ def update_vote_text(votes_needed): activity.end_vote_text.node.text = "{} more votes to end this map\ntype 'end' to vote".format( votes_needed) except: - with _babase.Context(bs.get_foreground_host_activity()): + activity = bs.get_foreground_host_activity() + with activity.context: node = bs.NodeActor(bs.newnode('text', attrs={ 'v_attach': 'top', @@ -142,4 +144,4 @@ def remove_vote_text(): activity = bs.get_foreground_host_activity() if hasattr(activity, "end_vote_text") and activity.end_vote_text.node.exists(): - activity.end_vote_text.node.delete() + activity.end_vote_text.node.delete() \ No newline at end of file diff --git a/dist/ba_root/mods/playersdata/pdata.py b/dist/ba_root/mods/playersdata/pdata.py index 213d9f1..1846328 100644 --- a/dist/ba_root/mods/playersdata/pdata.py +++ b/dist/ba_root/mods/playersdata/pdata.py @@ -160,7 +160,7 @@ def get_detailed_info(pbid): profiles = get_profiles() for key, value in profiles.items(): if ("lastIP" in value and value["lastIP"] == ip) or ( - "deviceUUID" in value and value["deviceUUID"] == deviceid): + "deviceUUID" in value and value["deviceUUID"] == deviceid): otheraccounts += ' '.join(value["display_string"]) return f"Accounts:{linked_accounts} \n other accounts {otheraccounts} \n created on {dob}" @@ -218,7 +218,7 @@ def add_profile( checkSpammer({'id': account_id, 'display': display_string, 'ip': ip, 'device': device_id}) if device_id in get_blacklist()["ban"]["deviceids"] or account_id in \ - get_blacklist()["ban"]["ids"]: + get_blacklist()["ban"]["ids"]: bs.disconnect_client(cid) serverdata.clients[account_id]["deviceUUID"] = device_id @@ -314,6 +314,11 @@ def unban_player(account_id): if account_id in current_profiles: ip = current_profiles[account_id]["lastIP"] device_id = current_profiles[account_id]["deviceUUID"] + else: + for account in serverdata.recents: + if account["pbid"] == account_id: + ip = account["ip"] + device_id = account["device_uuid"] CacheData.blacklist["ban"]["ips"].pop(ip, None) CacheData.blacklist["ban"]["deviceids"].pop(device_id, None) @@ -607,7 +612,7 @@ def get_custom() -> dict: custom["customeffects"][account_id] = [ custom["customeffects"][account_id]] if type( custom["customeffects"][account_id]) is str else \ - custom["customeffects"][account_id] + custom["customeffects"][account_id] return CacheData.custom @@ -626,7 +631,7 @@ def set_effect(effect: str, account_id: str) -> None: if account_id in custom["customeffects"]: effects = [custom["customeffects"][account_id]] if type( custom["customeffects"][account_id]) is str else \ - custom["customeffects"][account_id] + custom["customeffects"][account_id] effects.append(effect) custom["customeffects"][account_id] = effects else: diff --git a/dist/ba_root/mods/playersdata/profiles.json b/dist/ba_root/mods/playersdata/profiles.json index e3c4d6f..93fa88d 100644 --- a/dist/ba_root/mods/playersdata/profiles.json +++ b/dist/ba_root/mods/playersdata/profiles.json @@ -22992,5 +22992,28 @@ "lastMsgTime": 1692388464.9424736, "lastMsg": "/ping all", "cSameMsg": 0 + }, + "pb-IF4CUGMfXA==": { + "display_string": [ + "\ue030PC913901" + ], + "profiles": [], + "name": "\ue030PC913901", + "accountAge": "2023-04-29 21:29:25", + "registerOn": 1732811067.9550145, + "spamCount": 0, + "lastSpam": 1732811067.9550183, + "totaltimeplayer": 0, + "warnCount": 0, + "lastWarned": 1732815847.8397744, + "verified": true, + "rejoincount": 1, + "lastJoin": 1732815847.8397763, + "lastIP": "axj~|h~}hhai", + "deviceUUID": "794cde3b6d1a862addc825b3bd75d7dcddc379e0", + "cMsgCount": 0, + "lastMsgTime": 1732811106.4264228, + "lastMsg": "make meadmin", + "cSameMsg": 0 } } \ No newline at end of file diff --git a/dist/ba_root/mods/playersdata/profiles.json.backup b/dist/ba_root/mods/playersdata/profiles.json.backup index 275f971..93fa88d 100644 --- a/dist/ba_root/mods/playersdata/profiles.json.backup +++ b/dist/ba_root/mods/playersdata/profiles.json.backup @@ -22982,15 +22982,38 @@ "lastSpam": 1692041275.3951833, "totaltimeplayer": 0, "warnCount": 0, - "lastWarned": 1692388352.1588542, + "lastWarned": 1692388455.527413, "verified": true, "rejoincount": 1, - "lastJoin": 1692388352.1588569, + "lastJoin": 1692388455.5274153, "lastIP": "axj~|h~}hhai", "deviceUUID": "26f30cfd3921c2d15574b534cb824c952b14f189", - "cMsgCount": 0, - "lastMsgTime": 1692092832.8181152, + "cMsgCount": 1, + "lastMsgTime": 1692388464.9424736, "lastMsg": "/ping all", "cSameMsg": 0 + }, + "pb-IF4CUGMfXA==": { + "display_string": [ + "\ue030PC913901" + ], + "profiles": [], + "name": "\ue030PC913901", + "accountAge": "2023-04-29 21:29:25", + "registerOn": 1732811067.9550145, + "spamCount": 0, + "lastSpam": 1732811067.9550183, + "totaltimeplayer": 0, + "warnCount": 0, + "lastWarned": 1732815847.8397744, + "verified": true, + "rejoincount": 1, + "lastJoin": 1732815847.8397763, + "lastIP": "axj~|h~}hhai", + "deviceUUID": "794cde3b6d1a862addc825b3bd75d7dcddc379e0", + "cMsgCount": 0, + "lastMsgTime": 1732811106.4264228, + "lastMsg": "make meadmin", + "cSameMsg": 0 } } \ No newline at end of file diff --git a/dist/ba_root/mods/playersdata/roles.json b/dist/ba_root/mods/playersdata/roles.json index e3da243..d1ba115 100644 --- a/dist/ba_root/mods/playersdata/roles.json +++ b/dist/ba_root/mods/playersdata/roles.json @@ -71,7 +71,7 @@ "commands": [], "ids": [ "pb-IF4VAk4a", - "pb-IF5RU3EcAg==" + "pb-IF4CUGMfXA==" ] }, "bypass-warn": { diff --git a/dist/ba_root/mods/plugins/bcs_plugin.py b/dist/ba_root/mods/plugins/bcs_plugin.py index 9103886..0e419a7 100644 --- a/dist/ba_root/mods/plugins/bcs_plugin.py +++ b/dist/ba_root/mods/plugins/bcs_plugin.py @@ -11,6 +11,7 @@ from functools import wraps from threading import Thread import _babase +import _bascenev1 from flask import Flask, request, jsonify # import uvicorn @@ -42,7 +43,7 @@ def check_admin(func): @wraps(func) def wrapper(*args, **kwargs): if "Secret-Key" not in request.headers or request.headers[ - "Secret-Key"] != SECRET_KEY: + "Secret-Key"] != SECRET_KEY: return jsonify({"message": "Invalid secret key provided."}), 401 return func(*args, **kwargs) @@ -277,7 +278,7 @@ def update_server_config(): def run_server(): from waitress import serve - serve(app, host="0.0.0.0", port=_babase.get_game_port()) + serve(app, host="0.0.0.0", port=_bascenev1.get_game_port()) def enable(password): diff --git a/dist/ba_root/mods/plugins/bombsquad_service.py b/dist/ba_root/mods/plugins/bombsquad_service.py index 2711afb..8e8eaa5 100644 --- a/dist/ba_root/mods/plugins/bombsquad_service.py +++ b/dist/ba_root/mods/plugins/bombsquad_service.py @@ -28,11 +28,12 @@ class BsDataThread(object): def __init__(self): global stats stats["name"] = _babase.app.classic.server._config.party_name - stats["discord"] = "https://discord.gg/ucyaesh" + stats["discord"] = get_server_settings( + )["ballistica_web"]["server_password"] stats["vapidKey"] = notification_manager.get_vapid_keys()["public_key"] self.refresh_stats_cache_timer = bs.AppTimer(8, babase.Call( - self.refreshStats) , repeat=True) + self.refreshStats), repeat=True) self.refresh_leaderboard_cache_timer = bs.AppTimer(10, babase.Call( self.refreshLeaderboard), repeat=True) @@ -116,7 +117,8 @@ class BsDataThread(object): return data -BsDataThread() +v = bs.AppTimer(5, babase.Call( + BsDataThread)) def get_stats(): @@ -191,7 +193,7 @@ def search_player_profile(search_key: str, db: str): if (search_key == key or any(search_key.lower() in s.lower() for s in selectedDB[key].get("display_string", [])) or - search_key.lower() in selectedDB[key].get("name", "").lower()): + search_key.lower() in selectedDB[key].get("name", "").lower()): matching_objects[key] = selectedDB[key] count += 1 if count > 50: @@ -216,15 +218,15 @@ def get_player_details(account_id: str): isBanned = True extra_info += " , Banned for > " + haveBanReason if account_id in pdata.get_blacklist()[ - "muted-ids"] and current_time < datetime.strptime( - pdata.get_blacklist()["muted-ids"][account_id]["till"], - "%Y-%m-%d %H:%M:%S"): + "muted-ids"] and current_time < datetime.strptime( + pdata.get_blacklist()["muted-ids"][account_id]["till"], + "%Y-%m-%d %H:%M:%S"): isMuted = True extra_info += f', Muted for > {pdata.get_blacklist()["muted-ids"][account_id]["reason"]} , till > {pdata.get_blacklist()["muted-ids"][account_id]["till"]} ,' if account_id in pdata.get_blacklist()[ - "kick-vote-disabled"] and current_time < datetime.strptime( - pdata.get_blacklist()["kick-vote-disabled"][account_id]["till"], - "%Y-%m-%d %H:%M:%S"): + "kick-vote-disabled"] and current_time < datetime.strptime( + pdata.get_blacklist()["kick-vote-disabled"][account_id]["till"], + "%Y-%m-%d %H:%M:%S"): isKickVoteDisabled = True extra_info += f', Kick vote disabled for > {pdata.get_blacklist()["kick-vote-disabled"][account_id]["reason"]} , till > {pdata.get_blacklist()["kick-vote-disabled"][account_id]["till"]} ' diff --git a/dist/ba_root/mods/setting.json b/dist/ba_root/mods/setting.json index e3a0be7..51f83d9 100644 --- a/dist/ba_root/mods/setting.json +++ b/dist/ba_root/mods/setting.json @@ -4,7 +4,7 @@ "BrodcastCommand": true }, "HostDeviceName": "v1.4", - "HostName": "BCS", + "HostName": "BCSv2", "KickVoteMsgType": "chat", "ScoreScreenAnnouncement": { "enable": true, @@ -36,8 +36,9 @@ }, "autoTeamBalance": true, "ballistica_web": { - "enable": false, - "server_password": "my_secerT_password_very_hard" + "enable": true, + "server_password": "my_secerT_password_very_hard", + "discord_link": "https://discord.gg/ucyaesh" }, "character_chooser": { "enable": true diff --git a/dist/ba_root/mods/setting.py b/dist/ba_root/mods/setting.py index 657c027..8d0c85f 100644 --- a/dist/ba_root/mods/setting.py +++ b/dist/ba_root/mods/setting.py @@ -1,6 +1,6 @@ """Module to update `setting.json`.""" -# ba_meta require api 8 +# ba_meta require api 9 # (see https://ballistica.net/wiki/meta-tag-system) from __future__ import annotations diff --git a/dist/ba_root/mods/spazmod/modifyspaz.py b/dist/ba_root/mods/spazmod/modifyspaz.py index 2190a66..2036e18 100644 --- a/dist/ba_root/mods/spazmod/modifyspaz.py +++ b/dist/ba_root/mods/spazmod/modifyspaz.py @@ -40,7 +40,7 @@ def main(spaz, node, player): spaz.equip_boxing_gloves() if _setting['playermod']['default_shield']: spaz.equip_shields() - spaz.bomb_type_default = _setting['playermod']['default_bomb'] + spaz.bomb_type = _setting['playermod']['default_bomb'] spaz.bomb_count = _setting['playermod']['default_bomb_count'] # update_name() will add threading here later . it was adding delay on game start diff --git a/dist/ba_root/mods/spazmod/tag.py b/dist/ba_root/mods/spazmod/tag.py index 0761c9a..84679ce 100644 --- a/dist/ba_root/mods/spazmod/tag.py +++ b/dist/ba_root/mods/spazmod/tag.py @@ -25,7 +25,7 @@ def addtag(node, player): tag = roles[role]['tag'] col = ( 0.7, 0.7, 0.7) if 'tagcolor' not in roles[role] else \ - roles[role]['tagcolor'] + roles[role]['tagcolor'] break if tag: Tag(node, tag, col) @@ -48,8 +48,7 @@ def addhp(node, spaz): position=(0, 1.75, 0), shad=1.4) else: spaz.hptimer = None - - spaz.hptimer = bs.Timer(100, babase.Call( + spaz.hptimer = bs.Timer(2, babase.Call( showHP), repeat=True) @@ -165,5 +164,5 @@ class HitPoint(object): self._Text.delete() m.delete() - self.timer = bs.Timer(1.2, babase.Call( + self.timer = bs.Timer(2, babase.Call( a)) diff --git a/dist/ba_root/mods/tools/server_update.py b/dist/ba_root/mods/tools/server_update.py index e18ca14..0feed8a 100644 --- a/dist/ba_root/mods/tools/server_update.py +++ b/dist/ba_root/mods/tools/server_update.py @@ -10,13 +10,10 @@ import bascenev1 from efro.terminal import Clr from playersdata import pdata -VERSION = 80 +VERSION = 81 def check(): - print(babase.app.classic) - print(babase.app.classic.server) - _thread.start_new_thread(updateProfilesJson, ()) _thread.start_new_thread(checkChangelog, ()) diff --git a/dist/ba_root/mods/tools/servercheck.py b/dist/ba_root/mods/tools/servercheck.py index 2796f34..e7812e7 100644 --- a/dist/ba_root/mods/tools/servercheck.py +++ b/dist/ba_root/mods/tools/servercheck.py @@ -48,7 +48,7 @@ class checkserver(object): else: deviceClientMap[device_id].append(ros["client_id"]) if len(deviceClientMap[device_id]) >= settings[ - 'maxAccountPerIP']: + 'maxAccountPerIP']: bs.chatmessage( f"Only {settings['maxAccountPerIP']} player per IP allowed, disconnecting this device.", clients=[ @@ -74,7 +74,7 @@ class checkserver(object): continue newPlayers.append(ros['account_id']) if ros['account_id'] not in self.players and ros[ - 'client_id'] != -1: + 'client_id'] != -1: # new player joined lobby d_str = ros['display_string'] @@ -103,8 +103,8 @@ class checkserver(object): if settings["whitelist"] and ros["account_id"] is not None: if ros["account_id"] not in pdata.CacheData.whitelist: bs.broadcastmessage("Not in whitelist,contact admin", - color=(1, 0, 0), transient=True, - clients=[ros['client_id']]) + color=(1, 0, 0), transient=True, + clients=[ros['client_id']]) logger.log( f'{d_str} || {ros["account_id"]} | kicked > not in whitelist') bs.disconnect_client(ros['client_id']) @@ -141,9 +141,9 @@ def on_player_join_server(pbid, player_data, ip, device_id): joincount += 1 if joincount > 2: bs.broadcastmessage("Joining too fast , slow down dude", - # its not possible now tho, network layer will catch it before reaching here - color=(1, 0, 1), transient=True, - clients=[clid]) + # its not possible now tho, network layer will catch it before reaching here + color=(1, 0, 1), transient=True, + clients=[clid]) logger.log(f'{pbid} || kicked for joining too fast') bs.disconnect_client(clid) _thread.start_new_thread(reportSpam, (pbid,)) @@ -160,7 +160,7 @@ def on_player_join_server(pbid, player_data, ip, device_id): if player_data is not None: # player data is in serevrdata or in local.json cache serverdata.recents.append( - {"client_id": clid, "deviceId": device_string, "pbid": pbid}) + {"client_id": clid, "deviceId": device_string, "pbid": pbid, "ip": ip, "device_uuid": device_id}) serverdata.recents = serverdata.recents[-20:] if check_ban(ip, device_id, pbid): _babase.chatmessage( @@ -169,7 +169,7 @@ def on_player_join_server(pbid, player_data, ip, device_id): bs.disconnect_client(clid) return if get_account_age(player_data["accountAge"]) < \ - settings["minAgeToJoinInHours"]: + settings["minAgeToJoinInHours"]: for ros in bs.get_game_roster(): if ros['account_id'] == pbid: bs.broadcastmessage( @@ -191,9 +191,9 @@ def on_player_join_server(pbid, player_data, ip, device_id): serverdata.clients[pbid]["rejoincount"] = 1 serverdata.clients[pbid]["lastJoin"] = time.time() if pbid in blacklist[ - "kick-vote-disabled"] and current_time < datetime.strptime( - blacklist["kick-vote-disabled"][pbid]["till"], - "%Y-%m-%d %H:%M:%S"): + "kick-vote-disabled"] and current_time < datetime.strptime( + blacklist["kick-vote-disabled"][pbid]["till"], + "%Y-%m-%d %H:%M:%S"): _babase.disable_kickvote(pbid) serverdata.clients[pbid]["lastIP"] = ip @@ -235,15 +235,15 @@ def check_ban(ip, device_id, pbid, log=True): current_time = datetime.now() if ip in blacklist["ban"]['ips'] and current_time < datetime.strptime( - blacklist["ban"]["ips"][ip]["till"], "%Y-%m-%d %H:%M:%S"): + blacklist["ban"]["ips"][ip]["till"], "%Y-%m-%d %H:%M:%S"): msg = f' reason: matched IP | {blacklist["ban"]["ips"][ip]["reason"]} , Till : {blacklist["ban"]["ips"][ip]["till"]}' if log: logger.log(f'{pbid} | kicked > {msg}') return True return msg elif device_id in blacklist["ban"][ - "deviceids"] and current_time < datetime.strptime( - blacklist["ban"]["deviceids"][device_id]["till"], "%Y-%m-%d %H:%M:%S"): + "deviceids"] and current_time < datetime.strptime( + blacklist["ban"]["deviceids"][device_id]["till"], "%Y-%m-%d %H:%M:%S"): msg = f'reason: matched deviceId | {blacklist["ban"]["deviceids"][device_id]["reason"]}, Till : {blacklist["ban"]["deviceids"][device_id]["till"]}' if log: logger.log( @@ -251,7 +251,7 @@ def check_ban(ip, device_id, pbid, log=True): return True return msg elif pbid in blacklist["ban"]["ids"] and current_time < datetime.strptime( - blacklist["ban"]["ids"][pbid]["till"], "%Y-%m-%d %H:%M:%S"): + blacklist["ban"]["ids"][pbid]["till"], "%Y-%m-%d %H:%M:%S"): msg = f'reason: matched ID | {blacklist["ban"]["ids"][pbid]["reason"]} , Till : {blacklist["ban"]["ids"][pbid]["till"]}' if log: logger.log( @@ -401,7 +401,7 @@ def kick_by_pb_id(pb_id, msg): for ros in bs.get_game_roster(): if ros['account_id'] == pb_id: bs.broadcastmessage(msg, transient=True, - clients=[ros['client_id']]) + clients=[ros['client_id']]) bs.disconnect_client(ros['client_id']) diff --git a/dist/ba_root/mods/tools/servercontroller.py b/dist/ba_root/mods/tools/servercontroller.py index 2e9619c..f85b217 100644 --- a/dist/ba_root/mods/tools/servercontroller.py +++ b/dist/ba_root/mods/tools/servercontroller.py @@ -20,6 +20,9 @@ def _access_check_response(self, data) -> None: poststr = '' _babase.our_ip = addr _babase.our_port = port + print( + f'{Clr.BGRN}{Clr.WHT} Server started {addr}:{port} {Clr.RST}', + flush=True) if data['accessible']: # _fetch_public_servers() _babase.queue_chcker_timer = bs.AppTimer(8, babase.Call( @@ -45,7 +48,8 @@ def _access_check_response(self, data) -> None: f'{Clr.SRED}Master server access check of{addrstr}' f' udp port {port} failed.\n' f'Your server does not appear to be' - f' joinable from the internet. Please check your firewall or instance security group.{poststr}{Clr.RST}' + f' joinable from the internet. Please check your firewall or instance security group.{ + poststr}{Clr.RST}' ) @@ -106,7 +110,7 @@ def on_update_response(response): bs.set_public_party_queue_enabled(True) return if not allowed_to_join and len( - players_in_queue) > 1 and current_players < max_allowed_in_server: + players_in_queue) > 1 and current_players < max_allowed_in_server: # something is wrong , lets disable queue for some time bs.set_public_party_queue_enabled(False) diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index 282d177..eeb1f45 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ diff --git a/dist/bombsquad_headless_aarch64 b/dist/bombsquad_headless_aarch64 index 339926e..72c3f9a 100644 Binary files a/dist/bombsquad_headless_aarch64 and b/dist/bombsquad_headless_aarch64 differ