← RPG results

RPG

Qwen 3.8 max · full

Initial view of RPG

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 95,959 bytes · SHA-256 90e2d57ef59e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ember Vale — Action-Adventure RPG</title>
<link rel="icon" href="data:,">
<style>
:root{--gold:#d8b34a;--ink:#e8e2d0;--dim:#9a927c}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#07090d;color:var(--ink);
 font-family:'Segoe UI',system-ui,-apple-system,sans-serif;user-select:none;-webkit-user-select:none}
canvas#game{position:fixed;inset:0;display:block}
.hidden{display:none!important}
.overlay{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
 background:rgba(5,7,12,.82);backdrop-filter:blur(3px);z-index:40;text-align:center}
.panel{position:relative;background:linear-gradient(180deg,#1b1e2a,#101219);border:2px solid var(--gold);
 border-radius:10px;box-shadow:0 12px 44px #000c,inset 0 0 60px #0006;padding:24px 30px;max-width:min(720px,94vw)}
h1.logo{font-family:Georgia,'Times New Roman',serif;font-size:58px;letter-spacing:12px;color:var(--gold);
 text-shadow:0 0 26px #d8b34a55,0 4px 0 #000;margin-bottom:2px}
.sub{color:#b9b2a0;letter-spacing:4px;font-size:12px;margin-bottom:20px;text-transform:uppercase}
.lore{font-size:13px;color:#a89f8a;max-width:460px;margin:0 auto 18px;line-height:1.55;font-style:italic}
.btn{display:inline-block;margin:6px 8px;padding:10px 26px;font-size:15px;letter-spacing:2px;color:var(--ink);
 background:linear-gradient(#2a2e3e,#191c26);border:1px solid var(--gold);border-radius:6px;cursor:pointer;
 transition:.15s;font-family:inherit}
.btn:hover{background:linear-gradient(#3a3f55,#232738);box-shadow:0 0 16px #d8b34a44;transform:translateY(-1px)}
.btn:disabled{opacity:.35;cursor:default;transform:none;box-shadow:none}
.controls{display:grid;grid-template-columns:auto auto;gap:5px 20px;margin:16px auto 4px;width:fit-content;
 font-size:12.5px;color:#cfc8b6;text-align:left}
kbd{background:#262a38;border:1px solid #4a4f63;border-bottom-width:2px;border-radius:4px;
 padding:1px 7px;font-family:inherit;font-size:11px;color:#ffe9b0}
#mini{position:fixed;top:14px;right:14px;width:160px;height:102px;border:2px solid #d8b34a55;border-radius:6px;
 background:rgba(8,10,16,.75);z-index:20;pointer-events:none}
#quest{position:fixed;top:128px;right:14px;width:236px;background:rgba(10,12,18,.78);
 border:1px solid #d8b34a44;border-radius:6px;padding:9px 13px;z-index:20;pointer-events:none}
#questTitle{font-size:10px;letter-spacing:3px;color:var(--gold);margin-bottom:3px}
#questText{font-size:12.5px;line-height:1.4;color:#e2dcc8}
#toast{position:fixed;left:50%;bottom:24%;transform:translateX(-50%);z-index:30;pointer-events:none;
 display:flex;flex-direction:column;align-items:center;gap:6px}
.toastMsg{background:rgba(12,14,20,.9);border:1px solid var(--gold);color:#ffe9b0;padding:8px 20px;
 border-radius:6px;font-size:14px;letter-spacing:.5px;white-space:nowrap;animation:toastIn 3.4s forwards}
@keyframes toastIn{0%{opacity:0;transform:translateY(14px)}8%{opacity:1;transform:none}78%{opacity:1}100%{opacity:0;transform:translateY(-10px)}}
#dlg{position:fixed;left:50%;bottom:5%;transform:translateX(-50%);width:min(700px,92vw);
 background:linear-gradient(#181b25f2,#0e1016f2);border:2px solid var(--gold);border-radius:10px;
 padding:14px 22px 18px;z-index:35;text-align:left;box-shadow:0 8px 30px #000a}
#dlgName{font-family:Georgia,serif;color:var(--gold);letter-spacing:2px;font-size:14px;margin-bottom:6px}
#dlgText{font-size:15px;line-height:1.55;min-height:46px}
#dlgHint{position:absolute;right:16px;bottom:8px;font-size:11px;color:var(--dim);animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.3}}
#hurtFx{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;transition:opacity .35s;
 background:radial-gradient(ellipse at center,transparent 42%,rgba(200,30,30,.75) 130%)}
#inv .panel,#shopPanel .panel{width:min(560px,94vw)}
.panelTitle{font-family:Georgia,serif;color:var(--gold);letter-spacing:3px;font-size:20px;margin-bottom:4px}
.panelSub{font-size:12px;color:var(--dim);margin-bottom:12px}
.resLine{font-size:13px;color:#ffe9b0;margin-bottom:10px}
.slotGrid{display:grid;grid-template-columns:repeat(6,66px);gap:8px;justify-content:center;margin:6px 0 12px}
.slot{width:66px;height:66px;background:#0d0f16;border:1px solid #3c4054;border-radius:8px;display:flex;
 flex-direction:column;align-items:center;justify-content:center;cursor:pointer;position:relative;font-size:27px;transition:.12s}
.slot:hover{border-color:var(--gold);background:#141726}
.slot.empty{cursor:default;opacity:.45}
.slot .cnt{position:absolute;bottom:2px;right:6px;font-size:11px;color:#ffe9b0;text-shadow:0 1px 2px #000}
.slot .eq{position:absolute;top:2px;left:5px;font-size:9px;color:#7cfc90;letter-spacing:1px}
.slot.wsel{border-color:#7cb8ff;box-shadow:0 0 8px #7cb8ff44}
#itemInfo{min-height:42px;font-size:13px;color:#cfc8b6;text-align:center;line-height:1.45}
.footHint{font-size:11px;color:var(--dim);margin-top:10px}
.wareRow{display:flex;align-items:center;gap:12px;padding:9px 10px;border-bottom:1px solid #ffffff12;font-size:14px;text-align:left}
.wareRow .ico{font-size:23px;width:32px;text-align:center}
.wareRow .nm{flex:1}.wareRow small{display:block;color:var(--dim);font-size:11px;margin-top:1px}
.buyBtn{padding:5px 14px;font-size:12px;background:#232738;border:1px solid var(--gold);color:#ffe9b0;
 border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:1px}
.buyBtn:hover{background:#333952}.buyBtn:disabled{opacity:.35;cursor:default}
.bigWord{font-family:Georgia,serif;font-size:44px;letter-spacing:8px;color:var(--gold);margin-bottom:10px;text-shadow:0 0 24px #d8b34a44}
.deathWord{color:#e5484d;text-shadow:0 0 24px #e5484d55}
#winStats{font-size:14px;line-height:1.9;color:#cfc8b6;margin:8px 0 14px}
.credit{position:absolute;bottom:10px;right:14px;font-size:10px;color:#565b6b;letter-spacing:1px}
</style>
</head>
<body>
<canvas id="game"></canvas>
<canvas id="mini" width="160" height="102"></canvas>
<div id="hurtFx"></div>

<div id="quest"><div id="questTitle">OBJECTIVE</div><div id="questText">…</div></div>
<div id="toast"></div>

<div id="dlg" class="hidden"><div id="dlgName"></div><div id="dlgText"></div><div id="dlgHint">E ▸ continue</div></div>

<div id="title" class="overlay">
 <div class="panel">
  <h1 class="logo">EMBER VALE</h1>
  <div class="sub">A short action-adventure</div>
  <div class="lore">The Ash King has woken on the northern peak. Slimes choke the fields, the old ruins
  have sealed themselves, and the village keeps its last hope by the shrine. Take up the rusty blade, traveler.</div>
  <div><button class="btn" id="btnNew">New Journey</button><button class="btn" id="btnCont" disabled>Continue</button></div>
  <div class="controls">
   <span><kbd>W A S D</kbd> / <kbd>←↑↓→</kbd></span><span>move</span>
   <span><kbd>J</kbd> / <kbd>Z</kbd> / <kbd>Space</kbd></span><span>attack · shoot</span>
   <span><kbd>K</kbd> / <kbd>Shift</kbd></span><span>dodge roll (i-frames)</span>
   <span><kbd>E</kbd></span><span>interact / talk</span>
   <span><kbd>Q</kbd></span><span>swap weapon</span>
   <span><kbd>1</kbd> potion &nbsp;<kbd>2</kbd> bomb</span><span>quick use</span>
   <span><kbd>I</kbd> / <kbd>Tab</kbd></span><span>inventory</span>
   <span><kbd>M</kbd></span><span>music on/off</span>
  </div>
  <div class="footHint">Headphones recommended · progress is kept at shrines</div>
  <div class="credit">EMBER VALE v1.0</div>
 </div>
</div>

<div id="inv" class="overlay hidden"><div class="panel">
 <div class="panelTitle">SATCHEL</div>
 <div class="panelSub">Click consumables to use them · click accessories to equip (max 2)</div>
 <div class="resLine" id="invRes"></div>
 <div class="slotGrid" id="invGrid"></div>
 <div id="itemInfo">Hover an item.</div>
 <div class="footHint"><kbd>Q</kbd> swap melee/bow in the field · <kbd>Esc</kbd> close</div>
</div></div>

<div id="shopPanel" class="overlay hidden"><div class="panel">
 <div class="panelTitle">BRAM'S WARES</div>
 <div class="panelSub">"Everything a hero needs, provided the hero has gold."</div>
 <div class="resLine" id="shopGold"></div>
 <div id="shopList"></div>
 <div class="footHint"><kbd>Esc</kbd> leave the stall</div>
</div></div>

<div id="pauseP" class="overlay hidden"><div class="panel">
 <div class="panelTitle">PAUSED</div>
 <div class="panelSub">The vale holds its breath.</div>
 <div><button class="btn" id="btnResume">Resume</button><button class="btn" id="btnQuit">Title (progress kept at last shrine)</button></div>
</div></div>

<div id="death" class="overlay hidden"><div class="panel">
 <div class="bigWord deathWord">YOU FELL</div>
 <div class="panelSub">The vale mourns — but the shrine remembers.<br>You lose a fifth of your gold.</div>
 <div><button class="btn" id="btnRise">Rise at the shrine</button></div>
</div></div>

<div id="win" class="overlay hidden"><div class="panel">
 <div class="bigWord">THE ASH KING FALLS</div>
 <div class="panelSub">Ember Vale breathes again. The embers cool into quiet stars.</div>
 <div id="winStats"></div>
 <div><button class="btn" id="btnWinClose">Keep wandering</button></div>
</div></div>

<script>
"use strict";
/* ================================================================
   EMBER VALE — a one-file action-adventure RPG
   ================================================================ */
const TS=32, MW=100, MH=64;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(ax,ay,bx,by)=>Math.hypot(bx-ax,by-ay);
function angDiff(a,b){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return d}
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function hash2(x,y){let h=(x*374761393+y*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);return((h^(h>>>16))>>>0)/4294967296}
const rnd=(a,b)=>a+Math.random()*(b-a);
const irnd=(a,b)=>Math.floor(rnd(a,b+1));

/* ---------------- canvas ---------------- */
const cv=document.getElementById('game'),ctx=cv.getContext('2d');
const mini=document.getElementById('mini'),mctx=mini.getContext('2d');
let VW=0,VH=0,DPR=1;
function resize(){DPR=Math.min(window.devicePixelRatio||1,2);VW=window.innerWidth;VH=window.innerHeight;
 cv.width=VW*DPR;cv.height=VH*DPR;cv.style.width=VW+'px';cv.style.height=VH+'px';ctx.setTransform(DPR,0,0,DPR,0,0)}
window.addEventListener('resize',resize);resize();

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

/* ---------------- audio ---------------- */
const AU={
 ctx:null,master:null,mus:null,sfxG:null,del:null,noiseBuf:null,musicOn:true,musT:0,mstep:0,
 ensure(){
  if(this.ctx)return true;
  const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return false;
  const c=this.ctx=new AC();
  this.master=c.createGain();this.master.gain.value=.55;this.master.connect(c.destination);
  this.mus=c.createGain();this.mus.gain.value=.3;this.mus.connect(this.master);
  this.sfxG=c.createGain();this.sfxG.gain.value=.9;this.sfxG.connect(this.master);
  this.del=c.createDelay(1);this.del.delayTime.value=.29;
  const fb=c.createGain();fb.gain.value=.32;this.del.connect(fb);fb.connect(this.del);this.del.connect(this.mus);
  const b=c.createBuffer(1,c.sampleRate,c.sampleRate),d=b.getChannelData(0);
  for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1;this.noiseBuf=b;
  this.musT=c.currentTime+.2;
  setInterval(()=>this.musicTick(),110);
  return true;
 },
 tone(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.at||0);
  const osc=c.createOscillator();osc.type=o.type||'sine';osc.frequency.setValueAtTime(o.f0,t);
  if(o.f1)osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1),t+(o.dur||.2));
  const g=c.createGain();g.gain.setValueAtTime(0,t);
  g.gain.linearRampToValueAtTime(o.vol||.2,t+(o.a||.006));
  g.gain.exponentialRampToValueAtTime(.0001,t+(o.dur||.2));
  osc.connect(g);g.connect(o.music?this.mus:this.sfxG);
  if(o.echo)g.connect(this.del);
  osc.start(t);osc.stop(t+(o.dur||.2)+.1);
 },
 noise(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.at||0);
  const s=c.createBufferSource();s.buffer=this.noiseBuf;s.loop=true;
  const f=c.createBiquadFilter();f.type=o.ft||'bandpass';f.frequency.setValueAtTime(o.f0||800,t);
  if(o.f1)f.frequency.exponentialRampToValueAtTime(Math.max(20,o.f1),t+o.dur);f.Q.value=o.q||1;
  const g=c.createGain();g.gain.setValueAtTime(0,t);
  g.gain.linearRampToValueAtTime(o.vol||.2,t+(o.a||.005));
  g.gain.exponentialRampToValueAtTime(.0001,t+o.dur);
  s.connect(f);f.connect(g);g.connect(this.sfxG);s.start(t);s.stop(t+o.dur+.1);
 },
 foot(stone){this.noise({dur:.05,vol:.045,f0:stone?950:520,f1:stone?600:300,q:.7})},
 swing(){this.noise({dur:.13,vol:.11,f0:600,f1:2400,q:2})},
 swingFire(){this.noise({dur:.2,vol:.13,f0:300,f1:1900,q:1.4});this.tone({f0:200,f1:100,type:'sawtooth',dur:.18,vol:.045})},
 hit(){this.tone({f0:210,f1:90,type:'square',dur:.09,vol:.15});this.noise({dur:.06,vol:.11,f0:1900,q:1})},
 crit(){this.tone({f0:520,f1:170,type:'square',dur:.15,vol:.17})},
 hurt(){this.tone({f0:165,f1:52,type:'sawtooth',dur:.3,vol:.2});this.noise({dur:.16,vol:.12,f0:420,f1:140})},
 dodge(){this.noise({dur:.17,vol:.08,f0:400,f1:1700,q:1.2})},
 coin(){this.tone({f0:988,type:'square',dur:.06,vol:.09});this.tone({f0:1319,type:'square',dur:.16,vol:.09,at:.06})},
 pickup(){this.tone({f0:520,type:'triangle',dur:.08,vol:.13});this.tone({f0:784,type:'triangle',dur:.14,vol:.13,at:.07})},
 heartGet(){[523,659,784,1047].forEach((f,i)=>this.tone({f0:f,type:'triangle',dur:.24,vol:.15,at:i*.09}))},
 chest(){this.noise({dur:.22,vol:.07,f0:190,f1:120,ft:'lowpass'});this.tone({f0:660,type:'triangle',dur:.18,vol:.11,at:.16});this.tone({f0:880,type:'triangle',dur:.26,vol:.11,at:.28})},
 buy(){this.coin();this.tone({f0:2200,type:'triangle',dur:.06,vol:.06,at:.12})},
 deny(){this.tone({f0:140,f1:92,type:'square',dur:.2,vol:.11})},
 bow(){this.tone({f0:330,f1:140,type:'triangle',dur:.11,vol:.13});this.noise({dur:.05,vol:.05,f0:3000})},
 arrowHit(){this.noise({dur:.05,vol:.07,f0:2600,q:2})},
 door(){this.noise({dur:.7,vol:.19,f0:130,f1:55,ft:'lowpass'});this.tone({f0:72,f1:44,type:'sine',dur:.7,vol:.17})},
 crumble(){this.noise({dur:.5,vol:.24,f0:320,f1:70,ft:'lowpass'})},
 shrine(){[392,523,659,784].forEach((f,i)=>this.tone({f0:f,type:'sine',dur:.95,vol:.09,at:i*.12,echo:1,music:1}))},
 boom(){this.noise({dur:.55,vol:.28,f0:850,f1:55,ft:'lowpass'});this.tone({f0:95,f1:28,type:'sine',dur:.5,vol:.23})},
 roar(){this.tone({f0:120,f1:36,type:'sawtooth',dur:.95,vol:.23});this.noise({dur:.8,vol:.14,f0:520,f1:90})},
 burn(){this.noise({dur:.1,vol:.045,f0:1300,f1:380})},
 heal(){this.tone({f0:560,f1:880,type:'sine',dur:.32,vol:.1})},
 ui(){this.tone({f0:720,type:'triangle',dur:.05,vol:.05})},
 fanfare(){[392,392,392,523,659,784].forEach((f,i)=>this.tone({f0:f,type:'triangle',dur:i>3?.55:.13,vol:.14,at:i*.13,echo:1}))},
 musicTick(){
  if(!this.ctx)return;
  const c=this.ctx,scale=[220,261.6,293.7,329.6,392,440,523.3];
  while(this.musT<c.currentTime+.55){
   const at=Math.max(0,this.musT-c.currentTime),s=this.mstep;
   if(this.musicOn){
    if(s%16===0)this.tone({f0:s%32===0?110:82.4,type:'sine',dur:1.9,vol:.085,at,music:1});
    if(s%4===0&&Math.random()<.7){const f=scale[(Math.random()*scale.length)|0];
     this.tone({f0:f,type:'triangle',dur:.5,vol:.05,at,music:1,echo:1});}
   }
   this.musT+=.28;this.mstep++;
  }
 }
};

/* ---------------- game state ---------------- */
const G={
 state:'title', time:0, playTime:0, kills:0, shake:0, freeze:0,
 stage:0, slimes:0, respawn:{x:16.5*TS,y:52*TS},
 flags:{chests:{},doors:{},torches:{},wardenDead:false,guardianDead:false,bossDead:false,
        puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}},
 guardianUp:false, bossUp:false, winT:0
};
const cam={x:0,y:0};

/* ---------------- items & weapons ---------------- */
const WEAPONS=[
 {id:'rusty',name:'Rusty Sword',dmg:1,range:30,arc:1.9,cd:.34,blade:'#a89078',desc:'A worn blade. Better than fists.'},
 {id:'steel',name:'Steel Blade',dmg:2,range:38,arc:2.05,cd:.38,blade:'#d3dcea',desc:'Keen steel. +damage, +reach.'},
 {id:'flame',name:'Flame Blade',dmg:3,range:38,arc:2.2,cd:.5,blade:'#ffb347',fire:true,desc:'Slow but fierce. Sets foes alight.'}
];
const BOW={name:"Hunter's Bow",dmg:1,cd:.45};
const ITEMS={
 potion:{name:'Health Potion',icon:'🧪',type:'cons',desc:'Restores 4 HP. Sip responsibly.',price:15},
 bomb:{name:'Bomb',icon:'💣',type:'cons',desc:'Set it down, step away. Cracks cracked stone.',price:20},
 arrows:{name:'Arrows ×10',icon:'➶',type:'ammo',desc:'A bundle of ten arrows.',price:12},
 amulet:{name:'Iron Amulet',icon:'🛡️',type:'acc',desc:'Equipped: incoming damage −1 (min 1).',price:80},
 boots:{name:'Swift Boots',icon:'👢',type:'acc',desc:'Equipped: +18% movement speed.',price:60},
 charm:{name:'Lucky Charm',icon:'🍀',type:'acc',desc:'Equipped: +50% gold from coins, 12% crit chance.',price:0},
 firering:{name:'Fire Ring',icon:'💍',type:'acc',desc:'Equipped: immune to lava. Burns you inflict last longer.',price:0},
 rustykey:{name:'Rusty Key',icon:'🗝️',type:'key',desc:'Old iron. It yearns for the ruins gate.'},
};
const SHOP=['potion','arrows','bomb','amulet','boots'];

/* ---------------- player ---------------- */
const P={
 x:16.5*TS,y:52*TS,r:11,ang:Math.PI/2,dirX:0,dirY:1,
 hp:6,maxHp:6,containers:3,stamina:100,maxStamina:100,
 gold:20,arrows:0,weaponIdx:0,weaponsOwned:[true,false,false],bowOwned:false,
 inv:{},equipped:[],
 moving:false,anim:0,stepT:0,fatigued:false,
 iframes:0,hurtT:0,lavaT:0,
 atk:{on:false,t:0,dur:0,ang:0,did:false,combo:0,cd:0},
 bowCd:0, dodge:{on:false,t:0,ang:0}, dead:false, gone:false
};
function hasEq(id){return P.equipped.includes(id)}
function addItem(id,n=1){
 if(id==='gold'){giveGold(n);return}
 if(id==='arrows'){P.arrows+=n;toast(`+${n} Arrows`);AU.pickup();return}
 if(id==='heart'){
  if(P.containers<5){P.containers++;P.maxHp=P.containers*2;P.hp=P.maxHp;
   toast('❤ HEART CONTAINER — Max HP increased!');AU.heartGet();burst(P.x,P.y,26,'#ff6b81',1);
   if(P.containers===4)G.flags.heart1=true; if(P.containers===5)G.flags.heart2=true;}
  else{P.hp=P.maxHp;toast('Warm light fills you. HP restored.');AU.heal();}
  return;
 }
 if(id==='steelblade'){P.weaponsOwned[1]=true;P.weaponIdx=1;toast('⚔ STEEL BLADE obtained! Press Q to swap weapons.');AU.fanfare();burst(P.x,P.y,30,'#d3dcea',1);return}
 if(id==='flameblade'){P.weaponsOwned[2]=true;P.weaponIdx=2;toast('🔥 FLAME BLADE obtained! It hums with heat.');AU.fanfare();burst(P.x,P.y,30,'#ff9a3c',1);return}
 P.inv[id]=(P.inv[id]||0)+n;
 toast(`${ITEMS[id].icon} ${ITEMS[id].name} ×${n}`);AU.pickup();
}
function giveGold(n){P.gold+=n;AU.coin();floatText(P.x,P.y-20,'+'+n+'g','#ffd76a')}
function usePotion(){
 if((P.inv.potion||0)<1){toast('No potions left.');AU.deny();return}
 if(P.hp>=P.maxHp){toast('Already at full health.');return}
 P.inv.potion--;P.hp=Math.min(P.maxHp,P.hp+4);AU.heal();
 for(let i=0;i<8;i++)parts.push({x:P.x+rnd(-8,8),y:P.y+rnd(-6,10),vx:rnd(-15,15),vy:rnd(-50,-20),life:.8,t:0,c:'#7cfc90',sz:3,grav:0});
 floatText(P.x,P.y-22,'+4 HP','#7cfc90');
 if(G.state==='inv')renderInv();
}
function useBomb(){
 if((P.inv.bomb||0)<1){toast('No bombs. Bram sells them.');AU.deny();return}
 if(bombsA.length>2){toast('Too many live bombs!');return}
 P.inv.bomb--;AU.ui();
 bombsA.push({x:P.x+P.dirX*14,y:P.y+P.dirY*14,t:1.15});
 if(G.state==='inv')renderInv();
}
function toggleEquip(id){
 if(ITEMS[id].type!=='acc')return;
 const i=P.equipped.indexOf(id);
 if(i>=0){P.equipped.splice(i,1);AU.ui()}
 else{ if(P.equipped.length>=2){toast('Only two accessories fit.');AU.deny();return}
  P.equipped.push(id);AU.pickup()}
 renderInv();
}
function cycleWeapon(){
 const order=[0,1,2].filter(i=>P.weaponsOwned[i]);
 if(P.bowOwned)order.push(3);
 if(order.length<2){AU.deny();return}
 const cur=order.indexOf(P.weaponIdx);
 P.weaponIdx=order[(cur+1)%order.length];
 AU.ui();
 toast(P.weaponIdx===3?`🏹 ${BOW.name}`:`⚔ ${WEAPONS[P.weaponIdx].name}`);
}

/* ================= MAP ================= */
const T={GRASS:0,GRASS2:1,PATH:2,WATER:3,TREE:4,ROCK:5,FENCE:6,FLOOR:7,WALL:8,LAVA:9,CAVE:10,CAVEWALL:11,CRACK:12,DOOR:13};
const SOLIDSET=new Set([T.WATER,T.TREE,T.ROCK,T.FENCE,T.WALL,T.CAVEWALL,T.CRACK,T.DOOR]);
const map=new Uint8Array(MW*MH);
const ti=(x,y)=>y*MW+x;
function tAt(tx,ty){if(tx<0||ty<0||tx>=MW||ty>=MH)return T.TREE;return map[ti(tx,ty)]}
function setT(tx,ty,t){if(tx<0||ty<0||tx>=MW||ty>=MH)return;map[ti(tx,ty)]=t}
function fillR(x0,y0,x1,y1,t){for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)setT(x,y,t)}
function frameR(x0,y0,x1,y1,t){for(let x=x0;x<=x1;x++){setT(x,y0,t);setT(x,y1,t)}for(let y=y0;y<=y1;y++){setT(x0,y,t);setT(x1,y,t)}}

/* doors */
const doors=[
 {id:'ruinsGate',tiles:[[64,44],[64,45]],open:false,key:'rustykey',msg:'Locked. A rusty keyhole stares back.'},
 {id:'arenaDoor',tiles:[[81,41],[82,41]],open:false},
 {id:'vaultDoor',tiles:[[81,25],[82,25]],open:false},
 {id:'bossGate',tiles:[[38,5],[38,6]],open:false},
];
const doorGrid={};
function doorAt(tx,ty){return doorGrid[tx+','+ty]}
function buildDoorGrid(){for(const k in doorGrid)delete doorGrid[k];
 for(const d of doors)for(const [x,y] of d.tiles)doorGrid[x+','+y]=d}
function isSolid(tx,ty){
 const t=tAt(tx,ty);
 if(t===T.DOOR){const d=doorAt(tx,ty);return !d.open}
 return SOLIDSET.has(t);
}
function openDoor(id,silent){
 const d=doors.find(o=>o.id===id);if(!d||d.open)return;
 d.open=true;G.flags.doors[id]=true;
 if(!silent){AU.door();G.shake=Math.max(G.shake,6);toast('The way opens…')}
 for(const [x,y] of d.tiles)patchTile(x,y);
}

function buildMap(){
 const R=mulberry32(20240613);
 map.fill(T.GRASS);
 // grass variation & flowers
 for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
  const r=R();
  if(r<.09)setT(x,y,T.GRASS2);
 }
 // mountain (north)
 fillR(24,0,94,18,T.CAVEWALL);
 // cave interior carve
 fillR(46,12,55,17,T.CAVE);                    // entrance hall
 fillR(39,3,62,10,T.CAVE);                     // great hall
 fillR(63,4,68,9,T.CAVE);                      // alcove
 fillR(27,2,37,11,T.CAVE);                     // boss room
 fillR(42,4,55,9,T.LAVA);                      // lava lake
 fillR(46,6,48,6,T.CAVE);                      // island A (flame chest)
 fillR(51,5,53,5,T.CAVE);                      // island B (heart chest)
 setT(50,18,T.CRACK);setT(51,18,T.CRACK);      // cave entrance crack
 // ruins
 fillR(65,21,93,57,T.FLOOR);
 frameR(64,20,94,58,T.WALL);
 fillR(65,25,93,25,T.WALL);                    // vault wall
 fillR(65,41,93,41,T.WALL);                    // arena wall
 // village
 fillR(9,45,29,59,T.PATH);
 frameR(8,44,30,60,T.FENCE);
 fillR(12,47,16,50,T.WALL);                    // house 1
 fillR(23,51,26,51,T.WALL);                    // shop counter
 fillR(22,47,27,50,T.WALL);                    // house 2 / shop hut
 setT(18,44,T.PATH);setT(19,44,T.PATH);        // north gate
 setT(30,52,T.PATH);setT(30,53,T.PATH);        // east gate
 setT(8,50,T.PATH);setT(8,51,T.PATH);          // west gate
 // lake
 fillR(33,44,37,48,T.WATER);
 // paths
 fillR(18,40,19,44,T.PATH);                    // village → fields
 fillR(32,49,60,50,T.PATH);                    // fields road
 fillR(58,44,64,45,T.PATH);                    // → ruins gate
 fillR(4,50,8,51,T.PATH);                      // → forest
 fillR(15,30,16,50,T.PATH);                    // forest trail north
 fillR(50,19,51,44,T.PATH);                    // cliff trail
 fillR(46,44,55,44,T.PATH);
 // forest trees
 for(let y=6;y<=56;y++)for(let x=3;x<=29;x++){
  if(tAt(x,y)!==T.GRASS&&tAt(x,y)!==T.GRASS2)continue;
  if(R()<.27)setT(x,y,T.TREE);
 }
 // clearings in forest
 const clear=(cx,cy,r)=>{for(let y=cy-r;y<=cy+r;y++)for(let x=cx-r;x<=cx+r;x++){
   if(dist(x,y,cx,cy)<=r&&tAt(x,y)===T.TREE)setT(x,y,T.GRASS)}};
 clear(16,30,5);clear(16,44,3);clear(25,33,2);clear(6,12,3);clear(11,19,2);clear(22,24,2);
 // grove (hidden, NW): walls of trees + crack entrance
 fillR(3,8,9,16,T.GRASS);
 for(let y=8;y<=16;y++)setT(10,y,T.TREE);
 setT(10,11,T.CRACK);setT(10,12,T.CRACK);
 fillR(3,8,9,8,T.TREE);fillR(3,16,9,16,T.TREE);
 for(let x=3;x<=9;x++){if(tAt(x,8)===T.GRASS)setT(x,8,T.TREE);if(tAt(x,16)===T.GRASS)setT(x,16,T.TREE)}
 // scatter rocks in fields & forest edges
 for(let y=20;y<=60;y++)for(let x=31;x<=63;x++){
  if(tAt(x,y)===T.GRASS&&R()<.018)setT(x,y,T.ROCK);
 }
 for(let x=24;x<=94;x++)if(R()<.35&&tAt(x,19)===T.GRASS)setT(x,19,T.ROCK);
 // doors on map
 for(const d of doors)for(const [x,y] of d.tiles)setT(x,y,T.DOOR);
 // world border
 fillR(0,0,MW-1,1,T.TREE);fillR(0,MH-2,MW-1,MH-1,T.TREE);
 fillR(0,0,1,MH-1,T.TREE);fillR(MW-2,0,MW-1,MH-1,T.TREE);
 buildDoorGrid();
}

/* ---------------- base terrain pre-render ---------------- */
let base=null;
function drawTileBase(g,tx,ty){
 const t=tAt(tx,ty),x=tx*TS,y=ty*TS,h=hash2(tx,ty);
 const px=(fx,fy,w,hh,c)=>{g.fillStyle=c;g.fillRect(x+fx,y+fy,w,hh)};
 switch(t){
  case T.GRASS:case T.GRASS2:
   px(0,0,TS,TS,'#3d7a46');
   for(let i=0;i<4;i++){const hx=hash2(tx*7+i,ty*3),hy=hash2(tx,ty*11+i);
    px(hx*28,hy*28,3,3,hx>.5?'#35703e':'#468a50')}
   if(t===T.GRASS2){px(6+h*16,6+hash2(ty,tx)*16,3,3,h>.5?'#e7b7d3':'#f0e28a');
    px(4+h*18,18,2,2,'#2f6338')}
   break;
  case T.PATH:
   px(0,0,TS,TS,'#b09a6a');
   for(let i=0;i<3;i++){const hx=hash2(tx*5+i,ty*7),hy=hash2(tx*3,ty*5+i);
    px(hx*26,hy*26,4,3,'#a08a5c')}
   px(h*24,h*24|0,3,2,'#c0ab7c');
   break;
  case T.WATER: px(0,0,TS,TS,'#39649e');break;
  case T.TREE:
   px(0,0,TS,TS,'#35703e');
   px(12,14,8,14,'#5d4028');px(13,14,3,14,'#6f4e31');
   break;
  case T.ROCK:
   px(0,0,TS,TS,'#3d7a46');
   g.fillStyle='#7e8591';g.beginPath();g.ellipse(x+16,y+18,13,11,0,0,7);g.fill();
   g.fillStyle='#99a0ac';g.beginPath();g.ellipse(x+13,y+14,7,5,0,0,7);g.fill();
   break;
  case T.FENCE:
   px(0,0,TS,TS,'#3d7a46');
   px(4,12,24,4,'#7a5a36');px(4,22,24,4,'#7a5a36');
   px(2,8,5,20,'#8a683f');px(25,8,5,20,'#8a683f');
   break;
  case T.FLOOR:
   px(0,0,TS,TS,'#8f8f98');
   g.strokeStyle='#77777f';g.strokeRect(x+.5,y+.5,TS-1,TS-1);
   if(h>.7)px(8,8,4,3,'#83838c');
   break;
  case T.WALL:
   px(0,0,TS,TS,'#5c5f6d');
   g.strokeStyle='#484b56';g.lineWidth=1;
   g.strokeRect(x+.5,y+.5,TS-1,TS-1);
   g.beginPath();g.moveTo(x,y+16);g.lineTo(x+TS,y+16);g.stroke();
   g.beginPath();g.moveTo(x+(h>.5?10:22),y);g.lineTo(x+(h>.5?10:22),y+16);
   g.moveTo(x+(h>.5?22:10),y+16);g.lineTo(x+(h>.5?22:10),y+TS);g.stroke();
   px(2,2,4,3,'#6d7080');
   break;
  case T.LAVA: px(0,0,TS,TS,'#7a2010');break;
  case T.CAVE:
   px(0,0,TS,TS,'#4a4550');
   for(let i=0;i<3;i++){const hx=hash2(tx*3+i,ty*9),hy=hash2(tx*9,ty*3+i);
    px(hx*27,hy*27,3,3,'#423d48')}
   break;
  case T.CAVEWALL:
   px(0,0,TS,TS,'#2e3240');
   g.strokeStyle='#232633';g.strokeRect(x+.5,y+.5,TS-1,TS-1);
   px(4+h*16,4+hash2(ty,tx)*16,6,4,'#383d4e');
   break;
  case T.CRACK: break; // painted after the switch
  case T.DOOR:
   // floor under door
   px(0,0,TS,TS,ty<19?'#4a4550':'#8f8f98');
   break;
 }
 if(t===T.CRACK){
  const inCave=ty<19,inRuins=tx>=63;
  const baseC=inCave?'#2e3240':inRuins?'#5c5f6d':'#556b52';
  g.fillStyle=baseC;g.fillRect(x,y,TS,TS);
  g.strokeStyle='rgba(10,10,14,.85)';g.lineWidth=2;
  g.beginPath();
  g.moveTo(x+6,y+2);g.lineTo(x+14,y+12);g.lineTo(x+9,y+22);g.lineTo(x+16,y+30);
  g.moveTo(x+24,y+4);g.lineTo(x+18,y+14);g.lineTo(x+26,y+26);
  g.stroke();
 }
}
function buildBase(){
 base=document.createElement('canvas');base.width=MW*TS;base.height=MH*TS;
 const g=base.getContext('2d');
 for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++)drawTileBase(g,tx,ty);
}
function patchTile(tx,ty){
 if(!base)return;
 const g=base.getContext('2d');
 g.save();g.beginPath();g.rect(tx*TS,ty*TS,TS,TS);g.clip();drawTileBase(g,tx,ty);g.restore();
}

/* ================= WORLD ENTITIES ================= */
const chests=[
 {id:'c_field', tx:59,ty:43,items:[['potion',1],['gold',8]]},
 {id:'c_forest',tx:25,ty:33,items:[['gold',15],['arrows',6]]},
 {id:'c_grove', tx:6, ty:12,items:[['heart',1],['charm',1]]},
 {id:'c_blade', tx:68,ty:54,items:[['steelblade',1]]},
 {id:'c_ring',  tx:66,ty:6, items:[['firering',1]]},
 {id:'c_heart2',tx:52,ty:5, items:[['heart',1]]},
 {id:'c_flame', tx:47,ty:6, items:[['flameblade',1]]},
];
for(const c of chests){c.x=c.tx*TS+16;c.y=c.ty*TS+16;c.opened=false}

const shrines=[
 {id:'s_village',x:28.5*TS,y:58.5*TS,name:"Village Shrine"},
 {id:'s_ruins',  x:66.5*TS,y:47.5*TS,name:"Ruins Shrine"},
 {id:'s_cave',   x:47.5*TS,y:15.5*TS,name:"Cave Shrine"},
];
const pedestal={x:82.5*TS,y:22.5*TS,taken:false};
const tablets=[
 {x:18.5*TS,y:46.5*TS,text:"«EMBER VALE — fields east, woods west. Mind the mountain.»"},
 {x:72.5*TS,y:44.5*TS,text:"«WEIGHT UNLOCKS THE WAY. Set wandering stones upon the three sigils.»"},
 {x:49.5*TS,y:13.5*TS,text:"«Four cold braziers drink the light of arrows. Wake them, and the gate yields.»"},
];

const npcs=[
 {id:'elder',name:'Elder Rowan',x:15.5*TS,y:53.5*TS,c:'#8a6bb5'},
 {id:'bram', name:'Merchant Bram',x:24.5*TS,y:52.8*TS,c:'#b58a4b',shop:true},
 {id:'mira', name:'Mira',x:11.5*TS,y:56.5*TS,c:'#5ba0a8'},
];
const dummy={x:26.5*TS,y:57.5*TS,hp:99,wob:0};

const platesA=[[80,47],[88,47],[84,53]].map(p=>({tx:p[0],ty:p[1],on:false}));
const blocksA=[[83,50],[86,51],[80,52]].map(b=>({tx:b[0],ty:b[1],x:b[0]*TS+16,y:b[1]*TS+16,moving:false}));
const torchesA=[[39,4],[39,9],[44,6],[53,8]].map(t=>({tx:t[0],ty:t[1],x:t[0]*TS+16,y:t[1]*TS+16,lit:false}));

/* ---------------- enemy defs & spawning ---------------- */
const EDEFS={
 slime:{hp:2,spd:55,r:11,dmg:1,g:[2,4],xp:1},
 bat:{hp:1,spd:88,r:8,dmg:1,g:[1,2]},
 skel:{hp:3,spd:58,r:12,dmg:2,g:[3,6]},
 archer:{hp:3,spd:55,r:11,dmg:1,g:[4,6]},
 wisp:{hp:1,spd:115,r:7,dmg:2,g:[1,2]},
 warden:{hp:16,spd:42,r:20,dmg:2,g:[20,26]},
 guardian:{hp:26,spd:40,r:22,dmg:2,g:[40,48]},
 boss:{hp:90,spd:62,r:24,dmg:2,g:[100,120]},
};
const SPAWNS=[
 // fields
 {t:'slime',x:36,y:46},{t:'slime',x:42,y:52},{t:'slime',x:48,y:44},
 {t:'slime',x:54,y:50},{t:'slime',x:45,y:57},{t:'slime',x:58,y:54},
 // forest
 {t:'slime',x:20,y:47},{t:'slime',x:24,y:38},{t:'bat',x:12,y:36},
 {t:'bat',x:18,y:26},{t:'bat',x:24,y:19},
 // ruins
 {t:'skel',x:70,y:50},{t:'skel',x:72,y:46},{t:'skel',x:84,y:44},
 {t:'archer',x:76,y:54},{t:'archer',x:88,y:46},
 // cave
 {t:'skel',x:41,y:9},{t:'skel',x:60,y:9},{t:'bat',x:50,y:8},{t:'bat',x:55,y:12},
];
let enemies=[],projs=[],pickups=[],parts=[],floaters=[],bombsA=[];

function spawnEnemy(t,x,y,extra){
 const d=EDEFS[t];
 const e={t,x,y,r:d.r,hp:d.hp,maxHp:d.hp,spd:d.spd,dmg:d.dmg,g:d.g,
  st:'idle',t0:0,ang:rnd(0,6.28),flash:0,kx:0,ky:0,burn:0,burnT:0,dead:false,
  home:{x,y},wob:rnd(0,9),shootT:rnd(0,1),extra:extra||{}};
 enemies.push(e);return e;
}
function respawnAll(){
 enemies=[];
 for(const s of SPAWNS)spawnEnemy(s.t,s.x*TS+16,s.y*TS+16);
 if(!G.flags.wardenDead)spawnEnemy('warden',16*TS+16,30*TS+16);
 G.guardianUp=false;G.bossUp=false;
}

/* ---------------- quests & dialogue ---------------- */
const QUESTS=[
 "Speak with Elder Rowan in the village.",
 "Cull the slimes in the eastern fields. (0/8)",
 "Return to Elder Rowan for your reward.",
 "West forest: fell the mad Grove Warden and take its key.",
 "Open the Ruins Gate (east) with the Rusty Key.",
 "Ruins: set stones on sigils, then face the Stone Guardian.",
 "Crack open the mountain path (north, bombs) and climb inside.",
 "Light the four braziers, then end the Ash King.",
 "The vale is free. Breathe.",
];
function setStage(n){
 if(n<G.stage)return;
 G.stage=n;
 const el=document.getElementById('questText');
 el.textContent=QUESTS[Math.min(n,QUESTS.length-1)];
 if(n===1)el.textContent=`Cull the slimes in the eastern fields. (${G.slimes}/8)`;
}
function updateSlimeQuest(){
 if(G.stage===1){document.getElementById('questText').textContent=`Cull the slimes in the eastern fields. (${G.slimes}/8)`;
  if(G.slimes>=8){setStage(2);toast('Quest updated — return to Elder Rowan.')}}
}

let dlg=null; // {name,lines,i,chars,done,after}
function showDialogue(name,lines,after){
 dlg={name,lines,i:0,chars:0,done:false,after};
 G.state='dialog';
 document.getElementById('dlg').classList.remove('hidden');
 document.getElementById('dlgName').textContent=name;
 document.getElementById('dlgText').textContent='';
 AU.ui();
}
function dlgAdvance(){
 if(!dlg)return;
 const line=dlg.lines[dlg.i];
 if(dlg.chars<line.length){dlg.chars=line.length;renderDlg();return}
 dlg.i++;
 if(dlg.i>=dlg.lines.length){const a=dlg.after;dlg=null;
  document.getElementById('dlg').classList.add('hidden');
  G.state='play';if(a)a();}
 else{dlg.chars=0;AU.ui()}
 renderDlg();
}
function renderDlg(){if(dlg)document.getElementById('dlgText').textContent=dlg.lines[dlg.i].slice(0,dlg.chars)}
function dlgTick(dt){if(dlg&&!dlg.done&&dlg.chars<dlg.lines[dlg.i].length){dlg.chars+=dt*60;renderDlg()}}

function elderDialogue(){
 if(G.stage===0){
  showDialogue('Elder Rowan',[
   "Ah. You're awake at last, traveler. The vale has waited long for a sword arm.",
   "Slimes choke the eastern fields — fat green things, eight at least. Thin them out.",
   "Come back when it's done. The vale pays its debts."],
   ()=>{setStage(1)});
 }else if(G.stage===1){
  showDialogue('Elder Rowan',[`Still squelching out there, lad. (${G.slimes}/8 culled)`]);
 }else if(G.stage===2){
  showDialogue('Elder Rowan',[
   "Eight slimes, culled to jelly! The vale thanks you — take this purse.",
   "Now hear me: something in the west forest calls itself the Grove Warden. It swallowed the Rusty Key to the old ruins.",
   "Cut it out of him. The ruins hold weapons we'll need before this winter ends."],
   ()=>{giveGold(40);setStage(3)});
 }else if(G.stage===3){
  showDialogue('Elder Rowan',["The Warden haunts the deep west forest. Cracked stone yields to bombs, if you need a way through."]);
 }else if(G.stage<=5){
  showDialogue('Elder Rowan',["The ruins gate stands east of the fields. Mind the stones inside — they bite."]);
 }else{
  showDialogue('Elder Rowan',["The Ash King woke on the north peak. Pray at the shrines, traveler — they remember you."]);
 }
}
function miraDialogue(){
 const lines=[["Cracked walls crumble under bombs. Bram keeps stock, the old vulture.",
  "There's a grove in the forest's northwest corner — walled in by trees. I'd bet my boots something sleeps there.",
  "The ruins' floor-plates crave heavy things. Stones, I mean. Push them about."]];
 showDialogue('Mira',lines[0]);
}
function npcTalk(n){
 if(n.id==='elder')elderDialogue();
 else if(n.id==='mira')miraDialogue();
 else if(n.id==='bram'){
  showDialogue('Merchant Bram',["Gold buys everything but courage. Let's see what you can afford."],()=>openShop());
 }
}

/* ---------------- save / load ---------------- */
const SAVEKEY='embervale_save_v1';
function saveGame(shrine){
 try{
  const data={
   v:1,stage:G.stage,slimes:G.slimes,kills:G.kills,playTime:G.playTime,
   flags:{...G.flags,doors:{...G.flags.doors},chests:{...G.flags.chests},
          torches:{...G.flags.torches},shrineIds:{...G.flags.shrineIds}},
   p:{x:P.x,y:P.y,hp:P.hp,maxHp:P.maxHp,containers:P.containers,gold:P.gold,arrows:P.arrows,
      weaponIdx:P.weaponIdx,weaponsOwned:[...P.weaponsOwned],bowOwned:P.bowOwned,
      inv:{...P.inv},equipped:[...P.equipped]},
   pedestal:pedestal.taken,respawn:{...G.respawn}
  };
  localStorage.setItem(SAVEKEY,JSON.stringify(data));
  if(shrine)toast('✦ Progress saved — the shrine remembers.');
 }catch(e){/* private mode etc. */}
}
function hasSave(){try{return !!localStorage.getItem(SAVEKEY)}catch(e){return false}}
function loadGame(){
 try{
  const d=JSON.parse(localStorage.getItem(SAVEKEY));if(!d)return false;
  G.stage=0;G.slimes=d.slimes||0;G.kills=d.kills||0;G.playTime=d.playTime||0;
  G.flags={chests:{},doors:{},torches:{},cracks:[],wardenDead:false,guardianDead:false,bossDead:false,
   puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}};
  Object.assign(G.flags,d.flags);
  buildWorld();
  // restore cracked tiles
  for(const [cx,cy] of (G.flags.cracks||[])){
   setT(cx,cy,cy<19?T.CAVE:(cx>=63?T.FLOOR:T.GRASS));patchTile(cx,cy);
  }
  const p=d.p;
  P.x=p.x;P.y=p.y;P.hp=p.hp;P.maxHp=p.maxHp;P.containers=p.containers;
  P.gold=p.gold;P.arrows=p.arrows;P.weaponIdx=p.weaponIdx;
  P.weaponsOwned=p.weaponsOwned;P.bowOwned=p.bowOwned;
  P.inv=p.inv||{};P.equipped=p.equipped||[];
  G.respawn=d.respawn||{x:16.5*TS,y:52*TS};
  pedestal.taken=d.pedestal;
  for(const c of chests)c.opened=!!G.flags.chests[c.id];
  for(const tt of torchesA)tt.lit=!!G.flags.torches[tt.tx+','+tt.ty];
  for(const dd of doors)dd.open=!!G.flags.doors[dd.id];
  if(G.flags.puzzle){
   const pos=[[80,47],[88,47],[84,53]];
   blocksA.forEach((b,i)=>{b.tx=pos[i][0];b.ty=pos[i][1];b.x=b.tx*TS+16;b.y=b.ty*TS+16;b.moving=false});
   for(const p of platesA)p.on=true;
  }
  setStage(d.stage||0);
  P.dead=false;P.gone=false;
  cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
  return true;
 }catch(e){return false}
}

/* ================= FX HELPERS ================= */
function burst(x,y,n,c,pow=1){
 for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(30,140)*pow;
  parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(.3,.7),t:0,c,sz:rnd(1.5,3.5),grav:120})}
}
function sparks(x,y,n,c){
 for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(60,220);
  parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(.12,.3),t:0,c,sz:rnd(1,2.5),grav:0})}
}
function floatText(x,y,txt,c){floaters.push({x,y,txt,c,t:0})}
function toast(msg){
 const el=document.createElement('div');el.className='toastMsg';el.textContent=msg;
 const t=document.getElementById('toast');t.appendChild(el);
 while(t.children.length>3)t.removeChild(t.firstChild);
 setTimeout(()=>el.remove(),3500);
}
function hurtFlash(){const h=document.getElementById('hurtFx');h.style.opacity=1;setTimeout(()=>h.style.opacity=0,120)}
function dropCoins(x,y,total){
 let n=Math.min(10,Math.max(1,Math.round(total/2)));
 for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(20,80);
  pickups.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-40,type:'coin',val:Math.max(1,Math.round(total/n)),t:0})}
}
function dropPickup(x,y,type,val){pickups.push({x,y,vx:rnd(-30,30),vy:rnd(-60,-20),type,val,t:0})}

