1use crate::collider::Sphere;
25use crate::math::{Fix128, Vec3Fix};
26use crate::raycast::{ray_plane, ray_sphere, sweep_ray_sphere, Ray, RayHit};
27use crate::sdf_collider::SdfCollider;
28use crate::solver::RigidBody;
29
30#[cfg(not(feature = "std"))]
31use alloc::vec::Vec;
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct CharacterConfig {
40 pub radius: Fix128,
42 pub height: Fix128,
44 pub max_slope_angle: Fix128,
46 pub step_height: Fix128,
48 pub skin_width: Fix128,
50 pub ground_probe_distance: Fix128,
52 pub max_slides: usize,
54 pub push_force: Fix128,
56}
57
58impl Default for CharacterConfig {
59 fn default() -> Self {
60 Self {
61 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,
68 push_force: Fix128::from_int(5),
69 }
70 }
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct PushImpulse {
80 pub body_index: usize,
82 pub impulse: Vec3Fix,
84 pub point: Vec3Fix,
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct MoveResult {
91 pub position: Vec3Fix,
93 pub grounded: bool,
95 pub velocity: Vec3Fix,
97 pub platform_velocity: Vec3Fix,
99}
100
101pub struct CharacterController {
107 pub position: Vec3Fix,
109 pub velocity: Vec3Fix,
111 pub grounded: bool,
113 pub config: CharacterConfig,
115 pub ground_body_index: Option<usize>,
117 pub platform_velocity: Vec3Fix,
119}
120
121impl CharacterController {
122 #[must_use]
124 pub const fn new(position: Vec3Fix, config: CharacterConfig) -> Self {
125 Self {
126 position,
127 velocity: Vec3Fix::ZERO,
128 grounded: false,
129 config,
130 ground_body_index: None,
131 platform_velocity: Vec3Fix::ZERO,
132 }
133 }
134
135 #[must_use]
137 pub fn new_default(position: Vec3Fix) -> Self {
138 Self::new(position, CharacterConfig::default())
139 }
140
141 #[inline]
143 #[must_use]
144 pub fn feet_position(&self) -> Vec3Fix {
145 let half_height = self.config.height.half();
146 Vec3Fix::new(
147 self.position.x,
148 self.position.y - half_height + self.config.radius,
149 self.position.z,
150 )
151 }
152
153 pub fn move_and_slide(
164 &mut self,
165 displacement: Vec3Fix,
166 bodies: &[RigidBody],
167 sdf_colliders: &[SdfCollider],
168 ) -> MoveResult {
169 let mut remaining = displacement + self.platform_velocity;
171 let mut pos = self.position;
172
173 for _ in 0..self.config.max_slides {
174 let move_len = remaining.length();
175 if move_len < self.config.skin_width {
176 break;
177 }
178
179 if let Some(hit) = self.sweep_against_bodies(pos, remaining, bodies) {
181 let safe_t = if hit.t > self.config.skin_width {
183 hit.t - self.config.skin_width
184 } else {
185 Fix128::ZERO
186 };
187 let move_dir = remaining.normalize();
188 pos = pos + move_dir * safe_t;
189
190 let used = safe_t;
192 let left = move_len - used;
193 if left < self.config.skin_width {
194 break;
195 }
196 let remainder_dir = remaining.normalize();
197 let remainder_vec = remainder_dir * left;
198 let slide = remainder_vec - hit.normal * remainder_vec.dot(hit.normal);
199 remaining = slide;
200 } else {
201 pos = pos + remaining;
203 break;
204 }
205 }
206
207 #[cfg(feature = "std")]
209 {
210 pos = self.resolve_sdf(pos, sdf_colliders);
211 }
212
213 let horizontal_moved = Vec3Fix::new(
215 pos.x - self.position.x,
216 Fix128::ZERO,
217 pos.z - self.position.z,
218 );
219 let horizontal_desired = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
220 let moved_ratio = horizontal_moved.length_squared();
221 let desired_ratio = horizontal_desired.length_squared();
222
223 if self.grounded && !desired_ratio.is_zero() {
224 let threshold = desired_ratio * Fix128::from_ratio(1, 4); if moved_ratio < threshold {
226 let step_up = Vec3Fix::new(Fix128::ZERO, self.config.step_height, Fix128::ZERO);
227 let stepped_pos = pos + step_up;
228 let horiz_disp = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
230 let test_pos = stepped_pos + horiz_disp;
231
232 let step_clear = self.check_position_clear(test_pos, bodies);
234
235 #[cfg(feature = "std")]
236 let step_clear_sdf = self.check_sdf_clear(test_pos, sdf_colliders);
237 #[cfg(not(feature = "std"))]
238 let step_clear_sdf = true;
239
240 if step_clear && step_clear_sdf {
241 let snap_ray = Ray::new(
243 test_pos,
244 Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO),
245 );
246 let snap_dist = self.config.step_height + self.config.step_height;
247 if let Some(ground_hit) = ray_plane(
248 &snap_ray,
249 Vec3Fix::UNIT_Y,
250 self.position.y - self.config.height.half() + self.config.radius,
251 snap_dist,
252 ) {
253 let snapped_y = test_pos.y - ground_hit.t;
254 pos = Vec3Fix::new(test_pos.x, snapped_y, test_pos.z);
255 } else {
256 pos = test_pos;
257 }
258 }
259 }
260 }
261
262 let (grounded, ground_idx) = self.detect_ground(pos, bodies, sdf_colliders);
264
265 let platform_vel = ground_idx.map_or(Vec3Fix::ZERO, |idx| {
267 if idx < bodies.len() {
268 bodies[idx].velocity
269 } else {
270 Vec3Fix::ZERO
271 }
272 });
273
274 self.position = pos;
275 self.grounded = grounded;
276 self.ground_body_index = ground_idx;
277 self.platform_velocity = platform_vel;
278 self.velocity = displacement; MoveResult {
281 position: pos,
282 grounded,
283 velocity: displacement,
284 platform_velocity: platform_vel,
285 }
286 }
287
288 fn sweep_against_bodies(
298 &self,
299 from: Vec3Fix,
300 displacement: Vec3Fix,
301 bodies: &[RigidBody],
302 ) -> Option<RayHit> {
303 let move_len = displacement.length();
304 if move_len.is_zero() {
305 return None;
306 }
307 let direction = displacement / move_len;
308
309 let char_sphere = Sphere::new(from, self.config.radius);
310 let ray = Ray::new(from, direction);
311
312 let mut closest: Option<RayHit> = None;
313 let mut best_t = move_len;
314
315 for (i, body) in bodies.iter().enumerate() {
316 if !body.is_static() {
318 continue;
319 }
320
321 let body_sphere = Sphere::new(body.position, self.config.radius);
323 let expanded = Sphere::new(body_sphere.center, body_sphere.radius + char_sphere.radius);
324
325 if let Some(mut hit) = sweep_ray_sphere(&ray, &expanded, best_t) {
326 hit.body_index = i;
327 best_t = hit.t;
328 closest = Some(hit);
329 }
330 }
331
332 closest
333 }
334
335 fn check_position_clear(&self, pos: Vec3Fix, bodies: &[RigidBody]) -> bool {
337 let r = self.config.radius + self.config.skin_width;
338 let r_sq = r * r;
339
340 for body in bodies {
341 if !body.is_static() {
342 continue;
343 }
344 let dist_sq = (pos - body.position).length_squared();
345 if dist_sq < r_sq {
346 return false;
347 }
348 }
349 true
350 }
351
352 #[cfg(feature = "std")]
354 fn resolve_sdf(&self, mut pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> Vec3Fix {
355 let half_h = self.config.height.half();
356 let offsets = [
358 Vec3Fix::new(Fix128::ZERO, -half_h + self.config.radius, Fix128::ZERO),
359 Vec3Fix::ZERO,
360 Vec3Fix::new(Fix128::ZERO, half_h - self.config.radius, Fix128::ZERO),
361 ];
362
363 for sdf in sdf_colliders {
364 for offset in &offsets {
365 let sample_pos = pos + *offset;
366 let (lx, ly, lz) = sdf.world_to_local(sample_pos);
367 let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
368 let threshold = self.config.radius.to_f32();
369
370 if dist < threshold {
371 let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
372 let normal = sdf.local_normal_to_world(nx, ny, nz);
373 let push = Fix128::from_f32(threshold - dist);
374 pos = pos + normal * push;
375 }
376 }
377 }
378 pos
379 }
380
381 #[cfg(feature = "std")]
383 fn check_sdf_clear(&self, pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> bool {
384 for sdf in sdf_colliders {
385 let (lx, ly, lz) = sdf.world_to_local(pos);
386 let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
387 if dist < self.config.radius.to_f32() {
388 return false;
389 }
390 }
391 true
392 }
393
394 fn detect_ground(
397 &self,
398 pos: Vec3Fix,
399 bodies: &[RigidBody],
400 #[cfg_attr(not(feature = "std"), allow(unused_variables))] sdf_colliders: &[SdfCollider],
401 ) -> (bool, Option<usize>) {
402 let feet = Vec3Fix::new(
403 pos.x,
404 pos.y - self.config.height.half() + self.config.radius,
405 pos.z,
406 );
407 let down = Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO);
408 let ray = Ray::new(feet, down);
409 let probe = self.config.ground_probe_distance + self.config.skin_width;
410
411 for (i, body) in bodies.iter().enumerate() {
413 if !body.is_static() {
414 continue;
415 }
416 let body_sphere = Sphere::new(body.position, self.config.radius);
417 if ray_sphere(&ray, &body_sphere, probe).is_some() {
418 return (true, Some(i));
419 }
420 }
421
422 #[cfg(feature = "std")]
424 for sdf in sdf_colliders {
425 let (lx, ly, lz) = sdf.world_to_local(feet);
426 let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
427 if dist < (probe.to_f32() + self.config.radius.to_f32()) {
428 let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
430 let normal = sdf.local_normal_to_world(nx, ny, nz);
431 let up_dot = normal.dot(Vec3Fix::UNIT_Y);
432 let cos_max = self.config.max_slope_angle.cos();
433 if up_dot >= cos_max {
434 return (true, None); }
436 }
437 }
438
439 (false, None)
440 }
441
442 #[must_use]
447 pub fn compute_push_impulses(
448 &self,
449 bodies: &[RigidBody],
450 body_radius: Fix128,
451 ) -> Vec<PushImpulse> {
452 let mut pushes = Vec::new();
453 let combined = self.config.radius + body_radius;
454 let combined_sq = combined * combined;
455
456 for (i, body) in bodies.iter().enumerate() {
457 if body.is_static() || body.is_sensor {
459 continue;
460 }
461
462 let delta = body.position - self.position;
463 let dist_sq = delta.length_squared();
464 if dist_sq < combined_sq {
465 let (normal, overlap) = if dist_sq.is_zero() {
466 (Vec3Fix::UNIT_Y, combined)
468 } else {
469 let dist = dist_sq.sqrt();
470 (delta / dist, combined - dist)
471 };
472 let impulse = normal * overlap * self.config.push_force;
473 pushes.push(PushImpulse {
474 body_index: i,
475 impulse,
476 point: body.position - normal * body_radius,
477 });
478 }
479 }
480
481 pushes
482 }
483
484 #[inline]
486 #[must_use]
487 pub const fn get_platform_velocity(&self) -> Vec3Fix {
488 self.platform_velocity
489 }
490
491 pub fn apply_gravity(&mut self, gravity: Vec3Fix, dt: Fix128) {
497 if !self.grounded {
498 self.velocity = self.velocity + gravity * dt;
499 }
500 }
501}
502
503impl core::fmt::Debug for CharacterController {
504 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
505 f.debug_struct("CharacterController")
506 .field("position", &self.position)
507 .field("velocity", &self.velocity)
508 .field("grounded", &self.grounded)
509 .field("config", &self.config)
510 .field("ground_body_index", &self.ground_body_index)
511 .field("platform_velocity", &self.platform_velocity)
512 .finish()
513 }
514}
515
516#[cfg(all(test, feature = "std"))]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn test_character_creation() {
526 let pos = Vec3Fix::from_int(0, 5, 0);
527 let cc = CharacterController::new_default(pos);
528 assert_eq!(cc.position.x.hi, 0);
529 assert_eq!(cc.position.y.hi, 5);
530 assert!(!cc.grounded);
531 }
532
533 #[test]
534 fn test_feet_position() {
535 let cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
536 let feet = cc.feet_position();
537 let feet_y = feet.y.to_f32();
539 assert!(
540 feet_y > 4.0 && feet_y < 5.0,
541 "Feet should be below center, got {feet_y}"
542 );
543 }
544
545 #[test]
546 fn test_move_and_slide_no_collision() {
547 let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
548 let displacement = Vec3Fix::from_int(1, 0, 0);
549 let result = cc.move_and_slide(displacement, &[], &[]);
550 let dx = (result.position.x - Fix128::ONE).abs();
552 assert!(dx < Fix128::from_ratio(1, 10), "Should have moved to x=1");
553 }
554
555 #[test]
556 fn test_platform_velocity_tracking() {
557 let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 2, 0));
558
559 let mut platform = RigidBody::new_kinematic(Vec3Fix::from_int(0, 0, 0));
561 platform.velocity = Vec3Fix::from_int(5, 0, 0); let bodies = vec![platform];
563
564 cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
565
566 if cc.grounded {
568 assert_eq!(
569 cc.platform_velocity.x.hi, 5,
570 "Should track platform velocity"
571 );
572 }
573 }
574
575 #[test]
576 fn test_push_impulses() {
577 let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
578
579 let dynamic = RigidBody::new(Vec3Fix::from_int(0, 0, 0), Fix128::ONE);
581 let bodies = vec![dynamic];
582
583 let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
584 assert!(
585 !pushes.is_empty(),
586 "Should generate push impulse for overlapping dynamic body"
587 );
588 assert_eq!(pushes[0].body_index, 0);
589 }
590
591 #[test]
592 fn test_no_push_static() {
593 let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
594
595 let static_body = RigidBody::new_static(Vec3Fix::from_int(0, 0, 0));
597 let bodies = vec![static_body];
598
599 let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
600 assert!(pushes.is_empty(), "Should not push static bodies");
601 }
602
603 #[test]
604 fn test_move_and_slide_config() {
605 let config = CharacterConfig {
606 radius: Fix128::from_ratio(5, 10),
607 height: Fix128::from_int(2),
608 ..Default::default()
609 };
610 let cc = CharacterController::new(Vec3Fix::ZERO, config);
611 assert_eq!(cc.config.height.hi, 2);
612 }
613
614 #[test]
615 fn apply_gravity_accumulates_only_while_airborne() {
616 let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
617 let g = Vec3Fix::from_int(0, -10, 0);
618 let dt = Fix128::from_ratio(1, 4);
619 assert!(!cc.grounded);
620 cc.apply_gravity(g, dt);
621 assert_eq!(
623 cc.velocity,
624 Vec3Fix::new(Fix128::ZERO, -Fix128::from_ratio(5, 2), Fix128::ZERO)
625 );
626 cc.apply_gravity(g, dt);
627 assert_eq!(cc.velocity, Vec3Fix::from_int(0, -5, 0));
628 cc.velocity = Vec3Fix::from_int(3, 0, 0);
630 cc.apply_gravity(g, dt);
631 assert_eq!(
632 cc.velocity,
633 Vec3Fix::new(Fix128::from_int(3), -Fix128::from_ratio(5, 2), Fix128::ZERO)
634 );
635 cc.grounded = true;
637 cc.apply_gravity(g, dt);
638 assert_eq!(
639 cc.velocity,
640 Vec3Fix::new(Fix128::from_int(3), -Fix128::from_ratio(5, 2), Fix128::ZERO)
641 );
642 }
643
644 #[test]
645 fn get_platform_velocity_reports_ground_body_velocity_and_clears_after_leaving() {
646 let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 1, 0));
650 assert_eq!(cc.get_platform_velocity(), Vec3Fix::ZERO);
651 let mut platform = RigidBody::new_kinematic(Vec3Fix::ZERO);
652 platform.velocity = Vec3Fix::from_int(5, 0, 0);
653 let mut dynamic = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
655 dynamic.velocity = Vec3Fix::from_int(-9, 0, 0);
656 let bodies = vec![dynamic, platform];
657
658 let res = cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
659 assert!(cc.grounded);
660 assert_eq!(cc.ground_body_index, Some(1));
661 assert_eq!(cc.get_platform_velocity(), Vec3Fix::from_int(5, 0, 0));
662 assert_eq!(res.platform_velocity, cc.get_platform_velocity());
663 assert_eq!(cc.position, Vec3Fix::from_int(0, 1, 0));
664
665 let res2 = cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
668 assert_eq!(cc.position, Vec3Fix::from_int(5, 1, 0));
669 assert!(!cc.grounded);
670 assert_eq!(cc.ground_body_index, None);
671 assert_eq!(cc.get_platform_velocity(), Vec3Fix::ZERO);
672 assert_eq!(res2.platform_velocity, Vec3Fix::ZERO);
673 }
674}