RPG
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 6099745e01
Objective: Design the core mechanics and gameplay loop for a short, action-adventure RPG built in an HTML format, focused on exploration, combat, item collection, and progression. The experience should feel rewarding, balanced, and highly interactive within a timeframe of approximately one hour of focused gameplay. Core Gameplay Focus: Dynamic interaction between player movement, combat, environmental puzzle-solving, inventory management, and resource acquisition. Emphasize the feeling of discovery and tangible equipment upgrades. Mechanics & Systems Requirements: 1. Player State & Visuals: Character Representation: The game must feature visual feedback for the player character that dynamically changes based on their current equipment (e.g., displaying different weapons, walking animations reflecting stamina/movement style, and explicit damage indicators). Health System: Health must be tracked visually using shapes or symbols (e.g., hearts). Each shape must represent two points of health. The player starts with 3 containers and must achieve a total of 5 containers through gameplay. 2. Combat & Progression: Weapon Evolution: The player begins with a primary weapon. There must be exactly two distinct upgrades available for this main weapon, which significantly alter its abilities (damage type, attack speed, or range). Ranged Discovery: A ranged weapon system must be introduced and discoverable only after the player reaches the halfway point of the game's progression (mid-game discovery). 3. Inventory & Economy: Inventory System: Implement a functional inventory where players can collect and manage items. The player must acquire at least five distinct accessory or consumable items through exploration, combat, or purchases. Money System: A currency system is required. Players earn money through pickups and complete objectives. This money is used to purchase items from an in-world item shop. 4. Audio & Visual Feedback (Polish): Sound Design: Implement distinct sound effects for all major actions: subtle walking/movement sounds, weapon attacks, taking damage, successful pickups, and interaction with the item shop. Particle Effects: Utilize particle effects judiciously to enhance gameplay moments (e.g., impact effects during combat, collecting items, or showing status changes). 5. Save System: Save Points: Implement an in-world save system, allowing the player to mark specific locations as save points. This should provide safe checkpoints for progress. Design Mandate: The final design must prioritize balanced gameplay. The progression curve (from 3 health to 5 health, and through two weapon upgrades) must feel earned, engaging, and proportional to the challenges presented in the environment. Ensure that the combination of exploration, combat, and inventory management creates a cohesive and fun action-adventure experience suitable for a one-hour session.
Inspect original source 59,213 bytes · SHA-256 72a430fb3a2c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ember Hollow</title>
<style>
html,body{margin:0;height:100%;background:#07070a;overflow:hidden}
body{display:flex;align-items:center;justify-content:center;font-family:Georgia,'Times New Roman',serif;color:#eee}
#wrap{position:relative;width:min(98vw,calc(98vh*16/9));aspect-ratio:16/9}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;background:#000}
.panel{position:absolute;inset:0;display:none;background:rgba(5,4,10,.72);align-items:center;justify-content:center;flex-direction:column}
.panel.show{display:flex}
.box{background:#1d1a24;border:3px solid #b08d4c;padding:18px 24px;min-width:460px;max-width:86%;border-radius:6px;box-shadow:0 0 30px #000}
h1{margin:4px 0 10px;color:#ffd77a;text-shadow:2px 2px #000;font-size:38px;letter-spacing:3px;text-align:center}
h2{margin:2px 0 10px;color:#ffd77a;text-shadow:2px 2px #000;text-align:center}
button{font-family:inherit;background:#3a2f1f;color:#ffe8b0;border:2px solid #b08d4c;padding:7px 14px;margin:4px;cursor:pointer;font-size:14px}
button:hover{background:#56442a}
button:disabled{opacity:.4;cursor:not-allowed}
.row{display:flex;align-items:center;gap:10px;border-bottom:1px solid #3d3324;padding:6px 2px}
.row .nm{flex:1}
.row .nm b{color:#ffe8b0}
.row .nm small{display:block;color:#aaa;font-size:12px}
.swatch{width:26px;height:26px;border:2px solid #000;flex:none}
.grid{display:grid;grid-template-columns:repeat(4,104px);gap:8px;justify-content:center}
.slot{background:#120f18;border:2px solid #4b3f2a;height:90px;padding:4px;font-size:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:#ddd;cursor:pointer}
.slot:hover{border-color:#b08d4c}
.slot.eq{border-color:#7fdc7f;box-shadow:0 0 8px #3f9f3f}
.slot.empty{opacity:.35;cursor:default}
.slot small{color:#aaa;font-size:11px}
.small{font-size:13px;color:#bbb;margin-top:8px;text-align:center;line-height:1.4}
.ctrl{font-size:13px;color:#cfc3a6;margin-top:14px;line-height:1.6;text-align:left;background:#14111a;padding:8px 12px;border:1px solid #3d3324}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="960" height="540"></canvas>
<div id="title" class="panel show">
<div class="box">
<h1>EMBER HOLLOW</h1>
<div class="small">Gather gold, find a better blade, push stones, bomb the walls, and mend your heart.</div>
<div style="text-align:center;margin-top:10px">
<button id="bNew">New Game</button>
<button id="bCont">Continue</button>
</div>
<div class="ctrl">
<b>Move</b> WASD / Arrows <b>Sprint</b> Shift (uses stamina)<br>
<b>Attack</b> Space / J <b>Fire bow</b> F <b>Interact / Talk / Save</b> E<br>
<b>Red Potion</b> 1 <b>Place Bomb</b> 2 <b>Pack</b> I <b>Advance text</b> E / Space / Enter<br>
Save crystals restore your health. If you fall, you return to your last save.
</div>
</div>
</div>
<div id="shop" class="panel">
<div class="box">
<h2>Mira's Wares</h2>
<div id="shopList"></div>
<div class="small" id="shopMsg"></div>
<div style="text-align:center"><button id="shopClose">Leave (Esc)</button></div>
</div>
</div>
<div id="inv" class="panel">
<div class="box">
<h2>Pack</h2>
<div class="grid" id="invGrid"></div>
<div class="small" id="invInfo"></div>
<div style="text-align:center"><button id="invClose">Close (I)</button></div>
</div>
</div>
<div id="end" class="panel">
<div class="box" style="text-align:center">
<h1>SHRINE AWAKENED</h1>
<div id="endText" class="small"></div>
<button id="bRestart">Return to Title</button>
</div>
</div>
</div>
<script>
(()=>{
'use strict';
const cv=document.getElementById('c'), ctx=cv.getContext('2d');
const W=960,H=540,T=32,COLS=60,ROWS=20,WORLD_W=COLS*T,WORLD_H=ROWS*T;
/* ---------- input ---------- */
const keys={}; let pressed={};
addEventListener('keydown',e=>{
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if(!keys[e.code]) pressed[e.code]=true;
keys[e.code]=true;
ensureAudio();
});
addEventListener('keyup',e=>{keys[e.code]=false;});
const tapped=(...c)=>c.some(k=>pressed[k]);
const held=(...c)=>c.some(k=>keys[k]);
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(rnd(a,b+1));
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const dist=(ax,ay,bx,by)=>Math.hypot(ax-bx,ay-by);
/* ---------- audio (synthesized, no assets) ---------- */
let AC=null, master=null;
function ensureAudio(){
if(AC){ if(AC.state==='suspended') AC.resume(); return; }
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain(); master.gain.value=0.3; master.connect(AC.destination);
}catch(e){AC=null;}
}
function tone(f,d,type='square',v=0.2,slide=0,delay=0){
if(!AC) return;
const t=AC.currentTime+delay, o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.setValueAtTime(f,t);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide),t+d);
g.gain.setValueAtTime(v,t); g.gain.exponentialRampToValueAtTime(0.001,t+d);
o.connect(g); g.connect(master); o.start(t); o.stop(t+d+0.03);
}
function noise(d,v=0.2,freq=1200,type='lowpass',delay=0){
if(!AC) return;
const t=AC.currentTime+delay, len=Math.max(1,Math.floor(AC.sampleRate*d));
const buf=AC.createBuffer(1,len,AC.sampleRate), data=buf.getChannelData(0);
for(let i=0;i<len;i++) data[i]=(Math.random()*2-1)*(1-i/len);
const s=AC.createBufferSource(); s.buffer=buf;
const f=AC.createBiquadFilter(); f.type=type; f.frequency.value=freq;
const g=AC.createGain(); g.gain.value=v;
s.connect(f); f.connect(g); g.connect(master); s.start(t);
}
const SFX={
step(v=1){ noise(0.035,0.035*v,650,'lowpass'); },
swing(){ noise(0.12,0.15,2500,'highpass'); tone(560,0.09,'triangle',0.07,-320); },
hit(){ noise(0.07,0.2,900,'lowpass'); tone(190,0.12,'square',0.09,-110); },
hurt(){ tone(320,0.3,'sawtooth',0.14,-250); noise(0.15,0.16,400); },
pickup(){ [660,880,1100].forEach((f,i)=>tone(f,0.12,'triangle',0.13,0,i*0.07)); },
coin(){ tone(1400,0.08,'sine',0.11); tone(1900,0.12,'sine',0.09,0,0.06); },
shop(){ tone(900,0.08,'square',0.08); tone(1300,0.14,'square',0.08,0,0.09); noise(0.05,0.08,3000,'highpass',0.09); },
save(){ [523,659,784,1046].forEach((f,i)=>tone(f,0.25,'sine',0.12,0,i*0.12)); },
bow(){ tone(420,0.15,'triangle',0.12,600); noise(0.05,0.08,3000,'highpass'); },
boom(){ noise(0.5,0.45,300,'lowpass'); tone(80,0.5,'sine',0.35,-60); },
click(){ tone(1200,0.03,'square',0.07); tone(600,0.08,'triangle',0.07,0,0.03); },
enemyDie(){ tone(420,0.25,'square',0.09,-300); noise(0.12,0.09,1500,'bandpass'); },
roar(){ tone(90,0.9,'sawtooth',0.16,-40); noise(0.8,0.15,200); },
telegraph(){ tone(260,0.35,'triangle',0.08,200); },
fail(){ tone(200,0.4,'triangle',0.18,-120); },
wind(){ noise(0.3,0.1,800,'bandpass'); },
fanfare(){ [523,659,784,1046,1318].forEach((f,i)=>tone(f,0.35,'triangle',0.13,0,i*0.13)); },
ui(){ tone(700,0.05,'triangle',0.07); },
stomp(){ noise(0.4,0.4,220,'lowpass'); tone(70,0.4,'sine',0.3,-30); }
};
/* ---------- data ---------- */
const WEAPONS=[
{name:'Iron Sword', dmg:2, cd:0.38, range:34, blade:'#d9e2ec', tunic:'#3a6fb0', fire:false, desc:'Balanced reach and power.'},
{name:'Swift Blade',dmg:1, cd:0.14, range:30, blade:'#6ff0ff', tunic:'#2aa198', fire:false, desc:'Blistering speed, short reach.'},
{name:'Flamebrand', dmg:3, cd:0.52, range:50, blade:'#ff7a2a', tunic:'#b8452c', fire:true, desc:'Heavy fire blows; foes burn.'}
];
const ITEMS={
potion:{name:'Red Potion',kind:'use',desc:'Restores 4 points (2 hearts). Quick key 1.',color:'#e03a4a'},
bomb: {name:'Bomb', kind:'use',desc:'Place with key 2. Hurts foes and cracks walls.',color:'#333'},
boots: {name:'Swift Boots',kind:'acc',desc:'+30% movement speed.',color:'#7fd4ff'},
thorn: {name:'Thorn Ring', kind:'acc',desc:'Reflects 1 damage to any foe that hits you.',color:'#5fd35f'},
charm: {name:'Stamina Charm',kind:'acc',desc:'Sprint costs less stamina and it refills faster.',color:'#ffd65a'}
};
const SHOP=[
{id:'potion',price:15,n:1},
{id:'bomb', price:25,n:3},
{id:'charm', price:45,n:1},
{id:'boots', price:60,n:1},
{id:'thorn', price:70,n:1}
];
const ENEMY={
slime:{hp:3,dmg:1,spd:34,r:10,color:'#5fd35f',gold:[1,3],heart:0.15},
bat: {hp:2,dmg:1,spd:70,r:9,color:'#8a6fc0',gold:[2,3],heart:0.1},
skel: {hp:6,dmg:2,spd:52,r:11,color:'#d8d2c0',gold:[3,5],heart:0.2},
warden:{hp:32,dmg:2,spd:30,r:24,color:'#4f7a3a',boss:true,name:'Thornwarden'},
golem:{hp:56,dmg:2,spd:24,r:28,color:'#8a8794',boss:true,name:'Stone Golem'}
};
const CHEST_DEFS=[
{id:'swift',x:23,y:3,kind:'weapon',val:1,label:'Swift Blade'},
{id:'bombs',x:23,y:16,kind:'item',val:'bomb',n:3,label:'3 Bombs and 10 gold'},
{id:'thorn',x:31,y:16,kind:'item',val:'thorn',n:1,label:'Thorn Ring'},
{id:'charm',x:38,y:3,kind:'item',val:'charm',n:1,label:'Stamina Charm'},
{id:'boots',x:20,y:9,kind:'item',val:'boots',n:1,label:'Swift Boots'},
{id:'flame',x:44,y:15,kind:'weapon',val:2,label:'Flamebrand'},
{id:'bow',x:32,y:3,kind:'bow',val:0,label:"Hunter's Bow",dyn:true}
];
const NPCS=[
{id:'elder',x:10,y:11,name:'Elder Brannoc',color:'#c9a0ff'},
{id:'merchant',x:5,y:8,name:'Mira',color:'#ffb050'}
];
const SPAWNS=[
['s1','slime',18,5],['s2','slime',22,8],['s3','bat',24,15],['s4','slime',17,16],['s5','bat',19,6],
['s6','slime',30,11],['s7','bat',32,14],['s8','slime',29,15],['boss1','warden',30,4],
['k1','skel',38,5],['k2','skel',40,9],['k3','skel',46,15],['k4','bat',52,13],['k5','skel',49,11],
['golem','golem',52,4]
];
const COINS=[[10,5,2],[12,8,1],[20,5,2],[28,10,1],[24,12,1],[40,4,2],[50,12,2],[55,5,1],[43,15,2],[9,14,1]];
const MILESTONES=['elder','puzzle','swift','bomb','flame','boss1','golem','shrine'];
const HALF=MILESTONES.length/2;
const SAVE_KEY='emberhollow_save_v2';
/* ---------- world map ---------- */
// # wall g grass p path d arena floor r ruins floor T tree w water H house
// B cracked wall G gate P pressure plate S save crystal A altar
let map=[];
function fill(x1,y1,x2,y2,c){for(let y=y1;y<=y2;y++)for(let x=x1;x<=x2;x++)map[y][x]=c;}
function setT(x,y,c){map[y][x]=c;}
function buildMap(){
map=[];
for(let y=0;y<ROWS;y++){map.push([]);for(let x=0;x<COLS;x++)map[y].push('#');}
fill(1,2,13,17,'g');
fill(3,3,6,6,'H');
setT(10,3,'T');setT(12,3,'T');setT(12,15,'T');setT(2,16,'T');setT(11,6,'T');
setT(8,15,'S');
fill(14,2,14,17,'#'); setT(14,9,'p'); setT(14,10,'p');
fill(15,2,25,17,'g');
setT(16,2,'T');setT(17,2,'T');setT(24,3,'T');setT(15,16,'T');setT(25,8,'T');
fill(19,13,22,15,'w');
setT(21,11,'P');
setT(16,15,'S');
fill(26,2,26,17,'#'); fill(26,8,26,12,'G');
fill(27,7,34,17,'g');
setT(30,14,'T');setT(31,14,'T');setT(33,16,'T');setT(28,16,'T');
fill(27,2,34,6,'d');
fill(35,2,35,17,'#'); fill(35,8,35,11,'B');
fill(36,2,58,17,'r');
fill(44,2,58,8,'d');
fill(40,12,40,13,'#'); fill(47,14,48,14,'#'); fill(52,11,53,11,'#');
setT(38,15,'S');
setT(56,15,'A');
if(S && S.flags.puzzle) fill(26,8,26,12,'g');
if(S && S.flags.bomb) fill(35,8,35,11,'r');
}
/* ---------- state ---------- */
let S=null, P=null, mode='title';
let enemies=[], pickups=[], particles=[], texts=[], arrows=[], enemyShots=[], bombs=[], waves=[], blocks=[], chestsRT=[];
let toastMsg=null, toastT=0, dlg=[], dlgAfter=null, deadT=0, shakeT=0, shakeAmt=0, flashA=0;
let camX=0, camY=0, time=0, lastSave=null, mapCache=null;
function progress(){return MILESTONES.filter(k=>S.flags[k]).length;}
function toast(msg){toastMsg=msg;toastT=3.2;}
function addText(x,y,s,c){texts.push({x,y,s,c,t:0.9});}
function burst(x,y,n,color,spd=120,life=0.5,size=3,grav=0){
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2, s=Math.random()*spd;
particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:life*rnd(0.6,1.2),max:life,c:color,sz:size*rnd(0.5,1.4),g:grav});
}
if(particles.length>500) particles.splice(0,particles.length-500);
}
function say(lines,after){dlg=lines.slice();dlgAfter=after||null;mode='dialog';}
function advanceDialog(){
dlg.shift();
if(!dlg.length){ mode='play'; const f=dlgAfter; dlgAfter=null; if(f) f(); }
}
function setFlag(k){
if(S.flags[k]) return;
S.flags[k]=true;
if(k==='puzzle') fill(26,8,26,12,'g');
if(k==='bomb') fill(35,8,35,11,'r');
renderMapCache();
const p=progress();
if(p===HALF){
toast("Halfway! Something glints in the Thornwarden's den...");
if(!S.chests.includes('bow') && !chestsRT.some(c=>c.id==='bow')){
const d=CHEST_DEFS.find(c=>c.id==='bow');
chestsRT.push({...d,x:d.x*T+T/2,y:d.y*T+T/2,bob:0});
}
} else toast('Milestone reached ('+p+'/'+MILESTONES.length+')');
SFX.fanfare();
}
/* ---------- collision ---------- */
const SOLID=new Set(['#','T','w','H','B','G']);
function tileSolid(tx,ty){
if(tx<0||ty<0||tx>=COLS||ty>=ROWS) return true;
if(SOLID.has(map[ty][tx])) return true;
for(const b of blocks) if(b.x===tx&&b.y===ty) return true;
return false;
}
function rectSolid(x,y,hw,hh){
const x1=Math.floor((x-hw)/T), x2=Math.floor((x+hw-0.01)/T);
const y1=Math.floor((y-hh)/T), y2=Math.floor((y+hh-0.01)/T);
for(let ty=y1;ty<=y2;ty++)for(let tx=x1;tx<=x2;tx++) if(tileSolid(tx,ty)) return true;
return false;
}
function moveBody(e,dx,dy,hw,hh){
e.hitX=false; e.hitY=false;
if(dx){ const nx=e.x+dx; if(!rectSolid(nx,e.y,hw,hh)) e.x=nx; else e.hitX=true; }
if(dy){ const ny=e.y+dy; if(!rectSolid(e.x,ny,hw,hh)) e.y=ny; else e.hitY=true; }
}
/* ---------- map cache (static tiles drawn once; rebuilt on change) ---------- */
function hash(x,y){let h=x*374761393+y*668265263;h=(h^(h>>>13))*1274126177;return ((h^(h>>>16))>>>0)/4294967295;}
function renderMapCache(){
if(!mapCache){mapCache=document.createElement('canvas');mapCache.width=WORLD_W;mapCache.height=WORLD_H;}
const g=mapCache.getContext('2d');
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
const c=map[y][x], px=x*T, py=y*T, h=hash(x,y);
let base={'#':'#1b1a22',g:'#3f6e3b',p:'#8c7556',d:'#3b3346',r:'#5a544d',T:'#3f6e3b',w:'#2d5d88',H:'#6b4a2c',B:'#5f574f',G:'#7a4b22',P:'#4a4f5c',S:'#2e5a52',A:'#4a4a58'}[c]||'#222';
g.fillStyle=base; g.fillRect(px,py,T,T);
if(c==='g'||c==='T'){ g.fillStyle=h>0.5?'#4a7d44':'#376634'; g.fillRect(px+ (h*20|0),py+(h*14|0),3,3); }
if(c==='p'){ g.fillStyle='#9c8564'; g.fillRect(px+4,py+(h*20|0),8,3); }
if(c==='d'){ g.fillStyle='#4a3e58'; g.fillRect(px+6,py+6,T-12,T-12); }
if(c==='r'){ g.strokeStyle='#463f38'; g.strokeRect(px+0.5,py+0.5,T-1,T-1); }
if(c==='#'){ g.fillStyle='#2a2832'; g.fillRect(px,py,T,6); g.fillStyle='#0f0e13'; g.fillRect(px,py+T-4,T,4); }
if(c==='w'){ g.fillStyle='#4a86b8'; g.fillRect(px+(h*18|0),py+10,8,2); }
if(c==='B'){ g.strokeStyle='#2c2a26'; g.beginPath(); g.moveTo(px+4,py+4); g.lineTo(px+18,py+14); g.lineTo(px+12,py+28); g.stroke(); }
if(c==='G'){ g.strokeStyle='#4f2e12'; g.strokeRect(px+4,py+2,T-8,T-4); g.fillStyle='#caa15a'; g.fillRect(px+14,py+14,4,4); }
if(c==='P'){ g.fillStyle='#e0c44d'; g.beginPath(); g.arc(px+16,py+16,8,0,7); g.fill(); }
if(c==='H'){ g.fillStyle='#8c3c2a'; g.fillRect(px,py,T,10); g.fillStyle='#e9d9b0'; g.fillRect(px+8,py+14,16,12); }
if(c==='A'){ g.fillStyle='#8a86a0'; g.fillRect(px+6,py+14,20,12); g.fillStyle='#c9bfff'; g.fillRect(px+10,py+8,12,6); }
if(c==='S'){ g.fillStyle='#5a8a88'; g.fillRect(px+10,py+22,12,6); }
}
// tree canopies drawn over grass
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if(map[y][x]==='T'){
const px=x*T+T/2, py=y*T+T/2;
g.fillStyle='#2c5a2b'; g.beginPath(); g.arc(px,py-2,T*0.5,0,7); g.fill();
g.fillStyle='#3e7a3a'; g.beginPath(); g.arc(px-4,py-6,T*0.25,0,7); g.fill();
}
}
/* ---------- gameplay logic ---------- */
const maxHp=()=>S.containers*2;
function newState(){
return {containers:3,hp:6,gold:0,inv:{potion:1,bomb:0,boots:0,thorn:0,charm:0},eq:[],
tier:0,hasBow:false,flags:{},killed:[],chests:[],x:4.5*T,y:13.5*T,blocks:[{x:17,y:11}],stam:100};
}
function makeEnemy(id,type,x,y){
const d=ENEMY[type];
return {id,type,x,y,hp:d.hp,maxHp:d.hp,r:d.r,color:d.color,dead:false,flash:0,
kx:0,ky:0,kT:0,burn:0,burnAcc:0,t:rnd(0,6),wanderX:rnd(-1,1),wanderY:rnd(-1,1),wanderT:rnd(0.5,2),
state:'idle',stT:0,contactCd:0,phase:0,dashX:0,dashY:0,shotT:rnd(1,2),iframes:0};
}
function resetWorld(){
buildMap();
blocks=(S.blocks||[{x:17,y:11}]).map(b=>({x:b.x,y:b.y}));
enemies=[];
for(const [id,type,tx,ty] of SPAWNS){
if(S.killed.includes(id)) continue;
enemies.push(makeEnemy(id,type,tx*T+T/2,ty*T+T/2));
}
pickups=[];
for(const [cx,cy,v] of COINS){
const id='c'+cx+'_'+cy;
if(S.killed.includes(id)) continue;
pickups.push({id,kind:'coin',x:cx*T+T/2,y:cy*T+T/2,v,bob:rnd(0,6),vx:0,vy:0});
}
chestsRT=[];
for(const c of CHEST_DEFS){
if(S.chests.includes(c.id)) continue;
if(c.dyn && progress()<HALF) continue;
chestsRT.push({...c,x:c.x*T+T/2,y:c.y*T+T/2,bob:rnd(0,6)});
}
particles=[]; texts=[]; arrows=[]; enemyShots=[]; bombs=[]; waves=[];
P={x:S.x,y:S.y,fx:0,fy:1,anim:0,moving:false,sprinting:false,stam:S.stam??100,
atkCd:0,atkT:0,atkDur:0.16,swingAng:1.57,bowCd:0,iframes:0,hurtT:0,stepT:0,pushCd:0,kx:0,ky:0,kT:0,deadFlash:0};
S.hp=Math.min(S.hp,maxHp());
renderMapCache();
}
function addItem(id,n=1){
if(ITEMS[id].kind==='acc'){
S.inv[id]=1;
if(!S.eq.includes(id)) S.eq.push(id);
} else S.inv[id]=(S.inv[id]||0)+n;
}
function toggleEquip(id){
const i=S.eq.indexOf(id);
if(i>=0){S.eq.splice(i,1); SFX.ui(); return;}
if(S.eq.length>=2){ toast('Only two accessory slots. Unequip one first.'); SFX.fail(); return; }
S.eq.push(id); SFX.ui();
}
function usePotion(){
if(!S.inv.potion){ toast('No potions left.'); SFX.fail(); return; }
if(S.hp>=maxHp()){ toast('Health already full.'); SFX.fail(); return; }
S.inv.potion--; S.hp=Math.min(maxHp(),S.hp+4);
SFX.pickup(); burst(P.x,P.y-10,24,'#ff6b7a',100,0.7,3,-60);
addText(P.x,P.y-26,'+2 hearts','#7dff9c');
}
function playerHurt(dmg,sx,sy){
if(P.iframes>0||mode!=='play') return;
S.hp=Math.max(0,S.hp-dmg); P.iframes=1.0; P.hurtT=0.35;
shakeT=0.25; shakeAmt=5; flashA=0.35;
SFX.hurt();
burst(P.x,P.y,14,'#ff3b3b',120,0.45,3);
addText(P.x,P.y-22,dmg===1?'-1 pt':'-'+dmg/2+' heart'+(dmg>2?'s':''),'#ff6666');
if(sx!==undefined){
const l=Math.hypot(sx,sy)||1; P.kx=sx/l*150; P.ky=sy/l*150; P.kT=0.18;
}
if(S.hp<=0) die();
}
function die(){
mode='dead'; deadT=2.2; SFX.fail();
burst(P.x,P.y,30,'#aa2222',160,0.9,4);
}
function damageEnemy(e,dmg,fire,thornHit){
if(e.dead) return;
e.hp-=dmg; e.flash=0.12;
addText(e.x,e.y-e.r-6,'-'+dmg,fire?'#ffae3a':'#ffffff');
if(fire) e.burn=2.5;
const boss=ENEMY[e.type].boss;
if(!thornHit){
const dx=e.x-P.x, dy=e.y-P.y, l=Math.hypot(dx,dy)||1, k=boss?40:150;
e.kx=dx/l*k; e.ky=dy/l*k; e.kT=0.12;
SFX.hit();
} else { burst(e.x,e.y,4,'#5fd35f',60,0.25,2); }
burst(e.x,e.y,fire?10:6,fire?'#ff8a3a':'#ffe9a8',110,0.35,3);
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.dead=true; S.killed.push(e.id);
const def=ENEMY[e.type];
SFX.enemyDie();
burst(e.x,e.y,22,e.color,150,0.6,4);
if(def.boss) return bossDown(e);
const n=ri(def.gold[0],def.gold[1]);
for(let i=0;i<n;i++) pickups.push({id:null,kind:'coin',x:e.x,y:e.y,v:1,bob:rnd(0,6),vx:rnd(-50,50),vy:rnd(-70,-20)});
if(Math.random()<def.heart) pickups.push({id:null,kind:'heart',x:e.x,y:e.y,v:2,bob:0,vx:0,vy:-40});
}
function bossDown(e){
shakeT=0.6; shakeAmt=10; SFX.fanfare();
if(e.id==='boss1'){
S.gold+=30; S.containers=Math.max(S.containers,4); S.hp=maxHp();
setFlag('boss1');
toast('Heart container! Max health is now 4 hearts. (4/5 containers)');
}
if(e.id==='golem'){
S.gold+=60; S.containers=Math.max(S.containers,5); S.hp=maxHp();
setFlag('golem');
toast('Heart container! Max health is now 5 hearts. The shrine stirs.');
}
burst(e.x,e.y,60,'#ffd65a',220,1,5);
}
function openChest(c){
S.chests.push(c.id);
burst(c.x,c.y,30,'#ffd65a',160,0.8,4);
SFX.pickup();
chestsRT=chestsRT.filter(x=>x!==c);
if(c.kind==='weapon'){
S.tier=Math.max(S.tier,c.val);
setFlag(c.id);
say([['Found','You found the '+c.label+'!'],['Found',c.id==='swift'?'Quick strikes: attacks come faster but reach is shorter.':'Fire damage that burns foes, with a longer reach. Slower swings.']]);
} else if(c.kind==='bow'){
S.hasBow=true; setFlag('bow');
say([['Found',"A Hunter's Bow! Press F to loose arrows at range."],['Found','Ranged foes and far-off switches are yours to handle now.']]);
} else {
if(c.id==='bombs'){ addItem('bomb',3); S.gold+=10; }
else addItem(c.val,c.n||1);
const it=ITEMS[c.val];
say([['Found','You found: '+(c.n>1?c.n+' ':'')+it.name+'!'+(c.id==='bombs'?' Plus 10 gold.':'')],
['Found',it.desc+(it.kind==='acc'?' Equip it from the pack (I).':'')]]);
}
}
function setSaveOnCrystal(tx,ty){
S.x=tx*T+T/2; S.y=ty*T+T/2+6;
S.hp=maxHp(); S.stam=100;
S.blocks=blocks.map(b=>({x:b.x,y:b.y}));
const js=JSON.stringify(S);
lastSave=js;
try{ localStorage.setItem(SAVE_KEY,js); }catch(e){}
SFX.save();
burst(tx*T+T/2,ty*T+T/2,40,'#8ef0ff',140,0.9,3);
toast('Game saved. Health restored.');
}
function placeBomb(){
if(!S.inv.bomb){ toast('No bombs. Buy them from Mira or find them.'); SFX.fail(); return; }
const tx=Math.floor((P.x+P.fx*T*0.9)/T), ty=Math.floor((P.y+P.fy*T*0.9)/T);
if(tileSolid(tx,ty)||bombs.some(b=>b.tx===tx&&b.ty===ty)){ SFX.fail(); return; }
S.inv.bomb--;
bombs.push({tx,ty,x:tx*T+T/2,y:ty*T+T/2,t:1.4});
SFX.click();
}
function explode(b){
SFX.boom(); shakeT=0.4; shakeAmt=9;
burst(b.x,b.y,50,'#ffb040',240,0.7,5);
burst(b.x,b.y,25,'#777',120,0.9,4,40);
waves.push({x:b.x,y:b.y,t:0,max:0.45});
for(const e of enemies) if(!e.dead && dist(e.x,e.y,b.x,b.y)<70) damageEnemy(e,3,false,true);
const d=dist(P.x,P.y,b.x,b.y);
if(d<70) playerHurt(2,P.x-b.x,P.y-b.y);
let broke=false;
for(let y=b.ty-2;y<=b.ty+2;y++)for(let x=b.tx-2;x<=b.tx+2;x++){
if(x<0||y<0||x>=COLS||y>=ROWS) continue;
if(map[y][x]==='B'){ map[y][x]='r'; broke=true; burst(x*T+16,y*T+16,14,'#c8b99a',140,0.6,4,60); }
}
if(broke){ setFlag('bomb'); toast('The cracked wall crumbles! The ruins are open.'); }
renderMapCache();
}
function fireArrow(){
if(!S.hasBow||P.bowCd>0) return;
P.bowCd=0.5; SFX.bow();
arrows.push({x:P.x+P.fx*12,y:P.y+P.fy*12,vx:P.fx*440,vy:P.fy*440,t:1.1,dmg:2,ang:Math.atan2(P.fy,P.fx)});
}
function playerAttack(){
const w=WEAPONS[S.tier];
P.atkCd=w.cd; P.atkT=0.16; P.atkDur=0.16; SFX.swing();
P.swingAng=Math.atan2(P.fy,P.fx);
let hit=false;
for(const e of enemies){
if(e.dead) continue;
const dx=e.x-P.x, dy=e.y-P.y, d=Math.hypot(dx,dy);
if(d<w.range+e.r){
const dot=(dx*P.fx+dy*P.fy)/Math.max(d,1);
if(dot>0.3){ damageEnemy(e,w.dmg,w.fire,false); hit=true; }
}
}
const sx=P.x+P.fx*w.range*0.7, sy=P.y+P.fy*w.range*0.7;
burst(sx,sy,hit?6:3,hit?(w.fire?'#ff8a3a':'#fff'):'#cfd8e6',90,0.25,3);
}
function tryPush(sx,sy){
const px=P.x+sx*12, py=P.y+sy*12;
const tx=Math.floor(px/T), ty=Math.floor(py/T);
const b=blocks.find(b=>b.x===tx&&b.y===ty);
if(!b) return;
const nx=tx+sx, ny=ty+sy;
if(tileSolid(nx,ny)) return;
if(enemies.some(e=>!e.dead && Math.floor(e.x/T)===nx && Math.floor(e.y/T)===ny)) return;
b.x=nx; b.y=ny; P.pushCd=0.25;
SFX.step(1.8);
burst(nx*T+16,ny*T+16,4,'#8a8a94',50,0.3,3);
renderMapCache();
if(map[ny][nx]==='P' && !S.flags.puzzle){ setFlag('puzzle'); S.gold+=15; toast('A click from the pond... the gate groans open. (+15 gold)'); }
}
function interact(){
for(const n of NPCS){
if(dist(P.x,P.y,n.x*T+T/2,n.y*T+T/2)<46){
if(n.id==='elder') talkElder();
else openShop();
return true;
}
}
for(const c of chestsRT){
if(dist(P.x,P.y,c.x,c.y)<36){ openChest(c); return true; }
}
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
if(map[y][x]==='S' && dist(P.x,P.y,x*T+T/2,y*T+T/2)<40){ setSaveOnCrystal(x,y); return true; }
}
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
if(map[y][x]==='A' && dist(P.x,P.y,x*T+T/2,y*T+T/2)<46){ altar(); return true; }
}
return false;
}
const HINTS=[
'Push the loose stone in the western woods onto the pressure plate by the pond.',
'The Thornwarden sleeps in the den past the gate. Its gate opens when the plate is pressed.',
'Iron is for common foes. The Swift Blade lies in the woods to the north.',
'Cracked walls break under bombs. Bombs are sold by Mira, found in chests.',
'Flamebrand waits in the ruins to the south-east. Its fire burns.',
'The Golem sleeps in the ruined hall to the north-east.',
'The shrine altar wakes only when the Golem falls.'
];
function talkElder(){
if(!S.flags.elder){
say([
['Elder Brannoc','Ah, a new face. The Thornwarden has choked the hollow for years.'],
['Elder Brannoc','Push the loose stone in the western woods onto the pressure plate by the pond. The gate beyond will open.'],
['Elder Brannoc','Behind it lies the Thornwarden. Cracked walls beyond its den hide the ruins.'],
['Elder Brannoc','Mira sells supplies. Save at the crystals. Mind your heart, traveler.']
],()=>{ setFlag('elder'); S.gold+=5; toast('Quest started. (+5 gold)'); });
} else {
const idx=Math.max(0,Math.min(HINTS.length-1,progress()-1));
say([['Elder Brannoc',HINTS[idx]]]);
}
}
function altar(){
if(!S.flags.golem){
say([['Shrine','A stone altar hums. Its sockets are empty and cold. Something still guards the hollow.']]);
return;
}
setFlag('shrine'); S.gold+=100;
mode='end'; SFX.fanfare();
document.getElementById('endText').innerHTML=
'The shrine blazes and the hollow breathes again.<br><br>Containers: '+S.containers+'/5 Gold: '+S.gold+
'<br>Milestones: '+progress()+'/'+MILESTONES.length+'<br>Weapon: '+WEAPONS[S.tier].name+(S.hasBow?" + Hunter's Bow":'');
document.getElementById('end').classList.add('show');
}
/* ---------- enemy AI ---------- */
function updateEnemy(e,dt){
const d=ENEMY[e.type]; e.t+=dt; e.flash=Math.max(0,e.flash-dt);
e.contactCd=Math.max(0,e.contactCd-dt);
e.iframes=Math.max(0,e.iframes-dt);
if(e.burn>0){
e.burn-=dt; e.burnAcc+=dt;
if(e.burnAcc>0.5){ e.burnAcc=0; e.hp-=1; e.flash=0.08; addText(e.x,e.y-e.r-4,'-1','#ff9a3a'); burst(e.x,e.y,3,'#ff7a2a',60,0.3,2);
if(e.hp<=0){ killEnemy(e); return; } }
}
let vx=0, vy=0;
const dxp=P.x-e.x, dyp=P.y-e.y, dp=Math.hypot(dxp,dyp)||1;
const aggro = dp<170;
if(e.kT>0){ e.kT-=dt; vx=e.kx; vy=e.ky; }
else if(e.type==='bat'){
if(aggro){ vx=dxp/dp*d.spd; vy=dyp/dp*d.spd; }
else { e.wanderT-=dt; if(e.wanderT<=0){e.wanderX=rnd(-1,1);e.wanderY=rnd(-1,1);e.wanderT=rnd(0.6,1.6);} vx=e.wanderX*30; vy=e.wanderY*30; }
}
else if(e.type==='slime'){
e.hopT=(e.hopT||0)+dt;
const hop=Math.sin(e.t*5)>0.6;
if(aggro && hop){ vx=dxp/dp*d.spd*1.5; vy=dyp/dp*d.spd*1.5; }
else { e.wanderT-=dt; if(e.wanderT<=0){e.wanderX=rnd(-1,1);e.wanderY=rnd(-1,1);e.wanderT=rnd(0.8,2);} vx=e.wanderX*12; vy=e.wanderY*12; }
}
else if(e.type==='skel'){
if(aggro){
if(dp>60){ vx=dxp/dp*d.spd; vy=dyp/dp*d.spd; } else { vx=-dxp/dp*d.spd*0.3; vy=-dyp/dp*d.spd*0.3; }
e.shotT-=dt;
if(e.shotT<=0 && dp>80 && dp<200){
e.shotT=rnd(2.2,3.2);
const a=Math.atan2(dyp,dxp), sp=130;
enemyShots.push({x:e.x,y:e.y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,t:2.5});
SFX.click();
}
} else { e.wanderT-=dt; if(e.wanderT<=0){e.wanderX=rnd(-1,1);e.wanderY=rnd(-1,1);e.wanderT=rnd(1,2.5);} vx=e.wanderX*18; vy=e.wanderY*18; }
}
else if(e.type==='warden'){ updateWarden(e,dt,dxp,dyp,dp); return finishEnemy(e,vx,vy,dt,d); }
else if(e.type==='golem'){ updateGolem(e,dt,dxp,dyp,dp); return finishEnemy(e,vx,vy,dt,d); }
finishEnemy(e,vx,vy,dt,d);
}
function finishEnemy(e,vx,vy,dt,d){
const hw=e.r*0.8;
if(e.type==='bat'){ e.x+=vx*dt; e.y+=vy*dt; }
else moveBody(e,vx*dt,vy*dt,hw,hw);
e.x=clamp(e.x,T,WORLD_W-T); e.y=clamp(e.y,T,WORLD_H-T);
// contact damage
if(!e.dead && e.contactCd<=0 && dist(e.x,e.y,P.x,P.y)<e.r+8 && !e.boss){
e.contactCd=0.8;
playerHurt(d.dmg,P.x-e.x,P.y-e.y);
if(S.eq.includes('thorn')) damageEnemy(e,1,false,true);
}
}
function updateWarden(e,dt,dxp,dyp,dp){
// phases: 'idle' -> 'tell' -> 'dash' -> 'recover'; also thorn volley
e.stT-=dt;
if(e.hp<=e.maxHp*0.5 && !e.roared){ e.roared=true; SFX.roar(); shakeT=0.5; shakeAmt=7; toast('The Thornwarden roars! Thorns burst from the ground.'); }
if(e.state==='idle'){
if(e.stT<=0){
if(dp<200){ e.state='tell'; e.stT=0.7; e.dashX=dxp/dp; e.dashY=dyp/dp; SFX.telegraph(); e.dashX=dxp/dp; e.dashY=dyp/dp; }
else { e.stT=0.5; }
}
e.x+=Math.cos(e.t*1.2)*12*dt; e.y+=Math.sin(e.t*0.9)*12*dt;
} else if(e.state==='tell'){
e.dashX=dxp/dp; e.dashY=dyp/dp; // keep aiming until release
if(e.stT<=0){ e.state='dash'; e.stT=0.6; SFX.wind(); }
} else if(e.state==='dash'){
const sp=e.phase>0.5?360:260;
moveBody(e,e.dashX*sp*dt,e.dashY*sp*dt,e.r*0.8,e.r*0.8);
if(e.hitX||e.hitY||e.stT<=0){ e.state='recover'; e.stT=1.1; shakeT=0.15; shakeAmt=4; burst(e.x,e.y,12,'#8aa34a',110,0.5,4,50); }
if(dist(e.x,e.y,P.x,P.y)<e.r+12 && e.contactCd<=0){ e.contactCd=0.6; playerHurt(3,P.x-e.x,P.y-e.y); }
} else if(e.state==='recover'){
if(e.stT<=0){ e.state='idle'; e.stT=0.8; e.phase=e.hp/e.maxHp; }
}
if(e.contactCd>0) e.contactCd-=dt;
// thorn pulses (ring of projectiles) while enraged
if(e.roared){ e.shotT-=dt; if(e.shotT<=0){ e.shotT=2.6; for(let i=0;i<8;i++){ const a=i/8*Math.PI*2+e.t; enemyShots.push({x:e.x,y:e.y,vx:Math.cos(a)*110,vy:Math.sin(a)*110,t:3}); } SFX.wind(); } }
e.x=clamp(e.x,T*2,WORLD_W-T*2); e.y=clamp(e.y,T*2,WORLD_H-T*2);
}
function updateGolem(e,dt,dxp,dyp,dp){
e.stT-=dt;
if(e.state==='idle'){
if(e.stT<=0){
const r=Math.random();
if(r<0.5 && dp<240){ e.state='stomp'; e.stT=1.0; SFX.telegraph(); }
else { e.state='slam'; e.stT=0.9; SFX.telegraph(); }
}
const sp=e.hp<e.maxHp*0.5?42:26;
if(dp>60){ moveBody(e,dxp/dp*sp*dt,dyp/dp*sp*dt,e.r*0.8,e.r*0.8); }
} else if(e.state==='stomp'){
if(e.stT<=0){
SFX.stomp(); shakeT=0.4; shakeAmt=8;
waves.push({x:e.x,y:e.y,t:0,max:0.5,kind:'stone'});
burst(e.x,e.y,36,'#9a96a6',200,0.8,4,60);
if(dist(e.x,e.y,P.x,P.y)<110) playerHurt(4,P.x-e.x,P.y-e.y);
e.state='idle'; e.stT=1.2;
}
} else if(e.state==='slam'){
if(e.stT<=0){
SFX.stomp(); shakeT=0.3; shakeAmt=6;
for(let i=0;i<6;i++){ const a=i/6*Math.PI*2+e.t*0.4; enemyShots.push({x:e.x,y:e.y,vx:Math.cos(a)*100,vy:Math.sin(a)*100,t:2.6,rock:true}); }
e.state='idle'; e.stT=1.1;
}
}
if(e.hp<e.maxHp*0.5 && e.contactCd<=0 && dist(e.x,e.y,P.x,P.y)<e.r+8){ e.contactCd=0.8; playerHurt(2,P.x-e.x,P.y-e.y); }
e.x=clamp(e.x,T*2,WORLD_W-T*2); e.y=clamp(e.y,T*2,WORLD_H-T*2);
}
/* ---------- player update ---------- */
function updatePlayer(dt){
const sprintHeld=held('ShiftLeft','ShiftRight');
let ix=0, iy=0;
if(held('KeyA','ArrowLeft')) ix-=1;
if(held('KeyD','ArrowRight')) ix+=1;
if(held('KeyW','ArrowUp')) iy-=1;
if(held('KeyS','ArrowDown')) iy+=1;
const moving=ix||iy;
const boots=S.eq.includes('boots');
const charm=S.eq.includes('charm');
const canSprint=sprintHeld && moving && P.stam>0 && P.atkT<=0;
let sp=boots?110*1.3:110;
if(canSprint){ sp*=1.6; P.stam-=(charm?12:24)*dt; P.sprinting=true; }
else { P.sprinting=false; P.stam=Math.min(100,P.stam+(charm?40:22)*dt); }
S.stam=P.stam;
if(P.atkT>0) sp*=0.35;
if(moving){
const l=Math.hypot(ix,iy); ix/=l; iy/=l;
if(ix) P.fx=Math.sign(ix)*Math.abs(ix)>0?ix/Math.abs(ix)*0+ix:P.fx; // keep 8-way facing below
P.fx=ix; P.fy=iy;
P.moving=true;
} else P.moving=false;
P.anim+=dt*(P.sprinting?14:moving?9:0);
// knockback
let mx=ix*sp*dt, my=iy*sp*dt;
if(P.kT>0){ P.kT-=dt; mx+=P.kx*dt; my+=P.ky*dt; }
moveBody(P,mx,my,PH_HW,PH_HW);
if(moving){
P.stepT-=dt;
if(P.stepT<=0){ P.stepT=P.sprinting?0.22:0.34; SFX.step(P.sprinting?1.3:1); }
}
// facing: use last nonzero direction (already set) - if idle, keep
if(P.atkT>0) P.atkT-=dt;
P.atkCd=Math.max(0,P.atkCd-dt);
P.bowCd=Math.max(0,P.bowCd-dt);
P.iframes=Math.max(0,P.iframes-dt);
P.hurtT=Math.max(0,P.hurtT-dt);
P.pushCd=Math.max(0,P.pushCd-dt);
// Pushing blocks: if moving into a block, push it
if(moving && P.pushCd<=0){
if(P.hitX && ix) tryPush(Math.sign(ix),0);
else if(P.hitY && iy) tryPush(0,Math.sign(iy));
}
// actions
if(tapped('Space','KeyJ') && P.atkCd<=0) playerAttack();
if(tapped('KeyF')) fireArrow();
if(tapped('Digit1')) usePotion();
if(tapped('Digit2')) placeBomb();
if(tapped('KeyI')) { openInv(); }
if(tapped('KeyE')) interact();
}
const PH_HW=7;
/* ---------- world update ---------- */
function updateWorld(dt){
// enemies
for(const e of enemies){
if(e.dead) continue;
updateEnemy(e,dt);
if(e.dead) continue;
}
enemies=enemies.filter(e=>!e.dead);
// pickups
for(const p of pickups){
p.bob+=dt*4;
if(p.vx||p.vy){ p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=0.9; p.vy=(p.vy+260*dt)*0.9; }
if(dist(p.x,p.y,P.x,P.y)<22){
p.dead=true;
if(p.kind==='coin'){ S.gold+=p.v; SFX.coin(); addText(p.x,p.y-8,'+'+p.v+'g','#ffd65a'); burst(p.x,p.y,8,'#ffd65a',80,0.4,3); }
else { S.hp=Math.min(maxHp(),S.hp+p.v); SFX.pickup(); addText(p.x,p.y-8,'+heart','#ff6b7a'); burst(p.x,p.y,12,'#ff6b7a',90,0.5,3,-40); }
if(p.id) S.killed.push(p.id);
}
}
pickups=pickups.filter(p=>!p.dead);
// chests bob
for(const c of chestsRT) c.bob+=dt*3;
// bombs
for(const b of bombs){ b.t-=dt; if(b.t<=0){ b.dead=true; explode(b); } }
bombs=bombs.filter(b=>!b.dead);
// arrows
for(const a of arrows){
a.t-=dt; a.x+=a.vx*dt; a.y+=a.vy*dt;
if(a.t<=0||tileSolid(Math.floor(a.x/T),Math.floor(a.y/T))){ a.dead=true; burst(a.x,a.y,4,'#ccc',60,0.2,2); continue; }
for(const e of enemies){
if(e.dead||a.dead) continue;
if(dist(a.x,a.y,e.x,e.y)<e.r+3){ damageEnemy(e,a.dmg,false,false); a.dead=true; burst(a.x,a.y,6,'#eee',80,0.2,2); }
}
}
arrows=arrows.filter(a=>!a.dead);
// enemy shots
for(const s of enemyShots){
s.t-=dt; s.x+=s.vx*dt; s.y+=s.vy*dt;
if(s.t<=0||tileSolid(Math.floor(s.x/T),Math.floor(s.y/T))){ s.dead=true; continue; }
if(dist(s.x,s.y,P.x,P.y)<11){ s.dead=true; playerHurt(s.rock?3:2,s.x-P.x,s.y-P.y); burst(s.x,s.y,6,'#c0a090',60,0.25,2); }
}
enemyShots=enemyShots.filter(s=>!s.dead);
// waves
for(const w of waves) w.t+=dt;
waves=waves.filter(w=>w.t<w.max);
// particles
for(const p of particles){ p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=0.94; p.vy*=0.94; p.vy+=p.g*dt; }
particles=particles.filter(p=>p.life>0);
for(const t of texts){ t.t-=dt; t.y-=20*dt; }
texts=texts.filter(t=>t.t>0);
// chests interaction proximity prompt is handled in draw
// save crystals pass
}
function step(dt){
time+=dt;
if(toastT>0) toastT-=dt;
if(shakeT>0) shakeT-=dt;
if(flashA>0) flashA=Math.max(0,flashA-dt*1.5);
if(mode==='title'){ pressed={}; return; }
if(mode==='dialog'){
if(tapped('KeyE','Space','Enter')) { advanceDialog(); SFX.ui(); }
pressed={}; return;
}
if(mode==='shop'){
if(tapped('Escape','KeyE')) closeShop();
pressed={}; return;
}
if(mode==='inv'){
if(tapped('KeyI','Escape')) closeInv();
pressed={}; return;
}
if(mode==='end'){ pressed={}; return; }
if(mode==='dead'){
deadT-=dt;
if(deadT<=0) respawn();
updateWorld(dt);
pressed={}; return;
}
// play
if(tapped('Escape')) { /* no pause */ }
updatePlayer(dt);
// bomb place key check and bow and attack triggers are inside updatePlayer
updateWorld(dt);
// touch wind: knock arrows; the tapped set is cleared at the end
pressed={};
// fell in bounds
if(P.atkT>0) P.atkT=Math.max(0,P.atkT);
// camera
const tx=clamp(P.x-W/2,0,WORLD_W-W), ty=clamp(P.y-H/2,0,WORLD_H-H);
camX+=(tx-camX)*Math.min(1,dt*8); camY+=(ty-camY)*Math.min(1,dt*8);
// boss/enemy contact damage handled per enemy
// dying check
if(S.hp<=0 && mode==='play') die();
}
function respawn(){
// Return to last save point
mode='play';
if(lastSave){ const s=JSON.parse(lastSave); S=s; }
else { S=newState(); }
S.x=S.x||4.5*T; S.y=S.y||13.5*T;
S.hp=maxHp();
resetWorld();
toast('You awaken at the last save crystal.');
}
/* ---------- rendering ---------- */
function drawHeart(x,y,s,fill){ // fill: 2 full, 1 half, 0 empty
ctx.save(); ctx.translate(x,y); ctx.scale(s,s);
ctx.beginPath(); ctx.moveTo(0,4); ctx.bezierCurveTo(-8,-3,-4,-9,0,-4); ctx.bezierCurveTo(4,-9,8,-3,0,4); ctx.closePath();
ctx.fillStyle='#2a0c10'; ctx.fill();
if(fill>0){
ctx.save(); ctx.clip();
ctx.fillStyle=fill===2?'#ff3d56':'#ff3d56';
ctx.fillRect(-10,-10,fill===1?10:20,20);
ctx.fillStyle='rgba(255,255,255,.35)'; ctx.fillRect(-5,-6,3,3);
ctx.restore();
}
ctx.lineWidth=1.2; ctx.strokeStyle='#000'; ctx.stroke();
ctx.restore();
}
function drawHUD(){
// hearts: each shape = 2 points (1 heart). containers = S.containers shapes
const total=S.containers, hp=S.hp;
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(8,8,total*24+16,36);
for(let i=0;i<total;i++){
const pts=hp-i*2;
const f=pts>=2?2:pts===1?1:0;
drawHeart(24+i*24,26,1.0,f);
}
// stamina
ctx.fillStyle='rgba(0,0,0,.5)'; ctx.fillRect(8,48,120,8);
ctx.fillStyle=P.stam>25?'#7fd4ff':'#d86a3a'; ctx.fillRect(9,49,118*(P.stam/100),6);
// gold
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(W-150,8,142,28);
ctx.fillStyle='#ffd65a'; ctx.beginPath(); ctx.arc(W-136,22,7,0,7); ctx.fill();
ctx.fillStyle='#000'; ctx.beginPath(); ctx.arc(W-136,22,3,0,7); ctx.fill();
ctx.fillStyle='#fff'; ctx.font='bold 16px Georgia'; ctx.textAlign='left';
ctx.fillText(S.gold,W-120,28);
// quick items
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(W-150,42,142,28);
ctx.font='12px Georgia'; ctx.fillStyle='#ddd';
ctx.fillText('[1] Potion x'+S.inv.potion+' [2] Bomb x'+S.inv.bomb,W-144,60);
// weapon label
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(8,H-30,260,22);
ctx.font='12px Georgia'; ctx.fillStyle='#ffe8b0';
ctx.fillText(WEAPONS[S.tier].name+(S.hasBow?' | Bow [F]':'')+' | Acc: '+(S.eq.length?S.eq.map(i=>ITEMS[i].name).join(', '):'none'),14,H-15);
// progress
ctx.fillStyle='rgba(0,0,0,.45)'; ctx.fillRect(W-226,H-30,218,22);
ctx.fillStyle='#ffd77a'; ctx.fillText('Containers '+S.containers+'/5 Milestones '+progress()+'/'+MILESTONES.length,W-220,H-15);
// toast
if(toastT>0 && toastMsg){
const a=Math.min(1,toastT*2);
ctx.globalAlpha=a; ctx.fillStyle='rgba(10,8,14,.8)';
ctx.font='bold 15px Georgia';
const tw=ctx.measureText(toastMsg).width+30;
ctx.fillRect(W/2-tw/2,74,tw,28);
ctx.fillStyle='#ffe8b0'; ctx.textAlign='center'; ctx.fillText(toastMsg,W/2,93);
ctx.globalAlpha=1; ctx.textAlign='left';
}
}
function drawDialog(){
if(mode!=='dialog' || !dlg.length) return;
const d=dlg[0];
ctx.fillStyle='rgba(8,6,12,.9)'; ctx.fillRect(40,H-150,W-80,118);
ctx.strokeStyle='#b08d4c'; ctx.lineWidth=3; ctx.strokeRect(42,H-148,W-84,114);
ctx.fillStyle='#ffd77a'; ctx.font='bold 18px Georgia'; ctx.textAlign='left';
ctx.fillText(d[0],64,H-116);
ctx.fillStyle='#f0e6d0'; ctx.font='17px Georgia';
wrapText(d[1],64,H-88,W-130,24);
ctx.fillStyle='#aaa'; ctx.font='12px Georgia'; ctx.textAlign='right';
ctx.fillText((dlg.length>1?'E / Space: next':'E / Space: close'),W-64,H-40);
ctx.textAlign='left';
}
function wrapText(s,x,y,maxW,lh){
const words=s.split(' '); let line='';
for(const w of words){
const test=line+w+' ';
if(ctx.measureText(test).width>maxW && line){ ctx.fillText(line,x,y); line=w+' '; y+=lh; }
else line=test;
}
if(line) ctx.fillText(line,x,y);
}
function drawPrompt(){
if(mode!=='play') return;
let label=null, px=0, py=0;
for(const n of NPCS){ if(dist(P.x,P.y,n.x*T+T/2,n.y*T+T/2)<46){ label=n.name; px=n.x*T+T/2; py=n.y*T+T/2-30; break; } }
if(!label) for(const c of chestsRT){ if(dist(P.x,P.y,c.x,c.y)<36){ label='Open chest'; px=c.x; py=c.y-28; break; } }
if(!label) for(let y=0;y<ROWS&&!label;y++)for(let x=0;x<COLS;x++){
if(map[y][x]==='S' && dist(P.x,P.y,x*T+T/2,y*T+T/2)<40){ label='Save at crystal'; px=x*T+T/2; py=y*T+T/2-26; break; }
if(map[y][x]==='A' && dist(P.x,P.y,x*T+T/2,y*T+T/2)<46){ label='Altar'; px=x*T+T/2; py=y*T+T/2-30; break; }
}
if(label){
const sx=px-camX, sy=py-camY+Math.sin(time*5)*2;
ctx.font='bold 12px Georgia'; ctx.textAlign='center';
const tw=ctx.measureText('[E] '+label).width+14;
ctx.fillStyle='rgba(0,0,0,.7)'; ctx.fillRect(sx-tw/2,sy-14,tw,18);
ctx.fillStyle='#ffe8b0'; ctx.fillText('[E] '+label,sx,sy);
ctx.textAlign='left';
}
}
function drawGround(){
// sample camera-visible portion of the cached map
ctx.drawImage(mapCache,Math.round(-camX),Math.round(-camY));
// dynamic overlays for crystals (pulse)
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){
const c=map[y][x];
if(c==='S'){
const px=x*T-camX, py=y*T-camY;
if(px<-T||py<-T||px>W||py>H) continue;
const pulse=0.5+0.5*Math.sin(time*3+x);
ctx.fillStyle=`rgba(142,240,255,${0.15+0.15*pulse})`; ctx.beginPath(); ctx.arc(px+16,py+16,18+pulse*3,0,7); ctx.fill();
ctx.fillStyle='#8ef0ff'; ctx.beginPath(); ctx.moveTo(px+16,py+4); ctx.lineTo(px+24,py+16); ctx.lineTo(px+16,py+26); ctx.lineTo(px+8,py+16); ctx.closePath(); ctx.fill();
}
if(c==='w'){
const px=x*T-camX, py=y*T-camY;
if(px<-T||py<-T||px>W||py>H) continue;
ctx.fillStyle='rgba(200,240,255,.15)'; ctx.fillRect(px+4+Math.sin(time*2+x)*3,py+8,10,2);
}
}
}
function drawBlocks(){
for(const b of blocks){
const px=b.x*T-camX, py=b.y*T-camY;
ctx.fillStyle='#000'; ctx.fillRect(px+2,py+5,T-2,T-2);
ctx.fillStyle='#8a8794'; ctx.fillRect(px+3,py+3,T-6,T-6);
ctx.fillStyle='#b2afba'; ctx.fillRect(px+5,py+5,T-14,4);
ctx.fillStyle='#5a5860'; ctx.fillRect(px+3,py+T-7,T-6,4);
}
}
function drawChests(){
for(const c of chestsRT){
const x=c.x-camX, y=c.y-camY+Math.sin(c.bob)*1.5;
if(c.kind==='bow'){
ctx.strokeStyle='#c8a46a'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(x,y,12,-1.2,1.2); ctx.stroke();
ctx.strokeStyle='#eee'; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(x+Math.cos(-1.2)*12,y+Math.sin(-1.2)*12); ctx.lineTo(x+Math.cos(1.2)*12,y+Math.sin(1.2)*12); ctx.stroke();
ctx.fillStyle='rgba(255,215,90,.25)'; ctx.beginPath(); ctx.arc(x,y,20+Math.sin(time*4)*2,0,7); ctx.fill();
continue;
}
ctx.fillStyle='rgba(0,0,0,.4)'; ctx.fillRect(x-12,y+6,24,4);
ctx.fillStyle='#7a4b22'; ctx.fillRect(x-12,y-6,24,16);
ctx.fillStyle='#b58446'; ctx.fillRect(x-12,y-6,24,5);
ctx.fillStyle='#ffd65a'; ctx.fillRect(x-2,y-1,4,5);
ctx.strokeStyle='#2a1a0a'; ctx.strokeRect(x-12.5,y-6.5,25,17);
ctx.fillStyle='rgba(255,215,90,.18)'; ctx.beginPath(); ctx.arc(x,y,18+Math.sin(time*4)*2,0,7); ctx.fill();
}
}
function drawNPCs(){
for(const n of NPCS){
const x=n.x*T+T/2-camX, y=n.y*T+T/2-camY;
ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(x,y+10,9,4,0,0,7); ctx.fill();
ctx.fillStyle=n.id==='elder'?'#7b5bb0':'#c46a2a'; ctx.fillRect(x-7,y-6,14,16);
ctx.fillStyle='#e8c9a0'; ctx.beginPath(); ctx.arc(x,y-10,6,0,7); ctx.fill();
if(n.id==='elder'){ ctx.fillStyle='#ddd'; ctx.fillRect(x-7,y-16,14,3); }
else { ctx.fillStyle='#6a3a1a'; ctx.beginPath(); ctx.arc(x,y-12,7,Math.PI,0); ctx.fill(); }
ctx.fillStyle='#fff'; ctx.font='11px Georgia'; ctx.textAlign='center'; ctx.fillText(n.name.split(' ').pop(),x,y-22); ctx.textAlign='left';
}
}
function drawEnemy(e){
const x=e.x-camX, y=e.y-camY, d=ENEMY[e.type];
const bob=Math.sin(e.t*6)*1.5;
ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(x,y+e.r*0.9,e.r*0.9,e.r*0.35,0,0,7); ctx.fill();
const flash=e.flash>0;
if(e.type==='slime'){
ctx.fillStyle=flash?'#fff':e.color;
ctx.beginPath(); ctx.ellipse(x,y+bob,e.r,e.r*0.8,0,Math.PI,0); ctx.fill();
ctx.fillStyle='#fff'; ctx.fillRect(x-4,y-2+bob,3,3); ctx.fillRect(x+2,y-2+bob,3,3);
ctx.fillStyle='#000'; ctx.fillRect(x-3,y-1+bob,2,2); ctx.fillRect(x+3,y-1+bob,2,2);
} else if(e.type==='bat'){
const wing=Math.sin(e.t*18)*6;
ctx.fillStyle=flash?'#fff':e.color;
ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x-14,y-wing); ctx.lineTo(x-6,y+4); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+14,y-wing); ctx.lineTo(x+6,y+4); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.arc(x,y,6,0,7); ctx.fill();
ctx.fillStyle='#ff4a4a'; ctx.fillRect(x-3,y-2,2,2); ctx.fillRect(x+1,y-2,2,2);
} else if(e.type==='skel'){
ctx.fillStyle=flash?'#fff':e.color;
ctx.fillRect(x-7,y-4+bob,14,14);
ctx.beginPath(); ctx.arc(x,y-8+bob,7,0,7); ctx.fill();
ctx.fillStyle='#000'; ctx.fillRect(x-4,y-9+bob,3,3); ctx.fillRect(x+1,y-9+bob,3,3);
ctx.strokeStyle='#8a8478'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(x-6,y+4+bob); ctx.lineTo(x+6,y+10+bob); ctx.stroke();
} else if(e.type==='warden'){
const shake=e.state==='tell'?Math.sin(time*60)*2:0;
ctx.save(); ctx.translate(shake,0);
ctx.fillStyle=flash?'#fff':e.color;
ctx.beginPath(); ctx.arc(x,y,e.r,0,7); ctx.fill();
ctx.strokeStyle='#2c4a1c'; ctx.lineWidth=3;
for(let i=0;i<10;i++){ const a=i/10*Math.PI*2+e.t*0.3; ctx.beginPath(); ctx.moveTo(x+Math.cos(a)*e.r,y+Math.sin(a)*e.r); ctx.lineTo(x+Math.cos(a)*(e.r+8),y+Math.sin(a)*(e.r+8)); ctx.stroke(); }
ctx.fillStyle=e.roared?'#ff3d3d':'#ffd65a'; ctx.beginPath(); ctx.arc(x-7,y-4,3,0,7); ctx.arc(x+7,y-4,3,0,7); ctx.fill();
if(e.state==='tell'){ ctx.strokeStyle='rgba(255,80,80,.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+e.dashX*70,y+e.dashY*70); ctx.stroke(); }
ctx.restore();
} else if(e.type==='golem'){
const sh=e.state==='stomp'&&e.stT<0.3?Math.sin(time*80)*2:0;
ctx.save(); ctx.translate(sh,0);
ctx.fillStyle=flash?'#fff':e.color;
ctx.fillRect(x-e.r,y-e.r,e.r*2,e.r*2);
ctx.fillStyle='#5a5860'; ctx.fillRect(x-e.r+4,y-e.r+6,8,6); ctx.fillRect(x+6,y+2,10,8);
ctx.fillStyle=e.hp<e.maxHp*0.5?'#ff3d3d':'#ffd65a'; ctx.fillRect(x-7,y-5,4,4); ctx.fillRect(x+3,y-5,4,4);
if(e.state!=='idle'){ ctx.strokeStyle='rgba(255,160,60,.8)'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(x,y,e.r+(e.state==='stomp'?30:12)*(1-e.stT/1.0),0,7); ctx.stroke(); }
ctx.restore();
}
// HP bar for bosses & damaged enemies
if(d.boss || e.hp<e.maxHp){
const bw=d.boss?220:24, bx=d.boss?W/2-110:x-bw/2, by=d.boss?H-62:y-e.r-14;
if(d.boss){
if(dist(e.x,e.y,P.x,P.y)<420){
const slot=e.id==='golem'?1:0;
const yy=by-slot*44;
ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(bx-2,yy-16,bw+4,12);
ctx.fillStyle='#c94b2a'; ctx.fillRect(bx,yy-14,bw*(e.hp/e.maxHp),8);
ctx.fillStyle='#fff'; ctx.font='bold 12px Georgia'; ctx.textAlign='center'; ctx.fillText(d.name,W/2,yy-20); ctx.textAlign='left';
}
} else {
ctx.fillStyle='#000'; ctx.fillRect(bx,by,bw,4);
ctx.fillStyle='#ff5a5a'; ctx.fillRect(bx,by,bw*(e.hp/e.maxHp),4);
}
}
if(e.burn>0){ ctx.fillStyle='rgba(255,130,40,'+(0.4+0.3*Math.sin(time*20))+')'; ctx.fillRect(x-4,y-e.r-22,8,6); }
}
function drawPlayer(){
const x=Math.round(P.x-camX), y=Math.round(P.y-camY);
const w=WEAPONS[S.tier];
const bobAmp=P.sprinting?3:P.moving?2:0;
const bob=Math.sin(P.anim)*bobAmp;
const legSwing=P.moving?Math.sin(P.anim)*(P.sprinting?5:3):0;
const blink=P.iframes>0 && Math.floor(time*20)%2===0;
ctx.globalAlpha=blink?0.35:1;
// shadow
ctx.fillStyle='rgba(0,0,0,.4)'; ctx.beginPath(); ctx.ellipse(x,y+10,8,3.5,0,0,7); ctx.fill();
// legs (boots color if swift boots)
const bootC=S.eq.includes('boots')?'#7fd4ff':'#3a2a1a';
ctx.fillStyle=bootC;
ctx.fillRect(x-5,y+3-legSwing*0.5,4,7); ctx.fillRect(x+1,y+3+legSwing*0.5,4,7);
// tunic (tier color), body bob
const by=y-2+bob;
ctx.fillStyle=P.hurtT>0?'#ff5555':w.tunic;
ctx.fillRect(x-6,by-2,12,11);
ctx.fillStyle='rgba(0,0,0,.25)'; ctx.fillRect(x-6,by+6,12,3);
// belt
ctx.fillStyle='#4a3420'; ctx.fillRect(x-6,by+3,12,2);
// head
ctx.fillStyle='#f0c9a0'; ctx.beginPath(); ctx.arc(x,by-7,6,0,7); ctx.fill();
ctx.fillStyle='#3b2a1a'; ctx.fillRect(x-6,by-12,12,4);
// eyes oriented to facing
ctx.fillStyle='#111';
const ex=P.fx*2, ey=P.fy*1.5;
ctx.fillRect(x-3+ex,by-7+ey,2,2); ctx.fillRect(x+1+ex,by-7+ey,2,2);
// accessory: thorn ring (green glow on hand), charm (gold pendant)
if(S.eq.includes('charm')){ ctx.fillStyle='#ffd65a'; ctx.fillRect(x-1,by-1,2,2); }
if(S.eq.includes('thorn')){ ctx.strokeStyle='#5fd35f'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(x+(P.fx*12),by+ (P.fy*12)+2,2,0,7); ctx.stroke(); }
// weapon: sheath on back for idle; swing when attacking
const ang=P.atkT>0?P.swingAng+ (P.atkT/0.16-0.5)*2.2:P.swingAng;
const hx=x+Math.cos(P.swingAng)*6, hy=by+Math.sin(P.swingAng)*6;
if(P.atkT>0){
ctx.save(); ctx.translate(hx,hy); ctx.rotate(ang);
const len=w.range;
// swing arc
ctx.strokeStyle=w.fire?'rgba(255,120,40,.5)':'rgba(220,240,255,.35)';
ctx.lineWidth=6; ctx.beginPath(); ctx.arc(0,0,len*0.85,-0.7,0.7); ctx.stroke();
ctx.fillStyle=w.blade; ctx.fillRect(0,-2.5,len,5);
ctx.fillStyle='#6a4a22'; ctx.fillRect(-4,-4,6,8);
if(w.fire){ ctx.fillStyle='rgba(255,140,40,.8)'; ctx.fillRect(len-6,-4,6,8); }
ctx.restore();
} else {
// idle weapon held at side, length reflects tier
ctx.save(); ctx.translate(x+(P.fx>0?-5:5), by+ 5); ctx.rotate(P.fx<0?0.6:-0.6);
ctx.fillStyle=w.blade; ctx.fillRect(0,-w.range*0.28,3,w.range*0.28);
ctx.fillStyle='#6a4a22'; ctx.fillRect(-1,0,5,4);
if(w.fire){ ctx.fillStyle='rgba(255,140,40,'+(0.4+0.4*Math.sin(time*10))+')'; ctx.fillRect(-1,-w.range*0.28,5,6); }
ctx.restore();
}
// bow
if(S.hasBow){
ctx.strokeStyle='#c8a46a'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(x-P.fx*8,by,6,-1.4,1.4); ctx.stroke();
}
// damage flash red overlay
if(P.hurtT>0){ ctx.fillStyle='rgba(255,0,0,'+(P.hurtT*1.2)+')'; ctx.fillRect(x-8,by-14,16,26); }
ctx.globalAlpha=1;
}
function drawPickups(){
for(const p of pickups){
const x=p.x-camX, y=p.y-camY+Math.sin(p.bob)*2;
if(p.kind==='coin'){
ctx.fillStyle='#b3851e'; ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fill();
ctx.fillStyle='#ffd65a'; ctx.beginPath(); ctx.arc(x-1,y-1,4,0,7); ctx.fill();
} else {
drawHeart(x,y,0.7,2);
}
}
}
function drawBombs(){
for(const b of bombs){
const x=b.x-camX, y=b.y-camY;
ctx.fillStyle='#222'; ctx.beginPath(); ctx.arc(x,y,7,0,7); ctx.fill();
const flashOn=b.t<0.5 && Math.floor(time*16)%2===0;
ctx.fillStyle=flashOn?'#ff3d3d':'#ffb040'; ctx.fillRect(x+4,y-10,2,4);
ctx.fillStyle='#fff'; ctx.fillRect(x-2,y-3,2,2);
}
}
function drawProjectiles(){
for(const a of arrows){
const x=a.x-camX, y=a.y-camY;
ctx.strokeStyle='#e6dccb'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x-Math.cos(a.ang)*10,y-Math.sin(a.ang)*10); ctx.stroke();
}
for(const s of enemyShots){
const x=s.x-camX, y=s.y-camY;
if(s.rock){ ctx.fillStyle='#8a8794'; ctx.fillRect(x-4,y-4,8,8); }
else { ctx.fillStyle='#b8d46a'; ctx.beginPath(); ctx.arc(x,y,4,0,7); ctx.fill(); }
}
}
function drawWaves(){
for(const w of waves){
const p=w.t/w.max, r=(w.kind==='stone'?110:70)*p;
ctx.strokeStyle=w.kind==='stone'?'rgba(200,190,170,'+(1-p)+')':'rgba(255,180,60,'+(1-p)+')';
ctx.lineWidth=4*(1-p)+1; ctx.beginPath(); ctx.arc(w.x-camX,w.y-camY,r,0,7); ctx.stroke();
}
}
function drawParticles(){
for(const p of particles){
const a=Math.max(0,p.life/p.max);
ctx.globalAlpha=a; ctx.fillStyle=p.c;
ctx.fillRect(p.x-camX-p.sz/2,p.y-camY-p.sz/2,p.sz,p.sz);
}
ctx.globalAlpha=1;
ctx.font='bold 13px Georgia'; ctx.textAlign='center';
for(const t of texts){
ctx.globalAlpha=Math.min(1,t.t*2); ctx.fillStyle='#000'; ctx.fillText(t.s,t.x-camX+1,t.y-camY+1);
ctx.fillStyle=t.c; ctx.fillText(t.s,t.x-camX,t.y-camY);
}
ctx.globalAlpha=1; ctx.textAlign='left';
}
function drawDarkness(){
// soft vignette
const g=ctx.createRadialGradient(W/2,H/2,H*0.35,W/2,H/2,H*0.8);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.55)');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
if(flashA>0){ ctx.fillStyle='rgba(255,0,0,'+flashA*0.5+')'; ctx.fillRect(0,0,W,H); }
if(mode==='dead'){
const a=Math.min(1,(2.2-deadT)*0.9);
ctx.fillStyle='rgba(20,0,4,'+a*0.8+')'; ctx.fillRect(0,0,W,H);
ctx.globalAlpha=Math.min(1,(2.2-deadT)*1.5);
ctx.fillStyle='#ff9a9a'; ctx.font='bold 40px Georgia'; ctx.textAlign='center';
ctx.fillText('You have fallen',W/2,H/2-10);
ctx.font='16px Georgia'; ctx.fillStyle='#eed';
ctx.fillText('Returning to your last save crystal...',W/2,H/2+20);
ctx.globalAlpha=1; ctx.textAlign='left';
}
}
function draw(){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
if(mode==='title') return;
ctx.save();
if(shakeT>0){ const s=shakeAmt*(shakeT/0.6); ctx.translate(rnd(-s,s),rnd(-s,s)); }
drawGround();
drawWaves();
drawBlocks();
drawChests();
drawNPCs();
// y-sorted entities
const ents=[];
for(const e of enemies) ents.push({y:e.y,f:()=>drawEnemy(e)});
ents.push({y:P.y,f:()=>drawPlayer()});
ents.sort((a,b)=>a.y-b.y);
// draw pickups under entities
drawPickups();
drawBombs();
// swings are drawn on player itself
for(const en of ents) en.f();
drawProjectiles();
drawParticles();
ctx.restore();
drawDarkness();
drawHUD();
drawPrompt();
drawDialog();
}
/* ---------- shop & pack ---------- */
function openShop(){
mode='shop'; SFX.shop();
document.getElementById('shop').classList.add('show');
renderShop();
}
function closeShop(){
mode='play'; SFX.click();
document.getElementById('shop').classList.remove('show');
}
function renderShop(){
const list=document.getElementById('shopList'); list.innerHTML='';
document.getElementById('shopMsg').textContent='Your gold: '+S.gold+' · Bought items go to your pack (I).';
for(const s of SHOP){
const it=ITEMS[s.id];
const owned=it.kind==='acc' && S.inv[s.id];
const row=document.createElement('div'); row.className='row';
row.innerHTML='<div class="swatch" style="background:'+it.color+'"></div>'+
'<div class="nm"><b>'+it.name+(s.n>1?' x'+s.n:'')+'</b><small>'+it.desc+'</small></div>';
const b=document.createElement('button');
b.textContent=owned?'Owned':s.price+' g';
b.disabled=!!owned||S.gold<s.price;
b.onclick=()=>buy(s);
row.appendChild(b);
list.appendChild(row);
}
}
function buy(s){
const it=ITEMS[s.id];
if(S.gold<s.price){ SFX.fail(); document.getElementById('shopMsg').textContent='Not enough gold.'; return; }
if(it.kind==='acc' && S.inv[s.id]){ SFX.fail(); return; }
S.gold-=s.price; addItem(s.id,s.n); SFX.shop();
burst(P.x,P.y-10,14,'#ffd65a',90,0.5,3);
document.getElementById('shopMsg').textContent='Bought '+it.name+'. Thank you!';
renderShop();
}
function openInv(){
mode='inv'; SFX.ui();
document.getElementById('inv').classList.add('show');
renderInv();
}
function closeInv(){
mode='play'; SFX.click();
document.getElementById('inv').classList.remove('show');
}
function renderInv(){
const g=document.getElementById('invGrid'); g.innerHTML='';
const slots=[];
// weapons summary as first slots
for(const k of Object.keys(ITEMS)){
if(!S.inv[k]) continue;
slots.push({id:k,it:ITEMS[k],n:S.inv[k]});
}
const info=document.getElementById('invInfo');
info.innerHTML='Weapon: <b>'+WEAPONS[S.tier].name+'</b> ('+WEAPONS[S.tier].desc+')'+(S.hasBow?' · Bow ready [F]':'')+
'<br>Accessories equipped: '+(S.eq.length?S.eq.map(i=>ITEMS[i].name).join(', '):'none')+' (max 2, click to toggle)';
if(!slots.length){ g.innerHTML='<div class="small" style="grid-column:1/-1">Your pack is empty.</div>'; }
for(const s of slots){
const d=document.createElement('div');
const eq=s.it.kind==='acc' && S.eq.includes(s.id);
d.className='slot'+(eq?' eq':'');
d.innerHTML='<div class="swatch" style="background:'+s.it.color+';width:30px;height:30px;margin-bottom:4px"></div>'+
'<b>'+s.it.name+'</b>'+(s.it.kind==='use'?'<small>x'+s.n+' · '+(s.id==='potion'?'key 1':'key 2')+'</small>':'<small>'+(eq?'EQUIPPED':'click to equip')+'</small>');
d.onclick=()=>{ if(s.it.kind==='acc'){ toggleEquip(s.id); renderInv(); } };
g.appendChild(d);
}
}
/* ---------- title / flow ---------- */
function startGame(fresh){
ensureAudio();
if(fresh){ S=newState(); lastSave=null; }
else{
try{ const raw=localStorage.getItem(SAVE_KEY); if(!raw){ toast('No save found.'); return; } S=JSON.parse(raw); lastSave=raw; }
catch(e){ return; }
}
if(!S.inv.potion && S.inv.potion!==0) S.inv.potion=0;
S.eq=S.eq||[];
S.killed=S.killed||[]; S.chests=S.chests||[];
document.getElementById('title').classList.remove('show');
document.getElementById('end').classList.remove('show');
mode='play';
resetWorld();
renderMapCache();
if(fresh){
say([
['Narrator','The hollow has been silent since the Ember Heart went dark.'],
['Narrator','You wake at a crystal in the village. Begin your search.']
]);
SFX.wind();
}
}
document.getElementById('bNew').onclick=()=>startGame(true);
document.getElementById('bCont').onclick=()=>{
if(!localStorage.getItem(SAVE_KEY)){ toast('No save yet.'); return; }
startGame(false);
};
document.getElementById('shopClose').onclick=closeShop;
document.getElementById('invClose').onclick=closeInv;
document.getElementById('bRestart').onclick=()=>{
document.getElementById('end').classList.remove('show');
mode='title';
document.getElementById('title').classList.add('show');
};
addEventListener('keydown',e=>{
if(e.code==='Escape'){ if(mode==='shop') closeShop(); else if(mode==='inv') closeInv(); }
});
/* ---------- main loop ---------- */
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
try{
step(dt);
if(mode!=='title') { ctx.fillStyle='#000'; }
draw();
}catch(err){ console.error(err); }
requestAnimationFrame(frame);
}
buildMap();
renderMapCache();
requestAnimationFrame(frame);
window.__eh={get S(){return S;},get P(){return P;},get mode(){return mode;},get enemies(){return enemies;},setMode:m=>{mode=m;}};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"cd524655-5f68-4a06-9e59-f7549d2f4c34","tokensIn":687017,"tokensOut":187145,"tokensTotal":874162,"cost":0.10953777500000002,"turns":16,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-10-08T06:17:42.544Z"} -->