Controller tester¶

one ControllerDevice per pad

â–¶ Run in browser

Tags: 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 Controller per 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, source and family.

  • TouchController as a source of exactly that: an on-screen pad reads back through the same Controller object 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())