Character Presence¶
a character body is an ordinary kinematic body.
▶ Run in browserTags: 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.raycastcrosses the player’s path and comes back with thePlayernode itself ashit.node.Areas see it. The yellow
Area3Dfiresbody_enteredandbody_exitedwith 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
Blockeris a secondCharacterBody3Dthat 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 setspush_factor=0.0and pushes for itself: after each move,collisionslists every blocking contact as aSweepHit(the other body’s handle, the contact point, and a normal pointing back at the player), and the player applies an impulse along-normalto the bodies in itspushableset. 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.5lets 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())