Gamepad tester

direct polling with string names

▶ Run in browser

Tags: input gamepad polling

Every gamepad input the polling API exposes, drawn live: both sticks as a crosshair in a circle (get_gamepad_vector), the analogue triggers as fill bars (get_gamepad_axis “lt”/”rt”), and all fifteen buttons as labelled dots that light while held (is_gamepad_pressed). Which pads are readable comes from get_connected_gamepads(), so plugging one in and unplugging it again are both visible while the demo runs.

What it demonstrates

  • Direct polling with string names: Input.get_gamepad_vector(pad, "left"), Input.get_gamepad_axis(pad, "lt"), Input.is_gamepad_pressed(pad, "a").

  • The full standard-gamepad name set: 15 buttons and 6 axes.

  • Input.get_connected_gamepads(): the ids readable this frame, which shrinks the moment a pad is unplugged.

  • The engine’s axis convention, identical on every backend: sticks -1..1 centred at 0, triggers 0..1 resting at 0.

  • The built-in radial deadzone on get_gamepad_vector (a small circle marks it).

Controls: Gamepad - everything on it, mirrored on screen ESC - Quit

Run: uv run python examples/features/input/gamepad.py Headless self-check: uv run python examples/features/input/gamepad.py –test

Source

  1"""Gamepad tester: direct polling with string names
  2
  3Every gamepad input the polling API exposes, drawn live: both sticks as a
  4crosshair in a circle (get_gamepad_vector), the analogue triggers as fill
  5bars (get_gamepad_axis "lt"/"rt"), and all fifteen buttons as labelled dots
  6that light while held (is_gamepad_pressed). Which pads are readable comes
  7from get_connected_gamepads(), so plugging one in and unplugging it again are
  8both visible while the demo runs.
  9
 10# /// simvx
 11# tags = ["input", "gamepad", "polling"]
 12# ///
 13
 14## What it demonstrates
 15- Direct polling with string names: `Input.get_gamepad_vector(pad, "left")`,
 16  `Input.get_gamepad_axis(pad, "lt")`, `Input.is_gamepad_pressed(pad, "a")`.
 17- The full standard-gamepad name set: 15 buttons and 6 axes.
 18- `Input.get_connected_gamepads()`: the ids readable this frame, which shrinks
 19  the moment a pad is unplugged.
 20- The engine's axis convention, identical on every backend: sticks -1..1
 21  centred at 0, triggers 0..1 resting at 0.
 22- The built-in radial deadzone on `get_gamepad_vector` (a small circle marks it).
 23
 24Controls:
 25  Gamepad - everything on it, mirrored on screen
 26  ESC     - Quit
 27
 28Run: uv run python examples/features/input/gamepad.py
 29Headless self-check: uv run python examples/features/input/gamepad.py --test
 30"""
 31
 32from simvx.core import Input, Key, Node2D
 33
 34WIDTH, HEIGHT = 960, 540
 35
 36#: The full standard-gamepad name set both platform backends report
 37#: (`is_gamepad_pressed` / `get_gamepad_axis` take exactly these strings).
 38BUTTONS = (
 39    "a", "b", "x", "y", "lb", "rb", "l3", "r3",
 40    "back", "start", "guide", "dpad_up", "dpad_down", "dpad_left", "dpad_right",
 41)  # fmt: skip
 42
 43DIM = (0.42, 0.45, 0.52, 1.0)
 44BRIGHT = (0.90, 0.93, 1.00, 1.0)
 45ACTIVE = (0.30, 0.85, 0.45, 1.0)
 46MARKER = (1.00, 0.75, 0.20, 1.0)
 47WARN = (1.00, 0.65, 0.25, 1.0)
 48
 49
 50class GamepadTester(Node2D):
 51    """Live readout of one gamepad, polled by string name every frame."""
 52
 53    dynamic = True  # every reading can change every frame; redraw always
 54
 55    input_actions = {"quit": [Key.ESCAPE]}
 56
 57    def on_update(self, dt: float):
 58        if Input.is_action_just_pressed("quit"):
 59            self.app.quit()
 60
 61    # ---------------------------------------------------------------- drawing
 62
 63    def on_draw(self, renderer):
 64        connected = Input.get_connected_gamepads()
 65        pad = connected[0] if connected else 0
 66
 67        renderer.draw_text("Gamepad Tester", (20, 14), colour=BRIGHT, scale=2)
 68        if not connected:
 69            renderer.draw_text(
 70                "no gamepad connected (plug one in; this line clears itself)",
 71                (WIDTH / 2, 48), colour=WARN, alignment="centre",
 72            )  # fmt: skip
 73        else:
 74            ids = ", ".join(str(p) for p in connected)
 75            status = f"pad {pad} active   connected ids: {ids}"
 76            renderer.draw_text(status, (WIDTH / 2, 48), colour=ACTIVE, alignment="centre")
 77
 78        self._draw_stick(renderer, pad, "left", (300, 205))
 79        self._draw_stick(renderer, pad, "right", (660, 205))
 80        self._draw_trigger(renderer, pad, "lt", 62)
 81        self._draw_trigger(renderer, pad, "rt", 866)
 82        self._draw_buttons(renderer, pad)
 83
 84        renderer.draw_text("ESC: quit", (20, HEIGHT - 24), colour=DIM)
 85
 86    def _draw_stick(self, renderer, pad: int, stick: str, centre):
 87        cx, cy = centre
 88        r = 78.0
 89        v = Input.get_gamepad_vector(pad, stick)  # deadzoned Vec2, each axis -1..1
 90
 91        renderer.draw_circle((cx, cy), r, colour=DIM, segments=48)
 92        renderer.draw_circle((cx, cy), r * 0.15, colour=DIM, segments=24)  # the deadzone
 93        renderer.draw_line((cx - r, cy), (cx + r, cy), colour=DIM)
 94        renderer.draw_line((cx, cy - r), (cx, cy + r), colour=DIM)
 95
 96        # Crosshair marker at the deflection; stick up (y = -1) is up on screen.
 97        mx, my = cx + v.x * r, cy + v.y * r
 98        live = v.length() > 0.0
 99        colour = MARKER if live else BRIGHT
