← Warden results

Warden

Swift Qwen3.8 27B GGUF · 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 81,268 bytes · SHA-256 c43f8c802c42
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,500&display=swap" rel="stylesheet">
<style>
:root{
  --gold:#d9a441; --gold-hi:#f4c96b; --gold-dim:#8a6b3a;
  --slate:#1b2230; --slate-2:#232a3a; --ink:#0b0e14;
  --parch:#cfd6e4; --rust:#b4653a; --ember:#e8823c; --aether:#7fd4ff;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
  background:radial-gradient(1200px 700px at 50% 30%, #10151f 0%, #05070b 70%);
  color:var(--parch);
  font-family:'Cormorant Garamond', Georgia, serif;
  overflow:hidden;
  display:flex;align-items:center;justify-content:center;
}
#wrap{
  position:relative;
  width:min(100vw, calc(100vh * 16 / 9));
  aspect-ratio:16/9;
  background:#000;
  box-shadow:0 0 80px rgba(217,164,65,.08), 0 0 0 1px #1c2333;
}
canvas#game{width:100%;height:100%;display:block;image-rendering:auto}
#vignette{position:absolute;inset:0;pointer-events:none;
  background:radial-gradient(120% 90% at 50% 45%, transparent 55%, rgba(4,6,10,.55) 100%);}
/* ---------- HUD ---------- */
#hud{position:absolute;top:14px;left:16px;right:16px;display:flex;justify-content:space-between;pointer-events:none;z-index:5}
.hudbox{background:linear-gradient(180deg, rgba(16,20,30,.82), rgba(10,13,20,.72));
  border:1px solid rgba(217,164,65,.35); border-radius:8px; padding:8px 14px;
  box-shadow:0 4px 18px rgba(0,0,0,.5); backdrop-filter:blur(2px);}
