feat(simulation): add tile-type layer, content loader, and chunk streaming
Spatial chain for Sprint 23 (#576, #577, #578): - TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with set_tile_kind/tile_kind API, backward-compatible with existing is_walkable/set_walkable - Location YAML tile format: tiles as string arrays (F/W/V/R chars), load_location_tiles() stamps tile data onto WalkabilityMap from ContentStore on production startup - Chunk streaming system: ChunkLoadRadius + ChunkStreamingCadence resources, loads/unloads chunks around player position on cadence. v0.1 radius covers full district (no streaming stutter) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// Tile-based movement and collision system
|
||||
// Implements Sprint 1 ticket #236: walkability map and movement validation
|
||||
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
|
||||
// Extended by #576: TileKind layer per tile (server-authoritative tile classification)
|
||||
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
|
||||
// Y-down convention: North = y-1, South = y+1
|
||||
|
||||
@@ -16,6 +17,30 @@ use crate::simulation::stance::Stance;
|
||||
/// Chunk size in tiles (32x32 per chunk)
|
||||
pub const CHUNK_SIZE: i32 = 32;
|
||||
|
||||
/// Server-side authoritative tile classification (#576, D-012).
|
||||
///
|
||||
/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering)
|
||||
/// but serves a different purpose: simulation logic and tile authoring.
|
||||
///
|
||||
/// Tile format for location YAML authoring (#577):
|
||||
/// - `F` = Floor (walkable, open space)
|
||||
/// - `W` = Wall (solid obstacle, blocks movement and LOS)
|
||||
/// - `V` = Void (out-of-bounds / unloaded; treated as blocked)
|
||||
/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TileKind {
|
||||
/// Walkable floor tile. Default for populated chunks.
|
||||
#[default]
|
||||
Floor,
|
||||
/// Solid wall tile. Blocks movement and line-of-sight.
|
||||
Wall,
|
||||
/// Void / unloaded tile. Out-of-bounds or ungenerated space.
|
||||
Void,
|
||||
/// Restricted tile. Blocked for standard movement but accessible
|
||||
/// to authorised entities (e.g. locked zones, maintenance airlocks).
|
||||
Restricted,
|
||||
}
|
||||
|
||||
/// Marker component identifying the player-controlled entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PlayerCharacter;
|
||||
@@ -121,7 +146,7 @@ impl TilePosition {
|
||||
}
|
||||
|
||||
/// Get the chunk coordinate this tile belongs to.
|
||||
fn chunk_coord(&self) -> ChunkCoord {
|
||||
pub fn chunk_coord(&self) -> ChunkCoord {
|
||||
ChunkCoord {
|
||||
cx: self.x.div_euclid(CHUNK_SIZE),
|
||||
cy: self.y.div_euclid(CHUNK_SIZE),
|
||||
@@ -143,22 +168,43 @@ pub struct ChunkCoord {
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||
/// Per-tile storage cell: walkability flag and tile classification.
|
||||
/// Private — exposed only through WalkabilityMap's public API.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TileCell {
|
||||
walkable: bool,
|
||||
kind: TileKind,
|
||||
}
|
||||
|
||||
/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||
/// Extended by #576 to carry TileKind alongside the walkability bool.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChunkData {
|
||||
tiles: Vec<bool>, // CHUNK_SIZE * CHUNK_SIZE, true = walkable
|
||||
tiles: Vec<TileCell>,
|
||||
}
|
||||
|
||||
impl ChunkData {
|
||||
fn new_walkable() -> Self {
|
||||
Self {
|
||||
tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
tiles: vec![
|
||||
TileCell {
|
||||
walkable: true,
|
||||
kind: TileKind::Floor
|
||||
};
|
||||
(CHUNK_SIZE * CHUNK_SIZE) as usize
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn new_blocked() -> Self {
|
||||
Self {
|
||||
tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
tiles: vec![
|
||||
TileCell {
|
||||
walkable: false,
|
||||
kind: TileKind::Wall
|
||||
};
|
||||
(CHUNK_SIZE * CHUNK_SIZE) as usize
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +212,24 @@ impl ChunkData {
|
||||
(ly * CHUNK_SIZE + lx) as usize
|
||||
}
|
||||
|
||||
/// Returns the walkability flag for a tile (backward-compatible internal accessor).
|
||||
fn get(&self, lx: i32, ly: i32) -> bool {
|
||||
self.tiles[Self::index(lx, ly)]
|
||||
self.tiles[Self::index(lx, ly)].walkable
|
||||
}
|
||||
|
||||
/// Sets only the walkability flag; tile kind is unchanged.
|
||||
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
|
||||
self.tiles[Self::index(lx, ly)] = walkable;
|
||||
self.tiles[Self::index(lx, ly)].walkable = walkable;
|
||||
}
|
||||
|
||||
/// Returns the tile kind for a cell.
|
||||
fn get_kind(&self, lx: i32, ly: i32) -> TileKind {
|
||||
self.tiles[Self::index(lx, ly)].kind
|
||||
}
|
||||
|
||||
/// Sets the tile kind for a cell; walkability is unchanged.
|
||||
fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) {
|
||||
self.tiles[Self::index(lx, ly)].kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +284,7 @@ impl WalkabilityMap {
|
||||
}
|
||||
|
||||
/// Set walkability of a tile. Creates the chunk if it doesn't exist.
|
||||
/// Does not change the tile's TileKind.
|
||||
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
|
||||
let coord = pos.chunk_coord();
|
||||
let (lx, ly) = pos.local_offset();
|
||||
@@ -236,6 +295,27 @@ impl WalkabilityMap {
|
||||
chunk.set(lx, ly, walkable);
|
||||
}
|
||||
|
||||
/// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks.
|
||||
pub fn tile_kind(&self, pos: &TilePosition) -> TileKind {
|
||||
let coord = pos.chunk_coord();
|
||||
let (lx, ly) = pos.local_offset();
|
||||
self.chunks
|
||||
.get(&coord)
|
||||
.map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly))
|
||||
}
|
||||
|
||||
/// Set the tile kind at a position. Creates the chunk if it doesn't exist.
|
||||
/// Does not change the tile's walkability.
|
||||
pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) {
|
||||
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_kind(lx, ly, kind);
|
||||
}
|
||||
|
||||
/// Check if a chunk is loaded.
|
||||
pub fn has_chunk(&self, coord: &ChunkCoord) -> bool {
|
||||
self.chunks.contains_key(coord)
|
||||
@@ -259,6 +339,12 @@ impl WalkabilityMap {
|
||||
pub fn chunk_count(&self) -> usize {
|
||||
self.chunks.len()
|
||||
}
|
||||
|
||||
/// Returns the coordinates of all loaded chunks (#578).
|
||||
/// Used by the chunk streaming system to determine which chunks to unload.
|
||||
pub fn loaded_chunk_coords(&self) -> Vec<ChunkCoord> {
|
||||
self.chunks.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Component representing an intent to move to a target tile.
|
||||
@@ -366,6 +452,89 @@ pub fn validate_movement(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TileKind layer tests (#576)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tile_kind_default_is_floor_for_walkable_chunk() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_unloaded_chunk_returns_void() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
// Negative coords → unloaded chunk → Void
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void);
|
||||
// z-level not loaded → Void
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_round_trip() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let pos = TilePosition::new(5, 5, 0);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Restricted);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Restricted);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Void);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_independent_of_walkability() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let pos = TilePosition::new(3, 3, 0);
|
||||
|
||||
// Start: Floor + walkable
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
assert!(map.can_move_to(&pos));
|
||||
|
||||
// Set walkable = false; kind should remain Floor
|
||||
map.set_walkable(&pos, false);
|
||||
assert!(!map.can_move_to(&pos));
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
|
||||
// Set kind = Wall; walkability should remain false
|
||||
map.set_tile_kind(&pos, TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
assert!(!map.can_move_to(&pos));
|
||||
|
||||
// Restore walkable = true; kind should stay Wall
|
||||
map.set_walkable(&pos, true);
|
||||
assert!(map.can_move_to(&pos));
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_set_creates_chunk_on_demand() {
|
||||
let mut map = WalkabilityMap::new(1, 1, 1);
|
||||
let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk
|
||||
|
||||
assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
map.set_tile_kind(&new_chunk_pos, TileKind::Restricted);
|
||||
assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_chunk_default_kind_is_wall() {
|
||||
let map = WalkabilityMap::new_blocked(32, 32, 1);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_position_equality() {
|
||||
let pos1 = TilePosition::new(5, 10, 0);
|
||||
|
||||
Reference in New Issue
Block a user