use glam::{DVec2, DVec3};
use crate::collide::{box_overlaps_solid, Solidity};
use crate::Scene;
const SKIN: f64 = 1e-3;
const MAX_SUBSTEPS: u32 = 10_000;
#[derive(Debug, Clone, Copy)]
pub struct CharacterDef {
pub radius: f64,
pub height: f64,
pub eye_height: f64,
pub gravity: f64,
pub jump_speed: f64,
pub max_fall_speed: f64,
pub walk_speed: f64,
pub accel_ground: f64,
pub accel_air: f64,
pub step_up: f64,
pub coyote_time: f64,
pub jump_buffer: f64,
pub fly_speed: f64,
pub fly_accel: f64,
pub solidity: Solidity,
}
impl Default for CharacterDef {
fn default() -> Self {
Self {
radius: 0.4,
height: 1.8,
eye_height: 1.62,
gravity: 24.0,
jump_speed: 9.0,
max_fall_speed: 60.0,
walk_speed: 6.0,
accel_ground: 40.0,
accel_air: 8.0,
step_up: 1.05,
coyote_time: 0.12,
jump_buffer: 0.12,
fly_speed: 12.0,
fly_accel: f64::INFINITY,
solidity: Solidity::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MoveMode {
#[default]
Walk,
Fly,
Noclip,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WalkInput {
pub wish: DVec3,
pub jump: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct CharacterBody {
def: CharacterDef,
mode: MoveMode,
pos: DVec3,
vel: DVec3,
on_ground: bool,
hit_head: bool,
since_grounded: f64,
jump_buffer_left: f64,
}
impl CharacterBody {
#[must_use]
pub fn new(def: CharacterDef) -> Self {
Self {
def,
mode: MoveMode::Walk,
pos: DVec3::ZERO,
vel: DVec3::ZERO,
on_ground: false,
hit_head: false,
since_grounded: f64::INFINITY,
jump_buffer_left: 0.0,
}
}
#[must_use]
pub fn mode(&self) -> MoveMode {
self.mode
}
pub fn set_mode(&mut self, mode: MoveMode) {
self.mode = mode;
}
#[must_use]
pub fn def(&self) -> &CharacterDef {
&self.def
}
pub fn def_mut(&mut self) -> &mut CharacterDef {
&mut self.def
}
#[must_use]
pub fn pos(&self) -> DVec3 {
self.pos
}
#[must_use]
pub fn eye_pos(&self) -> DVec3 {
self.pos - DVec3::new(0.0, 0.0, self.def.eye_height)
}
#[must_use]
pub fn vel(&self) -> DVec3 {
self.vel
}
pub fn set_vel(&mut self, vel: DVec3) {
self.vel = vel;
}
#[must_use]
pub fn on_ground(&self) -> bool {
self.on_ground
}
#[must_use]
pub fn hit_head(&self) -> bool {
self.hit_head
}
pub fn set_pos(&mut self, pos: DVec3) {
self.pos = pos;
}
pub fn teleport(&mut self, pos: DVec3) {
self.pos = pos;
self.vel = DVec3::ZERO;
self.on_ground = false;
self.hit_head = false;
self.since_grounded = f64::INFINITY;
self.jump_buffer_left = 0.0;
}
pub fn walk(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
self.hit_head = false;
if dt <= 0.0 {
return;
}
match self.mode {
MoveMode::Walk => self.walk_grounded(scene, dt, input),
MoveMode::Fly => self.fly(scene, dt, input, true),
MoveMode::Noclip => self.fly(scene, dt, input, false),
}
}
fn walk_grounded(&mut self, scene: &Scene, dt: f64, input: WalkInput) {
let wish = input.wish.truncate();
let wish = if wish.length_squared() > 1.0 {
wish.normalize()
} else {
wish
};
let target = wish * self.def.walk_speed;
let accel = if self.on_ground {
self.def.accel_ground
} else {
self.def.accel_air
};
let horizontal = move_toward(self.vel.truncate(), target, accel * dt);
self.vel.x = horizontal.x;
self.vel.y = horizontal.y;
self.vel.z = (self.vel.z + self.def.gravity * dt).min(self.def.max_fall_speed);
if input.jump {
self.jump_buffer_left = self.def.jump_buffer;
}
let can_jump = self.on_ground || self.since_grounded <= self.def.coyote_time;
if self.jump_buffer_left > 0.0 && can_jump {
self.vel.z = -self.def.jump_speed;
self.jump_buffer_left = 0.0;
self.since_grounded = f64::INFINITY;
self.on_ground = false;
}
self.jump_buffer_left = (self.jump_buffer_left - dt).max(0.0);
if self.slide_move(scene, dt, true) {
self.since_grounded = f64::INFINITY;
return;
}
self.on_ground = self.ground_probe(scene);
if self.on_ground {
self.since_grounded = 0.0;
} else {
self.since_grounded += dt;
}
}
fn fly(&mut self, scene: &Scene, dt: f64, input: WalkInput, collide: bool) {
let wish = if input.wish.length_squared() > 1.0 {
input.wish.normalize()
} else {
input.wish
};
let target = wish * self.def.fly_speed;
let max_delta = self.def.fly_accel * dt;
let delta = target - self.vel;
let len = delta.length();
self.vel = if len <= max_delta || len < 1e-12 {
target
} else {
self.vel + delta * (max_delta / len)
};
if collide {
if !self.slide_move(scene, dt, false) {
self.on_ground = self.ground_probe(scene);
}
} else {
self.pos += self.vel * dt;
self.on_ground = false;
}
self.since_grounded = f64::INFINITY;
self.jump_buffer_left = 0.0;
}
fn slide_move(&mut self, scene: &Scene, dt: f64, step_up: bool) -> bool {
let mut disp = self.vel * dt;
if self.blocked_at(scene, self.pos) {
self.pos += disp;
self.on_ground = false;
return true;
}
let max_step = self.def.radius.min(0.5);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let substeps = ((disp.abs().max_element() / max_step).ceil() as u32).clamp(1, MAX_SUBSTEPS);
if disp.abs().max_element() > f64::from(MAX_SUBSTEPS) * max_step {
disp *= f64::from(MAX_SUBSTEPS) * max_step / disp.abs().max_element();
}
let step = disp / f64::from(substeps);
for _ in 0..substeps {
for axis in 0..3 {
if self.move_axis(scene, axis, step[axis]) {
if axis < 2
&& step_up
&& self.on_ground
&& self.def.step_up > 0.0
&& self.try_step_up(scene, axis, step[axis])
{
continue;
}
if axis == 2 && step.z < 0.0 {
self.hit_head = true;
}
self.vel[axis] = 0.0;
}
}
}
false
}
fn try_step_up(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
let saved = self.pos;
let mut lifted = self.pos;
lifted.z -= self.def.step_up;
if self.blocked_at(scene, lifted) {
return false; }
let mut over = lifted;
over[axis] += delta;
if self.blocked_at(scene, over) {
return false; }
self.pos = over;
if self.move_axis(scene, 2, self.def.step_up + SKIN) {
true
} else {
self.pos = saved;
false
}
}
fn ground_probe(&self, scene: &Scene) -> bool {
let (bmin, bmax) = self.box_at(self.pos);
box_overlaps_solid(
scene,
DVec3::new(bmin.x, bmin.y, bmax.z),
DVec3::new(bmax.x, bmax.y, bmax.z + 2.0 * SKIN),
self.def.solidity,
)
}
fn box_at(&self, pos: DVec3) -> (DVec3, DVec3) {
let r = self.def.radius;
(
DVec3::new(pos.x - r, pos.y - r, pos.z - self.def.height),
DVec3::new(pos.x + r, pos.y + r, pos.z),
)
}
fn blocked_at(&self, scene: &Scene, pos: DVec3) -> bool {
let (bmin, bmax) = self.box_at(pos);
box_overlaps_solid(scene, bmin, bmax, self.def.solidity)
}
fn move_axis(&mut self, scene: &Scene, axis: usize, delta: f64) -> bool {
if delta == 0.0 {
return false;
}
let mut candidate = self.pos;
candidate[axis] += delta;
if !self.blocked_at(scene, candidate) {
self.pos = candidate;
return false;
}
let (min_off, max_off) = {
let (bmin, bmax) = self.box_at(DVec3::ZERO);
(bmin[axis], bmax[axis])
};
let clamped = if delta > 0.0 {
(candidate[axis] + max_off).floor() - SKIN - max_off
} else {
(candidate[axis] + min_off).floor() + 1.0 + SKIN - min_off
};
let mut flush = self.pos;
flush[axis] = clamped;
let overshoots = (clamped - self.pos[axis]).abs() > delta.abs() + SKIN;
if !overshoots && !self.blocked_at(scene, flush) {
self.pos = flush;
}
true
}
}
fn move_toward(from: DVec2, to: DVec2, max_delta: f64) -> DVec2 {
let delta = to - from;
let len = delta.length();
if len <= max_delta || len < 1e-12 {
to
} else {
from + delta * (max_delta / len)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{GridTransform, VoxColor};
use glam::IVec3;
const DT: f64 = 1.0 / 60.0;
fn ground_scene() -> Scene {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let grid = scene.grid_mut(id).expect("grid present");
grid.set_rect(
IVec3::new(60, 60, 100),
IVec3::new(160, 160, 110),
Some(VoxColor(0x80_50_90_50)),
);
scene
}
fn body_on_ground(scene: &Scene) -> CharacterBody {
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(100.0, 100.0, 95.0));
for _ in 0..120 {
body.walk(scene, DT, WalkInput::default());
}
assert!(body.on_ground(), "settle: body must land");
body
}
#[test]
fn falls_and_lands_flush_on_the_surface_plane() {
let scene = ground_scene();
let body = body_on_ground(&scene);
assert!(
(body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
"feet at {} != {}",
body.pos().z,
100.0 - SKIN
);
assert_eq!(body.vel().z, 0.0);
assert!(!body.hit_head());
}
#[test]
fn walk_reaches_and_holds_walk_speed() {
let scene = ground_scene();
let mut body = body_on_ground(&scene);
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
for _ in 0..180 {
body.walk(&scene, DT, input);
}
let speed = body.vel().truncate().length();
assert!(
(speed - body.def().walk_speed).abs() < 1e-9,
"converged speed {speed}"
);
assert!(body.on_ground());
}
#[test]
fn walk_into_wall_clamps_flush_and_slides() {
let mut scene = ground_scene();
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(105, 60, 80),
IVec3::new(106, 160, 110),
Some(VoxColor(0x80_90_50_50)),
);
}
let mut body = body_on_ground(&scene);
let y0 = body.pos().y;
let input = WalkInput {
wish: DVec3::new(1.0, 1.0, 0.0),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
let expected_x = 105.0 - body.def().radius - SKIN;
assert!(
(body.pos().x - expected_x).abs() < 1e-9,
"x {} != flush {}",
body.pos().x,
expected_x
);
assert_eq!(body.vel().x, 0.0, "blocked axis velocity zeroed");
assert!(body.pos().y > y0 + 3.0, "slid along the wall");
}
#[test]
fn jump_apex_matches_ballistics_and_relands() {
let scene = ground_scene();
let mut body = body_on_ground(&scene);
let start_z = body.pos().z;
let def = *body.def();
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::ZERO,
jump: true,
},
);
assert!(!body.on_ground(), "airborne after jump");
let mut apex_rise = 0.0f64;
let mut relanded = false;
for _ in 0..240 {
body.walk(&scene, DT, WalkInput::default());
apex_rise = apex_rise.max(start_z - body.pos().z);
if body.on_ground() {
relanded = true;
break;
}
}
assert!(relanded, "must land again");
let ideal = def.jump_speed * def.jump_speed / (2.0 * def.gravity);
assert!(
(apex_rise - ideal).abs() < 0.2,
"apex {apex_rise} vs ideal {ideal}"
);
assert!((body.pos().z - start_z).abs() < 1e-9, "back on the floor");
}
#[test]
fn head_bump_stops_the_jump() {
let mut scene = ground_scene();
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(60, 60, 96),
IVec3::new(160, 160, 97),
Some(VoxColor(0x80_50_50_90)),
);
}
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(100.0, 100.0, 99.9));
for _ in 0..30 {
body.walk(&scene, DT, WalkInput::default());
}
assert!(body.on_ground(), "settled in the gap");
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::ZERO,
jump: true,
},
);
let mut bumped = false;
for _ in 0..120 {
body.walk(&scene, DT, WalkInput::default());
if body.hit_head() {
bumped = true;
let head = body.pos().z - body.def().height;
assert!((head - (98.0 + SKIN)).abs() < 1e-9, "head at {head}");
assert!(body.vel().z >= 0.0);
}
if body.on_ground() && bumped {
break;
}
}
assert!(bumped, "must bump the ceiling");
assert!(body.on_ground(), "falls back to the floor");
}
#[test]
fn fast_fall_does_not_tunnel_thin_floor() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let grid = scene.grid_mut(id).expect("grid present");
grid.set_rect(
IVec3::new(60, 60, 100),
IVec3::new(160, 160, 100),
Some(VoxColor(0x80_70_70_70)),
);
let mut body = CharacterBody::new(CharacterDef {
max_fall_speed: 400.0,
..CharacterDef::default()
});
body.teleport(DVec3::new(100.0, 100.0, 60.0));
body.set_vel(DVec3::new(0.0, 0.0, 200.0)); for _ in 0..5 {
body.walk(&scene, 0.1, WalkInput::default());
}
assert!(
(body.pos().z - (100.0 - SKIN)).abs() < 1e-9,
"feet at {} — tunneled?",
body.pos().z
);
assert!(body.on_ground());
}
#[test]
fn stuck_body_escapes_freely() {
let scene = ground_scene();
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(100.0, 100.0, 105.0));
assert!({
let (bmin, bmax) = body.box_at(body.pos());
box_overlaps_solid(&scene, bmin, bmax, Solidity::default())
});
let z0 = body.pos().z;
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
},
);
assert!(body.pos().z > z0, "gravity still applies while stuck");
assert!(body.pos().x > 100.0, "input still applies while stuck");
assert!(!body.on_ground());
}
fn ledge_scene(ledge_top_z: i32) -> Scene {
let mut scene = ground_scene();
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(105, 60, ledge_top_z),
IVec3::new(160, 160, 99),
Some(VoxColor(0x80_80_80_40)),
);
scene
}
#[test]
fn step_up_climbs_a_one_voxel_ledge() {
let scene = ledge_scene(99); let mut body = body_on_ground(&scene);
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
assert!(body.pos().x > 106.0, "walked onto the ledge");
assert!(
(body.pos().z - (99.0 - SKIN)).abs() < 1e-9,
"feet on the ledge plane, got {}",
body.pos().z
);
assert!(body.on_ground());
}
#[test]
fn step_up_refuses_a_two_voxel_wall() {
let scene = ledge_scene(98); let mut body = body_on_ground(&scene);
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
let expected_x = 105.0 - body.def().radius - SKIN;
assert!(
(body.pos().x - expected_x).abs() < 1e-9,
"clamped at {}, expected flush {expected_x}",
body.pos().x
);
assert!((body.pos().z - (100.0 - SKIN)).abs() < 1e-9, "stayed down");
}
#[test]
fn step_up_needs_headroom() {
let mut scene = ledge_scene(99);
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(104, 60, 97),
IVec3::new(160, 160, 97),
Some(VoxColor(0x80_40_40_80)),
);
}
let mut body = body_on_ground(&scene);
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
let expected_x = 105.0 - body.def().radius - SKIN;
assert!(
(body.pos().x - expected_x).abs() < 1e-9,
"no headroom ⇒ no step, got x {}",
body.pos().x
);
}
#[test]
fn coyote_jump_after_walking_off_an_edge() {
let scene = ground_scene();
let mut body = body_on_ground(&scene);
body.teleport(DVec3::new(159.0, 100.0, 95.0));
for _ in 0..120 {
body.walk(&scene, DT, WalkInput::default());
}
assert!(body.on_ground());
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
while body.on_ground() {
body.walk(&scene, DT, input);
}
body.walk(&scene, DT, input);
body.walk(&scene, DT, input);
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: true,
},
);
assert!(
body.vel().z < -0.5 * body.def().jump_speed,
"coyote jump fired, vel.z = {}",
body.vel().z
);
}
#[test]
fn coyote_does_not_double_jump() {
let scene = ground_scene();
let mut body = body_on_ground(&scene);
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::ZERO,
jump: true,
},
);
let rising = body.vel().z;
assert!(rising < 0.0);
for _ in 0..30 {
body.walk(&scene, DT, WalkInput::default());
}
let before = body.vel().z;
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::ZERO,
jump: true,
},
);
assert!(
body.vel().z > before,
"still decelerating upward/falling — no mid-air re-jump"
);
}
#[test]
fn buffered_jump_fires_on_landing() {
let scene = ground_scene();
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(100.0, 100.0, 99.9));
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::ZERO,
jump: true,
},
);
assert!(!body.on_ground(), "still airborne at press");
let mut jumped = false;
for _ in 0..30 {
body.walk(&scene, DT, WalkInput::default());
if body.vel().z <= -0.9 * body.def().jump_speed {
jumped = true;
break;
}
}
assert!(jumped, "buffered jump fired on landing");
}
#[test]
fn fly_mode_hovers_and_slides() {
let mut scene = ground_scene();
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(105, 60, 80),
IVec3::new(106, 160, 110),
Some(VoxColor(0x80_90_50_50)),
);
}
let mut body = CharacterBody::new(CharacterDef::default());
body.set_mode(MoveMode::Fly);
body.teleport(DVec3::new(100.0, 100.0, 95.0));
for _ in 0..60 {
body.walk(&scene, DT, WalkInput::default());
}
assert_eq!(body.pos().z, 95.0, "no gravity in fly mode");
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, -0.2),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
let expected_x = 105.0 - body.def().radius - SKIN;
assert!(
(body.pos().x - expected_x).abs() < 1e-9,
"fly clamps at the wall, got {}",
body.pos().x
);
assert!(body.pos().z < 95.0, "the -z wish component climbed");
}
#[test]
fn fly_stops_instantly_when_wish_drops() {
let scene = ground_scene();
let mut body = CharacterBody::new(CharacterDef::default());
body.set_mode(MoveMode::Fly);
body.teleport(DVec3::new(100.0, 100.0, 95.0));
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
},
);
assert!(
(body.vel().x - body.def().fly_speed).abs() < 1e-9,
"instant spin-up to fly_speed"
);
let x_after_release = {
body.walk(&scene, DT, WalkInput::default());
body.pos().x
};
assert_eq!(body.vel(), DVec3::ZERO, "instant stop on zero wish");
body.walk(&scene, DT, WalkInput::default());
assert_eq!(body.pos().x, x_after_release, "no drift while idle");
}
#[test]
fn noclip_passes_through_the_wall() {
let mut scene = ground_scene();
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(105, 60, 80),
IVec3::new(106, 160, 110),
Some(VoxColor(0x80_90_50_50)),
);
}
let mut body = CharacterBody::new(CharacterDef::default());
body.set_mode(MoveMode::Noclip);
body.teleport(DVec3::new(100.0, 100.0, 95.0));
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0),
jump: false,
};
for _ in 0..240 {
body.walk(&scene, DT, input);
}
assert!(
body.pos().x > 110.0,
"ghosted through, x = {}",
body.pos().x
);
assert!(!body.on_ground());
}
#[test]
#[ignore = "perf probe, run by hand with --ignored --nocapture"]
fn stress_probe() {
let mut scene = ground_scene();
{
let id = scene.grids().next().expect("grid").0;
let grid = scene.grid_mut(id).expect("grid");
grid.set_rect(
IVec3::new(105, 60, 80),
IVec3::new(106, 160, 110),
Some(VoxColor(0x80_90_50_50)),
);
}
let input = WalkInput {
wish: DVec3::new(1.0, 0.0, 0.0), jump: false,
};
let mut body = body_on_ground(&scene);
let t0 = std::time::Instant::now();
const FRAMES: u32 = 60_000;
for _ in 0..FRAMES {
body.walk(&scene, DT, input);
}
let per_frame = t0.elapsed() / FRAMES;
println!("single wall-hugging walker: {per_frame:?}/frame");
let mut crowd: Vec<CharacterBody> = (0..100)
.map(|i| {
let mut b = CharacterBody::new(CharacterDef::default());
#[allow(clippy::cast_lossless)]
b.teleport(DVec3::new(
70.0 + f64::from(i % 10) * 3.0,
70.0 + f64::from(i / 10) * 3.0,
95.0,
));
b
})
.collect();
for _ in 0..120 {
for b in &mut crowd {
b.walk(&scene, DT, WalkInput::default());
}
}
let t0 = std::time::Instant::now();
const CROWD_FRAMES: u32 = 600;
for _ in 0..CROWD_FRAMES {
for b in &mut crowd {
b.walk(&scene, DT, input);
}
}
let per_crowd_frame = t0.elapsed() / CROWD_FRAMES;
println!("100-NPC crowd: {per_crowd_frame:?}/frame (all 100 walking)");
}
#[test]
fn fall_speed_caps_at_terminal_velocity() {
let scene = Scene::new();
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(0.0, 0.0, 0.0));
for _ in 0..600 {
body.walk(&scene, DT, WalkInput::default());
}
assert_eq!(body.vel().z, body.def().max_fall_speed);
}
#[test]
fn deterministic_trajectory() {
let scene = ground_scene();
let run = || {
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(100.0, 100.0, 95.0));
let mut trace = Vec::new();
for i in 0..180 {
body.walk(
&scene,
DT,
WalkInput {
wish: DVec3::new(1.0, 0.3, 0.0),
jump: i == 90,
},
);
trace.push(body.pos());
}
trace
};
assert_eq!(run(), run(), "same input ⇒ bit-identical trajectory");
}
}