//! 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 { 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, /// Zone the player entered (None = no zone on new tile). pub to: Option, } /// Resource: queue of zone-crossing events (drained each tick by consumers). #[derive(Resource, Default)] pub struct ZoneCrossEventQueue { pub events: Vec, } impl ZoneCrossEventQueue { pub fn push(&mut self, event: ZoneCrossEvent) { self.events.push(event); } pub fn drain(&mut self) -> Vec { 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); /// 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>, mut previous_zone: ResMut, player_query: Query<&TilePosition, With>, mut queue: ResMut, ) { 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); } }