| 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; } |
| |
| |
| .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 { |
| 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 { |
| 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; } } |
| |
| |
| .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-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; } |
| |
| |
| .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; } |
| |
| |
| .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"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="card"> |
| <div class="card-title">▣ DOOR STATUS</div> |
| <div id="door-list"></div> |
| </div> |
|
|
| |
| <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> |
| |
| |
| |
| const FLOOR = 0; |
| const WALL = 1; |
| const DOOR_OPEN = 2; |
| const DOOR_CLOSED = 3; |
| const EXIT = 4; |
| const OBSTACLE = 5; |
| const DOOR_FAILED = 6; |
| |
| let W = 16, H = 16; |
| const CELL = 36; |
| |
| |
| 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'; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| let EXPLORED = new Set(); |
| |
| |
| let ROUTE_PLAN = new Set(); |
| |
| |
| let AGENT = { x: 0, y: 0 }; |
| const SCAN_RADIUS = 4; |
| |
| |
| |
| const ITEMS = { |
| |
| '1,1':'desk', '2,1':'monitor', '3,1':'chair', '1,3':'filing', |
| |
| '5,1':'desk', '6,1':'monitor', '7,1':'chair', '5,3':'plant', '7,3':'extinguisher', |
| |
| '9,1':'desk', '10,1':'monitor', '11,1':'chair', '9,3':'hazard', '11,3':'plant', |
| |
| '13,1':'desk', '14,1':'sensor', '13,3':'filing', |
| |
| '3,6':'alarm', '12,6':'alarm', |
| '3,5':'sprinkler', '8,5':'sprinkler', '5,8':'sprinkler', |
| '2,5':'hazard', |
| |
| '1,11':'desk', '2,11':'monitor', '3,11':'chair', '1,13':'plant', |
| |
| '5,12':'desk', '6,12':'monitor', '5,14':'filing', |
| |
| '9,12':'desk', '10,12':'monitor', '11,12':'chair', '9,14':'plant', |
| |
| '13,12':'desk', '14,12':'sensor', '13,14':'filing', |
| }; |
| |
| |
| 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', |
| }; |
| |
| |
| 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; |
| |
| |
| let prevAgent = { x: -1, y: -1 }; |
| let agentMoveFlash = 0; |
| let agentMoveCount = 0; |
| |
| |
| |
| |
| const canvas = document.getElementById('map'); |
| const ctx = canvas.getContext('2d'); |
| |
| |
| |
| |
| |
| 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'){ |
| |
| 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); |
| |
| ctx.fillStyle='rgba(255,0,0,0.10)'; ctx.fillRect(px,py,s,s); |
| } |
| if(item==='exit_sign'){ |
| |
| 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); |
| |
| 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); |
| } |
| |
| |
| function drawDoorFailed(px, py) { |
| const s=CELL; |
| |
| ctx.fillStyle='#180C04'; ctx.fillRect(px,py,s,s); |
| |
| 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); |
| |
| 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); |
| |
| ctx.fillStyle='#300A02'; |
| ctx.fillRect(px,py,4,s); ctx.fillRect(px+s-4,py,4,s); ctx.fillRect(px,py,s,4); |
| |
| 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); |
| |
| 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); |
| } |
| |
| |
| |
| |
| |
| 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; |
| case 'sprinkler':drawSprinkler(px,py); break; |
| case 'alarm': drawAlarmTriggered(px,py); break; |
| case 'sensor': drawSensorNode(px,py); break; |
| } |
| ctx.restore(); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| 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); |
| |
| ctx.fillStyle='#00E060'; ctx.fillRect(px+s/2+5,py+19,2,2); |
| |
| ctx.fillStyle='#202028'; ctx.fillRect(px+s/2-2,py+20,5,4); ctx.fillRect(px+s/2-4,py+23,9,2); |
| } |
| |
| |
| function drawChair(px, py) { |
| const s=CELL; |
| |
| ctx.fillStyle='#2A3050'; ctx.fillRect(px+7,py+16,s-14,10); |
| ctx.fillStyle='#3A4068'; ctx.fillRect(px+7,py+16,s-14,3); |
| |
| ctx.fillStyle='#2A3050'; ctx.fillRect(px+9,py+8,s-18,9); |
| ctx.fillStyle='#3A4068'; ctx.fillRect(px+9,py+8,s-18,2); |
| |
| ctx.fillStyle='#303844'; ctx.fillRect(px+5,py+16,4,7); ctx.fillRect(px+s-9,py+16,4,7); |
| |
| 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(); |
| } |
| } |
| |
| |
| function drawPlant(px, py) { |
| const s=CELL, cx=px+s/2; |
| |
| 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); |
| |
| 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(); |
| } |
| |
| |
| 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); }); |
| } |
| |
| |
| function drawExtinguisher(px, py) { |
| const s=CELL, cx=px+s/2; |
| |
| 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); |
| |
| ctx.fillStyle='#282828'; ctx.fillRect(cx-1,py+4,3,5); |
| ctx.fillRect(cx+1,py+4,5,2); |
| |
| ctx.fillStyle='#FFFFFF'; ctx.fillRect(cx-5,py+16,10,3); |
| |
| ctx.fillStyle='#FFFFFF'; ctx.font='bold 4px monospace'; ctx.textAlign='center'; |
| ctx.fillText('EXT',cx,py+23); |
| |
| ctx.fillStyle='#700000'; ctx.fillRect(cx-5,py+26,10,2); |
| |
| ctx.fillStyle='rgba(220,30,10,0.12)'; ctx.fillRect(px,py,s,s); |
| } |
| |
| |
| function drawHazardMarker(px, py) { |
| const s=CELL, cx=px+s/2, cy=py+s/2; |
| |
| ctx.fillStyle='rgba(255,190,0,0.18)'; ctx.fillRect(px,py,s,s); |
| |
| 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(); |
| |
| 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(); |
| |
| ctx.fillStyle='#FFE030'; ctx.font='bold 11px monospace'; |
| ctx.textAlign='center'; ctx.textBaseline='middle'; |
| ctx.fillText('!',cx,cy+1); ctx.textBaseline='alphabetic'; |
| |
| ctx.strokeStyle='#A08000'; ctx.lineWidth=1; |
| ctx.strokeRect(px+.5,py+.5,s-1,s-1); |
| } |
| |
| |
| function drawSprinkler(px, py) { |
| const s=CELL, cx=px+s/2; |
| |
| ctx.fillStyle='rgba(50,100,200,0.14)'; ctx.fillRect(px,py,s,s); |
| |
| ctx.fillStyle='#909098'; ctx.fillRect(cx-2,py+1,4,4); |
| ctx.fillStyle='#A8A8B0'; ctx.fillRect(cx-5,py+4,10,2); |
| |
| 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(); |
| }); |
| } |
| |
| |
| function drawAlarmTriggered(px, py) { |
| const s=CELL, cx=px+s/2; |
| |
| 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); |
| |
| 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(); |
| |
| ctx.font='bold 4px monospace'; ctx.textAlign='center'; |
| ctx.fillStyle='#FF6060'; ctx.fillText('FIRE',cx,py+s-4); |
| } |
| |
| |
| function drawSensorNode(px, py) { |
| const s=CELL, cx=px+s/2; |
| |
| 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(); |
| |
| ctx.fillStyle='#00FF70'; ctx.beginPath(); ctx.arc(cx,py+9,2,0,Math.PI*2); ctx.fill(); |
| |
| 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(); }); |
| |
| ctx.font='bold 4px monospace'; ctx.textAlign='center'; |
| ctx.fillStyle='#80A8C8'; ctx.fillText('SENS',cx,py+22); |
| |
| ctx.fillStyle='rgba(0,180,100,0.12)'; ctx.fillRect(px,py,s,s); |
| } |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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); |
| |
| 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'; |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| 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([]); |
| |
| 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(); |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| |
| |
| function drawAgent() { |
| const px=AGENT.x*CELL, py=AGENT.y*CELL, s=CELL; |
| const cx=px+s/2, cy=py+s/2; |
| |
| |
| 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--; |
| } |
| |
| |
| 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); |
| |
| 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(); |
| |
| ctx.fillStyle='#180C04'; ctx.fillRect(cx-6,cy+8,5,4); ctx.fillRect(cx+1,cy+8,5,4); |
| |
| 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); |
| |
| 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(); |
| |
| 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); |
| |
| 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); |
| |
| ctx.fillStyle='#F0C070'; ctx.fillRect(cx-2,cy-11,4,3); |
| |
| 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); |
| |
| 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); |
| |
| 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); |
| |
| 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(); |
| } |
| } |
| |
| |
| |
| |
| 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(); |
| } |
| |
| |
| |
| |
| function render() { |
| |
| 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); |
| |
| |
| 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; |
| |
| 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; |
| } |
| } |
| |
| |
| drawZoneLabels(); |
| |
| |
| 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); |
| } |
| |
| |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) |
| drawTemperature(x*CELL, y*CELL, x, y); |
| |
| |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) |
| drawRoutePlan(x*CELL, y*CELL, x, y); |
| |
| |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) |
| drawScanRadius(x*CELL, y*CELL, x, y); |
| |
| |
| 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)]); |
| } |
| |
| |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) |
| drawFog(x*CELL, y*CELL, x, y); |
| |
| |
| STAFF.forEach(drawStaff); |
| drawAgent(); |
| |
| |
| 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); |
| } |
| |
| |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| if (prevAgent.x !== -1 && (newX !== prevAgent.x || newY !== prevAgent.y)) { |
| agentMoveFlash = 18; |
| 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:8001/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); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function applyStateResponse(s) { |
| |
| 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'; |
| |
| |
| const FIRE_THRESH = 0.1; |
| const fireMax = s.fire_grid ? Math.max(...s.fire_grid) : 0; |
| const fireVisible = fireMax > FIRE_THRESH; |
| |
| |
| 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: [], |
| 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() { |
| |
| if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } |
| const btn = document.getElementById('btn-poll'); |
| if (btn) btn.textContent = 'POLL STATE: OFF'; |
| |
| |
| 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(); |
| |
| |
| 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); |
| }); |
| } |
| |
| |
| buildDoorList(); |
| buildLegend(); |
| initStaff(5); |
| startStaffAnimation(); |
| render(); |
| wireControls(); |
| setup(); |
| </script> |
| </body> |
| </html> |
|
|