← RPG results

RPG

GLM 5.2 · full

Initial view of RPG

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 80,576 bytes · SHA-256 263d36c012ad
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Ember Hollow — An Action Adventure</title>
<style>
  :root{
    --ink:#1a1426; --gold:#ffcc4d; --hp:#ff5a5a; --stam:#5ad7ff;
    --panel:rgba(20,14,30,.92); --panel2:rgba(34,24,48,.96);
    --accent:#c79bff; --good:#7CFC9A;
  }
  *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{height:100%;background:#0a0710;color:#efe6ff;font-family:"Trebuchet MS","Segoe UI",sans-serif;overflow:hidden;user-select:none}
  #wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 30%,#241a36,#0a0710)}
  #stage{position:relative;width:800px;height:600px;max-width:100%;max-height:100%;aspect-ratio:4/3;box-shadow:0 0 0 3px #000,0 0 40px #000a,0 0 80px #3a1e5a55 inset;border-radius:6px;overflow:hidden}
  canvas{display:block;width:100%;height:100%;image-rendering:pixelated;image-rendering:crisp-edges;background:#10101a}
  /* HUD */
  #hud{position:absolute;inset:0;pointer-events:none;font-size:13px;letter-spacing:.3px}
  .hearts{position:absolute;top:10px;left:12px;display:flex;gap:3px;filter:drop-shadow(0 1px 2px #000)}
  .heart{width:22px;height:20px;position:relative}
  .stamina{position:absolute;top:38px;left:12px;width:150px;height:7px;background:#0008;border:1px solid #000;border-radius:4px;overflow:hidden}
  .stamina>i{display:block;height:100%;width:100%;background:linear-gradient(90deg,#3aa0d8,var(--stam));transition:width .08s linear}
  .coins{position:absolute;top:12px;right:14px;font-weight:bold;color:var(--gold);text-shadow:0 1px 2px #000,0 0 6px #ffcc4d55;display:flex;align-items:center;gap:6px;font-size:16px}
  .coins .c{width:14px;height:14px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff3b0,#e0a020);box-shadow:0 0 5px #ffcc4d88;display:inline-block}
  .weaponbar{position:absolute;bottom:10px;left:12px;display:flex;gap:8px;align-items:center;background:var(--panel);padding:5px 9px;border-radius:7px;border:1px solid #ffffff14}
  .wicon{width:22px;height:22px;display:grid;place-items:center;border-radius:5px;background:#0006}
  .wname{font-size:12px;color:#d9ccff}
  .wk{font-size:10px;color:#9a8cb5}
  .minimap{position:absolute;top:40px;right:14px;background:var(--panel);padding:6px;border-radius:7px;border:1px solid #ffffff14}
  .hint{position:absolute;bottom:10px;right:14px;font-size:11px;color:#b9a9d6;background:var(--panel);padding:4px 8px;border-radius:6px;border:1px solid #ffffff14}
  /* Overlays */
  .overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(8,5,16,.78);backdrop-filter:blur(2px);z-index:5;pointer-events:auto}
  .overlay.show{display:flex}
  .panel{background:var(--panel2);border:2px solid #4a2f6e;border-radius:14px;padding:20px 22px;max-width:560px;box-shadow:0 10px 50px #000a,0 0 60px #5a2f8a33}
  .panel h2{color:var(--accent);margin-bottom:6px;font-size:20px;letter-spacing:1px}
  .panel p{color:#cabbe6;line-height:1.5;margin:6px 0;font-size:13px}
  .btnrow{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}
  .btn{background:linear-gradient(#5a3590,#3a2160);color:#fff;border:1px solid #7a4fb5;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:bold;transition:.12s;pointer-events:auto}
  .btn:hover{background:linear-gradient(#6b46a8,#4a2c78);transform:translateY(-1px)}
  .btn:active{transform:translateY(0)}
  .btn.ghost{background:#2a1d3e;border-color:#4a3464}
  .btn:disabled{opacity:.4;cursor:not-allowed;transform:none}
  /* Shop */
  .shopgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
  .shopitem{display:flex;gap:10px;align-items:center;background:#1c1430;border:1px solid #3a285c;border-radius:9px;padding:8px;cursor:pointer;transition:.1s}
  .shopitem:hover{border-color:#7a4fb5;background:#241a3e}
  .shopitem.disabled{opacity:.45;cursor:not-allowed}
  .si-icon{width:30px;height:30px;border-radius:6px;background:#0c0818;display:grid;place-items:center;flex-shrink:0}
  .si-info{flex:1;min-width:0}
  .si-name{font-size:13px;font-weight:bold;color:#e8def8}
  .si-desc{font-size:10px;color:#9a8cb5;line-height:1.2}
  .si-price{font-size:12px;color:var(--gold);font-weight:bold;white-space:nowrap}
  /* Inventory */
  .inv-grid{display:grid;grid-template-columns:repeat(5,48px);grid-auto-rows:48px;gap:8px;margin:12px 0;justify-content:center}
  .inv-slot{width:48px;height:48px;background:#160f26;border:1px solid #3a285c;border-radius:8px;display:grid;place-items:center;position:relative;cursor:pointer}
  .inv-slot.empty{opacity:.4}
  .inv-slot.equipped{border-color:var(--gold);box-shadow:0 0 8px #ffcc4d55}
  .inv-count{position:absolute;bottom:1px;right:3px;font-size:10px;color:#fff;text-shadow:0 1px 1px #000;font-weight:bold}
  .equip-slots{display:flex;gap:10px;justify-content:center;margin-top:6px}
  .eslot{width:52px;height:52px;border:2px dashed #4a3464;border-radius:9px;display:grid;place-items:center;font-size:9px;color:#6a5a86;text-align:center}
  .eslot.filled{border-style:solid;border-color:var(--accent)}
  .toast{position:absolute;top:74px;left:50%;transform:translateX(-50%) translateY(-10px);background:var(--panel);border:1px solid #7a4fb5;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;opacity:0;transition:.3s;pointer-events:none;z-index:6;text-align:center;max-width:80%}
  .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  .title-screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 40%,#2a1840,#0a0710);z-index:8}
  .title-screen h1{font-size:46px;letter-spacing:4px;color:var(--gold);text-shadow:0 0 20px #ffcc4d55,0 4px 0 #3a2160;margin-bottom:4px}
  .title-screen .sub{color:var(--accent);letter-spacing:3px;font-size:13px;margin-bottom:24px}
  .controls-list{color:#b9a9d6;font-size:12px;line-height:1.9;text-align:center;background:#0006;padding:12px 18px;border-radius:10px;border:1px solid #3a285c}
  .controls-list b{color:#e8def8}
  .blink{animation:blink 1.2s steps(2) infinite}
  @keyframes blink{50%{opacity:.3}}
  #dialog{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);width:min(90%,560px);background:var(--panel);border:1px solid #7a4fb5;border-radius:10px;padding:12px 16px;display:none;z-index:4;pointer-events:none}
  #dialog.show{display:block}
  #dialog .who{color:var(--accent);font-weight:bold;font-size:12px;margin-bottom:3px}
  #dialog .what{color:#e8def8;font-size:13px;line-height:1.45}
  .fade{position:absolute;inset:0;background:#000;z-index:9;pointer-events:none;opacity:0;transition:opacity .35s}
  @media(max-width:820px){#stage{box-shadow:none}}
</style>
</head>
<body>
<div id="wrap">
  <div id="stage">
    <canvas id="game" width="800" height="600"></canvas>
    <div id="hud">
      <div class="hearts" id="hearts"></div>
      <div class="stamina"><i id="stambar"></i></div>
      <div class="coins" id="coins"><span class="c"></span><span id="coinnum">0</span></div>
      <canvas class="minimap" id="minimap" width="86" height="60"></canvas>
      <div class="weaponbar" id="weaponbar"></div>
      <div class="hint" id="hint">I: Inventory &nbsp; E: Interact</div>
    </div>
    <div id="dialog"><div class="who" id="dlgwho"></div><div class="what" id="dlgwhat"></div></div>
    <div class="toast" id="toast"></div>

    <!-- Inventory -->
    <div class="overlay" id="invOverlay">
      <div class="panel" style="min-width:380px">
        <h2>Inventory</h2>
        <div class="inv-grid" id="invGrid"></div>
        <p style="font-size:11px;color:#9a8cb5;text-align:center;margin-bottom:4px">Equipped Accessories (click accessory to equip / unequip)</p>
        <div class="equip-slots" id="equipSlots"></div>
        <div class="btnrow"><button class="btn ghost" onclick="UI.closeInv()">Close (I)</button></div>
      </div>
    </div>

    <!-- Shop -->
    <div class="overlay" id="shopOverlay">
      <div class="panel" style="min-width:440px">
        <h2 id="shopTitle">Wandering Merchant</h2>
        <p style="font-size:11px;color:#9a8cb5">Spend your coins wisely, traveler.</p>
        <div class="shopgrid" id="shopGrid"></div>
        <div class="btnrow"><button class="btn ghost" onclick="UI.closeShop()">Leave (E)</button></div>
      </div>
    </div>

    <!-- Save prompt -->
    <div class="overlay" id="saveOverlay">
      <div class="panel" style="min-width:340px;text-align:center">
        <h2>Sanctum Crystal</h2>
        <p id="saveText">Record your progress at this crystal?</p>
        <div class="btnrow" style="justify-content:center">
          <button class="btn" id="saveBtn" onclick="Save.confirm()">Save</button>
          <button class="btn ghost" onclick="UI.closeSave()">Cancel</button>
        </div>
      </div>
    </div>

    <!-- Pause / Game Over -->
    <div class="overlay" id="overOverlay">
      <div class="panel" style="text-align:center;min-width:340px">
        <h2 id="overTitle"></h2>
        <p id="overText"></p>
        <div class="btnrow" style="justify-content:center"><button class="btn" id="overBtn" onclick="Game.restart()">Continue</button></div>
      </div>
    </div>

    <!-- Title -->
    <div class="title-screen" id="titleScreen">
      <h1>EMBER HOLLOW</h1>
      <div class="sub">— A SHORT ACTION ADVENTURE —</div>
      <div class="controls-list">
        <b>WASD / Arrows</b> — Move &nbsp; • &nbsp; <b>Shift</b> — Sprint<br>
        <b>J / Space</b> — Attack &nbsp; • &nbsp; <b>K</b> — Bow (when found)<br>
        <b>E</b> — Interact &nbsp; • &nbsp; <b>I / Tab</b> — Inventory
      </div>
      <div style="margin-top:18px">
        <button class="btn" id="newBtn" onclick="Game.newGame()">New Game</button>
        <button class="btn ghost" id="loadBtn" onclick="Game.loadGame()" style="display:none">Continue</button>
      </div>
      <p class="blink" style="margin-top:20px;color:#7a6a96;font-size:11px">Click to enable audio</p>
    </div>

    <div class="fade" id="fade"></div>
  </div>
</div>

<script>
"use strict";
/* ============================================================
   EMBER HOLLOW — single-file action-adventure RPG
   ============================================================ */
const TILE = 40, COLS = 20, ROWS = 15, VW = COLS*TILE, VH = ROWS*TILE;
const cv = document.getElementById('game');
const ctx = cv.getContext('2d');
ctx.imageSmoothingEnabled = false;

/* ---------- Utilities ---------- */
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(ax-bx,ay-by);
const rand=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rand(a,b+1));
const now=()=>performance.now();
function choice(a){return a[Math.floor(Math.random()*a.length)]}
// deterministic pseudo-random per tile for decoration
function tnoise(x,y){const s=Math.sin(x*127.1+y*311.7)*43758.5453;return s-Math.floor(s);}

/* ============================================================
   AUDIO ENGINE — procedural Web Audio SFX
   ============================================================ */
const Audio = (()=>{
  let actx=null, master=null, muted=false, walkT=0;
  function init(){
    if(actx) return;
    try{ actx=new (window.AudioContext||window.webkitAudioContext)(); master=actx.createGain(); master.gain.value=0.5; master.connect(actx.destination);}catch(e){}
  }
  function resume(){ if(actx&&actx.state==='suspended') actx.resume(); }
  function tone(freq,dur,type='sine',vol=0.3,slideTo=null,delay=0){
    if(!actx||muted) return;
    const t=actx.currentTime+delay;
    const o=actx.createOscillator(), g=actx.createGain();
    o.type=type; o.frequency.setValueAtTime(freq,t);
    if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(1,slideTo),t+dur);
    g.gain.setValueAtTime(0.0001,t);
    g.gain.exponentialRampToValueAtTime(vol,t+0.008);
    g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    o.connect(g); g.connect(master); o.start(t); o.stop(t+dur+0.02);
  }
  function noise(dur,vol=0.3,filterFreq=1000,type='lowpass',slideTo=null){
    if(!actx||muted) return;
    const t=actx.currentTime;
    const n=Math.floor(actx.sampleRate*dur);
    const buf=actx.createBuffer(1,n,actx.sampleRate);
    const d=buf.getChannelData(0);
    for(let i=0;i<n;i++) d[i]=(Math.random()*2-1);
    const src=actx.createBufferSource(); src.buffer=buf;
    const f=actx.createBiquadFilter(); f.type=type; f.frequency.setValueAtTime(filterFreq,t);
    if(slideTo) f.frequency.exponentialRampToValueAtTime(Math.max(40,slideTo),t+dur);
    const g=actx.createGain(); g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
    src.connect(f); f.connect(g); g.connect(master); src.start(t); src.stop(t+dur+0.02);
  }
  const sfx={
    walk(){ if(now()-walkT<180) return; walkT=now(); noise(0.05,0.06,500,'lowpass'); },
    sword(){ noise(0.12,0.18,2600,'highpass',600); tone(420,0.1,'triangle',0.12,180); },
    hit(){ noise(0.09,0.28,1400,'lowpass',300); tone(160,0.08,'square',0.14,80); },
    hurt(){ tone(300,0.22,'sawtooth',0.22,90); noise(0.1,0.18,800); },
    pickup(){ tone(660,0.08,'square',0.16); tone(990,0.1,'square',0.16,null,0.08); },
    coin(){ tone(988,0.06,'square',0.14); tone(1319,0.09,'square',0.12,null,0.05); },
    shop(){ tone(523,0.12,'triangle',0.18); tone(659,0.12,'triangle',0.16,null,0.1); tone(784,0.2,'triangle',0.16,null,0.2); },
    save(){ [392,494,587,784].forEach((f,i)=>tone(f,0.5,'sine',0.16,null,i*0.09)); },
    death(){ noise(0.3,0.3,1200,'lowpass',120); tone(200,0.4,'sawtooth',0.2,50); },
    heart(){ [523,659,784,1047].forEach((f,i)=>tone(f,0.4,'triangle',0.2,null,i*0.1)); },
    bow(){ tone(900,0.06,'sine',0.16,300); noise(0.05,0.08,2000,'highpass'); },
    unlock(){ tone(330,0.15,'square',0.16); tone(440,0.15,'square',0.16,null,0.12); tone(660,0.3,'square',0.18,null,0.24); },
    block(){ noise(0.08,0.18,300,'lowpass'); tone(90,0.1,'square',0.12,60); },
    pot(){ noise(0.12,0.22,1800,'highpass',400); },
    enemyAtk(){ tone(220,0.1,'sawtooth',0.1,140); },
    bossRoar(){ tone(110,0.5,'sawtooth',0.25,70); noise(0.5,0.2,400,'lowpass'); }
  };
  return { init, resume, sfx, setMuted(m){muted=m;}, isMuted(){return muted;} };
})();

/* ============================================================
   INPUT
   ============================================================ */
const Input = (()=>{
  const keys={}, just={};
  let attLock=0;
  window.addEventListener('keydown',e=>{
    const k=e.key.toLowerCase();
    if(['arrowup','arrowdown','arrowleft','arrowright',' ','tab'].includes(k)) e.preventDefault();
    if(!keys[k]) just[k]=true;
    keys[k]=true;
    Audio.resume();
  });
  window.addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
  window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
  // right click = bow
  cv.addEventListener('contextmenu',e=>{e.preventDefault();});
  cv.addEventListener('mousedown',e=>{ if(e.button===2) just['k']=true; else if(e.button===0) just['j']=true; Audio.resume();});
  return {
    held:k=>!!keys[k],
    pressed:k=>{ if(just[k]){return true;} return false; },
    clearJust(){ for(const k in just) just[k]=false; },
    anyDir(){ return keys['w']||keys['arrowup']||keys['s']||keys['arrowdown']||keys['a']||keys['arrowleft']||keys['d']||keys['arrowright']; },
    dir(){
      let x=0,y=0;
      if(keys['a']||keys['arrowleft'])x=-1; if(keys['d']||keys['arrowright'])x=1;
      if(keys['w']||keys['arrowup'])y=-1; if(keys['s']||keys['arrowdown'])y=1;
      return {x,y};
    }
  };
})();

/* ============================================================
   WORLD / TILE MAP BUILDER
   ============================================================ */
const T = { GRASS:0, PATH:1, WATER:2, TREE:3, WALL:4, SAND:5, COBBLE:6, DARK:7, RUIN:8, FLOW:9, BUSH:10 };
const SOLID_TILES = new Set([T.WATER,T.TREE,T.WALL,T.BUSH]);

// build a 20x15 map: border walls, gaps on specified exit sides, floor fill, then overrides
function buildMap(floorChar, exits, overrides){
  // char -> tile
  const cmap = {'.':T.GRASS, ',':T.PATH, '~':T.WATER, 'T':T.TREE, '#':T.WALL, 'S':T.SAND, 'o':T.COBBLE, 'd':T.DARK, 'r':T.RUIN, 'f':T.FLOW, 'b':T.BUSH};
  const map=[];
  for(let y=0;y<ROWS;y++){const row=[];for(let x=0;x<COLS;x++)row.push(cmap[floorChar]??T.GRASS);map.push(row);}
  // borders
  for(let x=0;x<COLS;x++){map[0][x]=T.WALL;map[ROWS-1][x]=T.WALL;}
  for(let y=0;y<ROWS;y++){map[y][0]=T.WALL;map[y][COLS-1]=T.WALL;}
  // exit gaps (2 wide, centered)
  if(exits.N){map[0][9]=floorChar?cmap[floorChar]:T.GRASS;map[0][10]=floorChar?cmap[floorChar]:T.GRASS;}
  if(exits.S){map[ROWS-1][9]=cmap[floorChar]??T.GRASS;map[ROWS-1][10]=cmap[floorChar]??T.GRASS;}
  if(exits.W){map[7][0]=cmap[floorChar]??T.GRASS;map[8][0]=cmap[floorChar]??T.GRASS;}
  if(exits.E){map[7][COLS-1]=cmap[floorChar]??T.GRASS;map[8][COLS-1]=cmap[floorChar]??T.GRASS;}
  // overrides
  for(const o of overrides){ const [x,y,ch]=o; if(x>=0&&x<COLS&&y>=0&&y<ROWS) map[y][x]=cmap[ch]??T.GRASS; }
  return map;
}

/* ============================================================
   ROOM DEFINITIONS
   Each room: map, exits {N,E,S,W -> roomName}, spawns
   spawns: {type, tx, ty, ...data}
   ============================================================ */
const ROOMS = {
  town: {
    name:"Haven Town", floor:'o',
    exits:{E:'forest', W:'cave', N:'ruins', S:'lake'},
    overrides:[
      // shop building (top-left)
      [3,2,'#'],[4,2,'#'],[5,2,'#'],[6,2,'#'],
      [3,3,'#'],[6,3,'#'],[3,4,'#'],[6,4,'#'],[3,5,'#'],[6,5,'#'],
      [4,5,'o'], // door
      // save crystal plaza (top-right)
      [14,2,'#'],[15,2,'#'],[16,2,'#'],[17,2,'#'],
      [14,3,'#'],[17,3,'#'],[14,4,'#'],[17,4,'#'],[14,5,'#'],[17,5,'#'],
      [15,5,'o'],
      // fountain
      [9,7,'~'],[10,7,'~'],[9,8,'~'],[10,8,'~'],
      // bushes
      [12,10,'b'],[7,11,'b'],[13,12,'b'],
      [2,12,'b'],[17,11,'b'],
    ],
    spawns:[
      {type:'shop', tx:4.5, ty:4}, // shop building center-top
      {type:'save', tx:15.5, ty:4},
      {type:'npc', tx:10, ty:11, name:"Elder Voss",
       lines:[
        "Welcome to Haven Town, traveler. The Hollow stirs once more.",
        "Head EAST into the forest. Slay the beast there to claim a Heart Crystal.",
        "Your blade is dull — find a Whetstone in the caves to the WEST.",
        "When you are ready, the RUINS to the north hold a greater foe.",
        "A bow lies hidden beyond the lake... but only those half-proven may enter."
       ]},
      {type:'npc', tx:5, ty:9, name:"Merchant Ryn",
       lines:["My shop is just up the path! Press E to browse my wares.","Coin buys life, traveler. Spend it well."]},
      {type:'pot', tx:2, ty:6},{type:'pot', tx:18, ty:8},{type:'pot', tx:11, ty:12},
    ]
  },

  forest: {
    name:"Whispwood", floor:'.',
    exits:{W:'town', E:'clearing'},
    overrides:[
      // scattered trees forming a maze-ish path
      [3,3,'T'],[4,5,'T'],[6,3,'T'],[7,6,'T'],[9,4,'T'],[10,7,'T'],
      [12,3,'T'],[13,6,'T'],[15,4,'T'],[16,7,'T'],[5,9,'T'],[8,10,'T'],
      [11,9,'T'],[14,10,'T'],[3,11,'T'],[6,12,'T'],[10,12,'T'],[13,11,'T'],[16,11,'T'],
      [9,2,'b'],[12,5,'b'],[15,9,'b'],[4,8,'b'],
      // a path trail
      [9,7,','],[10,7,','],[9,8,','],[10,8,','],
    ],
    spawns:[
      {type:'enemy', tx:8, ty:4, kind:'slime'},
      {type:'enemy', tx:13, ty:6, kind:'slime'},
      {type:'enemy', tx:6, ty:10, kind:'blob'},
      {type:'enemy', tx:15, ty:11, kind:'blob'},
      {type:'coin', tx:4, ty:3},{type:'coin', tx:16, ty:3},
      {type:'coin', tx:5, ty:12},{type:'coin', tx:14, ty:9},
      {type:'pot', tx:3, ty:6},{type:'pot', tx:17, ty:6},
      {type:'pickup', tx:10, ty:9, item:'potion'},
    ]
  },

  clearing: {
    name:"Moonlit Clearing", floor:'.',
    exits:{W:'forest'},
    overrides:[
      [9,2,'f'],[10,2,'f'],[11,3,'f'],[8,3,'f'],
      [3,3,'T'],[4,3,'T'],[15,3,'T'],[16,3,'T'],
      [3,11,'T'],[16,11,'T'],[4,12,'T'],[15,12,'T'],
      [9,5,'f'],[10,5,'f'],[9,9,'f'],[10,9,'f'],
    ],
    spawns:[
      {type:'boss', tx:10, ty:7, kind:'wolf', drops:'heart1'},
      {type:'coin', tx:4, ty:4},{type:'coin', tx:16, ty:4},
      {type:'coin', tx:4, ty:11},{type:'coin', tx:16, ty:11},
    ]
  },

  cave: {
    name:"Dripstone Cave", floor:'d',
    exits:{E:'town', W:'underground'},
    overrides:[
      // pillars
      [4,4,'#'],[4,10,'#'],[15,4,'#'],[15,10,'#'],
      [8,6,'#'],[12,8,'#'],
      [3,7,'#'],[3,8,'#'], // block puzzle area near west
      [5,7,'.'],[5,8,'.'],
      // water puddle
      [10,11,'~'],[11,11,'~'],
    ],
    spawns:[
      {type:'enemy', tx:12, ty:4, kind:'bat'},
      {type:'enemy', tx:8, ty:10, kind:'bat'},
      {type:'block', tx:6, ty:7},
      {type:'plate', tx:4, ty:7, id:'caveplate'},
      {type:'door', tx:0, ty:7, plate:'caveplate', side:'W'}, // blocks west exit until plate
      {type:'door', tx:0, ty:8, plate:'caveplate', side:'W'},
      {type:'coin', tx:15, ty:7},{type:'coin', tx:8, ty:4},
      {type:'pot', tx:13, ty:10},
    ]
  },

  underground: {
    name:"Forgotten Vault", floor:'d',
    exits:{E:'cave'},
    overrides:[
      [5,3,'#'],[6,3,'#'],[7,3,'#'],[5,7,'#'],[6,7,'#'],[7,7,'#'],
      [12,3,'#'],[13,3,'#'],[14,3,'#'],[12,11,'#'],[13,11,'#'],[14,11,'#'],
      [9,5,'f'],[10,5,'f'],[9,9,'f'],[10,9,'f'],
      [9,7,'f'],[10,7,'f'],
    ],
    spawns:[
      {type:'enemy', tx:5, ty:9, kind:'blob'},
      {type:'enemy', tx:14, ty:5, kind:'blob'},
      {type:'enemy', tx:8, ty:4, kind:'bat'},
      {type:'pickup', tx:10, ty:7, item:'whetstone'}, // weapon upgrade 1
      {type:'coin', tx:5, ty:5},{type:'coin', tx:14, ty:9},
      {type:'pot', tx:3, ty:11},{type:'pot', tx:16, ty:3},
    ]
  },

  ruins: {
    name:"Sunken Ruins", floor:'r',
    exits:{S:'town'},
    overrides:[
      [4,3,'#'],[5,3,'#'],[6,3,'#'],[7,3,'#'],
      [12,3,'#'],[13,3,'#'],[14,3,'#'],[15,3,'#'],
      [4,4,'#'],[7,4,'#'],[12,4,'#'],[15,4,'#'],
      [4,5,'#'],[7,5,'#'],[12,5,'#'],[15,5,'#'],
      [4,11,'#'],[5,11,'#'],[6,11,'#'],[7,11,'#'],
      [12,11,'#'],[13,11,'#'],[14,11,'#'],[15,11,'#'],
      [9,7,'f'],[10,7,'f'],
      [9,2,'f'],[10,2,'f'],[9,12,'f'],[10,12,'f'],
    ],
    spawns:[
      {type:'enemy', tx:5, ty:8, kind:'blob'},
      {type:'enemy', tx:14, ty:8, kind:'bat'},
      {type:'enemy', tx:5, ty:5, kind:'slime'},
      {type:'boss', tx:10, ty:7, kind:'guardian', drops:'crimson'},
      {type:'pickup', tx:10, ty:3, item:'heart2'}, // second heart container
      {type:'coin', tx:3, ty:7},{type:'coin', tx:16, ty:7},
    ]
  },

  lake: {
    name:"Mistveil Lake", floor:'S',
    exits:{N:'town', S:'grotto'},
    overrides:[
      // big lake
      [4,4,'~'],[5,4,'~'],[6,4,'~'],[7,4,'~'],
      [4,5,'~'],[5,5,'~'],[6,5,'~'],[7,5,'~'],
      [4,6,'~'],[5,6,'~'],[6,6,'~'],[7,6,'~'],
      [12,8,'~'],[13,8,'~'],[14,8,'~'],[15,8,'~'],
      [12,9,'~'],[13,9,'~'],[14,9,'~'],[15,9,'~'],
      [12,10,'~'],[13,10,'~'],[14,10,'~'],[15,10,'~'],
      [9,7,'S'],[10,7,'S'],
      [3,11,'b'],[16,3,'b'],
    ],
    spawns:[
      {type:'npc', tx:9, ty:6, name:"Hermit Yael",
       lines:[
        "The grotto to the south hums with old magic.",
        "Its door will not yield to the unproven... return when your journey is half-done.",
        "Within: the Hunter's Bow. A second way to fight."
       ]},
      {type:'enemy', tx:14, ty:4, kind:'bat'},
      {type:'enemy', tx:5, ty:11, kind:'slime'},
      {type:'coin', tx:9, ty:3},{type:'coin', tx:10, ty:11},{type:'coin', tx:16, ty:7},
      {type:'pot', tx:3, ty:7},{type:'pot', tx:16, ty:11},
      {type:'pickup', tx:3, ty:3, item:'boots'}, // swift boots findable in world
    ]
  },

  grotto: {
    name:"Sealed Grotto", floor:'r',
    exits:{N:'lake'},
    overrides:[
      [4,4,'#'],[15,4,'#'],[4,10,'#'],[15,10,'#'],
      [8,3,'f'],[9,3,'f'],[10,3,'f'],[11,3,'f'],
      [8,11,'f'],[9,11,'f'],[10,11,'f'],[11,11,'f'],
      [9,7,'f'],[10,7,'f'],
    ],
    spawns:[
      {type:'enemy', tx:5, ty:8, kind:'bat'},
      {type:'enemy', tx:14, ty:6, kind:'bat'},
      {type:'pickup', tx:10, ty:7, item:'bow'}, // ranged weapon
      {type:'coin', tx:4, ty:4},{type:'coin', tx:15, ty:10},
    ]
  },
};

// adjacency for transitions: from room + side -> target room (mirrored)
const ADJ = {};
for(const name in ROOMS){ const e=ROOMS[name].exits; ADJ[name]=e; }

/* ============================================================
   ITEMS / WEAPONS DEFINITIONS
   ============================================================ */
const ITEMS = {
  potion:   {name:"Health Potion", desc:"Restore 2 hearts.", price:25, kind:'consumable', icon:'potion', color:'#ff5a8a'},
  elixir:   {name:"Red Elixir",    desc:"Fully restore health.", price:60, kind:'consumable', icon:'elixir', color:'#ff2a4a'},
  bomb:     {name:"Bomb",          desc:"Damages all nearby foes.", price:40, kind:'consumable', icon:'bomb', color:'#888'},
  ring:     {name:"Power Ring",    desc:"+1 attack damage (accessory).", price:80, kind:'accessory', icon:'ring', color:'#ffd24d'},
  boots:    {name:"Swift Boots",   desc:"+25% move speed (accessory).", price:0, kind:'accessory', icon:'boots', color:'#7CFC9A'},
  charm:    {name:"Lucky Charm",   desc:"+50% coin drops (accessory).", price:70, kind:'accessory', icon:'charm', color:'#c79bff'},
  // key items (not sold)
  whetstone:{name:"Whetstone",     desc:"Sharpens your blade. Upgrade I.", price:0, kind:'key', icon:'whetstone', color:'#cfd8e8'},
  crimson:  {name:"Crimson Blade", desc:"A blazing sword. Upgrade II.", price:0, kind:'key', icon:'crimson', color:'#ff3a3a'},
  bow:      {name:"Hunter's Bow",  desc:"Fire arrows at range.", price:0, kind:'key', icon:'bow', color:'#d8a868'},
  heart1:   {name:"Heart Crystal", desc:"Adds a heart container.", price:0, kind:'key', icon:'heart', color:'#ff5a5a'},
  heart2:   {name:"Heart Crystal", desc:"Adds a heart container.", price:0, kind:'key', icon:'heart', color:'#ff5a5a'},
};

const WEAPONS = [
  { id:0, name:"Rusty Sword", dmg:1, range:46, arc:Math.PI*0.5, cd:0.42, color:'#b8c0d0', len:26 },
  { id:1, name:"Sharpened Sword", dmg:2, range:52, arc:Math.PI*0.55, cd:0.38, color:'#e8eef8', len:30 },
  { id:2, name:"Crimson Blade", dmg:3, range:56, arc:Math.PI*0.6, cd:0.28, color:'#ff5a3a', len:32, fire:true },
];

/* ============================================================
   GAME STATE
   ============================================================ */
const Game = {
  state:'title', // title, play, dead, win
  time:0, room:null, roomName:'town',
  player:null,
  entities:[], particles:[], floaters:[], projectiles:[],
  inv:{}, // itemId -> count
  equipped:[], // accessory itemIds (max 2)
  coins:0,
  weaponLevel:0, // 0,1,2
  hasBow:false,
  maxHearts:3, hp:6, // hp in half-points; 2 per heart
  progress:0, // 0..100
  flags:{ boss1:false, boss2:false, upgrade1:false, upgrade2:false, heart1:false, heart2:false, bow:false },
  saveRoom:'town', saveTx:9, saveTy:10,
  shake:0, transitioning:false,
  walkBob:0,
  lastRoom:null,
};

/* ---- helpers to access progression-weighted progress ---- */
function recomputeProgress(){
  const f=Game.flags;
  let p=0;
  if(f.boss1) p+=10;
  if(f.heart1) p+=15;
  if(f.upgrade1) p+=25;
  if(f.upgrade2) p+=25;
  if(f.boss2) p+=10;
  if(f.heart2) p+=15;
  Game.progress=p;
}

/* ============================================================
   PARTICLES & FLOATERS
   ============================================================ */
function spawnParticles(x,y,n,opts={}){
  const {color='#fff',speed=80,life=0.5,size=3,gravity=0,spread=Math.PI*2,dir=0}=opts;
  for(let i=0;i<n;i++){
    const a=dir+ (Math.random()-0.5)*spread;
    const sp=speed*(0.4+Math.random()*0.8);
    Game.particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:life*(0.7+Math.random()*0.6),max:life,size:size*(0.6+Math.random()*0.8),color,gravity});
  }
}
function floatText(x,y,txt,color='#fff',size=14){
  Game.floaters.push({x,y,txt,color,size,life:0.9,max:0.9,vy:-30});
}
function updateParticles(dt){
  for(let i=Game.particles.length-1;i>=0;i--){
    const p=Game.particles[i]; p.life-=dt;
    if(p.life<=0){Game.particles.splice(i,1);continue;}
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=p.gravity*dt; p.vx*=0.92;
  }
  for(let i=Game.floaters.length-1;i>=0;i--){
    const f=Game.floaters[i]; f.life-=dt;
    if(f.life<=0){Game.floaters.splice(i,1);continue;}
    f.y+=f.vy*dt; f.vy*=0.92;
  }
}

/* ============================================================
   COLLISION (tile-based circle)
   ============================================================ */
function tileAt(tx,ty){ if(!Game.room) return T.WALL; if(tx<0||ty<0||tx>=COLS||ty>=ROWS) return T.WALL; return Game.room.map[ty][tx]; }
function isSolidTile(tx,ty){ const t=tileAt(tx,ty); return SOLID_TILES.has(t); }
// door tiles: if a door entity occupies a tile and is closed, treat as solid
function doorAt(tx,ty){
  for(const e of Game.entities){ if(e.type==='door' && e.tx===tx && e.ty===ty && !e.open) return e; }
  return null;
}
function isBlocked(px,py,r){
  // check four sample points around circle
  const pts=[[px-r,py],[px+r,py],[px,py-r],[px,py+r],[px-r*0.7,py-r*0.7],[px+r*0.7,py-r*0.7],[px-r*0.7,py+r*0.7],[px+r*0.7,py+r*0.7]];
  for(const [x,y] of pts){
    const tx=Math.floor(x/TILE), ty=Math.floor(y/TILE);
    if(isSolidTile(tx,ty)) return true;
    if(doorAt(tx,ty)) return true;
  }
  // pushable blocks (solid unless being pushed)
  for(const e of Game.entities){
    if(e.type==='block' && !e.moving){
      if(Math.abs(px-e.x)<r+e.r-4 && Math.abs(py-e.y)<r+e.r-4) return true;
    }
  }
  return false;
}

/* ============================================================
   PLAYER
   ============================================================ */
function makePlayer(x,y){
  return {
    type:'player', x, y, r:11,
    facing:{x:0,y:1}, faceDir:'down',
    vx:0,vy:0,
    speed:118,
    attackTimer:0, attackActive:0, attackDir:0, swingPhase:0,
    bowCd:0,
    stamina:100, maxStamina:100, sprinting:false,
    invuln:0, // i-frames
    flash:0,
    walkPhase:0,
    bob:0,
  };
}
const STAM_SPRINT=32, STAM_BOW=18, STAM_REGEN=22;

function updatePlayer(dt){
  const p=Game.player;
  // input dir
  let d=Input.dir();
  const moving = d.x!==0||d.y!==0;
  if(moving){
    const len=Math.hypot(d.x,d.y); d.x/=len; d.y/=len;
    p.facing={x:d.x,y:d.y};
    if(Math.abs(d.x)>Math.abs(d.y)) p.faceDir = d.x>0?'right':'left';
    else p.faceDir = d.y>0?'down':'up';
  }
  // sprint
  const bootsOn = Game.equipped.includes('boots');
  let baseSpeed = p.speed * (bootsOn?1.25:1);
  p.sprinting = Input.held('shift') && moving && p.stamina>1;
  let spd = baseSpeed * (p.sprinting?1.6:1);
  if(p.stamina<8) spd*=0.6; // tired
  // stamina
  if(p.sprinting) p.stamina=Math.max(0,p.stamina-STAM_SPRINT*dt);
  else p.stamina=Math.min(p.maxStamina,p.stamina+STAM_REGEN*dt);

  // movement with collision (axis separated)
  let nx=p.x+d.x*spd*dt;
  if(!isBlocked(nx,p.y,p.r)) p.x=nx;
  let ny=p.y+d.y*spd*dt;
  if(!isBlocked(p.x,ny,p.r)) p.y=ny;

  // walk anim + sound
  if(moving){ p.walkPhase+=dt*(p.sprinting?16:11); p.bob=Math.sin(p.walkPhase)*2;
    if(Math.sin(p.walkPhase)>0.6) Audio.sfx.walk();
  } else { p.bob*=0.8; }

  // room transition (edge crossing)
  if(!Game.transitioning){
    if(p.x<4 && ADJ[Game.roomName].W) transition('W');
    else if(p.x>VW-4 && ADJ[Game.roomName].E) transition('E');
    else if(p.y<4 && ADJ[Game.roomName].N) transition('N');
    else if(p.y>VH-4 && ADJ[Game.roomName].S) transition('S');
  }

  // attack
  if(p.attackTimer>0){ p.attackTimer-=dt; }
  if(p.attackActive>0){ p.attackActive-=dt; }
  if(p.bowCd>0) p.bowCd-=dt;

  if((Input.pressed('j')||Input.pressed(' ')) && p.attackTimer<=0){
    const w=WEAPONS[Game.weaponLevel];
    p.attackTimer=w.cd; p.attackActive=w.cd*0.5;
    p.swingPhase=0;
    Audio.sfx.sword();
    // hit detection happens during active window
    doMeleeHit();
  }
  if(p.swingPhase<1) p.swingPhase+=dt/0.18;

  // bow
  if(Input.pressed('k') && Game.hasBow && p.bowCd<=0 && p.stamina>=STAM_BOW){
    p.stamina-=STAM_BOW; p.bowCd=0.45;
    fireArrow();
  }

  // interact
  if(Input.pressed('e')) tryInteract();

  // invuln
  if(p.invuln>0) p.invuln-=dt;
  if(p.flash>0) p.flash-=dt;
}

function doMeleeHit(){
  const p=Game.player, w=WEAPONS[Game.weaponLevel];
  const ringOn=Game.equipped.includes('ring');
  const dmg=w.dmg+(ringOn?1:0);
  const cx=p.x+p.facing.x*14, cy=p.y+p.facing.y*14;
  for(const e of Game.entities){
    if(!(e.type==='enemy'||e.type==='boss'||e.type==='pot')) continue;
    const d=dist(cx,cy,e.x,e.y);
    if(d<w.range+e.r){
      // arc check
      const ang=Math.atan2(e.y-p.y,e.x-p.x);
      const fang=Math.atan2(p.facing.y,p.facing.x);
      let da=Math.abs(Math.atan2(Math.sin(ang-fang),Math.cos(ang-fang)));
      if(da<w.arc/2 || d<e.r+10){
        damageEnemy(e,dmg);
      }
    }
  }
  // swing particle
  spawnParticles(cx,cy,4,{color:w.color,speed:60,life:0.25,size:2,dir:Math.atan2(p.facing.y,p.facing.x),spread:w.arc});
}

function damageEnemy(e,dmg){
  if(e.hitCd>0) return;
  e.hp-=dmg; e.hitCd=0.18; e.flash=0.18;
  const p=Game.player;
  const kx=Math.sign(e.x-p.x)||1, ky=Math.sign(e.y-p.y)||1;
  e.knock={x:kx*120,y:ky*120,t:0.18};
  floatText(e.x,e.y-e.r-4,'-'+dmg,'#fff',13);
  spawnParticles(e.x,e.y,6,{color:'#ffd0a0',speed:90,life:0.3,size:2.5});
  Audio.sfx.hit();
  if(e.type==='pot'){ breakPot(e); return; }
  if(e.hp<=0) killEnemy(e);
}

function killEnemy(e){
  e.dead=true;
  Audio.sfx.death();
  spawnParticles(e.x,e.y,16,{color:e.color||'#a060c0',speed:140,life:0.55,size:3});
  spawnParticles(e.x,e.y,8,{color:'#fff',speed:80,life:0.35,size:2});
  // coin drops (bosses drop no coins — they drop key items instead)
  if(e.type!=='boss'){
    const charmOn=Game.equipped.includes('charm');
    let n=ri(e.coins[0],e.coins[1])*(charmOn?1.5:1);
    n=Math.round(n);
    for(let i=0;i<n;i++) spawnCoinDrop(e.x+rand(-10,10),e.y+rand(-10,10));
    if(Math.random()<0.12) spawnPickupDrop(e.x,e.y,'potion');
  }
  // boss drops
  if(e.type==='boss'){
    if(e.drops==='heart1' && !Game.flags.heart1){ grantHeart('heart1'); }
    if(e.drops==='crimson' && !Game.flags.upgrade2){ grantCrimson(); }
    Game.shake=0.4;
  }
  Game.entities=Game.entities.filter(x=>x!==e);
}

function breakPot(e){
  e.dead=true; Audio.sfx.pot();
  spawnParticles(e.x,e.y,10,{color:'#caa070',speed:100,life:0.4,size:3});
  if(Math.random()<0.7) spawnCoinDrop(e.x,e.y);
  if(Math.random()<0.25) spawnPickupDrop(e.x,e.y,'bomb');
  Game.entities=Game.entities.filter(x=>x!==e);
}

function grantHeart(flag){
  Game.flags[flag]=true; Game.maxHearts++; Game.hp=Game.maxHearts*2;
  if(flag==='heart1') Game.flags.heart1=true;
  if(flag==='heart2') Game.flags.heart2=true;
  recomputeProgress();
  Audio.sfx.heart(); spawnParticles(Game.player.x,Game.player.y,30,{color:'#ff5a5a',speed:120,life:0.8,size:3});
  toast("Heart Crystal! Max hearts increased.");
}
function grantCrimson(){
  Game.flags.upgrade2=true; Game.weaponLevel=2;
  addItem('crimson',1);
  recomputeProgress();
  Audio.sfx.heart(); spawnParticles(Game.player.x,Game.player.y,30,{color:'#ff3a3a',speed:120,life:0.8,size:3});
  toast("Crimson Blade obtained! Attack speed & fire surged.");
}
function grantWhetstone(){
  Game.flags.upgrade1=true; Game.weaponLevel=1;
  addItem('whetstone',1);
  recomputeProgress();
  Audio.sfx.unlock();
  toast("Whetstone found! Your sword is sharper (Upgrade I).");
}
function grantBow(){
  Game.flags.bow=true; Game.hasBow=true; addItem('bow',1);
  recomputeProgress();
  Audio.sfx.unlock(); spawnParticles(Game.player.x,Game.player.y,26,{color:'#d8a868',speed:110,life:0.8,size:3});
  toast("Hunter's Bow acquired! Press K to fire arrows.");
}

/* ---- coin & pickup drops (world entities) ---- */
function spawnCoinDrop(x,y){
  Game.entities.push({type:'coindrop',x,y,r:7,vy:-60,t:0,bob:Math.random()*6});
}
function spawnPickupDrop(x,y,item){
  Game.entities.push({type:'itemdrop',x,y,r:9,item,bob:Math.random()*6,t:0});
}

/* ============================================================
   ARROWS
   ============================================================ */
function fireArrow(){
  const p=Game.player;
  const dx=p.facing.x, dy=p.facing.y;
  Game.projectiles.push({x:p.x+dx*16,y:p.y+dy*16,vx:dx*340,vy:dy*340,life:1.2,r:4,dmg:2,from:'player'});
  Audio.sfx.bow();
  spawnParticles(p.x+dx*16,p.y+dy*16,3,{color:'#d8a868',speed:50,life:0.2,size:2,dir:Math.atan2(dy,dx),spread:0.6});
}
function updateProjectiles(dt){
  for(let i=Game.projectiles.length-1;i>=0;i--){
    const a=Game.projectiles[i]; a.life-=dt;
    let nx=a.x+a.vx*dt, ny=a.y+a.vy*dt;
    if(isSolidTile(Math.floor(nx/TILE),Math.floor(ny/TILE)) || nx<0||ny<0||nx>VW||ny>VH){
      spawnParticles(a.x,a.y,5,{color:'#d8a868',speed:50,life:0.25,size:2}); Game.projectiles.splice(i,1); continue;
    }
    a.x=nx;a.y=ny;
    // hit enemies
    let hit=false;
    for(const e of Game.entities){
      if(e.type==='enemy'||e.type==='boss'){
        if(dist(a.x,a.y,e.x,e.y)<e.r+a.r){ damageEnemy(e,a.dmg); hit=true; break; }
      }
    }
    if(hit){ spawnParticles(a.x,a.y,6,{color:'#ffe0a0',speed:80,life:0.3,size:2.5}); Game.projectiles.splice(i,1); }
    else if(a.life<=0) Game.projectiles.splice(i,1);
  }
}

/* ============================================================
   ENEMIES
   ============================================================ */
const ENEMY_DEFS={
  slime:{hp:3,dmg:1,speed:38,color:'#7CFC4A',r:12,coins:[1,2],atkCd:0.8,see:140},
  blob: {hp:5,dmg:1,speed:30,color:'#c060e0',r:14,coins:[2,3],atkCd:0.9,see:160},
  bat:  {hp:2,dmg:1,speed:62,color:'#6080a0',r:10,coins:[1,2],atkCd:0.7,see:180,flying:true},
  wolf: {hp:14,dmg:2,speed:70,color:'#9a7a5a',r:16,coins:[0,0],atkCd:0.7,see:240,boss:true},
  guardian:{hp:26,dmg:2,speed:48,color:'#c0a0ff',r:18,coins:[0,0],atkCd:0.9,see:260,boss:true},
};

function makeEnemy(tx,ty,kind){
  const d=ENEMY_DEFS[kind];
  return {type: kind==='wolf'||kind==='guardian'?'boss':'enemy', kind,
    x:tx*TILE+TILE/2,y:ty*TILE+TILE/2,r:d.r,
    hp:d.hp,maxhp:d.hp,dmg:d.dmg,speed:d.speed,color:d.color,coins:d.coins,atkCd:d.atkCd,see:d.see,flying:!!d.flying,
    vx:0,vy:0,dir:Math.random()*6.28,wanderT:0,hitCd:0,flash:0,knock:null,atkT:0,homeX:tx*TILE+TILE/2,homeY:ty*TILE+TILE/2};
}

function updateEnemy(e,dt){
  const p=Game.player;
  if(e.hitCd>0)e.hitCd-=dt; if(e.flash>0)e.flash-=dt; if(e.atkT>0)e.atkT-=dt;
  // knockback
  if(e.knock){ e.x+=e.knock.x*dt; e.y+=e.knock.y*dt; e.knock.t-=dt; if(e.knock.t<=0)e.knock=null; clampToWorld(e); }
  else {
    const d=dist(e.x,e.y,p.x,p.y);
    if(d<e.see){
      // chase
      const ang=Math.atan2(p.y-e.y,p.x-e.x);
      let nx=e.x+Math.cos(ang)*e.speed*dt, ny=e.y+Math.sin(ang)*e.speed*dt;
      if(e.flying || !isBlocked(nx,e.y,e.r)) e.x=nx;
      if(e.flying || !isBlocked(e.x,ny,e.r)) e.y=ny;
      // attack on contact
      if(d<e.r+p.r+2 && e.atkT<=0){
        hurtPlayer(e.dmg); e.atkT=e.atkCd; Audio.sfx.enemyAtk();
        spawnParticles(p.x,p.y,5,{color:'#ff5a5a',speed:70,life:0.3,size:2.5});
      }
    } else {
      // wander
      e.wanderT-=dt;
      if(e.wanderT<=0){ e.dir=Math.random()*6.28; e.wanderT=rand(0.8,2.2); }
      let nx=e.x+Math.cos(e.dir)*e.speed*0.4*dt, ny=e.y+Math.sin(e.dir)*e.speed*0.4*dt;
      if(e.flying || !isBlocked(nx,e.y,e.r)) e.x=nx; else e.dir+=2;
      if(e.flying || !isBlocked(e.x,ny,e.r)) e.y=ny;
    }
    clampToWorld(e);
  }
}
function clampToWorld(e){ e.x=clamp(e.x,e.r,VW-e.r); e.y=clamp(e.y,e.r,VH-e.r); }

function hurtPlayer(dmg){
  const p=Game.player;
  if(p.invuln>0) return;
  Game.hp=Math.max(0,Game.hp-dmg);
  p.invuln=1.0; p.flash=0.3;
  Audio.sfx.hurt(); Game.shake=0.25;
  floatText(p.x,p.y-p.r-4,'-'+dmg,'#ff5a5a',14);
  spawnParticles(p.x,p.y,8,{color:'#ff5a5a',speed:90,life:0.35,size:2.5});
  if(Game.hp<=0) playerDie();
}
function playerDie(){
  Audio.sfx.death();
  spawnParticles(Game.player.x,Game.player.y,30,{color:'#ff5a5a',speed:140,life:0.7,size:3});
  Game.state='dead';
  setTimeout(()=>showOver('You Fell…','The Hollow claims another. Return to your last sanctum.'),400);
}

/* ============================================================
   BLOCKS / PLATES / DOORS
   ============================================================ */
function updateBlocks(dt){
  for(const e of Game.entities){
    if(e.type==='block'){
      if(e.moving){ e.moveT-=dt; e.x=lerp(e.fromX,e.toX,1-Math.max(0,e.moveT/0.16)); e.y=lerp(e.fromY,e.toY,1-Math.max(0,e.moveT/0.16)); if(e.moveT<=0){e.moving=false; e.tx=Math.round(e.x/TILE-0.5); e.ty=Math.round(e.y/TILE-0.5);} }
      else {
        // try push by player
        const p=Game.player;
        if(Math.abs(p.x-e.x)<p.r+e.r-2 && Math.abs(p.y-e.y)<p.r+e.r-2){
          const dx=Math.sign(p.x-e.x)||0, dy=Math.sign(p.y-e.y)||0;
          // push opposite to player movement: block moves away from player
          const pdx=-dx, pdy=-dy;
          const ntx=e.tx+pdx, nty=e.ty+pdy;
          if(!isSolidTile(ntx,nty) && !doorAt(ntx,nty) && !blockAt(ntx,nty)){
            e.fromX=e.x; e.fromY=e.y; e.toX=ntx*TILE+TILE/2; e.toY=nty*TILE+TILE/2;
            e.tx=ntx; e.ty=nty; e.moving=true; e.moveT=0.16; Audio.sfx.block();
            // push player back slightly
            p.x+=dx*4; p.y+=dy*4;
          }
        }
      }
    }
    if(e.type==='plate'){
      e.pressed = blockAt(e.tx,e.ty)!=null;
    }
    if(e.type==='door'){
      const pl = Game.entities.find(x=>x.type==='plate'&&x.id===e.plate);
      if(pl && pl.pressed && !e.open){
        e.open=true; Audio.sfx.unlock(); toast("A heavy door grinds open.");
        spawnParticles(e.tx*TILE+TILE/2,e.ty*TILE+TILE/2,12,{color:'#ffd24d',speed:70,life:0.5,size:3});
      }
    }
  }
}
function blockAt(tx,ty){ for(const e of Game.entities){ if(e.type==='block'&&e.tx===tx&&e.ty===ty) return e; } return null; }

/* ============================================================
   PICKUPS, COIN DROPS, ITEM DROPS update
   ============================================================ */
function updatePickups(dt){
  const p=Game.player;
  for(let i=Game.entities.length-1;i>=0;i--){
    const e=Game.entities[i];
    if(e.type==='coin'){
      e.bob=(e.bob||0)+dt*4;
      if(dist(p.x,p.y,e.x,e.y)<p.r+e.r){
        Game.coins+=1; Audio.sfx.coin(); spawnParticles(e.x,e.y,5,{color:'#ffcc4d',speed:60,life:0.3,size:2});
        Game.entities.splice(i,1);
      }
    } else if(e.type==='coindrop'){
      e.t+=dt; e.bob=(e.bob||0)+dt*5; e.vy+=240*dt; e.y+=e.vy*dt;
      if(e.y> VH-8){ e.y=VH-8; e.vy=-e.vy*0.4; if(Math.abs(e.vy)<20)e.vy=0; }
      if(dist(p.x,p.y,e.x,e.y)<p.r+e.r){
        Game.coins+=1; Audio.sfx.coin(); spawnParticles(e.x,e.y,4,{color:'#ffcc4d',speed:50,life:0.25,size:2});
        Game.entities.splice(i,1);
      }
    } else if(e.type==='itemdrop'){
      e.t+=dt; e.bob=(e.bob||0)+dt*4;
      if(dist(p.x,p.y,e.x,e.y)<p.r+e.r+2){
        if(e.item.startsWith('heart')){ /* shouldn't drop */ }
        else { addItem(e.item,1); Audio.sfx.pickup(); toast(ITEMS[e.item].name+' acquired!'); spawnParticles(e.x,e.y,8,{color:ITEMS[e.item].color,speed:70,life:0.4,size:2.5}); }
        Game.entities.splice(i,1);
      }
    } else if(e.type==='pickup'){
      e.bob=(e.bob||0)+dt*3;
      // pickups (key items / weapons) are collected via interact E when near, except auto for some
      // We'll auto-collect when overlapping for weapons/hearts/whetstone/bow
      if(dist(p.x,p.y,e.x,e.y)<p.r+e.r+2){
        collectPickup(e);
      }
    }
  }
}

function collectPickup(e){
  const it=e.item;
  switch(it){
    case 'potion': addItem('potion',1); Audio.sfx.pickup(); toast('Health Potion acquired!'); break;
    case 'boots': if(!Game.flags.boots){ grantBoots(); } else return; break;
    case 'whetstone': if(!Game.flags.upgrade1){ grantWhetstone(); } else return; break;
    case 'bow': if(!Game.flags.bow){ grantBow(); } else return; break;
    case 'heart1': if(!Game.flags.heart1){ grantHeart('heart1'); } else return; break;
    case 'heart2': if(!Game.flags.heart2){ grantHeart('heart2'); } else return; break;
    case 'crimson': if(!Game.flags.upgrade2){ grantCrimson(); } else return; break;
    default: addItem(it,1); Audio.sfx.pickup(); toast(ITEMS[it]?.name||it+' acquired!');
  }
  spawnParticles(e.x,e.y,12,{color:ITEMS[it]?.color||'#fff',speed:80,life:0.5,size:3});
  Game.entities=Game.entities.filter(x=>x!==e);
}
function grantBoots(){ Game.flags.boots=true; addItem('boots',1); Audio.sfx.pickup(); toast('Swift Boots found! +25% move speed.'); }

/* ============================================================
   INVENTORY
   ============================================================ */
function addItem(id,n){ Game.inv[id]=(Game.inv[id]||0)+n; UI.refreshInv(); }
function useConsumable(id){
  if((Game.inv[id]||0)<=0) return;
  if(id==='potion'){ if(Game.hp>=Game.maxHearts*2){toast('Health already full.');return;} Game.hp=Math.min(Game.maxHearts*2,Game.hp+4); Audio.sfx.pickup(); toast('Restored 2 hearts.'); spawnParticles(Game.player.x,Game.player.y,10,{color:'#ff5a8a',speed:70,life:0.4,size:2.5}); }
  else if(id==='elixir'){ if(Game.hp>=Game.maxHearts*2){toast('Health already full.');return;} Game.hp=Game.maxHearts*2; Audio.sfx.heart(); toast('Fully restored!'); spawnParticles(Game.player.x,Game.player.y,16,{color:'#ff2a4a',speed:90,life:0.5,size:3}); }
  else if(id==='bomb'){ doBomb(); Audio.sfx.block(); toast('BOOM!'); }
  else return;
  Game.inv[id]--; if(Game.inv[id]<=0)delete Game.inv[id];
  UI.refreshInv();
}
function doBomb(){
  const p=Game.player; Game.shake=0.4; Audio.sfx.death();
  spawnParticles(p.x,p.y,30,{color:'#ffaa3a',speed:200,life:0.6,size:4});
  for(const e of Game.entities){ if(e.type==='enemy'||e.type==='boss'){ if(dist(p.x,p.y,e.x,e.y)<90){ damageEnemy(e,3); } } }
}

/* ============================================================
   INTERACTION (E)
   ============================================================ */
let activeShop=null, activeSave=null, activeNPC=null;
function tryInteract(){
  const p=Game.player;
  if(UI.shopOpen){ UI.closeShop(); return; }
  if(UI.saveOpen){ UI.closeSave(); return; }
  if(UI.invOpen){ UI.closeInv(); return; }
  // find nearest interactable
  let best=null,bd=40;
  for(const e of Game.entities){
    if(e.type==='shop'||e.type==='save'||e.type==='npc'){
      const d=dist(p.x,p.y,e.x,e.y);
      if(d<bd){bd=d;best=e;}
    }
  }
  if(best){
    if(best.type==='shop'){ openShop(best); }
    else if(best.type==='save'){ openSave(best); }
    else if(best.type==='npc'){ talkNPC(best); }
    return;
  }
  // use potion quick? no.
}
function talkNPC(e){
  UI.showDialog(e.name, choice(e.lines));
  Audio.sfx.pickup();
}
function openShop(e){
  activeShop=e; UI.openShop();
  Audio.sfx.shop();
}
function openSave(e){
  activeSave=e; UI.openSave();
  Audio.sfx.shop();
}

/* ============================================================
   ROOM TRANSITIONS
   ============================================================ */
function transition(side){
  const target = ADJ[Game.roomName][side];
  if(!target) return;
  // lake->grotto locked until progress>=50
  if(Game.roomName==='lake' && side==='S' && Game.progress<50){
    toast("The grotto door is sealed. Prove yourself further. ("+Game.progress+"% proven)"); return;
  }
  Game.transitioning=true;
  fadeOut(()=>{
    loadRoom(target, side);
    fadeIn(()=>{ Game.transitioning=false; });
  });
}
function loadRoom(name, fromSide){
  Game.roomName=name; Game.lastRoom=name;
  const def=ROOMS[name];
  Game.room={ name:def.name, map: buildMap(def.floor, def.exits, def.overrides) };
  Game.entities=[]; Game.projectiles=[]; Game.particles=[];
  // spawn entities (skip already-collected pickups / defeated bosses)
  for(const s of def.spawns){
    const x=s.tx*TILE+TILE/2, y=s.ty*TILE+TILE/2;
    if(s.type==='enemy'||s.type==='boss'){
      // bosses stay defeated? Let's allow re-fight only if not defeated; once defeated, skip (room stays cleared)
      if(s.kind==='wolf' && Game.flags.boss1) continue;
      if(s.kind==='guardian' && Game.flags.boss2) continue;
      if(s.type==='boss'){ if(s.kind==='wolf'){Game.flags.boss1=false;} if(s.kind==='guardian'){Game.flags.boss2=false;} }
      Game.entities.push(makeEnemy(s.tx,s.ty,s.kind));
      if(s.drops) Game.entities[Game.entities.length-1].drops=s.drops;
    } else if(s.type==='pickup'){
      // skip if already collected
      if(s.item==='whetstone'&&Game.flags.upgrade1) continue;
      if(s.item==='bow'&&Game.flags.bow) continue;
      if(s.item==='heart1'&&Game.flags.heart1) continue;
      if(s.item==='heart2'&&Game.flags.heart2) continue;
      if(s.item==='boots'&&Game.flags.boots) continue;
      Game.entities.push({type:'pickup',x,y,r:10,item:s.item,bob:Math.random()*6});
    } else if(s.type==='coin'){
      Game.entities.push({type:'coin',x,y,r:7,bob:Math.random()*6});
    } else if(s.type==='pot'){
      Game.entities.push({type:'pot',x,y,r:11,hp:1,bob:0});
    } else if(s.type==='block'){
      Game.entities.push({type:'block',tx:s.tx,ty:s.ty,x:s.tx*TILE+TILE/2,y:s.ty*TILE+TILE/2,r:16,moving:false});
    } else if(s.type==='plate'){
      Game.entities.push({type:'plate',tx:s.tx,ty:s.ty,x,y,r:14,id:s.id,pressed:false});
    } else if(s.type==='door'){
      Game.entities.push({type:'door',tx:s.tx,ty:s.ty,plate:s.plate,side:s.side,open:false});
    } else if(s.type==='shop'){
      Game.entities.push({type:'shop',x,y,r:14,name:s.name||"Wandering Merchant"});
    } else if(s.type==='save'){
      Game.entities.push({type:'save',x,y,r:12});
    } else if(s.type==='npc'){
      Game.entities.push({type:'npc',x,y,r:12,name:s.name,lines:s.lines,bob:0});
    }
  }
  // reset door if plate already... fine
  // place player at entry based on fromSide
  const p=Game.player;
  if(fromSide==='E'){ p.x=VW-12; p.y = (p.y<0? VH/2 : clamp(p.y, 7*TILE, 8*TILE+TILE)); }
  else if(fromSide==='W'){ p.x=12; p.y=clamp(p.y,7*TILE,8*TILE+TILE); }
  else if(fromSide==='S'){ p.y=VH-12; p.x=clamp(p.x,9*TILE,10*TILE+TILE); }
  else if(fromSide==='N'){ p.y=12; p.x=clamp(p.x,9*TILE,10*TILE+TILE); }
  // default pos (new game)
  if(fromSide==null){ p.x=9*TILE+TILE/2; p.y=10*TILE+TILE/2; }
  // boss intro
  const boss = Game.entities.find(e=>e.type==='boss');
  if(boss){ Audio.sfx.bossRoar(); Game.shake=0.3; toast("A guardian awakens!"); }
}

/* ============================================================
   RENDERING — TILES
   ============================================================ */
function drawTiles(){
  const map=Game.room.map; const t=Game.time;
  for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
    const id=map[y][x]; const px=x*TILE,py=y*TILE;
    switch(id){
      case T.GRASS:{ ctx.fillStyle='#3a5a32'; ctx.fillRect(px,py,TILE,TILE);
        // texture dots
        const n=tnoise(x,y); if(n>0.5){ctx.fillStyle='#4a6a3a';ctx.fillRect(px+(n*30|0),py+((1-n)*30|0),3,3);}
        if(n>0.8){ctx.fillStyle='#2c4a26';ctx.fillRect(px+10,py+22,3,3);}
        break;}
      case T.PATH:{ ctx.fillStyle='#9a7a4a'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#866a3e'; ctx.fillRect(px,py,TILE,2); ctx.fillRect(px,py+TILE-2,TILE,2); break;}
      case T.SAND:{ ctx.fillStyle='#d8c080'; ctx.fillRect(px,py,TILE,TILE);
        const n=tnoise(x,y); if(n>0.6){ctx.fillStyle='#c8a868';ctx.fillRect(px+(n*30|0),py+12,2,2);} break;}
      case T.COBBLE:{ ctx.fillStyle='#6a6478'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#5a5468'; ctx.fillRect(px+1,py+1,18,18); ctx.fillRect(px+21,py+1,18,18);
        ctx.fillRect(px+1,py+21,18,18); ctx.fillRect(px+21,py+21,18,18);
        ctx.fillStyle='#7a7488'; ctx.fillRect(px+2,py+2,16,2); break;}
      case T.DARK:{ ctx.fillStyle='#2a2438'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#221c30'; ctx.fillRect(px,py,TILE,4); ctx.strokeStyle='#1a1428';ctx.strokeRect(px+0.5,py+0.5,TILE-1,TILE-1); break;}
      case T.RUIN:{ ctx.fillStyle='#7a6a6a'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#6a5a5a'; ctx.fillRect(px+2,py+2,16,16); ctx.fillRect(px+22,py+2,16,16);
        ctx.fillRect(px+2,py+22,16,16); ctx.fillRect(px+22,py+22,16,16);
        ctx.fillStyle='#8a7a7a'; ctx.fillRect(px+3,py+3,14,2); break;}
      case T.WATER:{ const w=Math.sin(t*2+x*0.5+y*0.3)*0.5+0.5; ctx.fillStyle=`rgb(${30+w*20},${70+w*40},${130+w*40})`; ctx.fillRect(px,py,TILE,TILE);
        ctx.strokeStyle='rgba(180,220,255,0.3)'; ctx.beginPath(); ctx.arc(px+20,py+20,6+w*3,0,6.28); ctx.stroke(); break;}
      case T.WALL:{ ctx.fillStyle='#3a2e44'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#4a3e54'; ctx.fillRect(px+1,py+1,TILE-2,18); ctx.fillRect(px+1,py+21,TILE-2,18);
        ctx.fillStyle='#2a2234'; ctx.fillRect(px,py+19,TILE,2); break;}
      case T.TREE:{ ctx.fillStyle='#3a5a32'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#5a3a1a'; ctx.fillRect(px+17,py+22,6,14);
        ctx.fillStyle='#2a6a2a'; ctx.beginPath(); ctx.arc(px+20,py+16,14,0,6.28); ctx.fill();
        ctx.fillStyle='#3a8a3a'; ctx.beginPath(); ctx.arc(px+16,py+12,8,0,6.28); ctx.fill(); break;}
      case T.BUSH:{ ctx.fillStyle='#3a5a32'; ctx.fillRect(px,py,TILE,TILE);
        ctx.fillStyle='#2c4a26'; ctx.beginPath(); ctx.arc(px+12,py+22,8,0,6.28); ctx.arc(px+26,py+20,9,0,6.28); ctx.arc(px+20,py+28,7,0,6.28); ctx.fill();
        ctx.fillStyle='#3a6a2a'; ctx.beginPath(); ctx.arc(px+14,py+20,4,0,6.28); ctx.fill(); break;}
      case T.FLOW:{ ctx.fillStyle='#3a5a32'; ctx.fillRect(px,py,TILE,TILE);
        const n=tnoise(x,y); ctx.fillStyle= n>0.5?'#e8c0ff':'#ffd0e0'; ctx.beginPath(); ctx.arc(px+10+n*20,py+12+n*16,3,0,6.28); ctx.fill();
        ctx.fillStyle='#ffe080'; ctx.beginPath(); ctx.arc(px+10+n*20,py+12+n*16,1.2,0,6.28); ctx.fill(); break;}
    }
  }
}

/* ============================================================
   RENDERING — ENTITIES
   ============================================================ */
function drawEntity(e){
  const t=Game.time;
  switch(e.type){
    case 'coin':{ const b=Math.sin(e.bob)*2; drawCoin(e.x,e.y-6+b,7); break; }
    case 'coindrop':{ drawCoin(e.x,e.y,7); break; }
    case 'itemdrop':{ drawItemIcon(e.item,e.x,e.y+Math.sin(e.bob)*2,16); 
      ctx.fillStyle='rgba(255,220,120,0.4)'; ctx.beginPath();ctx.arc(e.x,e.y,12+Math.sin(t*4)*2,0,6.28);ctx.fill(); break; }
    case 'pickup':{ const fl=Math.sin(e.bob)*0.5+0.5;
      ctx.fillStyle='rgba(255,220,120,'+(0.2+fl*0.3)+')'; ctx.beginPath();ctx.arc(e.x,e.y,14+fl*3,0,6.28);ctx.fill();
      drawItemIcon(e.item,e.x,e.y+Math.sin(e.bob)*2,18); break; }
    case 'pot':{ drawPot(e.x,e.y); break; }
    case 'block':{ drawBlock(e); break; }
    case 'plate':{ drawPlate(e); break; }
    case 'door':{ drawDoor(e); break; }
    case 'shop':{ drawShop(e); break; }
    case 'save':{ drawSave(e,t); break; }
    case 'npc':{ drawNPC(e,t); break; }
    case 'enemy': case 'boss':{ drawEnemy(e,t); break; }
  }
}
function drawCoin(x,y,r){
  ctx.fillStyle='#caa020'; ctx.beginPath();ctx.arc(x,y,r,0,6.28);ctx.fill();
  ctx.fillStyle='#ffcc4d'; ctx.beginPath();ctx.arc(x-r*0.2,y-r*0.2,r*0.8,0,6.28);ctx.fill();
  ctx.fillStyle='#fff3b0'; ctx.beginPath();ctx.arc(x-r*0.35,y-r*0.35,r*0.3,0,6.28);ctx.fill();
}
function drawPot(x,y){
  ctx.fillStyle='#9a7048'; ctx.beginPath();ctx.ellipse(x,y+2,13,12,0,0,6.28);ctx.fill();
  ctx.fillStyle='#b08858'; ctx.beginPath();ctx.ellipse(x,y-4,10,5,0,0,6.28);ctx.fill();
  ctx.fillStyle='#7a5630'; ctx.fillRect(x-12,y+1,24,3);
}
function drawBlock(e){
  ctx.fillStyle='#5a4a6a'; ctx.fillRect(e.x-16,e.y-16,32,32);
  ctx.fillStyle='#6a5a7a'; ctx.fillRect(e.x-14,e.y-14,28,28);
  ctx.fillStyle='#4a3a5a'; ctx.fillRect(e.x-14,e.y+10,28,3); ctx.fillRect(e.x+10,e.y-14,3,28);
  ctx.fillStyle='#7a6a8a'; ctx.fillRect(e.x-13,e.y-13,26,2);
}
function drawPlate(e){
  ctx.fillStyle= e.pressed?'#6a5a3a':'#3a3548';
  ctx.beginPath();ctx.ellipse(e.x,e.y,14,7,0,0,6.28);ctx.fill();
  ctx.fillStyle= e.pressed?'#ffd24d':'#5a5468'; ctx.beginPath();ctx.ellipse(e.x,e.y-2,11,5,0,0,6.28);ctx.fill();
  if(e.pressed){ ctx.fillStyle='rgba(255,210,77,0.3)'; ctx.beginPath();ctx.arc(e.x,e.y,18,0,6.28);ctx.fill(); }
}
function drawDoor(e){
  if(e.open){ ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.fillRect(e.tx*TILE,e.ty*TILE,TILE,TILE); return; }
  ctx.fillStyle='#3a2a1a'; ctx.fillRect(e.tx*TILE,e.ty*TILE,TILE,TILE);
  ctx.fillStyle='#5a4a2a'; for(let i=0;i<4;i++)ctx.fillRect(e.tx*TILE+4,e.ty*TILE+4+i*9,TILE-8,6);
  ctx.fillStyle='#ffd24d'; ctx.beginPath();ctx.arc(e.tx*TILE+TILE-10,e.ty*TILE+TILE/2,3,0,6.28);ctx.fill();
}
function drawShop(e){
  // building drawn via tiles; draw a sign / banner
  ctx.fillStyle='#8a3a3a'; ctx.fillRect(e.x-14,e.y-20,28,12);
  ctx.fillStyle='#fff'; ctx.font='bold 8px sans-serif'; ctx.textAlign='center'; ctx.fillText('SHOP',e.x,e.y-11);
  // merchant figure
  ctx.fillStyle='#c0a060'; ctx.beginPath();ctx.arc(e.x,e.y+4,7,0,6.28);ctx.fill();
  ctx.fillStyle='#e0c080'; ctx.beginPath();ctx.arc(e.x,e.y,5,0,6.28);ctx.fill();
  ctx.textAlign='left';
}
function drawSave(e,t){
  const fl=Math.sin(t*3)*0.5+0.5;
  ctx.fillStyle='rgba(120,180,255,'+(0.2+fl*0.3)+')'; ctx.beginPath();ctx.arc(e.x,e.y,18+fl*4,0,6.28);ctx.fill();
  ctx.fillStyle='#b8d8ff'; ctx.beginPath(); ctx.moveTo(e.x,e.y-12); ctx.lineTo(e.x+8,e.y); ctx.lineTo(e.x,e.y+12); ctx.lineTo(e.x-8,e.y); ctx.closePath(); ctx.fill();
  ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(e.x,e.y,3+fl,0,6.28); ctx.fill();
}
function drawNPC(e,t){
  const b=Math.sin(t*2)*1.5;
  ctx.fillStyle='#2a4a6a'; ctx.fillRect(e.x-7,e.y-2+b,14,14);
  ctx.fillStyle='#e0c090'; ctx.beginPath();ctx.arc(e.x,e.y-8+b,6,0,6.28);ctx.fill();
  ctx.fillStyle='#5a3a1a'; ctx.fillRect(e.x-5,e.y-14+b,10,4);
  // ! indicator
  ctx.fillStyle='#ffd24d'; ctx.font='bold 14px sans-serif'; ctx.textAlign='center'; ctx.fillText('!',e.x,e.y-20+Math.sin(t*4)*2); ctx.textAlign='left';
}
function drawEnemy(e,t){
  ctx.save(); ctx.translate(e.x,e.y);
  if(e.flash>0){ ctx.globalAlpha=0.7; }
  const k=e.kind;
  if(k==='slime'){
    const sq=1+Math.sin(t*6)*0.08; ctx.scale(sq, 2-sq);
    ctx.fillStyle=e.flash>0?'#fff':e.color; ctx.beginPath();ctx.arc(0,2,e.r,e.r,e.r); ctx.moveTo(-e.r,2); ctx.lineTo(e.r,2); ctx.lineTo(0,-e.r*0.4); ctx.closePath();ctx.fill();
    ctx.fillStyle='#fff'; ctx.beginPath();ctx.arc(-4,-2,2,0,6.28);ctx.arc(4,-2,2,0,6.28);ctx.fill();
    ctx.fillStyle='#000'; ctx.beginPath();ctx.arc(-4,-2,1,0,6.28);ctx.arc(4,-2,1,0,6.28);ctx.fill();
  } else if(k==='blob'){
    const sq=1+Math.sin(t*5)*0.1; ctx.scale(sq,2-sq);
    ctx.fillStyle=e.flash>0?'#fff':e.color; ctx.beginPath();ctx.arc(0,0,e.r,0,6.28);ctx.fill();
    ctx.fillStyle='#fff'; ctx.beginPath();ctx.arc(-5,-3,3,0,6.28);ctx.arc(5,-3,3,0,6.28);ctx.fill();
    ctx.fillStyle='#000'; ctx.beginPath();ctx.arc(-5,-3,1.5,0,6.28);ctx.arc(5,-3,1.5,0,6.28);ctx.fill();
  } else if(k==='bat'){
    const wf=Math.sin(t*18)*6;
    ctx.fillStyle=e.flash>0?'#fff':e.color;
    ctx.beginPath(); ctx.ellipse(0,0,7,6,0,0,6.28); ctx.fill();
    ctx.beginPath(); ctx.moveTo(-7,0); ctx.lineTo(-15,-wf); ctx.lineTo(-12,2); ctx.closePath(); ctx.moveTo(7,0); ctx.lineTo(15,-wf); ctx.lineTo(12,2); ctx.closePath(); ctx.fill();
    ctx.fillStyle='#ff5a5a'; ctx.beginPath();ctx.arc(-2,-1,1.2,0,6.28);ctx.arc(2,-1,1.2,0,6.28);ctx.fill();
  } else if(k==='wolf'){
    ctx.fillStyle=e.flash>0?'#fff':e.color; ctx.beginPath();ctx.ellipse(0,0,18,14,0,0,6.28);ctx.fill();
    ctx.beginPath(); ctx.moveTo(-14,-6); ctx.lineTo(-22,-16); ctx.lineTo(-12,-10); ctx.closePath(); ctx.moveTo(14,-6); ctx.lineTo(22,-16); ctx.lineTo(12,-10); ctx.closePath(); ctx.fill();
    ctx.fillStyle='#ffcc4d'; ctx.beginPath();ctx.arc(-7,-3,2,0,6.28);ctx.arc(7,-3,2,0,6.28);ctx.fill();
    ctx.fillStyle='#fff'; ctx.beginPath();ctx.arc(-12,8,3,0,6.28);ctx.arc(12,8,3,0,6.28);ctx.fill();
  } else if(k==='guardian'){
    const fl=Math.sin(t*3)*0.5+0.5;
    ctx.fillStyle='rgba(180,140,255,'+(0.3+fl*0.3)+')'; ctx.beginPath();ctx.arc(0,0,24,0,6.28);ctx.fill();
    ctx.fillStyle=e.flash>0?'#fff':e.color; ctx.beginPath();ctx.arc(0,0,e.r,0,6.28);ctx.fill();
    ctx.fillStyle='#4a2a8a'; ctx.fillRect(-10,-4,20,8);
    ctx.fillStyle='#ffcc4d'; ctx.beginPath();ctx.arc(-6,-2,2,0,6.28);ctx.arc(6,-2,2,0,6.28);ctx.fill();
    ctx.fillStyle='#c0a0ff'; ctx.fillRect(-12,8,8,10); ctx.fillRect(4,8,8,10);
  }
  ctx.restore();
  // hp bar for bosses / damaged
  if(e.type==='boss' || (e.hp<e.maxhp && e.type==='enemy')){
    const w=e.r*2, hpf=clamp(e.hp/e.maxhp,0,1);
    ctx.fillStyle='#000'; ctx.fillRect(e.x-w/2,e.y-e.r-9,w,4);
    ctx.fillStyle=hpf>0.5?'#5afc5a':hpf>0.25?'#ffcc4d':'#ff5a5a'; ctx.fillRect(e.x-w/2,e.y-e.r-9,w*hpf,4);
  }
}

/* ============================================================
   RENDERING — PLAYER (equipment-driven visuals)
   ============================================================ */
function drawPlayer(){
  const p=Game.player, t=Game.time;
  ctx.save(); ctx.translate(p.x,p.y);
  if(p.invuln>0 && Math.floor(t*20)%2===0) ctx.globalAlpha=0.4;
  const bob=p.bob;
  // shadow
  ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.beginPath();ctx.ellipse(0,12,11,4,0,0,6.28);ctx.fill();
  // legs (walking anim)
  const legSw=Math.sin(p.walkPhase)*4;
  ctx.fillStyle='#3a2a5a';
  ctx.fillRect(-6,6+bob,5,8+legSw*0.5);
  ctx.fillRect(2,6+bob,5,8-legSw*0.5);
  // body tunic (color reflects stamina/tired)
  const tired=p.stamina<8;
  ctx.fillStyle = tired? '#5a4a7a' : '#6a4ab0';
  ctx.fillRect(-8,-4+bob,16,12);
  ctx.fillStyle='#8a6ad0'; ctx.fillRect(-8,-4+bob,16,3);
  // head
  ctx.fillStyle='#f0d0a0'; ctx.beginPath();ctx.arc(0,-10+bob,6,0,6.28);ctx.fill();
  // hair
  ctx.fillStyle='#5a3a1a'; ctx.beginPath();ctx.arc(0,-13+bob,5,Math.PI,6.28);ctx.fill();
  // eyes face direction
  ctx.fillStyle='#000';
  const ex = p.faceDir==='left'?-3 : p.faceDir==='right'?3 : 0;
  if(p.faceDir==='down'||p.faceDir==='left'||p.faceDir==='right'){
    ctx.fillRect(ex-3,-11+bob,1.5,2); ctx.fillRect(ex+1,-11+bob,1.5,2);
  }
  // weapon drawn in facing direction
  drawPlayerWeapon(p);
  // damage flash overlay
  if(p.flash>0){ ctx.globalAlpha=0.5; ctx.fillStyle='#ff3a3a'; ctx.fillRect(-9,-16,18,28); }
  ctx.restore();
}
function drawPlayerWeapon(p){
  const w=WEAPONS[Game.weaponLevel];
  const ang=Math.atan2(p.facing.y,p.facing.x);
  ctx.save();
  // attach point at hand
  const hx=Math.cos(ang)*6, hy=Math.sin(ang)*6-2;
  ctx.translate(hx,hy);
  if(p.attackActive>0){
    // swing arc
    const sw=1-clamp(p.swingPhase,0,1);
    const swingAng = ang + (p.faceDir==='left'||p.faceDir==='up'? 1:-1) * (0.6 - sw*1.2);
    ctx.rotate(swingAng);
    ctx.translate(w.len*0.5,0);
    // blade
    ctx.fillStyle=w.color; ctx.fillRect(0,-3,w.len,6);
    ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.fillRect(0,-3,w.len,2);
    if(w.fire){ ctx.fillStyle='rgba(255,120,40,0.6)'; ctx.fillRect(-2,-5,w.len+4,10); }
    // swing trail
    ctx.globalAlpha=0.4;
    ctx.strokeStyle=w.color; ctx.lineWidth=4; ctx.beginPath(); ctx.arc(0,0,w.len,-0.6,0.6); ctx.stroke();
    ctx.globalAlpha=1;
  } else {
    // idle: sword held at side, pointing along facing
    ctx.rotate(ang);
    ctx.fillStyle=w.color; ctx.fillRect(2,-2,w.len*0.6,4);
    ctx.fillStyle='#6a5a4a'; ctx.fillRect(0,-3,4,6); // hilt
    if(Game.hasBow){
      // bow on back when not attacking
      ctx.save(); ctx.rotate(-ang); ctx.strokeStyle='#8a5a2a'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(-4,0,7,-1.2,1.2); ctx.stroke(); ctx.restore();
    }
  }
  ctx.restore();
}

/* ---- particles, projectiles, floaters ---- */
function drawParticles(){
  for(const p of Game.particles){
    ctx.globalAlpha=clamp(p.life/p.max,0,1);
    ctx.fillStyle=p.color; ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size);
  }
  ctx.globalAlpha=1;
  for(const a of Game.projectiles){
    const ang=Math.atan2(a.vy,a.vx);
    ctx.save(); ctx.translate(a.x,a.y); ctx.rotate(ang);
    ctx.fillStyle='#d8a868'; ctx.fillRect(-8,-1,14,2);
    ctx.fillStyle='#fff'; ctx.beginPath();ctx.moveTo(6,0);ctx.lineTo(2,-3);ctx.lineTo(2,3);ctx.fill();
    ctx.restore();
  }
  for(const f of Game.floaters){
    ctx.globalAlpha=clamp(f.life/f.max,0,1);
    ctx.font='bold '+f.size+'px sans-serif'; ctx.textAlign='center';
    ctx.fillStyle='#000'; ctx.fillText(f.txt,f.x+1,f.y+1);
    ctx.fillStyle=f.color; ctx.fillText(f.txt,f.x,f.y);
    ctx.textAlign='left';
  }
  ctx.globalAlpha=1;
}

/* ============================================================
   MAIN RENDER
   ============================================================ */
function render(){
  ctx.clearRect(0,0,VW,VH);
  ctx.save();
  // screen shake
  if(Game.shake>0){ ctx.translate(rand(-3,3)*Game.shake,rand(-3,3)*Game.shake); }
  // dark cave lighting
  drawTiles();
  // entities sorted by y
  const ents=Game.entities.slice().sort((a,b)=>a.y-b.y);
  // draw non-player entities, but insert player at correct y
  let drewPlayer=false;
  for(const e of ents){
    if(!drewPlayer && Game.player && e.y>Game.player.y){ drawPlayer(); drewPlayer=true; }
    drawEntity(e);
  }
  if(!drewPlayer) drawPlayer();
  drawParticles();
  // cave darkness overlay
  if(Game.roomName==='cave'||Game.roomName==='underground'){
    ctx.fillStyle='rgba(6,4,12,0.45)'; ctx.fillRect(0,0,VW,VH);
    // light around player
    const grd=ctx.createRadialGradient(Game.player.x,Game.player.y,30,Game.player.x,Game.player.y,160);
    grd.addColorStop(0,'rgba(0,0,0,0)'); grd.addColorStop(1,'rgba(6,4,12,0.55)');
    ctx.fillStyle=grd; ctx.fillRect(0,0,VW,VH);
  }
  ctx.restore();
  drawHUD();
}

/* ============================================================
   HUD
   ============================================================ */
const heartsEl=document.getElementById('hearts');
const stambar=document.getElementById('stambar');
const coinnum=document.getElementById('coinnum');
const weaponbar=document.getElementById('weaponbar');
const mmCanvas=document.getElementById('minimap');
const mmCtx=mmCanvas.getContext('2d');
const hintEl=document.getElementById('hint');

function drawHUD(){
  // hearts
  let html='';
  for(let i=0;i<Game.maxHearts;i++){
    const fill=clamp(Game.hp-i*2,0,2);
    html+=heartSVG(fill);
  }
  heartsEl.innerHTML=html;
  stambar.style.width=(Game.player?Game.player.stamina/Game.player.maxStamina*100:0)+'%';
  coinnum.textContent=Game.coins;
  // weapon bar
  const w=WEAPONS[Game.weaponLevel];
  let wb='<div class="wicon">'+weaponIconSVG(w)+'</div><div><div class="wname">'+w.name+'</div><div class="wk">J / Space</div></div>';
  if(Game.hasBow){ wb+='<div class="wicon">'+bowIconSVG()+'</div><div><div class="wname">Hunter\'s Bow</div><div class="wk">K</div></div>'; }
  weaponbar.innerHTML=wb;
  drawMinimap();
  // contextual hint
  let h='I: Inventory &nbsp; E: Interact';
  const near=nearestInteractable();
  if(near){ h = near.type==='shop'?'E: Shop': near.type==='save'?'E: Save': 'E: Talk'; }
  hintEl.innerHTML=h;
}
function nearestInteractable(){
  if(!Game.player) return null; let best=null,bd=40;
  for(const e of Game.entities){ if(e.type==='shop'||e.type==='save'||e.type==='npc'){ const d=dist(Game.player.x,Game.player.y,e.x,e.y); if(d<bd){bd=d;best=e;} } }
  return best;
}
function heartSVG(fill){
  // fill: 0,1,2 (empty, half, full)
  const col = fill>0?'#ff5a5a':'#3a2230';
  const half = fill===1;
  return `<svg class="heart" viewBox="0 0 24 22"><path d="M12 21C12 21 2 13.5 2 7.5C2 4 4.5 2 7.5 2C9.5 2 11 3 12 4.5C13 3 14.5 2 16.5 2C19.5 2 22 4 22 7.5C22 13.5 12 21 12 21Z" fill="#3a2230" stroke="#000" stroke-width="1.5"/><path d="M12 21C12 21 2 13.5 2 7.5C2 4 4.5 2 7.5 2C9.5 2 11 3 12 4.5C13 3 14.5 2 16.5 2C19.5 2 22 4 22 7.5C22 13.5 12 21 12 21Z" fill="${col}"/>${half?'<path d="M12 4.5C13 3 14.5 2 16.5 2C19.5 2 22 4 22 7.5C22 13.5 12 21 12 21Z" fill="#3a2230"/>':''}</svg>`;
}
function weaponIconSVG(w){
  const c=w.color;
  return `<svg width="18" height="18" viewBox="0 0 18 18"><rect x="2" y="8" width="3" height="4" fill="#6a5a4a"/><rect x="5" y="6" width="11" height="3" fill="${c}"/></svg>`;
}
function bowIconSVG(){ return `<svg width="18" height="18" viewBox="0 0 18 18"><path d="M4 2 Q14 9 4 16" stroke="#8a5a2a" stroke-width="2" fill="none"/><line x1="4" y1="2" x2="4" y2="16" stroke="#d8a868" stroke-width="1"/></svg>`; }

const ROOM_POS = { town:{x:1,y:1}, forest:{x:2,y:1}, clearing:{x:3,y:1}, cave:{x:0,y:1}, underground:{x:-1,y:1}, ruins:{x:1,y:0}, lake:{x:1,y:2}, grotto:{x:1,y:3} };
function drawMinimap(){
  mmCtx.clearRect(0,0,86,60);
  mmCtx.fillStyle='rgba(0,0,0,0.5)'; mmCtx.fillRect(0,0,86,60);
  const cx=43, cy=26, sz=12;
  for(const name in ROOM_POS){
    const p=ROOM_POS[name]; const rx=cx+p.x*sz-6, ry=cy+p.y*sz-6;
    const isCur=name===Game.roomName;
    mmCtx.fillStyle = isCur?'#ffd24d':'#5a4a78';
    mmCtx.fillRect(rx,ry,sz-2,sz-2);
    if(name==='grotto' && Game.progress<50){ mmCtx.fillStyle='rgba(0,0,0,0.5)'; mmCtx.fillRect(rx,ry,sz-2,sz-2); }
  }
  mmCtx.fillStyle='#b9a9d6'; mmCtx.font='7px sans-serif'; mmCtx.textAlign='center'; mmCtx.fillText('MAP',cx,57); mmCtx.textAlign='left';
}

/* ============================================================
   UI (Inventory, Shop, Save, Dialog, Toast, Fade)
   ============================================================ */
const UI = {
  invOpen:false, shopOpen:false, saveOpen:false,
  refreshInv(){
    const grid=document.getElementById('invGrid');
    let html='';
    const ids=Object.keys(Game.inv).filter(id=>Game.inv[id]>0);
    // always show key items in inv too
    for(const id of ids){
      const it=ITEMS[id]; if(!it) continue;
      const equipped=Game.equipped.includes(id);
      html+=`<div class="inv-slot ${equipped?'equipped':''}" onclick="UI.clickItem('${id}')">
        ${itemIconSVG(id,28)}<span class="inv-count">${Game.inv[id]>1?Game.inv[id]:''}</span></div>`;
    }
    // pad empty slots
    const total=Math.max(10, ids.length);
    for(let i=ids.length;i<total;i++) html+=`<div class="inv-slot empty"></div>`;
    grid.innerHTML=html;
    // equip slots
    const es=document.getElementById('equipSlots');
    let eh='';
    for(let i=0;i<2;i++){ const id=Game.equipped[i]; eh+=`<div class="eslot ${id?'filled':''}" onclick="UI.unequipSlot(${i})">${id?itemIconSVG(id,30):'Accessory '+(i+1)}</div>`; }
    es.innerHTML=eh;
  },
  clickItem(id){
    const it=ITEMS[id]; if(!it) return;
    if(it.kind==='consumable'){ useConsumable(id); this.refreshInv(); }
    else if(it.kind==='accessory'){ this.toggleEquip(id); }
    else { toast(it.name+': '+it.desc); }
  },
  toggleEquip(id){
    const i=Game.equipped.indexOf(id);
    if(i>=0){ Game.equipped.splice(i,1); toast(ITEMS[id].name+' unequipped.'); }
    else { if(Game.equipped.length>=2){toast('Two accessories max. Unequip one first.');return;} Game.equipped.push(id); toast(ITEMS[id].name+' equipped!'); Audio.sfx.pickup(); }
    this.refreshInv();
  },
  unequipSlot(i){ if(Game.equipped[i]){ const id=Game.equipped.splice(i,1)[0]; toast(ITEMS[id].name+' unequipped.'); this.refreshInv(); } },
  openInv(){ this.invOpen=true; this.refreshInv(); document.getElementById('invOverlay').classList.add('show'); },
  closeInv(){ this.invOpen=false; document.getElementById('invOverlay').classList.remove('show'); },
  openShop(){ this.shopOpen=true; this.buildShop(); document.getElementById('shopOverlay').classList.add('show'); },
  closeShop(){ this.shopOpen=false; document.getElementById('shopOverlay').classList.remove('show'); },
  buildShop(){
    const shopItems=['potion','elixir','bomb','ring','charm'];
    let html='';
    for(const id of shopItems){
      const it=ITEMS[id]; const can=Game.coins>=it.price;
      html+=`<div class="shopitem ${can?'':'disabled'}" onclick="UI.buy('${id}')">
        <div class="si-icon">${itemIconSVG(id,24)}</div>
        <div class="si-info"><div class="si-name">${it.name}</div><div class="si-desc">${it.desc}</div></div>
        <div class="si-price">${it.price}c</div></div>`;
    }
    document.getElementById('shopGrid').innerHTML=html;
  },
  buy(id){
    const it=ITEMS[id];
    if(Game.coins<it.price){ toast('Not enough coins.'); return; }
    Game.coins-=it.price; addItem(id,1); Audio.sfx.coin();
    toast('Bought '+it.name+'!'); this.buildShop();
  },
  openSave(){ this.saveOpen=true; document.getElementById('saveText').textContent='Record your progress at this crystal? (Health will be restored.)'; document.getElementById('saveOverlay').classList.add('show'); },
  closeSave(){ this.saveOpen=false; document.getElementById('saveOverlay').classList.remove('show'); },
  showDialog(who,what){ document.getElementById('dlgwho').textContent=who; document.getElementById('dlgwhat').textContent=what; document.getElementById('dialog').classList.add('show'); setTimeout(()=>document.getElementById('dialog').classList.remove('show'),4200); },
};
function itemIconSVG(id,s){
  const it=ITEMS[id]; const c=it?it.color:'#fff';
  const ic = it?it.icon:'x';
  const svg={
    potion:`<circle cx="12" cy="14" r="6" fill="${c}"/><rect x="10" y="4" width="4" height="6" fill="#8a6a4a"/><rect x="9" y="3" width="6" height="2" fill="#6a4a2a"/>`,
    elixir:`<circle cx="12" cy="14" r="7" fill="${c}"/><rect x="10" y="4" width="4" height="6" fill="#8a6a4a"/><circle cx="10" cy="13" r="1.5" fill="#fff" opacity="0.7"/>`,
    bomb:`<circle cx="12" cy="14" r="7" fill="#444"/><rect x="11" y="4" width="2" height="5" fill="#8a6a4a"/><circle cx="12" cy="3" r="2" fill="#ff8a3a"/>`,
    ring:`<circle cx="12" cy="12" r="7" fill="none" stroke="${c}" stroke-width="3"/><circle cx="12" cy="12" r="2" fill="${c}"/>`,
    boots:`<path d="M4 8 L12 8 L12 14 L20 14 L20 18 L4 18 Z" fill="${c}"/>`,
    charm:`<path d="M12 3 L15 9 L21 10 L16 14 L18 20 L12 17 L6 20 L8 14 L3 10 L9 9 Z" fill="${c}"/>`,
    whetstone:`<rect x="4" y="9" width="16" height="6" rx="2" fill="${c}"/><rect x="5" y="10" width="14" height="2" fill="#fff" opacity="0.5"/>`,
    crimson:`<rect x="3" y="10" width="3" height="4" fill="#6a4a2a"/><rect x="6" y="9" width="14" height="4" fill="${c}"/><rect x="6" y="9" width="14" height="1.5" fill="#fff" opacity="0.6"/>`,
    bow:`<path d="M6 3 Q16 12 6 21" stroke="${c}" stroke-width="2.5" fill="none"/><line x1="6" y1="3" x2="6" y2="21" stroke="#8a5a2a" stroke-width="1"/>`,
    heart:`<path d="M12 20 C12 20 3 13 3 7.5 C3 4 5.2 2 8 2 C10 2 11.3 3.2 12 4.5 C12.7 3.2 14 2 16 2 C18.8 2 21 4 21 7.5 C21 13 12 20 12 20 Z" fill="${c}"/>`,
  };
  return `<svg width="${s}" height="${s}" viewBox="0 0 24 24">${svg[ic]||''}</svg>`;
}

let toastT=null;
function toast(msg){
  const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show');
  clearTimeout(toastT); toastT=setTimeout(()=>el.classList.remove('show'),2600);
}
function fadeOut(cb){ const f=document.getElementById('fade'); f.style.opacity='1'; setTimeout(cb,300); }
function fadeIn(cb){ const f=document.getElementById('fade'); setTimeout(()=>{ f.style.opacity='0'; if(cb)cb(); },50); }

/* ============================================================
   SAVE / LOAD (localStorage)
   ============================================================ */
const SAVE_KEY='emberhollow_save_v1';
const Save = {
  confirm(){
    Game.hp=Game.maxHearts*2; // restore health on save
    Game.player.stamina=Game.player.maxStamina;
    Game.saveRoom=Game.roomName; Game.saveTx=Math.round(Game.player.x/TILE-0.5); Game.saveTy=Math.round(Game.player.y/TILE-0.5);
    const data={
      inv:Game.inv, equipped:Game.equipped, coins:Game.coins,
      weaponLevel:Game.weaponLevel, hasBow:Game.hasBow,
      maxHearts:Game.maxHearts, hp:Game.hp, progress:Game.progress,
      flags:Game.flags, saveRoom:Game.saveRoom, saveTx:Game.saveTx, saveTy:Game.saveTy,
    };
    localStorage.setItem(SAVE_KEY, JSON.stringify(data));
    Audio.sfx.save(); spawnParticles(Game.player.x,Game.player.y,20,{color:'#b8d8ff',speed:90,life:0.7,size:3});
    toast('Progress saved! Health restored.');
    UI.closeSave();
    document.getElementById('loadBtn').style.display='';
  },
  load(){
    const raw=localStorage.getItem(SAVE_KEY); if(!raw) return null;
    try{ return JSON.parse(raw);}catch(e){ return null; }
  },
  hasSave(){ return !!localStorage.getItem(SAVE_KEY); }
};

/* ============================================================
   GAME FLOW
   ============================================================ */
function showOver(title,txt){
  document.getElementById('overTitle').textContent=title;
  document.getElementById('overText').textContent=txt;
  document.getElementById('overBtn').textContent = Game.state==='win'?'New Game':'Return to Sanctum';
  document.getElementById('overOverlay').classList.add('show');
}
Game.newGame=function(){
  Audio.init(); Audio.resume();
  // reset state
  Object.assign(Game,{ inv:{}, equipped:[], coins:0, weaponLevel:0, hasBow:false, maxHearts:3, hp:6, progress:0,
    flags:{ boss1:false,boss2:false,upgrade1:false,upgrade2:false,heart1:false,heart2:false,bow:false,boots:false },
    particles:[],floaters:[],projectiles:[], transitioning:false, state:'play', _won:false });
  Game.player=makePlayer(9*TILE+TILE/2,10*TILE+TILE/2);
  loadRoom('town',null);
  document.getElementById('titleScreen').style.display='none';
  document.getElementById('overOverlay').classList.remove('show');
  UI.refreshInv();
  toast("Welcome to Haven Town. Speak to the Elder, then head EAST.");
};
Game.loadGame=function(){
  const d=Save.load(); if(!d){ Game.newGame(); return; }
  Audio.init(); Audio.resume();
  Game.inv=d.inv||{}; Game.equipped=d.equipped||[]; Game.coins=d.coins||0;
  Game.weaponLevel=d.weaponLevel||0; Game.hasBow=!!d.hasBow; Game.maxHearts=d.maxHearts||3; Game.hp=d.hp||Game.maxHearts*2;
  Game.progress=d.progress||0; Game.flags=Object.assign({boss1:false,boss2:false,upgrade1:false,upgrade2:false,heart1:false,heart2:false,bow:false,boots:false},d.flags||{});
  Game.saveRoom=d.saveRoom||'town'; Game.saveTx=d.saveTx||9; Game.saveTy=d.saveTy||10;
  Game.player=makePlayer((d.saveTx||9)*TILE+TILE/2,(d.saveTy||10)*TILE+TILE/2);
  Game.state='play';
  loadRoom(Game.saveRoom,null);
  document.getElementById('titleScreen').style.display='none';
  document.getElementById('overOverlay').classList.remove('show');
  UI.refreshInv();
  toast("Journey resumed.");
};
Game.restart=function(){
  document.getElementById('overOverlay').classList.remove('show');
  if(Game.state==='win'){ Game.newGame(); return; }
  // dead: return to save with full hp
  Game.state='play';
  Game.hp=Game.maxHearts*2;
  Game.player=makePlayer(Game.saveTx*TILE+TILE/2,Game.saveTy*TILE+TILE/2);
  Game.particles=[];Game.projectiles=[];Game.floaters=[];
  loadRoom(Game.saveRoom,null);
  toast("You awaken at the sanctum crystal.");
};

/* ============================================================
   UPDATE & LOOP
   ============================================================ */
function update(dt){
  if(Game.state!=='play') return;
  Game.time+=dt;
  if(Game.shake>0) Game.shake=Math.max(0,Game.shake-dt*2);
  updatePlayer(dt);
  updateProjectiles(dt);
  updateBlocks(dt);
  updatePickups(dt);
  for(const e of Game.entities){ if(e.type==='enemy'||e.type==='boss') updateEnemy(e,dt); if(e.type==='npc')e.bob+=dt; }
  updateParticles(dt);
  // win condition
  if(Game.flags.upgrade2 && Game.flags.heart2 && Game.flags.bow && !Game._won){
    Game._won=true; Game.state='win';
    setTimeout(()=>showOver('Hollow Conquered!','You forged the Crimson Blade, claimed both Heart Crystals, and found the Hunter\'s Bow. The Hollow is at peace.'),600);
  }
}

let last=0;
function loop(ts){
  const dt=Math.min(0.04,(ts-last)/1000||0); last=ts;
  // handle menu toggles
  if(Game.state==='play'){
    if(Input.pressed('i')||Input.pressed('tab')){ if(UI.invOpen) UI.closeInv(); else if(!UI.shopOpen&&!UI.saveOpen) UI.openInv(); }
    if(Input.pressed('e')){ /* handled in updatePlayer via tryInteract but also closes menus; ensure */ }
    if(Input.pressed('escape')){ if(UI.invOpen)UI.closeInv(); if(UI.shopOpen)UI.closeShop(); if(UI.saveOpen)UI.closeSave(); }
    update(dt);
  }
  if(Game.room) render();
  Input.clearJust();
  requestAnimationFrame(loop);
}

// boot
window.addEventListener('load',()=>{
  if(Save.hasSave()){ document.getElementById('loadBtn').style.display=''; }
  document.getElementById('titleScreen').addEventListener('click',()=>{ Audio.init(); Audio.resume(); });
  requestAnimationFrame(loop);
});
// expose for console debug
window.Game=Game; window.UI=UI;
</script>
</body>
</html>