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
// 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<u32> {
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<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)]
pub struct WalkabilityMap {
width: i32,
height: i32,
z_levels: i32,
tiles: Vec<bool>, // true = walkable, false = blocked
chunks: HashMap<ChunkCoord, ChunkData>,
}
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<Res<WalkabilityMap>>,
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::<MoveIntent>();
}
@@ -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::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 4, 0)
);
// Intent removed
assert!(world.get::<MoveIntent>(entity).is_none());
}
@@ -306,18 +433,15 @@ mod tests {
schedule.add_systems(validate_movement);
schedule.run(&mut world);
// Position unchanged
assert_eq!(
*world.get::<TilePosition>(entity).unwrap(),
TilePosition::new(5, 5, 0)
);
// Intent removed
assert!(world.get::<MoveIntent>(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::<TilePosition>(entity).unwrap(),
TilePosition::new(0, 0, 0)
);
// Intent removed
assert!(world.get::<MoveIntent>(entity).is_none());
}
}