Warden
Qwen 3.8 max 0902 · 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 112,437 bytes · SHA-256 131b11d7e28a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Warden's Corrosion — Aurelion's Sunken Observatory</title>
<style>
:root{
--gold:#f0b45a; --gold2:#ffd98a; --orange:#e2703a; --slate:#1a2236;
--panel:rgba(13,17,30,.94); --line:#5a4a2e; --ink:#e8dcc3; --dim:#9a917c;
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{
background:#05070d; color:var(--ink);
font-family:Georgia,'Times New Roman',serif;
overflow:hidden; user-select:none; -webkit-user-select:none;
}
#wrap{position:fixed; inset:0; display:flex; align-items:center; justify-content:center;}
#stage{position:relative; width:min(100vw, calc(100vh * 960 / 560)); aspect-ratio:960/560; max-height:100vh;}
canvas#cv{position:absolute; inset:0; width:100%; height:100%; background:#0a0d16;
box-shadow:0 0 80px rgba(0,0,0,.9), 0 0 0 1px #2a2416;}
/* ---------- HUD ---------- */
#hud{position:absolute; top:10px; left:12px; z-index:5; pointer-events:none; text-shadow:0 1px 3px #000;}
#hearts{display:flex; gap:3px; margin-bottom:5px;}
.heart{position:relative; width:26px; height:26px; filter:drop-shadow(0 1px 2px rgba(0,0,0,.8));}
.heart svg{position:absolute; inset:0; width:100%; height:100%;}
.heart .bg path{fill:#241c2b; stroke:#57465f; stroke-width:1.4;}
.heart .fg path{fill:#e0455f; stroke:#f0b45a; stroke-width:1.2;}
#hudRow2{display:flex; gap:14px; align-items:center; font-size:15px;}
.hudBox{display:flex; align-items:center; gap:5px; background:rgba(10,13,22,.55);
border:1px solid rgba(240,180,90,.25); border-radius:6px; padding:2px 8px;}
#goldN{color:var(--gold2); font-weight:bold; min-width:26px;}
#ammoN{color:#8fe3ff; font-weight:bold;}
#weaponBox{color:var(--ink); font-style:italic; font-size:13px;}
#accBox{display:flex; gap:6px; margin-top:5px;}
.accIcon{width:22px; height:22px; border-radius:50%; border:1px solid rgba(240,180,90,.5);
display:flex; align-items:center; justify-content:center; font-size:12px; background:rgba(10,13,22,.6);}
#zoneTag{position:absolute; top:10px; right:12px; z-index:5; text-align:right; pointer-events:none;
font-size:12px; color:var(--dim); letter-spacing:1px; text-shadow:0 1px 3px #000;}
#muteBtn{pointer-events:auto; margin-top:4px; cursor:pointer; background:rgba(10,13,22,.6);
border:1px solid rgba(240,180,90,.3); color:var(--dim); border-radius:5px; font-size:11px; padding:2px 8px; font-family:inherit;}
#muteBtn:hover{color:var(--gold2);}
/* ---------- messages ---------- */
#banner{position:absolute; top:16%; left:0; right:0; text-align:center; z-index:6; pointer-events:none; opacity:0;}
#banner.show{animation:bannerAnim 3.4s ease forwards;}
#bannerTitle{font-size:34px; letter-spacing:6px; color:var(--gold2); text-shadow:0 0 18px rgba(240,180,90,.55),0 2px 4px #000;}
#bannerSub{font-size:14px; letter-spacing:3px; color:var(--dim); margin-top:6px; font-style:italic;}
@keyframes bannerAnim{0%{opacity:0; transform:translateY(14px);} 12%{opacity:1; transform:translateY(0);} 78%{opacity:1;} 100%{opacity:0;}}
#toast{position:absolute; bottom:52px; left:0; right:0; text-align:center; z-index:6; pointer-events:none;}
.toastMsg{display:inline-block; margin:2px auto; padding:5px 16px; font-size:14px; color:var(--ink);
background:rgba(10,13,22,.78); border:1px solid rgba(240,180,90,.3); border-radius:4px;
text-shadow:0 1px 2px #000; animation:toastIn .3s ease; max-width:80%;}
.toastMsg.gold{color:var(--gold2); border-color:rgba(240,180,90,.6);}
@keyframes toastIn{from{opacity:0; transform:translateY(8px);} to{opacity:1;}}
#prompt{position:absolute; bottom:14px; left:0; right:0; text-align:center; z-index:6; pointer-events:none;
font-size:15px; color:var(--gold2); text-shadow:0 0 8px rgba(240,180,90,.6),0 1px 3px #000; opacity:0; transition:opacity .15s;}
#prompt.show{opacity:1;}
#prompt b{background:rgba(240,180,90,.15); border:1px solid rgba(240,180,90,.5); border-radius:4px; padding:0 6px; margin-right:6px;}
#hint{position:absolute; bottom:8px; right:12px; z-index:5; pointer-events:none; font-size:11px; color:rgba(154,145,124,.75); text-shadow:0 1px 2px #000; text-align:right; line-height:1.5;}
/* ---------- screen effects ---------- */
#fx{position:absolute; inset:0; z-index:4; pointer-events:none;}
#dmgFlash{position:absolute; inset:0; opacity:0; background:radial-gradient(ellipse at center, transparent 40%, rgba(190,30,50,.55) 100%); transition:opacity .35s;}
#dmgFlash.on{opacity:1; transition:opacity .05s;}
#shimmer{position:absolute; inset:0; opacity:0; background:radial-gradient(ellipse at center, transparent 55%, rgba(255,215,120,.5) 100%);}
#shimmer.on{animation:shimAnim 2s ease forwards;}
@keyframes shimAnim{0%{opacity:0;} 12%{opacity:1;} 100%{opacity:0;}}
#fade{position:absolute; inset:0; background:#04060b; opacity:0; pointer-events:none; z-index:8; transition:opacity .3s;}
#fade.on{opacity:1;}
/* ---------- overlays / modals ---------- */
.overlay{position:absolute; inset:0; z-index:10; display:flex; align-items:center; justify-content:center;
background:radial-gradient(ellipse at center, rgba(8,11,20,.82), rgba(3,5,10,.96));}
.overlay.hidden{display:none;}
.panel{background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:26px 34px;
box-shadow:0 0 0 1px rgba(240,180,90,.15), 0 18px 60px rgba(0,0,0,.8), inset 0 0 60px rgba(240,180,90,.04);
max-width:640px; max-height:92%; overflow-y:auto;}
.panel h1{font-size:40px; letter-spacing:5px; text-align:center; color:var(--gold2); font-weight:normal;
text-shadow:0 0 24px rgba(240,180,90,.4); margin-bottom:4px;}
.panel h2{font-size:22px; letter-spacing:3px; text-align:center; color:var(--gold); font-weight:normal; margin-bottom:14px;}
.panel .sub{text-align:center; color:var(--dim); font-style:italic; font-size:14px; margin-bottom:16px;}
.lore{font-size:14px; line-height:1.65; color:#c9bfa6; margin:10px 0 18px; text-align:center;}
.btn{display:block; width:100%; margin:8px 0; padding:10px 18px; font-family:inherit; font-size:16px; letter-spacing:2px;
color:#221a0c; background:linear-gradient(180deg,var(--gold2),var(--gold) 55%,#c98f3d); border:1px solid #8a6a2f;
border-radius:5px; cursor:pointer; text-shadow:0 1px 0 rgba(255,255,255,.3); box-shadow:0 3px 10px rgba(0,0,0,.5);}
.btn:hover{filter:brightness(1.12);}
.btn.ghost{background:transparent; color:var(--gold); border:1px solid var(--line); text-shadow:none;}
.btn.ghost:hover{background:rgba(240,180,90,.08);}
.btn:disabled{opacity:.35; cursor:default; filter:none;}
.ctrlGrid{display:grid; grid-template-columns:auto 1fr; gap:4px 14px; font-size:13px; color:var(--dim); margin:10px auto 4px; width:max-content;}
.ctrlGrid b{color:var(--gold2); font-weight:normal; background:rgba(240,180,90,.1); border:1px solid rgba(240,180,90,.3);
border-radius:3px; padding:0 6px; text-align:center; font-family:ui-monospace,monospace; font-size:12px;}
/* shop & inventory rows */
.row{display:flex; align-items:center; gap:12px; padding:9px 10px; margin:6px 0; border:1px solid rgba(90,74,46,.5);
border-radius:6px; background:rgba(20,25,42,.6);}
.row .ico{width:34px; height:34px; flex:0 0 34px; border-radius:6px; display:flex; align-items:center; justify-content:center; font-size:19px;
background:radial-gradient(circle at 35% 30%, #2c3552, #161c30); border:1px solid rgba(240,180,90,.25);}
.row .info{flex:1;}
.row .nm{font-size:15px; color:var(--gold2);}
.row .ds{font-size:12px; color:var(--dim); font-style:italic; margin-top:2px; line-height:1.4;}
.row .price{font-size:14px; color:var(--gold); white-space:nowrap;}
.buyBtn{padding:5px 14px; font-family:inherit; font-size:13px; color:#221a0c; cursor:pointer;
background:linear-gradient(180deg,var(--gold2),var(--gold)); border:1px solid #8a6a2f; border-radius:4px;}
.buyBtn:disabled{opacity:.35; cursor:default;}
.eqBtn{padding:5px 12px; font-family:inherit; font-size:12px; cursor:pointer; border-radius:4px;
background:transparent; color:var(--gold); border:1px solid var(--line);}
.eqBtn.on{background:rgba(240,180,90,.2); color:var(--gold2); border-color:var(--gold);}
.shopGold{text-align:right; font-size:15px; color:var(--gold2); margin-bottom:8px;}
.soldTag{font-size:12px; color:#7c8aa5;}
.closeNote{text-align:center; font-size:12px; color:var(--dim); margin-top:12px; font-style:italic;}
#invModal .panel, #shopModal .panel{width:560px;}
.statLine{font-size:14px; color:#c9bfa6; text-align:center; margin:4px 0;}
.statLine b{color:var(--gold2); font-weight:normal;}
::-webkit-scrollbar{width:8px;}
::-webkit-scrollbar-thumb{background:#3a3220; border-radius:4px;} ::-webkit-scrollbar-track{background:transparent;}
</style>
</head>
<body>
<div id="wrap"><div id="stage">
<canvas id="cv" width="960" height="560"></canvas>
<div id="fx"><div id="dmgFlash"></div><div id="shimmer"></div></div>
<div id="fade"></div>
<div id="hud">
<div id="hearts"></div>
<div id="hudRow2">
<span class="hudBox" title="Golden Sigils">
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="6" fill="#f0b45a" stroke="#8a6a2f"/><circle cx="7" cy="7" r="3.4" fill="none" stroke="#8a6a2f" stroke-width="1.2"/></svg>
<b id="goldN">0</b>
</span>
<span class="hudBox" id="ammoBox" style="display:none" title="Aether charges">◆ <b id="ammoN">0</b></span>
<span class="hudBox" id="weaponBox">Ember Blade</span>
</div>
<div id="accBox"></div>
</div>
<div id="zoneTag"><span id="zoneName"></span><br><button id="muteBtn">♪ sound on</button></div>
<div id="banner"><div id="bannerTitle"></div><div id="bannerSub"></div></div>
<div id="toast"></div>
<div id="prompt"></div>
<div id="hint">WASD / Arrows — move · J — attack · K — Aether Shard<br>E — interact · 1 — Vital Stem · 2 — Binding Seal · I — inventory</div>
<!-- TITLE -->
<div id="titleScreen" class="overlay">
<div class="panel" style="width:620px">
<h1>THE WARDEN'S CORROSION</h1>
<div class="sub">Aurelion's Sunken Observatory — a celestial archive above a storm-locked valley</div>
<div class="lore">
The gears have stopped. The telescopes lie shattered. A black-iron decay — <b style="color:#a8b4cc">the Corrosion</b> —
creeps through the bronze halls, and its Corrupted Constructs patrol the ruins of moss and gold.<br><br>
You are the <b style="color:var(--gold2)">Last Warden</b>. Reignite the Core Beacon before the observatory falls.
</div>
<button class="btn" id="btnNew">BEGIN THE VIGIL</button>
<button class="btn ghost hidden" id="btnContinue">CONTINUE — LAST SANCTUARY</button>
<div class="ctrlGrid">
<b>WASD/↑↓←→</b><span>Move</span>
<b>J / Space</b><span>Swing the Ember Blade</span>
<b>K</b><span>Fire the Aether Shard (once found)</span>
<b>E</b><span>Interact — stones, altars, chests, merchant</span>
<b>1 / 2</b><span>Use Vital Stem / Binding Seal</span>
<b>I</b><span>Inventory & accessories</span>
</div>
<div class="closeNote">Three Sanctuary Stones mark your path. Rest at them to heal and inscribe your progress.</div>
</div>
</div>
<!-- SHOP -->
<div id="shopModal" class="overlay hidden">
<div class="panel">
<h2>⚖ THE MERCHANT ALTAR</h2>
<div class="sub" id="shopQuote">"The Corrosion takes, Warden. I merely… redistribute."</div>
<div class="shopGold">Golden Sigils: <b id="shopGoldN">0</b></div>
<div id="shopRows"></div>
<button class="btn ghost" id="shopClose">Leave the altar (Esc)</button>
</div>
</div>
<!-- INVENTORY -->
<div id="invModal" class="overlay hidden">
<div class="panel">
<h2>✦ WARDEN'S SATCHEL</h2>
<div class="sub">Accessories toggle on and off — their tokens appear upon your person.</div>
<div id="invRows"></div>
<button class="btn ghost" id="invClose">Close (I / Esc)</button>
</div>
</div>
<!-- DEATH -->
<div id="deathScreen" class="overlay hidden">
<div class="panel" style="width:480px">
<h1 style="color:#c25060; font-size:32px">THE CORROSION CLAIMS YOU</h1>
<div class="lore">Your cloak settles among the rust. But the Sanctuary Stones remember the Last Warden…</div>
<button class="btn" id="btnRespawn">AWAKEN AT LAST SANCTUARY</button>
</div>
</div>
<!-- WIN -->
<div id="winScreen" class="overlay hidden">
<div class="panel" style="width:560px">
<h1 style="font-size:32px">THE BEACON BURNS AGAIN</h1>
<div class="sub">Aurelion's Observatory turns once more above the storm.</div>
<div class="lore" id="winLore"></div>
<div id="winStats"></div>
<button class="btn" id="btnWinNew">BEGIN A NEW VIGIL</button>
</div>
</div>
</div></div>
<script>
'use strict';
/* ================= UTILITIES ================= */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(ax,ay,bx,by)=>Math.hypot(bx-ax,by-ay);
const rnd=(a=1,b=0)=>b+Math.random()*(a-b);
const rndi=(a,b)=>Math.floor(rnd(b+1,a));
const pick=a=>a[Math.floor(Math.random()*a.length)];
function hash2(x,y){let h=(x*374761393+y*668265263)^0x5bf03635;h=(h^(h>>13))*1274126177;return((h^(h>>16))>>>0)/4294967295;}
const $=id=>document.getElementById(id);
const HEART_SVG='<svg viewBox="0 0 24 24"><path d="M12 21.2S3.8 16.3 1.9 11.6C0.5 8.2 2.6 4.8 6.1 4.8c2.2 0 4.3 1.4 5.9 3.6 1.6-2.2 3.7-3.6 5.9-3.6 3.5 0 5.6 3.4 4.2 6.8-1.9 4.7-10 9.4-10 9.4z"/></svg>';
/* ================= AUDIO ENGINE (fully synthesized) ================= */
const AU={
ctx:null,master:null,ready:false,muted:false,nb:null,droneNodes:null,amb:null,
init(){
if(this.ctx){ if(this.ctx.state==='suspended')this.ctx.resume(); return; }
const AC=window.AudioContext||window.webkitAudioContext; if(!AC)return;
try{ this.ctx=new AC(); }catch(e){ return; }
this.master=this.ctx.createGain(); this.master.gain.value=0.55; this.master.connect(this.ctx.destination);
const len=this.ctx.sampleRate; const b=this.ctx.createBuffer(1,len,this.ctx.sampleRate); const d=b.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1; this.nb=b;
// ambient drone pad
const g=this.ctx.createGain(); g.gain.value=0.0; g.connect(this.master);
const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=260; f.Q.value=0.7; f.connect(g);
const o1=this.ctx.createOscillator(); o1.type='sawtooth'; o1.frequency.value=55;
const o2=this.ctx.createOscillator(); o2.type='sine'; o2.frequency.value=82.4;
const g2=this.ctx.createGain(); g2.gain.value=0.35;
o1.connect(f); o2.connect(g2); g2.connect(f);
const lfo=this.ctx.createOscillator(); lfo.type='sine'; lfo.frequency.value=0.06;
const lg=this.ctx.createGain(); lg.gain.value=70; lfo.connect(lg); lg.connect(f.frequency);
o1.start();o2.start();lfo.start();
g.gain.linearRampToValueAtTime(0.05,this.ctx.currentTime+4);
this.amb={g,o1,o2,f}; this.ready=true;
},
setMood(hz){ if(this.amb)this.amb.o1.frequency.linearRampToValueAtTime(hz,this.ctx.currentTime+2); },
tone({f=440,f2,dur=0.2,type='sine',g=0.2,delay=0,atk=0.008}){
if(!this.ready||this.muted)return; const c=this.ctx,t=c.currentTime+delay;
const o=c.createOscillator(),gn=c.createGain();
o.type=type; o.frequency.setValueAtTime(Math.max(1,f),t);
if(f2)o.frequency.exponentialRampToValueAtTime(Math.max(1,f2),t+dur);
gn.gain.setValueAtTime(0.0001,t); gn.gain.linearRampToValueAtTime(g,t+atk);
gn.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(gn); gn.connect(this.master); o.start(t); o.stop(t+dur+0.06);
},
nz({dur=0.15,f=1000,f2,q=1,type='bandpass',g=0.2,delay=0}){
if(!this.ready||this.muted)return; const c=this.ctx,t=c.currentTime+delay;
const s=c.createBufferSource(); s.buffer=this.nb; s.loop=true;
const flt=c.createBiquadFilter(); flt.type=type; flt.frequency.setValueAtTime(Math.max(20,f),t); flt.Q.value=q;
if(f2)flt.frequency.exponentialRampToValueAtTime(Math.max(20,f2),t+dur);
const gn=c.createGain(); gn.gain.setValueAtTime(g,t); gn.gain.exponentialRampToValueAtTime(0.0001,t+dur);
s.connect(flt); flt.connect(gn); gn.connect(this.master); s.start(t); s.stop(t+dur+0.06);
},
seq(notes,type,g,dur,gap,delay0=0){ notes.forEach((n,i)=>this.tone({f:n,dur,type,g,delay:delay0+i*gap})); },
drone(on){
if(!this.ready)return;
if(on&&!this.droneNodes){
const c=this.ctx; const gn=c.createGain(); gn.gain.value=0.0001; gn.connect(this.master);
gn.gain.linearRampToValueAtTime(this.muted?0.0001:0.045,c.currentTime+0.6);
const oA=c.createOscillator(); oA.type='sine'; oA.frequency.value=110;
const oB=c.createOscillator(); oB.type='triangle'; oB.frequency.value=164.8;
const gb=c.createGain(); gb.gain.value=0.4;
oA.connect(gn); oB.connect(gb); gb.connect(gn); oA.start(); oB.start();
this.droneNodes={gn,oA,oB};
} else if(!on&&this.droneNodes){
const {gn,oA,oB}=this.droneNodes; this.droneNodes=null;
gn.gain.linearRampToValueAtTime(0.0001,this.ctx.currentTime+0.4);
setTimeout(()=>{try{oA.stop();oB.stop();}catch(e){}},600);
}
},
play(n){
if(!this.ready||this.muted)return;
switch(n){
case 'step_stone': this.nz({dur:0.06,f:280+Math.random()*220,q:1,g:0.05}); break;
case 'step_metal': this.nz({dur:0.08,f:1300+Math.random()*500,q:3,g:0.05}); this.tone({f:170+Math.random()*40,f2:110,dur:0.07,type:'triangle',g:0.05}); break;
case 'step_water': this.nz({dur:0.12,f:900,f2:260,q:1.5,type:'lowpass',g:0.07}); break;
case 'swing0': this.nz({dur:0.13,f:2400,f2:600,q:1.1,g:0.13}); break;
case 'swing1': this.nz({dur:0.13,f:2600,f2:700,q:1.1,g:0.13}); this.nz({dur:0.2,f:4200,q:0.6,g:0.05,type:'highpass'}); break;
case 'swing2':
this.tone({f:95,f2:42,dur:0.4,type:'sine',g:0.32});
this.nz({dur:0.22,f:1500,f2:380,q:0.9,g:0.17});
this.tone({f:311,f2:295,dur:0.28,type:'square',g:0.045});
this.tone({f:466,f2:445,dur:0.28,type:'square',g:0.035});
break;
case 'hit': this.nz({dur:0.07,f:950,q:1,g:0.15}); this.tone({f:150,f2:70,dur:0.09,type:'sine',g:0.19}); break;
case 'hitFire': this.nz({dur:0.07,f:950,q:1,g:0.14}); this.tone({f:150,f2:70,dur:0.09,type:'sine',g:0.18}); this.nz({dur:0.16,f:3600,q:0.6,g:0.06,type:'highpass'}); break;
case 'hitSolar': this.nz({dur:0.08,f:1200,q:1,g:0.15}); this.tone({f:110,f2:55,dur:0.14,type:'sine',g:0.26}); this.tone({f:880,f2:660,dur:0.1,type:'triangle',g:0.06}); break;
case 'shoot': this.tone({f:880,f2:2300,dur:0.09,type:'triangle',g:0.15}); this.tone({f:2150,dur:0.22,type:'sine',g:0.06,delay:0.1}); break;
case 'shootHit': this.tone({f:1700,f2:900,dur:0.12,type:'sine',g:0.1}); this.nz({dur:0.06,f:3000,q:1,g:0.06}); break;
case 'hurt': this.tone({f:105,f2:48,dur:0.22,type:'sine',g:0.36}); this.tone({f:680,dur:0.16,type:'sine',g:0.09}); this.tone({f:722,dur:0.16,type:'sine',g:0.09}); break;
case 'coin': this.tone({f:1250,dur:0.06,type:'square',g:0.05}); this.tone({f:1720,dur:0.09,type:'square',g:0.05,delay:0.055}); break;
case 'item': this.seq([784,988,1175,1568],'sine',0.11,0.16,0.075); break;
case 'rare':
this.seq([392,494,587,784],'sine',0.09,0.9,0.05);
this.nz({dur:0.7,f:5200,q:0.5,g:0.03,type:'highpass',delay:0.1});
break;
case 'heart':
this.seq([523,659,784,1047,1319],'sine',0.13,0.3,0.09);
this.tone({f:130,f2:65,dur:0.5,type:'sine',g:0.2});
break;
case 'save':
this.seq([294,441,588,882],'sine',0.09,1.1,0.09);
this.nz({dur:0.9,f:6000,q:0.4,g:0.025,type:'highpass',delay:0.2});
break;
case 'buy': this.tone({f:1600,f2:900,dur:0.1,type:'square',g:0.07}); this.tone({f:2050,dur:0.2,type:'sine',g:0.1,delay:0.1}); this.tone({f:2580,dur:0.25,type:'sine',g:0.07,delay:0.16}); break;
case 'altar':
this.tone({f:200,f2:820,dur:0.7,type:'sawtooth',g:0.09});
this.seq([330,415,494,659,830],'sine',0.1,0.7,0.11,0.2);
this.nz({dur:1.0,f:5600,q:0.4,g:0.03,type:'highpass',delay:0.3});
this.tone({f:82,f2:41,dur:0.9,type:'sine',g:0.25,delay:0.2});
break;
case 'break':
this.nz({dur:0.24,f:850,f2:180,q:0.8,g:0.26,type:'lowpass'});
this.nz({dur:0.1,f:2200,q:1,g:0.08,delay:0.1});
this.nz({dur:0.09,f:1800,q:1,g:0.06,delay:0.2});
break;
case 'switch': this.tone({f:1568,dur:0.3,type:'sine',g:0.14}); this.tone({f:2093,dur:0.35,type:'sine',g:0.09,delay:0.06}); break;
case 'door': this.tone({f:75,f2:48,dur:0.55,type:'sine',g:0.26}); this.nz({dur:0.5,f:500,f2:200,q:0.8,g:0.1,type:'lowpass'}); break;
case 'plate': this.tone({f:210,f2:160,dur:0.09,type:'square',g:0.07}); this.nz({dur:0.06,f:600,q:1,g:0.06}); break;
case 'boss': this.tone({f:62,f2:28,dur:1.1,type:'sawtooth',g:0.28}); this.nz({dur:0.9,f:400,f2:120,q:0.7,g:0.14,type:'lowpass'}); break;
case 'roar': this.tone({f:90,f2:40,dur:0.7,type:'sawtooth',g:0.2}); this.nz({dur:0.5,f:700,f2:200,q:0.8,g:0.1,type:'lowpass'}); break;
case 'shieldDown': this.nz({dur:0.5,f:3400,f2:400,q:0.6,g:0.16,type:'highpass'}); this.tone({f:1200,f2:200,dur:0.5,type:'triangle',g:0.12}); this.tone({f:70,f2:35,dur:0.7,type:'sine',g:0.3}); break;
case 'explode': this.nz({dur:0.6,f:1100,f2:90,q:0.6,g:0.4,type:'lowpass'}); this.tone({f:85,f2:30,dur:0.6,type:'sine',g:0.35}); break;
case 'freeze': this.tone({f:1900,f2:560,dur:0.3,type:'sine',g:0.12}); this.nz({dur:0.25,f:5200,q:0.5,g:0.05,type:'highpass'}); break;
case 'heal': this.tone({f:560,f2:920,dur:0.25,type:'sine',g:0.12}); this.seq([740,988],'sine',0.08,0.2,0.1,0.12); break;
case 'deny': this.tone({f:196,f2:180,dur:0.12,type:'square',g:0.08}); break;
case 'equip': this.tone({f:920,dur:0.07,type:'triangle',g:0.09}); this.tone({f:1240,dur:0.1,type:'triangle',g:0.09,delay:0.06}); break;
case 'death': this.seq([440,415,392,349,262,196],'sine',0.12,0.5,0.18); this.tone({f:65,f2:32,dur:1.4,type:'sine',g:0.25}); break;
case 'win':
this.seq([523,659,784,1047,784,1047,1319],'sine',0.12,0.5,0.14);
this.seq([262,330,392],'sine',0.09,1.4,0.05);
this.nz({dur:1.2,f:6000,q:0.4,g:0.03,type:'highpass',delay:0.4});
break;
case 'chest': this.nz({dur:0.12,f:700,f2:300,q:1,g:0.1,type:'lowpass'}); this.tone({f:300,f2:220,dur:0.1,type:'triangle',g:0.08}); break;
case 'pickupAmmo': this.tone({f:1400,f2:2100,dur:0.1,type:'sine',g:0.09}); break;
}
}
};
/* ================= ZONE DATA =================
Legend: # wall . floor , grate ~ water/pool o chasm
C cracked boulder (gold) c cracked wall (heart nook) q cracked wall (shard)
P pressure plate D door S sanctuary M merchant A altar K beacon
T chest(gold) 1 chest(Band) 2 chest(Cloak) 3 chest(big gold+ammo) 4 chest(stem)
J jar(gold) V jar(Vital Stem) N jar(Binding Seal)
X crystal switch H heart container Q aether shard pickup
E husk R sentinel B brute G guardian F final boss < > zone links */
const MAPS=[
[/* 0 — The Ashen Threshold */
'##################################',
'#S.......####...................##',
'#........#..#....E..........J...##',
'#..J.....#.T#..............####.##',
'#........#..#......E.......#..#.##',
'#...E....####..............#.T#.##',
'#..........................#..#.##',
'####.#####....J.....E......####.##',
'#...............................##',
'#....E..................E.......##',
'#...............................##',
'####.####........J..............##',
'#.......#.......................##',
'#..J..E.#....E......####........##',
'#.......#..........#..#....J....##',
'####.####..........#.E#.........##',
'#..................#..#....E....##',
'#...E..............####.........##',
'#.......J......................>#',
'##################################'],
[/* 1 — The Rusted Archives */
'##################################',
'#<..............................##',
'#....,,,,......####......,,,,...##',
'#....,,,,......#MM#......,,,,...##',
'#....,,,,..V...#MM#..J...,,,,.1.##',
'#....,,,,......####......,,,,JJ.##',
'#..J.,,,,..............,,,,,,...##',
'#....,,,,,,..E.........,,,,,,...##',
'####.####,,,,,,,,,,,,,,####.#####',
'#.........,,,,,,,,,,,,,,........##',
'#...E.....,,,,,,S,,,,,,,..E.....##',
'#.........,,,,,,.,,,,,,,........##',
'#.....E...,,,,,,.,,,,,,,...E....##',
'#.........,,,,,,.,,,,,,,........##',
'####.####,,,,,,,,,,,,,,####.#####',
'#...............................##',
'#....J.......E.........E........##',
'#..C....J.......................##',
'#.......####........####.....T..##',
'#..E....#..#...J....#..#........##',
'#.......#..#........#..#.......>#',
'##################################'],
[/* 2 — The Collapsed Gallery */
'##################################',
'#<..............................##',
'#.......####...A...####.........##',
'#.......#..#.......#..#....J....##',
'#...J...#..#...T...#..#.........##',
'#.......####.......####....E....##',
'#...............................##',
'####.#####....E.....#####.#######',
'#...............................##',
'#....P.................P........##',
'#.........E................B....##',
'#.........###D#####.......E.....##',
'#....J....#...c...#....J........##',
'#.........#...H...#.............##',
'#.........#########.......T.....##',
'#...............................##',
'####.####.................#######',
'#.......#....E......E...........##',
'#..J....#.......................##',
'#.......#.........E............>#',
'##################################'],
[/* 3 — The Sunken Vault */
'##################################',
'#<..............................##',
'#.................X...X.......X.##',
'#.....ooooo.......ooooooooooooo.##',
'#.....ooooo..E....ooooooooooooo.##',
'#.....ooooo.......ooooooooooooo.##',
'#.....ooooo...J.........####....##',
'#..E..ooooo.......T.....#..#....##',
'####.######.............#.4.....##',
'#.........ooo.....E.....#..#....##',
'#....E....ooo..........####.....##',
'#..q......ooo.....oooooooooooooo##',
'#.........ooo.....oooooooooooooo##',
'#....J....ooo.....oooXoooooooooo##',
'#.........ooo.....oooooooooooooo##',
'####.#####ooo.....oooooooooooooo##',
'#.................oooooooooooooo##',
'#...T....J..............D......>#',
'#..E.........J..........##########',
'##################################'],
[/* 4 — The Twin Spire */
'##################################',
'#S<.............................##',
'#.......####...A...####.........##',
'#.......#..#.......#..#....J....##',
'#...J...#..#...T...#..#.........##',
'#.......####.......####....R....##',
'#...............................##',
'####.#####..........#####.#######',
'#...............................##',
'#....B.......E.E.............2..##',
'#..........................G....##',
'#....R...E.......E...B....N.....##',
'#...............................##',
'####.#####...E.....#####........##',
'#............................3..##',
'#..C....J.......E...............##',
'#.########......................##',
'#.#..H...D......ooooXoooo.......##',
'#.#......#......ooooooo....J....##',
'#.########......ooooooo.........##',
'#..J............ooooooo........>#',
'##################################'],
[/* 5 — The Core Chamber */
'##############################',
'#<..........................##',
'#....ooo..........ooo.......##',
'#....oXo..........oXo.......##',
'#....ooo..........ooo...J...##',
'#...........................##',
'#..~~...........K.....~~....##',
'#..~~..................~~...##',
'#............F..............##',
'#..E.....................E..##',
'#..~~..................~~...##',
'#..~~.....ooooooo......~~...##',
'#.........ooooooo...........##',
'#....E....ooooooo......E....##',
'#...........................##',
'##############################']];
const ZONES=[
{name:'The Ashen Threshold', sub:'Prologue — where the last vigil begins', mat:'stone', dark:0.10, mood:55,
exits:[{ch:'>',to:1,spawn:'<',gate:null}]},
{name:'The Rusted Archives', sub:'Knowledge drowned in rust', mat:'metal', dark:0.20, mood:49,
exits:[{ch:'<',to:0,spawn:'>'},{ch:'>',to:2,spawn:'<',gate:'archivesCleared'}],
clearFlag:'archivesCleared', shop:true},
{name:'The Collapsed Gallery', sub:'Where the blade is reborn', mat:'stone', dark:0.16, mood:52,
exits:[{ch:'<',to:1,spawn:'>'},{ch:'>',to:3,spawn:'<',gate:'platesSolved'}],
doorFlag:'platesSolved', plates:{window:2.6,flag:'platesSolved'}},
{name:'The Sunken Vault', sub:'The halfway tide — secrets lie beneath', mat:'water', dark:0.58, mood:41,
exits:[{ch:'<',to:2,spawn:'>'},{ch:'>',to:4,spawn:'<',gate:'vaultExit'}],
doorFlag:'vaultExit',
sw:[{ids:[0,1,2],effect:'lantern',msg:'The light paths align — a Phantom Lantern materializes from the glow!'},{ids:[3],flag:'vaultExit',msg:'The eastern seal releases with a grinding sigh.'}]},
{name:'The Twin Spire', sub:'The final ascent awaits', mat:'metal', dark:0.28, mood:46,
exits:[{ch:'<',to:3,spawn:'>'},{ch:'>',to:5,spawn:'<',gate:'guardianDown'}],
doorFlag:'spireHeart',
sw:[{ids:[0],flag:'spireHeart',msg:'The distant mechanism turns — a sealed door grinds open below.'}]},
{name:'The Core Chamber', sub:'Reignite the Beacon, Last Warden', mat:'stone', dark:0.42, mood:36,
exits:[{ch:'<',to:4,spawn:'>'}],
sw:[{ids:[0,1],effect:'unshield',msg:'Both crystals flare — the Corroded Heart\u2019s shield collapses!'}]}];
const PALETTES=[
{floor:'#2b3145',floor2:'#272d40',wall:'#3d4560',wallTop:'#525c7e',grout:'#1c2130',moss:true, accent:'#f0b45a'},
{floor:'#3a3129',floor2:'#342c25',wall:'#54443a',wallTop:'#6b584a',grout:'#241d17',grate:true, accent:'#e2a24a'},
{floor:'#3b3648',floor2:'#35303f',wall:'#574f6b',wallTop:'#6e6488',grout:'#241f30',moss:true, accent:'#ffc46a'},
{floor:'#20343c',floor2:'#1c2e36',wall:'#33525c',wallTop:'#436875',grout:'#12222a',water:'#16333f', accent:'#7fd4e8'},
{floor:'#262c46',floor2:'#222840',wall:'#414a72',wallTop:'#586291',grout:'#161a2c', accent:'#ffd76a'},
{floor:'#221f26',floor2:'#1d1a21',wall:'#3a3340',wallTop:'#4e4557',grout:'#131017',corrode:true, accent:'#ff9a4a'}];
const WEAPONS=[
{name:'Ember Blade', dmg:2, cd:0.44, range:42, arc:1.15, color:'#c8cede', glow:'#8fa3b8'},
{name:'Ignited Edge', dmg:3, cd:0.31, range:47, arc:1.25, color:'#ff8c42', glow:'#ffb066'},
{name:'Solar Falchion', dmg:5, cd:0.56, range:62, arc:1.75, color:'#ffd76a', glow:'#fff0b0'}];
const EDEF={
husk: {hp:4, spd:58, dmg:1, r:11, gold:[5,9], score:1},
sentinel:{hp:5, spd:42, dmg:1, r:11, gold:[8,12], ranged:true, score:2},
brute: {hp:11,spd:38, dmg:2, r:15, gold:[12,15],score:3},
guardian:{hp:38,spd:56, dmg:2, r:17, gold:[90,110],boss:true,score:10},
boss: {hp:80,spd:52, dmg:2, r:22, gold:[140,160],boss:true,score:25}};
const SOLID=new Set(['#','C','c','q','o','S','M','A','K','J','V','N','T','1','2','3','4','X','H','Q']);
const CHEST_CHARS=new Set(['T','1','2','3','4']);
const CRACK_CHARS=new Set(['C','c','q']);
const JAR_CHARS=new Set(['J','V','N']);
/* ================= GAME STATE ================= */
const T=32, VW=960, VH=560;
let G=null; // persistent player progress
let Z=null; // runtime zone state
let paused=true, gameStarted=false, dead=false;
let keys={}, camX=0, camY=0, shake=0, timeNow=0;
let lastStep=0, droneOn=false;
const cv=$('cv'), ctx=cv.getContext('2d');
const lcv=document.createElement('canvas'); lcv.width=VW; lcv.height=VH;
const lctx=lcv.getContext('2d');
function freshPlayer(){
return {x:64,y:64,vx:0,vy:0,hw:9,hh:9,face:{x:0,y:1},moving:false,
hp:6,maxC:3,weapon:0,shard:false,ammo:0,gold:0,
items:{stem:0,seal:0},
accOwn:{band:false,lantern:false,cloak:false},
accEq:{band:false,lantern:false,cloak:false},
atkCd:0,atkAnim:0,atkDir:{x:0,y:1},shotCd:0,invuln:0,hurtT:0,poolTick:0,stepT:0};
}
function newGame(){
G={p:freshPlayer(), zi:0, flags:{}, shop:{stem:0,seal:0,heart:0}, respawn:{zi:0,x:2*T,y:2*T}, time:0, kills:0};
loadZone(0, null, {x:2*T+16,y:2*T+16});
}
/* ---------- save / load ---------- */
const SAVEKEY='wardensCorrosion_v1';
function saveGame(silent){
const d={zi:G.zi, x:G.p.x, y:G.p.y, hp:G.p.hp, maxC:G.p.maxC, weapon:G.p.weapon,
shard:G.p.shard, ammo:G.p.ammo, gold:G.p.gold, items:{...G.p.items},
accOwn:{...G.p.accOwn}, accEq:{...G.p.accEq}, flags:{...G.flags}, shop:{...G.shop},
respawn:{...G.respawn}, time:G.time, kills:G.kills};
try{ localStorage.setItem(SAVEKEY, JSON.stringify(d)); }catch(e){}
if(!silent) toast('Progress inscribed in the stone\u2019s light.','gold');
}
function hasSave(){ try{ return !!localStorage.getItem(SAVEKEY); }catch(e){ return false; } }
function loadSave(){
let d; try{ d=JSON.parse(localStorage.getItem(SAVEKEY)); }catch(e){ d=null; }
if(!d) return false;
G={zi:d.zi, flags:d.flags||{}, shop:d.shop||{stem:0,seal:0,heart:0}, respawn:d.respawn||{zi:0,x:48,y:48},
time:d.time||0, kills:d.kills||0, p:freshPlayer()};
const p=G.p;
p.hp=d.hp; p.maxC=d.maxC; p.weapon=d.weapon; p.shard=d.shard; p.ammo=d.ammo||0; p.gold=d.gold||0;
p.items=d.items||{stem:0,seal:0}; p.accOwn=d.accOwn||p.accOwn; p.accEq=d.accEq||p.accEq;
loadZone(d.zi, null, {x:d.x,y:d.y});
return true;
}
/* ================= ZONE LOADING ================= */
function loadZone(zi, spawnCh, spawnPos){
const zdef=ZONES[zi];
// normalize map: pad rows, force borders
const raw=MAPS[zi];
const w=Math.max(...raw.map(r=>r.length));
const grid=raw.map(r=>(r.padEnd(w-1,'.')+'#').split(''));
while(grid.length<10) grid.push(Array(w).fill('#'));
for(let x=0;x<w;x++){ grid[0][x]='#'; grid[grid.length-1][x]='#'; }
const h=grid.length;
Z={zi,w,h,grid,enemies:[],bolts:[],coins:[],parts:[],nums:[],plates:[],switches:[],exits:[],
cracks:{},merchant:null,t:0,ambientT:0,cleared:false,gateMsgT:0};
let swI=0, plI=0;
for(let y=0;y<h;y++)for(let x=0;x<w;x++){
const c=grid[y][x];
const px=x*T+T/2, py=y*T+T/2;
if(c==='E'||c==='R'||c==='B'||c==='G'||c==='F'){
const type=c==='E'?'husk':c==='R'?'sentinel':c==='B'?'brute':c==='G'?'guardian':'boss';
if(type==='guardian'&&G.flags.guardianDown){ grid[y][x]='.'; continue; }
if(type==='boss'&&G.flags.bossDown){ grid[y][x]='.'; continue; }
grid[y][x]='.';
Z.enemies.push(makeEnemy(type,px,py,zi));
}
else if(c==='X'){ Z.switches.push({x,y,i:swI++,on:false}); }
else if(c==='P'){ Z.plates.push({x,y,i:plI++,pressT:-99,held:false}); }
else if(c==='>'||c==='<'){
const edef=zdef.exits.find(e=>e.ch===c);
Z.exits.push({x,y,def:edef});
}
}
// fix switch on-state from flags
Z.switches.forEach(s=>{ s.on=!!G.flags['sw_'+zi+'_'+s.i]; });
// locate merchant tiles
for(let y=0;y<h&&!Z.merchant;y++)for(let x=0;x<w;x++){ if(grid[y][x]==='M'){ Z.merchant={x:x*T+T/2,y:y*T+T/2}; break; } }
// apply persistent flags to grid
for(let y=0;y<h;y++)for(let x=0;x<w;x++){
const c=grid[y][x];
if(CHEST_CHARS.has(c)&&G.flags['ch_'+zi+'_'+x+'_'+y]) grid[y][x]='t';
else if(JAR_CHARS.has(c)&&G.flags['j_'+zi+'_'+x+'_'+y]) grid[y][x]='.';
else if(CRACK_CHARS.has(c)&&G.flags['cw_'+zi+'_'+x+'_'+y]) grid[y][x]='.';
else if(c==='H'&&G.flags['heart_'+zi]) grid[y][x]='.';
else if(c==='Q'&&G.flags.shardGot) grid[y][x]='.';
else if(c==='A'&&( (zi===2&&G.p.weapon>=1)||(zi===4&&G.p.weapon>=2) )) grid[y][x]='a';
}
// spawn position
let sx,sy;
if(spawnPos){ sx=spawnPos.x; sy=spawnPos.y; }
else if(spawnCh){
const pos=findChar(zi,spawnCh); sx=pos.x*T+T/2; sy=pos.y*T+T/2;
} else { sx=3*T; sy=3*T; }
G.p.x=sx; G.p.y=sy; G.p.vx=G.p.vy=0; G.p.invuln=0.6;
G.zi=zi;
camX=clamp(sx-VW/2,0,Math.max(0,w*T-VW)); camY=clamp(sy-VH/2,0,Math.max(0,h*T-VH));
AU.setMood(zdef.mood);
$('zoneName').textContent=zdef.name;
showBanner(zdef.name, zdef.sub);
AU.play('save'); // soft arrival chime reused subtly
if(zdef.clearFlag&&G.flags[zdef.clearFlag]) Z.cleared=true;
}
function findChar(zi,ch){
const raw=MAPS[zi];
for(let y=0;y<raw.length;y++){ const x=raw[y].indexOf(ch); if(x>=0) return {x,y}; }
return {x:1,y:1};
}
function tileAt(px,py){
if(!isFinite(px)||!isFinite(py))return '#';
const x=Math.floor(px/T), y=Math.floor(py/T);
if(!Z||x<0||y<0||x>=Z.w||y>=Z.h)return '#';
return Z.grid[y][x];
}
function tileAtIdx(x,y){
if(!Z||x<0||y<0||x>=Z.w||y>=Z.h) return '#';
return Z.grid[y][x];
}
function isSolidTile(c,x,y){
if(c==='D') return !(ZONES[Z.zi].doorFlag && G.flags[ZONES[Z.zi].doorFlag]);
if(c==='>'||c==='<'){
const ex=Z&&Z.exits?Z.exits.find(e=>e.x===x&&e.y===y):null;
if(ex&&ex.def&&ex.def.gate) return !G.flags[ex.def.gate];
return false;
}
if(c==='t'||c==='a') return true; // opened chest / used altar remain furniture
return SOLID.has(c);
}
function solidAt(px,py){ return isSolidTile(tileAt(px,py),Math.floor(px/T),Math.floor(py/T)); }
function makeEnemy(type,x,y,zi){
const d=EDEF[type];
return {type,x,y,vx:0,vy:0,hp:d.hp,maxHp:d.hp,r:d.r,hw:d.r*0.7,hh:d.r*0.7,spd:d.spd,dmg:d.dmg,
atkCd:rnd(1.5,0.4),flinch:0,burn:0,frozen:0,zi,t:rnd(6),homeX:x,homeY:y,wanderA:rnd(6.28),
shielded:type==='boss'&&!(G&&G.flags&&G.flags.bossShieldDown), summoned:false, slamCd:3, shotCd:rnd(2.5,1), dashCd:2.5, dashing:0, dead:false};
}
/* ================= EFFECT HELPERS ================= */
function toast(msg,cls){
const t=$('toast'); const d=document.createElement('div');
d.className='toastMsg'+(cls?' '+cls:''); d.textContent=msg; t.appendChild(d);
while(t.children.length>3) t.removeChild(t.firstChild);
setTimeout(()=>{ d.style.transition='opacity .5s'; d.style.opacity='0'; setTimeout(()=>d.remove(),520); },2900);
}
function showBanner(title,sub){
const b=$('banner'); $('bannerTitle').textContent=title; $('bannerSub').textContent=sub;
b.classList.remove('show'); void b.offsetWidth; b.classList.add('show');
}
function flashDmg(){ const f=$('dmgFlash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),90); }
function doShimmer(){ const f=$('shimmer'); f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); }
function spawnP(o){
if(Z.parts.length>420)return;
Z.parts.push({x:o.x,y:o.y,vx:o.vx||0,vy:o.vy||0,life:o.life||0.6,max:o.life||0.6,
size:o.size||2.5,color:o.color||'#ffb066',grav:o.grav||0,type:o.type||'spark',glow:o.glow!==false,ring:o.ring||0});
}
function burst(x,y,n,color,opt={}){
for(let i=0;i<n;i++){
const a=rnd(Math.PI*2), s=rnd(opt.spd||110,20);
spawnP({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-(opt.up||0),life:rnd(opt.life||0.6,0.25),
size:rnd(opt.size||3,1.2),color:opt.colors?pick(opt.colors):color,grav:opt.grav||120,type:opt.type||'spark'});
}
}
function addNum(x,y,txt,color){ Z.nums.push({x,y,txt,color:color||'#fff',life:0.95}); }
/* ================= INPUT ================= */
const KEYMAP={w:'w',arrowup:'w',a:'a',arrowleft:'a',s:'s',arrowdown:'s',d:'d',arrowright:'d'};
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k))e.preventDefault();
if(KEYMAP[k])keys[KEYMAP[k]]=1;
if(e.repeat)return;
if(!gameStarted||dead)return;
if(k==='j'||k===' '){ if(!paused)playerAttack(); }
else if(k==='k'){ if(!paused)playerShoot(); }
else if(k==='e'){ if(!paused)interact(); }
else if(k==='1'){ if(!paused)useStem(); }
else if(k==='2'){ if(!paused)useSeal(); }
else if(k==='i'){ toggleInv(); }
else if(k==='escape'){ closeModals(); }
});
addEventListener('keyup',e=>{ const k=e.key.toLowerCase(); if(KEYMAP[k])keys[KEYMAP[k]]=0; });
addEventListener('blur',()=>{ keys={}; });
/* ================= MOVEMENT / COLLISION ================= */
function moveEnt(e,dx,dy){
if(!isFinite(e.x))e.x=(e.homeX!==undefined?e.homeX:80);
if(!isFinite(e.y))e.y=(e.homeY!==undefined?e.homeY:80);
if(!isFinite(dx))dx=0; if(!isFinite(dy))dy=0;
if(e.hw===undefined)e.hw=8; if(e.hh===undefined)e.hh=8;
if(dx!==0){
e.x+=dx; const s=Math.sign(dx);
if(solidAt(e.x+s*e.hw,e.y-e.hh+1)||solidAt(e.x+s*e.hw,e.y)||solidAt(e.x+s*e.hw,e.y+e.hh-1)){
const tx=Math.floor((e.x+s*e.hw)/T);
e.x = s>0 ? tx*T-e.hw-0.01 : (tx+1)*T+e.hw+0.01;
}
}
if(dy!==0){
e.y+=dy; const s=Math.sign(dy);
if(solidAt(e.x-e.hw+1,e.y+s*e.hh)||solidAt(e.x,e.y+s*e.hh)||solidAt(e.x+e.hw-1,e.y+s*e.hh)){
const ty=Math.floor((e.y+s*e.hh)/T);
e.y = s>0 ? ty*T-e.hh-0.01 : (ty+1)*T+e.hh+0.01;
}
}
}
/* ================= PLAYER ================= */
function updatePlayer(dt){
const p=G.p;
let ix=(keys.d?1:0)-(keys.a?1:0), iy=(keys.s?1:0)-(keys.w?1:0);
if(ix||iy){ const l=Math.hypot(ix,iy); ix/=l; iy/=l; p.face={x:ix,y:iy}; }
const under=tileAt(p.x,p.y);
const onPool=under==='~';
const spd=148*(onPool&&Z.zi===5?0.7:1)*(onPool&&Z.zi===3?0.62:1);
p.moving=!!(ix||iy);
moveEnt(p,ix*spd*dt,iy*spd*dt);
if(p.moving){
p.stepT-=dt;
if(p.stepT<=0){ p.stepT=Z.zi===3?0.42:0.34; AU.play('step_'+ZONES[Z.zi].mat); }
if(p.weapon>=1&&Math.random()<0.35){
spawnP({x:p.x-p.face.x*8+rnd(6,-6),y:p.y+8,vx:rnd(12,-12),vy:rnd(-6,-26),life:rnd(0.6,0.3),
size:rnd(2.4,1),color:p.weapon===2?'#ffd76a':'#ff8c42',grav:-30});
}
}
p.atkCd=Math.max(0,p.atkCd-dt); p.shotCd=Math.max(0,p.shotCd-dt);
p.atkAnim=Math.max(0,p.atkAnim-dt); p.invuln=Math.max(0,p.invuln-dt); p.hurtT=Math.max(0,p.hurtT-dt);
// corrosive pools (Core Chamber)
if(Z.zi===5&&onPool){
p.poolTick-=dt;
if(p.poolTick<=0){ p.poolTick=0.85;
if(p.accEq.cloak){ burst(p.x,p.y+6,4,'#7a86ff',{spd:40,up:30,life:0.5}); }
else damagePlayer(1,p.x,p.y+20,true);
}
} else p.poolTick=Math.max(p.poolTick,0.3);
// pickups underfoot
const tx=Math.floor(p.x/T), ty=Math.floor(p.y/T), ch=tileAtIdx(tx,ty);
if(ch==='H') collectHeart(tx,ty);
else if(ch==='Q') collectShard(tx,ty);
// zone exits
for(const ex of Z.exits){
if(!ex.def)continue;
if(Math.abs(p.x-(ex.x*T+T/2))<18&&Math.abs(p.y-(ex.y*T+T/2))<18){
if(!ex.def.gate||G.flags[ex.def.gate]){ changeZone(ex.def); return; }
else { Z.gateMsgT-=dt; if(Z.gateMsgT<=0){ Z.gateMsgT=2.2; toast(gateHint(ex.def.gate)); AU.play('deny'); } }
}
}
}
function gateHint(g){
switch(g){
case 'archivesCleared': return 'The stair is sealed by rust-magic. Silence every construct in the Archives.';
case 'platesSolved': return 'The way east is locked. Two bronze plates must be pressed in quick succession.';
case 'vaultExit': return 'A distant crystal mechanism guards this seal — only an aether bolt can reach it.';
case 'guardianDown': return 'The Spire Guardian yet bars the ascent to the Core Chamber.';
}
return 'Sealed.';
}
function playerAttack(){
const p=G.p; if(p.atkCd>0)return;
const w=WEAPONS[p.weapon];
p.atkCd=w.cd*(p.accEq.band?0.8:1); p.atkAnim=0.24; p.atkDir={...p.face};
AU.play('swing'+p.weapon);
const ang=Math.atan2(p.face.y,p.face.x);
const hx=p.x+p.face.x*(w.range*0.55), hy=p.y+p.face.y*(w.range*0.55);
// swing visuals
const col=p.weapon===2?'#ffe9a8':p.weapon===1?'#ffab5e':'#dfe7f2';
for(let i=0;i<(p.weapon===2?14:8);i++){
const a=ang-rnd(w.arc/2)+rnd(w.arc); const r=rnd(w.range,w.range*0.5);
spawnP({x:p.x+Math.cos(a)*r,y:p.y+Math.sin(a)*r,vx:Math.cos(a)*30,vy:Math.sin(a)*30,
life:rnd(0.3,0.12),size:rnd(2.6,1),color:col,grav:0});
}
if(p.weapon===2) shake=Math.max(shake,3.5);
// enemies
Z.enemies.forEach(e=>{
if(e.dead)return;
const d=dist(p.x,p.y,e.x,e.y);
if(d>w.range+e.r)return;
const a=Math.atan2(e.y-p.y,e.x-p.x);
let da=Math.abs(((a-ang+Math.PI*3)%(Math.PI*2))-Math.PI);
if(da<w.arc/2||d<26) damageEnemy(e,w.dmg,p.face.x,p.face.y);
});
// tiles in front
const ttx=Math.floor((p.x+p.face.x*28)/T), tty=Math.floor((p.y+p.face.y*28)/T);
hitTile(ttx,tty,w.dmg,false);
}
function playerShoot(){
const p=G.p;
if(!p.shard)return;
if(p.ammo<=0){ toast('No aether charges remain — the merchant may restock you.'); AU.play('deny'); return; }
if(p.shotCd>0)return;
p.shotCd=0.34; p.ammo--; updateHUD();
AU.play('shoot');
Z.bolts.push({x:p.x+p.face.x*14,y:p.y+p.face.y*14,vx:p.face.x*400,vy:p.face.y*400,dmg:3,friendly:true,life:1.4});
burst(p.x+p.face.x*16,p.y+p.face.y*16,5,'#8fe3ff',{spd:70,life:0.3,size:2});
}
function damagePlayer(amt,sx,sy,isTrap){
const p=G.p;
if(p.invuln>0||dead)return;
if(isTrap&&p.accEq.cloak){ toast('The Shadow Cloak drinks the corrosion.'); burst(p.x,p.y,8,'#5a648f',{spd:80,life:0.5}); p.invuln=0.5; return; }
p.hp-=amt; p.invuln=1.25; p.hurtT=0.35; shake=Math.max(shake,6);
AU.play('hurt'); flashDmg();
burst(p.x,p.y,14,'#e0455f',{spd:150,life:0.55,size:3});
addNum(p.x,p.y-20,'-'+amt,'#ff7a8a');
if(sx!==undefined){ const a=Math.atan2(p.y-sy,p.x-sx); moveEnt(p,Math.cos(a)*14,Math.sin(a)*14); }
updateHUD();
if(p.hp<=0){ p.hp=0; die(); }
}
function die(){
dead=true; AU.play('death'); AU.drone(false);
burst(G.p.x,G.p.y,26,'#e0455f',{spd:180,life:1});
setTimeout(()=>{ $('deathScreen').classList.remove('hidden'); },900);
}
function collectHeart(tx,ty){
const p=G.p;
Z.grid[ty][tx]='.'; G.flags['heart_'+Z.zi]=true;
p.maxC++; p.hp=p.maxC*2;
AU.play('heart'); doShimmer(); updateHUD();
burst(tx*T+16,ty*T+16,30,'#ffd76a',{spd:120,up:60,life:1.1,size:3.2,colors:['#ffd76a','#fff2c0','#f0b45a']});
spawnP({x:p.x,y:p.y,type:'ring',ring:1,life:0.9,color:'#ffd76a',size:10});
toast('A HEART CONTAINER! Your vitality swells to '+p.maxC+' vessels.','gold');
showBanner('VITALITY RESTORED', p.maxC+' heart containers — '+p.maxC*2+' HP');
}
function collectShard(tx,ty){
const p=G.p;
Z.grid[ty][tx]='.'; G.flags.shardGot=true;
p.shard=true; p.ammo+=6;
AU.play('rare'); doShimmer(); updateHUD();
burst(tx*T+16,ty*T+16,34,'#8fe3ff',{spd:130,up:70,life:1.2,size:3,colors:['#8fe3ff','#d8f4ff','#5ab8d8']});
toast('THE AETHER SHARD! Press K to fire bolts of crystal light.','gold');
showBanner('THE AETHER SHARD','A ranged mechanism awakens in your off-hand');
}
/* ================= TILES: jars, cracks, switches, plates ================= */
function hitTile(tx,ty,dmg,isBolt){
const c=tileAtIdx(tx,ty);
if(JAR_CHARS.has(c)) breakJar(tx,ty,c);
else if(CRACK_CHARS.has(c)) damageCrack(tx,ty,c,dmg);
else if(c==='X'&&isBolt){
const s=Z.switches.find(s=>s.x===tx&&s.y===ty);
if(s) activateSwitch(s);
}
else if(c==='X'&&!isBolt){ /* melee can't reach most crystals */ }
}
function breakJar(tx,ty,c){
const p=G.p;
Z.grid[ty][tx]='.'; G.flags['j_'+Z.zi+'_'+tx+'_'+ty]=true;
AU.play('break');
burst(tx*T+16,ty*T+16,12,'#a5713f',{spd:120,life:0.6,size:2.6,grav:260,colors:['#a5713f','#7c5227','#c98f4f']});
if(c==='V'){ p.items.stem++; AU.play('item'); toast('A Vital Stem glows among the shards! (+1)','gold'); updateHUD();
burst(tx*T+16,ty*T+16,14,'#7dff9a',{spd:60,up:50,life:0.9,size:2.4}); }
else if(c==='N'){ p.items.seal++; AU.play('item'); toast('A Binding Seal, cold and blue, survives the fall. (+1)','gold'); updateHUD();
burst(tx*T+16,ty*T+16,14,'#7ab8ff',{spd:60,up:50,life:0.9,size:2.4}); }
else dropCoins(tx*T+16,ty*T+16,rndi(5,12));
}
function damageCrack(tx,ty,c,dmg){
const key=tx+'_'+ty;
if(Z.cracks[key]===undefined) Z.cracks[key]=6;
Z.cracks[key]-=dmg;
AU.play('hit');
burst(tx*T+16,ty*T+16,6,'#8a8f9c',{spd:90,life:0.4,size:2,grav:200});
shake=Math.max(shake,2);
if(Z.cracks[key]<=0){
Z.grid[ty][tx]='.'; G.flags['cw_'+Z.zi+'_'+tx+'_'+ty]=true;
AU.play('break'); shake=Math.max(shake,5);
burst(tx*T+16,ty*T+16,20,'#7d828f',{spd:160,life:0.8,size:3,grav:300,colors:['#7d828f','#5a5f6b','#9aa0ae']});
if(c==='q'){
Z.grid[ty][tx]='Q';
toast('The cracked wall collapses — a cyan crystal pulses in the hollow!','gold');
} else if(c==='c'){
toast('The wall gives way. A warm red light pulses deeper in the alcove…','gold');
} else {
dropCoins(tx*T+16,ty*T+16,rndi(25,40));
toast('The boulder splits — Golden Sigils spill from its hollow!');
}
}
}
function dropCoins(x,y,total){
const n=Math.min(8,Math.max(3,Math.round(total/4)));
for(let i=0;i<n;i++){
const a=rnd(Math.PI*2),s=rnd(120,40);
Z.coins.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-60,val:Math.ceil(total/n),type:'gold',life:14});
}
}
function activateSwitch(s){
if(s.on)return;
s.on=true; G.flags['sw_'+Z.zi+'_'+s.i]=true;
AU.play('switch');
burst(s.x*T+16,s.y*T+16,18,'#8fe3ff',{spd:110,life:0.8,size:2.6,colors:['#8fe3ff','#d8f4ff']});
spawnP({x:s.x*T+16,y:s.y*T+16,type:'ring',ring:1,life:0.7,color:'#8fe3ff',size:8});
const cfgs=ZONES[Z.zi].sw||[];
cfgs.forEach((cfg,ci)=>{
if(!cfg.ids.includes(s.i))return;
const doneKey='swe_'+Z.zi+'_'+ci;
if(G.flags[doneKey])return;
if(cfg.ids.every(id=>G.flags['sw_'+Z.zi+'_'+id])){
G.flags[doneKey]=true;
if(cfg.flag){ G.flags[cfg.flag]=true; AU.play('door'); doShimmer(); }
if(cfg.effect==='lantern'&&!G.p.accOwn.lantern){
G.p.accOwn.lantern=true; G.p.accEq.lantern=true; AU.play('rare'); doShimmer(); updateHUD();
burst(G.p.x,G.p.y,26,'#7ab8ff',{spd:90,up:60,life:1.1,size:2.8});
} else if(cfg.effect==='lantern'){ G.p.gold+=60; toast('The lantern\u2019s light condenses into 60 Golden Sigils.'); }
if(cfg.effect==='unshield'){
G.flags.bossShieldDown=true;
Z.enemies.forEach(e=>{ if(e.type==='boss'&&e.shielded){ e.shielded=false; e.flinch=0.6; } });
AU.play('shieldDown'); shake=Math.max(shake,8); doShimmer();
}
if(cfg.msg) toast(cfg.msg,'gold');
}
});
}
function updatePlates(dt){
const cfg=ZONES[Z.zi].plates;
const p=G.p;
Z.plates.forEach(pl=>{
const cx=pl.x*T+T/2, cy=pl.y*T+T/2;
let held=(Math.abs(p.x-cx)<20&&Math.abs(p.y-cy)<20);
if(!held) held=Z.enemies.some(e=>!e.dead&&Math.abs(e.x-cx)<18&&Math.abs(e.y-cy)<18);
if(held&&!pl.held){ pl.pressT=timeNow; AU.play('plate'); }
pl.held=held;
});
if(cfg&&Z.plates.length>=2&&!G.flags[cfg.flag]){
const recent=Z.plates.filter(pl=>timeNow-pl.pressT<cfg.window);
const allPressed=Z.plates.every(pl=>pl.held||timeNow-pl.pressT<cfg.window);
if(allPressed&&recent.length>=Z.plates.length){
G.flags[cfg.flag]=true;
AU.play('door'); doShimmer(); shake=Math.max(shake,4);
Z.plates.forEach(pl=>spawnP({x:pl.x*T+16,y:pl.y*T+16,type:'ring',ring:1,life:0.8,color:'#f0b45a',size:8}));
toast('The plates sink together — bronze doors grind open across the Gallery!','gold');
}
}
}
/* ================= ENEMIES ================= */
function updateEnemies(dt){
const p=G.p;
for(const e of Z.enemies){
if(e.dead)continue;
e.t+=dt;
if(e.flinch>0)e.flinch-=dt;
if(e.burn>0)e.burn-=dt;
if(e.atkCd>0)e.atkCd-=dt;
if(e.frozen>0){ e.frozen-=dt; if(Math.random()<0.1)spawnP({x:e.x+rnd(10,-10),y:e.y+rnd(10,-10),vx:0,vy:-14,life:0.6,size:1.8,color:'#bfe8ff',grav:0}); continue; }
const d=dist(e.x,e.y,p.x,p.y);
let mvx=0,mvy=0;
if(e.type==='husk'||e.type==='brute'){
if(d<320){ const a=Math.atan2(p.y-e.y,p.x-e.x); mvx=Math.cos(a)*e.spd; mvy=Math.sin(a)*e.spd; }
else {
if(Math.random()<0.008)e.wanderA=rnd(Math.PI*2);
const hd=dist(e.x,e.y,e.homeX,e.homeY);
if(hd>90){ const a=Math.atan2(e.homeY-e.y,e.homeX-e.x); mvx=Math.cos(a)*e.spd*0.4; mvy=Math.sin(a)*e.spd*0.4; }
else { mvx=Math.cos(e.wanderA)*e.spd*0.3; mvy=Math.sin(e.wanderA)*e.spd*0.3; }
}
if(d<e.r+15&&e.atkCd<=0){ e.atkCd=1.0; damagePlayer(e.dmg,e.x,e.y); }
}
else if(e.type==='sentinel'){
if(d<340){
const a=Math.atan2(p.y-e.y,p.x-e.x);
if(d<120){ mvx=-Math.cos(a)*e.spd; mvy=-Math.sin(a)*e.spd; }
else if(d>180){ mvx=Math.cos(a)*e.spd; mvy=Math.sin(a)*e.spd; }
else { mvx=Math.cos(a+Math.PI/2)*e.spd*0.6; mvy=Math.sin(a+Math.PI/2)*e.spd*0.6; }
e.shotCd-=dt;
if(e.shotCd<=0&&d<330){
e.shotCd=2.3;
Z.bolts.push({x:e.x,y:e.y,vx:Math.cos(a)*175,vy:Math.sin(a)*175,dmg:1,friendly:false,life:2.4});
AU.tone({f:520,f2:300,dur:0.12,type:'square',g:0.07});
}
if(d<e.r+15&&e.atkCd<=0){ e.atkCd=1.0; damagePlayer(e.dmg,e.x,e.y); }
}
}
else if(e.type==='guardian'){
e.dashCd-=dt;
if(e.dashing>0){ e.dashing-=dt; }
else if(d<340){
const a=Math.atan2(p.y-e.y,p.x-e.x);
let sp=e.spd;
if(e.dashCd<=0&&d<230&&d>60){ e.dashing=0.45; e.dashCd=3.2; e.dashA=a; AU.play('roar'); sp=e.spd*2.7; }
mvx=Math.cos(e.dashing>0?e.dashA:a)*sp; mvy=Math.sin(e.dashing>0?e.dashA:a)*sp;
}
if(d<e.r+16&&e.atkCd<=0){ e.atkCd=0.9; damagePlayer(e.dmg,e.x,e.y); }
}
else if(e.type==='boss'){
if(e.shielded){
mvx=Math.cos(e.t*0.6)*18; mvy=Math.sin(e.t*0.8)*14;
e.shotCd-=dt;
if(e.shotCd<=0){
e.shotCd=2.2;
const base=Math.atan2(p.y-e.y,p.x-e.x);
for(let k=-1;k<=1;k++){
const a=base+k*0.28;
Z.bolts.push({x:e.x,y:e.y,vx:Math.cos(a)*155,vy:Math.sin(a)*155,dmg:1,friendly:false,life:3,corrode:true});
}
AU.play('roar');
}
} else {
const a=Math.atan2(p.y-e.y,p.x-e.x);
mvx=Math.cos(a)*e.spd; mvy=Math.sin(a)*e.spd;
e.slamCd-=dt;
if(d<125&&e.slamCd<=0){
e.slamCd=3.4; shake=Math.max(shake,9); AU.play('explode');
spawnP({x:e.x,y:e.y,type:'ring',ring:1,life:0.6,color:'#ff9a4a',size:16});
burst(e.x,e.y,20,'#ff9a4a',{spd:200,life:0.6,size:3});
if(d<115) damagePlayer(2,e.x,e.y);
}
if(d<e.r+16&&e.atkCd<=0){ e.atkCd=0.85; damagePlayer(e.dmg,e.x,e.y); }
if(e.hp<e.maxHp*0.4&&!e.summoned){
e.summoned=true; AU.play('roar');
toast('The Corroded Heart tears husks from its own mass!');
for(let k=0;k<2;k++){
const a=rnd(Math.PI*2);
const ne=makeEnemy('husk',e.x+Math.cos(a)*50,e.y+Math.sin(a)*50,Z.zi);
Z.enemies.push(ne);
burst(ne.x,ne.y,12,'#3a3340',{spd:100,life:0.6});
}
}
}
}
if(mvx||mvy){
const slow=(tileAt(e.x,e.y)==='~')?0.6:1;
moveEnt(e,mvx*dt*slow,mvy*dt*slow);
}
// separation
for(const o of Z.enemies){
if(o===e||o.dead)continue;
const dd=dist(e.x,e.y,o.x,o.y), min=e.r+o.r;
if(dd<min&&dd>0.01){ const push=(min-dd)*0.5, a=Math.atan2(e.y-o.y,e.x-o.x); e.x+=Math.cos(a)*push; e.y+=Math.sin(a)*push; }
}
}
Z.enemies=Z.enemies.filter(e=>!e.dead);
}
function damageEnemy(e,dmg,kx,ky){
if(e.dead)return;
if(e.shielded){
AU.tone({f:2400,f2:1800,dur:0.08,type:'sine',g:0.08});
burst(e.x+kx*e.r,e.y+ky*e.r,6,'#b48fff',{spd:90,life:0.35,size:2});
addNum(e.x,e.y-e.r-8,'WARD','#c9b6ff');
return;
}
e.hp-=dmg; e.flinch=0.2;
if(G.p.weapon>=1)e.burn=0.9;
e.x+=kx*(e.type==='brute'||e.type==='guardian'||e.type==='boss'?3:7);
e.y+=ky*(e.type==='brute'||e.type==='guardian'||e.type==='boss'?3:7);
AU.play(G.p.weapon===0?'hit':G.p.weapon===1?'hitFire':'hitSolar');
const cols=G.p.weapon===2?['#ffe9a8','#ffd76a','#fff']:G.p.weapon===1?['#ff8c42','#ffc46a','#ff5a2a']:['#ffb066','#fff','#e2703a'];
burst(e.x+kx*8,e.y+ky*8,G.p.weapon===2?14:9,null,{spd:140,life:0.5,size:2.8,colors:cols});
if(G.p.weapon===1) burst(e.x,e.y,4,'#6b6258',{spd:40,up:40,life:0.8,size:2.4,grav:-20,colors:['#6b6258','#4a443c']});
addNum(e.x,e.y-e.r-6,'-'+dmg,G.p.weapon===2?'#ffe9a8':'#fff');
if(G.p.weapon===2) shake=Math.max(shake,3);
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.dead=true; G.kills++;
const d=EDEF[e.type];
AU.play('explode');
burst(e.x,e.y,e.type==='boss'?46:16,'#e2703a',{spd:e.type==='boss'?240:150,life:0.9,size:e.type==='boss'?4:3,
colors:e.type==='boss'?['#ff9a4a','#3a3340','#ffd76a','#fff']:['#e2703a','#8a5a33','#f0b45a']});
dropCoins(e.x,e.y,rndi(d.gold[0],d.gold[1]));
if(G.p.shard&&Math.random()<0.25){
Z.coins.push({x:e.x,y:e.y,vx:rnd(60,-60),vy:-70,val:1,type:'ammo',life:14});
}
if(e.type==='guardian'){
G.flags.guardianDown=true; AU.play('rare'); doShimmer(); shake=Math.max(shake,7);
toast('The Spire Guardian collapses into still bronze — the ascent lies open!','gold');
showBanner('GUARDIAN FELLED','The way to the Core Chamber is open');
if(!G.p.accOwn.cloak){ G.p.accOwn.cloak=true; G.p.accEq.cloak=true; updateHUD();
toast('From its cloak of shadows, the Shadow Cloak is yours.','gold'); }
}
if(e.type==='boss'){
G.flags.bossDown=true; shake=Math.max(shake,12); AU.play('win'); doShimmer();
showBanner('THE CORROSION RECOILS','The Corroded Heart is silent — approach the Beacon (E)');
toast('The black-iron mass shudders and unravels. The Core Beacon awaits your hand.','gold');
}
// archives clear gate
const cf=ZONES[Z.zi].clearFlag;
if(cf&&!G.flags[cf]&&Z.enemies.every(x=>x.dead)){
G.flags[cf]=true; AU.play('door'); doShimmer();
toast('The Archives fall silent — the eastern stair unlocks.','gold');
}
}
/* ================= BOLTS / COINS / PARTICLES ================= */
function updateBolts(dt){
for(const b of Z.bolts){
b.life-=dt; if(b.life<=0){b.gone=true;continue;}
const steps=3;
for(let s=0;s<steps&&!b.gone;s++){
b.x+=b.vx*dt/steps; b.y+=b.vy*dt/steps;
if(b.friendly){
spawnP({x:b.x,y:b.y,vx:rnd(15,-15),vy:rnd(15,-15),life:0.28,size:2,color:'#8fe3ff',grav:0});
const tx=Math.floor(b.x/T),ty=Math.floor(b.y/T),c=tileAtIdx(tx,ty);
if(c==='X'){ hitTile(tx,ty,b.dmg,true); AU.play('shootHit'); burst(b.x,b.y,8,'#8fe3ff',{spd:90,life:0.4}); b.gone=true; continue; }
if(JAR_CHARS.has(c)||CRACK_CHARS.has(c)){ hitTile(tx,ty,b.dmg,true); AU.play('shootHit'); b.gone=true; continue; }
if(c!=='o'&&c!=='~'&&isSolidTile(c,tx,ty)){ AU.play('shootHit'); burst(b.x,b.y,7,'#8fe3ff',{spd:100,life:0.35,size:2}); b.gone=true; continue; }
for(const e of Z.enemies){
if(e.dead)continue;
if(dist(b.x,b.y,e.x,e.y)<e.r+5){
damageEnemy(e,b.dmg,Math.sign(b.vx)||1,Math.sign(b.vy)*0.4);
AU.play('shootHit'); b.gone=true; break;
}
}
} else {
if(b.corrode&&Math.random()<0.5)spawnP({x:b.x,y:b.y,vx:rnd(10,-10),vy:rnd(10,-10),life:0.3,size:2.4,color:'#7a5a8f',grav:0});
else if(Math.random()<0.5)spawnP({x:b.x,y:b.y,vx:0,vy:0,life:0.2,size:1.8,color:'#ff8c42',grav:0});
const tx=Math.floor(b.x/T),ty=Math.floor(b.y/T);
const cc=tileAtIdx(tx,ty);
if(cc!=='o'&&cc!=='~'&&isSolidTile(cc,tx,ty)){ burst(b.x,b.y,5,'#ff8c42',{spd:80,life:0.3}); b.gone=true; continue; }
if(dist(b.x,b.y,G.p.x,G.p.y)<13){ damagePlayer(b.dmg,b.x,b.y); b.gone=true; }
}
}
}
Z.bolts=Z.bolts.filter(b=>!b.gone);
}
let coinSndT=0;
function updateCoins(dt){
const p=G.p;
for(const c of Z.coins){
c.life-=dt; if(c.life<=0){c.gone=true;continue;}
c.vy+=340*dt; c.x+=c.vx*dt; c.y+=c.vy*dt;
c.vx*=Math.pow(0.02,dt);
if(c.vy>0&&solidAt(c.x,c.y+6)){ c.vy*=-0.4; c.vx*=0.6; }
const d=dist(c.x,c.y,p.x,p.y);
if(d<84){ const a=Math.atan2(p.y-c.y,p.x-c.x); const pull=260*(1-d/84)+60; c.vx+=Math.cos(a)*pull*dt*4; c.vy+=Math.sin(a)*pull*dt*4-340*dt; }
if(d<16){
c.gone=true;
if(c.type==='ammo'){ p.ammo+=c.val; if(timeNow-coinSndT>0.05){coinSndT=timeNow;AU.play('pickupAmmo');} }
else { p.gold+=c.val; if(timeNow-coinSndT>0.05){coinSndT=timeNow;AU.play('coin');}
burst(c.x,c.y,3,'#ffd76a',{spd:50,life:0.35,size:1.8}); }
updateHUD();
}
}
Z.coins=Z.coins.filter(c=>!c.gone);
}
function updateParts(dt){
for(const q of Z.parts){
q.life-=dt;
if(q.type==='ring'){ q.ring+=dt*260; continue; }
q.vy+=q.grav*dt; q.x+=q.vx*dt; q.y+=q.vy*dt;
q.vx*=Math.pow(0.3,dt); q.vy*=Math.pow(0.6,dt);
}
Z.parts=Z.parts.filter(q=>q.life>0);
for(const n of Z.nums){ n.life-=dt; n.y-=34*dt; }
Z.nums=Z.nums.filter(n=>n.life>0);
}
function updateAmbient(dt){
Z.ambientT-=dt;
const x0=camX,y0=camY;
if(Z.ambientT<=0){
Z.ambientT=0.16;
if(Z.zi===1||Z.zi===2){ // floating dust
spawnP({x:x0+rnd(VW),y:y0+rnd(VH),vx:rnd(8,-8),vy:rnd(-3,-12),life:rnd(3,1.6),size:rnd(1.8,0.7),color:'#c9bfa6',grav:0,glow:false});
}
if(Z.zi===3){ // mist rising
spawnP({x:x0+rnd(VW),y:y0+VH-rnd(120),vx:rnd(6,-6),vy:rnd(-16,-30),life:rnd(2.6,1.4),size:rnd(7,3),color:'rgba(140,190,210,0.5)',grav:-4});
}
if(Z.zi===5){ // corrosion embers
spawnP({x:x0+rnd(VW),y:y0+VH-rnd(80),vx:rnd(10,-10),vy:rnd(-30,-60),life:rnd(2,1),size:rnd(2.2,1),color:'#ff7a3a',grav:-8});
}
}
// sanctuary sparkles
const p=G.p;
const tx=Math.floor(p.x/T),ty=Math.floor(p.y/T);
for(let y=Math.max(0,ty-9);y<Math.min(Z.h,ty+9);y++)for(let x=Math.max(0,tx-15);x<Math.min(Z.w,tx+15);x++){
if(Z.grid[y][x]==='S'&&Math.random()<0.012){
spawnP({x:x*T+rnd(28,4),y:y*T+rnd(30,6),vx:rnd(8,-8),vy:rnd(-14,-30),life:rnd(1.4,0.7),size:rnd(2,1),color:'#ffe9a8',grav:-6});
}
if(Z.grid[y][x]==='~'&&Math.random()<0.002&&Z.zi===3){
spawnP({x:x*T+rnd(30,2),y:y*T+rnd(10,4),vx:rnd(5,-5),vy:rnd(-10,-22),life:rnd(2,1),size:rnd(5,2),color:'rgba(140,190,210,0.4)',grav:-4});
}
}
}
/* ================= INTERACTION ================= */
function nearestInteract(){
const p=G.p; let best=null,bd=50*50;
const tx=Math.floor(p.x/T),ty=Math.floor(p.y/T);
for(let y=ty-2;y<=ty+2;y++)for(let x=tx-2;x<=tx+2;x++){
const c=tileAtIdx(x,y);
let type=null,label='';
if(CHEST_CHARS.has(c)){type='chest';label='Open the chest';}
else if(c==='A'){type='altar';label='Touch the weapon altar';}
else if(c==='S'){type='stone';label='Rest & inscribe your progress';}
else if(c==='M'){type='shop';label='Approach the merchant altar';}
else if(c==='K'){type='beacon';label=G.flags.bossDown?'Ignite the Core Beacon':'The Beacon lies cold…';}
if(type){
const d=(x*T+T/2-p.x)**2+(y*T+T/2-p.y)**2;
if(d<bd){bd=d;best={type,x,y,c,label};}
}
}
return best;
}
function interact(){
const it=nearestInteract();
if(!it){ return; }
const p=G.p;
if(it.type==='chest') openChest(it.x,it.y,it.c);
else if(it.type==='altar'){
if(Z.zi===2&&p.weapon<1){
p.weapon=1; Z.grid[it.y][it.x]='a'; G.flags.altar2=true;
AU.play('altar'); doShimmer(); shake=Math.max(shake,5); updateHUD();
burst(it.x*T+16,it.y*T+16,40,'#ff8c42',{spd:150,up:80,life:1.3,size:3.2,colors:['#ff8c42','#ffc46a','#fff']});
spawnP({x:p.x,y:p.y,type:'ring',ring:1,life:1,color:'#ff8c42',size:12});
showBanner('IGNITED EDGE','The blade burns orange — faster strikes, ember trails, burning flinches');
toast('Your Ember Blade drinks the altar\u2019s fire: the IGNITED EDGE!','gold');
} else if(Z.zi===4&&p.weapon<2){
p.weapon=2; Z.grid[it.y][it.x]='a'; G.flags.altar4=true;
AU.play('altar'); doShimmer(); shake=Math.max(shake,7); updateHUD();
burst(it.x*T+16,it.y*T+16,50,'#ffd76a',{spd:170,up:90,life:1.5,size:3.6,colors:['#ffd76a','#fff2c0','#fff']});
spawnP({x:p.x,y:p.y,type:'ring',ring:1,life:1.1,color:'#ffd76a',size:14});
showBanner('SOLAR FALCHION','A wider arc of golden light — slower wind-up, devastating impact');
toast('Solar light floods the blade: the SOLAR FALCHION!','gold');
} else {
toast('The altar\u2019s power has already been claimed.'); AU.play('deny');
}
}
else if(it.type==='stone'){
p.hp=p.maxC*2;
G.respawn={zi:Z.zi,x:p.x,y:p.y};
AU.play('save'); doShimmer(); updateHUD();
burst(it.x*T+16,it.y*T+16,30,'#ffe9a8',{spd:80,up:60,life:1.4,size:2.6});
spawnP({x:it.x*T+16,y:it.y*T+16,type:'ring',ring:1,life:1.1,color:'#ffe9a8',size:10});
saveGame(false);
toast('Warmth returns to your limbs. Health fully restored.','gold');
}
else if(it.type==='shop'){ openShop(); }
else if(it.type==='beacon'){
if(G.flags.bossDown) winGame();
else { toast('The Beacon is cold iron. The Corroded Heart still pulses in this chamber.'); AU.play('deny'); }
}
}
function openChest(x,y,c){
const p=G.p;
Z.grid[y][x]='t'; G.flags['ch_'+Z.zi+'_'+x+'_'+y]=true;
AU.play('chest');
const cx=x*T+16, cy=y*T+16;
burst(cx,cy,14,'#fff',{spd:60,up:70,life:1,size:2.4,colors:['#fff','#ffe9a8','#ffd76a']});
if(c==='1'){
p.accOwn.band=true; p.accEq.band=true; AU.play('rare'); doShimmer(); updateHUD();
toast('The WARDEN\u2019S BAND — a silver ring, cool and humming. Attacks quicken. (Equipped)','gold');
showBanner('WARDEN\u2019S BAND','Attack speed increased — a silver token circles your wrist');
} else if(c==='2'){
p.accOwn.cloak=true; p.accEq.cloak=true; AU.play('rare'); doShimmer(); updateHUD();
toast('The SHADOW CLOAK — dark cloth trim. Environmental corrosion cannot bite you. (Equipped)','gold');
showBanner('SHADOW CLOAK','Trap & corrosion damage negated — dark trim flows behind you');
} else if(c==='3'){
const g=150; p.gold+=g; let msg='A prince\u2019s ransom: +'+g+' Golden Sigils!';
if(p.shard){ p.ammo+=5; msg+=' And 5 aether charges.'; }
AU.play('rare'); dropCoins(cx,cy,60); updateHUD(); toast(msg,'gold');
} else if(c==='4'){
p.items.stem++; AU.play('item'); updateHUD();
toast('A Vital Stem, wrapped in waxed cloth. (+1) Press 1 to use.','gold');
} else {
const g=rndi(20,40); dropCoins(cx,cy,g); AU.play('item');
toast('Golden Sigils glint in the chest. (+'+g+')');
}
}
function useStem(){
const p=G.p;
if(p.items.stem<=0){ toast('No Vital Stems remain.'); AU.play('deny'); return; }
if(p.hp>=p.maxC*2){ toast('Your wounds are already whole.'); AU.play('deny'); return; }
p.items.stem--; p.hp=Math.min(p.maxC*2,p.hp+4);
AU.play('heal'); updateHUD();
burst(p.x,p.y,16,'#7dff9a',{spd:70,up:60,life:0.9,size:2.6});
toast('The Vital Stem\u2019s sap knits your wounds. (+4 HP)','gold');
}
function useSeal(){
const p=G.p;
if(p.items.seal<=0){ toast('No Binding Seals remain.'); AU.play('deny'); return; }
let best=null,bd=280*280;
Z.enemies.forEach(e=>{ if(e.dead||e.frozen>0)return; const d=(e.x-p.x)**2+(e.y-p.y)**2; if(d<bd){bd=d;best=e;} });
if(!best){ toast('No construct nearby to bind.'); AU.play('deny'); return; }
p.items.seal--; updateHUD();
best.frozen=3; AU.play('freeze');
burst(best.x,best.y,18,'#7ab8ff',{spd:90,life:0.8,size:2.6});
spawnP({x:best.x,y:best.y,type:'ring',ring:1,life:0.7,color:'#7ab8ff',size:8});
toast('The Binding Seal locks the construct in frozen light!','gold');
}
function changeZone(def){
if(Z._changing)return; Z._changing=true;
AU.drone(false);
const f=$('fade'); f.classList.add('on');
setTimeout(()=>{
loadZone(def.to, def.spawn, null);
updateHUD();
f.classList.remove('on');
setTimeout(()=>{ if(Z)Z._changing=false; },350);
},320);
}
/* ================= RENDERING ================= */
function render(){
if(!Z)return;
const pal=PALETTES[Z.zi], t=Z.t;
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle='#05070c'; ctx.fillRect(0,0,VW,VH);
const shx=shake>0.2?rnd(shake,-shake):0, shy=shake>0.2?rnd(shake,-shake):0;
ctx.save();
ctx.translate(-Math.round(camX)+shx,-Math.round(camY)+shy);
const x0=Math.max(0,Math.floor(camX/T)-1), y0=Math.max(0,Math.floor(camY/T)-1);
const x1=Math.min(Z.w-1,Math.ceil((camX+VW)/T)+1), y1=Math.min(Z.h-1,Math.ceil((camY+VH)/T)+1);
// floor + tiles
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
drawTile(x,y,Z.grid[y][x],x*T,y*T,pal,t);
}
// y-sorted actors
const actors=[];
Z.enemies.forEach(e=>{ if(!e.dead)actors.push({y:e.y,f:()=>drawEnemy(e,t)}); });
actors.push({y:G.p.y,f:()=>drawPlayer(t)});
Z.coins.forEach(c=>actors.push({y:c.y,f:()=>drawCoin(c,t)}));
actors.sort((a,b)=>a.y-b.y);
actors.forEach(a=>a.f());
// bolts
for(const b of Z.bolts) drawBolt(b,t);
// particles (additive)
ctx.globalCompositeOperation='lighter';
for(const q of Z.parts){
const a=clamp(q.life/q.max,0,1);
if(q.type==='ring'){
ctx.strokeStyle=q.color; ctx.globalAlpha=a*0.7; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.arc(q.x,q.y,q.ring,0,7); ctx.stroke();
} else {
ctx.globalAlpha=q.glow?a:a*0.8;
ctx.fillStyle=q.color;
ctx.beginPath(); ctx.arc(q.x,q.y,q.size*a+0.4,0,7); ctx.fill();
}
}
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
// damage numbers
ctx.font='bold 13px Georgia';
ctx.textAlign='center';
for(const n of Z.nums){
const a=clamp(n.life/0.95,0,1);
ctx.globalAlpha=a;
ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3;
ctx.strokeText(n.txt,n.x,n.y);
ctx.fillStyle=n.color; ctx.fillText(n.txt,n.x,n.y);
}
ctx.globalAlpha=1;
ctx.restore();
renderLight(shx,shy);
// vignette + zone tint
const vg=ctx.createRadialGradient(VW/2,VH/2,VH*0.42,VW/2,VH/2,VH*0.85);
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)');
ctx.fillStyle=vg; ctx.fillRect(0,0,VW,VH);
if(Z.zi===3){ ctx.fillStyle='rgba(24,64,84,0.07)'; ctx.fillRect(0,0,VW,VH); }
if(Z.zi===5){ ctx.fillStyle='rgba(90,34,10,0.07)'; ctx.fillRect(0,0,VW,VH); }
}
function drawTile(x,y,c,px,py,pal,t){
const hv=hash2(x,y);
// base floor
ctx.fillStyle=hv<0.5?pal.floor:pal.floor2;
ctx.fillRect(px,py,T,T);
if(hv>0.88){ ctx.fillStyle='rgba(0,0,0,0.14)'; ctx.fillRect(px+(hv*23|0),py+((hv*97)%22|0),3,2); }
if(hv<0.1){ ctx.fillStyle='rgba(255,255,255,0.03)'; ctx.fillRect(px+((hv*173)%24|0),py+((hv*61)%24|0),4,2); }
switch(c){
case ',': { // grate
ctx.strokeStyle='rgba(0,0,0,0.4)'; ctx.lineWidth=2;
ctx.strokeRect(px+3,py+3,T-6,T-6);
ctx.beginPath();
ctx.moveTo(px+T/2,py+3);ctx.lineTo(px+T/2,py+T-3);
ctx.moveTo(px+3,py+T/2);ctx.lineTo(px+T-3,py+T/2);
ctx.stroke();
ctx.strokeStyle='rgba(200,150,90,0.12)'; ctx.lineWidth=1; ctx.strokeRect(px+4.5,py+4.5,T-9,T-9);
break; }
case '#': {
ctx.fillStyle=pal.wall; ctx.fillRect(px,py,T,T);
const up=y>0?Z.grid[y-1][x]:'#';
if(up!=='#'){ ctx.fillStyle=pal.wallTop; ctx.fillRect(px,py,T,10); ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.fillRect(px,py+10,T,2); }
ctx.strokeStyle=pal.grout; ctx.lineWidth=1.5;
ctx.strokeRect(px+0.5,py+0.5,T-1,T-1);
if(hv>0.5){ ctx.beginPath(); ctx.moveTo(px,py+T/2); ctx.lineTo(px+T,py+T/2); ctx.stroke(); }
if(pal.moss&&hv>0.62&&hv<0.8){ ctx.fillStyle='rgba(90,130,70,0.35)';
ctx.beginPath(); ctx.arc(px+8+hv*14,py+6+((hv*53)%18),3.4,0,7); ctx.fill(); }
if(pal.corrode&&hv<0.3){ ctx.fillStyle='rgba(20,14,26,0.6)';
ctx.beginPath(); ctx.arc(px+10+hv*30,py+10+((hv*71)%14),4+hv*3,0,7); ctx.fill(); }
if(pal.grate&&hv>0.7){ ctx.fillStyle='rgba(160,110,60,0.25)'; ctx.beginPath(); ctx.arc(px+8,py+8,1.6,0,7); ctx.arc(px+24,py+24,1.6,0,7); ctx.fill(); }
break; }
case 'o': { // chasm
ctx.fillStyle='#04060b'; ctx.fillRect(px,py,T,T);
const fl=hash2(x*3+7,y*5+2);
ctx.fillStyle='rgba(60,70,100,'+(0.05+fl*0.06)+')';
ctx.fillRect(px,py+fl*20,T,3);
// edge highlight where adjacent to walkable
ctx.strokeStyle='rgba(120,130,160,0.18)'; ctx.lineWidth=1;
if(y>0&&!SOLID.has(Z.grid[y-1][x])&&Z.grid[y-1][x]!=='o'){ctx.beginPath();ctx.moveTo(px,py+0.5);ctx.lineTo(px+T,py+0.5);ctx.stroke();}
break; }
case '~': {
const corrode=Z.zi===5;
ctx.fillStyle=corrode?'#1a1024':(pal.water||'#16333f'); ctx.fillRect(px,py,T,T);
const w1=Math.sin(t*1.6+x*0.9+y*0.4)*3, w2=Math.sin(t*1.1+x*0.5-y*0.7)*3;
ctx.strokeStyle=corrode?'rgba(255,120,50,0.22)':'rgba(140,210,230,0.22)';
ctx.lineWidth=1.6;
ctx.beginPath(); ctx.moveTo(px+2,py+10+w1); ctx.quadraticCurveTo(px+16,py+7+w1,px+30,py+11+w1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px+2,py+22+w2); ctx.quadraticCurveTo(px+16,py+25+w2,px+30,py+21+w2); ctx.stroke();
if(corrode&&hv>0.7){ ctx.fillStyle='rgba(255,140,60,'+(0.1+0.08*Math.sin(t*2+x))+')';
ctx.beginPath(); ctx.arc(px+16,py+16,5,0,7); ctx.fill(); }
break; }
case 'C': case 'c': case 'q': {
// cracked boulder / wall
const dmgState=Z.cracks[x+'_'+y];
const subtle=(c==='q'&&Z.zi===3&&!G.p.accEq.lantern);
ctx.fillStyle=pal.wall; ctx.fillRect(px,py,T,T);
const up2=y>0?Z.grid[y-1][x]:'#';
if(up2!=='#'&&up2!=='C'&&up2!=='c'&&up2!=='q'){ ctx.fillStyle=pal.wallTop; ctx.fillRect(px,py,T,9); }
ctx.strokeStyle=pal.grout; ctx.lineWidth=1.5; ctx.strokeRect(px+0.5,py+0.5,T-1,T-1);
// boulder face
ctx.fillStyle='rgba(0,0,0,0.22)';
ctx.beginPath(); ctx.moveTo(px+4,py+T-4); ctx.lineTo(px+9,py+8); ctx.lineTo(px+22,py+5); ctx.lineTo(px+T-4,py+14); ctx.lineTo(px+T-5,py+T-4); ctx.closePath(); ctx.fill();
// hairline cracks
const ca=subtle?0.28:0.75;
ctx.strokeStyle='rgba(15,12,10,'+ca+')'; ctx.lineWidth=1.2;
ctx.beginPath();
ctx.moveTo(px+7,py+5); ctx.lineTo(px+13,py+13); ctx.lineTo(px+10,py+18); ctx.lineTo(px+16,py+27);
ctx.moveTo(px+13,py+13); ctx.lineTo(px+22,py+15); ctx.lineTo(px+26,py+24);
if(dmgState!==undefined&&dmgState<6){ ctx.moveTo(px+18,py+6); ctx.lineTo(px+20,py+20); }
ctx.stroke();
if(c==='q'&&G.p.accEq.lantern){ // lantern reveals
ctx.strokeStyle='rgba(140,220,255,'+(0.5+0.3*Math.sin(t*3))+')'; ctx.lineWidth=1.6;
ctx.beginPath();
ctx.moveTo(px+7,py+5); ctx.lineTo(px+13,py+13); ctx.lineTo(px+10,py+18); ctx.lineTo(px+16,py+27);
ctx.moveTo(px+13,py+13); ctx.lineTo(px+22,py+15); ctx.lineTo(px+26,py+24);
ctx.stroke();
}
if(c==='c'){ ctx.fillStyle='rgba(255,90,110,'+(0.10+0.06*Math.sin(t*2.2+x))+')'; ctx.fillRect(px+2,py+2,T-4,T-4); }
break; }
case 'P': {
const pl=Z.plates.find(p=>p.x===x&&p.y===y);
const pressed=pl&&pl.held;
ctx.fillStyle=pressed?'#5a4a2e':'#8a6a3f';
ctx.beginPath(); ctx.moveTo(px+16,py+4); ctx.lineTo(px+28,py+16); ctx.lineTo(px+16,py+28); ctx.lineTo(px+4,py+16); ctx.closePath(); ctx.fill();
ctx.strokeStyle=pressed?'#f0b45a':'#c99a5a'; ctx.lineWidth=1.6; ctx.stroke();
ctx.fillStyle='rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.moveTo(px+16,py+9); ctx.lineTo(px+23,py+16); ctx.lineTo(px+16,py+23); ctx.lineTo(px+9,py+16); ctx.closePath(); ctx.fill();
if(pressed){ ctx.fillStyle='rgba(240,180,90,'+(0.25+0.15*Math.sin(t*6))+')'; ctx.fillRect(px+4,py+4,T-8,T-8); }
break; }
case 'D': {
const open=ZONES[Z.zi].doorFlag&&G.flags[ZONES[Z.zi].doorFlag];
if(open){
ctx.fillStyle='#0c0f18'; ctx.fillRect(px+2,py,T-4,T);
ctx.strokeStyle='rgba(240,180,90,0.5)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(px+3,py); ctx.lineTo(px+3,py+T); ctx.moveTo(px+T-3,py); ctx.lineTo(px+T-3,py+T); ctx.stroke();
ctx.fillStyle='rgba(240,180,90,'+(0.06+0.04*Math.sin(t*2))+')'; ctx.fillRect(px+4,py,T-8,T);
} else {
ctx.fillStyle='#6b5233'; ctx.fillRect(px,py,T,T);
ctx.strokeStyle='#3a2c1a'; ctx.lineWidth=2; ctx.strokeRect(px+1,py+1,T-2,T-2);
ctx.fillStyle='rgba(240,180,90,0.5)'; ctx.fillRect(px+T/2-1,py+2,2,T-4);
ctx.fillStyle='#8a6a3f'; ctx.beginPath(); ctx.arc(px+7,py+8,2.4,0,7); ctx.arc(px+25,py+8,2.4,0,7); ctx.arc(px+7,py+24,2.4,0,7); ctx.arc(px+25,py+24,2.4,0,7); ctx.fill();
ctx.fillStyle='rgba(140,220,255,'+(0.12+0.08*Math.sin(t*2.5))+')'; ctx.fillRect(px+T/2-1,py+2,2,T-4);
}
break; }
case '>': case '<': {
const ex=Z.exits.find(e=>e.x===x&&e.y===y);
const gated=ex&&ex.def&&ex.def.gate&&!G.flags[ex.def.gate];
ctx.fillStyle='#0c0f18'; ctx.fillRect(px+2,py,T-4,T);
ctx.strokeStyle=gated?'rgba(120,120,140,0.6)':'rgba(240,180,90,0.75)'; ctx.lineWidth=3;
ctx.beginPath(); ctx.moveTo(px+3,py+T); ctx.lineTo(px+3,py+6); ctx.quadraticCurveTo(px+16,py-4,px+T-3,py+6); ctx.lineTo(px+T-3,py+T); ctx.stroke();
if(gated){
ctx.strokeStyle='rgba(150,150,170,0.7)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(px+5,py+12); ctx.lineTo(px+27,py+20); ctx.moveTo(px+5,py+20); ctx.lineTo(px+27,py+12); ctx.stroke();
} else {
ctx.fillStyle='rgba(240,180,90,'+(0.10+0.07*Math.sin(t*2.4+x))+')'; ctx.fillRect(px+4,py+2,T-8,T-2);
if(Math.random()<0.05)spawnP({x:px+rnd(28,4),y:py+T-4,vx:0,vy:rnd(-16,-34),life:0.9,size:1.6,color:'#ffe9a8',grav:-4});
}
break; }
case 'S': drawSanctuary(px,py,t); break;
case 'M': drawMerchant(px,py,t,x,y); break;
case 'A': drawAltar(px,py,t,false); break;
case 'a': drawAltar(px,py,t,true); break;
case 'T': case '1': case '2': case '3': case '4': drawChest(px,py,t,c,false); break;
case 't': drawChest(px,py,t,'T',true); break;
case 'J': case 'V': case 'N': drawJar(px,py,t,c); break;
case 'X': {
const s=Z.switches.find(s=>s.x===x&&s.y===y);
drawSwitch(px,py,t,s&&s.on);
break; }
case 'H': drawHeartPickup(px,py,t); break;
case 'Q': drawShardPickup(px,py,t); break;
case 'K': drawBeacon(px,py,t); break;
}
}
function drawSanctuary(px,py,t){
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(px+16,py+29,13,4,0,0,7); ctx.fill();
ctx.fillStyle='#4e566e'; ctx.beginPath(); ctx.moveTo(px+5,py+30); ctx.lineTo(px+9,py+16); ctx.lineTo(px+23,py+16); ctx.lineTo(px+27,py+30); ctx.closePath(); ctx.fill();
ctx.fillStyle='#666f8c'; ctx.fillRect(px+8,py+13,16,4);
const gy=py+7+Math.sin(t*1.8)*1.6;
const gr=ctx.createRadialGradient(px+16,gy,1,px+16,gy,16);
gr.addColorStop(0,'rgba(255,233,168,0.8)'); gr.addColorStop(1,'rgba(255,233,168,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,gy,16,0,7); ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.beginPath(); ctx.moveTo(px+16,gy-7); ctx.lineTo(px+20,gy); ctx.lineTo(px+16,gy+7); ctx.lineTo(px+12,gy); ctx.closePath(); ctx.fill();
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.moveTo(px+16,gy-3.5); ctx.lineTo(px+18,gy); ctx.lineTo(px+16,gy+3.5); ctx.lineTo(px+14,gy); ctx.closePath(); ctx.fill();
}
function drawMerchant(px,py,t,x,y){
// 2x2 stall — draw counter + canopy per tile, banner on top-left tile
ctx.fillStyle='#4a3a28'; ctx.fillRect(px+1,py+10,T-2,T-11);
ctx.fillStyle='#5d4a33'; ctx.fillRect(px+1,py+10,T-2,5);
ctx.fillStyle='#7a2f2a'; ctx.fillRect(px+1,py,T-2,9);
ctx.fillStyle='#c9a15a'; ctx.fillRect(px+1+((x*7+y*13)%3)*9,py,7,9);
const gl=0.5+0.3*Math.sin(t*2+x+y);
ctx.fillStyle='rgba(255,200,110,'+gl+')';
ctx.beginPath(); ctx.arc(px+16,py+22,3,0,7); ctx.fill();
ctx.fillStyle='rgba(255,200,110,'+(gl*0.25)+')';
ctx.beginPath(); ctx.arc(px+16,py+22,8,0,7); ctx.fill();
}
function drawAltar(px,py,t,used){
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(px+16,py+29,14,4.5,0,0,7); ctx.fill();
ctx.fillStyle='#4e566e'; ctx.fillRect(px+4,py+18,24,12);
ctx.fillStyle='#666f8c'; ctx.fillRect(px+2,py+15,28,5);
ctx.fillStyle='rgba(240,180,90,0.4)';
ctx.fillRect(px+7,py+21,3,6); ctx.fillRect(px+14,py+21,3,6); ctx.fillRect(px+21,py+21,3,6);
const col=Z.zi===4?'#ffd76a':'#ff8c42';
const by=py+7+Math.sin(t*1.5)*2;
if(!used){
const gr=ctx.createRadialGradient(px+16,by,1,px+16,by,15);
gr.addColorStop(0,col+'cc'); gr.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,by,15,0,7); ctx.fill();
// floating blade
ctx.save(); ctx.translate(px+16,by); ctx.rotate(Math.sin(t*0.8)*0.15);
ctx.fillStyle='#cfd6e4'; ctx.fillRect(-1.6,-9,3.2,15);
ctx.fillStyle=col; ctx.fillRect(-1.6,-9,3.2,15);
ctx.globalAlpha=0.85; ctx.fillStyle=col; ctx.fillRect(-1.6,-9,3.2,15); ctx.globalAlpha=1;
ctx.fillStyle='#8a6a3f'; ctx.fillRect(-5,6,10,2.4); ctx.fillRect(-1.4,8,2.8,5);
ctx.restore();
if(Math.random()<0.08)spawnP({x:px+rnd(28,4),y:py+24,vx:rnd(6,-6),vy:rnd(-20,-40),life:0.8,size:1.7,color:col,grav:-10});
} else {
ctx.save(); ctx.translate(px+16,py+10); ctx.rotate(0.5);
ctx.fillStyle='#5a6272'; ctx.fillRect(-1.4,-6,2.8,12);
ctx.fillStyle='#3e4453'; ctx.fillRect(-4,6,8,2);
ctx.restore();
}
}
function drawChest(px,py,t,c,opened){
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(px+16,py+27,12,3.6,0,0,7); ctx.fill();
const glowCol=c==='1'?'#cfd6e4':c==='2'?'#8f7aff':c==='3'?'#ffd76a':c==='4'?'#7dff9a':'#f0b45a';
ctx.fillStyle='#5d4a33'; ctx.fillRect(px+4,py+14,24,13);
ctx.fillStyle='#7a5f3f'; ctx.fillRect(px+4,py+8,24,7);
ctx.strokeStyle='#3a2c1a'; ctx.lineWidth=1.4; ctx.strokeRect(px+4,py+8,24,19);
ctx.fillStyle='#8a6a3f'; ctx.fillRect(px+4,py+15.5,24,2);
if(opened){
ctx.fillStyle='#241a10'; ctx.fillRect(px+6,py+10,20,6);
ctx.save(); ctx.translate(px+4,py+8); ctx.rotate(-0.7);
ctx.fillStyle='#6b5238'; ctx.fillRect(0,-5,24,6); ctx.restore();
} else {
ctx.fillStyle=glowCol;
ctx.beginPath(); ctx.arc(px+16,py+16.5,2.6,0,7); ctx.fill();
const g=0.25+0.2*Math.sin(t*2.4+px);
ctx.fillStyle=glowCol; ctx.globalAlpha=g;
ctx.beginPath(); ctx.arc(px+16,py+16.5,7,0,7); ctx.fill(); ctx.globalAlpha=1;
if(c!=='T'&&Math.random()<0.04)spawnP({x:px+16,y:py+14,vx:rnd(8,-8),vy:rnd(-14,-30),life:0.8,size:1.6,color:glowCol,grav:-6});
}
}
function drawJar(px,py,t,c){
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(px+16,py+27,9,3,0,0,7); ctx.fill();
ctx.fillStyle='#9a6b3f';
ctx.beginPath(); ctx.moveTo(px+11,py+10); ctx.quadraticCurveTo(px+6,py+20,px+11,py+27); ctx.lineTo(px+21,py+27);
ctx.quadraticCurveTo(px+26,py+20,px+21,py+10); ctx.closePath(); ctx.fill();
ctx.fillStyle='#7c5227'; ctx.fillRect(px+10,py+7,12,4);
ctx.strokeStyle='rgba(50,30,15,0.6)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(px+11,py+17); ctx.quadraticCurveTo(px+16,py+19,px+21,py+17); ctx.stroke();
if(c==='V'){ const g=0.35+0.25*Math.sin(t*2.6+px); ctx.fillStyle='rgba(125,255,154,'+g+')'; ctx.beginPath(); ctx.arc(px+16,py+18,4,0,7); ctx.fill(); }
if(c==='N'){ const g=0.35+0.25*Math.sin(t*2.2+py); ctx.fillStyle='rgba(122,184,255,'+g+')'; ctx.beginPath(); ctx.arc(px+16,py+18,4,0,7); ctx.fill(); }
}
function drawSwitch(px,py,t,on){
const by=py+14+Math.sin(t*2)*2.4;
if(on){
const gr=ctx.createRadialGradient(px+16,by,1,px+16,by,17);
gr.addColorStop(0,'rgba(143,227,255,0.75)'); gr.addColorStop(1,'rgba(143,227,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,by,17,0,7); ctx.fill();
}
ctx.save(); ctx.translate(px+16,by); ctx.rotate(t*(on?1.4:0.5));
ctx.fillStyle=on?'#8fe3ff':'#5a5468';
ctx.beginPath(); ctx.moveTo(0,-8); ctx.lineTo(5.5,0); ctx.lineTo(0,8); ctx.lineTo(-5.5,0); ctx.closePath(); ctx.fill();
ctx.fillStyle=on?'#e8fbff':'#77708a';
ctx.beginPath(); ctx.moveTo(0,-4); ctx.lineTo(2.6,0); ctx.lineTo(0,4); ctx.lineTo(-2.6,0); ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawHeartPickup(px,py,t){
const by=py+14+Math.sin(t*2.2)*3;
const gr=ctx.createRadialGradient(px+16,by,2,px+16,by,20);
gr.addColorStop(0,'rgba(255,120,140,0.5)'); gr.addColorStop(1,'rgba(255,120,140,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,by,20,0,7); ctx.fill();
ctx.save(); ctx.translate(px+16,by); ctx.scale(0.85,0.85);
heartPath(ctx); ctx.fillStyle='#e0455f'; ctx.fill();
ctx.strokeStyle='#ffd76a'; ctx.lineWidth=1.6; ctx.stroke();
ctx.restore();
if(Math.random()<0.07)spawnP({x:px+16,y:by,vx:rnd(10,-10),vy:rnd(-16,-36),life:0.9,size:1.8,color:'#ff9aaa',grav:-8});
}
function heartPath(c2){
c2.beginPath();
c2.moveTo(0,9);
c2.bezierCurveTo(-10,2,-9,-8,-3.5,-8);
c2.bezierCurveTo(-1,-8,0,-5.5,0,-4);
c2.bezierCurveTo(0,-5.5,1,-8,3.5,-8);
c2.bezierCurveTo(9,-8,10,2,0,9);
c2.closePath();
}
function drawShardPickup(px,py,t){
const by=py+14+Math.sin(t*1.9)*3;
const gr=ctx.createRadialGradient(px+16,by,2,px+16,by,22);
gr.addColorStop(0,'rgba(143,227,255,0.55)'); gr.addColorStop(1,'rgba(143,227,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,by,22,0,7); ctx.fill();
ctx.save(); ctx.translate(px+16,by);
for(let i=0;i<3;i++){
ctx.save(); ctx.rotate(i*2.1+t*0.6); ctx.translate(4,0);
ctx.fillStyle=i===0?'#d8f4ff':'#8fe3ff';
ctx.beginPath(); ctx.moveTo(0,-6); ctx.lineTo(3.4,0); ctx.lineTo(0,6); ctx.lineTo(-3.4,0); ctx.closePath(); ctx.fill();
ctx.restore();
}
ctx.restore();
if(Math.random()<0.08)spawnP({x:px+16,y:by,vx:rnd(12,-12),vy:rnd(-14,-34),life:0.9,size:1.8,color:'#8fe3ff',grav:-8});
}
function drawBeacon(px,py,t){
const lit=!!G.flags.bossDown;
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(px+16,py+30,15,5,0,0,7); ctx.fill();
ctx.fillStyle='#3a3340'; ctx.fillRect(px+6,py+8,20,22);
ctx.fillStyle='#4e4557'; ctx.fillRect(px+3,py+4,26,6);
ctx.fillStyle='#2a2430'; ctx.fillRect(px+10,py+13,12,14);
if(lit){
const g=0.6+0.3*Math.sin(t*3);
const gr=ctx.createRadialGradient(px+16,py+2,2,px+16,py+2,34);
gr.addColorStop(0,'rgba(255,220,140,'+g+')'); gr.addColorStop(1,'rgba(255,180,80,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(px+16,py+2,34,0,7); ctx.fill();
ctx.fillStyle='rgba(255,230,160,0.5)';
ctx.beginPath(); ctx.moveTo(px+10,py+4); ctx.lineTo(px+16,py-26-Math.sin(t*4)*4); ctx.lineTo(px+22,py+4); ctx.closePath(); ctx.fill();
if(Math.random()<0.3)spawnP({x:px+16+rnd(8,-8),y:py,vx:rnd(10,-10),vy:rnd(-40,-80),life:1.1,size:2,color:'#ffe9a8',grav:-10});
} else {
ctx.fillStyle='rgba(120,60,160,'+(0.2+0.12*Math.sin(t*1.6))+')';
ctx.fillRect(px+10,py+13,12,14);
ctx.strokeStyle='rgba(30,20,40,0.8)'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(px+8,py+10); ctx.lineTo(px+14,py+18); ctx.lineTo(px+11,py+24); ctx.stroke();
}
}
function drawEnemy(e,t){
const fl=e.flinch>0?Math.sin(e.flinch*60)*2.5:0;
const x=e.x+fl, y=e.y;
const walk=Math.sin(e.t*9)*((Math.abs(e.vx||0)+Math.abs(e.vy||0))>1?1:0.3);
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(x,y+e.r*0.9,e.r*0.95,e.r*0.36,0,0,7); ctx.fill();
ctx.save(); ctx.translate(x,y);
if(e.frozen>0){ ctx.globalAlpha=0.92; }
const burnTint=e.burn>0;
switch(e.type){
case 'husk': {
const b=Math.sin(e.t*7)*1.4;
ctx.fillStyle='#514238'; ctx.fillRect(-6,4+b*0.3,4,8); ctx.fillRect(2,4-b*0.3,4,8); // legs
ctx.fillStyle=burnTint?'#8a5a3a':'#6b5a4a';
ctx.beginPath(); ctx.roundRect?ctx.roundRect(-8,-8+b,16,15,3):ctx.rect(-8,-8+b,16,15); ctx.fill(); // torso
ctx.fillStyle='#54463a'; ctx.fillRect(-9,-7+b,3,7); ctx.fillRect(6,-7+b,3,7); // pauldrons
ctx.fillStyle='#5c4c3e'; ctx.beginPath(); ctx.arc(0,-12+b,6,0,7); ctx.fill(); // helm
ctx.fillStyle='#3a2f26'; ctx.fillRect(-4.5,-13.4+b,9,3); // visor
ctx.fillStyle='#ff8c42'; ctx.fillRect(-3.4,-12.8+b,2.2,1.8); ctx.fillRect(1.2,-12.8+b,2.2,1.8); // eyes
ctx.fillStyle='#8a8f9c'; ctx.save(); ctx.rotate(0.5+walk*0.2); ctx.fillRect(8,-2+b,2.4,10); ctx.restore(); // blade
break; }
case 'sentinel': {
const b=Math.sin(e.t*3)*2.4;
ctx.fillStyle='#414a63';
ctx.beginPath(); ctx.moveTo(-6,10+b); ctx.lineTo(-4,-8+b); ctx.lineTo(4,-8+b); ctx.lineTo(6,10+b); ctx.closePath(); ctx.fill();
ctx.strokeStyle='rgba(143,190,255,0.5)'; ctx.lineWidth=1.4;
ctx.save(); ctx.translate(0,b); ctx.rotate(e.t*1.6);
ctx.beginPath(); ctx.ellipse(0,-2,11,4,0,0,7); ctx.stroke(); ctx.restore(); // instrument ring
ctx.fillStyle='#333b52'; ctx.beginPath(); ctx.arc(0,-12+b,5.4,0,7); ctx.fill();
const g=0.6+0.3*Math.sin(e.t*4);
ctx.fillStyle='rgba(120,190,255,'+g+')'; ctx.beginPath(); ctx.arc(0,-12+b,2.6,0,7); ctx.fill(); // lens
ctx.fillStyle='rgba(120,190,255,'+(g*0.3)+')'; ctx.beginPath(); ctx.arc(0,-12+b,5.5,0,7); ctx.fill();
break; }
case 'brute': {
const b=Math.sin(e.t*5)*1.6;
ctx.fillStyle='#3e3a46'; ctx.fillRect(-9,5,7,9); ctx.fillRect(2,5,7,9);
ctx.fillStyle=burnTint?'#5e4a40':'#4a4550';
ctx.beginPath(); ctx.roundRect?ctx.roundRect(-13,-11+b,26,19,4):ctx.rect(-13,-11+b,26,19); ctx.fill();
// gear shoulders
ctx.strokeStyle='#6b6474'; ctx.lineWidth=2.4;
ctx.beginPath(); ctx.arc(-13,-8+b,5.5,0,7); ctx.stroke();
ctx.beginPath(); ctx.arc(13,-8+b,5.5,0,7); ctx.stroke();
ctx.fillStyle='#ff7a3a';
ctx.fillRect(-7,-4+b,2.6,8); ctx.fillRect(1,-6+b,2.6,10); ctx.fillRect(6,-2+b,2.6,6); // corrosion cracks
ctx.fillStyle='#332f3a'; ctx.beginPath(); ctx.arc(0,-16+b,7,0,7); ctx.fill();
ctx.fillStyle='#ff5a2a'; ctx.fillRect(-4.6,-17.6+b,3,2.2); ctx.fillRect(1.6,-17.6+b,3,2.2);
break; }
case 'guardian': {
const b=Math.sin(e.t*4)*1.8;
// cape
ctx.fillStyle='#232a44';
ctx.beginPath(); ctx.moveTo(-12,-10+b);
ctx.quadraticCurveTo(-16+Math.sin(e.t*3)*3,6,-10,14); ctx.lineTo(10,14);
ctx.quadraticCurveTo(16+Math.sin(e.t*3+2)*3,6,12,-10+b); ctx.closePath(); ctx.fill();
ctx.fillStyle='#514238'; ctx.fillRect(-7,5,5,9); ctx.fillRect(2,5,5,9);
ctx.fillStyle='#2e3550';
ctx.beginPath(); ctx.roundRect?ctx.roundRect(-10,-10+b,20,18,4):ctx.rect(-10,-10+b,20,18); ctx.fill();
ctx.strokeStyle='#ffd76a'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(-10,-4+b); ctx.lineTo(10,-4+b); ctx.moveTo(0,-10+b); ctx.lineTo(0,8+b); ctx.stroke();
ctx.fillStyle='#262c46'; ctx.beginPath(); ctx.arc(0,-15+b,7,0,7); ctx.fill();
ctx.fillStyle='#ffd76a'; ctx.fillRect(-4,-16.6+b,3,2.2); ctx.fillRect(1,-16.6+b,3,2.2);
// twin blades
ctx.fillStyle='#cfd6e4';
ctx.save(); ctx.rotate(0.55+walk*0.25); ctx.fillRect(10,-14+b,2.6,17); ctx.restore();
ctx.save(); ctx.rotate(-0.55-walk*0.25); ctx.fillRect(-12.6,-14+b,2.6,17); ctx.restore();
break; }
case 'boss': {
const b=Math.sin(e.t*2.2)*2.6;
// corroded mass
ctx.fillStyle='#1d1822';
ctx.beginPath(); ctx.arc(0,b,20,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(-13,6+b,11,0,7); ctx.arc(13,6+b,11,0,7); ctx.arc(0,-14+b,12,0,7); ctx.fill();
// veins
ctx.strokeStyle='rgba(255,110,40,'+(0.5+0.3*Math.sin(e.t*3))+')'; ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(-14,-4+b); ctx.quadraticCurveTo(-4,2+b,2,-8+b);
ctx.moveTo(6,10+b); ctx.quadraticCurveTo(12,2+b,16,8+b);
ctx.moveTo(-2,-18+b); ctx.quadraticCurveTo(4,-12+b,10,-16+b);
ctx.stroke();
// orbiting shards
for(let i=0;i<4;i++){
const a=e.t*1.3+i*Math.PI/2;
ctx.fillStyle='#3a3340';
ctx.save(); ctx.translate(Math.cos(a)*27,Math.sin(a)*13+b); ctx.rotate(a*2);
ctx.fillRect(-3,-3,6,6); ctx.restore();
}
// eyes
ctx.fillStyle='#ff5a2a';
ctx.beginPath(); ctx.arc(-6,-4+b,3,0,7); ctx.arc(6,-4+b,3,0,7); ctx.fill();
ctx.fillStyle='rgba(255,90,42,0.3)';
ctx.beginPath(); ctx.arc(-6,-4+b,6,0,7); ctx.arc(6,-4+b,6,0,7); ctx.fill();
if(e.shielded){
const g=0.22+0.1*Math.sin(e.t*4);
ctx.strokeStyle='rgba(180,143,255,'+(g*2.2)+')'; ctx.lineWidth=2.4;
ctx.beginPath(); ctx.arc(0,b,30+Math.sin(e.t*2.6)*2,0,7); ctx.stroke();
ctx.fillStyle='rgba(150,110,255,'+g+')';
ctx.beginPath(); ctx.arc(0,b,30,0,7); ctx.fill();
for(let i=0;i<6;i++){ const a=e.t*0.8+i*1.047;
ctx.strokeStyle='rgba(200,170,255,0.25)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(Math.cos(a)*30,Math.sin(a)*30+b); ctx.lineTo(Math.cos(a+1.047)*30,Math.sin(a+1.047)*30+b); ctx.stroke(); }
}
break; }
}
if(e.frozen>0){
ctx.fillStyle='rgba(150,210,255,0.3)';
ctx.beginPath(); ctx.arc(0,0,e.r+5,0,7); ctx.fill();
ctx.strokeStyle='rgba(220,245,255,0.8)'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(-e.r,-e.r); ctx.lineTo(-e.r*0.4,0); ctx.lineTo(-e.r,e.r*0.7);
ctx.moveTo(e.r,-e.r*0.8); ctx.lineTo(e.r*0.3,-2); ctx.lineTo(e.r,e.r*0.6); ctx.stroke();
}
ctx.restore();
// hp bars for bosses
if((e.type==='guardian'||e.type==='boss')&&e.hp<e.maxHp){
const bw=e.type==='boss'?70:52;
ctx.fillStyle='rgba(10,10,16,0.75)'; ctx.fillRect(x-bw/2-1,y-e.r-22,bw+2,7);
ctx.fillStyle=e.type==='boss'?'#b4533a':'#c9873a';
ctx.fillRect(x-bw/2,y-e.r-21,bw*(e.hp/e.maxHp),5);
ctx.strokeStyle='rgba(240,180,90,0.5)'; ctx.lineWidth=1; ctx.strokeRect(x-bw/2-1.5,y-e.r-22.5,bw+3,8);
ctx.fillStyle='#e8dcc3'; ctx.font='9px Georgia'; ctx.textAlign='center';
ctx.fillText(e.type==='boss'?'THE CORRODED HEART':'SPIRE GUARDIAN',x,y-e.r-26);
}
}
function drawPlayer(t){
const p=G.p;
const blink=p.invuln>0&&Math.floor(t*14)%2===0;
ctx.save();
ctx.globalAlpha=blink?0.45:1;
// shadow
ctx.fillStyle='rgba(0,0,0,0.4)';
ctx.beginPath(); ctx.ellipse(p.x,p.y+11,10,3.6,0,0,7); ctx.fill();
const walk=p.moving?Math.sin(t*13):0;
const b=p.moving?Math.abs(Math.sin(t*13))*1.6:Math.sin(t*2)*0.7;
const fx=p.face.x, fy=p.face.y;
const flip=fx<0?-1:1;
ctx.save(); ctx.translate(p.x,p.y);
// feet
ctx.fillStyle='#2a2438';
ctx.fillRect(-5.5,7+walk*1.6,4.4,5); ctx.fillRect(1.2,7-walk*1.6,4.4,5);
// cloak body
const cloakDark=p.accEq.cloak;
const grd=ctx.createLinearGradient(0,-14-b,0,10);
grd.addColorStop(0,cloakDark?'#2c3454':'#3a4563');
grd.addColorStop(1,cloakDark?'#171d33':'#28304a');
ctx.fillStyle=grd;
ctx.beginPath();
ctx.moveTo(-8,-8-b); ctx.quadraticCurveTo(-10,4, -7.5+Math.sin(t*3)*0.6,10);
ctx.lineTo(7.5+Math.sin(t*3+1)*0.6,10); ctx.quadraticCurveTo(10,4,8,-8-b);
ctx.quadraticCurveTo(0,-13-b,-8,-8-b);
ctx.closePath(); ctx.fill();
// hem trim
ctx.strokeStyle=p.weapon>=2?'rgba(255,215,106,0.85)':cloakDark?'#5a4a8f':'rgba(240,180,90,0.55)';
ctx.lineWidth=1.6;
ctx.beginPath(); ctx.moveTo(-7.5,9.4); ctx.lineTo(7.5,9.4); ctx.stroke();
if(p.weapon>=2){ // shimmering cloak edges
ctx.strokeStyle='rgba(255,240,180,'+(0.4+0.3*Math.sin(t*5))+')';
ctx.beginPath(); ctx.moveTo(-8.6,-6-b); ctx.quadraticCurveTo(-10.5,3,-8,9); ctx.stroke();
ctx.beginPath(); ctx.moveTo(8.6,-6-b); ctx.quadraticCurveTo(10.5,3,8,9); ctx.stroke();
}
// hood + face
ctx.fillStyle=cloakDark?'#232a44':'#313b58';
ctx.beginPath(); ctx.arc(0,-13-b,7.2,0,7); ctx.fill();
ctx.fillStyle='#141824';
ctx.beginPath(); ctx.arc(flip*1.4,-12.6-b,4.6,0,7); ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.fillRect(flip*1.4-2.6,-13.4-b,1.8,1.6); ctx.fillRect(flip*1.4+0.9,-13.4-b,1.8,1.6);
// shoulder clasp for cloak accessory
if(p.accEq.cloak){ ctx.fillStyle='#8f7aff'; ctx.beginPath(); ctx.arc(-6,-8-b,1.8,0,7); ctx.fill(); }
// warden's band — silver ring orbiting wrist
if(p.accEq.band){
const a=t*3.4;
ctx.strokeStyle='#e8f0ff'; ctx.lineWidth=1.6;
ctx.beginPath(); ctx.arc(-flip*8+Math.cos(a)*2.4,2-b+Math.sin(a)*2.4,2.6,0,7); ctx.stroke();
ctx.fillStyle='rgba(232,240,255,'+(0.4+0.3*Math.sin(t*6))+')';
ctx.beginPath(); ctx.arc(-flip*8+Math.cos(a)*2.4,2-b+Math.sin(a)*2.4,1.2,0,7); ctx.fill();
}
// phantom lantern — floating blue orb
if(p.accEq.lantern){
const ly=-18-b+Math.sin(t*2.4)*2;
const gr=ctx.createRadialGradient(-flip*10,ly,1,-flip*10,ly,12);
gr.addColorStop(0,'rgba(140,200,255,0.8)'); gr.addColorStop(1,'rgba(140,200,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(-flip*10,ly,12,0,7); ctx.fill();
ctx.fillStyle='#bfe0ff'; ctx.beginPath(); ctx.arc(-flip*10,ly,3,0,7); ctx.fill();
}
// off-hand aether crystal
if(p.shard){
const ox=-flip*9, oy=1-b+Math.sin(t*3)*1.2;
const gr=ctx.createRadialGradient(ox,oy,1,ox,oy,9);
gr.addColorStop(0,'rgba(143,227,255,'+(0.5+0.25*Math.sin(t*4))+')'); gr.addColorStop(1,'rgba(143,227,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(ox,oy,9,0,7); ctx.fill();
ctx.save(); ctx.translate(ox,oy); ctx.rotate(t*1.2);
ctx.fillStyle='#8fe3ff';
ctx.beginPath(); ctx.moveTo(0,-4.4); ctx.lineTo(2.8,0); ctx.lineTo(0,4.4); ctx.lineTo(-2.8,0); ctx.closePath(); ctx.fill();
ctx.restore();
}
// weapon
const w=WEAPONS[p.weapon];
const ang=Math.atan2(p.atkAnim>0?p.atkDir.y:fy,p.atkAnim>0?p.atkDir.x:fx);
const wide=p.weapon===2;
let bladeAng;
if(p.atkAnim>0){
const k=1-p.atkAnim/0.24;
bladeAng=ang-w.arc/2+w.arc*k;
// arc trail
ctx.save();
ctx.strokeStyle=w.color; ctx.globalAlpha=0.5*(1-k)+0.2;
ctx.lineWidth=wide?7:4.5;
ctx.beginPath(); ctx.arc(0,0,w.range*0.78,ang-w.arc/2,bladeAng); ctx.stroke();
ctx.globalAlpha=1; ctx.restore();
} else {
bladeAng=ang+0.85+Math.sin(t*2)*0.06;
}
ctx.save();
ctx.rotate(bladeAng);
const hx=9, len=wide?26:p.weapon===1?21:18;
// hand
ctx.fillStyle='#c9bfa6'; ctx.beginPath(); ctx.arc(hx,0,2.4,0,7); ctx.fill();
// hilt
ctx.fillStyle='#8a6a3f'; ctx.fillRect(hx,-1.7,4,3.4);
// blade with glow
ctx.shadowColor=w.glow; ctx.shadowBlur=p.weapon===0?4:12;
ctx.fillStyle=w.color;
if(wide){
ctx.beginPath(); ctx.moveTo(hx+4,-2.6); ctx.quadraticCurveTo(hx+len*0.7,-4.4,hx+len,0);
ctx.quadraticCurveTo(hx+len*0.7,4.4,hx+4,2.6); ctx.closePath(); ctx.fill();
} else {
ctx.beginPath(); ctx.moveTo(hx+3,-2); ctx.lineTo(hx+len-3,-2); ctx.lineTo(hx+len,0); ctx.lineTo(hx+len-3,2); ctx.lineTo(hx+3,2); ctx.closePath(); ctx.fill();
}
ctx.shadowBlur=0;
if(p.weapon===2){ // golden halo
ctx.strokeStyle='rgba(255,240,176,'+(0.35+0.2*Math.sin(t*5))+')'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.arc(hx+len*0.55,0,len*0.62,0,7); ctx.stroke();
}
ctx.restore();
// hurt tint
if(p.hurtT>0){
ctx.globalAlpha=p.hurtT*1.6;
ctx.fillStyle='#e0455f';
ctx.beginPath(); ctx.arc(0,-4-b,12,0,7); ctx.fill();
ctx.globalAlpha=1;
}
ctx.restore();
ctx.restore();
}
function drawCoin(c,t){
if(c.type==='ammo'){
ctx.save(); ctx.translate(c.x,c.y); ctx.rotate(t*3);
ctx.fillStyle='#8fe3ff';
ctx.beginPath(); ctx.moveTo(0,-4); ctx.lineTo(2.6,0); ctx.lineTo(0,4); ctx.lineTo(-2.6,0); ctx.closePath(); ctx.fill();
ctx.restore();
return;
}
ctx.fillStyle='rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(c.x,c.y+4,3.4,1.4,0,0,7); ctx.fill();
const sq=Math.abs(Math.sin(t*6+c.x));
ctx.fillStyle='#f0b45a';
ctx.beginPath(); ctx.ellipse(c.x,c.y,3.4*sq+0.8,3.6,0,0,7); ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.beginPath(); ctx.ellipse(c.x-0.8*sq,c.y-0.8,1.5*sq+0.3,1.6,0,0,7); ctx.fill();
}
function drawBolt(b,t){
ctx.save(); ctx.translate(b.x,b.y);
if(b.friendly){
ctx.rotate(Math.atan2(b.vy,b.vx));
ctx.shadowColor='#8fe3ff'; ctx.shadowBlur=10;
ctx.fillStyle='#d8f4ff';
ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-4,2.6); ctx.lineTo(-2,0); ctx.lineTo(-4,-2.6); ctx.closePath(); ctx.fill();
} else if(b.corrode){
ctx.shadowColor='#8f5aff'; ctx.shadowBlur=9;
ctx.fillStyle='#6a4a8f';
ctx.beginPath(); ctx.arc(0,0,4.4,0,7); ctx.fill();
ctx.fillStyle='#b48fff'; ctx.beginPath(); ctx.arc(0,0,2,0,7); ctx.fill();
} else {
ctx.shadowColor='#ff8c42'; ctx.shadowBlur=9;
ctx.fillStyle='#ff8c42';
ctx.beginPath(); ctx.arc(0,0,3.6,0,7); ctx.fill();
ctx.fillStyle='#ffe0b0'; ctx.beginPath(); ctx.arc(0,0,1.6,0,7); ctx.fill();
}
ctx.shadowBlur=0; ctx.restore();
}
function renderLight(shx,shy){
const dark=ZONES[Z.zi].dark;
if(dark<0.03)return;
lctx.setTransform(1,0,0,1,0,0);
lctx.globalCompositeOperation='source-over';
lctx.clearRect(0,0,VW,VH);
lctx.fillStyle='rgba(5,8,16,'+dark+')';
lctx.fillRect(0,0,VW,VH);
lctx.globalCompositeOperation='destination-out';
const L=(wx,wy,r,str)=>{
const sx=wx-camX+shx, sy=wy-camY+shy;
if(sx<-r||sy<-r||sx>VW+r||sy>VH+r)return;
const gr=lctx.createRadialGradient(sx,sy,r*0.12,sx,sy,r);
gr.addColorStop(0,'rgba(255,255,255,'+str+')');
gr.addColorStop(0.55,'rgba(255,255,255,'+(str*0.5)+')');
gr.addColorStop(1,'rgba(255,255,255,0)');
lctx.fillStyle=gr;
lctx.beginPath(); lctx.arc(sx,sy,r,0,7); lctx.fill();
};
const p=G.p;
L(p.x,p.y,p.accEq.lantern?235:150,0.95);
const x0=Math.max(0,Math.floor(camX/T)-1),y0=Math.max(0,Math.floor(camY/T)-1);
const x1=Math.min(Z.w-1,Math.ceil((camX+VW)/T)+1),y1=Math.min(Z.h-1,Math.ceil((camY+VH)/T)+1);
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
const c=Z.grid[y][x], cx=x*T+16, cy=y*T+16;
if(c==='S')L(cx,cy,125,0.85);
else if(c==='A')L(cx,cy,105,0.8);
else if(c==='M')L(cx,cy,95,0.75);
else if(c==='K')L(cx,cy,G.flags.bossDown?210:60,G.flags.bossDown?0.95:0.4);
else if(c==='X'){ const s=Z.switches.find(s=>s.x===x&&s.y===y); if(s&&s.on)L(cx,cy,85,0.8); else L(cx,cy,40,0.35); }
else if(c==='H')L(cx,cy,70,0.7);
else if(c==='Q')L(cx,cy,80,0.8);
else if(c==='D'){ const open=ZONES[Z.zi].doorFlag&&G.flags[ZONES[Z.zi].doorFlag]; if(open)L(cx,cy,60,0.5); }
else if(c==='>'||c==='<')L(cx,cy,65,0.55);
else if(c==='~'&&Z.zi===5)L(cx,cy,44,0.3);
}
for(const b of Z.bolts)L(b.x,b.y,b.friendly?70:55,0.7);
for(const e of Z.enemies){ if(e.type==='boss'||e.type==='guardian')L(e.x,e.y,80,0.5); if(e.type==='sentinel')L(e.x,e.y,50,0.4); }
ctx.drawImage(lcv,0,0);
}
/* ================= HUD ================= */
const HEART_PATH='<path d="M12 21.2S3.8 16.3 1.9 11.6C0.5 8.2 2.6 4.8 6.1 4.8c2.2 0 4.3 1.4 5.9 3.6 1.6-2.2 3.7-3.6 5.9-3.6 3.5 0 5.6 3.4 4.2 6.8-1.9 4.7-10 9.4-10 9.4z"/>';
function updateHUD(){
if(!G)return;
const p=G.p;
const hc=$('hearts');
if(hc.childElementCount!==p.maxC){
hc.innerHTML='';
for(let i=0;i<p.maxC;i++){
const d=document.createElement('div'); d.className='heart';
d.innerHTML='<svg class="bg" viewBox="0 0 24 24">'+HEART_PATH+'</svg><svg class="fg" viewBox="0 0 24 24">'+HEART_PATH+'</svg>';
hc.appendChild(d);
}
}
for(let i=0;i<p.maxC;i++){
const v=p.hp-i*2;
const fg=hc.children[i]&&hc.children[i].querySelector('.fg');
if(fg) fg.style.clipPath = v>=2?'inset(0 0 0 0)':(v===1?'inset(0 50% 0 0)':'inset(0 100% 0 0)');
}
$('goldN').textContent=p.gold;
$('ammoBox').style.display=p.shard?'flex':'none';
$('ammoN').textContent=p.ammo;
$('weaponBox').textContent='⚔ '+WEAPONS[p.weapon].name+(p.shard?' · ◆ Aether Shard':'');
const ab=$('accBox'); let html='';
if(p.accEq.band) html+='<div class="accIcon" title="Warden\u2019s Band — attacks strike quicker" style="color:#e8f0ff">○</div>';
if(p.accEq.lantern) html+='<div class="accIcon" title="Phantom Lantern — reveals hidden cracks, lights the dark" style="color:#9fd0ff">✦</div>';
if(p.accEq.cloak) html+='<div class="accIcon" title="Shadow Cloak — corrosion and traps cannot bite" style="color:#b0a4e8">◈</div>';
if(p.items.stem>0) html+='<div class="accIcon" title="Vital Stem ×'+p.items.stem+' — press 1" style="color:#8dffaa">🌿</div>';
if(p.items.seal>0) html+='<div class="accIcon" title="Binding Seal ×'+p.items.seal+' — press 2" style="color:#8fc4ff">❄</div>';
ab.innerHTML=html;
}
/* ================= SHOP ================= */
const SHOP=[
{key:'stem', ico:'🌿', name:'Vital Stem', price:50, stock:3, desc:'Restores 4 HP — two full hearts of glowing green sap.'},
{key:'seal', ico:'❄', name:'Binding Seal', price:75, stock:2, desc:'Freezes one construct in place for 3 seconds. Cold blue light.'},
{key:'heart', ico:'❤', name:'Heart Container', price:300, stock:1, desc:'The merchant\u2019s masterpiece: a fifth vessel of vitality (+2 max HP).'},
{key:'ammo', ico:'◆', name:'Aether Charge', price:10, stock:Infinity, desc:'One crystal bolt for the Aether Shard. (Stocked after the Vault\u2019s secret is claimed.)'}
];
function openShop(){ paused=true; renderShop(); $('shopModal').classList.remove('hidden'); }
function renderShop(){
const p=G.p;
$('shopGoldN').textContent=p.gold;
const box=$('shopRows'); box.innerHTML='';
SHOP.forEach(it=>{
if(it.key==='ammo'&&!p.shard)return;
if(it.key==='heart'&&p.maxC>=5)return;
const sold=G.shop[it.key]||0;
const left=it.stock-sold;
if(left<=0)return;
const row=document.createElement('div'); row.className='row';
row.innerHTML='<div class="ico">'+it.ico+'</div><div class="info"><div class="nm">'+it.name+
(it.stock!==Infinity?' <span class="soldTag">('+left+' left)</span>':'')+'</div><div class="ds">'+it.desc+'</div></div>'+
'<div class="price">'+it.price+' ⛁</div>';
const b=document.createElement('button'); b.className='buyBtn'; b.textContent='Buy';
b.disabled=p.gold<it.price;
b.onclick=()=>buyItem(it);
row.appendChild(b); box.appendChild(row);
});
if(!box.children.length) box.innerHTML='<div class="closeNote">The merchant\u2019s shelves are bare. "Come back richer, Warden."</div>';
}
function buyItem(it){
const p=G.p;
if(p.gold<it.price){ AU.play('deny'); return; }
p.gold-=it.price; G.shop[it.key]=(G.shop[it.key]||0)+1;
AU.play('buy');
if(it.key==='stem'){ p.items.stem++; toast('Vital Stem acquired. The merchant pockets your sigils.'); }
if(it.key==='seal'){ p.items.seal++; toast('Binding Seal acquired — cold to the touch.'); }
if(it.key==='ammo'){ p.ammo++; }
if(it.key==='heart'){
p.maxC++; p.hp=Math.min(p.hp+2,p.maxC*2);
AU.play('heart'); doShimmer();
burst(p.x,p.y,30,'#ffd76a',{spd:110,up:60,life:1.1,size:3});
spawnP({x:p.x,y:p.y,type:'ring',ring:1,life:0.9,color:'#ffd76a',size:10});
toast('A HEART CONTAINER! Five vessels now beat with your vigil.','gold');
showBanner('VITALITY PURCHASED','5 heart containers — the merchant bows, barely');
}
updateHUD(); renderShop();
}
/* ================= INVENTORY ================= */
const ACCINFO={
band:{ico:'○',name:'Warden\u2019s Band',desc:'Silver ring of the old order. Attacks strike noticeably quicker.'},
lantern:{ico:'✦',name:'Phantom Lantern',desc:'Soft blue flame that reveals hairline cracks and lights dark halls.'},
cloak:{ico:'◈',name:'Shadow Cloak',desc:'Dark cloth trim. Corrosion pools and environmental traps cannot bite you.'}
};
function toggleInv(){
if(!$('shopModal').classList.contains('hidden'))return;
const m=$('invModal');
if(m.classList.contains('hidden')){ renderInv(); m.classList.remove('hidden'); paused=true; }
else closeModals();
}
function renderInv(){
const p=G.p; const box=$('invRows'); box.innerHTML='';
const secA=document.createElement('div'); secA.className='closeNote'; secA.style.textAlign='left';
secA.textContent='— Accessories —'; box.appendChild(secA);
let anyAcc=false;
['band','lantern','cloak'].forEach(k=>{
if(!p.accOwn[k])return; anyAcc=true;
const info=ACCINFO[k];
const row=document.createElement('div'); row.className='row';
row.innerHTML='<div class="ico">'+info.ico+'</div><div class="info"><div class="nm">'+info.name+'</div><div class="ds">'+info.desc+'</div></div>';
const b=document.createElement('button'); b.className='eqBtn'+(p.accEq[k]?' on':'');
b.textContent=p.accEq[k]?'Equipped':'Equip';
b.onclick=()=>{ p.accEq[k]=!p.accEq[k]; AU.play('equip'); updateHUD(); renderInv(); };
row.appendChild(b); box.appendChild(row);
});
if(!anyAcc) box.appendChild(noteRow('None found yet. Hidden chests await in the Archives, the Vault, and the Spire.'));
const secC=document.createElement('div'); secC.className='closeNote'; secC.style.textAlign='left';
secC.textContent='— Consumables —'; box.appendChild(secC);
box.appendChild(consumeRow('🌿','Vital Stem','Restores 4 HP (two hearts).',p.items.stem,()=>useStem()));
box.appendChild(consumeRow('❄','Binding Seal','Freezes the nearest construct for 3 seconds.',p.items.seal,()=>useSeal()));
const secW=document.createElement('div'); secW.className='closeNote'; secW.style.textAlign='left';
secW.textContent='— Armament —'; box.appendChild(secW);
box.appendChild(noteRow('⚔ '+WEAPONS[p.weapon].name+(p.weapon===1?' — fire damage, swift strikes, ember trails.':p.weapon===2?' — solar damage, wide heavy arcs, golden halo.':' — plain steel of the old wardens.')));
if(p.shard) box.appendChild(noteRow('◆ Aether Shard — '+p.ammo+' charges. Fire with K; strikes crystals, jars, and cracked walls from afar.'));
}
function noteRow(txt){ const d=document.createElement('div'); d.className='ds'; d.style.cssText='font-size:13px;color:#9a917c;padding:4px 10px;font-style:italic'; d.textContent=txt; return d; }
function consumeRow(ico,name,desc,count,fn){
const row=document.createElement('div'); row.className='row';
row.innerHTML='<div class="ico">'+ico+'</div><div class="info"><div class="nm">'+name+' ×'+count+'</div><div class="ds">'+desc+'</div></div>';
const b=document.createElement('button'); b.className='buyBtn'; b.textContent='Use';
b.disabled=count<=0;
b.onclick=()=>{ fn(); renderInv(); };
row.appendChild(b); return row;
}
function closeModals(){
$('shopModal').classList.add('hidden');
$('invModal').classList.add('hidden');
if(gameStarted&&!dead) paused=false;
}
/* ================= WIN ================= */
function fmtTime(s){ const m=Math.floor(s/60), ss=Math.floor(s%60); return m+'m '+String(ss).padStart(2,'0')+'s'; }
function winGame(){
paused=true; AU.drone(false); AU.play('win');
const p=G.p;
$('winLore').innerHTML='Light floods the valley. Gears turn in the deep bronze dark, moss glows gold, and the storm below finally breaks.<br>Aurelion\u2019s Sunken Observatory keeps its vigil once more — and so do you, <b style="color:var(--gold2)">Last Warden</b>.';
$('winStats').innerHTML=
'<div class="statLine">Vigil duration: <b>'+fmtTime(G.time)+'</b></div>'+
'<div class="statLine">Heart containers: <b>'+p.maxC+' / 5</b> · Weapon: <b>'+WEAPONS[p.weapon].name+'</b></div>'+
'<div class="statLine">Golden Sigils held: <b>'+p.gold+'</b> · Constructs felled: <b>'+G.kills+'</b></div>'+
'<div class="statLine">Relics: <b>'+(p.shard?'Aether Shard · ':'')+['band','lantern','cloak'].filter(k=>p.accOwn[k]).map(k=>ACCINFO[k].name).join(' · ')+'</b></div>';
setTimeout(()=>$('winScreen').classList.remove('hidden'),700);
}
/* ================= MASTER UPDATE ================= */
let lastPrompt='';
function update(dt){
timeNow+=dt; G.time+=dt; Z.t+=dt;
updatePlayer(dt);
updateEnemies(dt);
updateBolts(dt);
updateCoins(dt);
updateParts(dt);
updatePlates(dt);
updateAmbient(dt);
// camera
const tx=clamp(G.p.x-VW/2,0,Math.max(0,Z.w*T-VW));
const ty=clamp(G.p.y-VH/2,0,Math.max(0,Z.h*T-VH));
camX=lerp(camX,tx,Math.min(1,9*dt)); camY=lerp(camY,ty,Math.min(1,9*dt));
shake=Math.max(0,shake-dt*20);
// prompt
const it=nearestInteract();
const key=it?(it.type+it.x+'_'+it.y+it.label):'';
if(key!==lastPrompt){
lastPrompt=key;
const pr=$('prompt');
if(it){ pr.innerHTML='<b>E</b>'+it.label; pr.classList.add('show'); }
else pr.classList.remove('show');
}
// merchant drone
if(Z.merchant){
const near=dist(G.p.x,G.p.y,Z.merchant.x,Z.merchant.y)<125;
if(near&&!droneOn){ droneOn=true; AU.drone(true); }
else if(!near&&droneOn){ droneOn=false; AU.drone(false); }
}
}
/* ================= MAIN LOOP ================= */
let last=0;
function loop(ts){
requestAnimationFrame(loop);
const dt=Math.min(0.033,(ts-last)/1000||0.016); last=ts;
if(G&&Z){
if(gameStarted&&!paused&&!dead) update(dt);
else { Z.t+=dt; timeNow+=dt; updateParts(dt*0.4); }
render();
}
}
/* ================= INIT ================= */
function startGame(fresh){
AU.init();
$('titleScreen').classList.add('hidden');
gameStarted=true; paused=false; dead=false; lastPrompt='';
if(fresh||!loadSave()) newGame();
updateHUD();
}
$('btnNew').onclick=()=>startGame(true);
$('btnContinue').onclick=()=>startGame(false);
$('btnRespawn').onclick=()=>{
$('deathScreen').classList.add('hidden');
dead=false; paused=false; lastPrompt='';
if(!loadSave()) newGame();
updateHUD();
};
$('btnWinNew').onclick=()=>{
$('winScreen').classList.add('hidden');
dead=false; paused=false; lastPrompt='';
try{ localStorage.removeItem(SAVEKEY); }catch(e){}
newGame(); updateHUD();
};
$('shopClose').onclick=closeModals;
$('invClose').onclick=closeModals;
$('muteBtn').onclick=()=>{
AU.muted=!AU.muted;
if(AU.master) AU.master.gain.value=AU.muted?0:0.55;
$('muteBtn').textContent=AU.muted?'♪ sound off':'♪ sound on';
};
$('btnContinue').style.display=hasSave()?'block':'none';
requestAnimationFrame(loop);
</script>
</body>
</html>