← RPG results

RPG

Deepseek v4 Pro 0813 · 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 107,694 bytes · SHA-256 40d5b1df1236
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emberheart — Action-Adventure RPG</title>
<style>
  :root{
    --gold:#e8b04b; --gold2:#f7d98b; --ink:#120f14; --parch:#efe3c8;
    --panel:#1a1420e6; --panel2:#241b2b; --red:#e0433f; --green:#5fbf6a; --blue:#5aa7d6;
  }
  *{ box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
  html,body{ width:100%; height:100%; }
  body{
    background:#05060a radial-gradient(1200px 700px at 50% 20%, #141021 0%, #05060a 70%);
    display:flex; align-items:center; justify-content:center; overflow:hidden;
    font-family:'Segoe UI', 'Trebuchet MS', ui-monospace, Menlo, monospace;
    color:var(--parch); user-select:none; -webkit-user-select:none;
  }
  #stage{
    width:512px; height:288px; position:relative; transform-origin:center center;
    box-shadow:0 0 0 3px #000, 0 0 60px #000000cc, 0 0 24px #7a4c1d33;
  }
  canvas{ display:block; width:512px; height:288px; image-rendering:pixelated; background:#000; }
  .overlay{
    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
    text-align:center; z-index:20;
  }
  .hidden{ display:none !important; }
  #fade{ position:absolute; inset:0; background:#000; opacity:0; pointer-events:none; z-index:40; }
  #dialogue{
    position:absolute; left:10px; right:10px; bottom:10px; z-index:15; min-height:52px;
    background:var(--panel); border:2px solid #5a4566; border-radius:8px; padding:8px 12px;
    box-shadow:0 6px 18px #000a; text-align:left;
  }
  #dialogueName{ font-size:9px; letter-spacing:2px; color:var(--gold); text-transform:uppercase; margin-bottom:3px; }
  #dialogueText{ font-size:13px; line-height:1.35; color:#f3ead6; min-height:32px; }
  #dialogue .hint{ position:absolute; right:10px; bottom:6px; font-size:9px; color:#bda9c8; animation:blink 1s steps(2) infinite; }
  @keyframes blink{ 50%{ opacity:.25; } }
  #toasts{ position:absolute; top:40px; left:0; right:0; display:flex; flex-direction:column; align-items:center; gap:3px; z-index:35; pointer-events:none; }
  .toast{
    background:#100d15e6; border:1px solid var(--gold); color:var(--gold2); font-size:11px; letter-spacing:.5px;
    padding:4px 10px; border-radius:6px; animation:toastIn 2.4s forwards; box-shadow:0 4px 12px #000a;
  }
  @keyframes toastIn{ 0%{opacity:0; transform:translateY(-6px);} 8%{opacity:1; transform:translateY(0);} 80%{opacity:1;} 100%{opacity:0; transform:translateY(-10px);} }
  .btn{
    background:linear-gradient(#2b2033,#1b1422); border:2px solid #6d567b; color:var(--parch);
    padding:8px 22px; margin:5px; border-radius:8px; font-size:13px; letter-spacing:1px; cursor:pointer;
    font-family:inherit; transition:transform .08s, background .12s; box-shadow:0 4px 0 #000a;
  }
  .btn:hover{ background:linear-gradient(#3a2b45,#241b30); border-color:var(--gold); transform:translateY(-1px); }
  .btn:active{ transform:translateY(1px); }
  .btn.primary{ background:linear-gradient(#5f3d1e,#3f2712); border-color:var(--gold); }
  .btn.primary:hover{ background:linear-gradient(#7a4f28,#573617); }
  .btn:disabled{ opacity:.35; cursor:not-allowed; transform:none; }
  #title{ background:radial-gradient(circle at 50% 40%, #241a2c66 0%, #0b0810d9 75%); backdrop-filter:blur(1px); z-index:30; }
  #title .logo{
    font-size:44px; letter-spacing:6px; font-weight:800; color:var(--gold2);
    text-shadow:0 0 12px #e08a2ecc, 0 3px 0 #3a2410, 0 5px 0 #000;
  }
  #title .sub{ font-size:11px; letter-spacing:4px; color:#cbb693; margin-top:2px; text-transform:uppercase; }
  #title .desc{ font-size:11px; color:#b7a6c2; max-width:400px; margin:12px 20px 4px; line-height:1.5; }
  .controls{ font-size:10px; color:#8f7f9b; line-height:1.7; margin:8px 0; }
  .controls b{ color:#d8cbbf; }
  #modal{ background:#050409a6; backdrop-filter:blur(2px); z-index:25; }
  .panel{
    width:420px; max-height:250px; background:var(--panel); border:2px solid #6d567b; border-radius:10px;
    padding:12px 14px; box-shadow:0 10px 40px #000c; display:flex; flex-direction:column; text-align:left;
  }
  .panel h2{ font-size:15px; letter-spacing:2px; color:var(--gold); margin-bottom:8px; text-align:center; }
  .panel .close-hint{ text-align:center; font-size:9px; color:#8f7f9b; margin-top:8px; }
  #invGrid{ display:grid; grid-template-columns:repeat(6,1fr); gap:6px; overflow:auto; padding:4px; }
  .slot{
    background:#0f0c14; border:2px solid #453450; border-radius:6px; padding:6px 4px; min-height:58px;
    cursor:pointer; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:3px;
    font-size:9px; color:#cfc0d8; position:relative; transition:border-color .1s;
  }
  .slot:hover{ border-color:var(--gold); }
  .slot.selected{ border-color:var(--gold); box-shadow:0 0 8px #e8b04b66; }
  .slot .ic{ font-size:20px; line-height:1; filter:drop-shadow(0 2px 1px #000); }
  .slot .qty{ position:absolute; right:3px; bottom:2px; font-size:10px; color:var(--gold2); }
  .slot.eq::after{ content:'⚔'; position:absolute; left:3px; top:1px; font-size:9px; color:var(--gold); }
  #itemInfo{ margin-top:8px; background:#0f0c14; border:1px solid #453450; border-radius:6px; padding:7px 9px; font-size:11px; line-height:1.45; color:#e6d8c4; min-height:44px; }
  #itemInfo b{ color:var(--gold2); }
  .row{ display:flex; gap:8px; align-items:center; justify-content:center; margin-top:8px; }
  #shopList{ overflow:auto; }
  .shopItem{ display:flex; align-items:center; gap:8px; padding:7px 6px; border-bottom:1px solid #2b2131; font-size:11px; }
  .shopItem:last-child{ border-bottom:none; }
  .shopItem .ic{ font-size:18px; }
  .shopItem .nm{ flex:1; color:#e6d8c4; }
  .shopItem .nm small{ display:block; color:#8f7f9b; font-size:9px; }
  .shopItem .pr{ color:var(--gold2); font-size:11px; white-space:nowrap; }
  .buyBtn{ font-size:10px; padding:4px 9px; margin:0; }
  #bigText{ font-size:26px; letter-spacing:4px; color:var(--gold2); text-shadow:0 0 18px #e08a2e, 0 3px 0 #000; }
  .statline{ font-size:11px; color:#cfc0d8; margin:3px; }
  @media (max-width:600px){
    #title .logo{ font-size:32px; }
    .panel{ width:92%; }
  }
</style>
</head>
<body>
<div id="stage">
  <canvas id="game" width="512" height="288"></canvas>

  <div id="dialogue" class="hidden">
    <div id="dialogueName"></div>
    <div id="dialogueText"></div>
    <div class="hint">E / SPACE ▸</div>
  </div>

  <div id="toasts"></div>

  <div id="title" class="overlay">
    <div class="logo">EMBERHEART</div>
    <div class="sub">A one-hour action adventure</div>
    <div class="desc">A land has grown quiet, its heart stolen by the Corruption. Explore the forest, claim the Ember Blade, discover an ancient bow, and storm the ruins — piece by piece, blade by blade.</div>
    <div class="controls">
      <b>WASD / Arrows</b> move · <b>Space</b> attack · <b>Shift</b> sprint · <b>K</b> dash<br>
      <b>E</b> interact · <b>Q</b> swap sword/bow · <b>H</b> potion · <b>B</b> bomb<br>
      <b>I</b> inventory · <b>M</b> mute · <b>Esc</b> pause
    </div>
    <div>
      <button class="btn primary" id="btnNew">New Game</button>
      <button class="btn" id="btnCont">Continue</button>
    </div>
  </div>

  <div id="modal" class="overlay hidden"><div class="panel" id="modalInner"></div></div>

  <div id="death" class="overlay hidden">
    <div id="bigText">YOU HAVE FALLEN</div>
    <div class="statline">The shrine's light pulls you back…</div>
    <button class="btn primary" id="btnRespawn">Return to Shrine</button>
  </div>

  <div id="win" class="overlay hidden">
    <div id="bigText">LIGHT RESTORED</div>
    <div class="statline" id="winStats"></div>
    <button class="btn primary" id="btnAgain">Play Again</button>
  </div>

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

<script>
"use strict";
/* ============================================================
   EMBERHEART — a single-file action-adventure RPG
   Chunk 1: shell, helpers, input, audio
   ============================================================ */

const TILE = 16, VIEW_W = 512, VIEW_H = 288, MAP_W = 32, MAP_H = 18;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage = document.getElementById('stage');

function fit(){
  const s = Math.min(window.innerWidth / VIEW_W, window.innerHeight / VIEW_H) * 0.96;
  stage.style.transform = 'scale(' + s + ')';
}
window.addEventListener('resize', fit); fit();

/* ---------- small helpers ---------- */
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const lerp = (a,b,t)=> a+(b-a)*t;
const rnd = (a,b)=> a + Math.random()*(b-a);
const irnd = (a,b)=> Math.floor(rnd(a,b+1));
const pick = arr => arr[Math.floor(Math.random()*arr.length)];
const dist2 = (ax,ay,bx,by)=>{ const dx=ax-bx, dy=ay-by; return dx*dx+dy*dy; };
function hash2(x,y){ let h = (x*374761393 + y*668265263)|0; h = (h ^ (h>>13)) * 1274126177; return ((h ^ (h>>16))>>>0) / 4294967295; }
function angLerp(a,b,t){ let d = ((b-a+Math.PI*3)%(Math.PI*2))-Math.PI; return a + d*t; }

/* ---------- global game state ---------- */
const S = {
  mode:'title',            // title | play | dead | won
  mapId:null, map:null,
  tiles:[], ents:[], projectiles:[], particles:[], texts:[], pickups:[],
  player:null,
  flags:{}, tilesOver:{}, shopSold:[],
  inv:{}, equipped:null, rupees:0,
  respawn:{map:'village', x:16, y:14},
  started:false, time:0, kills:0,
  transition:null,          // {t, dir, cb}
  dialog:null, modal:null,  // 'inventory'|'shop'|'pause'
  shake:0, flash:0, objective:'',
  deathTimer:0, wonStats:null,
  muted:false,
};

/* ---------- input ---------- */
const keys = {};
const pressed = new Set();
const mouse = { x:VIEW_W/2, y:VIEW_H/2, active:false, down:false };

window.addEventListener('keydown', e => {
  AudioSys.ensure();
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' ','Spacebar'].includes(e.key)) e.preventDefault();
  if(e.repeat) return;
  keys[e.code] = true;
  pressed.add(e.code);
  onKeyDown(e.code);
});
window.addEventListener('keyup', e => { keys[e.code] = false; });

function canvasPos(e){
  const r = canvas.getBoundingClientRect();
  return { x:(e.clientX-r.left)/r.width*VIEW_W, y:(e.clientY-r.top)/r.height*VIEW_H };
}
canvas.addEventListener('mousemove', e => { const p = canvasPos(e); mouse.x = p.x; mouse.y = p.y; mouse.active = true; });
canvas.addEventListener('mousedown', e => { const p = canvasPos(e); mouse.x = p.x; mouse.y = p.y; mouse.active = true; mouse.down = true; });
window.addEventListener('mouseup', ()=> mouse.down = false);

/* ---------- audio ---------- */
const AudioSys = {
  ctx:null, master:null, musicGain:null, enabled:true, noiseBuf:null,
  ensure(){
    if(!this.enabled) return;
    if(!this.ctx){
      const AC = window.AudioContext || window.webkitAudioContext;
      if(!AC) return;
      this.ctx = new AC();
      this.master = this.ctx.createGain(); this.master.gain.value = 0.5; this.master.connect(this.ctx.destination);
      this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.14; this.musicGain.connect(this.master);
      const len = this.ctx.sampleRate * 0.5;
      this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
      const d = this.noiseBuf.getChannelData(0);
      for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
    }
    if(this.ctx.state === 'suspended') this.ctx.resume();
  },
  tone(o){
    if(!this.ctx || !this.enabled) return;
    const t = this.ctx.currentTime + (o.delay||0);
    const osc = this.ctx.createOscillator(), g = this.ctx.createGain();
    osc.type = o.type || 'square';
    osc.frequency.setValueAtTime(Math.max(20,o.freq), t);
    if(o.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20,o.slide), t+(o.dur||0.1));
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(o.vol||0.1, t+(o.attack||0.006));
    g.gain.exponentialRampToValueAtTime(0.0001, t+(o.dur||0.1));
    osc.connect(g); g.connect(this.master);
    osc.start(t); osc.stop(t+(o.dur||0.1)+0.05);
  },
  noise(o){
    if(!this.ctx || !this.enabled || !this.noiseBuf) return;
    const t = this.ctx.currentTime + (o.delay||0);
    const src = this.ctx.createBufferSource(); src.buffer = this.noiseBuf; src.loop = true;
    const f = this.ctx.createBiquadFilter(); f.type = o.filter || 'lowpass'; f.frequency.setValueAtTime(o.freq||1200, t);
    const g = this.ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(o.vol||0.1, t+(o.attack||0.005));
    g.gain.exponentialRampToValueAtTime(0.0001, t+(o.dur||0.1));
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t); src.stop(t+(o.dur||0.1)+0.05);
  }
};

const SFX = {
  step(o){
    if(Math.random()<0.5) AudioSys.tone({freq:rnd(150,190), dur:0.03, type:'triangle', vol:0.018, attack:0.002});
    else AudioSys.noise({dur:0.035, vol:0.012, freq:900, filter:'lowpass'});
  },
  swing(){ AudioSys.noise({dur:0.09, vol:0.12, freq:2600, filter:'bandpass'}); AudioSys.tone({freq:520, slide:180, dur:0.09, type:'sawtooth', vol:0.03}); },
  hit(){ AudioSys.noise({dur:0.08, vol:0.16, freq:500, filter:'lowpass'}); AudioSys.tone({freq:180, slide:90, dur:0.07, type:'square', vol:0.07}); },
  hurt(){ AudioSys.tone({freq:300, slide:110, dur:0.22, type:'sawtooth', vol:0.14}); AudioSys.noise({dur:0.14, vol:0.08, freq:400}); },
  pickup(){ AudioSys.tone({freq:660, dur:0.06, type:'square', vol:0.07}); AudioSys.tone({freq:990, dur:0.09, type:'square', vol:0.07, delay:0.06}); },
  coin(){ AudioSys.tone({freq:880, dur:0.05, type:'square', vol:0.06}); AudioSys.tone({freq:1320, dur:0.1, type:'square', vol:0.06, delay:0.05}); },
  chest(){ [523,659,784,1046].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.12, type:'triangle', vol:0.09, delay:i*0.07})); },
  shop(){ AudioSys.tone({freq:740, dur:0.06, type:'square', vol:0.07}); AudioSys.tone({freq:1180, dur:0.1, type:'square', vol:0.07, delay:0.07}); },
  heart(){ [392,523,659,784].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.16, type:'triangle', vol:0.1, delay:i*0.08})); },
  save(){ [523,659,880].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.25, type:'triangle', vol:0.08, delay:i*0.1})); },
  bow(){ AudioSys.tone({freq:900, slide:300, dur:0.12, type:'sawtooth', vol:0.05}); AudioSys.noise({dur:0.06, vol:0.05, freq:3000, filter:'highpass'}); },
  arrow(){ AudioSys.noise({dur:0.05, vol:0.05, freq:4000, filter:'highpass'}); },
  bomb(){ AudioSys.noise({dur:0.4, vol:0.25, freq:180, filter:'lowpass'}); AudioSys.tone({freq:90, slide:40, dur:0.4, type:'sine', vol:0.18}); },
  switch(){ AudioSys.tone({freq:420, dur:0.05, type:'square', vol:0.06}); AudioSys.tone({freq:620, dur:0.06, type:'square', vol:0.06, delay:0.05}); },
  gate(){ AudioSys.noise({dur:0.3, vol:0.08, freq:140, filter:'lowpass'}); AudioSys.tone({freq:70, slide:50, dur:0.3, type:'sine', vol:0.1}); },
  roar(){ AudioSys.tone({freq:160, slide:60, dur:0.5, type:'sawtooth', vol:0.16}); AudioSys.tone({freq:90, slide:45, dur:0.5, type:'square', vol:0.1}); },
  win(){ [523,659,784,1046,1318,1568].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.22, type:'triangle', vol:0.09, delay:i*0.11})); },
  die(){ [440,349,262,196,131].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.28, type:'sawtooth', vol:0.09, delay:i*0.15})); },
  levelup(){ [440,660,880,1320].forEach((f,i)=> AudioSys.tone({freq:f, dur:0.09, type:'square', vol:0.07, delay:i*0.05})); },
  pot(){ AudioSys.noise({dur:0.12, vol:0.14, freq:2200, filter:'bandpass'}); AudioSys.tone({freq:220, slide:120, dur:0.08, type:'square', vol:0.05}); },
  burn(){ AudioSys.noise({dur:0.25, vol:0.1, freq:900, filter:'lowpass'}); AudioSys.tone({freq:500, slide:200, dur:0.25, type:'sawtooth', vol:0.05}); },
};
function sfx(name, opt){ (SFX[name] || function(){})(opt); }

