From 78e0c71c6da1c6574ac2ef9b4a3821dd4d51df05 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 11 Feb 2026 19:32:29 +0100 Subject: [PATCH] refactor(simulation): chunk-based walkability map per D-012 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites WalkabilityMap from flat Vec to HashMap with 32x32 tile chunks. Supports chunk load/unload for future borderless generation. Unloaded chunks treated as unwalkable. Adds TilePosition ↔ f32 render coordinate conversion (to_render_coords, from_render_coords) bridging i32 simulation coords and f32 wire format. Addresses Tyre PR review: D-012 chunk architecture compatibility and VisibleEntity coordinate mismatch. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/movement.rs | 301 +++++++++++++++++++++--------- 1 file changed, 211 insertions(+), 90 deletions(-) diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 2a7a78bd2..f0f510abe 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -1,11 +1,18 @@ // Tile-based movement and collision system // Implements Sprint 1 ticket #236: walkability map and movement validation +// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation // Y-down convention: North = y-1, South = y+1 use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; -/// Tile position component for grid-based movement +/// Chunk size in tiles (32x32 per chunk) +pub const CHUNK_SIZE: i32 = 32; + +/// Tile position component for grid-based movement. +/// Discrete integer coordinates used in simulation; converted to f32 +/// at the bridge boundary for VisibleEntity wire format. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TilePosition { pub x: i32, @@ -18,8 +25,8 @@ impl TilePosition { Self { x, y, z } } - /// Calculate Manhattan distance to another position - /// Returns None if positions are on different z-levels + /// 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; @@ -27,8 +34,11 @@ impl TilePosition { 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 + /// 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. + /// + /// TODO: Diagonal movement (8-directional) for genre expectations. + /// TODO: Chunk boundary awareness for neighbors in different chunks. pub fn cardinal_neighbors(&self) -> [TilePosition; 4] { [ TilePosition::new(self.x, self.y - 1, self.z), // North @@ -37,109 +47,181 @@ impl TilePosition { TilePosition::new(self.x - 1, self.y, self.z), // West ] } + + /// Convert to f32 coordinates for VisibleEntity wire format (D-020). + /// Maps tile center to float position (tile 0 → 0.5, tile 1 → 1.5, etc.) + pub fn to_render_coords(&self) -> (f32, f32, i32) { + (self.x as f32 + 0.5, self.y as f32 + 0.5, self.z) + } + + /// Convert from f32 render coordinates back to tile position (floor). + pub fn from_render_coords(x: f32, y: f32, z: i32) -> Self { + Self { + x: x.floor() as i32, + y: y.floor() as i32, + z, + } + } + + /// Get the chunk coordinate this tile belongs to. + fn chunk_coord(&self) -> ChunkCoord { + ChunkCoord { + cx: self.x.div_euclid(CHUNK_SIZE), + cy: self.y.div_euclid(CHUNK_SIZE), + z: self.z, + } + } + + /// Get the local offset within its chunk. + fn local_offset(&self) -> (i32, i32) { + (self.x.rem_euclid(CHUNK_SIZE), self.y.rem_euclid(CHUNK_SIZE)) + } } -/// Walkability map resource for tile collision -/// Flat storage with index = z*w*h + y*w + x +/// Chunk coordinate for chunk-based map storage (D-012). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ChunkCoord { + pub cx: i32, + pub cy: i32, + pub z: i32, +} + +/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles). +#[derive(Debug, Clone)] +struct ChunkData { + tiles: Vec, // CHUNK_SIZE * CHUNK_SIZE, true = walkable +} + +impl ChunkData { + fn new_walkable() -> Self { + Self { + tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize], + } + } + + fn new_blocked() -> Self { + Self { + tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize], + } + } + + fn index(lx: i32, ly: i32) -> usize { + (ly * CHUNK_SIZE + lx) as usize + } + + fn get(&self, lx: i32, ly: i32) -> bool { + self.tiles[Self::index(lx, ly)] + } + + fn set(&mut self, lx: i32, ly: i32, walkable: bool) { + self.tiles[Self::index(lx, ly)] = walkable; + } +} + +/// Chunk-based walkability map resource (D-012, D-014). +/// Stores walkability per tile in CHUNK_SIZE x CHUNK_SIZE chunks. +/// Supports chunk load/unload for future borderless generation. +/// Unloaded chunks are treated as unwalkable. +/// +/// TODO: Entity-entity collision (multiple entities on same tile). #[derive(Resource, Debug, Clone)] pub struct WalkabilityMap { - width: i32, - height: i32, - z_levels: i32, - tiles: Vec, // true = walkable, false = blocked + chunks: HashMap, } impl WalkabilityMap { - /// Create a new walkability map with all tiles walkable + /// Create a walkability map covering a rectangular area with all tiles walkable. + /// Generates chunks to cover the specified dimensions on z-level 0..z_levels. 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], + let mut chunks = HashMap::new(); + let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE; + let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE; + for z in 0..z_levels { + for cy in 0..cy_max { + for cx in 0..cx_max { + chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_walkable()); + } + } } + Self { chunks } } - /// Create a new walkability map with all tiles blocked + /// Create a walkability map covering a rectangular area 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], + let mut chunks = HashMap::new(); + let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE; + let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE; + for z in 0..z_levels { + for cy in 0..cy_max { + for cx in 0..cx_max { + chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_blocked()); + } + } } + Self { chunks } } - 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) + /// Check if a tile is walkable. Unloaded chunks are treated as unwalkable. pub fn can_move_to(&self, pos: &TilePosition) -> bool { - if !self.in_bounds(pos) { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + self.chunks + .get(&coord) + .is_some_and(|chunk| chunk.get(lx, ly)) + } + + /// Set walkability of a tile. Creates the chunk if it doesn't exist. + pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + let chunk = self + .chunks + .entry(coord) + .or_insert_with(ChunkData::new_blocked); + chunk.set(lx, ly, walkable); + } + + /// Check if a chunk is loaded. + pub fn has_chunk(&self, coord: &ChunkCoord) -> bool { + self.chunks.contains_key(coord) + } + + /// Load a chunk (all walkable). Returns false if already loaded. + pub fn load_chunk(&mut self, coord: ChunkCoord) -> bool { + if self.chunks.contains_key(&coord) { return false; } - let idx = self.index(pos); - self.tiles[idx] + self.chunks.insert(coord, ChunkData::new_walkable()); + true } - /// 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; + /// Unload a chunk. Returns false if not loaded. + pub fn unload_chunk(&mut self, coord: &ChunkCoord) -> bool { + self.chunks.remove(coord).is_some() } - /// 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 + /// Number of loaded chunks. + pub fn chunk_count(&self) -> usize { + self.chunks.len() } } -/// Component representing an intent to move to a target tile +/// 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 +/// 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 { + tracing::warn!("No WalkabilityMap loaded — rejecting all move intents"); for (entity, _, _) in query.iter() { commands.entity(entity).remove::(); } @@ -209,6 +291,45 @@ mod tests { assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1) } + #[test] + fn render_coord_conversion_roundtrip() { + let pos = TilePosition::new(5, 10, 0); + let (rx, ry, rz) = pos.to_render_coords(); + assert_eq!(rx, 5.5); + assert_eq!(ry, 10.5); + assert_eq!(rz, 0); + let back = TilePosition::from_render_coords(rx, ry, rz); + assert_eq!(back, pos); + } + + #[test] + fn chunk_coord_calculation() { + // Tile (0,0) → chunk (0,0) + assert_eq!( + TilePosition::new(0, 0, 0).chunk_coord(), + ChunkCoord { cx: 0, cy: 0, z: 0 } + ); + // Tile (31,31) → chunk (0,0) + assert_eq!( + TilePosition::new(31, 31, 0).chunk_coord(), + ChunkCoord { cx: 0, cy: 0, z: 0 } + ); + // Tile (32,0) → chunk (1,0) + assert_eq!( + TilePosition::new(32, 0, 0).chunk_coord(), + ChunkCoord { cx: 1, cy: 0, z: 0 } + ); + // Negative tile (-1,0) → chunk (-1,0) + assert_eq!( + TilePosition::new(-1, 0, 0).chunk_coord(), + ChunkCoord { + cx: -1, + cy: 0, + z: 0 + } + ); + } + #[test] fn walkability_map_default_all_walkable() { let map = WalkabilityMap::new(10, 10, 1); @@ -219,16 +340,14 @@ mod tests { } #[test] - fn walkability_map_out_of_bounds_not_walkable() { + fn walkability_map_unloaded_chunk_not_walkable() { let map = WalkabilityMap::new(10, 10, 1); - // Negative coordinates + // Negative coords → unloaded chunk → not walkable 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))); + // z=1 not loaded assert!(!map.can_move_to(&TilePosition::new(0, 0, 1))); } @@ -250,14 +369,25 @@ mod tests { 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 chunk_load_unload() { + let mut map = WalkabilityMap::new(10, 10, 1); + let coord = ChunkCoord { cx: 0, cy: 0, z: 0 }; + assert!(map.has_chunk(&coord)); + + map.unload_chunk(&coord); + assert!(!map.has_chunk(&coord)); + assert!(!map.can_move_to(&TilePosition::new(0, 0, 0))); + + map.load_chunk(coord); + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + } + #[test] fn validate_movement_allows_walkable() { let mut world = bevy_ecs::world::World::new(); @@ -276,13 +406,10 @@ mod tests { 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()); } @@ -306,18 +433,15 @@ mod tests { 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() { + fn validate_movement_blocks_unloaded_chunk() { let mut world = bevy_ecs::world::World::new(); world.insert_resource(WalkabilityMap::new(10, 10, 1)); @@ -334,13 +458,10 @@ mod tests { 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()); } }