Warden
Claude Sonnet 5 · 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 43,114 bytes · SHA-256 a30b0366d7f5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>The Warden's Corrosion</title>
<style>
:root{ --gold:#e0a838; --ember:#ff7a2e; --solar:#ffd75e; --slate:#1b2230; }
html,body{margin:0;padding:0;background:#05070a;overflow:hidden;height:100%;font-family:'Georgia',serif;color:#f0e6d2;}
#wrap{position:relative;width:960px;height:600px;margin:20px auto;box-shadow:0 0 60px rgba(0,0,0,.8);border:3px solid #3a2e1a;border-radius:4px;background:#000;}
canvas{display:block;background:#0a0d12;image-rendering:pixelated;}
#ui{position:absolute;top:0;left:0;width:960px;height:600px;pointer-events:none;}
#hearts{position:absolute;top:10px;left:10px;display:flex;gap:4px;}
.heart{width:26px;height:22px;position:relative;filter:drop-shadow(0 0 3px #000);}
.heart svg{width:100%;height:100%;}
#gold{position:absolute;top:10px;right:14px;font-size:20px;color:var(--gold);text-shadow:0 0 6px #000;display:flex;align-items:center;gap:6px;}
#weaponInfo{position:absolute;top:40px;left:10px;font-size:13px;color:#dcc89a;text-shadow:0 0 4px #000;}
#zoneBanner{position:absolute;top:220px;left:0;width:100%;text-align:center;font-size:34px;letter-spacing:4px;color:#f5e3b8;text-shadow:0 0 20px #000,0 0 8px var(--gold);opacity:0;transition:opacity 1.2s;}
#toastBox{position:absolute;bottom:70px;left:0;width:100%;text-align:center;}
.toast{display:inline-block;background:rgba(10,8,4,.75);border:1px solid #6b5327;padding:6px 14px;margin:2px;border-radius:4px;font-size:14px;color:#f5e3b8;opacity:0;animation:toastIn 3.2s ease forwards;}
@keyframes toastIn{0%{opacity:0;transform:translateY(10px);}15%{opacity:1;transform:translateY(0);}80%{opacity:1;}100%{opacity:0;}}
#hint{position:absolute;bottom:8px;left:10px;font-size:12px;color:#9a8b6a;}
#interactPrompt{position:absolute;left:50%;top:60%;transform:translate(-50%,0);background:rgba(0,0,0,.6);padding:6px 14px;border:1px solid var(--gold);border-radius:4px;display:none;font-size:14px;}
.modal{position:absolute;top:60px;left:130px;width:700px;max-height:480px;overflow:auto;background:linear-gradient(#1a140a,#0c0a06);border:2px solid var(--gold);border-radius:6px;padding:16px;pointer-events:auto;box-shadow:0 0 40px rgba(0,0,0,.8);display:none;}
.modal h2{margin:0 0 10px;color:var(--gold);text-align:center;letter-spacing:2px;}
.row{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #332a1a;padding:8px 4px;}
.row button{background:#3a2e14;color:#f0e0b0;border:1px solid var(--gold);padding:4px 10px;border-radius:4px;cursor:pointer;font-family:inherit;}
.row button:disabled{opacity:.35;cursor:not-allowed;}
.closeBtn{position:absolute;top:8px;right:10px;cursor:pointer;color:#e08a8a;font-size:18px;}
#deathScreen,#victoryScreen{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;background:rgba(0,0,0,.88);pointer-events:auto;text-align:center;}
#deathScreen h1{color:#c0302a;font-size:48px;letter-spacing:6px;}
#victoryScreen h1{color:var(--solar);font-size:40px;letter-spacing:6px;text-shadow:0 0 30px var(--solar);}
#deathScreen button,#victoryScreen button{margin-top:16px;padding:10px 24px;background:#3a2e14;border:1px solid var(--gold);color:#f0e0b0;border-radius:4px;cursor:pointer;font-size:16px;}
#fade{position:absolute;inset:0;background:#000;opacity:0;pointer-events:none;transition:opacity .6s;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c" width="960" height="600"></canvas>
<div id="ui">
<div id="hearts"></div>
<div id="gold">⛁ <span id="goldAmt">0</span></div>
<div id="weaponInfo"></div>
<div id="zoneBanner"></div>
<div id="toastBox"></div>
<div id="interactPrompt"></div>
<div id="hint">WASD move · Space melee · F ranged (once found) · E interact · I inventory · 1 Vital Stem · 2 Binding Seal</div>
<div class="modal" id="shopModal">
<span class="closeBtn" id="shopClose">✕</span>
<h2>Merchant Altar</h2>
<div id="shopList"></div>
</div>
<div class="modal" id="invModal">
<span class="closeBtn" id="invClose">✕</span>
<h2>Inventory</h2>
<div id="invList"></div>
</div>
<div id="deathScreen"><h1>THE WARDEN FALLS</h1><p>The Corrosion claims another guardian.</p><button id="respawnBtn">Respawn at Last Sanctuary</button></div>
<div id="victoryScreen"><h1>THE BEACON IGNITES</h1><p>Aurelion's light returns to the valley.</p><button onclick="location.reload()">Restart</button></div>
</div>
<div id="fade"></div>
</div>
<script>
(function(){
"use strict";
const canvas=document.getElementById('c'), ctx=canvas.getContext('2d');
const VW=960,VH=600;
// ---------- AUDIO ----------
let actx=null;
function ac(){ if(!actx) actx=new (window.AudioContext||window.webkitAudioContext)(); return actx; }
function tone(freq,dur,type='sine',gain=0.15,delay=0,slide=0){
try{
const a=ac(); const t0=a.currentTime+delay;
const osc=a.createOscillator(); const g=a.createGain();
osc.type=type; osc.frequency.setValueAtTime(freq,t0);
if(slide) osc.frequency.linearRampToValueAtTime(freq+slide,t0+dur);
g.gain.setValueAtTime(gain,t0);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
osc.connect(g); g.connect(a.destination);
osc.start(t0); osc.stop(t0+dur+0.02);
}catch(e){}
}
function noise(dur,gain=0.12,delay=0){
try{
const a=ac(); const bufferSize=a.sampleRate*dur;
const buffer=a.createBuffer(1,bufferSize,a.sampleRate);
const data=buffer.getChannelData(0);
for(let i=0;i<bufferSize;i++) data[i]=(Math.random()*2-1)*(1-i/bufferSize);
const src=a.createBufferSource(); src.buffer=buffer;
const g=a.createGain(); g.gain.setValueAtTime(gain,a.currentTime+delay);
src.connect(g); g.connect(a.destination);
src.start(a.currentTime+delay);
}catch(e){}
}
const SFX={
swing(level){ if(level>=2){tone(140,0.28,'sawtooth',0.18);tone(90,0.35,'triangle',0.15,0.02);} else if(level>=1){tone(300,0.14,'sawtooth',0.15);noise(0.1,0.08,0.03);} else {tone(420,0.08,'square',0.12);} },
hit(){ noise(0.08,0.15); tone(180,0.1,'square',0.12); },
rangedFire(){ tone(900,0.08,'sine',0.15,0,600); },
rangedHit(){ tone(1200,0.15,'triangle',0.15); },
damage(){ tone(90,0.2,'sawtooth',0.2); tone(1500,0.08,'sine',0.08,0.02); },
pickupCommon(){ tone(700,0.08,'sine',0.12); tone(950,0.1,'sine',0.12,0.08); },
pickupRare(){ tone(500,0.15,'triangle',0.14); tone(750,0.15,'triangle',0.12,0.05); tone(1000,0.2,'triangle',0.1,0.1); },
coin(){ tone(1400,0.06,'square',0.1); tone(1800,0.06,'square',0.08,0.04); },
save(){ tone(440,0.3,'sine',0.12); tone(660,0.4,'sine',0.1,0.15); tone(880,0.5,'sine',0.08,0.3); },
upgrade(){ tone(300,0.4,'sawtooth',0.15); tone(450,0.4,'triangle',0.15,0.15); tone(600,0.6,'sine',0.12,0.3); },
breakWall(){ noise(0.25,0.22); tone(120,0.2,'square',0.1); },
door(){ tone(200,0.4,'sine',0.1,0,-60); },
plate(){ tone(250,0.12,'square',0.1); },
purchase(){ tone(600,0.08,'sine',0.1); tone(900,0.12,'sine',0.12,0.08); },
denied(){ tone(150,0.2,'sawtooth',0.1); },
footstep(){ tone(90+Math.random()*20,0.05,'square',0.03); }
};
// ---------- STATE ----------
const KEYS={};
window.addEventListener('keydown',e=>{KEYS[e.code]=true; handleKeyOnce(e.code);});
window.addEventListener('keyup',e=>{KEYS[e.code]=false;});
const GAME={
zoneIndex:0,
gold:0,
maxContainers:3,
hp:6,
weaponLevel:0,
hasRanged:false,
ammo:0,
inventory:{ band:false, lantern:false, cloak:false },
equipped:{ band:false, lantern:false, cloak:false },
consumables:{ vitalStem:0, bindingSeal:0 },
flags:{ zone3PlateA:0, zone3PlateB:0, zone3DoorOpen:false, zone3WallBroken:false, zone4WallBroken:false, zone4LightPuzzle:false,
zone5SwitchHit:false, heart5Bought:false, heart5Got:false, savedZone:0, altar1Used:false, altar2Used:false, zone2BossDown:false,
zone5BossDown:false },
lastSave:null,
invuln:0,
facing:'down',
};
function totalMaxHP(){ return GAME.maxContainers*2; }
// ---------- PARTICLES ----------
let particles=[];
function spawnParticles(x,y,n,opts={}){
for(let i=0;i<n;i++){
const ang=Math.random()*Math.PI*2;
const spd=(opts.speed||60)*(0.5+Math.random());
particles.push({
x,y, vx:Math.cos(ang)*spd*(opts.spread!==false?1:0.2), vy:Math.sin(ang)*spd*(opts.spread!==false?1:0.2)-(opts.up||0),
life:0, maxLife:opts.life||0.6, color:opts.color||'#ff9a3c', size:opts.size||3, grav:opts.grav!==undefined?opts.grav:40
});
}
}
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){
const p=particles[i]; p.life+=dt;
if(p.life>=p.maxLife){particles.splice(i,1);continue;}
p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=p.grav*dt; p.vx*=0.98;
}
}
function drawParticles(cam){
for(const p of particles){
const a=1-p.life/p.maxLife;
ctx.globalAlpha=Math.max(0,a);
ctx.fillStyle=p.color;
ctx.fillRect(p.x-cam.x-p.size/2,p.y-cam.y-p.size/2,p.size,p.size);
}
ctx.globalAlpha=1;
}
// ---------- TOAST / BANNER ----------
function toast(msg,cls=''){
const box=document.getElementById('toastBox');
const d=document.createElement('div'); d.className='toast '+cls; d.textContent=msg;
box.appendChild(d);
setTimeout(()=>d.remove(),3300);
}
function showZoneBanner(name){
const b=document.getElementById('zoneBanner');
b.textContent=name; b.style.opacity=1;
setTimeout(()=>b.style.opacity=0,2600);
}
let shimmerTimer=0;
function screenShimmer(){ shimmerTimer=2.0; }
// ---------- ENTITIES ----------
class Rect{constructor(x,y,w,h){this.x=x;this.y=y;this.w=w;this.h=h;}
intersects(o){return this.x<o.x+o.w&&this.x+this.w>o.x&&this.y<o.y+o.h&&this.y+this.h>o.y;}
}
const player={x:120,y:300,w:20,h:26,speed:130,vx:0,vy:0,attackCD:0,attacking:0,rangedCD:0,emberT:0};
function rectsCollide(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y;}
// ---------- ZONES ----------
// Each zone: {name,w,h,bg,walls:[Rect],breakables:[{...}],enemies:[],pickups:[],plates:[],saveStone:{},altar:{},shopNpc:{},exit:{to,rect,cond,lockedMsg},entrance:{x,y},decor:[]}
function makeEnemy(x,y,hp,dmg,speed,type='construct',drop=[8,15]){
return {x,y,w:22,h:26,hp,maxhp:hp,dmg,speed,type,drop,state:'idle',hitCD:0,flinch:0,alive:true,aiT:Math.random()*2};
}
const ZONES=[];
// ZONE 0 - Ashen Threshold
ZONES.push({
name:"THE ASHEN THRESHOLD", w:1400,h:600, ground:'#2a2420',
walls:[ new Rect(0,0,1400,40), new Rect(0,560,1400,40), new Rect(0,0,40,600) ],
breakables:[], plates:[],
enemies:[ makeEnemy(500,300,6,1,55), makeEnemy(780,420,6,1,55), makeEnemy(1000,200,6,1,55) ],
pickups:[ {type:'gold',x:640,y:300,amt:6} ],
saveStone:{x:150,y:300,used:false},
altar:null, shopNpc:null,
exit:{to:1,rect:new Rect(1360,0,40,600),cond:()=>true},
entrance:{x:100,y:300},
});
// ZONE 1 - Rusted Archives
ZONES.push({
name:"THE RUSTED ARCHIVES", w:1500,h:600, ground:'#2c2a1c',
walls:[ new Rect(0,0,1500,40), new Rect(0,560,1500,40), new Rect(0,0,40,600), new Rect(300,200,40,260), new Rect(900,60,40,260) ],
breakables:[ {x:1150,y:420,w:40,h:60,hp:1,maxhp:1,broken:false,reveal:{type:'item',item:'chest_band'}} ],
plates:[],
enemies:[ makeEnemy(500,300,6,1,60), makeEnemy(700,150,6,1,60), makeEnemy(1050,480,8,2,65,'construct',[10,18]) ],
pickups:[ {type:'gold',x:400,y:480,amt:10}, {type:'item',item:'vitalStem',x:650,y:500} ],
saveStone:{x:760,y:300,used:false},
altar:null,
shopNpc:{x:1250,y:150},
exit:{to:2,rect:new Rect(1460,0,40,600),cond:()=>true},
entrance:{x:80,y:300},
});
// ZONE 2 - Collapsed Gallery
ZONES.push({
name:"THE COLLAPSED GALLERY", w:1500,h:640, ground:'#241f1a',
walls:[ new Rect(0,0,1500,40), new Rect(0,600,1500,40), new Rect(0,0,40,640), new Rect(600,0,40,300), new Rect(600,420,40,220) ],
breakables:[ {x:980,y:500,w:40,h:60,hp:1,maxhp:1,broken:false,reveal:{type:'heart'}} ],
plates:[
{x:300,y:500,w:50,h:50,pressed:false,id:'A'},
{x:450,y:200,w:50,h:50,pressed:false,id:'B'}
],
doorForPlates:new Rect(600,300,40,120),
enemies:[ makeEnemy(700,300,10,2,70), makeEnemy(900,450,10,2,70), makeEnemy(1150,200,14,2,75,'construct',[15,25]) ],
pickups:[ {type:'gold',x:200,y:200,amt:12} ],
saveStone:null,
altar:{x:1300,y:300,used:false,type:'ignited'},
shopNpc:null,
exit:{to:3,rect:new Rect(1460,0,40,640),cond:()=>GAME.weaponLevel>=1, lockedMsg:"The way forward is sealed by cold iron. Perhaps a stronger flame is needed — find the altar."},
entrance:{x:80,y:320},
});
// ZONE 3 - Sunken Vault
ZONES.push({
name:"THE SUNKEN VAULT", w:1500,h:640, ground:'#151d24',
walls:[ new Rect(0,0,1500,40), new Rect(0,600,1500,40), new Rect(0,0,40,640) ],
breakables:[ {x:1050,y:300,w:40,h:80,hp:2,maxhp:2,broken:false,reveal:{type:'item',item:'aetherShard'}} ],
plates:[],
lightSwitch:{x:1250,y:150,w:24,h:24,hit:false},
puzzleDoor:new Rect(1300,120,40,120),
enemies:[ makeEnemy(600,300,10,2,65), makeEnemy(850,450,10,2,65) ],
pickups:[ {type:'item',item:'phantomLantern',x:400,y:200}, {type:'gold',x:700,y:500,amt:14} ],
saveStone:{x:150,y:300,used:false},
altar:null, shopNpc:null,
exit:{to:4,rect:new Rect(1460,0,40,640),cond:()=>GAME.hasRanged, lockedMsg:"Only the Aether Shard's light can part these deep waters onward."},
entrance:{x:80,y:320},
});
// ZONE 4 - Twin Spire
ZONES.push({
name:"THE TWIN SPIRE", w:1600,h:680, ground:'#211a2a',
walls:[ new Rect(0,0,1600,40), new Rect(0,640,1600,40), new Rect(0,0,40,680) ],
breakables:[],
plates:[],
farSwitch:{x:1500,y:120,w:24,h:24,hit:false},
enemies:[ makeEnemy(500,300,14,2,75), makeEnemy(750,450,14,2,75), makeEnemy(1000,200,14,2,80),
makeEnemy(1250,400,20,3,85,'construct',[20,30]) ],
pickups:[ {type:'item',item:'shadowCloak',x:300,y:500}, {type:'item',item:'bindingSeal',x:600,y:150},
{type:'gold',x:900,y:550,amt:40} ],
saveStone:{x:150,y:340,used:false},
altar:{x:1400,y:560,used:false,type:'solar'},
shopNpc:null,
exit:{to:5,rect:new Rect(1560,0,40,680),cond:()=>GAME.weaponLevel>=2, lockedMsg:"The Core Chamber demands the Solar Falchion's light. The altar awaits, guarded still."},
entrance:{x:80,y:340},
});
// ZONE 5 - Core Chamber
ZONES.push({
name:"THE CORE CHAMBER", w:1200,h:640, ground:'#1c1410',
walls:[ new Rect(0,0,1200,40), new Rect(0,600,1200,40), new Rect(0,0,40,640), new Rect(1160,0,40,640) ],
breakables:[], plates:[],
boss:makeEnemy(900,320,60,4,60,'boss',[80,120]),
enemies:[],
pickups:[],
saveStone:null, altar:null, shopNpc:null,
beacon:{x:1000,y:280,w:60,h:90,lit:false},
exit:null,
entrance:{x:80,y:320},
});
ZONES[5].beacon.y=280;
let cam={x:0,y:0};
function currentZone(){return ZONES[GAME.zoneIndex];}
function resetPlayerToEntrance(){
const z=currentZone();
player.x=z.entrance.x; player.y=z.entrance.y;
}
// ---------- ITEM DEFS ----------
const ITEM_NAMES={
vitalStem:"Vital Stem", bindingSeal:"Binding Seal", wardenBand:"Warden's Band",
phantomLantern:"Phantom Lantern", shadowCloak:"Shadow Cloak", aetherShard:"Aether Shard"
};
function grantItem(item){
if(item==='vitalStem'){ GAME.consumables.vitalStem++; toast("+1 Vital Stem",'rare'); SFX.pickupRare(); }
else if(item==='bindingSeal'){ GAME.consumables.bindingSeal++; toast("+1 Binding Seal",'rare'); SFX.pickupRare(); }
else if(item==='chest_band' || item==='wardenBand'){ GAME.inventory.band=true; GAME.equipped.band=true; toast("Warden's Band acquired — attack speed increased",'rare'); SFX.pickupRare(); }
else if(item==='phantomLantern'){ GAME.inventory.lantern=true; GAME.equipped.lantern=true; toast("Phantom Lantern acquired — hidden paths revealed",'rare'); SFX.pickupRare(); }
else if(item==='shadowCloak'){ GAME.inventory.cloak=true; GAME.equipped.cloak=true; toast("Shadow Cloak acquired — trap damage reduced",'rare'); SFX.pickupRare(); }
else if(item==='aetherShard'){ GAME.hasRanged=true; GAME.ammo=20; toast("THE AETHER SHARD — ranged attacks unlocked! Press F",'rare'); SFX.pickupRare(); screenShimmer(); }
}
function gainHeartContainer(){
GAME.maxContainers=Math.min(5,GAME.maxContainers+1);
GAME.hp=totalMaxHP();
screenShimmer();
spawnParticles(player.x,player.y,26,{color:'#ffdf8a',life:1.2,speed:40,up:60,grav:-10});
toast("Health Container acquired! Max HP increased",'rare');
SFX.pickupRare();
}
// ---------- SAVE / LOAD ----------
function saveGame(){
const data={GAME:JSON.parse(JSON.stringify(GAME)),px:player.x,py:player.y};
data.GAME.flags.savedZone=GAME.zoneIndex;
localStorage.setItem('wardenCorrosionSave',JSON.stringify(data));
GAME.lastSave={zone:GAME.zoneIndex,x:player.x,y:player.y};
toast("Progress saved at the Sanctuary Stone",'rare');
SFX.save(); screenShimmer();
}
function respawnAtSave(){
if(GAME.lastSave){
GAME.zoneIndex=GAME.lastSave.zone;
player.x=GAME.lastSave.x; player.y=GAME.lastSave.y;
} else { GAME.zoneIndex=0; resetPlayerToEntrance(); }
GAME.hp=totalMaxHP();
document.getElementById('deathScreen').style.display='none';
}
// ---------- KEY ONE-SHOT HANDLING ----------
function handleKeyOnce(code){
if(code==='KeyE') tryInteract();
if(code==='KeyI') toggleInventory();
if(code==='Digit1') useVitalStem();
if(code==='Digit2') useBindingSeal();
if(code==='Space') meleeAttack();
if(code==='KeyF') rangedAttack();
}
function useVitalStem(){
if(GAME.consumables.vitalStem>0 && GAME.hp<totalMaxHP()){
GAME.consumables.vitalStem--; GAME.hp=Math.min(totalMaxHP(),GAME.hp+4);
toast("Vital Stem restores 4 HP"); SFX.pickupCommon();
} else if(GAME.consumables.vitalStem<=0) toast("No Vital Stems remaining");
}
let freezeTimer=0, frozenEnemy=null;
function useBindingSeal(){
if(GAME.consumables.bindingSeal>0){
const z=currentZone(); const list=z.enemies.concat(z.boss?[z.boss]:[]);
let nearest=null,best=99999;
for(const en of list){ if(!en.alive)continue; const d=Math.hypot(en.x-player.x,en.y-player.y); if(d<best){best=d;nearest=en;} }
if(nearest && best<220){
GAME.consumables.bindingSeal--; nearest.frozen=3.0; toast("Binding Seal — enemy frozen!"); SFX.pickupRare();
} else toast("No enemy nearby to bind");
} else toast("No Binding Seals remaining");
}
// ---------- INTERACTION ----------
let nearInteract=null;
function tryInteract(){
if(!nearInteract) return;
if(nearInteract.type==='save'){ saveGame(); nearInteract.obj.used=true; }
else if(nearInteract.type==='shop'){ openShop(); }
else if(nearInteract.type==='altar'){ activateAltar(nearInteract.obj); }
else if(nearInteract.type==='pickup'){ collectPickup(nearInteract.obj); }
else if(nearInteract.type==='chest'){ breakChestDirect(nearInteract.obj); }
}
function activateAltar(altar){
if(altar.used){ toast("The altar's power has already been claimed."); return; }
if(altar.type==='ignited'){
if(GAME.weaponLevel<1){ GAME.weaponLevel=1; altar.used=true; toast("IGNITED EDGE forged — the Ember Blade burns anew",'rare'); SFX.upgrade(); screenShimmer(); }
} else if(altar.type==='solar'){
if(GAME.weaponLevel<2){ GAME.weaponLevel=2; altar.used=true; toast("SOLAR FALCHION awakened — golden light fills the blade",'rare'); SFX.upgrade(); screenShimmer(); }
}
}
function collectPickup(p){
const z=currentZone();
if(p.type==='gold'){ GAME.gold+=p.amt; toast("+"+p.amt+" gold"); SFX.coin(); }
else if(p.type==='item'){ grantItem(p.item); }
p.collected=true;
z.pickups=z.pickups.filter(x=>x!==p);
}
// ---------- SHOP ----------
function openShop(){
document.getElementById('shopModal').style.display='block';
renderShop();
}
function renderShop(){
const el=document.getElementById('shopList'); el.innerHTML='';
const entries=[
{key:'vitalStem',label:"Vital Stem (restore 4 HP)",cost:50,max:3,owned:()=>GAME.shopBought?.vitalStem||0},
{key:'bindingSeal',label:"Binding Seal (freeze enemy)",cost:75,max:2,owned:()=>GAME.shopBought?.bindingSeal||0},
{key:'heart5',label:"Heart Container Upgrade #5",cost:300,max:1,owned:()=>GAME.flags.heart5Bought?1:0},
{key:'ammo',label:"Aether Shard Ammo x5 (needs Zone 4 cleared)",cost:10,max:999,owned:()=>0,locked:()=>!GAME.hasRanged}
];
GAME.shopBought=GAME.shopBought||{vitalStem:0,bindingSeal:0};
for(const e of entries){
const ownedCount=e.owned();
const soldOut=ownedCount>=e.max;
const locked=e.locked&&e.locked();
const row=document.createElement('div'); row.className='row';
row.innerHTML=`<span>${e.label} — <b>${e.cost}g</b></span>`;
const btn=document.createElement('button');
btn.textContent=locked?"Locked":(soldOut?"Sold Out":"Buy");
btn.disabled=soldOut||locked;
btn.onclick=()=>buyShopItem(e);
row.appendChild(btn);
el.appendChild(row);
}
}
function buyShopItem(e){
if(GAME.gold<e.cost){ toast("Not enough gold"); SFX.denied(); return; }
GAME.gold-=e.cost;
if(e.key==='vitalStem'){ GAME.consumables.vitalStem++; GAME.shopBought.vitalStem++; }
else if(e.key==='bindingSeal'){ GAME.consumables.bindingSeal++; GAME.shopBought.bindingSeal++; }
else if(e.key==='heart5'){ GAME.flags.heart5Bought=true; gainHeartContainer(); }
else if(e.key==='ammo'){ GAME.ammo+=5; }
SFX.purchase(); toast("Purchased "+e.label);
renderShop(); updateHUD();
}
document.getElementById('shopClose').onclick=()=>document.getElementById('shopModal').style.display='none';
// ---------- INVENTORY ----------
function toggleInventory(){
const m=document.getElementById('invModal');
if(m.style.display==='block'){ m.style.display='none'; return; }
renderInventory(); m.style.display='block';
}
function renderInventory(){
const el=document.getElementById('invList'); el.innerHTML='';
const accessories=[
{key:'band',label:"Warden's Band (attack speed)"},
{key:'lantern',label:"Phantom Lantern (reveal hidden paths)"},
{key:'cloak',label:"Shadow Cloak (reduce trap damage)"}
];
for(const a of accessories){
if(!GAME.inventory[a.key]) continue;
const row=document.createElement('div'); row.className='row';
row.innerHTML=`<span>${a.label} ${GAME.equipped[a.key]?'<b style="color:#ffd75e">[Equipped]</b>':''}</span>`;
const btn=document.createElement('button'); btn.textContent=GAME.equipped[a.key]?"Unequip":"Equip";
btn.onclick=()=>{ GAME.equipped[a.key]=!GAME.equipped[a.key]; renderInventory(); };
row.appendChild(btn); el.appendChild(row);
}
const cRow=document.createElement('div'); cRow.className='row';
cRow.innerHTML=`<span>Vital Stem x${GAME.consumables.vitalStem} Binding Seal x${GAME.consumables.bindingSeal}</span>`;
el.appendChild(cRow);
const wRow=document.createElement('div'); wRow.className='row';
const wname=GAME.weaponLevel===0?"Ember Blade":GAME.weaponLevel===1?"Ignited Edge":"Solar Falchion";
wRow.innerHTML=`<span>Weapon: <b>${wname}</b>${GAME.hasRanged?' + Aether Shard ('+GAME.ammo+' charges)':''}</span>`;
el.appendChild(wRow);
if(!accessories.some(a=>GAME.inventory[a.key]) ){
const e=document.createElement('div'); e.className='row'; e.innerHTML='<span style="color:#877">No accessories found yet.</span>'; el.appendChild(e);
}
}
document.getElementById('invClose').onclick=()=>document.getElementById('invModal').style.display='none';
// ---------- COMBAT ----------
function meleeAttack(){
if(player.attackCD>0) return;
const lvl=GAME.weaponLevel;
const speedFactor = GAME.equipped.band?0.82:1;
const cooldowns=[0.5,0.32,0.62];
player.attackCD=cooldowns[lvl]*speedFactor;
player.attacking=0.22+(lvl===2?0.12:0);
SFX.swing(lvl);
const range= lvl===2?46:34;
const dmgs=[2,3,4];
const dmg=dmgs[lvl];
const hb=attackHitbox(range);
const z=currentZone();
const list=z.enemies.concat(z.boss?[z.boss]:[]);
let hitSomething=false;
for(const en of list){
if(!en.alive) continue;
const er=new Rect(en.x-en.w/2,en.y-en.h/2,en.w,en.h);
if(rectsCollide(hb,er)){
damageEnemy(en,dmg,lvl);
hitSomething=true;
}
}
// breakables
for(const b of z.breakables){
if(b.broken) continue;
const br=new Rect(b.x,b.y,b.w,b.h);
if(rectsCollide(hb,br)){
const dmgToWall = lvl===2?2:(lvl===1?1:0.5);
b.hp-=dmgToWall;
spawnParticles(b.x+b.w/2,b.y+b.h/2,6,{color:'#a08050',life:0.4,speed:50});
if(b.hp<=0){ breakWall(b); } else { toast("The wall cracks further...",''); }
hitSomething=true;
}
}
if(!hitSomething){} // whiff, no extra sfx
if(lvl>=1){ spawnParticles(hb.x+hb.w/2,hb.y+hb.h/2,8,{color:lvl===2?'#ffe08a':'#ff8a3c',life:0.4,speed:70}); }
}
function attackHitbox(range){
const cx=player.x, cy=player.y;
if(GAME.facing==='up') return new Rect(cx-14,cy-range,28,range);
if(GAME.facing==='down') return new Rect(cx-14,cy,28,range);
if(GAME.facing==='left') return new Rect(cx-range,cy-14,range,28);
return new Rect(cx,cy-14,range,28);
}
function breakWall(b){
b.broken=true; SFX.breakWall();
spawnParticles(b.x+b.w/2,b.y+b.h/2,18,{color:'#8a6a3a',life:0.6,speed:90});
if(b.reveal){
if(b.reveal.type==='item'){ currentZone().pickups.push({type:'item',item:b.reveal.item,x:b.x+b.w/2,y:b.y+b.h/2}); toast("A hidden item is revealed!"); }
else if(b.reveal.type==='heart'){ currentZone().pickups.push({type:'heartContainer',x:b.x+b.w/2,y:b.y+b.h/2}); toast("A health container gleams within!"); }
}
}
function rangedAttack(){
if(!GAME.hasRanged) return;
if(GAME.ammo<=0){ toast("No Aether Shard charges left"); SFX.denied(); return; }
if(player.rangedCD>0) return;
player.rangedCD=0.5;
GAME.ammo--;
SFX.rangedFire();
const dir={up:[0,-1],down:[0,1],left:[-1,0],right:[1,0]}[GAME.facing];
projectiles.push({x:player.x,y:player.y,vx:dir[0]*420,vy:dir[1]*420,life:1.2,dmg:3});
}
let projectiles=[];
function updateProjectiles(dt){
const z=currentZone();
for(let i=projectiles.length-1;i>=0;i--){
const p=projectiles[i]; p.life-=dt; p.x+=p.vx*dt; p.y+=p.vy*dt;
let removed=false;
if(p.life<=0) removed=true;
for(const w of z.walls){ if(p.x>w.x&&p.x<w.x+w.w&&p.y>w.y&&p.y<w.y+w.h){ removed=true; break; } }
const list=z.enemies.concat(z.boss?[z.boss]:[]);
for(const en of list){ if(!en.alive) continue; if(Math.hypot(en.x-p.x,en.y-p.y)<20){ damageEnemy(en,p.dmg,3); removed=true; SFX.rangedHit(); break; } }
if(z.lightSwitch && !z.lightSwitch.hit && Math.hypot(z.lightSwitch.x-p.x,z.lightSwitch.y-p.y)<20){
z.lightSwitch.hit=true; SFX.plate(); toast("The crystal switch resonates — a path opens!"); removed=true;
screenShimmer();
}
if(z.farSwitch && !z.farSwitch.hit && Math.hypot(z.farSwitch.x-p.x,z.farSwitch.y-p.y)<20){
z.farSwitch.hit=true; SFX.plate(); if(!GAME.flags.heart5Got){ GAME.flags.heart5Got=true; gainHeartContainer(); }
removed=true;
}
for(const b of z.breakables){ if(!b.broken && p.x>b.x&&p.x<b.x+b.w&&p.y>b.y&&p.y<b.y+b.h){ b.hp-=1.5; removed=true; if(b.hp<=0) breakWall(b); } }
if(removed) projectiles.splice(i,1);
}
}
function damageEnemy(en,dmg,weaponLvl){
if(en.hitCD>0) return;
en.hp-=dmg; en.hitCD=0.15; en.flinch=0.25;
const critColor = weaponLvl>=2?'#7ecbff':'#ff9a3c';
spawnParticles(en.x,en.y,10,{color:critColor,life:0.4,speed:90});
SFX.hit();
if(weaponLvl===1) spawnParticles(en.x,en.y,6,{color:'#553','life':0.5,speed:20,grav:-20});
if(en.hp<=0 && en.alive){
en.alive=false;
const g=Math.floor(en.drop[0]+Math.random()*(en.drop[1]-en.drop[0]));
currentZone().pickups.push({type:'gold',x:en.x,y:en.y,amt:g});
spawnParticles(en.x,en.y,22,{color:'#c8a040',life:0.8,speed:110});
if(en.type==='boss'){ onBossDown(); }
}
}
function onBossDown(){
const z=currentZone();
if(GAME.zoneIndex===5){
toast("The Corrupted Warden falls... the Beacon stirs.",'rare');
z.beacon.lit=true; screenShimmer();
}
}
// ---------- BREAKABLE DIRECT ----------
function breakChestDirect(obj){ /* unused placeholder */ }
// ---------- UPDATE ----------
let lastT=performance.now();
let stepTimer=0;
function update(dt){
if(GAME.invuln>0) GAME.invuln-=dt;
if(player.attackCD>0) player.attackCD-=dt;
if(player.rangedCD>0) player.rangedCD-=dt;
if(player.attacking>0) player.attacking-=dt;
if(shimmerTimer>0) shimmerTimer-=dt;
const z=currentZone();
let mvx=0,mvy=0;
if(KEYS['KeyW']||KEYS['ArrowUp']){mvy=-1;GAME.facing='up';}
if(KEYS['KeyS']||KEYS['ArrowDown']){mvy=1;GAME.facing='down';}
if(KEYS['KeyA']||KEYS['ArrowLeft']){mvx=-1;GAME.facing='left';}
if(KEYS['KeyD']||KEYS['ArrowRight']){mvx=1;GAME.facing='right';}
const len=Math.hypot(mvx,mvy)||1;
const moving=(mvx!==0||mvy!==0);
const nx=player.x+ (mvx/len)*player.speed*dt;
const ny=player.y+ (mvy/len)*player.speed*dt;
const pr=new Rect(nx-player.w/2,player.y-player.h/2,player.w,player.h);
let blockedX=false;
for(const w of z.walls) if(rectsCollide(pr,w)) blockedX=true;
for(const b of z.breakables) if(!b.broken && rectsCollide(pr,new Rect(b.x,b.y,b.w,b.h))) blockedX=true;
if(z.doorForPlates && !z.flags_doorOpenCache && rectsCollide(pr,z.doorForPlates)) blockedX=true;
if(!blockedX) player.x=nx;
const pr2=new Rect(player.x-player.w/2,ny-player.h/2,player.w,player.h);
let blockedY=false;
for(const w of z.walls) if(rectsCollide(pr2,w)) blockedY=true;
for(const b of z.breakables) if(!b.broken && rectsCollide(pr2,new Rect(b.x,b.y,b.w,b.h))) blockedY=true;
if(z.doorForPlates && rectsCollide(pr2,z.doorForPlates) && !isPlateDoorOpen(z)) blockedY=true;
if(!blockedY) player.y=ny;
player.x=Math.max(20,Math.min(z.w-20,player.x));
player.y=Math.max(20,Math.min(z.h-20,player.y));
if(moving){ stepTimer-=dt; if(stepTimer<=0){ SFX.footstep(); stepTimer=0.26; } if(GAME.weaponLevel>=1){ player.emberT-=dt; if(player.emberT<=0){ spawnParticles(player.x,player.y+8,1,{color:'#ff8a3c',life:0.5,speed:10,grav:-5,size:2}); player.emberT=0.08; } } }
// plates
if(z.plates){
for(const pl of z.plates){
const wasPressed=pl.pressed;
pl.pressed = rectsCollide(new Rect(player.x-10,player.y-10,20,20),pl) || false;
if(pl.pressed && !wasPressed){ SFX.plate(); if(pl.id==='A') GAME.flags.zone3PlateA=performance.now(); if(pl.id==='B') GAME.flags.zone3PlateB=performance.now(); }
}
}
// enemies AI
const list=z.enemies.concat(z.boss?[z.boss]:[]);
for(const en of list){
if(!en.alive) continue;
if(en.hitCD>0) en.hitCD-=dt;
if(en.frozen>0){ en.frozen-=dt; continue; }
const d=Math.hypot(player.x-en.x,player.y-en.y);
if(d<260){
const ang=Math.atan2(player.y-en.y,player.x-en.x);
const sp=en.flinch>0?en.speed*0.2:en.speed;
if(en.flinch>0) en.flinch-=dt;
if(d>34){ en.x+=Math.cos(ang)*sp*dt; en.y+=Math.sin(ang)*sp*dt; }
else {
en.aiT-=dt;
if(en.aiT<=0){ en.aiT=1.0; tryDamagePlayer(en.dmg); }
}
}
}
updateProjectiles(dt);
updateParticles(dt);
// pickups collision
for(const p of z.pickups.slice()){
if(Math.hypot(p.x-player.x,p.y-player.y)<26){
if(p.type==='heartContainer'){ gainHeartContainer(); z.pickups=z.pickups.filter(x=>x!==p); }
else collectPickup(p);
}
}
// interact detection
nearInteract=null;
if(z.saveStone && !z.saveStone.used && Math.hypot(z.saveStone.x-player.x,z.saveStone.y-player.y)<40) nearInteract={type:'save',obj:z.saveStone};
if(z.shopNpc && Math.hypot(z.shopNpc.x-player.x,z.shopNpc.y-player.y)<50) nearInteract={type:'shop',obj:z.shopNpc};
if(z.altar && !z.altar.used && Math.hypot(z.altar.x-player.x,z.altar.y-player.y)<50) nearInteract={type:'altar',obj:z.altar};
updateInteractPrompt();
// pressure-plate door (zone2 collapsed gallery)
if(z.doorForPlates){
const open=isPlateDoorOpen(z);
z.flags_doorOpenCache=open;
}
// zone exit
if(z.exit){
const pr3=new Rect(player.x-10,player.y-10,20,20);
if(rectsCollide(pr3,z.exit.rect)){
if(z.exit.cond()){
goToZone(z.exit.to);
} else if(z.exit.lockedMsg && Math.random()<0.02){
toast(z.exit.lockedMsg); SFX.denied();
}
}
}
// beacon interact (zone5)
if(z.beacon && z.beacon.lit && Math.hypot(z.beacon.x+z.beacon.w/2-player.x,z.beacon.y+z.beacon.h/2-player.y)<60){
if(KEYS['KeyE']) winGame();
}
// camera
cam.x=Math.max(0,Math.min(z.w-VW,player.x-VW/2));
cam.y=Math.max(0,Math.min(z.h-VH,player.y-VH/2));
if(GAME.hp<=0) onDeath();
updateHUD();
}
function isPlateDoorOpen(z){
if(!z.plates) return true;
const allPressed=z.plates.every(p=>p.pressed);
return allPressed;
}
function tryDamagePlayer(dmg){
if(GAME.invuln>0) return;
let d=dmg;
if(GAME.equipped.cloak) d=Math.max(1,Math.round(d*0.6));
GAME.hp-=d*2; // dmg in "containers worth" -> scale to hp units
GAME.invuln=0.9;
SFX.damage();
spawnParticles(player.x,player.y,16,{color:'#ff3030',life:0.5,speed:80});
toast("-"+ (d*2) +" HP");
}
function onDeath(){
document.getElementById('deathScreen').style.display='flex';
}
document.getElementById('respawnBtn').onclick=respawnAtSave;
let won=false;
function winGame(){
if(won) return; won=true;
document.getElementById('victoryScreen').style.display='flex';
}
function goToZone(idx){
GAME.zoneIndex=idx;
const z=currentZone();
player.x=z.entrance.x; player.y=z.entrance.y;
showZoneBanner(z.name);
}
// ---------- HUD ----------
function heartSVG(fillFrac){
const c = fillFrac>=1?'#c0302a':(fillFrac>0?'#7a1f1a':'#2a1414');
return `<svg viewBox="0 0 32 28"><path d="M16 26 C16 26 2 16 2 8 C2 2 8 0 11 3 C13 5 16 8 16 8 C16 8 19 5 21 3 C24 0 30 2 30 8 C30 16 16 26 16 26 Z" fill="${c}" stroke="#000" stroke-width="1.5"/></svg>`;
}
function updateHUD(){
const hEl=document.getElementById('hearts'); hEl.innerHTML='';
for(let i=0;i<GAME.maxContainers;i++){
const containerHP=Math.max(0,Math.min(2,GAME.hp-i*2));
const frac=containerHP/2;
const div=document.createElement('div'); div.className='heart'; div.innerHTML=heartSVG(frac);
hEl.appendChild(div);
}
document.getElementById('goldAmt').textContent=GAME.gold;
const wname=GAME.weaponLevel===0?"Ember Blade":GAME.weaponLevel===1?"Ignited Edge":"Solar Falchion";
document.getElementById('weaponInfo').textContent = wname + (GAME.hasRanged?" | Aether Shard x"+GAME.ammo:"");
}
function updateInteractPrompt(){
const el=document.getElementById('interactPrompt');
if(nearInteract){
let msg = nearInteract.type==='save'?"Press E to rest at the Sanctuary Stone":
nearInteract.type==='shop'?"Press E to trade with the Merchant Altar":
nearInteract.type==='altar'?"Press E to channel the altar's power":"";
el.textContent=msg; el.style.display='block';
} else el.style.display='none';
}
// ---------- RENDER ----------
function drawEnvironment(z){
ctx.fillStyle=z.ground; ctx.fillRect(0,0,VW,VH);
// ambient decoration: gears / dust based on zone index
ctx.save();
ctx.translate(-cam.x,-cam.y);
// vignette floor pattern
ctx.strokeStyle='rgba(255,255,255,0.03)';
for(let gx=0;gx<z.w;gx+=60){ ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,z.h); ctx.stroke(); }
for(let gy=0;gy<z.h;gy+=60){ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(z.w,gy); ctx.stroke(); }
for(const w of z.walls){
const grad=ctx.createLinearGradient(w.x,w.y,w.x,w.y+w.h);
grad.addColorStop(0,'#4a3a20'); grad.addColorStop(1,'#241a0e');
ctx.fillStyle=grad; ctx.fillRect(w.x,w.y,w.w,w.h);
ctx.strokeStyle='#6b5327'; ctx.strokeRect(w.x,w.y,w.w,w.h);
}
for(const b of z.breakables){
if(b.broken) continue;
ctx.fillStyle='#3a2e1c'; ctx.fillRect(b.x,b.y,b.w,b.h);
ctx.strokeStyle='#1a1408'; ctx.strokeRect(b.x,b.y,b.w,b.h);
const dmgFrac=1-(b.hp/b.maxhp);
ctx.strokeStyle='rgba(20,10,5,'+(0.4+dmgFrac*0.6)+')'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(b.x+4,b.y+4); ctx.lineTo(b.x+b.w-6,b.y+b.h-8); ctx.moveTo(b.x+b.w-4,b.y+6); ctx.lineTo(b.x+6,b.y+b.h-6); ctx.stroke();
ctx.lineWidth=1;
}
if(z.plates){
for(const pl of z.plates){
ctx.fillStyle=pl.pressed?'#8a6a2a':'#5a4a24';
ctx.fillRect(pl.x,pl.y,pl.w,pl.h);
ctx.strokeStyle='#c9a24a'; ctx.strokeRect(pl.x,pl.y,pl.w,pl.h);
}
}
if(z.doorForPlates){
const open=isPlateDoorOpen(z);
if(!open){ ctx.fillStyle='#6b3a2a'; ctx.fillRect(z.doorForPlates.x,z.doorForPlates.y,z.doorForPlates.w,z.doorForPlates.h); }
}
if(z.lightSwitch){
ctx.fillStyle=z.lightSwitch.hit?'#7ecbff':'#345'; ctx.beginPath(); ctx.arc(z.lightSwitch.x,z.lightSwitch.y,12,0,7); ctx.fill();
ctx.strokeStyle='#bcf'; ctx.stroke();
if(z.puzzleDoor && !z.lightSwitch.hit){ ctx.fillStyle='#403050'; ctx.fillRect(z.puzzleDoor.x,z.puzzleDoor.y,z.puzzleDoor.w,z.puzzleDoor.h); }
}
if(z.farSwitch){
ctx.fillStyle=z.farSwitch.hit?'#7ecbff':'#345'; ctx.beginPath(); ctx.arc(z.farSwitch.x,z.farSwitch.y,12,0,7); ctx.fill();
ctx.strokeStyle='#bcf'; ctx.stroke();
}
if(z.saveStone){
const s=z.saveStone;
const glow=s.used?0.4:0.9;
ctx.save(); ctx.globalAlpha=glow*(0.7+0.3*Math.sin(performance.now()/300));
ctx.fillStyle='#e0a838'; ctx.beginPath(); ctx.arc(s.x,s.y,10,0,7); ctx.fill();
ctx.restore();
ctx.fillStyle='#8a6a2a'; ctx.fillRect(s.x-14,s.y+6,28,14);
}
if(z.altar){
const a=z.altar;
ctx.fillStyle='#5a4428'; ctx.fillRect(a.x-22,a.y+10,44,20);
ctx.save();
const col=a.type==='ignited'?'#ff7a2e':'#ffd75e';
ctx.globalAlpha=a.used?0.25:0.9+0.1*Math.sin(performance.now()/200);
ctx.fillStyle=col; ctx.beginPath(); ctx.arc(a.x,a.y,14,0,7); ctx.fill();
ctx.restore();
}
if(z.shopNpc){
ctx.fillStyle='#7a5a2e'; ctx.fillRect(z.shopNpc.x-16,z.shopNpc.y-24,32,48);
ctx.fillStyle='#e0c890'; ctx.beginPath(); ctx.arc(z.shopNpc.x,z.shopNpc.y-30,10,0,7); ctx.fill();
}
if(z.beacon){
const bc=z.beacon;
ctx.save();
ctx.fillStyle=bc.lit?'#ffe089':'#334';
ctx.globalAlpha=bc.lit?0.85+0.15*Math.sin(performance.now()/150):0.6;
ctx.fillRect(bc.x,bc.y,bc.w,bc.h);
ctx.restore();
ctx.strokeStyle='#c9a24a'; ctx.strokeRect(bc.x,bc.y,bc.w,bc.h);
}
for(const p of z.pickups){
if(p.type==='gold'){ ctx.fillStyle='#e0c04a'; ctx.beginPath(); ctx.arc(p.x,p.y,6,0,7); ctx.fill(); ctx.strokeStyle='#8a6a1a'; ctx.stroke(); }
else if(p.type==='heartContainer'){ ctx.save(); ctx.translate(p.x-10,p.y-10); ctx.scale(0.7,0.7); ctx.fillStyle='#c0302a'; ctx.beginPath(); ctx.arc(10,10,10,0,7); ctx.fill(); ctx.restore(); }
else { ctx.save(); ctx.globalAlpha=0.85+0.15*Math.sin(performance.now()/200+p.x);
ctx.fillStyle = p.item==='vitalStem'?'#5ad16a': p.item==='bindingSeal'?'#4ab0ff' : p.item==='aetherShard'?'#7ecbff':'#e8d9a0';
ctx.beginPath(); ctx.arc(p.x,p.y,8,0,7); ctx.fill(); ctx.restore();
}
}
const list=z.enemies.concat(z.boss?[z.boss]:[]);
for(const en of list){
if(!en.alive) continue;
ctx.save();
if(en.frozen>0){ ctx.globalAlpha=0.6; ctx.fillStyle='#7ecbff'; ctx.fillRect(en.x-en.w/2-2,en.y-en.h/2-2,en.w+4,en.h+4); ctx.globalAlpha=1; }
ctx.fillStyle= en.type==='boss'?'#8a2a2a':'#5a4a3a';
const scale= en.type==='boss'?1.8:1;
ctx.fillRect(en.x-en.w/2*scale,en.y-en.h/2*scale,en.w*scale,en.h*scale);
ctx.strokeStyle= en.flinch>0?'#ff7a2e':'#2a2015'; ctx.strokeRect(en.x-en.w/2*scale,en.y-en.h/2*scale,en.w*scale,en.h*scale);
// hp bar
ctx.fillStyle='#000'; ctx.fillRect(en.x-16,en.y-en.h/2*scale-10,32,5);
ctx.fillStyle='#c0302a'; ctx.fillRect(en.x-16,en.y-en.h/2*scale-10,32*Math.max(0,en.hp/en.maxhp),5);
ctx.restore();
}
// player draw
drawPlayer();
drawParticles(cam);
ctx.restore();
}
function drawPlayer(){
const x=player.x-cam.x, y=player.y-cam.y;
ctx.save();
ctx.translate(x,y);
// cloak
ctx.fillStyle = GAME.equipped.cloak?'#241826':'#2e2a3a';
ctx.beginPath(); ctx.ellipse(0,4,13,16,0,0,7); ctx.fill();
if(GAME.equipped.cloak){ ctx.strokeStyle='#5a3a6a'; ctx.stroke(); }
// body
ctx.fillStyle='#d8c39a';
ctx.fillRect(-7,-14,14,20);
// blade
let bladeColor='#9aa0a8', bladeGlow=null;
if(GAME.weaponLevel===1){ bladeColor='#ff7a2e'; bladeGlow='rgba(255,120,40,0.6)'; }
if(GAME.weaponLevel===2){ bladeColor='#ffd75e'; bladeGlow='rgba(255,220,100,0.7)'; }
let bx=10,by=0,bw=6,bh= GAME.weaponLevel===2?32:24;
if(GAME.facing==='left'){bx=-10-bw;}
if(GAME.facing==='up'){bx=-3;by=-22;bw=GAME.weaponLevel===2?32:24;bh=6;}
if(GAME.facing==='down'){bx=-3;by=16;bw=GAME.weaponLevel===2?32:24;bh=6;}
if(bladeGlow){ ctx.save(); ctx.shadowColor=bladeGlow; ctx.shadowBlur=14; ctx.fillStyle=bladeColor; ctx.fillRect(bx,by-bh/2,bw,bh); ctx.restore(); }
else { ctx.fillStyle=bladeColor; ctx.fillRect(bx,by-bh/2,bw,bh); }
if(player.attacking>0){
ctx.save(); ctx.globalAlpha=0.5; ctx.strokeStyle=bladeColor; ctx.lineWidth=3;
ctx.beginPath(); ctx.arc(0,0, GAME.weaponLevel===2?44:32, -0.8,0.8); ctx.stroke(); ctx.restore();
}
// off-hand crystal for ranged
if(GAME.hasRanged){
ctx.save(); ctx.shadowColor='rgba(120,200,255,0.8)'; ctx.shadowBlur=10+4*Math.sin(performance.now()/150);
ctx.fillStyle='#7ecbff'; ctx.beginPath(); ctx.arc(-11,-2,4,0,7); ctx.fill(); ctx.restore();
}
// accessory tokens
if(GAME.equipped.band){ ctx.strokeStyle='#dde'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(-7,6,3,0,7); ctx.stroke(); ctx.lineWidth=1; }
if(GAME.equipped.lantern){ ctx.save(); ctx.shadowColor='rgba(90,160,255,0.9)'; ctx.shadowBlur=12; ctx.fillStyle='#8ac6ff'; ctx.beginPath(); ctx.arc(7,10,3,0,7); ctx.fill(); ctx.restore(); }
// head
ctx.fillStyle='#caa876'; ctx.beginPath(); ctx.arc(0,-18,7,0,7); ctx.fill();
ctx.restore();
// damage flash
if(GAME.invuln>0.6){
ctx.save(); ctx.globalAlpha=0.35; ctx.fillStyle='#ff2020';
ctx.beginPath(); ctx.arc(x,y,26,0,7); ctx.fill(); ctx.restore();
}
}
function render(){
const z=currentZone();
ctx.clearRect(0,0,VW,VH);
drawEnvironment(z);
for(const p of projectiles){
ctx.save(); ctx.translate(p.x-cam.x,p.y-cam.y);
ctx.shadowColor='rgba(120,200,255,0.9)'; ctx.shadowBlur=10;
ctx.fillStyle='#bfe8ff'; ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
ctx.restore();
}
if(shimmerTimer>0){
ctx.save(); ctx.globalAlpha=Math.min(1,shimmerTimer)*0.5;
const grad=ctx.createLinearGradient(0,0,VW,0);
grad.addColorStop(0,'rgba(255,220,130,0.5)'); grad.addColorStop(0.15,'rgba(0,0,0,0)');
grad.addColorStop(0.85,'rgba(0,0,0,0)'); grad.addColorStop(1,'rgba(255,220,130,0.5)');
ctx.fillStyle=grad; ctx.fillRect(0,0,VW,VH);
ctx.restore();
}
}
// ---------- LOOP ----------
function loop(now){
const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;
if(document.getElementById('deathScreen').style.display!=='flex' && document.getElementById('victoryScreen').style.display!=='flex'){
update(dt);
}
render();
requestAnimationFrame(loop);
}
// init
resetPlayerToEntrance();
updateHUD();
showZoneBanner(currentZone().name);
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-sonnet-5","provider":"openrouter","persona":"full","sessionId":"cbec64e3-ce80-448c-a97a-6827397cdc80","tokensIn":293112,"tokensOut":28095,"tokensTotal":321207,"cost":0.44042909999999996,"turns":8,"toolCalls":7,"failedToolCalls":0,"timestamp":"2026-09-30T21:57:24.494Z"} -->