Standard Controls¶
move, jump, shoot at the cursor, and rebind any of it.
▶ Run in browserTags: 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_contexttyped by their bindings:Axis2D.WASDandController.LEFT_STICKmakemoveaVec2, buttons makejumpandshoota bool.@on_input("jump")fires once per press; a handler hears a release only when it asks withedge="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 usingController.Afollows;InputMap.swapturns 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.labelprinting 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())