afterglow/ui/controls.py

Part of Afterglow.

  1"""Afterglow input layer: the one canonical cross-platform player read.
  2
  3Keyboard, controller, and on-screen touch all feed the *same* named input actions
  4(declared on the game root), so the sim sees identical intent regardless of
  5device. ``read_player_input`` is the single per-frame read; ``should_show_touch``
  6decides when the on-screen pad belongs on screen.
  7
  8Named actions (registered by the game root):
  9    move_left  move_right  move_up  move_down  jump  dash  pause
 10
 11``GameRoot.input_context`` binds each action to keyboard keys and a controller
 12button (D-pad for movement, A for jump, X or B for dash, Start for pause). The
 13on-screen pad is a :class:`~simvx.core.ui.TouchController`, which publishes what a
 14thumb does as a real controller, so touch play comes out of those same controller
 15bindings and there is no third input path to keep in step. Analog left-stick
 16movement is optional sugar: add ``Controller.LEFT_STICK.left`` (and friends) to
 17the same lists and nothing downstream changes.
 18"""
 19
 20from __future__ import annotations
 21
 22from simvx.core import Anchor, Control, Input, InputMethod, PointerFilter
 23
 24from ..sim.room import InputState
 25
 26#: Below this width (or when portrait / touch-detected) the on-screen pad shows.
 27_SMALL_SCREEN_W = 820.0
 28
 29
 30def read_player_input(tree) -> InputState:
 31    """The ONE canonical per-frame input read.
 32
 33    Merges keyboard + controller + on-screen touch transparently: every source
 34    feeds the same named actions, so this only ever queries those actions.
 35    ``move_y`` is y-DOWN (matches the sim) and also aims the dash.
 36    """
 37    move_x = float(Input.pressed("move_right")) - float(Input.pressed("move_left"))
 38    move_y = float(Input.pressed("move_down")) - float(Input.pressed("move_up"))
 39    # jump/dash carry a press EDGE (just_pressed) plus a HELD state. The sim runs a
 40    # fixed 60 Hz accumulator: above 60 fps a frame can run zero ticks, so sampling
 41    # held-state and re-deriving the edge inside the tick silently drops taps (and
 42    # releases) that fall between ticks. Sampling the edge here + latching it in
 43    # Room.step makes input frame-rate independent.
 44    return InputState(
 45        move_x=move_x,
 46        move_y=move_y,
 47        jump_pressed=Input.just_pressed("jump"),
 48        jump_held=Input.pressed("jump"),
 49        dash_pressed=Input.just_pressed("dash"),
 50    )
 51
 52
 53def should_show_touch(tree) -> bool:
 54    """True when the player is on a touchscreen, or the screen is small / portrait.
 55
 56    The game shows / hides the on-screen pad accordingly. ``tree`` is the
 57    node's ``SceneTree`` (``self.tree``). A finger on the glass makes the input
 58    method touch until the player reaches for a key or a pad, and a small or
 59    portrait viewport is a phone whether or not it has been touched yet.
 60    """
 61    if Input.input_method is InputMethod.TOUCH:
 62        return True
 63    w, h = tree.screen_size
 64    return w < _SMALL_SCREEN_W or h > w
 65
 66
 67# Theme accents shared with the HUD / menus so the overlays read as one game.
 68_ACCENT = (0.55, 1.0, 0.78, 1.0)
 69_TEXT = (0.95, 1.0, 0.96, 1.0)
 70_PANEL = (0.04, 0.05, 0.08, 0.62)
 71
 72
 73class RoomIntro(Control):
 74    """A brief, fading room-name card shown on entering a room.
 75
 76    Returning players press Play and get dropped onto the first room they have
 77    not cleared yet (which may not be the gentle tutorial). Surfacing the room
 78    NAME on entry turns "why did the game put me here?" into a legible "you are
 79    on 'Over the Brook'", so a death-respawn there reads as a death, not a random
 80    reset. Self-drawing (like the HUD) so it needs no container layout, anchored
 81    full-rect and drawn centred near the top. Auto-fades and then hides.
 82    """
 83
 84    #: Seconds the card holds full opacity before it begins to fade out.
 85    _HOLD = 1.6
 86    #: Seconds the fade-out takes once the hold elapses.
 87    _FADE = 0.9
 88
 89    def __init__(self, **kwargs):
 90        super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
 91        self.pointer_filter = PointerFilter.IGNORE  # never eat gameplay clicks
 92        self._title = ""
 93        self._subtitle = ""
 94        self._t = 0.0
 95        self._alpha = 0.0
 96
 97    def show(self, title: str, subtitle: str = "") -> None:
 98        """(Re)start the card for a room title (and optional subtitle)."""
 99        self._title = str(title)
