← RPG results

RPG

GLM 5.3 Flash · 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 39,581 bytes · SHA-256 135f759b667c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Emberfall — a tiny action RPG</title>
<style>
  html,body{margin:0;height:100%;background:#0b0e14;overflow:hidden;font-family:'Trebuchet MS',Verdana,sans-serif;color:#e8e2d0}
  #wrap{position:relative;width:100vw;height:100vh}
  canvas{display:block;image-rendering:pixelated}
  #hud{position:absolute;inset:0;pointer-events:none}
  #toasts{position:absolute;left:50%;top:12%;transform:translateX(-50%);text-align:center;width:70%}
  .toast{display:inline-block;background:rgba(10,12,20,.85);border:1px solid #c9a24b;border-radius:8px;padding:8px 16px;margin:4px;font-size:16px;animation:fade 3.5s forwards}
  @keyframes fade{0%{opacity:0;transform:translateY(-8px)}10%{opacity:1;transform:none}80%{opacity:1}100%{opacity:0}}
  #prompt{position:absolute;left:50%;bottom:14%;transform:translateX(-50%);background:rgba(10,12,20,.85);border:1px solid #7a8ba0;border-radius:8px;padding:6px 14px;font-size:15px;display:none}
  #panel{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:#141a26;border:2px solid #c9a24b;border-radius:12px;padding:20px 26px;width:520px;max-height:80vh;overflow:auto;display:none;pointer-events:auto}
  #panel h2{margin:0 0 10px;color:#c9a24b;letter-spacing:2px}
  .invrow{display:flex;align-items:center;gap:10px;padding:6px 4px;border-bottom:1px solid #2a3244;font-size:15px}
  .invrow .ic{width:26px;height:26px;border-radius:6px;flex:0 0 26px}
  .invrow .use{margin-left:auto;pointer-events:auto;cursor:pointer;background:#c9a24b;color:#141a26;border:0;border-radius:6px;padding:4px 12px;font-weight:bold;font-family:inherit}
  .shoprow{display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid #2a3244;font-size:15px}
  .shoprow .buy{margin-left:auto;pointer-events:auto;cursor:pointer;background:#3f8f5f;color:#fff;border:0;border-radius:6px;padding:5px 14px;font-weight:bold;font-family:inherit}
  .shoprow .buy:disabled{background:#444;color:#888;cursor:default}
  #start{position:absolute;inset:0;background:radial-gradient(ellipse at center,#1a2130,#0b0e14);display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:auto;z-index:5}
  #start h1{font-size:52px;color:#c9a24b;letter-spacing:6px;margin:0 0 6px;text-shadow:0 0 24px #c9a24b66}
  #start p{max-width:560px;text-align:center;line-height:1.6;color:#aab4c4}
  #start button{margin-top:22px;font-size:20px;padding:12px 34px;background:#c9a24b;border:0;border-radius:10px;color:#141a26;font-weight:bold;cursor:pointer;font-family:inherit}
  #start .keys{margin-top:26px;font-size:14px;color:#7a8ba0}
  kbd{background:#232b3c;border:1px solid #3a445c;border-radius:4px;padding:1px 7px;color:#e8e2d0}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv"></canvas>
  <div id="hud">
    <div id="toasts"></div>
    <div id="prompt"></div>
  </div>
  <div id="panel"></div>
  <div id="start">
    <h1>EMBERFALL</h1>
    <p>The shrine flames of Emberfall have gone dark. Explore the meadow, forest and the deep caves, grow stronger, and defeat the Shadow Knight to relight the great flame.</p>
    <button id="btnStart">New Game</button>
    <button id="btnCont" style="display:none;background:#3f8f5f;color:#fff">Continue</button>
    <div class="keys"><kbd>WASD</kbd>/<kbd>←↑↓→</kbd> move &nbsp; <kbd>Shift</kbd> sprint &nbsp; <kbd>Space</kbd>/<kbd>J</kbd> attack &nbsp; <kbd>K</kbd> bow &nbsp; <kbd>E</kbd> interact &nbsp; <kbd>I</kbd> inventory</div>
  </div>
</div>
<script>
"use strict";
/* ============ AUDIO (WebAudio synth) ============ */
let AC=null;
function ac(){ if(!AC) AC=new (window.AudioContext||window.webkitAudioContext)(); return AC; }
function tone(f,dur,type,vol,slide){ try{
  const a=ac(),o=a.createOscillator(),g=a.createGain();
  o.type=type||'square'; o.frequency.value=f;
  if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),a.currentTime+dur);
  g.gain.setValueAtTime(vol||.12,a.currentTime);
  g.gain.exponentialRampToValueAtTime(.0001,a.currentTime+dur);
  o.connect(g).connect(a.destination); o.start(); o.stop(a.currentTime+dur);
}catch(e){} }
function noise(dur,vol,filt){ try{
  const a=ac(),n=a.sampleRate*dur,buf=a.createBuffer(1,n,a.sampleRate),d=buf.getChannelData(0);
  for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*(1-i/n);
  const s=a.createBufferSource();s.buffer=buf;
  const f=a.createBiquadFilter();f.type='lowpass';f.frequency.value=filt||900;
  const g=a.createGain();g.gain.value=vol||.15;
  s.connect(f).connect(g).connect(a.destination);s.start();
}catch(e){} }
const SFX={
  step(){ tone(90+Math.random()*30,.05,'triangle',.03); },
  swing(){ noise(.12,.12,2400); tone(300,.08,'sawtooth',.05,120); },
  hit(){ noise(.08,.2,1200); tone(160,.1,'square',.12,60); },
  hurt(){ tone(200,.25,'sawtooth',.18,70); },
  pickup(){ tone(660,.08,'square',.1); setTimeout(()=>tone(880,.1,'square',.1),70); },
  coin(){ tone(988,.06,'square',.09); setTimeout(()=>tone(1319,.12,'square',.09),60); },
  buy(){ tone(523,.08,'square',.1); setTimeout(()=>tone(659,.08,'square',.1),80); setTimeout(()=>tone(784,.14,'square',.1),160); },
  shoot(){ tone(700,.1,'triangle',.1,1400); },
  save(){ tone(392,.15,'triangle',.12); setTimeout(()=>tone(523,.15,'triangle',.12),140); setTimeout(()=>tone(659,.25,'triangle',.12),280); },
  heart(){ [523,659,784,1047].forEach((f,i)=>setTimeout(()=>tone(f,.2,'triangle',.12),i*110)); },
  boss(){ tone(80,.6,'sawtooth',.2,40); noise(.5,.2,300); },
  deny(){ tone(150,.15,'square',.1,100); },
  potion(){ tone(400,.1,'sine',.12,800); setTimeout(()=>tone(800,.15,'sine',.1,1200),90); },
  door(){ noise(.3,.2,400); tone(120,.3,'square',.1,60); }
};

/* ============ MAP ============ */
const T=32, MW=120, MH=90;
// tiles: 0 grass 1 rock 2 tree 3 water 4 path 5 cavefloor 6 cavewall 7 sand 8 flame(boss)
const map=new Uint8Array(MW*MH);
const idx=(x,y)=>y*MW+x;
const gT=(x,y)=>(x<0||y<0||x>=MW||y>=MH)?1:map[idx(x,y)];
const sT=(x,y,t)=>{ if(x>=0&&y>=0&&x<MW&&y<MH) map[idx(x,y)]=t; };
const solidT=t=>t===1||t===2||t===3||t===6;
function rect(x0,y0,x1,y1,t){for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)sT(x,y,t);}
function ellipse(cx,cy,rx,ry,t){for(let y=cy-ry;y<=cy+ry;y++)for(let x=cx-rx;x<=cx+rx;x++){const dx=(x-cx)/rx,dy=(y-cy)/ry;if(dx*dx+dy*dy<=1)sT(x,y,t);}}

function genMap(){
  rect(0,0,MW-1,MH-1,0);
  rect(0,0,MW-1,1,1); rect(0,MH-2,MW-1,MH-1,1); rect(0,0,1,MH-1,1); rect(MW-2,0,MW-1,MH-1,1);
  // lake
  ellipse(52,62,14,9,3); ellipse(52,62,12,7,3);
  // village (top-left) paths
  rect(6,10,30,10,4); rect(10,6,10,26,4); rect(6,26,30,26,4);
  // road from village to center & east
  rect(10,26,10,50,4); rect(10,50,70,50,4); rect(70,50,70,30,4); rect(70,30,96,30,4);
  // forest (right/top) trees
  for(let i=0;i<420;i++){ const x=78+Math.floor(Math.random()*38), y=4+Math.floor(Math.random()*44);
    if(gT(x,y)===0 && !(y>=28&&y<=32&&x>=70)) sT(x,y,2); }
  // forest clearing with chest area
  rect(100,14,110,22,0); rect(100,13,110,13,2); rect(100,23,110,23,2); rect(99,14,99,22,2); rect(111,14,111,22,2);
  rect(96,17,100,17,0); // entrance gap
  // sand shore around lake
  for(let y=50;y<=74;y++)for(let x=34;x<=70;x++){ if(gT(x,y)===0){ let near=false;
    for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++) if(gT(x+dx,y+dy)===3) near=true;
    if(near) sT(x,y,7);} }
  // CAVE region bottom-right: big rock mass with carved interior
  rect(78,58,116,86,1);
  // carve cave rooms
  rect(82,62,96,70,5);        // entry hall
  rect(96,64,104,80,5);       // vertical hall down
  rect(90,74,104,82,5);       // lower hall (mini-boss)
  rect(106,74,114,82,5);      // boss-ish side room w/ bow chest
  rect(100,60,104,64,5);      // alcove
  // cave entrance from outside (west side)
  rect(74,64,82,66,5); rect(70,64,74,66,0);
  // boss arena: north of forest behind gate
  rect(96,4,114,12,0);
  rect(95,4,95,12,1); rect(115,4,115,12,1); rect(96,3,114,3,1);
  rect(96,12,114,12,1); rect(104,12,106,12,0); // gate gap (locked door entity)
  rect(102,12,104,17,0); // path from gate down to forest clearing
}
genMap();
const inCave=(x,y)=> x>=74&&x<=116&&y>=58&&y<=87;

/* ============ STATE ============ */
const ITEMS={
  potion:{name:'Health Potion',desc:'Restores 4 health.',icon:'#e05555',use:'potion',stack:true},
  tonic:{name:'Stamina Tonic',desc:'Fully restores stamina.',icon:'#4fc3f7',use:'tonic',stack:true},
  boots:{name:'Swift Boots',desc:'Permanent +15% move speed.',icon:'#b39ddb',passive:true},
  charm:{name:'Iron Charm',desc:'Take 1 less damage (min 1).',icon:'#90a4ae',passive:true},
  coin:{name:'Lucky Coin',desc:'Enemies drop +50% gems.',icon:'#ffd54f',passive:true},
  lantern:{name:'Ember Lantern',desc:'Lights up the deep caves.',icon:'#ffb74d',passive:true},
  key:{name:'Cave Key',desc:'Opens the northern gate.',icon:'#c9a24b',quest:true},
  bow:{name:'Hunter\u2019s Bow',desc:'Ranged weapon. Press K. Uses arrows.',icon:'#a1887f',quest:true},
  wideblade:{name:'Wide Blade',desc:'Sword upgrade: wider arc, faster swing.',icon:'#eee',quest:true},
  runeblade:{name:'Rune Blade',desc:'Sword upgrade: fires sword beams!',icon:'#7ee8fa',quest:true},
  arrow:{name:'Arrow',desc:'Ammunition for the bow.',icon:'#ddd',stack:true},
};
const state={
  started:false, dead:false, won:false,
  hp:6, maxHp:6, maxHpCap:10, stamina:100, maxStam:100,
  gems:0, arrows:0,
  swordLv:1, hasBow:false,
  inv:[], flags:{}, // flags: chest ids, bossDead, miniDead, gateOpen
  savedAt:null
};
const player={x:12*T,y:12*T,vx:0,vy:0,face:2/*0up1left2down3right*/,spd:150,anim:0,swing:0,swingDir:0,hurtT:0,bowT:0,stepT:0};
let enemies=[],pickups=[],particles=[],arrows=[],interacts=[],beams=[];
let shake=0, paused=false, shopOpen=false, invOpen=false;

function has(id){ return state.inv.some(s=>s.id===id); }
function give(id,n=1){ const def=ITEMS[id];
  if(def.stack){ const s=state.inv.find(s=>s.id===id); if(s){s.n+=n;return;} state.inv.push({id,n}); }
  else if(!has(id)) state.inv.push({id,n:1});
}
function take(id,n=1){ const s=state.inv.find(s=>s.id===id); if(!s)return false; s.n-=n; if(s.n<=0)state.inv.splice(state.inv.indexOf(s),1); return true; }

/* ============ WORLD PLACEMENT ============ */
function chest(x,y,id,label){ interacts.push({type:'chest',x:x*T+16,y:y*T+16,id,label,open:()=>state.flags['ch_'+id]}); }
function shrine(x,y,name){ interacts.push({type:'shrine',x:x*T+16,y:y*T+16,name}); }
function npc(x,y,kind){ interacts.push({type:'npc',x:x*T+16,y:y*T+16,kind}); }
function door(x,y,w,h,need,label){ interacts.push({type:'door',x:x*T+16,y:y*T+16,w:w*T,h:h*T,need,label}); }
function setupWorld(){
  shrine(14,12,'Village Shrine');
  npc(16,9,'shop');
  npc(12,9,'elder');
  chest(105,18,'wideblade');       // forest clearing: sword upgrade 1
  chest(102,61,'lantern');         // cave alcove
  chest(110,78,'bow');             // deep cave room: BOW (mid-game)
  shrine(88,68,'Cave Shrine');
  shrine(105,8,'Flame Shrine');
  door(105,12,1,1,'key','Northern Gate');
  // mini-boss golem in lower cave
  enemies.push(mkEnemy('golem',97*T,78*T));
  // final boss
  enemies.push(mkEnemy('boss',105*T,7*T));
  // regular enemies
  const spawn=(t,x,y)=>enemies.push(mkEnemy(t,x*T,y*T));
  spawn('slime',26,20);spawn('slime',30,32);spawn('slime',22,40);spawn('slime',40,44);spawn('slime',56,40);
  spawn('slime',60,56);spawn('slime',36,58);spawn('slime',44,68);spawn('slime',62,70);
  spawn('bat',84,20);spawn('bat',92,26);spawn('bat',100,34);spawn('bat',88,40);spawn('bat',96,44);
  spawn('bat',80,52);spawn('bat',104,40);
  spawn('skel',86,66);spawn('skel',100,70);spawn('skel',92,78);spawn('skel',102,80);spawn('skel',108,76);spawn('skel',98,62);
  // gem pickups scattered
  for(let i=0;i<14;i++){ const spots=[[24,18],[28,28],[20,36],[46,40],[58,44],[38,50],[66,52],[30,60],[48,66],[58,72],[84,24],[94,36],[86,46],[100,44]];
    const s=spots[i]; pickups.push({kind:'gem',x:s[0]*T+16,y:s[1]*T+16,val:5}); }
  pickups.push({kind:'heart',x:44*T+16,y:54*T+16,val:2});
  pickups.push({kind:'arrowp',x:90*T+16,y:64*T+16,val:5});
  pickups.push({kind:'arrowp',x:106*T+16,y:76*T+16,val:8});
}
function mkEnemy(t,x,y){
  const D={slime:{hp:3,dmg:1,spd:55,r:12,col:'#6fcf6f',gem:6},
           bat:{hp:2,dmg:1,spd:95,r:10,col:'#b39ddb',gem:8},
           skel:{hp:6,dmg:2,spd:70,r:12,col:'#e8e2d0',gem:14},
           golem:{hp:26,dmg:2,spd:48,r:20,col:'#8d6e63',gem:60,boss:'mini',name:'Stone Golem'},
           boss:{hp:55,dmg:3,spd:62,r:22,col:'#5c3a8f',gem:120,boss:'final',name:'Shadow Knight'}}[t];
  return {t,x,y,hp:D.hp,maxhp:D.hp,dmg:D.dmg,spd:D.spd,r:D.r,col:D.col,gem:D.gem,boss:D.boss||null,name:D.name||null,
          kx:0,ky:0,hitT:0,cool:0,wt:0,wa:0,home:{x,y},anim:0};
}

/* ============ INPUT ============ */
const keys={};
addEventListener('keydown',e=>{
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault();
  keys[e.code]=true;
  if(!state.started)return;
  if(e.code==='KeyI'){ toggleInv(); }
  if(e.code==='KeyE') interact();
  if(e.code==='Space'||e.code==='KeyJ') attack();
  if(e.code==='KeyK') shootBow();
  if(e.code==='Digit1')useItem('potion');
  if(e.code==='Digit2')useItem('tonic');
});
addEventListener('keyup',e=>keys[e.code]=false);

/* ============ COMBAT ============ */
function swordStats(){
  if(state.swordLv===3)return{range:56,arc:2.2,dmg:3,cool:.28,beam:true};
  if(state.swordLv===2)return{range:50,arc:2.0,dmg:2,cool:.30,beam:false};
  return{range:40,arc:1.4,dmg:1,cool:.42,beam:false};
}
function attack(){
  if(player.swing>0||paused||state.dead)return;
  const s=swordStats();
  player.swing=s.cool; player.swingMax=s.cool; player.swingDir=player.face;
  SFX.swing();
  if(s.beam) fireBeam();
}
function fireBeam(){
  const a=[-Math.PI/2,Math.PI,0,Math.PI/2][player.face];
  beams.push({x:player.x,y:player.y,vx:Math.cos(a)*330,vy:Math.sin(a)*330,life:.9,dmg:2});
  SFX.shoot();
}
function shootBow(){
  if(!state.hasBow||paused||state.dead)return;
  if(player.bowT>0)return;
  if(state.arrows<=0){ toast('No arrows! Buy some at the shop.'); SFX.deny(); return; }
  state.arrows--; player.bowT=.45;
  const a=[-Math.PI/2,Math.PI,0,Math.PI/2][player.face];
  arrows.push({x:player.x,y:player.y,vx:Math.cos(a)*420,vy:Math.sin(a)*420,life:1.2});
  SFX.shoot();
}
function hurtPlayer(d){
  if(player.hurtT>0||state.dead)return;
  if(has('charm'))d=Math.max(1,d-1);
  state.hp-=d; player.hurtT=.9; shake=8; SFX.hurt();
  burst(player.x,player.y,'#e05555',14);
  if(state.hp<=0){ state.hp=0; die(); }
}
function hitEnemy(e,dmg,kx,ky){
  e.hp-=dmg; e.hitT=.15; e.kx+=kx; e.ky+=ky;
  burst(e.x,e.y,'#fff',8); SFX.hit();
  if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
  enemies.splice(enemies.indexOf(e),1);
  burst(e.x,e.y,e.col,22);
  let g=e.gem; if(has('coin'))g=Math.round(g*1.5);
  for(let i=0;i<Math.min(g,6);i++) pickups.push({kind:'gem',x:e.x+rand(-14,14),y:e.y+rand(-14,14),val:Math.ceil(g/Math.min(g,6))});
  if(e.boss==='mini'){
    state.flags.miniDead=true;
    pickups.push({kind:'key',x:e.x,y:e.y});
    setTimeout(()=>{ pickups.push({kind:'container',x:e.x+24,y:e.y}); },400);
    toast('The Stone Golem crumbles! It dropped something...');
    SFX.boss();
  }
  if(e.boss==='final'){
    state.flags.bossDead=true; state.won=true;
    give('runeblade'); state.swordLv=3;
    pickups.push({kind:'container',x:e.x,y:e.y});
    toast('THE SHADOW KNIGHT FALLS! You claim the Rune Blade!');
    setTimeout(()=>toast('The great flame is relit. You win! Press the Flame Shrine to save your victory.'),1500);
    SFX.heart(); SFX.boss();
  }
}

/* ============ PARTICLES ============ */
function burst(x,y,col,n){ for(let i=0;i<n;i++){ const a=Math.random()*6.28,s=rand(40,180);
  particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(.25,.6),col,sz:rand(2,4)}); } }
function sparkle(x,y,col){ for(let i=0;i<8;i++)particles.push({x:x+rand(-8,8),y:y+rand(-8,8),vx:rand(-20,20),vy:rand(-60,-20),life:rand(.4,.8),col,sz:2}); }
function rand(a,b){return a+Math.random()*(b-a);}

/* ============ INTERACT / SHOP / INVENTORY ============ */
function nearestInteract(){
  let best=null,bd=48*48;
  for(const it of interacts){ const d=(it.x-player.x)**2+(it.y-player.y)**2; if(d<bd){bd=d;best=it;} }
  return best;
}
function interact(){
  const it=nearestInteract(); if(!it||paused)return;
  if(it.type==='shrine'){ saveGame(it.name); }
  else if(it.type==='npc'&&it.kind==='shop'){ openShop(); }
  else if(it.type==='npc'&&it.kind==='elder'){
    toast(state.flags.miniDead? 'Elder: "The gate north of the forest awaits its key. Beware the Shadow Knight."'
      : state.swordLv>=2? 'Elder: "A bow lies hidden deep in the caves. Arrows fly where swords cannot."'
      : 'Elder: "Seek chests in the forest and caves. The shrine flames will keep your progress safe."');
  }
  else if(it.type==='chest'){
    if(state.flags['ch_'+it.id])return;
    state.flags['ch_'+it.id]=true; SFX.pickup();
    if(it.id==='wideblade'){ give('wideblade'); state.swordLv=Math.max(state.swordLv,2); toast('WIDE BLADE! Your sword swings wider and faster.'); }
    if(it.id==='lantern'){ give('lantern'); toast('EMBER LANTERN! The caves will no longer blind you.'); }
    if(it.id==='bow'){ give('bow'); state.hasBow=true; give('arrow',10); state.arrows+=10; toast('HUNTER\u2019S BOW! Press K to fire arrows.'); }
    sparkle(it.x,it.y,'#ffd54f'); burst(it.x,it.y,'#c9a24b',16);
  }
  else if(it.type==='door'){
    if(state.flags.gateOpen)return;
    if(has('key')){ state.flags.gateOpen=true; take('key'); SFX.door(); toast('The northern gate grinds open!'); burst(it.x,it.y,'#c9a24b',20); }
    else { toast('Locked. A keyhole shaped like a cave...'); SFX.deny(); }
  }
}
const SHOP=[['potion',30],['tonic',25],['arrow',8],['boots',120],['charm',100],['coin',150]];
function openShop(){ shopOpen=true; paused=true; SFX.buy(); renderShop(); showPanel(true); }
function renderShop(){
  const p=document.getElementById('panel');
  p.innerHTML='<h2>ITEM SHOP</h2><div style="color:#ffd54f;margin-bottom:8px">Gems: '+state.gems+'</div>'+
   SHOP.map(([id,cost],i)=>{ const d=ITEMS[id]; const owned=d.passive&&has(id);
    return '<div class="shoprow"><span class="ic" style="background:'+d.icon+'"></span><b>'+d.name+'</b> <span style="color:#ffd54f">'+cost+'g</span><span style="color:#7a8ba0;font-size:12px">'+d.desc+'</span>'+
    (owned?'<span style="margin-left:auto;color:#6fcf6f">OWNED</span>':'<button class="buy" data-i="'+i+'" '+(state.gems<cost?'disabled':'')+'>Buy</button>')+'</div>'; }).join('')+
   '<div style="margin-top:12px;text-align:right"><button class="buy" id="shopClose" style="background:#c9a24b;color:#141a26">Leave</button></div>';
  p.querySelectorAll('.buy[data-i]').forEach(b=>b.onclick=()=>{
    const [id,cost]=SHOP[+b.dataset.i];
    if(state.gems<cost){SFX.deny();return;}
    state.gems-=cost; give(id); if(id==='arrow')state.arrows+=5;
    SFX.buy(); toast('Bought '+ITEMS[id].name+'!'); renderShop();
  });
  document.getElementById('shopClose').onclick=closePanel;
}
function toggleInv(){ if(shopOpen)return; invOpen=!invOpen; if(invOpen){renderInv();showPanel(true);} else showPanel(false); }
function renderInv(){
  const p=document.getElementById('panel');
  let rows=state.inv.map((s,i)=>{ const d=ITEMS[s.id];
    return '<div class="invrow"><span class="ic" style="background:'+d.icon+'"></span><b>'+d.name+'</b>'+(s.n>1?' x'+s.n:'')+
    '<span style="color:#7a8ba0;font-size:12px">'+d.desc+'</span>'+
    (d.use?'<button class="use" data-id="'+s.id+'">Use</button>':'')+'</div>'; }).join('');
  p.innerHTML='<h2>INVENTORY</h2><div style="font-size:13px;color:#7a8ba0;margin-bottom:8px">Sword: '+['Rusty Sword','Wide Blade','Rune Blade'][state.swordLv-1]+
   ' &nbsp;|&nbsp; '+(state.hasBow?'Bow, arrows: '+state.arrows:'No ranged weapon yet')+'</div>'+
   (rows||'<div style="color:#7a8ba0">Empty. Explore!</div>')+
   '<div style="margin-top:12px;text-align:right"><button class="buy" id="invClose" style="background:#c9a24b;color:#141a26">Close</button></div>';
  p.querySelectorAll('.use').forEach(b=>b.onclick=()=>{ useItem(b.dataset.id); renderInv(); });
  document.getElementById('invClose').onclick=()=>{invOpen=false;showPanel(false);};
}
function useItem(id){
  if(!has(id))return;
  if(id==='potion'){ if(state.hp>=state.maxHp){toast('Health is full.');return;} state.hp=Math.min(state.maxHp,state.hp+4); take('potion'); SFX.potion(); sparkle(player.x,player.y,'#e05555'); toast('Glug! +4 health.'); }
  if(id==='tonic'){ state.stamina=state.maxStam; take('tonic'); SFX.potion(); sparkle(player.x,player.y,'#4fc3f7'); toast('Stamina restored!'); }
  if(invOpen)renderInv();
}
function showPanel(v){ document.getElementById('panel').style.display=v?'block':'none'; }
function closePanel(){ shopOpen=false; invOpen=false; paused=false; showPanel(false); }

/* ============ SAVE ============ */
function saveGame(name){
  const data={v:1,hp:state.hp,maxHp:state.maxHp,gems:state.gems,arrows:state.arrows,swordLv:state.swordLv,
    hasBow:state.hasBow,inv:state.inv,flags:state.flags,
    x:player.x,y:player.y,
    dead:enemies.filter(e=>e.boss).map(e=>e.boss)};
  localStorage.setItem('emberfall',JSON.stringify(data));
  SFX.save(); sparkle(player.x,player.y,'#7ee8fa'); sparkle(player.x,player.y,'#ffd54f');
  toast('Progress saved at the '+name+'!');
}
function loadGame(){
  try{
    const d=JSON.parse(localStorage.getItem('emberfall')); if(!d)return false;
    state.hp=d.hp;state.maxHp=d.maxHp;state.gems=d.gems;state.arrows=d.arrows;state.swordLv=d.swordLv;
    state.hasBow=d.hasBow;state.inv=d.inv;state.flags=d.flags;player.x=d.x;player.y=d.y;
    if((d.dead||[]).includes('mini')){ const g=enemies.find(e=>e.boss==='mini'); if(g)enemies.splice(enemies.indexOf(g),1); }
    if((d.dead||[]).includes('final')){ const b=enemies.find(e=>e.boss==='final'); if(b)enemies.splice(enemies.indexOf(b),1); state.won=true; }
    return true;
  }catch(e){return false;}
}

/* ============ TOASTS ============ */
function toast(msg){ const t=document.createElement('div');t.className='toast';t.textContent=msg;
  const box=document.getElementById('toasts');box.appendChild(t);setTimeout(()=>t.remove(),3500);
  while(box.children.length>3)box.firstChild.remove(); }

/* ============ UPDATE ============ */
function update(dt){
  if(paused||state.dead)return;
  // movement
  let dx=0,dy=0;
  if(keys.KeyA||keys.ArrowLeft)dx--; if(keys.KeyD||keys.ArrowRight)dx++;
  if(keys.KeyW||keys.ArrowUp)dy--; if(keys.KeyS||keys.ArrowDown)dy++;
  const sprint=(keys.ShiftLeft||keys.ShiftRight)&&state.stamina>0&&(dx||dy);
  let spd=player.spd*(has('boots')?1.15:1)*(sprint?1.55:1);
  if(sprint)state.stamina=Math.max(0,state.stamina-26*dt);
  else state.stamina=Math.min(state.maxStam,state.stamina+16*dt);
  if(dx&&dy){dx*=.707;dy*=.707;}
  if(dx||dy){
    player.face=Math.abs(dx)>Math.abs(dy)?(dx>0?3:1):(dy>0?2:0);
    player.anim+=dt*(sprint?14:9);
    player.stepT-=dt*(sprint?1.8:1);
    if(player.stepT<=0){player.stepT=.32;SFX.step(); if(Math.random()<.5)particles.push({x:player.x+rand(-6,6),y:player.y+12,vx:0,vy:-8,life:.4,col:'#00000033',sz:3});}
  } else player.anim=0;
  moveEnt(player,dx*spd*dt,dy*spd*dt,10);
  // timers
  if(player.swing>0)player.swing-=dt;
  if(player.hurtT>0)player.hurtT-=dt;
  if(player.bowT>0)player.bowT-=dt;
  if(shake>0)shake-=dt*30;
  // sword hit detection
  if(player.swing>player.swingMax-.12){
    const s=swordStats(), a=[-Math.PI/2,Math.PI,0,Math.PI/2][player.swingDir];
    for(const e of enemies){
      const d=Math.hypot(e.x-player.x,e.y-player.y);
      if(d<s.range+e.r){ let ea=Math.atan2(e.y-player.y,e.x-player.x), diff=Math.abs(((ea-a+Math.PI*3)%(Math.PI*2))-Math.PI);
        if(diff<s.arc/2+ (e.r/Math.max(d,1))) hitEnemy(e,s.dmg,Math.cos(ea)*180,Math.sin(ea)*180); }
    }
  }
  // enemies
  for(const e of enemies){
    if(e.hitT>0)e.hitT-=dt;
    e.cool-=dt;
    const d=Math.hypot(player.x-e.x,player.y-e.y);
    const aggro=e.boss?600:(e.t==='skel'?260:200);
    if(d<aggro&&d>4){
      let ax=(player.x-e.x)/d, ay=(player.y-e.y)/d;
      if(e.t==='bat'){ e.wt-=dt; if(e.wt<=0){e.wt=.5;e.wa=Math.atan2(ay,ax)+rand(-1,1);} ax=Math.cos(e.wa);ay=Math.sin(e.wa); }
      moveEnt(e,ax*e.spd*dt,ay*e.spd*dt,e.r);
      e.anim+=dt*8;
    } else if(d>aggro*1.4&&!e.boss){ // wander home
      e.wt-=dt; if(e.wt<=0){e.wt=rand(1,3);e.wa=Math.random()*6.28;}
      if(Math.hypot(e.home.x-e.x,e.home.y-e.y)>120){ e.wa=Math.atan2(e.home.y-e.y,e.home.x-e.x); }
      moveEnt(e,Math.cos(e.wa)*e.spd*.4*dt,Math.sin(e.wa)*e.spd*.4*dt,e.r);
    }
    // contact damage
    if(d<e.r+14&&e.cool<=0){ e.cool=.8; hurtPlayer(e.dmg);
      const a=Math.atan2(player.y-e.y,player.x-e.x); moveEnt(player,Math.cos(a)*14,Math.sin(a)*14,10); }
    // knockback
    if(Math.abs(e.kx)>1||Math.abs(e.ky)>1){ moveEnt(e,e.kx*dt,e.ky*dt,e.r); e.kx*=.85;e.ky*=.85; }
  }
  // arrows
  for(let i=arrows.length-1;i>=0;i--){ const a=arrows[i];
    a.x+=a.vx*dt;a.y+=a.vy*dt;a.life-=dt;
    const tx=Math.floor(a.x/T),ty=Math.floor(a.y/T);
    let hit=solidT(gT(tx,ty));
    for(const e of enemies){ if(Math.hypot(e.x-a.x,e.y-a.y)<e.r+4){ hitEnemy(e,2,a.vx*.3,a.vy*.3); hit=true; break; } }
    if(hit||a.life<=0){ burst(a.x,a.y,'#ddd',5); arrows.splice(i,1); }
  }
  // beams
  for(let i=beams.length-1;i>=0;i--){ const b=beams[i];
    b.x+=b.vx*dt;b.y+=b.vy*dt;b.life-=dt;
    let hit=solidT(gT(Math.floor(b.x/T),Math.floor(b.y/T)));
    for(const e of enemies){ if(Math.hypot(e.x-b.x,e.y-b.y)<e.r+8){ hitEnemy(e,b.dmg,b.vx*.2,b.vy*.2); hit=true; break; } }
    if(hit||b.life<=0){ burst(b.x,b.y,'#7ee8fa',8); beams.splice(i,1); }
  }
  // pickups
  for(let i=pickups.length-1;i>=0;i--){ const p=pickups[i];
    const d=Math.hypot(p.x-player.x,p.y-player.y);
    if(d<70){ p.x+=(player.x-p.x)*6*dt; p.y+=(player.y-p.y)*6*dt; }
    if(d<20){
      pickups.splice(i,1);
      if(p.kind==='gem'){ state.gems+=p.val; SFX.coin(); sparkle(p.x,p.y,'#ffd54f'); }
      else if(p.kind==='heart'){ state.hp=Math.min(state.maxHp,state.hp+p.val); SFX.pickup(); sparkle(p.x,p.y,'#e05555'); }
      else if(p.kind==='arrowp'){ state.arrows+=p.val; SFX.pickup(); sparkle(p.x,p.y,'#ddd'); toast('+'+p.val+' arrows'); }
      else if(p.kind==='key'){ give('key'); SFX.pickup(); toast('You got the CAVE KEY! It opens the northern gate.'); }
      else if(p.kind==='container'){ if(state.maxHp<state.maxHpCap){ state.maxHp+=2; state.hp=state.maxHp; SFX.heart(); burst(p.x,p.y,'#e05555',24); toast('HEART CONTAINER! Maximum health increased!'); } else { state.hp=state.maxHp; SFX.pickup(); } }
    }
  }
  // particles
  for(let i=particles.length-1;i>=0;i--){ const p=particles[i];
    p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=60*dt;p.life-=dt; if(p.life<=0)particles.splice(i,1); }
  // prompt
  const it=nearestInteract();
  const pr=document.getElementById('prompt');
  if(it&&!paused){ pr.style.display='block';
    pr.textContent= it.type==='shrine'?'[E] Pray at the '+it.name+' (save)':
      it.type==='npc'?(it.kind==='shop'?'[E] Browse the Shop':'[E] Talk to the Elder'):
      it.type==='chest'?(it.open()?'':'[E] Open Chest'):
      it.type==='door'?(state.flags.gateOpen?'':'[E] '+it.label):'';
    if(it.type==='chest'&&it.open())pr.style.display='none';
    if(it.type==='door'&&state.flags.gateOpen)pr.style.display='none';
  } else pr.style.display='none';
}
function moveEnt(e,dx,dy,r){
  const passable=(x,y)=>{ const t=gT(Math.floor(x/T),Math.floor(y/T)); if(solidT(t))return false;
    if(t===undefined)return false; return true; };
  // door blocks
  const gate=interacts.find(i=>i.type==='door'&&!state.flags.gateOpen);
  let nx=e.x+dx, ny=e.y+dy;
  const blocked=(x,y)=>{
    if(!passable(x+r*.6,y)||!passable(x-r*.6,y)||!passable(x,y+r*.6)||!passable(x,y-r*.6))return true;
    if(gate&&x>gate.x-gate.w/2-10&&x<gate.x+gate.w/2+10&&Math.abs(y-gate.y)<gate.h/2+10)return true;
    return false; };
  if(!blocked(nx,e.y))e.x=nx;
  if(!blocked(e.x,ny))e.y=ny;
}

/* ============ DRAW ============ */
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
function resize(){cv.width=innerWidth;cv.height=innerHeight;}
addEventListener('resize',resize);resize();
const COL={0:'#4a7c3f',1:'#5a5f6b',2:'#2f5d2a',3:'#2e6f9e',4:'#a58d5f',5:'#3a3644',6:'#232030',7:'#cbb37a',8:'#ff9d3b'};
function draw(){
  const camX=player.x-cv.width/2, camY=player.y-cv.height/2;
  ctx.save();
  if(shake>0)ctx.translate(rand(-shake,shake),rand(-shake,shake));
  ctx.fillStyle='#1a1f2a';ctx.fillRect(0,0,cv.width,cv.height);
  const x0=Math.max(0,Math.floor(camX/T)),y0=Math.max(0,Math.floor(camY/T)),
        x1=Math.min(MW-1,Math.ceil((camX+cv.width)/T)),y1=Math.min(MH-1,Math.ceil((camY+cv.height)/T));
  for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){
    const t=gT(x,y);
    ctx.fillStyle=COL[t]||'#000';
    ctx.fillRect(x*T-camX,y*T-camY,T,T);
    if(t===2){ ctx.fillStyle='#1d4019'; ctx.beginPath();ctx.arc(x*T+16-camX,y*T+16-camY,12,0,7);ctx.fill();
      ctx.fillStyle='#5a3d22';ctx.fillRect(x*T+14-camX,y*T+20-camY,4,8); }
    if(t===1){ ctx.fillStyle='#454a55'; ctx.fillRect(x*T-camX,y*T-camY,T,4); }
    if(t===6){ ctx.fillStyle='#1a1826'; ctx.fillRect(x*T-camX,y*T-camY,T,3); }
    if(t===5&&(x+y)%7===0){ ctx.fillStyle='#443f52'; ctx.fillRect(x*T+8-camX,y*T+8-camY,6,6); }
    if(t===3){ ctx.fillStyle='#3f83b8'; ctx.fillRect(x*T-camX+((x*13+y*7)%20),y*T-camY+((y*11+x*5)%20),6,3); }
  }
  // interacts
  for(const it of interacts){
    const sx=it.x-camX,sy=it.y-camY;
    if(sx<-60||sy<-60||sx>cv.width+60||sy>cv.height+60)continue;
    if(it.type==='shrine'){ ctx.fillStyle='#6b6b78';ctx.fillRect(sx-10,sy-4,20,14);
      ctx.fillStyle=it.name==='Flame Shrine'?'#ff9d3b':'#7ee8fa';ctx.beginPath();ctx.arc(sx,sy-10,6+Math.sin(perf/200)*2,0,7);ctx.fill(); }
    if(it.type==='npc'){ ctx.fillStyle=it.kind==='shop'?'#c9a24b':'#7a8ba0';
      ctx.fillRect(sx-9,sy-20,18,26); ctx.fillStyle='#e8d5b5';ctx.beginPath();ctx.arc(sx,sy-24,7,0,7);ctx.fill(); }
    if(it.type==='chest'){ ctx.fillStyle=it.open()?'#6b5232':'#8a6a3a';ctx.fillRect(sx-13,sy-10,26,18);
      ctx.fillStyle='#c9a24b';ctx.fillRect(sx-3,sy-4,6,6); }
    if(it.type==='door'&&!state.flags.gateOpen){ ctx.fillStyle='#3a3226';ctx.fillRect(sx-it.w/2,sy-it.h/2,it.w,it.h);
      ctx.fillStyle='#c9a24b';ctx.beginPath();ctx.arc(sx,sy,5,0,7);ctx.fill(); }
  }
  // pickups
  for(const p of pickups){ const sx=p.x-camX,sy=p.y-camY+Math.sin(perf/300+p.x)*3;
    if(p.kind==='gem'){ ctx.fillStyle='#ffd54f';ctx.beginPath();ctx.moveTo(sx,sy-6);ctx.lineTo(sx+5,sy);ctx.lineTo(sx,sy+6);ctx.lineTo(sx-5,sy);ctx.fill(); }
    if(p.kind==='heart'){ drawHeart(sx,sy,7,'#e05555'); }
    if(p.kind==='arrowp'){ ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(sx-6,sy+6);ctx.lineTo(sx+6,sy-6);ctx.stroke(); }
    if(p.kind==='key'){ ctx.fillStyle='#c9a24b';ctx.beginPath();ctx.arc(sx-3,sy,5,0,7);ctx.fill();ctx.fillRect(sx+1,sy-1,9,3); }
    if(p.kind==='container'){ drawHeart(sx,sy,11,'#ff2e4d'); }
  }
  // enemies
  for(const e of enemies){ const sx=e.x-camX,sy=e.y-camY;
    ctx.save();
    if(e.hitT>0){ctx.globalAlpha=.6;}
    const bob=Math.sin(e.anim)*2;
    if(e.t==='slime'){ ctx.fillStyle=e.col;ctx.beginPath();ctx.ellipse(sx,sy+bob,13,9,0,0,7);ctx.fill();
      ctx.fillStyle='#1a1f2a';ctx.fillRect(sx-5,sy-3+bob,3,3);ctx.fillRect(sx+2,sy-3+bob,3,3); }
    else if(e.t==='bat'){ ctx.fillStyle=e.col;const w=Math.sin(e.anim*3)*6;
      ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(sx-16,sy-6+w);ctx.lineTo(sx-8,sy+2);ctx.fill();
      ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(sx+16,sy-6+w);ctx.lineTo(sx+8,sy+2);ctx.fill();
      ctx.beginPath();ctx.arc(sx,sy,6,0,7);ctx.fill(); ctx.fillStyle='#e05555';ctx.fillRect(sx-3,sy-2,2,2);ctx.fillRect(sx+1,sy-2,2,2); }
    else if(e.t==='skel'){ ctx.fillStyle=e.col;ctx.fillRect(sx-4,sy-14+bob,8,10);ctx.beginPath();ctx.arc(sx,sy-18+bob,6,0,7);ctx.fill();
      ctx.fillStyle='#1a1f2a';ctx.fillRect(sx-3,sy-19+bob,2,2);ctx.fillRect(sx+1,sy-19+bob,2,2); }
    else { // bosses
      ctx.fillStyle=e.hitT>0?'#fff':e.col;
      if(e.boss==='mini'){ ctx.fillRect(sx-18,sy-24+bob,36,34); ctx.fillStyle='#6d4c41';ctx.fillRect(sx-18,sy-24+bob,36,8);
        ctx.fillStyle='#ff7043';ctx.fillRect(sx-9,sy-12+bob,6,6);ctx.fillRect(sx+3,sy-12+bob,6,6); }
      else { ctx.beginPath();ctx.arc(sx,sy-6+bob,20,0,7);ctx.fill();
        ctx.fillStyle='#b388ff';ctx.beginPath();ctx.arc(sx-7,sy-10+bob,4,0,7);ctx.arc(sx+7,sy-10+bob,4,0,7);ctx.fill();
        ctx.fillStyle='#b388ff';ctx.fillRect(sx-10,sy+2+bob,20,3); }
    }
    ctx.restore();
    if(e.hp<e.maxhp){ ctx.fillStyle='#300';ctx.fillRect(sx-14,sy-e.r-16,28,4);
      ctx.fillStyle='#e05555';ctx.fillRect(sx-14,sy-e.r-16,28*e.hp/e.maxhp,4); }
    if(e.boss){ ctx.fillStyle='#fff';ctx.font='11px Verdana';ctx.textAlign='center';ctx.fillText(e.name,sx,sy-e.r-20); }
  }
  // arrows & beams
  ctx.strokeStyle='#ddd';ctx.lineWidth=3;
  for(const a of arrows){ const sx=a.x-camX,sy=a.y-camY;ctx.beginPath();ctx.moveTo(sx-a.vx*.02,sy-a.vy*.02);ctx.lineTo(sx,sy);ctx.stroke(); }
  for(const b of beams){ const sx=b.x-camX,sy=b.y-camY;
    ctx.fillStyle='#7ee8fa';ctx.save();ctx.translate(sx,sy);ctx.rotate(Math.atan2(b.vy,b.vx));ctx.fillRect(-16,-5,32,10);ctx.restore(); }
  // player
  drawPlayer(camX,camY);
  // particles
  for(const p of particles){ ctx.globalAlpha=Math.max(0,p.life*2);ctx.fillStyle=p.col;
    ctx.fillRect(p.x-camX-p.sz/2,p.y-camY-p.sz/2,p.sz,p.sz); }
  ctx.globalAlpha=1;
  // cave darkness
  if(inCaveNow()&&!has('lantern')){
    const sx=player.x-camX,sy=player.y-camY;
    const g=ctx.createRadialGradient(sx,sy,40,sx,sy,150);
    g.addColorStop(0,'rgba(0,0,10,0)');g.addColorStop(1,'rgba(0,0,10,.96)');
    ctx.fillStyle=g;ctx.fillRect(0,0,cv.width,cv.height);
  }
  ctx.restore();
  drawHUD();
}
function inCaveNow(){ return inCave(Math.floor(player.x/T),Math.floor(player.y/T)); }
function drawHeart(x,y,s,col){
  ctx.fillStyle=col;ctx.beginPath();
  ctx.moveTo(x,y+s*.9);
  ctx.bezierCurveTo(x-s*1.4,y-s*.2,x-s*.6,y-s*1.2,x,y-s*.3);
  ctx.bezierCurveTo(x+s*.6,y-s*1.2,x+s*1.4,y-s*.2,x,y+s*.9);
  ctx.fill();
}
function drawPlayer(camX,camY){
  const sx=player.x-camX,sy=player.y-camY;
  const walk=player.anim>0?Math.sin(player.anim*2):0;
  const bob=player.anim>0?Math.abs(Math.sin(player.anim))*2:0;
  ctx.save();
  if(player.hurtT>0&&Math.floor(player.hurtT*12)%2===0)ctx.globalAlpha=.35;
  // shadow
  ctx.fillStyle='rgba(0,0,0,.3)';ctx.beginPath();ctx.ellipse(sx,sy+12,10,4,0,0,7);ctx.fill();
  // body
  ctx.fillStyle='#3f6fb5';ctx.fillRect(sx-8,sy-8-bob,16,18);
  // head
  ctx.fillStyle='#e8d5b5';ctx.beginPath();ctx.arc(sx,sy-14-bob,8,0,7);ctx.fill();
  // hair/helm
  ctx.fillStyle='#2c4a80';ctx.beginPath();ctx.arc(sx,sy-16-bob,8,Math.PI,0);ctx.fill();
  // eyes face dir
  ctx.fillStyle='#1a1f2a';
  if(player.face===2){ctx.fillRect(sx-4,sy-14-bob,2,3);ctx.fillRect(sx+2,sy-14-bob,2,3);}
  else if(player.face===1)ctx.fillRect(sx-5,sy-14-bob,2,3);
  else if(player.face===3)ctx.fillRect(sx+3,sy-14-bob,2,3);
  // legs
  ctx.fillStyle='#2c4a80';
  const lo=player.anim>0?Math.sin(player.anim*2)*4:0;
  ctx.fillRect(sx-6,sy+4,5,7+lo*.4);ctx.fillRect(sx+1,sy+4,5,7-lo*.4);
  // weapon
  const a=[-Math.PI/2,Math.PI,0,Math.PI/2][player.face];
  if(player.swing>0){
    const s=swordStats(), prog=1-player.swing/player.swingMax;
    const sw=a+(prog-.5)*s.arc;
    ctx.save();ctx.translate(sx,sy-4);ctx.rotate(sw);
    ctx.fillStyle=state.swordLv===3?'#7ee8fa':state.swordLv===2?'#c0c0d0':'#b0b0b0';
    ctx.fillRect(8,-3,s.range-8,6);
    ctx.fillStyle='#8a6a3a';ctx.fillRect(4,-4,6,8);
    ctx.restore();
    // arc trail
    ctx.strokeStyle=state.swordLv===3?'rgba(126,232,250,.5)':'rgba(255,255,255,.35)';
    ctx.lineWidth=4;ctx.beginPath();ctx.arc(sx,sy-4,s.range-6,a-s.arc/2,a+s.arc/2);ctx.stroke();
  } else {
    ctx.save();ctx.translate(sx,sy-4);ctx.rotate(a+.6);
    ctx.fillStyle=state.swordLv===3?'#7ee8fa':state.swordLv===2?'#c0c0d0':'#9a9aa5';
    ctx.fillRect(6,-2,26,4);ctx.restore();
    if(state.hasBow){ ctx.strokeStyle='#a1887f';ctx.lineWidth=3;
      ctx.beginPath();ctx.arc(sx+Math.cos(a)*12,sy+Math.sin(a)*12,7,a-1.2,a+1.2);ctx.stroke(); }
  }
  ctx.restore();
}
function drawHUD(){
  // hearts (each = 2 hp)
  for(let i=0;i<state.maxHp/2;i++){
    const hx=26+i*30,hy=28;
    const v=state.hp-i*2;
    drawHeart(hx,hy,11,'#3a2020');
    if(v>=2)drawHeart(hx,hy,10,'#e8354d');
    else if(v===1){ ctx.save();ctx.beginPath();ctx.rect(hx-14,hy-14,14,28);ctx.clip();drawHeart(hx,hy,10,'#e8354d');ctx.restore(); }
  }
  // gems
  ctx.fillStyle='#ffd54f';ctx.beginPath();ctx.moveTo(26,56);ctx.lineTo(31,62);ctx.lineTo(26,68);ctx.lineTo(21,62);ctx.fill();
  ctx.fillStyle='#fff';ctx.font='bold 15px Verdana';ctx.textAlign='left';ctx.fillText(state.gems,40,66);
  // arrows
  if(state.hasBow){ ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,82);ctx.lineTo(34,74);ctx.stroke();
    ctx.fillText(state.arrows,40,84); }
  // stamina
  ctx.fillStyle='rgba(0,0,0,.5)';ctx.fillRect(20,96,120,8);
  ctx.fillStyle=state.stamina>30?'#4fc3f7':'#e07030';ctx.fillRect(20,96,120*state.stamina/state.maxStam,8);
  // sword level icon
  ctx.fillStyle=state.swordLv===3?'#7ee8fa':'#c0c0d0';ctx.font='12px Verdana';
  ctx.fillText(['Sword I','Sword II','Sword III'][state.swordLv-1],20,122);
  if(state.won){ ctx.fillStyle='rgba(255,157,59,.9)';ctx.font='bold 34px Verdana';ctx.textAlign='center';
    ctx.fillText('EMBERFALL REKINDLED!',cv.width/2,60);
    ctx.font='15px Verdana';ctx.fillText('You defeated the Shadow Knight. Keep exploring or start anew.',cv.width/2,86); }
  if(state.dead){ ctx.fillStyle='rgba(120,10,20,.55)';ctx.fillRect(0,0,cv.width,cv.height);
    ctx.fillStyle='#fff';ctx.font='bold 40px Verdana';ctx.textAlign='center';ctx.fillText('YOU FELL...',cv.width/2,cv.height/2);
    ctx.font='16px Verdana';ctx.fillText('Press Enter to return to your last shrine.',cv.width/2,cv.height/2+34); }
}

