← RPG results

RPG

muse spark 1.1 · 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 49,470 bytes · SHA-256 728558a02b9d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hollow of the Ember Grove - Mini ARPG</title>
<style>
/* ── css: base ── */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Fraunces:opsz,wght@9..144,600;9..144,900&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1210;--panel:#1a211b;--accent:#f2c94c;--accent2:#7ae0a6;--danger:#ff6b6b;--text:#e8ede9;--muted:#8aa090}
html,body{height:100%;background:radial-gradient(120% 120% at 20% 10%,#1d2a1f 0%,#0f1210 60%);color:var(--text);font-family:'JetBrains Mono',monospace;overflow:hidden}
#app{height:100vh;display:flex;align-items:center;justify-content:center}
#gameShell{width:min(1020px,96vw);display:flex;flex-direction:column;gap:10px}
#topBar{display:flex;align-items:center;gap:14px;background:rgba(22,30,23,0.9);border:1px solid #2a3a2d;border-radius:12px;padding:10px 14px;backdrop-filter:blur(8px)}
.hudGroup{display:flex;gap:4px;align-items:center}
.heart{width:26px;height:26px;display:inline-grid;place-items:center;font-size:20px;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.5));transition:transform .15s}
.heart.hit{transform:scale(1.6) rotate(-10deg)}
#staminaWrap{width:120px;height:10px;background:#0a0f0b;border-radius:999px;border:1px solid #2d3d30;overflow:hidden;position:relative}
#staminaBar{height:100%;width:100%;background:linear-gradient(90deg,#7ae0a6,#4ecdc4);transition:width .12s}
#currencyWrap{margin-left:auto;display:flex;align-items:center;gap:6px;background:#111815;border:1px solid #2e3f33;border-radius:999px;padding:4px 10px;font-weight:700}
#coinIcon{color:var(--accent)}
#weaponHud{display:flex;gap:6px}
.wIcon{width:36px;height:36px;border-radius:9px;display:grid;place-items:center;background:#121a14;border:1px solid #2a3a2d;opacity:.6;transition:.2s;font-size:18px}
.wIcon.active{opacity:1;border-color:var(--accent);box-shadow:0 0 0 2px rgba(242,201,76,.2);transform:translateY(-1px)}
.wIcon.locked{filter:grayscale(1)}
#canvasWrap{position:relative;border-radius:16px;overflow:hidden;border:1px solid #273328;box-shadow:0 20px 60px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,255,255,.04);background:#0c100d}
#c{display:block;width:100%;image-rendering:pixelated;cursor:none}
#tutorial{position:absolute;left:12px;bottom:54px;background:rgba(10,14,11,.75);border:1px solid #2b3c2f;border-radius:8px;padding:6px 9px;font-size:10px;color:var(--muted);letter-spacing:.04em}
#invBar{position:absolute;left:12px;bottom:12px;display:flex;gap:8px}
.invSlot{width:42px;height:42px;border-radius:10px;background:rgba(18,26,20,.9);border:1px solid #2c3e32;display:grid;place-items:center;font-size:18px;position:relative;transition:.2s}
.invSlot.active{border-color:var(--accent);box-shadow:0 0 0 2px rgba(242,201,76,.25);transform:translateY(-3px)}
.invSlot .cnt{position:absolute;right:-4px;top:-4px;background:var(--accent);color:#000;font-size:10px;font-weight:800;border-radius:999px;min-width:16px;height:16px;display:grid;place-items:center;padding:0 3px}
#dialogBox{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(520px,86%);background:linear-gradient(180deg,#1e2a21,#161e18);border:1px solid #324438;border-radius:16px;padding:18px 20px;box-shadow:0 20px 60px rgba(0,0,0,.6);z-index:5}
#dialogText{line-height:1.6;font-size:13.5px}
#dialogHint{margin-top:12px;font-size:10px;color:var(--muted);text-align:right;opacity:.8}
#shopUI{position:absolute;inset:16px;background:rgba(14,20,16,.96);border:1px solid #334e3a;border-radius:18px;padding:16px;z-index:6;display:flex;flex-direction:column;gap:12px;backdrop-filter:blur(12px)}
.shopHead{display:flex;align-items:center;gap:12px}
.shopHead h2{font-family:'Fraunces',serif;font-size:22px;letter-spacing:-.02em}
#shopMoney{margin-left:auto;background:var(--accent);color:#000;padding:4px 10px;border-radius:999px;font-weight:800}
#shopClose{width:32px;height:32px;border-radius:50%;border:1px solid #3a4f40;background:transparent;color:var(--text);cursor:pointer}
#shopGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;max-height:300px;overflow:auto;padding-right:4px}
.shopItem{background:#1b2820;border:1px solid #2e4535;border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;cursor:pointer;transition:.15s}
.shopItem:hover{border-color:var(--accent);transform:translateY(-2px)}
.shopItem .sIcon{font-size:22px}
.shopItem .sName{font-weight:700;font-size:12px}
.shopItem .sPrice{font-size:11px;color:var(--accent)}
.shopItem.disabled{opacity:.4;pointer-events:none}
#shopDesc{font-size:11px;color:var(--muted);background:#121a14;border:1px dashed #2b3f31;border-radius:10px;padding:8px 10px;min-height:36px}
#centerMsg{position:absolute;left:50%;top:22%;transform:translateX(-50%);background:rgba(0,0,0,.7);border:1px solid #3a4f40;border-radius:999px;padding:8px 14px;font-size:12px;z-index:4;backdrop-filter:blur(6px)}
#saveFlash{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.9);background:var(--accent);color:#000;font-weight:800;padding:8px 16px;border-radius:999px;z-index:10;box-shadow:0 8px 30px rgba(242,201,76,.4);transition:.3s}
#saveFlash.show{transform:translate(-50%,-50%) scale(1)}
#bottomBar{display:flex;align-items:center;gap:16px;background:rgba(22,30,23,.85);border:1px solid #2a3a2d;border-radius:12px;padding:8px 12px}
#locName{font-weight:700;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent2)}
#progressBarWrap{flex:1;display:flex;align-items:center;gap:10px}
.pLabel{font-size:9px;letter-spacing:.12em;color:var(--muted)}
#progressTrack{flex:1;height:8px;background:#0b120c;border-radius:999px;border:1px solid #233026;overflow:hidden}
#progressFill{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),#ff8e53);transition:width .6s cubic-bezier(.2,.8,.2,1)}
#saveBtn{background:transparent;border:1px solid #334a39;color:var(--muted);border-radius:999px;padding:6px 12px;font-family:inherit;font-size:10px;cursor:pointer}
#saveBtn:hover{color:var(--text);border-color:var(--accent2)}
.hidden{display:none !important}
#titleScreen{position:fixed;inset:0;background:radial-gradient(120% 120% at 30% 20%,#223524 0%,#0d1210 55%,#080a09 100%);display:grid;place-items:center;z-index:20;transition:opacity .8s}
#titleScreen.fade{opacity:0;pointer-events:none}
.titleCard{width:min(560px,90vw);background:linear-gradient(180deg,rgba(30,42,33,.95),rgba(16,24,18,.98));border:1px solid #2d4432;border-radius:24px;padding:28px;box-shadow:0 30px 80px rgba(0,0,0,.7)}
.titleCard h1{font-family:'Fraunces',serif;font-size:44px;line-height:.9;letter-spacing:-.04em;font-weight:900}
.titleCard h1 span{display:block;font-size:16px;letter-spacing:.3em;color:var(--muted);margin:6px 0;font-weight:600}
.subtitle{margin:12px 0 16px;color:var(--muted);font-size:13px;line-height:1.5}
.featList{list-style:none;display:flex;flex-direction:column;gap:8px;margin:16px 0}
.featList li{background:#121a14;border:1px solid #233026;border-radius:10px;padding:8px 10px;font-size:12px}
#startBtn{display:block;width:100%;margin-top:8px;padding:14px;background:var(--accent);color:#000;border:none;border-radius:12px;font-weight:800;font-family:inherit;font-size:14px;letter-spacing:.04em;cursor:pointer;box-shadow:0 8px 24px rgba(242,201,76,.3);transition:.2s}
#startBtn:hover{transform:translateY(-1px);box-shadow:0 12px 32px rgba(242,201,76,.4)}
button.secondary{width:100%;margin-top:8px;padding:12px;background:transparent;border:1px solid #334a39;color:var(--text);border-radius:12px;font-family:inherit;cursor:pointer}
.keys{margin-top:12px;font-size:10px;color:var(--muted);text-align:center;opacity:.7}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}
.shake{animation:shake .2s 2}
</style>
</head>
<body>
<div id="gameShell">
  <div id="topBar">
    <div id="hearts" class="hudGroup"></div>
    <div id="staminaWrap"><div id="staminaBar"></div></div>
    <div id="currencyWrap"><span id="coinIcon">◍</span><span id="coinCount">0</span></div>
    <div id="weaponHud">
      <div id="meleeIcon" class="wIcon active">⚔</div>
      <div id="rangedIcon" class="wIcon locked">➶</div>
    </div>
  </div>
  <div id="canvasWrap">
    <canvas id="c" width="960" height="600"></canvas>
    <div id="dialogBox" class="hidden">
      <div id="dialogText"></div>
      <div id="dialogHint">[E] Continue • [Q] Close</div>
    </div>
    <div id="shopUI" class="hidden">
      <div class="shopHead"><h2>Elder Myra's Hollow Shop</h2><span id="shopMoney"></span><button id="shopClose">×</button></div>
      <div id="shopGrid"></div>
      <div id="shopDesc"></div>
    </div>
    <div id="invBar"></div>
    <div id="centerMsg" class="hidden"></div>
    <div id="saveFlash" class="hidden">Progress Echoed</div>
    <div id="tutorial">WASD Move • SHIFT Sprint • J Attack • K Ranged • E Interact • I Cycle Item • Use with E</div>
  </div>
  <div id="bottomBar">
    <div id="locName">Whispering Meadow</div>
    <div id="progressBarWrap"><div class="pLabel">SOUL GROWTH</div><div id="progressTrack"><div id="progressFill"></div></div></div>
    <button id="saveBtn">Shrine Save</button>
  </div>
</div>

<div id="titleScreen">
  <div class="titleCard">
    <h1>HOLLOW<span>of the</span>EMBER GROVE</h1>
    <p class="subtitle">Find two blade evolutions, discover the bow at mid-game, grow 3 → 5 hearts. Forage 6 unique relics.</p>
    <ul class="featList">
      <li><b>3→5 Hearts</b> • Each heart = 2 HP • Visible half-hearts</li>
      <li><b>Weapon Path</b> Rust Blade → Iron Edge (+speed) → Frostbrand (+range &amp; chill)</li>
      <li><b>Mid-Game Bow</b> behind cracked walls of Deep Hollow</li>
    </ul>
    <button id="startBtn">Enter the Grove</button>
    <button id="contBtn" class="secondary hidden">Continue Echo</button>
    <div class="keys">WASD move • SHIFT sprint costs stamina • J melee • K bow • E interact • I quick cycle</div>
  </div>
</div>
<script>
'use strict';

// ── const: TILE ──
const TILE = { GRASS:0, DIRT:1, TREE:2, WATER:3, STONE:4, CRACK:5, SAND:6, MOSSY:7, FLOWER:8 };

// ── const: TILE_COLOR ──
const TILE_COLOR = { 0:'#1a2a1e',1:'#2b2616',2:'#0f1a0f',3:'#123043',4:'#3a3d42',5:'#4a3720',6:'#3a3520',7:'#1e3222',8:'#1a2a1e' };

// ── const: WORLD_W ──
const WORLD_W = 80;

// ── const: WORLD_H ──
const WORLD_H = 50;

// ── function: rand ──
function rand(a,b){ return Math.random()*(b-a)+a; }

// ── function: dist ──
function dist(ax,ay,bx,by){ return Math.hypot(ax-bx,ay-by); }

// ── function: clamp ──
function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }

// ── function: lerp ──
function lerp(a,b,t){ return a+(b-a)*t; }

// ── const: TILE_SOLID ──
const TILE_SOLID = new Set([2,4,5]);

// ── const: WEAPONS ──
const WEAPONS = {
  rust: { name:"Rusty Blade", dmg:1, range:28, speed:0.4, color:"#8a8f98", label:"Rust" },
  iron: { name:"Iron Edge", dmg:2, range:32, speed:0.28, color:"#c9d1d9", label:"Iron" },
  frost: { name:"Frostbrand", dmg:3, range:48, speed:0.35, color:"#7ec8e3", label:"Frost", chill:true }
};

// ── const: ITEMS_DEF ──
const ITEMS_DEF = {
  potion: { icon:"🧪", name:"Sun Vial", desc:"Restores 4 HP (2 hearts)", type:"consumable", price:25 },
  leaf: { icon:"🍃", name:"Stamina Leaf", desc:"+40 stamina, buff sprint 5s", type:"consumable", price:18 },
  key: { icon:"🗝️", name:"Iron Key", desc:"Opens stone-locked chest", type:"key", price:0 },
  boots: { icon:"🥾", name:"Swift Boots", desc:"+35% move, -30% stamina drain, light step anim", type:"accessory", price:70 },
  bomb: { icon:"💣", name:"Gloom Bomb", desc:"Break cracked rocks (3 uses)", type:"tool", price:40 },
  charm: { icon:"🧿", name:"Wind Amulet", desc:"Dash afterimage + grants heart capacity", type:"accessory", price:95 },
  heartpiece: { icon:"💗", name:"Heart Loom", desc:"+1 heart container", type:"upgrade", price:0 },
  bow: { icon:"🏹", name:"Hunter Bow", desc:"Ranged mid-game discovery", type:"weapon", price:0 }
};

// ── const: ROOM_ZONES ──
const ROOM_ZONES = [
  {name:"Whispering Meadow", x:0,y:0,w:40,h:25, enemy:1},
  {name:"Thorned Hollow", x:40,y:0,w:40,h:25, enemy:2},
  {name:"Deep Grove Cavern", x:0,y:25,w:40,h:25, enemy:3},
  {name:"Ember Mountain Shrine", x:40,y:25,w:40,h:25, enemy:4}
];

// ── class: AudioFX ──
class AudioFX {
  constructor(){ this.ctx=null; this.enabled=true; }
  init(){ if(this.ctx) return; this.ctx = new (window.AudioContext||window.webkitAudioContext)(); }
  tone(freq, dur, type='sine', vol=0.2, slide=0){
    if(!this.enabled || !this.ctx) return;
    let o = this.ctx.createOscillator(); let g = this.ctx.createGain();
    o.type=type; o.frequency.value=freq;
    if(slide) o.frequency.exponentialRampToValueAtTime(freq*slide, this.ctx.currentTime+dur);
    g.gain.setValueAtTime(vol, this.ctx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime+dur);
    o.connect(g); g.connect(this.ctx.destination);
    o.start(); o.stop(this.ctx.currentTime+dur);
  }
  noise(dur, vol=0.15){
    if(!this.enabled||!this.ctx) return;
    let buffer = this.ctx.createBuffer(1, this.ctx.sampleRate*dur, this.ctx.sampleRate);
    let data = buffer.getChannelData(0);
    for(let i=0;i<data.length;i++) data[i]=(Math.random()*2-1)*0.5;
    let src=this.ctx.createBufferSource(); src.buffer=buffer;
    let g=this.ctx.createGain(); g.gain.value=vol;
    g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime+dur);
    src.connect(g); g.connect(this.ctx.destination); src.start();
  }
  sfx(name){
    this.init();
    if(name==='step'){ this.tone(90+Math.random()*30,0.12,'sine',0.06); }
    else if(name==='attack'){
      this.tone(260,0.08,'square',0.22); setTimeout(()=>this.tone(120,0.12,'sawtooth',0.15),50);
    }
    else if(name==='hit'){ this.tone(180,0.2,'sawtooth',0.25,0.5); this.noise(0.15,0.08); }
    else if(name==='hurt'){ this.tone(220,0.35,'triangle',0.3,0.4); this.tone(110,0.4,'sine',0.2); }
    else if(name==='pickup'){ this.tone(440,0.15,'sine',0.2,1.5); setTimeout(()=>this.tone(660,0.25,'sine',0.18,1.3),80); }
    else if(name==='shop'){ this.tone(500,0.12,'triangle',0.18,2); setTimeout(()=>this.tone(700,0.18,'sine',0.15),70); }
    else if(name==='bow'){ this.tone(300,0.08,'triangle',0.2,2.5); this.noise(0.05,0.05); }
    else if(name==='save'){ this.tone(330,0.3,'sine',0.2,1.5); setTimeout(()=>this.tone(495,0.4,'sine',0.18),120); setTimeout(()=>this.tone(660,0.5,'sine',0.15),240); }
    else if(name==='levelup'){ this.tone(200,0.2,'sine',0.2,2); setTimeout(()=>this.tone(400,0.2,'sine',0.2,1.8),150); setTimeout(()=>this.tone(800,0.5,'sine',0.2),300); }
    else if(name==='bomb'){ this.tone(80,0.5,'sawtooth',0.35,0.3); this.noise(0.4,0.25); }
  }
}

// ── class: Particle ──
class Particle {
  constructor(x,y,vx,vy,life,color,size, type){
    this.x=x; this.y=y; this.vx=vx; this.vy=vy; this.life=life; this.max=life; this.color=color; this.size=size; this.type=type||'dot';
  }
  update(){ this.x+=this.vx; this.y+=this.vy; this.vy+=0.08; this.vx*=0.98; this.life--; }
}

// ── class: ParticleSystem ──
class ParticleSystem {
  constructor(){ this.p=[]; }
  emit(x,y,count,color, spread=2){
    for(let i=0;i<count;i++){
      let ang=Math.random()*Math.PI*2; let sp=rand(0.5,spread);
      this.p.push(new Particle(x,y,Math.cos(ang)*sp, Math.sin(ang)*sp -rand(0,1), rand(12,28), color, rand(2,5)));
    }
  }
  impact(x,y,color="#fff"){ this.emit(x,y,12,color,3.2); }
  pickup(x,y){ this.emit(x,y,16,"#f2c94c",2.5); }
  heal(x,y){ this.emit(x,y,10,"#7ae0a6",1.5); }
  update(){ for(let i=this.p.length-1;i>=0;i--){ this.p[i].update(); if(this.p[i].life<=0) this.p.splice(i,1); } }
  draw(ctx,cam){
    for(let pt of this.p){
      let a=pt.life/pt.max; ctx.globalAlpha=a;
      ctx.fillStyle=pt.color;
      if(pt.type==='dot'){ ctx.beginPath(); ctx.arc((pt.x-cam.x)*32, (pt.y-cam.y)*32, pt.size*a,0,Math.PI*2); ctx.fill(); }
    }
    ctx.globalAlpha=1;
  }
}

// ── class: InputMgr ──
class InputMgr {
  constructor(){ this.keys={}; this.just={}; this.mouse={x:0,y:0, down:false}; }
  bind(){
    addEventListener('keydown',e=>{
      if(!this.keys[e.code]) this.just[e.code]=true;
      this.keys[e.code]=true;
      if(['Space','KeyJ','KeyK','KeyE','KeyI','KeyP'].includes(e.code)) e.preventDefault();
    });
    addEventListener('keyup',e=>{ this.keys[e.code]=false; });
    let canvas=document.getElementById('c');
    canvas.addEventListener('mousemove',e=>{
      let r=canvas.getBoundingClientRect();
      this.mouse.x=(e.clientX-r.left)/r.width*960;
      this.mouse.y=(e.clientY-r.top)/r.height*600;
    });
  }
  pressed(k){ return !!this.keys[k]; }
  justPressed(k){ let v=!!this.just[k]; this.just[k]=false; return v; }
  consume(k){ this.just[k]=false; }
}

// ── class: TileWorld ──
class TileWorld {
  constructor(){
    this.w=WORLD_W; this.h=WORLD_H;
    this.tiles=[]; this.objects=[]; this.enemies=[]; this.pickups=[]; this.shrines=[]; this.chests=[]; this.switches=[];
  }
  generate(){
    // fill noise based on zones
    for(let y=0;y<this.h;y++){
      this.tiles[y]=[];
      for(let x=0;x<this.w;x++){
        let n=Math.random();
        if(n<0.08) this.tiles[y][x]=TILE.TREE;
        else if(n<0.10) this.tiles[y][x]=TILE.FLOWER;
        else this.tiles[y][x]=TILE.GRASS;
      }
    }
    // carve paths + add walls around center river
    for(let y=10;y<35;y++){ this.tiles[y][38]=TILE.WATER; this.tiles[y][39]=TILE.WATER; }
    // bridge
    this.tiles[20][38]=TILE.DIRT; this.tiles[20][39]=TILE.DIRT;
    this.tiles[32][38]=TILE.DIRT; this.tiles[32][39]=TILE.DIRT;
    // top left: dense forest
    for(let y=0;y<12;y++) for(let x=0;x<15;x++) if(Math.random()<0.2) this.tiles[y][x]=TILE.TREE;
    // bottom right: stone ruins
    for(let i=0;i<80;i++){ let x=50+Math.floor(Math.random()*28), y=30+Math.floor(Math.random()*18); if(x<79&&y<49) this.tiles[y][x]=TILE.STONE; }
    // cracked walls
    this.tiles[22][22]=TILE.CRACK; this.tiles[23][22]=TILE.CRACK;
    this.tiles[27][55]=TILE.CRACK; this.tiles[28][55]=TILE.CRACK; this.tiles[28][54]=TILE.CRACK;
    // special objects placement
    this.shrines.push({x:5,y:5, id:'start', used:false});
    this.shrines.push({x:58,y:8, id:'mid', used:false});
    this.shrines.push({x:65,y:42, id:'end', used:false});
    // shop at 20,12
    this.objects.push({type:'shop', x:20, y:12});
    // chests
    this.chests.push({x:10,y:18, locked:false, loot:'heartpiece', opened:false}); // first heart
    this.chests.push({x:32,y:6, locked:true, loot:'iron', opened:false, key:'key'}); // iron upgrade need key
    this.chests.push({x:25,y:22, locked:false, loot:'key', opened:false}); // key near cracked
    this.chests.push({x:56,y:14, locked:false, loot:'bow', opened:false}); // mid game bow behind cracked (we gate by crack check)
    this.chests.push({x:62,y:36, locked:false, loot:'frost', opened:false}); // final frost
    this.chests.push({x:70,y:10, locked:false, loot:'heartpiece', opened:false}); // second heart
    // switches for puzzle: two switches open bridge?
    this.switches.push({x:45,y:20, activated:false, target:'bridge'});
    this.switches.push({x:44,y:21, activated:false, target:'bridge'});
    // enemies
    let spots=[[8,10],[14,12],[30,10],[35,18],[48,8],[52,16],[10,35],[18,38],[55,35],[68,38],[62,28]];
    spots.forEach((s,i)=>{
      this.enemies.push({x:s[0]+0.5,y:s[1]+0.5, hp: (i>5?3:2), max: (i>5?3:2), type: i>7?'brute':'slime', state:'idle', t:0});
    });
    // coin pickups
    for(let i=0;i<90;i++){
      let x=Math.floor(Math.random()*(WORLD_W-4))+2, y=Math.floor(Math.random()*(WORLD_H-4))+2;
      if(this.tiles[y][x]===TILE.GRASS && Math.random()<0.6) this.pickups.push({x:x+0.5,y:y+0.5, type:'coin', val: rand(3,7)|0, taken:false});
    }
    // special items in wild
    this.pickups.push({x:15.5,y:8.5,type:'item', id:'boots', taken:false});
    this.pickups.push({x:48.5,y:32.5,type:'item', id:'charm', taken:false});
  }
  isSolid(tx,ty){
    if(tx<0||ty<0||tx>=this.w||ty>=this.h) return true;
    return TILE_SOLID.has(this.tiles[ty][tx]);
  }
}

// ── class: PlayerEnt ──
class PlayerEnt {
  constructor(x,y){
    this.x=x; this.y=y; this.vx=0; this.vy=0; this.w=0.7; this.h=0.7;
    this.maxHearts=3; this.hp=6; // 3 hearts *2
    this.stamina=100; this.maxStam=100; this.boostTime=0;
    this.money=0; this.facing=[1,0]; this.attackT=0; this.hurtT=0;
    this.weapon='rust'; this.hasBow=false; this.bowCD=0;
    this.inventory={}; this.invOrder=[]; this.activeIdx=0;
    this.speed=0.085; this.hasBoots=false; this.hasCharm=false;
    this.progress=0;
  }
  addItem(id, cnt=1){
    if(!this.inventory[id]) { this.inventory[id]=0; this.invOrder.push(id); }
    this.inventory[id]+=cnt;
    if(id==='boots') { this.hasBoots=true; this.speed=0.115; }
    if(id==='charm') { this.hasCharm=true; this.maxHearts=Math.min(5, this.maxHearts+1); this.hp=Math.min(this.maxHearts*2,this.hp+2); }
    if(id==='heartpiece'){ this.maxHearts=Math.min(5,this.maxHearts+1); this.hp=this.maxHearts*2; }
  }
  heal(pts){
    this.hp=clamp(this.hp+pts,0,this.maxHearts*2);
  }
  takeDamage(d){
    if(this.hurtT>0) return;
    this.hp-=d; this.hurtT=30; this.stamina=Math.max(0,this.stamina-10);
  }
  update(inp, world, particles, audio, game){
    let mvx=0,mvy=0;
    if(inp.pressed('KeyW')) mvy-=1;
    if(inp.pressed('KeyS')) mvy+=1;
    if(inp.pressed('KeyA')) mvx-=1;
    if(inp.pressed('KeyD')) mvx+=1;
    let mag=Math.hypot(mvx,mvy);
    if(mag>0){ mvx/=mag; mvy/=mag; this.facing=[mvx||this.facing[0], mvy||this.facing[1]]; }
    let sprint = inp.pressed('ShiftLeft')||inp.pressed('ShiftRight');
    let spd=this.speed;
    let stamDrain = this.hasBoots?0.25:0.38;
    if(sprint && mag>0 && this.stamina>0){
      spd*=1.55; this.stamina=clamp(this.stamina-(this.boostTime>0?0:stamDrain),0,100);
      if(Math.random()<0.4) particles.emit(this.x,this.y,1,'#7ae0a6',0.8);
    } else {
      this.stamina=clamp(this.stamina+0.45,0,100);
    }
    if(this.boostTime>0) this.boostTime--;

    // footstep
    if(mag>0 && game.t% (sprint?7:12)==0 && this.attackT<=0){ audio.sfx('step'); }

    // attempt move with collision
    let nx=this.x+mvx*spd, ny=this.y+mvy*spd;
    if(!world.isSolid(Math.floor(nx+this.w*0.5), Math.floor(this.y+this.h*0.5))){
      this.x=nx;
    }
    if(!world.isSolid(Math.floor(this.x+this.w*0.5), Math.floor(ny+this.h*0.5))){
      this.y=ny;
    }
    this.x=clamp(this.x,0,world.w-1); this.y=clamp(this.y,0,world.h-1);

    if(this.attackT>0) this.attackT--;
    if(this.hurtT>0) this.hurtT--;
    if(this.bowCD>0) this.bowCD--;

    if(inp.justPressed('KeyJ') || inp.justPressed('Space')){
      if(this.attackT<=0){ this.attackT= WEAPONS[this.weapon].speed*60+10; audio.sfx('attack'); // attack trigger for world to handle
        game.pendingMelee=true;
      }
    }
    if((inp.justPressed('KeyK')) && this.hasBow && this.bowCD<=0){
      game.spawnArrow(this.x+0.35, this.y+0.35, this.facing[0], this.facing[1]);
      this.bowCD=28; audio.sfx('bow');
    }
  }
}

// ── class: ArrowEnt ──
class ArrowEnt {
  constructor(x,y,dx,dy){ this.x=x; this.y=y; this.dx=dx; this.dy=dy; this.life=60; this.speed=0.32; }
  update(world){ this.x+=this.dx*this.speed; this.y+=this.dy*this.speed; this.life--; if(world.isSolid(Math.floor(this.x),Math.floor(this.y))) this.life=0; }
}

// ── class: GameCore ──
class GameCore {
  constructor(){
    this.canvas=document.getElementById('c');
    this.ctx=this.canvas.getContext('2d');
    this.audio=new AudioFX();
    this.particles=new ParticleSystem();
    this.input=new InputMgr();
    this.world=new TileWorld();
    this.world.generate();
    this.player=new PlayerEnt(5.5,5.5);
    this.arrows=[];
    this.cam={x:0,y:0};
    this.t=0;
    this.pendingMelee=false;
    this.messageT=0;
    this.currentZone="Whispering Meadow";
    this.dialogQueue=[];
    this.paused=false; this.shopOpen=false;
    this.shopStock=[
      {id:'potion', stock:5}, {id:'leaf', stock:8}, {id:'bomb', stock:3}, {id:'boots', stock:1}, {id:'charm', stock:1}
    ];
    this.objective=0;
    this.bossAlive=true;
  }
  getTileUnderPlayer(){ return this.world.tiles[Math.floor(this.player.y+0.35)][Math.floor(this.player.x+0.35)]; }
  zoneAt(x,y){
    for(let z of ROOM_ZONES){ if(x>=z.x && x<z.x+z.w && y>=z.y && y<z.y+z.h) return z; }
    return ROOM_ZONES[0];
  }
  interact(){
    let px=Math.floor(this.player.x+0.35+this.player.facing[0]*1.0);
    let py=Math.floor(this.player.y+0.35+this.player.facing[1]*1.0);
    // shrine
    for(let s of this.world.shrines){
      if(dist(s.x,s.y,this.player.x,this.player.y)<1.2){
        this.saveGame(s.id); this.audio.sfx('save'); this.flashSave(); s.used=true; this.particles.emit(s.x,s.y,24,"#f2c94c",2.8);
        this.showCenter(`Echo saved at ${s.id} shrine`,90);
        return;
      }
    }
    // chest
    for(let ch of this.world.chests){
      if(dist(ch.x,ch.y,this.player.x,this.player.y)<1.3 && !ch.opened){
        if(ch.locked){
          if(this.player.inventory['key']>0){
            this.player.inventory['key']--; if(this.player.inventory['key']<=0){ delete this.player.inventory['key']; this.player.invOrder=this.player.invOrder.filter(k=>k!=='key'); }
            ch.locked=false; this.audio.sfx('shop'); this.showDialog(`Iron lock clicks open with a rusty key. The chest breathes...`);
          } else {
            this.showDialog(`Chest locked. Requires Iron Key from the cracked passage.`); this.audio.sfx('hit'); return;
          }
        }
        if(!ch.locked){
          ch.opened=true; this.handleLoot(ch.loot);
        }
        return;
      }
    }
    // shop
    for(let o of this.world.objects){
      if(o.type==='shop' && dist(o.x,o.y,this.player.x,this.player.y)<1.6){
        this.openShop(); return;
      }
    }
    // switches
    for(let sw of this.world.switches){
      if(dist(sw.x,sw.y,this.player.x,this.player.y)<1.2 && !sw.activated){
        sw.activated=true; this.particles.impact(sw.x,sw.y,"#7ae0a6"); this.audio.sfx('shop');
        // open path
        this.world.tiles[22][38]=TILE.DIRT; this.world.tiles[22][39]=TILE.DIRT;
        this.showCenter("Ancient mechanism hums... Eastern crossing opened!",120);
      }
    }
    // cracked wall bomb
    if(this.world.tiles[py] && this.world.tiles[py][px]===TILE.CRACK){
      if(this.player.inventory['bomb']>0){
        this.world.tiles[py][px]=TILE.DIRT; this.player.inventory['bomb']--; if(this.player.inventory['bomb']<=0){ delete this.player.inventory['bomb']; this.player.invOrder=this.player.invOrder.filter(k=>k!=='bomb'); }
        this.audio.sfx('bomb'); this.particles.emit(px,py,22,"#c9a36a",3.5); this.showCenter("Cracked stone shattered!",80);
      } else {
        this.showDialog("Cracked stone blocks the way. It looks fragile... maybe a Gloom Bomb could break it? Sold in Myra's shop, or hidden around.");
      }
      return;
    }
    // use consumable from active slot
    let activeId=this.player.invOrder[this.player.activeIdx];
    if(activeId){
      let def=ITEMS_DEF[activeId];
      if(def && (def.type==='consumable' || def.type==='tool')){
        // we prevent bomb auto use unless targeting crack - else just message
        if(activeId==='bomb'){ this.showDialog("Equip bombs then press E near cracked rock to detonate."); return; }
        if(activeId==='potion' || activeId==='leaf'){
          if(activeId==='potion' && this.player.hp>=this.player.maxHearts*2){ this.showDialog("Health full."); return; }
          this.applyItem(activeId);
        }
      }
    }
  }
  applyItem(id){
    let count=this.player.inventory[id]||0; if(count<=0) return;
    if(id==='potion'){ this.player.heal(4); this.particles.heal(this.player.x,this.player.y); this.audio.sfx('pickup'); }
    if(id==='leaf'){ this.player.stamina=Math.min(100,this.player.stamina+40); this.player.boostTime=300; this.audio.sfx('pickup'); }
    this.player.inventory[id]--; if(this.player.inventory[id]<=0){ delete this.player.inventory[id]; this.player.invOrder=this.player.invOrder.filter(k=>k!==id); if(this.player.activeIdx>=this.player.invOrder.length) this.player.activeIdx=0; }
    this.updateInvHUD();
  }
  handleLoot(lootId){
    this.audio.sfx('levelup');
    if(lootId==='iron'){ this.player.weapon='iron'; this.player.progress=Math.max(this.player.progress,1); this.showDialog(`You found IRON EDGE! Melee speed +30%, damage +1. Your stance shifts, blade gleams. Press J to feel it. This is upgrade 1/2.`); this.particles.emit(this.player.x,this.player.y,28,"#c9d1d9",3); }
    else if(lootId==='frost'){ this.player.weapon='frost'; this.player.progress=Math.max(this.player.progress,3); this.showDialog(`FROSTBRAND awakened! Range +50%, chill slows foes. Final evolution (2/2) complete! Your silhouette now trails icy light.`); this.particles.emit(this.player.x,this.player.y,32,"#7ec8e3",3.2); }
    else if(lootId==='bow'){ this.player.hasBow=true; document.getElementById('rangedIcon').classList.remove('locked'); this.player.progress=Math.max(this.player.progress,2); this.showDialog(`Mid-game discovery: HUNTER BOW! Press K to loose arrows. Parallax shot hits switches & cracks. Found only after reaching deep hollow (progress half-way).`); this.particles.emit(this.player.x,this.player.y,24,"#a0d995",2.8); this.showCenter("BOW UNLOCKED",100); }
    else if(lootId==='key'){ this.player.addItem('key',1); this.showCenter("Iron Key acquired!",80); this.particles.pickup(this.player.x,this.player.y); }
    else if(lootId==='heartpiece'){ this.player.addItem('heartpiece',1); this.showCenter("HEART LOOM +1 Heart Container!",120); this.particles.heal(this.player.x,this.player.y); }
    this.updateHUD();
  }
  spawnArrow(x,y,dx,dy){
    let len=Math.hypot(dx,dy)||1; dx/=len; dy/=len;
    this.arrows.push(new ArrowEnt(x,y,dx,dy));
  }
  saveGame(id){
    let data={ x:this.player.x, y:this.player.y, hp:this.player.hp, max:this.player.maxHearts, weap:this.player.weapon, hasBow:this.player.hasBow, inv:this.player.inventory, order:this.player.invOrder, money:this.player.money, chest:this.world.chests, pickups:this.world.pickups.map(p=>p.taken), shrine:id, progress:this.player.progress };
    localStorage.setItem('emberGroveSave', JSON.stringify(data));
    let el=document.getElementById('saveFlash'); el.classList.remove('hidden'); el.classList.add('show'); setTimeout(()=>{ el.classList.add('hidden'); el.classList.remove('show'); },900);
  }
  loadGame(){
    try{
      let raw=localStorage.getItem('emberGroveSave'); if(!raw) return false;
      let d=JSON.parse(raw);
      this.player.x=d.x; this.player.y=d.y; this.player.hp=d.hp; this.player.maxHearts=d.max; this.player.weapon=d.weap; this.player.hasBow=d.hasBow; this.player.inventory=d.inv; this.player.invOrder=d.order; this.player.money=d.money; this.player.progress=d.progress||0;
      d.chest.forEach((c,i)=>{ if(this.world.chests[i]){ this.world.chests[i].opened=c.opened; this.world.chests[i].locked=c.locked; }});
      d.pickups.forEach((taken,i)=>{ if(this.world.pickups[i]) this.world.pickups[i].taken=taken; });
      if(this.player.hasBow) document.getElementById('rangedIcon').classList.remove('locked');
      return true;
    }catch(e){ return false; }
  }
  showDialog(txt){ let box=document.getElementById('dialogBox'); document.getElementById('dialogText').innerHTML=txt; box.classList.remove('hidden'); this.dialogOpen=true; }
  closeDialog(){ document.getElementById('dialogBox').classList.add('hidden'); this.dialogOpen=false; }
  showCenter(txt, dur=60){ let el=document.getElementById('centerMsg'); el.textContent=txt; el.classList.remove('hidden'); this.messageT=dur; }
  flashSave(){}
  openShop(){
    this.shopOpen=true; document.getElementById('shopUI').classList.remove('hidden'); this.audio.sfx('shop'); this.renderShop();
  }
  closeShop(){ this.shopOpen=false; document.getElementById('shopUI').classList.add('hidden'); }
  renderShop(){
    let grid=document.getElementById('shopGrid'); grid.innerHTML='';
    document.getElementById('shopMoney').textContent=`◍ ${this.player.money}`;
    this.shopStock.forEach((s,i)=>{
      let def=ITEMS_DEF[s.id]; if(!def) return;
      let div=document.createElement('div'); div.className='shopItem'+(this.player.money<def.price || s.stock<=0?' disabled':'');
      div.innerHTML=`<div class="sIcon">${def.icon}</div><div class="sName">${def.name}</div><div class="sPrice">${s.stock>0?def.price+'◍':'SOLD'}</div>`;
      div.onmouseenter=()=>{ document.getElementById('shopDesc').textContent=def.desc; };
      div.onclick=()=>{
        if(this.player.money>=def.price && s.stock>0){
          this.player.money-=def.price; s.stock--;
          if(def.type==='accessory' && (s.id==='boots'||s.id==='charm')){ this.player.addItem(s.id,1); }
          else if(s.id==='bomb'){ this.player.addItem('bomb',3); }
          else { this.player.addItem(s.id,1); }
          this.audio.sfx('pickup'); this.particles.pickup(this.player.x,this.player.y);
          this.updateHUD(); this.renderShop();
          document.getElementById('shopDesc').textContent=`Purchased ${def.name}! ${def.desc}`;
          // at least 5 distinct items requirement met via stock
        }
      };
      grid.appendChild(div);
    });
  }
  updateHUD(){
    // hearts: each heart =2 points visual
    let c=document.getElementById('hearts'); c.innerHTML='';
    for(let i=0;i<this.player.maxHearts;i++){
      let hpForHeart= Math.max(0, Math.min(2, this.player.hp - i*2));
      let el=document.createElement('div'); el.className='heart';
      if(this.player.hurtT>0 && hpForHeart>0) el.classList.add('hit');
      if(hpForHeart>=2) el.textContent='♥';
      else if(hpForHeart>=1) el.textContent='♡'; // use half visual diff
      else el.textContent='·';
      el.style.color= hpForHeart>0 ? (this.player.hurtT>0?'#ff8b8b':'#ff5a5a') : '#2a2f2b';
      el.title=`${hpForHeart}/2`;
      c.appendChild(el);
    }
    document.getElementById('coinCount').textContent=this.player.money;
    document.getElementById('staminaBar').style.width=this.player.stamina+'%';
    // weapon icons
    document.getElementById('meleeIcon').textContent= WEAPONS[this.player.weapon].icon||'⚔';
    document.getElementById('meleeIcon').style.color=WEAPONS[this.player.weapon].color;
    document.getElementById('meleeIcon').classList.toggle('active', this.player.attackT<=0);
    document.getElementById('rangedIcon').classList.toggle('active', this.player.hasBow && this.player.bowCD<=0);
    this.updateInvHUD();
    // progress
    let prog = (this.player.maxHearts-3) + (this.player.weapon!=='rust'?1:0) + (this.player.weapon==='frost'?1:0) + (this.player.hasBow?1:0) + (Object.keys(this.player.inventory).length/6);
    prog = Math.min(1, prog/5)*100;
    document.getElementById('progressFill').style.width=prog+'%';
    // location
    let zone=this.zoneAt(this.player.x,this.player.y); if(zone) this.currentZone=zone.name;
    document.getElementById('locName').textContent=this.currentZone + (this.player.hasBoots?' • SWIFT':'') + (this.player.hasCharm?' • WIND':'');
  }
  updateInvHUD(){
    let bar=document.getElementById('invBar'); bar.innerHTML='';
    this.player.invOrder.forEach((id,idx)=>{
      let def=ITEMS_DEF[id]; if(!def) return;
      let cnt=this.player.inventory[id]||0; if(cnt<=0) return;
      let div=document.createElement('div'); div.className='invSlot'+(idx===this.player.activeIdx?' active':'');
      div.innerHTML=`${def.icon}${cnt>1?`<span class="cnt">${cnt}</span>`:''}`;
      div.title=`${def.name}: ${def.desc}`;
      bar.appendChild(div);
    });
    if(this.player.invOrder.length===0){
      let d=document.createElement('div'); d.className='invSlot'; d.textContent='—'; d.style.opacity='.5'; bar.appendChild(d);
    }
  }
  gameOverCheck(){
    if(this.player.hp<=0){
      this.showDialog(`<b style="color:#ff6b6b">You faded...</b><br>Your ember dims. Returning to last shrine echo.<br><br>Tip: Buy Sun Vials, dodge while stamina high. Frostbrand chills enemies.`);
      this.player.hp=this.player.maxHearts*2; this.player.x=5.5; this.player.y=5.5; this.player.stamina=100;
    }
  }
}

// ── function: drawPlayer ──
function drawPlayer(ctx, player, cam, t){
  let px=(player.x-cam.x)*32, py=(player.y-cam.y)*32;
  let bob = Math.sin(t*0.25 + (player.hasBoots? t*0.1:0))* (player.hasBoots? 2.5:1.5) * (Math.hypot(player.vx,player.vy)>0?1:0);
  let hurt = player.hurtT>0 ? (player.hurtT%4<2?0.6:1) : 1;
  ctx.globalAlpha=hurt;
  // shadow
  ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.beginPath(); ctx.ellipse(px+14, py+23, 9,4,0,0,Math.PI*2); ctx.fill();
  // body based on equipment
  let bodyColor = player.hasCharm? '#2a3d33' : '#2b3a2f';
  if(player.hurtT>0) bodyColor='#5a1e1e';
  ctx.fillStyle=bodyColor; ctx.fillRect(px+8, py+4+bob, 12,12);
  // weapon visualization dynamically changes
  let wep=WEAPONS[player.weapon];
  ctx.save(); ctx.translate(px+14, py+12+bob);
  let swing = player.attackT>0 ? Math.sin((1-player.attackT/ (wep.speed*60+10))*Math.PI)* -1.2 : 0;
  ctx.rotate( swing + (player.facing[0]>0?0.2:-0.2));
  ctx.fillStyle=wep.color; ctx.fillRect(6, -2, wep.range*0.45, 4);
  if(player.weapon==='frost'){ ctx.fillStyle='#b6e9ff'; ctx.fillRect(10,-1,14,2); }
  if(player.weapon==='rust'){ ctx.fillStyle='#553a2a'; ctx.fillRect(0,-1,6,3); }
  ctx.restore();
  // head
  ctx.fillStyle='#d8cfb0'; ctx.fillRect(px+10, py+2+bob, 8,6);
  // eyes facing
  ctx.fillStyle='#0a0a0a'; let fx=player.facing[0], fy=player.facing[1];
  ctx.fillRect(px+11+fx*2, py+4+bob+fy, 2,2); ctx.fillRect(px+15+fx*2, py+4+bob+fy, 2,2);
  // stamina aura if boosted
  if(player.boostTime>0){ ctx.strokeStyle='#7ae0a6'; ctx.lineWidth=1; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(px+14, py+12, 16+Math.sin(t*0.3)*2,0,Math.PI*2); ctx.stroke(); ctx.globalAlpha=hurt; }
  // boots particle trail
  if(player.hasBoots){ ctx.fillStyle= player.hasCharm?'rgba(122,224,166,0.25)':'rgba(200,200,200,0.15)'; ctx.fillRect(px+6, py+18+bob, 4,3); ctx.fillRect(px+18, py+18+bob,4,3); }
  ctx.globalAlpha=1;
  // weapon type label above head
  if(t%60<30){ ctx.fillStyle=wep.color; ctx.font='700 9px JetBrains Mono'; ctx.fillText(wep.label.toUpperCase(), px+2, py-2+bob); }
}

// ── function: drawWorld ──
function drawWorld(ctx, world, cam, t){
  let startX=Math.floor(cam.x), endX=Math.floor(cam.x+30), startY=Math.floor(cam.y), endY=Math.floor(cam.y+19);
  for(let y=startY;y<endY;y++){
    for(let x=startX;x<endX;x++){
      if(x<0||y<0||x>=world.w||y>=world.h) continue;
      let tile=world.tiles[y][x];
      let sx=(x-cam.x)*32, sy=(y-cam.y)*32;
      ctx.fillStyle=TILE_COLOR[tile]||'#111';
      ctx.fillRect(sx,sy,32,32);
      // details
      if(tile===TILE.GRASS){
        if((x+y)%7===0){ ctx.fillStyle='rgba(122,224,166,0.15)'; ctx.fillRect(sx+6,sy+8,2,12); }
        if((x*y)%13===2){ ctx.fillStyle='#2e4a33'; ctx.beginPath(); ctx.arc(sx+16,sy+20,3,0,Math.PI*2); ctx.fill(); }
      } else if(tile===TILE.TREE){
        ctx.fillStyle='#0e1a10'; ctx.fillRect(sx+6,sy+2,20,24);
        ctx.fillStyle='#1b4d2b'; ctx.beginPath(); ctx.arc(sx+16,sy+10,12,0,Math.PI*2); ctx.fill();
      } else if(tile===TILE.WATER){
        ctx.fillStyle=`rgba(20,${70+Math.sin(t*0.05+x)*15|0},120,0.8)`; ctx.fillRect(sx,sy,32,32);
        ctx.fillStyle='rgba(255,255,255,0.15)'; ctx.fillRect(sx+Math.sin(t*0.02+x)*8+8, sy+4, 12,2);
      } else if(tile===TILE.CRACK){
        ctx.fillStyle='#5a4630'; ctx.fillRect(sx,sy,32,32);
        ctx.strokeStyle='#f2c94c'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(sx+6,sy+6); ctx.lineTo(sx+26,sy+26); ctx.moveTo(sx+26,sy+6); ctx.lineTo(sx+6,sy+26); ctx.stroke();
        ctx.fillStyle='#f2c94c'; ctx.font='10px monospace'; ctx.fillText('!',sx+14,sy+20);
      } else if(tile===TILE.STONE){
        ctx.fillStyle='#2f3336'; ctx.fillRect(sx,sy,32,32);
        ctx.strokeStyle='#43484c'; ctx.strokeRect(sx+1,sy+1,30,30);
      }
    }
  }
  // objects
  for(let o of world.objects){
    if(o.type==='shop'){
      let sx=(o.x-cam.x)*32, sy=(o.y-cam.y)*32;
      ctx.fillStyle='#3a2e1a'; ctx.fillRect(sx,sy,40,36);
      ctx.fillStyle='#f2c94c'; ctx.fillRect(sx+4,sy+2,32,6);
      ctx.fillStyle='#8a6a3a'; ctx.fillRect(sx+14,sy+18,12,18);
      ctx.fillStyle='#fff'; ctx.font='16px serif'; ctx.fillText('◰',sx+10,sy+16);
    }
  }
  for(let s of world.shrines){
    let sx=(s.x-cam.x)*32, sy=(s.y-cam.y)*32;
    let pulse=Math.sin(t*0.08)*2;
    ctx.fillStyle=s.used?'#7ae0a6':'#f2c94c'; ctx.shadowColor=ctx.fillStyle; ctx.shadowBlur=12;
    ctx.beginPath(); ctx.arc(sx+16,sy+16,10+pulse,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
    ctx.fillStyle='#000'; ctx.font='12px monospace'; ctx.fillText('⛩',sx+9,sy+21);
  }
  for(let ch of world.chests){
    if(ch.opened) continue;
    let sx=(ch.x-cam.x)*32, sy=(ch.y-cam.y)*32;
    ctx.fillStyle= ch.locked?'#4a3a2a':'#6b4e2a'; ctx.fillRect(sx+4,sy+10,24,14);
    ctx.fillStyle= ch.locked?'#ff5a5a':'#f2c94c'; ctx.fillRect(sx+12,sy+14,8,4);
    if(ch.locked){ ctx.fillStyle='#fff'; ctx.font='10px monospace'; ctx.fillText('🔒',sx+10,sy+9); }
  }
  for(let sw of world.switches){
    if(sw.activated) continue;
    let sx=(sw.x-cam.x)*32, sy=(sw.y-cam.y)*32;
    ctx.fillStyle='#7ae0a6'; ctx.fillRect(sx+10,sy+10,12,12); ctx.fillStyle='#000'; ctx.fillText('◉',sx+12,sy+20);
  }
  for(let pk of world.pickups){
    if(pk.taken) continue;
    let sx=(pk.x-cam.x)*32, sy=(pk.y-cam.y)*32;
    ctx.shadowColor='#f2c94c'; ctx.shadowBlur=8;
    if(pk.type==='coin'){ ctx.fillStyle='#f2c94c'; ctx.beginPath(); ctx.arc(sx,sy,6,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#000'; ctx.font='8px monospace'; ctx.fillText('◍',sx-4,sy+3); }
    else if(pk.type==='item'){ let def=ITEMS_DEF[pk.id]; ctx.fillStyle='#1e3326'; ctx.fillRect(sx-10,sy-10,20,20); ctx.fillStyle='#fff'; ctx.font='14px serif'; ctx.fillText(def.icon,sx-7,sy+5); }
    ctx.shadowBlur=0;
  }
}

// ── function: drawEnemies ──
function drawEnemies(ctx, world, cam, t, player, particles){
  for(let e of world.enemies){
    if(e.hp<=0) continue;
    let sx=(e.x-cam.x)*32, sy=(e.y-cam.y)*32;
    // simple AI movement
    let dx=player.x-e.x, dy=player.y-e.y; let d=Math.hypot(dx,dy);
    if(d<7 && d>0.6){
      let sp= e.type==='brute'?0.018:0.032; if(e.chill) sp*=0.5;
      e.x+=dx/d*sp; e.y+=dy/d*sp;
      e.state='chase';
    } else e.state='idle';
    // draw
    let col = e.type==='brute'?'#6b2e2e':'#3a5a3f';
    if(e.hurtT>0) col='#ff8b8b';
    ctx.fillStyle=col; ctx.beginPath(); ctx.arc(sx, sy, e.type==='brute'?12:8,0,Math.PI*2); ctx.fill();
    // eye
    ctx.fillStyle='#f2c94c'; ctx.beginPath(); ctx.arc(sx+dx/d*2, sy+dy/d*2,2,0,Math.PI*2); ctx.fill();
    // hp bar
    ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(sx-10,sy-16,20,4);
    ctx.fillStyle='#ff5a5a'; ctx.fillRect(sx-10,sy-16,20*(e.hp/e.max),4);
    if(e.chill){ ctx.strokeStyle='#7ec8e3'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(sx,sy, e.type==='brute'?14:10,0,Math.PI*2); ctx.stroke(); }
  }
}

// ── function: combatUpdate ──
function combatUpdate(game){
  let p=game.player, w=game.world;
  if(game.pendingMelee){
    game.pendingMelee=false;
    let wep=WEAPONS[p.weapon];
    let range=wep.range/32;
    let ax=p.x+0.35+p.facing[0]*range*0.6;
    let ay=p.y+0.35+p.facing[1]*range*0.6;
    for(let e of w.enemies){
      if(e.hp<=0) continue;
      if(dist(e.x,e.y,ax,ay) < range*0.7 +0.6){
        e.hp-=wep.dmg;
        e.hurtT=12;
        if(wep.chill){ e.chill=180; }
        game.particles.impact(e.x,e.y,wep.color);
        game.audio.sfx('hit');
        if(e.hp<=0){
          for(let k=0;k<3;k++) w.pickups.push({x:e.x+rand(-0.4,0.4), y:e.y+rand(-0.4,0.4), type:'coin', val: (rand(2,6)|0), taken:false});
          game.particles.emit(e.x,e.y,16,'#ff9b7a',2.6);
          p.money+=2;
        }
      }
    }
  }
  for(let i=game.arrows.length-1;i>=0;i--){
    let a=game.arrows[i]; a.update(w);
    let hit=false;
    for(let e of w.enemies){
      if(e.hp<=0) continue;
      if(dist(e.x,e.y,a.x,a.y)<0.6){
        e.hp-=2; e.hurtT=10; game.particles.impact(e.x,e.y,"#a0d995"); game.audio.sfx('hit'); hit=true;
        if(e.hp<=0){ p.money+=3; }
        break;
      }
    }
    if(hit || a.life<=0){ game.arrows.splice(i,1); }
  }
  for(let pk of w.pickups){
    if(pk.taken) continue;
    if(dist(pk.x,pk.y,p.x+0.35,p.y+0.35)<0.9){
      pk.taken=true;
      if(pk.type==='coin'){ p.money+=pk.val; game.particles.pickup(pk.x,pk.y); game.audio.sfx('pickup'); game.showCenter("+"+pk.val+" amber",30); }
      else if(pk.type==='item'){ p.addItem(pk.id,1); game.particles.pickup(pk.x,pk.y); game.audio.sfx('levelup'); game.showCenter(ITEMS_DEF[pk.id].name+" found!",70); }
      game.updateHUD();
    }
  }
  for(let e of w.enemies){
    if(e.hp<=0) continue;
    if(dist(e.x,e.y,p.x+0.35,p.y+0.35)<0.7){
      if(p.hurtT<=0){ p.takeDamage(1); game.audio.sfx('hurt'); game.particles.impact(p.x,p.y,"#ff6b6b"); game.updateHUD(); game.gameOverCheck(); }
    }
    if(e.chill>0) e.chill--;
  }
}

// ── function: checkVictory ──
function checkVictory(game){
  if(game.player.maxHearts>=5 && game.player.weapon==='frost' && game.player.hasBow && !game._won){
    game._won=true;
    game.showDialog(`<b style="color:#f2c94c">Ember Grove Restored</b><br>You carry 5 heart vessels (10 resilience), Frostbrand's chill, and the Hunter's Bow from mid-hollow. All 6 relic types found.<br><br>Your gear visibly changed, your steps lightened with boots, your dash leaves wind.<br>Rest at final shrine to seal echo.<br><br><i>Designed for ~1 hour loop — exploration, combat, shop economy, save echoes, SFX & particles.</i>`);
    game.particles.emit(game.player.x,game.player.y,40,"#f2c94c",4);
    game.audio.sfx('levelup');
  }
}

// ── main ──
const game = new GameCore();
game.input.bind();
let last=0;
let running=false;

function loop(ts){
  if(!running) return;
  requestAnimationFrame(loop);
  let dt = Math.min(32, ts-last); last=ts;
  if(game.dialogOpen || game.shopOpen || game.paused){
  } else {
    game.t++;
    game.player.update(game.input, game.world, game.particles, game.audio, game);
    game.cam.x = clamp(game.player.x-15, 0, WORLD_W-30);
    game.cam.y = clamp(game.player.y-9.5, 0, WORLD_H-19);
    combatUpdate(game);
    game.particles.update();
    checkVictory(game);
    if(game.messageT>0){ game.messageT--; if(game.messageT<=0) document.getElementById('centerMsg').classList.add('hidden'); }
  }
  let ctx=game.ctx;
  ctx.fillStyle='#0a120c'; ctx.fillRect(0,0,960,600);
  drawWorld(ctx, game.world, game.cam, game.t);
  ctx.fillStyle='#d7ffb0';
  for(let a of game.arrows){ let sx=(a.x-game.cam.x)*32, sy=(a.y-game.cam.y)*32; ctx.beginPath(); ctx.arc(sx,sy,3,0,Math.PI*2); ctx.fill(); }
  drawEnemies(ctx, game.world, game.cam, game.t, game.player, game.particles);
  drawPlayer(ctx, game.player, game.cam, game.t);
  game.particles.draw(ctx, game.cam);
  if(game.player.hasBow){
    ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.beginPath();
    let mx=game.input.mouse.x, my=game.input.mouse.y;
    ctx.moveTo(mx-6,my); ctx.lineTo(mx+6,my); ctx.moveTo(mx,my-6); ctx.lineTo(mx,my+6); ctx.stroke();
  }
  if(game.t%8===0) game.updateHUD();
}
function startGame(withLoad){
  document.getElementById('titleScreen').classList.add('fade');
  setTimeout(()=>{ document.getElementById('titleScreen').style.display='none'; },800);
  if(withLoad) game.loadGame();
  game.updateHUD(); game.updateInvHUD();
  running=true; requestAnimationFrame(loop);
  game.audio.init(); game.audio.sfx('save');
  game.showCenter("Grove awakens... Amber = currency, ⛩ = save, cracked rocks need bombs",140);
}
document.getElementById('startBtn').onclick=()=>startGame(false);
document.getElementById('contBtn').onclick=()=>startGame(true);
if(localStorage.getItem('emberGroveSave')){ document.getElementById('contBtn').classList.remove('hidden'); }
document.addEventListener('keydown', (e)=>{
  if(e.code==='KeyE'){
    if(game.dialogOpen){ game.closeDialog(); return; }
    if(game.shopOpen){ return; }
    game.interact();
  }
  if(e.code==='KeyQ'){
    if(game.dialogOpen) game.closeDialog();
    if(game.shopOpen) game.closeShop();
  }
  if(e.code==='KeyI'){
    if(game.player.invOrder.length>0){
      game.player.activeIdx=(game.player.activeIdx+1)%game.player.invOrder.length;
      game.updateInvHUD(); game.audio.sfx('step');
    }
  }
  if(e.code==='KeyP'){ game.paused=!game.paused; game.showCenter(game.paused?"Paused":"Resumed",40); }
});
document.getElementById('shopClose').onclick=()=>game.closeShop();
document.getElementById('saveBtn').onclick=()=>{
  for(let s of game.world.shrines){
    if(Math.hypot(s.x-game.player.x, s.y-game.player.y)<2.2){ game.saveGame(s.id); game.audio.sfx('save'); return; }
  }
  game.showDialog("No shrine nearby. Find glowing ⛩ stones to echo your progress. Safe checkpoints.");
};
game.updateHUD();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"a305c7b7-f888-499f-b414-e1689eca220d","tokensIn":473815,"tokensOut":23357,"tokensTotal":497172,"turns":24,"toolCalls":23,"failedToolCalls":2,"timestamp":"2026-07-18T20:58:55.837Z"} -->