Warden
ox alpha · 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 51,574 bytes · SHA-256 09f53d811b8c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>The Warden's Corrosion</title>
<style>
:root{ --gold:#e8b64a; --ember:#f07b2e; --slate:#1a2230; --slate2:#232e42; --parch:#d9c9a3; }
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%; background:#0c1017; overflow:hidden;
font-family:"Georgia","Times New Roman",serif; color:var(--parch);}
#wrap{position:relative; width:960px; height:640px; margin:0 auto;}
canvas{display:block; background:#0c1017; image-rendering:auto;}
/* ---------- overlays ---------- */
.overlay{position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:rgba(8,10,16,.82); z-index:20;}
.overlay.show{display:flex;}
.panel{width:560px; max-height:560px; overflow-y:auto; background:linear-gradient(160deg,#20293c,#161d2b);
border:2px solid #4a3d24; border-radius:10px; padding:22px 26px;
box-shadow:0 0 40px rgba(232,182,74,.15), inset 0 0 60px rgba(0,0,0,.5);}
h1{font-size:34px; color:var(--gold); letter-spacing:3px; text-align:center; text-shadow:0 0 18px rgba(232,182,74,.5);}
h2{font-size:20px; color:var(--gold); margin-bottom:12px; letter-spacing:1px;}
p,li{font-size:14px; line-height:1.55; color:#cbbfa0;}
.btn{display:inline-block; margin:6px 6px 0 0; padding:9px 18px; cursor:pointer;
background:linear-gradient(#3a3323,#2a2517); border:1px solid #6b5a2e; border-radius:6px;
color:var(--gold); font-family:inherit; font-size:14px; letter-spacing:1px;}
.btn:hover{background:linear-gradient(#57492a,#38301b); box-shadow:0 0 12px rgba(232,182,74,.35);}
.btn:disabled{opacity:.4; cursor:default; box-shadow:none;}
.row{display:flex; justify-content:space-between; align-items:center; padding:9px 6px;
border-bottom:1px solid rgba(120,100,60,.25);}
.row .nm{color:#e6d9b8;} .row .ds{font-size:12px; color:#9a8f75;}
.tag{font-size:11px; padding:2px 8px; border-radius:10px; border:1px solid #6b5a2e; color:var(--gold);}
.tag.on{background:#6b5a2e; color:#fff2cf;}
#toast{position:absolute; left:50%; bottom:70px; transform:translateX(-50%); z-index:30;
pointer-events:none; text-align:center;}
.toastMsg{background:rgba(20,26,40,.92); border:1px solid #6b5a2e; color:#efe3c0; font-size:14px;
padding:8px 20px; border-radius:20px; margin-top:6px; opacity:0; transition:opacity .3s;
box-shadow:0 0 16px rgba(0,0,0,.6);}
.toastMsg.show{opacity:1;}
#hint{position:absolute; left:50%; top:586px; transform:translateX(-50%); z-index:15;
color:#cdbf9d; font-size:13px; letter-spacing:1px; text-shadow:0 1px 3px #000; pointer-events:none;}
#zonename{position:absolute; left:50%; top:110px; transform:translateX(-50%); z-index:15; pointer-events:none;
color:var(--gold); font-size:26px; letter-spacing:5px; text-shadow:0 0 20px rgba(232,182,74,.6);
opacity:0; transition:opacity 1s;}
kbd{background:#2a2517; border:1px solid #6b5a2e; border-radius:4px; padding:1px 7px; font-size:12px; color:var(--gold);}
.center{text-align:center;}
.small{font-size:12px; color:#8d8368;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="960" height="600"></canvas>
<div id="hint"></div>
<div id="zonename"></div>
<div id="toast"></div>
<!-- title -->
<div class="overlay show" id="ovTitle">
<div class="panel center">
<h1>THE WARDEN'S CORROSION</h1>
<p style="margin:14px 0;">Aurelion's Sunken Observatory drifts above the storm. The Corrosion spreads.<br>You are the Last Warden. Reignite the Core Beacon.</p>
<p class="small">Move <kbd>WASD</kbd> · Attack <kbd>J</kbd>/<kbd>Space</kbd> · Fire Shard <kbd>K</kbd> · Interact <kbd>E</kbd> · Inventory <kbd>I</kbd></p>
<div style="margin-top:18px;">
<button class="btn" id="btnNew">Begin the Vigil</button>
<button class="btn" id="btnCont" style="display:none;">Continue Pilgrimage</button>
</div>
</div>
</div>
<!-- inventory -->
<div class="overlay" id="ovInv"><div class="panel">
<h2>Warden's Satchel</h2>
<div id="invList"></div>
<h2 style="margin-top:16px;">Accessories</h2>
<div id="accList"></div>
<div class="center" style="margin-top:14px;"><button class="btn" onclick="closeOv('ovInv')">Close (I)</button></div>
</div></div>
<!-- shop -->
<div class="overlay" id="ovShop"><div class="panel">
<h2>Merchant Altar — Golden Sigils Accepted</h2>
<p class="small" id="shopGold"></p>
<div id="shopList"></div>
<div class="center" style="margin-top:14px;"><button class="btn" onclick="closeOv('ovShop')">Leave (Esc)</button></div>
</div></div>
<!-- ending -->
<div class="overlay" id="ovEnd"><div class="panel center">
<h1 style="font-size:28px;">THE BEACON BURNS AGAIN</h1>
<p style="margin:14px 0;" id="endText"></p>
<button class="btn" onclick="location.reload()">Begin Anew</button>
</div></div>
<!-- game over -->
<div class="overlay" id="ovDead"><div class="panel center">
<h1 style="font-size:26px; color:#b0533a;">THE CORROSION TAKES YOU</h1>
<p style="margin:14px 0;">Yet a Warden's duty outlasts the flesh.</p>
<button class="btn" id="btnRespawn">Rise at the last Sanctuary</button>
</div></div>
</div>
<script>
"use strict";
/* ============================================================
THE WARDEN'S CORROSION — single-file action adventure
============================================================ */
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = 960, H = 600;
/* ---------------- audio (WebAudio synth) ---------------- */
let AC = null;
function ac(){ if(!AC) AC = new (window.AudioContext||window.webkitAudioContext)(); return AC; }
function tone(f, dur, type='sine', vol=.15, delay=0, slide=0){
try{
const a=ac(), t=a.currentTime+delay;
const o=a.createOscillator(), g=a.createGain();
o.type=type; o.frequency.setValueAtTime(f,t);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30,f+slide), t+dur);
g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
o.connect(g).connect(a.destination); o.start(t); o.stop(t+dur+.02);
}catch(e){}
}
function noise(dur, vol=.12, delay=0, lp=800){
try{
const a=ac(), t=a.currentTime+delay, n=a.sampleRate*dur|0;
const b=a.createBuffer(1,n,a.sampleRate), d=b.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
const s=a.createBufferSource(); s.buffer=b;
const f=a.createBiquadFilter(); f.type='lowpass'; f.frequency.value=lp;
const g=a.createGain(); g.gain.setValueAtTime(vol,t); g.gain.exponentialRampToValueAtTime(.0001,t+dur);
s.connect(f).connect(g).connect(a.destination); s.start(t);
}catch(e){}
}
const SFX = {
step(){ noise(.08,.03,0,500); },
swing(){ tone(320,.09,'sawtooth',.06,0,-180); noise(.07,.05,0,2500); },
hit(){ noise(.08,.12,0,900); tone(140,.1,'square',.09,0,-60); },
hitFire(){ noise(.12,.11,0,700); tone(110,.16,'sine',.13,0,-50); tone(700,.1,'sawtooth',.04); },
hitSolar(){ tone(90,.4,'sine',.2,0,-40); tone(520,.25,'triangle',.1); noise(.15,.1,0,400); },
shardFire(){ tone(1300,.12,'triangle',.12,0,-500); tone(1900,.08,'sine',.06); },
shardHit(){ tone(2100,.2,'sine',.09,0,300); },
hurt(){ tone(90,.2,'sine',.22); tone(1750,.25,'sine',.07,.02,-300); noise(.12,.14,0,300); },
coin(){ tone(1150,.07,'square',.07); tone(1550,.1,'square',.07,.06); },
pickup(){ [523,659,784].forEach((f,i)=>tone(f,.16,'sine',.09,i*.07)); },
rare(){ [392,494,587,784].forEach((f,i)=>tone(f,.4,'sine',.09,i*.1)); tone(196,.7,'triangle',.08); },
heartGet(){ [523,659,784,1046].forEach((f,i)=>tone(f,.5,'triangle',.1,i*.12)); },
altar(){ tone(147,.9,'sawtooth',.07); tone(220,.9,'sine',.1,.1); tone(294,.9,'sine',.08,.2); noise(.5,.05,0,300); },
save(){ [330,415,494,659].forEach((f,i)=>tone(f,.45,'sine',.09,i*.11)); },
shopDrone(){ tone(98,1.2,'sine',.05); tone(147,1.2,'sine',.04); },
buy(){ tone(900,.08,'square',.08); tone(1350,.14,'sine',.1,.07); },
deny(){ tone(200,.15,'square',.08,0,-80); },
breakWall(){ noise(.5,.2,0,350); tone(70,.4,'sine',.15,0,-30); },
plate(){ tone(160,.15,'sine',.14,0,-60); noise(.1,.08,0,400); },
switchHit(){ tone(1567,.3,'sine',.1); tone(2093,.3,'sine',.06,.05); },
seal(){ tone(880,.5,'sine',.1,0,200); },
bossRoar(){ tone(60,1.2,'sawtooth',.16,0,-20); noise(.8,.1,0,200); },
beacon(){ [262,330,392,523,659,784].forEach((f,i)=>tone(f,1.4,'sine',.1,i*.16)); }
};
/* ---------------- input ---------------- */
const keys = {};
addEventListener('keydown', e=>{
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
keys[e.code]=true;
if(e.code==='KeyI') toggleInv();
if(e.code==='Escape'){ closeOv('ovShop'); closeOv('ovInv'); }
if(e.code==='KeyE') tryInteract();
});
addEventListener('keyup', e=>keys[e.code]=false);
/* ---------------- state ---------------- */
let G = null;
function newState(){
return {
zone:0, px:110, py:300, face:{x:1,y:0}, moving:false,
hp:6, maxHp:6, gold:0, weapon:0, hasShard:false, ammo:0,
vital:1, seal:0,
acc:{band:false, lantern:false, cloak:false},
owned:{band:false, lantern:false, cloak:false},
flags:{}, invuln:0, atkCd:0, swingT:0, swingDir:{x:1,y:0},
bolts:[], enemies:[], parts:[], texts:[], pickups:[], platesT:[],
stepT:0, shake:0, shimmer:0, deadT:0, ended:false, time:0
};
}
function saveGame(){
try{ localStorage.setItem('warden_save', JSON.stringify({
zone:G.zone, px:G.px, py:G.py, hp:G.hp, maxHp:G.maxHp, gold:G.gold, weapon:G.weapon,
hasShard:G.hasShard, ammo:G.ammo, vital:G.vital, seal:G.seal, acc:G.acc, owned:G.owned,
flags:pickFlags() })); }catch(e){}
}
function pickFlags(){ const f={}; for(const k in ZONES[G.zone].flags) f[k]=!!G.flags[k]; return f; }
/* ---------------- helpers ---------------- */
const rnd=(a,b)=>a+Math.random()*(b-a);
const dist=(a,b)=>Math.hypot(a.x-b.x, a.y-b.y);
function rectVsCircle(r,cx,cy,rad){
const nx=Math.max(r.x,Math.min(cx,r.x+r.w)), ny=Math.max(r.y,Math.min(cy,r.y+r.h));
return (cx-nx)**2+(cy-ny)**2 < rad*rad;
}
function toast(msg, dur=2600){
const t=document.getElementById('toast');
const d=document.createElement('div'); d.className='toastMsg'; d.textContent=msg;
t.appendChild(d); requestAnimationFrame(()=>d.classList.add('show'));
setTimeout(()=>{ d.classList.remove('show'); setTimeout(()=>d.remove(),400); },dur);
}
function openOv(id){ document.getElementById(id).classList.add('show'); }
function closeOv(id){ document.getElementById(id).classList.remove('show'); }
/* ============================================================
WORLD DATA — six zones
obj types: stone, altar, chest, jar, crack, plate, gate,
switch, shrine, merchant, exit, beacon
============================================================ */
const BORDER = ()=>[{x:0,y:0,w:W,h:18},{x:0,y:H-18,w:W,h:18},{x:0,y:0,w:18,h:H},{x:W-18,y:0,w:18,h:H}];
const ENEMY_DEFS = {
crawler :{hp:4, spd:1.55, r:13, dmg:1, gold:[5,9], col:'#5a6678', eye:'#e05a3a', name:'Corrupted Crawler'},
sentinel:{hp:11,spd:.85, r:17, dmg:2, gold:[10,15], col:'#6b5646', eye:'#f0a03a', name:'Rusted Sentinel'},
gallery :{hp:26,spd:.95, r:22, dmg:2, gold:[45,60], col:'#7a5a48', eye:'#ffb04a', name:'Gallery Warden-Construct', boss:true},
guardian:{hp:42,spd:1.0, r:24, dmg:2, gold:[70,90], col:'#4a4a66', eye:'#7ab0ff', name:'Spire Guardian', boss:true},
wraith :{hp:110,spd:1.15,r:28,dmg:2, gold:[0,0], col:'#20242e', eye:'#c04aff', name:'Corrosion Wraith', boss:true}
};
const ZONES = [
{ /* ---- 1. THE ASHEN THRESHOLD ---- */
name:'I · THE ASHEN THRESHOLD', dark:false, floor:'#2a2622', grid:'#332d25',
solids:[...BORDER(), {x:380,y:0,w:26,h:190},{x:380,y:410,w:26,h:190}],
objs:[
{t:'stone', x:170,y:300, id:'s1'},
{t:'chest', x:800,y:110, item:'vital'},
{t:'jar', x:430,y:130, gold:12}, {t:'jar', x:660,y:470, gold:10},
{t:'exit', x:W-14,y:300, w:36,h:110, to:1, sx:64, sy:300}
],
enemies:[[560,220,'crawler'],[640,360,'crawler'],[720,240,'crawler']],
texts:[[250,180,'The Corrosion gnaws at the bronze…'],[560,120,'Strike with J / SPACE'],[170,370,'A Sanctuary Stone — press E to rest & save']]
},
{ /* ---- 2. THE RUSTED ARCHIVES ---- */
name:'II · THE RUSTED ARCHIVES', dark:false, floor:'#262a30', grid:'#2f343c',
solids:[...BORDER(),
{x:340,y:0,w:22,h:210},{x:340,y:390,w:22,h:190}, // challenge frame
{x:560,y:0,w:22,h:230, gate:'z2clear'},{x:560,y:370,w:22,h:230, gate:'z2clear'}, // inner gate
{x:180,y:96,w:150,h:20, crack:true} // cracked wall -> band chest
],
objs:[
{t:'chest', x:255,y:52, item:'band', hidden:true},
{t:'jar', x:460,y:520, gold:14},
{t:'stone', x:660,y:300, id:'s2'},
{t:'merchant', x:790,y:160},
{t:'chest', x:880,y:480, gold:35},
{t:'exit', x:W-14,y:300, w:36,h:110, to:2, sx:64, sy:300}
],
enemies:[[420,180,'crawler'],[470,320,'crawler'],[280,420,'sentinel'],[430,470,'crawler']],
texts:[[300,300,'Defeat the constructs to unseal the archive'],[255,140,'Hairline cracks… the blade may break it']]
},
{ /* ---- 3. THE COLLAPSED GALLERY ---- */
name:'III · THE COLLAPSED GALLERY', dark:false, floor:'#2b2721', grid:'#37302a',
solids:[...BORDER(),
{x:430,y:0,w:24,h:225, gate:'z3door'},{x:430,y:375,w:24,h:225, gate:'z3door'},
{x:600,y:H-58,w:170,h:40, crack:true}
],
objs:[
{t:'plate', x:290,y:190, id:'pA'}, {t:'plate', x:290,y:410, id:'pB'},
{t:'altar', x:530,y:300, tier:1},
{t:'chest', x:685,y:H-105, item:'heart', hidden:true},
{t:'jar', x:200,y:520, gold:16},
{t:'exit', x:W-14,y:300, w:36,h:110, to:3, sx:64, sy:300}
],
enemies:[[330,300,'crawler'],[700,180,'sentinel'],[760,330,'gallery']],
texts:[[290,140,'Twin plates — press both in quick succession'],[530,350,'An ember sleeps upon this altar']]
},
{ /* ---- 4. THE SUNKEN VAULT ---- */
name:'IV · THE SUNKEN VAULT', dark:true, floor:'#20262b', grid:'#28313a',
solids:[...BORDER(),
{x:300,y:150,w:22,h:130, crack:true},
{x:640,y:230,w:22,h:140}
],
objs:[
{t:'chest', x:365,y:215, item:'shard', hidden:true},
{t:'switch', x:900,y:70, id:'v1'}, {t:'switch', x:900,y:530, id:'v2'}, {t:'switch', x:480,y:52, id:'v3'},
{t:'shrine', x:480,y:310, item:'lantern', reqFlag:'vaultSwitches'},
{t:'jar', x:150,y:500, gold:18},
{t:'exit', x:W-14,y:300, w:36,h:110, to:4, sx:64, sy:300, requires:'lantern',
lockMsg:'The far door drinks light. Something must reveal its seam.'}
],
enemies:[[520,450,'crawler'],[700,120,'sentinel'],[760,470,'crawler'],[560,180,'sentinel']],
texts:[[380,120,'Damp dark. Cracks hide in shadow…'],[480,270,'A shrine waits for three lights']]
},
{ /* ---- 5. THE TWIN SPIRE ---- */
name:'V · THE TWIN SPIRE', dark:false, floor:'#252231', grid:'#2f2b40',
solids:[...BORDER(),
{x:760,y:0,w:24,h:120, gate:'z5sw'},
{x:840,y:180,w:100,h:24},
{x:520,y:180,w:150,h:22, crack:true}
],
objs:[
{t:'stone', x:110,y:300, id:'s3'},
{t:'altar', x:480,y:420, tier:2},
{t:'chest', x:595,y:120, item:'cloak', hidden:true},
{t:'jar', x:250,y:150, item:'seal'},
{t:'chest', x:900,y:520, gold:120},
{t:'switch', x:900,y:60, id:'sp1'},
{t:'chest', x:890,y:90, item:'heart', hidden:true, reqFlag:'z5sw'},
{t:'exit', x:W-14,y:430, w:36,h:110, to:5, sx:64, sy:300, requires:'guardianDead',
lockMsg:'The Spire Guardian bars the ascent.'}
],
enemies:[[350,220,'sentinel'],[420,470,'crawler'],[640,350,'sentinel'],[700,480,'guardian'],[300,420,'crawler']],
texts:[[480,470,'The falchion altar hums with captured dawn'],[900,140,'A distant mechanism glints — out of reach of blades']]
},
{ /* ---- 6. THE CORE CHAMBER ---- */
name:'VI · THE CORE CHAMBER', dark:false, floor:'#1d1a24', grid:'#262133',
solids:[...BORDER()],
objs:[
{t:'beacon', x:850,y:300},
{t:'switch', x:900,y:70, id:'c1'}, {t:'switch', x:900,y:530, id:'c2'}
],
enemies:[[480,300,'wraith']],
texts:[[480,120,'THE CORROSION WEARS A FACE'],[850,360,'The Beacon — dark. Slay what feeds upon it.'],
[900,110,'Crystal switches may stun the Wraith']]
}
];
/* ============================================================
GAME LOGIC
============================================================ */
let paused=false;
function loadZone(i, sx, sy){
const Z=ZONES[i];
G.zone=i; if(sx!==undefined){G.px=sx; G.py=sy;}
G.bolts=[]; G.parts=[]; G.texts=[]; G.enemies=[];
G.platesT={}; G.curSwitches={};
G.objs = Z.objs.map(o=>Object.assign({}, o));
// spawn enemies not permanently dead
for(const [x,y,t] of Z.enemies){
const key='kill_'+i+'_'+x+'_'+y;
if(t==='wraith' && G.flags.wraithDead) continue;
if(t==='gallery' && G.flags.galleryDead) continue;
if(t==='guardian' && G.flags.guardianDead) continue;
G.enemies.push({x,y,type:t, ...ENEMY_DEFS[t], maxhp:ENEMY_DEFS[t].hp, flash:0, burn:0, frozen:0, key, wob:rnd(0,6)});
}
// zone entry banner
const zn=document.getElementById('zonename');
zn.textContent=Z.name; zn.style.opacity=1;
clearTimeout(zn._t); zn._t=setTimeout(()=>zn.style.opacity=0, 2600);
if(i===3) SFX.step();
}
function solidRects(){
const Z=ZONES[G.zone], out=[];
for(const s of Z.solids){
if(s.gate && G.flags[s.gate]) continue;
if(s.crack && G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]) continue;
out.push(s);
}
return out;
}
function collide(px,py,r){
for(const s of solidRects()) if(rectVsCircle(s,px,py,r)) return true;
return false;
}
function movePlayer(dx,dy){
const r=13;
let nx=G.px+dx, ny=G.py+dy;
if(!collide(nx,G.py,r)) G.px=nx;
if(!collide(G.px,ny,r)) G.py=ny;
}
function addParts(x,y,n,col,spd=2.4,life=.6,grav=0){
for(let i=0;i<n;i++){
const a=rnd(0,Math.PI*2), s=rnd(.3,1)*spd;
G.parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life,t:life,col,grav,r:rnd(1.5,3.5)});
}
}
function addText(x,y,str,col='#ffe9b0'){ G.texts.push({x,y,str,col,t:1}); }
/* -------- combat -------- */
function weaponStats(){
// [damage, cooldown(s), arc radius, arc width]
if(G.weapon===0) return {dmg:2, cd:.42, rad:52, arc:1.5, kind:'phys'};
if(G.weapon===1) return {dmg:3, cd:.28, rad:56, arc:1.7, kind:'fire'};
return {dmg:5, cd:.55, rad:78, arc:2.3, kind:'solar'};
}
function doAttack(){
if(G.atkCd>0 || paused) return;
const ws=weaponStats();
const cdMul = G.acc.band?0.82:1;
G.atkCd = ws.cd*cdMul; G.swingT=.18; G.swingDir={...G.face};
if(ws.kind==='phys') SFX.swing(); else if(ws.kind==='fire'){SFX.swing(); SFX.hitFire();} else SFX.hitSolar();
const cx=G.px+G.face.x*26, cy=G.py+G.face.y*26;
let hitAny=false;
for(const e of G.enemies){
if(e.hp<=0) continue;
const d=Math.hypot(e.x-G.px, e.y-G.py);
if(d < ws.rad+e.r){
const ang=Math.atan2(e.y-G.py, e.x-G.px), fa=Math.atan2(G.face.y,G.face.x);
let da=Math.abs(ang-fa); if(da>Math.PI) da=Math.PI*2-da;
if(da < ws.arc/2 || d<e.r+16){
hitAny=true; hurtEnemy(e, ws.dmg, ws.kind);
}
}
}
// break cracked walls
for(const s of ZONES[G.zone].solids){
if(!s.crack || G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]) continue;
if(rectVsCircle({x:s.x-14,y:s.y-14,w:s.w+28,h:s.h+28}, cx,cy, ws.rad)){
const hits=(G.flags._crackHits=G.flags._crackHits||{});
const k=G.zone+'_'+s.x+'_'+s.y;
hits[k]=(hits[k]||0)+(G.weapon===2?3:1);
addParts(s.x+s.w/2, s.y+s.h/2, 6, '#8a7a5c', 2);
if(hits[k]>=6){
G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]=true; SFX.breakWall();
addParts(s.x+s.w/2, s.y+s.h/2, 26, '#9a8a68', 3.4, .9);
G.shake=8; toast('The cracked wall crumbles!');
}
}
}
if(hitAny) G.shake=Math.max(G.shake, ws.kind==='solar'?5:3);
}
function hurtEnemy(e, dmg, kind){
e.hp-=dmg; e.flash=.15;
if(kind==='fire'){ e.burn=1.2; addParts(e.x,e.y,8,'#f07b2e',2.6); }
else if(kind==='solar'){ addParts(e.x,e.y,14,'#ffd76a',3.2,.8); }
else addParts(e.x,e.y,6,'#ffb060',2.2);
if(kind==='phys') SFX.hit();
addText(e.x+rnd(-8,8), e.y-e.r-6, dmg, kind==='solar'?'#ffe08a':'#ff9a5a');
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
e.hp=0;
addParts(e.x,e.y, e.boss?40:16, e.col, e.boss?4:2.8, .9);
addParts(e.x,e.y, 8, e.eye, 3, .7);
G.flags[e.key]=true;
if(e.type==='gallery') G.flags.galleryDead=true;
if(e.type==='guardian') G.flags.guardianDead=true;
if(e.type==='wraith'){ G.flags.wraithDead=true; }
if(e.gold[1]>0){
const g=Math.round(rnd(e.gold[0],e.gold[1]));
for(let i=0;i<Math.min(8,Math.ceil(g/5));i++)
G.pickups.push({x:e.x+rnd(-16,16), y:e.y+rnd(-16,16), t:'gold', v:Math.ceil(g/Math.min(8,Math.ceil(g/5))), t2:.9});
}
if(e.boss){ SFX.bossRoar(); G.shake=12;
toast(e.name+' collapses into rust and silence.');
}
checkKills();
}
function checkKills(){
if(G.zone===1 && !G.flags.z2clear && G.enemies.every(e=>e.hp<=0)){
G.flags.z2clear=true; SFX.altar(); G.shimmer=2;
toast('The archive gate grinds open. A merchant altar lies beyond.');
}
}
function fireShard(){
if(!G.hasShard || G.atkCd>0 || paused) return;
if(G.ammo<=0){ toast('The Aether Shard is dormant — no charges. (Shop restock: 10g)'); SFX.deny(); return; }
G.ammo--; G.atkCd=.35;
SFX.shardFire();
G.bolts.push({x:G.px+G.face.x*16, y:G.py+G.face.y*16, vx:G.face.x*7.5, vy:G.face.y*7.5, t:2.2});
addParts(G.px+G.face.x*16, G.py+G.face.y*16, 4, '#8ad0ff', 1.6, .3);
}
function hurtPlayer(dmg){
if(G.invuln>0 || G.hp<=0) return;
G.hp-=dmg; G.invuln=1.1; G.shake=7;
SFX.hurt();
addParts(G.px,G.py,14,'#d84040',3,.6);
addText(G.px, G.py-24, '-'+dmg, '#ff6a5a');
if(G.hp<=0){ G.hp=0; die(); }
}
function die(){
openOv('ovDead'); paused=true;
}
document.getElementById('btnRespawn').onclick=()=>{
closeOv('ovDead'); paused=false;
G.hp=G.maxHp; G.invuln=2;
// respawn at last activated stone (or zone start)
const st=G.lastStone; loadZone(G.zone, st?st.x+40:64, st?st.y:300);
};
/* -------- interaction -------- */
function nearestObj(){
let best=null, bd=52;
for(const o of G.objs){
const d=Math.hypot(o.x-G.px, o.y-G.py);
if(d<bd+ (o.w?Math.max(o.w,o.h)/2:0)){ bd=d; best=o; }
}
return best;
}
function tryInteract(){
if(paused) return;
const o=nearestObj(); if(!o) return;
const d=Math.hypot(o.x-G.px,o.y-G.py);
if(o.t==='stone' && d<60){
G.lastStone=o; G.hp=G.maxHp; SFX.save(); G.shimmer=2;
addParts(o.x,o.y,20,'#ffe9a0',2,.9);
toast('Sanctuary attuned. Wounds close. Progress saved.'); saveGame();
}
else if(o.t==='altar' && d<60){
const tier=o.tier;
if(G.weapon>=tier){ toast('The altar\'s ember has already passed into your blade.'); return; }
G.weapon=tier; SFX.altar(); G.shimmer=2; G.shake=6;
addParts(o.x,o.y,30, tier===1?'#f07b2e':'#ffd76a',3.4,1);
toast(tier===1 ? 'IGNITED EDGE — your blade burns! Swings faster, strikes with fire.'
: 'SOLAR FALCHION — dawn-forged! Heavier, wider, blinding arcs of light.');
saveGame();
}
else if(o.t==='chest' && d<52){
if(o.reqFlag && !G.flags[o.reqFlag]) return;
if(o.opened) return;
o.opened=true; G.flags['chest_'+G.zone+'_'+o.x]=true;
giveItem(o.item, o.gold, o.x, o.y);
}
else if(o.t==='jar' && d<46){
if(o.opened) return;
o.opened=true; G.flags['jar_'+G.zone+'_'+o.x]=true;
SFX.breakWall(); addParts(o.x,o.y,12,'#a09070',2.4,.6);
if(o.item) giveItem(o.item,0,o.x,o.y); else gainGold(o.gold,o.x,o.y);
}
else if(o.t==='shrine' && d<60){
if(o.taken) return;
if(!G.flags[o.reqFlag]){ toast('The shrine sleeps. Its three lights are dark.'); SFX.deny(); return; }
o.taken=true; giveItem(o.item,0,o.x,o.y);
}
else if(o.t==='merchant' && d<64){ openShop(); }
else if(o.t==='beacon' && d<70){
if(!G.flags.wraithDead){ toast('The Beacon is choked black. The Wraith must fall first.'); SFX.deny(); return; }
endGame();
}
}
function giveItem(item, gold, x, y){
if(gold){ gainGold(gold,x,y); return; }
SFX.rare(); G.shimmer=2;
addParts(x,y,22,'#ffffff',2.6,1);
if(item==='vital'){ G.vital++; toast('Vital Stem acquired — a green restorative (restores 4 HP).'); }
if(item==='seal'){ G.seal++; toast('Binding Seal acquired — freezes one foe for 3 seconds.'); }
if(item==='band'){ G.owned.band=true; toast("WARDEN'S BAND — a silver ring. Equip it to swing faster."); }
if(item==='lantern'){ G.owned.lantern=true; G.acc.lantern=true; toast('PHANTOM LANTERN — equipped. Hidden cracks and seams now glow blue.'); }
if(item==='cloak'){ G.owned.cloak=true; toast('SHADOW CLOAK — equip it to soften environmental harm.'); }
if(item==='shard'){
G.hasShard=true; G.ammo+=10;
toast('AETHER SHARD — a crystal launcher! Press K to fire bolts at distant mechanisms.');
}
if(item==='heart'){
G.maxHp+=2; G.hp=G.maxHp; SFX.heartGet(); G.shimmer=2;
addParts(G.px,G.py,26,'#ffd76a',3,1.1);
toast('HEART CONTAINER — your vitality grows! ('+(G.maxHp/2)+' containers)');
}
saveGame();
}
function gainGold(v,x,y){
G.gold+=v; SFX.coin();
for(let i=0;i<6;i++) G.pickups.push({x:x+rnd(-10,10),y:y+rnd(-10,10),t:'spark',t2:.8});
addText(x,y-14,'+'+v+'g','#ffd76a');
}
/* -------- switches / plates / puzzles -------- */
function hitSwitch(sw){
if(G.flags['sw_'+G.zone+'_'+sw.id]) return;
G.flags['sw_'+G.zone+'_'+sw.id]=true;
SFX.switchHit(); addParts(sw.x,sw.y,14,'#8ad0ff',2.6,.8);
const Z=ZONES[G.zone];
const allOn = Z.objs.filter(o=>o.t==='switch').every(o=>G.flags['sw_'+G.zone+'_'+o.id]);
if(G.zone===3 && allOn){ G.flags.vaultSwitches=true; toast('Three lights burn — the shrine awakens.'); SFX.altar(); }
if(G.zone===4 && allOn){ G.flags.z5sw=true; toast('A distant mechanism turns. Stone grinds aside near the spire wall.'); SFX.altar(); }
if(G.zone===5){
G.flags['c_'+sw.id]=true;
const wr=G.enemies.find(e=>e.type==='wraith');
if(wr && wr.hp>0){ wr.frozen=3; addParts(wr.x,wr.y,20,'#c04aff',3,.8); toast('The Wraith seizes — crystal light binds it!'); SFX.seal(); }
}
}
function updatePlates(dt){
const Z=ZONES[G.zone]; if(G.zone!==2) return;
const plates=Z.objs.filter(o=>o.t==='plate');
for(const p of plates){
const on = Math.hypot(p.x-G.px,p.y-G.py)<26;
const k='pl_'+p.id;
if(on && !G.flags[k]){ G.flags[k]=true; SFX.plate(); addParts(p.x,p.y,6,'#c9a45a',1.6,.5); }
if(!on && G.flags[k] && !G.flags.z3door){
// decay quickly so "quick succession" matters
G.platesT[k]=(G.platesT[k]||0)+dt;
if(G.platesT[k]>1.4){ G.flags[k]=false; G.platesT[k]=0; }
}
}
if(!G.flags.z3door && plates.every(p=>G.flags['pl_'+p.id])){
G.flags.z3door=true; SFX.altar(); G.shimmer=2; G.shake=5;
toast('The gallery gate rumbles open!');
}
}
function useSeal(){
if(G.seal<=0){ toast('No Binding Seals remain.'); SFX.deny(); return; }
let tgt=null,bd=180;
for(const e of G.enemies){ if(e.hp<=0) continue; const d=Math.hypot(e.x-G.px,e.y-G.py); if(d<bd){bd=d;tgt=e;} }
if(!tgt){ toast('No construct near enough to bind.'); return; }
G.seal--; tgt.frozen=3; SFX.seal();
addParts(tgt.x,tgt.y,16,'#6ab0ff',2.4,.8);
toast('Binding Seal spent — '+tgt.name+' is frozen!');
}
/* -------- pickups -------- */
function updatePickups(dt){
for(const p of G.pickups){
p.t2-=dt;
const d=Math.hypot(p.x-G.px,p.y-G.py);
if(p.t==='gold' && d<90){ p.x+=(G.px-p.x)*4*dt; p.y+=(G.py-p.y)*4*dt; }
if(d<20){
if(p.t==='gold'){ G.gold+=p.v; SFX.coin(); addText(p.x,p.y-10,'+'+p.v+'g','#ffd76a'); }
p.t2=-1;
}
}
G.pickups=G.pickups.filter(p=>p.t2>0);
}
/* -------- shop -------- */
const SHOP=[
{id:'vital', nm:'Vital Stem', ds:'Restores 4 HP (2 containers).', cost:50, max:3, get:()=>G.vital, buy:()=>G.vital++},
{id:'seal', nm:'Binding Seal', ds:'Freezes one construct for 3s.', cost:75, max:2, get:()=>G.seal, buy:()=>G.seal++},
{id:'heart5',nm:'Heart Container', ds:'A forged vessel. +1 container, permanently.', cost:300, max:1,
avail:()=>G.maxHp<10, buy:()=>{G.maxHp+=2;G.hp=G.maxHp;SFX.heartGet();G.shimmer=2;}},
{id:'ammo', nm:'Aether Charge', ds:'One bolt for the Aether Shard.', cost:10, max:99,
avail:()=>G.hasShard && G.flags.z4done, buy:()=>G.ammo++}
];
function openShop(){
SFX.shopDrone(); renderShop(); openOv('ovShop'); paused=true;
}
function renderShop(){
document.getElementById('shopGold').textContent='Your purse: '+G.gold+' golden sigils';
const el=document.getElementById('shopList'); el.innerHTML='';
for(const s of SHOP){
if(s.avail && !s.avail()) continue;
const bought=G.flags['shop_'+s.id]||0;
const left=s.max-bought;
const row=document.createElement('div'); row.className='row';
row.innerHTML=`<div><div class="nm">${s.nm} <span class="small">(${left} left)</span></div><div class="ds">${s.ds}</div></div>`;
const b=document.createElement('button'); b.className='btn'; b.textContent=s.cost+'g';
b.disabled = left<=0 || G.gold<s.cost;
b.onclick=()=>{
if(G.gold<s.cost){SFX.deny();return;}
G.gold-=s.cost; s.buy(); G.flags['shop_'+s.id]=(G.flags['shop_'+s.id]||0)+1;
SFX.buy(); toast(s.nm+' purchased.'); renderShop(); saveGame();
};
row.appendChild(b); el.appendChild(row);
}
}
/* -------- inventory -------- */
function toggleInv(){
const ov=document.getElementById('ovInv');
if(ov.classList.contains('show')){ closeOv('ovInv'); paused=false; return; }
if(paused) return;
renderInv(); openOv('ovInv'); paused=true;
}
function renderInv(){
const il=document.getElementById('invList'); il.innerHTML='';
const cons=[
{nm:'Vital Stem', n:G.vital, ds:'Restores 4 HP.', use:()=>{ if(G.hp>=G.maxHp){toast('Your hearts already burn full.');return false;} G.vital--; G.hp=Math.min(G.maxHp,G.hp+4); SFX.pickup(); addParts(G.px,G.py,12,'#7ade7a',2,.7); toast('Vital Stem used. (+4 HP)'); }},
{nm:'Binding Seal', n:G.seal, ds:'Freezes the nearest construct (3s).', use:()=>{ useSeal(); }}
];
for(const c of cons){
const row=document.createElement('div'); row.className='row';
row.innerHTML=`<div><div class="nm">${c.nm} <span class="tag">${c.n}</span></div><div class="ds">${c.ds}</div></div>`;
const b=document.createElement('button'); b.className='btn'; b.textContent='Use';
b.disabled=c.n<=0;
b.onclick=()=>{ if(c.use()===false) return; renderInv(); saveGame(); };
row.appendChild(b); il.appendChild(row);
}
const al=document.getElementById('accList'); al.innerHTML='';
const accs=[
{k:'band', nm:"Warden's Band", ds:'Slightly faster attacks. Silver ring on the wrist.', owned:G.owned.band},
{k:'lantern', nm:'Phantom Lantern', ds:'Reveals cracks & hidden seams with a blue glow.', owned:G.owned.lantern},
{k:'cloak', nm:'Shadow Cloak', ds:'Reduces environmental harm. Dark cloth trim.', owned:G.owned.cloak}
];
for(const a of accs){
const row=document.createElement('div'); row.className='row';
row.innerHTML=`<div><div class="nm">${a.nm}</div><div class="ds">${a.ds}</div></div>`;
if(a.owned){
const b=document.createElement('button'); b.className='btn';
b.textContent=G.acc[a.k]?'Equipped':'Equip';
if(G.acc[a.k]) b.classList.add('on');
b.onclick=()=>{ G.acc[a.k]=!G.acc[a.k]; SFX.pickup(); renderInv(); saveGame(); };
row.appendChild(b);
} else {
const s=document.createElement('span'); s.className='small'; s.textContent='Not yet found'; row.appendChild(s);
}
al.appendChild(row);
}
}
function endGame(){
G.ended=true; paused=true; SFX.beacon();
document.getElementById('endText').innerHTML =
`The Corrosion Wraith unravels into motes. You press your palm to the cold lens,<br>
and dawn floods the Sunken Observatory from core to spire.<br><br>
<span class="small">Containers: ${G.maxHp/2} · Sigils: ${G.gold} · Blade tier: ${['Ember Blade','Ignited Edge','Solar Falchion'][G.weapon]}</span>`;
openOv('ovEnd');
try{ localStorage.removeItem('warden_save'); }catch(e){}
}
/* -------- main update -------- */
let last=0;
function update(dt){
G.time+=dt;
if(paused) return;
G.atkCd=Math.max(0,G.atkCd-dt); G.invuln=Math.max(0,G.invuln-dt);
G.swingT=Math.max(0,G.swingT-dt); G.shake=Math.max(0,G.shake-dt*20); G.shimmer=Math.max(0,G.shimmer-dt);
// movement
let dx=(keys.KeyD||keys.ArrowRight?1:0)-(keys.KeyA||keys.ArrowLeft?1:0);
let dy=(keys.KeyS||keys.ArrowDown?1:0)-(keys.KeyW||keys.ArrowUp?1:0);
G.moving = !!(dx||dy);
if(G.moving){
const l=Math.hypot(dx,dy); dx/=l; dy/=l;
G.face={x:dx,y:dy};
movePlayer(dx*2.6, dy*2.6);
G.stepT-=dt;
if(G.stepT<=0){ G.stepT=.34; SFX.step();
if(G.weapon===1) addParts(G.px,G.py+10,1,'#f07b2e',.6,.4);
if(G.weapon===2) addParts(G.px,G.py+10,1,'#ffd76a',.6,.5);
}
}
if(keys.Space||keys.KeyJ) doAttack();
if(keys.KeyK) fireShard();
// ember trail for ignited blade while swinging
if(G.swingT>0 && G.weapon>=1)
addParts(G.px+G.face.x*24, G.py+G.face.y*24, 1, G.weapon===2?'#ffd76a':'#f07b2e',1.2,.35);
// enemies
for(const e of G.enemies){
if(e.hp<=0) continue;
e.flash=Math.max(0,e.flash-dt);
if(e.burn>0){ e.burn-=dt; e.burnTick=(e.burnTick||0)-dt;
if(e.burnTick<=0){ e.burnTick=.4; e.hp-=1; addParts(e.x,e.y,3,'#f07b2e',1.4,.4);
addText(e.x,e.y-e.r-4,1,'#f07b2e'); if(e.hp<=0){killEnemy(e); continue;} } }
if(e.frozen>0){ e.frozen-=dt; continue; }
const d=Math.hypot(G.px-e.x,G.py-e.y);
if(d<340){
const a=Math.atan2(G.py-e.y,G.px-e.x);
const wob=Math.sin(G.time*3+e.wob)*.4;
const mx=Math.cos(a+wob)*e.spd, my=Math.sin(a+wob)*e.spd;
if(!collide(e.x+mx,e.y,e.r)) e.x+=mx;
if(!collide(e.x,e.y+my,e.r)) e.y+=my;
if(d < e.r+14) hurtPlayer(e.dmg);
}
}
// bolts
for(const b of G.bolts){
b.t-=dt; b.x+=b.vx; b.y+=b.vy;
if(Math.random()<.5) G.parts.push({x:b.x,y:b.y,vx:0,vy:0,life:.3,t:.3,col:'#8ad0ff',r:2});
for(const e of G.enemies){
if(e.hp>0 && Math.hypot(e.x-b.x,e.y-b.y)<e.r+6){
b.t=-1; SFX.shardHit(); hurtEnemy(e,3,'aether'); break;
}
}
for(const sw of G.objs){ if(sw.t==='switch' && Math.hypot(sw.x-b.x,sw.y-b.y)<20){ b.t=-1; hitSwitch(sw); } }
for(const s of ZONES[G.zone].solids){
if(s.crack && !G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y] && rectVsCircle(s,b.x,b.y,4)){
b.t=-1; addParts(b.x,b.y,6,'#8ad0ff',2,.4);
const hits=(G.flags._crackHits=G.flags._crackHits||{}); const k=G.zone+'_'+s.x+'_'+s.y;
hits[k]=(hits[k]||0)+2;
if(hits[k]>=6){ G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]=true; SFX.breakWall();
addParts(s.x+s.w/2,s.y+s.h/2,24,'#9a8a68',3,.9); toast('The shard shatters the cracked wall!'); }
}
}
if(collide(b.x,b.y,4)) b.t=-1;
}
G.bolts=G.bolts.filter(b=>b.t>0);
updatePlates(dt); updatePickups(dt);
// particles / texts
for(const p of G.parts){ p.t-=dt; p.x+=p.vx; p.y+=p.vy; p.vy+=(p.grav||0)*dt*60; p.vx*=.96; p.vy*=.96; }
G.parts=G.parts.filter(p=>p.t>0);
for(const t of G.texts){ t.t-=dt; t.y-=28*dt; }
G.texts=G.texts.filter(t=>t.t>0);
// exits
const Z=ZONES[G.zone];
for(const o of G.objs){
if(o.t!=='exit') continue;
if(Math.abs(G.px-o.x)<20 && Math.abs(G.py-o.y)<(o.h/2)){
if(o.requires && !G.flags[o.requires] && !(o.requires==='lantern'&&G.owned.lantern)){
if(!G._lockT||G.time-G._lockT>2){ G._lockT=G.time; toast(o.lockMsg||'The way is sealed.'); SFX.deny(); }
continue;
}
if(G.zone===3){ G.flags.z4done=true; }
SFX.step(); loadZone(o.to, o.sx, o.sy); saveGame(); break;
}
}
// ambient particles
if(Math.random()<.06){
if(G.zone===1) G.parts.push({x:rnd(20,W-20),y:rnd(20,H-20),vx:rnd(-.2,.2),vy:rnd(-.35,-.1),life:2.5,t:2.5,col:'#6a6048',r:1.6});
if(G.zone===3) G.parts.push({x:rnd(20,W-20),y:H-20,vx:rnd(-.15,.15),vy:rnd(-.6,-.25),life:3,t:3,col:'#5a7a8a',r:2.2});
}
}
/* ============================================================
RENDERING
============================================================ */
function drawFloor(Z){
ctx.fillStyle=Z.floor; ctx.fillRect(0,0,W,H);
ctx.strokeStyle=Z.grid; ctx.lineWidth=1;
for(let x=0;x<W;x+=48){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
for(let y=0;y<H;y+=48){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
// vignette
const g=ctx.createRadialGradient(W/2,H/2,200,W/2,H/2,620);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,.5)');
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
}
function drawSolids(){
for(const s of solidRects()){
if(s.gate){
ctx.fillStyle='#3a3226'; ctx.fillRect(s.x,s.y,s.w,s.h);
ctx.fillStyle='#57492e';
for(let y=s.y;y<s.y+s.h;y+=18) ctx.fillRect(s.x+3,y+4,s.w-6,4);
continue;
}
if(s.crack && !G.flags['brk_'+G.zone+'_'+s.x+'_'+s.y]){
ctx.fillStyle='#443c2e'; ctx.fillRect(s.x,s.y,s.w,s.h);
ctx.strokeStyle='rgba(20,14,8,.9)'; ctx.lineWidth=1.4;
ctx.beginPath();
const horiz=s.w>s.h;
for(let i=0;i<4;i++){
const f=(i+.7)/4.4;
if(horiz){ const cx=s.x+s.w*f; ctx.moveTo(cx-8,s.y+2); ctx.lineTo(cx+5,s.y+s.h*.5); ctx.lineTo(cx-6,s.y+s.h-2); }
else{ const cy=s.y+s.h*f; ctx.moveTo(s.x+2,cy-8); ctx.lineTo(s.x+s.w*.5,cy+5); ctx.lineTo(s.x+s.w-2,cy-6); }
}
ctx.stroke();
if(G.acc.lantern){ ctx.strokeStyle='rgba(110,190,255,.55)'; ctx.lineWidth=2; ctx.strokeRect(s.x+1,s.y+1,s.w-2,s.h-2); }
continue;
}
ctx.fillStyle='#171a1f'; ctx.fillRect(s.x,s.y,s.w,s.h);
ctx.fillStyle='#242a33'; ctx.fillRect(s.x+2,s.y+2,s.w-4,s.h-4);
// rivets
ctx.fillStyle='#3b4552';
for(let x=s.x+10;x<s.x+s.w-6;x+=34) for(let y=s.y+9;y<s.y+s.h-4;y+=Math.max(18,s.h-16))
ctx.fillRect(x,y,3,3);
}
}
function drawObj(o){
const t=G.time;
switch(o.t){
case 'stone':{
const lit = G.lastStone===o;
ctx.fillStyle='#3d4048'; ctx.fillRect(o.x-16,o.y-8,32,26);
ctx.fillStyle='#4d525e'; ctx.fillRect(o.x-11,o.y-30,22,24);
const gl=.5+.5*Math.sin(t*2.4);
ctx.fillStyle=`rgba(255,225,140,${lit?.85:.35+.3*gl})`;
ctx.beginPath(); ctx.arc(o.x,o.y-36,7+gl*2,0,7); ctx.fill();
ctx.fillStyle=lit?'#ffe9a0':'#c9b070'; ctx.fillRect(o.x-4,o.y-42,8,10);
if(Math.random()<.05) G.parts.push({x:o.x+rnd(-12,12),y:o.y,vx:0,vy:-.4,life:1.2,t:1.2,col:'rgba(255,230,160,.7)',r:1.5});
break;}
case 'altar':{
const done=G.weapon>=o.tier;
const col=o.tier===1?'#f07b2e':'#ffd76a';
ctx.fillStyle='#3a352c'; ctx.fillRect(o.x-24,o.y-6,48,20);
ctx.fillStyle='#4a4436'; ctx.fillRect(o.x-16,o.y-22,32,18);
const gl=.5+.5*Math.sin(t*(done?1.2:3));
ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=done?8:18*gl;
ctx.fillStyle=col; ctx.globalAlpha=done?.5:.5+.4*gl;
ctx.beginPath(); ctx.moveTo(o.x,o.y-46); ctx.lineTo(o.x+7,o.y-28); ctx.lineTo(o.x-7,o.y-28); ctx.fill();
ctx.restore(); ctx.globalAlpha=1;
break;}
case 'chest':{
if(o.opened){
ctx.fillStyle='#4a3d24'; ctx.fillRect(o.x-15,o.y-10,30,18);
ctx.fillStyle='#221b0e'; ctx.fillRect(o.x-11,o.y-6,22,10);
} else {
const hid=o.hidden && !G.flags['chest_'+G.zone+'_'+o.x] && Math.hypot(o.x-G.px,o.y-G.py)>120 && !G.acc.lantern;
if(hid) break;
ctx.fillStyle='#5a4726'; ctx.fillRect(o.x-15,o.y-12,30,22);
ctx.fillStyle='#6e5830'; ctx.fillRect(o.x-15,o.y-12,30,8);
ctx.fillStyle=G.acc.lantern?'rgba(110,190,255,.9)':'#c9a45a'; ctx.fillRect(o.x-3,o.y-6,6,8);
}
break;}
case 'jar':{
if(o.opened) break;
ctx.fillStyle='#6a5640'; ctx.beginPath(); ctx.ellipse(o.x,o.y-8,9,11,0,0,7); ctx.fill();
ctx.fillStyle='#7d6850'; ctx.fillRect(o.x-4,o.y-22,8,6);
break;}
case 'plate':{
const on=G.flags['pl_'+o.id];
ctx.fillStyle=on?'#8a6a30':'#5a4a28'; ctx.fillRect(o.x-20,o.y-14,40,28);
ctx.strokeStyle='#c9a45a'; ctx.lineWidth=2; ctx.strokeRect(o.x-20,o.y-14,40,28);
ctx.fillStyle='rgba(0,0,0,.25)';
if(on) ctx.fillRect(o.x-16,o.y-10,32,20);
break;}
case 'switch':{
const on=G.flags['sw_'+G.zone+'_'+o.id];
const gl=.5+.5*Math.sin(t*3);
ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=on?4:14*gl;
ctx.fillStyle=on?'#4a7080':'#8ad0ff';
ctx.beginPath(); ctx.moveTo(o.x,o.y-12); ctx.lineTo(o.x+8,o.y); ctx.lineTo(o.x,o.y+12); ctx.lineTo(o.x-8,o.y); ctx.fill();
ctx.restore();
break;}
case 'shrine':{
if(G.flags.vaultSwitches && !o.taken){
const gl=.5+.5*Math.sin(t*2.5);
ctx.save(); ctx.shadowColor='#bfe6ff'; ctx.shadowBlur=20*gl;
ctx.fillStyle='#bfe6ff'; ctx.beginPath(); ctx.arc(o.x,o.y-14,8+gl*2,0,7); ctx.fill(); ctx.restore();
} else { ctx.fillStyle='#39424c'; ctx.fillRect(o.x-12,o.y-10,24,18); }
break;}
case 'merchant':{
const d=Math.hypot(o.x-G.px,o.y-G.py);
if(d<180 && Math.random()<.03) SFX.shopDrone&&null;
ctx.fillStyle='#3a3323'; ctx.fillRect(o.x-26,o.y-4,52,18);
ctx.fillStyle='#4d4430'; ctx.fillRect(o.x-18,o.y-16,36,14);
const gl=.5+.5*Math.sin(t*1.8);
ctx.save(); ctx.shadowColor='#ffd76a'; ctx.shadowBlur=14*gl;
ctx.fillStyle='#ffd76a'; ctx.beginPath(); ctx.arc(o.x,o.y-24,5+gl*1.5,0,7); ctx.fill(); ctx.restore();
ctx.fillStyle='#e8b64a'; ctx.font='11px Georgia'; ctx.textAlign='center';
ctx.fillText('MERCHANT ALTAR', o.x, o.y+30);
break;}
case 'beacon':{
const on=G.flags.wraithDead;
ctx.fillStyle='#333042'; ctx.fillRect(o.x-20,o.y-10,40,60);
ctx.fillStyle='#454158'; ctx.fillRect(o.x-13,o.y-50,26,42);
if(on){
const gl=.6+.4*Math.sin(t*4);
ctx.save(); ctx.shadowColor='#ffe9a0'; ctx.shadowBlur=60*gl;
ctx.fillStyle='#fff2cf'; ctx.beginPath(); ctx.arc(o.x,o.y-58,12+gl*5,0,7); ctx.fill(); ctx.restore();
if(Math.random()<.3) G.parts.push({x:o.x+rnd(-14,14),y:o.y-58,vx:rnd(-.5,.5),vy:rnd(-2,-.6),life:1.4,t:1.4,col:'#ffe9a0',r:2});
} else {
ctx.fillStyle='#15131c'; ctx.beginPath(); ctx.arc(o.x,o.y-58,10,0,7); ctx.fill();
ctx.strokeStyle='#3a2f4a'; ctx.stroke();
}
break;}
case 'exit':{
const locked=o.requires && !G.flags[o.requires] && !(o.requires==='lantern'&&G.owned.lantern);
ctx.fillStyle=locked?'#2a2020':'#101720'; ctx.fillRect(o.x-8,o.y-o.h/2,16,o.h);
ctx.fillStyle=locked?'#4a3030':'#1d2a38';
for(let y=o.y-o.h/2+6;y<o.y+o.h/2-6;y+=16) ctx.fillRect(o.x-5,y,10,10);
const gl=.5+.5*Math.sin(t*2);
ctx.fillStyle=`rgba(232,182,74,${locked?.15:.25+.2*gl})`;
ctx.fillRect(o.x-8,o.y-o.h/2,16,o.h);
break;}
}
}
function drawEnemy(e){
if(e.hp<=0) return;
const bob=Math.sin(G.time*4+e.wob)*2;
ctx.save();
if(e.frozen>0){ ctx.filter='saturate(.4) brightness(1.2)';
ctx.strokeStyle='rgba(106,176,255,.8)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(e.x,e.y,e.r+6,0,7); ctx.stroke(); }
ctx.translate(e.x, e.y+bob*.3);
if(e.flash>0){ ctx.shadowColor='#fff'; ctx.shadowBlur=14; }
// body
ctx.fillStyle=e.col;
ctx.beginPath(); ctx.arc(0,0,e.r,0,7); ctx.fill();
ctx.fillStyle='rgba(0,0,0,.3)';
ctx.fillRect(-e.r*.7, e.r*.2, e.r*1.4, e.r*.5);
// eye
ctx.fillStyle=e.eye; ctx.shadowColor=e.eye; ctx.shadowBlur=8;
ctx.beginPath(); ctx.arc(0,-e.r*.25,e.r*.28,0,7); ctx.fill();
ctx.shadowBlur=0;
// rust streaks
ctx.strokeStyle='rgba(90,50,30,.5)'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-e.r*.5,-e.r*.6); ctx.lineTo(-e.r*.2,e.r*.5);
ctx.moveTo(e.r*.4,-e.r*.4); ctx.lineTo(e.r*.6,e.r*.4); ctx.stroke();
// hp bar for bosses
if(e.boss){
ctx.shadowBlur=0;
ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(-30,-e.r-14,60,6);
ctx.fillStyle='#c04aff'; ctx.fillRect(-30,-e.r-14,60*e.hp/e.maxhp,6);
}
ctx.restore();
}
function drawPlayer(){
const x=G.px, y=G.py, f=G.face;
ctx.save();
if(G.invuln>0 && Math.floor(G.time*14)%2===0) ctx.globalAlpha=.45;
// shadow
ctx.fillStyle='rgba(0,0,0,.35)'; ctx.beginPath(); ctx.ellipse(x,y+13,12,5,0,0,7); ctx.fill();
// ember trail upgrade1
if(G.weapon>=1 && G.moving){
ctx.fillStyle=G.weapon===2?'rgba(255,215,106,.35)':'rgba(240,123,46,.3)';
ctx.beginPath(); ctx.arc(x-f.x*8,y-f.y*8+8,5,0,7); ctx.fill();
}
// cloak
ctx.fillStyle = G.acc.cloak ? '#2c2c38' : '#3d4a5e';
ctx.beginPath(); ctx.moveTo(x-11,y+12); ctx.quadraticCurveTo(x,y+16,x+11,y+12);
ctx.lineTo(x+7,y-4); ctx.lineTo(x-7,y-4); ctx.fill();
if(G.acc.cloak){ ctx.strokeStyle='#57576e'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(x-11,y+10); ctx.quadraticCurveTo(x,y+15,x+11,y+10); ctx.stroke(); }
// body
ctx.fillStyle='#4e5f78'; ctx.fillRect(x-8,y-8,16,17);
// head / helm
ctx.fillStyle='#6a7a92'; ctx.fillRect(x-6,y-19,12,12);
ctx.fillStyle='#22282f'; ctx.fillRect(x-6+f.x*3,y-15,4,2); // visor glint toward facing
// shoulder clasp for accessories
if(G.acc.band||G.acc.cloak){ ctx.fillStyle='#c9a45a'; ctx.fillRect(x-9,y-8,4,4); ctx.fillRect(x+5,y-8,4,4); }
// weapon
const sw = G.swingT>0 ? Math.sin((0.18-G.swingT)/0.18*Math.PI)*1.4 : 0;
const wa = Math.atan2(f.y,f.x)+sw-.5;
const wx=x+Math.cos(wa)*16, wy=y+Math.sin(wa)*16;
ctx.save();
if(G.weapon===1){ ctx.shadowColor='#f07b2e'; ctx.shadowBlur=12; ctx.strokeStyle='#ff9a4a'; }
else if(G.weapon===2){ ctx.shadowColor='#ffd76a'; ctx.shadowBlur=18; ctx.strokeStyle='#ffe9a0'; }
else { ctx.strokeStyle='#b8c4d4'; }
ctx.lineWidth=G.weapon===2?5:3.5;
ctx.beginPath(); ctx.moveTo(x+Math.cos(wa)*6, y+Math.sin(wa)*6);
ctx.lineTo(wx+Math.cos(wa)*(G.weapon===2?16:11), wy+Math.sin(wa)*(G.weapon===2?16:11)); ctx.stroke();
ctx.restore();
// off-hand shard
if(G.hasShard){
const px2=x-f.x*10 - f.y*8, py2=y-f.y*10 + f.x*8;
const gl=.6+.4*Math.sin(G.time*3);
ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=10*gl;
ctx.fillStyle='#bfe6ff'; ctx.beginPath();
ctx.moveTo(px2,py2-5); ctx.lineTo(px2+4,py2); ctx.lineTo(px2,py2+5); ctx.lineTo(px2-4,py2); ctx.fill();
ctx.restore();
}
// damage flash ring
if(G.invuln>.8){ ctx.strokeStyle='rgba(216,64,64,.7)'; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(x,y,20,0,7); ctx.stroke(); }
ctx.restore();
}
function drawHUD(){
// hearts
for(let i=0;i<G.maxHp/2;i++){
const hx=30+i*34, hy=30, full=G.hp>=(i+1)*2, half=!full && G.hp===i*2+1;
drawHeart(hx,hy, full?'#d84a4a':half?'#a04040':'#3a3038', full||half);
if(half){ ctx.save(); ctx.beginPath(); ctx.rect(hx-14,hy-12,14,26); ctx.clip();
drawHeart(hx,hy,'#3a3038',true); ctx.restore(); }
}
// gold & gear
ctx.font='15px Georgia'; ctx.textAlign='left';
ctx.fillStyle='#ffd76a'; ctx.fillText('◉ '+G.gold+' sigils', 30, 68);
const wname=['Ember Blade','Ignited Edge','Solar Falchion'][G.weapon];
ctx.fillStyle=G.weapon===2?'#ffe9a0':G.weapon===1?'#ff9a4a':'#b8c4d4';
ctx.fillText('⚔ '+wname, 170, 68);
if(G.hasShard){ ctx.fillStyle='#8ad0ff'; ctx.fillText('◆ Aether Shard ×'+G.ammo+' [K]', 340, 68); }
// consumable quick count
ctx.fillStyle='#7ade7a'; ctx.fillText('✿ '+G.vital, 30, 88);
ctx.fillStyle='#6ab0ff'; ctx.fillText('❉ '+G.seal, 80, 88);
// zone progress
ctx.textAlign='right'; ctx.fillStyle='rgba(217,201,163,.65)'; ctx.font='12px Georgia';
ctx.fillText(ZONES[G.zone].name, W-26, 30);
if(G.shimmer>0){
const a=Math.min(1,G.shimmer)* .5*(0.6+0.4*Math.sin(G.time*10));
ctx.strokeStyle=`rgba(255,225,150,${a})`; ctx.lineWidth=8;
ctx.strokeRect(4,4,W-8,H-8);
}
}
function drawHeart(x,y,col,fillMode){
ctx.save(); ctx.translate(x,y); ctx.scale(1.15,1.15);
ctx.beginPath();
ctx.moveTo(0,10);
ctx.bezierCurveTo(-13,0,-11,-11,-4,-11); ctx.bezierCurveTo(-1,-11,0,-8,0,-7);
ctx.bezierCurveTo(0,-8,1,-11,4,-11); ctx.bezierCurveTo(11,-11,13,0,0,10);
ctx.closePath();
if(fillMode){ ctx.fillStyle=col; ctx.fill(); }
ctx.strokeStyle='#181218'; ctx.lineWidth=1.5; ctx.stroke();
ctx.restore();
}
function render(){
const Z=ZONES[G.zone];
ctx.clearRect(0,0,W,H);
ctx.save();
if(G.shake>0) ctx.translate(rnd(-G.shake,G.shake), rnd(-G.shake,G.shake));
drawFloor(Z);
// objects below entities
for(const o of G.objs) if(['plate','exit'].includes(o.t)) drawObj(o);
drawSolids();
for(const o of G.objs) if(!['plate','exit'].includes(o.t)) drawObj(o);
for(const e of G.enemies) drawEnemy(e);
// pickups
for(const p of G.pickups){
const b=Math.sin(G.time*6+p.x)*2;
if(p.t==='gold'){ ctx.fillStyle='#ffd76a'; ctx.shadowColor='#ffd76a'; ctx.shadowBlur=6;
ctx.beginPath(); ctx.arc(p.x,p.y+b,4,0,7); ctx.fill(); ctx.shadowBlur=0; }
}
// bolts
for(const b of G.bolts){
ctx.save(); ctx.shadowColor='#8ad0ff'; ctx.shadowBlur=12;
ctx.fillStyle='#dff2ff'; ctx.beginPath(); ctx.arc(b.x,b.y,4.5,0,7); ctx.fill(); ctx.restore();
}
drawPlayer();
// particles
for(const p of G.parts){
ctx.globalAlpha=Math.max(0,p.t/p.life);
ctx.fillStyle=p.col; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,7); ctx.fill();
}
ctx.globalAlpha=1;
// darkness (zone 4)
if(Z.dark){
const rad = G.acc.lantern? 260 : 130;
const dk=ctx.createRadialGradient(G.px,G.py,rad*.4,G.px,G.py,rad);
dk.addColorStop(0,'rgba(4,8,12,0)'); dk.addColorStop(1,'rgba(4,8,12,.93)');
ctx.fillStyle=dk; ctx.fillRect(0,0,W,H);
}
// floating texts
ctx.font='bold 13px Georgia'; ctx.textAlign='center';
for(const t of G.texts){
ctx.globalAlpha=Math.min(1,t.t*2); ctx.fillStyle=t.col;
ctx.fillText(t.str,t.x,t.y);
}
ctx.globalAlpha=1;
ctx.restore();
drawHUD();
// interaction hint
const o=nearestObj();
const hint=document.getElementById('hint');
let h='';
if(o && !paused){
const d=Math.hypot(o.x-G.px,o.y-G.py);
const labels={stone:'E — Rest at Sanctuary Stone', altar:o.tier===1?'E — Ignite the Blade':'E — Claim the Solar Falchion',
chest:(o.item==='heart'?'E — Open · Heart Container!':'E — Open the chest'), jar:'E — Break the jar',
merchant:'E — Trade with the Merchant Altar', beacon: G.flags.wraithDead?'E — Reignite the Core Beacon':'The Beacon lies dark…'};
if(labels[o.t] && d<(o.t==='merchant'?64:o.t==='stone'?60:o.t==='beacon'?70:52)) h=labels[o.t];
}
hint.textContent=h;
}
/* ---------------- boot ---------------- */
function startGame(fromSave){
ac(); // unlock audio
G=newState();
if(fromSave){
try{
const s=JSON.parse(localStorage.getItem('warden_save'));
Object.assign(G,{zone:s.zone,px:s.px,py:s.py,hp:s.hp,maxHp:s.maxHp,gold:s.gold,
weapon:s.weapon,hasShard:s.hasShard,ammo:s.ammo,vital:s.vital,seal:s.seal,
acc:s.acc,owned:s.owned});
for(const k in s.flags) G.flags[k]=s.flags[k];
G.hp=Math.max(2,G.hp);
}catch(e){}
}
closeOv('ovTitle'); closeOv('ovEnd'); closeOv('ovDead'); closeOv('ovInv'); closeOv('ovShop');
loadZone(G.zone, G.px, G.py);
toast('Zone I — walk with WASD. Strike with J or SPACE.',3600);
}
document.getElementById('btnNew').onclick=()=>startGame(false);
document.getElementById('btnCont').onclick=()=>startGame(true);
try{ if(localStorage.getItem('warden_save')) document.getElementById('btnCont').style.display='inline-block'; }catch(e){}
function loop(ts){
const dt=Math.min(.05,(ts-last)/1000||.016); last=ts;
if(G) { update(dt); render(); }
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"d2662be8-e684-43c1-a30b-93714c3525db","tokensIn":971476,"tokensOut":27623,"tokensTotal":999099,"cost":0,"turns":27,"toolCalls":26,"failedToolCalls":0,"timestamp":"2026-08-21T22:56:23.850Z"} -->