Standard Controls

move, jump, shoot at the cursor, and rebind any of it.

▶ Run in browser

Tags: input actions rebinding keyboard mouse controller touch

The controls nearly every game has, written the way SimVX spells them: a movement action on WASD or the left stick, jump on Space or pad A, shoot on a left click (at the cursor) or pad X, and a rebind screen with two tables: the pad’s controls (which physical button Controller.A is) and the actions (which names jump answers to). Each cell listens for the next control the player presses and binds that; its listener claims the press, so choosing Space for jump does not also jump.

What it demonstrates

  • Actions in input_context typed by their bindings: Axis2D.WASD and Controller.LEFT_STICK make move a Vec2, buttons make jump and shoot a bool.

  • @on_input("jump") fires once per press; a handler hears a release only when it asks with edge="release".

  • InputListener: a cell adds one and binds what it hears in one line, InputMap.rebind(target, heard). Cancelling is the cell’s own choice (Esc, or whatever the player has made pad Back).

  • Two tables, one map. A device row rebinds an engine entry, InputMap.rebind(Controller.A, heard), and every action using Controller.A follows; InputMap.swap turns a pad with A and B the wrong way round. An action row binds the press by the engine entry the map has for it, so it follows the device table, a chord with what was held for it, and the raw control when the player has left nothing naming it.

  • Mapping an unrecognised controller. A flight stick no controller database knows has only numbered controls. “Map a controller” walks the standard names in turn with the same one line; a press on an unrecognised controller maps the name on that device alone, so the game’s actions, which name Controller.A, play on it, and the mapping is saved with the rest of the player’s rebinds, filed under the device.

  • Input.label printing a control, raw or named, the way the hardware counts or names it.

  • InputContext(hides=InputContext.ALL) on the rebind screen, so the game stands still while it is open.

  • “Reset to defaults” calls InputMap.clear_rebinds(): the player’s changes are one override layer over the bindings the game declared, so dropping it is the whole of a reset, device mappings included.

Controls: WASD / left stick / drawn stick - Move SPACE / pad A - Jump Left click / pad X - Shoot at the cursor TAB / pad Start / Controls - Open the rebind screen (Esc, Tab or Start closes it) ESC / pad Back - Cancel a rebind, or close the screen

Run: simvx examples run features_input_standard_controls Headless self-check: simvx examples run features_input_standard_controls –test