#hearts{display:flex;gap:6px;align-items:center}
.hc{position:relative;width:26px;height:24px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}
.hc svg{position:absolute;inset:0;width:100%;height:100%}
.hc .base path{fill:#2a3040;stroke:#12151d;stroke-width:1}
.hc .fill path{fill:#d64545}
.hc .fill{clip-path:inset(0 0 0 0)}
.hc.empty{opacity:.35}
.hc.gain{animation:hcGain .8s ease}
@keyframes hcGain{0%{transform:scale(1)}35%{transform:scale(1.35)}100%{transform:scale(1)}}
#weaponName{margin-top:5px;font-family:'Cinzel',serif;font-size:12px;letter-spacing:.14em;color:var(--gold);text-transform:uppercase}
#weaponName .lvl{color:var(--parch);opacity:.7}
#rightHud{display:flex;gap:10px;align-items:stretch}
.stat{display:flex;flex-direction:column;align-items:center;min-width:64px}
.stat .ic{font-size:15px;line-height:1}
.stat .val{font-family:'Cinzel',serif;font-size:17px;color:var(--gold-hi);letter-spacing:.05em}
.stat .lbl{font-size:10px;letter-spacing:.18em;opacity:.65;text-transform:uppercase}
#ammoStat{display:none}
/* ---------- banner & toasts ---------- */
#banner{position:absolute;top:18%;left:0;right:0;text-align:center;pointer-events:none;z-index:6;opacity:0;transition:opacity .6s}
#banner .znum{font-family:'Cinzel',serif;font-size:13px;letter-spacing:.5em;color:var(--rust);text-transform:uppercase}
#banner .zname{font-family:'Cinzel',serif;font-weight:900;font-size:44px;color:var(--gold-hi);
  text-shadow:0 0 24px rgba(217,164,65,.45), 0 3px 10px #000;letter-spacing:.08em;margin-top:4px}
#banner .zsub{font-style:italic;font-size:17px;color:var(--parch);opacity:.85;margin-top:6px}
#banner.show{opacity:1}
#toasts{position:absolute;bottom:64px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;z-index:6}
.toast{background:rgba(12,15,24,.88);border:1px solid rgba(217,164,65,.4);border-radius:6px;
  padding:6px 18px;font-size:16px;color:var(--parch);box-shadow:0 4px 14px rgba(0,0,0,.5);
  animation:toastIn .3s ease, toastOut .5s ease 2.6s forwards;}
.toast.gold{color:var(--gold-hi)} .toast.blue{color:var(--aether)} .toast.red{color:#e07a7a}
@keyframes toastIn{from{transform:translateY(10px);opacity:0}to{transform:none;opacity:1}}
@keyframes toastOut{to{transform:translateY(-6px);opacity:0}}
/* ---------- hints ---------- */
#hints{position:absolute;bottom:10px;left:16px;z-index:5;font-size:12.5px;letter-spacing:.06em;opacity:.6;pointer-events:none}
#hints b{color:var(--gold);font-weight:600}
#muteBtn{position:absolute;bottom:8px;right:14px;z-index:7;background:none;border:1px solid rgba(217,164,65,.35);
  color:var(--gold);font-family:'Cinzel',serif;font-size:11px;letter-spacing:.15em;padding:4px 10px;border-radius:5px;cursor:pointer;pointer-events:auto}
#muteBtn:hover{background:rgba(217,164,65,.12)}
/* ---------- panels ---------- */
.panel{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10;
  width:min(560px, 86%);max-height:82%;overflow:auto;
  background:linear-gradient(180deg, rgba(20,25,38,.97), rgba(11,14,22,.97));
  border:1px solid rgba(217,164,65,.5);border-radius:10px;
  box-shadow:0 0 60px rgba(0,0,0,.8), inset 0 0 40px rgba(217,164,65,.05);
  padding:22px 26px;}
.panel h2{font-family:'Cinzel',serif;font-size:22px;letter-spacing:.2em;color:var(--gold-hi);text-transform:uppercase;
  text-align:center;margin-bottom:4px}
.panel .psub{text-align:center;font-style:italic;font-size:14px;opacity:.7;margin-bottom:14px}
.hidden{display:none !important}
.irow{display:flex;align-items:center;gap:12px;padding:10px 8px;border-bottom:1px solid rgba(217,164,65,.14)}
.irow:last-child{border-bottom:none}
.irow .glyph{width:38px;height:38px;border-radius:8px;display:flex;align-items:center;justify-content:center;
  font-size:20px;border:1px solid rgba(217,164,65,.3);background:rgba(255,255,255,.03);flex:none}
.irow .iname{font-family:'Cinzel',serif;font-size:15px;letter-spacing:.06em;color:var(--parch)}
.irow .idesc{font-size:13.5px;font-style:italic;opacity:.75;line-height:1.25}
.irow .count{font-family:'Cinzel',serif;color:var(--gold-hi);font-size:14px}
.ibtn{margin-left:auto;flex:none;background:linear-gradient(180deg,#3a2f1a,#241d10);border:1px solid rgba(217,164,65,.55);
  color:var(--gold-hi);font-family:'Cinzel',serif;font-size:12px;letter-spacing:.12em;padding:7px 14px;border-radius:6px;cursor:pointer;text-transform:uppercase}
.ibtn:hover:not(:disabled){background:linear-gradient(180deg,#4a3c22,#2e2514);box-shadow:0 0 12px rgba(217,164,65,.25)}
.ibtn:disabled{opacity:.35;cursor:default}
.ibtn.on{background:linear-gradient(180deg,#1d3a2a,#12241a);border-color:rgba(120,220,150,.5);color:#a8e8bc}
.shoprow .price{font-family:'Cinzel',serif;color:var(--gold-hi);font-size:15px;white-space:nowrap}
.shoprow .stock{font-size:12px;opacity:.6;font-style:italic}
.shopgold{text-align:center;font-family:'Cinzel',serif;color:var(--gold-hi);font-size:15px;margin:12px 0 4px;letter-spacing:.1em}
/* ---------- screens ---------- */
.screen{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;align-items:center;justify-content:center;
  background:radial-gradient(90% 90% at 50% 40%, rgba(10,13,20,.88) 0%, rgba(4,6,10,.96) 100%);text-align:center}
.screen h1{font-family:'Cinzel',serif;font-weight:900;font-size:clamp(34px,6vw,64px);letter-spacing:.12em;color:var(--gold-hi);
  text-shadow:0 0 40px rgba(217,164,65,.5), 0 4px 14px #000;line-height:1.1}
.screen .sub{font-style:italic;font-size:clamp(15px,2vw,20px);color:var(--parch);opacity:.85;margin-top:10px;max-width:640px;line-height:1.5;padding:0 20px}
.screen .lore{font-size:14.5px;opacity:.6;margin-top:14px;max-width:560px;line-height:1.55;padding:0 20px}
.btnrow{display:flex;gap:16px;margin-top:34px;flex-wrap:wrap;justify-content:center}
.bigbtn{background:linear-gradient(180deg,#4a3a1e,#241c0e);border:1px solid rgba(217,164,65,.6);color:var(--gold-hi);
  font-family:'Cinzel',serif;font-size:15px;letter-spacing:.22em;text-transform:uppercase;padding:14px 34px;border-radius:8px;cursor:pointer;
  transition:all .15s}
.bigbtn:hover{background:linear-gradient(180deg,#5d4a28,#2e2414);box-shadow:0 0 26px rgba(217,164,65,.35);transform:translateY(-1px)}
.bigbtn.ghost{background:transparent;border-color:rgba(217,164,65,.3)}
#title .orn{width:220px;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);margin:22px auto}
#title .ver{position:absolute;bottom:18px;font-size:12px;letter-spacing:.3em;opacity:.4;text-transform:uppercase}
#controlsCard{margin-top:26px;display:grid;grid-template-columns:auto auto;gap:6px 26px;font-size:14px;opacity:.75;text-align:left}
#controlsCard b{color:var(--gold);font-family:'Cinzel',serif;font-size:12px;letter-spacing:.1em}
.statline{font-family:'Cinzel',serif;font-size:15px;letter-spacing:.12em;color:var(--parch);margin-top:8px}
.statline span{color:var(--gold-hi)}
#goScreen h1{color:#d65a4a;text-shadow:0 0 40px rgba(214,70,50,.4), 0 4px 14px #000}
#winScreen h1{color:var(--gold-hi)}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game" width="1280" height="720"></canvas>
  <div id="vignette"></div>

  <div id="hud" class="hidden">
    <div class="hudbox">
      <div id="hearts"></div>
      <div id="weaponName"></div>
    </div>
    <div id="rightHud">
      <div class="hudbox stat"><span class="ic">◉</span><span class="val" id="goldVal">0</span><span class="lbl">Sigils</span></div>
      <div class="hudbox stat" id="ammoStat"><span class="ic" style="color:var(--aether)">✦</span><span class="val" id="ammoVal">0</span><span class="lbl">Shard</span></div>
    </div>
  </div>

  <div id="banner"><div class="znum"></div><div class="zname"></div><div class="zsub"></div></div>
  <div id="toasts"></div>
  <div id="hints"><b>WASD</b> move · <b>J</b> strike · <b>K</b> shard bolt · <b>E</b> interact · <b>I</b> satchel · <b>1/2</b> use item · <b>P</b> pause</div>
  <button id="muteBtn">Sound: On</button>

  <div id="invPanel" class="panel hidden">
    <h2>Warden's Satchel</h2><div class="psub">Consumables may be stacked · one of each trinket may be worn</div>
    <div id="invBody"></div>
    <div style="text-align:center;margin-top:14px"><button class="bigbtn ghost" id="invClose" style="padding:9px 26px;font-size:12px">Close (I)</button></div>
  </div>

  <div id="shopPanel" class="panel hidden">
    <h2>The Merchant's Altar</h2><div class="psub">"Sigils buy what the Corrosion takes. Spend well, Warden."</div>
    <div id="shopBody"></div>
    <div class="shopgold">Your Sigils: <span id="shopGold">0</span></div>
    <div style="text-align:center;margin-top:10px"><button class="bigbtn ghost" id="shopClose" style="padding:9px 26px;font-size:12px">Leave (E)</button></div>
  </div>

  <div id="title" class="screen">
    <h1>The Warden's<br>Corrosion</h1>
    <div class="sub">Aurelion's Sunken Observatory — the last light above a storm-locked valley</div>
    <div class="lore">You are the Last Warden. The great gears have stopped, the telescopes lie shattered, and the black-iron Corrosion creeps through the bronze halls. Before the archive falls into the storm, you must cross six ruined wings, wake the ancient altars, and reignite the Core Beacon.</div>
    <div class="orn"></div>
    <div class="btnrow">
      <button class="bigbtn" id="btnNew">Begin the Vigil</button>
      <button class="bigbtn ghost hidden" id="btnContinue">Continue</button>
    </div>
    <div id="controlsCard">
      <b>Move</b><span>W A S D / Arrows</span>
      <b>Strike</b><span>J / Z / Space</span>
      <b>Shard Bolt</b><span>K / X</span>
      <b>Interact</b><span>E</span>
      <b>Satchel</b><span>I / Tab</span>
      <b>Use Stem / Seal</b><span>1 / 2</span>
      <b>Pause</b><span>P / Esc</span>
    </div>
    <div class="ver">A single-file vigil · v1.0</div>
  </div>

  <div id="pause" class="screen hidden">
    <h1>The Vigil Pauses</h1>
    <div class="sub">The Corrosion waits. It is patient.</div>
    <div class="btnrow">
      <button class="bigbtn" id="btnResume">Resume</button>
      <button class="bigbtn ghost" id="btnRestart">Abandon Run</button>
    </div>
  </div>

  <div id="goScreen" class="screen hidden">
    <h1>The Warden Falls</h1>
    <div class="sub">Darkness takes you — but the Sanctuary Stones remember where you last stood.</div>
    <div class="statline">Gold carried: <span id="goGold">0</span></div>
    <div class="btnrow"><button class="bigbtn" id="btnRespawn">Rise at the Last Stone</button></div>
  </div>

  <div id="winScreen" class="screen hidden">
    <h1>The Beacon Burns Again</h1>
    <div class="sub">Light spills from the Core Chamber and races across the storm-locked valley. The gears stir. The archive breathes. The Warden's vigil is ended — and a new one begins.</div>
    <div class="statline">Vigil kept in <span id="winTime">0:00</span></div>
    <div class="statline">Sigils gathered: <span id="winGold">0</span> · Constructs unmade: <span id="winKills">0</span></div>
    <div class="btnrow"><button class="bigbtn" id="btnAgain">Begin a New Vigil</button></div>
  </div>
</div>

<script>
'use strict';
/* ============================== helpers ============================== */
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 choice=a=>a[Math.floor(Math.random()*a.length)];
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 fmtTime=s=>{s=Math.floor(s);return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');};

/* ============================== audio ============================== */
const Sfx={
  ctx:null,master:null,muted:false,noiseBuf:null,drone:null,
  ensure(){
    if(!this.ctx){
      const AC=window.AudioContext||window.webkitAudioContext;
      if(!AC)return;
      this.ctx=new AC();
      this.master=this.ctx.createGain();
      this.master.gain.value=0.45;
      this.master.connect(this.ctx.destination);
      const len=this.ctx.sampleRate;
      this.noiseBuf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
      const d=this.noiseBuf.getChannelData(0);
      for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
    }
    if(this.ctx.state==='suspended')this.ctx.resume();
  },
  t(f,d,type,g,slide,delay){
    if(!this.ctx||this.muted)return;
    type=type||'sine'; g=(g==null)?0.2:g; delay=delay||0;
    const t0=this.ctx.currentTime+delay;
    const o=this.ctx.createOscillator(),gn=this.ctx.createGain();
    o.type=type; o.frequency.setValueAtTime(f,t0);
    if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,slide),t0+d);
    gn.gain.setValueAtTime(g,t0);
    gn.gain.exponentialRampToValueAtTime(0.001,t0+d);
    o.connect(gn); gn.connect(this.master);
    o.start(t0); o.stop(t0+d+0.03);
  },
  n(d,fq,g,delay,ftype){
    if(!this.ctx||this.muted)return;
    delay=delay||0; ftype=ftype||'lowpass';
    const t0=this.ctx.currentTime+delay;
    const s=this.ctx.createBufferSource(); s.buffer=this.noiseBuf; s.loop=true;
    const fl=this.ctx.createBiquadFilter(); fl.type=ftype; fl.frequency.value=fq; fl.Q.value=1;
    const gn=this.ctx.createGain();
    gn.gain.setValueAtTime(g,t0);
    gn.gain.exponentialRampToValueAtTime(0.001,t0+d);
    s.connect(fl); fl.connect(gn); gn.connect(this.master);
    s.start(t0); s.stop(t0+d+0.03);
  },
  step(zi){
    const v=[[340,.05,.05],[950,.045,.055],[520,.05,.05],[430,.06,.045],[620,.05,.05],[390,.06,.055]][zi%6];
    this.n(v[1],v[0],v[2],0,'lowpass');
    if(zi===3)this.t(210,0.09,'sine',0.03,150);
    if(zi===1)this.n(0.03,2600,0.035,0,'bandpass');
  },
  whoosh(w){
    if(w===2){this.n(0.24,900,0.11,0,'bandpass'); this.t(68,0.32,'sine',0.13,48,0.06);}
    else this.n(w===1?0.16:0.12, w===1?1500:1150, 0.09,0,'bandpass');
  },
  hit(w,crit){
    if(crit)this.t(1250,0.12,'triangle',0.13,650);
    if(w===0){this.t(95,0.12,'sine',0.22,60); this.n(0.06,800,0.13,0,'lowpass');}
    else if(w===1){this.t(85,0.14,'sine',0.25,55); this.n(0.12,3200,0.08,0,'highpass');}
    else{this.t(58,0.38,'sine',0.32,40); this.t(520,0.2,'square',0.05,470); this.n(0.16,2100,0.1,0,'bandpass');}
  },
  twang(){this.t(880,0.12,'triangle',0.16,300); this.n(0.05,3600,0.05,0,'highpass');},
  boltHit(){this.t(1500,0.16,'sine',0.11,880);},
  hurt(){this.t(70,0.16,'sine',0.3,48); this.t(1240,0.2,'sawtooth',0.05,1150); this.t(1290,0.2,'sawtooth',0.05,1180);},
  coin(){this.t(1900,0.06,'square',0.06); this.t(2450,0.09,'square',0.05,0,0.05);},
  item(){this.t(520,0.1,'sine',0.12); this.t(660,0.1,'sine',0.12,0,0.08); this.t(880,0.18,'sine',0.12,0,0.16);},
  rare(){this.t(392,0.4,'sine',0.12); this.t(494,0.4,'sine',0.1,0,0.02); this.t(587,0.5,'sine',0.12,0,0.05); this.t(784,0.55,'sine',0.08,0,0.12);},
  heartUp(){for(let i=0;i<6;i++)this.t(400+i*130,0.26,'sine',0.09,0,i*0.06); this.n(0.5,5200,0.04,0,'highpass');},
  altar(){this.t(98,0.8,'sine',0.18); this.t(147,0.8,'sine',0.12,0,0.05); this.t(196,1,'sine',0.1,0,0.1); for(let i=0;i<5;i++)this.t(600+i*150,0.2,'sine',0.06,0,0.2+i*0.07);},
  door(){this.t(55,0.4,'sine',0.26,44); this.n(0.3,300,0.16,0.1,'lowpass');},
  plate(){this.t(120,0.12,'square',0.13,88); this.n(0.08,650,0.1,0,'lowpass');},
  sw(){this.t(1400,0.26,'sine',0.13,2200); this.t(1750,0.2,'sine',0.08,2400,0.06);},
  buy(){this.coin(); this.t(784,0.15,'sine',0.1,0,0.1); this.t(1046,0.22,'sine',0.1,0,0.18);},
  death(){this.t(220,0.8,'sawtooth',0.15,55); this.n(0.6,400,0.16,0.1,'lowpass');},
  victory(){[523,659,784,1046,1318].forEach((f,i)=>this.t(f,0.5,'sine',0.12,0,i*0.12));},
  freeze(){this.t(2000,0.3,'sine',0.09,600); this.n(0.2,6200,0.05,0,'highpass');},
  droneOn(){
    if(!this.ctx||this.drone)return;
    const o1=this.ctx.createOscillator(),o2=this.ctx.createOscillator(),g=this.ctx.createGain();
    o1.type='sine'; o2.type='sine'; o1.frequency.value=55; o2.frequency.value=82.5;
    g.gain.value=0; o1.connect(g); o2.connect(g); g.connect(this.master);
    o1.start(); o2.start();
    g.gain.linearRampToValueAtTime(0.07,this.ctx.currentTime+1.2);
    this.drone={o1,o2,g};
  },
  droneOff(){
    if(!this.drone)return;
    const d=this.drone;
    d.g.gain.linearRampToValueAtTime(0.0001,this.ctx.currentTime+0.5);
    setTimeout(()=>{try{d.o1.stop();d.o2.stop();}catch(e){}},700);
    this.drone=null;
  }
};

/* ============================== input ============================== */
const keys={};
let pressEdge={};
window.addEventListener('keydown',e=>{
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab'].includes(e.code))e.preventDefault();
  if(!keys[e.code])pressEdge[e.code]=true;
  keys[e.code]=true;
  Sfx.ensure();
});
window.addEventListener('keyup',e=>{keys[e.code]=false;});
window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
const down=(...codes)=>codes.some(c=>keys[c]);
const pressed=(...codes)=>codes.some(c=>pressEdge[c]);

/* ============================== data ============================== */
const TS=32, MW=40, MH=26;
const WEAPONS=[
  {name:'Ember Blade',   dmg:2, arc:1.9, range:46, cd:0.45, type:'phys'},
  {name:'Ignited Edge',  dmg:3, arc:2.1, range:48, cd:0.30, type:'fire'},
  {name:'Solar Falchion',dmg:5, arc:2.6, range:58, cd:0.55, windup:0.12, type:'solar'}
];
const ENEMY_TYPES={
  chaser:  {hp:4,  r:12, spd:95, dmg:1, coin:[5,10],  name:'Rusted Husk'},
  shooter: {hp:3,  r:12, spd:70, dmg:1, coin:[8,12],  name:'Broken Orrery'},
  brute:   {hp:10, r:16, spd:55, dmg:2, coin:[12,15], name:'Corroded Sentinel'},
  guardian:{hp:30, r:20, spd:72, dmg:2, coin:[25,35], name:'Gallery Guardian'},
  boss:    {hp:60, r:26, spd:82, dmg:2, coin:[100,120],name:'Corrupted Warden'}
};
const PICKUP_INFO={
  H:{name:'Heart Container',desc:'A vessel of the Warden\'s vitality. +2 max HP.'},
  V:{name:'Vital Stem',desc:'Restores 2 full containers (4 HP).'},
  W:{name:"Warden's Band",desc:'A glowing silver ring. Slightly swifter strikes.'},
  Q:{name:'Aether Shard',desc:'A crystalline launcher. Fire bolts with K. 10 charges.'},
  L:{name:'Phantom Lantern',desc:'Reveals hidden cracks and paths in the dark. Blue glow.'},
  G:{name:'Shadow Cloak',desc:'Dark cloth trim. Neutralizes environmental trap damage.'},
  Z:{name:'Binding Seal',desc:'Freezes the nearest construct for ~3 seconds.'}
};

/* ============================== canvas & state ============================== */
const cv=document.getElementById('game');
const cx=cv.getContext('2d');
const VW=1280,VH=720;
const cam={x:0,y:0};
const show=id=>document.getElementById(id).classList.remove('hidden');
const hide=id=>document.getElementById(id).classList.add('hidden');

const G={
  state:'title',zone:0,map:null,player:null,
  enemies:[],bolts:[],parts:[],dnums:[],pickups:[],
  doors:[],plates:[],switches:[],breaks:[],beacon:null,stonePos:[],
  flags:{},collected:{},
  shop:{stems:3,seals:2,heart:true},
  time:0,kills:0,flash:0,shimmer:0,
  fade:1,fadeDir:-1,fadeCb:null,
  lastStone:0,lastA:0,lastB:0
};

const PALETTES=[
  {floor:'#3a332c',floor2:'#342e28',wall:'#5a4c3c',wallHi:'#6b5a46'},
  {floor:'#3a362c',floor2:'#343028',wall:'#5f4c30',wallHi:'#715c3c'},
  {floor:'#362f2a',floor2:'#302a24',wall:'#574534',wallHi:'#675340'},
  {floor:'#2c333d',floor2:'#272e38',wall:'#43596d',wallHi:'#526a80'},
  {floor:'#302b36',floor2:'#2a2530',wall:'#4c3e54',wallHi:'#5a4a63'},
  {floor:'#382b28',floor2:'#322523',wall:'#5f3c34',wallHi:'#71483e'}
];

const ZONES=[
 {name:'The Ashen Threshold',sub:'Prologue — where the vigil begins',dark:false,
  spawn:[640,700],
  walls:[[4,5,3,1],[10,5,3,1],[16,5,2,1],[22,5,3,1],[29,5,3,1],[35,5,2,1],
         [4,10,3,1],[11,10,3,1],[20,10,3,1],[28,10,3,1],[35,10,2,1],
         [7,15,3,1],[15,15,3,1],[24,15,3,1],[32,15,3,1],
         [10,19,2,1],[28,19,2,1]],
  stone:[[19,21]],exit:[[19,1]],
  enemies:[['chaser',440,260],['chaser',840,270],['chaser',640,430],['chaser',380,560],['shooter',900,480]],
  pickups:[{t:'coin',x:300,y:500,a:8},{t:'coin',x:980,y:300,a:8}]
 },
 {name:'The Rusted Archives',sub:'Bronze shelves, drifting dust, a merchant\'s whisper',dark:false,
  spawn:[640,700],
  walls:[[3,4,7,1],[13,4,6,1],[23,4,6,1],[33,4,4,1],
         [3,9,5,1],[11,9,8,1],[23,9,5,1],[32,9,5,1],
         [3,14,8,1],[15,14,4,1],[24,14,6,1],[34,14,3,1],
         [6,19,4,1],[14,19,5,1],[23,19,5,1],[32,19,4,1]],
  grate:[[4,21,12,2],[24,21,12,2]],
  shop:[[19,22]],stone:[[19,12]],exit:[[19,1]],
  breaks:[{x:2,y:6,c:{t:'W',id:'z2band'}},{x:37,y:6,c:{t:'V',id:'z2stem'}},{x:37,y:11,c:{t:'coin',a:25,id:'z2c1'}}],
  enemies:[['chaser',300,300],['chaser',980,300],['chaser',420,520],['chaser',860,520],['shooter',320,380],['shooter',960,380],['chaser',640,600]],
  pickups:[{t:'coin',x:200,y:640,a:6},{t:'coin',x:1080,y:640,a:6}]
 },
 {name:'The Collapsed Gallery',sub:'Broken frames, a sleeping altar, a guardian in the dark',dark:false,
  spawn:[640,700],
  walls:[[3,4,4,1],[8,4,29,1],
         [6,5,1,4],[8,5,1,4],
         [10,10,5,1],[20,10,5,1],
         [4,12,2,1],[9,12,14,1],[26,12,6,1],[34,12,3,1],
         [6,13,1,3],[8,13,1,3],
         [10,16,5,1],[25,16,5,1]],
  trap:[[14,13,2,1]],
  plate:[[7,8]],door:[[7,4]],exit:[[19,1]],altar:[[24,2]],
  breaks:[{x:35,y:8,c:{t:'H',id:'z3heart'}}],
  enemies:[['chaser',300,260],['chaser',900,260],['brute',640,432],['shooter',300,500],['shooter',980,500],['guardian',928,576]],
  pickups:[{t:'coin',x:250,y:620,a:10}]
 },
 {name:'The Sunken Vault',sub:'Mist rises from black water. Something glimmers behind a cracked wall',dark:true,
  spawn:[640,700],
  walls:[[4,5,6,1],[14,5,6,1],[24,5,6,1],[34,5,3,1],
         [4,11,8,1],[16,11,8,1],[28,11,8,1],
         [8,17,6,1],[20,17,6,1],[32,17,4,1]],
  mist:[[2,3,36,21]],
  trap:[[10,8,2,1],[24,14,2,1]],
  switch:[{x:196,y:224,id:'z4s'}],
  exit:[[19,1]],
  breaks:[{x:34,y:4,c:{t:'Q',id:'z4shard'}}],
  enemies:[['chaser',320,300],['chaser',960,300],['shooter',420,460],['shooter',860,460],['brute',608,560],['chaser',260,600],['chaser',1020,600]],
  pickups:[{t:'coin',x:200,y:560,a:8},{t:'coin',x:1080,y:560,a:8}]
 },
 {name:'The Twin Spire',sub:'The final ascent. The stones remember the Warden',dark:true,
  spawn:[640,700],
  walls:[[3,5,8,1],[14,5,8,1],[26,5,8,1],
         [3,9,16,1],[20,9,17,1],
         [4,14,6,1],[14,14,6,1],[24,14,6,1],[34,14,3,1],
         [8,19,5,1],[20,19,5,1],[30,19,4,1]],
  trap:[[10,7,2,1],[28,13,2,1],[14,21,3,1]],
  stone:[[19,22]],altar:[[19,3]],switch:[{x:160,y:192,id:'z5s'}],
  plate:[[13,13]],plate2:[[25,13]],door:[[19,9]],exit:[[35,1]],
  breaks:[{x:36,y:7,c:{t:'G',id:'z5cloak'}},{x:3,y:13,c:{t:'Z',id:'z5seal'}},{x:36,y:17,c:{t:'coin',a:80,id:'z5gold'}}],
  enemies:[['chaser',300,240],['chaser',980,240],['brute',420,400],['brute',860,400],['shooter',300,520],['shooter',980,520],['chaser',540,560],['chaser',740,560]],
  pickups:[{t:'coin',x:240,y:640,a:10},{t:'coin',x:1040,y:640,a:10}]
 },
 {name:'The Core Chamber',sub:'The beacon sleeps. Wake it — or be unmade',dark:true,
  spawn:[640,700],
  walls:[[6,6,8,1],[26,6,8,1],
         [3,10,16,1],[20,10,17,1],
         [6,14,4,1],[30,14,4,1]],
  switch:[{x:260,y:256,id:'z6a'},{x:1020,y:256,id:'z6b'}],
  door:[[19,10]],beacon:[640,160],
  enemies:[['boss',640,520]],
  pickups:[]
 }
];

/* ============================== map building ============================== */
function buildMap(def){
  const m=Array.from({length:MH},()=>new Array(MW).fill(0));
  const set=(x,y,v)=>{if(x>=0&&y>=0&&x<MW&&y<MH)m[y][x]=v;};
  for(let x=0;x<MW;x++){m[0][x]=1;m[MH-1][x]=1;}
  for(let y=0;y<MH;y++){m[y][0]=1;m[y][MW-1]=1;}
  def.walls.forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,1);});
  (def.grate||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,10);});
  (def.mist||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)if(m[yy]&&m[yy][xx]===0)set(xx,yy,9);});
  (def.trap||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,11);});
  (def.stone||[]).forEach(([x,y])=>set(x,y,6));
  (def.exit||[]).forEach(([x,y])=>set(x,y,5));
  (def.shop||[]).forEach(([x,y])=>set(x,y,7));
  (def.altar||[]).forEach(([x,y])=>set(x,y,8));
  if(def.plate)set(def.plate[0],def.plate[1],3);
  if(def.plate2)set(def.plate2[0],def.plate2[1],3);
  if(def.door)set(def.door[0],def.door[1],4);
  return m;
}

function loadZone(i){
  const def=ZONES[i];
  G.zone=i;
  G.map=buildMap(def);
  G.breaks=(def.breaks||[]).map(b=>({...b,hp:8,maxHp:8,broken:false}));
  G.breaks.forEach(b=>{G.map[b.y][b.x]=2;});
  G.breaks=G.breaks.filter(b=>{
    if(G.collected['b'+b.x+'_'+b.y]){G.map[b.y][b.x]=0;return false;}
    return true;
  });
  G.doors=def.door?[{x:def.door[0],y:def.door[1],id:'z'+(i+1)+'d',open:false}]:[];
  G.plates=[];
  if(def.plate)G.plates.push({x:def.plate[0],y:def.plate[1],id:'z'+(i+1)+'p',pressed:false});
  if(def.plate2)G.plates.push({x:def.plate2[0],y:def.plate2[1],id:'z5p2',pressed:false});
  G.switches=(def.switch||[]).map(s=>({...s,active:false}));
  G.beacon=def.beacon?{x:def.beacon[0],y:def.beacon[1],active:!!G.flags.bossDown}:null;
  G.stonePos=[];
  for(let y=0;y<MH;y++)for(let x=0;x<MW;x++)if(G.map[y][x]===6)G.stonePos.push({x:x*TS+16,y:y*TS+16});
  G.enemies=def.enemies.map(e=>spawnEnemy(e[0],e[1],e[2]));
  G.bolts=[];G.parts=[];G.dnums=[];
  G.pickups=def.pickups.map((p,j)=>({...p,id:'s'+i+'_'+j}));
  G.pickups=G.pickups.filter(p=>!G.collected[p.id]);
  if(G.player){G.player.x=def.spawn[0];G.player.y=def.spawn[1];}
  showBanner();
}

function startNew(){
  G.flags={};G.collected={};G.shop={stems:3,seals:2,heart:true};
  G.time=0;G.kills=0;G.lastStone=0;G.lastA=0;G.lastB=0;
  G.player=makePlayer(640,700);
  loadZone(0);
  updateHUD();
}

/* ============================== save / load ============================== */
function saveGame(){
  const p=G.player;
  const data={v:1,zone:G.lastStone,px:p.x,py:p.y,hp:p.hp,maxHp:p.maxHp,weapon:p.weapon,ammo:p.ammo,gold:p.gold,
    items:p.items,own:p.own,equipped:p.equipped,flags:G.flags,collected:G.collected,shop:G.shop,time:G.time,kills:G.kills};
  try{localStorage.setItem('warden_save',JSON.stringify(data));}catch(e){}
}
function hasSave(){try{return !!localStorage.getItem('warden_save');}catch(e){return false;}}
function loadSave(){
  try{
    const d=JSON.parse(localStorage.getItem('warden_save'));
    if(!d||d.v!==1)return false;
    startNew();
    const p=G.player;
    p.hp=d.hp;p.maxHp=d.maxHp;p.weapon=d.weapon;p.ammo=d.ammo;p.gold=d.gold;
    p.items=d.items;p.own=d.own;p.equipped=d.equipped;
    p.shardReady=!!d.own.Q;
    G.flags=d.flags;G.collected=d.collected;G.shop=d.shop;G.time=d.time||0;G.kills=d.kills||0;
    loadZone(d.zone);
    p.x=d.px;p.y=d.py;
    updateHUD();
    return true;
  }catch(e){return false;}
}

/* ============================== toasts & banner ============================== */
function toast(msg,cls){
  const el=document.createElement('div');
  el.className='toast '+(cls||'');
  el.textContent=msg;
  document.getElementById('toasts').appendChild(el);
  setTimeout(()=>el.remove(),3300);
}
function showBanner(){
  const z=ZONES[G.zone],b=document.getElementById('banner');
  b.querySelector('.znum').textContent='Zone '+(G.zone+1)+' of 6';
  b.querySelector('.zname').textContent=z.name;
  b.querySelector('.zsub').textContent=z.sub;
  b.classList.add('show');
  clearTimeout(showBanner.t);
  showBanner.t=setTimeout(()=>b.classList.remove('show'),3200);
}
function fadeTo(cb){G.fade=1;G.fadeDir=-1;G.fadeCb=cb;}

/* ============================== player ============================== */
function makePlayer(x,y){
  return {x,y,r:11,hp:6,maxHp:6,weapon:0,ammo:0,gold:0,items:{V:0,Z:0},
    own:{},equipped:{W:false,L:false,G:false},
    face:1,aim:0,atkCd:0,atkT:0,windup:0,swingA:0,
    inv:0,hurtT:0,stepT:0,anim:0,shardReady:false,
    emberT:0,sealCd:0};
}
function solidAt(px,py){
  const tx=Math.floor(px/TS),ty=Math.floor(py/TS);
  if(tx<0||ty<0||tx>=MW||ty>=MH)return true;
  const t=G.map[ty][tx];
  if(t===1||t===10||t===2)return true;
  if(t===4){const d=G.doors.find(d=>d.x===tx&&d.y===ty);return d&&!d.open;}
  return false;
}
function moveEntity(e,dx,dy){
  const tryAxis=(ox,oy)=>{
    const nx=e.x+ox,ny=e.y+oy;
    const r=e.r;
    if(!solidAt(nx-r,ny-r)&&!solidAt(nx+r,ny-r)&&!solidAt(nx-r,ny+r)&&!solidAt(nx+r,ny+r)){
      e.x=nx;e.y=ny;return true;
    }
    return false;
  };
  if(dx)tryAxis(dx,0);
  if(dy)tryAxis(0,dy);
}
function updatePlayer(dt){
  const p=G.player,W=WEAPONS[p.weapon];
  p.atkCd-=dt;p.inv-=dt;p.hurtT-=dt;p.sealCd-=dt;
  let mx=(down('KeyD','ArrowRight')?1:0)-(down('KeyA','ArrowLeft')?1:0);
  let my=(down('KeyS','ArrowDown')?1:0)-(down('KeyW','ArrowUp')?1:0);
  const mv=205;
  if(mx&&my){mx*=0.7071;my*=0.7071;}
  if(mx||my){
    moveEntity(p,mx*mv*dt,my*mv*dt);
    if(mx)p.face=mx>0?1:-1;
    p.aim=Math.atan2(my,mx);
    p.anim+=dt*9;
    p.stepT-=dt;
    if(p.stepT<=0){p.stepT=0.33;Sfx.step(G.zone);}
  }else p.anim*=0.9;
  /* ember trail */
  if((mx||my)&&p.weapon>=1){
    p.emberT-=dt;
    if(p.emberT<=0){
      p.emberT=0.09;
      addPart(p.x-p.face*10+rand(-3,3),p.y+8,rand(-8,8),rand(4,14),rand(0.3,0.55),p.weapon===2?'#ffd97a':'#ff8c3a',p.weapon===2?3:2,'glow');
    }
  }
  /* attack */
  if(down('KeyJ','KeyZ','Space')&&p.atkCd<=0){
    if(W.windup&&p.windup<=0){p.windup=W.windup;p.atkCd=W.cd+W.windup;Sfx.whoosh(p.weapon+1);}
    else if(!W.windup){doSwing();}
  }
  if(p.windup>0){
    p.windup-=dt;
    if(p.windup<=0)doSwing();
  }
  if(p.atkT>0)p.atkT-=dt;
  /* ranged */
  if(pressed('KeyK','KeyX')&&p.shardReady){
    if(p.ammo>0){
      p.ammo--;
      const a=p.aim;
      G.bolts.push({x:p.x+Math.cos(a)*14,y:p.y+Math.sin(a)*14,vx:Math.cos(a)*460,vy:Math.sin(a)*460,life:1.6});
      Sfx.twang();
      for(let i=0;i<5;i++)addPart(p.x+Math.cos(a)*14,p.y+Math.sin(a)*14,Math.cos(a)*rand(30,90)+rand(-40,40),Math.sin(a)*rand(30,90)+rand(-40,40),0.3,'#9fe0ff',2,'glow');
      updateHUD();
    }else toast('The Aether Shard is spent — buy charges at the merchant','blue');
  }
  /* consumables */
  if(pressed('Digit1'))useItem('V');
  if(pressed('Digit2'))useItem('Z');
  /* world interactions */
  interactWorld();
}
function doSwing(){
  const p=G.player,W=WEAPONS[p.weapon];
  p.atkT=0.22;
  p.swingA=p.aim;
  let hitAny=false;
  G.enemies.forEach(e=>{
    if(e.dead)return;
    const d=dist(p.x,p.y,e.x,e.y);
    if(d<W.range+e.r){
      const a=Math.atan2(e.y-p.y,e.x-p.x);
      if(Math.abs(angDiff(p.aim,a))<W.arc/2+0.35){
        hitEnemy(e,W.dmg,W.type);
        hitAny=true;
      }
    }
  });
  /* breakable walls in arc */
  G.breaks.forEach(b=>{
    if(b.broken)return;
    const bx=b.x*TS+16,by=b.y*TS+16;
    const d=dist(p.x,p.y,bx,by);
    if(d<W.range+20){
      const a=Math.atan2(by-p.y,bx-p.x);
      if(Math.abs(angDiff(p.aim,a))<W.arc/2+0.6){
        b.hp-=p.weapon===2?3:1;
        addPart(bx,by,rand(-60,60),rand(-60,60),0.4,'#5a4a3a',3,'spark');
        Sfx.n(0.08,700,0.12,0,'lowpass');
        if(b.hp<=0)breakWall(b);
      }
    }
  });
  if(hitAny&&p.weapon>=1){
    for(let i=0;i<6;i++)addPart(p.x+Math.cos(p.aim)*30,p.y+Math.sin(p.aim)*30,Math.cos(p.aim)*rand(60,160)+rand(-50,50),Math.sin(p.aim)*rand(60,160)+rand(-50,50),0.4,p.weapon===2?'#ffd97a':'#ff8c3a',2,'glow');
  }
}
function breakWall(b){
  b.broken=true;
  G.map[b.y][b.x]=0;
  G.collected['b'+b.x+'_'+b.y]=1;
  Sfx.n(0.3,500,0.2,0,'lowpass');
  for(let i=0;i<14;i++)addPart(b.x*TS+16,b.y*TS+16,rand(-120,120),rand(-120,120),rand(0.4,0.8),choice(['#5a4a3a','#3d332a','#6b5a44']),3,'spark');
  if(b.c){
    const c={...b.c,x:b.x*TS+16,y:b.y*TS+16};
    if(c.t==='coin'){addCoin(c.x,c.y,c.a);}
    else G.pickups.push(c);
  }
  saveGame();
}
function hitEnemy(e,dmg,type){
  e.hp-=dmg;
  e.flash=0.15;
  e.burn=type==='fire'?0.6:0;
  e.vx=Math.cos(Math.atan2(e.y-G.player.y,e.x-G.player.x))*160;
  e.vy=Math.sin(Math.atan2(e.y-G.player.y,e.x-G.player.x))*160;
  Sfx.hit(type==='phys'?0:(type==='fire'?1:2),false);
  if(type==='fire'){
    for(let i=0;i<5;i++)addPart(e.x,e.y,rand(-80,80),rand(-80,80),0.5,'#ff8c3a',2,'glow');
  }else if(type==='solar'){
    for(let i=0;i<8;i++)addPart(e.x,e.y,rand(-140,140),rand(-140,140),0.5,'#ffd97a',2.5,'glow');
    G.shimmer=Math.max(G.shimmer,0.25);
  }else{
    for(let i=0;i<4;i++)addPart(e.x,e.y,rand(-100,100),rand(-100,100),0.35,'#ffb35c',2,'spark');
  }
  G.dnums.push({x:e.x,y:e.y-e.r-6,txt:String(dmg),t:0.7,col:type==='fire'?'#ff9c50':(type==='solar'?'#ffd97a':'#fff')});
  if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
  e.dead=true;
  G.kills++;
  const T=ENEMY_TYPES[e.type];
  Sfx.n(0.25,400,0.18,0,'lowpass');
  for(let i=0;i<12;i++)addPart(e.x,e.y,rand(-160,160),rand(-160,160),rand(0.4,0.7),choice(['#4a3b26','#5a4830','#2a2018']),3,'spark');
  const c=randi(T.coin[0],T.coin[1]);
  addCoin(e.x,e.y,c);
  if(e.type==='guardian'){
    if(!G.own.G){G.pickups.push({t:'G',id:'z5cloak',x:e.x,y:e.y});toast('The Guardian drops the Shadow Cloak','gold');}
  }
  if(e.type==='boss'){
    G.flags.bossDown=true;
    toast('The Corrupted Warden is unmade — the beacon stirs','gold');
    G.shimmer=2;
  }
  if(e.type==='shooter'&&Math.random()<0.5)G.pickups.push({t:'V',id:'drop'+Math.random(),x:e.x,y:e.y});
}
function damagePlayer(dmg,srcX,srcY){
  const p=G.player;
  if(p.inv>0||G.state!=='play')return;
  if(p.own.G&&srcX===undefined){/* trap hit handled elsewhere */}
  p.hp-=dmg;
  p.inv=1.0;p.hurtT=0.4;
  G.flash=0.5;
  Sfx.hurt();
  for(let i=0;i<10;i++)addPart(p.x,p.y,rand(-140,140),rand(-140,140),0.5,'#d64545',3,'glow');
  if(srcX!==undefined){
    const a=Math.atan2(p.y-srcY,p.x-srcX);
    moveEntity(p,Math.cos(a)*18,Math.sin(a)*18);
  }
  updateHUD();
  if(p.hp<=0)die();
}
function die(){
  G.state='dead';
  Sfx.death();
  document.getElementById('goGold').textContent=G.player.gold;
  show('goScreen');
}
function useItem(t){
  const p=G.player;
  if(p.items[t]<=0){toast(t==='V'?'No Vital Stems in your satchel':'No Binding Seals in your satchel','red');return;}
  if(t==='V'){
    if(p.hp>=p.maxHp){toast('Your vitality is already full','red');return;}
    p.items.V--;
    p.hp=Math.min(p.maxHp,p.hp+4);
    Sfx.item();
    for(let i=0;i<14;i++)addPart(p.x,p.y,rand(-60,60),rand(-120,-20),rand(0.5,0.9),'#7fe08a',2.5,'glow');
    toast('Vital Stem consumed — +4 HP');
  }else{
    if(p.sealCd>0)return;
    let best=null,bd=1e9;
    G.enemies.forEach(e=>{if(!e.dead){const d=dist(p.x,p.y,e.x,e.y);if(d<bd&&d<420){bd=d;best=e;}}});
    if(!best){toast('No construct in range to bind','red');return;}
    p.items.Z--;p.sealCd=0.5;
    best.freeze=3;
    Sfx.freeze();
    for(let i=0;i<12;i++)addPart(best.x,best.y,rand(-80,80),rand(-80,80),0.6,'#9fd8ff',2.5,'glow');
    toast('A construct is bound in frost','blue');
  }
  updateHUD();
}

/* ============================== enemies ============================== */
function spawnEnemy(type,x,y){
  const T=ENEMY_TYPES[type];
  return {type,x,y,r:T.r,hp:T.hp,maxHp:T.hp,spd:T.spd,dmg:T.dmg,
    vx:0,vy:0,flash:0,burn:0,freeze:0,shootT:rand(1,2.5),
    atkCd:0,dead:false,anim:rand(0,9),
    boss:type==='boss'?{phase:0,t:0,aim:0}:null};
}
function losClear(x1,y1,x2,y2){
  const d=dist(x1,y1,x2,y2),steps=Math.ceil(d/24);
  for(let i=1;i<steps;i++){
    const t=i/steps;
    if(solidAt(lerp(x1,x2,t),lerp(y1,y2,t)))return false;
  }
  return true;
}
function updateEnemies(dt){
  const p=G.player;
  G.enemies.forEach(e=>{
    if(e.dead)return;
    e.flash-=dt;e.atkCd-=dt;e.anim+=dt*6;
    if(e.burn>0)e.burn-=dt;
    if(e.freeze>0){e.freeze-=dt;return;}
    const d=dist(p.x,p.y,e.x,e.y);
    const a=Math.atan2(p.y-e.y,p.x-e.x);
    let spd=e.spd;
    if(e.burn>0)spd*=0.6;
    if(e.type==='chaser'||e.type==='brute'||e.type==='guardian'){
      if(d<560){
        e.vx=Math.cos(a)*spd;e.vy=Math.sin(a)*spd;
        moveEntity(e,e.vx*dt,e.vy*dt);
      }
      if(d<e.r+p.r+6&&e.atkCd<=0){
        e.atkCd=0.9;
        damagePlayer(e.dmg,e.x,e.y);
      }
    }else if(e.type==='shooter'){
      if(d>260){e.vx=Math.cos(a)*spd;e.vy=Math.sin(a)*spd;moveEntity(e,e.vx*dt,e.vy*dt);}
      else if(d<200){e.vx=-Math.cos(a)*spd*0.7;e.vy=-Math.sin(a)*spd*0.7;moveEntity(e,e.vx*dt,e.vy*dt);}
      else{e.vx*=0.8;e.vy*=0.8;}
      e.shootT-=dt;
      if(e.shootT<=0&&d<420&&losClear(e.x,e.y,p.x,p.y)){
        e.shootT=rand(1.8,2.6);
        G.bolts.push({x:e.x,y:e.y,vx:Math.cos(a)*280,vy:Math.sin(a)*280,life:2,hostile:true});
        Sfx.t(900,0.1,'triangle',0.06,400);
      }
    }else if(e.type==='boss'){
      updateBoss(e,dt,a,d);
    }
  });
}
function updateBoss(e,dt,a,d){
  const p=G.player;
  e.boss.t+=dt;
  const t=e.boss.t;
  /* slow orbit + charge pattern */
  const phase=Math.floor(t/6)%3;
  if(phase===0){
    e.vx=Math.cos(a)*e.spd;e.vy=Math.sin(a)*e.spd;
  }else if(phase===1){
    const oa=a+Math.PI/2;
    e.vx=Math.cos(oa)*e.spd*1.1;e.vy=Math.sin(oa)*e.spd*1.1;
  }else{
    e.vx=Math.cos(a)*e.spd*1.6;e.vy=Math.sin(a)*e.spd*1.6;
    if(t%6<0.4)addPart(e.x+rand(-10,10),e.y+rand(-10,10),rand(-30,30),rand(-30,30),0.3,'#d64545',3,'glow');
  }
  moveEntity(e,e.vx*dt,e.vy*dt);
  if(d<e.r+p.r+6&&e.atkCd<=0){e.atkCd=0.9;damagePlayer(e.dmg,e.x,e.y);}
  /* radial burst every 8s */
  if(Math.floor(t/8)!==Math.floor((t-dt)/8)){
    Sfx.t(200,0.3,'sawtooth',0.12,80);
    for(let i=0;i<10;i++){
      const ba=i/10*TAU;
      G.bolts.push({x:e.x,y:e.y,vx:Math.cos(ba)*240,vy:Math.sin(ba)*240,life:2.2,hostile:true});
    }
    for(let i=0;i<16;i++)addPart(e.x,e.y,Math.cos(i/16*TAU)*rand(100,200),Math.sin(i/16*TAU)*rand(100,200),0.5,'#d64545',3,'glow');
  }
  /* summon chasers at 60% and 30% */
  if(e.hp<e.maxHp*0.6&&!e.boss.s1){e.boss.s1=true;summonAdds(e);}
  if(e.hp<e.maxHp*0.3&&!e.boss.s2){e.boss.s2=true;summonAdds(e);}
}
function summonAdds(e){
  for(let i=0;i<3;i++){
    const a=rand(0,TAU);
    const c=spawnEnemy('chaser',clamp(e.x+Math.cos(a)*60,60,1220),clamp(e.y+Math.sin(a)*60,60,700));
    G.enemies.push(c);
    addPart(c.x,c.y,0,-40,0.6,'#d64545',3,'glow');
  }
  toast('The Corrupted Warden summons constructs!','red');
}

/* ============================== bolts, particles, pickups ============================== */
function updateBolts(dt){
  G.bolts=G.bolts.filter(b=>{
    b.life-=dt;
    b.x+=b.vx*dt;b.y+=b.vy*dt;
    if(b.life<=0)return false;
    if(solidAt(b.x,b.y)){
      Sfx.boltHit();
      for(let i=0;i<5;i++)addPart(b.x,b.y,rand(-80,80),rand(-80,80),0.3,b.hostile?'#d64545':'#9fe0ff',2,'glow');
      return false;
    }
    if(b.hostile){
      const p=G.player;
      if(dist(b.x,b.y,p.x,p.y)<p.r+4){damagePlayer(1,b.x,b.y);return false;}
    }else{
      for(const e of G.enemies){
        if(!e.dead&&dist(b.x,b.y,e.x,e.y)<e.r+4){
          hitEnemy(e,3,'fire');
          Sfx.boltHit();
          return false;
        }
      }
      for(const s of G.switches){
        if(!s.active&&dist(b.x,b.y,s.x,s.y)<20){
          s.active=true;
          Sfx.sw();
          for(let i=0;i<12;i++)addPart(s.x,s.y,rand(-100,100),rand(-100,100),0.5,'#9fe0ff',2.5,'glow');
          onSwitch(s);
          return false;
        }
      }
      if(G.beacon&&!G.beacon.active&&dist(b.x,b.y,G.beacon.x,G.beacon.y)<26){
        activateBeacon();
        return false;
      }
    }
    return true;
  });
}
function onSwitch(s){
  if(s.id==='z4s'){
    G.flags.z4s=true;
    for(let x=17;x<=21;x++)for(let y=2;y<=3;y++)if(G.map[y][x]===1)G.map[y][x]=0;
    toast('A sealed wall grinds open — the far gate lies clear','blue');
    Sfx.door();
  }else if(s.id==='z5s'){
    G.flags.z5s=true;
    for(let x=34;x<=36;x++)for(let y=2;y<=3;y++)if(G.map[y][x]===1)G.map[y][x]=0;
    if(!G.collected['z5heart5']){
      G.pickups.push({t:'H',id:'z5heart5',x:35*TS+16,y:3*TS+16});
      toast('A hidden alcove opens — a heart container glows within','gold');
    }
    Sfx.door();
  }else if(s.id==='z6a'){G.lastA=G.time;}
  else if(s.id==='z6b'){G.lastB=G.time;}
  if((s.id==='z6a'||s.id==='z6b')&&Math.abs(G.lastA-G.lastB)<3){
    G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
      for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
    toast('The twin seals align — the beacon chamber opens','gold');
  }
  saveGame();
}
function addPart(x,y,vx,vy,life,col,size,kind){
  if(G.parts.length>420)G.parts.shift();
  G.parts.push({x,y,vx,vy,life,maxLife:life,col,size,kind});
}
function updateParts(dt){
  G.parts=G.parts.filter(p=>{
    p.life-=dt;
    p.x+=p.vx*dt;p.y+=p.vy*dt;
    p.vx*=0.92;p.vy*=0.92;
    return p.life>0;
  });
  G.dnums=G.dnums.filter(d=>{d.t-=dt;d.y-=30*dt;return d.t>0;});
}
function addCoin(x,y,amount){
  const n=clamp(Math.ceil(amount/5),1,4);
  for(let i=0;i<n;i++){
    G.pickups.push({t:'coin',id:'c'+Math.random(),x:x+rand(-14,14),y:y+rand(-14,14),a:Math.ceil(amount/n),mag:0.5});
  }
}
function updatePickups(dt){
  const p=G.player;
  G.pickups=G.pickups.filter(pk=>{
    const d=dist(p.x,p.y,pk.x,pk.y);
    if(pk.mag!==undefined){
      pk.mag-=dt;
      if(pk.mag<=0){
        const a=Math.atan2(pk.y-p.y,pk.x-p.x);
        pk.x-=Math.cos(a)*120*dt;pk.y-=Math.sin(a)*120*dt;
      }
    }
    if(d<24){
      collect(pk);
      return false;
    }
    return true;
  });
}
function collect(pk){
  const p=G.player;
  if(pk.t==='coin'){
    p.gold+=pk.a;
    Sfx.coin();
    for(let i=0;i<6;i++)addPart(pk.x,pk.y,rand(-70,70),rand(-90,-10),rand(0.4,0.7),'#ffd97a',2,'glow');
    G.dnums.push({x:pk.x,y:pk.y-8,txt:'+'+pk.a,t:0.8,col:'#ffd97a'});
    updateHUD();
    return;
  }
  const info=PICKUP_INFO[pk.t];
  if(pk.t==='H'){
    p.maxHp+=2;p.hp=Math.min(p.maxHp,p.hp+2);
    Sfx.heartUp();
    G.shimmer=2;
    for(let i=0;i<26;i++){const a=i/26*TAU;addPart(p.x,p.y,Math.cos(a)*rand(40,120),Math.sin(a)*rand(40,120),rand(0.6,1),'#ffd97a',2.5,'glow');}
    toast('Heart Container acquired — max HP +2','gold');
  }else if(pk.t==='V'){
    p.items.V++;Sfx.item();
    for(let i=0;i<10;i++)addPart(pk.x,pk.y,rand(-60,60),rand(-100,-20),rand(0.4,0.8),'#7fe08a',2.5,'glow');
    toast('Vital Stem acquired');
  }else if(pk.t==='Z'){
    p.items.Z++;Sfx.item();
    for(let i=0;i<10;i++)addPart(pk.x,pk.y,rand(-60,60),rand(-100,-20),rand(0.4,0.8),'#9fd8ff',2.5,'glow');
    toast('Binding Seal acquired');
  }else if(pk.t==='Q'){
    p.own.Q=true;p.shardReady=true;p.ammo=10;
    Sfx.rare();
    for(let i=0;i<20;i++)addPart(pk.x,pk.y,rand(-90,90),rand(-120,-20),rand(0.5,0.9),'#9fe0ff',2.5,'glow');
    toast('The Aether Shard hums to life — press K to fire','blue');
    G.shimmer=2;
  }else{
    /* accessory */
    p.own[pk.t]=true;
    if(pk.t==='W')p.equipped.W=true;
    if(pk.t==='L')p.equipped.L=true;
    if(pk.t==='G')p.equipped.G=true;
    Sfx.rare();
    for(let i=0;i<16;i++)addPart(pk.x,pk.y,rand(-80,80),rand(-110,-20),rand(0.5,0.9),'#ffffff',2,'glow');
    toast(info.name+' acquired — equipped','gold');
    G.shimmer=1.2;
  }
  if(pk.id)G.collected[pk.id]=1;
  updateHUD();
  saveGame();
}

/* ============================== world interaction ============================== */
function tileAt(px,py){
  const tx=Math.floor(px/TS),ty=Math.floor(py/TS);
  if(tx<0||ty<0||tx>=MW||ty>=MH)return 0;
  return G.map[ty][tx];
}
function interactWorld(){
  const p=G.player;
  if(!pressed('KeyE'))return;
  const tx=Math.floor(p.x/TS),ty=Math.floor(p.y/TS);
  /* check 3x3 neighborhood for interactables */
  for(let oy=-1;oy<=1;oy++)for(let ox=-1;ox<=1;ox++){
    const t=tileAt(p.x+ox*TS,p.y+oy*TS);
    if(t===6){
      /* sanctuary stone */
      if(G.lastStone!==G.zone){
        G.lastStone=G.zone;
        p.hp=p.maxHp;
        Sfx.heartUp();
        for(let i=0;i<24;i++){const a=i/24*TAU;addPart(p.x,p.y,Math.cos(a)*rand(30,90),Math.sin(a)*rand(30,90),rand(0.5,0.9),'#ffd97a',2.5,'glow');}
        toast('The Sanctuary Stone drinks your weariness — vitality restored','gold');
      }else{
        p.hp=p.maxHp;
        Sfx.item();
        toast('You rest at the stone — vitality restored');
      }
      saveGame();
      updateHUD();
      return;
    }
    if(t===7){
      openShop();
      return;
    }
    if(t===8){
      /* altar */
      const w=G.zone===2?1:2;
      if(p.weapon<w){
        p.weapon=w;
        Sfx.altar();
        G.shimmer=2;
        for(let i=0;i<26;i++){const a=i/26*TAU;addPart(p.x,p.y,Math.cos(a)*rand(40,140),Math.sin(a)*rand(40,140),rand(0.6,1.1),w===1?'#ff8c3a':'#ffd97a',3,'glow');}
        toast('The altar ignites — '+WEAPONS[w].name+' is yours','gold');
        saveGame();
        updateHUD();
      }else{
        toast('The altar\'s flame already answers to your blade');
      }
      return;
    }
  }
}
function updateWorld(dt){
  const p=G.player;
  /* plates */
  G.plates.forEach(pl=>{
    const was=pl.pressed;
    pl.pressed=tileAt(p.x,p.y)===3&&(Math.floor(p.x/TS)===pl.x&&Math.floor(p.y/TS)===pl.y);
    if(pl.pressed&&!was){
      Sfx.plate();
      onPlate(pl);
    }
  });
  /* z5 timed: both plates within 3s */
  if(G.zone===4&&G.plates.length===2){
    const a=G.plates[0],b=G.plates[1];
    if(a.pressed&&b.pressed){
      G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
        for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
      toast('The twin plates align — the spire gate opens','gold');
    }
  }
  /* traps */
  if(tileAt(p.x,p.y)===11&&p.inv<=0){
    if(p.own.G&&p.equipped.G){
      p.inv=1.2;
      toast('The Shadow Cloak absorbs the trap\'s bite');
    }else{
      damagePlayer(1);
    }
  }
  /* exit */
  if(tileAt(p.x,p.y)===5){
    if(G.zone<5){
      fadeTo(()=>{loadZone(G.zone+1);if(G.player){G.player.x=ZONES[G.zone].spawn[0];G.player.y=ZONES[G.zone].spawn[1];}saveGame();});
    }else if(G.beacon&&G.beacon.active){
      /* handled by beacon */
    }
  }
}
function onPlate(pl){
  if(G.zone===2){
    G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
      for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
    toast('A hidden door grinds open above the gallery','gold');
  }
  /* z5 handled in updateWorld */
}
function activateBeacon(){
  const b=G.beacon;
  if(b.active)return;
  b.active=true;
  G.flags.beacon=true;
  Sfx.victory();
  G.shimmer=4;
  for(let i=0;i<80;i++){const a=rand(0,TAU);addPart(b.x,b.y,Math.cos(a)*rand(50,300),Math.sin(a)*rand(50,300),rand(0.8,1.6),'#ffd97a',3,'glow');}
  setTimeout(()=>{
    G.state='win';
    document.getElementById('winTime').textContent=fmtTime(G.time);
    document.getElementById('winGold').textContent=G.player.gold;
    document.getElementById('winKills').textContent=G.kills;
    show('winScreen');
    try{localStorage.removeItem('warden_save');}catch(e){}
  },2200);
}

/* ============================== main update ============================== */
function update(dt){
  if(G.state!=='play')return;
  G.time+=dt;
  updatePlayer(dt);
  updateEnemies(dt);
  updateBolts(dt);
  updatePickups(dt);
  updateWorld(dt);
  updateParts(dt);
  G.flash=Math.max(0,G.flash-dt*1.6);
  G.shimmer=Math.max(0,G.shimmer-dt);
  /* camera */
  cam.x=clamp(G.player.x-VW/2,0,MW*TS-VW);
  cam.y=clamp(G.player.y-VH/2,0,MH*TS-VH);
  /* fade */
  if(G.fadeDir<0){
    G.fade-=dt*2.2;
    if(G.fade<=0){G.fade=0;G.fadeDir=0;if(G.fadeCb){const cb=G.fadeCb;G.fadeCb=null;cb();}}
  }
}

/* ============================== rendering ============================== */
const amb=[];
let ambT=0;
function updateAmbient(dt){
  ambT-=dt;
  if(ambT<=0&&amb.length<130){
    ambT=0.08;
    const z=G.zone;
    if(z===1){amb.push({x:cam.x+rand(0,VW),y:cam.y+rand(0,VH),vx:rand(-6,6),vy:rand(-3,3),life:rand(2,4),max:4,col:'rgba(217,180,120,0.25)',size:rand(1,2.2),kind:'dust'});}
    if(z===3){amb.push({x:cam.x+rand(0,VW),y:cam.y+rand(100,VH),vx:rand(-4,4),vy:rand(-14,-6),life:rand(2,4),max:4,col:'rgba(140,180,220,0.10)',size:rand(14,30),kind:'mist'});}
    if(z===5&&G.beacon&&G.beacon.active){amb.push({x:cam.x+rand(0,VW),y:cam.y+VH,vx:rand(-10,10),vy:rand(-60,-30),life:rand(1.5,3),max:3,col:'rgba(255,217,122,0.35)',size:rand(1.5,3),kind:'dust'});}
    G.stonePos.forEach(s=>{
      if(Math.random()<0.35)amb.push({x:s.x+rand(-30,30),y:s.y+rand(-30,30),vx:rand(-8,8),vy:rand(-20,-8),life:rand(1,2),max:2,col:'rgba(255,217,122,0.5)',size:rand(1,2),kind:'dust'});
    });
  }
  for(let i=amb.length-1;i>=0;i--){
    const a=amb[i];
    a.life-=dt;a.x+=a.vx*dt;a.y+=a.vy*dt;
    if(a.life<=0)amb.splice(i,1);
  }
}
function render(){
  cx.setTransform(1,0,0,1,0,0);
  cx.clearRect(0,0,VW,VH);
  const P=PALETTES[G.zone];
  cx.save();
  cx.translate(-Math.round(cam.x),-Math.round(cam.y));
  drawTiles(P);
  /* warm player light */
  if(G.player){
    const g=cx.createRadialGradient(G.player.x,G.player.y,10,G.player.x,G.player.y,170);
    g.addColorStop(0,'rgba(255,196,120,0.16)');
    g.addColorStop(1,'rgba(255,196,120,0)');
    cx.fillStyle=g;
    cx.beginPath();cx.arc(G.player.x,G.player.y,170,0,TAU);cx.fill();
  }
  /* ambient warm wash for bright zones */
  if(!ZONES[G.zone].dark){
    const g=cx.createRadialGradient(G.player.x,G.player.y,200,G.player.x,G.player.y,700);
    g.addColorStop(0,'rgba(255,200,130,0.06)');
    g.addColorStop(1,'rgba(20,14,8,0.28)');
    cx.fillStyle=g;
    cx.fillRect(cam.x,cam.y,VW,VH);
  }
  drawMechanics();
  drawPickups();
  drawBolts();
  G.enemies.forEach(e=>{if(!e.dead)drawEnemy(e);});
  drawPlayer();
  drawParts();
  drawDnums();
  amb.forEach(a=>{
    cx.globalAlpha=clamp(a.life/a.max,0,1);
    cx.fillStyle=a.col;
    cx.beginPath();cx.arc(a.x,a.y,a.size,0,TAU);cx.fill();
  });
  cx.globalAlpha=1;
  cx.restore();
  drawDarkness();
  drawScreenFx();
}
function drawTiles(P){
  const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(MW-1,Math.ceil((cam.x+VW)/TS));
  const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(MH-1,Math.ceil((cam.y+VH)/TS));
  for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
    const t=G.map[ty][tx];
    const x=tx*TS,y=ty*TS;
    const h=hash2(tx,ty);
    if(t===1){
      cx.fillStyle=P.wall;
      cx.fillRect(x,y,TS,TS);
      cx.fillStyle=P.wallHi;
      cx.fillRect(x,y,TS,5);
      cx.fillStyle='rgba(0,0,0,0.35)';
      cx.fillRect(x,y+TS-4,TS,4);
      cx.fillStyle='rgba(255,255,255,0.06)';
      cx.fillRect(x,y,3,TS);
      if(h>0.8){cx.fillStyle='rgba(0,0,0,0.25)';cx.fillRect(x+4,y+12,TS-8,3);}
      if(h<0.15){cx.fillStyle='rgba(255,255,255,0.05)';cx.fillRect(x+6,y+14,10,10);}
    }else if(t===10){
      cx.fillStyle='#3a3f47';
      cx.fillRect(x,y,TS,TS);
      cx.strokeStyle='#23262c';cx.lineWidth=2;
      for(let i=0;i<=TS;i+=8){cx.beginPath();cx.moveTo(x+i,y);cx.lineTo(x+i,y+TS);cx.stroke();}
      cx.strokeStyle='#4a505a';cx.lineWidth=1;
      cx.strokeRect(x+1,y+1,TS-2,TS-2);
    }else if(t===9){
      cx.fillStyle='#1a2027';
      cx.fillRect(x,y,TS,TS);
      cx.strokeStyle='rgba(120,170,220,0.12)';cx.lineWidth=1;
      const ph=(G.time*0.8+h*3)%1;
      cx.beginPath();cx.arc(x+16,y+16,4+ph*10,0,TAU);cx.stroke();
    }else if(t===11){
      cx.fillStyle=P.floor;
      cx.fillRect(x,y,TS,TS);
      cx.fillStyle='#444a55';
      for(let i=0;i<4;i++){
        const sx=x+4+i*7;
        cx.beginPath();cx.moveTo(sx,y+26);cx.lineTo(sx+3,y+8);cx.lineTo(sx+6,y+26);cx.fill();
      }
      cx.fillStyle='rgba(214,69,69,0.25)';
      cx.fillRect(x,y+26,TS,2);
    }else{
      cx.fillStyle=h>0.5?P.floor:P.floor2;
      cx.fillRect(x,y,TS,TS);
      if(h>0.93){cx.fillStyle='rgba(255,255,255,0.03)';cx.fillRect(x+8,y+8,6,2);}
      if(h<0.07){cx.fillStyle='rgba(0,0,0,0.15)';cx.fillRect(x+12,y+18,8,3);}
    }
  }
}
function drawMechanics(){
  /* breakable walls */
  G.breaks.forEach(b=>{
    if(b.broken)return;
    const x=b.x*TS,y=b.y*TS;
    cx.fillStyle='#332a22';
    cx.fillRect(x,y,TS,TS);
    cx.fillStyle='#2a221b';
    cx.fillRect(x+2,y+2,TS-4,TS-4);
    cx.strokeStyle='rgba(0,0,0,0.5)';
    cx.lineWidth=1;
    const cracks=b.hp<=b.maxHp-3?3:(b.hp<=b.maxHp-1?2:1);
    for(let i=0;i<cracks;i++){
      cx.beginPath();
      cx.moveTo(x+6+i*8,y+4);
      cx.lineTo(x+10+i*7,y+14);
      cx.lineTo(x+5+i*9,y+22);
      cx.stroke();
    }
    if(G.player.own.L&&G.player.equipped.L){
      cx.strokeStyle='rgba(127,212,255,0.6)';
      cx.strokeRect(x+1,y+1,TS-2,TS-2);
    }
  });
  /* plates */
  G.plates.forEach(pl=>{
    const x=pl.x*TS,y=pl.y*TS;
    cx.fillStyle=pl.pressed?'#4a3b26':'#5a4830';
    cx.fillRect(x+3,y+3,TS-6,TS-6);
    cx.strokeStyle='#8a6b3a';cx.lineWidth=2;
    cx.strokeRect(x+5,y+5,TS-10,TS-10);
    cx.fillStyle=pl.pressed?'rgba(217,164,65,0.2)':'rgba(0,0,0,0.2)';
    cx.fillRect(x+8,y+8,TS-16,TS-16);
  });
  /* doors */
  G.doors.forEach(d=>{
    const x=d.x*TS,y=d.y*TS;
    if(!d.open){
      cx.fillStyle='#5a4830';
      cx.fillRect(x,y,TS,TS);
      cx.fillStyle='#3d332a';
      cx.fillRect(x+4,y+4,TS-8,TS-8);
      cx.fillStyle='rgba(217,164,65,0.7)';
      cx.beginPath();cx.arc(x+16,y+16,5+Math.sin(G.time*3)*1.5,0,TAU);cx.fill();
    }else{
      cx.fillStyle='rgba(217,164,65,0.12)';
      cx.fillRect(x,y,TS,TS);
    }
  });
  /* switches */
  G.switches.forEach(s=>{
    const pulse=Math.sin(G.time*4)*0.5+0.5;
    cx.fillStyle='#31404f';
    cx.fillRect(s.x-10,s.y+6,20,10);
    cx.fillStyle=s.active?'#bfeaff':'rgba(127,212,255,'+(0.35+pulse*0.4)+')';
    cx.beginPath();
    cx.moveTo(s.x,s.y-14);cx.lineTo(s.x+8,s.y+2);cx.lineTo(s.x,s.y+10);cx.lineTo(s.x-8,s.y+2);
    cx.closePath();cx.fill();
    if(s.active){
      cx.fillStyle='rgba(127,212,255,0.25)';
      cx.beginPath();cx.arc(s.x,s.y,26+pulse*6,0,TAU);cx.fill();
    }
    if(G.player.own.L&&G.player.equipped.L&&!s.active){
      const p=G.player;
      cx.strokeStyle='rgba(127,212,255,0.15)';
      cx.setLineDash([6,8]);cx.lineWidth=2;
      cx.beginPath();cx.moveTo(p.x,p.y);cx.lineTo(s.x,s.y);cx.stroke();
      cx.setLineDash([]);
    }
  });
  /* sanctuary stones */
  G.stonePos.forEach(s=>{
    const pulse=Math.sin(G.time*2.5)*0.5+0.5;
    cx.fillStyle='#4a4438';
    cx.fillRect(s.x-16,s.y+4,32,14);
    cx.fillStyle='#5a5448';
    cx.fillRect(s.x-12,s.y-6,24,12);
    cx.fillStyle='rgba(255,217,122,'+(0.5+pulse*0.5)+')';
    cx.beginPath();cx.arc(s.x,s.y-12,7+pulse*2,0,TAU);cx.fill();
    cx.fillStyle='rgba(255,217,122,0.15)';
    cx.beginPath();cx.arc(s.x,s.y-12,26+pulse*8,0,TAU);cx.fill();
  });
  /* shop */
  for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
    if(G.map[ty][tx]===7){
      const x=tx*TS,y=ty*TS;
      cx.fillStyle='#4a3b26';
      cx.fillRect(x+2,y+18,TS-4,10);
      cx.fillStyle='#3d332a';
      cx.fillRect(x+4,y+14,TS-8,6);
      cx.fillStyle='#2a2f3a';
      cx.beginPath();cx.arc(x+16,y+8,9,0,TAU);cx.fill();
      cx.fillStyle='#d9a441';
      cx.beginPath();cx.arc(x+16,y+8,3,0,TAU);cx.fill();
      cx.fillStyle='rgba(217,164,65,0.15)';
      cx.beginPath();cx.arc(x+16,y+10,24+Math.sin(G.time*2)*4,0,TAU);cx.fill();
    }
  }
  /* altars */
  for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
    if(G.map[ty][tx]===8){
      const x=tx*TS,y=ty*TS;
      const claimed=G.zone===2?G.player.weapon>=1:G.player.weapon>=2;
      cx.fillStyle='#4a4438';
      cx.fillRect(x+4,y+16,TS-8,10);
      cx.fillStyle='#5a5448';
      cx.fillRect(x+8,y+10,TS-16,8);
      const f=claimed?1:0.35;
      const col=claimed?(G.zone===2?'#ff8c3a':'#ffd97a'):'rgba(217,164,65,0.4)';
      const fl=Math.sin(G.time*6+tx)*2;
      cx.fillStyle=col;
      cx.globalAlpha=f;
      cx.beginPath();
      cx.moveTo(x+16,y+2-fl);
      cx.quadraticCurveTo(x+22,y+10,x+16,y+14);
      cx.quadraticCurveTo(x+10,y+10,x+16,y+2-fl);
      cx.fill();
      cx.globalAlpha=1;
      if(claimed){
        cx.fillStyle='rgba(255,200,100,0.12)';
        cx.beginPath();cx.arc(x+16,y+8,26+fl,0,TAU);cx.fill();
      }
    }
  }
  /* exits */
  for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
    if(G.map[ty][tx]===5){
      const x=tx*TS,y=ty*TS;
      cx.fillStyle='rgba(217,164,65,0.25)';
      cx.fillRect(x+4,y,TS-8,TS);
      const g=cx.createLinearGradient(0,y,0,y+TS);
      g.addColorStop(0,'rgba(244,201,107,0.7)');
      g.addColorStop(1,'rgba(244,201,107,0)');
      cx.fillStyle=g;
      cx.fillRect(x+8,y-6,TS-16,TS+6);
      cx.fillStyle='#d9a441';
      cx.fillRect(x+2,y,4,TS);cx.fillRect(x+TS-6,y,4,TS);
    }
  }
  /* beacon */
  if(G.beacon){
    const b=G.beacon;
    cx.fillStyle='#4a4438';
    cx.fillRect(b.x-24,b.y+18,48,22);
    cx.fillStyle='#5a5448';
    cx.fillRect(b.x-16,b.y-10,32,30);
    if(b.active){
      const g=cx.createLinearGradient(0,b.y,0,b.y-400);
      g.addColorStop(0,'rgba(255,217,122,0.8)');
      g.addColorStop(1,'rgba(255,217,122,0)');
      cx.fillStyle=g;
      cx.fillRect(b.x-14,b.y-400,28,400);
      cx.fillStyle='rgba(255,240,200,0.9)';
      cx.beginPath();cx.arc(b.x,b.y,16+Math.sin(G.time*5)*3,0,TAU);cx.fill();
      cx.fillStyle='rgba(255,217,122,0.2)';
      cx.beginPath();cx.arc(b.x,b.y,70+Math.sin(G.time*3)*10,0,TAU);cx.fill();
    }else{
      cx.fillStyle='rgba(127,212,255,'+(0.3+Math.sin(G.time*3)*0.2)+')';
      cx.beginPath();cx.arc(b.x,b.y,10,0,TAU);cx.fill();
    }
  }
}
function drawPickups(){
  G.pickups.forEach(pk=>{
    const bob=Math.sin(G.time*3+pk.x*0.1)*3;
    const x=pk.x,y=pk.y+bob;
    if(pk.t==='coin'){
      cx.fillStyle='#d9a441';
      cx.beginPath();cx.arc(x,y,5,0,TAU);cx.fill();
      cx.fillStyle='#f4c96b';
      cx.beginPath();cx.arc(x-1.5,y-1.5,2,0,TAU);cx.fill();
    }else if(pk.t==='H'){
      cx.fillStyle='#d64545';
      drawHeart(x,y,9);
      cx.fillStyle='rgba(255,255,255,0.3)';
      drawHeart(x-2,y-3,3);
    }else if(pk.t==='V'){
      cx.strokeStyle='#7fe08a';cx.lineWidth=3;
      cx.beginPath();cx.moveTo(x,y+8);cx.quadraticCurveTo(x+2,y,x,y-8);cx.stroke();
      cx.fillStyle='#7fe08a';
      cx.beginPath();cx.ellipse(x+5,y-4,5,3,0.6,0,TAU);cx.fill();
      cx.beginPath();cx.ellipse(x-4,y+1,5,3,-0.6,0,TAU);cx.fill();
    }else if(pk.t==='W'){
      cx.strokeStyle='#cfd6e4';cx.lineWidth=3;
      cx.beginPath();cx.arc(x,y,7,0,TAU);cx.stroke();
      cx.fillStyle='#ffffff';
      cx.beginPath();cx.arc(x,y-7,2,0,TAU);cx.fill();
    }else if(pk.t==='Q'){
      cx.fillStyle='#9fe0ff';
      cx.beginPath();
      cx.moveTo(x,y-10);cx.lineTo(x+7,y);cx.lineTo(x,y+10);cx.lineTo(x-7,y);
      cx.closePath();cx.fill();
      cx.fillStyle='rgba(255,255,255,0.6)';
      cx.beginPath();cx.moveTo(x,y-10);cx.lineTo(x+3,y);cx.lineTo(x,y+2);cx.closePath();cx.fill();
      cx.fillStyle='rgba(159,224,255,0.2)';
      cx.beginPath();cx.arc(x,y,14+Math.sin(G.time*4)*2,0,TAU);cx.fill();
    }else if(pk.t==='L'){
      cx.fillStyle='#7fd4ff';
      cx.fillRect(x-5,y-8,10,14);
      cx.fillStyle='#31404f';
      cx.fillRect(x-6,y-10,12,3);
      cx.fillStyle='rgba(127,212,255,0.25)';
      cx.beginPath();cx.arc(x,y,16,0,TAU);cx.fill();
    }else if(pk.t==='G'){
      cx.fillStyle='#2a2f3a';
      cx.beginPath();
      cx.moveTo(x-8,y-8);cx.lineTo(x+8,y-8);cx.lineTo(x+11,y+9);cx.lineTo(x-11,y+9);
      cx.closePath();cx.fill();
      cx.strokeStyle='#d9a441';cx.lineWidth=1.5;
      cx.beginPath();cx.moveTo(x-11,y+9);cx.lineTo(x+11,y+9);cx.stroke();
    }else if(pk.t==='Z'){
      cx.strokeStyle='#9fd8ff';cx.lineWidth=2;
      cx.beginPath();cx.arc(x,y,8,0,TAU);cx.stroke();
      cx.beginPath();cx.moveTo(x,y-8);cx.lineTo(x,y+8);cx.moveTo(x-8,y);cx.lineTo(x+8,y);cx.stroke();
      cx.fillStyle='rgba(159,216,255,0.3)';
      cx.beginPath();cx.arc(x,y,5,0,TAU);cx.fill();
    }
  });
}
function drawHeart(x,y,s){
  cx.beginPath();
  cx.moveTo(x,y+s*0.35);
  cx.bezierCurveTo(x-s,y-s*0.45,x-s*0.5,y-s*1.1,x,y-s*0.4);
  cx.bezierCurveTo(x+s*0.5,y-s*1.1,x+s,y-s*0.45,x,y+s*0.35);
  cx.fill();
}
function drawBolts(){
  G.bolts.forEach(b=>{
    const col=b.hostile?'#d64545':'#9fe0ff';
    cx.fillStyle=col;
    cx.beginPath();cx.arc(b.x,b.y,4,0,TAU);cx.fill();
    cx.fillStyle=b.hostile?'rgba(214,69,69,0.35)':'rgba(159,224,255,0.35)';
    cx.beginPath();cx.arc(b.x-b.vx*0.02,b.y-b.vy*0.02,6,0,TAU);cx.fill();
  });
}
function drawEnemy(e){
  const x=e.x,y=e.y;
  const flash=e.flash>0;
  const frozen=e.freeze>0;
  cx.fillStyle='rgba(0,0,0,0.3)';
  cx.beginPath();cx.ellipse(x,y+e.r*0.8,e.r*0.9,e.r*0.35,0,0,TAU);cx.fill();
  const bob=Math.sin(e.anim)*2;
  if(e.type==='chaser'){
    cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#5a4830');
    cx.beginPath();
    cx.moveTo(x-e.r,y+e.r*0.7);
    cx.quadraticCurveTo(x-e.r*1.1,y-e.r*0.8,x,y-e.r);
    cx.quadraticCurveTo(x+e.r*1.1,y-e.r*0.8,x+e.r,y+e.r*0.7);
    cx.closePath();cx.fill();
    cx.fillStyle=flash?'#fff':'#4a3b26';
    cx.fillRect(x-e.r*0.7,y+e.r*0.4,e.r*1.4,e.r*0.4);
    cx.fillStyle=frozen?'#9fd8ff':'#d64545';
    cx.beginPath();cx.arc(x-4,y-e.r*0.3,2.5,0,TAU);cx.fill();
    cx.beginPath();cx.arc(x+4,y-e.r*0.3,2.5,0,TAU);cx.fill();
  }else if(e.type==='shooter'){
    cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#4a505a');
    cx.beginPath();cx.arc(x,y+bob,e.r,0,TAU);cx.fill();
    cx.strokeStyle=flash?'#fff':'#31404f';cx.lineWidth=3;
    const ra=G.time*2+e.anim;
    cx.beginPath();cx.moveTo(x,y+bob);cx.lineTo(x+Math.cos(ra)*e.r*1.3,y+bob+Math.sin(ra)*e.r*1.3);cx.stroke();
    cx.fillStyle=frozen?'#9fd8ff':'#7fd4ff';
    cx.beginPath();cx.arc(x,y+bob,4,0,TAU);cx.fill();
  }else if(e.type==='brute'||e.type==='guardian'){
    const crown=e.type==='guardian';
    cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':(crown?'#5a4a30':'#4a3b26'));
    cx.fillRect(x-e.r,y-e.r+bob,e.r*2,e.r*1.8);
    cx.fillStyle=flash?'#fff':(frozen?'#4a6a8a':'#3d332a');
    cx.fillRect(x-e.r*1.25,y-e.r*0.6+bob,e.r*0.4,e.r*0.7);
    cx.fillRect(x+e.r*0.85,y-e.r*0.6+bob,e.r*0.4,e.r*0.7);
    if(crown){
      cx.fillStyle='#d9a441';
      for(let i=-1;i<=1;i++){
        cx.beginPath();
        cx.moveTo(x+i*8-3,y-e.r+bob);
        cx.lineTo(x+i*8,y-e.r-8+bob);
        cx.lineTo(x+i*8+3,y-e.r+bob);
        cx.fill();
      }
    }
    cx.fillStyle=frozen?'#9fd8ff':'#d64545';
    cx.beginPath();cx.arc(x,y-e.r*0.2+bob,4,0,TAU);cx.fill();
    if(e.type==='guardian'){
      cx.fillStyle='rgba(214,69,69,0.2)';
      cx.beginPath();cx.arc(x,y, e.r+8+Math.sin(G.time*3)*3,0,TAU);cx.fill();
    }
  }else if(e.type==='boss'){
    cx.save();
    cx.translate(x,y);
    cx.rotate(Math.sin(G.time)*0.05);
    cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#3d2e28');
    cx.beginPath();cx.arc(0,bob,e.r,0,TAU);cx.fill();
    cx.strokeStyle=flash?'#fff':'#2a1f1a';cx.lineWidth=4;
    for(let i=0;i<6;i++){
      const a=i/6*TAU+G.time*0.5;
      cx.beginPath();
      cx.moveTo(Math.cos(a)*e.r,bob+Math.sin(a)*e.r);
      cx.lineTo(Math.cos(a)*(e.r+10),bob+Math.sin(a)*(e.r+10));
      cx.stroke();
    }
    cx.fillStyle=flash?'#fff':'#4a3b26';
    cx.beginPath();cx.arc(0,bob,e.r*0.6,0,TAU);cx.fill();
    cx.fillStyle=frozen?'#9fd8ff':'#d64545';
    const pulse=0.6+Math.sin(G.time*4)*0.4;
    cx.globalAlpha=pulse;
    cx.beginPath();cx.arc(0,bob,e.r*0.3,0,TAU);cx.fill();
    cx.globalAlpha=1;
    cx.restore();
    /* boss hp bar */
    const w=70;
    cx.fillStyle='rgba(0,0,0,0.5)';
    cx.fillRect(x-w/2,y-e.r-24,w,7);
    cx.fillStyle='#d64545';
    cx.fillRect(x-w/2+1,y-e.r-23,(w-2)*clamp(e.hp/e.maxHp,0,1),5);
  }
  if(frozen){
    cx.strokeStyle='rgba(159,216,255,0.7)';cx.lineWidth=2;
    cx.beginPath();cx.arc(x,y,e.r+3,0,TAU);cx.stroke();
    cx.fillStyle='rgba(159,216,255,0.2)';
    cx.beginPath();cx.arc(x,y,e.r+3,0,TAU);cx.fill();
  }
  if(e.burn>0){
    cx.fillStyle='rgba(255,140,58,0.3)';
    cx.beginPath();cx.arc(x,y-e.r*0.4,e.r*0.6,0,TAU);cx.fill();
  }
}
function drawPlayer(){
  const p=G.player;
  if(!p)return;
  const x=p.x,y=p.y;
  const wcol=['#8a93a3','#ff8c3a','#ffd97a'][p.weapon];
  cx.fillStyle='rgba(0,0,0,0.35)';
  cx.beginPath();cx.ellipse(x,y+12,11,4.5,0,0,TAU);cx.fill();
  const bob=Math.sin(p.anim)*1.5;
  /* lantern glow if equipped */
  if(p.own.L&&p.equipped.L){
    cx.fillStyle='rgba(127,212,255,0.12)';
    cx.beginPath();cx.arc(x,y,34,0,TAU);cx.fill();
  }
  /* cloak */
  cx.save();
  cx.translate(x,y+bob);
  cx.scale(p.face,1);
  const hem=Math.sin(p.anim*1.3)*2;
  cx.fillStyle=p.hurtT>0?'#7a3a3a':'#2a2f3a';
  cx.beginPath();
  cx.moveTo(-9,-4);
  cx.quadraticCurveTo(-11,10,-8+hem,12);
  cx.lineTo(8+hem,12);
  cx.quadraticCurveTo(11,10,9,-4);
  cx.closePath();
  cx.fill();
  if(p.own.G&&p.equipped.G){
    cx.strokeStyle='#11141a';cx.lineWidth=3;
    cx.beginPath();cx.moveTo(-9,10);cx.lineTo(9,10);cx.stroke();
  }else{
    cx.strokeStyle='#d9a441';cx.lineWidth=1.5;
    cx.beginPath();cx.moveTo(-8+hem,12);cx.lineTo(8+hem,12);cx.stroke();
  }
  /* hood */
  cx.fillStyle=p.hurtT>0?'#8a4a4a':'#333947';
  cx.beginPath();cx.arc(0,-8,7.5,0,TAU);cx.fill();
  cx.fillStyle='#14161c';
  cx.beginPath();cx.arc(2,-8,4.5,0,TAU);cx.fill();
  /* shoulder clasp */
  cx.fillStyle='#d9a441';
  cx.beginPath();cx.arc(-6,-3,2,0,TAU);cx.fill();
  /* wardens band */
  if(p.own.W&&p.equipped.W){
    cx.strokeStyle='#cfd6e4';cx.lineWidth=2;
    cx.beginPath();cx.arc(7,2,3.5,0,TAU);cx.stroke();
    cx.fillStyle='rgba(207,214,228,0.5)';
    cx.beginPath();cx.arc(7,2,5.5,0,TAU);cx.stroke();
  }
  /* off-hand crystal */
  if(p.shardReady){
    const pulse=0.6+Math.sin(G.time*5)*0.4;
    cx.fillStyle='rgba(159,224,255,'+pulse+')';
    cx.beginPath();
    cx.moveTo(-8,-2);cx.lineTo(-4,1);cx.lineTo(-8,4);cx.lineTo(-12,1);
    cx.closePath();cx.fill();
    cx.fillStyle='rgba(159,224,255,0.15)';
    cx.beginPath();cx.arc(-8,1,8+pulse*2,0,TAU);cx.fill();
  }
  cx.restore();
  /* weapon glow aura */
  if(p.weapon>=1){
    cx.fillStyle=p.weapon===1?'rgba(255,140,58,0.10)':'rgba(255,217,122,0.12)';
    cx.beginPath();cx.arc(x,y,20+p.weapon*3,0,TAU);cx.fill();
  }
  /* blade */
  const aim=p.windup>0?p.aim+0.5:p.aim;
  const W=WEAPONS[p.weapon];
  const len=W.range*(p.weapon===2?1.0:0.85);
  cx.save();
  cx.translate(x,y-4+bob);
  cx.rotate(aim);
  cx.strokeStyle=wcol;
  cx.lineWidth=p.weapon===2?5:3.5;
  cx.lineCap='round';
  cx.beginPath();cx.moveTo(6,0);cx.lineTo(6+len,0);cx.stroke();
  if(p.weapon>=1){
    cx.strokeStyle=p.weapon===1?'rgba(255,140,58,0.4)':'rgba(255,217,122,0.45)';
    cx.lineWidth=9;
    cx.beginPath();cx.moveTo(6,0);cx.lineTo(6+len,0);cx.stroke();
  }
  cx.restore();
  /* swing arc */
  if(p.atkT>0){
    const prog=1-p.atkT/0.22;
    cx.save();
    cx.translate(x,y-4);
    cx.globalAlpha=0.35*(1-prog);
    cx.fillStyle=p.weapon===1?'#ff8c3a':(p.weapon===2?'#ffd97a':'#cfd6e4');
    cx.beginPath();
    cx.moveTo(0,0);
    cx.arc(0,0,W.range+8,p.aim-W.arc/2,p.aim-W.arc/2+W.arc*prog);
    cx.closePath();
    cx.fill();
    cx.globalAlpha=1;
    cx.restore();
  }
  /* windup shimmer */
  if(p.windup>0){
    cx.strokeStyle='rgba(255,217,122,'+(0.5*(1-p.windup/WEAPONS[2].windup))+')';
    cx.lineWidth=2;
    cx.beginPath();cx.arc(x,y,16,0,TAU);cx.stroke();
  }
  /* rim light in dark zones */
  if(ZONES[G.zone].dark){
    cx.strokeStyle='rgba(255,210,140,0.55)';
    cx.lineWidth=1.5;
    cx.beginPath();cx.arc(x,y-2,13,0,TAU);cx.stroke();
  }
  /* hurt flash */
  if(p.hurtT>0){
    cx.strokeStyle='rgba(214,69,69,'+(p.hurtT*1.6)+')';
    cx.lineWidth=3;
    cx.beginPath();cx.arc(x,y,15,0,TAU);cx.stroke();
  }
  /* interact prompt */
  const near=tileAt(p.x,p.y);
  let prompt=null;
  for(let oy=-1;oy<=1&&!prompt;oy++)for(let ox=-1;ox<=1;ox++){
    const t=tileAt(p.x+ox*TS,p.y+oy*TS);
    if(t===6){prompt='E · Rest at the Sanctuary Stone';break;}
    if(t===7){prompt='E · Visit the Merchant';break;}
    if(t===8){
      prompt=G.zone===2?(G.player.weapon<1?'E · Claim the Ignited Edge':'The altar is spent'):'E · Claim the Solar Falchion';
      if(G.player.weapon>=(G.zone===2?1:2))prompt='The altar is spent';
      break;
    }
  }
  if(prompt){
    cx.font='13px Cinzel, serif';
    const w=cx.measureText(prompt).width;
    cx.fillStyle='rgba(10,13,20,0.75)';
    cx.fillRect(x-w/2-8,y-42,w+16,20);
    cx.strokeStyle='rgba(217,164,65,0.5)';cx.lineWidth=1;
    cx.strokeRect(x-w/2-8,y-42,w+16,20);
    cx.fillStyle='#f4c96b';
    cx.textAlign='center';
    cx.fillText(prompt,x,y-28);
    cx.textAlign='left';
  }
}
function drawParts(){
  G.parts.forEach(p=>{
    const a=clamp(p.life/p.maxLife,0,1);
    cx.globalAlpha=a;
    if(p.kind==='glow'){
      cx.fillStyle=p.col;
      cx.beginPath();cx.arc(p.x,p.y,p.size*(0.5+a*0.5),0,TAU);cx.fill();
    }else{
      cx.strokeStyle=p.col;
      cx.lineWidth=p.size*0.6;
      cx.beginPath();cx.moveTo(p.x,p.y);cx.lineTo(p.x-p.vx*0.03,p.y-p.vy*0.03);cx.stroke();
    }
  });
  cx.globalAlpha=1;
}
function drawDnums(){
  cx.font='bold 15px Cinzel, serif';
  cx.textAlign='center';
  G.dnums.forEach(d=>{
    cx.globalAlpha=clamp(d.t/0.7,0,1);
    cx.fillStyle='#000';
    cx.fillText(d.txt,d.x+1,d.y+1);
    cx.fillStyle=d.col;
    cx.fillText(d.txt,d.x,d.y);
  });
  cx.globalAlpha=1;
  cx.textAlign='left';
}
function drawDarkness(){
  const z=ZONES[G.zone];
  if(!z.dark)return;
  const p=G.player;
  const r=(p.own.L&&p.equipped.L)?260:180;
  cx.save();
  cx.setTransform(1,0,0,1,0,0);
  cx.fillStyle='rgba(4,6,10,0.84)';
  cx.fillRect(0,0,VW,VH);
  cx.globalCompositeOperation='destination-out';
  const hole=(wx,wy,rad,a)=>{
    if(!isFinite(wx)||!isFinite(wy)||!isFinite(rad)||rad<1)return;
    const x=wx-cam.x,y=wy-cam.y;
    const g=cx.createRadialGradient(x,y,rad*0.2,x,y,rad);
    g.addColorStop(0,'rgba(0,0,0,'+a+')');
    g.addColorStop(1,'rgba(0,0,0,0)');
    cx.fillStyle=g;
    cx.beginPath();cx.arc(x,y,rad,0,TAU);cx.fill();
  };
  hole(p.x,p.y,r,1);
  G.stonePos.forEach(s=>hole(s.x,s.y,120,0.9));
  G.switches.forEach(s=>hole(s.x,s.y,90,0.8));
  if(G.beacon)hole(G.beacon.x,G.beacon.y,G.beacon.active?300:110,1);
  for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
    if(G.map[ty][tx]===7)hole(tx*TS+16,ty*TS+16,110,0.9);
    if(G.map[ty][tx]===8)hole(tx*TS+16,ty*TS+16,100,0.85);
  }
  G.enemies.forEach(e=>{if(!e.dead)hole(e.x,e.y,50,0.5);});
  cx.restore();
}
function drawScreenFx(){
  /* red hurt flash */
  if(G.flash>0){
    cx.fillStyle='rgba(214,69,69,'+(G.flash*0.35)+')';
    cx.fillRect(0,0,VW,VH);
  }
  /* gold shimmer edges */
  if(G.shimmer>0){
    const a=clamp(G.shimmer/2,0,1)*0.5;
    const g=cx.createRadialGradient(VW/2,VH/2,VH*0.42,VW/2,VH/2,VH*0.75);
    g.addColorStop(0,'rgba(255,217,122,0)');
    g.addColorStop(1,'rgba(255,217,122,'+a+')');
    cx.fillStyle=g;
    cx.fillRect(0,0,VW,VH);
  }
  /* low hp pulse */
  const p=G.player;
  if(p&&p.hp<=2&&G.state==='play'){
    const a=0.12+Math.sin(G.time*4)*0.08;
    const g=cx.createRadialGradient(VW/2,VH/2,VH*0.4,VW/2,VH/2,VH*0.8);
    g.addColorStop(0,'rgba(120,20,20,0)');
    g.addColorStop(1,'rgba(120,20,20,'+Math.max(0,a)+')');
    cx.fillStyle=g;
    cx.fillRect(0,0,VW,VH);
  }
  /* fade */
  if(G.fade>0){
    cx.fillStyle='rgba(0,0,0,'+G.fade+')';
    cx.fillRect(0,0,VW,VH);
  }
}

