Controller tester¶
one ControllerDevice per pad
â–¶ Run in browserTags: input controller touch polling
Every controller input a pad reports, drawn live: both sticks as a crosshair in a
circle (pad.stick), the analogue triggers as fill bars (pad.axis), and all
fifteen buttons as labelled dots that light while held (pad.pressed). The
pads themselves come from Input.controllers, so plugging one in and unplugging
it again are both visible while the demo runs.
A TouchController is drawn along the bottom, and it is a pad like any other: it
appears in Input.controllers under ControllerSource.VIRTUAL, so the readout above
has something to show with no controller in the room. It leaves the screen the
moment a real one is used, and the readout follows whichever pad is there.
What it demonstrates¶
Input.controllers: the pads readable this frame, keyed by id. It empties the moment a pad is unplugged.One
Controllerper pad, so a two-player game reads each pad separately:pad.stick(Controller.LEFT_STICK),pad.axis(Controller.LEFT_TRIGGER),pad.pressed(Controller.A).What a pad says about itself:
name,sourceandfamily.TouchControlleras a source of exactly that: an on-screen pad reads back through the sameControllerobject a controller does.The engine’s axis convention, identical on every backend: sticks -1..1 centred at 0, triggers 0..1 resting at 0.
The radial deadzone
stick()applies (a small circle marks it).
Controls: ControllerDevice - everything on it, mirrored on screen Drawn pad - the same, from two thumbs ESC - Quit
Run: simvx examples run features_input_controller Headless self-check: simvx examples run features_input_controller –test
Source¶
1# /// simvx
2# tags = ["input", "controller", "touch", "polling"]
3# web = { width = 960, height = 540, responsive = true }
4# ///
5"""Controller tester: one ControllerDevice per pad
6
7Every controller input a pad reports, drawn live: both sticks as a crosshair in a
8circle (`pad.stick`), the analogue triggers as fill bars (`pad.axis`), and all
9fifteen buttons as labelled dots that light while held (`pad.pressed`). The
10pads themselves come from `Input.controllers`, so plugging one in and unplugging
11it again are both visible while the demo runs.
12
13A `TouchController` is drawn along the bottom, and it is a pad like any other: it
14appears in `Input.controllers` under `ControllerSource.VIRTUAL`, so the readout above
15has something to show with no controller in the room. It leaves the screen the
16moment a real one is used, and the readout follows whichever pad is there.
17
18## What it demonstrates
19- `Input.controllers`: the pads readable this frame, keyed by id. It empties the
20 moment a pad is unplugged.
21- One `Controller` per pad, so a two-player game reads each pad separately:
22 `pad.stick(Controller.LEFT_STICK)`, `pad.axis(Controller.LEFT_TRIGGER)`, `pad.pressed(Controller.A)`.
23- What a pad says about itself: `name`, `source` and `family`.
24- `TouchController` as a source of exactly that: an on-screen pad reads back
25 through the same `Controller` object a controller does.
26- The engine's axis convention, identical on every backend: sticks -1..1
27 centred at 0, triggers 0..1 resting at 0.
28- The radial deadzone `stick()` applies (a small circle marks it).
29
30Controls:
31 ControllerDevice - everything on it, mirrored on screen
32 Drawn pad - the same, from two thumbs
33 ESC - Quit
34
35Run: simvx examples run features_input_controller
36Headless self-check: simvx examples run features_input_controller --test
37"""
38
39from simvx.core import Controller, DeviceAxis, DeviceButton, DeviceStick, Input, Key, Node2D
40from simvx.core.ui import TouchController, TouchControllerLayout
41
42#: The size the window opens at. Every position below is computed from the
43#: live ``viewport_size`` instead.
44WINDOW_W, WINDOW_H = 960, 540
45
46#: Every button on a standard controller, in the order this readout draws them.
47BUTTONS = (
48 Controller.A, Controller.B, Controller.X, Controller.Y,
49 Controller.LEFT_BUMPER, Controller.RIGHT_BUMPER, Controller.LEFT_THUMB, Controller.RIGHT_THUMB,
50 Controller.BACK, Controller.START, Controller.GUIDE,
51 Controller.DPAD_UP, Controller.DPAD_DOWN, Controller.DPAD_LEFT, Controller.DPAD_RIGHT,
52) # fmt: skip
53
54#: The fraction of the window the readout keeps for itself, leaving the rest
55#: to the drawn pad along the bottom.
56READOUT_BAND = 0.66
57
58DIM = (0.42, 0.45, 0.52, 1.0)
59BRIGHT = (0.90, 0.93, 1.00, 1.0)
60ACTIVE = (0.30, 0.85, 0.45, 1.0)
61MARKER = (1.00, 0.75, 0.20, 1.0)
62WARN = (1.00, 0.65, 0.25, 1.0)
63
64
65def _short(button: DeviceButton) -> str:
66 """The button's name in lower case, which is what fits under a 12px dot."""
67 return button.name.lower()
68
69
70class ControllerTester(Node2D):
71 """Live readout of one controller, read through its ControllerDevice every frame."""
72
73 dynamic = True # every reading can change every frame; redraw always
74
75 input_context = {"quit": [Key.ESCAPE]}
76
77 def on_ready(self):
78 # An on-screen pad is a pad: it publishes both sticks and Start under a
79 # virtual id, so everything below reads it with no special case. It is
80 # a CanvasLayer itself, so it needs no wrapper. No face buttons: two
81 # sticks and Start leave the readout above them room to breathe, and
82 # Start is enough to light a button dot.
83 self.add_child(
84 TouchController(layout=TouchControllerLayout.TWIN_STICK, buttons=(), name="TouchPad"),
85 )
86
87 def on_update(self, dt: float):
88 if Input.just_pressed("quit"):
89 self.app.quit()
90
91 # ---------------------------------------------------------------- drawing
92
93 def on_draw(self, renderer):
94 width, height = self.viewport_size
95 pads = Input.controllers
96 pad = pads[min(pads)] if pads else None
97
98 renderer.text("Controller Tester", (20, 14), fill=BRIGHT, scale=2)
99 if pad is None:
100 renderer.text(
101 "no controller connected (plug one in; this line clears itself)",
102 (width / 2, 48),
103 fill=WARN,
104 align="centre",
105 )
106 else:
107 ids = ", ".join(str(p) for p in sorted(pads))
108 status = f"pad {pad.id} ({pad.name or 'unnamed'}, {pad.family}, {pad.source}) connected ids: {ids}"
109 renderer.text(status, (width / 2, 48), fill=ACTIVE, align="centre")
110
111 # The readout lives above the band the drawn pad occupies.
112 top, bottom = 78.0, height * READOUT_BAND
113 radius = min(78.0, max(30.0, (bottom - top) * 0.26))
114 stick_y = top + radius + 16
115 self._draw_stick(renderer, pad, Controller.LEFT_STICK, "left", (width * 0.3, stick_y), radius)
116 self._draw_stick(renderer, pad, Controller.RIGHT_STICK, "right", (width * 0.7, stick_y), radius)
117 self._draw_trigger(renderer, pad, Controller.LEFT_TRIGGER, "lt", width * 0.045, top + 28, radius * 2)
118 self._draw_trigger(renderer, pad, Controller.RIGHT_TRIGGER, "rt", width * 0.92, top + 28, radius * 2)
119 self._draw_buttons(renderer, pad, width, stick_y + radius + 50)
120
121 renderer.text("ESC: quit", (20, top - 22), fill=DIM)
122
123 def _draw_stick(self, renderer, pad, stick: DeviceStick, side: str, centre, r: float):
124 from simvx.core.math import Vec2
125
126 cx, cy = centre
127 v = pad.stick(stick) if pad is not None else Vec2(0.0, 0.0) # deadzoned, each axis -1..1
128
129 renderer.circle((cx, cy), r, stroke=DIM, segments=48)
130 renderer.circle((cx, cy), r * 0.15, stroke=DIM, segments=24) # the deadzone
131 renderer.line((cx - r, cy), (cx + r, cy), stroke=DIM)
132 renderer.line((cx, cy - r), (cx, cy + r), stroke=DIM)
133
134 # Crosshair marker at the deflection; stick up (y = -1) is up on screen.
135 mx, my = cx + v.x * r, cy + v.y * r
136 live = v.length() > 0.0
137 colour = MARKER if live else BRIGHT
138 renderer.line((cx, cy), (mx, my), stroke=colour, width=2.0)
139 renderer.circle((mx, my), 7, fill=colour, segments=20)
140
141 renderer.text(f"{side} stick", (cx, cy + r + 14), fill=BRIGHT, align="centre")
142 renderer.text(f"({v.x:+.2f}, {v.y:+.2f})", (cx, cy + r + 32), fill=DIM, align="centre")
143
144 def _draw_trigger(self, renderer, pad, axis: DeviceAxis, label: str, x: float, top: float, height: float):
145 width = 32.0
146 # A trigger is 0 released and 1 fully pulled on every backend, so the
147 # reading is the fill fraction with no remapping.
148 value = pad.axis(axis) if pad is not None else 0.0
149
150 renderer.rect((x, top), (width, height), stroke=DIM)
151 if value > 0.0:
152 h = height * value
153 renderer.rect((x, top + height - h), (width, h), fill=ACTIVE)
154 renderer.text(label, (x + width / 2, top - 22), fill=BRIGHT, align="centre")
155 renderer.text(f"{value:.2f}", (x + width / 2, top + height + 10), fill=DIM, align="centre")
156
157 def _draw_buttons(self, renderer, pad, width: float, top: float):
158 rows = (BUTTONS[:8], BUTTONS[8:])
159 margin = width * 0.08
160 for row, y in zip(rows, (top, top + 46), strict=True):
161 spacing = (width - 2 * margin) / (len(row) - 1)
162 for i, button in enumerate(row):
163 x = margin + i * spacing
164 if pad is not None and pad.pressed(button):
165 renderer.circle((x, y), 12, fill=ACTIVE, segments=24)
166 else:
167 renderer.circle((x, y), 12, stroke=DIM, segments=24)
168 renderer.text(_short(button), (x, y + 18), fill=DIM, align="centre")
169
170
171def _selftest() -> bool:
172 """Headless: drive the per-pad state and check every reading the demo draws.
173
174 ``InputSimulator.set_controller`` publishes one pad's whole snapshot, which is
175 the same thing a platform adapter does once per pad per frame. Everything
176 is then read back through ``Input.controllers``, exactly as the demo does. The
177 drawn pad is pressed by pointer, which is the route a thumb takes, and read
178 back through the same ``Controller`` object.
179 """
180 from simvx.core import ControllerSource, Node, SceneTree
181 from simvx.core.testing import InputSimulator
182 from simvx.graphics import App
183 from simvx.graphics.testing import assert_not_blank, save_png
184
185 ok = True
186
187 def check(label: str, passed: bool, detail: str) -> None:
188 nonlocal ok
189 ok = ok and passed
190 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
191
192 # The pads are the machine's, so any tree reads them; this one exists to be driven.
193 probe = SceneTree()
194 probe.set_root(Node(name="PadProbe"))
195 sim = InputSimulator(probe)
196
197 # Zero pads connected: nothing is listed, so the demo draws its neutral frame.
198 check("no pad listed with none connected", len(Input.controllers) == 0, "controllers={}")
199
200 # A pad appears: X held, left stick deflected, left trigger at rest.
201 pad = sim.set_controller(0, buttons={"x": True}, axes={"left_x": 0.6, "left_y": -0.3, "lt": 0.0, "rt": 0.4})
202 check(
203 "the pad is listed once published",
204 list(Input.controllers.values()) == [pad],
205 f"controllers={dict(Input.controllers)}",
206 )
207 check(
208 "pressed sees exactly the held button",
209 pad.pressed(Controller.X) and not pad.pressed(Controller.A),
210 "x held, a not",
211 )
212 v = pad.stick(Controller.LEFT_STICK)
213 check("left stick passes the deadzone", abs(v.x - 0.6) < 1e-6 and abs(v.y + 0.3) < 1e-6, f"{v}")
214 sim.set_controller(0, axes={"right_x": 0.05, "right_y": 0.05})
215 r = pad.stick(Controller.RIGHT_STICK)
216 check("small right deflection is deadzoned to zero", r.length() == 0.0, f"{r}")
217 check(
218 "a resting trigger is 0 and a pulled one is its fraction",
219 pad.axis(Controller.LEFT_TRIGGER) == 0.0 and abs(pad.axis(Controller.RIGHT_TRIGGER) - 0.4) < 1e-6,
220 f"lt={pad.axis(Controller.LEFT_TRIGGER)}, rt={pad.axis(Controller.RIGHT_TRIGGER)}",
221 )
222
223 # A second pad appears; both are listed, and each answers for itself.
224 second = sim.set_controller(1, buttons={"start": True})
225 check("a second pad joins the mapping", list(Input.controllers.values()) == [pad, second], "two pads")
226 check(
227 "each pad answers about itself",
228 second.pressed(Controller.START) and not pad.pressed(Controller.START),
229 "start on the second pad only",
230 )
231
232 # A pad whose every reading is neutral is still connected: presence is
233 # reported by the poll, not inferred from whether anything moved.
234 sim.set_controller(2)
235 check("an all-neutral pad is still connected", len(Input.controllers) == 3, f"{len(Input.controllers)} pads")
236
237 # The drawn pad, in the running scene: the readout has a pad to show with
238 # no controller in the room, and a thumb on it reads back as stick travel
239 # and a held button through the very same ControllerDevice.
240 sim.reset()
241 app = App(title="Controller Tester", width=WINDOW_W, height=WINDOW_H, visible=False)
242 scene = ControllerTester(name="ControllerTester")
243 live: InputSimulator | None = None # built on the first frame, once the scene is in its tree
244 seen: dict[str, object] = {}
245
246 def drawn(name: str):
247 pad_node = next(c for c in scene.children if isinstance(c, TouchController))
248 return next(c for c in pad_node.children if c.name == name)
249
250 def centre_of(control) -> tuple[float, float]:
251 origin = control.world_position
252 return (float(origin.x) + float(control.size.x) / 2, float(origin.y) + float(control.size.y) / 2)
253
254 def on_frame(idx: int, _t: float) -> bool:
255 nonlocal live
256 if live is None:
257 live = InputSimulator(scene.tree)
258 virtual = next(c for c in scene.children if isinstance(c, TouchController)).controller
259 if idx == 1:
260 seen["listed"] = virtual is not None
261 seen["source"] = virtual.source if virtual is not None else None
262 seen["name"] = virtual.name if virtual is not None else ""
263 elif idx == 4:
264 stick = drawn("RightStick")
265 cx, cy = centre_of(stick)
266 seen["thumb_at"] = (cx, cy - stick.radius * 0.9)
267 live.touch_down(0, (cx, cy))
268 live.touch_move(0, seen["thumb_at"])
269 elif idx == 6:
270 seen["thumb_stick"] = virtual.stick(Controller.RIGHT_STICK)
271 live.touch_up(0, seen["thumb_at"])
272 elif idx == 9:
273 live.touch_down(1, centre_of(drawn("StartButton")))
274 elif idx == 11:
275 seen["thumb_button"] = (virtual.pressed(Controller.START), virtual.pressed(Controller.A))
276 live.touch_up(1, centre_of(drawn("StartButton")))
277 elif idx == 14:
278 seen["released"] = virtual.pressed(Controller.START)
279 seen["rest"] = virtual.stick(Controller.RIGHT_STICK).length()
280 return True
281
282 frames = app.run_headless(scene, frames=18, on_frame=on_frame, capture_frames=[10])
283 assert_not_blank(frames[0])
284 save_png(frames[0], "/tmp/controller_tester_test.png")
285
286 check(
287 "the drawn pad is listed as a virtual controller",
288 seen["listed"] and seen["source"] is ControllerSource.VIRTUAL,
289 f"{seen['name']!r}, source {seen['source']}",
290 )
291 thumb = seen["thumb_stick"]
292 check(
293 "a thumb on the drawn right stick reads as stick travel",
294 thumb.y < -0.5 and abs(thumb.x) < 0.3,
295 f"stick(Controller.RIGHT_STICK) = ({thumb.x:+.2f}, {thumb.y:+.2f})",
296 )
297 check(
298 "a thumb on the drawn Start reads as that button and no other",
299 seen["thumb_button"] == (True, False),
300 f"start held: {seen['thumb_button'][0]}, a held: {seen['thumb_button'][1]}",
301 )
302 check(
303 "lifting the thumb releases the button and recentres the stick",
304 not seen["released"] and seen["rest"] == 0.0,
305 f"start held: {seen['released']}, right stick magnitude {seen['rest']:.2f}",
306 )
307
308 print("screenshot: /tmp/controller_tester_test.png")
309 print("SELFTEST:", "PASS" if ok else "FAIL")
310 return ok
311
312
313if __name__ == "__main__":
314 import sys
315
316 if "--test" in sys.argv:
317 sys.exit(0 if _selftest() else 1)
318 from simvx.graphics import App
319
320 App(title="Controller Tester", width=WINDOW_W, height=WINDOW_H).run(ControllerTester())