Input and Movement¶
Drive a node from the keyboard with input actions.
▶ Run in browserTags: tutorial beginner input
Input and movement¶
Now make something move when the player presses a key. SimVX does not ask you to check raw key codes scattered through your game. Instead you name an action (“move”) and bind devices to it once; your gameplay reads the action. Rebinding keys, adding a controller or shipping to a phone never touches the movement code.
By the end you will drive a square around the window with WASD, the arrow keys, a controller’s left stick, or by dragging with the mouse or a finger.
Step 1: Declare the actions on the root node¶
Put an input_context dictionary at class scope on your root node. Each action
name maps to a list of sources, and each source produces a whole value of the
action on its own. Axis2D.WASD and Axis2D.ARROWS are ready-made four-key
directions:
class MovementDemo(Node2D):
input_context = {
"move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
"steer": [Pointer.PRIMARY],
}
Why the class attribute, not
main()? A node’sinput_contextis in force exactly while the node is in the tree, so the root’s covers the whole scene from before the first frame, and a scene swapped in later brings its own.InputMapbelongs to a scene tree, so callingInputMap.add_actioninmain()before the tree exists raises. The class attribute is the canonical path.add_actionfrom a node callback such ason_ready()works too, since the tree exists by then; entries every scene shares can also go in the project settings ([input]insimvx.toml).
Step 2: An action is typed by its bindings¶
move’s sources are all vector-shaped, so move is a Vec2 action and
Input.value("move") hands you a direction:
class Player(Node2D):
SPEED = 360.0
def on_update(self, dt):
direction = Input.value("move")
self.position += direction * self.SPEED * dt
Buttons alone would make the action a bool; an Axis1D or a single controller axis
would make it a float. You declare what the action is once, rather than
assembling it at every call site.
Step 3: Several sources, and no fight between them¶
Three sources drive move. They never conflict, because the one furthest from
rest wins: a hand resting on a stick loses to a key the player is actually
pressing, and a key, which is full deflection by nature, loses to a stick pushed
further than it. Summing would overshoot the diagonal and last-device-wins would
jitter; largest-magnitude is the one rule a player can predict.
It also fixes the classic movement bug for free. Hold right and down on the
keyboard and the raw vector is (1, 1), whose length is about 1.41: the square
would travel 41% faster diagonally. Axis2D normalises its corners, and its
deadzone is radial rather than per axis, so a stick pushed diagonally is not
clipped into a square either.
Step 4: Stay frame-rate independent¶
Multiplying by dt (seconds since the last frame) is what keeps the speed the
same on a 60 fps and a 144 fps machine. Never add a fixed amount per frame: the
game would run faster on faster hardware.
Step 5: Keep the square on screen¶
Vec2.clamped bounds both components at once against a minimum and a maximum
corner, so there is no need to clamp x and y by hand:
width, height = self.viewport_size
half = self.SIZE / 2
moved = self.position + direction * self.SPEED * dt
self.position = moved.clamped(Vec2(half, half), Vec2(width - half, height - half))
self.viewport_size is the live size of whatever this node draws into, read every
frame. A module-level WIDTH, HEIGHT would be right on the machine you wrote it
on and wrong in a resized window, on a phone, and in a browser canvas.
Step 6: The same movement code, from another device¶
Actions decouple gameplay from hardware, and so does anything else that produces a
direction. A browser build may be running on a phone with no keyboard, so when no
direction key is held the example steers towards a held pointer instead.
Pointer.PRIMARY is the main press of any pointer, so the left mouse button, a pen
tip and a finger all hold steer:
direction = Input.value("move")
if direction.length() == 0.0 and Input.pressed("steer"):
offset = Input.mouse_position - self.position
if offset.length() > self.POINTER_DEAD_ZONE:
direction = offset.normalized()
The movement lines never change: they consume a direction and do not care whether a key, a controller stick, or a fingertip produced it.
Gate on the action, read the device. The held button is read as the
steeraction, not throughInput.is_mouse_button_pressed. The two disagree exactly where it matters: a tap that a button or an on-screen stick took never reaches the action layer, while the device query stays honest that the button is physically down. Read through an action and the player does not chase the widget they just pressed. Device queries are the escape hatch, for when you genuinely want to know what the hardware is doing.
Run it¶
# In your own copy of this directory
python main.py
# Anywhere, with the example library installed
simvx examples run tutorials_input_and_movement
What’s next¶
Bouncing Balls –
Propertydescriptors and spawning many children.Pong – put input, signals, and collision together into a full game.
Source¶
1# /// simvx
2# tags = ["tutorial", "beginner", "input"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Input and Movement: Drive a node from the keyboard with input actions.
6
7The third lesson: turn key presses into movement. Instead of checking raw keys, you name an
8*input action* ("move") and bind devices to it once. Gameplay reads the action, so rebinding
9keys, adding a controller or shipping to a phone never touches the movement code.
10
11Move the square with WASD, the arrow keys or a controller's left stick, or hold the mouse
12button or a finger down to steer it towards the pointer.
13
14## Declare the actions
15
16The root node declares `input_context` at class scope. It is in force exactly while the node
17is in the tree, so the root's covers the whole scene from before the first frame. Each entry
18in an action's list is one whole source of its value:
19
20 input_context = {
21 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
22 "steer": [Pointer.PRIMARY],
23 }
24
25`Axis2D.WASD` and `Axis2D.ARROWS` are ready-made four-key directions. `Pointer.PRIMARY` is
26the main press of any pointer: the left mouse button, a pen tip or a finger.
27
28## Typed actions
29
30An action's type follows from its bindings. Every source of `move` is vector-shaped, so
31`Input.value("move")` is one `Vec2` however the player holds the game. Buttons alone, as in
32`steer`, make a `bool` action, read with `Input.pressed("steer")`.
33
34## Largest magnitude wins
35
36Several sources on one action never fight: the one furthest from rest is the answer, so a
37hand resting on a stick loses to a pressed key. `Axis2D` also normalises its corners, so
38holding right and down is no faster than holding right.
39
40## Frame-rate-independent movement
41
42`Player.on_update` moves by `direction * SPEED * dt`, so the square covers the same distance
43per second at any frame rate, and `Vec2.clamped` keeps it inside `self.viewport_size`, the
44live size of what the node draws into.
45
46## Gate on the action, read the device
47
48When `move` is at rest the player steers towards `Input.mouse_position` while `steer` is
49held, stopping inside a small dead zone. The press is read as the action rather than through
50`Input.is_mouse_button_pressed`: a tap that a UI widget took never reaches the action layer,
51while the device query would still report the button down and send the player chasing the
52widget.
53"""
54
55from simvx.core import Axis2D, Controller, Input, Node2D, Pointer, Vec2
56from simvx.graphics import App
57
58
59class Player(Node2D):
60 """Reads the actions and moves. Knows nothing about which keys are bound."""
61
62 SIZE = 56
63 SPEED = 360.0
64 POINTER_DEAD_ZONE = 4.0 # stop this close to the pointer rather than jitter around it
65
66 def on_update(self, dt: float):
67 direction = Input.value("move")
68 if direction.length() == 0.0 and Input.pressed("steer"):
69 offset = Input.mouse_position - self.position
70 if offset.length() > self.POINTER_DEAD_ZONE:
71 direction = offset.normalized()
72
73 width, height = self.viewport_size
74 half = self.SIZE / 2
75 moved = self.position + direction * self.SPEED * dt
76 self.position = moved.clamped(Vec2(half, half), Vec2(width - half, height - half))
77
78 def on_draw(self, renderer):
79 half = self.SIZE / 2
80 renderer.rect((-half, -half), (self.SIZE, self.SIZE), fill=(0.4, 0.8, 1.0, 1.0))
81
82
83class MovementDemo(Node2D):
84 """Root node: declares the input actions and spawns the player."""
85
86 input_context = {
87 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
88 "steer": [Pointer.PRIMARY],
89 }
90
91 def on_ready(self):
92 width, height = self.viewport_size
93 self.player = self.add_child(Player(position=(width / 2, height / 2)))
94
95 # Example help overlay: not part of the technique shown.
96 def on_draw(self, renderer):
97 renderer.text("Input and Movement", (20, 20), fill=(1, 1, 1), scale=2)
98 renderer.text("WASD, arrows or a stick to move, or hold the pointer to steer", (20, 52), fill=(0.7, 0.7, 0.7))
99
100
101if __name__ == "__main__":
102 App(title="Input and Movement", width=800, height=600).run(MovementDemo())