← Warden results

Warden

Qwen 3.8 27B GGUF XL · full

Initial view of Warden

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 106,317 bytes · SHA-256 e985ffd8eb1d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Warden's Corrosion</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;overflow:hidden}
  body{
    background:radial-gradient(1100px 640px at 50% 38%, #0c1120 0%, #04060b 72%);
    display:flex;align-items:center;justify-content:center;
    font-family:Georgia,'Times New Roman',serif;
  }
  #stage{position:relative;line-height:0}
  canvas{
    display:block;background:#04060b;border-radius:8px;
    box-shadow:0 0 0 1px #1b2334, 0 0 70px rgba(0,0,0,.85), 0 0 140px rgba(201,142,46,.05);
  }
</style>
</head>
<body>
<div id="stage"><canvas id="game" width="960" height="540"></canvas></div>
<script>
"use strict";
/* ================================================================
   THE WARDEN'S CORROSION — Aurelion's Sunken Observatory
   Single-file action-adventure. Canvas 2D + WebAudio synth.
   ================================================================ */

/* ---------------- utils ---------------- */
const TAU = Math.PI*2;
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const angDiff=(a,b)=>{let d=(b-a)%TAU; if(d>Math.PI)d-=TAU; if(d<-Math.PI)d+=TAU; return d;};
const hash2=(x,y)=>{let h=(x*374761393+y*668265263)^0x5bf03635; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967295;};
const TILE=32, VW=960, VH=540;
const key2=(x,y)=>x+","+y;

/* ---------------- canvas ---------------- */
const cv=document.getElementById('game');
const ctx=cv.getContext('2d');
const lcv=document.createElement('canvas'); lcv.width=VW; lcv.height=VH;
const lctx=lcv.getContext('2d');

function fit(){
  const s=Math.min(window.innerWidth/VW, window.innerHeight/VH, 1.6);
  cv.style.width=(VW*s)+'px'; cv.style.height=(VH*s)+'px';
}
window.addEventListener('resize',fit); fit();

/* ---------------- audio (WebAudio synth) ---------------- */
const AU={
  ctx:null, mg:null, muted:false, music:null, drone:null,
  init(){
    if(this.ctx) return;
    const C=window.AudioContext||window.webkitAudioContext; if(!C) return;
    this.ctx=new C();
    this.mg=this.ctx.createGain(); this.mg.gain.value=0.55; this.mg.connect(this.ctx.destination);
  },
  resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); },
  t(){ return this.ctx?this.ctx.currentTime:0; },
  env(g,t0,a,d,peak){ g.gain.setValueAtTime(0.0001,t0); g.gain.linearRampToValueAtTime(peak,t0+a); g.gain.exponentialRampToValueAtTime(0.0001,t0+a+d); },
  tone(f,dur,type='sine',vol=0.2,slide=0,delay=0){
    if(!this.ctx||this.muted) return;
    const t0=this.t()+delay, o=this.ctx.createOscillator(), g=this.ctx.createGain();
    o.type=type; o.frequency.setValueAtTime(Math.max(20,f),t0);
    if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),t0+dur);
    this.env(g,t0,0.006,dur,vol);
    o.connect(g); g.connect(this.mg); o.start(t0); o.stop(t0+dur+0.15);
  },
  noise(dur,vol=0.2,freq=800,type='lowpass',delay=0){
    if(!this.ctx||this.muted) return;
    const t0=this.t()+delay, n=Math.max(1,Math.floor(this.ctx.sampleRate*dur));
    const buf=this.ctx.createBuffer(1,n,this.ctx.sampleRate), d=buf.getChannelData(0);
    for(let i=0;i<n;i++) d[i]=Math.random()*2-1;
    const s=this.ctx.createBufferSource(); s.buffer=buf;
    const f=this.ctx.createBiquadFilter(); f.type=type; f.frequency.value=freq;
    const g=this.ctx.createGain(); this.env(g,t0,0.004,dur,vol);
    s.connect(f); f.connect(g); g.connect(this.mg); s.start(t0);
  },
  /* --- sfx --- */
  step(f){ this.noise(0.055,0.045,f,'lowpass'); },
  swing(w){
    if(w===0){ this.noise(0.11,0.12,1500,'bandpass'); }
    else if(w===1){ this.noise(0.1,0.12,1900,'bandpass'); this.noise(0.16,0.06,650,'lowpass'); }
    else { this.noise(0.2,0.13,900,'lowpass'); this.tone(68,0.32,'sine',0.28,40); }
  },
  hit(w){
    if(w===0){ this.tone(160,0.09,'square',0.14,80); this.noise(0.05,0.1,3200,'highpass'); }
    else if(w===1){ this.tone(140,0.1,'square',0.15,70); this.noise(0.13,0.12,2600,'bandpass'); }
    else { this.tone(88,0.26,'sine',0.3,44); this.tone(620,0.12,'triangle',0.09,300); this.noise(0.1,0.07,4200,'highpass'); }
  },
  hurt(){ this.tone(110,0.18,'sine',0.3,55); this.tone(1244,0.22,'sawtooth',0.05,930); },
  coin(){ this.tone(1568,0.07,'triangle',0.11); this.tone(2093,0.1,'triangle',0.09,0,0.05); },
  item(){ [523,659,784].forEach((f,i)=>this.tone(f,0.14,'sine',0.11,0,i*0.06)); },
  rare(){ [392,494,587,784].forEach((f,i)=>this.tone(f,0.34,'sine',0.1,0,i*0.08)); },
  crack(){ this.noise(0.08,0.14,900,'lowpass'); },
  brk(){ this.noise(0.26,0.24,520,'lowpass'); this.tone(78,0.22,'sine',0.2,40); },
  door(){ this.tone(54,0.6,'sine',0.28,38); this.noise(0.45,0.12,200,'lowpass'); },
  altar(){ [261,329,392,523,659].forEach((f,i)=>this.tone(f,0.55,'sine',0.09,0,i*0.09)); },
  save(){ [329,415,494].forEach((f,i)=>this.tone(f,0.42,'sine',0.11,0,i*0.1)); },
  shard(){ this.tone(1900,0.08,'square',0.07,880); },
  shardHit(){ this.tone(1568,0.14,'sine',0.11); this.tone(2093,0.2,'sine',0.07,0,0.06); },
  freeze(){ this.tone(880,0.3,'sine',0.11,1760); this.tone(1318,0.3,'sine',0.07,2636,0.05); },
  buy(){ this.tone(1046,0.1,'triangle',0.11); this.tone(1318,0.16,'triangle',0.11,0,0.08); },
  deny(){ this.tone(170,0.16,'square',0.09,110); },
  mirror(){ this.tone(660,0.09,'triangle',0.1,880); },
  solve(){ [523,659,784,1046].forEach((f,i)=>this.tone(f,0.36,'sine',0.1,0,i*0.07)); },
  roar(){ this.tone(58,0.7,'sawtooth',0.22,34); this.noise(0.5,0.18,300,'lowpass'); },
  seal(){ this.tone(220,0.4,'sawtooth',0.14,110); this.tone(1760,0.3,'sine',0.07,0,0.1); },
  beacon(){ [261,329,392,523,659,784].forEach((f,i)=>this.tone(f,1.3,'sine',0.08,0,i*0.13)); },
  drone(on){
    if(!this.ctx) return;
    if(on && !this.drone && !this.muted){
      const o1=this.ctx.createOscillator(), o2=this.ctx.createOscillator(), g=this.ctx.createGain();
      o1.type='triangle'; o1.frequency.value=82; o2.type='sine'; o2.frequency.value=123.5;
      g.gain.value=0.0001; g.gain.linearRampToValueAtTime(0.035,this.t()+0.8);
      o1.connect(g); o2.connect(g); g.connect(this.mg); o1.start(); o2.start();
      this.drone={o1,o2,g};
    } else if(!on && this.drone){
      const d=this.drone; this.drone=null;
      d.g.gain.linearRampToValueAtTime(0.0001,this.t()+0.5);
      setTimeout(()=>{ try{d.o1.stop();d.o2.stop();}catch(e){} },600);
    }
  },
  setMusic(root,boss){
    if(!this.ctx) return;
    if(this.music){ const m=this.music; this.music=null;
      m.g.gain.linearRampToValueAtTime(0.0001,this.t()+1.2);
      setTimeout(()=>{ try{m.osc.forEach(o=>o.stop());}catch(e){} },1400);
    }
    const t0=this.t(), osc=[];
    const g=this.ctx.createGain(); g.gain.value=0.0001; g.gain.linearRampToValueAtTime(0.045,t0+2.5);
    const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=620;
    const lfo=this.ctx.createOscillator(), lg=this.ctx.createGain();
    lfo.frequency.value=0.06; lg.gain.value=260; lfo.connect(lg); lg.connect(f.frequency); lfo.start();
    [[root,'triangle',0],[root*1.5,'triangle',3],[root/2,'sine',0]].forEach(([fr,ty,dt])=>{
      const o=this.ctx.createOscillator(); o.type=ty; o.frequency.value=fr; o.detune.value=dt;
      o.connect(f); osc.push(o); o.start();
    });
    f.connect(g); g.connect(this.mg);
    this.music={osc:[...osc,lfo],g};
    if(boss){
      const b=this.ctx.createOscillator(), bg=this.ctx.createGain();
      b.type='sawtooth'; b.frequency.value=root/4; bg.gain.value=0.02;
      const blfo=this.ctx.createOscillator(), blg=this.ctx.createGain();
      blfo.frequency.value=1.9; blg.gain.value=0.016; blfo.connect(blg); blg.connect(bg.gain); blfo.start();
      b.connect(bg); bg.connect(this.mg); b.start();
      this.music.osc.push(b,blfo);
    }
  }
};

/* ---------------- input ---------------- */
const keys={};
const mouse={x:VW/2,y:VH/2,down:false,aimT:0};
window.addEventListener('keydown',e=>{
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
  if(e.repeat) return;
  keys[e.code]=true;
  onKey(e.code);
});
window.addEventListener('keyup',e=>{ keys[e.code]=false; });
cv.addEventListener('mousemove',e=>{
  const r=cv.getBoundingClientRect();
  mouse.x=(e.clientX-r.left)*(VW/r.width); mouse.y=(e.clientY-r.top)*(VH/r.height);
  mouse.aimT=1.1;
});
cv.addEventListener('mousedown',e=>{ mouse.down=true; onAction(); });
window.addEventListener('mouseup',()=>{ mouse.down=false; });
cv.addEventListener('contextmenu',e=>e.preventDefault());

/* ---------------- particles ---------------- */
const parts=[];
function spawnP(o){ if(parts.length<520) parts.push(Object.assign({t:0,vx:0,vy:0,grav:0,drag:1,size:2,glow:true},o)); }
function burst(x,y,n,cols,spd=120,life=0.5,size=2.4,grav=0){
  for(let i=0;i<n;i++){
    const a=rand(0,TAU), s=rand(spd*0.3,spd);
    spawnP({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(life*0.5,life),max:life,
      color:cols[randi(0,cols.length-1)],size:rand(size*0.6,size*1.4),grav});
  }
}
function spark(x,y,ang,cols,n=6,spd=180){
  for(let i=0;i<n;i++){
    const a=ang+rand(-0.7,0.7), s=rand(spd*0.3,spd);
    spawnP({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(0.2,0.45),max:0.45,
      color:cols[randi(0,cols.length-1)],size:rand(1.5,3),grav:200});
  }
}
function goldSwirl(x,y,n=14){
  for(let i=0;i<n;i++){
    const a=rand(0,TAU), r=rand(6,26);
    spawnP({x:x+Math.cos(a)*r,y:y+Math.sin(a)*r,vx:Math.cos(a+1.6)*40,vy:Math.sin(a+1.6)*40-30,
      life:rand(0.5,0.9),max:0.9,color:'#ffd76a',size:rand(1.5,3),grav:-20});
  }
}
function motes(x,y,n=10){
  for(let i=0;i<n;i++) spawnP({x:x+rand(-10,10),y:y+rand(-6,6),vx:rand(-8,8),vy:rand(-46,-18),
    life:rand(0.6,1.1),max:1.1,color:'#fff7e0',size:rand(1.2,2.6),grav:-10});
}
function aura(x,y,color='#ffd76a'){
  for(let i=0;i<26;i++){
    const a=i/26*TAU;
    spawnP({x,y,vx:Math.cos(a)*90,vy:Math.sin(a)*90,life:0.7,max:0.7,color,size:2.6,drag:0.92});
  }
}
function emberTrail(x,y){
  spawnP({x:x+rand(-4,4),y:y+rand(-4,4),vx:rand(-6,6),vy:rand(-14,-4),
    life:rand(0.25,0.5),max:0.5,color:Math.random()<0.5?'#ff9a3c':'#ffcf6a',size:rand(1.2,2.4),grav:-14});
}
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; }
    p.vx*=Math.pow(p.drag,dt*60); p.vy=p.vy*Math.pow(p.drag,dt*60)+p.grav*dt;
    p.x+=p.vx*dt; p.y+=p.vy*dt;
  }
}
function drawParts(c){
  c.save(); c.globalCompositeOperation='lighter';
  for(const p of parts){
    const a=1-p.t/p.life;
    c.globalAlpha=a*0.9; c.fillStyle=p.color;
    c.beginPath(); c.arc(p.x,p.y,p.size*(0.5+a*0.5),0,TAU); c.fill();
  }
  c.restore(); c.globalAlpha=1;
}

/* ---------------- damage numbers & toasts ---------------- */
const dmgNums=[];
function dmgNum(x,y,text,color='#ffd76a',size=15){
  dmgNums.push({x:x+rand(-6,6),y,vy:-46,life:0.9,max:0.9,text,color,size});
}
function updateDmgNums(dt){
  for(let i=dmgNums.length-1;i>=0;i--){
    const d=dmgNums[i]; d.t=(d.t||0)+dt; d.y+=d.vy*dt; d.vy*=0.94;
    if(d.t>=d.life) dmgNums.splice(i,1);
  }
}
function drawDmgNums(c){
  for(const d of dmgNums){
    const a=1-d.t/d.life;
    c.globalAlpha=a; c.font='700 '+d.size+'px Georgia,serif'; c.textAlign='center';
    c.fillStyle='#000'; c.fillText(d.text,d.x+1.5,d.y+1.5);
    c.fillStyle=d.color; c.fillText(d.text,d.x,d.y);
  }
  c.globalAlpha=1; c.textAlign='left';
}
const toasts=[];
function toast(text,life=4.2){ toasts.push({text,t:0,life}); if(toasts.length>4) toasts.shift(); }
function updateToasts(dt){ for(let i=toasts.length-1;i>=0;i--){ toasts[i].t+=dt; if(toasts[i].t>=toasts[i].life) toasts.splice(i,1);} }
function drawToasts(c){
  c.textAlign='center';
  toasts.forEach((t,i)=>{
    const a=Math.min(1,t.t*3,(t.life-t.t)*1.5);
    c.globalAlpha=clamp(a,0,1);
    c.font='italic 15px Georgia,serif';
    const w=c.measureText(t.text).width+30;
    c.fillStyle='rgba(8,10,16,0.72)';
    roundRect(c,VW/2-w/2,VH-64-(i*26),w,22,11); c.fill();
    c.strokeStyle='rgba(201,142,46,0.5)'; c.lineWidth=1; c.stroke();
    c.fillStyle='#e8d5a3'; c.fillText(t.text,VW/2,VH-50-(i*26));
  });
  c.globalAlpha=1; c.textAlign='left';
}
function roundRect(c,x,y,w,h,r){
  c.beginPath();
  c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r);
  c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath();
}