Source

  1# /// simvx
  2# tags = ["input", "actions", "rebinding", "keyboard", "mouse", "controller", "touch"]
  3# web = { width = 960, height = 540, responsive = true }
  4# ///
  5"""Standard Controls: move, jump, shoot at the cursor, and rebind any of it.
  6
  7The controls nearly every game has, written the way SimVX spells them: a
  8movement action on WASD or the left stick, jump on Space or pad A, shoot on a
  9left click (at the cursor) or pad X, and a rebind screen with two tables:
 10the pad's controls (which physical button ``Controller.A`` is) and the actions
 11(which names ``jump`` answers to). Each cell listens for the next control the
 12player presses and binds that; its listener claims the press, so choosing
 13Space for jump does not also jump.
 14
 15## What it demonstrates
 16- Actions in ``input_context`` typed by their bindings: ``Axis2D.WASD`` and
 17  ``Controller.LEFT_STICK`` make ``move`` a ``Vec2``, buttons make ``jump`` and
 18  ``shoot`` a bool.
 19- ``@on_input("jump")`` fires once per press; a handler hears a release only
 20  when it asks with ``edge="release"``.
 21- ``InputListener``: a cell adds one and binds what it hears in one line,
 22  ``InputMap.rebind(target, heard)``. Cancelling is the cell's own choice
 23  (Esc, or whatever the player has made pad Back).
 24- **Two tables, one map.** A device row rebinds an engine entry,
 25  ``InputMap.rebind(Controller.A, heard)``, and every action using
 26  ``Controller.A`` follows; ``InputMap.swap`` turns a pad with A and B the wrong
 27  way round. An action row binds the press by the engine entry the map has
 28  for it, so it follows the device table, a chord with what was held for it,
 29  and the raw control when the player has left nothing naming it.
 30- **Mapping an unrecognised controller.** A flight stick no controller
 31  database knows has only numbered controls. "Map a controller" walks the
 32  standard names in turn with the same one line; a press on an unrecognised
 33  controller maps the name on that device alone, so the game's actions, which
 34  name ``Controller.A``, play on it, and the mapping is saved with the rest of
 35  the player's rebinds, filed under the device.
 36- ``Input.label`` printing a control, raw or named, the way the hardware
 37  counts or names it.
 38- ``InputContext(hides=InputContext.ALL)`` on the rebind screen, so the game
 39  stands still while it is open.
 40- "Reset to defaults" calls ``InputMap.clear_rebinds()``: the player's changes
 41  are one override layer over the bindings the game declared, so dropping it
 42  is the whole of a reset, device mappings included.
 43
 44Controls:
 45  WASD / left stick / drawn stick  - Move
 46  SPACE / pad A                    - Jump
 47  Left click / pad X               - Shoot at the cursor
 48  TAB / pad Start / Controls       - Open the rebind screen (Esc, Tab or Start closes it)
 49  ESC / pad Back                   - Cancel a rebind, or close the screen
 50
 51Run: simvx examples run features_input_standard_controls
 52Headless self-check: simvx examples run features_input_standard_controls --test
 53"""
 54
 55from simvx.core import (
 56    Anchor,
 57    Axis2D,
 58    BindingRole,
 59    CanvasLayer,
 60    Colour,
 61    ControlInput,
 62    Controller,
 63    DeviceType,
 64    Input,
 65    InputContext,
 66    InputListener,
 67    InputMap,
 68    Key,
 69    Label,
 70    Mouse,
 71    Node2D,
 72    Panel,
 73    Vec2,
 74    on_input,
 75)
 76from simvx.core.ui import Button, HBoxContainer, TouchController, VBoxContainer
 77from simvx.graphics import App
 78
 79SPEED = 240.0
 80JUMP_SPEED = 420.0
 81GRAVITY = 1400.0
 82BULLET_SPEED = 600.0
 83
 84#: What each column of the rebind screen listens to: the keyboard and mouse
 85#: column, and the pad column. A source's slot is its position in the
 86#: action's list below, so the columns line up with it.
 87DESK = (DeviceType.KEYBOARD, DeviceType.MOUSE)
 88PAD = (DeviceType.CONTROLLER,)
 89
 90#: The pad's engine entries the device table shows: the ones this game binds,
 91#: and Back, which cancels a rebind.
 92PAD_ENTRIES = (Controller.A, Controller.B, Controller.X, Controller.Y, Controller.START, Controller.BACK)
 93
 94#: What mapping a controller walks through: the buttons above, then the stick
 95#: the game moves with, one axis at a time.
 96MAPPED_NAMES = (*PAD_ENTRIES, Controller.LEFT_X, Controller.LEFT_Y)
 97
 98
 99def cancels(raw) -> bool:
