afterglow/ui/controls.py

Part of Afterglow.

  1"""Afterglow input layer: the one canonical cross-platform player read.
  2
  3Keyboard, gamepad, 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; ``TouchControls`` is
  6the mobile/web overlay that injects those same actions; ``should_show_touch``
  7decides when to show it.
  8
  9Named actions (registered by the game root, mirrored here for the injectors):
 10    move_left  move_right  move_up  move_down  jump  dash  pause
 11
 12``GameRoot.input_actions`` binds each action to keyboard keys and a gamepad
 13button (D-pad for movement, A / X for jump / dash, Start for pause). Analog
 14left-stick movement is optional sugar: add an explicit
 15``InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=False)`` (and
 16friends) to the same lists and nothing downstream changes. Touch is delivered as
 17``MouseButton.LEFT`` by the web runtime, so the virtual widgets work unchanged
 18on mobile.
 19"""
 20
 21from __future__ import annotations
 22
 23from simvx.core import AnchorPreset, Control, Input, Key, Vec2
 24from simvx.core.ui import VirtualButton, VirtualJoystick
 25
 26from ..sim.room import InputState
 27
 28#: Each named action -> the canonical key the touch overlay injects for it. The
 29#: injected key is *one of* the keys the action already binds, so on-screen taps
 30#: travel the exact same ``is_action_pressed`` path as a physical press.
 31_ACTION_KEY = {
 32    "move_left": Key.LEFT,
 33    "move_right": Key.RIGHT,
 34    "move_up": Key.UP,
 35    "move_down": Key.DOWN,
 36    "jump": Key.SPACE,
 37    "dash": Key.LEFT_SHIFT,
 38    "pause": Key.ESCAPE,
 39}
 40
 41#: Below this width (or when portrait / touch-detected) the touch overlay shows.
 42_SMALL_SCREEN_W = 820.0
 43
 44#: Joystick tilt past this magnitude latches a directional action on.
 45_AXIS_THRESHOLD = 0.4
 46
 47
 48def read_player_input(tree) -> InputState:
 49    """The ONE canonical per-frame input read.
 50
 51    Merges keyboard + gamepad + on-screen touch transparently: every source
 52    feeds the same named actions, so this only ever queries those actions.
 53    ``move_y`` is y-DOWN (matches the sim) and also aims the dash.
 54    """
 55    move_x = float(Input.is_action_pressed("move_right")) - float(Input.is_action_pressed("move_left"))
 56    move_y = float(Input.is_action_pressed("move_down")) - float(Input.is_action_pressed("move_up"))
 57    # jump/dash carry a press EDGE (just_pressed) plus a HELD state. The sim runs a
 58    # fixed 60 Hz accumulator: above 60 fps a frame can run zero ticks, so sampling
 59    # held-state and re-deriving the edge inside the tick silently drops taps (and
 60    # releases) that fall between ticks. Sampling the edge here + latching it in
 61    # Room.step makes input frame-rate independent.
 62    return InputState(
 63        move_x=move_x,
 64        move_y=move_y,
 65        jump_pressed=Input.is_action_just_pressed("jump"),
 66        jump_held=Input.is_action_pressed("jump"),
 67        dash_pressed=Input.is_action_just_pressed("dash"),
 68    )
 69
 70
 71def should_show_touch(tree) -> bool:
 72    """True when touch is detected or the screen is small / portrait.
 73
 74    The game shows / hides :class:`TouchControls` accordingly. ``tree`` is the
 75    node's ``SceneTree`` (``self.tree``); on the web runtime touch surfaces as a
 76    small or portrait viewport, which is what we key off here.
 77    """
 78    if Input.touches or Input.touches_just_pressed:
 79        return True
 80    w, h = tree.screen_size
 81    return w < _SMALL_SCREEN_W or h > w
 82
 83
 84class TouchControls(Control):
 85    """Responsive, safe-area-aware on-screen overlay (joystick + three buttons).
 86
 87    A :class:`VirtualJoystick` sits bottom-left and drives BOTH movement and the
 88    dash-aim (the sim reads ``move_x/move_y`` for the dash direction). Two
 89    :class:`VirtualButton` s sit bottom-right: JUMP and DASH, and a small pause
 90    button sits top-right so a touch-only player can reach the pause menu (and
 91    through it restart, options and quit). Every widget injects the same named
 92    actions the keyboard uses, so the game is identical across devices.
 93    """
 94
 95    def __init__(self, **kwargs):
 96        super().__init__(**kwargs)
 97        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 98
 99        self._joystick = VirtualJoystick(name="MoveStick")
