Skip to main content

roxlap_scene/
character.rs

1//! Character controller (stage CC.1 — see
2//! `docs/porting/PORTING-CONTROLLER.md`).
3//!
4//! A walking body over a [`Scene`]: substepped per-axis
5//! move-and-slide against the [`crate::collide`] probe, gravity,
6//! jumping, ground / head-bump detection. Not to be confused with
7//! `.rkc` characters (`roxlap-formats`' animated-model container) —
8//! this is what you *stand on the ground* with; a `.rkc` character is
9//! what you *draw* (CC.4 connects the two).
10//!
11//! Conventions (get the signs wrong and everything compiles and
12//! "works" upside down): **+z is DOWN**, so gravity is *positive* z,
13//! a jump impulse is *negative* z, the body's feet are its
14//! largest-z end and its head is at `feet.z - height`. Positions and
15//! velocities are f64 world space, matching the camera and
16//! [`crate::GridTransform`].
17//!
18//! Movement is deterministic — pure f64, no RNG, a fixed substep
19//! rule — so trajectories are unit-testable: same scene + same input
20//! sequence = identical path.
21
22use glam::{DVec2, DVec3};
23
24use crate::collide::{box_overlaps_solid, Solidity};
25use crate::Scene;
26
27/// Collision skin: contact rests this far off the blocking plane so
28/// the next probe of the resting pose stays clear.
29const SKIN: f64 = 1e-3;
30
31/// Hard cap on substeps per `walk` call — an anti-hang guard, far
32/// above any sane displacement (at the default radius this is ~4000
33/// voxels per call). Past it the displacement is truncated to keep
34/// the no-tunnel guarantee rather than probing less often.
35const MAX_SUBSTEPS: u32 = 10_000;
36
37/// Construction-time parameters of a [`CharacterBody`]. Distances in
38/// voxels (= world units), times in seconds.
39#[derive(Debug, Clone, Copy)]
40pub struct CharacterDef {
41    /// Half-extent of the collision box in x and y.
42    pub radius: f64,
43    /// Feet → head extent. The body occupies
44    /// `z ∈ [feet.z - height, feet.z]` (+z is down).
45    pub height: f64,
46    /// Feet → eye distance for [`CharacterBody::eye_pos`] (the
47    /// camera anchor), along the same up-is-−z axis.
48    pub eye_height: f64,
49    /// Gravity acceleration, **positive** (+z is down).
50    pub gravity: f64,
51    /// Initial upward speed of a jump, applied as **negative** z
52    /// velocity.
53    pub jump_speed: f64,
54    /// Terminal fall speed (+z velocity cap). Besides realism, this
55    /// bounds the per-frame probe cost: substeps scale with
56    /// displacement, so an unbounded fall gets linearly more
57    /// expensive the longer it lasts (the CC.5 stress probe caught
58    /// exactly that).
59    pub max_fall_speed: f64,
60    /// Target horizontal speed while walking.
61    pub walk_speed: f64,
62    /// How fast the horizontal velocity approaches the wish
63    /// direction on the ground, in speed units per second. Also the
64    /// stopping (friction) rate — with no input the target is zero.
65    pub accel_ground: f64,
66    /// Same, airborne — low, so jumps keep their momentum but retain
67    /// a little steering.
68    pub accel_air: f64,
69    /// Auto-step height: a grounded body blocked horizontally climbs
70    /// ledges up to this many voxels tall, if the lifted body fits
71    /// and finds ground on the far side. `1.05` clears 1-voxel
72    /// stairs; set `0.0` to disable.
73    pub step_up: f64,
74    /// Grace window after walking off an edge during which a jump
75    /// still fires (seconds) — "coyote time".
76    pub coyote_time: f64,
77    /// How long a jump pressed in mid-air stays queued and fires on
78    /// landing (seconds).
79    pub jump_buffer: f64,
80    /// Target speed in [`MoveMode::Fly`] / [`MoveMode::Noclip`],
81    /// where the full 3D `wish` steers.
82    pub fly_speed: f64,
83    /// Acceleration toward the wish in the fly modes. The default
84    /// `f64::INFINITY` means instant start/stop — a fly camera, not
85    /// a body with inertia (the demos' classic feel); set a finite
86    /// rate for drifty flight.
87    pub fly_accel: f64,
88    /// What counts as solid (bedrock-placeholder policy — must match
89    /// how the host *renders* the world; see
90    /// [`Solidity::bedrock_blocks`]).
91    pub solidity: Solidity,
92}
93
94impl Default for CharacterDef {
95    fn default() -> Self {
96        Self {
97            radius: 0.4,
98            height: 1.8,
99            eye_height: 1.62,
100            gravity: 24.0,
101            jump_speed: 9.0,
102            max_fall_speed: 60.0,
103            walk_speed: 6.0,
104            accel_ground: 40.0,
105            accel_air: 8.0,
106            step_up: 1.05,
107            coyote_time: 0.12,
108            jump_buffer: 0.12,
109            fly_speed: 12.0,
110            fly_accel: f64::INFINITY,
111            solidity: Solidity::default(),
112        }
113    }
114}
115
116/// How [`CharacterBody::walk`] moves the body.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum MoveMode {
119    /// Grounded movement: gravity, jumping, step-up, slide. The
120    /// default.
121    #[default]
122    Walk,
123    /// The demos' fly camera: no gravity, the full 3D `wish` steers
124    /// at [`CharacterDef::fly_speed`], collision still slides.
125    Fly,
126    /// Fly without any collision probes.
127    Noclip,
128}
129
130/// Per-frame input to [`CharacterBody::walk`].
131#[derive(Debug, Clone, Copy, Default)]
132pub struct WalkInput {
133    /// Wish direction, world space. In [`MoveMode::Walk`] only x/y
134    /// steer; the fly modes use all three components. Length is
135    /// clamped to 1, so passing a raw WASD sum is fine — scale it
136    /// *down* for analog part-speed input.
137    pub wish: DVec3,
138    /// Jump this frame. Fires when grounded or within the coyote
139    /// window; otherwise it stays buffered for
140    /// [`CharacterDef::jump_buffer`] seconds and fires on landing.
141    pub jump: bool,
142}
143
144/// A walking body: feet-positioned collision box + velocity +
145/// contact flags. Construct with [`CharacterBody::new`], place with
146/// [`teleport`](Self::teleport), then call
147/// [`walk`](Self::walk) once per frame.
148#[derive(Debug, Clone, Copy)]
149pub struct CharacterBody {
150    def: CharacterDef,
151    mode: MoveMode,
152    /// FEET position — the box is `pos ± radius` in x/y and
153    /// `[pos.z - height, pos.z]` in z.
154    pos: DVec3,
155    vel: DVec3,
156    on_ground: bool,
157    hit_head: bool,
158    /// Seconds since the body was last grounded (coyote window);
159    /// `INFINITY` once a jump consumes it.
160    since_grounded: f64,
161    /// Seconds of buffered-jump validity left.
162    jump_buffer_left: f64,
163}
164
165impl CharacterBody {
166    /// A body at the world origin with zero velocity — call
167    /// [`teleport`](Self::teleport) before the first `walk`.
168    #[must_use]
169    pub fn new(def: CharacterDef) -> Self {
170        Self {
171            def,
172            mode: MoveMode::Walk,
173            pos: DVec3::ZERO,
174            vel: DVec3::ZERO,
175            on_ground: false,
176            hit_head: false,
177            since_grounded: f64::INFINITY,
178            jump_buffer_left: 0.0,
179        }
180    }
181
182    /// Current movement mode.
183    #[must_use]
184    pub fn mode(&self) -> MoveMode {
185        self.mode
186    }
187
188    /// Switch movement mode. Velocity is kept — dropping out of
189    /// `Fly` mid-air falls with whatever speed you had.
190    pub fn set_mode(&mut self, mode: MoveMode) {
191        self.mode = mode;
192    }
193
194    /// The construction parameters.
195    #[must_use]
196    pub fn def(&self) -> &CharacterDef {
197        &self.def
198    }
199
200    /// Tune parameters at runtime — sprint (`walk_speed` /
201    /// `fly_speed`), variable jump height, etc. Growing `radius` /
202    /// `height` while standing in a tight spot can leave the body
203    /// overlapping solid; the stuck-escape rule makes that
204    /// recoverable rather than a jam.
205    pub fn def_mut(&mut self) -> &mut CharacterDef {
206        &mut self.def
207    }
208
209    /// Feet position, world space.
210    #[must_use]
211    pub fn pos(&self) -> DVec3 {
212        self.pos
213    }
214
215    /// Eye position — the camera anchor: `eye_height` *above* the
216    /// feet, i.e. toward −z.
217    #[must_use]
218    pub fn eye_pos(&self) -> DVec3 {
219        self.pos - DVec3::new(0.0, 0.0, self.def.eye_height)
220    }
221
222    /// Current velocity, world units per second.
223    #[must_use]
224    pub fn vel(&self) -> DVec3 {
225        self.vel
226    }
227
228    /// Overwrite the velocity — knockback, launch pads, spawn state.
229    pub fn set_vel(&mut self, vel: DVec3) {
230        self.vel = vel;
231    }
232
233    /// `true` while the feet rest on solid ground (skin probe below
234    /// the feet, updated by [`walk`](Self::walk)).
235    #[must_use]
236    pub fn on_ground(&self) -> bool {
237        self.on_ground
238    }
239
240    /// `true` if the head hit a ceiling during the last
241    /// [`walk`](Self::walk).
242    #[must_use]
243    pub fn hit_head(&self) -> bool {
244        self.hit_head
245    }
246
247    /// Reposition the feet, KEEPING velocity and contact state — for
248    /// world-bounds clamps and moving-platform style corrections.
249    /// For spawns and respawns use [`teleport`](Self::teleport).
250    pub fn set_pos(&mut self, pos: DVec3) {
251        self.pos = pos;
252    }
253
254    /// Hard-place the feet at `pos`, zeroing velocity and contact
255    /// flags. No collision check — placing inside solid is allowed
256    /// (the stuck-escape rule below makes it recoverable).
257    pub fn teleport(&mut self, pos: DVec3) {
258        self.pos = pos;
259        self.vel = DVec3::ZERO;
260        self.on_ground = false;
261        self.hit_head = false;
262        self.since_grounded = f64::INFINITY;
263        self.jump_buffer_left = 0.0;
264    }
265
266    /// Advance the body by `dt` seconds against `scene`. Behaviour
267    /// follows the current [`MoveMode`]; everything below describes
268    /// [`MoveMode::Walk`].
269    ///
270    /// Integration order: horizontal velocity approaches
271    /// `wish · walk_speed` at the ground/air accel rate; gravity;
272    /// jump if grounded; then the displacement is applied in
273    /// substeps of at most `radius` per axis (the no-tunnel
274    /// guarantee), each substep moving x, then y, then z, clamping
275    /// flush against the blocking cell plane and zeroing that
276    /// velocity component on contact — unless a grounded horizontal
277    /// block steps up a ledge ([`CharacterDef::step_up`]). +z contact
278    /// grounds the body; −z contact is a head bump.
279    ///
280    /// **Stuck escape** (kept verbatim from the demos): if the body
281    /// *starts* the frame overlapping solid — an edit carved under
282    /// the player, a bake reclassified a column — the whole frame
283    /// moves without collision so the player can escape rather than
284    /// jam.
285    pub fn walk(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
286        self.hit_head = false;
287        if dt <= 0.0 {
288            return;
289        }
290        match self.mode {
291            MoveMode::Walk => self.walk_grounded(scene, dt, input),
292            MoveMode::Fly => self.fly(scene, dt, input, true),
293            MoveMode::Noclip => self.fly(scene, dt, input, false),
294        }
295    }
296
297    fn walk_grounded(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
298        // -- integrate velocity ------------------------------------
299        let wish = input.wish.truncate();
300        let wish = if wish.length_squared() > 1.0 {
301            wish.normalize()
302        } else {
303            wish
304        };
305        let target = wish * self.def.walk_speed;
306        let accel = if self.on_ground {
307            self.def.accel_ground
308        } else {
309            self.def.accel_air
310        };
311        let horizontal = move_toward(self.vel.truncate(), target, accel * dt);
312        self.vel.x = horizontal.x;
313        self.vel.y = horizontal.y;
314
315        self.vel.z = (self.vel.z + self.def.gravity * dt).min(self.def.max_fall_speed);
316
317        // -- jump: buffered press + coyote window ------------------
318        if input.jump {
319            self.jump_buffer_left = self.def.jump_buffer;
320        }
321        let can_jump = self.on_ground || self.since_grounded <= self.def.coyote_time;
322        if self.jump_buffer_left > 0.0 && can_jump {
323            self.vel.z = -self.def.jump_speed;
324            self.jump_buffer_left = 0.0;
325            // Consume the coyote window — no double jumps off it.
326            self.since_grounded = f64::INFINITY;
327            self.on_ground = false;
328        }
329        self.jump_buffer_left = (self.jump_buffer_left - dt).max(0.0);
330
331        // -- move --------------------------------------------------
332        if self.slide_move(scene, dt, true) {
333            // Stuck escape ran: no contact state this frame (and no
334            // coyote jumps off the inside of a wall).
335            self.since_grounded = f64::INFINITY;
336            return;
337        }
338
339        // -- ground flag + coyote clock ----------------------------
340        self.on_ground = self.ground_probe(scene);
341        if self.on_ground {
342            self.since_grounded = 0.0;
343        } else {
344            self.since_grounded += dt;
345        }
346    }
347
348    /// `Fly` / `Noclip`: the full 3D wish steers at `fly_speed`, no
349    /// gravity, no jumping; `collide` picks slide-vs-ghost.
350    fn fly(&mut self, scene: &Scene, dt: f64, input: WalkInput, collide: bool) {
351        let wish = if input.wish.length_squared() > 1.0 {
352            input.wish.normalize()
353        } else {
354            input.wish
355        };
356        let target = wish * self.def.fly_speed;
357        let max_delta = self.def.fly_accel * dt;
358        let delta = target - self.vel;
359        let len = delta.length();
360        self.vel = if len <= max_delta || len < 1e-12 {
361            target
362        } else {
363            self.vel + delta * (max_delta / len)
364        };
365
366        if collide {
367            if !self.slide_move(scene, dt, false) {
368                self.on_ground = self.ground_probe(scene);
369            }
370        } else {
371            self.pos += self.vel * dt;
372            self.on_ground = false;
373        }
374        self.since_grounded = f64::INFINITY;
375        self.jump_buffer_left = 0.0;
376    }
377
378    /// The shared substepped per-axis mover; `step_up` enables the
379    /// grounded auto-step retry on horizontal blocks. Returns `true`
380    /// when the stuck-escape rule fired (the caller must not derive
381    /// contact state from this frame).
382    fn slide_move(&mut self, scene: &Scene, dt: f64, step_up: bool) -> bool {
383        let mut disp = self.vel * dt;
384
385        if self.blocked_at(scene, self.pos) {
386            // Stuck escape: free move, no probes, flags cleared.
387            self.pos += disp;
388            self.on_ground = false;
389            return true;
390        }
391
392        let max_step = self.def.radius.min(0.5);
393        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
394        let substeps = ((disp.abs().max_element() / max_step).ceil() as u32).clamp(1, MAX_SUBSTEPS);
395        if disp.abs().max_element() > f64::from(MAX_SUBSTEPS) * max_step {
396            // Anti-hang truncation — keeps probes-per-cell dense.
397            disp *= f64::from(MAX_SUBSTEPS) * max_step / disp.abs().max_element();
398        }
399        let step = disp / f64::from(substeps);
400
401        for _ in 0..substeps {
402            for axis in 0..3 {
403                if self.move_axis(scene, axis, step[axis]) {
404                    if axis < 2
405                        && step_up
406                        && self.on_ground
407                        && self.def.step_up > 0.0
408                        && self.try_step_up(scene, axis, step[axis])
409                    {
410                        // Climbed the ledge — keep the velocity.
411                        continue;
412                    }
413                    if axis == 2 && step.z < 0.0 {
414                        self.hit_head = true;
415                    }
416                    self.vel[axis] = 0.0;
417                }
418            }
419        }
420        false
421    }
422
423    /// Auto-step (CC.2): lift by `step_up` (up = −z), redo the
424    /// blocked horizontal move, then snap back down and require
425    /// ground under the new spot. All-or-nothing: any stage failing
426    /// reverts to the pre-step pose and the caller slides as usual.
427    fn try_step_up(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
428        let saved = self.pos;
429
430        let mut lifted = self.pos;
431        lifted.z -= self.def.step_up;
432        if self.blocked_at(scene, lifted) {
433            return false; // no headroom for the lifted body
434        }
435        let mut over = lifted;
436        over[axis] += delta;
437        if self.blocked_at(scene, over) {
438            return false; // ledge taller than step_up (or a wall)
439        }
440        self.pos = over;
441
442        // Snap down: must land within step_up, else it wasn't a
443        // ledge (walking off into air is the normal fall path, not a
444        // step).
445        if self.move_axis(scene, 2, self.def.step_up + SKIN) {
446            true
447        } else {
448            self.pos = saved;
449            false
450        }
451    }
452
453    /// Thin skin box just below the feet. After a landing clamp the
454    /// feet rest SKIN off the surface plane, so a 2·SKIN deep probe
455    /// reaches into the floor cell.
456    fn ground_probe(&self, scene: &Scene) -> bool {
457        let (bmin, bmax) = self.box_at(self.pos);
458        box_overlaps_solid(
459            scene,
460            DVec3::new(bmin.x, bmin.y, bmax.z),
461            DVec3::new(bmax.x, bmax.y, bmax.z + 2.0 * SKIN),
462            self.def.solidity,
463        )
464    }
465
466    /// Collision box corners for feet position `pos`.
467    fn box_at(&self, pos: DVec3) -> (DVec3, DVec3) {
468        let r = self.def.radius;
469        (
470            DVec3::new(pos.x - r, pos.y - r, pos.z - self.def.height),
471            DVec3::new(pos.x + r, pos.y + r, pos.z),
472        )
473    }
474
475    fn blocked_at(&self, scene: &Scene, pos: DVec3) -> bool {
476        let (bmin, bmax) = self.box_at(pos);
477        box_overlaps_solid(scene, bmin, bmax, self.def.solidity)
478    }
479
480    /// Move the feet along `axis` by `delta`; on block, clamp flush
481    /// (`SKIN` off the integer cell plane the leading face crossed)
482    /// or, if even the clamped pose probes solid (rotated-grid
483    /// geometry — its planes aren't world-axis planes), reject the
484    /// axis move entirely (the demos' behaviour). Returns `true` if
485    /// the axis was blocked.
486    fn move_axis(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
487        if delta == 0.0 {
488            return false;
489        }
490        let mut candidate = self.pos;
491        candidate[axis] += delta;
492        if !self.blocked_at(scene, candidate) {
493            self.pos = candidate;
494            return false;
495        }
496
497        // Leading-face offset from the feet position along this axis.
498        let (min_off, max_off) = {
499            let (bmin, bmax) = self.box_at(DVec3::ZERO);
500            (bmin[axis], bmax[axis])
501        };
502        let clamped = if delta > 0.0 {
503            // Leading face = max face; it entered cell
504            // `floor(face)` — rest SKIN before that plane.
505            (candidate[axis] + max_off).floor() - SKIN - max_off
506        } else {
507            // Leading face = min face; the entered cell's far
508            // boundary is `floor(face) + 1`.
509            (candidate[axis] + min_off).floor() + 1.0 + SKIN - min_off
510        };
511        let mut flush = self.pos;
512        flush[axis] = clamped;
513        // Never clamp *past* the attempted move, and only accept a
514        // pose the probe agrees is clear.
515        let overshoots = (clamped - self.pos[axis]).abs() > delta.abs() + SKIN;
516        if !overshoots && !self.blocked_at(scene, flush) {
517            self.pos = flush;
518        }
519        true
520    }
521}
522
523/// Move `from` toward `to` by at most `max_delta` (a deterministic
524/// approach — no overshoot, exact arrival).
525fn move_toward(from: DVec2, to: DVec2, max_delta: f64) -> DVec2 {
526    let delta = to - from;
527    let len = delta.length();
528    if len <= max_delta || len < 1e-12 {
529        to
530    } else {
531        from + delta * (max_delta / len)
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::{GridTransform, VoxColor};
539    use glam::IVec3;
540
541    const DT: f64 = 1.0 / 60.0;
542
543    /// Flat ground: solid slab z ∈ 100..=110 over x/y ∈ 60..=160,
544    /// grid at the world origin — floor *surface* plane at z = 100.
545    fn ground_scene() -> Scene {
546        let mut scene = Scene::new();
547        let id = scene.add_grid(GridTransform::identity());
548        let grid = scene.grid_mut(id).expect("grid present");
549        grid.set_rect(
550            IVec3::new(60, 60, 100),
551            IVec3::new(160, 160, 110),
552            Some(VoxColor(0x80_50_90_50)),
553        );
554        scene
555    }
556
557    fn body_on_ground(scene: &Scene) -> CharacterBody {
558        let mut body = CharacterBody::new(CharacterDef::default());
559        body.teleport(DVec3::new(100.0, 100.0, 95.0));
560        // Settle: fall to the floor.
561        for _ in 0..120 {
562            body.walk(scene, DT, WalkInput::default());
563        }
564        assert!(body.on_ground(), "settle: body must land");
565        body
566    }
567
568    #[test]
569    fn falls_and_lands_flush_on_the_surface_plane() {
570        let scene = ground_scene();
571        let body = body_on_ground(&scene);
572        // Feet rest exactly SKIN above (−z of) the z=100 plane.
573        assert!(
574            (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
575            "feet at {} != {}",
576            body.pos().z,
577            100.0 - SKIN
578        );
579        assert_eq!(body.vel().z, 0.0);
580        assert!(!body.hit_head());
581    }
582
583    #[test]
584    fn walk_reaches_and_holds_walk_speed() {
585        let scene = ground_scene();
586        let mut body = body_on_ground(&scene);
587        let input = WalkInput {
588            wish: DVec3::new(1.0, 0.0, 0.0),
589            jump: false,
590        };
591        for _ in 0..180 {
592            body.walk(&scene, DT, input);
593        }
594        let speed = body.vel().truncate().length();
595        assert!(
596            (speed - body.def().walk_speed).abs() < 1e-9,
597            "converged speed {speed}"
598        );
599        assert!(body.on_ground());
600    }
601
602    #[test]
603    fn walk_into_wall_clamps_flush_and_slides() {
604        let mut scene = ground_scene();
605        {
606            let id = scene.grids().next().expect("grid").0;
607            let grid = scene.grid_mut(id).expect("grid");
608            // Tall wall filling x ∈ 105..=106 in front of the body.
609            grid.set_rect(
610                IVec3::new(105, 60, 80),
611                IVec3::new(106, 160, 110),
612                Some(VoxColor(0x80_90_50_50)),
613            );
614        }
615        let mut body = body_on_ground(&scene);
616        let y0 = body.pos().y;
617        let input = WalkInput {
618            wish: DVec3::new(1.0, 1.0, 0.0),
619            jump: false,
620        };
621        for _ in 0..240 {
622            body.walk(&scene, DT, input);
623        }
624        // x face flush against the wall plane at x = 105…
625        let expected_x = 105.0 - body.def().radius - SKIN;
626        assert!(
627            (body.pos().x - expected_x).abs() < 1e-9,
628            "x {} != flush {}",
629            body.pos().x,
630            expected_x
631        );
632        assert_eq!(body.vel().x, 0.0, "blocked axis velocity zeroed");
633        // …while y keeps sliding.
634        assert!(body.pos().y > y0 + 3.0, "slid along the wall");
635    }
636
637    #[test]
638    fn jump_apex_matches_ballistics_and_relands() {
639        let scene = ground_scene();
640        let mut body = body_on_ground(&scene);
641        let start_z = body.pos().z;
642        let def = *body.def();
643
644        body.walk(
645            &scene,
646            DT,
647            WalkInput {
648                wish: DVec3::ZERO,
649                jump: true,
650            },
651        );
652        assert!(!body.on_ground(), "airborne after jump");
653
654        let mut apex_rise = 0.0f64;
655        let mut relanded = false;
656        for _ in 0..240 {
657            body.walk(&scene, DT, WalkInput::default());
658            apex_rise = apex_rise.max(start_z - body.pos().z);
659            if body.on_ground() {
660                relanded = true;
661                break;
662            }
663        }
664        assert!(relanded, "must land again");
665        // v²/2g, with discrete-integration slack.
666        let ideal = def.jump_speed * def.jump_speed / (2.0 * def.gravity);
667        assert!(
668            (apex_rise - ideal).abs() < 0.2,
669            "apex {apex_rise} vs ideal {ideal}"
670        );
671        assert!((body.pos().z - start_z).abs() < 1e-9, "back on the floor");
672    }
673
674    #[test]
675    fn head_bump_stops_the_jump() {
676        let mut scene = ground_scene();
677        {
678            let id = scene.grids().next().expect("grid").0;
679            let grid = scene.grid_mut(id).expect("grid");
680            // Ceiling slab: cells z ∈ 96..=97, so its underside
681            // plane is z = 98 — ~2.1 voxels of clearance over the
682            // 1.8 body.
683            grid.set_rect(
684                IVec3::new(60, 60, 96),
685                IVec3::new(160, 160, 97),
686                Some(VoxColor(0x80_50_50_90)),
687            );
688        }
689        // Start INSIDE the gap (between the ceiling underside at
690        // z = 98 and the floor plane at z = 100) — a spawn above the
691        // ceiling slab would settle on top of it instead.
692        let mut body = CharacterBody::new(CharacterDef::default());
693        body.teleport(DVec3::new(100.0, 100.0, 99.9));
694        for _ in 0..30 {
695            body.walk(&scene, DT, WalkInput::default());
696        }
697        assert!(body.on_ground(), "settled in the gap");
698        body.walk(
699            &scene,
700            DT,
701            WalkInput {
702                wish: DVec3::ZERO,
703                jump: true,
704            },
705        );
706        let mut bumped = false;
707        for _ in 0..120 {
708            body.walk(&scene, DT, WalkInput::default());
709            if body.hit_head() {
710                bumped = true;
711                // Head face flush under the ceiling plane, upward
712                // velocity killed.
713                let head = body.pos().z - body.def().height;
714                assert!((head - (98.0 + SKIN)).abs() < 1e-9, "head at {head}");
715                assert!(body.vel().z >= 0.0);
716            }
717            if body.on_ground() && bumped {
718                break;
719            }
720        }
721        assert!(bumped, "must bump the ceiling");
722        assert!(body.on_ground(), "falls back to the floor");
723    }
724
725    #[test]
726    fn fast_fall_does_not_tunnel_thin_floor() {
727        let mut scene = Scene::new();
728        let id = scene.add_grid(GridTransform::identity());
729        let grid = scene.grid_mut(id).expect("grid present");
730        // One voxel thick floor at z = 100.
731        grid.set_rect(
732            IVec3::new(60, 60, 100),
733            IVec3::new(160, 160, 100),
734            Some(VoxColor(0x80_70_70_70)),
735        );
736        let mut body = CharacterBody::new(CharacterDef {
737            // The test drives 200 v/s deliberately — lift the
738            // terminal-velocity clamp out of the way.
739            max_fall_speed: 400.0,
740            ..CharacterDef::default()
741        });
742        body.teleport(DVec3::new(100.0, 100.0, 60.0));
743        body.set_vel(DVec3::new(0.0, 0.0, 200.0)); // 20 voxels per step
744        for _ in 0..5 {
745            body.walk(&scene, 0.1, WalkInput::default());
746        }
747        assert!(
748            (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
749            "feet at {} — tunneled?",
750            body.pos().z
751        );
752        assert!(body.on_ground());
753    }
754
755    #[test]
756    fn stuck_body_escapes_freely() {
757        let scene = ground_scene();
758        let mut body = CharacterBody::new(CharacterDef::default());
759        // Feet buried mid-slab: the frame must move without probes.
760        body.teleport(DVec3::new(100.0, 100.0, 105.0));
761        assert!({
762            let (bmin, bmax) = body.box_at(body.pos());
763            box_overlaps_solid(&scene, bmin, bmax, Solidity::default())
764        });
765        let z0 = body.pos().z;
766        body.walk(
767            &scene,
768            DT,
769            WalkInput {
770                wish: DVec3::new(1.0, 0.0, 0.0),
771                jump: false,
772            },
773        );
774        assert!(body.pos().z > z0, "gravity still applies while stuck");
775        assert!(body.pos().x > 100.0, "input still applies while stuck");
776        assert!(!body.on_ground());
777    }
778
779    /// Floor plus a raised platform (top plane z = 99, one voxel
780    /// above the z = 100 floor) covering x >= 105.
781    fn ledge_scene(ledge_top_z: i32) -> Scene {
782        let mut scene = ground_scene();
783        let id = scene.grids().next().expect("grid").0;
784        let grid = scene.grid_mut(id).expect("grid");
785        grid.set_rect(
786            IVec3::new(105, 60, ledge_top_z),
787            IVec3::new(160, 160, 99),
788            Some(VoxColor(0x80_80_80_40)),
789        );
790        scene
791    }
792
793    #[test]
794    fn step_up_climbs_a_one_voxel_ledge() {
795        let scene = ledge_scene(99); // 1 voxel proud of the floor
796        let mut body = body_on_ground(&scene);
797        let input = WalkInput {
798            wish: DVec3::new(1.0, 0.0, 0.0),
799            jump: false,
800        };
801        for _ in 0..240 {
802            body.walk(&scene, DT, input);
803        }
804        assert!(body.pos().x > 106.0, "walked onto the ledge");
805        assert!(
806            (body.pos().z - (99.0 - SKIN)).abs() < 1e-9,
807            "feet on the ledge plane, got {}",
808            body.pos().z
809        );
810        assert!(body.on_ground());
811    }
812
813    #[test]
814    fn step_up_refuses_a_two_voxel_wall() {
815        let scene = ledge_scene(98); // 2 voxels proud — too tall
816        let mut body = body_on_ground(&scene);
817        let input = WalkInput {
818            wish: DVec3::new(1.0, 0.0, 0.0),
819            jump: false,
820        };
821        for _ in 0..240 {
822            body.walk(&scene, DT, input);
823        }
824        let expected_x = 105.0 - body.def().radius - SKIN;
825        assert!(
826            (body.pos().x - expected_x).abs() < 1e-9,
827            "clamped at {}, expected flush {expected_x}",
828            body.pos().x
829        );
830        assert!((body.pos().z - (100.0 - SKIN)).abs() < 1e-9, "stayed down");
831    }
832
833    #[test]
834    fn step_up_needs_headroom() {
835        let mut scene = ledge_scene(99);
836        {
837            let id = scene.grids().next().expect("grid").0;
838            let grid = scene.grid_mut(id).expect("grid");
839            // Ceiling low enough that the LIFTED body cannot fit
840            // over the ledge (lifted head reaches z ≈ 97.1; cell 97
841            // spans [97, 98)).
842            grid.set_rect(
843                IVec3::new(104, 60, 97),
844                IVec3::new(160, 160, 97),
845                Some(VoxColor(0x80_40_40_80)),
846            );
847        }
848        let mut body = body_on_ground(&scene);
849        let input = WalkInput {
850            wish: DVec3::new(1.0, 0.0, 0.0),
851            jump: false,
852        };
853        for _ in 0..240 {
854            body.walk(&scene, DT, input);
855        }
856        let expected_x = 105.0 - body.def().radius - SKIN;
857        assert!(
858            (body.pos().x - expected_x).abs() < 1e-9,
859            "no headroom ⇒ no step, got x {}",
860            body.pos().x
861        );
862    }
863
864    #[test]
865    fn coyote_jump_after_walking_off_an_edge() {
866        // Floor ends at x = 160; walk off it, then jump 3 frames
867        // late — inside the 0.12 s coyote window.
868        let scene = ground_scene();
869        let mut body = body_on_ground(&scene);
870        body.teleport(DVec3::new(159.0, 100.0, 95.0));
871        for _ in 0..120 {
872            body.walk(&scene, DT, WalkInput::default());
873        }
874        assert!(body.on_ground());
875        let input = WalkInput {
876            wish: DVec3::new(1.0, 0.0, 0.0),
877            jump: false,
878        };
879        while body.on_ground() {
880            body.walk(&scene, DT, input);
881        }
882        body.walk(&scene, DT, input);
883        body.walk(&scene, DT, input);
884        body.walk(
885            &scene,
886            DT,
887            WalkInput {
888                wish: DVec3::new(1.0, 0.0, 0.0),
889                jump: true,
890            },
891        );
892        assert!(
893            body.vel().z < -0.5 * body.def().jump_speed,
894            "coyote jump fired, vel.z = {}",
895            body.vel().z
896        );
897    }
898
899    #[test]
900    fn coyote_does_not_double_jump() {
901        let scene = ground_scene();
902        let mut body = body_on_ground(&scene);
903        body.walk(
904            &scene,
905            DT,
906            WalkInput {
907                wish: DVec3::ZERO,
908                jump: true,
909            },
910        );
911        let rising = body.vel().z;
912        assert!(rising < 0.0);
913        // A second press right after must not re-fire off the coyote
914        // window (nor may the buffer hold it until landing — wait
915        // out the buffer first).
916        for _ in 0..30 {
917            body.walk(&scene, DT, WalkInput::default());
918        }
919        let before = body.vel().z;
920        body.walk(
921            &scene,
922            DT,
923            WalkInput {
924                wish: DVec3::ZERO,
925                jump: true,
926            },
927        );
928        assert!(
929            body.vel().z > before,
930            "still decelerating upward/falling — no mid-air re-jump"
931        );
932    }
933
934    #[test]
935    fn buffered_jump_fires_on_landing() {
936        let scene = ground_scene();
937        let mut body = CharacterBody::new(CharacterDef::default());
938        body.teleport(DVec3::new(100.0, 100.0, 99.9));
939        // Press jump while still falling, just before touchdown
940        // (~0.1 voxels up ⇒ touchdown ≈ 0.08 s < the 0.12 s buffer;
941        // from 0.5 voxels the fall takes ~0.2 s and the buffer
942        // rightly expires).
943        body.walk(
944            &scene,
945            DT,
946            WalkInput {
947                wish: DVec3::ZERO,
948                jump: true,
949            },
950        );
951        assert!(!body.on_ground(), "still airborne at press");
952        let mut jumped = false;
953        for _ in 0..30 {
954            body.walk(&scene, DT, WalkInput::default());
955            if body.vel().z <= -0.9 * body.def().jump_speed {
956                jumped = true;
957                break;
958            }
959        }
960        assert!(jumped, "buffered jump fired on landing");
961    }
962
963    #[test]
964    fn fly_mode_hovers_and_slides() {
965        let mut scene = ground_scene();
966        {
967            let id = scene.grids().next().expect("grid").0;
968            let grid = scene.grid_mut(id).expect("grid");
969            grid.set_rect(
970                IVec3::new(105, 60, 80),
971                IVec3::new(106, 160, 110),
972                Some(VoxColor(0x80_90_50_50)),
973            );
974        }
975        let mut body = CharacterBody::new(CharacterDef::default());
976        body.set_mode(MoveMode::Fly);
977        body.teleport(DVec3::new(100.0, 100.0, 95.0));
978        // Hovers: no gravity.
979        for _ in 0..60 {
980            body.walk(&scene, DT, WalkInput::default());
981        }
982        assert_eq!(body.pos().z, 95.0, "no gravity in fly mode");
983        // Slides against the wall like the demo fly cameras.
984        let input = WalkInput {
985            wish: DVec3::new(1.0, 0.0, -0.2),
986            jump: false,
987        };
988        for _ in 0..240 {
989            body.walk(&scene, DT, input);
990        }
991        let expected_x = 105.0 - body.def().radius - SKIN;
992        assert!(
993            (body.pos().x - expected_x).abs() < 1e-9,
994            "fly clamps at the wall, got {}",
995            body.pos().x
996        );
997        assert!(body.pos().z < 95.0, "the -z wish component climbed");
998    }
999
1000    #[test]
1001    fn fly_stops_instantly_when_wish_drops() {
1002        // The cave-demo regression: walk() with a zero wish must
1003        // stop the body (default fly_accel is instant); a host that
1004        // skips walk() on idle keeps stale velocity instead.
1005        let scene = ground_scene();
1006        let mut body = CharacterBody::new(CharacterDef::default());
1007        body.set_mode(MoveMode::Fly);
1008        body.teleport(DVec3::new(100.0, 100.0, 95.0));
1009        body.walk(
1010            &scene,
1011            DT,
1012            WalkInput {
1013                wish: DVec3::new(1.0, 0.0, 0.0),
1014                jump: false,
1015            },
1016        );
1017        assert!(
1018            (body.vel().x - body.def().fly_speed).abs() < 1e-9,
1019            "instant spin-up to fly_speed"
1020        );
1021        let x_after_release = {
1022            body.walk(&scene, DT, WalkInput::default());
1023            body.pos().x
1024        };
1025        assert_eq!(body.vel(), DVec3::ZERO, "instant stop on zero wish");
1026        body.walk(&scene, DT, WalkInput::default());
1027        assert_eq!(body.pos().x, x_after_release, "no drift while idle");
1028    }
1029
1030    #[test]
1031    fn noclip_passes_through_the_wall() {
1032        let mut scene = ground_scene();
1033        {
1034            let id = scene.grids().next().expect("grid").0;
1035            let grid = scene.grid_mut(id).expect("grid");
1036            grid.set_rect(
1037                IVec3::new(105, 60, 80),
1038                IVec3::new(106, 160, 110),
1039                Some(VoxColor(0x80_90_50_50)),
1040            );
1041        }
1042        let mut body = CharacterBody::new(CharacterDef::default());
1043        body.set_mode(MoveMode::Noclip);
1044        body.teleport(DVec3::new(100.0, 100.0, 95.0));
1045        let input = WalkInput {
1046            wish: DVec3::new(1.0, 0.0, 0.0),
1047            jump: false,
1048        };
1049        for _ in 0..240 {
1050            body.walk(&scene, DT, input);
1051        }
1052        assert!(
1053            body.pos().x > 110.0,
1054            "ghosted through, x = {}",
1055            body.pos().x
1056        );
1057        assert!(!body.on_ground());
1058    }
1059
1060    /// CC.5 perf probe (not a gate — run by hand):
1061    /// `cargo test -p roxlap-scene --release --lib -- --ignored
1062    /// character::tests::stress_probe --nocapture`. Measures walk()
1063    /// per frame for one wall-hugging walker (worst-ish case: probes
1064    /// + flush clamps every substep) and for a 100-NPC crowd.
1065    #[test]
1066    #[ignore = "perf probe, run by hand with --ignored --nocapture"]
1067    fn stress_probe() {
1068        let mut scene = ground_scene();
1069        {
1070            let id = scene.grids().next().expect("grid").0;
1071            let grid = scene.grid_mut(id).expect("grid");
1072            grid.set_rect(
1073                IVec3::new(105, 60, 80),
1074                IVec3::new(106, 160, 110),
1075                Some(VoxColor(0x80_90_50_50)),
1076            );
1077        }
1078        let input = WalkInput {
1079            wish: DVec3::new(1.0, 0.0, 0.0), // straight INTO the wall
1080            jump: false,
1081        };
1082
1083        let mut body = body_on_ground(&scene);
1084        let t0 = std::time::Instant::now();
1085        const FRAMES: u32 = 60_000;
1086        for _ in 0..FRAMES {
1087            body.walk(&scene, DT, input);
1088        }
1089        let per_frame = t0.elapsed() / FRAMES;
1090        println!("single wall-hugging walker: {per_frame:?}/frame");
1091
1092        let mut crowd: Vec<CharacterBody> = (0..100)
1093            .map(|i| {
1094                let mut b = CharacterBody::new(CharacterDef::default());
1095                #[allow(clippy::cast_lossless)]
1096                b.teleport(DVec3::new(
1097                    70.0 + f64::from(i % 10) * 3.0,
1098                    70.0 + f64::from(i / 10) * 3.0,
1099                    95.0,
1100                ));
1101                b
1102            })
1103            .collect();
1104        for _ in 0..120 {
1105            for b in &mut crowd {
1106                b.walk(&scene, DT, WalkInput::default());
1107            }
1108        }
1109        let t0 = std::time::Instant::now();
1110        const CROWD_FRAMES: u32 = 600;
1111        for _ in 0..CROWD_FRAMES {
1112            for b in &mut crowd {
1113                b.walk(&scene, DT, input);
1114            }
1115        }
1116        let per_crowd_frame = t0.elapsed() / CROWD_FRAMES;
1117        println!("100-NPC crowd: {per_crowd_frame:?}/frame (all 100 walking)");
1118    }
1119
1120    #[test]
1121    fn fall_speed_caps_at_terminal_velocity() {
1122        // No floor: a long fall must clamp at max_fall_speed (this
1123        // also bounds substep count — an unbounded fall gets
1124        // linearly more expensive per frame).
1125        let scene = Scene::new();
1126        let mut body = CharacterBody::new(CharacterDef::default());
1127        body.teleport(DVec3::new(0.0, 0.0, 0.0));
1128        for _ in 0..600 {
1129            body.walk(&scene, DT, WalkInput::default());
1130        }
1131        assert_eq!(body.vel().z, body.def().max_fall_speed);
1132    }
1133
1134    #[test]
1135    fn deterministic_trajectory() {
1136        let scene = ground_scene();
1137        let run = || {
1138            let mut body = CharacterBody::new(CharacterDef::default());
1139            body.teleport(DVec3::new(100.0, 100.0, 95.0));
1140            let mut trace = Vec::new();
1141            for i in 0..180 {
1142                body.walk(
1143                    &scene,
1144                    DT,
1145                    WalkInput {
1146                        wish: DVec3::new(1.0, 0.3, 0.0),
1147                        jump: i == 90,
1148                    },
1149                );
1150                trace.push(body.pos());
1151            }
1152            trace
1153        };
1154        assert_eq!(run(), run(), "same input ⇒ bit-identical trajectory");
1155    }
1156}