Warden
Mimo v2.6 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 109,102 bytes · SHA-256 2f8345df65c5
<!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:#f5c451; --gold-dim:#b8892c; --orange:#e07a24; --ember:#ff8a3d;
--slate:#0d1422; --slate2:#16223a; --bronze:#8a6a3a; --moss:#6f7f4e;
--ink:#070a11; --text:#e9dfc8; --dim:#9aa3b5; --danger:#e2483c;
--seal:#6fd0ff; --leaf:#79d67a;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#05070c;overflow:hidden}
body{
font-family:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
color:var(--text);display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 30%,#101a2c 0%,#070a11 60%,#04060a 100%);
user-select:none;
}
#stage{
position:relative;width:min(100vw,177.7vh);height:min(56.25vw,100vh);
background:#05070c;overflow:hidden;box-shadow:0 0 90px rgba(0,0,0,.9),0 0 0 1px rgba(245,196,81,.12);
}
canvas#game{position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:auto}
#grain{position:absolute;inset:0;pointer-events:none;opacity:.16;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)' opacity='.5'/></svg>");}
#vig{position:absolute;inset:0;pointer-events:none;
background:radial-gradient(ellipse at center,rgba(0,0,0,0) 45%,rgba(0,0,0,.55) 100%);}
/* ---------- HUD ---------- */
#hud{position:absolute;inset:0;pointer-events:none;font-size:14px}
.panel{background:linear-gradient(180deg,rgba(10,15,26,.86),rgba(6,9,16,.9));
border:1px solid rgba(245,196,81,.28);border-radius:4px;
box-shadow:0 6px 22px rgba(0,0,0,.55),inset 0 1px 0 rgba(245,196,81,.12)}
#hearts{position:absolute;left:16px;top:14px;padding:9px 13px;display:flex;gap:7px;align-items:center}
#hearts svg{width:26px;height:26px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.7))}
#resline{position:absolute;left:16px;top:60px;padding:7px 13px;display:flex;gap:16px;align-items:center}
#resline .res{display:flex;align-items:center;gap:6px;font-weight:600;letter-spacing:.04em}
#resline .res svg{width:17px;height:17px}
#resline .res.gold{color:var(--gold)} #resline .res.ammo{color:var(--seal)}
#zonebox{position:absolute;right:16px;top:14px;padding:9px 14px;text-align:right;min-width:230px}
#zname{font-family:Georgia,"Times New Roman",serif;font-size:15px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold)}
#zsub{font-size:11px;color:var(--dim);letter-spacing:.1em;margin-top:3px;text-transform:uppercase}
#weap{margin-top:7px;padding-top:7px;border-top:1px solid rgba(245,196,81,.2);font-size:12px;letter-spacing:.08em;color:var(--ember);display:flex;justify-content:flex-end;gap:7px;align-items:center}
#accbox{position:absolute;right:16px;top:104px;display:flex;gap:7px}
.tok{width:34px;height:34px;display:grid;place-items:center;border-radius:4px;position:relative}
.tok svg{width:20px;height:20px}
.tok.off{opacity:.32;filter:grayscale(1)}
.tok .k{position:absolute;bottom:-1px;right:2px;font-size:9px;color:var(--gold);font-weight:700}
#keys{position:absolute;left:16px;bottom:14px;padding:8px 12px;font-size:11px;color:var(--dim);letter-spacing:.05em;line-height:1.65}
#keys b{color:var(--gold);font-weight:600}
/* ---------- prompts / toasts ---------- */
#prompt{position:absolute;left:50%;bottom:74px;transform:translateX(-50%);padding:9px 18px;
font-size:13px;letter-spacing:.1em;text-transform:uppercase;opacity:0;transition:opacity .18s;
color:var(--gold);white-space:nowrap}
#prompt.on{opacity:1}
#prompt .key{display:inline-block;border:1px solid var(--gold);border-radius:3px;padding:1px 7px;margin-right:9px;font-weight:700;color:#0b0f18;background:var(--gold)}
#toasts{position:absolute;left:50%;bottom:120px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:7px;align-items:center;pointer-events:none}
.toast{padding:8px 16px;font-size:13px;letter-spacing:.05em;background:rgba(6,9,16,.92);
border:1px solid rgba(245,196,81,.35);border-left:3px solid var(--gold);border-radius:3px;
animation:tin .3s ease;box-shadow:0 8px 24px rgba(0,0,0,.6);max-width:70vw;text-align:center}
.toast.rare{border-color:rgba(111,208,255,.6);border-left-color:var(--seal)}
.toast.dim{border-color:rgba(154,163,181,.35);border-left-color:var(--dim);color:var(--dim)}
@keyframes tin{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.toast.out{opacity:0;transition:opacity .5s}
/* ---------- zone card ---------- */
#zonecard{position:absolute;inset:0;display:grid;place-content:center;text-align:center;pointer-events:none;opacity:0}
#zonecard.show{animation:zcard 3.4s ease forwards}
#zonecard .zc-num{font-size:12px;letter-spacing:.5em;color:var(--orange);text-transform:uppercase;margin-bottom:14px}
#zonecard .zc-name{font-family:Georgia,serif;font-size:clamp(26px,4.4vw,54px);letter-spacing:.14em;
text-transform:uppercase;color:var(--gold);text-shadow:0 0 34px rgba(245,196,81,.45),0 4px 18px #000}
#zonecard .zc-sub{margin-top:14px;font-size:13px;letter-spacing:.22em;color:var(--dim);text-transform:uppercase}
#zonecard .zc-rule{width:180px;height:1px;margin:18px auto;background:linear-gradient(90deg,transparent,var(--gold),transparent)}
@keyframes zcard{0%{opacity:0;transform:scale(1.04)}14%{opacity:1;transform:none}72%{opacity:1}100%{opacity:0}}
/* ---------- overlays ---------- */
.screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;background:radial-gradient(ellipse at center,rgba(9,14,25,.99),rgba(3,5,9,1));z-index:30;padding:4vh 5vw}
.hidden{display:none!important}
h1.title{font-family:Georgia,serif;font-size:clamp(30px,6vw,74px);letter-spacing:.16em;text-transform:uppercase;
color:var(--gold);text-shadow:0 0 44px rgba(245,196,81,.4),0 6px 26px #000;line-height:1.1}
h1.title span{display:block;font-size:.36em;letter-spacing:.42em;color:var(--orange);margin-top:16px}
.rule{width:min(420px,70vw);height:1px;margin:26px auto;background:linear-gradient(90deg,transparent,var(--gold-dim),transparent)}
.lore{max-width:640px;font-size:14.5px;line-height:1.95;color:#c9c2b0;font-style:italic}
.lore b{color:var(--gold);font-style:normal;font-weight:600}
.btnrow{display:flex;gap:14px;margin-top:32px;flex-wrap:wrap;justify-content:center}
.btn{pointer-events:auto;cursor:pointer;font-family:inherit;font-size:13px;letter-spacing:.2em;text-transform:uppercase;
padding:13px 30px;color:var(--gold);background:linear-gradient(180deg,rgba(30,24,12,.9),rgba(12,10,6,.95));
border:1px solid rgba(245,196,81,.5);border-radius:3px;transition:.18s;font-weight:600}
.btn:hover{background:linear-gradient(180deg,rgba(60,44,16,.95),rgba(24,18,8,.95));box-shadow:0 0 24px rgba(245,196,81,.3);transform:translateY(-1px)}
.btn.ghost{color:var(--dim);border-color:rgba(154,163,181,.35)}
.btn.ghost:hover{color:var(--text);box-shadow:0 0 18px rgba(154,163,181,.2)}
.btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none}
.ctrls{margin-top:30px;font-size:12px;color:var(--dim);letter-spacing:.09em;line-height:2}
.ctrls b{color:var(--text);border:1px solid rgba(233,223,200,.3);border-radius:3px;padding:1px 7px;margin:0 3px;font-weight:600}
/* modal (inventory / shop / pause) */
#modal{position:absolute;inset:0;z-index:25;display:flex;align-items:center;justify-content:center;
background:rgba(3,5,9,.78);backdrop-filter:blur(3px)}
.card{width:min(880px,92%);max-height:88%;overflow:auto;background:linear-gradient(180deg,#0c1220,#070b13);
border:1px solid rgba(245,196,81,.35);border-radius:5px;padding:26px 30px 30px;
box-shadow:0 30px 80px rgba(0,0,0,.8),inset 0 1px 0 rgba(245,196,81,.14)}
.card h2{font-family:Georgia,serif;font-size:22px;letter-spacing:.2em;text-transform:uppercase;color:var(--gold);font-weight:400}
.card .sub{font-size:12px;color:var(--dim);letter-spacing:.12em;text-transform:uppercase;margin-top:6px}
.card .head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:1px solid rgba(245,196,81,.2);padding-bottom:16px;margin-bottom:18px}
.close{cursor:pointer;font-size:12px;letter-spacing:.16em;color:var(--dim);border:1px solid rgba(154,163,181,.3);
padding:7px 14px;border-radius:3px;text-transform:uppercase;background:none;font-family:inherit}
.close:hover{color:var(--gold);border-color:var(--gold)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:12px}
.slot{border:1px solid rgba(154,163,181,.22);border-radius:4px;padding:13px;background:rgba(255,255,255,.02);position:relative}
.slot.eq{border-color:rgba(245,196,81,.65);box-shadow:0 0 20px rgba(245,196,81,.14) inset}
.slot .top{display:flex;gap:11px;align-items:center}
.slot .ico{width:38px;height:38px;flex:none;display:grid;place-items:center;border-radius:4px;background:rgba(245,196,81,.08);border:1px solid rgba(245,196,81,.22)}
.slot .ico svg{width:23px;height:23px}
.slot .nm{font-size:13.5px;font-weight:600;letter-spacing:.05em;color:var(--text)}
.slot .ty{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold-dim);margin-top:3px}
.slot .ds{font-size:11.5px;color:var(--dim);line-height:1.65;margin:10px 0 11px;min-height:38px}
.slot .cnt{position:absolute;top:10px;right:11px;font-size:11px;color:var(--gold);font-weight:700;letter-spacing:.06em}
.slot .act{width:100%;font-family:inherit;font-size:11px;letter-spacing:.14em;text-transform:uppercase;padding:8px;
cursor:pointer;border-radius:3px;border:1px solid rgba(245,196,81,.45);background:rgba(245,196,81,.1);color:var(--gold)}
.slot .act:hover{background:rgba(245,196,81,.24)}
.slot .act[disabled]{opacity:.4;cursor:not-allowed}
.empty{grid-column:1/-1;text-align:center;color:var(--dim);font-size:13px;padding:26px;font-style:italic}
.statline{display:flex;gap:26px;flex-wrap:wrap;font-size:12.5px;letter-spacing:.08em;color:var(--dim);margin-bottom:16px}
.statline b{color:var(--gold);font-weight:600}
.shopgold{display:flex;align-items:center;gap:8px;font-size:16px;color:var(--gold);font-weight:700;letter-spacing:.08em}
.shopgold svg{width:19px;height:19px}
.price{font-size:13px;color:var(--gold);font-weight:700;letter-spacing:.06em}
.stock{font-size:10.5px;color:var(--dim);letter-spacing:.1em;text-transform:uppercase}
#death .title,#ending .title{font-family:Georgia,serif;font-size:clamp(28px,5vw,58px);letter-spacing:.18em;text-transform:uppercase}
#death .title{color:#e2483c;text-shadow:0 0 40px rgba(226,72,60,.4)}
#ending .title{color:var(--gold);text-shadow:0 0 44px rgba(245,196,81,.5)}
#ending .lore{font-style:normal;color:#cfc7b2}
.badge{display:inline-block;font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--orange);
border:1px solid rgba(224,122,36,.45);padding:6px 16px;border-radius:2px;margin-bottom:22px}
#flash{position:absolute;inset:0;pointer-events:none;opacity:0;z-index:12}
#edge{position:absolute;inset:0;pointer-events:none;opacity:0;z-index:11;
box-shadow:inset 0 0 90px rgba(245,196,81,.75),inset 0 0 220px rgba(245,196,81,.35);transition:opacity .3s}
#dmgv{position:absolute;inset:0;pointer-events:none;opacity:0;z-index:10;
background:radial-gradient(ellipse at center,rgba(226,72,60,0) 40%,rgba(226,72,60,.55) 100%)}
</style>
</head>
<body>
<div id="stage">
<canvas id="game" width="1280" height="720"></canvas>
<div id="grain"></div>
<div id="vig"></div>
<div id="dmgv"></div>
<div id="edge"></div>
<div id="flash"></div>
<div id="hud">
<div id="hearts" class="panel"></div>
<div id="resline" class="panel">
<div class="res gold" id="goldres"></div>
<div class="res ammo hidden" id="amores"></div>
</div>
<div id="zonebox" class="panel">
<div id="zname">The Ashen Threshold</div>
<div id="zsub">Prologue</div>
<div id="weap"></div>
</div>
<div id="accbox"></div>
<div id="keys" class="panel">
<b>WASD</b> move <b>J / Space</b> strike <b>K</b> aether bolt <b>E</b> interact <b>I</b> inventory <b>1 / 2</b> use items <b>Shift</b> step <b>Esc</b> pause
</div>
<div id="prompt" class="panel"></div>
<div id="toasts"></div>
<div id="zonecard">
<div class="zc-num" id="zcnum"></div>
<div class="zc-name" id="zcname"></div>
<div class="zc-rule"></div>
<div class="zc-sub" id="zcsub"></div>
</div>
</div>
<div id="modal" class="hidden"></div>
<div id="title" class="screen">
<div class="badge">A Celestial Ruin · Action Adventure</div>
<h1 class="title">The Warden's Corrosion<span>Aurelion's Sunken Observatory</span></h1>
<div class="rule"></div>
<p class="lore">The floating archive groans above the storm-locked valley. Black-iron <b>Corrosion</b> creeps through
the great gears, and every guardian but you has gone silent. You are the <b>Last Warden</b>. Carry the Ember Blade
through six failing halls, wake the two ancient altars, find what the vaults hid — and reignite the
<b>Core Beacon</b> before Aurelion falls.</p>
<div class="btnrow">
<button class="btn" id="btnNew">Begin the Vigil</button>
<button class="btn ghost hidden" id="btnCont">Continue</button>
</div>
<div class="ctrls">
<b>WASD</b> move <b>J / Space</b> strike <b>K</b> aether bolt <b>E</b> interact <b>I</b> inventory <b>Shift</b> step-dash<br/>
Sanctuary Stones save your progress · Three hearts at the start, five at the end
</div>
</div>
<div id="death" class="screen hidden">
<div class="title">The Warden Falls</div>
<div class="rule"></div>
<p class="lore" id="deathMsg">The Corrosion swallows the hall once more…</p>
<div class="btnrow">
<button class="btn" id="btnRespawn">Rise at the Sanctuary</button>
<button class="btn ghost" id="btnTitle">Return to Title</button>
</div>
</div>
<div id="ending" class="screen hidden">
<div class="badge">Beacon Restored</div>
<div class="title">Aurelion Burns Again</div>
<div class="rule"></div>
<p class="lore" id="endMsg"></p>
<div class="btnrow"><button class="btn" id="btnEnd">Walk the Halls Once More</button></div>
</div>
</div>
<script>
/* ============================================================
THE WARDEN'S CORROSION — single-file action adventure
============================================================ */
const W = 1280, H = 720;
const cv = document.getElementById('game'), ctx = cv.getContext('2d');
/* ---------------- utils ---------------- */
const rnd = (a,b)=>a+Math.random()*(b-a);
const ri = (a,b)=>Math.floor(rnd(a,b+1));
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
const dist2=(a,b)=>(a.x-b.x)**2+(a.y-b.y)**2;
const angDiff=(a,b)=>{let d=a-b;while(d>Math.PI)d-=Math.PI*2;while(d<-Math.PI)d+=Math.PI*2;return d;};
const TAU=Math.PI*2;
const now=()=>performance.now()/1000;
function aabb(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y;}
function circleRect(cx,cy,r,rc){
const nx=clamp(cx,rc.x,rc.x+rc.w), ny=clamp(cy,rc.y,rc.y+rc.h);
return (cx-nx)**2+(cy-ny)**2 < r*r;
}
const $=id=>document.getElementById(id);
/* ---------------- icons (inline svg) ---------------- */
const HEART_PATH='M12 21.2s-8.2-5.4-8.2-11A4.6 4.6 0 0 1 12 7.4a4.6 4.6 0 0 1 8.2 2.8c0 5.6-8.2 11-8.2 11z';
function heartSVG(fill,id){
const gid='hg'+id;
return `<svg viewBox="0 0 24 24"><defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff7a6a"/><stop offset="1" stop-color="#c22a2a"/></linearGradient>
<clipPath id="c${gid}"><rect x="0" y="0" width="${24*fill}" height="24"/></clipPath></defs>
<path d="${HEART_PATH}" fill="#241017" stroke="#5c2733" stroke-width="1.3"/>
<path d="${HEART_PATH}" fill="url(#${gid})" clip-path="url(#c${gid})" stroke="#ff9a8a" stroke-width=".7"/>
${fill>0.02?`<path d="M8.4 7.6a3 3 0 0 1 2.4-1.5" stroke="rgba(255,255,255,.65)" stroke-width="1.4" fill="none" stroke-linecap="round"/>`:''}
</svg>`;
}
const ICONS={
coin:`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.6" fill="#c99a2b" stroke="#f7dd8a" stroke-width="1.4"/><circle cx="12" cy="12" r="5.4" fill="none" stroke="#8a6a1c" stroke-width="1.2"/><path d="M12 7.6l1.5 3 3.3.4-2.4 2.3.6 3.2-3-1.6-3 1.6.6-3.2-2.4-2.3 3.3-.4z" fill="#ffeaa8" opacity=".85"/></svg>`,
shard:`<svg viewBox="0 0 24 24"><path d="M12 2.5l6 6.5-6 12.5L6 9z" fill="#8fe4ff" stroke="#e6fbff" stroke-width="1.2"/><path d="M12 2.5v19M6 9h12" stroke="#3fa9d8" stroke-width="1"/></svg>`,
band:`<svg viewBox="0 0 24 24"><circle cx="12" cy="13" r="7" fill="none" stroke="#dfe6f2" stroke-width="3"/><circle cx="12" cy="13" r="7" fill="none" stroke="#9fb4d6" stroke-width="1"/><path d="M8.6 8.4a5.6 5.6 0 0 1 3-2" stroke="#fff" stroke-width="1.6" fill="none" stroke-linecap="round"/><path d="M12 3.2l1.6 2.4h-3.2z" fill="#cfe0ff"/></svg>`,
stem:`<svg viewBox="0 0 24 24"><path d="M12 21V9" stroke="#4f9a52" stroke-width="2" stroke-linecap="round"/><path d="M12 11C12 6 15 3.5 19 3c.4 4.2-2.4 7.4-7 8z" fill="#7ee08a" stroke="#2f6b38" stroke-width="1"/><path d="M11.6 14C9 13 6.4 14 5 16.6c2.6 1.7 5.4 1 6.6-2.6z" fill="#5cc46a" stroke="#2f6b38" stroke-width="1"/></svg>`,
lantern:`<svg viewBox="0 0 24 24"><path d="M9 3h6M12 3v2" stroke="#9fd8ff" stroke-width="1.6" stroke-linecap="round"/><rect x="7" y="5" width="10" height="13" rx="2" fill="#123049" stroke="#7cc4f0" stroke-width="1.4"/><rect x="9.4" y="7.6" width="5.2" height="7.6" rx="2.4" fill="#bfe9ff"/><circle cx="12" cy="11.4" r="2.1" fill="#fff"/><path d="M8 19h8l1.6 2H6.4z" fill="#2c5f86" stroke="#7cc4f0" stroke-width="1"/></svg>`,
cloak:`<svg viewBox="0 0 24 24"><path d="M12 3l7 3.4-2 13.6H7L5 6.4z" fill="#1b1f34" stroke="#5b6490" stroke-width="1.3"/><path d="M12 3v17" stroke="#3a4166" stroke-width="1"/><path d="M7 20l1.6-2M17 20l-1.6-2" stroke="#8b93c4" stroke-width="1.2"/><path d="M5 6.4l7 2.2 7-2.2" stroke="#7d86b8" stroke-width="1.1" fill="none"/></svg>`,
seal:`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.4" fill="#0e2c46" stroke="#6fd0ff" stroke-width="1.6"/><circle cx="12" cy="12" r="5.4" fill="none" stroke="#6fd0ff" stroke-width="1" stroke-dasharray="2.6 2.2"/><path d="M12 6.6v10.8M7.8 9.6l8.4 4.8M16.2 9.6l-8.4 4.8" stroke="#cdefff" stroke-width="1.2"/></svg>`,
blade:`<svg viewBox="0 0 24 24"><path d="M4 20l11-13 3-2-1.6 4.4L6.6 21z" fill="#dfe6f2" stroke="#8f9bb3" stroke-width="1"/><path d="M4 20l3-3.4" stroke="#8a6a3a" stroke-width="2.4" stroke-linecap="round"/></svg>`,
gear:`<svg viewBox="0 0 24 24"><path d="M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8z" fill="none" stroke="#f5c451" stroke-width="1.6"/><path d="M12 3v2.4M12 18.6V21M3 12h2.4M18.6 12H21M5.6 5.6l1.7 1.7M16.7 16.7l1.7 1.7M18.4 5.6l-1.7 1.7M7.3 16.7l-1.7 1.7" stroke="#f5c451" stroke-width="1.7" stroke-linecap="round"/></svg>`
};
/* ---------------- audio ---------------- */
const SFX={
ctx:null,master:null,amb:null,ambGain:null,drone:null,droneGain:null,muted:false,
init(){
if(this.ctx)return;
const AC=window.AudioContext||window.webkitAudioContext; if(!AC)return;
this.ctx=new AC();
this.master=this.ctx.createGain(); this.master.gain.value=.55; this.master.connect(this.ctx.destination);
},
resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
t(d=0){ return this.ctx.currentTime+d; },
env(g,t0,a,peak,dur){
g.gain.setValueAtTime(.0001,t0);
g.gain.exponentialRampToValueAtTime(Math.max(peak,.0002),t0+a);
g.gain.exponentialRampToValueAtTime(.0001,t0+dur);
},
tone(freq,dur=.2,type='sine',vol=.3,slide=null,delay=0){
if(!this.ctx||this.muted)return;
const t0=this.t(delay),o=this.ctx.createOscillator(),g=this.ctx.createGain();
o.type=type;o.frequency.setValueAtTime(freq,t0);
if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(slide,20),t0+dur);
this.env(g,t0,.012,vol,dur); o.connect(g);g.connect(this.master);
o.start(t0);o.stop(t0+dur+.05);
},
noise(dur=.2,vol=.3,freq=1200,q=1,delay=0,type='bandpass'){
if(!this.ctx||this.muted)return;
const t0=this.t(delay),len=Math.max(dur,.02),sr=this.ctx.sampleRate;
const buf=this.ctx.createBuffer(1,Math.ceil(sr*len),sr),d=buf.getChannelData(0);
for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*(1-i/d.length);
const src=this.ctx.createBufferSource();src.buffer=buf;
const f=this.ctx.createBiquadFilter();f.type=type;f.frequency.value=freq;f.Q.value=q;
const g=this.ctx.createGain();this.env(g,t0,.006,vol,dur);
src.connect(f);f.connect(g);g.connect(this.master);src.start(t0);
},
/* --- signature sounds --- */
swing(tier){
if(!this.ctx)return;
if(tier===0){ this.noise(.17,.30,1500,1.4); this.tone(320,.14,'triangle',.16,150); }
if(tier===1){ this.noise(.16,.30,1200,1.2); this.tone(240,.2,'sawtooth',.14,110); this.noise(.34,.14,3400,.7,.04,'highpass'); }
if(tier===2){ this.tone(120,.34,'sawtooth',.2,60); this.noise(.3,.34,700,.9,.06); this.tone(58,.5,'sine',.34,40,.06); }
},
hit(tier,crit){
if(crit){ this.tone(880,.12,'square',.16,1400); this.noise(.12,.2,3000,2); }
else if(tier===0){ this.tone(190,.12,'square',.16,90); this.noise(.1,.22,900,1.2); }
else if(tier===1){ this.tone(150,.18,'square',.17,70); this.noise(.2,.2,600,1); this.noise(.3,.1,2600,.8,.03,'highpass'); }
else { this.tone(90,.34,'square',.24,44); this.tone(52,.5,'sine',.3,36); this.noise(.22,.24,500,1); }
},
hurt(){ this.tone(140,.26,'triangle',.3,70); this.noise(.16,.3,300,.8); this.tone(1750,.3,'sine',.12,1650,.03); this.tone(1793,.3,'sine',.1,1700,.05); },
coin(){ this.tone(1180,.07,'square',.14); this.tone(1560,.1,'square',.13,null,.05); },
item(){ [523,659,784,1046].forEach((f,i)=>this.tone(f,.24,'triangle',.16,null,i*.07)); },
rare(){ [392,523,659,784,988].forEach((f,i)=>this.tone(f,.7,'sine',.13,null,i*.045)); this.noise(.7,.06,4200,.6,.1,'highpass'); },
heart(){ [523,698,880,1174].forEach((f,i)=>this.tone(f,.5,'triangle',.16,null,i*.09)); this.tone(261,.9,'sine',.12,null,.05); },
altar(){ [196,294,392,587,784].forEach((f,i)=>this.tone(f,1.1,'sine',.15,null,i*.08)); this.tone(48,1.4,'sine',.3,40,.1); this.noise(1.1,.08,5200,.5,.2,'highpass'); },
shard(){ this.tone(1400,.16,'triangle',.22,320); this.noise(.1,.16,5200,3); this.tone(2093,.3,'sine',.1,2093,.1); },
shardHit(){ this.tone(1760,.16,'sine',.14,1760); this.tone(2637,.2,'sine',.09,null,.05); this.noise(.1,.14,4000,2); },
buy(){ this.tone(880,.09,'square',.16); this.tone(1318,.16,'triangle',.16,null,.07); this.tone(1760,.3,'sine',.12,null,.14); },
deny(){ this.tone(220,.16,'square',.14,140); this.tone(165,.22,'square',.12,110,.06); },
step(zone){
if(!this.ctx)return;
const f=zone===1?900:zone===3?1500:zone===2?520:zone===4?1900:760;
this.noise(.07,.055,f,zone===2?3:1.2);
if(zone===2)this.tone(180,.06,'square',.03,120);
if(zone===3)this.tone(300,.09,'sine',.03,180);
},
doorOpen(){ this.noise(.5,.22,320,.7); this.tone(70,.6,'sine',.2,50); this.tone(392,.5,'triangle',.1,392,.2); },
plate(){ this.noise(.12,.2,420,2); this.tone(120,.16,'square',.12,80); },
breakWall(){ this.noise(.4,.3,700,.8); this.noise(.5,.2,240,.6,.05); this.tone(90,.3,'triangle',.14,60); },
freeze(){ this.tone(1800,.4,'sine',.16,700); this.tone(2400,.5,'triangle',.1,900,.05); this.noise(.4,.1,6000,.6,.05,'highpass'); },
boss(){ this.tone(60,1.4,'sawtooth',.3,34); this.tone(92,1.2,'square',.16,48,.1); this.noise(1.2,.2,220,.7,.05); },
beacon(){ [131,196,262,392,523,784,1046].forEach((f,i)=>this.tone(f,2.2,'sine',.13,null,i*.16)); this.tone(65,3,'sine',.3,65,.2); },
startAmb(kind){
if(!this.ctx)return; this.stopAmb();
const map={fire:[110,164.8,220],rust:[98,146.8,196],vault:[87,130.8,174.6],spire:[123.5,185,246.9],core:[73.4,110,146.8]};
const set=map[kind]||map.fire;
this.ambGain=this.ctx.createGain(); this.ambGain.gain.value=.05; this.ambGain.connect(this.master);
this.amb=set.map(f=>{const o=this.ctx.createOscillator();o.type='sine';o.frequency.value=f;
const l=this.ctx.createOscillator();l.frequency.value=.07+Math.random()*.06;
const lg=this.ctx.createGain();lg.gain.value=1.6;l.connect(lg);lg.connect(o.frequency);
o.connect(this.ambGain);o.start();l.start();return {o,l};});
},
stopAmb(){ if(this.amb){this.amb.forEach(p=>{try{p.o.stop();p.l.stop();}catch(e){}});this.amb=null;}
if(this.ambGain){try{this.ambGain.disconnect();}catch(e){}this.ambGain=null;} },
shopDrone(on){
if(!this.ctx)return;
if(on&&!this.drone){
this.droneGain=this.ctx.createGain();this.droneGain.gain.value=.0001;this.droneGain.connect(this.master);
this.droneGain.gain.exponentialRampToValueAtTime(.07,this.ctx.currentTime+1.2);
this.drone=[82.4,123.5,164.8].map(f=>{const o=this.ctx.createOscillator();o.type='sine';o.frequency.value=f;
const g=this.ctx.createGain();g.gain.value=.5;o.connect(g);g.connect(this.droneGain);o.start();return o;});
}else if(!on&&this.drone){
this.droneGain.gain.exponentialRampToValueAtTime(.0001,this.ctx.currentTime+.5);
const d=this.drone,g=this.droneGain;this.drone=null;this.droneGain=null;
setTimeout(()=>{d.forEach(o=>{try{o.stop()}catch(e){}});try{g.disconnect()}catch(e){}},700);
}
}
};
/* ---------------- input ---------------- */
const keys={}, pressed={};
window.addEventListener('keydown',e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' ','Tab'].includes(e.key))e.preventDefault();
const k=e.key.length===1?e.key.toLowerCase():e.key;
if(!keys[k])pressed[k]=true;
keys[k]=true; SFX.init(); SFX.resume(); handleKey(k);
});
window.addEventListener('keyup',e=>{const k=e.key.length===1?e.key.toLowerCase():e.key;keys[k]=false;});
window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
function down(...ks){return ks.some(k=>keys[k]);}
function hit(...ks){return ks.some(k=>pressed[k]);}
function clearPressed(){for(const k in pressed)pressed[k]=false;}
/* ---------------- particles & floaters ---------------- */
const parts=[], floaters=[], rings=[];
function spawn(o){ if(parts.length>720)return; parts.push(Object.assign({x:0,y:0,vx:0,vy:0,life:.6,t:0,size:3,color:'#fff',glow:true,drag:.92,grav:0,shape:'dot'},o)); }
function burst(x,y,n,color,spd=180,size=3,life=.6,grav=0){
for(let i=0;i<n;i++){const a=rnd(0,TAU),s=rnd(spd*.3,spd);
spawn({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(life*.6,life),color,size:rnd(size*.6,size*1.4),grav,drag:.9});}
}
function ring(x,y,r,color,life=.5,w=3){rings.push({x,y,r0:r*.3,r1:r,color,life,t:0,w});}
function floatText(x,y,txt,color='#fff',size=17){floaters.push({x,y,txt,color,size,t:0,life:.95,vy:-46});}
function updateFx(dt){
for(let i=parts.length-1;i>=0;i--){const p=parts[i];p.t+=dt;
if(p.t>=p.life){parts.splice(i,1);continue;}
p.vy+=p.grav*dt;p.vx*=Math.pow(p.drag,dt*60);p.vy*=Math.pow(p.drag,dt*60);
p.x+=p.vx*dt;p.y+=p.vy*dt;}
for(let i=floaters.length-1;i>=0;i--){const f=floaters[i];f.t+=dt;f.y+=f.vy*dt;f.vy*=.94;
if(f.t>=f.life)floaters.splice(i,1);}
for(let i=rings.length-1;i>=0;i--){const r=rings[i];r.t+=dt;if(r.t>=r.life)rings.splice(i,1);}
}
function drawFx(){
ctx.save();
for(const p of parts){const k=1-p.t/p.life;
ctx.globalAlpha=clamp(k,0,1);
if(p.glow){ctx.globalCompositeOperation='lighter';}
ctx.fillStyle=p.color;
if(p.shape==='dot'){ctx.beginPath();ctx.arc(p.x,p.y,p.size*k+.4,0,TAU);ctx.fill();}
else if(p.shape==='rect'){ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.t*6);ctx.fillRect(-p.size/2,-p.size/2,p.size,p.size);ctx.restore();}
else if(p.shape==='smoke'){ctx.globalAlpha=clamp(k*.35,0,1);ctx.globalCompositeOperation='source-over';
ctx.beginPath();ctx.arc(p.x,p.y,p.size*(1.6-k),0,TAU);ctx.fill();}
ctx.globalCompositeOperation='source-over';
}
ctx.globalCompositeOperation='lighter';
for(const r of rings){const k=1-r.t/r.life;
ctx.globalAlpha=k*.85;ctx.strokeStyle=r.color;ctx.lineWidth=r.w*k+.6;
ctx.beginPath();ctx.arc(r.x,r.y,lerp(r.r0,r.r1,1-k*k),0,TAU);ctx.stroke();}
ctx.globalCompositeOperation='source-over';
for(const f of floaters){const k=1-f.t/f.life;
ctx.globalAlpha=clamp(k*1.4,0,1);
ctx.font=`700 ${f.size}px Georgia,serif`;ctx.textAlign='center';
ctx.lineWidth=4;ctx.strokeStyle='rgba(0,0,0,.75)';ctx.strokeText(f.txt,f.x,f.y);
ctx.fillStyle=f.color;ctx.fillText(f.txt,f.x,f.y);}
ctx.globalAlpha=1;ctx.restore();
}
/* ---------------- screen fx ---------------- */
const FX={shake:0,flash:0,dmg:0,edge:0,hitstop:0,shimmer:0};
function shake(v){FX.shake=Math.max(FX.shake,v);}
function screenFlash(a=.5,color='255,255,255'){FX.flash=Math.max(FX.flash,a);$('flash').style.background=`rgba(${color},${a})`;}
function edgeShimmer(sec=2){FX.edge=Math.max(FX.edge,sec);$('edge').style.opacity=1;}
function updateScreenFx(dt){
FX.shake=Math.max(0,FX.shake-dt*3.2);
FX.flash=Math.max(0,FX.flash-dt*2.4); $('flash').style.opacity=FX.flash;
FX.dmg=Math.max(0,FX.dmg-dt*1.9); $('dmgv').style.opacity=FX.dmg;
FX.edge=Math.max(0,FX.edge-dt*.55); $('edge').style.opacity=clamp(FX.edge/2,0,1);
FX.hitstop=Math.max(0,FX.hitstop-dt);
}
/* ---------------- items ---------------- */
const ITEMS={
band:{name:"Warden's Band",type:'accessory',icon:'band',
desc:"Silver ring from a dust-choked chest. The blade answers the wrist faster — attack speed up."},
stem:{name:'Vital Stem',type:'consumable',icon:'stem',
desc:"A glowing green shoot that still remembers sunlight. Restores two full heart containers (4 HP)."},
lantern:{name:'Phantom Lantern',type:'accessory',icon:'lantern',
desc:"Reward of the Vault's light puzzle. Its blue glow pushes back the dark and reveals hairline cracks."},
cloak:{name:'Shadow Cloak',type:'accessory',icon:'cloak',
desc:"Black-iron weave from the Twin Spire. Halves the harm of spike vents, grates and other traps."},
seal:{name:'Binding Seal',type:'consumable',icon:'seal',
desc:"A runic seal of cold light. Freezes the nearest construct solid for three seconds."}
};
const WEAPONS=[
{name:'Ember Blade',dmg:1,cd:.40,arc:1.15,range:66,color:'#cfd6e4',glow:'#8fa0bd',desc:'Base blade — physical cuts, quick whoosh.'},
{name:'Ignited Edge',dmg:2,cd:.30,arc:1.25,range:70,color:'#ffb066',glow:'#ff7a1e',desc:'Fire/ember damage · faster swings · orange trail · burn-flinch.'},
{name:'Solar Falchion',dmg:3,cd:.55,arc:1.75,range:84,color:'#ffe9a8',glow:'#ffce4a',desc:'Light/solar damage · wide arc · heavy wind-up and deep bass impact.'}
];
const SHOP=[
{id:'stem',name:'Vital Stem',price:50,icon:'stem',max:3,kind:'item',desc:'Restores 2 heart containers.'},
{id:'seal',name:'Binding Seal',price:75,icon:'seal',max:2,kind:'item',desc:'Freezes one construct for 3s.'},
{id:'heart',name:'Heart Container #5',price:300,icon:'heart',max:1,kind:'heart',desc:'The final container. Ten HP total.'},
{id:'ammo',name:'Aether Charge',price:10,icon:'shard',max:99,kind:'ammo',desc:'+1 Aether Shard charge (Zone 4 onward).'}
];
/* ---------------- zones ---------------- */
function border(g0,g1){
const w=[{x:0,y:0,w:W,h:40},{x:0,y:H-40,w:W,h:40},{x:0,y:0,w:40,h:H}];
w.push({x:W-40,y:0,w:40,h:g0}); w.push({x:W-40,y:g1,w:40,h:H-g1});
return w;
}
const ZONES=[];
/* --- 1. The Ashen Threshold --- */
ZONES.push({
key:'threshold',name:'The Ashen Threshold',sub:'Prologue · Movement & the First Blade',
amb:'fire',ambient:'ember',tint:['#2b2118','#131a2a'],
start:{x:140,y:470},
walls:border(320,420).concat([
{x:320,y:200,w:64,h:64},{x:320,y:456,w:64,h:64},{x:640,y:300,w:90,h:120},
{x:920,y:170,w:64,h:64},{x:920,y:480,w:64,h:64},{x:520,y:110,w:130,h:44}
]),
door:{x:1240,y:320,w:40,h:100},
save:{x:150,y:360},
breakables:[
{x:470,y:150,w:42,h:42,kind:'jar',hp:2,loot:{gold:12}},
{x:790,y:560,w:42,h:42,kind:'jar',hp:2,loot:{gold:15}},
{x:1090,y:150,w:42,h:42,kind:'jar',hp:2,loot:{gold:9}}
],
chests:[],plates:[],hazards:[],switches:[],pickups:[],
enemies:[{type:'husk',x:700,y:190},{type:'husk',x:840,y:545},{type:'husk',x:1060,y:360},{type:'skitter',x:620,y:610}],
hints:[
{x:150,y:478,text:'Sanctuary Stone — press E to save and restore'},
{x:640,y:660,text:'WASD to move · J / Space to strike the Ember Blade'},
{x:1150,y:360,text:'Slay the constructs to open the gate'}
],
exit:{x:1170,y:320,w:110,h:100,lock:'clear',open:false,to:1}
});
/* --- 2. The Rusted Archives --- */
ZONES.push({
key:'archives',name:'The Rusted Archives',sub:'Early · Shop, Caches & the Second Stone',
amb:'rust',ambient:'dust',tint:['#232a24','#101822'],
start:{x:110,y:360},
walls:border(300,400).concat([
{x:200,y:120,w:360,h:44},{x:200,y:120,w:44,h:300},{x:560,y:400,w:340,h:44},
{x:900,y:120,w:44,h:330},{x:620,y:540,w:44,h:140},{x:1004,y:320,w:236,h:44},
{x:360,y:560,w:180,h:44},{x:944,y:120,w:60,h:44}
]),
door:{x:1240,y:300,w:40,h:100},
save:{x:640,y:250}, saveGate:'challenge',
shop:{x:150,y:610},
breakables:[
{x:944,y:164,w:60,h:156,kind:'wall',hp:6,loot:null,crack:true},
{x:430,y:300,w:42,h:42,kind:'jar',hp:2,loot:{gold:18}},
{x:1090,y:600,w:42,h:42,kind:'jar',hp:2,loot:{gold:22}}
],
chests:[
{x:1120,y:120,w:56,h:46,item:'band',label:'Dust-choked Coffer',opened:false},
{x:760,y:600,w:56,h:46,gold:35,label:'Fallen Satchel',opened:false}
],
plates:[],hazards:[],switches:[],
pickups:[{x:1050,y:200,type:'ammo',amount:3}],
enemies:[{type:'husk',x:300,y:600},{type:'skitter',x:470,y:250}],
wave:{trigger:{x:470,y:60,w:520,h:600},spawn:[
[{type:'husk',x:520,y:170},{type:'husk',x:770,y:170},{type:'husk',x:700,y:530}],
[{type:'skitter',x:540,y:480},{type:'skitter',x:840,y:470},{type:'sentinel',x:860,y:300}]
],done:false,started:false},
hints:[
{x:150,y:690,text:'Merchant Altar — press E to trade golden sigils'},
{x:640,y:180,text:'The stone wakes only after the challenge'},
{x:1150,y:60,text:'Something glints behind the cracked shelf'}
],
exit:{x:1170,y:300,w:110,h:100,lock:'clear',open:false,to:2}
});
/* --- 3. The Collapsed Gallery --- */
ZONES.push({
key:'gallery',name:'The Collapsed Gallery',sub:'Mid-Early · Ignited Edge & the Pressure Plates',
amb:'fire',ambient:'ember',tint:['#2c2119','#141a2b'],
start:{x:110,y:565},
walls:border(330,430).concat([
{x:470,y:110,w:130,h:44},{x:710,y:110,w:130,h:44},
{x:300,y:250,w:64,h:64},{x:980,y:250,w:64,h:64},{x:300,y:520,w:64,h:64},
{x:980,y:520,w:64,h:64},{x:620,y:330,w:44,h:44},
{x:60,y:240,w:200,h:40},{x:60,y:440,w:200,h:40},
{x:220,y:240,w:40,h:60},{x:220,y:380,w:40,h:60}
]),
door:{x:1240,y:330,w:40,h:100},
altar:{x:640,y:175,type:'w1',taken:false},
breakables:[
{x:220,y:300,w:40,h:80,kind:'wall',hp:8,loot:null,crack:true},
{x:820,y:600,w:42,h:42,kind:'jar',hp:2,loot:{gold:20}},
{x:420,y:600,w:42,h:42,kind:'jar',hp:2,loot:{gold:14}}
],
chests:[
{x:110,y:330,w:56,h:46,container:4,label:'Cracked Reliquary',opened:false}
],
plates:[{x:300,y:600,w:130,h:64,id:'p1',on:false,t:0},{x:840,y:600,w:130,h:64,id:'p2',on:false,t:0}],
hazards:[
{x:540,y:470,w:180,h:60,period:2.4,phase:0,duty:.5},
{x:540,y:200,w:180,h:60,period:2.4,phase:1.2,duty:.5}
],
switches:[],pickups:[],
enemies:[
{type:'castellan',x:640,y:440},{type:'knight',x:400,y:190},{type:'husk',x:880,y:470},
{type:'husk',x:900,y:180},{type:'skitter',x:430,y:430},{type:'sentinel',x:1080,y:560}
],
hints:[
{x:640,y:70,text:'Weapon Altar I — press E to ignite the blade'},
{x:315,y:690,text:'Two plates · press both within a breath'},
{x:150,y:250,text:'Hairline cracks hide more than dust'}
],
exit:{x:1170,y:330,w:110,h:100,lock:'plates',open:false,to:3}
});
/* --- 4. The Sunken Vault (halfway) --- */
ZONES.push({
key:'vault',name:'The Sunken Vault',sub:'Mid-Game · The Aether Shard & the Lantern',
amb:'vault',ambient:'mist',tint:['#152833','#0b141f'],dark:235,
start:{x:110,y:360},
walls:border(300,400).concat([
{x:360,y:60,w:40,h:180},{x:560,y:60,w:40,h:180},{x:360,y:60,w:240,h:40},
{x:400,y:240,w:40,h:40},{x:520,y:240,w:40,h:40},
{x:700,y:60,w:44,h:230},{x:700,y:450,w:44,h:230},
{x:860,y:300,w:70,h:70},{x:240,y:520,w:220,h:44},{x:240,y:520,w:44,h:160}
]),
door:{x:1240,y:300,w:40,h:100},
altar:{x:480,y:150,type:'shard',taken:false},
breakables:[
{x:440,y:240,w:80,h:40,kind:'wall',hp:6,loot:null,crack:true},
{x:300,y:170,w:42,h:42,kind:'jar',hp:2,loot:{gold:16}},
{x:980,y:620,w:42,h:42,kind:'jar',hp:2,loot:{gold:24}}
],
chests:[{x:150,y:600,w:56,h:46,gold:45,label:'Barnacled Casket',opened:false}],
plates:[{x:520,y:600,w:130,h:64,id:'p1',on:false,t:0}],
hazards:[{x:760,y:560,w:200,h:60,period:2.6,phase:.6,duty:.5}],
lightPuzzle:{solved:false,seq:[0,2,1],step:0,t:0,
crystals:[{x:900,y:150},{x:1130,y:360},{x:900,y:570}]},
switches:[{x:1180,y:640,r:28,id:'exit',shot:false}],
pickups:[{x:640,y:660,type:'ammo',amount:4},{x:300,y:400,type:'ammo',amount:3}],
enemies:[
{type:'husk',x:600,y:430},{type:'husk',x:820,y:640},{type:'sentinel',x:1000,y:240},
{type:'sentinel',x:790,y:150},{type:'skitter',x:520,y:620},{type:'knight',x:1120,y:560}
],
hints:[
{x:480,y:330,text:'A hairline crack in the vault floor — strike it'},
{x:1010,y:360,text:'Three crystals · strike them in the order they pulse'},
{x:1180,y:560,text:'A distant switch answers only the Aether Shard'}
],
exit:{x:1170,y:300,w:110,h:100,lock:'switch',open:false,to:4}
});
/* --- 5. The Twin Spire --- */
ZONES.push({
key:'spire',name:'The Twin Spire',sub:'Late · Solar Falchion, Gauntlet & the Fifth Heart',
amb:'spire',ambient:'spark',tint:['#2e2415','#141a2b'],
start:{x:110,y:205},
walls:border(320,420).concat([
{x:470,y:110,w:130,h:44},{x:710,y:110,w:130,h:44},
{x:60,y:500,w:200,h:40},{x:220,y:500,w:40,h:40},{x:220,y:620,w:40,h:60},
{x:940,y:40,w:40,h:160},{x:940,y:280,w:40,h:120},{x:940,y:400,w:300,h:40},
{x:420,y:300,w:64,h:64},{x:760,y:300,w:64,h:64},{x:600,y:540,w:80,h:60}
]),
door:{x:1240,y:320,w:40,h:100},
save:{x:150,y:360},
altar:{x:640,y:175,type:'w2',taken:false},
breakables:[
{x:220,y:540,w:40,h:80,kind:'wall',hp:8,loot:null,crack:true},
{x:420,y:160,w:42,h:42,kind:'jar',hp:2,loot:{item:'seal'}},
{x:860,y:620,w:42,h:42,kind:'jar',hp:2,loot:{gold:26}}
],
chests:[
{x:110,y:590,w:56,h:46,item:'cloak',label:'Sealed Reliquary',opened:false},
{x:1060,y:150,w:56,h:46,container:5,label:'Heart of the Spire',opened:false},
{x:1130,y:560,w:60,h:50,gold:100,label:'Warden’s Tithe-Chest',opened:false}
],
doorChamber:{x:940,y:200,w:40,h:80,open:false},
plates:[],hazards:[
{x:470,y:420,w:180,h:60,period:2.2,phase:0,duty:.5},
{x:760,y:420,w:180,h:60,period:2.2,phase:1.1,duty:.5}
],
switches:[{x:700,y:640,r:28,id:'h5',shot:false}],
pickups:[{x:340,y:640,type:'ammo',amount:3}],
enemies:[{type:'husk',x:380,y:280},{type:'skitter',x:300,y:620}],
wave:{trigger:{x:430,y:40,w:700,h:640},spawn:[
[{type:'husk',x:520,y:250},{type:'husk',x:700,y:250},{type:'skitter',x:600,y:480},{type:'skitter',x:840,y:480}],
[{type:'sentinel',x:560,y:620},{type:'sentinel',x:860,y:200},{type:'husk',x:700,y:560},{type:'husk',x:480,y:420}],
[{type:'knight',x:560,y:300},{type:'knight',x:820,y:340},{type:'skitter',x:660,y:600},{type:'sentinel',x:960,y:560}]
],done:false,started:false},
hints:[
{x:150,y:450,text:'Sanctuary Stone III — the last rest before the ascent'},
{x:640,y:70,text:'Weapon Altar II — press E to take up the Solar Falchion'},
{x:700,y:560,text:'A switch far below answers only a crystal bolt'},
{x:150,y:560,text:'Cracked stone… the cloak of a dead warden'}
],
exit:{x:1170,y:320,w:110,h:100,lock:'clear',open:false,to:5}
});
/* --- 6. The Core Chamber --- */
ZONES.push({
key:'core',name:'The Core Chamber',sub:'Climax · Reignite the Beacon',
amb:'core',ambient:'spark',tint:['#33200f','#10131f'],
start:{x:545,y:645},
walls:border(300,420).concat([
{x:240,y:230,w:64,h:64},{x:976,y:230,w:64,h:64},
{x:240,y:500,w:64,h:64},{x:976,y:500,w:64,h:64},{x:600,y:580,w:80,h:60},
{x:560,y:60,w:160,h:44}
]),
door:null,
switches:[{x:150,y:150,r:30,id:'bs1',shot:false},{x:1130,y:150,r:30,id:'bs2',shot:false}],
breakables:[{x:400,y:620,w:42,h:42,kind:'jar',hp:2,loot:{gold:30}},{x:840,y:620,w:42,h:42,kind:'jar',hp:2,loot:{item:'stem'}}],
chests:[],plates:[],hazards:[],pickups:[
{x:120,y:640,type:'ammo',amount:4},{x:1160,y:640,type:'ammo',amount:4},{x:640,y:660,type:'heal',amount:4}
],
enemies:[{type:'boss',x:640,y:300}],
hints:[{x:640,y:690,text:'Two crystal switches feed the Beacon — the shield fails when they break'}],
exit:null
});
const ZNAME_NUM=['I','II','III','IV','V','VI'];
/* ---------------- game state ---------------- */
const ETYPES={
husk:{hp:3,r:17,spd:82,dmg:1,gold:[5,9],col:'#8b9a84',dark:'#3d4a3c',name:'Rusted Husk'},
skitter:{hp:2,r:14,spd:140,dmg:1,gold:[5,8],col:'#c2a06a',dark:'#5b4526',name:'Skittering Gear'},
sentinel:{hp:5,r:19,spd:64,dmg:1,gold:[8,13],col:'#9aa8c4',dark:'#3c4763',name:'Broken Instrument',shoot:1.9},
knight:{hp:9,r:23,spd:58,dmg:1,gold:[10,15],col:'#b08d5a',dark:'#4d3a1f',name:'Rusted Sentinel',shoot:2.6},
castellan:{hp:26,r:31,spd:60,dmg:2,gold:[45,60],col:'#d0a05a',dark:'#5a3d1a',name:'Gallery Castellan',boss:1},
boss:{hp:80,r:42,spd:66,dmg:2,gold:[0,0],col:'#7d5a3a',dark:'#2a1c12',name:'The Corroded Warden',boss:2}
};
const G={
mode:'title',zi:0,Z:null,time:0,started:false,
enemies:[],shots:[],eshots:[],pickups:[],
progress:{},shop:{stem:3,seal:2,heart:1,ammo:99},
modal:null,promptText:'',hintIdx:0,ambT:0,runStart:0,kills:0
};
const P={
x:140,y:470,vx:0,vy:0,r:15,face:-Math.PI/2,
hp:6,maxHp:6,containers:3,gold:0,
tier:0,hasShard:false,ammo:0,maxAmmo:12,
items:{stem:1,seal:0},
owned:{band:false,lantern:false,cloak:false},
eq:{band:false,lantern:false,cloak:false},
atkCd:0,atkT:0,swing:0,invuln:0,walkT:0,stepT:0,dashCd:0,dashT:0,
burnFx:0,hurtT:0,dead:false,restT:0
};
function weapon(){return WEAPONS[P.tier];}
function maxHp(){return P.containers*2;}
function prog(){ if(!G.progress[G.zi])G.progress[G.zi]={broken:[],chests:[],altar:false,sw:{},puzzle:false,wave:false,save:false};
return G.progress[G.zi]; }
/* ---------------- collision ---------------- */
function solids(){
const Z=G.Z,s=Z.walls.slice();
if(Z.door&&!Z.doorOpen)s.push(Z.door);
if(Z.doorChamber&&!Z.doorChamber.open)s.push(Z.doorChamber);
for(const b of Z.breakables)if(!b.dead)s.push(b);
return s;
}
function moveBody(e,dx,dy){
const S=solids();
e.x+=dx;
for(const s of S) if(aabb({x:e.x-e.r,y:e.y-e.r,w:e.r*2,h:e.r*2},s)){
if(dx>0)e.x=s.x-e.r; else if(dx<0)e.x=s.x+s.w+e.r;
}
e.y+=dy;
for(const s of S) if(aabb({x:e.x-e.r,y:e.y-e.r,w:e.r*2,h:e.r*2},s)){
if(dy>0)e.y=s.y-e.r; else if(dy<0)e.y=s.y+s.h+e.r;
}
e.x=clamp(e.x,e.r+2,W-e.r-2); e.y=clamp(e.y,e.r+2,H-e.r-2);
}
/* ---------------- damage ---------------- */
function damageEnemy(e,amount,opts={}){
if(e.dead)return;
const shielded=e.shield;
let dmg=amount;
if(shielded)dmg=Math.max(1,Math.round(amount*.25));
e.hp-=dmg; e.hurtT=.18;
const col=shielded?'#7fd8ff':(opts.tier===2?'#ffe08a':opts.tier===1?'#ff9a3d':'#ffb46a');
floatText(e.x,e.y-e.r-8,''+dmg,col,opts.tier===2?22:17);
burst(e.x,e.y,shielded?7:10,shielded?'#7fd8ff':'#ff9b3d',200,3,.5);
if(opts.tier===1&&!shielded){e.burn=2.2; burst(e.x,e.y,4,'#ff7a1e',70,4,.9,0);}
if(opts.tier===2){e.kx=Math.cos(opts.ang)*180;e.ky=Math.sin(opts.ang)*180; FX.hitstop=.05;}
SFX.hit(opts.tier||0,shielded);
if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
e.dead=true; G.kills++;
const t=ETYPES[e.type];
burst(e.x,e.y,e.boss?46:16,e.boss?'#ffcf6a':'#cfd6e4',e.boss?340:220,4,.8,60);
ring(e.x,e.y,e.r*2.4,'#ffb46a',.5,3);
if(t.gold[1]>0){const g=ri(t.gold[0],t.gold[1]);
for(let i=0;i<Math.min(5,Math.ceil(g/4));i++)
G.pickups.push({type:'gold',amount:Math.ceil(g/Math.min(5,Math.ceil(g/4))),x:e.x+rnd(-14,14),y:e.y+rnd(-14,14),t:0,r:9});
}
if(Math.random()<.22&&P.hasShard&&P.ammo<P.maxAmmo)
G.pickups.push({type:'ammo',amount:1,x:e.x+rnd(-10,10),y:e.y+rnd(-10,10),t:0,r:9});
if(e.type==='castellan'){G.pickups.push({type:'gold',amount:40,x:e.x,y:e.y-20,t:0,r:11});}
if(e.type==='boss'){ setTimeout(bossDefeated,900); }
G.enemies=G.enemies.filter(x=>x!==e);
}
function takeDamage(n,sx,sy){
if(P.invuln>0||P.dead||G.mode!=='play')return;
if(P.eq.cloak&&n>=2)n=1;
P.hp-=n; P.invuln=.95; P.hurtT=.5;
FX.dmg=1; shake(9); SFX.hurt();
burst(P.x,P.y,18,'#e2483c',240,3.4,.6,40);
ring(P.x,P.y,46,'#ff5a4a',.42,3);
floatText(P.x,P.y-30,'-'+n,'#ff6a5a',20);
const a=Math.atan2(P.y-sy,P.x-sx); P.x+=Math.cos(a)*16; P.y+=Math.sin(a)*16;
hud();
if(P.hp<=0){P.hp=0;P.dead=true;die();}
}
function heal(n){const before=P.hp;P.hp=clamp(P.hp+n,0,maxHp());
if(P.hp>before){SFX.item();burst(P.x,P.y,16,'#7ee08a',140,3,.7,-30);floatText(P.x,P.y-28,'+'+(P.hp-before),'#8ff29a',19);hud();}}
function addContainer(){
P.containers=Math.min(5,P.containers+1); P.maxHp=maxHp(); P.hp=Math.min(maxHp(),P.hp+2);
SFX.heart(); edgeShimmer(2); shake(5);
burst(P.x,P.y,44,'#ffd76a',260,4,1.1,-60);
ring(P.x,P.y,90,'#ffe08a',.9,5); ring(P.x,P.y,60,'#fff3c4',.7,3);
floatText(P.x,P.y-42,'HEART CONTAINER','#ffd76a',20);
hud();
}
/* ---------------- player update ---------------- */
function updatePlayer(dt){
if(P.dead)return;
P.atkCd=Math.max(0,P.atkCd-dt); P.atkT=Math.max(0,P.atkT-dt);
P.invuln=Math.max(0,P.invuln-dt); P.hurtT=Math.max(0,P.hurtT-dt);
P.dashCd=Math.max(0,P.dashCd-dt); P.dashT=Math.max(0,P.dashT-dt);
let ix=0,iy=0;
if(down('a','ArrowLeft'))ix--; if(down('d','ArrowRight'))ix++;
if(down('w','ArrowUp'))iy--; if(down('s','ArrowDown'))iy++;
const moving=(ix||iy);
let spd=252;
if(P.dashT>0){spd=640;}
if(moving){
const l=Math.hypot(ix,iy); ix/=l; iy/=l;
P.face=Math.atan2(iy,ix);
moveBody(P,ix*spd*dt,iy*spd*dt);
P.walkT+=dt*10;
P.stepT-=dt;
if(P.stepT<=0){SFX.step(G.zi);P.stepT=.30;}
if(P.tier>=1){ // ember trail
spawn({x:P.x-Math.cos(P.face)*10+rnd(-5,5),y:P.y-Math.sin(P.face)*10+rnd(-5,5),
vx:rnd(-14,14),vy:rnd(-30,-6),life:.5,size:rnd(2,4),
color:P.tier===2?'#ffd76a':'#ff7a1e',grav:-14,drag:.94});
}
if(P.eq.lantern&&Math.random()<.4)
spawn({x:P.x+rnd(-24,24),y:P.y+rnd(-24,24),vx:rnd(-8,8),vy:rnd(-22,-6),life:1.1,size:rnd(1.5,3),color:'#9fdcff',grav:-6});
} else P.stepT=Math.min(P.stepT,.12);
/* attack */
if(hit('j',' ')){ trySwing(); }
if(hit('k')){ tryShoot(); }
if(hit('Shift')&&P.dashCd<=0&&moving){P.dashT=.16;P.dashCd=.85;P.invuln=Math.max(P.invuln,.22);
SFX.noise(.14,.14,900,1.2); burst(P.x,P.y,8,'#cfd6e4',120,3,.4);}
if(P.atkT>0)resolveSwing();
}
function trySwing(){
if(P.atkCd>0)return;
const w=weapon();
P.atkCd=w.cd*(P.eq.band?.85:1);
P.atkT=.20; P.swing++;
SFX.swing(P.tier);
const w2=weapon();
for(let i=0;i<(P.tier===2?14:8);i++){
const a=P.face+rnd(-.5,.5);
spawn({x:P.x+Math.cos(a)*22,y:P.y+Math.sin(a)*22,
vx:Math.cos(a)*rnd(60,180),vy:Math.sin(a)*rnd(60,180),life:.32,size:rnd(2,4),
color:P.tier===2?'#ffd76a':P.tier===1?'#ff7a1e':'#cfd6e4',drag:.88});
}
}
function resolveSwing(){
const w=weapon();
for(const e of G.enemies){
if(e.dead||e.lastSwing===P.swing)continue;
const d=dist(P,e);
if(d>w.range+e.r)continue;
const a=Math.atan2(e.y-P.y,e.x-P.x);
if(Math.abs(angDiff(a,P.face))>w.arc/2+.25)continue;
e.lastSwing=P.swing;
damageEnemy(e,w.dmg,{tier:P.tier,ang:a});
}
/* breakables, switches, crystals */
const Z=G.Z;
for(const b of Z.breakables){
if(b.dead||b.hitSwing===P.swing)continue;
const d=dist(P,{x:b.x+b.w/2,y:b.y+b.h/2});
if(d>w.range+Math.max(b.w,b.h)/2)continue;
const a=Math.atan2(b.y+b.h/2-P.y,b.x+b.w/2-P.x);
if(Math.abs(angDiff(a,P.face))>w.arc/2+.3)continue;
b.hitSwing=P.swing; hitBreakable(b,w.dmg);
}
}
function tryShoot(){
if(!P.hasShard){toast('You have no ranged weapon yet.','dim');return;}
if(P.ammo<=0){SFX.deny();toast('No Aether charges — buy one at the Archives (10g).','dim');return;}
if(P.atkCd>0.06)return;
P.ammo=Math.max(0,P.ammo-1); P.atkCd=.34;
SFX.shard();
G.shots.push({x:P.x+Math.cos(P.face)*20,y:P.y+Math.sin(P.face)*20,
vx:Math.cos(P.face)*640,vy:Math.sin(P.face)*640,life:1.5,r:7,dmg:2,ang:P.face});
burst(P.x+Math.cos(P.face)*20,P.y+Math.sin(P.face)*20,7,'#9fe8ff',120,3,.4);
hud();
}
function hitBreakable(b,dmg){
b.hp-=dmg; SFX.breakWall();
burst(b.x+b.w/2,b.y+b.h/2,8,b.kind==='jar'?'#c9a05a':'#8a7a63',170,3.4,.6,80);
shake(2);
if(b.hp<=0){
b.dead=true; prog().broken[Z_BREAK_ID(b)]=true;
SFX.breakWall(); shake(5);
burst(b.x+b.w/2,b.y+b.h/2,22,b.kind==='jar'?'#e0b96a':'#a08a6a',260,4,.8,120);
const l=b.loot||{};
if(l.gold)for(let i=0;i<Math.min(4,Math.ceil(l.gold/8));i++)
G.pickups.push({type:'gold',amount:Math.ceil(l.gold/Math.min(4,Math.ceil(l.gold/8))),
x:b.x+b.w/2+rnd(-12,12),y:b.y+b.h/2+rnd(-12,12),t:0,r:9});
if(l.item){pickupItem(l.item);}
toast(b.kind==='jar'?'The jar shatters.':'The cracked stone gives way.','dim');
}
}
function Z_BREAK_ID(b){return G.Z.breakables.indexOf(b);}
/* ---------------- pickups ---------------- */
function pickupItem(id){
const it=ITEMS[id];
if(it.type==='consumable'){P.items[id]=(P.items[id]||0)+1;SFX.item();
toast('Acquired — '+it.name+' ('+P.items[id]+')');}
else { if(P.owned[id]){P.gold+=30;toast('Already owned — the archivists left 30 sigils instead.','dim');}
else {P.owned[id]=true;P.eq[id]=true;SFX.rare();edgeShimmer(1.4);
toast('ACQUIRED — '+it.name+' · equipped','');} }
burst(P.x,P.y,26,'#fff6d8',180,3.4,1,-40);
ring(P.x,P.y,70,'#fff3c4',.7,3); hud();
}
function useStem(){
if(P.items.stem<=0){SFX.deny();toast('No Vital Stems left.','dim');return;}
if(P.hp>=maxHp()){toast('Your hearts are already full.','dim');return;}
P.items.stem--; heal(4); SFX.rare(); hud(); toast('Vital Stem consumed — two containers restored.');
}
function useSeal(){
if(P.items.seal<=0){SFX.deny();toast('No Binding Seals left.','dim');return;}
let best=null,bd=340;
for(const e of G.enemies){const d=dist(P,e);if(d<bd){bd=d;best=e;}}
if(!best){SFX.deny();toast('No construct close enough to bind.','dim');return;}
P.items.seal--; best.frozen=3; best.lastSwing=-1;
SFX.freeze(); ring(best.x,best.y,60,'#6fd0ff',.7,4);
burst(best.x,best.y,20,'#9fe0ff',150,3.4,.9);
floatText(best.x,best.y-best.r-14,'BOUND','#9fe0ff',17); hud();
toast('Binding Seal set — the construct is frozen for three seconds.');
}
/* ---------------- enemies ---------------- */
function spawnEnemy(spec){
const t=ETYPES[spec.type];
return {type:spec.type,x:spec.x,y:spec.y,r:t.r,hp:t.hp,maxHp:t.hp,
atk:rnd(.4,1.4),shoot:rnd(.6,1.6),hurtT:0,burn:0,frozen:0,dead:false,
kx:0,ky:0,face:0,phase:0,pt:rnd(0,6),shield:spec.type==='boss',
shieldT:spec.type==='boss'?1e9:0,used:{},lastSwing:-1,elite:spec.elite};
}
function updateEnemies(dt){
const Z=G.Z;
for(const e of G.enemies){
if(e.dead)continue;
const t=ETYPES[e.type];
e.pt+=dt; e.hurtT=Math.max(0,e.hurtT-dt); e.atk-=dt;
if(e.burn>0){e.burn-=dt; e.hp-=dt*.6;
if(Math.random()<.35)spawn({x:e.x+rnd(-e.r,e.r),y:e.y+rnd(-e.r,e.r),vx:rnd(-10,10),vy:rnd(-34,-10),life:.6,size:rnd(2,3.5),color:'#ff7a1e',grav:-10});
if(e.hp<=0){killEnemy(e);continue;}}
if(e.frozen>0){e.frozen-=dt;
if(Math.random()<.5)spawn({x:e.x+rnd(-e.r,e.r),y:e.y+rnd(-e.r,e.r),vx:0,vy:rnd(-14,-4),life:.5,size:rnd(1.5,3),color:'#bfeaff'});
continue;}
const d=dist(P,e), a=Math.atan2(P.y-e.y,P.x-e.x);
e.face=a;
/* knockback */
if(Math.abs(e.kx)+Math.abs(e.ky)>4){moveBody(e,e.kx*dt,e.ky*dt);e.kx*=.86;e.ky*=.86;}
let mvx=0,mvy=0;
if(e.type==='husk'||e.type==='skitter'||e.type==='knight'||e.type==='castellan'){
if(d<520){mvx=Math.cos(a)*t.spd;mvy=Math.sin(a)*t.spd;}
if(e.type==='castellan')bossAI(e,dt,a,d);
if(d<e.r+P.r+6&&e.atk<=0){e.atk=e.type==='skitter'?.8:1.15;
takeDamage(t.dmg,e.x,e.y);}
}
if(e.type==='sentinel'){
if(d>300){mvx=Math.cos(a)*t.spd;mvy=Math.sin(a)*t.spd;}
else if(d<210){mvx=-Math.cos(a)*t.spd;mvy=-Math.sin(a)*t.spd;}
else {mvx=Math.cos(a+1.4)*t.spd*.55;mvy=Math.sin(a+1.4)*t.spd*.55;}
e.shoot-=dt;
if(e.shoot<=0&&d<520){e.shoot=t.shoot; fireAt(e.x,e.y,a,300,1,'#c9d6ff');}
if(d<e.r+P.r+4&&e.atk<=0){e.atk=1.2;takeDamage(1,e.x,e.y);}
}
if(e.type==='knight'){
e.shoot-=dt;
if(e.shoot<=0&&d<460&&d>120){e.shoot=t.shoot;
const ang=a+ (Math.random()<.5?0:0);
fireAt(e.x,e.y,ang,330,1,'#ffd08a');}
}
if(e.type==='boss')bossCombat(e,dt);
if(mvx||mvy)moveBody(e,mvx*dt,mvy*dt);
/* attack telegraph contact */
}
}
function fireAt(x,y,ang,spd,dmg,col){
G.eshots.push({x,y,vx:Math.cos(ang)*spd,vy:Math.sin(ang)*spd,life:3,r:8,dmg,col});
SFX.noise(.09,.1,1800,2);
}
function bossAI(e,dt,a,d){
e.phase=e.hp<e.maxHp*.45?1:0;
e.pt+=0;
if(e.pt>4.4&&!e.used.summon&&e.hp<e.maxHp*.55){e.used.summon=true;
G.enemies.push(spawnEnemy({type:'husk',x:e.x-90,y:e.y}),spawnEnemy({type:'husk',x:e.x+90,y:e.y}));
toast('The Castellan calls its rusted kin.','dim');}
}
function bossCombat(e,dt){
e.shoot-=dt;
const d=dist(P,e),a=Math.atan2(P.y-e.y,P.x-e.x);
if(e.shoot<=0){
e.shoot=e.phase?2.1:3.0;
const roll=Math.random();
if(roll<.45){ /* volley */
for(let i=-2;i<=2;i++)fireAt(e.x,e.y,a+i*.22,340,1,'#ffb46a');
SFX.swing(2);
} else if(roll<.75){ /* charge */
e.charge=.55; e.cang=a; SFX.boss();
} else { /* slam */
if(d<200){ ring(e.x,e.y,180,'#ff8a3d',.55,5); takeDamage(2,e.x,e.y); shake(11); SFX.hit(2,false);
burst(e.x,e.y,26,'#ff9b3d',300,4,.7,60);} else fireAt(e.x,e.y,a,300,1,'#ffb46a');
}
}
if(e.charge>0){e.charge-=dt; moveBody(e,Math.cos(e.cang)*460*dt,Math.sin(e.cang)*460*dt);
if(Math.random()<.5)spawn({x:e.x,y:e.y,vx:rnd(-30,30),vy:rnd(-30,30),life:.4,size:4,color:'#7d5a3a',drag:.9});
if(d<e.r+P.r+8){takeDamage(2,e.x,e.y);e.charge=0;}}
if(e.shield){e.shieldT-=dt;
if(e.shieldT<=0){e.shield=false;
G.Z.switches.forEach(s=>{s.shot=true;s.recharge=5;});
toast('The Beacon shield reforms — strike both switches again.','dim');}}
}
function bossDefeated(){
if(G.zi!==5)return;
ending();
}
/* ---------------- projectiles ---------------- */
function updateShots(dt){
for(let i=G.shots.length-1;i>=0;i--){
const s=G.shots[i]; s.life-=dt; s.x+=s.vx*dt; s.y+=s.vy*dt;
spawn({x:s.x,y:s.y,vx:rnd(-8,8),vy:rnd(-8,8),life:.3,size:rnd(1.5,3),color:'#9fe8ff'});
let gone=s.life<=0||s.x<0||s.x>W||s.y<0||s.y>H;
if(!gone)for(const w of G.Z.walls){if(circleRect(s.x,s.y,s.r,w)){gone=true;burst(s.x,s.y,8,'#9fe8ff',160,3,.4);SFX.shardHit();break;}}
if(!gone&&G.Z.door&&!G.Z.doorOpen&&circleRect(s.x,s.y,s.r,G.Z.door)){gone=true;SFX.shardHit();}
if(!gone)for(const b of G.Z.breakables){if(!b.dead&&circleRect(s.x,s.y,s.r,b)){gone=true;hitBreakable(b,1);break;}}
if(!gone)for(const e of G.enemies){if(!e.dead&&dist(s,e)<e.r+s.r){gone=true;
damageEnemy(e,s.dmg,{tier:1,ang:Math.atan2(s.vy,s.vx)});SFX.shardHit();break;}}
if(!gone)for(const sw of G.Z.switches||[]){if(!sw.shot&&Math.hypot(s.x-sw.x,s.y-sw.y)<sw.r+s.r){gone=true;hitSwitch(sw);break;}}
if(!gone&&G.Z.lightPuzzle)for(let ci=0;ci<G.Z.lightPuzzle.crystals.length;ci++){
const c=G.Z.lightPuzzle.crystals[ci];
if(Math.hypot(s.x-c.x,s.y-c.y)<30){gone=true;hitCrystal(ci);break;}}
if(gone)G.shots.splice(i,1);
}
for(let i=G.eshots.length-1;i>=0;i--){
const s=G.eshots[i]; s.life-=dt; s.x+=s.vx*dt; s.y+=s.vy*dt;
let gone=s.life<=0||s.x<0||s.x>W||s.y<0||s.y>H;
if(!gone)for(const w of G.Z.walls){if(circleRect(s.x,s.y,s.r,w)){gone=true;break;}}
if(!gone&&Math.hypot(s.x-P.x,s.y-P.y)<P.r+s.r){gone=true;takeDamage(s.dmg,s.x,s.y);}
if(gone)G.eshots.splice(i,1);
}
}
function hitSwitch(sw){
sw.shot=true; SFX.shardHit(); SFX.doorOpen();
burst(sw.x,sw.y,26,'#9fe8ff',240,4,.8); ring(sw.x,sw.y,60,'#9fe8ff',.6,4);
prog().sw[sw.id]=true;
if(sw.id==='exit'){G.Z.doorOpen=true;toast('The far gate answers the crystal — it grinds open.');}
if(sw.id==='h5'){G.Z.doorChamber.open=true;toast('A hidden chamber unseals in the upper spire.');}
if(sw.id==='bs1'||sw.id==='bs2'){
const boss=G.enemies.find(e=>e.type==='boss');
const both=G.Z.switches.every(s=>s.shot);
if(boss&&both&&boss.shield){
boss.shield=false; boss.shieldT=9;
toast('BEACON SHIELD DOWN — the Corroded Warden is exposed!',''); edgeShimmer(1.2); SFX.altar(); shake(7);
G.Z.switches.forEach(s=>{s.recharge=0;});
}
else if(boss&&both&&!boss.shield){ toast('The switches are spent — the shield will reform…','dim'); }
else if(boss)toast('One switch lit — strike the second to break the shield.','dim');
}
}
function hitCrystal(idx){
const lp=G.Z.lightPuzzle; if(lp.solved)return;
const c=lp.crystals[idx];
SFX.shardHit(); burst(c.x,c.y,20,'#bfe9ff',200,3.4,.7);
if(idx===lp.seq[lp.step]){
lp.step++; c.pulse=1;
if(lp.step>=lp.seq.length){
lp.solved=true; prog().puzzle=true; SFX.rare(); edgeShimmer(1.6);
G.Z.chests.push({x:1010,y:360,w:58,h:48,item:'lantern',label:'Reward of Reflected Light',opened:false,fresh:true});
toast('The light path resolves — a phantom lantern condenses from the beam.','');
} else toast('Crystal '+(lp.step)+' of 3 answers. Follow the pulse.','dim');
} else { lp.step=0; SFX.deny(); toast('The light scatters — the sequence resets.','dim'); }
}
/* ---------------- zone lifecycle ---------------- */
function cloneZone(i){return JSON.parse(JSON.stringify(ZONES[i]));}
function loadZone(i,opts={}){
G.zi=i; const Z=cloneZone(i); G.Z=Z;
const pr=prog();
Z.breakables.forEach((b,idx)=>{b.dead=!!pr.broken[idx];b.hitSwing=-1;});
Z.chests.forEach((c,idx)=>{c.opened=!!pr.chests[idx];});
if(Z.altar)Z.altar.taken=pr.altar;
if(Z.switches)Z.switches.forEach(s=>{s.shot=!!pr.sw[s.id];s.recharge=0;
if(s.shot&&s.id==='exit')Z.doorOpen=true;
if(s.shot&&s.id==='h5'&&Z.doorChamber)Z.doorChamber.open=true;});
if(Z.lightPuzzle){Z.lightPuzzle.solved=pr.puzzle;Z.lightPuzzle.step=pr.puzzle?3:0;
if(pr.puzzle)Z.chests.push({x:1010,y:360,w:58,h:48,item:'lantern',label:'Reward of Reflected Light',opened:false});}
if(Z.wave){Z.wave.done=pr.wave;Z.wave.started=pr.wave;Z.wave.gi=0;}
Z.platesLatch=false; Z.doorOpen=Z.doorOpen||false;
G.enemies=(Z.enemies||[]).map(spawnEnemy);
G.shots=[];G.eshots=[];
G.pickups=(Z.pickups||[]).map(p=>Object.assign({t:0,r:10},p));
P.x=Z.start.x;P.y=Z.start.y;P.vx=P.vy=0;P.dead=false;P.invuln=1;P.atkT=0;
parts.length=0;floaters.length=0;rings.length=0;
SFX.startAmb(Z.amb);
showZoneCard(Z);
hud();
if(!opts.silent){
if(i===3)toast('You have reached the halfway point of the observatory.','dim');
if(i===5)toast('The final ascent. Everything you carry will be tested.','dim');
}
}
function travel(to){
SFX.doorOpen();
loadZone(to);
}
function showZoneCard(Z){
$('zcnum').textContent='Zone '+ZNAME_NUM[G.zi];
$('zcname').textContent=Z.name;
$('zcsub').textContent=Z.sub;
const c=$('zonecard'); c.classList.remove('show'); void c.offsetWidth; c.classList.add('show');
$('zname').textContent=Z.name; $('zsub').textContent=Z.sub;
}
/* ---------------- interaction prompts ---------------- */
function nearTarget(){
const Z=G.Z;
if(Z.save&&dist(P,Z.save)<66){
if(Z.saveGate==='challenge'&&!Z.wave.done)
return {text:'The stone is dormant — survive the challenge',key:'',act:null};
return {text:'Activate Sanctuary Stone — save & restore',key:'E',act:()=>doSave()};
}
if(Z.shop&&dist(P,Z.shop)<74) return {text:'Trade at the Merchant Altar',key:'E',act:()=>openShop()};
if(Z.altar&&!Z.altar.taken&&dist(P,Z.altar)<72){
const label=Z.altar.type==='w1'?'Take up the Ignited Edge':Z.altar.type==='w2'?'Take up the Solar Falchion':'Claim the Aether Shard';
return {text:label,key:'E',act:()=>takeAltar()};
}
for(let i=0;i<Z.chests.length;i++){const c=Z.chests[i];if(c.opened)continue;
if(Math.hypot(P.x-(c.x+c.w/2),P.y-(c.y+c.h))<70)
return {text:'Open '+c.label,key:'E',act:()=>openChest(i)};}
return null;
}
function doSave(){
saveGame(); prog().save=true;
P.hp=maxHp(); P.ammo=P.hasShard?Math.max(P.ammo,4):P.ammo;
SFX.rare(); edgeShimmer(1.2);
const s=G.Z.save;
for(let i=0;i<26;i++)spawn({x:s.x+rnd(-16,16),y:s.y+rnd(-10,10),vx:rnd(-30,30),vy:rnd(-90,-30),life:1.3,size:rnd(1.5,3.5),color:'#ffd76a',grav:-10});
ring(s.x,s.y-10,70,'#ffe08a',.8,3);
toast('Sanctuary secured — progress saved, hearts restored.');
hud();
}
function takeAltar(){
const a=G.Z.altar; if(a.taken)return;
a.taken=true; prog().altar=true;
SFX.altar(); edgeShimmer(2); shake(6); screenFlash(.35,'255,180,80');
burst(a.x,a.y,60,P.tier===0?'#ff7a1e':'#ffd76a',300,4,1.3,-40);
ring(a.x,a.y,140,'#ffd76a',1,5);
if(a.type==='w1'){P.tier=1;toast('IGNITED EDGE — fire damage, faster swings, an orange trail on every cut.','');}
if(a.type==='w2'){P.tier=2;toast('SOLAR FALCHION — light damage, a wide arc, heavy wind-up and a deep resonant impact.','');}
if(a.type==='shard'){P.hasShard=true;P.ammo=Math.max(P.ammo,8);toast('AETHER SHARD — press K to fire a crystal bolt at range. It strikes switches, cracks and constructs.','');}
hud();
}
function openChest(i){
const c=G.Z.chests[i]; if(c.opened)return;
c.opened=true; prog().chests[i]=true;
const cx=c.x+c.w/2, cy=c.y+c.h/2;
burst(cx,cy,30,'#fff6d8',200,3.6,1,-40); ring(cx,cy,80,'#ffe08a',.8,4);
if(c.container){addContainer(); toast('HEART CONTAINER acquired — each container holds 2 HP.','');}
else if(c.item){pickupItem(c.item);}
else if(c.gold){P.gold+=c.gold;SFX.coin();floatText(cx,cy-20,'+'+c.gold+' sigils','#f5c451',18);
for(let i2=0;i2<18;i2++)spawn({x:cx,y:cy,vx:rnd(-90,90),vy:rnd(-140,-20),life:.9,size:rnd(2,4),color:'#f5c451',grav:280});
toast(c.label+' — '+c.gold+' golden sigils.','dim');}
SFX.item(); hud();
}
/* ---------------- world update ---------------- */
function inRect(p,r){return p.x>r.x&&p.x<r.x+r.w&&p.y>r.y&&p.y<r.y+r.h;}
function updateWorld(dt){
const Z=G.Z;
/* waves */
if(Z.wave&&!Z.wave.done){
if(!Z.wave.started&&inRect(P,Z.wave.trigger)){
Z.wave.started=true;Z.wave.gi=0;spawnWaveGroup(Z);
toast(' Constructs surge from the rust!','dim'); shake(6); SFX.boss();
} else if(Z.wave.started&&G.enemies.length===0){
Z.wave.gi++;
if(Z.wave.gi>=Z.wave.spawn.length){
Z.wave.done=true;prog().wave=true;SFX.doorOpen();edgeShimmer(1);
toast('The hall falls quiet. The way ahead opens.','');
} else spawnWaveGroup(Z);
}
}
/* plates */
const pbox={x:P.x-P.r,y:P.y-P.r,w:P.r*2,h:P.r*2};
for(const p of Z.plates){
let on=aabb(pbox,p);
if(!on)for(const e of G.enemies)if(aabb({x:e.x-e.r,y:e.y-e.r,w:e.r*2,h:e.r*2},p)){on=true;break;}
if(on&&!p.on){SFX.plate();burst(p.x+p.w/2,p.y+p.h/2,10,'#c9a05a',120,3,.5);}
p.on=on; p.sink=lerp(p.sink||0,on?1:0,dt*9);
if(on)p.last=G.time;
if(Z.plates.length===1&&on&&!Z.plateFired){
Z.plateFired=true;P.gold+=20;SFX.coin();
toast('The plate sinks — a hidden cache of 20 sigils rises.','dim');
for(let i=0;i<16;i++)spawn({x:p.x+p.w/2+rnd(-30,30),y:p.y,vx:rnd(-40,40),vy:rnd(-160,-40),life:.9,size:3,color:'#f5c451',grav:300});
hud();
}
}
if(Z.plates.length>1&&!Z.platesLatch){
const a=Z.plates[0],b=Z.plates[1];
if(a.last&&b.last&&Math.abs(a.last-b.last)<4.5){
Z.platesLatch=true;SFX.doorOpen();edgeShimmer(1);shake(5);
toast('Both plates answer — the gallery gate drops.','');
for(const p of Z.plates)ring(p.x+p.w/2,p.y+p.h/2,70,'#ffd76a',.7,4);
}
}
/* hazards */
for(const h of Z.hazards){
const c=((G.time+h.phase)%h.period)/h.period;
h.active=c<h.duty; h.k=h.active?Math.min(1,c/.12):Math.max(0,1-c/.12*3);
if(h.active&&aabb(pbox,h))takeDamage(2,h.x+h.w/2,h.y+h.h/2);
}
/* switches recharge */
for(const s of Z.switches||[]){
if(s.recharge>0){s.recharge-=dt;if(s.recharge<=0){s.shot=false;}}
}
/* light puzzle pulse */
if(Z.lightPuzzle&&!Z.lightPuzzle.solved){
const lp=Z.lightPuzzle; lp.t+=dt;
if(lp.t>1.6){lp.t=0; lp.seq.forEach((_,i)=>{setTimeout(()=>{},0);});
const cur=lp.seq[lp.step]; if(cur!=null)lp.crystals[cur].pulse=1;}
for(const c of lp.crystals)c.pulse=Math.max(0,(c.pulse||0)-dt*1.6);
}
/* pickups */
for(let i=G.pickups.length-1;i>=0;i--){
const p=G.pickups[i]; p.t+=dt;
const d=Math.hypot(P.x-p.x,P.y-p.y);
if(d<70){p.x=lerp(p.x,P.x,dt*7);p.y=lerp(p.y,P.y,dt*7);}
if(d<24){
if(p.type==='gold'){P.gold+=p.amount;SFX.coin();floatText(P.x,P.y-26,'+'+p.amount,'#f5c451',16);
for(let k=0;k<8;k++)spawn({x:p.x,y:p.y,vx:rnd(-60,60),vy:rnd(-90,-20),life:.6,size:2.5,color:'#f5c451',grav:200});}
if(p.type==='ammo'){P.ammo=Math.min(P.maxAmmo,P.ammo+p.amount);SFX.item();
floatText(P.x,P.y-26,'+'+p.amount+' aether','#9fe8ff',16);}
if(p.type==='heal'){heal(p.amount);}
G.pickups.splice(i,1);hud();
}
}
/* exit gate */
const ex=Z.exit;
if(ex){
let open=false;
if(ex.lock==='clear')open=G.enemies.length===0&&(!Z.wave||Z.wave.done);
else if(ex.lock==='plates')open=!!Z.platesLatch;
else if(ex.lock==='switch')open=!!Z.doorOpen;
if(open&&!ex.open){ex.open=true;Z.doorOpen=true;SFX.doorOpen();shake(4);
toast('The gate grinds open.',''); edgeShimmer(.8);}
if(open&&inRect(P,{x:ex.x,y:ex.y-20,w:ex.w,h:ex.h+40}))travel(ex.to);
}
/* ambient particles */
G.ambT-=dt;
if(G.ambT<=0){
G.ambT=.16;
const kind=Z.ambient;
if(kind==='dust')spawn({x:rnd(40,W-40),y:rnd(40,H-40),vx:rnd(-6,6),vy:rnd(-10,-2),life:rnd(3,6),size:rnd(1,2.4),color:'#d8c79a',grav:-2,drag:.99,glow:false});
if(kind==='mist')spawn({x:rnd(40,W-40),y:rnd(H-140,H-40),vx:rnd(-8,8),vy:rnd(-26,-10),life:rnd(2.5,4.5),size:rnd(4,10),color:'#5f9fb5',grav:-6,drag:.995,glow:false,shape:'smoke'});
if(kind==='ember')spawn({x:rnd(40,W-40),y:rnd(H-90,H-30),vx:rnd(-14,14),vy:rnd(-50,-18),life:rnd(1.6,3),size:rnd(1.4,3),color:'#ff8a3d',grav:-24,drag:.99});
if(kind==='spark')spawn({x:rnd(40,W-40),y:rnd(40,H-40),vx:rnd(-4,4),vy:rnd(-8,-2),life:rnd(1.4,2.6),size:rnd(1,2.2),color:'#ffd76a',grav:-4});
}
if(Z.save&&Math.random()<.3)spawn({x:Z.save.x+rnd(-24,24),y:Z.save.y+rnd(-26,6),vx:rnd(-6,6),vy:rnd(-24,-8),life:1.4,size:rnd(1,2.4),color:'#ffe08a',grav:-8});
if(Z.shop&&Math.random()<.2)spawn({x:Z.shop.x+rnd(-30,30),y:Z.shop.y+rnd(-24,6),vx:rnd(-6,6),vy:rnd(-18,-6),life:1.6,size:rnd(1,2.4),color:'#9fd8ff',grav:-6});
/* shop drone proximity */
const nearShop=Z.shop&&dist(P,Z.shop)<200;
if(!!nearShop!==!!G._shopNear){G._shopNear=!!nearShop;SFX.shopDrone(!!nearShop);}
}
function spawnWaveGroup(Z){
const grp=Z.wave.spawn[Z.wave.gi];
grp.forEach(s=>G.enemies.push(spawnEnemy(s)));
ring(P.x,P.y,120,'#ff8a3d',.7,4);
toast('Wave '+(Z.wave.gi+1)+' of '+Z.wave.spawn.length,'dim');
}
/* ---------------- main update ---------------- */
function update(dt){
G.time+=dt;
if(G.mode!=='play'){updateFx(dt);updateScreenFx(dt);return;}
if(FX.hitstop>0){updateFx(dt*.15);updateScreenFx(dt);return;}
updatePlayer(dt);
updateEnemies(dt);
updateShots(dt);
updateWorld(dt);
updateFx(dt);
updateScreenFx(dt);
const t=nearTarget();
const pr=$('prompt');
if(t&&t.act){pr.innerHTML='<span class="key">'+t.key+'</span>'+t.text;pr.classList.add('on');G._t=t;}
else if(t){pr.innerHTML='<span style="opacity:.6">'+t.text+'</span>';pr.classList.add('on');G._t=t;}
else {pr.classList.remove('on');G._t=null;}
if(hit('e')&&G._t&&G._t.act)G._t.act();
}
function die(){
G.mode='death'; SFX.boss(); shake(16); screenFlash(.7,'180,30,20');
$('deathMsg').textContent='The Corrosion swallows '+ZNAME_NUM[G.zi]+' · '+G.Z.name+
' once more. Your sanctuary waits — '+G.kills+' constructs have fallen to your blade.';
setTimeout(()=>{$('death').classList.remove('hidden');},700);
}
function respawn(){
$('death').classList.add('hidden');
P.dead=false;P.hp=maxHp();P.invuln=1.5;
loadZone(G.zi,{silent:true});
G.mode='play'; hud();
toast('You rise at the threshold of the hall. Hearts restored.','dim');
}
function ending(){
G.mode='ending'; SFX.beacon(); edgeShimmer(3);
const secs=Math.max(1,Math.round(G.time));
$('endMsg').innerHTML='The switches you broke, the altars you woke, the five containers you dug out of cracked stone — '+
'all of it feeds the Core Beacon. Gold light climbs the great gears of <b>Aurelion\'s Sunken Observatory</b>, and the black-iron '+
'Corrosion stops, mid-creep, like a held breath.<br/><br/>'+
'<b>'+G.kills+'</b> corrupted constructs laid to rest · <b>'+P.gold+'</b> golden sigils carried · '+
'<b>'+P.containers+'</b> heart containers · <b>'+WEAPONS[P.tier].name+
'</b> · '+(P.hasShard?'Aether Shard attuned':'no shard recovered')+' · '+Math.floor(secs/60)+'m '+(secs%60)+'s.<br/><br/>'+
'The last Warden sets down the blade. The valley below finally sees morning.';
setTimeout(()=>{$('ending').classList.remove('hidden');},600);
}
/* ================= RENDER ================= */
function rr(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
function drawFloor(Z){
const g=ctx.createRadialGradient(W*.5,H*.42,80,W*.5,H*.5,W*.75);
g.addColorStop(0,Z.tint[0]);g.addColorStop(1,Z.tint[1]);
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
/* warm key light so halls read clearly */
const k=ctx.createRadialGradient(W*.5,H*.5,40,W*.5,H*.5,W*.62);
k.addColorStop(0,'rgba(255,205,130,.13)');k.addColorStop(1,'rgba(255,190,110,0)');
ctx.fillStyle=k;ctx.fillRect(0,0,W,H);
ctx.strokeStyle='rgba(255,255,255,.05)';ctx.lineWidth=1;
ctx.beginPath();
for(let x=64;x<W;x+=64){ctx.moveTo(x+.5,0);ctx.lineTo(x+.5,H);}
for(let y=64;y<H;y+=64){ctx.moveTo(0,y+.5);ctx.lineTo(W,y+.5);}
ctx.stroke();
if(Z.key==='archives'){
ctx.strokeStyle='rgba(160,140,90,.07)';ctx.lineWidth=2;
for(let y=96;y<H;y+=96){ctx.beginPath();ctx.moveTo(40,y);ctx.lineTo(W-40,y);ctx.stroke();}
}
if(Z.key==='vault'){
ctx.fillStyle='rgba(90,170,200,.05)';
for(let i=0;i<7;i++){const y=110+i*90+Math.sin(G.time*.7+i)*6;
ctx.beginPath();ctx.ellipse(W*.5,y,W*.42,16,0,0,TAU);ctx.fill();}
}
if(Z.key==='core'){
const rg=ctx.createRadialGradient(W/2,140,10,W/2,140,420);
rg.addColorStop(0,'rgba(255,170,70,.20)');rg.addColorStop(1,'rgba(255,140,40,0)');
ctx.fillStyle=rg;ctx.fillRect(0,0,W,H);
}
}
function drawWall(s){
const g=ctx.createLinearGradient(s.x,s.y,s.x,s.y+s.h);
g.addColorStop(0,'#2a3448');g.addColorStop(.35,'#1b2436');g.addColorStop(1,'#121a29');
ctx.fillStyle=g;ctx.fillRect(s.x,s.y,s.w,s.h);
ctx.fillStyle='rgba(245,196,81,.16)';ctx.fillRect(s.x,s.y,s.w,3);
ctx.fillStyle='rgba(0,0,0,.5)';ctx.fillRect(s.x,s.y+s.h-3,s.w,3);
ctx.strokeStyle='rgba(0,0,0,.55)';ctx.lineWidth=1;ctx.strokeRect(s.x+.5,s.y+.5,s.w-1,s.h-1);
ctx.fillStyle='rgba(138,106,58,.5)';
for(let x=s.x+10;x<s.x+s.w-6;x+=28)for(let y=s.y+12;y<s.y+s.h-6;y+=28){
ctx.beginPath();ctx.arc(x,y,1.7,0,TAU);ctx.fill();}
if(s.w>40&&s.h>40){ctx.fillStyle='rgba(111,127,78,.16)';
ctx.fillRect(s.x+4,s.y+s.h-14,Math.min(40,s.w-8),8);}
}
function drawDoor(d,open){
if(open){
ctx.fillStyle='rgba(245,196,81,.10)';ctx.fillRect(d.x,d.y,d.w,d.h);
ctx.strokeStyle='rgba(245,196,81,.5)';ctx.lineWidth=2;ctx.strokeRect(d.x+1,d.y+1,d.w-2,d.h-2);
ctx.fillStyle='rgba(245,196,81,.06)';ctx.fillRect(d.x-40,d.y,d.w+40,d.h);
return;
}
const g=ctx.createLinearGradient(d.x,d.y,d.x+d.w,d.y);
g.addColorStop(0,'#3a3020');g.addColorStop(.5,'#5a4a2c');g.addColorStop(1,'#2b2418');
ctx.fillStyle=g;ctx.fillRect(d.x,d.y,d.w,d.h);
ctx.fillStyle='rgba(0,0,0,.55)';
for(let x=d.x+5;x<d.x+d.w;x+=9)ctx.fillRect(x,d.y,4,d.h);
ctx.fillStyle='rgba(245,196,81,.35)';ctx.fillRect(d.x,d.y,d.w,4);ctx.fillRect(d.x,d.y+d.h-4,d.w,4);
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.strokeRect(d.x+.5,d.y+.5,d.w-1,d.h-1);
}
function crackLines(x,y,w,h,seed,alpha=1){
ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='rgba(6,8,12,.95)';ctx.lineWidth=2;
let s=seed;
const rnd2=()=>{s=(s*9301+49297)%233280;return s/233280;};
for(let i=0;i<4;i++){
ctx.beginPath();let px=x+rnd2()*w,py=y;
ctx.moveTo(px,py);
for(let k=0;k<5;k++){px+=(rnd2()-.5)*w*.7;py+=h/5;ctx.lineTo(clamp(px,x,x+w),clamp(py,y,y+h));}
ctx.stroke();
}
ctx.restore();
}
function drawBreakable(b){
if(b.dead)return;
const cx=b.x+b.w/2,cy=b.y+b.h/2;
if(b.kind==='jar'){
const g=ctx.createRadialGradient(cx-6,cy-8,3,cx,cy,b.w*.7);
g.addColorStop(0,'#b98f4e');g.addColorStop(1,'#5b431f');
ctx.fillStyle=g;ctx.beginPath();ctx.ellipse(cx,cy,b.w*.45,b.h*.42,0,0,TAU);ctx.fill();
ctx.fillStyle='#3a2a14';ctx.fillRect(cx-9,cy-b.h*.48,18,7);
ctx.strokeStyle='rgba(255,225,170,.35)';ctx.lineWidth=1.4;
ctx.beginPath();ctx.arc(cx-5,cy-6,7,-2.4,-.6);ctx.stroke();
ctx.fillStyle='rgba(111,127,78,.5)';ctx.beginPath();ctx.ellipse(cx+8,cy+6,7,4,.5,0,TAU);ctx.fill();
} else {
const g=ctx.createLinearGradient(b.x,b.y,b.x,b.y+b.h);
g.addColorStop(0,'#2c3549');g.addColorStop(1,'#161d2c');
ctx.fillStyle=g;ctx.fillRect(b.x,b.y,b.w,b.h);
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.strokeRect(b.x+.5,b.y+.5,b.w-1,b.h-1);
crackLines(b.x,b.y,b.w,b.h,17+b.x,1);
ctx.fillStyle='rgba(0,0,0,.35)';ctx.fillRect(b.x+2,b.y+2,b.w-4,b.h-4);
crackLines(b.x,b.y,b.w,b.h,17+b.x,.9);
if(P.eq.lantern){ /* lantern reveals the crack */
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.strokeStyle='rgba(159,220,255,.85)';ctx.lineWidth=2;
crackLines(b.x,b.y,b.w,b.h,17+b.x,.6);
ctx.restore();
}
const dmg=Math.max(0,b.hp/(b.kind==='wall'?8:8));
ctx.fillStyle='rgba(0,0,0,.5)';ctx.fillRect(b.x,b.y-7,b.w,4);
ctx.fillStyle='#e0a94b';ctx.fillRect(b.x,b.y-7,b.w*dmg,4);
}
}
function drawChest(c){
const cx=c.x,cy=c.y,w=c.w,h=c.h;
ctx.save();
ctx.fillStyle='rgba(0,0,0,.45)';ctx.beginPath();ctx.ellipse(cx+w/2,cy+h+3,w*.55,7,0,0,TAU);ctx.fill();
const g=ctx.createLinearGradient(cx,cy,cx,cy+h);
g.addColorStop(0,c.opened?'#3a3529':'#6d5227');g.addColorStop(1,c.opened?'#241f18':'#3c2b12');
ctx.fillStyle=g;rr(cx,cy,w,h,4);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.lineWidth=1.4;rr(cx+.5,cy+.5,w-1,h-1,4);ctx.stroke();
if(c.opened){
ctx.fillStyle='#120f0a';ctx.fillRect(cx+3,cy+4,w-6,h*.4);
ctx.strokeStyle='rgba(245,196,81,.25)';ctx.strokeRect(cx+3,cy+4,w-6,h*.4);
}else{
ctx.fillStyle='rgba(245,196,81,.85)';ctx.fillRect(cx+w/2-4,cy+h*.34,8,12);
ctx.fillStyle='rgba(255,235,180,.5)';ctx.fillRect(cx+3,cy+4,w-6,3);
const pulse=.5+.5*Math.sin(G.time*3);
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(255,215,106,${.10+pulse*.12})`;
rr(cx-8,cy-8,w+16,h+16,8);ctx.fill();
ctx.globalCompositeOperation='source-over';
if(c.container){ctx.strokeStyle='#ff8f7a';ctx.lineWidth=2;rr(cx-3,cy-3,w+6,h+6,6);ctx.stroke();}
if(c.fresh){ctx.strokeStyle='rgba(159,220,255,.7)';ctx.lineWidth=2;rr(cx-4,cy-4,w+8,h+8,6);ctx.stroke();}
}
ctx.restore();
}
function drawPlate(p){
const sink=(p.sink||0)*6;
ctx.fillStyle='rgba(0,0,0,.4)';ctx.fillRect(p.x-4,p.y+p.h-8,p.w+8,10);
const g=ctx.createLinearGradient(p.x,p.y,p.x,p.y+p.h);
g.addColorStop(0,p.on?'#c79a3e':'#8a6a3a');g.addColorStop(1,p.on?'#7a5a1e':'#4a3a1c');
ctx.fillStyle=g;ctx.fillRect(p.x,p.y+sink,p.w,p.h-sink);
ctx.strokeStyle='rgba(245,196,81,.55)';ctx.lineWidth=2;ctx.strokeRect(p.x+3,p.y+3+sink,p.w-6,p.h-6-sink);
ctx.strokeStyle='rgba(0,0,0,.5)';ctx.strokeRect(p.x+.5,p.y+.5+sink,p.w-1,p.h-1-sink);
ctx.fillStyle='rgba(245,196,81,.5)';
ctx.beginPath();ctx.arc(p.x+p.w/2,p.y+p.h/2+sink,7,0,TAU);ctx.fill();
if(p.on){ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(255,215,106,.22)';ctx.fillRect(p.x,p.y+sink,p.w,p.h-sink);
ctx.globalCompositeOperation='source-over';}
}
function drawHazard(h){
ctx.fillStyle='#141a26';ctx.fillRect(h.x,h.y,h.w,h.h);
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.strokeRect(h.x+.5,h.y+.5,h.w-1,h.h-1);
const k=h.k||0;
const n=Math.floor(h.w/22);
for(let i=0;i<n;i++){
const x=h.x+12+i*22, base=h.y+h.h-6;
const len=6+k*26;
ctx.fillStyle=h.active?'#e2483c':'#6a7284';
ctx.beginPath();ctx.moveTo(x-7,base);ctx.lineTo(x,base-len);ctx.lineTo(x+7,base);ctx.closePath();ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.5)';ctx.lineWidth=1;ctx.stroke();
}
if(h.active){ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(226,72,60,.16)';ctx.fillRect(h.x,h.y,h.w,h.h);
ctx.globalCompositeOperation='source-over';}
}
function drawSwitch(s){
ctx.fillStyle='#20263a';ctx.fillRect(s.x-16,s.y+8,32,26);
ctx.strokeStyle='rgba(0,0,0,.6)';ctx.strokeRect(s.x-15.5,s.y+8.5,31,25);
const pulse=.5+.5*Math.sin(G.time*3.4);
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=s.shot?'rgba(159,232,255,.9)':`rgba(120,170,255,${.35+pulse*.3})`;
ctx.beginPath();ctx.moveTo(s.x,s.y-24);ctx.lineTo(s.x+15,s.y+4);ctx.lineTo(s.x,s.y+22);ctx.lineTo(s.x-15,s.y+4);ctx.closePath();ctx.fill();
ctx.fillStyle=s.shot?'rgba(159,232,255,.35)':`rgba(120,170,255,${.12+pulse*.14})`;
ctx.beginPath();ctx.arc(s.x,s.y,34,0,TAU);ctx.fill();
ctx.restore();
ctx.strokeStyle=s.shot?'#d8f7ff':'rgba(190,220,255,.7)';ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(s.x,s.y-24);ctx.lineTo(s.x+15,s.y+4);ctx.lineTo(s.x,s.y+22);ctx.lineTo(s.x-15,s.y+4);ctx.closePath();ctx.stroke();
}
function drawCrystal(c,i,lp){
const on=lp.solved||(c.pulse>0);
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=on?'rgba(200,240,255,.9)':`rgba(120,200,255,${.4+(c.pulse||0)*.5})`;
ctx.beginPath();ctx.moveTo(c.x,c.y-30);ctx.lineTo(c.x+20,c.y);ctx.lineTo(c.x,c.y+30);ctx.lineTo(c.x-20,c.y);ctx.closePath();ctx.fill();
ctx.fillStyle=`rgba(120,200,255,${.10+(c.pulse||0)*.35})`;
ctx.beginPath();ctx.arc(c.x,c.y,46,0,TAU);ctx.fill();
ctx.restore();
ctx.strokeStyle='rgba(220,245,255,.85)';ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(c.x,c.y-30);ctx.lineTo(c.x+20,c.y);ctx.lineTo(c.x,c.y+30);ctx.lineTo(c.x-20,c.y);ctx.closePath();ctx.stroke();
ctx.fillStyle='#0c1622';ctx.beginPath();ctx.arc(c.x,c.y,15,0,TAU);ctx.fill();
ctx.fillStyle='#cdefff';ctx.font='700 14px Georgia,serif';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(String(i+1),c.x,c.y+1);
}
function drawSaveStone(s){
const act=!G.Z.saveGate||G.Z.wave.done;
const pulse=.5+.5*Math.sin(G.time*2.2);
ctx.fillStyle='rgba(0,0,0,.5)';ctx.beginPath();ctx.ellipse(s.x,s.y+26,40,12,0,0,TAU);ctx.fill();
const g=ctx.createLinearGradient(s.x-30,s.y-40,s.x+30,s.y+26);
g.addColorStop(0,act?'#6a6f7d':'#3a3f4c');g.addColorStop(1,act?'#2c3140':'#1c202b');
ctx.fillStyle=g;rr(s.x-30,s.y-40,60,66,7);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.6)';rr(s.x-29.5,s.y-39.5,59,65,7);ctx.stroke();
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=act?`rgba(255,215,106,${.5+pulse*.4})`:'rgba(120,130,150,.25)';
ctx.beginPath();ctx.moveTo(s.x,s.y-30);ctx.lineTo(s.x+13,s.y-8);ctx.lineTo(s.x,s.y+12);ctx.lineTo(s.x-13,s.y-8);ctx.closePath();ctx.fill();
if(act){ctx.fillStyle=`rgba(255,215,106,${.10+pulse*.14})`;ctx.beginPath();ctx.arc(s.x,s.y-8,66,0,TAU);ctx.fill();}
ctx.restore();
ctx.strokeStyle=act?'rgba(255,235,180,.9)':'rgba(150,160,180,.5)';ctx.lineWidth=1.6;
ctx.beginPath();ctx.moveTo(s.x,s.y-30);ctx.lineTo(s.x+13,s.y-8);ctx.lineTo(s.x,s.y+12);ctx.lineTo(s.x-13,s.y-8);ctx.closePath();ctx.stroke();
}
function drawAltar(a){
const taken=a.taken;
ctx.fillStyle='rgba(0,0,0,.5)';ctx.beginPath();ctx.ellipse(a.x,a.y+30,52,14,0,0,TAU);ctx.fill();
const g=ctx.createLinearGradient(a.x-46,a.y-10,a.x+46,a.y+34);
g.addColorStop(0,'#4a4436');g.addColorStop(1,'#231f18');
ctx.fillStyle=g;rr(a.x-46,a.y-6,92,40,6);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.6)';rr(a.x-45.5,a.y-5.5,91,39,6);ctx.stroke();
ctx.fillStyle='rgba(245,196,81,.3)';ctx.fillRect(a.x-46,a.y-6,92,4);
const pulse=.5+.5*Math.sin(G.time*2.6);
const col=a.type==='w1'?'255,122,30':a.type==='w2'?'255,206,74':'143,228,255';
const fy=a.y-40+Math.sin(G.time*2)*5;
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(${col},${taken?.12:.28+pulse*.2})`;
ctx.beginPath();ctx.arc(a.x,fy,54,0,TAU);ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.globalAlpha=taken?.35:1;
if(a.type==='shard'){
ctx.fillStyle='#8fe4ff';ctx.beginPath();
ctx.moveTo(a.x,fy-26);ctx.lineTo(a.x+15,fy);ctx.lineTo(a.x,fy+26);ctx.lineTo(a.x-15,fy);ctx.closePath();ctx.fill();
ctx.strokeStyle='#e6fbff';ctx.lineWidth=1.6;ctx.stroke();
} else {
ctx.strokeStyle=a.type==='w1'?'#ff9a3d':'#ffe08a';ctx.lineWidth=7;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(a.x-20,fy+22);ctx.lineTo(a.x+18,fy-24);ctx.stroke();
ctx.strokeStyle='#fff6dc';ctx.lineWidth=2.4;
ctx.beginPath();ctx.moveTo(a.x-20,fy+22);ctx.lineTo(a.x+18,fy-24);ctx.stroke();
ctx.strokeStyle='#8a6a3a';ctx.lineWidth=6;
ctx.beginPath();ctx.moveTo(a.x-24,fy+14);ctx.lineTo(a.x-10,fy+26);ctx.stroke();
}
ctx.restore();
}
function drawShop(s){
const pulse=.5+.5*Math.sin(G.time*2);
ctx.fillStyle='rgba(0,0,0,.5)';ctx.beginPath();ctx.ellipse(s.x,s.y+26,58,15,0,0,TAU);ctx.fill();
/* counter */
const g=ctx.createLinearGradient(s.x-56,s.y-4,s.x+56,s.y+34);
g.addColorStop(0,'#5a4a2c');g.addColorStop(1,'#2a2214');
ctx.fillStyle=g;rr(s.x-56,s.y,112,34,5);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,.6)';rr(s.x-55.5,s.y+.5,111,33,5);ctx.stroke();
/* hooded merchant */
ctx.fillStyle='#2a2f3f';ctx.beginPath();
ctx.moveTo(s.x-26,s.y+4);ctx.quadraticCurveTo(s.x-24,s.y-56,s.x,s.y-58);
ctx.quadraticCurveTo(s.x+24,s.y-56,s.x+26,s.y+4);ctx.closePath();ctx.fill();
ctx.fillStyle='#171b28';ctx.beginPath();ctx.arc(s.x,s.y-40,15,0,TAU);ctx.fill();
ctx.fillStyle=`rgba(255,215,106,${.6+pulse*.4})`;
ctx.beginPath();ctx.arc(s.x-6,s.y-41,2.6,0,TAU);ctx.arc(s.x+6,s.y-41,2.6,0,TAU);ctx.fill();
ctx.strokeStyle='rgba(245,196,81,.5)';ctx.lineWidth=1.4;
ctx.beginPath();ctx.moveTo(s.x-26,s.y+2);ctx.quadraticCurveTo(s.x,s.y-16,s.x+26,s.y+2);ctx.stroke();
/* goods */
const goods=[['#7ee08a',-38],['#6fd0ff',0],['#ff8f7a',38]];
goods.forEach(([c,dx])=>{ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=c;ctx.globalAlpha=.75+.25*pulse;
ctx.beginPath();ctx.arc(s.x+dx,s.y-8,5,0,TAU);ctx.fill();ctx.restore();});
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(159,220,255,${.07+pulse*.06})`;ctx.beginPath();ctx.arc(s.x,s.y-20,120,0,TAU);ctx.fill();ctx.restore();
}
function drawHints(){
ctx.textAlign='center';ctx.textBaseline='middle';
for(const h of G.Z.hints||[]){
const d=Math.hypot(P.x-h.x,P.y-h.y);
const a=clamp(1-(d-90)/220,.15,1);
ctx.save();ctx.globalAlpha=a;
ctx.font='600 14px Georgia,serif';
const w=ctx.measureText(h.text).width+34;
const hx=clamp(h.x,w/2+12,W-w/2-12), hy=clamp(h.y,26,H-26);
ctx.fillStyle='rgba(6,9,16,.78)';rr(hx-w/2,hy-15,w,30,4);ctx.fill();
ctx.strokeStyle='rgba(245,196,81,.4)';ctx.lineWidth=1;rr(hx-w/2,hy-15,w,30,4);ctx.stroke();
ctx.fillStyle='#f5c451';ctx.fillText('✦',hx-w/2+15,hy+1);
ctx.fillStyle='#e9dfc8';ctx.fillText(h.text,hx+8,hy+1);
ctx.restore();
}
}
/* ---------- characters ---------- */
function drawPlayer(){
const t=G.time, bob=Math.sin(P.walkT)*1.6;
ctx.save();ctx.translate(P.x,P.y);
ctx.fillStyle='rgba(0,0,0,.45)';ctx.beginPath();ctx.ellipse(0,16,18,7,0,0,TAU);ctx.fill();
if(P.invuln>0&&Math.floor(t*22)%2===0)ctx.globalAlpha=.5;
const wide=P.tier===2?6:0;
/* cloak */
const cloakCol=P.eq.cloak?'#151a2c':'#2b3550';
ctx.fillStyle=cloakCol;
ctx.beginPath();
ctx.moveTo(-11-wide,-4);ctx.quadraticCurveTo(-16-wide,14,-9,20);
ctx.lineTo(9,20);ctx.quadraticCurveTo(16+wide,14,11+wide,-4);ctx.closePath();ctx.fill();
ctx.strokeStyle=P.tier===2?'rgba(255,220,130,.75)':P.eq.cloak?'rgba(90,100,150,.7)':'rgba(120,140,190,.5)';
ctx.lineWidth=1.6;ctx.stroke();
if(P.tier===2){ /* shimmering hem */
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(255,215,106,${.25+.2*Math.sin(t*5)})`;
ctx.beginPath();ctx.moveTo(-10-wide,16);ctx.lineTo(10+wide,16);ctx.lineTo(9,21);ctx.lineTo(-9,21);ctx.closePath();ctx.fill();
ctx.globalCompositeOperation='source-over';
}
if(P.eq.cloak){ctx.fillStyle='rgba(10,12,20,.85)';
ctx.beginPath();ctx.moveTo(-13-wide,10);ctx.lineTo(13+wide,10);ctx.lineTo(11,22);ctx.lineTo(-11,22);ctx.closePath();ctx.fill();}
/* body */
ctx.fillStyle='#3d4a6b';ctx.beginPath();ctx.ellipse(0,2,11+wide*.4,13,0,0,TAU);ctx.fill();
ctx.fillStyle='rgba(245,196,81,.55)';ctx.fillRect(-2,-8,4,16);
/* head + helm */
ctx.fillStyle='#c9a883';ctx.beginPath();ctx.arc(0,-12,8.4,0,TAU);ctx.fill();
ctx.fillStyle='#2a3350';ctx.beginPath();ctx.arc(0,-14,9,Math.PI,TAU);ctx.fill();
ctx.fillStyle='#1a2136';ctx.fillRect(-9,-14,18,4);
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=P.tier===2?'rgba(255,230,150,.95)':P.tier===1?'rgba(255,160,80,.9)':'rgba(160,200,255,.85)';
ctx.fillRect(-5.5,-11.5,4,2.4);ctx.fillRect(1.5,-11.5,4,2.4);ctx.restore();
/* shoulder clasp (band accessory shows on leading arm) */
ctx.save();ctx.rotate(P.face);
const reach=13+wide;
/* off-hand: aether shard */
if(P.hasShard){
const px=-reach*.55, py=9;
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(143,228,255,${.5+.3*Math.sin(t*4)})`;
ctx.beginPath();ctx.arc(px,py,10,0,TAU);ctx.fill();ctx.restore();
ctx.fillStyle='#8fe4ff';ctx.beginPath();
ctx.moveTo(px,py-9);ctx.lineTo(px+6,py);ctx.lineTo(px,py+9);ctx.lineTo(px-6,py);ctx.closePath();ctx.fill();
ctx.strokeStyle='#e6fbff';ctx.lineWidth=1;ctx.stroke();
}
/* arm */
ctx.strokeStyle='#3d4a6b';ctx.lineWidth=6;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(0,4);ctx.lineTo(reach,6);ctx.stroke();
if(P.eq.band){ctx.strokeStyle='#e6ecf7';ctx.lineWidth=3;
ctx.beginPath();ctx.arc(reach-3,6,4.4,0,TAU);ctx.stroke();}
/* blade */
const w=weapon();
const swingK=P.atkT>0?(1-P.atkT/.20):0;
const baseAng=-.55+ (P.atkT>0? lerp(-1.0,1.15,swingK):Math.sin(t*2)*.06);
ctx.save();ctx.translate(reach,4);ctx.rotate(baseAng);
ctx.globalCompositeOperation='lighter';
ctx.fillStyle=w.glow;ctx.globalAlpha=P.tier===0?.35:.6;
ctx.beginPath();ctx.ellipse(20,-2,26,7,0,0,TAU);ctx.fill();
ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';
ctx.fillStyle='#8a6a3a';ctx.fillRect(-4,-3,10,6);
ctx.fillStyle=w.color;
ctx.beginPath();ctx.moveTo(6,-3.6);ctx.lineTo(40,-2.4);ctx.lineTo(46,0);ctx.lineTo(40,2.4);ctx.lineTo(6,3.6);ctx.closePath();ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.75)';ctx.lineWidth=1;ctx.stroke();
ctx.restore();
/* swing arc */
if(P.atkT>0){
const k=swingK;
ctx.globalCompositeOperation='lighter';
ctx.strokeStyle=P.tier===2?'rgba(255,225,140,.9)':P.tier===1?'rgba(255,140,50,.85)':'rgba(210,225,255,.6)';
ctx.lineWidth=P.tier===2?9:6;ctx.lineCap='round';
const a0=-1.5+k*2.4, a1=a0+(P.tier===2?1.5:1.1);
ctx.beginPath();ctx.arc(0,0,w.range*.82,a0,a1);ctx.stroke();
ctx.globalCompositeOperation='source-over';
}
ctx.restore();
/* lantern orbit */
if(P.eq.lantern){
const lx=P.x+Math.cos(t*1.6)*30, ly=P.y-6+Math.sin(t*2.1)*8;
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(159,220,255,${.35+.2*Math.sin(t*4)})`;
ctx.beginPath();ctx.arc(lx,ly,16,0,TAU);ctx.fill();ctx.restore();
ctx.fillStyle='#bfe9ff';rr(lx-6,ly-7,12,15,3);ctx.fill();
ctx.strokeStyle='#7cc4f0';ctx.lineWidth=1.2;rr(lx-6,ly-7,12,15,3);ctx.stroke();
ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(lx,ly,3,0,TAU);ctx.fill();
}
ctx.restore();
if(P.hurtT>0){ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(226,72,60,${P.hurtT})`;ctx.beginPath();ctx.arc(P.x,P.y,30,0,TAU);ctx.fill();ctx.restore();}
}
function drawEnemy(e){
const t=G.time, sp=ETYPES[e.type];
ctx.save();ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,.45)';ctx.beginPath();ctx.ellipse(0,e.r*.85,e.r*.95,e.r*.34,0,0,TAU);ctx.fill();
if(e.frozen>0){ctx.globalAlpha=.9;}
const hurt=e.hurtT>0;
const bob=Math.sin(e.pt*4)*2.5;
ctx.save();ctx.translate(0,bob*(e.type==='sentinel'?1.4:.6));
if(e.type==='skitter'){
ctx.rotate(e.pt*3);
ctx.fillStyle=hurt?'#fff':sp.col;
ctx.beginPath();
for(let i=0;i<8;i++){const a=i/8*TAU,r=i%2?e.r*.7:e.r;
ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r);}
ctx.closePath();ctx.fill();
ctx.fillStyle=sp.dark;ctx.beginPath();ctx.arc(0,0,e.r*.4,0,TAU);ctx.fill();
ctx.fillStyle='#ff5a4a';ctx.beginPath();ctx.arc(0,0,2.6,0,TAU);ctx.fill();
} else if(e.type==='sentinel'){
ctx.fillStyle=hurt?'#fff':sp.dark;
ctx.beginPath();ctx.moveTo(-e.r*.8,e.r*.7);ctx.lineTo(0,-e.r*.2);ctx.lineTo(e.r*.8,e.r*.7);ctx.closePath();ctx.fill();
ctx.fillStyle=hurt?'#fff':sp.col;ctx.beginPath();ctx.arc(0,-e.r*.25,e.r*.72,0,TAU);ctx.fill();
ctx.fillStyle='#0e1420';ctx.beginPath();ctx.arc(0,-e.r*.25,e.r*.45,0,TAU);ctx.fill();
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(160,200,255,.85)';ctx.beginPath();ctx.arc(Math.cos(e.face)*5,-e.r*.25+Math.sin(e.face)*5,4,0,TAU);ctx.fill();ctx.restore();
ctx.strokeStyle='rgba(200,215,240,.5)';ctx.lineWidth=2;
ctx.beginPath();ctx.arc(0,-e.r*.25,e.r*.72,0,TAU);ctx.stroke();
} else if(e.type==='husk'||e.type==='knight'||e.type==='castellan'){
const s=e.r/17;
ctx.save();ctx.scale(s,s);
ctx.fillStyle=hurt?'#fff':sp.dark;
ctx.beginPath();ctx.moveTo(-14,16);ctx.lineTo(-11,-4);ctx.lineTo(11,-4);ctx.lineTo(14,16);ctx.closePath();ctx.fill();
ctx.fillStyle=hurt?'#fff':sp.col;
rr(-12,-8,24,18,5);ctx.fill();
ctx.fillStyle='rgba(0,0,0,.35)';ctx.fillRect(-12,-2,24,3);
ctx.fillStyle=hurt?'#fff':sp.dark;
ctx.beginPath();ctx.arc(0,-12,10,0,TAU);ctx.fill();
ctx.fillStyle='#12161f';ctx.fillRect(-10,-13,20,5);
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=e.burn>0?'rgba(255,140,50,.95)':'rgba(255,90,70,.9)';
ctx.fillRect(-6,-12,4,3);ctx.fillRect(2,-12,4,3);ctx.restore();
/* rust moss */
ctx.fillStyle='rgba(111,127,78,.45)';
ctx.beginPath();ctx.ellipse(-7,8,5,3,.4,0,TAU);ctx.fill();
/* weapon */
ctx.save();ctx.rotate(e.face);
ctx.fillStyle='#7a6244';ctx.fillRect(10,-2,16,4);
ctx.fillStyle='#c9cdd6';ctx.beginPath();ctx.moveTo(26,-3);ctx.lineTo(40,0);ctx.lineTo(26,3);ctx.closePath();ctx.fill();
ctx.restore();
if(e.type==='castellan'||e.type==='boss'){
ctx.strokeStyle='rgba(245,196,81,.75)';ctx.lineWidth=2;
for(let i=0;i<7;i++){const a=-Math.PI+ i/6*Math.PI;
ctx.beginPath();ctx.moveTo(Math.cos(a)*13,-18+Math.sin(a)*4);ctx.lineTo(Math.cos(a)*19,-24+Math.sin(a)*5);ctx.stroke();}
}
ctx.restore();
}
ctx.restore();
/* frozen shell */
if(e.frozen>0){
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle='rgba(140,220,255,.35)';ctx.beginPath();ctx.arc(0,0,e.r+7,0,TAU);ctx.fill();
ctx.restore();
ctx.strokeStyle='rgba(200,240,255,.8)';ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,e.r+7,0,TAU);ctx.stroke();
}
/* boss shield */
if(e.shield){
ctx.save();ctx.globalCompositeOperation='lighter';
ctx.fillStyle=`rgba(120,200,255,${.16+.1*Math.sin(t*5)})`;
ctx.beginPath();ctx.arc(0,0,e.r+18,0,TAU);ctx.fill();ctx.restore();
ctx.strokeStyle='rgba(180,235,255,.75)';ctx.lineWidth=2.4;
ctx.beginPath();ctx.arc(0,0,e.r+18,0,TAU);ctx.stroke();
}
ctx.restore();
/* hp bar */
if(e.hp<e.maxHp&&!sp.boss){
const w=e.r*2.1;
ctx.fillStyle='rgba(0,0,0,.6)';ctx.fillRect(e.x-w/2,e.y-e.r-16,w,5);
ctx.fillStyle=e.burn>0?'#ff7a1e':'#e2483c';ctx.fillRect(e.x-w/2,e.y-e.r-16,w*(e.hp/e.maxHp),5);
}
}
function drawPickup(p){
const b=Math.sin(G.time*4+p.x)*3;
ctx.save();ctx.translate(p.x,p.y+b);
ctx.globalCompositeOperation='lighter';
if(p.type==='gold'){ctx.fillStyle='rgba(245,196,81,.3)';ctx.beginPath();ctx.arc(0,0,12,0,TAU);ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.fillStyle='#f5c451';ctx.beginPath();ctx.arc(0,0,7,0,TAU);ctx.fill();
ctx.strokeStyle='#fff0bd';ctx.lineWidth=1.4;ctx.stroke();
ctx.fillStyle='#8a6a1c';ctx.beginPath();ctx.arc(0,0,3.4,0,TAU);ctx.fill();}
else if(p.type==='ammo'){ctx.fillStyle='rgba(143,228,255,.3)';ctx.beginPath();ctx.arc(0,0,13,0,TAU);ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.fillStyle='#8fe4ff';ctx.beginPath();ctx.moveTo(0,-9);ctx.lineTo(6,0);ctx.lineTo(0,9);ctx.lineTo(-6,0);ctx.closePath();ctx.fill();
ctx.strokeStyle='#e6fbff';ctx.stroke();}
else {ctx.fillStyle='rgba(126,224,138,.3)';ctx.beginPath();ctx.arc(0,0,13,0,TAU);ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.fillStyle='#7ee08a';ctx.beginPath();ctx.arc(0,0,7,0,TAU);ctx.fill();}
ctx.restore();
}
function drawShots(){
ctx.save();ctx.globalCompositeOperation='lighter';
for(const s of G.shots){
ctx.fillStyle='rgba(143,228,255,.35)';ctx.beginPath();ctx.arc(s.x,s.y,16,0,TAU);ctx.fill();
ctx.fillStyle='#d8f7ff';ctx.beginPath();ctx.arc(s.x,s.y,6,0,TAU);ctx.fill();
ctx.strokeStyle='rgba(143,228,255,.8)';ctx.lineWidth=3;
ctx.beginPath();ctx.moveTo(s.x,s.y);ctx.lineTo(s.x-s.vx*.03,s.y-s.vy*.03);ctx.stroke();
}
for(const s of G.eshots){
ctx.fillStyle=s.col;ctx.globalAlpha=.3;ctx.beginPath();ctx.arc(s.x,s.y,15,0,TAU);ctx.fill();
ctx.globalAlpha=1;ctx.beginPath();ctx.arc(s.x,s.y,7,0,TAU);ctx.fill();
}
ctx.restore();
}
function drawDarkness(){
const Z=G.Z; if(!Z.dark)return;
const r=P.eq.lantern?360:Z.dark;
const g=ctx.createRadialGradient(P.x,P.y,30,P.x,P.y,r);
g.addColorStop(0,'rgba(2,4,8,0)');g.addColorStop(.55,'rgba(2,4,8,.5)');g.addColorStop(1,'rgba(2,4,8,.95)');
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
ctx.fillStyle='rgba(2,4,8,.72)';ctx.globalCompositeOperation='destination-over';
ctx.fillRect(0,0,W,H);ctx.globalCompositeOperation='source-over';
}
function drawBossBar(){
const b=G.enemies.find(e=>e.type==='boss'); if(!b)return;
const w=520,x=W/2-w/2,y=26;
ctx.fillStyle='rgba(6,9,16,.85)';rr(x-10,y-20,w+20,54,4);ctx.fill();
ctx.strokeStyle='rgba(245,196,81,.4)';ctx.lineWidth=1;rr(x-10,y-20,w+20,54,4);ctx.stroke();
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.font='600 13px Georgia,serif';ctx.fillStyle='#f5c451';
ctx.fillText('THE CORRODED WARDEN'+(b.shield?' · BEACON SHIELD ACTIVE':' · EXPOSED'),W/2,y-8);
ctx.fillStyle='rgba(255,255,255,.1)';ctx.fillRect(x,y+4,w,14);
const g=ctx.createLinearGradient(x,0,x+w,0);g.addColorStop(0,'#e2483c');g.addColorStop(1,'#ff9a3d');
ctx.fillStyle=g;ctx.fillRect(x,y+4,w*Math.max(0,b.hp/b.maxHp),14);
ctx.strokeStyle='rgba(0,0,0,.7)';ctx.strokeRect(x+.5,y+4.5,w-1,13);
if(b.shield){ctx.fillStyle='#9fe8ff';ctx.font='600 11px system-ui';
ctx.fillText('Strike both crystal switches (K) to break the shield',W/2,y+28);}
}
function drawShieldSwitchHint(){
if(G.zi!==5)return;
const sw=G.Z.switches.filter(s=>s.id[0]==='b');
if(sw.some(s=>!s.shot)){}
}
function render(){
const Z=G.Z;
ctx.save();
if(FX.shake>0)ctx.translate(rnd(-FX.shake,FX.shake),rnd(-FX.shake,FX.shake));
drawFloor(Z);
for(const s of Z.walls)drawWall(s);
if(Z.door)drawDoor(Z.door,!!Z.doorOpen);
if(Z.doorChamber&&!Z.doorChamber.open)drawDoor(Z.doorChamber,false);
else if(Z.doorChamber)drawDoor(Z.doorChamber,true);
for(const h of Z.hazards)drawHazard(h);
for(const p of Z.plates)drawPlate(p);
for(const b of Z.breakables)drawBreakable(b);
for(const c of Z.chests)drawChest(c);
for(const s of Z.switches||[])drawSwitch(s);
if(Z.lightPuzzle&&!Z.lightPuzzle.solved)Z.lightPuzzle.crystals.forEach((c,i)=>drawCrystal(c,i,Z.lightPuzzle));
if(Z.save)drawSaveStone(Z.save);
if(Z.shop)drawShop(Z.shop);
if(Z.altar)drawAltar(Z.altar);
if(Z.key==='core')drawBeacon();
for(const p of G.pickups)drawPickup(p);
drawShots();
for(const e of G.enemies)drawEnemy(e);
drawPlayer();
drawFx();
drawDarkness();
drawHints();
if(G.zi===5)drawBossBar();
ctx.restore();
/* zone vignette tint */
const vg=ctx.createRadialGradient(W/2,H/2,H*.4,W/2,H/2,W*.75);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,.38)');
ctx.fillStyle=vg;ctx.fillRect(0,0,W,H);
}
function drawBeacon(){
const t=G.time, cx=W/2, cy=110;
ctx.save();
ctx.fillStyle='#1a1620';rr(cx-70,cy-40,140,60,8);ctx.fill();
ctx.strokeStyle='rgba(245,196,81,.35)';ctx.lineWidth=2;rr(cx-70,cy-40,140,60,8);ctx.stroke();
ctx.globalCompositeOperation='lighter';
const p=.5+.5*Math.sin(t*2);
const g=ctx.createRadialGradient(cx,cy-10,4,cx,cy-10,120);
g.addColorStop(0,`rgba(255,220,140,${.5+p*.3})`);g.addColorStop(1,'rgba(255,180,60,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(cx,cy-10,120,0,TAU);ctx.fill();
ctx.fillStyle=`rgba(255,240,200,${.75+p*.25})`;
ctx.beginPath();ctx.arc(cx,cy-10,17,0,TAU);ctx.fill();
ctx.restore();
ctx.strokeStyle='rgba(245,196,81,.6)';ctx.lineWidth=2;
ctx.beginPath();ctx.arc(cx,cy-10,26,0,TAU);ctx.stroke();
}
/* ================= UI ================= */
function hud(){
let h='';
for(let i=0;i<P.containers;i++){
const fill=clamp((P.hp-i*2)/2,0,1);
h+=heartSVG(fill,i);
}
$('hearts').innerHTML=h+`<span style="font-size:11px;letter-spacing:.1em;color:#9aa3b5;margin-left:6px">${P.hp}/${maxHp()}</span>`;
$('goldres').innerHTML=ICONS.coin+`<span>${P.gold}</span>`;
const am=$('amores');
if(P.hasShard){am.classList.remove('hidden');am.innerHTML=ICONS.shard+`<span>${P.ammo}/${P.maxAmmo}</span>`;}
else am.classList.add('hidden');
const w=weapon();
const wc=P.tier===2?'#ffce4a':P.tier===1?'#ff8a3d':'#9fb0cc';
$('weap').innerHTML=`<span style="color:${wc}">◈</span><span style="color:${wc}">${w.name}</span>`+
(P.tier>0?`<span style="color:#9aa3b5;font-size:11px">· ${P.tier===1?'fire':'solar'}</span>`:'');
let toks='';
for(const id of ['band','lantern','cloak']){
const has=P.owned[id];
toks+=`<div class="tok panel ${has&&P.eq[id]?'':'off'}" title="${ITEMS[id].name}">${ICONS[id]}${has&&P.eq[id]?'<span class="k">E</span>':''}</div>`;
}
$('accbox').innerHTML=toks;
}
function toast(msg,cls=''){
const t=document.createElement('div');
t.className='toast '+cls; t.textContent=msg;
$('toasts').appendChild(t);
setTimeout(()=>{t.classList.add('out');setTimeout(()=>t.remove(),600);},3600);
while($('toasts').children.length>4)$('toasts').firstChild.remove();
}
function closeModal(){ $('modal').classList.add('hidden'); $('modal').innerHTML='';
if(G.mode==='modal')G.mode='play'; G.modal=null; SFX.shopDrone(G.Z&&G.Z.shop&&dist(P,G.Z.shop)<200); }
function modalShell(title,sub,body,right=''){
$('modal').innerHTML=`<div class="card"><div class="head"><div><h2>${title}</h2><div class="sub">${sub}</div></div>
<div style="display:flex;gap:10px;align-items:center">${right}<button class="close" id="mClose">Esc · Close</button></div></div>${body}</div>`;
$('modal').classList.remove('hidden');
$('mClose').onclick=closeModal;
}
function openInventory(){
G.mode='modal'; G.modal='inv'; SFX.shopDrone(false);
const w=weapon();
const accs=['band','lantern','cloak'];
let slots='';
accs.forEach(id=>{
const it=ITEMS[id];
if(!P.owned[id])return;
slots+=`<div class="slot ${P.eq[id]?'eq':''}">
<div class="cnt">${P.eq[id]?'EQUIPPED':''}</div>
<div class="top"><div class="ico">${ICONS[id]}</div>
<div><div class="nm">${it.name}</div><div class="ty">Accessory</div></div></div>
<div class="ds">${it.desc}</div>
<button class="act" data-eq="${id}">${P.eq[id]?'Unequip':'Equip'}</button></div>`;
});
['stem','seal'].forEach(id=>{
const it=ITEMS[id]; const n=P.items[id]||0;
slots+=`<div class="slot">
<div class="cnt">×${n}</div>
<div class="top"><div class="ico">${ICONS[id]}</div>
<div><div class="nm">${it.name}</div><div class="ty">Consumable · Key ${id==='stem'?'1':'2'}</div></div></div>
<div class="ds">${it.desc}</div>
<button class="act" data-use="${id}" ${n<=0?'disabled':''}>Use</button></div>`;
});
if(!slots)slots='<div class="empty">Your satchel is empty — the halls keep their secrets well.</div>';
const found=accs.filter(a=>P.owned[a]).length;
const body=`<div class="statline">
<span>Weapon <b>${w.name}</b></span><span>Damage <b>${w.dmg}</b></span>
<span>Hearts <b>${P.containers}/5</b> (${P.hp}/${maxHp()} HP)</span>
<span>Sigils <b>${P.gold}</b></span>
<span>Aether <b>${P.hasShard?P.ammo+'/'+P.maxAmmo:'not found'}</b></span>
<span>Accessories <b>${found}/3</b></span></div>
<div class="grid">${slots}</div>
<div class="sub" style="margin-top:16px">${w.desc}</div>`;
modalShell('Inventory','Equipment, consumables & sigils',body);
$('modal').querySelectorAll('[data-eq]').forEach(b=>b.onclick=()=>{
const id=b.dataset.eq; P.eq[id]=!P.eq[id];
SFX.item(); toast(P.eq[id]?ITEMS[id].name+' equipped.':ITEMS[id].name+' removed.','dim');
hud(); openInventory();
});
$('modal').querySelectorAll('[data-use]').forEach(b=>b.onclick=()=>{
if(b.dataset.use==='stem')useStem(); else useSeal(); openInventory();
});
}
function openShop(){
G.mode='modal'; G.modal='shop'; SFX.shopDrone(false); SFX.item();
renderShop();
}
function renderShop(){
let rows='';
SHOP.forEach(s=>{
if(s.kind==='ammo'&&!P.hasShard)return;
const stock=G.shop[s.id];
const sold=stock<=0;
const afford=P.gold>=s.price;
rows+=`<div class="slot">
<div class="top"><div class="ico">${s.icon==='heart'?heartSVG(1,'s'+s.id):ICONS[s.icon]}</div>
<div><div class="nm">${s.name}</div><div class="ty">${s.kind==='heart'?'Permanent upgrade':s.kind==='ammo'?'Ammunition':'Consumable'}</div></div></div>
<div class="ds">${s.desc}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span class="price">${s.price} sigils</span>
<span class="stock">${s.max>90?'unlimited':(s.max-stock)+' left of '+s.max}</span></div>
<button class="act" data-buy="${s.id}" ${sold||!afford?'disabled':''}>${sold?'Sold out':afford?'Purchase':'Not enough sigils'}</button></div>`;
});
const body=`<div class="grid">${rows}</div>
<div class="sub" style="margin-top:16px">The merchant speaks little. Constructs ahead carry 5–15 sigils each; the Archives reward those who break every jar.</div>`;
modalShell('Merchant Altar','The Rusted Archives · safe zone',
body,`<div class="shopgold">${ICONS.coin}<span>${P.gold}</span></div>`);
$('modal').querySelectorAll('[data-buy]').forEach(b=>b.onclick=()=>buy(b.dataset.buy));
}
function buy(id){
const s=SHOP.find(x=>x.id===id);
if(!s||G.shop[id]<=0)return;
if(P.gold<s.price){SFX.deny();return;}
P.gold-=s.price; G.shop[id]--;
if(s.kind==='item'){P.items[id]=(P.items[id]||0)+1;toast('Purchased — '+s.name+' ×'+P.items[id]);}
if(s.kind==='ammo'){P.ammo=Math.min(P.maxAmmo,P.ammo+1);toast('An aether charge clicks into the shard.','dim');}
if(s.kind==='heart'){addContainer();toast('HEART CONTAINER #5 purchased — ten HP. The merchant bows.','');}
SFX.buy(); hud(); renderShop();
}
function openPause(){
G.mode='modal'; G.modal='pause';
modalShell('The Warden Pauses','The Corrosion waits',`
<div class="statline"><span>Zone <b>${ZNAME_NUM[G.zi]} · ${G.Z.name}</b></span>
<span>Hearts <b>${P.containers}/5</b></span><span>Sigils <b>${P.gold}</b></span>
<span>Weapon <b>${weapon().name}</b></span><span>Constructs fallen <b>${G.kills}</b></span></div>
<div class="btnrow" style="justify-content:flex-start;margin-top:6px">
<button class="btn" id="pResume">Resume</button>
<button class="btn ghost" id="pSave">Save now</button>
<button class="btn ghost" id="pTitle">Return to Title</button></div>
<div class="ctrls" style="text-align:left"><b>WASD</b> move <b>J / Space</b> strike <b>K</b> aether bolt
<b>E</b> interact <b>I</b> inventory <b>1 / 2</b> use stem / seal <b>Shift</b> step-dash</div>`);
$('pResume').onclick=closeModal;
$('pSave').onclick=()=>{saveGame();prog().save=true;SFX.rare();toast('Progress saved.');};
$('pTitle').onclick=()=>{closeModal();toTitle();};
}
function handleKey(k){
if(G.mode==='title')return;
if(k==='Escape'){
if(G.mode==='modal')closeModal();
else if(G.mode==='play')openPause();
return;
}
if(G.mode!=='play')return;
if(k==='i'){openInventory();return;}
if(k==='1'){useStem();return;}
if(k==='2'){useSeal();return;}
}
/* ================= SAVE / LOAD ================= */
const KEY='wardens-corrosion-save-v1';
const mem={};
const store={get(k){try{return localStorage.getItem(k);}catch(e){return mem[k]||null;}},
set(k,v){try{localStorage.setItem(k,v);}catch(e){mem[k]=v;}},
del(k){try{localStorage.removeItem(k);}catch(e){delete mem[k];}}};
function saveGame(){
const data={zi:G.zi,progress:G.progress,shop:G.shop,kills:G.kills,
p:{hp:P.hp,containers:P.containers,gold:P.gold,tier:P.tier,hasShard:P.hasShard,ammo:P.ammo,
items:P.items,owned:P.owned,eq:P.eq}};
store.set(KEY,JSON.stringify(data));
}
function readSave(){const s=store.get(KEY);if(!s)return null;try{return JSON.parse(s);}catch(e){return null;}}
function resetPlayer(){
P.hp=6;P.maxHp=6;P.containers=3;P.gold=0;P.tier=0;P.hasShard=false;P.ammo=0;
P.items={stem:1,seal:0};P.owned={band:false,lantern:false,cloak:false};
P.eq={band:false,lantern:false,cloak:false};P.dead=false;P.invuln=1;
}
function applySave(d){
const p=d.p;
P.hp=p.hp;P.containers=p.containers;P.maxHp=maxHp();P.gold=p.gold;P.tier=p.tier;
P.hasShard=p.hasShard;P.ammo=p.ammo;P.items=p.items;P.owned=p.owned;P.eq=p.eq;P.dead=false;P.invuln=1;
G.progress=d.progress||{};G.shop=d.shop||G.shop;G.kills=d.kills||0;
}
/* ================= FLOW ================= */
function toTitle(){
G.mode='title';SFX.shopDrone(false);SFX.stopAmb();
$('death').classList.add('hidden');$('ending').classList.add('hidden');
$('modal').classList.add('hidden');
$('title').classList.remove('hidden');
$('btnCont').classList.toggle('hidden',!readSave());
}
function startGame(cont){
SFX.init();SFX.resume();
G.runStart=performance.now();G.kills=0;G.progress={};G.shop={stem:3,seal:2,heart:1,ammo:99};
if(cont&&readSave()){const d=readSave();applySave(d);G.started=true;
$('title').classList.add('hidden');G.mode='play';loadZone(Math.min(d.zi||0,5));
toast('Vigil resumed from your sanctuary.','');}
else{resetPlayer();G.started=true;
$('title').classList.add('hidden');$('death').classList.add('hidden');$('ending').classList.add('hidden');
G.mode='play';loadZone(0);
toast('The Ashen Threshold. Find the sanctuary stone ahead.','dim');}
hud();
}
$('btnNew').onclick=()=>startGame(false);
$('btnCont').onclick=()=>startGame(true);
$('btnRespawn').onclick=respawn;
$('btnTitle').onclick=toTitle;
$('btnEnd').onclick=()=>{ $('ending').classList.add('hidden'); toTitle(); };
document.querySelectorAll('.screen').forEach(s=>s.addEventListener('click',()=>{SFX.init();SFX.resume();}));
/* ================= LOOP ================= */
let lastT=performance.now();
function frame(t){
const dt=Math.min(.05,(t-lastT)/1000); lastT=t;
update(dt);
if(G.Z)render();
clearPressed();
requestAnimationFrame(frame);
}
hud();
$('btnCont').classList.toggle('hidden',!readSave());
requestAnimationFrame(frame);
window.Warden={G,P,ZONES,SFX,hud,loadZone,saveGame,readSave,startGame,useStem,useSeal,damageEnemy,takeDamage};
</script>
</body>
</html>
<!-- agent-meta {"model":"xiaomi/mimo-v2.6-flash","provider":"openrouter","persona":"full","sessionId":"bd7e0bcf-17dd-4b71-9340-c9ef4a9720e0","tokensIn":7548345,"tokensOut":96775,"tokensTotal":7645120,"cost":0.12609954,"turns":46,"toolCalls":63,"failedToolCalls":0,"timestamp":"2026-09-26T15:57:18.824Z"} -->