Source code for bascenev1lib.actor.controlsguide

# Released under the MIT License. See LICENSE for details.
#
"""Defines Actors related to controls guides."""

from __future__ import annotations  # Docs-generation hack.

from typing import TYPE_CHECKING, override

import bascenev1 as bs
from bascenev1 import _commonassets, _builtinassets, _classicassets

if TYPE_CHECKING:
    from typing import Any, Sequence


[docs] class ControlsGuide(bs.Actor): """A screen overlay of game controls. category: Gameplay Classes Shows button mappings based on what controllers are connected. Handy to show at the start of a series or whenever there might be newbies watching. This creates a single ``localdisplay`` node; every machine in the game (the host and each client) then draws its own copy showing the button names for *its* controllers (see :class:`LocalControlsGuide` for that side). """ def __init__( self, *, position: tuple[float, float] = (390.0, 120.0), scale: float = 1.0, delay: float = 0.0, lifespan: float | None = None, bright: bool = False, ): """Instantiate an overlay. delay: is the time in seconds before the overlay fades in. lifespan: if not None, the overlay will fade back out and die after that long (in seconds). bright: if True, brighter colors will be used; handy when showing over gameplay but may be too bright for join-screens, etc. """ super().__init__() self._dead = False self.node: bs.Node | None = bs.newnode( 'localdisplay', attrs={ 'config': bs.LocalDisplayConfigSet.single( bs.ClassicControlsLocalDisplayConfig( position=position, scale=scale, delay=delay, lifespan=lifespan, bright=bright, ) ).to_json(), }, ) def _die(self) -> None: if self.node: self.node.delete() self.node = None self._dead = True
[docs] @override def exists(self) -> bool: return not self._dead
[docs] @override def handlemessage(self, msg: Any) -> Any: assert not self.expired if isinstance(msg, bs.DieMessage): if msg.immediate: self._die() else: # Flip invisible so each machine fades its copy out, and # take the node down once that has had time to finish. if self.node: self.node.visible = False bs.timer(3.1, bs.WeakCallStrict(self._die)) return None return super().handlemessage(msg)
[docs] class LocalControlsGuide(bs.Actor): """The per-machine drawing of the controls guide. Created on each machine (host and clients) within a :class:`bascenev1.LocalDisplay` context from a :class:`bascenev1.ClassicControlsLocalDisplayConfig`, so every player sees the button names for *their* controllers. Game code should use :class:`ControlsGuide`, which creates the shared node that results in one of these everywhere. """ def __init__(self, config: bs.ClassicControlsLocalDisplayConfig): super().__init__() position = config.position scale = config.scale delay = config.delay lifespan = config.lifespan bright = config.bright show_title = True scale *= 0.75 image_size = 90.0 * scale offs = 74.0 * scale offs5 = 43.0 * scale ouya = False maxw = 50 xtweak = -2.8 * scale self._lifespan = lifespan self._dead = False self._bright = bright self._cancel_timer: bs.Timer | None = None self._fade_in_timer: bs.Timer | None = None self._update_timer: bs.Timer | None = None self._title_text: bs.Node | None clr: Sequence[float] punch_pos = (position[0] - offs * 1.1, position[1]) jump_pos = (position[0], position[1] - offs) bomb_pos = (position[0] + offs * 1.1, position[1]) pickup_pos = (position[0], position[1] + offs) self._force_hide_button_names = False if show_title: self._title_text_pos_top = ( position[0], position[1] + 139.0 * scale, ) self._title_text_pos_bottom = ( position[0], position[1] + 139.0 * scale, ) clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7) tval = _commonassets.strings.compose.heading_suffix( main=_classicassets.strings.help.controls ) self._title_text = bs.newnode( 'text', attrs={ 'text': tval, 'scale': 1.1 * scale, 'shadow': 0.5, 'flatness': 1.0, 'maxwidth': 480, 'v_align': 'center', 'h_align': 'center', 'color': clr, }, ) else: self._title_text = None pos = jump_pos clr = (0.4, 1, 0.4) self._jump_image = bs.newnode( 'image', attrs={ 'texture': _classicassets.textures.button_jump.get(), 'absolute_scale': True, 'vr_depth': 10, 'position': pos, 'scale': (image_size, image_size), 'color': clr, }, ) self._jump_text = bs.newnode( 'text', attrs={ 'v_align': 'top', 'h_align': 'center', 'scale': 1.5 * scale, 'flatness': 1.0, 'shadow': 1.0, 'maxwidth': maxw, 'position': (pos[0] + xtweak, pos[1] - offs5), 'color': clr, }, ) clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3) pos = punch_pos self._punch_image = bs.newnode( 'image', attrs={ 'texture': _classicassets.textures.button_punch.get(), 'absolute_scale': True, 'vr_depth': 10, 'position': pos, 'scale': (image_size, image_size), 'color': clr, }, ) self._punch_text = bs.newnode( 'text', attrs={ 'v_align': 'top', 'h_align': 'center', 'scale': 1.5 * scale, 'flatness': 1.0, 'shadow': 1.0, 'maxwidth': maxw, 'position': (pos[0] + xtweak, pos[1] - offs5), 'color': clr, }, ) pos = bomb_pos clr = (1, 0.3, 0.3) self._bomb_image = bs.newnode( 'image', attrs={ 'texture': _classicassets.textures.button_bomb.get(), 'absolute_scale': True, 'vr_depth': 10, 'position': pos, 'scale': (image_size, image_size), 'color': clr, }, ) self._bomb_text = bs.newnode( 'text', attrs={ 'h_align': 'center', 'v_align': 'top', 'scale': 1.5 * scale, 'flatness': 1.0, 'shadow': 1.0, 'maxwidth': maxw, 'position': (pos[0] + xtweak, pos[1] - offs5), 'color': clr, }, ) pos = pickup_pos clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1) self._pickup_image = bs.newnode( 'image', attrs={ 'texture': _classicassets.textures.button_pick_up.get(), 'absolute_scale': True, 'vr_depth': 10, 'position': pos, 'scale': (image_size, image_size), 'color': clr, }, ) self._pick_up_text = bs.newnode( 'text', attrs={ 'v_align': 'top', 'h_align': 'center', 'scale': 1.5 * scale, 'flatness': 1.0, 'shadow': 1.0, 'maxwidth': maxw, 'position': (pos[0] + xtweak, pos[1] - offs5), 'color': clr, }, ) clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0) self._run_text_pos_top = (position[0], position[1] - 135.0 * scale) self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale) sval = 1.0 * scale if bs.app.env.vr else 0.8 * scale self._run_text = bs.newnode( 'text', attrs={ 'scale': sval, 'shadow': 1.0 if bs.app.env.vr else 0.5, 'flatness': 1.0, 'maxwidth': 380, 'v_align': 'top', 'h_align': 'center', 'color': clr, }, ) clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7) self._extra_text = bs.newnode( 'text', attrs={ 'scale': 0.8 * scale, 'shadow': 0.5, 'flatness': 1.0, 'maxwidth': 380, 'v_align': 'top', 'h_align': 'center', 'color': clr, }, ) self._extra_image_1 = None self._extra_image_2 = None self._nodes = [ self._bomb_image, self._bomb_text, self._punch_image, self._punch_text, self._jump_image, self._jump_text, self._pickup_image, self._pick_up_text, self._run_text, self._extra_text, ] if show_title: assert self._title_text self._nodes.append(self._title_text) # Start everything invisible. for node in self._nodes: node.opacity = 0.0 # Don't do anything until our delay has passed. bs.timer(delay, bs.WeakCallStrict(self._start_updating)) @staticmethod def _player_input_devices() -> list[bs.InputDevice]: """Return local input devices currently attached to players. This is the local-machine analog of walking the session's players' input devices, and it works on clients too (where devices are attached to remote players). """ return [d for d in bs.get_input_devices() if d.is_attached_to_player()] @staticmethod def _meaningful_button_name( device: bs.InputDevice, button_name: str ) -> str: """Return a flattened string button name; empty for non-meaningful.""" if not device.has_meaningful_button_names: return '' assert bs.app.classic is not None button = bs.app.classic.get_input_device_mapped_value( device, button_name ) # -1 means unset; let's show that. if button == -1: return ( _classicassets.strings.settings.controllers.gamepad.unset ).evaluate() return device.get_button_name(button).evaluate() def _start_updating(self) -> None: # Ok, our delay has passed. Now lets periodically see if we can fade # in (if a touch-screen is present we only want to show up if gamepads # are connected, etc). # Also set up a timer so if we haven't faded in by the end of our # duration, abort. if self._lifespan is not None: self._cancel_timer = bs.Timer( self._lifespan, bs.WeakCallStrict( self.handlemessage, bs.DieMessage(immediate=True) ), ) self._fade_in_timer = bs.Timer( 1.0, bs.WeakCallStrict(self._check_fade_in), repeat=True ) self._check_fade_in() # Do one check immediately. def _check_fade_in(self) -> None: assert bs.app.classic is not None # If we have a touchscreen, we only fade in if we have a player # with an input device that is *not* the touchscreen. Otherwise # it is confusing to see the touchscreen buttons right next to # our display buttons. touchscreen: bs.InputDevice | None = bs.getinputdevice( 'TouchScreen', '#1', doraise=False ) if touchscreen is not None: input_devices = [ i for i in self._player_input_devices() if i and i is not touchscreen ] fade_in = False if input_devices: # Only count this one if it has non-empty button names # (filters out wiimotes, the remote-app, etc). for device in input_devices: for name in ( 'buttonPunch', 'buttonJump', 'buttonBomb', 'buttonPickUp', ): if self._meaningful_button_name(device, name) != '': fade_in = True break if fade_in: break # No need to keep looking. else: # No touch-screen; fade in immediately. fade_in = True if fade_in: self._cancel_timer = None # Didn't need this. self._fade_in_timer = None # Done with this. self._fade_in() def _fade_in(self) -> None: for node in self._nodes: bs.animate(node, 'opacity', {0: 0.0, 2.0: 1.0}) # If we were given a lifespan, transition out after it. if self._lifespan is not None: bs.timer( self._lifespan, bs.WeakCallStrict(self.handlemessage, bs.DieMessage()), ) self._update() self._update_timer = bs.Timer( 1.0, bs.WeakCallStrict(self._update), repeat=True ) def _update(self) -> None: # pylint: disable=too-many-statements # pylint: disable=too-many-branches if self._dead: return classic = bs.app.classic assert classic is not None punch_button_names = set() jump_button_names = set() pickup_button_names = set() bomb_button_names = set() # Look at our local input devices attached to players (this # includes ones still in the process of selecting a character, # etc.). input_devices = [i for i in self._player_input_devices() if i] # If there's no players with input devices yet, try to default to # showing keyboard controls. if not input_devices: kbd = bs.getinputdevice('Keyboard', '#1', doraise=False) if kbd is not None: input_devices.append(kbd) # We word things specially if we have nothing but keyboards. all_keyboards = input_devices and all( i.name == 'Keyboard' for i in input_devices ) only_remote = len(input_devices) == 1 and all( i.name == 'Amazon Fire TV Remote' for i in input_devices ) right_button_names = set() left_button_names = set() up_button_names = set() down_button_names = set() # For each player in the game with an input device, # get the name of the button for each of these 4 actions. # If any of them are uniform across all devices, display the name. for device in input_devices: # We only care about movement buttons in the case of keyboards. if all_keyboards: right_button_names.add( self._meaningful_button_name(device, 'buttonRight') ) left_button_names.add( self._meaningful_button_name(device, 'buttonLeft') ) down_button_names.add( self._meaningful_button_name(device, 'buttonDown') ) up_button_names.add( self._meaningful_button_name(device, 'buttonUp') ) # Ignore empty values; things like the remote app or # wiimotes can return these. bname = self._meaningful_button_name(device, 'buttonPunch') if bname != '': punch_button_names.add(bname) bname = self._meaningful_button_name(device, 'buttonJump') if bname != '': jump_button_names.add(bname) bname = self._meaningful_button_name(device, 'buttonBomb') if bname != '': bomb_button_names.add(bname) bname = self._meaningful_button_name(device, 'buttonPickUp') if bname != '': pickup_button_names.add(bname) # If we have no values yet, we may want to throw out some sane # defaults. if all( not lst for lst in ( punch_button_names, jump_button_names, bomb_button_names, pickup_button_names, ) ): # Otherwise on android show standard buttons. if classic.platform == 'android': punch_button_names.add('X') jump_button_names.add('A') bomb_button_names.add('B') pickup_button_names.add('Y') run_text: bs.LangStr = ( _classicassets.strings.controls.run_hold_any_key if all_keyboards else _classicassets.strings.controls.run_hold_any_button ) # If we're all keyboards, lets show move keys too. if ( all_keyboards and len(up_button_names) == 1 and len(down_button_names) == 1 and len(left_button_names) == 1 and len(right_button_names) == 1 ): up_text = list(up_button_names)[0] down_text = list(down_button_names)[0] left_text = list(left_button_names)[0] right_text = list(right_button_names)[0] run_text = _commonassets.strings.compose.line_pair( first=_classicassets.strings.controls.move_directions( up=up_text, left=left_text, down=down_text, right=right_text, ), second=run_text, ) if self._force_hide_button_names: jump_button_names.clear() punch_button_names.clear() bomb_button_names.clear() pickup_button_names.clear() self._run_text.text = run_text w_text: bs.Lstr | bs.LangStr | str if only_remote and self._lifespan is None: w_text = _classicassets.strings.controls.fire_tv_remote_warning( remote_app_name=(_builtinassets.strings.ui.remote_app_name) ) else: w_text = '' self._extra_text.text = w_text if len(punch_button_names) == 1: self._punch_text.text = list(punch_button_names)[0] else: self._punch_text.text = '' if len(jump_button_names) == 1: tval = list(jump_button_names)[0] else: tval = '' self._jump_text.text = tval if tval == '': self._run_text.position = self._run_text_pos_top self._extra_text.position = ( self._run_text_pos_top[0], self._run_text_pos_top[1] - 50, ) else: self._run_text.position = self._run_text_pos_bottom self._extra_text.position = ( self._run_text_pos_bottom[0], self._run_text_pos_bottom[1] - 50, ) if len(bomb_button_names) == 1: self._bomb_text.text = list(bomb_button_names)[0] else: self._bomb_text.text = '' # Also move our title up/down depending on if this is shown. if len(pickup_button_names) == 1: self._pick_up_text.text = list(pickup_button_names)[0] if self._title_text is not None: self._title_text.position = self._title_text_pos_top else: self._pick_up_text.text = '' if self._title_text is not None: self._title_text.position = self._title_text_pos_bottom def _die(self) -> None: for node in self._nodes: node.delete() self._nodes = [] self._update_timer = None self._dead = True
[docs] @override def exists(self) -> bool: return not self._dead
[docs] @override def handlemessage(self, msg: Any) -> Any: assert not self.expired if isinstance(msg, bs.DieMessage): if msg.immediate: self._die() else: # If they don't need immediate, fade out our nodes and # die later. for node in self._nodes: bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0}) bs.timer(3.1, bs.WeakCallStrict(self._die)) return None return super().handlemessage(msg)
[docs] class ClassicControlsLocalDisplayHandler(bs.LocalDisplayHandler): """Runs a :class:`LocalControlsGuide` for a local display.""" def __init__( self, display: bs.LocalDisplay, config: bs.ClassicControlsLocalDisplayConfig, ) -> None: super().__init__(display) self._config = config self._guide: LocalControlsGuide | None = None if display.visible: self._spawn() def _spawn(self) -> None: # The host-drawn guide never made it into replays (its nodes were # host-only); keep that behavior. if bs.is_in_replay(): return self._guide = LocalControlsGuide(self._config).autoretain()
[docs] @override def on_config_changed(self, config: bs.LocalDisplayConfig) -> None: assert isinstance(config, bs.ClassicControlsLocalDisplayConfig) self._config = config
[docs] @override def on_visible_changed(self, visible: bool) -> None: if visible: if self._guide is None: self._spawn() else: if self._guide is not None: self._guide.handlemessage(bs.DieMessage()) self._guide = None
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self, TypeIs) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum