Warden
Claude Opus 5 · 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 117,402 bytes · SHA-256 257bb8ba9d3f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The Warden's Corrosion — Aurelion's Sunken Observatory</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root{
--gold:#f0b45a; --gold-b:#ffe0a3; --ember:#ff8a3c; --slate:#111a24; --slate2:#1b2733;
--ink:#070c12; --blue:#6fc9ff; --rust:#9a5a34; --paper:#e9d9b8;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:radial-gradient(circle at 50% 20%,#16222f 0%,#080d13 70%,#04070a 100%);overflow:hidden;
font-family:"Spectral",Georgia,serif;color:var(--paper);user-select:none;-webkit-user-select:none}
#wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center}
#stage{position:relative;box-shadow:0 0 80px rgba(0,0,0,.9),0 0 0 1px rgba(240,180,90,.25),0 0 40px rgba(240,180,90,.08) inset;background:#05090d}
canvas{display:block;image-rendering:auto}
/* ---------- HUD ---------- */
#hud{position:absolute;inset:0;pointer-events:none;font-family:"Cinzel",Georgia,serif}
.panel{background:linear-gradient(180deg,rgba(12,19,26,.86),rgba(6,10,14,.9));border:1px solid rgba(240,180,90,.35);
border-radius:3px;box-shadow:0 2px 18px rgba(0,0,0,.6),0 0 0 1px rgba(0,0,0,.5) inset}
#topleft{position:absolute;left:14px;top:12px;display:flex;flex-direction:column;gap:7px}
#hearts{display:flex;gap:5px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.8))}
.heart{width:26px;height:24px;position:relative}
.heart svg{width:100%;height:100%;display:block}
#statline{display:flex;gap:10px;align-items:center}
.chip{display:flex;align-items:center;gap:6px;padding:4px 9px;font-size:13px;letter-spacing:.06em;
background:rgba(9,14,20,.8);border:1px solid rgba(240,180,90,.28);border-radius:2px}
.chip b{color:var(--gold-b);font-weight:700}
#weapchip{color:#ffcf94}
#ammochip{color:#bfe9ff;border-color:rgba(111,201,255,.35)}
#topright{position:absolute;right:14px;top:12px;text-align:right}
#zonename{font-size:15px;letter-spacing:.18em;color:var(--gold);text-transform:uppercase;
text-shadow:0 0 16px rgba(240,180,90,.5),0 2px 4px #000}
#objective{font-family:"Spectral",serif;font-size:12.5px;color:#9fb3c4;margin-top:3px;max-width:330px;font-style:italic}
#slots{position:absolute;left:14px;bottom:12px;display:flex;gap:8px}
.slot{width:52px;height:52px;position:relative;display:flex;align-items:center;justify-content:center;
background:linear-gradient(180deg,rgba(14,22,30,.9),rgba(6,10,14,.92));border:1px solid rgba(240,180,90,.3);border-radius:3px}
.slot .k{position:absolute;top:1px;left:3px;font-size:9px;color:#7d8ea0}
.slot .n{position:absolute;bottom:1px;right:3px;font-size:11px;color:var(--gold-b)}
.slot.empty{opacity:.35}
#keyhint{position:absolute;right:14px;bottom:12px;font-family:"Spectral",serif;font-size:11.5px;color:#7d90a3;text-align:right;line-height:1.5}
#keyhint b{color:var(--gold);font-weight:600}
/* toasts */
#toasts{position:absolute;left:50%;top:82px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center}
.toast{padding:7px 16px;font-size:13px;letter-spacing:.08em;background:rgba(8,13,18,.9);
border:1px solid rgba(240,180,90,.45);border-radius:2px;color:var(--gold-b);
box-shadow:0 0 26px rgba(240,180,90,.16);animation:tin .35s ease-out;white-space:nowrap}
.toast.blue{border-color:rgba(111,201,255,.5);color:#cdeeff;box-shadow:0 0 26px rgba(111,201,255,.18)}
.toast.bad{border-color:rgba(220,90,70,.5);color:#ffc4b6}
@keyframes tin{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:none}}
/* prompt */
#prompt{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);padding:8px 18px;font-size:13px;
letter-spacing:.1em;background:rgba(8,13,18,.88);border:1px solid rgba(240,180,90,.5);color:var(--gold-b);
border-radius:2px;display:none;box-shadow:0 0 30px rgba(240,180,90,.2)}
#prompt b{color:#fff}
/* subtitle line for narrative */
#narr{position:absolute;left:50%;bottom:38px;transform:translateX(-50%);width:min(760px,88%);text-align:center;
font-family:"Spectral",serif;font-style:italic;font-size:15px;color:#d8c8a8;text-shadow:0 2px 10px #000;
opacity:0;transition:opacity .6s}
/* ---------- overlays ---------- */
.screen{position:absolute;inset:0;display:none;align-items:center;justify-content:center;pointer-events:auto;
background:radial-gradient(circle at 50% 45%,rgba(10,16,23,.9),rgba(3,6,9,.97));backdrop-filter:blur(3px)}
.screen.on{display:flex}
.card{width:min(720px,92%);max-height:88%;overflow:auto;padding:26px 30px;font-family:"Spectral",serif}
.card h1{font-family:"Cinzel",serif;font-size:34px;margin:0 0 4px;color:var(--gold);letter-spacing:.06em;
text-shadow:0 0 30px rgba(240,180,90,.4)}
.card h2{font-family:"Cinzel",serif;font-size:17px;letter-spacing:.14em;color:var(--gold);margin:0 0 14px;text-transform:uppercase}
.card p{line-height:1.6;color:#cbbfa8;font-size:14.5px}
.btn{font-family:"Cinzel",serif;cursor:pointer;padding:9px 18px;margin:6px 8px 0 0;font-size:13px;letter-spacing:.1em;
background:linear-gradient(180deg,#26313d,#141d26);color:var(--gold-b);border:1px solid rgba(240,180,90,.45);border-radius:2px}
.btn:hover{background:linear-gradient(180deg,#39485a,#1c2733);box-shadow:0 0 18px rgba(240,180,90,.3)}
.btn:disabled{opacity:.38;cursor:not-allowed;box-shadow:none}
.row{display:flex;gap:14px;align-items:flex-start;padding:10px 0;border-bottom:1px solid rgba(240,180,90,.13)}
.row:last-child{border-bottom:none}
.row .ic{width:44px;height:44px;flex:0 0 44px;border:1px solid rgba(240,180,90,.28);border-radius:3px;
display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.35)}
.row .meta{flex:1}
.row .nm{font-family:"Cinzel",serif;font-size:14px;color:var(--gold-b);letter-spacing:.06em}
.row .ds{font-size:12.5px;color:#a8b7c4;font-style:italic;margin-top:2px}
.row .rt{text-align:right;min-width:120px}
.tag{display:inline-block;font-family:"Cinzel",serif;font-size:10px;letter-spacing:.1em;padding:2px 6px;border-radius:2px;
border:1px solid rgba(240,180,90,.4);color:var(--gold);margin-left:6px}
.tag.eq{border-color:rgba(111,201,255,.6);color:#bfe9ff}
#titlebg{position:absolute;inset:0;pointer-events:none}
.flavor{font-style:italic;color:#8fa2b3;font-size:13px}
#vig{position:absolute;inset:0;pointer-events:none;box-shadow:0 0 160px 40px rgba(0,0,0,.75) inset}
#dmgflash{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle at 50% 50%,rgba(255,40,20,0) 40%,rgba(220,30,20,.55));opacity:0}
#goldflash{position:absolute;inset:0;pointer-events:none;box-shadow:0 0 100px 20px rgba(255,200,110,.55) inset;opacity:0}
.small{font-size:12px;color:#8fa2b3}
kbd{font-family:"Cinzel",serif;font-size:11px;background:#1b2733;border:1px solid rgba(240,180,90,.35);
padding:1px 6px;border-radius:2px;color:var(--gold-b)}
</style>
</head>
<body>
<div id="wrap"><div id="stage">
<canvas id="cv" width="960" height="576"></canvas>
<div id="hud">
<div id="topleft">
<div id="hearts"></div>
<div id="statline">
<div class="chip" id="goldchip">✦ <b id="gold">0</b> <span style="color:#8fa2b3;font-size:11px">SIGILS</span></div>
<div class="chip" id="weapchip">⚔ <b id="weapname">Ember Blade</b></div>
<div class="chip" id="ammochip" style="display:none">✧ <b id="ammo">0</b> <span style="font-size:11px">SHARDS</span></div>
</div>
</div>
<div id="topright"><div id="zonename">—</div><div id="objective"></div></div>
<div id="slots"></div>
<div id="keyhint"></div>
<div id="toasts"></div>
<div id="prompt"></div>
<div id="narr"></div>
</div>
<div id="vig"></div><div id="dmgflash"></div><div id="goldflash"></div>
<div class="screen on" id="scr-title">
<div class="card" style="text-align:center">
<div class="flavor" style="letter-spacing:.3em;font-style:normal;font-size:11px;color:var(--gold)">AURELION'S SUNKEN OBSERVATORY</div>
<h1 style="font-size:44px;margin-top:8px">The Warden's Corrosion</h1>
<p style="max-width:560px;margin:14px auto 20px">The celestial archive hangs above a storm-locked valley, its great gears slowing under
a creeping black-iron decay. You are the Last Warden. Six halls stand between you and the Core Beacon.</p>
<div>
<button class="btn" id="btn-new">Begin the Vigil</button>
<button class="btn" id="btn-cont">Continue from Sanctuary</button>
<button class="btn" id="btn-help">Warden's Codex</button>
</div>
<div class="small" style="margin-top:18px">WASD / Arrows — move · J or Space — strike · K or F — Aether Shard · E — interact
<br>I — inventory · 1 — Vital Stem · 2 — Binding Seal</div>
</div>
</div>
<div class="screen" id="scr-help"><div class="card">
<h2>Warden's Codex</h2>
<p><b style="color:var(--gold-b)">Movement & Combat.</b> Walk with <kbd>WASD</kbd> or arrows — your steps grind on bronze, clank on the Archive
grates, and splash in the flooded Vault. Strike with <kbd>J</kbd> / <kbd>Space</kbd>: a sweeping arc in the direction you face. Orange sparks mean a clean hit.</p>
<p><b style="color:var(--gold-b)">Health.</b> Each heart container holds exactly 2 HP. You begin with three containers (6 HP) and may reach five (10 HP).
Nothing regenerates on its own — rest at a Sanctuary Stone, or drink a Vital Stem.</p>
<p><b style="color:var(--gold-b)">Weapon Altars.</b> Two altars exist. The Collapsed Gallery holds the <i>Ignited Edge</i>; the Twin Spire holds the
<i>Solar Falchion</i>. There is no third form.</p>
<p><b style="color:var(--gold-b)">The Aether Shard.</b> A crystalline launcher sealed in the Sunken Vault, halfway through your vigil. It strikes distant
crystal switches, shatters weak barriers, and lets you fight at range. Restock charges at the merchant once the Vault is behind you.</p>
<p><b style="color:var(--gold-b)">Cracks.</b> Hairline fractures and darker discoloration mark breakable masonry. The base blade chips it slowly; the
Solar Falchion and Aether bolts break it fast. The Phantom Lantern makes every crack glow.</p>
<p><b style="color:var(--gold-b)">Sanctuary Stones.</b> Three, and only three: the Threshold gate, the heart of the Archives, and the foot of the Twin Spire.
Resting restores all HP and records your progress.</p>
<button class="btn" id="btn-help-back">Close</button>
</div></div>
<div class="screen" id="scr-inv"><div class="card">
<h2>Warden's Satchel</h2>
<div id="inv-body"></div>
<div class="small" style="margin-top:14px">Accessory slots: <b id="slotcount">0/2</b> — click <i>Equip</i> to toggle. Consumables stack.</div>
<button class="btn" id="btn-inv-back">Return to the halls</button>
</div></div>
<div class="screen" id="scr-shop"><div class="card">
<h2>Merchant Altar — The Rusted Archives</h2>
<p class="flavor" id="shoptalk">"Bronze remembers every coin, Warden. Spend well."</p>
<div id="shop-body"></div>
<div class="small" style="margin-top:12px">Sigils: <b id="shopgold" style="color:var(--gold-b)">0</b></div>
<button class="btn" id="btn-shop-back">Leave the altar</button>
</div></div>
<div class="screen" id="scr-dead"><div class="card" style="text-align:center">
<h1 style="color:#c9553f">The Corrosion Takes You</h1>
<p id="deadtxt">Your cloak settles into rust. But a Sanctuary Stone still holds your name.</p>
<button class="btn" id="btn-respawn">Awaken at the Sanctuary</button>
</div></div>
<div class="screen" id="scr-win"><div class="card" style="text-align:center">
<h1>The Beacon Answers</h1>
<p id="wintxt"></p>
<button class="btn" id="btn-win-back">Stand in the light</button>
</div></div>
</div></div>
<script>
"use strict";
/* ============================================================================
THE WARDEN'S CORROSION — single-file action-adventure
========================================================================== */
const TS = 32, VW = 960, VH = 576;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
const clamp=(v,a,b)=>v<a?a:v>b?b:v, lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a=1,b=0)=>b+Math.random()*(a-b), rint=(a,b)=>Math.floor(rnd(b+1,a));
const dist=(x,y,x2,y2)=>Math.hypot(x2-x,y2-y);
const now=()=>performance.now()/1000;
/* -------------------------------- AUDIO ---------------------------------- */
const A = {
ctx:null, master:null, ready:false, droneNode:null, droneGain:null,
init(){
if(this.ready) return;
const C = window.AudioContext||window.webkitAudioContext; if(!C) return;
this.ctx=new C(); this.master=this.ctx.createGain(); this.master.gain.value=.5;
const comp=this.ctx.createDynamicsCompressor(); comp.threshold.value=-16; comp.ratio.value=7;
this.master.connect(comp); comp.connect(this.ctx.destination);
// reverb-ish: convolver with generated impulse (hall echo)
const len=this.ctx.sampleRate*1.7, buf=this.ctx.createBuffer(2,len,this.ctx.sampleRate);
for(let c=0;c<2;c++){const d=buf.getChannelData(c);
for(let i=0;i<len;i++){const t=i/len; d[i]=(Math.random()*2-1)*Math.pow(1-t,2.6)*.6;}}
this.conv=this.ctx.createConvolver(); this.conv.buffer=buf;
this.wet=this.ctx.createGain(); this.wet.gain.value=.32;
this.conv.connect(this.wet); this.wet.connect(this.master);
this.noiseBuf=(()=>{const n=this.ctx.createBuffer(1,this.ctx.sampleRate*2,this.ctx.sampleRate);
const d=n.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1; return n;})();
this.ready=true;
},
t(o=0){return this.ctx.currentTime+o;},
env(g,t0,att,dur,peak){ g.gain.setValueAtTime(0.0001,t0); g.gain.linearRampToValueAtTime(peak,t0+att);
g.gain.exponentialRampToValueAtTime(0.0001,t0+att+dur); },
tone(f,{type='sine',dur=.2,att=.005,gain=.2,f2=null,delay=0,rev=.25,detune=0}={}){
if(!this.ready) return; const t0=this.t(delay); const o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.setValueAtTime(f,t0); if(f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,f2),t0+dur);
if(detune) o.detune.value=detune;
o.connect(g); g.connect(this.master); if(rev>0){const rg=this.ctx.createGain();rg.gain.value=rev;g.connect(rg);rg.connect(this.conv);}
this.env(g,t0,att,dur,gain); o.start(t0); o.stop(t0+att+dur+.05);
},
noise({dur=.2,gain=.2,f=800,q=1,type='bandpass',delay=0,f2=null,rev=.2}={}){
if(!this.ready) return; const t0=this.t(delay);
const s=this.ctx.createBufferSource(); s.buffer=this.noiseBuf; s.loop=true;
const bp=this.ctx.createBiquadFilter(); bp.type=type; bp.frequency.setValueAtTime(f,t0); bp.Q.value=q;
if(f2) bp.frequency.exponentialRampToValueAtTime(Math.max(40,f2),t0+dur);
const g=this.ctx.createGain(); s.connect(bp); bp.connect(g); g.connect(this.master);
if(rev>0){const rg=this.ctx.createGain();rg.gain.value=rev;g.connect(rg);rg.connect(this.conv);}
this.env(g,t0,.004,dur,gain); s.start(t0); s.stop(t0+dur+.1);
},
chord(freqs,{type='triangle',dur=.7,gain=.13,rev=.5,spread=.045}={}){
freqs.forEach((f,i)=>this.tone(f,{type,dur,gain,delay:i*spread,rev,att:.02}));
},
drone(on){
if(!this.ready) return;
if(on&&!this.droneNode){
const g=this.ctx.createGain(); g.gain.value=0; g.connect(this.master);
const o1=this.ctx.createOscillator(),o2=this.ctx.createOscillator(),o3=this.ctx.createOscillator();
o1.type='sine';o1.frequency.value=73.4; o2.type='sine';o2.frequency.value=110; o3.type='triangle';o3.frequency.value=146.8;
const lp=this.ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=400;
[o1,o2,o3].forEach(o=>{o.connect(lp);o.start();}); lp.connect(g);
g.gain.linearRampToValueAtTime(.1,this.t(.5));
this.droneNode=[o1,o2,o3]; this.droneGain=g;
} else if(!on&&this.droneNode){
const g=this.droneGain, ns=this.droneNode; g.gain.linearRampToValueAtTime(0,this.t(.4));
setTimeout(()=>ns.forEach(o=>{try{o.stop()}catch(e){}}),600);
this.droneNode=null;this.droneGain=null;
}
}
};
const SFX = {
step(mat){
if(mat==='metal'){ A.noise({dur:.09,gain:.1,f:1500,q:1.4,f2:600,rev:.3}); A.tone(190,{type:'square',dur:.06,gain:.035,rev:.3}); }
else if(mat==='water'){ A.noise({dur:.17,gain:.09,f:2600,q:.7,f2:700,type:'bandpass',rev:.45}); }
else { A.noise({dur:.075,gain:.075,f:520,q:1.1,f2:260,rev:.3}); }
},
swing(tier){
if(tier===0){ A.noise({dur:.13,gain:.16,f:1100,q:.9,f2:2600,rev:.2}); }
else if(tier===1){ A.noise({dur:.14,gain:.16,f:1400,q:.9,f2:3000,rev:.2});
A.noise({dur:.3,gain:.09,f:3400,q:.6,f2:900,delay:.03,rev:.3}); A.tone(320,{type:'sawtooth',dur:.12,gain:.04,f2:180}); }
else { A.tone(120,{type:'sine',dur:.22,gain:.12,f2:60,rev:.4}); A.noise({dur:.22,gain:.15,f:700,q:.7,f2:2200,delay:.1,rev:.35});
A.tone(880,{type:'triangle',dur:.5,gain:.06,delay:.12,rev:.7}); }
},
hit(tier){
A.noise({dur:.09,gain:.2,f:260,q:1.5,f2:110,rev:.25});
if(tier===0) A.tone(150,{type:'square',dur:.09,gain:.09,f2:80});
if(tier===1){ A.tone(110,{type:'sawtooth',dur:.16,gain:.1,f2:60}); A.noise({dur:.26,gain:.07,f:2800,q:.5,f2:1200,delay:.02}); }
if(tier===2){ A.tone(70,{type:'sine',dur:.42,gain:.16,f2:40,rev:.6}); A.tone(523,{type:'triangle',dur:.35,gain:.07,delay:.02,rev:.6}); }
},
bolt(){ A.tone(1760,{type:'triangle',dur:.16,gain:.11,f2:880,rev:.35}); A.tone(2640,{type:'sine',dur:.1,gain:.06,f2:1600}); },
boltHit(){ A.tone(1318,{type:'sine',dur:.5,gain:.09,rev:.8}); A.tone(1976,{type:'sine',dur:.6,gain:.05,delay:.04,rev:.8}); A.noise({dur:.08,gain:.1,f:3000,q:1,f2:900}); },
hurt(){ A.noise({dur:.16,gain:.24,f:180,q:1.2,f2:70,rev:.3}); A.tone(1245,{type:'square',dur:.22,gain:.07,f2:1100,rev:.5}); A.tone(1319,{type:'sawtooth',dur:.3,gain:.045,detune:35,rev:.6}); },
coin(){ A.tone(2100,{type:'square',dur:.05,gain:.06}); A.tone(3150,{type:'square',dur:.07,gain:.045,delay:.045,rev:.4}); },
pickup(){ A.chord([784,988,1175],{dur:.35,gain:.09,spread:.05,rev:.5}); },
rare(){ A.chord([392,494,587,784,988],{dur:1.1,gain:.085,spread:.07,rev:.85,type:'triangle'}); },
heartUp(){ A.chord([523,659,784,1047,1319],{dur:1.4,gain:.09,spread:.085,rev:.9,type:'sine'}); A.tone(131,{type:'sine',dur:1.2,gain:.1,rev:.7}); },
shimmer(){ for(let i=0;i<7;i++) A.tone(1200+i*420,{type:'sine',dur:.5,gain:.035,delay:i*.045,rev:.8}); },
purchase(){ A.tone(1400,{type:'square',dur:.05,gain:.06}); A.tone(1000,{type:'square',dur:.06,gain:.05,delay:.05});
A.chord([659,988,1319],{dur:.6,gain:.08,delay:0,spread:.05,rev:.6}); },
deny(){ A.tone(220,{type:'square',dur:.18,gain:.09,f2:110}); A.tone(233,{type:'square',dur:.2,gain:.06,delay:.02,f2:116}); },
save(){ A.chord([220,330,440,660],{dur:2.2,gain:.075,spread:.12,rev:.95,type:'sine'}); },
crack(){ A.noise({dur:.2,gain:.2,f:420,q:.8,f2:150,rev:.45}); A.tone(90,{type:'square',dur:.2,gain:.08,f2:50}); },
smash(){ A.noise({dur:.42,gain:.24,f:900,q:.5,f2:180,rev:.6}); A.tone(70,{type:'sine',dur:.5,gain:.13,f2:38,rev:.6}); },
gate(){ A.tone(58,{type:'sawtooth',dur:1.5,gain:.12,f2:44,rev:.7}); A.noise({dur:1.3,gain:.1,f:300,q:.6,f2:900,rev:.6}); },
plate(){ A.tone(330,{type:'square',dur:.1,gain:.08}); A.tone(220,{type:'square',dur:.16,gain:.07,delay:.07,rev:.4}); },
enemyDie(){ A.noise({dur:.45,gain:.2,f:600,q:.6,f2:120,rev:.5}); A.tone(150,{type:'sawtooth',dur:.4,gain:.1,f2:45,rev:.5}); },
freeze(){ A.chord([1568,2093,2637],{dur:.9,gain:.06,spread:.03,rev:.85,type:'sine'}); A.noise({dur:.5,gain:.06,f:5000,q:.6,f2:2000,rev:.7}); },
drink(){ A.noise({dur:.3,gain:.09,f:600,q:.9,f2:1600,rev:.3}); A.chord([523,784],{dur:.6,gain:.08,rev:.6}); },
boss(){ A.tone(46,{type:'sawtooth',dur:2.6,gain:.16,f2:38,rev:.8}); A.tone(69,{type:'square',dur:1.4,gain:.07,f2:55,rev:.7}); },
beacon(){ for(let i=0;i<12;i++) A.tone(261.6*Math.pow(2,i/6),{type:'sine',dur:2.5,gain:.05,delay:i*.12,rev:.95}); }
};
/* -------------------------------- INPUT ---------------------------------- */
const keys={}, pressed={};
const KEYMAP={KeyW:'up',ArrowUp:'up',KeyS:'down',ArrowDown:'down',KeyA:'left',ArrowLeft:'left',KeyD:'right',ArrowRight:'right',
KeyJ:'atk',Space:'atk',KeyK:'shoot',KeyF:'shoot',KeyE:'act',KeyI:'inv',Digit1:'stem',Digit2:'seal',Escape:'esc'};
addEventListener('keydown',e=>{ const k=KEYMAP[e.code]; if(k){ if(!keys[k]) pressed[k]=true; keys[k]=true; e.preventDefault(); }
if(!A.ready){A.init();} });
addEventListener('keyup',e=>{ const k=KEYMAP[e.code]; if(k){keys[k]=false;e.preventDefault();} });
addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
const wasPressed=k=>{ if(pressed[k]){pressed[k]=false;return true;} return false; };
/* ------------------------------- WORLD DATA ------------------------------ */
/* Legend: # wall . floor , rubble = bronze grate ~ water ^ mist-floor
C crack wall D gate P plate S sanctuary M merchant A altar
T chest J jar X crystal switch R shard pedestal L lantern pedestal
H heart container o construct O heavy construct B boss
> exit < entry * gold cache p pillar */
const RAW = {
1:{ name:"The Ashen Threshold", sub:"Prologue", mat:"stone", pal:"threshold",
obj:"Reach the far gate. Fell the constructs stirring in the ashen court.",
map:[
"############################################",
"#..........................................#",
"#..#####...#########..........#########....#",
"#..#...#...#.......#..........#.......#....#",
"#..#.S.....#...J...#....o.....#...J...#....#",
"#..#...#...#.......#..........#.......#..o.#",
"#..#####...####.####..........####.####....#",
"#..........................................#",
"#<.........,,,,,....o.....,,,,,........DDD>#",
"#..........................................#",
"#..#####...####.####..........####.####....#",
"#..#...#...#.......#..........#.......#....#",
"#..#.*.....#...o...#..........#...*...#..o.#",
"#..#...#...#.......#....o.....#.......#....#",
"#..#####...#########..........#########....#",
"#..........................................#",
"############################################"]},
2:{ name:"The Rusted Archives", sub:"Early", mat:"metal", pal:"archives",
obj:"Clear the stacks, wake the Sanctuary Stone, and find the merchant altar.",
map:[
"############################################",
"#=====#....#...#.........#....#............#",
"#==T==#....#...#....o....#.J..#....o.......#",
"#=====#....#...#.........#....#............#",
"###C###....##.##...####..######.......####.#",
"#..........................................#",
"#....o.......====....S....====.......o.....#",
"#............====.........====.............#",
"#<..........................o.........DDDD>#",
"#............====.........====.............#",
"#....J.......====....M....====.......*.....#",
"#..........................................#",
"#..####....####.####...........####.####...#",
"#..#..#....#.......#.....o.....#......#....#",
"#..#*.#....#...o...#...........#..J...#..o.#",
"#..####....#########...........########....#",
"#..........................................#",
"############################################"]},
3:{ name:"The Collapsed Gallery", sub:"Mid-Early", mat:"stone", pal:"gallery",
obj:"Two bronze plates, pressed in quick succession, lower the gallery bridge.",
map:[
"############################################",
"#..............#CCCC#......................#",
"#....o.........#.H..#.........#####........#",
"#..............#....#.........#...#....o...#",
"#....p....p....##CC##.........#.A.#........#",
"#.............................#...#........#",
"#....P........................##.##........#",
"#..........................................#",
"#<.......,,,,,.....O......,,,,,,.....DDDDD>#",
"#..........................................#",
"#.............................####.####....#",
"#....p....p...........P.......#.......#....#",
"#.........................o...#...J...#.o..#",
"#..............####...........#.......#....#",
"#....*.........#*.#...........####.####....#",
"#..............####........................#",
"############################################"]},
4:{ name:"The Sunken Vault", sub:"Mid-Game — Halfway", mat:"water", pal:"vault",
obj:"Flooded and dark. Something crystalline sleeps behind cracked masonry.",
map:[
"############################################",
"#~~~~~~~~~#....#CCCC#.....#####............#",
"#~~~~~~~~~#..o.#.R..#.....#...#....o.......#",
"#~~~~~~~~~#....#....#.....#.L.#............#",
"#~~~~~####.##.##..##......##.##......####..#",
"#~~~~~#....................................#",
"#~~~~~#...~~~~~......o.......~~~~~....o....#",
"#.....#...~~~~~..............~~~~~.........#",
"#<........~~~~~.....O........~~~~~....XDDD>#",
"#.....#...~~~~~..............~~~~~.........#",
"#~~~~~#...~~~~~.....J........~~~~~....*....#",
"#~~~~~#....................................#",
"#~~~~~####...####.####..........####.###...#",
"#~~~~~~~~~..#.......#....o......#.....#....#",
"#~~~~~~~~~..#...o...#...........#..J..#.o..#",
"#~~~~~~~~~..#########...........#######....#",
"#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#",
"############################################"]},
5:{ name:"The Twin Spire", sub:"Late", mat:"stone", pal:"spire",
obj:"Ascend between the spires. The Guardian holds the way to the Core.",
map:[
"############################################",
"#....#CCCC#.......#....#........#....#.....#",
"#....#..H.#...o...#.T..#...o....#.J..#..o..#",
"#....#....#.......#....#........#....#.....#",
"#....######..######....######..##....##....#",
"#..........................................#",
"#..S.....o......####.####.......o.....o....#",
"#...............#.......#..................#",
"#<.......,,,,,..#...A...#..,,,,,.....DDDDD>#",
"#...............#.......#..................#",
"#....o..........####.####........o.........#",
"#..........................................#",
"#....####....##.##.....##.##....####.......#",
"#....#*.#....#...#..O..#...#....#..#...o...#",
"#....#..#....#.J.#.....#.T.#....#..#.......#",
"#....####....##.##.....##.##....####.......#",
"#..........................................#",
"############################################"]},
6:{ name:"The Core Chamber", sub:"Climax", mat:"stone", pal:"core",
obj:"Three seals ring the Beacon. Shatter them with Aether, then end the Corrosion.",
map:[
"############################################",
"#..........................................#",
"#....X................................X....#",
"#..........................................#",
"#....##.....................##.......##....#",
"#..........................................#",
"#...............#####.####.................#",
"#...............#........#.................#",
"#<..............#...B....#.................#",
"#...............#........#.................#",
"#...............####.#####.................#",
"#..........................................#",
"#....##..............##.............##.....#",
"#..........................................#",
"#..................X.......................#",
"#..........................................#",
"#..........................................#",
"############################################"]}
};
const PALETTES = {
threshold:{ wallA:'#3b3026', wallB:'#241d17', floorA:'#2b2a2c', floorB:'#232227', tint:'#0d1119', fog:'rgba(20,26,36,.35)', dust:'#c9a06a', amb:.55 },
archives: { wallA:'#4a3524', wallB:'#2a1e15', floorA:'#2f2a26', floorB:'#262220', tint:'#141017', fog:'rgba(38,26,18,.32)', dust:'#e0b878', amb:.5 },
gallery: { wallA:'#37343e', wallB:'#221f28', floorA:'#2d2c33', floorB:'#25242b', tint:'#0f1220', fog:'rgba(24,26,42,.34)', dust:'#b9c4d8', amb:.48 },
vault: { wallA:'#26404a', wallB:'#152329', floorA:'#22323a', floorB:'#1b2830', tint:'#08131a', fog:'rgba(14,40,52,.42)', dust:'#9fdcea', amb:.4 },
spire: { wallA:'#4a3d2c', wallB:'#2a2318', floorA:'#332f2c', floorB:'#292623', tint:'#171220', fog:'rgba(40,30,20,.3)', dust:'#ffd9a0', amb:.55 },
core: { wallA:'#4d3320', wallB:'#2b1c12', floorA:'#2e2622', floorB:'#251f1c', tint:'#1a0f0a', fog:'rgba(60,26,10,.3)', dust:'#ffb066', amb:.6 }
};
const SOLID = new Set(['#','C','D','p']);
/* --------------------------- ITEM DEFINITIONS ---------------------------- */
const ITEMS = {
band:{ name:"Warden's Band", type:'accessory', desc:"A silver ring of the first vigil. Your swings come quicker.",
detail:"+22% attack speed. A silver ring glints at your wrist." },
lantern:{ name:"Phantom Lantern", type:'accessory', desc:"Cold blue flame that remembers what masonry forgets.",
detail:"Cracked walls and hidden caches glow. Dark halls brighten." },
cloak:{ name:"Shadow Cloak", type:'accessory', desc:"Trim of woven nightcloth, heavy with old dust.",
detail:"Halves damage from traps and the Corrosion's spill. Dark hem visible on your cloak." },
stem:{ name:"Vital Stem", type:'consumable', desc:"A green shoot grown in bronze. Restores 2 containers.",
detail:"Restores 4 HP (two full containers). Press 1." },
seal:{ name:"Binding Seal", type:'consumable', desc:"A glyph that argues with motion. Freezes one construct.",
detail:"Freezes the nearest construct for ~3 seconds. Press 2." }
};
const WEAPONS = [
{ name:"Ember Blade", dmg:2, cd:.42, reach:44, arc:1.5, color:'#cfd6de', glow:'#8fa3b8', dtype:'physical' },
{ name:"Ignited Edge", dmg:3, cd:.30, reach:50, arc:1.7, color:'#ffb057', glow:'#ff7a22', dtype:'ember' },
{ name:"Solar Falchion", dmg:5, cd:.52, reach:70, arc:2.5, color:'#ffe9a8', glow:'#ffc247', dtype:'solar' }
];
/* ------------------------------ GAME STATE ------------------------------- */
let G, Z, cam={x:0,y:0,shake:0,shimmer:0}, parts=[], floats=[], time=0, paused=false, mode='title';
const SAVEKEY='wardens_corrosion_v1';
function freshState(){
return {
zone:1, containers:3, hp:6, gold:0, weapon:0, hasShard:false, ammo:0,
inv:{ band:0, lantern:0, cloak:0, stem:0, seal:0 },
equipped:{ band:false, lantern:false, cloak:false },
flags:{}, shopSold:{stem:0,seal:0,heart:0},
save:{ zone:1, sx:0, sy:0, stone:1 },
deaths:0, kills:0, stones:{1:false,2:false,3:false}
};
}
function maxHP(){ return G.containers*2; }
function W(){ return WEAPONS[G.weapon]; }
function atkCD(){ return W().cd * (G.equipped.band?0.78:1); }
/* --------------------------- ZONE CONSTRUCTION --------------------------- */
function buildZone(n, entryFrom='left'){
const raw = RAW[n], rows = raw.map;
const h=rows.length, w=rows[0].length;
const grid=[]; const z={ n, name:raw.name, sub:raw.sub, mat:raw.mat, pal:PALETTES[raw.pal], obj:raw.obj,
w,h, pw:w*TS, ph:h*TS, enemies:[], objects:[], bolts:[], gates:[], plates:[], entry:{x:2*TS,y:8*TS}, exit:null,
decor:[], mist:[], cracks:[], done:false };
for(let y=0;y<h;y++){ grid.push([]);
for(let x=0;x<w;x++){
const c=rows[y][x]; let t='.';
const px=x*TS+TS/2, py=y*TS+TS/2;
switch(c){
case '#': t='#'; break;
case ',': t=','; break;
case '=': t='='; break;
case '~': t='~'; break;
case 'p': t='p'; break;
case 'C': t='C'; z.cracks.push({x,y}); break;
case 'D': t='D'; break;
case '<': t='.'; z.entry={x:px,y:py}; break;
case '>': t='.'; z.exit={x:px,y:py}; break;
case 'S': t='.'; z.objects.push(mkObj('stone',px,py,{id:stoneIdFor(n)})); break;
case 'M': t='.'; z.objects.push(mkObj('merchant',px,py,{})); break;
case 'A': t='.'; z.objects.push(mkObj('altar',px,py,{tier:n===3?1:2})); break;
case 'T': t='.'; z.objects.push(mkObj('chest',px,py,{})); break;
case 'J': t='.'; z.objects.push(mkObj('jar',px,py,{})); break;
case '*': t='.'; z.objects.push(mkObj('cache',px,py,{})); break;
case 'P': t='.'; z.plates.push(mkObj('plate',px,py,{on:false,t:-99})); break;
case 'X': t='.'; z.objects.push(mkObj('switch',px,py,{lit:false})); break;
case 'R': t='.'; z.objects.push(mkObj('shardped',px,py,{})); break;
case 'L': t='.'; z.objects.push(mkObj('lantped',px,py,{})); break;
case 'H': t='.'; z.objects.push(mkObj('heart',px,py,{})); break;
case 'o': t='.'; z.enemies.push(mkEnemy('sentinel',px,py,n)); break;
case 'O': t='.'; z.enemies.push(mkEnemy('heavy',px,py,n)); break;
case 'B': t='.'; z.enemies.push(mkEnemy('boss',px,py,n)); break;
}
grid[y].push(t);
}
}
z.grid=grid;
z.objects.push(...z.plates);
// gates: gather D columns
for(let y=0;y<h;y++)for(let x=0;x<w;x++) if(grid[y][x]==='D') z.gates.push({x,y});
// decor: pillars & random debris
for(let i=0;i<70;i++){ const x=rnd(z.pw),y=rnd(z.ph);
if(tileAt(z,x,y)==='.') z.decor.push({x,y,r:rnd(2.6,.6),a:rnd(.16,.03),c:z.pal.dust}); }
for(let i=0;i<46;i++) z.mist.push({x:rnd(z.pw),y:rnd(z.ph),vx:rnd(9,-9),vy:rnd(-6,-22),r:rnd(26,8),a:rnd(.1,.02),p:rnd(6.28)});
// per-zone scripted state
if(n===3){ z.plateSeq=[]; }
if(n===6){ z.seals=3; }
return z;
}
function stoneIdFor(n){ return n===1?1:n===2?2:3; }
function mkObj(kind,x,y,extra){ return Object.assign({kind,x,y,used:false,hp:kind==='cache'?3:1,pulse:rnd(6.28)},extra); }
const ENEMY_DEF = {
sentinel:{ hp:6, spd:52, dmg:1, r:13, atkR:26, cd:1.15, gold:[5,11], name:'Rusted Sentinel' },
heavy:{ hp:14, spd:40, dmg:2, r:18, atkR:34, cd:1.5, gold:[10,15], name:'Corroded Bulwark' },
boss:{ hp:60, spd:46, dmg:2, r:26, atkR:44, cd:1.0, gold:[60,80], name:'Warden of Ash' }
};
function mkEnemy(kind,x,y,zone){
const d=ENEMY_DEF[kind];
const scale = 1 + (zone-1)*0.18;
return { kind, x, y, hx:x, hy:y, hp:Math.round(d.hp*scale), max:Math.round(d.hp*scale), spd:d.spd, dmg:d.dmg,
r:d.r, atkR:d.atkR, cd:d.cd, tAtk:0, gold:d.gold, name:d.name, dead:false, hurt:0, frozen:0, dir:1,
wob:rnd(6.28), state:'idle', windup:0, phase:1, kb:{x:0,y:0} };
}
function tileAt(z,px,py){ const x=Math.floor(px/TS), y=Math.floor(py/TS);
if(x<0||y<0||x>=z.w||y>=z.h) return '#'; return z.grid[y][x]; }
function solidAt(z,px,py){ return SOLID.has(tileAt(z,px,py)); }
/* ------------------------------- PLAYER ---------------------------------- */
const P = { x:0,y:0,vx:0,vy:0, r:11, face:1, dirx:1,diry:0, anim:0, walking:false,
tAtk:-9, atkT:-9, atkDir:0, iframe:0, tStep:0, tShoot:-9, hurtFlash:0, breath:0, dead:false, chargeGlow:0 };
function enterZone(n, from='entry'){
Z = buildZone(n);
G.zone=n;
if(from==='entry'){ P.x=Z.entry.x; P.y=Z.entry.y; }
else { P.x=from.x; P.y=from.y; }
P.vx=P.vy=0;
cam.x=clamp(P.x-VW/2,0,Math.max(0,Z.pw-VW)); cam.y=clamp(P.y-VH/2,0,Math.max(0,Z.ph-VH));
parts.length=0; floats.length=0;
$('zonename').textContent = Z.name;
$('objective').textContent = Z.obj;
narrate(zoneNarration(n));
cam.shimmer=1.1;
A.drone(false);
}
function zoneNarration(n){
return {
1:"Ash drifts through the outer court. The gate at the far end still answers to a Warden's hand.",
2:"Grates clank underfoot. Ledgers of dead stars rot in their shelves — and someone still keeps a shop here.",
3:"The gallery fell inward a century ago. Two bronze plates remain, and they remember being pressed together.",
4:"Water to your ankles, and the dark drinks your lamp. Halfway now. Something crystalline hums behind the stone.",
5:"Two spires, one stair. Beyond the Guardian, the Beacon's shaft.",
6:"The Core Chamber. Three seals ring the Beacon, and the Corrosion has a face."
}[n];
}
/* ------------------------------ PARTICLES -------------------------------- */
function spark(x,y,n,col,{spd=90,life=.5,size=2.4,grav=140,glow=true}={}){
for(let i=0;i<n;i++){ const a=rnd(6.28), s=rnd(spd,spd*.25);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-20,life:rnd(life*1.5,life*.5),t:0,c:col,r:rnd(size,size*.4),grav,glow}); }
}
function burst(x,y,n,col,opt={}){ spark(x,y,n,col,opt); }
function motes(x,y,n,col,{rise=-40,life=1.1,size=2}={}){
for(let i=0;i<n;i++) parts.push({x:x+rnd(16,-16),y:y+rnd(12,-12),vx:rnd(14,-14),vy:rnd(rise*1.4,rise*.4),
life:rnd(life*1.4,life*.6),t:0,c:col,r:rnd(size,size*.5),grav:-8,glow:true});
}
function ring(x,y,col,rad=26,life=.5){ parts.push({ring:true,x,y,c:col,rad,life,t:0}); }
function float(x,y,txt,col='#ffd9a0',size=15){ floats.push({x,y,txt,col,size,t:0,life:.95}); }
function updateParts(dt){
for(let i=parts.length-1;i>=0;i--){ const p=parts[i]; p.t+=dt;
if(p.t>=p.life){parts.splice(i,1);continue;}
if(p.ring) continue;
p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=p.grav*dt; p.vx*=.985; p.vy*=.99;
}
for(let i=floats.length-1;i>=0;i--){const f=floats[i]; f.t+=dt; f.y-=34*dt; if(f.t>=f.life) floats.splice(i,1);}
}
/* ------------------------------ UI HELPERS ------------------------------- */
function toast(msg,cls=''){ const d=document.createElement('div'); d.className='toast '+cls; d.textContent=msg;
$('toasts').appendChild(d); setTimeout(()=>{d.style.transition='opacity .5s,transform .5s';d.style.opacity=0;d.style.transform='translateY(-12px)';},2100);
setTimeout(()=>d.remove(),2700); }
let narrT=0;
function narrate(txt){ const n=$('narr'); n.textContent=txt; n.style.opacity=1; narrT=now()+6.5; }
function flashEl(id,dur=.35){ const e=$(id); e.style.transition='none'; e.style.opacity=1;
requestAnimationFrame(()=>{ e.style.transition=`opacity ${dur}s ease-out`; e.style.opacity=0; }); }
const HEART_SVG=(fill)=>{
// fill: 2 = full, 1 = half, 0 = empty
const grad = `<defs>
<linearGradient id="g${fill}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffd48a"/><stop offset=".5" stop-color="#f0913c"/><stop offset="1" stop-color="#c04a1e"/></linearGradient>
<clipPath id="half${fill}"><rect x="0" y="0" width="13" height="26"/></clipPath></defs>`;
const path="M13 24.5C13 24.5 2 17.2 2 10.4C2 6.3 5.1 3.2 9 3.2C11 3.2 12.4 4.2 13 5.4C13.6 4.2 15 3.2 17 3.2C20.9 3.2 24 6.3 24 10.4C24 17.2 13 24.5 13 24.5Z";
let inner='';
if(fill===2) inner=`<path d="${path}" fill="url(#g2)"/>`;
else if(fill===1) inner=`<path d="${path}" fill="url(#g1)" clip-path="url(#half1)"/>`;
return `<svg viewBox="0 0 26 26">${grad}
<path d="${path}" fill="#1a1116" stroke="#7a5a34" stroke-width="1.6"/>${inner}
<path d="${path}" fill="none" stroke="${fill?'#ffe0a3':'#5c4a34'}" stroke-width="1.3" opacity="${fill?.9:.5}"/>
</svg>`;
};
let lastHeartSig='';
function drawHUD(){
const sig=G.hp+'/'+G.containers;
if(sig!==lastHeartSig){ lastHeartSig=sig;
const el=$('hearts'); el.innerHTML='';
for(let i=0;i<G.containers;i++){ const rem=G.hp-i*2; const f=rem>=2?2:rem===1?1:0;
const d=document.createElement('div'); d.className='heart'; d.innerHTML=HEART_SVG(f); el.appendChild(d); }
}
$('gold').textContent=G.gold;
$('weapname').textContent=W().name;
$('ammochip').style.display=G.hasShard?'flex':'none';
$('ammo').textContent=G.ammo;
// item slots
const el=$('slots'); const want=[['1','stem'],['2','seal'],['','band'],['','lantern'],['','cloak']];
let html='';
for(const [k,id] of want){
const it=ITEMS[id], count=G.inv[id];
const eq=G.equipped[id];
html+=`<div class="slot ${count?'':'empty'}" title="${it.name}">
<div class="k">${k}</div>${ITEM_ICON(id, eq)}
${it.type==='consumable'?`<div class="n">${count}</div>`:(count?`<div class="n" style="color:${eq?'#bfe9ff':'#6d7d8e'}">${eq?'●':'○'}</div>`:'')}
</div>`;
}
el.innerHTML=html;
$('keyhint').innerHTML = `<b>WASD</b> move · <b>J</b> strike${G.hasShard?' · <b>K</b> Aether Shard':''} · <b>E</b> interact<br><b>I</b> satchel · <b>1</b> stem · <b>2</b> seal`;
}
function ITEM_ICON(id,eq){
const s=(inner,extra='')=>`<svg width="30" height="30" viewBox="0 0 30 30" ${extra}>${inner}</svg>`;
switch(id){
case 'stem': return s(`<path d="M15 25 C15 16 15 12 15 8" stroke="#6fe08a" stroke-width="2" fill="none"/>
<path d="M15 14 C9 13 7 9 8 6 C12 6 15 9 15 14Z" fill="#4fd07a" opacity=".95"/>
<path d="M15 17 C21 16 23 12 22 9 C18 9 15 12 15 17Z" fill="#83e89b" opacity=".9"/>
<circle cx="15" cy="7" r="2.6" fill="#c9ffd8" opacity=".8"/>`);
case 'seal': return s(`<circle cx="15" cy="15" r="9" fill="none" stroke="#6fc9ff" stroke-width="1.6"/>
<circle cx="15" cy="15" r="5" fill="rgba(111,201,255,.25)" stroke="#bfe9ff" stroke-width="1"/>
<path d="M15 5 L15 25 M5 15 L25 15" stroke="#6fc9ff" stroke-width="1" opacity=".6"/>
<path d="M9 9 L21 21 M21 9 L9 21" stroke="#bfe9ff" stroke-width=".8" opacity=".5"/>`);
case 'band': return s(`<ellipse cx="15" cy="16" rx="8" ry="8.5" fill="none" stroke="#d8dee8" stroke-width="2.6"/>
<ellipse cx="15" cy="16" rx="8" ry="8.5" fill="none" stroke="#ffffff" stroke-width=".9" opacity=".7"/>
<path d="M15 5 l3 4 -3 3 -3 -3z" fill="${eq?'#bfe9ff':'#9fb3c4'}"/>`);
case 'lantern': return s(`<rect x="10" y="9" width="10" height="13" rx="2" fill="rgba(111,201,255,.28)" stroke="#6fc9ff" stroke-width="1.4"/>
<path d="M15 4 v5 M11 7 h8" stroke="#9fb3c4" stroke-width="1.4"/>
<circle cx="15" cy="15.5" r="3.4" fill="#bfe9ff" opacity="${eq?'.95':'.5'}"/>
<rect x="9" y="21" width="12" height="2.4" fill="#7d8ea0"/>`);
case 'cloak': return s(`<path d="M15 5 L22 10 L21 25 L9 25 L8 10 Z" fill="#1c1c26" stroke="${eq?'#8a7bd8':'#4a4a5a'}" stroke-width="1.4"/>
<path d="M9 25 L21 25" stroke="#6a5ac8" stroke-width="2" opacity="${eq?'.95':'.4'}"/>
<circle cx="15" cy="9" r="2.2" fill="#3a3a4c"/>`);
}
return '';
}
/* ------------------------------ COLLISION -------------------------------- */
function moveEntity(e,dx,dy,radius){
const z=Z;
// X
if(dx){ const nx=e.x+dx; const s=Math.sign(dx);
if(!solidAt(z,nx+s*radius,e.y-radius*.6)&&!solidAt(z,nx+s*radius,e.y+radius*.6)&&!solidAt(z,nx+s*radius,e.y)) e.x=nx;
}
if(dy){ const ny=e.y+dy; const s=Math.sign(dy);
if(!solidAt(z,e.x-radius*.6,ny+s*radius)&&!solidAt(z,e.x+radius*.6,ny+s*radius)&&!solidAt(z,e.x,ny+s*radius)) e.y=ny;
}
e.x=clamp(e.x,TS*0.6,z.pw-TS*0.6); e.y=clamp(e.y,TS*0.6,z.ph-TS*0.6);
}
/* ------------------------------ DAMAGE ----------------------------------- */
function hurtPlayer(amount,srcx,srcy,kind='enemy'){
if(P.iframe>0||P.dead||mode!=='play') return;
let dmg=amount;
if(kind==='trap'&&G.equipped.cloak) dmg=Math.max(1,Math.floor(dmg/2));
G.hp=Math.max(0,G.hp-dmg);
P.iframe=1.0; P.hurtFlash=.5;
cam.shake=Math.max(cam.shake,9);
SFX.hurt(); flashEl('dmgflash',.5);
burst(P.x,P.y,20,'#ff4030',{spd:150,life:.55,size:3.2,grav:60});
ring(P.x,P.y,'rgba(255,70,50,.85)',30,.45);
float(P.x,P.y-20,'-'+dmg,'#ff8878',18);
if(srcx!==undefined){ const a=Math.atan2(P.y-srcy,P.x-srcx); P.vx+=Math.cos(a)*190; P.vy+=Math.sin(a)*190; }
lastHeartSig='';
if(G.hp<=0) killPlayer();
}
function killPlayer(){
P.dead=true; G.deaths++;
burst(P.x,P.y,50,'#ff5a30',{spd:210,life:1,size:4,grav:120});
SFX.enemyDie(); A.tone(60,{type:'sawtooth',dur:2,gain:.14,f2:32,rev:.8});
setTimeout(()=>{ mode='dead';
$('deadtxt').textContent = G.stones[3]?"Your cloak settles into rust. The Twin Spire stone still holds your name.":
G.stones[2]?"Your cloak settles into rust. The Archives' stone still holds your name.":
"Your cloak settles into rust. The Threshold's stone still holds your name.";
show('scr-dead'); },1100);
}
function damageEnemy(e,dmg,fromx,fromy,src='melee'){
if(e.dead) return;
e.hp-=dmg; e.hurt=.28;
const tier=G.weapon;
const col = src==='bolt' ? '#8fdcff' : tier===0?'#ffb14a' : tier===1?'#ff7a22' : '#ffe08a';
burst(e.x,e.y-6,src==='bolt'?10:14,col,{spd:src==='bolt'?120:150,life:.42,size:2.6});
if(src==='bolt'){ burst(e.x,e.y-6,6,'#bfe9ff',{spd:90,life:.5,size:2}); }
if(tier>=1&&src==='melee'){ for(let i=0;i<7;i++) parts.push({x:e.x+rnd(9,-9),y:e.y+rnd(6,-10),vx:rnd(20,-20),vy:rnd(-16,-46),
life:rnd(1.1,.5),t:0,c:'#5a4a44',r:rnd(5,2),grav:-6,glow:false}); }
float(e.x,e.y-e.r-8,String(dmg), src==='bolt'?'#bfe9ff':col,14);
if(fromx!==undefined){ const a=Math.atan2(e.y-fromy,e.x-fromx); const k=src==='bolt'?90:(tier===2?200:120);
e.kb.x+=Math.cos(a)*k; e.kb.y+=Math.sin(a)*k; }
if(e.hp<=0) killEnemy(e);
else if(tier>=1) e.hurt=.45; // burn-flinch, longer stagger
}
function killEnemy(e){
e.dead=true; G.kills++;
SFX.enemyDie();
burst(e.x,e.y-4,26,'#c98a4a',{spd:170,life:.8,size:3.4,grav:170});
burst(e.x,e.y-4,12,'#3a3a44',{spd:80,life:1.3,size:5,grav:-10,glow:false});
ring(e.x,e.y,'rgba(255,170,90,.7)',34,.5);
const g=rint(e.gold[0],e.gold[1]);
Z.objects.push(mkObj('coin',e.x,e.y,{amt:g,vy:-40,bob:rnd(6.28)}));
if(e.kind==='heavy'&&Z.n===3&&!G.flags.h4){ Z.objects.push(mkObj('heart',e.x+rnd(16,-16),e.y+8,{fromBoss:true})); toast("The Bulwark's core cracks open…"); }
if(e.kind==='heavy'&&Z.n===5&&!G.inv.cloak&&!G.flags.cloakDrop){ G.flags.cloakDrop=true;
Z.objects.push(mkObj('drop',e.x,e.y+6,{item:'cloak'})); }
if(e.kind==='boss'){ onBossDown(e); }
}
/* --------------------------- PLAYER ACTIONS ------------------------------ */
function doAttack(){
const t=now(); if(t-P.tAtk<atkCD()) return;
P.tAtk=t; P.atkT=t; P.atkDir=Math.atan2(P.diry,P.dirx);
const w=W(); SFX.swing(G.weapon);
cam.shake=Math.max(cam.shake, G.weapon===2?7:3);
if(G.weapon===2) cam.shimmer=Math.max(cam.shimmer,.9);
// swing trail particles
const seg = G.weapon===2?16:11;
for(let i=0;i<seg;i++){ const a=P.atkDir-w.arc/2+w.arc*(i/(seg-1));
const rr=w.reach*rnd(1,.55);
parts.push({x:P.x+Math.cos(a)*rr,y:P.y+Math.sin(a)*rr-6,vx:Math.cos(a)*30,vy:Math.sin(a)*30-8,
life:rnd(.4,.18),t:0,c:G.weapon===0?'#c8d4e2':G.weapon===1?'#ff9a3c':'#ffe0a0',r:rnd(3.4,1.4),grav:0,glow:true}); }
// hit enemies
const wd = w.dmg;
for(const e of Z.enemies){ if(e.dead) continue;
const d=dist(P.x,P.y,e.x,e.y); if(d>w.reach+e.r) continue;
let da=Math.atan2(e.y-P.y,e.x-P.x)-P.atkDir; while(da>Math.PI)da-=6.283; while(da<-Math.PI)da+=6.283;
if(Math.abs(da)>w.arc/2+.25) continue;
const crit = Math.random()<0.16;
damageEnemy(e, crit?wd+Math.ceil(wd*.6):wd, P.x,P.y,'melee');
if(crit){ burst(e.x,e.y-6,10,'#7fd4ff',{spd:130,life:.5,size:2.8}); float(e.x+10,e.y-e.r-20,'CRIT','#9fe0ff',12); }
SFX.hit(G.weapon);
}
// hit breakables & cracked walls
hitBreakables(P.x+Math.cos(P.atkDir)*w.reach*.75, P.y+Math.sin(P.atkDir)*w.reach*.75, G.weapon===2?3:1, 'melee');
}
function hitBreakables(x,y,power,src){
// cracked wall tiles
const tx=Math.floor(x/TS), ty=Math.floor(y/TS);
for(let oy=-1;oy<=1;oy++)for(let ox=-1;ox<=1;ox++){
const cx=tx+ox, cy=ty+oy; if(cx<0||cy<0||cx>=Z.w||cy>=Z.h) continue;
if(Z.grid[cy][cx]!=='C') continue;
if(dist(x,y,cx*TS+16,cy*TS+16)>34) continue;
const key=`c${Z.n}_${cx}_${cy}`;
Z.crackHP=Z.crackHP||{}; Z.crackHP[key]=(Z.crackHP[key]||6)-power;
burst(cx*TS+16,cy*TS+16,8,'#a89078',{spd:110,life:.5,size:3,grav:200,glow:false});
if(Z.crackHP[key]<=0){ breakWallCluster(cx,cy); }
else { SFX.crack(); cam.shake=Math.max(cam.shake,4); }
return true;
}
// objects (jars, caches)
for(const o of Z.objects){ if(o.used) continue;
if((o.kind==='jar'||o.kind==='cache')&&dist(x,y,o.x,o.y)<30){ o.hp-=power; SFX.crack();
burst(o.x,o.y,10,o.kind==='jar'?'#a8704a':'#c9a06a',{spd:110,life:.5,size:2.8,grav:180,glow:false});
if(o.hp<=0) openObject(o,true);
return true; }
}
return false;
}
function breakWallCluster(cx,cy){
// flood-fill contiguous C tiles and remove them
const stack=[[cx,cy]], seen=new Set();
while(stack.length){ const [x,y]=stack.pop(); const k=x+','+y; if(seen.has(k))continue; seen.add(k);
if(x<0||y<0||x>=Z.w||y>=Z.h) continue; if(Z.grid[y][x]!=='C') continue;
Z.grid[y][x]=','; burst(x*TS+16,y*TS+16,16,'#b09878',{spd:150,life:.7,size:4,grav:230,glow:false});
stack.push([x+1,y],[x-1,y],[x,y+1],[x,y-1]);
}
SFX.smash(); cam.shake=12; cam.shimmer=Math.max(cam.shimmer,.6);
toast('The cracked masonry collapses!','blue');
Z.cracks=Z.cracks.filter(c=>Z.grid[c.y][c.x]==='C');
}
function shoot(){
if(!G.hasShard) return;
const t=now(); if(t-P.tShoot<.42) return;
if(G.ammo<=0){ SFX.deny(); toast('No Aether charges. Restock at the merchant altar.','bad'); return; }
P.tShoot=t; G.ammo--; SFX.bolt();
const a=Math.atan2(P.diry,P.dirx);
Z.bolts.push({x:P.x+Math.cos(a)*16,y:P.y+Math.sin(a)*16-4,vx:Math.cos(a)*440,vy:Math.sin(a)*440,life:1.5,t:0,trail:[]});
cam.shake=Math.max(cam.shake,2.5);
burst(P.x+Math.cos(a)*18,P.y+Math.sin(a)*18-4,7,'#9fe4ff',{spd:70,life:.35,size:2.2,grav:0});
}
function updateBolts(dt){
for(let i=Z.bolts.length-1;i>=0;i--){ const b=Z.bolts[i]; b.t+=dt;
b.trail.push({x:b.x,y:b.y}); if(b.trail.length>9)b.trail.shift();
const steps=3;
for(let s=0;s<steps;s++){
b.x+=b.vx*dt/steps; b.y+=b.vy*dt/steps;
let done=false;
// switches
for(const o of Z.objects){ if(o.kind==='switch'&&!o.lit&&dist(b.x,b.y,o.x,o.y)<19){ hitSwitch(o); done=true; break; } }
if(!done) for(const e of Z.enemies){ if(!e.dead&&dist(b.x,b.y,e.x,e.y)<e.r+5){ damageEnemy(e,3,b.x-b.vx*.02,b.y-b.vy*.02,'bolt'); SFX.boltHit(); done=true; break; } }
if(!done && solidAt(Z,b.x,b.y)){
if(!hitBreakables(b.x,b.y,3,'bolt')) { burst(b.x,b.y,8,'#8fdcff',{spd:100,life:.4,size:2.2,grav:30}); SFX.boltHit(); }
done=true;
}
if(!done){ for(const o of Z.objects){ if(o.used)continue;
if((o.kind==='jar'||o.kind==='cache')&&dist(b.x,b.y,o.x,o.y)<20){ o.hp-=3; if(o.hp<=0) openObject(o,true); else SFX.crack(); done=true; break; } } }
if(done||b.t>b.life){ Z.bolts.splice(i,1); s=steps; break; }
}
if(Z.bolts[i]) parts.push({x:b.x,y:b.y,vx:rnd(14,-14),vy:rnd(14,-14),life:.28,t:0,c:'#9fe4ff',r:rnd(2.4,1),grav:0,glow:true});
}
}
function hitSwitch(o){
o.lit=true; SFX.boltHit(); SFX.shimmer();
burst(o.x,o.y,26,'#8fdcff',{spd:160,life:.9,size:3,grav:-20});
ring(o.x,o.y,'rgba(143,220,255,.9)',40,.7);
cam.shimmer=1; cam.shake=5;
onSwitchLit(o);
}
/* --------------------------- ZONE PROGRESSION ---------------------------- */
function openGates(msg){
if(!Z.gates.length){ return; }
for(const g of Z.gates){ if(Z.grid[g.y][g.x]==='D'){ Z.grid[g.y][g.x]='.';
burst(g.x*TS+16,g.y*TS+16,14,'#c9a06a',{spd:120,life:.7,size:3,grav:120,glow:false}); } }
Z.gateOpen=true; SFX.gate(); cam.shake=10; cam.shimmer=.8;
toast(msg||'The gate grinds open.','blue');
}
function aliveCount(){ return Z.enemies.filter(e=>!e.dead).length; }
function checkZoneLogic(dt){
const n=Z.n;
if(n===1){
if(!Z.gateOpen && aliveCount()===0) openGates('The Threshold gate answers your hand.');
}
if(n===2){
if(!G.flags.shopOpen && G.kills>=3){ G.flags.shopOpen=true;
toast('A harmonic drone rises from the merchant altar.','blue'); narrate('Something at the altar has noticed you have coin.'); }
if(!Z.gateOpen && aliveCount()===0) openGates('The Archive doors unseal.');
}
if(n===3){
// timed plate puzzle
const t=now();
const recent=Z.plates.filter(p=>t-p.t<2.6).length;
if(!Z.gateOpen && recent>=2){ openGates('Both plates held — the gallery bridge lowers.'); }
}
if(n===4){
if(!Z.gateOpen){ const sw=Z.objects.find(o=>o.kind==='switch'); if(sw&&sw.lit) openGates('The vault sluice drains. The way opens.'); }
}
if(n===5){
if(!Z.flags5&&Z.objects.filter(o=>o.kind==='switch'&&o.lit).length>=2){ Z.flags5=true;
// grind open the cracked vault holding container 5
for(let y=0;y<Z.h;y++)for(let x=0;x<Z.w;x++) if(Z.grid[y][x]==='C'){ Z.grid[y][x]=','; burst(x*TS+16,y*TS+16,12,'#ffd9a0',{spd:130,life:.6,size:3,grav:180,glow:false}); }
Z.cracks=[]; SFX.smash(); SFX.shimmer(); cam.shimmer=1.2; cam.shake=11;
toast('Both crystal switches sing — the reliquary opens!','blue');
narrate('Machinery hidden in the spire wall retracts. A heart container waits in the alcove.');
}
if(!Z.gateOpen && aliveCount()===0) openGates('The stair to the Core stands clear.');
}
if(n===6){
const lit=Z.objects.filter(o=>o.kind==='switch'&&o.lit).length;
Z.seals=3-lit;
const boss=Z.enemies.find(e=>e.kind==='boss');
if(boss&&!boss.dead){ boss.armored = Z.seals>0; }
if(!G.flags.won && boss && boss.dead && Z.seals===0) triggerWin();
}
}
function onSwitchLit(o){
if(Z.n===4){ G.flags.vaultLight=true; toast('A beam of caught light reaches the dark pedestal.','blue');
narrate('The bolt\u2019s glow ricochets from the flooded glass and settles into the lantern\u2019s wick.'); }
if(Z.n===6){ toast(`Seal shattered — ${Z.seals-1} remain.`,'blue'); }
else if(Z.n===5){ toast('A crystal switch flares gold.','blue'); }
}
function onBossDown(e){
SFX.boss();
cam.shake=22; cam.shimmer=1.4;
for(let i=0;i<4;i++) setTimeout(()=>{ burst(e.x+rnd(40,-40),e.y+rnd(30,-30),30,'#ffb04a',{spd:200,life:1,size:4}); SFX.smash(); }, i*260);
narrate('The Warden of Ash folds inward, its black iron flaking away like burnt paper.');
if(Z.seals>0) toast('Seals still ring the Beacon — shatter them with Aether.','bad');
}
function triggerWin(){
G.flags.won=true; mode='win';
SFX.beacon(); cam.shimmer=2;
motes(Z.pw/2,Z.ph/2,80,'#ffd9a0',{rise:-90,life:2.4,size:3});
const summary = `The Core Beacon draws breath and floods the Observatory with morning.
You carried ${G.containers} heart containers, the ${W().name}, ${G.hasShard?'the Aether Shard':'no ranged tool'},
${Object.keys(G.equipped).filter(k=>G.equipped[k]).length} equipped accessories, ${G.gold} sigils, and ${G.kills} felled constructs.
${G.deaths===0?'You never fell.':`You fell ${G.deaths} time${G.deaths>1?'s':''}, and rose each time.`}
The Corrosion recedes down the valley walls. Aurelion holds.`;
$('wintxt').textContent=summary;
setTimeout(()=>show('scr-win'),1600);
}
/* --------------------------- INTERACTION --------------------------------- */
let promptObj=null;
function nearestInteractive(){
let best=null,bd=44;
for(const o of Z.objects){
if(!['stone','merchant','altar','chest','shardped','lantped','heart','coin','drop','jar','cache'].includes(o.kind)) continue;
if(o.used) continue;
if(o.kind==='merchant'&&!G.flags.shopOpen) continue;
if(o.kind==='coin'||o.kind==='drop'||o.kind==='heart') continue; // auto-pickup
if(o.kind==='jar'||o.kind==='cache') continue; // must be struck
const d=dist(P.x,P.y,o.x,o.y); if(d<bd){bd=d;best=o;}
}
if(Z.exit&&!solidAt(Z,Z.exit.x,Z.exit.y)&&dist(P.x,P.y,Z.exit.x,Z.exit.y)<40) return {kind:'exit',x:Z.exit.x,y:Z.exit.y};
return best;
}
function promptText(o){
switch(o.kind){
case 'stone': return 'Rest at the Sanctuary Stone — <b>E</b>';
case 'merchant': return 'Speak with the merchant altar — <b>E</b>';
case 'altar': return `Claim the weapon altar — <b>E</b>`;
case 'chest': return 'Open the chest — <b>E</b>';
case 'shardped': return 'Take the Aether Shard — <b>E</b>';
case 'lantped': return 'Take the Phantom Lantern — <b>E</b>';
case 'exit': return Z.n<6 ? 'Pass to the next hall — <b>E</b>' : 'Return — <b>E</b>';
}
return '';
}
function interact(){
const o=promptObj; if(!o) return;
if(o.kind==='exit'){
if(Z.n<6){ SFX.gate(); enterZone(Z.n+1); }
return;
}
switch(o.kind){
case 'stone': restAtStone(o); break;
case 'merchant': openShop(); break;
case 'altar': claimAltar(o); break;
case 'chest': openObject(o); break;
case 'shardped': o.used=true; giveShard(); break;
case 'lantped': o.used=true; giveItem('lantern',true); break;
}
}
function restAtStone(o){
G.hp=maxHP(); lastHeartSig='';
G.stones[o.id]=true;
G.save={ zone:Z.n, sx:o.x, sy:o.y, stone:o.id };
persist();
SFX.save(); cam.shimmer=1.1;
motes(P.x,P.y,40,'#ffe0a3',{rise:-60,life:1.8,size:2.6});
ring(o.x,o.y,'rgba(255,224,163,.9)',60,1.1);
flashEl('goldflash',.9);
toast('Sanctuary Stone '+o.id+' attuned — health restored, progress recorded.','blue');
narrate('Golden sparkles gather in the stone\u2019s hollow. Your name is written there now.');
}
function claimAltar(o){
o.used=true;
const tier=o.tier;
G.weapon=Math.max(G.weapon,tier);
SFX.rare(); SFX.shimmer(); cam.shimmer=1.6; cam.shake=8;
motes(P.x,P.y,50,tier===1?'#ff9a3c':'#ffe0a3',{rise:-80,life:2,size:3});
ring(P.x,P.y,tier===1?'rgba(255,150,60,.9)':'rgba(255,224,163,.95)',70,1);
flashEl('goldflash',1);
if(tier===1){ toast('IGNITED EDGE — the blade takes fire.','blue');
narrate('The altar\u2019s brazier empties into your steel. Faster now, and every swing trails embers.'); }
else { toast('SOLAR FALCHION — the blade takes the sun.','blue');
narrate('Heavier in the hand, slower to wind up — but it sweeps wide and breaks stone like bread.'); }
lastHeartSig='';
}
function giveShard(){
G.hasShard=true; G.ammo=Math.max(G.ammo,12);
SFX.rare(); SFX.shimmer(); cam.shimmer=1.6;
motes(P.x,P.y,55,'#9fe4ff',{rise:-80,life:2.2,size:3});
ring(P.x,P.y,'rgba(143,220,255,.95)',70,1);
toast('AETHER SHARD acquired — press K to fire. 12 charges.','blue');
narrate('A crystal launcher settles into your off-hand, pulsing cold. Distant switches are no longer distant.');
G.flags.shardZone=true;
}
function giveItem(id,rare=false){
G.inv[id]++;
if(ITEMS[id].type==='accessory'&&!G.equipped[id]&&equippedCount()<2) G.equipped[id]=true;
if(rare){ SFX.rare(); motes(P.x,P.y,34,'#ffffff',{rise:-70,life:1.6,size:2.6}); cam.shimmer=.9; }
else { SFX.pickup(); motes(P.x,P.y,18,'#ffffff',{rise:-55,life:1.2,size:2.2}); }
toast(`${ITEMS[id].name} acquired${ITEMS[id].type==='accessory'&&G.equipped[id]?' — equipped':''}.`, rare?'blue':'');
}
function equippedCount(){ return ['band','lantern','cloak'].filter(k=>G.equipped[k]).length; }
function giveGold(n,x,y){ G.gold+=n; SFX.coin();
for(let i=0;i<10;i++){ const a=rnd(6.28); parts.push({x,y,vx:Math.cos(a)*rnd(70,20),vy:Math.sin(a)*rnd(70,20)-30,
life:rnd(.9,.4),t:0,c:i%2?'#ffd77a':'#e8e2d0',r:rnd(2.6,1.2),grav:60,glow:true,swirl:true}); }
float(x,y-14,'+'+n+'✦','#ffd77a',14);
}
function giveContainer(x,y){
if(G.containers>=5){ giveGold(40,x,y); return; }
G.containers++; G.hp=maxHP(); lastHeartSig='';
SFX.heartUp(); cam.shimmer=1.8; flashEl('goldflash',1.4);
motes(P.x,P.y,70,'#ffcf94',{rise:-90,life:2.4,size:3.4});
ring(P.x,P.y,'rgba(255,207,148,.95)',90,1.3);
toast(`HEART CONTAINER ${G.containers} — maximum ${maxHP()} HP.`,'blue');
narrate('A golden aura climbs your body and settles. Your vigil can endure more now.');
if(G.containers>=4) G.flags.h4=true;
}
function openObject(o,smashed=false){
o.used=true;
const n=Z.n;
if(o.kind==='chest'){
SFX.smash&&null;
if(n===2&&!G.inv.band){ giveItem('band',true); return; }
if(n===5&&!G.inv.cloak){ giveItem('cloak',true); G.flags.cloakDrop=true; return; }
if(n===5){ giveGold(rint(90,140),o.x,o.y); toast('A bonus chest of sigils!','blue'); return; }
giveGold(rint(20,40),o.x,o.y); return;
}
if(o.kind==='jar'){
burst(o.x,o.y,16,'#a8704a',{spd:140,life:.6,size:3.4,grav:220,glow:false});
const roll=Math.random();
if(n===5&&!G.flags.sealFound){ G.flags.sealFound=true; giveItem('seal'); return; }
if(roll<.42) giveGold(rint(5,14),o.x,o.y);
else if(roll<.62){ giveItem('stem'); }
else if(roll<.72){ giveItem('seal'); }
else { float(o.x,o.y-10,'empty','#8fa2b3',12); A.noise({dur:.2,gain:.08,f:500,q:.8,f2:200}); }
return;
}
if(o.kind==='cache'){
burst(o.x,o.y,18,'#c9a06a',{spd:150,life:.7,size:3.4,grav:210,glow:false});
if(n===2&&!G.flags.stemCache){ G.flags.stemCache=true; giveItem('stem'); giveGold(rint(10,20),o.x,o.y); return; }
giveGold(rint(12,26),o.x,o.y);
if(Math.random()<.3) giveItem('stem');
return;
}
}
function useStem(){
if(G.inv.stem<=0){ SFX.deny(); toast('No Vital Stems.','bad'); return; }
if(G.hp>=maxHP()){ SFX.deny(); toast('Already whole.','bad'); return; }
G.inv.stem--; G.hp=Math.min(maxHP(),G.hp+4); lastHeartSig='';
SFX.drink(); motes(P.x,P.y,26,'#7fe89b',{rise:-60,life:1.3,size:2.4});
ring(P.x,P.y,'rgba(127,232,155,.8)',36,.6); float(P.x,P.y-24,'+4','#9dffb4',17);
toast('Vital Stem — two containers restored.');
}
function useSeal(){
if(G.inv.seal<=0){ SFX.deny(); toast('No Binding Seals.','bad'); return; }
let best=null,bd=260;
for(const e of Z.enemies){ if(e.dead)continue; const d=dist(P.x,P.y,e.x,e.y); if(d<bd){bd=d;best=e;} }
if(!best){ SFX.deny(); toast('Nothing to bind.','bad'); return; }
G.inv.seal--; best.frozen=3.0; SFX.freeze();
ring(best.x,best.y,'rgba(143,220,255,.95)',40,.8);
motes(best.x,best.y,22,'#bfe9ff',{rise:-30,life:1.2,size:2.2});
float(best.x,best.y-best.r-12,'BOUND','#bfe9ff',13);
toast(`${best.name} bound in place — 3 seconds.`,'blue');
}
/* ------------------------------ UPDATE ----------------------------------- */
function update(dt){
time+=dt;
if(mode!=='play'){ updateParts(dt); return; }
const t=now();
// ---- input / movement
let mx=(keys.right?1:0)-(keys.left?1:0), my=(keys.down?1:0)-(keys.up?1:0);
const attacking = t-P.atkT < (G.weapon===2?.30:.16);
if(mx||my){ const l=Math.hypot(mx,my); mx/=l; my/=l; P.dirx=mx; P.diry=my; if(mx) P.face=Math.sign(mx); }
const spd = (attacking? (G.weapon===2?58:96) : 168) * (P.dead?0:1);
P.vx=lerp(P.vx,mx*spd,1-Math.pow(0.0006,dt));
P.vy=lerp(P.vy,my*spd,1-Math.pow(0.0006,dt));
moveEntity(P,P.vx*dt,P.vy*dt,P.r);
P.walking=Math.hypot(P.vx,P.vy)>26;
P.anim+= (P.walking?Math.hypot(P.vx,P.vy)*.035:0)*dt*60*0.06;
P.breath+=dt;
if(P.iframe>0)P.iframe-=dt; if(P.hurtFlash>0)P.hurtFlash-=dt;
// footsteps
if(P.walking && t-P.tStep>0.30){ P.tStep=t;
const tile=tileAt(Z,P.x,P.y+8);
SFX.step(tile==='='?'metal':tile==='~'?'water':Z.mat);
const c = tile==='~'?'#9fdcea':Z.pal.dust;
for(let i=0;i<3;i++) parts.push({x:P.x+rnd(6,-6),y:P.y+10,vx:rnd(18,-18),vy:rnd(-6,-24),life:rnd(.6,.25),t:0,c,r:rnd(2.4,1),grav:14,glow:tile==='~'});
if(G.weapon>=1&&P.walking){ // ember trail
parts.push({x:P.x+rnd(5,-5)-P.face*8,y:P.y+rnd(4,-4),vx:rnd(8,-8),vy:rnd(-10,-28),life:rnd(.7,.3),t:0,
c:G.weapon===1?'#ff8a3c':'#ffd27a',r:rnd(2.6,1),grav:-4,glow:true});
}
}
if(wasPressed('atk')) doAttack();
if(wasPressed('shoot')) shoot();
if(wasPressed('stem')) useStem();
if(wasPressed('seal')) useSeal();
if(wasPressed('inv')) openInv();
// ---- interactions
promptObj=nearestInteractive();
const pr=$('prompt');
if(promptObj){
let txt=promptText(promptObj);
if(promptObj.kind==='lantped'&&!G.flags.vaultLight) txt='The lantern is dark — a beam must reach it first.';
if(promptObj.kind==='shardped'&&false) txt='';
pr.style.display='block'; pr.innerHTML=txt;
} else pr.style.display='none';
if(wasPressed('act')&&promptObj){
if(promptObj.kind==='lantped'&&!G.flags.vaultLight){ SFX.deny(); }
else interact();
}
// merchant drone proximity
const merch=Z.objects.find(o=>o.kind==='merchant');
A.drone(!!(merch&&G.flags.shopOpen&&dist(P.x,P.y,merch.x,merch.y)<130));
// ---- pickups
for(let i=Z.objects.length-1;i>=0;i--){ const o=Z.objects[i];
if(o.kind==='coin'&&!o.used){ o.bob+=dt*6;
const d=dist(P.x,P.y,o.x,o.y);
if(d<70){ const a=Math.atan2(P.y-o.y,P.x-o.x); o.x+=Math.cos(a)*180*dt; o.y+=Math.sin(a)*180*dt; }
if(d<18){ o.used=true; giveGold(o.amt,o.x,o.y); Z.objects.splice(i,1); }
}
if(o.kind==='heart'&&!o.used&&dist(P.x,P.y,o.x,o.y)<22){ o.used=true; giveContainer(o.x,o.y); Z.objects.splice(i,1); }
if(o.kind==='drop'&&!o.used&&dist(P.x,P.y,o.x,o.y)<24){ o.used=true; giveItem(o.item,true); Z.objects.splice(i,1); }
}
// ---- plates
for(const p of Z.plates){
const on=dist(P.x,P.y,p.x,p.y)<20 || Z.enemies.some(e=>!e.dead&&dist(e.x,e.y,p.x,p.y)<20);
if(on&&!p.on){ p.on=true; p.t=t; SFX.plate(); burst(p.x,p.y,10,'#ffd9a0',{spd:70,life:.5,size:2.2,grav:40});
toast('A bronze plate depresses with a clank.'); }
else if(!on&&p.on){ p.on=false; }
}
// ---- enemies
for(const e of Z.enemies){
if(e.dead) continue;
if(e.hurt>0) e.hurt-=dt;
if(e.frozen>0){ e.frozen-=dt;
if(Math.random()<.3) parts.push({x:e.x+rnd(12,-12),y:e.y+rnd(12,-12),vx:0,vy:rnd(-6,-18),life:.6,t:0,c:'#bfe9ff',r:rnd(2,.8),grav:0,glow:true});
continue; }
e.wob+=dt;
const d=dist(P.x,P.y,e.x,e.y);
const aggro = e.kind==='boss'?900:250;
// knockback decay
e.x+=e.kb.x*dt; e.y+=e.kb.y*dt; e.kb.x*=Math.pow(.02,dt); e.kb.y*=Math.pow(.02,dt);
if(P.dead){ continue; }
if(d<aggro && e.hurt<=0){
if(d>e.atkR){
const a=Math.atan2(P.y-e.y,P.x-e.x);
const strafe=Math.sin(e.wob*1.7)*.5;
moveEntity(e,Math.cos(a+strafe)*e.spd*dt,Math.sin(a+strafe)*e.spd*dt,e.r*.75);
e.dir=Math.sign(Math.cos(a))||e.dir; e.state='chase';
} else {
e.state='attack';
if(t-e.tAtk>e.cd){ e.tAtk=t; e.windup=.28;
setTimeout(()=>{ if(e.dead||mode!=='play')return;
if(dist(P.x,P.y,e.x,e.y)<e.atkR+16){ hurtPlayer(e.dmg,e.x,e.y); }
burst(e.x+(P.x-e.x)*.5,e.y+(P.y-e.y)*.5,10,'#7a3a2a',{spd:110,life:.4,size:2.6,grav:60,glow:false});
A.noise({dur:.14,gain:.13,f:340,q:1,f2:140,rev:.3});
},280);
}
}
} else { // idle patrol around home
const a=Math.atan2(e.hy-e.y,e.hx-e.x)+Math.sin(e.wob*.8)*1.4;
if(dist(e.x,e.y,e.hx,e.hy)>10) moveEntity(e,Math.cos(a)*e.spd*.35*dt,Math.sin(a)*e.spd*.35*dt,e.r*.75);
e.state='idle';
}
if(e.windup>0)e.windup-=dt;
// boss special: corrosion spill
if(e.kind==='boss'){
e.spill=(e.spill||0)-dt;
if(e.spill<=0&&d<340){ e.spill=2.4;
for(let i=0;i<10;i++){ const a=rnd(6.28);
Z.objects.push(mkObj('spill',e.x+Math.cos(a)*rnd(90,30),e.y+Math.sin(a)*rnd(90,30),{life:2.6,born:t})); }
A.tone(70,{type:'sawtooth',dur:.8,gain:.1,f2:44,rev:.6});
}
if(e.armored){ /* takes reduced damage until seals broken */ }
}
// contact damage
if(d<e.r+P.r-3) hurtPlayer(e.kind==='boss'?2:1,e.x,e.y);
}
// spills (traps)
for(let i=Z.objects.length-1;i>=0;i--){ const o=Z.objects[i];
if(o.kind==='spill'){ if(t-o.born>o.life){ Z.objects.splice(i,1); continue; }
if(dist(P.x,P.y,o.x,o.y)<16) hurtPlayer(1,o.x,o.y,'trap');
if(Math.random()<.15) parts.push({x:o.x+rnd(10,-10),y:o.y+rnd(8,-8),vx:0,vy:rnd(-8,-24),life:.7,t:0,c:'#2a2030',r:rnd(3,1.2),grav:0,glow:false});
}
}
updateBolts(dt);
checkZoneLogic(dt);
// ---- camera
const tx=clamp(P.x-VW/2,0,Math.max(0,Z.pw-VW)), ty=clamp(P.y-VH/2,0,Math.max(0,Z.ph-VH));
cam.x=lerp(cam.x,tx,1-Math.pow(0.0008,dt)); cam.y=lerp(cam.y,ty,1-Math.pow(0.0008,dt));
if(cam.shake>0) cam.shake=Math.max(0,cam.shake-dt*26);
if(cam.shimmer>0) cam.shimmer=Math.max(0,cam.shimmer-dt*.55);
// mist
for(const m of Z.mist){ m.x+=m.vx*dt; m.y+=m.vy*dt; m.p+=dt;
if(m.y<-40){m.y=Z.ph+30;m.x=rnd(Z.pw);} if(m.x<-40)m.x=Z.pw+30; if(m.x>Z.pw+40)m.x=-30; }
updateParts(dt);
if(narrT&&now()>narrT){ $('narr').style.opacity=0; narrT=0; }
drawHUD();
}
/* ------------------------------ RENDERING -------------------------------- */
function render(){
const p=Z?Z.pal:PALETTES.threshold;
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle=p.tint; ctx.fillRect(0,0,VW,VH);
if(!Z) return;
const sx=(Math.random()-.5)*cam.shake, sy=(Math.random()-.5)*cam.shake;
const cx=Math.round(-cam.x+sx), cy=Math.round(-cam.y+sy);
ctx.save(); ctx.translate(cx,cy);
const x0=Math.max(0,Math.floor(cam.x/TS)-1), x1=Math.min(Z.w-1,Math.ceil((cam.x+VW)/TS)+1);
const y0=Math.max(0,Math.floor(cam.y/TS)-1), y1=Math.min(Z.h-1,Math.ceil((cam.y+VH)/TS)+1);
// ---- floors
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
const t=Z.grid[y][x], px=x*TS, py=y*TS;
if(t==='#'||t==='C'||t==='D'||t==='p') continue;
const chk=(x+y)&1;
if(t==='~'){
const w=Math.sin(time*1.6+x*.5+y*.7)*.5+.5;
ctx.fillStyle=chk?'#16303c':'#122934'; ctx.fillRect(px,py,TS,TS);
ctx.fillStyle=`rgba(120,220,255,${.05+w*.06})`; ctx.fillRect(px,py,TS,TS);
ctx.strokeStyle=`rgba(160,235,255,${.06+w*.09})`; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(px,py+8+Math.sin(time*2+x)*3); ctx.lineTo(px+TS,py+8+Math.sin(time*2+x+1)*3); ctx.stroke();
ctx.beginPath(); ctx.moveTo(px,py+22+Math.sin(time*1.7+y)*3); ctx.lineTo(px+TS,py+22+Math.sin(time*1.7+y+1)*3); ctx.stroke();
} else if(t==='='){
ctx.fillStyle='#2a231c'; ctx.fillRect(px,py,TS,TS);
ctx.strokeStyle='rgba(190,140,80,.35)'; ctx.lineWidth=1.4;
for(let i=0;i<4;i++){ ctx.beginPath(); ctx.moveTo(px+2+i*8,py+1); ctx.lineTo(px+2+i*8,py+TS-1); ctx.stroke(); }
ctx.strokeStyle='rgba(120,90,50,.5)'; ctx.beginPath(); ctx.moveTo(px,py+16); ctx.lineTo(px+TS,py+16); ctx.stroke();
} else if(t===','){
ctx.fillStyle=chk?p.floorA:p.floorB; ctx.fillRect(px,py,TS,TS);
ctx.fillStyle='rgba(0,0,0,.22)';
for(let i=0;i<4;i++){ const rx=px+((x*7+i*11+y*3)%26), ry=py+((y*5+i*13+x*3)%26);
ctx.fillRect(rx,ry,3+((i+x)%3),2+((i+y)%3)); }
ctx.fillStyle='rgba(200,160,110,.07)'; ctx.fillRect(px,py,TS,2);
} else {
ctx.fillStyle=chk?p.floorA:p.floorB; ctx.fillRect(px,py,TS,TS);
ctx.strokeStyle='rgba(0,0,0,.20)'; ctx.lineWidth=1; ctx.strokeRect(px+.5,py+.5,TS-1,TS-1);
ctx.fillStyle='rgba(255,220,170,.028)'; ctx.fillRect(px+1,py+1,TS-2,3);
}
}
// ---- walls
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
const t=Z.grid[y][x], px=x*TS, py=y*TS;
if(t==='#'){ drawWall(px,py,x,y,p); }
else if(t==='p'){ drawPillar(px,py,p); }
else if(t==='C'){ drawCrack(px,py,x,y,p); }
else if(t==='D'){ drawGate(px,py,x,y); }
}
// ---- floor decor
for(const d of Z.decor){ if(d.x<cam.x-20||d.x>cam.x+VW+20)continue;
ctx.globalAlpha=d.a; ctx.fillStyle=d.c; ctx.beginPath(); ctx.arc(d.x,d.y,d.r,0,6.283); ctx.fill(); }
ctx.globalAlpha=1;
// ---- objects (below player)
for(const o of Z.objects) drawObject(o);
// ---- enemies
for(const e of Z.enemies) if(!e.dead) drawEnemy(e);
// ---- particles behind? draw all after
drawPlayer();
drawBolts();
drawParticles();
// ---- floats
ctx.textAlign='center'; ctx.font='700 15px Cinzel, serif';
for(const f of floats){ const a=1-f.t/f.life;
ctx.globalAlpha=a; ctx.fillStyle='rgba(0,0,0,.6)'; ctx.font=`700 ${f.size}px Cinzel, serif`;
ctx.fillText(f.txt,f.x+1,f.y+1); ctx.fillStyle=f.col; ctx.fillText(f.txt,f.x,f.y); }
ctx.globalAlpha=1;
// ---- environment overlay: mist / dust
ctx.save();
for(const m of Z.mist){
const a=m.a*(Z.mat==='water'?2.0:1);
ctx.globalAlpha=a; ctx.fillStyle=Z.mat==='water'?'#9fdcea':p.dust;
ctx.beginPath(); ctx.arc(m.x,m.y,m.r*(1+Math.sin(m.p)*.2),0,6.283); ctx.fill();
}
ctx.restore();
ctx.restore();
// ---- lighting pass (screen space)
drawLighting(p);
// ---- shimmer edges
if(cam.shimmer>0){
const a=Math.min(1,cam.shimmer)*.5;
const g=ctx.createLinearGradient(0,0,0,VH);
g.addColorStop(0,`rgba(255,215,150,${a})`); g.addColorStop(.25,'rgba(255,215,150,0)');
g.addColorStop(.75,'rgba(255,215,150,0)'); g.addColorStop(1,`rgba(255,215,150,${a})`);
ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
const g2=ctx.createLinearGradient(0,0,VW,0);
g2.addColorStop(0,`rgba(255,215,150,${a})`); g2.addColorStop(.2,'rgba(255,215,150,0)');
g2.addColorStop(.8,'rgba(255,215,150,0)'); g2.addColorStop(1,`rgba(255,215,150,${a})`);
ctx.fillStyle=g2; ctx.fillRect(0,0,VW,VH);
}
if(P.hurtFlash>0){ ctx.fillStyle=`rgba(200,30,20,${P.hurtFlash*.30})`; ctx.fillRect(0,0,VW,VH); }
// objective banner for seals in zone 6
if(Z.n===6){ ctx.textAlign='center'; ctx.font='600 14px Cinzel, serif';
ctx.fillStyle='rgba(0,0,0,.55)'; ctx.fillRect(VW/2-120,VH-40,240,26);
ctx.strokeStyle='rgba(240,180,90,.4)'; ctx.strokeRect(VW/2-120,VH-40,240,26);
ctx.fillStyle=Z.seals?'#ffb0a0':'#ffe0a3';
ctx.fillText(Z.seals?`BEACON SEALS INTACT: ${Z.seals}`:'BEACON SEALS SHATTERED',VW/2,VH-22); }
}
function drawWall(px,py,x,y,p){
const h=((x*31+y*17)%5);
ctx.fillStyle=p.wallB; ctx.fillRect(px,py,TS,TS);
ctx.fillStyle=p.wallA; ctx.fillRect(px+1,py+1,TS-2,TS-8);
// brick lines
ctx.strokeStyle='rgba(0,0,0,.34)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(px,py+16.5); ctx.lineTo(px+TS,py+16.5);
ctx.moveTo(px+(y%2?8.5:24.5),py); ctx.lineTo(px+(y%2?8.5:24.5),py+16);
ctx.moveTo(px+(y%2?24.5:8.5),py+16); ctx.lineTo(px+(y%2?24.5:8.5),py+TS); ctx.stroke();
// top highlight
ctx.fillStyle='rgba(255,220,160,.10)'; ctx.fillRect(px+1,py+1,TS-2,2);
// bottom shadow / moss
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(px,py+TS-6,TS,6);
if(h===0){ ctx.fillStyle='rgba(90,130,80,.18)'; ctx.fillRect(px+2,py+TS-9,TS-6,7); }
if(h===3){ ctx.fillStyle='rgba(150,90,50,.14)'; ctx.fillRect(px+5,py+6,TS-12,8); }
}
function drawPillar(px,py,p){
ctx.fillStyle='#1a1a20'; ctx.fillRect(px+3,py+2,TS-6,TS-2);
const g=ctx.createLinearGradient(px,0,px+TS,0);
g.addColorStop(0,'#2a2630'); g.addColorStop(.4,'#4a4450'); g.addColorStop(1,'#221e28');
ctx.fillStyle=g; ctx.fillRect(px+5,py,TS-10,TS);
ctx.fillStyle='rgba(240,180,90,.12)'; ctx.fillRect(px+2,py,TS-4,5);
ctx.fillStyle='rgba(0,0,0,.4)'; ctx.fillRect(px+2,py+TS-5,TS-4,5);
}
function drawCrack(px,py,x,y,p){
drawWall(px,py,x,y,p);
ctx.fillStyle='rgba(20,14,10,.5)'; ctx.fillRect(px,py,TS,TS);
const seed=(x*13+y*29)%4;
const revealed = G.equipped.lantern;
const pulse = revealed? .55+Math.sin(time*3+x)*.3 : .32;
ctx.strokeStyle=revealed?`rgba(150,220,255,${pulse})`:`rgba(210,180,140,${pulse})`;
ctx.lineWidth=revealed?1.7:1.2;
ctx.beginPath();
const pts=[[4,2],[12,10],[8,18],[16,24],[10,30]];
ctx.moveTo(px+pts[0][0]+seed,py+pts[0][1]);
for(let i=1;i<pts.length;i++) ctx.lineTo(px+pts[i][0]+seed,py+pts[i][1]);
ctx.moveTo(px+12+seed,py+10); ctx.lineTo(px+24,py+6);
ctx.moveTo(px+16+seed,py+24); ctx.lineTo(px+27,py+27);
ctx.stroke();
if(revealed){ ctx.globalAlpha=.14+Math.sin(time*3+y)*.07; ctx.fillStyle='#6fc9ff'; ctx.fillRect(px,py,TS,TS); ctx.globalAlpha=1; }
// damage state
const key=`c${Z.n}_${x}_${y}`; const hp=Z.crackHP&&Z.crackHP[key];
if(hp!==undefined&&hp<6){ ctx.strokeStyle='rgba(255,150,90,.6)'; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.moveTo(px+2,py+4); ctx.lineTo(px+28,py+29); ctx.moveTo(px+28,py+3); ctx.lineTo(px+4,py+28); ctx.stroke(); }
}
function drawGate(px,py,x,y){
ctx.fillStyle='#1a120c'; ctx.fillRect(px,py,TS,TS);
const g=ctx.createLinearGradient(px,py,px,py+TS);
g.addColorStop(0,'#6b4a26'); g.addColorStop(.5,'#3d2a17'); g.addColorStop(1,'#241708');
ctx.fillStyle=g; ctx.fillRect(px+1,py+1,TS-2,TS-2);
ctx.strokeStyle='rgba(240,180,90,.4)'; ctx.lineWidth=1.6;
for(let i=0;i<3;i++){ ctx.beginPath(); ctx.arc(px+16,py+8+i*11,6,0,6.283); ctx.stroke(); }
ctx.fillStyle='rgba(255,200,120,.13)'; ctx.fillRect(px+1,py+1,TS-2,2);
// rune glow when locked
ctx.globalAlpha=.4+Math.sin(time*2+y)*.2; ctx.fillStyle='#c9552f';
ctx.fillRect(px+13,py+13,6,6); ctx.globalAlpha=1;
}
function drawObject(o){
if(o.x<cam.x-60||o.x>cam.x+VW+60||o.y<cam.y-60||o.y>cam.y+VH+60) return;
const x=o.x,y=o.y, pl=Math.sin(time*2+o.pulse);
ctx.save();
switch(o.kind){
case 'stone':{
const on=G.stones[o.id];
shadow(x,y+12,16,6);
ctx.fillStyle='#2b2f38'; ctx.beginPath(); ctx.moveTo(x-14,y+12); ctx.lineTo(x-10,y-16); ctx.lineTo(x+10,y-16); ctx.lineTo(x+14,y+12); ctx.closePath(); ctx.fill();
ctx.fillStyle='#3d434f'; ctx.beginPath(); ctx.moveTo(x-11,y+10); ctx.lineTo(x-8,y-14); ctx.lineTo(x+3,y-14); ctx.lineTo(x+5,y+10); ctx.closePath(); ctx.fill();
// glowing hollow
const gr=ctx.createRadialGradient(x,y-6,1,x,y-6,20+pl*4);
gr.addColorStop(0,`rgba(255,235,190,${on?.95:.6})`); gr.addColorStop(.4,`rgba(255,190,100,${on?.5:.25})`); gr.addColorStop(1,'rgba(255,180,90,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(x,y-6,24+pl*4,0,6.283); ctx.fill();
ctx.fillStyle=on?'#fff3d0':'#ffd28a'; ctx.beginPath(); ctx.arc(x,y-6,4.5+pl*.7,0,6.283); ctx.fill();
// sparkles
for(let i=0;i<4;i++){ const a=time*.9+i*1.57+o.pulse;
ctx.globalAlpha=.35+Math.sin(a*2)*.3; ctx.fillStyle='#ffe6b0';
ctx.fillRect(x+Math.cos(a)*17-1,y-6+Math.sin(a)*14-1,2,2); }
ctx.globalAlpha=1;
ctx.font='600 9px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='rgba(255,224,163,.6)';
ctx.fillText('SANCTUARY '+o.id,x,y+24);
break;}
case 'merchant':{
shadow(x,y+14,20,7);
const open=G.flags.shopOpen;
ctx.fillStyle='#241c14'; ctx.fillRect(x-18,y-6,36,20);
const g=ctx.createLinearGradient(x,y-6,x,y+14); g.addColorStop(0,'#5c4326'); g.addColorStop(1,'#2a1d10');
ctx.fillStyle=g; ctx.fillRect(x-16,y-4,32,17);
ctx.strokeStyle='rgba(240,180,90,.5)'; ctx.lineWidth=1.2; ctx.strokeRect(x-16.5,y-4.5,33,18);
// hooded merchant
if(open){
ctx.fillStyle='#191621'; ctx.beginPath(); ctx.ellipse(x,y-16,11,15,0,0,6.283); ctx.fill();
ctx.fillStyle='#2e2740'; ctx.beginPath(); ctx.ellipse(x,y-22,8,9,0,0,6.283); ctx.fill();
ctx.fillStyle='#ffca6a'; ctx.beginPath(); ctx.arc(x-3,y-22,1.7,0,6.283); ctx.arc(x+3,y-22,1.7,0,6.283); ctx.fill();
const gr=ctx.createRadialGradient(x,y-14,2,x,y-14,40); gr.addColorStop(0,'rgba(255,190,90,.20)'); gr.addColorStop(1,'rgba(255,190,90,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(x,y-14,40,0,6.283); ctx.fill();
// wares
ctx.fillStyle='#6fe08a'; ctx.beginPath(); ctx.arc(x-9,y+2,3,0,6.283); ctx.fill();
ctx.strokeStyle='#6fc9ff'; ctx.lineWidth=1.4; ctx.beginPath(); ctx.arc(x,y+2,3.4,0,6.283); ctx.stroke();
ctx.fillStyle='#f0913c'; ctx.beginPath(); ctx.arc(x+9,y+2,3,0,6.283); ctx.fill();
ctx.font='600 9px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='rgba(255,224,163,.65)'; ctx.fillText('MERCHANT ALTAR',x,y+26);
} else { ctx.font='600 9px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='rgba(160,150,140,.45)'; ctx.fillText('DORMANT ALTAR',x,y+26); }
break;}
case 'altar':{
shadow(x,y+14,20,7);
const used=o.used, t1=o.tier===1;
ctx.fillStyle='#20242c'; ctx.beginPath(); ctx.moveTo(x-18,y+14); ctx.lineTo(x-13,y-8); ctx.lineTo(x+13,y-8); ctx.lineTo(x+18,y+14); ctx.closePath(); ctx.fill();
ctx.fillStyle='#343a46'; ctx.fillRect(x-14,y-10,28,5);
if(!used){
const col=t1?'255,140,50':'255,220,140';
const gr=ctx.createRadialGradient(x,y-26,2,x,y-26,34+pl*6);
gr.addColorStop(0,`rgba(${col},.95)`); gr.addColorStop(.35,`rgba(${col},.4)`); gr.addColorStop(1,`rgba(${col},0)`);
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(x,y-26,36+pl*6,0,6.283); ctx.fill();
// floating blade
ctx.save(); ctx.translate(x,y-26+Math.sin(time*1.5)*3); ctx.rotate(Math.sin(time*.7)*.12);
ctx.fillStyle=t1?'#ffb057':'#ffe9a8'; ctx.fillRect(-2,-16,4,26);
ctx.fillStyle='#8a7050'; ctx.fillRect(-6,9,12,3); ctx.fillRect(-1.6,12,3.2,7);
ctx.restore();
if(Math.random()<.5) parts.push({x:x+rnd(14,-14),y:y-20,vx:rnd(8,-8),vy:rnd(-14,-34),life:rnd(1.1,.5),t:0,
c:t1?'#ff9a3c':'#ffe0a3',r:rnd(2.4,1),grav:-6,glow:true});
ctx.font='600 9px Cinzel'; ctx.textAlign='center'; ctx.fillStyle=t1?'#ffb057':'#ffe9a8';
ctx.fillText(t1?'ALTAR OF THE IGNITED EDGE':'ALTAR OF THE SOLAR FALCHION',x,y+26);
} else { ctx.font='600 9px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='rgba(150,140,130,.4)'; ctx.fillText('ALTAR — CLAIMED',x,y+26); }
break;}
case 'chest':{
shadow(x,y+12,15,6);
ctx.fillStyle='#2a1c10'; ctx.fillRect(x-13,y-4,26,16);
const g=ctx.createLinearGradient(x,y-4,x,y+12); g.addColorStop(0,'#7a5628'); g.addColorStop(1,'#3a2712');
ctx.fillStyle=g; ctx.fillRect(x-12,y-3,24,14);
if(o.used){ ctx.fillStyle='#120c06'; ctx.fillRect(x-11,y-10,22,7); }
else { ctx.fillStyle='#5c4020'; ctx.beginPath(); ctx.moveTo(x-12,y-3); ctx.quadraticCurveTo(x,y-16,x+12,y-3); ctx.fill();
ctx.strokeStyle='rgba(255,210,130,.55)'; ctx.lineWidth=1.4; ctx.stroke();
ctx.fillStyle='#ffd27a'; ctx.fillRect(x-2.5,y-1,5,6);
ctx.globalAlpha=.2+pl*.12; ctx.fillStyle='#ffd27a'; ctx.beginPath(); ctx.arc(x,y-2,20,0,6.283); ctx.fill(); ctx.globalAlpha=1; }
ctx.strokeStyle='rgba(240,180,90,.4)'; ctx.lineWidth=1; ctx.strokeRect(x-12.5,y-3.5,25,15);
break;}
case 'jar':{
if(o.used) break;
shadow(x,y+10,10,4);
const g=ctx.createLinearGradient(x-8,0,x+8,0); g.addColorStop(0,'#8a5030'); g.addColorStop(.4,'#b8724a'); g.addColorStop(1,'#6a3a22');
ctx.fillStyle=g; ctx.beginPath(); ctx.moveTo(x-8,y+10); ctx.quadraticCurveTo(x-11,y-4,x-4,y-10);
ctx.lineTo(x+4,y-10); ctx.quadraticCurveTo(x+11,y-4,x+8,y+10); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(255,220,170,.14)'; ctx.fillRect(x-4,y-10,8,2);
ctx.strokeStyle='rgba(0,0,0,.3)'; ctx.beginPath(); ctx.moveTo(x-9,y+1); ctx.lineTo(x+9,y+1); ctx.stroke();
if(o.hp<1.5){ ctx.strokeStyle='rgba(30,20,10,.7)'; ctx.beginPath(); ctx.moveTo(x-4,y-6); ctx.lineTo(x+2,y+4); ctx.stroke(); }
break;}
case 'cache':{
if(o.used) break;
shadow(x,y+10,12,5);
ctx.fillStyle='#3a3128'; ctx.fillRect(x-11,y-8,22,18);
ctx.fillStyle='#59493a'; ctx.fillRect(x-9,y-6,18,14);
ctx.strokeStyle='rgba(0,0,0,.45)'; ctx.strokeRect(x-11.5,y-8.5,23,19);
// crack marks + lantern reveal
const rev=G.equipped.lantern;
ctx.strokeStyle=rev?`rgba(150,220,255,${.5+pl*.3})`:'rgba(200,170,130,.35)'; ctx.lineWidth=1.2;
ctx.beginPath(); ctx.moveTo(x-6,y-5); ctx.lineTo(x-1,y+1); ctx.lineTo(x-4,y+7); ctx.moveTo(x-1,y+1); ctx.lineTo(x+6,y-2); ctx.stroke();
if(rev){ ctx.globalAlpha=.16+pl*.08; ctx.fillStyle='#6fc9ff'; ctx.beginPath(); ctx.arc(x,y,18,0,6.283); ctx.fill(); ctx.globalAlpha=1; }
if(o.hp<2){ ctx.strokeStyle='rgba(255,150,90,.6)'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(x-8,y-6); ctx.lineTo(x+8,y+8); ctx.stroke(); }
break;}
case 'plate':{
const d=o.on?2:0;
ctx.fillStyle='#1a1610'; ctx.fillRect(x-16,y-12,32,26);
const g=ctx.createLinearGradient(x,y-12,x,y+12); g.addColorStop(0,o.on?'#8a6a34':'#6a5228'); g.addColorStop(1,'#3a2c14');
ctx.fillStyle=g; ctx.fillRect(x-14,y-10+d,28,20-d);
ctx.strokeStyle=o.on?'rgba(255,220,140,.8)':'rgba(240,180,90,.35)'; ctx.lineWidth=1.4;
ctx.strokeRect(x-14.5,y-10.5+d,29,20.5-d);
ctx.beginPath(); ctx.arc(x,y+d,6,0,6.283); ctx.stroke();
if(o.on){ ctx.globalAlpha=.25+pl*.1; ctx.fillStyle='#ffd9a0'; ctx.beginPath(); ctx.arc(x,y,24,0,6.283); ctx.fill(); ctx.globalAlpha=1; }
const rem=Math.max(0,2.6-(now()-o.t));
if(rem>0&&rem<2.6){ ctx.fillStyle='rgba(255,220,150,.8)'; ctx.fillRect(x-14,y-16,28*(rem/2.6),3); }
break;}
case 'switch':{
const lit=o.lit;
shadow(x,y+12,10,4);
ctx.fillStyle='#232a30'; ctx.fillRect(x-6,y+2,12,10);
ctx.save(); ctx.translate(x,y-6+Math.sin(time*2+o.pulse)*2); ctx.rotate(time*.5);
const col=lit?'255,225,150':'110,190,230';
const gr=ctx.createRadialGradient(0,0,1,0,0,26); gr.addColorStop(0,`rgba(${col},${lit?.95:.5})`);
gr.addColorStop(.4,`rgba(${col},${lit?.4:.16})`); gr.addColorStop(1,`rgba(${col},0)`);
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,0,26,0,6.283); ctx.fill();
ctx.fillStyle=lit?'#fff0c0':'#bfe9ff'; ctx.beginPath();
ctx.moveTo(0,-10); ctx.lineTo(7,0); ctx.lineTo(0,10); ctx.lineTo(-7,0); ctx.closePath(); ctx.fill();
ctx.strokeStyle=lit?'#fff':'#e0f6ff'; ctx.lineWidth=1; ctx.stroke();
ctx.restore();
if(!lit&&Math.random()<.25) parts.push({x:x+rnd(10,-10),y:y+rnd(8,-8),vx:0,vy:rnd(-6,-18),life:.8,t:0,c:'#8fdcff',r:rnd(1.8,.7),grav:0,glow:true});
ctx.font='600 8px Cinzel'; ctx.textAlign='center'; ctx.fillStyle=lit?'rgba(255,224,163,.6)':'rgba(143,220,255,.55)';
ctx.fillText(lit?'STRUCK':'AETHER SWITCH',x,y+24);
break;}
case 'shardped': case 'lantped':{
if(o.used) break;
const isShard=o.kind==='shardped';
const dark = !isShard && !G.flags.vaultLight;
shadow(x,y+12,14,6);
ctx.fillStyle='#1e2630'; ctx.beginPath(); ctx.moveTo(x-13,y+12); ctx.lineTo(x-9,y-4); ctx.lineTo(x+9,y-4); ctx.lineTo(x+13,y+12); ctx.closePath(); ctx.fill();
ctx.fillStyle='#2f3a46'; ctx.fillRect(x-11,y-7,22,4);
if(dark){ ctx.fillStyle='rgba(120,140,160,.5)'; ctx.beginPath(); ctx.arc(x,y-16,6,0,6.283); ctx.fill();
ctx.font='600 8px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='rgba(140,160,180,.5)'; ctx.fillText('UNLIT PEDESTAL',x,y+24); break; }
const col=isShard?'150,225,255':'160,215,255';
const gr=ctx.createRadialGradient(x,y-20,2,x,y-20,38+pl*6);
gr.addColorStop(0,`rgba(${col},.95)`); gr.addColorStop(.35,`rgba(${col},.35)`); gr.addColorStop(1,`rgba(${col},0)`);
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(x,y-20,40,0,6.283); ctx.fill();
ctx.save(); ctx.translate(x,y-20+Math.sin(time*1.6)*3);
if(isShard){ ctx.rotate(time*.8);
ctx.fillStyle='#dff4ff'; ctx.beginPath(); ctx.moveTo(0,-13); ctx.lineTo(6,-2); ctx.lineTo(0,13); ctx.lineTo(-6,-2); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(120,200,255,.7)'; ctx.beginPath(); ctx.moveTo(0,-13); ctx.lineTo(6,-2); ctx.lineTo(0,3); ctx.closePath(); ctx.fill();
} else {
ctx.fillStyle='rgba(160,215,255,.35)'; ctx.fillRect(-7,-9,14,17);
ctx.strokeStyle='#bfe9ff'; ctx.lineWidth=1.5; ctx.strokeRect(-7,-9,14,17);
ctx.fillStyle='#e8faff'; ctx.beginPath(); ctx.arc(0,0,4,0,6.283); ctx.fill();
ctx.strokeStyle='#9fb3c4'; ctx.beginPath(); ctx.moveTo(0,-15); ctx.lineTo(0,-9); ctx.stroke();
}
ctx.restore();
if(Math.random()<.4) parts.push({x:x+rnd(16,-16),y:y-14,vx:rnd(8,-8),vy:rnd(-14,-32),life:rnd(1.1,.5),t:0,c:'#9fe4ff',r:rnd(2.2,.9),grav:-6,glow:true});
ctx.font='600 8px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='#bfe9ff';
ctx.fillText(isShard?'THE AETHER SHARD':'PHANTOM LANTERN',x,y+24);
break;}
case 'heart':{
const bob=Math.sin(time*2.4+o.pulse)*3;
const gr=ctx.createRadialGradient(x,y-8+bob,2,x,y-8+bob,32);
gr.addColorStop(0,'rgba(255,210,150,.9)'); gr.addColorStop(.35,'rgba(255,140,70,.35)'); gr.addColorStop(1,'rgba(255,120,60,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(x,y-8+bob,32,0,6.283); ctx.fill();
ctx.save(); ctx.translate(x,y-8+bob); ctx.scale(.85,.85);
ctx.beginPath();
ctx.moveTo(0,11); ctx.bezierCurveTo(-13,2,-11,-9,-4,-9); ctx.bezierCurveTo(-1,-9,0,-6,0,-5);
ctx.bezierCurveTo(0,-6,1,-9,4,-9); ctx.bezierCurveTo(11,-9,13,2,0,11); ctx.closePath();
const hg=ctx.createLinearGradient(0,-10,0,11); hg.addColorStop(0,'#ffd48a'); hg.addColorStop(.5,'#f0913c'); hg.addColorStop(1,'#c04a1e');
ctx.fillStyle=hg; ctx.fill(); ctx.strokeStyle='#ffe0a3'; ctx.lineWidth=1.4; ctx.stroke(); ctx.restore();
if(Math.random()<.5) parts.push({x:x+rnd(14,-14),y:y+rnd(6,-6),vx:rnd(8,-8),vy:rnd(-16,-38),life:rnd(1.2,.5),t:0,c:'#ffcf94',r:rnd(2.4,1),grav:-8,glow:true});
ctx.font='600 8px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='#ffcf94'; ctx.fillText('HEART CONTAINER',x,y+22);
break;}
case 'coin':{
const bob=Math.sin(o.bob)*3;
ctx.globalAlpha=.5; ctx.fillStyle='#ffd77a'; ctx.beginPath(); ctx.arc(o.x,o.y+bob,7,0,6.283); ctx.fill(); ctx.globalAlpha=1;
ctx.fillStyle='#ffd77a'; ctx.beginPath(); ctx.ellipse(o.x,o.y+bob,4,4.6,0,0,6.283); ctx.fill();
ctx.fillStyle='#a8762a'; ctx.beginPath(); ctx.ellipse(o.x,o.y+bob,1.6,3,0,0,6.283); ctx.fill();
break;}
case 'drop':{
const bob=Math.sin(time*2.5)*3;
const gr=ctx.createRadialGradient(o.x,o.y+bob,1,o.x,o.y+bob,24);
gr.addColorStop(0,'rgba(255,255,255,.9)'); gr.addColorStop(1,'rgba(200,200,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(o.x,o.y+bob,24,0,6.283); ctx.fill();
ctx.fillStyle='#241c2c'; ctx.beginPath(); ctx.moveTo(o.x,o.y-7+bob); ctx.lineTo(o.x+7,o.y+bob); ctx.lineTo(o.x+5,o.y+9+bob);
ctx.lineTo(o.x-5,o.y+9+bob); ctx.lineTo(o.x-7,o.y+bob); ctx.closePath(); ctx.fill();
ctx.strokeStyle='#8a7bd8'; ctx.lineWidth=1.3; ctx.stroke();
if(Math.random()<.4) motes(o.x,o.y,1,'#ffffff',{rise:-40,life:.9,size:1.6});
break;}
case 'spill':{
const a=1-(now()-o.born)/o.life;
ctx.globalAlpha=a*.8; ctx.fillStyle='#1c1424';
ctx.beginPath(); ctx.ellipse(o.x,o.y,13,8,0,0,6.283); ctx.fill();
ctx.strokeStyle=`rgba(120,60,140,${a*.6})`; ctx.lineWidth=1.4; ctx.stroke();
ctx.globalAlpha=1;
break;}
}
ctx.restore();
}
function shadow(x,y,rx,ry){ ctx.fillStyle='rgba(0,0,0,.38)'; ctx.beginPath(); ctx.ellipse(x,y,rx,ry,0,0,6.283); ctx.fill(); }
function drawPlayer(){
const x=P.x, y=P.y;
const walk=P.walking?Math.sin(P.anim*7):0;
const bob=P.walking?Math.abs(Math.sin(P.anim*7))*2:Math.sin(P.breath*1.6)*1.1;
const t=now(), atkP=(t-P.atkT)/(G.weapon===2?.34:.20);
const attacking=atkP>=0&&atkP<=1;
const tier=G.weapon;
const stanceW = tier===2?1.18:(G.hasShard?1.09:1.0); // wider stance for falchion / dual-ready
ctx.save(); ctx.translate(x,y+bob);
if(P.dead){ ctx.rotate(1.35); ctx.globalAlpha=.6; }
shadow(0,12-bob,10*stanceW,4.5);
// ---- aura / glow layer
if(tier>=1){
const col=tier===1?'255,140,50':'255,210,120';
const gr=ctx.createRadialGradient(0,-2,2,0,-2,tier===1?30:42);
gr.addColorStop(0,`rgba(${col},${tier===1?.20:.28})`); gr.addColorStop(1,`rgba(${col},0)`);
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,-2,tier===1?30:42,0,6.283); ctx.fill();
}
if(G.equipped.lantern){
const gr=ctx.createRadialGradient(0,-2,2,0,-2,54); gr.addColorStop(0,'rgba(120,200,255,.16)'); gr.addColorStop(1,'rgba(120,200,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,-2,54,0,6.283); ctx.fill();
}
if(P.iframe>0&&Math.floor(P.iframe*14)%2===0) ctx.globalAlpha*=.55;
const f=P.face;
// ---- cloak
const cw=9*stanceW;
ctx.beginPath();
ctx.moveTo(-cw,12); ctx.quadraticCurveTo(-cw-2+walk*1.5,-2,-4,-10);
ctx.lineTo(4,-10); ctx.quadraticCurveTo(cw+2-walk*1.5,-2,cw,12); ctx.closePath();
const cg=ctx.createLinearGradient(0,-10,0,12);
cg.addColorStop(0,'#3f4a5c'); cg.addColorStop(.55,'#2a3242'); cg.addColorStop(1,'#161c26');
ctx.fillStyle=cg; ctx.fill();
// cloak trim — shimmer at tier 2, dark hem for Shadow Cloak
if(G.equipped.cloak){ ctx.strokeStyle='rgba(120,100,200,.85)'; ctx.lineWidth=2.4;
ctx.beginPath(); ctx.moveTo(-cw,12); ctx.lineTo(cw,12); ctx.stroke(); }
if(tier===2){ ctx.strokeStyle=`rgba(255,225,160,${.4+Math.sin(time*4)*.25})`; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(-cw,12); ctx.quadraticCurveTo(-cw-2,-2,-4,-10); ctx.moveTo(cw,12); ctx.quadraticCurveTo(cw+2,-2,4,-10); ctx.stroke(); }
else { ctx.strokeStyle='rgba(200,170,120,.28)'; ctx.lineWidth=1; ctx.stroke(); }
// legs
ctx.strokeStyle='#1a2028'; ctx.lineWidth=3.4; ctx.beginPath();
ctx.moveTo(-3,10); ctx.lineTo(-3-walk*3,15); ctx.moveTo(3,10); ctx.lineTo(3+walk*3,15); ctx.stroke();
// ---- head/hood
ctx.fillStyle='#232b38'; ctx.beginPath(); ctx.ellipse(0,-15,7,7.5,0,0,6.283); ctx.fill();
ctx.fillStyle='#2f3948'; ctx.beginPath(); ctx.ellipse(f*1,-16,6,6.4,0,0,6.283); ctx.fill();
// shoulder clasp for Warden's Band accessory set
if(G.equipped.band){ ctx.fillStyle='#e0e8f2'; ctx.beginPath(); ctx.arc(-f*7,-9,2.2,0,6.283); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.8)'; ctx.lineWidth=.8; ctx.stroke(); }
// eyes
ctx.fillStyle=tier===2?'#ffe6a8':tier===1?'#ffb057':'#9fd0e8';
ctx.fillRect(f*1-2.6,-17,2,2); ctx.fillRect(f*1+1.2,-17,2,2);
// ---- off-hand crystal (Aether Shard)
if(G.hasShard){
const ox=-f*11, oy=-2+Math.sin(time*2.4)*1.2;
const pu=.55+Math.sin(time*5)*.3;
const gr=ctx.createRadialGradient(ox,oy,1,ox,oy,13); gr.addColorStop(0,`rgba(180,235,255,${pu})`); gr.addColorStop(1,'rgba(120,200,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(ox,oy,13,0,6.283); ctx.fill();
ctx.fillStyle='#e2f6ff'; ctx.beginPath(); ctx.moveTo(ox,oy-5); ctx.lineTo(ox+3,oy); ctx.lineTo(ox,oy+5); ctx.lineTo(ox-3,oy); ctx.closePath(); ctx.fill();
if(t-P.tShoot<.18){ ctx.globalAlpha=.7; ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(ox,oy,7,0,6.283); ctx.fill(); ctx.globalAlpha=1; }
}
// ---- weapon arm + blade
const w=WEAPONS[tier];
let a;
if(attacking){
const swing = tier===2 ? easeOutCubic(atkP) : atkP;
a = P.atkDir - w.arc/2 + w.arc*swing;
} else {
a = Math.atan2(P.diry||0.001, P.dirx||f) + (tier===2?0.7:0.55)*f + Math.sin(time*1.4)*.05;
if(!P.dirx&&!P.diry) a = (f>0?0.5:Math.PI-0.5);
}
const bl = tier===2?26:tier===1?21:19;
ctx.save(); ctx.rotate(a);
// arm
ctx.strokeStyle='#2c3648'; ctx.lineWidth=3.6; ctx.beginPath(); ctx.moveTo(0,-4); ctx.lineTo(10,0); ctx.stroke();
// hilt
ctx.fillStyle='#7a5c34'; ctx.fillRect(9,-1.8,6,3.6);
ctx.fillStyle='#c9a460'; ctx.fillRect(14,-4,2.6,8);
// blade
ctx.beginPath();
if(tier===2){ ctx.moveTo(16,-4.6); ctx.quadraticCurveTo(16+bl*.6,-5.6,16+bl,-1.2); ctx.quadraticCurveTo(16+bl*.6,3.4,16,3.6); ctx.closePath(); }
else { ctx.moveTo(16,-2.6); ctx.lineTo(16+bl,-1.1); ctx.lineTo(16+bl+3,0); ctx.lineTo(16+bl,1.1); ctx.lineTo(16,2.6); ctx.closePath(); }
if(tier>0){ ctx.shadowColor=w.glow; ctx.shadowBlur=tier===2?18:12; }
ctx.fillStyle=w.color; ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle=tier===2?'rgba(255,255,240,.9)':'rgba(255,255,255,.5)'; ctx.lineWidth=.9; ctx.stroke();
ctx.restore();
// ---- swing arc visual
if(attacking){
const swing = tier===2?easeOutCubic(atkP):atkP;
const a0=P.atkDir-w.arc/2, a1=a0+w.arc*swing;
ctx.save();
const grad=ctx.createRadialGradient(0,0,w.reach*.4,0,0,w.reach+6);
const c=tier===0?'200,220,240':tier===1?'255,150,60':'255,225,150';
grad.addColorStop(0,`rgba(${c},0)`); grad.addColorStop(.7,`rgba(${c},${.30*(1-atkP)})`); grad.addColorStop(1,`rgba(${c},0)`);
ctx.fillStyle=grad; ctx.beginPath(); ctx.moveTo(0,0); ctx.arc(0,0,w.reach+6,a0,a1); ctx.closePath(); ctx.fill();
ctx.strokeStyle=`rgba(${c},${.75*(1-atkP)})`; ctx.lineWidth=tier===2?3.4:2.2;
ctx.beginPath(); ctx.arc(0,0,w.reach,Math.max(a0,a1-0.9),a1); ctx.stroke();
ctx.restore();
}
ctx.restore();
}
function easeOutCubic(t){ return 1-Math.pow(1-t,3); }
function drawEnemy(e){
if(e.x<cam.x-70||e.x>cam.x+VW+70||e.y<cam.y-70||e.y>cam.y+VH+70) return;
const x=e.x, y=e.y, sw=Math.sin(e.wob*4)*1.4;
ctx.save(); ctx.translate(x,y);
shadow(0,e.r*.75,e.r*.85,e.r*.34);
const flash=e.hurt>0?Math.min(1,e.hurt*4):0;
const froz=e.frozen>0;
if(e.kind==='boss'){
const bob=Math.sin(time*1.2)*3;
ctx.translate(0,bob);
// corroded halo
const gr=ctx.createRadialGradient(0,-8,4,0,-8,60);
gr.addColorStop(0,'rgba(120,50,140,.24)'); gr.addColorStop(.5,'rgba(60,20,80,.12)'); gr.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,-8,60,0,6.283); ctx.fill();
// body: broken armor colossus
ctx.fillStyle='#171420'; ctx.beginPath(); ctx.moveTo(-22,24); ctx.lineTo(-17,-16); ctx.lineTo(17,-16); ctx.lineTo(22,24); ctx.closePath(); ctx.fill();
const bg=ctx.createLinearGradient(0,-20,0,24); bg.addColorStop(0,'#3d3348'); bg.addColorStop(.5,'#2a2234'); bg.addColorStop(1,'#151020');
ctx.fillStyle=bg; ctx.beginPath(); ctx.moveTo(-19,22); ctx.lineTo(-14,-14); ctx.lineTo(14,-14); ctx.lineTo(19,22); ctx.closePath(); ctx.fill();
// shoulder plates
ctx.fillStyle='#4a3a28'; ctx.beginPath(); ctx.ellipse(-19,-12,9,7,-.4,0,6.283); ctx.fill();
ctx.beginPath(); ctx.ellipse(19,-12,9,7,.4,0,6.283); ctx.fill();
// helm
ctx.fillStyle='#241d30'; ctx.beginPath(); ctx.moveTo(-10,-16); ctx.lineTo(-8,-32); ctx.lineTo(8,-32); ctx.lineTo(10,-16); ctx.closePath(); ctx.fill();
ctx.fillStyle='#ff5a2a'; ctx.fillRect(-6,-27,4,3); ctx.fillRect(2,-27,4,3);
ctx.globalAlpha=.4+Math.sin(time*6)*.25; ctx.fillStyle='#ff7a3a'; ctx.fillRect(-7,-28,14,5); ctx.globalAlpha=1;
// core
const cg=ctx.createRadialGradient(0,0,1,0,0,12);
const seals=Z.seals||0;
cg.addColorStop(0,seals>0?'rgba(160,90,200,.95)':'rgba(255,180,80,.95)'); cg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(0,0,12,0,6.283); ctx.fill();
if(e.armored){ ctx.strokeStyle=`rgba(180,100,220,${.5+Math.sin(time*5)*.3})`; ctx.lineWidth=2.2;
ctx.beginPath(); ctx.arc(0,0,17,0,6.283); ctx.stroke(); }
// cracked-armor spill trails
if(Math.random()<.3) parts.push({x:x+rnd(20,-20),y:y+rnd(18,-8),vx:rnd(10,-10),vy:rnd(6,26),life:.9,t:0,c:'#3a2044',r:rnd(3,1.2),grav:30,glow:false});
} else if(e.kind==='heavy'){
// bulwark: broken instrument on legs
ctx.fillStyle='#191614'; ctx.fillRect(-16,-14,32,30);
const g=ctx.createLinearGradient(0,-14,0,16); g.addColorStop(0,'#5a4632'); g.addColorStop(.6,'#38291c'); g.addColorStop(1,'#1d150f');
ctx.fillStyle=g; ctx.fillRect(-14,-12,28,26);
ctx.strokeStyle='rgba(240,180,90,.28)'; ctx.lineWidth=1.2; ctx.strokeRect(-14.5,-12.5,29,27);
// telescope barrel arm
ctx.save(); ctx.rotate(Math.sin(e.wob*2)*.15);
ctx.fillStyle='#6a5236'; ctx.fillRect(e.dir>0?12:-24,-6,12,7);
ctx.fillStyle='#2a2018'; ctx.fillRect(e.dir>0?22:-26,-7,3,9); ctx.restore();
// corrosion patches
ctx.fillStyle='rgba(30,20,36,.7)'; ctx.beginPath(); ctx.arc(-6,4,5,0,6.283); ctx.arc(7,-4,4,0,6.283); ctx.fill();
// eye
ctx.fillStyle='#ff8a3c'; ctx.fillRect(-4,-6,3.4,3.4); ctx.fillRect(2,-6,3.4,3.4);
ctx.globalAlpha=.3+Math.sin(time*5+e.wob)*.2; ctx.fillStyle='#ff6a2a'; ctx.fillRect(-6,-8,14,7); ctx.globalAlpha=1;
// legs
ctx.strokeStyle='#241a12'; ctx.lineWidth=3.4; ctx.beginPath();
ctx.moveTo(-7,14); ctx.lineTo(-9-sw,22); ctx.moveTo(7,14); ctx.lineTo(9+sw,22); ctx.stroke();
} else {
// sentinel: animated armor suit
ctx.fillStyle='#141519'; ctx.beginPath(); ctx.moveTo(-11,14); ctx.lineTo(-8,-8); ctx.lineTo(8,-8); ctx.lineTo(11,14); ctx.closePath(); ctx.fill();
const g=ctx.createLinearGradient(0,-10,0,14); g.addColorStop(0,'#4c4a52'); g.addColorStop(.5,'#33313a'); g.addColorStop(1,'#1a1820');
ctx.fillStyle=g; ctx.beginPath(); ctx.moveTo(-9,12); ctx.lineTo(-7,-7); ctx.lineTo(7,-7); ctx.lineTo(9,12); ctx.closePath(); ctx.fill();
// rust
ctx.fillStyle='rgba(150,70,30,.42)'; ctx.beginPath(); ctx.arc(-4,3,4.4,0,6.283); ctx.arc(5,7,3.4,0,6.283); ctx.fill();
// helm
ctx.fillStyle='#3a3844'; ctx.beginPath(); ctx.ellipse(0,-13,7,7,0,0,6.283); ctx.fill();
ctx.fillStyle='#241f2c'; ctx.fillRect(-5,-14,10,4);
ctx.fillStyle='#ff9a4a'; ctx.fillRect(-3.6,-13.6,2.6,2.6); ctx.fillRect(1,-13.6,2.6,2.6);
// arms + broken sword
ctx.save(); ctx.rotate(e.state==='attack'? -0.7+Math.sin(time*14)*.4 : Math.sin(e.wob*3)*.2);
ctx.strokeStyle='#2a2832'; ctx.lineWidth=3; ctx.beginPath(); ctx.moveTo(0,-4); ctx.lineTo(e.dir*11,1); ctx.stroke();
ctx.fillStyle='#8a8a94'; ctx.fillRect(e.dir>0?11:-19,-1.6,9,2.8);
ctx.restore();
ctx.strokeStyle='#1c1a22'; ctx.lineWidth=3; ctx.beginPath();
ctx.moveTo(-4,12); ctx.lineTo(-5-sw,19); ctx.moveTo(4,12); ctx.lineTo(5+sw,19); ctx.stroke();
}
// windup tell
if(e.windup>0){ ctx.strokeStyle=`rgba(255,90,60,${e.windup*3})`; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,e.r+8,0,6.283); ctx.stroke(); }
if(flash>0){ ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(255,${G.weapon===1?140:220},${G.weapon===1?60:180},${flash*.6})`;
ctx.beginPath(); ctx.arc(0,0,e.r+4,0,6.283); ctx.fill(); ctx.globalCompositeOperation='source-over'; }
if(froz){ ctx.strokeStyle=`rgba(160,225,255,${.6+Math.sin(time*8)*.3})`; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,e.r+6,0,6.283); ctx.stroke();
ctx.fillStyle='rgba(140,210,255,.18)'; ctx.beginPath(); ctx.arc(0,0,e.r+6,0,6.283); ctx.fill();
for(let i=0;i<6;i++){const a=i*1.05+time; ctx.fillStyle='rgba(200,240,255,.7)';
ctx.fillRect(Math.cos(a)*(e.r+4)-1,Math.sin(a)*(e.r+4)-1,2.4,2.4);} }
ctx.restore();
// health bar
if(e.hp<e.max){
const w=e.kind==='boss'?90:Math.max(24,e.r*2.2), hh=e.kind==='boss'?6:3;
const bx=x-w/2, by=y-e.r-(e.kind==='boss'?42:14);
ctx.fillStyle='rgba(0,0,0,.65)'; ctx.fillRect(bx-1,by-1,w+2,hh+2);
const frac=e.hp/e.max;
const g=ctx.createLinearGradient(bx,0,bx+w,0); g.addColorStop(0,'#c94a2a'); g.addColorStop(1,'#f0913c');
ctx.fillStyle=g; ctx.fillRect(bx,by,w*frac,hh);
ctx.strokeStyle='rgba(240,180,90,.5)'; ctx.lineWidth=1; ctx.strokeRect(bx-.5,by-.5,w+1,hh+1);
if(e.kind==='boss'){ ctx.font='600 11px Cinzel'; ctx.textAlign='center'; ctx.fillStyle='#ffd9a0';
ctx.fillText('WARDEN OF ASH',x,by-6); }
}
}
function drawBolts(){
for(const b of Z.bolts){
for(let i=0;i<b.trail.length;i++){ const p=b.trail[i], a=(i+1)/b.trail.length;
ctx.globalAlpha=a*.5; ctx.fillStyle='#8fdcff'; ctx.beginPath(); ctx.arc(p.x,p.y,4.5*a,0,6.283); ctx.fill(); }
ctx.globalAlpha=1;
const gr=ctx.createRadialGradient(b.x,b.y,1,b.x,b.y,14);
gr.addColorStop(0,'rgba(230,250,255,1)'); gr.addColorStop(.35,'rgba(140,220,255,.7)'); gr.addColorStop(1,'rgba(100,190,255,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(b.x,b.y,14,0,6.283); ctx.fill();
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(b.x,b.y,2.6,0,6.283); ctx.fill();
}
}
function drawParticles(){
ctx.globalCompositeOperation='lighter';
for(const p of parts){
const a=1-p.t/p.life;
if(p.ring){ ctx.globalAlpha=a*.85; ctx.strokeStyle=p.c; ctx.lineWidth=3*a+.6;
ctx.beginPath(); ctx.arc(p.x,p.y,p.rad*(1-a)+4,0,6.283); ctx.stroke(); continue; }
if(!p.glow) continue;
ctx.globalAlpha=a*.9; ctx.fillStyle=p.c;
ctx.beginPath(); ctx.arc(p.x,p.y,p.r*(.4+a*.8),0,6.283); ctx.fill();
}
ctx.globalCompositeOperation='source-over';
for(const p of parts){ if(p.glow||p.ring) continue;
const a=1-p.t/p.life; ctx.globalAlpha=a*.8; ctx.fillStyle=p.c;
ctx.fillRect(p.x-p.r/2,p.y-p.r/2,p.r,p.r); }
ctx.globalAlpha=1;
}
function drawLighting(p){
// darkness with light holes around player & light sources
const amb = Z.mat==='water' ? (G.equipped.lantern?0.34:0.62) : (Z.n===6?0.20:0.34);
if(amb<=0.02) return;
const lc=document.createElement('canvas'); // cheap: reuse offscreen each frame is costly; cache
drawLighting.buf = drawLighting.buf || document.createElement('canvas');
const buf=drawLighting.buf; buf.width=VW; buf.height=VH;
const b=buf.getContext('2d');
b.fillStyle=`rgba(4,8,14,${amb})`; b.fillRect(0,0,VW,VH);
b.globalCompositeOperation='destination-out';
const px=P.x-cam.x, py=P.y-cam.y;
const radius=(G.equipped.lantern?200:130)+(G.weapon===2?40:G.weapon===1?18:0);
let g=b.createRadialGradient(px,py,10,px,py,radius);
g.addColorStop(0,'rgba(0,0,0,1)'); g.addColorStop(.55,'rgba(0,0,0,.6)'); g.addColorStop(1,'rgba(0,0,0,0)');
b.fillStyle=g; b.beginPath(); b.arc(px,py,radius,0,6.283); b.fill();
for(const o of Z.objects){
let r=0;
if(o.kind==='stone') r=120; else if(o.kind==='altar'&&!o.used) r=110;
else if(o.kind==='switch') r=90; else if((o.kind==='shardped'||o.kind==='lantped')&&!o.used) r=100;
else if(o.kind==='heart') r=90; else if(o.kind==='merchant'&&G.flags.shopOpen) r=110;
if(!r) continue;
const ox=o.x-cam.x, oy=o.y-cam.y;
if(ox<-r||ox>VW+r||oy<-r||oy>VH+r) continue;
const gg=b.createRadialGradient(ox,oy,4,ox,oy,r);
gg.addColorStop(0,'rgba(0,0,0,.9)'); gg.addColorStop(1,'rgba(0,0,0,0)');
b.fillStyle=gg; b.beginPath(); b.arc(ox,oy,r,0,6.283); b.fill();
}
for(const bo of Z.bolts){ const ox=bo.x-cam.x,oy=bo.y-cam.y;
const gg=b.createRadialGradient(ox,oy,2,ox,oy,70); gg.addColorStop(0,'rgba(0,0,0,.85)'); gg.addColorStop(1,'rgba(0,0,0,0)');
b.fillStyle=gg; b.beginPath(); b.arc(ox,oy,70,0,6.283); b.fill(); }
b.globalCompositeOperation='source-over';
ctx.drawImage(buf,0,0);
// colored ambient fog wash
ctx.fillStyle=p.fog; ctx.globalAlpha=.5; ctx.fillRect(0,0,VW,VH); ctx.globalAlpha=1;
}
/* ------------------------------ SCREENS ---------------------------------- */
function show(id){ document.querySelectorAll('.screen').forEach(s=>s.classList.remove('on')); if(id) $(id).classList.add('on'); }
function hideAll(){ show(null); }
function openInv(){
mode='inv'; A.drone(false); renderInv(); show('scr-inv');
A.tone(440,{type:'sine',dur:.25,gain:.06,rev:.5}); A.tone(660,{type:'sine',dur:.3,gain:.045,delay:.05,rev:.6});
}
function renderInv(){
const order=['band','lantern','cloak','stem','seal'];
let html='';
for(const id of order){
const it=ITEMS[id], have=G.inv[id];
const eq=G.equipped[id];
html+=`<div class="row" style="${have?'':'opacity:.42'}">
<div class="ic">${ITEM_ICON(id,eq)}</div>
<div class="meta"><div class="nm">${it.name}
<span class="tag ${eq?'eq':''}">${it.type==='accessory'?(eq?'EQUIPPED':'ACCESSORY'):'CONSUMABLE'}</span></div>
<div class="ds">${it.desc}</div>
<div class="ds" style="color:#c9b48c;font-style:normal;font-size:11.5px;margin-top:3px">${it.detail}</div>
${have?'':'<div class="ds" style="color:#7a8794">Not yet found.</div>'}</div>
<div class="rt">${have? (it.type==='accessory'
? `<button class="btn eqb" data-id="${id}" style="margin:0">${eq?'Unequip':'Equip'}</button>`
: `<div style="color:var(--gold-b);font-family:Cinzel">×${have}</div>
<button class="btn useb" data-id="${id}" style="margin:6px 0 0">Use</button>`)
: '<div class="small">—</div>'}</div></div>`;
}
html+=`<div class="row"><div class="ic" style="font-size:20px;color:var(--gold)">⚔</div>
<div class="meta"><div class="nm">${W().name}<span class="tag">${W().dtype.toUpperCase()}</span></div>
<div class="ds">${['Warden-issue steel. Plain, honest, slow.','Fire runs the fuller. Faster swings, ember trail, burn-flinch on impact.','Sunlight forged into a curve. Slow wind-up, wide arc, heavy impact.'][G.weapon]}</div>
<div class="ds" style="color:#c9b48c;font-style:normal;font-size:11.5px;margin-top:3px">
${W().dmg} damage · ${W().cd.toFixed(2)}s recovery · ${W().reach}px reach · ${(W().arc*57).toFixed(0)}° arc</div></div>
<div class="rt small">Altars claimed: ${G.weapon}/2</div></div>`;
html+=`<div class="row"><div class="ic" style="font-size:18px;color:#bfe9ff">✧</div>
<div class="meta"><div class="nm">Aether Shard${G.hasShard?'':' — undiscovered'}</div>
<div class="ds">${G.hasShard?'Crystalline launcher. Fires a glowing bolt: strikes distant switches, shatters weak barriers, fights at range.':'Sealed somewhere in the Sunken Vault, behind cracked masonry.'}</div></div>
<div class="rt small">${G.hasShard?G.ammo+' charges':'—'}</div></div>`;
html+=`<div class="row"><div class="ic">${HEART_SVG(2)}</div>
<div class="meta"><div class="nm">Heart Containers</div><div class="ds">Each container holds exactly 2 HP.</div></div>
<div class="rt small">${G.containers} / 5 · ${G.hp}/${maxHP()} HP</div></div>`;
$('inv-body').innerHTML=html;
$('slotcount').textContent=equippedCount()+'/2';
$('inv-body').querySelectorAll('.eqb').forEach(b=>b.onclick=()=>{
const id=b.dataset.id;
if(G.equipped[id]){ G.equipped[id]=false; A.tone(330,{type:'sine',dur:.2,gain:.06}); }
else { if(equippedCount()>=2){ SFX.deny(); toast('Only two accessories may be worn at once.','bad'); return; }
G.equipped[id]=true; SFX.pickup(); }
renderInv(); drawHUD();
});
$('inv-body').querySelectorAll('.useb').forEach(b=>b.onclick=()=>{
const id=b.dataset.id;
if(id==='stem') useStem(); else useSeal();
renderInv(); drawHUD();
});
}
function closeInv(){ mode='play'; hideAll(); for(const k in keys)keys[k]=false; }
const SHOP=[
{ id:'stem', name:'Vital Stem', price:50, max:3, desc:'Restores two full heart containers (4 HP).',
note:'A green shoot in a bronze cup.' },
{ id:'seal', name:'Binding Seal', price:75, max:2, desc:'Freezes one construct in place for ~3 seconds.',
note:'Glyph-paper, still humming.' },
{ id:'heart', name:'Heart Container V', price:300, max:1, desc:'Permanently adds your fifth container (10 HP total).',
note:'The merchant\u2019s greatest treasure — and priced like it.' },
{ id:'ammo', name:'Aether Charge', price:10, max:99, desc:'One charge for the Aether Shard.',
note:'Available only once the Sunken Vault is behind you.' }
];
function openShop(){
mode='shop'; renderShop(); show('scr-shop');
A.tone(110,{type:'sine',dur:1.2,gain:.08,rev:.7}); A.tone(165,{type:'triangle',dur:1,gain:.05,delay:.05,rev:.7});
$('shoptalk').textContent = G.gold>=300 ? '"Ah — a Warden with weight in the purse. The container is yours, if you want it."'
: G.hasShard ? '"You found the Shard. I stock charges now, ten a piece."'
: '"Bronze remembers every coin, Warden. Spend well."';
}
function renderShop(){
let html='';
for(const s of SHOP){
const sold=G.shopSold[s.id]||0;
const locked = s.id==='ammo' && !G.hasShard;
const soldOut = sold>=s.max;
const owned = s.id==='heart' && G.containers>=5;
const can = !locked && !soldOut && !owned && G.gold>=s.price;
const icon = s.id==='heart'?HEART_SVG(2):s.id==='ammo'
? `<svg width="30" height="30" viewBox="0 0 30 30"><path d="M15 5 L20 15 L15 25 L10 15Z" fill="#dff4ff" stroke="#6fc9ff"/></svg>`
: ITEM_ICON(s.id,false);
html+=`<div class="row" style="${(locked||soldOut||owned)?'opacity:.45':''}">
<div class="ic">${icon}</div>
<div class="meta"><div class="nm">${s.name}${s.id==='heart'?'<span class="tag">RARE</span>':''}</div>
<div class="ds">${s.desc}</div>
<div class="ds" style="font-style:normal;color:#8fa2b3;font-size:11px;margin-top:2px">${s.note}
${s.max<90?` · stock ${Math.max(0,s.max-sold)}/${s.max}`:''}</div></div>
<div class="rt"><div style="font-family:Cinzel;color:${G.gold>=s.price?'#ffe0a3':'#c98a7a'}">${s.price} ✦</div>
<button class="btn buyb" data-id="${s.id}" ${can?'':'disabled'} style="margin:6px 0 0">
${owned?'Owned':locked?'Locked':soldOut?'Sold out':'Buy'}</button></div></div>`;
}
$('shop-body').innerHTML=html;
$('shopgold').textContent=G.gold;
$('shop-body').querySelectorAll('.buyb').forEach(b=>b.onclick=()=>buy(b.dataset.id));
}
function buy(id){
const s=SHOP.find(x=>x.id===id); if(!s) return;
if(G.gold<s.price){ SFX.deny(); return; }
G.gold-=s.price; G.shopSold[id]=(G.shopSold[id]||0)+1;
SFX.purchase();
if(id==='stem'){ G.inv.stem++; toast('Vital Stem purchased.'); }
else if(id==='seal'){ G.inv.seal++; toast('Binding Seal purchased.'); }
else if(id==='ammo'){ G.ammo+=1; toast('Aether charge loaded.'); }
else if(id==='heart'){ SFX.shimmer(); giveContainer(P.x,P.y);
toast('The merchant surrenders the fifth container.','blue'); cam.shimmer=1.8; }
$('shoptalk').textContent='"A fair trade. The bronze approves."';
renderShop(); drawHUD(); persist();
}
/* ------------------------------ PERSISTENCE ------------------------------ */
function persist(){
try{ localStorage.setItem(SAVEKEY, JSON.stringify(G)); }catch(e){}
}
function loadSave(){
try{ const s=localStorage.getItem(SAVEKEY); if(!s) return null; const o=JSON.parse(s);
if(!o||typeof o.zone!=='number') return null; return o; }catch(e){ return null; }
}
function startNew(){
G=freshState(); lastHeartSig='';
mode='play'; hideAll(); P.dead=false; P.iframe=0;
enterZone(1);
drawHUD();
toast('The Ashen Threshold. Strike with J.','blue');
}
function continueSave(){
const s=loadSave(); if(!s){ startNew(); return; }
G=Object.assign(freshState(),s);
G.inv=Object.assign({band:0,lantern:0,cloak:0,stem:0,seal:0},s.inv||{});
G.equipped=Object.assign({band:false,lantern:false,cloak:false},s.equipped||{});
G.flags=s.flags||{}; G.stones=Object.assign({1:false,2:false,3:false},s.stones||{});
lastHeartSig=''; mode='play'; hideAll(); P.dead=false; P.iframe=0;
enterZone(G.save.zone||G.zone);
if(G.save.sx){ P.x=G.save.sx; P.y=G.save.sy; }
G.hp=maxHP();
drawHUD(); toast('You awaken at Sanctuary Stone '+(G.save.stone||1)+'.','blue');
}
function respawn(){
P.dead=false; P.iframe=1.5; G.hp=maxHP(); lastHeartSig='';
mode='play'; hideAll();
enterZone(G.save.zone||1);
if(G.save.sx){ P.x=G.save.sx; P.y=G.save.sy; }
drawHUD(); toast('The stone remembers you.','blue');
SFX.save();
}
/* ------------------------------ TITLE CANVAS ----------------------------- */
function drawTitleBG(){
ctx.setTransform(1,0,0,1,0,0);
const g=ctx.createRadialGradient(VW/2,VH*.35,40,VW/2,VH*.35,VH);
g.addColorStop(0,'#25333f'); g.addColorStop(.5,'#101a23'); g.addColorStop(1,'#05080c');
ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
// giant gear silhouettes
for(let k=0;k<3;k++){
const cxp=[180,780,480][k], cyp=[430,150,520][k], R=[150,120,220][k], spd=[.06,-.045,.03][k];
ctx.save(); ctx.translate(cxp,cyp); ctx.rotate(time*spd);
ctx.fillStyle='rgba(20,28,36,.85)';
ctx.beginPath(); ctx.arc(0,0,R*.72,0,6.283); ctx.fill();
for(let i=0;i<16;i++){ const a=i/16*6.283; ctx.save(); ctx.rotate(a);
ctx.fillRect(R*.66,-R*.09,R*.28,R*.18); ctx.restore(); }
ctx.strokeStyle='rgba(240,180,90,.10)'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(0,0,R*.72,0,6.283); ctx.stroke();
ctx.strokeStyle='rgba(240,180,90,.06)'; ctx.beginPath(); ctx.arc(0,0,R*.4,0,6.283); ctx.stroke();
for(let i=0;i<6;i++){ const a=i/6*6.283; ctx.beginPath(); ctx.moveTo(Math.cos(a)*R*.4,Math.sin(a)*R*.4);
ctx.lineTo(Math.cos(a)*R*.7,Math.sin(a)*R*.7); ctx.stroke(); }
ctx.restore();
}
// beacon shaft
ctx.save(); ctx.globalCompositeOperation='lighter';
const bg=ctx.createLinearGradient(0,VH,0,0);
bg.addColorStop(0,'rgba(255,170,70,.14)'); bg.addColorStop(1,'rgba(255,220,150,0)');
ctx.fillStyle=bg; ctx.beginPath(); ctx.moveTo(VW/2-40,VH); ctx.lineTo(VW/2-140,0); ctx.lineTo(VW/2+140,0); ctx.lineTo(VW/2+40,VH); ctx.fill();
ctx.restore();
// dust
titleDust=titleDust||Array.from({length:110},()=>({x:rnd(VW),y:rnd(VH),r:rnd(2.2,.4),s:rnd(16,3),a:rnd(.5,.08),p:rnd(6.28)}));
ctx.globalCompositeOperation='lighter';
for(const d of titleDust){ d.y-=d.s*.016; d.x+=Math.sin(time*.5+d.p)*.2; if(d.y<-5){d.y=VH+5;d.x=rnd(VW);}
ctx.globalAlpha=d.a*(.5+Math.sin(time*2+d.p)*.5); ctx.fillStyle='#f0c07a';
ctx.beginPath(); ctx.arc(d.x,d.y,d.r,0,6.283); ctx.fill(); }
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
ctx.fillStyle='rgba(5,8,12,.35)'; ctx.fillRect(0,0,VW,VH);
}
let titleDust=null;
/* ------------------------------ MAIN LOOP -------------------------------- */
let last=performance.now();
function frame(ts){
const dt=Math.min(.05,(ts-last)/1000); last=ts;
if(mode==='play'){ update(dt); render(); }
else { time+=dt; updateParts(dt);
if(mode==='title'||mode==='help') drawTitleBG();
else if(Z){ render(); }
}
requestAnimationFrame(frame);
}
/* ------------------------------ BINDINGS --------------------------------- */
$('btn-new').onclick=()=>{ A.init(); startNew(); };
$('btn-cont').onclick=()=>{ A.init(); continueSave(); };
$('btn-help').onclick=()=>{ A.init(); mode='help'; show('scr-help'); };
$('btn-help-back').onclick=()=>{ if(Z&&G){mode='play';hideAll();} else {mode='title';show('scr-title');} };
$('btn-inv-back').onclick=closeInv;
$('btn-shop-back').onclick=()=>{ mode='play'; hideAll(); persist(); };
$('btn-respawn').onclick=respawn;
$('btn-win-back').onclick=()=>{ mode='play'; hideAll(); };
addEventListener('keydown',e=>{
if(e.code==='Escape'){ if(mode==='inv') closeInv(); else if(mode==='shop'){mode='play';hideAll();} else if(mode==='help'&&Z){mode='play';hideAll();} }
if(e.code==='KeyI'&&mode==='inv') closeInv();
if(e.code==='KeyE'&&mode==='shop'){ mode='play'; hideAll(); }
});
// clicking canvas also unlocks audio
cv.addEventListener('pointerdown',()=>A.init());
/* fit stage to window */
function fit(){
const s=Math.min(window.innerWidth/VW, window.innerHeight/VH);
const st=$('stage'); st.style.width=VW+'px'; st.style.height=VH+'px';
st.style.transform=`scale(${s})`; st.style.transformOrigin='center center';
}
addEventListener('resize',fit); fit();
G=freshState();
if(loadSave()) $('btn-cont').textContent='Continue from Sanctuary';
else { $('btn-cont').disabled=true; $('btn-cont').textContent='No Sanctuary Recorded'; }
drawHUD();
requestAnimationFrame(frame);
</script>
</body>
</html>