/* ---------------- world definition ---------------- */
const ITEMS={
  band:{name:"Warden's Band",type:'accessory',desc:'A glowing silver band. +10% attack speed.',color:'#cfd8e8'},
  stem:{name:'Vital Stem',type:'consumable',desc:'Restores 2 heart containers (4 HP).',color:'#7ce88a'},
  lantern:{name:'Phantom Lantern',type:'accessory',desc:'Reveals hidden cracks and passages. Soft blue glow.',color:'#7ab8ff'},
  cloak:{name:'Shadow Cloak',type:'accessory',desc:'Halves damage from traps and the Guardian.',color:'#8a7fb8'},
  seal:{name:'Binding Seal',type:'consumable',desc:'Freezes one enemy in place for 3 seconds.',color:'#7ad0ff'},
};
const WEAPONS=[
  {name:'Ember Blade',dmg:1,cd:0.42,arc:1.9,range:46,color:'#d9c9a8'},
  {name:'Ignited Edge',dmg:2,cd:0.27,arc:2.1,range:50,color:'#ff9a3c'},
  {name:'Solar Falchion',dmg:3,cd:0.6,arc:2.75,range:62,color:'#ffd76a'},
];
const ZONES=[
  {name:'The Ashen Threshold',floor:['#3a3126','#332b21','#3e352a'],wall:'#201a14',trim:'#8a6a3a',light:1.05,stepF:520,step:'stone',
   music:[110,0],desc:'The outer gate of the Sunken Observatory. Bronze steps, fallen ash, and the first breath of the Corrosion.'},
  {name:'The Rusted Archives',floor:['#332e24','#2d2820','#383226'],wall:'#1c1812',trim:'#9a6a3a',light:1.0,stepF:300,step:'metal',
   music:[98,0],desc:'Corrupted records in endless bronze shelving. The merchant\'s altar hums beneath the dust.'},
  {name:'The Collapsed Gallery',floor:['#32333a','#2c2d34','#363740'],wall:'#191a20',trim:'#7a6a4a',light:0.95,stepF:520,step:'stone',
   music:[92,0],desc:'Framed star-charts under a caved ceiling. An altar of ignition waits among the rubble.'},
  {name:'The Sunken Vault',floor:['#2b3438','#252e32','#2f393e'],wall:'#141a1d',trim:'#5a8a8a',light:0.9,stepF:340,step:'water',
   music:[82,0],desc:'Flooded reliquary halls. Mist rises off the black water; something crystalline glitters in the dark.'},
  {name:'The Twin Spire',floor:['#38302a','#322a24','#3c342c'],wall:'#1e1814',trim:'#b08a4a',light:0.9,stepF:300,step:'metal',
   music:[78,0],desc:'Two rusted spires, one wounded. The final altar burns, and a Guardian coils in the dark between them.'},
  {name:'The Core Chamber',floor:['#2e2836','#282230','#322c3a'],wall:'#171220',trim:'#c9a44a',light:0.85,stepF:520,step:'stone',
   music:[70,1],desc:'The heart of the Observatory. The Core Beacon gutters above the Corrosion\'s throne.'},
];

/* per-zone tile layout: rows of 31 chars.
   # wall  . floor  B breakable  S stone  A altar  M merchant  C chest  D door  P plate  E exit  X mirror  L crystal  W wall-crack */
const LAYOUTS=[
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
];

/* ---------------- game state ---------------- */
const G={
  state:'title', zone:0, t:0, camx:0, camy:0, shake:0, flash:0, flashColor:'#ff4a3a',
  shimmer:0, deathT:0, fade:1, fadeDir:-1,
  zoneDirty:false, zoneClear:false,
  hp:6, maxHp:6, gold:0, weapon:0, shards:0,
  inv:{stem:1,seal:0}, equip:{band:false,cloak:false,lantern:false},
  flags:{heart4:false,heart5:false,altar1:false,altar2:false,shardFound:false,
    vaultDoor:false,vaultChest:false,plate1:false,plate2:false,seqDone:false,
    mirrorSolved:false,spireChest:false,spireSeal:false,guardianDown:false,
    seal1:false,seal2:false,beaconLit:false,zoneCleared:[false,false,false,false,false,false]},
  shop:{stem:3,seal:2,heart:1,ammo:99},
  savedZone:0, savedHp:6, savedGold:0, savedWeapon:0, savedShards:0,
  savedInv:{stem:1,seal:0}, savedEquip:{band:false,cloak:false,lantern:false},
  savedFlags:null,
};
let W=null;      // current world
let player=null;
let enemies=[];
let bolts=[];
let worldFx=[];  // ambient emitters (dust/mist)
let boss=null;

function hasItem(id){ return G.inv[id]>0; }
function hasAcc(id){ return G.equip[id]; }
function weapon(){ return WEAPONS[G.weapon]; }
function atkSpeed(){ return G.equip.band?1.12:1.0; }
function goldBonus(){ return G.equip.cloak?1.25:1.0; }

/* ---------------- world builder ---------------- */
/* ================= ZONE DATA (coordinate-based, authoritative) =================
   The LAYOUTS strings above are decorative only; everything interactive is
   placed here by explicit tile coordinates with wall/floor overrides. */
const ZDATA={
  0:{ // Ashen Threshold
    stones:[[3,28]], exits:[[28,2]],
    walls:[[14,9],[15,9],[16,9],[14,10],[15,10],[16,10],[7,14],[8,14],[9,14],[7,15],[8,15],[9,15]],
    cracks:[[15,8]],
    pickups:[[10,20,'gold',25],[22,12,'gold',30]],
  },
  1:{ // Rusted Archives
    stones:[[11,15]], exits:[[28,2]], merchant:[15,15],
    walls:[[23,6],[24,6],[25,6],[23,7],[25,7],[23,8],[25,8],
           [6,21],[7,21],[8,21],[6,22],[8,22],[6,23],[8,23]],
    floors:[[24,7],[7,22]],
    cracks:[[24,6]],
    chests:[[24,7,'band']],
    breaks:[[7,23]],
    pickups:[[7,22,'stem']],
  },
  2:{ // Collapsed Gallery
    stones:[], exits:[[28,2]],
    altars:[[15,6]],
    walls:[[8,12],[9,12],[8,13],[9,13],[19,15],[21,15]],
    cracks:[[13,14],[8,13]],
    breaks:[[13,15,'heart4']],
    plates:[[1,15],[13,22]],
    doors:[[20,29]],
    chests:[[20,28,'gallery']],
    pickups:[[24,10,'stem']],
  },
  3:{ // The Sunken Vault
    stones:[], exits:[[28,2]], crystalMode:'doors',
    walls:[[17,12],[18,12],[19,12],[17,13],[19,13],[12,18],[13,18],[14,18],[12,19],[13,19],[14,19]],
    cracks:[[18,11]],
    emitters:[[2,10]],
    mirrors:[[15,10,-Math.PI/2],[15,4,0]],
    crystals:[[19,4]],
    doors:[[18,14]],
    breaks:[[18,13,'shard']],
    pickups:[[24,10,'stem'],[6,20,'gold',40]],
  },
  4:{ // The Twin Spire
    stones:[[3,28]], exits:[[28,2]], crystalMode:'doors',
    altars:[[15,15]],
    walls:[[17,17],[18,17],[19,17],[17,18],[19,18],[24,24],[25,24],[26,24],[24,25],[26,25]],
    floors:[[18,18],[25,25]],
    cracks:[[18,17],[4,7]],
    breaks:[[4,8,'cloak']],
    crystals:[[13,4]],
    doors:[[18,19]],
    jars:[[11,22]],
    heart5:[18,18],
    chests:[[25,25,'spire']],
    pickups:[[11,22,'seal'],[22,12,'stem']],
  },
  5:{ // The Core Chamber
    stones:[], exits:[],
    beacons:[[15,13]],
    crystals:[[5,8],[25,8]], crystalMode:'beacon',
    walls:[[14,12],[16,12],[14,14],[16,14],[14,13],[16,13]],
    cracks:[[15,12]],
  },
};
function makeWorld(zi){
  const H=31, WD=31, zd=ZDATA[zi]||{};
  const w={
    zi, W:WD, H, tiles:[], breakables:[], stones:[], altars:[],
    merchant: zd.merchant?{x:zd.merchant[0],y:zd.merchant[1]}:null,
    chests:[], doors:[], plates:[],
    exit: zd.exits&&zd.exits.length?{x:zd.exits[0][0],y:zd.exits[0][1]}:null,
    entrance: zi>0?{x:3,y:28}:null,
    mirrors:[], crystals:[], emitters:[],
    beacon: zd.beacons&&zd.beacons.length?{x:zd.beacons[0][0],y:zd.beacons[0][1],ready:false,lit:false}:null,
    jars:[], heart5: zd.heart5?{x:zd.heart5[0],y:zd.heart5[1],taken:false}:null,
    cracks:[], pickups:[], enemies:[], spawns:[], seed:zi*991+7,
    crystalMode: zd.crystalMode||'none',
  };
  for(let y=0;y<H;y++){
    w.tiles[y]=[];
    for(let x=0;x<WD;x++) w.tiles[y][x]=(x===0||y===0||x===WD-1||y===H-1)?1:0;
  }
  for(const p of zd.walls||[]) if(p[0]>0&&p[1]>0&&p[0]<WD-1&&p[1]<H-1) w.tiles[p[1]][p[0]]=1;
  for(const p of zd.floors||[]) w.tiles[p[1]][p[0]]=0;
  for(const p of zd.cracks||[]) w.cracks.push({x:p[0],y:p[1]});
  for(const p of zd.stones||[]) w.stones.push({x:p[0],y:p[1],used:false});
  for(const p of zd.altars||[]) w.altars.push({x:p[0],y:p[1],used:false});
  (zd.chests||[]).forEach(c=>w.chests.push({x:c[0],y:c[1],opened:false,loot:c[2]}));
  for(const p of zd.doors||[]) w.doors.push({x:p[0],y:p[1],open:0,locked:true});
  for(const p of zd.plates||[]) w.plates.push({x:p[0],y:p[1],down:false,pressT:-99});
  for(const p of zd.mirrors||[]) w.mirrors.push({x:p[0],y:p[1],ang:p[2],active:false,flash:0});
  for(const p of zd.crystals||[]) w.crystals.push({x:p[0],y:p[1],active:false});
  for(const p of zd.emitters||[]) w.emitters.push({x:p[0],y:p[1]});
  for(const p of zd.breaks||[]) w.breakables.push({x:p[0],y:p[1],hp:3,max:3,hit:0,hidden:p[2]||null});
  for(const p of zd.jars||[]) w.jars.push({x:p[0],y:p[1],taken:false});
  for(const p of zd.pickups||[]) w.pickups.push({x:p[0]*TILE+16,y:p[1]*TILE+16,item:p[2],amount:p[3]||0,taken:false});
  for(const s of SPAWNS[zi]) w.spawns.push({x:s[0]*TILE+16,y:s[1]*TILE+16,type:s[2],count:s[3]||1});
  return w;
}
function tileAt(tx,ty){
  if(!W) return 1;
  if(tx<0||ty<0||tx>=W.W||ty>=W.H) return 1;
  const t=W.tiles[ty][tx];
  if(t===1) return 1;
  const d=W.doors.find(d=>d.x===tx&&d.y===ty);
  if(d && d.open<1) return 1;
  const b=W.breakables.find(b=>b.x===tx&&b.y===ty);
  if(b) return 1;
  return 0;
}
function solidRect(x,y,w,h){
  const x0=Math.floor(x/TILE), x1=Math.floor((x+w-0.01)/TILE);
  const y0=Math.floor(y/TILE), y1=Math.floor((y+h-0.01)/TILE);
  for(let ty=y0;ty<=y1;ty++) for(let tx=x0;tx<=x1;tx++) if(tileAt(tx,ty)) return true;
  return false;
}
function moveBody(b,dx,dy){
  let moved=0;
  if(dx!==0){
    const nx=b.x+dx;
    if(!solidRect(nx,b.y,b.w,b.h)) b.x=nx; else { b.hitWall=true; }
  }
  if(dy!==0){
    const ny=b.y+dy;
    if(!solidRect(b.x,ny,b.w,b.h)) b.y=ny; else { b.hitWall=true; }
  }
}
function pointInWorld(px,py){
  const tx=Math.floor(px/TILE), ty=Math.floor(py/TILE);
  if(tx<0||ty<0||tx>=W.W||ty>=W.H) return false;
  if(W.tiles[ty][tx]===1) return false;
  const d=W.doors.find(d=>d.x===tx&&d.y===ty);
  if(d && d.open<1) return false;
  const b=W.breakables.find(b=>b.x===tx&&b.y===ty);
  if(b) return false;
  return true;
}

/* hidden breakables (heart / shard / lantern) */
const WALLS_HIDDEN={
  3:[{x:21,y:15,item:'heart4'}],
  4:[{x:22,y:10,item:'cloak'}],
  5:[{x:10,y:13,item:'seal'}],
};
/* visible hairline-crack decals on real walls */
const WALLS_CRACKS={
  2:[{x:6,y:8},{x:25,y:18}],
  3:[{x:15,y:22},{x:21,y:14}],
  4:[{x:8,y:8},{x:23,y:20}],
  5:[{x:10,y:12},{x:22,y:15}],
};
/* enemy spawn definitions per zone: [tileX, tileY, type, count] */
const SPAWNS={
  0:[[14,10,'sentry',1],[17,12,'sentry',1]],
  1:[[9,8,'sentry',1],[15,14,'sentry',1],[19,10,'sentry',1],[11,20,'sentry',1],[24,18,'sentry',1]],
  2:[[8,9,'sentry',1],[14,12,'brute',1],[18,16,'sentry',1],[10,20,'sentry',1],[22,22,'brute',1],[14,8,'sentry',1]],
  3:[[9,10,'sentry',1],[15,13,'sentry',1],[20,17,'brute',1],[11,19,'sentry',1],[16,9,'sentry',1],[22,21,'sentry',1]],
  4:[[8,9,'brute',1],[13,13,'sentry',1],[17,10,'brute',1],[21,15,'sentry',1],[14,19,'brute',1],[19,21,'brute',1],[9,21,'sentry',1],[16,7,'sentry',1]],
  5:[[12,10,'brute',1],[19,14,'brute',1],[15,17,'sentry',1],[15,10,'boss',1]],
};