/* ============================== HUD ============================== */
const HEART_SVG='<svg viewBox="0 0 24 22"><path d="M12 20 C4 14 1 9 3.5 5.5 C5.5 2.5 10 3 12 6.5 C14 3 18.5 2.5 20.5 5.5 C23 9 20 14 12 20 Z"/></svg>';
function updateHUD(){
  const p=G.player;
  if(!p)return;
  const cont=Math.ceil(p.maxHp/2);
  const heartsEl=document.getElementById('hearts');
  if(heartsEl.children.length!==cont){
    heartsEl.innerHTML='';
    for(let i=0;i<cont;i++){
      const d=document.createElement('div');
      d.className='hc';
      d.innerHTML='<div class="base">'+HEART_SVG+'</div><div class="fill">'+HEART_SVG+'</div>';
      heartsEl.appendChild(d);
    }
  }
  for(let i=0;i<cont;i++){
    const el=heartsEl.children[i];
    const val=clamp(p.hp-i*2,0,2)/2;
    el.querySelector('.fill').style.clipPath='inset(0 0 '+((1-val)*100)+'% 0)';
    el.classList.toggle('empty',val<=0);
  }
  const W=WEAPONS[p.weapon];
  document.getElementById('weaponName').innerHTML=W.name+(p.weapon===0?' <span class="lvl">· unkindled</span>':'');
  document.getElementById('goldVal').textContent=p.gold;
  const ammoStat=document.getElementById('ammoStat');
  if(p.shardReady){ammoStat.style.display='flex';document.getElementById('ammoVal').textContent=p.ammo;}
  else ammoStat.style.display='none';
}

