embedded_3dgfx/character.rs
1//! First-person character controller with axis-aligned box collision.
2//!
3//! The controller is deliberately decoupled from the physics engine so it
4//! works in `no_std` environments without any allocator. Collision is
5//! resolved against a caller-supplied slice of AABB obstacles.
6//!
7//! # Collision format
8//!
9//! Each obstacle is `[min_x, min_y, min_z, max_x, max_y, max_z]` in
10//! world space. Include a floor slab so the player lands on it, a ceiling
11//! slab so jumping is bounded, and a wall slab for every solid surface.
12//!
13//! ```no_run
14//! use embedded_3dgfx::character::CharacterController;
15//! use embedded_3dgfx::input::InputState;
16//! use nalgebra::Point3;
17//!
18//! let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
19//!
20//! let colliders: &[[f32; 6]] = &[
21//! // floor slab (player stands on Y=0)
22//! [-10.0, -0.5, -10.0, 10.0, 0.0, 10.0],
23//! // north wall
24//! [-10.0, 0.0, -10.5, 10.0, 4.0, -10.0],
25//! // south wall
26//! [-10.0, 0.0, 10.0, 10.0, 4.0, 10.5],
27//! ];
28//!
29//! let input = InputState { forward: 1.0, ..Default::default() };
30//! ch.tick(&input, 1.0 / 60.0, colliders);
31//! ```
32
33use core::f32::consts::PI;
34use nalgebra::{Point3, Vector3};
35
36#[cfg(not(feature = "std"))]
37use micromath::F32Ext;
38
39use crate::input::InputState;
40
41/// Number of AABB resolution passes per tick.
42///
43/// More passes → more stable corner contacts; 3 is enough for typical
44/// dungeon geometry.
45const RESOLVE_ITERS: usize = 3;
46
47/// First-person character controller.
48///
49/// `position` is the **foot point** — the lowest Y the player occupies.
50/// Eyes sit at `position.y + height * eye_fraction`.
51#[derive(Clone, Debug)]
52pub struct CharacterController {
53 /// Foot position (Y = the floor the player stands on).
54 pub position: Point3<f32>,
55
56 /// Current velocity in m/s.
57 ///
58 /// Horizontal components are overwritten by input every tick.
59 /// The vertical component accumulates gravity between frames.
60 pub velocity: Vector3<f32>,
61
62 /// Horizontal look direction, radians. 0 = facing −Z (into the screen).
63 /// Increases clockwise when viewed from above.
64 pub yaw: f32,
65
66 /// Vertical look angle, radians. Clamped to ±~84°.
67 pub pitch: f32,
68
69 /// Standing height in metres.
70 pub height: f32,
71
72 /// Horizontal half-width of the player AABB (metres).
73 pub radius: f32,
74
75 /// Fraction of `height` at which the eye / camera sits (default 0.86).
76 pub eye_fraction: f32,
77
78 /// `true` when the character is resting on a solid surface.
79 pub on_ground: bool,
80
81 /// Walk speed (m/s, used when `InputState::sprint` is false).
82 pub walk_speed: f32,
83
84 /// Run / sprint speed (m/s).
85 pub run_speed: f32,
86
87 /// Upward velocity applied on jump (m/s).
88 pub jump_speed: f32,
89
90 /// Gravitational acceleration (m/s², positive = downward).
91 pub gravity: f32,
92}
93
94impl CharacterController {
95 /// Create a controller with sensible defaults.
96 ///
97 /// The player spawns at `pos` with feet touching the floor (Y=pos.y).
98 /// Call [`tick`](Self::tick) once per frame to advance the simulation.
99 pub fn new(pos: Point3<f32>) -> Self {
100 Self {
101 position: pos,
102 velocity: Vector3::zeros(),
103 yaw: 0.0,
104 pitch: 0.0,
105 height: 1.75,
106 radius: 0.28,
107 eye_fraction: 0.86,
108 on_ground: false,
109 walk_speed: 4.5,
110 run_speed: 8.5,
111 jump_speed: 5.5,
112 gravity: 14.0,
113 }
114 }
115
116 // ── Direction helpers ─────────────────────────────────────────────────────
117
118 /// Unit vector pointing forward in the horizontal plane (ignores pitch).
119 pub fn forward_flat(&self) -> Vector3<f32> {
120 Vector3::new(self.yaw.sin(), 0.0, -self.yaw.cos())
121 }
122
123 /// Unit vector pointing right in the horizontal plane.
124 pub fn right_flat(&self) -> Vector3<f32> {
125 Vector3::new(self.yaw.cos(), 0.0, self.yaw.sin())
126 }
127
128 /// Full 3-D look direction (yaw + pitch).
129 pub fn look_dir(&self) -> Vector3<f32> {
130 let cp = self.pitch.cos();
131 Vector3::new(cp * self.yaw.sin(), self.pitch.sin(), -cp * self.yaw.cos())
132 }
133
134 /// Camera / eye position in world space.
135 pub fn eye_position(&self) -> Point3<f32> {
136 Point3::new(
137 self.position.x,
138 self.position.y + self.height * self.eye_fraction,
139 self.position.z,
140 )
141 }
142
143 /// Point the camera should target (`eye_position + look_dir`).
144 pub fn look_target(&self) -> Point3<f32> {
145 self.eye_position() + self.look_dir()
146 }
147
148 /// Write the eye position and look target into the engine camera.
149 pub fn apply_to_camera(&self, engine: &mut crate::K3dengine) {
150 engine.camera.set_position(self.eye_position());
151 engine.camera.set_target(self.look_target());
152 }
153
154 // ── Simulation ────────────────────────────────────────────────────────────
155
156 /// Advance one frame of simulation.
157 ///
158 /// # Arguments
159 /// * `input` — this frame's input (direction, look deltas, jump, sprint)
160 /// * `dt` — elapsed time in seconds (clamp to ≤ 0.05 to avoid
161 /// tunnelling on lag spikes)
162 /// * `colliders` — world geometry as `[min_x, min_y, min_z, max_x, max_y,
163 /// max_z]` boxes. Include floor, ceiling, and all walls.
164 pub fn tick(&mut self, input: &InputState, dt: f32, colliders: &[[f32; 6]]) {
165 let dt = dt.min(0.05);
166
167 // ── Orientation ───────────────────────────────────────────────────────
168 self.yaw += input.look_yaw;
169 if self.yaw > PI {
170 self.yaw -= 2.0 * PI;
171 }
172 if self.yaw < -PI {
173 self.yaw += 2.0 * PI;
174 }
175 // ±84° pitch limit (avoids gimbal singularities)
176 self.pitch = (self.pitch + input.look_pitch).clamp(-1.466, 1.466);
177
178 // ── Horizontal velocity from input ────────────────────────────────────
179 let speed = if input.sprint {
180 self.run_speed
181 } else {
182 self.walk_speed
183 };
184 let fwd = self.forward_flat();
185 let rgt = self.right_flat();
186 self.velocity.x = (fwd.x * input.forward + rgt.x * input.strafe) * speed;
187 self.velocity.z = (fwd.z * input.forward + rgt.z * input.strafe) * speed;
188
189 // ── Vertical velocity (gravity / jump) ────────────────────────────────
190 if self.on_ground {
191 if input.jump {
192 self.velocity.y = self.jump_speed;
193 self.on_ground = false;
194 } else {
195 self.velocity.y = 0.0;
196 }
197 } else {
198 self.velocity.y -= self.gravity * dt;
199 }
200
201 // ── Integrate ─────────────────────────────────────────────────────────
202 let mut pos = self.position + self.velocity * dt;
203 self.on_ground = false;
204
205 // ── AABB collision resolution ─────────────────────────────────────────
206 // Multiple passes let corner contacts settle properly.
207 for _ in 0..RESOLVE_ITERS {
208 for &[cx0, cy0, cz0, cx1, cy1, cz1] in colliders {
209 // Player AABB (foot-to-head, square in XZ)
210 let px0 = pos.x - self.radius;
211 let px1 = pos.x + self.radius;
212 let py0 = pos.y;
213 let py1 = pos.y + self.height;
214 let pz0 = pos.z - self.radius;
215 let pz1 = pos.z + self.radius;
216
217 // Broad-phase
218 if px1 <= cx0 || px0 >= cx1 || py1 <= cy0 || py0 >= cy1 || pz1 <= cz0 || pz0 >= cz1
219 {
220 continue;
221 }
222
223 // Penetration depths along each axis
224 let ox_a = px1 - cx0; // push player left (−X)
225 let ox_b = cx1 - px0; // push player right (+X)
226 let oy_a = py1 - cy0; // push player down (−Y, ceiling)
227 let oy_b = cy1 - py0; // push player up (+Y, floor)
228 let oz_a = pz1 - cz0; // push player −Z
229 let oz_b = cz1 - pz0; // push player +Z
230
231 let ox = ox_a.min(ox_b);
232 let oy = oy_a.min(oy_b);
233 let oz = oz_a.min(oz_b);
234
235 if oy <= ox && oy <= oz {
236 // Vertical contact
237 if oy_b < oy_a {
238 // Floor: push player up
239 pos.y += oy_b;
240 self.on_ground = true;
241 if self.velocity.y < 0.0 {
242 self.velocity.y = 0.0;
243 }
244 } else {
245 // Ceiling: push player down
246 pos.y -= oy_a;
247 if self.velocity.y > 0.0 {
248 self.velocity.y = 0.0;
249 }
250 }
251 } else if ox <= oz {
252 // X-axis wall contact
253 if ox_a < ox_b {
254 pos.x -= ox_a;
255 } else {
256 pos.x += ox_b;
257 }
258 self.velocity.x = 0.0;
259 } else {
260 // Z-axis wall contact
261 if oz_a < oz_b {
262 pos.z -= oz_a;
263 } else {
264 pos.z += oz_b;
265 }
266 self.velocity.z = 0.0;
267 }
268 }
269 }
270
271 self.position = pos;
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 extern crate std;
278 use super::*;
279
280 fn no_colliders() -> &'static [[f32; 6]] {
281 &[]
282 }
283
284 #[test]
285 fn forward_flat_faces_neg_z_at_yaw_zero() {
286 let ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
287 let fwd = ch.forward_flat();
288 assert!((fwd.x).abs() < 1e-5);
289 assert!((fwd.z + 1.0).abs() < 1e-5);
290 }
291
292 #[test]
293 fn gravity_applies_when_airborne() {
294 let mut ch = CharacterController::new(Point3::new(0.0, 5.0, 0.0));
295 ch.on_ground = false;
296 let input = InputState::default();
297 ch.tick(&input, 1.0, no_colliders());
298 assert!(ch.position.y < 5.0, "should have fallen");
299 }
300
301 #[test]
302 fn floor_collider_stops_fall() {
303 let mut ch = CharacterController::new(Point3::new(0.0, 1.0, 0.0));
304 ch.on_ground = false;
305 ch.velocity.y = -10.0;
306 let floor = [[-5.0_f32, -0.5, -5.0, 5.0, 0.0, 5.0]];
307 let input = InputState::default();
308 // dt is clamped to 0.05 s internally; run enough steps to reach the floor
309 for _ in 0..8 {
310 ch.tick(&input, 1.0 / 60.0, &floor);
311 }
312 assert!(ch.on_ground, "should be on ground after collision");
313 assert!(ch.position.y.abs() < 1e-2, "foot should be at y≈0");
314 }
315
316 #[test]
317 fn wall_collider_stops_x_movement() {
318 let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
319 ch.on_ground = true;
320 // Wall to the right at X=2
321 let wall = [[1.9_f32, -1.0, -5.0, 10.0, 5.0, 5.0]];
322 let input = InputState {
323 strafe: 1.0,
324 ..Default::default()
325 };
326 for _ in 0..30 {
327 ch.tick(&input, 1.0 / 60.0, &wall);
328 }
329 // Should not have passed through the wall
330 assert!(ch.position.x + ch.radius <= 1.9 + 0.05);
331 }
332
333 #[test]
334 fn jump_increases_y_velocity() {
335 let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
336 ch.on_ground = true;
337 let input = InputState {
338 jump: true,
339 ..Default::default()
340 };
341 ch.tick(&input, 1.0 / 60.0, no_colliders());
342 assert!(
343 ch.velocity.y > 0.0,
344 "should have positive Y velocity after jump"
345 );
346 }
347
348 #[test]
349 fn pitch_is_clamped() {
350 let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
351 let input = InputState {
352 look_pitch: 999.0,
353 ..Default::default()
354 };
355 ch.tick(&input, 1.0 / 60.0, no_colliders());
356 assert!(ch.pitch <= 1.47);
357 }
358}