dreamwell_engine/physics/
character_controller.rs1use super::simulation::{CollisionShape, PhysicsWorld};
7
8pub struct CharacterController {
10 pub position: [f32; 3],
12 pub velocity: [f32; 3],
14 pub step_height: f32,
16 pub slope_limit_degrees: f32,
18 pub ground_snap_distance: f32,
20 pub is_grounded: bool,
22 pub radius: f32,
24 pub half_height: f32,
26 pub gravity: [f32; 3],
28}
29
30impl CharacterController {
31 pub fn new(position: [f32; 3], radius: f32, height: f32) -> Self {
33 let half_height = (height * 0.5 - radius).max(0.0);
34 Self {
35 position,
36 velocity: [0.0; 3],
37 step_height: 0.35,
38 slope_limit_degrees: 45.0,
39 ground_snap_distance: 0.1,
40 is_grounded: false,
41 radius,
42 half_height,
43 gravity: [0.0, -9.81, 0.0],
44 }
45 }
46
47 pub fn collision_shape(&self) -> CollisionShape {
49 CollisionShape::Capsule {
50 radius: self.radius,
51 half_height: self.half_height,
52 }
53 }
54
55 pub fn move_and_slide(&mut self, desired_velocity: [f32; 3], dt: f32, world: &PhysicsWorld) -> [f32; 3] {
63 let _ = world; self.velocity[0] = desired_velocity[0];
67 self.velocity[2] = desired_velocity[2];
68 self.velocity[1] += self.gravity[1] * dt;
69
70 let mut displacement = [self.velocity[0] * dt, self.velocity[1] * dt, self.velocity[2] * dt];
72
73 let new_y = self.position[1] + displacement[1];
75 if new_y <= 0.0 {
76 displacement[1] = -self.position[1];
77 self.velocity[1] = 0.0;
78 self.is_grounded = true;
79 } else {
80 self.is_grounded = false;
81 }
82
83 if self.is_grounded {
86 self.try_step_up(&mut displacement, world);
87 }
88
89 self.apply_slope_limit(&mut displacement);
91
92 if self.is_grounded {
94 self.snap_to_ground(&mut displacement, world);
95 }
96
97 self.position[0] += displacement[0];
99 self.position[1] += displacement[1];
100 self.position[2] += displacement[2];
101
102 displacement
103 }
104
105 fn try_step_up(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
107 let horiz_speed = (displacement[0] * displacement[0] + displacement[2] * displacement[2]).sqrt();
112 if horiz_speed > 0.001 && self.is_grounded {
113 }
117 }
118
119 fn apply_slope_limit(&self, displacement: &mut [f32; 3]) {
121 if !self.is_grounded {
122 return;
123 }
124 let _ = displacement;
128 }
129
130 fn snap_to_ground(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
132 let new_y = self.position[1] + displacement[1];
135 if new_y > 0.0 && new_y < self.ground_snap_distance {
136 displacement[1] = -self.position[1];
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn empty_world() -> PhysicsWorld {
146 PhysicsWorld::new()
147 }
148
149 #[test]
150 fn step_up_does_not_panic() {
151 let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
152 cc.step_height = 0.35;
153 let world = empty_world();
154 let disp = cc.move_and_slide([2.0, 0.0, 0.0], 0.016, &world);
156 assert!(disp[0].abs() > 0.001);
158 assert!(cc.is_grounded);
159 }
160
161 #[test]
162 fn ground_snap() {
163 let mut cc = CharacterController::new([0.0, 0.05, 0.0], 0.3, 1.8);
164 let world = empty_world();
165 let _ = cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
167 assert!(
168 cc.position[1].abs() < 0.2,
169 "position should snap near ground: {}",
170 cc.position[1]
171 );
172 }
173
174 #[test]
175 fn slope_limit_no_panic() {
176 let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
177 cc.slope_limit_degrees = 45.0;
178 let world = empty_world();
179 let disp = cc.move_and_slide([1.0, 0.0, 1.0], 0.016, &world);
181 assert!(disp[0].abs() > 0.001);
182 assert!(disp[2].abs() > 0.001);
183 }
184
185 #[test]
186 fn gravity_pulls_down() {
187 let mut cc = CharacterController::new([0.0, 5.0, 0.0], 0.3, 1.8);
188 let world = empty_world();
189 let initial_y = cc.position[1];
191 for _ in 0..10 {
192 cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
193 }
194 assert!(cc.position[1] < initial_y, "gravity should pull down");
195 }
196
197 #[test]
198 fn capsule_dimensions() {
199 let cc = CharacterController::new([1.0, 2.0, 3.0], 0.5, 2.0);
200 assert_eq!(cc.radius, 0.5);
201 assert!((cc.half_height - 0.5).abs() < 0.01);
203 assert_eq!(cc.position, [1.0, 2.0, 3.0]);
204 assert_eq!(cc.velocity, [0.0, 0.0, 0.0]);
205 }
206
207 #[test]
208 fn ground_contact_on_surface() {
209 let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
210 let world = empty_world();
211 cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
212 assert!(cc.is_grounded, "should be grounded at y=0");
213 }
214
215 #[test]
216 fn horizontal_movement_preserves_ground() {
217 let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
218 let world = empty_world();
219 for _ in 0..100 {
220 cc.move_and_slide([3.0, 0.0, 0.0], 0.016, &world);
221 }
222 assert!(cc.is_grounded, "should stay grounded during horizontal movement");
223 assert!(cc.position[0] > 1.0, "should have moved right");
224 }
225
226 #[test]
227 fn default_config_values() {
228 let cc = CharacterController::new([0.0; 3], 0.3, 1.8);
229 assert!((cc.gravity[1] - (-9.81)).abs() < 0.01);
230 assert!((cc.step_height - 0.35).abs() < 0.01);
231 assert!((cc.slope_limit_degrees - 45.0).abs() < 0.01);
232 assert!((cc.ground_snap_distance - 0.1).abs() < 0.01);
233 }
234
235 #[test]
236 fn zero_dt_no_movement() {
237 let mut cc = CharacterController::new([5.0, 0.0, 5.0], 0.3, 1.8);
238 let world = empty_world();
239 let pos_before = cc.position;
240 cc.move_and_slide([10.0, 0.0, 10.0], 0.0, &world);
241 let dx = (cc.position[0] - pos_before[0]).abs();
243 let dz = (cc.position[2] - pos_before[2]).abs();
244 assert!(dx < 0.01 && dz < 0.01, "zero dt should produce no movement");
245 }
246
247 #[test]
248 fn large_dt_clamped() {
249 let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
250 let world = empty_world();
251 cc.move_and_slide([1.0, 0.0, 0.0], 1.0, &world);
253 assert!(cc.position[0] < 50.0, "large dt should be bounded");
254 }
255}