Spaces:
Sleeping
Sleeping
| i | |
| <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">♥ ♥ ♥ ♥ ♥ 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 & 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> | |