RPG
GLM 5.3 Flash · full

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 <kbd>Shift</kbd> sprint <kbd>Space</kbd>/<kbd>J</kbd> attack <kbd>K</kbd> bow <kbd>E</kbd> interact <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]+
' | '+(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"} -->