← RPG results

RPG

Claude Opus 4.8 · composer

Initial view of RPG

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

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 40,814 bytes · SHA-256 716e5fee5567
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberlight — An Action Adventure</title>
<style>
/* ── css: base ── */
* { margin:0; padding:0; box-sizing:border-box; }
html,body { height:100%; background:#0b0d14; overflow:hidden;
  font-family:'Trebuchet MS','Segoe UI',sans-serif; color:#e6e9f0; }
#app { position:relative; width:960px; height:600px; margin:0 auto; top:50%; transform:translateY(-50%); }
canvas#game { display:block; width:960px; height:600px; background:#12151f;
  border:2px solid #2a3350; border-radius:6px; image-rendering:pixelated;
  box-shadow:0 0 60px rgba(90,120,220,.15); }
.hidden { display:none !important; }
/* ── css: hud ── */
#hud { position:absolute; top:14px; left:16px; right:16px; display:flex;
  align-items:center; gap:16px; pointer-events:none; z-index:5;
  text-shadow:0 2px 4px rgba(0,0,0,.8); }
#hearts { display:flex; gap:5px; }
.heart { width:26px; height:24px; position:relative; filter:drop-shadow(0 1px 1px #000); }
#money { font-size:20px; font-weight:bold; color:#ffd45e; }
#weaponBadge { margin-left:auto; background:rgba(20,26,44,.85); border:1px solid #384268;
  padding:6px 14px; border-radius:20px; font-size:14px; font-weight:bold; letter-spacing:.5px; }
#weaponName { color:#8fd0ff; }
#progressWrap { position:absolute; top:44px; right:16px; width:180px; height:6px;
  background:rgba(30,36,58,.8); border-radius:3px; overflow:hidden; }
#progressBar { height:100%; width:0%; background:linear-gradient(90deg,#5a78dc,#8fd0ff);
  transition:width .5s ease; }
/* ── css: overlays ── */
#overlay { position:absolute; inset:0; z-index:20;
  background:radial-gradient(circle at 50% 40%,#1a2038,#080a12);
  display:flex; align-items:center; justify-content:center; }
.center { text-align:center; }
#overlay h1 { font-size:72px; letter-spacing:10px; font-weight:900;
  background:linear-gradient(180deg,#ffd45e,#ff7a3c); -webkit-background-clip:text;
  background-clip:text; color:transparent; filter:drop-shadow(0 4px 20px rgba(255,140,50,.4)); }
