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:
@@ -327,6 +327,117 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
|
||||
}
|
||||
|
||||
/// Check if a YAML file contains only comments and whitespace (stub file).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile loading (#577)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
|
||||
|
||||
/// Parse a tile character into a TileKind.
|
||||
/// Returns `None` for unrecognized characters.
|
||||
fn parse_tile_char(ch: char) -> Option<TileKind> {
|
||||
match ch {
|
||||
'F' => Some(TileKind::Floor),
|
||||
'W' => Some(TileKind::Wall),
|
||||
'V' => Some(TileKind::Void),
|
||||
'R' => Some(TileKind::Restricted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
|
||||
///
|
||||
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
|
||||
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
|
||||
///
|
||||
/// Logs warnings for:
|
||||
/// - Row count mismatch vs tile_bounds height
|
||||
/// - Column count mismatch vs tile_bounds width
|
||||
/// - Unrecognized tile characters
|
||||
///
|
||||
/// Returns the number of locations that had tile data applied.
|
||||
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
|
||||
let mut locations_loaded = 0u32;
|
||||
|
||||
for (_district_id, district) in &store.districts {
|
||||
for location in &district.locations {
|
||||
if apply_location_tiles(location, walkability) {
|
||||
locations_loaded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locations_loaded
|
||||
}
|
||||
|
||||
/// Apply tile data from a single Location to the WalkabilityMap.
|
||||
/// Returns true if tiles were applied, false if skipped.
|
||||
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
|
||||
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
|
||||
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
|
||||
|
||||
if tiles.len() != expected_height {
|
||||
tracing::warn!(
|
||||
"Location '{}': tile row count {} != expected height {} (from tile_bounds)",
|
||||
location.canonical_id,
|
||||
tiles.len(),
|
||||
expected_height,
|
||||
);
|
||||
}
|
||||
|
||||
for (row_idx, row) in tiles.iter().enumerate() {
|
||||
let y = bounds.y_min + row_idx as i32;
|
||||
|
||||
if row.len() != expected_width {
|
||||
tracing::warn!(
|
||||
"Location '{}' row {}: length {} != expected width {}",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
row.len(),
|
||||
expected_width,
|
||||
);
|
||||
}
|
||||
|
||||
for (col_idx, ch) in row.chars().enumerate() {
|
||||
let x = bounds.x_min + col_idx as i32;
|
||||
let pos = TilePosition::new(x, y, bounds.z);
|
||||
|
||||
match parse_tile_char(ch) {
|
||||
Some(kind) => {
|
||||
let walkable = matches!(kind, TileKind::Floor);
|
||||
walkability.set_walkable(&pos, walkable);
|
||||
walkability.set_tile_kind(&pos, kind);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Location '{}' row {} col {}: unrecognized tile char '{}'",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
col_idx,
|
||||
ch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
|
||||
location.canonical_id,
|
||||
expected_width,
|
||||
expected_height,
|
||||
bounds.x_min,
|
||||
bounds.y_min,
|
||||
bounds.z,
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_comment_only_file(path: &Path) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return false;
|
||||
@@ -618,4 +729,193 @@ pools:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tile loading tests (#577)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
|
||||
Location {
|
||||
canonical_id: "test-loc".to_string(),
|
||||
display_name: "Test Location".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0,
|
||||
y_min: 0,
|
||||
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
|
||||
y_max: tiles.len() as i32 - 1,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tile_char_all_kinds() {
|
||||
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
|
||||
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
|
||||
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
|
||||
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
|
||||
assert_eq!(parse_tile_char('X'), None);
|
||||
assert_eq!(parse_tile_char(' '), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_walkability() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWF",
|
||||
"FFF",
|
||||
"WFW",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
|
||||
let applied = apply_location_tiles(&loc, &mut map);
|
||||
assert!(applied);
|
||||
|
||||
// Row 0: F W F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
|
||||
|
||||
// Row 1: F F F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
|
||||
|
||||
// Row 2: W F W
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_tile_kind() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWVR",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 1, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_with_offset() {
|
||||
let loc = Location {
|
||||
canonical_id: "offset-loc".to_string(),
|
||||
display_name: "Offset".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 10,
|
||||
y_min: 20,
|
||||
x_max: 12,
|
||||
y_max: 21,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(32, 32, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
// (10,20) = F, (11,20) = W, (12,20) = F
|
||||
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
|
||||
|
||||
// (10,21) = W, (11,21) = F, (12,21) = W
|
||||
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_tiles() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-tiles".to_string(),
|
||||
display_name: "No Tiles".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
|
||||
}),
|
||||
tiles: None,
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_bounds() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-bounds".to_string(),
|
||||
display_name: "No Bounds".to_string(),
|
||||
description: None,
|
||||
tile_bounds: None,
|
||||
tiles: Some(vec!["FFF".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_location_tiles_from_store() {
|
||||
let mut store = ContentStore::default();
|
||||
let mut district = DistrictContent::default();
|
||||
district.locations.push(make_location_with_tiles(vec![
|
||||
"FW",
|
||||
"WF",
|
||||
]));
|
||||
store.districts.insert("test".to_string(), district);
|
||||
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
let count = load_location_tiles(&store, &mut map);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 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(1, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_yaml_with_tiles_deserializes() {
|
||||
let yaml = r#"
|
||||
canonical_id: test-room
|
||||
display_name: "Test Room"
|
||||
tile_bounds:
|
||||
x_min: 5
|
||||
y_min: 10
|
||||
x_max: 9
|
||||
y_max: 12
|
||||
z: 0
|
||||
tiles:
|
||||
- "FFFFF"
|
||||
- "FWWWF"
|
||||
- "FFFFF"
|
||||
"#;
|
||||
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
|
||||
assert_eq!(loc.canonical_id, "test-room");
|
||||
assert!(loc.tiles.is_some());
|
||||
let tiles = loc.tiles.unwrap();
|
||||
assert_eq!(tiles.len(), 3);
|
||||
assert_eq!(tiles[0], "FFFFF");
|
||||
assert_eq!(tiles[1], "FWWWF");
|
||||
assert_eq!(tiles[2], "FFFFF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,15 @@ fn load_and_spawn_content(world: &mut World) {
|
||||
let result = spawn::spawn_content(world, &store);
|
||||
tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned);
|
||||
|
||||
// Stamp location tile data onto WalkabilityMap (#577)
|
||||
if world.contains_resource::<crate::simulation::movement::WalkabilityMap>() {
|
||||
let mut walkability = world.resource_mut::<crate::simulation::movement::WalkabilityMap>();
|
||||
let tiles_loaded = loader::load_location_tiles(&store, &mut walkability);
|
||||
if tiles_loaded > 0 {
|
||||
tracing::info!("Loaded tile data for {} locations", tiles_loaded);
|
||||
}
|
||||
}
|
||||
|
||||
// Build line pool index
|
||||
let index = line_pool::LinePoolIndex::build(&store);
|
||||
tracing::info!(
|
||||
|
||||
@@ -394,6 +394,20 @@ pub struct Location {
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tile_bounds: Option<TileBounds>,
|
||||
/// Tile layout for this location (#577).
|
||||
///
|
||||
/// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y.
|
||||
/// Each character maps to a server-side TileKind:
|
||||
/// `F` = Floor (walkable, open space)
|
||||
/// `W` = Wall (solid obstacle, blocks movement and LOS)
|
||||
/// `V` = Void (out-of-bounds / unloaded)
|
||||
/// `R` = Restricted (blocked but traversable by specific entities)
|
||||
///
|
||||
/// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`.
|
||||
/// Requires `tile_bounds` to be set. Row count must equal
|
||||
/// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`.
|
||||
#[serde(default)]
|
||||
pub tiles: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sightlines: Option<Sightlines>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Chunk streaming system (#578, D-012).
|
||||
//!
|
||||
//! Loads chunks near the player and unloads distant chunks based on a
|
||||
//! configurable radius. For v0.1 the radius covers the entire hand-authored
|
||||
//! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks
|
||||
//! remain loaded. The architecture supports future per-demand loading (v0.3+).
|
||||
//!
|
||||
//! The system runs on a configurable tick cadence (default: every 10 ticks).
|
||||
//! It queries the player's TilePosition, computes which chunks should be
|
||||
//! loaded (Chebyshev distance ≤ radius from the player's chunk), and
|
||||
//! loads/unloads accordingly.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// How many chunks around the player to keep loaded (Chebyshev distance).
|
||||
///
|
||||
/// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks).
|
||||
/// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ChunkLoadRadius {
|
||||
pub radius: i32,
|
||||
}
|
||||
|
||||
impl Default for ChunkLoadRadius {
|
||||
fn default() -> Self {
|
||||
Self { radius: 8 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the streaming system runs, in simulation ticks.
|
||||
///
|
||||
/// Default: 10 ticks (one game-minute at D-031 cadence).
|
||||
/// Lower values increase responsiveness but add per-tick overhead.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ChunkStreamingCadence {
|
||||
pub ticks: u64,
|
||||
}
|
||||
|
||||
impl Default for ChunkStreamingCadence {
|
||||
fn default() -> Self {
|
||||
Self { ticks: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk streaming system — loads/unloads chunks around the player position.
|
||||
///
|
||||
/// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks).
|
||||
/// Computes the set of chunks within `ChunkLoadRadius` of the player's
|
||||
/// current chunk (Chebyshev distance), loads missing chunks, and unloads
|
||||
/// chunks that are now out of range.
|
||||
///
|
||||
/// For v0.1, radius=8 covers the entire district so nothing ever unloads.
|
||||
/// For v0.3+, the generator fills newly loaded chunks with terrain data.
|
||||
pub fn chunk_streaming(
|
||||
time: Res<SimulationTime>,
|
||||
cadence: Res<ChunkStreamingCadence>,
|
||||
radius: Res<ChunkLoadRadius>,
|
||||
walkability: Option<ResMut<WalkabilityMap>>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Some(mut walkability) = walkability else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Cadence gate — only run every N ticks
|
||||
if cadence.ticks > 0 && time.tick % cadence.ticks != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(player_pos) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let player_chunk = player_pos.chunk_coord();
|
||||
let r = radius.radius;
|
||||
|
||||
// Load chunks within radius that aren't already loaded
|
||||
let mut loaded = 0u32;
|
||||
for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) {
|
||||
for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) {
|
||||
let coord = ChunkCoord {
|
||||
cx,
|
||||
cy,
|
||||
z: player_chunk.z,
|
||||
};
|
||||
if walkability.load_chunk(coord) {
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unload chunks outside radius
|
||||
let mut unloaded = 0u32;
|
||||
let to_check = walkability.loaded_chunk_coords();
|
||||
for coord in to_check {
|
||||
// Only manage chunks on the player's z-level
|
||||
if coord.z != player_chunk.z {
|
||||
continue;
|
||||
}
|
||||
let dx = (coord.cx - player_chunk.cx).abs();
|
||||
let dy = (coord.cy - player_chunk.cy).abs();
|
||||
if dx > r || dy > r {
|
||||
walkability.unload_chunk(&coord);
|
||||
unloaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if loaded > 0 || unloaded > 0 {
|
||||
tracing::debug!(
|
||||
"Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})",
|
||||
loaded,
|
||||
unloaded,
|
||||
player_chunk.cx,
|
||||
player_chunk.cy,
|
||||
player_chunk.z,
|
||||
r,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
|
||||
fn setup_streaming_world(
|
||||
radius: i32,
|
||||
cadence: u64,
|
||||
player_pos: TilePosition,
|
||||
map_width: i32,
|
||||
map_height: i32,
|
||||
) -> (World, Schedule) {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(ChunkLoadRadius { radius });
|
||||
world.insert_resource(ChunkStreamingCadence { ticks: cadence });
|
||||
world.insert_resource(WalkabilityMap::new(map_width, map_height, 1));
|
||||
world.spawn((PlayerCharacter, player_pos));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(chunk_streaming);
|
||||
(world, schedule)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_radius_covers_v01_district() {
|
||||
// 256×256 visual district = 8×8 chunks of 32 tiles each.
|
||||
// Player at center (128, 128). Default radius=8.
|
||||
// All original 64 chunks should still be loaded after streaming runs
|
||||
// (streaming may also create empty chunks beyond the district boundary).
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
8,
|
||||
1, // run every tick
|
||||
TilePosition::new(128, 128, 0),
|
||||
256,
|
||||
256,
|
||||
);
|
||||
|
||||
// Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 64);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// All original chunks (0..8, 0..8) must still be loaded — nothing unloaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
for cx in 0..8 {
|
||||
for cy in 0..8 {
|
||||
assert!(
|
||||
wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }),
|
||||
"chunk ({},{}) should still be loaded",
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Total count ≥ 64 (streaming may also load chunks beyond district boundary)
|
||||
assert!(wm.chunk_count() >= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_radius_unloads_distant_chunks() {
|
||||
// Start with a 5×5 chunk map (160×160 tiles), player at center.
|
||||
// Use radius=1 so only 3×3=9 chunks around the player are kept.
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
1,
|
||||
TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// Initial: 5×5 = 25 chunks
|
||||
let initial_count = world.resource::<WalkabilityMap>().chunk_count();
|
||||
assert_eq!(initial_count, 25);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// After streaming: only 3×3 = 9 chunks around player chunk (2,2)
|
||||
let after_count = world.resource::<WalkabilityMap>().chunk_count();
|
||||
assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1");
|
||||
|
||||
// Verify the player's chunk is still loaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
// Corner chunks should be unloaded
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_movement_loads_new_chunks() {
|
||||
// Start with radius=1, player at (16, 16) → chunk (0,0).
|
||||
// Map is 3×3 chunks (96×96 tiles).
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
1,
|
||||
TilePosition::new(16, 16, 0), // chunk (0,0)
|
||||
96,
|
||||
96,
|
||||
);
|
||||
|
||||
// Run streaming — unloads distant chunks
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded
|
||||
// (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1],
|
||||
// but negative coords weren't in the original map.
|
||||
// So loaded: (0,0) and its positive neighbors within range)
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
|
||||
// Chunk (2,2) should be unloaded (distance > 1 from (0,0))
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
|
||||
// Move player to chunk (2,2)
|
||||
let mut q = world.query_filtered::<&mut TilePosition, With<PlayerCharacter>>();
|
||||
let mut pos = q.single_mut(&mut world).unwrap();
|
||||
pos.x = 80;
|
||||
pos.y = 80;
|
||||
|
||||
// Advance tick so cadence gate passes
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Now chunk (2,2) and its neighbors should be loaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 }));
|
||||
// And chunk (0,0) should now be unloaded (distance 2 from (2,2))
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadence_gate_skips_intermediate_ticks() {
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
10, // run every 10 ticks
|
||||
TilePosition::new(80, 80, 0),
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// tick=0 → runs (0 % 10 == 0)
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
|
||||
// Reload all chunks to simulate "something loads chunks back"
|
||||
world.insert_resource(WalkabilityMap::new(160, 160, 1));
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 25);
|
||||
|
||||
// tick=5 → should NOT run (5 % 10 != 0)
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(
|
||||
world.resource::<WalkabilityMap>().chunk_count(),
|
||||
25,
|
||||
"should not have run at tick 5"
|
||||
);
|
||||
|
||||
// tick=10 → should run (10 % 10 == 0)
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cadence_runs_every_tick() {
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
0, // cadence=0 means run every tick
|
||||
TilePosition::new(80, 80, 0),
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_z_levels_untouched() {
|
||||
// Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk).
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(ChunkLoadRadius { radius: 0 });
|
||||
world.insert_resource(ChunkStreamingCadence { ticks: 1 });
|
||||
|
||||
// 2×2 chunks on 2 z-levels = 8 chunks total
|
||||
world.insert_resource(WalkabilityMap::new(64, 64, 2));
|
||||
world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(chunk_streaming);
|
||||
|
||||
// Before: 8 chunks (2×2×2)
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 8);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4)
|
||||
// Total: 1 + 4 = 5
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
|
||||
// z=1 chunks all still there
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 }));
|
||||
assert_eq!(wm.chunk_count(), 5);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod chunk_streaming;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
@@ -50,6 +51,8 @@ impl Plugin for SimulationPlugin {
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.init_resource::<sound::SoundEventQueue>()
|
||||
.init_resource::<spatial::NaiveSpatialIndex>()
|
||||
.init_resource::<chunk_streaming::ChunkLoadRadius>()
|
||||
.init_resource::<chunk_streaming::ChunkStreamingCadence>()
|
||||
.init_resource::<follow::FollowEndEventQueue>()
|
||||
.init_resource::<monologue::PostConversationQueue>()
|
||||
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
|
||||
@@ -127,6 +130,12 @@ impl Plugin for SimulationPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
zone::detect_zone_crossings.after(movement::validate_movement),
|
||||
)
|
||||
// Chunk streaming (#578, D-012) — loads/unloads chunks around the player.
|
||||
// Runs before input processing so chunks are available for the current tick.
|
||||
.add_systems(
|
||||
Update,
|
||||
chunk_streaming::chunk_streaming.before(input::process_player_input),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
|
||||
@@ -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