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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user