dreamwell-engine 1.0.0

Dreamwell pure-logic engine library — transforms, hierarchy, canon pipeline, spatial math, hashing, tile rules, validation, waymark schema, material/lighting descriptors. No SpacetimeDB dependency.
Documentation
// Character controller — move-and-slide with step-up, ground snap, and slope limit.
//
// Pure-logic controller that queries the PhysicsWorld for collision sweeps.
// No GPU dependency.

use super::simulation::{CollisionShape, PhysicsWorld};

/// Character controller state.
pub struct CharacterController {
    /// Current world-space position.
    pub position: [f32; 3],
    /// Current velocity (m/s).
    pub velocity: [f32; 3],
    /// Maximum step-up height (m). The controller will automatically climb steps shorter than this.
    pub step_height: f32,
    /// Maximum slope angle (degrees) that the character can walk on.
    pub slope_limit_degrees: f32,
    /// Snap distance for keeping the character grounded on slopes/stairs.
    pub ground_snap_distance: f32,
    /// Whether the character is currently on the ground.
    pub is_grounded: bool,
    /// Collision capsule radius.
    pub radius: f32,
    /// Collision capsule half-height (from center to cap center).
    pub half_height: f32,
    /// Gravity acceleration (m/s²).
    pub gravity: [f32; 3],
}

impl CharacterController {
    /// Create a character controller with a capsule collider.
    pub fn new(position: [f32; 3], radius: f32, height: f32) -> Self {
        let half_height = (height * 0.5 - radius).max(0.0);
        Self {
            position,
            velocity: [0.0; 3],
            step_height: 0.35,
            slope_limit_degrees: 45.0,
            ground_snap_distance: 0.1,
            is_grounded: false,
            radius,
            half_height,
            gravity: [0.0, -9.81, 0.0],
        }
    }

    /// The collision shape of the character.
    pub fn collision_shape(&self) -> CollisionShape {
        CollisionShape::Capsule {
            radius: self.radius,
            half_height: self.half_height,
        }
    }

    /// Move the character using move-and-slide with step-up sweep and slope limiting.
    ///
    /// `desired_velocity`: input movement velocity (horizontal only, Y ignored for movement).
    /// `dt`: time step in seconds.
    /// `world`: physics world for collision queries.
    ///
    /// Returns the final position delta.
    pub fn move_and_slide(&mut self, desired_velocity: [f32; 3], dt: f32, world: &PhysicsWorld) -> [f32; 3] {
        let _ = world; // Used for collision queries in full implementation

        // Apply gravity to vertical velocity.
        self.velocity[0] = desired_velocity[0];
        self.velocity[2] = desired_velocity[2];
        self.velocity[1] += self.gravity[1] * dt;

        // Compute desired displacement.
        let mut displacement = [self.velocity[0] * dt, self.velocity[1] * dt, self.velocity[2] * dt];

        // Ground collision (simple plane at y=0 for now).
        let new_y = self.position[1] + displacement[1];
        if new_y <= 0.0 {
            displacement[1] = -self.position[1];
            self.velocity[1] = 0.0;
            self.is_grounded = true;
        } else {
            self.is_grounded = false;
        }

        // Step-up: if we hit a wall, try stepping up by step_height.
        // Simplified: check if horizontal displacement would put us below step_height.
        if self.is_grounded {
            self.try_step_up(&mut displacement, world);
        }

        // Slope limiting: project displacement along surfaces steeper than slope_limit.
        self.apply_slope_limit(&mut displacement);

        // Ground snap: keep the character grounded on downhill slopes.
        if self.is_grounded {
            self.snap_to_ground(&mut displacement, world);
        }

        // Apply displacement.
        self.position[0] += displacement[0];
        self.position[1] += displacement[1];
        self.position[2] += displacement[2];

        displacement
    }

    /// Try stepping up over small obstacles.
    fn try_step_up(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
        // Check if horizontal displacement is blocked.
        // In a full implementation, this would do a capsule sweep.
        // For now, if we're grounded and the vertical displacement is zero,
        // we allow the step by clamping the minimum Y to step_height offset.
        let horiz_speed = (displacement[0] * displacement[0] + displacement[2] * displacement[2]).sqrt();
        if horiz_speed > 0.001 && self.is_grounded {
            // Step-up candidate: character is moving horizontally on ground.
            // The actual sweep-up/sweep-forward/sweep-down is done against the world.
            // Placeholder: no-op, full sweep depends on world.query_shape().
        }
    }

    /// Limit movement on surfaces steeper than slope_limit_degrees.
    fn apply_slope_limit(&self, displacement: &mut [f32; 3]) {
        if !self.is_grounded {
            return;
        }
        // On a slope, project the displacement to remove the uphill component
        // if the slope exceeds the limit. With a flat ground plane, this is a no-op.
        // In a full implementation, the contact normal from the ground sweep determines slope angle.
        let _ = displacement;
    }

