pyre_env / server /static /viewer_rpg.html
Akshaykumarbm's picture
Upload folder using huggingface_hub
443c22e verified
i <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>PYRE — OpenEnv Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #09090f;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
font-family: 'Press Start 2P', monospace;
background-image:
radial-gradient(ellipse at 15% 25%, rgba(50,15,70,0.35) 0%, transparent 55%),
radial-gradient(ellipse at 85% 75%, rgba(80,15,5,0.3) 0%, transparent 55%);
}
.outer { display: flex; gap: 16px; align-items: flex-start; }
/* ── GBA screen frame ─────────────────────── */
.screen-shell {
background: linear-gradient(160deg,#1e1e2e,#14141e);
border: 3px solid #2a2a40;
border-radius: 12px;
padding: 12px 12px 8px;
box-shadow: 0 0 0 1px #080810, 0 0 35px rgba(70,40,160,.28),
inset 0 1px 0 rgba(255,255,255,.04);
}
.screen-title {
text-align: center;
font-size: 8px;
letter-spacing: 2px;
color: #f03020;
padding-bottom: 9px;
text-shadow: 0 0 14px rgba(240,70,10,.9), 0 0 35px rgba(240,30,5,.45);
}
.screen-bezel {
border: 3px solid #080810;
border-radius: 4px;
line-height: 0;
box-shadow: inset 0 0 16px rgba(0,0,0,.85);
position: relative;
}
#map { display: block; image-rendering: pixelated; }
/* ── HUD ────────────────────────────────────── */
.hud {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 4px 2px; border-top: 1px solid #1c1c2e; margin-top: 7px;
}
.hud-item { font-size: 6px; letter-spacing: 1px; }
.hud-hp { color: #f06070; }
.hud-step { color: #60b0f0; }
.hud-act { color: #a0d060; }
/* ── Dialog ─────────────────────────────────── */
.dialog {
margin-top: 7px; background: #0e0e18;
border: 2px solid #252538; border-radius: 4px;
padding: 8px 11px; font-size: 6px; line-height: 2.3;
color: #d0d0e8; min-height: 48px; position: relative;
}
.dialog-who { font-size: 6px; color: #f8c030; display: block; margin-bottom: 4px; }
.dialog::after {
content: '▼'; position: absolute; right: 7px; bottom: 4px;
font-size: 5px; color: #505090; animation: blink 1.1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* ── Right panel ─────────────────────────────── */
.panel { width: 320px; display: flex; flex-direction: column; gap: 9px; }
.card {
background: linear-gradient(160deg,#12121c,#0e0e16);
border: 2px solid #1e1e30; border-radius: 6px; padding: 9px 11px;
}
.card-title {
font-size: 6px; color: #484888; letter-spacing: 1px;
margin-bottom: 7px; padding-bottom: 4px;
border-bottom: 1px solid #181828;
}
.srow { display:flex; justify-content:space-between; font-size:6px; color:#7070a0; padding:2px 0; }
.sv { color:#70d870; }
.sv.warn { color:#f0c030; }
.sv.hot { color:#f07030; }
.sv.danger { color:#f03050; }
.sv.blue { color:#50a8f0; }
.sv.gray { color:#606080; }
.bar-w { margin: 5px 0 2px; }
.bar-lbl { font-size:5px; color:#505080; margin-bottom:3px; display:flex; justify-content:space-between; }
.bar-bg { background:#080810; border:1px solid #1a1a30; border-radius:2px; height:5px; overflow:hidden; }
.bar-fill{ height:100%; background:linear-gradient(90deg,#f03050,#a0e050); border-radius:2px; }
/* door status list */
.door-row {
display:flex; align-items:center; gap:5px;
font-size:5px; color:#7070a0; padding:2.5px 0;
border-bottom:1px solid #111120;
}
.door-row:last-child { border-bottom:none; }
.door-id { color:#6060e0; min-width:45px; }
.d-open { color:#40c860; margin-left:auto; }
.d-closed { color:#c08030; margin-left:auto; }
.d-failed { color:#f03040; margin-left:auto; }
/* legend */
.leg-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px 8px; }
.leg-item { display:flex; align-items:center; gap:5px; font-size:5px; color:#6060a0; }
.leg-sw { width:13px; height:13px; border-radius:2px; border:1px solid rgba(255,255,255,.07); flex-shrink:0; }
/* controls */
.ctrl-grid { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
.ctrl-btn {
background:#1a1a2e; color:#b8c8ff; border:1px solid #2a2a40;
border-radius:4px; font-size:6px; padding:6px 4px; cursor:pointer;
font-family:'Press Start 2P', monospace;
}
.ctrl-btn:hover { background:#23233a; }
.ctrl-btn.wide { grid-column:1 / -1; }
.ctrl-input {
background:#121a2a; color:#c8d8ff; border:1px solid #2a2a40;
border-radius:4px; font-size:6px; padding:6px 4px;
font-family:'Press Start 2P', monospace; width:100%;
}
.ctrl-status { margin-top:6px; font-size:5px; color:#8a98cc; line-height:1.6; min-height:16px; }
.report-box {
background:#090f1b;
border:1px solid #2a2a40;
border-radius:4px;
padding:6px;
max-height:170px;
overflow:auto;
white-space:pre-wrap;
word-break:break-word;
font-size:5px;
line-height:1.6;
color:#cfe0ff;
}
.report-actions { display:grid; grid-template-columns:1fr 1fr; gap:5px; margin-top:6px; }
.report-meta { font-size:5px; color:#8ea2d8; margin-bottom:6px; line-height:1.6; }
::-webkit-scrollbar { width:3px; }
::-webkit-scrollbar-track { background:#0a0a14; }
::-webkit-scrollbar-thumb { background:#282848; }
</style>
</head>
<body>
<div class="outer">
<!-- MAIN SCREEN -->
<div>
<div class="screen-shell">
<div class="screen-title">★ PYRE — EVACUATION PROTOCOL ★</div>
<div class="screen-bezel">
<canvas id="map" width="576" height="576"></canvas>
</div>
<div class="hud">
<div class="hud-item hud-hp" id="hud-hp">♥ ♥ ♥ ♥ ♥&nbsp;HP</div>
<div class="hud-item hud-step" id="hud-step">STEP: 0 / 0</div>
<div class="hud-item hud-act" id="hud-act">LAST: RESET</div>
<div class="hud-item" id="hud-moves" style="color:#f8c030">MOVES: 0</div>
</div>
<div class="dialog">
<span class="dialog-who">AGENT REPORT</span>
<span id="dialog-text">Click RESET LIVE to connect this viewer to the running server.</span>
</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="panel">
<div class="card">
<div class="card-title">◆ LIVE CONTROLS</div>
<div class="ctrl-grid">
<button class="ctrl-btn wide" id="btn-setup">⚡ SETUP &amp; START POLLING</button>
<button class="ctrl-btn wide" id="btn-reset-live">RESET LIVE</button>
<button class="ctrl-btn wide" id="btn-reset-doors">RESET WITH DOORS</button>
<button class="ctrl-btn" id="btn-move-north">MOVE N</button>
<button class="ctrl-btn" id="btn-look-north">LOOK N</button>
<button class="ctrl-btn" id="btn-move-west">MOVE W</button>
<button class="ctrl-btn" id="btn-wait">WAIT</button>
<button class="ctrl-btn" id="btn-move-east">MOVE E</button>
<button class="ctrl-btn" id="btn-look-west">LOOK W</button>
<button class="ctrl-btn" id="btn-move-south">MOVE S</button>
<button class="ctrl-btn" id="btn-look-south">LOOK S</button>
<button class="ctrl-btn wide" id="btn-look-east">LOOK E</button>
<input class="ctrl-input wide" id="door-id-input" value="door_1" />
<button class="ctrl-btn" id="btn-open-door">OPEN DOOR</button>
<button class="ctrl-btn" id="btn-close-door">CLOSE DOOR</button>
<button class="ctrl-btn" id="btn-staff-add">STAFF +</button>
<button class="ctrl-btn" id="btn-staff-remove">STAFF -</button>
<button class="ctrl-btn wide" id="btn-staff-panic">STAFF PANIC: OFF</button>
<button class="ctrl-btn wide" id="btn-auto">AUTO WAIT: OFF</button>
<button class="ctrl-btn wide" id="btn-poll">POLL STATE: OFF</button>
</div>
<div class="ctrl-status" id="ctrl-status">Status: idle</div>
</div>
<!-- Agent -->
<div class="card">
<div class="card-title">▶ AGENT STATUS</div>
<div class="srow"><span>POSITION</span><span class="sv blue" id="agent-pos">(7 , 7)</span></div>
<div class="srow"><span>ZONE</span><span class="sv" id="agent-zone">CORRIDOR</span></div>
<div class="srow"><span>HEALTH</span><span class="sv" id="agent-health">100%</span></div>
<div class="srow"><span>SMOKE EXP.</span><span class="sv warn" id="agent-smoke">none</span></div>
<div class="srow"><span>WIND</span><span class="sv warn" id="agent-wind">CALM</span></div>
<div class="srow"><span>LAST ACTION</span><span class="sv blue" id="agent-last-action">RESET</span></div>
<div class="bar-w">
<div class="bar-lbl"><span>HEALTH</span><span id="health-bar-label">100%</span></div>
<div class="bar-bg"><div class="bar-fill" id="health-bar-fill" style="width:100%"></div></div>
</div>
</div>
<!-- Environment readings (from idea.md: temp, smoke density, visibility, belief) -->
<div class="card">
<div class="card-title">◈ ENV READINGS</div>
<div class="srow"><span>TEMPERATURE</span><span class="sv warn" id="env-temperature">0.00</span></div>
<div class="srow"><span>SMOKE DENSITY</span><span class="sv warn" id="env-smoke-density">0.00</span></div>
<div class="srow"><span>VISIBILITY</span><span class="sv" id="env-visibility">0%</span></div>
<div class="srow"><span>NEAREST EXIT</span><span class="sv blue" id="env-nearest-exit">N/A</span></div>
<div class="srow"><span>ROUTE RISK</span><span class="sv" id="env-route-risk">N/A</span></div>
<div class="srow"><span>BELIEF MAP</span><span class="sv" id="env-belief">0% explored</span></div>
</div>
<!-- Fire report -->
<div class="card">
<div class="card-title">🔥 FIRE REPORT</div>
<div class="srow"><span>FIRE CELLS</span><span class="sv hot" id="fire-cells">0</span></div>
<div class="srow"><span>MAX INTENSITY</span><span class="sv danger" id="fire-max-intensity">0.00</span></div>
<div class="srow"><span>SMOKE CELLS</span><span class="sv warn" id="smoke-cells">0</span></div>
<div class="srow"><span>ORIGIN CELL</span><span class="sv danger" id="fire-origin">N/A</span></div>
<div class="srow"><span>SPREAD DIR.</span><span class="sv hot" id="fire-spread-dir">N/A</span></div>
<div class="srow"><span>TEMP PEAK</span><span class="sv danger" id="temp-peak">0°C est.</span></div>
</div>
<!-- Door status (from idea.md: door open/closed/failed state) -->
<div class="card">
<div class="card-title">▣ DOOR STATUS</div>
<div id="door-list"></div>
</div>
<!-- Legend -->
<div class="card">
<div class="card-title">□ MAP LEGEND</div>
<div class="leg-grid" id="legend"></div>
</div>
<div class="card">
<div class="card-title">◉ API REPORT</div>
<div class="report-meta" id="report-meta">No API calls yet.</div>
<div class="report-box" id="report-json">{}</div>
<div class="report-actions">
<button class="ctrl-btn" id="btn-copy-curl-reset">COPY RESET CURL</button>
<button class="ctrl-btn" id="btn-copy-curl-step">COPY STEP CURL</button>
</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════════
// CELL TYPE CONSTANTS (mirrors models.py)
// ═══════════════════════════════════════════════════════════════
const FLOOR = 0;
const WALL = 1;
const DOOR_OPEN = 2;
const DOOR_CLOSED = 3;
const EXIT = 4;
const OBSTACLE = 5;
const DOOR_FAILED = 6; // burned-through door (new visual state)
let W = 16, H = 16;
const CELL = 36;
// Grids — populated entirely from API; start empty
let GRID = new Int32Array(W * H);
let FIRE = new Float32Array(W * H);
let SMOKE = new Float32Array(W * H);
function idx(x, y) { return y * W + x; }
function isOuter(x, y) { return x === 0 || x === W-1 || y === 0 || y === H-1; }
function getZone(x, y) {
const ct = GRID[idx(x,y)];
if (ct !== FLOOR && ct !== EXIT) return '';
if (y >= 5 && y <= 9) return 'corridor';
return y <= 4 ? 'north' : 'south';
}
// Heat derived from live FIRE grid — no hardcoded sources
function getHeat(x, y) {
let h = 0;
const radius = 5;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
const fi = FIRE[idx(nx, ny)];
if (fi <= 0) continue;
const d = Math.sqrt(dx*dx + dy*dy);
h = Math.max(h, fi * Math.max(0, 1 - d / (radius + 1)));
}
}
return Math.min(1, h * 1.15);
}
// Exploration / belief map — starts empty, populated by visible_cells from API
let EXPLORED = new Set();
// Agent planned route — starts empty (no hardcoded path)
let ROUTE_PLAN = new Set();
// Agent position — filled from API on first reset/poll
let AGENT = { x: 0, y: 0 };
const SCAN_RADIUS = 4;
// Items layer — office furniture + hazard items from idea.md
// Keys: "x,y" → item type
const ITEMS = {
// North Room 1 — on fire, but furniture visible under smoke
'1,1':'desk', '2,1':'monitor', '3,1':'chair', '1,3':'filing',
// North Room 2
'5,1':'desk', '6,1':'monitor', '7,1':'chair', '5,3':'plant', '7,3':'extinguisher',
// North Room 3
'9,1':'desk', '10,1':'monitor', '11,1':'chair', '9,3':'hazard', '11,3':'plant',
// North Room 4
'13,1':'desk', '14,1':'sensor', '13,3':'filing',
// Corridor — environmental hazard items
'3,6':'alarm', '12,6':'alarm',
'3,5':'sprinkler', '8,5':'sprinkler', '5,8':'sprinkler',
'2,5':'hazard',
// South Room 1 (fog)
'1,11':'desk', '2,11':'monitor', '3,11':'chair', '1,13':'plant',
// South Room 2 (fog)
'5,12':'desk', '6,12':'monitor', '5,14':'filing',
// South Room 3 (explored)
'9,12':'desk', '10,12':'monitor', '11,12':'chair', '9,14':'plant',
// South Room 4 (explored)
'13,12':'desk', '14,12':'sensor', '13,14':'filing',
};
// Wall-embedded items (alarms, exit signs on wall cells)
const WALL_ITEMS = {
'0,5':'alarm_wall', '0,9':'alarm_wall',
'15,5':'alarm_wall', '15,9':'alarm_wall',
'0,6':'exit_sign', '15,6':'exit_sign',
};
// Door registry — populated from API door_registry
let DOORS = [];
const STAFF_COLORS = {
calm: '#7de5ff',
panicked: '#ff9d5c',
};
let STAFF = [];
let staffPanicMode = false;
let staffTimer = null;
let autoRunTimer = null;
let pollTimer = null;
let lastResetPayload = null;
let lastStepPayload = null;
let lastActionSent = null;
// Agent change tracking
let prevAgent = { x: -1, y: -1 };
let agentMoveFlash = 0; // frames remaining for move-flash ring
let agentMoveCount = 0; // total detected moves this session
// ═══════════════════════════════════════════════════════════════
// CANVAS
// ═══════════════════════════════════════════════════════════════
const canvas = document.getElementById('map');
const ctx = canvas.getContext('2d');
// ═══════════════════════════════════════════════════════════════
// TILE DRAWING
// ═══════════════════════════════════════════════════════════════
function drawFloorCorridor(px, py) {
const s = CELL, hs = s/2;
ctx.fillStyle='#C8A260'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#D8B270'; ctx.fillRect(px,py,hs,hs);
ctx.fillStyle='#D8B270'; ctx.fillRect(px+hs,py+hs,hs,hs);
ctx.fillStyle='#B89050'; ctx.fillRect(px+hs,py,hs,hs);
ctx.fillStyle='#B89050'; ctx.fillRect(px,py+hs,hs,hs);
ctx.strokeStyle='#A07838'; ctx.lineWidth=0.8;
ctx.beginPath(); ctx.moveTo(px+hs,py); ctx.lineTo(px+hs,py+s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px,py+hs); ctx.lineTo(px+s,py+hs); ctx.stroke();
ctx.strokeStyle='#886020'; ctx.lineWidth=0.5;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
function drawFloorRoom(px, py) {
const s = CELL;
ctx.fillStyle='#E2D8C4'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#ECE4D0'; ctx.fillRect(px+2,py+2,s-4,s-4);
ctx.strokeStyle='#CCC2AE'; ctx.lineWidth=0.7;
ctx.beginPath(); ctx.moveTo(px+s/2,py+2); ctx.lineTo(px+s/2,py+s-2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+2,py+s/2); ctx.lineTo(px+s-2,py+s/2); ctx.stroke();
ctx.strokeStyle='#BCB2A0'; ctx.lineWidth=0.5;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
function drawWallOuter(px, py) {
const s = CELL;
ctx.fillStyle='#5C6470'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#80889A'; ctx.fillRect(px,py,s,6);
ctx.fillStyle='#70788A'; ctx.fillRect(px,py+6,s,3);
ctx.fillStyle='#404850'; ctx.fillRect(px,py+s-3,s,3);
ctx.strokeStyle='#4A5260'; ctx.lineWidth=0.7;
[13,23,30].forEach(ly=>{ ctx.beginPath();ctx.moveTo(px,py+ly);ctx.lineTo(px+s,py+ly);ctx.stroke(); });
ctx.lineWidth=0.4;
[[0,14,10],[14,10,18],[24,9,9]].forEach(([ry,h,off])=>{
for(let vx=off;vx<s;vx+=18){if(vx>0&&vx<s){ctx.beginPath();ctx.moveTo(px+vx,py+ry+8);ctx.lineTo(px+vx,py+ry+8+h);ctx.stroke();}}
});
ctx.strokeStyle='#282E38'; ctx.lineWidth=1;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
function drawWallInner(px, py) {
const s = CELL;
ctx.fillStyle='#726860'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#928880'; ctx.fillRect(px,py,s,5);
ctx.fillStyle='#827870'; ctx.fillRect(px,py+5,s,2);
ctx.fillStyle='#524840'; ctx.fillRect(px,py+s-2,s,2);
ctx.strokeStyle='#605850'; ctx.lineWidth=0.6;
for(let i=1;i<=4;i++){ const ly=py+Math.round(i*s/5);ctx.beginPath();ctx.moveTo(px+2,ly);ctx.lineTo(px+s-2,ly);ctx.stroke(); }
ctx.strokeStyle='#403830'; ctx.lineWidth=1;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
function drawWallWithItem(px, py, item) {
if(isOuter(px/CELL|0, py/CELL|0)) drawWallOuter(px,py); else drawWallInner(px,py);
const s=CELL, cx=px+s/2;
if(item==='alarm_wall'){
// Red fire alarm box embedded in wall
ctx.fillStyle='#AA0000'; ctx.fillRect(px+8,py+10,20,16);
ctx.fillStyle='#CC1010'; ctx.fillRect(px+8,py+10,20,3);
ctx.fillStyle='#FFD040';
ctx.beginPath(); ctx.arc(cx,py+19,4,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#AA0000';
ctx.beginPath(); ctx.arc(cx,py+19,2.5,0,Math.PI*2); ctx.fill();
ctx.font='bold 4px monospace'; ctx.textAlign='center';
ctx.fillStyle='#FF4040'; ctx.fillText('FIRE',cx,py+30);
// Red glow
ctx.fillStyle='rgba(255,0,0,0.10)'; ctx.fillRect(px,py,s,s);
}
if(item==='exit_sign'){
// Green EXIT arrow embedded in wall
ctx.fillStyle='#00A030'; ctx.fillRect(px+5,py+12,26,12);
ctx.fillStyle='#00C840'; ctx.fillRect(px+5,py+12,26,3);
ctx.fillStyle='#FFFFFF';
ctx.font='bold 5px monospace'; ctx.textAlign='center';
ctx.fillText('EXIT',cx,py+22);
// Arrow
ctx.fillStyle='#80FF80'; ctx.beginPath();
ctx.moveTo(cx-2,py+9); ctx.lineTo(cx+4,py+12); ctx.lineTo(cx-2,py+12); ctx.closePath(); ctx.fill();
}
}
function drawDoorOpen(px, py) {
const s=CELL, fw=5;
drawFloorCorridor(px,py);
ctx.fillStyle='#5C2408'; ctx.fillRect(px,py,fw,s); ctx.fillRect(px+s-fw,py,fw,s); ctx.fillRect(px,py,s,fw);
ctx.fillStyle='#8A3A18'; ctx.fillRect(px+1,py+fw,fw-2,s-fw-1); ctx.fillRect(px+s-fw+1,py+fw,fw-2,s-fw-1);
ctx.fillStyle='rgba(200,230,255,0.05)'; ctx.fillRect(px+fw,py+fw,s-fw*2,s-fw);
ctx.fillStyle='#28A040'; ctx.fillRect(px+fw,py+fw,s-fw*2,3);
ctx.fillStyle='#40C060'; ctx.fillRect(px+fw,py+fw,s-fw*2,1);
}
function drawDoorClosed(px, py) {
const s=CELL;
ctx.fillStyle='#240E05'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#7A3E16'; ctx.fillRect(px+3,py+2,s-6,s-4);
ctx.fillStyle='#9A5E34'; ctx.fillRect(px+3,py+2,s-6,3); ctx.fillRect(px+3,py+2,3,s-4);
ctx.fillStyle='#5A2608'; ctx.fillRect(px+3,py+s-4,s-6,2); ctx.fillRect(px+s-6,py+2,3,s-4);
ctx.fillStyle='#6A2E10'; ctx.fillRect(px+8,py+7,s-16,s-16);
ctx.fillStyle='#8A4E2E'; ctx.fillRect(px+8,py+7,s-16,2); ctx.fillRect(px+8,py+7,2,s-16);
ctx.fillStyle='#4A1E08'; ctx.fillRect(px+8,py+s-11,s-16,2); ctx.fillRect(px+s-10,py+7,2,s-16);
ctx.fillStyle='#C89020'; ctx.beginPath(); ctx.arc(px+s-10,py+s/2,3,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#F0C040'; ctx.beginPath(); ctx.arc(px+s-11,py+s/2-1,1.5,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#140800'; ctx.lineWidth=1; ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
// Burned-through failed door (idea.md: door failed state)
function drawDoorFailed(px, py) {
const s=CELL;
// Charred frame
ctx.fillStyle='#180C04'; ctx.fillRect(px,py,s,s);
// Floor visible through (ash-covered)
ctx.fillStyle='#D0A858'; ctx.fillRect(px+5,py+5,s-10,s-10);
ctx.fillStyle='#B08840'; ctx.fillRect(px+5,py+5,s-10,s-10);
// Ash/burn marks on floor
ctx.fillStyle='#281408'; ctx.fillRect(px+7,py+7,8,6); ctx.fillRect(px+18,py+18,8,5);
ctx.fillStyle='#380E04'; ctx.fillRect(px+12,py+12,8,8);
// Charred door frame remnants
ctx.fillStyle='#300A02';
ctx.fillRect(px,py,4,s); ctx.fillRect(px+s-4,py,4,s); ctx.fillRect(px,py,s,4);
// Glowing ember bits
ctx.fillStyle='#A03010'; ctx.fillRect(px+2,py+8,2,2); ctx.fillRect(px+s-4,py+14,2,2);
ctx.fillStyle='#D05020'; ctx.fillRect(px+2,py+9,1,1); ctx.fillRect(px+s-4,py+15,1,1);
// Red X overlay
ctx.strokeStyle='rgba(200,30,10,0.7)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(px+6,py+6); ctx.lineTo(px+s-6,py+s-6); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+s-6,py+6); ctx.lineTo(px+6,py+s-6); ctx.stroke();
ctx.strokeStyle='#100600'; ctx.lineWidth=1;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
function drawExit(px, py) {
const s=CELL;
ctx.fillStyle='#0C7224'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#1A9038'; ctx.fillRect(px+2,py+2,s-4,s-4);
ctx.fillStyle='#30A850'; ctx.fillRect(px+4,py+4,s-8,s-8);
ctx.fillStyle='#FFFFFF';
const cx=px+s/2, ay=py+6;
ctx.beginPath(); ctx.moveTo(cx,ay); ctx.lineTo(cx+7,ay+8); ctx.lineTo(cx+4,ay+8);
ctx.lineTo(cx+4,ay+19); ctx.lineTo(cx-4,ay+19); ctx.lineTo(cx-4,ay+8); ctx.lineTo(cx-7,ay+8);
ctx.closePath(); ctx.fill();
ctx.font='bold 5px monospace'; ctx.textAlign='center';
ctx.fillStyle='#FFFFFF'; ctx.fillText('EXIT',cx,py+s-4);
ctx.strokeStyle='#085018'; ctx.lineWidth=2; ctx.strokeRect(px+1,py+1,s-2,s-2);
ctx.strokeStyle='#48C860'; ctx.lineWidth=0.5; ctx.strokeRect(px+3.5,py+3.5,s-7,s-7);
}
function drawObstacle(px, py) {
const s=CELL;
ctx.fillStyle='#100A04'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#241408'; ctx.fillRect(px+3,py+3,11,7); ctx.fillRect(px+18,py+11,12,6);
ctx.fillRect(px+5,py+21,15,5); ctx.fillRect(px+21,py+25,9,5);
ctx.fillStyle='#7A3A10'; ctx.fillRect(px+5,py+5,4,3); ctx.fillRect(px+21,py+13,3,3);
ctx.fillStyle='#C05818'; ctx.fillRect(px+6,py+6,2,1); ctx.fillRect(px+22,py+14,1,2);
ctx.strokeStyle='#301808'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(px+2,py+2); ctx.lineTo(px+s-2,py+s-2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+s-2,py+2); ctx.lineTo(px+2,py+s-2); ctx.stroke();
ctx.strokeStyle='#080402'; ctx.lineWidth=1; ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
// ═══════════════════════════════════════════════════════════════
// ITEM DRAWING (office furniture + hazard items from idea.md)
// ═══════════════════════════════════════════════════════════════
function drawItem(item, px, py) {
const s=CELL, cx=px+s/2, cy=py+s/2;
ctx.save();
switch(item) {
case 'desk': drawDesk(px,py); break;
case 'monitor': drawMonitor(px,py); break;
case 'chair': drawChair(px,py); break;
case 'plant': drawPlant(px,py); break;
case 'filing': drawFiling(px,py); break;
case 'extinguisher': drawExtinguisher(px,py); break;
case 'hazard': drawHazardMarker(px,py); break; // mark_hazard action
case 'sprinkler':drawSprinkler(px,py); break; // active sprinkler
case 'alarm': drawAlarmTriggered(px,py); break; // fire alarm triggered
case 'sensor': drawSensorNode(px,py); break; // building sensor
}
ctx.restore();
}
// Office desk (horizontal)
function drawDesk(px, py) {
const s=CELL;
ctx.fillStyle='#7B5A30'; ctx.fillRect(px+2,py+14,s-4,s-20);
ctx.fillStyle='#9B7A50'; ctx.fillRect(px+2,py+14,s-4,3); ctx.fillRect(px+2,py+14,3,s-20);
ctx.fillStyle='#5A3A18'; ctx.fillRect(px+3,py+s-8,3,6); ctx.fillRect(px+s-6,py+s-8,3,6);
ctx.fillStyle='#F0E8D0'; ctx.fillRect(px+6,py+17,9,4);
ctx.fillStyle='#C8C0B0'; ctx.fillRect(px+17,py+16,7,3);
}
// Computer monitor
function drawMonitor(px, py) {
const s=CELL;
ctx.fillStyle='#1A2030'; ctx.fillRect(px+6,py+4,s-12,15);
ctx.fillStyle='#283050'; ctx.fillRect(px+7,py+5,s-14,13);
// Screen glow — showing data/map on monitor
ctx.fillStyle='#102848'; ctx.fillRect(px+8,py+6,s-16,11);
ctx.fillStyle='#204880'; ctx.fillRect(px+9,py+7,s-18,4);
ctx.fillStyle='#38A0D8'; ctx.fillRect(px+9,py+7,s-18,1);
ctx.fillStyle='#286080'; ctx.fillRect(px+9,py+10,s-18,1);
ctx.fillStyle='#204060'; ctx.fillRect(px+9,py+12,8,1);
// Status LED
ctx.fillStyle='#00E060'; ctx.fillRect(px+s/2+5,py+19,2,2);
// Stand
ctx.fillStyle='#202028'; ctx.fillRect(px+s/2-2,py+20,5,4); ctx.fillRect(px+s/2-4,py+23,9,2);
}
// Office chair (top-down)
function drawChair(px, py) {
const s=CELL;
// Seat (dark blue-gray)
ctx.fillStyle='#2A3050'; ctx.fillRect(px+7,py+16,s-14,10);
ctx.fillStyle='#3A4068'; ctx.fillRect(px+7,py+16,s-14,3);
// Backrest
ctx.fillStyle='#2A3050'; ctx.fillRect(px+9,py+8,s-18,9);
ctx.fillStyle='#3A4068'; ctx.fillRect(px+9,py+8,s-18,2);
// Armrests
ctx.fillStyle='#303844'; ctx.fillRect(px+5,py+16,4,7); ctx.fillRect(px+s-9,py+16,4,7);
// Wheel spokes (5-pointed base)
ctx.strokeStyle='#484E58'; ctx.lineWidth=1.2;
const base = [0,-8,6,5,-6,5,-9,-2,9,-2];
for(let i=0;i<5;i++){
ctx.beginPath(); ctx.moveTo(px+s/2,py+s-4); ctx.lineTo(px+s/2+base[i*2],py+s-4+base[i*2+1]); ctx.stroke();
}
}
// Office plant
function drawPlant(px, py) {
const s=CELL, cx=px+s/2;
// Pot
ctx.fillStyle='#C06030'; ctx.fillRect(cx-6,py+24,12,8);
ctx.fillStyle='#E08050'; ctx.fillRect(cx-6,py+24,12,2);
ctx.fillStyle='#4A2A10'; ctx.fillRect(cx-5,py+26,10,4);
// Leaves
ctx.fillStyle='#208038'; ctx.beginPath(); ctx.arc(cx,py+15,7,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#208038'; ctx.beginPath(); ctx.arc(cx-6,py+19,5,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#208038'; ctx.beginPath(); ctx.arc(cx+6,py+19,5,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#30C050'; ctx.beginPath(); ctx.arc(cx-1,py+13,4,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#48D860'; ctx.beginPath(); ctx.arc(cx-2,py+12,2,0,Math.PI*2); ctx.fill();
}
// Filing cabinet (top-down side view)
function drawFiling(px, py) {
const s=CELL;
ctx.fillStyle='#788090'; ctx.fillRect(px+6,py+3,s-12,s-8);
ctx.fillStyle='#90A0B0'; ctx.fillRect(px+6,py+3,s-12,2);
ctx.fillStyle='#586070'; ctx.fillRect(px+6,py+s-8,s-12,3);
ctx.strokeStyle='#485060'; ctx.lineWidth=0.8;
[11,19,27].forEach(y=>{ ctx.beginPath();ctx.moveTo(px+6,py+y);ctx.lineTo(px+s-6,py+y);ctx.stroke(); });
ctx.fillStyle='#B8C8D8';
[8,16,24].forEach(dy=>{ ctx.fillRect(px+s/2-4,py+dy+1,8,2); });
}
// Fire extinguisher (idea.md: inventory item)
function drawExtinguisher(px, py) {
const s=CELL, cx=px+s/2;
// Body
ctx.fillStyle='#BE0808'; ctx.fillRect(cx-5,py+8,10,20);
ctx.fillStyle='#E01010'; ctx.fillRect(cx-5,py+8,10,3);
ctx.fillStyle='#DD2828'; ctx.fillRect(cx-4,py+11,3,14);
// Top nozzle
ctx.fillStyle='#282828'; ctx.fillRect(cx-1,py+4,3,5);
ctx.fillRect(cx+1,py+4,5,2);
// White stripe
ctx.fillStyle='#FFFFFF'; ctx.fillRect(cx-5,py+16,10,3);
// Label
ctx.fillStyle='#FFFFFF'; ctx.font='bold 4px monospace'; ctx.textAlign='center';
ctx.fillText('EXT',cx,py+23);
// Bottom
ctx.fillStyle='#700000'; ctx.fillRect(cx-5,py+26,10,2);
// Glow
ctx.fillStyle='rgba(220,30,10,0.12)'; ctx.fillRect(px,py,s,s);
}
// Hazard marker — from mark_hazard action (idea.md: expanded action set)
function drawHazardMarker(px, py) {
const s=CELL, cx=px+s/2, cy=py+s/2;
// Ground glow
ctx.fillStyle='rgba(255,190,0,0.18)'; ctx.fillRect(px,py,s,s);
// Yellow diamond
ctx.fillStyle='#E8C000';
ctx.beginPath(); ctx.moveTo(cx,py+4); ctx.lineTo(px+s-4,cy); ctx.lineTo(cx,py+s-4); ctx.lineTo(px+4,cy); ctx.closePath(); ctx.fill();
// Inner black
ctx.fillStyle='#8A7200';
ctx.beginPath(); ctx.moveTo(cx,py+9); ctx.lineTo(px+s-9,cy); ctx.lineTo(cx,py+s-9); ctx.lineTo(px+9,cy); ctx.closePath(); ctx.fill();
// ! symbol
ctx.fillStyle='#FFE030'; ctx.font='bold 11px monospace';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('!',cx,cy+1); ctx.textBaseline='alphabetic';
// Dashed border
ctx.strokeStyle='#A08000'; ctx.lineWidth=1;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
// Active sprinkler (idea.md: structural hazards dynamic change)
function drawSprinkler(px, py) {
const s=CELL, cx=px+s/2;
// Blue water tint on floor
ctx.fillStyle='rgba(50,100,200,0.14)'; ctx.fillRect(px,py,s,s);
// Ceiling head
ctx.fillStyle='#909098'; ctx.fillRect(cx-2,py+1,4,4);
ctx.fillStyle='#A8A8B0'; ctx.fillRect(cx-5,py+4,10,2);
// Water drops (tear-drop shapes)
ctx.fillStyle='#58A0E8';
[[s*0.25,s*0.32],[s*0.5,s*0.38],[s*0.75,s*0.32],
[s*0.18,s*0.52],[s*0.5,s*0.58],[s*0.82,s*0.52],
[s*0.3,s*0.72],[s*0.7,s*0.72]].forEach(([wx,wy])=>{
ctx.beginPath(); ctx.ellipse(px+wx,py+wy,1.5,2.5,0,0,Math.PI*2); ctx.fill();
});
}
// Fire alarm triggered (idea.md: dynamic hazard changes)
function drawAlarmTriggered(px, py) {
const s=CELL, cx=px+s/2;
// Red pulsing box on wall/floor
ctx.fillStyle='rgba(200,0,0,0.18)'; ctx.fillRect(px,py,s,s);
ctx.fillStyle='#B80000'; ctx.fillRect(px+5,py+6,s-10,s-12);
ctx.fillStyle='#D81010'; ctx.fillRect(px+5,py+6,s-10,3);
ctx.fillStyle='#880000'; ctx.fillRect(px+5,py+s-9,s-10,3);
// Bell
ctx.fillStyle='#FFD030'; ctx.beginPath(); ctx.arc(cx,py+s/2,6,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#CC0000'; ctx.beginPath(); ctx.arc(cx,py+s/2,4,0,Math.PI*2); ctx.fill();
// FIRE text
ctx.font='bold 4px monospace'; ctx.textAlign='center';
ctx.fillStyle='#FF6060'; ctx.fillText('FIRE',cx,py+s-4);
}
// Building sensor node (idea.md: sensor uncertainty settings)
function drawSensorNode(px, py) {
const s=CELL, cx=px+s/2;
// Ceiling mount
ctx.fillStyle='#C0C8D0'; ctx.beginPath(); ctx.arc(cx,py+9,6,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#D8E0E8'; ctx.beginPath(); ctx.arc(cx,py+9,5,0,Math.PI*2); ctx.fill();
// LED status
ctx.fillStyle='#00FF70'; ctx.beginPath(); ctx.arc(cx,py+9,2,0,Math.PI*2); ctx.fill();
// Radio emission rings
ctx.strokeStyle='rgba(0,200,80,0.35)'; ctx.lineWidth=0.8;
[9,13,17].forEach(r=>{ ctx.beginPath(); ctx.arc(cx,py+9,r,Math.PI*1.2,Math.PI*1.8); ctx.stroke(); });
// Label
ctx.font='bold 4px monospace'; ctx.textAlign='center';
ctx.fillStyle='#80A8C8'; ctx.fillText('SENS',cx,py+22);
// Data readout
ctx.fillStyle='rgba(0,180,100,0.12)'; ctx.fillRect(px,py,s,s);
}
// ═══════════════════════════════════════════════════════════════
// OVERLAY LAYERS
// ═══════════════════════════════════════════════════════════════
// Temperature map (idea.md: temperature map layer)
function drawTemperature(px, py, x, y) {
const heat = getHeat(x, y);
if (heat < 0.08) return;
const alpha = Math.min(0.52, heat * 0.58);
if (heat > 0.7) {
ctx.fillStyle=`rgba(255,50,0,${alpha})`; ctx.fillRect(px,py,CELL,CELL);
} else if (heat > 0.45) {
ctx.fillStyle=`rgba(255,120,20,${alpha})`; ctx.fillRect(px,py,CELL,CELL);
} else {
ctx.fillStyle=`rgba(255,190,40,${alpha*0.7})`; ctx.fillRect(px,py,CELL,CELL);
}
}
// Fog of war / belief map (idea.md: explored map, confidence map)
function drawFog(px, py, x, y) {
if (EXPLORED.has(`${x},${y}`)) return;
ctx.fillStyle='rgba(5,5,12,0.72)'; ctx.fillRect(px,py,CELL,CELL);
// "?" for completely unseen areas
ctx.font='bold 9px monospace'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle='rgba(80,80,120,0.5)';
ctx.fillText('?',px+CELL/2,py+CELL/2); ctx.textBaseline='alphabetic';
}
// Fire overlay
function drawFire(px, py, intensity) {
if (intensity < 0.05) return;
const s=CELL;
const g=ctx.createRadialGradient(px+s/2,py+s/2,0,px+s/2,py+s/2,s*0.72);
g.addColorStop(0, `rgba(255,230,60,${Math.min(0.95,intensity*.95)})`);
g.addColorStop(0.3,`rgba(255,110,15,${Math.min(0.85,intensity*.80)})`);
g.addColorStop(0.7,`rgba(200,25,5,${Math.min(0.55,intensity*.55)})`);
g.addColorStop(1, 'rgba(130,0,0,0)');
ctx.fillStyle=g; ctx.fillRect(px,py,s,s);
if(intensity>0.45){
ctx.fillStyle=`rgba(255,240,120,${intensity*.65})`;
[[s/2-2,4,s/2+2,4,s/2,-2],[s*.28,7,s*.48,7,s*.38,1],[s*.58,6,s*.78,6,s*.68,0]].forEach(([x1,y1,x2,y2,x3,y3])=>{
ctx.beginPath();ctx.moveTo(px+x1,py+y1);ctx.lineTo(px+x2,py+y2);ctx.lineTo(px+x3,py+y3);ctx.closePath();ctx.fill();
});
}
if(intensity>0.65){
ctx.font=`${s*.54}px serif`; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.globalAlpha=Math.min(1,intensity);
ctx.fillText('🔥',px+s/2,py+s/2+2); ctx.globalAlpha=1; ctx.textBaseline='alphabetic';
}
}
// Smoke overlay
function drawSmoke(px, py, density) {
if(density<0.05) return;
const s=CELL;
ctx.fillStyle=`rgba(65,65,88,${Math.min(0.78,density*.82)})`; ctx.fillRect(px,py,s,s);
const g=ctx.createRadialGradient(px+s*.38,py+s*.28,0,px+s/2,py+s/2,s*.62);
g.addColorStop(0,`rgba(100,100,125,${density*.28})`); g.addColorStop(1,'rgba(40,40,60,0)');
ctx.fillStyle=g; ctx.fillRect(px,py,s,s);
}
// Planned route overlay (idea.md: replan action, route risk)
function drawRoutePlan(px, py, x, y) {
if(!ROUTE_PLAN.has(`${x},${y}`)) return;
const s=CELL;
ctx.fillStyle='rgba(255,220,30,0.14)'; ctx.fillRect(px,py,s,s);
// Dotted arrow on route cells
ctx.strokeStyle='rgba(255,200,0,0.55)'; ctx.lineWidth=1.5;
ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(px+4,py+s/2); ctx.lineTo(px+s-4,py+s/2); ctx.stroke();
ctx.setLineDash([]);
// Arrow tip (pointing west toward exit)
ctx.fillStyle='rgba(255,200,0,0.65)';
ctx.beginPath(); ctx.moveTo(px+3,py+s/2); ctx.lineTo(px+9,py+s/2-4); ctx.lineTo(px+9,py+s/2+4); ctx.closePath(); ctx.fill();
}
// Scan radius (idea.md: scan action, local perception radius)
function drawScanRadius(px, py, x, y) {
const mdist = Math.abs(x - AGENT.x) + Math.abs(y - AGENT.y);
if(mdist > SCAN_RADIUS || mdist === 0) return;
const s=CELL;
const fade = 1 - mdist/SCAN_RADIUS;
ctx.fillStyle=`rgba(100,160,255,${fade*0.08})`; ctx.fillRect(px,py,s,s);
if(mdist === 1){
ctx.strokeStyle='rgba(120,180,255,0.25)'; ctx.lineWidth=0.5;
ctx.strokeRect(px+.5,py+.5,s-1,s-1);
}
}
// ═══════════════════════════════════════════════════════════════
// AGENT SPRITE
// ═══════════════════════════════════════════════════════════════
function drawAgent() {
const px=AGENT.x*CELL, py=AGENT.y*CELL, s=CELL;
const cx=px+s/2, cy=py+s/2;
// Move-flash pulse ring — shown for agentMoveFlash frames after a position change
if (agentMoveFlash > 0) {
const alpha = agentMoveFlash / 18;
const radius = s * (0.6 + (1 - alpha) * 0.6);
ctx.save();
ctx.strokeStyle = `rgba(255, 230, 60, ${alpha})`;
ctx.lineWidth = 3 + alpha * 4;
ctx.shadowColor = `rgba(255, 200, 0, ${alpha})`;
ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
agentMoveFlash--;
}
// Aura
const gl=ctx.createRadialGradient(cx,cy,0,cx,cy,s*.65);
gl.addColorStop(0,'rgba(70,135,255,0.42)'); gl.addColorStop(1,'rgba(30,70,200,0)');
ctx.fillStyle=gl; ctx.fillRect(px,py,s,s);
// Shadow
ctx.fillStyle='rgba(0,0,0,0.34)';
ctx.beginPath(); ctx.ellipse(cx,cy+11,7,2.5,0,0,Math.PI*2); ctx.fill();
// Shoes
ctx.fillStyle='#180C04'; ctx.fillRect(cx-6,cy+8,5,4); ctx.fillRect(cx+1,cy+8,5,4);
// Pants
ctx.fillStyle='#1C3080'; ctx.fillRect(cx-6,cy+1,5,8); ctx.fillRect(cx+1,cy+1,5,8);
ctx.fillStyle='#2840A8'; ctx.fillRect(cx-6,cy+1,2,8); ctx.fillRect(cx+1,cy+1,2,8);
// Belt + buckle
ctx.fillStyle='#601808'; ctx.fillRect(cx-7,cy-1,14,2);
ctx.fillStyle='#E08020'; ctx.beginPath(); ctx.arc(cx,cy,2,0,Math.PI*2); ctx.fill();
// Shirt
ctx.fillStyle='#1A60D8'; ctx.fillRect(cx-7,cy-9,14,11);
ctx.fillStyle='#3080F8'; ctx.fillRect(cx-7,cy-9,14,3); ctx.fillRect(cx-7,cy-9,3,11);
// Arms + hands
ctx.fillStyle='#1A60D8'; ctx.fillRect(cx-10,cy-8,4,9); ctx.fillRect(cx+6,cy-8,4,9);
ctx.fillStyle='#F0C070'; ctx.fillRect(cx-10,cy+0,4,3); ctx.fillRect(cx+6,cy+0,4,3);
// Neck
ctx.fillStyle='#F0C070'; ctx.fillRect(cx-2,cy-11,4,3);
// Head
ctx.fillStyle='#F0C070'; ctx.fillRect(cx-5,cy-19,10,9);
ctx.fillStyle='#F8D890'; ctx.fillRect(cx-5,cy-19,10,3); ctx.fillRect(cx-5,cy-19,3,9);
// Hair
ctx.fillStyle='#4A2810'; ctx.fillRect(cx-5,cy-19,10,4);
ctx.fillRect(cx-6,cy-17,3,3); ctx.fillRect(cx+3,cy-17,3,3);
// Eyes
ctx.fillStyle='#180C00'; ctx.fillRect(cx-3,cy-13,2,2); ctx.fillRect(cx+1,cy-13,2,2);
ctx.fillStyle='#FFFFFF'; ctx.fillRect(cx-3,cy-14,1,1); ctx.fillRect(cx+2,cy-14,1,1);
// Label
ctx.font='bold 6px monospace'; ctx.textAlign='center';
ctx.fillStyle='#FFFFFF'; ctx.fillText('A',cx,py+2);
}
function drawStaff(staff) {
const px = staff.x * CELL;
const py = staff.y * CELL;
const cx = px + CELL / 2;
const bob = Math.sin(staff.phase) * 1.5;
const cy = py + CELL / 2 + bob;
const color = STAFF_COLORS[staff.mood] || STAFF_COLORS.calm;
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.beginPath();
ctx.ellipse(cx, cy + 9, 6, 2.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = color;
ctx.fillRect(cx - 4, cy - 8, 8, 12);
ctx.fillStyle = '#f4c890';
ctx.fillRect(cx - 3, cy - 13, 6, 5);
ctx.fillStyle = '#2c2a3a';
ctx.fillRect(cx - 3, cy - 13, 6, 2);
if (staff.mood === 'panicked') {
ctx.fillStyle = '#ff5050';
ctx.beginPath();
ctx.arc(cx + 7, cy - 11, 2, 0, Math.PI * 2);
ctx.fill();
}
}
// ═══════════════════════════════════════════════════════════════
// ZONE LABELS
// ═══════════════════════════════════════════════════════════════
function drawZoneLabels() {
ctx.save();
ctx.font='bold 7px monospace'; ctx.textAlign='center';
ctx.fillStyle='rgba(195,180,150,0.13)';
ctx.fillText('NORTH OFFICES', CELL*8, CELL*2.6);
ctx.fillText('MAIN CORRIDOR', CELL*8, CELL*7.4);
ctx.fillText('SOUTH OFFICES', CELL*8, CELL*12.9);
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════
// MAIN RENDER
// ═══════════════════════════════════════════════════════════════
function render() {
// Resize canvas if grid dimensions changed
const expectedW = W * CELL, expectedH = H * CELL;
if (canvas.width !== expectedW || canvas.height !== expectedH) {
canvas.width = expectedW;
canvas.height = expectedH;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Pass 1: base tiles
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
const ct=GRID[idx(x,y)], px=x*CELL, py=y*CELL;
// Wall items override normal wall drawing
if(ct===WALL && WALL_ITEMS[`${x},${y}`]) { drawWallWithItem(px,py,WALL_ITEMS[`${x},${y}`]); continue; }
switch(ct){
case FLOOR: getZone(x,y)==='corridor' ? drawFloorCorridor(px,py) : drawFloorRoom(px,py); break;
case WALL: isOuter(x,y) ? drawWallOuter(px,py) : drawWallInner(px,py); break;
case DOOR_OPEN: drawDoorOpen(px,py); break;
case DOOR_CLOSED:drawDoorClosed(px,py); break;
case DOOR_FAILED:drawDoorFailed(px,py); break;
case EXIT: drawExit(px,py); break;
case OBSTACLE: drawObstacle(px,py); break;
}
}
// Pass 2: zone labels (faint, behind overlays)
drawZoneLabels();
// Pass 3: floor items (furniture, hazard items)
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
const item = ITEMS[`${x},${y}`];
if(item) drawItem(item, x*CELL, y*CELL);
}
// Pass 4: temperature gradient
for(let y=0;y<H;y++) for(let x=0;x<W;x++)
drawTemperature(x*CELL, y*CELL, x, y);
// Pass 5: route plan overlay (idea.md: replan)
for(let y=0;y<H;y++) for(let x=0;x<W;x++)
drawRoutePlan(x*CELL, y*CELL, x, y);
// Pass 6: scan radius highlight
for(let y=0;y<H;y++) for(let x=0;x<W;x++)
drawScanRadius(x*CELL, y*CELL, x, y);
// Pass 7: smoke + fire
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
drawSmoke(x*CELL, y*CELL, SMOKE[idx(x,y)]);
drawFire(x*CELL, y*CELL, FIRE[idx(x,y)]);
}
// Pass 8: fog of war (belief map) — applied last on top of items too
for(let y=0;y<H;y++) for(let x=0;x<W;x++)
drawFog(x*CELL, y*CELL, x, y);
// Pass 9: agent
STAFF.forEach(drawStaff);
drawAgent();
// Pass 10: vignette
const vig=ctx.createRadialGradient(canvas.width/2,canvas.height/2,canvas.width*.28,canvas.width/2,canvas.height/2,canvas.width*.84);
vig.addColorStop(0,'rgba(0,0,0,0)'); vig.addColorStop(1,'rgba(0,0,0,0.45)');
ctx.fillStyle=vig; ctx.fillRect(0,0,canvas.width,canvas.height);
}
function isWalkableForStaff(x, y) {
if (x < 0 || x >= W || y < 0 || y >= H) return false;
const ct = GRID[idx(x, y)];
return ct === FLOOR || ct === DOOR_OPEN || ct === EXIT;
}
function randomWalkableCell() {
for (let i = 0; i < 500; i++) {
const x = Math.floor(Math.random() * W);
const y = Math.floor(Math.random() * H);
if (!isWalkableForStaff(x, y)) continue;
if (x === AGENT.x && y === AGENT.y) continue;
return { x, y };
}
return { x: AGENT.x, y: AGENT.y };
}
function initStaff(count = 5) {
STAFF = [];
for (let i = 0; i < count; i++) {
const pos = randomWalkableCell();
STAFF.push({
id: `staff_${i + 1}`,
x: pos.x,
y: pos.y,
phase: Math.random() * Math.PI * 2,
mood: staffPanicMode ? 'panicked' : 'calm',
});
}
}
function reconcileStaffToMap() {
if (!STAFF.length) initStaff(5);
STAFF.forEach(s => {
if (!isWalkableForStaff(s.x, s.y) || (s.x === AGENT.x && s.y === AGENT.y)) {
const pos = randomWalkableCell();
s.x = pos.x;
s.y = pos.y;
}
s.mood = staffPanicMode ? 'panicked' : 'calm';
});
}
function stepStaff() {
if (!STAFF.length) return;
STAFF.forEach(s => {
s.phase += staffPanicMode ? 0.5 : 0.25;
const moveChance = staffPanicMode ? 0.85 : 0.45;
if (Math.random() > moveChance) return;
const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]].sort(() => Math.random() - 0.5);
for (const [dx, dy] of dirs) {
const nx = s.x + dx;
const ny = s.y + dy;
if (!isWalkableForStaff(nx, ny)) continue;
if (nx === AGENT.x && ny === AGENT.y) continue;
s.x = nx;
s.y = ny;
break;
}
});
}
function startStaffAnimation() {
if (staffTimer) return;
staffTimer = setInterval(() => {
stepStaff();
render();
}, 180);
}
// ═══════════════════════════════════════════════════════════════
// UI PANELS
// ═══════════════════════════════════════════════════════════════
function buildDoorList() {
const el = document.getElementById('door-list');
el.innerHTML = '';
DOORS.forEach(d => {
const row = document.createElement('div');
row.className = 'door-row';
const stCls = d.state==='open' ? 'd-open' : d.state==='failed' ? 'd-failed' : 'd-closed';
const stTxt = d.state==='open' ? '● OPEN' : d.state==='failed' ? '✕ FAILED' : '■ CLOSED';
row.innerHTML=`<span class="door-id">${d.id}</span><span>(${d.x},${d.y})</span><span class="${stCls}">${stTxt}</span>`;
el.appendChild(row);
});
}
function nearestExitDistance(mapState) {
const exits = mapState.exit_positions || [];
if (!exits.length) return null;
let best = Infinity;
exits.forEach(([x, y]) => {
const d = Math.abs(x - mapState.agent_x) + Math.abs(y - mapState.agent_y);
if (d < best) best = d;
});
return Number.isFinite(best) ? best : null;
}
function applyObservation(obs, lastActionLabel = 'STEP') {
const mapState = obs.map_state;
if (!mapState) return;
// Resize grids if server reports different dimensions
const newW = mapState.grid_w || W;
const newH = mapState.grid_h || H;
const newSize = newW * newH;
if (newW !== W || newH !== H) {
W = newW; H = newH;
GRID = new Int32Array(newSize);
FIRE = new Float32Array(newSize);
SMOKE = new Float32Array(newSize);
EXPLORED.clear();
}
if (Array.isArray(mapState.cell_grid) && mapState.cell_grid.length === newSize) {
for (let i = 0; i < newSize; i++) GRID[i] = mapState.cell_grid[i];
}
if (Array.isArray(mapState.fire_grid) && mapState.fire_grid.length === newSize) {
for (let i = 0; i < newSize; i++) FIRE[i] = mapState.fire_grid[i];
}
if (Array.isArray(mapState.smoke_grid) && mapState.smoke_grid.length === newSize) {
for (let i = 0; i < newSize; i++) SMOKE[i] = mapState.smoke_grid[i];
}
const newX = mapState.agent_x ?? AGENT.x;
const newY = mapState.agent_y ?? AGENT.y;
// Detect position change and trigger move flash
if (prevAgent.x !== -1 && (newX !== prevAgent.x || newY !== prevAgent.y)) {
agentMoveFlash = 18; // ~18 render frames of flash
agentMoveCount++;
const el = document.getElementById('hud-moves');
if (el) el.textContent = `MOVES: ${agentMoveCount}`;
}
prevAgent.x = newX;
prevAgent.y = newY;
AGENT.x = newX;
AGENT.y = newY;
reconcileStaffToMap();
if (Array.isArray(mapState.visible_cells)) {
mapState.visible_cells.forEach(([x, y]) => EXPLORED.add(`${x},${y}`));
}
DOORS = Object.entries(mapState.door_registry || {}).map(([id, [x, y]]) => {
const ct = GRID[idx(x, y)];
let state = 'open';
if (ct === DOOR_CLOSED) state = 'closed';
if (ct === OBSTACLE) state = 'failed';
return { id, x, y, state };
});
DOORS.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
buildDoorList();
const doorInput = document.getElementById('door-id-input');
if (DOORS.length > 0) {
doorInput.value = DOORS[0].id;
} else {
doorInput.value = '';
}
document.getElementById('agent-pos').textContent = `(${mapState.agent_x}, ${mapState.agent_y})`;
document.getElementById('agent-zone').textContent = obs.location_label || 'unknown';
document.getElementById('agent-health').textContent = `${Math.round(obs.agent_health || 0)}%`;
document.getElementById('agent-smoke').textContent = obs.smoke_level || 'none';
document.getElementById('agent-wind').textContent = obs.wind_dir || 'CALM';
document.getElementById('agent-last-action').textContent = lastActionLabel;
const hp = Math.max(0, Math.min(100, Math.round(obs.agent_health || 0)));
document.getElementById('health-bar-label').textContent = `${hp}%`;
document.getElementById('health-bar-fill').style.width = `${hp}%`;
const fireMax = Math.max(...FIRE);
const smokeMax = Math.max(...SMOKE);
const fireCells = Array.from(FIRE).filter(v => v > 0.05).length;
const smokeCells = Array.from(SMOKE).filter(v => v > 0.05).length;
const nearest = nearestExitDistance(mapState);
const exploredPct = Math.round((EXPLORED.size / GRID.length) * 100);
const routeRisk = fireMax > 0.7 ? 'HIGH' : fireMax > 0.35 ? 'MEDIUM' : 'LOW';
const tempPeak = Math.round(24 + fireMax * 320);
document.getElementById('env-temperature').textContent = `${fireMax.toFixed(2)}`;
document.getElementById('env-smoke-density').textContent = `${smokeMax.toFixed(2)}`;
document.getElementById('env-visibility').textContent = `${Math.max(5, 100 - Math.round(smokeMax * 80))}%`;
document.getElementById('env-nearest-exit').textContent = nearest == null ? 'N/A' : `${nearest} cells`;
document.getElementById('env-route-risk').textContent = routeRisk;
document.getElementById('env-belief').textContent = `${exploredPct}% explored`;
document.getElementById('fire-cells').textContent = `${fireCells}`;
document.getElementById('fire-max-intensity').textContent = `${fireMax.toFixed(2)}`;
document.getElementById('smoke-cells').textContent = `${smokeCells}`;
document.getElementById('fire-origin').textContent = obs.fire_visible && obs.fire_direction ? obs.fire_direction.toUpperCase() : 'UNKNOWN';
document.getElementById('fire-spread-dir').textContent = obs.fire_direction ? obs.fire_direction.toUpperCase() : 'N/A';
document.getElementById('temp-peak').textContent = `${tempPeak}°C est.`;
document.getElementById('hud-hp').textContent = `HP ${hp}`;
document.getElementById('hud-step').textContent = `STEP: ${mapState.step_count} / ${mapState.max_steps}`;
document.getElementById('hud-act').textContent = `LAST: ${lastActionLabel}`;
const msg = obs.last_action_feedback || (obs.narrative || '').split('\n')[0] || 'No feedback';
document.getElementById('dialog-text').textContent = msg;
render();
}
async function apiCall(path, payload) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
function updateReport(kind, requestPayload, responsePayload) {
const mapState = responsePayload?.observation?.map_state;
const template = mapState?.template_name || 'unknown';
const step = mapState?.step_count ?? responsePayload?.observation?.elapsed_steps ?? '-';
const doors = mapState?.door_registry ? Object.keys(mapState.door_registry).length : 0;
const reward = Number(responsePayload?.reward ?? 0).toFixed(3);
const done = Boolean(responsePayload?.done);
document.getElementById('report-meta').textContent =
`${kind.toUpperCase()} | template=${template} | step=${step} | doors=${doors} | reward=${reward} | done=${done}`;
const report = {
call_type: kind,
request: requestPayload,
response: responsePayload,
};
document.getElementById('report-json').textContent = JSON.stringify(report, null, 2);
}
function copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => setStatus('Copied cURL command to clipboard.'))
.catch(() => setStatus('Clipboard blocked. Copy manually from report panel.', true));
return;
}
setStatus('Clipboard API not available in this browser.', true);
}
function buildResetCurl() {
return `curl -X POST "http://127.0.0.1:8001/reset" -H "Content-Type: application/json" -d "{\\"difficulty\\":\\"medium\\"}"`;
}
function buildStepCurl() {
const action = lastActionSent || { action: 'wait' };
const payload = { action };
const json = JSON.stringify(payload).replace(/"/g, '\\"');
return `curl -X POST "http://127.0.0.1:8000/step" -H "Content-Type: application/json" -d "${json}"`;
}
function setStatus(text, isError = false) {
const el = document.getElementById('ctrl-status');
el.textContent = text;
el.style.color = isError ? '#ff8c8c' : '#8a98cc';
}
async function resetLive() {
try {
setStatus('Resetting live episode...');
EXPLORED = new Set();
const payload = { difficulty: 'medium' };
const data = await apiCall('/reset', payload);
lastResetPayload = data;
applyObservation(data.observation, 'RESET');
updateReport('reset', payload, data);
setStatus(`Connected. Reward ${Number(data.reward || 0).toFixed(2)} | done=${data.done}`);
} catch (err) {
setStatus(`Connection failed: ${err.message}`, true);
}
}
async function resetUntilDoors(maxAttempts = 6) {
try {
setStatus('Searching for a template with doors...');
for (let i = 1; i <= maxAttempts; i++) {
const payload = { difficulty: 'medium' };
const data = await apiCall('/reset', payload);
const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
lastResetPayload = data;
applyObservation(data.observation, 'RESET');
updateReport('reset', payload, data);
if (doorCount > 0) {
setStatus(`Loaded template with ${doorCount} doors (attempt ${i}/${maxAttempts}).`);
return;
}
}
setStatus('No door template found in attempts. Try again.', true);
} catch (err) {
setStatus(`Reset with doors failed: ${err.message}`, true);
}
}
async function runAction(actionObj, label) {
try {
setStatus(`Running ${label}...`);
const payload = { action: actionObj };
const data = await apiCall('/step', payload);
lastActionSent = actionObj;
lastStepPayload = data;
applyObservation(data.observation, label);
updateReport('step', payload, data);
setStatus(`Reward ${Number(data.reward || 0).toFixed(2)} | done=${data.done}`);
if (data.done && autoRunTimer) {
clearInterval(autoRunTimer);
autoRunTimer = null;
document.getElementById('btn-auto').textContent = 'AUTO WAIT: OFF';
}
} catch (err) {
setStatus(`Action error: ${err.message}`, true);
}
}
// ═══════════════════════════════════════════════════════════════
// STATE POLLING (GET /state every 2 s)
// ═══════════════════════════════════════════════════════════════
/**
* Map a raw PyreState response (from GET /state) into the pseudo-observation
* shape that applyObservation() expects. Fields absent from PyreState are
* filled with sensible defaults so the existing renderer needs no changes.
*/
function applyStateResponse(s) {
// Derive smoke_level string from smoke grid max
const smokeMax = s.smoke_grid ? Math.max(...s.smoke_grid) : 0;
const smokeLevel = smokeMax > 0.7 ? 'heavy' : smokeMax > 0.4 ? 'moderate' : smokeMax > 0.1 ? 'light' : 'none';
// Detect fire in visible cells (heuristic: any fire cell > 0.1 near agent)
const FIRE_THRESH = 0.1;
const fireMax = s.fire_grid ? Math.max(...s.fire_grid) : 0;
const fireVisible = fireMax > FIRE_THRESH;
// Build a pseudo-observation wrapping the state fields as map_state
const pseudoObs = {
map_state: {
cell_grid: s.cell_grid || [],
fire_grid: s.fire_grid || [],
smoke_grid: s.smoke_grid || [],
agent_x: s.agent_x ?? AGENT.x,
agent_y: s.agent_y ?? AGENT.y,
visible_cells: [], // PyreState has no fog snapshot; keep fog as-is
door_registry: s.door_registry || {},
exit_positions:s.exit_positions|| [],
step_count: s.step_count ?? 0,
max_steps: s.max_steps ?? 150,
grid_w: s.grid_w || W,
grid_h: s.grid_h || H,
template_name: s.template_name || '',
},
agent_health: s.agent_health ?? 100,
location_label: s.zone_map?.[`${s.agent_x},${s.agent_y}`] || '',
smoke_level: smokeLevel,
wind_dir: s.wind_dir || 'CALM',
fire_visible: fireVisible,
fire_direction: null,
last_action_feedback:'[POLL] State refreshed from server.',
narrative: '',
};
applyObservation(pseudoObs, 'POLL');
}
async function fetchAndApplyState() {
try {
const res = await fetch('/state');
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const stateData = await res.json();
applyStateResponse(stateData);
setStatus(`[POLL] Step ${stateData.step_count ?? '?'} | health ${Math.round(stateData.agent_health ?? 100)}%`);
} catch (err) {
setStatus(`[POLL] Error: ${err.message}`, true);
}
}
function togglePoll() {
const btn = document.getElementById('btn-poll');
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
btn.textContent = 'POLL STATE: OFF';
setStatus('State polling stopped.');
} else {
fetchAndApplyState();
pollTimer = setInterval(fetchAndApplyState, 2000);
btn.textContent = 'POLL STATE: ON';
setStatus('Polling /state every 2 s…');
}
}
async function setup() {
// Stop any running poll first
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
const btn = document.getElementById('btn-poll');
if (btn) btn.textContent = 'POLL STATE: OFF';
// Reset counters
agentMoveCount = 0;
agentMoveFlash = 0;
prevAgent = { x: -1, y: -1 };
const movesEl = document.getElementById('hud-moves');
if (movesEl) movesEl.textContent = 'MOVES: 0';
setStatus('Setup: resetting episode…');
await resetLive();
// Start polling after reset
if (!pollTimer) togglePoll();
setStatus('Setup complete — polling /state every 2 s. Watch agent move!');
}
function wireControls() {
document.getElementById('btn-setup').addEventListener('click', setup);
document.getElementById('btn-reset-live').addEventListener('click', resetLive);
document.getElementById('btn-reset-doors').addEventListener('click', () => resetUntilDoors(8));
document.getElementById('btn-wait').addEventListener('click', () => runAction({ action: 'wait' }, 'WAIT'));
document.getElementById('btn-move-north').addEventListener('click', () => runAction({ action: 'move', direction: 'north' }, 'MOVE N'));
document.getElementById('btn-move-south').addEventListener('click', () => runAction({ action: 'move', direction: 'south' }, 'MOVE S'));
document.getElementById('btn-move-east').addEventListener('click', () => runAction({ action: 'move', direction: 'east' }, 'MOVE E'));
document.getElementById('btn-move-west').addEventListener('click', () => runAction({ action: 'move', direction: 'west' }, 'MOVE W'));
document.getElementById('btn-look-north').addEventListener('click', () => runAction({ action: 'look', direction: 'north' }, 'LOOK N'));
document.getElementById('btn-look-south').addEventListener('click', () => runAction({ action: 'look', direction: 'south' }, 'LOOK S'));
document.getElementById('btn-look-east').addEventListener('click', () => runAction({ action: 'look', direction: 'east' }, 'LOOK E'));
document.getElementById('btn-look-west').addEventListener('click', () => runAction({ action: 'look', direction: 'west' }, 'LOOK W'));
document.getElementById('btn-open-door').addEventListener('click', () => {
if (DOORS.length === 0) { setStatus('No doors in this map template.', true); return; }
const doorId = (document.getElementById('door-id-input').value || '').trim();
if (!doorId) { setStatus('Provide a door id (example: door_1).', true); return; }
runAction({ action: 'door', target_id: doorId, door_state: 'open' }, `OPEN ${doorId}`);
});
document.getElementById('btn-close-door').addEventListener('click', () => {
if (DOORS.length === 0) { setStatus('No doors in this map template.', true); return; }
const doorId = (document.getElementById('door-id-input').value || '').trim();
if (!doorId) { setStatus('Provide a door id (example: door_1).', true); return; }
runAction({ action: 'door', target_id: doorId, door_state: 'close' }, `CLOSE ${doorId}`);
});
document.getElementById('btn-staff-add').addEventListener('click', () => {
const pos = randomWalkableCell();
STAFF.push({
id: `staff_${STAFF.length + 1}`,
x: pos.x,
y: pos.y,
phase: Math.random() * Math.PI * 2,
mood: staffPanicMode ? 'panicked' : 'calm',
});
setStatus(`Staff added. Active staff: ${STAFF.length}`);
render();
});
document.getElementById('btn-staff-remove').addEventListener('click', () => {
if (STAFF.length > 0) STAFF.pop();
setStatus(`Staff removed. Active staff: ${STAFF.length}`);
render();
});
document.getElementById('btn-staff-panic').addEventListener('click', () => {
staffPanicMode = !staffPanicMode;
STAFF.forEach(s => { s.mood = staffPanicMode ? 'panicked' : 'calm'; });
document.getElementById('btn-staff-panic').textContent = `STAFF PANIC: ${staffPanicMode ? 'ON' : 'OFF'}`;
setStatus(`Staff mode: ${staffPanicMode ? 'panicked' : 'calm'}`);
render();
});
document.getElementById('btn-auto').addEventListener('click', () => {
const btn = document.getElementById('btn-auto');
if (autoRunTimer) {
clearInterval(autoRunTimer);
autoRunTimer = null;
btn.textContent = 'AUTO WAIT: OFF';
setStatus('Auto wait stopped.');
return;
}
autoRunTimer = setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
btn.textContent = 'AUTO WAIT: ON';
setStatus('Auto wait running...');
});
document.getElementById('btn-copy-curl-reset').addEventListener('click', () => {
copyTextToClipboard(buildResetCurl());
});
document.getElementById('btn-copy-curl-step').addEventListener('click', () => {
copyTextToClipboard(buildStepCurl());
});
document.getElementById('btn-poll').addEventListener('click', togglePoll);
}
function buildLegend() {
const items = [
{bg:'linear-gradient(135deg,#C8A260,#D8B270)',l:'Corridor'},
{bg:'#E2D8C4',l:'Room Floor'},
{bg:'linear-gradient(180deg,#80889A,#5C6470)',l:'Outer Wall'},
{bg:'linear-gradient(180deg,#928880,#726860)',l:'Inner Wall'},
{bg:'#1A9038',l:'Door (open)'},
{bg:'#7A3E16',l:'Door (closed)'},
{bg:'#180C04',l:'Door (failed)'},
{bg:'#30A850',l:'Exit'},
{bg:'linear-gradient(135deg,#FFD040,#FF2000)',l:'Fire'},
{bg:'rgba(65,65,88,0.8)',l:'Smoke'},
{bg:'rgba(255,120,20,0.5)',l:'Heat Zone'},
{bg:'rgba(255,220,30,0.3)',l:'Route Plan'},
{bg:'rgba(100,160,255,0.2)',l:'Scan Radius'},
{bg:'#7de5ff',l:'Staff (calm)'},
{bg:'#ff9d5c',l:'Staff (panic)'},
{bg:'rgba(5,5,12,0.75)',l:'Fog (unseen)'},
{bg:'#E8C000',l:'Hazard Marker'},
{bg:'#BE0808',l:'Extinguisher'},
];
const el = document.getElementById('legend');
items.forEach(item => {
const div=document.createElement('div'); div.className='leg-item';
div.innerHTML=`<div class="leg-sw" style="background:${item.bg}"></div>${item.l}`;
el.appendChild(div);
});
}
// BOOT
buildDoorList();
buildLegend();
initStaff(5);
startStaffAnimation();
render();
wireControls();
setup();
</script>
</body>
</html>