Skip to main content

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}