Warden
Mimo v2.6 Pro · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 77,881 bytes · SHA-256 3adf7dfa65df
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The Warden's Corrosion — Aurelion's Sunken Observatory</title>
<style>
:root{
--slate0:#070a0d; --slate1:#0d1319; --slate2:#16202a; --slate3:#223140;
--bronze:#8a6a3a; --bronze2:#b08a4c; --gold:#e8b45a; --gold2:#ffd98a;
--ember:#c8632a; --ember2:#ff8a3c; --moss:#5a6b4a; --parch:#e8d9b0;
--corrode:#191519; --blood:#a8262a;
}
*{box-sizing:border-box; -webkit-user-select:none; user-select:none;}
html,body{margin:0;height:100%;background:radial-gradient(120% 100% at 50% 0%,#0c1218 0%,#05080b 70%);overflow:hidden;}
body{display:flex;align-items:center;justify-content:center;font-family:"Segoe UI",system-ui,-apple-system,sans-serif;color:var(--parch);}
#wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;}
#stage{position:relative;width:960px;height:600px;transform-origin:center center;
box-shadow:0 0 0 1px #3a3226, 0 30px 90px rgba(0,0,0,.8), 0 0 120px rgba(232,180,90,.06);}
canvas#game{display:block;width:960px;height:600px;background:#080c11;}
.vig{position:absolute;inset:0;pointer-events:none;
box-shadow:inset 0 0 130px rgba(0,0,0,.85), inset 0 0 34px rgba(0,0,0,.6);}
#flash{position:absolute;inset:0;pointer-events:none;opacity:0;
background:radial-gradient(circle at 50% 55%, rgba(200,30,30,0) 35%, rgba(190,30,30,.75) 100%);}
#healFlash{position:absolute;inset:0;pointer-events:none;opacity:0;
background:radial-gradient(circle at 50% 55%, rgba(140,255,180,0) 40%, rgba(110,230,150,.5) 100%);}
#shimmer{position:absolute;inset:0;pointer-events:none;opacity:0;
box-shadow:inset 0 0 90px rgba(255,205,105,.85), inset 0 0 26px rgba(255,235,180,.7);}
.hidden{display:none !important;}
/* ---------- HUD ---------- */
#hud{position:absolute;inset:0;pointer-events:none;}
.hudtop{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
padding:12px 16px;background:linear-gradient(180deg, rgba(5,8,11,.85), rgba(5,8,11,0));}
.hudl{display:flex;flex-direction:column;gap:5px;}
#hearts{display:flex;gap:5px;}
.heart{position:relative;width:26px;height:24px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.7));}
.heart svg{position:absolute;inset:0;width:26px;height:24px;}
.heart .fillwrap{position:absolute;inset:0;overflow:hidden;width:0%;transition:width .18s ease;}
.wname{font-family:Georgia,"Times New Roman",serif;font-size:13px;letter-spacing:.18em;text-transform:uppercase;
color:var(--gold);text-shadow:0 1px 2px #000, 0 0 12px rgba(232,180,90,.35);}
.wname.t1{color:var(--ember2);text-shadow:0 1px 2px #000,0 0 14px rgba(255,138,60,.55);}
.wname.t2{color:var(--gold2);text-shadow:0 1px 2px #000,0 0 18px rgba(255,217,138,.8);}
.hudc{display:flex;flex-direction:column;align-items:center;gap:4px;padding-top:2px;}
#ammo{display:flex;align-items:center;gap:7px;font-size:10px;letter-spacing:.16em;color:#9fb6c9;}
#amPips{display:flex;gap:3px;}
.pip{width:7px;height:11px;background:#223646;border:1px solid #3d5f78;transform:skewX(-12deg);}
.pip.on{background:linear-gradient(180deg,#bfe9ff,#5fb6e0);box-shadow:0 0 7px rgba(120,200,255,.8);border-color:#9fdcff;}
.hudr{display:flex;flex-direction:column;align-items:flex-end;gap:6px;}
.coin{display:flex;align-items:center;gap:6px;font-family:Georgia,serif;font-size:17px;color:var(--gold2);text-shadow:0 1px 2px #000;}
.coin svg{width:18px;height:18px;filter:drop-shadow(0 0 5px rgba(232,180,90,.5));}
#objective{max-width:270px;text-align:right;font-size:11px;line-height:1.5;letter-spacing:.05em;color:#a9bccb;
border-right:2px solid rgba(232,180,90,.45);padding-right:9px;}
#objective b{color:var(--gold);font-weight:600;letter-spacing:.14em;text-transform:uppercase;font-size:9.5px;display:block;margin-bottom:2px;}
#belt{position:absolute;left:16px;bottom:14px;display:flex;gap:8px;align-items:center;}
.slot{position:relative;width:44px;height:44px;border:1px solid #4a4130;border-radius:3px;
background:linear-gradient(180deg,rgba(22,32,42,.9),rgba(10,15,20,.9));display:flex;align-items:center;justify-content:center;}
.slot svg{width:24px;height:24px;}
.slot .key{position:absolute;top:1px;left:3px;font-size:9px;color:#8d9bab;letter-spacing:.06em;}
.slot .cnt{position:absolute;bottom:1px;right:4px;font-size:11px;color:var(--parch);font-weight:700;text-shadow:0 1px 2px #000;}
.slot.empty{opacity:.32;}
.slot.usable{border-color:var(--bronze2);box-shadow:0 0 10px rgba(232,180,90,.18);}
#slotAcc{width:auto;min-width:120px;padding:0 12px;gap:8px;font-size:11px;letter-spacing:.08em;color:#cfe0ee;}
#slotAcc .accIco{width:16px;height:16px;border:1px solid var(--bronze2);border-radius:50%;box-shadow:0 0 8px rgba(180,220,255,.4);}
#prompt{position:absolute;left:50%;bottom:70px;transform:translateX(-50%);font-size:12.5px;letter-spacing:.12em;
text-transform:uppercase;color:var(--gold2);background:rgba(8,12,16,.8);border:1px solid rgba(138,106,58,.7);
padding:6px 14px;border-radius:3px;opacity:0;transition:opacity .15s;text-shadow:0 1px 3px #000;}
#prompt.show{opacity:1;}
#prompt em{font-style:normal;color:#fff;background:rgba(232,180,90,.22);border:1px solid rgba(232,180,90,.5);
padding:1px 6px;border-radius:3px;margin-right:8px;font-size:11px;}
#toast{position:absolute;left:50%;bottom:118px;transform:translateX(-50%);max-width:640px;min-width:320px;
background:linear-gradient(180deg,rgba(12,18,24,.94),rgba(8,12,16,.94));border:1px solid rgba(138,106,58,.75);
border-radius:4px;padding:11px 18px;font-family:Georgia,serif;font-style:italic;font-size:14.5px;line-height:1.5;
color:#e9dcb8;text-align:center;opacity:0;transition:opacity .35s;pointer-events:none;
box-shadow:0 10px 30px rgba(0,0,0,.6);}
#toast.show{opacity:1;}
#toast b{font-style:normal;color:var(--gold);letter-spacing:.14em;text-transform:uppercase;font-size:11px;display:block;margin-bottom:3px;}
#zoneTitle{position:absolute;left:0;right:0;top:200px;text-align:center;pointer-events:none;opacity:0;}
#zoneTitle .z{font-family:Georgia,serif;font-size:13px;letter-spacing:.55em;color:var(--gold);text-transform:uppercase;}
#zoneTitle .n{font-family:Georgia,serif;font-size:44px;letter-spacing:.12em;color:#f2e6c4;margin-top:4px;
text-shadow:0 3px 18px rgba(0,0,0,.9), 0 0 34px rgba(232,180,90,.35);}
#zoneTitle .s{font-size:12.5px;letter-spacing:.2em;color:#9db1c2;margin-top:8px;font-style:italic;}
/* ---------- Overlays ---------- */
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;
background:radial-gradient(90% 80% at 50% 40%, rgba(10,16,22,.9), rgba(4,6,9,.97));z-index:20;}
#title{background:linear-gradient(180deg, rgba(6,9,13,.72), rgba(4,6,9,.9));}
#title h1{font-family:Georgia,"Times New Roman",serif;font-size:60px;letter-spacing:.14em;margin:0;
background:linear-gradient(180deg,#ffeec2 0%,#e8b45a 45%,#a5702c 100%);-webkit-background-clip:text;background-clip:text;color:transparent;
filter:drop-shadow(0 4px 16px rgba(0,0,0,.8));text-align:center;line-height:1.05;}
#title .sub{margin-top:14px;font-family:Georgia,serif;font-style:italic;font-size:16px;color:#a9bccb;letter-spacing:.12em;}
#title .rule{width:340px;height:1px;margin:26px 0;background:linear-gradient(90deg,transparent,var(--bronze2),transparent);}
#title .lore{max-width:560px;text-align:center;font-family:Georgia,serif;font-size:14.5px;line-height:1.75;color:#c9d5de;font-style:italic;}
.btn{pointer-events:auto;cursor:pointer;margin-top:12px;min-width:250px;text-align:center;padding:11px 24px;
font-family:Georgia,serif;letter-spacing:.22em;text-transform:uppercase;font-size:13px;color:#0b0f13;
background:linear-gradient(180deg,#f0d79b,#c39a4d 55%,#8a6a3a);border:1px solid #5d4a2a;border-radius:3px;
box-shadow:0 4px 0 #4a3a1f, 0 10px 26px rgba(0,0,0,.55);transition:transform .1s, filter .15s;}
.btn:hover{filter:brightness(1.12);transform:translateY(-1px);}
.btn:active{transform:translateY(2px);box-shadow:0 2px 0 #4a3a1f;}
.btn.ghost{background:linear-gradient(180deg,rgba(34,49,64,.9),rgba(16,23,30,.9));color:#d7e3ec;border-color:#3f5468;box-shadow:0 4px 0 #101820;}
.btn.small{min-width:0;padding:7px 14px;font-size:11px;}
.btn[disabled]{filter:grayscale(.7) brightness(.6);cursor:not-allowed;pointer-events:none;}
.ctl{margin-top:22px;display:flex;gap:26px;font-size:11px;letter-spacing:.08em;color:#8fa3b3;}
.ctl div{line-height:1.9;}
.ctl b{color:var(--gold);font-weight:600;}
.key-cap{display:inline-block;border:1px solid #4f5f6d;background:rgba(20,28,36,.9);border-radius:3px;padding:1px 6px;color:#dfeaf2;font-size:10px;}
.panel{pointer-events:auto;width:640px;max-height:540px;overflow:hidden;background:linear-gradient(180deg,rgba(14,20,27,.97),rgba(8,12,17,.98));
border:1px solid rgba(138,106,58,.8);border-radius:5px;box-shadow:0 24px 70px rgba(0,0,0,.75), inset 0 0 40px rgba(232,180,90,.05);
padding:22px 26px;}
.panel h2{margin:0 0 4px;font-family:Georgia,serif;font-size:24px;letter-spacing:.16em;text-transform:uppercase;
background:linear-gradient(180deg,#ffeec2,#c39a4d);-webkit-background-clip:text;background-clip:text;color:transparent;}
.panel .flav{font-family:Georgia,serif;font-style:italic;color:#9db1c2;font-size:13px;margin-bottom:14px;}
.row{display:flex;align-items:center;gap:14px;padding:11px 12px;margin-bottom:8px;border:1px solid #2b3944;border-radius:4px;
background:linear-gradient(180deg,rgba(22,32,42,.7),rgba(12,18,24,.7));}
.row .ic{width:40px;height:40px;flex:0 0 40px;display:flex;align-items:center;justify-content:center;
border:1px solid #4a4130;border-radius:3px;background:rgba(8,12,16,.7);}
.row .ic svg{width:24px;height:24px;}
.row .tx{flex:1;}
.row .tx b{display:block;font-family:Georgia,serif;font-size:15px;color:#f0e4c2;letter-spacing:.06em;}
.row .tx span{font-size:12px;color:#9db1c2;line-height:1.5;}
.row .pr{font-family:Georgia,serif;color:var(--gold2);font-size:16px;min-width:74px;text-align:right;}
.row .pr s{color:#6d7d8b;font-size:12px;}
.tag{display:inline-block;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;padding:2px 7px;border-radius:2px;
border:1px solid #4f6a80;color:#a9d3ec;background:rgba(40,80,110,.25);margin-left:8px;}
.tag.acc{border-color:#7c6a3f;color:#f0d79b;background:rgba(120,90,30,.2);}
.tag.cons{border-color:#4f8a5f;color:#b8ecbf;background:rgba(40,110,60,.2);}
.eqbtn{min-width:104px;}
.panel .foot{display:flex;justify-content:space-between;align-items:center;margin-top:14px;}
.goldbig{font-family:Georgia,serif;font-size:18px;color:var(--gold2);display:flex;align-items:center;gap:8px;}
.goldbig svg{width:20px;height:20px;}
.note{font-size:11px;color:#7f93a3;letter-spacing:.06em;}
#end .epic{font-family:Georgia,serif;font-size:46px;letter-spacing:.14em;
background:linear-gradient(180deg,#fff3d2,#e8b45a 60%,#8a6a3a);-webkit-background-clip:text;background-clip:text;color:transparent;}
#end .body{max-width:600px;text-align:center;font-family:Georgia,serif;font-size:15.5px;line-height:1.85;color:#cfdbe4;font-style:italic;margin-top:18px;}
#end .stats{margin-top:22px;display:flex;gap:36px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:#8fa3b3;}
#end .stats b{display:block;font-family:Georgia,serif;font-size:22px;color:var(--gold2);letter-spacing:.06em;margin-top:5px;}
.flashmsg{position:absolute;left:50%;top:120px;transform:translateX(-50%);z-index:30;
font-family:Georgia,serif;letter-spacing:.3em;text-transform:uppercase;font-size:14px;color:#ffdce0;
background:rgba(60,10,12,.9);border:1px solid rgba(200,60,60,.7);padding:10px 26px;border-radius:3px;opacity:0;transition:opacity .3s;}
.flashmsg.show{opacity:1;}
#muteTip{position:absolute;right:16px;bottom:14px;font-size:10px;letter-spacing:.14em;color:#5f7385;}
@media (max-width:820px){ .ctl{flex-direction:column;gap:6px;} }
</style>
</head>
<body>
<div id="wrap">
<div id="stage">
<canvas id="game" width="960" height="600"></canvas>
<div class="vig"></div>
<div id="flash"></div>
<div id="healFlash"></div>
<div id="shimmer"></div>
<div id="hud" class="hidden">
<div class="hudtop">
<div class="hudl">
<div id="hearts"></div>
<div class="wname" id="wname">Ember Blade</div>
</div>
<div class="hudc">
<div id="ammo" class="hidden">
<span>AETHER SHARD</span><span id="amPips"></span>
</div>
</div>
<div class="hudr">
<div class="coin">
<svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="#e8b45a" stroke="#8a6a3a" stroke-width="2"/><circle cx="10" cy="10" r="3.4" fill="none" stroke="#8a6a3a" stroke-width="1.4"/></svg>
<span id="gold">0</span>
</div>
<div id="objective"><b>Objective</b><span id="objText">—</span></div>
</div>
</div>
<div id="belt">
<div class="slot empty" id="slotStem" title="Vital Stem — press 1">
<span class="key">1</span>
<svg viewBox="0 0 20 20"><path d="M10 18.5V9" stroke="#7fd08a" stroke-width="1.8" fill="none"/><path d="M10.2 10.5c0-4 2.8-6.4 6.3-6.6.2 4-2 6.6-6.3 6.6z" fill="#7fd08a"/><path d="M9.8 13.5c0-3.1-2-5-5-5.2.1 3.1 1.9 5.2 5 5.2z" fill="#4f9d63"/></svg>
<span class="cnt" id="cntStem">0</span>
</div>
<div class="slot empty" id="slotSeal" title="Binding Seal — press 2">
<span class="key">2</span>
<svg viewBox="0 0 20 20"><path d="M10 2l7 3.9v8.2L10 18l-7-3.9V5.9z" fill="none" stroke="#7fb6ff" stroke-width="1.7"/><circle cx="10" cy="10" r="2.6" fill="#7fb6ff"/><path d="M10 5.5v9M5.5 10h9" stroke="#7fb6ff" stroke-width="1"/></svg>
<span class="cnt" id="cntSeal">0</span>
</div>
<div class="slot empty" id="slotAcc" title="Equipped accessory — TAB for inventory">
<span class="key">TAB</span>
<span class="accIco" id="accIco"></span>
<span id="accName">No accessory</span>
</div>
</div>
<div id="prompt"><em>E</em><span id="promptTxt"></span></div>
<div id="toast"></div>
<div id="zoneTitle"><div class="z"></div><div class="n"></div><div class="s"></div></div>
<div class="flashmsg" id="deathMsg">The Corrosion takes you</div>
</div>
<div id="muteTip">M — sound on/off · ESC — pause</div>
<!-- TITLE -->
<div class="overlay" id="title">
<h1>THE WARDEN'S<br>CORROSION</h1>
<div class="sub">Aurelion's Sunken Observatory</div>
<div class="rule"></div>
<div class="lore">Black-iron decay climbs the gears of the Observatory. You are the Last Warden.<br>
Carry the Ember Blade through six sunken halls, restore two weapon altars,<br>
unearth the Aether Shard, and rekindle the Core Beacon before the vault falls.</div>
<button class="btn" id="btnNew">Begin the Descent</button>
<button class="btn ghost hidden" id="btnContinue">Continue — Sealed Pilgrimage</button>
<div class="ctl">
<div><b>MOVE</b> <span class="key-cap">WASD</span> / <span class="key-cap">ARROWS</span><br>
<b>MELEE</b> <span class="key-cap">SPACE</span> / <span class="key-cap">J</span> / <span class="key-cap">LMB</span><br>
<b>DASH</b> <span class="key-cap">SHIFT</span></div>
<div><b>RANGED</b> <span class="key-cap">K</span> / <span class="key-cap">RMB</span> (Aether Shard)<br>
<b>INTERACT</b> <span class="key-cap">E</span><br>
<b>USE ITEM</b> <span class="key-cap">1</span> Vital Stem · <span class="key-cap">2</span> Binding Seal</div>
<div><b>INVENTORY</b> <span class="key-cap">TAB</span><br>
<b>PAUSE</b> <span class="key-cap">ESC</span><br>
<b>SOUND</b> <span class="key-cap">M</span></div>
</div>
</div>
<!-- INVENTORY -->
<div class="overlay hidden" id="invOv">
<div class="panel">
<h2>Warden's Satchel</h2>
<div class="flav" id="invFlav">One accessory may be worn at a time. Consumables are spent freely.</div>
<div id="invBody"></div>
<div class="foot">
<div class="note">Press <span class="key-cap">TAB</span> or <span class="key-cap">ESC</span> to close</div>
<button class="btn small ghost" id="invClose">Close</button>
</div>
</div>
</div>
<!-- SHOP -->
<div class="overlay hidden" id="shopOv">
<div class="panel">
<h2>Merchant Altar</h2>
<div class="flav" id="shopFlav">"Bronze remembers every hand that spent it. Spend, Warden."</div>
<div id="shopBody"></div>
<div class="foot">
<div class="goldbig"><svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="8" fill="#e8b45a" stroke="#8a6a3a" stroke-width="2"/><circle cx="10" cy="10" r="3.4" fill="none" stroke="#8a6a3a" stroke-width="1.4"/></svg><span id="shopGold">0</span> Golden Sigils</div>
<button class="btn small ghost" id="shopClose">Leave</button>
</div>
</div>
</div>
<!-- PAUSE -->
<div class="overlay hidden" id="pauseOv">
<div class="panel" style="width:520px">
<h2>Paired Breath</h2>
<div class="flav">The Corrosion waits. It is patient, but it is still moving.</div>
<div class="ctl" style="margin:0 0 6px">
<div><b>MOVE</b> WASD · <b>MELEE</b> SPACE/LMB · <b>DASH</b> SHIFT</div>
<div><b>RANGED</b> K/RMB · <b>INTERACT</b> E · <b>ITEMS</b> 1 / 2</div>
<div><b>INVENTORY</b> TAB · <b>SOUND</b> M</div>
</div>
<div class="foot">
<div class="note" id="pauseNote">Progress seals only at Sanctuary Stones.</div>
<div style="display:flex;gap:10px">
<button class="btn small ghost" id="btnMute">Sound: On</button>
<button class="btn small" id="btnResume">Resume</button>
</div>
</div>
</div>
</div>
<!-- ENDING -->
<div class="overlay hidden" id="endOv">
<div class="epic">THE BEACON REKINDLED</div>
<div class="body" id="endBody"></div>
<div class="stats">
<div>Foes Unmade<b id="stKills">0</b></div>
<div>Sigils Gathered<b id="stGold">0</b></div>
<div>Heart Containers<b id="stHp">3</b></div>
<div>Blade<b id="stBlade">Ember</b></div>
</div>
<button class="btn" id="btnAgain" style="margin-top:28px">Walk the Halls Again</button>
</div>
</div>
</div>
<script>
/* ============================================================
THE WARDEN'S CORROSION — single-file action adventure
============================================================ */
const T=40, COLS=24, ROWS=15, VW=COLS*T, VH=ROWS*T;
const $=id=>document.getElementById(id);
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rnd(a,b,b));
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const tx=v=>v*T+T/2; // tile index -> centre px
const rr=(x,y,w,h)=>({x:x*T,y:y*T,w:w*T,h:h*T});
const now=()=>performance.now()/1000;
/* ============================ AUDIO ============================ */
const A=(()=>{
let ready=false, ctx=null, master=null, sfxG=null, ambG=null, noiseBuf=null;
let muted=false, ambSrc=[], ambGn=null, drone=null, lastStep=0;
function init(){
if(ready) return;
const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
ctx=new AC();
master=ctx.createGain(); master.gain.value=muted?0:0.8; master.connect(ctx.destination);
sfxG=ctx.createGain(); sfxG.gain.value=0.95; sfxG.connect(master);
ambG=ctx.createGain(); ambG.gain.value=0.3; ambG.connect(master);
const len=Math.floor(ctx.sampleRate*1.5);
noiseBuf=ctx.createBuffer(1,len,ctx.sampleRate);
const d=noiseBuf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
ready=true;
}
const T0=()=>ctx.currentTime;
function tone(fr,o={}){
if(!ready)return; o=o||{};
const dur=o.dur!==undefined?o.dur:0.3, t=T0()+(o.delay||0);
const os=ctx.createOscillator(); os.type=o.type||'sine';
os.frequency.setValueAtTime(Math.max(20,fr),t);
if(o.f2) os.frequency.exponentialRampToValueAtTime(Math.max(20,o.f2),t+dur);
if(o.det) os.detune.value=o.det;
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(Math.max(0.001,o.vol||0.18),t+(o.att||0.006));
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
os.connect(g); g.connect(o.dest||sfxG);
os.start(t); os.stop(t+dur+0.03);
}
function noise(o={}){
if(!ready)return;
const dur=o.dur!==undefined?o.dur:0.2, t=T0()+(o.delay||0);
const s=ctx.createBufferSource(); s.buffer=noiseBuf; s.loop=true;
const f=ctx.createBiquadFilter(); f.type=o.type||'lowpass';
f.frequency.setValueAtTime(Math.max(40,o.f||800),t);
if(o.f2) f.frequency.exponentialRampToValueAtTime(Math.max(40,o.f2),t+dur);
f.Q.value=o.q||1;
const g=ctx.createGain();
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(Math.max(0.001,o.vol||0.2),t+(o.att||0.005));
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
s.connect(f); f.connect(g); g.connect(o.dest||sfxG);
s.start(t); s.stop(t+dur+0.03);
}
const sfx={
step(kind){
const t=now(); if(t-lastStep<0.26) return; lastStep=t;
if(kind==='grate') noise({dur:0.07,vol:0.1,f:2400,f2:1100,type:'bandpass',q:2});
else if(kind==='damp'){ noise({dur:0.13,vol:0.12,f:700,f2:260,q:1.4}); tone(180,{dur:0.09,vol:0.04,f2:90,type:'triangle'}); }
else if(kind==='spire') noise({dur:0.09,vol:0.09,f:3200,f2:1500,type:'bandpass',q:3});
else noise({dur:0.08,vol:0.1,f:1200,f2:520,q:1});
},
swing(tier){
if(tier===0){ noise({dur:0.15,vol:0.18,f:1900,f2:520,type:'bandpass',q:1.4}); }
else if(tier===1){ noise({dur:0.18,vol:0.2,f:2300,f2:600,type:'bandpass',q:1.2});
noise({dur:0.2,vol:0.09,f:3400,f2:1400,type:'highpass',q:1,delay:0.03}); }
else { noise({dur:0.3,vol:0.16,f:900,f2:260,type:'lowpass',att:0.05});
tone(96,{dur:0.3,vol:0.16,f2:44,type:'sine',att:0.04});
noise({dur:0.22,vol:0.16,f:3600,f2:900,type:'bandpass',q:2,delay:0.08}); }
},
hit(tier){
if(tier===0){ noise({dur:0.1,vol:0.22,f:520,f2:180,q:1}); }
else if(tier===1){ noise({dur:0.14,vol:0.24,f:620,f2:200,q:1}); noise({dur:0.16,vol:0.08,f:2600,f2:1200,type:'highpass',q:1}); }
else { tone(140,{dur:0.3,vol:0.22,f2:70,type:'square'});
tone(520,{dur:0.16,vol:0.1,f2:240,type:'sawtooth'});
noise({dur:0.2,vol:0.16,f:1400,f2:400,q:1}); }
},
block(){ tone(880,{dur:0.1,vol:0.12,type:'triangle'}); tone(1320,{dur:0.08,vol:0.08,type:'triangle',det:6}); },
kill(){ noise({dur:0.3,vol:0.16,f:900,f2:180}); tone(220,{dur:0.3,vol:0.1,f2:70,type:'triangle'}); },
hurt(){
noise({dur:0.2,vol:0.24,f:380,f2:120});
tone(700,{dur:0.28,vol:0.13,type:'square',f2:640});
tone(745,{dur:0.28,vol:0.11,type:'square',f2:800,det:14});
tone(110,{dur:0.24,vol:0.16,f2:60,type:'sine'});
},
coin(){ tone(1560,{dur:0.06,vol:0.09,type:'square'});
tone(2100,{dur:0.09,vol:0.07,type:'square',delay:0.05}); noise({dur:0.05,vol:0.06,f:5200,type:'highpass'}); },
pickup(kind){
if(kind==='rare'){ [523,659,784,1046].forEach((f,i)=>tone(f,{dur:0.55,vol:0.11,type:'triangle',delay:i*0.06}));
tone(262,{dur:1.1,vol:0.08,type:'sine'}); tone(392,{dur:1.1,vol:0.06,type:'sine',delay:0.05}); noise({dur:0.7,vol:0.05,f:5000,f2:2200,type:'highpass'}); }
else { [659,880,1174].forEach((f,i)=>tone(f,{dur:0.24,vol:0.1,type:'triangle',delay:i*0.055})); }
},
heart(){ [392,523,659,880,1046].forEach((f,i)=>tone(f,{dur:0.8,vol:0.1,type:'sine',delay:i*0.08}));
tone(196,{dur:1.4,vol:0.09,type:'sine'}); noise({dur:1.1,vol:0.05,f:4200,f2:1600,type:'highpass'}); },
shoot(){ tone(1180,{dur:0.1,vol:0.14,type:'triangle',f2:380});
tone(2360,{dur:0.05,vol:0.07,type:'square',f2:900}); noise({dur:0.07,vol:0.06,f:5200,type:'highpass'}); },
boltHit(){ tone(1760,{dur:0.14,vol:0.1,type:'sine'}); tone(2640,{dur:0.2,vol:0.05,type:'sine',delay:0.03}); },
enemyShoot(){ tone(320,{dur:0.18,vol:0.09,type:'sawtooth',f2:150}); noise({dur:0.1,vol:0.06,f:900,q:1}); },
plate(){ tone(180,{dur:0.2,vol:0.16,type:'triangle',f2:80}); noise({dur:0.18,vol:0.12,f:600,f2:200}); },
gate(){ noise({dur:0.7,vol:0.16,f:340,f2:120,att:0.05}); tone(70,{dur:0.7,vol:0.14,f2:44,type:'sine',att:0.05});
noise({dur:0.4,vol:0.1,f:2400,f2:800,type:'bandpass',q:2,delay:0.1}); },
crack(){ noise({dur:0.24,vol:0.22,f:1800,f2:300}); tone(90,{dur:0.2,vol:0.14,f2:50,type:'square'}); },
wallDown(){ noise({dur:0.7,vol:0.26,f:900,f2:120,att:0.02}); tone(64,{dur:0.8,vol:0.2,f2:34,type:'sine'});
noise({dur:0.5,vol:0.12,f:2600,f2:600,type:'bandpass',q:1.4,delay:0.05}); },
altar(tier){
[262,330,392,523].forEach((f,i)=>tone(f,{dur:1.2,vol:0.1,type:'triangle',delay:i*0.07}));
tone(tier===1?148:196,{dur:1.8,vol:0.14,type:'sine'});
noise({dur:1.4,vol:0.07,f:3200,f2:1200,type:'highpass'});
if(tier===2){ tone(98,{dur:2,vol:0.16,f2:52,type:'sine'}); tone(392,{dur:1.6,vol:0.07,type:'sine',delay:0.2}); }
},
save(){ tone(392,{dur:1.5,vol:0.13,type:'sine'}); tone(588,{dur:1.5,vol:0.09,type:'sine',delay:0.05});
tone(784,{dur:1.8,vol:0.06,type:'sine',delay:0.1}); noise({dur:1.2,vol:0.05,f:4600,f2:1800,type:'highpass'}); },
buy(){ [880,1174,1568].forEach((f,i)=>tone(f,{dur:0.3,vol:0.1,type:'triangle',delay:i*0.05}));
noise({dur:0.2,vol:0.1,f:3600,f2:1400,type:'bandpass',q:2}); tone(1320,{dur:0.08,vol:0.07,type:'square'}); },
deny(){ tone(150,{dur:0.18,vol:0.12,type:'square',f2:110}); tone(120,{dur:0.2,vol:0.09,type:'square',f2:90,delay:0.08}); },
potion(){ [440,587,880].forEach((f,i)=>tone(f,{dur:0.5,vol:0.1,type:'sine',delay:i*0.06})); noise({dur:0.4,vol:0.05,f:2600,f2:5200,type:'highpass'}); },
seal(){ tone(300,{dur:0.5,vol:0.13,type:'triangle',f2:80}); tone(1200,{dur:0.3,vol:0.08,type:'sine'});
noise({dur:0.4,vol:0.08,f:2200,f2:600,type:'bandpass',q:3}); },
roar(){ tone(70,{dur:1.1,vol:0.2,f2:42,type:'sawtooth'}); noise({dur:1,vol:0.16,f:500,f2:140});
tone(140,{dur:0.9,vol:0.1,f2:60,type:'square'}); },
beacon(){
[131,196,262,330,392,523].forEach((f,i)=>tone(f,{dur:3.4,vol:0.1,type:'sine',delay:i*0.12}));
noise({dur:2.6,vol:0.08,f:2200,f2:6000,type:'highpass'});
tone(65,{dur:3.6,vol:0.14,type:'sine'});
},
pulse(){ tone(rnd(300,420),{dur:0.1,vol:0.03,type:'triangle'}); }
};
function amb(kind){
if(!ready) return;
stopAmb();
ambGn=ctx.createGain(); ambGn.gain.value=0.0; ambGn.connect(ambG);
const base={stone:73.4,grate:82.4,damp:65.4,spire:98,core:55}[kind]||73.4;
[1,1.5,2.02,3.0].forEach((m,i)=>{
const o=ctx.createOscillator(); o.type=i%2?'triangle':'sine';
o.frequency.value=base*m; o.detune.value=rnd(-7,7);
const g=ctx.createGain(); g.gain.value=[0.5,0.22,0.14,0.07][i];
const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=kind==='core'?520:380;
o.connect(g); g.connect(lp); lp.connect(ambGn); o.start(); ambSrc.push(o);
});
ambGn.gain.setTargetAtTime(0.24,T0(),1.2);
}
function stopAmb(){
if(ambGn){ ambGn.gain.setTargetAtTime(0.0001,T0(),0.4);
const g=ambGn, s=ambSrc.slice(); setTimeout(()=>{ s.forEach(o=>{try{o.stop()}catch(e){}}); },900); }
ambSrc=[]; ambGn=null;
}
function shopDrone(on){
if(!ready) return;
if(on && !drone){
const g=ctx.createGain(); g.gain.value=0.0001; g.connect(sfxG);
const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=420; lp.connect(g);
const o1=ctx.createOscillator(), o2=ctx.createOscillator();
o1.type='triangle'; o2.type='sine'; o1.frequency.value=55; o2.frequency.value=82.9; o2.detune.value=8;
o1.connect(lp); o2.connect(lp); o1.start(); o2.start();
g.gain.setTargetAtTime(0.05,T0(),0.5);
drone={o:[o1,o2],g};
} else if(!on && drone){
const d=drone; drone=null; d.g.gain.setTargetAtTime(0.0001,T0(),0.3);
setTimeout(()=>d.o.forEach(o=>{try{o.stop()}catch(e){}}),800);
}
}
function setMute(m){ muted=m; if(master) master.gain.value=m?0:0.8; }
return {init,sfx,amb,stopAmb,shopDrone,setMute,isMuted:()=>muted,ready:()=>ready};
})();
/* ============================ WORLD DATA ============================ */
/* tile coordinates; 24 x 15 tiles of 40px */
const ROOMS={
/* ---------- ZONE 1 : THE ASHEN THRESHOLD ---------- */
z1a:{zone:1,name:"The Ashen Threshold",sub:"Outer Verge — the storm-locked valley below",amb:"stone",pal:"threshold",
ex:{E:"z1b"}, gap:{E:[6,8]},
walls:[[17,2,1,4],[17,9,1,4]],
crates:[[9,3],[9,11]],
jars:[[5,3],[6,12],[14,3]],
feats:[{t:"stone",id:"s1",x:3,y:7},
{t:"sign",id:"sg1",x:6,y:5,text:"AURELION’S SUNKEN OBSERVATORY — no warden has wound these gears in nine years. The Corrosion climbs from the valley like black frost."}],
foes:[["husk",16,7],["husk",20,11]],
gears:[[770,440,150,0.05],[835,175,95,-0.08]],dust:14},
z1b:{zone:1,name:"The Ashen Threshold",sub:"The Broken Walk",amb:"stone",pal:"threshold",
ex:{W:"z1a",E:"z2a"},
walls:[[11,2,2,4],[11,9,2,4]],
crates:[[7,7],[8,7]],
jars:[[4,3],[4,11],[19,3],[19,11]],
feats:[{t:"sign",id:"sg2",x:21,y:6,text:"Your Ember Blade was forged for this vigil. Swing it with SPACE or the left mouse; SHIFT to dash through a killing stroke."}],
foes:[["husk",15,7],["chime",18,4]],
gears:[[120,300,110,0.06]],dust:10},
/* ---------- ZONE 2 : THE RUSTED ARCHIVES ---------- */
z2a:{zone:2,name:"The Rusted Archives",sub:"Archive Vestibule — bronze grating, drifting dust",amb:"grate",pal:"archive",
ex:{W:"z1b",E:"z2b"},
walls:[[6,1,2,5],[6,9,2,5],[16,1,2,5],[16,9,2,5]],
crates:[[11,3],[12,11]],
jars:[[3,3],[3,11],[21,3],[21,11],[21,12]],
feats:[{t:"sign",id:"sg3",x:2,y:2,text:"INDEX OF BRIGHT HOURS — every construct you unmake lets go of 5 to 15 Golden Sigils. Broken jars keep older coin."}],
foes:[["husk",12,7],["chime",19,6],["sentry",20,2]],
gears:[[470,215,80,-0.05],[500,430,110,0.04]],dust:26},
z2b:{zone:2,name:"The Rusted Archives",sub:"Index Hall — Sanctuary Stone & Merchant Altar",amb:"grate",pal:"archive",
ex:{W:"z2a",E:"z2c"}, clear:true,
walls:[[11,5,2,5]],
crates:[[19,2],[19,3],[8,2]],
jars:[[3,12],[21,12]],
feats:[{t:"stone",id:"s2",x:6,y:7,needClear:true},
{t:"shop",id:"shop",x:3,y:3,needClear:true},
{t:"chest",id:"band",x:21,y:2,loot:{acc:"band"}},
{t:"sign",id:"sg4",x:13,y:3,text:"The merchant altar still hums with commerce. Silence the constructs first — bronze will not bargain over noise."}],
foes:[["husk",8,3],["husk",15,3],["chime",18,11],["sentry",20,7]],
gears:[[470,300,130,0.03]],dust:30},
z2c:{zone:2,name:"The Rusted Archives",sub:"Catalogue Annex — coin-jars by the hundred",amb:"grate",pal:"archive",
ex:{W:"z2b",E:"z3a"},
walls:[[5,2,3,3],[13,8,4,3]],
crates:[[10,12],[11,12],[18,2]],
jars:[[2,11],[3,11],[4,11],[21,4],[22,4],[2,3],[20,11],[21,11]],
feats:[{t:"chest",id:"hid2c",x:21,y:2,loot:{gold:90,item:"stem",n:1},lantern:true},
{t:"sign",id:"sg5",x:12,y:6,text:"Warden’s note: some caches were sealed with light. Only a lantern of living phosphor shows the seams."}],
foes:[["husk",10,4],["husk",17,12],["sentry",22,2],["chime",6,12]],
gears:[[700,480,120,0.04]],dust:24},
/* ---------- ZONE 3 : THE COLLAPSED GALLERY ---------- */
z3a:{zone:3,name:"The Collapsed Gallery",sub:"Tilted Hall — two bronze plates, one held breath",amb:"stone",pal:"gallery",
ex:{W:"z2c",E:"z3b"},
walls:[[10,1,3,4],[11,10,3,4]],
crates:[[8,12],[9,12]],
jars:[[4,3],[20,3]],
gates:[{id:"g3",x:23,y:6,w:1,h:3,flag:"g3"}],
plates:[{id:"pA",x:6,y:4},{id:"pB",x:17,y:11}],
seq:{plates:["pA","pB"],gate:"g3",window:3.4},
feats:[{t:"sign",id:"sg6",x:8,y:6,text:"GALLERY MECHANISM: press both bronze plates within one held breath and the eastern grate rises. A construct lured onto a plate will hold it for you."}],
foes:[["husk",12,7],["chime",18,4]],
gears:[[430,180,90,-0.05]],dust:18},
z3b:{zone:3,name:"The Collapsed Gallery",sub:"Fracture Alcove — the Curator still bows to no one",amb:"stone",pal:"gallery",
ex:{W:"z3a",E:"z3c"},
walls:[[19,1,1,1],[19,4,1,2],[20,6,3,1]],
cracks:[{id:"crack4",r:[[19,2,1,2]],hp:8}],
crates:[[6,3],[7,3]],
jars:[[4,11],[5,11]],
feats:[{t:"pedestal",id:"ped4",x:21,y:3,loot:{heart:true}}],
foes:[["curator",10,7,"boss"]],
gears:[[300,470,120,0.05]],dust:16},
z3c:{zone:3,name:"The Collapsed Gallery",sub:"Ember Altar — Upgrade I",amb:"stone",pal:"gallery",
ex:{W:"z3b",E:"z4a"},
walls:[[3,2,2,3],[19,10,2,3]],
crates:[[8,12],[16,3]],
jars:[[4,11],[21,2]],
feats:[{t:"altar",id:"al1",x:12,y:4,up:1},
{t:"sign",id:"sg7",x:14,y:6,text:"ALTAR OF THE IGNITED EDGE: lay the Ember Blade upon the coals. Fire remembers; the blade will too."}],
foes:[["husk",7,11],["chime",17,3],["husk",20,7]],
gears:[[480,300,140,0.03]],dust:14}
};
Object.assign(ROOMS,{
/* ---------- ZONE 4 : THE SUNKEN VAULT ---------- */
z4a:{zone:4,name:"The Sunken Vault",sub:"Flooded Steps — mist rises, water remembers",amb:"damp",pal:"vault",
ex:{W:"z3c",E:"z4b"},
pits:[[6,1,4,3],[14,10,5,4]],
walls:[[11,5,2,5]],
crates:[[4,6]],
jars:[[3,4],[20,3]],
feats:[{t:"cistern",id:"cis",x:3,y:11},
{t:"sign",id:"sg8",x:8,y:3,text:"CISTERN OF QUIET WATER: drink and the water knits flesh — one draught per vigil, refilled only at a Sanctuary Stone."}],
foes:[["chime",15,4],["husk",10,12],["chime",20,7]],
gears:[[250,200,90,0.04]],mist:26},
z4b:{zone:4,name:"The Sunken Vault",sub:"Cracked Sea Wall — hairline fractures, older stone",amb:"damp",pal:"vault",
ex:{W:"z4a",E:"z4c"},
pits:[[5,9,6,4],[13,2,3,3]],
walls:[[18,1,1,1],[18,3,1,1],[19,4,4,1],[16,7,2,3]],
cracks:[{id:"crackShard",r:[[18,2,1,1]],hp:8,hidden:true}],
crates:[[8,4],[9,4]],
jars:[[3,12],[21,11]],
feats:[{t:"pedestal",id:"pedShard",x:20,y:2,loot:{shard:true}},
{t:"sign",id:"sg9",x:12,y:11,text:"Warden’s note: the sea wall is not solid. Look for hairline fractures and a discoloration in the stone — then strike it until it gives."}],
foes:[["chime",8,6],["husk",14,12],["sentry",21,12],["husk",6,2]],
gears:[[640,470,100,-0.04]],mist:30},
z4c:{zone:4,name:"The Sunken Vault",sub:"Mirror Gallery — bend the light to earn the lantern",amb:"damp",pal:"vault",
ex:{W:"z4b",E:"z5a"},
walls:[[15,4,4,3],[17,10,4,3],[5,3,3,2],[20,8,3,2]],
gates:[{id:"g4",x:23,y:6,w:1,h:3,flag:"g4"}],
feats:[{t:"emitter",x:2,y:2,dir:"E"},
{t:"mirror",id:"m1",x:10,y:2,o:0},
{t:"mirror",id:"m2",x:10,y:12,o:0},
{t:"mirror",id:"m3",x:3,y:12,o:0},
{t:"mirror",id:"m4",x:3,y:6,o:1},
{t:"receiver",x:8,y:6,flag:"lightOn"},
{t:"pedestal",id:"pedLantern",x:21,y:2,loot:{acc:"lantern"},needFlag:"lightOn"},
{t:"sign",id:"sg10",x:12,y:8,text:"LIGHT MECHANISM: mirrors turn on the flat (press E). The old rule stands — a beam running east leaves by the south face; running south leaves by the west."}],
foes:[["chime",16,7],["husk",21,11]],
gears:[[720,220,90,0.05]],mist:22},
/* ---------- ZONE 5 : THE TWIN SPIRE ---------- */
z5a:{zone:5,name:"The Twin Spire",sub:"Spire Approach — Sanctuary Stone & the sealed vault",amb:"spire",pal:"spire",
ex:{W:"z4c",E:"z5b"},
pits:[[10,2,5,5]],
walls:[[16,1,1,2],[16,4,1,2],[17,6,6,1]],
gates:[{id:"g5v",x:16,y:3,w:1,h:1,flag:"g5v"}],
crates:[[7,12],[8,12]],
jars:[[3,3],[21,12]],
feats:[{t:"stone",id:"s3",x:3,y:7},
{t:"switch",id:"sw5",x:12,y:4,flag:"g5v"},
{t:"pedestal",id:"ped5",x:20,y:3,loot:{heart:true}},
{t:"sign",id:"sg11",x:6,y:10,text:"THE SEALED VAULT: the crystal switch stands across the chasm, far beyond any blade’s reach. Only the Aether Shard will wake it."}],
foes:[["husk",8,3],["sentry",21,12],["chime",17,8]],
gears:[[430,330,120,-0.03]],dust:16},
z5b:{zone:5,name:"The Twin Spire",sub:"Gauntlet Ledge — the Spire Guardian",amb:"spire",pal:"spire",
ex:{W:"z5a",E:"z5c"}, clear:true,
walls:[[11,1,2,3],[11,11,2,3]],
vents:[[5,7,3,1,0],[15,3,1,4,1.3]],
crates:[[3,5],[4,5],[20,8]],
jars:[[3,3],[12,4]],
feats:[{t:"chest",id:"chestBonus",x:21,y:2,loot:{gold:200}},
{t:"chest",id:"hid5",x:2,y:12,loot:{gold:120,item:"seal",n:2},lantern:true},
{t:"sign",id:"sg12",x:13,y:8,text:"Steam vents grind along the ledge on a fixed rhythm. A Shadow Cloak turns their scalding aside; a dash will do, in a pinch."}],
foes:[["husk",7,3],["husk",16,11],["chime",12,7],["sentry",20,3],["sentry",4,12],["guardian",20,12,"boss"]],
gears:[[230,180,110,0.05]],dust:18},
z5c:{zone:5,name:"The Twin Spire",sub:"Solar Altar — Upgrade II",amb:"spire",pal:"spire",
ex:{W:"z5b",E:"z6a"},
walls:[[3,10,3,2],[18,2,3,2]],
crates:[[8,3]],
jars:[[21,11],[4,3]],
feats:[{t:"altar",id:"al2",x:12,y:3,up:2},
{t:"sign",id:"sg13",x:14,y:5,text:"ALTAR OF THE SOLAR FALCHION: a slow wind-up, a heavier fall. Light does not hurry — it simply arrives."}],
foes:[["chime",6,4],["husk",17,11],["husk",21,5]],
gears:[[480,420,150,-0.03]],dust:14},
/* ---------- ZONE 6 : THE CORE CHAMBER ---------- */
z6a:{zone:6,name:"The Core Chamber",sub:"The Long Bridge — two crystals, one gate",amb:"core",pal:"core",
ex:{W:"z5c",E:"z6b"},
pits:[[4,1,5,5],[4,9,5,5],[13,1,4,4],[13,10,4,4]],
gates:[{id:"g6",x:23,y:6,w:1,h:3,flag:"sw6a+sw6b"}],
feats:[{t:"switch",id:"sw6a",x:6,y:3,flag:"sw6a"},
{t:"switch",id:"sw6b",x:6,y:11,flag:"sw6b"},
{t:"sign",id:"sg14",x:11,y:7,text:"The Core Beacon burns one hall east. Two crystal switches hold the last grate — and the Corroded Regent holds the Beacon."}],
foes:[["sentry",14,7],["chime",18,4],["husk",18,11]],
gears:[[200,300,140,0.03],[760,300,140,-0.03]],dust:12},
z6b:{zone:6,name:"The Core Chamber",sub:"Beacon Core — the Corroded Regent",amb:"core",pal:"core",clear:true,
ex:{W:"z6a"},
pits:[[2,1,4,4],[19,1,4,4],[19,10,4,4]],
feats:[{t:"switch",id:"swB1",x:3,y:2,flag:"swB1"},
{t:"switch",id:"swB2",x:21,y:2,flag:"swB2"},
{t:"switch",id:"swB3",x:21,y:12,flag:"swB3"},
{t:"beacon",x:12,y:3},
{t:"sign",id:"sg15",x:2,y:8,text:"The Regent wears a corrosion-shield. Three crystal switches hold it shut — the Shard reaches them where your blade cannot."}],
foes:[["regent",12,9,"boss"]],
gears:[[480,300,190,0.02]],dust:10}
});
const PALS={
threshold:{skyA:"#101822",skyB:"#070a0e",floor:"#1b232b",floor2:"#151c23",grout:"#0d1318",wall:"#3b3527",wall2:"#5a4d33",edge:"#8a6a3a",moss:"#43503a"},
archive:{skyA:"#12181f",skyB:"#06090c",floor:"#1d232a",floor2:"#161b21",grout:"#0b0f13",wall:"#3a3226",wall2:"#63533a",edge:"#a07c42",moss:"#3f4a38"},
gallery:{skyA:"#151a20",skyB:"#07090c",floor:"#22262a",floor2:"#1a1e22",grout:"#0d1013",wall:"#453a2b",wall2:"#6b5a3c",edge:"#b08a4c",moss:"#4a5540"},
vault:{skyA:"#0d1620",skyB:"#04070a",floor:"#17222a",floor2:"#111a21",grout:"#080d11",wall:"#2f3a3f",wall2:"#4a5a5c",edge:"#6f8a86",moss:"#3c5a4a"},
spire:{skyA:"#181c26",skyB:"#07080d",floor:"#20242c",floor2:"#181c23",grout:"#0b0e12",wall:"#3c3a33",wall2:"#5f5947",edge:"#c19a52",moss:"#4b5340"},
core:{skyA:"#1a1414",skyB:"#060405",floor:"#241f1d",floor2:"#1a1615",grout:"#0a0808",wall:"#3d2f26",wall2:"#6a4b31",edge:"#e0a44a",moss:"#423b2c"}
};
const ZONE_TITLE={1:["ZONE I","The Ashen Threshold"],2:["ZONE II","The Rusted Archives"],3:["ZONE III","The Collapsed Gallery"],
4:["ZONE IV","The Sunken Vault"],5:["ZONE V","The Twin Spire"],6:["ZONE VI","The Core Chamber"]};
/* ============================ GAME STATE ============================ */
const G={
state:"title", room:null, roomId:null, t:0, runT:0,
gold:0, items:{stem:0,seal:0}, acc:{band:0,lantern:0,cloak:0}, worn:null,
weapon:0, hasShard:false, ammo:0, maxAmmo:24, ammoT:0,
containers:3, hp:6, flags:{}, kills:0, earned:0,
stoneRoom:"z1a", stonePos:{x:tx(3),y:tx(7)+36},
prj:[], parts:[], floats:[], drops:[], shake:0, hitstop:0, seqT:0, seqStep:0,
menu:null, dead:false, deadT:0, ending:false
};
const maxHp=()=>G.containers*2;
function flag(k,v){ if(v===undefined) return !!G.flags[k]; G.flags[k]=v; }
const P={x:400,y:300,r:13,hp:6,face:0,walk:0,moving:false,iframes:0,atkT:0,swing:0,swingHit:false,
dashT:0,dashCd:0,dashDir:0,hurtT:0,burn:null,stepT:0};
/* ============================ ROOM BUILD ============================ */
function segsH(y,gap){const o=[];if(!gap){o.push(rr(0,y,COLS,1));return o;}
const a=gap[0],b=gap[1]; if(a>0)o.push(rr(0,y,a,1)); if(b<COLS-1)o.push(rr(b+1,y,COLS-1-b,1)); return o;}
function segsV(x,gap){const o=[];if(!gap){o.push(rr(x,0,1,ROWS));return o;}
const a=gap[0],b=gap[1]; if(a>0)o.push(rr(x,0,1,a)); if(b<ROWS-1)o.push(rr(x,b+1,1,ROWS-1-b)); return o;}
function buildRoom(id){
const d=ROOMS[id], R={id,d,solid:[],objs:[],gates:[],plates:[],feats:[],foes:[],bg:null,gap:{}};
R.gap.W=d.gap&&d.gap.W?d.gap.W:[6,8]; R.gap.E=d.gap&&d.gap.E?d.gap.E:[6,8];
R.gap.N=d.gap&&d.gap.N?d.gap.N:[10,12]; R.gap.S=d.gap&&d.gap.S?d.gap.S:[10,12];
if(d.ex.W) segsV(0,R.gap.W).forEach(r=>R.solid.push(r)); else R.solid.push(rr(0,0,1,ROWS));
if(d.ex.E) segsV(COLS-1,R.gap.E).forEach(r=>R.solid.push(r)); else R.solid.push(rr(COLS-1,0,1,ROWS));
if(d.ex.N) segsH(0,R.gap.N).forEach(r=>R.solid.push(r)); else R.solid.push(rr(0,0,COLS,1));
if(d.ex.S) segsH(ROWS-1,R.gap.S).forEach(r=>R.solid.push(r)); else R.solid.push(rr(0,ROWS-1,COLS,1));
(d.walls||[]).forEach(w=>R.solid.push(rr(w[0],w[1],w[2],w[3])));
(d.pits||[]).forEach(w=>R.solid.push(Object.assign(rr(w[0],w[1],w[2],w[3]),{pit:true})));
(d.crates||[]).forEach((c,i)=>{ const id2=id+".crate"+i;
if(!flag(id2)) R.objs.push({id:id2,kind:"crate",rect:rr(c[0],c[1],1,1),hp:3,dead:false}); });
(d.jars||[]).forEach((c,i)=>{ const id2=id+".jar"+i;
if(!flag(id2)) R.objs.push({id:id2,kind:"jar",rect:rr(c[0],c[1],1,1),hp:1,dead:false}); });
(d.cracks||[]).forEach(c=>{ if(!flag(c.id)) R.objs.push({id:c.id,kind:c.hidden?"hidden":"crack",rects:c.r.map(w=>rr(w[0],w[1],w[2],w[3])),hp:c.hp,dead:false}); });
(d.gates||[]).forEach(g=>R.gates.push({id:g.id,rect:rr(g.x,g.y,g.w,g.h),flag:g.flag}));
(d.plates||[]).forEach(p=>R.plates.push({id:p.id,rect:rr(p.x,p.y,1,1),on:false,was:false}));
(d.feats||[]).forEach(f=>R.feats.push(Object.assign({},f,{px:tx(f.x),py:tx(f.y)})));
(d.foes||[]).forEach((f,i)=>{ const pers=!!f[3];
if(pers&&flag(id+".foe"+i)) return;
R.foes.push(spawnEnemy(f[0],tx(f[1]),tx(f[2]),pers?id+".foe"+i:null)); });
return R;
}
function dynBlockers(R){
const out=[];
R.objs.forEach(o=>{ if(o.dead) return;
if(o.rects) o.rects.forEach(r=>out.push(r)); else out.push(o.rect); });
R.gates.forEach(g=>{ if(!gateOpen(g)) out.push(g.rect); });
if(G.room&&G.room.d&&G.room.d.clear&&R.foes.some(f=>f.hp>0)){
// corridor seals over exits while the hall is unquiet
const d=R.d, gp=R.gap;
if(d.ex.W) out.push({x:0,y:gp.W[0]*T,w:T*0.9,h:(gp.W[1]-gp.W[0]+1)*T,seal:true});
if(d.ex.E) out.push({x:VW-T*0.9,y:gp.E[0]*T,w:T*0.9,h:(gp.E[1]-gp.E[0]+1)*T,seal:true});
if(d.ex.N) out.push({x:gp.N[0]*T,y:0,w:(gp.N[1]-gp.N[0]+1)*T,h:T*0.9,seal:true});
if(d.ex.S) out.push({x:gp.S[0]*T,y:VH-T*0.9,w:(gp.S[1]-gp.S[0]+1)*T,h:T*0.9,seal:true});
}
return out;
}
function gateOpen(g){ return g.flag.split("+").every(f=>flag(f)); }
function blockers(){ return G.room.solid.concat(dynBlockers(G.room)); }
function rectOf(o){ return o.rect||o; }
function hitRect(x,y,r,rects){
for(const q of rects){ const cx=clamp(x,q.x,q.x+q.w), cy=clamp(y,q.y,q.y+q.h);
if((x-cx)*(x-cx)+(y-cy)*(y-cy) < r*r) return q; }
return null;
}
function moveEnt(e,dx,dy){
const B=blockers();
e.x+=dx; let h=hitRect(e.x,e.y,e.r,B);
if(h){ e.x = dx>0 ? h.x-e.r-0.01 : h.x+h.w+e.r+0.01; }
e.y+=dy; h=hitRect(e.x,e.y,e.r,B);
if(h){ e.y = dy>0 ? h.y-e.r-0.01 : h.y+h.h+e.r+0.01; }
e.x=clamp(e.x,e.r,VW-e.r); e.y=clamp(e.y,e.r,VH-e.r);
}
/* ============================ PARTICLES / FLOATERS ============================ */
function part(o){ G.parts.push(Object.assign({x:0,y:0,vx:0,vy:0,life:.6,max:.6,r:2,c:"#fff",glow:false,g:0,drag:1},o)); }
function burst(x,y,c,n,spd,o){ o=o||{};
for(let i=0;i<n;i++){ const a=rnd(0,Math.PI*2), s=rnd(spd*0.3,spd);
part({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,c,r:rnd(1.2,o.r||3),life:rnd(.25,o.life||.6),max:o.life||.6,
glow:o.glow!==false,g:o.g||220,drag:o.drag||0.92}); } }
function sparks(x,y,c){ burst(x,y,c,12,260,{life:.4,g:300,r:2.2}); }
function emberSmoke(x,y){ for(let i=0;i<7;i++) part({x:x+rnd(-6,6),y:y+rnd(-6,6),vx:rnd(-18,18),vy:rnd(-46,-14),
c:Math.random()<.5?"#ff8a3c":"#5b5f66",r:rnd(2,4),life:rnd(.5,.9),max:.9,g:-16,glow:false,drag:.97}); }
function goldSwirl(x,y){ for(let i=0;i<10;i++) part({x:x+rnd(-8,8),y:y+rnd(-8,8),vx:rnd(-40,40),vy:rnd(-90,-20),
c:Math.random()<.5?"#e8b45a":"#ffd98a",r:rnd(1,2.4),life:rnd(.4,.8),max:.8,g:60,drag:.95}); }
function motes(x,y,c,n){ for(let i=0;i<(n||12);i++) part({x:x+rnd(-12,12),y:y+rnd(-4,14),vx:rnd(-14,14),vy:rnd(-70,-26),
c:c||"#ffffff",r:rnd(1,2.6),life:rnd(.6,1.2),max:1.2,g:-8,drag:.985}); }
function auraPulse(){ for(let i=0;i<34;i++){ const a=rnd(0,Math.PI*2);
part({x:P.x+Math.cos(a)*20,y:P.y+Math.sin(a)*20,vx:Math.cos(a)*70,vy:Math.sin(a)*70,
c:i%2?"#ffd98a":"#fff3d2",r:rnd(1.5,3.2),life:rnd(.5,1),max:1,g:0,drag:.94}); } }
function floatTxt(x,y,txt,c){ G.floats.push({x,y,txt,c:c||"#fff",t:0}); }
function updateParticles(dt){
for(let i=G.parts.length-1;i>=0;i--){ const p=G.parts[i];
p.life-=dt; if(p.life<=0){ G.parts.splice(i,1); continue; }
p.vy+=(p.g||0)*dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
p.vx*=Math.pow(p.drag||1,dt*60); p.vy*=Math.pow(p.drag||1,dt*60); }
for(let i=G.floats.length-1;i>=0;i--){ const f=G.floats[i]; f.t+=dt; f.y-=26*dt; if(f.t>0.9) G.floats.splice(i,1); }
for(let i=G.drops.length-1;i>=0;i--){ const d=G.drops[i];
d.t+=dt; const dx=P.x-d.x, dy=P.y-d.y, dd=Math.hypot(dx,dy);
if(dd<86||d.t>1.2){ const s=clamp(240/Math.max(dd,12),3,11); d.x+=dx/dd*s*dt*60*0.35; d.y+=dy/dd*s*dt*60*0.35; }
else { d.x+=Math.sin(d.t*4+d.seed)*10*dt; }
if(dd<P.r+8){ G.gold+=d.v; G.earned+=d.v; A.sfx.coin(); goldSwirl(d.x,d.y); floatTxt(d.x,d.y-8,"+"+d.v,"#ffd98a"); G.drops.splice(i,1); updateHUD(); } }
}
/* ============================ LOOT / ITEMS ============================ */
function give(loot,srcX,srcY){
if(!loot) return;
if(loot.gold){ G.gold+=loot.gold; G.earned+=loot.gold; goldSwirl(srcX||P.x,srcY||P.y); A.sfx.coin(); }
if(loot.item){ G.items[loot.item]+=loot.n||1; A.sfx.pickup("common"); motes(srcX||P.x,srcY||P.y,"#b8ecbf");
const inm=loot.item==="stem"?"Vital Stem":"Binding Seal";
const ifx=loot.item==="stem"?"Press 1 to drink — it restores 2 full containers (4 HP).":"Press 2 to bind — it freezes one construct in place for 3 seconds.";
toast(inm+" acquired",ifx); }
if(loot.acc){ G.acc[loot.acc]=1; A.sfx.pickup("rare"); motes(srcX||P.x,srcY||P.y,"#ffffff",18);
const nm={band:"Warden’s Band",lantern:"Phantom Lantern",cloak:"Shadow Cloak"}[loot.acc];
const fx={band:"Wrist-ring of living silver. Attack speed rises a little.",
lantern:"Soft blue phosphor. Hidden caches and hairline cracks now shine for you.",
cloak:"Dark cloth at your hem. Steam vents and traps bite half as deep."}[loot.acc];
toast(nm+" acquired",fx+" Open the Satchel (TAB) to wear it."); shimmer(1.2); }
if(loot.shard){ G.hasShard=true; G.ammo=16; A.sfx.pickup("rare"); A.sfx.altar(1); motes(srcX||P.x,srcY||P.y,"#bfe9ff",22);
toast("Aether Shard acquired","A crystalline launcher settles into your off-hand. Fire with K or the right mouse — it reaches where blades cannot."); shimmer(1.6); }
if(loot.heart){ G.containers=Math.min(5,G.containers+1); G.hp=maxHp(); A.sfx.heart(); auraPulse(); shimmer(1.6);
toast("Heart Container acquired","Your lifeblood deepens. "+G.containers+" containers — "+maxHp()+" HP."); }
if(loot.weapon){ G.weapon=loot.weapon; A.sfx.altar(loot.weapon); shimmer(1.8); }
if(loot.ammo){ G.ammo=Math.min(G.maxAmmo,G.ammo+loot.ammo); A.sfx.pickup("common"); }
updateHUD();
}
function useStem(){
if(G.items.stem<=0){ A.sfx.deny(); return; }
if(G.hp>=maxHp()){ toast("Vital Stem","Already at full lifeblood. Save it."); A.sfx.deny(); return; }
G.items.stem--; G.hp=Math.min(maxHp(),G.hp+4); A.sfx.potion(); healFlash();
motes(P.x,P.y,"#b8ecbf",16); floatTxt(P.x,P.y-18,"+4 HP","#b8ecbf"); updateHUD();
}
function useSeal(){
if(G.items.seal<=0){ A.sfx.deny(); return; }
let best=null,bd=280;
for(const e of G.room.foes){ if(e.hp<=0||e.boss) continue; const d=dist(e,P); if(d<bd){bd=d;best=e;} }
if(!best){ toast("Binding Seal","Nothing close enough to bind."); A.sfx.deny(); return; }
G.items.seal--; best.frozen=3; A.sfx.seal();
burst(best.x,best.y,"#7fb6ff",18,180,{life:.6,g:0});
floatTxt(best.x,best.y-24,"BOUND","#7fb6ff"); updateHUD();
}
/* ============================ ENEMIES ============================ */
const ETYPES={
husk:{hp:6,r:15,spd:64,dmg:1,gmin:5,gmax:10,c:"#3d434c",eye:"#ff8a3c"},
chime:{hp:4,r:13,spd:94,dmg:1,gmin:6,gmax:12,c:"#4a4230",eye:"#ffd98a"},
sentry:{hp:5,r:14,spd:32,dmg:1,gmin:8,gmax:14,c:"#33404c",eye:"#9fe8ff",ranged:true},
curator:{hp:26,r:22,spd:48,dmg:2,gmin:40,gmax:60,c:"#4a4030",eye:"#ff8a3c",big:true},
guardian:{hp:32,r:22,spd:78,dmg:2,gmin:50,gmax:80,c:"#3a3f46",eye:"#ff5a3c",big:true},
regent:{hp:70,r:30,spd:56,dmg:2,gmin:100,gmax:140,c:"#2e2a2c",eye:"#c8632a",big:true,boss:true}
};
function spawnEnemy(type,x,y,pers){
const s=ETYPES[type];
return {type,st:s,x,y,r:s.r,hp:s.hp,maxHp:s.hp,spd:s.spd,dmg:s.dmg,cd:rnd(.3,1),wind:0,face:0,hitT:0,burnT:0,
frozen:0,t:rnd(0,6),state:"idle",vx:0,vy:0,phase:0,pers:persistKey(pers),boss:!!s.big,anim:rnd(0,6),chargeT:0};
}
function persistKey(k){ return k; }
function damageEnemy(e,dmg,kx,ky,tier){
if(e.hp<=0) return;
if(e.type==="regent" && bossShieldUp()){ shieldPing(e); return; }
e.hp-=dmg; e.hitT=.16;
if(tier>0){ e.burnT=tier===2?1.4:0.9; if(tier===2) emberSmoke(e.x,e.y); }
if(kx!==undefined){ e.vx=(e.vx||0)+kx; e.vy=(e.vy||0)+ky; }
floatTxt(e.x+rnd(-6,6),e.y-e.r-6,""+dmg,tier===2?"#ffd98a":(tier===1?"#ff8a3c":"#f0e4c2"));
sparks(e.x,e.y,tier===2?"#ffd98a":(tier===1?"#ff8a3c":"#e8a24a"));
if(e.hp<=0) killEnemy(e); else A.sfx.hit(tier);
}
function killEnemy(e){
e.hp=0; G.kills++; A.sfx.kill();
burst(e.x,e.y,e.st.eye,20,240,{life:.6});
burst(e.x,e.y,"#2a2622",14,160,{life:.8,glow:false,g:120});
if(e.burnT>0) emberSmoke(e.x,e.y);
const gold=ri(e.st.gmin,e.st.gmax);
const n=Math.max(1,Math.round(gold/6));
for(let i=0;i<n;i++) G.drops.push({x:e.x+rnd(-10,10),y:e.y+rnd(-10,10),v:Math.round(gold/n),t:0,seed:rnd(0,9)});
if(e.pers) flag(e.pers,true);
if(e.type==="curator"){ toast("Gallery Curator unmade","The alcove stands quiet. The crack-marked wall to the north-east looks thinner than the rest.");
burst(e.x,e.y,"#ffd98a",26,300,{life:.9}); }
if(e.type==="guardian"){ toast("Spire Guardian unmade","Something dark slips from its armour — the Shadow Cloak.");
give({acc:"cloak"},e.x,e.y); }
if(e.type==="regent"){ bossDefeated(); }
if(G.room.d.clear && !G.room.foes.some(f=>f.hp>0)){ A.sfx.gate();
toast("The hall falls quiet","Corrosion seals loosen. The way onward opens."); }
}
function damagePlayer(dmg,srcX,srcY,env){
if(P.iframes>0||G.dead) return;
let d=dmg;
if(env && G.worn==="cloak") d=Math.max(0,d-1);
if(d<=0){ floatTxt(P.x,P.y-20,"turned aside","#a9bccb"); P.iframes=.6; A.sfx.block(); return; }
G.hp-=d; P.iframes=.9; P.hurtT=.3; G.shake=Math.max(G.shake,10);
flashRed(); A.sfx.hurt();
burst(P.x,P.y,"#c8302a",18,220,{life:.5});
floatTxt(P.x,P.y-22,"-"+d,"#ff6a5a");
updateHUD();
if(G.hp<=0) die();
}
function die(){
G.dead=true; G.deadT=0; G.hp=0; A.sfx.roar();
burst(P.x,P.y,"#c8302a",30,300,{life:.9});
showDeath();
}
/* ============================ PROJECTILES ============================ */
function spawnPrj(o){ G.prj.push(Object.assign({x:0,y:0,vx:0,vy:0,r:5,dmg:1,life:1.4,from:"en",kind:"bolt",c:"#9fe8ff",t:0},o)); }
function updatePrj(dt){
for(let i=G.prj.length-1;i>=0;i--){ const p=G.prj[i];
p.t+=dt; p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
if(p.kind==="ring"){ p.r+=p.grow*dt;
if(p.r>p.max){ G.prj.splice(i,1); continue; }
if(p.from==="en"&&Math.abs(dist(p,P)-p.r)<9&&p.hit!==true){ damagePlayer(p.dmg,p.x,p.y); p.hit=true; }
if(p.life<=0) G.prj.splice(i,1);
continue; }
if(p.life<=0||p.x<-20||p.x>VW+20||p.y<-20||p.y>VH+20){ if(p.kind==="bolt"&&p.from==="pl") burst(p.x,p.y,"#bfe9ff",5,90,{life:.3});
G.prj.splice(i,1); continue; }
const B=blockers(); const hit=hitRect(p.x,p.y,p.r,B);
if(hit){ impactPrj(p,i,hit); continue; }
if(p.from==="pl"){
// breakables
let done=false;
for(const o of G.room.objs){ if(o.dead) continue;
const r=o.rects?null:o.rect; const rs=o.rects||[r];
for(const q of rs){ if(p.x>q.x-p.r&&p.x<q.x+q.w+p.r&&p.y>q.y-p.r&&p.y<q.y+q.h+p.r){ damageObj(o,1,true); done=true; break; } }
if(done) break; }
if(done){ burst(p.x,p.y,"#bfe9ff",8,140,{life:.3}); A.sfx.boltHit(); G.prj.splice(i,1); continue; }
// crystal switches
for(const f of G.room.feats){ if(f.t!=="switch"||flag(f.flag)) continue;
if(Math.abs(p.x-f.px)<T*0.6&&Math.abs(p.y-f.py)<T*0.6){ hitSwitch(f); done=true; break; } }
if(done){ G.prj.splice(i,1); continue; }
for(const e of G.room.foes){ if(e.hp<=0) continue;
if(dist(p,e)<p.r+e.r){ damageEnemy(e,p.dmg,0,0,1); A.sfx.boltHit(); G.prj.splice(i,1); done=true; break; } }
if(done) continue;
} else {
if(dist(p,P)<p.r+P.r){ damagePlayer(p.dmg,p.x,p.y); G.prj.splice(i,1); continue; }
}
}
}
function impactPrj(p,i,rect){
if(p.kind==="bolt"&&p.from==="pl"){ burst(p.x,p.y,"#bfe9ff",7,150,{life:.3}); A.sfx.boltHit(); }
else burst(p.x,p.y,p.c,5,110,{life:.25});
G.prj.splice(i,1);
}
function hitSwitch(f){
flag(f.flag,true); A.sfx.plate(); A.sfx.boltHit();
burst(f.px,f.py,"#bfe9ff",22,240,{life:.7});
G.shake=Math.max(G.shake,6);
let opened=false;
G.room.gates.forEach(g=>{ if(gateOpen(g)) opened=true; });
if(opened){ A.sfx.gate(); toast("Crystal switch struck","Grates grind open somewhere in the hall."); }
else toast("Crystal switch struck","Something unlocks in the dark.");
if(f.flag==="swB1"||f.flag==="swB2"||f.flag==="swB3"){
if(flag("swB1")&&flag("swB2")&&flag("swB3")){ A.sfx.roar(); toast("Corrosion-shield collapsed","The Regent is open. Close and finish it!"); }
else floatTxt(f.px,f.py-18,"SHIELD FADES","#bfe9ff");
}
}
/* ============================ PLAYER COMBAT ============================ */
const WNAME=["Ember Blade","Ignited Edge","Solar Falchion"];
function meleeAttack(){
if(P.atkT>0||P.swing>0||G.dead||G.menu) return;
const tier=G.weapon;
const cd=[0.42,0.30,0.55][tier]*(G.worn==="band"?0.85:1);
P.atkT=cd; P.swingMax=[0.24,0.22,0.34][tier]; P.swing=P.swingMax; P.swingHit=false; P.swingTier=tier;
A.sfx.swing(tier);
if(tier>0) for(let i=0;i<6;i++) part({x:P.x+Math.cos(P.face)*10,y:P.y+Math.sin(P.face)*10,
vx:Math.cos(P.face+rnd(-.6,.6))*rnd(40,140),vy:Math.sin(P.face+rnd(-.6,.6))*rnd(40,140),
c:tier===2?"#ffd98a":"#ff8a3c",r:rnd(1,2.4),life:rnd(.2,.45),max:.45,g:120,drag:.93});
}
function doMeleeHit(){
const tier=P.swingTier, reach=[52,58,84][tier], arc=[2.0,2.2,2.6][tier], dmg=[2,3,5][tier];
for(const e of G.room.foes){ if(e.hp<=0) continue;
const d=dist(P,e); if(d>reach+e.r) continue;
let da=Math.atan2(e.y-P.y,e.x-P.x)-P.face; while(da>Math.PI)da-=Math.PI*2; while(da<-Math.PI)da+=Math.PI*2;
if(Math.abs(da)>arc/2) continue;
const k=tier===2?190:(tier===1?110:70);
damageEnemy(e,dmg,Math.cos(P.face)*k,Math.sin(P.face)*k,tier);
}
for(const o of G.room.objs){ if(o.dead) continue;
const rs=o.rects||[o.rect]; let hit=false;
for(const q of rs){ const cx=clamp(P.x,q.x,q.x+q.w), cy=clamp(P.y,q.y,q.y+q.h);
const d=Math.hypot(P.x-cx,P.y-cy); if(d>reach) continue;
let da=Math.atan2(cy-P.y,cx-P.x)-P.face; while(da>Math.PI)da-=Math.PI*2; while(da<-Math.PI)da+=Math.PI*2;
if(Math.abs(da)<arc/2+0.5){ hit=true; break; } }
if(hit) damageObj(o,dmg,false); }
G.shake=Math.max(G.shake,tier===2?7:3);
}
function damageObj(o,dmg,isRanged){
if(o.dead) return;
let d=dmg;
if(o.kind==="crack"||o.kind==="hidden") d=isRanged?1:[1,2,4][G.weapon];
o.hp-=d; o.hitT=.15;
if(o.kind==="crate"){ A.sfx.crack(); sparks(o.rect.x+o.rect.w/2,o.rect.y+o.rect.h/2,"#b08a4c"); }
else if(o.kind==="jar"){ A.sfx.crack(); }
else { A.sfx.crack(); sparks((o.rects[0].x+o.rects[o.rects.length-1].x)/2+20,(o.rects[0].y+o.rects[0].h/2),"#8a8a8a"); }
if(o.hp>0) return;
o.dead=true; flag(o.id,true);
const cx=o.rect?o.rect.x+o.rect.w/2:(o.rects[0].x+o.rects[0].w/2), cy=o.rect?o.rect.y+o.rect.h/2:(o.rects[0].y+o.rects[0].h/2);
if(o.kind==="crate"){ A.sfx.wallDown(); burst(cx,cy,"#8a6a3a",14,180,{life:.6,glow:false,g:200});
const g=ri(3,8); for(let i=0;i<2;i++) G.drops.push({x:cx+rnd(-8,8),y:cy+rnd(-8,8),v:Math.round(g/2),t:0,seed:rnd(0,9)}); }
else if(o.kind==="jar"){ A.sfx.wallDown(); burst(cx,cy,"#6b5636",12,180,{life:.5,glow:false,g:220});
const g=ri(5,15); for(let i=0;i<3;i++) G.drops.push({x:cx+rnd(-8,8),y:cy+rnd(-8,8),v:Math.round(g/3),t:0,seed:rnd(0,9)}); }
else { A.sfx.wallDown(); G.shake=14; burst(cx,cy,"#3a3f42",26,260,{life:.8,glow:false,g:260});
burst(cx,cy,"#ffd98a",14,220,{life:.7});
toast("The stone gives way","A hollow breathes behind the wall."); }
}
function rangedAttack(){
if(G.dead||G.menu) return;
if(!G.hasShard) return;
if(P.atkT>0) return;
if(G.ammo<=0){ A.sfx.deny(); floatTxt(P.x,P.y-24,"NO CHARGES","#9fb6c9"); return; }
G.ammo--; P.atkT=0.3; A.sfx.shoot();
spawnPrj({x:P.x+Math.cos(P.face)*20,y:P.y+Math.sin(P.face)*20,vx:Math.cos(P.face)*540,vy:Math.sin(P.face)*540,
from:"pl",kind:"bolt",dmg:2,r:6,life:1.6,c:"#bfe9ff"});
motes(P.x+Math.cos(P.face)*16,P.y+Math.sin(P.face)*16,"#bfe9ff",4);
updateHUD();
}
/* ============================ ENEMY AI ============================ */
function updateFoes(dt){
const R=G.room, cleared=!R.foes.some(f=>f.hp>0);
for(const e of R.foes){ if(e.hp<=0) continue;
e.t+=dt; e.anim+=dt; e.hitT=Math.max(0,e.hitT-dt); e.strike=Math.max(0,(e.strike||0)-dt);
if(e.burnT>0){ e.burnT-=dt; e.burnDmg=(e.burnDmg||0)+dt;
if(Math.random()<0.35) part({x:e.x+rnd(-8,8),y:e.y+rnd(-8,8),vx:rnd(-16,16),vy:rnd(-40,-12),
c:Math.random()<.5?"#ff8a3c":"#ffb060",r:rnd(1,2.2),life:.4,max:.4,g:-10,drag:.97});
if(e.burnDmg>0.9){ e.burnDmg-=0.9; damageEnemy(e,1,undefined,undefined,1); if(e.hp<=0) continue; } }
if(e.vx||e.vy){ moveEnt(e,e.vx*dt,e.vy*dt); e.vx*=Math.pow(0.86,dt*60); e.vy*=Math.pow(0.86,dt*60);
if(Math.abs(e.vx)<2)e.vx=0; if(Math.abs(e.vy)<2)e.vy=0; }
if(e.frozen>0){ e.frozen-=dt; continue; }
const d=dist(e,P), ang=Math.atan2(P.y-e.y,P.x-e.x);
e.face=ang;
if(G.dead) continue;
if(e.wind>0){ e.wind-=dt;
if(e.wind<=0){ e.strike=.22;
if(e.type==="curator"){
spawnPrj({x:e.x,y:e.y,from:"en",kind:"ring",r:10,grow:320,max:130,dmg:2,life:.7,c:"#ff8a3c",hit:false});
A.sfx.roar(); G.shake=8; burst(e.x,e.y,"#8a6a3a",18,220,{life:.5,glow:false});
} else if(e.type==="guardian"){
e.charging=.55; e.cvx=Math.cos(ang)*430; e.cvy=Math.sin(ang)*430;
A.sfx.roar(); burst(e.x,e.y,e.st.eye,12,180,{life:.4});
} else {
if(dist(e,P)<e.r+P.r+18) damagePlayer(e.dmg,e.x,e.y);
e.cd=1.2;
} }
continue; }
if(e.cd>0) e.cd-=dt;
if(e.type==="husk"){
if(d<330){ moveEnt(e,Math.cos(ang)*e.spd*dt,Math.sin(ang)*e.spd*dt); }
else { moveEnt(e,Math.cos(e.t)*e.spd*0.3*dt,Math.sin(e.t*1.3)*e.spd*0.3*dt); }
if(d<e.r+P.r+8&&e.cd<=0) e.wind=.3;
} else if(e.type==="chime"){
const wob=Math.sin(e.t*3)*0.9;
const sp=e.spd*(e.dashT>0?2.4:1);
if(e.dashT>0) e.dashT-=dt;
if(d<300){ moveEnt(e,Math.cos(ang+wob)*sp*dt,Math.sin(ang+wob)*sp*dt);
if(d<130&&e.cd<=0){ e.dashT=.5; e.cd=1.6; A.sfx.enemyShoot(); } }
else moveEnt(e,Math.cos(e.t*1.6)*e.spd*0.4*dt,Math.sin(e.t*2)*e.spd*0.4*dt);
if(d<e.r+P.r+4&&e.cd<=0){ e.wind=.16; }
} else if(e.type==="sentry"){
const want=190;
if(d<want-40) moveEnt(e,-Math.cos(ang)*e.spd*dt,-Math.sin(ang)*e.spd*dt);
else if(d>want+60) moveEnt(e,Math.cos(ang)*e.spd*dt,Math.sin(ang)*e.spd*dt);
if(d<420&&e.cd<=0){ e.cd=2.2; e.strike=.2;
A.sfx.enemyShoot();
spawnPrj({x:e.x+Math.cos(ang)*16,y:e.y+Math.sin(ang)*16,vx:Math.cos(ang)*230,vy:Math.sin(ang)*230,
from:"en",kind:"sbolt",dmg:1,r:6,life:2.4,c:"#9fe8ff"}); }
} else if(e.type==="curator"){
if(d<420) moveEnt(e,Math.cos(ang)*e.spd*dt,Math.sin(ang)*e.spd*dt);
if(e.cd<=0&&d<260){ e.cd=3.4; e.wind=.55; }
} else if(e.type==="guardian"){
if(e.charging>0){ e.charging-=dt; moveEnt(e,e.cvx*dt,e.cvy*dt);
if(d<e.r+P.r+8&&e.cd<=0){ damagePlayer(2,e.x,e.y); e.cd=1.4; }
continue; }
if(d<420) moveEnt(e,Math.cos(ang)*e.spd*dt,Math.sin(ang)*e.spd*dt);
if(e.cd<=0&&d<340){ e.cd=3.6; e.wind=.4; }
} else if(e.type==="regent"){
regentAI(e,dt,d,ang);
continue;
}
}
}
function regentAI(e,dt,d,ang){
const shield=bossShieldUp();
if(shield) e.shieldGlow=1; else e.shieldGlow=0;
if(e.hp<=35&&e.phase===0){ e.phase=1; flag("swB1",false); flag("swB2",false);
A.sfx.roar(); G.shake=16; burst(e.x,e.y,"#c8632a",30,320,{life:.9});
toast("The Regent re-knits its shield","Corrosion crawls back over the armour — two crystal switches wake again. Bring the Shard to bear."); }
e.cd-=dt;
if(d<460&&e.state!=="charge"){ moveEnt(e,Math.cos(ang)*e.spd*dt,Math.sin(ang)*e.spd*dt); }
if(e.state==="charge"){ e.ct-=dt; moveEnt(e,e.cvx*dt,e.cvy*dt);
if(d<e.r+P.r+6&&e.ccd<=0){ damagePlayer(2,e.x,e.y); e.ccd=1; } e.ccd-=dt;
if(e.ct<=0) e.state="idle"; return; }
if(e.wind>0){ e.wind-=dt;
if(e.wind<=0){
if(e.atk==="ring"){
const n=e.phase>=2?12:8;
for(let i=0;i<n;i++){ const a=(Math.PI*2/n)*i+e.t*0.4;
spawnPrj({x:e.x,y:e.y,vx:Math.cos(a)*200,vy:Math.sin(a)*200,from:"en",kind:"sbolt",dmg:1,r:7,life:2.6,c:"#c8632a"}); }
A.sfx.enemyShoot(); A.sfx.roar();
} else if(e.atk==="charge"){ e.state="charge"; e.ct=.6; e.ccd=0;
e.cvx=Math.cos(ang)*460; e.cvy=Math.sin(ang)*460; A.sfx.roar();
} else if(e.atk==="slam"){
spawnPrj({x:e.x,y:e.y,from:"en",kind:"ring",r:12,grow:340,max:180,dmg:2,life:.8,c:"#ffd98a",hit:false});
G.shake=12; A.sfx.roar();
}
}
return; }
if(e.cd<=0){
const rate=e.phase>=2?1.7:2.5;
e.cd=rate;
const roll=Math.random();
e.atk=roll<0.45?"ring":(roll<0.8?"charge":"slam");
e.wind=e.atk==="slam"?.5:.35;
if(e.summonT===undefined) e.summonT=0;
e.summonT+=rate;
if(e.summonT>8){ e.summonT=0;
const alive=G.room.foes.filter(f=>f.hp>0&&f!==e).length;
if(alive<2){ const s=spawnEnemy("husk",e.x+rnd(-70,70),e.y+rnd(-70,70),null); G.room.foes.push(s);
burst(s.x,s.y,"#ff8a3c",14,200,{life:.5}); A.sfx.roar(); } }
}
}
function bossShieldUp(){ return G.roomId==="z6b" && !flag("bossDead") && !(flag("swB1")&&flag("swB2")&&flag("swB3")); }
function shieldPing(e){ A.sfx.block(); burst(e.x,e.y,"#c8632a",8,140,{life:.3});
floatTxt(e.x,e.y-e.r-8,"SHIELDED","#ff9a5a"); }
function bossDefeated(){
flag("bossDead",true); A.sfx.roar(); A.sfx.beacon(); G.shake=20;
burst(P.x,P.y,"#ffd98a",40,380,{life:1.2});
const e=G.room.foes.find(f=>f.type==="regent");
if(e){ burst(e.x,e.y,"#ffd98a",50,420,{life:1.4}); burst(e.x,e.y,"#2a2622",30,260,{life:1,glow:false,g:200}); }
toast("The Corroded Regent unmade","Black-iron flakes away from the Core Beacon. Go to it — press E — and end the vigil.");
shimmer(2);
}
/* ============================ PUZZLES & WORLD ============================ */
const RMAP={o0:{E:"N",N:"E",S:"W",W:"S"}, o1:{E:"S",S:"E",N:"W",W:"N"}};
function blockedTile(x,y){
const px=x*T+T/2, py=y*T+T/2;
const B=G.room.solid.concat(dynBlockers(G.room));
for(const q of B){ if(px>q.x&&px<q.x+q.w&&py>q.y&&py<q.y+q.h) return true; }
for(const o of G.room.objs){ if(o.dead) continue;
const rs=o.rects||[o.rect];
for(const q of rs){ if(px>q.x&&px<q.x+q.w&&py>q.y&&py<q.y+q.h) return true; } }
return false;
}
function lightBeam(){
const R=G.room, em=R.feats.find(f=>f.t==="emitter"); if(!em) return null;
const dirs={E:[1,0],W:[-1,0],N:[0,-1],S:[0,1]};
let x=em.x, y=em.y, dir=em.dir, ok=false;
const pts=[[x,y]];
const mirrors=R.feats.filter(f=>f.t==="mirror"), recv=R.feats.find(f=>f.t==="receiver");
for(let s=0;s<80;s++){
const d=dirs[dir]; x+=d[0]; y+=d[1];
if(x<0||y<0||x>=COLS||y>=ROWS) break;
pts.push([x,y]);
const m=mirrors.find(mm=>mm.x===x&&mm.y===y);
if(m){ dir=RMAP["o"+m.o][dir]; continue; }
if(recv&&recv.x===x&&recv.y===y){ ok=true; break; }
if(blockedTile(x,y)) break;
}
return {pts,ok};
}
function updatePlates(dt){
const R=G.room; if(!R.plates.length) return;
for(const p of R.plates){
const pr={x:p.rect.x-2,y:p.rect.y-2,w:p.rect.w+4,h:p.rect.h+4};
let on=hitRect(P.x,P.y,P.r,pr)!==null;
if(!on) for(const e of R.foes){ if(e.hp>0&&hitRect(e.x,e.y,e.r,pr)){ on=true; break; } }
p.was=p.on; p.on=on;
if(on&&!p.was){ A.sfx.plate(); burst(p.px||p.rect.x+T/2,p.rect.y+T*0.7,"#b08a4c",8,120,{life:.35,glow:false}); }
}
const sq=R.d.seq; if(!sq||flag(sq.gate)) return;
const both=sq.plates.every(id=>R.plates.find(p=>p.id===id).on);
if(both){ openGate(sq.gate,"Both plates held — the grate rises."); return; }
for(const p of R.plates){
if(p.on&&!p.was){
if(G.seqT>0&&G.seqLast&&G.seqLast!==p.id){ openGate(sq.gate,"Pressed in one breath — the grate rises."); return; }
G.seqLast=p.id; G.seqT=sq.window;
}
}
G.seqT=Math.max(0,G.seqT-dt);
}
function openGate(f,msg){ if(flag(f)) return; flag(f,true); A.sfx.gate(); G.shake=8; toast("Mechanism answered",msg); }
function updateVents(dt){
const d=G.room.d; if(!d.vents) return;
for(const v of d.vents){ const r=rr(v[0],v[1],v[2],v[3]);
v._act=((G.t+v[4])%2.4)<1.0;
if(v._act){
if(Math.random()<0.5) part({x:rnd(r.x,r.x+r.w),y:r.y+r.h*0.5,vx:rnd(-14,14),vy:rnd(-70,-30),
c:"#c8d2d6",r:rnd(2,5),life:rnd(.4,.8),max:.8,g:-30,glow:false,drag:.97});
if(hitRect(P.x,P.y,P.r-3,[r])) damagePlayer(2,r.x+r.w/2,r.y,true);
}
}
}
function updateLight(){
const R=G.room; if(!R.feats.some(f=>f.t==="emitter")) { G.beam=null; return; }
G.beam=lightBeam();
if(G.beam&&G.beam.ok&&!flag("lightOn")){
flag("lightOn",true); flag("g4",true);
A.sfx.save(); A.sfx.gate(); shimmer(1.6);
burst(tx(8),tx(6),"#bfe9ff",30,260,{life:.9});
toast("Light-path restored","The beam finds its receiver. The Phantom Lantern lifts from its plinth — and the eastern grate rises.");
}
}
/* ============================ INTERACTION ============================ */
function featAvailable(f){
if(f.t==="chest"&&f.lantern&&G.worn!=="lantern") return false;
return true;
}
function nearestFeat(){
let best=null,bd=60;
for(const f of G.room.feats){
if(!featAvailable(f)) continue;
if(f.t==="emitter") continue;
const d=Math.hypot(P.x-f.px,P.y-(f.py+12));
if(d<bd){ bd=d; best=f; }
}
return best;
}
function promptFor(f){
if(!f) return "";
const used=(f.id&&(flag(f.id)&&f.t!=="switch"))||(f.t==="altar"&&flag(f.id));
switch(f.t){
case "stone": return flag("s"+f.id)?"Rest at the Sanctuary Stone":"Touch the Sanctuary Stone";
case "shop": return "Open the Merchant Altar";
case "altar": return used?"The altar is cold":"Lay the blade upon the altar";
case "chest": return used?"Empty coffer":"Open the coffer";
case "pedestal": return used?"Empty plinth":"Take the offering";
case "cistern": return flag("cisternUsed")?"The cistern is dry":"Drink from the Cistern";
case "mirror": return "Turn the mirror";
case "sign": return "Read the inscription";
case "beacon": return flag("bossDead")?"Rekindle the Core Beacon":"The Beacon is bound";
}
return "";
}
function interact(){
const f=nearestFeat(); if(!f) return;
const used=f.id?flag(f.id):false;
switch(f.t){
case "stone": {
if(f.needClear&&G.room.foes.some(e=>e.hp>0)){ toast("Sanctuary Stone","A corrosive hymn binds the hall. Silence the constructs first."); A.sfx.deny(); return; }
G.hp=maxHp(); flag("cisternUsed",false);
G.stoneRoom=G.roomId; G.stonePos={x:P.x,y:P.y};
flag("stone_"+f.id,true);
A.sfx.save(); shimmer(1.4); motes(f.px,f.py,"#ffd98a",22); auraPulse();
saveGame();
toast("Progress sealed","Lifeblood restored, the vigil written into the stone. You will wake here if the Corrosion takes you.");
break; }
case "shop": {
if(f.needClear&&G.room.foes.some(e=>e.hp>0)){ toast("Merchant Altar","Bronze will not bargain over noise. Silence the constructs."); A.sfx.deny(); return; }
openShop();
break; }
case "altar": {
if(used){ toast("Spent altar","Cold coals. It gave what it held."); return; }
flag(f.id,true);
give({weapon:f.up},f.px,f.py);
toast(f.up===1?"IGNITED EDGE":"SOLAR FALCHION",
f.up===1?"The Ember Blade drinks the altar-fire. Faster strokes, ember-trail on every swing, and stone cracks twice as easily."
:"The blade widens into solar gold. A slow wind-up, a far heavier fall, a wider arc — and hairline walls shatter at a touch.");
break; }
case "chest": {
if(used){ toast("Empty coffer","Only dust and older dust."); return; }
flag(f.id,true); A.sfx.buy(); A.sfx.pickup("rare");
burst(f.px,f.py,"#ffd98a",18,200,{life:.7});
give(f.loot,f.px,f.py);
break; }
case "pedestal": {
if(f.needFlag&&!flag(f.needFlag)){ toast("Sealed plinth","The mechanism above it is unlit."); A.sfx.deny(); return; }
if(used){ toast("Empty plinth","It remembers what it gave."); return; }
flag(f.id,true); A.sfx.pickup("rare");
burst(f.px,f.py,"#ffffff",20,220,{life:.8});
if(f.loot.heart&&flag("shopHeart")){ give({gold:150},f.px,f.py);
toast("The vault yields sigils","You already carry the merchant’s fifth container — so the plinth pays 150 Golden Sigils instead."); }
else give(f.loot,f.px,f.py);
break; }
case "cistern": {
if(flag("cisternUsed")){ toast("Cistern of Quiet Water","Dry as bone. It will fill again when you rest at a Sanctuary Stone."); return; }
flag("cisternUsed",true); G.hp=maxHp(); A.sfx.potion(); healFlash(); motes(f.px,f.py,"#b8ecbf",18);
toast("Cistern of Quiet Water","Still water knits flesh. Lifeblood restored to full.");
break; }
case "mirror": {
f.o=1-f.o; A.sfx.block(); burst(f.px,f.py,"#bfe9ff",10,140,{life:.4});
break; }
case "sign": { A.sfx.pickup("common"); toast("Inscription",f.text); break; }
case "beacon": {
if(!flag("bossDead")){ toast("The Core Beacon","Black-iron coils bind it. The Corroded Regent holds the last knot."); A.sfx.deny(); return; }
endGame();
break; }
}
updateHUD();
}
/* ============================ ROOM FLOW ============================ */
function goRoom(id,from){
G.roomId=id; G.room=buildRoom(id);
G.prj.length=0; G.seqT=0; G.seqLast=null;
const gp=G.room.gap;
if(from==="E"){ P.x=3*T; P.y=(gp.W[0]+gp.W[1]+1)/2*T; }
else if(from==="W"){ P.x=VW-3*T; P.y=(gp.E[0]+gp.E[1]+1)/2*T; }
else if(from==="N"){ P.y=3*T; P.x=(gp.S[0]+gp.S[1]+1)/2*T; }
else if(from==="S"){ P.y=VH-3*T; P.x=(gp.N[0]+gp.N[1]+1)/2*T; }
else { P.x=tx(3); P.y=tx(8); }
P.iframes=Math.max(P.iframes,.5);
A.amb(G.room.d.amb);
const z=ZONE_TITLE[G.room.d.zone];
showZone(z[0],z[1],G.room.d.sub);
updateHUD();
if(id==="z6b"&&!flag("bossDead")){ A.sfx.roar();
toast("The Corroded Regent","Its shield is corrosion-made. Three crystal switches ring the pit — the Aether Shard reaches all of them."); }
}
function checkTransitions(){
const d=G.room.d, gp=G.room.gap;
const inGap=(lo,hi,v)=>v>lo*T-14&&v<(hi+1)*T+14;
if(P.x>930&&d.ex.E&&inGap(gp.E[0],gp.E[1],P.y)) goRoom(d.ex.E,"E");
else if(P.x<30&&d.ex.W&&inGap(gp.W[0],gp.W[1],P.y)) goRoom(d.ex.W,"W");
else if(P.y<30&&d.ex.N&&inGap(gp.N[0],gp.N[1],P.x)) goRoom(d.ex.N,"N");
else if(P.y>570&&d.ex.S&&inGap(gp.S[0],gp.S[1],P.x)) goRoom(d.ex.S,"S");
}
/* ============================ PLAYER UPDATE ============================ */
const KEY={};
const M={x:480,y:300,moved:false,down:false,rdown:false};
function updatePlayer(dt){
if(G.dead){ G.deadT+=dt; return; }
P.iframes=Math.max(0,P.iframes-dt); P.hurtT=Math.max(0,P.hurtT-dt);
P.atkT=Math.max(0,P.atkT-dt); P.dashCd=Math.max(0,P.dashCd-dt);
let ax=(KEY.d||KEY.arrowright?1:0)-(KEY.a||KEY.arrowleft?1:0);
let ay=(KEY.s||KEY.arrowdown?1:0)-(KEY.w||KEY.arrowup?1:0);
const L=Math.hypot(ax,ay)||1; ax/=L; ay/=L;
P.moving=(ax!==0||ay!==0);
if(P.dashT>0){ P.dashT-=dt; moveEnt(P,Math.cos(P.dashDir)*520*dt,Math.sin(P.dashDir)*520*dt);
part({x:P.x,y:P.y,vx:rnd(-20,20),vy:rnd(-20,20),c:"#8fb6d8",r:rnd(1,2.5),life:.3,max:.3,g:0,drag:.9}); }
else {
const sp=182;
if(P.moving) moveEnt(P,ax*sp*dt,ay*sp*dt);
}
if(P.moving){
P.walk+=dt*9;
P.stepT+=dt*182;
if(P.stepT>30){ P.stepT=0; A.sfx.step(G.room.d.amb);
part({x:P.x+rnd(-4,4),y:P.y+14,vx:0,vy:-6,c:"#6d7d8b",r:1.6,life:.35,max:.35,g:-6,glow:false}); }
if(G.weapon>0&&Math.random()<dt*22) part({x:P.x+rnd(-6,6),y:P.y+rnd(0,14),vx:rnd(-8,8),vy:rnd(-24,-8),
c:G.weapon===2?"#ffd98a":"#ff8a3c",r:rnd(1,2),life:rnd(.3,.6),max:.6,g:-6,drag:.97});
}
if(M.moved) P.face=Math.atan2(M.y-P.y,M.x-P.x);
else if(P.moving) P.face=Math.atan2(ay,ax);
if(M.down) meleeAttack();
if(P.swing>0){ P.swing-=dt;
if(!P.swingHit&&P.swing<P.swingMax-0.09){ P.swingHit=true; doMeleeHit(); } }
// shard regrowth
if(G.hasShard){ G.ammoT+=dt;
if(G.ammoT>5){ G.ammoT=0; if(G.ammo<8){ G.ammo++; A.sfx.pulse(); motes(P.x+12,P.y-6,"#bfe9ff",3); updateHUD(); } } }
checkTransitions();
}
function dash(){
if(P.dashCd>0||G.dead||G.menu) return;
let dx=(KEY.d||KEY.arrowright?1:0)-(KEY.a||KEY.arrowleft?1:0);
let dy=(KEY.s||KEY.arrowdown?1:0)-(KEY.w||KEY.arrowup?1:0);
if(!dx&&!dy){ dx=Math.cos(P.face); dy=Math.sin(P.face); }
P.dashDir=Math.atan2(dy,dx); P.dashT=.16; P.dashCd=.9; P.iframes=Math.max(P.iframes,.3);
A.sfx.step("spire"); burst(P.x,P.y,"#8fb6d8",8,120,{life:.3,g:0});
}
/* ============================ SAVE / LOAD ============================ */
const SAVE_KEY="warden_corrosion_save_v1";
function saveGame(){
const data={gold:G.gold,items:G.items,acc:G.acc,worn:G.worn,weapon:G.weapon,hasShard:G.hasShard,ammo:G.ammo,
containers:G.containers,hp:G.hp,flags:G.flags,kills:G.kills,earned:G.earned,
stoneRoom:G.stoneRoom,stonePos:G.stonePos,roomId:G.roomId,pos:{x:P.x,y:P.y},runT:G.runT};
try{ localStorage.setItem(SAVE_KEY,JSON.stringify(data)); return true; }catch(e){ G.memSave=data; return false; }
}
function loadSave(){
try{ const s=localStorage.getItem(SAVE_KEY); return s?JSON.parse(s):G.memSave||null; }catch(e){ return G.memSave||null; }
}
function hasSave(){ return !!loadSave(); }
function applySave(d){
G.gold=d.gold; G.items=d.items; G.acc=d.acc; G.worn=d.worn; G.weapon=d.weapon;
G.hasShard=d.hasShard; G.ammo=d.ammo; G.containers=d.containers; G.hp=d.hp;
G.flags=d.flags; G.kills=d.kills; G.earned=d.earned; G.runT=d.runT||0;
G.stoneRoom=d.stoneRoom; G.stonePos=d.stonePos;
G.dead=false; G.ending=false;
goRoom(d.roomId||d.stoneRoom,null);
P.x=d.pos?d.pos.x:G.stonePos.x; P.y=d.pos?d.pos.y:G.stonePos.y;
}
function newGame(){
G.gold=0; G.items={stem:1,seal:0}; G.acc={band:0,lantern:0,cloak:0}; G.worn=null;
G.weapon=0; G.hasShard=false; G.ammo=0; G.containers=3; G.hp=6; G.flags={}; G.kills=0; G.earned=0; G.runT=0;
G.stoneRoom="z1a"; G.stonePos={x:tx(3),y:tx(7)+38};
G.dead=false; G.ending=false;
goRoom("z1a",null);
P.x=tx(7); P.y=tx(8);
toast("The Ashen Threshold","You are the Last Warden. Touch the Sanctuary Stone to seal your vigil, then take the eastern walk.");
}
function respawn(){
G.dead=false; G.hp=maxHp();
goRoom(G.stoneRoom,null);
P.x=G.stonePos.x; P.y=G.stonePos.y;
hideDeath();
toast("The stone takes you back","Lifeblood restored. What you carry, you keep.");
}
/* ============================ MAIN UPDATE ============================ */
function update(dt){
G.t+=dt; G.runT+=dt;
if(G.state!=="play"||G.menu) { updateParticles(dt); return; }
if(G.hitstop>0){ G.hitstop-=dt; return; }
updatePlayer(dt);
updateFoes(dt);
updatePrj(dt);
updatePlates(dt);
updateVents(dt);
updateLight();
updateParticles(dt);
G.shake=Math.max(0,G.shake-dt*26);
// shop drone ambience
const shop=G.room.feats.find(f=>f.t==="shop");
A.shopDrone(!!shop&&dist({x:shop.px,y:shop.py},P)<170&&(!shop.needClear||!G.room.foes.some(e=>e.hp>0)));
// prompt
const f=nearestFeat();
setPrompt(f?promptFor(f):"");
}