Named input actions

one intent bound to keyboard, mouse and gamepad

▶ Run in browser

Tags: 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_actions class attribute on the root node: the canonical, web-safe registration path, mixing bare Key / MouseButton / JoyButton values with explicit InputBinding(joy_axis=..., joy_axis_positive=...) for sticks.

  • Input.get_vector (normalised movement), is_action_just_pressed (jump) and get_action_strength (analog boost from a trigger axis).

  • Enumerating bindings back out of InputMap to 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())