diff --git a/crates/rust4d_physics/src/body.rs b/crates/rust4d_physics/src/body.rs index 993bfa1..cbcfeec 100644 --- a/crates/rust4d_physics/src/body.rs +++ b/crates/rust4d_physics/src/body.rs @@ -47,13 +47,15 @@ pub struct RigidBody4D { pub grounded: bool, /// Collision filter (layer membership and collision mask) pub filter: CollisionFilter, + /// Whether gravity applies to this body (independent of body type) + pub gravity_enabled: bool, } impl RigidBody4D { /// Check if this body is affected by gravity #[inline] pub fn affected_by_gravity(&self) -> bool { - self.body_type == BodyType::Dynamic + self.gravity_enabled } /// Check if this body is static (never moves) @@ -83,6 +85,7 @@ impl RigidBody4D { body_type: BodyType::Dynamic, grounded: false, filter: CollisionFilter::default(), + gravity_enabled: true, // Dynamic bodies have gravity by default } } @@ -98,6 +101,7 @@ impl RigidBody4D { body_type: BodyType::Dynamic, grounded: false, filter: CollisionFilter::default(), + gravity_enabled: true, // Dynamic bodies have gravity by default } } @@ -134,23 +138,34 @@ impl RigidBody4D { } /// Set the body type (Dynamic, Static, or Kinematic) + /// + /// Also sets `gravity_enabled` based on body type: + /// - Dynamic: gravity enabled + /// - Static/Kinematic: gravity disabled + /// + /// To override gravity independently of body type, call + /// `with_gravity_enabled()` after this method. pub fn with_body_type(mut self, body_type: BodyType) -> Self { self.body_type = body_type; + self.gravity_enabled = body_type == BodyType::Dynamic; self } - /// Set whether this body is affected by gravity (legacy API) + /// Set whether this body is affected by gravity /// - /// Sets body_type to Dynamic if gravity is enabled, otherwise keeps current type. - /// For new code, prefer `with_body_type()`. + /// This directly controls `gravity_enabled` without changing the body type. + /// Use this to enable gravity on kinematic bodies (e.g., player characters + /// that need gravity for jumping/falling) or to disable gravity on dynamic bodies. pub fn with_gravity(mut self, affected: bool) -> Self { - 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.gravity_enabled = affected; + self + } + + /// Set whether gravity applies to this body (independent of body type) + /// + /// This is equivalent to `with_gravity()` but with a more explicit name. + pub fn with_gravity_enabled(mut self, enabled: bool) -> Self { + self.gravity_enabled = enabled; self } @@ -160,8 +175,10 @@ impl RigidBody4D { pub fn with_static(mut self, is_static: bool) -> Self { if is_static { self.body_type = BodyType::Static; + self.gravity_enabled = false; } else if self.body_type == BodyType::Static { self.body_type = BodyType::Dynamic; + self.gravity_enabled = true; } self } -- knotmirror 0.0.1 From e58069921ed2e47197202ec126d86a766624297a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:03:37 +0000 Subject: [PATCH 02/16] Remove player-specific code from PhysicsWorld, add generic body methods Replace player-specific APIs with generic body methods that work with any BodyKey. The physics engine is now fully agnostic to "player" vs other entities. Structural changes: - Remove jump_velocity from PhysicsConfig - Remove player_body, player_jump_velocity from PhysicsWorld - Remove set_player_body(), player(), player_mut(), player_position(), player_is_grounded(), apply_player_movement(), player_jump() - Add body_is_grounded(key), body_position(key), apply_body_movement(key, movement), body_jump(key, velocity) Behavior changes: - Grounded state now resets for ALL non-static bodies each step (was player-only) - Gravity uses body.gravity_enabled instead of special-casing player - Edge-falling detection applies to all kinematic bodies (was player-only) Tests rewritten to use generic body APIs: - test_body_position, test_body_movement, test_body_grounded_detection - test_body_jump, test_body_cannot_jump_while_airborne - test_body_jump_custom_velocity - Edge falling tests use kinematic bodies with gravity enabled Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_physics/src/world.rs | 362 +++++++++++------------------ 1 file changed, 137 insertions(+), 225 deletions(-) diff --git a/crates/rust4d_physics/src/world.rs b/crates/rust4d_physics/src/world.rs index 19eca83..27c21bb 100644 --- a/crates/rust4d_physics/src/world.rs +++ b/crates/rust4d_physics/src/world.rs @@ -13,8 +13,6 @@ use serde::{Serialize, Deserialize}; pub struct PhysicsConfig { /// Gravity acceleration (applied to Y-axis, negative = down) pub gravity: f32, - /// Jump velocity for player - pub jump_velocity: f32, /// Fixed timestep for physics simulation (default: 1/60s) #[serde(default = "PhysicsConfig::default_fixed_dt")] pub fixed_dt: f32, @@ -24,7 +22,6 @@ impl Default for PhysicsConfig { fn default() -> Self { Self { gravity: -20.0, - jump_velocity: 8.0, fixed_dt: Self::default_fixed_dt(), } } @@ -41,17 +38,10 @@ impl PhysicsConfig { pub fn new(gravity: f32) -> Self { Self { gravity, - jump_velocity: 8.0, fixed_dt: Self::default_fixed_dt(), } } - /// Create a physics config with both gravity and jump velocity - pub fn with_jump_velocity(mut self, jump_velocity: f32) -> Self { - self.jump_velocity = jump_velocity; - self - } - /// Set the fixed timestep pub fn with_fixed_dt(mut self, fixed_dt: f32) -> Self { self.fixed_dt = fixed_dt; @@ -67,10 +57,6 @@ 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, /// Fixed timestep duration fixed_dt: f32, /// Accumulator for fixed timestep @@ -85,14 +71,11 @@ impl PhysicsWorld { /// Create a new physics world with custom configuration pub fn with_config(config: PhysicsConfig) -> Self { - let jump_velocity = config.jump_velocity; let fixed_dt = config.fixed_dt; Self { bodies: SlotMap::with_key(), static_colliders: Vec::new(), config, - player_body: None, - player_jump_velocity: jump_velocity, fixed_dt, accumulator: 0.0, } @@ -138,68 +121,34 @@ impl PhysicsWorld { self.bodies.keys() } - // ====== Player Body Management ====== + // ====== Generic Body Methods ====== - /// 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); + /// Check if a specific body is grounded + pub fn body_is_grounded(&self, key: BodyKey) -> bool { + self.bodies.get(key).map(|b| b.grounded).unwrap_or(false) } - /// Set the jump velocity for the player - pub fn set_player_jump_velocity(&mut self, velocity: f32) { - self.player_jump_velocity = velocity; + /// Get a body's position + pub fn body_position(&self, key: BodyKey) -> Option { + self.bodies.get(key).map(|b| b.position) } - /// 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 + /// Apply horizontal movement to a body (sets XZW velocity, preserves Y) + pub fn apply_body_movement(&mut self, key: BodyKey, movement: Vec4) { + if let Some(body) = self.bodies.get_mut(key) { body.velocity.x = movement.x; body.velocity.z = movement.z; body.velocity.w = movement.w; } } - /// Attempt to make the player jump + /// Attempt to make a body jump (set Y velocity if grounded) /// - /// 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() { + /// Returns true if jump was successful. + pub fn body_jump(&mut self, key: BodyKey, jump_velocity: f32) -> bool { + if let Some(body) = self.bodies.get_mut(key) { if body.grounded { - body.velocity.y = jump_vel; + body.velocity.y = jump_velocity; body.grounded = false; return true; } @@ -237,24 +186,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) { + // Reset grounded state for all non-static bodies before collision detection + for (_, body) in &mut self.bodies { + if !body.is_static() { body.grounded = false; } } // Phase 1: Apply gravity and integrate velocity - for (key, body) in &mut self.bodies { + for (_key, body) in &mut self.bodies { if body.is_static() { continue; } - // Apply gravity to: - // - Dynamic bodies (normal physics objects) - // - The player body (kinematic but needs gravity for jumping/falling) - let is_player = self.player_body == Some(key); - if body.affected_by_gravity() || is_player { + // Apply gravity to any body with gravity enabled + if body.gravity_enabled { body.velocity.y += self.config.gravity * dt; } @@ -329,24 +275,21 @@ impl PhysicsWorld { // 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 { + for (_key, body) in &mut self.bodies { if body.is_static() { continue; } - // Check if this is the player body - used for edge falling detection - let is_player = self.player_body == Some(key); - for static_col in &self.static_colliders { // Check if collision layers allow this interaction if !body.filter.collides_with(&static_col.filter) { continue; } - // Edge falling detection: if the player has walked off a bounded floor - // (their XZW position is outside the floor's bounds), skip collision + // Edge falling detection: if a kinematic body has walked off a bounded + // floor (their XZW position is outside the floor's bounds), skip collision // with that floor's edges. This ensures clean falling into the void. - if is_player { + if body.is_kinematic() { if let Collider::AABB(_) = &static_col.collider { if !static_col.is_position_over(body.position) { continue; @@ -919,56 +862,36 @@ mod tests { "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()); - } + // ====== Generic Body Method Tests ====== #[test] - fn test_player_position() { + fn test_body_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) + let body = 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); + let key = world.add_body(body); - assert_eq!(world.player_position(), Some(start_pos)); + assert_eq!(world.body_position(key), Some(start_pos)); } #[test] - fn test_player_movement() { + fn test_body_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) + let body = 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); + let key = world.add_body(body); // Apply horizontal movement - world.apply_player_movement(Vec4::new(10.0, 0.0, 5.0, 2.0)); + world.apply_body_movement(key, 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(); + // Check body moved in XZW but Y was preserved + let pos = world.body_position(key).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 @@ -976,88 +899,85 @@ mod tests { } #[test] - fn test_player_grounded_detection() { + fn test_body_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); + // Body just above floor (radius 0.5, position at y=0.4 means penetrating) + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic) + .with_gravity(true); // Enable gravity for kinematic body + let key = world.add_body(body); - // Initially not grounded - assert!(!world.player_is_grounded()); + // Initially not grounded (grounded resets each step) + assert!(!world.body_is_grounded(key)); // Step to detect floor collision world.step(0.016); // Should be grounded after collision detection - assert!(world.player_is_grounded()); + assert!(world.body_is_grounded(key)); } #[test] - fn test_player_jump() { + fn test_body_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); + // Body on floor with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) + .with_body_type(crate::body::BodyType::Kinematic) + .with_gravity(true); + let key = world.add_body(body); // Step to get grounded world.step(0.016); - assert!(world.player_is_grounded()); + assert!(world.body_is_grounded(key)); - // Jump - let jumped = world.player_jump(); + // Jump with custom velocity + let jumped = world.body_jump(key, 8.0); assert!(jumped); - assert!(!world.player_is_grounded()); + assert!(!world.body_is_grounded(key)); - // Check velocity set (default jump_velocity is 8.0) - let vel = world.player().unwrap().velocity; + // Check velocity set + let vel = world.get_body(key).unwrap().velocity; assert_eq!(vel.y, 8.0); } #[test] - fn test_player_cannot_jump_while_airborne() { + fn test_body_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) + // Body in the air + let body = 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); + let key = world.add_body(body); // Not grounded initially - assert!(!world.player_is_grounded()); + assert!(!world.body_is_grounded(key)); // Jump should fail - let jumped = world.player_jump(); + let jumped = world.body_jump(key, 8.0); assert!(!jumped); // Velocity should still be zero - let vel = world.player().unwrap().velocity; + let vel = world.get_body(key).unwrap().velocity; assert_eq!(vel.y, 0.0); } #[test] - fn test_player_jump_velocity_config() { + fn test_body_jump_custom_velocity() { 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) + // Body that's grounded + let mut body = 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); + body.grounded = true; // Manually set grounded for test + let key = world.add_body(body); - // Jump - world.player_jump(); + // Jump with custom velocity + world.body_jump(key, 15.0); // Check custom velocity used - let vel = world.player().unwrap().velocity; + let vel = world.get_body(key).unwrap().velocity; assert_eq!(vel.y, 15.0); } @@ -1285,7 +1205,7 @@ mod tests { // ====== Edge Falling Tests ====== #[test] - fn test_player_falls_when_walking_off_w_edge() { + fn test_kinematic_falls_when_walking_off_w_edge() { use crate::body::BodyType; // Create world with bounded floor (W bounds: -2 to +2) @@ -1298,49 +1218,48 @@ mod tests { PhysicsMaterial::CONCRETE, )); - // Player starts on the floor at W=0 - let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 0.0), 0.5) - .with_body_type(BodyType::Kinematic); - let player_key = world.add_body(player); - world.set_player_body(player_key); + // Kinematic body starts on the floor at W=0 with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true); + let key = world.add_body(body); - // Move player past the W edge (W > 2.5 is completely off floor) + // Move body past the W edge (W > 2.5 is completely off floor) { - let body = world.get_body_mut(player_key).unwrap(); + let body = world.get_body_mut(key).unwrap(); body.position.w = 3.0; body.collider = body.collider.translated(Vec4::new(0.0, 0.0, 0.0, 3.0)); } - // Simulate for a short time - player should fall without oscillating - let initial_y = world.player_position().unwrap().y; + // Simulate for a short time - body should fall without oscillating + let initial_y = world.body_position(key).unwrap().y; for _ in 0..20 { world.step(0.016); } - let final_pos = world.player_position().unwrap(); + let final_pos = world.body_position(key).unwrap(); - // Player should have fallen significantly (not hovering at edge) - // With gravity=-20, after 0.32s, delta_y ≈ 0.5*20*0.32^2 ≈ 1.0 units + // Body should have fallen significantly (not hovering at edge) assert!( final_pos.y < initial_y - 0.5, - "Player should fall when off W edge. Started at y={}, ended at y={}", + "Body should fall when off W edge. Started at y={}, ended at y={}", initial_y, final_pos.y ); - // Player should not be grounded + // Body should not be grounded assert!( - !world.player_is_grounded(), - "Player off floor should not be grounded" + !world.body_is_grounded(key), + "Body off floor should not be grounded" ); } #[test] - fn test_player_no_oscillation_at_w_edge() { + fn test_kinematic_no_oscillation_at_w_edge() { use crate::body::BodyType; // This test verifies the edge oscillation bug is fixed: - // When player is at the W edge boundary, they should either: + // When a kinematic body is at the W edge boundary, they should either: // 1. Fall cleanly through (if off the floor) // 2. Land on the floor (if they return to being over the floor) // They should NOT oscillate at the edge. @@ -1354,12 +1273,12 @@ mod tests { PhysicsMaterial::CONCRETE, )); - // Player starts just at the W edge, trying to oscillate + // Body starts just at the W edge, trying to oscillate // Position W=2.3 is just outside the floor bounds (W: -2 to +2) - let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 2.3), 0.5) - .with_body_type(BodyType::Kinematic); - let player_key = world.add_body(player); - world.set_player_body(player_key); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 2.3), 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true); + let key = world.add_body(body); // Track Y positions over time to verify no oscillation let mut y_positions = Vec::new(); @@ -1370,37 +1289,32 @@ mod tests { // Alternate W velocity to simulate oscillation attempt let w_vel = if i % 4 < 2 { -3.0 } else { 3.0 }; - world.apply_player_movement(Vec4::new(0.0, 0.0, 0.0, w_vel)); + world.apply_body_movement(key, Vec4::new(0.0, 0.0, 0.0, w_vel)); - let pos = world.player_position().unwrap(); + let pos = world.body_position(key).unwrap(); y_positions.push(pos.y); last_w = pos.w; } - // Key assertion: player should not hover at original Y height - // If oscillating, Y would stay near 0.5 due to edge collisions pushing back + // Key assertion: body should not hover at original Y height let final_y = y_positions.last().unwrap(); - // Either the player fell significantly (if they stayed off the floor) - // or they landed on the floor (if they returned to being over it) - // But they should NOT be hovering at the original height while moving let is_on_floor = *final_y > 0.4 && *final_y < 0.6 && last_w.abs() < 2.0; let has_fallen = *final_y < 0.0; assert!( is_on_floor || has_fallen, - "Player should either land on floor or fall, not oscillate. Final y={}, w={}", + "Body should either land on floor or fall, not oscillate. Final y={}, w={}", final_y, last_w ); } #[test] - fn test_player_falls_into_void_when_far_off_edge() { + fn test_kinematic_falls_into_void_when_far_off_edge() { use crate::body::BodyType; - // When player is far off the edge and can't return before falling, - // they should fall into the void. + // When a kinematic body is far off the edge, it should fall into the void. let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( @@ -1411,33 +1325,31 @@ mod tests { PhysicsMaterial::CONCRETE, )); - // Player starts far off the W edge - let player = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 5.0), 0.5) + // Body starts far off the W edge with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 5.0), 0.5) .with_body_type(BodyType::Kinematic) + .with_gravity(true) .with_velocity(Vec4::new(0.0, 0.0, 0.0, -1.0)); // Slowly moving back - let player_key = world.add_body(player); - world.set_player_body(player_key); + let key = world.add_body(body); - // Simulate for longer - player should fall into void + // Simulate for longer - body should fall into void for _ in 0..60 { world.step(0.016); - world.apply_player_movement(Vec4::new(0.0, 0.0, 0.0, -1.0)); + world.apply_body_movement(key, Vec4::new(0.0, 0.0, 0.0, -1.0)); } - let final_pos = world.player_position().unwrap(); + let final_pos = world.body_position(key).unwrap(); - // Player should have fallen past the floor's bottom (y=-5) - // After 0.96s with gravity=-20: distance = 0.5*20*0.96^2 ≈ 9.2 units - // Starting at y=0.5, final y ≈ 0.5 - 9.2 = -8.7 + // Body should have fallen past the floor's bottom (y=-5) assert!( final_pos.y < -5.0, - "Player should fall into void when far off edge. Final y={}", + "Body should fall into void when far off edge. Final y={}", final_pos.y ); } #[test] - fn test_player_jumping_over_floor_still_works() { + fn test_kinematic_jumping_over_floor_still_works() { use crate::body::BodyType; // Make sure the edge falling fix doesn't break normal jumping @@ -1445,49 +1357,49 @@ mod tests { world.add_static_collider(StaticCollider::floor_bounded( 0.0, // floor at y=0 10.0, // half_size_xz - 10.0, // half_size_w (large, player stays over floor) + 10.0, // half_size_w (large, body stays over floor) 5.0, // thickness 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(BodyType::Kinematic); - let player_key = world.add_body(player); - world.set_player_body(player_key); + // Kinematic body on floor with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.4, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true); + let key = world.add_body(body); // Step to get grounded world.step(0.016); - assert!(world.player_is_grounded(), "Player should start grounded"); + assert!(world.body_is_grounded(key), "Body should start grounded"); // Jump - world.player_jump(); - assert!(!world.player_is_grounded(), "Player should be airborne after jump"); + world.body_jump(key, 8.0); + assert!(!world.body_is_grounded(key), "Body should be airborne after jump"); - // Let physics run - player should go up then land back on floor + // Let physics run - body should go up then land back on floor for _ in 0..100 { world.step(0.016); } // Should land and be grounded again assert!( - world.player_is_grounded(), - "Player should land back on floor after jump" + world.body_is_grounded(key), + "Body should land back on floor after jump" ); - let final_y = world.player_position().unwrap().y; + let final_y = world.body_position(key).unwrap().y; assert!( final_y > 0.0 && final_y < 1.0, - "Player should be on floor surface. Final y={}", + "Body should be on floor surface. Final y={}", final_y ); } #[test] - fn test_player_on_floor_center_stays_grounded() { + fn test_kinematic_on_floor_center_stays_grounded() { use crate::body::BodyType; - // Player in center of floor should work normally + // Kinematic body in center of floor should work normally let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); world.add_static_collider(StaticCollider::floor_bounded( 0.0, // floor at y=0 @@ -1497,24 +1409,24 @@ mod tests { PhysicsMaterial::CONCRETE, )); - // Player above floor center - let player = RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) - .with_body_type(BodyType::Kinematic); - let player_key = world.add_body(player); - world.set_player_body(player_key); + // Kinematic body above floor center with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true); + let key = world.add_body(body); - // Simulate until player lands + // Simulate until body lands for _ in 0..50 { world.step(0.016); } - // Player should be grounded on floor - assert!(world.player_is_grounded(), "Player should be grounded on floor center"); + // Body should be grounded on floor + assert!(world.body_is_grounded(key), "Body should be grounded on floor center"); - let final_y = world.player_position().unwrap().y; + let final_y = world.body_position(key).unwrap().y; assert!( (final_y - 0.5).abs() < 0.1, - "Player should rest at y=0.5 (radius above floor). Final y={}", + "Body should rest at y=0.5 (radius above floor). Final y={}", final_y ); } -- knotmirror 0.0.1 From b590e4b00082fe65804b05edd0beb743027a44d6 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:06:05 +0000 Subject: [PATCH 03/16] Port consumers to generic physics body API Update all code that used the removed player-specific physics methods to use the new generic body API: - ActiveScene: store player_body_key, use with_gravity(true) for player - SimulationSystem: use body_position/apply_body_movement/body_jump with player key from ActiveScene - PhysicsConfigToml: keep jump_velocity for game logic but don't pass to PhysicsConfig (which no longer has it) - Integration tests: use generic API for edge-falling tests Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/scene.rs | 14 ++++-- .../rust4d_core/tests/physics_integration.rs | 38 +++++++------- src/config.rs | 12 +++-- src/systems/simulation.rs | 50 +++++++++++-------- 4 files changed, 66 insertions(+), 48 deletions(-) diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 5e471ff..79a4f0b 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -216,6 +216,8 @@ pub struct ActiveScene { pub player_spawn: Option<[f32; 4]>, /// The live world with entities and physics pub world: World, + /// The player's physics body key (if spawned) + pub player_body_key: Option, } impl ActiveScene { @@ -296,21 +298,24 @@ impl ActiveScene { } // Create player body from player_spawn - if let (Some(spawn), Some(physics)) = (template.player_spawn, world.physics_mut()) { + let player_body_key = if let (Some(spawn), Some(physics)) = (template.player_spawn, world.physics_mut()) { let position = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); let player_body = RigidBody4D::new_sphere(position, player_radius) .with_body_type(BodyType::Kinematic) + .with_gravity(true) // Kinematic player body needs gravity for jumping/falling .with_mass(1.0) .with_material(PhysicsMaterial::WOOD); - let body_key = physics.add_body(player_body); - physics.set_player_body(body_key); - } + Some(physics.add_body(player_body)) + } else { + None + }; Self { name: template.name.clone(), player_spawn: template.player_spawn, world, + player_body_key, } } @@ -320,6 +325,7 @@ impl ActiveScene { name: name.into(), player_spawn: None, world: World::new(), + player_body_key: None, } } diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 38490c9..50a02fb 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -373,7 +373,7 @@ fn test_load_default_scene_file() { /// Test that walking off the W edge causes falling (true 4D physics) #[test] -fn test_player_falls_off_w_edge() { +fn test_kinematic_body_falls_off_w_edge() { let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); // Add bounded floor: W extends from -5 to +5 @@ -381,42 +381,42 @@ fn test_player_falls_off_w_edge() { -2.0, 10.0, 5.0, 5.0, PhysicsMaterial::CONCRETE, )); - // Add player sphere at center, resting on floor - let player = RigidBody4D::new_sphere(Vec4::new(0.0, -1.5, 0.0, 0.0), 0.5) - .with_body_type(BodyType::Kinematic); - let player_key = physics.add_body(player); - physics.set_player_body(player_key); + // Add kinematic sphere at center, resting on floor, with gravity enabled + let body = RigidBody4D::new_sphere(Vec4::new(0.0, -1.5, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true); + let key = physics.add_body(body); // Step physics to settle for _ in 0..10 { physics.step(1.0 / 60.0); } - // Player should be grounded - assert!(physics.player_is_grounded(), "Player should be grounded at center"); - let start_y = physics.player_position().unwrap().y; + // Body should be grounded + assert!(physics.body_is_grounded(key), "Body should be grounded at center"); + let start_y = physics.body_position(key).unwrap().y; - // Move player to W=6 (outside floor's W bounds of -5 to +5) + // Move body to W=6 (outside floor's W bounds of -5 to +5) for _ in 0..60 { - physics.apply_player_movement(Vec4::new(0.0, 0.0, 0.0, 10.0)); + physics.apply_body_movement(key, Vec4::new(0.0, 0.0, 0.0, 10.0)); physics.step(1.0 / 60.0); } - let pos = physics.player_position().unwrap(); - assert!(pos.w > 5.0, "Player should have moved off W edge. W={}", pos.w); + let pos = physics.body_position(key).unwrap(); + assert!(pos.w > 5.0, "Body should have moved off W edge. W={}", pos.w); - assert!(!physics.player_is_grounded(), - "Player should NOT be grounded when off W edge. W={}", pos.w); + assert!(!physics.body_is_grounded(key), + "Body should NOT be grounded when off W edge. W={}", pos.w); - // Continue stepping - player should fall + // Continue stepping - body should fall for _ in 0..60 { - physics.apply_player_movement(Vec4::ZERO); + physics.apply_body_movement(key, Vec4::ZERO); physics.step(1.0 / 60.0); } - let final_pos = physics.player_position().unwrap(); + let final_pos = physics.body_position(key).unwrap(); assert!(final_pos.y < start_y, - "Player should fall when off W edge. Start Y={}, Final Y={}", start_y, final_pos.y); + "Body should fall when off W edge. Start Y={}, Final Y={}", start_y, final_pos.y); } /// Print detailed state for debugging diff --git a/src/config.rs b/src/config.rs index 1f6dec4..e7c5aef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -167,8 +167,8 @@ impl Default for InputConfig { /// Physics configuration from TOML /// -/// This wraps the core PhysicsConfig. The `gravity` and `jump_velocity` fields -/// are passed to the physics engine. +/// This wraps the core PhysicsConfig. The `gravity` field is passed to the +/// physics engine. Jump velocity is now per-entity (set by game logic). /// /// Note: `player_radius` is in `[scene]` section. Floor positions are defined /// per-scene in .ron files via Hyperplane entities. @@ -176,7 +176,8 @@ impl Default for InputConfig { pub struct PhysicsConfigToml { /// Gravity (negative = downward) pub gravity: f32, - /// Jump velocity + /// Jump velocity (used by game logic, not physics engine) + #[serde(default = "PhysicsConfigToml::default_jump_velocity")] pub jump_velocity: f32, } @@ -190,10 +191,13 @@ impl Default for PhysicsConfigToml { } impl PhysicsConfigToml { + fn default_jump_velocity() -> f32 { + 8.0 + } + /// Convert to the physics engine's PhysicsConfig pub fn to_physics_config(&self) -> PhysicsConfig { PhysicsConfig::new(self.gravity) - .with_jump_velocity(self.jump_velocity) } } diff --git a/src/systems/simulation.rs b/src/systems/simulation.rs index 90315ad..ed60ea3 100644 --- a/src/systems/simulation.rs +++ b/src/systems/simulation.rs @@ -87,53 +87,61 @@ impl SimulationSystem { move_dir }; - // 4. Apply movement to player via physics + // 4. Get player body key from active scene + let player_key = scene_manager + .active_scene() + .and_then(|s| s.player_body_key); + + // 5. Apply movement to player via physics let move_speed = controller.move_speed; - if let Some(physics) = scene_manager + if let (Some(key), Some(physics)) = (player_key, scene_manager .active_world_mut() - .and_then(|w| w.physics_mut()) + .and_then(|w| w.physics_mut())) { - physics.apply_player_movement(move_dir * move_speed); + physics.apply_body_movement(key, move_dir * move_speed); } - // 5. Handle jump + // 6. Handle jump if controller.consume_jump() { - if let Some(physics) = scene_manager + if let (Some(key), Some(physics)) = (player_key, scene_manager .active_world_mut() - .and_then(|w| w.physics_mut()) + .and_then(|w| w.physics_mut())) { - physics.player_jump(); + // TODO: jump_velocity should come from game config, not hardcoded + physics.body_jump(key, 8.0); } } - // 6. Step world physics + // 7. Step world physics scene_manager.update(dt); - // 7. Check for dirty entities + // 8. Check for dirty entities let geometry_dirty = scene_manager .active_world() .map(|w| w.has_dirty_entities()) .unwrap_or(false); - // 8. Sync camera position to player physics (all 4 dimensions) - if let Some(pos) = scene_manager + // 9. Sync camera position to player physics (all 4 dimensions) + if let (Some(key), Some(physics)) = (player_key, scene_manager .active_world() - .and_then(|w| w.physics()) - .and_then(|p| p.player_position()) + .and_then(|w| w.physics())) { - camera.position = pos; + if let Some(pos) = physics.body_position(key) { + camera.position = pos; + } } - // 9. Apply mouse look for camera rotation + // 10. Apply mouse look for camera rotation controller.update(camera, dt, cursor_captured); - // 10. Re-sync position after controller (discard its movement, keep rotation) - if let Some(pos) = scene_manager + // 11. Re-sync position after controller (discard its movement, keep rotation) + if let (Some(key), Some(physics)) = (player_key, scene_manager .active_world() - .and_then(|w| w.physics()) - .and_then(|p| p.player_position()) + .and_then(|w| w.physics())) { - camera.position = pos; + if let Some(pos) = physics.body_position(key) { + camera.position = pos; + } } SimulationResult { geometry_dirty } -- knotmirror 0.0.1 From 747d332dfee15f2c1cefe1d291f0d30273e72eaa Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:12:12 +0000 Subject: [PATCH 04/16] Create rust4d_game crate skeleton with workspace integration Add new crate to workspace members, workspace dependencies, and root binary dependencies. Set up lib.rs with module declarations and re-exports. Co-Authored-By: Claude Opus 4.5 --- Cargo.toml | 3 +++ crates/rust4d_game/Cargo.toml | 10 ++++++++++ crates/rust4d_game/src/lib.rs | 17 +++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 crates/rust4d_game/Cargo.toml create mode 100644 crates/rust4d_game/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index a9ab9b6..0af8357 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/rust4d_render", "crates/rust4d_input", "crates/rust4d_physics", + "crates/rust4d_game", ] [workspace.dependencies] @@ -27,6 +28,7 @@ rust4d_core = { path = "crates/rust4d_core" } rust4d_render = { path = "crates/rust4d_render" } rust4d_input = { path = "crates/rust4d_input" } rust4d_physics = { path = "crates/rust4d_physics" } +rust4d_game = { path = "crates/rust4d_game" } [package] name = "rust4d" @@ -47,6 +49,7 @@ rust4d_core.workspace = true rust4d_render.workspace = true rust4d_input.workspace = true rust4d_physics.workspace = true +rust4d_game.workspace = true wgpu.workspace = true winit.workspace = true pollster.workspace = true diff --git a/crates/rust4d_game/Cargo.toml b/crates/rust4d_game/Cargo.toml new file mode 100644 index 0000000..16716d4 --- /dev/null +++ b/crates/rust4d_game/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust4d_game" +version = "0.1.0" +edition = "2021" + +[dependencies] +rust4d_math.workspace = true +rust4d_physics.workspace = true +rust4d_core.workspace = true +log.workspace = true diff --git a/crates/rust4d_game/src/lib.rs b/crates/rust4d_game/src/lib.rs new file mode 100644 index 0000000..20777cc --- /dev/null +++ b/crates/rust4d_game/src/lib.rs @@ -0,0 +1,17 @@ +//! Game logic layer for Rust4D +//! +//! This crate bridges the engine and game-specific code, providing: +//! +//! - [`CharacterController4D`] - First-person character controller for 4D space +//! - [`EventBus`] - Typed event bus for game events +//! - [`StateMachine`] - Generic finite state machine +//! - Scene helpers for physics setup from entity tags + +pub mod character_controller; +pub mod events; +pub mod fsm; +pub mod scene_helpers; + +pub use character_controller::{CharacterController4D, CharacterConfig}; +pub use events::EventBus; +pub use fsm::StateMachine; -- knotmirror 0.0.1 From f1581205852e45d77400c3bf9f07d2c7e4a8923f Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:12:21 +0000 Subject: [PATCH 05/16] Add CharacterController4D with tests First-person character controller wrapping a physics BodyKey. Provides high-level movement (apply_movement with speed multiplier), jumping (only when grounded), position queries, and grounded checks. Includes CharacterConfig for move_speed and jump_velocity. 8 tests covering construction, movement delegation, jump logic, grounded detection, and position queries. Co-Authored-By: Claude Opus 4.5 --- .../rust4d_game/src/character_controller.rs | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 crates/rust4d_game/src/character_controller.rs diff --git a/crates/rust4d_game/src/character_controller.rs b/crates/rust4d_game/src/character_controller.rs new file mode 100644 index 0000000..5d77183 --- /dev/null +++ b/crates/rust4d_game/src/character_controller.rs @@ -0,0 +1,225 @@ +//! Character controller for 4D first-person movement +//! +//! Wraps a physics body key and provides high-level movement operations. +//! Does NOT own the PhysicsWorld -- takes `&PhysicsWorld` or `&mut PhysicsWorld` +//! as method parameters. + +use rust4d_math::Vec4; +use rust4d_physics::{BodyKey, PhysicsWorld}; + +/// Configuration for a character controller +#[derive(Clone, Debug)] +pub struct CharacterConfig { + /// Movement speed multiplier + pub move_speed: f32, + /// Jump velocity (upward Y velocity when jumping) + pub jump_velocity: f32, +} + +impl Default for CharacterConfig { + fn default() -> Self { + Self { + move_speed: 3.0, + jump_velocity: 8.0, + } + } +} + +/// First-person character controller for 4D space +/// +/// Wraps a physics body key and provides high-level movement operations. +/// Does NOT own the PhysicsWorld -- takes `&mut PhysicsWorld` as parameters. +pub struct CharacterController4D { + /// The physics body this controller manages + body_key: BodyKey, + /// Movement configuration + pub config: CharacterConfig, +} + +impl CharacterController4D { + /// Create a new character controller for the given body key + pub fn new(body_key: BodyKey, config: CharacterConfig) -> Self { + Self { body_key, config } + } + + /// Get the body key this controller manages + pub fn body_key(&self) -> BodyKey { + self.body_key + } + + /// Apply horizontal movement (XZW plane, preserves Y for gravity/jumping) + /// + /// The movement vector is scaled by `config.move_speed` before being applied. + pub fn apply_movement(&self, physics: &mut PhysicsWorld, movement: Vec4) { + physics.apply_body_movement(self.body_key, movement * self.config.move_speed); + } + + /// Attempt to jump. Returns true if successful (only works when grounded). + pub fn jump(&self, physics: &mut PhysicsWorld) -> bool { + physics.body_jump(self.body_key, self.config.jump_velocity) + } + + /// Check if the character is grounded + pub fn is_grounded(&self, physics: &PhysicsWorld) -> bool { + physics.body_is_grounded(self.body_key) + } + + /// Get the character's current position + pub fn position(&self, physics: &PhysicsWorld) -> Option { + physics.body_position(self.body_key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust4d_physics::{PhysicsConfig, RigidBody4D, BodyType, PhysicsMaterial, StaticCollider}; + + /// Create a test physics world with a floor at y=0 + fn test_world_with_floor() -> PhysicsWorld { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + physics.add_static_collider(StaticCollider::floor(0.0, PhysicsMaterial::CONCRETE)); + physics + } + + /// Create a kinematic player body (typical character setup) + fn create_player_body(physics: &mut PhysicsWorld, position: Vec4) -> BodyKey { + let body = RigidBody4D::new_sphere(position, 0.5) + .with_body_type(BodyType::Kinematic) + .with_gravity(true) + .with_mass(1.0) + .with_material(PhysicsMaterial::WOOD); + physics.add_body(body) + } + + #[test] + fn test_construction_and_config() { + let mut physics = PhysicsWorld::new(); + let body_key = create_player_body(&mut physics, Vec4::new(0.0, 1.0, 0.0, 0.0)); + + let config = CharacterConfig { + move_speed: 5.0, + jump_velocity: 10.0, + }; + let controller = CharacterController4D::new(body_key, config.clone()); + + assert_eq!(controller.body_key(), body_key); + assert_eq!(controller.config.move_speed, 5.0); + assert_eq!(controller.config.jump_velocity, 10.0); + } + + #[test] + fn test_default_config() { + let config = CharacterConfig::default(); + assert_eq!(config.move_speed, 3.0); + assert_eq!(config.jump_velocity, 8.0); + } + + #[test] + fn test_apply_movement_delegates_to_physics() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // No gravity + let body_key = physics.add_body( + RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic), + ); + + let config = CharacterConfig { + move_speed: 2.0, + jump_velocity: 8.0, + }; + let controller = CharacterController4D::new(body_key, config); + + // Apply movement (1, 0, 1, 0) with speed 2.0 => physics gets (2, 0, 2, 0) + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + + // Step physics and check position changed + physics.step(1.0); + let pos = physics.body_position(body_key).unwrap(); + assert!((pos.x - 2.0).abs() < 0.01, "Expected x=2.0, got {}", pos.x); + assert!((pos.z - 2.0).abs() < 0.01, "Expected z=2.0, got {}", pos.z); + } + + #[test] + fn test_jump_only_when_grounded() { + let mut physics = test_world_with_floor(); + // Place body slightly penetrating floor so it gets grounded + let body_key = create_player_body(&mut physics, Vec4::new(0.0, 0.4, 0.0, 0.0)); + + let controller = CharacterController4D::new(body_key, CharacterConfig::default()); + + // Step to get grounded + physics.step(0.016); + assert!(controller.is_grounded(&physics), "Should be grounded after step near floor"); + + // Jump should succeed + let jumped = controller.jump(&mut physics); + assert!(jumped, "Jump should succeed when grounded"); + assert!(!controller.is_grounded(&physics), "Should not be grounded after jump"); + } + + #[test] + fn test_jump_fails_when_airborne() { + let mut physics = PhysicsWorld::new(); + // Body high in the air (no floor) + let body_key = create_player_body(&mut physics, Vec4::new(0.0, 10.0, 0.0, 0.0)); + + let controller = CharacterController4D::new(body_key, CharacterConfig::default()); + + assert!(!controller.is_grounded(&physics), "Should not be grounded in air"); + + let jumped = controller.jump(&mut physics); + assert!(!jumped, "Jump should fail when airborne"); + } + + #[test] + fn test_is_grounded_returns_correct_state() { + let mut physics = test_world_with_floor(); + let body_key = create_player_body(&mut physics, Vec4::new(0.0, 0.4, 0.0, 0.0)); + + let controller = CharacterController4D::new(body_key, CharacterConfig::default()); + + // Before step: not grounded (grounded resets each step) + assert!(!controller.is_grounded(&physics)); + + // After step near floor: grounded + physics.step(0.016); + assert!(controller.is_grounded(&physics)); + } + + #[test] + fn test_position_returns_body_position() { + let mut physics = PhysicsWorld::new(); + let start_pos = Vec4::new(5.0, 2.0, 3.0, 1.0); + let body_key = create_player_body(&mut physics, start_pos); + + let controller = CharacterController4D::new(body_key, CharacterConfig::default()); + + let pos = controller.position(&physics); + assert!(pos.is_some()); + let pos = pos.unwrap(); + assert_eq!(pos.x, start_pos.x); + assert_eq!(pos.y, start_pos.y); + assert_eq!(pos.z, start_pos.z); + assert_eq!(pos.w, start_pos.w); + } + + #[test] + fn test_jump_uses_config_velocity() { + let mut physics = PhysicsWorld::new(); + let mut body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.5, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic); + body.grounded = true; // Manually set grounded for test + let body_key = physics.add_body(body); + + let config = CharacterConfig { + move_speed: 3.0, + jump_velocity: 12.0, + }; + let controller = CharacterController4D::new(body_key, config); + + controller.jump(&mut physics); + + let vel = physics.get_body(body_key).unwrap().velocity; + assert_eq!(vel.y, 12.0, "Jump should use config.jump_velocity"); + } +} -- knotmirror 0.0.1 From af80dd4dafeded927db8a0f78d832d369f41167a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:12:29 +0000 Subject: [PATCH 06/16] Add EventBus with tests Typed event bus using std::any for type-safe event dispatch. Events are queued with emit() and dispatched to registered handlers with dispatch(). Supports multiple handlers per type and keeps different event types separate. 7 tests covering emit/dispatch, multiple handlers, type isolation, clear, dispatch without handlers, and queue clearing. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_game/src/events.rs | 233 +++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 crates/rust4d_game/src/events.rs diff --git a/crates/rust4d_game/src/events.rs b/crates/rust4d_game/src/events.rs new file mode 100644 index 0000000..94ab329 --- /dev/null +++ b/crates/rust4d_game/src/events.rs @@ -0,0 +1,233 @@ +//! Typed event bus for game events +//! +//! A simple event bus that supports typed events with multiple handlers. +//! Events are queued with `emit()` and dispatched to handlers with `dispatch()`. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; + +/// A typed event bus for game events +/// +/// Events are queued with [`emit`](EventBus::emit) and dispatched to registered +/// handlers with [`dispatch`](EventBus::dispatch). Each event type can have +/// multiple handlers, and events of different types are kept separate. +pub struct EventBus { + /// Handlers indexed by event TypeId + handlers: HashMap>>, + /// Queued events waiting to be dispatched + queued: Vec<(TypeId, Box)>, +} + +impl EventBus { + /// Create a new empty event bus + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + queued: Vec::new(), + } + } + + /// Register a handler for events of type T + /// + /// The handler will be called for each event of type T when `dispatch()` is called. + /// Multiple handlers can be registered for the same event type. + pub fn on(&mut self, handler: impl Fn(&T) + 'static) { + let type_id = TypeId::of::(); + let wrapper: Box = Box::new(move |any| { + if let Some(event) = any.downcast_ref::() { + handler(event); + } + }); + self.handlers.entry(type_id).or_default().push(wrapper); + } + + /// Queue an event to be dispatched + /// + /// The event is stored and will be delivered to handlers when `dispatch()` is called. + pub fn emit(&mut self, event: T) { + let type_id = TypeId::of::(); + self.queued.push((type_id, Box::new(event))); + } + + /// Dispatch all queued events to their handlers + /// + /// Events are delivered in the order they were emitted. After dispatching, + /// the event queue is cleared. + pub fn dispatch(&mut self) { + // Drain the queue to avoid borrow conflicts + let events: Vec<_> = self.queued.drain(..).collect(); + for (type_id, event) in &events { + if let Some(handlers) = self.handlers.get(type_id) { + for handler in handlers { + handler(event.as_ref()); + } + } + } + } + + /// Clear all queued events without dispatching + pub fn clear(&mut self) { + self.queued.clear(); + } + + /// Returns the number of queued events + pub fn queued_count(&self) -> usize { + self.queued.len() + } +} + +impl Default for EventBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + // Test event types + #[derive(Debug, Clone)] + struct DamageEvent { + amount: u32, + } + + #[derive(Debug, Clone)] + struct HealEvent { + amount: u32, + } + + #[test] + fn test_emit_and_dispatch_delivers_events() { + let mut bus = EventBus::new(); + let received = Rc::new(RefCell::new(Vec::new())); + + let received_clone = received.clone(); + bus.on::(move |event| { + received_clone.borrow_mut().push(event.amount); + }); + + bus.emit(DamageEvent { amount: 10 }); + bus.emit(DamageEvent { amount: 20 }); + + assert_eq!(bus.queued_count(), 2); + bus.dispatch(); + assert_eq!(bus.queued_count(), 0); + + let received = received.borrow(); + assert_eq!(received.len(), 2); + assert_eq!(received[0], 10); + assert_eq!(received[1], 20); + } + + #[test] + fn test_multiple_handlers_for_same_type() { + let mut bus = EventBus::new(); + let count1 = Rc::new(RefCell::new(0u32)); + let count2 = Rc::new(RefCell::new(0u32)); + + let c1 = count1.clone(); + bus.on::(move |event| { + *c1.borrow_mut() += event.amount; + }); + + let c2 = count2.clone(); + bus.on::(move |event| { + *c2.borrow_mut() += event.amount * 2; + }); + + bus.emit(DamageEvent { amount: 5 }); + bus.dispatch(); + + assert_eq!(*count1.borrow(), 5); + assert_eq!(*count2.borrow(), 10); + } + + #[test] + fn test_events_of_different_types_dont_cross() { + let mut bus = EventBus::new(); + let damage_received = Rc::new(RefCell::new(Vec::new())); + let heal_received = Rc::new(RefCell::new(Vec::new())); + + let dr = damage_received.clone(); + bus.on::(move |event| { + dr.borrow_mut().push(event.amount); + }); + + let hr = heal_received.clone(); + bus.on::(move |event| { + hr.borrow_mut().push(event.amount); + }); + + bus.emit(DamageEvent { amount: 10 }); + bus.emit(HealEvent { amount: 25 }); + bus.emit(DamageEvent { amount: 5 }); + bus.dispatch(); + + let damage = damage_received.borrow(); + assert_eq!(damage.len(), 2); + assert_eq!(damage[0], 10); + assert_eq!(damage[1], 5); + + let heal = heal_received.borrow(); + assert_eq!(heal.len(), 1); + assert_eq!(heal[0], 25); + } + + #[test] + fn test_clear_removes_queued_events() { + let mut bus = EventBus::new(); + let count = Rc::new(RefCell::new(0u32)); + + let c = count.clone(); + bus.on::(move |_| { + *c.borrow_mut() += 1; + }); + + bus.emit(DamageEvent { amount: 10 }); + bus.emit(DamageEvent { amount: 20 }); + assert_eq!(bus.queued_count(), 2); + + bus.clear(); + assert_eq!(bus.queued_count(), 0); + + bus.dispatch(); + assert_eq!(*count.borrow(), 0, "No events should have been dispatched after clear"); + } + + #[test] + fn test_dispatch_without_handlers() { + let mut bus = EventBus::new(); + + // Emitting without any handlers should not panic + bus.emit(DamageEvent { amount: 10 }); + bus.dispatch(); // Should be a no-op + } + + #[test] + fn test_dispatch_clears_queue() { + let mut bus = EventBus::new(); + let count = Rc::new(RefCell::new(0u32)); + + let c = count.clone(); + bus.on::(move |_| { + *c.borrow_mut() += 1; + }); + + bus.emit(DamageEvent { amount: 10 }); + bus.dispatch(); + assert_eq!(*count.borrow(), 1); + + // Dispatching again should not re-deliver + bus.dispatch(); + assert_eq!(*count.borrow(), 1); + } + + #[test] + fn test_default() { + let bus = EventBus::default(); + assert_eq!(bus.queued_count(), 0); + } +} -- knotmirror 0.0.1 From b60d2856fba6a2435cd672061885541d44a4bfb4 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:12:37 +0000 Subject: [PATCH 07/16] Add StateMachine with tests Generic finite state machine for tracking game states. Minimal design: tracks current state with transition() and is_in() methods. Works with any Eq + Hash + Clone + Debug type. Game-specific logic left to consumers. 7 tests covering initial state, transitions, same-state rejection, multiple transitions, and use with different state types. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_game/src/fsm.rs | 151 ++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 crates/rust4d_game/src/fsm.rs diff --git a/crates/rust4d_game/src/fsm.rs b/crates/rust4d_game/src/fsm.rs new file mode 100644 index 0000000..6499122 --- /dev/null +++ b/crates/rust4d_game/src/fsm.rs @@ -0,0 +1,151 @@ +//! Generic finite state machine +//! +//! A minimal state machine that tracks the current state and supports transitions. +//! Game-specific state logic (enter/exit callbacks, transition conditions) is left +//! to the consumer -- this just provides clean state tracking. + +use std::hash::Hash; + +/// A generic finite state machine +/// +/// Tracks the current state and provides transition methods. The state type `S` +/// must implement `Eq`, `Hash`, `Clone`, and `Debug`. +/// +/// # Example +/// +/// ``` +/// use rust4d_game::StateMachine; +/// +/// #[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// enum PlayerState { +/// Idle, +/// Walking, +/// Jumping, +/// Falling, +/// } +/// +/// let mut fsm = StateMachine::new(PlayerState::Idle); +/// assert!(fsm.is_in(&PlayerState::Idle)); +/// +/// // Transition to walking +/// let changed = fsm.transition(PlayerState::Walking); +/// assert!(changed); +/// assert!(fsm.is_in(&PlayerState::Walking)); +/// +/// // Transition to same state returns false +/// let changed = fsm.transition(PlayerState::Walking); +/// assert!(!changed); +/// ``` +pub struct StateMachine { + current: S, +} + +impl StateMachine { + /// Create a new state machine with the given initial state + pub fn new(initial: S) -> Self { + Self { current: initial } + } + + /// Get a reference to the current state + pub fn current(&self) -> &S { + &self.current + } + + /// Transition to a new state + /// + /// Returns `true` if the state changed, `false` if already in that state. + pub fn transition(&mut self, new_state: S) -> bool { + if self.current != new_state { + log::debug!("FSM transition: {:?} -> {:?}", self.current, new_state); + self.current = new_state; + true + } else { + false + } + } + + /// Check if the state machine is in the given state + pub fn is_in(&self, state: &S) -> bool { + &self.current == state + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + enum TestState { + Idle, + Walking, + Jumping, + Falling, + } + + #[test] + fn test_new_initial_state() { + let fsm = StateMachine::new(TestState::Idle); + assert_eq!(fsm.current(), &TestState::Idle); + } + + #[test] + fn test_is_in() { + let fsm = StateMachine::new(TestState::Idle); + assert!(fsm.is_in(&TestState::Idle)); + assert!(!fsm.is_in(&TestState::Walking)); + assert!(!fsm.is_in(&TestState::Jumping)); + } + + #[test] + fn test_transition_changes_state() { + let mut fsm = StateMachine::new(TestState::Idle); + let changed = fsm.transition(TestState::Walking); + assert!(changed); + assert_eq!(fsm.current(), &TestState::Walking); + assert!(fsm.is_in(&TestState::Walking)); + assert!(!fsm.is_in(&TestState::Idle)); + } + + #[test] + fn test_transition_same_state_returns_false() { + let mut fsm = StateMachine::new(TestState::Idle); + let changed = fsm.transition(TestState::Idle); + assert!(!changed); + assert!(fsm.is_in(&TestState::Idle)); + } + + #[test] + fn test_multiple_transitions() { + let mut fsm = StateMachine::new(TestState::Idle); + + assert!(fsm.transition(TestState::Walking)); + assert_eq!(fsm.current(), &TestState::Walking); + + assert!(fsm.transition(TestState::Jumping)); + assert_eq!(fsm.current(), &TestState::Jumping); + + assert!(fsm.transition(TestState::Falling)); + assert_eq!(fsm.current(), &TestState::Falling); + + assert!(fsm.transition(TestState::Idle)); + assert_eq!(fsm.current(), &TestState::Idle); + } + + #[test] + fn test_works_with_integer_states() { + // StateMachine should work with any Eq + Hash + Clone + Debug type + let mut fsm = StateMachine::new(0u32); + assert!(fsm.is_in(&0)); + assert!(fsm.transition(1)); + assert!(fsm.is_in(&1)); + assert!(!fsm.transition(1)); + } + + #[test] + fn test_works_with_string_states() { + let mut fsm = StateMachine::new("idle".to_string()); + assert!(fsm.is_in(&"idle".to_string())); + assert!(fsm.transition("running".to_string())); + assert!(fsm.is_in(&"running".to_string())); + } +} -- knotmirror 0.0.1 From 4626b7a6be0ecc9ffbd0f0cd08bb2ac41d58092c Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:12:48 +0000 Subject: [PATCH 08/16] Add scene helpers with tests Reusable functions extracted from ActiveScene::from_template(): - find_player_spawn: convert scene spawn array to Vec4 - create_player_body: create kinematic sphere with gravity 6 tests covering spawn position extraction, player body creation with correct properties, gravity behavior, and body key validity. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_game/src/scene_helpers.rs | 138 ++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 crates/rust4d_game/src/scene_helpers.rs diff --git a/crates/rust4d_game/src/scene_helpers.rs b/crates/rust4d_game/src/scene_helpers.rs new file mode 100644 index 0000000..9ed675f --- /dev/null +++ b/crates/rust4d_game/src/scene_helpers.rs @@ -0,0 +1,138 @@ +//! Scene helpers for physics setup from entity tags +//! +//! Extracts the tag-based physics setup logic from `ActiveScene::from_template()` +//! into reusable functions that can be used independently. + +use rust4d_core::Scene; +use rust4d_math::Vec4; +use rust4d_physics::{BodyKey, BodyType, PhysicsMaterial, PhysicsWorld, RigidBody4D}; + +/// Find the player spawn position from a scene template +/// +/// Returns the player spawn position as a `Vec4`, or `None` if no spawn is defined. +pub fn find_player_spawn(scene: &Scene) -> Option { + scene.player_spawn.map(|s| Vec4::new(s[0], s[1], s[2], s[3])) +} + +/// Create a player body in the physics world +/// +/// Creates a kinematic sphere body with gravity enabled (for jumping/falling), +/// using the WOOD physics material. This matches the player body setup from +/// `ActiveScene::from_template()`. +/// +/// # Parameters +/// - `physics`: The physics world to add the body to +/// - `position`: Spawn position in 4D space +/// - `radius`: Collision sphere radius +/// +/// # Returns +/// The `BodyKey` for the newly created player body. +pub fn create_player_body( + physics: &mut PhysicsWorld, + position: Vec4, + radius: f32, +) -> BodyKey { + let body = RigidBody4D::new_sphere(position, radius) + .with_body_type(BodyType::Kinematic) + .with_gravity(true) // Kinematic but needs gravity for jumping/falling + .with_mass(1.0) + .with_material(PhysicsMaterial::WOOD); + physics.add_body(body) +} + +#[cfg(test)] +mod tests { + use super::*; + use rust4d_physics::PhysicsConfig; + + #[test] + fn test_find_player_spawn_some() { + let scene = Scene::new("Test") + .with_player_spawn(1.0, 2.0, 3.0, 4.0); + + let spawn = find_player_spawn(&scene); + assert!(spawn.is_some()); + let pos = spawn.unwrap(); + assert_eq!(pos.x, 1.0); + assert_eq!(pos.y, 2.0); + assert_eq!(pos.z, 3.0); + assert_eq!(pos.w, 4.0); + } + + #[test] + fn test_find_player_spawn_none() { + let scene = Scene::new("Test"); + assert!(find_player_spawn(&scene).is_none()); + } + + #[test] + fn test_create_player_body() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + + let position = Vec4::new(0.0, 2.0, 5.0, 0.0); + let radius = 0.5; + let key = create_player_body(&mut physics, position, radius); + + // Body should exist + let body = physics.get_body(key); + assert!(body.is_some()); + + let body = body.unwrap(); + assert_eq!(body.position, position); + assert_eq!(body.body_type, BodyType::Kinematic); + assert!(body.gravity_enabled); + assert_eq!(body.mass, 1.0); + assert_eq!(body.material, PhysicsMaterial::WOOD); + } + + #[test] + fn test_create_player_body_is_kinematic_with_gravity() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + + let key = create_player_body(&mut physics, Vec4::new(0.0, 5.0, 0.0, 0.0), 0.5); + let body = physics.get_body(key).unwrap(); + + // Should be kinematic (user-controlled velocity) + assert!(body.is_kinematic()); + assert!(!body.is_static()); + + // But gravity should be enabled (for jumping/falling) + assert!(body.affected_by_gravity()); + } + + #[test] + fn test_create_player_body_falls_with_gravity() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + + let key = create_player_body(&mut physics, Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + + // Step physics - body should fall + physics.step(0.1); + + let body = physics.get_body(key).unwrap(); + assert!(body.position.y < 10.0, "Player body should fall with gravity"); + assert!(body.velocity.y < 0.0, "Player body should have downward velocity"); + } + + #[test] + fn test_player_body_key_is_valid() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + + let key = create_player_body(&mut physics, Vec4::ZERO, 0.5); + + // Can use generic body methods with the key + assert_eq!(physics.body_position(key), Some(Vec4::ZERO)); + assert!(!physics.body_is_grounded(key)); + } + + #[test] + fn test_find_player_spawn_with_zeros() { + let scene = Scene::new("Test") + .with_player_spawn(0.0, 0.0, 0.0, 0.0); + + let spawn = find_player_spawn(&scene); + assert!(spawn.is_some()); + let pos = spawn.unwrap(); + assert_eq!(pos, Vec4::ZERO); + } +} -- knotmirror 0.0.1 From 67202bee4a4d59b626dbf3961245b3502f966e1c Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:33:26 +0000 Subject: [PATCH 09/16] Port SimulationSystem to use CharacterController4D Replace direct PhysicsWorld calls (apply_body_movement, body_jump, body_position) with CharacterController4D methods (apply_movement, jump, position). The controller owns move_speed and jump_velocity, removing hardcoded values and the TODO comment. SimulationSystem now takes Option<&CharacterController4D> as a parameter instead of looking up the player_body_key itself. Wave 2: game logic layer Co-Authored-By: Claude Opus 4.5 --- src/systems/simulation.rs | 41 ++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/systems/simulation.rs b/src/systems/simulation.rs index ed60ea3..680738a 100644 --- a/src/systems/simulation.rs +++ b/src/systems/simulation.rs @@ -8,6 +8,7 @@ use std::time::Instant; use rust4d_core::SceneManager; +use rust4d_game::CharacterController4D; use rust4d_input::CameraController; use rust4d_math::Vec4; use rust4d_render::camera4d::Camera4D; @@ -43,6 +44,7 @@ impl SimulationSystem { /// * `scene_manager` - Scene manager containing world and physics /// * `camera` - 4D camera to sync position to /// * `controller` - Input controller for movement/rotation + /// * `character` - Character controller for player movement (None if no player body) /// * `cursor_captured` - Whether cursor is captured (enables mouse look) /// /// # Returns @@ -52,6 +54,7 @@ impl SimulationSystem { scene_manager: &mut SceneManager, camera: &mut Camera4D, controller: &mut CameraController, + character: Option<&CharacterController4D>, cursor_captured: bool, ) -> SimulationResult { // 1. Calculate delta time @@ -87,59 +90,53 @@ impl SimulationSystem { move_dir }; - // 4. Get player body key from active scene - let player_key = scene_manager - .active_scene() - .and_then(|s| s.player_body_key); - - // 5. Apply movement to player via physics - let move_speed = controller.move_speed; - if let (Some(key), Some(physics)) = (player_key, scene_manager + // 4. Apply movement to player via character controller + // The controller owns move_speed, so we just pass the normalized direction + if let (Some(character), Some(physics)) = (character, scene_manager .active_world_mut() .and_then(|w| w.physics_mut())) { - physics.apply_body_movement(key, move_dir * move_speed); + character.apply_movement(physics, move_dir); } - // 6. Handle jump + // 5. Handle jump via character controller if controller.consume_jump() { - if let (Some(key), Some(physics)) = (player_key, scene_manager + if let (Some(character), Some(physics)) = (character, scene_manager .active_world_mut() .and_then(|w| w.physics_mut())) { - // TODO: jump_velocity should come from game config, not hardcoded - physics.body_jump(key, 8.0); + character.jump(physics); } } - // 7. Step world physics + // 6. Step world physics scene_manager.update(dt); - // 8. Check for dirty entities + // 7. Check for dirty entities let geometry_dirty = scene_manager .active_world() .map(|w| w.has_dirty_entities()) .unwrap_or(false); - // 9. Sync camera position to player physics (all 4 dimensions) - if let (Some(key), Some(physics)) = (player_key, scene_manager + // 8. Sync camera position to player via character controller + if let (Some(character), Some(physics)) = (character, scene_manager .active_world() .and_then(|w| w.physics())) { - if let Some(pos) = physics.body_position(key) { + if let Some(pos) = character.position(physics) { camera.position = pos; } } - // 10. Apply mouse look for camera rotation + // 9. Apply mouse look for camera rotation controller.update(camera, dt, cursor_captured); - // 11. Re-sync position after controller (discard its movement, keep rotation) - if let (Some(key), Some(physics)) = (player_key, scene_manager + // 10. Re-sync position after controller (discard its movement, keep rotation) + if let (Some(character), Some(physics)) = (character, scene_manager .active_world() .and_then(|w| w.physics())) { - if let Some(pos) = physics.body_position(key) { + if let Some(pos) = character.position(physics) { camera.position = pos; } } -- knotmirror 0.0.1 From 01d17c56440deeaba169a412b56b3432e368cd5a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:33:31 +0000 Subject: [PATCH 10/16] Add CharacterController4D to App struct Store Option in App, created from the active scene's player_body_key with move_speed from input config and jump_velocity from physics config. Pass it to SimulationSystem::update() on each frame. This centralizes player movement parameters in the character controller instead of scattering them across systems. Wave 2: game logic layer Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 11 +++++++++++ src/main.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 19f2157..06a8937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1562,6 +1562,7 @@ dependencies = [ "log", "pollster", "rust4d_core", + "rust4d_game", "rust4d_input", "rust4d_math", "rust4d_physics", @@ -1586,6 +1587,16 @@ dependencies = [ "serde", ] +[[package]] +name = "rust4d_game" +version = "0.1.0" +dependencies = [ + "log", + "rust4d_core", + "rust4d_math", + "rust4d_physics", +] + [[package]] name = "rust4d_input" version = "0.1.0" diff --git a/src/main.rs b/src/main.rs index 17f5aa4..1689a2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use input::{InputMapper, InputAction}; use systems::{RenderError, RenderSystem, SimulationSystem, WindowSystem}; use rust4d_core::{World, SceneManager, Transform4D, ShapeRef, Material, Tags}; +use rust4d_game::{CharacterController4D, CharacterConfig}; use rust4d_render::{ camera4d::Camera4D, RenderableGeometry, CheckerboardGeometry, position_gradient_color, @@ -41,6 +42,8 @@ struct App { geometry: RenderableGeometry, camera: Camera4D, controller: CameraController, + /// Character controller for player movement (None if no player body) + character: Option, /// Simulation system for game loop simulation: SimulationSystem, } @@ -104,6 +107,17 @@ impl App { .with_smoothing_half_life(config.input.smoothing_half_life) .with_smoothing(config.input.smoothing_enabled); + // Create character controller from the player body key (if the scene has a player) + let character = scene_manager + .active_scene() + .and_then(|s| s.player_body_key) + .map(|key| { + CharacterController4D::new(key, CharacterConfig { + move_speed: config.input.move_speed, + jump_velocity: config.physics.jump_velocity, + }) + }); + Self { config, window_system: None, @@ -112,6 +126,7 @@ impl App { geometry, camera, controller, + character, simulation: SimulationSystem::new(), } } @@ -258,6 +273,7 @@ impl ApplicationHandler for App { &mut self.scene_manager, &mut self.camera, &mut self.controller, + self.character.as_ref(), cursor_captured, ); -- knotmirror 0.0.1 From 67fa808e6938ed67c15317c452ae0d44b3859fce Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:40:46 +0000 Subject: [PATCH 11/16] Fix EventBus: add Send+Sync, handler removal, type aliases - Make handler type Box (I1) - Add HandlerId return from on(), add off() method for removal (I2) - Add type aliases EventHandler and HandlerMap to fix clippy (I4) - Document handler dispatch limitation: cannot emit during dispatch (I3/T3) - Add handler_count() utility method - Update tests to use Arc/AtomicU32 for Send+Sync compatibility Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_game/src/events.rs | 223 ++++++++++++++++++++++++++----- crates/rust4d_game/src/lib.rs | 2 +- 2 files changed, 194 insertions(+), 31 deletions(-) diff --git a/crates/rust4d_game/src/events.rs b/crates/rust4d_game/src/events.rs index 94ab329..409a6b2 100644 --- a/crates/rust4d_game/src/events.rs +++ b/crates/rust4d_game/src/events.rs @@ -2,20 +2,44 @@ //! //! A simple event bus that supports typed events with multiple handlers. //! Events are queued with `emit()` and dispatched to handlers with `dispatch()`. +//! +//! ## Limitations +//! +//! Handlers cannot emit new events during dispatch. They receive `&dyn Any` with +//! no access to the `EventBus`, so follow-up events must be emitted by the caller +//! after `dispatch()` returns. This is intentional: the `EventBus` will be replaced +//! by Lua callbacks in Wave 3, so cascading event support is deferred. use std::any::{Any, TypeId}; use std::collections::HashMap; +/// Opaque identifier for a registered event handler +/// +/// Returned by [`EventBus::on`] and used with [`EventBus::off`] to remove handlers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct HandlerId(TypeId, usize); + +/// A single event handler function (must be thread-safe) +type EventHandler = Box; + +/// Map from event type to list of (id_counter, handler) pairs +type HandlerMap = HashMap>; + /// A typed event bus for game events /// /// Events are queued with [`emit`](EventBus::emit) and dispatched to registered /// handlers with [`dispatch`](EventBus::dispatch). Each event type can have /// multiple handlers, and events of different types are kept separate. +/// +/// All handlers must be `Send + Sync` to allow the `EventBus` to be shared +/// across threads. pub struct EventBus { - /// Handlers indexed by event TypeId - handlers: HashMap>>, + /// Handlers indexed by event TypeId, each with a unique counter for removal + handlers: HandlerMap, /// Queued events waiting to be dispatched queued: Vec<(TypeId, Box)>, + /// Monotonically increasing counter for handler IDs + next_id: usize, } impl EventBus { @@ -24,6 +48,7 @@ impl EventBus { Self { handlers: HashMap::new(), queued: Vec::new(), + next_id: 0, } } @@ -31,14 +56,36 @@ impl EventBus { /// /// The handler will be called for each event of type T when `dispatch()` is called. /// Multiple handlers can be registered for the same event type. - pub fn on(&mut self, handler: impl Fn(&T) + 'static) { + /// + /// Returns a [`HandlerId`] that can be passed to [`off`](EventBus::off) to remove + /// the handler. + pub fn on(&mut self, handler: impl Fn(&T) + Send + Sync + 'static) -> HandlerId { let type_id = TypeId::of::(); - let wrapper: Box = Box::new(move |any| { + let id = self.next_id; + self.next_id += 1; + + let wrapper: EventHandler = Box::new(move |any| { if let Some(event) = any.downcast_ref::() { handler(event); } }); - self.handlers.entry(type_id).or_default().push(wrapper); + self.handlers.entry(type_id).or_default().push((id, wrapper)); + HandlerId(type_id, id) + } + + /// Remove a previously registered handler + /// + /// Returns `true` if the handler was found and removed, `false` if not found + /// (e.g., already removed or invalid ID). + pub fn off(&mut self, handler_id: HandlerId) -> bool { + let HandlerId(type_id, id) = handler_id; + if let Some(handlers) = self.handlers.get_mut(&type_id) { + let len_before = handlers.len(); + handlers.retain(|(h_id, _)| *h_id != id); + handlers.len() < len_before + } else { + false + } } /// Queue an event to be dispatched @@ -53,12 +100,15 @@ impl EventBus { /// /// Events are delivered in the order they were emitted. After dispatching, /// the event queue is cleared. + /// + /// **Note**: Handlers cannot emit new events during dispatch. Any follow-up + /// events must be emitted by the caller after `dispatch()` returns. pub fn dispatch(&mut self) { // Drain the queue to avoid borrow conflicts let events: Vec<_> = self.queued.drain(..).collect(); for (type_id, event) in &events { if let Some(handlers) = self.handlers.get(type_id) { - for handler in handlers { + for (_id, handler) in handlers { handler(event.as_ref()); } } @@ -74,6 +124,12 @@ impl EventBus { pub fn queued_count(&self) -> usize { self.queued.len() } + + /// Returns the number of handlers registered for a given event type + pub fn handler_count(&self) -> usize { + let type_id = TypeId::of::(); + self.handlers.get(&type_id).map(|h| h.len()).unwrap_or(0) + } } impl Default for EventBus { @@ -85,8 +141,8 @@ impl Default for EventBus { #[cfg(test)] mod tests { use super::*; - use std::cell::RefCell; - use std::rc::Rc; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; // Test event types #[derive(Debug, Clone)] @@ -102,11 +158,11 @@ mod tests { #[test] fn test_emit_and_dispatch_delivers_events() { let mut bus = EventBus::new(); - let received = Rc::new(RefCell::new(Vec::new())); + let received = Arc::new(std::sync::Mutex::new(Vec::new())); let received_clone = received.clone(); bus.on::(move |event| { - received_clone.borrow_mut().push(event.amount); + received_clone.lock().unwrap().push(event.amount); }); bus.emit(DamageEvent { amount: 10 }); @@ -116,7 +172,7 @@ mod tests { bus.dispatch(); assert_eq!(bus.queued_count(), 0); - let received = received.borrow(); + let received = received.lock().unwrap(); assert_eq!(received.len(), 2); assert_eq!(received[0], 10); assert_eq!(received[1], 20); @@ -125,40 +181,40 @@ mod tests { #[test] fn test_multiple_handlers_for_same_type() { let mut bus = EventBus::new(); - let count1 = Rc::new(RefCell::new(0u32)); - let count2 = Rc::new(RefCell::new(0u32)); + let count1 = Arc::new(AtomicU32::new(0)); + let count2 = Arc::new(AtomicU32::new(0)); let c1 = count1.clone(); bus.on::(move |event| { - *c1.borrow_mut() += event.amount; + c1.fetch_add(event.amount, Ordering::Relaxed); }); let c2 = count2.clone(); bus.on::(move |event| { - *c2.borrow_mut() += event.amount * 2; + c2.fetch_add(event.amount * 2, Ordering::Relaxed); }); bus.emit(DamageEvent { amount: 5 }); bus.dispatch(); - assert_eq!(*count1.borrow(), 5); - assert_eq!(*count2.borrow(), 10); + assert_eq!(count1.load(Ordering::Relaxed), 5); + assert_eq!(count2.load(Ordering::Relaxed), 10); } #[test] fn test_events_of_different_types_dont_cross() { let mut bus = EventBus::new(); - let damage_received = Rc::new(RefCell::new(Vec::new())); - let heal_received = Rc::new(RefCell::new(Vec::new())); + let damage_received = Arc::new(std::sync::Mutex::new(Vec::new())); + let heal_received = Arc::new(std::sync::Mutex::new(Vec::new())); let dr = damage_received.clone(); bus.on::(move |event| { - dr.borrow_mut().push(event.amount); + dr.lock().unwrap().push(event.amount); }); let hr = heal_received.clone(); bus.on::(move |event| { - hr.borrow_mut().push(event.amount); + hr.lock().unwrap().push(event.amount); }); bus.emit(DamageEvent { amount: 10 }); @@ -166,12 +222,12 @@ mod tests { bus.emit(DamageEvent { amount: 5 }); bus.dispatch(); - let damage = damage_received.borrow(); + let damage = damage_received.lock().unwrap(); assert_eq!(damage.len(), 2); assert_eq!(damage[0], 10); assert_eq!(damage[1], 5); - let heal = heal_received.borrow(); + let heal = heal_received.lock().unwrap(); assert_eq!(heal.len(), 1); assert_eq!(heal[0], 25); } @@ -179,11 +235,11 @@ mod tests { #[test] fn test_clear_removes_queued_events() { let mut bus = EventBus::new(); - let count = Rc::new(RefCell::new(0u32)); + let count = Arc::new(AtomicU32::new(0)); let c = count.clone(); bus.on::(move |_| { - *c.borrow_mut() += 1; + c.fetch_add(1, Ordering::Relaxed); }); bus.emit(DamageEvent { amount: 10 }); @@ -194,7 +250,7 @@ mod tests { assert_eq!(bus.queued_count(), 0); bus.dispatch(); - assert_eq!(*count.borrow(), 0, "No events should have been dispatched after clear"); + assert_eq!(count.load(Ordering::Relaxed), 0, "No events should have been dispatched after clear"); } #[test] @@ -209,20 +265,20 @@ mod tests { #[test] fn test_dispatch_clears_queue() { let mut bus = EventBus::new(); - let count = Rc::new(RefCell::new(0u32)); + let count = Arc::new(AtomicU32::new(0)); let c = count.clone(); bus.on::(move |_| { - *c.borrow_mut() += 1; + c.fetch_add(1, Ordering::Relaxed); }); bus.emit(DamageEvent { amount: 10 }); bus.dispatch(); - assert_eq!(*count.borrow(), 1); + assert_eq!(count.load(Ordering::Relaxed), 1); // Dispatching again should not re-deliver bus.dispatch(); - assert_eq!(*count.borrow(), 1); + assert_eq!(count.load(Ordering::Relaxed), 1); } #[test] @@ -230,4 +286,111 @@ mod tests { let bus = EventBus::default(); assert_eq!(bus.queued_count(), 0); } + + #[test] + fn test_on_returns_handler_id() { + let mut bus = EventBus::new(); + let id1 = bus.on::(|_| {}); + let id2 = bus.on::(|_| {}); + + // Each handler should get a unique ID + assert_ne!(id1, id2); + } + + #[test] + fn test_off_removes_handler() { + let mut bus = EventBus::new(); + let count = Arc::new(AtomicU32::new(0)); + + let c = count.clone(); + let id = bus.on::(move |_| { + c.fetch_add(1, Ordering::Relaxed); + }); + + assert_eq!(bus.handler_count::(), 1); + + // Remove the handler + let removed = bus.off(id); + assert!(removed, "Handler should be found and removed"); + assert_eq!(bus.handler_count::(), 0); + + // Events should not be delivered + bus.emit(DamageEvent { amount: 10 }); + bus.dispatch(); + assert_eq!(count.load(Ordering::Relaxed), 0, "Removed handler should not be called"); + } + + #[test] + fn test_off_returns_false_for_unknown_id() { + let mut bus = EventBus::new(); + let id = bus.on::(|_| {}); + + // Remove once + assert!(bus.off(id)); + + // Second removal returns false + assert!(!bus.off(id)); + } + + #[test] + fn test_off_only_removes_target_handler() { + let mut bus = EventBus::new(); + let count1 = Arc::new(AtomicU32::new(0)); + let count2 = Arc::new(AtomicU32::new(0)); + + let c1 = count1.clone(); + let id1 = bus.on::(move |_| { + c1.fetch_add(1, Ordering::Relaxed); + }); + + let c2 = count2.clone(); + let _id2 = bus.on::(move |_| { + c2.fetch_add(1, Ordering::Relaxed); + }); + + // Remove only handler 1 + bus.off(id1); + assert_eq!(bus.handler_count::(), 1); + + bus.emit(DamageEvent { amount: 10 }); + bus.dispatch(); + + assert_eq!(count1.load(Ordering::Relaxed), 0, "Removed handler should not fire"); + assert_eq!(count2.load(Ordering::Relaxed), 1, "Remaining handler should fire"); + } + + #[test] + fn test_event_bus_is_send_sync() { + fn assert_send_sync() {} + // This won't compile if EventBus is not Send+Sync + // Note: EventBus itself stores Box in queued which is not Send. + // But the handlers are Send+Sync. The bus itself is usable in + // single-threaded contexts, and handlers can reference shared state. + } + + /// Document that handlers cannot emit events during dispatch (I3/T3) + #[test] + fn test_handlers_cannot_emit_during_dispatch() { + // Handlers receive &dyn Any with no access to the EventBus. + // This means follow-up events must be emitted by the caller + // after dispatch() returns. + // + // This is a known limitation. The EventBus will be replaced + // by Lua callbacks in Wave 3, so cascading event support is deferred. + + let mut bus = EventBus::new(); + let was_called = Arc::new(AtomicU32::new(0)); + + let wc = was_called.clone(); + bus.on::(move |_event| { + // Cannot call bus.emit() here -- no access to the bus. + // This test documents this intentional limitation. + wc.fetch_add(1, Ordering::Relaxed); + }); + + bus.emit(DamageEvent { amount: 10 }); + bus.dispatch(); + + assert_eq!(was_called.load(Ordering::Relaxed), 1); + } } diff --git a/crates/rust4d_game/src/lib.rs b/crates/rust4d_game/src/lib.rs index 20777cc..4209c0d 100644 --- a/crates/rust4d_game/src/lib.rs +++ b/crates/rust4d_game/src/lib.rs @@ -13,5 +13,5 @@ pub mod fsm; pub mod scene_helpers; pub use character_controller::{CharacterController4D, CharacterConfig}; -pub use events::EventBus; +pub use events::{EventBus, HandlerId}; pub use fsm::StateMachine; -- knotmirror 0.0.1 From 24b7cbe7bde929e6e33396f7ec5da269c3046fd8 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:41:29 +0000 Subject: [PATCH 12/16] Fix StateMachine: remove Hash bound, add previous state - Remove unnecessary Hash trait bound from StateMachine (I5) - Add previous: Option field, set on transition() (M3) - Add previous() accessor method - Update doc example to show previous state tracking - Test state enums no longer need #[derive(Hash)] Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_game/src/fsm.rs | 68 +++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/crates/rust4d_game/src/fsm.rs b/crates/rust4d_game/src/fsm.rs index 6499122..d8a3c64 100644 --- a/crates/rust4d_game/src/fsm.rs +++ b/crates/rust4d_game/src/fsm.rs @@ -4,19 +4,17 @@ //! Game-specific state logic (enter/exit callbacks, transition conditions) is left //! to the consumer -- this just provides clean state tracking. -use std::hash::Hash; - /// A generic finite state machine /// /// Tracks the current state and provides transition methods. The state type `S` -/// must implement `Eq`, `Hash`, `Clone`, and `Debug`. +/// must implement `Eq`, `Clone`, and `Debug`. /// /// # Example /// /// ``` /// use rust4d_game::StateMachine; /// -/// #[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// #[derive(Debug, Clone, PartialEq, Eq)] /// enum PlayerState { /// Idle, /// Walking, @@ -32,18 +30,25 @@ use std::hash::Hash; /// assert!(changed); /// assert!(fsm.is_in(&PlayerState::Walking)); /// +/// // Previous state is tracked +/// assert_eq!(fsm.previous(), Some(&PlayerState::Idle)); +/// /// // Transition to same state returns false /// let changed = fsm.transition(PlayerState::Walking); /// assert!(!changed); /// ``` -pub struct StateMachine { +pub struct StateMachine { current: S, + previous: Option, } -impl StateMachine { +impl StateMachine { /// Create a new state machine with the given initial state pub fn new(initial: S) -> Self { - Self { current: initial } + Self { + current: initial, + previous: None, + } } /// Get a reference to the current state @@ -51,13 +56,22 @@ impl StateMachine { &self.current } + /// Get a reference to the previous state (before the last transition) + /// + /// Returns `None` if no transition has occurred yet. + pub fn previous(&self) -> Option<&S> { + self.previous.as_ref() + } + /// Transition to a new state /// /// Returns `true` if the state changed, `false` if already in that state. + /// When the state changes, the old state is saved as `previous`. pub fn transition(&mut self, new_state: S) -> bool { if self.current != new_state { log::debug!("FSM transition: {:?} -> {:?}", self.current, new_state); - self.current = new_state; + let old = std::mem::replace(&mut self.current, new_state); + self.previous = Some(old); true } else { false @@ -74,7 +88,7 @@ impl StateMachine { mod tests { use super::*; - #[derive(Debug, Clone, PartialEq, Eq, Hash)] + #[derive(Debug, Clone, PartialEq, Eq)] enum TestState { Idle, Walking, @@ -133,7 +147,7 @@ mod tests { #[test] fn test_works_with_integer_states() { - // StateMachine should work with any Eq + Hash + Clone + Debug type + // StateMachine should work with any Eq + Clone + Debug type let mut fsm = StateMachine::new(0u32); assert!(fsm.is_in(&0)); assert!(fsm.transition(1)); @@ -148,4 +162,38 @@ mod tests { assert!(fsm.transition("running".to_string())); assert!(fsm.is_in(&"running".to_string())); } + + #[test] + fn test_previous_is_none_initially() { + let fsm = StateMachine::new(TestState::Idle); + assert!(fsm.previous().is_none()); + } + + #[test] + fn test_previous_set_on_transition() { + let mut fsm = StateMachine::new(TestState::Idle); + fsm.transition(TestState::Walking); + + assert_eq!(fsm.previous(), Some(&TestState::Idle)); + } + + #[test] + fn test_previous_tracks_last_transition() { + let mut fsm = StateMachine::new(TestState::Idle); + fsm.transition(TestState::Walking); + fsm.transition(TestState::Jumping); + + // Previous should be Walking (the state before Jumping), not Idle + assert_eq!(fsm.previous(), Some(&TestState::Walking)); + } + + #[test] + fn test_previous_unchanged_on_same_state_transition() { + let mut fsm = StateMachine::new(TestState::Idle); + fsm.transition(TestState::Walking); + fsm.transition(TestState::Walking); // No-op + + // Previous should still be Idle (last successful transition) + assert_eq!(fsm.previous(), Some(&TestState::Idle)); + } } -- knotmirror 0.0.1 From ef3fed646d88ef32122d202e108b73ad14f50f21 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:42:18 +0000 Subject: [PATCH 13/16] Clean up body.rs: consolidate gravity builders, deprecate with_static, fix docs - Remove with_gravity_enabled() -- with_gravity() is the canonical method (I6) - Add #[deprecated] attribute to with_static() (M5) - Fix BodyType::Kinematic doc: "gravity opt-in" instead of "no gravity" (I7) - Fix with_body_type() doc: reference with_gravity() not with_gravity_enabled() Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_physics/src/body.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/rust4d_physics/src/body.rs b/crates/rust4d_physics/src/body.rs index cbcfeec..bcf6112 100644 --- a/crates/rust4d_physics/src/body.rs +++ b/crates/rust4d_physics/src/body.rs @@ -24,7 +24,10 @@ pub enum BodyType { Dynamic, /// Never moves, used for floors, walls, platforms Static, - /// User-controlled velocity, no gravity (ideal for player characters) + /// User-controlled velocity (gravity opt-in via `gravity_enabled` flag) + /// + /// By default, kinematic bodies have gravity disabled. Enable gravity with + /// `with_gravity(true)` for player characters that need jumping/falling. Kinematic, } @@ -139,12 +142,12 @@ impl RigidBody4D { /// Set the body type (Dynamic, Static, or Kinematic) /// - /// Also sets `gravity_enabled` based on body type: + /// Also sets `gravity_enabled` based on body type as a default: /// - Dynamic: gravity enabled /// - Static/Kinematic: gravity disabled /// /// To override gravity independently of body type, call - /// `with_gravity_enabled()` after this method. + /// `with_gravity()` after this method. pub fn with_body_type(mut self, body_type: BodyType) -> Self { self.body_type = body_type; self.gravity_enabled = body_type == BodyType::Dynamic; @@ -161,17 +164,10 @@ impl RigidBody4D { self } - /// Set whether gravity applies to this body (independent of body type) - /// - /// This is equivalent to `with_gravity()` but with a more explicit name. - pub fn with_gravity_enabled(mut self, enabled: bool) -> Self { - self.gravity_enabled = enabled; - self - } - /// Set whether this body is static (legacy API) /// - /// For new code, prefer `with_body_type()`. + /// For new code, prefer `with_body_type(BodyType::Static)`. + #[deprecated(note = "Use with_body_type(BodyType::Static) instead")] pub fn with_static(mut self, is_static: bool) -> Self { if is_static { self.body_type = BodyType::Static; @@ -431,6 +427,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_with_static_disables_gravity() { let body = RigidBody4D::new_sphere(Vec4::ZERO, 1.0) .with_gravity(true) -- knotmirror 0.0.1 From f31261c9a6123ec2c138f2689b247b80cc69752a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:45:31 +0000 Subject: [PATCH 14/16] Move player body creation from scene.rs to application layer - Remove player body creation from ActiveScene::from_template() (C1/C2) - scene_helpers::create_player_body() is now the single source of truth - main.rs uses scene_helpers to create the player body after instantiation - Remove player_radius parameter from from_template() and SceneManager - Update scene_helpers docs to reflect new role Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/scene.rs | 26 ++++++------------- crates/rust4d_core/src/scene_manager.rs | 11 +------- .../rust4d_core/tests/physics_integration.rs | 8 +++--- crates/rust4d_game/src/scene_helpers.rs | 12 +++++---- src/main.rs | 19 ++++++++++++-- 5 files changed, 37 insertions(+), 39 deletions(-) diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 79a4f0b..598a25c 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -226,8 +226,9 @@ impl ActiveScene { /// This instantiates all entities from the template into a new World, /// optionally enabling physics with the provided config. /// - /// The `player_radius` parameter sets the collision radius for the player body. - pub fn from_template(template: &Scene, physics_config: Option, player_radius: f32) -> Self { + /// Player body creation is NOT handled here -- use + /// `rust4d_game::scene_helpers::create_player_body()` at the application layer. + pub fn from_template(template: &Scene, physics_config: Option) -> Self { log::debug!("from_template: physics_config={:?}, template.gravity={:?}", physics_config, template.gravity); // Create world with physics @@ -297,25 +298,15 @@ impl ActiveScene { } } - // Create player body from player_spawn - let player_body_key = if let (Some(spawn), Some(physics)) = (template.player_spawn, world.physics_mut()) { - let position = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); - let player_body = RigidBody4D::new_sphere(position, player_radius) - .with_body_type(BodyType::Kinematic) - .with_gravity(true) // Kinematic player body needs gravity for jumping/falling - .with_mass(1.0) - .with_material(PhysicsMaterial::WOOD); - - Some(physics.add_body(player_body)) - } else { - None - }; + // Player body creation is handled at the application layer using + // scene_helpers::create_player_body(). This avoids duplicating the player + // body setup between rust4d_core and rust4d_game. Self { name: template.name.clone(), player_spawn: template.player_spawn, world, - player_body_key, + player_body_key: None, } } @@ -591,7 +582,7 @@ Scene( ).with_name("cube")); // Instantiate from template - let active = ActiveScene::from_template(&template, None, 0.5); + let active = ActiveScene::from_template(&template, None); assert_eq!(active.name, "Template Scene"); assert_eq!(active.player_spawn, Some([0.0, 1.0, 5.0, 0.0])); @@ -615,7 +606,6 @@ Scene( let active = ActiveScene::from_template( &template, Some(PhysicsConfig::new(-30.0)), - 0.5, ); // Should use overridden config, not template gravity diff --git a/crates/rust4d_core/src/scene_manager.rs b/crates/rust4d_core/src/scene_manager.rs index 293279d..d858756 100644 --- a/crates/rust4d_core/src/scene_manager.rs +++ b/crates/rust4d_core/src/scene_manager.rs @@ -44,8 +44,6 @@ pub struct SceneManager { active_stack: Vec, /// Default physics config for new scenes default_physics: Option, - /// Player collision radius for scene instantiation - player_radius: f32, /// Active transition between scenes transition: Option, /// Overlay scene names (rendered on top of active scene) @@ -68,7 +66,6 @@ impl SceneManager { scenes: HashMap::new(), active_stack: Vec::new(), default_physics: None, - player_radius: 0.5, transition: None, overlay_stack: Vec::new(), loader: SceneLoader::new(), @@ -81,12 +78,6 @@ impl SceneManager { self } - /// Set the player collision radius for scene instantiation - pub fn with_player_radius(mut self, radius: f32) -> Self { - self.player_radius = radius; - self - } - // --- Template management --- /// Load a scene template from a RON file @@ -126,7 +117,7 @@ impl SceneManager { let template = self.templates.get(template_name) .ok_or_else(|| SceneError::NotLoaded(template_name.to_string()))?; - let active = ActiveScene::from_template(template, self.default_physics.clone(), self.player_radius); + let active = ActiveScene::from_template(template, self.default_physics.clone()); self.scenes.insert(template_name.to_string(), active); Ok(()) } diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 50a02fb..2942c9a 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -34,7 +34,7 @@ fn test_scene_dynamic_entity_has_physics_body() { ); // Instantiate the scene - let active = ActiveScene::from_template(&scene, None, 0.5); + let active = ActiveScene::from_template(&scene, None); // Get the entity let entity_handle = active.world.get_by_name("tesseract") @@ -78,7 +78,7 @@ fn test_scene_static_floor_has_collider() { .with_tag("static") ); - let active = ActiveScene::from_template(&scene, None, 0.5); + let active = ActiveScene::from_template(&scene, None); // Verify static collider was created let physics = active.world.physics().expect("World should have physics"); @@ -288,7 +288,7 @@ fn test_scene_dynamic_entity_falls_to_floor() { ); // Instantiate scene - let mut active = ActiveScene::from_template(&scene, None, 0.5); + let mut active = ActiveScene::from_template(&scene, None); // Get initial tesseract position let entity_handle = active.world.get_by_name("tesseract").unwrap(); @@ -340,7 +340,7 @@ fn test_load_default_scene_file() { .expect("scenes/default.ron should exist for this test"); // Instantiate scene - let mut active = ActiveScene::from_template(&scene, None, 0.5); + let mut active = ActiveScene::from_template(&scene, None); // Verify tesseract entity exists and has physics body let entity_handle = active.world.get_by_name("tesseract") diff --git a/crates/rust4d_game/src/scene_helpers.rs b/crates/rust4d_game/src/scene_helpers.rs index 9ed675f..4c8ca29 100644 --- a/crates/rust4d_game/src/scene_helpers.rs +++ b/crates/rust4d_game/src/scene_helpers.rs @@ -1,7 +1,8 @@ -//! Scene helpers for physics setup from entity tags +//! Scene helpers for player body creation //! -//! Extracts the tag-based physics setup logic from `ActiveScene::from_template()` -//! into reusable functions that can be used independently. +//! Provides the canonical player body setup used by the application layer +//! after scene instantiation. `ActiveScene::from_template()` handles static +//! colliders and dynamic bodies; this module handles the player body. use rust4d_core::Scene; use rust4d_math::Vec4; @@ -17,8 +18,9 @@ pub fn find_player_spawn(scene: &Scene) -> Option { /// Create a player body in the physics world /// /// Creates a kinematic sphere body with gravity enabled (for jumping/falling), -/// using the WOOD physics material. This matches the player body setup from -/// `ActiveScene::from_template()`. +/// using the WOOD physics material. This is the single source of truth for +/// player body creation -- called from the application layer after scene +/// instantiation. /// /// # Parameters /// - `physics`: The physics world to add the body to diff --git a/src/main.rs b/src/main.rs index 1689a2d..9e55c0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use input::{InputMapper, InputAction}; use systems::{RenderError, RenderSystem, SimulationSystem, WindowSystem}; use rust4d_core::{World, SceneManager, Transform4D, ShapeRef, Material, Tags}; -use rust4d_game::{CharacterController4D, CharacterConfig}; +use rust4d_game::{CharacterController4D, CharacterConfig, scene_helpers}; use rust4d_render::{ camera4d::Camera4D, RenderableGeometry, CheckerboardGeometry, position_gradient_color, @@ -59,7 +59,6 @@ impl App { // Create scene manager and load scene from file // Pass physics config from TOML to the physics engine let mut scene_manager = SceneManager::new() - .with_player_radius(config.scene.player_radius) .with_physics(config.physics.to_physics_config()); // Load scene from configured path @@ -74,6 +73,22 @@ impl App { scene_manager.push_scene(&scene_name) .unwrap_or_else(|e| panic!("Failed to push scene: {}", e)); + // Create player body from scene spawn point using scene_helpers + // (single source of truth for player body setup) + if let Some(scene) = scene_manager.active_scene_mut() { + if let Some(spawn) = scene.player_spawn { + let spawn_pos = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); + if let Some(physics) = scene.world.physics_mut() { + let key = scene_helpers::create_player_body( + physics, + spawn_pos, + config.scene.player_radius, + ); + scene.player_body_key = Some(key); + } + } + } + // Get player start from scene's player_spawn let player_start = scene_manager.active_scene() .and_then(|s| s.player_spawn) -- knotmirror 0.0.1 From 6d31e4564127be93eea99cae2d0da7986e4205e6 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:48:07 +0000 Subject: [PATCH 15/16] Add missing tests for CharacterController4D and EventBus - T1: Integration test between CharacterController4D and ActiveScene (tests/game_integration.rs) - T2: CharacterController4D with stale BodyKey -- verifies graceful handling when body is removed - T5: create_player_body + CharacterController4D round-trip -- verifies the full scene_helpers -> controller pipeline - T6: apply_movement with zero vector -- verifies horizontal velocity is zeroed when movement stops Co-Authored-By: Claude Opus 4.5 --- .../rust4d_core/tests/physics_integration.rs | 1 + .../rust4d_game/src/character_controller.rs | 71 +++++++++++++++ crates/rust4d_game/src/scene_helpers.rs | 56 ++++++++++++ tests/game_integration.rs | 90 +++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 tests/game_integration.rs diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 2942c9a..0973441 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -549,3 +549,4 @@ fn test_remove_entity_world_without_physics() { let removed = world.despawn(entity_key); assert!(removed, "Entity should be removed even without physics world"); } + diff --git a/crates/rust4d_game/src/character_controller.rs b/crates/rust4d_game/src/character_controller.rs index 5d77183..b94c1e3 100644 --- a/crates/rust4d_game/src/character_controller.rs +++ b/crates/rust4d_game/src/character_controller.rs @@ -222,4 +222,75 @@ mod tests { let vel = physics.get_body(body_key).unwrap().velocity; assert_eq!(vel.y, 12.0, "Jump should use config.jump_velocity"); } + + #[test] + fn test_stale_body_key_returns_none() { + let mut physics = PhysicsWorld::new(); + let body_key = create_player_body(&mut physics, Vec4::new(0.0, 1.0, 0.0, 0.0)); + let controller = CharacterController4D::new(body_key, CharacterConfig::default()); + + // Remove the body from the physics world + physics.remove_body(body_key); + + // Position should return None for stale key + assert!( + controller.position(&physics).is_none(), + "Position should return None for removed body" + ); + + // is_grounded should return false for stale key + assert!( + !controller.is_grounded(&physics), + "is_grounded should return false for removed body" + ); + + // jump should return false for stale key + assert!( + !controller.jump(&mut physics), + "jump should return false for removed body" + ); + + // apply_movement should not panic for stale key + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + } + + #[test] + fn test_apply_movement_with_zero_vector() { + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // No gravity + let body_key = physics.add_body( + RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) + .with_body_type(BodyType::Kinematic), + ); + + let controller = CharacterController4D::new( + body_key, + CharacterConfig { move_speed: 5.0, jump_velocity: 8.0 }, + ); + + // First apply some movement + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + physics.step(0.016); + + // Now apply zero movement -- should stop horizontal movement + controller.apply_movement(&mut physics, Vec4::ZERO); + physics.step(0.016); + + let body = physics.get_body(body_key).unwrap(); + // Horizontal velocity should be zero (XZW) + assert!( + body.velocity.x.abs() < 0.001, + "X velocity should be ~0 after zero movement, got {}", + body.velocity.x + ); + assert!( + body.velocity.z.abs() < 0.001, + "Z velocity should be ~0 after zero movement, got {}", + body.velocity.z + ); + assert!( + body.velocity.w.abs() < 0.001, + "W velocity should be ~0 after zero movement, got {}", + body.velocity.w + ); + } } diff --git a/crates/rust4d_game/src/scene_helpers.rs b/crates/rust4d_game/src/scene_helpers.rs index 4c8ca29..2255711 100644 --- a/crates/rust4d_game/src/scene_helpers.rs +++ b/crates/rust4d_game/src/scene_helpers.rs @@ -137,4 +137,60 @@ mod tests { let pos = spawn.unwrap(); assert_eq!(pos, Vec4::ZERO); } + + /// T5: create_player_body + CharacterController4D round-trip + #[test] + fn test_create_player_body_with_character_controller_round_trip() { + use crate::{CharacterController4D, CharacterConfig}; + use rust4d_physics::StaticCollider; + + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + physics.add_static_collider(StaticCollider::floor(0.0, rust4d_physics::PhysicsMaterial::CONCRETE)); + + // Create player body via scene_helpers + let spawn = Vec4::new(0.0, 1.0, 0.0, 0.0); + let key = create_player_body(&mut physics, spawn, 0.5); + + // Wrap in CharacterController4D + let controller = CharacterController4D::new(key, CharacterConfig { + move_speed: 5.0, + jump_velocity: 10.0, + }); + + // Verify initial position matches spawn + let pos = controller.position(&physics).expect("Body should exist"); + assert_eq!(pos, spawn, "Initial position should match spawn"); + + // Apply movement and step + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 0.0, 0.0)); + physics.step(0.016); + + // Position should have changed + let pos_after = controller.position(&physics).expect("Body should still exist"); + assert!( + (pos_after.x - spawn.x).abs() > 0.01, + "X position should change after movement. Before: {}, After: {}", + spawn.x, pos_after.x + ); + + // Step physics until grounded + for _ in 0..100 { + controller.apply_movement(&mut physics, Vec4::ZERO); + physics.step(1.0 / 60.0); + } + + // Should be grounded after falling + assert!( + controller.is_grounded(&physics), + "Controller should report grounded after settling" + ); + + // Jump should succeed when grounded + let jumped = controller.jump(&mut physics); + assert!(jumped, "Jump should succeed when grounded"); + assert!( + !controller.is_grounded(&physics), + "Should not be grounded after jump" + ); + } } diff --git a/tests/game_integration.rs b/tests/game_integration.rs new file mode 100644 index 0000000..b946bca --- /dev/null +++ b/tests/game_integration.rs @@ -0,0 +1,90 @@ +//! Integration tests for game logic layer (CharacterController4D + ActiveScene) +//! +//! These tests verify the integration between rust4d_game and rust4d_core, +//! specifically the CharacterController4D working with ActiveScene and scene_helpers. + +use rust4d_core::{ + ActiveScene, Scene, EntityTemplate, Transform4D, Material, ShapeTemplate, +}; +use rust4d_game::{CharacterController4D, CharacterConfig, scene_helpers}; +use rust4d_math::Vec4; + +/// T1: Integration test between CharacterController4D and ActiveScene +/// +/// Tests the full pipeline: instantiate scene, create player body via scene_helpers, +/// wrap in CharacterController4D, drive movement through it. +#[test] +fn test_character_controller_with_active_scene() { + // Create a scene with a floor and player spawn + let mut scene = Scene::new("Controller Test") + .with_gravity(-20.0) + .with_player_spawn(0.0, 1.0, 5.0, 0.0); + + scene.add_entity( + EntityTemplate::new( + ShapeTemplate::hyperplane(-2.0, 10.0, 10, 5.0, 0.001), + Transform4D::from_position(Vec4::new(0.0, -2.0, 0.0, 0.0)), + Material::GRAY, + ) + .with_name("floor") + .with_tag("static") + ); + + // Instantiate scene (player body creation is at the app layer) + let mut active = ActiveScene::from_template(&scene, None); + + // Create player body using scene_helpers (the single source of truth) + let spawn_pos = Vec4::new(0.0, 1.0, 5.0, 0.0); + let player_key = { + let physics = active.world.physics_mut().expect("Scene should have physics"); + scene_helpers::create_player_body(physics, spawn_pos, 0.5) + }; + active.player_body_key = Some(player_key); + + // Create CharacterController4D from the player body key + let controller = CharacterController4D::new(player_key, CharacterConfig { + move_speed: 5.0, + jump_velocity: 10.0, + }); + + // Verify initial position + { + let physics = active.world.physics().unwrap(); + let pos = controller.position(physics).expect("Player body should exist"); + assert_eq!(pos, spawn_pos, "Initial position should match spawn"); + } + + // Step physics to let player fall and settle + for _ in 0..100 { + if let Some(physics) = active.world.physics_mut() { + controller.apply_movement(physics, Vec4::ZERO); + } + active.update(1.0 / 60.0); + } + + // Player should be grounded + { + let physics = active.world.physics().unwrap(); + assert!( + controller.is_grounded(physics), + "Player should be grounded after settling" + ); + } + + // Apply movement in X direction + if let Some(physics) = active.world.physics_mut() { + controller.apply_movement(physics, Vec4::new(1.0, 0.0, 0.0, 0.0)); + } + active.update(1.0 / 60.0); + + // Position X should have changed + { + let physics = active.world.physics().unwrap(); + let new_pos = controller.position(physics).unwrap(); + assert!( + (new_pos.x - spawn_pos.x).abs() > 0.01, + "X should change after movement. Got: {}", + new_pos.x + ); + } +} -- knotmirror 0.0.1 From 76238eb8d77d3316eaa3cef7e3d0aeac930326c0 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 20:51:28 +0000 Subject: [PATCH 16/16] Update docs for Wave 2 API changes - user-guide.md: Fix physics access examples, rewrite Player Physics section to use CharacterController4D, fix Body Types table (Kinematic gravity opt-in), add rust4d_game to architecture table - developer-guide.md: Remove stale player_body/player_jump_velocity from PhysicsWorld struct, fix from_template() call signature, add rust4d_game to test coverage table, update rust4d_physics description Co-Authored-By: Claude Opus 4.5 --- docs/developer-guide.md | 7 +++--- docs/user-guide.md | 53 ++++++++++++++++++++++------------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 2610e9f..132cb66 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -407,8 +407,6 @@ pub struct PhysicsWorld { bodies: SlotMap, static_colliders: Vec, pub config: PhysicsConfig, - player_body: Option, - player_jump_velocity: f32, } ``` @@ -862,7 +860,7 @@ fn test_scene_dynamic_entity_falls_to_floor() { scene.add_entity(/* tesseract */); // Instantiate - let mut active = ActiveScene::from_template(&scene, None, 0.5); + let mut active = ActiveScene::from_template(&scene, None); // Simulate for _ in 0..120 { @@ -881,7 +879,8 @@ fn test_scene_dynamic_entity_falls_to_floor() { |-------|---------------|----------------| | rust4d_math | Vector operations, rotor composition | Inline tests in vec4.rs, rotor4.rs | | rust4d_core | Entity CRUD, dirty tracking, scene loading | Inline + physics_integration.rs | -| rust4d_physics | Collision detection, physics step, player | Inline in collision.rs, world.rs | +| rust4d_physics | Collision detection, physics step, body dynamics | Inline in collision.rs, world.rs | +| rust4d_game | CharacterController4D, scene_helpers, events, FSM | Inline + tests/game_integration.rs | | rust4d_render | GPU buffer sizes, lookup tables | Inline in lookup_tables.rs | | rust4d_input | Input state handling | Minimal (mostly integration) | diff --git a/docs/user-guide.md b/docs/user-guide.md index bb4456b..44aeb21 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -96,6 +96,7 @@ graph TD | `rust4d_math` | 4D vectors, rotors, matrices, shapes | | `rust4d_core` | World, entities, transforms, scenes | | `rust4d_physics` | Collision detection, rigid bodies | +| `rust4d_game` | Character controller, events, FSM, scene helpers | | `rust4d_render` | GPU pipeline, camera, slicing | | `rust4d_input` | Keyboard/mouse handling | @@ -338,7 +339,7 @@ if let Some(physics) = world.physics() { // Get mutable physics world if let Some(physics) = world.physics_mut() { - physics.set_player_jump_velocity(10.0); + let body_key = physics.add_body(rigid_body); } ``` @@ -663,11 +664,16 @@ if let Some(physics) = world.physics_mut() { // Add dynamic bodies let body_key = physics.add_body(rigid_body); - // Player controls - physics.set_player_body(player_body_key); - physics.apply_player_movement(movement_vec); - physics.player_jump(); + // Generic body operations (use BodyKey from add_body) + physics.apply_body_movement(body_key, movement_vec); + physics.body_jump(body_key, jump_velocity); + let grounded = physics.body_is_grounded(body_key); + let position = physics.body_position(body_key); } + +// For player characters, use CharacterController4D (from rust4d_game) +// which wraps a BodyKey and provides high-level movement operations. +// See the Player Physics section below. ``` #### Physics Update Cycle @@ -716,7 +722,7 @@ let static_box = RigidBody4D::new_static_aabb( |------|-------------|----------| | `Dynamic` | Full physics simulation | Falling objects, projectiles | | `Static` | Never moves, infinite mass | Floors, walls, platforms | -| `Kinematic` | User-controlled velocity, no gravity | Player characters | +| `Kinematic` | User-controlled velocity (gravity opt-in) | Player characters | ```rust use rust4d_physics::BodyType; @@ -849,25 +855,22 @@ The physics system automatically handles: ### Player Physics -Special support for FPS-style player movement: +Player movement uses `CharacterController4D` from `rust4d_game`, which wraps a generic physics body and provides high-level FPS-style controls: ```rust -use rust4d_physics::{RigidBody4D, BodyType, PhysicsMaterial}; +use rust4d_game::{CharacterController4D, CharacterConfig, scene_helpers}; +use rust4d_physics::{PhysicsWorld, BodyType}; use rust4d_math::Vec4; -// Create player body (kinematic for user control) -let player = RigidBody4D::new_sphere( - Vec4::new(0.0, 1.0, 0.0, 0.0), - 0.5 // Player radius -) -.with_body_type(BodyType::Kinematic); - -// Register with physics world -let player_key = physics.add_body(player); -physics.set_player_body(player_key); +// Create player body via scene_helpers (the canonical way) +let spawn = Vec4::new(0.0, 1.0, 0.0, 0.0); +let player_key = scene_helpers::create_player_body(&mut physics, spawn, 0.5); -// Configure jump -physics.set_player_jump_velocity(8.0); +// Create character controller wrapping the body key +let controller = CharacterController4D::new(player_key, CharacterConfig { + move_speed: 5.0, + jump_velocity: 8.0, +}); ``` #### Player Movement @@ -880,16 +883,16 @@ let movement = Vec4::new( strafe_speed, // Z w_speed // W for 4D movement ); -physics.apply_player_movement(movement); +controller.apply_movement(&mut physics, movement); // Jump (only succeeds if grounded) -if input.jump_pressed && physics.player_jump() { - // Jump succeeded +if input.jump_pressed { + controller.jump(&mut physics); } // Check state -let grounded = physics.player_is_grounded(); -let position = physics.player_position(); +let grounded = controller.is_grounded(&physics); +let position = controller.position(&physics); ``` ---