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