100        self._joystick.moved.connect(self._on_stick)
101        self.add_child(self._joystick)
102
103        self._jump = VirtualButton(label="JUMP", name="JumpButton")
104        self._jump.pressed.connect(lambda: Input.inject_key(_ACTION_KEY["jump"], True))
105        self._jump.released.connect(lambda: Input.inject_key(_ACTION_KEY["jump"], False))
106        self.add_child(self._jump)
107
108        self._dash = VirtualButton(label="DASH", name="DashButton")
109        self._dash.pressed.connect(lambda: Input.inject_key(_ACTION_KEY["dash"], True))
110        self._dash.released.connect(lambda: Input.inject_key(_ACTION_KEY["dash"], False))
111        self.add_child(self._dash)
112
113        self._pause = VirtualButton(label="II", name="PauseButton")
114        self._pause.pressed.connect(self._tap_pause)
115        self.add_child(self._pause)
116
117        #: Directional keys the stick currently holds down, so we only inject on
118        #: edges (and can release cleanly when the stick re-centres or hides).
119        self._stick_held: set[Key] = set()
120
121    @staticmethod
122    def _tap_pause() -> None:
123        """Fire the pause action as a complete press + release.
124
125        Opening the pause menu hides this overlay, so the finger-up event would
126        never reach the button: injecting both edges here keeps the key from
127        latching down for the rest of the run.
128        """
129        Input.inject_key(_ACTION_KEY["pause"], True)
130        Input.inject_key(_ACTION_KEY["pause"], False)
131
132    def on_enter_tree(self) -> None:
133        self._layout(self.tree.screen_size)
134        self.tree.screen_resized.connect(self._layout)
135
136    def on_exit_tree(self) -> None:
137        self.tree.screen_resized.disconnect(self._layout)
138        self._release_stick()
139
140    # -- layout ------------------------------------------------------------
141
142    def _layout(self, size) -> None:
143        """Size + place the widgets for the viewport, with a safe-area inset.
144
145        Generous touch targets that scale with the smaller screen dimension;
146        bottom-left for the stick, bottom-right for the action buttons.
147        """
148        w, h = float(size[0]), float(size[1])
149        unit = min(w, h)
150        inset = max(20.0, unit * 0.04)  # safe-area margin from the edges
151
152        stick_r = max(64.0, unit * 0.13)
153        self._joystick.radius = stick_r
154        self._joystick.size = Vec2(stick_r * 2, stick_r * 2)
155        self._joystick.position = Vec2(inset, h - inset - stick_r * 2)
156
157        btn_r = max(44.0, unit * 0.085)
158        self._jump.button_radius = btn_r
159        self._dash.button_radius = btn_r
160        self._jump.size = Vec2(btn_r * 2, btn_r * 2)
161        self._dash.size = Vec2(btn_r * 2, btn_r * 2)
162
163        gap = btn_r * 0.6
164        # JUMP lower-right, DASH up-left of it (thumb-friendly diagonal).
165        self._jump.position = Vec2(w - inset - btn_r * 2, h - inset - btn_r * 2)
166        self._dash.position = Vec2(
167            w - inset - btn_r * 4 - gap,
168            h - inset - btn_r * 3 - gap,
169        )
170
171        # Pause sits top-right, deliberately small and far from the thumbs so it
172        # is reachable but never hit mid-jump.
173        pause_r = max(20.0, unit * 0.04)
174        self._pause.button_radius = pause_r
175        self._pause.size = Vec2(pause_r * 2, pause_r * 2)
176        self._pause.position = Vec2(w - inset - pause_r * 2, inset)
177
178    # -- joystick → directional actions -----------------------------------
179
180    def _on_stick(self, nx: float, ny: float) -> None:
181        """Map the analog tilt onto the digital move actions (y-DOWN)."""
182        want: set[Key] = set()
183        if nx <= -_AXIS_THRESHOLD:
184            want.add(_ACTION_KEY["move_left"])
185        elif nx >= _AXIS_THRESHOLD:
186            want.add(_ACTION_KEY["move_right"])
187        if ny <= -_AXIS_THRESHOLD:
188            want.add(_ACTION_KEY["move_up"])
189        elif ny >= _AXIS_THRESHOLD:
190            want.add(_ACTION_KEY["move_down"])
191
192        for k in self._stick_held - want:
193            Input.inject_key(k, False)
194        for k in want - self._stick_held:
195            Input.inject_key(k, True)
196        self._stick_held = want
197
198    def _release_stick(self) -> None:
199        for k in self._stick_held:
200            Input.inject_key(k, False)
201        self._stick_held = set()
202
203
204# Theme accents shared with the HUD / menus so the overlays read as one game.
205_ACCENT = (0.55, 1.0, 0.78, 1.0)
206_TEXT = (0.95, 1.0, 0.96, 1.0)
207_PANEL = (0.04, 0.05, 0.08, 0.62)
208
209
210class RoomIntro(Control):
211    """A brief, fading room-name card shown on entering a room.
212
213    Returning players press Play and get dropped onto the first room they have
214    not cleared yet (which may not be the gentle tutorial). Surfacing the room
215    NAME on entry turns "why did the game put me here?" into a legible "you are
216    on 'Over the Brook'", so a death-respawn there reads as a death, not a random
217    reset. Self-drawing (like the HUD) so it needs no container layout, anchored
218    full-rect and drawn centred near the top. Auto-fades and then hides.
219    """
220
221    #: Seconds the card holds full opacity before it begins to fade out.
222    _HOLD = 1.6
223    #: Seconds the fade-out takes once the hold elapses.
224    _FADE = 0.9
225
226    def __init__(self, **kwargs):
227        super().__init__(**kwargs)
228        self.set_anchor_preset(AnchorPreset.FULL_RECT)
229        self.mouse_filter = False  # never eat gameplay clicks
230        self._title = ""
231        self._subtitle = ""
232        self._t = 0.0
233        self._alpha = 0.0
234
235    def show(self, title: str, subtitle: str = "") -> None:
236        """(Re)start the card for a room title (and optional subtitle)."""
237        self._title = str(title)
238        self._subtitle = str(subtitle)
239        self._t = 0.0
240        self._alpha = 1.0
241        self.visible = True
242        self.queue_redraw()
243
244    def update(self, dt: float) -> None:
245        if not self.visible:
246            return
247        self._t += dt
248        if self._t <= self._HOLD:
249            self._alpha = 1.0
250        else:
251            self._alpha = max(0.0, 1.0 - (self._t - self._HOLD) / self._FADE)
252            if self._alpha <= 0.0:
253                self.visible = False
254        self.queue_redraw()
255
256    def on_draw(self, renderer) -> None:
257        a = self._alpha
258        if a <= 0.0 or not self._title:
259            return
260        x, y, w, _h = self.get_global_rect()
261        cx = x + w * 0.5
262        ty = y + 54.0
263        scale = 30.0 / 16.0
264        tw = renderer.text_width(self._title, scale)
265        pad = 18.0
266        renderer.draw_rect(
267            (cx - tw * 0.5 - pad, ty - 10.0),
268            (tw + pad * 2, 44.0),
269            colour=(_PANEL[0], _PANEL[1], _PANEL[2], _PANEL[3] * a),
270            filled=True,
271        )
272        renderer.draw_text(self._title, (cx - tw * 0.5, ty), colour=(*_ACCENT[:3], a), scale=scale)
273        if self._subtitle:
274            sscale = 15.0 / 16.0
275            sw = renderer.text_width(self._subtitle, sscale)
276            renderer.draw_text(
277                self._subtitle,
278                (cx - sw * 0.5, ty + 36.0),
279                colour=(_TEXT[0], _TEXT[1], _TEXT[2], 0.8 * a),
280                scale=sscale,
281            )
282
283
284class ControlHints(Control):
285    """A first-room control cheat-sheet ("Move <-  ->   Jump SPACE") that fades.
286
287    Shown only on the very first room of a fresh run (the tutorial), so a brand
288    new player learns the controls without a wall of text. Self-drawing, anchored
289    full-rect, drawn low-centre above the bottom edge. Holds, then fades out and
290    hides; it never injects input and never eats clicks.
291    """
292
293    _HOLD = 4.0
294    _FADE = 1.5
295    _LINES = ("Move  ←  →", "Jump  SPACE", "Dash  SHIFT")
296
297    def __init__(self, **kwargs):
298        super().__init__(**kwargs)
299        self.set_anchor_preset(AnchorPreset.FULL_RECT)
300        self.mouse_filter = False
301        self._t = 0.0
302        self._alpha = 1.0
303
304    def show(self) -> None:
305        self._t = 0.0
306        self._alpha = 1.0
307        self.visible = True
308        self.queue_redraw()
309
310    def update(self, dt: float) -> None:
311        if not self.visible:
312            return
313        self._t += dt
314        if self._t <= self._HOLD:
315            self._alpha = 1.0
316        else:
317            self._alpha = max(0.0, 1.0 - (self._t - self._HOLD) / self._FADE)
318            if self._alpha <= 0.0:
319                self.visible = False
320        self.queue_redraw()
321
322    def on_draw(self, renderer) -> None:
323        a = self._alpha
324        if a <= 0.0:
325            return
326        x, y, w, h = self.get_global_rect()
327        cx = x + w * 0.5
328        scale = 18.0 / 16.0
329        line_h = 26.0
330        total = line_h * len(self._LINES)
331        top = y + h - 40.0 - total
332        widths = [renderer.text_width(s, scale) for s in self._LINES]
333        box_w = max(widths) + 40.0
334        renderer.draw_rect(
335            (cx - box_w * 0.5, top - 12.0),
336            (box_w, total + 24.0),
337            colour=(_PANEL[0], _PANEL[1], _PANEL[2], _PANEL[3] * a),
338            filled=True,
339        )
340        for i, line in enumerate(self._LINES):
341            renderer.draw_text(
342                line,
343                (cx - widths[i] * 0.5, top + i * line_h),
344                colour=(_TEXT[0], _TEXT[1], _TEXT[2], a),
345                scale=scale,
346            )