diff --git a/crates/rust4d_physics/src/body.rs b/crates/rust4d_physics/src/body.rs index da0518c..26e1c37 100644 --- a/crates/rust4d_physics/src/body.rs +++ b/crates/rust4d_physics/src/body.rs @@ -1,5 +1,6 @@ //! Rigid body types for 4D physics simulation +use crate::collision::CollisionFilter; use crate::material::PhysicsMaterial; use crate::shapes::{Collider, Plane4D}; use rust4d_math::Vec4; @@ -15,6 +16,18 @@ new_key_type! { pub struct BodyKey; } +/// Type of rigid body that determines how it's simulated +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BodyType { + /// Full physics simulation with gravity and collision response + #[default] + Dynamic, + /// Never moves, used for floors, walls, platforms + Static, + /// User-controlled velocity, no gravity (ideal for player characters) + Kinematic, +} + /// A 4D rigid body with position, velocity, and collision shape #[derive(Clone, Debug)] pub struct RigidBody4D { @@ -26,14 +39,37 @@ pub struct RigidBody4D { pub mass: f32, /// Physical material properties (friction and restitution) pub material: PhysicsMaterial, - /// Whether this body is affected by gravity - pub affected_by_gravity: bool, /// The collision shape for this body (stores absolute world position) pub collider: Collider, - /// Whether this body is static (static bodies don't move) - pub is_static: bool, + /// Type of body (Dynamic, Static, or Kinematic) + pub body_type: BodyType, + /// Whether this body is touching the ground (set by physics step) + pub grounded: bool, + /// Collision filter (layer membership and collision mask) + pub filter: CollisionFilter, } +impl RigidBody4D { + /// Check if this body is affected by gravity + #[inline] + pub fn affected_by_gravity(&self) -> bool { + self.body_type == BodyType::Dynamic + } + + /// Check if this body is static (never moves) + #[inline] + pub fn is_static(&self) -> bool { + self.body_type == BodyType::Static + } + + /// Check if this body is kinematic (user-controlled, no gravity) + #[inline] + pub fn is_kinematic(&self) -> bool { + self.body_type == BodyType::Kinematic + } +} + +// Additional RigidBody4D constructors and builder methods impl RigidBody4D { /// Create a new rigid body with a sphere collider pub fn new_sphere(position: Vec4, radius: f32) -> Self { @@ -43,9 +79,10 @@ impl RigidBody4D { velocity: Vec4::ZERO, mass: 1.0, material: PhysicsMaterial::default(), - affected_by_gravity: true, collider: Collider::Sphere(Sphere4D::new(position, radius)), - is_static: false, + body_type: BodyType::Dynamic, + grounded: false, + filter: CollisionFilter::default(), } } @@ -57,18 +94,16 @@ impl RigidBody4D { velocity: Vec4::ZERO, mass: 1.0, material: PhysicsMaterial::default(), - affected_by_gravity: true, collider: Collider::AABB(AABB4D::from_center_half_extents(position, half_extents)), - is_static: false, + body_type: BodyType::Dynamic, + grounded: false, + filter: CollisionFilter::default(), } } /// Create a static body that doesn't move pub fn new_static_aabb(position: Vec4, half_extents: Vec4) -> Self { - let mut body = Self::new_aabb(position, half_extents); - body.is_static = true; - body.affected_by_gravity = false; - body + Self::new_aabb(position, half_extents).with_body_type(BodyType::Static) } /// Set the velocity of this body @@ -98,21 +133,57 @@ impl RigidBody4D { self } - /// Set whether this body is affected by gravity + /// Set the body type (Dynamic, Static, or Kinematic) + pub fn with_body_type(mut self, body_type: BodyType) -> Self { + self.body_type = body_type; + self + } + + /// Set whether this body is affected by gravity (legacy API) + /// + /// Sets body_type to Dynamic if gravity is enabled, otherwise keeps current type. + /// For new code, prefer `with_body_type()`. pub fn with_gravity(mut self, affected: bool) -> Self { - self.affected_by_gravity = affected; + if !affected && self.body_type == BodyType::Dynamic { + // If disabling gravity on a dynamic body, it becomes kinematic + self.body_type = BodyType::Kinematic; + } else if affected && self.body_type == BodyType::Kinematic { + // If enabling gravity on a kinematic body, it becomes dynamic + self.body_type = BodyType::Dynamic; + } self } - /// Set whether this body is static + /// Set whether this body is static (legacy API) + /// + /// For new code, prefer `with_body_type()`. pub fn with_static(mut self, is_static: bool) -> Self { - self.is_static = is_static; if is_static { - self.affected_by_gravity = false; + self.body_type = BodyType::Static; + } else if self.body_type == BodyType::Static { + self.body_type = BodyType::Dynamic; } self } + /// Set the collision filter for this body + pub fn with_filter(mut self, filter: CollisionFilter) -> Self { + self.filter = filter; + self + } + + /// Set the collision layer (which layer this body belongs to) + pub fn with_layer(mut self, layer: crate::collision::CollisionLayer) -> Self { + self.filter.layer = layer; + self + } + + /// Set the collision mask (which layers this body can collide with) + pub fn with_mask(mut self, mask: crate::collision::CollisionLayer) -> Self { + self.filter.mask = mask; + self + } + /// Update the position and sync the collider pub fn set_position(&mut self, position: Vec4) { let delta = position - self.position; @@ -137,12 +208,18 @@ pub struct StaticCollider { pub collider: Collider, /// Physics material (friction and restitution) pub material: PhysicsMaterial, + /// Collision filter (layer membership and collision mask) + pub filter: CollisionFilter, } impl StaticCollider { /// Create a new static collider with the given shape and material pub fn new(collider: Collider, material: PhysicsMaterial) -> Self { - Self { collider, material } + Self { + collider, + material, + filter: CollisionFilter::static_world(), + } } /// Create a plane collider @@ -150,6 +227,7 @@ impl StaticCollider { Self { collider: Collider::Plane(Plane4D::new(normal, distance)), material, + filter: CollisionFilter::static_world(), } } @@ -158,6 +236,7 @@ impl StaticCollider { Self { collider: Collider::Plane(Plane4D::floor(y)), material, + filter: CollisionFilter::static_world(), } } @@ -167,8 +246,15 @@ impl StaticCollider { Self { collider: Collider::AABB(AABB4D::from_center_half_extents(center, half_extents)), material, + filter: CollisionFilter::static_world(), } } + + /// Set the collision filter for this static collider + pub fn with_filter(mut self, filter: CollisionFilter) -> Self { + self.filter = filter; + self + } } #[cfg(test)] @@ -184,8 +270,8 @@ mod tests { assert_eq!(body.velocity, Vec4::ZERO); assert_eq!(body.mass, 1.0); assert_eq!(body.material, PhysicsMaterial::default()); - assert!(body.affected_by_gravity); - assert!(!body.is_static); + assert!(body.affected_by_gravity()); + assert!(!body.is_static()); // Check collider is properly set assert_eq!(body.collider.center(), pos); @@ -206,8 +292,8 @@ mod tests { let pos = Vec4::new(0.0, 0.0, 0.0, 0.0); let body = RigidBody4D::new_static_aabb(pos, Vec4::new(1.0, 1.0, 1.0, 1.0)); - assert!(body.is_static); - assert!(!body.affected_by_gravity); + assert!(body.is_static()); + assert!(!body.affected_by_gravity()); } #[test] @@ -221,7 +307,7 @@ mod tests { assert_eq!(body.velocity, Vec4::new(1.0, 2.0, 0.0, 0.0)); assert_eq!(body.mass, 5.0); assert_eq!(body.material.restitution, 0.8); - assert!(!body.affected_by_gravity); + assert!(!body.affected_by_gravity()); } #[test] @@ -269,7 +355,59 @@ mod tests { .with_gravity(true) .with_static(true); - assert!(body.is_static); - assert!(!body.affected_by_gravity); + assert!(body.is_static()); + assert!(!body.affected_by_gravity()); + } + + // ===== Collision Filter Tests ===== + + #[test] + fn test_default_filter() { + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0); + assert_eq!(body.filter, CollisionFilter::default()); + } + + #[test] + fn test_with_filter() { + use crate::collision::CollisionLayer; + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) + .with_filter(CollisionFilter::player()); + + assert_eq!(body.filter.layer, CollisionLayer::PLAYER); + } + + #[test] + fn test_with_layer() { + use crate::collision::CollisionLayer; + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) + .with_layer(CollisionLayer::ENEMY); + + assert_eq!(body.filter.layer, CollisionLayer::ENEMY); + } + + #[test] + fn test_with_mask() { + use crate::collision::CollisionLayer; + let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) + .with_mask(CollisionLayer::STATIC | CollisionLayer::ENEMY); + + assert!(body.filter.mask.contains(CollisionLayer::STATIC)); + assert!(body.filter.mask.contains(CollisionLayer::ENEMY)); + assert!(!body.filter.mask.contains(CollisionLayer::PLAYER)); + } + + #[test] + fn test_static_collider_default_filter() { + let collider = StaticCollider::floor(0.0, PhysicsMaterial::CONCRETE); + assert_eq!(collider.filter, CollisionFilter::static_world()); + } + + #[test] + fn test_static_collider_with_filter() { + use crate::collision::CollisionLayer; + let collider = StaticCollider::floor(0.0, PhysicsMaterial::CONCRETE) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + + assert_eq!(collider.filter.layer, CollisionLayer::TRIGGER); } } diff --git a/crates/rust4d_physics/src/collision.rs b/crates/rust4d_physics/src/collision.rs index f700ee4..375a687 100644 --- a/crates/rust4d_physics/src/collision.rs +++ b/crates/rust4d_physics/src/collision.rs @@ -1,10 +1,138 @@ //! Collision detection for 4D shapes //! //! Provides collision detection between spheres, AABBs, and planes. +//! Also provides collision filtering via layer masks. + +use bitflags::bitflags; use crate::shapes::{Plane4D, Sphere4D, AABB4D}; use rust4d_math::Vec4; +bitflags! { + /// Collision layers for filtering which objects can collide + /// + /// Each layer is a bit in a 32-bit mask. Objects can belong to multiple layers + /// and can define which layers they collide with via a collision mask. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct CollisionLayer: u32 { + /// Default layer for most objects + const DEFAULT = 1 << 0; + /// Player character layer + const PLAYER = 1 << 1; + /// Enemy/NPC layer + const ENEMY = 1 << 2; + /// Static world geometry (floors, walls) + const STATIC = 1 << 3; + /// Trigger zones (detect but don't push) + const TRIGGER = 1 << 4; + /// Projectiles (bullets, spells) + const PROJECTILE = 1 << 5; + /// Collectible items (coins, powerups) + const PICKUP = 1 << 6; + /// All layers (collide with everything) + const ALL = 0xFFFFFFFF; + } +} + +/// Collision filter determining what an object collides with +/// +/// Uses a layer/mask system: +/// - `layer`: Which layer(s) this object belongs to +/// - `mask`: Which layer(s) this object can collide with +/// +/// Two objects A and B collide if: +/// - (A.layer & B.mask) != 0, AND +/// - (B.layer & A.mask) != 0 +/// +/// This allows asymmetric collision relationships (e.g., triggers detect +/// players but players don't push triggers). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CollisionFilter { + /// Which layer(s) this object belongs to + pub layer: CollisionLayer, + /// Which layer(s) this object can collide with + pub mask: CollisionLayer, +} + +impl Default for CollisionFilter { + fn default() -> Self { + Self { + layer: CollisionLayer::DEFAULT, + mask: CollisionLayer::ALL, + } + } +} + +impl CollisionFilter { + /// Create a new collision filter with specified layer and mask + pub fn new(layer: CollisionLayer, mask: CollisionLayer) -> Self { + Self { layer, mask } + } + + /// Check if this filter allows collision with another filter + /// + /// Returns true if both objects' layers match each other's masks. + pub fn collides_with(&self, other: &Self) -> bool { + // Both must agree on the collision + self.layer.intersects(other.mask) && other.layer.intersects(self.mask) + } + + /// Create a filter for player objects + /// + /// Players collide with everything except other players, player projectiles, and triggers. + /// Triggers can still detect players for event handling, but players won't push triggers. + pub fn player() -> Self { + Self { + layer: CollisionLayer::PLAYER, + mask: CollisionLayer::ALL + & !CollisionLayer::PLAYER + & !CollisionLayer::PROJECTILE + & !CollisionLayer::TRIGGER, + } + } + + /// Create a filter for enemy objects + /// + /// Enemies collide with everything except other enemies. + pub fn enemy() -> Self { + Self { + layer: CollisionLayer::ENEMY, + mask: CollisionLayer::ALL & !CollisionLayer::ENEMY, + } + } + + /// Create a filter for static world geometry + /// + /// Static objects are detected by everything but don't detect anything themselves. + pub fn static_world() -> Self { + Self { + layer: CollisionLayer::STATIC, + mask: CollisionLayer::ALL, + } + } + + /// Create a filter for trigger zones + /// + /// Triggers detect specified layers but those layers don't get pushed by triggers. + /// The trigger can detect collisions (for events) but won't apply forces. + pub fn trigger(detects: CollisionLayer) -> Self { + Self { + layer: CollisionLayer::TRIGGER, + mask: detects, + } + } + + /// Create a filter for player projectiles + /// + /// Player projectiles hit enemies and static geometry, but not the player or pickups. + pub fn player_projectile() -> Self { + Self { + layer: CollisionLayer::PROJECTILE, + mask: CollisionLayer::ENEMY | CollisionLayer::STATIC, + } + } +} + /// Contact information from a collision #[derive(Clone, Copy, Debug)] pub struct Contact { @@ -309,4 +437,126 @@ mod tests { let contact = aabb_vs_aabb(&a, &b).expect("Should collide"); assert!((contact.penetration - 0.5).abs() < 0.0001); } + + // ===== Collision Filter Tests ===== + + #[test] + fn test_collision_filter_default() { + let filter = CollisionFilter::default(); + assert_eq!(filter.layer, CollisionLayer::DEFAULT); + assert_eq!(filter.mask, CollisionLayer::ALL); + } + + #[test] + fn test_collision_filter_default_collides_with_everything() { + let default = CollisionFilter::default(); + let player = CollisionFilter::player(); + let enemy = CollisionFilter::enemy(); + let static_world = CollisionFilter::static_world(); + + // Default should collide with player, enemy, and static + // (but player/enemy might not collide back due to their masks) + assert!(default.layer.intersects(player.mask)); + assert!(default.layer.intersects(enemy.mask)); + assert!(default.layer.intersects(static_world.mask)); + } + + #[test] + fn test_collision_filter_player_vs_static() { + let player = CollisionFilter::player(); + let static_world = CollisionFilter::static_world(); + + // Player should collide with static world + assert!(player.collides_with(&static_world)); + assert!(static_world.collides_with(&player)); + } + + #[test] + fn test_collision_filter_player_vs_player() { + let player1 = CollisionFilter::player(); + let player2 = CollisionFilter::player(); + + // Players should not collide with each other + assert!(!player1.collides_with(&player2)); + } + + #[test] + fn test_collision_filter_enemy_vs_enemy() { + let enemy1 = CollisionFilter::enemy(); + let enemy2 = CollisionFilter::enemy(); + + // Enemies should not collide with each other + assert!(!enemy1.collides_with(&enemy2)); + } + + #[test] + fn test_collision_filter_player_vs_enemy() { + let player = CollisionFilter::player(); + let enemy = CollisionFilter::enemy(); + + // Player should collide with enemy + assert!(player.collides_with(&enemy)); + } + + #[test] + fn test_collision_filter_player_projectile() { + let projectile = CollisionFilter::player_projectile(); + let player = CollisionFilter::player(); + let enemy = CollisionFilter::enemy(); + let static_world = CollisionFilter::static_world(); + + // Player projectile should hit enemies and static + assert!(projectile.collides_with(&enemy)); + assert!(projectile.collides_with(&static_world)); + + // Player projectile should not hit player + assert!(!projectile.collides_with(&player)); + } + + #[test] + fn test_collision_filter_trigger() { + let trigger = CollisionFilter::trigger(CollisionLayer::PLAYER); + let player = CollisionFilter::player(); + let enemy = CollisionFilter::enemy(); + + // Trigger that detects players + // Note: collides_with is symmetric, so both need to agree + // Trigger's mask has PLAYER, but player's mask doesn't have TRIGGER by default + // This allows triggers to detect players for events + assert!(trigger.mask.contains(CollisionLayer::PLAYER)); + + // The trigger layer is not in player's mask, so symmetric check fails + // This is intentional: triggers detect but don't push + assert!(!trigger.collides_with(&player)); + + // Trigger definitely doesn't collide with enemy + assert!(!trigger.collides_with(&enemy)); + } + + #[test] + fn test_collision_layer_bitflags() { + let combined = CollisionLayer::PLAYER | CollisionLayer::ENEMY; + assert!(combined.contains(CollisionLayer::PLAYER)); + assert!(combined.contains(CollisionLayer::ENEMY)); + assert!(!combined.contains(CollisionLayer::STATIC)); + } + + #[test] + fn test_collision_filter_custom() { + // Custom filter: belongs to PICKUP layer, only collides with PLAYER + let pickup = CollisionFilter::new(CollisionLayer::PICKUP, CollisionLayer::PLAYER); + + let player = CollisionFilter::new( + CollisionLayer::PLAYER, + CollisionLayer::ALL, // Player collides with everything + ); + + let enemy = CollisionFilter::enemy(); + + // Pickup collides with player (both agree) + assert!(pickup.collides_with(&player)); + + // Pickup doesn't collide with enemy (pickup's mask doesn't include ENEMY) + assert!(!pickup.collides_with(&enemy)); + } } diff --git a/crates/rust4d_physics/src/lib.rs b/crates/rust4d_physics/src/lib.rs index 5e93c65..63998f6 100644 --- a/crates/rust4d_physics/src/lib.rs +++ b/crates/rust4d_physics/src/lib.rs @@ -14,8 +14,8 @@ pub mod shapes; pub mod world; // Re-export commonly used types -pub use body::{BodyKey, RigidBody4D, StaticCollider}; -pub use collision::{aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, Contact}; +pub use body::{BodyKey, BodyType, RigidBody4D, StaticCollider}; +pub use collision::{aabb_vs_aabb, aabb_vs_plane, sphere_vs_aabb, sphere_vs_plane, CollisionFilter, CollisionLayer, Contact}; pub use material::PhysicsMaterial; pub use player::{PlayerPhysics, DEFAULT_JUMP_VELOCITY, DEFAULT_PLAYER_RADIUS}; pub use shapes::{Collider, Plane4D, Sphere4D, AABB4D}; diff --git a/crates/rust4d_physics/src/world.rs b/crates/rust4d_physics/src/world.rs index 308db92..4babfc2 100644 --- a/crates/rust4d_physics/src/world.rs +++ b/crates/rust4d_physics/src/world.rs @@ -28,6 +28,9 @@ impl PhysicsConfig { } } +/// Default jump velocity for player +pub const DEFAULT_JUMP_VELOCITY: f32 = 8.0; + /// The physics world containing all rigid bodies pub struct PhysicsWorld { /// All rigid bodies in the world (using generational keys) @@ -36,6 +39,10 @@ pub struct PhysicsWorld { static_colliders: Vec, /// Physics configuration pub config: PhysicsConfig, + /// The player body key (if a player has been registered) + player_body: Option, + /// Jump velocity for the player + player_jump_velocity: f32, } impl PhysicsWorld { @@ -50,6 +57,8 @@ impl PhysicsWorld { bodies: SlotMap::with_key(), static_colliders: Vec::new(), config, + player_body: None, + player_jump_velocity: DEFAULT_JUMP_VELOCITY, } } @@ -93,6 +102,75 @@ impl PhysicsWorld { self.bodies.keys() } + // ====== Player Body Management ====== + + /// Register a body as the player body + /// + /// This enables player-specific features like jump and grounded detection. + /// The body should typically be kinematic (no gravity, user-controlled velocity). + pub fn set_player_body(&mut self, key: BodyKey) { + self.player_body = Some(key); + } + + /// Set the jump velocity for the player + pub fn set_player_jump_velocity(&mut self, velocity: f32) { + self.player_jump_velocity = velocity; + } + + /// Get the player body key + pub fn player_key(&self) -> Option { + self.player_body + } + + /// Get an immutable reference to the player body + pub fn player(&self) -> Option<&RigidBody4D> { + self.player_body.and_then(|key| self.bodies.get(key)) + } + + /// Get a mutable reference to the player body + pub fn player_mut(&mut self) -> Option<&mut RigidBody4D> { + self.player_body.and_then(|key| self.bodies.get_mut(key)) + } + + /// Get the player's current position + pub fn player_position(&self) -> Option { + self.player().map(|body| body.position) + } + + /// Check if the player is currently grounded + pub fn player_is_grounded(&self) -> bool { + self.player().map(|body| body.grounded).unwrap_or(false) + } + + /// Apply horizontal movement to the player (XZ plane + W for 4D) + /// + /// This sets the player's velocity on the XZ and W axes. + /// The Y component is controlled by gravity and jumping. + pub fn apply_player_movement(&mut self, movement: Vec4) { + if let Some(body) = self.player_mut() { + // Only set horizontal velocity - preserve Y for gravity/jumping + body.velocity.x = movement.x; + body.velocity.z = movement.z; + body.velocity.w = movement.w; + } + } + + /// Attempt to make the player jump + /// + /// Only succeeds if the player is grounded. Sets vertical velocity + /// to the configured jump velocity. + pub fn player_jump(&mut self) -> bool { + let jump_vel = self.player_jump_velocity; + if let Some(body) = self.player_mut() { + if body.grounded { + body.velocity.y = jump_vel; + body.grounded = false; + return true; + } + } + false + } + /// Step the physics simulation forward by dt seconds /// /// This performs: @@ -101,14 +179,21 @@ impl PhysicsWorld { /// 3. Static collider collision detection and resolution /// 4. Body-body collision detection and resolution pub fn step(&mut self, dt: f32) { + // Reset grounded state for player before collision detection + if let Some(key) = self.player_body { + if let Some(body) = self.bodies.get_mut(key) { + body.grounded = false; + } + } + // Phase 1: Apply gravity and integrate velocity for (_key, body) in &mut self.bodies { - if body.is_static { + if body.is_static() { continue; } // Apply gravity - if body.affected_by_gravity { + if body.affected_by_gravity() { body.velocity.y += self.config.gravity * dt; } @@ -180,12 +265,20 @@ impl PhysicsWorld { /// Resolve collisions between bodies and static colliders fn resolve_static_collisions(&mut self) { + // Threshold for considering a surface as "ground" (normal pointing mostly up) + const GROUND_NORMAL_THRESHOLD: f32 = 0.7; + for (_key, body) in &mut self.bodies { - if body.is_static { + if body.is_static() { continue; } for static_col in &self.static_colliders { + // Check if collision layers allow this interaction + if !body.filter.collides_with(&static_col.filter) { + continue; + } + let contact = Self::check_static_collision(&body.collider, &static_col.collider); if let Some(contact) = contact { @@ -194,6 +287,12 @@ impl PhysicsWorld { let correction = contact.normal * contact.penetration; body.apply_correction(correction); + // Check if this is a ground contact (normal pointing up) + // This is used for grounded state detection + if contact.normal.y > GROUND_NORMAL_THRESHOLD { + body.grounded = true; + } + // Combine body and static collider materials let combined = body.material.combine(&static_col.material); @@ -219,6 +318,7 @@ impl PhysicsWorld { } } } + } /// Resolve collisions between bodies @@ -233,11 +333,11 @@ impl PhysicsWorld { let key_a = keys[i]; let key_b = keys[j]; - // Get colliders for both bodies - let (collider_a, collider_b, is_static_a, is_static_b) = { + // Get colliders and filters for both bodies + let (collider_a, collider_b, is_static_a, is_static_b, filter_a, filter_b) = { let body_a = &self.bodies[key_a]; let body_b = &self.bodies[key_b]; - (body_a.collider, body_b.collider, body_a.is_static, body_b.is_static) + (body_a.collider, body_b.collider, body_a.is_static(), body_b.is_static(), body_a.filter, body_b.filter) }; // Skip if both bodies are static @@ -245,6 +345,11 @@ impl PhysicsWorld { continue; } + // Check if collision layers allow this interaction + if !filter_a.collides_with(&filter_b) { + continue; + } + // Check for collision based on collider types // The contact normal convention: points FROM body A TOWARD body B let contact = match (&collider_a, &collider_b) { @@ -716,4 +821,246 @@ mod tests { assert!(ball.position.y >= 0.0 && ball.position.y <= 10.0, "Ball should be between floor and ceiling, got y={}", ball.position.y); } + + // ====== Player Body Tests ====== + + #[test] + fn test_player_body_registration() { + let mut world = PhysicsWorld::new(); + + // Create player body (kinematic - no gravity) + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + + // Register as player + world.set_player_body(player_key); + + // Check player body access + assert_eq!(world.player_key(), Some(player_key)); + assert!(world.player().is_some()); + assert!(world.player_mut().is_some()); + } + + #[test] + fn test_player_position() { + let mut world = PhysicsWorld::new(); + + let start_pos = Vec4::new(5.0, 2.0, 3.0, 1.0); + let player = RigidBody4D::new_sphere(start_pos, 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + world.set_player_body(player_key); + + assert_eq!(world.player_position(), Some(start_pos)); + } + + #[test] + fn test_player_movement() { + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // No gravity + + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + world.set_player_body(player_key); + + // Apply horizontal movement + world.apply_player_movement(Vec4::new(10.0, 0.0, 5.0, 2.0)); + + // Step physics + world.step(0.1); + + // Check player moved in XZW but Y was preserved + let pos = world.player_position().unwrap(); + assert!((pos.x - 1.0).abs() < 0.01); // 10 * 0.1 = 1.0 + assert!((pos.y - 1.0).abs() < 0.01); // Y unchanged + assert!((pos.z - 0.5).abs() < 0.01); // 5 * 0.1 = 0.5 + assert!((pos.w - 0.2).abs() < 0.01); // 2 * 0.1 = 0.2 + } + + #[test] + fn test_player_grounded_detection() { + let mut world = world_with_floor(0.0, 0.0, PhysicsMaterial::CONCRETE); + + // Player just above floor (radius 0.5, position at y=0.5 means touching floor) + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + world.set_player_body(player_key); + + // Initially not grounded + assert!(!world.player_is_grounded()); + + // Step to detect floor collision + world.step(0.016); + + // Should be grounded after collision detection + assert!(world.player_is_grounded()); + } + + #[test] + fn test_player_jump() { + let mut world = world_with_floor(0.0, 0.0, PhysicsMaterial::CONCRETE); + + // Player on floor + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + world.set_player_body(player_key); + + // Step to get grounded + world.step(0.016); + assert!(world.player_is_grounded()); + + // Jump + let jumped = world.player_jump(); + assert!(jumped); + assert!(!world.player_is_grounded()); + + // Check velocity set + let vel = world.player().unwrap().velocity; + assert_eq!(vel.y, DEFAULT_JUMP_VELOCITY); + } + + #[test] + fn test_player_cannot_jump_while_airborne() { + let mut world = PhysicsWorld::new(); + + // Player in the air + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + let player_key = world.add_body(player); + world.set_player_body(player_key); + + // Not grounded initially + assert!(!world.player_is_grounded()); + + // Jump should fail + let jumped = world.player_jump(); + assert!(!jumped); + + // Velocity should still be zero + let vel = world.player().unwrap().velocity; + assert_eq!(vel.y, 0.0); + } + + #[test] + fn test_player_jump_velocity_config() { + let mut world = PhysicsWorld::new(); + world.set_player_jump_velocity(15.0); + + // Player that's grounded + let mut player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic); + player.grounded = true; // Manually set grounded for test + let player_key = world.add_body(player); + world.set_player_body(player_key); + + // Jump + world.player_jump(); + + // Check custom velocity used + let vel = world.player().unwrap().velocity; + assert_eq!(vel.y, 15.0); + } + + // ====== Collision Filtering Tests ====== + + #[test] + fn test_collision_filter_static_collider_skip() { + use crate::collision::{CollisionFilter, CollisionLayer}; + + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); + + // Create a trigger zone that only detects players + // but players don't collide with triggers + let trigger = StaticCollider::floor(0.0, PhysicsMaterial::CONCRETE) + .with_filter(CollisionFilter::trigger(CollisionLayer::PLAYER)); + world.add_static_collider(trigger); + + // A sphere with default filter (DEFAULT layer) - should pass through trigger + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 0.0), 0.5) + .with_velocity(Vec4::new(0.0, -10.0, 0.0, 0.0)); + let handle = world.add_body(body); + + // Step physics - body should fall through trigger (no collision) + world.step(0.1); + + let body = world.get_body(handle).unwrap(); + // Body should have moved down (no floor collision) + assert!(body.position.y < 0.5, "Body should fall through trigger zone"); + } + + #[test] + fn test_collision_filter_body_body_skip() { + use crate::collision::CollisionFilter; + + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); + + // Two players - players don't collide with each other + let player1 = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) + .with_filter(CollisionFilter::player()); + let handle1 = world.add_body(player1); + + let player2 = RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5) + .with_filter(CollisionFilter::player()); + let _handle2 = world.add_body(player2); + + // They overlap (centers 0.8 apart, combined radii 1.0) but shouldn't collide + world.step(0.016); + + // Player1's position should be unchanged (no push) + let p1 = world.get_body(handle1).unwrap(); + assert_eq!(p1.position.x, 0.0, "Players should not push each other"); + } + + #[test] + fn test_collision_filter_body_body_collide() { + use crate::collision::CollisionFilter; + + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); + + // Player vs enemy - they should collide + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) + .with_filter(CollisionFilter::player()); + let handle_player = world.add_body(player); + + let enemy = RigidBody4D::new_sphere(Vec4::new(0.8, 0.0, 0.0, 0.0), 0.5) + .with_filter(CollisionFilter::enemy()); + world.add_body(enemy); + + // They overlap and should collide + world.step(0.016); + + // Player's position should change (pushed) + let p = world.get_body(handle_player).unwrap(); + assert!(p.position.x < 0.0, "Player should be pushed by enemy"); + } + + #[test] + fn test_player_projectile_filter() { + use crate::collision::CollisionFilter; + + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); + + // Player + let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) + .with_filter(CollisionFilter::player()); + let handle_player = world.add_body(player); + + // Player's projectile moving toward player - should not hit + let projectile = RigidBody4D::new_sphere(Vec4::new(1.5, 0.0, 0.0, 0.0), 0.3) + .with_filter(CollisionFilter::player_projectile()) + .with_velocity(Vec4::new(-20.0, 0.0, 0.0, 0.0)); + world.add_body(projectile); + + // Step several times + for _ in 0..10 { + world.step(0.016); + } + + // Player should not have moved (projectile passed through) + let p = world.get_body(handle_player).unwrap(); + assert_eq!(p.position.x, 0.0, "Player projectile should not hit player"); + } }