Warden
union 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 83,767 bytes · SHA-256 d3240bf8abc4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Warden's Corrosion</title>
<style>
:root{
--gold:#e8b45a; --gold2:#f5d38a; --ember:#ff9a3c; --ember2:#ffcf7a;
--slate:#101725; --slate2:#0a0f1a; --ink:#05080f;
--parch:#e9d9b6; --dim:#8a94a6; --red:#e0503c; --green:#7fc98a; --blue:#6fb8d8;
--panel:rgba(9,13,22,.9);
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
background:var(--ink); color:var(--parch); overflow:hidden;
font-family:Georgia,'Times New Roman',serif;
-webkit-user-select:none; user-select:none;
}
#game{position:fixed;inset:0}
canvas{display:block;position:absolute;inset:0}
#fx{pointer-events:none}
/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:20}
#hearts{position:absolute;top:16px;left:18px;display:flex;gap:6px;align-items:flex-end}
.heart{width:30px;height:28px;position:relative;filter:drop-shadow(0 2px 4px rgba(0,0,0,.6))}
.heart svg{width:100%;height:100%}
.heart .hfill{transform-origin:50% 60%}
#goldHud{position:absolute;top:16px;right:18px;text-align:right}
#goldHud .amt{font-size:22px;color:var(--gold2);letter-spacing:1px;text-shadow:0 0 12px rgba(232,180,90,.5)}
#goldHud .lbl{font-size:10px;letter-spacing:3px;color:var(--dim);text-transform:uppercase}
#zoneHud{position:absolute;top:14px;left:50%;transform:translateX(-50%);text-align:center;opacity:.85}
#zoneHud .z{font-size:13px;letter-spacing:4px;text-transform:uppercase;color:var(--gold2)}
#zoneHud .n{font-size:10px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;margin-top:2px}
#ammoHud{position:absolute;top:52px;right:18px;text-align:right;color:var(--blue);font-size:12px;letter-spacing:2px;text-shadow:0 0 8px rgba(111,184,216,.6)}
#weaponHud{position:absolute;bottom:14px;left:18px;font-size:11px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
#weaponHud b{color:var(--gold2);font-weight:normal;letter-spacing:3px}
#hintHud{position:absolute;bottom:14px;right:18px;font-size:11px;color:var(--dim);letter-spacing:1px;text-align:right}
#hintHud b{color:var(--parch)}
#prompt{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);
font-size:14px;letter-spacing:1px;color:var(--gold2);text-shadow:0 0 10px rgba(0,0,0,.9);
background:var(--panel);border:1px solid rgba(232,180,90,.35);padding:7px 16px;border-radius:3px;
opacity:0;transition:opacity .18s}
#prompt.show{opacity:1}
#prompt b{color:#fff}
#toastWrap{position:absolute;left:50%;top:70px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;align-items:center}
.toast{background:var(--panel);border:1px solid rgba(232,180,90,.4);border-left:3px solid var(--gold);
padding:8px 18px;font-size:13px;letter-spacing:1px;color:var(--parch);border-radius:2px;
animation:toastIn .3s ease, toastOut .5s ease 2.8s forwards;box-shadow:0 4px 18px rgba(0,0,0,.5)}
.toast.acc{border-left-color:var(--blue)}
.toast.warn{border-left-color:var(--red)}
@keyframes toastIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
@keyframes toastOut{to{opacity:0;transform:translateY(-6px)}}
/* ---------- Screens ---------- */
.screen{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 35%, #1a2236 0%, #0a0f1a 55%, #04060b 100%);}
.screen.hidden{display:none}
.panel{max-width:640px;width:92%;text-align:center;padding:40px 44px}
.eyebrow{font-size:11px;letter-spacing:6px;text-transform:uppercase;color:var(--dim);margin-bottom:14px}
h1.title{font-size:clamp(34px,6vw,58px);font-weight:normal;letter-spacing:6px;color:var(--gold2);
text-shadow:0 0 30px rgba(232,180,90,.35), 0 2px 0 #000; line-height:1.1}
h1.title .thin{display:block;font-size:.38em;letter-spacing:10px;color:var(--parch);margin-top:10px;opacity:.8}
.lore{margin:26px auto;max-width:520px;font-size:15px;line-height:1.75;color:#c9bda0;font-style:italic}
.keys{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:20px 0 6px}
.krow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);letter-spacing:1px}
kbd{font-family:inherit;border:1px solid rgba(232,180,90,.45);border-bottom-width:2px;border-radius:4px;
padding:3px 9px;font-size:12px;color:var(--gold2);background:rgba(232,180,90,.07);min-width:26px;display:inline-block;text-align:center}
.btnRow{display:flex;gap:14px;justify-content:center;margin-top:28px;flex-wrap:wrap}
button.gbtn{font-family:inherit;cursor:pointer;background:linear-gradient(180deg, rgba(232,180,90,.16), rgba(232,180,90,.05));
color:var(--gold2);border:1px solid rgba(232,180,90,.55);padding:13px 34px;font-size:14px;letter-spacing:4px;
text-transform:uppercase;border-radius:2px;transition:all .18s;position:relative}
button.gbtn:hover{background:rgba(232,180,90,.25);color:#fff;box-shadow:0 0 24px rgba(232,180,90,.25);transform:translateY(-1px)}
button.gbtn:disabled{opacity:.35;cursor:default;transform:none;box-shadow:none}
button.gbtn.ghost{border-color:rgba(138,148,166,.4);color:var(--dim)}
button.gbtn.ghost:hover{color:var(--parch);box-shadow:none;background:rgba(138,148,166,.1)}
.small{font-size:11px;color:var(--dim);letter-spacing:1px;margin-top:18px}
/* ---------- Modal (inventory / shop / dialog) ---------- */
#modal{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:rgba(3,5,10,.72);backdrop-filter:blur(3px)}
#modal.hidden{display:none}
.mpanel{width:min(680px,94vw);max-height:86vh;overflow:auto;background:linear-gradient(180deg,#141b2b,#0c111c);
border:1px solid rgba(232,180,90,.35);border-radius:4px;box-shadow:0 20px 60px rgba(0,0,0,.7), 0 0 0 1px rgba(0,0,0,.6);
padding:26px 30px}
.mhead{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid rgba(232,180,90,.2);padding-bottom:12px;margin-bottom:16px}
.mhead h2{font-weight:normal;letter-spacing:5px;font-size:18px;color:var(--gold2);text-transform:uppercase}
.mhead .sub{font-size:11px;color:var(--dim);letter-spacing:2px}
.mbody{font-size:14px;line-height:1.6;color:#cfc4a8}
.mfoot{margin-top:20px;padding-top:14px;border-top:1px solid rgba(232,180,90,.15);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
.mfoot .note{font-size:11px;color:var(--dim);letter-spacing:1px}
.mfoot .btns{display:flex;gap:10px;flex-wrap:wrap}
.mclose{background:none;border:1px solid rgba(138,148,166,.4);color:var(--dim);font-family:inherit;padding:8px 20px;
letter-spacing:3px;font-size:11px;text-transform:uppercase;cursor:pointer;border-radius:2px}
.mclose:hover{color:var(--parch);border-color:rgba(138,148,166,.7)}
/* inventory grid */
.invGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px}
.slot{border:1px solid rgba(232,180,90,.18);background:rgba(255,255,255,.02);border-radius:3px;padding:12px;display:flex;gap:11px;align-items:flex-start;cursor:default;transition:border-color .15s, background .15s}
.slot.clickable{cursor:pointer}
.slot.clickable:hover{border-color:rgba(232,180,90,.5);background:rgba(232,180,90,.05)}
.slot.equipped{border-color:rgba(111,184,216,.55);background:rgba(111,184,216,.06)}
.slot .ic{font-size:24px;line-height:1;filter:drop-shadow(0 0 6px rgba(255,255,255,.25));width:30px;text-align:center}
.slot .nm{font-size:13px;color:var(--parch);letter-spacing:1px}
.slot .ds{font-size:11px;color:var(--dim);margin-top:3px;line-height:1.45}
.slot .tg{margin-top:6px;font-size:10px;letter-spacing:2px;text-transform:uppercase}
.slot .tg.on{color:var(--blue)} .slot .tg.off{color:var(--dim)}
.slot .ct{margin-left:auto;font-size:12px;color:var(--gold2)}
.slot.empty{opacity:.35;border-style:dashed}
/* shop */
.shopGrid{display:grid;grid-template-columns:1fr;gap:10px}
.shopItem{display:flex;align-items:center;gap:14px;border:1px solid rgba(232,180,90,.18);border-radius:3px;padding:12px 14px;background:rgba(255,255,255,.02)}
.shopItem .ic{font-size:26px;width:34px;text-align:center}
.shopItem .info{flex:1}
.shopItem .nm{font-size:14px;color:var(--parch);letter-spacing:1px}
.shopItem .ds{font-size:11.5px;color:var(--dim);margin-top:2px}
.shopItem .pr{font-size:15px;color:var(--gold2);white-space:nowrap;letter-spacing:1px}
.shopItem.soldout{opacity:.4}
.shopItem button{font-family:inherit;background:rgba(232,180,90,.12);border:1px solid rgba(232,180,90,.45);color:var(--gold2);
padding:8px 18px;cursor:pointer;letter-spacing:2px;font-size:11px;text-transform:uppercase;border-radius:2px}
.shopItem button:hover:not(:disabled){background:rgba(232,180,90,.25);color:#fff}
.shopItem button:disabled{opacity:.4;cursor:default}
/* dialog */
#dlgName{font-size:11px;letter-spacing:4px;text-transform:uppercase;color:var(--gold2);margin-bottom:8px}
#dlgText{font-size:15px;line-height:1.7;color:#d8cdb0;min-height:70px}
#dlgNext{font-size:10px;color:var(--dim);letter-spacing:2px;text-align:right;margin-top:10px}
/* ---------- Canvas overlays ---------- */
#flash{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;background:radial-gradient(ellipse at center, transparent 40%, rgba(224,80,60,.55) 100%)}
#shimmer{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;
box-shadow:inset 0 0 90px rgba(245,211,138,.8), inset 0 0 22px rgba(255,240,200,.9)}
#vig{position:fixed;inset:0;pointer-events:none;z-index:10;background:radial-gradient(ellipse at center, transparent 52%, rgba(0,0,0,.55) 100%)}
@keyframes flashPulse{0%{opacity:0}12%{opacity:1}100%{opacity:0}}
.flashOn{animation:flashPulse .5s ease-out}
@keyframes shimPulse{0%{opacity:0}15%{opacity:1}100%{opacity:0}}
.shimOn{animation:shimPulse 2s ease-out}
/* fade */
#fade{position:fixed;inset:0;background:#04060b;z-index:60;pointer-events:none;opacity:0;transition:opacity .45s}
#fade.on{opacity:1}
/* save menu */
.saveRow{display:flex;gap:10px;justify-content:center;margin-top:6px}
#saveInfo{font-size:11px;color:var(--dim);letter-spacing:1px;margin-top:14px}
/* ending */
#endStats{display:flex;gap:26px;justify-content:center;margin:22px 0;font-size:12px;color:var(--dim);letter-spacing:2px}
#endStats b{display:block;font-size:22px;color:var(--gold2);letter-spacing:0;margin-top:4px}
#credits{font-size:10px;letter-spacing:3px;color:var(--dim);text-transform:uppercase;margin-top:26px;opacity:.7}
/* scanline + grain */
#crt{position:fixed;inset:0;pointer-events:none;z-index:70;opacity:.5;mix-blend-mode:overlay;
background:repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px)}
@media (max-width:640px){
#hearts{transform:scale(.8);transform-origin:top left}
#goldHud{transform:scale(.85);transform-origin:top right}
}
</style>
</head>
<body>
<div id="game">
<canvas id="cv"></canvas>
<canvas id="fx"></canvas>
</div>
<div id="vig"></div>
<div id="flash"></div>
<div id="shimmer"></div>
<div id="crt"></div>
<div id="hud">
<div id="hearts"></div>
<div id="goldHud"><div class="amt" id="goldAmt">0</div><div class="lbl">Golden Sigils</div></div>
<div id="zoneHud"><div class="z" id="zoneName">—</div><div class="n" id="zoneSub"></div></div>
<div id="ammoHud"></div>
<div id="weaponHud">Weapon · <b id="weaponName">Ember Blade</b></div>
<div id="hintHud"></div>
<div id="prompt"></div>
<div id="toastWrap"></div>
</div>
<div id="modal" class="hidden"><div class="mpanel">
<div class="mhead"><h2 id="mTitle"></h2><div class="sub" id="mSub"></div></div>
<div class="mbody" id="mBody"></div>
<div class="mfoot"><div class="note" id="mNote"></div><div class="btns" id="mBtns"></div></div>
</div></div>
<div id="fade"></div>
<div id="titleScreen" class="screen">
<div class="panel">
<div class="eyebrow">Aurelion's Sunken Observatory</div>
<h1 class="title">The Warden's Corrosion<span class="thin">Reignite the Core Beacon</span></h1>
<p class="lore">The archive drifts above a storm-locked valley. Gears have stopped. Moss drinks the bronze.
You are the Last Warden — and the Corrosion is patient no longer.<br><br>
Six halls lie between you and the Core Beacon. Restore what was broken. Carry the flame.</p>
<div class="keys">
<div class="krow"><kbd>W A S D</kbd> / <kbd>↑←↓→</kbd> Move</div>
<div class="krow"><kbd>J</kbd> / <kbd>Z</kbd> Strike</div>
<div class="krow"><kbd>K</kbd> / <kbd>X</kbd> Aether Bolt</div>
<div class="krow"><kbd>E</kbd> Interact</div>
<div class="krow"><kbd>I</kbd> Inventory</div>
<div class="krow"><kbd>1</kbd>–<kbd>2</kbd> Quick Use</div>
<div class="krow"><kbd>Space</kbd> Evade</div>
<div class="krow"><kbd>Esc</kbd> Close</div>
</div>
<div class="btnRow">
<button class="gbtn" id="btnNew">Begin the Vigil</button>
<button class="gbtn ghost" id="btnCont" disabled>Continue</button>
</div>
<div class="small" id="titleNote">Progress saves automatically at Sanctuary Stones.</div>
</div>
<canvas id="titleFx" style="position:absolute;inset:0;pointer-events:none"></canvas>
</div>
<div id="saveScreen" class="screen hidden">
<div class="panel">
<div class="eyebrow">Sanctuary Stone</div>
<h1 class="title" style="font-size:34px">The Vigil Rests<span class="thin">Progress Sealed</span></h1>
<div id="saveInfo"></div>
<div class="btnRow">
<button class="gbtn" id="btnSaveRest">Rest & Restore</button>
<button class="gbtn ghost" id="btnSaveClose">Leave the Stone</button>
<button class="gbtn ghost" id="btnSaveWipe">Abandon Vigil</button>
</div>
</div>
</div>
<div id="endScreen" class="screen hidden">
<div class="panel">
<div class="eyebrow">The Core Chamber</div>
<h1 class="title" style="font-size:40px">The Beacon Burns<span class="thin">The Corrosion Recedes</span></h1>
<p class="lore" id="endText"></p>
<div id="endStats"></div>
<div class="btnRow"><button class="gbtn" id="btnEndClose">Stand Watch Once More</button></div>
</div>
</div>
<script>
"use strict";
/* =====================================================================
THE WARDEN'S CORROSION — single-file action-adventure
===================================================================== */
/* ---------------- utils ---------------- */
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const dist2=(x1,y1,x2,y2)=>{const dx=x2-x1,dy=y2-y1;return dx*dx+dy*dy};
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const angTo=(x1,y1,x2,y2)=>Math.atan2(y2-y1,x2-x1);
const pick=a=>a[Math.floor(Math.random()*a.length)];
const now=()=>performance.now();
function shade(hex,f){ // lighten/darken hex by factor f (-1..1)
const n=parseInt(hex.slice(1),16);let r=n>>16,g=(n>>8)&255,b=n&255;
if(f>=0){r+= (255-r)*f; g+=(255-g)*f; b+=(255-b)*f}else{r*=1+f;g*=1+f;b*=1+f}
return `rgb(${r|0},${g|0},${b|0})`;
}
function rgba(hex,a){const n=parseInt(hex.slice(1),16);return `rgba(${n>>16},${(n>>8)&255},${n&255},${a})`}
const $=id=>document.getElementById(id);
/* ---------------- audio ---------------- */
const AudioSys=(()=>{
let ctx=null, master=null, musicGain=null, sfxGain=null, ambGain=null;
let musicTimer=null, ambTimer=null, muted=false, curTrack=null, curZone=-1;
function ensure(){
if(ctx) return true;
try{ctx=new (window.AudioContext||window.webkitAudioContext)()}catch(e){return false}
master=ctx.createGain(); master.gain.value=0.55; master.connect(ctx.destination);
const comp=ctx.createDynamicsCompressor();
comp.threshold.value=-18; comp.knee.value=24; comp.ratio.value=6;
comp.connect(master);
sfxGain=ctx.createGain(); sfxGain.gain.value=1; sfxGain.connect(comp);
musicGain=ctx.createGain(); musicGain.gain.value=0.5; musicGain.connect(comp);
ambGain=ctx.createGain(); ambGain.gain.value=0.35; ambGain.connect(comp);
return true;
}
function env(g,t0,a,d,peak){g.gain.setValueAtTime(0.0001,t0);g.gain.linearRampToValueAtTime(peak,t0+a);g.gain.exponentialRampToValueAtTime(0.0001,t0+a+d)}
function osc(type,f0,f1,t0,dur,peak,dest,detune=0){
const o=ctx.createOscillator(),g=ctx.createGain();
o.type=type;o.frequency.setValueAtTime(f0,t0);
if(f1!==null)o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t0+dur);
o.detune.value=detune;
env(g,t0,0.005,dur,peak);
o.connect(g);g.connect(dest||sfxGain);o.start(t0);o.stop(t0+dur+0.1);
}
function noise(t0,dur,peak,fc,q,dest,type='bandpass'){
const len=Math.max(1,ctx.sampleRate*dur|0),buf=ctx.createBuffer(1,len,ctx.sampleRate);
const d=buf.getChannelData(0);for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
const src=ctx.createBufferSource();src.buffer=buf;
const f=ctx.createBiquadFilter();f.type=type;f.frequency.value=fc;f.Q.value=q;
const g=ctx.createGain();env(g,t0,0.004,dur,peak);
src.connect(f);f.connect(g);g.connect(dest||sfxGain);src.start(t0);src.stop(t0+dur+0.05);
}
function tone(freq,dur,peak,type='sine',slide=null,when=0){
if(!ensure()||muted)return;const t0=ctx.currentTime+when;
osc(type,freq,slide,t0,dur,peak);
}
function sfx(name){
if(!ensure()||muted)return;const t=ctx.currentTime;
switch(name){
case 'swing': osc('sawtooth',520,120,t,0.12,0.10); noise(t,0.10,0.10,2200,1.2); break;
case 'swingHeavy': osc('sawtooth',300,70,t,0.20,0.14); noise(t,0.18,0.12,900,1); break;
case 'hit': osc('triangle',180,60,t,0.10,0.28); noise(t,0.06,0.18,3200,1); break;
case 'hitFire': osc('triangle',160,55,t,0.14,0.30); noise(t,0.22,0.20,1400,0.8); noise(t,0.3,0.10,500,0.7); break;
case 'hitSolar': osc('sine',90,38,t,0.5,0.4); osc('triangle',420,180,t,0.2,0.2); noise(t,0.12,0.14,2600,2); break;
case 'hurt': osc('sine',220,90,t,0.18,0.32); osc('sine',3140,2900,t,0.28,0.05); noise(t,0.08,0.2,600,1); break;
case 'die': osc('sawtooth',160,40,t,0.5,0.2); noise(t,0.4,0.15,800,0.8); break;
case 'coin': osc('sine',1320,1320,t,0.07,0.12); osc('sine',1760,1760,t+0.06,0.10,0.10); break;
case 'pickup': [523,659,784].forEach((f,i)=>osc('sine',f,f,t+i*0.05,0.18,0.09)); break;
case 'rare': [392,494,587,784].forEach((f,i)=>osc('triangle',f,f,t+i*0.07,0.35,0.10)); break;
case 'heart': [262,330,392,523,659].forEach((f,i)=>osc('sine',f,f,t+i*0.08,0.4,0.11)); break;
case 'door': osc('sine',70,45,t,0.7,0.3); noise(t,0.6,0.12,300,0.7); break;
case 'plate': osc('sine',110,70,t,0.25,0.25); noise(t,0.1,0.1,500,1); break;
case 'crack': noise(t,0.3,0.28,1800,0.6); osc('triangle',200,80,t,0.25,0.15); break;
case 'break': noise(t,0.45,0.32,900,0.5); osc('triangle',120,40,t,0.4,0.25); break;
case 'bolt': osc('square',1500,300,t,0.14,0.09); osc('sine',2400,900,t,0.12,0.06); break;
case 'boltHit': osc('sine',1900,1200,t,0.3,0.10); osc('sine',2850,2600,t,0.25,0.05); break;
case 'switch': osc('sine',880,880,t,0.15,0.14); osc('sine',1174,1174,t+0.09,0.3,0.10); break;
case 'chest': [330,415,494,659].forEach((f,i)=>osc('triangle',f,f,t+i*0.08,0.3,0.10)); noise(t,0.15,0.08,3000,2); break;
case 'save': [523,659,784,1047].forEach((f,i)=>osc('sine',f,f,t+i*0.12,0.6,0.10)); break;
case 'buy': osc('sine',880,880,t,0.1,0.12); osc('sine',1320,1320,t+0.08,0.25,0.12); noise(t,0.05,0.1,4000,2); break;
case 'deny': osc('square',180,140,t,0.15,0.08); break;
case 'seal': osc('sine',1200,300,t,0.5,0.10); osc('sine',600,150,t,0.5,0.08); break;
case 'freeze': osc('sine',1800,400,t,0.4,0.10); break;
case 'step': noise(t,0.05,0.05,rand(300,500),1.4); break;
case 'stepMetal': noise(t,0.07,0.07,rand(700,1100),2); osc('triangle',rand(140,190),80,t,0.06,0.03); break;
case 'stepWet': noise(t,0.09,0.06,rand(500,900),1); break;
case 'altar': [261,329,392,523].forEach((f,i)=>osc('sine',f,f,t+i*0.1,0.8,0.10)); noise(t,0.8,0.06,600,0.6); break;
case 'beacon': [130,196,261,329,392,523,659].forEach((f,i)=>osc('sine',f,f,t+i*0.15,1.6,0.12)); break;
case 'trap': osc('sawtooth',300,100,t,0.15,0.15); noise(t,0.1,0.12,1200,1); break;
case 'bossHit': osc('triangle',120,45,t,0.25,0.35); noise(t,0.15,0.2,1800,1); break;
case 'roar': osc('sawtooth',90,45,t,1.0,0.3); osc('sawtooth',95,48,t,1.0,0.2,null,15); break;
case 'win': [392,523,659,784,1047,1319].forEach((f,i)=>osc('triangle',f,f,t+i*0.13,0.9,0.12)); break;
}
}
/* --- music: slow melancholic arpeggio in D minor --- */
const SCALE=[293.66,349.23,392.00,440.00,523.25,587.33,698.46]; // D E F A C D F (dorian-ish)
const BASS=[73.42,87.31,98.00,110.00]; // D2 F2 G2 A2
let step=0;
function musicTick(){
if(!ctx||muted)return;
const t=ctx.currentTime+0.05;
if(step%8===0){
const b=BASS[(step/8)%4|0];
osc('sine',b,b,t,2.2,0.10,musicGain);
osc('sine',b*2,b*2,t,2.2,0.03,musicGain);
}
if(step%2===0){
const idx=(step/2)%SCALE.length;
const n=SCALE[idx]*(Math.random()<0.15?2:1);
osc('triangle',n,n,t,1.4,0.045,musicGain);
if(Math.random()<0.3)osc('sine',n*1.5,n*1.5,t+0.15,1.0,0.02,musicGain);
}
if(step%16===12)osc('sine',SCALE[randi(0,6)]*2,SCALE[randi(0,6)]*2,t,0.8,0.02,musicGain);
step++;
}
function ambTick(){
if(!ctx||muted)return;
const t=ctx.currentTime+0.05;
const z=curZone;
if(z===1){ // wind
noise(t,1.5,0.05,rand(400,900),0.6,ambGain,'lowpass');
}else if(z===2){ // drips
if(Math.random()<0.4)osc('sine',rand(800,1600),rand(500,900),t,0.15,0.03,ambGain);
noise(t,1.2,0.02,rand(200,400),0.5,ambGain,'lowpass');
}else if(z===3){ // wet
if(Math.random()<0.5)osc('sine',rand(600,1400),rand(300,700),t,0.2,0.035,ambGain);
noise(t,1.4,0.03,rand(150,350),0.5,ambGain,'lowpass');
}else if(z===4){ // high wind
noise(t,1.6,0.05,rand(600,1200),0.4,ambGain,'lowpass');
}else if(z===5){ // hum
osc('sine',55,55,t,1.8,0.05,ambGain); osc('sine',82.5,82.5,t,1.8,0.03,ambGain);
}else{ // faint dust
noise(t,1.2,0.02,rand(300,600),0.5,ambGain,'lowpass');
}
if(curZone===0){} // handled
}
function startMusic(){ if(musicTimer)return; musicTimer=setInterval(musicTick,700); }
function startAmb(){ if(ambTimer)return; ambTimer=setInterval(ambTick,1400); }
function setZone(z){ curZone=z; }
function toggleMute(){ muted=!muted; if(master)master.gain.value=muted?0:0.55; return muted; }
function resume(){ if(ensure()&&ctx.state==='suspended')ctx.resume(); }
function unlock(){ ensure(); resume(); startMusic(); startAmb(); }
return {sfx,tone,resume,unlock,setZone,toggleMute,
get muted(){return muted}};
})();
/* ---------------- input ---------------- */
const Keys={};
const Input={ax:0,ay:0,atk:false,bolt:false,interact:false,inv:false};
window.addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k))e.preventDefault();
if(Keys[k])return; Keys[k]=true;
onKeyDown(k,e);
});
window.addEventListener('keyup',e=>{Keys[e.key.toLowerCase()]=false});
window.addEventListener('blur',()=>{for(const k in Keys)Keys[k]=false; Input.ax=Input.ay=0});
function readAxes(){
let x=0,y=0;
if(Keys['w']||Keys['arrowup'])y-=1;
if(Keys['s']||Keys['arrowdown'])y+=1;
if(Keys['a']||Keys['arrowleft'])x-=1;
if(G.aimMode&&G.state==='play'){
// aim with mouse when aiming
}
if(Keys['d']||Keys['arrowright'])x+=1;
Input.ax=x;Input.ay=y;
const m=Math.hypot(x,y);if(m>1){x/=m;y/=m}
Input.ax=x;Input.ay=y;
}
let mouse={x:640,y:360,down:false};
cv.addEventListener('mousemove',e=>{const r=cv.getBoundingClientRect();mouse.x=e.clientX-r.left;mouse.y=e.clientY-r.top});
cv.addEventListener('mousedown',e=>{mouse.down=true; if(G.state==='play')tryAttack();});
cv.addEventListener('mouseup',e=>{mouse.down=false});
cv.addEventListener('contextmenu',e=>e.preventDefault());
/* ---------------- game state ---------------- */
const G={
state:'title', // title|play|paused|dialog|shop|inventory|save|dead|end
prevState:'play',
zone:0, zoneDef:null,
player:null,
enemies:[], pickups:[], particles:[], projectiles:[], floaters:[], shards:[], floatTexts:[],
particles2:[],
cam:{x:0,y:0,shake:0},
time:0, dt:0,
flags:{},
inventory:{stems:0,seals:0},
accessories:{band:false,lantern:false,cloak:false},
has:{band:false,lantern:false,cloak:false},
weapon:'ember', // ember|ignited|solar
hasRanged:false, ammo:0,
maxHp:6, hp:6, maxContainers:3,
gold:0,
kills:0, goldEarned:0, deaths:0,
shopStock:{stems:3,seals:2,heart:1,ammo:99},
dialog:null, dialogIdx:0,
prompt:null,
aimMode:false,
interactTarget:null,
hitstop:0,
saveData:null,
stats:{},
ending:false,
particlesPool:[],
enemiesKilled:0,
goldEarnedTotal:0,
};
/* ---------------- world ---------------- */
const C=$('cv'),ctx=C.getContext('2d');
let VW=1200,VH=720,scale=1,offX=0,offY=0;
const names=['The Ashen Threshold','The Rusted Archives','The Collapsed Gallery','The Sunken Vault','The Twin Spire','The Core Chamber'];
const subtitles=['I · OUTER VIGIL','II · THE MEMORY OF BRONZE','III · A FLAME REMEMBERED','IV · BENEATH THE MERIDIAN','V · THE LAST ASCENT','VI · A SUN IN CHAINS'];
const goals=[
'Silence the two sentinels. The eastern arch will open.',
'Clear the archives. Find the silver band among the shelves.',
'Cross both bronze plates within 5 seconds. Restore the ember altar.',
'Strike the hairline wall. Find the Shard, then turn the mirror toward the crystal.',
'Hit the distant crystal. Survive the gauntlet and restore the solar altar.',
'Break the Core’s shield with the distant crystal. End the First Warden’s vigil.'
];
function obj(type,x,y,extra={}){return {type,x,y,...extra}}
function makeWorld(){return [
{color:'#253744',accent:'#ad7950',objects:[
obj('stone',170,380,{id:'stone0',label:'Sanctuary · I'}),obj('tablet',270,230,{text:'Stone remembers what flesh cannot. Rest here with E. Your hearts and Aether charges return, and your vigil is sealed. The constructs beyond wake slowly: strike with J, evade with Space.'}),
obj('jar',350,480,{id:'jar0',reward:'stems',amount:1}),obj('jar',890,260,{id:'jar1',reward:'gold',amount:20}),
obj('pillar',430,230),obj('pillar',430,510),obj('pillar',770,230),obj('pillar',770,510)
],enemies:[{x:600,y:350,hp:6},{x:875,y:440,hp:6}]},
{color:'#303b42',accent:'#ae7046',objects:[
obj('stone',600,385,{id:'stone1',label:'Sanctuary · II',safe:true}),obj('shop',750,240,{safe:true}),
obj('shelf',315,220),obj('shelf',470,220),obj('shelf',840,500),obj('shelf',950,500),
obj('wall',320,500,{id:'bandWall',hp:6,reward:'band',label:'A silver glint behind the shelves'}),
obj('jar',900,250,{id:'jar2',reward:'stems',amount:1}),obj('jar',500,510,{id:'jar3',reward:'gold',amount:35}),
obj('tablet',210,250,{text:'The merchant keeps the old prices. Fallen constructs yield 5–15 sigils; departed halls stir again when you return. The cracked bookcase in the southwest hides a ring. Equip discoveries in your inventory [I].'})
],enemies:[{x:440,y:365,hp:7},{x:670,y:465,hp:7},{x:865,y:355,hp:8}]},
{color:'#293841',accent:'#bb8549',objects:[
obj('plate',380,455,{id:'plateA'}),obj('plate',640,275,{id:'plateB'}),
obj('altar',870,330,{id:'altar1',form:'ignited'}),
obj('wall',460,195,{id:'heartWall',hp:8,reward:'heart4',label:'A heartbeat beneath the fracture'}),
obj('tablet',220,255,{text:'Two bronze plates. Five seconds between their echoes. Step on each to lower the altar’s cage. Above the western plate, a jagged seam shelters a lost heart. Strike it with your blade.'}),
obj('jar',910,490,{id:'jar4',reward:'stems',amount:2}),obj('pillar',540,380),obj('trap',730,450,{phase:0})
],enemies:[{x:600,y:490,hp:10},{x:785,y:250,hp:12,kind:'guardian'}]},
{color:'#203d48',accent:'#718c84',objects:[
obj('wall',340,205,{id:'shardWall',hp:8,reward:'shard',label:'A thin blue light behind the crack'}),
obj('mirror',530,420,{id:'mirror',turn:0}),obj('switch',840,210,{id:'vaultSwitch'}),
obj('chest',960,440,{id:'lanternChest',reward:'lantern',requires:'vaultSwitch',label:'Phantom Lantern'}),
obj('jar',320,510,{id:'jar5',reward:'stems',amount:2}),obj('pillar',660,290),
obj('tablet',205,300,{text:'HALFWAY · The deep keeps a second weapon. Break the hairline wall to the north. Turn the mirror with E until its ray points northeast. Stand at the mirror and fire K: your Shard seeks the distant crystal. Its echo opens the lantern reliquary.'})
],enemies:[{x:590,y:510,hp:12},{x:860,y:350,hp:14,kind:'instrument'}]},
{color:'#2d3545',accent:'#bc8f4a',objects:[
obj('stone',180,375,{id:'stone2',label:'Sanctuary · III'}),
obj('switch',835,185,{id:'spireSwitch'}),obj('chest',915,245,{id:'spireHeart',reward:'heart5',requires:'spireSwitch',label:'A captive heart'}),
obj('altar',880,440,{id:'altar2',form:'solar'}),
obj('chest',460,210,{id:'bonusChest',reward:'gold',amount:120,label:'The treasury of the fallen'}),
obj('wall',410,520,{id:'cloakWall',hp:8,reward:'cloak',label:'Dark cloth in the breach'}),
obj('jar',260,255,{id:'jar6',reward:'seals',amount:2}),obj('jar',690,520,{id:'jar7',reward:'stems',amount:2}),
obj('trap',555,340,{phase:0}),obj('trap',690,380,{phase:1.8}),
obj('tablet',290,420,{text:'Rest before the ascent. The upper crystal hears only the Shard; it frees a fifth heart. Beneath the treasury, black cloth turns the teeth of traps. The solar altar wakes only when its guardians fall.'})
],enemies:[{x:480,y:390,hp:14},{x:700,y:260,hp:16,kind:'instrument'},{x:855,y:370,hp:22,kind:'guardian'}]},
{color:'#293744',accent:'#c69957',objects:[
obj('switch',850,190,{id:'coreSwitch'}),obj('beacon',975,365),
obj('plate',360,465,{id:'corePlate'}),obj('trap',550,260,{phase:0}),obj('trap',760,480,{phase:2}),
obj('jar',250,220,{id:'jar8',reward:'stems',amount:2}),obj('jar',260,510,{id:'jar9',reward:'seals',amount:1}),
obj('tablet',200,320,{text:'The First Warden wears the Corrosion as armor. Hold the bronze plate to expose the upper crystal, then fire K. Its broken shield stays down. Only the Solar Falchion can unravel the iron heart. Evade the red circles; bind the Warden to steal three quiet seconds.'})
],enemies:[{x:730,y:365,hp:72,kind:'boss'}]}
]}
let world=makeWorld();
const weaponStats={ember:{name:'Ember Blade',damage:2,range:76,rate:.48,wind:.06,color:'#c9c9af'},ignited:{name:'Ignited Edge',damage:4,range:88,rate:.30,wind:.04,color:'#ff9945'},solar:{name:'Solar Falchion',damage:8,range:116,rate:.64,wind:.18,color:'#ffe8a1'}};
let checkpoint=null;
function spawnEnemy(e,i){return {...e,id:i,maxHp:e.hp,homeX:e.x,homeY:e.y,r:e.kind==='boss'?30:e.kind==='guardian'?23:17,cool:1+i*.4,tell:0,stun:0,freeze:0,hit:0,dead:false,angle:0}}
function enterZone(n,back=false,restoring=false){
G.zone=n;G.zoneDef=world[n];G.projectiles=[];G.particles=[];G.floatTexts=[];
G.enemies=G.zoneDef.enemies.map(spawnEnemy);
if(G.flags['clear'+n]){if(n===5)G.enemies=[];else G.enemies.forEach(e=>e.hp=e.maxHp)}
const p=G.player;p.x=back?1030:225;p.y=365;p.angle=back?Math.PI:0;p.invuln=1.8;
G.state='play';G.prompt=null;G.interactTarget=null;
AudioSys.setZone(n);hud();
if(!restoring)toast(names[n]+' · '+(n+1)+'/6');
}
function isClear(){return G.enemies.every(e=>e.dead)}
function gateOpen(){
if(!isClear())return false;
if(G.zone===2)return G.weapon!=='ember'&&G.flags.galleryPlates;
if(G.zone===3)return G.hasRanged&&G.flags.vaultSwitch&&G.has.lantern;
if(G.zone===4)return G.weapon==='solar';
return G.zone<5;
}
function gateReason(){if(!isClear())return 'The arch is bound to the surviving constructs.';
if(G.zone===2)return 'Two plates must echo, and the ember altar must be restored.';
if(G.zone===3)return 'Recover the Aether Shard and the Phantom Lantern.';
if(G.zone===4)return 'Restore the Solar Falchion at the eastern altar.';
return ''}
function newGame(){
world=makeWorld();G.pickups=[];for(const k in Keys)Keys[k]=false;mouse.down=false;Object.assign(G,{flags:{},inventory:{stems:0,seals:0},accessories:{band:false,lantern:false,cloak:false},has:{band:false,lantern:false,cloak:false},weapon:'ember',hasRanged:false,ammo:0,maxHp:6,hp:6,maxContainers:3,gold:0,kills:0,goldEarned:0,deaths:0,time:0,shopStock:{stems:3,seals:2,heart:1,ammo:99},ending:false});
G.player={x:145,y:365,angle:0,step:0,cool:0,boltCool:0,invuln:0,dash:0,dashCool:0,swing:0,wind:0,walk:0};
$('titleScreen').classList.add('hidden');$('endScreen').classList.add('hidden');$('hud').style.display='block';AudioSys.unlock();enterZone(0);storeSave(false);toast('WASD to move · J to strike · E to touch the stone');
}
function burst(x,y,color,n=18,power=90){for(let i=0;i<n;i++){let a=rand(0,TAU),v=rand(20,power);G.particles.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v-20,life:rand(.3,.85),max:.85,color,size:rand(1,3)})}}
function floatText(x,y,text,color='#eed49b'){G.floatTexts.push({x,y,text,color,life:1.2})}
function shimmer(){const s=$('shimmer');s.classList.remove('shimOn');void s.offsetWidth;s.classList.add('shimOn')}
function addGold(n){G.gold+=n;G.goldEarned+=n;AudioSys.sfx('coin');floatText(G.player.x,G.player.y-30,'+'+n+' sigils');burst(G.player.x,G.player.y,'#e8b45a',12);hud()}
function reward(type,n=1){
const p=G.player;
if(type==='gold'){addGold(n);return}
if(type==='heart4'||type==='heart5'){
if(G.flags[type])return;G.flags[type]=true;G.maxHp+=2;G.maxContainers=G.maxHp/2;G.hp=G.maxHp;
if(type==='heart5')G.shopStock.heart=0;
AudioSys.sfx('heart');shimmer();burst(p.x,p.y,'#ffe4a1',55,130);toast('A heart rekindled · '+G.maxContainers+' containers / '+G.maxHp+' HP');
}else if(type==='shard'){
G.hasRanged=true;G.ammo=16;AudioSys.sfx('rare');shimmer();burst(p.x,p.y,'#8ad7ef',45);
toast('Aether Shard discovered · K to fire · 16 charges','acc');
}else if(['band','cloak','lantern'].includes(type)){
G.has[type]=true;AudioSys.sfx('rare');burst(p.x,p.y,'#e5f6ff',32);toast(itemInfo[type].name+' found · Equip in inventory [I]','acc');
}else{G.inventory[type]=(G.inventory[type]||0)+n;AudioSys.sfx('pickup');burst(p.x,p.y,'#c6ecd3',20);toast('+'+n+' '+itemInfo[type].name)}
hud();
}
function hitObject(o,damage){
if(o.open||G.flags[o.id]||!['wall','jar'].includes(o.type))return;
o.hp=(o.hp??2)-damage;burst(o.x,o.y,'#ad8967',10);AudioSys.sfx('crack');
if(o.hp<=0){o.open=true;G.flags[o.id]=true;AudioSys.sfx('break');burst(o.x,o.y,'#ad8967',28,160);reward(o.reward,o.amount||1)}
}
function aimAngle(){
const p=G.player;
// Gentle target assistance: distant mechanisms take priority when facing their quadrant.
const candidates=[...G.zoneDef.objects.filter(o=>o.type==='switch'&&!G.flags[o.id]),...G.enemies.filter(e=>!e.dead)];
let best=null,score=Infinity;
for(const o of candidates){const a=angTo(p.x,p.y,o.x,o.y),d=dist(p.x,p.y,o.x,o.y),diff=Math.abs(Math.atan2(Math.sin(a-p.angle),Math.cos(a-p.angle)));
if(d<740&&diff<1.2){const s=d+(o.type==='switch'?-300:0);if(s<score){score=s;best=o}}}
return best?angTo(p.x,p.y,best.x,best.y):p.angle;
}
function tryAttack(){
if(G.state!=='play'||G.player.cool>0||G.player.dash>0)return;
const p=G.player,w=weaponStats[G.weapon];p.cool=w.rate*(G.accessories.band?.83:1);p.wind=w.wind;p.pending=true;p.swing=.26+w.wind;
AudioSys.sfx(G.weapon==='solar'?'swingHeavy':'swing');
}
function performStrike(){
const p=G.player,w=weaponStats[G.weapon];let targets=[...G.enemies.filter(e=>!e.dead),...G.zoneDef.objects.filter(o=>['wall','jar'].includes(o.type)&&!G.flags[o.id])];
for(const e of targets){const d=dist(p.x,p.y,e.x,e.y);let a=angTo(p.x,p.y,e.x,e.y);let diff=Math.abs(Math.atan2(Math.sin(a-p.angle),Math.cos(a-p.angle)));
if(d<w.range+(e.r||20)&&(diff<(G.weapon==='solar'?1.65:1.4)||d<38)){
if(e.type)hitObject(e,w.damage);else damageEnemy(e,w.damage,false);
}
}
if(G.weapon!=='ember'){for(let i=0;i<15;i++){let a=p.angle+rand(-1.2,1.2);burst(p.x+Math.cos(a)*w.range*.8,p.y+Math.sin(a)*w.range*.8,w.color,1,20)}}
}
function fireBolt(){
if(!G.hasRanged){toast('A second weapon sleeps in the Sunken Vault.');return}
const p=G.player;if(p.boltCool>0)return;
if(G.ammo<1){toast('No charges · rest at a stone or use a blue condenser [E]','warn');AudioSys.sfx('deny');return}
G.ammo--;p.boltCool=.4;let a=aimAngle();p.angle=a;
G.projectiles.push({x:p.x+Math.cos(a)*24,y:p.y+Math.sin(a)*24,vx:Math.cos(a)*520,vy:Math.sin(a)*520,life:1.5,enemy:false});AudioSys.sfx('bolt');hud();
}
function damageEnemy(e,d,ranged){
if(e.kind==='boss'&&(!G.flags.coreSwitch||(!ranged&&G.weapon!=='solar'))){burst(e.x,e.y,'#77badd',15);floatText(e.x,e.y-45,'SHIELDED','#7ecde5');AudioSys.sfx('boltHit');return}
e.hp-=d;e.hit=.18;e.stun=G.weapon==='ignited'&&!ranged?.42:.23;e.tell=0;
const a=angTo(G.player.x,G.player.y,e.x,e.y);e.x=clamp(e.x+Math.cos(a)*12,130,1070);e.y=clamp(e.y+Math.sin(a)*12,160,560);
burst(e.x,e.y,ranged?'#8edcff':weaponStats[G.weapon].color,18);floatText(e.x,e.y-36,'−'+d,ranged?'#9adaf1':'#f8d5a0');
AudioSys.sfx(ranged?'boltHit':G.weapon==='solar'?'hitSolar':G.weapon==='ignited'?'hitFire':'hit');
G.cam.shake=G.weapon==='solar'?6:3;if(G.weapon==='solar'){$('shimmer').style.opacity='.12';setTimeout(()=>$('shimmer').style.opacity='',100)}
if(e.hp<=0){e.dead=true;G.kills++;AudioSys.sfx('die');burst(e.x,e.y,'#d8a667',35,150);const n=randi(5,15);G.pickups.push({x:e.x,y:e.y,amount:n,zone:G.zone});
if(G.hasRanged&&G.ammo<16){G.ammo=Math.min(16,G.ammo+2);floatText(e.x,e.y-55,'+2 charges','#8acfe1')}
if(isClear()){G.flags['clear'+G.zone]=true;AudioSys.sfx('door');toast(G.zone===5?'The First Warden rests. Touch the Beacon.':'The constructs fall. The hall grows quiet.');if(G.zone===5)reward('gold',50)}
hud();
}
}
function hurt(d,trap=false){
const p=G.player;if(p.invuln>0||p.dash>0)return;
if(trap&&G.accessories.cloak)d=1;
G.hp=Math.max(0,G.hp-d);p.invuln=1.05;G.cam.shake=9;burst(p.x,p.y,'#ef6150',28);floatText(p.x,p.y-35,'−'+d,'#ff7866');
const el=$('flash');el.classList.remove('flashOn');void el.offsetWidth;el.classList.add('flashOn');AudioSys.sfx('hurt');hud();
if(G.hp<=0){G.deaths++;G.state='dead';AudioSys.sfx('die');showModal('The vigil falters','The stone remembers','<p>The Corrosion takes your outline, but not your name. Return to the last Sanctuary Stone. Progress since that rest will be lost.</p>','', [{label:'Return to the stone',fn:()=>{closeModal();loadSave()}}]);G.state='dead'}
}
function useItem(type){
if(!['play','inventory'].includes(G.state))return;
if(!G.inventory[type]){toast('No '+itemInfo[type].name+' remaining','warn');return}
if(type==='stems'){
if(G.hp===G.maxHp){toast('Your hearts are already full.');return}
G.inventory.stems--;G.hp=Math.min(G.maxHp,G.hp+4);AudioSys.sfx('pickup');burst(G.player.x,G.player.y,'#95df9a',25);toast('Vital Stem · 4 HP restored');
}else{
const enemy=G.enemies.filter(e=>!e.dead).sort((a,b)=>dist(G.player.x,G.player.y,a.x,a.y)-dist(G.player.x,G.player.y,b.x,b.y))[0];
if(!enemy||dist(G.player.x,G.player.y,enemy.x,enemy.y)>500){toast('No construct within binding reach.');return}
G.inventory.seals--;enemy.freeze=3;enemy.tell=0;AudioSys.sfx('seal');burst(enemy.x,enemy.y,'#91ddf7',35);toast('The construct is bound · 3 seconds');
}
hud();if(G.state==='inventory')openInventory();
}
function blocked(x,y){
if(x<112||x>1085||y<153||y>557)return true;
for(const o of G.zoneDef.objects){if(['pillar','shelf','wall'].includes(o.type)&&!G.flags[o.id]){
const w=o.type==='shelf'?58: o.type==='wall'?46:23,h=o.type==='shelf'?25:o.type==='wall'?24:22;
if(Math.abs(x-o.x)<w+12&&Math.abs(y-o.y)<h+12)return true;
}}return false;
}
function moveBody(p,dx,dy){if(!blocked(p.x+dx,p.y))p.x+=dx;if(!blocked(p.x,p.y+dy))p.y+=dy}
function update(dt){
if(G.state!=='play')return;
G.time+=dt;readAxes();const p=G.player;p.cool=Math.max(0,p.cool-dt);p.boltCool=Math.max(0,p.boltCool-dt);p.invuln=Math.max(0,p.invuln-dt);p.dashCool=Math.max(0,p.dashCool-dt);p.dash=Math.max(0,p.dash-dt);p.swing=Math.max(0,p.swing-dt);
if(p.pending){p.wind-=dt;if(p.wind<=0){p.pending=false;performStrike()}}
if(Keys.j||Keys.z||mouse.down)tryAttack();if((Keys.k||Keys.x)&&G.hasRanged)fireBolt();
let moving=Input.ax||Input.ay;
if(moving){p.angle=Math.atan2(Input.ay,Input.ax);const speed=p.dash>0?490:185;moveBody(p,Input.ax*speed*dt,Input.ay*speed*dt);p.walk+=dt*10;p.step-=dt;
if(p.step<=0){p.step=.32;AudioSys.sfx(G.zone===1?'stepMetal':G.zone===3?'stepWet':'step')}
if(G.weapon!=='ember'&&Math.random()<.4)burst(p.x,p.y,weaponStats[G.weapon].color,1,12);
}else p.walk=0;
for(const e of G.enemies){if(e.dead)continue;e.hit=Math.max(0,e.hit-dt);e.stun=Math.max(0,e.stun-dt);e.freeze=Math.max(0,e.freeze-dt);if(e.freeze>0||e.stun>0)continue;
const d=dist(p.x,p.y,e.x,e.y);e.angle=angTo(e.x,e.y,p.x,p.y);e.cool-=dt;
const safe=G.zoneDef.objects.some(o=>o.type==='stone'&&(!o.safe||isClear())&&dist(p.x,p.y,o.x,o.y)<68);
if(safe){e.tell=0;continue}
if(e.tell>0){e.tell-=dt;if(e.tell<=0){
if(e.kind==='instrument')G.projectiles.push({x:e.x,y:e.y,vx:Math.cos(e.attackA)*240,vy:Math.sin(e.attackA)*240,enemy:true,life:3});
else{burst(e.tx,e.ty,'#cd6a45',24,120);if(dist(p.x,p.y,e.tx,e.ty)<(e.kind==='boss'?90:52))hurt(2);AudioSys.sfx(e.kind==='boss'?'bossHit':'hit')}
e.cool=e.kind==='boss'?1.25:1.6;
}continue}
let reach=e.kind==='instrument'?290:e.kind==='boss'?170:62;
if(d<reach&&e.cool<=0){e.tell=e.kind==='boss'?.85:.65;e.tx=p.x;e.ty=p.y;e.attackA=e.angle;continue}
if(d<(G.zone===0?250:365)&&d>reach*.7){const v=e.kind==='boss'?58:e.kind==='guardian'?62:72;
moveBody(e,Math.cos(e.angle)*v*dt,Math.sin(e.angle)*v*dt);
}
}
for(const o of G.zoneDef.objects){
if(o.type==='trap'&&Math.sin(G.time*1.8+o.phase)>.55&&dist(p.x,p.y,o.x,o.y)<38)hurt(2,true);
if(o.type==='plate'){
let pressed=dist(p.x,p.y,o.x,o.y)<29||G.enemies.some(e=>!e.dead&&dist(e.x,e.y,o.x,o.y)<27);
if(pressed&&!o.down){AudioSys.sfx('plate');o.until=G.time+5;if(G.zone===2){toast('Bronze echo · reach the other plate in 5 seconds')}}o.down=pressed;
if(pressed)o.until=G.time+5;
}
}
if(G.zone===2&&!G.flags.galleryPlates){const plates=G.zoneDef.objects.filter(o=>o.type==='plate');if(plates.every(o=>o.until>G.time)){G.flags.galleryPlates=true;addGold(35);AudioSys.sfx('door');shimmer();toast('Two echoes become one. The altar cage lowers.')}}
for(const b of G.projectiles){b.life-=dt;b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0)continue;
if(b.enemy){if(dist(b.x,b.y,p.x,p.y)<22){hurt(2);b.life=0}}else{
for(const o of G.zoneDef.objects){if(dist(b.x,b.y,o.x,o.y)<30){
if(o.type==='switch'&&!G.flags[o.id]){let ok=true;
if(o.id==='vaultSwitch')ok=G.flags.mirror===2;
if(o.id==='coreSwitch')ok=G.zoneDef.objects.find(o=>o.id==='corePlate').until>G.time;
if(ok){G.flags[o.id]=true;AudioSys.sfx('switch');burst(o.x,o.y,'#acedef',40);addGold(25);toast(o.id==='coreSwitch'?'The iron shield fractures. Solar light can reach him.':'The crystal answers. Its seal is broken.')}else toast(o.id==='vaultSwitch'?'Turn the mirror northeast [E] before firing.':'The bronze plate must still be echoing.','warn');
b.life=0;
}else if(['wall','jar'].includes(o.type)&&!G.flags[o.id]){hitObject(o,4);b.life=0}
}}
if(b.life>0)for(const e of G.enemies){if(!e.dead&&dist(b.x,b.y,e.x,e.y)<e.r+9){damageEnemy(e,3,true);b.life=0;break}}
}
}
G.projectiles=G.projectiles.filter(b=>b.life>0&&b.x>90&&b.x<1110&&b.y>110&&b.y<600);
for(const c of G.pickups){if(c.zone!==G.zone||c.taken)continue;const d=dist(c.x,c.y,p.x,p.y);if(d<110){c.x=lerp(c.x,p.x,dt*7);c.y=lerp(c.y,p.y,dt*7)}if(d<25){c.taken=true;addGold(c.amount)}}
findInteraction();
if(G.zone===1&&isClear()&&dist(p.x,p.y,750,240)<180){G.drone=(G.drone||0)-dt;if(G.drone<=0){AudioSys.tone(82,1.5,.035);G.drone=2}}
}
function findInteraction(){
const p=G.player;let nearest=null,best=85;
for(const o of G.zoneDef.objects){if(!['stone','shop','tablet','altar','chest','mirror','beacon'].includes(o.type))continue;if((o.type==='chest'||o.type==='altar')&&G.flags[o.id])continue;
if(o.safe&&!isClear())continue;
let d=dist(p.x,p.y,o.x,o.y);if(d<best){nearest=o;best=d}}
if(p.x>1000&&G.zone<5&&Math.abs(p.y-365)<70)nearest={type:'exit',x:1070,y:365};
if(p.x<185&&G.zone>0&&Math.abs(p.y-365)<45&&best>50)nearest={type:'back',x:125,y:365};
// A depleted Shard can always draw a small emergency reserve from a switch condenser.
if(!nearest&&G.hasRanged&&G.ammo===0)nearest={type:'charge'};
G.interactTarget=nearest;
let text='';if(nearest){const o=nearest;const labels={stone:'Rest & seal your vigil',shop:'Trade with the silent merchant',tablet:'Read the bronze inscription',altar:'Restore '+weaponStats[o.form||'ember'].name,chest:'Open '+(o.label||'reliquary'),mirror:'Turn mirror · '+['West','North','Northeast','South'][G.flags.mirror||0],beacon:'Reignite the Core Beacon',exit:gateOpen()?'Enter '+names[G.zone+1]:'The arch is sealed',back:'Return to '+names[G.zone-1],charge:'Draw 4 emergency charges'};text='<b>E</b> '+labels[o.type]}
$('prompt').innerHTML=text;$('prompt').classList.toggle('show',!!text);
}
function interact(){
const o=G.interactTarget;if(!o)return;
switch(o.type){
case 'exit':if(gateOpen()){G.flags['complete'+G.zone]=true;enterZone(G.zone+1)}else{toast(gateReason(),'warn');AudioSys.sfx('deny')}break;
case 'back':enterZone(G.zone-1,true);break;
case 'stone':openStone(o);break;
case 'shop':openShop();break;
case 'tablet':showModal('An inscription','BRONZE REMEMBERS','<p style="font-style:italic">'+o.text+'</p>','E / Esc to close');G.state='dialog';break;
case 'mirror':G.flags.mirror=((G.flags.mirror||0)+1)%4;AudioSys.sfx('plate');toast('Mirror turned '+['west','north','northeast · aligned','south'][G.flags.mirror]);break;
case 'altar':
if(!isClear()||(o.form==='ignited'&&!G.flags.galleryPlates)){toast('The altar waits for silence'+(o.form==='ignited'?' and two bronze echoes.':'.'),'warn');return}
G.weapon=o.form;G.flags[o.id]=true;AudioSys.sfx('altar');shimmer();burst(o.x,o.y,weaponStats[o.form].color,60,180);toast(weaponStats[o.form].name+' awakened · '+(o.form==='ignited'?'quicker strikes, burning edge':'wider sweep, heavier solar impact'));hud();break;
case 'chest':if(o.requires&&!G.flags[o.requires]){toast('A distant crystal holds this seal. Fire the Aether Shard.','warn');return}G.flags[o.id]=true;AudioSys.sfx('chest');reward(o.reward,o.amount||1);break;
case 'charge':G.ammo=4;AudioSys.sfx('rare');toast('The dormant crystal lends 4 charges.');hud();break;
case 'beacon':if(!isClear()){toast('The First Warden still stands between you and dawn.','warn');return}win();break;
}
}
/* ---------------- drawing: hand-built bronze & slate ---------------- */
function resize(){VW=innerWidth;VH=innerHeight;const d=Math.min(devicePixelRatio||1,2);C.width=VW*d;C.height=VH*d;C.style.width=VW+'px';C.style.height=VH+'px';ctx.setTransform(d,0,0,d,0,0);scale=Math.min(VW/1200,VH/720);offX=(VW-1200*scale)/2;offY=(VH-720*scale)/2;$('titleFx').width=VW*d;$('titleFx').height=VH*d;$('titleFx').style.width=VW+'px';$('titleFx').style.height=VH+'px'}
window.addEventListener('resize',resize);
function line(x1,y1,x2,y2,color,width=1){ctx.strokeStyle=color;ctx.lineWidth=width;ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke()}
function circle(x,y,r,color,fill=false,width=1){ctx.beginPath();ctx.arc(x,y,r,0,TAU);ctx.lineWidth=width;if(fill){ctx.fillStyle=color;ctx.fill()}else{ctx.strokeStyle=color;ctx.stroke()}}
function poly(points,fill,stroke){ctx.beginPath();ctx.moveTo(...points[0]);for(let i=1;i<points.length;i++)ctx.lineTo(...points[i]);ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill()}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=1;ctx.stroke()}}
function glow(x,y,r,color,alpha=.25){const g=ctx.createRadialGradient(x,y,0,x,y,r);g.addColorStop(0,rgba(color,alpha));g.addColorStop(1,rgba(color,0));ctx.fillStyle=g;ctx.fillRect(x-r,y-r,r*2,r*2)}
function text(t,x,y,size=12,color='#a9aaa0',align='center'){ctx.font=size+'px Georgia';ctx.textAlign=align;ctx.fillStyle=color;ctx.fillText(t,x,y)}
function gear(x,y,r,angle,color='#9c7850',teeth=20){ctx.save();ctx.translate(x,y);ctx.rotate(angle);ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();for(let i=0;i<teeth*4;i++){const a=i*TAU/(teeth*4),rr=r*(i%4<2?1:.89);const px=Math.cos(a)*rr,py=Math.sin(a)*rr;if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py)}ctx.closePath();ctx.stroke();circle(0,0,r*.73,color);circle(0,0,r*.23,color);for(let i=0;i<6;i++){let a=i*TAU/6;line(Math.cos(a)*r*.25,Math.sin(a)*r*.25,Math.cos(a)*r*.71,Math.sin(a)*r*.71,color,3)}ctx.restore()}
function diamond(x,y,s,col){poly([[x,y-s],[x+s*.6,y],[x,y+s],[x-s*.6,y]],col,'#d7ebd9');line(x,y-s,x,y+s,rgba('#ffffff',.4))}
function shadow(x,y,r=22){ctx.fillStyle='rgba(0,0,0,.45)';ctx.beginPath();ctx.ellipse(x,y+7,r,r*.4,0,0,TAU);ctx.fill()}
function floorWorld(t){
const z=G.zoneDef||world[0],base=z.color,bronze=z.accent;
ctx.fillStyle='#0a1420';ctx.fillRect(0,0,1200,720);
// Distant valley silhouettes and broken suspension chains.
const sky=ctx.createLinearGradient(0,0,0,720);sky.addColorStop(0,'#152937');sky.addColorStop(.55,'#243945');sky.addColorStop(1,'#0b1723');ctx.fillStyle=sky;ctx.fillRect(0,0,1200,720);
for(let layer=0;layer<3;layer++){let pts=[[0,720]];for(let i=0;i<=16;i++)pts.push([i*85,440+layer*70+Math.sin(i*2.35+layer*3)*70]);pts.push([1200,720]);poly(pts,['#1e303b','#142630','#0c1c26'][layer])}
for(let x of [52,1148]){for(let y=0;y<520;y+=23){ctx.strokeStyle='#4c5554';ctx.lineWidth=3;ctx.beginPath();ctx.ellipse(x,y,6,14,-.13,0,TAU);ctx.stroke()}}
gear(125,616,145,t*.015,'#34464a',32);gear(1090,622,180,-t*.012,'#34434a',38);gear(620,58,260,t*.008,'#465250',48);
// Island masonry, layered relief.
poly([[95,130],[1105,130],[1122,570],[1085,635],[150,646],[80,570]],'#0d171e','#566268');
poly([[95,544],[1105,544],[1084,610],[155,624],[96,591]],'#202b30','#53605d');
for(let x=125;x<1100;x+=65){line(x,569,x-3,612,'#0e191e',3);line(x+8,577,x+3,606,'#49504b')}
ctx.fillStyle=base;ctx.fillRect(105,139,990,425);
// Floor tiles: deterministic worn stone, fine bronze mortar.
for(let row=0;row<9;row++)for(let col=0;col<21;col++){
const x=106+col*48+(row%2)*24,y=143+row*47;if(x>1090)continue;
ctx.fillStyle=shade(base,Math.sin(row*26+col*8)*.036);ctx.fillRect(x,y,Math.min(46,1094-x),45);
line(x,y+45,Math.min(x+46,1094),y+45,'#14252c');
if((row*7+col*13)%17===0){line(x+5,y+12,x+20,y+20,'#14212a');line(x+20,y+20,x+14,y+31,'#14212a')}
}
ctx.strokeStyle=rgba(bronze,.55);ctx.lineWidth=2;ctx.strokeRect(120,157,960,390);ctx.lineWidth=1;ctx.strokeRect(127,164,946,376);
for(let x=150;x<1060;x+=80){diamond(x,172,3,bronze);diamond(x,532,3,bronze)}
// Engraved celestial planisphere.
ctx.save();ctx.translate(605,357);ctx.scale(1,.86);
for(let r of [73,80,140,147,181,187])circle(0,0,r,rgba(bronze,r===187?.45:.27));
for(let i=0;i<60;i++){let a=i*TAU/60;line(Math.cos(a)*174,Math.sin(a)*174,Math.cos(a)*(i%5===0?153:167),Math.sin(a)*(i%5===0?153:167),rgba(bronze,.4))}
for(let i=0;i<8;i++){let a=i*TAU/8;poly([[0,0],[Math.cos(a-.11)*40,Math.sin(a-.11)*40],[Math.cos(a)*135,Math.sin(a)*135],[Math.cos(a+.11)*40,Math.sin(a+.11)*40]],rgba(bronze,.05),rgba(bronze,.26))}
circle(0,0,14,rgba(bronze,.6));ctx.restore();
// Upper retaining wall and celestial windows.
ctx.fillStyle='#15222b';ctx.fillRect(101,93,996,61);ctx.fillStyle='#687066';ctx.fillRect(101,91,996,4);ctx.fillStyle='#877859';ctx.fillRect(100,143,1000,4);
for(let x=175;x<1080;x+=155){
ctx.fillStyle='#0a1821';ctx.beginPath();ctx.moveTo(x-37,138);ctx.lineTo(x-37,103);ctx.quadraticCurveTo(x,38,x+37,103);ctx.lineTo(x+37,138);ctx.fill();
ctx.strokeStyle='#6b6b58';ctx.lineWidth=4;ctx.stroke();line(x,70,x,138,'#686953',2);line(x-34,110,x+34,110,'#4c605b',2);
glow(x,107,47,'#c79559',.12);
}
// Railing pillars and gold lamps.
for(let x of [115,1090])for(let y of [188,520]){
ctx.fillStyle='#142027';ctx.fillRect(x-15,y-21,30,38);ctx.fillStyle='#6d6955';ctx.fillRect(x-18,y-25,36,7);
glow(x,y-32,85,'#ffad55',.27);diamond(x,y-31,8,'#ffc783');
}
// Moss creeping along the rim and dark corrosion veins.
for(let i=0;i<90;i++){let x=134+(i*139%927),y=i%2?154:547;circle(x,y+Math.sin(i*17)*8,2+(i%4),'#3b5148',true)}
for(let i=0;i<13;i++){let x=900+(i*31%177),y=440+(i*37%100);line(x,y,x+10,y-24,'#101a23',3);line(x+10,y-24,x-3,y-31,'#101a23',2);line(x+10,y-24,x+20,y-35,'#101a23',1)}
if(G.zone===3){for(let i=0;i<12;i++){ctx.strokeStyle='rgba(106,170,183,.10)';ctx.beginPath();ctx.ellipse(180+(i*139%830),225+(i*97%300),30+Math.sin(t+i)*7,6,0,0,TAU);ctx.stroke()}}
// Exit portals.
drawDoor(1080,365,G.zone<5&&gateOpen(),false);
if(G.zone>0)drawDoor(122,365,true,true);
}
function drawDoor(x,y,open,back){
glow(x,y,70,open?'#efc477':'#ba6444',.16);ctx.fillStyle='#111c25';ctx.fillRect(x-18,y-47,36,90);ctx.strokeStyle=open?'#ad9a69':'#785541';ctx.lineWidth=4;ctx.strokeRect(x-18,y-47,36,90);
if(!open)for(let xx=-10;xx<=10;xx+=10)line(x+xx,y-41,x+xx,y+40,'#7e634a',3);
else{ctx.strokeStyle='#e0b772';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x+(back?4:-4),y-10);ctx.lineTo(x+(back?-6:6),y);ctx.lineTo(x+(back?4:-4),y+10);ctx.stroke()}
text(back?'RETURN':open?'PASSAGE':'SEALED',x+(back?18:-20),y+70,9,'#b5a280');
}
function drawObject(o,t){
const done=!!G.flags[o.id];const col=G.zoneDef.accent;
if(o.type==='trap'){
circle(o.x,o.y,34,'#16212a',true);circle(o.x,o.y,35,'#78654c');const hot=Math.sin(G.time*1.8+o.phase)>.55;
for(let i=0;i<8;i++){const a=i*TAU/8;poly([[o.x+Math.cos(a)*10,o.y+Math.sin(a)*10],[o.x+Math.cos(a+.2)*29,o.y+Math.sin(a+.2)*29],[o.x+Math.cos(a-.2)*29,o.y+Math.sin(a-.2)*29]],hot?'#c18f69':'#46585d')}
if(hot)glow(o.x,o.y,45,'#ff743a',.18);return;
}
if(o.type==='plate'){
const on=G.flags.galleryPlates||o.until>G.time;ctx.fillStyle=on?'#8a794d':'#494638';ctx.fillRect(o.x-28,o.y-20,56,40);ctx.strokeStyle=on?'#f0d089':'#b08b53';ctx.lineWidth=2;ctx.strokeRect(o.x-28,o.y-20,56,40);gear(o.x,o.y,15,0,on?'#edcb7b':'#8c7957',12);
if(on)glow(o.x,o.y,55,'#edc26b',.16);text(o.id==='plateA'?'I':o.id==='plateB'?'II':'ECHO',o.x,o.y+38,11,'#b9a67a');return;
}
shadow(o.x,o.y,o.type==='wall'||o.type==='shelf'?54:24);
if(o.type==='pillar'){
ctx.fillStyle='#141e26';ctx.fillRect(o.x-26,o.y-9,52,23);ctx.fillStyle='#677065';ctx.fillRect(o.x-22,o.y-24,44,26);ctx.fillStyle='#3e514f';ctx.fillRect(o.x-15,o.y-64,30,42);ctx.fillStyle='#899077';ctx.fillRect(o.x-25,o.y-69,50,9);line(o.x-9,o.y-58,o.x-9,o.y-27,'#7e866f',2);gear(o.x,o.y-49,10,t*.1,'#bd9254',10);
}else if(o.type==='shelf'){
ctx.fillStyle='#312c28';ctx.fillRect(o.x-58,o.y-50,116,66);ctx.strokeStyle='#796046';ctx.lineWidth=4;ctx.strokeRect(o.x-58,o.y-50,116,66);
for(let row=0;row<2;row++){for(let i=0;i<15;i++){ctx.fillStyle=['#765c43','#4e6660','#7d704f','#374955'][i%4];ctx.fillRect(o.x-51+i*7,o.y-44+row*29,5,20);ctx.fillStyle='#b19a63';ctx.fillRect(o.x-51+i*7,o.y-39+row*29,5,1)}line(o.x-56,o.y-20+row*30,o.x+56,o.y-20+row*30,'#9a7851',3)}
}else if(o.type==='wall'){
if(done){for(let i=0;i<9;i++)poly([[o.x-40+i*10,o.y+7],[o.x-36+i*10,o.y-4-i%3*2],[o.x-30+i*10,o.y+10]],'#475451');return}
ctx.fillStyle='#202c32';ctx.fillRect(o.x-46,o.y-33,92,56);ctx.strokeStyle='#61665a';ctx.lineWidth=2;ctx.strokeRect(o.x-46,o.y-33,92,56);
line(o.x-46,o.y-8,o.x+46,o.y-8,'#111e27',2);line(o.x-16,o.y-33,o.x-16,o.y-8,'#111e27',2);line(o.x+22,o.y-8,o.x+22,o.y+23,'#111e27',2);
const crack=G.accessories.lantern?'#9ee6f0':o.id==='shardWall'?'#728f92':'#bda17a';line(o.x+8,o.y-32,o.x-3,o.y-13,crack);line(o.x-3,o.y-13,o.x+7,o.y-1,crack);line(o.x+7,o.y-1,o.x-9,o.y+22,crack);line(o.x-3,o.y-13,o.x-17,o.y-8,crack);
if(G.accessories.lantern)glow(o.x,o.y,70,'#84d1e8',.2);
if(dist(G.player.x,G.player.y,o.x,o.y)<120)text('J · FRACTURE',o.x,o.y-46,10,crack);
}else if(o.type==='jar'){
if(done){poly([[o.x-10,o.y+7],[o.x-5,o.y-1],[o.x+3,o.y+9]],'#856b4b');return}
ctx.fillStyle='#705d43';ctx.beginPath();ctx.ellipse(o.x,o.y-4,13,17,0,0,TAU);ctx.fill();ctx.fillStyle='#9e8052';ctx.fillRect(o.x-9,o.y-22,18,5);line(o.x-9,o.y-6,o.x+9,o.y-6,'#b99560',2);line(o.x+2,o.y-18,o.x-3,o.y+7,'#423b31');
}else if(o.type==='stone'){
const active=!o.safe||isClear();let c=active?'#ffd58a':'#65756d';glow(o.x,o.y-15,100,c,active?.20:.06);
poly([[o.x-28,o.y+6],[o.x,o.y-9],[o.x+28,o.y+6],[o.x,o.y+20]],'#4e5d58','#ac9e77');
ctx.fillStyle='#40524f';ctx.fillRect(o.x-15,o.y-33,30,37);poly([[o.x-18,o.y-33],[o.x,o.y-42],[o.x+18,o.y-33],[o.x,o.y-23]],'#83927b','#beae7c');
diamond(o.x,o.y-50+Math.sin(t*2)*3,17,c);circle(o.x,o.y-50,27,rgba(c,.35));line(o.x-6,o.y-13,o.x+6,o.y-13,c,2);
text(o.label.toUpperCase(),o.x,o.y+41,9,active?'#e6c794':'#6c7e7c');
}else if(o.type==='shop'){
glow(o.x,o.y-22,95,'#e8b45a',.15);ctx.fillStyle='#63543b';ctx.fillRect(o.x-34,o.y-5,68,16);ctx.fillStyle='#ac8b55';ctx.fillRect(o.x-40,o.y-12,80,9);
poly([[o.x-22,o.y-15],[o.x-19,o.y-55],[o.x,o.y-70],[o.x+19,o.y-55],[o.x+22,o.y-15]],'#192a32','#8b805d');ctx.fillStyle='#060f17';ctx.fillRect(o.x-10,o.y-51,20,13);line(o.x-7,o.y-45,o.x+7,o.y-45,'#c99759');gear(o.x,o.y-26,10,0,'#a79a66',10);text('THE SILENT MERCHANT',o.x,o.y+36,9,'#d1b47b');
}else if(o.type==='altar'){
const c=o.form==='solar'?'#ffe3a0':'#ff9a4a';const locked=!isClear()||(o.form==='ignited'&&!G.flags.galleryPlates);
circle(o.x,o.y,40,'#151f27',true);gear(o.x,o.y,38,t*.05,col,16);ctx.fillStyle='#4f5b53';ctx.fillRect(o.x-24,o.y-20,48,29);ctx.fillStyle='#8f8968';ctx.fillRect(o.x-30,o.y-24,60,7);
if(!done){glow(o.x,o.y-40,95,c,.25);poly([[o.x,o.y-75],[o.x+6,o.y-62],[o.x+4,o.y-30],[o.x-4,o.y-30],[o.x-6,o.y-62]],c);line(o.x-12,o.y-30,o.x+12,o.y-30,'#d4b275',4);line(o.x,o.y-28,o.x,o.y-13,'#634f3a',5);
if(locked){for(let dx of [-31,-16,16,31])line(o.x+dx,o.y-80,o.x+dx,o.y+2,'#3f535a',3);ctx.strokeStyle='#698080';ctx.strokeRect(o.x-34,o.y-81,68,85)}
}text(done?'ALTAR RESTORED':o.form==='solar'?'ALTAR OF THE SUN':'ALTAR OF EMBERS',o.x,o.y+58,10,done?'#7d968b':c);
}else if(o.type==='chest'){
const locked=o.requires&&!G.flags[o.requires];ctx.fillStyle=done?'#3e3930':'#775b38';ctx.fillRect(o.x-23,o.y-15,46,30);ctx.strokeStyle='#bc985c';ctx.strokeRect(o.x-23,o.y-15,46,30);ctx.fillStyle='#bb995b';ctx.fillRect(o.x-15,o.y-15,4,30);ctx.fillRect(o.x+12,o.y-15,4,30);line(o.x-23,o.y-4,o.x+23,o.y-4,'#201f20',3);diamond(o.x,o.y-2,5,locked?'#77bdd4':'#d7b578');if(done){ctx.fillStyle='#151e25';ctx.fillRect(o.x-17,o.y-19,34,11)}if(locked)circle(o.x,o.y-7,34,'#6d99a0');
}else if(o.type==='switch'){
// The upper island is visibly unreachable on foot.
poly([[o.x-26,o.y+13],[o.x-17,o.y-4],[o.x+18,o.y-4],[o.x+28,o.y+13]],'#15262f','#5b7b7d');
const c=done?'#ffe0a0':'#7fcce6';glow(o.x,o.y-15,64,c,.22);diamond(o.x,o.y-16+Math.sin(t*2)*3,17,c);circle(o.x,o.y-15,27,rgba(c,.6));text(done?'RESONATING':'K · CRYSTAL',o.x,o.y+38,9,c);
}else if(o.type==='mirror'){
const turn=G.flags.mirror||0;gear(o.x,o.y,26,0,col,12);ctx.save();ctx.translate(o.x,o.y-14);ctx.rotate([-.7,0,.7,1.5][turn]);ctx.fillStyle='#6cabb8';ctx.fillRect(-5,-25,10,50);ctx.strokeStyle='#cab780';ctx.lineWidth=3;ctx.strokeRect(-5,-25,10,50);ctx.restore();
text(['WEST','NORTH','NORTHEAST','SOUTH'][turn],o.x,o.y+43,9,'#b6cec8');
if(turn===2||G.accessories.lantern){ctx.save();ctx.setLineDash([5,9]);line(o.x,o.y-14,840,194,turn===2?'rgba(146,224,235,.5)':'rgba(146,224,235,.2)');ctx.restore()}
}else if(o.type==='tablet'){
poly([[o.x-17,o.y+9],[o.x-17,o.y-23],[o.x+17,o.y-29],[o.x+17,o.y+3]],'#5b675a','#9c926c');for(let i=0;i<4;i++)line(o.x-9,o.y-17+i*5,o.x+9,o.y-20+i*5,'#c1b17b');
}else if(o.type==='beacon'){
const lit=G.ending;gear(o.x,o.y,53,t*.08,'#b99d64',24);gear(o.x,o.y-45,40,-t*.09,'#baa673',16);glow(o.x,o.y-50,130,lit?'#ffe3a0':'#93bfd0',lit?.6:.15);ctx.fillStyle='#4a5c5b';ctx.fillRect(o.x-15,o.y-65,30,64);diamond(o.x,o.y-75,35,lit?'#fff2be':'#405e70');
}
}
function drawPlayer(t){
const p=G.player,w=weaponStats[G.weapon];shadow(p.x,p.y,23);
if(G.accessories.lantern)glow(p.x,p.y,160,'#72bddd',.14);
if(G.weapon==='solar')glow(p.x,p.y,60,'#ffe6a3',.10);
ctx.save();ctx.translate(p.x,p.y);if(p.invuln>0&&Math.floor(p.invuln*14)%2)ctx.globalAlpha=.55;
const walk=Math.sin(p.walk)*4,wide=G.weapon==='solar'?13:8;
line(-wide,0,-wide+walk,13,'#121e29',7);line(wide,0,wide-walk,13,'#121e29',7);
// Wind-tossed cloak, stitched gold rim.
poly([[-13,-25],[-21-walk*.4,6],[-9,3],[0,10],[17+walk*.5,5],[12,-25]],G.accessories.cloak?'#14212b':'#59686c',G.weapon==='solar'?'#edd7a1':'#a69570');
poly([[-7,-24],[-5,2],[4,4],[9,-24]],'#34454f');line(-15,1,0,8,G.accessories.cloak?'#252841':'#b5945e',3);
ctx.fillStyle='#81765b';ctx.fillRect(-11,-28,22,13);poly([[-12,-27],[-10,-38],[0,-44],[10,-38],[12,-27]],'#7f8e89','#b5b797');ctx.fillStyle='#17242c';ctx.fillRect(-8,-34,16,6);line(-4,-31,5,-31,'#f3d597',2);
if(G.accessories.band)circle(15,-12,5,'#dce8e9',false,2);
if(G.accessories.lantern){line(-16,-17,-21,-7,'#b2c5bb',2);diamond(-21,-5,7,'#94e0f4')}
if(G.hasRanged){const cx=-18-Math.cos(p.angle)*4,cy=-20;glow(cx,cy,25,'#8dd9f3',.35);diamond(cx,cy,7+Math.sin(t*4),'#a5e9f4')}
ctx.save();ctx.translate(0,-11);let a=p.angle+(p.swing>0?(-1.35+(.44-p.swing)*7):.35);ctx.rotate(a);
glow(34,0,G.weapon==='ember'?0.1:45,w.color,.28);poly([[15,-3],[49+(G.weapon==='solar'?10:0),-3],[58+(G.weapon==='solar'?10:0),0],[49+(G.weapon==='solar'?10:0),3],[15,3]],w.color,'#f2e1be');line(14,-8,14,8,'#b89b62',3);line(4,0,14,0,'#a78251',4);ctx.restore();
if(p.swing>0&&p.wind<=0){ctx.save();ctx.rotate(p.angle);ctx.strokeStyle=rgba(w.color,Math.min(1,p.swing*3));ctx.lineWidth=G.weapon==='solar'?8:4;ctx.beginPath();ctx.arc(0,-5,w.range*.83,-1.35,1.35);ctx.stroke();ctx.restore()}
if(p.dash>0){circle(0,0,31,'#d2ded6');}ctx.restore();
}
function drawEnemy(e,t){if(e.dead)return;const boss=e.kind==='boss',r=e.r;
if(e.tell>0&&e.kind!=='instrument'){circle(e.tx,e.ty,boss?90:52,'rgba(206,85,56,.14)',true);circle(e.tx,e.ty,boss?90:52,'#d67754',false,1.5);circle(e.tx,e.ty,(boss?90:52)*(1-e.tell/(boss?.85:.65)),'rgba(236,164,104,.55)')}
shadow(e.x,e.y,r*1.2);ctx.save();ctx.translate(e.x,e.y);const sc=boss?1.8:e.kind==='guardian'?1.3:1;ctx.scale(sc,sc);
const metal=e.hit>0?'#e8cda0':e.freeze>0?'#7caab7':'#555d5a';
line(-9,0,-12,12,'#17222a',8);line(9,0,12,12,'#17222a',8);
poly([[-17,-30],[-21,-13],[-12,-2],[0,7],[12,-2],[21,-13],[17,-30]],metal,'#918065');
poly([[-17,-32],[-9,-23],[-20,-17],[-27,-25]],metal,'#8e7a58');poly([[17,-32],[9,-23],[20,-17],[27,-25]],metal,'#8e7a58');
poly([[-10,-28],[-12,-41],[0,-49],[12,-41],[10,-28]],metal,'#8e876c');ctx.fillStyle='#12202a';ctx.fillRect(-10,-37,20,5);line(-7,-35,7,-35,e.freeze>0?'#acddf0':'#e69853',2);
gear(0,-17,9,t*.1,e.freeze>0?'#a1ddea':'#d49758',10);line(0,-25,-5,-7,'#0a1822',2);line(5,-13,12,-8,'#0a1822',2);
if(e.kind==='instrument'){gear(26,-18,17,t,'#92806a',12);diamond(26,-18,8,'#e59d61')}else{line(23,-14,28,17,'#796444',5);poly([[27,0],[33,-27],[40,-29],[33,6]],'#8d8b73','#be9b67')}
if(boss&&!G.flags.coreSwitch){circle(0,-17,43,rgba('#8ac9e0',.4),false,2);glow(0,-17,55,'#5ca2c0',.15)}
if(e.freeze>0){circle(0,-15,35,'#90ddf2');for(let i=0;i<6;i++)diamond(Math.cos(i*TAU/6)*34,-15+Math.sin(i*TAU/6)*34,4,'#a0e6fa')}
ctx.restore();
if(e.hp<e.maxHp||boss){ctx.fillStyle='#101b25';ctx.fillRect(e.x-r-5,e.y-r*2.5-19,r*2+10,4);ctx.fillStyle=e.freeze>0?'#8dd6e8':'#d89761';ctx.fillRect(e.x-r-5,e.y-r*2.5-19,(r*2+10)*Math.max(0,e.hp/e.maxHp),4)}
if(boss)text('THE FIRST WARDEN',e.x,e.y-112,10,'#d1ae7c');
if(e.tell>0){text('!',e.x,e.y-r*2.5-29,24,'#ffb475')}
}
function draw(t,dt){
ctx.save();ctx.clearRect(0,0,VW,VH);ctx.fillStyle='#08121a';ctx.fillRect(0,0,VW,VH);ctx.translate(offX,offY);ctx.scale(scale,scale);
if(G.cam.shake>0){ctx.translate(rand(-1,1)*G.cam.shake,rand(-1,1)*G.cam.shake);G.cam.shake=Math.max(0,G.cam.shake-dt*35)}
floorWorld(t);
if(G.zoneDef&&G.player){
// Plates and traps sit under every foot.
for(const o of G.zoneDef.objects.filter(o=>['plate','trap'].includes(o.type)))drawObject(o,t);
const sorted=[...G.zoneDef.objects.filter(o=>!['plate','trap'].includes(o.type)).map(o=>({y:o.y,draw:()=>drawObject(o,t)})),...G.enemies.map(e=>({y:e.y,draw:()=>drawEnemy(e,t)})),{y:G.player.y,draw:()=>drawPlayer(t)}].sort((a,b)=>a.y-b.y);
sorted.forEach(o=>o.draw());
for(const coin of G.pickups){if(coin.taken||coin.zone!==G.zone)continue;glow(coin.x,coin.y,25,'#ebc271',.24);circle(coin.x,coin.y+Math.sin(t*5)*3,5,'#e9c477',true);circle(coin.x,coin.y+Math.sin(t*5)*3,3,'#8c6238')}
for(const b of G.projectiles){const c=b.enemy?'#f3945b':'#a2edff';glow(b.x,b.y,25,c,.45);line(b.x-b.vx*.035,b.y-b.vy*.035,b.x,b.y,c,3);diamond(b.x,b.y,6,c)}
for(const p of G.particles){if(G.state==='play'){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=35*dt}ctx.globalAlpha=Math.max(0,p.life/p.max);circle(p.x,p.y,p.size,p.color,true)}ctx.globalAlpha=1;G.particles=G.particles.filter(p=>p.life>0);
for(const f of G.floatTexts){if(G.state==='play'){f.life-=dt;f.y-=dt*25}ctx.globalAlpha=Math.max(0,Math.min(1,f.life));text(f.text,f.x,f.y,14,f.color)}ctx.globalAlpha=1;G.floatTexts=G.floatTexts.filter(f=>f.life>0);
// Ambient motes / pale rising vault mist.
for(let i=0;i<50;i++){let x=130+((i*137+t*(G.zone===3?6:2))%940),y=163+((i*73-t*8+100000)%375);ctx.globalAlpha=(Math.sin(t+i)+1)*.14;circle(x,y,G.zone===3?13:1.2,G.zone===3?'#b3d5d6':'#e8c689',true)}ctx.globalAlpha=1;
// Objective and six-stage route.
text('THE VIGIL / '+String(G.zone+1).padStart(2,'0'),119,610,10,'#ae9970','left');
text(goals[G.zone],119,634,13,'#c4bfa9','left');
for(let i=0;i<6;i++){const x=929+i*28;circle(x,615,5,i===G.zone?'#edc87f':i<G.zone?'#8a917b':'#3b4c53',i<=G.zone);if(i<5)line(x+7,615,x+21,615,'#586257')}
text('AURELION · CELESTIAL ARCHIVE',600,682,9,'#63767b');
}
ctx.restore();
}
function drawTitle(t){
const can=$('titleFx'),c=can.getContext('2d'),d=Math.min(devicePixelRatio||1,2);c.setTransform(d,0,0,d,0,0);c.clearRect(0,0,VW,VH);
const cx=VW*.76,cy=VH*.48,R=Math.min(VW*.27,VH*.43);
c.save();c.translate(cx,cy);c.strokeStyle='rgba(204,170,100,.30)';c.lineWidth=1;
for(let r of [R,R*.95,R*.79,R*.74,R*.45]){c.beginPath();c.arc(0,0,r,0,TAU);c.stroke()}
for(let i=0;i<96;i++){let a=i*TAU/96+t*.008;c.beginPath();c.moveTo(Math.cos(a)*R*.94,Math.sin(a)*R*.94);c.lineTo(Math.cos(a)*R*(i%8===0?.82:.90),Math.sin(a)*R*(i%8===0?.82:.90));c.stroke()}
c.save();c.rotate(-.5);c.beginPath();c.ellipse(0,0,R*.91,R*.28,0,0,TAU);c.stroke();c.restore();
c.save();c.rotate(.6);c.beginPath();c.ellipse(0,0,R*.94,R*.25,0,0,TAU);c.stroke();c.restore();
// The observatory: broken towers in a great armillary sphere.
const g=c.createLinearGradient(0,-R,0,R);g.addColorStop(0,'#bca574');g.addColorStop(.45,'#445a60');g.addColorStop(1,'#0e1d28');
c.fillStyle=g;c.beginPath();c.moveTo(-R*.60,R*.6);c.lineTo(-R*.58,R*.05);c.lineTo(-R*.47,-R*.14);c.lineTo(-R*.36,R*.05);c.lineTo(-R*.33,R*.33);c.lineTo(-R*.19,R*.25);c.lineTo(-R*.17,-R*.23);c.lineTo(-R*.07,-R*.23);c.lineTo(-R*.07,-R*.5);c.lineTo(0,-R*.69);c.lineTo(R*.07,-R*.5);c.lineTo(R*.07,-R*.23);c.lineTo(R*.17,-R*.23);c.lineTo(R*.19,R*.25);c.lineTo(R*.34,R*.33);c.lineTo(R*.4,-R*.1);c.lineTo(R*.51,-R*.2);c.lineTo(R*.62,0);c.lineTo(R*.66,R*.6);c.closePath();c.fill();c.stroke();
c.fillStyle='#152b37';for(let row=0;row<4;row++)for(let col=-1;col<=1;col++)c.fillRect(col*R*.10-4,row*R*.13-R*.14,8,R*.065);
c.strokeStyle='#9f8b64';c.beginPath();c.moveTo(-R*.75,R*.53);c.lineTo(R*.75,R*.53);c.stroke();
let halo=c.createRadialGradient(0,-R*.48,0,0,-R*.48,R*.35);halo.addColorStop(0,'rgba(255,220,135,.6)');halo.addColorStop(1,'rgba(238,179,88,0)');c.fillStyle=halo;c.fillRect(-R*.4,-R*.9,R*.8,R*.8);
c.fillStyle='#f6db9f';c.beginPath();c.moveTo(0,-R*.66);c.lineTo(7,-R*.49);c.lineTo(0,-R*.35);c.lineTo(-7,-R*.49);c.fill();
c.restore();
for(let i=0;i<65;i++){c.fillStyle=`rgba(232,199,139,${.1+Math.sin(t+i)*.1})`;c.fillRect((i*193+t*6)%VW,(i*137-t*9+VH*100)%VH,1.5,1.5)}
}
/* ---------------- interface, inventory, economy ---------------- */
const itemInfo={
band:{name:'Warden’s Band',icon:'◉',desc:'A silver wrist ring. Strike 17% faster.',type:'Accessory'},
stems:{name:'Vital Stem',icon:'♧',desc:'Restore 4 HP — two full hearts.',type:'Consumable'},
lantern:{name:'Phantom Lantern',icon:'♜',desc:'Blue light reveals cracks and hidden light paths.',type:'Accessory'},
cloak:{name:'Shadow Cloak',icon:'◈',desc:'Dark cloak trim. Traps deal 1 HP instead of 2.',type:'Accessory'},
seals:{name:'Binding Seal',icon:'❖',desc:'Freeze the nearest construct for 3 seconds.',type:'Consumable'}
};
function toast(msg,kind=''){const el=document.createElement('div');el.className='toast '+kind;el.textContent=msg;$('toastWrap').appendChild(el);setTimeout(()=>el.remove(),3400);while($('toastWrap').children.length>3)$('toastWrap').firstChild.remove()}
function hud(){
const heartPath='M15 26 C12 23 1 16 1 8 C1 -1 12 -1 15 6 C18 -1 29 -1 29 8 C29 16 18 23 15 26 Z';
$('hearts').innerHTML=Array.from({length:G.maxHp/2},(_,i)=>{let hp=clamp(G.hp-i*2,0,2);return `<div class="heart" title="${hp} / 2 HP"><svg viewBox="0 0 30 28"><defs><clipPath id="hc${i}"><rect width="${hp*15}" height="28"/></clipPath></defs><path d="${heartPath}" fill="#263139" stroke="#8e806a" stroke-width="1.2"/><path d="${heartPath}" fill="#cf7757" stroke="#f5b485" stroke-width="1.2" clip-path="url(#hc${i})"/><path d="M5 8 Q6 3 10 5" fill="none" stroke="#f6cea2" stroke-opacity="${hp?'.7':'0'}"/></svg></div>`}).join('')+`<span style="font:10px Georgia;color:#8fa1a4;margin:0 0 5px 5px">${G.hp}/${G.maxHp}</span>`;
$('goldAmt').textContent=G.gold;$('zoneName').textContent=names[G.zone];$('zoneSub').textContent=subtitles[G.zone];$('weaponName').textContent=weaponStats[G.weapon].name;
$('ammoHud').textContent=G.hasRanged?'◇ '+G.ammo+' / 16 · AETHER':'';
$('hintHud').innerHTML='<b>I</b> Inventory <b>1</b> Stem '+G.inventory.stems+' <b>2</b> Seal '+G.inventory.seals+'<br><span style="line-height:2">WASD Move · J Strike · K Bolt · Space Evade · M Sound</span>';
}
function showModal(title,sub,body,note='',buttons=null){
G.state='paused';$('mTitle').textContent=title;$('mSub').textContent=sub;$('mBody').innerHTML=body;$('mNote').textContent=note;$('mBtns').innerHTML='';
(buttons||[{label:'Return',fn:closeModal}]).forEach(b=>{const el=document.createElement('button');el.className='mclose';el.textContent=b.label;el.onclick=b.fn;$('mBtns').appendChild(el)});$('modal').classList.remove('hidden');$('prompt').classList.remove('show');
}
function closeModal(){if(G.state==='dead')return;$('modal').classList.add('hidden');G.state='play';for(const k in Keys)Keys[k]=false;mouse.down=false}
function openInventory(){
let body='<div class="invGrid">';
for(const [key,it] of Object.entries(itemInfo)){
const accessory=it.type==='Accessory',owned=accessory?G.has[key]:G.inventory[key]>0,equipped=G.accessories[key];
body+=`<div class="slot ${owned?'clickable':'empty'} ${equipped?'equipped':''}" ${owned?'tabindex="0" role="button"':''} data-item="${key}"><div class="ic" style="color:${key==='stems'?'#92dca4':accessory?'#b9d6da':'#7dcbe7'}">${it.icon}</div><div><div class="nm">${it.name}${!accessory?' ×'+G.inventory[key]:''}</div><div class="ds">${it.desc}</div><div class="tg ${equipped?'on':'off'}">${!owned?'Undiscovered':accessory?equipped?'Equipped · click to remove':'Click to equip':'Click to use'}</div></div></div>`;
}
body+='</div><div style="margin-top:20px;border-top:1px solid #34414a;padding-top:14px"><span style="color:#e4bd78">'+weaponStats[G.weapon].name+'</span> · '+weaponStats[G.weapon].damage+' damage · '+(G.hasRanged?'Aether Shard equipped, '+G.ammo+' charges':'A quiet, empty off-hand')+'</div>';
showModal('The Warden’s satchel',G.gold+' SIGILS',body,'Accessories are individually toggled. Consumables stack.');G.state='inventory';
document.querySelectorAll('.slot.clickable').forEach(el=>{const fn=()=>{const key=el.dataset.item;if(itemInfo[key].type==='Accessory'){G.accessories[key]=!G.accessories[key];AudioSys.sfx('pickup');openInventory()}else useItem(key)};el.onclick=fn;el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();fn()}}});
}
function shopRows(){return [
{key:'stems',icon:'♧',name:'Vital Stem',desc:'Restores 4 HP · '+G.shopStock.stems+' of 3 remain',price:50},
{key:'seals',icon:'❖',name:'Binding Seal',desc:'Bind a construct for 3 seconds · '+G.shopStock.seals+' of 2 remain',price:75},
{key:'heart',icon:'♡',name:'The Fifth Heart',desc:G.flags.heart5?'Already reclaimed in the Spire.':!G.flags.heart4?'First recover the Gallery’s hidden fourth heart.':'A permanent container · +2 maximum HP · one only',price:300},
{key:'ammo',icon:'◇',name:'Aether Charge',desc:G.flags.complete3?'One charge for the Aether Shard · capacity 16':'Available after the Sunken Vault is completed.',price:10}
]}
function canBuy(row){return G.gold>=row.price&&G.shopStock[row.key]>0&&(row.key!=='heart'||(G.flags.heart4&&!G.flags.heart5))&&(row.key!=='ammo'||(G.flags.complete3&&G.ammo<16))}
function buy(key){const row=shopRows().find(r=>r.key===key);if(!row||!canBuy(row)){AudioSys.sfx('deny');return}G.gold-=row.price;if(key!=='ammo')G.shopStock[key]--;if(key==='heart')reward('heart5');else if(key==='ammo')G.ammo++;else reward(key);AudioSys.sfx('buy');hud();openShop()}
function openShop(){
const body='<p style="font-style:italic;margin-bottom:16px;color:#9eadab">“Even at the end of the heavens, a fair exchange.”</p><div class="shopGrid">'+shopRows().map(r=>`<div class="shopItem ${G.shopStock[r.key]===0?'soldout':''}"><div class="ic">${r.icon}</div><div class="info"><div class="nm">${r.name}</div><div class="ds">${r.desc}</div></div><div class="pr">${r.price} ◉</div><button data-buy="${r.key}" ${canBuy(r)?'':'disabled'}>${G.shopStock[r.key]===0?'Sold out':'Buy'}</button></div>`).join('')+'</div>';
showModal('The silent merchant',G.gold+' GOLDEN SIGILS',body,'Trade does not save progress. Rest at the nearby stone.');G.state='shop';document.querySelectorAll('[data-buy]').forEach(b=>b.onclick=()=>buy(b.dataset.buy));AudioSys.tone(110,.4,.04);
}
const SAVE_KEY='wardens-corrosion-vigil-v1';
function storeSave(rest=true){
if(rest){G.hp=G.maxHp;if(G.hasRanged)G.ammo=16}
const fields=['zone','flags','inventory','accessories','has','weapon','hasRanged','ammo','maxHp','hp','maxContainers','gold','kills','goldEarned','deaths','time','shopStock'];
const data={version:1,x:G.player.x,y:G.player.y,date:Date.now()};for(const f of fields)data[f]=G[f];
checkpoint=JSON.parse(JSON.stringify(data));try{localStorage.setItem(SAVE_KEY,JSON.stringify(data));G.storageOK=true}catch(e){G.storageOK=false}
hud();
}
function loadSave(){
let data=checkpoint;try{const stored=localStorage.getItem(SAVE_KEY);if(stored)data=JSON.parse(stored)}catch(e){}
if(!data||data.version!==1){newGame();return}
world=makeWorld();for(const k of ['zone','flags','inventory','accessories','has','weapon','hasRanged','ammo','maxHp','hp','maxContainers','gold','kills','goldEarned','deaths','time','shopStock'])G[k]=JSON.parse(JSON.stringify(data[k]));
G.player={x:data.x,y:data.y,angle:0,step:0,cool:0,boltCool:0,invuln:2,dash:0,dashCool:0,swing:0,wind:0,walk:0};G.ending=false;G.pickups=[];checkpoint=data;
$('modal').classList.add('hidden');$('titleScreen').classList.add('hidden');$('saveScreen').classList.add('hidden');$('endScreen').classList.add('hidden');$('hud').style.display='block';
AudioSys.unlock();enterZone(G.zone,false,true);G.player.x=data.x;G.player.y=data.y;G.hp=G.maxHp;hud();toast('The stone remembers. Your vigil resumes.');
}
function openStone(o){
storeSave(true);G.state='save';$('saveScreen').classList.remove('hidden');$('saveInfo').innerHTML=names[G.zone]+'<br><br>'+G.maxHp+' HP restored · '+(G.hasRanged?'16 Aether charges · ':'')+G.gold+' sigils retained<br><br>'+(G.storageOK?'Your progress is sealed on this device.':'Browser storage unavailable. Checkpoint lasts for this visit.');AudioSys.sfx('save');burst(o.x,o.y,'#f7d998',36);shimmer();
}
function leaveStone(){$('saveScreen').classList.add('hidden');G.state='play';for(const k in Keys)Keys[k]=false;G.player.invuln=2}
function win(){
G.ending=true;G.flags.beacon=true;G.state='ending';AudioSys.sfx('beacon');shimmer();burst(975,290,'#ffe6a7',130,230);
setTimeout(()=>{G.state='end';$('endScreen').classList.remove('hidden');$('endText').innerHTML='For a moment, the heavens remember their names.<br>The black iron loosens. Below the archive, the storm opens like a tired hand.<br><br>You leave the blade beside the living flame.<br>Not every vigil ends in silence.';$('endStats').innerHTML='<div>CONSTRUCTS<b>'+G.kills+'</b></div><div>SIGILS FOUND<b>'+G.goldEarned+'</b></div><div>VIGIL<b>'+Math.ceil(G.time/60)+' min</b></div>';AudioSys.sfx('win')},2500);
}
function onKeyDown(k,e){
if(k==='m'){AudioSys.toggleMute();toast(AudioSys.muted?'Sound muted':'Sound restored');return}
if(G.state==='title'){if(k==='enter')newGame();return}
if(G.state==='save'){if(k==='escape'||k==='e')leaveStone();return}
if(G.state==='dead')return;
if(G.state==='dialog'){if(k==='e'||k==='escape'||k==='enter')closeModal();return}
if(['inventory','shop','paused'].includes(G.state)){if(k==='escape'||k==='i')closeModal();return}
if(G.state!=='play')return;
if(k==='j'||k==='z')tryAttack();if(k==='k'||k==='x')fireBolt();if(k==='e')interact();if(k==='i')openInventory();if(k==='1')useItem('stems');if(k==='2')useItem('seals');
if(k===' '&&G.player.dashCool<=0){G.player.dash=.22;G.player.dashCool=.85;AudioSys.sfx('swing');burst(G.player.x,G.player.y,'#a2b8ba',8)}
if(k==='escape'){showModal('The vigil pauses','TIME STANDS STILL','<p>'+goals[G.zone]+'</p><div class="keys"><div class="krow"><kbd>WASD</kbd> Move</div><div class="krow"><kbd>J</kbd> Strike</div><div class="krow"><kbd>K</kbd> Fire</div><div class="krow"><kbd>Space</kbd> Evade</div><div class="krow"><kbd>E</kbd> Interact</div></div><p style="font-size:12px;color:#8da3a5;margin-top:15px">Face your target to aim. The Shard gently seeks crystals in front of you. Hearts do not regenerate: use a Vital Stem or rest at one of the three Sanctuary Stones.</p>','Only Sanctuary Stones seal your progress.',[{label:'Resume',fn:closeModal},{label:'Title',fn:()=>{closeModal();G.state='title';$('titleScreen').classList.remove('hidden');checkContinue()}}])}
}
function checkContinue(){try{$('btnCont').disabled=!localStorage.getItem(SAVE_KEY)}catch(e){$('btnCont').disabled=!checkpoint}}
$('btnNew').onclick=newGame;$('btnCont').onclick=loadSave;
$('btnSaveClose').onclick=leaveStone;$('btnSaveRest').onclick=()=>{storeSave(true);AudioSys.sfx('save');leaveStone();toast('Restored. Your vigil is sealed.')};
$('btnSaveWipe').onclick=()=>{leaveStone();showModal('Abandon this vigil?','RETURN TO TITLE','<p>Your sealed Sanctuary checkpoint remains available. Unsealed discoveries will be left behind.</p>','',[{label:'Keep watch',fn:closeModal},{label:'Leave',fn:()=>{closeModal();G.state='title';$('titleScreen').classList.remove('hidden');checkContinue()}}])};
$('btnEndClose').onclick=()=>{$('endScreen').classList.add('hidden');G.state='title';$('titleScreen').classList.remove('hidden');checkContinue()};
// Tap controls for smaller displays. Buttons share exactly the keyboard actions.
const touch=document.createElement('div');touch.id='touchControls';touch.innerHTML='<div class="dpad"><button data-key="w">↑</button><div><button data-key="a">←</button><button data-key="s">↓</button><button data-key="d">→</button></div></div><div class="actions"><button data-key="i">I</button><button data-key=" ">↝</button><button data-key="e">E</button><button data-key="k">◇</button><button data-key="j">⚔</button></div>';document.body.appendChild(touch);
for(const b of touch.querySelectorAll('button')){b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);Keys[b.dataset.key]=true;onKeyDown(b.dataset.key,e)};b.onpointerup=b.onpointercancel=e=>{Keys[b.dataset.key]=false}}
let last=performance.now();
function frame(time){const dt=Math.min(.035,(time-last)/1000);last=time;update(dt);draw(time/1000,dt);if(G.state==='title')drawTitle(time/1000);requestAnimationFrame(frame)}
resize();checkContinue();$('hud').style.display='none';requestAnimationFrame(frame);
</script>
<style>
/* Opening folio: an editorial left column and a celestial engraving. */
#titleScreen{justify-content:flex-start;background:radial-gradient(ellipse at 78% 42%,#263b45 0%,#111f2b 42%,#08111b 85%)}
#titleScreen:before{content:'';position:absolute;inset:22px;border:1px solid rgba(210,180,117,.23);pointer-events:none}
#titleScreen:after{content:'A CELESTIAL ACTION–ADVENTURE / 01—06';position:absolute;left:58px;bottom:38px;font:10px Georgia;letter-spacing:3px;color:#7d9396}
#titleScreen .panel{z-index:1;position:relative;width:55%;max-width:760px;text-align:left;padding:44px 28px 54px 7vw}
#titleScreen .eyebrow{font-size:10px;letter-spacing:3px;color:#b9a77e;margin-bottom:27px}
#titleScreen h1.title{font-size:clamp(40px,4.65vw,72px);letter-spacing:-1px;line-height:1.04;color:#eddbb4;text-shadow:0 2px 22px #091521}
#titleScreen h1.title .thin{font-size:11px;letter-spacing:4px;margin-top:23px;color:#bc9b62;text-transform:uppercase}
#titleScreen .lore{font-size:14px;line-height:1.75;margin:25px 0 22px;max-width:440px;color:#aebdb8}
#titleScreen .keys{justify-content:flex-start;gap:8px 14px;max-width:470px}
#titleScreen .krow{font-size:10px;letter-spacing:0;color:#8ea4a7}
#titleScreen kbd{font-size:10px;padding:3px 6px;min-width:20px;border-color:#526361;background:#1b2a31;color:#c6c1a6}
#titleScreen .btnRow{justify-content:flex-start;gap:12px;margin-top:27px}
#titleScreen .gbtn{font-size:11px;letter-spacing:2px;padding:15px 24px}
#titleScreen .gbtn:not(.ghost){background:#d7b575;color:#14212a;border-color:#e0c38f;box-shadow:0 2px 30px #d7b57518}
#titleScreen .small{font-size:10px;color:#738e93;margin-top:17px}
#titleFx{z-index:0}
#touchControls{display:none;position:fixed;bottom:50px;left:15px;right:15px;z-index:25;justify-content:space-between;align-items:flex-end;pointer-events:none}
#touchControls button{touch-action:none;pointer-events:auto;color:#eed4a1;background:#10212abf;border:1px solid #778373;border-radius:8px;font:20px Georgia;width:42px;height:40px;margin:2px}
.dpad{text-align:center}.actions{max-width:156px;text-align:right}
@media(pointer:coarse){#touchControls{display:flex}#hintHud{display:none}}
@media(max-width:700px){#titleScreen .panel{width:100%;padding:35px;max-width:600px}#titleFx{opacity:.23}#titleScreen .eyebrow{font-size:8px;letter-spacing:2px}#titleScreen .lore{font-size:12px;margin:18px 0}#titleScreen h1.title{font-size:44px}#titleScreen:after{display:none}#titleScreen .keys{gap:7px}#titleScreen .gbtn{padding:12px 16px}#zoneHud{top:58px;width:100%}#zoneHud .z{font-size:10px}#zoneHud .n{font-size:8px}#prompt{font-size:11px;width:max-content;max-width:90vw}#weaponHud{font-size:8px}.toast{font-size:11px;width:max-content;max-width:88vw}.mpanel{padding:20px}.mhead h2{font-size:15px}.shopItem{gap:7px;padding:9px}.shopItem button{padding:8px}.shopItem .nm{font-size:12px}}
@media(max-height:650px) and (min-width:701px){#titleScreen .panel{padding-top:25px;padding-bottom:28px}#titleScreen .eyebrow{margin-bottom:17px}#titleScreen .lore{font-size:12px;margin:18px 0}#titleScreen h1.title{font-size:48px}#titleScreen:after{bottom:24px;font-size:8px}#titleScreen .btnRow{margin-top:18px}}
</style>
</body>
</html>
<!-- agent-meta {"model":"stealth/union-alpha","provider":"openrouter","persona":"full","sessionId":"2b5b6f90-412a-42b2-baf1-5e648a6fdd13","tokensIn":840828,"tokensOut":56605,"tokensTotal":897433,"cost":0,"turns":23,"toolCalls":26,"failedToolCalls":2,"timestamp":"2026-09-17T21:03:05.543Z"} -->