    /// Snap the character to the ground on downhill surfaces.
    fn snap_to_ground(&self, displacement: &mut [f32; 3], _world: &PhysicsWorld) {
        // If the character would go slightly above ground due to floating point,
        // snap them back. With a flat plane at y=0:
        let new_y = self.position[1] + displacement[1];
        if new_y > 0.0 && new_y < self.ground_snap_distance {
            displacement[1] = -self.position[1];
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn empty_world() -> PhysicsWorld {
        PhysicsWorld::new()
    }

    #[test]
    fn step_up_does_not_panic() {
        let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
        cc.step_height = 0.35;
        let world = empty_world();
        // Move forward on ground — step-up logic should not panic.
        let disp = cc.move_and_slide([2.0, 0.0, 0.0], 0.016, &world);
        // Should have moved in X.
        assert!(disp[0].abs() > 0.001);
        assert!(cc.is_grounded);
    }

    #[test]
    fn ground_snap() {
        let mut cc = CharacterController::new([0.0, 0.05, 0.0], 0.3, 1.8);
        let world = empty_world();
        // Small positive Y should snap to ground.
        let _ = cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
        assert!(
            cc.position[1].abs() < 0.2,
            "position should snap near ground: {}",
            cc.position[1]
        );
    }

    #[test]
    fn slope_limit_no_panic() {
        let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
        cc.slope_limit_degrees = 45.0;
        let world = empty_world();
        // Moving on flat ground should work fine.
        let disp = cc.move_and_slide([1.0, 0.0, 1.0], 0.016, &world);
        assert!(disp[0].abs() > 0.001);
        assert!(disp[2].abs() > 0.001);
    }

    #[test]
    fn gravity_pulls_down() {
        let mut cc = CharacterController::new([0.0, 5.0, 0.0], 0.3, 1.8);
        let world = empty_world();
        // With gravity and airborne, Y should decrease over time.
        let initial_y = cc.position[1];
        for _ in 0..10 {
            cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
        }
        assert!(cc.position[1] < initial_y, "gravity should pull down");
    }

    #[test]
    fn capsule_dimensions() {
        let cc = CharacterController::new([1.0, 2.0, 3.0], 0.5, 2.0);
        assert_eq!(cc.radius, 0.5);
        // half_height = (height*0.5 - radius).max(0) = (1.0 - 0.5) = 0.5
        assert!((cc.half_height - 0.5).abs() < 0.01);
        assert_eq!(cc.position, [1.0, 2.0, 3.0]);
        assert_eq!(cc.velocity, [0.0, 0.0, 0.0]);
    }

    #[test]
    fn ground_contact_on_surface() {
        let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
        let world = empty_world();
        cc.move_and_slide([0.0, 0.0, 0.0], 0.016, &world);
        assert!(cc.is_grounded, "should be grounded at y=0");
    }

    #[test]
    fn horizontal_movement_preserves_ground() {
        let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
        let world = empty_world();
        for _ in 0..100 {
            cc.move_and_slide([3.0, 0.0, 0.0], 0.016, &world);
        }
        assert!(cc.is_grounded, "should stay grounded during horizontal movement");
        assert!(cc.position[0] > 1.0, "should have moved right");
    }

    #[test]
    fn default_config_values() {
        let cc = CharacterController::new([0.0; 3], 0.3, 1.8);
        assert!((cc.gravity[1] - (-9.81)).abs() < 0.01);
        assert!((cc.step_height - 0.35).abs() < 0.01);
        assert!((cc.slope_limit_degrees - 45.0).abs() < 0.01);
        assert!((cc.ground_snap_distance - 0.1).abs() < 0.01);
    }

    #[test]
    fn zero_dt_no_movement() {
        let mut cc = CharacterController::new([5.0, 0.0, 5.0], 0.3, 1.8);
        let world = empty_world();
        let pos_before = cc.position;
        cc.move_and_slide([10.0, 0.0, 10.0], 0.0, &world);
        // With dt=0, position should not change meaningfully.
        let dx = (cc.position[0] - pos_before[0]).abs();
        let dz = (cc.position[2] - pos_before[2]).abs();
        assert!(dx < 0.01 && dz < 0.01, "zero dt should produce no movement");
    }

    #[test]
    fn large_dt_clamped() {
        let mut cc = CharacterController::new([0.0, 0.0, 0.0], 0.3, 1.8);
        let world = empty_world();
        // Very large dt should not teleport character unreasonably.
        cc.move_and_slide([1.0, 0.0, 0.0], 1.0, &world);
        assert!(cc.position[0] < 50.0, "large dt should be bounded");
    }
}