RPG
Qwen 3.8 27B GGUF · full

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 83,727 bytes · SHA-256 6ce9cdfb1335
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Emberhollow — a one-hour action-adventure RPG</title>
<style>
:root{--gold:#d4a63f;--parch:#e8dcb8;--line:#6b5a34;--ember:#ff7a2f}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#05080a;font-family:Georgia,'Times New Roman',serif;color:var(--parch);user-select:none;-webkit-user-select:none}
#game{position:fixed;inset:0;display:block;cursor:default}
#game.play{cursor:crosshair}
#hudC{position:fixed;left:14px;top:12px;z-index:5;pointer-events:none;filter:drop-shadow(0 2px 3px rgba(0,0,0,.65))}
#objBox{position:fixed;left:16px;top:112px;z-index:5;pointer-events:none;max-width:360px}
#objLabel{font-size:10px;letter-spacing:3px;color:var(--gold);opacity:.9}
#objText{font-size:14px;line-height:1.35;text-shadow:0 2px 3px #000;margin-top:3px}
#goldBox{position:fixed;left:16px;bottom:14px;z-index:5;pointer-events:none;font-size:17px;color:var(--gold);text-shadow:0 2px 3px #000}
.gcoin{display:inline-block;width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#ffe9a8,#d4a63f 60%,#8a6a1f);margin-right:6px;box-shadow:0 0 6px rgba(212,166,63,.5);vertical-align:-1px}
#wBox{position:fixed;right:16px;bottom:14px;z-index:5;pointer-events:none;display:flex;align-items:center;gap:10px;background:rgba(10,16,12,.55);border:1px solid rgba(107,90,52,.6);border-radius:8px;padding:6px 14px 6px 6px}
#wBox .wn{font-size:14px;color:var(--parch)}
#wBox .ws{font-size:11px;color:#9fb098;margin-top:2px}
#arwBox{position:fixed;right:16px;bottom:96px;z-index:5;pointer-events:none;font-size:15px;color:#bcd6ff;text-shadow:0 2px 3px #000;display:none}
#hint{position:fixed;left:50%;bottom:16%;transform:translateX(-50%);z-index:5;pointer-events:none;font-size:15px;color:#ffe9b0;text-shadow:0 2px 4px #000;background:rgba(8,12,9,.55);padding:6px 16px;border-radius:20px;border:1px solid rgba(212,166,63,.35);display:none;white-space:nowrap}
#vign{position:fixed;inset:0;z-index:4;pointer-events:none;opacity:0;box-shadow:inset 0 0 150px 60px rgba(190,30,20,.85)}
#toasts{position:fixed;top:13%;left:50%;transform:translateX(-50%);z-index:6;pointer-events:none;display:flex;flex-direction:column;gap:6px;align-items:center;width:100%}
.toast{background:rgba(10,16,12,.85);border:1px solid rgba(212,166,63,.5);color:var(--parch);padding:7px 18px;border-radius:6px;font-size:15px;text-shadow:0 1px 2px #000;animation:tin .25s ease-out,tout .4s ease-in 3s forwards;white-space:nowrap}
.toast.gold{color:var(--gold)}
.toast.bad{color:#ff9c8a;border-color:rgba(192,57,43,.6)}
@keyframes tin{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
@keyframes tout{to{opacity:0;transform:translateY(-6px)}}
.overlay{position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;background:rgba(4,8,10,.5);backdrop-filter:blur(2px)}
.overlay[hidden]{display:none}
.panel{background:linear-gradient(#1a241e,#0f1613);border:2px solid var(--line);border-radius:8px;padding:22px 28px;box-shadow:0 12px 40px rgba(0,0,0,.7),inset 0 0 40px rgba(0,0,0,.4);max-width:660px;width:min(680px,92vw);max-height:88vh;overflow:auto}
.panel h2{font-size:20px;color:var(--gold);letter-spacing:2px;margin-bottom:12px;text-transform:uppercase}
.btn{display:block;width:100%;margin:8px 0;padding:11px 16px;font-family:inherit;font-size:16px;letter-spacing:1px;color:var(--parch);background:linear-gradient(#2a3830,#18221c);border:1px solid var(--line);border-radius:6px;cursor:pointer;text-shadow:0 1px 2px #000;transition:all .12s}
.btn:hover,.btn:focus{background:linear-gradient(#3a4c40,#1f2c24);border-color:var(--gold);box-shadow:0 0 14px rgba(212,166,63,.25);outline:none}
.btn:disabled{opacity:.4;cursor:default}
.twrap{text-align:center;width:min(720px,94vw)}
.ttitle{font-size:60px;letter-spacing:14px;color:var(--gold);text-shadow:0 0 34px rgba(255,122,47,.35),0 4px 0 #000;margin-bottom:2px}
.tsub{font-size:15px;font-style:italic;color:#b8c4b0;margin-bottom:24px}
.tbtns{width:min(320px,80vw);margin:0 auto}
#howTo{margin:20px auto 0;text-align:left;font-size:13.5px;line-height:1.75;color:#c9d4c2;max-width:480px;border-top:1px solid rgba(107,90,52,.5);padding-top:14px}
#howTo b{color:var(--gold)}
.ctrlgrid{display:grid;grid-template-columns:auto 1fr;gap:3px 14px}
.keys{color:var(--gold);text-align:right;font-family:ui-monospace,monospace;font-size:12.5px}
.shopRow{display:flex;align-items:center;justify-content:space-between;padding:9px 10px;border:1px solid transparent;border-radius:6px;margin:4px 0;cursor:pointer}
.shopRow:hover,.shopRow.sel{background:rgba(212,166,63,.12);border-color:rgba(212,166,63,.45)}
.shopRow .sn{font-size:15px}
.shopRow .sd{font-size:12px;color:#9fb098;margin-top:2px}
.shopRow .sc{color:var(--gold);font-size:15px;white-space:nowrap;margin-left:12px}
.shopRow.owned{opacity:.55;cursor:default}
.shopRow.owned .sc{color:#7f8f7a}
.shopHead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
.shopHead .sg{color:var(--gold);font-size:16px}
.shopFoot{margin-top:12px;font-size:12px;color:#8fa088;text-align:center;letter-spacing:1px}
.stat{font-size:14px;line-height:1.9}
.stat b{color:var(--gold)}
.endTitle{font-size:32px;color:var(--gold);letter-spacing:6px;text-align:center;margin-bottom:6px}
.endSub{text-align:center;font-style:italic;color:#b8c4b0;margin-bottom:14px}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="vign"></div>
<canvas id="hudC" width="330" height="96"></canvas>
<div id="objBox"><div id="objLabel">OBJECTIVE</div><div id="objText"></div></div>
<div id="goldBox"><span class="gcoin"></span><span id="goldVal">0</span> g</div>
<div id="arwBox">➤ <span id="arwVal">0</span></div>
<div id="wBox"><canvas id="wIcon" width="52" height="52"></canvas><div><div class="wn" id="wName"></div><div class="ws" id="wSub"></div></div></div>
<div id="hint"></div>
<div id="toasts"></div>
<div id="titleUI" class="overlay">
<div class="twrap">
<div class="ttitle">EMBERHOLLOW</div>
<div class="tsub">A one-hour action-adventure of the deep wood — explore, fight, grow, survive.</div>
<div class="tbtns">
<button class="btn" id="btnNew">Begin New Quest</button>
<button class="btn" id="btnCont" hidden>Continue Journey</button>
</div>
<div id="howTo">
<div class="ctrlgrid">
<span class="keys">WASD / Arrows</span><span>Move</span>
<span class="keys">Shift</span><span>Sprint (drains stamina)</span>
<span class="keys">Mouse</span><span>Aim</span>
<span class="keys">Click / J</span><span>Attack with sword</span>
<span class="keys">Right-click / K</span><span>Shoot bow (mid-game)</span>
<span class="keys">F / E</span><span>Interact — levers, save stones, merchant</span>
<span class="keys">P</span><span>Drink potion</span>
<span class="keys">I</span><span>Inventory · <b>M</b> mute · <b>Esc</b> pause</span>
</div>
<p style="margin-top:12px">Each heart holds <b>2 health</b> — you begin with 3 containers and can grow to 5. Find <b>3 heart containers</b> in the wild (Mirella sells 2 more), <b>2 sword upgrades</b>, and a <b>hidden bow</b>. Rest at <b>save stones</b> to checkpoint and heal. Spend gold with the merchant.</p>
</div>
</div>
</div>
<div id="pauseUI" class="overlay" hidden>
<div class="panel" style="width:min(420px,90vw)">
<h2>Paused</h2>
<button class="btn" id="btnResume">Resume (Esc)</button>
<button class="btn" id="btnMute">Toggle Sound (M)</button>
<p class="stat" style="margin-top:10px;font-size:13px;color:#9fb098">WASD move · Shift sprint · Click/J sword · K bow · F interact · P potion · I inventory</p>
</div>
</div>
<div id="deadUI" class="overlay" hidden>
<div class="panel" style="width:min(440px,90vw)">
<div class="endTitle" style="font-size:26px;color:#ff7a6a">You Have Fallen</div>
<p class="endSub">The hollow claims its slain — but the save stones remember.</p>
<button class="btn" id="btnRise">Rise at Save Stone (−25% gold)</button>
<button class="btn" id="btnDTitle">Abandon Quest (Title)</button>
</div>
</div>
<div id="endUI" class="overlay" hidden>
<div class="panel">
<div class="endTitle">THE LIGHT RETURNS</div>
<p class="endSub">Sir Malgrim falls, and warmth floods the emberhollow once more.</p>
<div class="stat" id="endStats"></div>
<button class="btn" id="btnKeep" style="margin-top:14px">Continue Wandering</button>
<button class="btn" id="btnETitle">Return to Title</button>
</div>
</div>
<div id="shopUI" class="overlay" hidden>
<div class="panel" style="width:min(560px,92vw)">
<div class="shopHead">
<h2 style="margin:0">Mirella the Merchant</h2>
<span class="sg"><span class="gcoin"></span><span id="shopGold">0</span> g</span>
</div>
<div id="shopList"></div>
<div class="shopFoot">↑↓ choose · ENTER buy · ESC leave · click to buy</div>
</div>
</div>
<div id="invUI" class="overlay" hidden>
<div class="panel" style="width:min(560px,92vw)">
<h2>Satchel</h2>
<div id="invList"></div>
<div class="shopFoot">Click a potion to drink · ESC / I close</div>
</div>
</div>
<script>
'use strict';
/* ================= helpers ================= */
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const angTo=(x1,y1,x2,y2)=>Math.atan2(y2-y1,x2-x1);
const angDiff=(a,b)=>{let d=(b-a)%Math.PI*2;if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return d;};
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const TAU=Math.PI*2;
function hash2(x,y){let h=(x*374761393+y*668265263)|0;h=((h^(h>>13))*1274126177)|0;return ((h^(h>>16))>>>0)/4294967295;}
const $=id=>document.getElementById(id);
const cv=$('game'),ctx=cv.getContext('2d');
function resize(){cv.width=innerWidth;cv.height=innerHeight;}
addEventListener('resize',resize);resize();
const els={
vign:$('vign'),hudC:$('hudC'),objText:$('objText'),goldVal:$('goldVal'),
wName:$('wName'),wSub:$('wSub'),wIcon:$('wIcon'),arwBox:$('arwBox'),arwVal:$('arwVal'),
hint:$('hint'),toasts:$('toasts'),
titleUI:$('titleUI'),btnNew:$('btnNew'),btnCont:$('btnCont'),
pauseUI:$('pauseUI'),btnResume:$('btnResume'),btnMute:$('btnMute'),
deadUI:$('deadUI'),btnRise:$('btnRise'),btnDTitle:$('btnDTitle'),
endUI:$('endUI'),endStats:$('endStats'),btnKeep:$('btnKeep'),btnETitle:$('btnETitle'),
shopUI:$('shopUI'),shopGold:$('shopGold'),shopList:$('shopList'),
invUI:$('invUI'),invList:$('invList')
};
/* ================= audio ================= */
const AU={
ctx:null,master:null,mGain:null,muted:false,nbuf:null,
init(){
if(this.ctx)return;
const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;
this.ctx=new AC();
this.master=this.ctx.createGain();this.master.gain.value=.55;this.master.connect(this.ctx.destination);
this.mGain=this.ctx.createGain();this.mGain.gain.value=.15;this.mGain.connect(this.master);
const len=this.ctx.sampleRate|0;this.nbuf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d=this.nbuf.getChannelData(0);for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
},
resume(){if(this.ctx&&this.ctx.state==='suspended')this.ctx.resume();},
tone(o){
if(!this.ctx||this.muted)return;
const t0=this.ctx.currentTime+(o.d||0),t=o.t||.15;
const osc=this.ctx.createOscillator(),g=this.ctx.createGain();
osc.type=o.type||'sine';osc.frequency.setValueAtTime(o.f,t0);
if(o.f2)osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f2),t0+t);
g.gain.setValueAtTime(0,t0);g.gain.linearRampToValueAtTime(o.v||.2,t0+(o.a||.006));
g.gain.exponentialRampToValueAtTime(.0001,t0+t);
let node=osc;
if(o.filt){const fl=this.ctx.createBiquadFilter();fl.type=o.filt;fl.frequency.value=o.ff||800;fl.Q.value=o.q||1;osc.connect(fl);node=fl;}
node.connect(g);g.connect(o.music?this.mGain:this.master);
osc.start(t0);osc.stop(t0+t+.05);
},
noise(o){
if(!this.ctx||this.muted)return;
const t0=this.ctx.currentTime+(o.d||0),t=o.t||.1;
const src=this.ctx.createBufferSource();src.buffer=this.nbuf;src.loop=true;
const g=this.ctx.createGain();
g.gain.setValueAtTime(0,t0);g.gain.linearRampToValueAtTime(o.v||.15,t0+.004);
g.gain.exponentialRampToValueAtTime(.0001,t0+t);
const fl=this.ctx.createBiquadFilter();fl.type=o.type||'bandpass';fl.frequency.setValueAtTime(o.f||800,t0);
if(o.f2)fl.frequency.exponentialRampToValueAtTime(Math.max(10,o.f2),t0+t);
fl.Q.value=o.q||1;
src.connect(fl);fl.connect(g);g.connect(o.music?this.mGain:this.master);
src.start(t0);src.stop(t0+t+.05);
}
};
const SFX={
step(s){AU.noise({t:.05,v:s?.05:.03,f:rand(220,380),type:'lowpass',q:.7});},
swing(){AU.noise({t:.13,v:.15,f:2400,f2:500,q:.8});AU.tone({type:'triangle',f:180,f2:70,t:.1,v:.04});},
hit(f){AU.noise({t:.08,v:.2,f:f?900:1600,q:1});AU.tone({type:'square',f:f?220:300,f2:90,t:.09,v:.1});},
hurt(){AU.tone({type:'sawtooth',f:190,f2:60,t:.28,v:.22});AU.noise({t:.2,v:.13,f:300,type:'lowpass'});},
die(){AU.tone({type:'sawtooth',f:160,f2:40,t:.6,v:.25});AU.noise({t:.5,v:.2,f:500,f2:100,type:'lowpass'});},
ekill(){AU.noise({t:.22,v:.16,f:700,f2:150});AU.tone({type:'triangle',f:300,f2:80,t:.2,v:.1});},
coin(){AU.tone({type:'square',f:1046,t:.07,v:.08});AU.tone({type:'square',f:1568,t:.12,v:.08,d:.06});},
pickup(){AU.tone({type:'sine',f:660,f2:880,t:.12,v:.13});AU.tone({type:'sine',f:1320,t:.18,v:.1,d:.08});},
item(){AU.tone({type:'triangle',f:523,t:.12,v:.14});AU.tone({type:'triangle',f:659,t:.12,v:.14,d:.1});AU.tone({type:'triangle',f:784,t:.24,v:.15,d:.2});},
heart(){AU.tone({type:'sine',f:392,t:.3,v:.15});AU.tone({type:'sine',f:494,t:.3,v:.13,d:.05});AU.tone({type:'sine',f:587,t:.5,v:.15,d:.1});AU.tone({type:'sine',f:784,t:.7,v:.12,d:.25});},
potion(){for(let i=0;i<3;i++)AU.tone({type:'sine',f:rand(300,420),f2:200,t:.09,v:.12,d:i*.09});AU.tone({type:'sine',f:990,t:.2,v:.08,d:.3});},
heal(){AU.tone({type:'sine',f:700,f2:1100,t:.25,v:.12});},
shop(){AU.tone({type:'square',f:880,t:.08,v:.08});AU.tone({type:'square',f:1318,t:.14,v:.1,d:.09});},
buy(){AU.tone({type:'square',f:1175,t:.07,v:.09});AU.tone({type:'square',f:1568,t:.13,v:.1,d:.06});},
deny(){AU.tone({type:'square',f:180,f2:120,t:.15,v:.1});},
save(){[523,659,784,1046].forEach((f,i)=>AU.tone({type:'sine',f,t:.5,v:.13,d:i*.12}));},
lever(){AU.tone({type:'square',f:140,f2:90,t:.12,v:.14});AU.noise({t:.5,v:.12,f:180,f2:60,type:'lowpass',d:.08});},
gate(){AU.noise({t:1.6,v:.28,f:120,f2:40,type:'lowpass'});AU.tone({type:'sine',f:55,f2:30,t:1.4,v:.2});},
bow(){AU.tone({type:'triangle',f:240,f2:90,t:.09,v:.16});AU.noise({t:.05,v:.05,f:1800});},
arrowHit(){AU.noise({t:.06,v:.14,f:1200,q:2});AU.tone({type:'square',f:240,f2:110,t:.07,v:.08});},
fireball(){AU.noise({t:.3,v:.14,f:600,f2:200});AU.tone({type:'sawtooth',f:160,f2:60,t:.3,v:.1});},
roar(){AU.tone({type:'sawtooth',f:90,f2:38,t:.9,v:.3});AU.noise({t:.8,v:.2,f:300,f2:80,type:'lowpass'});},
ui(){AU.tone({type:'square',f:600,t:.05,v:.07});},
exhaust(){AU.tone({type:'sine',f:200,f2:120,t:.3,v:.08});}
};
const Music={
t:0,beat:0,last:2,
scales:{
village:[220,261.6,293.7,329.6,392],
forest:[196,233.1,261.6,311.1,349.2],
cave:[174.6,207.7,220,261.6,293.7],
north:[233.1,293.7,329.6,392,440],
fort:[196,220,261.6,277.2,311.1],
boss:[185,207.7,246.9,261.6,293.7]
},
update(dt,zone){
if(!AU.ctx||AU.muted)return;
const spb=zone==='boss'?.28:.46;
this.t+=dt;
while(this.t>=spb){
this.t-=spb;this.beat++;
const sc=this.scales[zone]||this.scales.forest;
if(this.beat%8===0){sc.slice(0,3).forEach(f=>AU.tone({music:true,type:'sine',f:f/2,t:spb*8*.95,v:.045,a:.4}));}
if(Math.random()<(zone==='boss'?.72:.5)){
let n=clamp(this.last+randi(-1,1),0,sc.length-1);this.last=n;
AU.tone({music:true,type:'triangle',f:sc[n]*(Math.random()<.25?2:1),t:.4,v:zone==='boss'?.055:.045});
}
}
}
};
/* ================= input ================= */
const keys={},just={};
function clearJust(){for(const k in just)delete just[k];}
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(k===' '||k.startsWith('arrow')||k==='tab')e.preventDefault();
if(!keys[k])just[k]=true;
keys[k]=true;
AU.init();AU.resume();
onKey(k);
});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
const mouse={x:innerWidth/2,y:innerHeight/2};
cv.addEventListener('mousemove',e=>{mouse.x=e.clientX;mouse.y=e.clientY;});
cv.addEventListener('mousedown',e=>{
AU.init();AU.resume();
if(G.screen!=='play')return;
if(e.button===0)just.atk=true;
if(e.button===2)just.bow=true;
});
addEventListener('mouseup',()=>{});
cv.addEventListener('contextmenu',e=>e.preventDefault());
function onKey(k){
if(k==='m'){AU.muted=!AU.muted;toast(AU.muted?'Sound off':'Sound on','gold');return;}
if(G.screen==='title'){if(k==='enter')startNew();return;}
if(G.screen==='dead'){if(k==='enter')riseAtSave();return;}
if(G.screen==='end'){if(k==='enter')closeEnd();return;}
if(G.screen==='shop'){
if(k==='escape'){closeShop();return;}
if(k==='arrowup'||k==='w'){shopSel=(shopSel+SHOP.length-1)%SHOP.length;renderShop();SFX.ui();}
if(k==='arrowdown'||k==='s'){shopSel=(shopSel+1)%SHOP.length;renderShop();SFX.ui();}
if(k==='enter'||k==='f'||k===' '){tryBuy(shopSel);}
return;
}
if(G.screen==='inventory'){
if(k==='escape'||k==='i')closeInv();
return;
}
if(G.screen==='pause'){if(k==='escape')togglePause(false);return;}
if(G.screen!=='play')return;
if(k==='escape'){togglePause(true);}
else if(k==='i'){openInv();}
else if(k==='p'){drinkPotion();}
else if(k==='f'||k==='e'){just.interact=true;}
else if(k==='j'||k==='z'){just.atk=true;}
else if(k==='k'||k==='x'){just.bow=true;}
}
/* ================= game state & databases ================= */
const WSPD=224,SPRD=316,ARMR=28,ARROW=540,ARRR=9;
const EN={
slime:{hp:8,spd:82,dmg:2,reward:7,sc:14,flash:1,r:12,c:'#77c98b'},
rat:{hp:6,spd:178,dmg:3,reward:6,sc:16,flash:1,r:10,c:'#a58a72'},
bat:{hp:4,spd:198,dmg:2,reward:5,sc:12,flash:1,r:10,c:'#8a7fb0'},
goblin:{hp:16,spd:138,dmg:4,reward:13,sc:26,flash:1,r:14,c:'#6aa06a',ranged:1},
cultist:{hp:12,spd:126,dmg:3,reward:16,sc:30,flash:1,r:13,c:'#5b6d9e',ranged:1},
knight:{hp:34,spd:96,dmg:6,reward:24,sc:48,flash:1.4,r:16,c:'#7d88a6'},
wraith:{hp:22,spd:150,dmg:4,reward:19,sc:40,flash:1.2,r:13,c:'#9fd4d0',phase:1},
boss:{hp:240,spd:150,dmg:6,reward:0,sc:0,flash:1.6,r:24,c:'#b8452e',boss:1}
};
const ITEMS={
potion:{name:'Healing Potion',desc:'Restores 4 health (P to drink)',price:25,type:'potion',col:'#e05555',max:3},
vigor:{name:'Blessing of Vigor',desc:'Grants one heart container (+4 max health)',price:90,type:'heart',col:'#ff5d73'},
cloak:{name:'Stalker Cloak',desc:'+45% dodge chance while sprinting',price:60,type:'accessory',col:'#7fb2d6'},
boots:{name:'Swift Boots',desc:'+12% movement speed',price:45,type:'accessory',col:'#d6a85f'},
charm:{name:'Lucky Charm',desc:'+15% gold from kills',price:50,type:'accessory',col:'#e35fb1'},
sigil:{name:'Rune Sigil',desc:'+20% sword damage',price:70,type:'accessory',col:'#6fc3d6'}
};
const SHOP=['potion','boots','charm','sigil','cloak','vigor'];
const WEAPONS=[
{name:'Rusty Blade',dmg:3,arc:1.9,range:52,cd:.40,shake:3,pierce:0,desc:'A humble blade, dulled by years.'},
{name:'Knight\'s Longsword',dmg:6,arc:2.3,range:68,cd:.30,shake:5,pierce:0,knock:46,desc:'Faster and far deadlier.'},
{name:'Flamebrand',dmg:8,arc:2.6,range:80,cd:.26,shake:7,pierce:999,fire:1,desc:'A living flame. Nothing escapes its sweep.'}
];
let G={};
function freshState(){
return {
screen:'title',time:0,playTime:0,
p:{x:520,y:560,hp:6,maxhp:6,vx:0,vy:0,facing:0,atkT:0,atkCd:0,swingId:0,inv:0,dead:0,stepT:0,sprintT:0,stam:100,moving:false,weapon:0,arrows:0,invulnT:0,healT:0},
hearts:[1,1,1,0,0],heartIdx:3,
gold:0,gates:{gate1:false,gate2:false,gate3:false},
lever:{gate1:false,gate2:false},
kills:0,gems:0,
hasBow:false,hadBow:false,
owned:{},potionCount:0,
enemies:[],drops:[],parts:[],floats:[],bolts:[],arrows:[],
savedIdx:0,saveLoc:{x:520,y:560},
boss:{state:'idle',atkT:2.4,projT:3.5,phase2:false,dead:false},
relics:{},
zone:'village',shakeT:0,shakeA:0,
titleT:0,interactHint:'',objText:'',
endShown:false,invOpen:false,shopOpen:false,paused:false,deadT:0
};
}
G=freshState();
const P=()=>G.p;
/* save */
const SKEY='emberhollow_save_v1';
function writeSave(){
const p=P();
const o={time:Math.floor(G.playTime),hp:p.hp,maxhp:p.maxhp,hearts:G.hearts,heartIdx:G.heartIdx,
gold:G.gold,lever:G.lever,kills:G.kills,gems:G.gems,
weapon:p.weapon,arrows:p.arrows,hasBow:G.hasBow,
owned:G.owned,potionCount:G.potionCount,
savedIdx:G.savedIdx,saveLoc:G.saveLoc,boss:G.boss.dead?'dead':(G.boss.state==='idle'?'idle':(G.boss.state==='rise'?'rise':(G.boss.phase2?'w2':'w1'))),
relics:G.relics,zones:G.zonesDone};
try{localStorage.setItem(SKEY,JSON.stringify(o));}catch(e){}
}
function readSave(){try{const s=localStorage.getItem(SKEY);return s?JSON.parse(s):null;}catch(e){return null;}}
/* toast & float text */
function toast(txt,cls){
const d=document.createElement('div');d.className='toast'+(cls?' '+cls:'');d.textContent=txt;
els.toasts.appendChild(d);
while(els.toasts.children.length>3)els.toasts.firstChild.remove();
setTimeout(()=>d.remove(),3500);
}
function addFloat(x,y,txt,col,size){G.floats.push({x,y,txt,col:col||'#fff',t:0,size:size||15});}
function burst(x,y,col,n,spd,size,life){
for(let i=0;i<n;i++){const a=rand(0,TAU),s=rand(spd*.3,spd);
G.parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,t:0,life:rand(life*.6,life),col,size:rand(size*.5,size),g:60,shrink:1});}
if(G.parts.length>700)G.parts.splice(0,G.parts.length-700);
}
/* ================= world layout ================= */
function buildWorld(){
G.walls=[];G.circles=[];G.rocks=[];G.torches=[];G.crystals=[];
G.saveStones=[];G.levers=[];G.gates=[];G.spawnDefs=[];
const wall=(x,y,w,h)=>G.walls.push({x,y,w,h});
const rock=(x,y,r)=>G.rocks.push({x,y,r});
const torch=(x,y)=>G.torches.push({x,y,ph:rand(0,TAU)});
const crys=(x,y,c)=>G.crystals.push({x,y,c,ph:rand(0,TAU)});
const stone=(i,x,y,zone)=>G.saveStones.push({i,x,y,zone,used:false});
const gate=(id,x,y,w,h,open,zone)=>G.gates.push({id,x,y,w,h,open,zone});
/* ==== BOUNDARY WALL LINES (define zones + gated passages) ==== */
/* line A: village | forest (x~952), passage y 500-572 */
wall(952,80,24,420);wall(952,572,24,228);
gate('gate1',952,500,24,72);
/* line B: forest | north (x~1712), passage y 240-380 */
wall(1712,80,24,160);wall(1712,380,24,420);
gate('gate2',1712,240,24,140);
/* line C: north | fort (x~2552), passage y 240-380 */
wall(2552,80,24,160);wall(2552,380,24,420);
gate('gate3',2552,240,24,140);
/* line D: fort | eastern woods (y~372, x 2576-3440), entrance gap x 2860-2920 */
wall(2576,372,284,16);wall(2920,372,520,16);
/* ==== VILLAGE (x 80-960) ==== */
wall(360,380,14,150);wall(412,380,14,150);wall(360,380,66,14); // hut
wall(700,430,150,14);wall(700,444,14,122);wall(836,444,14,122); // merchant stall (open front)
wall(470,620,90,14); // well
torch(500,470);torch(560,600);torch(740,470);torch(800,540);torch(390,560);
crys(350,700,'#6fd0ff');crys(900,760,'#6fd0ff');
stone(0,520,500);
G.levers.push({id:'gate1',x:900,y:530,done:false});
G.spawnDefs.push({x:330,y:470,t:'slime',n:2},{x:860,y:640,t:'slime',n:2});
/* ==== FOREST (x 960-1720) ==== */
wall(1420,480,60,60);wall(1350,600,50,50);wall(1500,200,50,50);
torch(1400,320);torch(1520,500);torch(1100,700);
crys(1100,300,'#6fd0ff');
stone(1,1560,300);
G.levers.push({id:'gate2',x:1650,y:300,done:false});
G.spawnDefs.push({x:1300,y:420,t:'slime',n:2},{x:1100,y:520,t:'rat',n:2},{x:1550,y:650,t:'bat',n:2});
/* ==== NORTHERN CLEARING (x 1720-2560, y 80-380) — hidden bow ==== */
wall(2000,120,50,50);wall(2300,320,40,40);
torch(1850,320);torch(2400,120);torch(2150,240);
crys(2200,300,'#c07ff0');crys(1800,150,'#c07ff0');
stone(2,2450,150);
G.spawnDefs.push({x:1950,y:160,t:'bat',n:3},{x:2300,y:260,t:'goblin',n:2});
/* ==== DARK CAVES (x 1720-2560, y 380-800) ==== */
wall(2100,700,40,40);wall(1900,450,40,40);wall(2400,720,40,40);
torch(2000,500);torch(2300,600);torch(1850,700);
crys(2150,520,'#6fd0ff');crys(2450,450,'#6fd0ff');
stone(3,1980,680);
G.levers.push({id:'gate3',x:2500,y:200,done:false});
G.spawnDefs.push({x:2000,y:600,t:'bat',n:3},{x:2350,y:700,t:'cultist',n:2});
/* ==== RUINED FORT (x 2560-3440, y 80-372) — boss keep ==== */
wall(2700,180,50,50);wall(3300,180,50,50);
torch(2650,120);torch(3350,120);torch(2650,320);torch(3350,320);torch(3000,100);
crys(2800,250,'#ff7a2f');crys(3200,250,'#ff7a2f');
stone(4,2900,220);
G.levers.push({id:'boss',x:3120,y:240,done:false});
G.spawnDefs.push({x:2750,y:300,t:'knight',n:1},{x:3250,y:300,t:'cultist',n:2});
/* ==== EASTERN WOODS (x 2560-3440, y 380-800) — goblin camp ==== */
wall(3100,480,60,60);wall(2800,700,50,50);wall(3250,620,50,50);
torch(2850,560);torch(3150,680);torch(2700,740);
crys(3050,450,'#6fd0ff');
stone(5,3120,720);
G.spawnDefs.push({x:2950,y:600,t:'goblin',n:2},{x:3200,y:720,t:'rat',n:2},{x:2700,y:500,t:'bat',n:2});
}
/* ================= entities ================= */
function mkEnemy(type,x,y){
const d=EN[type];
return {type,x,y,hp:d.hp*(G.boss.phase2&&type==='knight'?1.25:1),maxhp:d.hp,spd:d.spd*(type==='boss'?1:rand(.9,1.1)),
dmg:d.dmg,flash:0,kb:0,kbx:0,kby:0,atkT:rand(.6,1.4),cd:0,dir:rand(0,TAU),t:rand(0,9),
r:d.r,stunT:0,dead:false,gone:0,projT:rand(2.5,4.5),ranged:!!d.ranged,phase:!!d.phase,
vx:0,vy:0,vx2:0,vy2:0,jumpT:0,alpha:1,phT:0};
}
function spawnEnemiesNear(cx,cy,count,types){
for(let i=0;i<count;i++){
const a=rand(0,TAU),d=rand(180,330);
let x=cx+Math.cos(a)*d,y=cy+Math.sin(a)*d;
x=clamp(x,260,3340);y=clamp(y,160,740);
if(collidesWorld(x,y,18))continue;
const t=types[randi(0,types.length-1)];
G.enemies.push(mkEnemy(t,x,y));
}
}
function collidesWorld(x,y,r){
for(const w of G.walls){if(x+r>w.x&&x-r<w.x+w.w&&y+r>w.y&&y-r<w.y+w.h)return true;}
for(const c of G.circles){if(dist(x,y,c.x,c.y)<r+c.r)return true;}
for(const g of G.gates){if(!g.open&&x+r>g.x&&x-r<g.x+g.w&&y+r>g.y&&y-r<g.y+g.h)return true;}
if(x<80+r||x>3440-r||y<80+r||y>800-r)return true;
return false;
}
function moveEnt(e,dx,dy,r){
let nx=e.x+dx;if(!collidesWorld(nx,e.y,r))e.x=nx;
let ny=e.y+dy;if(!collidesWorld(e.x,ny,r))e.y=ny;
}
function enemyDrops(en){
const r=Math.random();
if(r<.42)G.drops.push({x:en.x,y:en.y,type:'gold',amt:en.type==='boss'?150:EN[en.type].reward,t:0,bob:rand(0,9)});
if(r>=.42&&r<.54)G.drops.push({x:en.x+rand(-12,12),y:en.y+rand(-12,12),type:'potion',t:0,bob:rand(0,9)});
if(r>=.54&&r<.66)G.drops.push({x:en.x+rand(-12,12),y:en.y+rand(-12,12),type:'gem',t:0,bob:rand(0,9)});
}
function killEnemy(en,silent){
en.dead=true;G.kills++;
burst(en.x,en.y,EN[en.type].c,14,220,4,.7);
burst(en.x,en.y,'#ffffff',6,160,2.5,.4);
if(!silent){SFX.ekill();addFloat(en.x,en.y-16,'+'+EN[en.type].sc,'#d4a63f',12);}
enemyDrops(en);
if(en.type==='boss')onBossDead();
}
/* ================= items / progression ================= */
function giveItem(id,n){
const it=ITEMS[id];
if(it.type==='potion'){
if(G.potionCount>=it.max){toast('Potion satchel is full','bad');return false;}
G.potionCount+=n;G.owned.potion=true;
toast('+'+n+' Healing Potion'+(n>1?'s':''),'gold');SFX.item();
return true;
}
if(it.type==='heart'){
if(G.heartIdx>=5){toast('All heart containers found','bad');return false;}
giveHeart();return true;
}
G.owned[id]=true;
if(id==='cloak')toast('Stalker Cloak acquired — dodge while sprinting','gold');
if(id==='boots')toast('Swift Boots acquired — move faster','gold');
if(id==='charm')toast('Lucky Charm acquired — +15% gold','gold');
if(id==='sigil')toast('Rune Sigil acquired — +20% sword damage','gold');
SFX.item();return true;
}
function giveHeart(){
const p=P();
G.hearts[G.heartIdx]=1;G.heartIdx++;
const maxhp=G.heartIdx*2;p.maxhp=maxhp;p.hp=Math.min(maxhp,p.hp+4);
toast('Heart Container found! ('+G.heartIdx+'/5)','gold');
SFX.heart();
burst(p.x,p.y,'#ff5d73',26,260,4,.9);
addFloat(p.x,p.y-30,'MAX HEALTH +4','#ff5d73',18);
if(G.heartIdx===3)updateObjective();
writeSave();
}
function updateObjective(){
const p=P(),h=G.heartIdx;
let t;
if(!G.lever.gate1)t='Pull the lever east of the village to open the ember gate';
else if(!G.hasBow)t='A hidden bow lies in the northern clearing — beyond the first gate';
else if(p.weapon<1)t='The goblin camp east of the cave guards the Knight\'s Longsword ('+h+'/5 hearts)';
else if(p.weapon<2)t='Burn the goblin camp — the Flamebrand lies in its ashes ('+h+'/5 hearts)';
else if(!G.boss.dead){
t=G.boss.state==='idle'?'Pull the lever at the ruined keep (north) and face Malgrim'
:G.boss.state==='phase1'?'Slay Sir Malgrim the Emberknight!'
:'The keep gates rise — enter and face Malgrim!';
}
else t='The hollow is yours. Wander, rest, or hunt at will.';
G.objText=t;els.objText.textContent=t;
}
/* ================= shop & inventory ================= */
let shopSel=0;
function openShop(){
G.screen='shop';shopSel=0;renderShop();els.shopUI.hidden=false;
cv.classList.remove('play');SFX.shop();
toast('Mirella: "Spending gold? Wise, wandering blade."');
}
function closeShop(){G.screen='play';els.shopUI.hidden=true;cv.classList.add('play');SFX.ui();}
function renderShop(){
els.shopGold.textContent=G.gold;
const L=els.shopList;L.innerHTML='';
SHOP.forEach((id,i)=>{
const it=ITEMS[id];
const owned=it.type==='accessory'&&G.owned[id];
const full=it.type==='potion'&&G.potionCount>=it.max;
const fullH=it.type==='heart'&&G.heartIdx>=5;
const div=document.createElement('div');
div.className='shopRow'+(i===shopSel?' sel':'')+(owned||full||fullH?' owned':'');
div.innerHTML='<div><div class="sn">'+it.name+'</div><div class="sd">'+it.desc+'</div></div>'
+'<div class="sc">'+(owned?'Owned':full?'Full':fullH?'Max':'<span class="gcoin"></span>'+it.price+' g')+'</div>';
div.onclick=()=>{shopSel=i;renderShop();tryBuy(i);};
L.appendChild(div);
});
}
function tryBuy(i){
const id=SHOP[i],it=ITEMS[id];
const owned=it.type==='accessory'&&G.owned[id];
const full=it.type==='potion'&&G.potionCount>=it.max;
const fullH=it.type==='heart'&&G.heartIdx>=5;
if(owned||full||fullH){SFX.deny();toast(it.type==='potion'?'Potion satchel is full':it.type==='heart'?'All heart containers found':'Already equipped','bad');return;}
if(G.gold<it.price){SFX.deny();toast('Not enough gold','bad');return;}
G.gold-=it.price;SFX.buy();
giveItem(id,1);
renderShop();
}
function openInv(){
G.screen='inventory';renderInv();els.invUI.hidden=false;cv.classList.remove('play');SFX.ui();
}
function closeInv(){G.screen='play';els.invUI.hidden=true;cv.classList.add('play');}
function renderInv(){
const L=els.invList;L.innerHTML='';
const row=(name,sub,act)=>{
const d=document.createElement('div');d.className='shopRow';
d.innerHTML='<div><div class="sn">'+name+'</div><div class="sd">'+sub+'</div></div>';
if(act)d.onclick=act;
L.appendChild(d);
};
row('Weapon: '+WEAPONS[P().weapon].name,WEAPONS[P().weapon].desc,null);
if(G.hasBow)row('Huntsman\'s Bow',G.p.arrows>0?'Arrows: '+G.p.arrows:'No arrows — search the forest','');
if(G.potionCount>0)row('Healing Potion ×'+G.potionCount,'Restores 4 health',()=>drinkPotion());
if(G.owned.cloak)row('Stalker Cloak','+45% dodge chance while sprinting',null);
if(G.owned.boots)row('Swift Boots','+12% movement speed',null);
if(G.owned.charm)row('Lucky Charm','+15% gold from kills',null);
if(G.owned.sigil)row('Rune Sigil','+20% sword damage',null);
if(!G.owned.cloak&&!G.owned.boots&&!G.owned.charm&&!G.owned.sigil)
row('No accessories yet','Visit Mirella the merchant in the village, or find relics in the world',null);
row('Satchel','Gold: '+G.gold+' g · Gems: '+G.gems,null);
}
function drinkPotion(){
if(G.potionCount<=0){SFX.deny();return;}
const p=P();
if(p.hp>=p.maxhp){toast('Health already full','bad');SFX.deny();return;}
G.potionCount--;p.hp=Math.min(p.maxhp,p.hp+4);p.healT=.8;
SFX.potion();burst(p.x,p.y,'#7fe08a',14,180,3.5,.8);
addFloat(p.x,p.y-28,'+4 HP','#7fe08a',16);
if(G.screen==='inventory')renderInv();
}
/* ================= combat ================= */
function swordDmg(){
let d=WEAPONS[P().weapon].dmg;
if(G.owned.sigil)d*=1.2;
return Math.round(d);
}
function startSwing(){
const p=P();
if(p.atkCd>0||G.screen!=='play'||p.dead)return;
const w=WEAPONS[p.weapon];
p.atkCd=w.cd;p.atkT=w.cd;
p.swingId=(p.swingId||0)+1;
SFX.swing();
}
function swingHit(){
const p=P(),w=WEAPONS[p.weapon],dmg=swordDmg();
let hits=0;
const half=w.arc/2+.3; /* generous cone around the sword's current sweep direction */
for(const en of G.enemies){
if(en.dead)continue;
if(en.lastSwing===p.swingId)continue; /* each enemy takes one hit per swing */
const a=angTo(p.x,p.y,en.x,en.y),d=dist(p.x,p.y,en.x,en.y);
if(d>w.range+en.r+8)continue;
if(Math.abs(angDiff(p.facing,a))>half)continue;
en.lastSwing=p.swingId;
hitEnemy(en,dmg,w.fire,w.knock||0);hits++;
}
if(G.boss.state!=='idle'&&!G.boss.dead){
const b=G.enemies.find(e=>e.type==='boss');
if(b&&!b.dead&&b.lastSwing!==p.swingId){
const a=angTo(p.x,p.y,b.x,b.y),d=dist(p.x,p.y,b.x,b.y);
if(d<w.range+36&&Math.abs(angDiff(p.facing,a))<half+.1){
b.lastSwing=p.swingId;
hitEnemy(b,dmg,w.fire,30);hits++;
}
}
}
if(hits){SFX.hit(w.fire);G.shakeT=Math.max(G.shakeT,.06);G.shakeA=Math.max(G.shakeA,2.5);}
}
function hitEnemy(en,dmg,fire,knock){
if(!en||en.dead)return;
en.hp-=dmg;en.flash=.12;
const a=angTo(P().x,P().y,en.x,en.y);
en.kbx+=Math.cos(a)*knook_knock(knock,fire);en.kby+=Math.sin(a)*knook_knock(knock,fire);
if(fire)en.burn=Math.max(en.burn||0,2.5);
burst(en.x,en.y,fire?'#ff9b3d':'#ffddaa',5,180,3,.4);
addFloat(en.x,en.y-en.r-6,''+dmg,fire?'#ff9b3d':'#fff',fire?16:13);
if(en.hp<=0)killEnemy(en);
}
function knook_knock(kn,fire){return (kn||0)+(fire?12:0);}
function shootArrow(){
const p=P();
if(!G.hasBow||G.screen!=='play'||p.dead)return;
if(p.arrows<=0){SFX.deny();addFloat(p.x,p.y-24,'No arrows!','#bcd6ff',12);return;}
p.arrows--;
G.arrows.push({x:p.x+Math.cos(p.facing)*20,y:p.y+Math.sin(p.facing)*20,vx:Math.cos(p.facing)*ARROW,vy:Math.sin(p.facing)*ARROW,t:0,life:1.15});
SFX.bow();
burst(p.x+Math.cos(p.facing)*22,p.y+Math.sin(p.facing)*22,'#cfe8ff',3,120,2,.25);
}
function playerHit(dmg,srcX,srcY){
const p=P();
if(p.invulnT>0||p.dead)return;
const dodge=(G.owned.cloak&&p.moving&&p.stam<98&&Math.random()<.45);
if(dodge){
addFloat(p.x,p.y-26,'DODGED!','#7fb2d6',14);
burst(p.x,p.y,'#7fb2d6',8,150,3,.4);
SFX.step(1);return;
}
p.hp-=dmg;p.invulnT=.85;p.healT=0;
SFX.hurt();
const a=angTo(srcX,srcY,p.x,p.y);
p.vx+=Math.cos(a)*260;p.vy+=Math.sin(a)*260;
G.shakeT=.25;G.shakeA=6;
burst(p.x,p.y,'#e04444',10,200,3.5,.5);
addFloat(p.x,p.y-26,'-'+dmg,'#ff6a5a',16);
els.vign.style.transition='none';els.vign.style.opacity=.9;
setTimeout(()=>{els.vign.style.transition='opacity .8s';els.vign.style.opacity=0;},120);
if(p.hp<=0){p.hp=0;playerDie();}
}
function playerDie(){
const p=P();p.dead=.01;
SFX.die();burst(p.x,p.y,'#c0392b',30,300,5,1);
const lost=Math.floor(G.gold*.25);
setTimeout(()=>{
G.gold-=lost;
G.screen='dead';els.deadUI.hidden=false;
toast('The hollow claims you (−'+lost+' gold)','bad');
},1300);
}
function riseAtSave(){
const p=P();
p.dead=0;p.hp=p.maxhp;p.invulnT=1.5;
p.x=G.saveLoc.x;p.y=G.saveLoc.y;p.vx=0;p.vy=0;
els.deadUI.hidden=true;G.screen='play';cv.classList.add('play');
toast('You rise at the save stone','gold');SFX.save();
writeSave();
}
/* boss helpers */
function bossX(){const b=G.enemies.find(e=>e.type==='boss');return b?b.x:2160;}
function bossY(){const b=G.enemies.find(e=>e.type==='boss');return b?b.y:220;}
function onBossDead(){
G.boss.dead=true;
const b=G.enemies.find(e=>e.type==='boss');
const bx=b?b.x:2980,by=b?b.y:150;
burst(bx,by,'#ff7a2f',60,380,6,1.4);
burst(bx,by,'#ffe08a',40,300,4,1.2);
SFX.roar();
G.shakeT=.7;G.shakeA=14;
toast('Sir Malgrim has fallen!','gold');
for(let i=0;i<8;i++)G.drops.push({x:bx+rand(-80,80),y:by+rand(-60,60),type:'gold',amt:30,t:0,bob:rand(0,9)});
G.drops.push({x:bx,y:by,type:'gem',t:0,bob:0});
writeSave();
if(!G.endShown){G.endShown=true;setTimeout(showEnd,2200);}
updateObjective();
}
function showEnd(){
G.screen='end';
const m=Math.floor(G.playTime/60),s=Math.floor(G.playTime%60);
els.endStats.innerHTML=
'Time in the hollow: <b>'+m+'m '+String(s).padStart(2,'0')+'s</b><br>'
+'Enemies slain: <b>'+G.kills+'</b> · Hearts found: <b>'+G.heartIdx+'/3</b><br>'
+'Weapon: <b>'+WEAPONS[P().weapon].name+'</b> · Gold earned total: <b>'+G.gold+' g</b><br>'
+'Gems collected: <b>'+G.gems+'</b>';
els.endUI.hidden=false;
SFX.heart();
}
function closeEnd(){G.screen='play';els.endUI.hidden=true;cv.classList.add('play');}
/* ================= levers, gates, saves ================= */
function leverPull(lv){
if(lv.done){toast('The lever is spent','bad');return;}
lv.done=true;G.lever[lv.id]=true;SFX.lever();
const g=G.gates.find(g=>g.id===lv.id);
if(g){g.open=true;burst(g.x+g.w/2,g.y+g.h/2,'#ffd97a',22,240,4,.9);SFX.gate();}
if(lv.id==='gate1')toast('The eastern gate groans open','gold');
if(lv.id==='gate2')toast('The northern gate opens','gold');
if(lv.id==='gate3')toast('The keep gate grinds open','gold');
if(lv.id==='boss'){
const b=G.enemies.find(e=>e.type==='boss'&&!e.dead);
if(b){
enrageBoss(b);
toast('MALGRIM REFORGED HIMSELF!','bad');
}else{
G.boss.state='rise';G.boss.atkT=3;
toast('The keep shudders... SOMETHING RISES','bad');
G.shakeT=.5;G.shakeA=10;
}
SFX.gate();SFX.roar();
}
updateObjective();
writeSave();
}
function saveStoneTouch(s){
const p=P();
if(dist(p.x,p.y,s.x,s.y)>46){
G.interactHint='Rest at the save stone (F)';
return;
}
if(p.hp<p.maxhp)p.hp=p.maxhp;
G.savedIdx=s.i;G.saveLoc={x:s.x,y:s.y};
s.used=true;
SFX.save();burst(s.x,s.y-14,'#8fe6ff',24,220,3.5,.9);
addFloat(s.x,s.y-40,'PROGRESS SAVED','#8fe6ff',16);
toast('Saved — rest restores all health','gold');
writeSave();
}
function merchantTouch(){
const p=P();
const d=dist(p.x,p.y,775,505);
if(d<58){
G.interactHint='Trade with Mirella (F)';
if(just.interact)openShop();
}
}
/* ================= updates ================= */
function updatePlayer(dt){
const p=P();
if(p.dead){p.dead+=dt;return;}
p.atkCd=Math.max(0,p.atkCd-dt);
p.invulnT=Math.max(0,p.invulnT-dt);
p.healT=Math.max(0,p.healT-dt);
const w=WEAPONS[p.weapon];
if(p.atkT>0){
p.atkT-=dt;
if(p.atkT<0)p.atkT=0;
/* sword is "out" for most of the animation — test hits every frame in the active window */
const pr=1-clamp(p.atkT/w.cd,0,1);
if(pr>=0.12&&pr<=0.95)swingHit();
}
const ax=(keys.d||keys.arrowright?1:0)-(keys.a||keys.arrowleft?1:0);
const ay=(keys.s||keys.arrowdown?1:0)-(keys.w||keys.arrowup?1:0);
const mv=Math.hypot(ax,ay)>0;
p.moving=mv&&p.stam>1;
let spd=WSPD*(G.owned.boots?1.12:1);
const wantSprint=(keys.shift||keys.lshift)&&mv&&p.stam>1;
if(wantSprint){spd*=1.42;p.stam-=26*dt;if(p.stam<=0){p.stam=0;SFX.exhaust();}}
else p.stam=Math.min(100,p.stam+(mv?14:26)*dt);
if(mv){
const l=Math.hypot(ax,ay);
p.vx=lerp(p.vx,(ax/l)*spd,1-Math.pow(.0001,dt));
p.vy=lerp(p.vy,(ay/l)*spd,1-Math.pow(.0001,dt));
p.stepT-=dt;
if(p.stepT<=0){p.stepT=wantSprint?.2:.3;SFX.step(wantSprint);
if(Math.random()<.5)burst(p.x,p.y+14,'rgba(150,140,120,.5)',1,30,2,.3);}
}else{
p.vx=lerp(p.vx,0,1-Math.pow(.00005,dt));
p.vy=lerp(p.vy,0,1-Math.pow(.00005,dt));
}
moveEnt(p,p.vx*dt,p.vy*dt,14);
p.facing=angTo(p.x,p.y,camX+mouse.x,camY+mouse.y);
if(just.atk)startSwing();
if(just.bow)shootArrow();
/* items */
for(const d of G.drops){
if(d.gone)continue;
if(dist(p.x,p.y,d.x,d.y)<24){
d.gone=1;
if(d.type==='gold'){
let amt=d.amt;if(G.owned.charm)amt=Math.round(amt*1.15);
G.gold+=amt;SFX.coin();addFloat(p.x,p.y-22,'+'+amt+'g','#ffd97a',13);
}else if(d.type==='potion'){
if(giveItem('potion',1)){burst(d.x,d.y,'#e05555',10,160,3,.5);}
else G.gold+=10;
}else if(d.type==='gem'){
G.gems++;SFX.pickup();addFloat(d.x,d.y-14,'+1 gem','#8fe6ff',13);burst(d.x,d.y,'#8fe6ff',8,150,3,.5);
}
}
}
G.drops=G.drops.filter(d=>!d.gone&&d.t<40);
}
function updateEnemies(dt){
const p=P();
for(const en of G.enemies){
if(en.dead){en.gone+=dt;continue;}
en.t+=dt;en.flash=Math.max(0,en.flash-dt);
if((en.burn||0)>0){en.burn-=dt;en.hp-=1.5*dt;burst(en.x,en.y-8,'#ff9b3d',1,50,2.5,.3);if(en.hp<=0){killEnemy(en);continue;}}
if(en.kb>0){en.kb=Math.max(0,en.kb-4*dt);}
moveEnt(en,en.kbx*dt,en.kby*dt,en.r);
en.kbx*=Math.pow(.001,dt);en.kby*=Math.pow(.001,dt);
if(en.dead)continue;
en.cd=Math.max(0,en.cd-dt);
const d=dist(en.x,en.y,p.x,p.y);
const a=angTo(en.x,en.y,p.x,p.y);
const see=p.dead?0:(d<560&&los(en.x,en.y,p.x,p.y));
if(en.type==='slime'){
if(see&&d<420){
en.dir=lerp(en.dir,a,2*dt);
if(en.cd<=0){en.cd=rand(.7,1.2);en.vx2=Math.cos(a)*en.spd*2.6;en.vy2=Math.sin(a)*en.spd*2.6;en.jumpT=.35;}
}else{en.dir+=Math.sin(en.t*1.3)*dt;en.vx2=Math.cos(en.dir)*en.spd*.4;en.vy2=Math.sin(en.dir)*en.spd*.4;}
if(en.jumpT>0){en.jumpT-=dt;en.vx=en.vx2;en.vy=en.vy2;}else{en.vx=en.vx2*.12;en.vy=en.vy2*.12;}
moveEnt(en,en.vx*dt,en.vy*dt,en.r);
if(see&&d<en.r+18&&en.cd>0.3)en.cd=0;
}
else if(en.type==='rat'||en.type==='goblin'){
if(see&&d<520){
en.dir=lerp(en.dir,a,3*dt);
if(en.ranged&&d<300){
if(en.cd<=0){en.cd=rand(1.6,2.4);
G.bolts.push({x:en.x,y:en.y,vx:Math.cos(a)*230,vy:Math.sin(a)*230,t:0,life:2.2,col:'#8fe68f'});
SFX.swing();}
}else en.dir=a;
}else{
en.dir+=rand(-1,1)*dt*2;
}
en.vx=Math.cos(en.dir)*en.spd*(see&&d<520?1:.4);
en.vy=Math.sin(en.dir)*en.spd*(see&&d<520?1:.4);
moveEnt(en,en.vx*dt,en.vy*dt,en.r);
}
else if(en.type==='bat'){
if(see&&d<500)en.dir=angTo(en.x,en.y,p.x+Math.cos(en.t*4)*30,p.y+Math.sin(en.t*3)*30);
else en.dir+=rand(-2,2)*dt;
en.vx=Math.cos(en.dir)*en.spd;en.vy=Math.sin(en.dir)*en.spd;
en.x+=en.vx*dt;en.y+=en.vy*dt;
en.x=clamp(en.x,90,3430);en.y=clamp(en.y,90,790);
}
else if(en.type==='knight'){
if(see&&d<560){
en.dir=lerp(en.dir,a,1.6*dt);
if(d<70&&en.cd<=0){en.cd=1.5;en.vx=Math.cos(a)*300;en.vy=Math.sin(a)*300;SFX.swing();burst(en.x,en.y,'#aab4d6',6,160,3,.3);}
else{en.vx=Math.cos(en.dir)*en.spd;en.vy=Math.sin(en.dir)*en.spd;}
}else{en.vx*=.9;en.vy*=.9;}
moveEnt(en,(en.vx||0)*dt,(en.vy||0)*dt,en.r);
en.vx*=Math.pow(.05,dt);en.vy*=Math.pow(.05,dt);
}
else if(en.type==='wraith'){
if(see&&d<560){
en.dir=lerp(en.dir,a,2.4*dt);
en.phT=(en.phT||0)+dt;
if(en.phT>2.6)en.phT=0;
const ph=en.phT>2.2;
if(ph)en.alpha=lerp(en.alpha||1,.25,6*dt);
else en.alpha=lerp(en.alpha||1,1,4*dt);
en.vx=Math.cos(en.dir)*en.spd*(ph?1.5:1);
en.vy=Math.sin(en.dir)*en.spd*(ph?1.5:1);
en.x+=en.vx*dt;en.y+=en.vy*dt;
if(Math.random()<.12)burst(en.x,en.y,'#9fd4d0',1,40,2.5,.4);
}else{en.alpha=lerp(en.alpha||1,.4,2*dt);en.vx*=.95;en.vy*=.95;en.x+=en.vx*dt;en.y+=en.vy*dt;}
}
else if(en.type==='cultist'){
if(see&&d>380){en.dir=lerp(en.dir,a,2*dt);en.vx=Math.cos(en.dir)*en.spd;en.vy=Math.sin(en.dir)*en.spd;}
else if(see&&d<240){en.dir=lerp(en.dir,a+Math.PI,2*dt);en.vx=Math.cos(en.dir)*en.spd*.7;en.vy=Math.sin(en.dir)*en.spd*.7;}
else{en.vx*=.9;en.vy*=.9;}
if(see&&d<420&&en.cd<=0){en.cd=rand(2,3);
G.bolts.push({x:en.x,y:en.y,vx:Math.cos(a)*240,vy:Math.sin(a)*240,t:0,life:2.4,col:'#9db8ff'});
SFX.swing();}
moveEnt(en,en.vx*dt,en.vy*dt,en.r);
}
else if(en.type==='boss')updateBoss(en,dt);
if(en.type!=='boss'&&!en.permanent){
if(!see&&d>760){en.away=(en.away||0)+dt;if(en.away>12)en.dead=true;}
else en.away=0;
}
/* touch damage */
if(!p.dead&&d<en.r+13&&en.cd<1.2)playerHit(en.dmg,en.x,en.y);
/* enemy-enemy separation */
for(const o of G.enemies){
if(o===en||o.dead)continue;
const dd=dist(en.x,en.y,o.x,o.y);
if(dd<en.r+o.r&&dd>0){
const push=(en.r+o.r-dd)/2,pa=angTo(o.x,o.y,en.x,en.y);
en.x+=Math.cos(pa)*push;en.y+=Math.sin(pa)*push;
}
}
}
G.enemies=G.enemies.filter(e=>!e.dead||e.gone<0.4);
}
function updateBoss(b,dt){
const p=P();
const d=dist(b.x,b.y,p.x,p.y);
const a=angTo(b.x,b.y,p.x,p.y);
G.boss.atkT-=dt;
if(G.boss.state!=='rise'&&G.boss.state!=='phase1'&&G.boss.state!=='w2')return;
if(b.hp<b.maxhp/2&&!G.boss.phase2){G.boss.phase2=true;b.spd*=1.25;SFX.roar();G.shakeT=.4;G.shakeA=8;addFloat(b.x,b.y-40,'MALGRIM ENRAGED!','#ff7a2f',18);writeSave();}
/* movement: approach & orbit */
const want=d>190?0:Math.PI/2;
b.dir=a+want;
const sp=b.spd*(d>190?1:.7);
moveEnt(b,Math.cos(b.dir)*sp*dt,Math.sin(b.dir)*sp*dt,b.r);
if(G.boss.atkT<=0){
const roll=Math.random();
if(roll<.34&&d<420){
/* charge */
b.cd=.8;b.charge={a:a,t:.55};
SFX.roar();
}else if(roll<.62){
/* triple sweep */
b.cd=1.5;b.sweep=3;b.sweepT=.3;
SFX.swing();
}else{
/* fireball ring */
const n=G.boss.phase2?10:8;
for(let i=0;i<n;i++){
const aa=b.dir+i*(TAU/n);
G.bolts.push({x:b.x,y:b.y,vx:Math.cos(aa)*200,vy:Math.sin(aa)*200,t:0,life:3,col:'#ff7a2f',fire:1});
}
SFX.fireball();
}
G.boss.atkT=(G.boss.phase2?2.2:2.9)+rand(-.3,.4);
}
if(b.charge){
b.charge.t-=dt;
b.vx2=Math.cos(b.charge.a)*480;b.vy2=Math.sin(b.charge.a)*480;
moveEnt(b,b.vx2*dt,b.vy2*dt,b.r);
burst(b.x,b.y,'#ff7a2f',2,60,4,.3);
if(b.charge.t<=0){b.charge=null;b.cd=1.2;G.shakeT=.2;G.shakeA=5;}
}
if(b.sweep&&b.cd>0){
b.sweepT-=dt;
if(b.sweepT<=0){
b.sweepT=.42;b.sweep--;
b.dir=angTo(b.x,b.y,p.x,p.y);
let hit=false;
for(const en of G.enemies){
if(en.type==='boss'||en.dead)continue;
const dd=dist(b.x,b.y,en.x,en.y);
if(dd<86+en.r&&Math.abs(angDiff(b.dir,angTo(b.x,b.y,en.x,en.y)))<1.3){hitEnemy(en,9,false,80);hit=true;}
}
if(!p.dead&&d<96&&Math.abs(angDiff(b.dir,a))<1.3)playerHit(6,b.x,b.y);
if(hit)SFX.hit();
G.shakeT=.15;G.shakeA=4;
if(b.sweep<=0)b.sweep=0;
}
}
b.cd=Math.max(0,(b.cd||0)-dt);
}
function los(x1,y1,x2,y2){
const d=dist(x1,y1,x2,y2),steps=Math.ceil(d/24);
for(let i=1;i<steps;i++){
const x=lerp(x1,x2,i/steps),y=lerp(y1,y2,i/steps);
for(const w of G.walls)if(x>w.x&&x<w.x+w.w&&y>w.y&&y<w.y+w.h)return false;
}
return true;
}
function updateBolts(dt){
const p=P();
for(const b of G.bolts){
b.t+=dt;b.x+=b.vx*dt;b.y+=b.vy*dt;
if(collidesWorld(b.x,b.y,4)){b.t=99;burst(b.x,b.y,b.col,4,90,2.5,.3);continue;}
if(!p.dead&&dist(b.x,b.y,p.x,p.y)<14){b.t=99;playerHit(b.fire?5:4,b.x,b.y);}
else for(const en of G.enemies){
if(en.type==='boss'||en.dead)continue;
if(dist(b.x,b.y,en.x,en.y)<en.r+5){b.t=99;hitEnemy(en,b.fire?5:3,false,0);break;}
}
}
G.bolts=G.bolts.filter(b=>b.t<b.life);
}
function updateArrows(dt){
const p=P();
for(const ar of G.arrows){
ar.t+=dt;ar.x+=ar.vx*dt;ar.y+=ar.vy*dt;
if(collidesWorld(ar.x,ar.y,3)){ar.t=99;SFX.arrowHit();burst(ar.x,ar.y,'#cfe8ff',4,90,2,.3);continue;}
for(const en of G.enemies){
if(en.dead)continue;
if(dist(ar.x,ar.y,en.x,en.y)<en.r+ARRR){
ar.t=99;SFX.arrowHit();
hitEnemy(en,6,false,60);
burst(ar.x,ar.y,'#fff',5,140,2.5,.35);
break;
}
}
}
G.arrows=G.arrows.filter(a=>a.t<a.life);
}
function updateDrops(dt){for(const d of G.drops)d.t+=dt;}
function updateParts(dt){
for(const pt of G.parts){
pt.t+=dt;
pt.x+=pt.vx*dt;pt.y+=pt.vy*dt;
pt.vy+=(pt.g||0)*dt;pt.vx*=Math.pow(.1,dt);pt.vy*=Math.pow(.2,dt);
}
G.parts=G.parts.filter(p=>p.t<p.life);
for(const f of G.floats){f.t+=dt;f.y-=26*dt;}
G.floats=G.floats.filter(f=>f.t<1.2);
}
/* boss spawn state machine */
function enrageBoss(b){
G.boss.phase2=true;
b.hp=b.maxhp*.6;b.maxhp=Math.round(b.maxhp*.6);
b.spd*=1.2;
const k1=mkEnemy('knight',2700,300);k1.permanent=true;
const k2=mkEnemy('knight',3250,300);k2.permanent=true;
G.enemies.push(k1,k2);
}
function spawnMalgrim(){
const b=mkEnemy('boss',2980,150);b.permanent=true;
G.enemies.push(b);
const k1=mkEnemy('knight',2880,150);k1.permanent=true;
const k2=mkEnemy('knight',3080,150);k2.permanent=true;
G.enemies.push(k1,k2);
}
function updateBossState(dt){
if(G.boss.state==='rise'){
G.boss.atkT-=dt;
if(G.boss.atkT<=0){
G.boss.state='phase1';
spawnMalgrim();
G.boss.atkT=3;
toast('SIR MALGRIM, THE EMBERKNIGHT','bad');
G.shakeT=.4;G.shakeA=8;
}
}else if(G.boss.state==='w2'){
/* resumed after save: Malgrim returns, forged in embers */
spawnMalgrim();
const b=G.enemies.find(e=>e.type==='boss');
if(b)enrageBoss(b);
G.boss.state='phase1';
toast('MALGRIM RISES, FORGED IN EMBERS','bad');
}else if(G.boss.state==='phase1'&&!G.boss.dead){
/* mid-fight enrage is handled by updateBoss (single phase2 transition) */
}
}
function buildRelics(){
G.relicList=[
{id:'heart1',type:'heart',x:1380,y:330},
{id:'heart2',type:'heart',x:2950,y:660},
{id:'bless3',type:'bless',x:2100,y:560},
{id:'w1',type:'weapon',lv:1,x:2700,y:480},
{id:'w2',type:'weapon',lv:2,x:3020,y:540},
{id:'cloak',type:'item',id2:'cloak',x:1250,y:700},
{id:'boots',type:'item',id2:'boots',x:3100,y:600},
{id:'charm',type:'item',id2:'charm',x:850,y:480},
{id:'sigil',type:'item',id2:'sigil',x:2400,y:440},
{id:'bow',type:'bow',x:2200,y:180},
{id:'arrows1',type:'arrows',amt:15,x:1950,y:300},
{id:'arrows2',type:'arrows',amt:15,x:3060,y:680},
{id:'potion1',type:'potion',x:1480,y:420},
{id:'potion2',type:'potion',x:2300,y:700},
{id:'gold1',type:'gold',amt:40,x:600,y:500},
{id:'gold2',type:'gold',amt:40,x:2200,y:420},
{id:'gold3',type:'gold',amt:50,x:2860,y:600}
];
}
function updateRelicPickup(){
const p=P();
for(const r of G.relicList){
if(G.relics[r.id])continue;
if(dist(p.x,p.y,r.x,r.y)<26){
G.relics[r.id]=true;
if(r.type==='heart'||r.type==='bless')giveHeart();
else if(r.type==='weapon'){
p.weapon=r.lv;
toast('Weapon found: '+WEAPONS[r.lv].name+'!','gold');
SFX.item();burst(r.x,r.y,'#ffd97a',20,240,4,.8);
}
else if(r.type==='item'){giveItem(r.id2,1);}
else if(r.type==='bow'){
G.hasBow=true;toast('Huntsman\'s Bow discovered! (right-click / K)','gold');
SFX.item();burst(r.x,r.y,'#cfe8ff',18,220,4,.8);
}
else if(r.type==='arrows'){p.arrows+=r.amt;SFX.pickup();toast('+'+r.amt+' arrows','gold');}
else if(r.type==='potion'){giveItem('potion',1);}
else if(r.type==='gold'){G.gold+=r.amt;SFX.coin();addFloat(r.x,r.y,'+'+r.amt+'g','#ffd97a',14);burst(r.x,r.y,'#ffd97a',8,150,3,.5);}
writeSave();updateObjective();
}
}
}
/* zone detection & waves */
let lastZone='village';
function zoneOf(x,y){
if(x<960)return 'village';
if(x<1720)return 'forest';
if(y<380)return x<2560?'north':'fort';
return x<2560?'cave':'east';
}
function spawnAmbient(){
for(const s of G.spawnDefs){
for(let i=0;i<s.n;i++){
const e=mkEnemy(s.t,s.x+rand(-24,24),s.y+rand(-24,24));
e.permanent=true;
G.enemies.push(e);
}
}
}
function updateZone(dt){
const p=P();
const z=zoneOf(p.x,p.y);
G.zone=z;
if(z===lastZone)return;
lastZone=z;
if(!G.zonesDone)G.zonesDone={};
const done=G.zonesDone;
if(z==='forest'&&!done.forest){done.forest=1;spawnEnemiesNear(p.x,p.y,6,['slime','rat','bat']);toast('Creatures stir from the undergrowth!','bad');}
if(z==='north'&&!done.north){done.north=1;spawnEnemiesNear(p.x,p.y,7,['bat','rat','goblin']);toast('Goblin scouts block the northern path!','bad');}
if(z==='cave'&&!done.cave){done.cave=1;spawnEnemiesNear(p.x,p.y,9,['bat','rat','goblin','cultist','knight']);toast('The dark cave awakens!','bad');}
if(z==='fort'&&G.boss.state==='idle'&&!done.fort){done.fort=1;spawnEnemiesNear(p.x,p.y,4,['knight','cultist']);toast('Guardians of the keep engage!','bad');}
if(z==='east'&&!done.east){done.east=1;spawnEnemiesNear(p.x,p.y,9,['goblin','cultist','knight','bat']);toast('The goblin camp rises in defiance!','bad');}
}
/* camera */
let camX=0,camY=0;
function updateCam(dt){
const p=P();
const tx=p.x-cv.width/2,ty=p.y-cv.height/2;
camX=lerp(camX,clamp(tx,0,3520-cv.width),1-Math.pow(.001,dt));
camY=lerp(camY,clamp(ty,0,880-cv.height),1-Math.pow(.001,dt));
if(G.shakeT>0)G.shakeT-=dt;else G.shakeA=0;
}
function camShakeX(){return G.shakeT>0?rand(-G.shakeA,G.shakeA):0;}
function camShakeY(){return G.shakeT>0?rand(-G.shakeA,G.shakeA):0;}
/* ================= HUD ================= */
const hudCtx=els.hudC.getContext('2d');
function heartPath(c,x,y,s){
c.beginPath();
c.moveTo(x,y+s*.35);
c.bezierCurveTo(x,y-s*.1,x-s*.5,y-s*.1,x-s*.5,y+s*.15);
c.bezierCurveTo(x-s*.5,y+s*.55,x,y+s*.7,x,y+s*.95);
c.bezierCurveTo(x,y+s*.7,x+s*.5,y+s*.55,x+s*.5,y+s*.15);
c.bezierCurveTo(x+s*.5,y-s*.1,x,y-s*.1,x,y+s*.35);
c.closePath();
}
function drawHUD(){
const c=hudCtx,p=P();
c.clearRect(0,0,330,96);
/* hearts: 5 slots, 2 HP each, partial fill in current slot */
for(let i=0;i<5;i++){
const x=16+i*36,y=14,s=24;
const found=i<G.heartIdx;
if(found){
const used=p.hp>2*i?clamp((p.hp-2*i)/2,0,1):0;
heartPath(c,x,y,s);
c.fillStyle='#2a0f12';c.fill();
c.strokeStyle='#000';c.lineWidth=1.5;c.stroke();
if(used>0){
c.save();heartPath(c,x,y,s);c.clip();
c.fillStyle='#ff4d5e';
c.fillRect(x-13,y-4+s*1.1*(1-used),26,s*1.1*used);
c.restore();
}
}else{
heartPath(c,x,y,s);
c.strokeStyle='#3a3f38';c.lineWidth=1.5;c.stroke();
}
}
/* stamina */
c.fillStyle='#101812';c.fillRect(16,46,150,9);
const st=p.stam/100;
const grad=c.createLinearGradient(16,0,166,0);
grad.addColorStop(0,'#d4a63f');grad.addColorStop(1,'#7fc45f');
c.fillStyle=grad;
c.fillRect(18,48,146*st,5);
c.strokeStyle='rgba(107,90,52,.8)';c.strokeRect(16.5,46.5,149,8);
c.font='10px Georgia';c.fillStyle='#9fb098';
c.fillText('STAMINA',172,55);
/* weapon name */
c.font='13px Georgia';c.fillStyle='#d4a63f';
c.fillText(WEAPONS[p.weapon].name,16,76);
/* potion count */
c.fillStyle='#e05555';c.beginPath();c.arc(160,72,5,0,TAU);c.fill();
c.fillStyle='#e8dcb8';c.font='12px Georgia';
c.fillText('×'+(G.potionCount),170,76);
/* arrows */
if(G.hasBow){
c.fillStyle='#bcd6ff';
c.beginPath();c.moveTo(210,68);c.lineTo(218,76);c.lineTo(206,76);c.closePath();c.fill();
c.font='12px Georgia';c.fillText('×'+p.arrows,222,76);
}
/* boss bar */
if((G.boss.state==='phase1'||G.boss.state==='phase2'||G.boss.state==='w2')&&!G.boss.dead){
const b=G.enemies.find(e=>e.type==='boss');
if(b){
const w=300,x=15,y=84;
c.fillStyle='rgba(0,0,0,.6)';c.fillRect(x-2,y-2,w+4,12);
c.fillStyle='#5a1410';c.fillRect(x,y,w,8);
c.fillStyle=G.boss.phase2?'#ff5a2a':'#d4543f';
c.fillRect(x,y,w*clamp(b.hp/b.maxhp,0,1),8);
c.strokeStyle='#6b5a34';c.strokeRect(x-2,y-2,w+4,12);
}
}
/* weapon icon + name (DOM) */
els.wName.textContent=WEAPONS[p.weapon].name;
els.wSub.textContent=WEAPONS[p.weapon].desc;
drawWeaponIcon();
els.goldVal.textContent=G.gold;
/* arrow counter box */
if(G.hasBow&&p.arrows>0){els.arwBox.style.display='block';els.arwVal.textContent=p.arrows;}
else els.arwBox.style.display='none';
}
function drawWeaponIcon(){
const c=els.wIcon.getContext('2d');
c.clearRect(0,0,52,52);
const lv=P().weapon;
c.save();c.translate(26,28);c.rotate(-Math.PI/4);
/* blade */
const bc=['#9aa5ad','#c8d2dc','#ff8a3d'][lv];
c.fillStyle=bc;
c.fillRect(-2,-30,4,26);
c.beginPath();c.moveTo(-2,-30);c.lineTo(0,-36);c.lineTo(2,-30);c.closePath();c.fill();
if(lv===2){
c.shadowColor='#ff7a2f';c.shadowBlur=8;
c.fillStyle='#ffd08a';c.fillRect(-2,-30,2,26);
c.shadowBlur=0;
}
/* crossguard */
c.fillStyle=lv===0?'#6b5a34':(lv===1?'#8a6a1f':'#b8452e');
c.fillRect(-8,-4,16,4);
/* grip */
c.fillStyle='#4a3524';c.fillRect(-2,0,4,10);
c.restore();
}
/* ================= rendering ================= */
function zoneFloor(x,y){
const z=zoneOf(x,y);
if(z==='cave'||z==='cave2')return '#1d232b';
if(z==='fort')return '#241e1c';
if(z==='north')return '#1f2b1a';
if(z==='forest')return '#20291c';
return '#262e24';
}
function render(){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle='#0a0f0d';ctx.fillRect(0,0,cv.width,cv.height);
ctx.save();
ctx.translate(-camX+camShakeX(),-camY+camShakeY());
renderFloor();
renderGateBars();
renderRelics(false);
renderDrops();
renderStones();
renderLevers();
renderShopkeeper();
renderEnemies();
renderPlayer();
renderBolts();
renderArrows();
renderWalls();
renderRelics(true);
renderParticles();
renderFloats();
renderLighting();
ctx.restore();
if(G.screen==='play')drawHUD();
}
function renderFloor(){
/* visible world rect */
const x0=Math.max(80,camX-60),x1=Math.min(3440,camX+cv.width+60);
const y0=Math.max(80,camY-60),y1=Math.min(800,camY+cv.height+60);
ctx.fillStyle='#151a15';
ctx.fillRect(x0,y0,x1-x0,y1-y0);
/* zone bands */
ctx.fillStyle='#262e24';ctx.fillRect(x0,y0,x1-x0,y1-y0); // village base
ctx.fillStyle='#20291c';ctx.fillRect(960,Math.max(80,y0),Math.max(0,x1-960),y1-y0); // forest
ctx.fillStyle='#1d232b';ctx.fillRect(1720,380,Math.max(0,x1-1720),y1-380); // cave
ctx.fillStyle='#1f2b1a';ctx.fillRect(1720,Math.max(80,y0),Math.max(0,x1-1720),380-Math.max(80,y0)); // north
ctx.fillStyle='#241e1c';ctx.fillRect(2560,Math.max(80,y0),Math.max(0,x1-2560),372-Math.max(80,y0)); // fort
ctx.fillStyle='#232c1e';ctx.fillRect(2560,380,Math.max(0,x1-2560),y1-380); // eastern woods
/* boundary wall */
ctx.strokeStyle='#0c100c';ctx.lineWidth=26;
ctx.strokeRect(67,67,3406,746);
/* speckle */
const sx=Math.floor(camX/32)*32,sy=Math.floor(camY/32)*32;
for(let yy=sy;yy<y1;yy+=32){
for(let xx=sx;xx<x1;xx+=32){
if(xx<80||xx>3440||yy<80||yy>800)continue;
const h=hash2(xx,yy);
if(h<.5)continue;
const z=zoneOf(xx,yy);
let col;
if(z==='cave')col='rgba(90,110,130,.10)';
else if(z==='fort')col='rgba(120,80,60,.10)';
else col='rgba(60,90,50,.12)';
ctx.fillStyle=col;
ctx.fillRect(xx+h*20,yy+hash2(xx+7,yy)*20,3+h*3,2+h*2);
}
}
}
function renderWalls(){
for(const w of G.walls){
ctx.fillStyle='#0d120e';
ctx.fillRect(w.x-3,w.y-3,w.w+6,w.h+6);
ctx.fillStyle='#39453c';
ctx.fillRect(w.x,w.y,w.w,w.h);
ctx.fillStyle='#4a584c';
ctx.fillRect(w.x+2,w.y+2,w.w-4,Math.max(1,w.h*.35));
if(w.w>w.h){ctx.fillStyle='rgba(255,255,255,.05)';ctx.fillRect(w.x,w.y+2,w.w,3);}
else{ctx.fillStyle='rgba(255,255,255,.05)';ctx.fillRect(w.x+2,w.y,3,w.h);}
}
for(const g of G.gates){
ctx.fillStyle='#0d120e';
ctx.fillRect(g.x-4,g.y-4,g.w+8,g.h+8);
ctx.fillStyle='#3d3427';
ctx.fillRect(g.x,g.y,g.w,g.h);
ctx.fillStyle='#574a33';
for(let i=0;i<3;i++)ctx.fillRect(g.x+4+i*(g.w-8)/2.4,g.y+3,3,g.h-6);
}
}
function renderGateBars(){
for(const g of G.gates){
if(g.open)continue;
const cx=g.x+g.w/2,cy=g.y+g.h/2;
ctx.save();
ctx.translate(cx,cy);
ctx.shadowColor='#c0392b';ctx.shadowBlur=14;
ctx.fillStyle='#c0392b';
for(let i=-1;i<=1;i++)ctx.fillRect(i*14-2,-g.h/2,4,g.h);
ctx.shadowBlur=0;
ctx.fillStyle='#ffd97a';
ctx.beginPath();ctx.arc(0,0,4+Math.sin(G.time*5)*1.5,0,TAU);ctx.fill();
ctx.restore();
}
}
function renderStones(){
for(const s of G.saveStones){
const p=P();
const d=dist(p.x,p.y,s.x,s.y);
ctx.save();
ctx.translate(s.x,s.y);
/* base */
ctx.fillStyle='#3a4048';
ctx.beginPath();ctx.ellipse(0,6,20,9,0,0,TAU);ctx.fill();
/* slab */
ctx.fillStyle='#5a646e';
ctx.fillRect(-13,-26,26,32);
ctx.fillStyle='#77828e';
ctx.fillRect(-10,-24,20,8);
/* rune glow */
const gl=.5+Math.sin(G.time*2+s.i)*.3;
ctx.shadowColor='#8fe6ff';ctx.shadowBlur=12*gl;
ctx.fillStyle='rgba(143,230,255,'+(0.5+gl*.4)+')';
ctx.font='16px Georgia';ctx.textAlign='center';
ctx.fillText('\u2726',0,-8);
ctx.textAlign='left';
ctx.shadowBlur=0;
/* beam when active */
if(d<46){
const g2=ctx.createLinearGradient(0,-60,0,0);
g2.addColorStop(0,'rgba(143,230,255,0)');
g2.addColorStop(1,'rgba(143,230,255,.25)');
ctx.fillStyle=g2;
ctx.fillRect(-10,-60,20,60);
}
ctx.restore();
}
}
function renderLevers(){
for(const l of G.levers){
ctx.save();ctx.translate(l.x,l.y);
ctx.fillStyle='#2c3138';
ctx.beginPath();ctx.ellipse(0,6,16,7,0,0,TAU);ctx.fill();
const ang=l.done?-1.1:.5;
ctx.strokeStyle=l.done?'#6b7078':'#c8b070';
ctx.lineWidth=4;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(0,4);ctx.lineTo(Math.sin(ang)*18,4-Math.cos(ang)*18);ctx.stroke();
if(!l.done){
ctx.shadowColor='#ffd97a';ctx.shadowBlur=8+Math.sin(G.time*4)*4;
ctx.fillStyle='#ffd97a';
ctx.beginPath();ctx.arc(Math.sin(ang)*18,4-Math.cos(ang)*18,3.5,0,TAU);ctx.fill();
ctx.shadowBlur=0;
}
ctx.restore();
}
}
function renderShopkeeper(){
const x=775,y=505;
ctx.save();ctx.translate(x,y);
ctx.fillStyle='#2c3138';
ctx.beginPath();ctx.ellipse(0,16,18,7,0,0,TAU);ctx.fill();
ctx.fillStyle='#7a4a3a';
ctx.fillRect(-10,-12,20,26);
ctx.fillStyle='#e8dcb8';
ctx.beginPath();ctx.arc(0,-18,8,0,TAU);ctx.fill();
ctx.fillStyle='#5a3a2a';
ctx.fillRect(-8,-26,16,5);
ctx.fillStyle='#d4a63f';
ctx.font='11px Georgia';ctx.textAlign='center';
ctx.fillText('MIRELLA',0,-34);
ctx.textAlign='left';
ctx.restore();
}
function drawEnemy(en){
const d=EN[en.type];
ctx.save();
ctx.translate(en.x,en.y);
if(en.alpha)ctx.globalAlpha=en.alpha;
const squash=en.jumpT>0?1+en.jumpT*.5:1;
if(en.type==='slime'){
ctx.scale(1/squash,squash);
ctx.fillStyle=d.c;
ctx.beginPath();ctx.ellipse(0,0,en.r,en.r*.8,0,0,TAU);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.35)';
ctx.beginPath();ctx.ellipse(-4,-5,4,3,0,0,TAU);ctx.fill();
ctx.fillStyle='#123316';
ctx.beginPath();ctx.arc(-4,-1,2,0,TAU);ctx.arc(5,-1,2,0,TAU);ctx.fill();
}
else if(en.type==='rat'){
const a=en.dir;
ctx.rotate(a);
ctx.fillStyle=d.c;
ctx.beginPath();ctx.ellipse(0,0,en.r*1.25,en.r*.7,0,0,TAU);ctx.fill();
ctx.beginPath();ctx.arc(-en.r,-4,4,0,TAU);ctx.arc(-en.r,4,4,0,TAU);ctx.fill();
ctx.fillStyle='#7a6048';
ctx.beginPath();ctx.moveTo(-en.r*1.2,0);ctx.lineTo(-en.r*2,0);ctx.lineTo(-en.r*1.2,2);ctx.fill();
ctx.fillStyle='#1a120c';
ctx.beginPath();ctx.arc(en.r*.8,-2,1.5,0,TAU);ctx.fill();
}
else if(en.type==='bat'){
const flap=Math.sin(en.t*14)*.8;
ctx.fillStyle=d.c;
ctx.save();ctx.rotate(flap);ctx.beginPath();ctx.ellipse(-9,0,9,4,-.4,0,TAU);ctx.fill();ctx.restore();
ctx.save();ctx.rotate(-flap);ctx.beginPath();ctx.ellipse(9,0,9,4,.4,0,TAU);ctx.fill();ctx.restore();
ctx.beginPath();ctx.arc(0,0,6,0,TAU);ctx.fill();
ctx.fillStyle='#3a2f4a';
ctx.beginPath();ctx.moveTo(-3,-5);ctx.lineTo(-6,-10);ctx.lineTo(0,-6);ctx.lineTo(6,-10);ctx.lineTo(3,-5);ctx.fill();
}
else if(en.type==='goblin'||en.type==='cultist'){
const a=Math.atan2(P().y-en.y,P().x-en.x);
ctx.rotate(a);
ctx.fillStyle=d.c;
ctx.beginPath();ctx.arc(0,0,en.r,0,TAU);ctx.fill();
ctx.fillStyle='rgba(0,0,0,.25)';
ctx.beginPath();ctx.arc(0,0,en.r*.6,0,TAU);ctx.fill();
ctx.fillStyle='#1a2416';
ctx.beginPath();ctx.arc(en.r*.5,-3,2,0,TAU);ctx.arc(en.r*.5,3,2,0,TAU);ctx.fill();
if(en.type==='cultist'){
ctx.fillStyle='#31406b';
ctx.fillRect(-en.r,-4,4,8);
ctx.fillStyle='#9db8ff';
ctx.beginPath();ctx.arc(en.r+2,0,3+Math.sin(en.t*6)*1,0,TAU);ctx.fill();
}else{
ctx.fillStyle='#3a4a2a';
ctx.fillRect(en.r*.4,-2,12,3);
}
}
else if(en.type==='knight'){
ctx.rotate(en.dir);
ctx.fillStyle=d.c;
ctx.beginPath();ctx.arc(0,0,en.r,0,TAU);ctx.fill();
ctx.fillStyle='#5a6478';
ctx.beginPath();ctx.arc(0,0,en.r*.7,0,TAU);ctx.fill();
ctx.fillStyle='#ffd97a';
ctx.fillRect(en.r*.2,-2,8,4);
if(en.cd>0.6){
ctx.strokeStyle='rgba(255,217,122,.6)';ctx.lineWidth=2;
ctx.beginPath();ctx.arc(en.r*.6,0,26,-.9,.9);ctx.stroke();
}
}
else if(en.type==='wraith'){
ctx.fillStyle=d.c;
ctx.beginPath();ctx.arc(0,0,en.r,0,TAU);ctx.fill();
for(let i=0;i<5;i++){
const w2=Math.sin(en.t*4+i)*2;
ctx.beginPath();ctx.ellipse(-8+i*4,en.r*.7+w2,2.5,4,0,0,TAU);ctx.fill();
}
ctx.fillStyle='#0e2a28';
ctx.beginPath();ctx.arc(-4,-2,2.5,0,TAU);ctx.arc(4,-2,2.5,0,TAU);ctx.fill();
}
else if(en.type==='boss'){
const t=en.t;
ctx.rotate(0);
/* aura */
const ag=ctx.createRadialGradient(0,0,10,0,0,44);
ag.addColorStop(0,'rgba(255,90,40,.35)');ag.addColorStop(1,'rgba(255,90,40,0)');
ctx.fillStyle=ag;
ctx.beginPath();ctx.arc(0,0,44,0,TAU);ctx.fill();
/* shoulders */
ctx.fillStyle='#5a2418';
ctx.beginPath();ctx.ellipse(0,4,26,18,0,0,TAU);ctx.fill();
/* head */
ctx.fillStyle='#6b2d1c';
ctx.beginPath();ctx.arc(0,-10,14,0,TAU);ctx.fill();
/* helm */
ctx.fillStyle='#7d88a6';
ctx.beginPath();ctx.arc(0,-12,14,Math.PI,0);ctx.fill();
ctx.fillRect(-14,-14,28,4);
ctx.fillStyle=G.boss.phase2?'#ff5a2a':'#ffd97a';
ctx.shadowColor=G.boss.phase2?'#ff5a2a':'#ffd97a';ctx.shadowBlur=10;
ctx.beginPath();ctx.arc(-6,-10,2.5,0,TAU);ctx.arc(6,-10,2.5,0,TAU);ctx.fill();
ctx.shadowBlur=0;
/* horned crest */
ctx.strokeStyle='#7d88a6';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(-8,-22);ctx.quadraticCurveTo(-14,-30,-10,-36);ctx.stroke();
ctx.beginPath();ctx.moveTo(8,-22);ctx.quadraticCurveTo(14,-30,10,-36);ctx.stroke();
/* sword swing */
if(en.sweep&&en.cd>0){
ctx.rotate(en.dir||0);
ctx.strokeStyle='rgba(255,154,61,.7)';ctx.lineWidth=6;
ctx.beginPath();ctx.arc(0,0,60,-.5+Math.sin(t*10),.5+Math.sin(t*10));ctx.stroke();
ctx.strokeStyle='#c8d2dc';ctx.lineWidth=5;
ctx.beginPath();ctx.moveTo(18,0);ctx.lineTo(52,0);ctx.stroke();
}
/* charge telegraph */
if(en.charge){
ctx.rotate(en.charge.a);
ctx.fillStyle='rgba(255,90,40,.18)';
ctx.fillRect(20,-20,260,40);
}
}
/* flash */
if(en.flash>0){
ctx.globalAlpha=en.flash*4;
ctx.fillStyle='#fff';
ctx.beginPath();ctx.arc(0,0,en.r+2,0,TAU);ctx.fill();
}
ctx.restore();
/* hp bar */
if(!en.dead&&en.hp<en.maxhp&&en.type!=='boss'){
const w2=en.r*2;
ctx.fillStyle='rgba(0,0,0,.6)';
ctx.fillRect(en.x-w2/2,en.y-en.r-12,w2,4);
ctx.fillStyle='#d4543f';
ctx.fillRect(en.x-w2/2,en.y-en.r-12,w2*clamp(en.hp/en.maxhp,0,1),4);
}
}
function renderEnemies(){
const srt=G.enemies.slice().sort((a,b)=>a.y-b.y);
for(const en of srt){
if(en.dead&&en.gone>0)continue;
drawEnemy(en);
}
}
function renderPlayer(){
const p=P();
if(p.dead&&p.dead>1.2)return;
ctx.save();
ctx.translate(p.x,p.y);
if(p.dead){ctx.globalAlpha=1-p.dead*.8;ctx.rotate(p.dead*2);}
if(p.invulnT>0&&Math.floor(G.time*16)%2===0)ctx.globalAlpha=.45;
/* shadow */
ctx.fillStyle='rgba(0,0,0,.4)';
ctx.beginPath();ctx.ellipse(0,12,12,5,0,0,TAU);ctx.fill();
const moving=Math.hypot(p.vx,p.vy)>30;
const bob=moving?Math.sin(G.time*(p.moving&&p.stam<98?16:11))*1.8:Math.sin(G.time*2.5)*.8;
ctx.translate(0,bob);
/* cape if cloak */
if(G.owned.cloak){
ctx.fillStyle='rgba(90,130,170,.8)';
const wa=angTo(p.x,p.y,p.x+Math.cos(p.facing),p.y+Math.sin(p.facing));
ctx.save();ctx.rotate(p.facing+Math.PI);
ctx.beginPath();ctx.moveTo(-4,-4);ctx.lineTo(-16+Math.sin(G.time*8)*2,10);ctx.lineTo(-2,12);ctx.closePath();ctx.fill();
ctx.restore();
}
/* body */
ctx.fillStyle='#3d4a3e';
ctx.beginPath();ctx.arc(0,0,11,0,TAU);ctx.fill();
ctx.fillStyle='#55684f';
ctx.beginPath();ctx.arc(0,-2,8,0,TAU);ctx.fill();
/* head + helm tint by weapon */
const hc=['#8a949c','#c0cad4','#ffb066'][p.weapon];
ctx.fillStyle='#e8dcb8';
ctx.beginPath();ctx.arc(0,-4,6.5,0,TAU);ctx.fill();
ctx.fillStyle=hc;
ctx.beginPath();ctx.arc(0,-5,6.5,Math.PI,0);ctx.fill();
if(p.weapon===2){
ctx.fillStyle='#ff7a2f';
ctx.fillRect(-1.5,-13,3,5);
ctx.shadowColor='#ff7a2f';ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0;
}
/* weapon — sword sweeps through a wide arc; the trail shows the hit zone */
const w=WEAPONS[p.weapon];
const restA=0.45;
let a1=restA,off=12;
if(p.atkT>0){
const pr=1-clamp(p.atkT/(w.cd||.4),0,1);
a1=-w.arc/2+pr*w.arc;
off=12+Math.sin(pr*Math.PI)*(w.range*.55);
const t0=a1-Math.min(pr*w.arc,w.arc*.7);
ctx.save();
ctx.globalAlpha=(w.fire?.30:.22)*(1-.45*pr);
ctx.fillStyle=w.fire?'#ff9b3d':'#e8f0f4';
ctx.beginPath();
ctx.moveTo(0,0);
ctx.arc(0,0,w.range+14,t0,a1);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.save();
ctx.rotate(p.facing+a1);
if(p.weapon===0){
ctx.fillStyle='#9aa5ad';
ctx.fillRect(off-2,-2,20,3);
ctx.beginPath();ctx.moveTo(off+18,-3);ctx.lineTo(off+24,0);ctx.lineTo(off+18,1);ctx.closePath();ctx.fill();
ctx.fillStyle='#6b5a34';ctx.fillRect(off-2,-4,3,8);
}else if(p.weapon===1){
ctx.fillStyle='#c8d2dc';
ctx.fillRect(off-2,-2.5,28,4);
ctx.beginPath();ctx.moveTo(off+26,-4);ctx.lineTo(off+36,0);ctx.lineTo(off+26,2);ctx.closePath();ctx.fill();
ctx.fillStyle='#8a6a1f';ctx.fillRect(off-2,-5,3,11);
}else{
ctx.shadowColor='#ff7a2f';ctx.shadowBlur=10;
ctx.fillStyle='#ffb066';
ctx.fillRect(off-2,-2.5,30,4);
ctx.beginPath();ctx.moveTo(off+28,-5);ctx.lineTo(off+40,0);ctx.lineTo(off+28,3);ctx.closePath();ctx.fill();
ctx.fillStyle='#b8452e';ctx.fillRect(off-2,-6,3,12);
ctx.shadowBlur=0;
}
ctx.restore();
/* bow if owned (held at side) */
if(G.hasBow){
ctx.save();ctx.rotate(p.facing+Math.PI/2);
ctx.strokeStyle='#8a5a34';ctx.lineWidth=3;
ctx.beginPath();ctx.arc(14,0,12,-1.2,1.2);ctx.stroke();
ctx.strokeStyle='rgba(220,230,240,.5)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(14+Math.cos(-1.2)*12,Math.sin(-1.2)*12);ctx.lineTo(14+Math.cos(1.2)*12,Math.sin(1.2)*12);ctx.stroke();
ctx.restore();
}
/* heal sparkles */
if(p.healT>0){
ctx.fillStyle='rgba(127,224,138,'+(p.healT*.6)+')';
for(let i=0;i<3;i++){
const a=G.time*3+i*2.1;
ctx.beginPath();ctx.arc(Math.cos(a)*14,-8+Math.sin(a*1.3)*6,2,0,TAU);ctx.fill();
}
}
ctx.restore();
/* stamina sparkles when exhausted */
if(p.stam<8&&moving){
ctx.fillStyle='rgba(200,220,255,.5)';
ctx.beginPath();ctx.arc(p.x+rand(-8,8),p.y+rand(-8,8),1.5,0,TAU);ctx.fill();
}
}
function renderDrops(){
for(const d of G.drops){
const b=Math.sin(d.bob+G.time*4)*2;
ctx.save();ctx.translate(d.x,d.y+b);
if(d.type==='gold'){
ctx.shadowColor='#ffd97a';ctx.shadowBlur=8;
ctx.fillStyle='#d4a63f';
ctx.beginPath();ctx.arc(0,0,5,0,TAU);ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.beginPath();ctx.arc(-1.5,-1.5,2,0,TAU);ctx.fill();
}else if(d.type==='potion'){
ctx.shadowColor='#e05555';ctx.shadowBlur=8;
ctx.fillStyle='#a03040';
ctx.beginPath();ctx.arc(0,1,5,0,TAU);ctx.fill();
ctx.fillStyle='#7a4a3a';
ctx.fillRect(-2,-6,4,4);
ctx.fillStyle='rgba(255,255,255,.4)';
ctx.beginPath();ctx.arc(-1.5,-.5,1.5,0,TAU);ctx.fill();
}else{
ctx.shadowColor='#8fe6ff';ctx.shadowBlur=8;
ctx.fillStyle='#8fe6ff';
ctx.beginPath();ctx.moveTo(0,-6);ctx.lineTo(4,0);ctx.lineTo(0,6);ctx.lineTo(-4,0);ctx.closePath();ctx.fill();
}
ctx.restore();
}
}
function renderRelics(top){
for(const r of G.relicList){
if(G.relics&&G.relics[r.id])continue;
const isTop=r.type==='heart'||r.type==='weapon'||r.type==='bow';
if(top!==isTop)continue;
const b=Math.sin(G.time*3+r.x)*3;
ctx.save();
ctx.translate(r.x,r.y+b);
if(r.type==='heart'){
ctx.shadowColor='#ff5d73';ctx.shadowBlur=16;
ctx.scale(1,1);
ctx.fillStyle='#ff5d73';
heartPath(ctx,0,-16,22);ctx.fill();
ctx.strokeStyle='#fff';ctx.lineWidth=1.5;ctx.stroke();
}else if(r.type==='weapon'){
ctx.rotate(-Math.PI/4);
const lv=r.lv;
ctx.shadowColor=lv===1?'#8fb4ff':'#ff7a2f';ctx.shadowBlur=16;
ctx.fillStyle=lv===1?'#c8d2dc':'#ffb066';
ctx.fillRect(-2,-24,4,22);
ctx.beginPath();ctx.moveTo(-2,-24);ctx.lineTo(0,-30);ctx.lineTo(2,-24);ctx.closePath();ctx.fill();
ctx.fillStyle=lv===1?'#8a6a1f':'#b8452e';
ctx.fillRect(-6,0,12,3);
ctx.fillStyle='#4a3524';ctx.fillRect(-1.5,3,3,8);
}else if(r.type==='bow'){
ctx.shadowColor='#cfe8ff';ctx.shadowBlur=14;
ctx.strokeStyle='#8a5a34';ctx.lineWidth=4;
ctx.beginPath();ctx.arc(0,0,16,-1.1,1.1);ctx.stroke();
ctx.strokeStyle='#dde8f0';ctx.lineWidth=1.5;
ctx.beginPath();ctx.moveTo(Math.cos(-1.1)*16,Math.sin(-1.1)*16);ctx.lineTo(Math.cos(1.1)*16,Math.sin(1.1)*16);ctx.stroke();
}else if(r.type==='arrows'){
ctx.shadowColor='#cfe8ff';ctx.shadowBlur=10;
ctx.strokeStyle='#c8b080';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(-6,6);ctx.lineTo(6,-6);ctx.stroke();
ctx.fillStyle='#9fb098';
ctx.beginPath();ctx.moveTo(6,-6);ctx.lineTo(10,-10);ctx.lineTo(4,-10);ctx.closePath();ctx.fill();
}else if(r.type==='potion'){
ctx.shadowColor='#e05555';ctx.shadowBlur=10;
ctx.fillStyle='#a03040';
ctx.beginPath();ctx.arc(0,0,7,0,TAU);ctx.fill();
ctx.fillStyle='#7a4a3a';ctx.fillRect(-3,-10,6,5);
}else if(r.type==='item'){
const cols={cloak:'#7fb2d6',boots:'#d6a85f',charm:'#e35fb1',sigil:'#6fc3d6'};
ctx.shadowColor=cols[r.id2]||'#fff';ctx.shadowBlur=12;
ctx.fillStyle=cols[r.id2]||'#fff';
ctx.beginPath();ctx.moveTo(0,-8);ctx.lineTo(7,0);ctx.lineTo(0,8);ctx.lineTo(-7,0);ctx.closePath();ctx.fill();
ctx.strokeStyle='#fff';ctx.lineWidth=1;ctx.stroke();
}else{ /* gold chest */
ctx.shadowColor='#ffd97a';ctx.shadowBlur=10;
ctx.fillStyle='#8a6a1f';
ctx.fillRect(-8,-6,16,12);
ctx.fillStyle='#d4a63f';
ctx.fillRect(-8,-6,16,4);
ctx.fillStyle='#ffe9a8';
ctx.fillRect(-2,-2,4,4);
}
ctx.restore();
/* pedestal ring */
if(isTop){
ctx.strokeStyle='rgba(255,217,122,.35)';
ctx.lineWidth=2;
ctx.beginPath();ctx.ellipse(r.x,r.y+12,14+Math.sin(G.time*2)*2,6,0,0,TAU);ctx.stroke();
}
}
}
function renderBolts(){
for(const b of G.bolts){
ctx.save();
ctx.shadowColor=b.col;ctx.shadowBlur=10;
ctx.fillStyle=b.col;
ctx.beginPath();ctx.arc(b.x,b.y,b.fire?6:4,0,TAU);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.7)';
ctx.beginPath();ctx.arc(b.x,b.y,(b.fire?3:2),0,TAU);ctx.fill();
ctx.restore();
}
}
function renderArrows(){
for(const a of G.arrows){
const ang=Math.atan2(a.vy,a.vx);
ctx.save();ctx.translate(a.x,a.y);ctx.rotate(ang);
ctx.strokeStyle='#c8b080';ctx.lineWidth=2.5;
ctx.beginPath();ctx.moveTo(-10,0);ctx.lineTo(6,0);ctx.stroke();
ctx.fillStyle='#e8eef4';
ctx.beginPath();ctx.moveTo(10,0);ctx.lineTo(4,-3);ctx.lineTo(4,3);ctx.closePath();ctx.fill();
ctx.restore();
}
}
function renderParticles(){
for(const pt of G.parts){
const k=1-pt.t/pt.life;
ctx.globalAlpha=k;
ctx.fillStyle=pt.col;
const s=pt.size*(pt.shrink?k:1)+.5;
ctx.fillRect(pt.x-s/2,pt.y-s/2,s,s);
}
ctx.globalAlpha=1;
}
function renderFloats(){
ctx.textAlign='center';
for(const f of G.floats){
ctx.globalAlpha=clamp(1.2-f.t,0,1);
ctx.font='bold '+f.size+'px Georgia';
ctx.fillStyle='#000';
ctx.fillText(f.txt,f.x+1,f.y+1);
ctx.fillStyle=f.col;
ctx.fillText(f.txt,f.x,f.y);
}
ctx.globalAlpha=1;
ctx.textAlign='left';
}
function renderLighting(){
const p=P();
const lit=clamp(150+p.maxhp*8+p.weapon*30,0,240);
const g=ctx.createRadialGradient(p.x,p.y,lit*.5,p.x,p.y,lit);
g.addColorStop(0,'rgba(5,10,8,0)');
g.addColorStop(.72,'rgba(5,10,8,.25)');
g.addColorStop(1,'rgba(5,10,8,.92)');
ctx.fillStyle=g;
ctx.fillRect(camX-60,camY-60,cv.width+120,cv.height+120);
/* torch pools */
for(const t of G.torches){
const fl=.85+Math.sin(G.time*7+t.ph)*.1+rand(-.03,.03);
const r=95*fl;
const tg=ctx.createRadialGradient(t.x,t.y-14,4,t.x,t.y-14,r);
tg.addColorStop(0,'rgba(255,170,80,.28)');
tg.addColorStop(1,'rgba(255,170,80,0)');
ctx.fillStyle=tg;
ctx.fillRect(t.x-r,t.y-14-r,r*2,r*2);
/* flame */
ctx.fillStyle='#5a4030';
ctx.fillRect(t.x-3,t.y-18,6,18);
ctx.fillStyle='#ff9b3d';
ctx.beginPath();ctx.ellipse(t.x,t.y-22,4*fl,7*fl,0,0,TAU);ctx.fill();
ctx.fillStyle='#ffe9a8';
ctx.beginPath();ctx.ellipse(t.x,t.y-20,2*fl,4*fl,0,0,TAU);ctx.fill();
}
/* crystals */
for(const c2 of G.crystals){
const r=70;
const cg=ctx.createRadialGradient(c2.x,c2.y,3,c2.x,c2.y,r);
cg.addColorStop(0,c2.c+'44');cg.addColorStop(1,c2.c+'00');
ctx.fillStyle=cg;
ctx.fillRect(c2.x-r,c2.y-r,r*2,r*2);
ctx.fillStyle=c2.c;
ctx.save();ctx.translate(c2.x,c2.y);
ctx.beginPath();ctx.moveTo(0,-10);ctx.lineTo(5,4);ctx.lineTo(-5,4);ctx.closePath();ctx.fill();
ctx.restore();
}
}
/* ================= main loop ================= */
let last=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000;last=now;
dt=Math.min(dt,.05);
G.time+=dt;
if(G.screen==='play'){
G.playTime+=dt;
updatePlayer(dt);
updateEnemies(dt);
updateBolts(dt);
updateArrows(dt);
updateDrops(dt);
updateParts(dt);
updateBossState(dt);
updateRelicPickup();
merchantTouch();
G.interactHint='';
if(just.interact){
/* levers */
for(const l of G.levers){
if(!l.done&&dist(P().x,P().y,l.x,l.y)<40){leverPull(l);just.interact=false;break;}
}
if(just.interact){
for(const s of G.saveStones){
if(dist(P().x,P().y,s.x,s.y)<46){saveStoneTouch(s);just.interact=false;break;}
}
}
}
for(const s of G.saveStones){
if(dist(P().x,P().y,s.x,s.y)<46)G.interactHint='Rest at save stone (F)';
}
for(const l of G.levers){
if(!l.done&&dist(P().x,P().y,l.x,l.y)<40){G.interactHint='Pull the lever (F)';break;}
}
if(dist(P().x,P().y,775,505)<58)G.interactHint='Trade with Mirella (F)';
updateZone(dt);
updateCam(dt);
Music.update(dt,(G.boss.state==='phase1'||G.boss.state==='w2')?'boss':({village:'village',forest:'forest',north:'north',cave:'cave',east:'north',fort:'fort'})[G.zone]||'forest');
}else{
G.titleT+=dt;
updateParts(dt);
}
/* hints */
if(G.interactHint){
els.hint.style.display='block';
els.hint.textContent=G.interactHint;
}else els.hint.style.display='none';
render();
if(G.screen!=='title')drawHUD();
clearJust();
}
requestAnimationFrame(frame);
/* ================= flow ================= */
function startNew(){
try{localStorage.removeItem(SKEY);}catch(e){}
G=freshState();
buildWorld();buildRelics();
spawnAmbient();
lastZone='village';
P().x=520;P().y=560;
G.savedIdx=0;G.saveLoc={x:520,y:560};
G.screen='play';
els.titleUI.hidden=true;els.deadUI.hidden=true;els.endUI.hidden=true;
cv.classList.add('play');
camX=P().x-cv.width/2;camY=P().y-cv.height/2;
updateObjective();
toast('Find the lever east of the village...','gold');
SFX.ui();
writeSave();
}
function continueGame(){
const s=readSave();if(!s)return;
G=freshState();
buildWorld();buildRelics();
spawnAmbient();
lastZone=zoneOf(s.saveLoc?s.saveLoc.x:520,s.saveLoc?s.saveLoc.y:560);
const p=P();
p.hp=s.hp;p.maxhp=s.maxhp;G.hearts=s.hearts;G.heartIdx=s.heartIdx;
G.gold=s.gold;G.lever=s.lever||{};
G.kills=s.kills;G.gems=s.gems;
p.weapon=s.weapon;p.arrows=s.arrows||0;G.hasBow=s.hasBow;
G.owned=s.owned||{};G.potionCount=s.potionCount||0;
G.savedIdx=s.savedIdx||0;G.saveLoc=s.saveLoc||{x:520,y:560};
p.x=G.saveLoc.x;p.y=G.saveLoc.y;
for(const g of G.gates){if(G.lever[g.id])g.open=true;}
for(const l of G.levers){if(G.lever[l.id])l.done=true;}
G.relics=s.relics||{};G.zonesDone=s.zones||{};
if(s.boss==='rise'){G.boss.state='rise';G.boss.atkT=1.2;}
else if(s.boss==='w1'){spawnMalgrim();G.boss.state='phase1';G.boss.atkT=2.5;}
else if(s.boss==='w2'){G.boss.state='w2';G.boss.atkT=2;}
if(s.boss==='dead'){G.boss.state='dead';G.boss.dead=true;}
G.playTime=s.time||0;
G.screen='play';
els.titleUI.hidden=true;
cv.classList.add('play');
camX=p.x-cv.width/2;camY=p.y-cv.height/2;
updateObjective();
toast('Welcome back, wanderer','gold');
SFX.save();
}
function togglePause(on){
if(G.screen!=='play')return;
if(on){G.screen='pause';els.pauseUI.hidden=false;cv.classList.remove('play');}
else{G.screen='play';els.pauseUI.hidden=true;cv.classList.add('play');}
SFX.ui();
}
function toTitle(){
G=freshState();
buildWorld();buildRelics();
G.screen='title';
els.deadUI.hidden=true;els.endUI.hidden=true;els.pauseUI.hidden=true;els.shopUI.hidden=true;els.invUI.hidden=true;
els.titleUI.hidden=false;
cv.classList.remove('play');
checkContinue();
}
function checkContinue(){
els.btnCont.hidden=!readSave();
}
els.btnNew.onclick=()=>{AU.init();AU.resume();startNew();};
els.btnCont.onclick=()=>{AU.init();AU.resume();continueGame();};
els.btnResume.onclick=()=>togglePause(false);
els.btnMute.onclick=()=>{AU.muted=!AU.muted;toast(AU.muted?'Sound off':'Sound on','gold');};
els.btnRise.onclick=()=>riseAtSave();
els.btnDTitle.onclick=()=>toTitle();
els.btnKeep.onclick=()=>closeEnd();
els.btnETitle.onclick=()=>toTitle();
/* title ambience */
buildWorld();buildRelics();
checkContinue();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"a1644507-673b-44f9-b65b-f57229c3a0b5","tokensIn":417471,"tokensOut":27585,"tokensTotal":445056,"cost":0,"turns":10,"toolCalls":16,"failedToolCalls":0,"timestamp":"2026-08-17T17:54:26.041Z"} -->