/* ---------- music: soft pentatonic ambience ---------- */
const Music = {
  timer:null, step:0, next:0,
  start(){
    if(this.timer || !AudioSys.ctx) return;
    this.next = AudioSys.ctx.currentTime + 0.2;
    this.timer = setInterval(()=> this.tick(), 140);
  },
  tick(){
    if(!AudioSys.ctx || !S.started) return;
    const scale = [220, 261.6, 329.6, 392, 440, 523.3, 587.3];
    while(this.next < AudioSys.ctx.currentTime + 0.3){
      const t = this.next - AudioSys.ctx.currentTime;
      const n = scale[this.step % scale.length];
      if(this.step % 4 === 0) AudioSys.tone({freq:scale[0]/2, dur:0.5, type:'sine', vol:0.028, delay:Math.max(0,t)});
      AudioSys.tone({freq:n, dur:0.34, type:'triangle', vol:0.016, delay:Math.max(0,t)});
      if(Math.random()<0.18) AudioSys.tone({freq:n*2, dur:0.22, type:'sine', vol:0.01, delay:Math.max(0,t+0.12)});
      this.step++;
      this.next += 0.24;
    }
  },
  stop(){ if(this.timer){ clearInterval(this.timer); this.timer = null; } }
};

/* ---------- toast / misc UI ---------- */
const toastsEl = document.getElementById('toasts');
function toast(text, color){
  const el = document.createElement('div');
  el.className = 'toast';
  if(color) el.style.borderColor = el.style.color = color;
  el.textContent = text;
  toastsEl.appendChild(el);
  setTimeout(()=> el.remove(), 2500);
}


/* ============================================================
   Chunk 2: tile defs, world maps, items/shop data
   ============================================================ */

const TILE_DEFS = {
  '.': { solid:false, name:'grass' },
  ',': { solid:false, name:'grass2' },
  '=': { solid:false, name:'path' },
  '#': { solid:true,  name:'tree' },
  '~': { solid:true,  name:'water' },
  'w': { solid:true,  name:'wall' },
  'f': { solid:false, name:'floor' },
  'r': { solid:true,  name:'rock' },
  'b': { solid:true,  name:'bush' },
  'G': { solid:true,  name:'gate' },
  'V': { solid:true,  name:'vine' },
  'c': { solid:true,  name:'crack' },
};
function solidChar(c){ return (TILE_DEFS[c] && TILE_DEFS[c].solid) === true; }

/* Builds a 32x18 map. `rows` are 16 interior rows of up to 30 chars;
   the builder wraps them with a border and applies openTiles. */
function makeMap(id, name, border, rows, entities, openTiles){
  const g = [];
  const borderRow = Array(MAP_W).fill(border);
  g.push(borderRow.slice());
  for(let r=0; r<MAP_H-2; r++){
    const src = rows[r] || '';
    const row = new Array(MAP_W).fill(border);
    row[0] = border;
    for(let c=0; c<MAP_W-2; c++){
      const ch = src[c];
      row[c+1] = (ch === undefined || ch === ' ') ? border : ch;
    }
    row[MAP_W-1] = border;
    g.push(row);
  }
  g.push(borderRow.slice());
  if(openTiles) for(const [x,y,ch] of openTiles) g[y][x] = ch;
  return {
    id, name, border,
    floorChar: border === 'w' ? 'f' : '.',
    grid: g,
    ents: entities || [],
  };
}

/* ---------- world maps ---------- */
const MAPS = {};

MAPS.village = makeMap('village','Emberheart Village','#',[
  "..............==..............",
  "..............==....~~~~~~....",
  "..............==....~....~....",
  "..............==....~~~~~~....",
  "..............==..............",
  "..............==..............",
  "......##......==......##......",
  "......##......==......##......",
  "..............==..............",
  "..............==..............",
  "..............==..............",
  "..............==..............",
  "..............==..............",
  "..............==..............",
  "..............==..............",
  "..............==..............",
],[
  { t:'spawn', x:16, y:14 },
  { t:'npc', x:9, y:11, name:'Elder Rowan', lines:[
    "Welcome, traveler. Emberheart has not been kind since the Corruption came.",
    "Your wooden blade will do little against it. Seek the EMBER BLADE in the forest to the west.",
    "Press WASD to move, SPACE to swing your blade, and E to talk and open things.",
    "Hold SHIFT to sprint and press K to dash — watch your stamina.",
    "The shrine in the village will record your progress. Come back and rest often."] },
  { t:'shop', x:24, y:9 },
  { t:'shrine', x:16, y:12 },
  { t:'sign', x:16, y:6, lines:["Welcome to Emberheart Village. West: Thornwood Forest · North: Gloomspire Cave · East: the Sunken Ruins."] },
  { t:'chest', x:23, y:13, flag:'c_village_1', grants:[{type:'rupees', amt:15}] },
  { t:'pot', x:6, y:12 }, { t:'pot', x:26, y:12 }, { t:'pot', x:12, y:4 }, { t:'pot', x:20, y:4 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'meadow', tx:16, ty:16.4 },
],[
  [15,0,'='], [16,0,'='], [15,17,'='], [16,17,'='],
]);

MAPS.meadow = makeMap('meadow','Silverleaf Meadow','#',[
  "..............==..............",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....====..GG..",
  "..............==....====..GG..",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~=~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
  "..............==....~~~~......",
],[
  { t:'slime', x:8, y:6 }, { t:'slime', x:12, y:12 }, { t:'slime', x:17, y:4 },
  { t:'pot', x:5, y:9 }, { t:'pot', x:27, y:4 }, { t:'pot', x:11, y:14 },
  { t:'chest', x:6, y:13, flag:'c_meadow_1', grants:[{type:'rupees', amt:20}] },
  { t:'shrine', x:16, y:7 },
  { t:'eye', x:23, y:13, gate:'meadow' },
  { t:'sign', x:26, y:7, lines:["The Sunken Ruins lie beyond that gate. Only a true shot from across the water will wake the eye-switch."] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'village', tx:16, ty:1.4 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'cave_mouth', tx:16, ty:16.4 },
  { t:'warp', x:-1.2, y:8.5, w:1.4, h:3, to:'forest_edge', tx:30.4, ty:9.5 },
  { t:'warp', x:31.2, y:8.5, w:1.4, h:3, to:'ruins_entrance', tx:1.5, ty:9.5 },
],[
  [15,0,'='], [16,0,'='], [15,17,'='], [16,17,'='],
  [0,9,'='], [0,10,'='], [31,9,'='], [31,10,'='],
]);

MAPS.forest_edge = makeMap('forest_edge','Thornwood Edge','#',[
  "..............==..............",
  "..............==....##...#....",
  "...##.........==.............#",
  "..#............==...##........",
  "..............==....#.........",
  ".....#........==.............#",
  "..........#...==..............",
  "..............==.....#........",
  "VV............==..........#...",
  "VV............==..............",
  "..#...........==....##........",
  "..............==..............",
  "....##........==.........#....",
  "..............==.............#",
  "..............==..............",
  "..............==..............",
],[
  { t:'slime', x:8, y:8 }, { t:'slime', x:12, y:13 }, { t:'slime', x:26, y:7 },
  { t:'thorn', x:20, y:7 }, { t:'thorn', x:23, y:12 },
  { t:'pot', x:20, y:10 }, { t:'pot', x:6, y:11 }, { t:'pot', x:27, y:4 },
  { t:'chest', x:28, y:14, flag:'c_forest_1', grants:[{type:'rupees', amt:15}] },
  { t:'sign', x:13, y:10, lines:["The grove west of here is sealed by living vines. Only fire will open the way."] },
  { t:'warp', x:31.2, y:8.5, w:1.4, h:3, to:'meadow', tx:1.5, ty:9.5 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'forest_depths', tx:16, ty:16.4 },
  { t:'warp', x:-1.2, y:8.5, w:1.4, h:3, to:'grove', tx:30.4, ty:9.5 },
],[
  [15,0,'='], [16,0,'='], [0,9,'='], [0,10,'='], [31,9,'='], [31,10,'='],
]);

MAPS.forest_depths = makeMap('forest_depths','Thornwood Depths','#',[
  "..............==..............",
  "....#.........==.....#........",
  "..............==..............",
  "..#...........==..............",
  "..............==....#.........",
  "....#.........==..............",
  "..............==...........#..",
  "..............==..............",
  "..............==.....#........",
  "..#...........==..............",
  "..............==..............",
  "....#.........==..............",
  "..............==..............",
  "..............==....#.........",
  "..............==..............",
  "..............==..............",
],[
  { t:'thorn', x:10, y:7 }, { t:'thorn', x:21, y:7 }, { t:'thorn', x:15, y:12 },
  { t:'slime', x:5, y:12 }, { t:'slime', x:26, y:12 },
  { t:'pressure', x:8, y:11, id:'f1', gate:'forest_depths', latch:true },
  { t:'pressure', x:24, y:11, id:'f2', gate:'forest_depths', latch:true },
  { t:'pot', x:6, y:4 }, { t:'pot', x:26, y:4 },
  { t:'chest', x:16, y:3, flag:'c_forest_2', grants:[{type:'item', id:'potion', n:1},{type:'rupees', amt:15}] },
  { t:'sign', x:16, y:14, lines:["Two stone plates hold the northern gate. Stand on both to open the way to the Golem's glade."] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'forest_edge', tx:16, ty:1.4 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'golem_glade', tx:16, ty:16.4 },
],[
  [15,17,'='], [16,17,'='], [15,0,'G'], [16,0,'G'],
]);

MAPS.golem_glade = makeMap('golem_glade','Golem Glade','#',[
  "..............==..............",
  "..............==..............",
  ".....#........==..............",
  "..............==....#.........",
  "..............==..............",
  "..............==..............",
  "....#.........==..............",
  "..............==..............",
  "..............==..............",
  "..............==.........#....",
  "..............==..............",
  "....#.........==..............",
  "..............==..............",
  "..............==....#.........",
  "..............==..............",
  "..............==..............",
],[
  { t:'golem', x:16, y:9 },
  { t:'chest', x:16, y:4, flag:'c_golem', cond:'boss_golem', grants:[{type:'weapon', id:'ember'},{type:'key', id:'forestKey'}] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'forest_depths', tx:16, ty:1.4 },
],[
  [15,17,'='], [16,17,'='],
]);

MAPS.grove = makeMap('grove','Whisperwind Grove','#',[
  "................==............",
  "....~~~~~~~.....==....~~~~....",
  "....~..,..~.....==....~,,~....",
  "....~.,,..~.....==....~~~~....",
  "....~~~~~~~.....==...........",
  "................==............",
  "....,,..........==............",
  "................==............",
  "................==............",
  "................==............",
  "................==............",
  "................==............",
  "....,,..........==............",
  "................==............",
  "................==............",
  "................==............",
],[
  { t:'chest', x:16, y:5, flag:'c_bow', grants:[{type:'weapon', id:'bow'}] },
  { t:'chest', x:9, y:7, flag:'c_heart1', grants:[{type:'heart'}] },
  { t:'shrine', x:16, y:14 },
  { t:'sign', x:16, y:9, lines:["The ancients left a bow that never runs dry. Take it, and the heart of the grove is yours."] },
  { t:'warp', x:31.2, y:8.5, w:1.4, h:3, to:'forest_edge', tx:1.5, ty:9.5 },
],[
  [31,9,'='], [31,10,'='],
]);

MAPS.cave_mouth = makeMap('cave_mouth','Gloomspire Cave Mouth','w',[
  "ffffffffffffff==ffffffffffffff",
  "fffffrffffffffffffffffffffrfff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffrffffffffffffffffffff",
  "fffffffffffffffffffrfffffffff",
  "ffffrfffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffffffffffffrfffff",
  "fffffffffffffrffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrfffffffffffffffffffffffr",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffffrfffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffrffffffffffffffffffff",
  "ffffffffffffff==ffffffffffffff",
],[
  { t:'bat', x:10, y:8 }, { t:'bat', x:22, y:8 }, { t:'bat', x:16, y:12 },
  { t:'pot', x:6, y:5 }, { t:'pot', x:26, y:5 },
  { t:'sign', x:16, y:4, lines:["The cave echoes with wings. Fire and bombs will crack its old secrets."] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'meadow', tx:16, ty:1.4 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'cave_depths', tx:16, ty:16.4 },
],[
  [15,0,'f'], [16,0,'f'], [15,17,'f'], [16,17,'f'],
]);

MAPS.cave_depths = makeMap('cave_depths','Gloomspire Depths','w',[
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffVVfffffffffffffffccfffff",
  "ffffffffffffffffffffffffffffff",
  "fffrffffffffffffffffffffffffff",
  "fffffffffffffffffffffffffffrff",
  "ffffffffffffffffffffffffffffff",
  "ffffrfffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffffffffffffffGffff",
  "fffffffffffffffffffffffffGffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffff==ffffffffffffff",
],[
  { t:'bat', x:8, y:6 }, { t:'bat', x:20, y:8 }, { t:'slime', x:18, y:10 },
  { t:'weight', x:8, y:12, id:'cw1', gate:'cave_depths' },
  { t:'block', x:11, y:12, id:'block1' },
  { t:'chest', x:3, y:3, flag:'c_fang', grants:[{type:'item', id:'fang'}] },
  { t:'chest', x:28, y:3, flag:'c_bombroom', grants:[{type:'item', id:'bomb', n:2},{type:'rupees', amt:25}] },
  { t:'chest', x:29, y:12, flag:'c_swift', grants:[{type:'item', id:'swift'}] },
  { t:'pot', x:5, y:9 }, { t:'pot', x:27, y:9 },
  { t:'sign', x:16, y:3, lines:["Fire burns the vines. Bombs break cracked stone. Push the block onto the plate."] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'cave_mouth', tx:16, ty:1.4 },
],[
  [15,17,'f'], [16,17,'f'],
]);

MAPS.ruins_entrance = makeMap('ruins_entrance','Sunken Ruins','w',[
  "ffffffffffffff==ffffffffffffff",
  "fffrfffffffffffffffffffffrffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffffrfffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrfffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffffrffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffrff",
  "ffffrfffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "fffffffffffffrffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffrfffffffffffffffffffff",
  "ffffffffffffff==ffffffffffffff",
],[
  { t:'slime', x:10, y:8 }, { t:'slime', x:22, y:12 },
  { t:'thorn', x:16, y:6 }, { t:'thorn', x:26, y:8 },
  { t:'pot', x:6, y:4 }, { t:'pot', x:26, y:13 },
  { t:'chest', x:16, y:4, flag:'c_ruins_1', grants:[{type:'rupees', amt:25}] },
  { t:'sign', x:4, y:9, lines:["The Warden waits in the hall to the north. Its armor fears a blade of moonlight."] },
  { t:'warp', x:-1.2, y:8.5, w:1.4, h:3, to:'meadow', tx:30.4, ty:9.5 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'warden_hall', tx:16, ty:16.4 },
],[
  [15,0,'f'], [16,0,'f'], [0,9,'f'], [0,10,'f'],
]);

MAPS.warden_hall = makeMap('warden_hall','Warden Hall','w',[
  "ffffffffffffffGGffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrffffffffffffffffffffrffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffrfffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrffffffffffffffffffffrffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffrfffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffff==ffffffffffffff",
],[
  { t:'warden', x:16, y:9 },
  { t:'chest', x:16, y:4, flag:'c_warden', cond:'boss_warden', grants:[{type:'weapon', id:'moon'},{type:'heart'},{type:'key', id:'sanctumKey'}] },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'ruins_entrance', tx:16, ty:1.4 },
  { t:'warp', x:14.5, y:-1.2, w:3, h:1.4, to:'sanctum', tx:16, ty:16.4, needGate:'sanctum' },
],[
  [15,17,'f'], [16,17,'f'],
]);