/* ============================== inventory ============================== */
function invGlyph(t){
  return {V:'🌿',Z:'❄',W:'💍',L:'🏮',G:'🧥',Q:'✦',H:'❤'}[t]||'·';
}
function openInv(){
  const p=G.player;
  const body=document.getElementById('invBody');
  let html='';
  const acc=(t,owned)=>{
    const eq=p.equipped[t];
    html+='<div class="irow"><div class="glyph">'+invGlyph(t)+'</div><div><div class="iname">'+PICKUP_INFO[t].name+'</div><div class="idesc">'+PICKUP_INFO[t].desc+'</div></div>'+
      (owned?'<button class="ibtn '+(eq?'on':'')+'" data-act="toggle" data-item="'+t+'">'+(eq?'Worn':'Wear')+'</button>':'<span class="idesc" style="margin-left:auto">Not found</span>')+'</div>';
  };
  html+='<div class="irow"><div class="glyph">⚔</div><div><div class="iname">'+WEAPONS[p.weapon].name+'</div><div class="idesc">Your blade. Damage '+WEAPONS[p.weapon].dmg+' · speed '+(p.weapon===0?'slow':p.weapon===1?'swift':'heavy')+'</div></div></div>';
  if(p.shardReady)html+='<div class="irow"><div class="glyph" style="color:var(--aether)">✦</div><div><div class="iname">Aether Shard</div><div class="idesc">Fire bolts with K. Charges remaining: '+p.ammo+' (restock at the merchant, 10g)</div></div></div>';
  html+='<div class="irow"><div class="glyph">🌿</div><div><div class="iname">Vital Stem</div><div class="idesc">Restores 2 full containers (4 HP). Press 1 to use.</div></div><span class="count">×'+p.items.V+'</span></div>';
  html+='<div class="irow"><div class="glyph" style="color:var(--aether)">❄</div><div><div class="iname">Binding Seal</div><div class="idesc">Freezes the nearest construct for ~3s. Press 2 to use.</div></div><span class="count">×'+p.items.Z+'</span></div>';
  acc('W',p.own.W);
  acc('L',p.own.L);
  acc('G',p.own.G);
  body.innerHTML=html;
  body.querySelectorAll('[data-act="toggle"]').forEach(b=>{
    b.addEventListener('click',()=>{
      const t=b.dataset.item;
      p.equipped[t]=!p.equipped[t];
      Sfx.item();
      toast(p.equipped[t]?PICKUP_INFO[t].name+' worn':PICKUP_INFO[t].name+' stowed');
      openInv();
    });
  });
  show('invPanel');
}
function closeInv(){hide('invPanel');}