100    """Whether the press *raw* cancels a rebind: Esc, or whatever the pad's Back is now."""
101    return raw is Key.ESCAPE or raw == InputMap.resolve(Controller.BACK)
102
103
104class Player(Node2D):
105    """A disc that walks on the ground plane and hops off it."""
106
107    dynamic = True
108
109    def __init__(self, **kwargs):
110        super().__init__(**kwargs)
111        self.height = 0.0  # how far above its shadow the disc is
112        self.lift = 0.0
113
114    def jump(self) -> None:
115        if self.height == 0.0:
116            self.lift = JUMP_SPEED
117
118    def on_fixed_update(self, dt: float) -> None:
119        if self.height > 0.0 or self.lift > 0.0:
120            self.height = max(0.0, self.height + self.lift * dt)
121            self.lift -= GRAVITY * dt
122            if self.height == 0.0:
123                self.lift = 0.0
124
125    def on_draw(self, renderer) -> None:
126        renderer.circle((0, 0), 18, fill=(1.0, 1.0, 1.0, 0.12))  # the shadow
127        renderer.circle((0, -self.height), 18, fill=(0.4, 0.8, 1.0, 1.0))
128
129
130class Bullet(Node2D):
131    """A shot flying in a straight line until it leaves the screen."""
132
133    dynamic = True
134
135    def __init__(self, velocity: Vec2, **kwargs):
136        super().__init__(**kwargs)
137        self.velocity = velocity
138
139    def on_update(self, dt: float) -> None:
140        self.position += self.velocity * dt
141        width, height = self.viewport_size
142        if not (-20 <= self.position.x <= width + 20 and -20 <= self.position.y <= height + 20):
143            self.destroy()
144
145    def on_draw(self, renderer) -> None:
146        renderer.circle((0, 0), 5, fill=(1.0, 0.8, 0.3, 1.0))
147
148
149def bound(action: str, slot: int, role: BindingRole):
150    """What the player sees bound in one row: the whole source, or one direction of it."""
151    source = InputMap.get_sources(action)[slot]
152    return source if role is BindingRole.WHOLE else getattr(source, role.value)
153
154
155class RebindButton(Button):
156    """One cell of the rebind screen: press it, then press the control it should be.
157
158    *target* is an action's name, whose *slot* and *role* the cell rebinds, or
159    an engine entry such as ``Controller.A``, which the cell points at a raw
160    control.
161    """
162
163    def __init__(self, target, devices, *, slot: int = 0, role=BindingRole.WHOLE, **kwargs):
164        super().__init__(**kwargs)
165        self.target, self.devices, self.slot, self.role = target, devices, slot, BindingRole(role)
166        self.listener: InputListener | None = None
167        self.pressed.connect(self.listen)
168
169    def on_ready(self) -> None:
170        self.refresh()
171
172    def refresh(self) -> None:
173        if self.listener is not None:
174            self.text = "press..."
175        elif isinstance(self.target, str):
176            self.text = Input.label(bound(self.target, self.slot, self.role))
177        else:
178            raw = InputMap.resolve(self.target)
179            self.text = Input.label(raw) if raw is not None else "unbound"
180
181    def listen(self) -> None:
182        if self.listener is not None:  # a second click cancels
183            self.listener.destroy()
184            self.listener = None
185        else:
186            self.listener = self.add_child(InputListener(devices=self.devices))
187            self.listener.heard.connect(self.captured)
188        self.refresh()
189
190    def captured(self, heard: ControlInput) -> None:
191        self.listener = None
192        if not cancels(heard.control):
193            InputMap.rebind(self.target, heard, slot=self.slot, role=self.role)
194        self.screen.refresh()
195
196    @property
197    def screen(self) -> RebindScreen:
198        node = self.parent
199        while not isinstance(node, RebindScreen):
200            node = node.parent
201        return node
202
203
204class MappingWalk(Node2D):
205    """Map a controller: ask for each standard name in turn and map the press onto that device.
206
207    A mapping screen for a controller no database recognised, a flight stick
208    say, walks every name the game needs; the listener hands back the raw
209    control, and pinning it to the device the press came from maps it there
210    alone. Esc stops the walk, keeping what was mapped so far.
211    """
212
213    def __init__(self, status: Label, **kwargs):
214        super().__init__(**kwargs)
215        self.status = status
216        self.remaining = list(MAPPED_NAMES)
217        self.mapped: dict[object, object] = {}
218
219    def on_ready(self) -> None:
220        self.ask()
221
222    def ask(self) -> None:
223        if not self.remaining:
224            self.status.text = f"Mapped {len(self.mapped)} controls"
225            self.destroy()
226            return
227        name = self.remaining[0]
228        verb = "Push the stick along" if name in (Controller.LEFT_X, Controller.LEFT_Y) else "Press"
229        self.status.text = f"{verb} {Input.label(name)} on the controller to map (Esc stops)"
230        listener = self.add_child(InputListener(devices=(DeviceType.KEYBOARD, *PAD)))
231        listener.heard.connect(self.heard)
232
233    def heard(self, heard: ControlInput) -> None:
234        if heard.control is Key.ESCAPE:
235            self.status.text = f"Stopped; mapped {len(self.mapped)} controls"
236            self.destroy()
237            return
238        if heard.device_id not in Input.controllers:
239            self.ask()  # a key that is not Esc: ask again
240            return
241        name = self.remaining.pop(0)
242        self.mapped[name] = InputMap.rebind(name, heard).binding
243        self.ask()
244
245
246class RebindScreen(CanvasLayer):
247    """The pad's controls and the actions' bindings, one cell per thing a player can rebind."""
248
249    #: Everything the game binds is hidden while the screen is open; "close" is its own.
250    input_context = InputContext(
251        {"close": [Key.TAB, Key.ESCAPE, Controller.START, Controller.BACK]},
252        name="rebind screen",
253        hides=InputContext.ALL,
254    )
255
256    def __init__(self, drawn_pad: TouchController | None = None, **kwargs):
257        super().__init__(**kwargs)
258        #: The on-screen pad, put away while the screen is open so it neither
259        #: covers the tables nor takes a click meant for them.
260        self.drawn_pad = drawn_pad
261
262    def on_exit_tree(self) -> None:
263        if self.drawn_pad is not None:
264            self.drawn_pad.visible = True
265
266    ACTION_ROWS = (
267        ("Jump", "jump", BindingRole.WHOLE),
268        ("Shoot", "shoot", BindingRole.WHOLE),
269        ("Move up", "move", BindingRole.UP),
270        ("Move down", "move", BindingRole.DOWN),
271        ("Move left", "move", BindingRole.LEFT),
272        ("Move right", "move", BindingRole.RIGHT),
273    )
274
275    def on_ready(self) -> None:
276        if self.drawn_pad is not None:
277            self.drawn_pad.visible = False
278        panel = self.add_child(Panel(name="Panel", anchor=Anchor.FULL_RECT, inset=24))
279        panel.bg_colour = Colour.hex("#1A1A2E")
280        page = panel.add_child(VBoxContainer(name="Page", anchor=Anchor.FULL_RECT, inset=(18, 12)))
281        page.separation = 8.0
282        page.add_child(Label("Pick a cell, then press the control it should be (Esc or Back cancels)"))
283        tables = page.add_child(HBoxContainer(name="Tables"))
284        tables.separation = 24.0
285        self.cells: dict[object, RebindButton] = {}
286
287        # The device table: which raw button each of the pad's controls is.
288        pad = tables.add_child(VBoxContainer(name="Pad"))
289        pad.separation = 6.0
290        pad.add_child(Label("Pad controls"))
291        for control in PAD_ENTRIES:
292            row = pad.add_child(HBoxContainer(name=f"Pad {control}"))
293            row.add_child(Label(Input.label(control))).size = Vec2(70, 28)
294            cell = row.add_child(RebindButton(control, PAD, name=f"entry {control}"))
295            cell.size = Vec2(130, 28)
296            self.cells[control] = cell
297        pad.add_child(Button("Swap A and B", on_press=self.swap_ab, name="Swap")).size = Vec2(200, 28)
298        pad.add_child(Button("Map a controller", on_press=self.map_controller, name="Map")).size = Vec2(200, 28)
299        self.status = page.add_child(Label("", name="Status"))
300        self.walk: MappingWalk | None = None
301
302        # The action table: which names each action answers to, keyboard and pad.
303        actions = tables.add_child(VBoxContainer(name="Actions"))
304        actions.separation = 6.0
305        actions.add_child(Label("Actions"))
306        for title, action, role in self.ACTION_ROWS:
307            row = actions.add_child(HBoxContainer(name=f"Row {title}"))
308            row.separation = 8.0
309            row.add_child(Label(title)).size = Vec2(110, 28)
310            for slot, devices in enumerate((DESK, PAD)):
311                if role is not BindingRole.WHOLE and slot == 1:
312                    # The pad half of move is a whole stick: it reads, it is not rebound here.
313                    row.add_child(Label(Input.label(bound(action, 1, BindingRole.WHOLE)))).size = Vec2(130, 28)
314                    continue
315                cell = row.add_child(
316                    RebindButton(action, devices, slot=slot, role=role, name=f"{action} {role} {slot}")
317                )
318                cell.size = Vec2(130, 28)
319                self.cells[(action, slot, role)] = cell
320
321        footer = page.add_child(HBoxContainer(name="Footer"))
322        footer.add_child(Button("Reset to defaults", on_press=self.reset, name="Reset")).size = Vec2(180, 30)
323        footer.add_child(Button("Close", on_press=self.destroy, name="Close")).size = Vec2(120, 30)
324
325    def refresh(self) -> None:
326        for cell in self.cells.values():
327            cell.refresh()
328
329    def map_controller(self) -> None:
330        if self.walk is None or self.walk.tree is None:
331            self.walk = self.add_child(MappingWalk(self.status, name="MappingWalk"))
332
333    def swap_ab(self) -> None:
334        InputMap.swap(Controller.A, Controller.B)
335        self.refresh()
336
337    def reset(self) -> None:
338        InputMap.clear_rebinds()
339        self.refresh()
340
341    @on_input("close")
342    def close(self, event) -> None:
343        self.destroy()
344
345
346class Game(Node2D):
347    """Walk, hop and shoot at the cursor; open the rebind screen with Tab."""
348
349    input_context = {
350        "move": [Axis2D.WASD, Controller.LEFT_STICK],
351        "jump": [Key.SPACE, Controller.A],
352        "shoot": [Mouse.LEFT, Controller.X],
353        "controls": [Key.TAB, Controller.START],
354    }
355
356    def on_ready(self) -> None:
357        width, height = self.viewport_size
358        self.player = self.add_child(Player(name="Player", position=(width / 2, height / 2)))
359        self.screen: RebindScreen | None = None
360        hud = self.add_child(CanvasLayer(name="HUD"))
361        hud.add_child(
362            Button(
363                "Controls",
364                on_press=self.open_controls,
365                name="Controls",
366                anchor=Anchor.TOP_RIGHT,
367                inset=12,
368                size=(128, 32),
369            )
370        )
371        self.drawn_pad = self.add_child(TouchController())
372
373    def on_update(self, dt: float) -> None:
374        self.player.position += Input.value("move") * SPEED * dt
375
376    @on_input("jump")
377    def jump(self, event) -> None:
378        self.player.jump()
379
380    @on_input("shoot")
381    def shoot(self, event) -> None:
382        aim = Input.mouse_position - self.player.position
383        if aim.length() > 0:
384            self.add_child(Bullet(aim.normalized() * BULLET_SPEED, position=self.player.position))
385
386    @on_input("controls")
387    def open_controls(self, event=None) -> None:
388        if self.screen is None or self.screen.tree is None:
389            self.screen = self.add_child(RebindScreen(self.drawn_pad, name="RebindScreen"))
390
391
392def _selftest() -> bool:
393    """Headless: play, rebind through the listener, and play on the new bindings.
394
395    Every input goes through the real router, as a window would deliver it:
396    keys and clicks through the simulator, the pad through a simulated device.
397    """
398    from simvx.core.testing import InputSimulator, SceneRunner
399
400    runner = SceneRunner(screen_size=(960, 540)).load(Game(name="Game"))
401    game, sim = runner.tree.root, InputSimulator(runner.tree)
402
403    def step(count: int = 1) -> None:
404        runner.advance_frames(count, draw=True)
405
406    imap = runner.tree.input_map  # outside the tree's own code, InputMap is the process-wide one
407    step()
408    ok = True
409
410    def check(label: str, passed: bool, detail: object = "") -> None:
411        nonlocal ok
412        ok = ok and passed
413        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
414
415    def hold(key, frames: int = 10) -> None:
416        sim.press_key(key)
417        step(frames)
418        sim.release_key(key)
419        step()
420
421    def jumped(action) -> bool:
422        """Whether *action* made the player leave the ground, letting the hop finish after."""
423        game.player.height = game.player.lift = 0.0
424        action()
425        step(2)
426        up = game.player.height > 0.0
427        step(60)
428        return up
429
430    def click(widget) -> None:
431        sim.click(tuple(widget.to_global(widget.size / 2)))
432        step()
433
434    def cell(target, slot: int = 0, role=BindingRole.WHOLE) -> RebindButton:
435        cells = game.screen.cells
436        return cells[target] if not isinstance(target, str) else cells[(target, slot, BindingRole(role))]
437
438    start = Vec2(game.player.position)
439    hold(Key.D)
440    check("D moves the player right", game.player.position.x > start.x + 20, game.player.position)
441    check("Space jumps", jumped(lambda: sim.tap_key(Key.SPACE)))
442    check("pad A jumps", jumped(lambda: sim.tap_controller(Controller.A)))
443
444    # A click where the drawn stick is: the pad answers fingers only, so the
445    # click is the game's, and the pad takes no focus from it.
446    stick = game.drawn_pad.find("LeftStick")
447    target = stick.to_global(stick.size / 2)
448    sim.click((float(target.x), float(target.y)))
449    step()
450    bullets = [c for c in game.children if isinstance(c, Bullet)]
451    aim = (Vec2(target) - game.player.position).normalized()
452    check(
453        "a click shoots at the cursor, even where the pad is drawn",
454        len(bullets) == 1 and bullets[0].velocity.normalized().dot(aim) > 0.99,
455        bullets[0].velocity if bullets else "no bullet",
456    )
457
458    # The rebind screen: Tab opens it (nothing has focus, so Tab is the game's),
459    # and the game stands still behind it.
460    sim.tap_key(Key.TAB)
461    step()
462    check("Tab opens the rebind screen", game.screen is not None and game.screen.tree is not None)
463    before = Vec2(game.player.position)
464    hold(Key.D)
465    check("the game stands still behind the screen", game.player.position.x == before.x, game.player.position)
466
467    # Keyboard column: click the jump cell, press J. J binds and does not jump.
468    click(cell("jump", 0))
469    step()
470    check("clicking a cell listens", cell("jump", 0).listener is not None, cell("jump", 0).text)
471    game.player.height = game.player.lift = 0.0
472    sim.tap_key(Key.J)
473    step(2)
474    check("the heard key does not also jump", game.player.height == 0.0, game.player.height)
475    check("J is bound to jump", imap.get_sources("jump")[0].control is Key.J, imap.get_sources("jump"))
476    check("the cell reads the new binding", cell("jump", 0).text == "J", cell("jump", 0).text)
477
478    # Pad column: the press is bound by the entry naming it, Controller.Y, and the
479    # keyboard's slot is left alone.
480    click(cell("jump", 1))
481    step()
482    sim.tap_controller(Controller.Y)
483    step()
484    check(
485        "pad Y is bound to jump's pad slot, by name",
486        [s.control for s in imap.get_sources("jump")] == [Key.J, Controller.Y],
487        imap.get_sources("jump"),
488    )
489
490    # The device table: Controller.Y is now the physical button the pad calls X,
491    # and jump, which names Controller.Y, follows it.
492    check("the device table reads the raw button", cell(Controller.Y).text == "Button 4", cell(Controller.Y).text)
493    click(cell(Controller.Y))
494    step()
495    sim.tap_controller(Controller.X)
496    step()
497    check(
498        "Controller.Y is pad button 2 now",
499        imap.resolve(Controller.Y) is Controller.button(2),
500        imap.resolve(Controller.Y),
501    )
502    check("its cell says so", cell(Controller.Y).text == "Button 3", cell(Controller.Y).text)
503    check(
504        "nothing names pad button 3 any more",
505        imap.aliases(Controller.button(3)) == (),
506        imap.aliases(Controller.button(3)),
507    )
508
509    # A backwards pad: one press of Swap turns A and B round.
510    click(game.screen.find("Swap"))
511    check(
512        "Swap A and B",
513        imap.resolve(Controller.A) is Controller.button(1) and imap.resolve(Controller.B) is Controller.button(0),
514        (imap.resolve(Controller.A), imap.resolve(Controller.B)),
515    )
516
517    # One direction of move, through its role.
518    click(cell("move", 0, BindingRole.RIGHT))
519    step()
520    sim.tap_key(Key.L)
521    step()
522    right = imap.get_sources("move")[0].right
523    check("L is move right", right.control is Key.L, right)
524
525    # Esc cancels: shoot keeps its click.
526    click(cell("shoot", 0))
527    step()
528    sim.tap_key(Key.ESCAPE)
529    step()
530    check("Esc cancels a rebind", imap.get_sources("shoot")[0].control is Mouse.LEFT, imap.get_sources("shoot"))
531    check("and leaves the screen open", game.screen.tree is not None)
532
533    step()  # the first Esc's release, which a tap sends on the next frame
534    sim.tap_key(Key.ESCAPE)
535    step()
536    check("Esc closes the screen", game.screen.tree is None)
537
538    # Play on the new bindings.
539    check("J jumps now", jumped(lambda: sim.tap_key(Key.J)))
540    check("Space does not", not jumped(lambda: sim.tap_key(Key.SPACE)))
541    check("the button the pad calls X jumps now", jumped(lambda: sim.tap_controller(Controller.X)))
542    check("the one it calls Y does not", not jumped(lambda: sim.tap_controller(Controller.Y)))
543    start = Vec2(game.player.position)
544    hold(Key.L)
545    check("L moves right", game.player.position.x > start.x + 20, game.player.position)
546
547    # A flight stick no controller database knows: numbered controls only, so
548    # the game's actions, which name Controller.X and the left stick, hear
549    # nothing from it until the player maps it.
550    def stick(buttons=None, axes=None):
551        return sim.set_controller(
552            7, recognised=False, name="Flight Stick", guid="flight-stick", buttons=buttons or {}, axes=axes or {}
553        )
554
555    flight = stick()
556    check("the stick is not recognised", not flight.recognised, flight)
557    sim.tap_key(Key.TAB)
558    step()
559    click(game.screen.find("Map"))
560    step()
561    raw_buttons = dict(zip(PAD_ENTRIES, (5, 6, 7, 8, 9, 10), strict=True))
562    for name in MAPPED_NAMES:
563        if name in raw_buttons:
564            stick(buttons={raw_buttons[name]: True})
565            step()
566            stick(buttons={raw_buttons[name]: False})
567        else:
568            axis = 0 if name is Controller.LEFT_X else 1
569            stick(axes={axis: 0.9})
570            step()
571            stick(axes={axis: 0.0})
572        step()
573    check(
574        "the walk mapped every name",
575        game.screen.status.text == f"Mapped {len(MAPPED_NAMES)} controls",
576        game.screen.status.text,
577    )
578    check(
579        "Controller.X is the stick's button 7 there alone",
580        imap.resolve(Controller.X.on(flight)) == Controller.button(7).on(flight)
581        and imap.resolve(Controller.X) is Controller.button(2),
582        imap.resolve(Controller.X.on(flight)),
583    )
584    check(
585        "and the stick's button 7 is called X",
586        imap.aliases(Controller.button(7).on(flight)) == (Controller.X,),
587        imap.aliases(Controller.button(7).on(flight)),
588    )
589    check(
590        "the mapping is saved under the device",
591        "flight-stick" in imap.export().get("devices", {}),
592        sorted(imap.export().get("devices", {})),
593    )
594    sim.tap_key(Key.ESCAPE)
595    step()
596
597    # Jump's pad slot names Controller.Y, which the stick maps to its button 8.
598    def press_stick(index):
599        stick(buttons={index: True})
600        step()
601        stick(buttons={index: False})
602
603    check("Esc closed the screen", game.screen.tree is None)
604
605    check("the stick's mapped Y jumps", jumped(lambda: press_stick(raw_buttons[Controller.Y])))
606    start = Vec2(game.player.position)
607    stick(axes={0: 0.9})
608    step(10)
609    stick(axes={0: 0.0})
610    step()
611    check("the stick's mapped axis moves the player", game.player.position.x > start.x + 20, game.player.position)
612
613    # Reset to defaults drops every rebind: the declared bindings are back,
614    # the pad's A and B are the right way round, and the stick's mapping is gone.
615    sim.tap_key(Key.TAB)
616    step()
617    click(game.screen.find("Reset"))
618    check(
619        "Reset restores jump's declared bindings",
620        [s.control for s in imap.get_sources("jump")] == [Key.SPACE, Controller.A],
621        imap.get_sources("jump"),
622    )
623    check(
624        "and the pad's entries",
625        imap.resolve(Controller.A) is Controller.button(0) and imap.resolve(Controller.Y) is Controller.button(3),
626        (imap.resolve(Controller.A), imap.resolve(Controller.Y)),
627    )
628    check("and nothing is left to save", not imap.export().get("devices"), imap.export())
629    check("the cells read the defaults", cell("jump", 0).text == "Space", cell("jump", 0).text)
630    sim.tap_key(Key.ESCAPE)
631    step()
632    check("Space jumps again", jumped(lambda: sim.tap_key(Key.SPACE)))
633    check("J does not", not jumped(lambda: sim.tap_key(Key.J)))
634
635    print("SELFTEST:", "PASS" if ok else "FAIL")
636    return ok
637
638
639if __name__ == "__main__":
640    import sys
641
642    if "--test" in sys.argv:
643        sys.exit(0 if _selftest() else 1)
644    App(title="Standard Controls", width=960, height=540).run(Game())