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 )