MAPS.sanctum = makeMap('sanctum','Sanctum of Embers','w',[
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrffffffffffffffffffffrffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffrfffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffrffffffffffffffffffffrffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffrfffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffffffffffffffffffff",
  "ffffffffffffff==ffffffffffffff",
],[
  { t:'corruption', x:16, y:9 },
  { t:'warp', x:14.5, y:17.4, w:3, h:1.4, to:'warden_hall', tx:16, ty:1.4 },
],[
  [15,17,'f'], [16,17,'f'],
]);

/* ---------- items & shop ---------- */
const ITEMS = {
  potion: { name:'Red Potion', icon:'🧪', type:'consumable', desc:'Restores 6 HP (three full hearts). Hotkey: H', price:30 },
  bomb:   { name:'Bomb', icon:'💣', type:'consumable', desc:'A fused bomb. Explodes, breaks cracked walls. Hotkey: B', price:20 },
  swift:  { name:'Swift Charm', icon:'👟', type:'accessory', desc:'+18% movement speed while equipped.', price:80 },
  iron:   { name:'Iron Guard', icon:'🛡️', type:'accessory', desc:'Take 30% less damage while equipped.', price:100 },
  power:  { name:'Power Amulet', icon:'💪', type:'accessory', desc:'+25% attack damage while equipped.', price:120 },
  fang:   { name:'Vampiric Fang', icon:'🦷', type:'accessory', desc:'Restore 1 HP whenever you defeat an enemy.', price:140 },
  forestKey: { name:'Forest Key', icon:'🗝️', type:'key', desc:'Opens the way to the hidden grove.' },
  sanctumKey:{ name:'Sanctum Key', icon:'🗝️', type:'key', desc:'The final gate trembles before it.' },
};

const SHOP_STOCK = ['potion','bomb','swift','iron','power','fang'];

/* ============================================================
   Chunk 3: rendering, particles, pickups, world entities
   ============================================================ */

/* ---------- tile access ---------- */
function tileChar(x, y){
  if(x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return S.map ? S.map.border : 'w';
  return S.tiles[y][x];
}
function solidTile(x, y){ return solidChar(tileChar(x, y)); }
const tpx = t => t * TILE + TILE / 2;

function rebuildTiles(){
  const m = S.map;
  const tiles = m.grid.map(row => row.slice());
  const gateOpen = S.flags['gate_' + m.id] === true;
  for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
    if(tiles[y][x] === 'G' && gateOpen) tiles[y][x] = m.floorChar;
  }
  const over = S.tilesOver[m.id];
  if(over) for(const k in over){ const [x,y] = k.split(',').map(Number); if(x>=0&&y>=0&&x<MAP_W&&y<MAP_H) tiles[y][x] = over[k]; }
  S.tiles = tiles;
}
function setTileOverride(mapId, x, y, ch){
  if(!S.tilesOver[mapId]) S.tilesOver[mapId] = {};
  S.tilesOver[mapId][x + ',' + y] = ch;
  if(S.map && S.map.id === mapId) rebuildTiles();
}

/* ---------- particles ---------- */
function particle(x, y, o){
  S.particles.push(Object.assign({ x, y, vx:0, vy:0, g:0, life:0.6, t:0, size:2, color:'#fff', type:'dot', drag:0 }, o));
}
function burst(x, y, color, n, spd, o){
  for(let i=0;i<n;i++){
    const a = rnd(0, Math.PI*2), s = rnd(spd*0.35, spd);
    particle(x, y, Object.assign({ vx:Math.cos(a)*s, vy:Math.sin(a)*s, life:rnd(0.25,0.7), size:rnd(1.5,3.2), color }, o));
  }
}
function sparks(x, y, color, n){ burst(x, y, color, n, 90, {type:'spark', drag:6}); }
function smoke(x, y, n){ burst(x, y, 'rgba(150,140,160,0.5)', n, 26, {type:'smoke', size:rnd(3,5), life:rnd(0.5,0.9), vy:-8}); }
function updateParticles(dt){
  const arr = S.particles;
  for(let i=arr.length-1;i>=0;i--){
    const p = arr[i];
    p.t += dt; p.x += p.vx*dt; p.y += p.vy*dt;
    p.vy += (p.g||0)*dt;
    if(p.drag) { p.vx *= Math.max(0, 1-p.drag*dt); p.vy *= Math.max(0, 1-p.drag*dt); }
    if(p.t >= p.life) arr.splice(i,1);
  }
}
function drawParticles(){
  for(const p of S.particles){
    const k = 1 - p.t / p.life;
    ctx.globalAlpha = clamp(k, 0, 1);
    if(p.type === 'spark'){
      ctx.strokeStyle = p.color; ctx.lineWidth = p.size*0.7; ctx.beginPath();
      ctx.moveTo(p.x - p.vx*0.03, p.y - p.vy*0.03); ctx.lineTo(p.x, p.y); ctx.stroke();
    } else if(p.type === 'smoke'){
      ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size*(1.6-k*0.6), 0, 7); ctx.fill();
    } else if(p.type === 'flame'){
      ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size*k+0.5, 0, 7); ctx.fill();
    } else {
      ctx.fillStyle = p.color; ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size);
    }
  }
  ctx.globalAlpha = 1;
}

/* ---------- pickups (world rupees / hearts) ---------- */
function spawnPickup(x, y, kind, val){
  S.pickups.push({ x, y, kind, val: val||1, t:0, dead:false });
}
function updatePickups(dt){
  const pl = S.player;
  for(let i=S.pickups.length-1;i>=0;i--){
    const p = S.pickups[i];
    p.t += dt;
    if(pl){
      const d = Math.hypot(pl.x-p.x, pl.y-p.y);
      if(d < 26){
        const sp = 130;
        p.x += (pl.x-p.x)/Math.max(1,d)*sp*dt;
        p.y += (pl.y-p.y)/Math.max(1,d)*sp*dt;
      }
      if(d < 9){
        collectPickup(p); p.dead = true; S.pickups.splice(i,1);
      }
    }
  }
}
function collectPickup(p){
  if(p.kind === 'rupee'){
    S.rupees += p.val;
    sfx('coin');
    sparks(S.player.x, S.player.y, '#f7d98b', 5);
    toast('+' + p.val + ' ✦', '#f7d98b');
  } else if(p.kind === 'heart'){
    const pl = S.player;
    if(pl.hp >= pl.maxHp){ sparks(pl.x, pl.y, '#ff6b6b', 4); return; }
    pl.hp = Math.min(pl.maxHp, pl.hp + 2);
    sfx('heart');
    burst(pl.x, pl.y, '#ff6b6b', 10, 60, {type:'flame'});
    toast('+2 HP', '#ff6b6b');
  }
}
function drawPickups(){
  for(const p of S.pickups){
    const bob = Math.sin(p.t*6)*1.5;
    if(p.kind === 'rupee'){
      const c = p.val >= 10 ? '#4fd2e0' : p.val >= 5 ? '#f7d98b' : '#e8b04b';
      ctx.save(); ctx.translate(p.x, p.y+bob);
      ctx.fillStyle = c; ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(0,-5); ctx.lineTo(3,-2); ctx.lineTo(2,3); ctx.lineTo(-2,3); ctx.lineTo(-3,-2); ctx.closePath(); ctx.fill(); ctx.stroke();
      ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillRect(-1,-4,1,2);
      ctx.restore();
    } else if(p.kind === 'heart'){
      drawHeartShape(p.x, p.y+bob, 6, '#ff5a52', '#8e2a28');
    }
  }
}

/* ---------- heart shape ---------- */
function drawHeartShape(x, y, s, fill, outline){
  ctx.save(); ctx.translate(x, y); ctx.scale(s/6, s/6);
  ctx.beginPath();
  ctx.moveTo(0, 2);
  ctx.bezierCurveTo(-6, -3, -2, -6, 0, -2.5);
  ctx.bezierCurveTo(2, -6, 6, -3, 0, 2);
  ctx.closePath();
  ctx.fillStyle = fill; ctx.fill();
  ctx.strokeStyle = outline; ctx.lineWidth = 0.8; ctx.stroke();
  ctx.restore();
}

/* ---------- world tiles rendering ---------- */
function pathBase(px, py){
  ctx.fillStyle = '#c9a86a'; ctx.fillRect(px, py, TILE, TILE);
  ctx.fillStyle = '#b3925b';
  const v = hash2(px, py);
  if(v > 0.4) ctx.fillRect(px + 3 + v*6, py + 4 + v*4, 2, 2);
  if(v < 0.6) ctx.fillRect(px + 8 - v*6, py + 9, 2, 2);
}
function floorBase(px, py){
  ctx.fillStyle = '#2f3442'; ctx.fillRect(px, py, TILE, TILE);
  ctx.strokeStyle = 'rgba(20,22,30,0.55)'; ctx.lineWidth = 1;
  ctx.strokeRect(px+0.5, py+0.5, TILE-1, TILE-1);
  const v = hash2(px, py);
  if(v > 0.7){ ctx.fillStyle = '#262b37'; ctx.fillRect(px+4, py+5, 3, 3); }
}
function wallBase(px, py){
  ctx.fillStyle = '#3c4252'; ctx.fillRect(px, py, TILE, TILE);
  ctx.strokeStyle = 'rgba(20,22,30,0.7)'; ctx.lineWidth = 1;
  ctx.strokeRect(px+0.5, py+0.5, TILE-1, TILE-1);
  ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fillRect(px+1, py+1, TILE-2, 2);
}
function tileBase(c, px, py, tx, ty){
  const v = hash2(tx, ty);
  if(c === '='){ pathBase(px, py); return; }
  if(c === 'w'){ wallBase(px, py); return; }
  if(c === 'f' || (c === 'G' && S.map.border === 'w')){ floorBase(px, py); return; }
  if(c === 'G'){ pathBase(px, py); return; }
  if(c === 'c'){ wallBase(px, py); return; }
  if(c === 'V'){ (S.map.floorChar === 'f' ? floorBase : grassBase)(px, py, tx, ty); return; }
  if(c === 'r'){ floorBase(px, py); return; }
  if(c === '~'){ waterBase(px, py, tx, ty); return; }
  // grass-like defaults: . , # b
  grassBase(px, py, tx, ty);
  if(c === '#'){ // trunk
    ctx.fillStyle = '#5d4128'; ctx.beginPath(); ctx.arc(px+8, py+9, 4.2, 0, 7); ctx.fill();
    ctx.fillStyle = '#6f4f30'; ctx.fillRect(px+5, py+7, 3, 4);
  }
}
function grassBase(px, py, tx, ty){
  const v = hash2(tx, ty);
  ctx.fillStyle = v < 0.5 ? '#3f7d3a' : '#43813e';
  ctx.fillRect(px, py, TILE, TILE);
  if(v > 0.62){
    ctx.fillStyle = '#35692f';
    ctx.fillRect(px + 3 + v*5, py + 4 + (1-v)*6, 2, 3);
    ctx.fillRect(px + 9 - v*4, py + 9, 2, 3);
  }
}
function waterBase(px, py, tx, ty){
  const w = Math.sin(S.time*1.4 + tx*0.8 + ty*0.5) * 0.5;
  ctx.fillStyle = '#2f6f9e'; ctx.fillRect(px, py, TILE, TILE);
  ctx.fillStyle = 'rgba(255,255,255,0.08)';
  ctx.fillRect(px, py + 3 + w*2, TILE, 2);
}
function tileOver(c, px, py, tx, ty){
  const v = hash2(tx, ty);
  if(c === ','){ // flowers
    if(v > 0.3){ ctx.fillStyle = v > 0.65 ? '#f2d8e6' : '#f7d98b'; ctx.fillRect(px+3+v*8, py+4, 2, 2); }
  } else if(c === '#'){ // canopy
    ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.arc(px+8, py+11, 8, 0, 7); ctx.fill();
    ctx.fillStyle = v < 0.5 ? '#2c5c2b' : '#356c33'; ctx.beginPath(); ctx.arc(px+8, py+8, 8, 0, 7); ctx.fill();
    ctx.fillStyle = v < 0.5 ? '#3a7237' : '#42833f'; ctx.beginPath(); ctx.arc(px+6, py+6, 6.5, 0, 7); ctx.fill();
  } else if(c === '~'){
    const w = Math.sin(S.time*1.4 + tx*0.8 + ty*0.5);
    ctx.fillStyle = 'rgba(120,190,230,0.25)';
    ctx.fillRect(px + 2, py + 6 + w*2, 12, 1.4);
  } else if(c === 'r'){
    ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.arc(px+9, py+10, 6.5, 0, 7); ctx.fill();
    ctx.fillStyle = '#7d848f'; ctx.beginPath(); ctx.arc(px+8, py+8, 6.5, 0, 7); ctx.fill();
    ctx.fillStyle = '#98a0ab'; ctx.beginPath(); ctx.arc(px+6.5, py+6.5, 3.6, 0, 7); ctx.fill();
    ctx.fillStyle = '#59606b'; ctx.fillRect(px+9, py+6, 3, 3);
  } else if(c === 'b'){
    ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.arc(px+8, py+10, 6.5, 0, 7); ctx.fill();
    ctx.fillStyle = '#2e6e30'; ctx.beginPath(); ctx.arc(px+8, py+9, 6.2, 0, 7); ctx.fill();
    ctx.fillStyle = '#3a8039'; ctx.beginPath(); ctx.arc(px+6, py+7, 4.4, 0, 7); ctx.fill();
  } else if(c === 'G'){
    ctx.fillStyle = '#1b1f2a'; ctx.fillRect(px+1, py+3, TILE-2, 3);
    ctx.fillRect(px+1, py+10, TILE-2, 3);
    ctx.fillStyle = '#3c4458';
    for(let i=0;i<4;i++){ ctx.fillRect(px+2+i*4, py+3, 2, 10); }
    ctx.fillStyle = 'rgba(255,255,255,0.12)'; ctx.fillRect(px+1, py+3, TILE-2, 1);
  } else if(c === 'V'){
    ctx.fillStyle = '#2c5c2b'; ctx.fillRect(px, py, TILE, TILE);
    ctx.strokeStyle = '#4f9c46'; ctx.lineWidth = 1.6;
    ctx.beginPath();
    ctx.moveTo(px+2, py+TILE); ctx.quadraticCurveTo(px+5, py+10, px+3, py+2);
    ctx.moveTo(px+7, py+TILE); ctx.quadraticCurveTo(px+10, py+9, px+8, py+1);
    ctx.moveTo(px+12, py+TILE); ctx.quadraticCurveTo(px+14, py+8, px+13, py+3);
    ctx.stroke();
    ctx.fillStyle = '#6fbf5c'; ctx.fillRect(px+3, py+7, 2, 2); ctx.fillRect(px+9, py+11, 2, 2);
  } else if(c === 'c'){
    ctx.strokeStyle = 'rgba(20,20,26,0.9)'; ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.moveTo(px+3, py+14); ctx.lineTo(px+7, py+9); ctx.lineTo(px+5, py+4);
    ctx.moveTo(px+9, py+13); ctx.lineTo(px+12, py+8); ctx.lineTo(px+11, py+3);
    ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.08)'; ctx.fillRect(px+4, py+12, 3, 2);
  }
}
function renderWorld(){
  for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
    tileBase(S.tiles[y][x], x*TILE, y*TILE, x, y);
  }
  for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
    tileOver(S.tiles[y][x], x*TILE, y*TILE, x, y);
  }
}

/* ---------- generic entity helpers ---------- */
function entRect(e){
  const hw = e.hw || 7, hh = e.hh || 7;
  return { x0:e.x-hw, y0:e.y-hh, x1:e.x+hw, y1:e.y+hh };
}
function pointInEnt(e, x, y){
  const r = entRect(e);
  return x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1;
}
function shadow(x, y, r, a){
  ctx.fillStyle = 'rgba(0,0,0,' + (a||0.28) + ')';
  ctx.beginPath(); ctx.ellipse(x, y, r, r*0.42, 0, 0, 7); ctx.fill();
}
function glow(x, y, r, color, a){
  ctx.save();
  const g = ctx.createRadialGradient(x,y,0, x,y,r);
  g.addColorStop(0, color); g.addColorStop(1, 'rgba(0,0,0,0)');
  ctx.globalAlpha = a;
  ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x,y,r,0,7); ctx.fill();
  ctx.restore();
}

