← RPG results

RPG

ox alpha · full

Initial view of RPG

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

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 44,096 bytes · SHA-256 c5ec68635237
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Emberfall Vale</title>
<style>
  html,body{margin:0;padding:0;background:#0b0e14;height:100%;overflow:hidden;font-family:'Trebuchet MS',Verdana,sans-serif;}
  #wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;}
  canvas{background:#000;image-rendering:pixelated;box-shadow:0 0 40px #000;}
  .overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(5,8,14,.82);z-index:10;color:#dfe7f5;}
  .panel{background:#141a26;border:2px solid #3a4a66;border-radius:10px;padding:22px 28px;max-width:560px;text-align:center;box-shadow:0 8px 30px #000;}
  .panel h1{margin:0 0 8px;color:#ffd76a;letter-spacing:2px;}
  .panel h2{margin:0 0 10px;color:#ffd76a;}
  .panel p{font-size:14px;line-height:1.55;color:#aebad0;}
  .key{display:inline-block;background:#232d42;border:1px solid #4a5b7d;border-radius:4px;padding:1px 7px;margin:0 2px;color:#ffd76a;font-weight:bold;font-size:12px;}
  button{background:#2a3650;color:#ffd76a;border:1px solid #5a6d95;border-radius:6px;padding:9px 20px;font-size:15px;cursor:pointer;margin:6px;font-family:inherit;}
  button:hover{background:#3a4a6e;}
  button:disabled{opacity:.4;cursor:default;}
  #hud{position:absolute;top:8px;left:10px;z-index:5;pointer-events:none;}
  #hudTop{display:flex;align-items:center;gap:10px;}
  #hearts{display:flex;gap:2px;}
  #resRow{margin-top:4px;font-size:13px;color:#fff;text-shadow:1px 1px 2px #000;display:flex;gap:14px;align-items:center;}
  #toasts{position:absolute;bottom:70px;left:50%;transform:translateX(-50%);z-index:6;display:flex;flex-direction:column;align-items:center;gap:4px;pointer-events:none;}
  .toast{background:rgba(10,14,24,.88);border:1px solid #4a5b7d;color:#ffe9a8;padding:6px 16px;border-radius:6px;font-size:14px;animation:tfade 3.4s forwards;}
  @keyframes tfade{0%{opacity:0;transform:translateY(8px)}8%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
  #invPanel .slot{display:flex;justify-content:space-between;align-items:center;background:#1b2334;border:1px solid #3a4a66;border-radius:6px;padding:8px 12px;margin:6px 0;font-size:14px;text-align:left;}
  #invPanel .slot button{padding:4px 12px;margin:0;font-size:13px;}
  #shopPanel .slot{display:flex;justify-content:space-between;align-items:center;background:#1b2334;border:1px solid #3a4a66;border-radius:6px;padding:8px 12px;margin:6px 0;font-size:14px;text-align:left;gap:12px;}
  .small{font-size:12px;color:#8fa0bd;}
  #bossBar{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);width:420px;z-index:5;display:none;}
  #bossBar .name{text-align:center;color:#ff9a9a;font-size:13px;letter-spacing:2px;text-shadow:1px 1px 2px #000;margin-bottom:3px;}
  #bossBar .bar{height:12px;background:#2a1015;border:1px solid #7a3545;border-radius:6px;overflow:hidden;}
  #bossBar .fill{height:100%;background:linear-gradient(90deg,#c33,#f66);width:100%;}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv" width="960" height="600"></canvas>
  <div id="hud">
    <div id="hudTop"><div id="hearts"></div></div>
    <div id="resRow">
      <span id="goldTxt">💰 0</span>
      <span id="arrowTxt" style="display:none">🏹 0</span>
      <span id="wepTxt">🗡️ Rusty Sword</span>
    </div>
  </div>
  <div id="bossBar"><div class="name" id="bossName">SHADE KNIGHT</div><div class="bar"><div class="fill" id="bossFill"></div></div></div>
  <div id="toasts"></div>

  <div class="overlay" id="titleScreen">
    <div class="panel">
      <h1>EMBERFALL VALE</h1>
      <p>A short action-adventure. Explore the vale, slay its creatures, recover two lost Heart Containers, and face the Shade Knight beneath the ruins.</p>
      <p>
        <span class="key">WASD</span>/<span class="key">↑↓←→</span> move &nbsp;
        <span class="key">SPACE</span>/<span class="key">J</span> attack &nbsp;
        <span class="key">K</span> shoot bow &nbsp;
        <span class="key">E</span> interact &nbsp;
        <span class="key">I</span> inventory &nbsp;
        <span class="key">Q</span> quick-heal
      </p>
      <button id="btnNew">New Journey</button>
      <button id="btnCont" style="display:none">Continue</button>
      <p class="small">Progress saves at glowing shrines (press E there).</p>
    </div>
  </div>

  <div class="overlay" id="invPanel" style="display:none">
    <div class="panel" style="min-width:440px">
      <h2>Inventory</h2>
      <div id="invList"></div>
      <p class="small">Consumables can also be quick-used with <span class="key">Q</span> (healing item).</p>
      <button onclick="closePanels()">Close</button>
    </div>
  </div>

  <div class="overlay" id="shopPanel" style="display:none">
    <div class="panel" style="min-width:460px">
      <h2>🛒 Peddler Marla</h2>
      <p class="small" id="shopGold"></p>
      <div id="shopList"></div>
      <button onclick="closePanels()">Leave</button>
    </div>
  </div>

  <div class="overlay" id="deadScreen" style="display:none">
    <div class="panel">
      <h2 style="color:#ff7a7a">You have fallen...</h2>
      <p>The vale's magic pulls you back to your last shrine. Gold kept, progress kept.</p>
      <button id="btnRespawn">Awaken at Shrine</button>
    </div>
  </div>

  <div class="overlay" id="winScreen" style="display:none">
    <div class="panel">
      <h1>VALE RESTORED</h1>
      <p id="winStats"></p>
      <button onclick="location.reload()">Play Again</button>
    </div>
  </div>
</div>

<script>
"use strict";
/* ============================== AUDIO ============================== */
const AC = window.AudioContext || window.webkitAudioContext;
let actx = null;
function audio(){ if(!actx) actx = new AC(); if(actx.state==='suspended') actx.resume(); return actx; }
function tone(f0,f1,dur,type,vol,delay){
  const a=audio(), t=a.currentTime+(delay||0);
  const o=a.createOscillator(), g=a.createGain();
  o.type=type||'square'; o.frequency.setValueAtTime(f0,t);
  if(f1) o.frequency.exponentialRampToValueAtTime(Math.max(f1,1),t+dur);
  g.gain.setValueAtTime(vol||.12,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
  o.connect(g).connect(a.destination); o.start(t); o.stop(t+dur+.02);
}
function noise(dur,vol,fLow){
  const a=audio(), t=a.currentTime;
  const len=a.sampleRate*dur, buf=a.createBuffer(1,len,a.sampleRate), d=buf.getChannelData(0);
  for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
  const s=a.createBufferSource(); s.buffer=buf;
  const f=a.createBiquadFilter(); f.type='lowpass'; f.frequency.value=fLow||900;
  const g=a.createGain(); g.gain.value=vol||.15;
  s.connect(f).connect(g).connect(a.destination); s.start(t);
}
const SFX = {
  step(){ noise(.05,.045,500); },
  swing(){ noise(.09,.1,2400); tone(320,140,.08,'sawtooth',.05); },
  hit(){ tone(220,60,.12,'square',.14); noise(.07,.12,1500); },
  hurt(){ tone(180,50,.28,'sawtooth',.2); },
  pickup(){ tone(660,660,.07,'square',.1); tone(990,990,.1,'square',.1,.07); },
  money(){ tone(880,1320,.09,'triangle',.12); tone(1320,1760,.1,'triangle',.1,.08); },
  buy(){ tone(520,780,.1,'triangle',.12); tone(780,1040,.12,'triangle',.12,.1); },
  deny(){ tone(160,110,.18,'square',.12); },
  shrine(){ [523,659,784,1046].forEach((f,i)=>tone(f,f,.22,'triangle',.1,i*.12)); },
  chest(){ [392,523,659].forEach((f,i)=>tone(f,f,.15,'square',.09,i*.09)); },
  bow(){ tone(700,300,.12,'sawtooth',.08); noise(.06,.07,3000); },
  boom(){ noise(.3,.25,300); tone(120,40,.3,'sawtooth',.18); },
  heal(){ tone(440,880,.25,'sine',.14); },
  upgrade(){ [523,659,784,1046,1318].forEach((f,i)=>tone(f,f,.2,'triangle',.12,i*.1)); },
  die(){ tone(300,40,.8,'sawtooth',.2); }
};

/* ============================== CONSTANTS ============================== */
const TS=32, MW=64, MH=44;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const T={GRASS:0,ROCK:1,TREE:2,WATER:3,PATH:4,CAVE:5,GATE:6,DOOR:7,FLOOR:8,RUIN:9};
const SOLID = new Set([T.ROCK,T.TREE,T.WATER,T.GATE,T.DOOR]);

/* ============================== MAP ============================== */
const grid=[];
for(let y=0;y<MH;y++){ grid[y]=[]; for(let x=0;x<MW;x++){
  grid[y][x] = (x<21||y<21) ? (x<21&&y<21?T.GRASS:(x<21?T.CAVE:T.GRASS)) : (x<43?T.RUIN:T.GRASS);
}}
for(let x=0;x<MW;x++){grid[0][x]=grid[MH-1][x]=T.ROCK;}
for(let y=0;y<MH;y++){grid[y][0]=grid[y][MW-1]=T.ROCK;}
function vwall(x,y0,y1,t){for(let y=y0;y<=y1;y++)grid[y][x]=t;}
function hwall(y,x0,x1,t){for(let x=x0;x<=x1;x++)grid[y][x]=t;}
vwall(20,1,19,T.ROCK); vwall(20,21,26,T.ROCK); vwall(20,31,42,T.ROCK); // meadow|forest, cave|ruins (gate at 27-30)
hwall(20,1,7,T.ROCK);  hwall(20,12,29,T.ROCK); hwall(20,34,42,T.ROCK); // gaps: 8-11 meadow->cave, 30-33 forest->ruins
vwall(43,1,19,T.ROCK); vwall(43,24,42,T.ROCK); // boss arena wall, door gap 20-23
grid[20][43]=T.DOOR; grid[21][43]=T.DOOR; grid[22][43]=T.DOOR; grid[23][43]=T.DOOR;
grid[27][20]=grid[28][20]=grid[29][20]=grid[30][20]=T.GATE;
// scatter trees / rocks / water decor
function scatter(x0,y0,x1,y1,t,n,rnd){ for(let i=0;i<n;i++){ const x=x0+Math.floor(rnd()*(x1-x0)), y=y0+Math.floor(rnd()*(y1-y0)); if(grid[y][x]===T.GRASS||grid[y][x]===T.CAVE||grid[y][x]===T.RUIN) grid[y][x]=t; } }
let seed=1337; const rnd=()=>{seed=(seed*16807)%2147483647; return seed/2147483647;};
scatter(2,2,19,19,T.TREE,26,rnd); scatter(22,2,42,19,T.TREE,30,rnd);
scatter(2,22,19,42,T.ROCK,18,rnd); scatter(22,22,42,42,T.RUIN,22,rnd);
scatter(45,2,62,42,T.TREE,10,rnd);
for(let x=4;x<=17;x++) grid[10][x]=T.PATH;   // meadow path
for(let y=11;y<=19;y++) grid[y][9]=grid[y][10]=T.PATH;
for(let x=9;x<=11;x++) grid[19][x]=grid[19][x]=T.PATH;
for(let y=21;y<=42;y+=1){ if(grid[y][10]===T.CAVE) grid[y][10]=T.CAVE; }
for(let x=30;x<=33;x++) grid[19][x]=T.PATH, grid[21][x]=T.RUIN;
// pond in forest
for(let y=5;y<=8;y++)for(let x=33;x<=37;x++)grid[y][x]=T.WATER;
// clear spawn/shop/shrine areas
function clearRect(x0,y0,x1,y1,t){for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)grid[y][x]=t;}
clearRect(4,3,9,6,T.FLOOR);           // shop hut floor
clearRect(8,13,12,16,T.GRASS);        // shrine 1
clearRect(33,12,38,15,T.GRASS);       // shrine 2 (forest)
clearRect(7,34,13,39,T.CAVE);         // golem arena
clearRect(24,27,30,33,T.RUIN);        // bow chest area
clearRect(35,33,41,39,T.RUIN);        // storm shrine
clearRect(47,14,61,30,T.RUIN);        // boss arena
grid[36][27]=T.RUIN; // chest spot floor

/* ============================== STATE ============================== */
const WEAPONS=[
  {name:'Rusty Sword', dmg:1, cd:.42, arc:1.0, reach:40, kb:120, col:'#b9c2cf'},
  {name:'Ember Blade', dmg:2, cd:.30, arc:1.3, reach:46, kb:160, col:'#ff8c3a', fire:true},
  {name:'Storm Cleaver', dmg:3, cd:.26, arc:2.1, reach:56, kb:260, col:'#7fd8ff', storm:true}
];
const ITEMS={
  beet:{name:'Heart Beet', desc:'Restores 2 health.', price:18, consumable:true, heal:2, icon:'🫒'},
  potion:{name:'Sun Potion', desc:'Fully restores health.', price:45, consumable:true, heal:99, icon:'🧪'},
  arrows:{name:'Arrow Bundle', desc:'+8 arrows.', price:20, consumable:true, arrows:8, icon:'🏹'},
  charm:{name:'Lucky Charm', desc:'Enemies drop 50% more gold.', price:90, passive:true, icon:'🍀'},
  boots:{name:'Swift Boots', desc:'Move 18% faster.', price:110, passive:true, icon:'👟'},
  amulet:{name:'Guardian Amulet', desc:'Reduce damage taken by 1.', price:130, passive:true, icon:'🛡️'},
  key:{name:'Old Key', desc:'Opens the rusted cave gate.', price:80, quest:true, icon:'🔑'},
  shard:{name:'Rune Shard', desc:'Fragment of the storm shrine.', quest:true, icon:'🔷'},
  sigil:{name:'Crown Sigil', desc:'Opens the arena door.', quest:true, icon:'👑'},
  ember:{name:'Ember Blade', quest:true, icon:'🔥'},
  storm:{name:'Storm Cleaver', quest:true, icon:'⚡'},
  bow:{name:'Hunter Bow', quest:true, icon:'🏹'},
  heart:{name:'Heart Container', quest:true, icon:'❤️'}
};
let P, enemies, projectiles, particles, pickups, objects, cutBushes, gameState, cam, time, playSeconds, kills;
function newPlayer(){ return {
  x:10*TS, y:13*TS, vx:0, vy:0, r:11, face:{x:0,y:1}, moving:false, stepT:0,
  hp:6, containers:3, iframes:0, atkT:0, atkCd:0, wep:0, bow:false, arrows:0,
  gold:15, inv:{}, flags:{}, dead:false, flash:0
};}
function hasItem(id){return (P.inv[id]||0)>0;}
function give(id,n){ P.inv[id]=(P.inv[id]||0)+(n||1); }
function midGame(){ return P.wep>=1 || hasItem('bow'); }

/* ============================== OBJECTS (chests, shrines, npcs) ============================== */
function buildObjects(){
  objects=[
    {type:'shop', x:6.5*TS, y:7.2*TS, name:'Peddler Marla'},
    {type:'shrine', x:10*TS, y:14.5*TS, id:0, name:'Shrine of Dawn'},
    {type:'shrine', x:35.5*TS, y:13.5*TS, id:1, name:'Shrine of Boughs'},
    {type:'shrine', x:10*TS, y:33*TS, id:2, name:'Shrine of Depths'},
    {type:'chest', x:27*TS, y:36*TS, id:'bow', name:'Old Chest', opened:false, req:null,
      give:()=>{P.bow=true; P.inv.arrows=(P.inv.arrows||0)+10;}},
    {type:'pedestal', x:10*TS, y:38*TS, id:'ember', name:'Ember Pedestal', taken:false},
    {type:'shrine2', x:38*TS, y:36*TS, id:'storm', name:'Storm Shrine', done:false},
    {type:'sign', x:12*TS, y:9*TS, text:'North-east: Bough Forest. South: Hollow Cave. East: the Sunken Ruins.'},
    {type:'sign', x:31*TS, y:22*TS, text:'Ruins ahead. The rusted cave gate (south-west) yields to an Old Key.'},
    {type:'sign', x:41*TS, y:21*TS, text:'Beyond this door waits the Shade Knight. Bring the Crown Sigil.'}
  ];
}
function buildEnemies(){
  enemies=[];
  const spawn=(type,x,y)=>enemies.push(makeEnemy(type,x*TS,y*TS));
  // forest slimes
  for(let i=0;i<7;i++) spawn('slime', 24+rnd()*17, 3+rnd()*15);
  for(let i=0;i<3;i++) spawn('bat', 24+rnd()*17, 3+rnd()*15);
  // cave
  for(let i=0;i<6;i++) spawn('bat', 3+rnd()*15, 23+rnd()*18);
  for(let i=0;i<4;i++) spawn('slime', 3+rnd()*15, 23+rnd()*18);
  spawn('golem', 10, 36);
  // ruins
  for(let i=0;i<6;i++) spawn('skeleton', 23+rnd()*18, 23+rnd()*18);
  for(let i=0;i<3;i++) spawn('wisp', 23+rnd()*18, 23+rnd()*18);
  spawn('wraith', 38, 26);
  spawn('boss', 54, 22);
}
const ETYPES={
  slime:   {hp:3, spd:52, dmg:1, r:12, gold:[2,5], col:'#6fd66f', name:'Slime'},
  bat:     {hp:2, spd:95, dmg:1, r:9,  gold:[1,4], col:'#a97fd6', name:'Bat', fly:true},
  skeleton:{hp:5, spd:66, dmg:2, r:12, gold:[4,8], col:'#e8e2d0', name:'Skeleton'},
  wisp:    {hp:3, spd:80, dmg:1, r:9,  gold:[3,6], col:'#7fe8ff', name:'Wisp', fly:true},
  golem:   {hp:16, spd:42, dmg:2, r:20, gold:[30,40], col:'#8b7355', name:'Stone Golem', mini:true, shardDrop:true, heartDrop:true},
  wraith:  {hp:14, spd:74, dmg:2, r:16, gold:[25,35], col:'#c9a0ff', name:'Ruins Wraith', mini:true, sigilDrop:true},
  boss:    {hp:40, spd:60, dmg:2, r:22, gold:[80,100], col:'#5a4a7a', name:'Shade Knight', boss:true}
};
function makeEnemy(type,x,y){ const t=ETYPES[type]; return {type,x,y,hp:t.hp,maxhp:t.hp,r:t.r,cd:0,stun:0,phase:rnd()*6.28,boss:!!t.boss,mini:!!t.mini,home:{x,y}}; }

/* ============================== PARTICLES / PICKUPS ============================== */
function puff(x,y,col,n,spd,life){
  for(let i=0;i<(n||8);i++){ const a=rnd()*6.283, s=(spd||70)*(.4+rnd()*.8);
    particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:life||.5,max:life||.5,col,size:2+rnd()*3});}
}
function dropPickup(x,y,kind,val){ pickups.push({x,y,kind,val,t:0,vx:(rnd()-.5)*60,vy:(rnd()-.5)*60}); }

/* ============================== UI HELPERS ============================== */
const $=id=>document.getElementById(id);
function toast(msg){ const d=document.createElement('div'); d.className='toast'; d.innerHTML=msg; $('toasts').appendChild(d); setTimeout(()=>d.remove(),3500); }
function drawHearts(){
  const h=$('hearts'); h.innerHTML='';
  for(let i=0;i<P.containers;i++){
    const s=document.createElement('span');
    s.style.cssText='font-size:22px;line-height:20px;text-shadow:1px 1px 2px #000;color:'+(v===2?'#ff4a5a':v===1?'#ff9ab0':'#3a3f4a');
    s.textContent='♥';
    h.appendChild(s);
  }
  $('goldTxt').textContent='💰 '+P.gold;
  $('arrowTxt').style.display=P.bow?'':'none';
  $('arrowTxt').textContent='🏹 '+P.arrows;
  $('wepTxt').textContent=(P.bow?'🏹 ':'🗡️ ')+WEAPONS[P.wep].name;
}
function openInv(){
  const l=$('invList'); l.innerHTML='';
  const ids=Object.keys(P.inv).filter(k=>P.inv[k]>0);
  if(!ids.length) l.innerHTML='<p class="small">Empty. Explore, fight, and shop to fill it.</p>';
  ids.forEach(id=>{
    const it=ITEMS[id];
    const d=document.createElement('div'); d.className='slot';
    d.innerHTML=`<span>${it.icon} <b>${it.name}</b> ×${P.inv[id]}<br><span class="small">${it.desc}</span></span>`;
    if(it.consumable){ const b=document.createElement('button'); b.textContent='Use';
      b.onclick=()=>{ useConsumable(id); openInv(); }; d.appendChild(b); }
    l.appendChild(d);
  });
  $('invPanel').style.display='flex';
}
function useConsumable(id){
  const it=ITEMS[id];
  if(!hasItem(id)) return false;
  if(id==='arrows'){ P.arrows+=it.arrows; give(id,-1); SFX.pickup(); toast('+'+it.arrows+' arrows'); return true; }
  if(it.heal){ if(P.hp>=P.containers*2){ toast('Already at full health.'); return false; }
    P.hp=Math.min(P.containers*2,P.hp+it.heal); give(id,-1); SFX.heal(); puff(P.x,P.y,'#ff7a9a',12,60,.6); drawHearts(); return true; }
  return false;
}
let shopOpen=false;
function openShop(){
  shopOpen=true; $('shopPanel').style.display='flex'; SFX.pickup();
  const stock=['beet','potion','arrows','charm','boots','amulet'];
  if(!hasItem('key') && !P.flags.gateOpen) stock.push('key');
  const l=$('shopList'); l.innerHTML='';
  $('shopGold').textContent='Your gold: '+P.gold;
  stock.forEach(id=>{
    const it=ITEMS[id];
    const d=document.createElement('div'); d.className='slot';
    d.innerHTML=`<span>${it.icon} <b>${it.name}</b> — ${it.price}g<br><span class="small">${it.desc}</span></span>`;
    const b=document.createElement('button'); b.textContent='Buy';
    b.disabled=P.gold<it.price;
    b.onclick=()=>{ if(P.gold<it.price){SFX.deny();return;} P.gold-=it.price; SFX.buy();
      if(id==='arrows'){P.arrows+=it.arrows;} else give(id,1);
      toast('Bought '+it.name+'!'); openShop(); drawHearts(); };
    d.appendChild(b); l.appendChild(d);
  });
}
function closePanels(){ $('invPanel').style.display='none'; $('shopPanel').style.display='none'; shopOpen=false; }

/* ============================== INPUT ============================== */
const keys={};
addEventListener('keydown',e=>{
  if(gameState!=='play') { if(e.key==='Enter'&&gameState==='title'){} return; }
  keys[e.code]=true;
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
  if(e.code==='KeyI'){ if($('invPanel').style.display==='flex') closePanels(); else {closePanels(); openInv();} }
  if(e.code==='KeyQ'){ for(const id of ['potion','beet']){ if(hasItem(id)&&useConsumable(id)) break; } }
  if(e.code==='KeyK') shootBow();
  if(e.code==='KeyE') interact();
});
addEventListener('keyup',e=>keys[e.code]=false);

/* ============================== COLLISION ============================== */
function solidAt(px,py){
  const x=Math.floor(px/TS), y=Math.floor(py/TS);
  if(x<0||y<0||x>=MW||y>=MH) return true;
  const t=grid[y][x];
  if(t===T.GATE) return !P.flags.gateOpen;
  if(t===T.DOOR) return !P.flags.sigilDoor;
  return SOLID.has(t);
}
function moveEntity(e,dx,dy){
  const r=e.r||10;
  if(dx){ const nx=e.x+dx, ex=nx+(dx>0?r:-r);
    if(!solidAt(ex,e.y-r+2)&&!solidAt(ex,e.y+r-2)) e.x=nx; }
  if(dy){ const ny=e.y+dy, ey=ny+(dy>0?r:-r);
    if(!solidAt(e.x-r+2,ey)&&!solidAt(e.x+r-2,ey)) e.y=ny; }
}
function tileAt(px,py){ const x=Math.floor(px/TS),y=Math.floor(py/TS); return (grid[y]&&grid[y][x])??T.ROCK; }

/* ============================== COMBAT ============================== */
function tryAttack(){
  if(P.atkCd>0||P.atkT>0) return;
  const w=WEAPONS[P.wep];
  P.atkT=.16; P.atkCd=w.cd; SFX.swing();
  const cx=P.x+P.face.x*w.reach*.6, cy=P.y+P.face.y*w.reach*.6;
  // slash particles
  for(let i=0;i<8;i++){ const a=Math.atan2(P.face.y,P.face.x)+(rnd()-.5)*w.arc;
    particles.push({x:P.x+Math.cos(a)*w.reach,y:P.y+Math.sin(a)*w.reach,vx:Math.cos(a)*80,vy:Math.sin(a)*80,
      life:.22,max:.22,col:w.col,size:3}); }
  for(const e of enemies){
    if(e.hp<=0) continue;
    const dx=e.x-P.x, dy=e.y-P.y, d=Math.hypot(dx,dy);
    if(d<w.reach+e.r){
      let ang=Math.atan2(dy,dx)-Math.atan2(P.face.y,P.face.x);
      while(ang>Math.PI)ang-=6.283; while(ang<-Math.PI)ang+=6.283;
      if(Math.abs(ang)<w.arc*0.7+.25 || d<24){
        damageEnemy(e,w.dmg,dx/d*(w.kb),dy/d*(w.kb),w);
      }
    }
  }
  // cut bushes
  for(const [k,b] of cutBushes){
    if(b.gone) continue;
    if(Math.hypot(b.x-P.x,b.y-P.y)<w.reach){ b.gone=true;
      puff(b.x,b.y,'#5fae4f',10,70,.5); SFX.hit();
      if(rnd()<.45) dropPickup(b.x,b.y,'gold',1+Math.floor(rnd()*3));
      else if(rnd()<.18) dropPickup(b.x,b.y,'heart',1);
    }
  }
}
function damageEnemy(e,dmg,kx,ky,w){
  e.hp-=dmg; e.stun=.18; e.x+=kx*.12; e.y+=ky*.12;
  puff(e.x,e.y,'#ffd76a',8,90,.35); if(w&&w.fire) puff(e.x,e.y,'#ff8c3a',10,60,.5);
  if(w&&w.storm){ puff(e.x,e.y,'#7fd8ff',12,110,.4); }
  SFX.hit();
  if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
  const t=ETYPES[e.type];
  puff(e.x,e.y,t.col,20,120,.7); SFX.boom(); kills++;
  const mult = hasItem('charm')?1.5:1;
  const g=Math.round((t.gold[0]+rnd()*(t.gold[1]-t.gold[0]))*mult);
  const nG=Math.min(6,Math.max(1,Math.round(g/4)));
  for(let i=0;i<nG;i++) dropPickup(e.x,e.y,'gold',Math.ceil(g/nG));
  if(rnd()<.14) dropPickup(e.x,e.y,'beet',1);
  if(t.shardDrop){ for(let i=0;i<2;i++) dropPickup(e.x,e.y,'shard',1); dropPickup(e.x,e.y,'heartC',1); toast('💀 The Stone Golem crumbles! It guarded something precious...'); }
  if(t.sigilDrop){ dropPickup(e.x,e.y,'sigil',1); toast('💀 The Wraith dissipates, dropping the Crown Sigil!'); }
  if(t.boss){ winGame(); }
}
function shootBow(){
  if(!P.bow||P.atkCd>0) return;
  if(P.arrows<=0){ toast('No arrows! Buy bundles at the shop.'); SFX.deny(); P.atkCd=.3; return; }
  P.arrows--; drawHearts(); SFX.bow(); P.atkCd=.34;
  projectiles.push({x:P.x,y:P.y,vx:P.face.x*420,vy:P.face.y*420,life:1.1,friendly:true,r:4});
}
function hurtPlayer(dmg,src){
  if(P.iframes>0||P.dead) return;
  if(hasItem('amulet')) dmg=Math.max(1,dmg-1);
  P.hp-=dmg; P.iframes=1.0; P.flash=.35; SFX.hurt();
  puff(P.x,P.y,'#ff5a5a',12,100,.5); drawHearts();
  if(src){ const dx=P.x-src.x, dy=P.y-src.y, d=Math.hypot(dx,dy)||1; P.vx+=dx/d*220; P.vy+=dy/d*220; }
  if(P.hp<=0) die();
}

/* ============================== ENEMY AI ============================== */
function updEnemies(dt){
  let boss=null;
  for(const e of enemies){
    if(e.hp<=0) continue;
    const t=ETYPES[e.type];
    if(t.boss) boss=e;
    e.cd-=dt; if(e.stun>0){e.stun-=dt; continue;}
    const dx=P.x-e.x, dy=P.y-e.y, d=Math.hypot(dx,dy);
    const aggro=t.boss?600:(t.mini?340:210);
    let mx=0,my=0;
    if(d<aggro&&!P.dead){
      if(e.type==='bat'||e.type==='wisp'){ // swooping
        e.phase+=dt*4;
        const a=Math.atan2(dy,dx)+Math.sin(e.phase)*.7;
        mx=Math.cos(a); my=Math.sin(a);
      } else { mx=dx/d; my=dy/d; }
      // ranged wisp shot
      if(e.type==='wisp'&&e.cd<=0&&d<260){ e.cd=2.2;
        projectiles.push({x:e.x,y:e.y,vx:dx/d*180,vy:dy/d*180,life:2,friendly:false,r:5,col:'#7fe8ff'}); }
      if(t.boss&&e.cd<=0&&d<300){ e.cd=1.6; // boss volley
        for(let i=-1;i<=1;i++){ const a=Math.atan2(dy,dx)+i*.35;
          projectiles.push({x:e.x,y:e.y,vx:Math.cos(a)*230,vy:Math.sin(a)*230,life:2,friendly:false,r:6,col:'#c9a0ff'});}
        SFX.swing();
      }
      if(d<t.r+P.r+4 && e.cd<=0){ e.cd=.9; hurtPlayer(t.dmg,e); }
    } else {
      e.phase+=dt; mx=Math.cos(e.phase*.7); my=Math.sin(e.phase*.9);
      if(Math.hypot(e.x-e.home.x,e.y-e.home.y)>140){ mx=(e.home.x-e.x)/100; my=(e.home.y-e.y)/100; }
    }
    const sp=t.spd*(t.boss&&e.hp<e.maxhp*.4?1.35:1);
    moveEntity(e,mx*sp*dt,my*sp*dt);
  }
  const bossNear = boss && Math.hypot(boss.x-P.x,boss.y-P.y)<560;
  $('bossBar').style.display = bossNear?'block':'none';
  if(boss) $('bossFill').style.width=(boss.hp/boss.maxhp*100)+'%';
}

/* ============================== PROJECTILES / PICKUPS ============================== */
function updProjectiles(dt){
  for(const p of projectiles){
    p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
    if(solidAt(p.x,p.y)){ p.life=0; puff(p.x,p.y,p.col||'#ddd',5,50,.3); }
    if(p.friendly){
      for(const e of enemies){ if(e.hp>0&&Math.hypot(e.x-p.x,e.y-p.y)<e.r+p.r){
        damageEnemy(e,2,(p.vx/420)*160,(p.vy/420)*160,null); p.life=0; break; } }
    } else if(Math.hypot(P.x-p.x,P.y-p.y)<P.r+p.r){ hurtPlayer(1,{x:p.x-p.vx,y:p.y-p.vy}); p.life=0; }
  }
  projectiles=projectiles.filter(p=>p.life>0);
}
function updPickups(dt){
  for(const k of pickups){
    k.t+=dt; k.x+=(k.vx||0)*dt; k.y+=(k.vy||0)*dt; k.vx*=.9; k.vy*=.9;
    const d=Math.hypot(P.x-k.x,P.y-k.y);
    if(k.t>.4&&d<70){ k.x+=(P.x-k.x)*dt*6; k.y+=(P.y-k.y)*dt*6; }
    if(k.t>.3&&d<18){
      k.dead=true;
      if(k.kind==='gold'){ P.gold+=k.val; SFX.money(); puff(k.x,k.y,'#ffd76a',5,40,.3); }
      else if(k.kind==='heart'){ P.hp=Math.min(P.containers*2,P.hp+2); SFX.heal(); puff(k.x,k.y,'#ff7a9a',8,60,.4); }
      else { give(k.kind,k.val); SFX.pickup(); puff(k.x,k.y,'#7fd8ff',8,60,.4);
             toast(ITEMS[k.kind].icon+' Got '+ITEMS[k.kind].name+'!'); }
      drawHearts();
    }
  }
  pickups=pickups.filter(k=>!k.dead);
}

/* ============================== INTERACTION ============================== */
let nearObj=null;
function interact(){
  if(!nearObj) return;
  const o=nearObj;
  if(o.type==='shop'){ openShop(); }
  else if(o.type==='sign'){ toast('🪧 '+o.text); SFX.pickup(); }
  else if(o.type==='shrine'){
    saveGame(o.id); SFX.shrine(); puff(o.x,o.y,'#ffe9a8',24,110,.9);
    P.hp=P.containers*2; drawHearts();
    toast('✨ Progress saved at the '+o.name+'. Health restored.');
  }
  else if(o.type==='chest'&&!o.opened){
    o.opened=true; SFX.chest(); puff(o.x,o.y,'#ffd76a',20,100,.8);
    if(o.id==='bow'){
      if(midGame()||true){}
      o.give(); toast('🏹 Found the Hunter Bow! Press K to fire — stock up on arrows.');
      if(!midGame()){}
    }
  }
  else if(o.type==='pedestal'&&!o.taken){
    o.taken=true; P.wep=Math.max(P.wep,1); give('ember',1); SFX.upgrade();
    puff(o.x,o.y,'#ff8c3a',30,130,1); toast('🔥 EMBER BLADE acquired! Faster swings, burning strikes.');
  }
  else if(o.type==='shrine2'){
    const shards=P.inv.shard||0;
    if(o.done){ toast('The storm sleeps contentedly.'); return; }
    if(shards>=3){ P.inv.shard-=3; o.done=true; P.wep=2; give('storm',1); SFX.upgrade();
      puff(o.x,o.y,'#7fd8ff',40,150,1.2); toast('⚡ STORM CLEAVER forged! Wide arcs, massive damage.'); }
    else { SFX.deny(); toast('The shrine hungers for rune shards... ('+shards+'/3). Ruins monsters guard them.'); }
  }
}

/* ============================== SAVE SYSTEM ============================== */
const SAVE_KEY='emberfall_save';
function saveGame(shrineId){
  const data={ p:{x:P.x,y:P.y,hp:P.containers*2,containers:P.containers,wep:P.wep,bow:P.bow,arrows:P.arrows,gold:P.gold,inv:P.inv},
    flags:P.flags, shrineId,
    objs:objects.map(o=>({opened:o.opened,taken:o.taken,done:o.done})),
    dead:enemies.map(e=>e.hp<=0?1:0), time:playSeconds, kills };
  localStorage.setItem(SAVE_KEY,JSON.stringify(data));
}
function loadGame(){
  const raw=localStorage.getItem(SAVE_KEY); if(!raw) return false;
  try{
    const d=JSON.parse(raw);
    resetWorld();
    Object.assign(P,d.p); P.iframes=1.5;
    P.flags=d.flags||{};
    d.objs.forEach((s,i)=>{ if(objects[i]){ objects[i].opened=s.opened; objects[i].taken=s.taken; objects[i].done=s.done; } });
    d.dead.forEach((v,i)=>{ if(enemies[i]&&v) enemies[i].hp=0; });
    playSeconds=d.time||0; kills=d.kills||0;
    // respawn at shrine
    const sh=objects.find(o=>o.type==='shrine'&&o.id===d.shrineId)||objects[1];
    P.x=sh.x; P.y=sh.y+28;
    drawHearts(); return true;
  }catch(err){ return false; }
}
function die(){
  P.dead=true; SFX.die(); puff(P.x,P.y,'#ff5a5a',30,150,1);
  setTimeout(()=>{ $('deadScreen').style.display='flex'; },700);
}
$('btnRespawn').onclick=()=>{
  $('deadScreen').style.display='none';
  const sh=objects.filter(o=>o.type==='shrine').sort((a,b)=>Math.hypot(a.x-P.x,a.y-P.y)-Math.hypot(b.x-P.x,b.y-P.y))[0];
  P.x=sh.x; P.y=sh.y+28; P.hp=P.containers*2; P.dead=false; P.iframes=2; P.gold=Math.floor(P.gold*.85);
  enemies.forEach(e=>{ if(!ETYPES[e.type].boss&&!ETYPES[e.type].mini&&e.hp<=0){ Object.assign(e,makeEnemy(e.type,e.home.x,e.home.y)); } });
  drawHearts(); toast('You awaken at the '+sh.name+'.');
};
function winGame(){
  gameState='win'; SFX.upgrade();
  $('winStats').innerHTML=`The Shade Knight falls. Light returns to Emberfall Vale.<br><br>⏱ Time: ${Math.floor(playSeconds/60)}m ${Math.floor(playSeconds%60)}s &nbsp;·&nbsp; 💀 Kills: ${kills} &nbsp;·&nbsp; 💰 Gold: ${P.gold}`;
  setTimeout(()=>{ $('winScreen').style.display='flex'; },900);
}

/* ============================== WORLD RESET / INIT ============================== */
function resetWorld(){
  buildObjects(); buildEnemies();
  projectiles=[]; particles=[]; pickups=[]; cutBushes=new Map();
  // bushes (cuttable)
  seed=777;
  for(let i=0;i<70;i++){ const x=2+Math.floor(rnd()*40), y=2+Math.floor(rnd()*40);
    const t=grid[y]&&grid[y][x];
    if((t===T.GRASS||t===T.RUIN)&&!cutBushes.has(x+','+y)) cutBushes.set(x+','+y,{x:x*TS+16,y:y*TS+16,gone:false}); }
}
function startGame(cont){
  audio();
  resetWorld();
  P=newPlayer();
  if(cont){ if(!loadGame()){ toast('No save found — starting fresh.'); } }
  else { localStorage.removeItem(SAVE_KEY); }
  gameState='play'; playSeconds=0; kills=0;
  $('titleScreen').style.display='none';
  drawHearts();
  toast(cont?'Welcome back to the vale.':'Welcome to Emberfall Vale. Talk to Marla, then head east!');
}
$('btnNew').onclick=()=>startGame(false);
$('btnCont').onclick=()=>startGame(true);
if(localStorage.getItem(SAVE_KEY)) $('btnCont').style.display='';

/* ============================== UPDATE ============================== */
function update(dt){
  time+=dt; playSeconds+=dt;
  if(P.flash>0)P.flash-=dt; if(P.iframes>0)P.iframes-=dt;
  if(P.atkT>0)P.atkT-=dt; if(P.atkCd>0)P.atkCd-=dt;

  // movement
  let ix=0,iy=0;
  if(keys.KeyW||keys.ArrowUp)iy--; if(keys.KeyS||keys.ArrowDown)iy++;
  if(keys.KeyA||keys.ArrowLeft)ix--; if(keys.KeyD||keys.ArrowRight)ix++;
  const len=Math.hypot(ix,iy)||1;
  let sp=118*(hasItem('boots')?1.18:1);
  P.moving=(ix||iy)&&P.atkT<=0;
  if(P.moving){ P.face.x=ix/len; P.face.y=iy/len;
    moveEntity(P,ix/len*sp*dt,iy/len*sp*dt);
    P.stepT-=dt;
    if(P.stepT<=0){ P.stepT= P.face.x!==0?.28:.26; SFX.step();
      particles.push({x:P.x-P.face.x*8,y:P.y+10,vx:0,vy:-8,life:.3,max:.3,col:'#c9bfa8',size:2}); }
  }
  P.x+=P.vx*dt; P.y+=P.vy*dt; P.vx*=Math.pow(.001,dt); P.vy*=Math.pow(.001,dt);

  if((keys.Space||keys.KeyJ)&&P.atkT<=0&&P.atkCd<=0) tryAttack();

  // gate / door checks
  if(!P.flags.gateOpen && hasItem('key') && tileAt(P.x,P.y+24)===T.GATE){
    P.flags.gateOpen=true; SFX.boom(); puff(P.x,P.y+24,'#c9a06a',30,120,1);
    toast('🔑 The Old Key turns — the rusted gate grinds open!');
  }
  if(!P.flags.sigilDoor && hasItem('sigil') && tileAt(P.x,P.y+24)===T.DOOR){
    P.flags.sigilDoor=true; SFX.boom(); puff(P.x,P.y+24,'#c9a0ff',30,120,1);
    toast('👑 The Crown Sigil resonates — the arena door opens. Fate awaits.');
  }

  // near object
  nearObj=null; let best=48;
  for(const o of objects){
    if(o.type==='chest'&&o.opened) continue;
    if(o.type==='pedestal'&&o.taken) continue;
    const d=Math.hypot(o.x-P.x,o.y-P.y);
    if(d<best){ best=d; nearObj=o; }
  }

  updEnemies(dt); updProjectiles(dt); updPickups(dt);
  for(const p of particles){ p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=.94; p.vy*=.94; }
  particles=particles.filter(p=>p.life>0);

  // enemy respawns (overworld only, keeps world alive)
  if(rnd()<dt*.02){
    const dead=enemies.filter(e=>e.hp<=0&&!ETYPES[e.type].boss&&!ETYPES[e.type].mini&&!ETYPES[e.type].heartDrop);
    if(dead.length){ const e=dead[Math.floor(rnd()*dead.length)];
      if(Math.hypot(e.home.x-P.x,e.home.y-P.y)>420){ Object.assign(e,makeEnemy(e.type,e.home.x,e.home.y)); } }
  }
}

/* ============================== RENDER ============================== */
function drawTile(t,x,y){
  const px=x*TS,py=y*TS;
  switch(t){
    case T.GRASS: ctx.fillStyle='#3e7a3a'; ctx.fillRect(px,py,TS,TS);
      if((x*7+y*13)%5===0){ctx.fillStyle='#4a8a44';ctx.fillRect(px+8,py+10,4,4);ctx.fillRect(px+20,py+18,4,4);} break;
    case T.CAVE: ctx.fillStyle='#2b2733'; ctx.fillRect(px,py,TS,TS);
      if((x*11+y*3)%7===0){ctx.fillStyle='#332e3d';ctx.fillRect(px+6,py+8,8,6);} break;
    case T.RUIN: ctx.fillStyle='#5a5348'; ctx.fillRect(px,py,TS,TS);
      if((x+y)%3===0){ctx.fillStyle='#655d50';ctx.fillRect(px+4,py+4,10,8);} break;
    case T.ROCK: ctx.fillStyle='#5c5c66'; ctx.fillRect(px,py,TS,TS);
      ctx.fillStyle='#4a4a54'; ctx.fillRect(px+2,py+2,TS-4,TS-6);
      ctx.fillStyle='#6d6d78'; ctx.fillRect(px+4,py+4,10,6); break;
    case T.TREE: ctx.fillStyle='#3e7a3a'; ctx.fillRect(px,py,TS,TS);
      ctx.fillStyle='#274e24'; ctx.beginPath(); ctx.arc(px+16,py+14,13,0,7); ctx.fill();
      ctx.fillStyle='#1d3a1b'; ctx.fillRect(px+13,py+22,6,9); break;
    case T.WATER: ctx.fillStyle='#2a5f8a'; ctx.fillRect(px,py,TS,TS);
      ctx.fillStyle='#3a76a8'; ctx.fillRect(px+((x*13+y*7)%16),py+8,12,3); break;
    case T.PATH: ctx.fillStyle='#a08a5f'; ctx.fillRect(px,py,TS,TS);
      ctx.fillStyle='#8f7a52'; if((x*5+y*9)%4===0)ctx.fillRect(px+10,py+14,8,5); break;
    case T.FLOOR: ctx.fillStyle='#7a5c3a'; ctx.fillRect(px,py,TS,TS);
      ctx.fillStyle='#6b4f31'; ctx.fillRect(px,py,TS,2); ctx.fillRect(px,py+16,TS,2); break;
    case T.GATE: ctx.fillStyle=P.flags.gateOpen?'#3e7a3a':'#6e5230'; ctx.fillRect(px,py,TS,TS);
      if(!P.flags.gateOpen){ ctx.fillStyle='#4a3820'; for(let i=0;i<4;i++)ctx.fillRect(px+3+i*8,py,4,TS);
        ctx.fillStyle='#8a8a92'; ctx.fillRect(px+10,py+12,12,8); } break;
    case T.DOOR: ctx.fillStyle=P.flags.sigilDoor?'#5a5348':'#3a2a5a'; ctx.fillRect(px,py,TS,TS);
      if(!P.flags.sigilDoor){ ctx.fillStyle='#7a5adf'; ctx.fillRect(px+12,py+8,8,16);
        ctx.fillStyle='#c9b0ff'; ctx.fillRect(px+14,py+13,4,6);} break;
  }
}
function drawPlayer(){
  const w=WEAPONS[P.wep];
  const bob=P.moving?Math.sin(time*11)*2.5:0;
  ctx.save(); ctx.translate(P.x,P.y+bob);
  if(P.iframes>0 && Math.floor(time*14)%2===0) ctx.globalAlpha=.4;
  // shadow
  ctx.fillStyle='rgba(0,0,0,.3)'; ctx.beginPath(); ctx.ellipse(0,13-bob,10,4,0,0,7); ctx.fill();
  // body
  ctx.fillStyle=P.flash>0?'#ff6a6a':'#3a6ea8'; ctx.fillRect(-7,-8,14,16);
  ctx.fillStyle=P.flash>0?'#ffaaaa':'#e8c898'; ctx.fillRect(-5,-16,10,10); // head
  ctx.fillStyle='#2a4a70'; ctx.fillRect(-5,8,4,6); ctx.fillRect(1,8,4,6);  // legs
  ctx.fillStyle='#7a4a2a'; ctx.fillRect(-6,-18,12,4); // hair
  // weapon
  const swing=P.atkT>0? (1-P.atkT/.16)*2.2-1.1 : .6;
  ctx.rotate(Math.atan2(P.face.y,P.face.x)+swing);
  ctx.fillStyle='#6b4f31'; ctx.fillRect(8,-2,8,4);
  ctx.fillStyle=w.col; ctx.fillRect(15,-2.5,18,5);
  if(P.wep===1){ ctx.fillStyle='#ffd76a'; ctx.fillRect(15,-1,18,2); }
  if(P.wep===2){ ctx.fillStyle='#fff'; ctx.fillRect(15,-3.5,20,2); ctx.fillRect(15,1.5,20,2); }
  ctx.restore();
  // bow on back
  if(P.bow){ ctx.strokeStyle='#8a6a3a'; ctx.lineWidth=2; ctx.beginPath();
    ctx.arc(P.x-8,P.y,7,Math.PI*.5,Math.PI*1.5); ctx.stroke(); }
  // slash arc
  if(P.atkT>0){
    const pr=1-P.atkT/.16, a0=Math.atan2(P.face.y,P.face.x)-w.arc/2, a1=a0+w.arc;
    ctx.strokeStyle=w.col; ctx.globalAlpha=.7*(1-pr); ctx.lineWidth=4;
    ctx.beginPath(); ctx.arc(P.x,P.y,w.reach*(0.6+pr*0.5),a0,a1); ctx.stroke(); ctx.globalAlpha=1;
  }
}
function drawEnemy(e){
  const t=ETYPES[e.type];
  ctx.save(); ctx.translate(e.x,e.y);
  ctx.fillStyle='rgba(0,0,0,.3)'; ctx.beginPath(); ctx.ellipse(0,t.r*.7,t.r*.8,t.r*.3,0,0,7); ctx.fill();
  const wob=Math.sin(time*6+e.phase)*2;
  if(e.type==='slime'){ ctx.fillStyle=t.col; ctx.beginPath(); ctx.ellipse(0,wob*.3,t.r,t.r*.8,0,0,7); ctx.fill();
    ctx.fillStyle='#fff'; ctx.fillRect(-4,-3,3,3); ctx.fillRect(2,-3,3,3); }
  else if(e.type==='bat'){ ctx.fillStyle=t.col; ctx.beginPath(); ctx.ellipse(0,0,6,5,0,0,7); ctx.fill();
    ctx.fillStyle=t.col; const f=Math.sin(time*14)*5;
    ctx.beginPath();ctx.moveTo(-5,0);ctx.lineTo(-14,-f);ctx.lineTo(-6,4);ctx.fill();
    ctx.beginPath();ctx.moveTo(5,0);ctx.lineTo(14,-f);ctx.lineTo(6,4);ctx.fill();
    ctx.fillStyle='#ff5a5a'; ctx.fillRect(-3,-2,2,2); ctx.fillRect(1,-2,2,2); }
  else if(e.type==='skeleton'){ ctx.fillStyle=t.col; ctx.fillRect(-6,-10,12,12); ctx.fillRect(-4,2,8,10);
    ctx.fillStyle='#111'; ctx.fillRect(-4,-7,3,3); ctx.fillRect(2,-7,3,3);
    ctx.fillStyle='#9a9a9a'; ctx.fillRect(8,-8,3,16); }
  else if(e.type==='wisp'){ ctx.globalAlpha=.8; ctx.fillStyle=t.col;
    ctx.beginPath(); ctx.arc(0,wob,t.r,0,7); ctx.fill();
    ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(-2,wob-2,3,0,7); ctx.fill(); }
  else if(e.type==='golem'){ ctx.fillStyle=t.col; ctx.fillRect(-16,-18,32,34);
    ctx.fillStyle='#6e5a40'; ctx.fillRect(-16,-18,32,6);
    ctx.fillStyle='#ffd76a'; ctx.fillRect(-9,-8,6,6); ctx.fillRect(4,-8,6,6); }
  else if(e.type==='wraith'){ ctx.globalAlpha=.85; ctx.fillStyle=t.col;
    ctx.beginPath(); ctx.moveTo(0,-18); ctx.quadraticCurveTo(16,0,8,16+wob); ctx.lineTo(-8,16-wob); ctx.quadraticCurveTo(-16,0,0,-18); ctx.fill();
    ctx.fillStyle='#fff'; ctx.fillRect(-6,-8,4,4); ctx.fillRect(3,-8,4,4); }
  else if(e.type==='boss'){ ctx.fillStyle='#2a1f3d'; ctx.fillRect(-16,-22,32,40);
    ctx.fillStyle=t.col; ctx.fillRect(-13,-19,26,14);
    ctx.fillStyle='#ff4a6a'; ctx.fillRect(-8,-14,5,4); ctx.fillRect(4,-14,5,4);
    ctx.fillStyle='#1a1230'; ctx.fillRect(-16,2,10,20); ctx.fillRect(6,2,10,20);
    ctx.fillStyle='#c9a0ff'; ctx.fillRect(-24,-6,8,26); ctx.fillRect(16,-6,8,26); }
  ctx.restore();
  // hp bar for tough enemies
  if(e.hp<e.maxhp&&(t.mini||t.boss)){
    ctx.fillStyle='#300'; ctx.fillRect(e.x-20,e.y-t.r-12,40,5);
    ctx.fillStyle=(t.boss?'#f55':'#fa4'); ctx.fillRect(e.x-20,e.y-t.r-12,40*e.hp/e.maxhp,5);
  }
}
function drawObject(o){
  ctx.save(); ctx.translate(o.x,o.y);
  if(o.type==='shop'){
    ctx.fillStyle='#8a5a3a'; ctx.fillRect(-26,-14,52,28);
    ctx.fillStyle='#c94a4a'; ctx.beginPath(); ctx.moveTo(-30,-14); ctx.lineTo(0,-32); ctx.lineTo(30,-14); ctx.fill();
    ctx.fillStyle='#ffe9a8'; ctx.fillRect(-8,-4,16,12);
    ctx.fillStyle='#e8c898'; ctx.beginPath(); ctx.arc(0,22,8,0,7); ctx.fill();
    ctx.fillStyle='#333'; ctx.fillRect(-4,20,8,3);
    ctx.fillStyle='#ffd76a'; ctx.font='12px sans-serif'; ctx.textAlign='center'; ctx.fillText('🛒',0,-18);
  } else if(o.type==='shrine'){
    const glow=.6+Math.sin(time*2.5)*.3;
    ctx.fillStyle='#7a8aa0'; ctx.fillRect(-10,-8,20,22);
    ctx.fillStyle='#a8b8cc'; ctx.fillRect(-13,-12,26,6);
    ctx.fillStyle=`rgba(255,233,168,${glow})`; ctx.beginPath(); ctx.arc(0,-16,7,0,7); ctx.fill();
    ctx.fillStyle='#fff'; ctx.font='11px sans-serif'; ctx.textAlign='center'; ctx.fillText('✨',0,-12);
  } else if(o.type==='chest'){
    ctx.fillStyle=o.opened?'#6b4f31':'#8a5c2a'; ctx.fillRect(-12,-8,24,16);
    ctx.fillStyle='#ffd76a'; ctx.fillRect(-3,-3,6,6);
    if(!o.opened){ ctx.fillStyle=`rgba(255,215,106,${.3+Math.sin(time*3)*.2})`; ctx.fillRect(-12,-14,24,4); }
  } else if(o.type==='pedestal'){
    ctx.fillStyle='#888'; ctx.fillRect(-8,-2,16,14); ctx.fillRect(-11,-6,22,5);
    if(!o.taken){ ctx.fillStyle='#ff8c3a'; ctx.beginPath();
      ctx.moveTo(0,-22); ctx.quadraticCurveTo(8,-12,0,-4); ctx.quadraticCurveTo(-8,-12,0,-22); ctx.fill();
      ctx.fillStyle=`rgba(255,140,58,${.25+Math.sin(time*4)*.15})`; ctx.beginPath(); ctx.arc(0,-13,14,0,7); ctx.fill(); }
  } else if(o.type==='shrine2'){
    ctx.fillStyle='#4a6a8a'; ctx.fillRect(-12,-10,24,24);
    ctx.fillStyle=o.done?'#7fd8ff':'#2a4a6a'; ctx.beginPath(); ctx.arc(0,-14,6,0,7); ctx.fill();
    if(!o.done){ const n=P.inv.shard||0;
      ctx.fillStyle='#cfe8ff'; ctx.font='10px sans-serif'; ctx.textAlign='center'; ctx.fillText('🔷'+n+'/3',0,-26); }
  } else if(o.type==='sign'){
    ctx.fillStyle='#6b4f31'; ctx.fillRect(-2,-2,4,14);
    ctx.fillStyle='#a08a5f'; ctx.fillRect(-10,-12,20,12);
  }
  ctx.restore();
  if(o===nearObj){
    ctx.fillStyle='#fff'; ctx.font='bold 11px sans-serif'; ctx.textAlign='center';
    ctx.fillText('[E] '+(o.type==='shop'?'Talk':o.type==='shrine'?'Save':o.type==='chest'?'Open':o.type==='pedestal'?'Take':o.type==='shrine2'?'Offer':'Read'),o.x,o.y-34);
  }
}
function drawPickup(k){
  const bob=Math.sin(time*5+k.x)*3;
  ctx.save(); ctx.translate(k.x,k.y+bob);
  if(k.kind==='gold'){ ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
    ctx.fillStyle='#c9a030'; ctx.fillRect(-2,-2,4,4); }
  else if(k.kind==='heart'){ ctx.fillStyle='#ff5a7a'; ctx.font='13px sans-serif'; ctx.textAlign='center'; ctx.fillText('♥',0,4); }
  else if(k.kind==='shard'){ ctx.fillStyle='#7fd8ff'; ctx.beginPath(); ctx.moveTo(0,-6);ctx.lineTo(5,0);ctx.lineTo(0,6);ctx.lineTo(-5,0); ctx.fill(); }
  else if(k.kind==='sigil'){ ctx.fillStyle='#ffd76a'; ctx.font='14px sans-serif'; ctx.textAlign='center'; ctx.fillText('👑',0,4); }
  else if(k.kind==='heartC'){ ctx.fillStyle='#ff2a4a'; ctx.font='18px sans-serif'; ctx.textAlign='center'; ctx.fillText('❤',0,6); }
  ctx.restore();
}
function render(){
  cam.x=Math.max(0,Math.min(MW*TS-cv.width,P.x-cv.width/2));
  cam.y=Math.max(0,Math.min(MH*TS-cv.height,P.y-cv.height/2));
  ctx.save(); ctx.translate(-Math.round(cam.x),-Math.round(cam.y));
  const x0=Math.floor(cam.x/TS), y0=Math.floor(cam.y/TS);
  for(let y=y0;y<=y0+cv.height/TS+1;y++)for(let x=x0;x<=x0+cv.width/TS+1;x++){
    if(grid[y]&&grid[y][x]!==undefined) drawTile(grid[y][x],x,y);
  }
  for(const [,b] of cutBushes){ if(b.gone) continue;
    ctx.fillStyle='#2f6b2c'; ctx.beginPath(); ctx.arc(b.x,b.y,11,0,7); ctx.fill();
    ctx.fillStyle='#3f8a3a'; ctx.beginPath(); ctx.arc(b.x-3,b.y-3,6,0,7); ctx.fill(); }
  for(const o of objects) drawObject(o);
  for(const k of pickups) drawPickup(k);
  for(const e of enemies) if(e.hp>0) drawEnemy(e);
  for(const p of projectiles){ ctx.fillStyle=p.col||'#e8e2d0';
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
    if(p.friendly){ ctx.strokeStyle='#8a6a3a'; ctx.beginPath(); ctx.moveTo(p.x,p.y); ctx.lineTo(p.x-p.vx*.03,p.y-p.vy*.03); ctx.stroke(); } }
  drawPlayer();
  for(const p of particles){ ctx.globalAlpha=Math.max(0,p.life/p.max); ctx.fillStyle=p.col;
    ctx.fillRect(p.x-p.size/2,p.y-p.size/2,p.size,p.size); }
  ctx.globalAlpha=1;
  // cave darkness
  const cx=P.x, cy=P.y;
  if(cx<21*TS&&cy>20*TS){
    const g=ctx.createRadialGradient(cx,cy,60,cx,cy,240);
    g.addColorStop(0,'rgba(0,0,10,0)'); g.addColorStop(1,'rgba(0,0,10,.82)');
    ctx.fillStyle=g; ctx.fillRect(0,20*TS,21*TS,(MH-20)*TS);
  }
  ctx.restore();
  // vignette
  const vg=ctx.createRadialGradient(cv.width/2,cv.height/2,cv.height/2.4,cv.width/2,cv.height/2,cv.height/1.1);
  vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,.45)');
  ctx.fillStyle=vg; ctx.fillRect(0,0,cv.width,cv.height);
}

/* ============================== LOOP ============================== */
let last=0;
function loop(ts){
  requestAnimationFrame(loop);
  const dt=Math.min(.05,(ts-last)/1000||0); last=ts;
  if(gameState==='play'&&!shopOpen&&$('invPanel').style.display!=='flex') update(dt);
  if(P) render();
}
gameState='title'; cam={x:0,y:0}; time=0; playSeconds=0; kills=0;
resetWorld(); P=newPlayer();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"a146d5f9-8891-48fc-b387-d30f78d64b73","tokensIn":393605,"tokensOut":19701,"tokensTotal":413306,"cost":0,"turns":14,"toolCalls":13,"failedToolCalls":1,"timestamp":"2026-08-21T20:13:26.742Z"} -->