New test_world room with two zones (Terminal/Corridor) separated by a door. Adds ZoneCrossEventQueue resource and detect_zone_crossings system to fire events when the player crosses zone boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
154 lines
4.8 KiB
Rust
154 lines
4.8 KiB
Rust
//! Zone map — spatial zone assignment for tiles (D-077, D-073).
|
|
//!
|
|
//! Maps tile coordinates to zone identifiers. Used by:
|
|
//! - Observer snapshot: enriches VisibleTile with zone_id
|
|
//! - Client AudioManager: zone crossfade triggers (D-073)
|
|
//! - Client fog shader: deep fog temperature tint (D-059 layer 3)
|
|
//! - Zone Gate Gauntlet room: crossing-event detection (QA #512)
|
|
//!
|
|
//! BTreeMap per D-010 principle 4 (deterministic iteration).
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use bevy_ecs::prelude::*;
|
|
|
|
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
|
|
|
/// Server-authoritative zone assignment for tiles.
|
|
///
|
|
/// Each tile position maps to a zone ID. Tiles outside any defined zone
|
|
/// (corridors, transition spaces) have no entry and return None.
|
|
///
|
|
/// Zone IDs are opaque u16 values — the client maintains its own
|
|
/// `zone_id → zone_name / temperature_tint / ambient_layer` mapping.
|
|
///
|
|
/// Production population path is TBD — currently populated only by
|
|
/// Gauntlet room builders via `set_rect` / `set`.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct ZoneMap {
|
|
zones: BTreeMap<(i32, i32, i32), u16>,
|
|
}
|
|
|
|
impl ZoneMap {
|
|
/// Look up the zone for a tile position.
|
|
pub fn zone_at(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
|
self.zones.get(&(x, y, z)).copied()
|
|
}
|
|
|
|
/// Assign a zone to a rectangular region of tiles.
|
|
/// Used by map builders to define zone boundaries.
|
|
pub fn set_rect(&mut self, ox: i32, oy: i32, w: i32, h: i32, z: i32, zone_id: u16) {
|
|
for y in oy..(oy + h) {
|
|
for x in ox..(ox + w) {
|
|
self.zones.insert((x, y, z), zone_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assign a zone to a single tile.
|
|
pub fn set(&mut self, x: i32, y: i32, z: i32, zone_id: u16) {
|
|
self.zones.insert((x, y, z), zone_id);
|
|
}
|
|
}
|
|
|
|
/// Event emitted when the player crosses a zone boundary (D-077).
|
|
///
|
|
/// Queued once per tick when the player's tile zone_id differs from the
|
|
/// previously recorded zone. `from` or `to` may be None for tiles outside
|
|
/// any defined zone (corridors, unzoned transitions).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ZoneCrossEvent {
|
|
/// Zone the player left (None = no zone on previous tile).
|
|
pub from: Option<u16>,
|
|
/// Zone the player entered (None = no zone on new tile).
|
|
pub to: Option<u16>,
|
|
}
|
|
|
|
/// Resource: queue of zone-crossing events (drained each tick by consumers).
|
|
#[derive(Resource, Default)]
|
|
pub struct ZoneCrossEventQueue {
|
|
pub events: Vec<ZoneCrossEvent>,
|
|
}
|
|
|
|
impl ZoneCrossEventQueue {
|
|
pub fn push(&mut self, event: ZoneCrossEvent) {
|
|
self.events.push(event);
|
|
}
|
|
|
|
pub fn drain(&mut self) -> Vec<ZoneCrossEvent> {
|
|
std::mem::take(&mut self.events)
|
|
}
|
|
}
|
|
|
|
/// Tracks the player's zone from the previous tick.
|
|
///
|
|
/// Used by `detect_zone_crossings` to compare against the current tick's
|
|
/// zone and emit a `ZoneCrossEvent` when they differ.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct PreviousPlayerZone(pub Option<u16>);
|
|
|
|
/// System: detect zone crossings and queue a `ZoneCrossEvent`.
|
|
///
|
|
/// Runs after `validate_movement` so the player position is current.
|
|
/// Compares the player's current tile zone (from `ZoneMap`) against
|
|
/// `PreviousPlayerZone`. Queues a `ZoneCrossEvent` on change and updates
|
|
/// the resource for the next tick.
|
|
pub fn detect_zone_crossings(
|
|
zone_map: Option<Res<ZoneMap>>,
|
|
mut previous_zone: ResMut<PreviousPlayerZone>,
|
|
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
|
mut queue: ResMut<ZoneCrossEventQueue>,
|
|
) {
|
|
let Some(zone_map) = zone_map else {
|
|
return;
|
|
};
|
|
let Ok(pos) = player_query.single() else {
|
|
return;
|
|
};
|
|
let current_zone = zone_map.zone_at(pos.x, pos.y, pos.z);
|
|
if current_zone != previous_zone.0 {
|
|
queue.push(ZoneCrossEvent {
|
|
from: previous_zone.0,
|
|
to: current_zone,
|
|
});
|
|
previous_zone.0 = current_zone;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn zone_at_returns_none_for_unset_tile() {
|
|
let map = ZoneMap::default();
|
|
assert_eq!(map.zone_at(10, 20, 0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn set_rect_populates_zone() {
|
|
let mut map = ZoneMap::default();
|
|
map.set_rect(5, 10, 3, 2, 0, 42);
|
|
|
|
// Inside rect
|
|
assert_eq!(map.zone_at(5, 10, 0), Some(42));
|
|
assert_eq!(map.zone_at(7, 11, 0), Some(42));
|
|
|
|
// Outside rect
|
|
assert_eq!(map.zone_at(4, 10, 0), None);
|
|
assert_eq!(map.zone_at(8, 10, 0), None);
|
|
assert_eq!(map.zone_at(5, 12, 0), None);
|
|
|
|
// Wrong z-level
|
|
assert_eq!(map.zone_at(5, 10, 1), None);
|
|
}
|
|
|
|
#[test]
|
|
fn set_single_tile() {
|
|
let mut map = ZoneMap::default();
|
|
map.set(3, 7, 0, 99);
|
|
assert_eq!(map.zone_at(3, 7, 0), Some(99));
|
|
assert_eq!(map.zone_at(3, 8, 0), None);
|
|
}
|
|
}
|