1use glam::{DVec2, DVec3};
23
24use crate::collide::{box_overlaps_solid, Solidity};
25use crate::Scene;
26
27const SKIN: f64 = 1e-3;
30
31const MAX_SUBSTEPS: u32 = 10_000;
36
37#[derive(Debug, Clone, Copy)]
40pub struct CharacterDef {
41 pub radius: f64,
43 pub height: f64,
46 pub eye_height: f64,
49 pub gravity: f64,
51 pub jump_speed: f64,
54 pub max_fall_speed: f64,
60 pub walk_speed: f64,
62 pub accel_ground: f64,
66 pub accel_air: f64,
69 pub step_up: f64,
74 pub coyote_time: f64,
77 pub jump_buffer: f64,
80 pub fly_speed: f64,
83 pub fly_accel: f64,
88 pub solidity: Solidity,
92}
93
94impl Default for CharacterDef {
95 fn default() -> Self {
96 Self {
97 radius: 0.4,
98 height: 1.8,
99 eye_height: 1.62,
100 gravity: 24.0,
101 jump_speed: 9.0,
102 max_fall_speed: 60.0,
103 walk_speed: 6.0,
104 accel_ground: 40.0,
105 accel_air: 8.0,
106 step_up: 1.05,
107 coyote_time: 0.12,
108 jump_buffer: 0.12,
109 fly_speed: 12.0,
110 fly_accel: f64::INFINITY,
111 solidity: Solidity::default(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum MoveMode {
119 #[default]
122 Walk,
123 Fly,
126 Noclip,
128}
129
130#[derive(Debug, Clone, Copy, Default)]
132pub struct WalkInput {
133 pub wish: DVec3,
138 pub jump: bool,
142}
143
144#[derive(Debug, Clone, Copy)]
149pub struct CharacterBody {
150 def: CharacterDef,
151 mode: MoveMode,
152 pos: DVec3,
155 vel: DVec3,
156 on_ground: bool,
157 hit_head: bool,
158 since_grounded: f64,
161 jump_buffer_left: f64,
163}
164
165impl CharacterBody {
166 #[must_use]
169 pub fn new(def: CharacterDef) -> Self {
170 Self {
171 def,
172 mode: MoveMode::Walk,
173 pos: DVec3::ZERO,
174 vel: DVec3::ZERO,
175 on_ground: false,
176 hit_head: false,
177 since_grounded: f64::INFINITY,
178 jump_buffer_left: 0.0,
179 }
180 }
181
182 #[must_use]
184 pub fn mode(&self) -> MoveMode {
185 self.mode
186 }
187
188 pub fn set_mode(&mut self, mode: MoveMode) {
191 self.mode = mode;
192 }
193
194 #[must_use]
196 pub fn def(&self) -> &CharacterDef {
197 &self.def
198 }
199
200 pub fn def_mut(&mut self) -> &mut CharacterDef {
206 &mut self.def
207 }
208
209 #[must_use]
211 pub fn pos(&self) -> DVec3 {
212 self.pos
213 }
214
215 #[must_use]
218 pub fn eye_pos(&self) -> DVec3 {
219 self.pos - DVec3::new(0.0, 0.0, self.def.eye_height)
220 }
221
222 #[must_use]
224 pub fn vel(&self) -> DVec3 {
225 self.vel
226 }
227
228 pub fn set_vel(&mut self, vel: DVec3) {
230 self.vel = vel;
231 }
232
233 #[must_use]
236 pub fn on_ground(&self) -> bool {
237 self.on_ground
238 }
239
240 #[must_use]
243 pub fn hit_head(&self) -> bool {
244 self.hit_head
245 }
246
247 pub fn set_pos(&mut self, pos: DVec3) {
251 self.pos = pos;
252 }
253
254 pub fn teleport(&mut self, pos: DVec3) {
258 self.pos = pos;
259 self.vel = DVec3::ZERO;
260 self.on_ground = false;
261 self.hit_head = false;
262 self.since_grounded = f64::INFINITY;
263 self.jump_buffer_left = 0.0;
264 }
265
266 pub fn walk(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
286 self.hit_head = false;
287 if dt <= 0.0 {
288 return;
289 }
290 match self.mode {
291 MoveMode::Walk => self.walk_grounded(scene, dt, input),
292 MoveMode::Fly => self.fly(scene, dt, input, true),
293 MoveMode::Noclip => self.fly(scene, dt, input, false),
294 }
295 }
296
297 fn walk_grounded(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
298 let wish = input.wish.truncate();
300 let wish = if wish.length_squared() > 1.0 {
301 wish.normalize()
302 } else {
303 wish
304 };
305 let target = wish * self.def.walk_speed;
306 let accel = if self.on_ground {
307 self.def.accel_ground
308 } else {
309 self.def.accel_air
310 };
311 let horizontal = move_toward(self.vel.truncate(), target, accel * dt);
312 self.vel.x = horizontal.x;
313 self.vel.y = horizontal.y;
314
315 self.vel.z = (self.vel.z + self.def.gravity * dt).min(self.def.max_fall_speed);
316
317 if input.jump {
319 self.jump_buffer_left = self.def.jump_buffer;
320 }
321 let can_jump = self.on_ground || self.since_grounded <= self.def.coyote_time;
322 if self.jump_buffer_left > 0.0 && can_jump {
323 self.vel.z = -self.def.jump_speed;
324 self.jump_buffer_left = 0.0;
325 self.since_grounded = f64::INFINITY;
327 self.on_ground = false;
328 }
329 self.jump_buffer_left = (self.jump_buffer_left - dt).max(0.0);
330
331 if self.slide_move(scene, dt, true) {
333 self.since_grounded = f64::INFINITY;
336 return;
337 }
338
339 self.on_ground = self.ground_probe(scene);
341 if self.on_ground {
342 self.since_grounded = 0.0;
343 } else {
344 self.since_grounded += dt;
345 }
346 }
347
348 fn fly(&mut self, scene: &Scene, dt: f64, input: WalkInput, collide: bool) {
351 let wish = if input.wish.length_squared() > 1.0 {
352 input.wish.normalize()
353 } else {
354 input.wish
355 };
356 let target = wish * self.def.fly_speed;
357 let max_delta = self.def.fly_accel * dt;
358 let delta = target - self.vel;
359 let len = delta.length();
360 self.vel = if len <= max_delta || len < 1e-12 {
361 target
362 } else {
363 self.vel + delta * (max_delta / len)
364 };
365
366 if collide {
367 if !self.slide_move(scene, dt, false) {
368 self.on_ground = self.ground_probe(scene);
369 }
370 } else {
371 self.pos += self.vel * dt;
372 self.on_ground = false;
373 }
374 self.since_grounded = f64::INFINITY;
375 self.jump_buffer_left = 0.0;
376 }
377
378 fn slide_move(&mut self, scene: &Scene, dt: f64, step_up: bool) -> bool {
383 let mut disp = self.vel * dt;
384
385 if self.blocked_at(scene, self.pos) {
386 self.pos += disp;
388 self.on_ground = false;
389 return true;
390 }
391
392 let max_step = self.def.radius.min(0.5);
393 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
394 let substeps = ((disp.abs().max_element() / max_step).ceil() as u32).clamp(1, MAX_SUBSTEPS);
395 if disp.abs().max_element() > f64::from(MAX_SUBSTEPS) * max_step {
396 disp *= f64::from(MAX_SUBSTEPS) * max_step / disp.abs().max_element();
398 }
399 let step = disp / f64::from(substeps);
400
401 for _ in 0..substeps {
402 for axis in 0..3 {
403 if self.move_axis(scene, axis, step[axis]) {
404 if axis < 2
405 && step_up
406 && self.on_ground
407 && self.def.step_up > 0.0
408 && self.try_step_up(scene, axis, step[axis])
409 {
410 continue;
412 }
413 if axis == 2 && step.z < 0.0 {
414 self.hit_head = true;
415 }
416 self.vel[axis] = 0.0;
417 }
418 }
419 }
420 false
421 }
422
423 fn try_step_up(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
428 let saved = self.pos;
429
430 let mut lifted = self.pos;
431 lifted.z -= self.def.step_up;
432 if self.blocked_at(scene, lifted) {
433 return false; }
435 let mut over = lifted;
436 over[axis] += delta;
437 if self.blocked_at(scene, over) {
438 return false; }
440 self.pos = over;
441
442 if self.move_axis(scene, 2, self.def.step_up + SKIN) {
446 true
447 } else {
448 self.pos = saved;
449 false
450 }
451 }
452
453 fn ground_probe(&self, scene: &Scene) -> bool {
457 let (bmin, bmax) = self.box_at(self.pos);
458 box_overlaps_solid(
459 scene,
460 DVec3::new(bmin.x, bmin.y, bmax.z),
461 DVec3::new(bmax.x, bmax.y, bmax.z + 2.0 * SKIN),
462 self.def.solidity,
463 )
464 }
465
466 fn box_at(&self, pos: DVec3) -> (DVec3, DVec3) {
468 let r = self.def.radius;
469 (
470 DVec3::new(pos.x - r, pos.y - r, pos.z - self.def.height),
471 DVec3::new(pos.x + r, pos.y + r, pos.z),
472 )
473 }
474
475 fn blocked_at(&self, scene: &Scene, pos: DVec3) -> bool {
476 let (bmin, bmax) = self.box_at(pos);
477 box_overlaps_solid(scene, bmin, bmax, self.def.solidity)
478 }
479
480 fn move_axis(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
487 if delta == 0.0 {
488 return false;
489 }
490 let mut candidate = self.pos;
491 candidate[axis] += delta;
492 if !self.blocked_at(scene, candidate) {
493 self.pos = candidate;
494 return false;
495 }
496
497 let (min_off, max_off) = {
499 let (bmin, bmax) = self.box_at(DVec3::ZERO);
500 (bmin[axis], bmax[axis])
501 };
502 let clamped = if delta > 0.0 {
503 (candidate[axis] + max_off).floor() - SKIN - max_off
506 } else {
507 (candidate[axis] + min_off).floor() + 1.0 + SKIN - min_off
510 };
511 let mut flush = self.pos;
512 flush[axis] = clamped;
513 let overshoots = (clamped - self.pos[axis]).abs() > delta.abs() + SKIN;
516 if !overshoots && !self.blocked_at(scene, flush) {
517 self.pos = flush;
518 }
519 true
520 }
521}
522
523fn move_toward(from: DVec2, to: DVec2, max_delta: f64) -> DVec2 {
526 let delta = to - from;
527 let len = delta.length();
528 if len <= max_delta || len < 1e-12 {
529 to
530 } else {
531 from + delta * (max_delta / len)
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::{GridTransform, VoxColor};
539 use glam::IVec3;
540
541 const DT: f64 = 1.0 / 60.0;
542
543 fn ground_scene() -> Scene {
546 let mut scene = Scene::new();
547 let id = scene.add_grid(GridTransform::identity());
548 let grid = scene.grid_mut(id).expect("grid present");
549 grid.set_rect(
550 IVec3::new(60, 60, 100),
551 IVec3::new(160, 160, 110),
552 Some(VoxColor(0x80_50_90_50)),
553 );
554 scene
555 }
556
557 fn body_on_ground(scene: &Scene) -> CharacterBody {
558 let mut body = CharacterBody::new(CharacterDef::default());
559 body.teleport(DVec3::new(100.0, 100.0, 95.0));
560 for _ in 0..120 {
562 body.walk(scene, DT, WalkInput::default());
563 }
564 assert!(body.on_ground(), "settle: body must land");
565 body
566 }
567
568 #[test]
569 fn falls_and_lands_flush_on_the_surface_plane() {
570 let scene = ground_scene();
571 let body = body_on_ground(&scene);
572 assert!(
574 (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
575 "feet at {} != {}",
576 body.pos().z,
577 100.0 - SKIN
578 );
579 assert_eq!(body.vel().z, 0.0);
580 assert!(!body.hit_head());
581 }
582
583 #[test]
584 fn walk_reaches_and_holds_walk_speed() {
585 let scene = ground_scene();
586 let mut body = body_on_ground(&scene);
587 let input = WalkInput {
588 wish: DVec3::new(1.0, 0.0, 0.0),
589 jump: false,
590 };
591 for _ in 0..180 {
592 body.walk(&scene, DT, input);
593 }
594 let speed = body.vel().truncate().length();
595 assert!(
596 (speed - body.def().walk_speed).abs() < 1e-9,
597 "converged speed {speed}"
598 );
599 assert!(body.on_ground());
600 }
601
602 #[test]
603 fn walk_into_wall_clamps_flush_and_slides() {
604 let mut scene = ground_scene();
605 {
606 let id = scene.grids().next().expect("grid").0;
607 let grid = scene.grid_mut(id).expect("grid");
608 grid.set_rect(
610 IVec3::new(105, 60, 80),
611 IVec3::new(106, 160, 110),
612 Some(VoxColor(0x80_90_50_50)),
613 );
614 }
615 let mut body = body_on_ground(&scene);
616 let y0 = body.pos().y;
617 let input = WalkInput {
618 wish: DVec3::new(1.0, 1.0, 0.0),
619 jump: false,
620 };
621 for _ in 0..240 {
622 body.walk(&scene, DT, input);
623 }
624 let expected_x = 105.0 - body.def().radius - SKIN;
626 assert!(
627 (body.pos().x - expected_x).abs() < 1e-9,
628 "x {} != flush {}",
629 body.pos().x,
630 expected_x
631 );
632 assert_eq!(body.vel().x, 0.0, "blocked axis velocity zeroed");
633 assert!(body.pos().y > y0 + 3.0, "slid along the wall");
635 }
636
637 #[test]
638 fn jump_apex_matches_ballistics_and_relands() {
639 let scene = ground_scene();
640 let mut body = body_on_ground(&scene);
641 let start_z = body.pos().z;
642 let def = *body.def();
643
644 body.walk(
645 &scene,
646 DT,
647 WalkInput {
648 wish: DVec3::ZERO,
649 jump: true,
650 },
651 );
652 assert!(!body.on_ground(), "airborne after jump");
653
654 let mut apex_rise = 0.0f64;
655 let mut relanded = false;
656 for _ in 0..240 {
657 body.walk(&scene, DT, WalkInput::default());
658 apex_rise = apex_rise.max(start_z - body.pos().z);
659 if body.on_ground() {
660 relanded = true;
661 break;
662 }
663 }
664 assert!(relanded, "must land again");
665 let ideal = def.jump_speed * def.jump_speed / (2.0 * def.gravity);
667 assert!(
668 (apex_rise - ideal).abs() < 0.2,
669 "apex {apex_rise} vs ideal {ideal}"
670 );
671 assert!((body.pos().z - start_z).abs() < 1e-9, "back on the floor");
672 }
673
674 #[test]
675 fn head_bump_stops_the_jump() {
676 let mut scene = ground_scene();
677 {
678 let id = scene.grids().next().expect("grid").0;
679 let grid = scene.grid_mut(id).expect("grid");
680 grid.set_rect(
684 IVec3::new(60, 60, 96),
685 IVec3::new(160, 160, 97),
686 Some(VoxColor(0x80_50_50_90)),
687 );
688 }
689 let mut body = CharacterBody::new(CharacterDef::default());
693 body.teleport(DVec3::new(100.0, 100.0, 99.9));
694 for _ in 0..30 {
695 body.walk(&scene, DT, WalkInput::default());
696 }
697 assert!(body.on_ground(), "settled in the gap");
698 body.walk(
699 &scene,
700 DT,
701 WalkInput {
702 wish: DVec3::ZERO,
703 jump: true,
704 },
705 );
706 let mut bumped = false;
707 for _ in 0..120 {
708 body.walk(&scene, DT, WalkInput::default());
709 if body.hit_head() {
710 bumped = true;
711 let head = body.pos().z - body.def().height;
714 assert!((head - (98.0 + SKIN)).abs() < 1e-9, "head at {head}");
715 assert!(body.vel().z >= 0.0);
716 }
717 if body.on_ground() && bumped {
718 break;
719 }
720 }
721 assert!(bumped, "must bump the ceiling");
722 assert!(body.on_ground(), "falls back to the floor");
723 }
724
725 #[test]
726 fn fast_fall_does_not_tunnel_thin_floor() {
727 let mut scene = Scene::new();
728 let id = scene.add_grid(GridTransform::identity());
729 let grid = scene.grid_mut(id).expect("grid present");
730 grid.set_rect(
732 IVec3::new(60, 60, 100),
733 IVec3::new(160, 160, 100),
734 Some(VoxColor(0x80_70_70_70)),
735 );
736 let mut body = CharacterBody::new(CharacterDef {
737 max_fall_speed: 400.0,
740 ..CharacterDef::default()
741 });
742 body.teleport(DVec3::new(100.0, 100.0, 60.0));
743 body.set_vel(DVec3::new(0.0, 0.0, 200.0)); for _ in 0..5 {
745 body.walk(&scene, 0.1, WalkInput::default());
746 }
747 assert!(
748 (body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
749 "feet at {} — tunneled?",
750 body.pos().z
751 );
752 assert!(body.on_ground());
753 }
754
755 #[test]
756 fn stuck_body_escapes_freely() {
757 let scene = ground_scene();
758 let mut body = CharacterBody::new(CharacterDef::default());
759 body.teleport(DVec3::new(100.0, 100.0, 105.0));
761 assert!({
762 let (bmin, bmax) = body.box_at(body.pos());
763 box_overlaps_solid(&scene, bmin, bmax, Solidity::default())
764 });
765 let z0 = body.pos().z;
766 body.walk(
767 &scene,
768 DT,
769 WalkInput {
770 wish: DVec3::new(1.0, 0.0, 0.0),
771 jump: false,
772 },
773 );
774 assert!(body.pos().z > z0, "gravity still applies while stuck");
775 assert!(body.pos().x > 100.0, "input still applies while stuck");
776 assert!(!body.on_ground());
777 }
778
779 fn ledge_scene(ledge_top_z: i32) -> Scene {
782 let mut scene = ground_scene();
783 let id = scene.grids().next().expect("grid").0;
784 let grid = scene.grid_mut(id).expect("grid");
785 grid.set_rect(
786 IVec3::new(105, 60, ledge_top_z),
787 IVec3::new(160, 160, 99),
788 Some(VoxColor(0x80_80_80_40)),
789 );
790 scene
791 }
792
793 #[test]
794 fn step_up_climbs_a_one_voxel_ledge() {
795 let scene = ledge_scene(99); let mut body = body_on_ground(&scene);
797 let input = WalkInput {
798 wish: DVec3::new(1.0, 0.0, 0.0),
799 jump: false,
800 };
801 for _ in 0..240 {
802 body.walk(&scene, DT, input);
803 }
804 assert!(body.pos().x > 106.0, "walked onto the ledge");
805 assert!(
806 (body.pos().z - (99.0 - SKIN)).abs() < 1e-9,
807 "feet on the ledge plane, got {}",
808 body.pos().z
809 );
810 assert!(body.on_ground());
811 }
812
813 #[test]
814 fn step_up_refuses_a_two_voxel_wall() {
815 let scene = ledge_scene(98); let mut body = body_on_ground(&scene);
817 let input = WalkInput {
818 wish: DVec3::new(1.0, 0.0, 0.0),
819 jump: false,
820 };
821 for _ in 0..240 {
822 body.walk(&scene, DT, input);
823 }
824 let expected_x = 105.0 - body.def().radius - SKIN;
825 assert!(
826 (body.pos().x - expected_x).abs() < 1e-9,
827 "clamped at {}, expected flush {expected_x}",
828 body.pos().x
829 );
830 assert!((body.pos().z - (100.0 - SKIN)).abs() < 1e-9, "stayed down");
831 }
832
833 #[test]
834 fn step_up_needs_headroom() {
835 let mut scene = ledge_scene(99);
836 {
837 let id = scene.grids().next().expect("grid").0;
838 let grid = scene.grid_mut(id).expect("grid");
839 grid.set_rect(
843 IVec3::new(104, 60, 97),
844 IVec3::new(160, 160, 97),
845 Some(VoxColor(0x80_40_40_80)),
846 );
847 }
848 let mut body = body_on_ground(&scene);
849 let input = WalkInput {
850 wish: DVec3::new(1.0, 0.0, 0.0),
851 jump: false,
852 };
853 for _ in 0..240 {
854 body.walk(&scene, DT, input);
855 }
856 let expected_x = 105.0 - body.def().radius - SKIN;
857 assert!(
858 (body.pos().x - expected_x).abs() < 1e-9,
859 "no headroom ⇒ no step, got x {}",
860 body.pos().x
861 );
862 }
863
864 #[test]
865 fn coyote_jump_after_walking_off_an_edge() {
866 let scene = ground_scene();
869 let mut body = body_on_ground(&scene);
870 body.teleport(DVec3::new(159.0, 100.0, 95.0));
871 for _ in 0..120 {
872 body.walk(&scene, DT, WalkInput::default());
873 }
874 assert!(body.on_ground());
875 let input = WalkInput {
876 wish: DVec3::new(1.0, 0.0, 0.0),
877 jump: false,
878 };
879 while body.on_ground() {
880 body.walk(&scene, DT, input);
881 }
882 body.walk(&scene, DT, input);
883 body.walk(&scene, DT, input);
884 body.walk(
885 &scene,
886 DT,
887 WalkInput {
888 wish: DVec3::new(1.0, 0.0, 0.0),
889 jump: true,
890 },
891 );
892 assert!(
893 body.vel().z < -0.5 * body.def().jump_speed,
894 "coyote jump fired, vel.z = {}",
895 body.vel().z
896 );
897 }
898
899 #[test]
900 fn coyote_does_not_double_jump() {
901 let scene = ground_scene();
902 let mut body = body_on_ground(&scene);
903 body.walk(
904 &scene,
905 DT,
906 WalkInput {
907 wish: DVec3::ZERO,
908 jump: true,
909 },
910 );
911 let rising = body.vel().z;
912 assert!(rising < 0.0);
913 for _ in 0..30 {
917 body.walk(&scene, DT, WalkInput::default());
918 }
919 let before = body.vel().z;
920 body.walk(
921 &scene,
922 DT,
923 WalkInput {
924 wish: DVec3::ZERO,
925 jump: true,
926 },
927 );
928 assert!(
929 body.vel().z > before,
930 "still decelerating upward/falling — no mid-air re-jump"
931 );
932 }
933
934 #[test]
935 fn buffered_jump_fires_on_landing() {
936 let scene = ground_scene();
937 let mut body = CharacterBody::new(CharacterDef::default());
938 body.teleport(DVec3::new(100.0, 100.0, 99.9));
939 body.walk(
944 &scene,
945 DT,
946 WalkInput {
947 wish: DVec3::ZERO,
948 jump: true,
949 },
950 );
951 assert!(!body.on_ground(), "still airborne at press");
952 let mut jumped = false;
953 for _ in 0..30 {
954 body.walk(&scene, DT, WalkInput::default());
955 if body.vel().z <= -0.9 * body.def().jump_speed {
956 jumped = true;
957 break;
958 }
959 }
960 assert!(jumped, "buffered jump fired on landing");
961 }
962
963 #[test]
964 fn fly_mode_hovers_and_slides() {
965 let mut scene = ground_scene();
966 {
967 let id = scene.grids().next().expect("grid").0;
968 let grid = scene.grid_mut(id).expect("grid");
969 grid.set_rect(
970 IVec3::new(105, 60, 80),
971 IVec3::new(106, 160, 110),
972 Some(VoxColor(0x80_90_50_50)),
973 );
974 }
975 let mut body = CharacterBody::new(CharacterDef::default());
976 body.set_mode(MoveMode::Fly);
977 body.teleport(DVec3::new(100.0, 100.0, 95.0));
978 for _ in 0..60 {
980 body.walk(&scene, DT, WalkInput::default());
981 }
982 assert_eq!(body.pos().z, 95.0, "no gravity in fly mode");
983 let input = WalkInput {
985 wish: DVec3::new(1.0, 0.0, -0.2),
986 jump: false,
987 };
988 for _ in 0..240 {
989 body.walk(&scene, DT, input);
990 }
991 let expected_x = 105.0 - body.def().radius - SKIN;
992 assert!(
993 (body.pos().x - expected_x).abs() < 1e-9,
994 "fly clamps at the wall, got {}",
995 body.pos().x
996 );
997 assert!(body.pos().z < 95.0, "the -z wish component climbed");
998 }
999
1000 #[test]
1001 fn fly_stops_instantly_when_wish_drops() {
1002 let scene = ground_scene();
1006 let mut body = CharacterBody::new(CharacterDef::default());
1007 body.set_mode(MoveMode::Fly);
1008 body.teleport(DVec3::new(100.0, 100.0, 95.0));
1009 body.walk(
1010 &scene,
1011 DT,
1012 WalkInput {
1013 wish: DVec3::new(1.0, 0.0, 0.0),
1014 jump: false,
1015 },
1016 );
1017 assert!(
1018 (body.vel().x - body.def().fly_speed).abs() < 1e-9,
1019 "instant spin-up to fly_speed"
1020 );
1021 let x_after_release = {
1022 body.walk(&scene, DT, WalkInput::default());
1023 body.pos().x
1024 };
1025 assert_eq!(body.vel(), DVec3::ZERO, "instant stop on zero wish");
1026 body.walk(&scene, DT, WalkInput::default());
1027 assert_eq!(body.pos().x, x_after_release, "no drift while idle");
1028 }
1029
1030 #[test]
1031 fn noclip_passes_through_the_wall() {
1032 let mut scene = ground_scene();
1033 {
1034 let id = scene.grids().next().expect("grid").0;
1035 let grid = scene.grid_mut(id).expect("grid");
1036 grid.set_rect(
1037 IVec3::new(105, 60, 80),
1038 IVec3::new(106, 160, 110),
1039 Some(VoxColor(0x80_90_50_50)),
1040 );
1041 }
1042 let mut body = CharacterBody::new(CharacterDef::default());
1043 body.set_mode(MoveMode::Noclip);
1044 body.teleport(DVec3::new(100.0, 100.0, 95.0));
1045 let input = WalkInput {
1046 wish: DVec3::new(1.0, 0.0, 0.0),
1047 jump: false,
1048 };
1049 for _ in 0..240 {
1050 body.walk(&scene, DT, input);
1051 }
1052 assert!(
1053 body.pos().x > 110.0,
1054 "ghosted through, x = {}",
1055 body.pos().x
1056 );
1057 assert!(!body.on_ground());
1058 }
1059
1060 #[test]
1066 #[ignore = "perf probe, run by hand with --ignored --nocapture"]
1067 fn stress_probe() {
1068 let mut scene = ground_scene();
1069 {
1070 let id = scene.grids().next().expect("grid").0;
1071 let grid = scene.grid_mut(id).expect("grid");
1072 grid.set_rect(
1073 IVec3::new(105, 60, 80),
1074 IVec3::new(106, 160, 110),
1075 Some(VoxColor(0x80_90_50_50)),
1076 );
1077 }
1078 let input = WalkInput {
1079 wish: DVec3::new(1.0, 0.0, 0.0), jump: false,
1081 };
1082
1083 let mut body = body_on_ground(&scene);
1084 let t0 = std::time::Instant::now();
1085 const FRAMES: u32 = 60_000;
1086 for _ in 0..FRAMES {
1087 body.walk(&scene, DT, input);
1088 }
1089 let per_frame = t0.elapsed() / FRAMES;
1090 println!("single wall-hugging walker: {per_frame:?}/frame");
1091
1092 let mut crowd: Vec<CharacterBody> = (0..100)
1093 .map(|i| {
1094 let mut b = CharacterBody::new(CharacterDef::default());
1095 #[allow(clippy::cast_lossless)]
1096 b.teleport(DVec3::new(
1097 70.0 + f64::from(i % 10) * 3.0,
1098 70.0 + f64::from(i / 10) * 3.0,
1099 95.0,
1100 ));
1101 b
1102 })
1103 .collect();
1104 for _ in 0..120 {
1105 for b in &mut crowd {
1106 b.walk(&scene, DT, WalkInput::default());
1107 }
1108 }
1109 let t0 = std::time::Instant::now();
1110 const CROWD_FRAMES: u32 = 600;
1111 for _ in 0..CROWD_FRAMES {
1112 for b in &mut crowd {
1113 b.walk(&scene, DT, input);
1114 }
1115 }
1116 let per_crowd_frame = t0.elapsed() / CROWD_FRAMES;
1117 println!("100-NPC crowd: {per_crowd_frame:?}/frame (all 100 walking)");
1118 }
1119
1120 #[test]
1121 fn fall_speed_caps_at_terminal_velocity() {
1122 let scene = Scene::new();
1126 let mut body = CharacterBody::new(CharacterDef::default());
1127 body.teleport(DVec3::new(0.0, 0.0, 0.0));
1128 for _ in 0..600 {
1129 body.walk(&scene, DT, WalkInput::default());
1130 }
1131 assert_eq!(body.vel().z, body.def().max_fall_speed);
1132 }
1133
1134 #[test]
1135 fn deterministic_trajectory() {
1136 let scene = ground_scene();
1137 let run = || {
1138 let mut body = CharacterBody::new(CharacterDef::default());
1139 body.teleport(DVec3::new(100.0, 100.0, 95.0));
1140 let mut trace = Vec::new();
1141 for i in 0..180 {
1142 body.walk(
1143 &scene,
1144 DT,
1145 WalkInput {
1146 wish: DVec3::new(1.0, 0.3, 0.0),
1147 jump: i == 90,
1148 },
1149 );
1150 trace.push(body.pos());
1151 }
1152 trace
1153 };
1154 assert_eq!(run(), run(), "same input ⇒ bit-identical trajectory");
1155 }
1156}