/* ================= LAYOUT PATCH — real zone designs ================= */
/* Entrance is always bottom-left (2,28); exit E top-right (28,2) except Zone 6. */
LAYOUTS[0]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............###.............#.",
"#............###.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###S.........................#.",
"#............................#.",
"###############################",
];
LAYOUTS[1]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...................###......#.",
"#..................#C#.......#.",
"#..................#B#.......#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#................M...........#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#.....###....................#.",
"#.....#.#....................#.",
"#.....#B#....................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
LAYOUTS[2]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...............A............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...........####.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#..................####......#.",
"#..................#C#.......#.",
"#..................#D#.......#.",
"###############################",
];
/* Zone 3 needs plates: add here */
LAYOUTS[2][15]="#..........####.............#.";
LAYOUTS[2][15]="#P........####.............#.";
LAYOUTS[2][22]="#.............P............#.";
/* door at (26,29) row29 col26; chest at (26,28) */
LAYOUTS[2][28]="#..................#C#.......#.";
LAYOUTS[2][29]="#..................#D#.......#.";
LAYOUTS[2][27]="#..................###.......#.";
LAYOUTS[2][26]="#............................#.";
/* heart4 hidden breakable inside wall strip row15 cols19-22 */
LAYOUTS[2][15]="#...........####.............#.";
LAYOUTS[2][15]="#...........####.............#.";
LAYOUTS[2][15]="#...........#B##.............#.";
LAYOUTS[3]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#...........L................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#..E1..........X.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
/* Zone 4 (Vault): door + shard alcove, crystal, mirror beam */
LAYOUTS[3][4]="#...........L................#.";
LAYOUTS[3][10]="#..E1..........X.............#.";
LAYOUTS[3][11]="#............................#.";
LAYOUTS[3][12]="#..............####..........#.";
LAYOUTS[3][13]="#..............#B#...........#.";
LAYOUTS[3][14]="#..............#D#...........#.";
LAYOUTS[3][15]="#..............####..........#.";
LAYOUTS[4]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...###......................#.",
"#...#B#......................#.",
"#...###......................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...............A............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###S.........................#.",
"#............................#.",
"###############################",
];
/* Zone 5 (Spire): crystal+door+heart5, jar seal, bonus chest */
LAYOUTS[4][4]="#...........L................#.";
LAYOUTS[4][17]="#..............####..........#.";
LAYOUTS[4][18]="#..............#H#...........#.";
LAYOUTS[4][19]="#..............#D#...........#.";
LAYOUTS[4][20]="#..............####..........#.";
LAYOUTS[4][22]="#.........J..................#.";
LAYOUTS[4][24]="#...................###......#.";
LAYOUTS[4][25]="#..................#C#.......#.";
LAYOUTS[4][26]="#..................###.......#.";
LAYOUTS[5]=[
"###############################",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#....L.................L.....#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#.............K..............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
/* fix: K beacon at (15,13) */
LAYOUTS[5][13]="#.............K..............#.";
LAYOUTS[5][14]="#............................#.";

/* hidden breakables — corrected zone mapping */
WALLS_HIDDEN[2]=[{x:20,y:15,item:'heart4'}];
WALLS_HIDDEN[3]=[{x:18,y:13,item:'shard'}];
WALLS_HIDDEN[4]=[{x:5,y:8,item:'cloak'}];
delete WALLS_HIDDEN[5];
WALLS_CRACKS[2]=[{x:20,y:14},{x:26,y:28}];
WALLS_CRACKS[3]=[{x:18,y:12},{x:6,y:10}];
WALLS_CRACKS[4]=[{x:5,y:7},{x:18,y:17}];
WALLS_CRACKS[5]=[{x:5,y:8},{x:25,y:8}];
SPAWNS[4].push([15,18,'guardian',1]);

/* tile semantics extensions:
   E1 beam emitter (right)   K beacon   J broken jar (seal pickup)   H heart5 pickup
   B breakable (hidden item via WALLS_HIDDEN)   P plate   D door   L crystal switch
   X mirror   C chest   M merchant   S sanctuary stone   A altar   E exit */
const EXTRA_PICKUPS={
  1:[{x:7,y:22,item:'stem'},{x:25,y:7,item:'band'}],
  2:[{x:26,y:28,item:'chestGold',amount:80}],
  4:[{x:11,y:22,item:'seal'},{x:18,y:18,item:'heart5'},{x:25,y:25,item:'chestGold',amount:120}],
};
/* chests contents */
const CHEST_LOOT={
  1:[{item:'stem'}],                    // archives band chest
  2:[{gold:80},{item:'stem'}],          // gallery puzzle chest
  4:[{gold:120},{item:'stem'}],         // spire bonus chest
};

/* ---------------- player ---------------- */
function makePlayer(x,y){
  return {x,y,w:18,h:20,vx:0,vy:0,face:1,aim:0,
    atkCd:0, atkT:0, atkAng:0, atkDur:0, didHit:false,
    hurtT:0, invuln:0, dead:false, walkT:0, moving:false,
    footT:0, emberT:0, attackFlash:0};
}
function playerCenter(){ return {x:player.x+player.w/2, y:player.y+player.h/2}; }
function aimAngle(){
  if(mouse.aimT>0){
    const c=playerCenter();
    return Math.atan2((mouse.y+G.camy)-c.y,(mouse.x+G.camx)-c.x);
  }
  return player.aim;
}
function playerUpdate(dt){
  const p=player;
  p.atkCd-=dt; p.atkT-=dt; p.hurtT-=dt; p.invuln-=dt; p.attackFlash-=dt;
  let dx=0,dy=0;
  if(keys['KeyA']||keys['ArrowLeft']) dx-=1;
  if(keys['KeyD']||keys['ArrowRight']) dx+=1;
  if(keys['KeyW']||keys['ArrowUp']) dy-=1;
  if(keys['KeyS']||keys['ArrowDown']) dy+=1;
  const len=Math.hypot(dx,dy);
  const spd=172;
  p.moving=len>0;
  if(len>0){ dx/=len; dy/=len; p.face=dx!==0?(dx>0?1:-1):p.face; p.walkT+=dt*9; }
  else p.walkT*=0.9;
  p.vx=dx*spd; p.vy=dy*spd;
  p.hitWall=false;
  moveBody(p,p.vx*dt,p.vy*dt);
  p.x=clamp(p.x,4,W.W*TILE-4-p.w); p.y=clamp(p.y,4,W.H*TILE-4-p.h);
  if(!mouse.aimT) p.aim=Math.atan2(p.vy,p.vx)||0;
  mouse.aimT-=dt;
  /* footsteps */
  if(p.moving){
    p.footT-=dt;
    if(p.footT<=0){
      p.footT=0.34;
      const c=ZONES[W.zi];
      if(c.step==='metal') AU.step(260);
      else if(c.step==='water') AU.step(420);
      else AU.step(c.stepF);
      if(Math.random()<0.5) spawnP({x:p.x+p.w/2+rand(-6,6),y:p.y+p.h-2,vx:rand(-6,6),vy:rand(-10,-3),life:0.3,max:0.3,color:'rgba(200,180,140,0.25)',size:1.6,grav:-6});
    }
  }
  /* ember trail when upgraded */
  if(G.weapon>=1 && p.moving){
    p.emberT-=dt;
    if(p.emberT<=0){ p.emberT=G.weapon===2?0.05:0.09; emberTrail(p.x+p.w/2,p.y+p.h*0.7); }
  }
  /* attack */
  const act=keys['KeyJ']||keys['KeyZ']||mouse.down;
  if(act && p.atkCd<=0 && p.atkT<=0 && !p.dead){
    const w=weapon();
    p.atkT=w.cd/atkSpeed(); p.atkCd=p.atkT; p.atkDur=Math.max(0.12,w.cd*0.55);
    p.atkAng=aimAngle(); p.didHit=false; p.attackFlash=0.1;
    AU.swing(G.weapon);
  }
  if(p.atkT>0 && !p.didHit){
    const w=weapon(), c=playerCenter();
    const a=aimAngle();
    for(const e of enemies){
      if(e.dead) continue;
      const ex=e.x+e.w/2, ey=e.y+e.h/2;
      const d=dist(c.x,c.y,ex,ey);
      if(d<w.range+e.w/2 && Math.abs(angDiff(a,Math.atan2(ey-c.y,ex-c.x)))<w.arc/2){
        p.didHit=true;
        hitEnemy(e,w.dmg,a);
        break;
      }
    }
    if(!p.didHit){
      /* breakables in arc */
      for(const b of W.breakables){
        if(b.hp<=0) continue;
        const bx=b.x*TILE+16, by=b.y*TILE+16;
        const d=dist(c.x,c.y,bx,by);
        if(d<w.range+18 && Math.abs(angDiff(a,Math.atan2(by-c.y,bx-c.x)))<w.arc/2+0.25){
          p.didHit=true;
          b.hp-= G.weapon===2?3:1; b.hit=0.15;
          AU.crack();
          burst(bx,by,6,['#8a7a5a','#6a5a40'],60,0.4,2);
          if(b.hp<=0) breakWall(b);
          break;
        }
      }
    }
    /* swing sparks in front */
    if(G.weapon>=1){
      const sx=c.x+Math.cos(a)*w.range*0.8, sy=c.y+Math.sin(a)*w.range*0.8;
      spark(sx,sy,a,G.weapon===2?['#ffd76a','#fff2c0']:['#ff9a3c','#ffcf6a'],5,140);
    }
  }
  /* shard fire */
  if(keys['KeyK']||keys['KeyX']){
    keys['KeyK']=false; keys['KeyX']=false;
    fireShard();
  }
}
function fireShard(){
  const p=player;
  if(!G.flags.shardFound){ toast('You feel for a crystal in your off-hand... and find only air.'); return; }
  if(G.shards<=0){ toast('The Aether Shard is spent. (restock at the merchant, 10 gold)'); AU.deny(); return; }
  if(p.shardCd>0) return;
  p.shardCd=0.35;
  G.shards--;
  const a=aimAngle(), c=playerCenter();
  bolts.push({x:c.x,y:c.y,vx:Math.cos(a)*430,vy:Math.sin(a)*430,life:1.6,ang:a});
  AU.shard();
  spark(c.x+Math.cos(a)*14,c.y+Math.sin(a)*14,a,['#9fd8ff','#e8f6ff'],4,90);
}
function hitEnemy(e,dmg,ang){
  e.hp-=dmg; e.flash=0.12; e.hurtFlash=0.15;
  const knock=G.weapon===2?160:90;
  e.vx+=Math.cos(ang)*knock; e.vy+=Math.sin(ang)*knock;
  const ex=e.x+e.w/2, ey=e.y+e.h/2;
  dmgNum(ex,ey-14,String(dmg),G.weapon===2?'#ffe9a0':(G.weapon===1?'#ffb060':'#f0e0c0'),G.weapon===2?17:14);
  if(G.weapon===0) spark(ex,ey,ang,['#ffcf6a','#e8a050'],6,170);
  else if(G.weapon===1){ spark(ex,ey,ang,['#ff9a3c','#ff6a2a'],8,190); e.burn=1.2; }
  else { spark(ex,ey,ang,['#ffd76a','#fff2c0','#ff9a3c'],12,240); G.shake=Math.max(G.shake,3); }
  AU.hit(G.weapon);
  if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
  e.dead=true;
  const ex=e.x+e.w/2, ey=e.y+e.h/2;
  burst(ex,ey,18,['#4a4a55','#2a2a30','#8a3a2a'],140,0.7,3,60);
  burst(ex,ey,8,['#c98a4a','#7a5a3a'],90,0.5,2);
  const gold=Math.round(rand(5,15)*goldBonus());
  G.gold+=gold;
  dmgNum(ex,ey-26,'+'+gold+'g','#ffd76a',13);
  AU.coin(); goldSwirl(ex,ey,8);
  if(e.type==='guardian'){
    G.flags.guardianDown=true;
    if(!G.equip.cloak){ toast('The Guardian\'s husk collapses. A shadow cloak drifts from its shoulder.'); }
    G.zoneClear=true;
  }
  if(e.type==='boss'){
    G.flags.beaconLit=true;
    if(W.beacon) W.beacon.lit=true;
    G.zoneClear=true;
    G.shimmer=2.4;
    AU.beacon();
  }
  if(e.type==='sentry' && Math.random()<0.08) dropSeal(ex,ey);
}
function dropSeal(x,y){
  W.pickups.push({x,y,item:'seal',amount:0,taken:false});
  toast('A Binding Seal tumbles free!');
}
function damagePlayer(dmg,src){
  const p=player;
  if(p.invuln>0||p.dead) return;
  if(src==='trap' && G.equip.cloak) dmg=Math.ceil(dmg*0.5);
  if(src==='guardian' && G.equip.cloak) dmg=Math.ceil(dmg*0.5);
  G.hp-=dmg;
  p.invuln=0.9; p.hurtT=0.35;
  G.flash=0.35; G.flashColor='#ff4a3a'; G.shake=Math.max(G.shake,5);
  AU.hurt();
  const c=playerCenter();
  burst(c.x,c.y,14,['#ff4a3a','#c02a2a'],150,0.5,2.6);
  dmgNum(c.x,c.y-20,'-'+dmg,'#ff6a5a',16);
  if(G.hp<=0){ G.hp=0; p.dead=true; G.deathT=1.4; }
}

/* ---------------- enemies ---------------- */
function spawnEnemy(type,x,y){
  const e={type,x:x-14,y:y-14,w:28,h:28,vx:0,vy:0,hp:3,maxHp:3,
    state:'idle',t:rand(0,1),atkCd:0,flash:0,hurtFlash:0,burn:0,dead:false,
    homeX:x,homeY:y,wanderA:rand(0,TAU),face:1,castT:0,phase:0,chargeCd:3,
    aggro:false,spawnT:0.6,seed:rand(0,100)};
  if(type==='sentry'){ e.hp=e.maxHp=3; e.spd=62; e.dmg=1; }
  if(type==='brute'){ e.hp=e.maxHp=7; e.spd=44; e.dmg=2; e.w=32; e.h=32; e.x-=2; e.y-=2; }
  if(type==='guardian'){ e.hp=e.maxHp=26; e.spd=55; e.dmg=2; e.w=40; e.h=40; e.x-=6; e.y-=6; e.mini=true; }
  if(type==='boss'){ e.hp=e.maxHp=60; e.spd=48; e.dmg=2; e.w=52; e.h=52; e.x-=12; e.y-=12; e.isBoss=true; }
  enemies.push(e);
  return e;
}
function enemyUpdate(dt){
  const p=player;
  for(const e of enemies){
    if(e.dead) continue;
    e.flash-=dt; e.hurtFlash-=dt; e.burn-=dt; e.atkCd-=dt; e.t+=dt;
    if(e.spawnT>0){ e.spawnT-=dt; continue; }
    if(e.burn>0 && Math.random()<dt*6) emberTrail(e.x+e.w/2,e.y+e.h/2);
    const c={x:e.x+e.w/2,y:e.y+e.h/2};
    const pc=playerCenter();
    const d=dist(c.x,c.y,pc.x,pc.y);
    /* freeze check via e.frozen */
    if(e.frozen>0){ e.frozen-=dt; e.vx*=0.8; e.vy*=0.8; continue; }
    switch(e.type){
      case 'sentry': case 'brute': case 'guardian': {
        const aggroR=e.type==='brute'?150:(e.type==='guardian'?190:170);
        if(d<aggroR && !p.dead){ e.aggro=true; e.spawnT=0; }
        if(e.aggro){
          const a=Math.atan2(pc.y-c.y,pc.x-c.x);
          if(d>26){ e.vx+=Math.cos(a)*e.spd*4*dt; e.vy+=Math.sin(a)*e.spd*4*dt; e.face=Math.cos(a)>0?1:-1; }
          else if(e.atkCd<=0){ e.atkCd=1.1; damagePlayer(e.dmg,'enemy'); }
        } else {
          e.wanderT=(e.wanderT||0)-dt;
          if(e.wanderT<=0){ e.wanderT=rand(1.5,3.5); e.wanderA=rand(0,TAU); }
          e.vx+=Math.cos(e.wanderA)*e.spd*1.2*dt; e.vy+=Math.sin(e.wanderA)*e.spd*1.2*dt;
        }
        break;
      }
      case 'boss': bossUpdate(e,dt,c,pc,d); break;
    }
    /* friction + move */
    e.vx*=Math.pow(0.0009,dt); e.vy*=Math.pow(0.0009,dt);
    const sp=Math.hypot(e.vx,e.vy), maxS=e.spd*(e.aggro?1.15:0.5);
    if(sp>maxS){ e.vx*=maxS/sp; e.vy*=maxS/sp; }
    e.hitWall=false;
    moveBody(e,e.vx*dt,e.vy*dt);
    if(e.hitWall){ e.wanderA=rand(0,TAU); }
    /* body contact damage */
    if(!p.dead && p.invuln<=0 && p.hurtT<=0.4){
      if(rectsOverlap(p,e)){ damagePlayer(e.type==='sentry'?1:2,'enemy'); }
    }
  }
  enemies=enemies.filter(e=>!e.dead||e.deathAnim>0);
  enemies=enemies.filter(e=>!e.dead);
}
function rectsOverlap(a,b){ return a.x<b.x+b.w && a.x+a.w>b.x && a.y<b.y+b.h && a.y+a.h>b.y; }
function bossUpdate(e,dt,c,pc,d){
  e.phaseT=(e.phaseT||0)+dt;
  const enraged=e.hp<e.maxHp*0.5;
  e.aggro=true;
  if(e.phase===0){ // orbit + charge
    const a=Math.atan2(pc.y-c.y,pc.x-c.x)+Math.sin(e.phaseT*1.3)*0.9;
    if(d>70){ e.vx+=Math.cos(a)*e.spd*3.2*dt; e.vy+=Math.sin(a)*e.spd*3.2*dt; }
    else { e.vx*=0.9; e.vy*=0.9; }
    e.chargeCd-=dt;
    if(e.chargeCd<=0 && d>90){
      e.state='charging'; e.stateT=0.5; e.chargeA=Math.atan2(pc.y-c.y,pc.x-c.x);
      AU.roar();
    }
    if(e.state==='charging'){
      e.stateT-=dt;
      e.vx=Math.cos(e.chargeA)*260; e.vy=Math.sin(e.chargeA)*260;
      if(e.hitWall){ e.state='idle'; e.chargeCd=enraged?2.2:3.2; e.vx=0;e.vy=0; G.shake=Math.max(G.shake,4); }
      if(e.stateT<=0){ e.state='idle'; e.chargeCd=3; }
    } else if(e.state!=='idle'){ e.state='idle'; }
    /* cast bolts every 4s */
    e.castT-=dt;
    if(e.castT<=0){
      e.castT=enraged?3:4;
      const a=Math.atan2(pc.y-c.y,pc.x-c.x);
      bolts.push({x:c.x,y:c.y,vx:Math.cos(a)*230,vy:Math.sin(a)*230,life:3,ang:a,enemy:true});
      AU.tone(220,0.3,'sawtooth',0.1,110);
    }
  }
}

/* ---------------- bolts ---------------- */
function updateBolts(dt){
  for(let i=bolts.length-1;i>=0;i--){
    const b=bolts[i];
    b.life-=dt;
    b.x+=b.vx*dt; b.y+=b.vy*dt;
    let gone=b.life<=0;
    if(!gone && !pointInWorld(b.x,b.y)){
      /* hit a wall */
      if(Math.abs(b.vx)>Math.abs(b.vy)){ b.x-=b.vx*dt; } else { b.y-=b.vy*dt; }
      gone=true;
      spark(b.x,b.y,b.ang+Math.PI,['#7ab8ff','#c0e0ff'],5,100);
      /* breakable hit by bolt */
      for(const br of W.breakables){
        if(br.hp>0 && Math.floor(b.x/TILE)===br.x && Math.floor(b.y/TILE)===br.y){
          br.hp-=2; br.hit=0.15; AU.crack();
          if(br.hp<=0) breakWall(br);
        }
      }
    }
    if(!gone && !b.enemy){
      /* crystals */
      for(const cr of W.crystals){
        if(cr.active) continue;
        const cx=cr.x*TILE+16, cy=cr.y*TILE+16;
        if(dist(b.x,b.y,cx,cy)<20){
          cr.active=true; AU.shardHit(); G.solved=true;
          burst(cx,cy,20,['#9fd8ff','#e8f6ff'],160,0.7,2.6);
          if(W.crystalMode==='doors'){
            for(const dr of W.doors){ if(!dr.open) { dr.open=1; AU.door(); } }
            toast('The crystal answers — the vault gate grinds open.');
          } else if(W.crystalMode==='beacon'){
            if(W.beacon){ W.beacon.ready=true; toast('The Core Beacon stirs. Its eye opens to the Warden.'); }
          }
          gone=true;
        }
      }
      /* enemies */
      for(const e of enemies){
        if(e.dead) continue;
        if(b.x>e.x&&b.x<e.x+e.w&&b.y>e.y&&b.y<e.y+e.h){
          hitEnemy(e,1,b.ang);
          spark(b.x,b.y,b.ang,['#9fd8ff','#fff'],6,120);
          gone=true; break;
        }
      }
    } else if(!gone && b.enemy){
      const p=player;
      if(!p.dead && b.x>p.x&&b.x<p.x+p.w&&b.y>p.y&&b.y<p.y+p.h){
        damagePlayer(1,'enemy'); gone=true;
      }
    }
    if(gone){
      if(!b.enemy) spark(b.x,b.y,b.ang,['#7ab8ff','#c0e0ff'],6,110);
      AU.shardHit();
      bolts.splice(i,1);
    }
  }
}

/* ---------------- world interactions ---------------- */
function breakWall(b){
  const bx=b.x*TILE+16, by=b.y*TILE+16;
  AU.brk();
  burst(bx,by,22,['#6a5a40','#4a3a28','#8a7a5a'],150,0.8,3,120);
  G.shake=Math.max(G.shake,3);
  if(b.hidden==='heart4'){
    G.flags.heart4=true; G.maxHp=8; G.hp=Math.min(G.hp+4,G.maxHp);
    G.shimmer=2.2; AU.rare();
    aura(bx,by,'#ffd76a'); motes(bx,by,14);
    toast('A hidden cache! The fourth heart container pulses to life. (8 HP)');
  } else if(b.hidden==='shard'){
    G.flags.shardFound=true; G.shards=10;
    AU.rare();
    aura(bx,by,'#9fd8ff'); motes(bx,by,16);
    toast('The AETHER SHARD! A crystalline launcher hums in your off-hand. (10 charges — K to fire)');
  } else if(b.hidden==='cloak'){
    G.equip.cloak=true;
    AU.rare();
    aura(bx,by,'#8a7fb8'); motes(bx,by,14);
    toast('The SHADOW CLOAK settles over your shoulders. Traps and the Guardian hurt less.');
  }
}
function interactUpdate(dt){
  const c=playerCenter();
  let near=null;
  for(const s of W.stones) if(dist(c.x,c.y,s.x*TILE+16,s.y*TILE+16)<46){ near={t:'stone',o:s}; break; }
  if(!near) for(const a of W.altars) if(dist(c.x,c.y,a.x*TILE+16,a.y*TILE+16)<48){ near={t:'altar',o:a}; break; }
  if(!near && W.merchant) if(dist(c.x,c.y,W.merchant.x*TILE+16,W.merchant.y*TILE+16)<52){ near={t:'merchant'}; }
  if(!near) for(const ch of W.chests) if(!ch.opened && dist(c.x,c.y,ch.x*TILE+16,ch.y*TILE+16)<44){ near={t:'chest',o:ch}; break; }
  if(!near && W.beacon && W.beacon.ready && !W.beacon.lit) if(dist(c.x,c.y,W.beacon.x*TILE+16,W.beacon.y*TILE+16)<70){ near={t:'beacon'}; }
  if(!near && W.heart5 && !W.heart5.taken && dist(c.x,c.y,W.heart5.x*TILE+16,W.heart5.y*TILE+16)<40){ near={t:'heart5',o:W.heart5}; }
  if(!near) for(const j of W.jars) if(!j.taken && dist(c.x,c.y,j.x*TILE+16,j.y*TILE+16)<36){ near={t:'jar',o:j}; }
  G.near=near;
  G.nearHint=0;
  if(near) G.nearHint+=dt;
  if(near && G.nearHint>0.25 && !G.nearPrompted){
    G.nearPrompted=true;
    if(near.t==='stone') toast('Sanctuary Stone — press E to rest and save.');
    if(near.t==='altar') toast('Weapon altar — press E to attune the blade.');
    if(near.t==='merchant') toast('Merchant altar — press E to trade.');
    if(near.t==='chest') toast('A sealed chest — press E to open.');
    if(near.t==='beacon') toast('The Core Beacon — press E to reignite Aurelion.');
    if(near.t==='heart5') toast('The fifth heart container — press E to claim.');
    if(near.t==='jar') toast('A broken jar — press E to search.');
  }
  if(!near) G.nearPrompted=false;
  if(keys['KeyE'] && near){ keys['KeyE']=false; doInteract(near); }
  /* plates */
  for(const pl of W.plates){
    const was=pl.down;
    pl.down = (Math.floor((c.x)/TILE)===pl.x && Math.floor((c.y+player.h)/TILE)===pl.y) ||
             (Math.floor((c.x+player.w)/TILE)===pl.x && Math.floor((c.y+player.h)/TILE)===pl.y);
    if(pl.down && !was){
      pl.pressT=G.t;
      AU.noise(0.2,0.15,300,'lowpass');
      checkPlates();
    }
  }
  /* pickups */
  for(const pk of W.pickups){
    if(pk.taken) continue;
    if(dist(c.x,c.y,pk.x,pk.y)<24){
      pk.taken=true;
      if(pk.item==='gold'){ G.gold+=pk.amount; AU.coin(); goldSwirl(pk.x,pk.y,10); dmgNum(pk.x,pk.y-10,'+'+pk.amount+'g','#ffd76a',13); }
      else if(pk.item==='stem'){ G.inv.stem++; AU.item(); motes(pk.x,pk.y); toast('Vital Stem acquired. (Q to drink)'); }
      else if(pk.item==='seal'){ G.inv.seal++; AU.item(); motes(pk.x,pk.y); toast('Binding Seal acquired. (F to cast)'); }
      else if(pk.item==='band'){ G.equip.band=true; AU.rare(); aura(pk.x,pk.y,'#cfd8e8'); toast('The WARDEN\'S BAND binds your wrist. Attack speed +10%.'); }
    }
  }
  W.pickups=W.pickups.filter(p=>!p.taken);
  /* door open animations */
  for(const dr of W.doors){
    if(dr.open<1) dr.open=Math.min(1,dr.open+dt*1.4);
  }
  /* beacon */
  if(W.beacon){
    if(W.beacon.ready && !W.beacon.lit && Math.random()<dt*8) spark(W.beacon.x*TILE+16,W.beacon.y*TILE+16,rand(0,TAU),['#ffd76a','#fff2c0'],1,60);
    if(W.beacon.lit && Math.random()<dt*14) motes(W.beacon.x*TILE+16,W.beacon.y*TILE-10,1);
  }
  /* merchant drone */
  const nearM=W.merchant&&dist(c.x,c.y,W.merchant.x*TILE+16,W.merchant.y*TILE+16)<170;
  if(nearM!==G.merchantNear){ G.merchantNear=nearM; AU.drone(nearM); }
  /* zone cleared check: all constructs down (Guardian required in the Spire) */
  if(!G.zoneClear && W.zi<5){
    const living=enemies.filter(e=>!e.dead).length;
    const need = W.zi===4 ? (G.flags.guardianDown && living===0) : (living===0);
    if(need) G.zoneClear=true;
  }
}
function checkPlates(){
  if(W.zi!==2) return;
  const active=W.plates.filter(p=>G.t-p.pressT<3.5);
  if(active.length>=2 && !G.flags.seqDone){
    G.flags.seqDone=true;
    for(const dr of W.doors){ dr.open=1; AU.door(); }
    AU.solve();
    toast('The twin plates sing in unison — the gallery gate swings open. +30 gold');
    G.gold+=30; goldSwirl(playerCenter().x,playerCenter().y,12);
  }
}
function doInteract(near){
  if(near.t==='stone'){
    near.o.used=true;
    G.savedZone=W.zi; G.savedHp=G.hp; G.savedGold=G.gold; G.savedWeapon=G.weapon;
    G.savedShards=G.shards; G.savedInv={...G.inv}; G.savedEquip={...G.equip};
    G.savedFlags=JSON.parse(JSON.stringify(G.flags));
    persistSave();
    G.hp=G.maxHp; G.shards=Math.max(G.shards,5);
    AU.save(); G.shimmer=1.6;
    aura(playerCenter().x,playerCenter().y,'#ffd76a');
    toast('The Sanctuary Stone drinks your weariness. Progress inscribed. (healed to '+G.maxHp+' HP)');
  } else if(near.t==='altar'){
    if(G.weapon===0 && W.zi===2){
      G.weapon=1; G.flags.altar1=true;
      AU.altar(); G.shimmer=2;
      const c=playerCenter(); aura(c.x,c.y,'#ff9a3c');
      toast('The IGNITED EDGE. The blade catches fire; your swings grow swift and searing.');
    } else if(G.weapon===1 && W.zi===4){
      G.weapon=2; G.flags.altar2=true;
      AU.altar(); G.shimmer=2.4;
      const c=playerCenter(); aura(c.x,c.y,'#ffd76a');
      toast('The SOLAR FALCHION. A golden crescent of light — heavier, wider, terrible.');
    } else if(near.o.used){
      AU.deny(); toast('The altar is spent.');
    }
  } else if(near.t==='merchant'){
    G.state='shop'; AU.tone(392,0.2,'sine',0.08);
  } else if(near.t==='chest'){
    near.o.opened=true;
    AU.rare();
    const cx=near.o.x*TILE+16, cy=near.o.y*TILE+16;
    aura(cx,cy,'#fff2c0'); motes(cx,cy,16);
    if(near.o.loot==='band'){
      G.equip.band=true;
      toast('The WARDEN\'S BAND — a glowing silver ring. Attack speed +10%.');
    } else if(near.o.loot==='gallery'){
      G.gold+=80; G.inv.stem++;
      AU.coin(); goldSwirl(cx,cy,14);
      toast('Puzzle chest: +80 gold and a Vital Stem.');
    } else if(near.o.loot==='spire'){
      G.gold+=120; G.inv.stem++;
      AU.coin(); goldSwirl(cx,cy,16);
      toast('The bonus chest: +120 gold and a Vital Stem.');
    }
  } else if(near.t==='heart5'){
    if(!G.flags.heart5){
      G.flags.heart5=true; G.maxHp=10; G.hp=Math.min(G.hp+4,G.maxHp);
      AU.rare(); G.shimmer=2.4;
      const c=playerCenter(); aura(c.x,c.y,'#ffd76a');
      toast('The FIFTH heart container. Your heart beats as one with the Spire. (10 HP)');
    }
  } else if(near.t==='jar'){
    near.o.taken=true;
    G.inv.seal++; AU.item(); motes(near.o.x*TILE+16,near.o.y*TILE+16);
    toast('Inside the broken jar: a Binding Seal. (F to cast)');
  } else if(near.t==='beacon'){
    G.flags.beaconLit=true; W.beacon.lit=true;
    G.zoneClear=true;
    AU.beacon(); G.shimmer=3;
    toast('The Core Beacon reignites. Aurelion remembers its Warden.');
  }
}

/* ---------------- consumables ---------------- */
function useStem(){
  if(G.inv.stem<=0){ AU.deny(); toast('No Vital Stems remain. (merchant sells them, 50 gold)'); return; }
  if(G.hp>=G.maxHp){ AU.deny(); toast('Your hearts are already full.'); return; }
  G.inv.stem--;
  G.hp=Math.min(G.maxHp,G.hp+4);
  AU.item();
  const c=playerCenter();
  burst(c.x,c.y,12,['#7ce88a','#c0ffd0'],90,0.6,2.4);
  dmgNum(c.x,c.y-22,'+4','#7ce88a',15);
  toast('The Vital Stem knits your wounds. (+4 HP)');
}
function useSeal(){
  if(G.inv.seal<=0){ AU.deny(); toast('No Binding Seals remain. (merchant sells them, 75 gold)'); return; }
  let best=null,bd=1e9;
  for(const e of enemies){
    if(e.dead||e.frozen>0) continue;
    const d=dist(playerCenter().x,playerCenter().y,e.x+e.w/2,e.y+e.h/2);
    if(d<260 && d<bd){ bd=d; best=e; }
  }
  if(!best){ AU.deny(); toast('No enemy within reach to bind.'); return; }
  G.inv.seal--;
  best.frozen=3;
  AU.freeze();
  const c={x:best.x+best.w/2,y:best.y+best.h/2};
  burst(c.x,c.y,16,['#7ad0ff','#d0f0ff'],110,0.6,2.4);
  dmgNum(c.x,c.y-18,'BOUND','#7ad0ff',13);
}

/* ---------------- game flow ---------------- */
const SAVE_KEY='warden_corrosion_save_v1';
function hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } }
function persistSave(){
  try{
    localStorage.setItem(SAVE_KEY,JSON.stringify({
      zone:G.savedZone,hp:G.savedHp,gold:G.savedGold,weapon:G.savedWeapon,shards:G.savedShards,
      inv:G.savedInv,equip:G.savedEquip,flags:G.savedFlags,maxHp:G.maxHp
    }));
  }catch(e){}
}
function loadFromSave(){
  try{
    const d=JSON.parse(localStorage.getItem(SAVE_KEY));
    G.savedZone=d.zone; G.savedHp=d.hp; G.savedGold=d.gold; G.savedWeapon=d.weapon;
    G.savedShards=d.shards; G.savedInv=d.inv; G.savedEquip=d.equip; G.savedFlags=d.flags;
    G.maxHp=d.maxHp||6;
    G.hp=d.hp; G.gold=d.gold; G.weapon=d.weapon; G.shards=d.shards;
    G.inv=d.inv; G.equip=d.equip; G.flags=Object.assign(G.flags,d.flags);
    G.shop={stem:3,seal:2,heart:G.flags.heart5?0:1,ammo:99};
    toast('Returned to the last Sanctuary Stone.');
    return true;
  }catch(e){ toast('No valid save found.'); return false; }
}
function newGame(){
  G.hp=6; G.maxHp=6; G.gold=0; G.weapon=0; G.shards=0;
  G.inv={stem:1,seal:0}; G.equip={band:false,cloak:false,lantern:false};
  G.flags={heart4:false,heart5:false,altar1:false,altar2:false,shardFound:false,
    vaultDoor:false,vaultChest:false,plate1:false,plate2:false,seqDone:false,
    mirrorSolved:false,spireChest:false,spireSeal:false,guardianDown:false,
    seal1:false,seal2:false,beaconLit:false,zoneCleared:[false,false,false,false,false,false]};
  G.shop={stem:3,seal:2,heart:1,ammo:99};
  G.savedZone=0; G.savedHp=6; G.savedGold=0; G.savedWeapon=0; G.savedShards=0;
  G.savedInv={stem:1,seal:0}; G.savedEquip={band:false,cloak:false,lantern:false};
  G.savedFlags=JSON.parse(JSON.stringify(G.flags));
  G.state='playing';
  startFade(()=>loadZone(0));
}
function resumeGame(){
  if(!hasSave()) return;
  if(loadFromSave()){ G.state='playing'; startFade(()=>loadZone(G.savedZone)); }
}
function startFade(cb){
  G.fadeDir=1; G.fadeCb=cb;
}
function loadZone(zi){
  G.zone=zi;
  W=makeWorld(zi);
  player=makePlayer(3*TILE+16,28*TILE+16);
  player.shardCd=0;
  enemies=[]; bolts=[]; parts.length=0; dmgNums.length=0;
  for(const s of W.spawns) for(let i=0;i<s.count;i++) spawnEnemy(s.type,s.x+(i?10:0),s.y+(i?8:0));
  G.camx=clamp(player.x-VW/2,0,W.W*TILE-VW);
  G.camy=clamp(player.y-VH/2,0,W.H*TILE-VH);
  G.zoneClear=false; G.merchantNear=false; G.near=null; G.nearPrompted=false;
  G.shake=0; G.flash=0;
  AU.drone(false);
  AU.setMusic(ZONES[zi].music[0], zi===5);
  G.zoneToastT=3.2;
  worldFx=[];
  for(let i=0;i<40;i++){
    if(zi===1||zi===2) worldFx.push({type:'dust',x:rand(0,W.W*TILE),y:rand(0,W.H*TILE),vx:rand(-4,4),vy:rand(-3,3),life:rand(2,6)});
    if(zi===3) worldFx.push({type:'mist',x:rand(0,W.W*TILE),y:rand(W.H*TILE*0.4,W.H*TILE),vx:rand(-3,3),vy:rand(-14,-6),life:rand(3,8),s:rand(30,70)});
    if(zi===0) worldFx.push({type:'ash',x:rand(0,W.W*TILE),y:rand(0,W.H*TILE),vx:rand(-8,8),vy:rand(4,10),life:rand(2,5)});
  }
}
function tryTransition(){
  if(G.state!=='playing') return;
  const c=playerCenter();
  const tx=Math.floor(c.x/TILE), ty=Math.floor(c.y/TILE);
  if(W.exit && tx===W.exit.x && ty===W.exit.y && !G.zoneClear){
    /* need zone goal done */
  }
  if(W.exit && tx===W.exit.x && ty===W.exit.y && G.zoneClear){
    G.flags.zoneCleared[W.zi]=true;
    startFade(()=>loadZone(W.zi+1));
  } else if(W.entrance && tx===W.entrance.x && ty===W.entrance.y){
    startFade(()=>loadZone(W.zi-1));
  }
}

