Character Presence¶

a character body is an ordinary kinematic body.

â–¶ Run in browser

Tags: physics

A CharacterBody3D is a BodyMode.KINEMATIC physics body with a swept move_and_slide helper, not a separate kind of thing. This scene puts every consequence of that on screen at once, so it can be checked by eye:

  • a per-frame raycast through the node-level self.physics.raycast reports the player by name when the ray crosses it;

  • an Area3D trigger fires body_entered / body_exited with the player node as the payload, and never pushes it;

  • a DYNAMIC crate falls and comes to rest on the player’s head, then is left behind when the player walks off (a character does not carry riders);

  • a second CharacterBody3D blocks the player’s path;

  • walking into the crate stops the player without shoving it, because this player sets push_factor = 0 (the engine’s own push is on by default) and owns the rule itself; the contacts are reported in collisions;

  • a ball the player CAN push, because this scene asks for it: every blocking contact comes back in collisions, so the player applies its own impulse to the bodies it lists as pushable. With the engine’s push turned off, WHICH bodies move is entirely the game’s decision, which is why the ball rolls while the crate, just as dynamic and never listed, stays exactly where it is;

  • a low step in front of the player is climbed rather than walked into, because step_height is set; anything taller than step_height blocks like a wall, and so does anything too steep to stand on – walk this player into the ball from any angle and it never gets a foothold up the flank. Its collider is a box, and that is part of why: a flat-based character lands squarely on whatever it steps onto, while one with a rounded base mounts a step by perching on the edge, and that perch is harder to tell apart from a grip on a curved surface;

  • the player is authored exactly flush with the floor plane, the hardest starting pose for a swept character, and it moves freely from the first frame.

Controls: WASD - move the player (walk into the ball to push it) Left/Right - orbit camera R - reset the crate, the ball and both characters Escape - quit

Run (builtin): uv run python examples/features/physics/character_presence.py Run (Jolt): uv run python examples/features/physics/character_presence.py –jolt Headless check: uv run python examples/features/physics/character_presence.py –test Headless (Jolt): uv run python examples/features/physics/character_presence.py –test –jolt Web export: uv run simvx export web examples/features/physics/character_presence.py

The two backends must behave identically: a character is a kinematic body on both, so the crate rests on the player and the blocker blocks it either way.

