Character Presence

a character body is an ordinary kinematic body.

▶ Run in browser

Tags: physics 3d character

CharacterBody3D is a KINEMATIC physics body plus move_and_slide, the swept movement helper. Everything else about it is what any body gets, and this scene puts each consequence on screen at once:

  • Queries see it. A ray cast through arena.physics.raycast crosses the player’s path and comes back with the Player node itself as hit.node.

  • Areas see it. The yellow Area3D fires body_entered and body_exited with the player as the payload, and never pushes it.

  • Dynamic bodies rest on it. The orange crate falls onto the player’s head and stays there, then is left behind when the player walks off: a character does not carry what rides on it.

  • Characters block each other. The red Blocker is a second CharacterBody3D that never moves and stops the player like a wall.

  • Pushing is the game’s rule. By default a character shoves whatever dynamic body it walks into (push_factor = 1.0). This player sets push_factor=0.0 and pushes for itself: after each move, collisions lists every blocking contact as a SweepHit (the other body’s handle, the contact point, and a normal pointing back at the player), and the player applies an impulse along -normal to the bodies in its pushable set. The green ball is in that set and rolls away; the crate is just as dynamic, is not listed, and does not move when the player walks into it.

  • Steps are climbed. step_height=0.5 lets the player walk up the low green step. Anything taller, or too steep to stand on (slope_limit, 45 degrees by default), blocks instead, which is why the player never climbs the ball.

The player starts exactly flush with the floor, the hardest pose for a swept character, and moves freely from the first frame. Gravity is integrated by hand in on_fixed_update: a character’s velocity is its own to set.

Controls: WASD or the left stick (on-screen on a touch device) move the player, drag or the Left and Right arrows orbit the camera, and R (Y on a pad) reloads the scene.

Source

  1# /// simvx
  2# tags = ["3d", "physics", "character"]
  3# ///
  4"""Character Presence: a character body is an ordinary kinematic body.
  5
  6``CharacterBody3D`` is a ``KINEMATIC`` physics body plus ``move_and_slide``,
  7the swept movement helper. Everything else about it is what any body gets, and
  8this scene puts each consequence on screen at once:
  9
 10- **Queries see it.** A ray cast through ``arena.physics.raycast`` crosses the
 11  player's path and comes back with the ``Player`` node itself as ``hit.node``.
 12- **Areas see it.** The yellow ``Area3D`` fires ``body_entered`` and
 13  ``body_exited`` with the player as the payload, and never pushes it.
 14- **Dynamic bodies rest on it.** The orange crate falls onto the player's head
 15  and stays there, then is left behind when the player walks off: a character
 16  does not carry what rides on it.
 17- **Characters block each other.** The red ``Blocker`` is a second
 18  ``CharacterBody3D`` that never moves and stops the player like a wall.
 19- **Pushing is the game's rule.** By default a character shoves whatever
 20  dynamic body it walks into (``push_factor = 1.0``). This player sets
 21  ``push_factor=0.0`` and pushes for itself: after each move, ``collisions``
 22  lists every blocking contact as a ``SweepHit`` (the other body's handle, the
 23  contact point, and a normal pointing back at the player), and the player
 24  applies an impulse along ``-normal`` to the bodies in its ``pushable`` set.
 25  The green ball is in that set and rolls away; the crate is just as dynamic,
 26  is not listed, and does not move when the player walks into it.
 27- **Steps are climbed.** ``step_height=0.5`` lets the player walk up the low
 28  green step. Anything taller, or too steep to stand on (``slope_limit``, 45
 29  degrees by default), blocks instead, which is why the player never climbs
 30  the ball.
 31
 32The player starts exactly flush with the floor, the hardest pose for a swept
 33character, and moves freely from the first frame. Gravity is integrated by
 34hand in ``on_fixed_update``: a character's velocity is its own to set.
 35
 36Controls: WASD or the left stick (on-screen on a touch device) move the player,
 37drag or the Left and Right arrows orbit the camera, and R (Y on a pad) reloads the scene.
 38"""
 39
 40from simvx.core import (
 41    Anchor,
 42    Area3D,
 43    Axis1D,
 44    Axis2D,
 45    BodyMode,
 46    BoxShape3D,
 47    CanvasLayer,
 48    CharacterBody3D,
 49    Controller,
 50    DirectionalLight3D,
 51    Input,
 52    Key,
 53    Label,
 54    Material,
 55    Mesh,
 56    MeshInstance3D,
 57    Node3D,
 58    OrbitCamera3D,
 59    PhysicsBody3D,
 60    PhysicsRoot,
 61    SphereShape3D,
 62    Text2D,
 63    TouchController,
 64    Vec3,
 65    WorldEnvironment,
 66)
 67
 68GRAVITY = 18.0
 69SPEED = 4.0
 70PUSH_IMPULSE = 0.25  # applied once per fixed step to each pushable body touched
 71CHARACTER_HALF = Vec3(0.4, 0.9, 0.4)
 72BALL_SPAWN = Vec3(3.2, 0.45, -2.6)
 73STEP_TOP = 0.3
 74CRATE_ON_HEAD_Y = CHARACTER_HALF.y * 2 - 0.2  # a crate centre above this sits on the player's head
 75# The ray starts between the trigger and the player: a query reports sensors
 76# too, so starting further left would hit the trigger first.
 77RAY_FROM = Vec3(-3.0, CHARACTER_HALF.y, 0.0)
 78
 79
 80def visual(mesh: Mesh, colour: tuple, glow: float = 0.0) -> MeshInstance3D:
 81    r, g, b, _ = colour
 82    return MeshInstance3D(
 83        mesh=mesh, material=Material(colour=colour, emissive_colour=(r * glow, g * glow, b * glow, 1.0), roughness=0.55)
 84    )
 85
 86
 87def boxed(body, colour: tuple, glow: float = 0.0):
 88    """Give a box-shaped body a mesh the size of its collider."""
 89    body.add_child(visual(Mesh.box(body.shape.half_extents * 2), colour, glow))
 90    return body
 91
 92
 93def static(name: str, half: tuple, at: tuple, colour: tuple) -> PhysicsBody3D:
 94    body = PhysicsBody3D(name=name, mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=half), position=at)
 95    return boxed(body, colour)
 96
 97
 98class Player(CharacterBody3D):
 99    """Walks with the ``move`` action and pushes only the bodies in ``pushable``."""