/* ================= COMBAT ================= */
function tryAttack(){
 if(P.dodge.on||P.dead)return;
 if(P.weaponIdx===3){shootArrow();return}
 const w=WEAPONS[P.weaponIdx];
 if(P.atk.on||P.atk.cd>0)return;
 P.atk.on=true;P.atk.t=0;P.atk.dur=w.cd;P.atk.ang=P.ang;P.atk.did=false;
 P.atk.combo=(P.atk.combo+1)%3;
 if(w.fire)AU.swingFire();else AU.swing();
 P.stamina=Math.max(0,P.stamina-3);
}
function meleeHit(){
 const w=WEAPONS[P.weaponIdx];
 const reach=w.range+ (hasEq('charm')?0:0);
 let hitAny=false;
 // enemies
 for(const e of enemies){
  if(e.dead)continue;
  const d=dist(P.x,P.y,e.x,e.y);
  if(d<reach+e.r&&Math.abs(angDiff(P.atk.ang,Math.atan2(e.y-P.y,e.x-P.x)))<w.arc/2+.3){
   let dmg=w.dmg,crit=false;
   if(hasEq('charm')&&Math.random()<.12){dmg*=2;crit=true}
   damageEnemy(e,dmg,P.atk.ang,crit);
   if(w.fire){e.burn=hasEq('firering')?6:3.6;e.burnT=0;AU.burn()}
   hitAny=true;
  }
 }
 // torches
 for(const tt of torchesA){
  if(tt.lit)continue;
  if(dist(P.x,P.y,tt.x,tt.y)<reach+12&&Math.abs(angDiff(P.atk.ang,Math.atan2(tt.y-P.y,tt.x-P.x)))<w.arc/2+.4)
   lightTorch(tt);
 }
 // dummy
 if(dist(P.x,P.y,dummy.x,dummy.y)<reach+12){dummy.wob=1;sparks(dummy.x,dummy.y,4,'#c8b790');AU.hit();hitAny=true}
 if(hitAny){G.freeze=Math.max(G.freeze,.05)}
 // swing particles
 const pa=P.atk.ang;
 for(let i=0;i<3;i++){const a=pa+rnd(-.6,.6);
  parts.push({x:P.x+Math.cos(a)*reach*.8,y:P.y+Math.sin(a)*reach*.8,vx:Math.cos(a)*60,vy:Math.sin(a)*60,
   life:.18,t:0,c:w.fire?'#ffb347':'#e8e8f0',sz:2,grav:0})}
}
function shootArrow(){
 if(P.bowCd>0||P.dodge.on)return;
 if(P.arrows<1){toast('No arrows — buy some from Bram.');AU.deny();return}
 if(P.stamina<8){toast('Too winded…');return}
 P.arrows--;P.stamina-=8;P.bowCd=BOW.cd;
 AU.bow();
 projs.push({x:P.x+Math.cos(P.ang)*14,y:P.y+Math.sin(P.ang)*14,
  vx:Math.cos(P.ang)*430,vy:Math.sin(P.ang)*430,dmg:BOW.dmg,from:'player',type:'arrow',life:1.6});
}
function damageEnemy(e,dmg,ang,crit){
 e.hp-=dmg;e.flash=.15;
 e.kx+=Math.cos(ang)*140;e.ky+=Math.sin(ang)*140;
 sparks(e.x,e.y,6,crit?'#ffd76a':'#ffffff');
 floatText(e.x,e.y-e.r-6,crit?dmg+'!':''+dmg,crit?'#ffd76a':'#ffffff');
 if(crit)AU.crit();else AU.hit();
 if(e.hp<=0)killEnemy(e);
 else if(e.t==='dummy'){}
 else if(e.st!=='lunge')e.st='chase';
}
function killEnemy(e){
 if(e.dead)return;e.dead=true;G.kills++;
 burst(e.x,e.y,12,e.t==='wisp'?'#ffb347':e.t==='bat'?'#8a7ba8':'#9fdc7f');
 AU.pickup&&AU.noise({dur:.15,vol:.07,f0:900,f1:200});
 let gold=irnd(e.g[0],e.g[1]);
 if(hasEq('charm'))gold=Math.round(gold*1.5);
 dropCoins(e.x,e.y,gold);
 if(e.t==='bat'&&Math.random()<.5)dropPickup(e.x,e.y,'arrows',2);
 if(e.t==='archer'&&Math.random()<.7)dropPickup(e.x,e.y,'arrows',3);
 if(Math.random()<.08)dropPickup(e.x,e.y,'potion',1);
 if(e.t==='slime'){G.slimes++;updateSlimeQuest()}
 if(e.t==='warden'){
  G.flags.wardenDead=true;dropPickup(e.x,e.y,'key',1);
  toast('🗝️ The Warden drops a RUSTY KEY!');AU.fanfare();
  burst(e.x,e.y,30,'#7cfc90',1.4);
  if(G.stage<4)setStage(4);
  saveGame(false);
 }
 if(e.t==='guardian'){
  G.flags.guardianDead=true;
  toast('The sealed vault grinds open somewhere north…');
  openDoor('vaultDoor',true);AU.door();
  burst(e.x,e.y,40,'#aab4c8',1.5);
  saveGame(false);
 }
 if(e.t==='boss'){
  G.flags.bossDead=true;
  burst(e.x,e.y,60,'#ffb347',2);burst(e.x,e.y,40,'#e5484d',1.6);
  AU.boom();G.shake=14;
  openDoor('bossGate',true);
  setStage(8);G.winT=2.2;
  saveGame(false);
 }
}
function hurtPlayer(d,fromAng){
 if(P.iframes>0||P.dodge.on||P.dead)return;
 if(hasEq('amulet'))d=Math.max(1,d-1);
 P.hp-=d;P.iframes=1.0;P.hurtT=.4;
 P.x+=Math.cos(fromAng)*10;P.y+=Math.sin(fromAng)*10;
 AU.hurt();G.shake=Math.max(G.shake,7);hurtFlash();
 floatText(P.x,P.y-24,'-'+d,'#ff6b6b');
 burst(P.x,P.y,8,'#e5484d',.7);
 if(P.hp<=0){P.hp=0;diePlayer()}
}
function diePlayer(){
 P.dead=true;P.gone=true;
 burst(P.x,P.y,30,'#e5484d',1.2);AU.roar();G.shake=10;
 setTimeout(()=>{if(G.state==='play'||G.state==='dead'){G.state='dead';
  document.getElementById('death').classList.remove('hidden')}},1000);
}
function respawnPlayer(){
 document.getElementById('death').classList.add('hidden');
 P.dead=false;P.gone=false;P.hp=P.maxHp;P.stamina=P.maxStamina;P.iframes=2;
 P.gold=Math.floor(P.gold*.8);
 P.x=G.respawn.x;P.y=G.respawn.y;
 respawnAll();
 G.state='play';
 toast('You wake at the shrine. The vale waits.');
 cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
}

