use core::f32::consts::PI;
use nalgebra::{Point3, Vector3};
#[cfg(not(feature = "std"))]
use micromath::F32Ext;
use crate::input::InputState;
const RESOLVE_ITERS: usize = 3;
#[derive(Clone, Debug)]
pub struct CharacterController {
pub position: Point3<f32>,
pub velocity: Vector3<f32>,
pub yaw: f32,
pub pitch: f32,
pub height: f32,
pub radius: f32,
pub eye_fraction: f32,
pub on_ground: bool,
pub walk_speed: f32,
pub run_speed: f32,
pub jump_speed: f32,
pub gravity: f32,
}
impl CharacterController {
pub fn new(pos: Point3<f32>) -> Self {
Self {
position: pos,
velocity: Vector3::zeros(),
yaw: 0.0,
pitch: 0.0,
height: 1.75,
radius: 0.28,
eye_fraction: 0.86,
on_ground: false,
walk_speed: 4.5,
run_speed: 8.5,
jump_speed: 5.5,
gravity: 14.0,
}
}
pub fn forward_flat(&self) -> Vector3<f32> {
Vector3::new(self.yaw.sin(), 0.0, -self.yaw.cos())
}
pub fn right_flat(&self) -> Vector3<f32> {
Vector3::new(self.yaw.cos(), 0.0, self.yaw.sin())
}
pub fn look_dir(&self) -> Vector3<f32> {
let cp = self.pitch.cos();
Vector3::new(cp * self.yaw.sin(), self.pitch.sin(), -cp * self.yaw.cos())
}
pub fn eye_position(&self) -> Point3<f32> {
Point3::new(
self.position.x,
self.position.y + self.height * self.eye_fraction,
self.position.z,
)
}
pub fn look_target(&self) -> Point3<f32> {
self.eye_position() + self.look_dir()
}
pub fn apply_to_camera(&self, engine: &mut crate::K3dengine) {
engine.camera.set_position(self.eye_position());
engine.camera.set_target(self.look_target());
}
pub fn tick(&mut self, input: &InputState, dt: f32, colliders: &[[f32; 6]]) {
let dt = dt.min(0.05);
self.yaw += input.look_yaw;
if self.yaw > PI {
self.yaw -= 2.0 * PI;
}
if self.yaw < -PI {
self.yaw += 2.0 * PI;
}
self.pitch = (self.pitch + input.look_pitch).clamp(-1.466, 1.466);
let speed = if input.sprint {
self.run_speed
} else {
self.walk_speed
};
let fwd = self.forward_flat();
let rgt = self.right_flat();
self.velocity.x = (fwd.x * input.forward + rgt.x * input.strafe) * speed;
self.velocity.z = (fwd.z * input.forward + rgt.z * input.strafe) * speed;
if self.on_ground {
if input.jump {
self.velocity.y = self.jump_speed;
self.on_ground = false;
} else {
self.velocity.y = 0.0;
}
} else {
self.velocity.y -= self.gravity * dt;
}
let mut pos = self.position + self.velocity * dt;
self.on_ground = false;
for _ in 0..RESOLVE_ITERS {
for &[cx0, cy0, cz0, cx1, cy1, cz1] in colliders {
let px0 = pos.x - self.radius;
let px1 = pos.x + self.radius;
let py0 = pos.y;
let py1 = pos.y + self.height;
let pz0 = pos.z - self.radius;
let pz1 = pos.z + self.radius;
if px1 <= cx0 || px0 >= cx1 || py1 <= cy0 || py0 >= cy1 || pz1 <= cz0 || pz0 >= cz1
{
continue;
}
let ox_a = px1 - cx0; let ox_b = cx1 - px0; let oy_a = py1 - cy0; let oy_b = cy1 - py0; let oz_a = pz1 - cz0; let oz_b = cz1 - pz0;
let ox = ox_a.min(ox_b);
let oy = oy_a.min(oy_b);
let oz = oz_a.min(oz_b);
if oy <= ox && oy <= oz {
if oy_b < oy_a {
pos.y += oy_b;
self.on_ground = true;
if self.velocity.y < 0.0 {
self.velocity.y = 0.0;
}
} else {
pos.y -= oy_a;
if self.velocity.y > 0.0 {
self.velocity.y = 0.0;
}
}
} else if ox <= oz {
if ox_a < ox_b {
pos.x -= ox_a;
} else {
pos.x += ox_b;
}
self.velocity.x = 0.0;
} else {
if oz_a < oz_b {
pos.z -= oz_a;
} else {
pos.z += oz_b;
}
self.velocity.z = 0.0;
}
}
}
self.position = pos;
}
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
fn no_colliders() -> &'static [[f32; 6]] {
&[]
}
#[test]
fn forward_flat_faces_neg_z_at_yaw_zero() {
let ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
let fwd = ch.forward_flat();
assert!((fwd.x).abs() < 1e-5);
assert!((fwd.z + 1.0).abs() < 1e-5);
}
#[test]
fn gravity_applies_when_airborne() {
let mut ch = CharacterController::new(Point3::new(0.0, 5.0, 0.0));
ch.on_ground = false;
let input = InputState::default();
ch.tick(&input, 1.0, no_colliders());
assert!(ch.position.y < 5.0, "should have fallen");
}
#[test]
fn floor_collider_stops_fall() {
let mut ch = CharacterController::new(Point3::new(0.0, 1.0, 0.0));
ch.on_ground = false;
ch.velocity.y = -10.0;
let floor = [[-5.0_f32, -0.5, -5.0, 5.0, 0.0, 5.0]];
let input = InputState::default();
for _ in 0..8 {
ch.tick(&input, 1.0 / 60.0, &floor);
}
assert!(ch.on_ground, "should be on ground after collision");
assert!(ch.position.y.abs() < 1e-2, "foot should be at y≈0");
}
#[test]
fn wall_collider_stops_x_movement() {
let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
ch.on_ground = true;
let wall = [[1.9_f32, -1.0, -5.0, 10.0, 5.0, 5.0]];
let input = InputState {
strafe: 1.0,
..Default::default()
};
for _ in 0..30 {
ch.tick(&input, 1.0 / 60.0, &wall);
}
assert!(ch.position.x + ch.radius <= 1.9 + 0.05);
}
#[test]
fn jump_increases_y_velocity() {
let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
ch.on_ground = true;
let input = InputState {
jump: true,
..Default::default()
};
ch.tick(&input, 1.0 / 60.0, no_colliders());
assert!(
ch.velocity.y > 0.0,
"should have positive Y velocity after jump"
);
}
#[test]
fn pitch_is_clamped() {
let mut ch = CharacterController::new(Point3::new(0.0, 0.0, 0.0));
let input = InputState {
look_pitch: 999.0,
..Default::default()
};
ch.tick(&input, 1.0 / 60.0, no_colliders());
assert!(ch.pitch <= 1.47);
}
}