100        self._subtitle = str(subtitle)
101        self._t = 0.0
102        self._alpha = 1.0
103        self.visible = True
104        self.queue_redraw()
105
106    def update(self, dt: float) -> None:
107        if not self.visible:
108            return
109        self._t += dt
110        if self._t <= self._HOLD:
111            self._alpha = 1.0
112        else:
113            self._alpha = max(0.0, 1.0 - (self._t - self._HOLD) / self._FADE)
114            if self._alpha <= 0.0:
115                self.visible = False
116        self.queue_redraw()
117
118    def on_draw(self, renderer) -> None:
119        a = self._alpha
120        if a <= 0.0 or not self._title:
121            return
122        _, _, w, _h = self.get_rect()
123        cx = w * 0.5
124        ty = 54.0
125        scale = 30.0 / 16.0
126        tw = renderer.text_width(self._title, scale)
127        pad = 18.0
128        renderer.rect(
129            (cx - tw * 0.5 - pad, ty - 10.0),
130            (tw + pad * 2, 44.0),
131            fill=(_PANEL[0], _PANEL[1], _PANEL[2], _PANEL[3] * a),
132        )
133        renderer.text(self._title, (cx - tw * 0.5, ty), fill=(*_ACCENT[:3], a), scale=scale)
134        if self._subtitle:
135            sscale = 15.0 / 16.0
136            sw = renderer.text_width(self._subtitle, sscale)
137            renderer.text(
138                self._subtitle, (cx - sw * 0.5, ty + 36.0), fill=(_TEXT[0], _TEXT[1], _TEXT[2], 0.8 * a), scale=sscale
139            )
140
141
142class ControlHints(Control):
143    """A first-room control cheat-sheet ("Move <-  ->   Jump SPACE") that fades.
144
145    Shown only on the very first room of a fresh run (the tutorial), so a brand
146    new player learns the controls without a wall of text. Self-drawing, anchored
147    full-rect, drawn low-centre above the bottom edge. Holds, then fades out and
148    hides; it never injects input and never eats clicks.
149    """
150
151    _HOLD = 4.0
152    _FADE = 1.5
153    _LINES = ("Move  ←  →", "Jump  SPACE", "Dash  SHIFT")
154
155    def __init__(self, **kwargs):
156        super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
157        self.pointer_filter = PointerFilter.IGNORE
158        self._t = 0.0
159        self._alpha = 1.0
160
161    def show(self) -> None:
162        self._t = 0.0
163        self._alpha = 1.0
164        self.visible = True
165        self.queue_redraw()
166
167    def update(self, dt: float) -> None:
168        if not self.visible:
169            return
170        self._t += dt
171        if self._t <= self._HOLD:
172            self._alpha = 1.0
173        else:
174            self._alpha = max(0.0, 1.0 - (self._t - self._HOLD) / self._FADE)
175            if self._alpha <= 0.0:
176                self.visible = False
177        self.queue_redraw()
178
179    def on_draw(self, renderer) -> None:
180        a = self._alpha
181        if a <= 0.0:
182            return
183        _, _, w, h = self.get_rect()
184        cx = w * 0.5
185        scale = 18.0 / 16.0
186        line_h = 26.0
187        total = line_h * len(self._LINES)
188        top = h - 40.0 - total
189        widths = [renderer.text_width(s, scale) for s in self._LINES]
190        box_w = max(widths) + 40.0
191        renderer.rect(
192            (cx - box_w * 0.5, top - 12.0), (box_w, total + 24.0), fill=(_PANEL[0], _PANEL[1], _PANEL[2], _PANEL[3] * a)
193        )
194        for i, line in enumerate(self._LINES):
195            renderer.text(
196                line, (cx - widths[i] * 0.5, top + i * line_h), fill=(_TEXT[0], _TEXT[1], _TEXT[2], a), scale=scale
197            )