100
101    def __init__(self, **kwargs):
102        super().__init__(**kwargs)
103        self.pushable: set[int] = set()  # body handles, the currency of ``collisions``
104
105    def on_fixed_update(self, dt: float):
106        move = Input.value("move") * SPEED  # +y is back, toward +z
107        fall = 0.0 if self.is_on_floor() else self.velocity.y - GRAVITY * dt
108        self.velocity = Vec3(move.x, fall, move.y)
109        self.move_and_slide(dt)
110        for hit in self.collisions:
111            if hit.body in self.pushable:
112                self.world.apply_impulse(hit.body, -hit.normal * PUSH_IMPULSE, at=hit.point)
113
114
115class CharacterPresence(Node3D):
116    input_context = {
117        "move": [Axis2D.WASD, Controller.LEFT_STICK],
118        "orbit": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
119        "reset": [Key.R, Controller.Y],
120    }
121
122    def on_ready(self):
123        self.add_child(WorldEnvironment(ambient_light_colour=(0.30, 0.33, 0.40, 1.0), ambient_light_energy=0.9))
124        self.add_child(DirectionalLight3D(colour=(1.0, 0.96, 0.86), intensity=1.2, direction=(-8, -14, -10)))
125        self.camera = self.add_child(OrbitCamera3D(pivot=Vec3(0, 1, 0), distance=16.0, yaw=0.6, pitch=-0.52))
126
127        self.arena = arena = self.add_child(PhysicsRoot(gravity=Vec3(0, -GRAVITY, 0)))
128        # The floor's top face is y = 0, so a character centred at its half-height stands flush on it.
129        arena.add_child(static("Floor", (24, 0.5, 24), (0, -0.5, 0), (0.16, 0.17, 0.2, 1)))
130        arena.add_child(static("Step", (4, STEP_TOP / 2, 2), (0, STEP_TOP / 2, 6), (0.22, 0.4, 0.3, 1)))
131        arena.add_child(static("Wall", (8, 1.5, 0.5), (0, 1.5, -7), (0.3, 0.28, 0.34, 1)))
132
133        self.player = Player(
134            name="Player",
135            shape=BoxShape3D(half_extents=CHARACTER_HALF),
136            position=(0, CHARACTER_HALF.y, 0),
137            step_height=0.5,
138            push_factor=0.0,
139        )
140        arena.add_child(boxed(self.player, (0.2, 0.75, 1.0, 1), glow=0.35))
141        blocker = CharacterBody3D(
142            name="Blocker", shape=BoxShape3D(half_extents=CHARACTER_HALF), position=(6, CHARACTER_HALF.y, 0)
143        )
144        arena.add_child(boxed(blocker, (1.0, 0.45, 0.25, 1), glow=0.3))
145
146        self.trigger = Area3D(name="Trigger", shape=BoxShape3D(half_extents=(1.6, 1.2, 1.6)), position=(-6, 1.2, 0))
147        arena.add_child(boxed(self.trigger, (0.95, 0.85, 0.2, 0.25), glow=0.4))
148        self.entered = self.exited = "-"
149        self.trigger.body_entered.connect(self._on_entered)
150        self.trigger.body_exited.connect(self._on_exited)
151
152        self.crate = PhysicsBody3D(
153            name="Crate",
154            mode=BodyMode.DYNAMIC,
155            mass=1.0,
156            shape=BoxShape3D(half_extents=(0.35, 0.35, 0.35)),
157            position=(0, 3.2, 0),
158        )
159        arena.add_child(boxed(self.crate, (0.85, 0.55, 0.15, 1), glow=0.25))
160        self.ball = PhysicsBody3D(
161            name="Ball", mode=BodyMode.DYNAMIC, mass=0.6, shape=SphereShape3D(radius=0.45), position=BALL_SPAWN
162        )
163        self.ball.add_child(visual(Mesh.sphere(radius=0.45, rings=16, segments=24), (0.55, 0.9, 0.45, 1)))
164        arena.add_child(self.ball)
165        self.player.pushable = {self.ball.handle}
166
167        hud = self.add_child(CanvasLayer())
168        self.readout = hud.add_child(Text2D(position=(12, 12)))
169        # Example help overlay: not part of the technique shown.
170        hud.add_child(
171            Label(
172                "WASD move, S climbs the step | drag or Left/Right orbit | R reset", anchor=Anchor.BOTTOM_LEFT, inset=12
173            )
174        )
175        hud.add_child(TouchController(buttons=(Controller.Y,)))
176
177    def _on_entered(self, body):
178        self.entered = body.name
179
180    def _on_exited(self, body):
181        self.exited = body.name
182
183    def on_update(self, dt: float):
184        self.camera.orbit(Input.value("orbit") * 1.5 * dt, 0.0)
185        if Input.just_pressed("reset"):
186            self.tree.change_scene(CharacterPresence())
187
188        hit = self.arena.physics.raycast(RAY_FROM, Vec3(1, 0, 0), distance=20.0)
189        ray = f"{hit.node.name} at {hit.distance:.2f}" if hit else "-"
190        inside = [body.name for body in self.trigger.get_overlapping_bodies()] or "-"
191        player = self.player.world_position
192        crate_y = float(self.crate.world_position.y)
193        pushed = (self.ball.world_position - BALL_SPAWN).length()
194        normals = ", ".join(f"({c.normal.x:.1f}, {c.normal.y:.1f}, {c.normal.z:.1f})" for c in self.player.collisions)
195        self.readout.text = "\n".join(
196            (
197                f"raycast hit: {ray}",
198                f"trigger: inside={inside}  entered={self.entered}  exited={self.exited}",
199                f"crate y={crate_y:.2f} {'ON THE PLAYER' if crate_y > CRATE_ON_HEAD_Y else 'on the floor'}",
200                f"player floor={self.player.is_on_floor()} wall={self.player.is_on_wall()} y={player.y:.3f}"
201                f"  collisions: {normals or '-'}",
202                f"step: {'ON TOP' if player.y > CHARACTER_HALF.y + STEP_TOP / 2 else 'below'}",
203                f"ball (pushable): {f'PUSHED {pushed:.2f}m' if pushed > 0.05 else 'at rest'}",
204            )
205        )
206
207
208if __name__ == "__main__":
209    from simvx.graphics import App
210
211    App(title="Character Presence", width=1280, height=720).run(CharacterPresence())