Warden
Swift Qwen3.8 27B GGUF · 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 81,268 bytes · SHA-256 c43f8c802c42
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Warden's Corrosion</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,500&display=swap" rel="stylesheet">
<style>
:root{
--gold:#d9a441; --gold-hi:#f4c96b; --gold-dim:#8a6b3a;
--slate:#1b2230; --slate-2:#232a3a; --ink:#0b0e14;
--parch:#cfd6e4; --rust:#b4653a; --ember:#e8823c; --aether:#7fd4ff;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
background:radial-gradient(1200px 700px at 50% 30%, #10151f 0%, #05070b 70%);
color:var(--parch);
font-family:'Cormorant Garamond', Georgia, serif;
overflow:hidden;
display:flex;align-items:center;justify-content:center;
}
#wrap{
position:relative;
width:min(100vw, calc(100vh * 16 / 9));
aspect-ratio:16/9;
background:#000;
box-shadow:0 0 80px rgba(217,164,65,.08), 0 0 0 1px #1c2333;
}
canvas#game{width:100%;height:100%;display:block;image-rendering:auto}
#vignette{position:absolute;inset:0;pointer-events:none;
background:radial-gradient(120% 90% at 50% 45%, transparent 55%, rgba(4,6,10,.55) 100%);}
/* ---------- HUD ---------- */
#hud{position:absolute;top:14px;left:16px;right:16px;display:flex;justify-content:space-between;pointer-events:none;z-index:5}
.hudbox{background:linear-gradient(180deg, rgba(16,20,30,.82), rgba(10,13,20,.72));
border:1px solid rgba(217,164,65,.35); border-radius:8px; padding:8px 14px;
box-shadow:0 4px 18px rgba(0,0,0,.5); backdrop-filter:blur(2px);}
#hearts{display:flex;gap:6px;align-items:center}
.hc{position:relative;width:26px;height:24px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.6))}
.hc svg{position:absolute;inset:0;width:100%;height:100%}
.hc .base path{fill:#2a3040;stroke:#12151d;stroke-width:1}
.hc .fill path{fill:#d64545}
.hc .fill{clip-path:inset(0 0 0 0)}
.hc.empty{opacity:.35}
.hc.gain{animation:hcGain .8s ease}
@keyframes hcGain{0%{transform:scale(1)}35%{transform:scale(1.35)}100%{transform:scale(1)}}
#weaponName{margin-top:5px;font-family:'Cinzel',serif;font-size:12px;letter-spacing:.14em;color:var(--gold);text-transform:uppercase}
#weaponName .lvl{color:var(--parch);opacity:.7}
#rightHud{display:flex;gap:10px;align-items:stretch}
.stat{display:flex;flex-direction:column;align-items:center;min-width:64px}
.stat .ic{font-size:15px;line-height:1}
.stat .val{font-family:'Cinzel',serif;font-size:17px;color:var(--gold-hi);letter-spacing:.05em}
.stat .lbl{font-size:10px;letter-spacing:.18em;opacity:.65;text-transform:uppercase}
#ammoStat{display:none}
/* ---------- banner & toasts ---------- */
#banner{position:absolute;top:18%;left:0;right:0;text-align:center;pointer-events:none;z-index:6;opacity:0;transition:opacity .6s}
#banner .znum{font-family:'Cinzel',serif;font-size:13px;letter-spacing:.5em;color:var(--rust);text-transform:uppercase}
#banner .zname{font-family:'Cinzel',serif;font-weight:900;font-size:44px;color:var(--gold-hi);
text-shadow:0 0 24px rgba(217,164,65,.45), 0 3px 10px #000;letter-spacing:.08em;margin-top:4px}
#banner .zsub{font-style:italic;font-size:17px;color:var(--parch);opacity:.85;margin-top:6px}
#banner.show{opacity:1}
#toasts{position:absolute;bottom:64px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:6px;pointer-events:none;z-index:6}
.toast{background:rgba(12,15,24,.88);border:1px solid rgba(217,164,65,.4);border-radius:6px;
padding:6px 18px;font-size:16px;color:var(--parch);box-shadow:0 4px 14px rgba(0,0,0,.5);
animation:toastIn .3s ease, toastOut .5s ease 2.6s forwards;}
.toast.gold{color:var(--gold-hi)} .toast.blue{color:var(--aether)} .toast.red{color:#e07a7a}
@keyframes toastIn{from{transform:translateY(10px);opacity:0}to{transform:none;opacity:1}}
@keyframes toastOut{to{transform:translateY(-6px);opacity:0}}
/* ---------- hints ---------- */
#hints{position:absolute;bottom:10px;left:16px;z-index:5;font-size:12.5px;letter-spacing:.06em;opacity:.6;pointer-events:none}
#hints b{color:var(--gold);font-weight:600}
#muteBtn{position:absolute;bottom:8px;right:14px;z-index:7;background:none;border:1px solid rgba(217,164,65,.35);
color:var(--gold);font-family:'Cinzel',serif;font-size:11px;letter-spacing:.15em;padding:4px 10px;border-radius:5px;cursor:pointer;pointer-events:auto}
#muteBtn:hover{background:rgba(217,164,65,.12)}
/* ---------- panels ---------- */
.panel{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10;
width:min(560px, 86%);max-height:82%;overflow:auto;
background:linear-gradient(180deg, rgba(20,25,38,.97), rgba(11,14,22,.97));
border:1px solid rgba(217,164,65,.5);border-radius:10px;
box-shadow:0 0 60px rgba(0,0,0,.8), inset 0 0 40px rgba(217,164,65,.05);
padding:22px 26px;}
.panel h2{font-family:'Cinzel',serif;font-size:22px;letter-spacing:.2em;color:var(--gold-hi);text-transform:uppercase;
text-align:center;margin-bottom:4px}
.panel .psub{text-align:center;font-style:italic;font-size:14px;opacity:.7;margin-bottom:14px}
.hidden{display:none !important}
.irow{display:flex;align-items:center;gap:12px;padding:10px 8px;border-bottom:1px solid rgba(217,164,65,.14)}
.irow:last-child{border-bottom:none}
.irow .glyph{width:38px;height:38px;border-radius:8px;display:flex;align-items:center;justify-content:center;
font-size:20px;border:1px solid rgba(217,164,65,.3);background:rgba(255,255,255,.03);flex:none}
.irow .iname{font-family:'Cinzel',serif;font-size:15px;letter-spacing:.06em;color:var(--parch)}
.irow .idesc{font-size:13.5px;font-style:italic;opacity:.75;line-height:1.25}
.irow .count{font-family:'Cinzel',serif;color:var(--gold-hi);font-size:14px}
.ibtn{margin-left:auto;flex:none;background:linear-gradient(180deg,#3a2f1a,#241d10);border:1px solid rgba(217,164,65,.55);
color:var(--gold-hi);font-family:'Cinzel',serif;font-size:12px;letter-spacing:.12em;padding:7px 14px;border-radius:6px;cursor:pointer;text-transform:uppercase}
.ibtn:hover:not(:disabled){background:linear-gradient(180deg,#4a3c22,#2e2514);box-shadow:0 0 12px rgba(217,164,65,.25)}
.ibtn:disabled{opacity:.35;cursor:default}
.ibtn.on{background:linear-gradient(180deg,#1d3a2a,#12241a);border-color:rgba(120,220,150,.5);color:#a8e8bc}
.shoprow .price{font-family:'Cinzel',serif;color:var(--gold-hi);font-size:15px;white-space:nowrap}
.shoprow .stock{font-size:12px;opacity:.6;font-style:italic}
.shopgold{text-align:center;font-family:'Cinzel',serif;color:var(--gold-hi);font-size:15px;margin:12px 0 4px;letter-spacing:.1em}
/* ---------- screens ---------- */
.screen{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:radial-gradient(90% 90% at 50% 40%, rgba(10,13,20,.88) 0%, rgba(4,6,10,.96) 100%);text-align:center}
.screen h1{font-family:'Cinzel',serif;font-weight:900;font-size:clamp(34px,6vw,64px);letter-spacing:.12em;color:var(--gold-hi);
text-shadow:0 0 40px rgba(217,164,65,.5), 0 4px 14px #000;line-height:1.1}
.screen .sub{font-style:italic;font-size:clamp(15px,2vw,20px);color:var(--parch);opacity:.85;margin-top:10px;max-width:640px;line-height:1.5;padding:0 20px}
.screen .lore{font-size:14.5px;opacity:.6;margin-top:14px;max-width:560px;line-height:1.55;padding:0 20px}
.btnrow{display:flex;gap:16px;margin-top:34px;flex-wrap:wrap;justify-content:center}
.bigbtn{background:linear-gradient(180deg,#4a3a1e,#241c0e);border:1px solid rgba(217,164,65,.6);color:var(--gold-hi);
font-family:'Cinzel',serif;font-size:15px;letter-spacing:.22em;text-transform:uppercase;padding:14px 34px;border-radius:8px;cursor:pointer;
transition:all .15s}
.bigbtn:hover{background:linear-gradient(180deg,#5d4a28,#2e2414);box-shadow:0 0 26px rgba(217,164,65,.35);transform:translateY(-1px)}
.bigbtn.ghost{background:transparent;border-color:rgba(217,164,65,.3)}
#title .orn{width:220px;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);margin:22px auto}
#title .ver{position:absolute;bottom:18px;font-size:12px;letter-spacing:.3em;opacity:.4;text-transform:uppercase}
#controlsCard{margin-top:26px;display:grid;grid-template-columns:auto auto;gap:6px 26px;font-size:14px;opacity:.75;text-align:left}
#controlsCard b{color:var(--gold);font-family:'Cinzel',serif;font-size:12px;letter-spacing:.1em}
.statline{font-family:'Cinzel',serif;font-size:15px;letter-spacing:.12em;color:var(--parch);margin-top:8px}
.statline span{color:var(--gold-hi)}
#goScreen h1{color:#d65a4a;text-shadow:0 0 40px rgba(214,70,50,.4), 0 4px 14px #000}
#winScreen h1{color:var(--gold-hi)}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game" width="1280" height="720"></canvas>
<div id="vignette"></div>
<div id="hud" class="hidden">
<div class="hudbox">
<div id="hearts"></div>
<div id="weaponName"></div>
</div>
<div id="rightHud">
<div class="hudbox stat"><span class="ic">◉</span><span class="val" id="goldVal">0</span><span class="lbl">Sigils</span></div>
<div class="hudbox stat" id="ammoStat"><span class="ic" style="color:var(--aether)">✦</span><span class="val" id="ammoVal">0</span><span class="lbl">Shard</span></div>
</div>
</div>
<div id="banner"><div class="znum"></div><div class="zname"></div><div class="zsub"></div></div>
<div id="toasts"></div>
<div id="hints"><b>WASD</b> move · <b>J</b> strike · <b>K</b> shard bolt · <b>E</b> interact · <b>I</b> satchel · <b>1/2</b> use item · <b>P</b> pause</div>
<button id="muteBtn">Sound: On</button>
<div id="invPanel" class="panel hidden">
<h2>Warden's Satchel</h2><div class="psub">Consumables may be stacked · one of each trinket may be worn</div>
<div id="invBody"></div>
<div style="text-align:center;margin-top:14px"><button class="bigbtn ghost" id="invClose" style="padding:9px 26px;font-size:12px">Close (I)</button></div>
</div>
<div id="shopPanel" class="panel hidden">
<h2>The Merchant's Altar</h2><div class="psub">"Sigils buy what the Corrosion takes. Spend well, Warden."</div>
<div id="shopBody"></div>
<div class="shopgold">Your Sigils: <span id="shopGold">0</span></div>
<div style="text-align:center;margin-top:10px"><button class="bigbtn ghost" id="shopClose" style="padding:9px 26px;font-size:12px">Leave (E)</button></div>
</div>
<div id="title" class="screen">
<h1>The Warden's<br>Corrosion</h1>
<div class="sub">Aurelion's Sunken Observatory — the last light above a storm-locked valley</div>
<div class="lore">You are the Last Warden. The great gears have stopped, the telescopes lie shattered, and the black-iron Corrosion creeps through the bronze halls. Before the archive falls into the storm, you must cross six ruined wings, wake the ancient altars, and reignite the Core Beacon.</div>
<div class="orn"></div>
<div class="btnrow">
<button class="bigbtn" id="btnNew">Begin the Vigil</button>
<button class="bigbtn ghost hidden" id="btnContinue">Continue</button>
</div>
<div id="controlsCard">
<b>Move</b><span>W A S D / Arrows</span>
<b>Strike</b><span>J / Z / Space</span>
<b>Shard Bolt</b><span>K / X</span>
<b>Interact</b><span>E</span>
<b>Satchel</b><span>I / Tab</span>
<b>Use Stem / Seal</b><span>1 / 2</span>
<b>Pause</b><span>P / Esc</span>
</div>
<div class="ver">A single-file vigil · v1.0</div>
</div>
<div id="pause" class="screen hidden">
<h1>The Vigil Pauses</h1>
<div class="sub">The Corrosion waits. It is patient.</div>
<div class="btnrow">
<button class="bigbtn" id="btnResume">Resume</button>
<button class="bigbtn ghost" id="btnRestart">Abandon Run</button>
</div>
</div>
<div id="goScreen" class="screen hidden">
<h1>The Warden Falls</h1>
<div class="sub">Darkness takes you — but the Sanctuary Stones remember where you last stood.</div>
<div class="statline">Gold carried: <span id="goGold">0</span></div>
<div class="btnrow"><button class="bigbtn" id="btnRespawn">Rise at the Last Stone</button></div>
</div>
<div id="winScreen" class="screen hidden">
<h1>The Beacon Burns Again</h1>
<div class="sub">Light spills from the Core Chamber and races across the storm-locked valley. The gears stir. The archive breathes. The Warden's vigil is ended — and a new one begins.</div>
<div class="statline">Vigil kept in <span id="winTime">0:00</span></div>
<div class="statline">Sigils gathered: <span id="winGold">0</span> · Constructs unmade: <span id="winKills">0</span></div>
<div class="btnrow"><button class="bigbtn" id="btnAgain">Begin a New Vigil</button></div>
</div>
</div>
<script>
'use strict';
/* ============================== helpers ============================== */
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const dist=(x1,y1,x2,y2)=>Math.hypot(x2-x1,y2-y1);
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const choice=a=>a[Math.floor(Math.random()*a.length)];
const angDiff=(a,b)=>{let d=(b-a)%TAU; if(d>Math.PI)d-=TAU; if(d<-Math.PI)d+=TAU; return d;};
const hash2=(x,y)=>{let h=(x*374761393+y*668265263)^0x5bf03635; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967295;};
const fmtTime=s=>{s=Math.floor(s);return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');};
/* ============================== audio ============================== */
const Sfx={
ctx:null,master:null,muted:false,noiseBuf:null,drone:null,
ensure(){
if(!this.ctx){
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC)return;
this.ctx=new AC();
this.master=this.ctx.createGain();
this.master.gain.value=0.45;
this.master.connect(this.ctx.destination);
const len=this.ctx.sampleRate;
this.noiseBuf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d=this.noiseBuf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
}
if(this.ctx.state==='suspended')this.ctx.resume();
},
t(f,d,type,g,slide,delay){
if(!this.ctx||this.muted)return;
type=type||'sine'; g=(g==null)?0.2:g; delay=delay||0;
const t0=this.ctx.currentTime+delay;
const o=this.ctx.createOscillator(),gn=this.ctx.createGain();
o.type=type; o.frequency.setValueAtTime(f,t0);
if(slide)o.frequency.exponentialRampToValueAtTime(Math.max(30,slide),t0+d);
gn.gain.setValueAtTime(g,t0);
gn.gain.exponentialRampToValueAtTime(0.001,t0+d);
o.connect(gn); gn.connect(this.master);
o.start(t0); o.stop(t0+d+0.03);
},
n(d,fq,g,delay,ftype){
if(!this.ctx||this.muted)return;
delay=delay||0; ftype=ftype||'lowpass';
const t0=this.ctx.currentTime+delay;
const s=this.ctx.createBufferSource(); s.buffer=this.noiseBuf; s.loop=true;
const fl=this.ctx.createBiquadFilter(); fl.type=ftype; fl.frequency.value=fq; fl.Q.value=1;
const gn=this.ctx.createGain();
gn.gain.setValueAtTime(g,t0);
gn.gain.exponentialRampToValueAtTime(0.001,t0+d);
s.connect(fl); fl.connect(gn); gn.connect(this.master);
s.start(t0); s.stop(t0+d+0.03);
},
step(zi){
const v=[[340,.05,.05],[950,.045,.055],[520,.05,.05],[430,.06,.045],[620,.05,.05],[390,.06,.055]][zi%6];
this.n(v[1],v[0],v[2],0,'lowpass');
if(zi===3)this.t(210,0.09,'sine',0.03,150);
if(zi===1)this.n(0.03,2600,0.035,0,'bandpass');
},
whoosh(w){
if(w===2){this.n(0.24,900,0.11,0,'bandpass'); this.t(68,0.32,'sine',0.13,48,0.06);}
else this.n(w===1?0.16:0.12, w===1?1500:1150, 0.09,0,'bandpass');
},
hit(w,crit){
if(crit)this.t(1250,0.12,'triangle',0.13,650);
if(w===0){this.t(95,0.12,'sine',0.22,60); this.n(0.06,800,0.13,0,'lowpass');}
else if(w===1){this.t(85,0.14,'sine',0.25,55); this.n(0.12,3200,0.08,0,'highpass');}
else{this.t(58,0.38,'sine',0.32,40); this.t(520,0.2,'square',0.05,470); this.n(0.16,2100,0.1,0,'bandpass');}
},
twang(){this.t(880,0.12,'triangle',0.16,300); this.n(0.05,3600,0.05,0,'highpass');},
boltHit(){this.t(1500,0.16,'sine',0.11,880);},
hurt(){this.t(70,0.16,'sine',0.3,48); this.t(1240,0.2,'sawtooth',0.05,1150); this.t(1290,0.2,'sawtooth',0.05,1180);},
coin(){this.t(1900,0.06,'square',0.06); this.t(2450,0.09,'square',0.05,0,0.05);},
item(){this.t(520,0.1,'sine',0.12); this.t(660,0.1,'sine',0.12,0,0.08); this.t(880,0.18,'sine',0.12,0,0.16);},
rare(){this.t(392,0.4,'sine',0.12); this.t(494,0.4,'sine',0.1,0,0.02); this.t(587,0.5,'sine',0.12,0,0.05); this.t(784,0.55,'sine',0.08,0,0.12);},
heartUp(){for(let i=0;i<6;i++)this.t(400+i*130,0.26,'sine',0.09,0,i*0.06); this.n(0.5,5200,0.04,0,'highpass');},
altar(){this.t(98,0.8,'sine',0.18); this.t(147,0.8,'sine',0.12,0,0.05); this.t(196,1,'sine',0.1,0,0.1); for(let i=0;i<5;i++)this.t(600+i*150,0.2,'sine',0.06,0,0.2+i*0.07);},
door(){this.t(55,0.4,'sine',0.26,44); this.n(0.3,300,0.16,0.1,'lowpass');},
plate(){this.t(120,0.12,'square',0.13,88); this.n(0.08,650,0.1,0,'lowpass');},
sw(){this.t(1400,0.26,'sine',0.13,2200); this.t(1750,0.2,'sine',0.08,2400,0.06);},
buy(){this.coin(); this.t(784,0.15,'sine',0.1,0,0.1); this.t(1046,0.22,'sine',0.1,0,0.18);},
death(){this.t(220,0.8,'sawtooth',0.15,55); this.n(0.6,400,0.16,0.1,'lowpass');},
victory(){[523,659,784,1046,1318].forEach((f,i)=>this.t(f,0.5,'sine',0.12,0,i*0.12));},
freeze(){this.t(2000,0.3,'sine',0.09,600); this.n(0.2,6200,0.05,0,'highpass');},
droneOn(){
if(!this.ctx||this.drone)return;
const o1=this.ctx.createOscillator(),o2=this.ctx.createOscillator(),g=this.ctx.createGain();
o1.type='sine'; o2.type='sine'; o1.frequency.value=55; o2.frequency.value=82.5;
g.gain.value=0; o1.connect(g); o2.connect(g); g.connect(this.master);
o1.start(); o2.start();
g.gain.linearRampToValueAtTime(0.07,this.ctx.currentTime+1.2);
this.drone={o1,o2,g};
},
droneOff(){
if(!this.drone)return;
const d=this.drone;
d.g.gain.linearRampToValueAtTime(0.0001,this.ctx.currentTime+0.5);
setTimeout(()=>{try{d.o1.stop();d.o2.stop();}catch(e){}},700);
this.drone=null;
}
};
/* ============================== input ============================== */
const keys={};
let pressEdge={};
window.addEventListener('keydown',e=>{
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab'].includes(e.code))e.preventDefault();
if(!keys[e.code])pressEdge[e.code]=true;
keys[e.code]=true;
Sfx.ensure();
});
window.addEventListener('keyup',e=>{keys[e.code]=false;});
window.addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
const down=(...codes)=>codes.some(c=>keys[c]);
const pressed=(...codes)=>codes.some(c=>pressEdge[c]);
/* ============================== data ============================== */
const TS=32, MW=40, MH=26;
const WEAPONS=[
{name:'Ember Blade', dmg:2, arc:1.9, range:46, cd:0.45, type:'phys'},
{name:'Ignited Edge', dmg:3, arc:2.1, range:48, cd:0.30, type:'fire'},
{name:'Solar Falchion',dmg:5, arc:2.6, range:58, cd:0.55, windup:0.12, type:'solar'}
];
const ENEMY_TYPES={
chaser: {hp:4, r:12, spd:95, dmg:1, coin:[5,10], name:'Rusted Husk'},
shooter: {hp:3, r:12, spd:70, dmg:1, coin:[8,12], name:'Broken Orrery'},
brute: {hp:10, r:16, spd:55, dmg:2, coin:[12,15], name:'Corroded Sentinel'},
guardian:{hp:30, r:20, spd:72, dmg:2, coin:[25,35], name:'Gallery Guardian'},
boss: {hp:60, r:26, spd:82, dmg:2, coin:[100,120],name:'Corrupted Warden'}
};
const PICKUP_INFO={
H:{name:'Heart Container',desc:'A vessel of the Warden\'s vitality. +2 max HP.'},
V:{name:'Vital Stem',desc:'Restores 2 full containers (4 HP).'},
W:{name:"Warden's Band",desc:'A glowing silver ring. Slightly swifter strikes.'},
Q:{name:'Aether Shard',desc:'A crystalline launcher. Fire bolts with K. 10 charges.'},
L:{name:'Phantom Lantern',desc:'Reveals hidden cracks and paths in the dark. Blue glow.'},
G:{name:'Shadow Cloak',desc:'Dark cloth trim. Neutralizes environmental trap damage.'},
Z:{name:'Binding Seal',desc:'Freezes the nearest construct for ~3 seconds.'}
};
/* ============================== canvas & state ============================== */
const cv=document.getElementById('game');
const cx=cv.getContext('2d');
const VW=1280,VH=720;
const cam={x:0,y:0};
const show=id=>document.getElementById(id).classList.remove('hidden');
const hide=id=>document.getElementById(id).classList.add('hidden');
const G={
state:'title',zone:0,map:null,player:null,
enemies:[],bolts:[],parts:[],dnums:[],pickups:[],
doors:[],plates:[],switches:[],breaks:[],beacon:null,stonePos:[],
flags:{},collected:{},
shop:{stems:3,seals:2,heart:true},
time:0,kills:0,flash:0,shimmer:0,
fade:1,fadeDir:-1,fadeCb:null,
lastStone:0,lastA:0,lastB:0
};
const PALETTES=[
{floor:'#3a332c',floor2:'#342e28',wall:'#5a4c3c',wallHi:'#6b5a46'},
{floor:'#3a362c',floor2:'#343028',wall:'#5f4c30',wallHi:'#715c3c'},
{floor:'#362f2a',floor2:'#302a24',wall:'#574534',wallHi:'#675340'},
{floor:'#2c333d',floor2:'#272e38',wall:'#43596d',wallHi:'#526a80'},
{floor:'#302b36',floor2:'#2a2530',wall:'#4c3e54',wallHi:'#5a4a63'},
{floor:'#382b28',floor2:'#322523',wall:'#5f3c34',wallHi:'#71483e'}
];
const ZONES=[
{name:'The Ashen Threshold',sub:'Prologue — where the vigil begins',dark:false,
spawn:[640,700],
walls:[[4,5,3,1],[10,5,3,1],[16,5,2,1],[22,5,3,1],[29,5,3,1],[35,5,2,1],
[4,10,3,1],[11,10,3,1],[20,10,3,1],[28,10,3,1],[35,10,2,1],
[7,15,3,1],[15,15,3,1],[24,15,3,1],[32,15,3,1],
[10,19,2,1],[28,19,2,1]],
stone:[[19,21]],exit:[[19,1]],
enemies:[['chaser',440,260],['chaser',840,270],['chaser',640,430],['chaser',380,560],['shooter',900,480]],
pickups:[{t:'coin',x:300,y:500,a:8},{t:'coin',x:980,y:300,a:8}]
},
{name:'The Rusted Archives',sub:'Bronze shelves, drifting dust, a merchant\'s whisper',dark:false,
spawn:[640,700],
walls:[[3,4,7,1],[13,4,6,1],[23,4,6,1],[33,4,4,1],
[3,9,5,1],[11,9,8,1],[23,9,5,1],[32,9,5,1],
[3,14,8,1],[15,14,4,1],[24,14,6,1],[34,14,3,1],
[6,19,4,1],[14,19,5,1],[23,19,5,1],[32,19,4,1]],
grate:[[4,21,12,2],[24,21,12,2]],
shop:[[19,22]],stone:[[19,12]],exit:[[19,1]],
breaks:[{x:2,y:6,c:{t:'W',id:'z2band'}},{x:37,y:6,c:{t:'V',id:'z2stem'}},{x:37,y:11,c:{t:'coin',a:25,id:'z2c1'}}],
enemies:[['chaser',300,300],['chaser',980,300],['chaser',420,520],['chaser',860,520],['shooter',320,380],['shooter',960,380],['chaser',640,600]],
pickups:[{t:'coin',x:200,y:640,a:6},{t:'coin',x:1080,y:640,a:6}]
},
{name:'The Collapsed Gallery',sub:'Broken frames, a sleeping altar, a guardian in the dark',dark:false,
spawn:[640,700],
walls:[[3,4,4,1],[8,4,29,1],
[6,5,1,4],[8,5,1,4],
[10,10,5,1],[20,10,5,1],
[4,12,2,1],[9,12,14,1],[26,12,6,1],[34,12,3,1],
[6,13,1,3],[8,13,1,3],
[10,16,5,1],[25,16,5,1]],
trap:[[14,13,2,1]],
plate:[[7,8]],door:[[7,4]],exit:[[19,1]],altar:[[24,2]],
breaks:[{x:35,y:8,c:{t:'H',id:'z3heart'}}],
enemies:[['chaser',300,260],['chaser',900,260],['brute',640,432],['shooter',300,500],['shooter',980,500],['guardian',928,576]],
pickups:[{t:'coin',x:250,y:620,a:10}]
},
{name:'The Sunken Vault',sub:'Mist rises from black water. Something glimmers behind a cracked wall',dark:true,
spawn:[640,700],
walls:[[4,5,6,1],[14,5,6,1],[24,5,6,1],[34,5,3,1],
[4,11,8,1],[16,11,8,1],[28,11,8,1],
[8,17,6,1],[20,17,6,1],[32,17,4,1]],
mist:[[2,3,36,21]],
trap:[[10,8,2,1],[24,14,2,1]],
switch:[{x:196,y:224,id:'z4s'}],
exit:[[19,1]],
breaks:[{x:34,y:4,c:{t:'Q',id:'z4shard'}}],
enemies:[['chaser',320,300],['chaser',960,300],['shooter',420,460],['shooter',860,460],['brute',608,560],['chaser',260,600],['chaser',1020,600]],
pickups:[{t:'coin',x:200,y:560,a:8},{t:'coin',x:1080,y:560,a:8}]
},
{name:'The Twin Spire',sub:'The final ascent. The stones remember the Warden',dark:true,
spawn:[640,700],
walls:[[3,5,8,1],[14,5,8,1],[26,5,8,1],
[3,9,16,1],[20,9,17,1],
[4,14,6,1],[14,14,6,1],[24,14,6,1],[34,14,3,1],
[8,19,5,1],[20,19,5,1],[30,19,4,1]],
trap:[[10,7,2,1],[28,13,2,1],[14,21,3,1]],
stone:[[19,22]],altar:[[19,3]],switch:[{x:160,y:192,id:'z5s'}],
plate:[[13,13]],plate2:[[25,13]],door:[[19,9]],exit:[[35,1]],
breaks:[{x:36,y:7,c:{t:'G',id:'z5cloak'}},{x:3,y:13,c:{t:'Z',id:'z5seal'}},{x:36,y:17,c:{t:'coin',a:80,id:'z5gold'}}],
enemies:[['chaser',300,240],['chaser',980,240],['brute',420,400],['brute',860,400],['shooter',300,520],['shooter',980,520],['chaser',540,560],['chaser',740,560]],
pickups:[{t:'coin',x:240,y:640,a:10},{t:'coin',x:1040,y:640,a:10}]
},
{name:'The Core Chamber',sub:'The beacon sleeps. Wake it — or be unmade',dark:true,
spawn:[640,700],
walls:[[6,6,8,1],[26,6,8,1],
[3,10,16,1],[20,10,17,1],
[6,14,4,1],[30,14,4,1]],
switch:[{x:260,y:256,id:'z6a'},{x:1020,y:256,id:'z6b'}],
door:[[19,10]],beacon:[640,160],
enemies:[['boss',640,520]],
pickups:[]
}
];
/* ============================== map building ============================== */
function buildMap(def){
const m=Array.from({length:MH},()=>new Array(MW).fill(0));
const set=(x,y,v)=>{if(x>=0&&y>=0&&x<MW&&y<MH)m[y][x]=v;};
for(let x=0;x<MW;x++){m[0][x]=1;m[MH-1][x]=1;}
for(let y=0;y<MH;y++){m[y][0]=1;m[y][MW-1]=1;}
def.walls.forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,1);});
(def.grate||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,10);});
(def.mist||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)if(m[yy]&&m[yy][xx]===0)set(xx,yy,9);});
(def.trap||[]).forEach(([x,y,w,h])=>{for(let yy=y;yy<y+h;yy++)for(let xx=x;xx<x+w;xx++)set(xx,yy,11);});
(def.stone||[]).forEach(([x,y])=>set(x,y,6));
(def.exit||[]).forEach(([x,y])=>set(x,y,5));
(def.shop||[]).forEach(([x,y])=>set(x,y,7));
(def.altar||[]).forEach(([x,y])=>set(x,y,8));
if(def.plate)set(def.plate[0],def.plate[1],3);
if(def.plate2)set(def.plate2[0],def.plate2[1],3);
if(def.door)set(def.door[0],def.door[1],4);
return m;
}
function loadZone(i){
const def=ZONES[i];
G.zone=i;
G.map=buildMap(def);
G.breaks=(def.breaks||[]).map(b=>({...b,hp:8,maxHp:8,broken:false}));
G.breaks.forEach(b=>{G.map[b.y][b.x]=2;});
G.breaks=G.breaks.filter(b=>{
if(G.collected['b'+b.x+'_'+b.y]){G.map[b.y][b.x]=0;return false;}
return true;
});
G.doors=def.door?[{x:def.door[0],y:def.door[1],id:'z'+(i+1)+'d',open:false}]:[];
G.plates=[];
if(def.plate)G.plates.push({x:def.plate[0],y:def.plate[1],id:'z'+(i+1)+'p',pressed:false});
if(def.plate2)G.plates.push({x:def.plate2[0],y:def.plate2[1],id:'z5p2',pressed:false});
G.switches=(def.switch||[]).map(s=>({...s,active:false}));
G.beacon=def.beacon?{x:def.beacon[0],y:def.beacon[1],active:!!G.flags.bossDown}:null;
G.stonePos=[];
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++)if(G.map[y][x]===6)G.stonePos.push({x:x*TS+16,y:y*TS+16});
G.enemies=def.enemies.map(e=>spawnEnemy(e[0],e[1],e[2]));
G.bolts=[];G.parts=[];G.dnums=[];
G.pickups=def.pickups.map((p,j)=>({...p,id:'s'+i+'_'+j}));
G.pickups=G.pickups.filter(p=>!G.collected[p.id]);
if(G.player){G.player.x=def.spawn[0];G.player.y=def.spawn[1];}
showBanner();
}
function startNew(){
G.flags={};G.collected={};G.shop={stems:3,seals:2,heart:true};
G.time=0;G.kills=0;G.lastStone=0;G.lastA=0;G.lastB=0;
G.player=makePlayer(640,700);
loadZone(0);
updateHUD();
}
/* ============================== save / load ============================== */
function saveGame(){
const p=G.player;
const data={v:1,zone:G.lastStone,px:p.x,py:p.y,hp:p.hp,maxHp:p.maxHp,weapon:p.weapon,ammo:p.ammo,gold:p.gold,
items:p.items,own:p.own,equipped:p.equipped,flags:G.flags,collected:G.collected,shop:G.shop,time:G.time,kills:G.kills};
try{localStorage.setItem('warden_save',JSON.stringify(data));}catch(e){}
}
function hasSave(){try{return !!localStorage.getItem('warden_save');}catch(e){return false;}}
function loadSave(){
try{
const d=JSON.parse(localStorage.getItem('warden_save'));
if(!d||d.v!==1)return false;
startNew();
const p=G.player;
p.hp=d.hp;p.maxHp=d.maxHp;p.weapon=d.weapon;p.ammo=d.ammo;p.gold=d.gold;
p.items=d.items;p.own=d.own;p.equipped=d.equipped;
p.shardReady=!!d.own.Q;
G.flags=d.flags;G.collected=d.collected;G.shop=d.shop;G.time=d.time||0;G.kills=d.kills||0;
loadZone(d.zone);
p.x=d.px;p.y=d.py;
updateHUD();
return true;
}catch(e){return false;}
}
/* ============================== toasts & banner ============================== */
function toast(msg,cls){
const el=document.createElement('div');
el.className='toast '+(cls||'');
el.textContent=msg;
document.getElementById('toasts').appendChild(el);
setTimeout(()=>el.remove(),3300);
}
function showBanner(){
const z=ZONES[G.zone],b=document.getElementById('banner');
b.querySelector('.znum').textContent='Zone '+(G.zone+1)+' of 6';
b.querySelector('.zname').textContent=z.name;
b.querySelector('.zsub').textContent=z.sub;
b.classList.add('show');
clearTimeout(showBanner.t);
showBanner.t=setTimeout(()=>b.classList.remove('show'),3200);
}
function fadeTo(cb){G.fade=1;G.fadeDir=-1;G.fadeCb=cb;}
/* ============================== player ============================== */
function makePlayer(x,y){
return {x,y,r:11,hp:6,maxHp:6,weapon:0,ammo:0,gold:0,items:{V:0,Z:0},
own:{},equipped:{W:false,L:false,G:false},
face:1,aim:0,atkCd:0,atkT:0,windup:0,swingA:0,
inv:0,hurtT:0,stepT:0,anim:0,shardReady:false,
emberT:0,sealCd:0};
}
function solidAt(px,py){
const tx=Math.floor(px/TS),ty=Math.floor(py/TS);
if(tx<0||ty<0||tx>=MW||ty>=MH)return true;
const t=G.map[ty][tx];
if(t===1||t===10||t===2)return true;
if(t===4){const d=G.doors.find(d=>d.x===tx&&d.y===ty);return d&&!d.open;}
return false;
}
function moveEntity(e,dx,dy){
const tryAxis=(ox,oy)=>{
const nx=e.x+ox,ny=e.y+oy;
const r=e.r;
if(!solidAt(nx-r,ny-r)&&!solidAt(nx+r,ny-r)&&!solidAt(nx-r,ny+r)&&!solidAt(nx+r,ny+r)){
e.x=nx;e.y=ny;return true;
}
return false;
};
if(dx)tryAxis(dx,0);
if(dy)tryAxis(0,dy);
}
function updatePlayer(dt){
const p=G.player,W=WEAPONS[p.weapon];
p.atkCd-=dt;p.inv-=dt;p.hurtT-=dt;p.sealCd-=dt;
let mx=(down('KeyD','ArrowRight')?1:0)-(down('KeyA','ArrowLeft')?1:0);
let my=(down('KeyS','ArrowDown')?1:0)-(down('KeyW','ArrowUp')?1:0);
const mv=205;
if(mx&&my){mx*=0.7071;my*=0.7071;}
if(mx||my){
moveEntity(p,mx*mv*dt,my*mv*dt);
if(mx)p.face=mx>0?1:-1;
p.aim=Math.atan2(my,mx);
p.anim+=dt*9;
p.stepT-=dt;
if(p.stepT<=0){p.stepT=0.33;Sfx.step(G.zone);}
}else p.anim*=0.9;
/* ember trail */
if((mx||my)&&p.weapon>=1){
p.emberT-=dt;
if(p.emberT<=0){
p.emberT=0.09;
addPart(p.x-p.face*10+rand(-3,3),p.y+8,rand(-8,8),rand(4,14),rand(0.3,0.55),p.weapon===2?'#ffd97a':'#ff8c3a',p.weapon===2?3:2,'glow');
}
}
/* attack */
if(down('KeyJ','KeyZ','Space')&&p.atkCd<=0){
if(W.windup&&p.windup<=0){p.windup=W.windup;p.atkCd=W.cd+W.windup;Sfx.whoosh(p.weapon+1);}
else if(!W.windup){doSwing();}
}
if(p.windup>0){
p.windup-=dt;
if(p.windup<=0)doSwing();
}
if(p.atkT>0)p.atkT-=dt;
/* ranged */
if(pressed('KeyK','KeyX')&&p.shardReady){
if(p.ammo>0){
p.ammo--;
const a=p.aim;
G.bolts.push({x:p.x+Math.cos(a)*14,y:p.y+Math.sin(a)*14,vx:Math.cos(a)*460,vy:Math.sin(a)*460,life:1.6});
Sfx.twang();
for(let i=0;i<5;i++)addPart(p.x+Math.cos(a)*14,p.y+Math.sin(a)*14,Math.cos(a)*rand(30,90)+rand(-40,40),Math.sin(a)*rand(30,90)+rand(-40,40),0.3,'#9fe0ff',2,'glow');
updateHUD();
}else toast('The Aether Shard is spent — buy charges at the merchant','blue');
}
/* consumables */
if(pressed('Digit1'))useItem('V');
if(pressed('Digit2'))useItem('Z');
/* world interactions */
interactWorld();
}
function doSwing(){
const p=G.player,W=WEAPONS[p.weapon];
p.atkT=0.22;
p.swingA=p.aim;
let hitAny=false;
G.enemies.forEach(e=>{
if(e.dead)return;
const d=dist(p.x,p.y,e.x,e.y);
if(d<W.range+e.r){
const a=Math.atan2(e.y-p.y,e.x-p.x);
if(Math.abs(angDiff(p.aim,a))<W.arc/2+0.35){
hitEnemy(e,W.dmg,W.type);
hitAny=true;
}
}
});
/* breakable walls in arc */
G.breaks.forEach(b=>{
if(b.broken)return;
const bx=b.x*TS+16,by=b.y*TS+16;
const d=dist(p.x,p.y,bx,by);
if(d<W.range+20){
const a=Math.atan2(by-p.y,bx-p.x);
if(Math.abs(angDiff(p.aim,a))<W.arc/2+0.6){
b.hp-=p.weapon===2?3:1;
addPart(bx,by,rand(-60,60),rand(-60,60),0.4,'#5a4a3a',3,'spark');
Sfx.n(0.08,700,0.12,0,'lowpass');
if(b.hp<=0)breakWall(b);
}
}
});
if(hitAny&&p.weapon>=1){
for(let i=0;i<6;i++)addPart(p.x+Math.cos(p.aim)*30,p.y+Math.sin(p.aim)*30,Math.cos(p.aim)*rand(60,160)+rand(-50,50),Math.sin(p.aim)*rand(60,160)+rand(-50,50),0.4,p.weapon===2?'#ffd97a':'#ff8c3a',2,'glow');
}
}
function breakWall(b){
b.broken=true;
G.map[b.y][b.x]=0;
G.collected['b'+b.x+'_'+b.y]=1;
Sfx.n(0.3,500,0.2,0,'lowpass');
for(let i=0;i<14;i++)addPart(b.x*TS+16,b.y*TS+16,rand(-120,120),rand(-120,120),rand(0.4,0.8),choice(['#5a4a3a','#3d332a','#6b5a44']),3,'spark');
if(b.c){
const c={...b.c,x:b.x*TS+16,y:b.y*TS+16};
if(c.t==='coin'){addCoin(c.x,c.y,c.a);}
else G.pickups.push(c);
}
saveGame();
}
function hitEnemy(e,dmg,type){
e.hp-=dmg;
e.flash=0.15;
e.burn=type==='fire'?0.6:0;
e.vx=Math.cos(Math.atan2(e.y-G.player.y,e.x-G.player.x))*160;
e.vy=Math.sin(Math.atan2(e.y-G.player.y,e.x-G.player.x))*160;
Sfx.hit(type==='phys'?0:(type==='fire'?1:2),false);
if(type==='fire'){
for(let i=0;i<5;i++)addPart(e.x,e.y,rand(-80,80),rand(-80,80),0.5,'#ff8c3a',2,'glow');
}else if(type==='solar'){
for(let i=0;i<8;i++)addPart(e.x,e.y,rand(-140,140),rand(-140,140),0.5,'#ffd97a',2.5,'glow');
G.shimmer=Math.max(G.shimmer,0.25);
}else{
for(let i=0;i<4;i++)addPart(e.x,e.y,rand(-100,100),rand(-100,100),0.35,'#ffb35c',2,'spark');
}
G.dnums.push({x:e.x,y:e.y-e.r-6,txt:String(dmg),t:0.7,col:type==='fire'?'#ff9c50':(type==='solar'?'#ffd97a':'#fff')});
if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
e.dead=true;
G.kills++;
const T=ENEMY_TYPES[e.type];
Sfx.n(0.25,400,0.18,0,'lowpass');
for(let i=0;i<12;i++)addPart(e.x,e.y,rand(-160,160),rand(-160,160),rand(0.4,0.7),choice(['#4a3b26','#5a4830','#2a2018']),3,'spark');
const c=randi(T.coin[0],T.coin[1]);
addCoin(e.x,e.y,c);
if(e.type==='guardian'){
if(!G.own.G){G.pickups.push({t:'G',id:'z5cloak',x:e.x,y:e.y});toast('The Guardian drops the Shadow Cloak','gold');}
}
if(e.type==='boss'){
G.flags.bossDown=true;
toast('The Corrupted Warden is unmade — the beacon stirs','gold');
G.shimmer=2;
}
if(e.type==='shooter'&&Math.random()<0.5)G.pickups.push({t:'V',id:'drop'+Math.random(),x:e.x,y:e.y});
}
function damagePlayer(dmg,srcX,srcY){
const p=G.player;
if(p.inv>0||G.state!=='play')return;
if(p.own.G&&srcX===undefined){/* trap hit handled elsewhere */}
p.hp-=dmg;
p.inv=1.0;p.hurtT=0.4;
G.flash=0.5;
Sfx.hurt();
for(let i=0;i<10;i++)addPart(p.x,p.y,rand(-140,140),rand(-140,140),0.5,'#d64545',3,'glow');
if(srcX!==undefined){
const a=Math.atan2(p.y-srcY,p.x-srcX);
moveEntity(p,Math.cos(a)*18,Math.sin(a)*18);
}
updateHUD();
if(p.hp<=0)die();
}
function die(){
G.state='dead';
Sfx.death();
document.getElementById('goGold').textContent=G.player.gold;
show('goScreen');
}
function useItem(t){
const p=G.player;
if(p.items[t]<=0){toast(t==='V'?'No Vital Stems in your satchel':'No Binding Seals in your satchel','red');return;}
if(t==='V'){
if(p.hp>=p.maxHp){toast('Your vitality is already full','red');return;}
p.items.V--;
p.hp=Math.min(p.maxHp,p.hp+4);
Sfx.item();
for(let i=0;i<14;i++)addPart(p.x,p.y,rand(-60,60),rand(-120,-20),rand(0.5,0.9),'#7fe08a',2.5,'glow');
toast('Vital Stem consumed — +4 HP');
}else{
if(p.sealCd>0)return;
let best=null,bd=1e9;
G.enemies.forEach(e=>{if(!e.dead){const d=dist(p.x,p.y,e.x,e.y);if(d<bd&&d<420){bd=d;best=e;}}});
if(!best){toast('No construct in range to bind','red');return;}
p.items.Z--;p.sealCd=0.5;
best.freeze=3;
Sfx.freeze();
for(let i=0;i<12;i++)addPart(best.x,best.y,rand(-80,80),rand(-80,80),0.6,'#9fd8ff',2.5,'glow');
toast('A construct is bound in frost','blue');
}
updateHUD();
}
/* ============================== enemies ============================== */
function spawnEnemy(type,x,y){
const T=ENEMY_TYPES[type];
return {type,x,y,r:T.r,hp:T.hp,maxHp:T.hp,spd:T.spd,dmg:T.dmg,
vx:0,vy:0,flash:0,burn:0,freeze:0,shootT:rand(1,2.5),
atkCd:0,dead:false,anim:rand(0,9),
boss:type==='boss'?{phase:0,t:0,aim:0}:null};
}
function losClear(x1,y1,x2,y2){
const d=dist(x1,y1,x2,y2),steps=Math.ceil(d/24);
for(let i=1;i<steps;i++){
const t=i/steps;
if(solidAt(lerp(x1,x2,t),lerp(y1,y2,t)))return false;
}
return true;
}
function updateEnemies(dt){
const p=G.player;
G.enemies.forEach(e=>{
if(e.dead)return;
e.flash-=dt;e.atkCd-=dt;e.anim+=dt*6;
if(e.burn>0)e.burn-=dt;
if(e.freeze>0){e.freeze-=dt;return;}
const d=dist(p.x,p.y,e.x,e.y);
const a=Math.atan2(p.y-e.y,p.x-e.x);
let spd=e.spd;
if(e.burn>0)spd*=0.6;
if(e.type==='chaser'||e.type==='brute'||e.type==='guardian'){
if(d<560){
e.vx=Math.cos(a)*spd;e.vy=Math.sin(a)*spd;
moveEntity(e,e.vx*dt,e.vy*dt);
}
if(d<e.r+p.r+6&&e.atkCd<=0){
e.atkCd=0.9;
damagePlayer(e.dmg,e.x,e.y);
}
}else if(e.type==='shooter'){
if(d>260){e.vx=Math.cos(a)*spd;e.vy=Math.sin(a)*spd;moveEntity(e,e.vx*dt,e.vy*dt);}
else if(d<200){e.vx=-Math.cos(a)*spd*0.7;e.vy=-Math.sin(a)*spd*0.7;moveEntity(e,e.vx*dt,e.vy*dt);}
else{e.vx*=0.8;e.vy*=0.8;}
e.shootT-=dt;
if(e.shootT<=0&&d<420&&losClear(e.x,e.y,p.x,p.y)){
e.shootT=rand(1.8,2.6);
G.bolts.push({x:e.x,y:e.y,vx:Math.cos(a)*280,vy:Math.sin(a)*280,life:2,hostile:true});
Sfx.t(900,0.1,'triangle',0.06,400);
}
}else if(e.type==='boss'){
updateBoss(e,dt,a,d);
}
});
}
function updateBoss(e,dt,a,d){
const p=G.player;
e.boss.t+=dt;
const t=e.boss.t;
/* slow orbit + charge pattern */
const phase=Math.floor(t/6)%3;
if(phase===0){
e.vx=Math.cos(a)*e.spd;e.vy=Math.sin(a)*e.spd;
}else if(phase===1){
const oa=a+Math.PI/2;
e.vx=Math.cos(oa)*e.spd*1.1;e.vy=Math.sin(oa)*e.spd*1.1;
}else{
e.vx=Math.cos(a)*e.spd*1.6;e.vy=Math.sin(a)*e.spd*1.6;
if(t%6<0.4)addPart(e.x+rand(-10,10),e.y+rand(-10,10),rand(-30,30),rand(-30,30),0.3,'#d64545',3,'glow');
}
moveEntity(e,e.vx*dt,e.vy*dt);
if(d<e.r+p.r+6&&e.atkCd<=0){e.atkCd=0.9;damagePlayer(e.dmg,e.x,e.y);}
/* radial burst every 8s */
if(Math.floor(t/8)!==Math.floor((t-dt)/8)){
Sfx.t(200,0.3,'sawtooth',0.12,80);
for(let i=0;i<10;i++){
const ba=i/10*TAU;
G.bolts.push({x:e.x,y:e.y,vx:Math.cos(ba)*240,vy:Math.sin(ba)*240,life:2.2,hostile:true});
}
for(let i=0;i<16;i++)addPart(e.x,e.y,Math.cos(i/16*TAU)*rand(100,200),Math.sin(i/16*TAU)*rand(100,200),0.5,'#d64545',3,'glow');
}
/* summon chasers at 60% and 30% */
if(e.hp<e.maxHp*0.6&&!e.boss.s1){e.boss.s1=true;summonAdds(e);}
if(e.hp<e.maxHp*0.3&&!e.boss.s2){e.boss.s2=true;summonAdds(e);}
}
function summonAdds(e){
for(let i=0;i<3;i++){
const a=rand(0,TAU);
const c=spawnEnemy('chaser',clamp(e.x+Math.cos(a)*60,60,1220),clamp(e.y+Math.sin(a)*60,60,700));
G.enemies.push(c);
addPart(c.x,c.y,0,-40,0.6,'#d64545',3,'glow');
}
toast('The Corrupted Warden summons constructs!','red');
}
/* ============================== bolts, particles, pickups ============================== */
function updateBolts(dt){
G.bolts=G.bolts.filter(b=>{
b.life-=dt;
b.x+=b.vx*dt;b.y+=b.vy*dt;
if(b.life<=0)return false;
if(solidAt(b.x,b.y)){
Sfx.boltHit();
for(let i=0;i<5;i++)addPart(b.x,b.y,rand(-80,80),rand(-80,80),0.3,b.hostile?'#d64545':'#9fe0ff',2,'glow');
return false;
}
if(b.hostile){
const p=G.player;
if(dist(b.x,b.y,p.x,p.y)<p.r+4){damagePlayer(1,b.x,b.y);return false;}
}else{
for(const e of G.enemies){
if(!e.dead&&dist(b.x,b.y,e.x,e.y)<e.r+4){
hitEnemy(e,3,'fire');
Sfx.boltHit();
return false;
}
}
for(const s of G.switches){
if(!s.active&&dist(b.x,b.y,s.x,s.y)<20){
s.active=true;
Sfx.sw();
for(let i=0;i<12;i++)addPart(s.x,s.y,rand(-100,100),rand(-100,100),0.5,'#9fe0ff',2.5,'glow');
onSwitch(s);
return false;
}
}
if(G.beacon&&!G.beacon.active&&dist(b.x,b.y,G.beacon.x,G.beacon.y)<26){
activateBeacon();
return false;
}
}
return true;
});
}
function onSwitch(s){
if(s.id==='z4s'){
G.flags.z4s=true;
for(let x=17;x<=21;x++)for(let y=2;y<=3;y++)if(G.map[y][x]===1)G.map[y][x]=0;
toast('A sealed wall grinds open — the far gate lies clear','blue');
Sfx.door();
}else if(s.id==='z5s'){
G.flags.z5s=true;
for(let x=34;x<=36;x++)for(let y=2;y<=3;y++)if(G.map[y][x]===1)G.map[y][x]=0;
if(!G.collected['z5heart5']){
G.pickups.push({t:'H',id:'z5heart5',x:35*TS+16,y:3*TS+16});
toast('A hidden alcove opens — a heart container glows within','gold');
}
Sfx.door();
}else if(s.id==='z6a'){G.lastA=G.time;}
else if(s.id==='z6b'){G.lastB=G.time;}
if((s.id==='z6a'||s.id==='z6b')&&Math.abs(G.lastA-G.lastB)<3){
G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
toast('The twin seals align — the beacon chamber opens','gold');
}
saveGame();
}
function addPart(x,y,vx,vy,life,col,size,kind){
if(G.parts.length>420)G.parts.shift();
G.parts.push({x,y,vx,vy,life,maxLife:life,col,size,kind});
}
function updateParts(dt){
G.parts=G.parts.filter(p=>{
p.life-=dt;
p.x+=p.vx*dt;p.y+=p.vy*dt;
p.vx*=0.92;p.vy*=0.92;
return p.life>0;
});
G.dnums=G.dnums.filter(d=>{d.t-=dt;d.y-=30*dt;return d.t>0;});
}
function addCoin(x,y,amount){
const n=clamp(Math.ceil(amount/5),1,4);
for(let i=0;i<n;i++){
G.pickups.push({t:'coin',id:'c'+Math.random(),x:x+rand(-14,14),y:y+rand(-14,14),a:Math.ceil(amount/n),mag:0.5});
}
}
function updatePickups(dt){
const p=G.player;
G.pickups=G.pickups.filter(pk=>{
const d=dist(p.x,p.y,pk.x,pk.y);
if(pk.mag!==undefined){
pk.mag-=dt;
if(pk.mag<=0){
const a=Math.atan2(pk.y-p.y,pk.x-p.x);
pk.x-=Math.cos(a)*120*dt;pk.y-=Math.sin(a)*120*dt;
}
}
if(d<24){
collect(pk);
return false;
}
return true;
});
}
function collect(pk){
const p=G.player;
if(pk.t==='coin'){
p.gold+=pk.a;
Sfx.coin();
for(let i=0;i<6;i++)addPart(pk.x,pk.y,rand(-70,70),rand(-90,-10),rand(0.4,0.7),'#ffd97a',2,'glow');
G.dnums.push({x:pk.x,y:pk.y-8,txt:'+'+pk.a,t:0.8,col:'#ffd97a'});
updateHUD();
return;
}
const info=PICKUP_INFO[pk.t];
if(pk.t==='H'){
p.maxHp+=2;p.hp=Math.min(p.maxHp,p.hp+2);
Sfx.heartUp();
G.shimmer=2;
for(let i=0;i<26;i++){const a=i/26*TAU;addPart(p.x,p.y,Math.cos(a)*rand(40,120),Math.sin(a)*rand(40,120),rand(0.6,1),'#ffd97a',2.5,'glow');}
toast('Heart Container acquired — max HP +2','gold');
}else if(pk.t==='V'){
p.items.V++;Sfx.item();
for(let i=0;i<10;i++)addPart(pk.x,pk.y,rand(-60,60),rand(-100,-20),rand(0.4,0.8),'#7fe08a',2.5,'glow');
toast('Vital Stem acquired');
}else if(pk.t==='Z'){
p.items.Z++;Sfx.item();
for(let i=0;i<10;i++)addPart(pk.x,pk.y,rand(-60,60),rand(-100,-20),rand(0.4,0.8),'#9fd8ff',2.5,'glow');
toast('Binding Seal acquired');
}else if(pk.t==='Q'){
p.own.Q=true;p.shardReady=true;p.ammo=10;
Sfx.rare();
for(let i=0;i<20;i++)addPart(pk.x,pk.y,rand(-90,90),rand(-120,-20),rand(0.5,0.9),'#9fe0ff',2.5,'glow');
toast('The Aether Shard hums to life — press K to fire','blue');
G.shimmer=2;
}else{
/* accessory */
p.own[pk.t]=true;
if(pk.t==='W')p.equipped.W=true;
if(pk.t==='L')p.equipped.L=true;
if(pk.t==='G')p.equipped.G=true;
Sfx.rare();
for(let i=0;i<16;i++)addPart(pk.x,pk.y,rand(-80,80),rand(-110,-20),rand(0.5,0.9),'#ffffff',2,'glow');
toast(info.name+' acquired — equipped','gold');
G.shimmer=1.2;
}
if(pk.id)G.collected[pk.id]=1;
updateHUD();
saveGame();
}
/* ============================== world interaction ============================== */
function tileAt(px,py){
const tx=Math.floor(px/TS),ty=Math.floor(py/TS);
if(tx<0||ty<0||tx>=MW||ty>=MH)return 0;
return G.map[ty][tx];
}
function interactWorld(){
const p=G.player;
if(!pressed('KeyE'))return;
const tx=Math.floor(p.x/TS),ty=Math.floor(p.y/TS);
/* check 3x3 neighborhood for interactables */
for(let oy=-1;oy<=1;oy++)for(let ox=-1;ox<=1;ox++){
const t=tileAt(p.x+ox*TS,p.y+oy*TS);
if(t===6){
/* sanctuary stone */
if(G.lastStone!==G.zone){
G.lastStone=G.zone;
p.hp=p.maxHp;
Sfx.heartUp();
for(let i=0;i<24;i++){const a=i/24*TAU;addPart(p.x,p.y,Math.cos(a)*rand(30,90),Math.sin(a)*rand(30,90),rand(0.5,0.9),'#ffd97a',2.5,'glow');}
toast('The Sanctuary Stone drinks your weariness — vitality restored','gold');
}else{
p.hp=p.maxHp;
Sfx.item();
toast('You rest at the stone — vitality restored');
}
saveGame();
updateHUD();
return;
}
if(t===7){
openShop();
return;
}
if(t===8){
/* altar */
const w=G.zone===2?1:2;
if(p.weapon<w){
p.weapon=w;
Sfx.altar();
G.shimmer=2;
for(let i=0;i<26;i++){const a=i/26*TAU;addPart(p.x,p.y,Math.cos(a)*rand(40,140),Math.sin(a)*rand(40,140),rand(0.6,1.1),w===1?'#ff8c3a':'#ffd97a',3,'glow');}
toast('The altar ignites — '+WEAPONS[w].name+' is yours','gold');
saveGame();
updateHUD();
}else{
toast('The altar\'s flame already answers to your blade');
}
return;
}
}
}
function updateWorld(dt){
const p=G.player;
/* plates */
G.plates.forEach(pl=>{
const was=pl.pressed;
pl.pressed=tileAt(p.x,p.y)===3&&(Math.floor(p.x/TS)===pl.x&&Math.floor(p.y/TS)===pl.y);
if(pl.pressed&&!was){
Sfx.plate();
onPlate(pl);
}
});
/* z5 timed: both plates within 3s */
if(G.zone===4&&G.plates.length===2){
const a=G.plates[0],b=G.plates[1];
if(a.pressed&&b.pressed){
G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
toast('The twin plates align — the spire gate opens','gold');
}
}
/* traps */
if(tileAt(p.x,p.y)===11&&p.inv<=0){
if(p.own.G&&p.equipped.G){
p.inv=1.2;
toast('The Shadow Cloak absorbs the trap\'s bite');
}else{
damagePlayer(1);
}
}
/* exit */
if(tileAt(p.x,p.y)===5){
if(G.zone<5){
fadeTo(()=>{loadZone(G.zone+1);if(G.player){G.player.x=ZONES[G.zone].spawn[0];G.player.y=ZONES[G.zone].spawn[1];}saveGame();});
}else if(G.beacon&&G.beacon.active){
/* handled by beacon */
}
}
}
function onPlate(pl){
if(G.zone===2){
G.doors.forEach(d=>{if(!d.open){d.open=true;Sfx.door();
for(let i=0;i<10;i++)addPart(d.x*TS+16,d.y*TS+16,rand(-80,80),rand(-80,80),0.5,'#d9a441',3,'glow');}});
toast('A hidden door grinds open above the gallery','gold');
}
/* z5 handled in updateWorld */
}
function activateBeacon(){
const b=G.beacon;
if(b.active)return;
b.active=true;
G.flags.beacon=true;
Sfx.victory();
G.shimmer=4;
for(let i=0;i<80;i++){const a=rand(0,TAU);addPart(b.x,b.y,Math.cos(a)*rand(50,300),Math.sin(a)*rand(50,300),rand(0.8,1.6),'#ffd97a',3,'glow');}
setTimeout(()=>{
G.state='win';
document.getElementById('winTime').textContent=fmtTime(G.time);
document.getElementById('winGold').textContent=G.player.gold;
document.getElementById('winKills').textContent=G.kills;
show('winScreen');
try{localStorage.removeItem('warden_save');}catch(e){}
},2200);
}
/* ============================== main update ============================== */
function update(dt){
if(G.state!=='play')return;
G.time+=dt;
updatePlayer(dt);
updateEnemies(dt);
updateBolts(dt);
updatePickups(dt);
updateWorld(dt);
updateParts(dt);
G.flash=Math.max(0,G.flash-dt*1.6);
G.shimmer=Math.max(0,G.shimmer-dt);
/* camera */
cam.x=clamp(G.player.x-VW/2,0,MW*TS-VW);
cam.y=clamp(G.player.y-VH/2,0,MH*TS-VH);
/* fade */
if(G.fadeDir<0){
G.fade-=dt*2.2;
if(G.fade<=0){G.fade=0;G.fadeDir=0;if(G.fadeCb){const cb=G.fadeCb;G.fadeCb=null;cb();}}
}
}
/* ============================== rendering ============================== */
const amb=[];
let ambT=0;
function updateAmbient(dt){
ambT-=dt;
if(ambT<=0&&amb.length<130){
ambT=0.08;
const z=G.zone;
if(z===1){amb.push({x:cam.x+rand(0,VW),y:cam.y+rand(0,VH),vx:rand(-6,6),vy:rand(-3,3),life:rand(2,4),max:4,col:'rgba(217,180,120,0.25)',size:rand(1,2.2),kind:'dust'});}
if(z===3){amb.push({x:cam.x+rand(0,VW),y:cam.y+rand(100,VH),vx:rand(-4,4),vy:rand(-14,-6),life:rand(2,4),max:4,col:'rgba(140,180,220,0.10)',size:rand(14,30),kind:'mist'});}
if(z===5&&G.beacon&&G.beacon.active){amb.push({x:cam.x+rand(0,VW),y:cam.y+VH,vx:rand(-10,10),vy:rand(-60,-30),life:rand(1.5,3),max:3,col:'rgba(255,217,122,0.35)',size:rand(1.5,3),kind:'dust'});}
G.stonePos.forEach(s=>{
if(Math.random()<0.35)amb.push({x:s.x+rand(-30,30),y:s.y+rand(-30,30),vx:rand(-8,8),vy:rand(-20,-8),life:rand(1,2),max:2,col:'rgba(255,217,122,0.5)',size:rand(1,2),kind:'dust'});
});
}
for(let i=amb.length-1;i>=0;i--){
const a=amb[i];
a.life-=dt;a.x+=a.vx*dt;a.y+=a.vy*dt;
if(a.life<=0)amb.splice(i,1);
}
}
function render(){
cx.setTransform(1,0,0,1,0,0);
cx.clearRect(0,0,VW,VH);
const P=PALETTES[G.zone];
cx.save();
cx.translate(-Math.round(cam.x),-Math.round(cam.y));
drawTiles(P);
/* warm player light */
if(G.player){
const g=cx.createRadialGradient(G.player.x,G.player.y,10,G.player.x,G.player.y,170);
g.addColorStop(0,'rgba(255,196,120,0.16)');
g.addColorStop(1,'rgba(255,196,120,0)');
cx.fillStyle=g;
cx.beginPath();cx.arc(G.player.x,G.player.y,170,0,TAU);cx.fill();
}
/* ambient warm wash for bright zones */
if(!ZONES[G.zone].dark){
const g=cx.createRadialGradient(G.player.x,G.player.y,200,G.player.x,G.player.y,700);
g.addColorStop(0,'rgba(255,200,130,0.06)');
g.addColorStop(1,'rgba(20,14,8,0.28)');
cx.fillStyle=g;
cx.fillRect(cam.x,cam.y,VW,VH);
}
drawMechanics();
drawPickups();
drawBolts();
G.enemies.forEach(e=>{if(!e.dead)drawEnemy(e);});
drawPlayer();
drawParts();
drawDnums();
amb.forEach(a=>{
cx.globalAlpha=clamp(a.life/a.max,0,1);
cx.fillStyle=a.col;
cx.beginPath();cx.arc(a.x,a.y,a.size,0,TAU);cx.fill();
});
cx.globalAlpha=1;
cx.restore();
drawDarkness();
drawScreenFx();
}
function drawTiles(P){
const x0=Math.max(0,Math.floor(cam.x/TS)),x1=Math.min(MW-1,Math.ceil((cam.x+VW)/TS));
const y0=Math.max(0,Math.floor(cam.y/TS)),y1=Math.min(MH-1,Math.ceil((cam.y+VH)/TS));
for(let ty=y0;ty<=y1;ty++)for(let tx=x0;tx<=x1;tx++){
const t=G.map[ty][tx];
const x=tx*TS,y=ty*TS;
const h=hash2(tx,ty);
if(t===1){
cx.fillStyle=P.wall;
cx.fillRect(x,y,TS,TS);
cx.fillStyle=P.wallHi;
cx.fillRect(x,y,TS,5);
cx.fillStyle='rgba(0,0,0,0.35)';
cx.fillRect(x,y+TS-4,TS,4);
cx.fillStyle='rgba(255,255,255,0.06)';
cx.fillRect(x,y,3,TS);
if(h>0.8){cx.fillStyle='rgba(0,0,0,0.25)';cx.fillRect(x+4,y+12,TS-8,3);}
if(h<0.15){cx.fillStyle='rgba(255,255,255,0.05)';cx.fillRect(x+6,y+14,10,10);}
}else if(t===10){
cx.fillStyle='#3a3f47';
cx.fillRect(x,y,TS,TS);
cx.strokeStyle='#23262c';cx.lineWidth=2;
for(let i=0;i<=TS;i+=8){cx.beginPath();cx.moveTo(x+i,y);cx.lineTo(x+i,y+TS);cx.stroke();}
cx.strokeStyle='#4a505a';cx.lineWidth=1;
cx.strokeRect(x+1,y+1,TS-2,TS-2);
}else if(t===9){
cx.fillStyle='#1a2027';
cx.fillRect(x,y,TS,TS);
cx.strokeStyle='rgba(120,170,220,0.12)';cx.lineWidth=1;
const ph=(G.time*0.8+h*3)%1;
cx.beginPath();cx.arc(x+16,y+16,4+ph*10,0,TAU);cx.stroke();
}else if(t===11){
cx.fillStyle=P.floor;
cx.fillRect(x,y,TS,TS);
cx.fillStyle='#444a55';
for(let i=0;i<4;i++){
const sx=x+4+i*7;
cx.beginPath();cx.moveTo(sx,y+26);cx.lineTo(sx+3,y+8);cx.lineTo(sx+6,y+26);cx.fill();
}
cx.fillStyle='rgba(214,69,69,0.25)';
cx.fillRect(x,y+26,TS,2);
}else{
cx.fillStyle=h>0.5?P.floor:P.floor2;
cx.fillRect(x,y,TS,TS);
if(h>0.93){cx.fillStyle='rgba(255,255,255,0.03)';cx.fillRect(x+8,y+8,6,2);}
if(h<0.07){cx.fillStyle='rgba(0,0,0,0.15)';cx.fillRect(x+12,y+18,8,3);}
}
}
}
function drawMechanics(){
/* breakable walls */
G.breaks.forEach(b=>{
if(b.broken)return;
const x=b.x*TS,y=b.y*TS;
cx.fillStyle='#332a22';
cx.fillRect(x,y,TS,TS);
cx.fillStyle='#2a221b';
cx.fillRect(x+2,y+2,TS-4,TS-4);
cx.strokeStyle='rgba(0,0,0,0.5)';
cx.lineWidth=1;
const cracks=b.hp<=b.maxHp-3?3:(b.hp<=b.maxHp-1?2:1);
for(let i=0;i<cracks;i++){
cx.beginPath();
cx.moveTo(x+6+i*8,y+4);
cx.lineTo(x+10+i*7,y+14);
cx.lineTo(x+5+i*9,y+22);
cx.stroke();
}
if(G.player.own.L&&G.player.equipped.L){
cx.strokeStyle='rgba(127,212,255,0.6)';
cx.strokeRect(x+1,y+1,TS-2,TS-2);
}
});
/* plates */
G.plates.forEach(pl=>{
const x=pl.x*TS,y=pl.y*TS;
cx.fillStyle=pl.pressed?'#4a3b26':'#5a4830';
cx.fillRect(x+3,y+3,TS-6,TS-6);
cx.strokeStyle='#8a6b3a';cx.lineWidth=2;
cx.strokeRect(x+5,y+5,TS-10,TS-10);
cx.fillStyle=pl.pressed?'rgba(217,164,65,0.2)':'rgba(0,0,0,0.2)';
cx.fillRect(x+8,y+8,TS-16,TS-16);
});
/* doors */
G.doors.forEach(d=>{
const x=d.x*TS,y=d.y*TS;
if(!d.open){
cx.fillStyle='#5a4830';
cx.fillRect(x,y,TS,TS);
cx.fillStyle='#3d332a';
cx.fillRect(x+4,y+4,TS-8,TS-8);
cx.fillStyle='rgba(217,164,65,0.7)';
cx.beginPath();cx.arc(x+16,y+16,5+Math.sin(G.time*3)*1.5,0,TAU);cx.fill();
}else{
cx.fillStyle='rgba(217,164,65,0.12)';
cx.fillRect(x,y,TS,TS);
}
});
/* switches */
G.switches.forEach(s=>{
const pulse=Math.sin(G.time*4)*0.5+0.5;
cx.fillStyle='#31404f';
cx.fillRect(s.x-10,s.y+6,20,10);
cx.fillStyle=s.active?'#bfeaff':'rgba(127,212,255,'+(0.35+pulse*0.4)+')';
cx.beginPath();
cx.moveTo(s.x,s.y-14);cx.lineTo(s.x+8,s.y+2);cx.lineTo(s.x,s.y+10);cx.lineTo(s.x-8,s.y+2);
cx.closePath();cx.fill();
if(s.active){
cx.fillStyle='rgba(127,212,255,0.25)';
cx.beginPath();cx.arc(s.x,s.y,26+pulse*6,0,TAU);cx.fill();
}
if(G.player.own.L&&G.player.equipped.L&&!s.active){
const p=G.player;
cx.strokeStyle='rgba(127,212,255,0.15)';
cx.setLineDash([6,8]);cx.lineWidth=2;
cx.beginPath();cx.moveTo(p.x,p.y);cx.lineTo(s.x,s.y);cx.stroke();
cx.setLineDash([]);
}
});
/* sanctuary stones */
G.stonePos.forEach(s=>{
const pulse=Math.sin(G.time*2.5)*0.5+0.5;
cx.fillStyle='#4a4438';
cx.fillRect(s.x-16,s.y+4,32,14);
cx.fillStyle='#5a5448';
cx.fillRect(s.x-12,s.y-6,24,12);
cx.fillStyle='rgba(255,217,122,'+(0.5+pulse*0.5)+')';
cx.beginPath();cx.arc(s.x,s.y-12,7+pulse*2,0,TAU);cx.fill();
cx.fillStyle='rgba(255,217,122,0.15)';
cx.beginPath();cx.arc(s.x,s.y-12,26+pulse*8,0,TAU);cx.fill();
});
/* shop */
for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
if(G.map[ty][tx]===7){
const x=tx*TS,y=ty*TS;
cx.fillStyle='#4a3b26';
cx.fillRect(x+2,y+18,TS-4,10);
cx.fillStyle='#3d332a';
cx.fillRect(x+4,y+14,TS-8,6);
cx.fillStyle='#2a2f3a';
cx.beginPath();cx.arc(x+16,y+8,9,0,TAU);cx.fill();
cx.fillStyle='#d9a441';
cx.beginPath();cx.arc(x+16,y+8,3,0,TAU);cx.fill();
cx.fillStyle='rgba(217,164,65,0.15)';
cx.beginPath();cx.arc(x+16,y+10,24+Math.sin(G.time*2)*4,0,TAU);cx.fill();
}
}
/* altars */
for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
if(G.map[ty][tx]===8){
const x=tx*TS,y=ty*TS;
const claimed=G.zone===2?G.player.weapon>=1:G.player.weapon>=2;
cx.fillStyle='#4a4438';
cx.fillRect(x+4,y+16,TS-8,10);
cx.fillStyle='#5a5448';
cx.fillRect(x+8,y+10,TS-16,8);
const f=claimed?1:0.35;
const col=claimed?(G.zone===2?'#ff8c3a':'#ffd97a'):'rgba(217,164,65,0.4)';
const fl=Math.sin(G.time*6+tx)*2;
cx.fillStyle=col;
cx.globalAlpha=f;
cx.beginPath();
cx.moveTo(x+16,y+2-fl);
cx.quadraticCurveTo(x+22,y+10,x+16,y+14);
cx.quadraticCurveTo(x+10,y+10,x+16,y+2-fl);
cx.fill();
cx.globalAlpha=1;
if(claimed){
cx.fillStyle='rgba(255,200,100,0.12)';
cx.beginPath();cx.arc(x+16,y+8,26+fl,0,TAU);cx.fill();
}
}
}
/* exits */
for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
if(G.map[ty][tx]===5){
const x=tx*TS,y=ty*TS;
cx.fillStyle='rgba(217,164,65,0.25)';
cx.fillRect(x+4,y,TS-8,TS);
const g=cx.createLinearGradient(0,y,0,y+TS);
g.addColorStop(0,'rgba(244,201,107,0.7)');
g.addColorStop(1,'rgba(244,201,107,0)');
cx.fillStyle=g;
cx.fillRect(x+8,y-6,TS-16,TS+6);
cx.fillStyle='#d9a441';
cx.fillRect(x+2,y,4,TS);cx.fillRect(x+TS-6,y,4,TS);
}
}
/* beacon */
if(G.beacon){
const b=G.beacon;
cx.fillStyle='#4a4438';
cx.fillRect(b.x-24,b.y+18,48,22);
cx.fillStyle='#5a5448';
cx.fillRect(b.x-16,b.y-10,32,30);
if(b.active){
const g=cx.createLinearGradient(0,b.y,0,b.y-400);
g.addColorStop(0,'rgba(255,217,122,0.8)');
g.addColorStop(1,'rgba(255,217,122,0)');
cx.fillStyle=g;
cx.fillRect(b.x-14,b.y-400,28,400);
cx.fillStyle='rgba(255,240,200,0.9)';
cx.beginPath();cx.arc(b.x,b.y,16+Math.sin(G.time*5)*3,0,TAU);cx.fill();
cx.fillStyle='rgba(255,217,122,0.2)';
cx.beginPath();cx.arc(b.x,b.y,70+Math.sin(G.time*3)*10,0,TAU);cx.fill();
}else{
cx.fillStyle='rgba(127,212,255,'+(0.3+Math.sin(G.time*3)*0.2)+')';
cx.beginPath();cx.arc(b.x,b.y,10,0,TAU);cx.fill();
}
}
}
function drawPickups(){
G.pickups.forEach(pk=>{
const bob=Math.sin(G.time*3+pk.x*0.1)*3;
const x=pk.x,y=pk.y+bob;
if(pk.t==='coin'){
cx.fillStyle='#d9a441';
cx.beginPath();cx.arc(x,y,5,0,TAU);cx.fill();
cx.fillStyle='#f4c96b';
cx.beginPath();cx.arc(x-1.5,y-1.5,2,0,TAU);cx.fill();
}else if(pk.t==='H'){
cx.fillStyle='#d64545';
drawHeart(x,y,9);
cx.fillStyle='rgba(255,255,255,0.3)';
drawHeart(x-2,y-3,3);
}else if(pk.t==='V'){
cx.strokeStyle='#7fe08a';cx.lineWidth=3;
cx.beginPath();cx.moveTo(x,y+8);cx.quadraticCurveTo(x+2,y,x,y-8);cx.stroke();
cx.fillStyle='#7fe08a';
cx.beginPath();cx.ellipse(x+5,y-4,5,3,0.6,0,TAU);cx.fill();
cx.beginPath();cx.ellipse(x-4,y+1,5,3,-0.6,0,TAU);cx.fill();
}else if(pk.t==='W'){
cx.strokeStyle='#cfd6e4';cx.lineWidth=3;
cx.beginPath();cx.arc(x,y,7,0,TAU);cx.stroke();
cx.fillStyle='#ffffff';
cx.beginPath();cx.arc(x,y-7,2,0,TAU);cx.fill();
}else if(pk.t==='Q'){
cx.fillStyle='#9fe0ff';
cx.beginPath();
cx.moveTo(x,y-10);cx.lineTo(x+7,y);cx.lineTo(x,y+10);cx.lineTo(x-7,y);
cx.closePath();cx.fill();
cx.fillStyle='rgba(255,255,255,0.6)';
cx.beginPath();cx.moveTo(x,y-10);cx.lineTo(x+3,y);cx.lineTo(x,y+2);cx.closePath();cx.fill();
cx.fillStyle='rgba(159,224,255,0.2)';
cx.beginPath();cx.arc(x,y,14+Math.sin(G.time*4)*2,0,TAU);cx.fill();
}else if(pk.t==='L'){
cx.fillStyle='#7fd4ff';
cx.fillRect(x-5,y-8,10,14);
cx.fillStyle='#31404f';
cx.fillRect(x-6,y-10,12,3);
cx.fillStyle='rgba(127,212,255,0.25)';
cx.beginPath();cx.arc(x,y,16,0,TAU);cx.fill();
}else if(pk.t==='G'){
cx.fillStyle='#2a2f3a';
cx.beginPath();
cx.moveTo(x-8,y-8);cx.lineTo(x+8,y-8);cx.lineTo(x+11,y+9);cx.lineTo(x-11,y+9);
cx.closePath();cx.fill();
cx.strokeStyle='#d9a441';cx.lineWidth=1.5;
cx.beginPath();cx.moveTo(x-11,y+9);cx.lineTo(x+11,y+9);cx.stroke();
}else if(pk.t==='Z'){
cx.strokeStyle='#9fd8ff';cx.lineWidth=2;
cx.beginPath();cx.arc(x,y,8,0,TAU);cx.stroke();
cx.beginPath();cx.moveTo(x,y-8);cx.lineTo(x,y+8);cx.moveTo(x-8,y);cx.lineTo(x+8,y);cx.stroke();
cx.fillStyle='rgba(159,216,255,0.3)';
cx.beginPath();cx.arc(x,y,5,0,TAU);cx.fill();
}
});
}
function drawHeart(x,y,s){
cx.beginPath();
cx.moveTo(x,y+s*0.35);
cx.bezierCurveTo(x-s,y-s*0.45,x-s*0.5,y-s*1.1,x,y-s*0.4);
cx.bezierCurveTo(x+s*0.5,y-s*1.1,x+s,y-s*0.45,x,y+s*0.35);
cx.fill();
}
function drawBolts(){
G.bolts.forEach(b=>{
const col=b.hostile?'#d64545':'#9fe0ff';
cx.fillStyle=col;
cx.beginPath();cx.arc(b.x,b.y,4,0,TAU);cx.fill();
cx.fillStyle=b.hostile?'rgba(214,69,69,0.35)':'rgba(159,224,255,0.35)';
cx.beginPath();cx.arc(b.x-b.vx*0.02,b.y-b.vy*0.02,6,0,TAU);cx.fill();
});
}
function drawEnemy(e){
const x=e.x,y=e.y;
const flash=e.flash>0;
const frozen=e.freeze>0;
cx.fillStyle='rgba(0,0,0,0.3)';
cx.beginPath();cx.ellipse(x,y+e.r*0.8,e.r*0.9,e.r*0.35,0,0,TAU);cx.fill();
const bob=Math.sin(e.anim)*2;
if(e.type==='chaser'){
cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#5a4830');
cx.beginPath();
cx.moveTo(x-e.r,y+e.r*0.7);
cx.quadraticCurveTo(x-e.r*1.1,y-e.r*0.8,x,y-e.r);
cx.quadraticCurveTo(x+e.r*1.1,y-e.r*0.8,x+e.r,y+e.r*0.7);
cx.closePath();cx.fill();
cx.fillStyle=flash?'#fff':'#4a3b26';
cx.fillRect(x-e.r*0.7,y+e.r*0.4,e.r*1.4,e.r*0.4);
cx.fillStyle=frozen?'#9fd8ff':'#d64545';
cx.beginPath();cx.arc(x-4,y-e.r*0.3,2.5,0,TAU);cx.fill();
cx.beginPath();cx.arc(x+4,y-e.r*0.3,2.5,0,TAU);cx.fill();
}else if(e.type==='shooter'){
cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#4a505a');
cx.beginPath();cx.arc(x,y+bob,e.r,0,TAU);cx.fill();
cx.strokeStyle=flash?'#fff':'#31404f';cx.lineWidth=3;
const ra=G.time*2+e.anim;
cx.beginPath();cx.moveTo(x,y+bob);cx.lineTo(x+Math.cos(ra)*e.r*1.3,y+bob+Math.sin(ra)*e.r*1.3);cx.stroke();
cx.fillStyle=frozen?'#9fd8ff':'#7fd4ff';
cx.beginPath();cx.arc(x,y+bob,4,0,TAU);cx.fill();
}else if(e.type==='brute'||e.type==='guardian'){
const crown=e.type==='guardian';
cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':(crown?'#5a4a30':'#4a3b26'));
cx.fillRect(x-e.r,y-e.r+bob,e.r*2,e.r*1.8);
cx.fillStyle=flash?'#fff':(frozen?'#4a6a8a':'#3d332a');
cx.fillRect(x-e.r*1.25,y-e.r*0.6+bob,e.r*0.4,e.r*0.7);
cx.fillRect(x+e.r*0.85,y-e.r*0.6+bob,e.r*0.4,e.r*0.7);
if(crown){
cx.fillStyle='#d9a441';
for(let i=-1;i<=1;i++){
cx.beginPath();
cx.moveTo(x+i*8-3,y-e.r+bob);
cx.lineTo(x+i*8,y-e.r-8+bob);
cx.lineTo(x+i*8+3,y-e.r+bob);
cx.fill();
}
}
cx.fillStyle=frozen?'#9fd8ff':'#d64545';
cx.beginPath();cx.arc(x,y-e.r*0.2+bob,4,0,TAU);cx.fill();
if(e.type==='guardian'){
cx.fillStyle='rgba(214,69,69,0.2)';
cx.beginPath();cx.arc(x,y, e.r+8+Math.sin(G.time*3)*3,0,TAU);cx.fill();
}
}else if(e.type==='boss'){
cx.save();
cx.translate(x,y);
cx.rotate(Math.sin(G.time)*0.05);
cx.fillStyle=flash?'#fff':(frozen?'#5a7a9a':'#3d2e28');
cx.beginPath();cx.arc(0,bob,e.r,0,TAU);cx.fill();
cx.strokeStyle=flash?'#fff':'#2a1f1a';cx.lineWidth=4;
for(let i=0;i<6;i++){
const a=i/6*TAU+G.time*0.5;
cx.beginPath();
cx.moveTo(Math.cos(a)*e.r,bob+Math.sin(a)*e.r);
cx.lineTo(Math.cos(a)*(e.r+10),bob+Math.sin(a)*(e.r+10));
cx.stroke();
}
cx.fillStyle=flash?'#fff':'#4a3b26';
cx.beginPath();cx.arc(0,bob,e.r*0.6,0,TAU);cx.fill();
cx.fillStyle=frozen?'#9fd8ff':'#d64545';
const pulse=0.6+Math.sin(G.time*4)*0.4;
cx.globalAlpha=pulse;
cx.beginPath();cx.arc(0,bob,e.r*0.3,0,TAU);cx.fill();
cx.globalAlpha=1;
cx.restore();
/* boss hp bar */
const w=70;
cx.fillStyle='rgba(0,0,0,0.5)';
cx.fillRect(x-w/2,y-e.r-24,w,7);
cx.fillStyle='#d64545';
cx.fillRect(x-w/2+1,y-e.r-23,(w-2)*clamp(e.hp/e.maxHp,0,1),5);
}
if(frozen){
cx.strokeStyle='rgba(159,216,255,0.7)';cx.lineWidth=2;
cx.beginPath();cx.arc(x,y,e.r+3,0,TAU);cx.stroke();
cx.fillStyle='rgba(159,216,255,0.2)';
cx.beginPath();cx.arc(x,y,e.r+3,0,TAU);cx.fill();
}
if(e.burn>0){
cx.fillStyle='rgba(255,140,58,0.3)';
cx.beginPath();cx.arc(x,y-e.r*0.4,e.r*0.6,0,TAU);cx.fill();
}
}
function drawPlayer(){
const p=G.player;
if(!p)return;
const x=p.x,y=p.y;
const wcol=['#8a93a3','#ff8c3a','#ffd97a'][p.weapon];
cx.fillStyle='rgba(0,0,0,0.35)';
cx.beginPath();cx.ellipse(x,y+12,11,4.5,0,0,TAU);cx.fill();
const bob=Math.sin(p.anim)*1.5;
/* lantern glow if equipped */
if(p.own.L&&p.equipped.L){
cx.fillStyle='rgba(127,212,255,0.12)';
cx.beginPath();cx.arc(x,y,34,0,TAU);cx.fill();
}
/* cloak */
cx.save();
cx.translate(x,y+bob);
cx.scale(p.face,1);
const hem=Math.sin(p.anim*1.3)*2;
cx.fillStyle=p.hurtT>0?'#7a3a3a':'#2a2f3a';
cx.beginPath();
cx.moveTo(-9,-4);
cx.quadraticCurveTo(-11,10,-8+hem,12);
cx.lineTo(8+hem,12);
cx.quadraticCurveTo(11,10,9,-4);
cx.closePath();
cx.fill();
if(p.own.G&&p.equipped.G){
cx.strokeStyle='#11141a';cx.lineWidth=3;
cx.beginPath();cx.moveTo(-9,10);cx.lineTo(9,10);cx.stroke();
}else{
cx.strokeStyle='#d9a441';cx.lineWidth=1.5;
cx.beginPath();cx.moveTo(-8+hem,12);cx.lineTo(8+hem,12);cx.stroke();
}
/* hood */
cx.fillStyle=p.hurtT>0?'#8a4a4a':'#333947';
cx.beginPath();cx.arc(0,-8,7.5,0,TAU);cx.fill();
cx.fillStyle='#14161c';
cx.beginPath();cx.arc(2,-8,4.5,0,TAU);cx.fill();
/* shoulder clasp */
cx.fillStyle='#d9a441';
cx.beginPath();cx.arc(-6,-3,2,0,TAU);cx.fill();
/* wardens band */
if(p.own.W&&p.equipped.W){
cx.strokeStyle='#cfd6e4';cx.lineWidth=2;
cx.beginPath();cx.arc(7,2,3.5,0,TAU);cx.stroke();
cx.fillStyle='rgba(207,214,228,0.5)';
cx.beginPath();cx.arc(7,2,5.5,0,TAU);cx.stroke();
}
/* off-hand crystal */
if(p.shardReady){
const pulse=0.6+Math.sin(G.time*5)*0.4;
cx.fillStyle='rgba(159,224,255,'+pulse+')';
cx.beginPath();
cx.moveTo(-8,-2);cx.lineTo(-4,1);cx.lineTo(-8,4);cx.lineTo(-12,1);
cx.closePath();cx.fill();
cx.fillStyle='rgba(159,224,255,0.15)';
cx.beginPath();cx.arc(-8,1,8+pulse*2,0,TAU);cx.fill();
}
cx.restore();
/* weapon glow aura */
if(p.weapon>=1){
cx.fillStyle=p.weapon===1?'rgba(255,140,58,0.10)':'rgba(255,217,122,0.12)';
cx.beginPath();cx.arc(x,y,20+p.weapon*3,0,TAU);cx.fill();
}
/* blade */
const aim=p.windup>0?p.aim+0.5:p.aim;
const W=WEAPONS[p.weapon];
const len=W.range*(p.weapon===2?1.0:0.85);
cx.save();
cx.translate(x,y-4+bob);
cx.rotate(aim);
cx.strokeStyle=wcol;
cx.lineWidth=p.weapon===2?5:3.5;
cx.lineCap='round';
cx.beginPath();cx.moveTo(6,0);cx.lineTo(6+len,0);cx.stroke();
if(p.weapon>=1){
cx.strokeStyle=p.weapon===1?'rgba(255,140,58,0.4)':'rgba(255,217,122,0.45)';
cx.lineWidth=9;
cx.beginPath();cx.moveTo(6,0);cx.lineTo(6+len,0);cx.stroke();
}
cx.restore();
/* swing arc */
if(p.atkT>0){
const prog=1-p.atkT/0.22;
cx.save();
cx.translate(x,y-4);
cx.globalAlpha=0.35*(1-prog);
cx.fillStyle=p.weapon===1?'#ff8c3a':(p.weapon===2?'#ffd97a':'#cfd6e4');
cx.beginPath();
cx.moveTo(0,0);
cx.arc(0,0,W.range+8,p.aim-W.arc/2,p.aim-W.arc/2+W.arc*prog);
cx.closePath();
cx.fill();
cx.globalAlpha=1;
cx.restore();
}
/* windup shimmer */
if(p.windup>0){
cx.strokeStyle='rgba(255,217,122,'+(0.5*(1-p.windup/WEAPONS[2].windup))+')';
cx.lineWidth=2;
cx.beginPath();cx.arc(x,y,16,0,TAU);cx.stroke();
}
/* rim light in dark zones */
if(ZONES[G.zone].dark){
cx.strokeStyle='rgba(255,210,140,0.55)';
cx.lineWidth=1.5;
cx.beginPath();cx.arc(x,y-2,13,0,TAU);cx.stroke();
}
/* hurt flash */
if(p.hurtT>0){
cx.strokeStyle='rgba(214,69,69,'+(p.hurtT*1.6)+')';
cx.lineWidth=3;
cx.beginPath();cx.arc(x,y,15,0,TAU);cx.stroke();
}
/* interact prompt */
const near=tileAt(p.x,p.y);
let prompt=null;
for(let oy=-1;oy<=1&&!prompt;oy++)for(let ox=-1;ox<=1;ox++){
const t=tileAt(p.x+ox*TS,p.y+oy*TS);
if(t===6){prompt='E · Rest at the Sanctuary Stone';break;}
if(t===7){prompt='E · Visit the Merchant';break;}
if(t===8){
prompt=G.zone===2?(G.player.weapon<1?'E · Claim the Ignited Edge':'The altar is spent'):'E · Claim the Solar Falchion';
if(G.player.weapon>=(G.zone===2?1:2))prompt='The altar is spent';
break;
}
}
if(prompt){
cx.font='13px Cinzel, serif';
const w=cx.measureText(prompt).width;
cx.fillStyle='rgba(10,13,20,0.75)';
cx.fillRect(x-w/2-8,y-42,w+16,20);
cx.strokeStyle='rgba(217,164,65,0.5)';cx.lineWidth=1;
cx.strokeRect(x-w/2-8,y-42,w+16,20);
cx.fillStyle='#f4c96b';
cx.textAlign='center';
cx.fillText(prompt,x,y-28);
cx.textAlign='left';
}
}
function drawParts(){
G.parts.forEach(p=>{
const a=clamp(p.life/p.maxLife,0,1);
cx.globalAlpha=a;
if(p.kind==='glow'){
cx.fillStyle=p.col;
cx.beginPath();cx.arc(p.x,p.y,p.size*(0.5+a*0.5),0,TAU);cx.fill();
}else{
cx.strokeStyle=p.col;
cx.lineWidth=p.size*0.6;
cx.beginPath();cx.moveTo(p.x,p.y);cx.lineTo(p.x-p.vx*0.03,p.y-p.vy*0.03);cx.stroke();
}
});
cx.globalAlpha=1;
}
function drawDnums(){
cx.font='bold 15px Cinzel, serif';
cx.textAlign='center';
G.dnums.forEach(d=>{
cx.globalAlpha=clamp(d.t/0.7,0,1);
cx.fillStyle='#000';
cx.fillText(d.txt,d.x+1,d.y+1);
cx.fillStyle=d.col;
cx.fillText(d.txt,d.x,d.y);
});
cx.globalAlpha=1;
cx.textAlign='left';
}
function drawDarkness(){
const z=ZONES[G.zone];
if(!z.dark)return;
const p=G.player;
const r=(p.own.L&&p.equipped.L)?260:180;
cx.save();
cx.setTransform(1,0,0,1,0,0);
cx.fillStyle='rgba(4,6,10,0.84)';
cx.fillRect(0,0,VW,VH);
cx.globalCompositeOperation='destination-out';
const hole=(wx,wy,rad,a)=>{
if(!isFinite(wx)||!isFinite(wy)||!isFinite(rad)||rad<1)return;
const x=wx-cam.x,y=wy-cam.y;
const g=cx.createRadialGradient(x,y,rad*0.2,x,y,rad);
g.addColorStop(0,'rgba(0,0,0,'+a+')');
g.addColorStop(1,'rgba(0,0,0,0)');
cx.fillStyle=g;
cx.beginPath();cx.arc(x,y,rad,0,TAU);cx.fill();
};
hole(p.x,p.y,r,1);
G.stonePos.forEach(s=>hole(s.x,s.y,120,0.9));
G.switches.forEach(s=>hole(s.x,s.y,90,0.8));
if(G.beacon)hole(G.beacon.x,G.beacon.y,G.beacon.active?300:110,1);
for(let ty=0;ty<MH;ty++)for(let tx=0;tx<MW;tx++){
if(G.map[ty][tx]===7)hole(tx*TS+16,ty*TS+16,110,0.9);
if(G.map[ty][tx]===8)hole(tx*TS+16,ty*TS+16,100,0.85);
}
G.enemies.forEach(e=>{if(!e.dead)hole(e.x,e.y,50,0.5);});
cx.restore();
}
function drawScreenFx(){
/* red hurt flash */
if(G.flash>0){
cx.fillStyle='rgba(214,69,69,'+(G.flash*0.35)+')';
cx.fillRect(0,0,VW,VH);
}
/* gold shimmer edges */
if(G.shimmer>0){
const a=clamp(G.shimmer/2,0,1)*0.5;
const g=cx.createRadialGradient(VW/2,VH/2,VH*0.42,VW/2,VH/2,VH*0.75);
g.addColorStop(0,'rgba(255,217,122,0)');
g.addColorStop(1,'rgba(255,217,122,'+a+')');
cx.fillStyle=g;
cx.fillRect(0,0,VW,VH);
}
/* low hp pulse */
const p=G.player;
if(p&&p.hp<=2&&G.state==='play'){
const a=0.12+Math.sin(G.time*4)*0.08;
const g=cx.createRadialGradient(VW/2,VH/2,VH*0.4,VW/2,VH/2,VH*0.8);
g.addColorStop(0,'rgba(120,20,20,0)');
g.addColorStop(1,'rgba(120,20,20,'+Math.max(0,a)+')');
cx.fillStyle=g;
cx.fillRect(0,0,VW,VH);
}
/* fade */
if(G.fade>0){
cx.fillStyle='rgba(0,0,0,'+G.fade+')';
cx.fillRect(0,0,VW,VH);
}
}
/* ============================== HUD ============================== */
const HEART_SVG='<svg viewBox="0 0 24 22"><path d="M12 20 C4 14 1 9 3.5 5.5 C5.5 2.5 10 3 12 6.5 C14 3 18.5 2.5 20.5 5.5 C23 9 20 14 12 20 Z"/></svg>';
function updateHUD(){
const p=G.player;
if(!p)return;
const cont=Math.ceil(p.maxHp/2);
const heartsEl=document.getElementById('hearts');
if(heartsEl.children.length!==cont){
heartsEl.innerHTML='';
for(let i=0;i<cont;i++){
const d=document.createElement('div');
d.className='hc';
d.innerHTML='<div class="base">'+HEART_SVG+'</div><div class="fill">'+HEART_SVG+'</div>';
heartsEl.appendChild(d);
}
}
for(let i=0;i<cont;i++){
const el=heartsEl.children[i];
const val=clamp(p.hp-i*2,0,2)/2;
el.querySelector('.fill').style.clipPath='inset(0 0 '+((1-val)*100)+'% 0)';
el.classList.toggle('empty',val<=0);
}
const W=WEAPONS[p.weapon];
document.getElementById('weaponName').innerHTML=W.name+(p.weapon===0?' <span class="lvl">· unkindled</span>':'');
document.getElementById('goldVal').textContent=p.gold;
const ammoStat=document.getElementById('ammoStat');
if(p.shardReady){ammoStat.style.display='flex';document.getElementById('ammoVal').textContent=p.ammo;}
else ammoStat.style.display='none';
}
/* ============================== inventory ============================== */
function invGlyph(t){
return {V:'🌿',Z:'❄',W:'💍',L:'🏮',G:'🧥',Q:'✦',H:'❤'}[t]||'·';
}
function openInv(){
const p=G.player;
const body=document.getElementById('invBody');
let html='';
const acc=(t,owned)=>{
const eq=p.equipped[t];
html+='<div class="irow"><div class="glyph">'+invGlyph(t)+'</div><div><div class="iname">'+PICKUP_INFO[t].name+'</div><div class="idesc">'+PICKUP_INFO[t].desc+'</div></div>'+
(owned?'<button class="ibtn '+(eq?'on':'')+'" data-act="toggle" data-item="'+t+'">'+(eq?'Worn':'Wear')+'</button>':'<span class="idesc" style="margin-left:auto">Not found</span>')+'</div>';
};
html+='<div class="irow"><div class="glyph">⚔</div><div><div class="iname">'+WEAPONS[p.weapon].name+'</div><div class="idesc">Your blade. Damage '+WEAPONS[p.weapon].dmg+' · speed '+(p.weapon===0?'slow':p.weapon===1?'swift':'heavy')+'</div></div></div>';
if(p.shardReady)html+='<div class="irow"><div class="glyph" style="color:var(--aether)">✦</div><div><div class="iname">Aether Shard</div><div class="idesc">Fire bolts with K. Charges remaining: '+p.ammo+' (restock at the merchant, 10g)</div></div></div>';
html+='<div class="irow"><div class="glyph">🌿</div><div><div class="iname">Vital Stem</div><div class="idesc">Restores 2 full containers (4 HP). Press 1 to use.</div></div><span class="count">×'+p.items.V+'</span></div>';
html+='<div class="irow"><div class="glyph" style="color:var(--aether)">❄</div><div><div class="iname">Binding Seal</div><div class="idesc">Freezes the nearest construct for ~3s. Press 2 to use.</div></div><span class="count">×'+p.items.Z+'</span></div>';
acc('W',p.own.W);
acc('L',p.own.L);
acc('G',p.own.G);
body.innerHTML=html;
body.querySelectorAll('[data-act="toggle"]').forEach(b=>{
b.addEventListener('click',()=>{
const t=b.dataset.item;
p.equipped[t]=!p.equipped[t];
Sfx.item();
toast(p.equipped[t]?PICKUP_INFO[t].name+' worn':PICKUP_INFO[t].name+' stowed');
openInv();
});
});
show('invPanel');
}
function closeInv(){hide('invPanel');}
/* ============================== shop ============================== */
function shopItems(){
const p=G.player;
const list=[
{id:'stem',name:'Vital Stem',desc:'Restores 2 full containers (4 HP).',price:50,stock:G.shop.stems,glyph:'🌿'},
{id:'seal',name:'Binding Seal',desc:'Freezes a construct for ~3 seconds.',price:75,stock:G.shop.seals,glyph:'❄'},
{id:'heart',name:'Heart Vessel',desc:'A 5th heart container — +2 max HP. The priciest wares in the archive.',price:300,stock:G.shop.heart?1:0,glyph:'❤',
locked:p.maxHp>=10?'You already carry five vessels.':'',one:true},
];
if(p.shardReady)list.push({id:'ammo',name:'Aether Charges',desc:'Restock the Aether Shard (+5 charges).',price:10,stock:99,glyph:'✦'});
return list;
}
function openShop(){
const body=document.getElementById('shopBody');
let html='';
shopItems().forEach(it=>{
const can=p=>p.gold>=it.price&&it.stock>0;
html+='<div class="irow shoprow"><div class="glyph" '+(it.id==='ammo'?'style="color:var(--aether)"':'')+'>'+it.glyph+'</div>'+
'<div><div class="iname">'+it.name+'</div><div class="idesc">'+it.desc+(it.locked?'<br><i style="color:#e07a7a">'+it.locked+'</i>':'')+'</div></div>'+
'<span class="price">'+it.price+' ◉</span>'+
(it.stock<99?'<span class="stock">stock '+it.stock+'</span>':'')+
'<button class="ibtn" data-buy="'+it.id+'" '+(can(G.player)?'':'disabled')+'>Buy</button></div>';
});
body.innerHTML=html;
document.getElementById('shopGold').textContent=G.player.gold;
body.querySelectorAll('[data-buy]').forEach(b=>{
b.addEventListener('click',()=>buyItem(b.dataset.buy));
});
show('shopPanel');
Sfx.droneOn();
}
function closeShop(){hide('shopPanel');Sfx.droneOff();}
function buyItem(id){
const p=G.player;
const items=shopItems().find(i=>i.id===id);
if(!items||p.gold<items.price||items.stock<=0)return;
if(items.one&&p.maxHp>=10)return;
p.gold-=items.price;
Sfx.buy();
if(id==='stem'){p.items.V++;G.shop.stems--;}
else if(id==='seal'){p.items.Z++;G.shop.seals--;}
else if(id==='heart'){
p.maxHp+=2;p.hp=Math.min(p.maxHp,p.hp+2);
G.shop.heart=false;
Sfx.heartUp();
G.shimmer=2;
toast('Heart Vessel acquired — max HP +2','gold');
}
else if(id==='ammo'){p.ammo+=5;}
updateHUD();
saveGame();
openShop();
}
/* ============================== UI wiring ============================== */
function toTitle(){
G.state='title';
show('title');
hide('hud');
if(hasSave())show('btnContinue');else hide('btnContinue');
}
function beginPlay(cont){
Sfx.ensure();
if(cont){
if(!loadSave()){toast('The save is corrupted — a new vigil begins','red');startNew();}
}else{
try{localStorage.removeItem('warden_save');}catch(e){}
startNew();
}
hide('title');hide('pause');hide('goScreen');hide('winScreen');
show('hud');
G.state='play';
G.fade=1;G.fadeDir=-1;
}
document.getElementById('btnNew').addEventListener('click',()=>beginPlay(false));
document.getElementById('btnContinue').addEventListener('click',()=>beginPlay(true));
document.getElementById('btnResume').addEventListener('click',()=>{hide('pause');G.state='play';});
document.getElementById('btnRestart').addEventListener('click',()=>{try{localStorage.removeItem('warden_save');}catch(e){} toTitle();});
document.getElementById('btnRespawn').addEventListener('click',()=>{
const p=G.player;
p.hp=p.maxHp;
hide('goScreen');
loadZone(G.lastStone);
p.x=ZONES[G.lastStone].spawn[0];p.y=ZONES[G.lastStone].spawn[1];
G.fade=1;G.fadeDir=-1;
G.state='play';
toast('You rise at the last Sanctuary Stone');
});
document.getElementById('btnAgain').addEventListener('click',()=>toTitle());
document.getElementById('invClose').addEventListener('click',closeInv);
document.getElementById('shopClose').addEventListener('click',closeShop);
document.getElementById('muteBtn').addEventListener('click',()=>{
Sfx.muted=!Sfx.muted;
document.getElementById('muteBtn').textContent='Sound: '+(Sfx.muted?'Off':'On');
});
/* ============================== main loop ============================== */
let lastT=performance.now();
function frame(now){
const dt=Math.min(0.05,(now-lastT)/1000);
lastT=now;
/* global key handling */
if(G.state==='play'){
if(pressed('KeyI','Tab')){closeShop();openInv();}
if(pressed('Escape','KeyP')){hide('invPanel');closeShop();G.state='pause';show('pause');}
}else if(G.state==='pause'){
if(pressed('Escape','KeyP')){hide('pause');G.state='play';}
}
if(G.state==='play'){
const invOpen=!document.getElementById('invPanel').classList.contains('hidden');
const shopOpen=!document.getElementById('shopPanel').classList.contains('hidden');
if(!invOpen&&!shopOpen)update(dt);
}
if(G.state!=='title')updateAmbient(dt);
if(G.state!=='title')render();
pressEdge={};
requestAnimationFrame(frame);
}
window.addEventListener('pointerdown',()=>Sfx.ensure(),{once:false});
/* init */
toTitle();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"ukisai/Swift-Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"2e8bc065-3791-460b-84f3-6729f283239e","tokensIn":8735202,"tokensOut":132422,"tokensTotal":8867624,"cost":0,"turns":72,"toolCalls":77,"failedToolCalls":0,"timestamp":"2026-09-29T17:59:00.607Z"} -->