// 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()); } }