/* ================= INTERACTION ================= */
function nearestInteract(){
 let best=null,bd=52;
 const consider=(x,y,obj,label)=>{const d=dist(P.x,P.y,x,y);if(d<bd){bd=d;best={x,y,obj,label}}};
 for(const c of chests)if(!c.opened)consider(c.x,c.y,c,'Open chest');
 for(const n of npcs)consider(n.x,n.y,n,'Talk');
 for(const s of shrines)consider(s.x,s.y,s,'Pray & Save');
 for(const tb of tablets)consider(tb.x,tb.y,tb,'Read');
 if(!pedestal.taken&&G.flags.guardianDead)consider(pedestal.x,pedestal.y,pedestal,'Take the Bow');
 for(const d of doors)if(!d.open&&d.key)for(const [tx,ty] of d.tiles)consider(tx*TS+16,ty*TS+16,d,'Unlock');
 return best;
}
function tryInteract(){
 const it=nearestInteract();
 if(!it){ // generic poke
  return;
 }
 const o=it.obj;
 if(o.items){ // chest
  o.opened=true;G.flags.chests[o.id]=true;AU.chest();
  burst(o.x,o.y,14,'#ffd76a');
  for(const [id,n] of o.items)addItem(id,n);
  if(o.id==='c_grove')setStage(Math.max(G.stage,5));
  if(o.id==='c_blade'){}
  saveGame(false);
 }else if(o.id&&o.tiles&&o.key){ // locked door
  if((P.inv[o.key]||0)>0||o.key==='rustykey'&&P.inv.rustykey){
   openDoor(o.id);G.flags.keyUsed=true;
   toast('The Rusty Key turns. The gate groans open.');
   setStage(5);
  }else toast(o.msg||'Locked.');
 }else if(o.shop){ npcTalk(o) }
 else if(o.name&&o.lines===undefined&&npcs.includes(o)){ npcTalk(o) }
 else if(o.text){ showDialogue('Weathered tablet',[o.text]) }
 else if(o===pedestal){
  pedestal.taken=true;G.flags.bowTaken=true;
  P.bowOwned=true;P.arrows+=15;
  AU.fanfare();burst(pedestal.x,pedestal.y,26,'#8fd3ff',1.2);
  toast("🏹 HUNTER'S BOW obtained! Press Q to swap. Arrows +15.");
  showDialogue('Engraved plaque',["To the guardian's slayer: the hunter's bow.","Let arrows find what the blade cannot. The mountain path north is next — bombs open what stone hides."],
   ()=>{setStage(6)});
 }else if(shrines.includes(o)){
  G.respawn={x:o.x,y:o.y};
  P.hp=P.maxHp;P.stamina=P.maxStamina;AU.shrine();AU.heal();
  G.flags.shrineIds[o.id]=true;
  for(let i=0;i<20;i++)parts.push({x:o.x+rnd(-14,14),y:o.y+rnd(-6,10),vx:rnd(-8,8),vy:rnd(-60,-30),life:rnd(.6,1.2),t:0,c:'#8fd3ff',sz:2.5,grav:-20});
  saveGame(true);
 }
}
function openShop(){
 G.state='shop';
 document.getElementById('shopPanel').classList.remove('hidden');
 renderShop();AU.ui();
}
function closeShop(){
 document.getElementById('shopPanel').classList.add('hidden');
 G.state='play';
}
function renderShop(){
 document.getElementById('shopGold').innerHTML=`Your gold: <b style="color:#ffd76a">${P.gold}</b>`;
 const list=document.getElementById('shopList');list.innerHTML='';
 for(const id of SHOP){
  const it=ITEMS[id];
  const row=document.createElement('div');row.className='wareRow';
  row.innerHTML=`<div class="ico">${it.icon}</div><div class="nm">${it.name}<small>${it.desc}</small></div>`;
  const b=document.createElement('button');b.className='buyBtn';b.textContent=it.price+'g';
  b.disabled=P.gold<it.price;
  b.onclick=()=>{
   if(P.gold<it.price){AU.deny();return}
   P.gold-=it.price;addItem(id,1);AU.buy();renderShop();
  };
  row.appendChild(b);list.appendChild(row);
 }
}
function openInv(){
 G.state='inv';document.getElementById('inv').classList.remove('hidden');renderInv();AU.ui();
}
function closeInv(){document.getElementById('inv').classList.add('hidden');G.state='play'}
function renderInv(){
 document.getElementById('invRes').innerHTML=
  `Gold: <b style="color:#ffd76a">${P.gold}</b> &nbsp;·&nbsp; Arrows: <b>${P.arrows}</b> &nbsp;·&nbsp; HP: <b>${P.hp}/${P.maxHp}</b>`;
 const grid=document.getElementById('invGrid');grid.innerHTML='';
 const entries=[];
 for(let i=0;i<P.weaponsOwned.length;i++)if(P.weaponsOwned[i])
  entries.push({kind:'weapon',idx:i,name:WEAPONS[i].name,icon:['🗡️','⚔️','🔥'][i],desc:WEAPONS[i].desc});
 if(P.bowOwned)entries.push({kind:'weapon',idx:3,name:BOW.name,icon:'🏹',desc:'Ranged. Uses arrows.'});
 for(const id in P.inv)if(P.inv[id]>0&&ITEMS[id])
  entries.push({kind:'item',id,name:ITEMS[id].name,icon:ITEMS[id].icon,desc:ITEMS[id].desc,cnt:P.inv[id]});
 if(P.inv&&P.inv.rustykey)0;
 while(entries.length<12)entries.push(null);
 for(const en of entries){
  const s=document.createElement('div');
  if(!en){s.className='slot empty';grid.appendChild(s);continue}
  s.className='slot';
  if(en.kind==='weapon'&&P.weaponIdx===en.idx)s.classList.add('wsel');
  s.innerHTML=`<span>${en.icon}</span>`+(en.cnt?`<span class="cnt">×${en.cnt}</span>`:'')+
   (P.equipped.includes(en.id)?`<span class="eq">E</span>`:'');
  s.onmouseenter=()=>{document.getElementById('itemInfo').innerHTML=`<b>${en.name}</b><br>${en.desc}`}
  s.onclick=()=>{
   if(en.kind==='weapon'){P.weaponIdx=en.idx;AU.ui();renderInv()}
   else if(ITEMS[en.id].type==='cons'){ if(en.id==='potion')usePotion(); if(en.id==='bomb')useBomb(); }
   else if(ITEMS[en.id].type==='acc')toggleEquip(en.id);
  };
  grid.appendChild(s);
 }
}

