Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
| <title>Pyre — Crisis Navigation Demo</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --s0:#f5f2ed; --s1:#ffffff; --s2:#faf9f6; --s3:#f0ede8; | |
| --bd:#e4dfd7; --bd2:#cdc7be; | |
| --t1:#1c1712; --t2:#6b6460; --t3:#a8a29e; | |
| --fire:#c2410c; --fire2:rgba(194,65,12,0.08); | |
| --blue:#1d4ed8; --blue2:rgba(29,78,216,0.08); | |
| --green:#166534; --green2:rgba(22,101,52,0.08); | |
| --amber:#92400e; --amber2:rgba(146,64,14,0.08); | |
| --red:#b91c1c; --red2:rgba(185,28,28,0.08); | |
| --mono:'DM Mono','JetBrains Mono',monospace; | |
| --sans:'DM Sans','Inter',system-ui,sans-serif; | |
| --r:6px; | |
| } | |
| html, body { height:100%; background:var(--s0); color:var(--t1); font-family:var(--sans); overflow:hidden; font-size:13px; } | |
| /* ── Shell ── */ | |
| .shell { display:grid; grid-template-rows:44px 1fr 48px; height:100vh; min-width:1100px; } | |
| /* ── Topbar ── */ | |
| .topbar { | |
| background:var(--s1); border-bottom:1px solid var(--bd); | |
| display:flex; align-items:center; padding:0 20px; gap:16px; | |
| } | |
| .brand { display:flex; align-items:center; gap:9px; } | |
| .brand-icon { | |
| width:28px; height:28px; border-radius:6px; | |
| background:var(--fire); display:flex; align-items:center; justify-content:center; | |
| font-size:14px; flex-shrink:0; | |
| } | |
| .brand-name { font-family:var(--mono); font-size:14px; font-weight:500; letter-spacing:.05em; } | |
| .brand-sep { width:1px; height:18px; background:var(--bd); } | |
| .brand-sub { font-size:11px; color:var(--t3); } | |
| .topbar-sep { width:1px; height:20px; background:var(--bd); } | |
| .topbar-right { margin-left:auto; display:flex; align-items:center; gap:8px; } | |
| .seg { display:flex; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--s2); } | |
| .seg button { | |
| background:none; border:none; padding:5px 11px; | |
| font-family:var(--mono); font-size:11px; font-weight:500; color:var(--t2); | |
| cursor:pointer; letter-spacing:.03em; transition:background .1s,color .1s; | |
| } | |
| .seg button:hover { background:var(--s3); color:var(--t1); } | |
| .seg button.on { background:var(--s1); color:var(--t1); box-shadow:inset 0 1px 0 rgba(255,255,255,.8); } | |
| .seg button.on.fire { color:var(--fire); } | |
| .seg button + button { border-left:1px solid var(--bd); } | |
| .live-chip { | |
| font-family:var(--mono); font-size:10px; font-weight:500; | |
| padding:3px 10px; border-radius:12px; border:1px solid; letter-spacing:.05em; | |
| } | |
| .live-chip.demo { border-color:var(--bd2); color:var(--t2); } | |
| .live-chip.online { border-color:rgba(22,101,52,.4); color:var(--green); background:var(--green2); } | |
| /* ── Content ── */ | |
| .content { display:grid; grid-template-columns:1fr 300px; overflow:hidden; min-height:0; } | |
| /* ── Canvas zone ── */ | |
| .canvas-zone { | |
| display:flex; align-items:center; justify-content:center; | |
| background:var(--s0); padding:20px; position:relative; overflow:hidden; | |
| } | |
| .canvas-frame { | |
| position:relative; border-radius:10px; overflow:hidden; | |
| box-shadow:0 0 0 1px var(--bd2),0 24px 64px rgba(0,0,0,.20),0 6px 16px rgba(0,0,0,.12); | |
| max-width:min(calc(100vh - 140px), calc(100vw - 340px)); | |
| max-height:calc(100vh - 140px); | |
| aspect-ratio:1; | |
| } | |
| #mainCanvas { display:block; width:100%; height:100%; } | |
| /* ── HUD overlays ── */ | |
| .hud-tl, .hud-tr { position:absolute; top:12px; pointer-events:none; z-index:2; } | |
| .hud-tl { left:12px; } | |
| .hud-tr { right:12px; } | |
| .hud-card { | |
| background:rgba(12,9,7,0.84); | |
| border:1px solid rgba(255,255,255,0.10); | |
| border-radius:8px; padding:9px 12px; | |
| backdrop-filter:blur(10px); color:#e8e0d8; | |
| min-width:140px; | |
| } | |
| .hud-row { display:flex; align-items:center; gap:8px; margin-bottom:5px; } | |
| .hud-row:last-child { margin-bottom:0; } | |
| .hlbl { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.8); letter-spacing:.10em; } | |
| .hval { font-family:var(--mono); font-size:13px; font-weight:500; color:#f0e8e0; } | |
| .hbar-bg { width:84px; height:5px; background:rgba(255,255,255,.08); border-radius:3px; overflow:hidden; flex:1; } | |
| .hbar-fill { height:100%; border-radius:3px; transition:width .35s,background .35s; } | |
| .hbar-fill.g { background:#4ade80; } | |
| .hbar-fill.m { background:#fbbf24; } | |
| .hbar-fill.c { background:#f87171; animation:hblink .5s infinite alternate; } | |
| @keyframes hblink { to { opacity:.25 } } | |
| .hstatus { font-family:var(--mono); font-size:9px; } | |
| .hstatus.good { color:#4ade80; } | |
| .hstatus.moderate { color:#fbbf24; } | |
| .hstatus.low { color:#fb923c; } | |
| .hstatus.critical { color:#f87171; } | |
| .step-val { font-family:var(--mono); font-size:13px; font-weight:500; color:#93c5fd; } | |
| .sbar-bg { width:100%; height:3px; background:rgba(255,255,255,.07); border-radius:2px; overflow:hidden; margin-top:5px; } | |
| .sbar-fill { height:100%; border-radius:2px; background:#60a5fa; transition:width .35s; } | |
| .step-meta { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.55); margin-top:3px; } | |
| #epBanner { | |
| display:none; position:absolute; | |
| bottom:14px; left:50%; transform:translateX(-50%); | |
| font-family:var(--mono); font-size:13px; font-weight:500; | |
| letter-spacing:.04em; padding:10px 22px; | |
| border-radius:8px; backdrop-filter:blur(10px); | |
| pointer-events:none; white-space:nowrap; z-index:3; | |
| } | |
| #epBanner.ok { background:rgba(21,128,61,.82); border:1px solid rgba(74,222,128,.3); color:#dcfce7; } | |
| #epBanner.bad { background:rgba(153,27,27,.82); border:1px solid rgba(248,113,113,.3); color:#fee2e2; } | |
| #epBanner.tmo { background:rgba(120,53,15,.82); border:1px solid rgba(251,191,36,.3); color:#fef3c7; } | |
| /* ── Legend badge ── */ | |
| .legend { | |
| position:absolute; bottom:12px; left:12px; z-index:2; pointer-events:none; | |
| display:flex; flex-direction:column; gap:4px; | |
| } | |
| .leg-row { display:flex; align-items:center; gap:5px; font-family:var(--mono); font-size:9px; color:rgba(220,210,200,.7); } | |
| .leg-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } | |
| /* ── Side panel ── */ | |
| .side { | |
| background:var(--s1); border-left:1px solid var(--bd); | |
| overflow-y:auto; overflow-x:hidden; | |
| scrollbar-width:thin; scrollbar-color:var(--bd) transparent; | |
| display:flex; flex-direction:column; | |
| } | |
| ::-webkit-scrollbar { width:3px; } | |
| ::-webkit-scrollbar-thumb { background:var(--bd); border-radius:2px; } | |
| .side-sec { padding:14px 16px; border-bottom:1px solid var(--bd); } | |
| .side-sec:last-child { border-bottom:none; flex:1; min-height:0; display:flex; flex-direction:column; } | |
| .sec-hd { | |
| font-family:var(--mono); font-size:9px; font-weight:500; | |
| letter-spacing:.12em; text-transform:uppercase; color:var(--t3); | |
| margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; | |
| } | |
| .sg { display:grid; grid-template-columns:1fr 1fr; gap:6px; } | |
| .sc { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); padding:8px 10px; } | |
| .sc-l { font-size:10px; color:var(--t3); margin-bottom:2px; } | |
| .sc-v { font-family:var(--mono); font-size:14px; font-weight:500; color:var(--t1); } | |
| .sc-v.fire { color:var(--fire); } | |
| .sc-v.blue { color:var(--blue); } | |
| .sc-v.amber { color:var(--amber); } | |
| .meta-row { | |
| display:flex; justify-content:space-between; align-items:center; | |
| margin-top:8px; padding-top:8px; border-top:1px solid var(--bd); font-size:11px; | |
| } | |
| /* Health legend bar */ | |
| .health-legend { display:flex; gap:3px; margin-top:8px; } | |
| .hl-seg { height:4px; border-radius:2px; flex:1; } | |
| .hl-seg.g { background:#4ade80; } | |
| .hl-seg.m { background:#fbbf24; } | |
| .hl-seg.l { background:#fb923c; } | |
| .hl-seg.c { background:#f87171; } | |
| /* Sparkline chart */ | |
| .chart-wrap { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); overflow:hidden; margin-top:6px; } | |
| .chart-wrap canvas { display:block; width:100% ; } | |
| /* Event log */ | |
| .elog { display:flex; flex-direction:column; gap:1px; overflow-y:auto; flex:1; } | |
| .erow { | |
| display:flex; align-items:baseline; gap:7px; | |
| padding:3px 6px; border-radius:4px; | |
| font-family:var(--mono); font-size:10px; | |
| border-left:2px solid transparent; | |
| animation:ein .15s ease; | |
| } | |
| @keyframes ein { from { opacity:0; transform:translateY(-2px) } } | |
| .erow:hover { background:var(--s3); } | |
| .erow.alarm { border-left-color:var(--fire); background:var(--fire2); } | |
| .estep { color:var(--t3); min-width:22px; font-size:9px; } | |
| .etext { color:var(--t2); flex:1; line-height:1.4; } | |
| .erwd { font-size:9px; min-width:32px; text-align:right; } | |
| .erwd.p { color:var(--green); } | |
| .erwd.n { color:var(--red); } | |
| /* ── Bottom bar ── */ | |
| .botbar { | |
| background:var(--s1); border-top:1px solid var(--bd); | |
| display:flex; align-items:center; padding:0 16px; gap:6px; | |
| } | |
| .bsep { width:1px; height:20px; background:var(--bd); margin:0 4px; } | |
| .icn-btn { | |
| width:30px; height:30px; display:flex; align-items:center; justify-content:center; | |
| background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); | |
| color:var(--t2); font-size:12px; cursor:pointer; | |
| transition:background .1s,color .1s,border-color .1s; | |
| } | |
| .icn-btn:hover { background:var(--s3); color:var(--t1); border-color:var(--bd2); } | |
| .icn-btn:active { transform:scale(.94); } | |
| .icn-btn.play { background:var(--fire2); border-color:rgba(194,65,12,.3); color:var(--fire); } | |
| .icn-btn.play:hover { background:rgba(194,65,12,.14); } | |
| .seed-wrap { display:flex; align-items:center; gap:6px; } | |
| .seed-lbl { font-size:11px; color:var(--t3); } | |
| #seedInput { | |
| width:60px; background:var(--s2); border:1px solid var(--bd); | |
| color:var(--t1); font-family:var(--mono); font-size:11px; | |
| padding:4px 8px; border-radius:var(--r); outline:none; | |
| } | |
| #seedInput:focus { border-color:var(--bd2); } | |
| .keys { margin-left:auto; display:flex; gap:12px; align-items:center; font-size:10px; color:var(--t3); } | |
| .keys kbd { | |
| background:var(--s2); border:1px solid var(--bd2); border-radius:3px; | |
| padding:1px 5px; font-family:var(--mono); font-size:10px; color:var(--t2); | |
| } | |
| /* Toast */ | |
| #toast { | |
| position:fixed; bottom:58px; left:50%; | |
| transform:translateX(-50%) translateY(6px); | |
| background:var(--t1); color:var(--s0); | |
| font-family:var(--mono); font-size:11px; | |
| padding:7px 16px; border-radius:20px; | |
| opacity:0; pointer-events:none; z-index:99; | |
| transition:opacity .22s,transform .22s; | |
| } | |
| #toast.up { opacity:1; transform:translateX(-50%) translateY(0); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <!-- ── Topbar ── --> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <div class="brand-icon">🔥</div> | |
| <span class="brand-name">Pyre</span> | |
| <div class="brand-sep"></div> | |
| <span class="brand-sub">Crisis Navigation Environment</span> | |
| </div> | |
| <div class="topbar-sep"></div> | |
| <div class="seg"> | |
| <button class="diff-btn on fire" data-diff="easy">Easy</button> | |
| <button class="diff-btn" data-diff="medium">Medium</button> | |
| <button class="diff-btn" data-diff="hard">Hard</button> | |
| </div> | |
| <div class="topbar-right"> | |
| <span id="liveChip" class="live-chip demo">Demo Mode</span> | |
| </div> | |
| </header> | |
| <!-- ── Content ── --> | |
| <div class="content"> | |
| <!-- Canvas zone --> | |
| <div class="canvas-zone"> | |
| <div class="canvas-frame"> | |
| <canvas id="mainCanvas" width="576" height="576"></canvas> | |
| <!-- Top-left HUD: Health --> | |
| <div class="hud-tl"> | |
| <div class="hud-card"> | |
| <div class="hud-row"> | |
| <span class="hlbl">HP</span> | |
| <div class="hbar-bg"><div id="healthBar" class="hbar-fill g" style="width:100%"></div></div> | |
| <span id="healthVal" class="hval">100</span> | |
| </div> | |
| <div class="hud-row" style="gap:10px"> | |
| <span id="healthStatus" class="hstatus good">Good</span> | |
| <span id="smokeTag" style="font-family:var(--mono);font-size:9px;color:rgba(168,162,158,.6);margin-left:auto">smoke: clear</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Top-right HUD: Steps --> | |
| <div class="hud-tr"> | |
| <div class="hud-card" style="text-align:right"> | |
| <div class="hud-row" style="justify-content:flex-end"> | |
| <span class="hlbl">STEP</span> | |
| <span id="stepCount" class="step-val">0 / 200</span> | |
| </div> | |
| <div class="sbar-bg"><div id="stepBar" class="sbar-fill" style="width:0%"></div></div> | |
| <div id="diffTag" class="step-meta">easy · small_office</div> | |
| </div> | |
| </div> | |
| <!-- Episode end banner --> | |
| <div id="epBanner"></div> | |
| <!-- Legend --> | |
| <div class="legend"> | |
| <div class="leg-row"><div class="leg-dot" style="background:#0ea5e9"></div>Agent (human)</div> | |
| <div class="leg-row"><div class="leg-dot" style="background:rgba(2,132,199,.45)"></div>Footprint trail</div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#c2410c"></div>Fire</div> | |
| <div class="leg-row"><div class="leg-dot" style="background:rgba(200,210,220,.7)"></div>Smoke</div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#22c55e"></div>Exit</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Side panel --> | |
| <aside class="side"> | |
| <div class="side-sec"> | |
| <div class="sec-hd">Agent Status</div> | |
| <div class="sg"> | |
| <div class="sc"><div class="sc-l">Health</div><div id="sHealthVal" class="sc-v blue">100</div></div> | |
| <div class="sc"><div class="sc-l">Status</div><div id="sStatusVal" class="sc-v">Good</div></div> | |
| <div class="sc"><div class="sc-l">Smoke</div><div id="sSmokeVal" class="sc-v">clear</div></div> | |
| <div class="sc"><div class="sc-l">Reward</div><div id="sRewardVal" class="sc-v blue">0.00</div></div> | |
| </div> | |
| <div class="health-legend"> | |
| <div class="hl-seg g" title="Good (>75)"></div> | |
| <div class="hl-seg m" title="Moderate (>50)"></div> | |
| <div class="hl-seg l" title="Low (>25)"></div> | |
| <div class="hl-seg c" title="Critical (≤25)"></div> | |
| </div> | |
| </div> | |
| <div class="side-sec"> | |
| <div class="sec-hd"> | |
| Episode | |
| <span id="sBurnVal" style="color:var(--fire);font-family:var(--mono);font-size:10px">0% burned</span> | |
| </div> | |
| <div class="sg"> | |
| <div class="sc"><div class="sc-l">Fire cells</div><div id="sFireVal" class="sc-v fire">0</div></div> | |
| <div class="sc"><div class="sc-l">Step</div><div id="sStepVal" class="sc-v">0</div></div> | |
| <div class="sc"><div class="sc-l">Wind</div><div id="sWindVal" class="sc-v amber">CALM</div></div> | |
| <div class="sc"><div class="sc-l">Spread ρ</div><div id="sSpreadVal" class="sc-v fire">0.10</div></div> | |
| </div> | |
| <div class="meta-row"> | |
| <span style="color:var(--t3);font-size:11px">Exits blocked</span> | |
| <span id="sExitBlocked" style="font-family:var(--mono);font-size:11px;color:var(--green)">0 / 2</span> | |
| </div> | |
| </div> | |
| <div class="side-sec"> | |
| <div class="sec-hd"> | |
| Cumulative Reward | |
| <span id="sRewardLast" style="color:var(--t2);font-size:10px">—</span> | |
| </div> | |
| <div class="chart-wrap" style="height:50px"> | |
| <canvas id="rewardChart" height="50"></canvas> | |
| </div> | |
| </div> | |
| <div class="side-sec"> | |
| <div class="sec-hd"> | |
| Active Fire | |
| <span id="sFireLast" style="color:var(--t2);font-size:10px">0 cells</span> | |
| </div> | |
| <div class="chart-wrap" style="height:44px"> | |
| <canvas id="fireChart" height="44"></canvas> | |
| </div> | |
| </div> | |
| <div class="side-sec"> | |
| <div class="sec-hd">Event Log</div> | |
| <div id="eventLog" class="elog"> | |
| <div class="erow"> | |
| <span class="estep">000</span> | |
| <span class="etext">Episode started. Assess surroundings.</span> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| </div> | |
| <!-- ── Bottom bar ── --> | |
| <footer class="botbar"> | |
| <button id="resetBtn" class="icn-btn" title="Reset (R)">↺</button> | |
| <button id="playBtn" class="icn-btn play" title="Play / Pause (Space)">▶</button> | |
| <button id="stepBtn" class="icn-btn" title="Step once (→)">⏭</button> | |
| <div class="bsep"></div> | |
| <div class="seg"> | |
| <button class="spd-btn" data-s="0.5">½×</button> | |
| <button class="spd-btn on" data-s="1">1×</button> | |
| <button class="spd-btn" data-s="2">2×</button> | |
| <button class="spd-btn" data-s="4">4×</button> | |
| </div> | |
| <div class="bsep"></div> | |
| <div class="seed-wrap"> | |
| <span class="seed-lbl">Seed</span> | |
| <input id="seedInput" type="number" value="42" min="0" max="9999"> | |
| </div> | |
| <div class="keys"> | |
| <span><kbd>Space</kbd> play</span> | |
| <span><kbd>→</kbd> step</span> | |
| <span><kbd>R</kbd> reset</span> | |
| </div> | |
| </footer> | |
| </div> | |
| <div id="toast"></div> | |
| <script> | |
| ; | |
| // ── Constants ───────────────────────────────────────────────────────────────── | |
| const FLOOR=0, WALL=1, DOOR_OPEN=2, DOOR_CLOSED=3, EXIT=4, OBSTACLE=5; | |
| const FIRE_IGNITION=0.1, FIRE_BURNING=0.3, FIRE_GAIN=0.15, BURNOUT_TICKS=5; | |
| const SMOKE_SPREAD=0.20, SMOKE_DECAY=0.025, SMOKE_DOOR=0.4; | |
| const EXIT_BLOCK=0.50; | |
| const CARDINAL=[[0,-1],[0,1],[-1,0],[1,0]]; | |
| const WIND_DIRS={N:[0,-1],NE:[1,-1],E:[1,0],SE:[1,1],S:[0,1],SW:[-1,1],W:[-1,0],NW:[-1,-1],CALM:[0,0]}; | |
| const DIFFICULTY = { | |
| easy: { nSrc:[1,1], spread:[.10,.20], humid:[.30,.50], winds:['CALM'], maxSteps:200 }, | |
| medium: { nSrc:[2,3], spread:[.15,.35], humid:[.15,.40], winds:Object.keys(WIND_DIRS), maxSteps:150 }, | |
| hard: { nSrc:[3,4], spread:[.30,.50], humid:[.05,.20], winds:['N','NE','E','SE','S','SW','W','NW'], maxSteps:100 }, | |
| }; | |
| // ── Seeded PRNG (mulberry32) ────────────────────────────────────────────────── | |
| function makePRNG(seed) { | |
| let s = seed >>> 0; | |
| return () => { | |
| s += 0x6D2B79F5; | |
| let t = s; | |
| t = Math.imul(t ^ t>>>15, t|1); | |
| t ^= t + Math.imul(t^t>>>7, t|61); | |
| return ((t^t>>>14)>>>0) / 4294967296; | |
| }; | |
| } | |
| // ── Fire color ramp ─────────────────────────────────────────────────────────── | |
| function fireColor(t, a=1) { | |
| t = Math.max(0, Math.min(1, t)); | |
| let r,g,b; | |
| if (t<.15){ const s=t/.15; r=~~(98+88*s); g=~~(6*s); b=0; } | |
| else if (t<.40){ const s=(t-.15)/.25; r=~~(186+60*s); g=~~(6+60*s); b=0; } | |
| else if (t<.65){ const s=(t-.40)/.25; r=~~(246+9*s); g=~~(66+112*s); b=~~(9*s); } | |
| else if (t<.85){ const s=(t-.65)/.20; r=255; g=~~(178+57*s); b=~~(9+32*s); } | |
| else { const s=(t-.85)/.15; r=255; g=~~(235+20*s); b=~~(41+214*s); } | |
| return `rgba(${r},${g},${b},${a})`; | |
| } | |
| // ── Floor plan: small office 16×16 ─────────────────────────────────────────── | |
| const LAYOUT = [ | |
| [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4], | |
| [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], | |
| [1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1], | |
| [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], | |
| ].flat(); | |
| const W=16, H=16; | |
| const EXITS=[[0,6],[15,8]]; | |
| const DOORS=[[2,4],[6,4],[10,4],[14,4],[2,10],[6,10],[10,10],[14,10]]; | |
| // ── BFS utilities ───────────────────────────────────────────────────────────── | |
| function bfsDist(ax, ay, exits, cellGrid) { | |
| const targets = exits.filter(([ex,ey])=>cellGrid[ey*W+ex]!==OBSTACLE); | |
| if (!targets.length) return 999; | |
| const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]]; | |
| while (q.length) { | |
| const [x,y,d]=q.shift(); | |
| for (const [dx,dy] of CARDINAL) { | |
| const nx=x+dx, ny=y+dy; | |
| if (nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const k=`${nx},${ny}`; | |
| if (vis.has(k)) continue; | |
| const ct=cellGrid[ny*W+nx]; | |
| if (ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue; | |
| if (targets.some(([ex,ey])=>ex===nx&&ey===ny)) return d+1; | |
| vis.add(k); q.push([nx,ny,d+1]); | |
| } | |
| } | |
| return 999; | |
| } | |
| function computeVisible(ax, ay, cellGrid, smokeGrid) { | |
| const smoke=smokeGrid[ay*W+ax]; | |
| const radius=smoke>.5?2:smoke>.2?3:5; | |
| const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]], seen=new Set([`${ax},${ay}`]); | |
| while (q.length) { | |
| const [x,y,d]=q.shift(); | |
| if (d>=radius) continue; | |
| for (const [dx,dy] of CARDINAL) { | |
| const nx=x+dx, ny=y+dy; | |
| if (nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const k=`${nx},${ny}`; | |
| if (seen.has(k)) continue; | |
| seen.add(k); | |
| const ct=cellGrid[ny*W+nx]; | |
| if (ct===WALL) continue; | |
| vis.add(k); q.push([nx,ny,d+1]); | |
| } | |
| } | |
| return vis; | |
| } | |
| // ── Simulation state ────────────────────────────────────────────────────────── | |
| let state = null; | |
| function buildState(difficulty='easy', seed=42) { | |
| const p = DIFFICULTY[difficulty]; | |
| const rng = makePRNG(seed); | |
| const cellGrid = LAYOUT.slice(); | |
| // Randomly close some doors | |
| const doorReg = {}; | |
| DOORS.forEach((d,i) => { | |
| doorReg[`door_${i+1}`] = d; | |
| if (rng()<0.3) cellGrid[d[1]*W+d[0]] = DOOR_CLOSED; | |
| }); | |
| const nSrc = Math.round(p.nSrc[0] + rng()*(p.nSrc[1]-p.nSrc[0])); | |
| const pSpread = p.spread[0] + rng()*(p.spread[1]-p.spread[0]); | |
| const humidity = p.humid[0] + rng()*(p.humid[1]-p.humid[0]); | |
| const windDir = p.winds[Math.floor(rng()*p.winds.length)]; | |
| const wv = WIND_DIRS[windDir]||[0,0]; | |
| const fireGrid = new Float32Array(W*H); | |
| const smokeGrid = new Float32Array(W*H); | |
| const burnTimers= new Int32Array(W*H); | |
| // Spawn agent in corridor | |
| const spawns=[]; | |
| for(let y=5;y<=9;y++) for(let x=1;x<=14;x++) if(cellGrid[y*W+x]===FLOOR) spawns.push([x,y]); | |
| const sp = spawns[Math.floor(rng()*spawns.length)]; | |
| // Place fire far from agent and exits | |
| const candidates=[]; | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| if(cellGrid[y*W+x]===FLOOR) { | |
| const farAgent=Math.abs(x-sp[0])+Math.abs(y-sp[1])>=5; | |
| const farExits=EXITS.every(([ex,ey])=>Math.abs(x-ex)+Math.abs(y-ey)>=4); | |
| if(farAgent&&farExits) candidates.push([x,y]); | |
| } | |
| } | |
| candidates.sort(()=>rng()-.5); | |
| for(let i=0;i<Math.min(nSrc,candidates.length);i++) { | |
| const [fx,fy]=candidates[i]; | |
| fireGrid[fy*W+fx]=FIRE_IGNITION; | |
| } | |
| const visible = computeVisible(sp[0],sp[1],cellGrid,smokeGrid); | |
| const explore = new Set(visible); | |
| return { | |
| cellGrid, fireGrid, smokeGrid, burnTimers, doorReg, | |
| pSpread, humidity, windDir, wx:wv[0], wy:wv[1], | |
| effectiveSpread: pSpread * Math.max(0, 1-humidity), | |
| maxSteps: p.maxSteps, stepCount:0, | |
| agentX:sp[0], agentY:sp[1], | |
| agentHealth:100, agentAlive:true, agentEvacuated:false, | |
| totalReward:0, lastReward:0, | |
| rewardHistory:[], fireSizeHistory:[], | |
| eventLog:[{step:0,text:'Episode started. Assess your surroundings.',reward:0}], | |
| visibleCells:visible, exploreSet:explore, | |
| difficulty, seed, | |
| rng2: makePRNG(seed+1), | |
| }; | |
| } | |
| // ── Fire simulation step ────────────────────────────────────────────────────── | |
| function simStep(s) { | |
| const {cellGrid,fireGrid,smokeGrid,burnTimers,pSpread,effectiveSpread,wx,wy,rng2} = s; | |
| const ignite = new Uint8Array(W*H); | |
| const burned = []; | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const i=y*W+x; | |
| if(fireGrid[i]<FIRE_BURNING) continue; | |
| for(const [dx,dy] of CARDINAL) { | |
| const nx=x+dx, ny=y+dy; | |
| if(nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const ni=ny*W+nx, nct=cellGrid[ni]; | |
| if(nct===WALL||nct===OBSTACLE||fireGrid[ni]>0) continue; | |
| let p = nct===DOOR_CLOSED ? effectiveSpread*.15 : effectiveSpread; | |
| const dot=dx*wx+dy*wy; | |
| p *= (dot>0?2.0:dot<0?0.5:1.0); | |
| if(rng2()<Math.min(1,p)) ignite[ni]=1; | |
| } | |
| } | |
| const nf=fireGrid.slice(), nt=burnTimers.slice(); | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const i=y*W+x, ct=cellGrid[i]; | |
| if(ct===WALL||ct===OBSTACLE) continue; | |
| if(fireGrid[i]>0) { | |
| nf[i]=Math.min(1,fireGrid[i]+FIRE_GAIN); | |
| if(fireGrid[i]>=FIRE_BURNING) nt[i]++; | |
| if(nt[i]>=BURNOUT_TICKS&&nf[i]>=1) { | |
| cellGrid[i]=OBSTACLE; nf[i]=0; nt[i]=0; burned.push([x,y]); | |
| } | |
| } else if(ignite[i]) { nf[i]=FIRE_IGNITION; nt[i]=0; } | |
| } | |
| for(let i=0;i<W*H;i++) { fireGrid[i]=nf[i]; burnTimers[i]=nt[i]; } | |
| // Smoke | |
| const ns=smokeGrid.slice(); | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const i=y*W+x, ct=cellGrid[i]; | |
| if(ct===WALL||ct===OBSTACLE) continue; | |
| if(fireGrid[i]>=FIRE_BURNING) ns[i]=Math.min(1,smokeGrid[i]+0.3); | |
| for(const [dx,dy] of CARDINAL) { | |
| const nx=x+dx, ny=y+dy; | |
| if(nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const ni=ny*W+nx, nct=cellGrid[ni]; | |
| if(nct===WALL||nct===OBSTACLE) continue; | |
| const diff=smokeGrid[i]-smokeGrid[ni]; | |
| if(diff<=0) continue; | |
| let rate=SMOKE_SPREAD; | |
| if(nct===DOOR_CLOSED) rate*=SMOKE_DOOR; | |
| ns[ni]=Math.min(1,ns[ni]+Math.min(diff*rate,diff*.5)); | |
| } | |
| ns[i]=Math.max(0,ns[i]-SMOKE_DECAY); | |
| } | |
| for(let i=0;i<W*H;i++) smokeGrid[i]=ns[i]; | |
| return burned; | |
| } | |
| // ── Demo agent (BFS toward exit) ────────────────────────────────────────────── | |
| function agentAct(s) { | |
| const {agentX:ax,agentY:ay,cellGrid,fireGrid,doorReg} = s; | |
| const dirs=['north','south','east','west']; | |
| const deltas={north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]}; | |
| // Check if adjacent to closed door | |
| for(const dir of dirs) { | |
| const [dx,dy]=deltas[dir]; | |
| const nx=ax+dx, ny=ay+dy; | |
| if(nx<0||nx>=W||ny<0||ny>=H) continue; | |
| if(cellGrid[ny*W+nx]===DOOR_CLOSED) { | |
| for(const [id,[ddx,ddy]] of Object.entries(doorReg)) { | |
| if(ddx===nx&&ddy===ny) return {action:'door',target_id:id,door_state:'open'}; | |
| } | |
| } | |
| } | |
| let bestDir=null, bestDist=Infinity; | |
| for(const dir of dirs) { | |
| const [dx,dy]=deltas[dir]; | |
| const nx=ax+dx, ny=ay+dy; | |
| if(nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const ct=cellGrid[ny*W+nx]; | |
| if(ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue; | |
| if(fireGrid[ny*W+nx]>0.35) continue; | |
| const d=bfsDist(nx,ny,EXITS,cellGrid); | |
| if(d<bestDist) { bestDist=d; bestDir=dir; } | |
| } | |
| if(!bestDir||Math.random()<0.12) { | |
| const shuffled=[...dirs].sort(()=>Math.random()-.5); | |
| for(const dir of shuffled) { | |
| const [dx,dy]=deltas[dir]; | |
| const nx=ax+dx, ny=ay+dy; | |
| if(nx<0||nx>=W||ny<0||ny>=H) continue; | |
| const ct=cellGrid[ny*W+nx]; | |
| if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED&&fireGrid[ny*W+nx]<0.4) return {action:'move',direction:dir}; | |
| } | |
| } | |
| return bestDir ? {action:'move',direction:bestDir} : {action:'wait'}; | |
| } | |
| function doStep(s) { | |
| if(!s.agentAlive||s.agentEvacuated||s.stepCount>=s.maxSteps) return; | |
| const action = agentAct(s); | |
| const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]}; | |
| const prevX=s.agentX, prevY=s.agentY; | |
| let feedback=''; | |
| if(action.action==='move') { | |
| const [dx,dy]=deltas[action.direction]||[0,0]; | |
| const nx=s.agentX+dx, ny=s.agentY+dy; | |
| if(nx>=0&&nx<W&&ny>=0&&ny<H) { | |
| const ct=s.cellGrid[ny*W+nx]; | |
| if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED) { | |
| s.agentX=nx; s.agentY=ny; feedback=`Moved ${action.direction}`; | |
| } else feedback='Path blocked'; | |
| } | |
| } else if(action.action==='door') { | |
| const pos=s.doorReg[action.target_id]; | |
| if(pos) { | |
| const [dx,dy]=pos; | |
| if(Math.abs(dx-s.agentX)+Math.abs(dy-s.agentY)<=2) { | |
| if(s.cellGrid[dy*W+dx]===DOOR_CLOSED) { | |
| s.cellGrid[dy*W+dx]=DOOR_OPEN; | |
| feedback=`Opened ${action.target_id}`; | |
| } | |
| } | |
| } | |
| } else if(action.action==='wait') { | |
| feedback='Waiting...'; | |
| } | |
| // Check evacuation | |
| if(s.cellGrid[s.agentY*W+s.agentX]===EXIT && s.fireGrid[s.agentY*W+s.agentX]<EXIT_BLOCK) { | |
| s.agentEvacuated=true; feedback='EVACUATED — reached safety!'; | |
| } | |
| // Fire step | |
| const burned = simStep(s); | |
| // Damage | |
| const ai=s.agentY*W+s.agentX; | |
| const smoke=s.smokeGrid[ai], fire=s.fireGrid[ai]; | |
| let dmg=0; | |
| if(smoke>=.8) dmg+=5; else if(smoke>=.5) dmg+=2; else if(smoke>=.2) dmg+=.5; | |
| if(fire>=FIRE_BURNING) dmg+=10; | |
| s.agentHealth=Math.max(0,s.agentHealth-dmg); | |
| if(s.agentHealth<=0) { s.agentAlive=false; feedback='Incapacitated!'; } | |
| s.stepCount++; | |
| s.visibleCells=computeVisible(s.agentX,s.agentY,s.cellGrid,s.smokeGrid); | |
| for(const k of s.visibleCells) s.exploreSet.add(k); | |
| // Reward | |
| const prevDist=bfsDist(prevX,prevY,EXITS,s.cellGrid); | |
| const curDist=bfsDist(s.agentX,s.agentY,EXITS,s.cellGrid); | |
| let reward=-0.01; | |
| if(curDist<prevDist) reward+=0.1; | |
| if(smoke>=.5||fire>=FIRE_BURNING) reward-=0.5; | |
| reward-=0.02*dmg; | |
| if(s.agentEvacuated) reward+=5+(0.05*Math.max(0,s.maxSteps-s.stepCount)); | |
| if(!s.agentAlive) reward-=10; | |
| if(action.action==='door') reward+=0.5; | |
| s.lastReward=Math.round(reward*1000)/1000; | |
| s.totalReward=Math.round((s.totalReward+reward)*1000)/1000; | |
| s.rewardHistory.push(s.totalReward); | |
| s.fireSizeHistory.push(Array.from(s.fireGrid).filter(f=>f>=FIRE_BURNING).length); | |
| if(feedback) s.eventLog.unshift({step:s.stepCount,text:feedback,reward:s.lastReward}); | |
| if(burned.length) s.eventLog.unshift({step:s.stepCount,text:`${burned.length} cell(s) burned out`,reward:0,isAlert:true}); | |
| if(s.eventLog.length>50) s.eventLog.length=50; | |
| } | |
| // ── Renderer ────────────────────────────────────────────────────────────────── | |
| const canvas = document.getElementById('mainCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const CS = 36; // cell size px | |
| let time = 0; | |
| let agentTrail = []; // [{px,py}] max 14 | |
| let smokePhases; // per-cell random phase | |
| function initRender() { | |
| smokePhases = new Float32Array(W*H).map(()=>Math.random()*Math.PI*2); | |
| agentTrail = []; | |
| } | |
| function renderFrame(s) { | |
| time += 0.016; | |
| const {cellGrid,fireGrid,smokeGrid,agentX,agentY,visibleCells,exploreSet} = s; | |
| // ── Background ── | |
| ctx.fillStyle='#c8b890'; | |
| ctx.fillRect(0,0,576,576); | |
| // ── Cell base ── | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) drawCellBase(ctx,x,y,cellGrid[y*W+x],CS); | |
| // ── Fire ambient (multiply — warms light floor tiles naturally) ── | |
| ctx.save(); | |
| ctx.globalCompositeOperation='multiply'; | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const f=fireGrid[y*W+x]; | |
| if(f<0.28) continue; | |
| drawFireAmbient(ctx,x,y,f,CS); | |
| } | |
| ctx.restore(); | |
| // ── Fire cell (multiply — burns vivid deep orange-red into light tiles) ── | |
| ctx.save(); | |
| ctx.globalCompositeOperation='multiply'; | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const f=fireGrid[y*W+x]; | |
| if(f<0.05) continue; | |
| drawFireCell(ctx,x,y,f,CS); | |
| } | |
| ctx.restore(); | |
| // ── Smoke ── | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const sm=smokeGrid[y*W+x]; | |
| if(sm<0.08) continue; | |
| drawSmoke(ctx,x,y,sm,CS,smokePhases[y*W+x]); | |
| } | |
| // ── Exits & doors ── | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const ct=cellGrid[y*W+x]; | |
| if(ct===EXIT) drawExit(ctx,x,y,CS,fireGrid[y*W+x]); | |
| else if(ct===DOOR_OPEN||ct===DOOR_CLOSED) drawDoor(ctx,x,y,ct,CS); | |
| } | |
| // ── Visibility shading — all cells visible, dim by knowledge tier ── | |
| for(let y=0;y<H;y++) for(let x=0;x<W;x++) { | |
| const k=`${x},${y}`; | |
| if(visibleCells.has(k)) continue; // fully lit — no overlay | |
| // Explored but out of sight: light desaturating veil | |
| // Unexplored: heavier veil — but never fully opaque, structure always readable | |
| ctx.fillStyle = exploreSet.has(k) | |
| ? 'rgba(180,174,165,0.30)' // explored — slight dim | |
| : 'rgba(140,134,126,0.55)'; // unseen — moderate dim, structure visible | |
| ctx.fillRect(x*CS,y*CS,CS,CS); | |
| } | |
| // ── Agent vision lantern — warm radial highlight on visible area ── | |
| const agPx=agentX*CS+CS/2, agPy=agentY*CS+CS/2; | |
| const vR=5.5*CS; | |
| const vGr=ctx.createRadialGradient(agPx,agPy,0,agPx,agPy,vR); | |
| vGr.addColorStop(0, 'rgba(255,248,215,0.28)'); | |
| vGr.addColorStop(0.45,'rgba(255,244,200,0.14)'); | |
| vGr.addColorStop(0.80,'rgba(255,240,180,0.04)'); | |
| vGr.addColorStop(1, 'rgba(255,240,180,0)'); | |
| ctx.fillStyle=vGr; | |
| ctx.beginPath(); ctx.arc(agPx,agPy,vR,0,Math.PI*2); ctx.fill(); | |
| // ── Trail ── | |
| updateTrail(agentX,agentY); | |
| drawTrail(ctx); | |
| // ── Agent ── | |
| drawAgent(ctx,agentX,agentY,s.agentHealth,s.agentAlive,s.agentEvacuated,CS); | |
| // ── Grid overlay ── | |
| ctx.strokeStyle='rgba(0,0,0,0.07)'; | |
| ctx.lineWidth=0.5; | |
| for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CS,0);ctx.lineTo(x*CS,H*CS);ctx.stroke();} | |
| for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CS);ctx.lineTo(W*CS,y*CS);ctx.stroke();} | |
| } | |
| // ── Cell base drawing ───────────────────────────────────────────────────────── | |
| function drawCellBase(ctx,x,y,ct,cs) { | |
| const px=x*cs, py=y*cs; | |
| switch(ct) { | |
| case WALL: { | |
| const wh=9; | |
| // Front shadow face (very dark — makes wall pop from floor) | |
| ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(px,py+cs-wh,cs,wh); | |
| // Right drop shadow | |
| ctx.fillStyle='rgba(0,0,0,0.30)'; ctx.fillRect(px+cs,py-wh+2,4,cs+2); | |
| // Top face — dark concrete gray | |
| ctx.fillStyle='#5e5850'; ctx.fillRect(px,py-wh,cs,cs); | |
| // Top edge catch-light (bright white strip) | |
| ctx.fillStyle='rgba(255,255,255,0.80)'; ctx.fillRect(px,py-wh,cs,3); | |
| // Left highlight strip | |
| ctx.fillStyle='rgba(255,255,255,0.35)'; ctx.fillRect(px,py-wh+3,2,cs-3); | |
| // Bottom-right inner shadow for depth | |
| ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+cs-3,py-wh+3,3,cs-3); | |
| ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+3,py-wh+cs-3,cs-6,3); | |
| // Mortar lines | |
| ctx.fillStyle='rgba(0,0,0,0.18)'; | |
| ctx.fillRect(px+3,py-wh+~~(cs*.34),cs-6,1); | |
| ctx.fillRect(px+3,py-wh+~~(cs*.67),cs-6,1); | |
| break; | |
| } | |
| case OBSTACLE: { | |
| const oh=5; | |
| // Deep shadow underface | |
| ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(px,py+cs-oh,cs,oh); | |
| ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.fillRect(px+cs,py-oh+2,4,cs); | |
| // Dark charred surface | |
| ctx.fillStyle='#3a3530'; ctx.fillRect(px,py-oh,cs,cs); | |
| // Diagonal char texture | |
| ctx.strokeStyle='rgba(0,0,0,0.30)'; ctx.lineWidth=0.9; | |
| for(let d=0;d<cs+cs;d+=5){ | |
| ctx.beginPath(); | |
| ctx.moveTo(px+Math.max(0,d-cs),py-oh+Math.min(d,cs)); | |
| ctx.lineTo(px+Math.min(d,cs),py-oh+Math.max(0,d-cs)); | |
| ctx.stroke(); | |
| } | |
| // Vivid ember edge glow | |
| ctx.fillStyle='rgba(230,80,0,0.80)'; | |
| ctx.fillRect(px,py-oh,cs,1); ctx.fillRect(px,py-oh+cs-1,cs,1); | |
| ctx.fillRect(px,py-oh+1,1,cs-2); ctx.fillRect(px+cs-1,py-oh+1,1,cs-2); | |
| break; | |
| } | |
| case EXIT: { | |
| ctx.fillStyle='#e6f4ec'; ctx.fillRect(px,py,cs,cs); | |
| ctx.fillStyle='rgba(34,197,94,0.18)'; ctx.fillRect(px+2,py+2,cs-4,cs-4); | |
| break; | |
| } | |
| default: { // FLOOR, DOOR — Minecraft-style strong checkerboard | |
| // Alternating tiles: warm sand-tan vs golden-tan (like reference image 2) | |
| ctx.fillStyle = (x+y)%2===0 ? '#e8d8b8' : '#d0be98'; | |
| ctx.fillRect(px, py, cs, cs); | |
| // Subtle inner bevel (top-left lighter, bottom-right darker) | |
| ctx.fillStyle = 'rgba(255,255,255,0.20)'; | |
| ctx.fillRect(px, py, cs, 2); ctx.fillRect(px, py+2, 2, cs-2); | |
| ctx.fillStyle = 'rgba(0,0,0,0.18)'; | |
| ctx.fillRect(px, py+cs-2, cs, 2); ctx.fillRect(px+cs-2, py, 2, cs-2); | |
| } | |
| } | |
| } | |
| // ── Fire rendering ──────────────────────────────────────────────────────────── | |
| function drawFireAmbient(ctx,x,y,fire,cs) { | |
| // multiply: deeply warms floor around fire — reduce G+B channels | |
| const px=x*cs+cs/2, py=y*cs+cs/2; | |
| const r=cs*(3.0+fire*2.2); | |
| const a=0.72+fire*0.25; // high alpha for vivid multiply effect | |
| const gr=ctx.createRadialGradient(px,py,0,px,py,r); | |
| gr.addColorStop(0, `rgba(255,50,0,${a})`); // deep red-orange core | |
| gr.addColorStop(0.30, `rgba(255,90,0,${a*0.80})`); // orange mid | |
| gr.addColorStop(0.60, `rgba(255,150,20,${a*0.45})`); | |
| gr.addColorStop(0.85, `rgba(255,200,60,${a*0.15})`); | |
| gr.addColorStop(1, 'rgba(255,255,255,0)'); | |
| ctx.fillStyle=gr; | |
| ctx.fillRect(px-r,py-r,r*2,r*2); | |
| } | |
| function drawFireCell(ctx,x,y,fire,cs) { | |
| const px=x*cs+cs/2, py=y*cs+cs/2; | |
| const f1=0.82+0.18*Math.sin(time*9.5+x*2.7+y*3.14); | |
| const eff=fire*f1; | |
| // multiply blend: alpha values are high so colors deeply saturate the light floor | |
| const layers=[ | |
| {r:cs*.35, a:.99, ox:0, oy:-cs*.10}, // white-hot core | |
| {r:cs*.68, a:.95, ox:0, oy:0}, // inner flame | |
| {r:cs*1.0, a:.82, ox:0, oy:0}, // flame body | |
| {r:cs*1.55,a:.60, ox:0, oy:0}, // outer heat glow | |
| ]; | |
| for(const {r,a,ox,oy} of layers) { | |
| const cx=px+ox, cy=py+oy; | |
| const gr=ctx.createRadialGradient(cx,cy,0,cx,cy,r); | |
| gr.addColorStop(0, fireColor(eff, a)); | |
| gr.addColorStop(.35, fireColor(eff*.80, a*.75)); | |
| gr.addColorStop(.70, fireColor(eff*.40, a*.40)); | |
| gr.addColorStop(1, fireColor(eff*.10, 0)); | |
| ctx.fillStyle=gr; | |
| ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill(); | |
| } | |
| // White-hot core punch-through (visible even in multiply) | |
| if(eff>.45) { | |
| const gr2=ctx.createRadialGradient(px,py-cs*.12,0,px,py-cs*.12,cs*.20); | |
| gr2.addColorStop(0,`rgba(255,200,50,${Math.min(1,(eff-.45)*2.2)})`); | |
| gr2.addColorStop(1,'rgba(255,140,0,0)'); | |
| ctx.fillStyle=gr2; | |
| ctx.beginPath(); ctx.arc(px,py-cs*.12,cs*.20,0,Math.PI*2); ctx.fill(); | |
| } | |
| } | |
| // ── Smoke ───────────────────────────────────────────────────────────────────── | |
| function drawSmoke(ctx,x,y,smoke,cs,phase) { | |
| const px=x*cs+cs/2, py=y*cs+cs/2; | |
| const dx=Math.sin(time*.35+phase)*1.8, dy=Math.cos(time*.28+phase+1.2)*1.8; | |
| const alpha=Math.min(0.85,smoke*.95); | |
| const r=cs*.85; | |
| ctx.save(); | |
| ctx.translate(px+dx,py+dy); | |
| const gr=ctx.createRadialGradient(0,0,0,0,0,r); | |
| gr.addColorStop(0,`rgba(72,82,96,${alpha})`); | |
| gr.addColorStop(.5,`rgba(72,82,96,${alpha*.75})`); | |
| gr.addColorStop(1,'rgba(72,82,96,0)'); | |
| ctx.fillStyle=gr; | |
| ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.fill(); | |
| if(smoke>.3) { | |
| const gr2=ctx.createRadialGradient(dy*1.5,dx*1.5,0,dy*1.5,dx*1.5,cs*.6); | |
| gr2.addColorStop(0,`rgba(88,98,114,${alpha*.5})`); | |
| gr2.addColorStop(1,'rgba(88,98,114,0)'); | |
| ctx.fillStyle=gr2; | |
| ctx.beginPath(); ctx.arc(dy*1.5,dx*1.5,cs*.6,0,Math.PI*2); ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| // ── Exit ────────────────────────────────────────────────────────────────────── | |
| function drawExit(ctx,x,y,cs,fire) { | |
| const px=x*cs, py=y*cs; | |
| const pulse=0.62+0.38*Math.sin(time*2.8+x+y); | |
| const blocked=fire>=EXIT_BLOCK; | |
| const col=blocked?'#ef4444':'#22c55e'; | |
| ctx.save(); | |
| ctx.shadowBlur=cs*1.1*pulse; ctx.shadowColor=col; | |
| ctx.strokeStyle=col; ctx.lineWidth=2; | |
| ctx.strokeRect(px+2,py+2,cs-4,cs-4); | |
| ctx.fillStyle=blocked?'rgba(220,38,38,.11)':'rgba(34,197,94,.11)'; | |
| ctx.fillRect(px+3,py+3,cs-6,cs-6); | |
| ctx.shadowBlur=0; | |
| const sa=0.55+0.35*pulse; | |
| ctx.fillStyle=blocked?`rgba(220,38,38,${sa})`:`rgba(22,163,74,${sa})`; | |
| ctx.font=`bold ${~~(cs*.50)}px sans-serif`; | |
| ctx.textAlign='center'; ctx.textBaseline='middle'; | |
| ctx.fillText(blocked?'✕':'⇥',px+cs/2,py+cs/2+1); | |
| // Corner brackets | |
| ctx.fillStyle=blocked?'rgba(220,38,38,.85)':'rgba(22,163,74,.85)'; | |
| const ca=5; | |
| ctx.fillRect(px,py,ca,1); ctx.fillRect(px,py,1,ca); | |
| ctx.fillRect(px+cs-ca,py,ca,1); ctx.fillRect(px+cs-1,py,1,ca); | |
| ctx.fillRect(px,py+cs-1,ca,1); ctx.fillRect(px,py+cs-ca,1,ca); | |
| ctx.fillRect(px+cs-ca,py+cs-1,ca,1); ctx.fillRect(px+cs-1,py+cs-ca,1,ca); | |
| ctx.restore(); | |
| } | |
| // ── Door ────────────────────────────────────────────────────────────────────── | |
| function drawDoor(ctx,x,y,ct,cs) { | |
| const px=x*cs, py=y*cs; | |
| if(ct===DOOR_OPEN) { | |
| ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.fillRect(px+4,py,cs-6,cs); | |
| ctx.fillStyle='#5c3621'; ctx.fillRect(px,py,4,cs); | |
| ctx.fillStyle='#291508'; ctx.fillRect(px,py,cs,2); | |
| ctx.fillStyle='rgba(125,211,252,0.10)'; ctx.fillRect(px+5,py+2,cs-7,cs-4); | |
| } else { | |
| ctx.fillStyle='#5c3621'; ctx.fillRect(px+2,py+1,cs-4,cs-2); | |
| ctx.fillStyle='#291508'; | |
| ctx.fillRect(px,py,cs,2); ctx.fillRect(px,py+cs-2,cs,2); | |
| ctx.fillRect(px,py+2,2,cs-4); ctx.fillRect(px+cs-2,py+2,2,cs-4); | |
| ctx.fillStyle='rgba(0,0,0,0.35)'; | |
| ctx.fillRect(px+4,py+~~(cs*.14),cs-8,1); | |
| ctx.fillRect(px+4,py+~~(cs*.50),cs-8,1); | |
| ctx.fillRect(px+4,py+~~(cs*.82),cs-8,1); | |
| ctx.fillStyle='rgba(0,0,0,0.13)'; | |
| ctx.fillRect(px+4,py+~~(cs*.14)+2,cs-8,~~(cs*.34)); | |
| ctx.fillRect(px+4,py+~~(cs*.50)+2,cs-8,~~(cs*.30)); | |
| ctx.beginPath(); ctx.arc(px+cs-8,py+~~(cs/2),2.5,0,Math.PI*2); | |
| ctx.fillStyle='rgba(210,168,75,.88)'; ctx.fill(); | |
| } | |
| } | |
| // ── Agent trail ─────────────────────────────────────────────────────────────── | |
| function updateTrail(ax,ay) { | |
| const px=ax*CS+CS/2, py=ay*CS+CS/2; | |
| const last=agentTrail[0]; | |
| if(!last||Math.abs(last.px-px)>1||Math.abs(last.py-py)>1) { | |
| agentTrail.unshift({px,py}); | |
| } | |
| while(agentTrail.length>14) agentTrail.pop(); | |
| } | |
| function drawTrail(ctx) { | |
| for(let i=1;i<agentTrail.length;i++) { | |
| const ratio = 1-i/agentTrail.length; | |
| const alpha = ratio * 0.70; | |
| const r = Math.max(1.5, ratio * CS * 0.24); | |
| ctx.beginPath(); | |
| ctx.arc(agentTrail[i].px,agentTrail[i].py,r,0,Math.PI*2); | |
| ctx.fillStyle=`rgba(2,132,199,${alpha.toFixed(3)})`; | |
| ctx.fill(); | |
| } | |
| } | |
| // ── Agent color themes (body = shirt/arms, matches reference image) ─────────── | |
| const AGENT_COLORS = { | |
| healthy: { body:'#3b82f6', dark:'#1d4ed8', ring:'#fbbf24', ringGlow:'#f59e0b' }, | |
| moderate: { body:'#f97316', dark:'#c2410c', ring:'#fb923c', ringGlow:'#ea580c' }, | |
| low: { body:'#dc2626', dark:'#991b1b', ring:'#f87171', ringGlow:'#dc2626' }, | |
| evacuated:{ body:'#16a34a', dark:'#14532d', ring:'#4ade80', ringGlow:'#22c55e' }, | |
| }; | |
| // ── Minecraft-style blocky pixel character ──────────────────────────────────── | |
| // Drawn entirely with fillRect — crisp pixel art, no anti-aliasing curves. | |
| // Layout (in "units" u = cs/18 ≈ 2px): | |
| // Rows 0-1 : Helmet (10u wide incl brim, 2u tall) | |
| // Rows 2-5 : Head (8u wide, 4u tall, skin + eyes) | |
| // Rows 6-10: Body+Arms (body 6u, arms 2u each side → 10u total, 5u tall) | |
| // Rows 11-15: Legs (3u each, 5u tall) | |
| // Total: 16u tall × 10u wide → 32px × 20px at cs=36 | |
| function drawMinecraftAgent(ctx, px, py, cs, theme) { | |
| const u = cs / 18; // ~2px at cs=36 | |
| const ow = 10 * u; // total character width | |
| const oh = 16 * u; // total character height | |
| const ox = Math.round(px - ow / 2); // top-left X | |
| const oy = Math.round(py - oh / 2); // top-left Y (centered on cell) | |
| // ── Color palette ── | |
| const skin = '#f4c08a'; // face / skin | |
| const helmTop = '#b8c8d8'; // hard-hat top — steel blue-gray | |
| const helmBrm = '#7a98ae'; // brim — darker | |
| const helmVis = '#d4e8f8'; // visor stripe — pale blue | |
| const body = theme.body; // health-coded shirt | |
| const bodyShd = theme.dark; // shirt shadow / dark side | |
| const pants = '#1e3a6e'; // navy trousers | |
| const pantsShd= '#142856'; | |
| const eye = '#1a1818'; | |
| const skin2 = '#e8a870'; // skin shadow / cheek | |
| // helper: fill a rectangle in character-local units | |
| const r = (col, dx, dy, dw, dh) => { | |
| ctx.fillStyle = col; | |
| ctx.fillRect(ox + dx*u, oy + dy*u, dw*u, dh*u); | |
| }; | |
| // ─── HELMET ──────────────────────────────────────────────────────────────── | |
| r(helmBrm, 0, 0, 10, 1); // brim (full width) | |
| r(helmTop, 1, 1, 8, 1); // dome top | |
| r(helmVis, 2, 1, 6, 0.5); // visor line on brim | |
| // ─── HEAD ────────────────────────────────────────────────────────────────── | |
| r(skin, 1, 2, 8, 4); // face (skin) | |
| r(skin2, 1, 5, 8, 1); // chin / lower shadow | |
| r(eye, 2, 3, 1, 1); // left eye | |
| r(eye, 5, 3, 1, 1); // right eye | |
| r('rgba(160,80,40,0.35)', 3, 5, 2, 0.5); // mouth hint | |
| // ─── BODY ────────────────────────────────────────────────────────────────── | |
| r(body, 2, 6, 6, 5); // torso | |
| r(bodyShd, 2, 6, 1, 5); // left-edge shadow (depth) | |
| r(bodyShd, 2,10, 6, 1); // bottom shadow | |
| // ─── ARMS ────────────────────────────────────────────────────────────────── | |
| r(body, 0, 6, 2, 5); // left arm | |
| r(bodyShd, 0, 6, 1, 5); // left arm shadow | |
| r(body, 8, 6, 2, 5); // right arm | |
| r(bodyShd, 9, 6, 1, 5); // right arm right-edge shadow | |
| // ─── LEGS ────────────────────────────────────────────────────────────────── | |
| r(pants, 2, 11, 3, 5); // left leg | |
| r(pantsShd, 2, 11, 1, 5); // left leg shadow | |
| r(pants, 5, 11, 3, 5); // right leg | |
| r(pantsShd, 5, 11, 1, 5); // right leg shadow | |
| r('rgba(0,0,0,0.18)', 4, 11, 1, 5); // centre gap between legs | |
| } | |
| // ── Agent: golden aura + Minecraft character + health arc ──────────────────── | |
| function drawAgent(ctx, ax, ay, health, alive, evacuated, cs) { | |
| if (!alive && !evacuated) return; | |
| const px = ax*cs + cs/2, py = ay*cs + cs/2; | |
| // Aura pulse — two independent oscillators (matching original frontend) | |
| const pulse2 = 0.68 + 0.32*Math.sin(time*2.1 + 1.0); | |
| const pulse3 = 0.80 + 0.20*Math.sin(time*4.8); // faster inner shimmer | |
| const hRatio = Math.max(0, Math.min(1, health/100)); | |
| const theme = evacuated ? AGENT_COLORS.evacuated | |
| : hRatio > 0.6 ? AGENT_COLORS.healthy | |
| : hRatio > 0.3 ? AGENT_COLORS.moderate | |
| : AGENT_COLORS.low; | |
| const arcBaseR = cs * 0.42; // fixed radius (no size-pulse — keeps pixel char stable) | |
| // ── 1. GOLDEN OUTER GLOW (large halo — like reference image) ────────────── | |
| ctx.save(); | |
| const auraR = cs * 0.85 * pulse2; | |
| const aGr = ctx.createRadialGradient(px, py, arcBaseR*0.55, px, py, auraR); | |
| aGr.addColorStop(0, 'rgba(0,0,0,0)'); | |
| aGr.addColorStop(0.40, `rgba(251,191,36,${0.28 * pulse3})`); // gold inner | |
| aGr.addColorStop(0.72, `rgba(245,158,11,${0.14 * pulse3})`); // amber mid | |
| aGr.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = aGr; | |
| ctx.beginPath(); ctx.arc(px, py, auraR, 0, Math.PI*2); ctx.fill(); | |
| ctx.restore(); | |
| // ── 2. GROUND SHADOW (ellipse under feet) ───────────────────────────────── | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(0,0,0,0.22)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(px + 1, py + cs*0.46, cs*0.24, cs*0.055, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| // ── 3. CHARACTER SHADOW GLOW (subtle colored shadow under sprite) ────────── | |
| ctx.save(); | |
| ctx.shadowBlur = cs * 0.55; | |
| ctx.shadowColor = theme.ring; | |
| // Draw an invisible rect at the character centre just to emit the shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0)'; | |
| ctx.fillRect(px - 1, py - 1, 2, 2); | |
| ctx.restore(); | |
| // ── 4. MINECRAFT CHARACTER (crisp pixel art — no transforms) ────────────── | |
| drawMinecraftAgent(ctx, px, py, cs, theme); | |
| // ── 5. HEALTH ARC RING (gold/orange/red — clockwise from 12 o'clock) ────── | |
| ctx.save(); | |
| const arcR = arcBaseR * 1.68; | |
| // Track ring (always full circle, subtle) | |
| ctx.beginPath(); ctx.arc(px, py, arcR, 0, Math.PI*2); | |
| ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.lineWidth = 4; ctx.stroke(); | |
| // Health fill arc | |
| if (hRatio > 0) { | |
| const arcCol = theme.ring; | |
| const arcGlow = theme.ringGlow; | |
| ctx.beginPath(); | |
| ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + hRatio*Math.PI*2); | |
| ctx.strokeStyle = arcCol; | |
| ctx.lineWidth = 4.5; | |
| ctx.lineCap = 'round'; | |
| // Double-stroke for bright glow (same technique as original frontend) | |
| ctx.shadowBlur = 14; | |
| ctx.shadowColor = arcGlow; | |
| ctx.stroke(); | |
| ctx.shadowBlur = 6; | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| // ── Mini sparkline chart ────────────────────────────────────────────────────── | |
| function drawSparkline(canvasEl, data, color, fill) { | |
| if(!canvasEl) return; | |
| const c=canvasEl.getContext('2d'); | |
| const w=canvasEl.offsetWidth||canvasEl.parentElement.clientWidth||240; | |
| const h=canvasEl.height; | |
| canvasEl.width=w; | |
| c.fillStyle='#faf9f6'; c.fillRect(0,0,w,h); | |
| if(data.length<2) return; | |
| const yMin=Math.min(...data), yMax=Math.max(...data); | |
| const yRange=(yMax-yMin)||1; | |
| const pad=4; | |
| // Grid lines | |
| c.strokeStyle='rgba(0,0,0,0.06)'; c.lineWidth=.5; | |
| for(let i=0;i<=2;i++){ | |
| const gy=pad+(h-2*pad)*(i/2); | |
| c.beginPath(); c.moveTo(0,gy); c.lineTo(w,gy); c.stroke(); | |
| } | |
| const toX=i=>(i/(data.length-1))*w; | |
| const toY=v=>h-pad-((v-yMin)/yRange)*(h-2*pad); | |
| // Fill area | |
| c.beginPath(); c.moveTo(toX(0),h); | |
| for(let i=0;i<data.length;i++) c.lineTo(toX(i),toY(data[i])); | |
| c.lineTo(toX(data.length-1),h); c.closePath(); | |
| c.fillStyle=fill; c.fill(); | |
| // Line | |
| c.beginPath(); c.moveTo(toX(0),toY(data[0])); | |
| for(let i=1;i<data.length;i++) c.lineTo(toX(i),toY(data[i])); | |
| c.strokeStyle=color; c.lineWidth=1.8; c.stroke(); | |
| // Dot | |
| const lx=toX(data.length-1), ly=toY(data[data.length-1]); | |
| c.beginPath(); c.arc(lx,ly,3,0,Math.PI*2); | |
| c.fillStyle=color; c.fill(); | |
| } | |
| // ── HUD & UI updates ────────────────────────────────────────────────────────── | |
| function updateHUD(s) { | |
| // Health bar | |
| const hPct=s.agentHealth/100; | |
| const bar=document.getElementById('healthBar'); | |
| if(bar){ bar.style.width=`${s.agentHealth}%`; bar.className='hbar-fill '+(hPct>.6?'g':hPct>.3?'m':'c'); } | |
| setText('healthVal', Math.round(s.agentHealth)); | |
| const status = s.agentHealth>75?'Good':s.agentHealth>50?'Moderate':s.agentHealth>25?'Low':'Critical'; | |
| const hStatus=document.getElementById('healthStatus'); | |
| if(hStatus){ hStatus.textContent=status; hStatus.className='hstatus '+status.toLowerCase(); } | |
| // Smoke | |
| const smoke=s.smokeGrid[s.agentY*W+s.agentX]; | |
| const smokeLabel=smoke>.8?'heavy':smoke>.5?'moderate':smoke>.2?'light':'clear'; | |
| setText('smokeTag',`smoke: ${smokeLabel}`); | |
| // Steps | |
| setText('stepCount',`${s.stepCount} / ${s.maxSteps}`); | |
| const sb=document.getElementById('stepBar'); | |
| if(sb) sb.style.width=`${100*s.stepCount/s.maxSteps}%`; | |
| setText('diffTag',`${s.difficulty} · small_office · ${s.windDir}`); | |
| // Episode banner | |
| const banner=document.getElementById('epBanner'); | |
| if(banner) { | |
| if(s.agentEvacuated){ banner.textContent='✓ EVACUATED — reached safety'; banner.className='ok'; banner.style.display='block'; } | |
| else if(!s.agentAlive){ banner.textContent='✗ INCAPACITATED'; banner.className='bad'; banner.style.display='block'; } | |
| else if(s.stepCount>=s.maxSteps){ banner.textContent='⏱ TIMEOUT'; banner.className='tmo'; banner.style.display='block'; } | |
| else banner.style.display='none'; | |
| } | |
| // Side panel | |
| setText('sHealthVal', Math.round(s.agentHealth)); | |
| setText('sStatusVal', status); | |
| setText('sSmokeVal', smokeLabel); | |
| setText('sRewardVal', s.totalReward.toFixed(2)); | |
| const fireCells=s.fireSizeHistory[s.fireSizeHistory.length-1]||0; | |
| setText('sFireVal', fireCells); | |
| setText('sStepVal', s.stepCount); | |
| setText('sWindVal', s.windDir); | |
| setText('sSpreadVal',s.pSpread.toFixed(2)); | |
| const burnedPct=Math.round(100*Array.from(s.fireGrid).filter(f=>f>0).length/(W*H)); | |
| setText('sBurnVal',`${burnedPct}% burned`); | |
| const exitBlock=EXITS.filter(([ex,ey])=>s.fireGrid[ey*W+ex]>=EXIT_BLOCK).length; | |
| setText('sExitBlocked',`${exitBlock} / ${EXITS.length}`); | |
| document.getElementById('sExitBlocked').style.color = exitBlock>0?'var(--fire)':'var(--green)'; | |
| // Reward delta | |
| if(s.lastReward!==undefined) { | |
| const lr=s.lastReward; | |
| const el=document.getElementById('sRewardLast'); | |
| if(el){ el.textContent=(lr>0?'+':'')+lr.toFixed(2); el.style.color=lr>0?'var(--green)':'var(--red)'; } | |
| } | |
| // Charts | |
| drawSparkline(document.getElementById('rewardChart'), s.rewardHistory,'#1d4ed8','rgba(29,78,216,0.08)'); | |
| drawSparkline(document.getElementById('fireChart'), s.fireSizeHistory,'#c2410c','rgba(194,65,12,0.08)'); | |
| const fl=document.getElementById('sFireLast'); | |
| if(fl) fl.textContent=`${fireCells} cells`; | |
| // Event log | |
| const log=document.getElementById('eventLog'); | |
| if(log) { | |
| log.innerHTML=s.eventLog.slice(0,18).map(e=>` | |
| <div class="erow${e.isAlert?' alarm':''}"> | |
| <span class="estep">${String(e.step).padStart(3,'0')}</span> | |
| <span class="etext">${e.text}</span> | |
| ${e.reward?`<span class="erwd ${e.reward>0?'p':'n'}">${e.reward>0?'+':''}${e.reward.toFixed(2)}</span>`:''} | |
| </div>`).join(''); | |
| } | |
| } | |
| function setText(id,val) { | |
| const el=document.getElementById(id); | |
| if(el) el.textContent=val; | |
| } | |
| function showToast(msg) { | |
| const t=document.getElementById('toast'); | |
| if(!t) return; | |
| t.textContent=msg; t.classList.add('up'); | |
| setTimeout(()=>t.classList.remove('up'),3000); | |
| } | |
| // ── App controller ──────────────────────────────────────────────────────────── | |
| let playing=false, speed=1, stepTimer=null, rafId=null; | |
| let difficulty='easy'; | |
| function getSeed() { return parseInt(document.getElementById('seedInput')?.value)||42; } | |
| function newEpisode() { | |
| clearTimer(); | |
| state = buildState(difficulty, getSeed()); | |
| initRender(); | |
| updateHUD(state); | |
| setText('liveChip','Demo Mode'); | |
| document.getElementById('epBanner').style.display='none'; | |
| playing=false; updatePlayBtn(); | |
| setTimeout(()=>{ playing=true; updatePlayBtn(); startTimer(); },350); | |
| } | |
| function tick() { | |
| if(!state) return; | |
| doStep(state); | |
| updateHUD(state); | |
| if(state.agentEvacuated||!state.agentAlive||state.stepCount>=state.maxSteps) { | |
| playing=false; clearTimer(); updatePlayBtn(); | |
| setTimeout(()=>newEpisode(), 2800); | |
| } | |
| } | |
| function startTimer() { | |
| clearTimer(); | |
| stepTimer=setInterval(tick, Math.max(50,1000/speed)); | |
| } | |
| function clearTimer() { if(stepTimer){ clearInterval(stepTimer); stepTimer=null; } } | |
| function togglePlay() { | |
| playing=!playing; | |
| if(playing) startTimer(); else clearTimer(); | |
| updatePlayBtn(); | |
| } | |
| function updatePlayBtn() { | |
| const b=document.getElementById('playBtn'); | |
| if(b){ b.textContent=playing?'⏸':'▶'; b.classList.toggle('play',!playing); } | |
| } | |
| // Render loop (independent of sim tick rate) | |
| function startRender() { | |
| const loop=()=>{ | |
| if(state) renderFrame(state); | |
| rafId=requestAnimationFrame(loop); | |
| }; | |
| rafId=requestAnimationFrame(loop); | |
| } | |
| // ── Controls ────────────────────────────────────────────────────────────────── | |
| document.getElementById('playBtn')?.addEventListener('click',togglePlay); | |
| document.getElementById('stepBtn')?.addEventListener('click',()=>{ if(state&&!state.agentEvacuated&&state.agentAlive) { doStep(state); updateHUD(state); } }); | |
| document.getElementById('resetBtn')?.addEventListener('click',newEpisode); | |
| document.querySelectorAll('.diff-btn').forEach(b=>{ | |
| b.addEventListener('click',()=>{ | |
| document.querySelectorAll('.diff-btn').forEach(x=>{x.classList.remove('on','fire');}); | |
| b.classList.add('on','fire'); | |
| difficulty=b.dataset.diff; | |
| newEpisode(); | |
| }); | |
| }); | |
| document.querySelectorAll('.spd-btn').forEach(b=>{ | |
| b.addEventListener('click',()=>{ | |
| document.querySelectorAll('.spd-btn').forEach(x=>x.classList.remove('on')); | |
| b.classList.add('on'); speed=parseFloat(b.dataset.s); | |
| if(playing){ clearTimer(); startTimer(); } | |
| }); | |
| }); | |
| document.getElementById('seedInput')?.addEventListener('change',newEpisode); | |
| window.addEventListener('keydown',e=>{ | |
| if(e.target.tagName==='INPUT') return; | |
| if(e.code==='Space'){ e.preventDefault(); togglePlay(); } | |
| else if(e.code==='ArrowRight'&&state&&!state.agentEvacuated&&state.agentAlive){ doStep(state); updateHUD(state); } | |
| else if(e.code==='KeyR') newEpisode(); | |
| }); | |
| // ── Boot ────────────────────────────────────────────────────────────────────── | |
| window.addEventListener('DOMContentLoaded',()=>{ | |
| newEpisode(); | |
| startRender(); | |
| showToast('Press Space to pause · → to step · R to reset'); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |