Character Platformer¶
CharacterBody2D with gravity, jump, and platforms.
▶ Run in browserTags: physics 2d character-body gravity input-actions
A blue block runs and jumps across floating ledges above a full-width ground, and respawns at the top if it falls off the bottom of the level.
CharacterBody2D is a kinematic body: the simulation never pushes it, so the
game owns its velocity. Each physics tick, on_fixed_update sets the
velocity and calls move_and_slide(dt), which sweeps the collider along it,
stops at what it hits and slides the remainder along the surface. Afterwards
is_on_floor() says whether it ended standing on something, which is what
gates the jump. World gravity only accelerates dynamic bodies, so the character
adds self.world.gravity to its own velocity, keeping one source of truth.
The ledges are PhysicsBody2D in BodyMode.STATIC with a
RectangleShape2D collider. Their w and h are Properties whose change
hook rebuilds the collider, so a ledge always collides at the size it draws.
The world is in screen space, Y pointing down: the PhysicsRoot2D gets a
+Y gravity and the character’s up_direction is flipped to -Y, since the
floor test behind is_on_floor() needs to know which way is up. The level is
authored in an 800 x 600 space, and ContentScaleMode.FIT on the tree scales
it to fit the window.
Input is two typed actions on the root’s input_context: move is one
signed Axis1D driven by A/D, the arrow keys or a stick, read with
Input.value("move"), and jump is a button read with
Input.just_pressed("jump"). Space, W, Up, pad A, or a click or tap
(Pointer.PRIMARY) jumps, and a TouchController gives a phone a stick
and a jump button.
vy = self.velocity.y + self.world.gravity.y * dt
if Input.just_pressed("jump") and self.is_on_floor():
vy = -self.jump_speed
self.velocity = Vec2(Input.value("move") * self.speed, vy)
self.move_and_slide(dt)
Source¶
1#!/usr/bin/env python3
2# /// simvx
3# tags = ["physics", "2d", "character-body", "gravity", "input-actions"]
4# ///
5"""Character Platformer: CharacterBody2D with gravity, jump, and platforms.
6
7A blue block runs and jumps across floating ledges above a full-width ground,
8and respawns at the top if it falls off the bottom of the level.
9
10``CharacterBody2D`` is a kinematic body: the simulation never pushes it, so the
11game owns its ``velocity``. Each physics tick, ``on_fixed_update`` sets the
12velocity and calls ``move_and_slide(dt)``, which sweeps the collider along it,
13stops at what it hits and slides the remainder along the surface. Afterwards
14``is_on_floor()`` says whether it ended standing on something, which is what
15gates the jump. World gravity only accelerates dynamic bodies, so the character
16adds ``self.world.gravity`` to its own velocity, keeping one source of truth.
17
18The ledges are ``PhysicsBody2D`` in ``BodyMode.STATIC`` with a
19``RectangleShape2D`` collider. Their ``w`` and ``h`` are Properties whose change
20hook rebuilds the collider, so a ledge always collides at the size it draws.
21
22The world is in screen space, Y pointing down: the ``PhysicsRoot2D`` gets a
23+Y ``gravity`` and the character's ``up_direction`` is flipped to -Y, since the
24floor test behind ``is_on_floor()`` needs to know which way is up. The level is
25authored in an 800 x 600 space, and ``ContentScaleMode.FIT`` on the tree scales
26it to fit the window.
27
28Input is two typed actions on the root's ``input_context``: ``move`` is one
29signed ``Axis1D`` driven by A/D, the arrow keys or a stick, read with
30``Input.value("move")``, and ``jump`` is a button read with
31``Input.just_pressed("jump")``. Space, W, Up, pad A, or a click or tap
32(``Pointer.PRIMARY``) jumps, and a ``TouchController`` gives a phone a stick
33and a jump button.
34
35```python
36vy = self.velocity.y + self.world.gravity.y * dt
37if Input.just_pressed("jump") and self.is_on_floor():
38 vy = -self.jump_speed
39self.velocity = Vec2(Input.value("move") * self.speed, vy)
40self.move_and_slide(dt)
41```
42"""
43
44from simvx.core import (
45 Axis1D,
46 BodyMode,
47 CharacterBody2D,
48 ContentScaleMode,
49 Controller,
50 Input,
51 Key,
52 Node2D,
53 PhysicsBody2D,
54 PhysicsRoot2D,
55 Pointer,
56 Property,
57 RectangleShape2D,
58 Vec2,
59)
60from simvx.core.ui import TouchController
61from simvx.graphics import App
62
63LEVEL_SIZE = Vec2(800, 600)
64SPAWN = Vec2(400, 100)
65# Each ledge is (centre x, centre y, width), in level units.
66LEDGES = [
67 (200, 450, 150),
68 (400, 350, 120),
69 (600, 450, 150),
70 (300, 250, 100),
71 (500, 250, 100),
72 (150, 150, 130),
73 (650, 150, 130),
74]
75
76
77class Platform(PhysicsBody2D):
78 """A static ledge; changing ``w`` or ``h`` rebuilds its collider to match."""
79
80 w = Property(120.0, range=(20.0, 2000.0), on_change="_fit_collider")
81 h = Property(16.0, range=(8.0, 60.0), on_change="_fit_collider")
82
83 def __init__(self, **kwargs):
84 super().__init__(mode=BodyMode.STATIC, **kwargs)
85 self._fit_collider()
86
87 def _fit_collider(self):
88 self.shape = RectangleShape2D(half_extents=Vec2(self.w / 2, self.h / 2))
89
90 def on_draw(self, renderer):
91 renderer.rect((-self.w / 2, -self.h / 2), (self.w, self.h), fill=(0.39, 0.71, 0.31, 1.0))
92
93
94class Player(CharacterBody2D):
95 speed = Property(250.0, range=(100, 500))
96 jump_speed = Property(450.0, range=(200, 800))
97 half_size = Vec2(10, 14)
98
99 def __init__(self, **kwargs):
100 super().__init__(shape=RectangleShape2D(half_extents=self.half_size), **kwargs)
101 self.up_direction = Vec2(0, -1)
102
103 def on_fixed_update(self, dt):
104 vy = self.velocity.y + self.world.gravity.y * dt
105 if Input.just_pressed("jump") and self.is_on_floor():
106 vy = -self.jump_speed
107 self.velocity = Vec2(Input.value("move") * self.speed, vy)
108 self.move_and_slide(dt)
109
110 if self.position.y > LEVEL_SIZE.y + 50:
111 self.position = SPAWN
112 self.velocity = Vec2()
113
114 def on_draw(self, renderer):
115 hw, hh = self.half_size
116 renderer.rect((-hw, -hh), (hw * 2, hh * 2), fill=(0.24, 0.55, 1.0, 1.0))
117 renderer.rect((-5, 4 - hh), (4, 4), fill=(1.0, 1.0, 1.0, 1.0))
118 renderer.rect((1, 4 - hh), (4, 4), fill=(1.0, 1.0, 1.0, 1.0))
119
120
121class PlatformerDemo(Node2D):
122 input_context = {
123 "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
124 "jump": [Key.SPACE, Key.W, Key.UP, Pointer.PRIMARY, Controller.A],
125 }
126
127 def on_ready(self):
128 self.tree.content_scale_mode = ContentScaleMode.FIT
129 self.tree.content_scale_size = tuple(LEVEL_SIZE)
130 world = self.add_child(PhysicsRoot2D(name="World", gravity=Vec2(0, 800)))
131 world.add_child(Player(name="Player", position=SPAWN))
132 world.add_child(Platform(name="Ground", w=LEVEL_SIZE.x, h=20, position=(LEVEL_SIZE.x / 2, LEVEL_SIZE.y - 10)))
133 for i, (x, y, w) in enumerate(LEDGES):
134 world.add_child(Platform(name=f"Ledge{i}", w=w, position=(x, y)))
135 self.add_child(TouchController(buttons=(Controller.A,)))
136
137 # Example help overlay: not part of the technique shown.
138 def on_draw(self, renderer):
139 renderer.text("Character Platformer", (10, 10), fill=(1.0, 1.0, 1.0), scale=2)
140 renderer.text("A/D or arrows: move Space, W, Up, click or tap: jump", (10, 45), fill=(0.71, 0.71, 0.71))
141
142
143if __name__ == "__main__":
144 App(title="Character Platformer", width=800, height=600).run(PlatformerDemo())