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 )