Source¶

  1"""Character Presence: a character body is an ordinary kinematic body.
  2
  3A ``CharacterBody3D`` is a ``BodyMode.KINEMATIC`` physics body with a swept
  4``move_and_slide`` helper, not a separate kind of thing. This scene puts every
  5consequence of that on screen at once, so it can be checked by eye:
  6
  7  - a per-frame **raycast** through the node-level ``self.physics.raycast`` reports
  8    the player by name when the ray crosses it;
  9  - an **Area3D trigger** fires ``body_entered`` / ``body_exited`` with the player
 10    node as the payload, and never pushes it;
 11  - a DYNAMIC **crate** falls and comes to rest on the player's head, then is left
 12    behind when the player walks off (a character does not carry riders);
 13  - a second **CharacterBody3D** blocks the player's path;
 14  - walking into the crate stops the player without shoving it, because this
 15    player sets ``push_factor = 0`` (the engine's own push is on by default) and
 16    owns the rule itself; the contacts are reported in ``collisions``;
 17  - a **ball** the player CAN push, because this scene asks for it: every blocking
 18    contact comes back in ``collisions``, so the player applies its own impulse to
 19    the bodies it lists as pushable. With the engine's push turned off, WHICH
 20    bodies move is entirely the game's decision, which is why the ball rolls while
 21    the crate, just as dynamic and never listed, stays exactly where it is;
 22  - a low **step** in front of the player is climbed rather than walked into,
 23    because ``step_height`` is set; anything taller than ``step_height`` blocks
 24    like a wall, and so does anything too steep to stand on -- walk this player
 25    into the ball from any angle and it never gets a foothold up the flank. Its
 26    collider is a box, and that is part of why: a flat-based character lands
 27    squarely on whatever it steps onto, while one with a rounded base mounts a
 28    step by perching on the edge, and that perch is harder to tell apart from a
 29    grip on a curved surface;
 30  - the player is authored exactly flush with the floor plane, the hardest
 31    starting pose for a swept character, and it moves freely from the first frame.
 32
 33Controls:
 34    WASD          - move the player (walk into the ball to push it)
 35    Left/Right    - orbit camera
 36    R             - reset the crate, the ball and both characters
 37    Escape        - quit
 38
 39Run (builtin):    uv run python examples/features/physics/character_presence.py
 40Run (Jolt):       uv run python examples/features/physics/character_presence.py --jolt
 41Headless check:   uv run python examples/features/physics/character_presence.py --test
 42Headless (Jolt):  uv run python examples/features/physics/character_presence.py --test --jolt
 43Web export:       uv run simvx export web examples/features/physics/character_presence.py
 44
 45The two backends must behave identically: a character is a kinematic body on both,
 46so the crate rests on the player and the blocker blocks it either way.
 47"""
 48
 49import math
 50
 51from simvx.core import (
 52    Area3D,
 53    BodyMode,
 54    BoxShape3D,
 55    Camera3D,
 56    CharacterBody3D,
 57    DirectionalLight3D,
 58    Input,
 59    Key,
 60    Material,
 61    Mesh,
 62    MeshInstance3D,
 63    Node3D,
 64    PhysicsBody3D,
 65    PhysicsRoot,
 66    SphereShape3D,
 67    Text2D,
 68    Vec3,
 69    WorldEnvironment,
 70)
 71
 72# Geometry. The floor's TOP face is exactly y = 0, and the player's half-height is
 73# PLAYER_HALF, so authoring the player at y = PLAYER_HALF is bit-exactly flush.
 74FLOOR_HALF = Vec3(24.0, 0.5, 24.0)
 75PLAYER_HALF = 0.9
 76PLAYER_SPEED = 4.0
 77GRAVITY = 18.0
 78CRATE_HALF = 0.35
 79CRATE_SPAWN = Vec3(0.0, 3.2, 0.0)
 80BALL_RADIUS = 0.45
 81BALL_MASS = 0.6
 82BALL_SPAWN = Vec3(3.2, BALL_RADIUS, -2.6)
 83#: Impulse the player applies per fixed step to each body it is touching. The
 84#: engine never applies this by itself: see ``Player._push_what_it_touched``.
 85PUSH_IMPULSE = 0.25
 86BLOCKER_AT = Vec3(6.0, PLAYER_HALF, 0.0)
 87WALL_AT = Vec3(0.0, 1.5, -7.0)
 88TRIGGER_AT = Vec3(-6.0, 1.2, 0.0)
 89#: A low step the player walks up. Its top is well inside PLAYER_STEP_HEIGHT.
 90STEP_HALF = Vec3(4.0, 0.15, 2.0)
 91STEP_AT = Vec3(0.0, STEP_HALF.y, 6.0)
 92PLAYER_STEP_HEIGHT = 0.5
 93#: Backend override for the scene's PhysicsRoot. ``None`` means "resolve normally"
 94#: (project setting, then auto-discovery, then Builtin); ``--jolt`` pins the
 95#: optional native backend so the two can be compared side by side by hand.
 96BACKEND: str | None = None
 97#: The debug ray starts BETWEEN the trigger and the player: a query is
 98#: observer-decides, so it reports a sensor too, and starting further left would
 99#: report the trigger's sensor body rather than the character.
100_RAY_FROM = Vec3(-3.0, PLAYER_HALF, 0.0)
101
102
103def _visual(mesh: Mesh, scale: tuple[float, float, float], colour: tuple, *, emissive: float = 0.0):
104    """A MeshInstance3D child that inherits its body's transform."""
105    return MeshInstance3D(
106        mesh=mesh,
107        material=Material(
108            colour=colour,
109            emissive_colour=(colour[0] * emissive, colour[1] * emissive, colour[2] * emissive, 1.0),
110            roughness=0.55,
111        ),
112        scale=scale,
113    )
114
115
116class Player(CharacterBody3D):
117    """The WASD-driven character. Gravity is integrated by hand, as always."""
118
119    def __init__(self, **kwargs):
120        super().__init__(
121            shape=BoxShape3D(half_extents=Vec3(0.4, PLAYER_HALF, 0.4)),
122            step_height=PLAYER_STEP_HEIGHT,
123            #: Off, so this scene can own the rule: the engine's shove is on by
124            #: default and would move the crate as readily as the ball.
125            push_factor=0.0,
126            **kwargs,
127        )
128        self.add_child(_visual(Mesh.cube(), (0.8, PLAYER_HALF * 2, 0.8), (0.20, 0.75, 1.0, 1.0), emissive=0.35))
129        #: Seam handles this game is willing to shove, filled in by the scene.
130        #: ``collisions`` reports handles, so gameplay code that wants to decide
131        #: per body keeps its own set rather than resolving nodes every frame.
132        self.pushable: set[int] = set()
133
134    def on_fixed_update(self, dt: float):
135        vx = Input.is_action_pressed("move_right") - Input.is_action_pressed("move_left")
136        vz = Input.is_action_pressed("move_back") - Input.is_action_pressed("move_forward")
137        vy = 0.0 if self.is_on_floor() else self.velocity.y - GRAVITY * dt
138        self.velocity = Vec3(vx * PLAYER_SPEED, vy, vz * PLAYER_SPEED)
139        self.move_and_slide(dt)
140        self._push_what_it_touched()
141
142    def _push_what_it_touched(self):
143        """Push the bodies this game says are pushable, with the engine's push off.
144
145        ``move_and_slide`` hands back every blocking contact it resolved in
146        ``collisions``. Each normal points away from the body that was hit and
147        toward the player, so shoving that body means an impulse along
148        ``-normal``.
149
150        Which bodies are pushable is a game rule here rather than a physics one,
151        so the scene registers them in ``pushable``. That is what turning
152        ``push_factor`` off buys: here the ball rolls and the crate, which is just
153        as dynamic, stays exactly where it is.
154        """
155        world = self.world
156        if world is None:
157            return
158        for hit in self.collisions:
159            if hit.body in self.pushable:
160                world.apply_impulse(hit.body, -hit.normal * PUSH_IMPULSE, at=hit.point)
161
162
163class Blocker(CharacterBody3D):
164    """A second character that never moves. It still blocks the player."""
165
166    def __init__(self, **kwargs):
167        super().__init__(shape=BoxShape3D(half_extents=Vec3(0.4, PLAYER_HALF, 0.4)), **kwargs)
168        self.add_child(_visual(Mesh.cube(), (0.8, PLAYER_HALF * 2, 0.8), (1.0, 0.45, 0.25, 1.0), emissive=0.3))
169
170
171class CharacterPresenceScene(Node3D):
172    """Floor + player + blocker + wall + crate + trigger, and a HUD reading it all."""
173
174    #: Declared on the ROOT so the tree registers them before enter-tree, which is
175    #: also what makes them work on the web export (which never runs ``main()``).
176    input_actions = {
177        "move_forward": [Key.W],
178        "move_back": [Key.S],
179        "move_left": [Key.A],
180        "move_right": [Key.D],
181        "orbit_left": [Key.LEFT],
182        "orbit_right": [Key.RIGHT],
183        "reset": [Key.R],
184        "quit": [Key.ESCAPE],
185    }
186
187    def on_ready(self):
188        self.add_child(WorldEnvironment(ambient_light_colour=(0.30, 0.33, 0.40, 1.0), ambient_light_energy=0.9))
189        sun = DirectionalLight3D(position=(8.0, 14.0, 10.0))
190        sun.colour = (1.0, 0.96, 0.86)
191        sun.intensity = 1.2
192        sun.look_at((0.0, 0.0, 0.0))
193        self.add_child(sun)
194
195        self._root = self.add_child(PhysicsRoot(gravity=Vec3(0.0, -GRAVITY, 0.0), backend=BACKEND))
196        self._build_world()
197        self._build_hud()
198
199        self._cam_yaw = 0.6
200        self._camera = self.add_child(Camera3D(fov=55.0))
201        self._update_camera()
202
203        self._entered = ""
204        self._exited = ""
205        self._trigger.body_entered.connect(self._on_trigger_enter)
206        self._trigger.body_exited.connect(self._on_trigger_exit)
207
208    # -- scene ---------------------------------------------------------------
209
210    def _build_world(self):
211        floor = PhysicsBody3D(name="Floor", mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=FLOOR_HALF))
212        floor.position = Vec3(0.0, -FLOOR_HALF.y, 0.0)  # top face exactly at y = 0
213        floor.add_child(_visual(Mesh.cube(), tuple(FLOOR_HALF * 2), (0.16, 0.17, 0.20, 1.0)))
214        self._root.add_child(floor)
215
216        step = PhysicsBody3D(name="Step", mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=STEP_HALF))
217        step.position = STEP_AT
218        step.add_child(_visual(Mesh.cube(), tuple(STEP_HALF * 2), (0.22, 0.40, 0.30, 1.0)))
219        self._root.add_child(step)
220
221        wall = PhysicsBody3D(name="Wall", mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=Vec3(8.0, 1.5, 0.5)))
222        wall.position = WALL_AT
223        wall.add_child(_visual(Mesh.cube(), (16.0, 3.0, 1.0), (0.30, 0.28, 0.34, 1.0)))
224        self._root.add_child(wall)
225
226        # Authored bit-exactly flush with the floor plane on purpose: this is the
227        # pose that starts every sweep at zero separation.
228        self._player = Player(name="Player")
229        self._player.position = Vec3(0.0, PLAYER_HALF, 0.0)
230        self._root.add_child(self._player)
231
232        self._blocker = Blocker(name="Blocker")
233        self._blocker.position = BLOCKER_AT
234        self._root.add_child(self._blocker)
235
236        self._trigger = Area3D(name="Trigger", shape=BoxShape3D(half_extents=Vec3(1.6, 1.2, 1.6)))
237        self._trigger.position = TRIGGER_AT
238        self._trigger.add_child(_visual(Mesh.cube(), (3.2, 2.4, 3.2), (0.95, 0.85, 0.20, 0.25), emissive=0.4))
239        self._root.add_child(self._trigger)
240
241        self._spawn_crate()
242        self._spawn_ball()
243
244    def _spawn_ball(self):
245        """A DYNAMIC ball the player may shove, registered as pushable."""
246        ball = PhysicsBody3D(
247            name="Ball",
248            mode=BodyMode.DYNAMIC,
249            mass=BALL_MASS,
250            shape=SphereShape3D(radius=BALL_RADIUS),
251        )
252        ball.position = BALL_SPAWN
253        ball.add_child(
254            _visual(Mesh.sphere(radius=BALL_RADIUS, rings=16, segments=24), (1.0, 1.0, 1.0), (0.55, 0.90, 0.45, 1.0))
255        )
256        self._root.add_child(ball)
257        self._ball = ball
258        self._player.pushable = {ball.handle}
259
260    def _spawn_crate(self):
261        crate = PhysicsBody3D(
262            name="Crate",
263            mode=BodyMode.DYNAMIC,
264            mass=1.0,
265            shape=BoxShape3D(half_extents=Vec3(CRATE_HALF, CRATE_HALF, CRATE_HALF)),
266        )
267        crate.position = CRATE_SPAWN
268        crate.add_child(_visual(Mesh.cube(), (CRATE_HALF * 2,) * 3, (0.85, 0.55, 0.15, 1.0), emissive=0.25))
269        self._root.add_child(crate)
270        self._crate = crate
271
272    def _build_hud(self):
273        self._ray_text = self.add_child(Text2D(text="raycast hit: -", position=(12, 12), font_scale=1.0))
274        self._trigger_text = self.add_child(Text2D(text="trigger: -", position=(12, 36), font_scale=1.0))
275        self._crate_text = self.add_child(Text2D(text="crate: -", position=(12, 60), font_scale=1.0))
276        self._state_text = self.add_child(Text2D(text="player: -", position=(12, 84), font_scale=1.0))
277        self._step_text = self.add_child(Text2D(text="step: -", position=(12, 108), font_scale=1.0))
278        self._ball_text = self.add_child(Text2D(text="ball: -", position=(12, 132), font_scale=1.0))
279        self.add_child(
280            Text2D(
281                text=(
282                    "WASD move (walk into the ball to push it, S climbs the step) | "
283                    "Left/Right arrows orbit | R reset | Esc quit"
284                ),
285                position=(12, 156),
286                font_scale=0.9,
287            )
288        )
289
290    # -- signals -------------------------------------------------------------
291
292    def _on_trigger_enter(self, node):
293        # The payload is a PhysicsObject3D: here, the CharacterBody3D player.
294        self._entered = type(node).__name__
295
296    def _on_trigger_exit(self, node):
297        self._exited = type(node).__name__
298
299    # -- per-frame -----------------------------------------------------------
300
301    def on_update(self, dt: float):
302        if Input.is_action_just_pressed("quit"):
303            self.app.quit()
304            return
305
306        turn = 1.5
307        self._cam_yaw += (Input.is_action_pressed("orbit_right") - Input.is_action_pressed("orbit_left")) * turn * dt
308        self._update_camera()
309        if Input.is_action_just_pressed("reset"):
310            self.reset()
311        self._update_hud()
312
313    def _update_camera(self):
314        radius, height = 14.0, 9.0
315        self._camera.position = Vec3(math.sin(self._cam_yaw) * radius, height, math.cos(self._cam_yaw) * radius)
316        self._camera.look_at(Vec3(0.0, 1.0, 0.0))
317
318    def _debug_raycast(self):
319        """A ray across the arena at the player's chest height, through the NODE API.
320
321        ``self.physics.raycast`` resolves the seam handle back to the scene node, and
322        a character joins that registry like any other body, so the hit comes back as
323        the ``Player`` node itself rather than as an opaque handle.
324        """
325        hit = self._player.physics.raycast(_RAY_FROM, Vec3(1.0, 0.0, 0.0), distance=20.0)
326        if hit is None:
327            return "-"
328        name = type(hit.node).__name__
329        return f"{name} ({'PLAYER' if hit.node is self._player else 'other'}) at {hit.distance:.2f}"
330
331    def _update_hud(self):
332        self._ray_text.text = f"raycast hit: {self._debug_raycast()}"
333        inside = [type(b).__name__ for b in self._trigger.get_overlapping_bodies()]
334        self._trigger_text.text = (
335            f"trigger: inside={inside or '-'}  entered={self._entered or '-'}  exited={self._exited or '-'}"
336        )
337        crate_y = float(self._crate.world_position.y)
338        riding = crate_y > PLAYER_HALF * 2 - 0.2
339        self._crate_text.text = f"crate y={crate_y:.2f} {'ON THE PLAYER' if riding else 'on the floor'}"
340        hits = (
341            ", ".join(
342                f"n=({float(c.normal.x):.1f},{float(c.normal.y):.1f},{float(c.normal.z):.1f})"
343                for c in self._player.collisions
344            )
345            or "-"
346        )
347        self._state_text.text = (
348            f"player floor={self._player.is_on_floor()} wall={self._player.is_on_wall()} "
349            f"y={float(self._player.world_position.y):.3f} collisions: {hits}"
350        )
351        pos = self._player.world_position
352        step_top = STEP_AT.y + STEP_HALF.y
353        on_step = float(pos.z) > STEP_AT.z - STEP_HALF.z and float(pos.y) > PLAYER_HALF + step_top * 0.5
354        self._step_text.text = (
355            f"step (top y={step_top:.2f}, step_height={PLAYER_STEP_HEIGHT:.2f}): "
356            f"{'ON TOP' if on_step else 'below'}  z={float(pos.z):.2f}"
357        )
358        bp = self._ball.world_position
359        moved = (bp - BALL_SPAWN).length()
360        self._ball_text.text = (
361            f"ball (pushable) at ({float(bp.x):.2f}, {float(bp.z):.2f})  "
362            f"{'PUSHED ' + format(moved, '.2f') + 'm' if moved > 0.05 else 'at rest'}"
363        )
364
365    def reset(self):
366        self._player.position = Vec3(0.0, PLAYER_HALF, 0.0)
367        self._player.velocity = Vec3()
368        self._blocker.position = BLOCKER_AT
369        self._crate.destroy()
370        self._spawn_crate()
371        self._ball.destroy()
372        self._spawn_ball()
373        self._entered = ""
374        self._exited = ""
375
376
377def _selftest() -> bool:
378    """Headless smoke check: the scene renders, and the three claims easiest to miss hold."""
379    from simvx.core.physics.nodes import PhysicsObject3D
380    from simvx.graphics import App
381    from simvx.graphics.testing import assert_not_blank, save_png
382
383    app = App(title="CharacterPresence", width=1280, height=720, visible=False)
384    scene = CharacterPresenceScene(name="CharacterPresenceScene")
385    frames = app.run_headless(scene, frames=180, capture_frames=[179])
386    assert_not_blank(frames[0])
387    save_png(frames[0], "/tmp/character_presence.png")
388
389    player = scene._player
390    ok = True
391    print(f"backend: {type(player.world).__name__}")
392
393    # A character joins the body registry like anything else, so a node-level
394    # query resolves it back to the scene node.
395    hit = player.physics.raycast(_RAY_FROM, Vec3(1.0, 0.0, 0.0), distance=20.0)
396    ray_ok = hit is not None and hit.node is player and isinstance(hit.node, PhysicsObject3D)
397    print(f"raycast resolves the character node: {ray_ok}")
398    ok = ok and ray_ok
399
400    # The crate that fell in the first 180 frames is still up on the player's head.
401    crate_y = float(scene._crate.world_position.y)
402    rest_ok = crate_y > PLAYER_HALF * 2 - 0.2
403    print(f"crate rests on the character: {rest_ok} (y={crate_y:.3f}, player top={PLAYER_HALF * 2:.2f})")
404    ok = ok and rest_ok
405
406    # Drive the player into the blocker and confirm it stops without pushing.
407    blocker_x0 = float(scene._blocker.world_position.x)
408    for _ in range(180):
409        player.velocity = Vec3(PLAYER_SPEED, 0.0, 0.0)
410        player.move_and_slide(1 / 60)
411    px = float(player.world_position.x)
412    block_ok = px < BLOCKER_AT.x - 0.7 and abs(float(scene._blocker.world_position.x) - blocker_x0) < 0.05
413    print(f"blocked by the other character without pushing it: {block_ok} (player x={px:.3f})")
414    ok = ok and block_ok
415    print(f"blocking contacts reported: {len(player.collisions)}")
416    ok = ok and len(player.collisions) >= 1
417
418    # Walk back across the ball on the diagonal two held keys give, which is what
419    # grazes its flank rather than meeting it head-on. The step-up probe classifies
420    # what it lands on against slope_limit, so for this box-collidered player a ball
421    # is not a staircase: it ends at the height it started at instead of ratcheting
422    # up the side.
423    diagonal = Vec3(-1.0, 0.0, -1.0).normalized() * PLAYER_SPEED
424    start_y = float(player.world_position.y)
425    peak_y = start_y
426    for _ in range(240):
427        player.velocity = diagonal
428        player.move_and_slide(1 / 60)
429        peak_y = max(peak_y, float(player.world_position.y))
430    climb_ok = peak_y < start_y + 0.05
431    print(f"the ball is not climbable: {climb_ok} (highest y={peak_y:.3f}, standing y={start_y:.3f})")
432    ok = ok and climb_ok
433
434    print("screenshot: /tmp/character_presence.png")
435    print("SELFTEST:", "PASS" if ok else "FAIL")
436    return ok
437
438
439if __name__ == "__main__":
440    import sys
441
442    if "--jolt" in sys.argv:
443        # Module scope, so on_ready sees it when the scene is built below.
444        BACKEND = "jolt"
445    if "--test" in sys.argv:
446        sys.exit(0 if _selftest() else 1)
447
448    from simvx.graphics import App
449
450    app = App(title=f"Character Presence ({BACKEND or 'builtin'})", width=1280, height=720)
451    app.run(CharacterPresenceScene())