/* ================= UPDATE ================= */
function rectFree(x,y,r,ignoreBlocks){
 const x0=Math.floor((x-r)/TS),x1=Math.floor((x+r)/TS),y0=Math.floor((y-r)/TS),y1=Math.floor((y+r)/TS);
 for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++)if(isSolid(tx,ty))return false;
 if(!ignoreBlocks)for(const b of blocksA){
  if(x+r>b.x-15&&x-r<b.x+15&&y+r>b.y-15&&y-r<b.y+15)return false}
 return true;
}
function moveEnt(e,dx,dy,ignoreBlocks){
 if(dx!==0&&rectFree(e.x+dx,e.y,e.r,ignoreBlocks))e.x+=dx;else if(dx!==0)e.hitWall=true;
 if(dy!==0&&rectFree(e.x,e.y+dy,e.r,ignoreBlocks))e.y+=dy;else if(dy!==0)e.hitWall=true;
}
function tryPushBlock(){
 // find block player is pressing into
 for(const b of blocksA){
  if(b.moving)continue;
  const dx=P.x-b.x,dy=P.y-b.y;
  if(Math.abs(dx)<30&&Math.abs(dy)<30){
   let px=0,py=0;
   if(keys['KeyA']||keys['ArrowLeft'])px=-1;else if(keys['KeyD']||keys['ArrowRight'])px=1;
   else if(keys['KeyW']||keys['ArrowUp'])py=-1;else if(keys['KeyS']||keys['ArrowDown'])py=1;
   if(px===0&&py===0)continue;
   const nx=b.tx+px,ny=b.ty+py;
   if(isSolid(nx,ny))continue;
   if(blocksA.some(o=>o!==b&&o.tx===nx&&o.ty===ny))continue;
   if(platesA.some(p=>p.tx===nx&&p.ty===ny)||true){
    b.moving=true;b.tx=nx;b.ty=ny;
    b.fx=b.x;b.fy=b.y;b.mx=nx*TS+16;b.my=ny*TS+16;b.mt=0;
    AU.noise&&AU.noise({dur:.2,vol:.08,f0:200,f1:90,ft:'lowpass'});
   }
  }
 }
}
function updateBlocks(dt){
 for(const b of blocksA){
  if(b.moving){
   b.mt+=dt/.16;
   const t=Math.min(1,b.mt);
   b.x=lerp(b.fx,b.mx,t);b.y=lerp(b.fy,b.my,t);
   if(t>=1){b.moving=false;AU.foot&&AU.noise({dur:.08,vol:.07,f0:150,f1:80,ft:'lowpass'});
    G.shake=Math.max(G.shake,2);checkPlates()}
  }
 }
}
function checkPlates(){
 for(const p of platesA){
  p.on=(Math.floor(P.x/TS)===p.tx&&Math.floor(P.y/TS)===p.ty)||
       blocksA.some(b=>!b.moving&&b.tx===p.tx&&b.ty===p.ty);
 }
 if(!G.flags.puzzle&&platesA.every(p=>p.on)){
  G.flags.puzzle=true;openDoor('arenaDoor');
  toast('⚙ The sigils accept the weight. The arena gate rises.');
  saveGame(false);
 }else if(G.flags.puzzle){
  // keep door open regardless once solved
  for(const p of platesA)p.on=true;
 }
}
function lightTorch(tt){
 if(tt.lit)return;tt.lit=true;G.flags.torches[tt.tx+','+tt.ty]=true;
 AU.swingFire&&AU.noise({dur:.3,vol:.1,f0:400,f1:1400});
 burst(tt.x,tt.y-10,12,'#ffb347');
 const n=torchesA.filter(t=>t.lit).length;
 toast(`Brazier lit (${n}/4)`);
 if(n===4){openDoor('bossGate');toast('🔥 The four braziers roar — the gate gives way!')}
}
function explodeBomb(b){
 AU.boom();G.shake=9;
 burst(b.x,b.y,30,'#ffb347',1.4);burst(b.x,b.y,16,'#666',1);
 for(const e of enemies)if(!e.dead&&dist(b.x,b.y,e.x,e.y)<70)damageEnemy(e,3,Math.atan2(e.y-b.y,e.x-b.x),false);
 if(dist(b.x,b.y,P.x,P.y)<50)hurtPlayer(1,Math.atan2(P.y-b.y,P.x-b.x));
 // crack walls
 const cx=Math.round((b.x-16)/TS),cy=Math.round((b.y-16)/TS);
 let cracked=false;
 for(let dy=-2;dy<=2;dy++)for(let dx=-2;dx<=2;dx++){
  const tx=cx+dx,ty=cy+dy;
  if(tAt(tx,ty)===T.CRACK&&dist(b.x,b.y,tx*TS+16,ty*TS+16)<62){
   setT(tx,ty,ty<19?T.CAVE:(tx>=63?T.FLOOR:T.GRASS));
   patchTile(tx,ty);cracked=true;
   if(!G.flags.cracks)G.flags.cracks=[];
   G.flags.cracks.push([tx,ty]);
   burst(tx*TS+16,ty*TS+16,10,'#8a8f9e');
  }
 }
 if(cracked){AU.crumble();toast('The cracked stone collapses!');
  if(G.stage===6)setStage(7);
 }
}