/* ---------- entity factory & updates ---------- */
function entFromDef(def){
  if(def.t === 'spawn') return null;
  if(def.t === 'chest'){
    if(S.flags[def.flag]) return null;
    if(def.cond && !S.flags[def.cond]) return null;
    return { type:'chest', x:tpx(def.x), y:tpx(def.y), hw:8, hh:6, def, open:0, solid:false, dead:false };
  }
  if(def.t === 'pot') return { type:'pot', x:tpx(def.x), y:tpx(def.y), hw:6, hh:6, def, solid:true, dead:false, hp:1 };
  if(def.t === 'block') return { type:'block', x:tpx(def.x), y:tpx(def.y), hw:8, hh:8, def, solid:true, dead:false, pushCd:0 };
  if(def.t === 'pressure') return { type:'pressure', x:tpx(def.x), y:tpx(def.y), hw:7, hh:7, def, on:false, solid:false, dead:false };
  if(def.t === 'weight') return { type:'weight', x:tpx(def.x), y:tpx(def.y), hw:7, hh:7, def, on:false, solid:false, dead:false };
  if(def.t === 'eye') return { type:'eye', x:tpx(def.x), y:tpx(def.y), hw:7, hh:7, def, on:S.flags['gate_'+def.gate]===true, solid:false, dead:false, flash:0 };
  if(def.t === 'warp') return { type:'warp', rx:def.x*TILE, ry:def.y*TILE, rw:def.w*TILE, rh:def.h*TILE, def, dead:false };
  if(def.t === 'npc' || def.t === 'shop') return { type:def.t, x:tpx(def.x), y:tpx(def.y), hw:6, hh:5, def, solid:false, dead:false };
  if(def.t === 'sign') return { type:'sign', x:tpx(def.x), y:tpx(def.y), hw:6, hh:6, def, solid:false, dead:false };
  if(def.t === 'shrine') return { type:'shrine', x:tpx(def.x), y:tpx(def.y), hw:8, hh:8, def, solid:false, dead:false };
  return makeEnemy(def);   // defined in the combat chunk
}

function updateEnts(dt){
  const pl = S.player;
  for(const e of S.ents){
    e.def && (e.def.t);
    if(e.type === 'chest'){
      if(e.open > 0) e.open = Math.min(1, e.open + dt*2.2);
    } else if(e.type === 'pressure' && pl){
      const hit = pointInEnt(e, pl.x, pl.y);
      if(hit && !e.on){
        e.on = true;
        sfx('switch'); sfx('gate');
        setGate(e.def.gate, true);
        burst(e.x, e.y, '#e8b04b', 8, 60);
      }
    } else if(e.type === 'weight'){
      let hit = false;
      if(pl) hit = pointInEnt(e, pl.x, pl.y);
      if(!hit) for(const o of S.ents) if(o.type==='block' && pointInEnt(e, o.x, o.y)){ hit = true; break; }
      if(hit !== e.on){
        e.on = hit;
        sfx('switch');
        setGate(e.def.gate, hit);
        burst(e.x, e.y, hit ? '#e8b04b' : '#7a8', 6, 50);
      }
    } else if(e.type === 'eye'){
      if(e.flash > 0) e.flash -= dt;
      e.on = S.flags['gate_' + e.def.gate] === true;
    } else if(e.type === 'warp'){
      let rx = e.rx, ry = e.ry, rw = e.rw, rh = e.rh;
      if(e.def.y < 0) rh += 20;
      else if(e.def.y > MAP_H-1){ ry -= 20; rh += 20; }
      if(e.def.x < 0) rw += 20;
      else if(e.def.x > MAP_W-1){ rx -= 20; rw += 20; }
      if(pl && !S.transition && pl.x >= rx && pl.x <= rx+rw && pl.y >= ry && pl.y <= ry+rh){
        if(e.def.needGate && !S.flags['gate_' + e.def.needGate]) continue;
        transitionTo(e.def.to, e.def.tx, e.def.ty);
      }
    }
  }
}
function setGate(id, open){
  if(S.flags['gate_' + id] === open) return;
  S.flags['gate_' + id] = open;
  if(S.map && S.map.id === id) rebuildTiles();
}

/* ============================================================
   Chunk 4a: entity visuals, player, movement & melee/ranged
   ============================================================ */

const WEAPONS = {
  wood:  { name:'Wooden Blade',   dmg:2, range:15, arc:1.9, cd:0.32, color:'#c9a26b' },
  ember: { name:'Ember Blade',    dmg:3, range:17, arc:2.0, cd:0.23, color:'#ff8a3d', fire:true },
  moon:  { name:'Moon Edge',      dmg:4, range:21, arc:2.5, cd:0.30, color:'#9fd8ff', wide:true },
  bow:   { name:'Windcaller Bow', dmg:2, range:190, cd:0.42, color:'#c9a26b' },
};

function drawCharacter(x, y, dir, o){
  const moving = o.moving, walkT = o.walkT || 0, lean = o.lean || 0;
  const bob = moving ? Math.sin(walkT*12)*1.4 : Math.sin(S.time*2.5)*0.6;
  shadow(x, y+6, 5.4);
  ctx.save();
  ctx.translate(x, y + bob*0.5);
  if(lean) ctx.rotate(lean*0.25);
  if(o.hunch) ctx.scale(1, 0.92);
  // legs
  const step = moving ? Math.sin(walkT*12)*2.6 : 0;
  ctx.fillStyle = o.legs || '#3a2f28';
  ctx.fillRect(-3 + step, 2.5, 2.4, 3.4);
  ctx.fillRect(1 - step, 2.5, 2.4, 3.4);
  // body
  ctx.fillStyle = o.tunic || '#3f8f4f';
  roundRect(-4.6, -3.4, 9.2, 7, 2.6); ctx.fill();
  ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 0.8; roundRect(-4.6, -3.4, 9.2, 7, 2.6); ctx.stroke();
  if(o.belt){ ctx.fillStyle = o.belt; ctx.fillRect(-4.6, 0.6, 9.2, 1.6); }
  // arms
  ctx.fillStyle = o.tunic || '#3f8f4f';
  ctx.beginPath(); ctx.arc(-5.5, -1, 1.6, 0, 7); ctx.arc(5.5, -1, 1.6, 0, 7); ctx.fill();
  // head
  ctx.fillStyle = o.skin || '#e8b48a';
  ctx.beginPath(); ctx.arc(0, -6.4, 3.6, 0, 7); ctx.fill();
  ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = 0.8; ctx.stroke();
  // hair / hood
  if(o.hair){ ctx.fillStyle = o.hair; ctx.beginPath(); ctx.arc(0, -7.2, 3.6, Math.PI, Math.PI*2); ctx.fill(); }
  // face (eyes toward dir)
  const ex = Math.cos(dir)*1.2, ey = Math.sin(dir)*1.2;
  ctx.fillStyle = '#1c1c22';
  ctx.fillRect(-2.2 + ex, -6.9 + ey, 1.4, 1.6);
  ctx.fillRect(1.0 + ex, -6.9 + ey, 1.4, 1.6);
  ctx.restore();
}

function roundRect(x, y, w, h, r){
  ctx.beginPath();
  ctx.moveTo(x+r, y); ctx.arcTo(x+w, y, x+w, y+h, r); ctx.arcTo(x+w, y+h, x, y+h, r);
  ctx.arcTo(x, y+h, x, y, r); ctx.arcTo(x, y, x+w, y, r); ctx.closePath();
}

/* ---------- static / interactive entity drawing ---------- */
function drawEntity(e){
  switch(e.type){
    case 'pot': {
      shadow(e.x, e.y+5, 5);
      ctx.fillStyle = '#8a5a34'; roundRect(e.x-5, e.y-5, 10, 10, 4); ctx.fill();
      ctx.strokeStyle = '#5c3a20'; ctx.lineWidth = 1; roundRect(e.x-5, e.y-5, 10, 10, 4); ctx.stroke();
      ctx.fillStyle = '#a9744a'; ctx.fillRect(e.x-3.6, e.y-4, 7.2, 3);
      ctx.fillStyle = '#5c3a20'; roundRect(e.x-2.6, e.y-7, 5.2, 2.6, 1.4); ctx.fill();
      break;
    }
    case 'chest': {
      shadow(e.x, e.y+5, 7);
      const op = e.open || 0;
      ctx.fillStyle = '#6b4526'; roundRect(e.x-7, e.y-5, 14, 10, 2.4); ctx.fill();
      ctx.strokeStyle = '#402912'; ctx.lineWidth = 1.2; roundRect(e.x-7, e.y-5, 14, 10, 2.4); ctx.stroke();
      ctx.fillStyle = '#8a5a34'; ctx.fillRect(e.x-7, e.y-1, 14, 4);
      ctx.fillStyle = '#e8b04b'; roundRect(e.x-2, e.y-2.4, 4, 4, 1); ctx.fill();
      // lid
      ctx.save(); ctx.translate(e.x, e.y-4); ctx.rotate(-op*0.9);
      ctx.fillStyle = '#7c4e28'; roundRect(-8, -4.6, 16, 5, 2.4); ctx.fill();
      ctx.strokeStyle = '#402912'; ctx.lineWidth = 1.2; roundRect(-8, -4.6, 16, 5, 2.4); ctx.stroke();
      ctx.restore();
      if(op > 0 && op < 1){ glow(e.x, e.y, 22+op*20, 'rgba(232,176,75,0.55)', 0.5); }
      break;
    }
    case 'block': {
      shadow(e.x, e.y+6, 7);
      ctx.fillStyle = '#7d8490'; roundRect(e.x-7, e.y-7, 14, 14, 3); ctx.fill();
      ctx.strokeStyle = '#43484f'; ctx.lineWidth = 1.2; roundRect(e.x-7, e.y-7, 14, 14, 3); ctx.stroke();
      ctx.fillStyle = '#a1a8b4'; roundRect(e.x-5, e.y-5, 10, 5, 2); ctx.fill();
      ctx.fillStyle = '#5b616a'; roundRect(e.x-3, e.y-1.5, 6, 3, 1); ctx.fill();
      break;
    }
    case 'pressure': case 'weight': {
      const on = e.on;
      ctx.fillStyle = on ? '#6d5a2a' : '#4a4138';
      roundRect(e.x-7, e.y-6, 14, 12, 3); ctx.fill();
      ctx.strokeStyle = '#2b251e'; ctx.lineWidth = 1.2; roundRect(e.x-7, e.y-6, 14, 12, 3); ctx.stroke();
      ctx.fillStyle = on ? '#e8b04b' : '#8d8375';
      roundRect(e.x-4.5, e.y - (on?2.5:4.5), 9, 4.5, 1.6); ctx.fill();
      if(e.type === 'weight'){
        ctx.fillStyle = '#33261a'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center';
        ctx.fillText('▼', e.x, e.y - (on?1.4:3.2));
      }
      break;
    }
    case 'eye': {
      shadow(e.x, e.y+5, 6);
      ctx.fillStyle = '#4a4138'; roundRect(e.x-6, e.y-6, 12, 12, 3); ctx.fill();
      ctx.strokeStyle = '#2b251e'; ctx.lineWidth = 1.2; roundRect(e.x-6, e.y-6, 12, 12, 3); ctx.stroke();
      const open = !e.on;
      ctx.fillStyle = open ? '#e8e4d0' : '#3a3a40';
      ctx.beginPath(); ctx.arc(e.x, e.y-1, 4, 0, 7); ctx.fill();
      ctx.strokeStyle = '#2b251e'; ctx.lineWidth = 1; ctx.stroke();
      if(open){ ctx.fillStyle = '#c33b3b'; ctx.beginPath(); ctx.arc(e.x, e.y-1, 1.8, 0, 7); ctx.fill(); }
      else { ctx.fillStyle = '#6d6d78'; ctx.beginPath(); ctx.arc(e.x, e.y-1, 3, 0, 7); ctx.fill(); }
      if(e.flash > 0) glow(e.x, e.y-1, 16, 'rgba(232,176,75,0.8)', e.flash*2.4);
      break;
    }
    case 'npc': case 'shop': {
      drawCharacter(e.x, e.y, Math.sin(S.time)*0.2, {
        tunic: e.type==='shop' ? '#7a4a9e' : '#5a7db8', skin:'#e8b48a',
        hair: e.type==='shop' ? '#3a2a20' : '#cfd8d8', belt: e.type==='shop' ? '#e8b04b' : '#3f3f4a',
        moving:false, walkT:0, lean:0, hunch:false,
      });
      if(e.type==='shop'){ // coin symbol
        ctx.fillStyle = '#f7d98b'; ctx.beginPath(); ctx.arc(e.x+6, e.y-4, 2.6, 0, 7); ctx.fill();
        ctx.fillStyle = '#b3923a'; ctx.font = 'bold 7px monospace'; ctx.textAlign='center'; ctx.fillText('$', e.x+6, e.y-2.6);
      }
      break;
    }
    case 'sign': {
      shadow(e.x, e.y+5, 5);
      ctx.fillStyle = '#6b4526'; ctx.fillRect(e.x-1, e.y-6, 2, 8);
      ctx.fillStyle = '#a9744a'; roundRect(e.x-7, e.y-10, 14, 8, 2); ctx.fill();
      ctx.strokeStyle = '#5c3a20'; ctx.lineWidth = 1; roundRect(e.x-7, e.y-10, 14, 8, 2); ctx.stroke();
      ctx.fillStyle = '#3a2a1c'; ctx.fillRect(e.x-5, e.y-8.4, 10, 1); ctx.fillRect(e.x-5, e.y-6, 7, 1);
      break;
    }
    case 'shrine': {
      const pulse = 0.6 + Math.sin(S.time*2.2)*0.3;
      glow(e.x, e.y, 26+pulse*6, 'rgba(120,200,220,0.5)', 0.35+pulse*0.15);
      shadow(e.x, e.y+6, 7);
      ctx.fillStyle = '#8d94a0'; roundRect(e.x-4, e.y-6, 8, 13, 2.4); ctx.fill();
      ctx.strokeStyle = '#43484f'; ctx.lineWidth = 1; roundRect(e.x-4, e.y-6, 8, 13, 2.4); ctx.stroke();
      ctx.fillStyle = '#b7c0cd'; ctx.beginPath(); ctx.arc(e.x, e.y-7.4, 3.6, 0, 7); ctx.fill();
      ctx.strokeStyle = '#43484f'; ctx.stroke();
      ctx.fillStyle = '#a7e6f0'; ctx.beginPath(); ctx.arc(e.x, e.y-7.4, 1.7, 0, 7); ctx.fill();
      ctx.strokeStyle = 'rgba(160,220,235,0.8)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(e.x-7, e.y+7); ctx.lineTo(e.x-3, e.y+2); ctx.moveTo(e.x+7, e.y+7); ctx.lineTo(e.x+3, e.y+2); ctx.stroke();
      break;
    }
    case 'warp': break;
  }
}

/* ---------- player ---------- */
function createPlayer(x, y){
  return {
    x:tpx(x), y:tpx(y), hw:4, hh:4, dir:Math.PI/2, speed:70,
    hp:6, maxHp:6, stamina:100, maxStamina:100,
    invuln:0, attackT:0, attackCd:0, swingDir:0, swingHit:new Set(),
    dashT:0, dashCd:0, dashDir:0, walkT:0, stepT:0, moving:false, sprinting:false,
    weapon:'wood', hasBow:false, bowEquipped:false,
    bombCd:0, dead:false,
  };
}

function weaponStats(){ return WEAPONS[S.player.weapon]; }

function playerAim(){
  const pl = S.player;
  if(mouse.active) return Math.atan2(mouse.y - pl.y, mouse.x - pl.x);
  return pl.dir;
}

function updatePlayer(dt){
  const pl = S.player;
  if(pl.dead) return;
  pl.speed = 70 * (S.equipped === 'swift' ? 1.18 : 1);
  if(S.dialog || S.modal) return;
  pl.invuln = Math.max(0, pl.invuln - dt);
  pl.attackCd = Math.max(0, pl.attackCd - dt);
  pl.dashCd = Math.max(0, pl.dashCd - dt);
  pl.bombCd = Math.max(0, pl.bombCd - dt);

  // ---- dash ----
  if(pl.dashT > 0){
    pl.dashT -= dt;
    pl.x += Math.cos(pl.dashDir) * pl.speed * 2.15 * dt;
    pl.y += Math.sin(pl.dashDir) * pl.speed * 2.15 * dt;
    if(Math.random() < 0.5) particle(pl.x, pl.y, {type:'smoke', size:rnd(2,4), life:0.4, color:'rgba(200,210,230,0.35)', vx:rnd(-10,10), vy:rnd(-14,-4)});
    clampPlayer();
    return;
  }

  // ---- input ----
  let dx = 0, dy = 0;
  if(keys.KeyA || keys.ArrowLeft) dx -= 1;
  if(keys.KeyD || keys.ArrowRight) dx += 1;
  if(keys.KeyW || keys.ArrowUp) dy -= 1;
  if(keys.KeyS || keys.ArrowDown) dy += 1;
  if(dx && dy){ dx *= 0.7071; dy *= 0.7071; }
  pl.moving = !!(dx || dy);
  if(pl.moving) pl.dir = Math.atan2(dy, dx);

  // ---- sprint & stamina ----
  const wantSprint = (keys.ShiftLeft || keys.ShiftRight) && pl.moving && pl.stamina > 1;
  pl.sprinting = wantSprint && pl.stamina > 12;
  let mult = 1;
  if(pl.sprinting){ mult = 1.45; pl.stamina = Math.max(0, pl.stamina - 26*dt); }
  else pl.stamina = Math.min(pl.maxStamina, pl.stamina + 22*dt);

  const lowStam = pl.stamina < 18;
  const vx = dx * pl.speed * mult * dt, vy = dy * pl.speed * mult * dt;
  moveWithCollisions(pl, vx, vy, true);

  // walk animation & step sounds
  if(pl.moving){
    pl.walkT += dt * (pl.sprinting ? 1.6 : 1);
    pl.stepT -= dt;
    if(pl.stepT <= 0){ pl.stepT = pl.sprinting ? 0.16 : 0.26; sfx('step'); }
    if(pl.sprinting && Math.random() < 0.25) particle(pl.x - Math.cos(pl.dir)*3, pl.y+5, {type:'smoke', size:rnd(1.5,3), life:0.4, color:'rgba(200,190,170,0.3)', vx:rnd(-6,6), vy:rnd(-10,-2)});
  }

  // ---- attack ----
  const wantAttack = mouse.down || pressed.has('Space');
  if(wantAttack && pl.attackCd <= 0 && pl.attackT <= 0){
    if(pl.bowEquipped && pl.hasBow) shootBow();
    else swingSword();
  }

  // ---- swing active ----
  if(pl.attackT > 0){
    updateSwing();
    pl.attackT -= dt;
    if(pl.attackT <= 0) pl.swingHit.clear();
  }

  clampPlayer();
}

