2D Physics Playground

rigid bodies, a character, one-way platforms, and joints.

▶ Run in browser

Tags: 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 a STATIC floor whose collider is a SegmentShape2D. A PhysicsMaterial sets friction and restitution. The physics step writes each body’s transform, so a visual added as a child (a Polygon2D slab, or a disc with a spoke that shows its spin) moves and turns with it.

  • Character. A CharacterBody2D is driven by velocity and move_and_slide(dt) in on_fixed_update; is_on_floor() gates the jump. The character adds gravity to its own velocity, and its up_direction is set to -Y to match the Y-down world.

  • One-way platform. one_way=True with one_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 Area2D reports body_entered and body_exited for 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 its body_exited, handed over with handle None, so a handler can tell “destroyed inside” from “left”.

  • Joints. Two PinJoint2Ds hang a two-link pendulum from a static anchor, and a GrooveJoint2D holds a bead on a rail, kicked along it at the start. The bead and its carrier have collision_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())