RPG
Qwen 3.8 max · 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 95,959 bytes · SHA-256 90e2d57ef59e
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ember Vale — Action-Adventure RPG</title>
<link rel="icon" href="data:,">
<style>
:root{--gold:#d8b34a;--ink:#e8e2d0;--dim:#9a927c}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#07090d;color:var(--ink);
font-family:'Segoe UI',system-ui,-apple-system,sans-serif;user-select:none;-webkit-user-select:none}
canvas#game{position:fixed;inset:0;display:block}
.hidden{display:none!important}
.overlay{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:rgba(5,7,12,.82);backdrop-filter:blur(3px);z-index:40;text-align:center}
.panel{position:relative;background:linear-gradient(180deg,#1b1e2a,#101219);border:2px solid var(--gold);
border-radius:10px;box-shadow:0 12px 44px #000c,inset 0 0 60px #0006;padding:24px 30px;max-width:min(720px,94vw)}
h1.logo{font-family:Georgia,'Times New Roman',serif;font-size:58px;letter-spacing:12px;color:var(--gold);
text-shadow:0 0 26px #d8b34a55,0 4px 0 #000;margin-bottom:2px}
.sub{color:#b9b2a0;letter-spacing:4px;font-size:12px;margin-bottom:20px;text-transform:uppercase}
.lore{font-size:13px;color:#a89f8a;max-width:460px;margin:0 auto 18px;line-height:1.55;font-style:italic}
.btn{display:inline-block;margin:6px 8px;padding:10px 26px;font-size:15px;letter-spacing:2px;color:var(--ink);
background:linear-gradient(#2a2e3e,#191c26);border:1px solid var(--gold);border-radius:6px;cursor:pointer;
transition:.15s;font-family:inherit}
.btn:hover{background:linear-gradient(#3a3f55,#232738);box-shadow:0 0 16px #d8b34a44;transform:translateY(-1px)}
.btn:disabled{opacity:.35;cursor:default;transform:none;box-shadow:none}
.controls{display:grid;grid-template-columns:auto auto;gap:5px 20px;margin:16px auto 4px;width:fit-content;
font-size:12.5px;color:#cfc8b6;text-align:left}
kbd{background:#262a38;border:1px solid #4a4f63;border-bottom-width:2px;border-radius:4px;
padding:1px 7px;font-family:inherit;font-size:11px;color:#ffe9b0}
#mini{position:fixed;top:14px;right:14px;width:160px;height:102px;border:2px solid #d8b34a55;border-radius:6px;
background:rgba(8,10,16,.75);z-index:20;pointer-events:none}
#quest{position:fixed;top:128px;right:14px;width:236px;background:rgba(10,12,18,.78);
border:1px solid #d8b34a44;border-radius:6px;padding:9px 13px;z-index:20;pointer-events:none}
#questTitle{font-size:10px;letter-spacing:3px;color:var(--gold);margin-bottom:3px}
#questText{font-size:12.5px;line-height:1.4;color:#e2dcc8}
#toast{position:fixed;left:50%;bottom:24%;transform:translateX(-50%);z-index:30;pointer-events:none;
display:flex;flex-direction:column;align-items:center;gap:6px}
.toastMsg{background:rgba(12,14,20,.9);border:1px solid var(--gold);color:#ffe9b0;padding:8px 20px;
border-radius:6px;font-size:14px;letter-spacing:.5px;white-space:nowrap;animation:toastIn 3.4s forwards}
@keyframes toastIn{0%{opacity:0;transform:translateY(14px)}8%{opacity:1;transform:none}78%{opacity:1}100%{opacity:0;transform:translateY(-10px)}}
#dlg{position:fixed;left:50%;bottom:5%;transform:translateX(-50%);width:min(700px,92vw);
background:linear-gradient(#181b25f2,#0e1016f2);border:2px solid var(--gold);border-radius:10px;
padding:14px 22px 18px;z-index:35;text-align:left;box-shadow:0 8px 30px #000a}
#dlgName{font-family:Georgia,serif;color:var(--gold);letter-spacing:2px;font-size:14px;margin-bottom:6px}
#dlgText{font-size:15px;line-height:1.55;min-height:46px}
#dlgHint{position:absolute;right:16px;bottom:8px;font-size:11px;color:var(--dim);animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.3}}
#hurtFx{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;transition:opacity .35s;
background:radial-gradient(ellipse at center,transparent 42%,rgba(200,30,30,.75) 130%)}
#inv .panel,#shopPanel .panel{width:min(560px,94vw)}
.panelTitle{font-family:Georgia,serif;color:var(--gold);letter-spacing:3px;font-size:20px;margin-bottom:4px}
.panelSub{font-size:12px;color:var(--dim);margin-bottom:12px}
.resLine{font-size:13px;color:#ffe9b0;margin-bottom:10px}
.slotGrid{display:grid;grid-template-columns:repeat(6,66px);gap:8px;justify-content:center;margin:6px 0 12px}
.slot{width:66px;height:66px;background:#0d0f16;border:1px solid #3c4054;border-radius:8px;display:flex;
flex-direction:column;align-items:center;justify-content:center;cursor:pointer;position:relative;font-size:27px;transition:.12s}
.slot:hover{border-color:var(--gold);background:#141726}
.slot.empty{cursor:default;opacity:.45}
.slot .cnt{position:absolute;bottom:2px;right:6px;font-size:11px;color:#ffe9b0;text-shadow:0 1px 2px #000}
.slot .eq{position:absolute;top:2px;left:5px;font-size:9px;color:#7cfc90;letter-spacing:1px}
.slot.wsel{border-color:#7cb8ff;box-shadow:0 0 8px #7cb8ff44}
#itemInfo{min-height:42px;font-size:13px;color:#cfc8b6;text-align:center;line-height:1.45}
.footHint{font-size:11px;color:var(--dim);margin-top:10px}
.wareRow{display:flex;align-items:center;gap:12px;padding:9px 10px;border-bottom:1px solid #ffffff12;font-size:14px;text-align:left}
.wareRow .ico{font-size:23px;width:32px;text-align:center}
.wareRow .nm{flex:1}.wareRow small{display:block;color:var(--dim);font-size:11px;margin-top:1px}
.buyBtn{padding:5px 14px;font-size:12px;background:#232738;border:1px solid var(--gold);color:#ffe9b0;
border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:1px}
.buyBtn:hover{background:#333952}.buyBtn:disabled{opacity:.35;cursor:default}
.bigWord{font-family:Georgia,serif;font-size:44px;letter-spacing:8px;color:var(--gold);margin-bottom:10px;text-shadow:0 0 24px #d8b34a44}
.deathWord{color:#e5484d;text-shadow:0 0 24px #e5484d55}
#winStats{font-size:14px;line-height:1.9;color:#cfc8b6;margin:8px 0 14px}
.credit{position:absolute;bottom:10px;right:14px;font-size:10px;color:#565b6b;letter-spacing:1px}
</style>
</head>
<body>
<canvas id="game"></canvas>
<canvas id="mini" width="160" height="102"></canvas>
<div id="hurtFx"></div>
<div id="quest"><div id="questTitle">OBJECTIVE</div><div id="questText">…</div></div>
<div id="toast"></div>
<div id="dlg" class="hidden"><div id="dlgName"></div><div id="dlgText"></div><div id="dlgHint">E ▸ continue</div></div>
<div id="title" class="overlay">
<div class="panel">
<h1 class="logo">EMBER VALE</h1>
<div class="sub">A short action-adventure</div>
<div class="lore">The Ash King has woken on the northern peak. Slimes choke the fields, the old ruins
have sealed themselves, and the village keeps its last hope by the shrine. Take up the rusty blade, traveler.</div>
<div><button class="btn" id="btnNew">New Journey</button><button class="btn" id="btnCont" disabled>Continue</button></div>
<div class="controls">
<span><kbd>W A S D</kbd> / <kbd>←↑↓→</kbd></span><span>move</span>
<span><kbd>J</kbd> / <kbd>Z</kbd> / <kbd>Space</kbd></span><span>attack · shoot</span>
<span><kbd>K</kbd> / <kbd>Shift</kbd></span><span>dodge roll (i-frames)</span>
<span><kbd>E</kbd></span><span>interact / talk</span>
<span><kbd>Q</kbd></span><span>swap weapon</span>
<span><kbd>1</kbd> potion <kbd>2</kbd> bomb</span><span>quick use</span>
<span><kbd>I</kbd> / <kbd>Tab</kbd></span><span>inventory</span>
<span><kbd>M</kbd></span><span>music on/off</span>
</div>
<div class="footHint">Headphones recommended · progress is kept at shrines</div>
<div class="credit">EMBER VALE v1.0</div>
</div>
</div>
<div id="inv" class="overlay hidden"><div class="panel">
<div class="panelTitle">SATCHEL</div>
<div class="panelSub">Click consumables to use them · click accessories to equip (max 2)</div>
<div class="resLine" id="invRes"></div>
<div class="slotGrid" id="invGrid"></div>
<div id="itemInfo">Hover an item.</div>
<div class="footHint"><kbd>Q</kbd> swap melee/bow in the field · <kbd>Esc</kbd> close</div>
</div></div>
<div id="shopPanel" class="overlay hidden"><div class="panel">
<div class="panelTitle">BRAM'S WARES</div>
<div class="panelSub">"Everything a hero needs, provided the hero has gold."</div>
<div class="resLine" id="shopGold"></div>
<div id="shopList"></div>
<div class="footHint"><kbd>Esc</kbd> leave the stall</div>
</div></div>
<div id="pauseP" class="overlay hidden"><div class="panel">
<div class="panelTitle">PAUSED</div>
<div class="panelSub">The vale holds its breath.</div>
<div><button class="btn" id="btnResume">Resume</button><button class="btn" id="btnQuit">Title (progress kept at last shrine)</button></div>
</div></div>
<div id="death" class="overlay hidden"><div class="panel">
<div class="bigWord deathWord">YOU FELL</div>
<div class="panelSub">The vale mourns — but the shrine remembers.<br>You lose a fifth of your gold.</div>
<div><button class="btn" id="btnRise">Rise at the shrine</button></div>
</div></div>
<div id="win" class="overlay hidden"><div class="panel">
<div class="bigWord">THE ASH KING FALLS</div>
<div class="panelSub">Ember Vale breathes again. The embers cool into quiet stars.</div>
<div id="winStats"></div>
<div><button class="btn" id="btnWinClose">Keep wandering</button></div>
</div></div>
<script>
"use strict";
/* ================================================================
EMBER VALE — a one-file action-adventure RPG
================================================================ */
const TS=32, MW=100, MH=64;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(ax,ay,bx,by)=>Math.hypot(bx-ax,by-ay);
function 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}
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function hash2(x,y){let h=(x*374761393+y*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);return((h^(h>>>16))>>>0)/4294967296}
const rnd=(a,b)=>a+Math.random()*(b-a);
const irnd=(a,b)=>Math.floor(rnd(a,b+1));
/* ---------------- canvas ---------------- */
const cv=document.getElementById('game'),ctx=cv.getContext('2d');
const mini=document.getElementById('mini'),mctx=mini.getContext('2d');
let VW=0,VH=0,DPR=1;
function resize(){DPR=Math.min(window.devicePixelRatio||1,2);VW=window.innerWidth;VH=window.innerHeight;
cv.width=VW*DPR;cv.height=VH*DPR;cv.style.width=VW+'px';cv.style.height=VH+'px';ctx.setTransform(DPR,0,0,DPR,0,0)}
window.addEventListener('resize',resize);resize();
/* ---------------- input ---------------- */
const keys={},pressed={};
window.addEventListener('keydown',e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Tab'].includes(e.code))e.preventDefault();
if(!keys[e.code])pressed[e.code]=true;
keys[e.code]=true;
handleKey(e.code);
});
window.addEventListener('keyup',e=>{keys[e.code]=false});
window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false});
/* ---------------- audio ---------------- */
const AU={
ctx:null,master:null,mus:null,sfxG:null,del:null,noiseBuf:null,musicOn:true,musT:0,mstep:0,
ensure(){
if(this.ctx)return true;
const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return false;
const c=this.ctx=new AC();
this.master=c.createGain();this.master.gain.value=.55;this.master.connect(c.destination);
this.mus=c.createGain();this.mus.gain.value=.3;this.mus.connect(this.master);
this.sfxG=c.createGain();this.sfxG.gain.value=.9;this.sfxG.connect(this.master);
this.del=c.createDelay(1);this.del.delayTime.value=.29;
const fb=c.createGain();fb.gain.value=.32;this.del.connect(fb);fb.connect(this.del);this.del.connect(this.mus);
const b=c.createBuffer(1,c.sampleRate,c.sampleRate),d=b.getChannelData(0);
for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1;this.noiseBuf=b;
this.musT=c.currentTime+.2;
setInterval(()=>this.musicTick(),110);
return true;
},
tone(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.at||0);
const osc=c.createOscillator();osc.type=o.type||'sine';osc.frequency.setValueAtTime(o.f0,t);
if(o.f1)osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1),t+(o.dur||.2));
const g=c.createGain();g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(o.vol||.2,t+(o.a||.006));
g.gain.exponentialRampToValueAtTime(.0001,t+(o.dur||.2));
osc.connect(g);g.connect(o.music?this.mus:this.sfxG);
if(o.echo)g.connect(this.del);
osc.start(t);osc.stop(t+(o.dur||.2)+.1);
},
noise(o){if(!this.ctx)return;const c=this.ctx,t=c.currentTime+(o.at||0);
const s=c.createBufferSource();s.buffer=this.noiseBuf;s.loop=true;
const f=c.createBiquadFilter();f.type=o.ft||'bandpass';f.frequency.setValueAtTime(o.f0||800,t);
if(o.f1)f.frequency.exponentialRampToValueAtTime(Math.max(20,o.f1),t+o.dur);f.Q.value=o.q||1;
const g=c.createGain();g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(o.vol||.2,t+(o.a||.005));
g.gain.exponentialRampToValueAtTime(.0001,t+o.dur);
s.connect(f);f.connect(g);g.connect(this.sfxG);s.start(t);s.stop(t+o.dur+.1);
},
foot(stone){this.noise({dur:.05,vol:.045,f0:stone?950:520,f1:stone?600:300,q:.7})},
swing(){this.noise({dur:.13,vol:.11,f0:600,f1:2400,q:2})},
swingFire(){this.noise({dur:.2,vol:.13,f0:300,f1:1900,q:1.4});this.tone({f0:200,f1:100,type:'sawtooth',dur:.18,vol:.045})},
hit(){this.tone({f0:210,f1:90,type:'square',dur:.09,vol:.15});this.noise({dur:.06,vol:.11,f0:1900,q:1})},
crit(){this.tone({f0:520,f1:170,type:'square',dur:.15,vol:.17})},
hurt(){this.tone({f0:165,f1:52,type:'sawtooth',dur:.3,vol:.2});this.noise({dur:.16,vol:.12,f0:420,f1:140})},
dodge(){this.noise({dur:.17,vol:.08,f0:400,f1:1700,q:1.2})},
coin(){this.tone({f0:988,type:'square',dur:.06,vol:.09});this.tone({f0:1319,type:'square',dur:.16,vol:.09,at:.06})},
pickup(){this.tone({f0:520,type:'triangle',dur:.08,vol:.13});this.tone({f0:784,type:'triangle',dur:.14,vol:.13,at:.07})},
heartGet(){[523,659,784,1047].forEach((f,i)=>this.tone({f0:f,type:'triangle',dur:.24,vol:.15,at:i*.09}))},
chest(){this.noise({dur:.22,vol:.07,f0:190,f1:120,ft:'lowpass'});this.tone({f0:660,type:'triangle',dur:.18,vol:.11,at:.16});this.tone({f0:880,type:'triangle',dur:.26,vol:.11,at:.28})},
buy(){this.coin();this.tone({f0:2200,type:'triangle',dur:.06,vol:.06,at:.12})},
deny(){this.tone({f0:140,f1:92,type:'square',dur:.2,vol:.11})},
bow(){this.tone({f0:330,f1:140,type:'triangle',dur:.11,vol:.13});this.noise({dur:.05,vol:.05,f0:3000})},
arrowHit(){this.noise({dur:.05,vol:.07,f0:2600,q:2})},
door(){this.noise({dur:.7,vol:.19,f0:130,f1:55,ft:'lowpass'});this.tone({f0:72,f1:44,type:'sine',dur:.7,vol:.17})},
crumble(){this.noise({dur:.5,vol:.24,f0:320,f1:70,ft:'lowpass'})},
shrine(){[392,523,659,784].forEach((f,i)=>this.tone({f0:f,type:'sine',dur:.95,vol:.09,at:i*.12,echo:1,music:1}))},
boom(){this.noise({dur:.55,vol:.28,f0:850,f1:55,ft:'lowpass'});this.tone({f0:95,f1:28,type:'sine',dur:.5,vol:.23})},
roar(){this.tone({f0:120,f1:36,type:'sawtooth',dur:.95,vol:.23});this.noise({dur:.8,vol:.14,f0:520,f1:90})},
burn(){this.noise({dur:.1,vol:.045,f0:1300,f1:380})},
heal(){this.tone({f0:560,f1:880,type:'sine',dur:.32,vol:.1})},
ui(){this.tone({f0:720,type:'triangle',dur:.05,vol:.05})},
fanfare(){[392,392,392,523,659,784].forEach((f,i)=>this.tone({f0:f,type:'triangle',dur:i>3?.55:.13,vol:.14,at:i*.13,echo:1}))},
musicTick(){
if(!this.ctx)return;
const c=this.ctx,scale=[220,261.6,293.7,329.6,392,440,523.3];
while(this.musT<c.currentTime+.55){
const at=Math.max(0,this.musT-c.currentTime),s=this.mstep;
if(this.musicOn){
if(s%16===0)this.tone({f0:s%32===0?110:82.4,type:'sine',dur:1.9,vol:.085,at,music:1});
if(s%4===0&&Math.random()<.7){const f=scale[(Math.random()*scale.length)|0];
this.tone({f0:f,type:'triangle',dur:.5,vol:.05,at,music:1,echo:1});}
}
this.musT+=.28;this.mstep++;
}
}
};
/* ---------------- game state ---------------- */
const G={
state:'title', time:0, playTime:0, kills:0, shake:0, freeze:0,
stage:0, slimes:0, respawn:{x:16.5*TS,y:52*TS},
flags:{chests:{},doors:{},torches:{},wardenDead:false,guardianDead:false,bossDead:false,
puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}},
guardianUp:false, bossUp:false, winT:0
};
const cam={x:0,y:0};
/* ---------------- items & weapons ---------------- */
const WEAPONS=[
{id:'rusty',name:'Rusty Sword',dmg:1,range:30,arc:1.9,cd:.34,blade:'#a89078',desc:'A worn blade. Better than fists.'},
{id:'steel',name:'Steel Blade',dmg:2,range:38,arc:2.05,cd:.38,blade:'#d3dcea',desc:'Keen steel. +damage, +reach.'},
{id:'flame',name:'Flame Blade',dmg:3,range:38,arc:2.2,cd:.5,blade:'#ffb347',fire:true,desc:'Slow but fierce. Sets foes alight.'}
];
const BOW={name:"Hunter's Bow",dmg:1,cd:.45};
const ITEMS={
potion:{name:'Health Potion',icon:'🧪',type:'cons',desc:'Restores 4 HP. Sip responsibly.',price:15},
bomb:{name:'Bomb',icon:'💣',type:'cons',desc:'Set it down, step away. Cracks cracked stone.',price:20},
arrows:{name:'Arrows ×10',icon:'➶',type:'ammo',desc:'A bundle of ten arrows.',price:12},
amulet:{name:'Iron Amulet',icon:'🛡️',type:'acc',desc:'Equipped: incoming damage −1 (min 1).',price:80},
boots:{name:'Swift Boots',icon:'👢',type:'acc',desc:'Equipped: +18% movement speed.',price:60},
charm:{name:'Lucky Charm',icon:'🍀',type:'acc',desc:'Equipped: +50% gold from coins, 12% crit chance.',price:0},
firering:{name:'Fire Ring',icon:'💍',type:'acc',desc:'Equipped: immune to lava. Burns you inflict last longer.',price:0},
rustykey:{name:'Rusty Key',icon:'🗝️',type:'key',desc:'Old iron. It yearns for the ruins gate.'},
};
const SHOP=['potion','arrows','bomb','amulet','boots'];
/* ---------------- player ---------------- */
const P={
x:16.5*TS,y:52*TS,r:11,ang:Math.PI/2,dirX:0,dirY:1,
hp:6,maxHp:6,containers:3,stamina:100,maxStamina:100,
gold:20,arrows:0,weaponIdx:0,weaponsOwned:[true,false,false],bowOwned:false,
inv:{},equipped:[],
moving:false,anim:0,stepT:0,fatigued:false,
iframes:0,hurtT:0,lavaT:0,
atk:{on:false,t:0,dur:0,ang:0,did:false,combo:0,cd:0},
bowCd:0, dodge:{on:false,t:0,ang:0}, dead:false, gone:false
};
function hasEq(id){return P.equipped.includes(id)}
function addItem(id,n=1){
if(id==='gold'){giveGold(n);return}
if(id==='arrows'){P.arrows+=n;toast(`+${n} Arrows`);AU.pickup();return}
if(id==='heart'){
if(P.containers<5){P.containers++;P.maxHp=P.containers*2;P.hp=P.maxHp;
toast('❤ HEART CONTAINER — Max HP increased!');AU.heartGet();burst(P.x,P.y,26,'#ff6b81',1);
if(P.containers===4)G.flags.heart1=true; if(P.containers===5)G.flags.heart2=true;}
else{P.hp=P.maxHp;toast('Warm light fills you. HP restored.');AU.heal();}
return;
}
if(id==='steelblade'){P.weaponsOwned[1]=true;P.weaponIdx=1;toast('⚔ STEEL BLADE obtained! Press Q to swap weapons.');AU.fanfare();burst(P.x,P.y,30,'#d3dcea',1);return}
if(id==='flameblade'){P.weaponsOwned[2]=true;P.weaponIdx=2;toast('🔥 FLAME BLADE obtained! It hums with heat.');AU.fanfare();burst(P.x,P.y,30,'#ff9a3c',1);return}
P.inv[id]=(P.inv[id]||0)+n;
toast(`${ITEMS[id].icon} ${ITEMS[id].name} ×${n}`);AU.pickup();
}
function giveGold(n){P.gold+=n;AU.coin();floatText(P.x,P.y-20,'+'+n+'g','#ffd76a')}
function usePotion(){
if((P.inv.potion||0)<1){toast('No potions left.');AU.deny();return}
if(P.hp>=P.maxHp){toast('Already at full health.');return}
P.inv.potion--;P.hp=Math.min(P.maxHp,P.hp+4);AU.heal();
for(let i=0;i<8;i++)parts.push({x:P.x+rnd(-8,8),y:P.y+rnd(-6,10),vx:rnd(-15,15),vy:rnd(-50,-20),life:.8,t:0,c:'#7cfc90',sz:3,grav:0});
floatText(P.x,P.y-22,'+4 HP','#7cfc90');
if(G.state==='inv')renderInv();
}
function useBomb(){
if((P.inv.bomb||0)<1){toast('No bombs. Bram sells them.');AU.deny();return}
if(bombsA.length>2){toast('Too many live bombs!');return}
P.inv.bomb--;AU.ui();
bombsA.push({x:P.x+P.dirX*14,y:P.y+P.dirY*14,t:1.15});
if(G.state==='inv')renderInv();
}
function toggleEquip(id){
if(ITEMS[id].type!=='acc')return;
const i=P.equipped.indexOf(id);
if(i>=0){P.equipped.splice(i,1);AU.ui()}
else{ if(P.equipped.length>=2){toast('Only two accessories fit.');AU.deny();return}
P.equipped.push(id);AU.pickup()}
renderInv();
}
function cycleWeapon(){
const order=[0,1,2].filter(i=>P.weaponsOwned[i]);
if(P.bowOwned)order.push(3);
if(order.length<2){AU.deny();return}
const cur=order.indexOf(P.weaponIdx);
P.weaponIdx=order[(cur+1)%order.length];
AU.ui();
toast(P.weaponIdx===3?`🏹 ${BOW.name}`:`⚔ ${WEAPONS[P.weaponIdx].name}`);
}
/* ================= MAP ================= */
const T={GRASS:0,GRASS2:1,PATH:2,WATER:3,TREE:4,ROCK:5,FENCE:6,FLOOR:7,WALL:8,LAVA:9,CAVE:10,CAVEWALL:11,CRACK:12,DOOR:13};
const SOLIDSET=new Set([T.WATER,T.TREE,T.ROCK,T.FENCE,T.WALL,T.CAVEWALL,T.CRACK,T.DOOR]);
const map=new Uint8Array(MW*MH);
const ti=(x,y)=>y*MW+x;
function tAt(tx,ty){if(tx<0||ty<0||tx>=MW||ty>=MH)return T.TREE;return map[ti(tx,ty)]}
function setT(tx,ty,t){if(tx<0||ty<0||tx>=MW||ty>=MH)return;map[ti(tx,ty)]=t}
function fillR(x0,y0,x1,y1,t){for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)setT(x,y,t)}
function frameR(x0,y0,x1,y1,t){for(let x=x0;x<=x1;x++){setT(x,y0,t);setT(x,y1,t)}for(let y=y0;y<=y1;y++){setT(x0,y,t);setT(x1,y,t)}}
/* doors */
const doors=[
{id:'ruinsGate',tiles:[[64,44],[64,45]],open:false,key:'rustykey',msg:'Locked. A rusty keyhole stares back.'},
{id:'arenaDoor',tiles:[[81,41],[82,41]],open:false},
{id:'vaultDoor',tiles:[[81,25],[82,25]],open:false},
{id:'bossGate',tiles:[[38,5],[38,6]],open:false},
];
const doorGrid={};
function doorAt(tx,ty){return doorGrid[tx+','+ty]}
function buildDoorGrid(){for(const k in doorGrid)delete doorGrid[k];
for(const d of doors)for(const [x,y] of d.tiles)doorGrid[x+','+y]=d}
function isSolid(tx,ty){
const t=tAt(tx,ty);
if(t===T.DOOR){const d=doorAt(tx,ty);return !d.open}
return SOLIDSET.has(t);
}
function openDoor(id,silent){
const d=doors.find(o=>o.id===id);if(!d||d.open)return;
d.open=true;G.flags.doors[id]=true;
if(!silent){AU.door();G.shake=Math.max(G.shake,6);toast('The way opens…')}
for(const [x,y] of d.tiles)patchTile(x,y);
}
function buildMap(){
const R=mulberry32(20240613);
map.fill(T.GRASS);
// grass variation & flowers
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
const r=R();
if(r<.09)setT(x,y,T.GRASS2);
}
// mountain (north)
fillR(24,0,94,18,T.CAVEWALL);
// cave interior carve
fillR(46,12,55,17,T.CAVE); // entrance hall
fillR(39,3,62,10,T.CAVE); // great hall
fillR(63,4,68,9,T.CAVE); // alcove
fillR(27,2,37,11,T.CAVE); // boss room
fillR(42,4,55,9,T.LAVA); // lava lake
fillR(46,6,48,6,T.CAVE); // island A (flame chest)
fillR(51,5,53,5,T.CAVE); // island B (heart chest)
setT(50,18,T.CRACK);setT(51,18,T.CRACK); // cave entrance crack
// ruins
fillR(65,21,93,57,T.FLOOR);
frameR(64,20,94,58,T.WALL);
fillR(65,25,93,25,T.WALL); // vault wall
fillR(65,41,93,41,T.WALL); // arena wall
// village
fillR(9,45,29,59,T.PATH);
frameR(8,44,30,60,T.FENCE);
fillR(12,47,16,50,T.WALL); // house 1
fillR(23,51,26,51,T.WALL); // shop counter
fillR(22,47,27,50,T.WALL); // house 2 / shop hut
setT(18,44,T.PATH);setT(19,44,T.PATH); // north gate
setT(30,52,T.PATH);setT(30,53,T.PATH); // east gate
setT(8,50,T.PATH);setT(8,51,T.PATH); // west gate
// lake
fillR(33,44,37,48,T.WATER);
// paths
fillR(18,40,19,44,T.PATH); // village → fields
fillR(32,49,60,50,T.PATH); // fields road
fillR(58,44,64,45,T.PATH); // → ruins gate
fillR(4,50,8,51,T.PATH); // → forest
fillR(15,30,16,50,T.PATH); // forest trail north
fillR(50,19,51,44,T.PATH); // cliff trail
fillR(46,44,55,44,T.PATH);
// forest trees
for(let y=6;y<=56;y++)for(let x=3;x<=29;x++){
if(tAt(x,y)!==T.GRASS&&tAt(x,y)!==T.GRASS2)continue;
if(R()<.27)setT(x,y,T.TREE);
}
// clearings in forest
const clear=(cx,cy,r)=>{for(let y=cy-r;y<=cy+r;y++)for(let x=cx-r;x<=cx+r;x++){
if(dist(x,y,cx,cy)<=r&&tAt(x,y)===T.TREE)setT(x,y,T.GRASS)}};
clear(16,30,5);clear(16,44,3);clear(25,33,2);clear(6,12,3);clear(11,19,2);clear(22,24,2);
// grove (hidden, NW): walls of trees + crack entrance
fillR(3,8,9,16,T.GRASS);
for(let y=8;y<=16;y++)setT(10,y,T.TREE);
setT(10,11,T.CRACK);setT(10,12,T.CRACK);
fillR(3,8,9,8,T.TREE);fillR(3,16,9,16,T.TREE);
for(let x=3;x<=9;x++){if(tAt(x,8)===T.GRASS)setT(x,8,T.TREE);if(tAt(x,16)===T.GRASS)setT(x,16,T.TREE)}
// scatter rocks in fields & forest edges
for(let y=20;y<=60;y++)for(let x=31;x<=63;x++){
if(tAt(x,y)===T.GRASS&&R()<.018)setT(x,y,T.ROCK);
}
for(let x=24;x<=94;x++)if(R()<.35&&tAt(x,19)===T.GRASS)setT(x,19,T.ROCK);
// doors on map
for(const d of doors)for(const [x,y] of d.tiles)setT(x,y,T.DOOR);
// world border
fillR(0,0,MW-1,1,T.TREE);fillR(0,MH-2,MW-1,MH-1,T.TREE);
fillR(0,0,1,MH-1,T.TREE);fillR(MW-2,0,MW-1,MH-1,T.TREE);
buildDoorGrid();
}
/* ---------------- base terrain pre-render ---------------- */
let base=null;
function drawTileBase(g,tx,ty){
const t=tAt(tx,ty),x=tx*TS,y=ty*TS,h=hash2(tx,ty);
const px=(fx,fy,w,hh,c)=>{g.fillStyle=c;g.fillRect(x+fx,y+fy,w,hh)};
switch(t){
case T.GRASS:case T.GRASS2:
px(0,0,TS,TS,'#3d7a46');
for(let i=0;i<4;i++){const hx=hash2(tx*7+i,ty*3),hy=hash2(tx,ty*11+i);
px(hx*28,hy*28,3,3,hx>.5?'#35703e':'#468a50')}
if(t===T.GRASS2){px(6+h*16,6+hash2(ty,tx)*16,3,3,h>.5?'#e7b7d3':'#f0e28a');
px(4+h*18,18,2,2,'#2f6338')}
break;
case T.PATH:
px(0,0,TS,TS,'#b09a6a');
for(let i=0;i<3;i++){const hx=hash2(tx*5+i,ty*7),hy=hash2(tx*3,ty*5+i);
px(hx*26,hy*26,4,3,'#a08a5c')}
px(h*24,h*24|0,3,2,'#c0ab7c');
break;
case T.WATER: px(0,0,TS,TS,'#39649e');break;
case T.TREE:
px(0,0,TS,TS,'#35703e');
px(12,14,8,14,'#5d4028');px(13,14,3,14,'#6f4e31');
break;
case T.ROCK:
px(0,0,TS,TS,'#3d7a46');
g.fillStyle='#7e8591';g.beginPath();g.ellipse(x+16,y+18,13,11,0,0,7);g.fill();
g.fillStyle='#99a0ac';g.beginPath();g.ellipse(x+13,y+14,7,5,0,0,7);g.fill();
break;
case T.FENCE:
px(0,0,TS,TS,'#3d7a46');
px(4,12,24,4,'#7a5a36');px(4,22,24,4,'#7a5a36');
px(2,8,5,20,'#8a683f');px(25,8,5,20,'#8a683f');
break;
case T.FLOOR:
px(0,0,TS,TS,'#8f8f98');
g.strokeStyle='#77777f';g.strokeRect(x+.5,y+.5,TS-1,TS-1);
if(h>.7)px(8,8,4,3,'#83838c');
break;
case T.WALL:
px(0,0,TS,TS,'#5c5f6d');
g.strokeStyle='#484b56';g.lineWidth=1;
g.strokeRect(x+.5,y+.5,TS-1,TS-1);
g.beginPath();g.moveTo(x,y+16);g.lineTo(x+TS,y+16);g.stroke();
g.beginPath();g.moveTo(x+(h>.5?10:22),y);g.lineTo(x+(h>.5?10:22),y+16);
g.moveTo(x+(h>.5?22:10),y+16);g.lineTo(x+(h>.5?22:10),y+TS);g.stroke();
px(2,2,4,3,'#6d7080');
break;
case T.LAVA: px(0,0,TS,TS,'#7a2010');break;
case T.CAVE:
px(0,0,TS,TS,'#4a4550');
for(let i=0;i<3;i++){const hx=hash2(tx*3+i,ty*9),hy=hash2(tx*9,ty*3+i);
px(hx*27,hy*27,3,3,'#423d48')}
break;
case T.CAVEWALL:
px(0,0,TS,TS,'#2e3240');
g.strokeStyle='#232633';g.strokeRect(x+.5,y+.5,TS-1,TS-1);
px(4+h*16,4+hash2(ty,tx)*16,6,4,'#383d4e');
break;
case T.CRACK: break; // painted after the switch
case T.DOOR:
// floor under door
px(0,0,TS,TS,ty<19?'#4a4550':'#8f8f98');
break;
}
if(t===T.CRACK){
const inCave=ty<19,inRuins=tx>=63;
const baseC=inCave?'#2e3240':inRuins?'#5c5f6d':'#556b52';
g.fillStyle=baseC;g.fillRect(x,y,TS,TS);
g.strokeStyle='rgba(10,10,14,.85)';g.lineWidth=2;
g.beginPath();
g.moveTo(x+6,y+2);g.lineTo(x+14,y+12);g.lineTo(x+9,y+22);g.lineTo(x+16,y+30);
g.moveTo(x+24,y+4);g.lineTo(x+18,y+14);g.lineTo(x+26,y+26);
g.stroke();
}
}
function buildBase(){
base=document.createElement('canvas');base.width=MW*TS;base.height=MH*TS;
const g=base.getContext('2d');
for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++)drawTileBase(g,tx,ty);
}
function patchTile(tx,ty){
if(!base)return;
const g=base.getContext('2d');
g.save();g.beginPath();g.rect(tx*TS,ty*TS,TS,TS);g.clip();drawTileBase(g,tx,ty);g.restore();
}
/* ================= WORLD ENTITIES ================= */
const chests=[
{id:'c_field', tx:59,ty:43,items:[['potion',1],['gold',8]]},
{id:'c_forest',tx:25,ty:33,items:[['gold',15],['arrows',6]]},
{id:'c_grove', tx:6, ty:12,items:[['heart',1],['charm',1]]},
{id:'c_blade', tx:68,ty:54,items:[['steelblade',1]]},
{id:'c_ring', tx:66,ty:6, items:[['firering',1]]},
{id:'c_heart2',tx:52,ty:5, items:[['heart',1]]},
{id:'c_flame', tx:47,ty:6, items:[['flameblade',1]]},
];
for(const c of chests){c.x=c.tx*TS+16;c.y=c.ty*TS+16;c.opened=false}
const shrines=[
{id:'s_village',x:28.5*TS,y:58.5*TS,name:"Village Shrine"},
{id:'s_ruins', x:66.5*TS,y:47.5*TS,name:"Ruins Shrine"},
{id:'s_cave', x:47.5*TS,y:15.5*TS,name:"Cave Shrine"},
];
const pedestal={x:82.5*TS,y:22.5*TS,taken:false};
const tablets=[
{x:18.5*TS,y:46.5*TS,text:"«EMBER VALE — fields east, woods west. Mind the mountain.»"},
{x:72.5*TS,y:44.5*TS,text:"«WEIGHT UNLOCKS THE WAY. Set wandering stones upon the three sigils.»"},
{x:49.5*TS,y:13.5*TS,text:"«Four cold braziers drink the light of arrows. Wake them, and the gate yields.»"},
];
const npcs=[
{id:'elder',name:'Elder Rowan',x:15.5*TS,y:53.5*TS,c:'#8a6bb5'},
{id:'bram', name:'Merchant Bram',x:24.5*TS,y:52.8*TS,c:'#b58a4b',shop:true},
{id:'mira', name:'Mira',x:11.5*TS,y:56.5*TS,c:'#5ba0a8'},
];
const dummy={x:26.5*TS,y:57.5*TS,hp:99,wob:0};
const platesA=[[80,47],[88,47],[84,53]].map(p=>({tx:p[0],ty:p[1],on:false}));
const blocksA=[[83,50],[86,51],[80,52]].map(b=>({tx:b[0],ty:b[1],x:b[0]*TS+16,y:b[1]*TS+16,moving:false}));
const torchesA=[[39,4],[39,9],[44,6],[53,8]].map(t=>({tx:t[0],ty:t[1],x:t[0]*TS+16,y:t[1]*TS+16,lit:false}));
/* ---------------- enemy defs & spawning ---------------- */
const EDEFS={
slime:{hp:2,spd:55,r:11,dmg:1,g:[2,4],xp:1},
bat:{hp:1,spd:88,r:8,dmg:1,g:[1,2]},
skel:{hp:3,spd:58,r:12,dmg:2,g:[3,6]},
archer:{hp:3,spd:55,r:11,dmg:1,g:[4,6]},
wisp:{hp:1,spd:115,r:7,dmg:2,g:[1,2]},
warden:{hp:16,spd:42,r:20,dmg:2,g:[20,26]},
guardian:{hp:26,spd:40,r:22,dmg:2,g:[40,48]},
boss:{hp:90,spd:62,r:24,dmg:2,g:[100,120]},
};
const SPAWNS=[
// fields
{t:'slime',x:36,y:46},{t:'slime',x:42,y:52},{t:'slime',x:48,y:44},
{t:'slime',x:54,y:50},{t:'slime',x:45,y:57},{t:'slime',x:58,y:54},
// forest
{t:'slime',x:20,y:47},{t:'slime',x:24,y:38},{t:'bat',x:12,y:36},
{t:'bat',x:18,y:26},{t:'bat',x:24,y:19},
// ruins
{t:'skel',x:70,y:50},{t:'skel',x:72,y:46},{t:'skel',x:84,y:44},
{t:'archer',x:76,y:54},{t:'archer',x:88,y:46},
// cave
{t:'skel',x:41,y:9},{t:'skel',x:60,y:9},{t:'bat',x:50,y:8},{t:'bat',x:55,y:12},
];
let enemies=[],projs=[],pickups=[],parts=[],floaters=[],bombsA=[];
function spawnEnemy(t,x,y,extra){
const d=EDEFS[t];
const e={t,x,y,r:d.r,hp:d.hp,maxHp:d.hp,spd:d.spd,dmg:d.dmg,g:d.g,
st:'idle',t0:0,ang:rnd(0,6.28),flash:0,kx:0,ky:0,burn:0,burnT:0,dead:false,
home:{x,y},wob:rnd(0,9),shootT:rnd(0,1),extra:extra||{}};
enemies.push(e);return e;
}
function respawnAll(){
enemies=[];
for(const s of SPAWNS)spawnEnemy(s.t,s.x*TS+16,s.y*TS+16);
if(!G.flags.wardenDead)spawnEnemy('warden',16*TS+16,30*TS+16);
G.guardianUp=false;G.bossUp=false;
}
/* ---------------- quests & dialogue ---------------- */
const QUESTS=[
"Speak with Elder Rowan in the village.",
"Cull the slimes in the eastern fields. (0/8)",
"Return to Elder Rowan for your reward.",
"West forest: fell the mad Grove Warden and take its key.",
"Open the Ruins Gate (east) with the Rusty Key.",
"Ruins: set stones on sigils, then face the Stone Guardian.",
"Crack open the mountain path (north, bombs) and climb inside.",
"Light the four braziers, then end the Ash King.",
"The vale is free. Breathe.",
];
function setStage(n){
if(n<G.stage)return;
G.stage=n;
const el=document.getElementById('questText');
el.textContent=QUESTS[Math.min(n,QUESTS.length-1)];
if(n===1)el.textContent=`Cull the slimes in the eastern fields. (${G.slimes}/8)`;
}
function updateSlimeQuest(){
if(G.stage===1){document.getElementById('questText').textContent=`Cull the slimes in the eastern fields. (${G.slimes}/8)`;
if(G.slimes>=8){setStage(2);toast('Quest updated — return to Elder Rowan.')}}
}
let dlg=null; // {name,lines,i,chars,done,after}
function showDialogue(name,lines,after){
dlg={name,lines,i:0,chars:0,done:false,after};
G.state='dialog';
document.getElementById('dlg').classList.remove('hidden');
document.getElementById('dlgName').textContent=name;
document.getElementById('dlgText').textContent='';
AU.ui();
}
function dlgAdvance(){
if(!dlg)return;
const line=dlg.lines[dlg.i];
if(dlg.chars<line.length){dlg.chars=line.length;renderDlg();return}
dlg.i++;
if(dlg.i>=dlg.lines.length){const a=dlg.after;dlg=null;
document.getElementById('dlg').classList.add('hidden');
G.state='play';if(a)a();}
else{dlg.chars=0;AU.ui()}
renderDlg();
}
function renderDlg(){if(dlg)document.getElementById('dlgText').textContent=dlg.lines[dlg.i].slice(0,dlg.chars)}
function dlgTick(dt){if(dlg&&!dlg.done&&dlg.chars<dlg.lines[dlg.i].length){dlg.chars+=dt*60;renderDlg()}}
function elderDialogue(){
if(G.stage===0){
showDialogue('Elder Rowan',[
"Ah. You're awake at last, traveler. The vale has waited long for a sword arm.",
"Slimes choke the eastern fields — fat green things, eight at least. Thin them out.",
"Come back when it's done. The vale pays its debts."],
()=>{setStage(1)});
}else if(G.stage===1){
showDialogue('Elder Rowan',[`Still squelching out there, lad. (${G.slimes}/8 culled)`]);
}else if(G.stage===2){
showDialogue('Elder Rowan',[
"Eight slimes, culled to jelly! The vale thanks you — take this purse.",
"Now hear me: something in the west forest calls itself the Grove Warden. It swallowed the Rusty Key to the old ruins.",
"Cut it out of him. The ruins hold weapons we'll need before this winter ends."],
()=>{giveGold(40);setStage(3)});
}else if(G.stage===3){
showDialogue('Elder Rowan',["The Warden haunts the deep west forest. Cracked stone yields to bombs, if you need a way through."]);
}else if(G.stage<=5){
showDialogue('Elder Rowan',["The ruins gate stands east of the fields. Mind the stones inside — they bite."]);
}else{
showDialogue('Elder Rowan',["The Ash King woke on the north peak. Pray at the shrines, traveler — they remember you."]);
}
}
function miraDialogue(){
const lines=[["Cracked walls crumble under bombs. Bram keeps stock, the old vulture.",
"There's a grove in the forest's northwest corner — walled in by trees. I'd bet my boots something sleeps there.",
"The ruins' floor-plates crave heavy things. Stones, I mean. Push them about."]];
showDialogue('Mira',lines[0]);
}
function npcTalk(n){
if(n.id==='elder')elderDialogue();
else if(n.id==='mira')miraDialogue();
else if(n.id==='bram'){
showDialogue('Merchant Bram',["Gold buys everything but courage. Let's see what you can afford."],()=>openShop());
}
}
/* ---------------- save / load ---------------- */
const SAVEKEY='embervale_save_v1';
function saveGame(shrine){
try{
const data={
v:1,stage:G.stage,slimes:G.slimes,kills:G.kills,playTime:G.playTime,
flags:{...G.flags,doors:{...G.flags.doors},chests:{...G.flags.chests},
torches:{...G.flags.torches},shrineIds:{...G.flags.shrineIds}},
p:{x:P.x,y:P.y,hp:P.hp,maxHp:P.maxHp,containers:P.containers,gold:P.gold,arrows:P.arrows,
weaponIdx:P.weaponIdx,weaponsOwned:[...P.weaponsOwned],bowOwned:P.bowOwned,
inv:{...P.inv},equipped:[...P.equipped]},
pedestal:pedestal.taken,respawn:{...G.respawn}
};
localStorage.setItem(SAVEKEY,JSON.stringify(data));
if(shrine)toast('✦ Progress saved — the shrine remembers.');
}catch(e){/* private mode etc. */}
}
function hasSave(){try{return !!localStorage.getItem(SAVEKEY)}catch(e){return false}}
function loadGame(){
try{
const d=JSON.parse(localStorage.getItem(SAVEKEY));if(!d)return false;
G.stage=0;G.slimes=d.slimes||0;G.kills=d.kills||0;G.playTime=d.playTime||0;
G.flags={chests:{},doors:{},torches:{},cracks:[],wardenDead:false,guardianDead:false,bossDead:false,
puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}};
Object.assign(G.flags,d.flags);
buildWorld();
// restore cracked tiles
for(const [cx,cy] of (G.flags.cracks||[])){
setT(cx,cy,cy<19?T.CAVE:(cx>=63?T.FLOOR:T.GRASS));patchTile(cx,cy);
}
const p=d.p;
P.x=p.x;P.y=p.y;P.hp=p.hp;P.maxHp=p.maxHp;P.containers=p.containers;
P.gold=p.gold;P.arrows=p.arrows;P.weaponIdx=p.weaponIdx;
P.weaponsOwned=p.weaponsOwned;P.bowOwned=p.bowOwned;
P.inv=p.inv||{};P.equipped=p.equipped||[];
G.respawn=d.respawn||{x:16.5*TS,y:52*TS};
pedestal.taken=d.pedestal;
for(const c of chests)c.opened=!!G.flags.chests[c.id];
for(const tt of torchesA)tt.lit=!!G.flags.torches[tt.tx+','+tt.ty];
for(const dd of doors)dd.open=!!G.flags.doors[dd.id];
if(G.flags.puzzle){
const pos=[[80,47],[88,47],[84,53]];
blocksA.forEach((b,i)=>{b.tx=pos[i][0];b.ty=pos[i][1];b.x=b.tx*TS+16;b.y=b.ty*TS+16;b.moving=false});
for(const p of platesA)p.on=true;
}
setStage(d.stage||0);
P.dead=false;P.gone=false;
cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
return true;
}catch(e){return false}
}
/* ================= FX HELPERS ================= */
function burst(x,y,n,c,pow=1){
for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(30,140)*pow;
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(.3,.7),t:0,c,sz:rnd(1.5,3.5),grav:120})}
}
function sparks(x,y,n,c){
for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(60,220);
parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(.12,.3),t:0,c,sz:rnd(1,2.5),grav:0})}
}
function floatText(x,y,txt,c){floaters.push({x,y,txt,c,t:0})}
function toast(msg){
const el=document.createElement('div');el.className='toastMsg';el.textContent=msg;
const t=document.getElementById('toast');t.appendChild(el);
while(t.children.length>3)t.removeChild(t.firstChild);
setTimeout(()=>el.remove(),3500);
}
function hurtFlash(){const h=document.getElementById('hurtFx');h.style.opacity=1;setTimeout(()=>h.style.opacity=0,120)}
function dropCoins(x,y,total){
let n=Math.min(10,Math.max(1,Math.round(total/2)));
for(let i=0;i<n;i++){const a=rnd(0,6.28),s=rnd(20,80);
pickups.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-40,type:'coin',val:Math.max(1,Math.round(total/n)),t:0})}
}
function dropPickup(x,y,type,val){pickups.push({x,y,vx:rnd(-30,30),vy:rnd(-60,-20),type,val,t:0})}
/* ================= COMBAT ================= */
function tryAttack(){
if(P.dodge.on||P.dead)return;
if(P.weaponIdx===3){shootArrow();return}
const w=WEAPONS[P.weaponIdx];
if(P.atk.on||P.atk.cd>0)return;
P.atk.on=true;P.atk.t=0;P.atk.dur=w.cd;P.atk.ang=P.ang;P.atk.did=false;
P.atk.combo=(P.atk.combo+1)%3;
if(w.fire)AU.swingFire();else AU.swing();
P.stamina=Math.max(0,P.stamina-3);
}
function meleeHit(){
const w=WEAPONS[P.weaponIdx];
const reach=w.range+ (hasEq('charm')?0:0);
let hitAny=false;
// enemies
for(const e of enemies){
if(e.dead)continue;
const d=dist(P.x,P.y,e.x,e.y);
if(d<reach+e.r&&Math.abs(angDiff(P.atk.ang,Math.atan2(e.y-P.y,e.x-P.x)))<w.arc/2+.3){
let dmg=w.dmg,crit=false;
if(hasEq('charm')&&Math.random()<.12){dmg*=2;crit=true}
damageEnemy(e,dmg,P.atk.ang,crit);
if(w.fire){e.burn=hasEq('firering')?6:3.6;e.burnT=0;AU.burn()}
hitAny=true;
}
}
// torches
for(const tt of torchesA){
if(tt.lit)continue;
if(dist(P.x,P.y,tt.x,tt.y)<reach+12&&Math.abs(angDiff(P.atk.ang,Math.atan2(tt.y-P.y,tt.x-P.x)))<w.arc/2+.4)
lightTorch(tt);
}
// dummy
if(dist(P.x,P.y,dummy.x,dummy.y)<reach+12){dummy.wob=1;sparks(dummy.x,dummy.y,4,'#c8b790');AU.hit();hitAny=true}
if(hitAny){G.freeze=Math.max(G.freeze,.05)}
// swing particles
const pa=P.atk.ang;
for(let i=0;i<3;i++){const a=pa+rnd(-.6,.6);
parts.push({x:P.x+Math.cos(a)*reach*.8,y:P.y+Math.sin(a)*reach*.8,vx:Math.cos(a)*60,vy:Math.sin(a)*60,
life:.18,t:0,c:w.fire?'#ffb347':'#e8e8f0',sz:2,grav:0})}
}
function shootArrow(){
if(P.bowCd>0||P.dodge.on)return;
if(P.arrows<1){toast('No arrows — buy some from Bram.');AU.deny();return}
if(P.stamina<8){toast('Too winded…');return}
P.arrows--;P.stamina-=8;P.bowCd=BOW.cd;
AU.bow();
projs.push({x:P.x+Math.cos(P.ang)*14,y:P.y+Math.sin(P.ang)*14,
vx:Math.cos(P.ang)*430,vy:Math.sin(P.ang)*430,dmg:BOW.dmg,from:'player',type:'arrow',life:1.6});
}
function damageEnemy(e,dmg,ang,crit){
e.hp-=dmg;e.flash=.15;
e.kx+=Math.cos(ang)*140;e.ky+=Math.sin(ang)*140;
sparks(e.x,e.y,6,crit?'#ffd76a':'#ffffff');
floatText(e.x,e.y-e.r-6,crit?dmg+'!':''+dmg,crit?'#ffd76a':'#ffffff');
if(crit)AU.crit();else AU.hit();
if(e.hp<=0)killEnemy(e);
else if(e.t==='dummy'){}
else if(e.st!=='lunge')e.st='chase';
}
function killEnemy(e){
if(e.dead)return;e.dead=true;G.kills++;
burst(e.x,e.y,12,e.t==='wisp'?'#ffb347':e.t==='bat'?'#8a7ba8':'#9fdc7f');
AU.pickup&&AU.noise({dur:.15,vol:.07,f0:900,f1:200});
let gold=irnd(e.g[0],e.g[1]);
if(hasEq('charm'))gold=Math.round(gold*1.5);
dropCoins(e.x,e.y,gold);
if(e.t==='bat'&&Math.random()<.5)dropPickup(e.x,e.y,'arrows',2);
if(e.t==='archer'&&Math.random()<.7)dropPickup(e.x,e.y,'arrows',3);
if(Math.random()<.08)dropPickup(e.x,e.y,'potion',1);
if(e.t==='slime'){G.slimes++;updateSlimeQuest()}
if(e.t==='warden'){
G.flags.wardenDead=true;dropPickup(e.x,e.y,'key',1);
toast('🗝️ The Warden drops a RUSTY KEY!');AU.fanfare();
burst(e.x,e.y,30,'#7cfc90',1.4);
if(G.stage<4)setStage(4);
saveGame(false);
}
if(e.t==='guardian'){
G.flags.guardianDead=true;
toast('The sealed vault grinds open somewhere north…');
openDoor('vaultDoor',true);AU.door();
burst(e.x,e.y,40,'#aab4c8',1.5);
saveGame(false);
}
if(e.t==='boss'){
G.flags.bossDead=true;
burst(e.x,e.y,60,'#ffb347',2);burst(e.x,e.y,40,'#e5484d',1.6);
AU.boom();G.shake=14;
openDoor('bossGate',true);
setStage(8);G.winT=2.2;
saveGame(false);
}
}
function hurtPlayer(d,fromAng){
if(P.iframes>0||P.dodge.on||P.dead)return;
if(hasEq('amulet'))d=Math.max(1,d-1);
P.hp-=d;P.iframes=1.0;P.hurtT=.4;
P.x+=Math.cos(fromAng)*10;P.y+=Math.sin(fromAng)*10;
AU.hurt();G.shake=Math.max(G.shake,7);hurtFlash();
floatText(P.x,P.y-24,'-'+d,'#ff6b6b');
burst(P.x,P.y,8,'#e5484d',.7);
if(P.hp<=0){P.hp=0;diePlayer()}
}
function diePlayer(){
P.dead=true;P.gone=true;
burst(P.x,P.y,30,'#e5484d',1.2);AU.roar();G.shake=10;
setTimeout(()=>{if(G.state==='play'||G.state==='dead'){G.state='dead';
document.getElementById('death').classList.remove('hidden')}},1000);
}
function respawnPlayer(){
document.getElementById('death').classList.add('hidden');
P.dead=false;P.gone=false;P.hp=P.maxHp;P.stamina=P.maxStamina;P.iframes=2;
P.gold=Math.floor(P.gold*.8);
P.x=G.respawn.x;P.y=G.respawn.y;
respawnAll();
G.state='play';
toast('You wake at the shrine. The vale waits.');
cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
}
/* ================= INTERACTION ================= */
function nearestInteract(){
let best=null,bd=52;
const consider=(x,y,obj,label)=>{const d=dist(P.x,P.y,x,y);if(d<bd){bd=d;best={x,y,obj,label}}};
for(const c of chests)if(!c.opened)consider(c.x,c.y,c,'Open chest');
for(const n of npcs)consider(n.x,n.y,n,'Talk');
for(const s of shrines)consider(s.x,s.y,s,'Pray & Save');
for(const tb of tablets)consider(tb.x,tb.y,tb,'Read');
if(!pedestal.taken&&G.flags.guardianDead)consider(pedestal.x,pedestal.y,pedestal,'Take the Bow');
for(const d of doors)if(!d.open&&d.key)for(const [tx,ty] of d.tiles)consider(tx*TS+16,ty*TS+16,d,'Unlock');
return best;
}
function tryInteract(){
const it=nearestInteract();
if(!it){ // generic poke
return;
}
const o=it.obj;
if(o.items){ // chest
o.opened=true;G.flags.chests[o.id]=true;AU.chest();
burst(o.x,o.y,14,'#ffd76a');
for(const [id,n] of o.items)addItem(id,n);
if(o.id==='c_grove')setStage(Math.max(G.stage,5));
if(o.id==='c_blade'){}
saveGame(false);
}else if(o.id&&o.tiles&&o.key){ // locked door
if((P.inv[o.key]||0)>0||o.key==='rustykey'&&P.inv.rustykey){
openDoor(o.id);G.flags.keyUsed=true;
toast('The Rusty Key turns. The gate groans open.');
setStage(5);
}else toast(o.msg||'Locked.');
}else if(o.shop){ npcTalk(o) }
else if(o.name&&o.lines===undefined&&npcs.includes(o)){ npcTalk(o) }
else if(o.text){ showDialogue('Weathered tablet',[o.text]) }
else if(o===pedestal){
pedestal.taken=true;G.flags.bowTaken=true;
P.bowOwned=true;P.arrows+=15;
AU.fanfare();burst(pedestal.x,pedestal.y,26,'#8fd3ff',1.2);
toast("🏹 HUNTER'S BOW obtained! Press Q to swap. Arrows +15.");
showDialogue('Engraved plaque',["To the guardian's slayer: the hunter's bow.","Let arrows find what the blade cannot. The mountain path north is next — bombs open what stone hides."],
()=>{setStage(6)});
}else if(shrines.includes(o)){
G.respawn={x:o.x,y:o.y};
P.hp=P.maxHp;P.stamina=P.maxStamina;AU.shrine();AU.heal();
G.flags.shrineIds[o.id]=true;
for(let i=0;i<20;i++)parts.push({x:o.x+rnd(-14,14),y:o.y+rnd(-6,10),vx:rnd(-8,8),vy:rnd(-60,-30),life:rnd(.6,1.2),t:0,c:'#8fd3ff',sz:2.5,grav:-20});
saveGame(true);
}
}
function openShop(){
G.state='shop';
document.getElementById('shopPanel').classList.remove('hidden');
renderShop();AU.ui();
}
function closeShop(){
document.getElementById('shopPanel').classList.add('hidden');
G.state='play';
}
function renderShop(){
document.getElementById('shopGold').innerHTML=`Your gold: <b style="color:#ffd76a">${P.gold}</b>`;
const list=document.getElementById('shopList');list.innerHTML='';
for(const id of SHOP){
const it=ITEMS[id];
const row=document.createElement('div');row.className='wareRow';
row.innerHTML=`<div class="ico">${it.icon}</div><div class="nm">${it.name}<small>${it.desc}</small></div>`;
const b=document.createElement('button');b.className='buyBtn';b.textContent=it.price+'g';
b.disabled=P.gold<it.price;
b.onclick=()=>{
if(P.gold<it.price){AU.deny();return}
P.gold-=it.price;addItem(id,1);AU.buy();renderShop();
};
row.appendChild(b);list.appendChild(row);
}
}
function openInv(){
G.state='inv';document.getElementById('inv').classList.remove('hidden');renderInv();AU.ui();
}
function closeInv(){document.getElementById('inv').classList.add('hidden');G.state='play'}
function renderInv(){
document.getElementById('invRes').innerHTML=
`Gold: <b style="color:#ffd76a">${P.gold}</b> · Arrows: <b>${P.arrows}</b> · HP: <b>${P.hp}/${P.maxHp}</b>`;
const grid=document.getElementById('invGrid');grid.innerHTML='';
const entries=[];
for(let i=0;i<P.weaponsOwned.length;i++)if(P.weaponsOwned[i])
entries.push({kind:'weapon',idx:i,name:WEAPONS[i].name,icon:['🗡️','⚔️','🔥'][i],desc:WEAPONS[i].desc});
if(P.bowOwned)entries.push({kind:'weapon',idx:3,name:BOW.name,icon:'🏹',desc:'Ranged. Uses arrows.'});
for(const id in P.inv)if(P.inv[id]>0&&ITEMS[id])
entries.push({kind:'item',id,name:ITEMS[id].name,icon:ITEMS[id].icon,desc:ITEMS[id].desc,cnt:P.inv[id]});
if(P.inv&&P.inv.rustykey)0;
while(entries.length<12)entries.push(null);
for(const en of entries){
const s=document.createElement('div');
if(!en){s.className='slot empty';grid.appendChild(s);continue}
s.className='slot';
if(en.kind==='weapon'&&P.weaponIdx===en.idx)s.classList.add('wsel');
s.innerHTML=`<span>${en.icon}</span>`+(en.cnt?`<span class="cnt">×${en.cnt}</span>`:'')+
(P.equipped.includes(en.id)?`<span class="eq">E</span>`:'');
s.onmouseenter=()=>{document.getElementById('itemInfo').innerHTML=`<b>${en.name}</b><br>${en.desc}`}
s.onclick=()=>{
if(en.kind==='weapon'){P.weaponIdx=en.idx;AU.ui();renderInv()}
else if(ITEMS[en.id].type==='cons'){ if(en.id==='potion')usePotion(); if(en.id==='bomb')useBomb(); }
else if(ITEMS[en.id].type==='acc')toggleEquip(en.id);
};
grid.appendChild(s);
}
}
/* ================= UPDATE ================= */
function rectFree(x,y,r,ignoreBlocks){
const x0=Math.floor((x-r)/TS),x1=Math.floor((x+r)/TS),y0=Math.floor((y-r)/TS),y1=Math.floor((y+r)/TS);
for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++)if(isSolid(tx,ty))return false;
if(!ignoreBlocks)for(const b of blocksA){
if(x+r>b.x-15&&x-r<b.x+15&&y+r>b.y-15&&y-r<b.y+15)return false}
return true;
}
function moveEnt(e,dx,dy,ignoreBlocks){
if(dx!==0&&rectFree(e.x+dx,e.y,e.r,ignoreBlocks))e.x+=dx;else if(dx!==0)e.hitWall=true;
if(dy!==0&&rectFree(e.x,e.y+dy,e.r,ignoreBlocks))e.y+=dy;else if(dy!==0)e.hitWall=true;
}
function tryPushBlock(){
// find block player is pressing into
for(const b of blocksA){
if(b.moving)continue;
const dx=P.x-b.x,dy=P.y-b.y;
if(Math.abs(dx)<30&&Math.abs(dy)<30){
let px=0,py=0;
if(keys['KeyA']||keys['ArrowLeft'])px=-1;else if(keys['KeyD']||keys['ArrowRight'])px=1;
else if(keys['KeyW']||keys['ArrowUp'])py=-1;else if(keys['KeyS']||keys['ArrowDown'])py=1;
if(px===0&&py===0)continue;
const nx=b.tx+px,ny=b.ty+py;
if(isSolid(nx,ny))continue;
if(blocksA.some(o=>o!==b&&o.tx===nx&&o.ty===ny))continue;
if(platesA.some(p=>p.tx===nx&&p.ty===ny)||true){
b.moving=true;b.tx=nx;b.ty=ny;
b.fx=b.x;b.fy=b.y;b.mx=nx*TS+16;b.my=ny*TS+16;b.mt=0;
AU.noise&&AU.noise({dur:.2,vol:.08,f0:200,f1:90,ft:'lowpass'});
}
}
}
}
function updateBlocks(dt){
for(const b of blocksA){
if(b.moving){
b.mt+=dt/.16;
const t=Math.min(1,b.mt);
b.x=lerp(b.fx,b.mx,t);b.y=lerp(b.fy,b.my,t);
if(t>=1){b.moving=false;AU.foot&&AU.noise({dur:.08,vol:.07,f0:150,f1:80,ft:'lowpass'});
G.shake=Math.max(G.shake,2);checkPlates()}
}
}
}
function checkPlates(){
for(const p of platesA){
p.on=(Math.floor(P.x/TS)===p.tx&&Math.floor(P.y/TS)===p.ty)||
blocksA.some(b=>!b.moving&&b.tx===p.tx&&b.ty===p.ty);
}
if(!G.flags.puzzle&&platesA.every(p=>p.on)){
G.flags.puzzle=true;openDoor('arenaDoor');
toast('⚙ The sigils accept the weight. The arena gate rises.');
saveGame(false);
}else if(G.flags.puzzle){
// keep door open regardless once solved
for(const p of platesA)p.on=true;
}
}
function lightTorch(tt){
if(tt.lit)return;tt.lit=true;G.flags.torches[tt.tx+','+tt.ty]=true;
AU.swingFire&&AU.noise({dur:.3,vol:.1,f0:400,f1:1400});
burst(tt.x,tt.y-10,12,'#ffb347');
const n=torchesA.filter(t=>t.lit).length;
toast(`Brazier lit (${n}/4)`);
if(n===4){openDoor('bossGate');toast('🔥 The four braziers roar — the gate gives way!')}
}
function explodeBomb(b){
AU.boom();G.shake=9;
burst(b.x,b.y,30,'#ffb347',1.4);burst(b.x,b.y,16,'#666',1);
for(const e of enemies)if(!e.dead&&dist(b.x,b.y,e.x,e.y)<70)damageEnemy(e,3,Math.atan2(e.y-b.y,e.x-b.x),false);
if(dist(b.x,b.y,P.x,P.y)<50)hurtPlayer(1,Math.atan2(P.y-b.y,P.x-b.x));
// crack walls
const cx=Math.round((b.x-16)/TS),cy=Math.round((b.y-16)/TS);
let cracked=false;
for(let dy=-2;dy<=2;dy++)for(let dx=-2;dx<=2;dx++){
const tx=cx+dx,ty=cy+dy;
if(tAt(tx,ty)===T.CRACK&&dist(b.x,b.y,tx*TS+16,ty*TS+16)<62){
setT(tx,ty,ty<19?T.CAVE:(tx>=63?T.FLOOR:T.GRASS));
patchTile(tx,ty);cracked=true;
if(!G.flags.cracks)G.flags.cracks=[];
G.flags.cracks.push([tx,ty]);
burst(tx*TS+16,ty*TS+16,10,'#8a8f9e');
}
}
if(cracked){AU.crumble();toast('The cracked stone collapses!');
if(G.stage===6)setStage(7);
}
}
/* ---------------- player update ---------------- */
function updatePlayer(dt){
if(P.dead)return;
P.iframes=Math.max(0,P.iframes-dt);
P.hurtT=Math.max(0,P.hurtT-dt);
P.atk.cd=Math.max(0,P.atk.cd-dt);
P.bowCd=Math.max(0,P.bowCd-dt);
// movement input
let ix=0,iy=0;
if(keys['KeyA']||keys['ArrowLeft'])ix-=1;
if(keys['KeyD']||keys['ArrowRight'])ix+=1;
if(keys['KeyW']||keys['ArrowUp'])iy-=1;
if(keys['KeyS']||keys['ArrowDown'])iy+=1;
const mv=(ix!==0||iy!==0);
if(mv){const l=Math.hypot(ix,iy);ix/=l;iy/=l;P.ang=Math.atan2(iy,ix);P.dirX=ix;P.dirY=iy}
P.moving=mv&&!P.atk.on;
// stamina
const regen=P.moving?16:26;
P.stamina=Math.min(P.maxStamina,P.stamina+regen*dt);
P.fatigued=P.stamina<22;
// dodge
if(P.dodge.on){
P.dodge.t-=dt;
const sp=330;
moveEnt(P,Math.cos(P.dodge.ang)*sp*dt,Math.sin(P.dodge.ang)*sp*dt,true);
if(Math.random()<.5)parts.push({x:P.x,y:P.y+6,vx:rnd(-10,10),vy:rnd(-10,10),life:.3,t:0,c:'#cfd8e6',sz:2,grav:0});
if(P.dodge.t<=0)P.dodge.on=false;
}else if(!P.atk.on||true){
let sp=150*(hasEq('boots')?1.18:1)*(P.fatigued?.85:1)*(P.atk.on?.45:1);
if(mv){
moveEnt(P,ix*sp*dt,iy*sp*dt,false);
tryPushBlock();
P.anim+=dt*(sp/16);
P.stepT-=dt;
if(P.stepT<=0){
const onStone=tAt(Math.floor(P.x/TS),Math.floor(P.y/TS))===T.FLOOR||tAt(Math.floor(P.x/TS),Math.floor(P.y/TS))===T.CAVE;
AU.foot(onStone);P.stepT=onStone?.26:.3;
parts.push({x:P.x+rnd(-3,3),y:P.y+10,vx:rnd(-6,6),vy:rnd(-14,-4),life:.35,t:0,c:'rgba(120,120,110,.5)',sz:2,grav:-10});
}
}
}
// lava
const ft=tAt(Math.floor(P.x/TS),Math.floor(P.y/TS));
if(ft===T.LAVA){
if(hasEq('firering')){
if(Math.random()<.3)parts.push({x:P.x+rnd(-6,6),y:P.y+8,vx:0,vy:-30,life:.4,t:0,c:'#ffb347',sz:2,grav:0});
}else{
P.lavaT-=dt;
if(P.lavaT<=0){P.lavaT=.8;hurtPlayer(1,rnd(0,6.28));AU.burn();}
}
}
// dodge trigger
if((pressed['KeyK']||pressed['KeyX']||pressed['ShiftLeft']||pressed['ShiftRight'])&&!P.dodge.on&&P.stamina>=30){
P.dodge.on=true;P.dodge.t=.3;P.dodge.ang=mv?P.ang:(Math.atan2(P.dirY,P.dirX));
P.stamina-=30;AU.dodge();
}
// attack
if(pressed['KeyJ']||pressed['KeyZ']||pressed['Space'])tryAttack();
// melee swing progress
if(P.atk.on){
P.atk.t+=dt;
const p=P.atk.t/P.atk.dur;
if(!P.atk.did&&p>.45){P.atk.did=true;meleeHit()}
if(p>=1){P.atk.on=false;P.atk.cd=.06}
}
}
/* ---------------- enemy update ---------------- */
function seesPlayer(e,range){return !P.dead&&!P.gone&&dist(e.x,e.y,P.x,P.y)<range}
function touchDamage(e,dt){
if(!P.dead&&!P.dodge.on&&P.iframes<=0&&dist(e.x,e.y,P.x,P.y)<e.r+P.r+2){
hurtPlayer(e.dmg,Math.atan2(P.y-e.y,P.x-e.x));
}
}
function enemyShoot(e,ang,spd,dmg,type){
projs.push({x:e.x+Math.cos(ang)*e.r,y:e.y+Math.sin(ang)*e.r,vx:Math.cos(ang)*spd,vy:Math.sin(ang)*spd,dmg,from:'enemy',type:type||'fire',life:3});
}
function updateEnemy(e,dt){
if(e.dead)return;
e.flash=Math.max(0,e.flash-dt);
// burn
if(e.burn>0){
e.burn-=dt;e.burnT-=dt;
if(Math.random()<.25)parts.push({x:e.x+rnd(-e.r,e.r)*.6,y:e.y-e.r*.4,vx:rnd(-8,8),vy:rnd(-40,-20),life:.4,t:0,c:'#ffb347',sz:2,grav:0});
if(e.burnT<=0){e.burnT=1.2;e.hp-=1;floatText(e.x,e.y-e.r-4,'1','#ffb347');
if(e.hp<=0){killEnemy(e);return}}
}
// knockback decay
e.x+=e.kx*dt;e.y+=e.ky*dt;e.kx*=Math.pow(.02,dt);e.ky*=Math.pow(.02,dt);
const dP=dist(e.x,e.y,P.x,P.y);
const angP=Math.atan2(P.y-e.y,P.x-e.x);
e.t0+=dt;e.wob+=dt*4;
switch(e.t){
case 'slime':{
if(seesPlayer(e,150))e.st='chase';else e.st='idle';
if(e.st==='chase'){
if(e.extra.phase===undefined)e.extra.phase=0;
if(e.extra.phase===0){ // approach & windup
moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
if(dP<64){e.extra.phase=1;e.extra.t=.4;e.st='windup'}
}else if(e.extra.phase===1){e.extra.t-=dt;
if(e.extra.t<=0){e.extra.phase=2;e.extra.t=.45;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP);AU.noise&&AU.noise({dur:.1,vol:.05,f0:300,f1:600})}
}else if(e.extra.phase===2){e.extra.t-=dt;
moveEnt(e,e.extra.ax*190*dt,e.extra.ay*190*dt);
touchDamage(e,dt);
if(e.extra.t<=0){e.extra.phase=3;e.extra.t=.7}
}else{e.extra.t-=dt;if(e.extra.t<=0)e.extra.phase=0}
}else{
if(Math.random()<dt*.5)e.ang=rnd(0,6.28);
moveEnt(e,Math.cos(e.ang)*18*dt,Math.sin(e.ang)*18*dt);
}
break;}
case 'bat':{
if(seesPlayer(e,260)){
const a=angP+Math.sin(e.wob*2)*1.1;
moveEnt(e,Math.cos(a)*e.spd*dt,Math.sin(a)*e.spd*dt);
touchDamage(e,dt);
}else{
moveEnt(e,Math.cos(e.ang)*30*dt,Math.sin(e.ang)*30*dt);
if(Math.random()<dt*.4)e.ang=rnd(0,6.28);
}
break;}
case 'skel':{
if(seesPlayer(e,200)){
if(dP>42){moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);e.st='chase'}
else{
if(e.st!=='windup'&&e.st!=='attack'){e.st='windup';e.extra.t=.45;e.extra.ax=angP}
if(e.st==='windup'){e.extra.t-=dt;
if(e.extra.t<=0){e.st='attack';e.extra.t=.2;
if(dist(e.x,e.y,P.x,P.y)<52)hurtPlayer(e.dmg,e.extra.ax);
sparks(e.x+Math.cos(e.extra.ax)*24,e.y+Math.sin(e.extra.ax)*24,3,'#cfd8e6');AU.swing()}}
else if(e.st==='attack'){e.extra.t-=dt;if(e.extra.t<=0){e.st='chase';e.extra.cd=.5}}
}
if(e.extra.cd>0)e.extra.cd-=dt;
}else e.st='idle';
break;}
case 'archer':{
if(seesPlayer(e,260)){
const want=dP<110?-1:dP>190?1:0;
if(want!==0)moveEnt(e,Math.cos(angP)*e.spd*dt*want,Math.sin(angP)*e.spd*dt*want);
e.shootT-=dt;
if(e.shootT<=0&&dP<280){e.shootT=2.2;
enemyShoot(e,angP,240,1,'earrow');AU.bow()}
}
break;}
case 'wisp':{
if(seesPlayer(e,300)){
moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
if(dP<26){ // detonate
e.dead=true;AU.boom();burst(e.x,e.y,18,'#ffb347');G.shake=5;
if(dist(e.x,e.y,P.x,P.y)<55)hurtPlayer(e.dmg,angP);
return;
}
}
if(Math.random()<.3)parts.push({x:e.x,y:e.y,vx:rnd(-6,6),vy:rnd(-20,-6),life:.3,t:0,c:'#ff9a3c',sz:2,grav:0});
break;}
case 'warden':{
if(seesPlayer(e,230)){
if(e.extra.phase===undefined)e.extra.phase=0;
if(e.extra.phase===0){
moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
touchDamage(e,dt);
if(e.t0>2.2){e.t0=0;e.extra.phase=1;e.extra.t=.6;AU.roar&&AU.noise({dur:.3,vol:.1,f0:150,f1:60})}
}else if(e.extra.phase===1){e.extra.t-=dt;
if(e.extra.t<=0){e.extra.phase=2;e.extra.t=.5;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP)}
}else if(e.extra.phase===2){e.extra.t-=dt;
moveEnt(e,e.extra.ax*230*dt,e.extra.ay*230*dt);touchDamage(e,dt);
if(e.extra.t<=0){e.extra.phase=3;e.extra.t=1.0}
}else{e.extra.t-=dt;if(e.extra.t<=0)e.extra.phase=0}
}else{
if(dist(e.x,e.y,e.home.x,e.home.y)>8)moveEnt(e,Math.sign(e.home.x-e.x)*20*dt,Math.sign(e.home.y-e.y)*20*dt);
}
break;}
case 'guardian':{
if(e.extra.phase===undefined){e.extra.phase=0;AU.roar();toast('⚠ THE STONE GUARDIAN AWAKENS');
closeDoorBehind('arenaDoor')}
if(e.extra.phase===0){ // slow chase
moveEnt(e,Math.cos(angP)*e.spd*dt,Math.sin(angP)*e.spd*dt);
touchDamage(e,dt);
if(e.t0>1.8){e.t0=0;
if(dP<80){e.extra.phase=1;e.extra.t=.7} // slam
else{e.extra.phase=2;e.extra.t=.55;e.extra.tx=P.x;e.extra.ty=P.y} // leap
}
}else if(e.extra.phase===1){ // slam windup
e.extra.t-=dt;
if(e.extra.t<=0){
e.extra.phase=3;e.extra.t=.6;AU.boom();G.shake=8;
burst(e.x,e.y,20,'#aab4c8',1.2);
if(dist(e.x,e.y,P.x,P.y)<80)hurtPlayer(3,angP);
}
}else if(e.extra.phase===2){ // leap
e.extra.t-=dt;
const t=1-e.extra.t/.55;
e.x=lerp(e.x,e.extra.tx,dt*6);e.y=lerp(e.y,e.extra.ty,dt*6);
if(e.extra.t<=0){e.extra.phase=3;e.extra.t=.6;AU.boom();G.shake=8;burst(e.x,e.y,20,'#aab4c8',1.2);
if(dist(e.x,e.y,P.x,P.y)<70)hurtPlayer(2,angP)}
}else{ // stun — punish window
e.extra.t-=dt;
if(e.extra.t<=0)e.extra.phase=0;
}
break;}
case 'boss':{
if(e.extra.phase===undefined){
e.extra.phase=0;e.extra.cd=1.5;e.extra.mode='chase';e.extra.t=0;
AU.roar();toast('🔥 THE ASH KING RISES');closeDoorBehind('bossGate');
}
const ph2=e.hp<e.maxHp*.5;
const spd=e.spd*(ph2?1.5:1);
e.extra.cd-=dt;
if(e.extra.mode==='chase'){
moveEnt(e,Math.cos(angP)*spd*dt,Math.sin(angP)*spd*dt);
touchDamage(e,dt);
if(e.extra.cd<=0){
e.extra.cd=ph2?1.7:2.4;
const roll=Math.random();
if(roll<.4){e.extra.mode='cast';e.extra.t=.5}
else if(roll<.7){e.extra.mode='ring';e.extra.t=.5}
else{e.extra.mode='dash';e.extra.t=.55;e.extra.ax=Math.cos(angP);e.extra.ay=Math.sin(angP);
AU.noise&&AU.noise({dur:.4,vol:.12,f0:200,f1:900})}
}
}else if(e.extra.mode==='cast'){
e.extra.t-=dt;
if(e.extra.t<=0){e.extra.mode='chase';
for(let i=-1;i<=1;i++){const a=angP+i*.22;enemyShoot(e,a,ph2?260:210,2,'fire')}
AU.swingFire&&AU.noise({dur:.25,vol:.1,f0:300,f1:1200});}
}else if(e.extra.mode==='ring'){
e.extra.t-=dt;
if(e.extra.t<=0){e.extra.mode='chase';
const n=ph2?10:8;
for(let i=0;i<n;i++){const a=i/n*Math.PI*2+e.wob;enemyShoot(e,a,170,2,'fire')}
AU.boom();G.shake=6;}
}else if(e.extra.mode==='dash'){
e.extra.t-=dt;
moveEnt(e,e.extra.ax*spd*3.4*dt,e.extra.ay*spd*3.4*dt);
touchDamage(e,dt);
if(Math.random()<.6)parts.push({x:e.x,y:e.y,vx:rnd(-10,10),vy:rnd(-10,10),life:.3,t:0,c:'#ff9a3c',sz:3,grav:0});
if(e.extra.t<=0)e.extra.mode='chase';
}
// summon wisps occasionally in phase 2
if(ph2&&Math.random()<dt*.12&&enemies.filter(o=>o.t==='wisp'&&!o.dead).length<3)
spawnEnemy('wisp',e.x+rnd(-40,40),e.y+rnd(-40,40));
if(Math.random()<.4)parts.push({x:e.x+rnd(-14,14),y:e.y-e.r*.6,vx:rnd(-6,6),vy:rnd(-40,-20),life:.5,t:0,c:'#ff9a3c',sz:2.5,grav:0});
break;}
}
}
function closeDoorBehind(id){const d=doors.find(o=>o.id===id);if(d&&!d.open){/* stay closed; ensure closed */}
// ensure the door is explicitly closed for the fight
const d2=doors.find(o=>o.id===id);if(d2){d2.open=false;delete G.flags.doors[id]}
}
/* ---------------- projectiles, pickups, bombs, particles ---------------- */
function updateProjs(dt){
for(let i=projs.length-1;i>=0;i--){
const p=projs[i];
p.x+=p.vx*dt;p.y+=p.vy*dt;p.life-=dt;
let kill=p.life<=0;
const tx=Math.floor(p.x/TS),ty=Math.floor(p.y/TS);
if(isSolid(tx,ty)){kill=true;sparks(p.x,p.y,4,p.type==='arrow'?'#c8b790':'#ff9a3c');if(p.type==='arrow')AU.arrowHit()}
if(!kill&&p.from==='player'){
for(const e of enemies){
if(e.dead)continue;
if(dist(p.x,p.y,e.x,e.y)<e.r+4){
let dmg=p.dmg,crit=false;
if(hasEq('charm')&&Math.random()<.12){dmg*=2;crit=true}
damageEnemy(e,dmg,Math.atan2(p.vy,p.vx),crit);
kill=true;break;
}
}
if(!kill)for(const tt of torchesA){
if(!tt.lit&&dist(p.x,p.y,tt.x,tt.y)<16){lightTorch(tt);kill=true;break}
}
if(!kill&&dist(p.x,p.y,dummy.x,dummy.y)<14){dummy.wob=1;kill=true;AU.arrowHit()}
}
if(!kill&&p.from==='enemy'){
if(!P.dead&&!P.dodge.on&&dist(p.x,p.y,P.x,P.y)<P.r+5){
hurtPlayer(p.dmg,Math.atan2(p.vy,p.vx));kill=true;
}
}
if(p.type==='fire'&&Math.random()<.5)
parts.push({x:p.x,y:p.y,vx:rnd(-8,8),vy:rnd(-8,8),life:.25,t:0,c:'#ff9a3c',sz:2.5,grav:0});
if(kill)projs.splice(i,1);
}
}
function updatePickups(dt){
for(let i=pickups.length-1;i>=0;i--){
const p=pickups[i];p.t+=dt;
p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=200*dt;p.vx*=Math.pow(.1,dt);
if(p.t>.25)p.vy=Math.max(0,p.vy);
const d=dist(p.x,p.y,P.x,P.y);
if(d<70&&p.type==='coin'){p.x+=(P.x-p.x)*dt*8;p.y+=(P.y-p.y)*dt*8}
if(d<18&&!P.dead){
if(p.type==='coin'){giveGold(p.val)}
else if(p.type==='arrows'){addItem('arrows',p.val)}
else if(p.type==='potion'){addItem('potion',p.val)}
else if(p.type==='key'){P.inv.rustykey=(P.inv.rustykey||0)+1;toast('🗝️ Rusty Key obtained');AU.pickup()}
pickups.splice(i,1);continue;
}
if(p.t>30)pickups.splice(i,1);
}
}
function updateBombs(dt){
for(let i=bombsA.length-1;i>=0;i--){
const b=bombsA[i];b.t-=dt;
if(b.t<=0){explodeBomb(b);bombsA.splice(i,1)}
}
}
function updateParts(dt){
for(let i=parts.length-1;i>=0;i--){
const p=parts[i];p.t+=dt;
if(p.t>=p.life){parts.splice(i,1);continue}
p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=(p.grav||0)*dt;
}
if(parts.length>500)parts.splice(0,parts.length-500);
for(let i=floaters.length-1;i>=0;i--){
const f=floaters[i];f.t+=dt;f.y-=34*dt;
if(f.t>1)floaters.splice(i,1);
}
}
/* ---------------- triggers & quest flow ---------------- */
function inRect(px,py,x0,y0,x1,y1){const tx=px/TS,ty=py/TS;return tx>=x0&&tx<=x1&&ty>=y0&&ty<=y1}
function updateTriggers(dt){
// arena
if(!G.flags.guardianDead&&!G.guardianUp&&inRect(P.x,P.y,65,26,93,40)&&G.flags.puzzle){
G.guardianUp=true;spawnEnemy('guardian',82.5*TS,33*TS);
}
// boss room
if(!G.flags.bossDead&&!G.bossUp&&inRect(P.x,P.y,27,2,37,11)){
const allLit=torchesA.every(t=>t.lit);
if(allLit){G.bossUp=true;spawnEnemy('boss',32.5*TS,6.5*TS)}
}
// stage nudges
if(G.stage===3&&G.flags.wardenDead)setStage(4);
if(G.stage===4&&G.flags.doors['ruinsGate'])setStage(5);
// win
if(G.winT>0){G.winT-=dt;
if(G.winT<=0)showWin();
}
}
function showWin(){
G.state='win';
const m=Math.floor(G.playTime/60),s=Math.floor(G.playTime%60);
document.getElementById('winStats').innerHTML=
`Time: <b>${m}m ${s}s</b><br>Foes felled: <b>${G.kills}</b><br>Gold held: <b>${P.gold}</b><br>`+
`Heart containers: <b>${P.containers}/5</b> · Final blade: <b>${WEAPONS[P.weaponIdx].name}</b>`;
document.getElementById('win').classList.remove('hidden');
AU.fanfare();
}
/* ---------------- master update ---------------- */
function update(dt){
if(G.freeze>0){G.freeze-=dt;return}
G.time+=dt;G.playTime+=dt;
G.shake=Math.max(0,G.shake-dt*22);
updatePlayer(dt);
tryPushBlockPassive(dt);
updateBlocks(dt);
checkPlates();
for(const e of enemies)updateEnemy(e,dt);
enemies=enemies.filter(e=>!e.dead||false); // keep dead removed after burst
enemies=enemies.filter(e=>!e.dead);
updateProjs(dt);updatePickups(dt);updateBombs(dt);updateParts(dt);
dummy.wob=Math.max(0,dummy.wob-dt*3);
updateTriggers(dt);
// camera
const cx=clamp(P.x-VW/2,0,Math.max(0,MW*TS-VW));
const cy=clamp(P.y-VH/2,0,Math.max(0,MH*TS-VH));
cam.x=lerp(cam.x,cx,Math.min(1,dt*7));cam.y=lerp(cam.y,cy,Math.min(1,dt*7));
}
function tryPushBlockPassive(dt){/* pushing handled in player step */}
/* ================= DRAW ================= */
function drawWorld(){
const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(MW-1,Math.ceil((cam.x+VW)/TS));
const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(MH-1,Math.ceil((cam.y+VH)/TS));
ctx.drawImage(base,cam.x,cam.y,VW,VH,0,0,VW,VH);
const t=G.time;
for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
const tt=tAt(tx,ty),x=tx*TS-cam.x,y=ty*TS-cam.y,h=hash2(tx,ty);
if(tt===T.WATER){
ctx.fillStyle='rgba(120,180,255,'+(.08+.05*Math.sin(t*2+tx+ty))+')';
ctx.fillRect(x,y+ (h*20|0),TS,3);
ctx.fillRect(x+(h*14|0),y+((h*37+10)%28),14,2);
}else if(tt===T.LAVA){
const g=.5+.5*Math.sin(t*3+tx*2+ty*3);
ctx.fillStyle=`rgba(255,${100+g*90|0},40,${.55+.25*g})`;
ctx.fillRect(x,y,TS,TS);
ctx.fillStyle='rgba(255,220,120,'+(.25+.3*g)+')';
ctx.beginPath();ctx.arc(x+8+h*14,y+10+((h*23)%12),3+2*g,0,7);ctx.fill();
if(Math.random()<.004)parts.push({x:tx*TS+rnd(4,28),y:ty*TS+rnd(4,28),vx:0,vy:rnd(-30,-14),life:.7,t:0,c:'#ffb347',sz:2,grav:0});
}
}
}
function drawDoorTiles(){
for(const d of doors){
if(d.open)continue;
for(const [tx,ty] of d.tiles){
const x=tx*TS-cam.x,y=ty*TS-cam.y;
if(x<-TS||y<-TS||x>VW||y>VH)continue;
const cave=ty<19;
ctx.fillStyle=cave?'#3a2f28':'#4a3826';ctx.fillRect(x+2,y+2,TS-4,TS-4);
ctx.fillStyle=cave?'#4d3f35':'#5f4933';
ctx.fillRect(x+4,y+4,6,TS-8);ctx.fillRect(x+13,y+4,6,TS-8);ctx.fillRect(x+22,y+4,6,TS-8);
ctx.fillStyle='#2a2018';
ctx.fillRect(x+2,y+8,TS-4,3);ctx.fillRect(x+2,y+21,TS-4,3);
if(d.key){ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.arc(x+16,y+18,3.4,0,7);ctx.fill();
ctx.fillStyle='#2a2018';ctx.fillRect(x+15,y+18,2,5)}
}
}
}
function drawShadow(x,y,r){ctx.fillStyle='rgba(0,0,0,.3)';ctx.beginPath();ctx.ellipse(x,y,r,r*.42,0,0,7);ctx.fill()}
function drawTreeOver(){
const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(MW-1,Math.ceil((cam.x+VW)/TS));
const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(MH-1,Math.ceil((cam.y+VH)/TS));
for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
if(tAt(tx,ty)!==T.TREE)continue;
const x=tx*TS-cam.x+16,y=ty*TS-cam.y+16,h=hash2(tx,ty);
const sw=Math.sin(G.time*1.2+tx*1.7+ty)*1.2;
drawShadow(x,y+12,10);
ctx.fillStyle=h>.5?'#2c5e34':'#265430';
ctx.beginPath();ctx.arc(x+sw,y-8,13,0,7);ctx.fill();
ctx.fillStyle=h>.5?'#397741':'#316b39';
ctx.beginPath();ctx.arc(x+sw*.6,y-13,9,0,7);ctx.fill();
}
}
function drawChest(c){
const x=c.x-cam.x,y=c.y-cam.y;
drawShadow(x,y+9,11);
ctx.fillStyle='#6b4a2f';ctx.fillRect(x-11,y-8,22,16);
ctx.fillStyle=c.opened?'#4a3421':'#8a683f';ctx.fillRect(x-11,y-8,22,c.opened?5:7);
ctx.fillStyle='#d8b34a';ctx.fillRect(x-2,y-4,4,6);
if(!c.opened&&Math.sin(G.time*3+c.x)>.4){ctx.fillStyle='rgba(255,220,120,.8)';ctx.fillRect(x-1,y-2,2,2)}
}
function drawShrine(s){
const x=s.x-cam.x,y=s.y-cam.y;
const active=G.flags.shrineIds[s.id];
drawShadow(x,y+12,12);
ctx.fillStyle='#5a5f6e';ctx.fillRect(x-9,y-6,18,18);
ctx.fillStyle='#6d7385';ctx.fillRect(x-6,y-16,12,12);
ctx.fillStyle=active?'#8fd3ff':'#3a3f4d';
ctx.beginPath();ctx.moveTo(x,y-13);ctx.lineTo(x+4,y-7);ctx.lineTo(x,y-1);ctx.lineTo(x-4,y-7);ctx.closePath();ctx.fill();
if(active){
ctx.fillStyle='rgba(143,211,255,'+(.4+.3*Math.sin(G.time*3))+')';
ctx.beginPath();ctx.arc(x,y-7,7+Math.sin(G.time*3),0,7);ctx.fill();
if(Math.random()<.05)parts.push({x:x+rnd(-8,8),y:y,vx:0,vy:rnd(-30,-14),life:.8,t:0,c:'#8fd3ff',sz:2,grav:-10});
}
}
function drawNPC(n){
const x=n.x-cam.x,y=n.y-cam.y;
const bob=Math.sin(G.time*2+n.x)*1.2;
drawShadow(x,y+11,8);
ctx.fillStyle=n.c;ctx.fillRect(x-6,y-6+bob*.3,12,14);
ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x,y-11+bob*.3,6,0,7);ctx.fill();
ctx.fillStyle='#4a3826';ctx.beginPath();ctx.arc(x,y-13+bob*.3,5.4,Math.PI,0);ctx.fill();
ctx.fillStyle='#222';ctx.fillRect(x-3,y-11+bob*.3,1.6,2);ctx.fillRect(x+1.5,y-11+bob*.3,1.6,2);
if(n.id==='bram'){ctx.fillStyle='#7a5a36';ctx.fillRect(x-8,y-20,16,4)}
ctx.fillStyle='rgba(255,255,255,.85)';ctx.font='9px sans-serif';ctx.textAlign='center';
ctx.fillText('💬',x,y-22+Math.sin(G.time*3)*2);
}
function drawTablet(tb){
const x=tb.x-cam.x,y=tb.y-cam.y;
drawShadow(x,y+9,9);
ctx.fillStyle='#6d7385';ctx.fillRect(x-7,y-14,14,22);
ctx.fillStyle='#565b6b';ctx.fillRect(x-7,y-14,14,4);
ctx.fillStyle='#3a3f4d';
for(let i=0;i<3;i++)ctx.fillRect(x-4,y-7+i*5,8,1.6);
}
function drawPlatesBlocks(){
for(const p of platesA){
const x=p.tx*TS+16-cam.x,y=p.ty*TS+16-cam.y;
const pulse=.5+.5*Math.sin(G.time*3+p.tx);
ctx.fillStyle='#3f4350';ctx.beginPath();ctx.arc(x,y,12,0,7);ctx.fill();
ctx.strokeStyle=p.on?`rgba(124,252,144,${.6+.4*pulse})`:`rgba(216,179,74,${.35+.35*pulse})`;
ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,10,0,7);ctx.stroke();
ctx.strokeStyle=p.on?'#7cfc90':'#8a7230';ctx.lineWidth=1.4;
ctx.beginPath();
ctx.moveTo(x-5,y);ctx.lineTo(x+5,y);ctx.moveTo(x,y-5);ctx.lineTo(x,y+5);
ctx.moveTo(x-3.5,y-3.5);ctx.lineTo(x+3.5,y+3.5);ctx.moveTo(x-3.5,y+3.5);ctx.lineTo(x+3.5,y-3.5);
ctx.stroke();
if(p.on&&Math.random()<.06)parts.push({x:x+rnd(-8,8),y:y+rnd(-8,8),vx:0,vy:-24,life:.5,t:0,c:'#7cfc90',sz:2,grav:0});
}
for(const b of blocksA){
const x=b.x-cam.x,y=b.y-cam.y;
drawShadow(x,y+13,13);
ctx.fillStyle='#7e8591';ctx.fillRect(x-13,y-13,26,26);
ctx.fillStyle='#99a0ac';ctx.fillRect(x-13,y-13,26,6);
ctx.strokeStyle='#565b6b';ctx.strokeRect(x-12.5,y-12.5,25,25);
}
}
function drawTorch(tt){
const x=tt.x-cam.x,y=tt.y-cam.y;
ctx.fillStyle='#4a3826';ctx.fillRect(x-2,y-6,4,14);
ctx.fillStyle='#33281c';ctx.fillRect(x-5,y-9,10,4);
if(tt.lit){
const f=Math.sin(G.time*9+tt.x)*2;
ctx.fillStyle='rgba(255,150,50,.9)';
ctx.beginPath();ctx.arc(x,y-13+f*.4,5,0,7);ctx.fill();
ctx.fillStyle='rgba(255,230,140,.95)';
ctx.beginPath();ctx.arc(x,y-14+f*.3,2.5,0,7);ctx.fill();
}else{
ctx.fillStyle='#2a2018';ctx.beginPath();ctx.arc(x,y-11,3,0,7);ctx.fill();
}
}
function drawPedestal(){
const x=pedestal.x-cam.x,y=pedestal.y-cam.y;
ctx.fillStyle='#5a5f6e';ctx.fillRect(x-10,y-2,20,12);
ctx.fillStyle='#6d7385';ctx.fillRect(x-7,y-10,14,8);
if(!pedestal.taken){
const b=Math.sin(G.time*2.5)*2;
ctx.strokeStyle='#c8b790';ctx.lineWidth=2.5;
ctx.beginPath();ctx.arc(x,y-20+b,7,0.3,Math.PI-0.3);ctx.stroke();
ctx.beginPath();ctx.moveTo(x-6,y-18+b);ctx.lineTo(x+6,y-18+b);ctx.stroke();
ctx.fillStyle='rgba(140,200,255,'+(.3+.2*Math.sin(G.time*3))+')';
ctx.beginPath();ctx.arc(x,y-18+b,11,0,7);ctx.fill();
}
}
function drawDummy(){
const x=dummy.x-cam.x,y=dummy.y-cam.y;
const w=Math.sin(G.time*14)*dummy.wob*4;
drawShadow(x,y+12,9);
ctx.fillStyle='#6b4a2f';ctx.fillRect(x-2,y-4,4,16);
ctx.fillStyle='#c8b790';ctx.beginPath();ctx.ellipse(x+w*.5,y-12,9,12,w*.04,0,7);ctx.fill();
ctx.strokeStyle='#8a7a5a';ctx.beginPath();ctx.moveTo(x-7+w*.5,y-16);ctx.lineTo(x+7+w*.5,y-16);
ctx.moveTo(x-8+w*.5,y-8);ctx.lineTo(x+8+w*.5,y-8);ctx.stroke();
}
function drawPickup(p){
const x=p.x-cam.x,y=p.y-cam.y;
if(p.type==='coin'){
const s=Math.abs(Math.sin(G.time*6+p.x));
ctx.fillStyle='#ffd76a';ctx.beginPath();ctx.ellipse(x,y,4*Math.max(.3,s),4,0,0,7);ctx.fill();
ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.ellipse(x,y,2*Math.max(.3,s),2,0,0,7);ctx.fill();
}else if(p.type==='arrows'){
ctx.strokeStyle='#c8b790';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(x-5,y+4);ctx.lineTo(x+5,y-4);ctx.stroke();
ctx.fillStyle='#e8e8f0';ctx.beginPath();ctx.moveTo(x+6,y-5);ctx.lineTo(x+2,y-4);ctx.lineTo(x+5,y-1);ctx.fill();
}else if(p.type==='potion'){
ctx.fillStyle='#e5484d';ctx.beginPath();ctx.arc(x,y+2,4.5,0,7);ctx.fill();
ctx.fillStyle='#cfd8e6';ctx.fillRect(x-1.5,y-7,3,5);
}else if(p.type==='key'){
ctx.fillStyle='#c9a84a';ctx.beginPath();ctx.arc(x,y-3,4,0,7);ctx.fill();
ctx.fillRect(x-1.5,y-3,3,9);ctx.fillRect(x,y+3,4,2);
ctx.fillStyle='#7a5a36';ctx.beginPath();ctx.arc(x,y-3,1.8,0,7);ctx.fill();
}
}
function drawBomb(b){
const x=b.x-cam.x,y=b.y-cam.y;
drawShadow(x,y+7,7);
const fl=b.t<.4;
ctx.fillStyle='#23252e';ctx.beginPath();ctx.arc(x,y,7,0,7);ctx.fill();
ctx.fillStyle='#3a3d4a';ctx.beginPath();ctx.arc(x-2,y-2,2.5,0,7);ctx.fill();
ctx.strokeStyle='#8a683f';ctx.beginPath();ctx.moveTo(x+3,y-6);ctx.quadraticCurveTo(x+7,y-10,x+5,y-12);ctx.stroke();
ctx.fillStyle=fl&&Math.sin(G.time*30)>0?'#fff':'#ffb347';
ctx.beginPath();ctx.arc(x+5,y-12,2+Math.random(),0,7);ctx.fill();
}
/* ---------------- enemies draw ---------------- */
function drawEnemy(e){
const x=e.x-cam.x,y=e.y-cam.y;
if(x<-60||y<-60||x>VW+60||y>VH+60)return;
const fl=e.flash>0;
drawShadow(x,y+e.r*.8,e.r*.9);
ctx.save();
if(fl){ctx.globalAlpha=.95}
switch(e.t){
case 'slime':{
const sq=1+Math.sin(e.wob*2)*.12;
ctx.fillStyle=fl?'#fff':'#7cc26a';
ctx.beginPath();ctx.ellipse(x,y+4,e.r*sq,e.r/sq*.9,0,0,7);ctx.fill();
ctx.fillStyle=fl?'#eee':'#a3dd90';
ctx.beginPath();ctx.ellipse(x-3,y,e.r*.4,e.r*.3,0,0,7);ctx.fill();
ctx.fillStyle='#20401f';ctx.fillRect(x-5,y-2,2.6,4);ctx.fillRect(x+2.5,y-2,2.6,4);
break;}
case 'bat':{
const w=Math.sin(e.wob*5)*6;
ctx.fillStyle=fl?'#fff':'#5d4a75';
ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-12,y-4-w);ctx.lineTo(x-4,y+1);ctx.lineTo(x+4,y+1);ctx.lineTo(x+12,y-4-w);ctx.closePath();ctx.fill();
ctx.beginPath();ctx.arc(x,y,4.6,0,7);ctx.fill();
ctx.fillStyle='#ffd76a';ctx.fillRect(x-2.5,y-1.5,1.6,1.6);ctx.fillRect(x+1,y-1.5,1.6,1.6);
break;}
case 'skel':{
const step=Math.sin(e.wob*2.4)*2;
ctx.fillStyle=fl?'#fff':'#d8d5c8';
ctx.fillRect(x-5,y-4+step*.2,10,12);
ctx.beginPath();ctx.arc(x,y-9,5.6,0,7);ctx.fill();
ctx.fillStyle='#1a1a1f';ctx.fillRect(x-3,y-10,2,2.6);ctx.fillRect(x+1,y-10,2,2.6);
ctx.strokeStyle=fl?'#fff':'#d8d5c8';ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(x-3,y-2);ctx.lineTo(x-3,y+7);ctx.moveTo(x+3,y-2);ctx.lineTo(x+3,y+7);ctx.stroke();
if(e.st==='windup'){ctx.strokeStyle='#ffdf80';ctx.beginPath();ctx.arc(x,y,e.r+6,0,7);ctx.stroke()}
ctx.strokeStyle='#aab4c8';ctx.lineWidth=2;
const aa=(e.extra&&e.extra.ax!==undefined?e.extra.ax:e.ang);
ctx.beginPath();ctx.moveTo(x+Math.cos(aa)*6,y+Math.sin(aa)*6);ctx.lineTo(x+Math.cos(aa)*16,y+Math.sin(aa)*16);ctx.stroke();
break;}
case 'archer':{
ctx.fillStyle=fl?'#fff':'#cfd8c8';
ctx.fillRect(x-5,y-4,10,12);
ctx.beginPath();ctx.arc(x,y-9,5.6,0,7);ctx.fill();
ctx.fillStyle='#1a1a1f';ctx.fillRect(x-3,y-10,2,2.6);ctx.fillRect(x+1,y-10,2,2.6);
const aa=Math.atan2(P.y-e.y,P.x-e.x);
ctx.strokeStyle='#8a683f';ctx.lineWidth=2;
ctx.beginPath();ctx.arc(x+Math.cos(aa)*8,y+Math.sin(aa)*8,6,aa-1.2,aa+1.2);ctx.stroke();
break;}
case 'wisp':{
ctx.fillStyle=fl?'#fff':'rgba(255,154,60,.9)';
ctx.beginPath();ctx.arc(x,y,e.r+Math.sin(e.wob*3)*1.5,0,7);ctx.fill();
ctx.fillStyle='#fff3c0';ctx.beginPath();ctx.arc(x,y,3.4,0,7);ctx.fill();
break;}
case 'warden':{
const sq=1+Math.sin(e.wob*1.6)*.08;
ctx.fillStyle=fl?'#fff':'#4e8a44';
ctx.beginPath();ctx.ellipse(x,y+4,e.r*sq,e.r/sq*.92,0,0,7);ctx.fill();
ctx.fillStyle=fl?'#eee':'#6bb05e';
ctx.beginPath();ctx.ellipse(x-5,y-2,e.r*.45,e.r*.32,0,0,7);ctx.fill();
ctx.fillStyle='#16280f';ctx.fillRect(x-8,y-6,4,7);ctx.fillRect(x+4,y-6,4,7);
ctx.fillStyle='#c9a84a';ctx.fillRect(x-8,y-9,4,2);ctx.fillRect(x+4,y-9,4,2);
// crown of thorns
ctx.strokeStyle='#2f4a26';ctx.lineWidth=2;
for(let i=0;i<5;i++){const a=-2.4+i*.55;
ctx.beginPath();ctx.moveTo(x+Math.cos(a)*e.r*.8,y+Math.sin(a)*e.r*.7);
ctx.lineTo(x+Math.cos(a)*(e.r*.8+7),y+Math.sin(a)*(e.r*.7+7)-4);ctx.stroke()}
break;}
case 'guardian':{
const lunge=e.extra.phase===2?Math.sin(e.t*30)*1.5:0;
ctx.fillStyle=fl?'#fff':'#7e8591';
ctx.fillRect(x-16+lunge,y-18,32,34);
ctx.fillStyle=fl?'#eee':'#99a0ac';ctx.fillRect(x-16+lunge,y-18,32,8);
ctx.fillStyle=fl?'#eee':'#6d7385';
ctx.fillRect(x-24,y-14,8,22);ctx.fillRect(x+16,y-14,8,22);
ctx.fillStyle=e.extra.phase===3?'#ffe9b0':'#8fd3ff';
ctx.fillRect(x-8,y-11,5,4);ctx.fillRect(x+3,y-11,5,4);
ctx.strokeStyle='#565b6b';ctx.strokeRect(x-15.5,y-17.5,31,33);
if(e.hp<e.maxHp*.5){ctx.strokeStyle='#3a3f4d';ctx.beginPath();
ctx.moveTo(x-10,y-16);ctx.lineTo(x-4,y-4);ctx.lineTo(x-12,y+8);ctx.stroke()}
break;}
case 'boss':{
const ph2=e.hp<e.maxHp*.5;
const pu=1+Math.sin(G.time*(ph2?8:4))*.04;
ctx.fillStyle=fl?'#fff':'#2c2028';
ctx.beginPath();ctx.ellipse(x,y+2,e.r*pu,e.r*1.25*pu,0,0,7);ctx.fill();
ctx.fillStyle=fl?'#eee':'#3d2c35';
ctx.beginPath();ctx.arc(x,y-e.r*.7,e.r*.62,0,7);ctx.fill();
// crown
ctx.fillStyle='#c9a84a';
ctx.beginPath();ctx.moveTo(x-10,y-e.r*.7-8);ctx.lineTo(x-7,y-e.r*.7-16);ctx.lineTo(x-4,y-e.r*.7-9);
ctx.lineTo(x,y-e.r*.7-18);ctx.lineTo(x+4,y-e.r*.7-9);ctx.lineTo(x+7,y-e.r*.7-16);ctx.lineTo(x+10,y-e.r*.7-8);ctx.closePath();ctx.fill();
// eyes
ctx.fillStyle=ph2?'#ff5040':'#ffb347';
ctx.fillRect(x-7,y-e.r*.7-2,4,3.4);ctx.fillRect(x+3,y-e.r*.7-2,4,3.4);
// ribs
ctx.strokeStyle='#57493f';ctx.lineWidth=2;
for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(x-10,y-6+i*7);ctx.lineTo(x+10,y-6+i*7);ctx.stroke()}
break;}
}
ctx.restore();
// hp bar for mini/boss (guardian handled in HUD)
if((e.t==='warden'||e.t==='guardian')&&e.hp<e.maxHp){
ctx.fillStyle='#000a';ctx.fillRect(x-18,y-e.r-14,36,5);
ctx.fillStyle='#e5484d';ctx.fillRect(x-17,y-e.r-13,34*e.hp/e.maxHp,3);
}
if(e.burn>0){ctx.fillStyle='rgba(255,150,50,'+(.4+.3*Math.sin(G.time*10))+')';
ctx.beginPath();ctx.arc(x,y,e.r+3,0,7);ctx.fill()}
}
/* ---------------- player draw ---------------- */
function drawWeaponSprite(x,y,ang,w,swingP){
ctx.save();ctx.translate(x,y);ctx.rotate(ang);
const len=w.id==='rusty'?16:w.id==='steel'?20:20;
// handle
ctx.fillStyle='#5f4933';ctx.fillRect(6,-1.6,7,3.2);
ctx.fillStyle='#c9a84a';ctx.fillRect(12.5,-3.4,2,6.8);
// blade
ctx.fillStyle=w.blade;
ctx.beginPath();
ctx.moveTo(14.5,-2.6);ctx.lineTo(14.5+len,-.8);ctx.lineTo(14.5+len+3,0);ctx.lineTo(14.5+len,.8);ctx.lineTo(14.5,2.6);ctx.closePath();ctx.fill();
if(w.fire){
ctx.fillStyle='rgba(255,154,60,'+(.6+.4*Math.sin(G.time*12))+')';
ctx.beginPath();ctx.moveTo(15,-2);ctx.lineTo(15+len,0);ctx.lineTo(15,2);ctx.closePath();ctx.fill();
if(Math.random()<.35)parts.push({x:x+Math.cos(ang)*(14+len)*.9,y:y+Math.sin(ang)*(14+len)*.9,
vx:rnd(-12,12),vy:rnd(-30,-10),life:.3,t:0,c:'#ffb347',sz:2,grav:0});
}else{
ctx.fillStyle='rgba(255,255,255,.5)';ctx.fillRect(15,-1.2,len*.7,1);
}
ctx.restore();
}
function drawPlayer(){
if(P.gone)return;
const x=P.x-cam.x,y=P.y-cam.y;
const w=P.weaponIdx<3?WEAPONS[P.weaponIdx]:null;
drawShadow(x,y+11,9);
ctx.save();
if(P.iframes>0&&Math.sin(G.time*40)>0)ctx.globalAlpha=.45;
if(P.dodge.on){
// rolling ball
ctx.rotate(P.dodge.t*18);
ctx.fillStyle='#4a7fb5';ctx.beginPath();ctx.arc(x,y,9,0,7);ctx.fill();
ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x+3,y-3,4,0,7);ctx.fill();
ctx.restore();return;
}
const tired=P.fatigued;
const legAmp=P.moving?(tired?3:4.5):0;
const l1=Math.sin(P.anim*2.2)*legAmp,l2=-l1;
const bob=P.moving?Math.abs(Math.sin(P.anim*2.2))*(tired?1:1.6):0;
const lean=tired?1.5:0;
// legs
ctx.fillStyle=hasEq('boots')?'#3fa7a0':'#5a4632';
ctx.fillRect(x-5+l1*.4,y+4,4,7-bob*.5);
ctx.fillRect(x+1+l2*.4,y+4,4,7-bob*.5);
// body
ctx.fillStyle='#4a7fb5';
ctx.fillRect(x-6,y-7-bob+lean*.3,12,13);
ctx.fillStyle='#3a6592';ctx.fillRect(x-6,y+2-bob,12,3);
// belt
ctx.fillStyle='#8a683f';ctx.fillRect(x-6,y+4-bob,12,2);
// quiver when bow owned
if(P.bowOwned){ctx.fillStyle='#6b4a2f';ctx.save();ctx.translate(x-7,y-4-bob);ctx.rotate(.5);
ctx.fillRect(-2,-6,4,12);ctx.restore();
ctx.strokeStyle='#c8b790';ctx.beginPath();ctx.moveTo(x-9,y-10-bob);ctx.lineTo(x-6,y-6-bob);ctx.stroke()}
// accessories
if(hasEq('amulet')){ctx.fillStyle='#c9c9d4';ctx.beginPath();ctx.arc(x,y-4-bob,2.2,0,7);ctx.fill()}
if(hasEq('firering')){ctx.fillStyle='#ff9a3c';ctx.fillRect(x+5,y-2-bob,2.4,2.4)}
// head
ctx.fillStyle='#e8c39a';ctx.beginPath();ctx.arc(x,y-12-bob+lean*.6,6.2,0,7);ctx.fill();
ctx.fillStyle='#6b4a2f';ctx.beginPath();ctx.arc(x,y-14-bob+lean*.6,5.6,Math.PI*.95,Math.PI*2.05);ctx.fill();
// eyes by facing
const fx=Math.cos(P.ang),fy=Math.sin(P.ang);
ctx.fillStyle='#222';
if(fy>0.55){ctx.fillRect(x-3,y-12-bob,1.8,2.4);ctx.fillRect(x+1.5,y-12-bob,1.8,2.4)}
else if(fy<-0.55){}
else{ctx.fillRect(x+(fx>0?1:-3),y-12.5-bob,2,2.6)}
if(hasEq('charm')){
const a=G.time*3;
ctx.fillStyle='#7cfc90';ctx.beginPath();ctx.arc(x+Math.cos(a)*11,y-6+Math.sin(a)*5,1.8,0,7);ctx.fill();
}
// weapon
if(w){
if(P.atk.on){
const p=Math.min(1,P.atk.t/P.atk.dur);
const ease=1-(1-p)*(1-p);
const dir=P.atk.combo%2===0?1:-1;
const a=P.atk.ang - (w.arc/2)*dir + w.arc*ease*dir;
drawWeaponSprite(x,y-2-bob,a,w,p);
// swing arc fx
ctx.strokeStyle=w.fire?'rgba(255,154,60,.5)':'rgba(232,232,240,.4)';
ctx.lineWidth=3;ctx.beginPath();
ctx.arc(x,y-2-bob,w.range+8,a-.3*dir,a+.12*dir,dir<0);ctx.stroke();
}else{
drawWeaponSprite(x,y-2-bob,P.ang+.55,w,0);
}
}else if(P.bowOwned&&P.weaponIdx===3){
ctx.save();ctx.translate(x,y-2-bob);ctx.rotate(P.ang);
ctx.strokeStyle='#8a683f';ctx.lineWidth=2.6;
ctx.beginPath();ctx.arc(9,0,8,-1.15,1.15);ctx.stroke();
ctx.strokeStyle='#e8e8f0';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(9+Math.cos(-1.15)*8,Math.sin(-1.15)*8);
ctx.lineTo(9+Math.cos(1.15)*8,Math.sin(1.15)*8);ctx.stroke();
if(P.arrows>0){ctx.strokeStyle='#c8b790';ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(2,0);ctx.lineTo(15,0);ctx.stroke()}
ctx.restore();
}
ctx.restore();
}
/* ---------------- cave darkness ---------------- */
let dark=null;
function drawDarkness(){
const ptx=Math.floor(P.x/TS),pty=Math.floor(P.y/TS);
const inCave=pty<19&&ptx>=24&&ptx<=94;
if(!inCave)return;
if(!dark){dark=document.createElement('canvas')}
if(dark.width!==VW||dark.height!==VH){dark.width=VW;dark.height=VH}
const g=dark.getContext('2d');
g.clearRect(0,0,VW,VH);
g.fillStyle='rgba(4,5,12,.93)';g.fillRect(0,0,VW,VH);
g.globalCompositeOperation='destination-out';
const punch=(px,py,r,strength)=>{
const grd=g.createRadialGradient(px,py,r*.15,px,py,r);
grd.addColorStop(0,'rgba(0,0,0,'+strength+')');grd.addColorStop(1,'rgba(0,0,0,0)');
g.fillStyle=grd;g.beginPath();g.arc(px,py,r,0,7);g.fill();
};
punch(P.x-cam.x,P.y-cam.y,180,1);
for(const tt of torchesA)if(tt.lit)punch(tt.x-cam.x,tt.y-cam.y,110,.95);
for(const s of shrines)if(G.flags.shrineIds[s.id])punch(s.x-cam.x,s.y-cam.y,80,.8);
// lava glow
const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(MW-1,Math.ceil((cam.x+VW)/TS));
const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(MH-1,Math.ceil((cam.y+VH)/TS));
for(let ty=y0;ty<=y1;ty+=2)for(let tx=x0;tx<=x1;tx+=2)
if(tAt(tx,ty)===T.LAVA)punch(tx*TS+16-cam.x,ty*TS+16-cam.y,54,.55);
g.globalCompositeOperation='source-over';
ctx.drawImage(dark,0,0);
}
/* ---------------- HUD ---------------- */
function drawHeart(x,y,full,half,locked){
ctx.save();ctx.translate(x,y);
ctx.beginPath();
ctx.moveTo(0,3);
ctx.bezierCurveTo(-9,-5,-4,-11,0,-6);
ctx.bezierCurveTo(4,-11,9,-5,0,3);
ctx.closePath();
if(locked){ctx.strokeStyle='rgba(216,179,74,.28)';ctx.lineWidth=1.4;ctx.stroke();ctx.restore();return}
ctx.fillStyle=full?'#e5484d':half?'#5a2026':'#2a1216';
ctx.fill();
if(half){ctx.save();ctx.clip();ctx.fillStyle='#e5484d';ctx.fillRect(-10,-12,10,20);ctx.restore();
ctx.strokeStyle='#00000055';ctx.stroke()}
ctx.strokeStyle='#7a1216';ctx.lineWidth=1;ctx.stroke();
if(full){ctx.fillStyle='rgba(255,255,255,.55)';ctx.beginPath();ctx.arc(-3,-4,1.6,0,7);ctx.fill()}
ctx.restore();
}
function drawHUD(){
// hearts (each = 2 HP), 5 slots total
for(let i=0;i<5;i++){
const x=26+i*26,y=26;
const hpFor=P.hp-i*2;
const locked=i>=P.containers;
const hurtShake=P.hurtT>0&&i<P.containers?Math.sin(G.time*60)*1.5:0;
drawHeart(x+hurtShake,y,hpFor>=2,hpFor===1,locked);
}
// stamina
ctx.fillStyle='rgba(0,0,0,.5)';ctx.fillRect(20,42,110,7);
ctx.fillStyle=P.fatigued?'#e0a83c':'#5fd068';
ctx.fillRect(21,43,108*(P.stamina/P.maxStamina),5);
ctx.strokeStyle='rgba(255,255,255,.15)';ctx.strokeRect(20.5,42.5,110,7);
// gold + arrows
ctx.font='13px sans-serif';ctx.textAlign='left';
ctx.fillStyle='#ffd76a';ctx.fillText('● '+P.gold,22,68);
if(P.bowOwned){ctx.fillStyle='#c8b790';ctx.fillText('➶ '+P.arrows,90,68)}
ctx.fillStyle='rgba(232,226,208,.5)';ctx.font='10px sans-serif';
ctx.fillText('M music · I bag',22,84);
// weapon box
const boxY=VH-58;
ctx.fillStyle='rgba(10,12,18,.75)';ctx.fillRect(16,boxY,190,44);
ctx.strokeStyle='#d8b34a66';ctx.strokeRect(16.5,boxY+.5,190,44);
if(P.weaponIdx===3){
ctx.font='13px sans-serif';ctx.fillStyle='#e8e2d0';
ctx.fillText('🏹 '+BOW.name,28,boxY+18);
ctx.fillStyle='#9a927c';ctx.font='11px sans-serif';
ctx.fillText(P.arrows>0?'dmg 1 · uses arrows':'NO ARROWS!',28,boxY+34);
}else{
const w=WEAPONS[P.weaponIdx];
ctx.font='13px sans-serif';ctx.fillStyle='#e8e2d0';
ctx.fillText(['🗡️','⚔️','🔥'][P.weaponIdx]+' '+w.name,28,boxY+18);
ctx.fillStyle='#9a927c';ctx.font='11px sans-serif';
ctx.fillText('dmg '+w.dmg+(w.fire?' +burn':'')+' · Q swap',28,boxY+34);
}
// quickslots
ctx.fillStyle='rgba(10,12,18,.75)';ctx.fillRect(214,boxY,92,44);
ctx.strokeStyle='#d8b34a44';ctx.strokeRect(214.5,boxY+.5,92,44);
ctx.font='11px sans-serif';ctx.fillStyle='#e8e2d0';
ctx.fillText('1🧪×'+(P.inv.potion||0),222,boxY+18);
ctx.fillText('2💣×'+(P.inv.bomb||0),222,boxY+34);
ctx.fillStyle='#9a927c';ctx.fillText('quick',272,boxY+26);
// boss bar
const boss=enemies.find(e=>e.t==='boss'&&!e.dead);
if(boss){
const bw=Math.min(460,VW-120);
ctx.fillStyle='rgba(0,0,0,.6)';ctx.fillRect(VW/2-bw/2-2,16,bw+4,18);
ctx.fillStyle='#5a1015';ctx.fillRect(VW/2-bw/2,18,bw,14);
ctx.fillStyle=boss.hp<boss.maxHp*.5?'#ff7040':'#e5484d';
ctx.fillRect(VW/2-bw/2,18,bw*boss.hp/boss.maxHp,14);
ctx.font='11px Georgia,serif';ctx.fillStyle='#ffd9c0';ctx.textAlign='center';
ctx.fillText('— THE ASH KING —',VW/2,29);ctx.textAlign='left';
}
// interact prompt
const it=nearestInteract();
if(it&&G.state==='play'){
const sx=it.x-cam.x,sy=it.y-cam.y-34;
ctx.font='12px sans-serif';
const tw=ctx.measureText('[E] '+it.label).width;
ctx.fillStyle='rgba(10,12,18,.8)';ctx.fillRect(sx-tw/2-7,sy-12,tw+14,19);
ctx.strokeStyle='#d8b34a66';ctx.strokeRect(sx-tw/2-6.5,sy-11.5,tw+13,18);
ctx.fillStyle='#ffe9b0';ctx.textAlign='center';ctx.fillText('[E] '+it.label,sx,sy+2);ctx.textAlign='left';
}
// vignette
const vg=ctx.createRadialGradient(VW/2,VH/2,Math.min(VW,VH)*.42,VW/2,VH/2,Math.max(VW,VH)*.72);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,.42)');
ctx.fillStyle=vg;ctx.fillRect(0,0,VW,VH);
}
function drawMini(){
mctx.clearRect(0,0,160,102);
const sx=160/MW,sy=102/MH;
mctx.fillStyle='#101820';mctx.fillRect(0,0,160,102);
// zones
mctx.fillStyle='#2e3240';mctx.fillRect(24*sx,0,70*sx,19*sy); // mountain
mctx.fillStyle='#3d7a4666';mctx.fillRect(0,0,160,102);
mctx.fillStyle='#8f8f98';mctx.fillRect(64*sx,20*sy,31*sx,39*sy); // ruins
mctx.fillStyle='#b09a6a';mctx.fillRect(8*sx,44*sy,23*sx,17*sy); // village
mctx.fillStyle='#2c5e34';mctx.fillRect(3*sx,6*sy,27*sx,51*sy); // forest
mctx.fillStyle='#7a2010';mctx.fillRect(42*sx,4*sy,14*sx,6*sy); // lava
// shrines
for(const s of shrines){mctx.fillStyle=G.flags.shrineIds[s.id]?'#8fd3ff':'#5a5f6e';
mctx.fillRect(s.x/TS*sx-1.5,s.y/TS*sy-1.5,3,3)}
// bosses alive
for(const e of enemies)if(!e.dead&&(e.t==='warden'||e.t==='guardian'||e.t==='boss')){
mctx.fillStyle='#e5484d';mctx.fillRect(e.x/TS*sx-1.5,e.y/TS*sy-1.5,3,3)}
// player
mctx.fillStyle='#fff';mctx.beginPath();mctx.arc(P.x/TS*sx,P.y/TS*sy,2.4,0,7);mctx.fill();
// viewport
mctx.strokeStyle='rgba(255,255,255,.25)';
mctx.strokeRect(cam.x/TS*sx,cam.y/TS*sy,VW/TS*sx,VH/TS*sy);
}
/* ---------------- floaters/projs/parts draw ---------------- */
function drawProjs(){
for(const p of projs){
const x=p.x-cam.x,y=p.y-cam.y;
if(p.type==='arrow'||p.type==='earrow'){
ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(p.vy,p.vx));
ctx.strokeStyle=p.type==='arrow'?'#c8b790':'#9fdc7f';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(-7,0);ctx.lineTo(6,0);ctx.stroke();
ctx.fillStyle='#e8e8f0';ctx.beginPath();ctx.moveTo(8,0);ctx.lineTo(4,-2);ctx.lineTo(4,2);ctx.fill();
ctx.restore();
}else{
ctx.fillStyle='rgba(255,154,60,.9)';ctx.beginPath();ctx.arc(x,y,5,0,7);ctx.fill();
ctx.fillStyle='#ffe9b0';ctx.beginPath();ctx.arc(x,y,2.4,0,7);ctx.fill();
}
}
}
function drawParts(){
for(const p of parts){
const a=1-p.t/p.life;
ctx.globalAlpha=a;ctx.fillStyle=p.c;
ctx.fillRect(p.x-cam.x-p.sz/2,p.y-cam.y-p.sz/2,p.sz,p.sz);
}
ctx.globalAlpha=1;
}
function drawFloaters(){
ctx.font='bold 13px sans-serif';ctx.textAlign='center';
for(const f of floaters){
ctx.globalAlpha=1-f.t;
ctx.fillStyle='#000';ctx.fillText(f.txt,f.x-cam.x+1,f.y-cam.y+1);
ctx.fillStyle=f.c;ctx.fillText(f.txt,f.x-cam.x,f.y-cam.y);
}
ctx.globalAlpha=1;ctx.textAlign='left';
}
/* ---------------- master draw ---------------- */
function draw(){
ctx.fillStyle='#0a0c10';ctx.fillRect(0,0,VW,VH);
ctx.save();
if(G.shake>0)ctx.translate(rnd(-G.shake,G.shake)*.6,rnd(-G.shake,G.shake)*.6);
drawWorld();
// ground-level markers (plates under blocks handled here)
drawPlatesBlocks();
drawDoorTiles();
for(const c of chests)drawChest(c);
for(const s of shrines)drawShrine(s);
for(const tb of tablets)drawTablet(tb);
for(const tt of torchesA)drawTorch(tt);
drawPedestal();
drawDummy();
for(const p of pickups)drawPickup(p);
for(const b of bombsA)drawBomb(b);
// y-sorted actors
const actors=[];
for(const e of enemies)if(!e.dead)actors.push(e);
for(const n of npcs)actors.push(n);
actors.push({_player:true,y:P.y});
actors.sort((a,b)=>a.y-b.y);
for(const a of actors){
if(a._player)drawPlayer();
else if(a.id)drawNPC(a);
else drawEnemy(a);
}
drawProjs();
drawTreeOver();
drawParts();
drawFloaters();
ctx.restore();
drawDarkness();
if(G.state!=='title')drawHUD();
drawMini();
dlgTick(1/60);
}
/* ---------------- key dispatch ---------------- */
function handleKey(code){
if(code==='KeyM'){AU.musicOn=!AU.musicOn;toast(AU.musicOn?'Music on':'Music off');return}
switch(G.state){
case 'title':
if(code==='Enter'||code==='Space'){AU.ensure();
if(hasSave())doContinue();else startNew();}
break;
case 'play':
if(code==='KeyE'||code==='Enter')tryInteract();
else if(code==='KeyI'||code==='Tab'||code==='KeyB')openInv();
else if(code==='KeyQ')cycleWeapon();
else if(code==='Digit1')usePotion();
else if(code==='Digit2')useBomb();
else if(code==='Escape')openPause();
break;
case 'dialog':
if(code==='KeyE'||code==='Enter'||code==='Space')dlgAdvance();
break;
case 'inv':
if(code==='KeyI'||code==='Tab'||code==='Escape'||code==='KeyB')closeInv();
break;
case 'shop':
if(code==='Escape')closeShop();
break;
case 'pause':
if(code==='Escape')closePause();
break;
case 'win':
if(code==='Escape'){document.getElementById('win').classList.add('hidden');G.state='play'}
break;
}
}
function openPause(){G.state='pause';document.getElementById('pauseP').classList.remove('hidden')}
function closePause(){G.state='play';document.getElementById('pauseP').classList.add('hidden')}
/* ---------------- world build & flow ---------------- */
function buildWorld(){
buildMap();buildBase();
projs=[];pickups=[];parts=[];floaters=[];bombsA=[];
dummy.wob=0;
respawnAll();
}
function startNew(){
AU.ensure();
try{localStorage.removeItem(SAVEKEY)}catch(e){}
// fresh state
P.hp=6;P.maxHp=6;P.containers=3;P.gold=20;P.arrows=0;
P.weaponIdx=0;P.weaponsOwned=[true,false,false];P.bowOwned=false;
P.inv={};P.equipped=[];P.x=16.5*TS;P.y=52*TS;P.dead=false;P.gone=false;
P.stamina=P.maxStamina;P.iframes=0;
G.stage=0;G.slimes=0;G.kills=0;G.playTime=0;G.winT=0;
G.flags={chests:{},doors:{},torches:{},wardenDead:false,guardianDead:false,bossDead:false,
puzzle:false,keyUsed:false,bowTaken:false,heart1:false,heart2:false,shrineIds:{}};
for(const c of chests)c.opened=false;
for(const d of doors)d.open=false;
for(const t of torchesA)t.lit=false;
for(const b of blocksA){b.moving=false}
blocksA[0].tx=83;blocksA[0].ty=50;blocksA[0].x=83*TS+16;blocksA[0].y=50*TS+16;
blocksA[1].tx=86;blocksA[1].ty=51;blocksA[1].x=86*TS+16;blocksA[1].y=51*TS+16;
blocksA[2].tx=80;blocksA[2].ty=52;blocksA[2].x=80*TS+16;blocksA[2].y=52*TS+16;
pedestal.taken=false;
G.respawn={x:16.5*TS,y:52*TS};
buildWorld();
setStage(0);
document.getElementById('title').classList.add('hidden');
G.state='play';
cam.x=clamp(P.x-VW/2,0,MW*TS-VW);cam.y=clamp(P.y-VH/2,0,MH*TS-VH);
showDialogue('The Vale',["A rusty sword at your hip. Three hearts beating. The village shrine glows behind you.",
"WASD to move · J/Z/Space to attack · K to dodge · E to interact."],null);
}
function doContinue(){
AU.ensure();
if(loadGame()){
document.getElementById('title').classList.add('hidden');
G.state='play';
toast('Welcome back, traveler.');
}else startNew();
}
/* ---------------- main loop ---------------- */
let last=0;
function loop(t){
requestAnimationFrame(loop);
const dt=Math.min(.05,(t-last)/1000||.016);last=t;
if(G.state==='play')update(dt);
else if(G.state==='dialog'||G.state==='inv'||G.state==='shop'){
G.time+=dt;updateParts(dt);
}
draw();
for(const k in pressed)pressed[k]=false;
}
function init(){
buildWorld();
setStage(0);
document.getElementById('btnNew').onclick=startNew;
const bc=document.getElementById('btnCont');
if(hasSave()){bc.disabled=false;bc.onclick=doContinue}
document.getElementById('btnRise').onclick=respawnPlayer;
document.getElementById('btnResume').onclick=closePause;
document.getElementById('btnQuit').onclick=()=>{
document.getElementById('pauseP').classList.add('hidden');
G.state='title';document.getElementById('title').classList.remove('hidden');
};
document.getElementById('btnWinClose').onclick=()=>{
document.getElementById('win').classList.add('hidden');G.state='play';
};
window.addEventListener('pointerdown',()=>AU.ensure(),{once:true});
requestAnimationFrame(loop);
}
window.G=G;window.P=P;window.AU=AU;
init();
</script>
</body>
</html>