/* ---------------- input dispatch ---------------- */
function onKey(code){
  AU.init(); AU.resume();
  if(G.state==='title'){
    if(code==='Enter'||code==='Space') newGame();
    else if(code==='KeyR'&&hasSave()) resumeGame();
    return;
  }
  if(G.state==='ending'){ if(code==='Enter') { G.state='title'; } return; }
  if(G.state==='death') return;
  if(G.state==='shop'){
    if(code==='Escape'||code==='KeyE') closeShop();
    else if(code>='Digit1'&&code<='Digit4') buyShop(parseInt(code.charAt(5))-1);
    return;
  }
  if(G.state==='inv'){
    if(code==='Escape'||code==='KeyI'||code==='Tab') G.state='playing';
    else if(code==='Digit1') toggleAcc('band');
    else if(code==='Digit2') toggleAcc('lantern');
    else if(code==='Digit3') toggleAcc('cloak');
    return;
  }
  if(G.state==='pause'){
    if(code==='Escape'||code==='Enter') G.state='playing';
    else if(code==='KeyR'){ if(hasSave()){ G.state='playing'; if(loadFromSave()) startFade(()=>loadZone(G.savedZone)); } }
    else if(code==='KeyM'){ AU.muted=!AU.muted; toast(AU.muted?'Sound muted.':'Sound on.'); }
    return;
  }
  /* playing */
  if(code==='KeyQ') useStem();
  else if(code==='KeyF') useSeal();
  else if(code==='KeyI'||code==='Tab'){ G.state='inv'; }
  else if(code==='KeyM'){ AU.muted=!AU.muted; AU.drone(G.merchantNear); toast(AU.muted?'Sound muted.':'Sound on.'); }
  else if(code==='Escape'){ G.state='pause'; }
}
function toggleAcc(id){
  G.equip[id]=!G.equip[id];
  AU.tone(G.equip[id]?660:330,0.12,'triangle',0.1);
  const n=ITEMS[id].name;
  toast(G.equip[id]?n+' equipped.':n+' unequipped.');
}
function closeShop(){ G.state='playing'; AU.drone(G.merchantNear); }

