Warden
Deepseek v4.1 Flash · 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 117,944 bytes · SHA-256 45f9835a9325
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>The Warden's Corrosion</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;background:#05060a;overflow:hidden;color:#e9dcc0;
font-family:Georgia,'Times New Roman',serif;-webkit-font-smoothing:antialiased}
#wrap{position:fixed;inset:0}
canvas{display:block;width:100%;height:100%;image-rendering:auto}
.screen{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:30;
background:radial-gradient(ellipse at 50% 38%,rgba(30,18,8,.55),rgba(3,4,8,.94));
animation:fadeIn .6s ease both}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.screen.hidden,.panel.hidden{display:none}
.frame{width:min(92vw,880px);max-height:92vh;overflow:auto;padding:26px 34px 30px;
border:1px solid rgba(214,164,74,.34);border-radius:3px;
background:linear-gradient(180deg,rgba(20,15,10,.95),rgba(9,9,13,.96));
box-shadow:0 0 80px rgba(0,0,0,.75),inset 0 0 60px rgba(214,164,74,.05)}
h1{font-size:clamp(23px,4.2vw,42px);letter-spacing:.09em;color:#f2c66c;text-align:center;
text-shadow:0 0 22px rgba(240,166,60,.45),0 0 60px rgba(240,120,30,.18);font-weight:400}
h2{font-size:clamp(17px,2.4vw,25px);letter-spacing:.12em;color:#eabd63;text-align:center;font-weight:400;
margin-bottom:14px;text-shadow:0 0 16px rgba(230,160,50,.35)}
.sub{text-align:center;color:#9d9madd}
.sub{text-align:center;color:#a89b7d;font-style:italic;letter-spacing:.14em;margin-top:8px;font-size:clamp(11px,1.5vw,14px)}
.lore{margin:16px auto 6px;max-width:660px;color:#c9bfa6;line-height:1.62;font-size:clamp(12px,1.5vw,15px);text-align:center}
.lore em{color:#e9b45e;font-style:normal}
.grid{display:grid;grid-template-columns:auto 1fr;gap:5px 16px;margin:18px auto 4px;max-width:520px;
font-size:clamp(11px,1.35vw,13px);color:#b9b09a}
.grid b{color:#e7c478;font-weight:400;font-family:ui-monospace,Consolas,monospace;
border:1px solid rgba(214,164,74,.3);border-radius:3px;padding:1px 7px;justify-self:start;background:rgba(214,164,74,.07)}
.btns{display:flex;gap:14px;justify-content:center;margin-top:22px;flex-wrap:wrap}
.btn{background:linear-gradient(180deg,rgba(214,164,74,.16),rgba(214,164,74,.04));color:#f0cc83;
border:1px solid rgba(214,164,74,.45);padding:10px 24px;border-radius:2px;cursor:pointer;
font-family:Georgia,serif;font-size:14px;letter-spacing:.12em;transition:.18s}
.btn:hover{background:linear-gradient(180deg,rgba(240,180,80,.3),rgba(214,164,74,.1));
box-shadow:0 0 24px rgba(240,170,60,.35);color:#fff2d4}
.btn.buy{font-size:11px;padding:6px 14px;letter-spacing:.08em}
.btn[disabled]{opacity:.35;cursor:not-allowed;box-shadow:none}
.panel{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:28;
background:rgba(4,5,9,.72);backdrop-filter:blur(3px);animation:fadeIn .22s ease both}
.item{display:flex;align-items:center;gap:14px;padding:11px 14px;border:1px solid rgba(214,164,74,.18);
border-radius:2px;margin-bottom:9px;background:rgba(255,255,255,.022)}
.item.eq{border-color:rgba(120,220,255,.45);background:rgba(90,190,255,.07)}
.icon{width:34px;height:34px;flex:0 0 34px;border-radius:2px;display:flex;align-items:center;justify-content:center;
font-size:15px;border:1px solid rgba(0,0,0,.4);font-family:ui-monospace,monospace}
.itxt{flex:1;min-width:0}
.iname{color:#efd79f;font-size:14px;letter-spacing:.05em}
.idesc{color:#9e967f;font-size:11.5px;line-height:1.45;margin-top:2px}
.tag{font-size:10px;letter-spacing:.14em;color:#8fb8c8;text-transform:uppercase;font-family:ui-monospace,monospace}
.hint{text-align:center;color:#8b8471;font-size:11px;margin-top:14px;letter-spacing:.08em}
.goldline{text-align:center;color:#f0c469;font-size:14px;letter-spacing:.12em;margin-bottom:16px;
font-family:ui-monospace,monospace}
.empty{color:#7d7767;text-align:center;font-style:italic;padding:18px 0;font-size:13px}
.endstat{display:grid;grid-template-columns:auto 1fr;gap:7px 22px;max-width:560px;margin:22px auto 0;
font-size:13px;color:#bdb49c}
.endstat span{white-space:nowrap;color:#9e967f;letter-spacing:.03em}
.endstat b{color:#f2c66c;font-weight:400;font-family:ui-monospace,monospace}
.chip{display:inline-block;font-size:11px;letter-spacing:.1em;color:#c9b98d;border:1px solid rgba(214,164,74,.25);
padding:2px 9px;border-radius:2px;margin:3px 3px 0 0;background:rgba(214,164,74,.06)}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div id="title" class="screen">
<div class="frame">
<h1>THE WARDEN'S CORROSION</h1>
<div class="sub">Aurelion's Sunken Observatory</div>
<p class="lore">The archive drifts above a storm-locked valley, its great gears grinding to a halt.
<em>Black-iron decay</em> — the Corrosion — creeps through every hall, and the constructs that once
kept the peace now serve the rot. You are the Last Warden. Reach the Core Chamber and reignite the
<em>Beacon</em>, before the Observatory falls.</p>
<div class="grid">
<b>A / D</b><span>Walk the halls</span>
<b>W · Space</b><span>Leap (hold for height)</span>
<b>J · Click</b><span>Swing the Ember Blade</span>
<b>K</b><span>Fire the Aether Shard <i>(once found)</i></span>
<b>E</b><span>Interact — stones, altars, chests, doors</span>
<b>I</b><span>Satchel — equip and use items</span>
<b>1 / 2</b><span>Quaff Vital Stem · throw Binding Seal</span>
</div>
<div class="btns">
<button class="btn" id="btnNew">BEGIN THE WARD</button>
<button class="btn" id="btnCont">CONTINUE</button>
</div>
</div>
</div>
<div id="inv" class="panel hidden">
<div class="frame">
<h2>THE WARDEN'S SATCHEL</h2>
<div class="goldline" id="invGold"></div>
<div id="invBody"></div>
<div class="hint">[I] / [ESC] close · accessories may be toggled freely · consumables are spent on use</div>
</div>
</div>
<div id="shop" class="panel hidden">
<div class="frame">
<h2>ALTAR OF THE RUSTED MERCHANT</h2>
<div class="goldline" id="shopGold"></div>
<div id="shopBody"></div>
<div class="hint">"Coin for the living, Warden. The dead have no use for sigils."<br/>[E] / [ESC] to step away</div>
</div>
</div>
<div id="ending" class="screen hidden">
<div class="frame">
<h1 id="endTitle">THE BEACON BURNS</h1>
<p class="lore" id="endLore"></p>
<div class="endstat" id="endStat"></div>
<div class="btns"><button class="btn" id="btnAgain">WALK THE HALLS AGAIN</button></div>
</div>
</div>
</div>
<script>
'use strict';
/* =========================================================================
THE WARDEN'S CORROSION — single-file action adventure
========================================================================= */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a,b)=>a+Math.random()*(b-a);
const rndi=(a,b)=>Math.floor(a+Math.random()*(b-a+1));
const pick=a=>a[(Math.random()*a.length)|0];
const TAU=Math.PI*2;
const sign=v=>v<0?-1:1;
/* ---------------------------- AUDIO ENGINE ---------------------------- */
const SFX=(()=>{
let ac=null,master=null,drone=null,droneG=null,droneF=[349,261],muted=false;
function init(){
if(ac)return;
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC)return;
try{ac=new AC();}catch(e){return;}
master=ac.createGain();master.gain.value=.4;master.connect(ac.destination);
const g=ac.createGain();g.gain.value=0;g.connect(master);
const lp=ac.createBiquadFilter();lp.type='lowpass';lp.frequency.value=380;lp.Q.value=2;
lp.connect(g);
const o1=ac.createOscillator(),o2=ac.createOscillator();
o1.type='sawtooth';o2.type='triangle';
o1.frequency.value=droneF[0];o2.frequency.value=droneF[1];
o1.connect(lp);o2.connect(lp);o1.start();o2.start();
drone=g;droneG=g;
}
const t0=()=>ac?ac.currentTime:0;
function tone(f,o){
if(!ac||muted)return;o=o||{};
const t=t0()+(o.delay||0),a=o.a!==undefined?o.a:.008,d=o.d||.25,peak=o.peak!==undefined?o.peak:.25;
const osc=ac.createOscillator();osc.type=o.type||'sine';
osc.frequency.setValueAtTime(f,t);
if(o.f2)osc.frequency.exponentialRampToValueAtTime(Math.max(20,o.f2),t+a+d*.92);
const g=ac.createGain();
g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(peak,t+a);
g.gain.exponentialRampToValueAtTime(.0001,t+a+d);
let out=g;
if(ac.createStereoPanner){const p=ac.createStereoPanner();p.pan.value=o.pan||0;g.connect(p);out=p;}
osc.connect(g);out.connect(master);
osc.start(t);osc.stop(t+a+d+.05);
}
function noise(o){
if(!ac||muted)return;o=o||{};
const t=t0()+(o.delay||0),a=o.a!==undefined?o.a:.004,d=o.d||.2,peak=o.peak!==undefined?o.peak:.25;
const len=Math.max(1,Math.ceil(ac.sampleRate*(a+d+.06)));
const buf=ac.createBuffer(1,len,ac.sampleRate),dd=buf.getChannelData(0);
for(let i=0;i<len;i++)dd[i]=Math.random()*2-1;
const s=ac.createBufferSource();s.buffer=buf;
const f=ac.createBiquadFilter();f.type=o.type||'bandpass';f.frequency.setValueAtTime(o.f||900,t);
f.Q.value=o.q||1;
if(o.f2)f.frequency.exponentialRampToValueAtTime(Math.max(60,o.f2),t+a+d);
const g=ac.createGain();
g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(peak,t+a);
g.gain.exponentialRampToValueAtTime(.0001,t+a+d);
const pan=ac.createStereoPanner?ac.createStereoPanner():null;
s.connect(f);f.connect(g);
if(pan){g.connect(pan);pan.pan.value=o.pan||0;pan.connect(master);}else g.connect(master);
s.start(t);s.stop(t+a+d+.06);
}
return {
init, ready:()=>!!ac,
setMuted:m=>{muted=m;if(master)master.gain.value=m?0:.4;},
drone(f1,f2,vol){
if(!ac||!droneG)return;
droneF=[f1,f2];
droneG.gain.cancelScheduledValues(t0());
droneG.gain.linearRampToValueAtTime(vol,t0()+1.6);
},
whoosh(){noise({f:1500,f2:400,q:.7,d:.16,peak:.16,a:.006});},
impact(fire){noise({f:fire?260:420,f2:80,d:.22,peak:.3,q:.9});tone(fire?110:150,{type:'triangle',f2:60,d:.22,peak:.22});},
solarSwing(){noise({f:900,f2:180,q:.6,d:.34,peak:.22,a:.02});
tone(140,{type:'triangle',f2:70,d:.5,peak:.3,delay:.05});tone(880,{type:'sine',f2:520,d:.3,peak:.1,delay:.06});},
solarHit(){noise({f:2000,f2:300,q:.5,d:.3,peak:.25});tone(80,{type:'sine',f2:45,d:.55,peak:.34});},
fireCrackle(){for(let i=0;i<3;i++)noise({f:1600+i*700,f2:600,q:1.4,d:.09,peak:.09,delay:i*.045});},
twang(){tone(1450,{type:'triangle',f2:2400,d:.13,peak:.2});noise({f:3000,f2:900,q:2,d:.1,peak:.1});},
boltImpact(){tone(620,{type:'sine',f2:1180,d:.3,peak:.14});noise({f:2400,f2:500,q:1.2,d:.16,peak:.14});},
hurt(){tone(210,{type:'square',f2:120,d:.18,peak:.16});noise({f:500,f2:120,d:.28,peak:.24});tone(1180,{type:'sine',f2:900,d:.14,peak:.07,delay:.02});},
coin(){tone(1400,{type:'triangle',d:.1,peak:.14});tone(2100,{type:'triangle',d:.14,peak:.11,delay:.05});},
chime(asc){const base=asc?660:520;tone(base,{type:'sine',d:.3,peak:.14});
tone(base*1.5,{type:'sine',d:.4,peak:.12,delay:.07});tone(base*2,{type:'sine',d:.5,peak:.09,delay:.14});},
chord(){[523,659,784,1046].forEach((f,i)=>tone(f,{type:'triangle',d:1.1,peak:.11,delay:i*.07,a:.02}));},
heart(){[392,523,659,784].forEach((f,i)=>tone(f,{type:'sine',d:.9,peak:.14,delay:i*.11}));},
purchase(){noise({f:2600,f2:900,q:1.5,d:.1,peak:.12});tone(880,{type:'triangle',d:.2,peak:.13,delay:.03});
tone(1320,{type:'sine',d:.45,peak:.12,delay:.12});},
deny(){tone(160,{type:'square',f2:110,d:.22,peak:.14});},
door(){noise({f:220,f2:90,q:.6,d:1.1,peak:.2,a:.05});tone(70,{type:'sine',d:1.2,peak:.2,a:.1});},
plate(){tone(190,{type:'square',f2:330,d:.16,peak:.16});noise({f:900,f2:200,q:1,d:.2,peak:.14});},
switchHit(){tone(1760,{type:'triangle',f2:2640,d:.22,peak:.16});tone(2640,{type:'sine',d:.4,peak:.1,delay:.08});},
crackHit(type){noise({f:type===2?900:600,f2:180,q:.8,d:.14,peak:.18});},
breakWall(){noise({f:700,f2:120,q:.6,d:.7,peak:.3,a:.01});tone(90,{type:'triangle',f2:50,d:.6,peak:.22});},
jump(){tone(330,{type:'triangle',f2:520,d:.13,peak:.1});},
land(hard){noise({f:hard?420:700,f2:140,q:.8,d:hard?.2:.11,peak:hard?.2:.1});},
step(zone){
const p=[[300,120],[420,160],[240,300],[520,900],[160,700],[110,500]][zone]||[320,140];
noise({f:p[0],f2:p[1],q:.9,d:zone===2?.2:.09,peak:zone===2?.1:.07,a:.003});
},
hazard(){noise({f:1200,f2:300,q:.8,d:.4,peak:.24});tone(300,{type:'sawtooth',f2:90,d:.4,peak:.14});},
freeze(){tone(1500,{type:'sine',f2:2200,d:.5,peak:.12});noise({f:3400,f2:600,q:2,d:.5,peak:.1});
tone(900,{type:'triangle',f2:1400,d:.7,peak:.09,delay:.05});},
bossRoar(){tone(60,{type:'sawtooth',f2:38,d:1.6,peak:.34,a:.12});noise({f:300,f2:80,q:.5,d:1.4,peak:.2,a:.1});},
slam(){noise({f:180,f2:50,q:.6,d:.9,peak:.34,a:.006});tone(48,{type:'sine',f2:30,d:1.1,peak:.34});},
beacon(){[261,329,392,523,659].forEach((f,i)=>tone(f,{type:'triangle',d:2.4,peak:.13,delay:i*.22,a:.1}));
[130,196].forEach((f,i)=>tone(f,{type:'sine',d:3,peak:.2,delay:i*.3,a:.3}));},
warp(){noise({f:300,f2:2600,q:.7,d:.8,peak:.2,a:.05});tone(220,{type:'sine',f2:880,d:.9,peak:.14});}
};
})();
/* ---------------------------- CANVAS / VIEW ---------------------------- */
const cvs=document.getElementById('c'),ctx=cvs.getContext('2d');
const LEVEL_H=720;
const VIEW={w:1280,h:1280,k:1,dpr:1,cw:0,ch:0,vh:560};
function resize(){
VIEW.dpr=Math.min(2,window.devicePixelRatio||1);
VIEW.cw=cvs.clientWidth;VIEW.ch=cvs.clientHeight;
cvs.width=Math.floor(VIEW.cw*VIEW.dpr);cvs.height=Math.floor(VIEW.ch*VIEW.dpr);
VIEW.k=cvs.height/VIEW.vh;
VIEW.w=cvs.width/VIEW.k;VIEW.h=VIEW.vh;
}
window.addEventListener('resize',resize);
/* ---------------------------- ITEM TABLE ---------------------------- */
const ITEMS={
stem:{name:'Vital Stem',kind:'consumable',tag:'Restorative',color:'#6fdc7a',glyph:'❦',
desc:'A waxy green stalk of archive-moss. Restores 2 containers (4 HP).'},
seal:{name:'Binding Seal',kind:'consumable',tag:'Talisman',color:'#6cc7ff',glyph:'✧',
desc:'A clotted glyph of blue glass. Freezes the nearest construct for ~3 seconds.'},
band:{name:"Warden's Band",kind:'accessory',tag:'Accessory · Wrist',color:'#d8dbe2',glyph:'◉',
desc:'Glowing silver wrist-ring. Quickens blade recovery — attack speed +30%.'},
lantern:{name:'Phantom Lantern',kind:'accessory',tag:'Accessory · Hand',color:'#7fd8ff',glyph:'☖',
desc:'Blue lantern that reveals hidden ledges, hairline cracks and dark passages nearby.'},
cloak:{name:'Shadow Cloak',kind:'accessory',tag:'Accessory · Cloak',color:'#5b4b7a',glyph:'⌘',
desc:'Heavy dark cloth. Negates all damage from environmental traps and hazards.'},
shard:{name:'Aether Shard',kind:'weapon',tag:'Ranged Weapon',color:'#b6f0ff',glyph:'◆',
desc:'Crystalline projector. Fires a glowing aether bolt at range — strikes crystal switches.'},
heart:{name:'Heart Container',kind:'relic',tag:'Vessel',color:'#ff6b5e',glyph:'♥',
desc:'A pulsing chamber of Warden vitae. Grants one additional container (2 HP).'},
ammo:{name:'Aether Charge',kind:'consumable',tag:'Ammunition',color:'#b6f0ff',glyph:'◆',
desc:'Raw aether, pressed into the shard.'}
};
const SHOP_STOCK=[
{id:'stem',name:'Vital Stem',price:50,max:3,desc:'Restores 2 containers (4 HP).'},
{id:'seal',name:'Binding Seal',price:75,max:2,desc:'Freezes a construct for ~3s.'},
{id:'heart',name:'Heart Container',price:300,max:1,desc:'A fifth vessel — +2 HP, permanently.',relic:true},
{id:'ammo',name:'Aether Charges ×5',price:10,max:99,desc:'Restock the Aether Shard. Only once the Vault is behind you.',ammo:true}
];
/* ---------------------------- ZONE DATA ---------------------------- */
// Rect helper: oneWay platforms unless flagged otherwise
function R(x,y,w,h,solid){return {x:x,y:y,w:w,h:h||20,oneWay:!solid};}
function GR(x,w){return {x:x,y:620,w:w,h:200,oneWay:false};}
function E(type,x,y,o){
const S=ENEMY_STATS[type]||{hp:6,w:28,h:40,spd:50,gold:[5,10],dmg:1};
return Object.assign({type:type,x:x,y:y,home:x,vy:0,vx:0,hp:S.hp,w:S.w,h:S.h,face:-1,
anim:Math.random()*6,hit:0,frz:0,fire:0,atk:0,patrol:120,dead:false,mode:'idle',mt:0,stagger:0,shield:true},o||{});
}
const ENEMY_STATS={
walker:{hp:7,w:30,h:40,spd:58,gold:[5,12],dmg:1},
flyer:{hp:6,w:28,h:26,spd:76,gold:[5,12],dmg:1},
sentinel:{hp:12,w:34,h:48,spd:0,gold:[8,16],dmg:1,shoot:2.2},
brute:{hp:30,w:52,h:64,spd:48,gold:[26,40],dmg:2},
guardian:{hp:56,w:58,h:74,spd:52,gold:[40,60],dmg:2},
boss:{hp:74,w:96,h:112,spd:64,gold:[120,120],dmg:2}
};
function base(idx,name,width,palette,amb){
return {idx:idx,name:name,width:width,palette:palette,amb:amb,
solids:[],props:[],pickups:[],enemies:[],bg:[],hint:null};
}
/* ===== ZONE 1 — THE ASHEN THRESHOLD ===== */
function zone1(){
const z=base(1,'The Ashen Threshold',2800,'ashen','dust');
z.solids.push(GR(-200,3200));
z.solids.push(R(500,480,180),R(790,362,150),R(1080,480,200),R(1420,362,170),
R(1700,480,190),R(2000,362,160),R(2320,480,180));
z.props.push({type:'save',x:210,y:620,stone:1});
z.props.push({type:'sign',x:390,y:620,text:'“The wards are ash. Walk right, and do not linger.”'});
z.props.push({type:'crack',x:1180,y:500,w:36,h:120,hp:10,loot:[{kind:'gold',amount:22}]});
z.props.push({type:'jar',x:1880,y:596,loot:[{kind:'gold',amount:17}]});
z.pickups.push({kind:'gold',amount:14,x:545,y:440},{kind:'gold',amount:12,x:1120,y:440},
{kind:'stem',x:840,y:318});
z.enemies.push(E('walker',760,570,{patrol:130}),E('walker',1500,570,{patrol:170}),
E('flyer',1800,380,{patrol:220}),E('walker',2160,570,{patrol:140}));
z.hint={x:250,y:520,r:170,t:'PRESS [E] AT THE SANCTUARY STONE'};
z.gate={x:2700};
return z;
}
/* ===== ZONE 2 — THE RUSTED ARCHIVES ===== */
function zone2(){
const z=base(2,'The Rusted Archives',3200,'rust','dust');
z.solids.push(GR(-200,3600));
z.solids.push(R(560,478,170),R(860,352,150),R(1150,478,200),R(1480,352,160),
R(1760,478,190),R(2080,352,170),R(2380,478,200),R(2680,352,150),R(2900,478,140));
z.props.push({type:'shop',x:900,y:620});
z.props.push({type:'save',x:1560,y:620,stone:2});
z.props.push({type:'sign',x:1150,y:620,text:'“Coin is a kind of prayer. The merchant accepts both.”'});
z.props.push({type:'jar',x:1780,y:596,loot:[{kind:'gold',amount:20},{kind:'stem'}]});
z.props.push({type:'jar',x:2440,y:596,loot:[{kind:'gold',amount:26}]});
z.props.push({type:'crack',x:1980,y:500,w:36,h:120,hp:12,loot:[{kind:'gold',amount:34}]});
// hidden treasure: Warden's Band on the high ledge
z.props.push({type:'chest',x:2720,y:320,loot:[{kind:'item',item:'band'}],label:"WARDEN'S BAND"});
z.pickups.push({kind:'gold',amount:16,x:600,y:438},{kind:'gold',amount:18,x:1200,y:438},
{kind:'gold',amount:14,x:2420,y:438});
z.enemies.push(E('walker',1300,570,{patrol:150}),E('walker',1700,570,{patrol:160}),
E('sentinel',2250,570,{patrol:0}),E('flyer',2000,360,{patrol:230}),
E('walker',2620,570,{patrol:130}),E('sentinel',3010,430,{patrol:0}));
z.hint={x:900,y:520,r:150,t:'PRESS [E] AT THE MERCHANT ALTAR'};
z.gate={x:3100};
return z;
}
/* ===== ZONE 3 — THE COLLAPSED GALLERY ===== */
function zone3(){
const z=base(3,'The Collapsed Gallery',3300,'gallery','ember');
z.solids.push(GR(-200,3700));
z.solids.push(R(420,478,170),R(700,352,170),R(1020,478,180),R(1320,352,160),
R(1620,478,200),R(1960,478,160),R(2200,352,180),R(2500,478,190),R(2820,470,170));
z.props.push({type:'altar',x:640,y:620,upgrade:1,label:'ALTAR OF THE IGNITED EDGE'});
// pressure-plate puzzle: heavy plate held by a construct, light plate by the Warden
z.props.push({type:'plate',x:1440,y:604,w:110,h:16,heavy:true,target:'door1'});
z.props.push({type:'plate',x:1680,y:462,w:110,h:16,target:'door1'});
z.props.push({type:'door',id:'door1',x:2280,y:380,w:40,h:240,perm:true});
z.props.push({type:'sign',x:1700,y:620,text:'“Two weights, one gate. The gallery watches which of you is heavier.”'});
z.props.push({type:'crack',x:2900,y:500,w:36,h:120,hp:26,loot:[{kind:'heart'}],sealed:true});
z.props.push({type:'jar',x:1080,y:596,loot:[{kind:'gold',amount:24}]});
z.pickups.push({kind:'gold',amount:18,x:460,y:438},{kind:'gold',amount:20,x:1060,y:438},
{kind:'gold',amount:16,x:2240,y:312},{kind:'stem',x:1360,y:312});
z.enemies.push(E('walker',1180,570,{patrol:110}),E('walker',1450,570,{patrol:150}),
E('flyer',1560,340,{patrol:250}),E('walker',2100,570,{patrol:120}),
E('brute',2600,556,{patrol:180}),E('sentinel',2860,430,{patrol:0}));
z.hint={x:640,y:520,r:160,t:'PRESS [E] AT THE ALTAR — THE IGNITED EDGE'};
z.gate={x:3200};
return z;
}
/* ===== ZONE 4 — THE SUNKEN VAULT ===== */
function zone4(){
const z=base(4,'The Sunken Vault',3500,'vault','mist');
z.solids.push(GR(-200,3900));
z.solids.push(R(560,470,180),R(880,340,150),R(1200,470,200),R(1520,340,170),
R(1820,470,190),R(2140,340,170),R(2420,470,190),R(2740,340,160),R(3000,470,170));
// hidden ledges — only solid while the Phantom Lantern is lit
z.solids.push({x:1000,y:220,w:150,h:18,oneWay:true,hidden:true});
z.solids.push({x:1360,y:222,w:130,h:18,oneWay:true,hidden:true});
// breakable wall hiding the Aether Shard
z.props.push({type:'crack',x:1660,y:480,w:38,h:140,hp:14,loot:[{kind:'item',item:'shard'}],label:'THE AETHER SHARD',
reveal:true,text:'Hairline cracks web the bronze — something hums behind it.'});
// light puzzle: three crystal switches
z.props.push({type:'switch',id:'sw_a',x:2060,y:310,target:'reliquary'});
z.props.push({type:'switch',id:'sw_b',x:2560,y:300,target:'reliquary'});
z.props.push({type:'switch',id:'sw_c',x:3060,y:300,target:'reliquary'});
z.props.push({type:'reliquary',id:'reliquary',x:2900,y:430,need:['sw_a','sw_b','sw_c'],
loot:[{kind:'item',item:'lantern'}],label:'PHANTOM LANTERN'});
z.props.push({type:'jar',x:1300,y:596,loot:[{kind:'gold',amount:30}]});
z.props.push({type:'chest',x:1400,y:190,loot:[{kind:'gold',amount:70},{kind:'stem'}],hiddenCache:true,label:'SUNKEN CACHE'});
z.props.push({type:'save',x:3230,y:620,stone:3,label:'THRESHOLD OF THE TWIN SPIRE'});
z.pickups.push({kind:'gold',amount:20,x:610,y:430},{kind:'gold',amount:22,x:1240,y:430},
{kind:'gold',amount:18,x:1860,y:430},{kind:'gold',amount:20,x:2460,y:430});
z.enemies.push(E('flyer',1000,330,{patrol:220}),E('sentinel',1250,570,{patrol:0}),
E('walker',1400,570,{patrol:150}),E('flyer',1900,300,{patrol:260}),
E('sentinel',2280,470,{patrol:0}),E('walker',2650,570,{patrol:140}),
E('brute',3050,556,{patrol:150}));
z.hint={x:1660,y:520,r:200,t:'HAIRLINE CRACKS — STRIKE THE WALL'};
z.gate={x:3400};
return z;
}
/* ===== ZONE 5 — THE TWIN SPIRE ===== */
function zone5(){
const z=base(5,'The Twin Spire',3800,'spire','ember');
z.solids.push(GR(-200,4200));
z.solids.push(R(500,470,170),R(800,340,150),R(1120,470,190),R(1450,340,160),
R(1750,470,190),R(2060,340,170),R(2360,470,200),R(2700,340,170),R(3000,470,190),R(3300,340,150));
z.props.push({type:'altar',x:700,y:620,upgrade:2,label:'ALTAR OF THE SOLAR FALCHION'});
z.props.push({type:'jar',x:1180,y:596,loot:[{kind:'item',item:'seal'}]});
z.props.push({type:'chest',x:1500,y:300,loot:[{kind:'item',item:'cloak'}],label:'SHADOW CLOAK'});
z.props.push({type:'chest',x:2100,y:430,loot:[{kind:'gold',amount:110}],label:'SPIRE TRIBUTE · 110 SIGILS'});
// heart container 5 — sealed behind a ranged switch
z.props.push({type:'switch',id:'sw_h',x:2860,y:290,target:'heartGate'});
z.props.push({type:'door',id:'heartGate',x:3120,y:380,w:40,h:240,perm:true,
label:'A distant reliquary opens somewhere behind you.'});
z.props.push({type:'chest',x:3300,y:620,loot:[{kind:'heart'}],label:'HEART CONTAINER'});
z.props.push({type:'sign',x:2760,y:620,text:'“Strike the far crystal, and what was sealed will open.”'});
z.pickups.push({kind:'gold',amount:22,x:540,y:430},{kind:'gold',amount:24,x:1160,y:430},
{kind:'gold',amount:20,x:2400,y:430},{kind:'gold',amount:26,x:3040,y:430},{kind:'stem',x:1500,y:300});
z.enemies.push(E('walker',1000,570,{patrol:130}),E('flyer',1350,320,{patrol:240}),
E('sentinel',1800,570,{patrol:0}),E('walker',2250,570,{patrol:160}),
E('flyer',2450,300,{patrol:250}),E('sentinel',2700,430,{patrol:0}),
E('walker',2950,570,{patrol:140}),E('guardian',3450,556,{patrol:170}));
z.hint={x:700,y:520,r:170,t:'PRESS [E] AT THE ALTAR — THE SOLAR FALCHION'};
z.gate={x:3700};
return z;
}
/* ===== ZONE 6 — THE CORE CHAMBER ===== */
function zone6(){
const z=base(6,'The Core Chamber',2100,'core','core');
z.solids.push(GR(-200,2500));
z.solids.push(R(240,410,180),R(700,380,190),R(1180,410,180),R(1620,380,180));
z.props.push({type:'switch',id:'bs_a',x:330,y:350,target:'boss'});
z.props.push({type:'switch',id:'bs_b',x:795,y:320,target:'boss'});
z.props.push({type:'switch',id:'bs_c',x:1270,y:350,target:'boss'});
z.props.push({type:'beacon',x:1880,y:560,id:'beacon'});
z.enemies.push(E('boss',1450,470,{patrol:0}));
z.hint={x:1050,y:520,r:260,t:'THREE CRYSTALS SHATTER THE WARD'};
z.gate=null;
return z;
}
const ZONE_BUILDERS=[null,zone1,zone2,zone3,zone4,zone5,zone6];
/* ---------------------------- GAME STATE ---------------------------- */
const G={state:'title',zone:1,t:0,run:0,gold:0,kills:0,ammo:0,
flags:{},msgs:[],shake:0,flash:0,shimmerT:0,fade:0,fadeDir:0,pending:null,
save:null,hitStop:0,lastZone:0,victory:false,deaths:0,damageTaken:0};
const P={x:150,y:540,vx:0,vy:0,w:20,h:44,onGround:false,face:1,hp:6,containers:3,weapon:0,
items:{stem:0,seal:0},owned:{band:0,lantern:0,cloak:0},equip:{band:false,lantern:false,cloak:false},
hasShard:false,shardCd:0,atkT:0,atkCd:0,swingHit:null,invuln:0,hitFlash:0,anim:0,stepT:0,
dead:false,coyote:0,trail:0,glow:0,lastGround:0};
const maxHP=()=>P.containers*2;
const WEAPONS=[
{name:'Ember Blade',dmg:3,wind:.07,active:.13,cool:.30,reach:54,arc:46,col:'#e9e3d2',glow:'#c9c2ad'},
{name:'Ignited Edge',dmg:5,wind:.045,active:.12,cool:.21,reach:62,arc:50,col:'#ffb057',glow:'#ff7b1e'},
{name:'Solar Falchion',dmg:9,wind:.17,active:.13,cool:.44,reach:80,arc:64,col:'#ffe6a0',glow:'#ffc53d'}
];
let Z=null,CAM={x:0,y:0},parts=[],bolts=[],floaters=[],col=[];
const AMB=[];
/* ---------------------------- HELPERS ---------------------------- */
function msg(t,big){G.msgs.push({t:t,l:big?3.4:2.6,big:!!big});if(G.msgs.length>3)G.msgs.shift();}
function floater(x,y,txt,col,size,vy){floaters.push({x:x,y:y,txt:txt,col:col,l:1.0,tl:1.0,s:size||13,vy:vy||-46});}
function part(o){parts.push(Object.assign({x:0,y:0,vx:0,vy:0,g:0,l:.6,tl:.6,c:'#fff',s:3,t:'dot',rot:0,vr:0},o));}
function burst(x,y,n,o){for(let i=0;i<n;i++){const a=rnd(0,TAU),sp=rnd(o.sp0||60,o.sp1||220);
part({x:x+rnd(-4,4),y:y+rnd(-4,4),vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-(o.up||0),g:o.g!==undefined?o.g:420,
l:rnd(o.l0||.3,o.l1||.7),tl:rnd(.4,.8),c:pick(o.cols),s:rnd(o.s0||2,o.s1||4.4),t:o.t||'dot'});}}
function dust(x,y,n){for(let i=0;i<n;i++)part({x:x+rnd(-30,30),y:y,vy:rnd(-16,-4),vx:rnd(-10,10),
l:rnd(.7,1.8),tl:1.8,c:'rgba(220,200,150,.5)',s:rnd(1,2.4),t:'dot'});}
function hitBox(e,c){const cw=c.w!==undefined?c.w:c.ow,chh=c.h!==undefined?c.h:c.oh;
return Math.abs(e.x-(c.x+cw/2))*2<(e.w+cw)&&Math.abs(e.y-(c.y+chh/2))*2<(e.h+chh);}
/* ---------------------------- COLLISION ---------------------------- */
function buildColliders(){
col.length=0;
for(const s of Z.solids){
if(s.hidden&&!P.equip.lantern)continue;
col.push(s);
}
for(const p of Z.props){
if(p.type==='crack'&&p.hp>0)col.push({x:p.x,y:p.y,w:p.w,h:p.h,oneWay:false});
if(p.type==='door'&&!p.open)col.push({x:p.x,y:p.y,w:p.w,h:p.h,oneWay:false});
if(p.type==='spike')col.push({x:p.x+4,y:p.y+p.h-14,w:p.w-8,h:14,oneWay:true,hazard:true});
}
}
function moveEntity(e,dt){
e.onGround=false;
const oldB=e.y+e.h/2;
e.y+=e.vy*dt;
for(const c of col){
if(!hitBox(e,c))continue;
if(e.vy>=0&&oldB<=c.y+6){e.y=c.y-e.h/2;e.vy=0;e.onGround=true;}
else if(e.vy<0&&!c.oneWay&&(e.y-e.h/2)>=c.y+c.h-6){e.y=c.y+c.h+e.h/2;e.vy=0;}
else if(!c.oneWay){e.y=c.y-e.h/2;e.vy=0;e.onGround=true;}
}
const oldR=e.x+e.w/2,oldL=e.x-e.w/2;
e.x+=e.vx*dt;
for(const c of col){
if(c.oneWay)continue;
if(!hitBox(e,c))continue;
if(oldR<=c.x+6){e.x=c.x-e.w/2;}
else if(oldL>=c.x+c.w-6){e.x=c.x+c.w+e.w/2;}
else{e.x=(e.x<c.x+c.w/2)?c.x-e.w/2:c.x+c.w+e.w/2;}
e.vx=0;e.blocked=true;
}
}
function solidAt(x,y,pad){
pad=pad||0;
for(const c of col){if(c.oneWay)continue;
if(x>c.x-pad&&x<c.x+c.w+pad&&y>c.y-pad&&y<c.y+c.h+pad)return true;}
return false;
}
function groundBelow(x,y,maxD){
let best=null;
for(const c of col){
const cx0=c.x,cx1=c.x+c.w;
if(x<cx0-6||x>cx1+6)continue;
if(c.y>=y-6&&c.y<y+maxD&&(!best||c.y<best))best=c.y;
}
return best;
}
/* ---------------------------- PICKUPS ---------------------------- */
function spawnLoot(o){
if(!o.loot)return;
o.loot.forEach((L,i)=>{
const zid='loot:'+o.zid+':'+i;
if(G.flags['p:'+zid])return;
const pk={x:o.x+(o.w?o.w/2:0)+rnd(-14,14),y:o.y-(o.h||0)/2-rnd(0,16),vy:-120,zid:zid,lootId:i};
Object.assign(pk,L);
if(L.kind==='gold'){pk.vy=-190;pk.vx=rnd(-90,90);}
Z.pickups.push(pk);
burst(pk.x,pk.y,10,{cols:['#ffd98a','#fff3c6','#e8a33c'],sp1:120,l1:.7});
});
}
function collect(pk){
G.flags['p:'+pk.zid]=true;pk.gone=true;
if(pk.kind==='gold'){
G.gold+=pk.amount;SFX.coin();
burst(pk.x,pk.y,14,{cols:['#ffd45e','#fff0b0','#d99b2c'],g:180,sp1:150,l1:.9});
floater(pk.x,pk.y-8,'+'+pk.amount+' ⬤','#ffd45e',14);
}else if(pk.kind==='stem'||pk.kind==='seal'){
P.items[pk.kind]++;SFX.chime(true);
burst(pk.x,pk.y,18,{cols:['#ffffff','#cfe9ff','#9fd8a0'],g:-60,sp1:90,l1:1.2,s0:2,s1:5});
msg(pk.kind==='stem'?'Vital Stem gathered — [1] to quaff':'Binding Seal gathered — [2] to cast');
}else if(pk.kind==='heart'){
P.containers++;P.hp=maxHP();SFX.heart();G.shimmerT=2.2;
for(let i=0;i<40;i++){const a=rnd(0,TAU);part({x:P.x,y:P.y,vx:Math.cos(a)*rnd(40,190),vy:Math.sin(a)*rnd(40,190)-40,
g:-40,l:rnd(.8,1.6),tl:1.6,c:pick(['#ff6b5e','#ffd0a0','#fff6e0']),s:rnd(2,5)});}
floater(P.x,P.y-40,'VESSEL EXPANDED','#ff9c8a',17);
msg('A NEW HEART CONTAINER BLAZES IN YOUR CHEST — '+P.containers+' vessels',true);
}else if(pk.kind==='ammo'){
G.ammo+=pk.amount||3;SFX.coin();
burst(pk.x,pk.y,12,{cols:['#b6f0ff','#ffffff','#7fd8ff'],g:120,sp1:120,l1:.8});
floater(pk.x,pk.y-8,'+'+(pk.amount||3)+' ◆','#9fe8ff',13);
}else if(pk.kind==='item'){
const it=pk.item,I=ITEMS[it];
if(it==='shard'){P.hasShard=true;G.ammo=Math.max(G.ammo,30);P.shardCd=0;
msg('THE AETHER SHARD answers your hand — press [K] to loose a bolt',true);
msg('Ranged unlocked: strike distant crystal switches');
}else if(it==='heart'){P.containers++;P.hp=maxHP();}
else{P.owned[it]=1;P.equip[it]=true;
msg(I.name+' equipped — '+I.tag,true);}
SFX.chord();G.shimmerT=1.8;
for(let i=0;i<36;i++){const a=rnd(0,TAU);part({x:pk.x,y:pk.y,vx:Math.cos(a)*rnd(30,200),vy:Math.sin(a)*rnd(30,200)-60,
g:-30,l:rnd(.7,1.5),tl:1.5,c:pick(['#ffffff',I.color,'#ffeeb8']),s:rnd(2,5)});}
floater(pk.x,pk.y-20,I.name.toUpperCase(),I.color,16);
}
}
/* ---------------------------- INTERACTION ---------------------------- */
let nearInt=null;
function findInteract(){
nearInt=null;let bd=1e9;
for(const p of Z.props){
if(p.looted)continue;
if(p.type==='crack'||p.type==='door'||p.type==='plate'||p.type==='switch'||p.type==='spike')continue;
const px=p.x+(p.w?p.w/2:0),py=p.y;
let r=p.type==='save'?86:(p.type==='shop'?92:74);
if(p.type==='chest'&&p.y<300)r=74;
const d=Math.hypot(P.x-px,P.y-(py-20));
if(d<r&&d<bd){
if(p.type==='reliquary'&&!allHit(p.need)){ /* still show, but locked */ }
bd=d;nearInt=p;
}
}
}
function allHit(list){return list.every(id=>{const s=Z.props.find(q=>q.id===id);return s&&s.hit;});}
function interact(){
const p=nearInt;if(!p)return;
switch(p.type){
case 'sign':msg(p.text);SFX.chime(false);break;
case 'save':{
P.hp=maxHP();G.save={zone:Z.idx,x:P.x};
G.flags['save']=G.save;persist();
SFX.chord();G.shimmerT=1.4;
for(let i=0;i<30;i++){const a=rnd(0,TAU);part({x:p.x+rnd(-8,8),y:p.y-30,vx:Math.cos(a)*rnd(20,110),vy:-rnd(30,140),
g:-30,l:rnd(.6,1.3),tl:1.3,c:pick(['#ffe9a8','#fff8dc','#e8b95c']),s:rnd(1.6,4)});}
msg('SANCTUARY STONE LIT — the Warden is mended and remembered',true);
break;}
case 'altar':{
if(P.weapon>=p.upgrade){msg('The altar is already attuned to you.');SFX.deny();break;}
P.weapon=p.upgrade;G.shimmerT=2.2;SFX.chord();SFX.door();
const W=WEAPONS[P.weapon];
for(let i=0;i<70;i++){const a=rnd(0,TAU);part({x:P.x,y:P.y,vx:Math.cos(a)*rnd(50,260),vy:Math.sin(a)*rnd(50,260)-60,
g:-50,l:rnd(.7,1.6),tl:1.6,c:pick(P.weapon===2?['#fff3c0','#ffd166','#ffffff']:['#ffb457','#ff7a20','#ffe0a0']),s:rnd(2,5)});}
floater(p.x,p.y-70,W.name.toUpperCase(),P.weapon===2?'#ffe6a0':'#ffb057',19);
msg('ALTAR IGNITED — '+W.name+' answers your grip',true);
if(P.weapon===1)msg('Damage is now ember; swings come faster; the blade trails fire');
else msg('Damage is now solar; the arc sweeps wider and lands far heavier');
break;}
case 'shop':openShop();break;
case 'chest':case 'jar':case 'reliquary':{
if(p.type==='reliquary'&&!allHit(p.need)){msg('The reliquary is warded — all three crystals must sing.');SFX.deny();break;}
p.looted=true;G.flags['c:'+p.zid]=true;spawnLoot(p);
SFX.chime(true);SFX.door();
burst(p.x+(p.w||0)/2,p.y-20,24,{cols:['#fff3c6','#ffd98a','#e8a33c'],sp1:160,l1:1});
msg(p.label?(p.label+' — revealed'):'Cache opened');
break;}
case 'beacon':{
if(!G.flags.bossDead){msg('The Beacon is cold — the Warden of Corrosion still bars it.');SFX.deny();break;}
winGame();break;}
}
}
function nearestEnemy(){let b=null,bd=460;for(const e of Z.enemies){if(e.dead)continue;const d=Math.hypot(e.x-P.x,e.y-P.y);if(d<bd){bd=d;b=e;}}return b;}
function useItem(key){
if(key==='stem'){
if(P.items.stem<=0){msg('No Vital Stem remains.');SFX.deny();return;}
if(P.hp>=maxHP()){msg('Your vessels are already full.');SFX.deny();return;}
P.items.stem--;P.hp=Math.min(maxHP(),P.hp+4);SFX.chime(true);G.shimmerT=1.0;
for(let i=0;i<26;i++){const a=rnd(0,TAU);part({x:P.x,y:P.y,vx:Math.cos(a)*rnd(20,120),vy:-rnd(20,120),g:-40,
l:rnd(.6,1.2),tl:1.2,c:pick(['#9fffa8','#e8ffe0','#5fd070']),s:rnd(2,4)});}
floater(P.x,P.y-46,'+4 HP','#8ef09a',16);
msg('Vital Stem quaffed — two containers refill');
}else if(key==='seal'){
if(P.items.seal<=0){msg('No Binding Seal remains.');SFX.deny();return;}
const e=nearestEnemy();
if(!e){msg('Nothing near enough to bind.');SFX.deny();return;}
P.items.seal--;e.frz=3.0;SFX.freeze();
burst(e.x,e.y,26,{cols:['#b6f0ff','#ffffff','#5fb8ff'],sp1:130,l1:1.1,g:-20});
floater(e.x,e.y-40,'BOUND','#7fd8ff',15);
msg('Binding Seal cast — the construct is frozen mid-gesture');
}
}
/* ---------------------------- PROJECTILES ---------------------------- */
function fireBolt(){
if(!P.hasShard){msg('No ranged weapon yet — the Aether Shard sleeps in the Sunken Vault.');SFX.deny();return;}
if(G.ammo<=0){msg('The Shard is spent — buy charges from the Rusted Merchant.');SFX.deny();return;}
if(P.shardCd>0)return;
P.shardCd=.34;G.ammo--;
let vx=P.face*700,vy=0;
const up=keys['w']||keys['arrowup'],dn=keys['s']||keys['arrowdown'];
if(up&&!dn){vx=P.face*495;vy=-495;}
else if(dn&&!up){vx=P.face*495;vy=495;}
else{
// light aim-assist toward the nearest unlit crystal in front
let tgt=null,bd=560;
for(const p of Z.props){
if(p.type!=='switch'||p.hit)continue;
const dxs=p.x-P.x,dys=(p.y-8)-(P.y-6);
if(dxs*P.face<-10)continue;
const d=Math.hypot(dxs,dys);
if(d>14&&d<bd){bd=d;tgt=p;}
}
if(tgt){const dx2=tgt.x-P.x,dy2=(tgt.y-8)-(P.y-6),d=Math.hypot(dx2,dy2);
vx=dx2/d*700;vy=dy2/d*700;}
}
bolts.push({x:P.x+P.face*16,y:P.y-6,vx:vx,vy:vy,l:1.6,team:'p',dmg:5,kind:'aether'});
SFX.twang();
for(let i=0;i<10;i++)part({x:P.x+P.face*18,y:P.y-6,vx:P.face*rnd(40,180),vy:rnd(-50,50),l:.35,tl:.35,
c:pick(['#b6f0ff','#ffffff','#7fd8ff']),s:rnd(1.5,3.5)});
}
/* ---------------------------- COMBAT ---------------------------- */
function damageEnemy(e,dmg,sx,sy,src){
if(e.dead)return;
if(e.type==='boss'&&e.shield){
floater(e.x,e.y-60,'WARDED','#9fb6c8',15);
burst(e.x-14,e.y-30,10,{cols:['#9fd8ff','#5f88a8'],sp1:110,l1:.4});
SFX.deny();return;
}
e.hp-=dmg;e.hit=.16;
const kb=src==='bolt'?60:(src==='solar'?150:90);
e.vx+=sign(e.x-sx)*kb*(e.type==='brute'||e.type==='guardian'||e.type==='boss'?.35:1);
if(e.type==='walker'||e.type==='sentinel')e.vy=-110;
const cols=src==='bolt'?['#b6f0ff','#ffffff','#7fd8ff']:
(src==='solar'?['#fff3c0','#ffd166','#ffffff']:
(src==='fire'?['#ffb457','#ff6a1e','#ffe0a0']:['#ffd07a','#fff0c0','#c89a4e']));
burst(e.x+sign(e.x-sx)*8,e.y-6,12,{cols:cols,sp1:200,l1:.55,g:300});
if(src==='fire'||src==='solar')burst(e.x,e.y-10,6,{cols:['rgba(120,120,120,.6)','rgba(200,200,200,.5)'],g:-60,sp1:60,l1:1.2,s0:3,s1:7});
floater(e.x,e.y-e.h/2-6,'-'+dmg,src==='solar'?'#ffe6a0':(src==='fire'?'#ffb057':'#ffd9a0'),13);
SFX.impact(src==='fire'||src==='solar');
if(src==='solar'){SFX.solarHit();G.shake=Math.max(G.shake,5);}
else if(src==='fire')SFX.fireCrackle();
if(src==='bolt')SFX.boltImpact();
if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
e.dead=true;G.kills++;
const st=ENEMY_STATS[e.type];
burst(e.x,e.y-10,30,{cols:['#3a3f4a','#7a6a52','#c8a45e','#8d8d8d'],sp1:240,l1:1,s0:2,s1:6});
burst(e.x,e.y-10,14,{cols:['#ff9d3c','#ffd98a','#5a5a5a'],sp1:150,l1:.8,g:-40,t:'smoke'});
SFX.breakWall();
const n=e.type==='brute'||e.type==='guardian'||e.type==='boss'?5:rndi(2,3);
for(let i=0;i<n;i++)Z.pickups.push({kind:'gold',amount:rndi(st.gold[0],st.gold[1])/1|0||1,
x:e.x+rnd(-16,16),y:e.y-14,vy:rnd(-260,-140),vx:rnd(-120,120)});
if(Z.idx>=5&&Math.random()<.55)Z.pickups.push({kind:'ammo',amount:3,x:e.x+rnd(-12,12),y:e.y-22,vy:-220,vx:rnd(-70,70)});
if(e.type==='boss'){
G.flags.bossDead=true;G.shimmerT=3.0;G.shake=16;SFX.beacon();
msg('THE WARDEN OF CORROSION FALLS — the Corrosion recoils from the chamber',true);
msg('Approach the Core Beacon and press [E]');
}else if(e.type==='guardian'){
msg('The Spire Guardian shatters — its dark cloak unfurls as treasure.');
Z.pickups.push({kind:'item',item:'cloak',x:e.x,y:e.y-20,vy:-160});
if(Z.idx===5&&!P.owned.cloak){}
}
for(let i=0;i<(e.type==='boss'?60:0);i++){const a=rnd(0,TAU);part({x:e.x,y:e.y-30,vx:Math.cos(a)*rnd(60,320),vy:Math.sin(a)*rnd(60,320)-60,
g:120,l:rnd(.8,1.8),tl:1.8,c:pick(['#ffb457','#ff6a1e','#2c2f38','#c8a45e']),s:rnd(3,7)});}
}
function hurtPlayer(dmg,srcx){
if(P.invuln>0||P.dead)return;
if(P.equip.cloak&&srcx===null){return;}
dmg=Math.max(1,Math.round(dmg));
P.hp-=dmg;P.invuln=1.05;P.hitFlash=1;G.flash=Math.max(G.flash,.55);G.shake=Math.max(G.shake,7);
G.damageTaken+=dmg;
SFX.hurt();
burst(P.x,P.y-10,26,{cols:['#ff5a4a','#ff8a70','#7a1f18'],sp1:220,l1:.8,g:160,s0:2,s1:5});
floater(P.x,P.y-40,'-'+dmg,'#ff7a6a',16);
if(srcx!==null&&srcx!==undefined){P.vx+=sign(P.x-srcx)*180;P.vy=-220;}
if(P.hp<=0){P.hp=0;P.dead=true;G.deaths++;
setTimeout(die,700);}
}
function die(){
G.fadeDir=1;G.pending='respawn';
msg('The Corrosion claims the Warden…',true);
}
/* =======================================================================
RENDERING
======================================================================= */
const PAL={
1:{a:'#151a26',b:'#242633',c:'#463532',d:'#7a4a2c',fog:'rgba(150,140,150,.08)',gear:'#2a3040',far:'#161a24',near:'#1d222e',
stone:'#3b3d48',stoneTop:'#5a5c6a',trim:'#c8a45e',moss:'#3f5a44',glow:'rgba(255,170,80,.10)'},
2:{a:'#1d1712',b:'#33261c',c:'#54331d',d:'#7d4a22',fog:'rgba(200,150,90,.09)',gear:'#3a2c20',far:'#181410',near:'#221a14',
stone:'#4a3c2c',stoneTop:'#6d5a40',trim:'#d8a94e',moss:'#4a5a3a',glow:'rgba(255,190,90,.12)'},
3:{a:'#191320',b:'#30202c',c:'#55302c',d:'#8a4526',fog:'rgba(190,140,110,.10)',gear:'#332635',far:'#171220',near:'#1f1826',
stone:'#463a44',stoneTop:'#6a5762',trim:'#d8a94e',moss:'#43563f',glow:'rgba(255,160,70,.14)'},
4:{a:'#0e1a22',b:'#14303a',c:'#1c4650',d:'#2f6a70',fog:'rgba(150,220,230,.11)',gear:'#1b3138',far:'#0b1a20',near:'#122830',
stone:'#2b434a',stoneTop:'#41626a',trim:'#7fd8ff',moss:'#2f5a52',glow:'rgba(120,220,255,.12)'},
5:{a:'#1a1724',b:'#2c2436',c:'#4a3428',d:'#7d5222',fog:'rgba(200,180,150,.08)',gear:'#2f2a3a',far:'#151320',near:'#1e1a28',
stone:'#403a4c',stoneTop:'#615a72',trim:'#e8c061',moss:'#48543c',glow:'rgba(255,200,100,.14)'},
6:{a:'#160c0e',b:'#2c1210',c:'#571e12',d:'#8f3512',fog:'rgba(255,140,80,.09)',gear:'#3a1e16',far:'#120a0b',near:'#1d0f0e',
stone:'#3a2620',stoneTop:'#5c3a2c',trim:'#ff9a3c',moss:'#3a2a1e',glow:'rgba(255,120,40,.18)'}
};
function drawGear(g,x,y,r,rot,col,teeth,dark){
g.save();g.translate(x,y);g.rotate(rot);
g.fillStyle=col;
g.beginPath();
for(let i=0;i<teeth;i++){
const a0=i/teeth*TAU,a1=(i+0.42)/teeth*TAU,a2=(i+0.58)/teeth*TAU,a3=(i+1)/teeth*TAU;
const rr=r*1.16;
if(i===0)g.moveTo(Math.cos(a0)*r,Math.sin(a0)*r);
g.lineTo(Math.cos(a0)*rr,Math.sin(a0)*rr);
g.lineTo(Math.cos(a1)*rr,Math.sin(a1)*rr);
g.lineTo(Math.cos(a1)*r,Math.sin(a1)*r);
g.arc(0,0,r,a1,a2);
g.lineTo(Math.cos(a2)*rr,Math.sin(a2)*rr);
g.lineTo(Math.cos(a3)*rr,Math.sin(a3)*rr);
g.lineTo(Math.cos(a3)*r,Math.sin(a3)*r);
g.arc(0,0,r,a3,a0+TAU);
}
g.closePath();g.fill();
g.globalCompositeOperation='destination-out';
g.beginPath();g.arc(0,0,r*0.30,0,TAU);g.fill();
for(let i=0;i<5;i++){const a=i/5*TAU+0.3;
g.beginPath();g.arc(Math.cos(a)*r*0.58,Math.sin(a)*r*0.58,r*0.13,0,TAU);g.fill();}
g.restore();
}
function drawBackground(){
const p=PAL[Z.idx],t=G.t;
const sky=ctx.createLinearGradient(0,0,0,LEVEL_H);
sky.addColorStop(0,p.a);sky.addColorStop(.42,p.b);sky.addColorStop(.72,p.c);sky.addColorStop(1,p.d);
ctx.fillStyle=sky;ctx.fillRect(CAM.x,0,VIEW.w,LEVEL_H);
// storm clouds
ctx.save();
for(let i=0;i<7;i++){
const cx=CAM.x*0.15+i*420+((t*6)%420);
const cy=60+Math.sin(i*2.3)*38;
const cg=ctx.createRadialGradient(cx,cy,0,cx,cy,250);
cg.addColorStop(0,'rgba(255,190,130,.10)');
cg.addColorStop(.55,p.fog);
cg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=cg;
ctx.beginPath();ctx.ellipse(cx,cy,250,86,0,0,TAU);ctx.fill();
}
ctx.restore();
// far city / towers (parallax 0.25)
ctx.save();ctx.globalAlpha=.85;
const ox=CAM.x*0.25;
for(let i=0;i<14;i++){
const bx=i*300-((ox)%300)-300+ (i*37)%90;
const bw=90+((i*53)%70),bh=180+((i*97)%260);
ctx.fillStyle=p.far;
ctx.fillRect(bx,LEVEL_H-40-bh,bw,bh);
// broken top
ctx.beginPath();ctx.moveTo(bx,LEVEL_H-40-bh);
ctx.lineTo(bx+bw*0.4,LEVEL_H-40-bh-40);ctx.lineTo(bx+bw,LEVEL_H-40-bh);ctx.closePath();ctx.fill();
ctx.fillStyle='rgba(255,190,110,.16)';
for(let w=0;w<4;w++)if((i+w*3)%3===0)ctx.fillRect(bx+14+w*18,LEVEL_H-60-bh+30+w*36,9,14);
}
ctx.restore();
// big gears (parallax 0.42)
ctx.save();ctx.globalAlpha=.9;
for(let i=0;i<9;i++){
const gx=i*520-CAM.x*0.42-(i%3)*110,r=90+((i*67)%110);
ctx.save();ctx.translate(gx,0);
drawGear(ctx,0,180+((i*43)%220),r,t*(i%2?-0.14:0.2),p.gear,14,true);
ctx.restore();
}
ctx.restore();
// mid arches (parallax 0.62)
ctx.save();ctx.fillStyle=p.near;
const mx=CAM.x*0.62;
for(let i=0;i<12;i++){
const ax=i*420-(mx%420)-420;
ctx.beginPath();
ctx.moveTo(ax,LEVEL_H-40);ctx.lineTo(ax,LEVEL_H-260);
ctx.quadraticCurveTo(ax+100,LEVEL_H-420,ax+200,LEVEL_H-260);
ctx.lineTo(ax+200,LEVEL_H-40);ctx.closePath();ctx.fill();
ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(255,200,120,.05)';ctx.fillRect(ax+70,LEVEL_H-300,60,180);
ctx.globalCompositeOperation='source-over';ctx.fillStyle=p.near;
}
ctx.restore();
// horizon glow
const hg=ctx.createLinearGradient(0,LEVEL_H-330,0,LEVEL_H);
hg.addColorStop(0,'rgba(255,150,60,0)');hg.addColorStop(1,p.glow);
ctx.fillStyle=hg;ctx.fillRect(CAM.x,LEVEL_H-330,VIEW.w,330);
}
function drawPlatforms(){
const p=PAL[Z.idx];
for(const s of Z.solids){
if(s.hidden&&!P.equip.lantern){
ctx.save();ctx.globalAlpha=.10;ctx.strokeStyle=p.trim;ctx.setLineDash([5,9]);
ctx.strokeRect(s.x,s.y,s.w,s.h);ctx.restore();continue;
}
const g=ctx.createLinearGradient(0,s.y,0,s.y+s.h);
g.addColorStop(0,p.stoneTop);g.addColorStop(.12,p.stone);g.addColorStop(1,'#11141a');
ctx.fillStyle=g;ctx.fillRect(s.x,s.y,s.w,s.h);
ctx.fillStyle=p.trim;ctx.globalAlpha=.7;ctx.fillRect(s.x,s.y,s.w,2.4);ctx.globalAlpha=1;
// rivets + moss
ctx.fillStyle='rgba(0,0,0,.28)';
for(let x=s.x+10;x<s.x+s.w-6;x+=26)ctx.fillRect(x,s.y+7,4,4);
ctx.fillStyle=p.moss;ctx.globalAlpha=.5;
for(let x=s.x+4;x<s.x+s.w-4;x+=41)ctx.fillRect(x,s.y+3+rnd(0,1),rnd(6,16),3);ctx.globalAlpha=1;
if(s.hidden){ctx.strokeStyle='rgba(160,230,255,.5)';ctx.strokeRect(s.x,s.y,s.w,s.h);}
ctx.fillStyle='rgba(0,0,0,.35)';ctx.fillRect(s.x,s.y+s.h,s.w,6);
}
}
function drawProps(){
const p=PAL[Z.idx],t=G.t;
// gate arch marker
if(Z.gate&&!G.pending){
const gx=Z.gate.x;
ctx.save();
ctx.fillStyle='rgba(255,210,140,.10)';ctx.fillRect(gx,300,70,320);
const gg=ctx.createLinearGradient(0,300,0,620);gg.addColorStop(0,'rgba(255,190,90,0)');gg.addColorStop(1,'rgba(255,190,90,.22)');
ctx.fillStyle=gg;ctx.fillRect(gx+8,300,54,320);
ctx.strokeStyle='rgba(255,215,150,.5)';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(gx+35,300);ctx.lineTo(gx+35,300+Math.sin(t*2)*4);ctx.stroke();
ctx.restore();
}
for(const o of Z.props){
if(o.type==='sign'){drawSign(o);}
else if(o.type==='save'){drawSave(o);}
else if(o.type==='shop'){drawShop(o);}
else if(o.type==='altar'){drawAltar(o);}
else if(o.type==='crack'){drawCrack(o);}
else if(o.type==='plate'){drawPlate(o);}
else if(o.type==='door'){drawDoor(o);}
else if(o.type==='switch'){drawSwitch(o);}
else if(o.type==='chest'||o.type==='jar'||o.type==='reliquary'){drawContainer(o);}
else if(o.type==='spike'){drawSpike(o);}
else if(o.type==='beacon'){drawBeacon(o);}
}
}
function drawSign(o){
ctx.save();
ctx.strokeStyle='#6b5a3c';ctx.lineWidth=4;
ctx.beginPath();ctx.moveTo(o.x,o.y);ctx.lineTo(o.x,o.y-70);ctx.stroke();
ctx.fillStyle='#5b4a30';ctx.fillRect(o.x-52,o.y-112,104,44);
ctx.strokeStyle='#c8a45e';ctx.lineWidth=1.5;ctx.strokeRect(o.x-52,o.y-112,104,44);
ctx.fillStyle='rgba(245,225,175,.5)';
for(let i=0;i<3;i++)ctx.fillRect(o.x-40,o.y-104+i*12,80-i*18,3);
ctx.fillStyle='rgba(0,0,0,.25)';ctx.fillRect(o.x-52,o.y-112,104,4);
ctx.restore();
}
function drawSave(o){
const t=G.t,lit=G.save&&G.save.zone===Z.idx;
ctx.save();
const base=o.y;
ctx.fillStyle='#2a2c34';ctx.fillRect(o.x-26,base-26,52,26);
ctx.fillStyle=PAL[Z.idx].trim;ctx.globalAlpha=.6;ctx.fillRect(o.x-26,base-26,52,3);ctx.globalAlpha=1;
const g=ctx.createLinearGradient(0,base-100,0,base);
g.addColorStop(0,'rgba(255,225,150,.25)');g.addColorStop(1,'rgba(255,180,80,0)');
ctx.fillStyle=g;ctx.fillRect(o.x-40,base-140,80,140);
// pillar
ctx.fillStyle='#3a3d48';ctx.fillRect(o.x-13,base-96,26,72);
ctx.strokeStyle='#c8a45e';ctx.lineWidth=1;ctx.strokeRect(o.x-13,base-96,26,72);
for(let i=0;i<5;i++){
const a=t*1.2+i/5*TAU;
const cy=base-104+Math.sin(a)*5;
ctx.globalAlpha=.28+.28*Math.sin(a*.5+1);
ctx.fillStyle='#ffe9a8';
ctx.beginPath();ctx.arc(o.x+Math.cos(a)*15,cy,2.2,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;
// sigil
const pulse=.6+.4*Math.sin(t*2.2);
ctx.shadowColor='#ffd071';ctx.shadowBlur=26*pulse;
ctx.fillStyle=lit?'#fff0c0':'#ffe08a';
ctx.beginPath();ctx.arc(o.x,base-108,7,0,TAU);ctx.fill();
ctx.shadowBlur=0;
ctx.globalAlpha=.30*pulse;ctx.fillStyle='#ffe9a8';
ctx.beginPath();ctx.arc(o.x,base-108,10+16*(1-pulse),0,TAU);ctx.fill();
ctx.globalAlpha=1;
ctx.restore();
}
function drawShop(o){
ctx.save();const t=G.t,b=o.y;
const g=ctx.createLinearGradient(0,b-200,0,b);
g.addColorStop(0,'rgba(255,200,120,.20)');g.addColorStop(1,'rgba(255,160,60,0)');
ctx.fillStyle=g;ctx.fillRect(o.x-130,b-220,260,220);
ctx.fillStyle='#33281c';ctx.fillRect(o.x-90,b-70,180,70);
ctx.fillStyle='#4a3a26';ctx.fillRect(o.x-96,b-78,192,12);
ctx.strokeStyle='#d8a94e';ctx.lineWidth=1.4;ctx.strokeRect(o.x-96,b-78,192,12);
// scales & wares
for(let i=0;i<3;i++){
ctx.fillStyle=['#7fd88a','#7fc8ff','#ff9a6a'][i];
ctx.beginPath();ctx.arc(o.x-46+i*46,b-86,7,0,TAU);ctx.fill();
ctx.globalAlpha=.35;ctx.beginPath();ctx.arc(o.x-46+i*46,b-86,7+4*Math.abs(Math.sin(t*2+i)),0,TAU);ctx.fill();
ctx.globalAlpha=1;
}
// hooded merchant
ctx.fillStyle='#241d16';
ctx.beginPath();ctx.moveTo(o.x+62,b-10);ctx.lineTo(o.x+50,b-86);
ctx.quadraticCurveTo(o.x+64,b-118,o.x+78,b-86);ctx.lineTo(o.x+86,b-10);ctx.closePath();ctx.fill();
ctx.fillStyle='#f0b45e';ctx.globalAlpha=.8;
ctx.beginPath();ctx.arc(o.x+64,b-84,3.4,0,TAU);ctx.fill();ctx.globalAlpha=1;
ctx.restore();
}
function drawAltar(o){
ctx.save();const t=G.t,b=o.y;
const done=P.weapon>=o.upgrade;
const col=o.upgrade===2?'#ffd875':'#ff8c2e';
const g=ctx.createLinearGradient(0,b-260,0,b);
g.addColorStop(0,done?'rgba(255,220,140,.06)':'rgba(255,170,80,.22)');
g.addColorStop(1,'rgba(255,150,60,0)');
ctx.fillStyle=g;ctx.fillRect(o.x-110,b-280,220,280);
ctx.fillStyle='#2e2a26';ctx.fillRect(o.x-56,b-22,112,22);
ctx.fillStyle='#3c3630';ctx.fillRect(o.x-46,b-56,92,36);
ctx.strokeStyle=col;ctx.globalAlpha=.7;ctx.lineWidth=1.4;ctx.strokeRect(o.x-46,b-56,92,36);
ctx.globalAlpha=1;
ctx.fillStyle='#2e2a26';ctx.fillRect(o.x-14,b-108,28,54);
const pulse=.55+.45*Math.sin(t*(done?1.4:3));
ctx.shadowColor=col;ctx.shadowBlur=30*pulse;
ctx.fillStyle=done?'rgba(200,200,200,.6)':col;
ctx.beginPath();ctx.moveTo(o.x,b-152);ctx.lineTo(o.x+13,b-120);ctx.lineTo(o.x,b-96);ctx.lineTo(o.x-13,b-120);ctx.closePath();ctx.fill();
ctx.shadowBlur=0;
if(!done){
for(let i=0;i<10;i++){
const a=t*1.1+i*.63;
ctx.globalAlpha=.3+.3*Math.sin(t*3+i);
ctx.fillStyle=col;
ctx.beginPath();ctx.arc(o.x+Math.cos(a)*rnd(14,34),b-120+Math.sin(a*1.7)*rnd(14,44),rnd(1,2.6),0,TAU);ctx.fill();
}
ctx.globalAlpha=1;
}
ctx.restore();
}
function drawCrack(o){
if(o.hp<=0){
// rubble
ctx.save();ctx.fillStyle='#3a352e';
for(let i=0;i<7;i++)ctx.fillRect(o.x-4+(i*7)%(o.w+8),o.y+o.h-10-((i*13)%14),9,8);
ctx.restore();return;
}
const p=PAL[Z.idx];
ctx.save();
const g=ctx.createLinearGradient(o.x,o.y,o.x+o.w,o.y+o.h);
g.addColorStop(0,'#3a3226');g.addColorStop(1,'#221d16');
ctx.fillStyle=g;ctx.fillRect(o.x,o.y,o.w,o.h);
ctx.fillStyle='rgba(20,16,12,.75)';ctx.fillRect(o.x,o.y,o.w,3);
// hairline crack web — brighter with lantern
const boost=P.equip.lantern?1.5:1;
ctx.strokeStyle='rgba(10,8,6,'+(0.85*boost>1?1:0.85*boost)+')';
ctx.lineWidth=1.2;
const cx=o.x+o.w/2;
ctx.beginPath();ctx.moveTo(cx,o.y+3);
let px=cx,py=o.y+3;
for(let i=0;i<9;i++){px+=(Math.sin(i*2.7)*o.w*0.32);py+=o.h/9;ctx.lineTo(px,py);}
ctx.stroke();
ctx.strokeStyle=P.equip.lantern?'rgba(180,240,255,.5)':'rgba(255,170,90,.28)';
ctx.lineWidth=.9;ctx.stroke();
ctx.restore();
}
function drawPlate(o){
const p=PAL[Z.idx];
ctx.save();
ctx.fillStyle='#2b2620';ctx.fillRect(o.x,o.y+6,o.w,o.h);
ctx.fillStyle=o.pressed?'#8a6a34':'#6b5a3c';
const dy=o.pressed?5:0;
ctx.fillRect(o.x,o.y+dy,o.w,o.h-dy);
ctx.strokeStyle='#c8a45e';ctx.globalAlpha=.8;ctx.lineWidth=1.2;
ctx.strokeRect(o.x,o.y+dy,o.w,o.h-dy);ctx.globalAlpha=1;
ctx.fillStyle='rgba(0,0,0,.3)';
for(let i=0;i<5;i++)ctx.fillRect(o.x+12+i*18,o.y+dy+5,3,3);
if(o.heavy){ctx.fillStyle='#ffd071';ctx.font='11px Georgia';ctx.textAlign='center';
ctx.globalAlpha=.7;ctx.fillText('❖',o.x+o.w/2,o.y-6);ctx.globalAlpha=1;}
ctx.restore();
}
function drawDoor(o){
ctx.save();
const p=PAL[Z.idx];
ctx.fillStyle='#2a2b33';ctx.fillRect(o.x-10,o.y-10,o.w+20,o.h+10);
if(o.open){
ctx.fillStyle='rgba(255,200,120,.08)';ctx.fillRect(o.x,o.y,o.w,o.h);
ctx.fillStyle=p.trim;ctx.globalAlpha=.65;
ctx.fillRect(o.x-4,o.y,5,o.h);ctx.fillRect(o.x+o.w-1,o.y,5,o.h);
ctx.globalAlpha=.5;
for(let y=o.y+10;y<o.y+o.h;y+=26){ctx.fillRect(o.x-2,y,o.w+4,2);}
ctx.globalAlpha=1;
}else{
const g=ctx.createLinearGradient(o.x,0,o.x+o.w,0);
g.addColorStop(0,'#5a4a30');g.addColorStop(.4,'#7d6538');g.addColorStop(1,'#3e3322');
ctx.fillStyle=g;ctx.fillRect(o.x,o.y,o.w,o.h);
ctx.strokeStyle='#c8a45e';ctx.globalAlpha=.8;ctx.lineWidth=1.4;ctx.strokeRect(o.x,o.y,o.w,o.h);
ctx.globalAlpha=1;
ctx.fillStyle='#2a2418';
for(let y=o.y+16;y<o.y+o.h;y+=34)ctx.fillRect(o.x+5,y,o.w-10,4);
ctx.fillStyle='#ffd071';ctx.beginPath();ctx.arc(o.x+o.w/2,o.y+o.h/2,4,0,TAU);ctx.fill();
}
ctx.restore();
}
function drawSwitch(o){
ctx.save();const t=G.t;
ctx.fillStyle='#2a2c34';ctx.fillRect(o.x-16,o.y+12,32,10);
ctx.fillStyle='#3c4050';ctx.fillRect(o.x-4,o.y-4,8,20);
const col=o.hit?'#b6f0ff':'#4a6a80';
const pulse=o.hit?(.6+.4*Math.sin(t*4)):(.35+.15*Math.sin(t*2));
ctx.shadowColor='#9fe8ff';ctx.shadowBlur=o.hit?30*pulse:14*pulse;
ctx.fillStyle=o.hit?'#e8ffff':col;
ctx.beginPath();ctx.moveTo(o.x,o.y-26);ctx.lineTo(o.x+13,o.y-2);ctx.lineTo(o.x,o.y+6);ctx.lineTo(o.x-13,o.y-2);ctx.closePath();ctx.fill();
ctx.shadowBlur=0;
ctx.globalAlpha=.5;ctx.strokeStyle=col;ctx.lineWidth=1;
ctx.beginPath();ctx.arc(o.x,o.y-8,20+6*Math.sin(t*2),0,TAU);ctx.stroke();
ctx.globalAlpha=1;
ctx.restore();
}
function drawContainer(o){
const t=G.t;
ctx.save();
if(o.looted||o.type==='reliquary'&&o.looted){
if(o.type==='jar'){
ctx.fillStyle='#4a3a2a';
for(let i=0;i<5;i++)ctx.fillRect(o.x-10+(i*9)%22,o.y-6-((i*7)%10),7,7);
}else{
ctx.fillStyle='#33291c';ctx.fillRect(o.x-24,o.y-16,48,16);
}
ctx.restore();return;
}
if(o.type==='jar'){
ctx.fillStyle='#4a3a2a';
ctx.beginPath();ctx.moveTo(o.x-14,o.y);ctx.lineTo(o.x-11,o.y-26);
ctx.lineTo(o.x+11,o.y-26);ctx.lineTo(o.x+14,o.y);ctx.closePath();ctx.fill();
ctx.strokeStyle='#8a6a3a';ctx.lineWidth=1;ctx.stroke();
}else if(o.type==='reliquary'){
ctx.fillStyle='#2c333c';ctx.fillRect(o.x-26,o.y-34,52,34);
ctx.strokeStyle=o.hit?'#7fd8ff':'#6a7684';ctx.lineWidth=1.4;ctx.strokeRect(o.x-26,o.y-34,52,34);
ctx.fillStyle=o.hit?'#dff6ff':'#4a5560';
ctx.beginPath();ctx.arc(o.x,o.y-17,9,0,TAU);ctx.fill();
}else{
const hidden=o.hiddenCache&&!P.equip.lantern;
ctx.globalAlpha=hidden?.35:1;
ctx.fillStyle='#4a3520';ctx.fillRect(o.x-24,o.y-30,48,30);
ctx.fillStyle='#5d4427';ctx.beginPath();
ctx.moveTo(o.x-24,o.y-30);ctx.quadraticCurveTo(o.x,o.y-52,o.x+24,o.y-30);ctx.closePath();ctx.fill();
ctx.strokeStyle='#d8a94e';ctx.lineWidth=1.4;ctx.strokeRect(o.x-24,o.y-30,48,30);
ctx.fillStyle='#ffd071';ctx.fillRect(o.x-4,o.y-18,8,7);
ctx.globalAlpha=1;
}
const glow=o.type==='reliquary'&&!o.hit?'rgba(120,220,255,':'rgba(255,210,120,';
const pulse=.08+.06*Math.sin(t*2.4);
ctx.fillStyle=glow+pulse+')';
ctx.beginPath();ctx.arc(o.x,o.y-24,34,0,TAU);ctx.fill();
ctx.restore();
}
function drawSpike(o){
const p=PAL[Z.idx];
ctx.save();ctx.fillStyle='#4a4038';
for(let i=0;i<3;i++){
const bx=o.x+6+i*((o.w-12)/3);
ctx.beginPath();ctx.moveTo(bx,o.y+o.h);ctx.lineTo(bx+6,o.y-4);ctx.lineTo(bx+12,o.y+o.h);ctx.closePath();ctx.fill();
}
ctx.fillStyle=p.trim;ctx.globalAlpha=.5;
for(let i=0;i<3;i++)ctx.fillRect(o.x+6+i*((o.w-12)/3)+4,o.y,2,10);
ctx.globalAlpha=1;ctx.restore();
}
function drawBeacon(o){
const t=G.t,on=G.flags.bossDead;
ctx.save();
const b=o.y;
const g=ctx.createLinearGradient(0,b-900,0,b+60);
g.addColorStop(0,'rgba(255,220,140,0)');
g.addColorStop(1,on?'rgba(255,214,138,'+(.20+.10*Math.sin(t*1.6))+')':'rgba(90,90,110,.06)');
ctx.fillStyle=g;
ctx.fillRect(o.x-90,b-900,180,960);
ctx.fillStyle='#2b2620';ctx.fillRect(o.x-70,b-20,140,20);
ctx.fillStyle='#3b352c';ctx.fillRect(o.x-46,b-64,92,44);
ctx.strokeStyle=on?'#ffd071':'#6a6a80';ctx.lineWidth=1.6;ctx.strokeRect(o.x-46,b-64,92,44);
ctx.fillStyle='#2b2620';ctx.fillRect(o.x-16,b-190,32,130);
for(let i=0;i<5;i++){
const rr=30+i*22+Math.sin(t*1.4+i)*4;
ctx.globalAlpha=on?(.10-i*0.012):(.03);
ctx.fillStyle=on?'#ffe9a8':'#667';
ctx.beginPath();ctx.ellipse(o.x,b-210,rr,rr*.5,0,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;
ctx.shadowColor=on?'#ffd071':'#444';ctx.shadowBlur=on?60:10;
ctx.fillStyle=on?'#fff6dc':'#4a4a5a';
ctx.beginPath();ctx.arc(o.x,b-206,on?18+2*Math.sin(t*3):8,0,TAU);ctx.fill();
ctx.shadowBlur=0;
if(on)for(let i=0;i<16;i++){
const a=t*.7+i*.4;
ctx.globalAlpha=.3+.3*Math.sin(t*2+i);
ctx.fillStyle='#ffe9a8';
ctx.beginPath();ctx.arc(o.x+Math.cos(a)*rnd(20,90),b-206-Math.abs(Math.sin(t+i))*rnd(20,220),rnd(1,3),0,TAU);ctx.fill();
}
ctx.globalAlpha=1;
// crystals
for(const q of[0,1,2]){
const s=Z.props.filter(p=>p.type==='switch')[q];if(!s)continue;
}
ctx.restore();
}
const keys={};
function tryJump(){
if(P.dead)return;
if(P.onGround||P.coyote>0){P.vy=-940;P.onGround=false;P.coyote=0;P.jumpT=.16;SFX.jump();
for(let i=0;i<8;i++)part({x:P.x+rnd(-10,10),y:P.y+20,vx:rnd(-60,60),vy:rnd(10,70),l:.4,tl:.4,
c:'rgba(220,205,170,.6)',s:rnd(1.5,3)});}
}
function startSwing(){
if(P.dead||P.atkCd>0)return;
const W=WEAPONS[P.weapon];
P.atkT=0;P.atkCd=W.wind+W.active+W.cool;P.swingHit=[];
if(P.weapon===2)SFX.solarSwing();else SFX.whoosh();
P.swing=null;
}
function updatePlayer(dt){
const W=WEAPONS[P.weapon];
P.atkCd=Math.max(0,P.atkCd-dt);
P.shardCd=Math.max(0,P.shardCd-dt);
P.invuln=Math.max(0,P.invuln-dt);
P.hitFlash=Math.max(0,P.hitFlash-dt*2.4);
P.anim+=dt;
if(P.dead){P.vx*=.86;P.vy=Math.min(P.vy+2200*dt,900);moveEntity(P,dt);return;}
const mv=(keys['a']||keys['arrowleft']?-1:0)+(keys['d']||keys['arrowright']?1:0);
const spd=236*(P.equip.band?1.07:1);
const acc=P.onGround?15:9;
P.vx=lerp(P.vx,mv*spd,clamp(acc*dt,0,1));
if(mv&&!P.atkT)P.face=mv;
if(P.onGround)P.coyote=.11;else P.coyote=Math.max(0,P.coyote-dt);
P.vy=Math.min(P.vy+2300*dt,980);
if(P.jumpT>0)P.jumpT-=dt;
if(!(keys['w']||keys[' ']||keys['arrowup'])&&P.vy<-330&&P.jumpT<=0)P.vy=-330;
const wasAir=!P.onGround;
moveEntity(P,dt);
if(P.onGround&&wasAir){SFX.land(Math.abs(P.vy)>600);
for(let i=0;i<7;i++)part({x:P.x+rnd(-9,9),y:P.y+20,vx:rnd(-70,70),vy:rnd(-40,-6),l:.45,tl:.45,
c:'rgba(210,195,160,.55)',s:rnd(1.5,3)});}
// footsteps
P.stepT-=dt;
if(P.onGround&&Math.abs(P.vx)>40&&P.stepT<=0){P.stepT=.30-Math.min(.1,Math.abs(P.vx)/2600);SFX.step(Z.idx);}
// swing timeline
if(P.atkCd>0){
P.atkT+=dt;
const t=P.atkT-W.wind;
if(t>=0&&t<=W.active){
const hx=P.x+P.face*(W.reach*0.5+6),hy=P.y-4;
const box={x:hx-W.reach/2,y:hy-W.arc/2,w:W.reach,h:W.arc};
const src=P.weapon===2?'solar':(P.weapon===1?'fire':'base');
for(const e of Z.enemies){
if(e.dead||P.swingHit.indexOf(e)>=0)continue;
const cx=e.x,cy=e.y,ew=e.w,eh=e.h;
if(Math.abs(cx-(box.x+box.w/2))*2<(ew+box.w)&&Math.abs(cy-(box.y+box.h/2))*2<(eh+box.h)){
P.swingHit.push(e);damageEnemy(e,W.dmg,P.x,P.y,src);
}
}
for(const p of Z.props){
if(p.type==='crack'&&p.hp>0&&Math.abs(p.x+p.w/2-(box.x+box.w/2))*2<(p.w+box.w)&&Math.abs(p.y+p.h/2-(box.y+box.h/2))*2<(p.h+box.h)){
damageCrack(p,Math.round(W.dmg*0.8+(P.weapon===2?2:0)));
}
}
if(P.weapon>=1&&Math.random()<.6){
part({x:hx+rnd(-10,10),y:hy+rnd(-W.arc/2,W.arc/2),vx:rnd(-30,30),vy:rnd(-60,-10),g:-20,
l:rnd(.25,.5),tl:.5,c:P.weapon===2?'#ffe6a0':'#ff8c2e',s:rnd(2,4.6)});
}
}
} else if(P.atkT){P.atkT=0;}
// ember / shimmer trail while walking with upgrades
if(P.weapon>0&&P.onGround&&Math.abs(P.vx)>60){
P.trail-=dt;
if(P.trail<=0){P.trail=.11;
part({x:P.x-P.face*8,y:P.y+14,vx:-P.face*rnd(10,40),vy:rnd(-40,-8),g:-25,l:rnd(.3,.7),tl:.7,
c:P.weapon===2?'rgba(255,214,120,.9)':'rgba(255,140,60,.85)',s:rnd(1.6,3.4)});}
}
// hazards
for(const p of Z.props){
if(p.type!=='spike')continue;
if(Math.abs(P.x-(p.x+p.w/2))*2<(P.w+p.w)&&Math.abs(P.y-(p.y+p.h/2))*2<(P.h+p.h)){
if(P.equip.cloak){
if(!p._told){p._told=true;msg('The Shadow Cloak drinks the spikes — you pass unharmed.');}
}else{SFX.hazard();hurtPlayer(2,null);}
}
}
if(P.y>820){hurtPlayer(3,P.x);P.y=520;}
}
function damageCrack(p,dmg){
p.hp-=dmg;SFX.crackHit(P.weapon);
burst(p.x+p.w/2,p.y+p.h/2,10,{cols:['#c8a45e','#8a7a5c','#6b6257','#e8dcb8'],sp1:150,l1:.6});
if(p.hp<=0){
p.hp=0;G.flags['c:'+p.zid]=true;SFX.breakWall();G.shake=Math.max(G.shake,6);
burst(p.x+p.w/2,p.y+p.h/2,40,{cols:['#c8a45e','#8a7a5c','#6b6257','#e8dcb8','#4a453d'],sp1:260,l1:1.1,s0:2,s1:7});
spawnLoot(p);
msg(p.reveal?('The wall gives way — '+(p.label||'something')+' lies within'):'The cracked bronze collapses');
}
}
/* ---------------------------- ENEMIES ---------------------------- */
let bossRef=null;
function updateEnemies(dt){
for(const e of Z.enemies){
if(e.dead)continue;
const st=ENEMY_STATS[e.type];
e.anim+=dt;e.hit=Math.max(0,e.hit-dt);e.blocked=false;
if(e.frz>0)e.frz-=dt;
const frozen=e.frz>0;
const dx=P.x-e.x,dy=P.y-e.y,adx=Math.abs(dx);
if(!frozen){
if(e.type==='walker'){
const chase=adx<300&&Math.abs(dy)<130&&!P.dead;
let dir;
if(chase)dir=sign(dx);
else{ if(e.x>e.home+e.patrol)dir=-1; else if(e.x<e.home-e.patrol)dir=1; else dir=e.face; }
e.face=dir||e.face;e.vx=dir*st.spd;
}else if(e.type==='flyer'){
const chase=adx<420&&Math.abs(dy)<220&&!P.dead;
const tx=chase?P.x:e.home+(e.patrol?Math.sin(e.anim*.7)*e.patrol:0);
const ty=chase?P.y-20:(e.home0y||(e.home0y=e.y))+Math.sin(e.anim*1.6)*22;
e.vx=lerp(e.vx,clamp((tx-e.x)*1.4,-st.spd,st.spd),clamp(3*dt,0,1));
e.vy=lerp(e.vy,clamp((ty-e.y)*2.2,-90,90),clamp(4*dt,0,1));
e.face=sign(e.vx)||e.face;
}else if(e.type==='sentinel'){
e.face=sign(dx)||e.face;e.vx=0;
if(adx<560&&Math.abs(dy)<160&&!P.dead){
e.shootT=(e.shootT===undefined?rnd(.6,1.8):e.shootT)-dt;
if(e.shootT<=0){e.shootT=st.shoot*rnd(.85,1.2);e.fireAnim=.3;
const ang=Math.atan2(P.y-6-(e.y-14),P.x-e.x);
bolts.push({x:e.x+e.face*16,y:e.y-14,vx:Math.cos(ang)*300,vy:Math.sin(ang)*300,l:2.6,team:'e',dmg:1,kind:'corrupt'});
SFX.boltImpact();}
}
if(e.fireAnim>0)e.fireAnim-=dt;
}else if(e.type==='brute'||e.type==='guardian'){
const chase=adx<420&&Math.abs(dy)<140&&!P.dead;
if(chase){
e.atk=(e.atk||rnd(1,2))-dt;
if(e.atk<=0){e.atk=rnd(1.6,2.6);e.lunge=.5;}
e.vx=(e.lunge>0?sign(dx)*st.spd*2.3:sign(dx)*st.spd*.7);
e.face=sign(dx)||e.face;
if(e.lunge>0)e.lunge-=dt;
}else{
if(e.x>e.home+e.patrol)e.face=-1;else if(e.x<e.home-e.patrol)e.face=1;
e.vx=e.face*st.spd*.5;
}
}else if(e.type==='boss'){
updateBoss(e,dt,dx);
}
e.vy=Math.min(e.vy+2300*dt,1000);
if(e.type==='flyer')e.vy=clamp(e.vy,-140,140);
}else{
e.vx*=.8;
if(e.type!=='flyer')e.vy=Math.min(e.vy+2300*dt,1000);
}
moveEntity(e,dt);
if(e.blocked){e.face=-e.face;if(e.type==='walker'||e.type==='brute'||e.type==='guardian')e.vx=-e.vx;}
// contact damage
if(!P.dead&&Math.abs(e.x-P.x)*2<(e.w+P.w)&&Math.abs(e.y-P.y)*2<(e.h+P.h)){
hurtPlayer(st.dmg,e.x);
}
// pit
if(e.y>900&&e.type!=='boss')e.dead=true;
}
}
function updateBoss(e,dt,dx){
bossRef=e;
const st=ENEMY_STATS.boss;
e.shield=!(e.stagger>0);
if(e.stagger>0){
e.stagger-=dt;e.vx=0;
if(e.anim%0.2<dt)part({x:e.x+rnd(-40,40),y:e.y-rnd(10,80),vx:rnd(-20,20),vy:rnd(-50,-10),l:.5,tl:.5,
c:'#9fd8ff',s:rnd(2,4)});
if(e.stagger<=0){
for(const p of Z.props)if(p.type==='switch'&&p.target==='boss')p.hit=false;
msg('The Warden of Corrosion restores its ward — shatter the crystals again.');
SFX.bossRoar();
}
return;
}
e.atk-=dt;e.mt-=dt;
if(!e.mode)e.mode='idle';
if(e.mode==='idle'){
e.face=sign(dx)||e.face;
e.vx=clamp(dx*.9,-st.spd*0.75,st.spd*0.75);
if(e.atk<=0){e.atk=rnd(1.5,2.4);e.mode=Math.random()<.5?'volley':'slam';e.mt=e.mode==='volley'?1.0:.15;e.volFired=false;
if(e.mode==='slam'){e.vy=-820;e.slammed=false;e.vx=sign(dx)*180;}}
}else if(e.mode==='volley'){
e.vx*=.9;
if(e.mt>0){
const k=(1.0-e.mt);
if(k>0.55&&!e.volFired){
e.volFired=true;
const ang=Math.atan2(P.y-10-(e.y-20),P.x-e.x);
for(let i=-1;i<=1;i++){
const a=ang+i*0.22;
bolts.push({x:e.x+sign(dx)*26,y:e.y-20,vx:Math.cos(a)*330,vy:Math.sin(a)*330,l:2.4,team:'e',dmg:1,kind:'corrupt'});
}
SFX.boltImpact();
}
}else{e.mode='idle';e.atk=rnd(1.2,2.0);e.shot=0;}
}else if(e.mode==='slam'){
if(e.vy>0&&e.onGround&&!e.slammed){
e.slammed=true;G.shake=16;SFX.slam();
burst(e.x,e.y+50,40,{cols:['#c8a45e','#8a7a5c','#6b6257','#ff8c2e'],sp1:380,l1:1.1,g:500});
for(const d of[-1,1])bolts.push({x:e.x+d*50,y:600,vx:d*360,vy:0,l:2.0,team:'e',dmg:2,kind:'shock'});
}
if(e.mt<=0&&e.onGround&&e.slammed){e.mode='idle';e.atk=rnd(1.0,1.8);}
}
}
function checkBossSwitches(){
const sw=Z.props.filter(p=>p.type==='switch'&&p.target==='boss');
if(sw.length&&sw.every(s=>s.hit)){
const b=Z.enemies.find(e=>e.type==='boss'&&!e.dead);
if(b&&b.stagger<=0){
b.stagger=7.5;b.shield=false;SFX.bossRoar();G.shake=12;G.shimmerT=1.6;
msg('ALL THREE CRYSTALS SING — the ward shatters! STRIKE NOW',true);
burst(b.x,b.y-40,50,{cols:['#b6f0ff','#ffffff','#7fd8ff'],sp1:300,l1:1.3,g:-30});
}
}
}
/* ---------------------------- BOLTS / PICKUPS / PROPS ---------------------------- */
function updateBolts(dt){
for(let i=bolts.length-1;i>=0;i--){
const b=bolts[i];b.l-=dt;
if(b.l<=0){bolts.splice(i,1);continue;}
b.x+=b.vx*dt;b.y+=b.vy*dt;
if(b.kind==='shock'){
part({x:b.x,y:b.y+rnd(-16,4),vx:rnd(-40,40),vy:rnd(-90,-20),l:.4,tl:.4,c:'#ffb457',s:rnd(2,5)});
}else{
part({x:b.x,y:b.y,vx:rnd(-20,20),vy:rnd(-20,20),l:.28,tl:.28,
c:b.team==='p'?'#9fe8ff':(b.kind==='corrupt'?'#a05cff':'#ffa14a'),s:rnd(1.6,3.6)});
}
let dead=false;
// solids
for(const c of col){
if(c.oneWay&&b.kind!=='shock')continue;
if(b.x>c.x&&b.x<c.x+c.w&&b.y>c.y&&b.y<c.y+c.h){
if(b.kind!=='shock'){dead=true;burst(b.x,b.y,10,{cols:['#ffe0a0','#c8a45e'],sp1:120,l1:.5});}
}
}
// switches
for(const p of Z.props){
if(p.type!=='switch'||p.hit)continue;
if(Math.abs(b.x-p.x)<22&&Math.abs(b.y-p.y)<26){
p.hit=true;dead=true;SFX.switchHit();
burst(p.x,p.y,26,{cols:['#b6f0ff','#ffffff','#7fd8ff'],sp1:180,l1:.9,g:-20});
floater(p.x,p.y-26,'CRYSTAL LIT','#b6f0ff',14);
if(p.target==='boss')checkBossSwitches();
else{
const d=Z.props.find(o=>o.id===p.target);
if(d&&d.type==='door'){d.open=true;G.flags['d:'+d.zid]=true;SFX.door();G.shimmerT=1.2;
msg(d.label||'A distant mechanism grinds open',true);}
}
}
}
// cracks
if(!dead)for(const p of Z.props){
if(p.type!=='crack'||p.hp<=0)continue;
if(b.x>p.x&&b.x<p.x+p.w&&b.y>p.y&&b.y<p.y+p.h){damageCrack(p,4);dead=true;}
}
// enemies / player
if(!dead){
if(b.team==='p'){
for(const e of Z.enemies){
if(e.dead)continue;
if(Math.abs(e.x-b.x)*2<(e.w+14)&&Math.abs(e.y-b.y)*2<(e.h+14)){
damageEnemy(e,b.dmg,b.x,b.y,'bolt');dead=true;break;
}
}
}else{
if(!P.dead&&Math.abs(P.x-b.x)*2<(P.w+12)&&Math.abs(P.y-b.y)*2<(P.h+12)){
if(P.equip.cloak&&b.kind==='corrupt'){}else hurtPlayer(b.dmg,b.x);
dead=true;
}
}
}
if(b.x<CAM.x-400||b.x>CAM.x+VIEW.w+400||b.y>800)dead=true;
if(dead)bolts.splice(i,1);
}
}
function updatePickups(dt){
for(let i=Z.pickups.length-1;i>=0;i--){
const pk=Z.pickups[i];
if(pk.gone){Z.pickups.splice(i,1);continue;}
if(pk.kind==='gold'){
pk.vy=Math.min((pk.vy||0)+1900*dt,760);
pk.x+=(pk.vx||0)*dt;pk.y+=pk.vy*dt;pk.vx*=.985;
const g2=groundBelow(pk.x,pk.y-6,700);
if(g2!==null&&pk.y>g2-6){
pk.y=g2-6;
if(pk.vy>140)pk.vy=-pk.vy*0.26;else pk.vy=0;
pk.vx*=.55;
}
if(pk.y>860)pk.gone=true;
}else{
pk.float=(pk.float||Math.random()*6)+dt*(pk.kind==='item'?2.0:1.4);
if(pk.vy){pk.vy=Math.min(pk.vy+1500*dt,700);pk.x+=(pk.vx||0)*dt;pk.y+=pk.vy*dt;pk.vx=(pk.vx||0)*.94;}
const g2=groundBelow(pk.x,pk.y-6,700);
if(g2!==null&&pk.y>g2-16){pk.y=g2-16;pk.vy=0;pk.grounded=true;}
if(pk.grounded&&!pk.baseY)pk.baseY=pk.y;
if(pk.grounded)pk.y=pk.baseY+Math.sin(pk.float)*3.2;
}
const dx=Math.abs(pk.x-P.x),dy=Math.abs(pk.y-(P.y-4));
if(!P.dead&&dx<26&&dy<38)collect(pk);
}
}
function updateProps(dt){
// plates
for(const p of Z.props){
if(p.type!=='plate')continue;
const cx=p.x+p.w/2,cy=p.y+p.h/2;
const onPlayer=!P.dead&&Math.abs(P.x-cx)*2<(P.w+p.w)&&(P.y+P.h/2)>p.y-4&&(P.y+P.h/2)<p.y+26;
let onEnemy=false;
for(const e of Z.enemies){
if(e.dead)continue;
if(Math.abs(e.x-cx)*2<(e.w+p.w)&&(e.y+e.h/2)>p.y-4&&(e.y+e.h/2)<p.y+30){onEnemy=true;break;}
}
let was=p.pressed;
if(p.heavy){
if(onEnemy){p.holdT=4;}
else if(onPlayer){p.holdT=(p.holdT||0)+dt;
if(p.holdT>4&&!p.latch){p.latch=true;
msg('The heavy plate latches — the Warden’s own weight, held long enough, will serve.',true);
SFX.plate();}
}else p.holdT=Math.max(0,(p.holdT||0)-dt*.6);
p.pressed=onEnemy||!!p.latch||(p.holdT||0)>=4;
}else{
p.pressed=onPlayer||onEnemy;
}
p.pJust=(p.pressed&&!was);
if(p.pJust)SFX.plate();
if(p.heavy&&onPlayer&&!onEnemy&&!p._told){p._told=true;
msg('The plate sinks but does not hold — it hungers for something heavier than a Warden.');}
}
// doors
for(const d of Z.props){
if(d.type!=='door'||d.permOpen)continue;
const trig=Z.props.filter(o=>o.target===d.id);
const ok=trig.length>0&&trig.every(o=>o.type==='plate'?o.pressed:o.hit);
if(ok&&!d.open){
d.open=true;SFX.door();G.shimmerT=1.0;
msg(d.label||'Something grinds open in the distance',true);
if(d.perm){d.permOpen=true;G.flags['d:'+d.zid]=true;}
for(let i=0;i<20;i++)part({x:d.x+d.w/2+rnd(-20,20),y:d.y+d.h-rnd(0,40),vx:rnd(-40,40),vy:rnd(-60,-10),
l:.8,tl:.8,c:'#c8a45e',s:rnd(2,4)});
}else if(!ok&&!d.permOpen&&d.open){d.open=false;}
}
// switches hit melee-adjacent
for(const p of Z.props){
if(p.type!=='switch'||p.hit)continue;
if(P.atkT>0&&Math.abs(P.x-p.x)<58&&Math.abs(P.y-p.y)<50){
p.hit=true;SFX.switchHit();
burst(p.x,p.y,26,{cols:['#b6f0ff','#ffffff','#7fd8ff'],sp1:180,l1:.9,g:-20});
floater(p.x,p.y-26,'CRYSTAL LIT','#b6f0ff',14);
if(p.target==='boss')checkBossSwitches();
else{const d=Z.props.find(o=>o.id===p.target);
if(d&&d.type==='door'){d.open=true;d.permOpen=true;G.flags['d:'+d.zid]=true;SFX.door();msg(d.label||'A distant mechanism grinds open',true);}}
}
}
}
/* ---------------------------- ZONE FLOW ---------------------------- */
function loadZone(idx,side,xOverride){
G.zone=idx;
Z=ZONE_BUILDERS[idx]();
parts.length=0;bolts.length=0;AMB.length=0;
let pi=0,ci=0;
for(const o of Z.props){if(o.id===undefined)o.id=o.type+'_'+(pi++);o.zid=idx+':'+o.id;}
for(const o of Z.pickups){o.zid=idx+':pk_'+(ci++);}
for(const o of Z.props){
if(G.flags['c:'+o.zid]){
if(o.type==='crack'){o.hp=0;o.looted=true;}
else o.looted=true;
}
if(G.flags['d:'+o.zid]){o.open=true;o.permOpen=true;}
if(G.flags['s:'+o.zid])o.hit=true;
}
for(const o of Z.pickups)if(G.flags['p:'+o.zid])o.gone=true;
Z.enemies=Z.enemies.filter(e=>!(e.type==='boss'&&G.flags.bossDead));
// spawn point
if(xOverride!==undefined){P.x=xOverride;}
else if(side==='right')P.x=72;
else if(side==='left')P.x=Z.width-72;
else P.x=150;
P.y=520;P.vx=0;P.vy=0;P.dead=false;
if(idx===6&&!G.flags.bossDead){SFX.bossRoar();}
SFX.drone([[220,165],[196,147],[174,130],[233,155],[164,123],[207,138],[131,98]][idx][0],
[[220,165],[196,147],[174,130],[233,155],[164,123],[207,138],[131,98]][idx][1],0.30);
CAM.x=clamp(P.x-VIEW.w/2,0,Math.max(0,Z.width-VIEW.w));
buildColliders();
msg('— '+Z.name+' —',true);
}
function goZone(dir){
const n=Z.idx+dir;
if(n<1)return;
if(n>6)return;
G.fadeDir=1;G.pending={zone:n,side:dir>0?'right':'left'};
SFX.warp();
}
function updateFlow(dt){
if(G.pending===null&&!P.dead&&G.state==='play'){
if(Z.gate&&P.x>Z.gate.x)goZone(1);
else if(Z.idx>1&&P.x<26)goZone(-1);
}
if(G.fadeDir>0){G.fade=Math.min(1,G.fade+dt*2.6);
if(G.fade>=1&&G.pending){
const pd=G.pending;G.pending=null;
if(pd==='respawn'){
loadZone(G.save?G.save.zone:1,'right',G.save?G.save.x:undefined);
P.hp=maxHP();P.dead=false;
msg('The Sanctuary Stone re-knits you. The halls remember.',true);
}else{
loadZone(pd.zone,pd.side);
}
G.fadeDir=-1;
}
}else if(G.fadeDir<0){G.fade=Math.max(0,G.fade-dt*1.6);if(G.fade<=0)G.fadeDir=0;}
}
function updateCamera(dt){
const tx=clamp(P.x-VIEW.w/2+(P.face*30),0,Math.max(0,Z.width-VIEW.w));
CAM.x=lerp(CAM.x,tx,clamp(7*dt,0,1));
const ty=clamp(P.y-VIEW.h*0.58,0,Math.max(0,LEVEL_H-VIEW.h));
CAM.y=lerp(CAM.y,ty,clamp(6*dt,0,1));
if(G.shake>0)G.shake=Math.max(0,G.shake-dt*26);
G.flash=Math.max(0,G.flash-dt*1.8);
G.shimmerT=Math.max(0,G.shimmerT-dt);
}
function updateFX(dt){
for(let i=parts.length-1;i>=0;i--){
const p=parts[i];p.l-=dt;
if(p.l<=0){parts.splice(i,1);continue;}
p.vy+=p.g*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.rot+=p.vr*dt;
if(p.t==='smoke'){p.vx*=.98;p.s+=dt*6;}
}
for(let i=floaters.length-1;i>=0;i--){
const f=floaters[i];f.l-=dt;f.y+=f.vy*dt;f.vy*=.96;
if(f.l<=0)floaters.splice(i,1);
}
const want=(Z.amb==='dust'?40:Z.amb==='mist'?46:Z.amb==='ember'?30:40);
if(AMB.length<want&&Math.random()<.7){
const R2=(Math.random()*VIEW.w)|0;
if(Z.amb==='mist')AMB.push({x:CAM.x+R2,y:rnd(420,760),vy:-rnd(14,40),vx:rnd(-8,8),a:rnd(.05,.16),s:rnd(16,44),t:'mist'});
else if(Z.amb==='ember')AMB.push({x:CAM.x+R2,y:rnd(300,700),vy:-rnd(10,34),vx:rnd(-12,12),a:rnd(.2,.5),s:rnd(1,2.6),t:'ember'});
else AMB.push({x:CAM.x+R2,y:rnd(60,640),vy:rnd(6,22),vx:rnd(-14,14),a:rnd(.1,.35),s:rnd(1,2.4),t:'dust'});
}
for(let i=AMB.length-1;i>=0;i--){
const a=AMB[i];a.x+=a.vx*dt;a.y+=a.vy*dt;
if(a.y<-40||a.y>820||a.x<CAM.x-80||a.x>CAM.x+VIEW.w+80)AMB.splice(i,1);
}
}
/* ---------------------------- ENTITY ART ---------------------------- */
function shadowUnder(x,y,w,a){
ctx.fillStyle='rgba(0,0,0,'+(a||.3)+')';
ctx.beginPath();ctx.ellipse(x,y+2,w,4,0,0,TAU);ctx.fill();
}
function drawPlayer(){
const t=G.t,W=WEAPONS[P.weapon],f=P.face;
const moving=P.onGround&&Math.abs(P.vx)>40;
const ph=moving?Math.sin(P.anim*12):0;
const bob=moving?Math.abs(ph)*1.4:(P.onGround?0:1.5);
ctx.save();
shadowUnder(P.x,P.y+22,14,P.onGround?.34:.16);
ctx.translate(P.x,P.y-bob);
if(P.invuln>0&&Math.floor(G.t*20)%2===0)ctx.globalAlpha=.55;
ctx.save();ctx.scale(f,1);
const glow=W.glow;
// ---- legs
ctx.fillStyle='#262a36';
ctx.fillRect(-7+ph*3,6,7,17);ctx.fillRect(1-ph*3,6,7,17);
ctx.fillStyle='#3a3f4e';ctx.fillRect(-8+ph*3,18,9,5);ctx.fillRect(0-ph*3,18,9,5);
// ---- cloak (back layer)
const cloakCol=P.equip.cloak?'#2c2242':'#232838';
ctx.fillStyle=cloakCol;
ctx.beginPath();
ctx.moveTo(-9,-10);
ctx.quadraticCurveTo(-16+ph*2,6,-12,22);
ctx.lineTo(10,22);
ctx.quadraticCurveTo(12,-2,9,-10);
ctx.closePath();ctx.fill();
if(P.equip.cloak){
ctx.fillStyle='rgba(120,90,200,.28)';
ctx.beginPath();ctx.moveTo(-13,8);ctx.quadraticCurveTo(-15,18,-13,22);
ctx.lineTo(-4,22);ctx.quadraticCurveTo(-9,16,-8,8);ctx.closePath();ctx.fill();
ctx.fillStyle='#7a6aa8';
for(let i=0;i<3;i++)ctx.fillRect(-14+i*2,20-i,2,4);
}
// ---- torso
const tg=ctx.createLinearGradient(0,-10,0,10);
tg.addColorStop(0,'#3d4356');tg.addColorStop(1,'#242938');
ctx.fillStyle=tg;
ctx.beginPath();ctx.moveTo(-9,-9);ctx.lineTo(9,-9);ctx.lineTo(7,9);ctx.lineTo(-7,9);ctx.closePath();ctx.fill();
ctx.fillStyle=PAL[Z.idx].trim;ctx.globalAlpha=.85;
ctx.fillRect(-9,-9,18,2.4);
ctx.globalAlpha=1;
ctx.fillStyle='#c8a45e';ctx.fillRect(-2,-4,4,10);
// ---- head + hood
ctx.fillStyle='#2b3040';
ctx.beginPath();ctx.arc(0,-15,7.4,0,TAU);ctx.fill();
ctx.fillStyle='#e0c9a8';
ctx.beginPath();ctx.arc(2,-15,4.6,0,TAU);ctx.fill();
ctx.fillStyle='#1a1e28';
ctx.beginPath();ctx.moveTo(-8,-16);ctx.quadraticCurveTo(-4,-26,4,-22);
ctx.quadraticCurveTo(9,-19,8,-12);ctx.quadraticCurveTo(0,-22,-8,-16);ctx.closePath();ctx.fill();
ctx.fillStyle='#ffd071';ctx.fillRect(4,-16,2.4,1.6);
// ---- off hand gear
ctx.save();
ctx.translate(-9,-2);
if(P.equip.lantern&&P.owned.lantern){
ctx.strokeStyle='#8fb8c8';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(0,-4);ctx.lineTo(-2,2);ctx.stroke();
ctx.fillStyle='#3a4650';ctx.fillRect(-6,2,11,10);
ctx.strokeStyle='#9fd8ff';ctx.lineWidth=1;ctx.strokeRect(-6,2,11,10);
const p=.6+.4*Math.sin(t*3);
ctx.shadowColor='#7fd8ff';ctx.shadowBlur=22*p;
ctx.fillStyle='#bff0ff';
ctx.beginPath();ctx.arc(-.5,7,3.4,0,TAU);ctx.fill();
ctx.shadowBlur=0;
ctx.globalAlpha=.12;ctx.fillStyle='#7fd8ff';
ctx.beginPath();ctx.arc(-.5,7,26+p*6,0,TAU);ctx.fill();ctx.globalAlpha=1;
}else if(P.hasShard){
const p=.6+.4*Math.sin(t*4);
ctx.shadowColor='#b6f0ff';ctx.shadowBlur=20*p;
ctx.fillStyle='#dff8ff';
ctx.beginPath();ctx.moveTo(0,-10);ctx.lineTo(5,-1);ctx.lineTo(0,8);ctx.lineTo(-5,-1);ctx.closePath();ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='rgba(150,230,255,.5)';
ctx.beginPath();ctx.arc(0,-1,5+3*p,0,TAU);ctx.fill();
}
ctx.restore();
// ---- band
if(P.owned.band&&P.equip.band){
ctx.strokeStyle='#e8ecf5';ctx.lineWidth=1.6;
ctx.shadowColor='#dfe8ff';ctx.shadowBlur=9;
ctx.beginPath();ctx.arc(9,0,3.2,0,TAU);ctx.stroke();
ctx.shadowBlur=0;
}
// ---- weapon arm
const total=W.wind+W.active+W.cool;
let ang=0.55;
if(P.atkCd>0){
const sp=clamp(P.atkT/total,0,1);
ang=lerp(-1.5,1.5,Math.pow(sp,.72));
}
const hx=Math.cos(ang)*3+6,hy=Math.sin(ang)*3+1;
ctx.strokeStyle='#cbb992';ctx.lineWidth=3.4;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(5,-4);ctx.lineTo(hx,hy);ctx.stroke();
ctx.save();
ctx.translate(hx,hy);ctx.rotate(ang-0.15);
const blen=P.weapon===2?50:(P.weapon===1?42:36);
if(P.weapon>0){
ctx.shadowColor=P.weapon===2?'rgba(255,214,120,.95)':'rgba(255,140,50,.9)';
ctx.shadowBlur=P.weapon===2?26:18;
}
ctx.fillStyle='#2b2f3a';ctx.fillRect(-8,-2.6,10,5.2);
const bg=ctx.createLinearGradient(0,-3,0,3);
if(P.weapon===2){bg.addColorStop(0,'#fffaf0');bg.addColorStop(.5,'#ffe08a');bg.addColorStop(1,'#e0a93c');}
else if(P.weapon===1){bg.addColorStop(0,'#ffe3b8');bg.addColorStop(.5,'#ff9a3c');bg.addColorStop(1,'#b8541a');}
else{bg.addColorStop(0,'#f2f0e6');bg.addColorStop(.5,'#c9c6b8');bg.addColorStop(1,'#7e7c72');}
ctx.fillStyle=bg;
ctx.beginPath();ctx.moveTo(2,-1.4);ctx.lineTo(blen*0.32,-4.6);ctx.lineTo(blen,-2.6);
ctx.lineTo(blen+7,0);ctx.lineTo(blen,2.6);ctx.lineTo(blen*0.32,4.6);ctx.lineTo(2,1.4);ctx.closePath();ctx.fill();
ctx.fillStyle='#4a3a24';ctx.fillRect(-12,-5,6,10);
ctx.restore();
// swing arc trail
if(P.atkCd>0&&(P.atkT-W.wind)>-0.02&&(P.atkT-W.wind)<W.active+.05){
const sp=clamp((P.atkT-W.wind)/(W.active+.05),0,1);
ctx.save();
ctx.globalCompositeOperation='lighter';
ctx.strokeStyle=P.weapon===2?'rgba(255,225,140,.6)':(P.weapon===1?'rgba(255,150,60,.55)':'rgba(240,240,235,.3)');
ctx.lineWidth=P.weapon===2?13:9;
ctx.beginPath();
const a0=lerp(-1.5,1.5,Math.pow(clamp(sp-.28,0,1),.7));
const a1=lerp(-1.5,1.5,Math.pow(sp,.72));
ctx.arc(6,1,W.reach*0.72,a0,a1);
ctx.stroke();
ctx.restore();
}
ctx.restore();
ctx.restore();
// damage flash
if(P.hitFlash>0){
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.globalAlpha=P.hitFlash*.5;
const g=ctx.createRadialGradient(P.x,P.y-4,2,P.x,P.y-4,52);
g.addColorStop(0,'rgba(255,70,50,.6)');g.addColorStop(1,'rgba(255,60,40,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(P.x,P.y-4,52,0,TAU);ctx.fill();
ctx.restore();
}
}
function tintEnemy(e,extra){
if(e.hit>0){ctx.save();ctx.globalCompositeOperation='lighter';ctx.globalAlpha=.5;
ctx.fillStyle='#ffb057';ctx.beginPath();ctx.ellipse(e.x,e.y,e.w*.7,e.h*.6,0,0,TAU);ctx.fill();ctx.restore();}
}
function enemyBody(e,col1,col2,w,h){
const g=ctx.createLinearGradient(0,e.y-h/2,0,e.y+h/2);
g.addColorStop(0,col1);g.addColorStop(1,col2);
ctx.fillStyle=g;
ctx.beginPath();
ctx.moveTo(e.x-w/2,e.y+h/2);
ctx.lineTo(e.x-w/2+2,e.y-h/3);
ctx.quadraticCurveTo(e.x,e.y-h/2-2,e.x+w/2-2,e.y-h/3);
ctx.lineTo(e.x+w/2,e.y+h/2);
ctx.closePath();ctx.fill();
ctx.fillStyle='rgba(0,0,0,.3)';
for(let i=0;i<4;i++)ctx.fillRect(e.x-w/2+3+((i*11)%(w-8)),e.y-h/4+((i*17)%(h*.5)),rnd(3,7),rnd(2,5));
}
function drawEnemy(e){
const st=ENEMY_STATS[e.type];
const f=e.face||1;
ctx.save();
if(e.frz>0){ctx.globalAlpha=.9;}
ctx.translate(e.x,e.y);
ctx.scale(f,1);
ctx.translate(-e.x,-e.y);
const t=e.anim;
if(e.type==='walker'){
shadowUnder(e.x,e.y+e.h/2,e.w*.6,.3);
ctx.fillStyle='#1e222c';
const sw=Math.sin(t*9)*4;
ctx.fillRect(e.x-11+sw,e.y+2,8,18);ctx.fillRect(e.x+3-sw,e.y+2,8,18);
enemyBody(e,'#4a505e','#22262f',e.w,e.h);
ctx.fillStyle='#2b303a';
ctx.beginPath();ctx.arc(e.x,e.y-16,9,0,TAU);ctx.fill();
ctx.fillStyle='#ff8c2e';ctx.shadowColor='#ff7a1e';ctx.shadowBlur=14;
ctx.fillRect(e.x+1,e.y-19,8,3.4);ctx.shadowBlur=0;
ctx.fillStyle='#5a6270';ctx.fillRect(e.x-16,e.y-8,10,16);
ctx.strokeStyle='#0e1218';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(e.x-18,e.y-22);ctx.lineTo(e.x-9,e.y-4);ctx.stroke();
}else if(e.type==='flyer'){
ctx.fillStyle='rgba(0,0,0,.12)';
ctx.beginPath();ctx.ellipse(e.x,e.y+70,16,4,0,0,TAU);ctx.fill();
const flap=Math.sin(t*10)*6;
ctx.strokeStyle='#3a4250';ctx.lineWidth=3;
for(const s of[-1,1]){
ctx.beginPath();ctx.moveTo(e.x+s*8,e.y-2);
ctx.quadraticCurveTo(e.x+s*24,e.y-14+flap,e.x+s*30,e.y+4);ctx.stroke();
ctx.beginPath();ctx.moveTo(e.x+s*8,e.y+2);
ctx.quadraticCurveTo(e.x+s*20,e.y+10-flap,e.x+s*26,e.y+14);ctx.stroke();
}
ctx.fillStyle='#333a46';
ctx.beginPath();ctx.arc(e.x,e.y-2,12,0,TAU);ctx.fill();
ctx.fillStyle='#1c2028';
ctx.beginPath();ctx.moveTo(e.x-11,e.y-6);ctx.lineTo(e.x+11,e.y-6);ctx.lineTo(e.x,e.y+15);ctx.closePath();ctx.fill();
ctx.fillStyle='#ff8c2e';ctx.shadowColor='#ff7a1e';ctx.shadowBlur=12;
ctx.beginPath();ctx.arc(e.x+3,e.y-3,3.4,0,TAU);ctx.fill();ctx.shadowBlur=0;
ctx.strokeStyle='rgba(140,150,170,.5)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(e.x,e.y+12);ctx.lineTo(e.x+Math.sin(t*3)*3,e.y+22);ctx.stroke();
}else if(e.type==='sentinel'){
shadowUnder(e.x,e.y+e.h/2,e.w*.6,.32);
ctx.fillStyle='#252a33';ctx.fillRect(e.x-13,e.y+6,10,20);ctx.fillRect(e.x+3,e.y+6,10,20);
enemyBody(e,'#565e6c','#262b34',e.w,e.h+10);
ctx.fillStyle='#2b303a';ctx.fillRect(e.x-13,e.y-30,26,16);
ctx.fillStyle='#ff8c2e';ctx.shadowColor='#ff7a1e';ctx.shadowBlur=16;
ctx.fillRect(e.x-8,e.y-24,16,3.6);ctx.shadowBlur=0;
// broken instrument
ctx.fillStyle='#8a6a34';
ctx.save();ctx.translate(e.x+14,e.y-6);ctx.rotate(-0.5);
ctx.beginPath();ctx.moveTo(0,-3);ctx.lineTo(26,-9);ctx.lineTo(26,9);ctx.lineTo(0,3);ctx.closePath();ctx.fill();
ctx.restore();
if(e.fireAnim>0){
ctx.globalAlpha=e.fireAnim*3;ctx.fillStyle='#a05cff';ctx.shadowColor='#a05cff';ctx.shadowBlur=18;
ctx.beginPath();ctx.arc(e.x+18,e.y-14,7,0,TAU);ctx.fill();ctx.shadowBlur=0;ctx.globalAlpha=1;
}
ctx.strokeStyle='#6a7280';ctx.lineWidth=2.6;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(e.x-8,e.y-8);ctx.lineTo(e.x-22,e.y+18);ctx.stroke();
}else if(e.type==='brute'){
shadowUnder(e.x,e.y+e.h/2,e.w*.6,.36);
ctx.fillStyle='#1e222c';
const sw=Math.sin(t*5)*5;
ctx.fillRect(e.x-18+sw,e.y+14,13,22);ctx.fillRect(e.x+5-sw,e.y+14,13,22);
enemyBody(e,'#5a4a44','#241d20',e.w,e.h);
ctx.fillStyle='#2b2620';
ctx.beginPath();ctx.arc(e.x,e.y-26,14,0,TAU);ctx.fill();
ctx.fillStyle='#ff6a1e';ctx.shadowColor='#ff6a1e';ctx.shadowBlur=18;
ctx.fillRect(e.x-8,e.y-31,16,4.4);ctx.shadowBlur=0;
ctx.fillStyle='#6a5a4a';
ctx.fillRect(e.x-30,e.y-18,14,32);
ctx.fillStyle='#3a332c';
ctx.save();ctx.translate(e.x+20,e.y-14);ctx.rotate(e.lunge>0?-0.9:0.5);
ctx.fillRect(0,-4,26,8);ctx.fillRect(22,-14,16,28);ctx.restore();
ctx.fillStyle='#2b2620';
for(let i=0;i<3;i++)ctx.beginPath(),ctx.arc(e.x-14+i*14,e.y-40,4,0,TAU),ctx.fill();
}else if(e.type==='guardian'){
shadowUnder(e.x,e.y+e.h/2,e.w*.62,.38);
ctx.fillStyle='#241c30';
ctx.beginPath();ctx.moveTo(e.x-26,e.y-24);ctx.quadraticCurveTo(e.x-38,e.y+30,e.x-22,e.y+44);
ctx.lineTo(e.x+22,e.y+44);ctx.quadraticCurveTo(e.x+38,e.y+30,e.x+26,e.y-24);ctx.closePath();ctx.fill();
ctx.fillStyle='#1e222c';
const sw=Math.sin(t*6)*6;
ctx.fillRect(e.x-19+sw,e.y+20,13,22);ctx.fillRect(e.x+6-sw,e.y+20,13,22);
enemyBody(e,'#655a72','#241f2c',e.w,e.h);
ctx.fillStyle='#2b2634';
ctx.beginPath();ctx.arc(e.x,e.y-32,15,0,TAU);ctx.fill();
ctx.fillStyle='#ff8c2e';ctx.shadowColor='#ff7a1e';ctx.shadowBlur=20;
ctx.fillRect(e.x-9,e.y-36,6,4);ctx.fillRect(e.x+3,e.y-36,6,4);ctx.shadowBlur=0;
ctx.fillStyle='#8a7a5c';
for(const s of[-1,1]){
ctx.save();ctx.translate(e.x+s*22,e.y-6);ctx.rotate(s*(0.5+Math.sin(t*4)*0.15));
ctx.fillRect(0,-3,34,6);ctx.beginPath();ctx.moveTo(34,-3);ctx.lineTo(44,0);ctx.lineTo(34,3);ctx.closePath();ctx.fill();
ctx.restore();
}
}else if(e.type==='boss'){
shadowUnder(e.x,e.y+e.h/2,e.w*.6,.42);
ctx.save();
ctx.translate(e.x,e.y);
// crown gears
for(let i=0;i<2;i++){
ctx.save();ctx.translate(-40+i*80,-64);
drawGear(ctx,0,0,22,t*(i?-0.9:0.9),'#4a2a1e',10);
ctx.restore();
}
ctx.fillStyle='#1a1a20';
const sw=Math.sin(t*4)*7;
ctx.fillRect(-34+sw,34,22,30);ctx.fillRect(12-sw,34,22,30);
enemyBody(e,'#5a3a2e','#1a1214',e.w,e.h);
ctx.fillStyle='#3a241c';
ctx.beginPath();ctx.arc(0,-52,26,0,TAU);ctx.fill();
ctx.fillStyle='#ff6a1e';ctx.shadowColor='#ff6a1e';ctx.shadowBlur=26;
ctx.fillRect(-16,-58,32,7);ctx.shadowBlur=0;
// molten core
const cp=.6+.4*Math.sin(t*3);
ctx.shadowColor='#ff8a2e';ctx.shadowBlur=30*cp;
ctx.fillStyle='#ffb457';
ctx.beginPath();ctx.arc(0,-4,12,0,TAU);ctx.fill();
ctx.shadowBlur=0;
// shield ward
if(e.shield){
ctx.save();
ctx.globalCompositeOperation='lighter';
const g=ctx.createRadialGradient(0,0,30,0,0,90);
g.addColorStop(0,'rgba(120,180,255,0)');
g.addColorStop(.75,'rgba(110,170,255,'+(.16+.06*Math.sin(t*3))+')');
g.addColorStop(1,'rgba(150,210,255,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(0,0,90,0,TAU);ctx.fill();
ctx.strokeStyle='rgba(160,220,255,'+(.35+.2*Math.sin(t*3))+')';ctx.lineWidth=2;
ctx.beginPath();ctx.arc(0,0,80,0,TAU);ctx.stroke();
ctx.restore();
}
ctx.restore();
}
if(e.frz>0){
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(120,210,255,.28)';
ctx.beginPath();ctx.ellipse(e.x,e.y,e.w*.8,e.h*.7,0,0,TAU);ctx.fill();
ctx.restore();
}
if(e.hit>0)tintEnemy(e);
ctx.restore();
}
function drawBolts(){
for(const b of bolts){
ctx.save();ctx.globalCompositeOperation='lighter';
if(b.kind==='shock'){
ctx.fillStyle='rgba(255,150,60,.6)';
ctx.beginPath();ctx.ellipse(b.x,b.y+10,14,26,0,0,TAU);ctx.fill();
ctx.fillStyle='rgba(255,220,150,.9)';
ctx.beginPath();ctx.ellipse(b.x,b.y+10,6,20,0,0,TAU);ctx.fill();
}else{
const col=b.team==='p'?'150,235,255':(b.kind==='corrupt'?'180,110,255':'255,150,70');
const g=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,20);
g.addColorStop(0,'rgba(255,255,255,.95)');g.addColorStop(.4,'rgba('+col+',.8)');g.addColorStop(1,'rgba('+col+',0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(b.x,b.y,20,0,TAU);ctx.fill();
ctx.fillStyle='#fff';
ctx.beginPath();ctx.ellipse(b.x,b.y,9,4,-Math.atan2(b.vy,b.vx),0,TAU);ctx.fill();
}
ctx.restore();
}
}
function drawPickups(){
const t=G.t;
for(const pk of Z.pickups){
if(pk.gone)continue;
const y=pk.y+Math.sin((pk.float||0))*2;
if(pk.kind==='gold'){
const sp=Math.abs(Math.cos(t*3+pk.x*.02));
ctx.save();
ctx.shadowColor='#ffcf5e';ctx.shadowBlur=12;
ctx.fillStyle='#ffd45e';
ctx.beginPath();ctx.ellipse(pk.x,y,5*sp+1.5,5,0,0,TAU);ctx.fill();
ctx.fillStyle='#fff0b0';
ctx.beginPath();ctx.ellipse(pk.x,y,2.4*sp+.7,2.6,0,0,TAU);ctx.fill();
ctx.restore();
}else{
const I=ITEMS[pk.kind==='item'?pk.item:pk.kind]||{color:'#fff'};
const col=pk.kind==='heart'?'#ff6b5e':I.color;
const big=pk.kind==='item'||pk.kind==='heart';
ctx.save();
ctx.globalCompositeOperation='lighter';
const r=big?30:18;
const g=ctx.createRadialGradient(pk.x,y,0,pk.x,y,r);
g.addColorStop(0,'rgba(255,255,255,.5)');g.addColorStop(.35,col.replace(')',',.5)').replace('#','rgba(').length?col:col);
ctx.globalAlpha=.30+.14*Math.sin(t*3);
ctx.fillStyle=col;
ctx.beginPath();ctx.arc(pk.x,y,r,0,TAU);ctx.fill();
ctx.restore();
ctx.save();
ctx.shadowColor=col;ctx.shadowBlur=18;
if(pk.kind==='heart'){
ctx.fillStyle=col;heartPath(ctx,pk.x,y-8,9);ctx.fill();
ctx.shadowBlur=0;ctx.fillStyle='#ffd0c6';heartPath(ctx,pk.x,y-9,4.5);ctx.fill();
}else{
ctx.fillStyle=col;
ctx.font=(big?'20px':'16px')+' Georgia';ctx.textAlign='center';
ctx.fillText(I.glyph||'◆',pk.x,y+6);
ctx.shadowBlur=0;
ctx.fillStyle='rgba(255,255,255,.85)';
ctx.font=(big?'20px':'16px')+' Georgia';
ctx.globalAlpha=.6;ctx.fillText(I.glyph||'◆',pk.x-1,y+5);ctx.globalAlpha=1;
}
ctx.restore();
if(big&&pk.kind==='item'){
ctx.save();ctx.font='10px Georgia';ctx.textAlign='center';
ctx.fillStyle='rgba(255,240,200,.75)';ctx.fillText((ITEMS[pk.item].name||'').toUpperCase(),pk.x,y-30);
ctx.restore();
}
}
}
}
function drawParticles(){
for(const p of parts){
const a=clamp(p.l/p.tl,0,1);
ctx.save();ctx.globalAlpha=a;
if(p.t==='smoke'){ctx.fillStyle=p.c;ctx.beginPath();ctx.arc(p.x,p.y,p.s,0,TAU);ctx.fill();}
else{
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=p.c;
ctx.beginPath();ctx.arc(p.x,p.y,p.s*(.4+a*.6),0,TAU);ctx.fill();
}
ctx.restore();
}
for(const f of floaters){
const a=clamp(f.l/f.tl,0,1);
ctx.save();ctx.globalAlpha=a;
ctx.font='bold '+f.s+'px Georgia';ctx.textAlign='center';
ctx.lineWidth=3;ctx.strokeStyle='rgba(0,0,0,.55)';
ctx.strokeText(f.txt,f.x,f.y);ctx.fillStyle=f.col;ctx.fillText(f.txt,f.x,f.y);
ctx.restore();
}
}
function drawAmbient(){
for(const a of AMB){
ctx.save();
if(a.t==='mist'){
const g=ctx.createRadialGradient(a.x,a.y,0,a.x,a.y,a.s);
g.addColorStop(0,'rgba(190,235,240,'+a.a+')');g.addColorStop(1,'rgba(190,235,240,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(a.x,a.y,a.s,0,TAU);ctx.fill();
}else if(a.t==='ember'){
ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(255,170,80,'+a.a+')';
ctx.beginPath();ctx.arc(a.x,a.y,a.s,0,TAU);ctx.fill();
}else{
ctx.fillStyle='rgba(230,215,180,'+a.a+')';
ctx.beginPath();ctx.arc(a.x,a.y,a.s,0,TAU);ctx.fill();
}
ctx.restore();
}
}
/* ---------------------------- HUD ---------------------------- */
function heartPath(g,x,y,r){
g.beginPath();
g.moveTo(x,y+r*.35);
g.bezierCurveTo(x,y-r*.12,x-r*.62,y-r*.12,x-r*.62,y+r*.3);
g.bezierCurveTo(x-r*.62,y+r*.78,x-r*.14,y+r*.98,x,y+r*1.28);
g.bezierCurveTo(x+r*.14,y+r*.98,x+r*.62,y+r*.78,x+r*.62,y+r*.3);
g.bezierCurveTo(x+r*.62,y-r*.12,x,y-r*.12,x,y+r*.35);
g.closePath();
}
function drawHUD(){
const t=G.t;
// hearts
for(let i=0;i<P.containers;i++){
const x=34+i*36,y=40;
const rem=clamp(P.hp-i*2,0,2);
ctx.save();
heartPath(ctx,x,y,13);
ctx.fillStyle='rgba(20,16,20,.6)';ctx.fill();
ctx.lineWidth=1.6;ctx.strokeStyle='rgba(230,190,130,.45)';ctx.stroke();
if(rem>0){
ctx.save();heartPath(ctx,x,y,13);ctx.clip();
const g=ctx.createLinearGradient(x-14,y-8,x+14,y+34);
g.addColorStop(0,'#ff8f7a');g.addColorStop(.5,'#f0402c');g.addColorStop(1,'#8c1a12');
ctx.fillStyle=g;
ctx.fillRect(x-16,y-10,rem===1?(16):(32),40);
ctx.fillStyle='rgba(255,225,200,.5)';
ctx.beginPath();ctx.ellipse(x-4,y+6,4,7,-0.5,0,TAU);ctx.fill();
ctx.restore();
ctx.save();ctx.globalCompositeOperation='lighter';ctx.globalAlpha=.18+.08*Math.sin(t*2+i);
ctx.fillStyle='#ff5a44';heartPath(ctx,x,y,13);ctx.fill();ctx.restore();
}
ctx.restore();
}
// gold
ctx.save();
ctx.shadowColor='#ffcf5e';ctx.shadowBlur=10;
ctx.fillStyle='#ffd45e';ctx.beginPath();ctx.ellipse(30,84,7,8,0,0,TAU);ctx.fill();
ctx.shadowBlur=0;ctx.fillStyle='#fff0b0';ctx.beginPath();ctx.ellipse(30,84,3,3.6,0,0,TAU);ctx.fill();
ctx.font='bold 19px Georgia';ctx.textAlign='left';ctx.fillStyle='#f0c469';
ctx.fillText(G.gold,46,91);
ctx.font='11px Georgia';ctx.fillStyle='rgba(210,190,150,.65)';
ctx.fillText('SIGILS',46+String(G.gold).length*10+8,90);
ctx.restore();
// ammo
if(P.hasShard){
ctx.save();
ctx.translate(30,120);
ctx.shadowColor='#b6f0ff';ctx.shadowBlur=12;
ctx.fillStyle='#dff8ff';
ctx.beginPath();ctx.moveTo(0,-9);ctx.lineTo(6,0);ctx.lineTo(0,9);ctx.lineTo(-6,0);ctx.closePath();ctx.fill();
ctx.shadowBlur=0;
ctx.font='bold 17px Georgia';ctx.fillStyle='#bfeaff';ctx.textAlign='left';
ctx.fillText(G.ammo,16,6);
ctx.font='10px Georgia';ctx.fillStyle='rgba(180,220,240,.6)';
ctx.fillText('AETHER',16+String(G.ammo).length*9+6,5);
ctx.restore();
}
// item chips (equipped accessories)
let cx0=0;
ctx.save();ctx.font='11px Georgia';ctx.textAlign='left';
for(const k of['band','lantern','cloak']){
if(!P.owned[k])continue;
const I=ITEMS[k];
const on=P.equip[k];
const w=ctx.measureText(I.name).width+30;
ctx.globalAlpha=on?1:.5;
ctx.fillStyle=on?'rgba(255,210,120,.18)':'rgba(255,255,255,.05)';
ctx.fillRect(20,148+cx0,w,20);
ctx.strokeStyle=on?I.color:'rgba(170,170,170,.5)';ctx.lineWidth=1;
ctx.strokeRect(20,148+cx0,w,20);
ctx.fillStyle=on?I.color:'#a8a49a';
ctx.font='12px Georgia';ctx.fillText(I.glyph,27,163+cx0);
ctx.fillStyle=on?'#f6e8c8':'#b8b2a2';
ctx.font='11px Georgia';ctx.fillText(I.name.toUpperCase(),43,163+cx0);
cx0+=25;
}
ctx.restore();
// consumables
ctx.save();ctx.textAlign='left';
const cons=[['stem','1'],['seal','2']];
let cy=0;
for(const[k,kk]of cons){
const I=ITEMS[k],n=P.items[k];
ctx.globalAlpha=n>0?1:.35;
ctx.fillStyle='rgba(255,255,255,.05)';
ctx.fillRect(20,232+cy,120,20);
ctx.strokeStyle=n>0?I.color:'rgba(150,150,150,.35)';ctx.lineWidth=1;
ctx.strokeRect(20,232+cy,120,20);
ctx.fillStyle=n>0?I.color:'#888';ctx.font='12px Georgia';
ctx.fillText(I.glyph+' '+I.name.split(' ')[0].toUpperCase(),27,247+cy);
ctx.fillStyle=n>0?'#e8dcb8':'#777';ctx.font='bold 12px Georgia';
ctx.fillText('×'+n+' ['+kk+']',100,247+cy);
cy+=25;
}
ctx.restore();
// zone banner
ctx.save();
ctx.textAlign='right';
ctx.font='13px Georgia';ctx.fillStyle='rgba(240,196,105,.95)';
const zname=Z.name.toUpperCase();
ctx.fillText(zname,VIEW.w-28,44);
ctx.font='11px Georgia';ctx.fillStyle='rgba(200,180,140,.6)';
ctx.fillText('ZONE '+Z.idx+' OF 6',VIEW.w-28,64);
// progress pips
for(let i=0;i<6;i++){
const px=VIEW.w-28-((5-i)*18);
ctx.beginPath();ctx.arc(px,78,4,0,TAU);
ctx.fillStyle=i===Z.idx-1?'#f0c469':(i<Z.idx-1?'rgba(240,196,105,.4)':'rgba(140,130,110,.25)');
ctx.fill();
}
// weapon
ctx.textAlign='right';ctx.font='12px Georgia';
ctx.fillStyle=WEAPONS[P.weapon].glow;ctx.shadowColor=WEAPONS[P.weapon].glow;ctx.shadowBlur=10;
ctx.fillText(WEAPONS[P.weapon].name,VIEW.w-28,104);
ctx.shadowBlur=0;
// weapon damage pips
ctx.fillStyle='rgba(230,210,170,.7)';ctx.font='10px Georgia';
ctx.fillText('DMG '+WEAPONS[P.weapon].dmg,VIEW.w-28,120);
// hearts count
ctx.fillStyle='rgba(200,180,140,.6)';ctx.font='11px Georgia';
ctx.fillText(P.containers+' VESSELS · '+P.hp+'/'+maxHP()+' HP',VIEW.w-28,138);
ctx.restore();
// interaction prompt
if(nearInt&&!P.dead){
const label=interactLabel(nearInt);
if(label){
const px=nearInt.x+(nearInt.w?nearInt.w/2:0)-CAM.x;
const py=nearInt.y-(nearInt.type==='save'?150:(nearInt.type==='shop'?130:(nearInt.type==='beacon'?250:90)))-CAM.y;
ctx.save();
ctx.textAlign='center';ctx.font='13px Georgia';
const w=ctx.measureText(label).width+64;
ctx.fillStyle='rgba(12,10,8,.78)';
ctx.fillRect(px-w/2,py-18,w,26);
ctx.strokeStyle='rgba(240,196,105,.6)';ctx.lineWidth=1;ctx.strokeRect(px-w/2,py-18,w,26);
ctx.fillStyle='#ffd071';ctx.font='bold 13px Georgia';
ctx.fillText('[E]',px-w/2+18,py);
ctx.fillStyle='#eaddbb';ctx.font='13px Georgia';
ctx.fillText(label,px-w/2+42,py);
ctx.restore();
}
}
// contextual hint
if(Z.hint){
const d=Math.hypot(P.x-Z.hint.x,P.y-Z.hint.y);
if(d<Z.hint.r&&!P.dead){
ctx.save();
ctx.globalAlpha=clamp((Z.hint.r-d)/60,0,1)*(.6+.4*Math.sin(t*3));
ctx.textAlign='center';ctx.font='14px Georgia';
ctx.fillStyle='#ffd071';
ctx.fillText(Z.hint.t,P.x-CAM.x,P.y-CAM.y-70);
ctx.restore();
}
}
// messages
ctx.save();ctx.textAlign='center';
let my=0;
for(let i=G.msgs.length-1;i>=0;i--){
const m=G.msgs[i];
const a=clamp(m.l/1.1,0,1);
ctx.globalAlpha=a;
ctx.font=(m.big?'bold 16px Georgia':'14px Georgia');
const yy=150+(G.msgs.length-1-i)*27;
const w=ctx.measureText(m.t).width+30;
ctx.fillStyle='rgba(8,8,10,.62)';
ctx.fillRect(VIEW.w/2-w/2,yy-17,w,24);
ctx.fillStyle=m.big?'#ffe0a0':'#dcd0b4';
ctx.fillText(m.t,VIEW.w/2,yy);
my++;
}
ctx.restore();
// controls hint
if(G.run<22&&Z.idx<=2){
ctx.save();ctx.globalAlpha=clamp((22-G.run)/6,0,1)*.65;
ctx.textAlign='center';ctx.font='12px Georgia';ctx.fillStyle='#c9bfa6';
ctx.fillText('A / D move · W or SPACE leap · J or CLICK swing · E interact · I satchel · [1] stem · [2] seal',VIEW.w/2,VIEW.h-26);
ctx.restore();
}
// vignette
const vg=ctx.createRadialGradient(VIEW.w/2,VIEW.h/2,VIEW.h*.32,VIEW.w/2,VIEW.h/2,VIEW.h*.95);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,.52)');
ctx.fillStyle=vg;ctx.fillRect(0,0,VIEW.w,VIEW.h);
// dark vault ambience
if(Z.idx===4&&!P.equip.lantern){
ctx.save();ctx.fillStyle='rgba(2,8,14,.34)';ctx.fillRect(0,0,VIEW.w,VIEW.h);
const lx=P.x-CAM.x,ly=P.y-CAM.y;
const g=ctx.createRadialGradient(lx,ly,20,lx,ly,260);
g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(2,8,14,.5)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(lx,ly,260,0,TAU);ctx.fill();
ctx.restore();
}
if(Z.idx===6&&!G.flags.bossDead){
ctx.save();ctx.globalAlpha=.10+.04*Math.sin(t*1.6);
ctx.fillStyle='#ff4a1e';ctx.fillRect(0,0,VIEW.w,VIEW.h);ctx.restore();
}
// damage flash
if(G.flash>0){
ctx.save();ctx.globalAlpha=G.flash*.75;
const g=ctx.createRadialGradient(VIEW.w/2,VIEW.h/2,VIEW.h*.2,VIEW.w/2,VIEW.h/2,VIEW.h);
g.addColorStop(0,'rgba(180,20,10,0)');g.addColorStop(1,'rgba(200,30,15,.95)');
ctx.fillStyle=g;ctx.fillRect(0,0,VIEW.w,VIEW.h);ctx.restore();
}
// shimmer on upgrade / vessel gain
if(G.shimmerT>0){
ctx.save();ctx.globalAlpha=clamp(G.shimmerT,0,1)*.75;
ctx.globalCompositeOperation='lighter';
const g1=ctx.createLinearGradient(0,0,0,110);
g1.addColorStop(0,'rgba(255,220,140,.85)');g1.addColorStop(1,'rgba(255,200,100,0)');
ctx.fillStyle=g1;ctx.fillRect(0,0,VIEW.w,110);
const g2=ctx.createLinearGradient(0,VIEW.h,0,VIEW.h-110);
g2.addColorStop(0,'rgba(255,220,140,.85)');g2.addColorStop(1,'rgba(255,200,100,0)');
ctx.fillStyle=g2;ctx.fillRect(0,VIEW.h-110,VIEW.w,110);
ctx.restore();
}
if(P.dead&&G.fade<.4){
ctx.save();ctx.globalAlpha=.4;
ctx.fillStyle='#000';ctx.fillRect(0,0,VIEW.w,VIEW.h);
ctx.globalAlpha=1;ctx.textAlign='center';ctx.fillStyle='#c0443a';
ctx.font='26px Georgia';ctx.fillText('THE CORROSION TAKES HOLD',VIEW.w/2,VIEW.h/2);
ctx.restore();
}
if(G.fade>0){
ctx.save();ctx.globalAlpha=clamp(G.fade,0,1);ctx.fillStyle='#04050a';
ctx.fillRect(0,0,VIEW.w,VIEW.h);ctx.restore();
}
}
function interactLabel(p){
switch(p.type){
case 'save':return 'LIGHT THE SANCTUARY STONE';
case 'shop':return 'TRADE WITH THE MERCHANT';
case 'altar':return P.weapon>=p.upgrade?'ALTAR (ALREADY ATTUNED)':'TAKE THE '+(p.upgrade===1?'IGNITED EDGE':'SOLAR FALCHION');
case 'chest':return p.looted?null:'OPEN '+(p.label||'THE CHEST');
case 'jar':return 'SMASH THE JAR';
case 'reliquary':return allHit(p.need)?'OPEN THE RELIQUARY':'RELIQUARY — SEALED';
case 'sign':return 'READ THE TABLET';
case 'beacon':return G.flags.bossDead?'REIGNITE THE CORE BEACON':'THE BEACON IS COLD';
default:return null;
}
}
/* ---------------------------- MASTER RENDER ---------------------------- */
function render(){
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,cvs.width,cvs.height);
const k=VIEW.k;
const sx=G.shake>0?rnd(-G.shake,G.shake):0,sy=G.shake>0?rnd(-G.shake,G.shake):0;
ctx.setTransform(k,0,0,k,(-CAM.x*k)+sx*k, (-CAM.y*k)+sy*k);
drawBackground();
drawPlatforms();
drawProps();
drawPickups();
for(const e of Z.enemies)if(!e.dead)drawEnemy(e);
drawBolts();
if(!P.dead)drawPlayer();
drawParticles();
drawAmbient();
ctx.setTransform(1,0,0,1,0,0);
ctx.save();
ctx.scale(VIEW.k,VIEW.k);
ctx.scale(1,1);
drawHUD();
ctx.restore();
}
/* =======================================================================
PERSISTENCE
======================================================================= */
const SAVE_KEY='wardens_corrosion_v1';
function saveData(){
return {v:1,zone:Z.idx,containers:P.containers,weapon:P.weapon,hasShard:P.hasShard,ammo:G.ammo,
gold:G.gold,items:P.items,owned:P.owned,equip:P.equip,flags:G.flags,save:G.save,kills:G.kills,run:G.run};
}
function persist(){
try{localStorage.setItem(SAVE_KEY,JSON.stringify(saveData()));}catch(e){}
}
function readSave(){
try{const s=localStorage.getItem(SAVE_KEY);return s?JSON.parse(s):null;}catch(e){return null;}
}
function applySave(d){
G.gold=d.gold||0;G.flags=d.flags||{};G.kills=d.kills||0;G.ammo=d.ammo||0;G.save=d.save||null;G.run=d.run||0;
P.containers=d.containers||3;P.hp=P.containers*2;P.weapon=d.weapon||0;P.hasShard=!!d.hasShard;
P.items=Object.assign({stem:0,seal:0},d.items||{});
P.owned=Object.assign({band:0,lantern:0,cloak:0},d.owned||{});
P.equip=Object.assign({band:false,lantern:false,cloak:false},d.equip||{});
}
/* =======================================================================
UI PANELS
======================================================================= */
function closeAllPanels(){
document.getElementById('inv').classList.add('hidden');
document.getElementById('shop').classList.add('hidden');
}
function toggleInv(){
if(G.state==='inv'){closeInv();return;}
if(G.state!=='play')return;
G.state='inv';renderInv();document.getElementById('inv').classList.remove('hidden');
}
function closeInv(){
if(G.state!=='inv')return;
G.state='play';document.getElementById('inv').classList.add('hidden');
}
function openShop(){
if(G.state!=='play')return;
G.state='shop';renderShop();document.getElementById('shop').classList.remove('hidden');
SFX.chime(false);
}
function closeShop(){
if(G.state!=='shop')return;
G.state='play';document.getElementById('shop').classList.add('hidden');
}
function itemRow(key,extra){
const I=ITEMS[key];
const eq=P.equip[key];
return '<div class="item'+(eq?' eq':'')+'">'+
'<div class="icon" style="color:'+I.color+';border-color:'+I.color+'55;background:'+I.color+'12">'+I.glyph+'</div>'+
'<div class="itxt"><div class="iname">'+I.name+' <span class="tag">'+I.tag+'</span></div>'+
'<div class="idesc">'+I.desc+'</div></div>'+(extra||'')+'</div>';
}
function renderInv(){
document.getElementById('invGold').textContent='◆ '+G.gold+' GOLDEN SIGILS';
const b=document.getElementById('invBody');
let h='';
const owned=['band','lantern','cloak'].filter(k=>P.owned[k]);
if(owned.length){
h+='<div class="hint" style="text-align:left;margin:0 0 8px">ACCESSORIES · toggled on the wrist, hand and cloak</div>';
for(const k of owned){
h+=itemRow(k,'<button class="btn buy" data-eq="'+k+'">'+(P.equip[k]?'UNEQUIP':'EQUIP')+'</button>');
}
}else{
h+='<div class="empty">No accessories yet. The Archives hide a band; the Vault hides a lantern.</div>';
}
h+='<div class="hint" style="text-align:left;margin:16px 0 8px">CONSUMABLES · spent on use</div>';
for(const k of['stem','seal']){
h+=itemRow(k,P.items[k]>0?'<button class="btn buy" data-use="'+k+'">USE</button>':'<span class="tag">NONE HELD</span>');
}
h+='<div class="hint" style="text-align:left;margin:16px 0 8px">ARMAMENT</div>';
const W=WEAPONS[P.weapon];
h+='<div class="item"><div class="icon" style="color:'+W.glow+';border-color:'+W.glow+'55;background:'+W.glow+'12">⚔</div>'+
'<div class="itxt"><div class="iname">'+W.name+' <span class="tag">Melee · '+W.dmg+' dmg</span></div>'+
'<div class="idesc">'+(P.weapon===0?'Unaltered steel. Reliable, unremarkable.':P.weapon===1?'Ember-wreathed: faster recovery, burning impacts, an ember trail while you walk.':'Solar-forged: slow wind-up, a wide sweep, and crushing solar impact that shimmers the screen edge.')+'</div></div></div>';
if(P.hasShard){
h+='<div class="item eq"><div class="icon" style="color:#b6f0ff;border-color:#b6f0ff55;background:#b6f0ff12">◆</div>'+
'<div class="itxt"><div class="iname">Aether Shard <span class="tag">Ranged · '+G.ammo+' charges</span></div>'+
'<div class="idesc">'+ITEMS.shard.desc+'</div></div></div>';
}else{
h+='<div class="item"><div class="icon" style="color:#5a6070;border-color:#5a607055">◆</div>'+
'<div class="itxt"><div class="iname" style="color:#8a8578">Aether Shard <span class="tag">Not yet found</span></div>'+
'<div class="idesc">Something crystalline hums behind a cracked wall in the Sunken Vault.</div></div></div>';
}
h+='<div class="hint" style="text-align:left;margin:16px 0 8px">RELICS</div>';
h+='<div class="item"><div class="icon" style="color:#ff6b5e;border-color:#ff6b5e55;background:#ff6b5e12">♥</div>'+
'<div class="itxt"><div class="iname">Heart Containers <span class="tag">'+P.containers+' of 5 vessels · '+maxHP()+' max HP</span></div>'+
'<div class="idesc">Each vessel holds 2 HP. Sanctuary Stones and Vital Stems restore what is spent.</div></div></div>';
b.innerHTML=h;
b.querySelectorAll('[data-eq]').forEach(el=>el.onclick=()=>{
const k=el.getAttribute('data-eq');
P.equip[k]=!P.equip[k];
SFX.chime(false);
msg(P.equip[k]?ITEMS[k].name+' equipped':ITEMS[k].name+' stowed');
renderInv();
});
b.querySelectorAll('[data-use]').forEach(el=>el.onclick=()=>{
useItem(el.getAttribute('data-use'));
setTimeout(renderInv,10);
if(G.state==='inv'&&!document.getElementById('inv').classList.contains('hidden'))renderInv();
closeInv();
});
}
function renderShop(){
document.getElementById('shopGold').textContent='◆ '+G.gold+' GOLDEN SIGILS IN HAND';
const b=document.getElementById('shopBody');
let h='';
SHOP_STOCK.forEach(it=>{
if(it.ammo&&!P.hasShard)return;
const sold=G.flags['shop:'+it.id]||0;
const left=it.max-sold;
const canAfford=G.gold>=it.price;
const sell=left>0&&canAfford;
h+='<div class="item'+(it.relic?' eq':'')+'">'+
'<div class="icon" style="color:'+(it.ammo?'#b6f0ff':(it.relic?'#ff6b5e':'#ffd45e'))+';border-color:#ffffff22">'+
(it.ammo?'◆':(it.relic?'♥':'◉'))+'</div>'+
'<div class="itxt"><div class="iname">'+it.name+' <span class="tag">'+it.price+' gold · '+(it.ammo?'always in stock':(left>0?left+' left':'SOLD OUT'))+'</span></div>'+
'<div class="idesc">'+it.desc+'</div></div>'+
'<button class="btn buy" data-buy="'+it.id+'"'+(sell?'':' disabled')+'>'+
(left<=0&&!it.ammo?'SOLD':'BUY')+'</button></div>';
});
h+='<div class="hint" style="text-align:left;margin-top:16px">SIGILS ARE DRAWN FROM CONSTRUCTS, BROKEN JARS AND HIDDEN CACHES. THE FIELDS PAY WELL IF YOU CLEAR THEM.</div>';
b.innerHTML=h;
b.querySelectorAll('[data-buy]').forEach(el=>el.onclick=()=>buyItem(el.getAttribute('data-buy')));
}
function buyItem(id){
const it=SHOP_STOCK.find(s=>s.id===id);if(!it)return;
const sold=G.flags['shop:'+id]||0;
if(!it.ammo&&sold>=it.max){SFX.deny();msg('The merchant has none left.');return;}
if(G.gold<it.price){SFX.deny();msg('Not enough sigils — the merchant only trusts coin.');return;}
G.gold-=it.price;G.flags['shop:'+id]=sold+1;
SFX.purchase();G.shimmerT=1.0;
if(it.ammo){G.ammo+=5;msg('Aether charges restocked — '+G.ammo+' in the shard');}
else if(id==='stem'){P.items.stem++;msg('Vital Stem purchased — quaff with [1]');}
else if(id==='seal'){P.items.seal++;msg('Binding Seal purchased — cast with [2]');}
else if(id==='heart'){
if(P.containers<5){
P.containers++;P.hp=maxHP();G.shimmerT=2.2;SFX.heart();
floater(P.x,P.y-40,'VESSEL EXPANDED','#ff9c8a',17);
msg('A FIFTH HEART CONTAINER IS FORGED — '+P.containers+' vessels, '+maxHP()+' HP',true);
for(let i=0;i<40;i++){const a=rnd(0,TAU);part({x:P.x,y:P.y,vx:Math.cos(a)*rnd(40,200),vy:Math.sin(a)*rnd(40,200)-40,
g:-40,l:rnd(.8,1.6),tl:1.6,c:pick(['#ff6b5e','#ffd0a0','#fff6e0']),s:rnd(2,5)});}
}
}
persist();
renderShop();
}
/* =======================================================================
FLOW / LOOP
======================================================================= */
function winGame(){
G.state='ending';G.victory=true;
SFX.beacon();
for(let i=0;i<120;i++){const a=rnd(0,TAU);part({x:P.x,y:P.y,vx:Math.cos(a)*rnd(50,400),vy:Math.sin(a)*rnd(50,400)-90,
g:-30,l:rnd(1,2.4),tl:2.4,c:pick(['#ffe9a8','#fff6dc','#ffd071','#ffffff']),s:rnd(2,6)});}
document.getElementById('endLore').innerHTML=
'The Beacon drinks the light of every crystal you lit and answers with a dawn of its own. '+
'The gears turn. The black-iron Corrosion recedes, hissing, down through the flooded vaults. '+
'Aurelion’s Observatory will drift another age — and the Warden who walked its ruin will be remembered as the last, and the first of a kinder silence.';
document.getElementById('endStat').innerHTML=
'<span>Zone reached</span><b>'+Z.idx+' / 6 — '+Z.name+'</b>'+
'<span>Heart containers</span><b>'+P.containers+' / 5</b>'+
'<span>Blade</span><b>'+WEAPONS[P.weapon].name+'</b>'+
'<span>Aether Shard</span><b>'+(P.hasShard?'claimed — '+G.ammo+' charges':'never found')+'</b>'+
'<span>Accessories</span><b>'+['band','lantern','cloak'].filter(k=>P.owned[k]).map(k=>ITEMS[k].name).join(', ')+'</b>'+
'<span>Golden Sigils</span><b>'+G.gold+'</b>'+
'<span>Constructs felled</span><b>'+G.kills+'</b>'+
'<span>Times reclaimed by rot</span><b>'+G.deaths+'</b>'+
'<span>Damage taken</span><b>'+G.damageTaken+' HP</b>';
document.getElementById('ending').classList.remove('hidden');
}
function startNew(){
SFX.init();
G.gold=0;G.flags={};G.kills=0;G.run=0;G.deaths=0;G.ammo=0;G.damageTaken=0;G.save=null;
P.containers=3;P.hp=6;P.weapon=0;P.hasShard=false;
P.items={stem:0,seal:0};P.owned={band:0,lantern:0,cloak:0};P.equip={band:false,lantern:false,cloak:false};
P.atkCd=0;P.atkT=0;P.invuln=0;P.dead=false;
document.getElementById('title').classList.add('hidden');
document.getElementById('ending').classList.add('hidden');
G.state='play';G.fade=0;G.fadeDir=0;G.pending=null;
loadZone(1,'right');
msg('The Observatory groans above the storm. The Corrosion is already inside.',true);
}
function startFromSave(d){
SFX.init();
applySave(d);
G.deaths=0;G.damageTaken=0;
document.getElementById('title').classList.add('hidden');
document.getElementById('ending').classList.add('hidden');
G.state='play';G.fade=0;G.fadeDir=0;G.pending=null;
loadZone(d.zone||1,'right',d.save?d.save.x:undefined);
msg('The Warden returns to '+ (ZONE_BUILDERS[d.zone||1]().name) +'.',true);
}
function update(dt){
G.t+=dt;
if(G.state==='play'){
G.run+=dt;
buildColliders();
updatePlayer(dt);
updateEnemies(dt);
updateBolts(dt);
updatePickups(dt);
updateProps(dt);
updateFX(dt);
findInteract();
updateFlow(dt);
updateCamera(dt);
for(let i=G.msgs.length-1;i>=0;i--){G.msgs[i].l-=dt;if(G.msgs[i].l<=0)G.msgs.splice(i,1);}
}else if(G.state==='title'){
CAM.x+=dt*26;
if(CAM.x>Z.width-VIEW.w)CAM.x=0;
updateFX(dt);
for(let i=G.msgs.length-1;i>=0;i--)G.msgs.splice(i,1);
}
}
let last=performance.now();
function frame(now){
const dt=Math.min(.034,Math.max(.001,(now-last)/1000));
last=now;
update(dt);
render();
requestAnimationFrame(frame);
}
/* =======================================================================
INPUT
======================================================================= */
window.addEventListener('keydown',e=>{
const k=e.key===' '?' ':e.key.toLowerCase();
if([' ','arrowup','arrowdown','arrowleft','arrowright','tab'].indexOf(k)>=0)e.preventDefault();
if(G.state==='ending'&&k==='enter')return;
if(keys[k])return;keys[k]=true;
if(G.state!=='play'&&G.state!=='title'){
if(k==='i'||k==='tab'||k==='escape'){closeAllPanels();G.state='play';}
return;
}
if(G.state==='title'){if(k==='enter'||k===' ')startNew();return;}
if(P.dead)return;
if(k==='j'||k==='x')startSwing();
if(k==='k'||k==='c')fireBolt();
if(k==='w'||k===' '||k==='arrowup')tryJump();
if(k==='e')interact();
if(k==='q'||k==='1')useItem('stem');
if(k==='r'||k==='2')useItem('seal');
if(k==='i'||k==='tab')toggleInv();
if(k==='escape'){closeAllPanels();}
});
window.addEventListener('keyup',e=>{const k=e.key===' '?' ':e.key.toLowerCase();keys[k]=false;});
cvs.addEventListener('mousedown',e=>{
if(G.state!=='play')return;
SFX.init();
if(e.button===0)startSwing();
if(e.button===2)fireBolt();
});
cvs.addEventListener('contextmenu',e=>e.preventDefault());
document.getElementById('btnNew').onclick=()=>startNew();
document.getElementById('btnAgain').onclick=()=>{document.getElementById('ending').classList.add('hidden');startNew();};
document.getElementById('btnCont').onclick=()=>{const d=readSave();if(!d){SFX.deny();return;}startFromSave(d);};
/* =======================================================================
BOOT
======================================================================= */
function boot(){
resize();
Z=ZONE_BUILDERS[1]();
let pi=0,ci=0;
for(const o of Z.props){if(o.id===undefined)o.id='p'+(pi++);o.zid='1:'+o.id;}
for(const o of Z.pickups)o.zid='1:pk_'+(ci++);
buildColliders();
CAM.x=210;
const d=readSave();
if(!d)document.getElementById('btnCont').disabled=true;
else document.getElementById('btnCont').textContent='CONTINUE — ZONE '+(d.zone||1);
window.addEventListener('resize',()=>{resize();});
requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"06578a75-8647-46f7-813d-c63fa030d733","tokensIn":13595721,"tokensOut":109692,"tokensTotal":13705413,"cost":0.13126205399999996,"turns":84,"toolCalls":83,"failedToolCalls":1,"timestamp":"2026-09-10T21:58:54.028Z"} -->