Character Push

a character shoves what it walks into, by mass.

▶ Run in browser

Tags: physics 3d character

A CharacterBody3D moves where move_and_slide(dt) sends it, so on its own a crate in the way would only block it. push_factor turns that block into a shove, and it defaults to 1.0: walk into a crate and the crate goes.

Each blocking contact hands the struck body an impulse of push_factor * (M * m / (M + m)) * approach_speed, where M is the character’s mass and m the mass of what it hit. At 1.0 that is the impulse of a perfectly inelastic collision, so one contact never sends a crate off faster than the character was walking. Set push_factor = 0.0 for a pure block.

  • The character here weighs 1 kg rather than the 70 kg default, so the three crates in a row (1 kg, 20 kg and 200 kg) react three different ways: the light one skitters ahead, the middle one is carried, the heavy one barely moves.

  • The wall is static: it has no finite mass to share, so it takes no push and the character simply stops.

  • A floor contact is exempt at any setting: jump onto a crate and standing on it does not drive it into the ground.

  • The stack at the far end falls asleep while it waits. Sleep is per island, so shoving the bottom crate wakes the whole stack.

  • push_factor is read on every move, so changing it mid-walk (as a “carrying something heavy” state would) takes effect at once.

  • collisions lists the blocking contacts of the last move whatever the factor is, for a game that wants to add physics of its own. The readout shows them.

move_and_slide does not apply gravity, so the character’s loop in on_fixed_update adds it to velocity.y itself before each move.

Walk with WASD, the arrow keys, a left stick or the on-screen stick, and jump with Space or the A button. The buttons along the bottom change push_factor (also + and -, and 0 for no push) and rebuild the scene (R).

