use crate::collider::Sphere;
use crate::math::{Fix128, Vec3Fix};
use crate::raycast::{ray_plane, ray_sphere, Ray, RayHit};
use crate::sdf_collider::SdfCollider;
use crate::solver::RigidBody;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CharacterConfig {
pub radius: Fix128,
pub height: Fix128,
pub max_slope_angle: Fix128,
pub step_height: Fix128,
pub skin_width: Fix128,
pub ground_probe_distance: Fix128,
pub max_slides: usize,
pub push_force: Fix128,
}
impl Default for CharacterConfig {
fn default() -> Self {
Self {
radius: Fix128::from_ratio(3, 10), height: Fix128::from_ratio(18, 10), max_slope_angle: Fix128::from_ratio(785, 1000), step_height: Fix128::from_ratio(3, 10), skin_width: Fix128::from_ratio(1, 100), ground_probe_distance: Fix128::from_ratio(1, 10), max_slides: 4,
push_force: Fix128::from_int(5),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PushImpulse {
pub body_index: usize,
pub impulse: Vec3Fix,
pub point: Vec3Fix,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MoveResult {
pub position: Vec3Fix,
pub grounded: bool,
pub velocity: Vec3Fix,
pub platform_velocity: Vec3Fix,
}
pub struct CharacterController {
pub position: Vec3Fix,
pub velocity: Vec3Fix,
pub grounded: bool,
pub config: CharacterConfig,
pub ground_body_index: Option<usize>,
pub platform_velocity: Vec3Fix,
}
impl CharacterController {
#[must_use]
pub const fn new(position: Vec3Fix, config: CharacterConfig) -> Self {
Self {
position,
velocity: Vec3Fix::ZERO,
grounded: false,
config,
ground_body_index: None,
platform_velocity: Vec3Fix::ZERO,
}
}
#[must_use]
pub fn new_default(position: Vec3Fix) -> Self {
Self::new(position, CharacterConfig::default())
}
#[inline]
#[must_use]
pub fn feet_position(&self) -> Vec3Fix {
let half_height = self.config.height.half();
Vec3Fix::new(
self.position.x,
self.position.y - half_height + self.config.radius,
self.position.z,
)
}
pub fn move_and_slide(
&mut self,
displacement: Vec3Fix,
bodies: &[RigidBody],
sdf_colliders: &[SdfCollider],
) -> MoveResult {
let mut remaining = displacement + self.platform_velocity;
let mut pos = self.position;
for _ in 0..self.config.max_slides {
let move_len = remaining.length();
if move_len < self.config.skin_width {
break;
}
if let Some(hit) = self.sweep_against_bodies(pos, remaining, bodies) {
let safe_t = if hit.t > self.config.skin_width {
hit.t - self.config.skin_width
} else {
Fix128::ZERO
};
let move_dir = remaining.normalize();
pos = pos + move_dir * safe_t;
let used = safe_t;
let left = move_len - used;
if left < self.config.skin_width {
break;
}
let remainder_dir = remaining.normalize();
let remainder_vec = remainder_dir * left;
let slide = remainder_vec - hit.normal * remainder_vec.dot(hit.normal);
remaining = slide;
} else {
pos = pos + remaining;
break;
}
}
#[cfg(feature = "std")]
{
pos = self.resolve_sdf(pos, sdf_colliders);
}
let horizontal_moved = Vec3Fix::new(
pos.x - self.position.x,
Fix128::ZERO,
pos.z - self.position.z,
);
let horizontal_desired = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
let moved_ratio = horizontal_moved.length_squared();
let desired_ratio = horizontal_desired.length_squared();
if self.grounded && !desired_ratio.is_zero() {
let threshold = desired_ratio * Fix128::from_ratio(1, 4); if moved_ratio < threshold {
let step_up = Vec3Fix::new(Fix128::ZERO, self.config.step_height, Fix128::ZERO);
let stepped_pos = pos + step_up;
let horiz_disp = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
let test_pos = stepped_pos + horiz_disp;
let step_clear = self.check_position_clear(test_pos, bodies);
#[cfg(feature = "std")]
let step_clear_sdf = self.check_sdf_clear(test_pos, sdf_colliders);
#[cfg(not(feature = "std"))]
let step_clear_sdf = true;
if step_clear && step_clear_sdf {
let snap_ray = Ray::new(
test_pos,
Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO),
);
let snap_dist = self.config.step_height + self.config.step_height;
if let Some(ground_hit) = ray_plane(
&snap_ray,
Vec3Fix::UNIT_Y,
self.position.y - self.config.height.half() + self.config.radius,
snap_dist,
) {
let snapped_y = test_pos.y - ground_hit.t;
pos = Vec3Fix::new(test_pos.x, snapped_y, test_pos.z);
} else {
pos = test_pos;
}
}
}
}
let (grounded, ground_idx) = self.detect_ground(pos, bodies, sdf_colliders);
let platform_vel = ground_idx.map_or(Vec3Fix::ZERO, |idx| {
if idx < bodies.len() {
bodies[idx].velocity
} else {
Vec3Fix::ZERO
}
});
self.position = pos;
self.grounded = grounded;
self.ground_body_index = ground_idx;
self.platform_velocity = platform_vel;
self.velocity = displacement;
MoveResult {
position: pos,
grounded,
velocity: displacement,
platform_velocity: platform_vel,
}
}
fn sweep_against_bodies(
&self,
from: Vec3Fix,
displacement: Vec3Fix,
bodies: &[RigidBody],
) -> Option<RayHit> {
let move_len = displacement.length();
if move_len.is_zero() {
return None;
}
let direction = displacement / move_len;
let char_sphere = Sphere::new(from, self.config.radius);
let ray = Ray::new(from, direction);
let mut closest: Option<RayHit> = None;
let mut best_t = move_len;
for (i, body) in bodies.iter().enumerate() {
if !body.is_static() {
continue;
}
let body_sphere = Sphere::new(body.position, self.config.radius);
let expanded = Sphere::new(body_sphere.center, body_sphere.radius + char_sphere.radius);
if let Some(mut hit) = ray_sphere(&ray, &expanded, best_t) {
hit.body_index = i;
best_t = hit.t;
closest = Some(hit);
}
}
closest
}
fn check_position_clear(&self, pos: Vec3Fix, bodies: &[RigidBody]) -> bool {
let r = self.config.radius + self.config.skin_width;
let r_sq = r * r;
for body in bodies {
if !body.is_static() {
continue;
}
let dist_sq = (pos - body.position).length_squared();
if dist_sq < r_sq {
return false;
}
}
true
}
#[cfg(feature = "std")]
fn resolve_sdf(&self, mut pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> Vec3Fix {
let half_h = self.config.height.half();
let offsets = [
Vec3Fix::new(Fix128::ZERO, -half_h + self.config.radius, Fix128::ZERO),
Vec3Fix::ZERO,
Vec3Fix::new(Fix128::ZERO, half_h - self.config.radius, Fix128::ZERO),
];
for sdf in sdf_colliders {
for offset in &offsets {
let sample_pos = pos + *offset;
let (lx, ly, lz) = sdf.world_to_local(sample_pos);
let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
let threshold = self.config.radius.to_f32();
if dist < threshold {
let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
let normal = sdf.local_normal_to_world(nx, ny, nz);
let push = Fix128::from_f32(threshold - dist);
pos = pos + normal * push;
}
}
}
pos
}
#[cfg(feature = "std")]
fn check_sdf_clear(&self, pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> bool {
for sdf in sdf_colliders {
let (lx, ly, lz) = sdf.world_to_local(pos);
let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
if dist < self.config.radius.to_f32() {
return false;
}
}
true
}
fn detect_ground(
&self,
pos: Vec3Fix,
bodies: &[RigidBody],
#[cfg_attr(not(feature = "std"), allow(unused_variables))] sdf_colliders: &[SdfCollider],
) -> (bool, Option<usize>) {
let feet = Vec3Fix::new(
pos.x,
pos.y - self.config.height.half() + self.config.radius,
pos.z,
);
let down = Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO);
let ray = Ray::new(feet, down);
let probe = self.config.ground_probe_distance + self.config.skin_width;
for (i, body) in bodies.iter().enumerate() {
if !body.is_static() {
continue;
}
let body_sphere = Sphere::new(body.position, self.config.radius);
if ray_sphere(&ray, &body_sphere, probe).is_some() {
return (true, Some(i));
}
}
#[cfg(feature = "std")]
for sdf in sdf_colliders {
let (lx, ly, lz) = sdf.world_to_local(feet);
let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
if dist < (probe.to_f32() + self.config.radius.to_f32()) {
let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
let normal = sdf.local_normal_to_world(nx, ny, nz);
let up_dot = normal.dot(Vec3Fix::UNIT_Y);
let cos_max = self.config.max_slope_angle.cos();
if up_dot >= cos_max {
return (true, None); }
}
}
(false, None)
}
#[must_use]
pub fn compute_push_impulses(
&self,
bodies: &[RigidBody],
body_radius: Fix128,
) -> Vec<PushImpulse> {
let mut pushes = Vec::new();
let combined = self.config.radius + body_radius;
let combined_sq = combined * combined;
for (i, body) in bodies.iter().enumerate() {
if body.is_static() || body.is_sensor {
continue;
}
let delta = body.position - self.position;
let dist_sq = delta.length_squared();
if dist_sq < combined_sq {
let (normal, overlap) = if dist_sq.is_zero() {
(Vec3Fix::UNIT_Y, combined)
} else {
let dist = dist_sq.sqrt();
(delta / dist, combined - dist)
};
let impulse = normal * overlap * self.config.push_force;
pushes.push(PushImpulse {
body_index: i,
impulse,
point: body.position - normal * body_radius,
});
}
}
pushes
}
#[inline]
#[must_use]
pub const fn get_platform_velocity(&self) -> Vec3Fix {
self.platform_velocity
}
pub fn apply_gravity(&mut self, gravity: Vec3Fix, dt: Fix128) {
if !self.grounded {
self.velocity = self.velocity + gravity * dt;
}
}
}
impl core::fmt::Debug for CharacterController {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CharacterController")
.field("position", &self.position)
.field("velocity", &self.velocity)
.field("grounded", &self.grounded)
.field("config", &self.config)
.field("ground_body_index", &self.ground_body_index)
.field("platform_velocity", &self.platform_velocity)
.finish()
}
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
#[test]
fn test_character_creation() {
let pos = Vec3Fix::from_int(0, 5, 0);
let cc = CharacterController::new_default(pos);
assert_eq!(cc.position.x.hi, 0);
assert_eq!(cc.position.y.hi, 5);
assert!(!cc.grounded);
}
#[test]
fn test_feet_position() {
let cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
let feet = cc.feet_position();
let feet_y = feet.y.to_f32();
assert!(
feet_y > 4.0 && feet_y < 5.0,
"Feet should be below center, got {feet_y}"
);
}
#[test]
fn test_move_and_slide_no_collision() {
let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
let displacement = Vec3Fix::from_int(1, 0, 0);
let result = cc.move_and_slide(displacement, &[], &[]);
let dx = (result.position.x - Fix128::ONE).abs();
assert!(dx < Fix128::from_ratio(1, 10), "Should have moved to x=1");
}
#[test]
fn test_platform_velocity_tracking() {
let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 2, 0));
let mut platform = RigidBody::new_kinematic(Vec3Fix::from_int(0, 0, 0));
platform.velocity = Vec3Fix::from_int(5, 0, 0); let bodies = vec![platform];
cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
if cc.grounded {
assert_eq!(
cc.platform_velocity.x.hi, 5,
"Should track platform velocity"
);
}
}
#[test]
fn test_push_impulses() {
let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
let dynamic = RigidBody::new(Vec3Fix::from_int(0, 0, 0), Fix128::ONE);
let bodies = vec![dynamic];
let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
assert!(
!pushes.is_empty(),
"Should generate push impulse for overlapping dynamic body"
);
assert_eq!(pushes[0].body_index, 0);
}
#[test]
fn test_no_push_static() {
let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
let static_body = RigidBody::new_static(Vec3Fix::from_int(0, 0, 0));
let bodies = vec![static_body];
let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
assert!(pushes.is_empty(), "Should not push static bodies");
}
#[test]
fn test_move_and_slide_config() {
let config = CharacterConfig {
radius: Fix128::from_ratio(5, 10),
height: Fix128::from_int(2),
..Default::default()
};
let cc = CharacterController::new(Vec3Fix::ZERO, config);
assert_eq!(cc.config.height.hi, 2);
}
}