Warden
Qwen 3.8 27B GGUF XL · 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 106,317 bytes · SHA-256 e985ffd8eb1d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Warden's Corrosion</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{
background:radial-gradient(1100px 640px at 50% 38%, #0c1120 0%, #04060b 72%);
display:flex;align-items:center;justify-content:center;
font-family:Georgia,'Times New Roman',serif;
}
#stage{position:relative;line-height:0}
canvas{
display:block;background:#04060b;border-radius:8px;
box-shadow:0 0 0 1px #1b2334, 0 0 70px rgba(0,0,0,.85), 0 0 140px rgba(201,142,46,.05);
}
</style>
</head>
<body>
<div id="stage"><canvas id="game" width="960" height="540"></canvas></div>
<script>
"use strict";
/* ================================================================
THE WARDEN'S CORROSION — Aurelion's Sunken Observatory
Single-file action-adventure. Canvas 2D + WebAudio synth.
================================================================ */
/* ---------------- utils ---------------- */
const TAU = Math.PI*2;
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const angDiff=(a,b)=>{let d=(b-a)%TAU; if(d>Math.PI)d-=TAU; if(d<-Math.PI)d+=TAU; return d;};
const hash2=(x,y)=>{let h=(x*374761393+y*668265263)^0x5bf03635; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967295;};
const TILE=32, VW=960, VH=540;
const key2=(x,y)=>x+","+y;
/* ---------------- canvas ---------------- */
const cv=document.getElementById('game');
const ctx=cv.getContext('2d');
const lcv=document.createElement('canvas'); lcv.width=VW; lcv.height=VH;
const lctx=lcv.getContext('2d');
function fit(){
const s=Math.min(window.innerWidth/VW, window.innerHeight/VH, 1.6);
cv.style.width=(VW*s)+'px'; cv.style.height=(VH*s)+'px';
}
window.addEventListener('resize',fit); fit();
/* ---------------- audio (WebAudio synth) ---------------- */
const AU={
ctx:null, mg:null, muted:false, music:null, drone:null,
init(){
if(this.ctx) return;
const C=window.AudioContext||window.webkitAudioContext; if(!C) return;
this.ctx=new C();
this.mg=this.ctx.createGain(); this.mg.gain.value=0.55; this.mg.connect(this.ctx.destination);
},
resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); },
t(){ return this.ctx?this.ctx.currentTime:0; },
env(g,t0,a,d,peak){ g.gain.setValueAtTime(0.0001,t0); g.gain.linearRampToValueAtTime(peak,t0+a); g.gain.exponentialRampToValueAtTime(0.0001,t0+a+d); },
tone(f,dur,type='sine',vol=0.2,slide=0,delay=0){
if(!this.ctx||this.muted) return;
const t0=this.t()+delay, o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.setValueAtTime(Math.max(20,f),t0);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),t0+dur);
this.env(g,t0,0.006,dur,vol);
o.connect(g); g.connect(this.mg); o.start(t0); o.stop(t0+dur+0.15);
},
noise(dur,vol=0.2,freq=800,type='lowpass',delay=0){
if(!this.ctx||this.muted) return;
const t0=this.t()+delay, n=Math.max(1,Math.floor(this.ctx.sampleRate*dur));
const buf=this.ctx.createBuffer(1,n,this.ctx.sampleRate), d=buf.getChannelData(0);
for(let i=0;i<n;i++) d[i]=Math.random()*2-1;
const s=this.ctx.createBufferSource(); s.buffer=buf;
const f=this.ctx.createBiquadFilter(); f.type=type; f.frequency.value=freq;
const g=this.ctx.createGain(); this.env(g,t0,0.004,dur,vol);
s.connect(f); f.connect(g); g.connect(this.mg); s.start(t0);
},
/* --- sfx --- */
step(f){ this.noise(0.055,0.045,f,'lowpass'); },
swing(w){
if(w===0){ this.noise(0.11,0.12,1500,'bandpass'); }
else if(w===1){ this.noise(0.1,0.12,1900,'bandpass'); this.noise(0.16,0.06,650,'lowpass'); }
else { this.noise(0.2,0.13,900,'lowpass'); this.tone(68,0.32,'sine',0.28,40); }
},
hit(w){
if(w===0){ this.tone(160,0.09,'square',0.14,80); this.noise(0.05,0.1,3200,'highpass'); }
else if(w===1){ this.tone(140,0.1,'square',0.15,70); this.noise(0.13,0.12,2600,'bandpass'); }
else { this.tone(88,0.26,'sine',0.3,44); this.tone(620,0.12,'triangle',0.09,300); this.noise(0.1,0.07,4200,'highpass'); }
},
hurt(){ this.tone(110,0.18,'sine',0.3,55); this.tone(1244,0.22,'sawtooth',0.05,930); },
coin(){ this.tone(1568,0.07,'triangle',0.11); this.tone(2093,0.1,'triangle',0.09,0,0.05); },
item(){ [523,659,784].forEach((f,i)=>this.tone(f,0.14,'sine',0.11,0,i*0.06)); },
rare(){ [392,494,587,784].forEach((f,i)=>this.tone(f,0.34,'sine',0.1,0,i*0.08)); },
crack(){ this.noise(0.08,0.14,900,'lowpass'); },
brk(){ this.noise(0.26,0.24,520,'lowpass'); this.tone(78,0.22,'sine',0.2,40); },
door(){ this.tone(54,0.6,'sine',0.28,38); this.noise(0.45,0.12,200,'lowpass'); },
altar(){ [261,329,392,523,659].forEach((f,i)=>this.tone(f,0.55,'sine',0.09,0,i*0.09)); },
save(){ [329,415,494].forEach((f,i)=>this.tone(f,0.42,'sine',0.11,0,i*0.1)); },
shard(){ this.tone(1900,0.08,'square',0.07,880); },
shardHit(){ this.tone(1568,0.14,'sine',0.11); this.tone(2093,0.2,'sine',0.07,0,0.06); },
freeze(){ this.tone(880,0.3,'sine',0.11,1760); this.tone(1318,0.3,'sine',0.07,2636,0.05); },
buy(){ this.tone(1046,0.1,'triangle',0.11); this.tone(1318,0.16,'triangle',0.11,0,0.08); },
deny(){ this.tone(170,0.16,'square',0.09,110); },
mirror(){ this.tone(660,0.09,'triangle',0.1,880); },
solve(){ [523,659,784,1046].forEach((f,i)=>this.tone(f,0.36,'sine',0.1,0,i*0.07)); },
roar(){ this.tone(58,0.7,'sawtooth',0.22,34); this.noise(0.5,0.18,300,'lowpass'); },
seal(){ this.tone(220,0.4,'sawtooth',0.14,110); this.tone(1760,0.3,'sine',0.07,0,0.1); },
beacon(){ [261,329,392,523,659,784].forEach((f,i)=>this.tone(f,1.3,'sine',0.08,0,i*0.13)); },
drone(on){
if(!this.ctx) return;
if(on && !this.drone && !this.muted){
const o1=this.ctx.createOscillator(), o2=this.ctx.createOscillator(), g=this.ctx.createGain();
o1.type='triangle'; o1.frequency.value=82; o2.type='sine'; o2.frequency.value=123.5;
g.gain.value=0.0001; g.gain.linearRampToValueAtTime(0.035,this.t()+0.8);
o1.connect(g); o2.connect(g); g.connect(this.mg); o1.start(); o2.start();
this.drone={o1,o2,g};
} else if(!on && this.drone){
const d=this.drone; this.drone=null;
d.g.gain.linearRampToValueAtTime(0.0001,this.t()+0.5);
setTimeout(()=>{ try{d.o1.stop();d.o2.stop();}catch(e){} },600);
}
},
setMusic(root,boss){
if(!this.ctx) return;
if(this.music){ const m=this.music; this.music=null;
m.g.gain.linearRampToValueAtTime(0.0001,this.t()+1.2);
setTimeout(()=>{ try{m.osc.forEach(o=>o.stop());}catch(e){} },1400);
}
const t0=this.t(), osc=[];
const g=this.ctx.createGain(); g.gain.value=0.0001; g.gain.linearRampToValueAtTime(0.045,t0+2.5);
const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=620;
const lfo=this.ctx.createOscillator(), lg=this.ctx.createGain();
lfo.frequency.value=0.06; lg.gain.value=260; lfo.connect(lg); lg.connect(f.frequency); lfo.start();
[[root,'triangle',0],[root*1.5,'triangle',3],[root/2,'sine',0]].forEach(([fr,ty,dt])=>{
const o=this.ctx.createOscillator(); o.type=ty; o.frequency.value=fr; o.detune.value=dt;
o.connect(f); osc.push(o); o.start();
});
f.connect(g); g.connect(this.mg);
this.music={osc:[...osc,lfo],g};
if(boss){
const b=this.ctx.createOscillator(), bg=this.ctx.createGain();
b.type='sawtooth'; b.frequency.value=root/4; bg.gain.value=0.02;
const blfo=this.ctx.createOscillator(), blg=this.ctx.createGain();
blfo.frequency.value=1.9; blg.gain.value=0.016; blfo.connect(blg); blg.connect(bg.gain); blfo.start();
b.connect(bg); bg.connect(this.mg); b.start();
this.music.osc.push(b,blfo);
}
}
};
/* ---------------- input ---------------- */
const keys={};
const mouse={x:VW/2,y:VH/2,down:false,aimT:0};
window.addEventListener('keydown',e=>{
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if(e.repeat) return;
keys[e.code]=true;
onKey(e.code);
});
window.addEventListener('keyup',e=>{ keys[e.code]=false; });
cv.addEventListener('mousemove',e=>{
const r=cv.getBoundingClientRect();
mouse.x=(e.clientX-r.left)*(VW/r.width); mouse.y=(e.clientY-r.top)*(VH/r.height);
mouse.aimT=1.1;
});
cv.addEventListener('mousedown',e=>{ mouse.down=true; onAction(); });
window.addEventListener('mouseup',()=>{ mouse.down=false; });
cv.addEventListener('contextmenu',e=>e.preventDefault());
/* ---------------- particles ---------------- */
const parts=[];
function spawnP(o){ if(parts.length<520) parts.push(Object.assign({t:0,vx:0,vy:0,grav:0,drag:1,size:2,glow:true},o)); }
function burst(x,y,n,cols,spd=120,life=0.5,size=2.4,grav=0){
for(let i=0;i<n;i++){
const a=rand(0,TAU), s=rand(spd*0.3,spd);
spawnP({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(life*0.5,life),max:life,
color:cols[randi(0,cols.length-1)],size:rand(size*0.6,size*1.4),grav});
}
}
function spark(x,y,ang,cols,n=6,spd=180){
for(let i=0;i<n;i++){
const a=ang+rand(-0.7,0.7), s=rand(spd*0.3,spd);
spawnP({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rand(0.2,0.45),max:0.45,
color:cols[randi(0,cols.length-1)],size:rand(1.5,3),grav:200});
}
}
function goldSwirl(x,y,n=14){
for(let i=0;i<n;i++){
const a=rand(0,TAU), r=rand(6,26);
spawnP({x:x+Math.cos(a)*r,y:y+Math.sin(a)*r,vx:Math.cos(a+1.6)*40,vy:Math.sin(a+1.6)*40-30,
life:rand(0.5,0.9),max:0.9,color:'#ffd76a',size:rand(1.5,3),grav:-20});
}
}
function motes(x,y,n=10){
for(let i=0;i<n;i++) spawnP({x:x+rand(-10,10),y:y+rand(-6,6),vx:rand(-8,8),vy:rand(-46,-18),
life:rand(0.6,1.1),max:1.1,color:'#fff7e0',size:rand(1.2,2.6),grav:-10});
}
function aura(x,y,color='#ffd76a'){
for(let i=0;i<26;i++){
const a=i/26*TAU;
spawnP({x,y,vx:Math.cos(a)*90,vy:Math.sin(a)*90,life:0.7,max:0.7,color,size:2.6,drag:0.92});
}
}
function emberTrail(x,y){
spawnP({x:x+rand(-4,4),y:y+rand(-4,4),vx:rand(-6,6),vy:rand(-14,-4),
life:rand(0.25,0.5),max:0.5,color:Math.random()<0.5?'#ff9a3c':'#ffcf6a',size:rand(1.2,2.4),grav:-14});
}
function updateParts(dt){
for(let i=parts.length-1;i>=0;i--){
const p=parts[i]; p.t+=dt;
if(p.t>=p.life){ parts.splice(i,1); continue; }
p.vx*=Math.pow(p.drag,dt*60); p.vy=p.vy*Math.pow(p.drag,dt*60)+p.grav*dt;
p.x+=p.vx*dt; p.y+=p.vy*dt;
}
}
function drawParts(c){
c.save(); c.globalCompositeOperation='lighter';
for(const p of parts){
const a=1-p.t/p.life;
c.globalAlpha=a*0.9; c.fillStyle=p.color;
c.beginPath(); c.arc(p.x,p.y,p.size*(0.5+a*0.5),0,TAU); c.fill();
}
c.restore(); c.globalAlpha=1;
}
/* ---------------- damage numbers & toasts ---------------- */
const dmgNums=[];
function dmgNum(x,y,text,color='#ffd76a',size=15){
dmgNums.push({x:x+rand(-6,6),y,vy:-46,life:0.9,max:0.9,text,color,size});
}
function updateDmgNums(dt){
for(let i=dmgNums.length-1;i>=0;i--){
const d=dmgNums[i]; d.t=(d.t||0)+dt; d.y+=d.vy*dt; d.vy*=0.94;
if(d.t>=d.life) dmgNums.splice(i,1);
}
}
function drawDmgNums(c){
for(const d of dmgNums){
const a=1-d.t/d.life;
c.globalAlpha=a; c.font='700 '+d.size+'px Georgia,serif'; c.textAlign='center';
c.fillStyle='#000'; c.fillText(d.text,d.x+1.5,d.y+1.5);
c.fillStyle=d.color; c.fillText(d.text,d.x,d.y);
}
c.globalAlpha=1; c.textAlign='left';
}
const toasts=[];
function toast(text,life=4.2){ toasts.push({text,t:0,life}); if(toasts.length>4) toasts.shift(); }
function updateToasts(dt){ for(let i=toasts.length-1;i>=0;i--){ toasts[i].t+=dt; if(toasts[i].t>=toasts[i].life) toasts.splice(i,1);} }
function drawToasts(c){
c.textAlign='center';
toasts.forEach((t,i)=>{
const a=Math.min(1,t.t*3,(t.life-t.t)*1.5);
c.globalAlpha=clamp(a,0,1);
c.font='italic 15px Georgia,serif';
const w=c.measureText(t.text).width+30;
c.fillStyle='rgba(8,10,16,0.72)';
roundRect(c,VW/2-w/2,VH-64-(i*26),w,22,11); c.fill();
c.strokeStyle='rgba(201,142,46,0.5)'; c.lineWidth=1; c.stroke();
c.fillStyle='#e8d5a3'; c.fillText(t.text,VW/2,VH-50-(i*26));
});
c.globalAlpha=1; c.textAlign='left';
}
function roundRect(c,x,y,w,h,r){
c.beginPath();
c.moveTo(x+r,y); c.arcTo(x+w,y,x+w,y+h,r); c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r); c.arcTo(x,y,x+w,y,r); c.closePath();
}
/* ---------------- world definition ---------------- */
const ITEMS={
band:{name:"Warden's Band",type:'accessory',desc:'A glowing silver band. +10% attack speed.',color:'#cfd8e8'},
stem:{name:'Vital Stem',type:'consumable',desc:'Restores 2 heart containers (4 HP).',color:'#7ce88a'},
lantern:{name:'Phantom Lantern',type:'accessory',desc:'Reveals hidden cracks and passages. Soft blue glow.',color:'#7ab8ff'},
cloak:{name:'Shadow Cloak',type:'accessory',desc:'Halves damage from traps and the Guardian.',color:'#8a7fb8'},
seal:{name:'Binding Seal',type:'consumable',desc:'Freezes one enemy in place for 3 seconds.',color:'#7ad0ff'},
};
const WEAPONS=[
{name:'Ember Blade',dmg:1,cd:0.42,arc:1.9,range:46,color:'#d9c9a8'},
{name:'Ignited Edge',dmg:2,cd:0.27,arc:2.1,range:50,color:'#ff9a3c'},
{name:'Solar Falchion',dmg:3,cd:0.6,arc:2.75,range:62,color:'#ffd76a'},
];
const ZONES=[
{name:'The Ashen Threshold',floor:['#3a3126','#332b21','#3e352a'],wall:'#201a14',trim:'#8a6a3a',light:1.05,stepF:520,step:'stone',
music:[110,0],desc:'The outer gate of the Sunken Observatory. Bronze steps, fallen ash, and the first breath of the Corrosion.'},
{name:'The Rusted Archives',floor:['#332e24','#2d2820','#383226'],wall:'#1c1812',trim:'#9a6a3a',light:1.0,stepF:300,step:'metal',
music:[98,0],desc:'Corrupted records in endless bronze shelving. The merchant\'s altar hums beneath the dust.'},
{name:'The Collapsed Gallery',floor:['#32333a','#2c2d34','#363740'],wall:'#191a20',trim:'#7a6a4a',light:0.95,stepF:520,step:'stone',
music:[92,0],desc:'Framed star-charts under a caved ceiling. An altar of ignition waits among the rubble.'},
{name:'The Sunken Vault',floor:['#2b3438','#252e32','#2f393e'],wall:'#141a1d',trim:'#5a8a8a',light:0.9,stepF:340,step:'water',
music:[82,0],desc:'Flooded reliquary halls. Mist rises off the black water; something crystalline glitters in the dark.'},
{name:'The Twin Spire',floor:['#38302a','#322a24','#3c342c'],wall:'#1e1814',trim:'#b08a4a',light:0.9,stepF:300,step:'metal',
music:[78,0],desc:'Two rusted spires, one wounded. The final altar burns, and a Guardian coils in the dark between them.'},
{name:'The Core Chamber',floor:['#2e2836','#282230','#322c3a'],wall:'#171220',trim:'#c9a44a',light:0.85,stepF:520,step:'stone',
music:[70,1],desc:'The heart of the Observatory. The Core Beacon gutters above the Corrosion\'s throne.'},
];
/* per-zone tile layout: rows of 31 chars.
# wall . floor B breakable S stone A altar M merchant C chest D door P plate E exit X mirror L crystal W wall-crack */
const LAYOUTS=[
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
],
];
/* ---------------- game state ---------------- */
const G={
state:'title', zone:0, t:0, camx:0, camy:0, shake:0, flash:0, flashColor:'#ff4a3a',
shimmer:0, deathT:0, fade:1, fadeDir:-1,
zoneDirty:false, zoneClear:false,
hp:6, maxHp:6, gold:0, weapon:0, shards:0,
inv:{stem:1,seal:0}, equip:{band:false,cloak:false,lantern:false},
flags:{heart4:false,heart5:false,altar1:false,altar2:false,shardFound:false,
vaultDoor:false,vaultChest:false,plate1:false,plate2:false,seqDone:false,
mirrorSolved:false,spireChest:false,spireSeal:false,guardianDown:false,
seal1:false,seal2:false,beaconLit:false,zoneCleared:[false,false,false,false,false,false]},
shop:{stem:3,seal:2,heart:1,ammo:99},
savedZone:0, savedHp:6, savedGold:0, savedWeapon:0, savedShards:0,
savedInv:{stem:1,seal:0}, savedEquip:{band:false,cloak:false,lantern:false},
savedFlags:null,
};
let W=null; // current world
let player=null;
let enemies=[];
let bolts=[];
let worldFx=[]; // ambient emitters (dust/mist)
let boss=null;
function hasItem(id){ return G.inv[id]>0; }
function hasAcc(id){ return G.equip[id]; }
function weapon(){ return WEAPONS[G.weapon]; }
function atkSpeed(){ return G.equip.band?1.12:1.0; }
function goldBonus(){ return G.equip.cloak?1.25:1.0; }
/* ---------------- world builder ---------------- */
/* ================= ZONE DATA (coordinate-based, authoritative) =================
The LAYOUTS strings above are decorative only; everything interactive is
placed here by explicit tile coordinates with wall/floor overrides. */
const ZDATA={
0:{ // Ashen Threshold
stones:[[3,28]], exits:[[28,2]],
walls:[[14,9],[15,9],[16,9],[14,10],[15,10],[16,10],[7,14],[8,14],[9,14],[7,15],[8,15],[9,15]],
cracks:[[15,8]],
pickups:[[10,20,'gold',25],[22,12,'gold',30]],
},
1:{ // Rusted Archives
stones:[[11,15]], exits:[[28,2]], merchant:[15,15],
walls:[[23,6],[24,6],[25,6],[23,7],[25,7],[23,8],[25,8],
[6,21],[7,21],[8,21],[6,22],[8,22],[6,23],[8,23]],
floors:[[24,7],[7,22]],
cracks:[[24,6]],
chests:[[24,7,'band']],
breaks:[[7,23]],
pickups:[[7,22,'stem']],
},
2:{ // Collapsed Gallery
stones:[], exits:[[28,2]],
altars:[[15,6]],
walls:[[8,12],[9,12],[8,13],[9,13],[19,15],[21,15]],
cracks:[[13,14],[8,13]],
breaks:[[13,15,'heart4']],
plates:[[1,15],[13,22]],
doors:[[20,29]],
chests:[[20,28,'gallery']],
pickups:[[24,10,'stem']],
},
3:{ // The Sunken Vault
stones:[], exits:[[28,2]], crystalMode:'doors',
walls:[[17,12],[18,12],[19,12],[17,13],[19,13],[12,18],[13,18],[14,18],[12,19],[13,19],[14,19]],
cracks:[[18,11]],
emitters:[[2,10]],
mirrors:[[15,10,-Math.PI/2],[15,4,0]],
crystals:[[19,4]],
doors:[[18,14]],
breaks:[[18,13,'shard']],
pickups:[[24,10,'stem'],[6,20,'gold',40]],
},
4:{ // The Twin Spire
stones:[[3,28]], exits:[[28,2]], crystalMode:'doors',
altars:[[15,15]],
walls:[[17,17],[18,17],[19,17],[17,18],[19,18],[24,24],[25,24],[26,24],[24,25],[26,25]],
floors:[[18,18],[25,25]],
cracks:[[18,17],[4,7]],
breaks:[[4,8,'cloak']],
crystals:[[13,4]],
doors:[[18,19]],
jars:[[11,22]],
heart5:[18,18],
chests:[[25,25,'spire']],
pickups:[[11,22,'seal'],[22,12,'stem']],
},
5:{ // The Core Chamber
stones:[], exits:[],
beacons:[[15,13]],
crystals:[[5,8],[25,8]], crystalMode:'beacon',
walls:[[14,12],[16,12],[14,14],[16,14],[14,13],[16,13]],
cracks:[[15,12]],
},
};
function makeWorld(zi){
const H=31, WD=31, zd=ZDATA[zi]||{};
const w={
zi, W:WD, H, tiles:[], breakables:[], stones:[], altars:[],
merchant: zd.merchant?{x:zd.merchant[0],y:zd.merchant[1]}:null,
chests:[], doors:[], plates:[],
exit: zd.exits&&zd.exits.length?{x:zd.exits[0][0],y:zd.exits[0][1]}:null,
entrance: zi>0?{x:3,y:28}:null,
mirrors:[], crystals:[], emitters:[],
beacon: zd.beacons&&zd.beacons.length?{x:zd.beacons[0][0],y:zd.beacons[0][1],ready:false,lit:false}:null,
jars:[], heart5: zd.heart5?{x:zd.heart5[0],y:zd.heart5[1],taken:false}:null,
cracks:[], pickups:[], enemies:[], spawns:[], seed:zi*991+7,
crystalMode: zd.crystalMode||'none',
};
for(let y=0;y<H;y++){
w.tiles[y]=[];
for(let x=0;x<WD;x++) w.tiles[y][x]=(x===0||y===0||x===WD-1||y===H-1)?1:0;
}
for(const p of zd.walls||[]) if(p[0]>0&&p[1]>0&&p[0]<WD-1&&p[1]<H-1) w.tiles[p[1]][p[0]]=1;
for(const p of zd.floors||[]) w.tiles[p[1]][p[0]]=0;
for(const p of zd.cracks||[]) w.cracks.push({x:p[0],y:p[1]});
for(const p of zd.stones||[]) w.stones.push({x:p[0],y:p[1],used:false});
for(const p of zd.altars||[]) w.altars.push({x:p[0],y:p[1],used:false});
(zd.chests||[]).forEach(c=>w.chests.push({x:c[0],y:c[1],opened:false,loot:c[2]}));
for(const p of zd.doors||[]) w.doors.push({x:p[0],y:p[1],open:0,locked:true});
for(const p of zd.plates||[]) w.plates.push({x:p[0],y:p[1],down:false,pressT:-99});
for(const p of zd.mirrors||[]) w.mirrors.push({x:p[0],y:p[1],ang:p[2],active:false,flash:0});
for(const p of zd.crystals||[]) w.crystals.push({x:p[0],y:p[1],active:false});
for(const p of zd.emitters||[]) w.emitters.push({x:p[0],y:p[1]});
for(const p of zd.breaks||[]) w.breakables.push({x:p[0],y:p[1],hp:3,max:3,hit:0,hidden:p[2]||null});
for(const p of zd.jars||[]) w.jars.push({x:p[0],y:p[1],taken:false});
for(const p of zd.pickups||[]) w.pickups.push({x:p[0]*TILE+16,y:p[1]*TILE+16,item:p[2],amount:p[3]||0,taken:false});
for(const s of SPAWNS[zi]) w.spawns.push({x:s[0]*TILE+16,y:s[1]*TILE+16,type:s[2],count:s[3]||1});
return w;
}
function tileAt(tx,ty){
if(!W) return 1;
if(tx<0||ty<0||tx>=W.W||ty>=W.H) return 1;
const t=W.tiles[ty][tx];
if(t===1) return 1;
const d=W.doors.find(d=>d.x===tx&&d.y===ty);
if(d && d.open<1) return 1;
const b=W.breakables.find(b=>b.x===tx&&b.y===ty);
if(b) return 1;
return 0;
}
function solidRect(x,y,w,h){
const x0=Math.floor(x/TILE), x1=Math.floor((x+w-0.01)/TILE);
const y0=Math.floor(y/TILE), y1=Math.floor((y+h-0.01)/TILE);
for(let ty=y0;ty<=y1;ty++) for(let tx=x0;tx<=x1;tx++) if(tileAt(tx,ty)) return true;
return false;
}
function moveBody(b,dx,dy){
let moved=0;
if(dx!==0){
const nx=b.x+dx;
if(!solidRect(nx,b.y,b.w,b.h)) b.x=nx; else { b.hitWall=true; }
}
if(dy!==0){
const ny=b.y+dy;
if(!solidRect(b.x,ny,b.w,b.h)) b.y=ny; else { b.hitWall=true; }
}
}
function pointInWorld(px,py){
const tx=Math.floor(px/TILE), ty=Math.floor(py/TILE);
if(tx<0||ty<0||tx>=W.W||ty>=W.H) return false;
if(W.tiles[ty][tx]===1) return false;
const d=W.doors.find(d=>d.x===tx&&d.y===ty);
if(d && d.open<1) return false;
const b=W.breakables.find(b=>b.x===tx&&b.y===ty);
if(b) return false;
return true;
}
/* hidden breakables (heart / shard / lantern) */
const WALLS_HIDDEN={
3:[{x:21,y:15,item:'heart4'}],
4:[{x:22,y:10,item:'cloak'}],
5:[{x:10,y:13,item:'seal'}],
};
/* visible hairline-crack decals on real walls */
const WALLS_CRACKS={
2:[{x:6,y:8},{x:25,y:18}],
3:[{x:15,y:22},{x:21,y:14}],
4:[{x:8,y:8},{x:23,y:20}],
5:[{x:10,y:12},{x:22,y:15}],
};
/* enemy spawn definitions per zone: [tileX, tileY, type, count] */
const SPAWNS={
0:[[14,10,'sentry',1],[17,12,'sentry',1]],
1:[[9,8,'sentry',1],[15,14,'sentry',1],[19,10,'sentry',1],[11,20,'sentry',1],[24,18,'sentry',1]],
2:[[8,9,'sentry',1],[14,12,'brute',1],[18,16,'sentry',1],[10,20,'sentry',1],[22,22,'brute',1],[14,8,'sentry',1]],
3:[[9,10,'sentry',1],[15,13,'sentry',1],[20,17,'brute',1],[11,19,'sentry',1],[16,9,'sentry',1],[22,21,'sentry',1]],
4:[[8,9,'brute',1],[13,13,'sentry',1],[17,10,'brute',1],[21,15,'sentry',1],[14,19,'brute',1],[19,21,'brute',1],[9,21,'sentry',1],[16,7,'sentry',1]],
5:[[12,10,'brute',1],[19,14,'brute',1],[15,17,'sentry',1],[15,10,'boss',1]],
};
/* ================= LAYOUT PATCH — real zone designs ================= */
/* Entrance is always bottom-left (2,28); exit E top-right (28,2) except Zone 6. */
LAYOUTS[0]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............###.............#.",
"#............###.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###S.........................#.",
"#............................#.",
"###############################",
];
LAYOUTS[1]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...................###......#.",
"#..................#C#.......#.",
"#..................#B#.......#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#................M...........#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#.....###....................#.",
"#.....#.#....................#.",
"#.....#B#....................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
LAYOUTS[2]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...............A............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...........####.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#..................####......#.",
"#..................#C#.......#.",
"#..................#D#.......#.",
"###############################",
];
/* Zone 3 needs plates: add here */
LAYOUTS[2][15]="#..........####.............#.";
LAYOUTS[2][15]="#P........####.............#.";
LAYOUTS[2][22]="#.............P............#.";
/* door at (26,29) row29 col26; chest at (26,28) */
LAYOUTS[2][28]="#..................#C#.......#.";
LAYOUTS[2][29]="#..................#D#.......#.";
LAYOUTS[2][27]="#..................###.......#.";
LAYOUTS[2][26]="#............................#.";
/* heart4 hidden breakable inside wall strip row15 cols19-22 */
LAYOUTS[2][15]="#...........####.............#.";
LAYOUTS[2][15]="#...........####.............#.";
LAYOUTS[2][15]="#...........#B##.............#.";
LAYOUTS[3]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#...........L................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#..E1..........X.............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
/* Zone 4 (Vault): door + shard alcove, crystal, mirror beam */
LAYOUTS[3][4]="#...........L................#.";
LAYOUTS[3][10]="#..E1..........X.............#.";
LAYOUTS[3][11]="#............................#.";
LAYOUTS[3][12]="#..............####..........#.";
LAYOUTS[3][13]="#..............#B#...........#.";
LAYOUTS[3][14]="#..............#D#...........#.";
LAYOUTS[3][15]="#..............####..........#.";
LAYOUTS[4]=[
"###############################",
"#............................E#",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...###......................#.",
"#...#B#......................#.",
"#...###......................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#...............A............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###S.........................#.",
"#............................#.",
"###############################",
];
/* Zone 5 (Spire): crystal+door+heart5, jar seal, bonus chest */
LAYOUTS[4][4]="#...........L................#.";
LAYOUTS[4][17]="#..............####..........#.";
LAYOUTS[4][18]="#..............#H#...........#.";
LAYOUTS[4][19]="#..............#D#...........#.";
LAYOUTS[4][20]="#..............####..........#.";
LAYOUTS[4][22]="#.........J..................#.";
LAYOUTS[4][24]="#...................###......#.";
LAYOUTS[4][25]="#..................#C#.......#.";
LAYOUTS[4][26]="#..................###.......#.";
LAYOUTS[5]=[
"###############################",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#....L.................L.....#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#.............K..............#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"#............................#.",
"###############################",
];
/* fix: K beacon at (15,13) */
LAYOUTS[5][13]="#.............K..............#.";
LAYOUTS[5][14]="#............................#.";
/* hidden breakables — corrected zone mapping */
WALLS_HIDDEN[2]=[{x:20,y:15,item:'heart4'}];
WALLS_HIDDEN[3]=[{x:18,y:13,item:'shard'}];
WALLS_HIDDEN[4]=[{x:5,y:8,item:'cloak'}];
delete WALLS_HIDDEN[5];
WALLS_CRACKS[2]=[{x:20,y:14},{x:26,y:28}];
WALLS_CRACKS[3]=[{x:18,y:12},{x:6,y:10}];
WALLS_CRACKS[4]=[{x:5,y:7},{x:18,y:17}];
WALLS_CRACKS[5]=[{x:5,y:8},{x:25,y:8}];
SPAWNS[4].push([15,18,'guardian',1]);
/* tile semantics extensions:
E1 beam emitter (right) K beacon J broken jar (seal pickup) H heart5 pickup
B breakable (hidden item via WALLS_HIDDEN) P plate D door L crystal switch
X mirror C chest M merchant S sanctuary stone A altar E exit */
const EXTRA_PICKUPS={
1:[{x:7,y:22,item:'stem'},{x:25,y:7,item:'band'}],
2:[{x:26,y:28,item:'chestGold',amount:80}],
4:[{x:11,y:22,item:'seal'},{x:18,y:18,item:'heart5'},{x:25,y:25,item:'chestGold',amount:120}],
};
/* chests contents */
const CHEST_LOOT={
1:[{item:'stem'}], // archives band chest
2:[{gold:80},{item:'stem'}], // gallery puzzle chest
4:[{gold:120},{item:'stem'}], // spire bonus chest
};
/* ---------------- player ---------------- */
function makePlayer(x,y){
return {x,y,w:18,h:20,vx:0,vy:0,face:1,aim:0,
atkCd:0, atkT:0, atkAng:0, atkDur:0, didHit:false,
hurtT:0, invuln:0, dead:false, walkT:0, moving:false,
footT:0, emberT:0, attackFlash:0};
}
function playerCenter(){ return {x:player.x+player.w/2, y:player.y+player.h/2}; }
function aimAngle(){
if(mouse.aimT>0){
const c=playerCenter();
return Math.atan2((mouse.y+G.camy)-c.y,(mouse.x+G.camx)-c.x);
}
return player.aim;
}
function playerUpdate(dt){
const p=player;
p.atkCd-=dt; p.atkT-=dt; p.hurtT-=dt; p.invuln-=dt; p.attackFlash-=dt;
let dx=0,dy=0;
if(keys['KeyA']||keys['ArrowLeft']) dx-=1;
if(keys['KeyD']||keys['ArrowRight']) dx+=1;
if(keys['KeyW']||keys['ArrowUp']) dy-=1;
if(keys['KeyS']||keys['ArrowDown']) dy+=1;
const len=Math.hypot(dx,dy);
const spd=172;
p.moving=len>0;
if(len>0){ dx/=len; dy/=len; p.face=dx!==0?(dx>0?1:-1):p.face; p.walkT+=dt*9; }
else p.walkT*=0.9;
p.vx=dx*spd; p.vy=dy*spd;
p.hitWall=false;
moveBody(p,p.vx*dt,p.vy*dt);
p.x=clamp(p.x,4,W.W*TILE-4-p.w); p.y=clamp(p.y,4,W.H*TILE-4-p.h);
if(!mouse.aimT) p.aim=Math.atan2(p.vy,p.vx)||0;
mouse.aimT-=dt;
/* footsteps */
if(p.moving){
p.footT-=dt;
if(p.footT<=0){
p.footT=0.34;
const c=ZONES[W.zi];
if(c.step==='metal') AU.step(260);
else if(c.step==='water') AU.step(420);
else AU.step(c.stepF);
if(Math.random()<0.5) spawnP({x:p.x+p.w/2+rand(-6,6),y:p.y+p.h-2,vx:rand(-6,6),vy:rand(-10,-3),life:0.3,max:0.3,color:'rgba(200,180,140,0.25)',size:1.6,grav:-6});
}
}
/* ember trail when upgraded */
if(G.weapon>=1 && p.moving){
p.emberT-=dt;
if(p.emberT<=0){ p.emberT=G.weapon===2?0.05:0.09; emberTrail(p.x+p.w/2,p.y+p.h*0.7); }
}
/* attack */
const act=keys['KeyJ']||keys['KeyZ']||mouse.down;
if(act && p.atkCd<=0 && p.atkT<=0 && !p.dead){
const w=weapon();
p.atkT=w.cd/atkSpeed(); p.atkCd=p.atkT; p.atkDur=Math.max(0.12,w.cd*0.55);
p.atkAng=aimAngle(); p.didHit=false; p.attackFlash=0.1;
AU.swing(G.weapon);
}
if(p.atkT>0 && !p.didHit){
const w=weapon(), c=playerCenter();
const a=aimAngle();
for(const e of enemies){
if(e.dead) continue;
const ex=e.x+e.w/2, ey=e.y+e.h/2;
const d=dist(c.x,c.y,ex,ey);
if(d<w.range+e.w/2 && Math.abs(angDiff(a,Math.atan2(ey-c.y,ex-c.x)))<w.arc/2){
p.didHit=true;
hitEnemy(e,w.dmg,a);
break;
}
}
if(!p.didHit){
/* breakables in arc */
for(const b of W.breakables){
if(b.hp<=0) continue;
const bx=b.x*TILE+16, by=b.y*TILE+16;
const d=dist(c.x,c.y,bx,by);
if(d<w.range+18 && Math.abs(angDiff(a,Math.atan2(by-c.y,bx-c.x)))<w.arc/2+0.25){
p.didHit=true;
b.hp-= G.weapon===2?3:1; b.hit=0.15;
AU.crack();
burst(bx,by,6,['#8a7a5a','#6a5a40'],60,0.4,2);
if(b.hp<=0) breakWall(b);
break;
}
}
}
/* swing sparks in front */
if(G.weapon>=1){
const sx=c.x+Math.cos(a)*w.range*0.8, sy=c.y+Math.sin(a)*w.range*0.8;
spark(sx,sy,a,G.weapon===2?['#ffd76a','#fff2c0']:['#ff9a3c','#ffcf6a'],5,140);
}
}
/* shard fire */
if(keys['KeyK']||keys['KeyX']){
keys['KeyK']=false; keys['KeyX']=false;
fireShard();
}
}
function fireShard(){
const p=player;
if(!G.flags.shardFound){ toast('You feel for a crystal in your off-hand... and find only air.'); return; }
if(G.shards<=0){ toast('The Aether Shard is spent. (restock at the merchant, 10 gold)'); AU.deny(); return; }
if(p.shardCd>0) return;
p.shardCd=0.35;
G.shards--;
const a=aimAngle(), c=playerCenter();
bolts.push({x:c.x,y:c.y,vx:Math.cos(a)*430,vy:Math.sin(a)*430,life:1.6,ang:a});
AU.shard();
spark(c.x+Math.cos(a)*14,c.y+Math.sin(a)*14,a,['#9fd8ff','#e8f6ff'],4,90);
}
function hitEnemy(e,dmg,ang){
e.hp-=dmg; e.flash=0.12; e.hurtFlash=0.15;
const knock=G.weapon===2?160:90;
e.vx+=Math.cos(ang)*knock; e.vy+=Math.sin(ang)*knock;
const ex=e.x+e.w/2, ey=e.y+e.h/2;
dmgNum(ex,ey-14,String(dmg),G.weapon===2?'#ffe9a0':(G.weapon===1?'#ffb060':'#f0e0c0'),G.weapon===2?17:14);
if(G.weapon===0) spark(ex,ey,ang,['#ffcf6a','#e8a050'],6,170);
else if(G.weapon===1){ spark(ex,ey,ang,['#ff9a3c','#ff6a2a'],8,190); e.burn=1.2; }
else { spark(ex,ey,ang,['#ffd76a','#fff2c0','#ff9a3c'],12,240); G.shake=Math.max(G.shake,3); }
AU.hit(G.weapon);
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.dead=true;
const ex=e.x+e.w/2, ey=e.y+e.h/2;
burst(ex,ey,18,['#4a4a55','#2a2a30','#8a3a2a'],140,0.7,3,60);
burst(ex,ey,8,['#c98a4a','#7a5a3a'],90,0.5,2);
const gold=Math.round(rand(5,15)*goldBonus());
G.gold+=gold;
dmgNum(ex,ey-26,'+'+gold+'g','#ffd76a',13);
AU.coin(); goldSwirl(ex,ey,8);
if(e.type==='guardian'){
G.flags.guardianDown=true;
if(!G.equip.cloak){ toast('The Guardian\'s husk collapses. A shadow cloak drifts from its shoulder.'); }
G.zoneClear=true;
}
if(e.type==='boss'){
G.flags.beaconLit=true;
if(W.beacon) W.beacon.lit=true;
G.zoneClear=true;
G.shimmer=2.4;
AU.beacon();
}
if(e.type==='sentry' && Math.random()<0.08) dropSeal(ex,ey);
}
function dropSeal(x,y){
W.pickups.push({x,y,item:'seal',amount:0,taken:false});
toast('A Binding Seal tumbles free!');
}
function damagePlayer(dmg,src){
const p=player;
if(p.invuln>0||p.dead) return;
if(src==='trap' && G.equip.cloak) dmg=Math.ceil(dmg*0.5);
if(src==='guardian' && G.equip.cloak) dmg=Math.ceil(dmg*0.5);
G.hp-=dmg;
p.invuln=0.9; p.hurtT=0.35;
G.flash=0.35; G.flashColor='#ff4a3a'; G.shake=Math.max(G.shake,5);
AU.hurt();
const c=playerCenter();
burst(c.x,c.y,14,['#ff4a3a','#c02a2a'],150,0.5,2.6);
dmgNum(c.x,c.y-20,'-'+dmg,'#ff6a5a',16);
if(G.hp<=0){ G.hp=0; p.dead=true; G.deathT=1.4; }
}
/* ---------------- enemies ---------------- */
function spawnEnemy(type,x,y){
const e={type,x:x-14,y:y-14,w:28,h:28,vx:0,vy:0,hp:3,maxHp:3,
state:'idle',t:rand(0,1),atkCd:0,flash:0,hurtFlash:0,burn:0,dead:false,
homeX:x,homeY:y,wanderA:rand(0,TAU),face:1,castT:0,phase:0,chargeCd:3,
aggro:false,spawnT:0.6,seed:rand(0,100)};
if(type==='sentry'){ e.hp=e.maxHp=3; e.spd=62; e.dmg=1; }
if(type==='brute'){ e.hp=e.maxHp=7; e.spd=44; e.dmg=2; e.w=32; e.h=32; e.x-=2; e.y-=2; }
if(type==='guardian'){ e.hp=e.maxHp=26; e.spd=55; e.dmg=2; e.w=40; e.h=40; e.x-=6; e.y-=6; e.mini=true; }
if(type==='boss'){ e.hp=e.maxHp=60; e.spd=48; e.dmg=2; e.w=52; e.h=52; e.x-=12; e.y-=12; e.isBoss=true; }
enemies.push(e);
return e;
}
function enemyUpdate(dt){
const p=player;
for(const e of enemies){
if(e.dead) continue;
e.flash-=dt; e.hurtFlash-=dt; e.burn-=dt; e.atkCd-=dt; e.t+=dt;
if(e.spawnT>0){ e.spawnT-=dt; continue; }
if(e.burn>0 && Math.random()<dt*6) emberTrail(e.x+e.w/2,e.y+e.h/2);
const c={x:e.x+e.w/2,y:e.y+e.h/2};
const pc=playerCenter();
const d=dist(c.x,c.y,pc.x,pc.y);
/* freeze check via e.frozen */
if(e.frozen>0){ e.frozen-=dt; e.vx*=0.8; e.vy*=0.8; continue; }
switch(e.type){
case 'sentry': case 'brute': case 'guardian': {
const aggroR=e.type==='brute'?150:(e.type==='guardian'?190:170);
if(d<aggroR && !p.dead){ e.aggro=true; e.spawnT=0; }
if(e.aggro){
const a=Math.atan2(pc.y-c.y,pc.x-c.x);
if(d>26){ e.vx+=Math.cos(a)*e.spd*4*dt; e.vy+=Math.sin(a)*e.spd*4*dt; e.face=Math.cos(a)>0?1:-1; }
else if(e.atkCd<=0){ e.atkCd=1.1; damagePlayer(e.dmg,'enemy'); }
} else {
e.wanderT=(e.wanderT||0)-dt;
if(e.wanderT<=0){ e.wanderT=rand(1.5,3.5); e.wanderA=rand(0,TAU); }
e.vx+=Math.cos(e.wanderA)*e.spd*1.2*dt; e.vy+=Math.sin(e.wanderA)*e.spd*1.2*dt;
}
break;
}
case 'boss': bossUpdate(e,dt,c,pc,d); break;
}
/* friction + move */
e.vx*=Math.pow(0.0009,dt); e.vy*=Math.pow(0.0009,dt);
const sp=Math.hypot(e.vx,e.vy), maxS=e.spd*(e.aggro?1.15:0.5);
if(sp>maxS){ e.vx*=maxS/sp; e.vy*=maxS/sp; }
e.hitWall=false;
moveBody(e,e.vx*dt,e.vy*dt);
if(e.hitWall){ e.wanderA=rand(0,TAU); }
/* body contact damage */
if(!p.dead && p.invuln<=0 && p.hurtT<=0.4){
if(rectsOverlap(p,e)){ damagePlayer(e.type==='sentry'?1:2,'enemy'); }
}
}
enemies=enemies.filter(e=>!e.dead||e.deathAnim>0);
enemies=enemies.filter(e=>!e.dead);
}
function rectsOverlap(a,b){ return a.x<b.x+b.w && a.x+a.w>b.x && a.y<b.y+b.h && a.y+a.h>b.y; }
function bossUpdate(e,dt,c,pc,d){
e.phaseT=(e.phaseT||0)+dt;
const enraged=e.hp<e.maxHp*0.5;
e.aggro=true;
if(e.phase===0){ // orbit + charge
const a=Math.atan2(pc.y-c.y,pc.x-c.x)+Math.sin(e.phaseT*1.3)*0.9;
if(d>70){ e.vx+=Math.cos(a)*e.spd*3.2*dt; e.vy+=Math.sin(a)*e.spd*3.2*dt; }
else { e.vx*=0.9; e.vy*=0.9; }
e.chargeCd-=dt;
if(e.chargeCd<=0 && d>90){
e.state='charging'; e.stateT=0.5; e.chargeA=Math.atan2(pc.y-c.y,pc.x-c.x);
AU.roar();
}
if(e.state==='charging'){
e.stateT-=dt;
e.vx=Math.cos(e.chargeA)*260; e.vy=Math.sin(e.chargeA)*260;
if(e.hitWall){ e.state='idle'; e.chargeCd=enraged?2.2:3.2; e.vx=0;e.vy=0; G.shake=Math.max(G.shake,4); }
if(e.stateT<=0){ e.state='idle'; e.chargeCd=3; }
} else if(e.state!=='idle'){ e.state='idle'; }
/* cast bolts every 4s */
e.castT-=dt;
if(e.castT<=0){
e.castT=enraged?3:4;
const a=Math.atan2(pc.y-c.y,pc.x-c.x);
bolts.push({x:c.x,y:c.y,vx:Math.cos(a)*230,vy:Math.sin(a)*230,life:3,ang:a,enemy:true});
AU.tone(220,0.3,'sawtooth',0.1,110);
}
}
}
/* ---------------- bolts ---------------- */
function updateBolts(dt){
for(let i=bolts.length-1;i>=0;i--){
const b=bolts[i];
b.life-=dt;
b.x+=b.vx*dt; b.y+=b.vy*dt;
let gone=b.life<=0;
if(!gone && !pointInWorld(b.x,b.y)){
/* hit a wall */
if(Math.abs(b.vx)>Math.abs(b.vy)){ b.x-=b.vx*dt; } else { b.y-=b.vy*dt; }
gone=true;
spark(b.x,b.y,b.ang+Math.PI,['#7ab8ff','#c0e0ff'],5,100);
/* breakable hit by bolt */
for(const br of W.breakables){
if(br.hp>0 && Math.floor(b.x/TILE)===br.x && Math.floor(b.y/TILE)===br.y){
br.hp-=2; br.hit=0.15; AU.crack();
if(br.hp<=0) breakWall(br);
}
}
}
if(!gone && !b.enemy){
/* crystals */
for(const cr of W.crystals){
if(cr.active) continue;
const cx=cr.x*TILE+16, cy=cr.y*TILE+16;
if(dist(b.x,b.y,cx,cy)<20){
cr.active=true; AU.shardHit(); G.solved=true;
burst(cx,cy,20,['#9fd8ff','#e8f6ff'],160,0.7,2.6);
if(W.crystalMode==='doors'){
for(const dr of W.doors){ if(!dr.open) { dr.open=1; AU.door(); } }
toast('The crystal answers — the vault gate grinds open.');
} else if(W.crystalMode==='beacon'){
if(W.beacon){ W.beacon.ready=true; toast('The Core Beacon stirs. Its eye opens to the Warden.'); }
}
gone=true;
}
}
/* enemies */
for(const e of enemies){
if(e.dead) continue;
if(b.x>e.x&&b.x<e.x+e.w&&b.y>e.y&&b.y<e.y+e.h){
hitEnemy(e,1,b.ang);
spark(b.x,b.y,b.ang,['#9fd8ff','#fff'],6,120);
gone=true; break;
}
}
} else if(!gone && b.enemy){
const p=player;
if(!p.dead && b.x>p.x&&b.x<p.x+p.w&&b.y>p.y&&b.y<p.y+p.h){
damagePlayer(1,'enemy'); gone=true;
}
}
if(gone){
if(!b.enemy) spark(b.x,b.y,b.ang,['#7ab8ff','#c0e0ff'],6,110);
AU.shardHit();
bolts.splice(i,1);
}
}
}
/* ---------------- world interactions ---------------- */
function breakWall(b){
const bx=b.x*TILE+16, by=b.y*TILE+16;
AU.brk();
burst(bx,by,22,['#6a5a40','#4a3a28','#8a7a5a'],150,0.8,3,120);
G.shake=Math.max(G.shake,3);
if(b.hidden==='heart4'){
G.flags.heart4=true; G.maxHp=8; G.hp=Math.min(G.hp+4,G.maxHp);
G.shimmer=2.2; AU.rare();
aura(bx,by,'#ffd76a'); motes(bx,by,14);
toast('A hidden cache! The fourth heart container pulses to life. (8 HP)');
} else if(b.hidden==='shard'){
G.flags.shardFound=true; G.shards=10;
AU.rare();
aura(bx,by,'#9fd8ff'); motes(bx,by,16);
toast('The AETHER SHARD! A crystalline launcher hums in your off-hand. (10 charges — K to fire)');
} else if(b.hidden==='cloak'){
G.equip.cloak=true;
AU.rare();
aura(bx,by,'#8a7fb8'); motes(bx,by,14);
toast('The SHADOW CLOAK settles over your shoulders. Traps and the Guardian hurt less.');
}
}
function interactUpdate(dt){
const c=playerCenter();
let near=null;
for(const s of W.stones) if(dist(c.x,c.y,s.x*TILE+16,s.y*TILE+16)<46){ near={t:'stone',o:s}; break; }
if(!near) for(const a of W.altars) if(dist(c.x,c.y,a.x*TILE+16,a.y*TILE+16)<48){ near={t:'altar',o:a}; break; }
if(!near && W.merchant) if(dist(c.x,c.y,W.merchant.x*TILE+16,W.merchant.y*TILE+16)<52){ near={t:'merchant'}; }
if(!near) for(const ch of W.chests) if(!ch.opened && dist(c.x,c.y,ch.x*TILE+16,ch.y*TILE+16)<44){ near={t:'chest',o:ch}; break; }
if(!near && W.beacon && W.beacon.ready && !W.beacon.lit) if(dist(c.x,c.y,W.beacon.x*TILE+16,W.beacon.y*TILE+16)<70){ near={t:'beacon'}; }
if(!near && W.heart5 && !W.heart5.taken && dist(c.x,c.y,W.heart5.x*TILE+16,W.heart5.y*TILE+16)<40){ near={t:'heart5',o:W.heart5}; }
if(!near) for(const j of W.jars) if(!j.taken && dist(c.x,c.y,j.x*TILE+16,j.y*TILE+16)<36){ near={t:'jar',o:j}; }
G.near=near;
G.nearHint=0;
if(near) G.nearHint+=dt;
if(near && G.nearHint>0.25 && !G.nearPrompted){
G.nearPrompted=true;
if(near.t==='stone') toast('Sanctuary Stone — press E to rest and save.');
if(near.t==='altar') toast('Weapon altar — press E to attune the blade.');
if(near.t==='merchant') toast('Merchant altar — press E to trade.');
if(near.t==='chest') toast('A sealed chest — press E to open.');
if(near.t==='beacon') toast('The Core Beacon — press E to reignite Aurelion.');
if(near.t==='heart5') toast('The fifth heart container — press E to claim.');
if(near.t==='jar') toast('A broken jar — press E to search.');
}
if(!near) G.nearPrompted=false;
if(keys['KeyE'] && near){ keys['KeyE']=false; doInteract(near); }
/* plates */
for(const pl of W.plates){
const was=pl.down;
pl.down = (Math.floor((c.x)/TILE)===pl.x && Math.floor((c.y+player.h)/TILE)===pl.y) ||
(Math.floor((c.x+player.w)/TILE)===pl.x && Math.floor((c.y+player.h)/TILE)===pl.y);
if(pl.down && !was){
pl.pressT=G.t;
AU.noise(0.2,0.15,300,'lowpass');
checkPlates();
}
}
/* pickups */
for(const pk of W.pickups){
if(pk.taken) continue;
if(dist(c.x,c.y,pk.x,pk.y)<24){
pk.taken=true;
if(pk.item==='gold'){ G.gold+=pk.amount; AU.coin(); goldSwirl(pk.x,pk.y,10); dmgNum(pk.x,pk.y-10,'+'+pk.amount+'g','#ffd76a',13); }
else if(pk.item==='stem'){ G.inv.stem++; AU.item(); motes(pk.x,pk.y); toast('Vital Stem acquired. (Q to drink)'); }
else if(pk.item==='seal'){ G.inv.seal++; AU.item(); motes(pk.x,pk.y); toast('Binding Seal acquired. (F to cast)'); }
else if(pk.item==='band'){ G.equip.band=true; AU.rare(); aura(pk.x,pk.y,'#cfd8e8'); toast('The WARDEN\'S BAND binds your wrist. Attack speed +10%.'); }
}
}
W.pickups=W.pickups.filter(p=>!p.taken);
/* door open animations */
for(const dr of W.doors){
if(dr.open<1) dr.open=Math.min(1,dr.open+dt*1.4);
}
/* beacon */
if(W.beacon){
if(W.beacon.ready && !W.beacon.lit && Math.random()<dt*8) spark(W.beacon.x*TILE+16,W.beacon.y*TILE+16,rand(0,TAU),['#ffd76a','#fff2c0'],1,60);
if(W.beacon.lit && Math.random()<dt*14) motes(W.beacon.x*TILE+16,W.beacon.y*TILE-10,1);
}
/* merchant drone */
const nearM=W.merchant&&dist(c.x,c.y,W.merchant.x*TILE+16,W.merchant.y*TILE+16)<170;
if(nearM!==G.merchantNear){ G.merchantNear=nearM; AU.drone(nearM); }
/* zone cleared check: all constructs down (Guardian required in the Spire) */
if(!G.zoneClear && W.zi<5){
const living=enemies.filter(e=>!e.dead).length;
const need = W.zi===4 ? (G.flags.guardianDown && living===0) : (living===0);
if(need) G.zoneClear=true;
}
}
function checkPlates(){
if(W.zi!==2) return;
const active=W.plates.filter(p=>G.t-p.pressT<3.5);
if(active.length>=2 && !G.flags.seqDone){
G.flags.seqDone=true;
for(const dr of W.doors){ dr.open=1; AU.door(); }
AU.solve();
toast('The twin plates sing in unison — the gallery gate swings open. +30 gold');
G.gold+=30; goldSwirl(playerCenter().x,playerCenter().y,12);
}
}
function doInteract(near){
if(near.t==='stone'){
near.o.used=true;
G.savedZone=W.zi; G.savedHp=G.hp; G.savedGold=G.gold; G.savedWeapon=G.weapon;
G.savedShards=G.shards; G.savedInv={...G.inv}; G.savedEquip={...G.equip};
G.savedFlags=JSON.parse(JSON.stringify(G.flags));
persistSave();
G.hp=G.maxHp; G.shards=Math.max(G.shards,5);
AU.save(); G.shimmer=1.6;
aura(playerCenter().x,playerCenter().y,'#ffd76a');
toast('The Sanctuary Stone drinks your weariness. Progress inscribed. (healed to '+G.maxHp+' HP)');
} else if(near.t==='altar'){
if(G.weapon===0 && W.zi===2){
G.weapon=1; G.flags.altar1=true;
AU.altar(); G.shimmer=2;
const c=playerCenter(); aura(c.x,c.y,'#ff9a3c');
toast('The IGNITED EDGE. The blade catches fire; your swings grow swift and searing.');
} else if(G.weapon===1 && W.zi===4){
G.weapon=2; G.flags.altar2=true;
AU.altar(); G.shimmer=2.4;
const c=playerCenter(); aura(c.x,c.y,'#ffd76a');
toast('The SOLAR FALCHION. A golden crescent of light — heavier, wider, terrible.');
} else if(near.o.used){
AU.deny(); toast('The altar is spent.');
}
} else if(near.t==='merchant'){
G.state='shop'; AU.tone(392,0.2,'sine',0.08);
} else if(near.t==='chest'){
near.o.opened=true;
AU.rare();
const cx=near.o.x*TILE+16, cy=near.o.y*TILE+16;
aura(cx,cy,'#fff2c0'); motes(cx,cy,16);
if(near.o.loot==='band'){
G.equip.band=true;
toast('The WARDEN\'S BAND — a glowing silver ring. Attack speed +10%.');
} else if(near.o.loot==='gallery'){
G.gold+=80; G.inv.stem++;
AU.coin(); goldSwirl(cx,cy,14);
toast('Puzzle chest: +80 gold and a Vital Stem.');
} else if(near.o.loot==='spire'){
G.gold+=120; G.inv.stem++;
AU.coin(); goldSwirl(cx,cy,16);
toast('The bonus chest: +120 gold and a Vital Stem.');
}
} else if(near.t==='heart5'){
if(!G.flags.heart5){
G.flags.heart5=true; G.maxHp=10; G.hp=Math.min(G.hp+4,G.maxHp);
AU.rare(); G.shimmer=2.4;
const c=playerCenter(); aura(c.x,c.y,'#ffd76a');
toast('The FIFTH heart container. Your heart beats as one with the Spire. (10 HP)');
}
} else if(near.t==='jar'){
near.o.taken=true;
G.inv.seal++; AU.item(); motes(near.o.x*TILE+16,near.o.y*TILE+16);
toast('Inside the broken jar: a Binding Seal. (F to cast)');
} else if(near.t==='beacon'){
G.flags.beaconLit=true; W.beacon.lit=true;
G.zoneClear=true;
AU.beacon(); G.shimmer=3;
toast('The Core Beacon reignites. Aurelion remembers its Warden.');
}
}
/* ---------------- consumables ---------------- */
function useStem(){
if(G.inv.stem<=0){ AU.deny(); toast('No Vital Stems remain. (merchant sells them, 50 gold)'); return; }
if(G.hp>=G.maxHp){ AU.deny(); toast('Your hearts are already full.'); return; }
G.inv.stem--;
G.hp=Math.min(G.maxHp,G.hp+4);
AU.item();
const c=playerCenter();
burst(c.x,c.y,12,['#7ce88a','#c0ffd0'],90,0.6,2.4);
dmgNum(c.x,c.y-22,'+4','#7ce88a',15);
toast('The Vital Stem knits your wounds. (+4 HP)');
}
function useSeal(){
if(G.inv.seal<=0){ AU.deny(); toast('No Binding Seals remain. (merchant sells them, 75 gold)'); return; }
let best=null,bd=1e9;
for(const e of enemies){
if(e.dead||e.frozen>0) continue;
const d=dist(playerCenter().x,playerCenter().y,e.x+e.w/2,e.y+e.h/2);
if(d<260 && d<bd){ bd=d; best=e; }
}
if(!best){ AU.deny(); toast('No enemy within reach to bind.'); return; }
G.inv.seal--;
best.frozen=3;
AU.freeze();
const c={x:best.x+best.w/2,y:best.y+best.h/2};
burst(c.x,c.y,16,['#7ad0ff','#d0f0ff'],110,0.6,2.4);
dmgNum(c.x,c.y-18,'BOUND','#7ad0ff',13);
}
/* ---------------- game flow ---------------- */
const SAVE_KEY='warden_corrosion_save_v1';
function hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } }
function persistSave(){
try{
localStorage.setItem(SAVE_KEY,JSON.stringify({
zone:G.savedZone,hp:G.savedHp,gold:G.savedGold,weapon:G.savedWeapon,shards:G.savedShards,
inv:G.savedInv,equip:G.savedEquip,flags:G.savedFlags,maxHp:G.maxHp
}));
}catch(e){}
}
function loadFromSave(){
try{
const d=JSON.parse(localStorage.getItem(SAVE_KEY));
G.savedZone=d.zone; G.savedHp=d.hp; G.savedGold=d.gold; G.savedWeapon=d.weapon;
G.savedShards=d.shards; G.savedInv=d.inv; G.savedEquip=d.equip; G.savedFlags=d.flags;
G.maxHp=d.maxHp||6;
G.hp=d.hp; G.gold=d.gold; G.weapon=d.weapon; G.shards=d.shards;
G.inv=d.inv; G.equip=d.equip; G.flags=Object.assign(G.flags,d.flags);
G.shop={stem:3,seal:2,heart:G.flags.heart5?0:1,ammo:99};
toast('Returned to the last Sanctuary Stone.');
return true;
}catch(e){ toast('No valid save found.'); return false; }
}
function newGame(){
G.hp=6; G.maxHp=6; G.gold=0; G.weapon=0; G.shards=0;
G.inv={stem:1,seal:0}; G.equip={band:false,cloak:false,lantern:false};
G.flags={heart4:false,heart5:false,altar1:false,altar2:false,shardFound:false,
vaultDoor:false,vaultChest:false,plate1:false,plate2:false,seqDone:false,
mirrorSolved:false,spireChest:false,spireSeal:false,guardianDown:false,
seal1:false,seal2:false,beaconLit:false,zoneCleared:[false,false,false,false,false,false]};
G.shop={stem:3,seal:2,heart:1,ammo:99};
G.savedZone=0; G.savedHp=6; G.savedGold=0; G.savedWeapon=0; G.savedShards=0;
G.savedInv={stem:1,seal:0}; G.savedEquip={band:false,cloak:false,lantern:false};
G.savedFlags=JSON.parse(JSON.stringify(G.flags));
G.state='playing';
startFade(()=>loadZone(0));
}
function resumeGame(){
if(!hasSave()) return;
if(loadFromSave()){ G.state='playing'; startFade(()=>loadZone(G.savedZone)); }
}
function startFade(cb){
G.fadeDir=1; G.fadeCb=cb;
}
function loadZone(zi){
G.zone=zi;
W=makeWorld(zi);
player=makePlayer(3*TILE+16,28*TILE+16);
player.shardCd=0;
enemies=[]; bolts=[]; parts.length=0; dmgNums.length=0;
for(const s of W.spawns) for(let i=0;i<s.count;i++) spawnEnemy(s.type,s.x+(i?10:0),s.y+(i?8:0));
G.camx=clamp(player.x-VW/2,0,W.W*TILE-VW);
G.camy=clamp(player.y-VH/2,0,W.H*TILE-VH);
G.zoneClear=false; G.merchantNear=false; G.near=null; G.nearPrompted=false;
G.shake=0; G.flash=0;
AU.drone(false);
AU.setMusic(ZONES[zi].music[0], zi===5);
G.zoneToastT=3.2;
worldFx=[];
for(let i=0;i<40;i++){
if(zi===1||zi===2) worldFx.push({type:'dust',x:rand(0,W.W*TILE),y:rand(0,W.H*TILE),vx:rand(-4,4),vy:rand(-3,3),life:rand(2,6)});
if(zi===3) worldFx.push({type:'mist',x:rand(0,W.W*TILE),y:rand(W.H*TILE*0.4,W.H*TILE),vx:rand(-3,3),vy:rand(-14,-6),life:rand(3,8),s:rand(30,70)});
if(zi===0) worldFx.push({type:'ash',x:rand(0,W.W*TILE),y:rand(0,W.H*TILE),vx:rand(-8,8),vy:rand(4,10),life:rand(2,5)});
}
}
function tryTransition(){
if(G.state!=='playing') return;
const c=playerCenter();
const tx=Math.floor(c.x/TILE), ty=Math.floor(c.y/TILE);
if(W.exit && tx===W.exit.x && ty===W.exit.y && !G.zoneClear){
/* need zone goal done */
}
if(W.exit && tx===W.exit.x && ty===W.exit.y && G.zoneClear){
G.flags.zoneCleared[W.zi]=true;
startFade(()=>loadZone(W.zi+1));
} else if(W.entrance && tx===W.entrance.x && ty===W.entrance.y){
startFade(()=>loadZone(W.zi-1));
}
}
/* ---------------- input dispatch ---------------- */
function onKey(code){
AU.init(); AU.resume();
if(G.state==='title'){
if(code==='Enter'||code==='Space') newGame();
else if(code==='KeyR'&&hasSave()) resumeGame();
return;
}
if(G.state==='ending'){ if(code==='Enter') { G.state='title'; } return; }
if(G.state==='death') return;
if(G.state==='shop'){
if(code==='Escape'||code==='KeyE') closeShop();
else if(code>='Digit1'&&code<='Digit4') buyShop(parseInt(code.charAt(5))-1);
return;
}
if(G.state==='inv'){
if(code==='Escape'||code==='KeyI'||code==='Tab') G.state='playing';
else if(code==='Digit1') toggleAcc('band');
else if(code==='Digit2') toggleAcc('lantern');
else if(code==='Digit3') toggleAcc('cloak');
return;
}
if(G.state==='pause'){
if(code==='Escape'||code==='Enter') G.state='playing';
else if(code==='KeyR'){ if(hasSave()){ G.state='playing'; if(loadFromSave()) startFade(()=>loadZone(G.savedZone)); } }
else if(code==='KeyM'){ AU.muted=!AU.muted; toast(AU.muted?'Sound muted.':'Sound on.'); }
return;
}
/* playing */
if(code==='KeyQ') useStem();
else if(code==='KeyF') useSeal();
else if(code==='KeyI'||code==='Tab'){ G.state='inv'; }
else if(code==='KeyM'){ AU.muted=!AU.muted; AU.drone(G.merchantNear); toast(AU.muted?'Sound muted.':'Sound on.'); }
else if(code==='Escape'){ G.state='pause'; }
}
function toggleAcc(id){
G.equip[id]=!G.equip[id];
AU.tone(G.equip[id]?660:330,0.12,'triangle',0.1);
const n=ITEMS[id].name;
toast(G.equip[id]?n+' equipped.':n+' unequipped.');
}
function closeShop(){ G.state='playing'; AU.drone(G.merchantNear); }
/* ---------------- shop ---------------- */
const SHOP=[
{id:'stem', name:'Vital Stem', cost:50, desc:'Restores 4 HP (two heart containers).'},
{id:'seal', name:'Binding Seal', cost:75, desc:'Freezes one enemy for 3 seconds.'},
{id:'heart',name:'Heart Container', cost:300, desc:'A permanent fifth heart. (+2 max HP)'},
{id:'ammo', name:'Aether Charge', cost:10, desc:'+1 Aether Shard charge.'},
];
function shopEntry(i){
const s=SHOP[i];
if(s.id==='stem') return {stock:G.shop.stem, locked:false};
if(s.id==='seal') return {stock:G.shop.seal, locked:false};
if(s.id==='heart') return {stock:G.shop.heart, locked:G.flags.heart5};
if(s.id==='ammo') return {stock:99, locked:!G.flags.shardFound, hidden:!G.flags.shardFound};
}
function buyShop(i){
const s=SHOP[i], e=shopEntry(i);
if(e.hidden||e.locked){ AU.deny(); toast(i===3?'The merchant has no word of crystals yet.':'Already claimed.'); return; }
if(e.stock<=0){ AU.deny(); toast('Sold out.'); return; }
if(G.gold<s.cost){ AU.deny(); toast('Not enough gold. ('+G.gold+' / '+s.cost+')'); return; }
G.gold-=s.cost;
AU.buy();
if(s.id==='stem'){ G.inv.stem++; G.shop.stem--; toast('Vital Stem purchased. (Q to drink)'); }
else if(s.id==='seal'){ G.inv.seal++; G.shop.seal--; toast('Binding Seal purchased. (F to cast)'); }
else if(s.id==='heart'){
G.shop.heart=0; G.maxHp=10; G.hp=Math.min(G.hp+4,G.maxHp);
G.flags.heart5=true; G.shimmer=2.4;
aura(playerCenter().x,playerCenter().y,'#ffd76a');
toast('The FIFTH heart container takes root. (10 HP)');
}
else if(s.id==='ammo'){ G.shards=Math.min(30,G.shards+1); toast('Aether Shard recharged. ('+G.shards+' charges)'); }
goldSwirl(mouse.x+G.camx,mouse.y+G.camy,6);
}
/* ---------------- master update ---------------- */
function update(dt){
G.t+=dt;
G.shake=Math.max(0,G.shake-dt*14);
G.flash=Math.max(0,G.flash-dt*2);
G.shimmer=Math.max(0,G.shimmer-dt);
G.zoneToastT=Math.max(0,G.zoneToastT-dt);
updateParts(dt); updateDmgNums(dt); updateToasts(dt);
/* fade */
if(G.fadeDir===1){
G.fade+=dt*2.2;
if(G.fade>=1){ G.fade=1; const cb=G.fadeCb; G.fadeCb=null; if(cb) cb(); G.fadeDir=-1; }
} else {
G.fade-=dt*2.2;
if(G.fade<=0){ G.fade=0; G.fadeDir=0; }
}
if(G.state==='playing'){
if(!player || !W){ /* fade-in still loading */ }
else {
playerUpdate(dt);
enemyUpdate(dt);
updateBolts(dt);
interactUpdate(dt);
tryTransition();
/* camera */
const c=playerCenter();
const tx=clamp(c.x-VW/2,0,W.W*TILE-VW), ty=clamp(c.y-VH/2,0,W.H*TILE-VH);
G.camx=lerp(G.camx,tx,Math.min(1,dt*7));
G.camy=lerp(G.camy,ty,Math.min(1,dt*7));
/* ambient fx */
for(const f of worldFx){
f.life-=dt; f.x+=f.vx*dt; f.y+=f.vy*dt;
if(f.life<=0){
f.life=rand(2,7);
f.x=G.camx+rand(0,VW); f.y=G.camy+rand(0,VH);
}
}
/* death */
if(player.dead){
G.deathT-=dt;
if(G.deathT<=0){
G.state='death'; G.deathT=2.2;
AU.tone(60,1.2,'sine',0.3,30);
}
}
}
} else if(G.state==='death'){
G.deathT-=dt;
if(G.deathT<=0){
G.state='playing';
if(hasSave() && loadFromSave()){
startFade(()=>loadZone(G.savedZone));
} else {
G.hp=G.maxHp;
toast('The Corrosion releases you at the threshold.');
startFade(()=>loadZone(0));
}
}
}
}
/* ================= RENDER ================= */
function render(){
const c=ctx;
c.clearRect(0,0,VW,VH);
c.save();
const shx=G.shake>0?rand(-G.shake,G.shake):0, shy=G.shake>0?rand(-G.shake,G.shake):0;
c.translate(-Math.round(G.camx+shx),-Math.round(G.camy+shy));
if(W){
drawTiles(c);
drawFloorDecals(c);
drawPlates(c);
drawDoors(c);
drawCrystals(c);
drawMirrors(c);
drawStones(c);
drawAltars(c);
drawMerchant(c);
drawJars(c);
drawHeart5(c);
drawBeacon(c);
drawPickups(c);
drawChests(c);
drawBreakables(c);
drawEnemies(c);
if(player && !player.dead) drawPlayer(c);
drawBolts(c);
drawParts(c);
drawAmbient(c);
drawExitSign(c);
drawLighting(c,G.camx+shx,G.camy+shy);
}
c.restore();
drawVignette(c);
drawShimmer(c);
drawDmgNums(c);
if(G.flash>0){ c.fillStyle=G.flashColor; c.globalAlpha=G.flash*0.45; c.fillRect(0,0,VW,VH); c.globalAlpha=1; }
if(G.state==='title') drawTitle(c);
else if(G.state==='ending') drawEnding(c);
else if(W && player){
drawHUD(c);
drawToasts(c);
if(G.zoneToastT>0 && W) drawZoneBanner(c);
if(G.state==='shop') drawShop(c);
if(G.state==='inv') drawInv(c);
if(G.state==='pause') drawPause(c);
if(G.state==='death') drawDeath(c);
}
if(G.fade>0){ c.fillStyle='#000'; c.globalAlpha=Math.min(1,G.fade); c.fillRect(0,0,VW,VH); c.globalAlpha=1; }
}
function drawTiles(c){
const z=ZONES[W.zi];
const x0=Math.max(0,Math.floor(G.camx/TILE)-1), x1=Math.min(W.W-1,Math.ceil((G.camx+VW)/TILE)+1);
const y0=Math.max(0,Math.floor(G.camy/TILE)-1), y1=Math.min(W.H-1,Math.ceil((G.camy+VH)/TILE)+1);
for(let ty=y0;ty<=y1;ty++){
for(let tx=x0;tx<=x1;tx++){
const h=hash2(tx+W.seed,ty);
const px=tx*TILE, py=ty*TILE;
if(W.tiles[ty][tx]===1){
c.fillStyle=z.wall; c.fillRect(px,py,TILE,TILE);
/* top face if floor below */
if(ty+1<W.H && W.tiles[ty+1][tx]===0){
c.fillStyle=z.trim; c.globalAlpha=0.5; c.fillRect(px,py+TILE-3,TILE,3); c.globalAlpha=1;
c.fillStyle='rgba(0,0,0,0.35)'; c.fillRect(px,py,TILE,10);
}
c.fillStyle='rgba(0,0,0,0.18)';
if(h<0.3) c.fillRect(px+4+h*40,py+8+h*30,3,3);
if(h>0.8) c.fillRect(px+10,py+18,2,2);
} else {
c.fillStyle=z.floor[Math.floor(h*3)]; c.fillRect(px,py,TILE,TILE);
c.strokeStyle='rgba(0,0,0,0.12)'; c.lineWidth=1;
c.strokeRect(px+0.5,py+0.5,TILE,TILE);
if(h>0.93){ c.fillStyle='rgba(0,0,0,0.15)'; c.beginPath(); c.arc(px+16,py+16,5,0,TAU); c.fill(); }
if(h<0.05){ c.fillStyle='rgba(255,255,255,0.03)'; c.fillRect(px,py,TILE,2); }
}
}
}
/* zone 3 water sheen */
if(W.zi===3){
c.fillStyle='rgba(90,160,170,0.05)';
for(let i=0;i<5;i++){
const yy=G.camy+((G.t*12+i*110)%(VH+120))-60;
c.fillRect(G.camx,yy,VW,2);
}
}
}
function drawFloorDecals(c){
/* sanctuary glow patches, crack decals */
for(const cr of W.cracks){
const px=cr.x*TILE, py=cr.y*TILE;
c.strokeStyle='rgba(10,8,6,0.7)'; c.lineWidth=1.5;
c.beginPath();
c.moveTo(px+8,py+TILE-4); c.lineTo(px+13,py+TILE-12); c.lineTo(px+9,py+TILE-18); c.lineTo(px+15,py+TILE-26);
c.moveTo(px+13,py+TILE-12); c.lineTo(px+19,py+TILE-16);
c.stroke();
c.fillStyle='rgba(0,0,0,0.25)'; c.fillRect(px+6,py+TILE-8,14,8);
}
}
function drawPlates(c){
for(const pl of W.plates){
const px=pl.x*TILE, py=pl.y*TILE;
const off=pl.down?4:0;
c.fillStyle='#241f18'; c.fillRect(px+2,py+2,TILE-4,TILE-4);
c.fillStyle=pl.down?'#8a6a3a':'#6a5232';
c.fillRect(px+5,py+5+off,TILE-10,TILE-10);
c.strokeStyle='rgba(255,210,120,0.35)'; c.lineWidth=1;
c.strokeRect(px+8+0.5,py+8+off+0.5,TILE-17,TILE-17);
if(pl.down && G.t-pl.pressT<0.4){ c.fillStyle='rgba(255,210,120,0.25)'; c.fillRect(px+5,py+5+off,TILE-10,TILE-10); }
}
}
function drawDoors(c){
for(const dr of W.doors){
const px=dr.x*TILE, py=dr.y*TILE;
if(dr.open<1){
c.save();
c.translate(px+TILE/2,py+TILE/2);
c.scale(1,1-dr.open);
c.fillStyle='#3a2f22'; c.fillRect(-TILE/2,-TILE/2,TILE,TILE);
c.fillStyle='#5a4630';
for(let i=0;i<3;i++) c.fillRect(-TILE/2+4+i*10,-TILE/2+4,6,TILE-8);
c.fillStyle='rgba(201,142,46,0.6)'; c.beginPath(); c.arc(0,0,4,0,TAU); c.fill();
c.restore();
}
}
}
function drawCrystals(c){
for(const cr of W.crystals){
const px=cr.x*TILE+16, py=cr.y*TILE+16;
const pulse=0.7+Math.sin(G.t*3+cr.x)*0.3;
c.save();
c.translate(px,py);
if(cr.active){
c.globalAlpha=0.9;
c.fillStyle='#bff0ff';
c.shadowColor='#9fd8ff'; c.shadowBlur=18;
} else {
c.globalAlpha=pulse;
c.fillStyle='#3a6a8a';
c.shadowColor='#4a8aba'; c.shadowBlur=8;
}
c.beginPath();
c.moveTo(0,-14); c.lineTo(8,0); c.lineTo(0,14); c.lineTo(-8,0); c.closePath(); c.fill();
c.restore();
if(!cr.active){
c.fillStyle='rgba(159,216,255,0.6)'; c.font='10px Georgia,serif'; c.textAlign='center';
c.fillText('◇',px,py-20); c.textAlign='left';
}
}
}
function drawMirrors(c){
for(const m of W.mirrors){
const px=m.x*TILE+16, py=m.y*TILE+16;
c.save(); c.translate(px,py);
c.fillStyle='#2a3a44'; c.beginPath(); c.arc(0,0,10,0,TAU); c.fill();
c.rotate(m.ang);
c.fillStyle=m.active?'#cfeeff':'#7a95a8';
c.fillRect(-2,-16,4,32);
c.restore();
if(m.flash>0){ m.flash-=0.016; c.fillStyle='rgba(207,238,255,'+m.flash+')'; c.beginPath(); c.arc(px,py,16,0,TAU); c.fill(); }
}
/* beam */
if(W.zi===3 && W.mirrors.length>=2){
const m1=W.mirrors[0], m2=W.mirrors[1];
const e={x:W.emitters[0].x*TILE+16,y:W.emitters[0].y*TILE+16};
const p1={x:m1.x*TILE+16,y:m1.y*TILE+16};
const p2={x:m2.x*TILE+16,y:m2.y*TILE+16};
const cr=W.crystals[0]; const cp={x:cr.x*TILE+16,y:cr.y*TILE+16};
const segs=[[e,p1],[p1,p2],[p2,cp]];
const on=[true, dist(e.x,e.y,p1.x,p1.y)<900, m1.active, cr.active];
c.save(); c.globalCompositeOperation='lighter';
for(let i=0;i<segs.length;i++){
const a=segs[i][0], b=segs[i][1];
const lit = i===0?true : i===1?m1.active : cr.active;
if(!lit) continue;
const al=i===2?0.5:0.25;
c.strokeStyle='rgba(159,216,255,'+al+')'; c.lineWidth=i===2?3:2;
c.beginPath(); c.moveTo(a.x,a.y); c.lineTo(b.x,b.y); c.stroke();
c.strokeStyle='rgba(232,246,255,0.5)'; c.lineWidth=1;
c.beginPath(); c.moveTo(a.x,a.y); c.lineTo(b.x,b.y); c.stroke();
}
c.restore();
/* emitter lens */
c.fillStyle='#3a5a6a'; c.fillRect(e.x-8,e.y-10,16,20);
c.fillStyle='#9fd8ff'; c.beginPath(); c.arc(e.x,e.y,5,0,TAU); c.fill();
}
}
function drawStones(c){
for(const s of W.stones){
const px=s.x*TILE+16, py=s.y*TILE+16;
c.fillStyle='#2e2a24'; c.fillRect(px-14,py+2,28,14);
c.fillStyle='#4a4238'; c.fillRect(px-11,py-14,22,18);
const pulse=0.5+Math.sin(G.t*2.4)*0.5;
c.fillStyle='rgba(255,215,106,'+(0.5+pulse*0.5)+')';
c.beginPath(); c.moveTo(px,py-12); c.lineTo(px+5,py-4); c.lineTo(px,py+2); c.lineTo(px-5,py-4); c.closePath(); c.fill();
if(Math.random()<0.12) spawnP({x:px+rand(-10,10),y:py-10,vx:rand(-4,4),vy:rand(-16,-6),life:0.8,max:0.8,color:'#ffd76a',size:1.4,grav:-6});
}
}
function drawAltars(c){
for(const a of W.altars){
const px=a.x*TILE+16, py=a.y*TILE+16;
c.fillStyle='#332b20'; c.fillRect(px-16,py+4,32,12);
c.fillStyle='#4a3c28'; c.fillRect(px-12,py-10,24,16);
const lit=G.weapon>=(W.zi===2?1:2);
const col=W.zi===2?'#ff9a3c':'#ffd76a';
if(lit){
c.save(); c.globalCompositeOperation='lighter';
const gr=c.createRadialGradient(px,py-18,2,px,py-18,26);
gr.addColorStop(0,col); gr.addColorStop(1,'rgba(0,0,0,0)');
c.fillStyle=gr; c.beginPath(); c.arc(px,py-18,26,0,TAU); c.fill();
c.restore();
c.fillStyle=col;
c.beginPath(); c.moveTo(px,py-30); c.lineTo(px+4,py-16); c.lineTo(px,py-10); c.lineTo(px-4,py-16); c.closePath(); c.fill();
} else {
c.fillStyle='#5a4a34';
c.beginPath(); c.moveTo(px,py-26); c.lineTo(px+4,py-16); c.lineTo(px,py-10); c.lineTo(px-4,py-16); c.closePath(); c.fill();
if(Math.random()<0.05) spawnP({x:px+rand(-8,8),y:py-14,vx:rand(-3,3),vy:rand(-12,-4),life:0.6,max:0.6,color:'#8a7a5a',size:1.2,grav:-8});
}
}
}
function drawMerchant(c){
if(!W.merchant) return;
const m=W.merchant, px=m.x*TILE+16, py=m.y*TILE+16;
c.fillStyle='#2a2118'; c.fillRect(px-18,py+6,36,10);
const robe='#5a4a6a';
c.fillStyle=robe;
c.beginPath(); c.moveTo(px,py-20); c.lineTo(px+10,py+6); c.lineTo(px-10,py+6); c.closePath(); c.fill();
c.fillStyle='#c9a44a'; c.beginPath(); c.arc(px,py-16,6,0,TAU); c.fill();
c.fillStyle='#3a2f1a'; c.fillRect(px-4,py-20,8,3);
const pulse=0.4+Math.sin(G.t*2)*0.3;
c.fillStyle='rgba(201,164,74,'+pulse+')'; c.font='11px Georgia,serif'; c.textAlign='center';
c.fillText('⚖',px,py-30); c.textAlign='left';
}
function drawJars(c){
for(const j of W.jars){
if(j.taken) continue;
const px=j.x*TILE+16, py=j.y*TILE+16;
c.fillStyle='#4a5a52';
c.beginPath(); c.ellipse(px,py+4,8,7,0,0,TAU); c.fill();
c.fillStyle='#6a7a70'; c.fillRect(px-5,py-8,10,10);
c.strokeStyle='rgba(122,208,255,0.5)'; c.lineWidth=1;
c.beginPath(); c.moveTo(px-3,py-4); c.lineTo(px+2,py+2); c.moveTo(px+3,py-4); c.lineTo(px-2,py+2); c.stroke();
}
}
function drawHeart5(c){
if(!W.heart5||W.heart5.taken) return;
const px=W.heart5.x*TILE+16, py=W.heart5.y*TILE+16;
const pulse=1+Math.sin(G.t*3)*0.12;
c.save(); c.translate(px,py); c.scale(pulse,pulse);
c.fillStyle='rgba(255,120,90,0.9)';
heart(c,0,0,10); c.fill();
c.restore();
if(Math.random()<0.1) spawnP({x:px+rand(-8,8),y:py-8,vx:0,vy:-14,life:0.8,max:0.8,color:'#ff9a8a',size:1.6,grav:-8});
}
function drawBeacon(c){
if(!W.beacon) return;
const b=W.beacon, px=b.x*TILE+16, py=b.y*TILE+16;
c.fillStyle='#2a2438'; c.fillRect(px-26,py+8,52,16);
c.fillStyle='#3a3248'; c.fillRect(px-20,py-14,40,24);
c.fillStyle='#4a4058'; c.fillRect(px-14,py-26,28,14);
const col=b.lit?'#ffd76a':(b.ready?'#9fd8ff':'#4a4a5a');
const pulse=b.lit?1:(0.5+Math.sin(G.t*2)*0.3);
c.save(); c.globalCompositeOperation='lighter';
const gr=c.createRadialGradient(px,py-34,2,px,py-34,b.lit?60:24);
gr.addColorStop(0,col); gr.addColorStop(1,'rgba(0,0,0,0)');
c.globalAlpha=pulse; c.fillStyle=gr; c.beginPath(); c.arc(px,py-34,b.lit?60:24,0,TAU); c.fill();
c.restore();
c.fillStyle=col; c.beginPath(); c.arc(px,py-34,7,0,TAU); c.fill();
if(b.lit){
c.save(); c.globalCompositeOperation='lighter';
c.fillStyle='rgba(255,215,106,0.14)';
c.fillRect(px-3,py-540,6,506);
c.restore();
}
}
function drawPickups(c){
for(const pk of W.pickups){
const bob=Math.sin(G.t*3+pk.x)*3;
const px=pk.x, py=pk.y+bob;
if(pk.item==='gold'){
c.fillStyle='#ffd76a'; c.beginPath(); c.arc(px,py,6,0,TAU); c.fill();
c.fillStyle='#b8862a'; c.beginPath(); c.arc(px,py,3,0,TAU); c.fill();
} else if(pk.item==='stem'){
c.fillStyle='#7ce88a';
c.beginPath(); c.ellipse(px,py,5,8,0.4,0,TAU); c.fill();
c.strokeStyle='#3a8a4a'; c.lineWidth=2;
c.beginPath(); c.moveTo(px,py+8); c.quadraticCurveTo(px+2,py,px-1,py-6); c.stroke();
} else if(pk.item==='seal'){
c.fillStyle='#7ad0ff';
c.beginPath(); c.arc(px,py,7,0,TAU); c.fill();
c.strokeStyle='#2a6a9a'; c.lineWidth=2;
c.beginPath(); c.arc(px,py,4,0,TAU); c.stroke();
} else if(pk.item==='band'){
c.fillStyle='#cfd8e8';
c.beginPath(); c.arc(px,py,7,0,TAU); c.fill();
c.fillStyle='#2a3040'; c.beginPath(); c.arc(px,py,4,0,TAU); c.fill();
}
}
}
function drawChests(c){
for(const ch of W.chests){
const px=ch.x*TILE, py=ch.y*TILE;
c.fillStyle=ch.opened?'#3a2f22':'#5a4630'; c.fillRect(px+4,py+10,24,16);
c.fillStyle=ch.opened?'#2a2118':'#6a5232'; c.fillRect(px+4,py+6,24,6);
c.fillStyle='#c9a44a'; c.fillRect(px+14,py+14,4,5);
if(!ch.opened && Math.random()<0.06) spawnP({x:px+16,y:py+8,vx:rand(-3,3),vy:-8,life:0.5,max:0.5,color:'#ffd76a',size:1.2,grav:-4});
}
}
function drawBreakables(c){
for(const b of W.breakables){
const px=b.x*TILE, py=b.y*TILE;
const z=ZONES[W.zi];
c.fillStyle=z.wall; c.fillRect(px,py,TILE,TILE);
c.fillStyle='rgba(0,0,0,0.25)'; c.fillRect(px,py,TILE,TILE);
c.strokeStyle='rgba(15,10,8,0.8)'; c.lineWidth=1.5;
c.beginPath();
c.moveTo(px+8,py+4); c.lineTo(px+14,py+12); c.lineTo(px+10,py+20); c.lineTo(px+16,py+28);
c.moveTo(px+14,py+12); c.lineTo(px+22,py+16);
c.stroke();
const dmg=1-b.hp/b.max;
if(dmg>0){
c.strokeStyle='rgba(5,4,3,0.9)'; c.lineWidth=2;
c.beginPath();
c.moveTo(px+22,py+6); c.lineTo(px+16,py+14); c.lineTo(px+22,py+22);
c.stroke();
}
if(b.hit>0){ b.hit-=0.016; c.fillStyle='rgba(255,255,255,0.2)'; c.fillRect(px,py,TILE,TILE); }
}
}
function heart(c,x,y,s){
c.beginPath();
c.moveTo(x,y+s*0.9);
c.bezierCurveTo(x-s*1.2,y+s*0.1,x-s*0.7,y-s*0.9,x,y-s*0.3);
c.bezierCurveTo(x+s*0.7,y-s*0.9,x+s*1.2,y+s*0.1,x,y+s*0.9);
c.closePath();
}
function drawEnemies(c){
for(const e of enemies){
if(e.dead) continue;
const cx=e.x+e.w/2, cy=e.y+e.h/2;
const bob=Math.sin(e.t*4+e.seed)*2;
c.save();
c.translate(cx,cy);
if(e.spawnT>0){ c.globalAlpha=1-e.spawnT/0.6; }
if(e.frozen>0){
c.fillStyle='rgba(122,208,255,0.35)';
c.beginPath(); c.arc(0,0,e.w*0.7,0,TAU); c.fill();
c.strokeStyle='rgba(208,240,255,0.8)'; c.lineWidth=2;
c.beginPath(); c.arc(0,0,e.w*0.7,0,TAU); c.stroke();
}
const col=e.hurtFlash>0?'#ff8a6a':(e.burn>0?'#ff6a3a':'#3a3a45');
if(e.type==='sentry'){
c.fillStyle=col;
c.beginPath(); c.moveTo(0,-14+bob); c.lineTo(11,bob); c.lineTo(7,13+bob); c.lineTo(-7,13+bob); c.lineTo(-11,bob); c.closePath(); c.fill();
c.fillStyle='#1a1a22'; c.beginPath(); c.arc(0,-4+bob,5,0,TAU); c.fill();
c.fillStyle=e.aggro?'#ff5a3a':'#7a4a3a'; c.fillRect(e.face>0?0:-5,-6+bob,5,3);
} else if(e.type==='brute'){
c.fillStyle=col;
c.fillRect(-15,-12+bob,30,26);
c.fillStyle='#26262e'; c.fillRect(-11,-8+bob,22,10);
c.fillStyle=e.aggro?'#ff5a3a':'#7a4a3a';
c.fillRect(-8,-6+bob,5,3); c.fillRect(3,-6+bob,5,3);
c.fillStyle='#4a4a55'; c.fillRect(-18,-8+bob,5,18); c.fillRect(13,-8+bob,5,18);
} else if(e.type==='guardian'){
c.fillStyle=col;
c.beginPath(); c.moveTo(0,-20+bob); c.lineTo(18,-4+bob); c.lineTo(14,18+bob); c.lineTo(-14,18+bob); c.lineTo(-18,-4+bob); c.closePath(); c.fill();
c.fillStyle='#15151c'; c.beginPath(); c.arc(0,-4+bob,8,0,TAU); c.fill();
c.fillStyle='#ff3a2a'; c.beginPath(); c.arc(0,-4+bob,3.5,0,TAU); c.fill();
c.fillStyle='#4a4a55'; c.fillRect(-22,-8+bob,6,16); c.fillRect(16,-8+bob,6,16);
} else if(e.type==='boss'){
const en=e.hp<e.maxHp*0.5;
c.fillStyle=col;
c.beginPath(); c.moveTo(0,-26+bob); c.lineTo(24,-8+bob); c.lineTo(20,22+bob); c.lineTo(-20,22+bob); c.lineTo(-24,-8+bob); c.closePath(); c.fill();
c.fillStyle='#101018'; c.beginPath(); c.arc(0,-6+bob,12,0,TAU); c.fill();
c.fillStyle=en?'#ff2a1a':'#ff5a3a';
c.beginPath(); c.arc(-5,-8+bob,3,0,TAU); c.arc(5,-8+bob,3,0,TAU); c.fill();
c.fillStyle='#4a4a55'; c.fillRect(-30,-10+bob,7,20); c.fillRect(23,-10+bob,7,20);
if(e.state==='charging'){
c.save(); c.globalCompositeOperation='lighter';
c.fillStyle='rgba(255,60,30,0.25)';
c.beginPath(); c.arc(0,0,34,0,TAU); c.fill(); c.restore();
}
if(en && Math.random()<0.3) emberTrail(cx,cy);
}
c.restore();
/* mini hp bar */
if(e.mini||e.isBoss){
const bw=e.w+8;
c.fillStyle='rgba(0,0,0,0.6)'; c.fillRect(cx-bw/2,cy-e.h/2-12,bw,5);
c.fillStyle=e.isBoss?'#ff5a3a':'#c96a4a';
c.fillRect(cx-bw/2+1,cy-e.h/2-11,(bw-2)*clamp(e.hp/e.maxHp,0,1),3);
}
}
}
function drawPlayer(c){
const p=player;
const cx=p.x+p.w/2, cy=p.y+p.h/2;
const bob=p.moving?Math.sin(p.walkT)*1.6:Math.sin(G.t*2)*0.8;
const w=weapon();
c.save();
c.translate(cx,cy+bob*0.4);
if(p.invuln>0 && Math.floor(G.t*16)%2===0) c.globalAlpha=0.45;
const stance=G.weapon===2?1.18:(G.flags.shardFound?1.08:1);
c.scale(stance,1);
/* cloak */
const cloakCol=G.equip.cloak?'#2a2438':'#3a3040';
c.fillStyle=cloakCol;
c.beginPath();
c.moveTo(0,-11+bob); c.lineTo(9,6+bob); c.lineTo(6,12+bob); c.lineTo(-6,12+bob); c.lineTo(-9,6+bob);
c.closePath(); c.fill();
/* cloak hem shimmer for falchion */
if(G.weapon===2){
c.strokeStyle='rgba(255,215,106,'+(0.4+Math.sin(G.t*6)*0.25)+')'; c.lineWidth=1.5;
c.beginPath(); c.moveTo(-6,12+bob); c.lineTo(6,12+bob); c.stroke();
}
if(G.equip.cloak){
c.strokeStyle='#1a1626'; c.lineWidth=2;
c.beginPath(); c.moveTo(-9,6+bob); c.lineTo(9,6+bob); c.stroke();
}
/* head */
c.fillStyle='#d8c8a8'; c.beginPath(); c.arc(0,-9+bob,4.5,0,TAU); c.fill();
c.fillStyle='#4a3c2c'; c.beginPath(); c.arc(0,-10+bob,4.5,Math.PI,0); c.fill();
/* band token */
if(G.equip.band){
c.strokeStyle='#cfd8e8'; c.lineWidth=2;
c.beginPath(); c.arc(-7*p.face,2+bob,3,0,TAU); c.stroke();
c.save(); c.globalCompositeOperation='lighter';
c.strokeStyle='rgba(207,216,232,0.5)'; c.beginPath(); c.arc(-7*p.face,2+bob,4.5,0,TAU); c.stroke(); c.restore();
}
/* lantern token */
if(G.equip.lantern){
const la=G.t*2;
c.save(); c.globalCompositeOperation='lighter';
c.fillStyle='rgba(122,184,255,'+(0.5+Math.sin(la)*0.3)+')';
c.beginPath(); c.arc(11,4+bob,3,0,TAU); c.fill(); c.restore();
c.fillStyle='#3a4a5a'; c.fillRect(9,1+bob,4,6);
}
/* weapon */
const a=aimAngle();
const swingT=p.atkT>0?1-p.atkT/(Math.max(0.12,w.cd*0.55)):0;
let wa;
if(p.atkT>0){ wa=p.atkAng - w.arc/2 + swingT*w.arc; }
else { wa=a+Math.sin(G.t*1.5)*0.15; }
c.save();
c.rotate(wa);
const reach=w.range*0.62;
if(G.weapon===0){
c.strokeStyle='#b8a888'; c.lineWidth=3;
c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.7,0); c.stroke();
c.strokeStyle='#8a7a5a'; c.lineWidth=1.5;
c.beginPath(); c.moveTo(6+reach*0.5,3); c.lineTo(6+reach*0.5,-3); c.stroke();
} else if(G.weapon===1){
c.strokeStyle='#c98a4a'; c.lineWidth=3;
c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.5,0); c.stroke();
c.save(); c.globalCompositeOperation='lighter';
c.strokeStyle='rgba(255,154,60,0.9)'; c.lineWidth=4;
c.beginPath(); c.moveTo(6+reach*0.5,0); c.lineTo(6+reach,0); c.stroke();
c.strokeStyle='rgba(255,207,106,0.5)'; c.lineWidth=7;
c.beginPath(); c.moveTo(6+reach*0.7,0); c.lineTo(6+reach,0); c.stroke();
c.restore();
} else {
c.strokeStyle='#b8862a'; c.lineWidth=3;
c.beginPath(); c.moveTo(6,0); c.lineTo(6+reach*0.45,0); c.stroke();
c.save(); c.globalCompositeOperation='lighter';
const gr=c.createLinearGradient(6+reach*0.45,0,6+reach,0);
gr.addColorStop(0,'rgba(255,215,106,0.4)'); gr.addColorStop(1,'#ffe9a0');
c.strokeStyle=gr; c.lineWidth=6;
c.beginPath(); c.moveTo(6+reach*0.45,0); c.lineTo(6+reach,0); c.stroke();
c.strokeStyle='rgba(255,242,192,0.6)'; c.lineWidth=10;
c.beginPath(); c.moveTo(6+reach*0.75,0); c.lineTo(6+reach,0); c.stroke();
c.restore();
}
c.restore();
/* swing arc */
if(p.atkT>0 && p.atkDur>0){
c.save(); c.globalCompositeOperation='lighter';
const prog=1-p.atkT/p.atkDur;
c.globalAlpha=(1-prog)*0.4;
c.strokeStyle=G.weapon===2?'#ffe9a0':(G.weapon===1?'#ff9a3c':'#d9c9a8');
c.lineWidth=G.weapon===2?10:6;
c.beginPath();
c.arc(0,0,w.range*0.9,p.atkAng-w.arc/2,p.atkAng-w.arc/2+prog*w.arc);
c.stroke();
c.restore(); c.globalAlpha=1;
}
/* off-hand shard */
if(G.flags.shardFound){
const oa=a+Math.PI*0.8;
const sx=Math.cos(oa)*10, sy=Math.sin(oa)*10;
c.save(); c.globalCompositeOperation='lighter';
const pulse=0.6+Math.sin(G.t*5)*0.4;
c.fillStyle='rgba(159,216,255,'+pulse+')';
c.beginPath(); c.moveTo(sx,sy-6); c.lineTo(sx+4,sy); c.lineTo(sx,sy+6); c.lineTo(sx-4,sy); c.closePath(); c.fill();
c.restore();
}
c.restore();
/* hurt flash ring */
if(p.hurtT>0){
c.strokeStyle='rgba(255,74,58,'+(p.hurtT*2)+')'; c.lineWidth=2;
c.beginPath(); c.arc(cx,cy,16,0,TAU); c.stroke();
}
}
function drawBolts(c){
c.save(); c.globalCompositeOperation='lighter';
for(const b of bolts){
const col=b.enemy?'#ff6a4a':'#9fd8ff';
c.fillStyle=col;
c.save(); c.translate(b.x,b.y); c.rotate(b.ang);
c.beginPath(); c.moveTo(8,0); c.lineTo(-6,4); c.lineTo(-6,-4); c.closePath(); c.fill();
c.restore();
c.fillStyle='rgba(255,255,255,0.7)';
c.beginPath(); c.arc(b.x,b.y,2.5,0,TAU); c.fill();
}
c.restore();
}
function drawAmbient(c){
for(const f of worldFx){
if(f.type==='dust'){
c.fillStyle='rgba(220,200,160,'+(0.12*Math.min(1,f.life))+')';
c.fillRect(f.x,f.y,2,2);
} else if(f.type==='ash'){
c.fillStyle='rgba(150,140,130,0.2)';
c.fillRect(f.x,f.y,2,1);
} else if(f.type==='mist'){
c.fillStyle='rgba(140,190,200,'+(0.05*Math.min(1,f.life*0.5))+')';
c.beginPath(); c.ellipse(f.x,f.y,f.s,f.s*0.35,0,0,TAU); c.fill();
}
}
}
function drawExitSign(c){
if(!W.exit) return;
const px=W.exit.x*TILE+16, py=W.exit.y*TILE+16;
const ok=G.zoneClear;
const pulse=0.5+Math.sin(G.t*3)*0.4;
c.save();
if(ok){
c.globalCompositeOperation='lighter';
const gr=c.createRadialGradient(px,py,4,px,py,40);
gr.addColorStop(0,'rgba(255,215,106,'+0.5*pulse+')'); gr.addColorStop(1,'rgba(0,0,0,0)');
c.fillStyle=gr; c.beginPath(); c.arc(px,py,40,0,TAU); c.fill();
}
c.restore();
c.fillStyle=ok?'#ffd76a':'#5a5a66';
c.font='16px Georgia,serif'; c.textAlign='center';
c.fillText(ok?'✦':'✧',px,py+5);
c.font='italic 10px Georgia,serif';
c.fillText(ok?'Ascend':'sealed',px,py+22);
c.textAlign='left';
}
function drawLighting(c,ox,oy){
lctx.clearRect(0,0,VW,VH);
lctx.fillStyle='rgba(4,6,12,'+(0.62/(ZONES[W.zi].light||1))+')';
lctx.fillRect(0,0,VW,VH);
lctx.globalCompositeOperation='destination-out';
const c1=playerCenter();
const pr=player&&player.dead?120:230;
let g=lctx.createRadialGradient(c1.x,c1.y,20,c1.x,c1.y,pr);
g.addColorStop(0,'rgba(0,0,0,0.95)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(c1.x,c1.y,pr,0,TAU); lctx.fill();
for(const s of W.stones){
const x=s.x*TILE+16,y=s.y*TILE+16;
g=lctx.createRadialGradient(x,y,4,x,y,90);
g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,90,0,TAU); lctx.fill();
}
for(const a of W.altars){
const x=a.x*TILE+16,y=a.y*TILE+16;
g=lctx.createRadialGradient(x,y,4,x,y,110);
g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,110,0,TAU); lctx.fill();
}
for(const cr of W.crystals){
const x=cr.x*TILE+16,y=cr.y*TILE+16;
g=lctx.createRadialGradient(x,y,4,x,y,cr.active?130:60);
g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,cr.active?130:60,0,TAU); lctx.fill();
}
if(W.beacon){
const x=W.beacon.x*TILE+16,y=W.beacon.y*TILE-20;
g=lctx.createRadialGradient(x,y,4,x,y,W.beacon.lit?220:100);
g.addColorStop(0,'rgba(0,0,0,0.9)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,W.beacon.lit?220:100,0,TAU); lctx.fill();
}
if(W.merchant){
const x=W.merchant.x*TILE+16,y=W.merchant.y*TILE+16;
g=lctx.createRadialGradient(x,y,4,x,y,120);
g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,120,0,TAU); lctx.fill();
}
if(W.exit && G.zoneClear){
const x=W.exit.x*TILE+16,y=W.exit.y*TILE+16;
g=lctx.createRadialGradient(x,y,4,x,y,100);
g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
lctx.fillStyle=g; lctx.beginPath(); lctx.arc(x,y,100,0,TAU); lctx.fill();
}
lctx.globalCompositeOperation='source-over';
c.drawImage(lcv,ox,oy);
}
function drawVignette(c){
const g=c.createRadialGradient(VW/2,VH/2,VH*0.45,VW/2,VH/2,VH*0.85);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.55)');
c.fillStyle=g; c.fillRect(0,0,VW,VH);
}
function drawShimmer(c){
if(G.shimmer<=0) return;
const a=Math.min(1,G.shimmer)*0.5;
c.save(); c.globalCompositeOperation='lighter';
const g=c.createRadialGradient(VW/2,VH/2,VH*0.35,VW/2,VH/2,VH*0.75);
g.addColorStop(0,'rgba(255,215,106,0)'); g.addColorStop(1,'rgba(255,215,106,'+a+')');
c.fillStyle=g; c.fillRect(0,0,VW,VH);
c.restore();
}
/* ---------------- HUD ---------------- */
function drawHUD(c){
/* hearts */
const maxC=Math.ceil(G.maxHp/2);
for(let i=0;i<maxC;i++){
const hx=26+i*30, hy=30;
const fill=Math.clamp2?0:0;
const hpIn=i*2;
const full=G.hp>=hpIn+2, half=G.hp===hpIn+1;
c.save();
c.translate(hx,hy);
c.fillStyle='rgba(0,0,0,0.55)'; heart(c,0,0,11); c.fill();
if(full){ c.fillStyle='#e84a3a'; heart(c,0,0,9); c.fill(); c.fillStyle='rgba(255,255,255,0.35)'; c.beginPath(); c.arc(-3,-3,2.5,0,TAU); c.fill(); }
else if(half){
c.save(); c.beginPath(); c.rect(-11,-11,11,22); c.clip();
c.fillStyle='#e84a3a'; heart(c,0,0,9); c.fill(); c.restore();
}
c.strokeStyle='rgba(201,142,46,0.7)'; c.lineWidth=1.5; heart(c,0,0,11); c.stroke();
c.restore();
}
/* gold */
c.fillStyle='#ffd76a'; c.beginPath(); c.arc(26,64,7,0,TAU); c.fill();
c.fillStyle='#b8862a'; c.beginPath(); c.arc(26,64,3.5,0,TAU); c.fill();
c.fillStyle='#f0e0b0'; c.font='16px Georgia,serif';
c.fillText(String(G.gold),40,69);
/* weapon */
const w=weapon();
c.fillStyle='#8a7a5a'; c.font='11px Georgia,serif';
c.fillText('WEAPON',20,90);
c.fillStyle=w.color; c.font='italic 14px Georgia,serif';
c.fillText(w.name,20,105);
/* shards */
if(G.flags.shardFound){
c.fillStyle='#7ab8ff'; c.font='11px Georgia,serif';
c.fillText('AETHER SHARD [K]',20,126);
c.fillStyle='#9fd8ff'; c.fillText(String(G.shards),20,141);
}
/* consumables */
c.font='11px Georgia,serif';
c.fillStyle='#7ce88a'; c.fillText('[Q] Vital Stem ×'+G.inv.stem,20,162);
c.fillStyle='#7ad0ff'; c.fillText('[F] Binding Seal ×'+G.inv.seal,20,178);
c.fillStyle='#8a7a5a'; c.fillText('[I] Inventory [M] Sound',20,194);
/* equipped accessories */
let ay=214;
for(const id of ['band','lantern','cloak']){
if(G.equip[id]){
c.fillStyle=ITEMS[id].color; c.font='11px Georgia,serif';
c.fillText('◈ '+ITEMS[id].name,20,ay); ay+=15;
}
}
/* minimap */
const ms=3.2, mx=VW-31*ms-14, my=14;
c.fillStyle='rgba(8,10,16,0.75)'; c.fillRect(mx-4,my-4,31*ms+8,31*ms+8);
c.strokeStyle='rgba(201,142,46,0.4)'; c.strokeRect(mx-4.5,my-4.5,31*ms+9,31*ms+9);
for(let ty=0;ty<W.H;ty++) for(let tx=0;tx<W.W;tx++){
if(W.tiles[ty][tx]) { c.fillStyle='rgba(90,80,60,0.5)'; c.fillRect(mx+tx*ms,my+ty*ms,ms,ms); }
}
for(const s of W.stones){ c.fillStyle='#ffd76a'; c.fillRect(mx+s.x*ms-1,my+s.y*ms-1,ms+2,ms+2); }
for(const a of W.altars){ c.fillStyle='#ff9a3c'; c.fillRect(mx+a.x*ms-1,my+a.y*ms-1,ms+2,ms+2); }
if(W.merchant){ c.fillStyle='#c9a44a'; c.fillRect(mx+W.merchant.x*ms-1,my+W.merchant.y*ms-1,ms+2,ms+2); }
if(W.exit){ c.fillStyle=G.zoneClear?'#ffe9a0':'#5a5a66'; c.fillRect(mx+W.exit.x*ms-1,my+W.exit.y*ms-1,ms+2,ms+2); }
if(W.beacon){ c.fillStyle=W.beacon.lit?'#ffd76a':'#9fd8ff'; c.fillRect(mx+W.beacon.x*ms-1,my+W.beacon.y*ms-1,ms+2,ms+2); }
const pc=playerCenter();
c.fillStyle='#fff'; c.beginPath(); c.arc(mx+pc.x/TILE*ms,my+pc.y/TILE*ms,2.5,0,TAU); c.fill();
/* objective */
const obj=objective();
c.textAlign='right';
c.fillStyle='rgba(232,213,163,0.85)'; c.font='italic 13px Georgia,serif';
c.fillText('◆ '+obj,VW-16,VH-18);
c.textAlign='left';
/* prompt */
if(G.near){
let label='';
if(G.near.t==='stone') label='[E] Rest at Sanctuary Stone';
else if(G.near.t==='altar') label='[E] Attune the altar';
else if(G.near.t==='merchant') label='[E] Trade with the Merchant';
else if(G.near.t==='chest') label='[E] Open the chest';
else if(G.near.t==='beacon') label='[E] Reignite the Core Beacon';
else if(G.near.t==='heart5') label='[E] Claim the fifth heart';
else if(G.near.t==='jar') label='[E] Search the jar';
if(label){
c.font='14px Georgia,serif';
const tw=c.measureText(label).width+26;
c.fillStyle='rgba(8,10,16,0.8)'; roundRect(c,VW/2-tw/2,VH-100,tw,24,12); c.fill();
c.strokeStyle='rgba(201,142,46,0.6)'; c.stroke();
c.fillStyle='#ffe9a0'; c.textAlign='center'; c.fillText(label,VW/2,VH-84); c.textAlign='left';
}
}
/* zone clear hint */
if(G.zoneClear && W.exit){
c.textAlign='center';
c.fillStyle='rgba(255,233,160,'+(0.6+Math.sin(G.t*4)*0.3)+')';
c.font='italic 15px Georgia,serif';
c.fillText('The way forward is open — ascend to the upper right.',VW/2,40);
c.textAlign='left';
}
}
function objective(){
const z=W.zi;
if(z===0) return G.zoneClear?'Ascend to the Archives (upper right)':'Clear the sentries guarding the threshold';
if(z===1) return G.flags.band||G.equip.band?'Ascend to the Gallery (upper right)':'Search the archives — something glints in the shelves';
if(z===2) return G.weapon>=1?'Press both plates, then ascend':'Attune the Ignited Edge at the altar';
if(z===3) return G.flags.shardFound?'Ascend to the Twin Spire (upper right)':'Fire the Aether Shard at the crystal — the mirror beam may help';
if(z===4) return G.flags.guardianDown?'Ascend to the Core Chamber':'Defeat the Spire Guardian';
return G.flags.beaconLit?'The Beacon burns':'Strike both crystals, then reignite the Core Beacon';
}
function drawZoneBanner(c){
const a=clamp(G.zoneToastT,0,1);
c.globalAlpha=a;
c.textAlign='center';
c.fillStyle='rgba(8,10,16,0.7)'; c.fillRect(0,VH/2-64,VW,110);
c.fillStyle='#c98a4a'; c.font='12px Georgia,serif';
c.fillText('— ZONE '+(W.zi+1)+' OF 6 —',VW/2,VH/2-38);
c.fillStyle='#f0e0b0'; c.font='28px Georgia,serif';
c.fillText(ZONES[W.zi].name,VW/2,VH/2-8);
c.fillStyle='#8a7a5a'; c.font='italic 13px Georgia,serif';
c.fillText(ZONES[W.zi].desc,VW/2,VH/2+20);
c.globalAlpha=1; c.textAlign='left';
}
/* ---------------- overlays ---------------- */
function panel(c,x,y,w,h){
c.fillStyle='rgba(10,12,20,0.94)'; roundRect(c,x,y,w,h,10); c.fill();
c.strokeStyle='rgba(201,142,46,0.6)'; c.lineWidth=1.5; roundRect(c,x,y,w,h,10); c.stroke();
c.strokeStyle='rgba(201,142,46,0.25)'; c.lineWidth=1;
roundRect(c,x+5,y+5,w-10,h-10,7); c.stroke();
}
function drawShop(c){
panel(c,VW/2-280,60,560,420);
c.textAlign='center';
c.fillStyle='#c9a44a'; c.font='13px Georgia,serif';
c.fillText('THE MERCHANT OF AURELION',VW/2,92);
c.fillStyle='#f0e0b0'; c.font='22px Georgia,serif';
c.fillText('“Sigils for service, Warden.”',VW/2,120);
c.fillStyle='#ffd76a'; c.font='16px Georgia,serif';
c.fillText('Your gold: '+G.gold,VW/2,146);
SHOP.forEach((s,i)=>{
const e=shopEntry(i);
const y=170+i*62;
c.textAlign='left';
if(e.hidden){
c.fillStyle='#4a4a55'; c.font='14px Georgia,serif';
c.fillText('[ '+(i+1)+' ] · · · (the merchant has no word of this yet)',VW/2-240,y+10);
return;
}
c.fillStyle=e.locked?'#6a5a4a':'#e8d5a3'; c.font='16px Georgia,serif';
c.fillText('[ '+(i+1)+' ] '+s.name,VW/2-240,y+8);
c.fillStyle='#8a7a5a'; c.font='12px Georgia,serif';
let stockTxt='';
if(s.id==='stem') stockTxt=' (stock '+e.stock+')';
if(s.id==='seal') stockTxt=' (stock '+e.stock+')';
if(s.id==='heart') stockTxt=e.locked?' (claimed)':' (one only)';
c.fillText(s.desc+stockTxt,VW/2-220,y+26);
c.fillStyle=e.locked?'#5a4a3a':'#ffd76a'; c.font='15px Georgia,serif';
c.fillText(e.locked?'—':s.cost+' gold',VW/2+150,y+14);
});
c.textAlign='center';
c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
c.fillText('[1-4] buy [E / Esc] leave',VW/2,462);
c.textAlign='left';
}
function drawInv(c){
panel(c,VW/2-260,80,520,380);
c.textAlign='center';
c.fillStyle='#c9a44a'; c.font='13px Georgia,serif';
c.fillText('THE WARDEN\'S PACK',VW/2,112);
c.textAlign='left';
const rows=[
['1','Warden\'s Band',G.equip.band,'Accessory — +10% attack speed. Silver ring at your wrist.'],
['2','Phantom Lantern',G.equip.lantern,'Accessory — reveals hidden cracks; soft blue light.'],
['3','Shadow Cloak',G.equip.cloak,'Accessory — halves trap & Guardian damage. Dark hem.'],
];
rows.forEach((r,i)=>{
const y=150+i*54;
c.fillStyle=r[2]?'#9fd8a0':'#6a6a7a'; c.font='14px Georgia,serif';
c.fillText('['+r[0]+'] '+r[1]+' — '+(r[2]?'EQUIPPED':'unequipped'),VW/2-220,y);
c.fillStyle='#8a7a5a'; c.font='12px Georgia,serif';
c.fillText(r[3],VW/2-200,y+18);
});
c.fillStyle='#7ce88a'; c.font='14px Georgia,serif';
c.fillText('[Q] Vital Stem ×'+G.inv.stem+' — restores 4 HP',VW/2-220,330);
c.fillStyle='#7ad0ff';
c.fillText('[F] Binding Seal ×'+G.inv.seal+' — freezes an enemy 3s',VW/2-220,354);
c.fillStyle='#9fd8ff';
c.fillText('◇ Aether Shard — '+G.shards+' charges (fire with K / right-click)',VW/2-220,378);
c.textAlign='center';
c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
c.fillText('[1-3] toggle accessories [Esc / I] close',VW/2,442);
c.textAlign='left';
}
function drawPause(c){
c.fillStyle='rgba(4,6,12,0.7)'; c.fillRect(0,0,VW,VH);
panel(c,VW/2-200,VH/2-120,400,240);
c.textAlign='center';
c.fillStyle='#f0e0b0'; c.font='24px Georgia,serif';
c.fillText('The Corrosion Waits',VW/2,VH/2-76);
c.fillStyle='#8a7a5a'; c.font='14px Georgia,serif';
c.fillText('WASD move · Mouse aim · J/Z/left-click strike',VW/2,VH/2-38);
c.fillText('K/X fire Aether Shard · Q Vital Stem · F Binding Seal',VW/2,VH/2-16);
c.fillText('E interact · I inventory · M sound',VW/2,VH/2+6);
c.fillText('[Esc] resume [R] return to last Sanctuary Stone',VW/2,VH/2+34);
c.fillStyle='#6a6a7a'; c.font='12px Georgia,serif';
c.fillText('Zone '+(W?W.zi+1:1)+': '+(W?ZONES[W.zi].name:''),VW/2,VH/2+70);
c.textAlign='left';
}
function drawDeath(c){
c.fillStyle='rgba(20,4,4,'+clamp(1-G.deathT/2.2,0,0.8)+')';
c.fillRect(0,0,VW,VH);
c.textAlign='center';
c.fillStyle='#c02a2a'; c.font='26px Georgia,serif';
c.fillText('The Corrosion claims another Warden…',VW/2,VH/2-10);
c.fillStyle='#8a7a5a'; c.font='italic 14px Georgia,serif';
c.fillText('returning to the last Sanctuary Stone',VW/2,VH/2+20);
c.textAlign='left';
}
function drawTitle(c){
c.fillStyle='rgba(4,6,12,0.82)'; c.fillRect(0,0,VW,VH);
/* decorative gears */
c.save(); c.translate(140,420); c.rotate(G.t*0.2);
c.strokeStyle='rgba(201,142,46,0.2)'; c.lineWidth=6;
for(let i=0;i<8;i++){ c.rotate(TAU/8); c.beginPath(); c.moveTo(0,40); c.lineTo(0,70); c.stroke(); }
c.beginPath(); c.arc(0,0,44,0,TAU); c.stroke(); c.restore();
c.save(); c.translate(830,140); c.rotate(-G.t*0.15);
c.strokeStyle='rgba(201,142,46,0.15)'; c.lineWidth=5;
for(let i=0;i<8;i++){ c.rotate(TAU/8); c.beginPath(); c.moveTo(0,30); c.lineTo(0,54); c.stroke(); }
c.beginPath(); c.arc(0,0,34,0,TAU); c.stroke(); c.restore();
c.textAlign='center';
c.fillStyle='#c98a4a'; c.font='13px Georgia,serif';
c.fillText('AURELION\'S SUNKEN OBSERVATORY',VW/2,150);
c.fillStyle='#f0e0b0'; c.font='44px Georgia,serif';
c.fillText('The Warden\'s Corrosion',VW/2,200);
c.fillStyle='#8a7a5a'; c.font='italic 15px Georgia,serif';
c.fillText('Six zones. Two altars. One Beacon. The decay is patient — you must not be.',VW/2,236);
c.fillStyle='#e8d5a3'; c.font='17px Georgia,serif';
c.fillText('[ Enter ] Begin as the Last Warden',VW/2,300);
if(hasSave()){
c.fillStyle='#9fd8a0'; c.font='15px Georgia,serif';
c.fillText('[ R ] Resume from the last Sanctuary Stone',VW/2,330);
}
c.fillStyle='#6a6a7a'; c.font='13px Georgia,serif';
c.fillText('WASD move · mouse aim · J / left-click strike · K fire shard · E interact',VW/2,400);
c.fillText('Q Vital Stem · F Binding Seal · I inventory · M sound',VW/2,422);
c.fillStyle='#5a5a66'; c.font='11px Georgia,serif';
c.fillText('headphones recommended — every swing, coin and crystal has its voice',VW/2,470);
c.textAlign='left';
}
function drawEnding(c){
c.fillStyle='rgba(10,8,4,0.88)'; c.fillRect(0,0,VW,VH);
const g=c.createRadialGradient(VW/2,200,20,VW/2,200,300);
g.addColorStop(0,'rgba(255,215,106,'+(0.25+Math.sin(G.t*2)*0.08)+')');
g.addColorStop(1,'rgba(0,0,0,0)');
c.fillStyle=g; c.fillRect(0,0,VW,VH);
c.textAlign='center';
c.fillStyle='#ffd76a'; c.font='36px Georgia,serif';
c.fillText('The Beacon Burns Again',VW/2,220);
c.fillStyle='#e8d5a3'; c.font='italic 16px Georgia,serif';
c.fillText('Light climbs the bronze spires; the Corrosion recedes like a tide.',VW/2,262);
c.fillText('Aurelion remembers its Warden — and so will you.',VW/2,286);
c.fillStyle='#8a7a5a'; c.font='14px Georgia,serif';
c.fillText('Gold gathered: '+G.gold+' · Weapon: '+weapon().name+' · Hearts: '+(G.maxHp/2),VW/2,330);
c.fillStyle='#e8d5a3'; c.font='16px Georgia,serif';
c.fillText('[ Enter ] Return to the threshold',VW/2,400);
c.textAlign='left';
}
/* ---------------- main loop ---------------- */
let last=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-last)/1000);
last=now;
if(G.state!=='shop'&&G.state!=='inv'&&G.state!=='pause'&&G.state!=='title'&&G.state!=='ending') update(dt);
else { G.t+=dt; updateToasts(dt); updateParts(dt); G.fade=Math.max(0,G.fade-dt*2); }
render();
requestAnimationFrame(frame);
}
window.addEventListener('pointerdown',()=>{ AU.init(); AU.resume(); },{once:false});
requestAnimationFrame(frame);
/* right-click also fires the shard */
cv.addEventListener('mousedown',e=>{ if(e.button===2){ e.preventDefault(); onActionRC(); } });
function onActionRC(){
if(G.state==='playing') fireShard();
}
</script>
</body>
</html>