Gamepad tester¶
direct polling with string names
▶ Run in browserTags: 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())