Warden
Qwen 3.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 98,035 bytes · SHA-256 399290e66fd3
<!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:#d9a441; --gold2:#f0d9a0; --bronze:#8a6a3f;
--ink:#070a10; --panel1:#141b28; --panel2:#0b1018;
--red:#c0452e; --blue:#5aa8c8; --text:#d8d2c4; --dim:#9a8f78;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
background:radial-gradient(120% 100% at 50% 0%, #0c1119 0%, #05070b 70%);
color:var(--text); font-family:Georgia,'Times New Roman',serif;
display:flex;align-items:center;justify-content:center;min-height:100vh;
overflow:hidden;
}
#wrap{
position:relative; width:min(1080px, 97vw, calc(96vh * 16 / 9));
aspect-ratio:16/9;
}
canvas{
width:100%;height:100%;display:block;background:#070a10;
border:1px solid #33291a;
box-shadow:0 0 60px rgba(0,0,0,.85), 0 0 24px rgba(217,164,65,.06) inset;
}
/* ---------- zone banner ---------- */
#banner{
position:absolute; top:13%; left:0; width:100%; text-align:center;
pointer-events:none; opacity:0; transition:opacity .55s ease; z-index:5;
}
#banner.show{opacity:1}
#banner h2{
font-size:30px; font-weight:normal; color:#ecc984; letter-spacing:.28em;
text-transform:uppercase; text-shadow:0 0 26px rgba(217,164,65,.5);
}
#banner p{color:#b9a98c;font-style:italic;margin-top:10px;font-size:14.5px;letter-spacing:.04em;
text-shadow:0 1px 8px #000}
/* ---------- overlays ---------- */
.ov{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(3,5,9,.68); z-index:20;
}
.ov[hidden]{display:none}
.panel{
width:min(620px, 90%); max-height:94%; overflow-y:auto; text-align:center;
padding:26px 34px 24px;
background:linear-gradient(165deg, rgba(21,28,42,.98), rgba(9,13,20,.98));
border:1px solid #57482a;
box-shadow:0 0 70px rgba(0,0,0,.85), inset 0 0 90px rgba(0,0,0,.45);
}
.panel::-webkit-scrollbar{width:8px}
.panel::-webkit-scrollbar-thumb{background:#3a3226}
.overline{color:#8d7a52;letter-spacing:.34em;font-size:11px;text-transform:uppercase}
h1{
font-size:40px; font-weight:normal; color:#ecd09a; letter-spacing:.1em;
margin:10px 0 14px; text-shadow:0 0 24px rgba(217,164,65,.4);
}
h1.small{font-size:28px;margin-bottom:10px}
.lore{color:#b7ad97;font-size:14.5px;line-height:1.6;max-width:470px;margin:0 auto 20px;font-style:italic}
.btnrow{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin:4px 0 18px}
.btn{
background:#1a2230; border:1px solid #6a5a33; color:#e8d9b0;
padding:10px 22px; font:15px Georgia,serif; letter-spacing:.08em; cursor:pointer;
transition:all .16s ease;
}
.btn:hover{background:#243048;border-color:var(--gold);box-shadow:0 0 14px rgba(217,164,65,.25);color:#f7e7bd}
.btn.primary{border-color:#a8823f;background:#241f12;color:#f2dfa8}
.btn.small{padding:5px 14px;font-size:13px}
.btn:disabled{opacity:.35;cursor:default;box-shadow:none}
.btn:disabled:hover{background:#1a2230;border-color:#6a5a33;color:#e8d9b0}
.ctrls{
display:grid;grid-template-columns:auto auto;gap:5px 20px;justify-content:center;
font-size:12.5px;color:var(--dim);text-align:left;margin-top:6px;
}
kbd{
background:#1a2230;border:1px solid #4a3d24;padding:1px 7px;border-radius:3px;
font:12px Georgia,serif;color:#cfc2a0;display:inline-block;min-width:14px;text-align:center
}
.gold-line{color:#e8c67a;font-size:15px;margin:8px 0 2px;letter-spacing:.05em}
.row{
display:flex;align-items:center;justify-content:space-between;gap:14px;text-align:left;
padding:9px 13px;border:1px solid #2c3547;border-radius:4px;margin:8px 0;
background:rgba(15,21,33,.65);
}
.row .nm{color:#e6d3a3;font-size:14.5px;letter-spacing:.04em}
.row .ds{color:#8f8672;font-size:12px;margin-top:2px;line-height:1.35}
.price{color:#e8c67a;font-size:14px;white-space:nowrap}
.tag{
display:inline-block;font-size:9.5px;letter-spacing:.18em;text-transform:uppercase;
padding:2px 8px;border:1px solid #4a3d24;color:#a8956a;border-radius:2px;
margin-left:8px;vertical-align:2px;
}
.tag.on{color:#8fd4a5;border-color:#3d5a46}
.tag.off{color:#6a6152}
.mnote{color:#6f6857;font-size:12px;margin-top:14px;letter-spacing:.06em}
.stats{
display:grid;grid-template-columns:auto auto;gap:6px 26px;justify-content:center;
font-size:14px;color:#cfc2a0;margin:12px auto 18px;text-align:left;
}
.stats b{color:#e8c67a;font-weight:normal;text-align:right}
.foot{color:#6f6857;font-size:11.5px;margin-top:16px;letter-spacing:.1em}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="960" height="540"></canvas>
<div id="banner"><h2></h2><p></p></div>
<!-- TITLE -->
<div class="ov" id="ovTitle">
<div class="panel">
<div class="overline">Aurelion's Sunken Observatory</div>
<h1>The Warden's Corrosion</h1>
<p class="lore">The sky-wheel has stopped. Corrosion — a black-iron hunger — climbs the
bronze halls, and the archive drifts toward the storm-locked valley below.
You are the <b>Last Warden</b>. Rekindle the Core Beacon, or all that was kept is lost.</p>
<div class="btnrow">
<button class="btn primary" id="btnNew">Begin the Vigil</button>
<button class="btn" id="btnCont" hidden>Continue</button>
</div>
<div class="ctrls">
<span><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / Arrows</span><span>Move</span>
<span><kbd>J</kbd> or <kbd>Z</kbd> / Click</span><span>Strike</span>
<span><kbd>K</kbd> or <kbd>X</kbd></span><span>Aether Shard (when found)</span>
<span><kbd>E</kbd></span><span>Interact</span>
<span><kbd>Tab</kbd> / <kbd>I</kbd></span><span>Warden's Kit</span>
<span><kbd>1</kbd> / <kbd>2</kbd></span><span>Vital Stem / Binding Seal</span>
<span><kbd>P</kbd> / <kbd>Esc</kbd></span><span>Pause</span>
<span><kbd>M</kbd></span><span>Sound on / off</span>
</div>
<div class="foot">A melancholy of bronze and brass — a keyboard is required.</div>
</div>
</div>
<!-- PAUSE -->
<div class="ov" id="ovPause" hidden>
<div class="panel">
<div class="overline">The Vigil Pauses</div>
<h1 class="small">Rest, Warden</h1>
<p class="lore">Outside, the storm keeps its counsel. Your progress is kept at the last Sanctuary Stone.</p>
<div class="btnrow">
<button class="btn primary" id="btnResume">Resume</button>
<button class="btn" id="btnMute">Sound: On</button>
<button class="btn" id="btnAbandon">Abandon to Title</button>
</div>
<div class="ctrls">
<span><kbd>WASD</kbd></span><span>Move</span>
<span><kbd>J</kbd></span><span>Strike</span>
<span><kbd>K</kbd></span><span>Aether Shard</span>
<span><kbd>E</kbd></span><span>Interact</span>
<span><kbd>Tab</kbd></span><span>Kit</span>
<span><kbd>1</kbd>/<kbd>2</kbd></span><span>Use supplies</span>
</div>
</div>
</div>
<!-- INVENTORY -->
<div class="ov" id="ovInv" hidden>
<div class="panel">
<div class="overline">The Warden's Kit</div>
<h1 class="small">What the Vigil Holds</h1>
<div id="invBody"></div>
<div class="btnrow"><button class="btn" id="btnInvClose">Close <span class="tag off">Tab</span></button></div>
</div>
</div>
<!-- SHOP -->
<div class="ov" id="ovShop" hidden>
<div class="panel">
<div class="overline">The Merchant of the Archives</div>
<h1 class="small">The Corrosion Hastens…</h1>
<p class="lore" id="shopLine">"A Warden, at my door. The vaults are full of what you'll need — and pricier than the valley below."</p>
<div class="gold-line" id="shopGold"></div>
<div id="shopBody"></div>
<div class="btnrow"><button class="btn" id="btnShopClose">Leave <span class="tag off">Esc</span></button></div>
</div>
</div>
<!-- VICTORY -->
<div class="ov" id="ovWin" hidden>
<div class="panel">
<div class="overline">Dawn Over the Valley</div>
<h1 class="small">The Beacon Kindled</h1>
<p class="lore">Light climbs the spire through the broken dome, and for one held breath the
storm remembers its manners. The Corrosion stills. Aurelion's archive endures —
and so does its Warden.</p>
<div class="stats" id="winStats"></div>
<div class="btnrow"><button class="btn primary" id="btnWinTitle">Return to Title</button></div>
</div>
</div>
</div>
<script>
'use strict';
/* ================================================================
THE WARDEN'S CORROSION — single-file action-adventure
================================================================ */
const T=32, CW=960, CH=540;
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a,b)=>a+Math.random()*(b-a);
const ri=(a,b)=>Math.floor(a+Math.random()*(b-a+1));
const dist=(ax,ay,bx,by)=>Math.hypot(bx-ax,by-ay);
const angN=a=>{while(a>Math.PI)a-=2*Math.PI;while(a<-Math.PI)a+=2*Math.PI;return a;};
const angDiff=(a,b)=>angN(a-b);
const ease=t=>t<0?0:(t>1?1:t*t*(3-2*t));
const $=id=>document.getElementById(id);
/* ================= AUDIO (all synthesized) ================= */
const AudioSys={
ctx:null, master:null, muted:false, drone:null,
init(){
if(this.ctx) return;
const C=window.AudioContext||window.webkitAudioContext;
if(!C) return;
this.ctx=new C();
this.master=this.ctx.createGain();
this.master.gain.value=this.muted?0:0.8;
const comp=this.ctx.createDynamicsCompressor();
this.master.connect(comp); comp.connect(this.ctx.destination);
},
resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
setMuted(m){ this.muted=m; if(this.master) this.master.gain.value=m?0:0.8; },
t(){ return this.ctx?this.ctx.currentTime:0; },
env(g,t0,a,peak,dur){
g.gain.setValueAtTime(0.0001,t0);
g.gain.linearRampToValueAtTime(peak,t0+a);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
},
osc(type,f0,f1,dur,peak,t0,dest){
if(!this.ctx||this.muted) return;
t0=t0||this.t();
const o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type=type; o.frequency.setValueAtTime(Math.max(1,f0),t0);
if(f1) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t0+dur);
this.env(g,t0,0.006,peak,dur);
o.connect(g); g.connect(dest||this.master);
o.start(t0); o.stop(t0+dur+0.06);
},
noise(dur,peak,ftype,f0,f1,q,t0){
if(!this.ctx||this.muted) return;
t0=t0||this.t();
const n=this.ctx.createBufferSource();
const len=Math.max(16,(dur*this.ctx.sampleRate)|0);
const b=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d=b.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
n.buffer=b;
const f=this.ctx.createBiquadFilter(); f.type=ftype||'lowpass';
f.frequency.setValueAtTime(f0||800,t0);
if(f1) f.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t0+dur);
f.Q.value=q||0.8;
const g=this.ctx.createGain();
this.env(g,t0,0.004,peak,dur);
n.connect(f); f.connect(g); g.connect(this.master);
n.start(t0); n.stop(t0+dur+0.06);
},
arp(freqs,gap,dur,peak,type){
freqs.forEach((f,i)=>this.osc(type||'sine',f,null,dur,peak,this.t()+i*gap));
},
sfx(name, arg){
if(!this.ctx||this.muted) return;
const t=this.t();
switch(name){
case 'step':{
const z=arg||0;
if(z===1){ this.noise(0.05,0.045,'highpass',900,null,1); this.osc('triangle',180+rnd(-30,30),140,0.05,0.028); }
else if(z===3){ this.noise(0.09,0.05,'lowpass',520,160); }
else { this.noise(0.05,0.032,'lowpass',720,280); }
break; }
case 'whoosh':{
const w=arg||0;
if(w===0){ this.noise(0.11,0.08,'bandpass',520,1250,1.4); }
else if(w===1){ this.noise(0.1,0.08,'bandpass',600,1400,1.4); this.noise(0.08,0.05,'highpass',2600,null,1); }
else { this.noise(0.22,0.1,'bandpass',240,700,1); this.osc('sine',72,48,0.26,0.06); }
break; }
case 'impact':{
const w=arg||0;
if(w===0){ this.osc('triangle',110,55,0.09,0.16); this.noise(0.06,0.1,'lowpass',420,200); }
else if(w===1){ this.osc('triangle',100,50,0.11,0.17); this.noise(0.07,0.1,'lowpass',380,180); this.noise(0.09,0.06,'highpass',2400,null,1); }
else { this.osc('sine',62,36,0.32,0.26); this.osc('triangle',920,1500,0.12,0.05); this.noise(0.16,0.14,'lowpass',260,120); }
break; }
case 'shoot':{ this.osc('square',660,190,0.07,0.08); this.osc('sine',1900,1400,0.06,0.035); break; }
case 'chime':{ this.osc('sine',1250,900,0.24,0.08); this.osc('sine',2500,2000,0.16,0.03); break; }
case 'hurt':{
this.osc('triangle',92,44,0.16,0.24);
this.osc('sine',1180,1180,0.2,0.05); this.osc('sine',1246,1246,0.2,0.05);
break; }
case 'coin':{ this.osc('square',1500,1500,0.05,0.05); this.osc('square',2100,2100,0.07,0.05,t+0.05); break; }
case 'item':{ this.arp([660,880,1320],0.07,0.12,0.07); break; }
case 'rare':{
this.arp([523,659,784],0.02,0.4,0.06);
this.arp([1568,2093],0.1,0.2,0.04); break; }
case 'heart':{ this.arp([523,659,784,1046],0.09,0.22,0.08); this.noise(0.4,0.03,'highpass',3000,null,1); break; }
case 'shimmer':{ this.osc('sine',1046,2093,0.3,0.06); break; }
case 'altar':{
this.osc('sawtooth',82,52,0.6,0.07);
this.arp([392,494,587],0.02,0.7,0.06);
this.arp([1046,1318,1568],0.12,0.25,0.04); break; }
case 'save':{ this.arp([330,415,494],0.02,0.6,0.06); this.osc('sine',990,880,0.4,0.03, t+0.25); break; }
case 'break':{ this.noise(0.2,0.2,'lowpass',900,180); this.osc('triangle',120,58,0.12,0.1); break; }
case 'tick':{ this.noise(0.05,0.06,'lowpass',1300,500); break; }
case 'plate':{ this.osc('square',150,88,0.09,0.12); this.noise(0.06,0.08,'lowpass',320,140); break; }
case 'door':{ this.noise(0.55,0.12,'lowpass',620,140); this.osc('sine',55,48,0.55,0.08); break; }
case 'seal':{ this.osc('sine',1800,820,0.22,0.09); this.noise(0.18,0.05,'highpass',2800,null,1); break; }
case 'block':{ this.osc('triangle',210,120,0.08,0.1); this.noise(0.05,0.06,'highpass',2200,null,1); break; }
case 'boss':{ this.osc('sawtooth',72,38,0.55,0.14); this.noise(0.4,0.1,'lowpass',420,120); break; }
case 'charge':{ this.noise(0.3,0.08,'bandpass',300,950,1.2); this.osc('sawtooth',60,130,0.3,0.05); break; }
case 'ebolt':{ this.osc('sine',310,170,0.12,0.06); break; }
case 'die':{ this.osc('triangle',150,48,0.16,0.12); this.noise(0.12,0.09,'lowpass',520,160); break; }
case 'death':{ this.osc('sawtooth',220,38,0.9,0.15); this.noise(0.6,0.1,'lowpass',600,80); break; }
case 'victory':{ this.arp([392,523,659,784,1046,1318],0.15,0.5,0.09); this.noise(1.2,0.04,'highpass',3000,null,1); break; }
case 'buy':{ this.osc('square',920,300,0.09,0.07); this.osc('sine',1568,1568,0.18,0.08,t+0.1); break; }
case 'empty':{ this.osc('sine',240,220,0.09,0.05); break; }
}
},
droneOn(){
if(!this.ctx||this.muted||this.drone) return;
const g=this.ctx.createGain(); g.gain.value=0.0001; g.connect(this.master);
const o1=this.ctx.createOscillator(), o2=this.ctx.createOscillator();
const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=240;
o1.type='sine'; o1.frequency.value=55; o2.type='sine'; o2.frequency.value=55.8;
o1.connect(f); o2.connect(f); f.connect(g);
o1.start(); o2.start();
g.gain.linearRampToValueAtTime(0.05,this.t()+1.4);
this.drone={g,o1,o2};
},
droneOff(){
if(!this.drone) return;
const d=this.drone, t=this.t();
d.g.gain.cancelScheduledValues(t);
d.g.gain.setValueAtTime(Math.max(0.0001,d.g.gain.value),t);
d.g.gain.linearRampToValueAtTime(0.0001,t+0.8);
d.o1.stop(t+1); d.o2.stop(t+1);
this.drone=null;
},
ambient(zone){
this.droneOff();
if(!this.ctx||this.muted) return;
const base=[55,49,64,41,58,36.7][zone]||55;
const g=this.ctx.createGain(); g.gain.value=0.0001; g.connect(this.master);
const o1=this.ctx.createOscillator(), o2=this.ctx.createOscillator();
const f=this.ctx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=200+zone*30;
o1.type='sine'; o1.frequency.value=base; o2.type='triangle'; o2.frequency.value=base*1.007;
o1.connect(f); o2.connect(f); f.connect(g);
o1.start(); o2.start();
g.gain.linearRampToValueAtTime(0.035,this.t()+2);
this.drone={g,o1,o2};
}
};
/* ================= CONSTANTS & DATA ================= */
const ZONES=[
{name:'The Ashen Threshold', sub:'Prologue', w:60, h:13},
{name:'The Rusted Archives', sub:'Early Halls', w:26, h:28},
{name:'The Collapsed Gallery', sub:'Mid Halls', w:28, h:14},
{name:'The Sunken Vault', sub:'Halfway Point', w:32, h:16},
{name:'The Twin Spire', sub:'Final Ascent', w:18, h:42},
{name:'The Core Chamber', sub:'The Beacon', w:34, h:22}
];
const ITEMS={
band:{name:"Warden's Band", type:'accessory', desc:'A ring of unbroken ward. Attack speed rises slightly.'},
stem:{name:'Vital Stem', type:'consumable', use:'Use (1)', desc:'A living sprig of the archive garden. Restores 2 full hearts (4 HP).'},
lantern:{name:'Phantom Lantern', type:'accessory', desc:'Blue flame, no heat. Reveals hidden cracks and paths in the dark.'},
cloak:{name:'Shadow Cloak', type:'accessory', desc:'A trim of storm-dark cloth. Halves damage from the observatory’s traps.'},
seal:{name:'Binding Seal', type:'consumable', use:'Use (2)', desc:'A knot of cold light. Freezes the nearest construct for ~3 seconds.'}
};
const ACCS=['band','lantern','cloak'];
const ENEMY_TYPES={
crawler:{name:'Rusted Sentinel', hp:4, dmg:1, sp:44, touchCd:0.8, r:9, gold:[5,9], flash:0.09, touchD:0.3, body:'#6e462f', trim:'#2f2019', eye:'#ff9d3c'},
sentinel:{name:'Corrupted Sentinel', hp:8, dmg:1, sp:42, touchCd:0.8, r:10, gold:[7,13], flash:0.1, touchD:0.32, body:'#57606f', trim:'#232a33', eye:'#e0483a'},
warden:{name:'Warden Construct', hp:12, dmg:1, sp:60, touchCd:0.7, r:10, gold:[10,16], flash:0.1, touchD:0.35, body:'#4a3a2e', trim:'#241d16', eye:'#7fd4ff', boss:true},
guardian:{name:'Spire Guardian', hp:30, dmg:1, sp:78, touchCd:0.7, r:15, gold:[60,90], flash:0.12, touchD:0.4, body:'#3b4152', trim:'#1c212c', eye:'#ffd257', boss:true}
};
const EKEYS=['crawler','sentinel','warden','guardian'];
const ECOL=[['#5c4632','#2c221a'],['#4c5866','#242d38'],['#43302a','#221a14'],['#303848','#181e2a']];
const WEAPONS=[
{name:'Ember Blade', dmg:1, wind:0.30, swing:0.16, rec:0.14, arc:1.95, range:30, burn:0, trail:0},
{name:'Ignited Edge', dmg:2, wind:0.20, swing:0.14, rec:0.10, arc:2.15, range:32, burn:1.2, trail:1},
{name:'Solar Falchion',dmg:3, wind:0.46, swing:0.22, rec:0.17, arc:2.65, range:40, burn:1.2, trail:2}
];
const SHOT={speed:340, life:1.1, dmg:2};
/* --- map builder --- */
function buildMap(){
const M=[];
for(let z=0;z<6;z++){
const {w,h}=ZONES[z];
const g=new Array(w*h).fill(0);
const put=(x,y,v)=>{ if(x>=0&&y>=0&&x<w&&y<h) g[y*w+x]=v; };
const ring=(x0,y0,x1,y1,v)=>{ for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){ if(x===x0||x===x1||y===y0||y===y1) put(x,y,v); } };
const box=(x0,y0,x1,y1,v)=>{ for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) put(x,y,v); };
const door=(x,y)=>{ if(x>0&&x<w-1&&y>0&&y<h-1) put(x,y,2); };
const wall=(x,y)=>put(x,y,1);
const breakw=(x,y,v)=>put(x,y,v||5);
const deco=(x,y,v)=>{ if(g[y*w+x]===0) put(x,y,v); };
const clear=(x,y)=>put(x,y,0);
if(z===0){
// The Ashen Threshold
wall(2,1); wall(2,2); wall(2,3); wall(2,4);
box(6,1,7,1,1); box(6,5,7,5,1);
wall(5,1); wall(5,2); wall(5,3); wall(5,4); wall(5,5);
box(13,1,14,1,1); box(13,11,14,11,1);
wall(12,1); wall(12,2); wall(12,3); wall(12,4); wall(12,5);
wall(12,7); wall(12,8); wall(12,9); wall(12,10); wall(12,11);
box(21,3,22,4,1); box(21,8,22,9,1);
box(26,2,27,3,1); box(26,9,27,10,1);
wall(25,2); wall(25,3); wall(25,4); wall(25,5);
wall(25,7); wall(25,8); wall(25,9); wall(25,10);
wall(24,2); wall(24,3); wall(24,4);
wall(24,8); wall(24,9); wall(24,10);
deco(3,1,3); deco(3,2,3); deco(3,5,3); deco(3,6,3); deco(3,10,3);
deco(4,3,4); deco(4,4,4);
deco(9,1,3); deco(9,11,3); deco(10,6,5); deco(15,2,4); deco(15,9,4);
deco(17,4,7); deco(17,8,7); deco(18,4,7); deco(18,8,7); deco(19,6,7);
deco(28,1,5); deco(28,10,5); deco(29,1,5); deco(29,10,5); deco(30,6,9);
deco(31,1,5); deco(31,10,5); deco(32,1,5); deco(32,10,5);
deco(33,1,8); deco(33,11,8); deco(34,1,8); deco(34,11,8);
deco(35,1,8); deco(35,11,8); deco(36,1,8); deco(36,11,8); deco(37,1,8); deco(37,11,8);
door(39,6);
breakw(19,7,5); breakw(19,8,5);
breakw(33,7,8);
}
if(z===1){
// The Rusted Archives — shelves, dark vault, hidden alcove
box(0,3,4,3,1); box(0,10,2,10,1); box(0,14,2,14,1); box(0,19,3,19,1); box(0,25,4,25,1);
box(21,3,25,3,1); box(23,10,25,10,1); box(25,14,25,14,1); box(22,19,25,19,1); box(24,25,25,25,1);
box(3,6,3,8,1); box(12,7,12,8,1); box(17,7,17,8,1); box(22,6,22,8,1);
box(2,12,3,12,1); box(12,12,12,13,1); box(17,12,17,13,1);
box(2,17,4,17,1); box(12,16,12,17,1); box(17,16,17,17,1); box(22,16,24,16,1);
box(2,21,4,22,1); clear(3,22);
box(5,21,5,22,1); box(13,21,13,22,1); box(21,21,21,22,1);
box(8,10,13,10,5); box(8,11,13,11,5); box(8,12,13,12,5); box(8,13,13,13,5);
box(8,14,13,14,5); box(8,15,13,15,5);
clear(9,11); clear(9,12); clear(9,13); clear(9,14);
clear(12,11); clear(12,12); clear(12,13); clear(12,14);
clear(10,13); clear(11,13); clear(10,14); clear(11,14); clear(11,11); clear(11,12);
breakw(8,6,5); breakw(8,7,5); breakw(12,8,5);
breakw(18,12,5); breakw(8,17,5); breakw(13,16,5); breakw(20,17,5);
breakw(9,21,9); breakw(10,21,9); breakw(11,21,9);
breakw(18,21,9); breakw(19,21,9); breakw(20,21,9);
deco(4,4,3); deco(5,4,3); deco(4,10,4); deco(5,10,4);
deco(20,4,3); deco(21,4,3); deco(20,10,4); deco(21,10,4);
deco(3,14,3); deco(4,14,4); deco(21,14,3); deco(22,14,4);
deco(20,19,3); deco(21,19,3);
for(let y=11;y<=21;y+=2){ deco(6,y,6); deco(15,y,6); }
door(12,26); door(13,26);
}
if(z===2){
// The Collapsed Gallery
box(5,2,5,11,1); breakw(5,6);
box(14,2,14,4,1); box(14,6,14,11,1); clear(14,5);
box(15,6,22,6,1);
box(23,6,27,6,1);
box(14,1,27,1,1);
box(25,11,27,13,1); clear(26,12); clear(27,12);
breakw(25,2); breakw(6,2);
door(27,7);
deco(2,2,3); deco(3,3,4);
deco(2,6,4); deco(3,7,4);
deco(2,10,4); deco(3,11,3);
deco(6,4,7); deco(12,4,7); deco(6,8,7); deco(12,8,7);
deco(9,5,6); deco(9,7,6);
deco(16,1,4); deco(17,2,4); deco(21,1,3); deco(22,2,4);
deco(16,9,4); deco(17,10,6); deco(21,10,6); deco(22,9,4);
deco(25,11,3); deco(26,11,4);
door(26,6);
}
if(z===3){
// The Sunken Vault
box(5,4,6,5,1);
box(11,2,11,6,1); box(12,5,12,6,1);
box(17,9,18,13,1);
box(19,11,20,12,1);
box(24,3,25,8,1);
box(11,8,12,13,1);
box(11,15,12,15,1);
box(15,9,15,10,1); box(16,9,16,10,1); box(17,9,17,10,1);
box(15,14,15,15,1); box(16,14,16,15,1); box(17,14,17,15,1);
clear(15,11); clear(15,12);
clear(24,11);
box(24,12,27,12,1); box(25,10,27,10,1); box(27,11,27,11,1); box(24,13,24,14,1);
breakw(12,5,5);
breakw(5,7,8); breakw(6,7,8); breakw(6,8,8);
breakw(14,14,9);
breakw(23,10,8);
deco(1,5,3); deco(1,8,4); deco(2,10,4);
deco(9,1,3); deco(10,1,4);
deco(14,1,3); deco(15,2,4);
deco(21,1,3); deco(22,1,4); deco(23,2,4);
deco(29,2,3); deco(30,3,4); deco(30,5,4);
deco(1,13,4); deco(2,14,3);
deco(9,14,4); deco(10,15,4);
deco(22,14,4); deco(23,15,3);
deco(30,13,4); deco(30,15,3);
deco(19,1,6);
door(16,7); door(17,7);
}
if(z===4){
// The Twin Spire — ascending columns
for(let y=2;y<=4;y+=2){
for(let x=2;x<8;x++) if((x+y)%2===0) box(x,y,x,y,1);
for(let x=9;x<16;x++) if((x+y)%2===1) box(x,y,x,y,1);
}
box(1,7,16,7,1);
for(let y=9;y<=21;y+=2){
for(let x=2;x<8;x++) if((x+y)%2===0) box(x,y,x,y,1);
for(let x=9;x<16;x++) if((x+y)%2===1) box(x,y,x,y,1);
}
box(1,22,16,22,1);
for(let y=24;y<=36;y+=2){
for(let x=2;x<8;x++) if((x+y)%2===0) box(x,y,x,y,1);
for(let x=9;x<16;x++) if((x+y)%2===1) box(x,y,x,y,1);
}
box(5,38,12,38,1); box(5,39,12,39,1);
clear(7,39); clear(8,39); clear(9,39); clear(10,39);
clear(8,5); clear(10,5); clear(9,7);
clear(8,17); clear(10,17); clear(9,22);
clear(8,29); clear(10,29); clear(9,34);
box(1,1,4,1,1); box(1,2,2,2,1); box(4,2,4,2,1);
clear(3,2); breakw(3,3,9);
box(1,36,4,36,1); box(1,37,1,37,1); box(4,37,4,37,1);
clear(2,37); clear(3,37); breakw(2,38,9); breakw(3,38,9);
box(13,10,16,10,1);
box(13,16,16,16,1);
clear(15,11); clear(14,11); breakw(14,10,5);
clear(15,15); clear(14,15); breakw(14,16,5);
clear(9,3); clear(10,3);
deco(1,1,3); deco(16,1,4);
deco(4,10,7); deco(13,10,7);
deco(4,14,6); deco(13,14,6);
deco(4,25,7); deco(13,25,7);
deco(4,31,6); deco(13,31,6);
deco(2,38,3); deco(15,38,3);
door(8,41); door(9,41);
}
if(z===5){
// The Core Chamber — ring of light
ring(9,6,24,15,1);
clear(16,6); clear(17,6);
for(let x=11;x<=23;x+=3) clear(x,15);
for(let y=7;y<=14;y+=3) clear(9,y);
for(let y=7;y<=14;y+=3) clear(24,y);
clear(16,15); clear(17,15);
box(11,8,11,9,1); box(22,8,22,9,1);
box(11,12,11,13,1); box(22,12,22,13,1);
clear(11,10); clear(11,11);
clear(22,10); clear(22,11);
deco(3,3,3); deco(4,4,4);
deco(13,2,6); deco(17,2,6);
deco(2,18,3); deco(3,19,4);
deco(30,18,3); deco(31,19,4);
deco(16,2,9);
door(16,15); door(17,15);
}
M[z]=g;
}
return M;
}
/* --- entity factories --- */
function EntFactory(){
const E1={
e(type,gx,gy,vx,vy,opts){
const d=ENEMY_TYPES[type];
const o=Object.assign({},d,opts||{});
o.type=type; o.x=gx*T+T/2; o.y=gy*T+T/2; o.vx=vx||0; o.vy=vy||0;
o.state='chase'; o.hp=d.hp; o.maxhp=d.hp;
o.t=0; o.ia=0; o.hitT=0; o.burnT=0; o.frozenT=0; o.dieT=0; o.gold=ri(o.gold[0],o.gold[1]);
o.sx=rnd(0,6); o.sy=rnd(0,6);
o.cd2=null; o.spd2=0; o.atk=0; o.atkT=0; o.touchCdT=0;
return o;
},
chest(gx,gy,type){ return {x:gx*T+T/2, y:gy*T+T/2, type:type||'gold', openT:0, id:nextId++}; },
heart(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'heart', id:nextId++}; },
stone(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'stone', id:nextId++}; },
altar(gx,gy,kind){ return {x:gx*T+T/2, y:gy*T+T/2, kind:kind, done:false, id:nextId++}; },
merchant(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'merchant', id:nextId++}; },
shard(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'shard', id:nextId++}; },
trap(gx,gy,dir){ return {x:gx*T+T/2, y:gy*T+T/2, dir:dir||'x', t:rnd(0,2), on:false, hitT:0, id:nextId++}; },
plate(gx,gy,tid){ return {x:gx*T+T/2, y:gy*T+T/2, tid:tid||null, t:0, press:false, id:nextId++}; },
crystal(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'crystal', id:nextId++, hitT:undefined}; },
beacon(gx,gy){ return {x:gx*T+T/2, y:gy*T+T/2, type:'beacon', id:nextId++, lit:false, animT:0}; }
};
return E1;
}
/* --- zone entity placement (uses E = EntFactory) --- */
let nextId=1;
const E=EntFactory();
function zoneEntities(z){
const out=[];
const add=o=>out.push(o);
if(z===0){
add(E.stone(3,8));
add(E.e('crawler',10,7)); add(E.e('crawler',11,6));
add(E.e('crawler',17,6)); add(E.e('crawler',17,7));
add(E.e('crawler',23,6)); add(E.e('crawler',28,7));
add(E.chest(20,3,'stem'));
add(E.chest(18,11,'gold'));
add(E.chest(33,7,'gold'));
}
if(z===1){
add(E.stone(13,13));
add(E.e('crawler',3,2)); add(E.e('crawler',22,2));
add(E.e('crawler',3,26)); add(E.e('crawler',22,26));
add(E.e('sentinel',12,11)); add(E.e('sentinel',14,13));
add(E.chest(3,22,'band'));
add(E.chest(14,12,'gold'));
add(E.chest(18,17,'gold'));
add(E.heart(18,14));
add(E.merchant(12,13));
}
if(z===2){
add(E.plate(8,4,'A')); add(E.plate(8,9,'A'));
add(E.plate(13,2,'A')); add(E.plate(13,9,'A'));
add(E.plate(26,3,'B')); add(E.plate(26,9,'B'));
add(E.plate(21,13,'C'));
add(E.crystal(26,12));
add(E.chest(16,2,'stem'));
add(E.chest(14,5,'seal'));
add(E.altar(19,5,'u1'));
add(E.e('crawler',17,2)); add(E.e('crawler',17,10));
add(E.e('sentinel',9,7));
add(E.e('warden',21,5));
}
if(z===3){
add(E.chest(12,2,'shard'));
add(E.e('crawler',13,2)); add(E.e('crawler',14,2));
add(E.e('sentinel',9,2)); add(E.e('sentinel',10,2));
add(E.e('sentinel',13,11)); add(E.e('sentinel',14,11));
add(E.chest(1,1,'gold'));
add(E.chest(30,1,'gold'));
add(E.chest(26,11,'lantern'));
add(E.crystal(1,11));
}
if(z===4){
add(E.stone(11,1));
add(E.altar(9,3,'u2'));
add(E.e('sentinel',5,10)); add(E.e('sentinel',5,14));
add(E.e('sentinel',10,10)); add(E.e('sentinel',10,14));
add(E.e('sentinel',5,18)); add(E.e('sentinel',12,20)); add(E.e('sentinel',13,19));
add(E.chest(3,37,'cloak'));
add(E.chest(3,36,'seal'));
add(E.chest(9,37,'gold'));
add(E.e('warden',6,33)); add(E.e('warden',10,30));
add(E.e('guardian',9,39));
}
if(z===5){
add(E.crystal(10,6)); add(E.crystal(23,6));
add(E.crystal(9,11)); add(E.crystal(24,11));
add(E.beacon(16,10));
add(E.trap(13,10,'x')); add(E.trap(20,10,'x'));
add(E.trap(16,8,'y')); add(E.trap(16,13,'y'));
add(E.e('sentinel',16,9)); add(E.e('sentinel',15,12));
add(E.e('warden',14,10)); add(E.e('warden',18,10));
add(E.e('guardian',16,11));
add(E.chest(16,11,'stem'));
}
return out;
}
/* ================= GAME STATE ================= */
const cv=$('cv'), ctx=cv.getContext('2d');
const game={
state:'title', // title, playing, paused, inv, shop, dead, win
zone:0, maps:null, ents:[], bolts:[], parts:[], floaters:[],
player:null, time:0, shake:0, flash:0, edge:0, deathT:0, winT:0,
saveIdx:0, lastSaveZone:0, winShown:false, backOk:false, looted:[],
stats:{time:0, kills:0, gold:0, steps:0},
banners:[], interactHint:'', shopStock:null,
broken:[], // persistent broken wall keys "z:tx,ty"
vaultDoor:false, // zone 3 mechanism door to the lantern room
doorAnim:{},
ambientAcc:0, stepAcc:0, emberAcc:0
};
function newPlayer(){
return {
x:0, y:0, r:7, dir:0, moving:false,
hp:6, maxhp:6, containers:3,
wpn:0, hasShard:false, shardCharges:0,
gold:0, inv:{stem:0, seal:0}, equipped:{},
atkT:0, atkPhase:'idle', hitDone:false, trail:[],
hitFlash:0, invuln:0, dead:false, aura:0, emberT:0,
kills:0
};
}
function saveStock(){ return {stem:3, seal:2, heart:true, ammo:0}; }
/* ---- save system ---- */
const SAVE_KEY='wardensCorrosion.v1';
function doSave(){
const p=game.player;
const d={
zone:game.zone, saveIdx:game.saveIdx, lastSaveZone:game.zone,
broken:game.broken, vaultDoor:game.vaultDoor,
player:{x:p.x,y:p.y,hp:p.hp,maxhp:p.maxhp,containers:p.containers,wpn:p.wpn,
hasShard:p.hasShard,shardCharges:p.shardCharges,gold:p.gold,
inv:{...p.inv}, equipped:{...p.equipped}},
stock:game.shopStock,
stats:{...game.stats},
ents:game.ents.map(e=>{
const o={};
o.x=e.x; o.y=e.y;
if(e.type) o.type=e.type;
if(e.kind) o.kind=e.kind;
if(e.hp!==undefined){o.hp=e.hp; o.maxhp=e.maxhp;}
if(e.openT) o.openT=e.openT;
if(e.done!==undefined) o.done=e.done;
if(e.tid) o.tid=e.tid;
return o;
})
};
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(d)); }catch(e){}
}
function loadSave(){
try{
const s=localStorage.getItem(SAVE_KEY);
if(!s) return null;
return JSON.parse(s);
}catch(e){ return null; }
}
function hasSave(){ return !!loadSave(); }
function clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} }
/* ================= INPUT ================= */
const keys={};
let mouseDown=false, mouseX=480, mouseY=270;
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(['tab',' ','arrowup','arrowdown','arrowleft','arrowright'].includes(k)) e.preventDefault();
keys[k]=true;
onKeyPress(k);
});
addEventListener('keyup',e=>{ keys[e.key.toLowerCase()]=false; });
cv.addEventListener('mousedown',e=>{
mouseDown=true;
const r=cv.getBoundingClientRect();
mouseX=(e.clientX-r.left)*CW/r.width; mouseY=(e.clientY-r.top)*CH/r.height;
onKeyPress('j');
});
addEventListener('mouseup',()=>mouseDown=false);
cv.addEventListener('contextmenu',e=>e.preventDefault());
function axisX(){ return (keys['d']||keys['arrowright']?1:0)-(keys['a']||keys['arrowleft']?1:0); }
function axisY(){ return (keys['s']||keys['arrowdown']?1:0)-(keys['w']||keys['arrowup']?1:0); }
function onKeyPress(k){
if(k==='m'){
AudioSys.setMuted(!AudioSys.muted);
$('btnMute').textContent='Sound: '+(AudioSys.muted?'Off':'On');
return;
}
if(game.state==='title' && k==='enter'){ startNew(); return; }
if(game.state==='dead'){ if(game.deathT>1 && (k==='r'||k==='enter')) respawn(); return; }
if(game.state==='win'){ if(game.winT>1.5 && (k==='r'||k==='enter')) toTitle(); return; }
if(k==='p'||k==='escape'){
if(game.state==='playing'){ game.state='paused'; $('ovPause').hidden=false; }
else if(game.state==='paused'){ game.state='playing'; $('ovPause').hidden=true; }
else if(game.state==='inv'){ game.state='playing'; $('ovInv').hidden=true; }
else if(game.state==='shop'){ game.state='playing'; $('ovShop').hidden=true; }
return;
}
if(game.state!=='playing') return;
if(k==='tab'||k==='i'){ game.state='inv'; renderInv(); $('ovInv').hidden=false; return; }
if(k==='j'||k==='z'||k==='enter') tryAttack();
if((k==='k'||k==='x')) tryShoot();
if(k==='e') tryInteract();
if(k==='1') useItem('stem');
if(k==='2') useItem('seal');
}
function setOv(shown){
$('ovTitle').hidden=!shown; $('ovPause').hidden=true;
$('ovInv').hidden=true; $('ovShop').hidden=true; $('ovWin').hidden=true;
}
function toTitle(){
game.state='title';
setOv(true);
$('btnCont').hidden=!hasSave();
$('btnCont').textContent='Continue';
AudioSys.droneOff();
}
function startNew(){
AudioSys.init(); AudioSys.resume();
nextId=1;
game.maps=buildMap();
const p=newPlayer();
p.x=2*T+T/2; p.y=8*T+T/2;
game.player=p;
game.zone=0; game.saveIdx=0; game.lastSaveZone=0;
game.ents=zoneEntities(0);
game.bolts=[]; game.parts=[]; game.floaters=[];
game.stats={time:0,kills:0,gold:0,steps:0};
game.shopStock=saveStock();
game.broken=[]; game.vaultDoor=false; game.doorAnim={};
game.looted=[]; game.winShown=false; game.backOk=false;
game.state='playing'; setOv(false);
banner(0);
AudioSys.ambient(0);
}
function continueGame(){
const d=loadSave(); if(!d) return startNew();
AudioSys.init(); AudioSys.resume();
nextId=100000;
game.maps=buildMap();
game.zone=d.zone;
game.saveIdx=d.saveIdx; game.lastSaveZone=d.lastSaveZone||d.zone;
game.broken=d.broken||[];
game.vaultDoor=!!d.vaultDoor;
applyBroken(d.zone);
const fresh=zoneEntities(d.zone);
for(const se of d.ents||[]){
const m=fresh.find(e=>{
if(e.kind==='u1'||e.kind==='u2') return e.kind===se.kind;
if(e.type) return e.type===se.type&&Math.abs(e.x-se.x)<1&&Math.abs(e.y-se.y)<1;
return Math.abs(e.x-se.x)<1&&Math.abs(e.y-se.y)<1;
});
if(m){
if(se.hp!==undefined) m.hp=se.hp;
if(se.openT) m.openT=se.openT;
if(se.done!==undefined) m.done=se.done;
}
}
if(d.zone===4){
const has=fresh.find(e=>e.kind==='u2');
if(!has) fresh.push(Object.assign(E.altar(9,3,'u2'),{done:(d.player&&d.player.wpn>=2)}));
else has.done=(d.player&&d.player.wpn>=2);
}
game.ents=fresh;
game.bolts=[]; game.parts=[]; game.floaters=[];
const p=newPlayer();
const sp=d.player||{};
p.x=sp.x||2*T+T/2; p.y=sp.y||8*T+T/2;
p.hp=sp.hp!=null?sp.hp:6; p.maxhp=sp.maxhp||6; p.containers=sp.containers||3;
p.wpn=sp.wpn||0; p.hasShard=!!sp.hasShard; p.shardCharges=sp.shardCharges||0;
p.gold=sp.gold||0; p.inv=sp.inv||{stem:0,seal:0}; p.equipped=sp.equipped||{};
game.player=p;
game.shopStock=d.stock||saveStock();
game.stats=d.stats||{time:0,kills:0,gold:0,steps:0};
game.doorAnim={};
game.state='playing'; setOv(false);
banner(game.zone);
AudioSys.ambient(game.zone);
}
function respawn(){
const p=game.player;
p.hp=p.maxhp;
const zx=game.lastSaveZone;
if(zx!==game.zone){
game.zone=zx; game.ents=zoneEntities(zx);
game.bolts=[]; game.parts=[]; game.floaters=[];
game.doorAnim={};
applyBroken(zx);
AudioSys.ambient(zx);
} else {
const fresh=zoneEntities(zx);
for(const f of fresh){
const cur=game.ents.find(e=>(e.type&&e.type===f.type)||(!e.type&&!f.type));
if(cur&&f.hp!==undefined&&cur.hp<=0){ cur.hp=f.hp; cur.dieT=0; cur.frozenT=0; cur.burnT=0; }
}
}
const st=game.ents.find(e=>e.type==='stone');
if(st){ p.x=st.x; p.y=st.y+T*0.8; }
p.dead=false; p.invuln=2; p.hitFlash=0;
game.state='playing'; banner(game.zone, true);
game.stats.deaths=(game.stats.deaths||0)+1;
}
/* ================= ZONE FLOW ================= */
function applyBroken(z){
const {w,h}=ZONES[z];
for(const key of game.broken){
if(!key.startsWith(z+':')) continue;
const [tx,ty]=key.slice(2).split(',').map(Number);
if(tx>=0&&ty>=0&&tx<w&&ty<h) game.maps[z][ty*w+tx]=0;
}
}
function banner(z, soft){
game.banners.push({t:0, dur:3.2, title:ZONES[z].name, sub:ZONES[z].sub, soft:!!soft});
}
function loadZone(z){
game.zone=z;
game.ents=zoneEntities(z);
game.bolts=[]; game.parts=[]; game.floaters=[];
game.doorAnim={};
applyBroken(z);
AudioSys.ambient(z);
banner(z);
}
function zoneDoorPos(){
switch(game.zone){
case 0: return {x:40*T-16, y:7*T-16, n:1, p:0};
case 1: return {x:13*T-16, y:27*T-16, n:2, p:0};
case 2: return {x:27*T-16, y:7*T-16, n:3, p:2};
case 3: return {x:17*T-16, y:8*T-16, n:4, p:3};
case 4: return {x:9*T-16, y:42*T-16, n:5, p:4};
default: return null;
}
}
function zoneEnterPos(z){
switch(z){
case 0: return {x:2*T+T/2, y:8*T+T/2};
case 1: return {x:13*T+T/2, y:2*T+T/2};
case 2: return {x:3*T+T/2, y:2*T+T/2};
case 3: return {x:3*T+T/2, y:7*T+T/2};
case 4: return {x:8*T+T/2, y:1*T+T/2};
case 5: return {x:16*T+T/2, y:3*T+T/2};
}
}
function tryAdvance(){
const d=zoneDoorPos(); if(!d) return;
const p=game.player;
if(dist(p.x,p.y,d.x+16,d.y+16)>30) return;
if(d.n===game.zone+1){
loadZone(d.n);
const ep=zoneEnterPos(d.n);
p.x=ep.x; p.y=ep.y;
if(d.n>game.lastSaveZone) game.lastSaveZone=d.n; // forward progress is never barred
}
}
function toast(msg){ game.floaters.push({x:game.player.x,y:game.player.y-26,txt:msg,t:0,dur:2.4,c:'#cbb88a',size:11}); }
/* ================= PARTICLES & FX ================= */
function spark(x,y,n,cols,spd,life,grav){
for(let i=0;i<n;i++){
const a=rnd(0,6.283), s=rnd(spd*0.3,spd);
game.parts.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s-10,
t:0, life:rnd(life*0.6,life), c:cols[ri(0,cols.length-1)],
s:rnd(1.2,2.6), g:grav||40});
}
}
function floatText(x,y,txt,c,size){ game.floaters.push({x,y,txt,t:0,dur:1.1,c:c||'#e8d9b0',size:size||12}); }
function goldSwirl(x,y){
for(let i=0;i<10;i++){
const a=rnd(0,6.283);
game.parts.push({x:x+Math.cos(a)*8, y:y+Math.sin(a)*8,
vx:Math.cos(a)*20, vy:-rnd(20,60), t:0, life:rnd(0.5,0.9),
c:'#e8c67a', s:rnd(1.2,2.4), g:-20, gold:true, a:a});
}
}
function auraBurst(x,y){
for(let i=0;i<26;i++){
const a=rnd(0,6.283);
game.parts.push({x,y,vx:Math.cos(a)*rnd(10,50),vy:Math.sin(a)*rnd(10,50)-20,
t:0,life:rnd(0.8,1.4),c:i%2?'#fff3d0':'#e8c67a',s:rnd(1.5,3),g:-10});
}
}
function updateFx(dt){
for(let i=game.parts.length-1;i>=0;i--){
const p=game.parts[i];
p.t+=dt;
if(p.t>p.life){ game.parts.splice(i,1); continue; }
if(p.gold){ p.a+=dt*5; p.x+=Math.cos(p.a)*22*dt; p.y+=p.vy*dt; }
else { p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=(p.g||0)*dt; }
}
for(let i=game.floaters.length-1;i>=0;i--){
const f=game.floaters[i];
f.t+=dt; f.y-=22*dt;
if(f.t>f.dur) game.floaters.splice(i,1);
}
for(let i=game.banners.length-1;i>=0;i--){
const b=game.banners[i]; b.t+=dt;
if(b.t>b.dur) game.banners.splice(i,1);
}
game.shake=Math.max(0,game.shake-dt*22);
game.flash=Math.max(0,game.flash-dt*2.2);
game.edge=Math.max(0,game.edge-dt*0.5);
}
/* ================= PLAYER & COMBAT ================= */
function solidAt(x,y){
const {w,h}=ZONES[game.zone];
const tx=Math.floor(x/T), ty=Math.floor(y/T);
if(tx<0||ty<0||tx>=w||ty>=h) return true;
let g=game.maps[game.zone][ty*w+tx];
if(game.zone===3 && !game.vaultDoor && (tx===24)&&(ty===11||ty===12)) g=1;
return g===1||(g>=5&&g<=9);
}
function canStand(x,y,r){
return !solidAt(x-r,y-r)&&!solidAt(x+r,y-r)&&!solidAt(x-r,y+r)&&!solidAt(x+r,y+r);
}
function moveCircle(o, r, dt){
const ax=axisX(), ay=axisY();
if(!ax&&!ay) return;
const l=Math.hypot(ax,ay), sp=165;
let dx=ax/l*sp*dt, dy=ay/l*sp*dt;
if(dx){ const nx=o.x+dx; if(canStand(nx,o.y,r)) o.x=nx; }
if(dy){ const ny=o.y+dy; if(canStand(o.x,ny,r)) o.y=ny; }
}
function weapon(){ return WEAPONS[game.player.wpn]; }
function atkSpeedMul(){ return game.player.equipped.band?1.18:1; }
function tryAttack(){
const p=game.player;
if(p.dead||p.atkPhase!=='idle') return;
p.atkPhase='windup'; p.atkT=0; p.hitDone=false;
AudioSys.sfx('whoosh', p.wpn);
}
function swingActive(p){
return p.atkPhase==='swing';
}
function attackAngle(p){
if(p.atkPhase==='swing') return p.dir;
return p.dir;
}
function meleeHitEnemies(p, ang, range, arc, dmg, widx){
const w=WEAPONS[widx];
let hitAny=false;
for(const e of game.ents){
if(e.hp===undefined||e.hp<=0) continue;
const d=dist(p.x,p.y,e.x,e.y);
if(d>range+ (e.r||9)) continue;
const a=Math.atan2(e.y-p.y,e.x-p.x);
if(Math.abs(angDiff(a,ang))>arc/2) continue;
if(e.hitT!==undefined&&e.hitT>0) continue;
hitEnemy(e, dmg, a, p, w.burn, widx);
hitAny=true;
}
// breakable walls
const tx0=Math.floor(p.x/T), ty0=Math.floor(p.y/T);
for(let yy=ty0-2;yy<=ty0+2;yy++) for(let xx=tx0-2;xx<=tx0+2;xx++){
const w=ZONES[game.zone].w, h=ZONES[game.zone].h;
if(xx<1||yy<1||xx>=w-1||yy>=h-1) continue;
const g=game.maps[game.zone][yy*w+xx];
if(g>=5&&g<=9){
const cx=xx*T+T/2, cy=yy*T+T/2;
const a=Math.atan2(cy-p.y,cx-p.x);
if(Math.abs(angDiff(a,ang))<arc/2+0.4 && dist(p.x,p.y,cx,cy)<range+18){
strikeWall(game.zone,xx,yy,g,p);
}
}
}
return hitAny;
}
function wallMaxHp(v){ return v===9?4:(v===8?0:5); } // 0 = bolts only
function strikeWall(z,tx,ty,v,p){
const key=z+':'+tx+','+ty;
const pwr=p.wpn===2?3:(p.wpn===1?2:1);
if(v===8){
if(p.hasShard){ setWallBroken(z,tx,ty); }
else toast('The aether-vein hums beyond the blade’s reach. A shard must strike it.');
return;
}
if(v===9){
if(!p.hasShard){ toast('A hairline crack only — the blade skates off. A shard must open this.'); return; }
pwr=3;
}
const maxHp=wallMaxHp(v);
p._bw=p._bw||{};
p._bw[key]=(p._bw[key]==null?maxHp:p._bw[key])-pwr;
spark(tx*T+T/2, ty*T+T/2, 7, ['#b9a98c','#6a5a45'], 110, 0.35, 80);
AudioSys.sfx('tick');
if(p._bw[key]<=0){
setWallBroken(z,tx,ty);
delete p._bw[key];
}
}
function setWallBroken(z,tx,ty){
const w=ZONES[z].w, h=ZONES[z].h;
if(tx<0||ty<0||tx>=w||ty>=h) return;
const g=game.maps[z];
if(g[ty*w+tx]===0) return;
g[ty*w+tx]=0;
const key=z+':'+tx+','+ty;
if(!game.broken.includes(key)) game.broken.push(key);
spark(tx*T+T/2, ty*T+T/2, 18, ['#b9a98c','#8a7a5f','#d9c9a8'], 200, 0.7, 90);
AudioSys.sfx('break');
game.shake=Math.max(game.shake,2.5);
const p=game.player;
// loot conventions per zone
if(z===1){
if(tx===18||tx===19||tx===20) game.ents.push(E.chest(tx,ty,'heart'));
if(tx===9||tx===10||tx===11) game.ents.push(E.chest(tx,ty,'gold'));
}
if(z===2){
if(tx===26&&ty===7) game.ents.push(E.chest(26,7,'heart'));
if(tx===16) game.ents.push(E.chest(16,8,'stem'));
if(tx===20&&(ty===5||ty===6)) game.ents.push(E.chest(tx,ty,'heart'));
if(tx===23) game.ents.push(E.chest(tx,ty,'gold'));
}
if(z===3){
if((tx===5||tx===6)&&ty>=7) game.ents.push(E.chest(tx,ty,'heart'));
if(tx===23) game.ents.push(E.chest(tx,ty,'gold'));
if(tx===14) game.ents.push(E.chest(tx,ty,'shard'));
}
if(z===4){
// (hidden alcove loot placed directly — no wall drops here)
}
}
function hitEnemy(e, dmg, ang, p, burn, widx){
e.hp-=dmg; e.hitT=ENEMY_TYPES[e.type].flash;
e.vx=Math.cos(ang)*130; e.vy=Math.sin(ang)*130;
e.burnT=Math.max(e.burnT, burn);
spark(e.x,e.y, widx===2?10:6, widx===0?['#ffb45e','#ff8a3c']:['#ffd257','#fff3d0','#ff9d3c'], 150, 0.5, 60);
floatText(e.x, e.y-14, dmg, '#f2dfae', 12);
AudioSys.sfx('impact', widx);
if(e.hp<=0) killEnemy(e, p);
}
function killEnemy(e, p){
e.dieT=0.5; e.hp=0;
AudioSys.sfx('die');
spark(e.x,e.y,14,['#8a5a3a','#3a2a22','#ff9d3c'],180,0.7,80);
game.stats.kills++; p.kills++;
if(e.boss) game.shake=Math.max(game.shake,4);
if(game.zone===4 && e.type==='guardian'){
// drops Shadow Cloak if not held
const has=Object.values(p.equipped).includes('cloak')||p.inv.cloak;
if(!has) game.ents.push(E.chest(e.x/T-0.5, e.y/T-0.5,'cloak'));
}
}
function enemyGoldDrop(e){
goldSwirl(e.x,e.y);
game.player.gold+=e.gold;
game.stats.gold+=e.gold;
floatText(e.x, e.y-4, '+'+e.gold, '#e8c67a', 11);
AudioSys.sfx('coin');
}
function hurtPlayer(dmg, srcX, srcY, kind){
const p=game.player;
if(p.dead||p.invuln>0) return;
let d=dmg;
if(kind==='trap'&&p.equipped.cloak) d=Math.ceil(d/2);
p.hp-=d; p.invuln=0.9; p.hitFlash=1;
game.flash=0.6; game.shake=Math.max(game.shake,3.5);
spark(p.x,p.y,12,['#ff5a3c','#c0452e','#8a2a1a'],160,0.6,60);
floatText(p.x, p.y-16, '-'+d, '#ff8a6a', 13);
AudioSys.sfx('hurt');
const a=srcX!==undefined?Math.atan2(p.y-srcY,p.x-srcX):0;
p.x+=Math.cos(a)*6; p.y+=Math.sin(a)*6;
if(p.hp<=0){
p.hp=0; p.dead=true;
game.state='dead'; game.deathT=0;
AudioSys.sfx('death');
spark(p.x,p.y,30,['#ff8a3c','#c0452e','#8a8a8a'],220,1.1,90);
}
}
function addContainer(p, where){
if(p.containers>=5){ floatText(p.x,p.y-20,'The vigil is already full','#8f8672',11); return false; }
p.containers++; p.maxhp=p.containers*2; p.hp=p.maxhp;
p.aura=2; game.edge=2;
auraBurst(p.x,p.y);
AudioSys.sfx('heart');
toast('Heart Container restored — the vigil deepens ('+p.containers+'/5).');
return true;
}
function tryShoot(){
const p=game.player;
if(!p.hasShard){ toast('You feel for the Shard — but find only empty air.'); return; }
if(p.shardCharges<=0){ toast('The Aether Shard is dim. Seek the merchant’s restock (10g).'); return; }
if(p.atkPhase!=='idle') return;
p.shardCharges--;
const a=angN(p.dir);
game.bolts.push({x:p.x+Math.cos(a)*14, y:p.y+Math.sin(a)*14,
vx:Math.cos(a)*SHOT.speed, vy:Math.sin(a)*SHOT.speed, t:0, life:SHOT.life});
AudioSys.sfx('shoot');
}
function updateBolts(dt){
const p=game.player;
for(let i=game.bolts.length-1;i>=0;i--){
const b=game.bolts[i];
b.t+=dt;
if(b.t>b.life){ game.bolts.splice(i,1); continue; }
const steps=3;
let dead=false;
for(let s=0;s<steps&&!dead;s++){
b.x+=b.vx*dt/steps; b.y+=b.vy*dt/steps;
if(solidAt(b.x,b.y)){
spark(b.x,b.y,8,['#7fd4ff','#c8ecff'],140,0.4,0);
AudioSys.sfx('chime'); dead=true; break;
}
// crystals
for(const e of game.ents){
if(e.type!=='crystal'&&e.type!=='beacon') continue;
if(e.type==='beacon'&&e.lit) continue;
if(dist(b.x,b.y,e.x,e.y)<12){
hitCrystal(e); dead=true; break;
}
}
if(dead) break;
// enemies
for(const e of game.ents){
if(e.hp===undefined||e.hp<=0) continue;
if(dist(b.x,b.y,e.x,e.y)<(e.r||9)+3){
const a=Math.atan2(b.vy,b.vx);
hitEnemy(e, SHOT.dmg, a, p, WEAPONS[p.wpn].burn, p.wpn);
spark(b.x,b.y,8,['#7fd4ff','#c8ecff'],150,0.4,0);
AudioSys.sfx('chime'); dead=true; break;
}
}
}
if(dead) game.bolts.splice(i,1);
}
}
function hitCrystal(c){
if(c.hitT!==undefined) return;
AudioSys.sfx('rare');
spark(c.x,c.y,16,['#7fd4ff','#fff'],200,0.7,0);
if(game.zone===2){
// Gallery light-mechanism: the sealed door on the east wall
c.hitT=1;
const w=ZONES[2].w;
const key='2:27,7';
if(!game.broken.includes(key)) game.broken.push(key);
game.maps[2][7*w+27]=0;
spark(27*T+16,7*T+16,20,['#b9a98c','#8a7a5f'],180,0.7,80);
AudioSys.sfx('door');
toast('A distant mechanism answers — the gallery door seals open.');
return;
}
if(game.zone===5){
c.hitT=1;
const beacon=game.ents.find(e=>e.type==='beacon');
const unlit=game.ents.filter(e=>e.type==='crystal'&&e.hitT===undefined).length;
if(unlit<=0 && beacon){
beacon.lit=true; beacon.animT=0;
game.state='win'; game.winT=0;
game.edge=3; game.shake=5;
AudioSys.sfx('victory');
spark(beacon.x,beacon.y,60,['#ffd257','#fff3d0','#7fd4ff'],260,1.6,-30);
} else toast('The crystal answers — the ring brightens. ('+unlit+' remain)');
} else if(game.zone===3){
// Vault mechanism: opens the hidden lantern chamber on the east side
c.hitT=1;
game.vaultDoor=true;
const w=ZONES[3].w;
for(const ty of [11,12]){
const key='3:24,'+ty;
if(!game.broken.includes(key)) game.broken.push(key);
game.maps[3][ty*w+24]=0;
}
spark(24*T+16,12*T,20,['#b9a98c','#8a7a5f'],180,0.7,80);
AudioSys.sfx('door');
toast('Stone grinds aside — a hidden chamber yawns open on the east wall.');
}
}
function useItem(kind){
const p=game.player;
if(p.dead) return;
if(kind==='stem'){
if(p.inv.stem<=0){ AudioSys.sfx('empty'); return; }
if(p.hp>=p.maxhp){ toast('The vigil is already whole.'); return; }
p.inv.stem--;
p.hp=Math.min(p.maxhp,p.hp+4);
spark(p.x,p.y,14,['#8fd4a5','#d0ffe0'],120,0.8,-20);
AudioSys.sfx('item');
floatText(p.x,p.y-16,'+4','#8fd4a5',12);
} else if(kind==='seal'){
if(p.inv.seal<=0){ AudioSys.sfx('empty'); return; }
let best=null,bd=1e9;
for(const e of game.ents){
if(e.hp===undefined||e.hp<=0) continue;
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 is within reach of the seal.'); AudioSys.sfx('empty'); return; }
p.inv.seal--;
best.frozenT=3;
AudioSys.sfx('seal');
spark(best.x,best.y,16,['#7fd4ff','#c8ecff'],140,0.8,0);
floatText(best.x,best.y-16,'bound','#7fd4ff',11);
}
}
function useItemEnd(){}
/* ================= PLAYER UPDATE ================= */
function updatePlayer(dt){
const p=game.player;
if(p.dead){ game.deathT+=dt; updateFx(dt); return; }
const ax=axisX(), ay=axisY();
p.moving=(ax!==0||ay!==0);
if(p.moving){
const l=Math.hypot(ax,ay);
moveCircle(p, p.r, dt);
const na=Math.atan2(ay,ax);
p.dir=angN(p.dir+angDiff(na,p.dir)*Math.min(1,dt*12));
// footsteps
game.stepAcc+=dt;
const zone=game.zone;
if(game.stepAcc>0.34){
game.stepAcc=0;
AudioSys.sfx('step', zone===1?1:zone===3?2:zone===4?0:0);
}
// ember trail (upgraded blade)
if(p.wpn>=1){
game.emberAcc+=dt;
if(game.emberAcc>0.05){
game.emberAcc=0;
const back=p.dir+Math.PI;
game.parts.push({x:p.x+Math.cos(back)*10+rnd(-3,3), y:p.y+Math.sin(back)*10+rnd(-3,3),
vx:0,vy:-14,t:0,life:rnd(0.4,0.7),
c:p.wpn===1?'#ff8a3c':'#ffd257',s:rnd(1,2.2),g:-6});
}
}
if(p.hasShard && Math.random()<0.12){
const side=p.dir+Math.PI/2;
game.parts.push({x:p.x+Math.cos(side)*9, y:p.y+Math.sin(side)*9,
vx:0,vy:-10,t:0,life:0.5,c:'#7fd4ff',s:1.4,g:-8});
}
}
// attack phases
if(p.atkPhase!=='idle'){
p.atkT+=dt*atkSpeedMul();
const w=weapon();
if(p.atkPhase==='windup'&&p.atkT>=w.wind){
p.atkPhase='swing'; p.atkT=0;
meleeHitEnemies(p, p.dir, w.range, w.arc, w.dmg, p.wpn);
if(p.wpn===2){ game.edge=Math.max(game.edge,0.8); game.shake=Math.max(game.shake,2.5); }
} else if(p.atkPhase==='swing'){
if(p.atkT>=w.swing*0.5){
meleeHitEnemies(p, p.dir, w.range, w.arc, w.dmg*0.6, p.wpn);
}
if(p.atkT>=w.swing){ p.atkPhase='rec'; p.atkT=0; }
} else if(p.atkT>=w.rec){ p.atkPhase='idle'; p.atkT=0; }
}
p.invuln=Math.max(0,p.invuln-dt);
p.hitFlash=Math.max(0,p.hitFlash-dt*3);
p.aura=Math.max(0,p.aura-dt);
// traps
for(const e of game.ents){
if(e.type!=='trap') continue;
e.t+=dt;
const cyc=(e.t%2.4);
e.on=cyc>0.9&&cyc<1.5;
if(e.on&&dist(p.x,p.y,e.x,e.y)<13){
if(e.hitT<=0){
e.hitT=1;
hurtPlayer(1, e.x, e.y, 'trap');
if(p.equipped.cloak) floatText(p.x,p.y-30,'the cloak darkens','#b0a890',10);
}
}
e.hitT=Math.max(0,e.hitT-dt);
}
// interact hint
const hint=interactHint();
game.interactHint=hint;
}
function interactHint(){
const p=game.player;
for(const e of game.ents){
const d=dist(p.x,p.y,e.x,e.y);
if(e.type==='stone'&&d<30){
if(game.lastSaveZone===game.zone) return 'Rest at the Sanctuary Stone';
return 'The stone is quiet here';
}
if(e.type==='chest'&&d<26) return 'Open the chest';
if(e.type==='heart'&&d<24) return 'Take the heart';
if(e.type==='shard'&&d<24) return 'Claim the Aether Shard';
if(e.type==='merchant'&&d<30) return 'Barter with the merchant';
if(e.type==='altar'&&d<28&&!e.done) return e.kind==='u1'?'Approach the Weapon Altar':'Approach the altar';
if(e.type==='beacon'&&!e.lit&&game.zone===5&&d<60) return 'Kindle the Core Beacon (E)';
}
const d=zoneDoorPos();
if(d&&dist(p.x,p.y,d.x+16,d.y+16)<30) return 'Pass through the arch';
return '';
}
function tryInteract(){
const p=game.player;
for(const e of game.ents){
const d=dist(p.x,p.y,e.x,e.y);
if(e.type==='stone'&&d<30){
if(game.lastSaveZone!==game.zone){ toast('This stone has no memory here.'); AudioSys.sfx('empty'); }
else {
p.hp=p.maxhp;
doSave();
game.edge=Math.max(game.edge,1);
spark(e.x,e.y-10,20,['#ffd257','#fff3d0'],90,1,-10);
AudioSys.sfx('save');
toast('Progress kept. The vigil renews.');
}
return;
}
if(e.type==='chest'&&d<26){
openChest(e); return;
}
if(e.type==='heart'&&d<24){
const ok=addContainer(p);
if(ok||p.containers>=5) game.ents.splice(game.ents.indexOf(e),1);
return;
}
if(e.type==='shard'&&d<24){
p.hasShard=true; p.shardCharges=8;
game.ents.splice(game.ents.indexOf(e),1);
game.edge=2; p.aura=2;
auraBurst(p.x,p.y);
AudioSys.sfx('rare');
toast('The Aether Shard bonds to your off-hand. (K to fire)');
return;
}
if(e.type==='merchant'&&d<30){
openShop(); return;
}
if(e.type==='altar'&&d<28&&!e.done){
e.done=true;
const widx=(e.kind==='u2')?2:1;
p.wpn=widx;
game.edge=2; p.aura=2;
auraBurst(p.x,p.y);
spark(e.x,e.y-8,24,['#ff9d3c','#ffd257','#fff3d0'],180,0.9,20);
AudioSys.sfx('altar');
if(widx===1) toast('IGNITED EDGE — the blade drinks the ember. It burns, and strikes faster.');
else toast('SOLAR FALCHION — the blade becomes noonlight. Wider arc, heavier impact.');
return;
}
if(e.type==='beacon'&&!e.lit&&game.zone===5&&d<60){
const unlit=game.ents.filter(c=>c.type==='crystal'&&c.hitT===undefined).length;
if(unlit>0){ toast('The ring is dark. Strike its four crystals with the Shard ('+unlit+' remain).'); }
else { toast('The ring is dark.'); }
return;
}
}
const d=zoneDoorPos();
if(d&&dist(p.x,p.y,d.x+16,d.y+16)<30) tryAdvance();
}
function openChest(e){
if(e.openT>0) return;
e.openT=0.6;
AudioSys.sfx('item');
spark(e.x,e.y-6,14,['#fff3d0','#e8c67a'],110,0.7,-20);
const p=game.player;
const loot=e.type;
if(loot==='gold'){
const g=ri(20,40);
p.gold+=g; game.stats.gold+=g;
goldSwirl(e.x,e.y);
floatText(e.x,e.y-16,'+'+g,'#e8c67a',12);
AudioSys.sfx('coin');
} else if(loot==='heart'){
const ok=addContainer(p);
if(!ok){ p.gold+=30; goldSwirl(e.x,e.y); floatText(e.x,e.y-16,'+30g','#e8c67a',11); }
} else if(loot==='stem'||loot==='seal'){
p.inv[loot]++;
AudioSys.sfx('item');
toast(ITEMS[loot].name+' taken.');
} else if(loot==='shard'){
p.hasShard=true; p.shardCharges=8;
game.edge=2; p.aura=2;
auraBurst(p.x,p.y);
AudioSys.sfx('rare');
toast('The Aether Shard bonds to your off-hand. (K to fire)');
} else if(ACCS.includes(loot)){
p.equipped[loot]=true;
AudioSys.sfx('rare');
toast(ITEMS[loot].name+' equipped — '+(loot==='band'?'strike faster.':loot==='lantern'?'hidden cracks glow blue in the dark.':'traps lose half their teeth.'));
}
}
/* ================= ENEMIES ================= */
function updateEnemies(dt){
const p=game.player;
for(const e of game.ents){
if(e.hp===undefined) continue;
e.hitT=Math.max(0,e.hitT-dt);
if(e.frozenT>0){ e.frozenT-=dt; e.burnT=0; continue; }
if(e.burnT>0){
e.burnT-=dt;
e.burnAcc=(e.burnAcc||0)+dt;
if(Math.random()<0.3) game.parts.push({x:e.x+rnd(-4,4),y:e.y+rnd(-6,2),vx:0,vy:-20,t:0,life:0.4,c:'#ff9d3c',s:1.4,g:-40});
if(e.burnAcc>0.5){ e.burnAcc=0; e.hp-=1; if(e.hp<=0&&e.dieT===0){ killEnemy(e,p); } }
}
if(e.hp<=0){
if(e.dieT>0){ e.dieT-=dt; if(e.dieT<=0&&!e.goldGiven){ e.goldGiven=true; enemyGoldDrop(e); } }
continue;
}
const d=dist(p.x,p.y,e.x,e.y);
const dDef=ENEMY_TYPES[e.type];
// touch damage
if(d<e.r+p.r+2 && e.touchCdT<=0 && !p.dead){
hurtPlayer(dDef.dmg, e.x, e.y, 'melee');
e.touchCdT=dDef.touchCd;
}
e.touchCdT=Math.max(0,(e.touchCdT||0)-dt);
if(p.dead) continue;
// behavior
if(e.type==='crawler'||e.type==='sentinel'){
if(d<400){
const a=Math.atan2(p.y-e.y,p.x-e.x);
e.ia=angN(e.ia!==undefined?e.ia:0);
e.vx=lerp(e.vx, Math.cos(a)*dDef.sp, dt*3);
e.vy=lerp(e.vy, Math.sin(a)*dDef.sp, dt*3);
e.face=angN((e.face!==undefined?e.face:0)+angDiff(a,e.face)*dt*6);
}
if(d<700){ /* stay */ }
}
else if(e.type==='warden'){
if(e.atkT>0){
e.atkT-=dt;
if(e.cd2){ // lunge in progress
e.vx=e.cd2.vx; e.vy=e.cd2.vy;
if(!e.cd2.hit){
e.cd2.hit=dist(p.x,p.y,e.x,e.y)<26;
if(e.cd2.hit&&p.invuln<=0&&!p.dead){ hurtPlayer(1,e.x,e.y,'melee'); }
}
if(Math.hypot(e.vx,e.vy)<10) e.cd2=null;
}
} else if(d<320){
const a=Math.atan2(p.y-e.y,p.x-e.x);
e.face=angN((e.face!==undefined?e.face:0)+angDiff(a,e.face)*dt*5);
e.atkT=0.7+rnd(0,0.3);
const sp=dDef.sp*4.2;
e.cd2={vx:Math.cos(a)*sp, vy:Math.sin(a)*sp, hit:false};
AudioSys.sfx('charge');
spark(e.x,e.y,6,['#7fd4ff'],90,0.3,0);
}
}
else if(e.type==='guardian'){
if(e.atkT>0){
e.atkT-=dt;
if(e.atkT<=0) e.atkC=false;
if(e.cd2){
e.vx=e.cd2.vx; e.vy=e.cd2.vy;
e.cd2.t-=dt;
if(e.cd2.t<=0){ e.cd2=null; e.vx*=0.3; e.vy*=0.3; }
}
} else if(d<420){
const a=Math.atan2(p.y-e.y,p.x-e.x);
e.face=angN((e.face!==undefined?e.face:0)+angDiff(a,e.face)*dt*4);
if(!e.atkC){
e.atkT=0.9;
e.cd2={vx:Math.cos(a)*dDef.sp*4, vy:Math.sin(a)*dDef.sp*4, t:0.5};
e.atkC=true;
AudioSys.sfx('boss');
spark(e.x,e.y,10,['#ffd257','#ff9d3c'],120,0.5,0);
}
}
}
// integrate
if(e.frozenT<=0){
e.x+=e.vx*dt; e.y+=e.vy*dt;
e.vx*=Math.pow(0.0009,dt); e.vy*=Math.pow(0.0009,dt);
}
// collide with walls (slide)
if(!canStand(e.x,e.y,e.r)){
if(canStand(e.x,e.y- (e.vy>0?e.r*2:-e.r*2), e.r)) e.y=e.y-(e.vy>0?e.r*2:-e.r*2);
else { e.x-=e.vx*dt; e.y-=e.vy*dt; }
}
}
// enemy-enemy separation
const list=game.ents.filter(e=>e.hp!==undefined&&e.hp>0);
for(let i=0;i<list.length;i++) for(let j=i+1;j<list.length;j++){
const a=list[i], b=list[j];
const d=dist(a.x,a.y,b.x,b.y);
const min=(a.r+b.r)*0.8;
if(d<min&&d>0.01){
const push=(min-d)/2;
const nx=(b.x-a.x)/d, ny=(b.y-a.y)/d;
a.x-=nx*push; a.y-=ny*push;
b.x+=nx*push; b.y+=ny*push;
}
}
}
/* ================= UI: SHOP & INVENTORY ================= */
function openShop(){
game.state='shop';
renderShop();
$('ovShop').hidden=false;
AudioSys.droneOn();
}
function renderShop(){
const p=game.player, st=game.shopStock;
$('shopGold').innerHTML=' '+p.gold+'<span class="price"> golden sigils</span>';
const rows=[];
rows.push({k:'stem', nm:'Vital Stem', ds:'Restores 2 full hearts (4 HP).', price:50,
avail:st.stem>0, note:st.stem>0?('left: '+st.stem):'sold out'});
rows.push({k:'seal', nm:'Binding Seal', ds:'Freezes one construct for ~3 seconds.', price:75,
avail:st.seal>0, note:st.seal>0?('left: '+st.seal):'sold out'});
const canHeart=p.containers<5;
rows.push({k:'heart', nm:'Heart Container ('+p.containers+'/5)', ds:'Widens the vigil to '+(p.containers+1)+' heart containers. The price of permanence.',
price:300, avail:st.heart&&canHeart, note:!canHeart?'the vigil is already full':(st.heart?'one available':'already taken')});
const canAmmo=p.hasShard;
rows.push({k:'ammo', nm:'Aether Charge', ds:'Restock the Shard: +1 charge per purchase.', price:10,
avail:canAmmo, note:canAmmo?'unlimited':('requires the Aether Shard (Zone 4)')});
const body=$('shopBody');
body.innerHTML='';
for(const r of rows){
const div=document.createElement('div'); div.className='row';
div.innerHTML='<div><div class="nm">'+r.nm+'</div><div class="ds">'+r.ds+' — <i>'+r.note+'</i></div></div>'+
'<div style="text-align:right"><div class="price">'+r.price+'g</div>'+
'<button class="btn small" '+(r.avail&&p.gold>=r.price?'':'disabled')+'>'+
(r.avail?'Buy':'—')+'</button></div>';
const b=div.querySelector('button');
if(r.avail){
b.addEventListener('click',()=>buyItem(r.k,r.price));
}
body.appendChild(div);
}
}
function buyItem(k,price){
const p=game.player, st=game.shopStock;
if(p.gold<price){ AudioSys.sfx('empty'); return; }
p.gold-=price;
if(k==='stem'){ st.stem--; p.inv.stem++; }
else if(k==='seal'){ st.seal--; p.inv.seal++; }
else if(k==='heart'){ st.heart=false; addContainer(p,'shop'); }
else if(k==='ammo'){ p.shardCharges++; }
AudioSys.sfx('buy');
renderShop();
}
function renderInv(){
const p=game.player;
const body=$('invBody');
let html='<div class="mnote" style="margin-top:0;margin-bottom:8px">Weapon — <b style="color:#e8c67a">'+WEAPONS[p.wpn].name+'</b>'+
(p.wpn===1?' <i>(fire/ember: burn, faster)</i>':p.wpn===2?' <i>(light/solar: wide arc, heavy)</i>':'')+'</div>';
for(const a of ACCS){
const eq=p.equipped[a];
html+='<div class="row"><div><div class="nm">'+ITEMS[a].name+'<span class="tag '+(eq?'on':'off')+'">'+(eq?'equipped':'')+'</span></div>'+
'<div class="ds">'+ITEMS[a].desc+'</div></div>'+
'<button class="btn small invEq" data-a="'+a+'">'+(eq?'Unequip':'Equip')+'</button></div>';
}
html+='<div class="mnote" style="margin:10px 0 4px">Consumables</div>';
html+='<div class="row"><div><div class="nm">Vital Stem <span class="tag off">×'+p.inv.stem+'</span> <span class="tag off">1</span></div>'+
'<div class="ds">'+ITEMS.stem.desc+'</div></div>'+
'<button class="btn small invUse" data-k="stem">Use</button></div>';
html+='<div class="row"><div><div class="nm">Binding Seal <span class="tag off">×'+p.inv.seal+'</span> <span class="tag off">2</span></div>'+
'<div class="ds">'+ITEMS.seal.desc+'</div></div>'+
'<button class="btn small invUse" data-k="seal">Use</button></div>';
if(p.hasShard){
html+='<div class="row"><div><div class="nm">Aether Shard <span class="tag off">×'+p.shardCharges+' charges</span></div>'+
'<div class="ds">A crystalline launcher. Fire with <b>K</b> or <b>X</b> — strikes distant switches and weak barriers. Restock at the merchant (10g).</div></div></div>';
} else {
html+='<div class="row"><div><div class="nm">Aether Shard</div><div class="ds">Not yet found. It sleeps in the Sunken Vault (Zone 4), behind a wall of hairline cracks.</div></div></div>';
}
html+='<div class="mnote">The merchant keeps the archives’ heart (Zone 2). Sanctuary Stones hold your progress.</div>';
body.innerHTML=html;
body.querySelectorAll('.invEq').forEach(b=>b.addEventListener('click',()=>{
const a=b.dataset.a; p.equipped[a]=!p.equipped[a];
AudioSys.sfx('item'); renderInv();
}));
body.querySelectorAll('.invUse').forEach(b=>b.addEventListener('click',()=>{
useItem(b.dataset.k); renderInv();
}));
}
function closeInv(){ $('ovInv').hidden=true; game.state='playing'; }
/* ================= RENDER ================= */
const PAL=[
{floor:'#2a2621', floor2:'#242019', wall:'#57452c', wall2:'#3a2f1e', amb:'rgba(214,160,90,0.05)'},
{floor:'#25221c', floor2:'#201d18', wall:'#4c3f2e', wall2:'#332a1e', amb:'rgba(200,150,80,0.04)'},
{floor:'#282429', floor2:'#232024', wall:'#4a4152', wall2:'#322b38', amb:'rgba(170,140,200,0.035)'},
{floor:'#1e262d', floor2:'#1a2127', wall:'#3d4c59', wall2:'#2a353f', amb:'rgba(110,170,220,0.05)'},
{floor:'#26232e', floor2:'#211e28', wall:'#463d52', wall2:'#2f2937', amb:'rgba(150,130,200,0.04)'},
{floor:'#231f2b', floor2:'#1e1b25', wall:'#4b3f5a', wall2:'#332a40', amb:'rgba(255,200,120,0.05)'}
];
function hash2(x,y){ let h=x*374761393+y*668265263; h=(h^(h>>13))*1274126177; return ((h^(h>>16))>>>0)/4294967295; }
function camera(){
const {w,h}=ZONES[game.zone];
const wpx=w*T, hpx=h*T;
const cx = wpx<CW ? (wpx-CW)/2 : clamp(game.player.x-CW/2, 0, wpx-CW);
const cy = hpx<CH ? (hpx-CH)/2 : clamp(game.player.y-CH/2, 0, hpx-CH);
return {x:cx, y:cy};
}
function render(){
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,CW,CH);
const pal=PAL[game.zone]||PAL[0];
// base
ctx.fillStyle='#070a10'; ctx.fillRect(0,0,CW,CH);
if(game.state==='title'){ renderTitle(); return; }
if(!game.player) return;
const cam=camera();
const sh=game.shake;
const ox=-cam.x + (sh?rnd(-sh,sh):0), oy=-cam.y + (sh?rnd(-sh,sh):0);
ctx.save();
ctx.translate(ox,oy);
drawFloor(pal,cam);
drawPlatesAndTraps();
drawWorldEntities();
drawEnemies();
drawPlayer();
drawBolts();
drawParticles();
drawWalls(pal,cam);
drawFloaters();
ctx.restore();
// lantern glow (screen)
if(game.player.equipped.lantern){
const p=game.player;
const px=p.x-cam.x, py=p.y-cam.y;
const g=ctx.createRadialGradient(px,py,20,px,py,150);
g.addColorStop(0,'rgba(90,168,220,0.14)'); g.addColorStop(1,'rgba(90,168,220,0)');
ctx.fillStyle=g; ctx.fillRect(px-160,py-160,320,320);
}
// ambient zone tint
ctx.fillStyle=pal.amb; ctx.fillRect(0,0,CW,CH);
drawVignette(pal);
drawHUD();
drawEdgeFx();
drawBanner();
}
function renderTitle(){
// quiet attract scene
const t=game.time;
const g=ctx.createLinearGradient(0,0,0,CH);
g.addColorStop(0,'#0a0f18'); g.addColorStop(0.6,'#141019'); g.addColorStop(1,'#070a10');
ctx.fillStyle=g; ctx.fillRect(0,0,CW,CH);
// gear ring
ctx.save(); ctx.translate(CW/2, CH/2+30);
for(let i=0;i<9;i++){
const a=t*0.08+i*6.283/9;
ctx.save(); ctx.translate(Math.cos(a)*210, Math.sin(a)*210*0.55);
drawGear(26, i*0.7+t*(i%2?0.3:-0.3), 'rgba(160,120,60,0.25)');
ctx.restore();
}
drawGear(64, -t*0.12, 'rgba(200,150,70,0.3)');
ctx.restore();
// falling ash
for(let i=0;i<40;i++){
const x=(hash2(i,7)*CW + t*12*(1+hash2(i,3)))%CW;
const y=(hash2(i,11)*CH + t*24*(1+hash2(i,5)))%CH;
ctx.fillStyle='rgba(200,170,120,'+(0.05+hash2(i,9)*0.12)+')';
ctx.fillRect(x,y,1.6,1.6);
}
}
function drawGear(r, rot, col){
ctx.save(); ctx.rotate(rot);
ctx.strokeStyle=col; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,0,r,0,6.283); ctx.stroke();
for(let i=0;i<8;i++){
const a=i*6.283/8;
ctx.beginPath();
ctx.moveTo(Math.cos(a)*r, Math.sin(a)*r);
ctx.lineTo(Math.cos(a)*(r+6), Math.sin(a)*(r+6));
ctx.stroke();
}
ctx.restore();
}
function drawFloor(pal,cam){
const {w,h}=ZONES[game.zone];
const x0=Math.max(0,Math.floor(cam.x/T)-1), x1=Math.min(w-1,Math.ceil((cam.x+CW)/T)+1);
const y0=Math.max(0,Math.floor(cam.y/T)-1), y1=Math.min(h-1,Math.ceil((cam.y+CH)/T)+1);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){
const g=game.maps[game.zone][y*w+x];
if(g===1) continue;
ctx.fillStyle=(x+y)%2?pal.floor:pal.floor2;
ctx.fillRect(x*T,y*T,T,T);
if(hash2(x,y)<0.16){
ctx.fillStyle='rgba(0,0,0,0.14)';
ctx.fillRect(x*T+6,y*T+6,10,3);
}
if(g>=3&&g<=7) drawDecoTile(g,x,y);
if(g>=5&&g<=9) drawCracks(g,x,y);
if(g===2) drawDoorTile(x,y);
}
}
function drawDecoTile(v,x,y){
const px=x*T, py=y*T;
ctx.save(); ctx.translate(px+16,py+16);
if(v===3){ // pillar base
ctx.fillStyle='rgba(0,0,0,0.25)'; ctx.fillRect(-10,-10,20,20);
ctx.strokeStyle='rgba(180,140,80,0.5)'; ctx.lineWidth=2;
ctx.strokeRect(-8,-8,16,16);
ctx.fillStyle='rgba(140,110,60,0.4)'; ctx.fillRect(-4,-4,8,8);
} else if(v===4){ // rubble
ctx.fillStyle='rgba(0,0,0,0.22)';
ctx.beginPath(); ctx.moveTo(-10,8); ctx.lineTo(0,-6); ctx.lineTo(10,8); ctx.closePath(); ctx.fill();
ctx.fillStyle='rgba(120,100,70,0.3)'; ctx.fillRect(-3,0,6,4);
} else if(v===6){ // grate
ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.fillRect(-11,-11,22,22);
ctx.strokeStyle='rgba(150,120,70,0.5)'; ctx.lineWidth=1.5;
for(let i=-8;i<=8;i+=4){ ctx.beginPath(); ctx.moveTo(i,-10); ctx.lineTo(i,10); ctx.stroke(); }
} else if(v===7){ // mosaic
ctx.strokeStyle='rgba(190,150,80,0.35)'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.arc(0,0,9,0,6.283); ctx.stroke();
ctx.beginPath(); ctx.arc(0,0,4,0,6.283); ctx.stroke();
}
ctx.restore();
}
function drawCracks(v,x,y){
const px=x*T+16, py=y*T+16;
const strong=(v===9)?(game.player.equipped.lantern?0.9:0.28):0.7;
const base=v===8;
ctx.save(); ctx.translate(px,py);
if(base){
ctx.fillStyle='rgba(70,110,160,0.12)'; ctx.fillRect(-14,-14,28,28);
const gl=0.4+0.3*Math.sin(game.time*3+x*2+y);
ctx.strokeStyle='rgba(127,212,255,'+(0.3+gl*0.4)+')';
} else {
ctx.strokeStyle='rgba(20,12,8,'+strong+')';
}
ctx.lineWidth=base?1:1.4;
ctx.beginPath();
ctx.moveTo(-10,-12); ctx.lineTo(-3,-4); ctx.lineTo(-7,4); ctx.lineTo(2,12);
ctx.moveTo(-3,-4); ctx.lineTo(6,-8); ctx.lineTo(12,-2);
ctx.moveTo(-7,4); ctx.lineTo(4,7);
ctx.stroke();
if(game.player.equipped.lantern){
ctx.strokeStyle='rgba(127,212,255,0.75)'; ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(-10,-12); ctx.lineTo(-3,-4); ctx.lineTo(-7,4); ctx.lineTo(2,12);
ctx.stroke();
}
ctx.restore();
}
function drawDoorTile(x,y){
const px=x*T, py=y*T;
ctx.save(); ctx.translate(px+16,py+16);
ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillRect(-12,-15,24,30);
const d=zoneDoorPos();
const locked=d&&d.p>game.lastSaveZone;
const gl=0.5+0.4*Math.sin(game.time*2);
ctx.strokeStyle=locked?'rgba(90,80,90,0.8)':'rgba(230,180,90,'+(0.5+gl*0.4)+')';
ctx.lineWidth=3;
ctx.beginPath();
ctx.moveTo(-12,14); ctx.lineTo(-12,-6); ctx.arc(0,-6,12,Math.PI,0); ctx.lineTo(12,14);
ctx.stroke();
ctx.restore();
}
function drawPlatesAndTraps(){
for(const e of game.ents){
if(e.type==='plate'){
const p=game.player;
const pressed=dist(p.x,p.y,e.x,e.y)<16;
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(-13,-13,26,26);
ctx.fillStyle=pressed?'#7a5c2e':'#5c4a2c';
ctx.beginPath(); ctx.arc(0,0,11,0,6.283); ctx.fill();
ctx.strokeStyle='rgba(230,180,90,0.7)'; ctx.lineWidth=1.5; ctx.stroke();
if(pressed){
const g=ctx.createRadialGradient(0,0,2,0,0,18);
g.addColorStop(0,'rgba(255,210,120,0.35)'); g.addColorStop(1,'rgba(255,210,120,0)');
ctx.fillStyle=g; ctx.fillRect(-20,-20,40,40);
}
ctx.restore();
}
else if(e.type==='trap'){
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.fillRect(-13,-13,26,26);
ctx.strokeStyle='rgba(150,60,40,0.8)'; ctx.lineWidth=1.5;
ctx.strokeRect(-13,-13,26,26);
if(e.on){
ctx.fillStyle='#a03a28';
for(let i=-1;i<=1;i++){
ctx.beginPath();
ctx.moveTo(i*8-3,10); ctx.lineTo(i*8,-8- (i===0?4:0)); ctx.lineTo(i*8+3,10);
ctx.closePath(); ctx.fill();
}
}
ctx.restore();
}
}
}
function drawWorldEntities(){
const p=game.player;
for(const e of game.ents){
switch(e.type||e.kind){
case 'chest': drawChest(e); break;
case 'heart': drawHeartItem(e); break;
case 'shard': drawShardItem(e); break;
case 'stone': drawStone(e); break;
case 'altar': drawAltar(e); break;
case 'merchant': drawMerchant(e); break;
case 'crystal': drawCrystal(e); break;
case 'beacon': drawBeacon(e); break;
}
}
}
function drawChest(e){
const open=e.openT>0||e.opened;
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(0,8,10,4,0,0,6.283); ctx.fill();
ctx.fillStyle=open?'#5c4a2e':'#6a5636';
ctx.fillRect(-9,-6,18,12);
ctx.fillStyle=open?'#7a6542':'#8a6a3f';
ctx.fillRect(-9,-10,18,5);
ctx.strokeStyle='#d9a441'; ctx.lineWidth=1.5;
ctx.strokeRect(-9,-10,18,16);
ctx.fillStyle='#f0d9a0'; ctx.fillRect(-1.5,-6,3,6);
if(!open){
const gl=0.3+0.3*Math.sin(game.time*2.5+e.x);
const g=ctx.createRadialGradient(0,0,2,0,0,20);
g.addColorStop(0,'rgba(255,230,160,'+gl*0.4+')'); g.addColorStop(1,'rgba(255,230,160,0)');
ctx.fillStyle=g; ctx.fillRect(-22,-22,44,44);
}
ctx.restore();
}
function drawHeartItem(e){
ctx.save(); ctx.translate(e.x,e.y+Math.sin(game.time*3+e.x)*3);
heartPath(0,0,11);
ctx.fillStyle='#c0452e'; ctx.fill();
ctx.strokeStyle='#f0d9a0'; ctx.lineWidth=1.5; ctx.stroke();
const g=ctx.createRadialGradient(0,0,2,0,0,18);
g.addColorStop(0,'rgba(255,120,90,0.5)'); g.addColorStop(1,'rgba(255,120,90,0)');
ctx.fillStyle=g; ctx.fillRect(-20,-20,40,40);
ctx.restore();
}
function drawShardItem(e){
ctx.save(); ctx.translate(e.x,e.y+Math.sin(game.time*2.6+e.x)*3);
ctx.rotate(game.time*0.8);
ctx.beginPath(); ctx.moveTo(0,-10); ctx.lineTo(7,0); ctx.lineTo(0,10); ctx.lineTo(-7,0); ctx.closePath();
ctx.fillStyle='#7fd4ff'; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke();
const g=ctx.createRadialGradient(0,0,2,0,0,22);
g.addColorStop(0,'rgba(127,212,255,0.5)'); g.addColorStop(1,'rgba(127,212,255,0)');
ctx.fillStyle=g; ctx.fillRect(-24,-24,48,48);
ctx.restore();
}
function drawStone(e){
const active=game.lastSaveZone===game.zone;
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(0,12,14,5,0,0,6.283); ctx.fill();
ctx.fillStyle=active?'#5a5268':'#3c3844';
ctx.fillRect(-8,-4,16,16);
ctx.beginPath(); ctx.moveTo(-9,-4); ctx.lineTo(9,-4); ctx.lineTo(6,-10); ctx.lineTo(-6,-10); ctx.closePath(); ctx.fill();
const gl=active?(0.5+0.4*Math.sin(game.time*2.4)):0.15;
ctx.beginPath(); ctx.moveTo(0,-24); ctx.lineTo(6,-12); ctx.lineTo(0,-4); ctx.lineTo(-6,-12); ctx.closePath();
ctx.fillStyle='rgba(255,210,120,'+(0.35+gl*0.5)+')'; ctx.fill();
ctx.strokeStyle='#f0d9a0'; ctx.lineWidth=1; ctx.stroke();
const g=ctx.createRadialGradient(0,-14,4,0,-14,40);
g.addColorStop(0,'rgba(255,210,120,'+gl*0.25+')'); g.addColorStop(1,'rgba(255,210,120,0)');
ctx.fillStyle=g; ctx.fillRect(-44,-58,88,88);
ctx.restore();
if(active && Math.random()<0.08){
game.parts.push({x:e.x+rnd(-10,10), y:e.y-rnd(4,26), vx:rnd(-6,6), vy:-rnd(8,20),
t:0, life:rnd(0.6,1.2), c:'#f0d9a0', s:rnd(1,2), g:-6});
}
}
function drawAltar(e){
const u2=e.kind==='u2';
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.ellipse(0,10,14,5,0,0,6.283); ctx.fill();
ctx.fillStyle='#4a4256'; ctx.fillRect(-10,-2,20,12);
ctx.fillStyle='#5a5268'; ctx.beginPath(); ctx.ellipse(0,-2,11,4,0,0,6.283); ctx.fill();
const fl=e.done?1:0.7;
const col=u2?'255,210,90':'255,157,60';
const flh=(8+3*Math.sin(game.time*6))*(1+fl*0.3);
ctx.beginPath(); ctx.moveTo(0,-4-flh); ctx.quadraticCurveTo(6,-4-flh*0.4,0,-2); ctx.quadraticCurveTo(-6,-4-flh*0.4,0,-4-flh);
ctx.fillStyle='rgba('+col+','+(0.5+fl*0.3)+')'; ctx.fill();
if(e.done){
const g=ctx.createRadialGradient(0,-8,4,0,-8,36);
g.addColorStop(0,'rgba('+col+',0.3)'); g.addColorStop(1,'rgba('+col+',0)');
ctx.fillStyle=g; ctx.fillRect(-40,-50,80,80);
}
ctx.restore();
}
function drawMerchant(e){
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(0,10,12,4,0,0,6.283); ctx.fill();
ctx.fillStyle='#3a4256';
ctx.beginPath(); ctx.moveTo(-8,10); ctx.lineTo(-5,-8); ctx.lineTo(5,-8); ctx.lineTo(8,10); ctx.closePath(); ctx.fill();
ctx.fillStyle='#222838'; ctx.beginPath(); ctx.arc(0,-11,5,0,6.283); ctx.fill();
ctx.fillStyle='#d9a441'; ctx.fillRect(-2,-12,4,2);
const gl=0.4+0.3*Math.sin(game.time*3);
ctx.fillStyle='rgba(230,180,90,'+gl+')';
ctx.beginPath(); ctx.arc(0,-20,2.5,0,6.283); ctx.fill();
const g=ctx.createRadialGradient(0,-6,6,0,-6,44);
g.addColorStop(0,'rgba(120,160,220,0.10)'); g.addColorStop(1,'rgba(120,160,220,0)');
ctx.fillStyle=g; ctx.fillRect(-48,-54,96,96);
ctx.restore();
}
function drawCrystal(e){
const lit=game.zone===5&&e.hitT!==undefined;
ctx.save(); ctx.translate(e.x,e.y+Math.sin(game.time*2+e.x)*2);
ctx.beginPath(); ctx.moveTo(0,-14); ctx.lineTo(8,0); ctx.lineTo(0,14); ctx.lineTo(-8,0); ctx.closePath();
ctx.fillStyle=lit?'#fff3d0':'#7fd4ff'; ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.stroke();
const g=ctx.createRadialGradient(0,0,3,0,0,26);
g.addColorStop(0,lit?'rgba(255,240,200,0.5)':'rgba(127,212,255,0.45)');
g.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g; ctx.fillRect(-28,-28,56,56);
ctx.restore();
}
function drawBeacon(e){
ctx.save(); ctx.translate(e.x,e.y);
ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.beginPath(); ctx.ellipse(0,14,18,6,0,0,6.283); ctx.fill();
ctx.fillStyle='#3a3248'; ctx.fillRect(-8,-14,16,26);
ctx.strokeStyle='#d9a441'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,-16,9,0,6.283); ctx.stroke();
drawGear(7, game.time*(e.lit?2:0.3), e.lit?'rgba(255,220,140,0.9)':'rgba(160,120,60,0.5)');
if(e.lit){
e.animT+=0.016;
const h=140+20*Math.sin(e.animT*2);
const g=ctx.createLinearGradient(0,-16,0,-16-h);
g.addColorStop(0,'rgba(255,220,140,0.85)'); g.addColorStop(1,'rgba(255,220,140,0)');
ctx.fillStyle=g;
ctx.beginPath(); ctx.moveTo(-7,-16); ctx.lineTo(7,-16); ctx.lineTo(16,-16-h); ctx.lineTo(-16,-16-h); ctx.closePath(); ctx.fill();
if(Math.random()<0.5) game.parts.push({x:e.x+rnd(-8,8), y:e.y-rnd(16,60), vx:rnd(-10,10), vy:-rnd(20,50),
t:0,life:rnd(0.5,1),c:'#ffd257',s:rnd(1,2.5),g:-20});
}
ctx.restore();
}
function heartPath(x,y,s){
ctx.beginPath();
ctx.moveTo(x, y+s*0.32);
ctx.bezierCurveTo(x, y, x-s*0.52, y, x-s*0.52, y+s*0.34);
ctx.bezierCurveTo(x-s*0.52, y+s*0.62, x-s*0.1, y+s*0.8, x, y+s*0.95);
ctx.bezierCurveTo(x+s*0.1, y+s*0.8, x+s*0.52, y+s*0.62, x+s*0.52, y+s*0.34);
ctx.bezierCurveTo(x+s*0.52, y, x, y, x, y+s*0.32);
ctx.closePath();
}
function drawEnemies(){
const p=game.player;
for(const e of game.ents){
if(e.hp===undefined) continue;
const alive=e.hp>0;
if(!alive && e.dieT<=0) continue;
const def=ENEMY_TYPES[e.type];
const alpha=!alive?clamp(e.dieT*2,0,1):1;
const bob=Math.sin(game.time*7+e.sx)* (Math.hypot(e.vx,e.vy)>8?1.8:0.8);
ctx.save(); ctx.translate(e.x, e.y+bob*0.5); ctx.globalAlpha=alpha;
const face=e.face!==undefined?e.face:(p?Math.atan2(p.y-e.y,p.x-e.x):0);
if(e.type==='crawler'){
ctx.strokeStyle='#2c221a'; ctx.lineWidth=1.6;
for(let i=0;i<3;i++){
const la=game.time*10+e.sx+i;
ctx.beginPath(); ctx.moveTo(-6,2+i*3); ctx.lineTo(-10-Math.abs(Math.sin(la))*4, 5+i*3); ctx.stroke();
ctx.beginPath(); ctx.moveTo(6,2+i*3); ctx.lineTo(10+Math.abs(Math.sin(la+1))*4, 5+i*3); ctx.stroke();
}
ctx.fillStyle=ECOL[0][0];
ctx.beginPath(); ctx.ellipse(0,0,9,7,0,0,6.283); ctx.fill();
ctx.strokeStyle=ECOL[0][1]; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle=def.eye; ctx.beginPath(); ctx.arc(Math.cos(face)*4, Math.sin(face)*4-2,2,0,6.283); ctx.fill();
}
else if(e.type==='sentinel'){
ctx.fillStyle=ECOL[1][0];
ctx.fillRect(-7,-11,14,20);
ctx.strokeStyle=ECOL[1][1]; ctx.lineWidth=1.6; ctx.strokeRect(-7,-11,14,20);
ctx.fillStyle='#3a4252';
ctx.fillRect(Math.cos(face)*4-5, Math.sin(face)*4-6, 10, 14);
ctx.fillStyle=def.eye; ctx.beginPath(); ctx.arc(Math.cos(face)*3, Math.sin(face)*3-4,2.2,0,6.283); ctx.fill();
}
else if(e.type==='warden'){
ctx.fillStyle=ECOL[2][0];
ctx.beginPath(); ctx.moveTo(-8,12); ctx.lineTo(-5,-10); ctx.lineTo(5,-10); ctx.lineTo(8,12); ctx.closePath(); ctx.fill();
ctx.strokeStyle=ECOL[2][1]; ctx.lineWidth=1.6; ctx.stroke();
ctx.fillStyle='#221a14'; ctx.beginPath(); ctx.arc(0,-9,5,Math.PI,0); ctx.fill();
if(e.cd2){
const gl=0.5+0.4*Math.sin(game.time*14);
ctx.strokeStyle='rgba(127,212,255,'+gl+')'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(0,0,15,face-0.9,face+0.9); ctx.stroke();
}
ctx.fillStyle=def.eye; ctx.beginPath(); ctx.arc(Math.cos(face)*3, Math.sin(face)*3-8,2,0,6.283); ctx.fill();
}
else if(e.type==='guardian'){
ctx.fillStyle=ECOL[3][0];
ctx.beginPath(); ctx.moveTo(-12,16); ctx.lineTo(-9,-14); ctx.lineTo(9,-14); ctx.lineTo(12,16); ctx.closePath(); ctx.fill();
ctx.strokeStyle=ECOL[3][1]; ctx.lineWidth=2; ctx.stroke();
ctx.fillStyle='#2a3040';
ctx.fillRect(-14,-4,5,16); ctx.fillRect(9,-4,5,16);
ctx.fillStyle=ECOL[3][0];
ctx.beginPath(); ctx.moveTo(-9,-14); ctx.lineTo(-13,-22); ctx.lineTo(-6,-16); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(9,-14); ctx.lineTo(13,-22); ctx.lineTo(6,-16); ctx.closePath(); ctx.fill();
ctx.fillStyle=def.eye;
ctx.beginPath(); ctx.arc(Math.cos(face)*4-2, Math.sin(face)*4-8,2.6,0,6.283); ctx.fill();
ctx.beginPath(); ctx.arc(Math.cos(face)*4+4, Math.sin(face)*4-8,2.6,0,6.283); ctx.fill();
}
if(e.hitT>0){
ctx.globalAlpha=alpha*0.7;
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.ellipse(0,0,10,12,0,0,6.283); ctx.fill();
}
if(e.frozenT>0){
ctx.globalAlpha=alpha*0.75;
ctx.strokeStyle='#7fd4ff'; ctx.lineWidth=2;
ctx.beginPath(); ctx.ellipse(0,0,11,13,0,0,6.283); ctx.stroke();
ctx.fillStyle='rgba(127,212,255,0.25)'; ctx.fill();
}
ctx.restore();
// boss bar
if(def.boss&&alive){
ctx.save();
const bw=120, bx=e.x-60, by=e.y-26;
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(bx-1,by-1,bw+2,6);
ctx.fillStyle='#3a2a22'; ctx.fillRect(bx,by,bw,4);
ctx.fillStyle=e.type==='guardian'?'#ffd257':'#7fd4ff';
ctx.fillRect(bx,by,bw*clamp(e.hp/e.maxhp,0,1),4);
ctx.fillStyle='rgba(232,217,176,0.85)';
ctx.font='9px Georgia'; ctx.textAlign='center';
ctx.fillText(def.name, e.x, by-3);
ctx.restore();
}
}
}
function drawPlayer(){
const p=game.player;
if(!p||p.dead) return;
const cam=camera();
const px=p.x-cam.x, py=p.y-cam.y;
ctx.save(); ctx.translate(px,py);
const bob=p.moving?Math.sin(game.time*10)*1.5:0;
const w=p.wpn;
// shadow
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.ellipse(0,9, w===2?10:8, 3.5,0,0,6.283); ctx.fill();
// cloak
ctx.translate(0,bob*0.5);
const cloakCol=w===0?'#4a4458':w===1?'#54465e':'#5c4c3c';
ctx.fillStyle=cloakCol;
ctx.beginPath();
const baseW=w===2?9:7;
ctx.moveTo(-baseW,8); ctx.quadraticCurveTo(-6,-8, 0,-10); ctx.quadraticCurveTo(6,-8, baseW,8);
ctx.quadraticCurveTo(0,11,-baseW,8);
ctx.closePath(); ctx.fill();
if(p.equipped.cloak){
ctx.strokeStyle='#14121c'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(-baseW+1,7); ctx.quadraticCurveTo(0,10,baseW-1,7); ctx.stroke();
}
if(w===2 && Math.random()<0.25){
ctx.fillStyle='rgba(255,210,120,0.5)';
ctx.fillRect(rnd(-baseW,baseW), rnd(-6,6), 1.5, 1.5);
}
// head
ctx.fillStyle='#d8c9a8';
ctx.beginPath(); ctx.arc(0,-11,4.2,0,6.283); ctx.fill();
ctx.fillStyle=cloakCol;
ctx.beginPath(); ctx.arc(0,-12.5,4.4,Math.PI*0.9,Math.PI*2.1); ctx.fill();
// accessories
if(p.equipped.band){
ctx.strokeStyle='#cfcfe8'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(7,2+bob,3,0,6.283); ctx.stroke();
}
if(p.equipped.lantern){
const gl=0.5+0.35*Math.sin(game.time*4);
ctx.fillStyle='rgba(127,212,255,'+(0.6+gl*0.4)+')';
ctx.beginPath(); ctx.arc(-9,-4+bob,2.5,0,6.283); ctx.fill();
const g=ctx.createRadialGradient(-9,-4,1,-9,-4,18);
g.addColorStop(0,'rgba(127,212,255,0.35)'); g.addColorStop(1,'rgba(127,212,255,0)');
ctx.fillStyle=g; ctx.fillRect(-30,-24,40,40);
}
if(p.equipped.cloak && !p.equipped.band){
ctx.fillStyle='#cfcfe8'; ctx.fillRect(3,-9,2,2);
}
// blade
const wIdx=w;
let bAng=p.dir, bLen=WEAPONS[wIdx].range*0.72;
if(p.atkPhase==='windup'){
const t=p.atkT/WEAPONS[wIdx].wind;
bAng=p.dir-WEAPONS[wIdx].arc*0.55*t;
} else if(p.atkPhase==='swing'){
const t=clamp(p.atkT/WEAPONS[wIdx].swing,0,1);
bAng=p.dir-WEAPONS[wIdx].arc/2 + WEAPONS[wIdx].arc*ease(t);
p.trail.push({a:bAng,t:0});
if(p.trail.length>10) p.trail.shift();
}
for(let i=p.trail.length-1;i>=0;i--){ p.trail[i].t+=1/60; if(p.trail[i].t>0.22) p.trail.splice(i,1); }
if(p.wpn>=1){
for(const tr of p.trail){
const a=tr.a;
ctx.strokeStyle=w===1?'rgba(255,138,60,'+(0.5*tr.t/0.22)+')':'rgba(255,210,120,'+(0.5*tr.t/0.22)+')';
ctx.lineWidth=3;
ctx.beginPath();
ctx.arc(0,0,bLen*0.9, a-0.22, a+0.06);
ctx.stroke();
}
}
ctx.save();
ctx.rotate(bAng);
ctx.strokeStyle=w===0?'#c8b898':w===1?'#ff9d3c':'#ffd257';
ctx.lineWidth=w===2?3.4:2.6;
if(w>0){ ctx.shadowColor=w===1?'#ff8a3c':'#ffd257'; ctx.shadowBlur=10; }
ctx.beginPath(); ctx.moveTo(5,0); ctx.lineTo(bLen,0); ctx.stroke();
if(w===2){
ctx.strokeStyle='rgba(255,240,200,0.8)'; ctx.lineWidth=1.4;
ctx.beginPath(); ctx.moveTo(8,0); ctx.lineTo(bLen-2,0); ctx.stroke();
}
ctx.restore();
// off-hand shard
if(p.hasShard){
const sa=p.dir+Math.PI/2;
const sx=Math.cos(sa)*8, sy=Math.sin(sa)*8;
const pul=0.6+0.4*Math.sin(game.time*5);
ctx.fillStyle='rgba(127,212,255,'+pul+')';
ctx.beginPath(); ctx.moveTo(sx,sy-4); ctx.lineTo(sx+3,sy); ctx.lineTo(sx,sy+4); ctx.lineTo(sx-3,sy); ctx.closePath(); ctx.fill();
}
// windup glow
if(p.atkPhase==='windup'&&w===2){
const t=p.atkT/WEAPONS[2].wind;
const g=ctx.createRadialGradient(0,0,2,0,0,26);
g.addColorStop(0,'rgba(255,220,140,'+t*0.5+')'); g.addColorStop(1,'rgba(255,220,140,0)');
ctx.fillStyle=g; ctx.fillRect(-28,-28,56,56);
}
ctx.restore();
// invuln flicker & hit flash (screen-space over player)
if(p.invuln>0 && Math.floor(game.time*20)%2===0){
ctx.save(); ctx.globalAlpha=0.3;
heartPath(px,py-14,14); ctx.fillStyle='#fff'; ctx.fill();
ctx.restore();
}
if(p.hitFlash>0){
ctx.save(); ctx.globalAlpha=p.hitFlash*0.5;
ctx.fillStyle='#ff4020';
ctx.beginPath(); ctx.arc(px,py,14,0,6.283); ctx.fill();
ctx.restore();
}
if(p.aura>0){
ctx.save();
const t=1-p.aura/2;
ctx.strokeStyle='rgba(255,220,140,'+(0.7*(1-t))+')';
ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(px,py,16+t*30,0,6.283); ctx.stroke();
ctx.restore();
}
}
function drawBolts(){
for(const b of game.bolts){
const cam=camera();
const px=b.x-cam.x, py=b.y-cam.y;
ctx.save(); ctx.translate(px,py);
const a=Math.atan2(b.vy,b.vx);
ctx.rotate(a);
const g=ctx.createRadialGradient(0,0,1,0,0,12);
g.addColorStop(0,'rgba(255,255,255,0.9)'); g.addColorStop(0.4,'rgba(127,212,255,0.6)'); g.addColorStop(1,'rgba(127,212,255,0)');
ctx.fillStyle=g; ctx.fillRect(-12,-12,24,24);
ctx.strokeStyle='rgba(200,236,255,0.8)'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(-8,0); ctx.lineTo(8,0); ctx.stroke();
ctx.restore();
}
}
function drawParticles(){
for(const p of game.parts){
const cam=camera();
const a=1-p.t/p.life;
ctx.globalAlpha=a;
ctx.fillStyle=p.c;
ctx.fillRect(p.x-cam.x-p.s/2, p.y-cam.y-p.s/2, p.s, p.s);
}
ctx.globalAlpha=1;
}
function drawWalls(pal,cam){
const {w,h}=ZONES[game.zone];
const x0=Math.max(0,Math.floor(cam.x/T)-1), x1=Math.min(w-1,Math.ceil((cam.x+CW)/T)+1);
const y0=Math.max(0,Math.floor(cam.y/T)-1), y1=Math.min(h-1,Math.ceil((cam.y+CH)/T)+1);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){
const g=game.maps[game.zone][y*w+x];
if(g!==1) continue;
const px=x*T, py=y*T;
ctx.fillStyle=pal.wall2; ctx.fillRect(px,py+6,T,T-6);
ctx.fillStyle=pal.wall; ctx.fillRect(px,py,T,6);
// brick seams
ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(px,py+16); ctx.lineTo(px+T,py+16); ctx.stroke();
const off=(y%2)?16:8;
ctx.beginPath(); ctx.moveTo(px+off,py+6); ctx.lineTo(px+off,py+16); ctx.stroke();
// moss
if(hash2(x,y*7)<0.3){
ctx.fillStyle='rgba(90,110,70,0.25)';
ctx.fillRect(px+hash2(x,y)*20, py+6, 5, 4);
}
}
}
function drawFloaters(){
const cam=camera();
for(const f of game.floaters){
const a=1-f.t/f.dur;
ctx.globalAlpha=clamp(a*1.5,0,1);
ctx.font=(f.size||12)+'px Georgia';
ctx.textAlign='center';
ctx.strokeStyle='rgba(0,0,0,0.8)'; ctx.lineWidth=3;
ctx.strokeText(f.txt, f.x-cam.x, f.y-cam.y);
ctx.fillStyle=f.c||'#e8d9b0';
ctx.fillText(f.txt, f.x-cam.x, f.y-cam.y);
}
ctx.globalAlpha=1; ctx.textAlign='left';
}
function drawVignette(pal){
const g=ctx.createRadialGradient(CW/2,CH/2,CH*0.42,CW/2,CH/2,CH*0.78);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(3,5,9,0.62)');
ctx.fillStyle=g; ctx.fillRect(0,0,CW,CH);
}
function drawHUD(){
const p=game.player;
ctx.save();
// hearts
for(let i=0;i<p.containers;i++){
const x=26+i*28, y=24;
heartPath(x,y,15);
ctx.fillStyle='rgba(20,14,12,0.75)'; ctx.fill();
const full=p.hp>=i*2+2, half=p.hp===i*2+1;
if(full){
heartPath(x,y,15);
ctx.fillStyle='#c0452e'; ctx.fill();
ctx.fillStyle='rgba(255,190,150,0.5)';
ctx.beginPath(); ctx.arc(x-3,y+4,2,0,6.283); ctx.fill();
} else if(half){
ctx.save();
heartPath(x,y,15); ctx.clip();
ctx.fillStyle='#c0452e';
ctx.fillRect(x-8,y-1,9,16);
ctx.restore();
}
heartPath(x,y,15);
ctx.strokeStyle='#d9a441'; ctx.lineWidth=1.5; ctx.stroke();
}
ctx.font='11px Georgia'; ctx.fillStyle='#9a8f78';
ctx.fillText(WEAPONS[p.wpn].name, 24, 50);
// gold
ctx.textAlign='right';
ctx.fillStyle='#d9a441';
ctx.beginPath(); ctx.arc(CW-70,24,7,0,6.283); ctx.fill();
ctx.fillStyle='#8a6a3f';
ctx.beginPath(); ctx.arc(CW-70,24,4,0,6.283); ctx.fill();
ctx.fillStyle='#f0d9a0'; ctx.font='15px Georgia';
ctx.fillText(p.gold, CW-24, 29);
ctx.font='11px Georgia'; ctx.fillStyle='#9a8f78';
ctx.fillText(ZONES[game.zone].name, CW-24, 46);
if(p.hasShard){
ctx.fillStyle='rgba(127,212,255,0.9)';
ctx.beginPath(); ctx.moveTo(CW-70,52); ctx.lineTo(CW-66,57); ctx.lineTo(CW-70,62); ctx.lineTo(CW-74,57); ctx.closePath(); ctx.fill();
ctx.fillText(p.shardCharges+' ⌁', CW-24, 61);
}
ctx.textAlign='left';
// interact hint
if(game.state==='playing'&&game.interactHint){
const msg=game.interactHint;
ctx.font='13px Georgia';
const wdt=ctx.measureText(msg).width+64;
ctx.fillStyle='rgba(10,12,18,0.8)';
ctx.fillRect(CW/2-wdt/2, CH-44, wdt, 26);
ctx.strokeStyle='rgba(217,164,65,0.5)'; ctx.lineWidth=1;
ctx.strokeRect(CW/2-wdt/2, CH-44, wdt, 26);
ctx.fillStyle='#e8c67a';
ctx.textAlign='center';
const k=msg.startsWith('Rest')||msg.startsWith('Open')||msg.startsWith('Take')||msg.startsWith('Claim')||msg.startsWith('Barter')||msg.startsWith('Kindle')||msg.startsWith('Approach')?'E':'J';
ctx.fillText('['+k+'] '+msg, CW/2, CH-26);
ctx.textAlign='left';
}
// control footer
ctx.fillStyle='rgba(154,143,120,0.55)'; ctx.font='10px Georgia';
ctx.fillText('WASD move · J strike · K shard · E interact · Tab kit', 12, CH-10);
ctx.restore();
}
function drawEdgeFx(){
if(game.edge>0){
const a=clamp(game.edge/2.5,0,0.85);
ctx.save();
ctx.strokeStyle='rgba(255,215,140,'+a+')';
ctx.lineWidth=5;
ctx.shadowColor='#ffd257'; ctx.shadowBlur=24;
ctx.strokeRect(3,3,CW-6,CH-6);
ctx.restore();
}
if(game.flash>0){
ctx.fillStyle='rgba(190,40,20,'+game.flash*0.22+')';
ctx.fillRect(0,0,CW,CH);
}
if(game.state==='dead'){
const a=clamp(game.deathT/1.2,0,1)*0.75;
ctx.fillStyle='rgba(20,4,4,'+a+')';
ctx.fillRect(0,0,CW,CH);
ctx.fillStyle='rgba(232,217,176,'+clamp(game.deathT-0.4,0,1)+')';
ctx.font='24px Georgia'; ctx.textAlign='center';
ctx.fillText('The vigil falters…', CW/2, CH/2-16);
if(game.deathT>1){
ctx.font='14px Georgia'; ctx.fillStyle='#9a8f78';
ctx.fillText('press R or Enter — you return to the Sanctuary Stone, whole again.', CW/2, CH/2+14);
}
ctx.textAlign='left';
}
if(game.state==='win'){
game.winT+=1/60;
const a=clamp(game.winT/2,0,1)*0.5;
ctx.fillStyle='rgba(255,235,190,'+a*0.5+')';
ctx.fillRect(0,0,CW,CH);
}
}
function drawBanner(){
for(const b of game.banners){
const t=b.t;
const a=t<0.5?t/0.5:(t>b.dur-0.8?clamp((b.dur-t)/0.8,0,1):1);
ctx.save(); ctx.globalAlpha=a;
ctx.textAlign='center';
ctx.font='28px Georgia';
ctx.fillStyle='#ecc984';
ctx.shadowColor='rgba(217,164,65,0.6)'; ctx.shadowBlur=18;
ctx.fillText(b.title.toUpperCase(), CW/2, CH*0.3);
ctx.shadowBlur=0;
ctx.font='italic 13px Georgia';
ctx.fillStyle='#b9a98c';
ctx.fillText(b.sub, CW/2, CH*0.3+24);
ctx.textAlign='left';
ctx.restore();
}
}
/* ================= MAIN LOOP ================= */
let lastTs=0;
function loop(ts){
requestAnimationFrame(loop);
let dt=(ts-lastTs)/1000; lastTs=ts;
if(!dt||dt>0.1) dt=0.016;
update(dt);
render();
}
function update(dt){
game.time+=dt;
updateFx(dt);
if(game.state==='dead'){ game.deathT+=dt; return; }
if(game.state==='win'){
game.winT+=dt;
const b=game.ents.find(e=>e.type==='beacon');
if(b&&b.lit&&Math.random()<0.4){
game.parts.push({x:b.x+rnd(-30,30), y:b.y-rnd(20,120), vx:rnd(-14,14), vy:-rnd(30,80),
t:0,life:rnd(0.6,1.4),c:Math.random()<0.5?'#ffd257':'#fff3d0',s:rnd(1.2,2.6),g:-16});
}
return;
}
if(game.state!=='playing') return;
game.stats.time+=dt;
// ambient particles
game.ambientAcc+=dt;
if(game.ambientAcc>0.07){
game.ambientAcc=0;
if(game.parts.length<420){
const cam=camera();
if(game.zone===1){ // floating dust
game.parts.push({x:cam.x+rnd(0,CW), y:cam.y+rnd(0,CH), vx:rnd(-4,4), vy:rnd(2,7),
t:0,life:rnd(2,4),c:'rgba(200,170,120,0.5)',s:1.2,g:0});
} else if(game.zone===3){ // rising mist
game.parts.push({x:cam.x+rnd(0,CW), y:cam.y+CH+rnd(-20,0), vx:rnd(-5,5), vy:-rnd(12,26),
t:0,life:rnd(3,5),c:'rgba(140,180,220,0.22)',s:rnd(2,3.5),g:0});
} else if(game.zone===0){ // falling ash
game.parts.push({x:cam.x+rnd(0,CW), y:cam.y-10, vx:rnd(-8,4), vy:rnd(16,30),
t:0,life:rnd(3,6),c:'rgba(190,160,120,0.4)',s:1.4,g:0});
}
}
}
updatePlayer(dt);
updateEnemies(dt);
updateBolts(dt);
}
/* ================= BOOTSTRAP ================= */
function bindUI(){
$('btnNew').addEventListener('click',()=>{ clearSave(); startNew(); });
$('btnCont').addEventListener('click',()=>continueGame());
$('btnResume').addEventListener('click',()=>{ game.state='playing'; $('ovPause').hidden=true; });
$('btnMute').addEventListener('click',()=>{
AudioSys.setMuted(!AudioSys.muted);
$('btnMute').textContent='Sound: '+(AudioSys.muted?'Off':'On');
});
$('btnAbandon').addEventListener('click',toTitle);
$('btnInvClose').addEventListener('click',closeInv);
$('btnShopClose').addEventListener('click',()=>{ $('ovShop').hidden=true; game.state='playing'; AudioSys.droneOff(); });
$('btnWinTitle').addEventListener('click',()=>{ clearSave(); toTitle(); });
addEventListener('keydown',e=>{
if((e.key==='Enter')&&game.state==='win'&&game.winT>1.5){ clearSave(); toTitle(); }
});
}
function refreshTitle(){
$('btnCont').hidden=!hasSave();
}
bindUI();
refreshTitle();
game.time=0;
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"bcc833cd-4604-467b-8b64-3cf9d295ecf8","tokensIn":4575540,"tokensOut":246213,"tokensTotal":4821753,"cost":0,"turns":62,"toolCalls":61,"failedToolCalls":0,"timestamp":"2026-08-17T20:36:07.160Z"} -->