/* ---------------- shop ---------------- */
const SHOP=[
  {id:'stem', name:'Vital Stem', cost:50, desc:'Restores 4 HP (two heart containers).'},
  {id:'seal', name:'Binding Seal', cost:75, desc:'Freezes one enemy for 3 seconds.'},
  {id:'heart',name:'Heart Container', cost:300, desc:'A permanent fifth heart. (+2 max HP)'},
  {id:'ammo', name:'Aether Charge', cost:10, desc:'+1 Aether Shard charge.'},
];
function shopEntry(i){
  const s=SHOP[i];
  if(s.id==='stem') return {stock:G.shop.stem, locked:false};
  if(s.id==='seal') return {stock:G.shop.seal, locked:false};
  if(s.id==='heart') return {stock:G.shop.heart, locked:G.flags.heart5};
  if(s.id==='ammo') return {stock:99, locked:!G.flags.shardFound, hidden:!G.flags.shardFound};
}
function buyShop(i){
  const s=SHOP[i], e=shopEntry(i);
  if(e.hidden||e.locked){ AU.deny(); toast(i===3?'The merchant has no word of crystals yet.':'Already claimed.'); return; }
  if(e.stock<=0){ AU.deny(); toast('Sold out.'); return; }
  if(G.gold<s.cost){ AU.deny(); toast('Not enough gold. ('+G.gold+' / '+s.cost+')'); return; }
  G.gold-=s.cost;
  AU.buy();
  if(s.id==='stem'){ G.inv.stem++; G.shop.stem--; toast('Vital Stem purchased. (Q to drink)'); }
  else if(s.id==='seal'){ G.inv.seal++; G.shop.seal--; toast('Binding Seal purchased. (F to cast)'); }
  else if(s.id==='heart'){
    G.shop.heart=0; G.maxHp=10; G.hp=Math.min(G.hp+4,G.maxHp);
    G.flags.heart5=true; G.shimmer=2.4;
    aura(playerCenter().x,playerCenter().y,'#ffd76a');
    toast('The FIFTH heart container takes root. (10 HP)');
  }
  else if(s.id==='ammo'){ G.shards=Math.min(30,G.shards+1); toast('Aether Shard recharged. ('+G.shards+' charges)'); }
  goldSwirl(mouse.x+G.camx,mouse.y+G.camy,6);
}

/* ---------------- master update ---------------- */
function update(dt){
  G.t+=dt;
  G.shake=Math.max(0,G.shake-dt*14);
  G.flash=Math.max(0,G.flash-dt*2);
  G.shimmer=Math.max(0,G.shimmer-dt);
  G.zoneToastT=Math.max(0,G.zoneToastT-dt);
  updateParts(dt); updateDmgNums(dt); updateToasts(dt);
  /* fade */
  if(G.fadeDir===1){
    G.fade+=dt*2.2;
    if(G.fade>=1){ G.fade=1; const cb=G.fadeCb; G.fadeCb=null; if(cb) cb(); G.fadeDir=-1; }
  } else {
    G.fade-=dt*2.2;
    if(G.fade<=0){ G.fade=0; G.fadeDir=0; }
  }
  if(G.state==='playing'){
    if(!player || !W){ /* fade-in still loading */ }
    else {
    playerUpdate(dt);
    enemyUpdate(dt);
    updateBolts(dt);
    interactUpdate(dt);
    tryTransition();
    /* camera */
    const c=playerCenter();
    const tx=clamp(c.x-VW/2,0,W.W*TILE-VW), ty=clamp(c.y-VH/2,0,W.H*TILE-VH);
    G.camx=lerp(G.camx,tx,Math.min(1,dt*7));
    G.camy=lerp(G.camy,ty,Math.min(1,dt*7));
    /* ambient fx */
    for(const f of worldFx){
      f.life-=dt; f.x+=f.vx*dt; f.y+=f.vy*dt;
      if(f.life<=0){
        f.life=rand(2,7);
        f.x=G.camx+rand(0,VW); f.y=G.camy+rand(0,VH);
      }
    }
    /* death */
    if(player.dead){
      G.deathT-=dt;
      if(G.deathT<=0){
        G.state='death'; G.deathT=2.2;
        AU.tone(60,1.2,'sine',0.3,30);
      }
    }
    }
  } else if(G.state==='death'){
    G.deathT-=dt;
    if(G.deathT<=0){
      G.state='playing';
      if(hasSave() && loadFromSave()){
        startFade(()=>loadZone(G.savedZone));
      } else {
        G.hp=G.maxHp;
        toast('The Corrosion releases you at the threshold.');
        startFade(()=>loadZone(0));
      }
    }
  }
}

