Named input actions¶
one intent bound to keyboard, mouse and gamepad
▶ Run in browserTags: input actions keyboard mouse gamepad
The root node declares an input_actions dictionary binding each action to
keyboard keys, a mouse button, a gamepad button (JoyButton.A) and analog
stick axes (explicit InputBinding(joy_axis=...)). A shape moves with
Input.get_vector, hops on is_action_just_pressed, and a boost bar shows
get_action_strength reading an analog trigger. The HUD lists every action
with its bindings and lights up the ones active this frame.
What it demonstrates¶
input_actionsclass attribute on the root node: the canonical, web-safe registration path, mixing bare Key / MouseButton / JoyButton values with explicitInputBinding(joy_axis=..., joy_axis_positive=...)for sticks.Input.get_vector(normalised movement),is_action_just_pressed(jump) andget_action_strength(analog boost from a trigger axis).Enumerating bindings back out of
InputMapto label the HUD.
Controls: WASD / arrows / left stick - Move SPACE / left click / pad A - Jump SHIFT / right trigger - Boost ESC - Quit
Run: uv run python examples/features/input/actions.py Headless self-check: uv run python examples/features/input/actions.py –test
Source¶
1"""Named input actions: one intent bound to keyboard, mouse and gamepad
2
3The root node declares an ``input_actions`` dictionary binding each action to
4keyboard keys, a mouse button, a gamepad button (``JoyButton.A``) and analog
5stick axes (explicit ``InputBinding(joy_axis=...)``). A shape moves with
6``Input.get_vector``, hops on ``is_action_just_pressed``, and a boost bar shows
7``get_action_strength`` reading an analog trigger. The HUD lists every action
8with its bindings and lights up the ones active this frame.
9
10# /// simvx
11# tags = ["input", "actions", "keyboard", "mouse", "gamepad"]
12# web = { root = "InputActionsDemo", width = 960, height = 540, responsive = true }
13# ///
14
15## What it demonstrates
16- ``input_actions`` class attribute on the root node: the canonical, web-safe
17 registration path, mixing bare Key / MouseButton / JoyButton values with
18 explicit ``InputBinding(joy_axis=..., joy_axis_positive=...)`` for sticks.
19- ``Input.get_vector`` (normalised movement), ``is_action_just_pressed`` (jump)
20 and ``get_action_strength`` (analog boost from a trigger axis).
21- Enumerating bindings back out of ``InputMap`` to label the HUD.
22
23Controls:
24 WASD / arrows / left stick - Move
25 SPACE / left click / pad A - Jump
26 SHIFT / right trigger - Boost
27 ESC - Quit
28
29Run: uv run python examples/features/input/actions.py
30Headless self-check: uv run python examples/features/input/actions.py --test
31"""
32
33from simvx.core import (
34 Input,
35 InputBinding,
36 InputMap,
37 JoyAxis,
38 JoyButton,
39 Key,
40 MouseButton,
41 Node2D,
42 Vec2,
43)
44from simvx.graphics import App
45
46WIDTH, HEIGHT = 960, 540
47RADIUS = 24.0
48BASE_SPEED = 320.0
49BOOST_SPEED = 320.0 # added on top at full boost strength
50HOP_VELOCITY = -420.0
51HOP_GRAVITY = 1400.0
52
53# The HUD's display order; bindings are enumerated from InputMap at runtime.
54ACTIONS = ("move_left", "move_right", "move_up", "move_down", "jump", "boost")
55
56
57def _describe(binding: InputBinding) -> str:
58 """One binding as a short human-readable label."""
59 if binding.key is not None:
60 return binding.key_combo
61 if binding.mouse_button is not None:
62 return f"mouse {binding.mouse_button.name.lower()}"
63 if binding.joy_button is not None:
64 return f"pad {binding.joy_button.name}"
65 sign = "+" if binding.joy_axis_positive else "-"
66 return f"pad {binding.joy_axis.name.lower()}{sign}"
67
68
69class InputActionsDemo(Node2D):
70 """A shape driven entirely through named actions, never raw device state."""
71
72 dynamic = True # position, hop and HUD change every frame
73
74 # The canonical registration path: consumed when the scene mounts, before
75 # the first frame, and re-applied on every change_scene. Bare Key /
76 # MouseButton / JoyButton values auto-wrap; stick axes need an explicit
77 # InputBinding because one axis splits into two opposing actions.
78 input_actions = {
79 "move_left": [Key.A, Key.LEFT, InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=False)],
80 "move_right": [Key.D, Key.RIGHT, InputBinding(joy_axis=JoyAxis.LEFT_X, joy_axis_positive=True)],
81 "move_up": [Key.W, Key.UP, InputBinding(joy_axis=JoyAxis.LEFT_Y, joy_axis_positive=False)],
82 "move_down": [Key.S, Key.DOWN, InputBinding(joy_axis=JoyAxis.LEFT_Y, joy_axis_positive=True)],
83 # One intent, three devices: keyboard, mouse and gamepad all "jump".
84 "jump": [Key.SPACE, MouseButton.LEFT, JoyButton.A],
85 # A digital key and an analog trigger feed the same strength query:
86 # the key reads 1.0, the trigger its actual deflection.
87 "boost": [Key.LEFT_SHIFT, Key.RIGHT_SHIFT, InputBinding(joy_axis=JoyAxis.RIGHT_TRIGGER)],
88 "quit": [Key.ESCAPE],
89 }
90
91 def on_ready(self):
92 self.position = Vec2(WIDTH / 2, HEIGHT * 0.62)
93 self._hop_y = 0.0 # vertical hop offset, drawn only
94 self._hop_v = 0.0
95 self._jump_count = 0
96 # Enumerate the map back out so the HUD never drifts from the truth.
97 self._legend = {name: ", ".join(_describe(b) for b in InputMap.get_bindings(name)) for name in ACTIONS}
98
99 def on_update(self, dt: float):
100 if Input.is_action_just_pressed("quit"):
101 self.app.quit()
102
103 # Movement: four opposing actions folded into one normalised vector.
104 # Digital keys give unit deflection; a stick gives its analog value.
105 move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
106 boost = Input.get_action_strength("boost")
107 speed = BASE_SPEED + BOOST_SPEED * boost
108 self.position = Vec2(
109 min(max(self.position.x + move.x * speed * dt, RADIUS), WIDTH - RADIUS),
110 min(max(self.position.y + move.y * speed * dt, RADIUS + 90), HEIGHT - RADIUS - 20),
111 )
112
113 # Jump: an edge, not a level. Fires once per press on any device.
114 if Input.is_action_just_pressed("jump") and self._hop_y == 0.0:
115 self._hop_v = HOP_VELOCITY
116 self._jump_count += 1
117 if self._hop_y < 0.0 or self._hop_v < 0.0:
118 self._hop_v += HOP_GRAVITY * dt
119 self._hop_y = min(self._hop_y + self._hop_v * dt, 0.0)
120 if self._hop_y == 0.0 and self._hop_v > 0.0:
121 self._hop_v = 0.0
122
123 def on_draw(self, renderer):
124 # The shape, lifted by the hop offset, tinted by boost strength.
125 boost = Input.get_action_strength("boost")
126 cx, cy = self.position.x, self.position.y + self._hop_y
127 renderer.draw_circle((cx, cy + RADIUS * 0.9), RADIUS * 0.8, colour=(0, 0, 0, 0.25), filled=True)
128 renderer.draw_circle((cx, cy), RADIUS, colour=(0.4 + 0.6 * boost, 0.8, 1.0 - 0.5 * boost, 1.0), filled=True)
129
130 renderer.draw_text("Named Input Actions", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
131 renderer.draw_text(f"Jumps: {self._jump_count}", (WIDTH - 130, 12), colour=(0.75, 0.75, 0.75))
132
133 # Live action panel: green while active, with strength and bindings.
134 y = 44
135 for name in ACTIONS:
136 strength = Input.get_action_strength(name)
137 active = Input.is_action_pressed(name)
138 colour = (0.35, 0.95, 0.45) if active else (0.55, 0.55, 0.6)
139 renderer.draw_text(f"{name:<10} {strength:4.2f} [{self._legend[name]}]", (10, y), colour=colour)
140 y += 22
141
142 # Boost strength bar: full for the SHIFT key, partial for the trigger.
143 renderer.draw_text("boost", (10, HEIGHT - 52), colour=(0.75, 0.75, 0.75))
144 renderer.draw_rect((70, HEIGHT - 52), (220, 14), colour=(0.25, 0.25, 0.3, 1.0), filled=True)
145 if boost > 0.0:
146 renderer.draw_rect((70, HEIGHT - 52), (220 * boost, 14), colour=(1.0, 0.65, 0.2, 1.0), filled=True)
147 renderer.draw_text("ESC: quit", (10, HEIGHT - 28), colour=(0.6, 0.6, 0.6))
148
149
150def _selftest() -> bool:
151 """Headless: drive every device the docstring claims through the action map.
152
153 Movement is measured off the shape's real position, jumps off the demo's own
154 ``_jump_count``, so the test exercises the same action queries the demo runs
155 on, not listeners added for the test.
156 """
157 from simvx.core.testing import InputSimulator
158 from simvx.graphics.testing import assert_not_blank, save_png
159
160 FRAMES = 190
161 app = App(title="Named Input Actions", width=WIDTH, height=HEIGHT, visible=False)
162 scene = InputActionsDemo(name="InputActionsDemo")
163 sim = InputSimulator()
164 seen: dict[str, object] = {}
165 marks: dict[str, Vec2] = {}
166
167 def on_frame(idx: int, _t: float) -> bool:
168 # Keyboard: D alone, then a D+S diagonal.
169 if idx == 5:
170 marks["kb_start"] = scene.position
171 sim.press_key(Key.D)
172 elif idx == 20:
173 marks["kb_end"] = scene.position
174 sim.press_key(Key.S)
175 elif idx == 22:
176 seen["diagonal"] = Input.get_vector("move_left", "move_right", "move_up", "move_down")
177 elif idx == 25:
178 sim.release_key(Key.D)
179 sim.release_key(Key.S)
180 # Jump on all three devices bound to the one action. The presses sit
181 # a full hop apart so the shape is back on the ground for each one.
182 elif idx == 35:
183 sim.press_gamepad(JoyButton.A)
184 elif idx == 38:
185 sim.release_gamepad(JoyButton.A)
186 elif idx == 75:
187 sim.press_mouse(MouseButton.LEFT)
188 elif idx == 78:
189 sim.release_mouse(MouseButton.LEFT)
190 elif idx == 115:
191 sim.press_key(Key.SPACE)
192 elif idx == 118:
193 sim.release_key(Key.SPACE)
194 # Analog stick: a 0.6 deflection, then one inside the deadzone.
195 elif idx == 155:
196 marks["stick_start"] = scene.position
197 sim.set_gamepad_axis(JoyAxis.LEFT_X, 0.6)
198 elif idx == 165:
199 seen["stick_strength"] = Input.get_action_strength("move_right")
200 marks["stick_end"] = scene.position
201 sim.set_gamepad_axis(JoyAxis.LEFT_X, 0.1)
202 elif idx == 170:
203 seen["deadzone_strength"] = Input.get_action_strength("move_right")
204 sim.set_gamepad_axis(JoyAxis.LEFT_X, 0.0)
205 # Analog trigger feeding get_action_strength through the boost action.
206 elif idx == 175:
207 sim.set_gamepad_axis(JoyAxis.RIGHT_TRIGGER, 0.75)
208 elif idx == 180:
209 seen["boost_strength"] = Input.get_action_strength("boost")
210 sim.set_gamepad_axis(JoyAxis.RIGHT_TRIGGER, 0.0)
211 return True
212
213 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[45])
214 assert_not_blank(frames[0])
215 save_png(frames[0], "/tmp/input_actions_test.png")
216
217 ok = True
218
219 def check(label: str, passed: bool, detail: str) -> None:
220 nonlocal ok
221 ok = ok and passed
222 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
223
224 kb_travel = marks["kb_end"].x - marks["kb_start"].x
225 check("D moves the shape right through the action map", kb_travel > 30, f"travelled {kb_travel:.1f}px")
226
227 diag = seen["diagonal"]
228 check(
229 "a diagonal is normalised by get_vector",
230 abs(diag.length() - 1.0) < 1e-4 and diag.x > 0 and diag.y > 0,
231 f"vector ({diag.x:.3f}, {diag.y:.3f}), length {diag.length():.4f}",
232 )
233
234 check(
235 "pad A, left click and SPACE each fired one jump",
236 scene._jump_count == 3,
237 f"{scene._jump_count} jumps recorded",
238 )
239
240 check(
241 "a 0.6 stick deflection reads as 0.6 strength",
242 abs(seen["stick_strength"] - 0.6) < 1e-4,
243 f"strength {seen['stick_strength']:.3f}",
244 )
245 stick_travel = marks["stick_end"].x - marks["stick_start"].x
246 check(
247 "the stick moves the shape slower than a full key press",
248 20 < stick_travel < kb_travel,
249 f"stick {stick_travel:.1f}px vs keyboard {kb_travel:.1f}px over the same hold",
250 )
251 check(
252 "a deflection inside the deadzone reads as zero",
253 seen["deadzone_strength"] == 0.0,
254 f"strength {seen['deadzone_strength']:.3f} at 0.1 deflection",
255 )
256 check(
257 "the trigger's analog value reaches get_action_strength",
258 abs(seen["boost_strength"] - 0.75) < 1e-4,
259 f"strength {seen['boost_strength']:.3f}",
260 )
261
262 legend = scene._legend["jump"]
263 check(
264 "the HUD legend enumerates all three jump bindings",
265 "space" in legend and "mouse left" in legend and "pad A" in legend,
266 f"jump legend: {legend}",
267 )
268
269 print("screenshot: /tmp/input_actions_test.png")
270 print("SELFTEST:", "PASS" if ok else "FAIL")
271 return ok
272
273
274if __name__ == "__main__":
275 import sys
276
277 if "--test" in sys.argv:
278 sys.exit(0 if _selftest() else 1)
279 App(title="Named Input Actions", width=WIDTH, height=HEIGHT).run(InputActionsDemo())