pyre_env / demo.html
Akshaykumarbm's picture
Upload folder using huggingface_hub
1123bef verified
<!DOCTYPE html>
<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% !important; }
/* 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"></button>
<button class="spd-btn" data-s="2"></button>
<button class="spd-btn" data-s="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>
'use strict';
// ── 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>