function clampPlayer(){
  const pl = S.player;
  pl.x = clamp(pl.x, 8, VIEW_W-8);
  pl.y = clamp(pl.y, 8, VIEW_H-8);
}

/* ---------- collision ---------- */
function solidEntNear(x, y, r, ignore){
  for(const e of S.ents){
    if(!e.solid || e.dead || e === ignore) continue;
    if(pointInEnt(e, x-r, y-r) || pointInEnt(e, x+r, y-r) || pointInEnt(e, x-r, y+r) || pointInEnt(e, x+r, y+r)) return e;
  }
  return null;
}
function boxFree(x, y, r, ignore){
  if(solidTile(x-r, y-r) || solidTile(x+r, y-r) || solidTile(x-r, y+r) || solidTile(x+r, y+r)) return false;
  return !solidEntNear(x, y, r, ignore);
}
function tryPushBlock(b, dx, dy){
  if(b.pushCd > 0) return false;
  const nx = b.x + dx, ny = b.y + dy;
  if(nx < 10 || ny < 10 || nx > VIEW_W-10 || ny > VIEW_H-10) return false;
  if(solidTile(nx-8, ny-8) || solidTile(nx+8, ny-8) || solidTile(nx-8, ny+8) || solidTile(nx+8, ny+8)) return false;
  if(solidEntNear(nx, ny, 8, b)) return false;
  b.x = nx; b.y = ny; b.pushCd = 0.08;
  return true;
}
function moveWithCollisions(ent, dx, dy, pushBlocks){
  const r = ent.hw || 4;
  let nx = ent.x + dx;
  if(boxFree(nx, ent.y, r, ent)){
    ent.x = nx;
  } else if(pushBlocks){
    const b = solidEntNear(nx, ent.y, r, ent);
    if(b && b.type === 'block' && tryPushBlock(b, dx, 0)) ent.x = nx;
  }
  let ny = ent.y + dy;
  if(boxFree(ent.x, ny, r, ent)){
    ent.y = ny;
  } else if(pushBlocks){
    const b = solidEntNear(ent.x, ny, r, ent);
    if(b && b.type === 'block' && tryPushBlock(b, 0, dy)) ent.y = ny;
  }
}

/* ---------- melee ---------- */
function swingSword(){
  const pl = S.player;
  const w = weaponStats();
  pl.attackCd = w.cd;
  pl.attackT = 0.16;
  pl.swingDir = playerAim();
  pl.swingHit.clear();
  sfx('swing');
  // break pots / bushes / vines in front
  const fx = pl.x + Math.cos(pl.swingDir) * 12, fy = pl.y + Math.sin(pl.swingDir) * 12;
  const tc = tileChar(Math.floor(fx/TILE), Math.floor(fy/TILE));
  if(tc === 'b'){
    setTileOverride(S.mapId, Math.floor(fx/TILE), Math.floor(fy/TILE), S.map.floorChar);
    sfx('pot'); burst(fx, fy, '#5fbf6a', 8, 70);
    if(Math.random() < 0.3) spawnPickup(fx, fy, 'rupee', irnd(1,4));
  } else if(tc === 'V' && w.fire){
    burnVine(Math.floor(fx/TILE), Math.floor(fy/TILE));
  }
  for(const e of S.ents){
    if(e.type === 'pot' && !e.dead){
      const d = Math.hypot(e.x-pl.x, e.y-pl.y);
      const ang = Math.atan2(e.y-pl.y, e.x-pl.x);
      let diff = Math.abs(angLerp(pl.swingDir, ang, 0)); // angle offset magnitude
      if(d < w.range + 4 && diff < w.arc/2 + 0.35) breakPot(e);
    }
  }
}
function burnVine(tx, ty){
  setTileOverride(S.mapId, tx, ty, S.map.floorChar);
  sfx('burn'); burst(tpx(tx), tpx(ty), '#ff8a3d', 14, 80, {type:'flame'});
}
function breakPot(e){
  e.dead = true; e.solid = false;
  sfx('pot'); smoke(e.x, e.y, 5); sparks(e.x, e.y, '#a9744a', 6);
  const roll = Math.random();
  if(roll < 0.5) spawnPickup(e.x, e.y, 'rupee', irnd(1,5));
  else if(roll < 0.68) spawnPickup(e.x, e.y, 'heart', 1);
}

/* ---------- ranged ---------- */
function shootBow(){
  const pl = S.player;
  const w = WEAPONS.bow;
  pl.attackCd = w.cd;
  sfx('bow');
  // gentle aim assist
  let a = playerAim();
  let best = null, bestD = 999999;
  for(const e of S.ents){
    if(!e.hp || e.dead) continue;
    const d = Math.hypot(e.x-pl.x, e.y-pl.y);
    if(d > w.range) continue;
    const ang = Math.atan2(e.y-pl.y, e.x-pl.x);
    let diff = Math.abs(angLerp(a, ang, 0));
    if(diff < 0.42 && d < bestD){ bestD = d; best = e; }
  }
  if(best) a = Math.atan2(best.y-pl.y, best.x-pl.x);
  pl.dir = a;
  const dmg = Math.round(w.dmg * (S.equipped==='power' ? 1.25 : 1));
  fireProjectile({ x:pl.x + Math.cos(a)*8, y:pl.y + Math.sin(a)*8, vx:Math.cos(a)*250, vy:Math.sin(a)*250, dmg, from:'player', kind:'arrow', life:0.8 });
  sfx('arrow');
}

function fireProjectile(o){
  S.projectiles.push(Object.assign({ t:0, dead:false }, o));
}
function updateProjectiles(dt){
  const pl = S.player;
  for(let i=S.projectiles.length-1;i>=0;i--){
    const p = S.projectiles[i];
    p.t += dt;
    p.x += p.vx*dt; p.y += p.vy*dt;
    if(p.t >= p.life || p.x<0 || p.y<0 || p.x>VIEW_W || p.y>VIEW_H){ S.projectiles.splice(i,1); continue; }
    if(p.from === 'player'){
      let hitWall = false;
      if(solidTile(p.x, p.y)) hitWall = true;
      else {
        for(const e of S.ents){
          if(e.type === 'eye' && Math.hypot(e.x-p.x, e.y-p.y) < 9){
            setGate(e.def.gate, true); e.flash = 0.4;
            sfx('gate'); burst(e.x, e.y, '#e8b04b', 12, 80);
            S.projectiles.splice(i,1); hitWall = true; break;
          }
          if(e.enemy && !e.dead && Math.hypot(e.x-p.x, e.y-p.y) < (e.hw||7)+3){
            damageEnemy(e, p.dmg, p.vx, p.vy);
            sparks(p.x, p.y, '#ffd9a0', 4);
            S.projectiles.splice(i,1); hitWall = true; break;
          }
        }
      }
      if(hitWall){ S.projectiles.splice(i,1); continue; }
    } else {
      if(pl && !pl.dead && Math.hypot(pl.x-p.x, pl.y-p.y) < 5){
        hurtPlayer(p.dmg, p.x, p.y);
        S.projectiles.splice(i,1); continue;
      }
      if(solidTile(p.x, p.y)){ sparks(p.x, p.y, '#c9a0e8', 3); S.projectiles.splice(i,1); continue; }
    }
  }
}
function drawProjectiles(){
  for(const p of S.projectiles){
    const a = Math.atan2(p.vy, p.vx);
    if(p.kind === 'arrow'){
      ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(a);
      ctx.strokeStyle = '#d8c9a8'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(-5,0); ctx.lineTo(4,0); ctx.stroke();
      ctx.fillStyle = '#e8e0d0'; ctx.beginPath(); ctx.moveTo(5,0); ctx.lineTo(2,-1.6); ctx.lineTo(2,1.6); ctx.closePath(); ctx.fill();
      ctx.strokeStyle = '#8a7a5a'; ctx.beginPath(); ctx.moveTo(-5,-1.6); ctx.lineTo(-2.4,0); ctx.lineTo(-5,1.6); ctx.stroke();
      ctx.restore();
    } else if(p.kind === 'thorn'){
      ctx.fillStyle = '#7fbf5c'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.4, 0, 7); ctx.fill();
      ctx.strokeStyle = '#3a6b2a'; ctx.lineWidth = 1; ctx.stroke();
    } else {
      glow(p.x, p.y, 6, 'rgba(200,120,240,0.8)', 0.5);
      ctx.fillStyle = '#c26ae8'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill();
      ctx.fillStyle = '#f0d6ff'; ctx.beginPath(); ctx.arc(p.x-0.8, p.y-0.8, 1, 0, 7); ctx.fill();
    }
  }
}

/* ---------- bombs ---------- */
function placeBomb(){
  const pl = S.player;
  if(pl.bombCd > 0) return;
  const n = S.inv.bomb || 0;
  if(n <= 0){ toast('No bombs! Buy or find some.', '#8f7f9b'); return; }
  S.inv.bomb = n-1;
  pl.bombCd = 0.4;
  const bx = pl.x + Math.cos(pl.dir)*10, by = pl.y + Math.sin(pl.dir)*10;
  S.ents.push({ type:'bomb', x:bx, y:by, t:1.0, dead:false, hw:4, hh:4, solid:false });
  sfx('switch');
}
function updateBombs(dt){
  for(let i=S.ents.length-1;i>=0;i--){
    const e = S.ents[i];
    if(e.type !== 'bomb') continue;
    e.t -= dt;
    if(e.t <= 0){ explodeBomb(e); S.ents.splice(i,1); }
  }
}
function explodeBomb(e){
  sfx('bomb'); S.shake = Math.max(S.shake, 7);
  burst(e.x, e.y, '#ff9a3d', 22, 130, {type:'flame'});
  smoke(e.x, e.y, 8);
  glow(e.x, e.y, 30, 'rgba(255,160,60,0.9)', 0.7);
  for(const o of S.ents){
    if(o.dead) continue;
    const d = Math.hypot(o.x-e.x, o.y-e.y);
    if(d < 30){
      if(o.enemy) damageEnemy(o, 6, (o.x-e.x)*4, (o.y-e.y)*4);
      else if(o.type === 'pot') breakPot(o);
    }
  }
  if(S.player && Math.hypot(S.player.x-e.x, S.player.y-e.y) < 22) hurtPlayer(1, e.x, e.y);
  // break cracked walls in radius
  for(let ty=Math.max(0,Math.floor((e.y-30)/TILE)); ty<=Math.min(MAP_H-1,Math.floor((e.y+30)/TILE)); ty++){
    for(let tx=Math.max(0,Math.floor((e.x-30)/TILE)); tx<=Math.min(MAP_W-1,Math.floor((e.x+30)/TILE)); tx++){
      if(tileChar(tx,ty) === 'c' && Math.hypot(tx*TILE+8-e.x, ty*TILE+8-e.y) < 30){
        setTileOverride(S.mapId, tx, ty, S.map.floorChar);
        sfx('pot'); smoke(tx*TILE+8, ty*TILE+8, 6);
      }
    }
  }
}

/* ============================================================
   Chunk 4b: enemies, bosses, damage, loot, interactions
   ============================================================ */

function makeEnemy(def){
  if(def.t === 'slime') return { type:'slime', enemy:true, x:tpx(def.x), y:tpx(def.y), hw:6, hh:5, hp:4, maxHp:4, speed:28, dmg:1, dead:false, hitT:0, atkCd:0, dir:0, wanderT:0, hop:0, def };
  if(def.t === 'thorn') return { type:'thorn', enemy:true, x:tpx(def.x), y:tpx(def.y), hw:6, hh:5, hp:3, maxHp:3, speed:34, dmg:1, dead:false, hitT:0, atkCd:0, shootCd:1.2, dir:0, def };
  if(def.t === 'bat') return { type:'bat', enemy:true, x:tpx(def.x), y:tpx(def.y), hw:5, hh:4, hp:2, maxHp:2, speed:46, dmg:1, dead:false, hitT:0, atkCd:0, flap:0, def };
  if(def.t === 'golem'){
    if(S.flags.boss_golem) return null;
    return { type:'golem', enemy:true, boss:true, flag:'boss_golem', x:tpx(def.x), y:tpx(def.y), hw:10, hh:10, hp:45, maxHp:45, speed:26, dmg:1, dead:false, hitT:0, atkCd:0, state:'chase', stateT:0, chargeCd:3, dir:0, def };
  }
  if(def.t === 'warden'){
    if(S.flags.boss_warden) return null;
    return { type:'warden', enemy:true, boss:true, flag:'boss_warden', x:tpx(def.x), y:tpx(def.y), hw:9, hh:9, hp:55, maxHp:55, speed:30, dmg:1, dead:false, hitT:0, atkCd:0, state:'chase', stateT:0, shootCd:2.2, dir:0, def };
  }
  if(def.t === 'corruption'){
    if(S.flags.boss_corruption) return null;
    return { type:'corruption', enemy:true, boss:true, flag:'boss_corruption', x:tpx(def.x), y:tpx(def.y), hw:11, hh:11, hp:75, maxHp:75, speed:30, dmg:2, dead:false, hitT:0, atkCd:0, state:'chase', stateT:0, ringCd:2.8, chargeCd:2, minions:false, dir:0, def };
  }
  return null;
}

function updateEnemy(e, dt){
  if(e.dead) return;
  const pl = S.player;
  e.hitT = Math.max(0, e.hitT - dt);
  e.atkCd = Math.max(0, e.atkCd - dt);
  if(!pl || pl.dead || S.dialog || S.modal){ return; }
  const dx = pl.x - e.x, dy = pl.y - e.y;
  const dist = Math.hypot(dx, dy) || 1;
  const nx = dx/dist, ny = dy/dist;

  if(e.type === 'slime'){
    e.hop += dt * (dist < 60 ? 7 : 3.4);
    e.wanderT -= dt;
    if(dist < 70){ e.dir = Math.atan2(dy, dx); moveWithCollisions(e, nx*e.speed*dt, ny*e.speed*dt, false); }
    else if(e.wanderT <= 0){ e.wanderT = rnd(0.8, 1.6); e.dir = rnd(0, Math.PI*2); }
    else moveWithCollisions(e, Math.cos(e.dir)*e.speed*0.5*dt, Math.sin(e.dir)*e.speed*0.5*dt, false);
    if(dist < 9 && e.atkCd <= 0){ hurtPlayer(e.dmg, e.x, e.y); e.atkCd = 0.85; }
  }
  else if(e.type === 'thorn'){
    e.shootCd -= dt;
    let vx = 0, vy = 0;
    if(dist > 90){ vx = nx*e.speed; vy = ny*e.speed; }
    else if(dist < 55){ vx = -nx*e.speed*0.8; vy = -ny*e.speed*0.8; }
    else { vx = -ny*e.speed*0.6; vy = nx*e.speed*0.6; }
    e.dir = Math.atan2(dy, dx);
    moveWithCollisions(e, vx*dt, vy*dt, false);
    if(e.shootCd <= 0 && dist < 150){
      e.shootCd = 1.8;
      const a = Math.atan2(dy, dx);
      fireProjectile({ x:e.x + nx*6, y:e.y + ny*6, vx:Math.cos(a)*95, vy:Math.sin(a)*95, dmg:1, from:'enemy', kind:'thorn', life:2.2 });
      sfx('arrow');
    }
  }
  else if(e.type === 'bat'){
    e.flap += dt*14;
    const perp = Math.sin(e.flap*0.8)*0.7;
    const vx = nx*e.speed + (-ny)*e.speed*perp, vy = ny*e.speed + (nx)*e.speed*perp;
    moveWithCollisions(e, vx*dt, vy*dt, false);
    if(dist < 9 && e.atkCd <= 0){ hurtPlayer(e.dmg, e.x, e.y); e.atkCd = 0.8; }
  }
  else if(e.type === 'golem'){ updateGolem(e, dt, nx, ny, dist); }
  else if(e.type === 'warden'){ updateWarden(e, dt, nx, ny, dist); }
  else if(e.type === 'corruption'){ updateCorruption(e, dt, nx, ny, dist); }
}