/* ================= RENDER ================= */
function render(){
  const c=ctx;
  c.clearRect(0,0,VW,VH);
  c.save();
  const shx=G.shake>0?rand(-G.shake,G.shake):0, shy=G.shake>0?rand(-G.shake,G.shake):0;
  c.translate(-Math.round(G.camx+shx),-Math.round(G.camy+shy));
  if(W){
    drawTiles(c);
    drawFloorDecals(c);
    drawPlates(c);
    drawDoors(c);
    drawCrystals(c);
    drawMirrors(c);
    drawStones(c);
    drawAltars(c);
    drawMerchant(c);
    drawJars(c);
    drawHeart5(c);
    drawBeacon(c);
    drawPickups(c);
    drawChests(c);
    drawBreakables(c);
    drawEnemies(c);
    if(player && !player.dead) drawPlayer(c);
    drawBolts(c);
    drawParts(c);
    drawAmbient(c);
    drawExitSign(c);
    drawLighting(c,G.camx+shx,G.camy+shy);
  }
  c.restore();
  drawVignette(c);
  drawShimmer(c);
  drawDmgNums(c);
  if(G.flash>0){ c.fillStyle=G.flashColor; c.globalAlpha=G.flash*0.45; c.fillRect(0,0,VW,VH); c.globalAlpha=1; }
  if(G.state==='title') drawTitle(c);
  else if(G.state==='ending') drawEnding(c);
  else if(W && player){
    drawHUD(c);
    drawToasts(c);
    if(G.zoneToastT>0 && W) drawZoneBanner(c);
    if(G.state==='shop') drawShop(c);
    if(G.state==='inv') drawInv(c);
    if(G.state==='pause') drawPause(c);
    if(G.state==='death') drawDeath(c);
  }
  if(G.fade>0){ c.fillStyle='#000'; c.globalAlpha=Math.min(1,G.fade); c.fillRect(0,0,VW,VH); c.globalAlpha=1; }
}
function drawTiles(c){
  const z=ZONES[W.zi];
  const x0=Math.max(0,Math.floor(G.camx/TILE)-1), x1=Math.min(W.W-1,Math.ceil((G.camx+VW)/TILE)+1);
  const y0=Math.max(0,Math.floor(G.camy/TILE)-1), y1=Math.min(W.H-1,Math.ceil((G.camy+VH)/TILE)+1);
  for(let ty=y0;ty<=y1;ty++){
    for(let tx=x0;tx<=x1;tx++){
      const h=hash2(tx+W.seed,ty);
      const px=tx*TILE, py=ty*TILE;
      if(W.tiles[ty][tx]===1){
        c.fillStyle=z.wall; c.fillRect(px,py,TILE,TILE);
        /* top face if floor below */
        if(ty+1<W.H && W.tiles[ty+1][tx]===0){
          c.fillStyle=z.trim; c.globalAlpha=0.5; c.fillRect(px,py+TILE-3,TILE,3); c.globalAlpha=1;
          c.fillStyle='rgba(0,0,0,0.35)'; c.fillRect(px,py,TILE,10);
        }
        c.fillStyle='rgba(0,0,0,0.18)';
        if(h<0.3) c.fillRect(px+4+h*40,py+8+h*30,3,3);
        if(h>0.8) c.fillRect(px+10,py+18,2,2);
      } else {
        c.fillStyle=z.floor[Math.floor(h*3)]; c.fillRect(px,py,TILE,TILE);
        c.strokeStyle='rgba(0,0,0,0.12)'; c.lineWidth=1;
        c.strokeRect(px+0.5,py+0.5,TILE,TILE);
        if(h>0.93){ c.fillStyle='rgba(0,0,0,0.15)'; c.beginPath(); c.arc(px+16,py+16,5,0,TAU); c.fill(); }
        if(h<0.05){ c.fillStyle='rgba(255,255,255,0.03)'; c.fillRect(px,py,TILE,2); }
      }
    }
  }
  /* zone 3 water sheen */
  if(W.zi===3){
    c.fillStyle='rgba(90,160,170,0.05)';
    for(let i=0;i<5;i++){
      const yy=G.camy+((G.t*12+i*110)%(VH+120))-60;
      c.fillRect(G.camx,yy,VW,2);
    }
  }
}
function drawFloorDecals(c){
  /* sanctuary glow patches, crack decals */
  for(const cr of W.cracks){
    const px=cr.x*TILE, py=cr.y*TILE;
    c.strokeStyle='rgba(10,8,6,0.7)'; c.lineWidth=1.5;
    c.beginPath();
    c.moveTo(px+8,py+TILE-4); c.lineTo(px+13,py+TILE-12); c.lineTo(px+9,py+TILE-18); c.lineTo(px+15,py+TILE-26);
    c.moveTo(px+13,py+TILE-12); c.lineTo(px+19,py+TILE-16);
    c.stroke();
    c.fillStyle='rgba(0,0,0,0.25)'; c.fillRect(px+6,py+TILE-8,14,8);
  }
}
function drawPlates(c){
  for(const pl of W.plates){
    const px=pl.x*TILE, py=pl.y*TILE;
    const off=pl.down?4:0;
    c.fillStyle='#241f18'; c.fillRect(px+2,py+2,TILE-4,TILE-4);
    c.fillStyle=pl.down?'#8a6a3a':'#6a5232';
    c.fillRect(px+5,py+5+off,TILE-10,TILE-10);
    c.strokeStyle='rgba(255,210,120,0.35)'; c.lineWidth=1;
    c.strokeRect(px+8+0.5,py+8+off+0.5,TILE-17,TILE-17);
    if(pl.down && G.t-pl.pressT<0.4){ c.fillStyle='rgba(255,210,120,0.25)'; c.fillRect(px+5,py+5+off,TILE-10,TILE-10); }
  }
}
function drawDoors(c){
  for(const dr of W.doors){
    const px=dr.x*TILE, py=dr.y*TILE;
    if(dr.open<1){
      c.save();
      c.translate(px+TILE/2,py+TILE/2);
      c.scale(1,1-dr.open);
      c.fillStyle='#3a2f22'; c.fillRect(-TILE/2,-TILE/2,TILE,TILE);
      c.fillStyle='#5a4630';
      for(let i=0;i<3;i++) c.fillRect(-TILE/2+4+i*10,-TILE/2+4,6,TILE-8);
      c.fillStyle='rgba(201,142,46,0.6)'; c.beginPath(); c.arc(0,0,4,0,TAU); c.fill();
      c.restore();
    }
  }
}
function drawCrystals(c){
  for(const cr of W.crystals){
    const px=cr.x*TILE+16, py=cr.y*TILE+16;
    const pulse=0.7+Math.sin(G.t*3+cr.x)*0.3;
    c.save();
    c.translate(px,py);
    if(cr.active){
      c.globalAlpha=0.9;
      c.fillStyle='#bff0ff';
      c.shadowColor='#9fd8ff'; c.shadowBlur=18;
    } else {
      c.globalAlpha=pulse;
      c.fillStyle='#3a6a8a';
      c.shadowColor='#4a8aba'; c.shadowBlur=8;
    }
    c.beginPath();
    c.moveTo(0,-14); c.lineTo(8,0); c.lineTo(0,14); c.lineTo(-8,0); c.closePath(); c.fill();
    c.restore();
    if(!cr.active){
      c.fillStyle='rgba(159,216,255,0.6)'; c.font='10px Georgia,serif'; c.textAlign='center';
      c.fillText('◇',px,py-20); c.textAlign='left';
    }
  }
}
function drawMirrors(c){
  for(const m of W.mirrors){
    const px=m.x*TILE+16, py=m.y*TILE+16;
    c.save(); c.translate(px,py);
    c.fillStyle='#2a3a44'; c.beginPath(); c.arc(0,0,10,0,TAU); c.fill();
    c.rotate(m.ang);
    c.fillStyle=m.active?'#cfeeff':'#7a95a8';
    c.fillRect(-2,-16,4,32);
    c.restore();
    if(m.flash>0){ m.flash-=0.016; c.fillStyle='rgba(207,238,255,'+m.flash+')'; c.beginPath(); c.arc(px,py,16,0,TAU); c.fill(); }
  }
  /* beam */
  if(W.zi===3 && W.mirrors.length>=2){
    const m1=W.mirrors[0], m2=W.mirrors[1];
    const e={x:W.emitters[0].x*TILE+16,y:W.emitters[0].y*TILE+16};
    const p1={x:m1.x*TILE+16,y:m1.y*TILE+16};
    const p2={x:m2.x*TILE+16,y:m2.y*TILE+16};
    const cr=W.crystals[0]; const cp={x:cr.x*TILE+16,y:cr.y*TILE+16};
    const segs=[[e,p1],[p1,p2],[p2,cp]];
    const on=[true, dist(e.x,e.y,p1.x,p1.y)<900, m1.active, cr.active];
    c.save(); c.globalCompositeOperation='lighter';
    for(let i=0;i<segs.length;i++){
      const a=segs[i][0], b=segs[i][1];
      const lit = i===0?true : i===1?m1.active : cr.active;
      if(!lit) continue;
      const al=i===2?0.5:0.25;
      c.strokeStyle='rgba(159,216,255,'+al+')'; c.lineWidth=i===2?3:2;
      c.beginPath(); c.moveTo(a.x,a.y); c.lineTo(b.x,b.y); c.stroke();
      c.strokeStyle='rgba(232,246,255,0.5)'; c.lineWidth=1;
      c.beginPath(); c.moveTo(a.x,a.y); c.lineTo(b.x,b.y); c.stroke();
    }
    c.restore();
    /* emitter lens */
    c.fillStyle='#3a5a6a'; c.fillRect(e.x-8,e.y-10,16,20);
    c.fillStyle='#9fd8ff'; c.beginPath(); c.arc(e.x,e.y,5,0,TAU); c.fill();
  }
}
function drawStones(c){
  for(const s of W.stones){
    const px=s.x*TILE+16, py=s.y*TILE+16;
    c.fillStyle='#2e2a24'; c.fillRect(px-14,py+2,28,14);
    c.fillStyle='#4a4238'; c.fillRect(px-11,py-14,22,18);
    const pulse=0.5+Math.sin(G.t*2.4)*0.5;
    c.fillStyle='rgba(255,215,106,'+(0.5+pulse*0.5)+')';
    c.beginPath(); c.moveTo(px,py-12); c.lineTo(px+5,py-4); c.lineTo(px,py+2); c.lineTo(px-5,py-4); c.closePath(); c.fill();
    if(Math.random()<0.12) spawnP({x:px+rand(-10,10),y:py-10,vx:rand(-4,4),vy:rand(-16,-6),life:0.8,max:0.8,color:'#ffd76a',size:1.4,grav:-6});
  }
}
function drawAltars(c){
  for(const a of W.altars){
    const px=a.x*TILE+16, py=a.y*TILE+16;
    c.fillStyle='#332b20'; c.fillRect(px-16,py+4,32,12);
    c.fillStyle='#4a3c28'; c.fillRect(px-12,py-10,24,16);
    const lit=G.weapon>=(W.zi===2?1:2);
    const col=W.zi===2?'#ff9a3c':'#ffd76a';
    if(lit){
      c.save(); c.globalCompositeOperation='lighter';
      const gr=c.createRadialGradient(px,py-18,2,px,py-18,26);
      gr.addColorStop(0,col); gr.addColorStop(1,'rgba(0,0,0,0)');
      c.fillStyle=gr; c.beginPath(); c.arc(px,py-18,26,0,TAU); c.fill();
      c.restore();
      c.fillStyle=col;
      c.beginPath(); c.moveTo(px,py-30); c.lineTo(px+4,py-16); c.lineTo(px,py-10); c.lineTo(px-4,py-16); c.closePath(); c.fill();
    } else {
      c.fillStyle='#5a4a34';
      c.beginPath(); c.moveTo(px,py-26); c.lineTo(px+4,py-16); c.lineTo(px,py-10); c.lineTo(px-4,py-16); c.closePath(); c.fill();
      if(Math.random()<0.05) spawnP({x:px+rand(-8,8),y:py-14,vx:rand(-3,3),vy:rand(-12,-4),life:0.6,max:0.6,color:'#8a7a5a',size:1.2,grav:-8});
    }
  }
}
function drawMerchant(c){
  if(!W.merchant) return;
  const m=W.merchant, px=m.x*TILE+16, py=m.y*TILE+16;
  c.fillStyle='#2a2118'; c.fillRect(px-18,py+6,36,10);
  const robe='#5a4a6a';
  c.fillStyle=robe;
  c.beginPath(); c.moveTo(px,py-20); c.lineTo(px+10,py+6); c.lineTo(px-10,py+6); c.closePath(); c.fill();
  c.fillStyle='#c9a44a'; c.beginPath(); c.arc(px,py-16,6,0,TAU); c.fill();
  c.fillStyle='#3a2f1a'; c.fillRect(px-4,py-20,8,3);
  const pulse=0.4+Math.sin(G.t*2)*0.3;
  c.fillStyle='rgba(201,164,74,'+pulse+')'; c.font='11px Georgia,serif'; c.textAlign='center';
  c.fillText('⚖',px,py-30); c.textAlign='left';
}
function drawJars(c){
  for(const j of W.jars){
    if(j.taken) continue;
    const px=j.x*TILE+16, py=j.y*TILE+16;
    c.fillStyle='#4a5a52';
    c.beginPath(); c.ellipse(px,py+4,8,7,0,0,TAU); c.fill();
    c.fillStyle='#6a7a70'; c.fillRect(px-5,py-8,10,10);
    c.strokeStyle='rgba(122,208,255,0.5)'; c.lineWidth=1;
    c.beginPath(); c.moveTo(px-3,py-4); c.lineTo(px+2,py+2); c.moveTo(px+3,py-4); c.lineTo(px-2,py+2); c.stroke();
  }
}
function drawHeart5(c){
  if(!W.heart5||W.heart5.taken) return;
  const px=W.heart5.x*TILE+16, py=W.heart5.y*TILE+16;
  const pulse=1+Math.sin(G.t*3)*0.12;
  c.save(); c.translate(px,py); c.scale(pulse,pulse);
  c.fillStyle='rgba(255,120,90,0.9)';
  heart(c,0,0,10); c.fill();
  c.restore();
  if(Math.random()<0.1) spawnP({x:px+rand(-8,8),y:py-8,vx:0,vy:-14,life:0.8,max:0.8,color:'#ff9a8a',size:1.6,grav:-8});
}
function drawBeacon(c){
  if(!W.beacon) return;
  const b=W.beacon, px=b.x*TILE+16, py=b.y*TILE+16;
  c.fillStyle='#2a2438'; c.fillRect(px-26,py+8,52,16);
  c.fillStyle='#3a3248'; c.fillRect(px-20,py-14,40,24);
  c.fillStyle='#4a4058'; c.fillRect(px-14,py-26,28,14);
  const col=b.lit?'#ffd76a':(b.ready?'#9fd8ff':'#4a4a5a');
  const pulse=b.lit?1:(0.5+Math.sin(G.t*2)*0.3);
  c.save(); c.globalCompositeOperation='lighter';
  const gr=c.createRadialGradient(px,py-34,2,px,py-34,b.lit?60:24);
  gr.addColorStop(0,col); gr.addColorStop(1,'rgba(0,0,0,0)');
  c.globalAlpha=pulse; c.fillStyle=gr; c.beginPath(); c.arc(px,py-34,b.lit?60:24,0,TAU); c.fill();
  c.restore();
  c.fillStyle=col; c.beginPath(); c.arc(px,py-34,7,0,TAU); c.fill();
  if(b.lit){
    c.save(); c.globalCompositeOperation='lighter';
    c.fillStyle='rgba(255,215,106,0.14)';
    c.fillRect(px-3,py-540,6,506);
    c.restore();
  }
}
function drawPickups(c){
  for(const pk of W.pickups){
    const bob=Math.sin(G.t*3+pk.x)*3;
    const px=pk.x, py=pk.y+bob;
    if(pk.item==='gold'){
      c.fillStyle='#ffd76a'; c.beginPath(); c.arc(px,py,6,0,TAU); c.fill();
      c.fillStyle='#b8862a'; c.beginPath(); c.arc(px,py,3,0,TAU); c.fill();
    } else if(pk.item==='stem'){
      c.fillStyle='#7ce88a';
      c.beginPath(); c.ellipse(px,py,5,8,0.4,0,TAU); c.fill();
      c.strokeStyle='#3a8a4a'; c.lineWidth=2;
      c.beginPath(); c.moveTo(px,py+8); c.quadraticCurveTo(px+2,py,px-1,py-6); c.stroke();
    } else if(pk.item==='seal'){
      c.fillStyle='#7ad0ff';
      c.beginPath(); c.arc(px,py,7,0,TAU); c.fill();
      c.strokeStyle='#2a6a9a'; c.lineWidth=2;
      c.beginPath(); c.arc(px,py,4,0,TAU); c.stroke();
    } else if(pk.item==='band'){
      c.fillStyle='#cfd8e8';
      c.beginPath(); c.arc(px,py,7,0,TAU); c.fill();
      c.fillStyle='#2a3040'; c.beginPath(); c.arc(px,py,4,0,TAU); c.fill();
    }
  }
}
function drawChests(c){
  for(const ch of W.chests){
    const px=ch.x*TILE, py=ch.y*TILE;
    c.fillStyle=ch.opened?'#3a2f22':'#5a4630'; c.fillRect(px+4,py+10,24,16);
    c.fillStyle=ch.opened?'#2a2118':'#6a5232'; c.fillRect(px+4,py+6,24,6);
    c.fillStyle='#c9a44a'; c.fillRect(px+14,py+14,4,5);
    if(!ch.opened && Math.random()<0.06) spawnP({x:px+16,y:py+8,vx:rand(-3,3),vy:-8,life:0.5,max:0.5,color:'#ffd76a',size:1.2,grav:-4});
  }
}
function drawBreakables(c){
  for(const b of W.breakables){
    const px=b.x*TILE, py=b.y*TILE;
    const z=ZONES[W.zi];
    c.fillStyle=z.wall; c.fillRect(px,py,TILE,TILE);
    c.fillStyle='rgba(0,0,0,0.25)'; c.fillRect(px,py,TILE,TILE);
    c.strokeStyle='rgba(15,10,8,0.8)'; c.lineWidth=1.5;
    c.beginPath();
    c.moveTo(px+8,py+4); c.lineTo(px+14,py+12); c.lineTo(px+10,py+20); c.lineTo(px+16,py+28);
    c.moveTo(px+14,py+12); c.lineTo(px+22,py+16);
    c.stroke();
    const dmg=1-b.hp/b.max;
    if(dmg>0){
      c.strokeStyle='rgba(5,4,3,0.9)'; c.lineWidth=2;
      c.beginPath();
      c.moveTo(px+22,py+6); c.lineTo(px+16,py+14); c.lineTo(px+22,py+22);
      c.stroke();
    }
    if(b.hit>0){ b.hit-=0.016; c.fillStyle='rgba(255,255,255,0.2)'; c.fillRect(px,py,TILE,TILE); }
  }
}
function heart(c,x,y,s){
  c.beginPath();
  c.moveTo(x,y+s*0.9);
  c.bezierCurveTo(x-s*1.2,y+s*0.1,x-s*0.7,y-s*0.9,x,y-s*0.3);
  c.bezierCurveTo(x+s*0.7,y-s*0.9,x+s*1.2,y+s*0.1,x,y+s*0.9);
  c.closePath();
}
function drawEnemies(c){
  for(const e of enemies){
    if(e.dead) continue;
    const cx=e.x+e.w/2, cy=e.y+e.h/2;
    const bob=Math.sin(e.t*4+e.seed)*2;
    c.save();
    c.translate(cx,cy);
    if(e.spawnT>0){ c.globalAlpha=1-e.spawnT/0.6; }
    if(e.frozen>0){
      c.fillStyle='rgba(122,208,255,0.35)';
      c.beginPath(); c.arc(0,0,e.w*0.7,0,TAU); c.fill();
      c.strokeStyle='rgba(208,240,255,0.8)'; c.lineWidth=2;
      c.beginPath(); c.arc(0,0,e.w*0.7,0,TAU); c.stroke();
    }
    const col=e.hurtFlash>0?'#ff8a6a':(e.burn>0?'#ff6a3a':'#3a3a45');
    if(e.type==='sentry'){
      c.fillStyle=col;
      c.beginPath(); c.moveTo(0,-14+bob); c.lineTo(11,bob); c.lineTo(7,13+bob); c.lineTo(-7,13+bob); c.lineTo(-11,bob); c.closePath(); c.fill();
      c.fillStyle='#1a1a22'; c.beginPath(); c.arc(0,-4+bob,5,0,TAU); c.fill();
      c.fillStyle=e.aggro?'#ff5a3a':'#7a4a3a'; c.fillRect(e.face>0?0:-5,-6+bob,5,3);
    } else if(e.type==='brute'){
      c.fillStyle=col;
      c.fillRect(-15,-12+bob,30,26);
      c.fillStyle='#26262e'; c.fillRect(-11,-8+bob,22,10);
      c.fillStyle=e.aggro?'#ff5a3a':'#7a4a3a';
      c.fillRect(-8,-6+bob,5,3); c.fillRect(3,-6+bob,5,3);
      c.fillStyle='#4a4a55'; c.fillRect(-18,-8+bob,5,18); c.fillRect(13,-8+bob,5,18);
    } else if(e.type==='guardian'){
      c.fillStyle=col;
      c.beginPath(); c.moveTo(0,-20+bob); c.lineTo(18,-4+bob); c.lineTo(14,18+bob); c.lineTo(-14,18+bob); c.lineTo(-18,-4+bob); c.closePath(); c.fill();
      c.fillStyle='#15151c'; c.beginPath(); c.arc(0,-4+bob,8,0,TAU); c.fill();
      c.fillStyle='#ff3a2a'; c.beginPath(); c.arc(0,-4+bob,3.5,0,TAU); c.fill();
      c.fillStyle='#4a4a55'; c.fillRect(-22,-8+bob,6,16); c.fillRect(16,-8+bob,6,16);
    } else if(e.type==='boss'){
      const en=e.hp<e.maxHp*0.5;
      c.fillStyle=col;
      c.beginPath(); c.moveTo(0,-26+bob); c.lineTo(24,-8+bob); c.lineTo(20,22+bob); c.lineTo(-20,22+bob); c.lineTo(-24,-8+bob); c.closePath(); c.fill();
      c.fillStyle='#101018'; c.beginPath(); c.arc(0,-6+bob,12,0,TAU); c.fill();
      c.fillStyle=en?'#ff2a1a':'#ff5a3a';
      c.beginPath(); c.arc(-5,-8+bob,3,0,TAU); c.arc(5,-8+bob,3,0,TAU); c.fill();
      c.fillStyle='#4a4a55'; c.fillRect(-30,-10+bob,7,20); c.fillRect(23,-10+bob,7,20);
      if(e.state==='charging'){
        c.save(); c.globalCompositeOperation='lighter';
        c.fillStyle='rgba(255,60,30,0.25)';
        c.beginPath(); c.arc(0,0,34,0,TAU); c.fill(); c.restore();
      }
      if(en && Math.random()<0.3) emberTrail(cx,cy);
    }
    c.restore();
    /* mini hp bar */
    if(e.mini||e.isBoss){
      const bw=e.w+8;
      c.fillStyle='rgba(0,0,0,0.6)'; c.fillRect(cx-bw/2,cy-e.h/2-12,bw,5);
      c.fillStyle=e.isBoss?'#ff5a3a':'#c96a4a';
      c.fillRect(cx-bw/2+1,cy-e.h/2-11,(bw-2)*clamp(e.hp/e.maxHp,0,1),3);
    }
  }
}
function drawPlayer(c){
  const p=player;
  const cx=p.x+p.w/2, cy=p.y+p.h/2;
  const bob=p.moving?Math.sin(p.walkT)*1.6:Math.sin(G.t*2)*0.8;
  const w=weapon();
  c.save();
  c.translate(cx,cy+bob*0.4);
  if(p.invuln>0 && Math.floor(G.t*16)%2===0) c.globalAlpha=0.45;
  const stance=G.weapon===2?1.18:(G.flags.shardFound?1.08:1);
  c.scale(stance,1);
  /* cloak */
  const cloakCol=G.equip.cloak?'#2a2438':'#3a3040';
  c.fillStyle=cloakCol;
  c.beginPath();
  c.moveTo(0,-11+bob); c.lineTo(9,6+bob); c.lineTo(6,12+bob); c.lineTo(-6,12+bob); c.lineTo(-9,6+bob);
  c.closePath(); c.fill();
  /* cloak hem shimmer for falchion */
  if(G.weapon===2){
    c.strokeStyle='rgba(255,215,106,'+(0.4+Math.sin(G.t*6)*0.25)+')'; c.lineWidth=1.5;
    c.beginPath(); c.moveTo(-6,12+bob); c.lineTo(6,12+bob); c.stroke();
  }
  if(G.equip.cloak){
    c.strokeStyle='#1a1626'; c.lineWidth=2;
    c.beginPath(); c.moveTo(-9,6+bob); c.lineTo(9,6+bob); c.stroke();
  }
  /* head */
  c.fillStyle='#d8c8a8'; c.beginPath(); c.arc(0,-9+bob,4.5,0,TAU); c.fill();
  c.fillStyle='#4a3c2c'; c.beginPath(); c.arc(0,-10+bob,4.5,Math.PI,0); c.fill();
  /* band token */
  if(G.equip.band){
    c.strokeStyle='#cfd8e8'; c.lineWidth=2;
    c.beginPath(); c.arc(-7*p.face,2+bob,3,0,TAU); c.stroke();
    c.save(); c.globalCompositeOperation='lighter';
    c.strokeStyle='rgba(207,216,232,0.5)'; c.beginPath(); c.arc(-7*p.face,2+bob,4.5,0,TAU); c.stroke(); c.restore();
  }
  /* lantern token */
  if(G.equip.lantern){
    const la=G.t*2;
    c.save(); c.globalCompositeOperation='lighter';
    c.fillStyle='rgba(122,184,255,'+(0.5+Math.sin(la)*0.3)+')';
    c.beginPath(); c.arc(11,4+bob,3,0,TAU); c.fill(); c.restore();
    c.fillStyle='#3a4a5a'; c.fillRect(9,1+bob,4,6);
  }
  /* weapon */
  const a=aimAngle();
  const swingT=p.atkT>0?1-p.atkT/(Math.max(0.12,w.cd*0.55)):0;
  let wa;
  if(p.atkT>0){ wa=p.atkAng - w.arc/2 + swingT*w.arc; }
  else { wa=a+Math.sin(G.t*1.5)*0.15; }
  c.save();
  c.rotate(wa);
  const reach=w.range*0.62;
  if(G.weapon===0){
    c.strokeStyle='#b8a888'; c.lineWidth=3;
    c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.7,0); c.stroke();
    c.strokeStyle='#8a7a5a'; c.lineWidth=1.5;
    c.beginPath(); c.moveTo(6+reach*0.5,3); c.lineTo(6+reach*0.5,-3); c.stroke();
  } else if(G.weapon===1){
    c.strokeStyle='#c98a4a'; c.lineWidth=3;
    c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.5,0); c.stroke();
    c.save(); c.globalCompositeOperation='lighter';
    c.strokeStyle='rgba(255,154,60,0.9)'; c.lineWidth=4;
    c.beginPath(); c.moveTo(6+reach*0.5,0); c.lineTo(6+reach,0); c.stroke();
    c.strokeStyle='rgba(255,207,106,0.5)'; c.lineWidth=7;
    c.beginPath(); c.moveTo(6+reach*0.7,0); c.lineTo(6+reach,0); c.stroke();
    c.restore();
  } else {
    c.strokeStyle='#b8862a'; c.lineWidth=3;
    c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.45,0); c.stroke();
    c.save(); c.globalCompositeOperation='lighter';
    const gr=c.createLinearGradient(6+reach*0.45,0,6+reach,0);
    gr.addColorStop(0,'rgba(255,215,106,0.4)'); gr.addColorStop(1,'#ffe9a0');
    c.strokeStyle=gr; c.lineWidth=6;
    c.beginPath(); c.moveTo(6+reach*0.45,0); c.lineTo(6+reach,0); c.stroke();
    c.strokeStyle='rgba(255,242,192,0.6)'; c.lineWidth=10;
    c.beginPath(); c.moveTo(6+reach*0.75,0); c.lineTo(6+reach,0); c.stroke();
    c.restore();
  }
  c.restore();
  /* swing arc */
  if(p.atkT>0 && p.atkDur>0){
    c.save(); c.globalCompositeOperation='lighter';
    const prog=1-p.atkT/p.atkDur;
    c.globalAlpha=(1-prog)*0.4;
    c.strokeStyle=G.weapon===2?'#ffe9a0':(G.weapon===1?'#ff9a3c':'#d9c9a8');
    c.lineWidth=G.weapon===2?10:6;
    c.beginPath();
    c.arc(0,0,w.range*0.9,p.atkAng-w.arc/2,p.atkAng-w.arc/2+prog*w.arc);
    c.stroke();
    c.restore(); c.globalAlpha=1;
  }
  /* off-hand shard */
  if(G.flags.shardFound){
    const oa=a+Math.PI*0.8;
    const sx=Math.cos(oa)*10, sy=Math.sin(oa)*10;
    c.save(); c.globalCompositeOperation='lighter';
    const pulse=0.6+Math.sin(G.t*5)*0.4;
    c.fillStyle='rgba(159,216,255,'+pulse+')';
    c.beginPath(); c.moveTo(sx,sy-6); c.lineTo(sx+4,sy); c.lineTo(sx,sy+6); c.lineTo(sx-4,sy); c.closePath(); c.fill();
    c.restore();
  }
  c.restore();
  /* hurt flash ring */
  if(p.hurtT>0){
    c.strokeStyle='rgba(255,74,58,'+(p.hurtT*2)+')'; c.lineWidth=2;
    c.beginPath(); c.arc(cx,cy,16,0,TAU); c.stroke();
  }
}
function drawBolts(c){
  c.save(); c.globalCompositeOperation='lighter';
  for(const b of bolts){
    const col=b.enemy?'#ff6a4a':'#9fd8ff';
    c.fillStyle=col;
    c.save(); c.translate(b.x,b.y); c.rotate(b.ang);
    c.beginPath(); c.moveTo(8,0); c.lineTo(-6,4); c.lineTo(-6,-4); c.closePath(); c.fill();
    c.restore();
    c.fillStyle='rgba(255,255,255,0.7)';
    c.beginPath(); c.arc(b.x,b.y,2.5,0,TAU); c.fill();
  }
  c.restore();
}
function drawAmbient(c){
  for(const f of worldFx){
    if(f.type==='dust'){
      c.fillStyle='rgba(220,200,160,'+(0.12*Math.min(1,f.life))+')';
      c.fillRect(f.x,f.y,2,2);
    } else if(f.type==='ash'){
      c.fillStyle='rgba(150,140,130,0.2)';
      c.fillRect(f.x,f.y,2,1);
    } else if(f.type==='mist'){
      c.fillStyle='rgba(140,190,200,'+(0.05*Math.min(1,f.life*0.5))+')';
      c.beginPath(); c.ellipse(f.x,f.y,f.s,f.s*0.35,0,0,TAU); c.fill();
    }
  }
}
function drawExitSign(c){
  if(!W.exit) return;
  const px=W.exit.x*TILE+16, py=W.exit.y*TILE+16;
  const ok=G.zoneClear;
  const pulse=0.5+Math.sin(G.t*3)*0.4;
  c.save();
  if(ok){
    c.globalCompositeOperation='lighter';
    const gr=c.createRadialGradient(px,py,4,px,py,40);
    gr.addColorStop(0,'rgba(255,215,106,'+0.5*pulse+')'); gr.addColorStop(1,'rgba(0,0,0,0)');
    c.fillStyle=gr; c.beginPath(); c.arc(px,py,40,0,TAU); c.fill();
  }
  c.restore();
  c.fillStyle=ok?'#ffd76a':'#5a5a66';
  c.font='16px Georgia,serif'; c.textAlign='center';
  c.fillText(ok?'✦':'✧',px,py+5);
  c.font='italic 10px Georgia,serif';
  c.fillText(ok?'Ascend':'sealed',px,py+22);
  c.textAlign='left';
}
function drawLighting(c,ox,oy){
  lctx.clearRect(0,0,VW,VH);
  lctx.fillStyle='rgba(4,6,12,'+(0.62/(ZONES[W.zi].light||1))+')';
  lctx.fillRect(0,0,VW,VH);
  lctx.globalCompositeOperation='destination-out';
  const c1=playerCenter();
  const pr=player&&player.dead?120:230;
  let g=lctx.createRadialGradient(c1.x,c1.y,20,c1.x,c1.y,pr);
  g.addColorStop(0,'rgba(0,0,0,0.95)'); g.addColorStop(1,'rgba(0,0,0,0)');
  lctx.fillStyle=g; lctx.beginPath(); lctx.arc(c1.x,c1.y,pr,0,TAU); lctx.fill();
  for(const s of W.stones){
    const x=s.x*TILE+16,y=s.y*TILE+16;
    g=lctx.createRadialGradient(x,y,4,x,y,90);
    g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,90,0,TAU); lctx.fill();
  }
  for(const a of W.altars){
    const x=a.x*TILE+16,y=a.y*TILE+16;
    g=lctx.createRadialGradient(x,y,4,x,y,110);
    g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,110,0,TAU); lctx.fill();
  }
  for(const cr of W.crystals){
    const x=cr.x*TILE+16,y=cr.y*TILE+16;
    g=lctx.createRadialGradient(x,y,4,x,y,cr.active?130:60);
    g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,cr.active?130:60,0,TAU); lctx.fill();
  }
  if(W.beacon){
    const x=W.beacon.x*TILE+16,y=W.beacon.y*TILE-20;
    g=lctx.createRadialGradient(x,y,4,x,y,W.beacon.lit?220:100);
    g.addColorStop(0,'rgba(0,0,0,0.9)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,W.beacon.lit?220:100,0,TAU); lctx.fill();
  }
  if(W.merchant){
    const x=W.merchant.x*TILE+16,y=W.merchant.y*TILE+16;
    g=lctx.createRadialGradient(x,y,4,x,y,120);
    g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,120,0,TAU); lctx.fill();
  }
  if(W.exit && G.zoneClear){
    const x=W.exit.x*TILE+16,y=W.exit.y*TILE+16;
    g=lctx.createRadialGradient(x,y,4,x,y,100);
    g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
    lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,100,0,TAU); lctx.fill();
  }
  lctx.globalCompositeOperation='source-over';
  c.drawImage(lcv,ox,oy);
}
function drawVignette(c){
  const g=c.createRadialGradient(VW/2,VH/2,VH*0.45,VW/2,VH/2,VH*0.85);
  g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.55)');
  c.fillStyle=g; c.fillRect(0,0,VW,VH);
}
function drawShimmer(c){
  if(G.shimmer<=0) return;
  const a=Math.min(1,G.shimmer)*0.5;
  c.save(); c.globalCompositeOperation='lighter';
  const g=c.createRadialGradient(VW/2,VH/2,VH*0.35,VW/2,VH/2,VH*0.75);
  g.addColorStop(0,'rgba(255,215,106,0)'); g.addColorStop(1,'rgba(255,215,106,'+a+')');
  c.fillStyle=g; c.fillRect(0,0,VW,VH);
  c.restore();
}