/* ---------------- player update ---------------- */
function updatePlayer(dt){
 if(P.dead)return;
 P.iframes=Math.max(0,P.iframes-dt);
 P.hurtT=Math.max(0,P.hurtT-dt);
 P.atk.cd=Math.max(0,P.atk.cd-dt);
 P.bowCd=Math.max(0,P.bowCd-dt);

 // movement input
 let ix=0,iy=0;
 if(keys['KeyA']||keys['ArrowLeft'])ix-=1;
 if(keys['KeyD']||keys['ArrowRight'])ix+=1;
 if(keys['KeyW']||keys['ArrowUp'])iy-=1;
 if(keys['KeyS']||keys['ArrowDown'])iy+=1;
 const mv=(ix!==0||iy!==0);
 if(mv){const l=Math.hypot(ix,iy);ix/=l;iy/=l;P.ang=Math.atan2(iy,ix);P.dirX=ix;P.dirY=iy}
 P.moving=mv&&!P.atk.on;

 // stamina
 const regen=P.moving?16:26;
 P.stamina=Math.min(P.maxStamina,P.stamina+regen*dt);
 P.fatigued=P.stamina<22;

 // dodge
 if(P.dodge.on){
  P.dodge.t-=dt;
  const sp=330;
  moveEnt(P,Math.cos(P.dodge.ang)*sp*dt,Math.sin(P.dodge.ang)*sp*dt,true);
  if(Math.random()<.5)parts.push({x:P.x,y:P.y+6,vx:rnd(-10,10),vy:rnd(-10,10),life:.3,t:0,c:'#cfd8e6',sz:2,grav:0});
  if(P.dodge.t<=0)P.dodge.on=false;
 }else if(!P.atk.on||true){
  let sp=150*(hasEq('boots')?1.18:1)*(P.fatigued?.85:1)*(P.atk.on?.45:1);
  if(mv){
   moveEnt(P,ix*sp*dt,iy*sp*dt,false);
   tryPushBlock();
   P.anim+=dt*(sp/16);
   P.stepT-=dt;
   if(P.stepT<=0){
    const onStone=tAt(Math.floor(P.x/TS),Math.floor(P.y/TS))===T.FLOOR||tAt(Math.floor(P.x/TS),Math.floor(P.y/TS))===T.CAVE;
    AU.foot(onStone);P.stepT=onStone?.26:.3;
    parts.push({x:P.x+rnd(-3,3),y:P.y+10,vx:rnd(-6,6),vy:rnd(-14,-4),life:.35,t:0,c:'rgba(120,120,110,.5)',sz:2,grav:-10});
   }
  }
 }

 // lava
 const ft=tAt(Math.floor(P.x/TS),Math.floor(P.y/TS));
 if(ft===T.LAVA){
  if(hasEq('firering')){
   if(Math.random()<.3)parts.push({x:P.x+rnd(-6,6),y:P.y+8,vx:0,vy:-30,life:.4,t:0,c:'#ffb347',sz:2,grav:0});
  }else{
   P.lavaT-=dt;
   if(P.lavaT<=0){P.lavaT=.8;hurtPlayer(1,rnd(0,6.28));AU.burn();}
  }
 }

 // dodge trigger
 if((pressed['KeyK']||pressed['KeyX']||pressed['ShiftLeft']||pressed['ShiftRight'])&&!P.dodge.on&&P.stamina>=30){
  P.dodge.on=true;P.dodge.t=.3;P.dodge.ang=mv?P.ang:(Math.atan2(P.dirY,P.dirX));
  P.stamina-=30;AU.dodge();
 }
 // attack
 if(pressed['KeyJ']||pressed['KeyZ']||pressed['Space'])tryAttack();

 // melee swing progress
 if(P.atk.on){
  P.atk.t+=dt;
  const p=P.atk.t/P.atk.dur;
  if(!P.atk.did&&p>.45){P.atk.did=true;meleeHit()}
  if(p>=1){P.atk.on=false;P.atk.cd=.06}
 }
}

/* ---------------- enemy update ---------------- */
function seesPlayer(e,range){return !P.dead&&!P.gone&&dist(e.x,e.y,P.x,P.y)<range}
function touchDamage(e,dt){
 if(!P.dead&&!P.dodge.on&&P.iframes<=0&&dist(e.x,e.y,P.x,P.y)<e.r+P.r+2){
  hurtPlayer(e.dmg,Math.atan2(P.y-e.y,P.x-e.x));
 }
}
function enemyShoot(e,ang,spd,dmg,type){
 projs.push({x:e.x+Math.cos(ang)*e.r,y:e.y+Math.sin(ang)*e.r,vx:Math.cos(ang)*spd,vy:Math.sin(ang)*spd,dmg,from:'enemy',type:type||'fire',life:3});
}
function updateEnemy(e,dt){
 if(e.dead)return;
 e.flash=Math.max(0,e.flash-dt);
 // burn
 if(e.burn>0){
  e.burn-=dt;e.burnT-=dt;
  if(Math.random()<.25)parts.push({x:e.x+rnd(-e.r,e.r)*.6,y:e.y-e.r*.4,vx:rnd(-8,8),vy:rnd(-40,-20),life:.4,t:0,c:'#ffb347',sz:2,grav:0});
  if(e.burnT<=0){e.burnT=1.2;e.hp-=1;floatText(e.x,e.y-e.r-4,'1','#ffb347');
   if(e.hp<=0){killEnemy(e);return}}
 }
 // knockback decay
 e.x+=e.kx*dt;e.y+=e.ky*dt;e.kx*=Math.pow(.02,dt);e.ky*=Math.pow(.02,dt);
 const dP=dist(e.x,e.y,P.x,P.y);
 const angP=Math.atan2(P.y-e.y,P.x-e.x);
 e.t0+=dt;e.wob+=dt*4;

 switch(e.t){
 case 'slime':{
  if(seesPlayer(e,150))e.st='chase';else e.st='idle';
  if(e.st==='chase'){
   if(e.extra.phase===undefined)e.extra.phase=0;
   if(e.extra.phase===0){ // approach & windup
    moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
    if(dP<64){e.extra.phase=1;e.extra.t=.4;e.st='windup'}
   }else if(e.extra.phase===1){e.extra.t-=dt;
    if(e.extra.t<=0){e.extra.phase=2;e.extra.t=.45;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP);AU.noise&&AU.noise({dur:.1,vol:.05,f0:300,f1:600})}
   }else if(e.extra.phase===2){e.extra.t-=dt;
    moveEnt(e,e.extra.ax*190*dt,e.extra.ay*190*dt);
    touchDamage(e,dt);
    if(e.extra.t<=0){e.extra.phase=3;e.extra.t=.7}
   }else{e.extra.t-=dt;if(e.extra.t<=0)e.extra.phase=0}
  }else{
   if(Math.random()<dt*.5)e.ang=rnd(0,6.28);
   moveEnt(e,Math.cos(e.ang)*18*dt,Math.sin(e.ang)*18*dt);
  }
  break;}
 case 'bat':{
  if(seesPlayer(e,260)){
   const a=angP+Math.sin(e.wob*2)*1.1;
   moveEnt(e,Math.cos(a)*e.spd*dt,Math.sin(a)*e.spd*dt);
   touchDamage(e,dt);
  }else{
   moveEnt(e,Math.cos(e.ang)*30*dt,Math.sin(e.ang)*30*dt);
   if(Math.random()<dt*.4)e.ang=rnd(0,6.28);
  }
  break;}
 case 'skel':{
  if(seesPlayer(e,200)){
   if(dP>42){moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);e.st='chase'}
   else{
    if(e.st!=='windup'&&e.st!=='attack'){e.st='windup';e.extra.t=.45;e.extra.ax=angP}
    if(e.st==='windup'){e.extra.t-=dt;
     if(e.extra.t<=0){e.st='attack';e.extra.t=.2;
      if(dist(e.x,e.y,P.x,P.y)<52)hurtPlayer(e.dmg,e.extra.ax);
      sparks(e.x+Math.cos(e.extra.ax)*24,e.y+Math.sin(e.extra.ax)*24,3,'#cfd8e6');AU.swing()}}
    else if(e.st==='attack'){e.extra.t-=dt;if(e.extra.t<=0){e.st='chase';e.extra.cd=.5}}
   }
   if(e.extra.cd>0)e.extra.cd-=dt;
  }else e.st='idle';
  break;}
 case 'archer':{
  if(seesPlayer(e,260)){
   const want=dP<110?-1:dP>190?1:0;
   if(want!==0)moveEnt(e,Math.cos(angP)*e.spd*dt*want,Math.sin(angP)*e.spd*dt*want);
   e.shootT-=dt;
   if(e.shootT<=0&&dP<280){e.shootT=2.2;
    enemyShoot(e,angP,240,1,'earrow');AU.bow()}
  }
  break;}
 case 'wisp':{
  if(seesPlayer(e,300)){
   moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
   if(dP<26){ // detonate
    e.dead=true;AU.boom();burst(e.x,e.y,18,'#ffb347');G.shake=5;
    if(dist(e.x,e.y,P.x,P.y)<55)hurtPlayer(e.dmg,angP);
    return;
   }
  }
  if(Math.random()<.3)parts.push({x:e.x,y:e.y,vx:rnd(-6,6),vy:rnd(-20,-6),life:.3,t:0,c:'#ff9a3c',sz:2,grav:0});
  break;}
 case 'warden':{
  if(seesPlayer(e,230)){
   if(e.extra.phase===undefined)e.extra.phase=0;
   if(e.extra.phase===0){
    moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
    touchDamage(e,dt);
    if(e.t0>2.2){e.t0=0;e.extra.phase=1;e.extra.t=.6;AU.roar&&AU.noise({dur:.3,vol:.1,f0:150,f1:60})}
   }else if(e.extra.phase===1){e.extra.t-=dt;
    if(e.extra.t<=0){e.extra.phase=2;e.extra.t=.5;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP)}
   }else if(e.extra.phase===2){e.extra.t-=dt;
    moveEnt(e,e.extra.ax*230*dt,e.extra.ay*230*dt);touchDamage(e,dt);
    if(e.extra.t<=0){e.extra.phase=3;e.extra.t=1.0}
   }else{e.extra.t-=dt;if(e.extra.t<=0)e.extra.phase=0}
  }else{
   if(dist(e.x,e.y,e.home.x,e.home.y)>8)moveEnt(e,Math.sign(e.home.x-e.x)*20*dt,Math.sign(e.home.y-e.y)*20*dt);
  }
  break;}
 case 'guardian':{
  if(e.extra.phase===undefined){e.extra.phase=0;AU.roar();toast('⚠ THE STONE GUARDIAN AWAKENS');
   closeDoorBehind('arenaDoor')}
  if(e.extra.phase===0){ // slow chase
   moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
   touchDamage(e,dt);
   if(e.t0>1.8){e.t0=0;
    if(dP<80){e.extra.phase=1;e.extra.t=.7} // slam
    else{e.extra.phase=2;e.extra.t=.55;e.extra.tx=P.x;e.extra.ty=P.y} // leap
   }
  }else if(e.extra.phase===1){ // slam windup
   e.extra.t-=dt;
   if(e.extra.t<=0){
    e.extra.phase=3;e.extra.t=.6;AU.boom();G.shake=8;
    burst(e.x,e.y,20,'#aab4c8',1.2);
    if(dist(e.x,e.y,P.x,P.y)<80)hurtPlayer(3,angP);
   }
  }else if(e.extra.phase===2){ // leap
   e.extra.t-=dt;
   const t=1-e.extra.t/.55;
   e.x=lerp(e.x,e.extra.tx,dt*6);e.y=lerp(e.y,e.extra.ty,dt*6);
   if(e.extra.t<=0){e.extra.phase=3;e.extra.t=.6;AU.boom();G.shake=8;burst(e.x,e.y,20,'#aab4c8',1.2);
    if(dist(e.x,e.y,P.x,P.y)<70)hurtPlayer(2,angP)}
  }else{ // stun — punish window
   e.extra.t-=dt;
   if(e.extra.t<=0)e.extra.phase=0;
  }
  break;}
 case 'boss':{
  if(e.extra.phase===undefined){
   e.extra.phase=0;e.extra.cd=1.5;e.extra.mode='chase';e.extra.t=0;
   AU.roar();toast('🔥 THE ASH KING RISES');closeDoorBehind('bossGate');
  }
  const ph2=e.hp<e.maxHp*.5;
  const spd=e.spd*(ph2?1.5:1);
  e.extra.cd-=dt;
  if(e.extra.mode==='chase'){
   moveEnt(e,Math.cos(angP)*spd*dt,Math.sin(angP)*spd*dt);
   touchDamage(e,dt);
   if(e.extra.cd<=0){
    e.extra.cd=ph2?1.7:2.4;
    const roll=Math.random();
    if(roll<.4){e.extra.mode='cast';e.extra.t=.5}
    else if(roll<.7){e.extra.mode='ring';e.extra.t=.5}
    else{e.extra.mode='dash';e.extra.t=.55;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP);
     AU.noise&&AU.noise({dur:.4,vol:.12,f0:200,f1:900})}
   }
  }else if(e.extra.mode==='cast'){
   e.extra.t-=dt;
   if(e.extra.t<=0){e.extra.mode='chase';
    for(let i=-1;i<=1;i++){const a=angP+i*.22;enemyShoot(e,a,ph2?260:210,2,'fire')}
    AU.swingFire&&AU.noise({dur:.25,vol:.1,f0:300,f1:1200});}
  }else if(e.extra.mode==='ring'){
   e.extra.t-=dt;
   if(e.extra.t<=0){e.extra.mode='chase';
    const n=ph2?10:8;
    for(let i=0;i<n;i++){const a=i/n*Math.PI*2+e.wob;enemyShoot(e,a,170,2,'fire')}
    AU.boom();G.shake=6;}
  }else if(e.extra.mode==='dash'){
   e.extra.t-=dt;
   moveEnt(e,e.extra.ax*spd*3.4*dt,e.extra.ay*spd*3.4*dt);
   touchDamage(e,dt);
   if(Math.random()<.6)parts.push({x:e.x,y:e.y,vx:rnd(-10,10),vy:rnd(-10,10),life:.3,t:0,c:'#ff9a3c',sz:3,grav:0});
   if(e.extra.t<=0)e.extra.mode='chase';
  }
  // summon wisps occasionally in phase 2
  if(ph2&&Math.random()<dt*.12&&enemies.filter(o=>o.t==='wisp'&&!o.dead).length<3)
   spawnEnemy('wisp',e.x+rnd(-40,40),e.y+rnd(-40,40));
  if(Math.random()<.4)parts.push({x:e.x+rnd(-14,14),y:e.y-e.r*.6,vx:rnd(-6,6),vy:rnd(-40,-20),life:.5,t:0,c:'#ff9a3c',sz:2.5,grav:0});
  break;}
 }
}
function closeDoorBehind(id){const d=doors.find(o=>o.id===id);if(d&&!d.open){/* stay closed; ensure closed */} 
 // ensure the door is explicitly closed for the fight
 const d2=doors.find(o=>o.id===id);if(d2){d2.open=false;delete G.flags.doors[id]}
}