function updateGolem(e, dt, nx, ny, dist){
  const pl = S.player;
  e.chargeCd -= dt;
  if(e.state === 'chase'){
    e.dir = Math.atan2(ny, nx);
    moveWithCollisions(e, nx*e.speed*dt, ny*e.speed*dt, false);
    if(dist < 27){ e.state = 'windup'; e.stateT = 0.65; sfx('roar'); }
    else if(e.chargeCd <= 0 && dist > 45){ e.state = 'telegraph'; e.stateT = 0.5; e.dir = e.dir; e.chargeCd = 4; }
  } else if(e.state === 'telegraph'){
    e.stateT -= dt;
    if(e.stateT <= 0){ e.state = 'charge'; e.stateT = 0.55; sfx('roar'); }
  } else if(e.state === 'charge'){
    e.stateT -= dt;
    moveWithCollisions(e, Math.cos(e.dir)*125*dt, Math.sin(e.dir)*125*dt, false);
    if(dist < 12 && e.atkCd <= 0){ hurtPlayer(2, e.x, e.y); e.atkCd = 0.8; }
    if(e.stateT <= 0){ e.state = 'chase'; e.stateT = 0; }
  } else if(e.state === 'windup'){
    e.stateT -= dt;
    if(Math.random() < 0.4) particle(e.x + rnd(-10,10), e.y + rnd(-10,10), {type:'flame', size:rnd(2,4), life:0.3, color:'rgba(255,150,60,0.5)'});
    if(e.stateT <= 0){
      e.state = 'chase';
      const d = Math.hypot(pl.x-e.x, pl.y-e.y);
      if(d < 32){ hurtPlayer(2, e.x, e.y); }
      S.shake = Math.max(S.shake, 8); sfx('bomb');
      burst(e.x, e.y, '#c9a06a', 18, 120, {type:'flame'});
      for(let i=0;i<6;i++){ const a=i/6*Math.PI*2; particle(e.x, e.y+4, {type:'smoke', size:rnd(3,6), life:0.5, color:'rgba(160,140,120,0.4)', vx:Math.cos(a)*40, vy:Math.sin(a)*30-8}); }
    }
  }
}

function updateWarden(e, dt, nx, ny, dist){
  const pl = S.player;
  e.shootCd -= dt;
  if(e.state === 'chase'){
    e.dir = Math.atan2(ny, nx);
    moveWithCollisions(e, nx*e.speed*dt, ny*e.speed*dt, false);
    if(dist < 26){ e.state = 'windup'; e.stateT = 0.55; }
    else if(e.shootCd <= 0 && dist < 170){
      e.shootCd = 2.4; e.dir = e.dir;
      const a = Math.atan2(ny, nx);
      for(let k=-1;k<=1;k++) fireProjectile({ x:e.x+nx*8, y:e.y+ny*8, vx:Math.cos(a+k*0.24)*110, vy:Math.sin(a+k*0.24)*110, dmg:1, from:'enemy', kind:'orb', life:2.4 });
      sfx('arrow');
    }
  } else if(e.state === 'windup'){
    e.stateT -= dt;
    if(Math.random() < 0.3) particle(e.x + Math.cos(e.dir)*10, e.y + Math.sin(e.dir)*10, {type:'flame', size:rnd(2,3.6), life:0.25, color:'rgba(160,200,255,0.7)'});
    if(e.stateT <= 0){
      e.state = 'chase';
      const d = Math.hypot(pl.x-e.x, pl.y-e.y);
      const ang = Math.atan2(pl.y-e.y, pl.x-e.x);
      let diff = Math.abs(angLerp(e.dir, ang, 0));
      if(d < 30 && diff < 1.6){ hurtPlayer(2, e.x, e.y); }
      sfx('swing'); sparks(e.x + Math.cos(e.dir)*12, e.y + Math.sin(e.dir)*12, '#cfe4ff', 8);
    }
  }
}

function updateCorruption(e, dt, nx, ny, dist){
  const pl = S.player;
  e.ringCd -= dt; e.chargeCd -= dt;
  if(e.state === 'chase'){
    e.dir = Math.atan2(ny, nx);
    moveWithCollisions(e, nx*e.speed*dt, ny*e.speed*dt, false);
    if(e.ringCd <= 0){
      e.ringCd = (e.hp < e.maxHp*0.5 ? 2.2 : 3.0);
      const n = 10;
      for(let i=0;i<n;i++){ const a = i/n*Math.PI*2 + S.time; fireProjectile({ x:e.x, y:e.y, vx:Math.cos(a)*100, vy:Math.sin(a)*100, dmg:1, from:'enemy', kind:'orb', life:2.6 }); }
      sfx('arrow'); S.shake = Math.max(S.shake, 3);
    }
    if(e.chargeCd <= 0 && dist > 30){ e.state = 'telegraph'; e.stateT = 0.6; e.chargeCd = 3.4; }
    if(e.hp < e.maxHp*0.5 && !e.minions){
      e.minions = true;
      for(let i=0;i<2;i++) S.ents.push(makeEnemy({ t:'slime', x:Math.max(2, Math.min(30, Math.floor(e.x/TILE)+i*3-2)), y:Math.max(2, Math.min(16, Math.floor(e.y/TILE)+2)) }));
      toast('The Corruption calls for aid!', '#c26ae8');
    }
  } else if(e.state === 'telegraph'){
    e.stateT -= dt;
    if(Math.random() < 0.5) particle(e.x + rnd(-12,12), e.y + rnd(-12,12), {type:'flame', size:rnd(2,4), life:0.3, color:'rgba(200,110,240,0.6)'});
    if(e.stateT <= 0){ e.state = 'charge'; e.stateT = 0.5; sfx('roar'); }
  } else if(e.state === 'charge'){
    e.stateT -= dt;
    moveWithCollisions(e, Math.cos(e.dir)*115*dt, Math.sin(e.dir)*115*dt, false);
    if(dist < 13 && e.atkCd <= 0){ hurtPlayer(2, e.x, e.y); e.atkCd = 0.8; }
    if(e.stateT <= 0) e.state = 'chase';
  }
}

/* ---------- damage / death ---------- */
function damageEnemy(e, dmg, kx, ky){
  if(e.dead) return;
  e.hp -= dmg;
  e.hitT = 0.13;
  sparks(e.x, e.y, '#ffffff', 4);
  addDamageText(e.x + rnd(-3,3), e.y - (e.hh||7) - 3, dmg, '#fff2c0');
  if(!e.boss){
    const m = Math.hypot(kx, ky) || 1;
    const kb = Math.min(4, 3 + dmg*0.4);
    e.x += kx/m*kb; e.y += ky/m*kb;
  }
  sfx('hit');
  if(e.hp <= 0) killEnemy(e);
}
function killEnemy(e){
  e.dead = true; e.hp = 0;
  S.kills++;
  burst(e.x, e.y, e.boss ? '#e8b04b' : '#cfe0c0', e.boss ? 30 : 10, e.boss ? 150 : 70, {type:'flame'});
  smoke(e.x, e.y, e.boss ? 10 : 3);
  // loot
  const rupees = e.boss ? 30 : irnd(2, 6);
  spawnPickup(e.x, e.y, 'rupee', rupees);
  if(!e.boss && Math.random() < 0.09) spawnPickup(e.x + rnd(-6,6), e.y + rnd(-6,6), 'heart', 1);
  // vampiric fang
  if(S.equipped === 'fang' && S.player && S.player.hp < S.player.maxHp){
    S.player.hp = Math.min(S.player.maxHp, S.player.hp + 1);
    addDamageText(S.player.x, S.player.y - 10, '+1', '#7dff9a');
  }
  if(e.boss){
    S.flags[e.flag] = true;
    refreshCondChests();
    if(e.flag === 'boss_golem'){ toast('Thorn Golem slain — the way is open!', '#f7d98b'); }
    if(e.flag === 'boss_warden'){ toast('The Warden falls!', '#f7d98b'); }
    if(e.flag === 'boss_corruption'){ winGame(); }
    sfx('levelup');
    updateObjective();
  }
}

function addDamageText(x, y, text, color){
  S.texts.push({ x, y, text:String(text), color:color||'#fff', t:0, life:0.8 });
}
function updateTexts(dt){
  for(let i=S.texts.length-1;i>=0;i--){
    const t = S.texts[i];
    t.t += dt; t.y -= 22*dt;
    if(t.t >= t.life) S.texts.splice(i,1);
  }
}
function drawTexts(){
  for(const t of S.texts){
    ctx.globalAlpha = 1 - t.t/t.life;
    ctx.font = 'bold 8px monospace';
    ctx.textAlign = 'center';
    ctx.fillStyle = '#000'; ctx.fillText(t.text, t.x+1, t.y+1);
    ctx.fillStyle = t.color; ctx.fillText(t.text, t.x, t.y);
    ctx.globalAlpha = 1;
  }
}

function hurtPlayer(dmg, sx, sy){
  const pl = S.player;
  if(!pl || pl.dead || pl.invuln > 0 || S.mode !== 'play' || S.transition) return;
  let d = dmg;
  if(S.equipped === 'iron') d = Math.max(1, Math.round(d * 0.7));
  pl.hp -= d;
  pl.invuln = 0.8;
  S.flash = 0.35;
  S.shake = Math.max(S.shake, 5);
  sfx('hurt');
  addDamageText(pl.x, pl.y - 12, '-' + d, '#ff6b6b');
  burst(pl.x, pl.y, '#ff5a52', 8, 90);
  const m = Math.hypot(pl.x-sx, pl.y-sy) || 1;
  pl.x += (pl.x-sx)/m*6; pl.y += (pl.y-sy)/m*6;
  if(pl.hp <= 0) die();
}
function die(){
  const pl = S.player;
  pl.dead = true; pl.hp = 0;
  sfx('die');
  Music.stop();
  S.mode = 'dead';
  S.deathTimer = 2.6;
  burst(pl.x, pl.y, '#ff5a52', 24, 120, {type:'flame'});
}

/* ---------- loot / grants ---------- */
function grantItem(id, n){
  n = n || 1;
  if(!S.inv[id]) S.inv[id] = 0;
  S.inv[id] = Math.min(9, S.inv[id] + n);
  if(ITEMS[id].type === 'accessory'){
    if(S.shopSold.indexOf(id) < 0) S.shopSold.push(id);
    if(!S.equipped) S.equipped = id;
  }
  toast((n>1 ? n+'× ' : '') + ITEMS[id].name + ' acquired', '#7dff9a');
  sfx('pickup');
}
function grant(g){
  if(g.type === 'rupees'){ S.rupees += g.amt; sfx('coin'); toast('+' + g.amt + ' ✦', '#f7d98b'); }
  else if(g.type === 'item'){ grantItem(g.id, g.n); }
  else if(g.type === 'weapon'){
    if(g.id === 'ember'){
      S.flags.ember = true; S.player.weapon = 'ember';
      toast('EMBER BLADE acquired! Fire burns living vines.', '#ff8a3d');
      sfx('levelup'); burst(S.player.x, S.player.y, '#ff8a3d', 20, 110, {type:'flame'});
    } else if(g.id === 'bow'){
      S.flags.bow = true; S.player.hasBow = true; S.player.bowEquipped = true;
      toast('WINDCALLER BOW discovered! Press Q to swap, SPACE to fire.', '#9fd8ff');
      sfx('bow'); burst(S.player.x, S.player.y, '#9fd8ff', 20, 110);
    } else if(g.id === 'moon'){
      S.flags.moon = true; S.player.weapon = 'moon';
      toast('MOON EDGE acquired! Long, sweeping, and true.', '#9fd8ff');
      sfx('levelup'); burst(S.player.x, S.player.y, '#9fd8ff', 24, 130);
    }
  }
  else if(g.type === 'heart'){
    const pl = S.player;
    if(pl.maxHp < 10){
      pl.maxHp = Math.min(10, pl.maxHp + 2);
      pl.hp = pl.maxHp;
      S.flags[pl.maxHp >= 10 ? 'heart2' : 'heart1'] = true;
      toast('HEART CONTAINER! Max health increased.', '#ff6b6b');
      sfx('heart'); burst(pl.x, pl.y, '#ff6b6b', 26, 130, {type:'flame'});
      S.flash = 0.25;
    } else {
      pl.hp = pl.maxHp; toast('Fully restored!', '#ff6b6b');
    }
  }
  else if(g.type === 'key'){
    S.flags[g.id] = true;
    if(g.id === 'forestKey') toast("Forest Key! The grove's seal is broken.", '#f7d98b');
    else toast('Sanctum Key! The final gate opens.', '#f7d98b');
    sfx('chest');
  }
  updateObjective();
}

function updateObjective(){
  const f = S.flags;
  if(f.boss_corruption) S.objective = 'Emberheart is saved.';
  else if(f.moon) S.objective = 'Enter the Sanctum north of Warden Hall and defeat the Corruption.';
  else if(f.bow && f.gate_meadow) S.objective = 'Defeat the Warden in the Sunken Ruins to claim the Moon Edge.';
  else if(f.bow) S.objective = 'Shoot the eye-switch across the river to open the ruins gate (east).';
  else if(f.ember) S.objective = 'Burn the living vines west of Thornwood Edge to find the hidden grove.';
  else S.objective = 'Explore the forest to the west and claim the Ember Blade.';
}

/* ---------- chests & interaction ---------- */
function interactables(){
  const pl = S.player;
  for(const e of S.ents){
    if(e.dead) continue;
    if(['chest','npc','shop','sign','shrine'].indexOf(e.type) >= 0 && Math.hypot(e.x-pl.x, e.y-pl.y) < 18) return e;
  }
  return null;
}
function openChest(e){
  if(e.open > 0) return;
  e.open = 0.001;
  S.flags[e.def.flag] = true;
  sfx('chest');
  burst(e.x, e.y, '#e8b04b', 12, 80);
  for(const g of e.def.grants || []) grant(g);
}
function performInteract(){
  if(S.transition || S.mode !== 'play') return;
  const e = interactables();
  if(!e) return;
  if(e.type === 'chest') openChest(e);
  else if(e.type === 'npc') openDialogue(e.def.name, e.def.lines);
  else if(e.type === 'shop'){ openDialogue(e.def.name || 'Merchant Mira', ["Welcome! Care to trade?"], true); }
  else if(e.type === 'sign') openDialogue('Sign', e.def.lines);
  else if(e.type === 'shrine') saveAtShrine();
}

/* ---------- dialogue state ---------- */
function openDialogue(name, lines, openShopAfter){
  S.dialog = { name, lines, i:0, t:0, shopAfter:!!openShopAfter };
}
function updateDialog(dt){
  if(!S.dialog) return;
  const d = S.dialog;
  const line = d.lines[d.i];
  if(d.t < line.length) d.t += dt*42;
}
function advanceDialog(){
  if(!S.dialog) return;
  const d = S.dialog;
  if(d.t < d.lines[d.i].length){ d.t = d.lines[d.i].length; return; }
  if(d.i < d.lines.length-1){ d.i++; d.t = 0; return; }
  const shop = d.shopAfter;
  S.dialog = null;
  if(shop) openShop();
}

/* ============================================================
   Chunk 5a: enemy visuals, player visuals, HUD, map loading
   ============================================================ */

