Skip to main content

dreamwell_engine/physics/
character_controller.rs

1// Character controller — move-and-slide with step-up, ground snap, and slope limit.
2//
3// Pure-logic controller that queries the PhysicsWorld for collision sweeps.
4// No GPU dependency.
5
6use super::simulation::{CollisionShape, PhysicsWorld};
7
8/// Character controller state.
9pub struct CharacterController {
10    /// Current world-space position.
11    pub position: [f32; 3],
12    /// Current velocity (m/s).
13    pub velocity: [f32; 3],
14    /// Maximum step-up height (m). The controller will automatically climb steps shorter than this.
15    pub step_height: f32,
16    /// Maximum slope angle (degrees) that the character can walk on.
17    pub slope_limit_degrees: f32,
18    /// Snap distance for keeping the character grounded on slopes/stairs.
19    pub ground_snap_distance: f32,
20    /// Whether the character is currently on the ground.
21    pub is_grounded: bool,
22    /// Collision capsule radius.
23    pub radius: f32,
24    /// Collision capsule half-height (from center to cap center).
25    pub half_height: f32,
26    /// Gravity acceleration (m/s²).
27    pub gravity: [f32; 3],
28}
29
30impl CharacterController {
31    /// Create a character controller with a capsule collider.
32    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    /// The collision shape of the character.
48    pub fn collision_shape(&self) -> CollisionShape {
49        CollisionShape::Capsule {
50            radius: self.radius,
51            half_height: self.half_height,
52        }
53    }
54
55    /// Move the character using move-and-slide with step-up sweep and slope limiting.
56    ///
57    /// `desired_velocity`: input movement velocity (horizontal only, Y ignored for movement).
58    /// `dt`: time step in seconds.
59    /// `world`: physics world for collision queries.
60    ///
61    /// Returns the final position delta.
62    pub fn move_and_slide(&mut self, desired_velocity: [f32; 3], dt: f32, world: &PhysicsWorld) -> [f32; 3] {
63        let _ = world; // Used for collision queries in full implementation
64
65        // Apply gravity to vertical velocity.
66        self.velocity[0] = desired_velocity[0];
67        self.velocity[2] = desired_velocity[2];
68        self.velocity[1] += self.gravity[1] * dt;
69
70        // Compute desired displacement.
71        let mut displacement = [self.velocity[0] * dt, self.velocity[1] * dt, self.velocity[2] * dt];
72
73        // Ground collision (simple plane at y=0 for now).
74        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        // Step-up: if we hit a wall, try stepping up by step_height.
84        // Simplified: check if horizontal displacement would put us below step_height.
85        if self.is_grounded {
86            self.try_step_up(&mut displacement, world);
87        }
88
89        // Slope limiting: project displacement along surfaces steeper than slope_limit.
90        self.apply_slope_limit(&mut displacement);
91
92        // Ground snap: keep the character grounded on downhill slopes.
93        if self.is_grounded {
94            self.snap_to_ground(&mut displacement, world);
95        }
96
97        // Apply displacement.
98        self.position[0] += displacement[0];
99        self.position[1] += displacement[1];
100        self.position[2] += displacement[2];
101
102        displacement
103    }
104
105    /// Try stepping up over small obstacles.
106    fn try_step_up(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
107        // Check if horizontal displacement is blocked.
108        // In a full implementation, this would do a capsule sweep.
109        // For now, if we're grounded and the vertical displacement is zero,
110        // we allow the step by clamping the minimum Y to step_height offset.
111        let horiz_speed = (displacement[0] * displacement[0] + displacement[2] * displacement[2]).sqrt();
112        if horiz_speed > 0.001 && self.is_grounded {
113            // Step-up candidate: character is moving horizontally on ground.
114            // The actual sweep-up/sweep-forward/sweep-down is done against the world.
115            // Placeholder: no-op, full sweep depends on world.query_shape().
116        }
117    }
118
119    /// Limit movement on surfaces steeper than slope_limit_degrees.
120    fn apply_slope_limit(&self, displacement: &mut [f32; 3]) {
121        if !self.is_grounded {
122            return;
123        }
124        // On a slope, project the displacement to remove the uphill component
125        // if the slope exceeds the limit. With a flat ground plane, this is a no-op.
126        // In a full implementation, the contact normal from the ground sweep determines slope angle.
127        let _ = displacement;
128    }
129
130    /// Snap the character to the ground on downhill surfaces.
131    fn snap_to_ground(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
132        // If the character would go slightly above ground due to floating point,
133        // snap them back. With a flat plane at y=0:
134        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        // Move forward on ground — step-up logic should not panic.
155        let disp = cc.move_and_slide([2.0, 0.0, 0.0], 0.016, &world);
156        // Should have moved in X.
157        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        // Small positive Y should snap to ground.
166        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        // Moving on flat ground should work fine.
180        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        // With gravity and airborne, Y should decrease over time.
190        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        // half_height = (height*0.5 - radius).max(0) = (1.0 - 0.5) = 0.5
202        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        // With dt=0, position should not change meaningfully.
242        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        // Very large dt should not teleport character unreasonably.
252        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}