/* ---------------- projectiles, pickups, bombs, particles ---------------- */
function updateProjs(dt){
 for(let i=projs.length-1;i>=0;i--){
  const p=projs[i];
  p.x+=p.vx*dt;p.y+=p.vy*dt;p.life-=dt;
  let kill=p.life<=0;
  const tx=Math.floor(p.x/TS),ty=Math.floor(p.y/TS);
  if(isSolid(tx,ty)){kill=true;sparks(p.x,p.y,4,p.type==='arrow'?'#c8b790':'#ff9a3c');if(p.type==='arrow')AU.arrowHit()}
  if(!kill&&p.from==='player'){
   for(const e of enemies){
    if(e.dead)continue;
    if(dist(p.x,p.y,e.x,e.y)<e.r+4){
     let dmg=p.dmg,crit=false;
     if(hasEq('charm')&&Math.random()<.12){dmg*=2;crit=true}
     damageEnemy(e,dmg,Math.atan2(p.vy,p.vx),crit);
     kill=true;break;
    }
   }
   if(!kill)for(const tt of torchesA){
    if(!tt.lit&&dist(p.x,p.y,tt.x,tt.y)<16){lightTorch(tt);kill=true;break}
   }
   if(!kill&&dist(p.x,p.y,dummy.x,dummy.y)<14){dummy.wob=1;kill=true;AU.arrowHit()}
  }
  if(!kill&&p.from==='enemy'){
   if(!P.dead&&!P.dodge.on&&dist(p.x,p.y,P.x,P.y)<P.r+5){
    hurtPlayer(p.dmg,Math.atan2(p.vy,p.vx));kill=true;
   }
  }
  if(p.type==='fire'&&Math.random()<.5)
   parts.push({x:p.x,y:p.y,vx:rnd(-8,8),vy:rnd(-8,8),life:.25,t:0,c:'#ff9a3c',sz:2.5,grav:0});
  if(kill)projs.splice(i,1);
 }
}
function updatePickups(dt){
 for(let i=pickups.length-1;i>=0;i--){
  const p=pickups[i];p.t+=dt;
  p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=200*dt;p.vx*=Math.pow(.1,dt);
  if(p.t>.25)p.vy=Math.max(0,p.vy);
  const d=dist(p.x,p.y,P.x,P.y);
  if(d<70&&p.type==='coin'){p.x+=(P.x-p.x)*dt*8;p.y+=(P.y-p.y)*dt*8}
  if(d<18&&!P.dead){
   if(p.type==='coin'){giveGold(p.val)}
   else if(p.type==='arrows'){addItem('arrows',p.val)}
   else if(p.type==='potion'){addItem('potion',p.val)}
   else if(p.type==='key'){P.inv.rustykey=(P.inv.rustykey||0)+1;toast('🗝️ Rusty Key obtained');AU.pickup()}
   pickups.splice(i,1);continue;
  }
  if(p.t>30)pickups.splice(i,1);
 }
}
function updateBombs(dt){
 for(let i=bombsA.length-1;i>=0;i--){
  const b=bombsA[i];b.t-=dt;
  if(b.t<=0){explodeBomb(b);bombsA.splice(i,1)}
 }
}
function updateParts(dt){
 for(let i=parts.length-1;i>=0;i--){
  const p=parts[i];p.t+=dt;
  if(p.t>=p.life){parts.splice(i,1);continue}
  p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=(p.grav||0)*dt;
 }
 if(parts.length>500)parts.splice(0,parts.length-500);
 for(let i=floaters.length-1;i>=0;i--){
  const f=floaters[i];f.t+=dt;f.y-=34*dt;
  if(f.t>1)floaters.splice(i,1);
 }
}

/* ---------------- triggers & quest flow ---------------- */
function inRect(px,py,x0,y0,x1,y1){const tx=px/TS,ty=py/TS;return tx>=x0&&tx<=x1&&ty>=y0&&ty<=y1}
function updateTriggers(dt){
 // arena
 if(!G.flags.guardianDead&&!G.guardianUp&&inRect(P.x,P.y,65,26,93,40)&&G.flags.puzzle){
  G.guardianUp=true;spawnEnemy('guardian',82.5*TS,33*TS);
 }
 // boss room
 if(!G.flags.bossDead&&!G.bossUp&&inRect(P.x,P.y,27,2,37,11)){
  const allLit=torchesA.every(t=>t.lit);
  if(allLit){G.bossUp=true;spawnEnemy('boss',32.5*TS,6.5*TS)}
 }
 // stage nudges
 if(G.stage===3&&G.flags.wardenDead)setStage(4);
 if(G.stage===4&&G.flags.doors['ruinsGate'])setStage(5);
 // win
 if(G.winT>0){G.winT-=dt;
  if(G.winT<=0)showWin();
 }
}
function showWin(){
 G.state='win';
 const m=Math.floor(G.playTime/60),s=Math.floor(G.playTime%60);
 document.getElementById('winStats').innerHTML=
  `Time: <b>${m}m ${s}s</b><br>Foes felled: <b>${G.kills}</b><br>Gold held: <b>${P.gold}</b><br>`+
  `Heart containers: <b>${P.containers}/5</b> · Final blade: <b>${WEAPONS[P.weaponIdx].name}</b>`;
 document.getElementById('win').classList.remove('hidden');
 AU.fanfare();
}

/* ---------------- master update ---------------- */
function update(dt){
 if(G.freeze>0){G.freeze-=dt;return}
 G.time+=dt;G.playTime+=dt;
 G.shake=Math.max(0,G.shake-dt*22);
 updatePlayer(dt);
 tryPushBlockPassive(dt);
 updateBlocks(dt);
 checkPlates();
 for(const e of enemies)updateEnemy(e,dt);
 enemies=enemies.filter(e=>!e.dead||false); // keep dead removed after burst
 enemies=enemies.filter(e=>!e.dead);
 updateProjs(dt);updatePickups(dt);updateBombs(dt);updateParts(dt);
 dummy.wob=Math.max(0,dummy.wob-dt*3);
 updateTriggers(dt);
 // camera
 const cx=clamp(P.x-VW/2,0,Math.max(0,MW*TS-VW));
 const cy=clamp(P.y-VH/2,0,Math.max(0,MH*TS-VH));
 cam.x=lerp(cam.x,cx,Math.min(1,dt*7));cam.y=lerp(cam.y,cy,Math.min(1,dt*7));
}
function tryPushBlockPassive(dt){/* pushing handled in player step */}

/* ================= DRAW ================= */
function drawWorld(){
 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));
 ctx.drawImage(base,cam.x,cam.y,VW,VH,0,0,VW,VH);
 const t=G.time;
 for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
  const tt=tAt(tx,ty),x=tx*TS-cam.x,y=ty*TS-cam.y,h=hash2(tx,ty);
  if(tt===T.WATER){
   ctx.fillStyle='rgba(120,180,255,'+(.08+.05*Math.sin(t*2+tx+ty))+')';
   ctx.fillRect(x,y+ (h*20|0),TS,3);
   ctx.fillRect(x+(h*14|0),y+((h*37+10)%28),14,2);
  }else if(tt===T.LAVA){
   const g=.5+.5*Math.sin(t*3+tx*2+ty*3);
   ctx.fillStyle=`rgba(255,${100+g*90|0},40,${.55+.25*g})`;
   ctx.fillRect(x,y,TS,TS);
   ctx.fillStyle='rgba(255,220,120,'+(.25+.3*g)+')';
   ctx.beginPath();ctx.arc(x+8+h*14,y+10+((h*23)%12),3+2*g,0,7);ctx.fill();
   if(Math.random()<.004)parts.push({x:tx*TS+rnd(4,28),y:ty*TS+rnd(4,28),vx:0,vy:rnd(-30,-14),life:.7,t:0,c:'#ffb347',sz:2,grav:0});
  }
 }
}
function drawDoorTiles(){
 for(const d of doors){
  if(d.open)continue;
  for(const [tx,ty] of d.tiles){
   const x=tx*TS-cam.x,y=ty*TS-cam.y;
   if(x<-TS||y<-TS||x>VW||y>VH)continue;
   const cave=ty<19;
   ctx.fillStyle=cave?'#3a2f28':'#4a3826';ctx.fillRect(x+2,y+2,TS-4,TS-4);
   ctx.fillStyle=cave?'#4d3f35':'#5f4933';
   ctx.fillRect(x+4,y+4,6,TS-8);ctx.fillRect(x+13,y+4,6,TS-8);ctx.fillRect(x+22,y+4,6,TS-8);
   ctx.fillStyle='#2a2018';
   ctx.fillRect(x+2,y+8,TS-4,3);ctx.fillRect(x+2,y+21,TS-4,3);
   if(d.key){ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.arc(x+16,y+18,3.4,0,7);ctx.fill();
    ctx.fillStyle='#2a2018';ctx.fillRect(x+15,y+18,2,5)}
  }
 }
}
function drawShadow(x,y,r){ctx.fillStyle='rgba(0,0,0,.3)';ctx.beginPath();ctx.ellipse(x,y,r,r*.42,0,0,7);ctx.fill()}
function drawTreeOver(){
 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++){
  if(tAt(tx,ty)!==T.TREE)continue;
  const x=tx*TS-cam.x+16,y=ty*TS-cam.y+16,h=hash2(tx,ty);
  const sw=Math.sin(G.time*1.2+tx*1.7+ty)*1.2;
  drawShadow(x,y+12,10);
  ctx.fillStyle=h>.5?'#2c5e34':'#265430';
  ctx.beginPath();ctx.arc(x+sw,y-8,13,0,7);ctx.fill();
  ctx.fillStyle=h>.5?'#397741':'#316b39';
  ctx.beginPath();ctx.arc(x+sw*.6,y-13,9,0,7);ctx.fill();
 }
}
function drawChest(c){
 const x=c.x-cam.x,y=c.y-cam.y;
 drawShadow(x,y+9,11);
 ctx.fillStyle='#6b4a2f';ctx.fillRect(x-11,y-8,22,16);
 ctx.fillStyle=c.opened?'#4a3421':'#8a683f';ctx.fillRect(x-11,y-8,22,c.opened?5:7);
 ctx.fillStyle='#d8b34a';ctx.fillRect(x-2,y-4,4,6);
 if(!c.opened&&Math.sin(G.time*3+c.x)>.4){ctx.fillStyle='rgba(255,220,120,.8)';ctx.fillRect(x-1,y-2,2,2)}
}
function drawShrine(s){
 const x=s.x-cam.x,y=s.y-cam.y;
 const active=G.flags.shrineIds[s.id];
 drawShadow(x,y+12,12);
 ctx.fillStyle='#5a5f6e';ctx.fillRect(x-9,y-6,18,18);
 ctx.fillStyle='#6d7385';ctx.fillRect(x-6,y-16,12,12);
 ctx.fillStyle=active?'#8fd3ff':'#3a3f4d';
 ctx.beginPath();ctx.moveTo(x,y-13);ctx.lineTo(x+4,y-7);ctx.lineTo(x,y-1);ctx.lineTo(x-4,y-7);ctx.closePath();ctx.fill();
 if(active){
  ctx.fillStyle='rgba(143,211,255,'+(.4+.3*Math.sin(G.time*3))+')';
  ctx.beginPath();ctx.arc(x,y-7,7+Math.sin(G.time*3),0,7);ctx.fill();
  if(Math.random()<.05)parts.push({x:x+rnd(-8,8),y:y,vx:0,vy:rnd(-30,-14),life:.8,t:0,c:'#8fd3ff',sz:2,grav:-10});
 }
}
function drawNPC(n){
 const x=n.x-cam.x,y=n.y-cam.y;
 const bob=Math.sin(G.time*2+n.x)*1.2;
 drawShadow(x,y+11,8);
 ctx.fillStyle=n.c;ctx.fillRect(x-6,y-6+bob*.3,12,14);
 ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x,y-11+bob*.3,6,0,7);ctx.fill();
 ctx.fillStyle='#4a3826';ctx.beginPath();ctx.arc(x,y-13+bob*.3,5.4,Math.PI,0);ctx.fill();
 ctx.fillStyle='#222';ctx.fillRect(x-3,y-11+bob*.3,1.6,2);ctx.fillRect(x+1.5,y-11+bob*.3,1.6,2);
 if(n.id==='bram'){ctx.fillStyle='#7a5a36';ctx.fillRect(x-8,y-20,16,4)}
 ctx.fillStyle='rgba(255,255,255,.85)';ctx.font='9px sans-serif';ctx.textAlign='center';
 ctx.fillText('💬',x,y-22+Math.sin(G.time*3)*2);
}
function drawTablet(tb){
 const x=tb.x-cam.x,y=tb.y-cam.y;
 drawShadow(x,y+9,9);
 ctx.fillStyle='#6d7385';ctx.fillRect(x-7,y-14,14,22);
 ctx.fillStyle='#565b6b';ctx.fillRect(x-7,y-14,14,4);
 ctx.fillStyle='#3a3f4d';
 for(let i=0;i<3;i++)ctx.fillRect(x-4,y-7+i*5,8,1.6);
}
function drawPlatesBlocks(){
 for(const p of platesA){
  const x=p.tx*TS+16-cam.x,y=p.ty*TS+16-cam.y;
  const pulse=.5+.5*Math.sin(G.time*3+p.tx);
  ctx.fillStyle='#3f4350';ctx.beginPath();ctx.arc(x,y,12,0,7);ctx.fill();
  ctx.strokeStyle=p.on?`rgba(124,252,144,${.6+.4*pulse})`:`rgba(216,179,74,${.35+.35*pulse})`;
  ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,10,0,7);ctx.stroke();
  ctx.strokeStyle=p.on?'#7cfc90':'#8a7230';ctx.lineWidth=1.4;
  ctx.beginPath();
  ctx.moveTo(x-5,y);ctx.lineTo(x+5,y);ctx.moveTo(x,y-5);ctx.lineTo(x,y+5);
  ctx.moveTo(x-3.5,y-3.5);ctx.lineTo(x+3.5,y+3.5);ctx.moveTo(x-3.5,y+3.5);ctx.lineTo(x+3.5,y-3.5);
  ctx.stroke();
  if(p.on&&Math.random()<.06)parts.push({x:x+rnd(-8,8),y:y+rnd(-8,8),vx:0,vy:-24,life:.5,t:0,c:'#7cfc90',sz:2,grav:0});
 }
 for(const b of blocksA){
  const x=b.x-cam.x,y=b.y-cam.y;
  drawShadow(x,y+13,13);
  ctx.fillStyle='#7e8591';ctx.fillRect(x-13,y-13,26,26);
  ctx.fillStyle='#99a0ac';ctx.fillRect(x-13,y-13,26,6);
  ctx.strokeStyle='#565b6b';ctx.strokeRect(x-12.5,y-12.5,25,25);
 }
}
function drawTorch(tt){
 const x=tt.x-cam.x,y=tt.y-cam.y;
 ctx.fillStyle='#4a3826';ctx.fillRect(x-2,y-6,4,14);
 ctx.fillStyle='#33281c';ctx.fillRect(x-5,y-9,10,4);
 if(tt.lit){
  const f=Math.sin(G.time*9+tt.x)*2;
  ctx.fillStyle='rgba(255,150,50,.9)';
  ctx.beginPath();ctx.arc(x,y-13+f*.4,5,0,7);ctx.fill();
  ctx.fillStyle='rgba(255,230,140,.95)';
  ctx.beginPath();ctx.arc(x,y-14+f*.3,2.5,0,7);ctx.fill();
 }else{
  ctx.fillStyle='#2a2018';ctx.beginPath();ctx.arc(x,y-11,3,0,7);ctx.fill();
 }
}
function drawPedestal(){
 const x=pedestal.x-cam.x,y=pedestal.y-cam.y;
 ctx.fillStyle='#5a5f6e';ctx.fillRect(x-10,y-2,20,12);
 ctx.fillStyle='#6d7385';ctx.fillRect(x-7,y-10,14,8);
 if(!pedestal.taken){
  const b=Math.sin(G.time*2.5)*2;
  ctx.strokeStyle='#c8b790';ctx.lineWidth=2.5;
  ctx.beginPath();ctx.arc(x,y-20+b,7,0.3,Math.PI-0.3);ctx.stroke();
  ctx.beginPath();ctx.moveTo(x-6,y-18+b);ctx.lineTo(x+6,y-18+b);ctx.stroke();
  ctx.fillStyle='rgba(140,200,255,'+(.3+.2*Math.sin(G.time*3))+')';
  ctx.beginPath();ctx.arc(x,y-18+b,11,0,7);ctx.fill();
 }
}
function drawDummy(){
 const x=dummy.x-cam.x,y=dummy.y-cam.y;
 const w=Math.sin(G.time*14)*dummy.wob*4;
 drawShadow(x,y+12,9);
 ctx.fillStyle='#6b4a2f';ctx.fillRect(x-2,y-4,4,16);
 ctx.fillStyle='#c8b790';ctx.beginPath();ctx.ellipse(x+w*.5,y-12,9,12,w*.04,0,7);ctx.fill();
 ctx.strokeStyle='#8a7a5a';ctx.beginPath();ctx.moveTo(x-7+w*.5,y-16);ctx.lineTo(x+7+w*.5,y-16);
 ctx.moveTo(x-8+w*.5,y-8);ctx.lineTo(x+8+w*.5,y-8);ctx.stroke();
}
function drawPickup(p){
 const x=p.x-cam.x,y=p.y-cam.y;
 if(p.type==='coin'){
  const s=Math.abs(Math.sin(G.time*6+p.x));
  ctx.fillStyle='#ffd76a';ctx.beginPath();ctx.ellipse(x,y,4*Math.max(.3,s),4,0,0,7);ctx.fill();
  ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.ellipse(x,y,2*Math.max(.3,s),2,0,0,7);ctx.fill();
 }else if(p.type==='arrows'){
  ctx.strokeStyle='#c8b790';ctx.lineWidth=2;
  ctx.beginPath();ctx.moveTo(x-5,y+4);ctx.lineTo(x+5,y-4);ctx.stroke();
  ctx.fillStyle='#e8e8f0';ctx.beginPath();ctx.moveTo(x+6,y-5);ctx.lineTo(x+2,y-4);ctx.lineTo(x+5,y-1);ctx.fill();
 }else if(p.type==='potion'){
  ctx.fillStyle='#e5484d';ctx.beginPath();ctx.arc(x,y+2,4.5,0,7);ctx.fill();
  ctx.fillStyle='#cfd8e6';ctx.fillRect(x-1.5,y-7,3,5);
 }else if(p.type==='key'){
  ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.arc(x,y-3,4,0,7);ctx.fill();
  ctx.fillRect(x-1.5,y-3,3,9);ctx.fillRect(x,y+3,4,2);
  ctx.fillStyle='#7a5a36';ctx.beginPath();ctx.arc(x,y-3,1.8,0,7);ctx.fill();
 }
}
function drawBomb(b){
 const x=b.x-cam.x,y=b.y-cam.y;
 drawShadow(x,y+7,7);
 const fl=b.t<.4;
 ctx.fillStyle='#23252e';ctx.beginPath();ctx.arc(x,y,7,0,7);ctx.fill();
 ctx.fillStyle='#3a3d4a';ctx.beginPath();ctx.arc(x-2,y-2,2.5,0,7);ctx.fill();
 ctx.strokeStyle='#8a683f';ctx.beginPath();ctx.moveTo(x+3,y-6);ctx.quadraticCurveTo(x+7,y-10,x+5,y-12);ctx.stroke();
 ctx.fillStyle=fl&&Math.sin(G.time*30)>0?'#fff':'#ffb347';
 ctx.beginPath();ctx.arc(x+5,y-12,2+Math.random(),0,7);ctx.fill();
}

