refactor(simulation): chunk-based walkability map per D-012

Rewrites WalkabilityMap from flat Vec<bool> to HashMap<ChunkCoord, ChunkData>
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 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:32:29 +01:00
co-authored by Claude Opus 4.6
parent e44e90c2cb
commit 78e0c71c6d
+211 -90
View File
@@ -1,11 +1,18 @@
// Tile-based movement and collision system // Tile-based movement and collision system
// Implements Sprint 1 ticket #236: walkability map and movement validation // 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 // Y-down convention: North = y-1, South = y+1
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize}; 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)] #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TilePosition { pub struct TilePosition {
pub x: i32, pub x: i32,
@@ -18,8 +25,8 @@ impl TilePosition {
Self { x, y, z } Self { x, y, z }
} }
/// Calculate Manhattan distance to another position /// Calculate Manhattan distance to another position.
/// Returns None if positions are on different z-levels /// Returns None if positions are on different z-levels.
pub fn manhattan_distance(&self, other: &TilePosition) -> Option<u32> { pub fn manhattan_distance(&self, other: &TilePosition) -> Option<u32> {
if self.z != other.z { if self.z != other.z {
return None; return None;
@@ -27,8 +34,11 @@ impl TilePosition {
Some(self.x.abs_diff(other.x) + self.y.abs_diff(other.y)) 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 /// 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 /// 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] { 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), // North
@@ -37,109 +47,181 @@ impl TilePosition {
TilePosition::new(self.x - 1, self.y, self.z), // West 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 /// Chunk coordinate for chunk-based map storage (D-012).
/// Flat storage with index = z*w*h + y*w + x #[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<bool>, // 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)] #[derive(Resource, Debug, Clone)]
pub struct WalkabilityMap { pub struct WalkabilityMap {
width: i32, chunks: HashMap<ChunkCoord, ChunkData>,
height: i32,
z_levels: i32,
tiles: Vec<bool>, // true = walkable, false = blocked
} }
impl WalkabilityMap { 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 { pub fn new(width: i32, height: i32, z_levels: i32) -> Self {
let size = (width * height * z_levels) as usize; let mut chunks = HashMap::new();
Self { let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
width, let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
height, for z in 0..z_levels {
z_levels, for cy in 0..cy_max {
tiles: vec![true; size], 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 { pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self {
let size = (width * height * z_levels) as usize; let mut chunks = HashMap::new();
Self { let cx_max = (width + CHUNK_SIZE - 1) / CHUNK_SIZE;
width, let cy_max = (height + CHUNK_SIZE - 1) / CHUNK_SIZE;
height, for z in 0..z_levels {
z_levels, for cy in 0..cy_max {
tiles: vec![false; size], for cx in 0..cx_max {
chunks.insert(ChunkCoord { cx, cy, z }, ChunkData::new_blocked());
}
}
} }
Self { chunks }
} }
pub fn width(&self) -> i32 { /// Check if a tile is walkable. Unloaded chunks are treated as unwalkable.
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 { 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; return false;
} }
let idx = self.index(pos); self.chunks.insert(coord, ChunkData::new_walkable());
self.tiles[idx] true
} }
/// Set walkability of a tile /// Unload a chunk. Returns false if not loaded.
/// Panics if position is out of bounds pub fn unload_chunk(&mut self, coord: &ChunkCoord) -> bool {
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { self.chunks.remove(coord).is_some()
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 /// Number of loaded chunks.
fn index(&self, pos: &TilePosition) -> usize { pub fn chunk_count(&self) -> usize {
(pos.z * self.width * self.height + pos.y * self.width + pos.x) as 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)] #[derive(Component, Debug, Clone)]
pub struct MoveIntent { pub struct MoveIntent {
pub target: TilePosition, pub target: TilePosition,
} }
/// System to validate and execute movement intents /// System to validate and execute movement intents.
/// Checks walkability map and updates positions for valid moves /// Checks walkability map and updates positions for valid moves.
/// Always removes MoveIntent component after processing /// Always removes MoveIntent component after processing.
pub fn validate_movement( pub fn validate_movement(
mut commands: Commands, mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>, walkability: Option<Res<WalkabilityMap>>,
mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>, mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>,
) { ) {
// If no walkability map exists, reject all move intents
let Some(map) = walkability else { let Some(map) = walkability else {
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
for (entity, _, _) in query.iter() { for (entity, _, _) in query.iter() {
commands.entity(entity).remove::<MoveIntent>(); commands.entity(entity).remove::<MoveIntent>();
} }
@@ -209,6 +291,45 @@ mod tests {
assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1) 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] #[test]
fn walkability_map_default_all_walkable() { fn walkability_map_default_all_walkable() {
let map = WalkabilityMap::new(10, 10, 1); let map = WalkabilityMap::new(10, 10, 1);
@@ -219,16 +340,14 @@ mod tests {
} }
#[test] #[test]
fn walkability_map_out_of_bounds_not_walkable() { fn walkability_map_unloaded_chunk_not_walkable() {
let map = WalkabilityMap::new(10, 10, 1); 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(-1, 0, 0)));
assert!(!map.can_move_to(&TilePosition::new(0, -1, 0))); assert!(!map.can_move_to(&TilePosition::new(0, -1, 0)));
// Over bounds // z=1 not loaded
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))); assert!(!map.can_move_to(&TilePosition::new(0, 0, 1)));
} }
@@ -250,14 +369,25 @@ mod tests {
map.set_walkable(&blocked_pos, false); map.set_walkable(&blocked_pos, false);
// z=1 is blocked
assert!(!map.can_move_to(&blocked_pos)); 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, 0)));
assert!(map.can_move_to(&TilePosition::new(5, 5, 2))); 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] #[test]
fn validate_movement_allows_walkable() { fn validate_movement_allows_walkable() {
let mut world = bevy_ecs::world::World::new(); let mut world = bevy_ecs::world::World::new();
@@ -276,13 +406,10 @@ mod tests {
schedule.add_systems(validate_movement); schedule.add_systems(validate_movement);
schedule.run(&mut world); schedule.run(&mut world);
// Position updated to target
assert_eq!( assert_eq!(
*world.get::<TilePosition>(entity).unwrap(), *world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 4, 0) TilePosition::new(5, 4, 0)
); );
// Intent removed
assert!(world.get::<MoveIntent>(entity).is_none()); assert!(world.get::<MoveIntent>(entity).is_none());
} }
@@ -306,18 +433,15 @@ mod tests {
schedule.add_systems(validate_movement); schedule.add_systems(validate_movement);
schedule.run(&mut world); schedule.run(&mut world);
// Position unchanged
assert_eq!( assert_eq!(
*world.get::<TilePosition>(entity).unwrap(), *world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 5, 0) TilePosition::new(5, 5, 0)
); );
// Intent removed
assert!(world.get::<MoveIntent>(entity).is_none()); assert!(world.get::<MoveIntent>(entity).is_none());
} }
#[test] #[test]
fn validate_movement_blocks_out_of_bounds() { fn validate_movement_blocks_unloaded_chunk() {
let mut world = bevy_ecs::world::World::new(); let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1)); world.insert_resource(WalkabilityMap::new(10, 10, 1));
@@ -334,13 +458,10 @@ mod tests {
schedule.add_systems(validate_movement); schedule.add_systems(validate_movement);
schedule.run(&mut world); schedule.run(&mut world);
// Position unchanged
assert_eq!( assert_eq!(
*world.get::<TilePosition>(entity).unwrap(), *world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(0, 0, 0) TilePosition::new(0, 0, 0)
); );
// Intent removed
assert!(world.get::<MoveIntent>(entity).is_none()); assert!(world.get::<MoveIntent>(entity).is_none());
} }
} }