Warden
Grok 4.6 · 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 78,851 bytes · SHA-256 23760964e5d5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Warden's Corrosion</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet" />
<style>
:root {
--gold: #e0b45a;
--gold-dim: #a67c3a;
--ember: #d4652f;
--slate: #121820;
--ink: #0a0d12;
--mist: #8aa0b8;
--heart: #c94a4a;
--ok: #7dba6a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: var(--ink);
color: #e8dcc4;
font-family: "Cormorant Garamond", Palatino, Georgia, serif;
user-select: none;
}
#wrap {
position: relative; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at 50% 40%, #1a2230 0%, #0a0d12 70%);
}
canvas { display: block; background: #0d1218; }
#hud {
position: absolute; inset: 0; pointer-events: none;
font-family: Cinzel, Palatino, serif;
}
#hud-top {
position: absolute; top: 18px; left: 22px; right: 22px;
display: flex; justify-content: space-between; align-items: flex-start;
}
#hearts { display: flex; gap: 6px; filter: drop-shadow(0 2px 4px #000a); }
.heart {
width: 26px; height: 24px; position: relative;
}
.heart svg { width: 100%; height: 100%; }
#meta {
text-align: right; font-size: 13px; letter-spacing: 0.14em;
color: var(--gold); text-shadow: 0 1px 6px #000c;
}
#meta .gold { font-size: 18px; font-weight: 600; }
#meta .zone { color: #b8c4d4; letter-spacing: 0.22em; font-size: 11px; margin-top: 4px; opacity: 0.85; }
#meta .charges { color: #8ec8e8; font-size: 12px; margin-top: 3px; letter-spacing: 0.12em; }
#prompt {
position: absolute; bottom: 86px; left: 50%; transform: translateX(-50%);
font-size: 15px; letter-spacing: 0.18em; color: var(--gold);
text-shadow: 0 2px 10px #000; opacity: 0; transition: opacity 0.2s;
white-space: nowrap;
}
#prompt.show { opacity: 1; }
#toast {
position: absolute; top: 22%; left: 50%; transform: translateX(-50%);
text-align: center; pointer-events: none;
}
#toast .title {
font-family: Cinzel, serif; font-size: 22px; letter-spacing: 0.28em;
color: var(--gold); text-shadow: 0 0 24px #e0b45a88, 0 2px 8px #000;
opacity: 0; transform: translateY(8px);
transition: opacity 0.5s, transform 0.5s;
}
#toast .sub {
font-style: italic; font-size: 16px; color: #d4c8b0; margin-top: 6px;
opacity: 0; transition: opacity 0.6s 0.15s;
}
#toast.show .title, #toast.show .sub { opacity: 1; transform: none; }
#log {
position: absolute; bottom: 18px; left: 22px; max-width: 420px;
font-size: 14px; color: #c9bda4; text-shadow: 0 1px 4px #000;
line-height: 1.35;
}
#log div { opacity: 0.95; animation: fadeLog 5s forwards; }
@keyframes fadeLog { 0%{opacity:1} 70%{opacity:1} 100%{opacity:0} }
#controls-hint {
position: absolute; bottom: 16px; right: 20px;
font-size: 11px; letter-spacing: 0.16em; color: #8a8070; text-align: right;
line-height: 1.6;
}
#inv-panel, #shop-panel, #pause-panel {
pointer-events: auto;
position: absolute; inset: 0;
background: radial-gradient(ellipse at center, #121820ee 0%, #0a0d12f2 100%);
display: none; align-items: center; justify-content: center;
flex-direction: column;
}
#inv-panel.open, #shop-panel.open, #pause-panel.open { display: flex; }
.panel {
width: min(640px, 92vw); border: 1px solid #a67c3a66;
background: linear-gradient(180deg, #1a2230 0%, #121820 100%);
box-shadow: 0 0 0 1px #000, 0 20px 80px #000c, 0 0 60px #e0b45a18;
padding: 28px 32px 24px;
}
.panel h2 {
font-family: Cinzel, serif; font-weight: 600; letter-spacing: 0.32em;
font-size: 15px; color: var(--gold); text-align: center;
margin-bottom: 18px;
}
.slots { display: flex; flex-direction: column; gap: 8px; }
.slot {
display: grid; grid-template-columns: 42px 1fr auto; gap: 12px; align-items: center;
padding: 10px 12px; border: 1px solid #3d4d6355; background: #0e141c;
cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.slot:hover, .slot.sel { border-color: var(--gold-dim); background: #1a2430; }
.slot .ico { width: 36px; height: 36px; display: grid; place-items: center; font-size: 20px; }
.slot .nm { font-family: Cinzel, serif; font-size: 13px; letter-spacing: 0.12em; color: #e8dcc4; }
.slot .ds { font-size: 13px; color: #9aa6b4; font-style: italic; }
.slot .st { font-size: 11px; letter-spacing: 0.16em; color: var(--gold); }
.slot.on { border-color: #7dba6a88; }
.panel .foot {
margin-top: 16px; text-align: center; font-size: 12px; letter-spacing: 0.2em; color: #7a7264;
}
#overlay-title {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
flex-direction: column; background: #07090d;
z-index: 20;
}
#overlay-title.gone { display: none; }
#overlay-title .crest {
width: 72px; height: 72px; margin-bottom: 18px; opacity: 0.9;
}
#overlay-title h1 {
font-family: Cinzel, serif; font-weight: 700; font-size: clamp(28px, 5vw, 52px);
letter-spacing: 0.22em; color: var(--gold);
text-shadow: 0 0 40px #e0b45a44;
}
#overlay-title .subhead {
margin-top: 10px; letter-spacing: 0.46em; font-size: 12px; color: #8aa0b8;
text-transform: uppercase;
}
#overlay-title .lore {
max-width: 540px; text-align: center; margin: 28px 24px 0;
font-size: 17px; line-height: 1.55; color: #c4b8a0; font-style: italic;
}
#overlay-title button, .menu-btn {
margin-top: 36px; pointer-events: auto; cursor: pointer;
background: transparent; color: var(--gold);
border: 1px solid var(--gold-dim);
font-family: Cinzel, serif; letter-spacing: 0.34em; font-size: 13px;
padding: 12px 34px; transition: background 0.2s, box-shadow 0.2s;
}
#overlay-title button:hover, .menu-btn:hover {
background: #e0b45a18; box-shadow: 0 0 24px #e0b45a33;
}
#end-screen {
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
flex-direction: column; background: radial-gradient(ellipse, #1c1810ee, #07090df6);
z-index: 25; text-align: center; padding: 24px;
}
#end-screen.show { display: flex; }
#end-screen h1 {
font-family: Cinzel, serif; letter-spacing: 0.28em; color: var(--gold);
font-size: clamp(26px, 4vw, 44px); text-shadow: 0 0 50px #e0b45a66;
}
#end-screen p { max-width: 520px; margin-top: 20px; font-size: 18px; font-style: italic; color: #d4c8b0; line-height: 1.5; }
#dmg-flash {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(ellipse at center, transparent 40%, #c94a4a55 100%);
opacity: 0; transition: opacity 0.08s;
}
#shimmer {
position: absolute; inset: 0; pointer-events: none;
box-shadow: inset 0 0 80px 20px #e0b45a33;
opacity: 0; transition: opacity 0.4s;
}
#death {
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
flex-direction: column; background: #0a0608ee; z-index: 18;
}
#death.show { display: flex; }
#death h2 { font-family: Cinzel, serif; letter-spacing: 0.4em; color: #c94a4a; font-size: 28px; }
#death p { margin-top: 12px; color: #b8a898; letter-spacing: 0.12em; }
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div id="hud">
<div id="hud-top">
<div id="hearts"></div>
<div id="meta">
<div class="gold" id="gold-lbl">0 ✦</div>
<div class="zone" id="zone-lbl">THE ASHEN THRESHOLD</div>
<div class="charges" id="charge-lbl"></div>
</div>
</div>
<div id="prompt"></div>
<div id="toast"><div class="title"></div><div class="sub"></div></div>
<div id="log"></div>
<div id="controls-hint">WASD MOVE · J / CLICK SWING · K SHARD<br>E INTERACT · I INVENTORY · ESC PAUSE</div>
</div>
<div id="dmg-flash"></div>
<div id="shimmer"></div>
<div id="inv-panel">
<div class="panel">
<h2>WARDEN'S SATCHEL</h2>
<div class="slots" id="inv-slots"></div>
<div class="foot">CLICK TO USE / TOGGLE · I OR ESC TO CLOSE</div>
</div>
</div>
<div id="shop-panel">
<div class="panel">
<h2>MERCHANT ALTAR</h2>
<div class="slots" id="shop-slots"></div>
<div class="foot">CLICK TO PURCHASE · ESC TO LEAVE</div>
</div>
</div>
<div id="pause-panel">
<div class="panel" style="text-align:center">
<h2>SUSPENDED</h2>
<p style="font-style:italic;color:#b8a898;margin-bottom:8px">The Observatory holds its breath.</p>
<button class="menu-btn" id="btn-resume">RESUME</button>
<div class="foot" style="margin-top:20px">ESC TO RETURN</div>
</div>
</div>
<div id="death">
<h2>CORRODED</h2>
<p>The Sanctuary Stone recalls you…</p>
</div>
<div id="end-screen">
<h1>THE BEACON IGNITES</h1>
<p>Light pours through the Sunken Observatory. The Corrosion recedes into the storm. The Last Warden stands watch once more.</p>
<button class="menu-btn" id="btn-again" style="margin-top:28px">BEGIN ANEW</button>
</div>
<div id="overlay-title">
<svg class="crest" viewBox="0 0 72 72" fill="none">
<circle cx="36" cy="36" r="30" stroke="#e0b45a" stroke-width="1.2" opacity="0.7"/>
<path d="M36 10 L40 28 L58 28 L44 38 L50 56 L36 46 L22 56 L28 38 L14 28 L32 28 Z" fill="#e0b45a" opacity="0.85"/>
</svg>
<h1>THE WARDEN'S CORROSION</h1>
<div class="subhead">Aurelion's Sunken Observatory</div>
<p class="lore">The Core Beacon gutters. Black-iron decay climbs the bronze halls. You are the Last Warden — the only guardian left who can reignite the light before the structure falls into the storm.</p>
<button id="btn-begin">TAKE UP THE BLADE</button>
</div>
</div>
<script>
window.__G = {};
(() => {
"use strict";
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const T = 40;
const COL = {
gold: "#e0b45a", ember: "#e07030", slate: "#1c2836", floor: "#243040",
wall: "#3a2c22", rust: "#6a3a22", mist: "#2a4050", deep: "#121820"
};
let W = 1280, H = 720, dpr = 1;
function resize() {
const wrap = document.getElementById("wrap");
W = wrap.clientWidth; H = wrap.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + "px"; canvas.style.height = H + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();
// ─── Audio ───────────────────────────────────────────────
const AudioSys = (() => {
let ac = null, master, ambGain, footT = 0;
const ensure = () => {
if (ac) return ac;
ac = new (window.AudioContext || window.webkitAudioContext)();
master = ac.createGain(); master.gain.value = 0.28; master.connect(ac.destination);
ambGain = ac.createGain(); ambGain.gain.value = 0.07; ambGain.connect(master);
return ac;
};
const env = (g, t, a, s, r) => {
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(s, t + a);
g.gain.exponentialRampToValueAtTime(0.0001, t + a + r);
};
const tone = (f, dur, type, vol, atk=0.01, slide=0) => {
const a = ensure(), t = a.currentTime;
const o = a.createOscillator(), g = a.createGain();
o.type = type; o.frequency.setValueAtTime(f, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, f + slide), t + dur);
g.gain.value = 0; env(g, t, atk, vol, dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.05);
};
const noise = (dur, vol, bp, q=4) => {
const a = ensure(), t = a.currentTime, n = a.sampleRate * dur;
const buf = a.createBuffer(1, n, a.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = a.createBufferSource(); src.buffer = buf;
const f = a.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = bp; f.Q.value = q;
const g = a.createGain(); env(g, t, 0.005, vol, dur);
src.connect(f); f.connect(g); g.connect(master); src.start(t);
};
return {
resume() { ensure().resume(); },
swingBase() { noise(0.12, 0.35, 800, 1.2); tone(220, 0.1, "triangle", 0.15, 0.005, -80); },
swingFire() { noise(0.16, 0.3, 500, 0.8); tone(180, 0.14, "sawtooth", 0.1, 0.01, -40); tone(420, 0.2, "triangle", 0.08, 0.02, 80); },
swingSolar() { tone(90, 0.28, "sine", 0.28, 0.06, -20); tone(180, 0.22, "triangle", 0.16, 0.04); noise(0.2, 0.25, 300, 0.6); },
impact() { noise(0.08, 0.4, 200, 0.8); tone(90, 0.1, "sine", 0.2); },
impactFire() { noise(0.12, 0.35, 350, 0.7); tone(140, 0.16, "sawtooth", 0.12, 0.01, -60); },
impactSolar() { tone(70, 0.3, "sine", 0.32, 0.02); tone(220, 0.18, "triangle", 0.12); noise(0.15, 0.3, 180, 0.5); },
twang() { tone(880, 0.18, "triangle", 0.18, 0.005, -200); tone(1320, 0.12, "sine", 0.08, 0.005, -400); },
chimeHit() { tone(1480, 0.25, "sine", 0.1, 0.005, -100); },
hurt() { noise(0.14, 0.45, 160, 0.6); tone(310, 0.16, "square", 0.08, 0.005, -180); tone(980, 0.1, "sine", 0.07); },
pickup() { tone(523, 0.12, "sine", 0.14, 0.005, 80); tone(784, 0.18, "sine", 0.1, 0.01); },
rare() { tone(392, 0.15, "sine", 0.14); tone(523, 0.2, "sine", 0.12, 0.04); tone(784, 0.28, "triangle", 0.1, 0.08); },
coin() { tone(980, 0.08, "square", 0.08, 0.002); tone(1310, 0.1, "triangle", 0.07, 0.02); },
shopNear() { tone(110, 0.6, "sine", 0.05, 0.2, 10); },
buy() { tone(330, 0.08, "triangle", 0.12); tone(660, 0.14, "sine", 0.1, 0.04); },
save() { tone(261, 0.3, "sine", 0.12, 0.05); tone(329, 0.4, "sine", 0.1, 0.1); tone(392, 0.5, "triangle", 0.08, 0.15); },
door() { noise(0.3, 0.3, 120, 0.5); tone(80, 0.35, "sine", 0.15); },
break() { noise(0.22, 0.4, 280, 0.7); tone(100, 0.15, "sawtooth", 0.1, 0.005, -50); },
plate() { tone(180, 0.12, "triangle", 0.1); noise(0.08, 0.15, 400, 2); },
switchHit() { tone(640, 0.2, "sine", 0.14, 0.005); tone(960, 0.25, "triangle", 0.08, 0.04); },
altar() { tone(196, 0.4, "sine", 0.14, 0.08); tone(392, 0.5, "triangle", 0.1, 0.12); tone(588, 0.7, "sine", 0.08, 0.2); },
freeze() { tone(740, 0.3, "sine", 0.12, 0.01, -200); tone(1480, 0.2, "triangle", 0.06); },
foot(kind, now) {
if (now - footT < 0.28) return; footT = now;
if (kind === "grate") { noise(0.06, 0.18, 700, 3); tone(180, 0.05, "square", 0.03); }
else if (kind === "wet") { noise(0.08, 0.14, 400, 1.4); tone(140, 0.07, "sine", 0.04); }
else { noise(0.05, 0.12, 220, 1.1); }
},
amb(zone) {
if (!ac) return;
// simple drifting pad
},
boom() { tone(55, 0.6, "sine", 0.35, 0.02, -10); noise(0.4, 0.4, 90, 0.4); }
};
})();
// ─── Particles ───────────────────────────────────────────
const P = [];
function burst(x, y, n, cols, spd, life, size, grav=0) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, s = Math.random() * spd;
P.push({
x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s,
life, max: life, col: cols[i % cols.length],
size: size * (0.5 + Math.random()), grav, type: 0
});
}
}
function emberTrail(x, y) {
P.push({
x: x + (Math.random()-0.5)*8, y: y + (Math.random()-0.5)*8,
vx: (Math.random()-0.5)*12, vy: -20 - Math.random()*20,
life: 0.4 + Math.random()*0.3, max: 0.7, col: Math.random()>0.5 ? "#e07030" : "#f0b060",
size: 2 + Math.random()*2, grav: 30, type: 1
});
}
function goldDust(x, y) {
for (let i = 0; i < 14; i++) {
P.push({
x, y, vx: (Math.random()-0.5)*50, vy: -20 - Math.random()*40,
life: 0.6 + Math.random()*0.4, max: 1, col: "#e0b45a",
size: 2 + Math.random()*2, grav: 40, type: 1
});
}
}
function whiteMotes(x, y) {
for (let i = 0; i < 18; i++) {
P.push({
x, y, vx: (Math.random()-0.5)*30, vy: -30 - Math.random()*50,
life: 0.8 + Math.random()*0.5, max: 1.3, col: i%2?"#fff8e0":"#c8e8ff",
size: 2 + Math.random()*3, grav: 10, type: 1
});
}
}
function tickP(dt) {
for (let i = P.length - 1; i >= 0; i--) {
const p = P[i];
p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += p.grav * dt;
if (p.life <= 0) P.splice(i, 1);
}
}
function drawP(cam) {
for (const p of P) {
const a = Math.max(0, p.life / p.max);
ctx.globalAlpha = a;
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x - cam.x, p.y - cam.y, p.size * (0.5 + a*0.5), 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ─── Input ───────────────────────────────────────────────
const keys = Object.create(null);
const mouse = { x: 0, y: 0, down: false, worldX: 0, worldY: 0, moved: false };
window.addEventListener("keydown", e => {
keys[e.code] = true;
if (["Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.code)) e.preventDefault();
onKey(e);
});
window.addEventListener("keyup", e => { keys[e.code] = false; });
canvas.addEventListener("mousemove", e => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; mouse.moved = true;
});
canvas.addEventListener("mousedown", e => { mouse.down = true; if (e.button === 0) tryMelee(); });
window.addEventListener("mouseup", () => { mouse.down = false; });
// ─── Game State ──────────────────────────────────────────
const S = {
started: false, paused: false, over: false, invOpen: false, shopOpen: false,
zone: 0,
gold: 0,
hp: 6, maxHp: 6, hearts: 3,
weapon: 0, // 0 base, 1 ignited, 2 solar
hasShard: false, charges: 0, maxCharges: 12,
items: {
band: false, lantern: false, cloak: false,
stems: 0, seals: 0
},
equip: { band: false, lantern: false, cloak: false },
flags: {
save1: false, save2: false, save3: false,
shopUnlocked: false,
heart4: false, heart5: false,
bandGot: false, lanternGot: false, cloakGot: false,
shardGot: false, w1: false, w2: false,
galleryBoss: false, spireGuard: false, coreDead: false,
wallA: false, wallB: false, wallC: false, wallD: false, wallE: false,
plateA: false, plateB: false, plateC: false,
switchA: false, switchB: false, switchC: false, switchD: false, switchE: false,
chestBand: false, chestGold5: false, jarSeal: false, cacheStem: false,
door2: false, door3: false, door4: false, door5: false, door6: false,
galleryBridge: false, vaultGate: false, spireGate: false,
ammoShop: false, boughtHeart: false
},
shopStock: { stem: 3, seal: 2, heart: 1 },
lastSave: { zone: 0, x: 0, y: 0 },
msg: "", prompt: "",
freezeT: 0
};
const ZN = [
{ id: 0, name: "THE ASHEN THRESHOLD", floor: "stone", amb: "dust" },
{ id: 1, name: "THE RUSTED ARCHIVES", floor: "grate", amb: "dust" },
{ id: 2, name: "THE COLLAPSED GALLERY", floor: "stone", amb: "dust" },
{ id: 3, name: "THE SUNKEN VAULT", floor: "wet", amb: "mist" },
{ id: 4, name: "THE TWIN SPIRE", floor: "stone", amb: "wind" },
{ id: 5, name: "THE CORE CHAMBER", floor: "stone", amb: "core" }
];
// ─── Maps ────────────────────────────────────────────────
// # wall . floor , grate ~ wet = bronze
// + breakable P plate ^ save $ shop A altar
// X ranged switch D next-door H hazard B pot
// C chest G gold ? faint crack (lantern)
const MAPS = [];
const ENT = []; // per-zone entity lists built from maps + extras
function parseMap(str) {
const rows = str.trim().split("\n").map(r => r.replace(/^\s+/, ""));
const h = rows.length, w = rows[0].length;
const tiles = [];
const ents = [];
for (let y = 0; y < h; y++) {
tiles[y] = [];
const row = rows[y].padEnd(w, "#");
for (let x = 0; x < w; x++) {
const ch = row[x] || "#";
let t = 1;
if (ch === "." || ch === " " ) t = 0;
else if (ch === ",") t = 2;
else if (ch === "~") t = 3;
else if (ch === "=") t = 4;
else if (ch === "#") t = 1;
else if (ch === "+") { t = 5; }
else if (ch === "?") { t = 6; }
else { t = 0; }
tiles[y][x] = t;
const px = x * T + T/2, py = y * T + T/2;
if (ch === "^") ents.push({ kind: "save", x: px, y: py });
if (ch === "$") ents.push({ kind: "shop", x: px, y: py });
if (ch === "A") ents.push({ kind: "altar", x: px, y: py });
if (ch === "P") ents.push({ kind: "plate", x: px, y: py, id: "auto" });
if (ch === "X") ents.push({ kind: "switch", x: px, y: py, id: "auto" });
if (ch === "D") ents.push({ kind: "door", x: px, y: py });
if (ch === "H") ents.push({ kind: "hazard", x: px, y: py });
if (ch === "B") ents.push({ kind: "pot", x: px, y: py });
if (ch === "C") ents.push({ kind: "chest", x: px, y: py });
if (ch === "G") ents.push({ kind: "cache", x: px, y: py });
if (ch === "E") ents.push({ kind: "enemy", x: px, y: py, type: "construct" });
if (ch === "N") ents.push({ kind: "enemy", x: px, y: py, type: "sentinel" });
if (ch === "M") ents.push({ kind: "enemy", x: px, y: py, type: "brute" });
if (ch === "K") ents.push({ kind: "beacon", x: px, y: py });
}
}
return { w, h, tiles, ents };
}
MAPS[0] = parseMap(`
########################################
#......................................#
#..^...................................#
#......................................#
#..........E..............B............#
#......................................#
#....................E.................#
#......####................####........#
#......#..#........B.......#..#........#
#......#..+................#..#........#
#......................................#
#.................E....................#
#......................................#
#..............................E.......#
#......................................#
#...............................D......#
#......................................#
#......................................#
########################################
`);
MAPS[1] = parseMap(`
########################################
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
#,,D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,D,,#
#,,,,,,,,,,,,,,,,,,E,,,,,,,,,,,,,,,,,,,#
#,,,,####,,,,,,,,,,,,,,,,,,,,####,,,,,,#
#,,,,#,,#,,,,,,,,B,,,,,,,,,,,#,C#,,,,,,#
#,,,,#,,+,,,,,,,,,,,,,,,,,,,,#,,#,,,,,,#
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
#,,,,,,,,,,E,,,,,,,,,,,,E,,,,,,,,,,,,,,#
#,,,,,,,,,,,,,,,,,,,,^,,,,,,,,,,,,,,,,,#
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
#,,,,####,,,,,,,,,,,,,,,,,,,,####,,,,,,#
#,,,,#,,#,,,,,,,,,,,,,,,,,,,,#,,#,,,,,,#
#,,,,#G,#,,,,,,,,,N,,,,,,,,,,#,,#,,,,,,#
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
#,,,,,,,,E,,,,,,,,,,,,,,B,,,,,,,E,,,,,,#
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
#,,,,,,,,,,,,,,,$,,,,,,,,,,,,,,,,,,,,,,#
#,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,#
########################################
`);
MAPS[2] = parseMap(`
##########################################
#........................................#
#..D..................................D..#
#........................................#
#.....P......................P...........#
#........................................#
#...........############.................#
#...........#..........#......E..........#
#...........#....A.....#.................#
#.....E.....#..........#.................#
#...........#..........#........M........#
#...........######+#####.................#
#........................................#
#.................E......................#
#....####....................####........#
#....#B.#....................#.G#........#
#....#..+....................#..#........#
#........................................#
#........................................#
##########################################
`);
MAPS[3] = parseMap(`
##########################################
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~D~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~D~~#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~####~~~~~~~~~~~~~~~~~~~~X~~~~~~~~~~#
#~~~~~#~~#~~~~~~~~~~~~~~E~~~~~~~~~~~~~~~~#
#~~~~~#~C?~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~~~~~~E~~~~~~~~~~~~~####~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~#~~#~~~~~E~~~~~~#
#~~~~~P~~~~~~~~~~~~~~~~~~#~A#~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~#~~#~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~N~~~~~~~~####~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~####~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~#~G#~~~~~~~~~~B~~~~~~~~~E~~~~~~~~~~#
#~~~~~#~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~X~~~#
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~#
##########################################
`);
MAPS[4] = parseMap(`
##########################################
#........................................#
#..^..................................D..#
#........................................#
#.....H....E......H...........E.....H....#
#........................................#
#...........############.................#
#...........#..........#.................#
#.....N.....#....A.....#........N........#
#...........#..........#.................#
#...........######+#####.................#
#........................................#
#..X..............................C......#
#........................................#
#.....####......M..............####......#
#.....#.B#.....................#.G#......#
#.....#..+.....................#..#......#
#........................................#
#.................H......................#
##########################################
`);
MAPS[5] = parseMap(`
######################################
#====================================#
#==D=================================#
#====================================#
#==========E========E================#
#====================================#
#==============####==================#
#==============#KK#==================#
#=======N======#KK#======N===========#
#==============####==================#
#====================================#
#==========E========E================#
#====================================#
#================M===================#
#====================================#
#====================================#
######################################
`);
// Assign specific IDs / loot after parse
function decorate() {
// Zone 0
const z0 = MAPS[0];
z0.ents.filter(e => e.kind === "save")[0].sid = 1;
z0.ents.filter(e => e.kind === "door")[0].to = 1;
z0.ents.filter(e => e.kind === "pot").forEach((e,i) => e.loot = i===0 ? "gold" : "gold");
// hidden wall gold
const bw0 = findTiles(z0, 5)[0];
if (bw0) z0.breakLoot = z0.breakLoot || [];
// Zone 1
const z1 = MAPS[1];
z1.ents.filter(e => e.kind === "save")[0].sid = 2;
const doors1 = z1.ents.filter(e => e.kind === "door");
doors1[0].to = 0; doors1[0].back = true;
doors1[1].to = 2;
z1.ents.filter(e => e.kind === "chest")[0].loot = "band";
z1.ents.filter(e => e.kind === "cache")[0].loot = "stem";
// Zone 2
const z2 = MAPS[2];
const doors2 = z2.ents.filter(e => e.kind === "door");
doors2[0].to = 1; doors2[0].back = true;
doors2[1].to = 3;
z2.ents.filter(e => e.kind === "altar")[0].up = 1;
const plates2 = z2.ents.filter(e => e.kind === "plate");
plates2[0].id = "gA"; plates2[1].id = "gB";
z2.ents.filter(e => e.kind === "cache")[0].loot = "gold";
z2.ents.filter(e => e.kind === "enemy").find(e => e.type === "brute").isMini = true;
// Zone 3
const z3 = MAPS[3];
const doors3 = z3.ents.filter(e => e.kind === "door");
doors3[0].to = 2; doors3[0].back = true;
doors3[1].to = 4;
z3.ents.filter(e => e.kind === "chest")[0].loot = "shard";
z3.ents.filter(e => e.kind === "altar")[0].up = "lantern";
z3.ents.filter(e => e.kind === "plate")[0].id = "vA";
const sw3 = z3.ents.filter(e => e.kind === "switch");
sw3[0].id = "v1"; sw3[1].id = "v2";
z3.ents.filter(e => e.kind === "cache")[0].loot = "gold";
// Zone 4
const z4 = MAPS[4];
z4.ents.filter(e => e.kind === "save")[0].sid = 3;
z4.ents.filter(e => e.kind === "door")[0].to = 5;
z4.ents.filter(e => e.kind === "altar")[0].up = 2;
z4.ents.filter(e => e.kind === "chest")[0].loot = "cloak";
z4.ents.filter(e => e.kind === "cache")[0].loot = "goldbig";
z4.ents.filter(e => e.kind === "switch")[0].id = "h5";
z4.ents.filter(e => e.kind === "pot")[0].loot = "seal";
z4.ents.filter(e => e.kind === "enemy").filter(e => e.type === "brute")[0].isGuard = true;
z4.ents.push({ kind: "door", x: 2.5 * T, y: 4.5 * T, to: 3, back: true });
// heart-5 switch is ranged-only; tag it
const hsw = z4.ents.find(e => e.kind === "switch" && e.id === "h5");
if (hsw) hsw.ranged = true;
// Zone 5
const z5 = MAPS[5];
z5.ents.filter(e => e.kind === "door")[0].to = 4; z5.ents.filter(e => e.kind === "door")[0].back = true;
z5.ents.filter(e => e.kind === "enemy").filter(e => e.type === "brute")[0].isCore = true;
}
function findTiles(map, type) {
const out = [];
for (let y = 0; y < map.h; y++)
for (let x = 0; x < map.w; x++)
if (map.tiles[y][x] === type) out.push({x,y});
return out;
}
decorate();
// ─── Player ──────────────────────────────────────────────
const player = {
x: 4.5 * T, y: 3.5 * T, vx: 0, vy: 0, r: 12,
facing: 0, speed: 145,
atkT: 0, atkWind: 0, swinging: false, swingA: 0,
invuln: 0, flash: 0, bob: 0,
shootT: 0
};
function spawnInZone(z, goingBack) {
S.zone = z;
const m = MAPS[z];
const doors = m.ents.filter(e => e.kind === "door");
if (goingBack) {
// arrived from a later zone — stand at this zone's forward door
const d = doors.find(e => !e.back) || doors[doors.length - 1];
if (d) {
player.x = d.x + (d.back ? 36 : -36);
player.y = d.y + 8;
}
} else {
// arrived from an earlier zone — stand at the entrance (back door)
const d = doors.find(e => e.back);
if (d) { player.x = d.x + 40; player.y = d.y; }
else { player.x = 4.5 * T; player.y = 3.5 * T; }
}
player.vx = player.vy = 0;
document.getElementById("zone-lbl").textContent = ZN[z].name;
toast(ZN[z].name, zoneFlavor(z));
rebuildEnemies(z);
}
function zoneFlavor(z) {
return [
"Bronze dust hangs in the dying light.",
"Ledgers rust. The merchant altar hums.",
"A collapsed nave. An altar waits, cold.",
"Mist rises from drowned star-charts.",
"Twin towers pierce the storm-lock.",
"The Core Beacon flickers. End this."
][z];
}
// ─── Enemies ─────────────────────────────────────────────
let enemies = [];
let projs = [];
let floats = [];
function rebuildEnemies(z) {
enemies = [];
const m = MAPS[z];
for (const e of m.ents) {
if (e.kind !== "enemy") continue;
if (e.isMini && S.flags.galleryBoss) continue;
if (e.isGuard && S.flags.spireGuard) continue;
if (e.isCore && S.flags.coreDead) continue;
enemies.push(makeEnemy(e));
}
}
function makeEnemy(e) {
const t = e.type;
const base = {
construct: { hp: 4, spd: 48, dmg: 1, r: 13, gold: [6,12], name: "Corrupted Construct", color: "#6a5344" },
sentinel: { hp: 6, spd: 72, dmg: 1, r: 12, gold: [8,14], name: "Rusted Sentinel", color: "#7a4030" },
brute: { hp: 16, spd: 40, dmg: 2, r: 18, gold: [20,30], name: "Iron Brute", color: "#4a3028" }
}[t];
let hp = base.hp + S.zone;
if (e.isMini) { hp = 18; }
if (e.isGuard) { hp = 26; }
if (e.isCore) { hp = 42; base.r = 22; base.name = "Corroded Warden"; }
return {
...e, ...base, hp, maxHp: hp, alive: true,
vx: 0, vy: 0, hitT: 0, atkT: 0, stun: 0, burn: 0, frozen: 0,
ang: 0, wander: Math.random()*6, phase: 0
};
}
function atkDmg() { return S.weapon === 0 ? 1 : S.weapon === 1 ? 2 : 3; }
function atkCD() {
let c = S.weapon === 0 ? 0.42 : S.weapon === 1 ? 0.30 : 0.55;
if (S.equip.band) c *= 0.82;
return c;
}
function atkRange() { return S.weapon === 2 ? 64 : 48; }
function atkArc() { return S.weapon === 2 ? 2.6 : 2.0; }
function aimAtPointer() {
if (!mouse.moved) return;
const dx = mouse.worldX - player.x, dy = mouse.worldY - player.y;
if (Math.hypot(dx, dy) > 4) player.facing = Math.atan2(dy, dx);
}
function tryMelee() {
if (!S.started || S.paused || S.over || S.invOpen || S.shopOpen) return;
if (player.atkT > 0 || player.atkWind > 0) return;
aimAtPointer();
if (S.weapon === 2) {
player.atkWind = 0.22;
AudioSys.swingSolar();
} else {
startSwing();
}
}
function startSwing() {
player.swinging = true;
player.swingA = 0;
player.atkT = atkCD();
if (S.weapon === 0) AudioSys.swingBase();
else if (S.weapon === 1) AudioSys.swingFire();
else AudioSys.swingSolar();
// hit detection
const ang = player.facing;
const range = atkRange();
const arc = atkArc();
let hit = false;
for (const en of enemies) {
if (!en.alive || en.frozen > 0 && false) {}
if (!en.alive) continue;
const dx = en.x - player.x, dy = en.y - player.y;
const d = Math.hypot(dx, dy);
if (d > range + en.r) continue;
const a = Math.atan2(dy, dx);
let da = Math.abs(normAng(a - ang));
if (da > arc / 2) continue;
hurtEnemy(en, atkDmg(), a);
hit = true;
}
// breakable walls
hitWalls(player.x + Math.cos(ang)*28, player.y + Math.sin(ang)*28, S.weapon === 2 ? 2 : 1);
// pots
hitPots(player.x + Math.cos(ang)*30, player.y + Math.sin(ang)*30);
if (S.weapon === 2) {
S.shimmer = 0.45;
document.getElementById("shimmer").style.opacity = "1";
setTimeout(() => document.getElementById("shimmer").style.opacity = "0", 180);
}
}
function tryShoot() {
if (!S.hasShard || S.charges <= 0) return;
if (player.shootT > 0) return;
if (!S.started || S.paused || S.over || S.invOpen || S.shopOpen) return;
aimAtPointer();
S.charges--;
player.shootT = 0.38;
const a = player.facing;
projs.push({
x: player.x + Math.cos(a)*16, y: player.y + Math.sin(a)*16,
vx: Math.cos(a)*340, vy: Math.sin(a)*340,
life: 1.1, r: 5, friendly: true
});
AudioSys.twang();
updateHUD();
}
function hurtEnemy(en, dmg, ang) {
if (en.frozen > 0) dmg += 1;
en.hp -= dmg;
en.hitT = 0.15;
en.stun = 0.18;
en.vx += Math.cos(ang) * 80;
en.vy += Math.sin(ang) * 80;
if (S.weapon === 1) { en.burn = 1.4; AudioSys.impactFire(); burst(en.x, en.y, 10, ["#e07030","#f0b060","#802000"], 80, 0.35, 3); }
else if (S.weapon === 2) { AudioSys.impactSolar(); burst(en.x, en.y, 14, ["#ffe08a","#e0b45a","#fff"], 100, 0.4, 3); }
else { AudioSys.impact(); burst(en.x, en.y, 8, ["#c0a070","#888"], 70, 0.3, 2); }
floatTxt(en.x, en.y - 16, ""+dmg, "#f0d080");
if (en.hp <= 0) killEnemy(en);
}
function killEnemy(en) {
en.alive = false;
const g = en.gold[0] + ((Math.random() * (en.gold[1]-en.gold[0]))|0);
S.gold += g;
goldDust(en.x, en.y);
AudioSys.coin();
floatTxt(en.x, en.y, "+"+g+" ✦", "#e0b45a");
log("The " + en.name + " collapses. " + g + " sigils.");
if (en.isMini) {
S.flags.galleryBoss = true;
if (!S.flags.heart4) grantHeart(4);
}
if (en.isGuard) {
S.flags.spireGuard = true;
if (!S.flags.cloakGot) {
S.flags.cloakGot = true; S.items.cloak = true;
whiteMotes(en.x, en.y); AudioSys.rare();
toast("SHADOW CLOAK", "Dark cloth settles on your shoulders.");
log("The Spire Guardian yields a Shadow Cloak.");
}
}
if (en.isCore) {
S.flags.coreDead = true;
AudioSys.boom();
burst(en.x, en.y, 40, ["#e0b45a","#fff","#e07030"], 160, 1.2, 5, 20);
setTimeout(() => igniteBeacon(), 800);
}
updateHUD();
}
function hurtPlayer(dmg, src) {
if (player.invuln > 0 || S.over) return;
if (src === "hazard" && S.equip.cloak) dmg = Math.max(1, dmg - 1);
S.hp = Math.max(0, S.hp - dmg);
player.invuln = 0.85;
player.flash = 0.2;
AudioSys.hurt();
burst(player.x, player.y, 12, ["#c94a4a","#802020"], 90, 0.35, 3);
floatTxt(player.x, player.y - 18, "-"+dmg, "#ff6a6a");
const fl = document.getElementById("dmg-flash");
fl.style.opacity = "1";
setTimeout(() => fl.style.opacity = "0", 120);
updateHUD();
if (S.hp <= 0) die();
}
function die() {
document.getElementById("death").classList.add("show");
AudioSys.boom();
setTimeout(() => {
document.getElementById("death").classList.remove("show");
S.zone = S.lastSave.zone;
player.x = S.lastSave.x; player.y = S.lastSave.y;
S.hp = S.maxHp;
player.invuln = 1.2;
document.getElementById("zone-lbl").textContent = ZN[S.zone].name;
rebuildEnemies(S.zone);
updateHUD();
log("You wake at the Sanctuary Stone.");
}, 1600);
}
function grantHeart(n) {
if (n === 4 && S.flags.heart4) return;
if (n === 5 && S.flags.heart5) return;
if (n === 4) S.flags.heart4 = true;
if (n === 5) S.flags.heart5 = true;
S.hearts = Math.min(5, S.hearts + 1);
S.maxHp = S.hearts * 2;
S.hp = Math.min(S.maxHp, S.hp + 2);
document.getElementById("shimmer").style.opacity = "1";
setTimeout(() => document.getElementById("shimmer").style.opacity = "0", 2000);
whiteMotes(player.x, player.y);
burst(player.x, player.y, 20, ["#ffe08a","#fff"], 60, 0.8, 3, -20);
AudioSys.altar();
toast("HEART CONTAINER", "A new vessel of light binds to you.");
updateHUD();
}
function igniteBeacon() {
S.over = true;
document.getElementById("end-screen").classList.add("show");
AudioSys.altar();
}
// ─── Combat helpers ──────────────────────────────────────
function hitWalls(x, y, power) {
const m = MAPS[S.zone];
const tx = Math.floor(x / T), ty = Math.floor(y / T);
for (let iy = ty-1; iy <= ty+1; iy++)
for (let ix = tx-1; ix <= tx+1; ix++) {
if (!inMap(m, ix, iy)) continue;
const t = m.tiles[iy][ix];
if (t === 5 || t === 6) {
// breakable
const key = wallKey(S.zone, ix, iy);
if (S.flags[key]) continue;
// some walls need more hits — store hp on map
m.whp = m.whp || {};
const k = ix+","+iy;
m.whp[k] = (m.whp[k] || (t===6?6:4)) - power;
burst(ix*T+T/2, iy*T+T/2, 6, ["#5a4030","#888"], 50, 0.3, 2);
AudioSys.break();
if (m.whp[k] <= 0) {
S.flags[key] = true;
m.tiles[iy][ix] = S.zone === 3 ? 3 : 0;
onWallBreak(S.zone, ix, iy);
}
}
}
}
function wallKey(z, x, y) { return "W_"+z+"_"+x+"_"+y; }
function onWallBreak(z, x, y) {
log("The corroded masonry gives way.");
if (z === 2 && !S.flags.heart4) {
// only the cracked seal under the gallery altar
const m = MAPS[2];
if (m.tiles[y] && y >= 10 && y <= 12) grantHeart(4);
}
if (z === 0) {
S.gold += 12; goldDust(x * T + T / 2, y * T + T / 2); AudioSys.coin();
}
}
function hitPots(x, y) {
const m = MAPS[S.zone];
for (const e of m.ents) {
if (e.kind !== "pot" || e.broke) continue;
if (Math.hypot(e.x - x, e.y - y) < 34 || Math.hypot(e.x - player.x, e.y - player.y) < 26) {
e.broke = true;
AudioSys.break();
burst(e.x, e.y, 10, ["#8a7050","#c0a070"], 60, 0.3, 2);
if (e.loot === "seal" && !S.flags.jarSeal) {
S.flags.jarSeal = true; S.items.seals++;
whiteMotes(e.x, e.y); AudioSys.rare();
toast("BINDING SEAL", "A cold blue glyph, waiting to be spent.");
} else {
const g = 5 + (Math.random()*8|0);
S.gold += g; goldDust(e.x, e.y); AudioSys.coin();
floatTxt(e.x, e.y, "+"+g+" ✦", "#e0b45a");
}
updateHUD();
}
}
}
function inMap(m, x, y) { return y>=0 && x>=0 && y<m.h && x<m.w; }
function tileAt(wx, wy) {
const m = MAPS[S.zone];
const x = Math.floor(wx / T), y = Math.floor(wy / T);
if (!inMap(m, x, y)) return 1;
const t = m.tiles[y][x];
if ((t === 5 || t === 6) && S.flags[wallKey(S.zone, x, y)]) return 0;
return t;
}
function solid(t) { return t === 1 || t === 5 || t === 6; }
function floorKind(t) {
if (t === 2) return "grate";
if (t === 3) return "wet";
return "stone";
}
function blocked(x, y, r) {
const pts = [[x-r,y-r],[x+r,y-r],[x-r,y+r],[x+r,y+r],[x,y]];
for (const [px,py] of pts) if (solid(tileAt(px, py))) return true;
return false;
}
// ─── Interact ────────────────────────────────────────────
let nearEnt = null;
function scanInteract() {
const m = MAPS[S.zone];
nearEnt = null;
let best = 36;
for (const e of m.ents) {
if (e.kind === "enemy" || e.kind === "hazard" || e.kind === "pot") continue;
if (e.kind === "switch") continue;
const d = Math.hypot(e.x - player.x, e.y - player.y);
if (d < best) { best = d; nearEnt = e; }
}
const pr = document.getElementById("prompt");
if (nearEnt) {
pr.textContent = promptFor(nearEnt);
pr.classList.add("show");
} else {
pr.classList.remove("show");
}
}
function promptFor(e) {
if (e.kind === "save") return "E · SANCTUARY STONE";
if (e.kind === "shop") return "E · MERCHANT ALTAR";
if (e.kind === "altar") {
if (e.up === 1) return S.flags.w1 ? "IGNITED EDGE — RESTING" : "E · WEAPON ALTAR";
if (e.up === 2) return S.flags.w2 ? "SOLAR FALCHION — RESTING" : "E · SOLAR ALTAR";
if (e.up === "lantern") return S.flags.lanternGot ? "LIGHT PUZZLE — SOLVED" : "E · REFLECTION ALTAR";
}
if (e.kind === "door") return "E · PASSAGE";
if (e.kind === "chest") return e.opened ? "EMPTY VESSEL" : "E · SEALED CHEST";
if (e.kind === "cache") return e.opened ? "EMPTY CACHE" : "E · HIDDEN CACHE";
if (e.kind === "plate") return "PRESSURE PLATE";
if (e.kind === "beacon") return S.flags.coreDead ? "THE BEACON BURNS" : "THE CORE BEACON";
return "E · INTERACT";
}
function interact() {
scanInteract();
if (!nearEnt) return;
const e = nearEnt;
if (e.kind === "save") activateSave(e);
else if (e.kind === "shop") openShop();
else if (e.kind === "altar") useAltar(e);
else if (e.kind === "door") useDoor(e);
else if (e.kind === "chest") openChest(e);
else if (e.kind === "cache") openCache(e);
}
function activateSave(e) {
S.hp = S.maxHp;
S.lastSave = { zone: S.zone, x: e.x, y: e.y };
if (e.sid === 1) S.flags.save1 = true;
if (e.sid === 2) S.flags.save2 = true;
if (e.sid === 3) S.flags.save3 = true;
persist();
AudioSys.save();
burst(e.x, e.y, 20, ["#ffe08a","#e0b45a"], 40, 0.9, 3, -10);
toast("SANCTUARY BOUND", "Wounds close. The stone remembers you.");
updateHUD();
}
function useAltar(e) {
if (e.up === 1 && !S.flags.w1) {
S.flags.w1 = true; S.weapon = 1;
AudioSys.altar();
document.getElementById("shimmer").style.opacity = "1";
setTimeout(() => document.getElementById("shimmer").style.opacity = "0", 2000);
burst(e.x, e.y, 28, ["#e07030","#f0b060"], 70, 1, 4, -15);
toast("IGNITED EDGE", "The blade drinks ember. Swings leave fire in their wake.");
log("The first altar answers. Your blade is flame.");
} else if (e.up === 2 && !S.flags.w2) {
S.flags.w2 = true; S.weapon = 2;
AudioSys.altar();
document.getElementById("shimmer").style.opacity = "1";
setTimeout(() => document.getElementById("shimmer").style.opacity = "0", 2000);
burst(e.x, e.y, 32, ["#ffe08a","#fff","#e0b45a"], 80, 1.1, 4, -15);
toast("SOLAR FALCHION", "A wider arc. A heavier truth. Light answers steel.");
log("The second altar crowns the blade in solar fire.");
} else if (e.up === "lantern") {
// require both vault switches
if (S.flags.switchA && S.flags.switchB) {
if (!S.flags.lanternGot) {
S.flags.lanternGot = true; S.items.lantern = true; S.equip.lantern = true;
AudioSys.rare(); whiteMotes(e.x, e.y);
toast("PHANTOM LANTERN", "Soft blue light. Hidden wounds in the stone reveal themselves.");
}
} else {
log("The reflection altar is dark. Distant crystals still sleep.");
}
}
updateHUD();
}
function useDoor(e) {
const to = e.to;
// gates
if (!e.back) {
if (S.zone === 0) { /* free */ }
if (S.zone === 1) {
const live = enemies.filter(en => en.alive).length;
if (live > 2 && !S.flags.save2) { log("Constructs still bar the way forward."); return; }
S.flags.shopUnlocked = true;
}
if (S.zone === 2 && !S.flags.galleryBridge) {
log("The far gate is sealed. The bronze plates must speak together.");
return;
}
if (S.zone === 3 && !S.hasShard) {
log("A crystal lock. You need a ranged key.");
return;
}
if (S.zone === 3 && !S.flags.vaultGate) {
log("The vault's outer seal still holds. Strike the high crystals.");
return;
}
if (S.zone === 4) {
const live = enemies.filter(en => en.alive && (en.isGuard || en.type==="brute" || en.type==="sentinel")).length;
if (!S.flags.w2) { log("The Solar Altar must be claimed before the Core."); return; }
}
}
AudioSys.door();
spawnInZone(to, !!e.back);
if (to === 1) S.flags.shopUnlocked = true;
if (S.zone === 4 && !e.back) S.flags.ammoShop = true;
}
function openChest(e) {
if (e.opened) return;
e.opened = true;
if (e.loot === "band" && !S.flags.bandGot) {
S.flags.bandGot = true; S.items.band = true; S.equip.band = true;
AudioSys.rare(); whiteMotes(e.x, e.y);
toast("WARDEN'S BAND", "A silver ring. Your hands remember a faster cadence.");
} else if (e.loot === "shard" && !S.flags.shardGot) {
S.flags.shardGot = true; S.hasShard = true; S.charges = 8;
AudioSys.rare(); whiteMotes(e.x, e.y);
toast("AETHER SHARD", "A pulsing crystal fills your off-hand. K to loose a bolt.");
log("The halfway secret is yours. Distance is no longer a wall.");
} else if (e.loot === "cloak" && !S.flags.cloakGot) {
S.flags.cloakGot = true; S.items.cloak = true; S.equip.cloak = true;
AudioSys.rare(); whiteMotes(e.x, e.y);
toast("SHADOW CLOAK", "Trap-light dulls against the hem.");
} else {
S.gold += 20; goldDust(e.x, e.y); AudioSys.coin();
}
updateHUD();
}
function openCache(e) {
if (e.opened) return;
e.opened = true;
if (e.loot === "stem" && !S.flags.cacheStem) {
S.flags.cacheStem = true; S.items.stems++;
AudioSys.pickup(); whiteMotes(e.x, e.y);
toast("VITAL STEM", "A green pulse. Four points of life, waiting.");
} else if (e.loot === "goldbig") {
S.gold += 80; goldDust(e.x, e.y); AudioSys.coin();
toast("SPIRE HOARD", "Eighty golden sigils spill into your palm.");
} else {
const g = 15 + (Math.random()*15|0);
S.gold += g; goldDust(e.x, e.y); AudioSys.coin();
floatTxt(e.x, e.y, "+"+g+" ✦", "#e0b45a");
}
updateHUD();
}
// ─── Shop / Inventory ────────────────────────────────────
function openShop() {
S.shopOpen = true;
document.getElementById("shop-panel").classList.add("open");
AudioSys.shopNear();
renderShop();
}
function closeShop() {
S.shopOpen = false;
document.getElementById("shop-panel").classList.remove("open");
}
function renderShop() {
const el = document.getElementById("shop-slots");
const items = [
{ id: "stem", name: "VITAL STEM", ds: "Restore 2 containers (4 HP)", cost: 50, stock: S.shopStock.stem },
{ id: "seal", name: "BINDING SEAL", ds: "Freeze one foe for 3 seconds", cost: 75, stock: S.shopStock.seal },
{ id: "heart", name: "HEART CONTAINER", ds: "A fifth vessel. Permanent.", cost: 300, stock: S.flags.heart5 ? 0 : S.shopStock.heart },
];
if (S.flags.ammoShop || S.hasShard) {
items.push({ id: "ammo", name: "AETHER CHARGE", ds: "One crystalline bolt", cost: 10, stock: 99 });
}
el.innerHTML = items.map(it => `
<div class="slot" data-id="${it.id}">
<div class="ico">${it.id==="stem"?"🌿":it.id==="seal"?"◈":it.id==="heart"?"♥":"✧"}</div>
<div><div class="nm">${it.name}</div><div class="ds">${it.ds}</div></div>
<div class="st">${it.stock<=0?"SOLD":it.cost+" ✦"}</div>
</div>`).join("");
el.querySelectorAll(".slot").forEach(s => s.addEventListener("click", () => buy(s.dataset.id)));
}
function buy(id) {
const price = { stem:50, seal:75, heart:300, ammo:10 }[id];
if (id === "stem") {
if (S.shopStock.stem <= 0) return;
if (S.gold < price) { log("The altar is unmoved. You lack sigils."); return; }
S.gold -= price; S.shopStock.stem--; S.items.stems++;
} else if (id === "seal") {
if (S.shopStock.seal <= 0) return;
if (S.gold < price) { log("The altar is unmoved. You lack sigils."); return; }
S.gold -= price; S.shopStock.seal--; S.items.seals++;
} else if (id === "heart") {
if (S.flags.heart5 || S.shopStock.heart <= 0) return;
if (S.gold < price) { log("Three hundred sigils. The vessel waits."); return; }
S.gold -= price; S.shopStock.heart = 0; S.flags.boughtHeart = true;
grantHeart(5);
} else if (id === "ammo") {
if (!S.hasShard) return;
if (S.gold < price) return;
if (S.charges >= S.maxCharges) { log("The shard is already sated."); return; }
S.gold -= price; S.charges++;
}
AudioSys.buy();
updateHUD();
renderShop();
}
function toggleInv() {
if (S.shopOpen) return;
S.invOpen = !S.invOpen;
document.getElementById("inv-panel").classList.toggle("open", S.invOpen);
if (S.invOpen) renderInv();
}
function renderInv() {
const el = document.getElementById("inv-slots");
const rows = [];
rows.push(accRow("band", "WARDEN'S BAND", "Silver ring. Attack cadence quickens.", S.items.band, S.equip.band, "○"));
rows.push(accRow("lantern", "PHANTOM LANTERN", "Reveals hairline cracks and hidden paths.", S.items.lantern, S.equip.lantern, "◉"));
rows.push(accRow("cloak", "SHADOW CLOAK", "Softens environmental harm.", S.items.cloak, S.equip.cloak, "▴"));
rows.push({
html: `<div class="slot" data-use="stem">
<div class="ico">🌿</div>
<div><div class="nm">VITAL STEM ×${S.items.stems}</div>
<div class="ds">Restore 2 containers (4 HP)</div></div>
<div class="st">${S.items.stems?"USE":"—"}</div></div>`
});
rows.push({
html: `<div class="slot" data-use="seal">
<div class="ico">◈</div>
<div><div class="nm">BINDING SEAL ×${S.items.seals}</div>
<div class="ds">Freeze the nearest foe (~3s)</div></div>
<div class="st">${S.items.seals?"USE":"—"}</div></div>`
});
const wname = ["EMBER BLADE","IGNITED EDGE","SOLAR FALCHION"][S.weapon];
rows.push({
html: `<div class="slot">
<div class="ico">⚔</div>
<div><div class="nm">${wname}</div>
<div class="ds">${S.hasShard?"Aether Shard bound to the off-hand.":"A relic of the last watch."}</div></div>
<div class="st">WIELDED</div></div>`
});
el.innerHTML = rows.map(r => r.html || r).join("");
el.querySelectorAll(".slot").forEach(s => {
s.addEventListener("click", () => {
const acc = s.dataset.acc, use = s.dataset.use;
if (acc) {
if (!S.items[acc]) return;
S.equip[acc] = !S.equip[acc];
AudioSys.pickup();
renderInv();
}
if (use === "stem") useStem();
if (use === "seal") useSeal();
});
});
}
function accRow(id, name, ds, have, on, ico) {
return { html: `<div class="slot ${on?"on":""}" data-acc="${id}">
<div class="ico">${have?ico:"·"}</div>
<div><div class="nm">${name}</div><div class="ds">${have?ds:"Not yet found."}</div></div>
<div class="st">${!have?"UNKNOWN":on?"EQUIPPED":"EQUIP"}</div></div>` };
}
function useStem() {
if (S.items.stems <= 0) return;
if (S.hp >= S.maxHp) { log("You are already whole."); return; }
S.items.stems--;
S.hp = Math.min(S.maxHp, S.hp + 4);
AudioSys.pickup();
burst(player.x, player.y, 14, ["#7dba6a","#c0f0a0"], 40, 0.6, 3, -20);
toast("VITAL STEM", "Green fire knits the wounds.");
updateHUD(); renderInv();
}
function useSeal() {
if (S.items.seals <= 0) return;
const live = enemies.filter(e => e.alive).sort((a,b) =>
Math.hypot(a.x-player.x,a.y-player.y) - Math.hypot(b.x-player.x,b.y-player.y));
if (!live.length) { log("No construct near enough to bind."); return; }
S.items.seals--;
live[0].frozen = 3;
AudioSys.freeze();
burst(live[0].x, live[0].y, 16, ["#80c8ff","#fff"], 40, 0.5, 3);
log("The Binding Seal locks a construct in pale ice.");
updateHUD(); renderInv();
}
// ─── HUD ─────────────────────────────────────────────────
function updateHUD() {
const h = document.getElementById("hearts");
let html = "";
for (let i = 0; i < S.hearts; i++) {
const v = S.hp - i*2;
const fill = v >= 2 ? 1 : v === 1 ? 0.5 : 0;
html += heartSVG(fill);
}
h.innerHTML = html;
document.getElementById("gold-lbl").textContent = S.gold + " ✦";
const cl = document.getElementById("charge-lbl");
cl.textContent = S.hasShard ? ("SHARD " + "◆".repeat(S.charges) + "◇".repeat(Math.max(0, Math.min(8, S.maxCharges) - S.charges))) : "";
}
let _hid = 0;
function heartSVG(fill) {
const id = "h" + (_hid++);
const col = "#c43b3b";
return `<div class="heart"><svg viewBox="0 0 32 30">
<defs><clipPath id="${id}"><rect x="0" y="0" width="${fill>=1?32:fill>=0.5?16:0}" height="30"/></clipPath></defs>
<path d="M16 26.5 C16 26.5 4 17.5 4 10.2 C4 5.8 7.4 3.2 11.1 3.2 C13.8 3.2 15.6 4.8 16 6.8 C16.4 4.8 18.2 3.2 20.9 3.2 C24.6 3.2 28 5.8 28 10.2 C28 17.5 16 26.5 16 26.5Z"
fill="#2a181c" stroke="#a67c3a88" stroke-width="1.1"/>
<path d="M16 26.5 C16 26.5 4 17.5 4 10.2 C4 5.8 7.4 3.2 11.1 3.2 C13.8 3.2 15.6 4.8 16 6.8 C16.4 4.8 18.2 3.2 20.9 3.2 C24.6 3.2 28 5.8 28 10.2 C28 17.5 16 26.5 16 26.5Z"
fill="${col}" clip-path="url(#${id})"/>
</svg></div>`;
}
function toast(t, s) {
const el = document.getElementById("toast");
el.querySelector(".title").textContent = t;
el.querySelector(".sub").textContent = s || "";
el.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 3200);
}
function log(m) {
const el = document.getElementById("log");
const d = document.createElement("div");
d.textContent = m;
el.prepend(d);
while (el.children.length > 3) el.lastChild.remove();
}
function floatTxt(x, y, t, col) {
floats.push({ x, y, t, col, life: 0.9 });
}
function persist() {
try {
localStorage.setItem("warden_corrosion", JSON.stringify({
S, px: player.x, py: player.y
}));
} catch (_) {}
}
// ─── Keys ────────────────────────────────────────────────
function onKey(e) {
if (!S.started) return;
if (e.code === "Escape") {
if (S.shopOpen) { closeShop(); return; }
if (S.invOpen) { toggleInv(); return; }
S.paused = !S.paused;
document.getElementById("pause-panel").classList.toggle("open", S.paused);
return;
}
if (S.over) return;
if (e.code === "KeyI") toggleInv();
if (e.code === "KeyE" && !S.invOpen && !S.shopOpen && !S.paused) interact();
if ((e.code === "KeyJ" || e.code === "Space") && !S.invOpen && !S.shopOpen) tryMelee();
if (e.code === "KeyK" && !S.invOpen && !S.shopOpen) tryShoot();
if (e.code === "Digit1" && S.invOpen) useStem();
if (e.code === "Digit2" && S.invOpen) useSeal();
}
document.getElementById("btn-resume").onclick = () => {
S.paused = false; document.getElementById("pause-panel").classList.remove("open");
};
document.getElementById("btn-begin").onclick = () => startGame();
document.getElementById("btn-again").onclick = () => location.reload();
function startGame() {
AudioSys.resume();
S.started = true;
document.getElementById("overlay-title").classList.add("gone");
S.lastSave = { zone: 0, x: 4.5*T, y: 3.5*T };
spawnInZone(0, false);
player.x = 4.5*T; player.y = 3.5*T;
updateHUD();
toast("THE ASHEN THRESHOLD", "The outer gate remembers your step.");
log("A Sanctuary Stone glows to the west. Constructs stir ahead.");
}
// ─── Update ──────────────────────────────────────────────
const cam = { x: 0, y: 0 };
let last = performance.now();
let dustT = 0;
function update(dt) {
if (!S.started || S.paused || S.over || S.invOpen || S.shopOpen) {
tickP(dt);
return;
}
const m = MAPS[S.zone];
// movement
let ix = 0, iy = 0;
if (keys["KeyA"] || keys["ArrowLeft"]) ix -= 1;
if (keys["KeyD"] || keys["ArrowRight"]) ix += 1;
if (keys["KeyW"] || keys["ArrowUp"]) iy -= 1;
if (keys["KeyS"] || keys["ArrowDown"]) iy += 1;
const moving = ix || iy;
if (moving) {
const len = Math.hypot(ix, iy);
ix /= len; iy /= len;
player.facing = Math.atan2(iy, ix);
}
// face mouse if aiming
mouse.worldX = mouse.x + cam.x;
mouse.worldY = mouse.y + cam.y;
if (!moving) {
const dx = mouse.worldX - player.x, dy = mouse.worldY - player.y;
if (Math.hypot(dx,dy) > 8) player.facing = Math.atan2(dy, dx);
}
let spd = player.speed;
if (player.atkWind > 0) spd *= 0.35;
player.vx = ix * spd; player.vy = iy * spd;
let nx = player.x + player.vx * dt;
if (!blocked(nx, player.y, player.r)) player.x = nx;
let ny = player.y + player.vy * dt;
if (!blocked(player.x, ny, player.r)) player.y = ny;
// clamp
player.x = Math.max(T+8, Math.min(m.w*T - T - 8, player.x));
player.y = Math.max(T+8, Math.min(m.h*T - T - 8, player.y));
if (moving) {
player.bob += dt * 10;
AudioSys.foot(floorKind(tileAt(player.x, player.y)), performance.now()/1000);
if (S.weapon >= 1 && Math.random() < dt * 8) emberTrail(player.x - Math.cos(player.facing)*6, player.y + 8);
}
if (player.atkT > 0) player.atkT -= dt;
if (player.shootT > 0) player.shootT -= dt;
if (player.invuln > 0) player.invuln -= dt;
if (player.flash > 0) player.flash -= dt;
if (player.atkWind > 0) {
player.atkWind -= dt;
if (player.atkWind <= 0) startSwing();
}
if (player.swinging) {
player.swingA += dt * (S.weapon===2?8:14);
if (player.swingA > 1) player.swinging = false;
}
updatePlates(dt);
// hazards
for (const e of m.ents) {
if (e.kind === "hazard" && Math.hypot(e.x-player.x,e.y-player.y) < 18) {
hurtPlayer(1, "hazard");
}
}
// enemies
for (const en of enemies) {
if (!en.alive) continue;
if (en.hitT > 0) en.hitT -= dt;
if (en.stun > 0) en.stun -= dt;
if (en.frozen > 0) { en.frozen -= dt; continue; }
if (en.burn > 0) {
en.burn -= dt;
if (Math.random() < dt * 3) { en.hp -= 0.35; emberTrail(en.x, en.y); if (en.hp<=0) killEnemy(en); }
}
en.atkT -= dt;
const dx = player.x - en.x, dy = player.y - en.y;
const d = Math.hypot(dx, dy);
en.ang = Math.atan2(dy, dx);
const aggro = en.isCore ? 420 : en.isGuard || en.isMini ? 240 : 170;
if (en.stun <= 0) {
if (d < aggro) {
const sp = en.spd * (d < 40 ? 0.7 : 1);
const nx = en.x + (dx / d) * sp * dt;
const ny = en.y + (dy / d) * sp * dt;
if (!blocked(nx, en.y, en.r - 2)) en.x = nx;
if (!blocked(en.x, ny, en.r - 2)) en.y = ny;
} else {
en.wander += dt;
en.x += Math.cos(en.wander) * 18 * dt;
en.y += Math.sin(en.wander * 0.7) * 18 * dt;
}
} else {
en.x += en.vx * dt; en.y += en.vy * dt;
en.vx *= 0.85; en.vy *= 0.85;
}
// core special: shoot
if (en.isCore && d < 360 && en.atkT <= 0) {
en.atkT = 1.6;
const a = en.ang;
projs.push({ x: en.x, y: en.y, vx: Math.cos(a)*160, vy: Math.sin(a)*160, life: 2, r: 6, friendly: false, core: true });
}
if (d < en.r + player.r + 4 && en.atkT <= 0) {
en.atkT = 0.9;
hurtPlayer(en.dmg, "melee");
const a = Math.atan2(player.y-en.y, player.x-en.x);
player.x += Math.cos(a)*10; player.y += Math.sin(a)*10;
}
}
// projectiles
for (let i = projs.length-1; i>=0; i--) {
const p = projs[i];
p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt;
if (p.life <= 0 || solid(tileAt(p.x, p.y))) {
if (solid(tileAt(p.x,p.y))) {
hitWalls(p.x, p.y, 2);
AudioSys.chimeHit();
}
// switches
hitSwitches(p.x, p.y);
projs.splice(i,1);
continue;
}
if (p.friendly) {
hitSwitches(p.x, p.y);
for (const en of enemies) {
if (!en.alive) continue;
if (Math.hypot(en.x-p.x, en.y-p.y) < en.r + p.r) {
hurtEnemy(en, 2, Math.atan2(en.y-p.y, en.x-p.x));
AudioSys.chimeHit();
projs.splice(i,1);
break;
}
}
} else {
if (Math.hypot(player.x-p.x, player.y-p.y) < player.r + p.r) {
hurtPlayer(1, "proj");
projs.splice(i,1);
}
}
}
// env particles
dustT += dt;
if (dustT > 0.08) {
dustT = 0;
const zx = cam.x + Math.random()*W, zy = cam.y + Math.random()*H;
if (S.zone === 1) P.push({ x: zx, y: zy, vx: 8, vy: 12, life: 2, max: 2, col: "#c0a07044", size: 1.2, grav: 5, type: 1 });
if (S.zone === 3) P.push({ x: zx, y: zy+40, vx: (Math.random()-0.5)*10, vy: -18, life: 2.2, max: 2.2, col: "#8ec8e833", size: 2, grav: -4, type: 1 });
if (S.zone === 5) P.push({ x: zx, y: zy, vx: 0, vy: -10, life: 1.6, max: 1.6, col: "#e0b45a33", size: 1.5, grav: -6, type: 1 });
}
for (let i = floats.length-1; i>=0; i--) {
floats[i].life -= dt; floats[i].y -= 22*dt;
if (floats[i].life <= 0) floats.splice(i,1);
}
tickP(dt);
scanInteract();
// camera
const tw = m.w * T, th = m.h * T;
let cx = player.x - W/2, cy = player.y - H/2;
cx = Math.max(0, Math.min(tw - W, cx));
cy = Math.max(0, Math.min(th - H, cy));
if (tw < W) cx = (tw - W)/2;
if (th < H) cy = (th - H)/2;
cam.x += (cx - cam.x) * Math.min(1, dt * 6);
cam.y += (cy - cam.y) * Math.min(1, dt * 6);
}
function updatePlates(dt) {
const m = MAPS[S.zone];
for (const e of m.ents) {
if (e.kind !== "plate") continue;
const held = Math.hypot(e.x-player.x, e.y-player.y) < 22 ||
enemies.some(en => en.alive && Math.hypot(en.x-e.x,en.y-e.y) < 22);
if (held) e.latch = 3.2;
if (e.latch > 0) e.latch -= dt || 0.016;
const on = held || e.latch > 0;
if (on && !e.on) { e.on = true; AudioSys.plate(); burst(e.x, e.y, 6, ["#e0b45a"], 30, 0.3, 2); }
if (!on) e.on = false;
if (e.id === "gA") S._pA = on;
if (e.id === "gB") S._pB = on;
if (e.id === "vA") S._pV = on;
}
if (S.zone === 2) {
if (S._pA) S._pAt = performance.now();
if (S._pB) S._pBt = performance.now();
const both = S._pA && S._pB;
const seq = S._pAt && S._pBt && Math.abs(S._pAt - S._pBt) < 2200;
if ((both || seq) && !S.flags.galleryBridge) {
S.flags.galleryBridge = true;
AudioSys.door();
toast("GALLERY GATE", "Twin plates agree. The far nave unlocks.");
}
}
if (S.zone === 3 && S._pV) {
// reveal / weaken the shard wall — already breakable
}
}
function hitSwitches(x, y) {
const m = MAPS[S.zone];
for (const e of m.ents) {
if (e.kind !== "switch" || e.on) continue;
if (Math.hypot(e.x-x, e.y-y) < 22) {
e.on = true;
AudioSys.switchHit();
burst(e.x, e.y, 14, ["#80e0ff","#fff"], 50, 0.5, 3);
if (e.id === "v1") S.flags.switchA = true;
if (e.id === "v2") S.flags.switchB = true;
if (e.id === "h5" && !S.flags.heart5) grantHeart(5);
if (S.flags.switchA && S.flags.switchB && !S.flags.vaultGate) {
S.flags.vaultGate = true;
toast("VAULT SEAL", "The outer crystals unlock the ascent.");
}
log("A distant mechanism answers the shard.");
}
}
}
function normAng(a) {
while (a > Math.PI) a -= Math.PI*2;
while (a < -Math.PI) a += Math.PI*2;
return a;
}
// ─── Draw ────────────────────────────────────────────────
function draw() {
ctx.clearRect(0, 0, W, H);
const m = MAPS[S.started ? S.zone : 0];
// backdrop
const bg = S.zone === 3 ? "#0c1820" : S.zone === 5 ? "#1a140c" : S.zone === 1 ? "#16120e" : "#10161e";
ctx.fillStyle = bg;
ctx.fillRect(0,0,W,H);
const x0 = Math.max(0, Math.floor(cam.x / T) - 1);
const y0 = Math.max(0, Math.floor(cam.y / T) - 1);
const x1 = Math.min(m.w-1, Math.floor((cam.x+W)/T) + 1);
const y1 = Math.min(m.h-1, Math.floor((cam.y+H)/T) + 1);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
let t = m.tiles[y][x];
if ((t === 5 || t === 6) && S.flags[wallKey(S.zone, x, y)]) t = S.zone===3 ? 3 : 0;
const sx = x*T - cam.x, sy = y*T - cam.y;
drawTile(sx, sy, t, x, y);
}
}
// entities under player
for (const e of m.ents) {
if (e.kind === "enemy") continue;
drawEnt(e);
}
// dead enemies fade
for (const en of enemies) if (!en.alive) drawEnemy(en);
for (const en of enemies) if (en.alive) drawEnemy(en);
drawPlayer();
// projs
for (const p of projs) {
ctx.save();
ctx.translate(p.x-cam.x, p.y-cam.y);
ctx.fillStyle = p.friendly ? "#9ae8ff" : "#ff8060";
ctx.shadowColor = p.friendly ? "#6cf" : "#f40";
ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(0,0,p.r,0,Math.PI*2); ctx.fill();
ctx.restore();
}
drawP(cam);
for (const f of floats) {
ctx.globalAlpha = Math.max(0, f.life);
ctx.fillStyle = f.col;
ctx.font = "bold 14px Cinzel, serif";
ctx.textAlign = "center";
ctx.fillText(f.t, f.x-cam.x, f.y-cam.y);
ctx.globalAlpha = 1;
}
// vignette
const g = ctx.createRadialGradient(W/2,H/2, W*0.25, W/2,H/2, W*0.72);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(1, S.zone===5 ? "rgba(40,20,0,0.55)" : "rgba(0,0,0,0.55)");
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
if (player.flash > 0) {
ctx.fillStyle = `rgba(180,30,30,${player.flash})`;
ctx.fillRect(0,0,W,H);
}
// lantern reveal pulse
if (S.equip.lantern) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
const lg = ctx.createRadialGradient(player.x-cam.x, player.y-cam.y, 10, player.x-cam.x, player.y-cam.y, 160);
lg.addColorStop(0, "rgba(120,180,220,0.12)");
lg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = lg;
ctx.fillRect(0,0,W,H);
ctx.restore();
}
}
function drawTile(sx, sy, t, tx, ty) {
const hash = ((tx*17 + ty*31) & 7);
if (t === 1) {
ctx.fillStyle = (tx+ty)%2 ? "#2a221c" : "#241c18";
ctx.fillRect(sx, sy, T, T);
ctx.strokeStyle = "#3a3028";
ctx.strokeRect(sx+0.5, sy+0.5, T-1, T-1);
ctx.fillStyle = "#4a3a28";
if (hash===0) ctx.fillRect(sx+8, sy+10, 10, 4);
return;
}
// floor
if (t === 2) {
ctx.fillStyle = hash%2 ? "#3a2a20" : "#32241c";
ctx.fillRect(sx, sy, T, T);
ctx.strokeStyle = "#5a4030";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sx, sy+T/2); ctx.lineTo(sx+T, sy+T/2);
ctx.moveTo(sx+T/2, sy); ctx.lineTo(sx+T/2, sy+T);
ctx.stroke();
} else if (t === 3) {
ctx.fillStyle = hash%2 ? "#1a3040" : "#162838";
ctx.fillRect(sx, sy, T, T);
ctx.fillStyle = "rgba(80,140,160,0.15)";
ctx.fillRect(sx+4, sy+6, 12, 5);
} else if (t === 4) {
ctx.fillStyle = hash%2 ? "#3a3020" : "#443818";
ctx.fillRect(sx, sy, T, T);
ctx.fillStyle = "#e0b45a22";
ctx.fillRect(sx, sy, T, T);
} else {
const pal = S.zone === 5
? ["#3a2c18", "#322614"]
: S.zone === 4
? ["#2a2834", "#22202c"]
: ["#243040", "#1e2a38"];
ctx.fillStyle = pal[hash % 2];
ctx.fillRect(sx, sy, T, T);
ctx.fillStyle = "rgba(0,0,0,0.14)";
ctx.fillRect(sx, sy + T - 6, T, 6);
if (hash === 3) {
ctx.fillStyle = "rgba(224,180,90,0.05)";
ctx.beginPath(); ctx.arc(sx + 20, sy + 18, 7, 0, Math.PI * 2); ctx.fill();
}
if (hash === 5) {
ctx.fillStyle = "rgba(60,90,50,0.12)";
ctx.fillRect(sx + 4, sy + 22, 16, 5);
}
}
// breakable
if (t === 5 || t === 6) {
ctx.fillStyle = t===6 ? "#2a2420" : "#3a2820";
ctx.fillRect(sx, sy, T, T);
ctx.strokeStyle = "#1a120e";
ctx.strokeRect(sx, sy, T, T);
ctx.strokeStyle = S.equip.lantern || t===5 ? "#c0a070" : "#5a4038";
ctx.lineWidth = S.equip.lantern ? 1.6 : 1;
ctx.beginPath();
ctx.moveTo(sx+8, sy+6); ctx.lineTo(sx+14, sy+22); ctx.lineTo(sx+28, sy+30);
ctx.moveTo(sx+22, sy+4); ctx.lineTo(sx+18, sy+18);
ctx.stroke();
if (S.equip.lantern) {
ctx.fillStyle = "rgba(140,200,230,0.12)";
ctx.fillRect(sx, sy, T, T);
}
}
}
function drawEnt(e) {
const x = e.x - cam.x, y = e.y - cam.y;
if (e.kind === "save") {
const on = (e.sid===1&&S.flags.save1)||(e.sid===2&&S.flags.save2)||(e.sid===3&&S.flags.save3);
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = "#2a2418";
ctx.beginPath(); ctx.moveTo(-10,8); ctx.lineTo(10,8); ctx.lineTo(6,14); ctx.lineTo(-6,14); ctx.fill();
ctx.fillStyle = on ? "#e0b45a" : "#8a7040";
ctx.shadowColor = "#e0b45a"; ctx.shadowBlur = on ? 18 : 6;
ctx.beginPath(); ctx.moveTo(0,-16); ctx.lineTo(8,8); ctx.lineTo(-8,8); ctx.closePath(); ctx.fill();
ctx.restore();
if (Math.random() < 0.3) P.push({ x: e.x+(Math.random()-0.5)*10, y: e.y-16, vx:0, vy:-12, life:0.8, max:0.8, col:"#e0b45a", size:1.5, grav:-4, type:1 });
} else if (e.kind === "shop") {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = "#1a1820";
ctx.fillRect(-16, -8, 32, 16);
ctx.strokeStyle = "#e0b45a"; ctx.strokeRect(-16,-8,32,16);
ctx.fillStyle = "#e0b45a"; ctx.font = "12px Cinzel, serif"; ctx.textAlign="center";
ctx.fillText("✦", 0, 4);
ctx.restore();
} else if (e.kind === "altar") {
ctx.save(); ctx.translate(x,y);
const lit = (e.up===1&&S.flags.w1)||(e.up===2&&S.flags.w2)||(e.up==="lantern"&&S.flags.lanternGot);
ctx.fillStyle = "#2a2018";
ctx.beginPath(); ctx.ellipse(0, 8, 16, 7, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = lit ? (e.up===2?"#e0b45a":"#e07030") : "#4a4030";
ctx.shadowColor = lit ? "#e0b45a" : "transparent"; ctx.shadowBlur = 16;
ctx.fillRect(-8, -14, 16, 20);
ctx.restore();
} else if (e.kind === "plate") {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = e.on ? "#a08040" : "#5a4a30";
ctx.beginPath(); ctx.ellipse(0, 0, 16, 10, 0, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = "#e0b45a88"; ctx.stroke();
ctx.restore();
} else if (e.kind === "switch") {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = e.on ? "#80e0ff" : "#406080";
ctx.shadowColor = e.on ? "#6cf" : "#246"; ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI*2); ctx.fill();
ctx.restore();
} else if (e.kind === "door") {
ctx.save(); ctx.translate(x, y);
ctx.fillStyle = "#1a120c";
ctx.fillRect(-12, -20, 24, 36);
ctx.strokeStyle = "#e0b45a"; ctx.lineWidth = 1.4;
ctx.strokeRect(-12, -20, 24, 36);
ctx.fillStyle = "#e0b45a88";
ctx.fillRect(-2, -4, 4, 10);
ctx.restore();
} else if (e.kind === "hazard") {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = "#6a2020";
for (let i=0;i<5;i++) {
const a = i/5*Math.PI*2 + 0.3;
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(a)*10, Math.sin(a)*7); ctx.lineTo(Math.cos(a+0.3)*6, Math.sin(a+0.3)*4); ctx.fill();
}
ctx.restore();
} else if (e.kind === "pot" && !e.broke) {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = "#6a5038";
ctx.beginPath(); ctx.ellipse(0, 4, 7, 5, 0, 0, Math.PI*2); ctx.fill();
ctx.fillRect(-6, -6, 12, 10);
ctx.fillStyle = "#8a6a48"; ctx.fillRect(-4, -8, 8, 4);
ctx.restore();
} else if (e.kind === "chest") {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = e.opened ? "#3a3020" : "#8a6030";
ctx.fillRect(-10, -6, 20, 14);
ctx.strokeStyle = "#e0b45a"; ctx.strokeRect(-10,-6,20,14);
if (!e.opened) { ctx.fillStyle = "#e0b45a"; ctx.fillRect(-2,-2,4,6); }
ctx.restore();
} else if (e.kind === "cache" && !e.opened) {
ctx.save(); ctx.translate(x,y);
ctx.fillStyle = "#4a4030";
ctx.beginPath(); ctx.arc(0,0,6,0,Math.PI*2); ctx.fill();
ctx.fillStyle = "#e0b45a88"; ctx.fillRect(-2,-2,4,4);
ctx.restore();
} else if (e.kind === "beacon") {
ctx.save(); ctx.translate(x,y);
const burn = S.flags.coreDead;
ctx.fillStyle = "#2a2010";
ctx.beginPath(); ctx.moveTo(-14,16); ctx.lineTo(14,16); ctx.lineTo(8,-4); ctx.lineTo(-8,-4); ctx.fill();
ctx.fillStyle = burn ? "#ffe08a" : "#806030";
ctx.shadowColor = "#e0b45a"; ctx.shadowBlur = burn ? 40 : 10;
ctx.beginPath(); ctx.arc(0, -10, burn?14:8, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
function drawEnemy(en) {
const x = en.x - cam.x, y = en.y - cam.y;
ctx.save();
ctx.translate(x, y);
if (!en.alive) { ctx.globalAlpha = 0.25; }
// shadow
ctx.fillStyle = "rgba(0,0,0,0.35)";
ctx.beginPath(); ctx.ellipse(0, en.r+2, en.r*0.9, 5, 0, 0, Math.PI*2); ctx.fill();
if (en.frozen > 0) { ctx.shadowColor = "#6cf"; ctx.shadowBlur = 16; }
if (en.hitT > 0) ctx.globalAlpha = 0.55;
// body
ctx.fillStyle = en.isCore ? "#2a1810" : en.color;
ctx.beginPath(); ctx.arc(0, -2, en.r, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = en.isCore ? "#e07030" : "#c94a4a";
ctx.fillRect(-6, -8, 12, 4);
ctx.strokeStyle = "#1a120c";
ctx.stroke();
if (en.type === "sentinel") {
ctx.fillStyle = "#8a5040";
ctx.fillRect(-10, -4, 4, 10);
ctx.fillRect(6, -4, 4, 10);
}
if (en.type === "brute" || en.isCore) {
ctx.fillStyle = "#2a1810";
ctx.fillRect(-en.r + 2, 2, en.r * 2 - 4, 6);
}
if (en.isCore || en.isGuard || en.isMini) {
ctx.strokeStyle = "#e0b45a66";
ctx.beginPath(); ctx.arc(0, -2, en.r + 3, 0, Math.PI * 2); ctx.stroke();
}
// hp
if (en.alive && en.hp < en.maxHp) {
ctx.fillStyle = "#000"; ctx.fillRect(-en.r, -en.r-10, en.r*2, 3);
ctx.fillStyle = "#c94a4a"; ctx.fillRect(-en.r, -en.r-10, en.r*2 * (en.hp/en.maxHp), 3);
}
ctx.restore();
}
function drawPlayer() {
const x = player.x - cam.x, y = player.y - cam.y;
ctx.save();
ctx.translate(x, y);
if (player.invuln > 0 && Math.floor(player.invuln*12)%2===0) ctx.globalAlpha = 0.45;
const bob = Math.sin(player.bob) * 1.5;
// shadow
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath(); ctx.ellipse(0, 12, 11, 5, 0, 0, Math.PI*2); ctx.fill();
const ang = player.facing;
// cloak
ctx.save();
ctx.rotate(ang + Math.PI/2);
const cloakCol = S.equip.cloak ? "#24182c" : "#4a3a30";
ctx.fillStyle = cloakCol;
ctx.beginPath();
ctx.moveTo(0, -6 + bob);
ctx.quadraticCurveTo(16, 4, 8, 18 + bob);
ctx.lineTo(-8, 18 + bob);
ctx.quadraticCurveTo(-16, 4, 0, -6 + bob);
ctx.fill();
if (S.weapon === 2) {
ctx.strokeStyle = "#e0b45a88"; ctx.lineWidth = 1.4;
ctx.stroke();
}
if (S.equip.cloak) {
ctx.strokeStyle = "#4a3060"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-7, 16+bob); ctx.lineTo(7, 16+bob); ctx.stroke();
}
ctx.restore();
ctx.fillStyle = "#5a5048";
ctx.beginPath(); ctx.arc(0, -2+bob, 10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#2a2220";
ctx.beginPath(); ctx.arc(0, -7+bob, 8.5, Math.PI, 0); ctx.fill();
ctx.beginPath(); ctx.arc(0, -5+bob, 6.5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#e0b45a";
ctx.fillRect(-3.5, -6+bob, 7, 2.2);
// lantern glow token
if (S.equip.lantern) {
ctx.fillStyle = "#8ec8e8";
ctx.shadowColor = "#6cf"; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(-10, 2+bob, 3, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
}
// band
if (S.equip.band) {
ctx.strokeStyle = "#d0d8e0"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(8, 2+bob, 3, 0, Math.PI*2); ctx.stroke();
}
// blade
const swing = player.swinging ? (player.swingA - 0.5) * atkArc() : 0;
const bladeA = ang + swing;
const bl = S.weapon === 2 ? 28 : 22;
ctx.save();
ctx.rotate(bladeA);
if (S.weapon === 1) { ctx.shadowColor = "#e07030"; ctx.shadowBlur = 12; }
if (S.weapon === 2) { ctx.shadowColor = "#e0b45a"; ctx.shadowBlur = 16; }
ctx.strokeStyle = S.weapon===2 ? "#ffe08a" : S.weapon===1 ? "#e07030" : "#c0c4c8";
ctx.lineWidth = S.weapon===2 ? 3.4 : 2.4;
ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(8+bl, 0); ctx.stroke();
ctx.fillStyle = "#8a7030";
ctx.fillRect(6, -2, 5, 4);
ctx.restore();
// aether shard offhand
if (S.hasShard) {
ctx.save();
ctx.rotate(ang);
ctx.fillStyle = "#7ad0f0";
ctx.shadowColor = "#6cf"; ctx.shadowBlur = 10;
ctx.beginPath();
ctx.moveTo(-12, -6); ctx.lineTo(-6, 0); ctx.lineTo(-12, 6); ctx.lineTo(-18, 0);
ctx.closePath(); ctx.fill();
ctx.restore();
}
// solar halo
if (S.weapon === 2) {
ctx.strokeStyle = "rgba(224,180,90,0.25)";
ctx.lineWidth = 6;
ctx.beginPath(); ctx.arc(0, -2+bob, 16, 0, Math.PI*2); ctx.stroke();
}
ctx.restore();
}
// ─── Loop ────────────────────────────────────────────────
function frame(now) {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
update(dt);
draw();
requestAnimationFrame(frame);
}
updateHUD();
requestAnimationFrame(frame);
window.__G.S = S;
window.__G.player = player;
window.__G.MAPS = MAPS;
window.__G.enemies = () => enemies;
window.__G.spawnInZone = spawnInZone;
window.__G.grantHeart = grantHeart;
window.__G.updateHUD = updateHUD;
window.__G.openShop = openShop;
window.__G.rebuildEnemies = rebuildEnemies;
window.__G.tryMelee = tryMelee;
window.__G.startSwing = startSwing;
window.__G.tryShoot = tryShoot;
window.__G.interact = interact;
window.__G.scanInteract = scanInteract;
window.__G.useStem = useStem;
window.__G.useSeal = useSeal;
window.__G.hurtEnemy = hurtEnemy;
window.__G.igniteBeacon = igniteBeacon;
window.__G.buy = buy;
window.addEventListener("pointerdown", () => AudioSys.resume(), { once: false });
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.6","provider":"openrouter","persona":"full","sessionId":"f1e5838e-40af-4fea-8f94-602a9918c522","tokensIn":2356670,"tokensOut":54835,"tokensTotal":2411505,"cost":1.7411019999999997,"turns":25,"toolCalls":50,"failedToolCalls":0,"timestamp":"2026-08-17T17:47:40.955Z"} -->