/* ============================== shop ============================== */
function shopItems(){
  const p=G.player;
  const list=[
    {id:'stem',name:'Vital Stem',desc:'Restores 2 full containers (4 HP).',price:50,stock:G.shop.stems,glyph:'🌿'},
    {id:'seal',name:'Binding Seal',desc:'Freezes a construct for ~3 seconds.',price:75,stock:G.shop.seals,glyph:'❄'},
    {id:'heart',name:'Heart Vessel',desc:'A 5th heart container — +2 max HP. The priciest wares in the archive.',price:300,stock:G.shop.heart?1:0,glyph:'❤',
      locked:p.maxHp>=10?'You already carry five vessels.':'',one:true},
  ];
  if(p.shardReady)list.push({id:'ammo',name:'Aether Charges',desc:'Restock the Aether Shard (+5 charges).',price:10,stock:99,glyph:'✦'});
  return list;
}
function openShop(){
  const body=document.getElementById('shopBody');
  let html='';
  shopItems().forEach(it=>{
    const can=p=>p.gold>=it.price&&it.stock>0;
    html+='<div class="irow shoprow"><div class="glyph" '+(it.id==='ammo'?'style="color:var(--aether)"':'')+'>'+it.glyph+'</div>'+
      '<div><div class="iname">'+it.name+'</div><div class="idesc">'+it.desc+(it.locked?'<br><i style="color:#e07a7a">'+it.locked+'</i>':'')+'</div></div>'+
      '<span class="price">'+it.price+' ◉</span>'+
      (it.stock<99?'<span class="stock">stock '+it.stock+'</span>':'')+
      '<button class="ibtn" data-buy="'+it.id+'" '+(can(G.player)?'':'disabled')+'>Buy</button></div>';
  });
  body.innerHTML=html;
  document.getElementById('shopGold').textContent=G.player.gold;
  body.querySelectorAll('[data-buy]').forEach(b=>{
    b.addEventListener('click',()=>buyItem(b.dataset.buy));
  });
  show('shopPanel');
  Sfx.droneOn();
}
function closeShop(){hide('shopPanel');Sfx.droneOff();}
function buyItem(id){
  const p=G.player;
  const items=shopItems().find(i=>i.id===id);
  if(!items||p.gold<items.price||items.stock<=0)return;
  if(items.one&&p.maxHp>=10)return;
  p.gold-=items.price;
  Sfx.buy();
  if(id==='stem'){p.items.V++;G.shop.stems--;}
  else if(id==='seal'){p.items.Z++;G.shop.seals--;}
  else if(id==='heart'){
    p.maxHp+=2;p.hp=Math.min(p.maxHp,p.hp+2);
    G.shop.heart=false;
    Sfx.heartUp();
    G.shimmer=2;
    toast('Heart Vessel acquired — max HP +2','gold');
  }
  else if(id==='ammo'){p.ammo+=5;}
  updateHUD();
  saveGame();
  openShop();
}

