feat(simulation): add Zone Gate gauntlet room and zone crossing detection (#512)

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>
This commit is contained in:
2026-02-28 16:34:21 +01:00
co-authored by Claude Opus 4.6
parent 61eb40fcab
commit daae3dd6ab
6 changed files with 501 additions and 3 deletions
+87 -1
View File
@@ -16,7 +16,7 @@
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13):
//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13 + Sprint 22):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8
@@ -35,6 +35,8 @@
//! Decay Observatory: 69
//! Shift Change: 70-71
//! Reset plates (Sprint 13 rooms): 72-74
//! Zone Gate: 75 (door entity)
//! Reset plates (Sprint 22 rooms): 76
#[cfg(feature = "gauntlet")]
pub mod constants;
@@ -113,6 +115,8 @@ pub fn setup_gauntlet(app: &mut App) {
carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab
carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory
carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change
// Sprint 22 rooms
carve_room_interior(&mut walkability, 64, 102, 16, 22); // Zone Gate
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
@@ -144,8 +148,13 @@ pub fn setup_gauntlet(app: &mut App) {
// Zone map: assign zone IDs per room bounding box (D-077, D-073).
// Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None).
// Exception: Zone Gate (index 14) uses two explicit zone IDs for its dual-zone split.
let mut zone_map = ZoneMap::default();
for (i, room) in constants::ROOMS.iter().enumerate() {
// Zone Gate handled separately below — skip in the sequential loop.
if room.name == "zone_gate" {
continue;
}
let zone_id = i as u16;
zone_map.set_rect(
room.origin.x,
@@ -156,6 +165,23 @@ pub fn setup_gauntlet(app: &mut App) {
zone_id,
);
}
// Zone Gate: Terminal side (left 8 cols, x=64-71) and Corridor side (right 8 cols, x=72-79).
zone_map.set_rect(
constants::ZONE_GATE.origin.x,
constants::ZONE_GATE.origin.y,
constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.size.1,
constants::ZONE_GATE.origin.z,
constants::ZONE_GATE_TERMINAL_ZONE_ID,
);
zone_map.set_rect(
constants::ZONE_GATE.origin.x + constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.origin.y,
constants::ZONE_GATE.size.0 / 2,
constants::ZONE_GATE.size.1,
constants::ZONE_GATE.origin.z,
constants::ZONE_GATE_CORRIDOR_ZONE_ID,
);
app.insert_resource(zone_map);
// Entity spawning in canonical StableId order.
@@ -333,6 +359,9 @@ pub fn setup_gauntlet(app: &mut App) {
}
// --- Sprint 13 reset plates (StableId 72-74) ---
// NOTE: Sprint 22 reset plate (zone_gate) is spawned AFTER these to maintain additive order.
// The sprint13 block is numbered 72-74; zone_gate entities are 75; sprint22 plate is 76.
// The spawn order below matches the StableId allocation table in the module doc.
let sprint13_reset_plates: &[(&str, TilePosition)] = &[
(
"sound_lab",
@@ -370,6 +399,34 @@ pub fn setup_gauntlet(app: &mut App) {
.insert(StableEntityId(sid));
}
// --- Zone Gate (StableId 75) ---
// Spawned AFTER Sprint 13 reset plates so it gets ID 75 per the allocation table.
rooms::zone_gate::spawn_entities(app, &mut registry);
// --- Sprint 22 reset plates (StableId 76) ---
let sprint22_reset_plates: &[(&str, TilePosition)] = &[(
"zone_gate",
constants::ZONE_GATE
.reset_plate
.expect("zone_gate should have a reset_plate"),
)];
for &(room_name, pos) in sprint22_reset_plates {
let entity = app
.world_mut()
.spawn((
Interactable,
RoomResetTrigger {
room_name: room_name.to_string(),
},
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
@@ -495,6 +552,15 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Zone Gate entities (StableId 75): door only, no floor items
for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("zone_gate", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
// --- Sprint 14 component fixup ---
@@ -864,5 +930,25 @@ mod tests {
id
);
}
// Zone Gate at 75
for id in constants::ZONE_GATE_STABLE_IDS.0..=constants::ZONE_GATE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Zone Gate at StableId {}",
id
);
}
// Sprint 22 reset plate at 76
for id in constants::SPRINT22_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT22_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 22 reset plate at StableId {}",
id
);
}
}
}