/* ---------------- enemies draw ---------------- */
function drawEnemy(e){
 const x=e.x-cam.x,y=e.y-cam.y;
 if(x<-60||y<-60||x>VW+60||y>VH+60)return;
 const fl=e.flash>0;
 drawShadow(x,y+e.r*.8,e.r*.9);
 ctx.save();
 if(fl){ctx.globalAlpha=.95}
 switch(e.t){
 case 'slime':{
  const sq=1+Math.sin(e.wob*2)*.12;
  ctx.fillStyle=fl?'#fff':'#7cc26a';
  ctx.beginPath();ctx.ellipse(x,y+4,e.r*sq,e.r/sq*.9,0,0,7);ctx.fill();
  ctx.fillStyle=fl?'#eee':'#a3dd90';
  ctx.beginPath();ctx.ellipse(x-3,y,e.r*.4,e.r*.3,0,0,7);ctx.fill();
  ctx.fillStyle='#20401f';ctx.fillRect(x-5,y-2,2.6,4);ctx.fillRect(x+2.5,y-2,2.6,4);
  break;}
 case 'bat':{
  const w=Math.sin(e.wob*5)*6;
  ctx.fillStyle=fl?'#fff':'#5d4a75';
  ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-12,y-4-w);ctx.lineTo(x-4,y+1);ctx.lineTo(x+4,y+1);ctx.lineTo(x+12,y-4-w);ctx.closePath();ctx.fill();
  ctx.beginPath();ctx.arc(x,y,4.6,0,7);ctx.fill();
  ctx.fillStyle='#ffd76a';ctx.fillRect(x-2.5,y-1.5,1.6,1.6);ctx.fillRect(x+1,y-1.5,1.6,1.6);
  break;}
 case 'skel':{
  const step=Math.sin(e.wob*2.4)*2;
  ctx.fillStyle=fl?'#fff':'#d8d5c8';
  ctx.fillRect(x-5,y-4+step*.2,10,12);
  ctx.beginPath();ctx.arc(x,y-9,5.6,0,7);ctx.fill();
  ctx.fillStyle='#1a1a1f';ctx.fillRect(x-3,y-10,2,2.6);ctx.fillRect(x+1,y-10,2,2.6);
  ctx.strokeStyle=fl?'#fff':'#d8d5c8';ctx.lineWidth=1.6;
  ctx.beginPath();ctx.moveTo(x-3,y-2);ctx.lineTo(x-3,y+7);ctx.moveTo(x+3,y-2);ctx.lineTo(x+3,y+7);ctx.stroke();
  if(e.st==='windup'){ctx.strokeStyle='#ffdf80';ctx.beginPath();ctx.arc(x,y,e.r+6,0,7);ctx.stroke()}
  ctx.strokeStyle='#aab4c8';ctx.lineWidth=2;
  const aa=(e.extra&&e.extra.ax!==undefined?e.extra.ax:e.ang);
  ctx.beginPath();ctx.moveTo(x+Math.cos(aa)*6,y+Math.sin(aa)*6);ctx.lineTo(x+Math.cos(aa)*16,y+Math.sin(aa)*16);ctx.stroke();
  break;}
 case 'archer':{
  ctx.fillStyle=fl?'#fff':'#cfd8c8';
  ctx.fillRect(x-5,y-4,10,12);
  ctx.beginPath();ctx.arc(x,y-9,5.6,0,7);ctx.fill();
  ctx.fillStyle='#1a1a1f';ctx.fillRect(x-3,y-10,2,2.6);ctx.fillRect(x+1,y-10,2,2.6);
  const aa=Math.atan2(P.y-e.y,P.x-e.x);
  ctx.strokeStyle='#8a683f';ctx.lineWidth=2;
  ctx.beginPath();ctx.arc(x+Math.cos(aa)*8,y+Math.sin(aa)*8,6,aa-1.2,aa+1.2);ctx.stroke();
  break;}
 case 'wisp':{
  ctx.fillStyle=fl?'#fff':'rgba(255,154,60,.9)';
  ctx.beginPath();ctx.arc(x,y,e.r+Math.sin(e.wob*3)*1.5,0,7);ctx.fill();
  ctx.fillStyle='#fff3c0';ctx.beginPath();ctx.arc(x,y,3.4,0,7);ctx.fill();
  break;}
 case 'warden':{
  const sq=1+Math.sin(e.wob*1.6)*.08;
  ctx.fillStyle=fl?'#fff':'#4e8a44';
  ctx.beginPath();ctx.ellipse(x,y+4,e.r*sq,e.r/sq*.92,0,0,7);ctx.fill();
  ctx.fillStyle=fl?'#eee':'#6bb05e';
  ctx.beginPath();ctx.ellipse(x-5,y-2,e.r*.45,e.r*.32,0,0,7);ctx.fill();
  ctx.fillStyle='#16280f';ctx.fillRect(x-8,y-6,4,7);ctx.fillRect(x+4,y-6,4,7);
  ctx.fillStyle='#c9a84a';ctx.fillRect(x-8,y-9,4,2);ctx.fillRect(x+4,y-9,4,2);
  // crown of thorns
  ctx.strokeStyle='#2f4a26';ctx.lineWidth=2;
  for(let i=0;i<5;i++){const a=-2.4+i*.55;
   ctx.beginPath();ctx.moveTo(x+Math.cos(a)*e.r*.8,y+Math.sin(a)*e.r*.7);
   ctx.lineTo(x+Math.cos(a)*(e.r*.8+7),y+Math.sin(a)*(e.r*.7+7)-4);ctx.stroke()}
  break;}
 case 'guardian':{
  const lunge=e.extra.phase===2?Math.sin(e.t*30)*1.5:0;
  ctx.fillStyle=fl?'#fff':'#7e8591';
  ctx.fillRect(x-16+lunge,y-18,32,34);
  ctx.fillStyle=fl?'#eee':'#99a0ac';ctx.fillRect(x-16+lunge,y-18,32,8);
  ctx.fillStyle=fl?'#eee':'#6d7385';
  ctx.fillRect(x-24,y-14,8,22);ctx.fillRect(x+16,y-14,8,22);
  ctx.fillStyle=e.extra.phase===3?'#ffe9b0':'#8fd3ff';
  ctx.fillRect(x-8,y-11,5,4);ctx.fillRect(x+3,y-11,5,4);
  ctx.strokeStyle='#565b6b';ctx.strokeRect(x-15.5,y-17.5,31,33);
  if(e.hp<e.maxHp*.5){ctx.strokeStyle='#3a3f4d';ctx.beginPath();
   ctx.moveTo(x-10,y-16);ctx.lineTo(x-4,y-4);ctx.lineTo(x-12,y+8);ctx.stroke()}
  break;}
 case 'boss':{
  const ph2=e.hp<e.maxHp*.5;
  const pu=1+Math.sin(G.time*(ph2?8:4))*.04;
  ctx.fillStyle=fl?'#fff':'#2c2028';
  ctx.beginPath();ctx.ellipse(x,y+2,e.r*pu,e.r*1.25*pu,0,0,7);ctx.fill();
  ctx.fillStyle=fl?'#eee':'#3d2c35';
  ctx.beginPath();ctx.arc(x,y-e.r*.7,e.r*.62,0,7);ctx.fill();
  // crown
  ctx.fillStyle='#c9a84a';
  ctx.beginPath();ctx.moveTo(x-10,y-e.r*.7-8);ctx.lineTo(x-7,y-e.r*.7-16);ctx.lineTo(x-4,y-e.r*.7-9);
  ctx.lineTo(x,y-e.r*.7-18);ctx.lineTo(x+4,y-e.r*.7-9);ctx.lineTo(x+7,y-e.r*.7-16);ctx.lineTo(x+10,y-e.r*.7-8);ctx.closePath();ctx.fill();
  // eyes
  ctx.fillStyle=ph2?'#ff5040':'#ffb347';
  ctx.fillRect(x-7,y-e.r*.7-2,4,3.4);ctx.fillRect(x+3,y-e.r*.7-2,4,3.4);
  // ribs
  ctx.strokeStyle='#57493f';ctx.lineWidth=2;
  for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(x-10,y-6+i*7);ctx.lineTo(x+10,y-6+i*7);ctx.stroke()}
  break;}
 }
 ctx.restore();
 // hp bar for mini/boss (guardian handled in HUD)
 if((e.t==='warden'||e.t==='guardian')&&e.hp<e.maxHp){
  ctx.fillStyle='#000a';ctx.fillRect(x-18,y-e.r-14,36,5);
  ctx.fillStyle='#e5484d';ctx.fillRect(x-17,y-e.r-13,34*e.hp/e.maxHp,3);
 }
 if(e.burn>0){ctx.fillStyle='rgba(255,150,50,'+(.4+.3*Math.sin(G.time*10))+')';
  ctx.beginPath();ctx.arc(x,y,e.r+3,0,7);ctx.fill()}
}

/* ---------------- player draw ---------------- */
function drawWeaponSprite(x,y,ang,w,swingP){
 ctx.save();ctx.translate(x,y);ctx.rotate(ang);
 const len=w.id==='rusty'?16:w.id==='steel'?20:20;
 // handle
 ctx.fillStyle='#5f4933';ctx.fillRect(6,-1.6,7,3.2);
 ctx.fillStyle='#c9a84a';ctx.fillRect(12.5,-3.4,2,6.8);
 // blade
 ctx.fillStyle=w.blade;
 ctx.beginPath();
 ctx.moveTo(14.5,-2.6);ctx.lineTo(14.5+len,-.8);ctx.lineTo(14.5+len+3,0);ctx.lineTo(14.5+len,.8);ctx.lineTo(14.5,2.6);ctx.closePath();ctx.fill();
 if(w.fire){
  ctx.fillStyle='rgba(255,154,60,'+(.6+.4*Math.sin(G.time*12))+')';
  ctx.beginPath();ctx.moveTo(15,-2);ctx.lineTo(15+len,0);ctx.lineTo(15,2);ctx.closePath();ctx.fill();
  if(Math.random()<.35)parts.push({x:x+Math.cos(ang)*(14+len)*.9,y:y+Math.sin(ang)*(14+len)*.9,
   vx:rnd(-12,12),vy:rnd(-30,-10),life:.3,t:0,c:'#ffb347',sz:2,grav:0});
 }else{
  ctx.fillStyle='rgba(255,255,255,.5)';ctx.fillRect(15,-1.2,len*.7,1);
 }
 ctx.restore();
}
function drawPlayer(){
 if(P.gone)return;
 const x=P.x-cam.x,y=P.y-cam.y;
 const w=P.weaponIdx<3?WEAPONS[P.weaponIdx]:null;
 drawShadow(x,y+11,9);
 ctx.save();
 if(P.iframes>0&&Math.sin(G.time*40)>0)ctx.globalAlpha=.45;

 if(P.dodge.on){
  // rolling ball
  ctx.rotate(P.dodge.t*18);
  ctx.fillStyle='#4a7fb5';ctx.beginPath();ctx.arc(x,y,9,0,7);ctx.fill();
  ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x+3,y-3,4,0,7);ctx.fill();
  ctx.restore();return;
 }
 const tired=P.fatigued;
 const legAmp=P.moving?(tired?3:4.5):0;
 const l1=Math.sin(P.anim*2.2)*legAmp,l2=-l1;
 const bob=P.moving?Math.abs(Math.sin(P.anim*2.2))*(tired?1:1.6):0;
 const lean=tired?1.5:0;
 // legs
 ctx.fillStyle=hasEq('boots')?'#3fa7a0':'#5a4632';
 ctx.fillRect(x-5+l1*.4,y+4,4,7-bob*.5);
 ctx.fillRect(x+1+l2*.4,y+4,4,7-bob*.5);
 // body
 ctx.fillStyle='#4a7fb5';
 ctx.fillRect(x-6,y-7-bob+lean*.3,12,13);
 ctx.fillStyle='#3a6592';ctx.fillRect(x-6,y+2-bob,12,3);
 // belt
 ctx.fillStyle='#8a683f';ctx.fillRect(x-6,y+4-bob,12,2);
 // quiver when bow owned
 if(P.bowOwned){ctx.fillStyle='#6b4a2f';ctx.save();ctx.translate(x-7,y-4-bob);ctx.rotate(.5);
  ctx.fillRect(-2,-6,4,12);ctx.restore();
  ctx.strokeStyle='#c8b790';ctx.beginPath();ctx.moveTo(x-9,y-10-bob);ctx.lineTo(x-6,y-6-bob);ctx.stroke()}
 // accessories
 if(hasEq('amulet')){ctx.fillStyle='#c9c9d4';ctx.beginPath();ctx.arc(x,y-4-bob,2.2,0,7);ctx.fill()}
 if(hasEq('firering')){ctx.fillStyle='#ff9a3c';ctx.fillRect(x+5,y-2-bob,2.4,2.4)}
 // head
 ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x,y-12-bob+lean*.6,6.2,0,7);ctx.fill();
 ctx.fillStyle='#6b4a2f';ctx.beginPath();ctx.arc(x,y-14-bob+lean*.6,5.6,Math.PI*.95,Math.PI*2.05);ctx.fill();
 // eyes by facing
 const fx=Math.cos(P.ang),fy=Math.sin(P.ang);
 ctx.fillStyle='#222';
 if(fy>0.55){ctx.fillRect(x-3,y-12-bob,1.8,2.4);ctx.fillRect(x+1.5,y-12-bob,1.8,2.4)}
 else if(fy<-0.55){}
 else{ctx.fillRect(x+(fx>0?1:-3),y-12.5-bob,2,2.6)}
 if(hasEq('charm')){
  const a=G.time*3;
  ctx.fillStyle='#7cfc90';ctx.beginPath();ctx.arc(x+Math.cos(a)*11,y-6+Math.sin(a)*5,1.8,0,7);ctx.fill();
 }
 // weapon
 if(w){
  if(P.atk.on){
   const p=Math.min(1,P.atk.t/P.atk.dur);
   const ease=1-(1-p)*(1-p);
   const dir=P.atk.combo%2===0?1:-1;
   const a=P.atk.ang - (w.arc/2)*dir + w.arc*ease*dir;
   drawWeaponSprite(x,y-2-bob,a,w,p);
   // swing arc fx
   ctx.strokeStyle=w.fire?'rgba(255,154,60,.5)':'rgba(232,232,240,.4)';
   ctx.lineWidth=3;ctx.beginPath();
   ctx.arc(x,y-2-bob,w.range+8,a-.3*dir,a+.12*dir,dir<0);ctx.stroke();
  }else{
   drawWeaponSprite(x,y-2-bob,P.ang+.55,w,0);
  }
 }else if(P.bowOwned&&P.weaponIdx===3){
  ctx.save();ctx.translate(x,y-2-bob);ctx.rotate(P.ang);
  ctx.strokeStyle='#8a683f';ctx.lineWidth=2.6;
  ctx.beginPath();ctx.arc(9,0,8,-1.15,1.15);ctx.stroke();
  ctx.strokeStyle='#e8e8f0';ctx.lineWidth=1;
  ctx.beginPath();ctx.moveTo(9+Math.cos(-1.15)*8,Math.sin(-1.15)*8);
  ctx.lineTo(9+Math.cos(1.15)*8,Math.sin(1.15)*8);ctx.stroke();
  if(P.arrows>0){ctx.strokeStyle='#c8b790';ctx.lineWidth=1.6;
   ctx.beginPath();ctx.moveTo(2,0);ctx.lineTo(15,0);ctx.stroke()}
  ctx.restore();
 }
 ctx.restore();
}

