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

from typing import TYPE_CHECKING, override

import bascenev1 as bs

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. """ 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. """ # pylint: disable=too-many-statements # pylint: disable=too-many-locals super().__init__() 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 = bs.Lstr( value='${A}:', subs=[('${A}', bs.Lstr(resource='controlsText'))] ) self._title_text = bs.newnode( 'text', attrs={ 'text': tval, 'host_only': True, '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': bs.gettexture('buttonJump'), 'absolute_scale': True, 'host_only': 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, 'host_only': True, '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': bs.gettexture('buttonPunch'), 'absolute_scale': True, 'host_only': 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, 'host_only': True, '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': bs.gettexture('buttonBomb'), 'absolute_scale': True, 'host_only': 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, 'host_only': True, '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': bs.gettexture('buttonPickUp'), 'absolute_scale': True, 'host_only': 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, 'host_only': True, '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, 'host_only': True, '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, 'host_only': True, '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.WeakCall(self._start_updating)) @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 bs.Lstr(resource='configGamepadWindow.unsetText').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.WeakCall(self.handlemessage, bs.DieMessage(immediate=True)), ) self._fade_in_timer = bs.Timer( 1.0, bs.WeakCall(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: # We look at the session's players; not the activity's. # We want to get ones who are still in the process of # selecting a character, etc. input_devices = [ p.inputdevice for p in bs.getsession().sessionplayers ] input_devices = [ i for i in 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.WeakCall(self.handlemessage, bs.DieMessage()) ) self._update() self._update_timer = bs.Timer( 1.0, bs.WeakCall(self._update), repeat=True ) def _update(self) -> None: # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals 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() # We look at the session's players; not the activity's - we want to # get ones who are still in the process of selecting a character, etc. input_devices = [p.inputdevice for p in bs.getsession().sessionplayers] input_devices = [i for i in 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.Lstr( value='${R}: ${B}', subs=[ ('${R}', bs.Lstr(resource='runText')), ( '${B}', bs.Lstr( resource=( 'holdAnyKeyText' if all_keyboards else 'holdAnyButtonText' ) ), ), ], ) # 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 = bs.Lstr( value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}', subs=[ ('${M}', bs.Lstr(resource='moveText')), ('${U}', up_text), ('${L}', left_text), ('${D}', down_text), ('${R}', right_text), ('${RUN}', 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 | str if only_remote and self._lifespan is None: w_text = bs.Lstr( resource='fireTVRemoteWarningText', subs=[('${REMOTE_APP_NAME}', bs.get_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.WeakCall(self._die)) return None return super().handlemessage(msg)
# 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) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum