Character Presence¶
a character body is an ordinary kinematic body.
â–¶ Run in browserTags: 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.raycastreports the player by name when the ray crosses it;an Area3D trigger fires
body_entered/body_exitedwith 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 incollisions;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_heightis set; anything taller thanstep_heightblocks 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())