/* ============================== UI wiring ============================== */
function toTitle(){
  G.state='title';
  show('title');
  hide('hud');
  if(hasSave())show('btnContinue');else hide('btnContinue');
}
function beginPlay(cont){
  Sfx.ensure();
  if(cont){
    if(!loadSave()){toast('The save is corrupted — a new vigil begins','red');startNew();}
  }else{
    try{localStorage.removeItem('warden_save');}catch(e){}
    startNew();
  }
  hide('title');hide('pause');hide('goScreen');hide('winScreen');
  show('hud');
  G.state='play';
  G.fade=1;G.fadeDir=-1;
}
document.getElementById('btnNew').addEventListener('click',()=>beginPlay(false));
document.getElementById('btnContinue').addEventListener('click',()=>beginPlay(true));
document.getElementById('btnResume').addEventListener('click',()=>{hide('pause');G.state='play';});
document.getElementById('btnRestart').addEventListener('click',()=>{try{localStorage.removeItem('warden_save');}catch(e){} toTitle();});
document.getElementById('btnRespawn').addEventListener('click',()=>{
  const p=G.player;
  p.hp=p.maxHp;
  hide('goScreen');
  loadZone(G.lastStone);
  p.x=ZONES[G.lastStone].spawn[0];p.y=ZONES[G.lastStone].spawn[1];
  G.fade=1;G.fadeDir=-1;
  G.state='play';
  toast('You rise at the last Sanctuary Stone');
});
document.getElementById('btnAgain').addEventListener('click',()=>toTitle());
document.getElementById('invClose').addEventListener('click',closeInv);
document.getElementById('shopClose').addEventListener('click',closeShop);
document.getElementById('muteBtn').addEventListener('click',()=>{
  Sfx.muted=!Sfx.muted;
  document.getElementById('muteBtn').textContent='Sound: '+(Sfx.muted?'Off':'On');
});

