From 862ab9099ffcb1e06b4d5d40bb2987b6b21d1d04 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:07:53 +0100 Subject: [PATCH] feat(simulation): add tile collision system (#236) TilePosition component with discrete grid coordinates, flat-storage WalkabilityMap resource with O(1) can_move_to() lookup, MoveIntent component and validate_movement system. Movement validated against walkability map each tick, blocking all NPC and player movement through unwalkable tiles. 11 unit tests + 1 integration test. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/mod.rs | 8 +- server/src/simulation/movement.rs | 346 ++++++++++++++++++++++++++++++ server/tests/movement.rs | 54 +++++ 3 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 server/src/simulation/movement.rs create mode 100644 server/tests/movement.rs diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b0cd793f4..054a04e1d 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -2,8 +2,10 @@ // Implements deterministic tick-based simulation (D-010 principle 4) use bevy_app::prelude::*; +use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; +pub mod movement; pub mod rng; pub mod tier; pub mod time; @@ -18,7 +20,11 @@ impl Plugin for SimulationPlugin { app.init_resource::() .insert_resource(rng::SimRng::new(0)) .init_resource::() - .add_systems(Update, time::advance_tick); + .add_systems(Update, time::advance_tick) + .add_systems( + Update, + movement::validate_movement.after(time::advance_tick), + ); tracing::debug!("SimulationPlugin initialized"); } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs new file mode 100644 index 000000000..2a7a78bd2 --- /dev/null +++ b/server/src/simulation/movement.rs @@ -0,0 +1,346 @@ +// Tile-based movement and collision system +// Implements Sprint 1 ticket #236: walkability map and movement validation +// Y-down convention: North = y-1, South = y+1 + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Tile position component for grid-based movement +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TilePosition { + pub x: i32, + pub y: i32, + pub z: i32, +} + +impl TilePosition { + pub fn new(x: i32, y: i32, z: i32) -> Self { + Self { x, y, z } + } + + /// Calculate Manhattan distance to another position + /// Returns None if positions are on different z-levels + pub fn manhattan_distance(&self, other: &TilePosition) -> Option { + if self.z != other.z { + return None; + } + Some(self.x.abs_diff(other.x) + self.y.abs_diff(other.y)) + } + + /// Returns the four cardinal neighbors (N/S/E/W) on the same z-level + /// Y-down convention: North = y-1, South = y+1, East = x+1, West = x-1 + pub fn cardinal_neighbors(&self) -> [TilePosition; 4] { + [ + TilePosition::new(self.x, self.y - 1, self.z), // North + TilePosition::new(self.x, self.y + 1, self.z), // South + TilePosition::new(self.x + 1, self.y, self.z), // East + TilePosition::new(self.x - 1, self.y, self.z), // West + ] + } +} + +/// Walkability map resource for tile collision +/// Flat storage with index = z*w*h + y*w + x +#[derive(Resource, Debug, Clone)] +pub struct WalkabilityMap { + width: i32, + height: i32, + z_levels: i32, + tiles: Vec, // true = walkable, false = blocked +} + +impl WalkabilityMap { + /// Create a new walkability map with all tiles walkable + pub fn new(width: i32, height: i32, z_levels: i32) -> Self { + let size = (width * height * z_levels) as usize; + Self { + width, + height, + z_levels, + tiles: vec![true; size], + } + } + + /// Create a new walkability map with all tiles blocked + pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self { + let size = (width * height * z_levels) as usize; + Self { + width, + height, + z_levels, + tiles: vec![false; size], + } + } + + pub fn width(&self) -> i32 { + self.width + } + + pub fn height(&self) -> i32 { + self.height + } + + pub fn z_levels(&self) -> i32 { + self.z_levels + } + + /// Check if a position is within map bounds + pub fn in_bounds(&self, pos: &TilePosition) -> bool { + pos.x >= 0 + && pos.x < self.width + && pos.y >= 0 + && pos.y < self.height + && pos.z >= 0 + && pos.z < self.z_levels + } + + /// Check if movement to a position is valid (in bounds and walkable) + pub fn can_move_to(&self, pos: &TilePosition) -> bool { + if !self.in_bounds(pos) { + return false; + } + let idx = self.index(pos); + self.tiles[idx] + } + + /// Set walkability of a tile + /// Panics if position is out of bounds + pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { + assert!( + self.in_bounds(pos), + "Position {:?} out of bounds ({}x{}x{})", + pos, + self.width, + self.height, + self.z_levels + ); + let idx = self.index(pos); + self.tiles[idx] = walkable; + } + + /// Calculate flat storage index for a position + fn index(&self, pos: &TilePosition) -> usize { + (pos.z * self.width * self.height + pos.y * self.width + pos.x) as usize + } +} + +/// Component representing an intent to move to a target tile +#[derive(Component, Debug, Clone)] +pub struct MoveIntent { + pub target: TilePosition, +} + +/// System to validate and execute movement intents +/// Checks walkability map and updates positions for valid moves +/// Always removes MoveIntent component after processing +pub fn validate_movement( + mut commands: Commands, + walkability: Option>, + mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>, +) { + // If no walkability map exists, reject all move intents + let Some(map) = walkability else { + for (entity, _, _) in query.iter() { + commands.entity(entity).remove::(); + } + return; + }; + + for (entity, intent, mut position) in query.iter_mut() { + if map.can_move_to(&intent.target) { + tracing::trace!( + "Entity {:?} moving from {:?} to {:?}", + entity, + *position, + intent.target + ); + *position = intent.target; + } else { + tracing::trace!( + "Entity {:?} blocked at {:?}, cannot move to {:?}", + entity, + *position, + intent.target + ); + } + commands.entity(entity).remove::(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tile_position_equality() { + let pos1 = TilePosition::new(5, 10, 0); + let pos2 = TilePosition::new(5, 10, 0); + let pos3 = TilePosition::new(5, 11, 0); + + assert_eq!(pos1, pos2); + assert_ne!(pos1, pos3); + } + + #[test] + fn manhattan_distance_same_level() { + let pos1 = TilePosition::new(0, 0, 0); + let pos2 = TilePosition::new(3, 4, 0); + + assert_eq!(pos1.manhattan_distance(&pos2), Some(7)); + assert_eq!(pos2.manhattan_distance(&pos1), Some(7)); + } + + #[test] + fn manhattan_distance_different_level_returns_none() { + let pos1 = TilePosition::new(0, 0, 0); + let pos2 = TilePosition::new(0, 0, 1); + + assert_eq!(pos1.manhattan_distance(&pos2), None); + } + + #[test] + fn cardinal_neighbors_correct() { + let pos = TilePosition::new(5, 5, 2); + let neighbors = pos.cardinal_neighbors(); + + assert_eq!(neighbors[0], TilePosition::new(5, 4, 2)); // North (y-1) + assert_eq!(neighbors[1], TilePosition::new(5, 6, 2)); // South (y+1) + assert_eq!(neighbors[2], TilePosition::new(6, 5, 2)); // East (x+1) + assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1) + } + + #[test] + fn walkability_map_default_all_walkable() { + let map = WalkabilityMap::new(10, 10, 1); + + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + assert!(map.can_move_to(&TilePosition::new(5, 5, 0))); + assert!(map.can_move_to(&TilePosition::new(9, 9, 0))); + } + + #[test] + fn walkability_map_out_of_bounds_not_walkable() { + let map = WalkabilityMap::new(10, 10, 1); + + // Negative coordinates + assert!(!map.can_move_to(&TilePosition::new(-1, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, -1, 0))); + + // Over bounds + assert!(!map.can_move_to(&TilePosition::new(10, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, 10, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, 0, 1))); + } + + #[test] + fn walkability_map_set_blocked() { + let mut map = WalkabilityMap::new(10, 10, 1); + let blocked_pos = TilePosition::new(5, 5, 0); + + map.set_walkable(&blocked_pos, false); + + assert!(!map.can_move_to(&blocked_pos)); + assert!(map.can_move_to(&TilePosition::new(5, 6, 0))); // Adjacent still walkable + } + + #[test] + fn walkability_map_multi_z_level() { + let mut map = WalkabilityMap::new(10, 10, 3); + let blocked_pos = TilePosition::new(5, 5, 1); + + map.set_walkable(&blocked_pos, false); + + // z=1 is blocked + assert!(!map.can_move_to(&blocked_pos)); + + // z=0 and z=2 at same x,y are walkable + assert!(map.can_move_to(&TilePosition::new(5, 5, 0))); + assert!(map.can_move_to(&TilePosition::new(5, 5, 2))); + } + + #[test] + fn validate_movement_allows_walkable() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + // Position updated to target + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(5, 4, 0) + ); + + // Intent removed + assert!(world.get::(entity).is_none()); + } + + #[test] + fn validate_movement_blocks_unwalkable() { + let mut world = bevy_ecs::world::World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + map.set_walkable(&TilePosition::new(5, 4, 0), false); + world.insert_resource(map); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + // Position unchanged + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(5, 5, 0) + ); + + // Intent removed + assert!(world.get::(entity).is_none()); + } + + #[test] + fn validate_movement_blocks_out_of_bounds() { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity = world + .spawn(( + TilePosition::new(0, 0, 0), + MoveIntent { + target: TilePosition::new(-1, 0, 0), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(validate_movement); + schedule.run(&mut world); + + // Position unchanged + assert_eq!( + *world.get::(entity).unwrap(), + TilePosition::new(0, 0, 0) + ); + + // Intent removed + assert!(world.get::(entity).is_none()); + } +} diff --git a/server/tests/movement.rs b/server/tests/movement.rs new file mode 100644 index 000000000..7b3600d4b --- /dev/null +++ b/server/tests/movement.rs @@ -0,0 +1,54 @@ +use bevy_app::prelude::*; +use settled_reach_server::simulation::movement::*; +use settled_reach_server::simulation::time::SimulationTime; +use settled_reach_server::simulation::SimulationPlugin; + +#[test] +fn movement_validated_within_app() { + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + + // Insert a walkability map with one blocked tile + let mut map = WalkabilityMap::new(10, 10, 1); + map.set_walkable(&TilePosition::new(3, 3, 0), false); + app.insert_resource(map); + + // Spawn mover (walkable target) and blocked entity + let mover = app + .world_mut() + .spawn(( + TilePosition::new(5, 5, 0), + MoveIntent { + target: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let blocked = app + .world_mut() + .spawn(( + TilePosition::new(3, 4, 0), + MoveIntent { + target: TilePosition::new(3, 3, 0), + }, + )) + .id(); + + app.update(); + + // Mover moved + assert_eq!( + *app.world().get::(mover).unwrap(), + TilePosition::new(5, 4, 0) + ); + // Blocked stayed + assert_eq!( + *app.world().get::(blocked).unwrap(), + TilePosition::new(3, 4, 0) + ); + // Tick advanced + assert_eq!(app.world().resource::().tick, 1); + // Both intents consumed + assert!(app.world().get::(mover).is_none()); + assert!(app.world().get::(blocked).is_none()); +}