/* ---------------- cave darkness ---------------- */
let dark=null;
function drawDarkness(){
 const ptx=Math.floor(P.x/TS),pty=Math.floor(P.y/TS);
 const inCave=pty<19&&ptx>=24&&ptx<=94;
 if(!inCave)return;
 if(!dark){dark=document.createElement('canvas')}
 if(dark.width!==VW||dark.height!==VH){dark.width=VW;dark.height=VH}
 const g=dark.getContext('2d');
 g.clearRect(0,0,VW,VH);
 g.fillStyle='rgba(4,5,12,.93)';g.fillRect(0,0,VW,VH);
 g.globalCompositeOperation='destination-out';
 const punch=(px,py,r,strength)=>{
  const grd=g.createRadialGradient(px,py,r*.15,px,py,r);
  grd.addColorStop(0,'rgba(0,0,0,'+strength+')');grd.addColorStop(1,'rgba(0,0,0,0)');
  g.fillStyle=grd;g.beginPath();g.arc(px,py,r,0,7);g.fill();
 };
 punch(P.x-cam.x,P.y-cam.y,180,1);
 for(const tt of torchesA)if(tt.lit)punch(tt.x-cam.x,tt.y-cam.y,110,.95);
 for(const s of shrines)if(G.flags.shrineIds[s.id])punch(s.x-cam.x,s.y-cam.y,80,.8);
 // lava glow
 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+=2)for(let tx=x0;tx<=x1;tx+=2)
  if(tAt(tx,ty)===T.LAVA)punch(tx*TS+16-cam.x,ty*TS+16-cam.y,54,.55);
 g.globalCompositeOperation='source-over';
 ctx.drawImage(dark,0,0);
}

/* ---------------- HUD ---------------- */
function drawHeart(x,y,full,half,locked){
 ctx.save();ctx.translate(x,y);
 ctx.beginPath();
 ctx.moveTo(0,3);
 ctx.bezierCurveTo(-9,-5,-4,-11,0,-6);
 ctx.bezierCurveTo(4,-11,9,-5,0,3);
 ctx.closePath();
 if(locked){ctx.strokeStyle='rgba(216,179,74,.28)';ctx.lineWidth=1.4;ctx.stroke();ctx.restore();return}
 ctx.fillStyle=full?'#e5484d':half?'#5a2026':'#2a1216';
 ctx.fill();
 if(half){ctx.save();ctx.clip();ctx.fillStyle='#e5484d';ctx.fillRect(-10,-12,10,20);ctx.restore();
  ctx.strokeStyle='#00000055';ctx.stroke()}
 ctx.strokeStyle='#7a1216';ctx.lineWidth=1;ctx.stroke();
 if(full){ctx.fillStyle='rgba(255,255,255,.55)';ctx.beginPath();ctx.arc(-3,-4,1.6,0,7);ctx.fill()}
 ctx.restore();
}
function drawHUD(){
 // hearts (each = 2 HP), 5 slots total
 for(let i=0;i<5;i++){
  const x=26+i*26,y=26;
  const hpFor=P.hp-i*2;
  const locked=i>=P.containers;
  const hurtShake=P.hurtT>0&&i<P.containers?Math.sin(G.time*60)*1.5:0;
  drawHeart(x+hurtShake,y,hpFor>=2,hpFor===1,locked);
 }
 // stamina
 ctx.fillStyle='rgba(0,0,0,.5)';ctx.fillRect(20,42,110,7);
 ctx.fillStyle=P.fatigued?'#e0a83c':'#5fd068';
 ctx.fillRect(21,43,108*(P.stamina/P.maxStamina),5);
 ctx.strokeStyle='rgba(255,255,255,.15)';ctx.strokeRect(20.5,42.5,110,7);
 // gold + arrows
 ctx.font='13px sans-serif';ctx.textAlign='left';
 ctx.fillStyle='#ffd76a';ctx.fillText('● '+P.gold,22,68);
 if(P.bowOwned){ctx.fillStyle='#c8b790';ctx.fillText('➶ '+P.arrows,90,68)}
 ctx.fillStyle='rgba(232,226,208,.5)';ctx.font='10px sans-serif';
 ctx.fillText('M music · I bag',22,84);

 // weapon box
 const boxY=VH-58;
 ctx.fillStyle='rgba(10,12,18,.75)';ctx.fillRect(16,boxY,190,44);
 ctx.strokeStyle='#d8b34a66';ctx.strokeRect(16.5,boxY+.5,190,44);
 if(P.weaponIdx===3){
  ctx.font='13px sans-serif';ctx.fillStyle='#e8e2d0';
  ctx.fillText('🏹 '+BOW.name,28,boxY+18);
  ctx.fillStyle='#9a927c';ctx.font='11px sans-serif';
  ctx.fillText(P.arrows>0?'dmg 1 · uses arrows':'NO ARROWS!',28,boxY+34);
 }else{
  const w=WEAPONS[P.weaponIdx];
  ctx.font='13px sans-serif';ctx.fillStyle='#e8e2d0';
  ctx.fillText(['🗡️','⚔️','🔥'][P.weaponIdx]+' '+w.name,28,boxY+18);
  ctx.fillStyle='#9a927c';ctx.font='11px sans-serif';
  ctx.fillText('dmg '+w.dmg+(w.fire?' +burn':'')+' · Q swap',28,boxY+34);
 }
 // quickslots
 ctx.fillStyle='rgba(10,12,18,.75)';ctx.fillRect(214,boxY,92,44);
 ctx.strokeStyle='#d8b34a44';ctx.strokeRect(214.5,boxY+.5,92,44);
 ctx.font='11px sans-serif';ctx.fillStyle='#e8e2d0';
 ctx.fillText('1🧪×'+(P.inv.potion||0),222,boxY+18);
 ctx.fillText('2💣×'+(P.inv.bomb||0),222,boxY+34);
 ctx.fillStyle='#9a927c';ctx.fillText('quick',272,boxY+26);

 // boss bar
 const boss=enemies.find(e=>e.t==='boss'&&!e.dead);
 if(boss){
  const bw=Math.min(460,VW-120);
  ctx.fillStyle='rgba(0,0,0,.6)';ctx.fillRect(VW/2-bw/2-2,16,bw+4,18);
  ctx.fillStyle='#5a1015';ctx.fillRect(VW/2-bw/2,18,bw,14);
  ctx.fillStyle=boss.hp<boss.maxHp*.5?'#ff7040':'#e5484d';
  ctx.fillRect(VW/2-bw/2,18,bw*boss.hp/boss.maxHp,14);
  ctx.font='11px Georgia,serif';ctx.fillStyle='#ffd9c0';ctx.textAlign='center';
  ctx.fillText('— THE ASH KING —',VW/2,29);ctx.textAlign='left';
 }
 // interact prompt
 const it=nearestInteract();
 if(it&&G.state==='play'){
  const sx=it.x-cam.x,sy=it.y-cam.y-34;
  ctx.font='12px sans-serif';
  const tw=ctx.measureText('[E] '+it.label).width;
  ctx.fillStyle='rgba(10,12,18,.8)';ctx.fillRect(sx-tw/2-7,sy-12,tw+14,19);
  ctx.strokeStyle='#d8b34a66';ctx.strokeRect(sx-tw/2-6.5,sy-11.5,tw+13,18);
  ctx.fillStyle='#ffe9b0';ctx.textAlign='center';ctx.fillText('[E] '+it.label,sx,sy+2);ctx.textAlign='left';
 }
 // vignette
 const vg=ctx.createRadialGradient(VW/2,VH/2,Math.min(VW,VH)*.42,VW/2,VH/2,Math.max(VW,VH)*.72);
 vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,.42)');
 ctx.fillStyle=vg;ctx.fillRect(0,0,VW,VH);
}
function drawMini(){
 mctx.clearRect(0,0,160,102);
 const sx=160/MW,sy=102/MH;
 mctx.fillStyle='#101820';mctx.fillRect(0,0,160,102);
 // zones
 mctx.fillStyle='#2e3240';mctx.fillRect(24*sx,0,70*sx,19*sy);            // mountain
 mctx.fillStyle='#3d7a4666';mctx.fillRect(0,0,160,102);
 mctx.fillStyle='#8f8f98';mctx.fillRect(64*sx,20*sy,31*sx,39*sy);        // ruins
 mctx.fillStyle='#b09a6a';mctx.fillRect(8*sx,44*sy,23*sx,17*sy);         // village
 mctx.fillStyle='#2c5e34';mctx.fillRect(3*sx,6*sy,27*sx,51*sy);          // forest
 mctx.fillStyle='#7a2010';mctx.fillRect(42*sx,4*sy,14*sx,6*sy);          // lava
 // shrines
 for(const s of shrines){mctx.fillStyle=G.flags.shrineIds[s.id]?'#8fd3ff':'#5a5f6e';
  mctx.fillRect(s.x/TS*sx-1.5,s.y/TS*sy-1.5,3,3)}
 // bosses alive
 for(const e of enemies)if(!e.dead&&(e.t==='warden'||e.t==='guardian'||e.t==='boss')){
  mctx.fillStyle='#e5484d';mctx.fillRect(e.x/TS*sx-1.5,e.y/TS*sy-1.5,3,3)}
 // player
 mctx.fillStyle='#fff';mctx.beginPath();mctx.arc(P.x/TS*sx,P.y/TS*sy,2.4,0,7);mctx.fill();
 // viewport
 mctx.strokeStyle='rgba(255,255,255,.25)';
 mctx.strokeRect(cam.x/TS*sx,cam.y/TS*sy,VW/TS*sx,VH/TS*sy);
}

/* ---------------- floaters/projs/parts draw ---------------- */
function drawProjs(){
 for(const p of projs){
  const x=p.x-cam.x,y=p.y-cam.y;
  if(p.type==='arrow'||p.type==='earrow'){
   ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(p.vy,p.vx));
   ctx.strokeStyle=p.type==='arrow'?'#c8b790':'#9fdc7f';ctx.lineWidth=2;
   ctx.beginPath();ctx.moveTo(-7,0);ctx.lineTo(6,0);ctx.stroke();
   ctx.fillStyle='#e8e8f0';ctx.beginPath();ctx.moveTo(8,0);ctx.lineTo(4,-2);ctx.lineTo(4,2);ctx.fill();
   ctx.restore();
  }else{
   ctx.fillStyle='rgba(255,154,60,.9)';ctx.beginPath();ctx.arc(x,y,5,0,7);ctx.fill();
   ctx.fillStyle='#ffe9b0';ctx.beginPath();ctx.arc(x,y,2.4,0,7);ctx.fill();
  }
 }
}
function drawParts(){
 for(const p of parts){
  const a=1-p.t/p.life;
  ctx.globalAlpha=a;ctx.fillStyle=p.c;
  ctx.fillRect(p.x-cam.x-p.sz/2,p.y-cam.y-p.sz/2,p.sz,p.sz);
 }
 ctx.globalAlpha=1;
}
function drawFloaters(){
 ctx.font='bold 13px sans-serif';ctx.textAlign='center';
 for(const f of floaters){
  ctx.globalAlpha=1-f.t;
  ctx.fillStyle='#000';ctx.fillText(f.txt,f.x-cam.x+1,f.y-cam.y+1);
  ctx.fillStyle=f.c;ctx.fillText(f.txt,f.x-cam.x,f.y-cam.y);
 }
 ctx.globalAlpha=1;ctx.textAlign='left';
}

/* ---------------- master draw ---------------- */
function draw(){
 ctx.fillStyle='#0a0c10';ctx.fillRect(0,0,VW,VH);
 ctx.save();
 if(G.shake>0)ctx.translate(rnd(-G.shake,G.shake)*.6,rnd(-G.shake,G.shake)*.6);
 drawWorld();
 // ground-level markers (plates under blocks handled here)
 drawPlatesBlocks();
 drawDoorTiles();
 for(const c of chests)drawChest(c);
 for(const s of shrines)drawShrine(s);
 for(const tb of tablets)drawTablet(tb);
 for(const tt of torchesA)drawTorch(tt);
 drawPedestal();
 drawDummy();
 for(const p of pickups)drawPickup(p);
 for(const b of bombsA)drawBomb(b);
 // y-sorted actors
 const actors=[];
 for(const e of enemies)if(!e.dead)actors.push(e);
 for(const n of npcs)actors.push(n);
 actors.push({_player:true,y:P.y});
 actors.sort((a,b)=>a.y-b.y);
 for(const a of actors){
  if(a._player)drawPlayer();
  else if(a.id)drawNPC(a);
  else drawEnemy(a);
 }
 drawProjs();
 drawTreeOver();
 drawParts();
 drawFloaters();
 ctx.restore();
 drawDarkness();
 if(G.state!=='title')drawHUD();
 drawMini();
 dlgTick(1/60);
}

/* ---------------- key dispatch ---------------- */
function handleKey(code){
 if(code==='KeyM'){AU.musicOn=!AU.musicOn;toast(AU.musicOn?'Music on':'Music off');return}
 switch(G.state){
 case 'title':
  if(code==='Enter'||code==='Space'){AU.ensure();
   if(hasSave())doContinue();else startNew();}
  break;
 case 'play':
  if(code==='KeyE'||code==='Enter')tryInteract();
  else if(code==='KeyI'||code==='Tab'||code==='KeyB')openInv();
  else if(code==='KeyQ')cycleWeapon();
  else if(code==='Digit1')usePotion();
  else if(code==='Digit2')useBomb();
  else if(code==='Escape')openPause();
  break;
 case 'dialog':
  if(code==='KeyE'||code==='Enter'||code==='Space')dlgAdvance();
  break;
 case 'inv':
  if(code==='KeyI'||code==='Tab'||code==='Escape'||code==='KeyB')closeInv();
  break;
 case 'shop':
  if(code==='Escape')closeShop();
  break;
 case 'pause':
  if(code==='Escape')closePause();
  break;
 case 'win':
  if(code==='Escape'){document.getElementById('win').classList.add('hidden');G.state='play'}
  break;
 }
}
function openPause(){G.state='pause';document.getElementById('pauseP').classList.remove('hidden')}
function closePause(){G.state='play';document.getElementById('pauseP').classList.add('hidden')}

/* ---------------- world build & flow ---------------- */
function buildWorld(){
 buildMap();buildBase();
 projs=[];pickups=[];parts=[];floaters=[];bombsA=[];
 dummy.wob=0;
 respawnAll();
}
function startNew(){
 AU.ensure();
 try{localStorage.removeItem(SAVEKEY)}catch(e){}
 // fresh state
 P.hp=6;P.maxHp=6;P.containers=3;P.gold=20;P.arrows=0;
 P.weaponIdx=0;P.weaponsOwned=[true,false,false];P.bowOwned=false;
 P.inv={};P.equipped=[];P.x=16.5*TS;P.y=52*TS;P.dead=false;P.gone=false;
 P.stamina=P.maxStamina;P.iframes=0;
 G.stage=0;G.slimes=0;G.kills=0;G.playTime=0;G.winT=0;
 G.flags={chests:{},doors:{},torches:{},wardenDead:false,guardianDead:false,bossDead:false,
  puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}};
 for(const c of chests)c.opened=false;
 for(const d of doors)d.open=false;
 for(const t of torchesA)t.lit=false;
 for(const b of blocksA){b.moving=false}
 blocksA[0].tx=83;blocksA[0].ty=50;blocksA[0].x=83*TS+16;blocksA[0].y=50*TS+16;
 blocksA[1].tx=86;blocksA[1].ty=51;blocksA[1].x=86*TS+16;blocksA[1].y=51*TS+16;
 blocksA[2].tx=80;blocksA[2].ty=52;blocksA[2].x=80*TS+16;blocksA[2].y=52*TS+16;
 pedestal.taken=false;
 G.respawn={x:16.5*TS,y:52*TS};
 buildWorld();
 setStage(0);
 document.getElementById('title').classList.add('hidden');
 G.state='play';
 cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
 showDialogue('The Vale',["A rusty sword at your hip. Three hearts beating. The village shrine glows behind you.",
  "WASD to move · J/Z/Space to attack · K to dodge · E to interact."],null);
}
function doContinue(){
 AU.ensure();
 if(loadGame()){
  document.getElementById('title').classList.add('hidden');
  G.state='play';
  toast('Welcome back, traveler.');
 }else startNew();
}

/* ---------------- main loop ---------------- */
let last=0;
function loop(t){
 requestAnimationFrame(loop);
 const dt=Math.min(.05,(t-last)/1000||.016);last=t;
 if(G.state==='play')update(dt);
 else if(G.state==='dialog'||G.state==='inv'||G.state==='shop'){
  G.time+=dt;updateParts(dt);
 }
 draw();
 for(const k in pressed)pressed[k]=false;
}
function init(){
 buildWorld();
 setStage(0);
 document.getElementById('btnNew').onclick=startNew;
 const bc=document.getElementById('btnCont');
 if(hasSave()){bc.disabled=false;bc.onclick=doContinue}
 document.getElementById('btnRise').onclick=respawnPlayer;
 document.getElementById('btnResume').onclick=closePause;
 document.getElementById('btnQuit').onclick=()=>{
  document.getElementById('pauseP').classList.add('hidden');
  G.state='title';document.getElementById('title').classList.remove('hidden');
 };
 document.getElementById('btnWinClose').onclick=()=>{
  document.getElementById('win').classList.add('hidden');G.state='play';
 };
 window.addEventListener('pointerdown',()=>AU.ensure(),{once:true});
 requestAnimationFrame(loop);
}
window.G=G;window.P=P;window.AU=AU;
init();
</script>
</body>
</html>