1use glam::{DVec2, DVec3};
28
29use crate::collide::{box_overlaps_solid, Solidity};
30use crate::Scene;
31
32const SKIN: f64 = 1e-3;
35
36const MAX_SUBSTEPS: u32 = 10_000;
41
42#[derive(Debug, Clone, Copy)]
45pub struct CharacterDef {
46 pub radius: f64,
48 pub height: f64,
51 pub eye_height: f64,
54 pub gravity: f64,
56 pub jump_speed: f64,
59 pub max_fall_speed: f64,
65 pub walk_speed: f64,
67 pub accel_ground: f64,
71 pub accel_air: f64,
74 pub step_up: f64,
79 pub coyote_time: f64,
82 pub jump_buffer: f64,
85 pub fly_speed: f64,
88 pub fly_accel: f64,
93 pub solidity: Solidity,
97 pub buoyancy: f64,
103 pub water_drag: f64,
107 pub swim_speed: f64,
109 pub swim_accel: f64,
113 pub swim_enter_frac: f64,
119 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum MoveMode {
159 #[default]
162 Walk,
163 Fly,
166 Noclip,
168}
169
170#[derive(Debug, Clone, Copy, Default)]
172pub struct WalkInput {
173 pub wish: DVec3,
178 pub jump: bool,
185 pub sink: bool,
188}
189
190#[derive(Debug, Clone, Copy)]
195#[allow(clippy::struct_excessive_bools)]
198pub struct CharacterBody {
199 def: CharacterDef,
200 mode: MoveMode,
201 pos: DVec3,
204 vel: DVec3,
205 on_ground: bool,
206 hit_head: bool,
207 since_grounded: f64,
210 jump_buffer_left: f64,
212 swimming: bool,
216 breach_latched: bool,
222}
223
224impl CharacterBody {
225 #[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 #[must_use]
245 pub fn mode(&self) -> MoveMode {
246 self.mode
247 }
248
249 pub fn set_mode(&mut self, mode: MoveMode) {
252 self.mode = mode;
253 if mode != MoveMode::Walk {
258 self.swimming = false;
259 self.breach_latched = false;
260 }
261 }
262
263 #[must_use]
265 pub fn def(&self) -> &CharacterDef {
266 &self.def
267 }
268
269 pub fn def_mut(&mut self) -> &mut CharacterDef {
275 &mut self.def
276 }
277
278 #[must_use]
280 pub fn pos(&self) -> DVec3 {
281 self.pos
282 }
283
284 #[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 #[must_use]
293 pub fn vel(&self) -> DVec3 {
294 self.vel
295 }
296
297 pub fn set_vel(&mut self, vel: DVec3) {
299 self.vel = vel;
300 }
301
302 #[must_use]
305 pub fn on_ground(&self) -> bool {
306 self.on_ground
307 }
308
309 #[must_use]
312 pub fn hit_head(&self) -> bool {
313 self.hit_head
314 }
315
316 #[must_use]
321 pub fn is_swimming(&self) -> bool {
322 self.swimming
323 }
324
325 #[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 #[must_use]
362 pub fn eye_in_water(&self, scene: &Scene) -> bool {
363 scene.in_water(self.eye_pos())
364 }
365
366 pub fn set_pos(&mut self, pos: DVec3) {
370 self.pos = pos;
371 }
372
373 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 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 let frac = self.submerged_fraction(scene);
423 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; 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 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 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 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 if self.slide_move(scene, dt, true) {
502 self.since_grounded = f64::INFINITY;
505 return;
506 }
507
508 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 fn swim(&mut self, scene: &Scene, dt: f64, input: WalkInput, frac: f64) {
525 self.steer_horizontal(input.wish, self.def.swim_speed, self.def.swim_accel, dt);
527
528 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 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 self.vel.z -= self.vel.z * (self.def.water_drag * dt).min(1.0);
552 self.vel.z = self.vel.z.min(self.def.max_fall_speed);
556 }
557
558 self.since_grounded = f64::INFINITY;
562 self.jump_buffer_left = 0.0;
563
564 if self.slide_move(scene, dt, true) {
565 return; }
567 self.on_ground = self.ground_probe(scene);
568 }
569
570 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 fn fly(&mut self, scene: &Scene, dt: f64, input: WalkInput, collide: bool) {
588 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 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 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 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 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 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; }
675 let mut over = lifted;
676 over[axis] += delta;
677 if self.blocked_at(scene, over) {
678 return false; }
680 self.pos = over;
681
682 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 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 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 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 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 (candidate[axis] + max_off).floor() - SKIN - max_off
746 } else {
747 (candidate[axis] + min_off).floor() + 1.0 + SKIN - min_off
750 };
751 let mut flush = self.pos;
752 flush[axis] = clamped;
753 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
763fn 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 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 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 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 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 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 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 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 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 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 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 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 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)); 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 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 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); 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); 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 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 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 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 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 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 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 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 #[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), 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 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 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 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 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 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 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 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 for _ in 0..300 {
1479 body.walk(&scene, DT, WalkInput::default());
1480 }
1481 let surface_z = body.pos().z;
1482
1483 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 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 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 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 body.walk(&scene, DT, held);
1583 assert_eq!(body.vel().z, -body.def().jump_speed, "breach fired");
1584 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 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 let mut scene = Scene::new();
1611 let id = scene.add_grid(GridTransform::identity());
1612 let grid = scene.grid_mut(id).expect("grid present");
1613 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 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 for _ in 0..600 {
1648 body.walk(&scene, DT, WalkInput::default());
1649 }
1650 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 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 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 grid.add_water_volume(IVec3::new(60, 60, 100), IVec3::new(160, 160, 109));
1687 let mut body = CharacterBody::new(CharacterDef::default());
1688 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 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 assert!(body.is_swimming());
1731 assert!(!body.eye_in_water(&scene), "surface bob: dry camera");
1732 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 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 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 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 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}