Source

  1# /// simvx
  2# tags = ["3d", "physics", "character"]
  3# ///
  4"""Character Push: a character shoves what it walks into, by mass.
  5
  6A `CharacterBody3D` moves where `move_and_slide(dt)` sends it, so on its own a
  7crate in the way would only block it. `push_factor` turns that block into a
  8shove, and it defaults to `1.0`: walk into a crate and the crate goes.
  9
 10Each blocking contact hands the struck body an impulse of
 11`push_factor * (M * m / (M + m)) * approach_speed`, where `M` is the character's
 12`mass` and `m` the mass of what it hit. At `1.0` that is the impulse of a
 13perfectly inelastic collision, so one contact never sends a crate off faster
 14than the character was walking. Set `push_factor = 0.0` for a pure block.
 15
 16- The character here weighs 1 kg rather than the 70 kg default, so the three
 17  crates in a row (1 kg, 20 kg and 200 kg) react three different ways: the light
 18  one skitters ahead, the middle one is carried, the heavy one barely moves.
 19- The wall is static: it has no finite mass to share, so it takes no push and
 20  the character simply stops.
 21- A floor contact is exempt at any setting: jump onto a crate and standing on it
 22  does not drive it into the ground.
 23- The stack at the far end falls asleep while it waits. Sleep is per island, so
 24  shoving the bottom crate wakes the whole stack.
 25- `push_factor` is read on every move, so changing it mid-walk (as a "carrying
 26  something heavy" state would) takes effect at once.
 27- `collisions` lists the blocking contacts of the last move whatever the factor
 28  is, for a game that wants to add physics of its own. The readout shows them.
 29
 30`move_and_slide` does not apply gravity, so the character's loop in
 31`on_fixed_update` adds it to `velocity.y` itself before each move.
 32
 33Walk with WASD, the arrow keys, a left stick or the on-screen stick, and jump
 34with Space or the A button. The buttons along the bottom change `push_factor`
 35(also + and -, and 0 for no push) and rebuild the scene (R).
 36"""
 37
 38from simvx.core import (
 39    Anchor,
 40    Axis2D,
 41    BodyMode,
 42    BoxShape3D,
 43    Button,
 44    Camera3D,
 45    CanvasLayer,
 46    CharacterBody3D,
 47    Controller,
 48    DirectionalLight3D,
 49    HBoxContainer,
 50    Input,
 51    Key,
 52    Label,
 53    Material,
 54    Mesh,
 55    MeshInstance3D,
 56    Node,
 57    PhysicsBody3D,
 58    TouchController,
 59    Vec3,
 60    on_input,
 61)
 62from simvx.graphics import App
 63
 64SPEED = 4.0
 65JUMP_SPEED = 7.0
 66GRAVITY = 18.0
 67PUSH_STEP = 0.1
 68CRATE = Vec3(1.0, 1.0, 1.0)
 69
 70
 71def box(size, colour, *, mode=BodyMode.DYNAMIC, mass=1.0, **kwargs) -> PhysicsBody3D:
 72    """A body whose collider and mesh are one box of ``size``."""
 73    body = PhysicsBody3D(shape=BoxShape3D(half_extents=Vec3(size) / 2), mode=mode, mass=mass, **kwargs)
 74    body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=colour, roughness=0.7)))
 75    return body
 76
 77
 78class CharacterPush(Node):
 79    input_context = {
 80        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
 81        "jump": [Key.SPACE, Controller.A],
 82        "push_down": [Key.MINUS, Key.KP_SUBTRACT],
 83        "push_up": [Key.EQUAL, Key.KP_ADD],
 84        "no_push": [Key.KEY_0],
 85        "rebuild": [Key.R],
 86    }
 87
 88    def on_ready(self):
 89        self.add_child(Camera3D(position=(-3.5, 7.0, 9.5), look_at=(2.5, 0.5, 0.0)))
 90        self.add_child(DirectionalLight3D(direction=(-6, -12, -8), colour=(1.0, 0.96, 0.85), intensity=3.0))
 91        # A dim fill from the camera side, so faces turned toward the viewer are not black.
 92        self.add_child(DirectionalLight3D(direction=(8, -6, -10), colour=(0.6, 0.68, 0.85), intensity=1.0))
 93
 94        self.add_child(box((28, 1, 28), (0.2, 0.22, 0.26, 1), mode=BodyMode.STATIC, position=(3, -0.5, 0)))
 95        self.wall = self.add_child(box((1, 3, 6), (0.42, 0.4, 0.46, 1), mode=BodyMode.STATIC, position=(6.5, 1.5, 2.5)))
 96        # Identical but for their mass, and darker as they get heavier.
 97        self.crates = [
 98            self.add_child(box(CRATE, colour, mass=mass, name=f"{mass:g} kg", position=(2.0, 0.5, z)))
 99            for mass, z, colour in (
100                (1.0, -2.5, (0.95, 0.78, 0.3, 1)),
101                (20.0, 0.0, (0.85, 0.48, 0.2, 1)),
102                (200.0, 2.5, (0.55, 0.22, 0.16, 1)),
103            )
104        ]
105        self.stack = [
106            self.add_child(
107                box(CRATE, (0.35 + 0.2 * (i % 2), 0.55, 0.8, 1), mass=2.0, position=(6.5, 0.5 + i * 1.02, -2.5))
108            )
109            for i in range(3)
110        ]
111        self.start_x = [float(c.world_position.x) for c in self.crates]
112
113        size = Vec3(0.7, 1.8, 0.7)
114        self.character = self.add_child(
115            CharacterBody3D(shape=BoxShape3D(half_extents=size / 2), mass=1.0, position=(-2.0, 0.9, 0.0))
116        )
117        self.character.add_child(
118            MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=(0.45, 0.85, 0.55, 1), roughness=0.4))
119        )
120
121        hud = self.add_child(CanvasLayer())
122        self.status = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=16, font_size=18))
123        buttons = hud.add_child(HBoxContainer(anchor=Anchor.CENTER_BOTTOM, inset=16))
124        for text, action in (
125            ("Push -", "push_down"),
126            ("Push +", "push_up"),
127            ("No push", "no_push"),
128            ("Rebuild", "rebuild"),
129        ):
130            buttons.add_child(Button(text, action=action))
131        buttons.autosize_to_content()
132        self.add_child(TouchController(buttons=(Controller.A,)))
133
134        # Example help overlay: not part of the technique shown.
135        hud.add_child(Label("WASD / arrows / stick: walk\nSpace / A: jump", anchor=Anchor.TOP_RIGHT, inset=16))
136
137    @on_input(("push_down", "push_up"))
138    def change_push(self, event):
139        # The Property's range clamps at 0.
140        self.character.push_factor += PUSH_STEP if event.action == "push_up" else -PUSH_STEP
141
142    @on_input("no_push")
143    def remove_push(self, event):
144        self.character.push_factor = 0.0
145
146    @on_input("rebuild")
147    def rebuild(self, event):
148        self.tree.change_scene(CharacterPush())
149
150    def on_fixed_update(self, dt):
151        character = self.character
152        walk = Input.value("move") * SPEED
153        fall = character.velocity.y - GRAVITY * dt
154        if Input.just_pressed("jump") and character.is_on_floor():
155            fall = JUMP_SPEED
156        character.velocity = Vec3(walk.x, fall, walk.y)
157        character.move_and_slide(dt)
158
159    def on_update(self, dt):
160        travel = "   ".join(
161            f"{c.name} {float(c.world_position.x) - x0:+.2f} m" for c, x0 in zip(self.crates, self.start_x, strict=True)
162        )
163        asleep = sum(c.is_sleeping for c in self.stack)
164        self.status.text = (
165            f"push_factor {self.character.push_factor:.2f}\n"
166            f"crate travel: {travel}\n"
167            f"stack asleep: {asleep}/{len(self.stack)}\n"
168            f"blocking contacts last move: {len(self.character.collisions)}"
169        )
170
171
172if __name__ == "__main__":
173    App(title="Character Push", width=1280, height=720).run(CharacterPush())