/* ============================== main loop ============================== */
let lastT=performance.now();
function frame(now){
  const dt=Math.min(0.05,(now-lastT)/1000);
  lastT=now;
  /* global key handling */
  if(G.state==='play'){
    if(pressed('KeyI','Tab')){closeShop();openInv();}
    if(pressed('Escape','KeyP')){hide('invPanel');closeShop();G.state='pause';show('pause');}
  }else if(G.state==='pause'){
    if(pressed('Escape','KeyP')){hide('pause');G.state='play';}
  }
  if(G.state==='play'){
    const invOpen=!document.getElementById('invPanel').classList.contains('hidden');
    const shopOpen=!document.getElementById('shopPanel').classList.contains('hidden');
    if(!invOpen&&!shopOpen)update(dt);
  }
  if(G.state!=='title')updateAmbient(dt);
  if(G.state!=='title')render();
  pressEdge={};
  requestAnimationFrame(frame);
}
window.addEventListener('pointerdown',()=>Sfx.ensure(),{once:false});
/* init */
toTitle();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"ukisai/Swift-Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"2e8bc065-3791-460b-84f3-6729f283239e","tokensIn":8735202,"tokensOut":132422,"tokensTotal":8867624,"cost":0,"turns":72,"toolCalls":77,"failedToolCalls":0,"timestamp":"2026-09-29T17:59:00.607Z"} -->