function drawEnemy(e){
  const hitA = e.hitT > 0 ? e.hitT / 0.13 : 0;
  if(e.boss && (e.state === 'windup' || e.state === 'telegraph')){
    glow(e.x, e.y, (e.hw||10) + 9, 'rgba(255,120,60,0.45)', 0.35 + Math.sin(S.time*22)*0.18);
  }
  if(e.type === 'slime'){
    const sq = 1 + Math.sin(e.hop)*0.14;
    shadow(e.x, e.y+4, 5.6);
    ctx.fillStyle = '#5fbf6a'; ctx.beginPath(); ctx.ellipse(e.x, e.y+1, 6.4*sq, 5/Math.max(0.7,sq), 0, 0, 7); ctx.fill();
    ctx.strokeStyle = '#2f6b36'; ctx.lineWidth = 1; ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.ellipse(e.x-2, e.y-1, 2, 1.2, -0.4, 0, 7); ctx.fill();
    ctx.fillStyle = '#173018'; ctx.fillRect(e.x-3.4, e.y-0.6, 1.6, 2); ctx.fillRect(e.x+1.4, e.y-0.6, 1.6, 2);
  } else if(e.type === 'thorn'){
    shadow(e.x, e.y+4, 5);
    ctx.fillStyle = '#7fbf5c'; ctx.beginPath(); ctx.ellipse(e.x, e.y, 5.6, 4.6, 0, 0, 7); ctx.fill();
    ctx.strokeStyle = '#3a6b2a'; ctx.lineWidth = 1; ctx.stroke();
    ctx.strokeStyle = '#3a6b2a'; ctx.lineWidth = 1.2;
    for(let i=0;i<6;i++){ const a = i/6*Math.PI*2 + S.time*0.4; ctx.beginPath(); ctx.moveTo(e.x+Math.cos(a)*4, e.y+Math.sin(a)*4); ctx.lineTo(e.x+Math.cos(a)*8, e.y+Math.sin(a)*8); ctx.stroke(); }
    ctx.fillStyle = '#173018'; ctx.fillRect(e.x-3, e.y-0.6, 1.6, 2); ctx.fillRect(e.x+1, e.y-0.6, 1.6, 2);
  } else if(e.type === 'bat'){
    const w = Math.sin(e.flap)*3;
    shadow(e.x, e.y+4, 4.6);
    ctx.fillStyle = '#4a4058';
    ctx.beginPath(); ctx.moveTo(e.x-6, e.y-w); ctx.lineTo(e.x-1, e.y-1); ctx.lineTo(e.x-1, e.y+1); ctx.lineTo(e.x-6, e.y+w); ctx.closePath(); ctx.fill();
    ctx.beginPath(); ctx.moveTo(e.x+6, e.y-w); ctx.lineTo(e.x+1, e.y-1); ctx.lineTo(e.x+1, e.y+1); ctx.lineTo(e.x+6, e.y+w); ctx.closePath(); ctx.fill();
    ctx.fillStyle = '#322a3e'; ctx.beginPath(); ctx.ellipse(e.x, e.y, 3.4, 3, 0, 0, 7); ctx.fill();
    ctx.fillStyle = '#ff5a52'; ctx.fillRect(e.x-2.4, e.y-1, 1.4, 1.6); ctx.fillRect(e.x+1, e.y-1, 1.4, 1.6);
  } else if(e.type === 'golem'){
    shadow(e.x, e.y+11, 11);
    ctx.fillStyle = '#5a606b'; ctx.beginPath(); ctx.arc(e.x-5.6, e.y+2, 4.4, 0, 7); ctx.arc(e.x+5.6, e.y+2, 4.4, 0, 7); ctx.fill();
    ctx.fillStyle = '#7d8490'; roundRect(e.x-9, e.y-10, 18, 18, 6); ctx.fill();
    ctx.strokeStyle = '#3d4249'; ctx.lineWidth = 1.4; roundRect(e.x-9, e.y-10, 18, 18, 6); ctx.stroke();
    ctx.fillStyle = '#98a0ab'; roundRect(e.x-5, e.y-12.6, 10, 5, 2.4); ctx.fill();
    ctx.fillStyle = '#5fbf6a'; ctx.fillRect(e.x-8, e.y-4, 4, 3); ctx.fillRect(e.x+3, e.y+2, 5, 3);
    if(e.state === 'windup'){ ctx.fillStyle = '#e8b04b'; } else ctx.fillStyle = '#e8b04b';
    ctx.beginPath(); ctx.arc(e.x-3, e.y-5, 1.6, 0, 7); ctx.arc(e.x+3, e.y-5, 1.6, 0, 7); ctx.fill();
    ctx.fillStyle = '#ff8a3d'; ctx.fillRect(e.x-4.4, e.y-8.4, 2.6, 1.6); ctx.fillRect(e.x+1.8, e.y-8.4, 2.6, 1.6);
  } else if(e.type === 'warden'){
    shadow(e.x, e.y+9, 9);
    ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.dir);
    ctx.strokeStyle = '#c9d2e0'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(4,0); ctx.lineTo(14,0); ctx.stroke();
    ctx.fillStyle = '#e8eef5'; ctx.beginPath(); ctx.moveTo(15,0); ctx.lineTo(11,-2.6); ctx.lineTo(11,2.6); ctx.closePath(); ctx.fill();
    ctx.restore();
    ctx.fillStyle = '#5a6070'; roundRect(e.x-8, e.y-7, 16, 14, 4.4); ctx.fill();
    ctx.strokeStyle = '#2e323b'; ctx.lineWidth = 1.4; roundRect(e.x-8, e.y-7, 16, 14, 4.4); ctx.stroke();
    ctx.fillStyle = '#8d94a0'; roundRect(e.x-5, e.y-11, 10, 7, 3); ctx.fill();
    ctx.fillStyle = '#5fd2e0'; ctx.fillRect(e.x-3.6, e.y-9, 7.2, 2);
    ctx.fillStyle = '#c9d2e0'; ctx.fillRect(e.x-7, e.y+2, 14, 2.4);
  } else if(e.type === 'corruption'){
    glow(e.x, e.y, 20, 'rgba(180,90,230,0.5)', 0.4 + Math.sin(S.time*3)*0.12);
    shadow(e.x, e.y+10, 10);
    ctx.strokeStyle = '#6d3b8a'; ctx.lineWidth = 1.6;
    for(let i=0;i<6;i++){ const a = i/6*Math.PI*2 + S.time*1.2; ctx.beginPath(); ctx.moveTo(e.x+Math.cos(a)*9, e.y+Math.sin(a)*9); ctx.lineTo(e.x+Math.cos(a)*14, e.y+Math.sin(a)*14); ctx.stroke(); }
    ctx.fillStyle = '#2a1f38'; ctx.beginPath(); ctx.arc(e.x, e.y, 10, 0, 7); ctx.fill();
    ctx.strokeStyle = '#7d4fa0'; ctx.lineWidth = 2; ctx.stroke();
    ctx.fillStyle = '#e0c0ff'; ctx.beginPath(); ctx.arc(e.x, e.y, 5, 0, 7); ctx.fill();
    ctx.fillStyle = e.hp < e.maxHp*0.5 ? '#ff5a52' : '#8a3fae'; ctx.beginPath(); ctx.arc(e.x, e.y, 2.4, 0, 7); ctx.fill();
  }
  if(hitA > 0){
    ctx.globalAlpha = hitA*0.65; ctx.fillStyle = '#ffffff';
    ctx.beginPath(); ctx.arc(e.x, e.y, (e.hw||7)+1, 0, 7); ctx.fill();
    ctx.globalAlpha = 1;
  }
}

function drawBomb(e){
  shadow(e.x, e.y+3, 4);
  ctx.fillStyle = '#1b1f2a'; ctx.beginPath(); ctx.arc(e.x, e.y, 4, 0, 7); ctx.fill();
  ctx.fillStyle = '#8a5a34'; ctx.fillRect(e.x-1.4, e.y-7, 2.8, 3);
  const s = Math.sin(S.time*30)*1.4;
  ctx.fillStyle = '#ffd24a'; ctx.beginPath(); ctx.arc(e.x + s*0.4, e.y-8, 1.6, 0, 7); ctx.fill();
  if(e.t < 0.4) glow(e.x, e.y, 12, 'rgba(255,150,60,0.8)', 0.5);
}

function drawPlayer(){
  const pl = S.player;
  if(!pl || (pl.dead && S.mode === 'dead')) return;
  const w = pl.bowEquipped && pl.hasBow ? WEAPONS.bow : weaponStats();
  drawCharacter(pl.x, pl.y, pl.dir, {
    tunic:'#3f8f4f', skin:'#e8b48a', hair:'#8a5a34', legs:'#3a2f28', belt:w.color,
    moving:pl.moving && pl.dashT<=0, walkT:pl.walkT, lean:pl.sprinting ? 0.5 : 0, hunch:pl.stamina < 18,
  });
  // weapon
  ctx.save(); ctx.translate(pl.x, pl.y); ctx.rotate(pl.dir);
  if(pl.bowEquipped && pl.hasBow){
    ctx.strokeStyle = '#8a5a34'; ctx.lineWidth = 1.8;
    ctx.beginPath(); ctx.arc(2, 0, 6.4, -1.15, 1.15); ctx.stroke();
    ctx.strokeStyle = '#e8e4d0'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(2+6.4*Math.cos(1.15), 6.4*Math.sin(1.15)); ctx.lineTo(2+6.4*Math.cos(1.15), -6.4*Math.sin(1.15)); ctx.stroke();
    if(pl.attackCd > WEAPONS.bow.cd - 0.16){
      ctx.strokeStyle = '#d8c9a8'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(2,0); ctx.lineTo(10,0); ctx.stroke();
    }
  } else {
    const swing = pl.attackT > 0 ? (0.5 - pl.attackT/0.16)*1.6 : 0.35;
    ctx.rotate(swing);
    if(w.fire && Math.random() < 0.3) particle(pl.x + Math.cos(pl.dir+swing)*(10+w.range*0.5), pl.y + Math.sin(pl.dir+swing)*(10+w.range*0.5), {type:'flame', size:rnd(2,4), life:0.3, color:'rgba(255,138,61,0.8)', vx:rnd(-8,8), vy:rnd(-8,8)});
    if(w.wide) glow(pl.x + Math.cos(pl.dir+swing)*10, pl.y + Math.sin(pl.dir+swing)*10, 12, 'rgba(159,216,255,0.4)', 0.3);
    const bl = w.range * 0.55;
    ctx.fillStyle = w.color;
    ctx.beginPath(); ctx.moveTo(4,0); ctx.lineTo(4+bl, -1.5); ctx.lineTo(4+bl, 1.5); ctx.closePath(); ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(5,0); ctx.lineTo(4+bl, 0); ctx.stroke();
    ctx.fillStyle = '#5c3a20'; ctx.fillRect(3, -2.6, 1.8, 5.2);
    ctx.fillStyle = '#6b4526'; ctx.fillRect(2.4, -1, 1.4, 2);
  }
  ctx.restore();
  // swing arc feedback
  if(pl.attackT > 0 && !(pl.bowEquipped && pl.hasBow)){
    const w2 = weaponStats();
    ctx.globalAlpha = 0.24 * (pl.attackT/0.16);
    ctx.fillStyle = w2.color;
    ctx.beginPath(); ctx.moveTo(pl.x, pl.y);
    ctx.arc(pl.x, pl.y, w2.range, pl.swingDir - w2.arc/2, pl.swingDir + w2.arc/2);
    ctx.closePath(); ctx.fill();
    ctx.globalAlpha = 1;
  }
  if(pl.invuln > 0){
    ctx.globalAlpha = 0.2 + Math.sin(S.time*30)*0.08;
    ctx.fillStyle = '#ff5a52'; ctx.beginPath(); ctx.arc(pl.x, pl.y, 9, 0, 7); ctx.fill();
    ctx.globalAlpha = 1;
  }
}

/* ---------- sword hit resolution ---------- */
function updateSwing(){
  const pl = S.player;
  if(pl.attackT <= 0 || (pl.bowEquipped && pl.hasBow)) return;
  const w = weaponStats();
  const dmg = Math.round(w.dmg * (S.equipped === 'power' ? 1.25 : 1));
  for(const e of S.ents){
    if(!e.enemy || e.dead) continue;
    if(pl.swingHit.has(e)) continue;
    const d = Math.hypot(e.x-pl.x, e.y-pl.y);
    if(d > w.range + (e.hw||6)) continue;
    const ang = Math.atan2(e.y-pl.y, e.x-pl.x);
    const diff = Math.abs(angLerp(pl.swingDir, ang, 0));
    if(diff < w.arc/2 + 0.3){
      pl.swingHit.add(e);
      damageEnemy(e, dmg, e.x-pl.x, e.y-pl.y);
      sparks(e.x, e.y, w.color, 5);
    }
  }
}

/* ---------- HUD ---------- */
function drawHeartState(x, y, s, state){
  const outline = '#241015', empty = '#4a2430';
  if(state === 2){ drawHeartShape(x, y, s, '#ff5a52', outline); }
  else if(state === 1){
    drawHeartShape(x, y, s, empty, outline);
    ctx.save(); ctx.beginPath(); ctx.rect(x - s, y - s, s, s*2); ctx.clip();
    drawHeartShape(x, y, s, '#ff5a52', outline);
    ctx.restore();
  } else drawHeartShape(x, y, s, empty, outline);
}
function drawHUD(){
  if(S.mode === 'title' || !S.player) return;
  const pl = S.player;
  // hearts
  const containers = Math.ceil(pl.maxHp/2);
  for(let i=0;i<containers;i++){
    let state;
    if(pl.hp >= (i+1)*2) state = 2;
    else if(pl.hp === i*2+1) state = 1;
    else state = 0;
    drawHeartState(16 + i*13, 13, 5.4, state);
  }
  // stamina
  const sw = 46;
  ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillRect(12, 21, sw+4, 6);
  const sp = pl.stamina/pl.maxStamina;
  ctx.fillStyle = sp > 0.4 ? '#7dff9a' : sp > 0.18 ? '#f7d98b' : '#ff6b6b';
  ctx.fillRect(14, 23, sw*sp, 2);
  ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.strokeRect(12.5, 21.5, sw+3, 5);
  if(pl.stamina < 18){ ctx.fillStyle = '#ff6b6b'; ctx.font = 'bold 7px monospace'; ctx.textAlign='left'; ctx.fillText('TIRED', 64, 27); }

  // rupees
  ctx.font = 'bold 10px monospace'; ctx.textAlign = 'right';
  ctx.fillStyle = '#000'; ctx.fillText('✦ ' + S.rupees, VIEW_W-11, 14);
  ctx.fillStyle = '#f7d98b'; ctx.fillText('✦ ' + S.rupees, VIEW_W-12, 13);
  // weapon line
  const w = pl.bowEquipped && pl.hasBow ? WEAPONS.bow : weaponStats();
  ctx.font = 'bold 8px monospace';
  ctx.fillStyle = '#000'; ctx.fillText(w.name, VIEW_W-11, 27);
  ctx.fillStyle = w.color; ctx.fillText(w.name, VIEW_W-12, 26);

  // objective
  if(S.objective){
    ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center';
    const wdt = ctx.measureText(S.objective).width + 12;
    ctx.fillStyle = 'rgba(10,8,14,0.55)'; ctx.fillRect(VIEW_W/2 - wdt/2, 6, wdt, 12);
    ctx.fillStyle = '#e6d8c4'; ctx.fillText(S.objective, VIEW_W/2, 14);
  }

  // boss bar
  const boss = S.ents.find(e => e.boss && !e.dead);
  if(boss){
    const bw = 180, bx = VIEW_W/2 - bw/2, by = 22;
    ctx.fillStyle = 'rgba(10,8,14,0.6)'; ctx.fillRect(bx-4, by-4, bw+8, 14);
    ctx.fillStyle = '#3a1f24'; ctx.fillRect(bx, by, bw, 6);
    ctx.fillStyle = '#e0433f'; ctx.fillRect(bx, by, bw*clamp(boss.hp/boss.maxHp,0,1), 6);
    ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.strokeRect(bx-0.5, by-0.5, bw+1, 7);
    ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center';
    ctx.fillStyle = '#f3ead6'; ctx.fillText(boss.type === 'golem' ? 'THORN GOLEM' : boss.type === 'warden' ? 'THE WARDEN' : 'THE CORRUPTION', VIEW_W/2, by+15);
  }
}

/* ---------- map loading & transitions ---------- */
function loadMap(id, tx, ty){
  S.mapId = id;
  S.map = MAPS[id];
  S.ents = []; S.projectiles = []; S.pickups = []; S.particles = []; S.texts = [];
  rebuildTiles();
  for(const def of S.map.ents){
    const e = entFromDef(def);
    if(e) S.ents.push(e);
  }
  if(S.player){
    S.player.x = tpx(tx); S.player.y = tpx(ty);
    S.player.invuln = 0.7;
    clampPlayer();
  }
}
function transitionTo(mapId, tx, ty){
  if(S.transition) return;
  S.transition = { t:0, phase:0, mapId, tx, ty };
  sfx('gate');
}
function updateTransition(dt){
  const tr = S.transition;
  if(!tr) return;
  tr.t += dt;
  if(tr.phase === 0 && tr.t >= 0.26){ loadMap(tr.mapId, tr.tx, tr.ty); tr.phase = 1; }
  if(tr.phase === 1 && tr.t >= 0.52){ S.transition = null; }
}
function refreshCondChests(){
  if(!S.map) return;
  for(const def of S.map.ents){
    if(def.t === 'chest' && def.cond && S.flags[def.cond] && !S.flags[def.flag]){
      if(!S.ents.some(e => e.type === 'chest' && e.def === def)) S.ents.push(entFromDef(def));
    }
  }
}

/* ============================================================
   Chunk 5b: UI, save system, input dispatch, main loop, boot
   ============================================================ */

const modalEl = document.getElementById('modal');
const modalInner = document.getElementById('modalInner');
const titleEl = document.getElementById('title');
const deathEl = document.getElementById('death');
const winEl = document.getElementById('win');
const winStats = document.getElementById('winStats');
const dialogueEl = document.getElementById('dialogue');
const dialogueName = document.getElementById('dialogueName');
const dialogueText = document.getElementById('dialogueText');

function showModal(){ modalEl.classList.remove('hidden'); }
function hideModal(){ modalEl.classList.add('hidden'); S.modal = null; }

/* ---------- dialogue DOM ---------- */
function renderDialogue(){
  if(!S.dialog){ dialogueEl.classList.add('hidden'); return; }
  dialogueEl.classList.remove('hidden');
  dialogueName.textContent = S.dialog.name;
  const line = S.dialog.lines[S.dialog.i];
  dialogueText.textContent = line.slice(0, Math.floor(S.dialog.t));
}

