← Warden results

Warden

ox alpha · 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 51,574 bytes · SHA-256 09f53d811b8c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>The Warden's Corrosion</title>
<style>
  :root{ --gold:#e8b64a; --ember:#f07b2e; --slate:#1a2230; --slate2:#232e42; --parch:#d9c9a3; }
  *{box-sizing:border-box; margin:0; padding:0;}
  html,body{height:100%; background:#0c1017; overflow:hidden;
    font-family:"Georgia","Times New Roman",serif; color:var(--parch);}
  #wrap{position:relative; width:960px; height:640px; margin:0 auto;}
  canvas{display:block; background:#0c1017; image-rendering:auto;}
  /* ---------- overlays ---------- */
  .overlay{position:absolute; inset:0; display:none; align-items:center; justify-content:center;
    background:rgba(8,10,16,.82); z-index:20;}
  .overlay.show{display:flex;}
  .panel{width:560px; max-height:560px; overflow-y:auto; background:linear-gradient(160deg,#20293c,#161d2b);
    border:2px solid #4a3d24; border-radius:10px; padding:22px 26px;
    box-shadow:0 0 40px rgba(232,182,74,.15), inset 0 0 60px rgba(0,0,0,.5);}
  h1{font-size:34px; color:var(--gold); letter-spacing:3px; text-align:center; text-shadow:0 0 18px rgba(232,182,74,.5);}
  h2{font-size:20px; color:var(--gold); margin-bottom:12px; letter-spacing:1px;}
  p,li{font-size:14px; line-height:1.55; color:#cbbfa0;}
  .btn{display:inline-block; margin:6px 6px 0 0; padding:9px 18px; cursor:pointer;
    background:linear-gradient(#3a3323,#2a2517); border:1px solid #6b5a2e; border-radius:6px;
    color:var(--gold); font-family:inherit; font-size:14px; letter-spacing:1px;}
  .btn:hover{background:linear-gradient(#57492a,#38301b); box-shadow:0 0 12px rgba(232,182,74,.35);}
  .btn:disabled{opacity:.4; cursor:default; box-shadow:none;}
  .row{display:flex; justify-content:space-between; align-items:center; padding:9px 6px;
    border-bottom:1px solid rgba(120,100,60,.25);}
  .row .nm{color:#e6d9b8;} .row .ds{font-size:12px; color:#9a8f75;}
  .tag{font-size:11px; padding:2px 8px; border-radius:10px; border:1px solid #6b5a2e; color:var(--gold);}
  .tag.on{background:#6b5a2e; color:#fff2cf;}
  #toast{position:absolute; left:50%; bottom:70px; transform:translateX(-50%); z-index:30;
    pointer-events:none; text-align:center;}
  .toastMsg{background:rgba(20,26,40,.92); border:1px solid #6b5a2e; color:#efe3c0; font-size:14px;
    padding:8px 20px; border-radius:20px; margin-top:6px; opacity:0; transition:opacity .3s;
    box-shadow:0 0 16px rgba(0,0,0,.6);}
  .toastMsg.show{opacity:1;}
  #hint{position:absolute; left:50%; top:586px; transform:translateX(-50%); z-index:15;
    color:#cdbf9d; font-size:13px; letter-spacing:1px; text-shadow:0 1px 3px #000; pointer-events:none;}
  #zonename{position:absolute; left:50%; top:110px; transform:translateX(-50%); z-index:15; pointer-events:none;
    color:var(--gold); font-size:26px; letter-spacing:5px; text-shadow:0 0 20px rgba(232,182,74,.6);
    opacity:0; transition:opacity 1s;}
  kbd{background:#2a2517; border:1px solid #6b5a2e; border-radius:4px; padding:1px 7px; font-size:12px; color:var(--gold);}
  .center{text-align:center;}
  .small{font-size:12px; color:#8d8368;}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv" width="960" height="600"></canvas>
  <div id="hint"></div>
  <div id="zonename"></div>
  <div id="toast"></div>

  <!-- title -->
  <div class="overlay show" id="ovTitle">
    <div class="panel center">
      <h1>THE WARDEN'S CORROSION</h1>
      <p style="margin:14px 0;">Aurelion's Sunken Observatory drifts above the storm. The Corrosion spreads.<br>You are the Last Warden. Reignite the Core Beacon.</p>
      <p class="small">Move <kbd>WASD</kbd> · Attack <kbd>J</kbd>/<kbd>Space</kbd> · Fire Shard <kbd>K</kbd> · Interact <kbd>E</kbd> · Inventory <kbd>I</kbd></p>
      <div style="margin-top:18px;">
        <button class="btn" id="btnNew">Begin the Vigil</button>
        <button class="btn" id="btnCont" style="display:none;">Continue Pilgrimage</button>
      </div>
    </div>
  </div>

  <!-- inventory -->
  <div class="overlay" id="ovInv"><div class="panel">
    <h2>Warden's Satchel</h2>
    <div id="invList"></div>
    <h2 style="margin-top:16px;">Accessories</h2>
    <div id="accList"></div>
    <div class="center" style="margin-top:14px;"><button class="btn" onclick="closeOv('ovInv')">Close (I)</button></div>
  </div></div>

  <!-- shop -->
  <div class="overlay" id="ovShop"><div class="panel">
    <h2>Merchant Altar — Golden Sigils Accepted</h2>
    <p class="small" id="shopGold"></p>
    <div id="shopList"></div>
    <div class="center" style="margin-top:14px;"><button class="btn" onclick="closeOv('ovShop')">Leave (Esc)</button></div>
  </div></div>

  <!-- ending -->
  <div class="overlay" id="ovEnd"><div class="panel center">
    <h1 style="font-size:28px;">THE BEACON BURNS AGAIN</h1>
    <p style="margin:14px 0;" id="endText"></p>
    <button class="btn" onclick="location.reload()">Begin Anew</button>
  </div></div>

  <!-- game over -->
  <div class="overlay" id="ovDead"><div class="panel center">
    <h1 style="font-size:26px; color:#b0533a;">THE CORROSION TAKES YOU</h1>
    <p style="margin:14px 0;">Yet a Warden's duty outlasts the flesh.</p>
    <button class="btn" id="btnRespawn">Rise at the last Sanctuary</button>
  </div></div>
</div>
<script>
"use strict";
/* ============================================================
   THE WARDEN'S CORROSION — single-file action adventure
   ============================================================ */
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = 960, H = 600;

/* ---------------- audio (WebAudio synth) ---------------- */
let AC = null;
function ac(){ if(!AC) AC = new (window.AudioContext||window.webkitAudioContext)(); return AC; }
function tone(f, dur, type='sine', vol=.15, delay=0, slide=0){
  try{
    const a=ac(), t=a.currentTime+delay;
    const o=a.createOscillator(), g=a.createGain();
    o.type=type; o.frequency.setValueAtTime(f,t);
    if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide), t+dur);
    g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    o.connect(g).connect(a.destination); o.start(t); o.stop(t+dur+.02);
  }catch(e){}
}
function noise(dur, vol=.12, delay=0, lp=800){
  try{
    const a=ac(), t=a.currentTime+delay, n=a.sampleRate*dur|0;
    const b=a.createBuffer(1,n,a.sampleRate), d=b.getChannelData(0);
    for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
    const s=a.createBufferSource(); s.buffer=b;
    const f=a.createBiquadFilter(); f.type='lowpass'; f.frequency.value=lp;
    const g=a.createGain(); g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
    s.connect(f).connect(g).connect(a.destination); s.start(t);
  }catch(e){}
}
const SFX = {
  step(){ noise(.08,.03,0,500); },
  swing(){ tone(320,.09,'sawtooth',.06,0,-180); noise(.07,.05,0,2500); },
  hit(){ noise(.08,.12,0,900); tone(140,.1,'square',.09,0,-60); },
  hitFire(){ noise(.12,.11,0,700); tone(110,.16,'sine',.13,0,-50); tone(700,.1,'sawtooth',.04); },
  hitSolar(){ tone(90,.4,'sine',.2,0,-40); tone(520,.25,'triangle',.1); noise(.15,.1,0,400); },
  shardFire(){ tone(1300,.12,'triangle',.12,0,-500); tone(1900,.08,'sine',.06); },
  shardHit(){ tone(2100,.2,'sine',.09,0,300); },
  hurt(){ tone(90,.2,'sine',.22); tone(1750,.25,'sine',.07,.02,-300); noise(.12,.14,0,300); },
  coin(){ tone(1150,.07,'square',.07); tone(1550,.1,'square',.07,.06); },
  pickup(){ [523,659,784].forEach((f,i)=>tone(f,.16,'sine',.09,i*.07)); },
  rare(){ [392,494,587,784].forEach((f,i)=>tone(f,.4,'sine',.09,i*.1)); tone(196,.7,'triangle',.08); },
  heartGet(){ [523,659,784,1046].forEach((f,i)=>tone(f,.5,'triangle',.1,i*.12)); },
  altar(){ tone(147,.9,'sawtooth',.07); tone(220,.9,'sine',.1,.1); tone(294,.9,'sine',.08,.2); noise(.5,.05,0,300); },
  save(){ [330,415,494,659].forEach((f,i)=>tone(f,.45,'sine',.09,i*.11)); },
  shopDrone(){ tone(98,1.2,'sine',.05); tone(147,1.2,'sine',.04); },
  buy(){ tone(900,.08,'square',.08); tone(1350,.14,'sine',.1,.07); },
  deny(){ tone(200,.15,'square',.08,0,-80); },
  breakWall(){ noise(.5,.2,0,350); tone(70,.4,'sine',.15,0,-30); },
  plate(){ tone(160,.15,'sine',.14,0,-60); noise(.1,.08,0,400); },
  switchHit(){ tone(1567,.3,'sine',.1); tone(2093,.3,'sine',.06,.05); },
  seal(){ tone(880,.5,'sine',.1,0,200); },
  bossRoar(){ tone(60,1.2,'sawtooth',.16,0,-20); noise(.8,.1,0,200); },
  beacon(){ [262,330,392,523,659,784].forEach((f,i)=>tone(f,1.4,'sine',.1,i*.16)); }
};

/* ---------------- input ---------------- */
const keys = {};
addEventListener('keydown', e=>{
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
  keys[e.code]=true;
  if(e.code==='KeyI') toggleInv();
  if(e.code==='Escape'){ closeOv('ovShop'); closeOv('ovInv'); }
  if(e.code==='KeyE') tryInteract();
});
addEventListener('keyup', e=>keys[e.code]=false);

/* ---------------- state ---------------- */
let G = null;
function newState(){
  return {
    zone:0, px:110, py:300, face:{x:1,y:0}, moving:false,
    hp:6, maxHp:6, gold:0, weapon:0, hasShard:false, ammo:0,
    vital:1, seal:0,
    acc:{band:false, lantern:false, cloak:false},
    owned:{band:false, lantern:false, cloak:false},
    flags:{}, invuln:0, atkCd:0, swingT:0, swingDir:{x:1,y:0},
    bolts:[], enemies:[], parts:[], texts:[], pickups:[], platesT:[],
    stepT:0, shake:0, shimmer:0, deadT:0, ended:false, time:0
  };
}
function saveGame(){
  try{ localStorage.setItem('warden_save', JSON.stringify({
    zone:G.zone, px:G.px, py:G.py, hp:G.hp, maxHp:G.maxHp, gold:G.gold, weapon:G.weapon,
    hasShard:G.hasShard, ammo:G.ammo, vital:G.vital, seal:G.seal, acc:G.acc, owned:G.owned,
    flags:pickFlags() })); }catch(e){}
}
function pickFlags(){ const f={}; for(const k in ZONES[G.zone].flags) f[k]=!!G.flags[k]; return f; }

/* ---------------- helpers ---------------- */
const rnd=(a,b)=>a+Math.random()*(b-a);
const dist=(a,b)=>Math.hypot(a.x-b.x, a.y-b.y);
function rectVsCircle(r,cx,cy,rad){
  const nx=Math.max(r.x,Math.min(cx,r.x+r.w)), ny=Math.max(r.y,Math.min(cy,r.y+r.h));
  return (cx-nx)**2+(cy-ny)**2 < rad*rad;
}
function toast(msg, dur=2600){
  const t=document.getElementById('toast');
  const d=document.createElement('div'); d.className='toastMsg'; d.textContent=msg;
  t.appendChild(d); requestAnimationFrame(()=>d.classList.add('show'));
  setTimeout(()=>{ d.classList.remove('show'); setTimeout(()=>d.remove(),400); },dur);
}
function openOv(id){ document.getElementById(id).classList.add('show'); }
function closeOv(id){ document.getElementById(id).classList.remove('show'); }

/* ============================================================
   WORLD DATA — six zones
   obj types: stone, altar, chest, jar, crack, plate, gate,
              switch, shrine, merchant, exit, beacon
   ============================================================ */
const BORDER = ()=>[{x:0,y:0,w:W,h:18},{x:0,y:H-18,w:W,h:18},{x:0,y:0,w:18,h:H},{x:W-18,y:0,w:18,h:H}];
const ENEMY_DEFS = {
  crawler :{hp:4, spd:1.55, r:13, dmg:1, gold:[5,9],  col:'#5a6678', eye:'#e05a3a', name:'Corrupted Crawler'},
  sentinel:{hp:11,spd:.85, r:17, dmg:2, gold:[10,15], col:'#6b5646', eye:'#f0a03a', name:'Rusted Sentinel'},
  gallery :{hp:26,spd:.95, r:22, dmg:2, gold:[45,60], col:'#7a5a48', eye:'#ffb04a', name:'Gallery Warden-Construct', boss:true},
  guardian:{hp:42,spd:1.0, r:24, dmg:2, gold:[70,90], col:'#4a4a66', eye:'#7ab0ff', name:'Spire Guardian', boss:true},
  wraith  :{hp:110,spd:1.15,r:28,dmg:2, gold:[0,0],   col:'#20242e', eye:'#c04aff', name:'Corrosion Wraith', boss:true}
};
const ZONES = [
{ /* ---- 1. THE ASHEN THRESHOLD ---- */
  name:'I · THE ASHEN THRESHOLD', dark:false, floor:'#2a2622', grid:'#332d25',
  solids:[...BORDER(), {x:380,y:0,w:26,h:190},{x:380,y:410,w:26,h:190}],
  objs:[
    {t:'stone', x:170,y:300, id:'s1'},
    {t:'chest', x:800,y:110, item:'vital'},
    {t:'jar', x:430,y:130, gold:12}, {t:'jar', x:660,y:470, gold:10},
    {t:'exit', x:W-14,y:300, w:36,h:110, to:1, sx:64, sy:300}
  ],
  enemies:[[560,220,'crawler'],[640,360,'crawler'],[720,240,'crawler']],
  texts:[[250,180,'The Corrosion gnaws at the bronze…'],[560,120,'Strike with J / SPACE'],[170,370,'A Sanctuary Stone — press E to rest & save']]
},
{ /* ---- 2. THE RUSTED ARCHIVES ---- */
  name:'II · THE RUSTED ARCHIVES', dark:false, floor:'#262a30', grid:'#2f343c',
  solids:[...BORDER(),
    {x:340,y:0,w:22,h:210},{x:340,y:390,w:22,h:190},          // challenge frame
    {x:560,y:0,w:22,h:230, gate:'z2clear'},{x:560,y:370,w:22,h:230, gate:'z2clear'}, // inner gate
    {x:180,y:96,w:150,h:20, crack:true}                        // cracked wall -> band chest
  ],
  objs:[
    {t:'chest', x:255,y:52, item:'band', hidden:true},
    {t:'jar', x:460,y:520, gold:14},
    {t:'stone', x:660,y:300, id:'s2'},
    {t:'merchant', x:790,y:160},
    {t:'chest', x:880,y:480, gold:35},
    {t:'exit', x:W-14,y:300, w:36,h:110, to:2, sx:64, sy:300}
  ],
  enemies:[[420,180,'crawler'],[470,320,'crawler'],[280,420,'sentinel'],[430,470,'crawler']],
  texts:[[300,300,'Defeat the constructs to unseal the archive'],[255,140,'Hairline cracks… the blade may break it']]
},
{ /* ---- 3. THE COLLAPSED GALLERY ---- */
  name:'III · THE COLLAPSED GALLERY', dark:false, floor:'#2b2721', grid:'#37302a',
  solids:[...BORDER(),
    {x:430,y:0,w:24,h:225, gate:'z3door'},{x:430,y:375,w:24,h:225, gate:'z3door'},
    {x:600,y:H-58,w:170,h:40, crack:true}
  ],
  objs:[
    {t:'plate', x:290,y:190, id:'pA'}, {t:'plate', x:290,y:410, id:'pB'},
    {t:'altar', x:530,y:300, tier:1},
    {t:'chest', x:685,y:H-105, item:'heart', hidden:true},
    {t:'jar', x:200,y:520, gold:16},
    {t:'exit', x:W-14,y:300, w:36,h:110, to:3, sx:64, sy:300}
  ],
  enemies:[[330,300,'crawler'],[700,180,'sentinel'],[760,330,'gallery']],
  texts:[[290,140,'Twin plates — press both in quick succession'],[530,350,'An ember sleeps upon this altar']]
},
{ /* ---- 4. THE SUNKEN VAULT ---- */
  name:'IV · THE SUNKEN VAULT', dark:true, floor:'#20262b', grid:'#28313a',
  solids:[...BORDER(),
    {x:300,y:150,w:22,h:130, crack:true},
    {x:640,y:230,w:22,h:140}
  ],
  objs:[
    {t:'chest', x:365,y:215, item:'shard', hidden:true},
    {t:'switch', x:900,y:70, id:'v1'}, {t:'switch', x:900,y:530, id:'v2'}, {t:'switch', x:480,y:52, id:'v3'},
    {t:'shrine', x:480,y:310, item:'lantern', reqFlag:'vaultSwitches'},
    {t:'jar', x:150,y:500, gold:18},
    {t:'exit', x:W-14,y:300, w:36,h:110, to:4, sx:64, sy:300, requires:'lantern',
      lockMsg:'The far door drinks light. Something must reveal its seam.'}
  ],
  enemies:[[520,450,'crawler'],[700,120,'sentinel'],[760,470,'crawler'],[560,180,'sentinel']],
  texts:[[380,120,'Damp dark. Cracks hide in shadow…'],[480,270,'A shrine waits for three lights']]
},
{ /* ---- 5. THE TWIN SPIRE ---- */
  name:'V · THE TWIN SPIRE', dark:false, floor:'#252231', grid:'#2f2b40',
  solids:[...BORDER(),
    {x:760,y:0,w:24,h:120, gate:'z5sw'},
    {x:840,y:180,w:100,h:24},
    {x:520,y:180,w:150,h:22, crack:true}
  ],
  objs:[
    {t:'stone', x:110,y:300, id:'s3'},
    {t:'altar', x:480,y:420, tier:2},
    {t:'chest', x:595,y:120, item:'cloak', hidden:true},
    {t:'jar', x:250,y:150, item:'seal'},
    {t:'chest', x:900,y:520, gold:120},
    {t:'switch', x:900,y:60, id:'sp1'},
    {t:'chest', x:890,y:90, item:'heart', hidden:true, reqFlag:'z5sw'},
    {t:'exit', x:W-14,y:430, w:36,h:110, to:5, sx:64, sy:300, requires:'guardianDead',
      lockMsg:'The Spire Guardian bars the ascent.'}
  ],
  enemies:[[350,220,'sentinel'],[420,470,'crawler'],[640,350,'sentinel'],[700,480,'guardian'],[300,420,'crawler']],
  texts:[[480,470,'The falchion altar hums with captured dawn'],[900,140,'A distant mechanism glints — out of reach of blades']]
},
{ /* ---- 6. THE CORE CHAMBER ---- */
  name:'VI · THE CORE CHAMBER', dark:false, floor:'#1d1a24', grid:'#262133',
  solids:[...BORDER()],
  objs:[
    {t:'beacon', x:850,y:300},
    {t:'switch', x:900,y:70, id:'c1'}, {t:'switch', x:900,y:530, id:'c2'}
  ],
  enemies:[[480,300,'wraith']],
  texts:[[480,120,'THE CORROSION WEARS A FACE'],[850,360,'The Beacon — dark. Slay what feeds upon it.'],
         [900,110,'Crystal switches may stun the Wraith']]
}
];
/* ============================================================
   GAME LOGIC
   ============================================================ */
let paused=false;
function loadZone(i, sx, sy){
  const Z=ZONES[i];
  G.zone=i; if(sx!==undefined){G.px=sx; G.py=sy;}
  G.bolts=[]; G.parts=[]; G.texts=[]; G.enemies=[];
  G.platesT={}; G.curSwitches={};
  G.objs = Z.objs.map(o=>Object.assign({}, o));
  // spawn enemies not permanently dead
  for(const [x,y,t] of Z.enemies){
    const key='kill_'+i+'_'+x+'_'+y;
    if(t==='wraith' && G.flags.wraithDead) continue;
    if(t==='gallery' && G.flags.galleryDead) continue;
    if(t==='guardian' && G.flags.guardianDead) continue;
    G.enemies.push({x,y,type:t, ...ENEMY_DEFS[t], maxhp:ENEMY_DEFS[t].hp, flash:0, burn:0, frozen:0, key, wob:rnd(0,6)});
  }
  // zone entry banner
  const zn=document.getElementById('zonename');
  zn.textContent=Z.name; zn.style.opacity=1;
  clearTimeout(zn._t); zn._t=setTimeout(()=>zn.style.opacity=0, 2600);
  if(i===3) SFX.step();
}
function solidRects(){
  const Z=ZONES[G.zone], out=[];
  for(const s of Z.solids){
    if(s.gate && G.flags[s.gate]) continue;
    if(s.crack && G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]) continue;
    out.push(s);
  }
  return out;
}
function collide(px,py,r){
  for(const s of solidRects()) if(rectVsCircle(s,px,py,r)) return true;
  return false;
}
function movePlayer(dx,dy){
  const r=13;
  let nx=G.px+dx, ny=G.py+dy;
  if(!collide(nx,G.py,r)) G.px=nx;
  if(!collide(G.px,ny,r)) G.py=ny;
}
function addParts(x,y,n,col,spd=2.4,life=.6,grav=0){
  for(let i=0;i<n;i++){
    const a=rnd(0,Math.PI*2), s=rnd(.3,1)*spd;
    G.parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life,t:life,col,grav,r:rnd(1.5,3.5)});
  }
}
function addText(x,y,str,col='#ffe9b0'){ G.texts.push({x,y,str,col,t:1}); }

/* -------- combat -------- */
function weaponStats(){
  // [damage, cooldown(s), arc radius, arc width]
  if(G.weapon===0) return {dmg:2, cd:.42, rad:52, arc:1.5, kind:'phys'};
  if(G.weapon===1) return {dmg:3, cd:.28, rad:56, arc:1.7, kind:'fire'};
  return {dmg:5, cd:.55, rad:78, arc:2.3, kind:'solar'};
}
function doAttack(){
  if(G.atkCd>0 || paused) return;
  const ws=weaponStats();
  const cdMul = G.acc.band?0.82:1;
  G.atkCd = ws.cd*cdMul; G.swingT=.18; G.swingDir={...G.face};
  if(ws.kind==='phys') SFX.swing(); else if(ws.kind==='fire'){SFX.swing(); SFX.hitFire();} else SFX.hitSolar();
  const cx=G.px+G.face.x*26, cy=G.py+G.face.y*26;
  let hitAny=false;
  for(const e of G.enemies){
    if(e.hp<=0) continue;
    const d=Math.hypot(e.x-G.px, e.y-G.py);
    if(d < ws.rad+e.r){
      const ang=Math.atan2(e.y-G.py, e.x-G.px), fa=Math.atan2(G.face.y,G.face.x);
      let da=Math.abs(ang-fa); if(da>Math.PI) da=Math.PI*2-da;
      if(da < ws.arc/2 || d<e.r+16){
        hitAny=true; hurtEnemy(e, ws.dmg, ws.kind);
      }
    }
  }
  // break cracked walls
  for(const s of ZONES[G.zone].solids){
    if(!s.crack || G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]) continue;
    if(rectVsCircle({x:s.x-14,y:s.y-14,w:s.w+28,h:s.h+28}, cx,cy, ws.rad)){
      const hits=(G.flags._crackHits=G.flags._crackHits||{});
      const k=G.zone+'_'+s.x+'_'+s.y;
      hits[k]=(hits[k]||0)+(G.weapon===2?3:1);
      addParts(s.x+s.w/2, s.y+s.h/2, 6, '#8a7a5c', 2);
      if(hits[k]>=6){
        G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]=true; SFX.breakWall();
        addParts(s.x+s.w/2, s.y+s.h/2, 26, '#9a8a68', 3.4, .9);
        G.shake=8; toast('The cracked wall crumbles!');
      }
    }
  }
  if(hitAny) G.shake=Math.max(G.shake, ws.kind==='solar'?5:3);
}
function hurtEnemy(e, dmg, kind){
  e.hp-=dmg; e.flash=.15;
  if(kind==='fire'){ e.burn=1.2; addParts(e.x,e.y,8,'#f07b2e',2.6); }
  else if(kind==='solar'){ addParts(e.x,e.y,14,'#ffd76a',3.2,.8); }
  else addParts(e.x,e.y,6,'#ffb060',2.2);
  if(kind==='phys') SFX.hit();
  addText(e.x+rnd(-8,8), e.y-e.r-6, dmg, kind==='solar'?'#ffe08a':'#ff9a5a');
  if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
  e.hp=0;
  addParts(e.x,e.y, e.boss?40:16, e.col, e.boss?4:2.8, .9);
  addParts(e.x,e.y, 8, e.eye, 3, .7);
  G.flags[e.key]=true;
  if(e.type==='gallery') G.flags.galleryDead=true;
  if(e.type==='guardian') G.flags.guardianDead=true;
  if(e.type==='wraith'){ G.flags.wraithDead=true; }
  if(e.gold[1]>0){
    const g=Math.round(rnd(e.gold[0],e.gold[1]));
    for(let i=0;i<Math.min(8,Math.ceil(g/5));i++)
      G.pickups.push({x:e.x+rnd(-16,16), y:e.y+rnd(-16,16), t:'gold', v:Math.ceil(g/Math.min(8,Math.ceil(g/5))), t2:.9});
  }
  if(e.boss){ SFX.bossRoar(); G.shake=12;
    toast(e.name+' collapses into rust and silence.');
  }
  checkKills();
}
function checkKills(){
  if(G.zone===1 && !G.flags.z2clear && G.enemies.every(e=>e.hp<=0)){
    G.flags.z2clear=true; SFX.altar(); G.shimmer=2;
    toast('The archive gate grinds open. A merchant altar lies beyond.');
  }
}
function fireShard(){
  if(!G.hasShard || G.atkCd>0 || paused) return;
  if(G.ammo<=0){ toast('The Aether Shard is dormant — no charges. (Shop restock: 10g)'); SFX.deny(); return; }
  G.ammo--; G.atkCd=.35;
  SFX.shardFire();
  G.bolts.push({x:G.px+G.face.x*16, y:G.py+G.face.y*16, vx:G.face.x*7.5, vy:G.face.y*7.5, t:2.2});
  addParts(G.px+G.face.x*16, G.py+G.face.y*16, 4, '#8ad0ff', 1.6, .3);
}
function hurtPlayer(dmg){
  if(G.invuln>0 || G.hp<=0) return;
  G.hp-=dmg; G.invuln=1.1; G.shake=7;
  SFX.hurt();
  addParts(G.px,G.py,14,'#d84040',3,.6);
  addText(G.px, G.py-24, '-'+dmg, '#ff6a5a');
  if(G.hp<=0){ G.hp=0; die(); }
}
function die(){
  openOv('ovDead'); paused=true;
}
document.getElementById('btnRespawn').onclick=()=>{
  closeOv('ovDead'); paused=false;
  G.hp=G.maxHp; G.invuln=2;
  // respawn at last activated stone (or zone start)
  const st=G.lastStone; loadZone(G.zone, st?st.x+40:64, st?st.y:300);
};

/* -------- interaction -------- */
function nearestObj(){
  let best=null, bd=52;
  for(const o of G.objs){
    const d=Math.hypot(o.x-G.px, o.y-G.py);
    if(d<bd+ (o.w?Math.max(o.w,o.h)/2:0)){ bd=d; best=o; }
  }
  return best;
}
function tryInteract(){
  if(paused) return;
  const o=nearestObj(); if(!o) return;
  const d=Math.hypot(o.x-G.px,o.y-G.py);
  if(o.t==='stone' && d<60){
    G.lastStone=o; G.hp=G.maxHp; SFX.save(); G.shimmer=2;
    addParts(o.x,o.y,20,'#ffe9a0',2,.9);
    toast('Sanctuary attuned. Wounds close. Progress saved.'); saveGame();
  }
  else if(o.t==='altar' && d<60){
    const tier=o.tier;
    if(G.weapon>=tier){ toast('The altar\'s ember has already passed into your blade.'); return; }
    G.weapon=tier; SFX.altar(); G.shimmer=2; G.shake=6;
    addParts(o.x,o.y,30, tier===1?'#f07b2e':'#ffd76a',3.4,1);
    toast(tier===1 ? 'IGNITED EDGE — your blade burns! Swings faster, strikes with fire.'
                   : 'SOLAR FALCHION — dawn-forged! Heavier, wider, blinding arcs of light.');
    saveGame();
  }
  else if(o.t==='chest' && d<52){
    if(o.reqFlag && !G.flags[o.reqFlag]) return;
    if(o.opened) return;
    o.opened=true; G.flags['chest_'+G.zone+'_'+o.x]=true;
    giveItem(o.item, o.gold, o.x, o.y);
  }
  else if(o.t==='jar' && d<46){
    if(o.opened) return;
    o.opened=true; G.flags['jar_'+G.zone+'_'+o.x]=true;
    SFX.breakWall(); addParts(o.x,o.y,12,'#a09070',2.4,.6);
    if(o.item) giveItem(o.item,0,o.x,o.y); else gainGold(o.gold,o.x,o.y);
  }
  else if(o.t==='shrine' && d<60){
    if(o.taken) return;
    if(!G.flags[o.reqFlag]){ toast('The shrine sleeps. Its three lights are dark.'); SFX.deny(); return; }
    o.taken=true; giveItem(o.item,0,o.x,o.y);
  }
  else if(o.t==='merchant' && d<64){ openShop(); }
  else if(o.t==='beacon' && d<70){
    if(!G.flags.wraithDead){ toast('The Beacon is choked black. The Wraith must fall first.'); SFX.deny(); return; }
    endGame();
  }
}
function giveItem(item, gold, x, y){
  if(gold){ gainGold(gold,x,y); return; }
  SFX.rare(); G.shimmer=2;
  addParts(x,y,22,'#ffffff',2.6,1);
  if(item==='vital'){ G.vital++; toast('Vital Stem acquired — a green restorative (restores 4 HP).'); }
  if(item==='seal'){ G.seal++; toast('Binding Seal acquired — freezes one foe for 3 seconds.'); }
  if(item==='band'){ G.owned.band=true; toast("WARDEN'S BAND — a silver ring. Equip it to swing faster."); }
  if(item==='lantern'){ G.owned.lantern=true; G.acc.lantern=true; toast('PHANTOM LANTERN — equipped. Hidden cracks and seams now glow blue.'); }
  if(item==='cloak'){ G.owned.cloak=true; toast('SHADOW CLOAK — equip it to soften environmental harm.'); }
  if(item==='shard'){
    G.hasShard=true; G.ammo+=10;
    toast('AETHER SHARD — a crystal launcher! Press K to fire bolts at distant mechanisms.');
  }
  if(item==='heart'){
    G.maxHp+=2; G.hp=G.maxHp; SFX.heartGet(); G.shimmer=2;
    addParts(G.px,G.py,26,'#ffd76a',3,1.1);
    toast('HEART CONTAINER — your vitality grows! ('+(G.maxHp/2)+' containers)');
  }
  saveGame();
}
function gainGold(v,x,y){
  G.gold+=v; SFX.coin();
  for(let i=0;i<6;i++) G.pickups.push({x:x+rnd(-10,10),y:y+rnd(-10,10),t:'spark',t2:.8});
  addText(x,y-14,'+'+v+'g','#ffd76a');
}

/* -------- switches / plates / puzzles -------- */
function hitSwitch(sw){
  if(G.flags['sw_'+G.zone+'_'+sw.id]) return;
  G.flags['sw_'+G.zone+'_'+sw.id]=true;
  SFX.switchHit(); addParts(sw.x,sw.y,14,'#8ad0ff',2.6,.8);
  const Z=ZONES[G.zone];
  const allOn = Z.objs.filter(o=>o.t==='switch').every(o=>G.flags['sw_'+G.zone+'_'+o.id]);
  if(G.zone===3 && allOn){ G.flags.vaultSwitches=true; toast('Three lights burn — the shrine awakens.'); SFX.altar(); }
  if(G.zone===4 && allOn){ G.flags.z5sw=true; toast('A distant mechanism turns. Stone grinds aside near the spire wall.'); SFX.altar(); }
  if(G.zone===5){
    G.flags['c_'+sw.id]=true;
    const wr=G.enemies.find(e=>e.type==='wraith');
    if(wr && wr.hp>0){ wr.frozen=3; addParts(wr.x,wr.y,20,'#c04aff',3,.8); toast('The Wraith seizes — crystal light binds it!'); SFX.seal(); }
  }
}
function updatePlates(dt){
  const Z=ZONES[G.zone]; if(G.zone!==2) return;
  const plates=Z.objs.filter(o=>o.t==='plate');
  for(const p of plates){
    const on = Math.hypot(p.x-G.px,p.y-G.py)<26;
    const k='pl_'+p.id;
    if(on && !G.flags[k]){ G.flags[k]=true; SFX.plate(); addParts(p.x,p.y,6,'#c9a45a',1.6,.5); }
    if(!on && G.flags[k] && !G.flags.z3door){
      // decay quickly so "quick succession" matters
      G.platesT[k]=(G.platesT[k]||0)+dt;
      if(G.platesT[k]>1.4){ G.flags[k]=false; G.platesT[k]=0; }
    }
  }
  if(!G.flags.z3door && plates.every(p=>G.flags['pl_'+p.id])){
    G.flags.z3door=true; SFX.altar(); G.shimmer=2; G.shake=5;
    toast('The gallery gate rumbles open!');
  }
}
function useSeal(){
  if(G.seal<=0){ toast('No Binding Seals remain.'); SFX.deny(); return; }
  let tgt=null,bd=180;
  for(const e of G.enemies){ if(e.hp<=0) continue; const d=Math.hypot(e.x-G.px,e.y-G.py); if(d<bd){bd=d;tgt=e;} }
  if(!tgt){ toast('No construct near enough to bind.'); return; }
  G.seal--; tgt.frozen=3; SFX.seal();
  addParts(tgt.x,tgt.y,16,'#6ab0ff',2.4,.8);
  toast('Binding Seal spent — '+tgt.name+' is frozen!');
}

/* -------- pickups -------- */
function updatePickups(dt){
  for(const p of G.pickups){
    p.t2-=dt;
    const d=Math.hypot(p.x-G.px,p.y-G.py);
    if(p.t==='gold' && d<90){ p.x+=(G.px-p.x)*4*dt; p.y+=(G.py-p.y)*4*dt; }
    if(d<20){
      if(p.t==='gold'){ G.gold+=p.v; SFX.coin(); addText(p.x,p.y-10,'+'+p.v+'g','#ffd76a'); }
      p.t2=-1;
    }
  }
  G.pickups=G.pickups.filter(p=>p.t2>0);
}

/* -------- shop -------- */
const SHOP=[
  {id:'vital', nm:'Vital Stem', ds:'Restores 4 HP (2 containers).', cost:50, max:3, get:()=>G.vital, buy:()=>G.vital++},
  {id:'seal',  nm:'Binding Seal', ds:'Freezes one construct for 3s.', cost:75, max:2, get:()=>G.seal, buy:()=>G.seal++},
  {id:'heart5',nm:'Heart Container', ds:'A forged vessel. +1 container, permanently.', cost:300, max:1,
    avail:()=>G.maxHp<10, buy:()=>{G.maxHp+=2;G.hp=G.maxHp;SFX.heartGet();G.shimmer=2;}},
  {id:'ammo',  nm:'Aether Charge', ds:'One bolt for the Aether Shard.', cost:10, max:99,
    avail:()=>G.hasShard && G.flags.z4done, buy:()=>G.ammo++}
];
function openShop(){
  SFX.shopDrone(); renderShop(); openOv('ovShop'); paused=true;
}
function renderShop(){
  document.getElementById('shopGold').textContent='Your purse: '+G.gold+' golden sigils';
  const el=document.getElementById('shopList'); el.innerHTML='';
  for(const s of SHOP){
    if(s.avail && !s.avail()) continue;
    const bought=G.flags['shop_'+s.id]||0;
    const left=s.max-bought;
    const row=document.createElement('div'); row.className='row';
    row.innerHTML=`<div><div class="nm">${s.nm} <span class="small">(${left} left)</span></div><div class="ds">${s.ds}</div></div>`;
    const b=document.createElement('button'); b.className='btn'; b.textContent=s.cost+'g';
    b.disabled = left<=0 || G.gold<s.cost;
    b.onclick=()=>{
      if(G.gold<s.cost){SFX.deny();return;}
      G.gold-=s.cost; s.buy(); G.flags['shop_'+s.id]=(G.flags['shop_'+s.id]||0)+1;
      SFX.buy(); toast(s.nm+' purchased.'); renderShop(); saveGame();
    };
    row.appendChild(b); el.appendChild(row);
  }
}

/* -------- inventory -------- */
function toggleInv(){
  const ov=document.getElementById('ovInv');
  if(ov.classList.contains('show')){ closeOv('ovInv'); paused=false; return; }
  if(paused) return;
  renderInv(); openOv('ovInv'); paused=true;
}
function renderInv(){
  const il=document.getElementById('invList'); il.innerHTML='';
  const cons=[
    {nm:'Vital Stem', n:G.vital, ds:'Restores 4 HP.', use:()=>{ if(G.hp>=G.maxHp){toast('Your hearts already burn full.');return false;} G.vital--; G.hp=Math.min(G.maxHp,G.hp+4); SFX.pickup(); addParts(G.px,G.py,12,'#7ade7a',2,.7); toast('Vital Stem used. (+4 HP)'); }},
    {nm:'Binding Seal', n:G.seal, ds:'Freezes the nearest construct (3s).', use:()=>{ useSeal(); }}
  ];
  for(const c of cons){
    const row=document.createElement('div'); row.className='row';
    row.innerHTML=`<div><div class="nm">${c.nm} <span class="tag">${c.n}</span></div><div class="ds">${c.ds}</div></div>`;
    const b=document.createElement('button'); b.className='btn'; b.textContent='Use';
    b.disabled=c.n<=0;
    b.onclick=()=>{ if(c.use()===false) return; renderInv(); saveGame(); };
    row.appendChild(b); il.appendChild(row);
  }
  const al=document.getElementById('accList'); al.innerHTML='';
  const accs=[
    {k:'band', nm:"Warden's Band", ds:'Slightly faster attacks. Silver ring on the wrist.', owned:G.owned.band},
    {k:'lantern', nm:'Phantom Lantern', ds:'Reveals cracks & hidden seams with a blue glow.', owned:G.owned.lantern},
    {k:'cloak', nm:'Shadow Cloak', ds:'Reduces environmental harm. Dark cloth trim.', owned:G.owned.cloak}
  ];
  for(const a of accs){
    const row=document.createElement('div'); row.className='row';
    row.innerHTML=`<div><div class="nm">${a.nm}</div><div class="ds">${a.ds}</div></div>`;
    if(a.owned){
      const b=document.createElement('button'); b.className='btn';
      b.textContent=G.acc[a.k]?'Equipped':'Equip';
      if(G.acc[a.k]) b.classList.add('on');
      b.onclick=()=>{ G.acc[a.k]=!G.acc[a.k]; SFX.pickup(); renderInv(); saveGame(); };
      row.appendChild(b);
    } else {
      const s=document.createElement('span'); s.className='small'; s.textContent='Not yet found'; row.appendChild(s);
    }
    al.appendChild(row);
  }
}
function endGame(){
  G.ended=true; paused=true; SFX.beacon();
  document.getElementById('endText').innerHTML =
    `The Corrosion Wraith unravels into motes. You press your palm to the cold lens,<br>
     and dawn floods the Sunken Observatory from core to spire.<br><br>
     <span class="small">Containers: ${G.maxHp/2} · Sigils: ${G.gold} · Blade tier: ${['Ember Blade','Ignited Edge','Solar Falchion'][G.weapon]}</span>`;
  openOv('ovEnd');
  try{ localStorage.removeItem('warden_save'); }catch(e){}
}

/* -------- main update -------- */
let last=0;
function update(dt){
  G.time+=dt;
  if(paused) return;
  G.atkCd=Math.max(0,G.atkCd-dt); G.invuln=Math.max(0,G.invuln-dt);
  G.swingT=Math.max(0,G.swingT-dt); G.shake=Math.max(0,G.shake-dt*20); G.shimmer=Math.max(0,G.shimmer-dt);

  // movement
  let dx=(keys.KeyD||keys.ArrowRight?1:0)-(keys.KeyA||keys.ArrowLeft?1:0);
  let dy=(keys.KeyS||keys.ArrowDown?1:0)-(keys.KeyW||keys.ArrowUp?1:0);
  G.moving = !!(dx||dy);
  if(G.moving){
    const l=Math.hypot(dx,dy); dx/=l; dy/=l;
    G.face={x:dx,y:dy};
    movePlayer(dx*2.6, dy*2.6);
    G.stepT-=dt;
    if(G.stepT<=0){ G.stepT=.34; SFX.step();
      if(G.weapon===1) addParts(G.px,G.py+10,1,'#f07b2e',.6,.4);
      if(G.weapon===2) addParts(G.px,G.py+10,1,'#ffd76a',.6,.5);
    }
  }
  if(keys.Space||keys.KeyJ) doAttack();
  if(keys.KeyK) fireShard();

  // ember trail for ignited blade while swinging
  if(G.swingT>0 && G.weapon>=1)
    addParts(G.px+G.face.x*24, G.py+G.face.y*24, 1, G.weapon===2?'#ffd76a':'#f07b2e',1.2,.35);

  // enemies
  for(const e of G.enemies){
    if(e.hp<=0) continue;
    e.flash=Math.max(0,e.flash-dt);
    if(e.burn>0){ e.burn-=dt; e.burnTick=(e.burnTick||0)-dt;
      if(e.burnTick<=0){ e.burnTick=.4; e.hp-=1; addParts(e.x,e.y,3,'#f07b2e',1.4,.4);
        addText(e.x,e.y-e.r-4,1,'#f07b2e'); if(e.hp<=0){killEnemy(e); continue;} } }
    if(e.frozen>0){ e.frozen-=dt; continue; }
    const d=Math.hypot(G.px-e.x,G.py-e.y);
    if(d<340){
      const a=Math.atan2(G.py-e.y,G.px-e.x);
      const wob=Math.sin(G.time*3+e.wob)*.4;
      const mx=Math.cos(a+wob)*e.spd, my=Math.sin(a+wob)*e.spd;
      if(!collide(e.x+mx,e.y,e.r)) e.x+=mx;
      if(!collide(e.x,e.y+my,e.r)) e.y+=my;
      if(d < e.r+14) hurtPlayer(e.dmg);
    }
  }

  // bolts
  for(const b of G.bolts){
    b.t-=dt; b.x+=b.vx; b.y+=b.vy;
    if(Math.random()<.5) G.parts.push({x:b.x,y:b.y,vx:0,vy:0,life:.3,t:.3,col:'#8ad0ff',r:2});
    for(const e of G.enemies){
      if(e.hp>0 && Math.hypot(e.x-b.x,e.y-b.y)<e.r+6){
        b.t=-1; SFX.shardHit(); hurtEnemy(e,3,'aether'); break;
      }
    }
    for(const sw of G.objs){ if(sw.t==='switch' && Math.hypot(sw.x-b.x,sw.y-b.y)<20){ b.t=-1; hitSwitch(sw); } }
    for(const s of ZONES[G.zone].solids){
      if(s.crack && !G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y] && rectVsCircle(s,b.x,b.y,4)){
        b.t=-1; addParts(b.x,b.y,6,'#8ad0ff',2,.4);
        const hits=(G.flags._crackHits=G.flags._crackHits||{}); const k=G.zone+'_'+s.x+'_'+s.y;
        hits[k]=(hits[k]||0)+2;
        if(hits[k]>=6){ G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]=true; SFX.breakWall();
          addParts(s.x+s.w/2,s.y+s.h/2,24,'#9a8a68',3,.9); toast('The shard shatters the cracked wall!'); }
      }
    }
    if(collide(b.x,b.y,4)) b.t=-1;
  }
  G.bolts=G.bolts.filter(b=>b.t>0);

  updatePlates(dt); updatePickups(dt);

  // particles / texts
  for(const p of G.parts){ p.t-=dt; p.x+=p.vx; p.y+=p.vy; p.vy+=(p.grav||0)*dt*60; p.vx*=.96; p.vy*=.96; }
  G.parts=G.parts.filter(p=>p.t>0);
  for(const t of G.texts){ t.t-=dt; t.y-=28*dt; }
  G.texts=G.texts.filter(t=>t.t>0);

  // exits
  const Z=ZONES[G.zone];
  for(const o of G.objs){
    if(o.t!=='exit') continue;
    if(Math.abs(G.px-o.x)<20 && Math.abs(G.py-o.y)<(o.h/2)){
      if(o.requires && !G.flags[o.requires] && !(o.requires==='lantern'&&G.owned.lantern)){
        if(!G._lockT||G.time-G._lockT>2){ G._lockT=G.time; toast(o.lockMsg||'The way is sealed.'); SFX.deny(); }
        continue;
      }
      if(G.zone===3){ G.flags.z4done=true; }
      SFX.step(); loadZone(o.to, o.sx, o.sy); saveGame(); break;
    }
  }
  // ambient particles
  if(Math.random()<.06){
    if(G.zone===1) G.parts.push({x:rnd(20,W-20),y:rnd(20,H-20),vx:rnd(-.2,.2),vy:rnd(-.35,-.1),life:2.5,t:2.5,col:'#6a6048',r:1.6});
    if(G.zone===3) G.parts.push({x:rnd(20,W-20),y:H-20,vx:rnd(-.15,.15),vy:rnd(-.6,-.25),life:3,t:3,col:'#5a7a8a',r:2.2});
  }
}
/* ============================================================
   RENDERING
   ============================================================ */
function drawFloor(Z){
  ctx.fillStyle=Z.floor; ctx.fillRect(0,0,W,H);
  ctx.strokeStyle=Z.grid; ctx.lineWidth=1;
  for(let x=0;x<W;x+=48){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
  for(let y=0;y<H;y+=48){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
  // vignette
  const g=ctx.createRadialGradient(W/2,H/2,200,W/2,H/2,620);
  g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,.5)');
  ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
}
function drawSolids(){
  for(const s of solidRects()){
    if(s.gate){
      ctx.fillStyle='#3a3226'; ctx.fillRect(s.x,s.y,s.w,s.h);
      ctx.fillStyle='#57492e';
      for(let y=s.y;y<s.y+s.h;y+=18) ctx.fillRect(s.x+3,y+4,s.w-6,4);
      continue;
    }
    if(s.crack && !G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]){
      ctx.fillStyle='#443c2e'; ctx.fillRect(s.x,s.y,s.w,s.h);
      ctx.strokeStyle='rgba(20,14,8,.9)'; ctx.lineWidth=1.4;
      ctx.beginPath();
      const horiz=s.w>s.h;
      for(let i=0;i<4;i++){
        const f=(i+.7)/4.4;
        if(horiz){ const cx=s.x+s.w*f; ctx.moveTo(cx-8,s.y+2); ctx.lineTo(cx+5,s.y+s.h*.5); ctx.lineTo(cx-6,s.y+s.h-2); }
        else{ const cy=s.y+s.h*f; ctx.moveTo(s.x+2,cy-8); ctx.lineTo(s.x+s.w*.5,cy+5); ctx.lineTo(s.x+s.w-2,cy-6); }
      }
      ctx.stroke();
      if(G.acc.lantern){ ctx.strokeStyle='rgba(110,190,255,.55)'; ctx.lineWidth=2; ctx.strokeRect(s.x+1,s.y+1,s.w-2,s.h-2); }
      continue;
    }
    ctx.fillStyle='#171a1f'; ctx.fillRect(s.x,s.y,s.w,s.h);
    ctx.fillStyle='#242a33'; ctx.fillRect(s.x+2,s.y+2,s.w-4,s.h-4);
    // rivets
    ctx.fillStyle='#3b4552';
    for(let x=s.x+10;x<s.x+s.w-6;x+=34) for(let y=s.y+9;y<s.y+s.h-4;y+=Math.max(18,s.h-16))
      ctx.fillRect(x,y,3,3);
  }
}
function drawObj(o){
  const t=G.time;
  switch(o.t){
  case 'stone':{
    const lit = G.lastStone===o;
    ctx.fillStyle='#3d4048'; ctx.fillRect(o.x-16,o.y-8,32,26);
    ctx.fillStyle='#4d525e'; ctx.fillRect(o.x-11,o.y-30,22,24);
    const gl=.5+.5*Math.sin(t*2.4);
    ctx.fillStyle=`rgba(255,225,140,${lit?.85:.35+.3*gl})`;
    ctx.beginPath(); ctx.arc(o.x,o.y-36,7+gl*2,0,7); ctx.fill();
    ctx.fillStyle=lit?'#ffe9a0':'#c9b070'; ctx.fillRect(o.x-4,o.y-42,8,10);
    if(Math.random()<.05) G.parts.push({x:o.x+rnd(-12,12),y:o.y,vx:0,vy:-.4,life:1.2,t:1.2,col:'rgba(255,230,160,.7)',r:1.5});
    break;}
  case 'altar':{
    const done=G.weapon>=o.tier;
    const col=o.tier===1?'#f07b2e':'#ffd76a';
    ctx.fillStyle='#3a352c'; ctx.fillRect(o.x-24,o.y-6,48,20);
    ctx.fillStyle='#4a4436'; ctx.fillRect(o.x-16,o.y-22,32,18);
    const gl=.5+.5*Math.sin(t*(done?1.2:3));
    ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=done?8:18*gl;
    ctx.fillStyle=col; ctx.globalAlpha=done?.5:.5+.4*gl;
    ctx.beginPath(); ctx.moveTo(o.x,o.y-46); ctx.lineTo(o.x+7,o.y-28); ctx.lineTo(o.x-7,o.y-28); ctx.fill();
    ctx.restore(); ctx.globalAlpha=1;
    break;}
  case 'chest':{
    if(o.opened){
      ctx.fillStyle='#4a3d24'; ctx.fillRect(o.x-15,o.y-10,30,18);
      ctx.fillStyle='#221b0e'; ctx.fillRect(o.x-11,o.y-6,22,10);
    } else {
      const hid=o.hidden && !G.flags['chest_'+G.zone+'_'+o.x] && Math.hypot(o.x-G.px,o.y-G.py)>120 && !G.acc.lantern;
      if(hid) break;
      ctx.fillStyle='#5a4726'; ctx.fillRect(o.x-15,o.y-12,30,22);
      ctx.fillStyle='#6e5830'; ctx.fillRect(o.x-15,o.y-12,30,8);
      ctx.fillStyle=G.acc.lantern?'rgba(110,190,255,.9)':'#c9a45a'; ctx.fillRect(o.x-3,o.y-6,6,8);
    }
    break;}
  case 'jar':{
    if(o.opened) break;
    ctx.fillStyle='#6a5640'; ctx.beginPath(); ctx.ellipse(o.x,o.y-8,9,11,0,0,7); ctx.fill();
    ctx.fillStyle='#7d6850'; ctx.fillRect(o.x-4,o.y-22,8,6);
    break;}
  case 'plate':{
    const on=G.flags['pl_'+o.id];
    ctx.fillStyle=on?'#8a6a30':'#5a4a28'; ctx.fillRect(o.x-20,o.y-14,40,28);
    ctx.strokeStyle='#c9a45a'; ctx.lineWidth=2; ctx.strokeRect(o.x-20,o.y-14,40,28);
    ctx.fillStyle='rgba(0,0,0,.25)';
    if(on) ctx.fillRect(o.x-16,o.y-10,32,20);
    break;}
  case 'switch':{
    const on=G.flags['sw_'+G.zone+'_'+o.id];
    const gl=.5+.5*Math.sin(t*3);
    ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=on?4:14*gl;
    ctx.fillStyle=on?'#4a7080':'#8ad0ff';
    ctx.beginPath(); ctx.moveTo(o.x,o.y-12); ctx.lineTo(o.x+8,o.y); ctx.lineTo(o.x,o.y+12); ctx.lineTo(o.x-8,o.y); ctx.fill();
    ctx.restore();
    break;}
  case 'shrine':{
    if(G.flags.vaultSwitches && !o.taken){
      const gl=.5+.5*Math.sin(t*2.5);
      ctx.save(); ctx.shadowColor='#bfe6ff'; ctx.shadowBlur=20*gl;
      ctx.fillStyle='#bfe6ff'; ctx.beginPath(); ctx.arc(o.x,o.y-14,8+gl*2,0,7); ctx.fill(); ctx.restore();
    } else { ctx.fillStyle='#39424c'; ctx.fillRect(o.x-12,o.y-10,24,18); }
    break;}
  case 'merchant':{
    const d=Math.hypot(o.x-G.px,o.y-G.py);
    if(d<180 && Math.random()<.03) SFX.shopDrone&&null;
    ctx.fillStyle='#3a3323'; ctx.fillRect(o.x-26,o.y-4,52,18);
    ctx.fillStyle='#4d4430'; ctx.fillRect(o.x-18,o.y-16,36,14);
    const gl=.5+.5*Math.sin(t*1.8);
    ctx.save(); ctx.shadowColor='#ffd76a'; ctx.shadowBlur=14*gl;
    ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(o.x,o.y-24,5+gl*1.5,0,7); ctx.fill(); ctx.restore();
    ctx.fillStyle='#e8b64a'; ctx.font='11px Georgia'; ctx.textAlign='center';
    ctx.fillText('MERCHANT ALTAR', o.x, o.y+30);
    break;}
  case 'beacon':{
    const on=G.flags.wraithDead;
    ctx.fillStyle='#333042'; ctx.fillRect(o.x-20,o.y-10,40,60);
    ctx.fillStyle='#454158'; ctx.fillRect(o.x-13,o.y-50,26,42);
    if(on){
      const gl=.6+.4*Math.sin(t*4);
      ctx.save(); ctx.shadowColor='#ffe9a0'; ctx.shadowBlur=60*gl;
      ctx.fillStyle='#fff2cf'; ctx.beginPath(); ctx.arc(o.x,o.y-58,12+gl*5,0,7); ctx.fill(); ctx.restore();
      if(Math.random()<.3) G.parts.push({x:o.x+rnd(-14,14),y:o.y-58,vx:rnd(-.5,.5),vy:rnd(-2,-.6),life:1.4,t:1.4,col:'#ffe9a0',r:2});
    } else {
      ctx.fillStyle='#15131c'; ctx.beginPath(); ctx.arc(o.x,o.y-58,10,0,7); ctx.fill();
      ctx.strokeStyle='#3a2f4a'; ctx.stroke();
    }
    break;}
  case 'exit':{
    const locked=o.requires && !G.flags[o.requires] && !(o.requires==='lantern'&&G.owned.lantern);
    ctx.fillStyle=locked?'#2a2020':'#101720'; ctx.fillRect(o.x-8,o.y-o.h/2,16,o.h);
    ctx.fillStyle=locked?'#4a3030':'#1d2a38';
    for(let y=o.y-o.h/2+6;y<o.y+o.h/2-6;y+=16) ctx.fillRect(o.x-5,y,10,10);
    const gl=.5+.5*Math.sin(t*2);
    ctx.fillStyle=`rgba(232,182,74,${locked?.15:.25+.2*gl})`;
    ctx.fillRect(o.x-8,o.y-o.h/2,16,o.h);
    break;}
  }
}
function drawEnemy(e){
  if(e.hp<=0) return;
  const bob=Math.sin(G.time*4+e.wob)*2;
  ctx.save();
  if(e.frozen>0){ ctx.filter='saturate(.4) brightness(1.2)';
    ctx.strokeStyle='rgba(106,176,255,.8)'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.arc(e.x,e.y,e.r+6,0,7); ctx.stroke(); }
  ctx.translate(e.x, e.y+bob*.3);
  if(e.flash>0){ ctx.shadowColor='#fff'; ctx.shadowBlur=14; }
  // body
  ctx.fillStyle=e.col;
  ctx.beginPath(); ctx.arc(0,0,e.r,0,7); ctx.fill();
  ctx.fillStyle='rgba(0,0,0,.3)';
  ctx.fillRect(-e.r*.7, e.r*.2, e.r*1.4, e.r*.5);
  // eye
  ctx.fillStyle=e.eye; ctx.shadowColor=e.eye; ctx.shadowBlur=8;
  ctx.beginPath(); ctx.arc(0,-e.r*.25,e.r*.28,0,7); ctx.fill();
  ctx.shadowBlur=0;
  // rust streaks
  ctx.strokeStyle='rgba(90,50,30,.5)'; ctx.lineWidth=2;
  ctx.beginPath(); ctx.moveTo(-e.r*.5,-e.r*.6); ctx.lineTo(-e.r*.2,e.r*.5);
  ctx.moveTo(e.r*.4,-e.r*.4); ctx.lineTo(e.r*.6,e.r*.4); ctx.stroke();
  // hp bar for bosses
  if(e.boss){
    ctx.shadowBlur=0;
    ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(-30,-e.r-14,60,6);
    ctx.fillStyle='#c04aff'; ctx.fillRect(-30,-e.r-14,60*e.hp/e.maxhp,6);
  }
  ctx.restore();
}
function drawPlayer(){
  const x=G.px, y=G.py, f=G.face;
  ctx.save();
  if(G.invuln>0 && Math.floor(G.time*14)%2===0) ctx.globalAlpha=.45;
  // shadow
  ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(x,y+13,12,5,0,0,7); ctx.fill();
  // ember trail upgrade1
  if(G.weapon>=1 && G.moving){
    ctx.fillStyle=G.weapon===2?'rgba(255,215,106,.35)':'rgba(240,123,46,.3)';
    ctx.beginPath(); ctx.arc(x-f.x*8,y-f.y*8+8,5,0,7); ctx.fill();
  }
  // cloak
  ctx.fillStyle = G.acc.cloak ? '#2c2c38' : '#3d4a5e';
  ctx.beginPath(); ctx.moveTo(x-11,y+12); ctx.quadraticCurveTo(x,y+16,x+11,y+12);
  ctx.lineTo(x+7,y-4); ctx.lineTo(x-7,y-4); ctx.fill();
  if(G.acc.cloak){ ctx.strokeStyle='#57576e'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(x-11,y+10); ctx.quadraticCurveTo(x,y+15,x+11,y+10); ctx.stroke(); }
  // body
  ctx.fillStyle='#4e5f78'; ctx.fillRect(x-8,y-8,16,17);
  // head / helm
  ctx.fillStyle='#6a7a92'; ctx.fillRect(x-6,y-19,12,12);
  ctx.fillStyle='#22282f'; ctx.fillRect(x-6+f.x*3,y-15,4,2); // visor glint toward facing
  // shoulder clasp for accessories
  if(G.acc.band||G.acc.cloak){ ctx.fillStyle='#c9a45a'; ctx.fillRect(x-9,y-8,4,4); ctx.fillRect(x+5,y-8,4,4); }
  // weapon
  const sw = G.swingT>0 ? Math.sin((0.18-G.swingT)/0.18*Math.PI)*1.4 : 0;
  const wa = Math.atan2(f.y,f.x)+sw-.5;
  const wx=x+Math.cos(wa)*16, wy=y+Math.sin(wa)*16;
  ctx.save();
  if(G.weapon===1){ ctx.shadowColor='#f07b2e'; ctx.shadowBlur=12; ctx.strokeStyle='#ff9a4a'; }
  else if(G.weapon===2){ ctx.shadowColor='#ffd76a'; ctx.shadowBlur=18; ctx.strokeStyle='#ffe9a0'; }
  else { ctx.strokeStyle='#b8c4d4'; }
  ctx.lineWidth=G.weapon===2?5:3.5;
  ctx.beginPath(); ctx.moveTo(x+Math.cos(wa)*6, y+Math.sin(wa)*6);
  ctx.lineTo(wx+Math.cos(wa)*(G.weapon===2?16:11), wy+Math.sin(wa)*(G.weapon===2?16:11)); ctx.stroke();
  ctx.restore();
  // off-hand shard
  if(G.hasShard){
    const px2=x-f.x*10 - f.y*8, py2=y-f.y*10 + f.x*8;
    const gl=.6+.4*Math.sin(G.time*3);
    ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=10*gl;
    ctx.fillStyle='#bfe6ff'; ctx.beginPath();
    ctx.moveTo(px2,py2-5); ctx.lineTo(px2+4,py2); ctx.lineTo(px2,py2+5); ctx.lineTo(px2-4,py2); ctx.fill();
    ctx.restore();
  }
  // damage flash ring
  if(G.invuln>.8){ ctx.strokeStyle='rgba(216,64,64,.7)'; ctx.lineWidth=3;
    ctx.beginPath(); ctx.arc(x,y,20,0,7); ctx.stroke(); }
  ctx.restore();
}
function drawHUD(){
  // hearts
  for(let i=0;i<G.maxHp/2;i++){
    const hx=30+i*34, hy=30, full=G.hp>=(i+1)*2, half=!full && G.hp===i*2+1;
    drawHeart(hx,hy, full?'#d84a4a':half?'#a04040':'#3a3038', full||half);
    if(half){ ctx.save(); ctx.beginPath(); ctx.rect(hx-14,hy-12,14,26); ctx.clip();
      drawHeart(hx,hy,'#3a3038',true); ctx.restore(); }
  }
  // gold & gear
  ctx.font='15px Georgia'; ctx.textAlign='left';
  ctx.fillStyle='#ffd76a'; ctx.fillText('◉ '+G.gold+' sigils', 30, 68);
  const wname=['Ember Blade','Ignited Edge','Solar Falchion'][G.weapon];
  ctx.fillStyle=G.weapon===2?'#ffe9a0':G.weapon===1?'#ff9a4a':'#b8c4d4';
  ctx.fillText('⚔ '+wname, 170, 68);
  if(G.hasShard){ ctx.fillStyle='#8ad0ff'; ctx.fillText('◆ Aether Shard ×'+G.ammo+'  [K]', 340, 68); }
  // consumable quick count
  ctx.fillStyle='#7ade7a'; ctx.fillText('✿ '+G.vital, 30, 88);
  ctx.fillStyle='#6ab0ff'; ctx.fillText('❉ '+G.seal, 80, 88);
  // zone progress
  ctx.textAlign='right'; ctx.fillStyle='rgba(217,201,163,.65)'; ctx.font='12px Georgia';
  ctx.fillText(ZONES[G.zone].name, W-26, 30);
  if(G.shimmer>0){
    const a=Math.min(1,G.shimmer)* .5*(0.6+0.4*Math.sin(G.time*10));
    ctx.strokeStyle=`rgba(255,225,150,${a})`; ctx.lineWidth=8;
    ctx.strokeRect(4,4,W-8,H-8);
  }
}
function drawHeart(x,y,col,fillMode){
  ctx.save(); ctx.translate(x,y); ctx.scale(1.15,1.15);
  ctx.beginPath();
  ctx.moveTo(0,10);
  ctx.bezierCurveTo(-13,0,-11,-11,-4,-11); ctx.bezierCurveTo(-1,-11,0,-8,0,-7);
  ctx.bezierCurveTo(0,-8,1,-11,4,-11); ctx.bezierCurveTo(11,-11,13,0,0,10);
  ctx.closePath();
  if(fillMode){ ctx.fillStyle=col; ctx.fill(); }
  ctx.strokeStyle='#181218'; ctx.lineWidth=1.5; ctx.stroke();
  ctx.restore();
}
function render(){
  const Z=ZONES[G.zone];
  ctx.clearRect(0,0,W,H);
  ctx.save();
  if(G.shake>0) ctx.translate(rnd(-G.shake,G.shake), rnd(-G.shake,G.shake));
  drawFloor(Z);
  // objects below entities
  for(const o of G.objs) if(['plate','exit'].includes(o.t)) drawObj(o);
  drawSolids();
  for(const o of G.objs) if(!['plate','exit'].includes(o.t)) drawObj(o);
  for(const e of G.enemies) drawEnemy(e);
  // pickups
  for(const p of G.pickups){
    const b=Math.sin(G.time*6+p.x)*2;
    if(p.t==='gold'){ ctx.fillStyle='#ffd76a'; ctx.shadowColor='#ffd76a'; ctx.shadowBlur=6;
      ctx.beginPath(); ctx.arc(p.x,p.y+b,4,0,7); ctx.fill(); ctx.shadowBlur=0; }
  }
  // bolts
  for(const b of G.bolts){
    ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=12;
    ctx.fillStyle='#dff2ff'; ctx.beginPath(); ctx.arc(b.x,b.y,4.5,0,7); ctx.fill(); ctx.restore();
  }
  drawPlayer();
  // particles
  for(const p of G.parts){
    ctx.globalAlpha=Math.max(0,p.t/p.life);
    ctx.fillStyle=p.col; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
  }
  ctx.globalAlpha=1;
  // darkness (zone 4)
  if(Z.dark){
    const rad = G.acc.lantern? 260 : 130;
    const dk=ctx.createRadialGradient(G.px,G.py,rad*.4,G.px,G.py,rad);
    dk.addColorStop(0,'rgba(4,8,12,0)'); dk.addColorStop(1,'rgba(4,8,12,.93)');
    ctx.fillStyle=dk; ctx.fillRect(0,0,W,H);
  }
  // floating texts
  ctx.font='bold 13px Georgia'; ctx.textAlign='center';
  for(const t of G.texts){
    ctx.globalAlpha=Math.min(1,t.t*2); ctx.fillStyle=t.col;
    ctx.fillText(t.str,t.x,t.y);
  }
  ctx.globalAlpha=1;
  ctx.restore();
  drawHUD();
  // interaction hint
  const o=nearestObj();
  const hint=document.getElementById('hint');
  let h='';
  if(o && !paused){
    const d=Math.hypot(o.x-G.px,o.y-G.py);
    const labels={stone:'E — Rest at Sanctuary Stone', altar:o.tier===1?'E — Ignite the Blade':'E — Claim the Solar Falchion',
      chest:(o.item==='heart'?'E — Open · Heart Container!':'E — Open the chest'), jar:'E — Break the jar',
      merchant:'E — Trade with the Merchant Altar', beacon: G.flags.wraithDead?'E — Reignite the Core Beacon':'The Beacon lies dark…'};
    if(labels[o.t] && d<(o.t==='merchant'?64:o.t==='stone'?60:o.t==='beacon'?70:52)) h=labels[o.t];
  }
  hint.textContent=h;
}

/* ---------------- boot ---------------- */
function startGame(fromSave){
  ac(); // unlock audio
  G=newState();
  if(fromSave){
    try{
      const s=JSON.parse(localStorage.getItem('warden_save'));
      Object.assign(G,{zone:s.zone,px:s.px,py:s.py,hp:s.hp,maxHp:s.maxHp,gold:s.gold,
        weapon:s.weapon,hasShard:s.hasShard,ammo:s.ammo,vital:s.vital,seal:s.seal,
        acc:s.acc,owned:s.owned});
      for(const k in s.flags) G.flags[k]=s.flags[k];
      G.hp=Math.max(2,G.hp);
    }catch(e){}
  }
  closeOv('ovTitle'); closeOv('ovEnd'); closeOv('ovDead'); closeOv('ovInv'); closeOv('ovShop');
  loadZone(G.zone, G.px, G.py);
  toast('Zone I — walk with WASD. Strike with J or SPACE.',3600);
}
document.getElementById('btnNew').onclick=()=>startGame(false);
document.getElementById('btnCont').onclick=()=>startGame(true);
try{ if(localStorage.getItem('warden_save')) document.getElementById('btnCont').style.display='inline-block'; }catch(e){}

function loop(ts){
  const dt=Math.min(.05,(ts-last)/1000||.016); last=ts;
  if(G) { update(dt); render(); }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"d2662be8-e684-43c1-a30b-93714c3525db","tokensIn":971476,"tokensOut":27623,"tokensTotal":999099,"cost":0,"turns":27,"toolCalls":26,"failedToolCalls":0,"timestamp":"2026-08-21T22:56:23.850Z"} -->