2D Physics Playground¶
rigid bodies, a character, one-way platforms, and joints.
▶ Run in browserTags: physics 2d character joints
One scene that exercises the 2D physics stack end to end. Every physics node sits under a
PhysicsRoot2D whose gravity points down the screen (+Y), so this is a Y-down pixel world.
Rigid bodies.
PhysicsBody2D(mode=BodyMode.DYNAMIC, shape=..., material=...)boxes and circles fall and pile up on aSTATICfloor whose collider is aSegmentShape2D. APhysicsMaterialsets friction and restitution. The physics step writes each body’s transform, so a visual added as a child (aPolygon2Dslab, or a disc with a spoke that shows its spin) moves and turns with it.Character. A
CharacterBody2Dis driven byvelocityandmove_and_slide(dt)inon_fixed_update;is_on_floor()gates the jump. The character adds gravity to its own velocity, and itsup_directionis set to -Y to match the Y-down world.One-way platform.
one_way=Truewithone_way_normal=(0, -1)makes the green platform solid from above: the character lands on it and jumps up through it from below.Trigger zone. An
Area2Dreportsbody_enteredandbody_exitedfor every body crossing the yellow zone, the character included, since a character is an ordinary kinematic body. A body destroyed inside the zone still gets itsbody_exited, handed over withhandleNone, so a handler can tell “destroyed inside” from “left”.Joints. Two
PinJoint2Ds hang a two-link pendulum from a static anchor, and aGrooveJoint2Dholds a bead on a rail, kicked along it at the start. The bead and its carrier havecollision_mask=0, so they never collide as circles and only the groove acts.
The world is authored at 1280x720 and the tree runs in ContentScaleMode.FIT, so any window
shows the whole scene, scaled and letterboxed, and pointer positions arrive in the same units.
Move with A/D, the arrow keys or a stick; hold the mouse button or a finger to walk toward the pointer. Space, a click or a tap jumps. R drops a new batch and X destroys whatever is inside the zone (both are buttons on screen too).
Source¶
1# /// simvx
2# tags = ["2d", "physics", "character", "joints"]
3# ///
4"""2D Physics Playground: rigid bodies, a character, one-way platforms, and joints.
5
6One scene that exercises the 2D physics stack end to end. Every physics node sits under a
7`PhysicsRoot2D` whose `gravity` points down the screen (+Y), so this is a Y-down pixel world.
8
9- **Rigid bodies.** `PhysicsBody2D(mode=BodyMode.DYNAMIC, shape=..., material=...)` boxes and
10 circles fall and pile up on a `STATIC` floor whose collider is a `SegmentShape2D`. A
11 `PhysicsMaterial` sets friction and restitution. The physics step writes each body's
12 transform, so a visual added as a child (a `Polygon2D` slab, or a disc with a spoke that shows
13 its spin) moves and turns with it.
14- **Character.** A `CharacterBody2D` is driven by `velocity` and `move_and_slide(dt)` in
15 `on_fixed_update`; `is_on_floor()` gates the jump. The character adds gravity to its own
16 velocity, and its `up_direction` is set to -Y to match the Y-down world.
17- **One-way platform.** `one_way=True` with `one_way_normal=(0, -1)` makes the green platform
18 solid from above: the character lands on it and jumps up through it from below.
19- **Trigger zone.** An `Area2D` reports `body_entered` and `body_exited` for every body crossing
20 the yellow zone, the character included, since a character is an ordinary kinematic body. A
21 body destroyed inside the zone still gets its `body_exited`, handed over with `handle` None,
22 so a handler can tell "destroyed inside" from "left".
23- **Joints.** Two `PinJoint2D`s hang a two-link pendulum from a static anchor, and a
24 `GrooveJoint2D` holds a bead on a rail, kicked along it at the start. The bead and its
25 carrier have `collision_mask=0`, so they never collide as circles and only the groove acts.
26
27The world is authored at 1280x720 and the tree runs in `ContentScaleMode.FIT`, so any window
28shows the whole scene, scaled and letterboxed, and pointer positions arrive in the same units.
29
30Move with A/D, the arrow keys or a stick; hold the mouse button or a finger to walk toward the
31pointer. Space, a click or a tap jumps. R drops a new batch and X destroys whatever is inside
32the zone (both are buttons on screen too).
33"""
34
35import math
36
37from simvx.core import (
38 Anchor,
39 Area2D,
40 Axis1D,
41 BodyMode,
42 Button,
43 CanvasLayer,
44 CharacterBody2D,
45 CircleShape2D,
46 Colour,
47 ContentScaleMode,
48 Controller,
49 GrooveJoint2D,
50 HBoxContainer,
51 Input,
52 Key,
53 Label,
54 Node2D,
55 PhysicsBody2D,
56 PhysicsMaterial,
57 PhysicsRoot2D,
58 PinJoint2D,
59 Pointer,
60 Polygon2D,
61 Property,
62 RectangleShape2D,
63 SegmentShape2D,
64 Vec2,
65 on_input,
66)
67from simvx.graphics import App
68
69GRAVITY = Vec2(0.0, 1400.0)
70UP = Vec2(0.0, -1.0)
71FLOOR_Y = 660.0
72WALK_SPEED = 300.0
73JUMP_SPEED = 650.0
74GREY = (0.7, 0.7, 0.7, 1.0)
75DROP_MATERIAL = PhysicsMaterial(friction=0.5, restitution=0.1)
76COLOURS = [(0.9, 0.3, 0.25, 1.0), (0.3, 0.7, 0.95, 1.0), (0.95, 0.8, 0.25, 1.0), (0.55, 0.85, 0.35, 1.0)]
77
78
79class Disc(Node2D):
80 """A filled circle with a spoke, so its rotation shows."""
81
82 radius = Property(10.0)
83 colour = Colour((1.0, 1.0, 1.0, 1.0))
84
85 def on_draw(self, renderer):
86 renderer.circle((0, 0), self.radius, fill=self.colour)
87 renderer.line((0, 0), (self.radius, 0), stroke=(0.1, 0.1, 0.1, 0.8), width=2.0)
88
89
90def slab(hx, hy, colour):
91 return Polygon2D(polygon=[(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)], colour=colour)
92
93
94def ball(radius, colour, **body):
95 node = PhysicsBody2D(shape=CircleShape2D(radius), **body)
96 node.add_child(Disc(radius=radius, colour=colour))
97 return node
98
99
100def crate(hx, hy, colour, **body):
101 node = PhysicsBody2D(shape=RectangleShape2D(half_extents=(hx, hy)), **body)
102 node.add_child(slab(hx, hy, colour))
103 return node
104
105
106class Playground(Node2D):
107 input_context = {
108 "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
109 "jump": [Key.SPACE, Controller.A, Pointer.PRIMARY],
110 "walk_to_pointer": [Pointer.PRIMARY],
111 "respawn": [Key.R],
112 "vaporise": [Key.X],
113 }
114
115 def on_ready(self):
116 self.tree.content_scale_mode = ContentScaleMode.FIT
117 self.tree.content_scale_size = (1280, 720)
118 self.world = self.add_child(PhysicsRoot2D(name="World", gravity=GRAVITY))
119
120 floor = self.world.add_child(
121 PhysicsBody2D(
122 name="Floor",
123 mode=BodyMode.STATIC,
124 position=(640, FLOOR_Y),
125 shape=SegmentShape2D(a=(-560, 0), b=(560, 0), radius=6.0),
126 material=PhysicsMaterial(friction=0.7),
127 )
128 )
129 floor.add_child(slab(560, 8, (0.24, 0.26, 0.3, 1.0)))
130 self.world.add_child(
131 crate(
132 110,
133 8,
134 (0.45, 0.75, 0.45, 1.0),
135 name="OneWay",
136 mode=BodyMode.STATIC,
137 position=(900, 470),
138 one_way=True,
139 one_way_normal=UP,
140 material=PhysicsMaterial(friction=0.6),
141 )
142 )
143
144 zone_shape = RectangleShape2D(half_extents=(120, 110))
145 self.zone = self.world.add_child(Area2D(name="Trigger", position=(560, 300), shape=zone_shape))
146 self.zone.add_child(slab(120, 110, (0.95, 0.85, 0.2, 0.18)))
147 self.zone.body_entered.connect(self.on_body_entered)
148 self.zone.body_exited.connect(self.on_body_exited)
149 self.inside = self.vaporised = 0
150 self.last_in = self.last_out = ""
151
152 # Joints go in after their bodies, so both ends exist when the constraint is made.
153 anchor = self.world.add_child(ball(7, GREY, name="PendulumAnchor", mode=BodyMode.STATIC, position=(170, 140)))
154 link1 = self.world.add_child(ball(20, COLOURS[1], name="PendulumLink1", position=(250, 140)))
155 link2 = self.world.add_child(ball(20, (0.8, 0.45, 0.9, 1.0), name="PendulumLink2", position=(330, 140)))
156 self.world.add_child(PinJoint2D(body_a=anchor, body_b=link1, anchor=anchor.position))
157 self.world.add_child(PinJoint2D(body_a=link1, body_b=link2, anchor=link1.position))
158
159 carrier = self.world.add_child(
160 ball(7, GREY, name="GrooveCarrier", mode=BodyMode.STATIC, position=(640, 230), collision_mask=0)
161 )
162 carrier.add_child(slab(180, 2, (0.5, 0.5, 0.55, 1.0)))
163 self.bead = self.world.add_child(
164 ball(16, (0.95, 0.55, 0.2, 1.0), name="GrooveBead", position=(480, 230), collision_mask=0)
165 )
166 self.world.add_child(GrooveJoint2D(body_a=carrier, body_b=self.bead, groove_a=(-180, 0), groove_b=(180, 0)))
167 self.bead.velocity = (240, 0)
168
169 self.player = self.world.add_child(CharacterBody2D(name="Player", position=(900, 140), shape=CircleShape2D(24)))
170 self.player.up_direction = UP
171 self.player.add_child(Disc(radius=24, colour=(1.0, 0.55, 0.15, 1.0)))
172
173 self.hud = self.add_child(CanvasLayer(name="HUD"))
174 self.status = self.hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12))
175 self.respawn()
176 self._add_help()
177
178 @on_input("respawn")
179 def respawn(self, event=None):
180 for body in self.tree.group("drops"):
181 body.destroy()
182 for i in range(8):
183 colour = COLOURS[i % len(COLOURS)]
184 x, y = 560 + (i % 4 - 1.5) * 55, 60 - (i // 4) * 65
185 spot = {"name": f"Drop{i}", "position": (x, y), "material": DROP_MATERIAL}
186 body = ball(22, colour, **spot) if i % 2 == 0 else crate(22, 22, colour, **spot)
187 body.add_to_group("drops")
188 self.world.add_child(body)
189
190 @on_input("vaporise")
191 def vaporise(self, event=None):
192 for body in self.zone.get_overlapping_bodies():
193 body.destroy()
194
195 def on_body_entered(self, body):
196 self.inside += 1
197 self.last_in = type(body).__name__
198
199 def on_body_exited(self, body):
200 self.inside -= 1
201 if body.handle is None:
202 self.vaporised += 1
203 self.last_out = f"{body.name} destroyed inside"
204 else:
205 self.last_out = f"{body.name} left"
206
207 def on_fixed_update(self, dt):
208 vx = Input.value("move") * WALK_SPEED
209 if vx == 0.0 and Input.pressed("walk_to_pointer"):
210 dx = float(Input.mouse_position.x - self.player.world_position.x)
211 if abs(dx) > 8.0: # dead zone, so the character does not jitter under the pointer
212 vx = math.copysign(WALK_SPEED, dx)
213 vy = float(self.player.velocity.y)
214 if self.player.is_on_floor():
215 # Resting on the floor still accumulates gravity unless the fall is cancelled.
216 vy = -JUMP_SPEED if Input.just_pressed("jump") else min(vy, 0.0)
217 self.player.velocity = Vec2(vx, vy + GRAVITY.y * dt)
218 self.player.move_and_slide(dt)
219
220 def on_update(self, dt):
221 drops = self.tree.group("drops")
222 rested = sum(1 for body in drops if body.world_position.y > FLOOR_Y - 80)
223 last_in = f" last in: {self.last_in}" if self.last_in else ""
224 last_out = f" last out: {self.last_out}" if self.last_out else ""
225 self.status.text = (
226 f"bodies: {len(drops)} rested: {rested} on floor: {self.player.is_on_floor()}\n"
227 f"trigger: {self.inside} inside{last_in}{last_out} vaporised: {self.vaporised}"
228 )
229
230 # Example help overlay: not part of the technique shown.
231 def _add_help(self):
232 hint = "A/D or hold to walk, Space or click to jump"
233 buttons = [Button("R: respawn", on_press=self.respawn), Button("X: vaporise", on_press=self.vaporise)]
234 self.hud.add_child(HBoxContainer(children=[Label(hint), *buttons], anchor=Anchor.BOTTOM_LEFT, inset=12))
235
236
237if __name__ == "__main__":
238 App(title="2D Physics Playground", width=1280, height=720).run(Playground())