/* ---------- inventory ---------- */
function openInventory(){ S.modal = 'inventory'; S.selItem = null; renderInventory(); showModal(); }
function itemOrder(){ return ['potion','bomb','swift','iron','power','fang','forestKey','sanctumKey']; }
function renderInventory(){
  const ids = itemOrder().filter(id => (S.inv[id]||0) > 0);
  modalInner.innerHTML = '';
  const h = document.createElement('h2'); h.textContent = 'Inventory'; modalInner.appendChild(h);
  const grid = document.createElement('div'); grid.id = 'invGrid'; modalInner.appendChild(grid);
  ids.forEach(id => {
    const it = ITEMS[id];
    const slot = document.createElement('div');
    slot.className = 'slot' + (S.selItem === id ? ' selected' : '') + (S.equipped === id ? ' eq' : '');
    slot.innerHTML = '<span class="ic">' + it.icon + '</span><span>' + it.name + '</span>';
    const qty = S.inv[id] || 0;
    if(it.type !== 'accessory') slot.innerHTML += '<span class="qty">×' + qty + '</span>';
    slot.onclick = () => { S.selItem = id; renderInventory(); };
    grid.appendChild(slot);
  });
  if(!ids.length){
    const p = document.createElement('div'); p.style.cssText = 'font-size:11px;color:#8f7f9b;text-align:center;padding:18px;';
    p.textContent = 'Your bag is empty. Explore, fight, and shop!'; grid.appendChild(p);
  }
  const info = document.createElement('div'); info.id = 'itemInfo'; modalInner.appendChild(info);
  const row = document.createElement('div'); row.className = 'row'; modalInner.appendChild(row);
  if(S.selItem){
    const it = ITEMS[S.selItem];
    info.innerHTML = '<b>' + it.name + '</b> — ' + it.desc;
    if(it.type === 'consumable'){
      const b = document.createElement('button'); b.className = 'btn'; b.textContent = 'Use'; b.onclick = useSelected; row.appendChild(b);
    } else if(it.type === 'accessory'){
      const b = document.createElement('button'); b.className = 'btn'; b.textContent = S.equipped === S.selItem ? 'Unequip' : 'Equip'; b.onclick = useSelected; row.appendChild(b);
    } else {
      info.innerHTML += '<br><span style="color:#8f7f9b">A quest item. It will be used automatically.</span>';
    }
  } else if(ids.length){
    info.textContent = 'Select an item to use or equip it.';
  }
  const close = document.createElement('button'); close.className = 'btn'; close.textContent = 'Close (I)'; close.onclick = hideModal; row.appendChild(close);
  const ch = document.createElement('div'); ch.className = 'close-hint'; ch.textContent = 'One accessory can be equipped at a time.'; modalInner.appendChild(ch);
}
function useSelected(){
  const id = S.selItem; if(!id) return;
  const it = ITEMS[id];
  if(it.type === 'consumable'){
    if(id === 'potion') usePotion();
    else if(id === 'bomb') placeBomb();
  } else if(it.type === 'accessory'){
    S.equipped = S.equipped === id ? null : id;
    sfx('switch');
    toast(S.equipped === id ? it.name + ' equipped' : it.name + ' unequipped', '#7dff9a');
  }
  S.selItem = null;
  renderInventory();
}
function usePotion(){
  const pl = S.player;
  if((S.inv.potion||0) <= 0){ toast('No potions. Buy one from the merchant!', '#8f7f9b'); return; }
  if(pl.hp >= pl.maxHp){ toast('Health is already full.', '#8f7f9b'); return; }
  S.inv.potion--;
  pl.hp = Math.min(pl.maxHp, pl.hp + 6);
  sfx('heart'); burst(pl.x, pl.y, '#ff6b6b', 16, 90, {type:'flame'});
  toast('Quaffed a Red Potion. +6 HP', '#ff6b6b');
  if(S.modal === 'inventory') renderInventory();
}
function toggleWeapon(){
  const pl = S.player;
  if(!pl.hasBow){ toast('You have not discovered the bow yet.', '#8f7f9b'); return; }
  pl.bowEquipped = !pl.bowEquipped;
  sfx('switch');
  toast(pl.bowEquipped ? 'Windcaller Bow ready' : 'Sword ready', '#9fd8ff');
}
function startDash(){
  const pl = S.player;
  if(S.dialog || S.modal || S.transition) return;
  if(pl.dashCd > 0 || pl.stamina < 25 || pl.dashT > 0) return;
  pl.dashDir = playerAim();
  pl.dashT = 0.16; pl.dashCd = 0.5;
  pl.stamina -= 25;
  pl.invuln = Math.max(pl.invuln, 0.2);
  sfx('swing');
  for(let i=0;i<6;i++) particle(pl.x, pl.y+4, {type:'smoke', size:rnd(2,4), life:0.35, color:'rgba(200,210,230,0.4)', vx:rnd(-20,20), vy:rnd(-18,-4)});
}

/* ---------- shop ---------- */
function openShop(){ S.modal = 'shop'; renderShop(); showModal(); }
function renderShop(){
  modalInner.innerHTML = '';
  const h = document.createElement('h2'); h.textContent = 'Merchant Mira — ✦ ' + S.rupees; modalInner.appendChild(h);
  const list = document.createElement('div'); list.id = 'shopList'; modalInner.appendChild(list);
  SHOP_STOCK.forEach(id => {
    const it = ITEMS[id];
    const rowEl = document.createElement('div'); rowEl.className = 'shopItem';
    const owned = (S.inv[id]||0) > 0 && it.type === 'accessory';
    rowEl.innerHTML = '<span class="ic">' + it.icon + '</span><span class="nm">' + it.name + '<small>' + it.desc + '</small></span><span class="pr">' + (owned ? 'SOLD' : it.price + ' ✦') + '</span>';
    if(!owned){
      const b = document.createElement('button'); b.className = 'btn buyBtn'; b.textContent = 'Buy'; b.onclick = ()=> buyItem(id);
      rowEl.appendChild(b);
    }
    list.appendChild(rowEl);
  });
  const row = document.createElement('div'); row.className = 'row';
  const close = document.createElement('button'); close.className = 'btn'; close.textContent = 'Close (Esc)'; close.onclick = hideModal;
  row.appendChild(close);
  modalInner.appendChild(row);
  const ch = document.createElement('div'); ch.className = 'close-hint'; ch.textContent = 'Accessories are one of a kind. Consumables restock freely.'; modalInner.appendChild(ch);
}
function buyItem(id){
  const it = ITEMS[id];
  if(it.type === 'accessory' && (S.inv[id]||0) > 0){ toast('Already owned.', '#8f7f9b'); return; }
  if(S.rupees < it.price){ toast('Not enough rupees!', '#ff6b6b'); sfx('hurt'); return; }
  S.rupees -= it.price;
  grantItem(id, 1);
  sfx('shop');
  renderShop();
}

/* ---------- pause ---------- */
function openPause(){ S.modal = 'pause'; renderPause(); showModal(); }
function renderPause(){
  modalInner.innerHTML = '';
  const h = document.createElement('h2'); h.textContent = 'Paused'; modalInner.appendChild(h);
  const txt = document.createElement('div'); txt.style.cssText = 'font-size:11px;color:#cfc0d8;line-height:1.7;text-align:center;';
  txt.innerHTML = 'WASD move · SPACE attack · SHIFT sprint · K dash<br>E interact · Q swap weapon · H potion · B bomb<br>I inventory · M mute · Esc resume';
  modalInner.appendChild(txt);
  const row = document.createElement('div'); row.className = 'row';
  const b = document.createElement('button'); b.className = 'btn primary'; b.textContent = 'Resume'; b.onclick = hideModal; row.appendChild(b);
  const m = document.createElement('button'); m.className = 'btn'; m.textContent = S.muted ? 'Unmute' : 'Mute'; m.onclick = ()=>{ toggleMute(); renderPause(); }; row.appendChild(m);
  modalInner.appendChild(row);
  const ch = document.createElement('div'); ch.className = 'close-hint'; ch.textContent = 'Progress is saved only at glowing shrines.'; modalInner.appendChild(ch);
}

/* ---------- save system ---------- */
const SAVE_KEY = 'emberheart_save_v1';
function persistSave(){
  const pl = S.player;
  const data = {
    v:1, mapId:S.mapId, px:pl.x/16, py:pl.y/16, hp:pl.hp, maxHp:pl.maxHp,
    rupees:S.rupees, inv:S.inv, equipped:S.equipped, weapon:pl.weapon,
    bow:pl.hasBow, bowEquipped:pl.bowEquipped, flags:S.flags, tilesOver:S.tilesOver,
    shopSold:S.shopSold, respawn:S.respawn, kills:S.kills, time:S.time,
  };
  try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); return true; }
  catch(e){ toast('Could not save (local storage blocked).', '#ff6b6b'); return false; }
}
function saveAtShrine(){
  const pl = S.player;
  pl.hp = pl.maxHp;
  S.respawn = { map:S.mapId, x:Math.round(pl.x/16), y:Math.round(pl.y/16) };
  persistSave();
  sfx('save');
  burst(pl.x, pl.y, '#7fd8e8', 16, 70);
  toast('Progress saved at the shrine. Health restored.', '#7fd8e8');
}
function loadSave(){
  let raw = null;
  try { raw = localStorage.getItem(SAVE_KEY); } catch(e){}
  if(!raw) return false;
  let d = null;
  try { d = JSON.parse(raw); } catch(e){ return false; }
  if(!d || d.v !== 1 || !MAPS[d.mapId]) return false;
  resetState();
  S.flags = d.flags || {}; S.tilesOver = d.tilesOver || {}; S.shopSold = d.shopSold || [];
  S.inv = d.inv || {}; S.equipped = d.equipped || null; S.rupees = d.rupees || 0;
  S.kills = d.kills || 0; S.time = d.time || 0;
  S.player = createPlayer(d.px || 16, d.py || 14);
  S.player.hp = d.hp; S.player.maxHp = d.maxHp;
  S.player.weapon = d.weapon || 'wood';
  S.player.hasBow = !!d.bow; S.player.bowEquipped = !!d.bowEquipped;
  loadMap(d.mapId, d.px || 16, d.py || 14);
  S.respawn = d.respawn || { map:d.mapId, x:d.px, y:d.py };
  return true;
}
function respawnFromSave(){
  deathEl.classList.add('hidden');
  if(loadSave()){ S.started = true; S.mode = 'play'; Music.start(); }
  else newGame();
}

/* ---------- game lifecycle ---------- */
function resetState(){
  S.flags = {}; S.tilesOver = {}; S.shopSold = [];
  S.inv = {}; S.equipped = null; S.rupees = 20;
  S.respawn = { map:'village', x:16, y:14 };
  S.kills = 0; S.time = 0; S.deathTimer = 0;
  S.projectiles = []; S.pickups = []; S.particles = []; S.texts = []; S.ents = [];
  S.dialog = null; S.modal = null; S.transition = null;
  S.flash = 0; S.shake = 0;
}
function newGame(){
  resetState();
  S.player = createPlayer(16, 14);
  S.started = true; S.mode = 'play';
  loadMap('village', 16, 14);
  updateObjective();
  titleEl.classList.add('hidden'); deathEl.classList.add('hidden'); winEl.classList.add('hidden');
  hideModal();
  AudioSys.ensure(); Music.start();
}
function continueGame(){
  if(loadSave()){
    S.started = true; S.mode = 'play';
    titleEl.classList.add('hidden'); deathEl.classList.add('hidden'); winEl.classList.add('hidden');
    AudioSys.ensure(); Music.start();
    updateObjective();
    toast('Welcome back, traveler.', '#7fd8e8');
  } else newGame();
}
function winGame(){
  S.mode = 'won';
  const m = Math.floor(S.time/60), s = Math.floor(S.time%60);
  S.wonStats = 'Time ' + m + ':' + String(s).padStart(2,'0') + ' · Enemies defeated ' + S.kills + ' · Rupees held ' + S.rupees;
  Music.stop(); sfx('win');
  winStats.textContent = S.wonStats;
  winEl.classList.remove('hidden');
}
function toggleMute(){
  S.muted = !S.muted;
  AudioSys.enabled = !S.muted;
  toast(S.muted ? 'Sound muted' : 'Sound on', '#cfc0d8');
}

/* ---------- input dispatch ---------- */
function onKeyDown(code){
  AudioSys.ensure();
  if(code === 'KeyM'){ toggleMute(); return; }
  if(S.mode === 'title'){
    if(code === 'Enter' || code === 'NumpadEnter') newGame();
    return;
  }
  if(S.mode === 'dead' || S.mode === 'won') return;
  if(code === 'Escape'){
    if(S.dialog){ S.dialog = null; return; }
    if(S.modal){ hideModal(); return; }
    openPause(); return;
  }
  if(S.dialog){ if(code === 'KeyE' || code === 'Space') advanceDialog(); return; }
  if(S.modal){
    if(code === 'KeyE' || code === 'KeyI' || code === 'Escape') hideModal();
    return;
  }
  if(S.transition) return;
  if(code === 'KeyE') performInteract();
  else if(code === 'KeyI') openInventory();
  else if(code === 'KeyQ') toggleWeapon();
  else if(code === 'KeyH') usePotion();
  else if(code === 'KeyB') placeBomb();
  else if(code === 'KeyK') startDash();
}

/* ---------- update / render ---------- */
function updateEnemies(dt){
  for(const e of S.ents) if(e.enemy) updateEnemy(e, dt);
  S.ents = S.ents.filter(e => !(e.enemy && e.dead));
}
function update(dt){
  updateTransition(dt);
  updateParticles(dt);
  updateTexts(dt);
  S.shake = Math.max(0, S.shake - dt*42);
  S.flash = Math.max(0, S.flash - dt);
  if(S.mode === 'play' && S.started){
    S.time += dt;
    if(!S.transition && !S.dialog && !S.modal){
      updatePlayer(dt);
      updateEnts(dt);
      updateEnemies(dt);
      updateBombs(dt);
      updateProjectiles(dt);
      updatePickups(dt);
    }
    updateDialog(dt);
  } else if(S.mode === 'dead'){
    S.deathTimer -= dt;
    if(S.deathTimer < 1.3) deathEl.classList.remove('hidden');
  }
  renderDialogue();
}
function render(){
  ctx.fillStyle = '#05060a';
  ctx.fillRect(0, 0, VIEW_W, VIEW_H);
  if(!S.map || !S.tiles.length) return;
  ctx.save();
  if(S.shake > 0.05) ctx.translate((Math.random()-0.5)*S.shake, (Math.random()-0.5)*S.shake);
  renderWorld();
  drawPickups();
  for(const e of S.ents){
    if(e.dead) continue;
    if(e.enemy) drawEnemy(e);
    else if(e.type === 'bomb') drawBomb(e);
    else drawEntity(e);
  }
  drawProjectiles();
  drawPlayer();
  drawParticles();
  drawTexts();
  ctx.restore();
  drawHUD();
  if(S.flash > 0){
    ctx.fillStyle = 'rgba(224,43,43,' + clamp(S.flash*0.55,0,0.5) + ')';
    ctx.fillRect(0, 0, VIEW_W, VIEW_H);
  }
  const vg = ctx.createRadialGradient(VIEW_W/2, VIEW_H/2, VIEW_H*0.45, VIEW_W/2, VIEW_H/2, VIEW_H*0.85);
  vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.42)');
  ctx.fillStyle = vg; ctx.fillRect(0, 0, VIEW_W, VIEW_H);
  if(S.transition){
    const tr = S.transition;
    const a = tr.phase === 0 ? clamp(tr.t/0.26, 0, 1) : clamp(1-(tr.t-0.26)/0.26, 0, 1);
    document.getElementById('fade').style.opacity = String(a);
  } else document.getElementById('fade').style.opacity = '0';
}

/* ---------- boot ---------- */
let last = performance.now();
function frame(now){
  const dt = Math.min(0.04, (now - last)/1000);
  last = now;
  S.time += dt;
  update(dt);
  render();
  pressed.clear();
  requestAnimationFrame(frame);
}
function boot(){
  fit();
  document.getElementById('btnNew').addEventListener('click', ()=>{ AudioSys.ensure(); newGame(); });
  document.getElementById('btnCont').addEventListener('click', ()=>{ AudioSys.ensure(); continueGame(); });
  document.getElementById('btnRespawn').addEventListener('click', respawnFromSave);
  document.getElementById('btnAgain').addEventListener('click', ()=>{ newGame(); });
  // title backdrop: show the village behind the menu
  S.mapId = 'village'; S.map = MAPS.village; rebuildTiles();
  updateObjective();
  let hasSave = false;
  try { hasSave = !!localStorage.getItem(SAVE_KEY); } catch(e){}
  document.getElementById('btnCont').disabled = !hasSave;
  requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>