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 — and swimming (stage WT.1):
7//! a `Walk`-mode body submerged past
8//! [`CharacterDef::swim_enter_frac`] of its height in a
9//! [`crate::WaterVolume`] enters the swim state automatically
10//! (buoyancy, vertical drag, `jump` strokes up / `sink` strokes down,
11//! a jump with the head above water breaches). Not to be confused with
12//! `.rkc` characters (`roxlap-formats`' animated-model container) —
13//! this is what you *stand on the ground* with; a `.rkc` character is
14//! what you *draw* (CC.4 connects the two).
15//!
16//! Conventions (get the signs wrong and everything compiles and
17//! "works" upside down): **+z is DOWN**, so gravity is *positive* z,
18//! a jump impulse is *negative* z, the body's feet are its
19//! largest-z end and its head is at `feet.z - height`. Positions and
20//! velocities are f64 world space, matching the camera and
21//! [`crate::GridTransform`].
22//!
23//! Movement is deterministic — pure f64, no RNG, a fixed substep
24//! rule — so trajectories are unit-testable: same scene + same input
25//! sequence = identical path.
26
27use glam::{DVec2, DVec3};
28
29use crate::collide::{box_overlaps_solid, Solidity};
30use crate::Scene;
31
32/// Collision skin: contact rests this far off the blocking plane so
33/// the next probe of the resting pose stays clear.
34const SKIN: f64 = 1e-3;
35
36/// Hard cap on substeps per `walk` call — an anti-hang guard, far
37/// above any sane displacement (at the default radius this is ~4000
38/// voxels per call). Past it the displacement is truncated to keep
39/// the no-tunnel guarantee rather than probing less often.
40const MAX_SUBSTEPS: u32 = 10_000;
41
42/// Construction-time parameters of a [`CharacterBody`]. Distances in
43/// voxels (= world units), times in seconds.
44#[derive(Debug, Clone, Copy)]
45pub struct CharacterDef {
46    /// Half-extent of the collision box in x and y.
47    pub radius: f64,
48    /// Feet → head extent. The body occupies
49    /// `z ∈ [feet.z - height, feet.z]` (+z is down).
50    pub height: f64,
51    /// Feet → eye distance for [`CharacterBody::eye_pos`] (the
52    /// camera anchor), along the same up-is-−z axis.
53    pub eye_height: f64,
54    /// Gravity acceleration, **positive** (+z is down).
55    pub gravity: f64,
56    /// Initial upward speed of a jump, applied as **negative** z
57    /// velocity.
58    pub jump_speed: f64,
59    /// Terminal fall speed (+z velocity cap). Besides realism, this
60    /// bounds the per-frame probe cost: substeps scale with
61    /// displacement, so an unbounded fall gets linearly more
62    /// expensive the longer it lasts (the CC.5 stress probe caught
63    /// exactly that).
64    pub max_fall_speed: f64,
65    /// Target horizontal speed while walking.
66    pub walk_speed: f64,
67    /// How fast the horizontal velocity approaches the wish
68    /// direction on the ground, in speed units per second. Also the
69    /// stopping (friction) rate — with no input the target is zero.
70    pub accel_ground: f64,
71    /// Same, airborne — low, so jumps keep their momentum but retain
72    /// a little steering.
73    pub accel_air: f64,
74    /// Auto-step height: a grounded body blocked horizontally climbs
75    /// ledges up to this many voxels tall, if the lifted body fits
76    /// and finds ground on the far side. `1.05` clears 1-voxel
77    /// stairs; set `0.0` to disable.
78    pub step_up: f64,
79    /// Grace window after walking off an edge during which a jump
80    /// still fires (seconds) — "coyote time".
81    pub coyote_time: f64,
82    /// How long a jump pressed in mid-air stays queued and fires on
83    /// landing (seconds).
84    pub jump_buffer: f64,
85    /// Target speed in [`MoveMode::Fly`] / [`MoveMode::Noclip`],
86    /// where the full 3D `wish` steers.
87    pub fly_speed: f64,
88    /// Acceleration toward the wish in the fly modes. The default
89    /// `f64::INFINITY` means instant start/stop — a fly camera, not
90    /// a body with inertia (the demos' classic feel); set a finite
91    /// rate for drifty flight.
92    pub fly_accel: f64,
93    /// What counts as solid (bedrock-placeholder policy — must match
94    /// how the host *renders* the world; see
95    /// [`Solidity::bedrock_blocks`]).
96    pub solidity: Solidity,
97    /// WT.1 — upward acceleration at FULL submersion, **positive**
98    /// (applied toward −z, fighting `gravity`). With no stroke input
99    /// the body settles where `gravity == buoyancy · fraction`; the
100    /// defaults (24 / 32) float it at 75% submerged — head above the
101    /// surface.
102    pub buoyancy: f64,
103    /// WT.1 — vertical velocity damping in water, per second
104    /// (exponential). Caps stroke/fall speeds under water and kills
105    /// the surface bob; higher = thicker water.
106    pub water_drag: f64,
107    /// WT.1 — target horizontal speed while swimming.
108    pub swim_speed: f64,
109    /// WT.1 — acceleration while swimming: the horizontal approach
110    /// rate AND the vertical stroke strength (`jump` up / `sink`
111    /// down).
112    pub swim_accel: f64,
113    /// WT.1 — the swim state ENGAGES at this submerged fraction of
114    /// the body height. The default `0.6` deliberately lets the
115    /// default 1.8-body WADE through minimal authored water (one
116    /// voxel over the floor = fraction ≈ 0.56): a decorative puddle
117    /// must not swap walking for swimming.
118    pub swim_enter_frac: f64,
119    /// …and RELEASES below this one. The gap is deliberate hysteresis:
120    /// a bobbing body at the waterline must not flicker between
121    /// swimming and walking (`enter > exit`). The EFFECTIVE release
122    /// threshold is additionally clamped below the no-stroke bobbing
123    /// equilibrium (`gravity / buoyancy`) at runtime, so a very
124    /// buoyant "cork" tuning can't limit-cycle at the waterline.
125    pub swim_exit_frac: f64,
126}
127
128impl Default for CharacterDef {
129    fn default() -> Self {
130        Self {
131            radius: 0.4,
132            height: 1.8,
133            eye_height: 1.62,
134            gravity: 24.0,
135            jump_speed: 9.0,
136            max_fall_speed: 60.0,
137            walk_speed: 6.0,
138            accel_ground: 40.0,
139            accel_air: 8.0,
140            step_up: 1.05,
141            coyote_time: 0.12,
142            jump_buffer: 0.12,
143            fly_speed: 12.0,
144            fly_accel: f64::INFINITY,
145            solidity: Solidity::default(),
146            buoyancy: 32.0,
147            water_drag: 3.0,
148            swim_speed: 4.0,
149            swim_accel: 20.0,
150            swim_enter_frac: 0.6,
151            swim_exit_frac: 0.35,
152        }
153    }
154}
155
156/// How [`CharacterBody::walk`] moves the body.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum MoveMode {
159    /// Grounded movement: gravity, jumping, step-up, slide. The
160    /// default.
161    #[default]
162    Walk,
163    /// The demos' fly camera: no gravity, the full 3D `wish` steers
164    /// at [`CharacterDef::fly_speed`], collision still slides.
165    Fly,
166    /// Fly without any collision probes.
167    Noclip,
168}
169
170/// Per-frame input to [`CharacterBody::walk`].
171#[derive(Debug, Clone, Copy, Default)]
172pub struct WalkInput {
173    /// Wish direction, world space. In [`MoveMode::Walk`] only x/y
174    /// steer; the fly modes use all three components. Length is
175    /// clamped to 1, so passing a raw WASD sum is fine — scale it
176    /// *down* for analog part-speed input.
177    pub wish: DVec3,
178    /// Jump this frame. Fires when grounded or within the coyote
179    /// window; otherwise it stays buffered for
180    /// [`CharacterDef::jump_buffer`] seconds and fires on landing.
181    /// While swimming (WT.1) it strokes the body UP instead — and a
182    /// jump with the head already above the surface breaches into a
183    /// normal jump.
184    pub jump: bool,
185    /// WT.1 — stroke DOWN while swimming (dive). Ignored on land and
186    /// in the fly modes (they steer with the full 3D `wish`).
187    pub sink: bool,
188}
189
190/// A walking body: feet-positioned collision box + velocity +
191/// contact flags. Construct with [`CharacterBody::new`], place with
192/// [`teleport`](Self::teleport), then call
193/// [`walk`](Self::walk) once per frame.
194#[derive(Debug, Clone, Copy)]
195// Four bools, all genuinely independent contact/state flags (ground,
196// head bump, swim, breach latch) — not an encoded state machine.
197#[allow(clippy::struct_excessive_bools)]
198pub struct CharacterBody {
199    def: CharacterDef,
200    mode: MoveMode,
201    /// FEET position — the box is `pos ± radius` in x/y and
202    /// `[pos.z - height, pos.z]` in z.
203    pos: DVec3,
204    vel: DVec3,
205    on_ground: bool,
206    hit_head: bool,
207    /// Seconds since the body was last grounded (coyote window);
208    /// `INFINITY` once a jump consumes it.
209    since_grounded: f64,
210    /// Seconds of buffered-jump validity left.
211    jump_buffer_left: f64,
212    /// WT.1 — the hysteretic swim state (see
213    /// [`CharacterDef::swim_enter_frac`]). Only meaningful in
214    /// [`MoveMode::Walk`]; the fly modes clear it.
215    swimming: bool,
216    /// WT.1 — breach one-shot: set when a breach jump fires, held
217    /// until the jump input releases. Without it a HELD jump at the
218    /// surface would re-impulse to full `-jump_speed` every frame
219    /// (no ballistic decay — the body leaves any pool at jump speed
220    /// and overshoots a normal jump's apex).
221    breach_latched: bool,
222}
223
224impl CharacterBody {
225    /// A body at the world origin with zero velocity — call
226    /// [`teleport`](Self::teleport) before the first `walk`.
227    #[must_use]
228    pub fn new(def: CharacterDef) -> Self {
229        Self {
230            def,
231            mode: MoveMode::Walk,
232            pos: DVec3::ZERO,
233            vel: DVec3::ZERO,
234            on_ground: false,
235            hit_head: false,
236            since_grounded: f64::INFINITY,
237            jump_buffer_left: 0.0,
238            swimming: false,
239            breach_latched: false,
240        }
241    }
242
243    /// Current movement mode.
244    #[must_use]
245    pub fn mode(&self) -> MoveMode {
246        self.mode
247    }
248
249    /// Switch movement mode. Velocity is kept — dropping out of
250    /// `Fly` mid-air falls with whatever speed you had.
251    pub fn set_mode(&mut self, mode: MoveMode) {
252        self.mode = mode;
253        // WT.1 — the fly modes ignore water. Clear the swim state
254        // HERE, not just on the next walk(): a host polling
255        // `is_swimming()` right after the switch (cutscene camera, no
256        // walk() calls) must not see a stale underwater tint/lowpass.
257        if mode != MoveMode::Walk {
258            self.swimming = false;
259            self.breach_latched = false;
260        }
261    }
262
263    /// The construction parameters.
264    #[must_use]
265    pub fn def(&self) -> &CharacterDef {
266        &self.def
267    }
268
269    /// Tune parameters at runtime — sprint (`walk_speed` /
270    /// `fly_speed`), variable jump height, etc. Growing `radius` /
271    /// `height` while standing in a tight spot can leave the body
272    /// overlapping solid; the stuck-escape rule makes that
273    /// recoverable rather than a jam.
274    pub fn def_mut(&mut self) -> &mut CharacterDef {
275        &mut self.def
276    }
277
278    /// Feet position, world space.
279    #[must_use]
280    pub fn pos(&self) -> DVec3 {
281        self.pos
282    }
283
284    /// Eye position — the camera anchor: `eye_height` *above* the
285    /// feet, i.e. toward −z.
286    #[must_use]
287    pub fn eye_pos(&self) -> DVec3 {
288        self.pos - DVec3::new(0.0, 0.0, self.def.eye_height)
289    }
290
291    /// Current velocity, world units per second.
292    #[must_use]
293    pub fn vel(&self) -> DVec3 {
294        self.vel
295    }
296
297    /// Overwrite the velocity — knockback, launch pads, spawn state.
298    pub fn set_vel(&mut self, vel: DVec3) {
299        self.vel = vel;
300    }
301
302    /// `true` while the feet rest on solid ground (skin probe below
303    /// the feet, updated by [`walk`](Self::walk)).
304    #[must_use]
305    pub fn on_ground(&self) -> bool {
306        self.on_ground
307    }
308
309    /// `true` if the head hit a ceiling during the last
310    /// [`walk`](Self::walk).
311    #[must_use]
312    pub fn hit_head(&self) -> bool {
313        self.hit_head
314    }
315
316    /// WT.1 — `true` while the body is in the swim state (submerged
317    /// past [`CharacterDef::swim_enter_frac`], held until it falls
318    /// below [`CharacterDef::swim_exit_frac`]). Updated by
319    /// [`walk`](Self::walk); always `false` in the fly modes.
320    #[must_use]
321    pub fn is_swimming(&self) -> bool {
322        self.swimming
323    }
324
325    /// WT.1 — fraction of the body height below a water surface,
326    /// `0.0..=1.0`: the [`Scene::water_depth_at`] point depth at the
327    /// FEET over [`CharacterDef::height`] (exact for the
328    /// world-horizontal surfaces water is authored with —
329    /// PORTING-WATER.md decision 1: there is deliberately no
330    /// box-overlap query). This is the BODY-STATE number (it drives
331    /// the swim state) — do NOT wire underwater tint/audio to it: at
332    /// the default bobbing equilibrium it reads 0.75 while the head
333    /// (and camera) are in the air. Use [`Self::eye_in_water`] for
334    /// those.
335    ///
336    /// Continuity guard: feet just below an authored volume's BOTTOM
337    /// face (a pool volume that stops short of the floor) read as
338    /// fully submerged while the head is still in water — without
339    /// this, the fraction would snap 0.55+ → 0.0 mid-dive and strobe
340    /// the swim state faster than hysteresis can absorb. (Author
341    /// volumes down to the floor anyway.)
342    #[must_use]
343    pub fn submerged_fraction(&self, scene: &Scene) -> f64 {
344        if let Some((_, depth)) = scene.water_depth_at(self.pos) {
345            return (depth / self.def.height).clamp(0.0, 1.0);
346        }
347        let head = self.pos - DVec3::new(0.0, 0.0, self.def.height);
348        if scene.in_water(head) {
349            1.0
350        } else {
351            0.0
352        }
353    }
354
355    /// WT.1 — `true` while the EYE (the camera anchor,
356    /// [`Self::eye_pos`]) is under a water surface. THE hook for the
357    /// underwater feel — WT.2 tint, WT.3 listener lowpass: it flips
358    /// exactly when the player's view goes under, unlike
359    /// [`Self::submerged_fraction`] (feet-depth, 0.75 at the surface
360    /// bob with a dry camera).
361    #[must_use]
362    pub fn eye_in_water(&self, scene: &Scene) -> bool {
363        scene.in_water(self.eye_pos())
364    }
365
366    /// Reposition the feet, KEEPING velocity and contact state — for
367    /// world-bounds clamps and moving-platform style corrections.
368    /// For spawns and respawns use [`teleport`](Self::teleport).
369    pub fn set_pos(&mut self, pos: DVec3) {
370        self.pos = pos;
371    }
372
373    /// Hard-place the feet at `pos`, zeroing velocity and contact
374    /// flags. No collision check — placing inside solid is allowed
375    /// (the stuck-escape rule below makes it recoverable).
376    pub fn teleport(&mut self, pos: DVec3) {
377        self.pos = pos;
378        self.vel = DVec3::ZERO;
379        self.on_ground = false;
380        self.hit_head = false;
381        self.since_grounded = f64::INFINITY;
382        self.jump_buffer_left = 0.0;
383        self.swimming = false;
384        self.breach_latched = false;
385    }
386
387    /// Advance the body by `dt` seconds against `scene`. Behaviour
388    /// follows the current [`MoveMode`]; everything below describes
389    /// [`MoveMode::Walk`].
390    ///
391    /// Integration order: horizontal velocity approaches
392    /// `wish · walk_speed` at the ground/air accel rate; gravity;
393    /// jump if grounded; then the displacement is applied in
394    /// substeps of at most `radius` per axis (the no-tunnel
395    /// guarantee), each substep moving x, then y, then z, clamping
396    /// flush against the blocking cell plane and zeroing that
397    /// velocity component on contact — unless a grounded horizontal
398    /// block steps up a ledge ([`CharacterDef::step_up`]). +z contact
399    /// grounds the body; −z contact is a head bump.
400    ///
401    /// **Stuck escape** (kept verbatim from the demos): if the body
402    /// *starts* the frame overlapping solid — an edit carved under
403    /// the player, a bake reclassified a column — the whole frame
404    /// moves without collision so the player can escape rather than
405    /// jam.
406    pub fn walk(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
407        self.hit_head = false;
408        if dt <= 0.0 {
409            return;
410        }
411        match self.mode {
412            MoveMode::Walk => self.walk_grounded(scene, dt, input),
413            MoveMode::Fly => self.fly(scene, dt, input, true),
414            MoveMode::Noclip => self.fly(scene, dt, input, false),
415        }
416    }
417
418    fn walk_grounded(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
419        // -- WT.1: hysteretic swim state ----------------------------
420        // A dry scene short-circuits in `water_depth_at` (no volumes),
421        // so the pre-WT walk path runs unperturbed.
422        let frac = self.submerged_fraction(scene);
423        // BOTH thresholds are clamped around the no-stroke bobbing
424        // equilibrium (gravity/buoyancy) so the resting point always
425        // lies INSIDE the hysteresis band and the flag keeps tracking
426        // the truth for extreme tunings: release below the
427        // equilibrium (0.9× — a cork bobbing at 0.3 must not release
428        // at swim_exit_frac = 0.35), engage no higher than just above
429        // it (1.1× — a cork's bob never reaches the default 0.6).
430        // The band alone can't prevent waterline strobing, though —
431        // that is the passive-force continuity's job (see the
432        // integration below). The default tuning (equilibrium 0.75)
433        // leaves both knobs untouched: 0.9·0.75 > 0.35 and
434        // 1.1·0.75 > 0.6.
435        let equilibrium = if self.def.buoyancy > 0.0 {
436            self.def.gravity / self.def.buoyancy
437        } else {
438            f64::INFINITY
439        };
440        let exit = self.def.swim_exit_frac.min(0.9 * equilibrium);
441        let enter = self
442            .def
443            .swim_enter_frac
444            .min(1.1 * equilibrium)
445            .max(exit + 0.01);
446        if self.swimming {
447            if frac < exit {
448                self.swimming = false;
449            }
450        } else if frac >= enter {
451            self.swimming = true;
452        }
453        if self.swimming {
454            self.swim(scene, dt, input, frac);
455            return;
456        }
457        self.breach_latched = false; // walking re-arms the breach
458
459        // -- integrate velocity ------------------------------------
460        let accel = if self.on_ground {
461            self.def.accel_ground
462        } else {
463            self.def.accel_air
464        };
465        self.steer_horizontal(input.wish, self.def.walk_speed, accel, dt);
466
467        // WT.1 — the PASSIVE water forces (buoyancy + drag, both
468        // scaled by the submerged fraction) apply in BOTH states; the
469        // swim flag gates only the CONTROLS (strokes, speeds, coyote).
470        // This continuity is load-bearing: if the walking body felt
471        // dry gravity inside water, the force would jump by
472        // `buoyancy · frac` at every engage/release crossing — a
473        // relay oscillator whose hysteresis loop pumps energy each
474        // cycle, so a buoyant body strobes swim → walk at the
475        // waterline forever instead of settling (drag alone cannot
476        // outrun the pump; measured before this fix). Besides
477        // stability it is also the right feel — wading is floaty,
478        // a splashing landing is cushioned. `frac == 0` reduces to
479        // exactly the pre-WT integration (bit-identical, pinned).
480        self.vel.z = (self.vel.z + (self.def.gravity - self.def.buoyancy * frac) * dt)
481            .min(self.def.max_fall_speed);
482        if frac > 0.0 {
483            self.vel.z -= self.vel.z * (self.def.water_drag * frac * dt).min(1.0);
484        }
485
486        // -- jump: buffered press + coyote window ------------------
487        if input.jump {
488            self.jump_buffer_left = self.def.jump_buffer;
489        }
490        let can_jump = self.on_ground || self.since_grounded <= self.def.coyote_time;
491        if self.jump_buffer_left > 0.0 && can_jump {
492            self.vel.z = -self.def.jump_speed;
493            self.jump_buffer_left = 0.0;
494            // Consume the coyote window — no double jumps off it.
495            self.since_grounded = f64::INFINITY;
496            self.on_ground = false;
497        }
498        self.jump_buffer_left = (self.jump_buffer_left - dt).max(0.0);
499
500        // -- move --------------------------------------------------
501        if self.slide_move(scene, dt, true) {
502            // Stuck escape ran: no contact state this frame (and no
503            // coyote jumps off the inside of a wall).
504            self.since_grounded = f64::INFINITY;
505            return;
506        }
507
508        // -- ground flag + coyote clock ----------------------------
509        self.on_ground = self.ground_probe(scene);
510        if self.on_ground {
511            self.since_grounded = 0.0;
512        } else {
513            self.since_grounded += dt;
514        }
515    }
516
517    /// WT.1 — swimming: buoyancy scaled by the submerged fraction
518    /// fights gravity (equilibrium bobbing at the surface), vertical
519    /// drag caps every speed, `jump`/`sink` stroke up/down, and a
520    /// jump with the head above the surface breaches into a real
521    /// jump (one-shot — release the jump input to re-arm). Collision
522    /// is the same slide as walking — pool walls and floor still
523    /// block (step-up assists a grounded wade out).
524    fn swim(&mut self, scene: &Scene, dt: f64, input: WalkInput, frac: f64) {
525        // Horizontal: like walking, at swim speed/accel.
526        self.steer_horizontal(input.wish, self.def.swim_speed, self.def.swim_accel, dt);
527
528        // Breach FIRST (the stroke/drag it replaces would only be
529        // overwritten): a jump with the head above the surface is a
530        // real jump, not a stroke. ONE-SHOT via the latch — a held
531        // jump must not re-impulse to full `-jump_speed` every frame
532        // (the body would leave any pool at jump speed with no
533        // ballistic decay and overshoot a normal jump's apex).
534        let head = self.pos - DVec3::new(0.0, 0.0, self.def.height);
535        let breach = input.jump && !self.breach_latched && !scene.in_water(head);
536        self.breach_latched = input.jump && (self.breach_latched || breach);
537        if breach {
538            self.vel.z = -self.def.jump_speed;
539        } else {
540            // Vertical (+z is DOWN): gravity pulls down (+), buoyancy
541            // pushes up (−) in proportion to how much of the body is
542            // under; a stroke adds on top. With no stroke the body
543            // settles where gravity == buoyancy·frac — bobbing at the
544            // surface, above the enter threshold at the defaults (and
545            // the release threshold is clamped under the equilibrium
546            // for any tuning — see `walk_grounded`).
547            let stroke = f64::from(input.sink) - f64::from(input.jump);
548            self.vel.z += (self.def.gravity - self.def.buoyancy * frac) * dt;
549            self.vel.z += stroke * self.def.swim_accel * dt;
550            // Exponential drag — the water is thick.
551            self.vel.z -= self.vel.z * (self.def.water_drag * dt).min(1.0);
552            // The dry terminal cap still binds: a legal weak-drag,
553            // zero-buoyancy tuning must not sink faster than the walk
554            // path is allowed to fall.
555            self.vel.z = self.vel.z.min(self.def.max_fall_speed);
556        }
557
558        // Swimming neither grants coyote time nor buffers jumps
559        // (jump means "stroke up" here; a press near the shore must
560        // not fire a stale jump on landing).
561        self.since_grounded = f64::INFINITY;
562        self.jump_buffer_left = 0.0;
563
564        if self.slide_move(scene, dt, true) {
565            return; // stuck escape — no contact state this frame
566        }
567        self.on_ground = self.ground_probe(scene);
568    }
569
570    /// The walk/swim horizontal steer: clamp the wish's x/y to unit
571    /// length, approach `wish · speed` at `accel` (also the friction
572    /// rate — zero wish brakes).
573    fn steer_horizontal(&mut self, wish: DVec3, speed: f64, accel: f64, dt: f64) {
574        let wish = wish.truncate();
575        let wish = if wish.length_squared() > 1.0 {
576            wish.normalize()
577        } else {
578            wish
579        };
580        let horizontal = move_toward(self.vel.truncate(), wish * speed, accel * dt);
581        self.vel.x = horizontal.x;
582        self.vel.y = horizontal.y;
583    }
584
585    /// `Fly` / `Noclip`: the full 3D wish steers at `fly_speed`, no
586    /// gravity, no jumping; `collide` picks slide-vs-ghost.
587    fn fly(&mut self, scene: &Scene, dt: f64, input: WalkInput, collide: bool) {
588        // WT.1 — the fly modes ignore water entirely.
589        self.swimming = false;
590        self.breach_latched = false;
591        let wish = if input.wish.length_squared() > 1.0 {
592            input.wish.normalize()
593        } else {
594            input.wish
595        };
596        let target = wish * self.def.fly_speed;
597        let max_delta = self.def.fly_accel * dt;
598        let delta = target - self.vel;
599        let len = delta.length();
600        self.vel = if len <= max_delta || len < 1e-12 {
601            target
602        } else {
603            self.vel + delta * (max_delta / len)
604        };
605
606        if collide {
607            if !self.slide_move(scene, dt, false) {
608                self.on_ground = self.ground_probe(scene);
609            }
610        } else {
611            self.pos += self.vel * dt;
612            self.on_ground = false;
613        }
614        self.since_grounded = f64::INFINITY;
615        self.jump_buffer_left = 0.0;
616    }
617
618    /// The shared substepped per-axis mover; `step_up` enables the
619    /// grounded auto-step retry on horizontal blocks. Returns `true`
620    /// when the stuck-escape rule fired (the caller must not derive
621    /// contact state from this frame).
622    fn slide_move(&mut self, scene: &Scene, dt: f64, step_up: bool) -> bool {
623        let mut disp = self.vel * dt;
624
625        if self.blocked_at(scene, self.pos) {
626            // Stuck escape: free move, no probes, flags cleared.
627            self.pos += disp;
628            self.on_ground = false;
629            return true;
630        }
631
632        let max_step = self.def.radius.min(0.5);
633        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
634        let substeps = ((disp.abs().max_element() / max_step).ceil() as u32).clamp(1, MAX_SUBSTEPS);
635        if disp.abs().max_element() > f64::from(MAX_SUBSTEPS) * max_step {
636            // Anti-hang truncation — keeps probes-per-cell dense.
637            disp *= f64::from(MAX_SUBSTEPS) * max_step / disp.abs().max_element();
638        }
639        let step = disp / f64::from(substeps);
640
641        for _ in 0..substeps {
642            for axis in 0..3 {
643                if self.move_axis(scene, axis, step[axis]) {
644                    if axis < 2
645                        && step_up
646                        && self.on_ground
647                        && self.def.step_up > 0.0
648                        && self.try_step_up(scene, axis, step[axis])
649                    {
650                        // Climbed the ledge — keep the velocity.
651                        continue;
652                    }
653                    if axis == 2 && step.z < 0.0 {
654                        self.hit_head = true;
655                    }
656                    self.vel[axis] = 0.0;
657                }
658            }
659        }
660        false
661    }
662
663    /// Auto-step (CC.2): lift by `step_up` (up = −z), redo the
664    /// blocked horizontal move, then snap back down and require
665    /// ground under the new spot. All-or-nothing: any stage failing
666    /// reverts to the pre-step pose and the caller slides as usual.
667    fn try_step_up(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
668        let saved = self.pos;
669
670        let mut lifted = self.pos;
671        lifted.z -= self.def.step_up;
672        if self.blocked_at(scene, lifted) {
673            return false; // no headroom for the lifted body
674        }
675        let mut over = lifted;
676        over[axis] += delta;
677        if self.blocked_at(scene, over) {
678            return false; // ledge taller than step_up (or a wall)
679        }
680        self.pos = over;
681
682        // Snap down: must land within step_up, else it wasn't a
683        // ledge (walking off into air is the normal fall path, not a
684        // step).
685        if self.move_axis(scene, 2, self.def.step_up + SKIN) {
686            true
687        } else {
688            self.pos = saved;
689            false
690        }
691    }
692
693    /// Thin skin box just below the feet. After a landing clamp the
694    /// feet rest SKIN off the surface plane, so a 2·SKIN deep probe
695    /// reaches into the floor cell.
696    fn ground_probe(&self, scene: &Scene) -> bool {
697        let (bmin, bmax) = self.box_at(self.pos);
698        box_overlaps_solid(
699            scene,
700            DVec3::new(bmin.x, bmin.y, bmax.z),
701            DVec3::new(bmax.x, bmax.y, bmax.z + 2.0 * SKIN),
702            self.def.solidity,
703        )
704    }
705
706    /// Collision box corners for feet position `pos`.
707    fn box_at(&self, pos: DVec3) -> (DVec3, DVec3) {
708        let r = self.def.radius;
709        (
710            DVec3::new(pos.x - r, pos.y - r, pos.z - self.def.height),
711            DVec3::new(pos.x + r, pos.y + r, pos.z),
712        )
713    }
714
715    fn blocked_at(&self, scene: &Scene, pos: DVec3) -> bool {
716        let (bmin, bmax) = self.box_at(pos);
717        box_overlaps_solid(scene, bmin, bmax, self.def.solidity)
718    }
719
720    /// Move the feet along `axis` by `delta`; on block, clamp flush
721    /// (`SKIN` off the integer cell plane the leading face crossed)
722    /// or, if even the clamped pose probes solid (rotated-grid
723    /// geometry — its planes aren't world-axis planes), reject the
724    /// axis move entirely (the demos' behaviour). Returns `true` if
725    /// the axis was blocked.
726    fn move_axis(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
727        if delta == 0.0 {
728            return false;
729        }
730        let mut candidate = self.pos;
731        candidate[axis] += delta;
732        if !self.blocked_at(scene, candidate) {
733            self.pos = candidate;
734            return false;
735        }
736
737        // Leading-face offset from the feet position along this axis.
738        let (min_off, max_off) = {
739            let (bmin, bmax) = self.box_at(DVec3::ZERO);
740            (bmin[axis], bmax[axis])
741        };
742        let clamped = if delta > 0.0 {
743            // Leading face = max face; it entered cell
744            // `floor(face)` — rest SKIN before that plane.
745            (candidate[axis] + max_off).floor() - SKIN - max_off
746        } else {
747            // Leading face = min face; the entered cell's far
748            // boundary is `floor(face) + 1`.
749            (candidate[axis] + min_off).floor() + 1.0 + SKIN - min_off
750        };
751        let mut flush = self.pos;
752        flush[axis] = clamped;
753        // Never clamp *past* the attempted move, and only accept a
754        // pose the probe agrees is clear.
755        let overshoots = (clamped - self.pos[axis]).abs() > delta.abs() + SKIN;
756        if !overshoots && !self.blocked_at(scene, flush) {
757            self.pos = flush;
758        }
759        true
760    }
761}
762
763/// Move `from` toward `to` by at most `max_delta` (a deterministic
764/// approach — no overshoot, exact arrival).
765fn move_toward(from: DVec2, to: DVec2, max_delta: f64) -> DVec2 {
766    let delta = to - from;
767    let len = delta.length();
768    if len <= max_delta || len < 1e-12 {
769        to
770    } else {
771        from + delta * (max_delta / len)
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::{GridTransform, VoxColor};
779    use glam::IVec3;
780
781    const DT: f64 = 1.0 / 60.0;
782
783    /// Flat ground: solid slab z ∈ 100..=110 over x/y ∈ 60..=160,
784    /// grid at the world origin — floor *surface* plane at z = 100.
785    fn ground_scene() -> Scene {
786        let mut scene = Scene::new();
787        let id = scene.add_grid(GridTransform::identity());
788        let grid = scene.grid_mut(id).expect("grid present");
789        grid.set_rect(
790            IVec3::new(60, 60, 100),
791            IVec3::new(160, 160, 110),
792            Some(VoxColor(0x80_50_90_50)),
793        );
794        scene
795    }
796
797    fn body_on_ground(scene: &Scene) -> CharacterBody {
798        let mut body = CharacterBody::new(CharacterDef::default());
799        body.teleport(DVec3::new(100.0, 100.0, 95.0));
800        // Settle: fall to the floor.
801        for _ in 0..120 {
802            body.walk(scene, DT, WalkInput::default());
803        }
804        assert!(body.on_ground(), "settle: body must land");
805        body
806    }
807
808    #[test]
809    fn falls_and_lands_flush_on_the_surface_plane() {
810        let scene = ground_scene();
811        let body = body_on_ground(&scene);
812        // Feet rest exactly SKIN above (−z of) the z=100 plane.
813        assert!(
814            (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
815            "feet at {} != {}",
816            body.pos().z,
817            100.0 - SKIN
818        );
819        assert_eq!(body.vel().z, 0.0);
820        assert!(!body.hit_head());
821    }
822
823    #[test]
824    fn walk_reaches_and_holds_walk_speed() {
825        let scene = ground_scene();
826        let mut body = body_on_ground(&scene);
827        let input = WalkInput {
828            wish: DVec3::new(1.0, 0.0, 0.0),
829            jump: false,
830            sink: false,
831        };
832        for _ in 0..180 {
833            body.walk(&scene, DT, input);
834        }
835        let speed = body.vel().truncate().length();
836        assert!(
837            (speed - body.def().walk_speed).abs() < 1e-9,
838            "converged speed {speed}"
839        );
840        assert!(body.on_ground());
841    }
842
843    #[test]
844    fn walk_into_wall_clamps_flush_and_slides() {
845        let mut scene = ground_scene();
846        {
847            let id = scene.grids().next().expect("grid").0;
848            let grid = scene.grid_mut(id).expect("grid");
849            // Tall wall filling x ∈ 105..=106 in front of the body.
850            grid.set_rect(
851                IVec3::new(105, 60, 80),
852                IVec3::new(106, 160, 110),
853                Some(VoxColor(0x80_90_50_50)),
854            );
855        }
856        let mut body = body_on_ground(&scene);
857        let y0 = body.pos().y;
858        let input = WalkInput {
859            wish: DVec3::new(1.0, 1.0, 0.0),
860            jump: false,
861            sink: false,
862        };
863        for _ in 0..240 {
864            body.walk(&scene, DT, input);
865        }
866        // x face flush against the wall plane at x = 105…
867        let expected_x = 105.0 - body.def().radius - SKIN;
868        assert!(
869            (body.pos().x - expected_x).abs() < 1e-9,
870            "x {} != flush {}",
871            body.pos().x,
872            expected_x
873        );
874        assert_eq!(body.vel().x, 0.0, "blocked axis velocity zeroed");
875        // …while y keeps sliding.
876        assert!(body.pos().y > y0 + 3.0, "slid along the wall");
877    }
878
879    #[test]
880    fn jump_apex_matches_ballistics_and_relands() {
881        let scene = ground_scene();
882        let mut body = body_on_ground(&scene);
883        let start_z = body.pos().z;
884        let def = *body.def();
885
886        body.walk(
887            &scene,
888            DT,
889            WalkInput {
890                wish: DVec3::ZERO,
891                jump: true,
892                sink: false,
893            },
894        );
895        assert!(!body.on_ground(), "airborne after jump");
896
897        let mut apex_rise = 0.0f64;
898        let mut relanded = false;
899        for _ in 0..240 {
900            body.walk(&scene, DT, WalkInput::default());
901            apex_rise = apex_rise.max(start_z - body.pos().z);
902            if body.on_ground() {
903                relanded = true;
904                break;
905            }
906        }
907        assert!(relanded, "must land again");
908        // v²/2g, with discrete-integration slack.
909        let ideal = def.jump_speed * def.jump_speed / (2.0 * def.gravity);
910        assert!(
911            (apex_rise - ideal).abs() < 0.2,
912            "apex {apex_rise} vs ideal {ideal}"
913        );
914        assert!((body.pos().z - start_z).abs() < 1e-9, "back on the floor");
915    }
916
917    #[test]
918    fn head_bump_stops_the_jump() {
919        let mut scene = ground_scene();
920        {
921            let id = scene.grids().next().expect("grid").0;
922            let grid = scene.grid_mut(id).expect("grid");
923            // Ceiling slab: cells z ∈ 96..=97, so its underside
924            // plane is z = 98 — ~2.1 voxels of clearance over the
925            // 1.8 body.
926            grid.set_rect(
927                IVec3::new(60, 60, 96),
928                IVec3::new(160, 160, 97),
929                Some(VoxColor(0x80_50_50_90)),
930            );
931        }
932        // Start INSIDE the gap (between the ceiling underside at
933        // z = 98 and the floor plane at z = 100) — a spawn above the
934        // ceiling slab would settle on top of it instead.
935        let mut body = CharacterBody::new(CharacterDef::default());
936        body.teleport(DVec3::new(100.0, 100.0, 99.9));
937        for _ in 0..30 {
938            body.walk(&scene, DT, WalkInput::default());
939        }
940        assert!(body.on_ground(), "settled in the gap");
941        body.walk(
942            &scene,
943            DT,
944            WalkInput {
945                wish: DVec3::ZERO,
946                jump: true,
947                sink: false,
948            },
949        );
950        let mut bumped = false;
951        for _ in 0..120 {
952            body.walk(&scene, DT, WalkInput::default());
953            if body.hit_head() {
954                bumped = true;
955                // Head face flush under the ceiling plane, upward
956                // velocity killed.
957                let head = body.pos().z - body.def().height;
958                assert!((head - (98.0 + SKIN)).abs() < 1e-9, "head at {head}");
959                assert!(body.vel().z >= 0.0);
960            }
961            if body.on_ground() && bumped {
962                break;
963            }
964        }
965        assert!(bumped, "must bump the ceiling");
966        assert!(body.on_ground(), "falls back to the floor");
967    }
968
969    #[test]
970    fn fast_fall_does_not_tunnel_thin_floor() {
971        let mut scene = Scene::new();
972        let id = scene.add_grid(GridTransform::identity());
973        let grid = scene.grid_mut(id).expect("grid present");
974        // One voxel thick floor at z = 100.
975        grid.set_rect(
976            IVec3::new(60, 60, 100),
977            IVec3::new(160, 160, 100),
978            Some(VoxColor(0x80_70_70_70)),
979        );
980        let mut body = CharacterBody::new(CharacterDef {
981            // The test drives 200 v/s deliberately — lift the
982            // terminal-velocity clamp out of the way.
983            max_fall_speed: 400.0,
984            ..CharacterDef::default()
985        });
986        body.teleport(DVec3::new(100.0, 100.0, 60.0));
987        body.set_vel(DVec3::new(0.0, 0.0, 200.0)); // 20 voxels per step
988        for _ in 0..5 {
989            body.walk(&scene, 0.1, WalkInput::default());
990        }
991        assert!(
992            (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
993            "feet at {} — tunneled?",
994            body.pos().z
995        );
996        assert!(body.on_ground());
997    }
998
999    #[test]
1000    fn stuck_body_escapes_freely() {
1001        let scene = ground_scene();
1002        let mut body = CharacterBody::new(CharacterDef::default());
1003        // Feet buried mid-slab: the frame must move without probes.
1004        body.teleport(DVec3::new(100.0, 100.0, 105.0));
1005        assert!({
1006            let (bmin, bmax) = body.box_at(body.pos());
1007            box_overlaps_solid(&scene, bmin, bmax, Solidity::default())
1008        });
1009        let z0 = body.pos().z;
1010        body.walk(
1011            &scene,
1012            DT,
1013            WalkInput {
1014                wish: DVec3::new(1.0, 0.0, 0.0),
1015                jump: false,
1016                sink: false,
1017            },
1018        );
1019        assert!(body.pos().z > z0, "gravity still applies while stuck");
1020        assert!(body.pos().x > 100.0, "input still applies while stuck");
1021        assert!(!body.on_ground());
1022    }
1023
1024    /// Floor plus a raised platform (top plane z = 99, one voxel
1025    /// above the z = 100 floor) covering x >= 105.
1026    fn ledge_scene(ledge_top_z: i32) -> Scene {
1027        let mut scene = ground_scene();
1028        let id = scene.grids().next().expect("grid").0;
1029        let grid = scene.grid_mut(id).expect("grid");
1030        grid.set_rect(
1031            IVec3::new(105, 60, ledge_top_z),
1032            IVec3::new(160, 160, 99),
1033            Some(VoxColor(0x80_80_80_40)),
1034        );
1035        scene
1036    }
1037
1038    #[test]
1039    fn step_up_climbs_a_one_voxel_ledge() {
1040        let scene = ledge_scene(99); // 1 voxel proud of the floor
1041        let mut body = body_on_ground(&scene);
1042        let input = WalkInput {
1043            wish: DVec3::new(1.0, 0.0, 0.0),
1044            jump: false,
1045            sink: false,
1046        };
1047        for _ in 0..240 {
1048            body.walk(&scene, DT, input);
1049        }
1050        assert!(body.pos().x > 106.0, "walked onto the ledge");
1051        assert!(
1052            (body.pos().z - (99.0 - SKIN)).abs() < 1e-9,
1053            "feet on the ledge plane, got {}",
1054            body.pos().z
1055        );
1056        assert!(body.on_ground());
1057    }
1058
1059    #[test]
1060    fn step_up_refuses_a_two_voxel_wall() {
1061        let scene = ledge_scene(98); // 2 voxels proud — too tall
1062        let mut body = body_on_ground(&scene);
1063        let input = WalkInput {
1064            wish: DVec3::new(1.0, 0.0, 0.0),
1065            jump: false,
1066            sink: false,
1067        };
1068        for _ in 0..240 {
1069            body.walk(&scene, DT, input);
1070        }
1071        let expected_x = 105.0 - body.def().radius - SKIN;
1072        assert!(
1073            (body.pos().x - expected_x).abs() < 1e-9,
1074            "clamped at {}, expected flush {expected_x}",
1075            body.pos().x
1076        );
1077        assert!((body.pos().z - (100.0 - SKIN)).abs() < 1e-9, "stayed down");
1078    }
1079
1080    #[test]
1081    fn step_up_needs_headroom() {
1082        let mut scene = ledge_scene(99);
1083        {
1084            let id = scene.grids().next().expect("grid").0;
1085            let grid = scene.grid_mut(id).expect("grid");
1086            // Ceiling low enough that the LIFTED body cannot fit
1087            // over the ledge (lifted head reaches z ≈ 97.1; cell 97
1088            // spans [97, 98)).
1089            grid.set_rect(
1090                IVec3::new(104, 60, 97),
1091                IVec3::new(160, 160, 97),
1092                Some(VoxColor(0x80_40_40_80)),
1093            );
1094        }
1095        let mut body = body_on_ground(&scene);
1096        let input = WalkInput {
1097            wish: DVec3::new(1.0, 0.0, 0.0),
1098            jump: false,
1099            sink: false,
1100        };
1101        for _ in 0..240 {
1102            body.walk(&scene, DT, input);
1103        }
1104        let expected_x = 105.0 - body.def().radius - SKIN;
1105        assert!(
1106            (body.pos().x - expected_x).abs() < 1e-9,
1107            "no headroom ⇒ no step, got x {}",
1108            body.pos().x
1109        );
1110    }
1111
1112    #[test]
1113    fn coyote_jump_after_walking_off_an_edge() {
1114        // Floor ends at x = 160; walk off it, then jump 3 frames
1115        // late — inside the 0.12 s coyote window.
1116        let scene = ground_scene();
1117        let mut body = body_on_ground(&scene);
1118        body.teleport(DVec3::new(159.0, 100.0, 95.0));
1119        for _ in 0..120 {
1120            body.walk(&scene, DT, WalkInput::default());
1121        }
1122        assert!(body.on_ground());
1123        let input = WalkInput {
1124            wish: DVec3::new(1.0, 0.0, 0.0),
1125            jump: false,
1126            sink: false,
1127        };
1128        while body.on_ground() {
1129            body.walk(&scene, DT, input);
1130        }
1131        body.walk(&scene, DT, input);
1132        body.walk(&scene, DT, input);
1133        body.walk(
1134            &scene,
1135            DT,
1136            WalkInput {
1137                wish: DVec3::new(1.0, 0.0, 0.0),
1138                jump: true,
1139                sink: false,
1140            },
1141        );
1142        assert!(
1143            body.vel().z < -0.5 * body.def().jump_speed,
1144            "coyote jump fired, vel.z = {}",
1145            body.vel().z
1146        );
1147    }
1148
1149    #[test]
1150    fn coyote_does_not_double_jump() {
1151        let scene = ground_scene();
1152        let mut body = body_on_ground(&scene);
1153        body.walk(
1154            &scene,
1155            DT,
1156            WalkInput {
1157                wish: DVec3::ZERO,
1158                jump: true,
1159                sink: false,
1160            },
1161        );
1162        let rising = body.vel().z;
1163        assert!(rising < 0.0);
1164        // A second press right after must not re-fire off the coyote
1165        // window (nor may the buffer hold it until landing — wait
1166        // out the buffer first).
1167        for _ in 0..30 {
1168            body.walk(&scene, DT, WalkInput::default());
1169        }
1170        let before = body.vel().z;
1171        body.walk(
1172            &scene,
1173            DT,
1174            WalkInput {
1175                wish: DVec3::ZERO,
1176                jump: true,
1177                sink: false,
1178            },
1179        );
1180        assert!(
1181            body.vel().z > before,
1182            "still decelerating upward/falling — no mid-air re-jump"
1183        );
1184    }
1185
1186    #[test]
1187    fn buffered_jump_fires_on_landing() {
1188        let scene = ground_scene();
1189        let mut body = CharacterBody::new(CharacterDef::default());
1190        body.teleport(DVec3::new(100.0, 100.0, 99.9));
1191        // Press jump while still falling, just before touchdown
1192        // (~0.1 voxels up ⇒ touchdown ≈ 0.08 s < the 0.12 s buffer;
1193        // from 0.5 voxels the fall takes ~0.2 s and the buffer
1194        // rightly expires).
1195        body.walk(
1196            &scene,
1197            DT,
1198            WalkInput {
1199                wish: DVec3::ZERO,
1200                jump: true,
1201                sink: false,
1202            },
1203        );
1204        assert!(!body.on_ground(), "still airborne at press");
1205        let mut jumped = false;
1206        for _ in 0..30 {
1207            body.walk(&scene, DT, WalkInput::default());
1208            if body.vel().z <= -0.9 * body.def().jump_speed {
1209                jumped = true;
1210                break;
1211            }
1212        }
1213        assert!(jumped, "buffered jump fired on landing");
1214    }
1215
1216    #[test]
1217    fn fly_mode_hovers_and_slides() {
1218        let mut scene = ground_scene();
1219        {
1220            let id = scene.grids().next().expect("grid").0;
1221            let grid = scene.grid_mut(id).expect("grid");
1222            grid.set_rect(
1223                IVec3::new(105, 60, 80),
1224                IVec3::new(106, 160, 110),
1225                Some(VoxColor(0x80_90_50_50)),
1226            );
1227        }
1228        let mut body = CharacterBody::new(CharacterDef::default());
1229        body.set_mode(MoveMode::Fly);
1230        body.teleport(DVec3::new(100.0, 100.0, 95.0));
1231        // Hovers: no gravity.
1232        for _ in 0..60 {
1233            body.walk(&scene, DT, WalkInput::default());
1234        }
1235        assert_eq!(body.pos().z, 95.0, "no gravity in fly mode");
1236        // Slides against the wall like the demo fly cameras.
1237        let input = WalkInput {
1238            wish: DVec3::new(1.0, 0.0, -0.2),
1239            jump: false,
1240            sink: false,
1241        };
1242        for _ in 0..240 {
1243            body.walk(&scene, DT, input);
1244        }
1245        let expected_x = 105.0 - body.def().radius - SKIN;
1246        assert!(
1247            (body.pos().x - expected_x).abs() < 1e-9,
1248            "fly clamps at the wall, got {}",
1249            body.pos().x
1250        );
1251        assert!(body.pos().z < 95.0, "the -z wish component climbed");
1252    }
1253
1254    #[test]
1255    fn fly_stops_instantly_when_wish_drops() {
1256        // The cave-demo regression: walk() with a zero wish must
1257        // stop the body (default fly_accel is instant); a host that
1258        // skips walk() on idle keeps stale velocity instead.
1259        let scene = ground_scene();
1260        let mut body = CharacterBody::new(CharacterDef::default());
1261        body.set_mode(MoveMode::Fly);
1262        body.teleport(DVec3::new(100.0, 100.0, 95.0));
1263        body.walk(
1264            &scene,
1265            DT,
1266            WalkInput {
1267                wish: DVec3::new(1.0, 0.0, 0.0),
1268                jump: false,
1269                sink: false,
1270            },
1271        );
1272        assert!(
1273            (body.vel().x - body.def().fly_speed).abs() < 1e-9,
1274            "instant spin-up to fly_speed"
1275        );
1276        let x_after_release = {
1277            body.walk(&scene, DT, WalkInput::default());
1278            body.pos().x
1279        };
1280        assert_eq!(body.vel(), DVec3::ZERO, "instant stop on zero wish");
1281        body.walk(&scene, DT, WalkInput::default());
1282        assert_eq!(body.pos().x, x_after_release, "no drift while idle");
1283    }
1284
1285    #[test]
1286    fn noclip_passes_through_the_wall() {
1287        let mut scene = ground_scene();
1288        {
1289            let id = scene.grids().next().expect("grid").0;
1290            let grid = scene.grid_mut(id).expect("grid");
1291            grid.set_rect(
1292                IVec3::new(105, 60, 80),
1293                IVec3::new(106, 160, 110),
1294                Some(VoxColor(0x80_90_50_50)),
1295            );
1296        }
1297        let mut body = CharacterBody::new(CharacterDef::default());
1298        body.set_mode(MoveMode::Noclip);
1299        body.teleport(DVec3::new(100.0, 100.0, 95.0));
1300        let input = WalkInput {
1301            wish: DVec3::new(1.0, 0.0, 0.0),
1302            jump: false,
1303            sink: false,
1304        };
1305        for _ in 0..240 {
1306            body.walk(&scene, DT, input);
1307        }
1308        assert!(
1309            body.pos().x > 110.0,
1310            "ghosted through, x = {}",
1311            body.pos().x
1312        );
1313        assert!(!body.on_ground());
1314    }
1315
1316    /// CC.5 perf probe (not a gate — run by hand):
1317    /// `cargo test -p roxlap-scene --release --lib -- --ignored
1318    /// character::tests::stress_probe --nocapture`. Measures walk()
1319    /// per frame for one wall-hugging walker (worst-ish case: probes
1320    /// + flush clamps every substep) and for a 100-NPC crowd.
1321    #[test]
1322    #[ignore = "perf probe, run by hand with --ignored --nocapture"]
1323    fn stress_probe() {
1324        let mut scene = ground_scene();
1325        {
1326            let id = scene.grids().next().expect("grid").0;
1327            let grid = scene.grid_mut(id).expect("grid");
1328            grid.set_rect(
1329                IVec3::new(105, 60, 80),
1330                IVec3::new(106, 160, 110),
1331                Some(VoxColor(0x80_90_50_50)),
1332            );
1333        }
1334        let input = WalkInput {
1335            wish: DVec3::new(1.0, 0.0, 0.0), // straight INTO the wall
1336            jump: false,
1337            sink: false,
1338        };
1339
1340        let mut body = body_on_ground(&scene);
1341        let t0 = std::time::Instant::now();
1342        const FRAMES: u32 = 60_000;
1343        for _ in 0..FRAMES {
1344            body.walk(&scene, DT, input);
1345        }
1346        let per_frame = t0.elapsed() / FRAMES;
1347        println!("single wall-hugging walker: {per_frame:?}/frame");
1348
1349        let mut crowd: Vec<CharacterBody> = (0..100)
1350            .map(|i| {
1351                let mut b = CharacterBody::new(CharacterDef::default());
1352                #[allow(clippy::cast_lossless)]
1353                b.teleport(DVec3::new(
1354                    70.0 + f64::from(i % 10) * 3.0,
1355                    70.0 + f64::from(i / 10) * 3.0,
1356                    95.0,
1357                ));
1358                b
1359            })
1360            .collect();
1361        for _ in 0..120 {
1362            for b in &mut crowd {
1363                b.walk(&scene, DT, WalkInput::default());
1364            }
1365        }
1366        let t0 = std::time::Instant::now();
1367        const CROWD_FRAMES: u32 = 600;
1368        for _ in 0..CROWD_FRAMES {
1369            for b in &mut crowd {
1370                b.walk(&scene, DT, input);
1371            }
1372        }
1373        let per_crowd_frame = t0.elapsed() / CROWD_FRAMES;
1374        println!("100-NPC crowd: {per_crowd_frame:?}/frame (all 100 walking)");
1375    }
1376
1377    #[test]
1378    fn fall_speed_caps_at_terminal_velocity() {
1379        // No floor: a long fall must clamp at max_fall_speed (this
1380        // also bounds substep count — an unbounded fall gets
1381        // linearly more expensive per frame).
1382        let scene = Scene::new();
1383        let mut body = CharacterBody::new(CharacterDef::default());
1384        body.teleport(DVec3::new(0.0, 0.0, 0.0));
1385        for _ in 0..600 {
1386            body.walk(&scene, DT, WalkInput::default());
1387        }
1388        assert_eq!(body.vel().z, body.def().max_fall_speed);
1389    }
1390
1391    #[test]
1392    fn deterministic_trajectory() {
1393        let scene = ground_scene();
1394        let run = || {
1395            let mut body = CharacterBody::new(CharacterDef::default());
1396            body.teleport(DVec3::new(100.0, 100.0, 95.0));
1397            let mut trace = Vec::new();
1398            for i in 0..180 {
1399                body.walk(
1400                    &scene,
1401                    DT,
1402                    WalkInput {
1403                        wish: DVec3::new(1.0, 0.3, 0.0),
1404                        jump: i == 90,
1405                        sink: false,
1406                    },
1407                );
1408                trace.push(body.pos());
1409            }
1410            trace
1411        };
1412        assert_eq!(run(), run(), "same input ⇒ bit-identical trajectory");
1413    }
1414
1415    // ---- WT.1: swimming --------------------------------------------
1416
1417    /// A deep pool: solid basin floor at z = 120, water surface plane
1418    /// at z = 100 (20 voxels of water above the floor). Physics water
1419    /// only — no visual voxels needed.
1420    fn pool_scene() -> Scene {
1421        let mut scene = Scene::new();
1422        let id = scene.add_grid(GridTransform::identity());
1423        let grid = scene.grid_mut(id).expect("grid present");
1424        grid.set_rect(
1425            IVec3::new(60, 60, 120),
1426            IVec3::new(160, 160, 130),
1427            Some(VoxColor(0x80_50_90_50)),
1428        );
1429        grid.add_water_volume(IVec3::new(60, 60, 100), IVec3::new(160, 160, 119));
1430        scene
1431    }
1432
1433    /// The no-stroke bobbing equilibrium: gravity == buoyancy · frac.
1434    fn equilibrium_frac(def: &CharacterDef) -> f64 {
1435        def.gravity / def.buoyancy
1436    }
1437
1438    #[test]
1439    fn floats_to_equilibrium_and_never_flickers() {
1440        let scene = pool_scene();
1441        let mut body = CharacterBody::new(CharacterDef::default());
1442        // Drop in deep: feet at z = 115 (fully submerged).
1443        body.teleport(DVec3::new(100.0, 100.0, 115.0));
1444        let mut engaged_at = None;
1445        for i in 0..600 {
1446            body.walk(&scene, DT, WalkInput::default());
1447            if body.is_swimming() && engaged_at.is_none() {
1448                engaged_at = Some(i);
1449            }
1450            // Hysteresis gate: once the swim state engages it must
1451            // hold through the whole rise + surface bob.
1452            if engaged_at.is_some() {
1453                assert!(body.is_swimming(), "swim state flickered at frame {i}");
1454            }
1455        }
1456        assert!(engaged_at.is_some(), "fully submerged body must swim");
1457        // Settled: negligible vertical motion at the predicted bob.
1458        assert!(body.vel().z.abs() < 0.05, "still bobbing: {}", body.vel().z);
1459        let frac = body.submerged_fraction(&scene);
1460        let eq = equilibrium_frac(body.def());
1461        assert!(
1462            (frac - eq).abs() < 0.03,
1463            "equilibrium fraction {frac} != {eq}"
1464        );
1465        // Head above the surface at the default tuning (z-down:
1466        // smaller z is higher).
1467        let head_z = body.pos().z - body.def().height;
1468        assert!(head_z < 100.0, "head under water at rest: {head_z}");
1469        assert!(!body.on_ground(), "floating, not standing");
1470    }
1471
1472    #[test]
1473    fn sink_dives_and_jump_strokes_up() {
1474        let scene = pool_scene();
1475        let mut body = CharacterBody::new(CharacterDef::default());
1476        body.teleport(DVec3::new(100.0, 100.0, 102.0));
1477        // Settle at the surface first.
1478        for _ in 0..300 {
1479            body.walk(&scene, DT, WalkInput::default());
1480        }
1481        let surface_z = body.pos().z;
1482
1483        // Hold sink: the body dives (+z grows).
1484        let dive = WalkInput {
1485            sink: true,
1486            ..WalkInput::default()
1487        };
1488        for _ in 0..120 {
1489            body.walk(&scene, DT, dive);
1490        }
1491        assert!(
1492            body.pos().z > surface_z + 1.0,
1493            "dive made no depth: {} vs {surface_z}",
1494            body.pos().z
1495        );
1496        assert!(body.is_swimming());
1497
1498        // Hold jump: the body strokes back up (−z).
1499        let rise = WalkInput {
1500            jump: true,
1501            ..WalkInput::default()
1502        };
1503        let deep_z = body.pos().z;
1504        for _ in 0..60 {
1505            body.walk(&scene, DT, rise);
1506        }
1507        assert!(
1508            body.pos().z < deep_z - 1.0,
1509            "stroke up made no height: {} vs {deep_z}",
1510            body.pos().z
1511        );
1512    }
1513
1514    #[test]
1515    fn breach_jump_fires_with_head_above_water() {
1516        let scene = pool_scene();
1517        let mut body = CharacterBody::new(CharacterDef::default());
1518        body.teleport(DVec3::new(100.0, 100.0, 102.0));
1519        for _ in 0..300 {
1520            body.walk(&scene, DT, WalkInput::default());
1521        }
1522        // At equilibrium the head is above the surface — a jump is a
1523        // BREACH (full jump impulse), not a stroke.
1524        assert!(body.is_swimming());
1525        let head_z = body.pos().z - body.def().height;
1526        assert!(head_z < 100.0, "test setup: head must be above water");
1527        body.walk(
1528            &scene,
1529            DT,
1530            WalkInput {
1531                jump: true,
1532                ..WalkInput::default()
1533            },
1534        );
1535        assert!(
1536            body.vel().z < -0.8 * body.def().jump_speed,
1537            "breach impulse missing: vel.z = {}",
1538            body.vel().z
1539        );
1540    }
1541
1542    #[test]
1543    fn wading_below_enter_fraction_stays_walking() {
1544        // The minimal authored water — ONE voxel over the walkable
1545        // floor (surface z = 99 above the z = 100 plane) — must stay
1546        // wadable for the DEFAULT body: 1.0 deep on a 1.8 body is a
1547        // fraction of ≈ 0.56, under the 0.6 enter threshold (the
1548        // threshold default was picked for exactly this: a
1549        // decorative puddle must not swap walking for swimming).
1550        let mut scene = ground_scene();
1551        let id = scene.grids().next().expect("grid").0;
1552        scene
1553            .grid_mut(id)
1554            .expect("grid")
1555            .add_water_volume(IVec3::new(60, 60, 99), IVec3::new(160, 160, 99));
1556        let mut body = body_on_ground(&scene);
1557        let frac = body.submerged_fraction(&scene);
1558        assert!(
1559            frac > 0.5 && frac < body.def().swim_enter_frac,
1560            "test setup: wading fraction {frac}"
1561        );
1562        for _ in 0..120 {
1563            body.walk(&scene, DT, WalkInput::default());
1564        }
1565        assert!(!body.is_swimming(), "wading must not engage the swim state");
1566        assert!(body.on_ground(), "wading body stands on the floor");
1567    }
1568
1569    #[test]
1570    fn breach_is_one_shot_until_jump_releases() {
1571        let scene = pool_scene();
1572        let mut body = CharacterBody::new(CharacterDef::default());
1573        body.teleport(DVec3::new(100.0, 100.0, 102.0));
1574        for _ in 0..300 {
1575            body.walk(&scene, DT, WalkInput::default());
1576        }
1577        let held = WalkInput {
1578            jump: true,
1579            ..WalkInput::default()
1580        };
1581        // First held frame: the breach impulse, exactly.
1582        body.walk(&scene, DT, held);
1583        assert_eq!(body.vel().z, -body.def().jump_speed, "breach fired");
1584        // Still held: NO re-impulse — the very next frame the
1585        // velocity must have integrated away from the exact impulse
1586        // value (the old bug pinned it to -jump_speed every frame).
1587        body.walk(&scene, DT, held);
1588        assert!(
1589            (body.vel().z - -body.def().jump_speed).abs() > 1e-9,
1590            "held jump re-impulsed: vel.z = {}",
1591            body.vel().z
1592        );
1593        // Release: fall back into the pool and settle at the bob
1594        // again (the breach launched the body clear of the water) —
1595        // then a fresh press re-arms the latch and breaches again.
1596        for _ in 0..300 {
1597            body.walk(&scene, DT, WalkInput::default());
1598        }
1599        assert!(body.is_swimming(), "back at the surface bob");
1600        body.walk(&scene, DT, held);
1601        assert_eq!(body.vel().z, -body.def().jump_speed, "re-armed breach");
1602    }
1603
1604    #[test]
1605    fn underwater_sink_respects_the_terminal_fall_cap() {
1606        // A legal tuning with no buoyancy and near-zero drag must not
1607        // sink faster than the dry terminal cap (gravity/drag alone
1608        // would reach 240 u/s here — 4× the cap — and could launch
1609        // the body out of the volume's bottom face).
1610        let mut scene = Scene::new();
1611        let id = scene.add_grid(GridTransform::identity());
1612        let grid = scene.grid_mut(id).expect("grid present");
1613        // A very deep column of water, no floor for a while.
1614        grid.add_water_volume(IVec3::new(60, 60, 100), IVec3::new(160, 160, 900));
1615        let def = CharacterDef {
1616            buoyancy: 0.0,
1617            water_drag: 0.1,
1618            ..CharacterDef::default()
1619        };
1620        let mut body = CharacterBody::new(def);
1621        body.teleport(DVec3::new(100.0, 100.0, 110.0));
1622        for _ in 0..600 {
1623            body.walk(&scene, DT, WalkInput::default());
1624            assert!(
1625                body.vel().z <= body.def().max_fall_speed + 1e-9,
1626                "sink speed {} exceeded the terminal cap",
1627                body.vel().z
1628            );
1629        }
1630        assert!(body.is_swimming(), "zero buoyancy: still under water");
1631    }
1632
1633    #[test]
1634    fn cork_tuning_does_not_limit_cycle_at_the_waterline() {
1635        // buoyancy 80 / gravity 24 → equilibrium fraction 0.3, BELOW
1636        // swim_exit_frac (0.35). Without the runtime clamp on the
1637        // release threshold this strobes swim → walk forever.
1638        let scene = pool_scene();
1639        let mut body = CharacterBody::new(CharacterDef {
1640            buoyancy: 80.0,
1641            ..CharacterDef::default()
1642        });
1643        body.teleport(DVec3::new(100.0, 100.0, 115.0));
1644        // The strong buoyancy legitimately POPS the cork clear of the
1645        // water first (airborne = not swimming — physical, not a
1646        // flicker); let the splash oscillation decay…
1647        for _ in 0..600 {
1648            body.walk(&scene, DT, WalkInput::default());
1649        }
1650        // …then the settled bob must hold ONE state steadily
1651        // (whichever the last threshold crossing left — the clamped
1652        // band contains the equilibrium, so either flag is honest).
1653        // Without continuous passive water forces this strobes every
1654        // few frames: the walk path's dry gravity vs the swim path's
1655        // buoyancy made a relay oscillator across the band.
1656        let settled = body.is_swimming();
1657        for i in 0..300 {
1658            body.walk(&scene, DT, WalkInput::default());
1659            assert_eq!(
1660                body.is_swimming(),
1661                settled,
1662                "settled cork flickered at frame {i}"
1663            );
1664        }
1665        // Floating high: equilibrium ≈ 0.3 of the body under water.
1666        let frac = body.submerged_fraction(&scene);
1667        assert!((frac - 0.3).abs() < 0.05, "cork equilibrium {frac}");
1668    }
1669
1670    #[test]
1671    fn fraction_survives_a_volume_that_stops_short_of_the_floor() {
1672        // An authored volume whose bottom face ends ABOVE the basin
1673        // floor: a diving body's feet exit through the bottom while
1674        // the head is still deep in water. The fraction must read
1675        // fully-submerged there, not snap to dry (the discontinuity
1676        // would strobe the swim state).
1677        let mut scene = Scene::new();
1678        let id = scene.add_grid(GridTransform::identity());
1679        let grid = scene.grid_mut(id).expect("grid present");
1680        grid.set_rect(
1681            IVec3::new(60, 60, 120),
1682            IVec3::new(160, 160, 130),
1683            Some(VoxColor(0x80_50_90_50)),
1684        );
1685        // Water stops at z = 110 — ten voxels short of the floor.
1686        grid.add_water_volume(IVec3::new(60, 60, 100), IVec3::new(160, 160, 109));
1687        let mut body = CharacterBody::new(CharacterDef::default());
1688        // Feet below the volume's bottom face, head well inside it.
1689        body.teleport(DVec3::new(100.0, 100.0, 111.0));
1690        assert_eq!(
1691            body.submerged_fraction(&scene),
1692            1.0,
1693            "feet under the volume's bottom + head in water = fully submerged"
1694        );
1695        // Diving straight through: the guard covers the straddle band
1696        // (feet out, head in), so the ONLY release happens when the
1697        // whole body has passed below the volume — one clean
1698        // swim→walk transition, no strobing across the bottom face.
1699        let mut transitions = 0;
1700        let mut was_swimming = false;
1701        for _ in 0..300 {
1702            body.walk(
1703                &scene,
1704                DT,
1705                WalkInput {
1706                    sink: true,
1707                    ..WalkInput::default()
1708                },
1709            );
1710            if was_swimming && !body.is_swimming() {
1711                transitions += 1;
1712            }
1713            was_swimming = body.is_swimming();
1714        }
1715        assert!(transitions <= 1, "strobed across the face: {transitions}");
1716        assert!(!body.is_swimming(), "fully below the volume = not in water");
1717        assert!(body.on_ground(), "landed on the basin floor");
1718    }
1719
1720    #[test]
1721    fn eye_in_water_flips_at_the_camera_not_the_feet() {
1722        let scene = pool_scene();
1723        let mut body = CharacterBody::new(CharacterDef::default());
1724        body.teleport(DVec3::new(100.0, 100.0, 102.0));
1725        for _ in 0..300 {
1726            body.walk(&scene, DT, WalkInput::default());
1727        }
1728        // Bobbing at the surface: swimming, but the camera is dry —
1729        // the tint/lowpass hook must be off.
1730        assert!(body.is_swimming());
1731        assert!(!body.eye_in_water(&scene), "surface bob: dry camera");
1732        // Dive: the eye goes under.
1733        for _ in 0..120 {
1734            body.walk(
1735                &scene,
1736                DT,
1737                WalkInput {
1738                    sink: true,
1739                    ..WalkInput::default()
1740                },
1741            );
1742        }
1743        assert!(body.eye_in_water(&scene), "diving: submerged camera");
1744    }
1745
1746    #[test]
1747    fn dry_walk_is_unaffected_by_far_away_water() {
1748        // Byte-identity gate: the same input sequence over the same
1749        // floor must produce the SAME trajectory whether or not the
1750        // scene has water somewhere else.
1751        let run = |with_water: bool| {
1752            let mut scene = ground_scene();
1753            if with_water {
1754                let id = scene.grids().next().expect("grid").0;
1755                scene
1756                    .grid_mut(id)
1757                    .expect("grid")
1758                    .add_water_volume(IVec3::new(150, 150, 90), IVec3::new(160, 160, 99));
1759            }
1760            let mut body = CharacterBody::new(CharacterDef::default());
1761            body.teleport(DVec3::new(100.0, 100.0, 95.0));
1762            let mut trace = Vec::new();
1763            for i in 0..240 {
1764                body.walk(
1765                    &scene,
1766                    DT,
1767                    WalkInput {
1768                        wish: DVec3::new(-1.0, 0.4, 0.0),
1769                        jump: i == 60,
1770                        sink: false,
1771                    },
1772                );
1773                trace.push(body.pos());
1774            }
1775            trace
1776        };
1777        assert_eq!(run(false), run(true), "dry path must be unperturbed");
1778    }
1779
1780    #[test]
1781    fn swims_identically_on_a_scaled_grid() {
1782        // SC discipline: the same pool authored on a vws = 0.5 grid
1783        // (double the voxel counts, half the size each) floats the
1784        // body to the same WORLD equilibrium.
1785        let mut scene = Scene::new();
1786        let id = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 0.5));
1787        let grid = scene.grid_mut(id).expect("grid present");
1788        grid.set_rect(
1789            IVec3::new(120, 120, 240),
1790            IVec3::new(320, 320, 260),
1791            Some(VoxColor(0x80_50_90_50)),
1792        );
1793        grid.add_water_volume(IVec3::new(120, 120, 200), IVec3::new(320, 320, 239));
1794        let mut body = CharacterBody::new(CharacterDef::default());
1795        body.teleport(DVec3::new(100.0, 100.0, 115.0));
1796        for _ in 0..600 {
1797            body.walk(&scene, DT, WalkInput::default());
1798        }
1799        assert!(body.is_swimming());
1800        let frac = body.submerged_fraction(&scene);
1801        let eq = equilibrium_frac(body.def());
1802        assert!(
1803            (frac - eq).abs() < 0.03,
1804            "scaled-grid equilibrium {frac} != {eq}"
1805        );
1806        // World surface plane: local z 200 × 0.5 = world z 100 — the
1807        // same waterline as the identity pool.
1808        let expect_feet = 100.0 + eq * body.def().height;
1809        assert!(
1810            (body.pos().z - expect_feet).abs() < 0.1,
1811            "feet at {} != {expect_feet}",
1812            body.pos().z
1813        );
1814    }
1815
1816    #[test]
1817    fn fly_mode_ignores_water_and_clears_swim_state() {
1818        let scene = pool_scene();
1819        let mut body = CharacterBody::new(CharacterDef::default());
1820        body.teleport(DVec3::new(100.0, 100.0, 110.0));
1821        for _ in 0..120 {
1822            body.walk(&scene, DT, WalkInput::default());
1823        }
1824        assert!(body.is_swimming(), "walk mode swims in the pool");
1825        body.set_mode(MoveMode::Fly);
1826        body.walk(&scene, DT, WalkInput::default());
1827        assert!(!body.is_swimming(), "fly cleared the swim state");
1828        // Hovering: no gravity, no buoyancy — velocity stays zero.
1829        for _ in 0..60 {
1830            body.walk(&scene, DT, WalkInput::default());
1831        }
1832        assert_eq!(body.vel(), DVec3::ZERO, "fly ignores water forces");
1833    }
1834}