/* ---------------- HUD ---------------- */
function drawHUD(c){
  /* hearts */
  const maxC=Math.ceil(G.maxHp/2);
  for(let i=0;i<maxC;i++){
    const hx=26+i*30, hy=30;
    const fill=Math.clamp2?0:0;
    const hpIn=i*2;
    const full=G.hp>=hpIn+2, half=G.hp===hpIn+1;
    c.save();
    c.translate(hx,hy);
    c.fillStyle='rgba(0,0,0,0.55)'; heart(c,0,0,11); c.fill();
    if(full){ c.fillStyle='#e84a3a'; heart(c,0,0,9); c.fill(); c.fillStyle='rgba(255,255,255,0.35)'; c.beginPath(); c.arc(-3,-3,2.5,0,TAU); c.fill(); }
    else if(half){
      c.save(); c.beginPath(); c.rect(-11,-11,11,22); c.clip();
      c.fillStyle='#e84a3a'; heart(c,0,0,9); c.fill(); c.restore();
    }
    c.strokeStyle='rgba(201,142,46,0.7)'; c.lineWidth=1.5; heart(c,0,0,11); c.stroke();
    c.restore();
  }
  /* gold */
  c.fillStyle='#ffd76a'; c.beginPath(); c.arc(26,64,7,0,TAU); c.fill();
  c.fillStyle='#b8862a'; c.beginPath(); c.arc(26,64,3.5,0,TAU); c.fill();
  c.fillStyle='#f0e0b0'; c.font='16px Georgia,serif';
  c.fillText(String(G.gold),40,69);
  /* weapon */
  const w=weapon();
  c.fillStyle='#8a7a5a'; c.font='11px Georgia,serif';
  c.fillText('WEAPON',20,90);
  c.fillStyle=w.color; c.font='italic 14px Georgia,serif';
  c.fillText(w.name,20,105);
  /* shards */
  if(G.flags.shardFound){
    c.fillStyle='#7ab8ff'; c.font='11px Georgia,serif';
    c.fillText('AETHER SHARD  [K]',20,126);
    c.fillStyle='#9fd8ff'; c.fillText(String(G.shards),20,141);
  }
  /* consumables */
  c.font='11px Georgia,serif';
  c.fillStyle='#7ce88a'; c.fillText('[Q] Vital Stem ×'+G.inv.stem,20,162);
  c.fillStyle='#7ad0ff'; c.fillText('[F] Binding Seal ×'+G.inv.seal,20,178);
  c.fillStyle='#8a7a5a'; c.fillText('[I] Inventory   [M] Sound',20,194);
  /* equipped accessories */
  let ay=214;
  for(const id of ['band','lantern','cloak']){
    if(G.equip[id]){
      c.fillStyle=ITEMS[id].color; c.font='11px Georgia,serif';
      c.fillText('◈ '+ITEMS[id].name,20,ay); ay+=15;
    }
  }
  /* minimap */
  const ms=3.2, mx=VW-31*ms-14, my=14;
  c.fillStyle='rgba(8,10,16,0.75)'; c.fillRect(mx-4,my-4,31*ms+8,31*ms+8);
  c.strokeStyle='rgba(201,142,46,0.4)'; c.strokeRect(mx-4.5,my-4.5,31*ms+9,31*ms+9);
  for(let ty=0;ty<W.H;ty++) for(let tx=0;tx<W.W;tx++){
    if(W.tiles[ty][tx]) { c.fillStyle='rgba(90,80,60,0.5)'; c.fillRect(mx+tx*ms,my+ty*ms,ms,ms); }
  }
  for(const s of W.stones){ c.fillStyle='#ffd76a'; c.fillRect(mx+s.x*ms-1,my+s.y*ms-1,ms+2,ms+2); }
  for(const a of W.altars){ c.fillStyle='#ff9a3c'; c.fillRect(mx+a.x*ms-1,my+a.y*ms-1,ms+2,ms+2); }
  if(W.merchant){ c.fillStyle='#c9a44a'; c.fillRect(mx+W.merchant.x*ms-1,my+W.merchant.y*ms-1,ms+2,ms+2); }
  if(W.exit){ c.fillStyle=G.zoneClear?'#ffe9a0':'#5a5a66'; c.fillRect(mx+W.exit.x*ms-1,my+W.exit.y*ms-1,ms+2,ms+2); }
  if(W.beacon){ c.fillStyle=W.beacon.lit?'#ffd76a':'#9fd8ff'; c.fillRect(mx+W.beacon.x*ms-1,my+W.beacon.y*ms-1,ms+2,ms+2); }
  const pc=playerCenter();
  c.fillStyle='#fff'; c.beginPath(); c.arc(mx+pc.x/TILE*ms,my+pc.y/TILE*ms,2.5,0,TAU); c.fill();
  /* objective */
  const obj=objective();
  c.textAlign='right';
  c.fillStyle='rgba(232,213,163,0.85)'; c.font='italic 13px Georgia,serif';
  c.fillText('◆ '+obj,VW-16,VH-18);
  c.textAlign='left';
  /* prompt */
  if(G.near){
    let label='';
    if(G.near.t==='stone') label='[E] Rest at Sanctuary Stone';
    else if(G.near.t==='altar') label='[E] Attune the altar';
    else if(G.near.t==='merchant') label='[E] Trade with the Merchant';
    else if(G.near.t==='chest') label='[E] Open the chest';
    else if(G.near.t==='beacon') label='[E] Reignite the Core Beacon';
    else if(G.near.t==='heart5') label='[E] Claim the fifth heart';
    else if(G.near.t==='jar') label='[E] Search the jar';
    if(label){
      c.font='14px Georgia,serif';
      const tw=c.measureText(label).width+26;
      c.fillStyle='rgba(8,10,16,0.8)'; roundRect(c,VW/2-tw/2,VH-100,tw,24,12); c.fill();
      c.strokeStyle='rgba(201,142,46,0.6)'; c.stroke();
      c.fillStyle='#ffe9a0'; c.textAlign='center'; c.fillText(label,VW/2,VH-84); c.textAlign='left';
    }
  }
  /* zone clear hint */
  if(G.zoneClear && W.exit){
    c.textAlign='center';
    c.fillStyle='rgba(255,233,160,'+(0.6+Math.sin(G.t*4)*0.3)+')';
    c.font='italic 15px Georgia,serif';
    c.fillText('The way forward is open — ascend to the upper right.',VW/2,40);
    c.textAlign='left';
  }
}
function objective(){
  const z=W.zi;
  if(z===0) return G.zoneClear?'Ascend to the Archives (upper right)':'Clear the sentries guarding the threshold';
  if(z===1) return G.flags.band||G.equip.band?'Ascend to the Gallery (upper right)':'Search the archives — something glints in the shelves';
  if(z===2) return G.weapon>=1?'Press both plates, then ascend':'Attune the Ignited Edge at the altar';
  if(z===3) return G.flags.shardFound?'Ascend to the Twin Spire (upper right)':'Fire the Aether Shard at the crystal — the mirror beam may help';
  if(z===4) return G.flags.guardianDown?'Ascend to the Core Chamber':'Defeat the Spire Guardian';
  return G.flags.beaconLit?'The Beacon burns':'Strike both crystals, then reignite the Core Beacon';
}
function drawZoneBanner(c){
  const a=clamp(G.zoneToastT,0,1);
  c.globalAlpha=a;
  c.textAlign='center';
  c.fillStyle='rgba(8,10,16,0.7)'; c.fillRect(0,VH/2-64,VW,110);
  c.fillStyle='#c98a4a'; c.font='12px Georgia,serif';
  c.fillText('— ZONE '+(W.zi+1)+' OF 6 —',VW/2,VH/2-38);
  c.fillStyle='#f0e0b0'; c.font='28px Georgia,serif';
  c.fillText(ZONES[W.zi].name,VW/2,VH/2-8);
  c.fillStyle='#8a7a5a'; c.font='italic 13px Georgia,serif';
  c.fillText(ZONES[W.zi].desc,VW/2,VH/2+20);
  c.globalAlpha=1; c.textAlign='left';
}
/* ---------------- overlays ---------------- */
function panel(c,x,y,w,h){
  c.fillStyle='rgba(10,12,20,0.94)'; roundRect(c,x,y,w,h,10); c.fill();
  c.strokeStyle='rgba(201,142,46,0.6)'; c.lineWidth=1.5; roundRect(c,x,y,w,h,10); c.stroke();
  c.strokeStyle='rgba(201,142,46,0.25)'; c.lineWidth=1;
  roundRect(c,x+5,y+5,w-10,h-10,7); c.stroke();
}
function drawShop(c){
  panel(c,VW/2-280,60,560,420);
  c.textAlign='center';
  c.fillStyle='#c9a44a'; c.font='13px Georgia,serif';
  c.fillText('THE MERCHANT OF AURELION',VW/2,92);
  c.fillStyle='#f0e0b0'; c.font='22px Georgia,serif';
  c.fillText('“Sigils for service, Warden.”',VW/2,120);
  c.fillStyle='#ffd76a'; c.font='16px Georgia,serif';
  c.fillText('Your gold: '+G.gold,VW/2,146);
  SHOP.forEach((s,i)=>{
    const e=shopEntry(i);
    const y=170+i*62;
    c.textAlign='left';
    if(e.hidden){
      c.fillStyle='#4a4a55'; c.font='14px Georgia,serif';
      c.fillText('[ '+(i+1)+' ]  · · ·  (the merchant has no word of this yet)',VW/2-240,y+10);
      return;
    }
    c.fillStyle=e.locked?'#6a5a4a':'#e8d5a3'; c.font='16px Georgia,serif';
    c.fillText('[ '+(i+1)+' ]  '+s.name,VW/2-240,y+8);
    c.fillStyle='#8a7a5a'; c.font='12px Georgia,serif';
    let stockTxt='';
    if(s.id==='stem') stockTxt=' (stock '+e.stock+')';
    if(s.id==='seal') stockTxt=' (stock '+e.stock+')';
    if(s.id==='heart') stockTxt=e.locked?' (claimed)':' (one only)';
    c.fillText(s.desc+stockTxt,VW/2-220,y+26);
    c.fillStyle=e.locked?'#5a4a3a':'#ffd76a'; c.font='15px Georgia,serif';
    c.fillText(e.locked?'—':s.cost+' gold',VW/2+150,y+14);
  });
  c.textAlign='center';
  c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
  c.fillText('[1-4] buy    [E / Esc] leave',VW/2,462);
  c.textAlign='left';
}
function drawInv(c){
  panel(c,VW/2-260,80,520,380);
  c.textAlign='center';
  c.fillStyle='#c9a44a'; c.font='13px Georgia,serif';
  c.fillText('THE WARDEN\'S PACK',VW/2,112);
  c.textAlign='left';
  const rows=[
    ['1','Warden\'s Band',G.equip.band,'Accessory — +10% attack speed. Silver ring at your wrist.'],
    ['2','Phantom Lantern',G.equip.lantern,'Accessory — reveals hidden cracks; soft blue light.'],
    ['3','Shadow Cloak',G.equip.cloak,'Accessory — halves trap & Guardian damage. Dark hem.'],
  ];
  rows.forEach((r,i)=>{
    const y=150+i*54;
    c.fillStyle=r[2]?'#9fd8a0':'#6a6a7a'; c.font='14px Georgia,serif';
    c.fillText('['+r[0]+']  '+r[1]+'   —   '+(r[2]?'EQUIPPED':'unequipped'),VW/2-220,y);
    c.fillStyle='#8a7a5a'; c.font='12px Georgia,serif';
    c.fillText(r[3],VW/2-200,y+18);
  });
  c.fillStyle='#7ce88a'; c.font='14px Georgia,serif';
  c.fillText('[Q]  Vital Stem ×'+G.inv.stem+' — restores 4 HP',VW/2-220,330);
  c.fillStyle='#7ad0ff';
  c.fillText('[F]  Binding Seal ×'+G.inv.seal+' — freezes an enemy 3s',VW/2-220,354);
  c.fillStyle='#9fd8ff';
  c.fillText('◇  Aether Shard — '+G.shards+' charges (fire with K / right-click)',VW/2-220,378);
  c.textAlign='center';
  c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
  c.fillText('[1-3] toggle accessories    [Esc / I] close',VW/2,442);
  c.textAlign='left';
}
function drawPause(c){
  c.fillStyle='rgba(4,6,12,0.7)'; c.fillRect(0,0,VW,VH);
  panel(c,VW/2-200,VH/2-120,400,240);
  c.textAlign='center';
  c.fillStyle='#f0e0b0'; c.font='24px Georgia,serif';
  c.fillText('The Corrosion Waits',VW/2,VH/2-76);
  c.fillStyle='#8a7a5a'; c.font='14px Georgia,serif';
  c.fillText('WASD move · Mouse aim · J/Z/left-click strike',VW/2,VH/2-38);
  c.fillText('K/X fire Aether Shard · Q Vital Stem · F Binding Seal',VW/2,VH/2-16);
  c.fillText('E interact · I inventory · M sound',VW/2,VH/2+6);
  c.fillText('[Esc] resume   [R] return to last Sanctuary Stone',VW/2,VH/2+34);
  c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
  c.fillText('Zone '+(W?W.zi+1:1)+': '+(W?ZONES[W.zi].name:''),VW/2,VH/2+70);
  c.textAlign='left';
}
function drawDeath(c){
  c.fillStyle='rgba(20,4,4,'+clamp(1-G.deathT/2.2,0,0.8)+')';
  c.fillRect(0,0,VW,VH);
  c.textAlign='center';
  c.fillStyle='#c02a2a'; c.font='26px Georgia,serif';
  c.fillText('The Corrosion claims another Warden…',VW/2,VH/2-10);
  c.fillStyle='#8a7a5a'; c.font='italic 14px Georgia,serif';
  c.fillText('returning to the last Sanctuary Stone',VW/2,VH/2+20);
  c.textAlign='left';
}
function drawTitle(c){
  c.fillStyle='rgba(4,6,12,0.82)'; c.fillRect(0,0,VW,VH);
  /* decorative gears */
  c.save(); c.translate(140,420); c.rotate(G.t*0.2);
  c.strokeStyle='rgba(201,142,46,0.2)'; c.lineWidth=6;
  for(let i=0;i<8;i++){ c.rotate(TAU/8); c.beginPath(); c.moveTo(0,40); c.lineTo(0,70); c.stroke(); }
  c.beginPath(); c.arc(0,0,44,0,TAU); c.stroke(); c.restore();
  c.save(); c.translate(830,140); c.rotate(-G.t*0.15);
  c.strokeStyle='rgba(201,142,46,0.15)'; c.lineWidth=5;
  for(let i=0;i<8;i++){ c.rotate(TAU/8); c.beginPath(); c.moveTo(0,30); c.lineTo(0,54); c.stroke(); }
  c.beginPath(); c.arc(0,0,34,0,TAU); c.stroke(); c.restore();
  c.textAlign='center';
  c.fillStyle='#c98a4a'; c.font='13px Georgia,serif';
  c.fillText('AURELION\'S SUNKEN OBSERVATORY',VW/2,150);
  c.fillStyle='#f0e0b0'; c.font='44px Georgia,serif';
  c.fillText('The Warden\'s Corrosion',VW/2,200);
  c.fillStyle='#8a7a5a'; c.font='italic 15px Georgia,serif';
  c.fillText('Six zones. Two altars. One Beacon. The decay is patient — you must not be.',VW/2,236);
  c.fillStyle='#e8d5a3'; c.font='17px Georgia,serif';
  c.fillText('[ Enter ]  Begin as the Last Warden',VW/2,300);
  if(hasSave()){
    c.fillStyle='#9fd8a0'; c.font='15px Georgia,serif';
    c.fillText('[ R ]  Resume from the last Sanctuary Stone',VW/2,330);
  }
  c.fillStyle='#6a6a7a'; c.font='13px Georgia,serif';
  c.fillText('WASD move · mouse aim · J / left-click strike · K fire shard · E interact',VW/2,400);
  c.fillText('Q Vital Stem · F Binding Seal · I inventory · M sound',VW/2,422);
  c.fillStyle='#5a5a66'; c.font='11px Georgia,serif';
  c.fillText('headphones recommended — every swing, coin and crystal has its voice',VW/2,470);
  c.textAlign='left';
}
function drawEnding(c){
  c.fillStyle='rgba(10,8,4,0.88)'; c.fillRect(0,0,VW,VH);
  const g=c.createRadialGradient(VW/2,200,20,VW/2,200,300);
  g.addColorStop(0,'rgba(255,215,106,'+(0.25+Math.sin(G.t*2)*0.08)+')');
  g.addColorStop(1,'rgba(0,0,0,0)');
  c.fillStyle=g; c.fillRect(0,0,VW,VH);
  c.textAlign='center';
  c.fillStyle='#ffd76a'; c.font='36px Georgia,serif';
  c.fillText('The Beacon Burns Again',VW/2,220);
  c.fillStyle='#e8d5a3'; c.font='italic 16px Georgia,serif';
  c.fillText('Light climbs the bronze spires; the Corrosion recedes like a tide.',VW/2,262);
  c.fillText('Aurelion remembers its Warden — and so will you.',VW/2,286);
  c.fillStyle='#8a7a5a'; c.font='14px Georgia,serif';
  c.fillText('Gold gathered: '+G.gold+'   ·   Weapon: '+weapon().name+'   ·   Hearts: '+(G.maxHp/2),VW/2,330);
  c.fillStyle='#e8d5a3'; c.font='16px Georgia,serif';
  c.fillText('[ Enter ]  Return to the threshold',VW/2,400);
  c.textAlign='left';
}

/* ---------------- main loop ---------------- */
let last=performance.now();
function frame(now){
  const dt=Math.min(0.05,(now-last)/1000);
  last=now;
  if(G.state!=='shop'&&G.state!=='inv'&&G.state!=='pause'&&G.state!=='title'&&G.state!=='ending') update(dt);
  else { G.t+=dt; updateToasts(dt); updateParts(dt); G.fade=Math.max(0,G.fade-dt*2); }
  render();
  requestAnimationFrame(frame);
}
window.addEventListener('pointerdown',()=>{ AU.init(); AU.resume(); },{once:false});
requestAnimationFrame(frame);

/* right-click also fires the shard */
cv.addEventListener('mousedown',e=>{ if(e.button===2){ e.preventDefault(); onActionRC(); } });
function onActionRC(){
  if(G.state==='playing') fireShard();
}
</script>
</body>
</html>