100        renderer.draw_line((cx, cy), (mx, my), colour=colour, thickness=2.0)
101        renderer.draw_circle((mx, my), 7, colour=colour, filled=True, segments=20)
102
103        renderer.draw_text(f"{stick} stick", (cx, cy + r + 14), colour=BRIGHT, alignment="centre")
104        renderer.draw_text(f"({v.x:+.2f}, {v.y:+.2f})", (cx, cy + r + 32), colour=DIM, alignment="centre")
105
106    def _draw_trigger(self, renderer, pad: int, name: str, x: float):
107        top, height, width = 130, 170, 32
108        # A trigger is 0 released and 1 fully pulled on every backend, so the
109        # reading is the fill fraction with no remapping.
110        value = Input.get_gamepad_axis(pad, name)
111
112        renderer.draw_rect((x, top), (width, height), colour=DIM)
113        if value > 0.0:
114            h = height * value
115            renderer.draw_rect((x, top + height - h), (width, h), colour=ACTIVE, filled=True)
116        renderer.draw_text(name, (x + width / 2, top - 22), colour=BRIGHT, alignment="centre")
117        renderer.draw_text(f"{value:.2f}", (x + width / 2, top + height + 10), colour=DIM, alignment="centre")
118
119    def _draw_buttons(self, renderer, pad: int):
120        rows = (BUTTONS[:8], BUTTONS[8:])
121        for row, y in zip(rows, (400, 468), strict=True):
122            spacing = (WIDTH - 160) / (len(row) - 1)
123            for i, name in enumerate(row):
124                x = 80 + i * spacing
125                if Input.is_gamepad_pressed(pad, name):
126                    renderer.draw_circle((x, y), 12, colour=ACTIVE, filled=True, segments=24)
127                else:
128                    renderer.draw_circle((x, y), 12, colour=DIM, segments=24)
129                renderer.draw_text(name, (x, y + 18), colour=DIM, alignment="centre")
130
131
132def _selftest() -> bool:
133    """Headless: drive the per-pad state and check every reading the demo draws.
134
135    ``InputSimulator.set_gamepad`` publishes one pad's whole snapshot, which is
136    the same thing a platform adapter does once per pad per frame. Everything
137    is then read back through the public polling API the demo itself uses.
138    """
139    from simvx.core.testing import InputSimulator
140
141    ok = True
142
143    def check(label: str, passed: bool, detail: str) -> None:
144        nonlocal ok
145        ok = ok and passed
146        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
147
148    sim = InputSimulator()
149
150    # Zero pads connected: nothing is listed and every getter reads neutral.
151    v = Input.get_gamepad_vector(0, "left")
152    check(
153        "zero pads read neutral",
154        v.length() == 0.0 and not Input.is_gamepad_pressed(0, "a") and Input.get_gamepad_axis(0, "rt") == 0.0,
155        f"vector {v}, a=False, rt=0.0",
156    )
157    check("no pad listed with none connected", Input.get_connected_gamepads() == [], "connected=[]")
158
159    # A pad appears: X held, left stick deflected, left trigger at rest.
160    sim.set_gamepad(0, buttons={"x": True}, axes={"left_x": 0.6, "left_y": -0.3, "lt": 0.0, "rt": 0.4})
161    check("the pad is listed once published", Input.get_connected_gamepads() == [0], "connected=[0]")
162    check(
163        "is_gamepad_pressed sees exactly the held button",
164        Input.is_gamepad_pressed(0, "x") and not Input.is_gamepad_pressed(0, "a"),
165        "x held, a not",
166    )
167    v = Input.get_gamepad_vector(0, "left")
168    check("left stick passes the deadzone", abs(v.x - 0.6) < 1e-6 and abs(v.y + 0.3) < 1e-6, f"{v}")
169    sim.set_gamepad(0, axes={"right_x": 0.05, "right_y": 0.05})
170    r = Input.get_gamepad_vector(0, "right")
171    check("small right deflection is deadzoned to zero", r.length() == 0.0, f"{r}")
172    check(
173        "a resting trigger is 0 and a pulled one is its fraction",
174        Input.get_gamepad_axis(0, "lt") == 0.0 and abs(Input.get_gamepad_axis(0, "rt") - 0.4) < 1e-6,
175        f"lt={Input.get_gamepad_axis(0, 'lt')}, rt={Input.get_gamepad_axis(0, 'rt')}",
176    )
177
178    # A second pad appears; both are listed, lowest id first.
179    sim.set_gamepad(1, buttons={"start": True})
180    check("a second pad joins the connected list", Input.get_connected_gamepads() == [0, 1], "connected=[0, 1]")
181
182    # A pad whose every reading is neutral is still connected: presence is
183    # reported by the poll, not inferred from whether anything moved.
184    sim.set_gamepad(2)
185    check("an all-neutral pad is still connected", Input.get_connected_gamepads() == [0, 1, 2], "connected=[0, 1, 2]")
186
187    print("SELFTEST:", "PASS" if ok else "FAIL")
188    return ok
189
190
191if __name__ == "__main__":
192    import sys
193
194    if "--test" in sys.argv:
195        sys.exit(0 if _selftest() else 1)
196    from simvx.graphics import App
197
198    App(title="Gamepad Tester", width=WIDTH, height=HEIGHT).run(GamepadTester())