RPG
Claude Opus 5 · 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 120,049 bytes · SHA-256 230336450543
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Emberhollow — An Action Adventure</title>
<style>
* { box-sizing: border-box; }
html,body {
margin:0; padding:0; height:100%;
background:#07070c;
color:#e8e0d0;
font-family: 'Courier New', ui-monospace, monospace;
overflow:hidden;
user-select:none;
-webkit-font-smoothing:none;
}
#wrap {
position:fixed; inset:0;
display:flex; align-items:center; justify-content:center;
background:
radial-gradient(ellipse at 50% 40%, #16161f 0%, #08080d 70%, #040407 100%);
}
#stage {
position:relative;
image-rendering: pixelated;
image-rendering: crisp-edges;
box-shadow: 0 0 0 2px #2b2b3a, 0 0 40px rgba(0,0,0,.9), 0 0 90px rgba(90,60,160,.12);
background:#000;
}
canvas { display:block; position:absolute; top:0; left:0; }
#game { z-index:1; }
#ui { z-index:2; pointer-events:none; }
/* ---------- Overlay panels ---------- */
.panel {
position:absolute; inset:0; z-index:5;
display:none;
flex-direction:column; align-items:center; justify-content:center;
background:rgba(6,6,12,.92);
text-align:center;
padding:24px;
}
.panel.show { display:flex; }
.panel h1 {
font-size:34px; margin:0 0 6px; letter-spacing:6px;
color:#f0c060;
text-shadow:0 3px 0 #6b3a10, 0 0 26px rgba(240,192,96,.4);
}
.panel h2 { font-size:16px; margin:0 0 18px; color:#8f86a8; letter-spacing:3px; font-weight:normal;}
.panel p { max-width:520px; line-height:1.65; font-size:12.5px; color:#b9b0c6; margin:6px 0; }
.key { color:#f0c060; }
.btn {
pointer-events:auto; cursor:pointer;
margin-top:16px; padding:11px 26px;
background:#1a1a2a; color:#f0c060;
border:2px solid #f0c060;
font-family:inherit; font-size:13px; letter-spacing:3px;
transition:.12s;
}
.btn:hover { background:#f0c060; color:#17131f; }
#ctrlGrid {
display:grid; grid-template-columns:auto auto; gap:4px 16px;
font-size:11.5px; margin-top:14px; color:#9d94b0; text-align:left;
}
#ctrlGrid b { color:#e8e0d0; font-weight:normal; }
#toast {
position:absolute; left:50%; bottom:78px; transform:translateX(-50%);
z-index:4; pointer-events:none;
background:rgba(10,8,18,.9); border:1px solid #4a4160;
padding:7px 16px; font-size:11.5px; color:#f0e0b0;
letter-spacing:1px; white-space:nowrap;
opacity:0; transition:opacity .3s;
text-shadow:0 1px 0 #000;
}
#toast.show { opacity:1; }
#dlg {
position:absolute; left:16px; right:16px; bottom:14px; z-index:4;
background:linear-gradient(#161226,#0d0a17);
border:2px solid #6b5f8e; border-radius:3px;
padding:12px 16px; display:none;
font-size:12.5px; line-height:1.7; color:#ded4ea;
box-shadow:0 6px 24px rgba(0,0,0,.7);
}
#dlg .who { color:#f0c060; letter-spacing:2px; font-size:11px; display:block; margin-bottom:5px;}
#dlg .more { position:absolute; right:12px; bottom:6px; font-size:10px; color:#8f86a8; }
.fadeOut { animation: fo .6s forwards; }
@keyframes fo { to { opacity:0; } }
#hint {
position:absolute; top:8px; left:50%; transform:translateX(-50%);
z-index:4; font-size:10.5px; color:#7a7290; letter-spacing:2px;
}
</style>
</head>
<body>
<div id="wrap">
<div id="stage">
<canvas id="game"></canvas>
<canvas id="ui"></canvas>
<div id="toast"></div>
<div id="dlg"><span class="who"></span><span class="txt"></span><span class="more">[SPACE]</span></div>
<div id="hint"></div>
<div class="panel show" id="titlePanel">
<h1>EMBERHOLLOW</h1>
<h2>the last ember of the deep</h2>
<p>The hollow beneath the village has woken. Its creatures spill into the fields, and the old Forge-Heart that once sealed it has gone cold.</p>
<p>Find the three Emberstones. Rekindle the Heart. Do not die down there.</p>
<div id="ctrlGrid">
<b>WASD / Arrows</b><span>move</span>
<b>J / Z</b><span>attack</span>
<b>K / X</b><span>ranged (once found)</span>
<b>SHIFT</b><span>sprint (stamina)</span>
<b>E</b><span>interact / save / shop</span>
<b>I / TAB</b><span>inventory</span>
<b>1..5</b><span>use quick item</span>
<b>M</b><span>mute audio</span>
</div>
<button class="btn" id="startBtn">BEGIN</button>
<button class="btn" id="contBtn" style="display:none">CONTINUE</button>
</div>
</div>
</div>
<script>
"use strict";
/* ==========================================================================
EMBERHOLLOW — single-file action adventure RPG
========================================================================== */
const TILE = 24;
const VIEW_W = 640, VIEW_H = 400; // logical pixels
const SCALE_MAX = 3;
const game = document.getElementById('game');
const uiC = document.getElementById('ui');
const gx = game.getContext('2d');
const ux = uiC.getContext('2d');
let SCALE = 2;
function fit(){
const w = window.innerWidth, h = window.innerHeight;
SCALE = Math.max(1, Math.min(SCALE_MAX, Math.floor(Math.min(w/VIEW_W, h/VIEW_H)*1000)/1000));
for (const c of [game, uiC]){
c.width = VIEW_W; c.height = VIEW_H;
c.style.width = (VIEW_W*SCALE)+'px';
c.style.height= (VIEW_H*SCALE)+'px';
}
const st = document.getElementById('stage');
st.style.width = (VIEW_W*SCALE)+'px';
st.style.height= (VIEW_H*SCALE)+'px';
gx.imageSmoothingEnabled = false; ux.imageSmoothingEnabled = false;
}
window.addEventListener('resize', fit); fit();
/* ==========================================================================
AUDIO — tiny procedural synth (no assets)
========================================================================== */
const Audio_ = {
ctx:null, master:null, muted:false, ready:false,
init(){
if (this.ready) return;
try{
const AC = window.AudioContext||window.webkitAudioContext;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.34;
const comp = this.ctx.createDynamicsCompressor();
this.master.connect(comp); comp.connect(this.ctx.destination);
this.ready = true;
}catch(e){ this.ready=false; }
},
now(){ return this.ctx.currentTime; },
env(node, t, a, d, peak){
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002,peak), t+a);
g.gain.exponentialRampToValueAtTime(0.0001, t+a+d);
node.connect(g); g.connect(this.master);
return g;
},
tone(freq, t, dur, type='square', peak=0.3, sweepTo=null){
if(!this.ready||this.muted) return;
const o = this.ctx.createOscillator();
o.type=type; o.frequency.setValueAtTime(freq,t);
if (sweepTo) o.frequency.exponentialRampToValueAtTime(Math.max(20,sweepTo), t+dur);
this.env(o,t,Math.min(0.012,dur*0.2),dur,peak);
o.start(t); o.stop(t+dur+0.05);
},
noise(t, dur, peak=0.25, filterFreq=1200, q=1, sweep=null){
if(!this.ready||this.muted) return;
const len = Math.max(1,Math.floor(this.ctx.sampleRate*dur));
const buf = this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
const src = this.ctx.createBufferSource(); src.buffer=buf;
const f = this.ctx.createBiquadFilter(); f.type='bandpass';
f.frequency.setValueAtTime(filterFreq,t); f.Q.value=q;
if(sweep) f.frequency.exponentialRampToValueAtTime(Math.max(60,sweep), t+dur);
src.connect(f);
this.env(f,t,0.004,dur,peak);
src.start(t);
},
/* --- named SFX --- */
step(soft){ if(!this.ready)return; const t=this.now(); this.noise(t,0.055,soft?0.055:0.1, 260+Math.random()*140, 1.4, 130); },
swing(kind){
if(!this.ready)return; const t=this.now();
if(kind===0){ this.noise(t,0.13,0.20,1500,0.9,420); this.tone(560,t,0.07,'triangle',0.10,300); }
else if(kind===1){ this.noise(t,0.16,0.24,2100,1.1,500); this.tone(760,t,0.09,'sawtooth',0.11,320); }
else { this.noise(t,0.2,0.26,2600,1.3,380); this.tone(300,t,0.16,'sawtooth',0.14,90); this.tone(920,t+0.02,0.1,'square',0.07,400); }
},
bow(){ if(!this.ready)return; const t=this.now(); this.noise(t,0.09,0.2,900,3,240); this.tone(1300,t,0.06,'triangle',0.09,600); },
hit(){ if(!this.ready)return; const t=this.now(); this.noise(t,0.1,0.3,700,1.2,160); this.tone(180,t,0.09,'square',0.16,70); },
kill(){ if(!this.ready)return; const t=this.now(); this.noise(t,0.3,0.3,600,0.8,90);
this.tone(320,t,0.18,'square',0.13,60); this.tone(160,t+0.05,0.28,'triangle',0.12,40); },
hurt(){ if(!this.ready)return; const t=this.now();
this.tone(300,t,0.22,'sawtooth',0.26,70); this.noise(t,0.18,0.24,420,0.9,120); },
pickup(){ if(!this.ready)return; const t=this.now();
this.tone(880,t,0.07,'square',0.16); this.tone(1320,t+0.06,0.1,'square',0.15); },
coin(){ if(!this.ready)return; const t=this.now();
this.tone(1180,t,0.05,'square',0.12); this.tone(1560,t+0.045,0.09,'square',0.11); },
heart(){ if(!this.ready)return; const t=this.now();
[523,659,784,1047].forEach((f,i)=>this.tone(f,t+i*0.075,0.18,'triangle',0.16)); },
fanfare(){ if(!this.ready)return; const t=this.now();
[392,523,659,784,1047].forEach((f,i)=>this.tone(f,t+i*0.11,0.34,'square',0.14)); },
shopOpen(){ if(!this.ready)return; const t=this.now();
[440,587,740].forEach((f,i)=>this.tone(f,t+i*0.06,0.2,'triangle',0.13)); },
shopBuy(){ if(!this.ready)return; const t=this.now();
this.tone(700,t,0.06,'square',0.13); this.tone(1050,t+0.055,0.07,'square',0.13); this.tone(1400,t+0.11,0.13,'square',0.12); },
deny(){ if(!this.ready)return; const t=this.now(); this.tone(190,t,0.14,'square',0.16,120); },
save(){ if(!this.ready)return; const t=this.now();
[659,784,988,1319].forEach((f,i)=>this.tone(f,t+i*0.09,0.3,'triangle',0.13)); },
ui(){ if(!this.ready)return; this.tone(760,this.now(),0.035,'square',0.09); },
door(){ if(!this.ready)return; const t=this.now(); this.noise(t,0.4,0.22,240,0.7,70); this.tone(90,t,0.35,'square',0.12,50); },
die(){ if(!this.ready)return; const t=this.now();
[440,392,330,262,196].forEach((f,i)=>this.tone(f,t+i*0.16,0.42,'sawtooth',0.15)); },
boss(){ if(!this.ready)return; const t=this.now(); this.tone(70,t,0.9,'sawtooth',0.2,40); this.noise(t,0.7,0.2,180,0.6,60); },
push(){ if(!this.ready)return; const t=this.now(); this.noise(t,0.3,0.16,330,0.8,150); },
};
/* ==========================================================================
INPUT
========================================================================== */
const Keys = {};
const pressed = {};
const KEYMAP = {
ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right',
KeyW:'up', KeyS:'down', KeyA:'left', KeyD:'right',
KeyJ:'atk', KeyZ:'atk', KeyK:'shoot', KeyX:'shoot',
ShiftLeft:'run', ShiftRight:'run',
KeyE:'act', Space:'act', Enter:'act',
KeyI:'inv', Tab:'inv', Escape:'esc', KeyM:'mute',
Digit1:'q1',Digit2:'q2',Digit3:'q3',Digit4:'q4',Digit5:'q5',
};
addEventListener('keydown', e=>{
const k = KEYMAP[e.code];
if (k){ e.preventDefault(); if(!Keys[k]) pressed[k]=true; Keys[k]=true; }
Audio_.init(); if (Audio_.ctx && Audio_.ctx.state==='suspended') Audio_.ctx.resume();
});
addEventListener('keyup', e=>{ const k=KEYMAP[e.code]; if(k){ e.preventDefault(); Keys[k]=false; } });
addEventListener('blur', ()=>{ for(const k in Keys) Keys[k]=false; });
function tap(k){ if(pressed[k]){ pressed[k]=false; return true; } return false; }
function clearTaps(){ for(const k in pressed) pressed[k]=false; }
/* ==========================================================================
ITEM & EQUIPMENT DATA
========================================================================== */
const ITEMS = {
salve: {name:'Healing Salve', kind:'use', price:25, desc:'Restores 4 HP (2 hearts).', icon:'salve'},
flask: {name:'Ember Flask', kind:'use', price:70, desc:'Restores all health.', icon:'flask'},
arrows: {name:'Arrow Bundle', kind:'use', price:20, desc:'+8 arrows for the Emberbow.', icon:'arrow'},
charm: {name:'Iron Charm', kind:'acc', price:120, desc:'Accessory: take 30% less damage.', icon:'charm'},
boots: {name:'Swift Boots', kind:'acc', price:100, desc:'Accessory: +20% speed, quick stamina.', icon:'boots'},
pouch: {name:'Lucky Pouch', kind:'acc', price:90, desc:'Accessory: +60% ember coins found.', icon:'pouch'},
lens: {name:'Seer Lens', kind:'acc', price:150, desc:'Accessory: reveals foe health & secrets.', icon:'lens'},
ore: {name:'Ember Ore', kind:'key', desc:'Raw ore. The smith will want this.', icon:'ore'},
ironkey:{name:'Iron Key', kind:'key', desc:'Opens a barred ruin door.', icon:'key'},
stone: {name:'Emberstone', kind:'key', desc:'A shard of the old Forge-Heart.', icon:'stone'},
};
const SWORDS = [
{name:'Rusted Shortsword', dmg:2, range:17, arc:1.05, cd:0.42, wind:0.09, len:9, col:'#b9b6c4', edge:'#e8e6f0', fire:false},
{name:'Ember Blade', dmg:4, range:21, arc:1.25, cd:0.30, wind:0.06, len:13, col:'#ff8a3c', edge:'#ffe08a', fire:true},
{name:'Tempest Edge', dmg:7, range:27, arc:2.35, cd:0.34, wind:0.05, len:16, col:'#7ce8ff', edge:'#eaffff', fire:true, spin:true},
];
/* ==========================================================================
WORLD DATA — rooms are 24x14 interiors; borders/exits generated
========================================================================== */
const IW = 24, IH = 14; // interior tiles
const RW = IW+2, RH = IH+2; // with border
const ROOMS = {
/* ---------------- GROVE (block puzzle) ---------------- */
'0,0': { name:'Whispering Grove', theme:'forest', pad:'.', closed:['n','w','s'],
rows:[
"........................",
"...##################...",
"...#................#...",
"...#.....C....C.....#...",
"...#................#...",
"...#########GG#######...",
"........................",
"..T...B........B...T....",
"........................",
"....P............P......",
"........................",
"..T..r..........r..T....",
"..TT..............TT....",
"........................"],
chests:[{item:'stone'},{coins:45}],
enemies:[{t:'slime',x:11,y:9},{t:'slime',x:15,y:10},{t:'bat',x:5,y:12}],
note:'Two stones sink into the earth where they belong.'
},
/* ---------------- NORTH FIELDS ---------------- */
'1,0': { name:'North Fields', theme:'field', pad:'.', closed:['n'],
rows:[
".....T........T.........",
"..T.........f.....T.....",
"......p.............p...",
"..f........rr...........",
".........r....r.........",
"..T.....r......r....T...",
"................f.......",
"....f...r......r........",
".........r....r.........",
"..p........rr........p..",
"........................",
"...T........f.......T...",
"..TT..............f.....",
"........................"],
enemies:[{t:'slime',x:7,y:5},{t:'slime',x:16,y:8},{t:'bat',x:14,y:1},{t:'husk',x:12,y:11}]
},
/* ---------------- SHRINE GLADE ---------------- */
'2,0': { name:'Ashen Glade', theme:'forest', pad:'.', closed:['n','e'],
rows:[
"....TTTTTTTTTTTTTTTT....",
"..T................T....",
"..T....##....##....T....",
".......#......#.........",
"...........S............",
"........................",
"..p...................p.",
"...........2............",
"........................",
"....C..............p....",
"........................",
"..T................T....",
"..TT..............TT....",
"........................"],
chests:[{item:'salve'}],
enemies:[{t:'bat',x:6,y:6},{t:'bat',x:18,y:5}]
},
/* ---------------- WEST WOODS ---------------- */
'0,1': { name:'Tangled Woods', theme:'forest', pad:'T', closed:['n','w','s'],
rows:[
"TTTTTTTTTTTTTTTTTTTTTTTT",
"TT..T...TT......T....TTT",
"T....................TTT",
"T..T....p......p.....TTT",
"T.....TT....TT.......TTT",
"T........C...........TTT",
"T.....TT....TT.......TTT",
"T....................TTT",
"T..T....p......p...T.TTT",
"T.......................",
"T..TT..........TT....TTT",
"T....................TTT",
"TT..T....TT.....T....TTT",
"TTTTTTTTTTTTTTTTTTTTTTTT"],
chests:[{item:'pouch'}],
enemies:[{t:'husk',x:8,y:5},{t:'slime',x:15,y:9},{t:'bat',x:11,y:2}]
},
/* ---------------- VILLAGE ---------------- */
'1,1': { name:'Emberhollow Village', theme:'village', pad:'.', safe:true,
rows:[
"..#####......#####......",
"..#...#......#...#......",
"..#.2.#......#.$.#......",
"..##.##......##.##......",
"....,..........,........",
"..S.,..........,........",
"....,..........,........",
"..1.,..........,........",
"....,,,,,,,,,,,,........",
"....,..........,........",
"..p.,..........,...p....",
"....,..........,........",
"..T.,..........,...T....",
"....,,,,,,,,...,...TT..."],
spawnDefault:{x:9,y:9}
},
/* ---------------- EAST FIELDS ---------------- */
'2,1': { name:'Scorched Fields', theme:'field', pad:'.',
rows:[
"...r................r...",
"........................",
"..r...p..........p...r..",
"........................",
"....rr............rr....",
"..........C.............",
"........................",
"..r..................r..",
"........................",
"....p..............p....",
"........................",
"..rr................rr..",
"........................",
"........................"],
chests:[{item:'ore', locked:'captain'}],
enemies:[{t:'captain',x:12,y:7},{t:'husk',x:6,y:3},{t:'husk',x:19,y:10},{t:'bat',x:12,y:1}],
note:'A heavy shape guards something bright.'
},
/* ---------------- RUIN GATE (brazier puzzle) ---------------- */
'3,1': { name:'Ruin Gate', theme:'ruin', pad:'#', closed:['n','e'],
rows:[
"########################",
"#......................#",
"#..g................g..#",
"#......................#",
"#....b............b....#",
"#......................#",
"..........C............#",
"..........p............#",
"#..g................g..#",
"#....p............p....#",
"#......................#",
"#........p....p........#",
"#########GG#############",
"#########..#############"],
chests:[{coins:35}],
enemies:[{t:'bat',x:6,y:6},{t:'bat',x:17,y:7},{t:'husk',x:12,y:9}],
gateBy:'braziers',
note:'Cold braziers. Only true fire will wake them.'
},
/* ---------------- SOUTH PATH ---------------- */
'1,2': { name:'Hollow Road', theme:'field', pad:'.', closed:['s','w'],
rows:[
"........................",
"..rr..............rr....",
"........................",
"....p...r....r...p......",
"........................",
"..r.......~~~~.......r..",
"..........~~~~..........",
"....C.....~==~..........",
"..........~~~~..........",
"..r.......~~~~.......r..",
"........................",
"....p..............p....",
"..rr..............rr....",
"........................"],
chests:[{coins:30}],
enemies:[{t:'husk',x:6,y:6},{t:'husk',x:18,y:6},{t:'slime',x:12,y:11},{t:'bat',x:4,y:2}]
},
/* ---------------- CAVE MOUTH ---------------- */
'2,2': { name:'Cave Mouth', theme:'cave', pad:'#',
rows:[
"#####..............#####",
"###..................###",
"#........p....p........#",
"#......................#",
"#..r....##....##....r..#",
"#.......##....##.......#",
"..........S............#",
"#......................#",
"#..r................r..#",
"#....p............p....#",
"#......................#",
"#..##..............##..#",
"#####..........#########",
"#####..........#########"],
enemies:[{t:'slime',x:6,y:3},{t:'bat',x:17,y:8},{t:'husk',x:12,y:10}],
edgeGate:{dir:'s', need:'stones2', txt:'A wall of cold ash. It thins only for two Emberstones.'}
},
/* ---------------- RUIN HALL ---------------- */
'3,2': { name:'Hall of Cinders', theme:'ruin', pad:'#', closed:['w','s','e'],
rows:[
"#......................#",
"#......................#",
"#..g..g........g..g....#",
"#......................#",
"#...........p..........#",
"#..........LLL.........#",
"#..........#C#.........#",
"#..........###.........#",
"#......................#",
"#..g..g........g..g....#",
"#....p............p....#",
"#........C.............#",
"#......................#",
"########################"],
chests:[{item:'stone'},{heart:true}],
enemies:[{t:'skeleton',x:8,y:4},{t:'skeleton',x:16,y:9},{t:'bat',x:12,y:11},{t:'husk',x:5,y:12}],
note:'One of these bones still carries a key.'
},
/* ---------------- CAVE HALL (bow) ---------------- */
'2,3': { name:'Deep Cistern', theme:'cave', pad:'#', closed:['w'],
rows:[
"#####..............#####",
"#......................#",
"#..~~~~..........~~~~..#",
"#..~~~~....C.....~~~~..#",
"#..~~~~..........~~~~..#",
"#......................#",
"#.......b......b.......#",
"#......................#",
"#...C..............C....",
"#......................#",
"#..##..............##..#",
"#..##......S.......##..#",
"#####..........#########",
"#####..........#########"],
chests:[{item:'bow'},{heart:true},{coins:60}],
enemies:[{t:'skeleton',x:7,y:8},{t:'slime',x:17,y:5},{t:'bat',x:12,y:2},{t:'husk',x:12,y:9}],
edgeGate:{dir:'s', need:'stones3', txt:'The Warden\'s door. Three Emberstones, or nothing.'},
note:'Cold braziers again — and something below the water.'
},
/* ---------------- DEEP BRIDGE (bow targets) ---------------- */
'3,3': { name:'The Long Span', theme:'cave', pad:'~', closed:['n','e','s'],
rows:[
"~~~~~~~~~~~~~~~~~~~~~~~~",
"~~..o..~~~~~~~~~o..~~~~~",
"~~.....~~~~~~~~....~~~~~",
"~~~~~~~~~~~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~~~~~~~~~~~",
"~~~~==============~~~~~~",
"........................",
"~~~~==============~~~~~~",
"~~~~=~~~~~~~~~~~~=~~~~~~",
"~~~~=~~~~~~~~~~~~=~~~~~~",
"~~~~==============~~~~~~",
"~~~~~~~~~~GG~~~~~~~~~~~~",
"~~~~~~~~#C..C#~~~~~~~~~~",
"~~~~~~~~######~~~~~~~~~~"],
chests:[{item:'sword3'},{item:'stone'}],
enemies:[{t:'bat',x:9,y:6},{t:'bat',x:15,y:6},{t:'skeleton',x:12,y:6}],
gateBy:'targets',
note:'Two eyes of stone across the water. Something must strike them.'
},
/* ---------------- BOSS ---------------- */
'2,4': { name:'The Forge-Heart', theme:'boss', pad:'#', closed:['w','e','s'],
rows:[
"#####..............#####",
"#......................#",
"#..g................g..#",
"#......................#",
"#......................#",
"#..........@...........#",
"#......................#",
"#......................#",
"#..g................g..#",
"#......................#",
"#..........S...........#",
"#......................#",
"########################",
"########################"],
boss:true
},
};
ROOMS['2,2'].closed = ['e'];
/* ==========================================================================
TILE SEMANTICS
========================================================================== */
const SOLID = new Set(['#','T','~','r','G','L','p','B','b','g','o','C','S','$','1','2']);
const WALK = new Set(['.',',','=','f','@']);
const THEME = {
forest:{ floor:['#3b6b3a','#376634','#40733c'], wall:'#2a4a2c', wallTop:'#3a6640', deco:'#2f5630', tint:'rgba(20,50,25,.0)' },
field: { floor:['#6b7a3a','#657437','#71803e'], wall:'#4a5a2c', wallTop:'#5f7038', deco:'#5a6a30', tint:'rgba(60,60,20,.0)' },
village:{floor:['#5f7a44','#5a7440','#647f48'], wall:'#6b5138', wallTop:'#8a6a48', deco:'#4a5f36', tint:'rgba(0,0,0,0)' },
ruin: { floor:['#4a4458','#454054','#4f4a5e'], wall:'#2d2a3c', wallTop:'#413c56', deco:'#38334a', tint:'rgba(30,20,50,.12)' },
cave: { floor:['#37343f','#33303b','#3c3945'], wall:'#211f28', wallTop:'#312e3c', deco:'#2a2833', tint:'rgba(10,10,30,.22)' },
boss: { floor:['#4a2c2c','#452828','#503030'], wall:'#2a1818', wallTop:'#432424', deco:'#3a2020', tint:'rgba(60,10,0,.18)' },
};
/* deterministic pseudo random per tile */
function hash2(x,y){ let h=x*374761393+y*668265263; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967296; }
/* ==========================================================================
GAME STATE
========================================================================== */
let S = null; // persistent-ish state
let R = null; // current room runtime
let mode = 'title'; // title|play|inv|dlg|shop|dead|win
let t = 0, dt = 0, lastT = 0;
function freshState(){
return {
room:'1,1', px:9*TILE+TILE/2+TILE, py:9*TILE+TILE/2+TILE,
hp:3, maxhp:3, coins:30, sword:0, hasBow:false, arrows:0, maxArrows:12,
stones:0, inv:{}, acc:{charm:false,boots:false,pouch:false,lens:false},
taken:{}, // "room:idx" for chests / one-time
puzzles:{}, // room -> solved
kills:0, deaths:0, playtime:0, bossDead:false,
flags:{ smithTalked:false, elderTalked:false, oreGiven:false, sawBow:false },
save:{room:'1,1', px:9*TILE+TILE/2+TILE, py:9*TILE+TILE/2+TILE},
};
}
function invCount(id){ return S.inv[id]|0; }
function addInv(id,n=1){ S.inv[id]=(S.inv[id]|0)+n; }
function useInvItem(id){ if((S.inv[id]|0)>0){ S.inv[id]--; if(S.inv[id]<=0) delete S.inv[id]; return true;} return false; }
/* ==========================================================================
PARTICLES
========================================================================== */
const P = [];
function part(x,y,o={}){
P.push({x,y,
vx:o.vx!==undefined?o.vx:(Math.random()*2-1)*40,
vy:o.vy!==undefined?o.vy:(Math.random()*2-1)*40,
g:o.g||0, life:o.life||0.5, max:o.life||0.5,
r:o.r||2, col:o.col||'#fff', shape:o.shape||'sq', fade:o.fade!==false,
spin:o.spin||0, rot:o.rot||0, z:o.z||0, vz:o.vz||0
});
}
function burst(x,y,n,cols,o={}){
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2, sp=(o.spd||60)*(0.35+Math.random());
part(x,y,{vx:Math.cos(a)*sp, vy:Math.sin(a)*sp*0.7, life:(o.life||0.45)*(0.6+Math.random()*0.7),
r:o.r||2, col:cols[(Math.random()*cols.length)|0], g:o.g||0, shape:o.shape});
}
}
function updateParts(d){
for(let i=P.length-1;i>=0;i--){
const p=P[i]; p.life-=d;
if(p.life<=0){ P.splice(i,1); continue; }
p.x+=p.vx*d; p.y+=p.vy*d; p.vy+=p.g*d; p.rot+=p.spin*d;
p.vx*=(1-2.2*d); p.vy*=(1-1.6*d);
}
}
function drawParts(c){
for(const p of P){
const k=p.life/p.max;
c.globalAlpha = p.fade? Math.min(1,k*1.6) : 1;
c.fillStyle=p.col;
const r=p.r*(p.shape==='shrink'? k : 1);
if(p.shape==='circ'){ c.beginPath(); c.arc(p.x,p.y,r,0,7); c.fill(); }
else c.fillRect(p.x-r,p.y-r,r*2,r*2);
}
c.globalAlpha=1;
}
/* ==========================================================================
FLOATING TEXT
========================================================================== */
const FT=[];
function ftext(x,y,txt,col='#fff',big=false){ FT.push({x,y,txt,col,life:0.9,max:0.9,big}); }
function updateFT(d){ for(let i=FT.length-1;i>=0;i--){ const f=FT[i]; f.life-=d; f.y-=22*d; if(f.life<=0)FT.splice(i,1);} }
function drawFT(c){
for(const f of FT){
c.globalAlpha=Math.min(1,f.life/f.max*1.8);
c.font=(f.big?'bold 13px':'bold 10px')+' "Courier New",monospace';
c.textAlign='center'; c.lineWidth=3; c.strokeStyle='rgba(0,0,0,.85)';
c.strokeText(f.txt,f.x,f.y); c.fillStyle=f.col; c.fillText(f.txt,f.x,f.y);
}
c.globalAlpha=1; c.textAlign='left';
}
/* ==========================================================================
TOASTS / DIALOG
========================================================================== */
const toastEl=document.getElementById('toast');
let toastTimer=0;
function toast(msg,dur=2.2){ toastEl.textContent=msg; toastEl.classList.add('show'); toastTimer=dur; }
function updateToast(d){ if(toastTimer>0){ toastTimer-=d; if(toastTimer<=0) toastEl.classList.remove('show'); } }
const dlgEl=document.getElementById('dlg');
let dlgQueue=[], dlgAfter=null;
function say(lines, who='', after=null){
dlgQueue = (Array.isArray(lines)?lines:[lines]).map(l=> typeof l==='string'?{who,txt:l}:l);
dlgAfter = after; mode='dlg'; showDlg();
}
function showDlg(){
const d=dlgQueue[0];
dlgEl.style.display='block';
dlgEl.querySelector('.who').textContent=d.who||'';
dlgEl.querySelector('.txt').textContent=d.txt;
dlgEl.querySelector('.more').textContent = dlgQueue.length>1?'[SPACE] ▾':'[SPACE] ✕';
}
function advanceDlg(){
dlgQueue.shift(); Audio_.ui();
if(dlgQueue.length) showDlg();
else { dlgEl.style.display='none'; mode='play'; const a=dlgAfter; dlgAfter=null; if(a)a(); }
}
/* ==========================================================================
ROOM CONSTRUCTION
========================================================================== */
function walkableChar(ch){ return WALK.has(ch); }
function buildRoom(key){
const def = ROOMS[key];
const grid=[]; const pad = def.pad||'.';
for(let y=0;y<RH;y++){ grid.push(new Array(RW).fill('#')); }
for(let y=0;y<IH;y++) for(let x=0;x<IW;x++) grid[y+1][x+1]=def.rows[y][x];
const closed=new Set(def.closed||[]);
const neigh={n:[0,-1],s:[0,1],w:[-1,0],e:[1,0]};
const kx=+key.split(',')[0], ky=+key.split(',')[1];
const doors={};
for(const dir in neigh){
if(closed.has(dir)) continue;
const nk=(kx+neigh[dir][0])+','+(ky+neigh[dir][1]);
if(!ROOMS[nk]) continue;
// find a walkable interior lane near center
if(dir==='n'||dir==='s'){
const iy = dir==='n'?1:RH-2;
let best=null;
for(let off=0;off<12 && !best;off++){
for(const s of [1,-1]){
const x=Math.floor(RW/2)+off*s;
if(x<1||x>RW-2) continue;
if(walkableChar(grid[iy][x])||grid[iy][x]==='G'){ best=x; break; }
}
}
if(best===null) best=Math.floor(RW/2);
const xs=[best]; if(walkableChar(grid[iy][best+1])||grid[iy][best+1]==='G') xs.push(best+1);
else if(walkableChar(grid[iy][best-1])||grid[iy][best-1]==='G') xs.unshift(best-1);
const by = dir==='n'?0:RH-1;
for(const x of xs) grid[by][x]='D';
doors[dir]={tiles:xs.map(x=>[x,by]), to:nk, dir};
} else {
const ix = dir==='w'?1:RW-2;
let best=null;
for(let off=0;off<8 && !best;off++){
for(const s of [1,-1]){
const y=Math.floor(RH/2)+off*s;
if(y<1||y>RH-2) continue;
if(walkableChar(grid[y][ix])||grid[y][ix]==='G'){ best=y; break; }
}
}
if(best===null) best=Math.floor(RH/2);
const ys=[best]; if(walkableChar(grid[best+1]?.[ix])) ys.push(best+1);
else if(walkableChar(grid[best-1]?.[ix])) ys.unshift(best-1);
const bx = dir==='w'?0:RW-1;
for(const y of ys) grid[y][bx]='D';
doors[dir]={tiles:ys.map(y=>[y===undefined?0:bx,y]).map((_,i)=>[bx,ys[i]]), to:nk, dir};
}
}
// extract entities
const ents={chests:[],pots:[],blocks:[],plates:[],braziers:[],targets:[],gates:[],npcs:[],saves:[],shops:[],doorsLocked:[]};
let chestIdx=0;
for(let y=0;y<RH;y++)for(let x=0;x<RW;x++){
const ch=grid[y][x]; const cx=x*TILE+TILE/2, cy=y*TILE+TILE/2;
switch(ch){
case 'C': { const cfg=(def.chests||[])[chestIdx]||{coins:15};
ents.chests.push({x:cx,y:cy,tx:x,ty:y,idx:chestIdx,cfg,open:!!S.taken[key+':c'+chestIdx]});
chestIdx++; grid[y][x]='.'; break; }
case 'p': ents.pots.push({x:cx,y:cy,tx:x,ty:y,hp:1,broken:false}); grid[y][x]='.'; break;
case 'B': ents.blocks.push({x:cx,y:cy,tx:x,ty:y,on:false}); grid[y][x]='.'; break;
case 'P': ents.plates.push({x:cx,y:cy,tx:x,ty:y,on:false}); grid[y][x]='.'; break;
case 'b': ents.braziers.push({x:cx,y:cy,tx:x,ty:y,lit:false,puzzle:true}); grid[y][x]='.'; break;
case 'g': ents.braziers.push({x:cx,y:cy,tx:x,ty:y,lit:true,puzzle:false}); grid[y][x]='.'; break;
case 'o': ents.targets.push({x:cx,y:cy,tx:x,ty:y,hit:false}); grid[y][x]='.'; break;
case 'G': ents.gates.push({x:cx,y:cy,tx:x,ty:y}); break;
case 'L': ents.doorsLocked.push({x:cx,y:cy,tx:x,ty:y}); break;
case 'S': ents.saves.push({x:cx,y:cy,tx:x,ty:y}); grid[y][x]='.'; break;
case '$': ents.shops.push({x:cx,y:cy,tx:x,ty:y}); grid[y][x]='#'; break;
case '1': ents.npcs.push({x:cx,y:cy,tx:x,ty:y,id:'smith'}); grid[y][x]='.'; break;
case '2': ents.npcs.push({x:cx,y:cy,tx:x,ty:y,id:'elder'}); grid[y][x]='.'; break;
case '@': grid[y][x]='.'; break;
}
}
// puzzle already solved -> remove gates / mark
const solved = !!S.puzzles[key];
if(solved){ for(const g of ents.gates) grid[g.ty][g.tx]='.'; ents.gates.length=0;
for(const b of ents.braziers) b.lit=true; for(const o of ents.targets) o.hit=true;
for(const b of ents.blocks) b.solvedHide=true; }
if(S.taken[key+':lock']){ for(const d of ents.doorsLocked) grid[d.ty][d.tx]='.'; ents.doorsLocked.length=0; }
// enemies
const enemies=[];
for(const e of (def.enemies||[])){
if(S.taken[key+':e'+e.x+'_'+e.y]) continue;
enemies.push(makeEnemy(e.t,(e.x+1)*TILE+TILE/2,(e.y+1)*TILE+TILE/2,key+':e'+e.x+'_'+e.y));
}
if(def.boss && !S.bossDead) enemies.push(makeEnemy('warden',13*TILE,7*TILE,key+':boss'));
const room={key,def,grid,doors,ents,enemies,theme:THEME[def.theme]||THEME.field,
bg:null, shake:0, drops:[], projectiles:[], solvedFlash:0};
bakeRoom(room);
return room;
}
function isSolidAt(room,x,y){
const tx=Math.floor(x/TILE), ty=Math.floor(y/TILE);
if(tx<0||ty<0||tx>=RW||ty>=RH) return true;
const ch=room.grid[ty][tx];
if(ch==='D') return false;
if(!SOLID.has(ch)) return false;
return true;
}
function tileAt(room,x,y){
const tx=Math.floor(x/TILE), ty=Math.floor(y/TILE);
if(tx<0||ty<0||tx>=RW||ty>=RH) return '#';
return room.grid[ty][tx];
}
function blockAt(room,x,y){
for(const b of room.ents.blocks){ if(b.solvedHide) continue;
if(Math.abs(b.x-x)<TILE/2 && Math.abs(b.y-y)<TILE/2) return b; }
return null;
}
function solidForEntity(room,x,y,ignoreBlocks){
if(isSolidAt(room,x,y)) return true;
if(!ignoreBlocks && blockAt(room,x,y)) return true;
for(const c of room.ents.chests) if(Math.abs(c.x-x)<12&&Math.abs(c.y-y)<12) return true;
for(const p of room.ents.pots) if(!p.broken && Math.abs(p.x-x)<11&&Math.abs(p.y-y)<11) return true;
for(const b of room.ents.braziers) if(Math.abs(b.x-x)<10&&Math.abs(b.y-y)<10) return true;
for(const n of room.ents.npcs) if(Math.abs(n.x-x)<11&&Math.abs(n.y-y)<11) return true;
for(const s of room.ents.saves) if(Math.abs(s.x-x)<10&&Math.abs(s.y-y)<10) return true;
return false;
}
/* ==========================================================================
ROOM BACKGROUND BAKING
========================================================================== */
function bakeRoom(room){
const c=document.createElement('canvas'); c.width=RW*TILE; c.height=RH*TILE;
const k=c.getContext('2d'); k.imageSmoothingEnabled=false;
const th=room.theme;
for(let y=0;y<RH;y++)for(let x=0;x<RW;x++){
const ch=room.grid[y][x]; const px=x*TILE, py=y*TILE; const rnd=hash2(x,y+room.key.length*7);
// base floor everywhere
k.fillStyle=th.floor[(rnd*th.floor.length)|0];
k.fillRect(px,py,TILE,TILE);
// subtle speckle
if(rnd>0.82){ k.fillStyle='rgba(255,255,255,.035)'; k.fillRect(px+((rnd*20)|0)%20, py+((rnd*37)|0)%20, 2,2); }
if(rnd<0.12){ k.fillStyle='rgba(0,0,0,.10)'; k.fillRect(px+((rnd*90)|0)%18, py+((rnd*61)|0)%18, 3,2); }
if(ch==='#'){ drawWallTile(k,px,py,th,room,x,y); }
else if(ch==='T'){ } // tree drawn as object layer
else if(ch==='~'){ } // water animated
else if(ch==='='){ // bridge / plank
k.fillStyle='#6b5138'; k.fillRect(px,py,TILE,TILE);
k.fillStyle='#7d6042'; k.fillRect(px,py,TILE,3); k.fillRect(px,py+11,TILE,3);
k.fillStyle='rgba(0,0,0,.25)'; k.fillRect(px,py+8,TILE,2); k.fillRect(px,py+19,TILE,2);
k.fillStyle='#4d3a28'; k.fillRect(px+2,py,2,TILE); k.fillRect(px+TILE-4,py,2,TILE);
}
else if(ch===','){ // path
k.fillStyle='#8a7a58'; k.fillRect(px,py,TILE,TILE);
for(let i=0;i<7;i++){ const r=hash2(x*13+i,y*7+i);
k.fillStyle= r>0.5?'rgba(255,255,255,.07)':'rgba(0,0,0,.10)';
k.fillRect(px+((r*TILE)|0), py+((hash2(i,x+y)*TILE)|0), 3,2); }
}
else if(ch==='f'){ // flowers
const cols=['#e8d05a','#e87a9a','#c8e0f0'];
for(let i=0;i<3;i++){ const r=hash2(x*3+i,y*5+i);
k.fillStyle=cols[(r*3)|0]; k.fillRect(px+3+((r*16)|0), py+4+((hash2(i,y)*15)|0), 2,2); }
}
else if(ch==='r'){ } // rock object layer
else if(ch==='D'){ // doorway
k.fillStyle='#1a1620'; k.fillRect(px,py,TILE,TILE);
k.fillStyle='rgba(0,0,0,.5)'; k.fillRect(px,py,TILE,TILE);
}
}
// vignette per-room
const g=k.createRadialGradient(c.width/2,c.height/2,60,c.width/2,c.height/2,c.width*0.62);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,.35)');
k.fillStyle=g; k.fillRect(0,0,c.width,c.height);
if(th.tint!=='rgba(0,0,0,0)'){ k.fillStyle=th.tint; k.fillRect(0,0,c.width,c.height); }
room.bg=c;
}
function drawWallTile(k,px,py,th,room,x,y){
const open = (dx,dy)=>{ const nx=x+dx, ny=y+dy;
if(nx<0||ny<0||nx>=RW||ny>=RH) return false;
const ch=room.grid[ny][nx]; return ch!=='#'&&ch!=='T'; };
k.fillStyle=th.wall; k.fillRect(px,py,TILE,TILE);
// brick texture
for(let i=0;i<2;i++){ k.fillStyle='rgba(0,0,0,.16)'; k.fillRect(px,py+i*12+11,TILE,1);
k.fillStyle='rgba(255,255,255,.05)'; k.fillRect(px,py+i*12,TILE,1); }
const off=(x%2)?0:6;
k.fillStyle='rgba(0,0,0,.13)'; k.fillRect(px+off,py,1,11); k.fillRect(px+((off+12)%24),py+12,1,11);
if(open(0,1)){ k.fillStyle='rgba(0,0,0,.45)'; k.fillRect(px,py+TILE-4,TILE,4); }
if(open(0,-1)){ k.fillStyle=th.wallTop; k.fillRect(px,py,TILE,6);
k.fillStyle='rgba(255,255,255,.10)'; k.fillRect(px,py,TILE,2); }
}
/* ==========================================================================
ENEMIES
========================================================================== */
const ETYPE = {
slime: {hp:4, spd:26, dmg:1, r:8, col:'#5ad07a', col2:'#2f7a48', coins:[4,9], xp:1, ai:'hop', name:'Bogslime'},
bat: {hp:3, spd:52, dmg:1, r:7, col:'#9b6bd0', col2:'#4d2f70', coins:[3,8], xp:1, ai:'flit', name:'Cave Bat', fly:true},
husk: {hp:8, spd:34, dmg:2, r:9, col:'#b08a52', col2:'#6b4f2c', coins:[7,14], xp:2, ai:'chase', name:'Ash Husk'},
skeleton:{hp:12, spd:42, dmg:2, r:9, col:'#ddd8c8', col2:'#8a8474', coins:[12,20], xp:3, ai:'lunge', name:'Cinder Bones'},
captain: {hp:26, spd:32, dmg:3, r:11, col:'#c85a4a', col2:'#7a2c24', coins:[38,52], xp:6, ai:'charge',name:'Husk Captain', big:true},
warden: {hp:90, spd:36, dmg:3, r:16, col:'#e05a2a', col2:'#7a1f10', coins:[0,0], xp:0, ai:'boss', name:'The Warden', big:true, boss:true},
};
function makeEnemy(t,x,y,id){
const d=ETYPE[t];
return {t,id,x,y,hx:x,hy:y, hp:d.hp,maxhp:d.hp, r:d.r, dir:1, vx:0,vy:0,
st:'idle', tmr:Math.random()*1.5, hitT:0, kb:0,kbx:0,kby:0, atkCd:0,
anim:Math.random()*6, dead:false, phase:1, invul:0, telegraph:0, def:d};
}
/* ==========================================================================
PLAYER
========================================================================== */
const PL = {
x:0,y:0, dir:'down', vx:0,vy:0,
anim:0, walkT:0, stepT:0,
atkT:0, atkDir:0, atkKind:0, atkHits:null, atkCd:0,
shootCd:0,
hurtT:0, invulT:0, flashT:0,
stam:100, maxStam:100, sprinting:false, stamCd:0,
spinT:0, dead:false, deadT:0,
quick:['salve','flask','arrows','',''],
};
function playerSpeed(){
let s = 78;
if(S.acc.boots) s*=1.2;
if(PL.sprinting) s*=1.55;
return s;
}
function playerRect(){ return {x:PL.x-7,y:PL.y-6,w:14,h:14}; }
function damagePlayer(n, srcx, srcy){
if(PL.invulT>0||PL.dead||mode!=='play') return;
if(S.acc.charm) n = Math.max(1, Math.round(n*0.7));
S.hp -= n;
PL.invulT = 1.1; PL.hurtT=0.34; R.shake=Math.max(R.shake,7);
Audio_.hurt();
ftext(PL.x,PL.y-16,'-'+n,'#ff6a6a',true);
const a=Math.atan2(PL.y-srcy,PL.x-srcx);
PL.vx=Math.cos(a)*180; PL.vy=Math.sin(a)*180;
burst(PL.x,PL.y-4,10,['#ff4a4a','#ff9a6a','#ffd0a0'],{spd:90,life:.4,r:2});
if(S.hp<=0){ S.hp=0; playerDie(); }
}
function healPlayer(n){
const before=S.hp; S.hp=Math.min(S.maxhp*2, S.hp+n);
if(S.hp>before){ ftext(PL.x,PL.y-18,'+'+(S.hp-before),'#7aff9a',true);
burst(PL.x,PL.y-6,14,['#7aff9a','#d0ffe0','#ffffff'],{spd:50,life:.7,r:2,g:-40}); }
}
function playerDie(){
PL.dead=true; PL.deadT=0; mode='dead'; S.deaths++;
Audio_.die();
burst(PL.x,PL.y-4,30,['#ff4a4a','#8a2020','#ffd0a0'],{spd:120,life:.9,r:3,g:60});
setTimeout(()=>{ document.getElementById('deadPanel').classList.add('show'); }, 900);
}
/* ==========================================================================
PROGRESS / GATES
========================================================================== */
function progressPct(){
let p=0;
p += S.stones*18;
p += S.sword*10;
p += (S.maxhp-3)*7;
if(S.hasBow) p+=8;
return Math.min(100, Math.round(p));
}
function checkNeed(need){
if(need==='stones2') return S.stones>=2;
if(need==='stones3') return S.stones>=3;
return true;
}
/* ==========================================================================
ROOM TRANSITION
========================================================================== */
let trans = null; // {phase:'out'|'in', t, to, dir}
function gotoRoom(key, dir){
if(trans) return;
trans={phase:'out',t:0,to:key,dir};
Audio_.door();
}
function finishTransition(){
const from=R? R.key:null;
S.room=trans.to;
R=buildRoom(trans.to);
// place player at opposite door
const opp={n:'s',s:'n',w:'e',e:'w'}[trans.dir];
const d=R.doors[opp];
if(d){
const [tx,ty]=d.tiles[0];
PL.x=tx*TILE+TILE/2 + (d.tiles.length>1? TILE/2:0);
PL.y=ty*TILE+TILE/2;
if(opp==='n') PL.y+=TILE*1.1; if(opp==='s') PL.y-=TILE*1.1;
if(opp==='w') PL.x+=TILE*1.1; if(opp==='e') PL.x-=TILE*1.1;
}
PL.vx=PL.vy=0;
toast(R.def.name, 2.0);
if(R.def.note && !S.taken[trans.to+':note']){ S.taken[trans.to+':note']=1; setTimeout(()=>toast('“'+R.def.note+'”',3.4),1500); }
if(R.def.boss && !S.bossDead) Audio_.boss();
}
/* ==========================================================================
PICKUP DROPS
========================================================================== */
function drop(x,y,kind,val){
R.drops.push({x,y,kind,val,vy:-60,z:6,life:26,anim:Math.random()*6,pickT:0.35});
}
function dropCoins(x,y,amount){
let left=amount, guard=0;
while(left>0 && guard++<12){
const v = left>=10? 10 : (left>=5?5:1);
left-=v;
const a=Math.random()*Math.PI*2, sp=20+Math.random()*30;
const d={x,y,kind:'coin',val:v,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,z:8,vz:70,life:24,anim:Math.random()*6,pickT:0.3};
R.drops.push(d);
}
}
function updateDrops(d){
for(let i=R.drops.length-1;i>=0;i--){
const o=R.drops[i]; o.life-=d; o.anim+=d*4; if(o.pickT>0)o.pickT-=d;
if(o.vz!==undefined){ o.z+=o.vz*d; o.vz-=340*d; if(o.z<0){ o.z=0; o.vz*=-0.35; if(Math.abs(o.vz)<12)o.vz=0; } }
if(o.vx){ const nx=o.x+o.vx*d; if(!isSolidAt(R,nx,o.y)) o.x=nx; else o.vx*=-0.4; o.vx*=(1-3*d); }
if(o.vy&&o.vz!==undefined){ const ny=o.y+o.vy*d; if(!isSolidAt(R,o.x,ny)) o.y=ny; else o.vy*=-0.4; o.vy*=(1-3*d); }
if(o.life<=0){ R.drops.splice(i,1); continue; }
// magnet + pickup
const dx=PL.x-o.x, dy=PL.y-4-o.y, dd=Math.hypot(dx,dy);
if(o.pickT<=0 && dd<40 && !PL.dead){ o.x+=dx/dd*120*d; o.y+=dy/dd*120*d; }
if(o.pickT<=0 && dd<12){ collect(o); R.drops.splice(i,1); }
}
}
function collect(o){
if(o.kind==='coin'){
let v=o.val; if(S.acc.pouch) v=Math.ceil(v*1.6);
S.coins+=v; Audio_.coin(); ftext(o.x,o.y-10,'+'+v,'#ffe07a');
burst(o.x,o.y,5,['#ffe07a','#fff0b0'],{spd:40,life:.35,r:1.5});
} else if(o.kind==='hp'){
healPlayer(o.val); Audio_.pickup();
} else if(o.kind==='arrow'){
S.arrows=Math.min(S.maxArrows,S.arrows+o.val); Audio_.pickup();
ftext(o.x,o.y-10,'+'+o.val+' arrows','#c8e0f0');
} else if(o.kind==='item'){
addInv(o.val); Audio_.pickup(); ftext(o.x,o.y-12,ITEMS[o.val].name,'#f0c060');
burst(o.x,o.y,10,['#f0c060','#fff0c0'],{spd:50,life:.6,r:2,g:-30});
}
}
/* ==========================================================================
COMBAT — player attacks
========================================================================== */
const DIRV={up:[0,-1],down:[0,1],left:[-1,0],right:[1,0]};
function dirAngle(d){ return {right:0,down:Math.PI/2,left:Math.PI,up:-Math.PI/2}[d]; }
function tryAttack(){
if(PL.atkCd>0||PL.atkT>0||PL.dead) return;
const w=SWORDS[S.sword];
PL.atkT=w.wind + 0.16; PL.atkTotal=PL.atkT; PL.atkDir=dirAngle(PL.dir);
PL.atkCd=w.cd; PL.atkHits=new Set(); PL.atkKind=S.sword;
Audio_.swing(S.sword);
if(w.spin) PL.spinT=0.30;
// swing dust
const a=PL.atkDir;
for(let i=0;i<5;i++){
const aa=a+(Math.random()-0.5)*w.arc;
part(PL.x+Math.cos(aa)*12, PL.y-3+Math.sin(aa)*12,
{vx:Math.cos(aa)*70,vy:Math.sin(aa)*60,life:0.22,r:1.6,
col:w.fire?(S.sword===2?'#9fe8ff':'#ffb060'):'#ddd8e8', shape:'shrink'});
}
}
function attackHitCheck(){
if(!PL.atkHits||!PL.atkTotal) return; // no swing in flight — nothing to resolve
const w=SWORDS[S.sword];
const prog = 1 - (PL.atkT/PL.atkTotal);
if(prog < w.wind/PL.atkTotal) return;
const base = PL.atkDir;
const arc = w.spin? Math.PI*2 : w.arc;
const sweep = base - arc/2 + arc*Math.min(1,(prog-0.3)/0.7);
const range=w.range;
const hx=PL.x+Math.cos(sweep)*range*0.75, hy=PL.y-3+Math.sin(sweep)*range*0.75;
for(const e of R.enemies){
if(e.dead||PL.atkHits.has(e)) continue;
const dx=e.x-PL.x, dy=(e.y-3)-(PL.y-3);
const dd=Math.hypot(dx,dy);
if(dd > range+e.r) continue;
let ad=Math.atan2(dy,dx)-base;
while(ad>Math.PI)ad-=Math.PI*2; while(ad<-Math.PI)ad+=Math.PI*2;
if(!w.spin && Math.abs(ad)>arc/2+0.25) continue;
hitEnemy(e, w.dmg, PL.x,PL.y, w.fire?(S.sword===2?'shock':'fire'):'slash');
PL.atkHits.add(e);
}
// pots / breakables
for(const p of R.ents.pots){
if(p.broken||PL.atkHits.has(p)) continue;
if(Math.hypot(p.x-PL.x,p.y-PL.y)<range+8){ breakPot(p); PL.atkHits.add(p); }
}
// braziers: light with fire sword
if(w.fire) for(const b of R.ents.braziers){
if(b.lit||PL.atkHits.has(b)) continue;
if(Math.hypot(b.x-PL.x,b.y-PL.y)<range+9){ lightBrazier(b); PL.atkHits.add(b); }
}
// targets can be hit in melee only if reachable (usually not)
for(const o of R.ents.targets){
if(o.hit||PL.atkHits.has(o)) continue;
if(Math.hypot(o.x-PL.x,o.y-PL.y)<range+8){ hitTarget(o); PL.atkHits.add(o); }
}
}
function hitEnemy(e,dmg,fx,fy,type){
if(e.invul>0) return;
e.hp-=dmg; e.hitT=0.18; e.invul=0.06;
const a=Math.atan2(e.y-fy,e.x-fx);
const kbf = e.def.big?60:150;
e.kbx=Math.cos(a)*kbf; e.kby=Math.sin(a)*kbf; e.kb=0.18;
R.shake=Math.max(R.shake, e.def.big?4:2.5);
Audio_.hit();
const cols = type==='fire'?['#ffd070','#ff8a3c','#ff5020']
: type==='shock'?['#dffcff','#7ce8ff','#3aa0ff']
: type==='arrow'?['#e8e0d0','#b0a898']
: ['#ffffff','#ffd0d0','#ff8080'];
burst(e.x,e.y-3,type==='arrow'?7:11,cols,{spd:90,life:.35,r:2});
ftext(e.x,e.y-e.r-6,''+dmg, type==='shock'?'#9fe8ff':(type==='fire'?'#ffb060':'#ffffff'));
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.dead=true; e.deadT=0; S.kills++;
Audio_.kill();
burst(e.x,e.y-3,20,[e.def.col,e.def.col2,'#ffffff'],{spd:110,life:.6,r:2.5,g:50});
if(e.def.boss){ onBossDead(e); return; }
S.taken[e.id]=1;
const [a,b]=e.def.coins;
dropCoins(e.x,e.y,a+((Math.random()*(b-a+1))|0));
if(Math.random()<0.18) drop(e.x+6,e.y,'hp',2);
if(S.hasBow && Math.random()<0.4) drop(e.x-6,e.y,'arrow',2);
// captain drops the ore chest key-lock removal
if(e.t==='captain'){ toast('The chest is unguarded now.',2.4); }
if(e.t==='skeleton' && R.key==='3,2' && !S.taken['3,2:lock'] && invCount('ironkey')===0){
drop(e.x,e.y+6,'item','ironkey'); toast('The bones dropped an Iron Key!',2.6);
}
}
function onBossDead(e){
S.bossDead=true; mode='win';
for(let i=0;i<70;i++) setTimeout(()=>{
burst(e.x+(Math.random()-.5)*60, e.y+(Math.random()-.5)*50, 6,
['#ffd070','#ff8a3c','#ffffff','#7ce8ff'],{spd:120,life:.8,r:3,g:20});
}, i*26);
Audio_.fanfare();
R.shake=14;
setTimeout(()=>{ document.getElementById('winPanel').classList.add('show');
document.getElementById('winStats').innerHTML =
`foes felled <b>${S.kills}</b> · coins <b>${S.coins}</b> · deaths <b>${S.deaths}</b> · time <b>${fmtTime(S.playtime)}</b>`;
}, 2400);
}
function breakPot(p){
p.broken=true; Audio_.hit();
burst(p.x,p.y-2,14,['#a06840','#7a4a2c','#c89060'],{spd:80,life:.5,r:2,g:180});
const r=Math.random();
if(r<0.42) dropCoins(p.x,p.y,2+((Math.random()*6)|0));
else if(r<0.55) drop(p.x,p.y,'hp',2);
else if(r<0.68 && S.hasBow) drop(p.x,p.y,'arrow',3);
else if(r<0.72) drop(p.x,p.y,'item','salve');
}
function lightBrazier(b){
b.lit=true; Audio_.pickup();
burst(b.x,b.y-8,18,['#ffd070','#ff8a3c','#ff5020'],{spd:60,life:.7,r:2,g:-60});
checkPuzzle();
}
function hitTarget(o){
o.hit=true; Audio_.pickup();
burst(o.x,o.y,16,['#7ce8ff','#ffffff','#f0c060'],{spd:70,life:.6,r:2});
checkPuzzle();
}
/* ---------- ranged ---------- */
function tryShoot(){
if(!S.hasBow||PL.shootCd>0||PL.dead) return;
if(S.arrows<=0){ Audio_.deny(); toast('Out of arrows.',1.2); return; }
/* bowT alone drives the draw pose — atkT means "a melee swing is running"
and attackHitCheck needs the swing fields tryAttack sets, so don't touch it here */
S.arrows--; PL.shootCd=0.34; PL.bowT=0.22;
Audio_.bow();
const [dx,dy]=DIRV[PL.dir];
R.projectiles.push({x:PL.x+dx*10,y:PL.y-4+dy*8,vx:dx*250,vy:dy*250,life:1.5,friendly:true,dmg:3,dir:PL.dir});
part(PL.x+dx*8,PL.y-4+dy*8,{vx:-dx*40,vy:-dy*40,life:0.2,r:1.5,col:'#e8e0d0',shape:'shrink'});
}
function updateProjectiles(d){
for(let i=R.projectiles.length-1;i>=0;i--){
const pr=R.projectiles[i]; pr.life-=d;
pr.x+=pr.vx*d; pr.y+=pr.vy*d;
let gone = pr.life<=0;
if(!gone && pr.friendly){
if(isSolidAt(R,pr.x,pr.y) && tileAt(R,pr.x,pr.y)!=='~'){
// arrows fly over water but stop at walls
const ch=tileAt(R,pr.x,pr.y);
if(ch!=='~'){ gone=true; burst(pr.x,pr.y,5,['#c8c0b0'],{spd:40,life:.25,r:1.5}); }
}
for(const o of R.ents.targets){ if(!o.hit && Math.hypot(o.x-pr.x,o.y-pr.y)<11){ hitTarget(o); gone=true; } }
for(const b of R.ents.braziers){ if(!b.lit && Math.hypot(b.x-pr.x,b.y-pr.y)<11 && S.sword>0){ lightBrazier(b); gone=true; } }
for(const p of R.ents.pots){ if(!p.broken && Math.hypot(p.x-pr.x,p.y-pr.y)<10){ breakPot(p); gone=true; } }
for(const e of R.enemies){ if(e.dead) continue;
if(Math.hypot(e.x-pr.x,(e.y-3)-pr.y)<e.r+3){ hitEnemy(e,pr.dmg,pr.x,pr.y,'arrow'); gone=true; break; } }
} else if(!pr.friendly){
if(isSolidAt(R,pr.x,pr.y)){ gone=true; burst(pr.x,pr.y,6,['#ff8a4a'],{spd:50,life:.3,r:2}); }
if(Math.hypot(PL.x-pr.x,(PL.y-4)-pr.y)<10){ damagePlayer(pr.dmg,pr.x,pr.y); gone=true; }
}
if(!pr.friendly) part(pr.x,pr.y,{vx:0,vy:0,life:0.25,r:2.5,col:'#ff7a3a',shape:'shrink'});
if(gone) R.projectiles.splice(i,1);
}
}
/* ==========================================================================
PUZZLES
========================================================================== */
function checkPuzzle(){
if(S.puzzles[R.key]) return;
const e=R.ents; let solved=false;
const by=R.def.gateBy;
if(by==='braziers'){ const pz=e.braziers.filter(b=>b.puzzle); solved = pz.length>0 && pz.every(b=>b.lit); }
else if(by==='targets'){ solved = e.targets.length>0 && e.targets.every(o=>o.hit); }
else if(e.plates.length>0){ solved = e.plates.every(p=>p.on); }
if(solved) solvePuzzle();
}
function solvePuzzle(){
S.puzzles[R.key]=1; R.solvedFlash=1.2;
Audio_.save(); Audio_.fanfare();
for(const g of R.ents.gates){
R.grid[g.ty][g.tx]='.';
burst(g.x,g.y,22,['#f0c060','#fff0c0','#ffffff'],{spd:100,life:.8,r:2.5,g:-30});
}
R.ents.gates.length=0; bakeRoom(R);
R.shake=6;
toast('Something heavy grinds open...',2.6);
ftext(PL.x,PL.y-24,'PUZZLE SOLVED','#f0c060',true);
}
function updatePlates(){
if(S.puzzles[R.key]) return;
let changed=false;
for(const p of R.ents.plates){
const on = !!R.ents.blocks.find(b=>!b.solvedHide && Math.abs(b.x-p.x)<10 && Math.abs(b.y-p.y)<10);
if(on!==p.on){ p.on=on; changed=true;
if(on){ Audio_.ui(); burst(p.x,p.y,10,['#f0c060','#ffffff'],{spd:45,life:.4,r:2}); } }
}
if(changed) checkPuzzle();
}
/* block pushing */
function tryPush(b, dx, dy, d){
b.pushT = (b.pushT||0) + d;
if(b.pushT < 0.28) return false;
const nx=b.x+dx*TILE, ny=b.y+dy*TILE;
// target tile must be free
const tx=Math.floor(nx/TILE), ty=Math.floor(ny/TILE);
if(isSolidAt(R,nx,ny)) { b.pushT=0; return false; }
if(blockAt(R,nx,ny)){ b.pushT=0; return false; }
for(const c of R.ents.chests) if(Math.abs(c.x-nx)<14&&Math.abs(c.y-ny)<14){b.pushT=0;return false;}
for(const p of R.ents.pots) if(!p.broken&&Math.abs(p.x-nx)<14&&Math.abs(p.y-ny)<14){b.pushT=0;return false;}
b.x=tx*TILE+TILE/2; b.y=ty*TILE+TILE/2; b.tx=tx; b.ty=ty; b.pushT=0;
b.slide=0.14; b.sdx=dx; b.sdy=dy;
Audio_.push(); R.shake=Math.max(R.shake,2);
burst(b.x-dx*10,b.y+8-dy*4,8,['#8a8474','#6b6558'],{spd:40,life:.4,r:1.5,g:60});
updatePlates();
return true;
}
/* ==========================================================================
INTERACTION (E key)
========================================================================== */
function nearestInteract(){
const cands=[];
const px=PL.x, py=PL.y;
for(const c of R.ents.chests) if(!c.open) cands.push({o:c,type:'chest',d:Math.hypot(c.x-px,c.y-py)});
for(const s of R.ents.saves) cands.push({o:s,type:'save',d:Math.hypot(s.x-px,s.y-py)});
for(const n of R.ents.npcs) cands.push({o:n,type:'npc',d:Math.hypot(n.x-px,n.y-py)});
for(const s of R.ents.shops) cands.push({o:s,type:'shop',d:Math.hypot(s.x-px,s.y-py+8)});
for(const dd of R.ents.doorsLocked) cands.push({o:dd,type:'lock',d:Math.hypot(dd.x-px,dd.y-py)});
cands.sort((a,b)=>a.d-b.d);
if(cands.length && cands[0].d<26) return cands[0];
return null;
}
function doInteract(){
const it=nearestInteract(); if(!it) return;
if(it.type==='chest') openChest(it.o);
else if(it.type==='save') doSave(it.o);
else if(it.type==='npc') talkNPC(it.o);
else if(it.type==='shop') openShop();
else if(it.type==='lock') tryUnlock();
}
function tryUnlock(){
if(useInvItem('ironkey')){
S.taken[R.key+':lock']=1;
for(const d of R.ents.doorsLocked){ R.grid[d.ty][d.tx]='.';
burst(d.x,d.y,20,['#c8c0a0','#f0c060'],{spd:80,life:.7,r:2}); }
R.ents.doorsLocked.length=0; bakeRoom(R); Audio_.door(); Audio_.save();
toast('The Iron Key turns. The way opens.',2.4);
} else { Audio_.deny(); toast('Barred. An iron key would fit here.',2.0); }
}
function openChest(c){
if(c.cfg.locked==='captain'){
const cap=R.enemies.find(e=>e.t==='captain'&&!e.dead);
if(cap){ Audio_.deny(); toast('The Captain\'s sigil seals it.',2.0); return; }
}
c.open=true; S.taken[R.key+':c'+c.idx]=1;
Audio_.pickup();
burst(c.x,c.y-6,26,['#f0c060','#fff0c0','#ffffff'],{spd:70,life:.9,r:2.5,g:-50});
const cfg=c.cfg;
if(cfg.coins){ dropCoins(c.x,c.y+2,cfg.coins); toast('Ember coins!',1.6); }
else if(cfg.heart){ S.maxhp++; S.hp=S.maxhp*2; Audio_.heart();
ftext(c.x,c.y-24,'MAX HEALTH UP','#ff6a8a',true);
toast('Heart Ember! Max health is now '+S.maxhp+' hearts.',3.2);
for(let i=0;i<3;i++) setTimeout(()=>burst(PL.x,PL.y-8,18,['#ff6a8a','#ffb0c0','#ffffff'],{spd:70,life:.9,r:2,g:-40}),i*160);
}
else if(cfg.item==='bow'){ S.hasBow=true; S.arrows=12; Audio_.heart();
say([{who:'',txt:'A slender bow of blackened yew rests in the chest, still warm. Arrows bundle themselves at your hip.'},
{who:'THE EMBERBOW',txt:'Press [K] or [X] to fire. Arrows strike distant things — and can carry your blade\'s fire.'}],'',()=>{});
ftext(c.x,c.y-24,'EMBERBOW','#7ce8ff',true);
}
else if(cfg.item==='sword3'){ upgradeSword(2); }
else if(cfg.item==='stone'){ S.stones++; Audio_.heart();
ftext(c.x,c.y-24,'EMBERSTONE '+S.stones+'/3','#ff8a3c',true);
toast('Emberstone recovered — '+S.stones+' of 3.',3.0);
for(let i=0;i<3;i++) setTimeout(()=>burst(c.x,c.y-8,20,['#ff8a3c','#ffd070','#ffffff'],{spd:80,life:1,r:2.5,g:-40}),i*180);
}
else if(cfg.item){ addInv(cfg.item); ftext(c.x,c.y-22,ITEMS[cfg.item].name,'#f0c060',true);
toast('Found: '+ITEMS[cfg.item].name,2.4); }
}
function upgradeSword(lv){
S.sword=lv; Audio_.fanfare();
const w=SWORDS[lv];
ftext(PL.x,PL.y-26,w.name.toUpperCase(),'#ffd070',true);
for(let i=0;i<4;i++) setTimeout(()=>burst(PL.x,PL.y-8,22,
lv===2?['#7ce8ff','#dffcff','#ffffff']:['#ff8a3c','#ffd070','#ffffff'],
{spd:100,life:1,r:3,g:-40}),i*170);
if(lv===1) say([{who:'THE EMBER BLADE',txt:'The smith\'s work: ore-fed steel wrapped in living flame. It bites deeper, swings faster — and its fire will wake cold braziers.'}]);
else say([{who:'THE TEMPEST EDGE',txt:'Storm-forged. Every swing becomes a full spinning arc that cuts everything around you. Heavy damage, wide reach.'}]);
}
function doSave(s){
S.save={room:R.key,px:PL.x,py:PL.y};
S.hp=S.maxhp*2;
writeSave();
Audio_.save();
burst(s.x,s.y-10,28,['#7ce8ff','#ffffff','#a0f0ff'],{spd:60,life:1.1,r:2,g:-50});
ftext(s.x,s.y-26,'SAVED','#7ce8ff',true);
toast('Progress saved. Health restored.',2.6);
}
function talkNPC(n){
if(n.id==='smith') smithTalk();
else elderTalk();
}
function smithTalk(){
if(S.sword===0 && invCount('ore')>0){
say([{who:'BRANN THE SMITH',txt:'Ember Ore! From the Captain\'s hoard, no doubt. Hold still.'},
{who:'BRANN THE SMITH',txt:'*hammer, hiss, hammer* ...There. Your rusted stub is a blade again.'}],'',()=>{
useInvItem('ore'); S.flags.oreGiven=true; upgradeSword(1);
});
} else if(S.sword===0){
say([{who:'BRANN THE SMITH',txt:'That rusted thing? It\'ll break on a slime\'s backside.'},
{who:'BRANN THE SMITH',txt:'Bring me Ember Ore and I\'ll forge you something with fire in it. The husks in the eastern fields sit on a hoard of it — their Captain especially.'}]);
} else if(S.sword===1){
say([{who:'BRANN THE SMITH',txt:'Ember Blade suits you. Note this: its fire lights the old braziers in the ruins. Cold stone won\'t bar a burning sword.'},
{who:'BRANN THE SMITH',txt:'There\'s a better steel down in the deep, past the long water span. Storm-forged. I couldn\'t make it if I tried.'}]);
} else {
say([{who:'BRANN THE SMITH',txt:'Tempest Edge. Hah! Mind you don\'t cut the village in half.'}]);
}
S.flags.smithTalked=true;
}
function elderTalk(){
const lines=[];
if(!S.flags.elderTalked){
lines.push({who:'ELDER MAEVE',txt:'You came. Good. The Forge-Heart below has gone cold, and three Emberstones were scattered when it cracked.'});
lines.push({who:'ELDER MAEVE',txt:'One lies in the grove west of here, sunk behind sinking stones. One in the ruins east. The last, deep below, past water.'});
lines.push({who:'ELDER MAEVE',txt:'Bring all three to the Heart and something very old will finally sleep. Rest at the blue lanterns — they remember you.'});
} else {
if(S.stones===0) lines.push({who:'ELDER MAEVE',txt:'No stones yet? The grove, west. Push the sinking stones onto the marks.'});
else if(S.stones===1) lines.push({who:'ELDER MAEVE',txt:'One stone. The ruins east hold another — but you\'ll need fire to pass their braziers, and iron to pass their door.'});
else if(S.stones===2) lines.push({who:'ELDER MAEVE',txt:'Two. The ash-wall at the Cave Mouth will thin for you now. Go down. Take a bow if you find one — the deep has eyes of stone that must be struck from afar.'});
else lines.push({who:'ELDER MAEVE',txt:'All three! The Warden\'s door will open. Go, and rekindle the Heart. We will keep a light for you.'});
lines.push({who:'ELDER MAEVE',txt:'Progress: '+progressPct()+'%. Stones '+S.stones+'/3. '+(S.hasBow?'Bow in hand. ':'')+SWORDS[S.sword].name+'.'});
}
S.flags.elderTalked=true;
say(lines);
}
/* ==========================================================================
SHOP
========================================================================== */
const SHOP_STOCK=['salve','flask','arrows','charm','boots','lens'];
let shopSel=0;
function openShop(){
mode='shop'; shopSel=0; Audio_.shopOpen(); clearTaps();
}
function closeShop(){ mode='play'; Audio_.ui(); }
/* the list has one extra row past the stock: LEAVE */
function shopRows(){ return SHOP_STOCK.length+1; }
function shopOnLeave(){ return shopSel===SHOP_STOCK.length; }
function shopConfirm(){ if(shopOnLeave()) closeShop(); else shopBuy(); }
function shopBuy(){
const id=SHOP_STOCK[shopSel]; if(!id) return;
const it=ITEMS[id];
if(id==='arrows' && !S.hasBow){ Audio_.deny(); toast('You have nothing to shoot them with.',1.8); return; }
if(it.kind==='acc' && S.acc[id]){ Audio_.deny(); toast('Already worn.',1.4); return; }
if(S.coins<it.price){ Audio_.deny(); toast('Not enough ember coins.',1.6); return; }
S.coins-=it.price; Audio_.shopBuy();
if(id==='arrows'){ S.maxArrows=Math.max(S.maxArrows,20); S.arrows=Math.min(S.maxArrows,S.arrows+8); toast('+8 arrows.',1.6); }
else if(it.kind==='acc'){ S.acc[id]=true; addInv(id); toast(it.name+' equipped!',2.2);
burst(PL.x,PL.y-8,18,['#f0c060','#ffffff'],{spd:70,life:.8,r:2,g:-40}); }
else { addInv(id); toast('Bought '+it.name+'.',1.8); }
}
/* ==========================================================================
INVENTORY / QUICK USE
========================================================================== */
let invSel=0;
function invList(){
const l=[];
for(const id in S.inv) if(S.inv[id]>0) l.push(id);
return l;
}
function useItem(id){
if(!id||!ITEMS[id]) return false;
const it=ITEMS[id];
if(it.kind==='acc'){ Audio_.ui(); toast(it.name+' is passive — always working.',2.0); return false; }
if(it.kind==='key'){ Audio_.deny(); toast(it.name+': used at the right place.',1.8); return false; }
if(id==='salve'){ if(S.hp>=S.maxhp*2){ Audio_.deny(); toast('Already at full health.',1.4); return false; }
if(useInvItem('salve')){ healPlayer(4); Audio_.pickup(); return true; } }
if(id==='flask'){ if(S.hp>=S.maxhp*2){ Audio_.deny(); toast('Already at full health.',1.4); return false; }
if(useInvItem('flask')){ healPlayer(99); Audio_.heart();
burst(PL.x,PL.y-8,26,['#ff8a3c','#ffd070','#ffffff'],{spd:90,life:1,r:2.5,g:-50}); return true; } }
if(id==='arrows'){ if(!S.hasBow){ Audio_.deny(); return false; }
if(S.arrows>=S.maxArrows){ Audio_.deny(); toast('Quiver full.',1.4); return false; }
if(useInvItem('arrows')){ S.arrows=Math.min(S.maxArrows,S.arrows+8); Audio_.pickup();
ftext(PL.x,PL.y-18,'+8 arrows','#c8e0f0'); return true; } }
return false;
}
function quickUse(slot){
const list=invList().filter(id=>ITEMS[id].kind==='use');
const id=list[slot];
if(!id){ Audio_.deny(); return; }
useItem(id);
}
/* ==========================================================================
SAVE / LOAD (localStorage)
========================================================================== */
const SAVE_KEY='emberhollow_save_v1';
function writeSave(){
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(S)); }catch(e){}
}
function readSave(){
try{ const raw=localStorage.getItem(SAVE_KEY); if(!raw) return null; return JSON.parse(raw); }catch(e){ return null; }
}
function loadFromSave(){
const d=readSave(); if(!d) return false;
S=Object.assign(freshState(),d);
S.room=S.save.room; PL.x=S.save.px; PL.y=S.save.py;
S.hp=Math.max(2,S.hp);
R=buildRoom(S.room);
return true;
}
function respawn(){
document.getElementById('deadPanel').classList.remove('show');
const d=readSave();
if(d){ S=Object.assign(freshState(),d); }
S.room=S.save.room; S.hp=S.maxhp*2;
R=buildRoom(S.room); PL.x=S.save.px; PL.y=S.save.py;
PL.dead=false; PL.invulT=1.2; PL.vx=PL.vy=0; P.length=0; FT.length=0;
mode='play'; clearTaps();
toast('You wake at the lantern.',2.4);
}
function fmtTime(sec){ const m=Math.floor(sec/60), s=Math.floor(sec%60); return m+'m '+(s<10?'0':'')+s+'s'; }
/* ==========================================================================
ENEMY AI
========================================================================== */
function moveEntity(e,dx,dy,fly){
let nx=e.x+dx, ny=e.y+dy;
const solid=(x,y)=>{
if(fly){ const ch=tileAt(R,x,y); return ch==='#'||ch==='T'||ch==='G'||ch==='L'||ch==='r'; }
return solidForEntity(R,x,y,false);
};
if(!solid(nx+Math.sign(dx)*e.r*0.7, e.y)) e.x=nx;
if(!solid(e.x, ny+Math.sign(dy)*e.r*0.7)) e.y=ny;
}
function updateEnemies(d){
const pd = ()=>Math.hypot(PL.x-0,0);
for(const e of R.enemies){
if(e.dead){ e.deadT+=d; continue; }
e.anim+=d; e.tmr-=d;
if(e.hitT>0)e.hitT-=d; if(e.invul>0)e.invul-=d; if(e.atkCd>0)e.atkCd-=d;
if(e.kb>0){ e.kb-=d; moveEntity(e,e.kbx*d,e.kby*d,e.def.fly); e.kbx*=0.86; e.kby*=0.86; continue; }
const dx=PL.x-e.x, dy=(PL.y-4)-e.y, dist=Math.hypot(dx,dy)||1;
const seeing = dist<170 && !PL.dead && mode==='play';
e.dir = dx<0?-1:1;
const ai=e.def.ai;
if(ai==='hop'){
if(e.st==='idle'){ if(e.tmr<=0){ e.st='hop'; e.tmr=0.42;
const a= seeing? Math.atan2(dy,dx)+(Math.random()-.5)*0.6 : Math.random()*Math.PI*2;
e.hvx=Math.cos(a)*e.def.spd*2.2; e.hvy=Math.sin(a)*e.def.spd*1.8; } }
else { moveEntity(e,e.hvx*d,e.hvy*d,false); e.hvx*=(1-2.4*d); e.hvy*=(1-2.4*d);
if(e.tmr<=0){ e.st='idle'; e.tmr=0.5+Math.random()*0.7;
burst(e.x,e.y+e.r*0.7,4,[e.def.col2],{spd:26,life:.3,r:1.5}); } }
}
else if(ai==='flit'){
if(e.tmr<=0){ e.tmr=0.5+Math.random()*0.6;
const a= seeing? Math.atan2(dy,dx)+(Math.random()-.5)*1.4 : Math.random()*Math.PI*2;
e.fvx=Math.cos(a)*e.def.spd*1.5; e.fvy=Math.sin(a)*e.def.spd*1.3; }
moveEntity(e,(e.fvx||0)*d,(e.fvy||0)*d+Math.sin(e.anim*9)*22*d,true);
}
else if(ai==='chase'){
if(seeing){ moveEntity(e,dx/dist*e.def.spd*d, dy/dist*e.def.spd*d,false); e.st='walk'; }
else { if(e.tmr<=0){ e.tmr=1+Math.random()*1.4; const a=Math.random()*Math.PI*2;
e.wvx=Math.cos(a)*e.def.spd*0.5; e.wvy=Math.sin(a)*e.def.spd*0.5; }
moveEntity(e,(e.wvx||0)*d,(e.wvy||0)*d,false); e.st='walk'; }
}
else if(ai==='lunge'){
if(e.st==='wind'){ e.tmr-=0; if(e.telegraph>0)e.telegraph-=d;
if(e.telegraph<=0){ e.st='dash'; e.tmr=0.34;
e.dvx=dx/dist*e.def.spd*3.6; e.dvy=dy/dist*e.def.spd*3.6; Audio_.swing(0); } }
else if(e.st==='dash'){ moveEntity(e,e.dvx*d,e.dvy*d,false);
part(e.x,e.y+4,{vx:0,vy:0,life:.24,r:2.5,col:e.def.col2,shape:'shrink'});
if(e.tmr<=0){ e.st='idle'; e.tmr=0.7; } }
else {
if(seeing && dist<120 && e.atkCd<=0){ e.st='wind'; e.telegraph=0.42; e.atkCd=1.5; }
else if(seeing){ moveEntity(e,dx/dist*e.def.spd*0.6*d,dy/dist*e.def.spd*0.6*d,false); }
else if(e.tmr<=0){ e.tmr=1.2; }
}
}
else if(ai==='charge'){
if(e.st==='wind'){ e.telegraph-=d;
if(e.telegraph<=0){ e.st='dash'; e.tmr=0.7; e.dvx=dx/dist*e.def.spd*4.2; e.dvy=dy/dist*e.def.spd*4.2;
Audio_.swing(1); R.shake=Math.max(R.shake,3); } }
else if(e.st==='dash'){
moveEntity(e,e.dvx*d,e.dvy*d,false);
part(e.x,e.y+6,{vx:(Math.random()-.5)*30,vy:0,life:.3,r:3,col:'#8a3a2a',shape:'shrink'});
if(e.tmr<=0){ e.st='idle'; e.tmr=1.0; R.shake=Math.max(R.shake,4);
burst(e.x,e.y+6,12,['#8a3a2a','#c85a4a'],{spd:70,life:.5,r:2,g:80}); } }
else { if(seeing && dist<160 && e.atkCd<=0){ e.st='wind'; e.telegraph=0.55; e.atkCd=2.2; }
else if(seeing) moveEntity(e,dx/dist*e.def.spd*d,dy/dist*e.def.spd*d,false); }
}
else if(ai==='boss'){ bossAI(e,d,dx,dy,dist); }
// contact damage
const cd = Math.hypot(PL.x-e.x,(PL.y-4)-e.y);
if(cd < e.r+8 && !PL.dead){
let dmg=e.def.dmg;
if(e.st==='dash') dmg+=1;
damagePlayer(dmg,e.x,e.y);
}
}
for(let i=R.enemies.length-1;i>=0;i--) if(R.enemies[i].dead && R.enemies[i].deadT>0.6) R.enemies.splice(i,1);
}
function bossAI(e,d,dx,dy,dist){
const hpf=e.hp/e.maxhp;
e.phase = hpf>0.62?1:(hpf>0.3?2:3);
if(e.st==='idle'){
if(e.tmr<=0){
const roll=Math.random();
if(e.phase>=2 && roll<0.38){ e.st='shoot'; e.tmr=0.5; e.shots=e.phase===3?5:3; e.shotT=0; }
else if(roll<0.7){ e.st='wind'; e.telegraph=e.phase===3?0.34:0.5; }
else { e.st='walk'; e.tmr=1.0; }
}
}
else if(e.st==='walk'){ const sp=e.def.spd*(e.phase===3?1.5:1);
moveEntity(e,dx/dist*sp*d,dy/dist*sp*d,false);
if(e.tmr<=0){ e.st='idle'; e.tmr=0.35; } }
else if(e.st==='wind'){ e.telegraph-=d;
if(e.telegraph<=0){ e.st='dash'; e.tmr=0.75; const m=e.phase===3?5.4:4.4;
e.dvx=dx/dist*e.def.spd*m; e.dvy=dy/dist*e.def.spd*m; Audio_.boss(); } }
else if(e.st==='dash'){ moveEntity(e,e.dvx*d,e.dvy*d,false);
burst(e.x,e.y+8,2,['#ff7a3a','#8a2a10'],{spd:40,life:.4,r:3});
if(e.tmr<=0){ e.st='idle'; e.tmr=0.7; R.shake=8;
burst(e.x,e.y+8,22,['#ff7a3a','#ffd070','#8a2a10'],{spd:120,life:.7,r:3,g:90});
if(Math.hypot(PL.x-e.x,PL.y-e.y)<40) damagePlayer(2,e.x,e.y); } }
else if(e.st==='shoot'){
e.shotT-=d;
if(e.shotT<=0 && e.shots>0){
e.shots--; e.shotT=0.19;
const spread=(e.shots-1)*0.2;
const a=Math.atan2(dy,dx)+spread*0.5*(Math.random()-.5)+ (e.shots%2?0.16:-0.16);
R.projectiles.push({x:e.x,y:e.y-2,vx:Math.cos(a)*160,vy:Math.sin(a)*160,life:2.4,friendly:false,dmg:2});
Audio_.bow();
}
if(e.shots<=0 && e.shotT<=0){ e.st='idle'; e.tmr=0.6; }
}
}
/* ==========================================================================
PLAYER UPDATE
========================================================================== */
function updatePlayer(d){
if(PL.dead) return;
if(PL.invulT>0)PL.invulT-=d; if(PL.hurtT>0)PL.hurtT-=d;
if(PL.atkCd>0)PL.atkCd-=d; if(PL.shootCd>0)PL.shootCd-=d;
if(PL.spinT>0)PL.spinT-=d; if(PL.bowT>0)PL.bowT-=d;
if(PL.stamCd>0)PL.stamCd-=d;
let ix=0,iy=0;
if(Keys.left)ix--; if(Keys.right)ix++; if(Keys.up)iy--; if(Keys.down)iy++;
const moving=(ix||iy);
// sprint / stamina
const wantRun = Keys.run && moving && PL.stam>2 && PL.stamCd<=0;
PL.sprinting = wantRun;
if(wantRun){ PL.stam-=(S.acc.boots?26:38)*d; if(PL.stam<=0){ PL.stam=0; PL.stamCd=1.0; PL.sprinting=false; } }
else { PL.stam=Math.min(PL.maxStam, PL.stam + (S.acc.boots?42:28)*d); }
if(moving && PL.atkT<=0){
const l=Math.hypot(ix,iy);
const sp=playerSpeed();
// facing: prefer horizontal on diagonal changes
if(ix&&!iy) PL.dir= ix<0?'left':'right';
else if(iy&&!ix) PL.dir= iy<0?'up':'down';
else if(ix) PL.dir= ix<0?'left':'right';
PL.vx += (ix/l*sp - PL.vx)*Math.min(1,16*d);
PL.vy += (iy/l*sp - PL.vy)*Math.min(1,16*d);
PL.walkT += d*(PL.sprinting?13:8);
// footsteps
PL.stepT -= d*(PL.sprinting?1.7:1);
if(PL.stepT<=0){ PL.stepT=0.34; Audio_.step(!PL.sprinting);
part(PL.x+(Math.random()-.5)*6, PL.y+7, {vx:(Math.random()-.5)*20,vy:-10,life:0.24,r:1.4,
col:'rgba(255,255,255,.28)',shape:'shrink'}); }
} else {
PL.vx += (0-PL.vx)*Math.min(1,14*d);
PL.vy += (0-PL.vy)*Math.min(1,14*d);
PL.walkT += d*2;
}
// move with collision + block pushing
const step=(ax,ay)=>{
const nx=PL.x+ax, ny=PL.y+ay;
// horizontal
if(ax){
if(!solidForEntity(R,nx+Math.sign(ax)*7, PL.y, true) ){
const b=blockAt(R,nx+Math.sign(ax)*9,PL.y);
if(b){ if(tryPush(b,Math.sign(ax),0,d)) PL.x=nx; }
else { PL.x=nx; }
}
}
if(ay){
if(!solidForEntity(R,PL.x, ny+Math.sign(ay)*7, true)){
const b=blockAt(R,PL.x,ny+Math.sign(ay)*9);
if(b){ if(tryPush(b,0,Math.sign(ay),d)) PL.y=ny; }
else { PL.y=ny; }
}
}
};
step(PL.vx*d,0); step(0,PL.vy*d);
// reset push timers of far blocks
for(const b of R.ents.blocks){ if(Math.hypot(b.x-PL.x,b.y-PL.y)>26) b.pushT=0; if(b.slide>0)b.slide-=d; }
// attacks
if(tap('atk')) tryAttack();
if(tap('shoot')) tryShoot();
if(PL.atkT>0){ attackHitCheck(); PL.atkT-=d; if(PL.atkT<=0){PL.atkT=0;PL.atkHits=null;} }
// doors
checkDoors();
updatePlates();
}
function checkDoors(){
if(trans) return; // a transition is already running — don't restart it every frame
const tx=Math.floor(PL.x/TILE), ty=Math.floor(PL.y/TILE);
for(const dir in R.doors){
const dd=R.doors[dir];
for(const [dxq,dyq] of dd.tiles){
if(tx===dxq && ty===dyq){
// edge gate check
const eg=R.def.edgeGate;
if(eg && eg.dir===dir && !checkNeed(eg.need)){
// push back
if(dir==='s') PL.y-=6; if(dir==='n') PL.y+=6;
if(dir==='e') PL.x-=6; if(dir==='w') PL.x+=6;
PL.vx=PL.vy=0;
if(!R.gateMsgT || R.gateMsgT<=0){ R.gateMsgT=2.6; toast(eg.txt,2.6); Audio_.deny();
burst(PL.x,PL.y-4,12,['#6b6070','#3a3444'],{spd:60,life:.5,r:2}); }
return;
}
gotoRoom(dd.to,dir); return;
}
}
}
}
/* ==========================================================================
RENDER HELPERS
========================================================================== */
function px(c,x,y,w,h,col){ c.fillStyle=col; c.fillRect(Math.round(x),Math.round(y),w,h); }
function shadow(c,x,y,rx,ry,a=0.3){
c.fillStyle='rgba(0,0,0,'+a+')'; c.beginPath();
c.ellipse(x,y,rx,ry,0,0,7); c.fill();
}
/* ---------- PLAYER SPRITE (procedural, equipment-aware) ---------- */
function drawPlayer(c){
const x=Math.round(PL.x), y=Math.round(PL.y);
const moving = Math.hypot(PL.vx,PL.vy)>8;
const bobF = PL.sprinting?1.6:1;
const bob = moving? Math.round(Math.sin(PL.walkT)*1.4*bobF) : Math.round(Math.sin(PL.walkT*0.5)*0.6);
const legPhase = Math.sin(PL.walkT);
const legA = moving? Math.round(legPhase*3*bobF) : 0;
const legB = moving? Math.round(-legPhase*3*bobF) : 0;
const flick = PL.invulT>0 && Math.floor(t*22)%2===0;
shadow(c,x,y+8,8,3.4,0.32);
if(flick){ c.globalAlpha=0.4; }
const hurt = PL.hurtT>0;
const skin = hurt? '#ffb0b0':'#e8b088';
const tunic= S.sword===2? '#3a6a8a' : (S.sword===1? '#7a3a4a' : '#4a5a7a');
const tunicD=S.sword===2? '#27506b' : (S.sword===1? '#5a2634' : '#333f5c');
const trim = S.acc.charm? '#c8c0a0' : '#8a7a58';
const boot = S.acc.boots? '#d8a840':'#4a3a2c';
const hair = '#6b4a2a';
const d=PL.dir;
// legs
px(c,x-5,y+2-legA,4,6,boot); px(c,x+1,y+2-legB,4,6,boot);
// cloak back when facing up
if(d==='up'){ px(c,x-7,y-9+bob,14,12,tunicD); }
// body
px(c,x-6,y-9+bob,12,12,tunic);
px(c,x-6,y-9+bob,12,2,'rgba(255,255,255,.14)');
px(c,x-6,y+1+bob,12,2,tunicD);
// belt
px(c,x-6,y-2+bob,12,2,trim);
// accessory: pouch on hip
if(S.acc.pouch) px(c,x+4,y-2+bob,3,4,'#c89040');
// arms
if(d==='left'||d==='right'){
const ax = d==='right'? x+4 : x-7;
px(c,ax,y-6+bob,3,6,skin);
} else { px(c,x-8,y-6+bob,3,6,skin); px(c,x+5,y-6+bob,3,6,skin); }
// head
px(c,x-5,y-17+bob,10,9,skin);
// hair / hood
px(c,x-6,y-18+bob,12,4,hair);
if(d==='up'){ px(c,x-6,y-18+bob,12,9,hair); }
else if(d==='left'){ px(c,x+1,y-18+bob,5,7,hair); }
else if(d==='right'){ px(c,x-6,y-18+bob,5,7,hair); }
// face
if(d==='down'){ px(c,x-3,y-12+bob,2,2,'#1a1420'); px(c,x+2,y-12+bob,2,2,'#1a1420');
if(hurt) px(c,x-1,y-9+bob,3,1,'#8a2020'); }
else if(d==='left'){ px(c,x-4,y-12+bob,2,2,'#1a1420'); }
else if(d==='right'){ px(c,x+3,y-12+bob,2,2,'#1a1420'); }
// seer lens
if(S.acc.lens && d!=='up'){ px(c,x-6,y-13+bob,12,2,'rgba(160,240,255,.55)'); }
// damage indicator: red gashes + drip
if(PL.hurtT>0){
px(c,x-4,y-6+bob,6,1,'#c02020'); px(c,x+1,y-1+bob,4,1,'#c02020');
if(Math.random()<0.5) part(x+(Math.random()-.5)*10, y-4+bob,{vx:0,vy:40,g:120,life:.4,r:1.3,col:'#c02020'});
}
// low health smoke
if(S.hp<=2 && Math.random()<0.16){
part(x+(Math.random()-.5)*10, y-6, {vx:(Math.random()-.5)*10,vy:-26,life:.7,r:2,col:'rgba(200,40,40,.5)',shape:'shrink'});
}
// exhausted indicator
if(PL.stamCd>0 && Math.random()<0.2){
part(x+(Math.random()-.5)*8, y-18, {vx:(Math.random()-.5)*16,vy:-22,life:.6,r:1.6,col:'rgba(220,220,255,.45)',shape:'shrink'});
}
c.globalAlpha=1;
drawWeapon(c,x,y,bob);
}
function drawWeapon(c,x,y,bob){
const w=SWORDS[S.sword];
const attacking = PL.atkT>0 && PL.bowT<=0;
// bow draw pose
if(PL.bowT>0 && S.hasBow){
const [dx,dy]=DIRV[PL.dir];
const bx=x+dx*9, by=y-5+dy*7;
c.save(); c.translate(bx,by); c.rotate(dirAngle(PL.dir));
c.strokeStyle='#6b4a2a'; c.lineWidth=2;
c.beginPath(); c.arc(0,0,7,-1.5,1.5); c.stroke();
c.strokeStyle='#ded4c0'; c.lineWidth=1;
c.beginPath(); c.moveTo(0,-7); c.lineTo(-3,0); c.lineTo(0,7); c.stroke();
c.restore();
return;
}
let ang, ext;
if(attacking){
const prog=1-(PL.atkT/PL.atkTotal);
const arc = w.spin? Math.PI*2.4 : w.arc;
const ease = prog<0.32? (prog/0.32)*0.18 : 0.18+((prog-0.32)/0.68)*0.82;
ang = PL.atkDir - arc/2 + arc*ease;
ext = 8 + 12*Math.sin(Math.min(1,prog*1.3)*Math.PI*0.8);
} else {
// idle carry
const base=dirAngle(PL.dir);
ang = base + (PL.dir==='left'? 1.1 : PL.dir==='right'? -1.1 : (PL.dir==='up'? 1.0 : 1.0));
ext = 7;
}
const hx=x+Math.cos(ang)*ext, hy=y-5+Math.sin(ang)*ext*0.85;
c.save(); c.translate(hx,hy); c.rotate(ang);
// handle
c.fillStyle='#4a3524'; c.fillRect(-5,-1.5,5,3);
c.fillStyle='#c8a040'; c.fillRect(0,-3,2,6);
// blade
const L=w.len+(attacking?3:0);
c.fillStyle=w.col; c.fillRect(2,-2,L,4);
c.fillStyle=w.edge; c.fillRect(2,-2,L,1.4);
c.fillStyle=w.col; c.beginPath(); c.moveTo(2+L,-2); c.lineTo(2+L+4,0); c.lineTo(2+L,2); c.fill();
c.restore();
// trail / fire
if(attacking){
const prog=1-(PL.atkT/PL.atkTotal);
const arc = w.spin? Math.PI*2.4 : w.arc;
c.save(); c.translate(x,y-5);
c.beginPath();
const a0=PL.atkDir-arc/2, a1=ang;
c.moveTo(0,0);
c.arc(0,0,w.range*0.95,Math.min(a0,a1),Math.max(a0,a1));
c.closePath();
const gd=c.createRadialGradient(0,0,4,0,0,w.range);
if(S.sword===2){ gd.addColorStop(0,'rgba(124,232,255,0)'); gd.addColorStop(.7,'rgba(124,232,255,.16)'); gd.addColorStop(1,'rgba(220,255,255,.4)'); }
else if(S.sword===1){ gd.addColorStop(0,'rgba(255,138,60,0)'); gd.addColorStop(.7,'rgba(255,160,60,.14)'); gd.addColorStop(1,'rgba(255,220,140,.36)'); }
else { gd.addColorStop(0,'rgba(255,255,255,0)'); gd.addColorStop(.75,'rgba(230,230,255,.10)'); gd.addColorStop(1,'rgba(255,255,255,.26)'); }
c.fillStyle=gd; c.fill(); c.restore();
if(w.fire && Math.random()<0.75){
part(hx+Math.cos(ang)*L, hy+Math.sin(ang)*L,
{vx:(Math.random()-.5)*40,vy:-20-Math.random()*30,life:.32,r:2,
col:S.sword===2?'#7ce8ff':'#ff9a40',shape:'shrink'});
}
} else if(w.fire && Math.random()<0.13){
part(hx+Math.cos(ang)*w.len, hy+Math.sin(ang)*w.len,
{vx:(Math.random()-.5)*12,vy:-18,life:.4,r:1.4,col:S.sword===2?'#7ce8ff':'#ff9a40',shape:'shrink'});
}
}
/* ---------- ENEMIES ---------- */
function drawEnemy(c,e){
const x=Math.round(e.x), y=Math.round(e.y);
const flash = e.hitT>0 && Math.floor(t*40)%2===0;
const dying = e.dead;
if(dying){ c.globalAlpha=Math.max(0,1-e.deadT/0.6); }
const col = flash?'#ffffff':e.def.col;
const col2= flash?'#ffdddd':e.def.col2;
if(e.t==='slime'){
const sq = e.st==='hop'? 1.2 : 1+Math.sin(e.anim*4)*0.1;
const h=Math.round(11*sq), w=Math.round(14/sq);
shadow(c,x,y+7,w*0.45,3);
px(c,x-w/2,y+7-h,w,h,col2);
px(c,x-w/2+1,y+7-h+1,w-2,h-3,col);
px(c,x-w/2+2,y+8-h+2,4,3,'rgba(255,255,255,.5)');
px(c,x-4,y+1-h*0.4,2,2,'#182018'); px(c,x+3,y+1-h*0.4,2,2,'#182018');
}
else if(e.t==='bat'){
const fl=Math.sin(e.anim*18);
const yy=y+Math.sin(e.anim*3)*2;
shadow(c,x,y+10,6,2,0.2);
px(c,x-3,yy-4,6,6,col);
px(c,x-2,yy-2,2,2,'#ffd070'); px(c,x+1,yy-2,2,2,'#ffd070');
const wy=Math.round(fl*3);
px(c,x-10,yy-3+wy,7,3,col2); px(c,x+4,yy-3-wy,7,3,col2);
px(c,x-11,yy-1+wy,4,2,col2); px(c,x+8,yy-1-wy,4,2,col2);
}
else if(e.t==='husk'){
const sw=Math.sin(e.anim*7)*2;
shadow(c,x,y+8,7,3);
px(c,x-5,y+2,4,6,'#4a3a22'); px(c,x+1,y+2,4,6,'#4a3a22');
px(c,x-6,y-8,12,11,col2);
px(c,x-5,y-8,10,9,col);
px(c,x-4,y-15,9,8,col);
px(c,x-5,y-16,11,3,'#5a4028');
px(c,x-2,y-12,2,2,'#ff7040'); px(c,x+2,y-12,2,2,'#ff7040');
px(c,x-9+sw,y-6,3,7,col); px(c,x+7-sw,y-6,3,7,col);
if(Math.random()<0.06) part(x+(Math.random()-.5)*10,y-4,{vx:0,vy:-18,life:.5,r:1.4,col:'rgba(180,140,90,.5)',shape:'shrink'});
}
else if(e.t==='skeleton'){
const wind = e.st==='wind';
const sw=wind? -4 : Math.sin(e.anim*8)*2;
shadow(c,x,y+8,7,3);
px(c,x-5,y+2,3,6,col2); px(c,x+2,y+2,3,6,col2);
px(c,x-5,y-8,10,10,col);
px(c,x-6,y-6,12,1,col2); px(c,x-6,y-3,12,1,col2);
px(c,x-4,y-16,9,8,col);
px(c,x-2,y-13,2,3,'#301818'); px(c,x+2,y-13,2,3,'#301818');
px(c,x-2,y-9,5,1,'#301818');
// rusty blade
const bx = e.dir>0? x+6 : x-9;
px(c,bx,y-7+sw,3,2,'#6b5138');
px(c,e.dir>0?bx+3:bx-8, y-7+sw, 8,2, wind?'#ffd070':'#a8a090');
if(wind){ px(c,x-7,y-20,14,2,'rgba(255,120,60,.6)');
if(Math.random()<0.5) part(x+(Math.random()-.5)*14,y-18,{vx:0,vy:-30,life:.3,r:1.6,col:'#ff8a3c',shape:'shrink'}); }
}
else if(e.t==='captain'){
const wind=e.st==='wind', dash=e.st==='dash';
shadow(c,x,y+11,10,4);
px(c,x-7,y+3,5,8,'#3a2418'); px(c,x+2,y+3,5,8,'#3a2418');
px(c,x-9,y-10,18,14,col2);
px(c,x-8,y-10,16,12,col);
px(c,x-8,y-6,16,2,'#4a1810');
px(c,x-6,y-20,12,11,col);
px(c,x-7,y-21,14,4,'#8a2c1c');
px(c,x-3,y-16,2,3,wind?'#fff070':'#ff9040'); px(c,x+2,y-16,2,3,wind?'#fff070':'#ff9040');
// horn crest
px(c,x-9,y-24,3,4,'#d8c8a0'); px(c,x+7,y-24,3,4,'#d8c8a0');
// big axe
const bx=e.dir>0? x+9 : x-13;
px(c,bx,y-16,3,16,'#5a3a22');
px(c,e.dir>0?bx+2:bx-6, y-18, 7,8, wind?'#ffe090':'#b0a898');
if(wind||dash){ if(Math.random()<0.6) part(x+(Math.random()-.5)*20,y-8,{vx:0,vy:-24,life:.4,r:2,col:'#ff6030',shape:'shrink'}); }
}
else if(e.t==='warden'){
drawWarden(c,e,x,y,flash);
}
// health bar for big/lensed
if(!dying && (e.def.big || S.acc.lens) && e.hp<e.maxhp){
const w= e.def.boss? 0:26;
if(w){ px(c,x-w/2,y-e.r-16,w,4,'rgba(0,0,0,.65)');
px(c,x-w/2+1,y-e.r-15,(w-2)*(e.hp/e.maxhp),2,'#e04040'); }
}
if(S.acc.lens && !dying && !e.def.boss){
c.font='8px "Courier New",monospace'; c.textAlign='center';
c.fillStyle='rgba(200,240,255,.8)'; c.fillText(e.hp+'/'+e.maxhp, x, y-e.r-19); c.textAlign='left';
}
c.globalAlpha=1;
}
function drawWarden(c,e,x,y,flash){
const ph=e.phase;
const glow= ph===3? '#ffe070' : ph===2? '#ff9040':'#ff6a2a';
const body= flash?'#ffffff': (ph===3?'#8a2010':'#5a1a0c');
shadow(c,x,y+16,20,6,0.4);
// molten aura
c.save();
const g=c.createRadialGradient(x,y,4,x,y,34+Math.sin(t*4)*4);
g.addColorStop(0,'rgba(255,140,40,.28)'); g.addColorStop(1,'rgba(255,60,0,0)');
c.fillStyle=g; c.fillRect(x-40,y-40,80,80); c.restore();
// legs
px(c,x-11,y+6,7,10,body); px(c,x+4,y+6,7,10,body);
// torso
px(c,x-14,y-14,28,22,body);
px(c,x-12,y-12,24,8,flash?'#fff0f0':'#7a2814');
// cracks
for(let i=0;i<4;i++){ const rr=hash2(i,ph);
px(c,x-12+((rr*22)|0), y-12+((hash2(ph,i)*18)|0), 2, 5, glow); }
// core
const pulse=2+Math.sin(t*7)*1.2;
c.fillStyle=glow; c.beginPath(); c.arc(x,y-4,5+pulse,0,7); c.fill();
c.fillStyle='#fff8e0'; c.beginPath(); c.arc(x,y-4,2+pulse*0.4,0,7); c.fill();
// head
px(c,x-9,y-27,18,14,body);
px(c,x-11,y-29,22,4,'#3a1208');
px(c,x-6,y-23,4,4,glow); px(c,x+2,y-23,4,4,glow);
px(c,x-5,y-17,10,2,'#2a0c04');
// horns
px(c,x-13,y-33,4,7,'#c8b090'); px(c,x+9,y-33,4,7,'#c8b090');
px(c,x-15,y-37,3,5,'#c8b090'); px(c,x+12,y-37,3,5,'#c8b090');
// arms
const sw=Math.sin(e.anim*5)*3;
px(c,x-19,y-12+sw,5,15,body); px(c,x+14,y-12-sw,5,15,body);
px(c,x-20,y+2+sw,7,6,'#3a1208'); px(c,x+13,y+2-sw,7,6,'#3a1208');
if(e.st==='wind'){
c.strokeStyle='rgba(255,220,120,'+(0.4+Math.sin(t*30)*0.3)+')'; c.lineWidth=2;
c.beginPath(); c.arc(x,y-4,30,0,7); c.stroke();
}
if(Math.random()<0.6) part(x+(Math.random()-.5)*28, y+6,
{vx:(Math.random()-.5)*20,vy:-30-Math.random()*30,life:.6,r:2,col:Math.random()<.5?'#ff7a2a':'#ffc060',shape:'shrink'});
}
/* ==========================================================================
OBJECT LAYER RENDER (trees, rocks, water, chests, etc.)
========================================================================== */
function drawObjects(c){
const th=R.theme;
// water animated (behind everything)
for(let y=0;y<RH;y++)for(let x=0;x<RW;x++){
const ch=R.grid[y][x]; if(ch!=='~') continue;
const pxx=x*TILE, pyy=y*TILE;
px(c,pxx,pyy,TILE,TILE,'#1e3a5c');
px(c,pxx,pyy,TILE,TILE,'rgba(20,60,110,.5)');
const w1=Math.sin(t*1.6+x*0.7+y*0.4);
const w2=Math.sin(t*2.3+x*0.4-y*0.8);
px(c,pxx+2+((w1*4)|0), pyy+6, 9,2,'rgba(160,220,255,.20)');
px(c,pxx+8+((w2*5)|0), pyy+15, 7,2,'rgba(160,220,255,.14)');
if(hash2(x,y)>0.9) px(c,pxx+10,pyy+10,3,3,'rgba(255,255,255,.18)');
}
// sortable object list
const objs=[];
for(let y=0;y<RH;y++)for(let x=0;x<RW;x++){
const ch=R.grid[y][x];
if(ch==='T') objs.push({y:y*TILE+TILE,draw:()=>drawTree(c,x*TILE,y*TILE,x,y,th)});
else if(ch==='r') objs.push({y:y*TILE+TILE-4,draw:()=>drawRock(c,x*TILE,y*TILE,x,y)});
else if(ch==='G') objs.push({y:y*TILE+TILE,draw:()=>drawGate(c,x*TILE,y*TILE)});
else if(ch==='L') objs.push({y:y*TILE+TILE,draw:()=>drawLockedDoor(c,x*TILE,y*TILE)});
}
for(const e of R.ents.plates) objs.push({y:e.y-8,draw:()=>drawPlate(c,e)});
for(const e of R.ents.blocks) if(!e.solvedHide) objs.push({y:e.y+10,draw:()=>drawBlock(c,e)});
for(const e of R.ents.chests) objs.push({y:e.y+8,draw:()=>drawChest(c,e)});
for(const e of R.ents.pots) if(!e.broken) objs.push({y:e.y+8,draw:()=>drawPot(c,e)});
for(const e of R.ents.braziers) objs.push({y:e.y+8,draw:()=>drawBrazier(c,e)});
for(const e of R.ents.targets) objs.push({y:e.y+8,draw:()=>drawTarget(c,e)});
for(const e of R.ents.saves) objs.push({y:e.y+8,draw:()=>drawSavePoint(c,e)});
for(const e of R.ents.shops) objs.push({y:e.y+8,draw:()=>drawShopSign(c,e)});
for(const e of R.ents.npcs) objs.push({y:e.y+8,draw:()=>drawNPC(c,e)});
for(const o of R.drops) objs.push({y:o.y+4,draw:()=>drawDrop(c,o)});
for(const e of R.enemies) objs.push({y:e.y+e.r,draw:()=>drawEnemy(c,e)});
objs.push({y:PL.y+8,draw:()=>drawPlayer(c)});
objs.sort((a,b)=>a.y-b.y);
for(const o of objs) o.draw();
// projectiles on top
for(const pr of R.projectiles) drawProjectile(c,pr);
}
function drawTree(c,x,y,tx,ty,th){
const r=hash2(tx,ty);
const sway=Math.sin(t*1.1+tx*0.6)*1.4;
shadow(c,x+12,y+21,9,4,0.28);
px(c,x+10,y+12,5,11,'#4a3320');
const cols= th===THEME.forest? ['#2f6b34','#25562a','#3a7a3e'] : ['#3a6b3a','#2d5a2d','#457a45'];
const cc=cols[(r*3)|0];
c.fillStyle=cc;
c.beginPath(); c.ellipse(x+12+sway,y+8,11,10,0,0,7); c.fill();
c.fillStyle='rgba(255,255,255,.10)';
c.beginPath(); c.ellipse(x+9+sway,y+4,6,5,0,0,7); c.fill();
c.fillStyle='rgba(0,0,0,.20)';
c.beginPath(); c.ellipse(x+15+sway,y+13,7,5,0,0,7); c.fill();
if(r>0.75){ px(c,x+7+sway,y+9,2,2,'#e05a5a'); px(c,x+16+sway,y+5,2,2,'#e05a5a'); }
}
function drawRock(c,x,y,tx,ty){
const r=hash2(tx,ty);
shadow(c,x+12,y+20,9,3.5,0.3);
const b= r>0.5?'#7a7484':'#6b6578';
px(c,x+3,y+8,18,13,b);
px(c,x+5,y+5,14,5,b);
px(c,x+5,y+5,12,3,'rgba(255,255,255,.16)');
px(c,x+3,y+18,18,3,'rgba(0,0,0,.26)');
px(c,x+8,y+11,3,2,'rgba(0,0,0,.2)');
if(r>0.7){ px(c,x+15,y+13,3,3,'rgba(120,200,140,.35)'); }
}
function drawGate(c,x,y){
px(c,x,y,TILE,TILE,'#3a3444');
px(c,x,y,TILE,3,'#584f66');
for(let i=0;i<3;i++) px(c,x+3+i*7,y+3,4,TILE-5,'#6b6078');
for(let i=0;i<3;i++) px(c,x+3+i*7,y+3,1,TILE-5,'#8a7f98');
px(c,x,y+8,TILE,2,'#4a4256'); px(c,x,y+17,TILE,2,'#4a4256');
// glow lock
const p=0.5+Math.sin(t*3)*0.3;
c.fillStyle='rgba(240,192,96,'+(0.25*p)+')'; c.fillRect(x,y,TILE,TILE);
}
function drawLockedDoor(c,x,y){
px(c,x,y,TILE,TILE,'#4a3a28');
px(c,x+2,y+2,TILE-4,TILE-4,'#6b5138');
for(let i=0;i<2;i++) px(c,x+2,y+5+i*10,TILE-4,3,'#8a7048');
px(c,x+9,y+10,6,6,'#d8c060');
px(c,x+11,y+12,2,3,'#4a3a10');
const p=0.5+Math.sin(t*4)*0.5;
c.fillStyle='rgba(240,192,96,'+(0.18*p)+')'; c.fillRect(x,y,TILE,TILE);
}
function drawPlate(c,e){
const on=e.on;
px(c,e.x-10,e.y-7,20,15,'#3a3444');
px(c,e.x-8,e.y-5,16,11, on?'#f0c060':'#5a5268');
px(c,e.x-8,e.y-5,16,2, on?'#fff0b0':'#6b6078');
if(on){ c.fillStyle='rgba(240,192,96,'+(0.2+Math.sin(t*5)*0.1)+')';
c.fillRect(e.x-14,e.y-11,28,22);
if(Math.random()<0.1) part(e.x+(Math.random()-.5)*14,e.y,{vx:0,vy:-24,life:.5,r:1.4,col:'#f0c060',shape:'shrink'}); }
else { px(c,e.x-4,e.y-1,8,3,'rgba(0,0,0,.3)'); }
}
function drawBlock(c,e){
const sl=e.slide>0? e.slide/0.14 : 0;
const ox = -(e.sdx||0)*sl*TILE*0.5, oy=-(e.sdy||0)*sl*TILE*0.5;
const x=e.x+ox, y=e.y+oy;
shadow(c,x,y+11,11,4,0.35);
px(c,x-11,y-13,22,24,'#5a5468');
px(c,x-11,y-13,22,4,'#7a748c');
px(c,x-11,y+7,22,4,'#3f394f');
px(c,x-9,y-9,18,14,'#6b6578');
// rune
px(c,x-3,y-5,6,6,'#f0c060');
px(c,x-1,y-3,2,2,'#7a5a10');
const pr = R.ents.plates.some(p=>Math.abs(p.x-e.x)<26&&Math.abs(p.y-e.y)<26);
if(pr && Math.random()<0.05) part(x+(Math.random()-.5)*16,y-14,{vx:0,vy:-16,life:.5,r:1.3,col:'rgba(240,192,96,.6)',shape:'shrink'});
}
function drawChest(c,e){
shadow(c,e.x,e.y+9,10,3.5);
if(e.open){
px(c,e.x-9,e.y-2,18,11,'#5a4028');
px(c,e.x-9,e.y-2,18,3,'#3a2818');
px(c,e.x-11,e.y-9,22,7,'#6b5138');
px(c,e.x-11,e.y-9,22,2,'#8a6a48');
} else {
px(c,e.x-9,e.y-4,18,13,'#7a5a34');
px(c,e.x-9,e.y-11,18,8,'#8a6a3c');
px(c,e.x-9,e.y-11,18,2,'#a88a54');
px(c,e.x-9,e.y-4,18,2,'#d8b060');
px(c,e.x-2,e.y-6,4,6,'#e8c060');
px(c,e.x-1,e.y-4,2,3,'#5a4010');
px(c,e.x-9,e.y+7,18,2,'rgba(0,0,0,.3)');
const p=0.5+Math.sin(t*3+e.x)*0.5;
c.fillStyle='rgba(240,192,96,'+(0.12*p)+')'; c.fillRect(e.x-14,e.y-16,28,26);
if(Math.random()<0.05) part(e.x+(Math.random()-.5)*14,e.y-8,{vx:0,vy:-20,life:.6,r:1.3,col:'#f0c060',shape:'shrink'});
}
}
function drawPot(c,e){
shadow(c,e.x,e.y+8,7,3);
px(c,e.x-6,e.y-6,12,14,'#8a5a34');
px(c,e.x-5,e.y-6,10,12,'#a06840');
px(c,e.x-7,e.y-9,14,4,'#7a4a28');
px(c,e.x-4,e.y-4,3,7,'rgba(255,255,255,.16)');
px(c,e.x-6,e.y+6,12,2,'rgba(0,0,0,.3)');
}
function drawBrazier(c,e){
shadow(c,e.x,e.y+9,8,3);
px(c,e.x-3,e.y-2,6,11,'#4a4456');
px(c,e.x-7,e.y+7,14,3,'#3a3444');
px(c,e.x-8,e.y-8,16,7,'#5a5468');
px(c,e.x-8,e.y-8,16,2,'#7a748c');
if(e.lit){
const f=Math.sin(t*9+e.x)*1.6;
c.save();
const g=c.createRadialGradient(e.x,e.y-12,2,e.x,e.y-12,20+f);
g.addColorStop(0,'rgba(255,230,150,.6)'); g.addColorStop(.4,'rgba(255,140,50,.25)'); g.addColorStop(1,'rgba(255,80,0,0)');
c.fillStyle=g; c.fillRect(e.x-26,e.y-38,52,52); c.restore();
px(c,e.x-4,e.y-15+f*0.4,8,8,'#ff8a3c');
px(c,e.x-2,e.y-19+f*0.5,5,7,'#ffd070');
px(c,e.x-1,e.y-21+f*0.6,2,4,'#fff4c0');
if(Math.random()<0.5) part(e.x+(Math.random()-.5)*7, e.y-14,
{vx:(Math.random()-.5)*14,vy:-34-Math.random()*22,life:.7,r:1.6,col:Math.random()<.5?'#ff9a40':'#ffd070',shape:'shrink'});
} else {
px(c,e.x-5,e.y-9,10,4,'#2a2634');
px(c,e.x-3,e.y-10,3,2,'#4a4050');
if(Math.random()<0.03) part(e.x,e.y-10,{vx:0,vy:-12,life:.6,r:1.2,col:'rgba(120,120,140,.5)',shape:'shrink'});
}
}
function drawTarget(c,e){
shadow(c,e.x,e.y+8,7,3,0.25);
px(c,e.x-8,e.y-10,16,18,'#5a5468');
px(c,e.x-8,e.y-10,16,2,'#7a748c');
if(e.hit){
c.fillStyle='#7ce8ff'; c.beginPath(); c.arc(e.x,e.y-1,5,0,7); c.fill();
c.fillStyle='#ffffff'; c.beginPath(); c.arc(e.x,e.y-1,2.4,0,7); c.fill();
const g=c.createRadialGradient(e.x,e.y-1,2,e.x,e.y-1,16);
g.addColorStop(0,'rgba(124,232,255,.4)'); g.addColorStop(1,'rgba(124,232,255,0)');
c.fillStyle=g; c.fillRect(e.x-20,e.y-21,40,40);
if(Math.random()<0.1) part(e.x,e.y-2,{vx:(Math.random()-.5)*16,vy:-18,life:.5,r:1.3,col:'#7ce8ff',shape:'shrink'});
} else {
c.fillStyle='#2a2634'; c.beginPath(); c.arc(e.x,e.y-1,5,0,7); c.fill();
c.fillStyle='#c85a4a'; c.beginPath(); c.arc(e.x,e.y-1,2.6,0,7); c.fill();
}
}
function drawSavePoint(c,e){
const f=Math.sin(t*2.4)*2;
shadow(c,e.x,e.y+9,8,3);
px(c,e.x-2,e.y-2,4,11,'#3a4458');
px(c,e.x-6,e.y+7,12,3,'#2a3444');
px(c,e.x-6,e.y-14,12,13,'#4a5468');
px(c,e.x-4,e.y-12,8,9,'#8ae0ff');
const g=c.createRadialGradient(e.x,e.y-8,2,e.x,e.y-8,22+f);
g.addColorStop(0,'rgba(140,224,255,.5)'); g.addColorStop(.5,'rgba(80,180,255,.16)'); g.addColorStop(1,'rgba(60,140,255,0)');
c.fillStyle=g; c.fillRect(e.x-30,e.y-38,60,56);
px(c,e.x-6,e.y-17,12,3,'#6b7a90');
px(c,e.x-1,e.y-21,2,4,'#6b7a90');
if(Math.random()<0.35) part(e.x+(Math.random()-.5)*8, e.y-9,
{vx:(Math.random()-.5)*10,vy:-22-Math.random()*14,life:1,r:1.4,col:'#a0e8ff',shape:'shrink'});
}
function drawShopSign(c,e){
// shop counter (in house doorway)
px(c,e.x-11,e.y-14,22,20,'#5a3a24');
px(c,e.x-9,e.y-12,18,14,'#1a1218');
px(c,e.x-11,e.y+4,22,4,'#7a5a34');
px(c,e.x-11,e.y+4,22,1,'#a88a54');
// wares
px(c,e.x-7,e.y-1,4,5,'#e05a7a'); px(c,e.x-1,e.y-2,4,6,'#5ac0e0'); px(c,e.x+5,e.y-1,3,5,'#f0c060');
// sign
px(c,e.x-8,e.y-25,16,10,'#6b4a2a');
px(c,e.x-7,e.y-24,14,8,'#8a6a3c');
c.font='bold 9px "Courier New",monospace'; c.textAlign='center';
c.fillStyle='#2a1a08'; c.fillText('$',e.x,e.y-17); c.textAlign='left';
const p=0.5+Math.sin(t*2.6)*0.5;
c.fillStyle='rgba(240,192,96,'+(0.10*p)+')'; c.fillRect(e.x-16,e.y-30,32,40);
}
function drawNPC(c,e){
const bob=Math.round(Math.sin(t*1.8+e.x)*1);
shadow(c,e.x,e.y+8,7,3);
if(e.id==='smith'){
px(c,e.x-5,e.y+2,4,6,'#3a2a1a'); px(c,e.x+1,e.y+2,4,6,'#3a2a1a');
px(c,e.x-7,e.y-9+bob,14,12,'#6b4a2a');
px(c,e.x-7,e.y-4+bob,14,3,'#4a3a20');
px(c,e.x-5,e.y-18+bob,10,9,'#d8a888');
px(c,e.x-6,e.y-19+bob,12,4,'#3a3038');
px(c,e.x-4,e.y-11+bob,8,4,'#8a7060'); // beard
px(c,e.x-3,e.y-14+bob,2,2,'#1a1420'); px(c,e.x+2,e.y-14+bob,2,2,'#1a1420');
px(c,e.x+6,e.y-10+bob,3,4,'#8a8494'); // hammer
px(c,e.x+7,e.y-6+bob,1,6,'#5a4028');
if(Math.random()<0.08) part(e.x+7,e.y-10,{vx:(Math.random()-.5)*30,vy:-20,life:.4,r:1.3,col:'#ffb060',shape:'shrink'});
} else {
px(c,e.x-6,e.y-10+bob,12,18,'#5a4a7a');
px(c,e.x-6,e.y-10+bob,12,3,'#7a68a0');
px(c,e.x-5,e.y-19+bob,10,9,'#e8c8a8');
px(c,e.x-7,e.y-21+bob,14,5,'#c8c0d0');
px(c,e.x-3,e.y-15+bob,2,2,'#1a1420'); px(c,e.x+2,e.y-15+bob,2,2,'#1a1420');
px(c,e.x+6,e.y-16+bob,2,20,'#6b5138'); // staff
px(c,e.x+5,e.y-20+bob,4,4,'#8ae0ff');
const g=c.createRadialGradient(e.x+7,e.y-18,1,e.x+7,e.y-18,12);
g.addColorStop(0,'rgba(140,224,255,.4)'); g.addColorStop(1,'rgba(140,224,255,0)');
c.fillStyle=g; c.fillRect(e.x-6,e.y-31,28,26);
}
// name tag
c.font='7px "Courier New",monospace'; c.textAlign='center';
c.fillStyle='rgba(0,0,0,.5)'; c.fillRect(e.x-16,e.y-31,32,8);
c.fillStyle='#e0d8c0'; c.fillText(e.id==='smith'?'BRANN':'MAEVE',e.x,e.y-25); c.textAlign='left';
}
function drawDrop(c,o){
const y=o.y-(o.z||0);
const bb=Math.sin(o.anim)*1.4;
const fading = o.life<4 && Math.floor(o.life*8)%2===0;
if(fading) c.globalAlpha=0.4;
shadow(c,o.x,o.y+3,4,1.6,0.25);
if(o.kind==='coin'){
const w=o.val>=10?7:(o.val>=5?6:4);
px(c,o.x-w/2,y-w/2+bb,w,w,'#e8b040');
px(c,o.x-w/2,y-w/2+bb,w,2,'#ffe89a');
px(c,o.x-1,y-1+bb,2,2,'#a87020');
} else if(o.kind==='hp'){
drawHeartShape(c,o.x-4,y-4+bb,8,'#e04a6a','#ff8aa0');
} else if(o.kind==='arrow'){
px(c,o.x-1,y-5+bb,2,10,'#8a6a44'); px(c,o.x-3,y-6+bb,6,3,'#c8c0b0');
px(c,o.x-2,y+4+bb,4,2,'#e8e0d0');
} else if(o.kind==='item'){
drawItemIcon(c,o.x-6,y-6+bb,ITEMS[o.val].icon,12);
const g=c.createRadialGradient(o.x,y+bb,1,o.x,y+bb,14);
g.addColorStop(0,'rgba(240,192,96,.35)'); g.addColorStop(1,'rgba(240,192,96,0)');
c.fillStyle=g; c.fillRect(o.x-16,y-16+bb,32,32);
}
c.globalAlpha=1;
}
function drawProjectile(c,pr){
if(pr.friendly){
const a=Math.atan2(pr.vy,pr.vx);
c.save(); c.translate(pr.x,pr.y); c.rotate(a);
px(c,-7,-1,12,2,'#8a6a44');
px(c,5,-2,4,4,'#d8d0c0');
px(c,-8,-3,3,6,'#e8e0d0');
c.restore();
} else {
const g=c.createRadialGradient(pr.x,pr.y,1,pr.x,pr.y,9);
g.addColorStop(0,'rgba(255,240,180,.95)'); g.addColorStop(.5,'rgba(255,130,40,.6)'); g.addColorStop(1,'rgba(255,60,0,0)');
c.fillStyle=g; c.fillRect(pr.x-12,pr.y-12,24,24);
c.fillStyle='#fff0c0'; c.beginPath(); c.arc(pr.x,pr.y,2.6,0,7); c.fill();
}
}
function drawHeartShape(c,x,y,s,dark,light){
const u=s/8;
c.fillStyle=dark;
c.fillRect(x+u,y, 2*u,u); c.fillRect(x+5*u,y, 2*u,u);
c.fillRect(x, y+u, 8*u,2*u);
c.fillRect(x+u, y+3*u,6*u,u);
c.fillRect(x+2*u, y+4*u,4*u,u);
c.fillRect(x+3*u, y+5*u,2*u,u);
c.fillStyle=light;
c.fillRect(x+u,y+u,2*u,u); c.fillRect(x+2*u,y+2*u,u,u);
}
/* ==========================================================================
ITEM ICONS
========================================================================== */
function drawItemIcon(c,x,y,icon,s){
const u=s/12;
const P_=(ax,ay,aw,ah,col)=>{ c.fillStyle=col; c.fillRect(x+ax*u,y+ay*u,aw*u,ah*u); };
switch(icon){
case 'salve': P_(4,1,4,2,'#8a7050'); P_(3,3,6,8,'#5ad07a'); P_(4,4,2,5,'#a0ffc0'); P_(3,3,6,1,'#3a9a54'); break;
case 'flask': P_(5,1,2,2,'#8a7050'); P_(3,3,6,8,'#ff8a3c'); P_(4,5,2,4,'#ffd070'); P_(3,10,6,1,'#c0501a'); break;
case 'arrow': P_(5,1,2,10,'#8a6a44'); P_(3,0,6,3,'#d8d0c0'); P_(4,10,4,2,'#e8e0d0'); break;
case 'charm': P_(2,3,8,7,'#a8a4b4'); P_(3,4,6,5,'#c8c4d4'); P_(5,1,2,3,'#8a8494'); P_(4,5,4,3,'#6b6578'); break;
case 'boots': P_(2,4,4,6,'#d8a840'); P_(6,4,4,6,'#d8a840'); P_(1,9,10,2,'#8a6a20'); P_(2,4,4,1,'#ffe0a0'); break;
case 'pouch': P_(3,3,6,8,'#c89040'); P_(4,1,4,3,'#8a6020'); P_(4,6,4,3,'#ffe07a'); break;
case 'lens': c.strokeStyle='#8a8494'; c.lineWidth=Math.max(1,1.4*u); c.beginPath(); c.arc(x+5*u,y+5*u,3.4*u,0,7); c.stroke();
P_(3,3,4,4,'rgba(160,240,255,.8)'); P_(7,7,4,4,'#8a8494'); break;
case 'ore': P_(2,5,8,6,'#5a4a3a'); P_(3,3,6,4,'#7a5a3a'); P_(4,5,2,2,'#ff8a3c'); P_(7,7,2,2,'#ffd070'); break;
case 'key': P_(2,3,4,4,'#e8c060'); P_(3,4,2,2,'#8a6a20'); P_(6,4,5,2,'#e8c060'); P_(9,6,2,2,'#e8c060'); break;
case 'stone': P_(4,1,4,3,'#ff8a3c'); P_(2,3,8,6,'#ff6a20'); P_(4,9,4,2,'#c04010'); P_(4,3,3,3,'#ffe0a0'); break;
default: P_(3,3,6,6,'#c0c0c0');
}
}
/* ==========================================================================
HUD
========================================================================== */
function drawHUD(){
const c=ux; c.clearRect(0,0,VIEW_W,VIEW_H);
if(mode==='title') return;
// ---- top-left panel ----
c.fillStyle='rgba(8,6,14,.62)'; c.fillRect(6,6,186,S.hasBow?52:40);
c.strokeStyle='rgba(140,130,170,.35)'; c.lineWidth=1; c.strokeRect(6.5,6.5,185,(S.hasBow?52:40)-1);
// hearts (each = 2 HP)
for(let i=0;i<S.maxhp;i++){
const hx=12+i*17, hy=12;
const filled = S.hp - i*2;
// empty backing
c.globalAlpha=0.35; drawHeartShape(c,hx,hy,14,'#2a1a20','#3a2830'); c.globalAlpha=1;
if(filled>=2) drawHeartShape(c,hx,hy,14,'#d8304a','#ff7a90');
else if(filled===1){
c.save(); c.beginPath(); c.rect(hx,hy,7,14); c.clip();
drawHeartShape(c,hx,hy,14,'#d8304a','#ff7a90'); c.restore();
}
// pulse when low
if(S.hp<=2 && i===0){ const p=0.5+Math.sin(t*8)*0.5;
c.globalAlpha=0.35*p; drawHeartShape(c,hx-1,hy-1,16,'#ff2040','#ff8090'); c.globalAlpha=1; }
}
// stamina bar
const sy=28;
c.fillStyle='rgba(0,0,0,.5)'; c.fillRect(12,sy,90,5);
const stf=PL.stam/PL.maxStam;
c.fillStyle= PL.stamCd>0? '#8a5a3a' : (stf<0.3?'#d8a040':'#5ad0a0');
c.fillRect(13,sy+1,88*stf,3);
c.font='7px "Courier New",monospace';
c.fillStyle='rgba(180,200,220,.7)'; c.fillText('STAM',106,sy+5);
// arrows
if(S.hasBow){
const ay=40;
for(let i=0;i<Math.min(S.maxArrows,20);i++){
const axx=12+i*8.6;
c.fillStyle = i<S.arrows? '#d8d0b0':'rgba(255,255,255,.13)';
c.fillRect(axx,ay,2,8);
if(i<S.arrows){ c.fillStyle='#8a7a58'; c.fillRect(axx-1,ay,4,2); }
}
}
// ---- coins ----
const cw=78;
c.fillStyle='rgba(8,6,14,.62)'; c.fillRect(VIEW_W-cw-6,6,cw,22);
c.strokeStyle='rgba(140,130,170,.35)'; c.strokeRect(VIEW_W-cw-5.5,6.5,cw-1,21);
c.fillStyle='#e8b040'; c.fillRect(VIEW_W-cw+2,12,8,8);
c.fillStyle='#ffe89a'; c.fillRect(VIEW_W-cw+2,12,8,2);
c.font='bold 11px "Courier New",monospace'; c.fillStyle='#ffe07a';
c.textAlign='right'; c.fillText(String(S.coins),VIEW_W-12,21); c.textAlign='left';
// ---- stones + progress ----
const py2=32;
c.fillStyle='rgba(8,6,14,.62)'; c.fillRect(VIEW_W-88,py2,82,20);
c.strokeStyle='rgba(140,130,170,.35)'; c.strokeRect(VIEW_W-87.5,py2+.5,81,19);
for(let i=0;i<3;i++){
const sx=VIEW_W-82+i*15;
if(i<S.stones){ drawItemIcon(c,sx,py2+4,'stone',13);
if(Math.random()<0.04) part(sx+6,py2+8,{vx:0,vy:-14,life:.5,r:1.2,col:'#ff8a3c',shape:'shrink'});
} else { c.globalAlpha=0.25; drawItemIcon(c,sx,py2+4,'stone',13); c.globalAlpha=1; }
}
c.font='8px "Courier New",monospace'; c.fillStyle='#a09ab0';
c.textAlign='right'; c.fillText(progressPct()+'%',VIEW_W-11,py2+14); c.textAlign='left';
// ---- weapon + accessories strip (bottom-left) ----
const by=VIEW_H-30;
c.fillStyle='rgba(8,6,14,.62)'; c.fillRect(6,by,150,24);
c.strokeStyle='rgba(140,130,170,.35)'; c.strokeRect(6.5,by+.5,149,23);
// mini sword icon
const w=SWORDS[S.sword];
c.save(); c.translate(20,by+12); c.rotate(-0.7);
c.fillStyle='#4a3524'; c.fillRect(-5,-1,4,2);
c.fillStyle='#c8a040'; c.fillRect(-1,-2.5,1.6,5);
c.fillStyle=w.col; c.fillRect(1,-1.5,w.len*0.75,3);
c.fillStyle=w.edge; c.fillRect(1,-1.5,w.len*0.75,1);
c.restore();
c.font='8px "Courier New",monospace'; c.fillStyle='#ded4c0';
c.fillText(w.name.toUpperCase(),32,by+9);
c.fillStyle='#8a84a0'; c.fillText('DMG '+w.dmg+' RNG '+w.range,32,by+19);
// accessories
let axp=124;
for(const k of ['charm','boots','pouch','lens']) if(S.acc[k]){ drawItemIcon(c,axp,by+7,ITEMS[k].icon,11); axp-=13; }
// ---- quick items (bottom-right) ----
const list=invList().filter(id=>ITEMS[id].kind==='use');
const qn=Math.max(3,Math.min(5,list.length));
const qw=qn*26+8;
c.fillStyle='rgba(8,6,14,.62)'; c.fillRect(VIEW_W-qw-6,VIEW_H-34,qw,28);
c.strokeStyle='rgba(140,130,170,.35)'; c.strokeRect(VIEW_W-qw-5.5,VIEW_H-33.5,qw-1,27);
for(let i=0;i<qn;i++){
const sx=VIEW_W-qw-2+i*26, sy2=VIEW_H-30;
c.fillStyle='rgba(0,0,0,.4)'; c.fillRect(sx,sy2,22,20);
c.strokeStyle='rgba(120,112,150,.5)'; c.strokeRect(sx+.5,sy2+.5,21,19);
const id=list[i];
if(id){ drawItemIcon(c,sx+5,sy2+3,ITEMS[id].icon,13);
c.font='bold 8px "Courier New",monospace'; c.fillStyle='#ffe07a';
c.textAlign='right'; c.fillText('x'+S.inv[id],sx+20,sy2+18); c.textAlign='left'; }
c.font='7px "Courier New",monospace'; c.fillStyle='rgba(180,175,200,.75)';
c.fillText(String(i+1),sx+2,sy2+8);
}
// ---- room name / interact prompt ----
const it=nearestInteract && mode==='play'? nearestInteract():null;
if(it){
const label = it.type==='chest'?'OPEN' : it.type==='save'?'REST & SAVE'
: it.type==='npc'?'TALK' : it.type==='shop'?'SHOP' : 'UNLOCK';
const wtxt='[E] '+label;
c.font='bold 9px "Courier New",monospace';
const tw=c.measureText(wtxt).width+12;
const bx=Math.round(it.o.x-tw/2 - camX), byy=Math.round(it.o.y-34 - camY);
c.fillStyle='rgba(10,8,18,.85)'; c.fillRect(bx,byy,tw,15);
c.strokeStyle='#f0c060'; c.lineWidth=1; c.strokeRect(bx+.5,byy+.5,tw-1,14);
c.fillStyle='#f0c060'; c.textAlign='center'; c.fillText(wtxt,bx+tw/2,byy+11); c.textAlign='left';
}
if(mode==='inv') drawInventory(c);
if(mode==='shop') drawShop(c);
if(mode==='dead'){ c.fillStyle='rgba(60,0,0,'+Math.min(0.6,PL.deadT*0.5)+')'; c.fillRect(0,0,VIEW_W,VIEW_H); }
}
/* ==========================================================================
INVENTORY UI
========================================================================== */
function drawInventory(c){
c.fillStyle='rgba(6,5,12,.86)'; c.fillRect(0,0,VIEW_W,VIEW_H);
const W=430,H=250, X=(VIEW_W-W)/2, Y=(VIEW_H-H)/2;
panelBox(c,X,Y,W,H,'INVENTORY');
const list=invList();
// left: grid
const cols=4;
for(let i=0;i<12;i++){
const gx2=X+16+(i%cols)*44, gy2=Y+34+Math.floor(i/cols)*44;
const sel=i===invSel;
c.fillStyle= sel?'rgba(240,192,96,.16)':'rgba(255,255,255,.045)';
c.fillRect(gx2,gy2,38,38);
c.strokeStyle= sel?'#f0c060':'rgba(140,130,170,.4)'; c.lineWidth= sel?2:1;
c.strokeRect(gx2+.5,gy2+.5,37,37);
const id=list[i];
if(id){
drawItemIcon(c,gx2+9,gy2+8,ITEMS[id].icon,20);
if(S.inv[id]>1){ c.font='bold 9px "Courier New",monospace'; c.fillStyle='#ffe07a';
c.textAlign='right'; c.fillText('x'+S.inv[id],gx2+35,gy2+35); c.textAlign='left'; }
if(ITEMS[id].kind==='acc' && S.acc[id]){
c.fillStyle='#5ad07a'; c.font='7px "Courier New",monospace'; c.fillText('ON',gx2+3,gy2+35); }
}
}
// right: detail
const DX=X+206, DY=Y+34;
c.fillStyle='rgba(0,0,0,.3)'; c.fillRect(DX,DY,W-222,H-52);
c.strokeStyle='rgba(140,130,170,.3)'; c.lineWidth=1; c.strokeRect(DX+.5,DY+.5,W-223,H-53);
const id=list[invSel];
if(id){
const it=ITEMS[id];
drawItemIcon(c,DX+12,DY+12,it.icon,28);
c.font='bold 11px "Courier New",monospace'; c.fillStyle='#f0c060';
c.fillText(it.name,DX+48,DY+26);
c.font='8px "Courier New",monospace'; c.fillStyle='#8a84a0';
c.fillText(it.kind==='use'?'CONSUMABLE':it.kind==='acc'?'ACCESSORY (PASSIVE)':'KEY ITEM',DX+48,DY+38);
wrapText(c,it.desc,DX+12,DY+62,W-246,13,'#c8c0d8','9px "Courier New",monospace');
if(it.kind==='use'){ c.fillStyle='#7ce8ff'; c.font='9px "Courier New",monospace';
c.fillText('[ENTER] use',DX+12,DY+H-74); }
} else {
c.font='9px "Courier New",monospace'; c.fillStyle='#6b6580';
c.fillText('(empty slot)',DX+12,DY+24);
}
// stats block
const sy=DY+H-64;
c.font='8px "Courier New",monospace';
const stats=[
['HEALTH', S.hp+' / '+(S.maxhp*2)+' ('+S.maxhp+' hearts)'],
['WEAPON', SWORDS[S.sword].name+' (dmg '+SWORDS[S.sword].dmg+')'],
['RANGED', S.hasBow? 'Emberbow — '+S.arrows+'/'+S.maxArrows+' arrows':'— not found —'],
['STONES', S.stones+' / 3 PROGRESS '+progressPct()+'%'],
['TIME', fmtTime(S.playtime)+' FOES '+S.kills+' DEATHS '+S.deaths],
];
stats.forEach((s,i)=>{
c.fillStyle='#6b6580'; c.fillText(s[0],DX+12,sy+i*11);
c.fillStyle='#ded4c0'; c.fillText(s[1],DX+62,sy+i*11);
});
c.font='8px "Courier New",monospace'; c.fillStyle='#6b6580'; c.textAlign='center';
c.fillText('ARROWS/WASD move · ENTER use · I or ESC close',VIEW_W/2,Y+H-8); c.textAlign='left';
}
function panelBox(c,X,Y,W,H,title){
c.fillStyle='rgba(16,13,26,.97)'; c.fillRect(X,Y,W,H);
c.strokeStyle='#6b5f8e'; c.lineWidth=2; c.strokeRect(X+1,Y+1,W-2,H-2);
c.strokeStyle='rgba(240,192,96,.22)'; c.lineWidth=1; c.strokeRect(X+5,Y+5,W-10,H-10);
c.fillStyle='rgba(240,192,96,.10)'; c.fillRect(X+2,Y+2,W-4,22);
c.font='bold 11px "Courier New",monospace'; c.fillStyle='#f0c060'; c.textAlign='center';
c.fillText(title,X+W/2,Y+17); c.textAlign='left';
}
function wrapText(c,txt,x,y,maxw,lh,col,font){
c.font=font; c.fillStyle=col;
const words=txt.split(' '); let line='', yy=y;
for(const wd of words){
const test=line?line+' '+wd:wd;
if(c.measureText(test).width>maxw){ c.fillText(line,x,yy); line=wd; yy+=lh; }
else line=test;
}
if(line) c.fillText(line,x,yy);
return yy;
}
/* ==========================================================================
SHOP UI
========================================================================== */
function drawShop(c){
c.fillStyle='rgba(6,5,12,.86)'; c.fillRect(0,0,VIEW_W,VIEW_H);
const W=400,H=288,X=(VIEW_W-W)/2,Y=(VIEW_H-H)/2;
panelBox(c,X,Y,W,H,"TILDA'S SUNDRIES");
// shopkeeper
const kx=X+W-46, ky=Y+40;
px(c,kx-9,ky,18,20,'#7a4a6a'); px(c,kx-9,ky,18,3,'#a06a90');
px(c,kx-7,ky-11,14,12,'#e8c8a8'); px(c,kx-8,ky-13,16,4,'#3a2a30');
const blink=Math.sin(t*2)>-0.9;
px(c,kx-4,ky-6,2,blink?2:1,'#1a1420'); px(c,kx+2,ky-6,2,blink?2:1,'#1a1420');
px(c,kx-2,ky-2,4,1,'#c07080');
c.font='7px "Courier New",monospace'; c.textAlign='center'; c.fillStyle='#a09ab0';
c.fillText('TILDA',kx,ky+30); c.textAlign='left';
// coins
c.font='bold 10px "Courier New",monospace';
c.fillStyle='#e8b040'; c.fillRect(X+14,Y+30,8,8);
c.fillStyle='#ffe07a'; c.fillText(S.coins+' coins',X+28,Y+38);
SHOP_STOCK.forEach((id,i)=>{
const it=ITEMS[id];
const ry=Y+48+i*31, sel=i===shopSel;
const owned = it.kind==='acc' && S.acc[id];
const afford = S.coins>=it.price && !owned && !(id==='arrows'&&!S.hasBow);
c.fillStyle= sel?'rgba(240,192,96,.14)':'rgba(255,255,255,.03)';
c.fillRect(X+14,ry,W-90,27);
if(sel){ c.strokeStyle='#f0c060'; c.lineWidth=1; c.strokeRect(X+14.5,ry+.5,W-91,26); }
drawItemIcon(c,X+20,ry+6,it.icon,16);
c.font='9px "Courier New",monospace';
c.fillStyle= owned? '#5ad07a' : afford? '#ded4c0':'#6b6580';
c.fillText(it.name,X+44,ry+12);
c.font='7px "Courier New",monospace'; c.fillStyle= sel?'#a89ec0':'#6b6580';
c.fillText(it.desc,X+44,ry+22);
c.font='bold 9px "Courier New",monospace';
c.textAlign='right';
c.fillStyle= owned?'#5ad07a': afford?'#ffe07a':'#8a5a5a';
c.fillText(owned?'OWNED':(it.price+'c'),X+W-78,ry+16);
c.textAlign='left';
});
// LEAVE row — selectable like an item, so the interact key also closes the shop
const ly=Y+48+SHOP_STOCK.length*31, lsel=shopOnLeave();
c.fillStyle= lsel?'rgba(240,192,96,.14)':'rgba(255,255,255,.03)';
c.fillRect(X+14,ly,W-90,22);
if(lsel){ c.strokeStyle='#f0c060'; c.lineWidth=1; c.strokeRect(X+14.5,ly+.5,W-91,21); }
c.font='9px "Courier New",monospace'; c.fillStyle= lsel?'#ded4c0':'#8a84a0';
c.fillText('LEAVE THE STALL',X+44,ly+14);
c.font='8px "Courier New",monospace'; c.fillStyle='#6b6580'; c.textAlign='center';
c.fillText('UP/DOWN choose · ENTER or E confirm · ESC leave',VIEW_W/2,Y+H-10); c.textAlign='left';
}
/* ==========================================================================
CAMERA + WORLD RENDER
========================================================================== */
let camX=0, camY=0;
function render(){
const c=gx;
const worldW=RW*TILE, worldH=RH*TILE;
camX = Math.round((worldW-VIEW_W)/2);
camY = Math.round((worldH-VIEW_H)/2);
let sx=0, sy=0;
if(R.shake>0){ sx=(Math.random()-.5)*R.shake; sy=(Math.random()-.5)*R.shake; }
c.fillStyle='#05050a'; c.fillRect(0,0,VIEW_W,VIEW_H);
c.save();
c.translate(Math.round(-camX+sx), Math.round(-camY+sy));
c.drawImage(R.bg,0,0);
drawObjects(c);
drawParts(c);
drawFT(c);
// lighting overlay for dark rooms
const th=R.def.theme;
if(th==='cave'||th==='ruin'||th==='boss'){
const lc=lightCanvas(); c.drawImage(lc,0,0);
}
// solved flash
if(R.solvedFlash>0){ c.fillStyle='rgba(240,192,96,'+(R.solvedFlash*0.22)+')'; c.fillRect(0,0,worldW,worldH); }
c.restore();
// hurt vignette
if(PL.hurtT>0){
const a=PL.hurtT/0.34;
const g=c.createRadialGradient(VIEW_W/2,VIEW_H/2,VIEW_H*0.24,VIEW_W/2,VIEW_H/2,VIEW_H*0.72);
g.addColorStop(0,'rgba(200,0,0,0)'); g.addColorStop(1,'rgba(200,0,0,'+(a*0.55)+')');
c.fillStyle=g; c.fillRect(0,0,VIEW_W,VIEW_H);
}
// low health pulse
if(S.hp<=2 && !PL.dead && mode!=='title'){
const p=(0.5+Math.sin(t*6)*0.5)*0.20;
const g=c.createRadialGradient(VIEW_W/2,VIEW_H/2,VIEW_H*0.3,VIEW_W/2,VIEW_H/2,VIEW_H*0.8);
g.addColorStop(0,'rgba(180,0,20,0)'); g.addColorStop(1,'rgba(180,0,20,'+p+')');
c.fillStyle=g; c.fillRect(0,0,VIEW_W,VIEW_H);
}
// transition wipe
if(trans){
const k = trans.phase==='out'? trans.t/0.22 : 1-trans.t/0.22;
c.fillStyle='rgba(0,0,0,'+Math.min(1,Math.max(0,k))+')';
c.fillRect(0,0,VIEW_W,VIEW_H);
}
drawHUD();
}
let _lc=null;
function lightCanvas(){
if(!_lc){ _lc=document.createElement('canvas'); _lc.width=RW*TILE; _lc.height=RH*TILE; }
const k=_lc.getContext('2d');
k.clearRect(0,0,_lc.width,_lc.height);
const dark = R.def.theme==='boss'? 0.42 : R.def.theme==='cave'? 0.62:0.42;
k.fillStyle='rgba(4,4,14,'+dark+')';
k.fillRect(0,0,_lc.width,_lc.height);
k.globalCompositeOperation='destination-out';
const holes=[[PL.x,PL.y-4, S.sword>0? 92:76]];
for(const b of R.ents.braziers) if(b.lit) holes.push([b.x,b.y-8,74]);
for(const s of R.ents.saves) holes.push([s.x,s.y-8,58]);
for(const o of R.ents.targets) if(o.hit) holes.push([o.x,o.y,44]);
for(const e of R.enemies) if(e.def.boss&&!e.dead) holes.push([e.x,e.y-4,86]);
for(const pr of R.projectiles) if(!pr.friendly) holes.push([pr.x,pr.y,30]);
for(const [hx,hy,hr] of holes){
const rr=hr*(0.96+Math.sin(t*3+hx*0.05)*0.04);
const g=k.createRadialGradient(hx,hy,rr*0.1,hx,hy,rr);
g.addColorStop(0,'rgba(0,0,0,1)'); g.addColorStop(0.55,'rgba(0,0,0,.7)'); g.addColorStop(1,'rgba(0,0,0,0)');
k.fillStyle=g; k.beginPath(); k.arc(hx,hy,rr,0,7); k.fill();
}
k.globalCompositeOperation='source-over';
return _lc;
}
/* ==========================================================================
MAIN LOOP
========================================================================== */
const hintEl=document.getElementById('hint');
function loop(now){
requestAnimationFrame(loop);
if(!lastT) lastT=now;
dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
t+=dt;
if(mode==='title'){ clearTaps(); return; }
if(!R) return;
S.playtime += (mode==='play')? dt : 0;
// global keys
if(tap('mute')){ Audio_.muted=!Audio_.muted; toast(Audio_.muted?'Audio muted':'Audio on',1.2); }
if(mode==='dlg'){ if(tap('act')) advanceDlg(); clearTaps(); }
else if(mode==='inv'){
if(tap('inv')||tap('esc')){ mode='play'; Audio_.ui(); }
const n=Math.max(1,invList().length);
if(tap('left')){ invSel=(invSel+11)%12; Audio_.ui(); }
if(tap('right')){ invSel=(invSel+1)%12; Audio_.ui(); }
if(tap('up')){ invSel=(invSel+8)%12; Audio_.ui(); }
if(tap('down')){ invSel=(invSel+4)%12; Audio_.ui(); }
if(tap('act')){ const id=invList()[invSel]; if(id) useItem(id); }
clearTaps();
}
else if(mode==='shop'){
if(tap('esc')||tap('inv')){ closeShop(); }
if(tap('up')){ shopSel=(shopSel+shopRows()-1)%shopRows(); Audio_.ui(); }
if(tap('down')){ shopSel=(shopSel+1)%shopRows(); Audio_.ui(); }
if(tap('act')) shopConfirm();
clearTaps();
}
else if(mode==='dead'){ clearTaps(); PL.deadT+=dt; }
else if(mode==='win'){ clearTaps(); }
else if(mode==='play'){
if(tap('inv')){ mode='inv'; invSel=0; Audio_.ui(); clearTaps(); }
else if(tap('esc')){ /* nothing */ }
else {
if(tap('act')) doInteract();
for(let i=1;i<=5;i++) if(tap('q'+i)) quickUse(i-1);
updatePlayer(dt);
updateEnemies(dt);
}
}
// always-updating world bits
if(R.gateMsgT>0) R.gateMsgT-=dt;
if(R.solvedFlash>0) R.solvedFlash-=dt*1.4;
if(R.shake>0) R.shake=Math.max(0,R.shake-dt*26);
updateProjectiles(dt);
updateDrops(dt);
updateParts(dt);
updateFT(dt);
updateToast(dt);
// ambient particles
ambient(dt);
// transition
if(trans){
trans.t+=dt;
if(trans.phase==='out'&&trans.t>=0.22){ finishTransition(); trans.phase='in'; trans.t=0; }
else if(trans.phase==='in'&&trans.t>=0.22) trans=null;
}
// hint line
hintEl.textContent = R? R.def.name.toUpperCase() : '';
render();
clearTaps();
}
let ambT=0;
function ambient(d){
ambT-=d;
if(ambT>0) return;
ambT=0.09;
const th=R.def.theme;
if(th==='forest'||th==='field'){
if(Math.random()<0.5) part(Math.random()*RW*TILE, Math.random()*RH*TILE,
{vx:12+Math.random()*10,vy:-4+Math.random()*8,life:2.4,r:1.2,col:'rgba(255,240,180,.35)'});
} else if(th==='cave'){
if(Math.random()<0.4) part(Math.random()*RW*TILE, -4,
{vx:0,vy:20+Math.random()*20,life:2.6,r:1,col:'rgba(160,200,255,.28)'});
} else if(th==='ruin'){
if(Math.random()<0.4) part(Math.random()*RW*TILE, RH*TILE,
{vx:(Math.random()-.5)*8,vy:-14-Math.random()*10,life:2.4,r:1.2,col:'rgba(200,170,255,.22)'});
} else if(th==='boss'){
if(Math.random()<0.8) part(Math.random()*RW*TILE, RH*TILE+4,
{vx:(Math.random()-.5)*12,vy:-26-Math.random()*20,life:2,r:1.6,col:Math.random()<.5?'rgba(255,120,40,.4)':'rgba(255,60,20,.3)'});
} else if(th==='village'){
if(Math.random()<0.3) part(Math.random()*RW*TILE, Math.random()*RH*TILE,
{vx:8,vy:-10,life:1.8,r:1,col:'rgba(255,255,200,.3)'});
}
}
requestAnimationFrame(loop);
/* ==========================================================================
BOOT / PANELS
========================================================================== */
function startNew(){
S=freshState();
R=buildRoom(S.room);
PL.x=S.px; PL.y=S.py; PL.dead=false; PL.stam=PL.maxStam;
P.length=0; FT.length=0;
document.getElementById('titlePanel').classList.remove('show');
mode='play'; clearTaps();
Audio_.init(); if(Audio_.ctx&&Audio_.ctx.state==='suspended') Audio_.ctx.resume();
toast('Emberhollow Village',2.4);
setTimeout(()=>{ if(mode==='play') say([
{who:'',txt:'You arrive at dusk. The village smells of cold iron and ash.'},
{who:'',txt:'Speak with ELDER MAEVE (the staff) and BRANN THE SMITH (the hammer). Rest at blue lanterns to save.'},
]); }, 1400);
}
document.getElementById('startBtn').addEventListener('click', ()=>{ Audio_.init(); startNew(); });
if(readSave()){
const cb=document.getElementById('contBtn'); cb.style.display='inline-block';
cb.addEventListener('click', ()=>{
Audio_.init();
if(loadFromSave()){
document.getElementById('titlePanel').classList.remove('show');
mode='play'; clearTaps(); PL.stam=PL.maxStam;
toast('Welcome back — '+R.def.name,2.6);
} else startNew();
});
}
</script>
<div class="panel" id="deadPanel" style="z-index:6">
<h1 style="color:#e05a5a;text-shadow:0 3px 0 #4a1010">YOU FELL</h1>
<h2>the hollow keeps what it takes</h2>
<p>Your last lantern still burns. Return to it — the coins you gathered remain, but the hollow has refilled itself.</p>
<button class="btn" id="respawnBtn" style="border-color:#e05a5a;color:#e05a5a">WAKE AT THE LANTERN</button>
</div>
<div class="panel" id="winPanel" style="z-index:6">
<h1>THE HEART BURNS</h1>
<h2>emberhollow is safe</h2>
<p>The Warden collapses into cinders, and three Emberstones sink into the Forge-Heart. Far above, every lantern in the village flares gold at once.</p>
<p id="winStats" style="color:#f0c060;margin-top:14px"></p>
<button class="btn" id="againBtn">PLAY AGAIN</button>
</div>
<script>
document.getElementById('respawnBtn').addEventListener('click', respawn);
document.getElementById('againBtn').addEventListener('click', ()=>{
document.getElementById('winPanel').classList.remove('show');
try{ localStorage.removeItem(SAVE_KEY); }catch(e){}
startNew();
});
</script>
</body>
</html>