.sub { color:#8b93ad; margin:6px 0 26px; letter-spacing:3px; font-size:14px; }
.controls { display:grid; grid-template-columns:1fr 1fr; gap:8px 30px; margin-bottom:26px;
  font-size:13px; color:#c4cbe0; }
.controls span { background:rgba(255,255,255,.04); padding:7px 12px; border-radius:6px;
  border:1px solid rgba(255,255,255,.06); }
.controls b { color:#8fd0ff; }
#startBtn { font-size:20px; font-weight:bold; padding:12px 44px; cursor:pointer;
  border:none; border-radius:8px; color:#0b0d14; letter-spacing:2px;
  background:linear-gradient(180deg,#ffd45e,#ff9a3c); transition:transform .1s; }
#startBtn:hover { transform:scale(1.05); }
.hint { color:#6b melon; color:#707892; font-size:12px; margin-top:16px; }
#toast { position:absolute; bottom:70px; left:50%; transform:translateX(-50%);
  z-index:15; display:flex; flex-direction:column; gap:6px; align-items:center;
  pointer-events:none; }
.toastItem { background:rgba(15,19,32,.92); border:1px solid #3a4468; color:#e6e9f0;
  padding:8px 18px; border-radius:20px; font-size:14px; animation:toastIn .3s ease;
  box-shadow:0 4px 20px rgba(0,0,0,.5); }
.toastItem.good { border-color:#5fd08a; color:#aef0c4; }
.toastItem.gold { border-color:#ffd45e; color:#ffe89a; }
@keyframes toastIn { from{opacity:0; transform:translateY(12px);} to{opacity:1;} }
#prompt { position:absolute; bottom:24px; left:50%; transform:translateX(-50%);
  z-index:12; background:rgba(15,19,32,.9); border:1px solid #4a5688;
  padding:9px 20px; border-radius:8px; font-size:14px; }
#prompt b { color:#ffd45e; }
/* ── css: panels ── */
.hint { color:#707892; font-size:12px; margin-top:16px; text-align:center; }
#inventory, #shop { position:absolute; inset:0; z-index:18;
  background:rgba(6,8,14,.75); display:flex; align-items:center; justify-content:center;
  backdrop-filter:blur(2px); }
.panel { background:linear-gradient(180deg,#1a2036,#12151f); border:2px solid #38436a;
  border-radius:12px; padding:22px 26px; min-width:420px; max-width:520px;
  box-shadow:0 20px 60px rgba(0,0,0,.6); }
.panel h2 { color:#ffd45e; letter-spacing:2px; margin-bottom:16px; text-align:center; }
#invGrid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; }
.slot { aspect-ratio:1; background:rgba(255,255,255,.03); border:1px solid #333c5e;
  border-radius:8px; display:flex; flex-direction:column; align-items:center;
  justify-content:center; cursor:pointer; position:relative; transition:.12s; }
.slot.filled:hover { border-color:#8fd0ff; background:rgba(143,208,255,.08); transform:translateY(-2px); }
.slot .ico { font-size:26px; line-height:1; }
.slot .qty { position:absolute; bottom:3px; right:6px; font-size:11px; font-weight:bold; color:#ffd45e; }
.slot .nm { font-size:9px; color:#9aa3bf; margin-top:3px; text-align:center; padding:0 2px; }
#shopList { display:flex; flex-direction:column; gap:8px; }
.shopRow { display:flex; align-items:center; gap:12px; padding:10px 12px;
  background:rgba(255,255,255,.03); border:1px solid #333c5e; border-radius:8px; }
.shopRow .sIco { font-size:24px; }
.shopRow .sInfo { flex:1; }
.shopRow .sName { font-weight:bold; color:#e6e9f0; }
.shopRow .sDesc { font-size:11px; color:#8b93ad; }
.shopRow .sBuy { background:linear-gradient(180deg,#ffd45e,#ff9a3c); color:#0b0d14;
  border:none; padding:7px 14px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:13px; }
.shopRow .sBuy:disabled { background:#2a3048; color:#5a6280; cursor:not-allowed; }
.shopRow.sold { opacity:.4; }
</style>
</head>
<body>
<div id="app">
  <canvas id="game" width="960" height="600"></canvas>
  <div id="hud">
    <div id="hearts"></div>
    <div id="money">◈ <span id="moneyVal">0</span></div>
    <div id="weaponBadge"><span id="weaponName">Rusty Sword</span></div>
    <div id="progressWrap"><div id="progressBar"></div></div>
  </div>
  <div id="toast"></div>
  <div id="prompt" class="hidden"></div>
  <div id="inventory" class="hidden">
    <div class="panel">
      <h2>Inventory</h2>
      <div id="invGrid"></div>
      <p class="hint">[I] close &nbsp; click item to use</p>
    </div>
  </div>
  <div id="shop" class="hidden">
    <div class="panel">
      <h2>Wandering Merchant</h2>
      <div id="shopList"></div>
      <p class="hint">[E] leave</p>
    </div>
  </div>
  <div id="overlay">
    <div class="center">
      <h1>EMBERLIGHT</h1>
      <p class="sub">A short action-adventure</p>
      <div class="controls">
        <span><b>WASD / Arrows</b> move</span>
        <span><b>J / Space</b> attack</span>
        <span><b>K</b> ranged (when found)</span>
        <span><b>E</b> interact · <b>I</b> inventory</span>
      </div>
      <button id="startBtn">Begin</button>
      <p class="hint">Reach 5 health · upgrade your blade twice · uncover the bow · survive the depths</p>
    </div>
  </div>
</div>
<script>
'use strict';

// ── const: Sfx ──
// ---- Web Audio: tiny synth for all SFX ----
const Sfx = (function(){
  let ctx = null;
  function ac(){ if(!ctx) ctx = new (window.AudioContext||window.webkitAudioContext)(); return ctx; }
  function tone(freq, dur, type, vol, slideTo){
    const c = ac(); if(c.state==='suspended') c.resume();
    const o = c.createOscillator(), g = c.createGain();
    o.type = type||'square'; o.frequency.value = freq;
    if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, c.currentTime+dur);
    g.gain.value = 0.0001;
    g.gain.exponentialRampToValueAtTime(vol||0.15, c.currentTime+0.005);
    g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime+dur);
    o.connect(g); g.connect(c.destination);
    o.start(); o.stop(c.currentTime+dur+0.02);
  }
  function noise(dur, vol, hp){
    const c = ac(); if(c.state==='suspended') c.resume();
    const n = Math.floor(c.sampleRate*dur), buf = c.createBuffer(1,n,c.sampleRate);
    const d = buf.getChannelData(0);
    for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
    const s = c.createBufferSource(); s.buffer=buf;
    const g = c.createGain(); g.gain.value=vol||0.12;
    const f = c.createBiquadFilter(); f.type='highpass'; f.frequency.value=hp||600;
    s.connect(f); f.connect(g); g.connect(c.destination); s.start();
  }
  return {
    step(){ tone(90+Math.random()*20, 0.06, 'triangle', 0.04); },
    swing(){ tone(320,0.09,'sawtooth',0.09,140); },
    heavy(){ tone(180,0.16,'sawtooth',0.13,70); noise(0.1,0.06); },
    fire(){ tone(680,0.1,'square',0.08,1200); },
    hit(){ noise(0.09,0.14,400); tone(160,0.08,'square',0.08,80); },
    hurt(){ tone(220,0.22,'sawtooth',0.14,90); noise(0.12,0.08,200); },
    pickup(){ tone(660,0.08,'square',0.1); setTimeout(()=>tone(990,0.1,'square',0.1),70); },
    coin(){ tone(880,0.06,'square',0.08); setTimeout(()=>tone(1320,0.08,'square',0.08),50); },
    shop(){ tone(520,0.09,'triangle',0.1); setTimeout(()=>tone(780,0.12,'triangle',0.1),90); },
    save(){ tone(440,0.14,'sine',0.12); setTimeout(()=>tone(660,0.14,'sine',0.12),120); setTimeout(()=>tone(880,0.2,'sine',0.12),240); },
    upgrade(){ [523,659,784,1047].forEach((f,i)=>setTimeout(()=>tone(f,0.18,'triangle',0.12),i*90)); },
    die(){ tone(300,0.5,'sawtooth',0.16,60); }
  };
})();

// ── class: Particles ──
// ---- Particle system ----
class Particles {
  constructor(){ this.list = []; }
  burst(x,y,color,count,opts={}){
    const spd = opts.speed||3, life = opts.life||30, size = opts.size||3;
    for(let i=0;i<count;i++){
      const a = Math.random()*Math.PI*2, s = spd*(0.4+Math.random()*0.8);
      this.list.push({ x,y, vx:Math.cos(a)*s, vy:Math.sin(a)*s,
        life:life*(0.6+Math.random()*0.6), max:life, color,
        size:size*(0.6+Math.random()*0.8), grav:opts.grav||0 });
    }
  }
  ring(x,y,color,count,radius){
    for(let i=0;i<count;i++){
      const a = (i/count)*Math.PI*2;
      this.list.push({ x,y, vx:Math.cos(a)*radius*0.12, vy:Math.sin(a)*radius*0.12,
        life:24, max:24, color, size:3, grav:0 });
    }
  }
  update(){
    for(let i=this.list.length-1;i>=0;i--){
      const p = this.list[i];
      p.x+=p.vx; p.y+=p.vy; p.vy+=p.grav; p.vx*=0.94; p.vy*=0.94; p.life--;
      if(p.life<=0) this.list.splice(i,1);
    }
  }
  draw(ctx,cam){
    for(const p of this.list){
      const a = Math.max(0,p.life/p.max);
      ctx.globalAlpha = a;
      ctx.fillStyle = p.color;
      const s = p.size*a;
      ctx.fillRect(p.x-cam.x-s/2, p.y-cam.y-s/2, s, s);
    }
    ctx.globalAlpha = 1;
  }
}

// ── const: CFG ──
// ---- Static config ----
const CFG = {
  tile: 48,
  playerSpeed: 2.4,
  worldW: 40, worldH: 30
};

// ── const: MELEE ──
// Melee weapon tiers. Index 0 = start, 1 & 2 = the two upgrades.
const MELEE = [
  { name:'Rusty Sword',  dmg:1, range:52, arc:1.2, cd:26, color:'#b8b0a0', knock:5 },
  { name:'Iron Blade',   dmg:2, range:60, arc:1.5, cd:20, color:'#d8e0ff', knock:8 },
  { name:'Ember Glaive', dmg:3, range:78, arc:2.0, cd:16, color:'#ff9a3c', knock:12, burn:true }
];

// ── const: BOW ──
const BOW = { name:'Hunter\'s Bow', dmg:2, cd:22, speed:7, range:520 };

// ── const: ITEMS ──
// Purchasable / findable items
const ITEMS = {
  potion:   { name:'Health Potion', ico:'🧪', desc:'Restore 1 heart',        price:18, consumable:true },
  bomb:     { name:'Fire Bomb',     ico:'💣', desc:'Hurl for area damage',   price:22, consumable:true },
  charm:    { name:'Swift Charm',   ico:'🪶', desc:'+15% move speed',        price:35, passive:true },
  amulet:   { name:'Iron Amulet',   ico:'🛡️', desc:'Take 1 less damage min', price:45, passive:true },
  torch:    { name:'Everburn Torch',ico:'🔥', desc:'Reveals dark chambers',  price:15, passive:true },
  key:      { name:'Rune Key',      ico:'🗝️', desc:'Opens the sealed depth', found:true },
  heartpc:  { name:'Heart Vessel',  ico:'💗', desc:'+1 max health',          found:true }
};

// ── function: buildWorld ──
// ---- World: hand-tuned zones on a tile grid ----
// tiles: 0 floor, 1 wall, 2 floor-alt(deco), 3 dark-floor(depths)
function buildWorld(){
  const W=CFG.worldW, H=CFG.worldH;
  const g = [];
  for(let y=0;y<H;y++){ g.push([]); for(let x=0;x<W;x++){
    const border = (x===0||y===0||x===W-1||y===H-1);
    g[y][x] = border?1:0;
  }}
  // internal walls carving three horizontal zones with gaps
  const rock=(x1,y1,x2,y2)=>{ for(let y=y1;y<=y2;y++)for(let x=x1;x<=x2;x++) if(g[y]&&g[y][x]!==undefined) g[y][x]=1; };
  // zone divider between grove (top) and ruins (mid)
  rock(1,10,17,10); rock(23,10,38,10);       // gap 18-22
  // zone divider between ruins and depths
  rock(1,20,13,20); rock(19,20,38,20);        // gap 14-18
  // scatter pillars in ruins
  [[6,14],[10,14],[6,16],[10,16],[28,13],[32,13],[28,16],[32,16]].forEach(([x,y])=>rock(x,y,x,y));
  // depths marked dark
  for(let y=21;y<H-1;y++)for(let x=1;x<W-1;x++) if(g[y][x]===0) g[y][x]=3;
  // deco patches in grove
  [[4,4],[7,3],[12,6],[30,4],[34,7],[20,3]].forEach(([x,y])=>{ if(g[y][x]===0) g[y][x]=2; });
  // sealed door into depths center (wall until key)
  return { g, W, H };
}

// ── function: isSolid ──
function isSolid(world, tx, ty){
  if(tx<0||ty<0||tx>=world.W||ty>=world.H) return true;
  const t = world.g[ty][tx];
  return t===1;
}

// ── function: initEntities ──
// ---- Entity spawns: pickups, enemies, interactables, per zone ----
function initEntities(){
  const T = CFG.tile, c = (tx,ty)=>({x:tx*T+T/2, y:ty*T+T/2});
  const enemies = [];
  const mkEnemy=(tx,ty,type)=>{
    const base = c(tx,ty);
    const stats = type==='brute' ? {hp:6,spd:1.0,dmg:2,r:16,color:'#c0553c',touch:true}
                : type==='shade' ? {hp:4,spd:1.7,dmg:1,r:12,color:'#7a5ccc',touch:true}
                : {hp:3,spd:1.3,dmg:1,r:12,color:'#5c8a4c',touch:true}; // slime
    enemies.push({ ...base, ...stats, maxhp:stats.hp, type, hitT:0, alive:true, wanderT:0, vx:0, vy:0 });
  };
  // grove (easy)
  mkEnemy(6,4,'slime'); mkEnemy(10,6,'slime'); mkEnemy(30,5,'slime'); mkEnemy(34,4,'slime');
  // ruins (medium)
  mkEnemy(7,14,'shade'); mkEnemy(11,15,'shade'); mkEnemy(29,14,'brute'); mkEnemy(33,15,'shade');
  // depths (hard)
  mkEnemy(8,24,'brute'); mkEnemy(14,26,'shade'); mkEnemy(24,25,'brute'); mkEnemy(30,24,'shade'); mkEnemy(20,27,'brute');

  const pickups = [
    // coins scattered
    ...[[3,3],[14,4],[33,7],[20,5],[5,8],[36,3]].map(([x,y])=>({...c(x,y),kind:'coin',amt:6,r:10})),
    ...[[8,15],[31,13],[16,17],[4,17]].map(([x,y])=>({...c(x,y),kind:'coin',amt:10,r:10})),
    ...[[10,24],[26,26],[35,22],[18,28]].map(([x,y])=>({...c(x,y),kind:'coin',amt:14,r:10})),
    // found items
    {...c(12,3), kind:'item', item:'potion', r:12},
    {...c(35,5), kind:'item', item:'torch', r:12},
    {...c(16,17), kind:'item', item:'bomb', r:12},
  ];

  const interact = [
    // weapon upgrade shrines
    {...c(15,4),  kind:'shrine', tier:1, used:false, r:20, label:'Blade Shrine'},
    {...c(29,26), kind:'shrine', tier:2, used:false, r:20, label:'Ember Forge'},
    // heart vessels (3->4->5)
    {...c(33,15), kind:'heart', used:false, r:16, label:'Heart Vessel'},
    {...c(24,25), kind:'heart', used:false, r:16, label:'Heart Vessel'},
    // bow discovery — mid-game, in ruins, gated to appear after half progress
    {...c(20,14), kind:'bow', used:false, r:18, label:'Ancient Bow'},
    // rune key + sealed door
    {...c(4,17),  kind:'key', used:false, r:16, label:'Rune Key'},
    {...c(16,20), kind:'door', open:false, r:22, label:'Sealed Depths'},
    // save points
    {...c(9,7),  kind:'save', r:20, label:'Save Stone'},
    {...c(30,17),kind:'save', r:20, label:'Save Stone'},
    {...c(20,24),kind:'save', r:20, label:'Save Stone'},
    // shop
    {...c(24,4), kind:'shop', r:26, label:'Merchant'},
  ];
  return { enemies, pickups, interact };
}

// ── function: drawWorld ──
// ---- Rendering helpers ----
function drawWorld(ctx, world, cam, G){
  const T=CFG.tile;
  const x0=Math.floor(cam.x/T), y0=Math.floor(cam.y/T);
  const x1=x0+Math.ceil(960/T)+1, y1=y0+Math.ceil(600/T)+1;
  for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
    if(x<0||y<0||x>=world.W||y>=world.H) continue;
    const t=world.g[y][x], px=x*T-cam.x, py=y*T-cam.y;
    let fill;
    if(t===1) fill='#2a3050';
    else if(t===3) fill = G.hasTorch ? '#1c1626' : '#0d0a14';
    else if(t===2) fill='#1a3524';
    else fill='#182238';
    ctx.fillStyle=fill; ctx.fillRect(px,py,T,T);
    if(t===1){ ctx.fillStyle='#3a4470'; ctx.fillRect(px,py,T,4); }
    else {
      ctx.fillStyle='rgba(0,0,0,.12)'; ctx.fillRect(px,py,T,1); ctx.fillRect(px,py,1,T);
      if(t===2){ ctx.fillStyle='#2c6b3c'; ctx.beginPath(); ctx.arc(px+T/2,py+T/2,7,0,7); ctx.fill(); }
      if(t===3 && G.hasTorch){ ctx.fillStyle='rgba(255,140,60,.05)'; ctx.fillRect(px,py,T,T); }
    }
  }
}

// ── function: drawPlayer ──
function drawPlayer(ctx, p, G){
  const sx=p.x-G.cam.x, sy=p.y-G.cam.y;
  // shadow
  ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(sx,sy+16,12,5,0,0,7); ctx.fill();
  // damage flash
  const hurt = G.player.invT>0 && Math.floor(G.time/4)%2===0;
  // walk bob
  const bob = p.moving ? Math.sin(G.time*(0.25+G.moveStyle*0.1))*2 : 0;
  // body
  ctx.fillStyle = hurt ? '#ff5a5a' : '#5a9ae0';
  ctx.fillRect(sx-9, sy-16+bob, 18, 26);
  // head
  ctx.fillStyle = hurt ? '#ffb0b0' : '#f0c8a0';
  ctx.fillRect(sx-7, sy-24+bob, 14, 10);
  // legs (animate)
  ctx.fillStyle='#2a3a5a';
  const legSwing = p.moving ? Math.sin(G.time*0.25)*4 : 0;
  ctx.fillRect(sx-8, sy+8, 6, 8+legSwing*0.5);
  ctx.fillRect(sx+2, sy+8, 6, 8-legSwing*0.5);
  // weapon in hand — colour/shape reflects tier
  const w = MELEE[G.tier];
  const dir = p.face;
  ctx.save();
  ctx.translate(sx+dir*10, sy-4+bob);
  ctx.rotate(G.attack.t>0 ? dir*(G.attack.t/G.attack.max)*w.arc - dir*0.6 : dir*0.2);
  ctx.fillStyle=w.color;
  ctx.fillRect(0, -2, dir*(w.range*0.42), 4+G.tier);
  if(G.tier===2){ ctx.fillStyle='rgba(255,150,60,.5)'; ctx.fillRect(0,-4,dir*(w.range*0.42),8+G.tier); }
  ctx.restore();
  // bow indicator
  if(G.hasBow){ ctx.fillStyle='#c89a5a'; ctx.fillRect(sx-dir*8, sy-2+bob, 3, 12); }
}

// ── function: drawEnemy ──
// ---- Draw enemies, pickups, interactables, projectiles ----
function drawEnemy(ctx, e, G){
  const sx=e.x-G.cam.x, sy=e.y-G.cam.y;
  ctx.fillStyle='rgba(0,0,0,.3)'; ctx.beginPath(); ctx.ellipse(sx,sy+e.r-2,e.r,e.r*0.4,0,0,7); ctx.fill();
  const flash = e.hitT>0 && Math.floor(G.time/2)%2===0;
  ctx.fillStyle = flash ? '#ffffff' : e.color;
  const wob = Math.sin(G.time*0.15+e.x)*2;
  if(e.type==='slime'){
    ctx.beginPath(); ctx.ellipse(sx,sy+wob*0.5,e.r,e.r-2+wob,0,0,7); ctx.fill();
  } else if(e.type==='shade'){
    ctx.beginPath(); ctx.moveTo(sx,sy-e.r-2); ctx.lineTo(sx+e.r,sy+e.r); ctx.lineTo(sx-e.r,sy+e.r); ctx.closePath(); ctx.fill();
  } else {
    ctx.fillRect(sx-e.r,sy-e.r+wob*0.4,e.r*2,e.r*2);
    ctx.fillStyle='#ff2a2a'; ctx.fillRect(sx-6,sy-5,3,3); ctx.fillRect(sx+3,sy-5,3,3);
  }
  // hp bar
  if(e.hp<e.maxhp){
    ctx.fillStyle='#000'; ctx.fillRect(sx-e.r,sy-e.r-8,e.r*2,4);
    ctx.fillStyle='#e04c4c'; ctx.fillRect(sx-e.r,sy-e.r-8,e.r*2*(e.hp/e.maxhp),4);
  }
}

// ── function: drawInteract ──
function drawInteract(ctx, o, G){
  if(o.hidden) return;
  const sx=o.x-G.cam.x, sy=o.y-G.cam.y, pulse=1+Math.sin(G.time*0.08)*0.12;
  const glyph = { shrine:'⛩️', heart:'💗', bow:'🏹', key:'🗝️', door:'🚪', save:'💠', shop:'🏪' }[o.kind];
  if((o.kind==='shrine'||o.kind==='heart'||o.kind==='bow'||o.kind==='key') && o.used) return;
  if(o.kind==='door' && o.open){
    ctx.fillStyle='#182238'; ctx.fillRect(sx-24,sy-24,48,48); return;
  }
  ctx.save();
  ctx.globalAlpha = 0.35;
  ctx.fillStyle = o.kind==='save'?'#5fd0c4':o.kind==='shop'?'#ffd45e':'#8fd0ff';
  ctx.beginPath(); ctx.arc(sx,sy,o.r*pulse,0,7); ctx.fill();
  ctx.restore();
  ctx.font='24px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillText(glyph||'❓', sx, sy);
}

// ── function: drawPickup ──
function drawPickup(ctx, pk, G){
  const sx=pk.x-G.cam.x, sy=pk.y-G.cam.y, fl=Math.sin(G.time*0.12+pk.x)*3;
  if(pk.kind==='coin'){
    ctx.fillStyle='#ffd45e'; ctx.beginPath(); ctx.arc(sx,sy+fl,7,0,7); ctx.fill();
    ctx.fillStyle='#c89a2a'; ctx.font='10px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText('◈',sx,sy+fl+1);
  } else {
    ctx.font='20px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText(ITEMS[pk.item].ico, sx, sy+fl);
  }
}

// ── function: drawProjectile ──
function drawProjectile(ctx, pr, G){
  const sx=pr.x-G.cam.x, sy=pr.y-G.cam.y;
  ctx.save(); ctx.translate(sx,sy); ctx.rotate(Math.atan2(pr.vy,pr.vx));
  ctx.fillStyle='#ffd45e'; ctx.fillRect(-8,-1.5,16,3);
  ctx.fillStyle='#fff'; ctx.fillRect(4,-1,6,2);
  ctx.restore();
}

// ── function: renderHearts ──
// ---- HUD & UI sync ----
function renderHearts(G){
  const el = document.getElementById('hearts'); el.innerHTML='';
  const full = Math.floor(G.player.hp/2), half = G.player.hp%2, max = G.player.maxhp/2;
  for(let i=0;i<max;i++){
    const state = i<full ? 'full' : (i===full && half) ? 'half' : 'empty';
    const col = state==='empty' ? '#3a2028' : '#ff4c5e';
    const fillW = state==='half' ? 13 : 26;
    el.insertAdjacentHTML('beforeend',
      `<svg class="heart" viewBox="0 0 26 24"><defs><clipPath id="hc${i}"><rect x="0" y="0" width="${state==='full'?26:fillW}" height="24"/></clipPath></defs>`+
      `<path d="M13 23 L3 12 A6 6 0 0 1 13 5 A6 6 0 0 1 23 12 Z" fill="#3a2028"/>`+
      `<path d="M13 23 L3 12 A6 6 0 0 1 13 5 A6 6 0 0 1 23 12 Z" fill="${col}" clip-path="url(#hc${i})"/></svg>`);
  }
}

// ── function: updateHUD ──
function updateHUD(G){
  document.getElementById('moneyVal').textContent = G.money;
  document.getElementById('weaponName').textContent =
    G.activeWeapon==='bow' ? BOW.name : MELEE[G.tier].name;
  const pct = Math.min(100, Math.round(G.progress/G.progressMax*100));
  document.getElementById('progressBar').style.width = pct+'%';
  renderHearts(G);
}

// ── function: toast ──
function toast(msg, cls){
  const box=document.getElementById('toast');
  const d=document.createElement('div'); d.className='toastItem '+(cls||''); d.textContent=msg;
  box.appendChild(d);
  setTimeout(()=>{ d.style.opacity='0'; d.style.transition='opacity .4s'; setTimeout(()=>d.remove(),400); }, 1800);
}

// ── function: setPrompt ──
function setPrompt(txt){
  const el=document.getElementById('prompt');
  if(!txt){ el.classList.add('hidden'); return; }
  el.classList.remove('hidden'); el.innerHTML=txt;
}

// ── function: addItem ──
// ---- Inventory & item use ----
function addItem(G, id, qty=1){
  G.inv[id] = (G.inv[id]||0) + qty;
}

// ── function: renderInventory ──
function renderInventory(G){
  const grid=document.getElementById('invGrid'); grid.innerHTML='';
  const ids = Object.keys(G.inv).filter(k=>G.inv[k]>0);
  const slots = Math.max(10, Math.ceil(ids.length/5)*5);
  for(let i=0;i<slots;i++){
    const id = ids[i];
    if(id){
      const it=ITEMS[id];
      const d=document.createElement('div'); d.className='slot filled';
      d.innerHTML=`<span class="ico">${it.ico}</span><span class="nm">${it.name}</span>`+
        (G.inv[id]>1?`<span class="qty">×${G.inv[id]}</span>`:'');
      d.onclick=()=>useItem(G, id);
      grid.appendChild(d);
    } else {
      const d=document.createElement('div'); d.className='slot'; grid.appendChild(d);
    }
  }
}

// ── function: useItem ──
function useItem(G, id){
  const it=ITEMS[id];
  if(id==='potion'){
    if(G.player.hp>=G.player.maxhp){ toast('Already at full health'); return; }
    G.player.hp=Math.min(G.player.maxhp,G.player.hp+2); G.inv[id]--;
    G.particles.ring(G.player.x,G.player.y,'#5fd08a',12,20); Sfx.pickup();
    toast('Restored a heart','good'); updateHUD(G); renderInventory(G);
  } else if(id==='bomb'){
    G.inv[id]--; G.pendingBomb=true; toast('Bomb armed — J to throw'); renderInventory(G);
    G.showInv(false);
  } else if(it.passive){
    toast(it.name+' is always active');
  } else {
    toast('Cannot use that here');
  }
}

// ── function: renderShop ──
// ---- Shop ----
function renderShop(G){
  const list=document.getElementById('shopList'); list.innerHTML='';
  G.shopStock.forEach((s,i)=>{
    const it=ITEMS[s.id];
    const row=document.createElement('div'); row.className='shopRow'+(s.sold?' sold':'');
    row.innerHTML=`<span class="sIco">${it.ico}</span>`+
      `<div class="sInfo"><div class="sName">${it.name}</div><div class="sDesc">${it.desc}</div></div>`+
      `<button class="sBuy" ${(s.sold||G.money<it.price)?'disabled':''}>◈${it.price}</button>`;
    const btn=row.querySelector('.sBuy');
    if(btn) btn.onclick=()=>buyItem(G,i);
    list.appendChild(row);
  });
}

// ── function: buyItem ──
function buyItem(G, i){
  const s=G.shopStock[i], it=ITEMS[s.id];
  if(s.sold||G.money<it.price) return;
  G.money-=it.price;
  if(it.passive){ applyPassive(G, s.id); s.sold = !it.stackable; }
  else addItem(G, s.id, 1);
  Sfx.shop(); G.particles.ring(G.player.x,G.player.y,'#ffd45e',10,18);
  toast('Bought '+it.name,'gold');
  if(it.passive) s.sold=true;
  updateHUD(G); renderShop(G);
}

// ── function: applyPassive ──
function applyPassive(G, id){
  if(id==='charm'){ G.moveMult=1.15; G.moveStyle=1; }
  if(id==='amulet'){ G.hasAmulet=true; }
  if(id==='torch'){ G.hasTorch=true; }
  G.passives[id]=true;
  addItem(G, id, 1);
}

// ── function: movePlayer ──
// ---- Player movement & attacks ----
function movePlayer(G){
  const p=G.player, k=G.keys;
  let dx=0, dy=0;
  if(k['a']||k['arrowleft']) dx-=1;
  if(k['d']||k['arrowright']) dx+=1;
  if(k['w']||k['arrowup']) dy-=1;
  if(k['s']||k['arrowdown']) dy+=1;
  p.moving = (dx||dy);
  if(dx) p.face = dx>0?1:-1;
  if(dx&&dy){ dx*=0.707; dy*=0.707; }
  const sp = CFG.playerSpeed*G.moveMult;
  const T=CFG.tile, r=12;
  // x
  let nx=p.x+dx*sp;
  if(!isSolid(G.world, Math.floor((nx+(dx>0?r:-r))/T), Math.floor((p.y-r)/T)) &&
     !isSolid(G.world, Math.floor((nx+(dx>0?r:-r))/T), Math.floor((p.y+r-1)/T))) p.x=nx;
  let ny=p.y+dy*sp;
  if(!isSolid(G.world, Math.floor((p.x-r)/T), Math.floor((ny+(dy>0?r:-r))/T)) &&
     !isSolid(G.world, Math.floor((p.x+r-1)/T), Math.floor((ny+(dy>0?r:-r))/T))) p.y=ny;
  // step sound
  if(p.moving){ G.stepT--; if(G.stepT<=0){ Sfx.step(); G.stepT=14; } }
}

// ── function: doAttack ──
function doAttack(G){
  if(G.pendingBomb){ throwBomb(G); G.pendingBomb=false; return; }
  if(G.attack.cd>0) return;
  const w=MELEE[G.tier];
  G.attack.cd=w.cd; G.attack.t=w.max=10;
  (G.tier===2?Sfx.heavy:Sfx.swing)();
  const p=G.player, reach=w.range;
  const cx=p.x+p.face*reach*0.4, cy=p.y;
  G.particles.burst(cx+p.face*10, cy, w.color, 4, {speed:2,life:14,size:2});
  for(const e of G.enemies){ if(!e.alive) continue;
    const d=Math.hypot(e.x-cx,e.y-cy);
    if(d<reach*0.6+e.r){ hurtEnemy(G,e,w.dmg, p.face, w.knock, w.burn); }
  }
}

// ── function: fireBow ──
function fireBow(G){
  if(!G.hasBow || G.bowCd>0) return;
  G.bowCd=BOW.cd; Sfx.fire();
  const p=G.player;
  G.projectiles.push({ x:p.x+p.face*12, y:p.y-2, vx:p.face*BOW.speed, vy:0, dmg:BOW.dmg, life:80 });
}

// ── function: throwBomb ──
function throwBomb(G){
  const p=G.player, tx=p.x+p.face*80, ty=p.y;
  Sfx.heavy();
  setTimeout(()=>{
    G.particles.burst(tx,ty,'#ff9a3c',30,{speed:5,life:34,size:5});
    G.particles.ring(tx,ty,'#ffd45e',16,40);
    Sfx.hit();
    for(const e of G.enemies){ if(!e.alive) continue;
      if(Math.hypot(e.x-tx,e.y-ty)<70) hurtEnemy(G,e,4,e.x>tx?1:-1,14,true);
    }
  }, 300);
  toast('💥');
}

// ── function: hurtEnemy ──
// ---- Combat resolution & enemy AI ----
function hurtEnemy(G, e, dmg, dir, knock, burn){
  e.hp-=dmg; e.hitT=8;
  e.x+=dir*knock; 
  G.particles.burst(e.x,e.y,'#ff6a4a',8,{speed:3,life:18,size:3});
  Sfx.hit();
  if(burn) G.particles.burst(e.x,e.y,'#ff9a3c',6,{speed:2,life:22,size:2});
  if(e.hp<=0){
    e.alive=false;
    G.particles.burst(e.x,e.y,e.color,16,{speed:4,life:26,size:4});
    G.particles.ring(e.x,e.y,'#ffffff',10,20);
    // reward
    const drop = e.type==='brute'?12:e.type==='shade'?8:5;
    spawnCoin(G, e.x, e.y, drop);
    addProgress(G, e.type==='brute'?3:2);
  }
}

// ── function: spawnCoin ──
function spawnCoin(G,x,y,amt){
  G.pickups.push({x:x+(Math.random()*20-10),y:y+(Math.random()*20-10),kind:'coin',amt,r:10,ready:G.time+20});
}

// ── function: updateEnemies ──
function updateEnemies(G){
  const p=G.player;
  for(const e of G.enemies){ if(!e.alive) continue;
    if(e.hitT>0) e.hitT--;
    const dist=Math.hypot(p.x-e.x,p.y-e.y);
    const aggro = dist < 220;
    if(aggro){
      const a=Math.atan2(p.y-e.y,p.x-e.x);
      e.vx=Math.cos(a)*e.spd; e.vy=Math.sin(a)*e.spd;
    } else {
      e.wanderT--; if(e.wanderT<=0){ const wa=Math.random()*7; e.vx=Math.cos(wa)*e.spd*0.4; e.vy=Math.sin(wa)*e.spd*0.4; e.wanderT=60+Math.random()*60; }
    }
    const T=CFG.tile;
    if(!isSolid(G.world,Math.floor((e.x+e.vx+e.r*Math.sign(e.vx))/T),Math.floor(e.y/T))) e.x+=e.vx;
    if(!isSolid(G.world,Math.floor(e.x/T),Math.floor((e.y+e.vy+e.r*Math.sign(e.vy))/T))) e.y+=e.vy;
    // touch damage
    if(dist<e.r+12 && G.player.invT<=0){ damagePlayer(G, e.dmg, e.x); }
  }
}

// ── function: damagePlayer ──
function damagePlayer(G, dmg, fromX){
  const p=G.player;
  if(G.hasAmulet) dmg=Math.max(1,dmg-1);
  p.hp-=dmg; if(p.hp<0)p.hp=0;
  p.invT=60;
  const dir = p.x<fromX?-1:1; p.x+=dir*10;
  G.particles.burst(p.x,p.y,'#ff4c5e',10,{speed:3,life:20,size:3});
  Sfx.hurt(); updateHUD(G);
  if(p.hp<=0) playerDeath(G);
}

// ── function: addProgress ──
// ---- Progression, pickups, projectiles ----
function addProgress(G, n){
  G.progress=Math.min(G.progressMax,G.progress+n);
  // reveal bow at halfway
  if(G.progress>=G.progressMax*0.5 && !G.bowRevealed){
    G.bowRevealed=true;
    const bow=G.interact.find(o=>o.kind==='bow'); if(bow) bow.hidden=false;
    toast('The ruins hum — an Ancient Bow is revealed','good');
  }
  updateHUD(G);
}

// ── function: updatePickups ──
function updatePickups(G){
  const p=G.player;
  for(let i=G.pickups.length-1;i>=0;i--){
    const pk=G.pickups[i];
    if(pk.ready && G.time<pk.ready) continue;
    if(Math.hypot(p.x-pk.x,p.y-pk.y) < pk.r+12){
      if(pk.kind==='coin'){ G.money+=pk.amt; Sfx.coin(); G.particles.burst(pk.x,pk.y,'#ffd45e',6,{speed:2,life:16,size:2}); }
      else { addItem(G,pk.item,1); Sfx.pickup(); toast('Found '+ITEMS[pk.item].name,'good'); G.particles.ring(pk.x,pk.y,'#8fd0ff',10,16); addProgress(G,1); }
      G.pickups.splice(i,1); updateHUD(G);
    }
  }
}

// ── function: updateProjectiles ──
function updateProjectiles(G){
  for(let i=G.projectiles.length-1;i>=0;i--){
    const pr=G.projectiles[i]; pr.x+=pr.vx; pr.y+=pr.vy; pr.life--;
    const T=CFG.tile; let hit=false;
    if(isSolid(G.world,Math.floor(pr.x/T),Math.floor(pr.y/T))) hit=true;
    for(const e of G.enemies){ if(!e.alive)continue;
      if(Math.hypot(e.x-pr.x,e.y-pr.y)<e.r+6){ hurtEnemy(G,e,pr.dmg,pr.vx>0?1:-1,10,false); hit=true; break; }
    }
    if(hit||pr.life<=0){ G.particles.burst(pr.x,pr.y,'#ffd45e',5,{speed:2,life:12,size:2}); G.projectiles.splice(i,1); }
  }
}

// ── function: nearestInteract ──
// ---- Interactables: nearest & activation ----
function nearestInteract(G){
  const p=G.player; let best=null, bd=1e9;
  for(const o of G.interact){
    if(o.hidden) continue;
    if((o.used) && o.kind!=='save' && o.kind!=='shop') continue;
    if(o.kind==='door' && o.open) continue;
    const d=Math.hypot(p.x-o.x,p.y-o.y);
    if(d<o.r+18 && d<bd){ bd=d; best=o; }
  }
  return best;
}

// ── function: activate ──
function activate(G, o){
  if(o.kind==='shrine'){
    if(G.tier>=o.tier){ toast('Already attuned'); return; }
    if(o.tier===2 && G.tier<1){ toast('This forge needs an Iron Blade first'); return; }
    G.tier=o.tier; o.used=true; Sfx.upgrade();
    G.particles.ring(o.x,o.y,MELEE[G.tier].color,20,30);
    G.particles.burst(o.x,o.y,'#fff',20,{speed:4,life:30,size:4});
    toast('Weapon evolved: '+MELEE[G.tier].name,'good');
    addProgress(G,4); updateHUD(G);
  } else if(o.kind==='heart'){
    if(G.player.maxhp>=10){ toast('Health already at maximum'); return; }
    o.used=true; G.player.maxhp+=2; G.player.hp=G.player.maxhp; Sfx.upgrade();
    G.particles.ring(o.x,o.y,'#ff4c5e',18,28);
    toast('Max health increased! ('+(G.player.maxhp/2)+' hearts)','good'); addProgress(G,3); updateHUD(G);
  } else if(o.kind==='bow'){
    o.used=true; G.hasBow=true; Sfx.upgrade();
    G.particles.ring(o.x,o.y,'#c89a5a',18,28);
    toast('Acquired '+BOW.name+' — press K to fire','good'); addProgress(G,3);
  } else if(o.kind==='key'){
    o.used=true; addItem(G,'key',1); Sfx.pickup();
    toast('Picked up the Rune Key','good');
    const door=G.interact.find(d=>d.kind==='door'); if(door) door.label='Unlock Depths';
  } else if(o.kind==='door'){
    if(!G.inv.key){ toast('The door is sealed — find the Rune Key'); return; }
    o.open=true; Sfx.save(); G.particles.ring(o.x,o.y,'#8fd0ff',24,40);
    const T=CFG.tile, tx=Math.round(o.x/T), ty=Math.round(o.y/T);
    G.world.g[ty][tx]=3; if(G.world.g[ty-1]) G.world.g[ty-1][tx]=3;
    toast('The Depths open before you','good'); addProgress(G,4);
  } else if(o.kind==='save'){
    saveGame(G); Sfx.save();
    G.particles.ring(o.x,o.y,'#5fd0c4',16,26);
    toast('Progress saved at Save Stone','good');
  } else if(o.kind==='shop'){
    openShop(G);
  }
}

// ── function: saveGame ──
// ---- Save / load / death / shop open ----
function saveGame(G){
  const data = {
    x:G.player.x, y:G.player.y, hp:G.player.hp, maxhp:G.player.maxhp,
    money:G.money, tier:G.tier, hasBow:G.hasBow, inv:G.inv, passives:G.passives,
    progress:G.progress, bowRevealed:G.bowRevealed,
    used:G.interact.map(o=>({k:o.kind,x:o.x,used:!!o.used,open:!!o.open,hidden:!!o.hidden})),
    deadEnemies:G.enemies.map(e=>!e.alive),
    gotPickups:G.pickups.length,
    savePoint:{x:G.player.x,y:G.player.y}
  };
  G.checkpoint=data;
  try{ localStorage.setItem('emberlight_save', JSON.stringify(data)); }catch(e){}
}

// ── function: loadGame ──
function loadGame(G){
  let data=G.checkpoint;
  if(!data){ try{ data=JSON.parse(localStorage.getItem('emberlight_save')); }catch(e){} }
  if(!data) return false;
  const cp=data.savePoint||{x:data.x,y:data.y};
  G.player.x=cp.x; G.player.y=cp.y;
  G.player.hp=data.maxhp; G.player.maxhp=data.maxhp; G.player.invT=0;
  G.money=data.money; G.tier=data.tier; G.hasBow=data.hasBow;
  G.inv=data.inv||{}; G.passives=data.passives||{}; G.progress=data.progress; G.bowRevealed=data.bowRevealed;
  Object.keys(G.passives).forEach(id=>applyPassive(G,id));
  data.used.forEach((s,i)=>{ const o=G.interact[i]; if(o){ o.used=s.used; if('open'in o)o.open=s.open; if('hidden'in o)o.hidden=s.hidden; }});
  data.deadEnemies.forEach((d,i)=>{ if(d&&G.enemies[i]) G.enemies[i].alive=false; });
  updateHUD(G);
  return true;
}

// ── function: playerDeath ──
function playerDeath(G){
  Sfx.die(); G.dead=true;
  G.particles.burst(G.player.x,G.player.y,'#ff4c5e',30,{speed:5,life:40,size:5});
  setTimeout(()=>{
    if(loadGame(G)){ toast('Revived at last Save Stone','good'); }
    else { G.player.hp=G.player.maxhp; G.player.x=9*CFG.tile; G.player.y=7*CFG.tile; toast('Restarted'); }
    G.dead=false; updateHUD(G);
  }, 1400);
}

// ── function: openShop ──
function openShop(G){
  G.paused=true; renderShop(G);
  document.getElementById('shop').classList.remove('hidden');
}

// ── function: tick ──
// ---- Frame tick & render ----
function tick(G){
  if(G.paused||G.dead) return;
  G.time++;
  movePlayer(G);
  if(G.player.invT>0) G.player.invT--;
  if(G.attack.cd>0) G.attack.cd--;
  if(G.attack.t>0) G.attack.t--;
  if(G.bowCd>0) G.bowCd--;
  updateEnemies(G);
  updatePickups(G);
  updateProjectiles(G);
  G.particles.update();
  // camera follow with clamp
  const cw=960, ch=600, wpx=G.world.W*CFG.tile, hpx=G.world.H*CFG.tile;
  G.cam.x=Math.max(0,Math.min(wpx-cw, G.player.x-cw/2));
  G.cam.y=Math.max(0,Math.min(hpx-ch, G.player.y-ch/2));
  // prompt
  const near=nearestInteract(G);
  G.nearObj=near;
  if(near){
    const verb={shrine:'attune',heart:'take',bow:'take',key:'take',door:near.label,save:'save',shop:'trade'}[near.kind];
    setPrompt(`<b>[E]</b> ${near.label} — ${near.kind==='door'?'':verb}`);
  } else setPrompt(null);
  // win check
  if(G.progress>=G.progressMax && !G.won){ G.won=true; showWin(G); }
}

// ── function: render ──
function render(G){
  const ctx=G.ctx;
  ctx.clearRect(0,0,960,600);
  drawWorld(ctx,G.world,G.cam,G);
  for(const pk of G.pickups) drawPickup(ctx,pk,G);
  for(const o of G.interact) drawInteract(ctx,o,G);
  for(const e of G.enemies) if(e.alive) drawEnemy(ctx,e,G);
  for(const pr of G.projectiles) drawProjectile(ctx,pr,G);
  drawPlayer(ctx,G.player,G);
  G.particles.draw(ctx,G.cam);
  // vignette in depths
  if(!G.hasTorch && G.player.y>20*CFG.tile){
    const grd=ctx.createRadialGradient(480,300,80,480,300,420);
    grd.addColorStop(0,'rgba(0,0,0,0)'); grd.addColorStop(1,'rgba(0,0,0,.88)');
    ctx.fillStyle=grd; ctx.fillRect(0,0,960,600);
  }
}

// ── function: showWin ──
function showWin(G){
  const ov=document.getElementById('overlay');
  ov.innerHTML=`<div class="center"><h1>VICTORY</h1><p class="sub">You mastered the Emberlight</p>`+
    `<p class="hint">HP ${G.player.hp}/${G.player.maxhp} · Weapon: ${MELEE[G.tier].name}${G.hasBow?' + Bow':''} · ◈${G.money}</p>`+
    `<button id="againBtn">Play again</button></div>`;
  ov.classList.remove('hidden');
  document.getElementById('againBtn').onclick=()=>location.reload();
  Sfx.save();
}

// ── main ──
// ---- Startup & wiring ----
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const world=buildWorld();
const ents=initEntities();

// hide bow until revealed
ents.interact.find(o=>o.kind==='bow').hidden=true;

const G = {
  ctx, world,
  player:{ x:9*CFG.tile, y:7*CFG.tile, hp:6, maxhp:6, face:1, moving:false, invT:0 },
  enemies:ents.enemies, pickups:ents.pickups, interact:ents.interact,
  projectiles:[], particles:new Particles(),
  keys:{}, time:0, money:0, tier:0, activeWeapon:'melee',
  hasBow:false, bowRevealed:false, bowCd:0,
  attack:{cd:0,t:0,max:10}, stepT:14,
  moveMult:1, moveStyle:0, hasTorch:false, hasAmulet:false,
  inv:{}, passives:{},
  progress:0, progressMax:60,
  cam:{x:0,y:0}, paused:true, dead:false, won:false, pendingBomb:false,
  nearObj:null, checkpoint:null,
  shopStock:[
    {id:'potion'}, {id:'potion'}, {id:'bomb'}, {id:'charm'}, {id:'amulet'}, {id:'torch'}
  ],
  showInv(v){ document.getElementById('inventory').classList.toggle('hidden', !v); if(v) renderInventory(this); this.paused = v || !document.getElementById('shop').classList.contains('hidden'); }
};
// starting HP: mandate says start with 3 points; but we set 3? spec: starts 3 -> 5 total health POINTS via hearts(2 each). Interpret: 3 hearts... use maxhp=6 (3 hearts) growing to 10 (5 hearts).
G.player.hp=6; G.player.maxhp=6;
window.G=G;

document.getElementById('startBtn').onclick=()=>{
  document.getElementById('overlay').classList.add('hidden');
  G.paused=false; Sfx.step();
  toast('Explore the grove — press E at glowing markers');
};

window.addEventListener('keydown',e=>{
  const k=e.key.toLowerCase();
  G.keys[k]=true;
  if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
  if(G.paused && !document.getElementById('shop').classList.contains('hidden')===false){}
  if(k==='i'){ G.showInv(document.getElementById('inventory').classList.contains('hidden')); }
  if(k==='e'){
    if(!document.getElementById('shop').classList.contains('hidden')){
      document.getElementById('shop').classList.add('hidden'); G.paused=false; return;
    }
    if(!G.paused && G.nearObj) activate(G,G.nearObj);
  }
  if(G.paused) return;
  if(k==='j'||k===' ') doAttack(G);
  if(k==='k') fireBow(G);
});
window.addEventListener('keyup',e=>{ G.keys[e.key.toLowerCase()]=false; });

// try resume
if(localStorage.getItem('emberlight_save')){
  // offer resume via toast after start; keep fresh world but flag
}

updateHUD(G);
(function loop(){ tick(G); render(G); requestAnimationFrame(loop); })();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-opus-4.8","provider":"openrouter","persona":"composer","sessionId":"3319e2c8-29d3-4a2f-bf31-d8e1fbd0f46d","tokensIn":805950,"tokensOut":27993,"tokensTotal":833943,"turns":31,"toolCalls":34,"failedToolCalls":0,"timestamp":"2026-07-22T19:53:13.243Z"} -->