/* ============ LOOP ============ */
let last=0,perf=0;
function loop(ts){
  const dt=Math.min(.05,(ts-last)/1000||0);last=ts;perf=ts;
  if(state.started&&!paused)update(dt);
  else if(state.started){ /* paused: still draw */ }
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

/* ============ BOOT ============ */
function respawn(){
  // find last saved position or village
  try{ const d=JSON.parse(localStorage.getItem('emberfall'));
    if(d){ player.x=d.x;player.y=d.y; } else { player.x=12*T;player.y=12*T; }
  }catch(e){ player.x=12*T;player.y=12*T; }
  state.hp=state.maxHp; state.dead=false; state.stamina=state.maxStam;
  // respawn non-boss enemies
  enemies=enemies.filter(e=>e.boss);
}
function startGame(cont){
  ac(); // unlock audio
  if(cont){ if(!loadGame()){ toast('No save found.'); return; } toast('Welcome back, wanderer.'); }
  else { setupWorld(); toast('Find the Elder and the shrines. Relight the great flame!'); }
  state.started=true;
  document.getElementById('start').style.display='none';
}
document.getElementById('btnStart').onclick=()=>startGame(false);
document.getElementById('btnCont').onclick=()=>startGame(true);
if(localStorage.getItem('emberfall'))document.getElementById('btnCont').style.display='inline-block';
addEventListener('keydown',e=>{ if(state.dead&&e.code==='Enter')respawn(); });
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"c80b89f1-01cb-4702-8c22-5d3b318e8521","tokensIn":246385,"tokensOut":18664,"tokensTotal":265049,"cost":0.015082939999999998,"turns":12,"toolCalls":11,"failedToolCalls":0,"timestamp":"2026-08-28T04:28:26.606Z"} -->