RPG
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 81,436 bytes · SHA-256 af44043be54a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Emberwake — A Short Adventure</title>
<style>
:root {
--ink: #1a1410;
--parchment: #e8d5a3;
--gold: #d4a017;
--blood: #c23b22;
--moss: #2d4a3e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0b0a09;
font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
color: var(--parchment);
user-select: none;
}
#wrap {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(ellipse at 50% 40%, #1c1612 0%, #0b0a09 70%);
}
canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
box-shadow: 0 0 0 3px #3a2e22, 0 0 0 6px #1a1410, 0 20px 60px rgba(0,0,0,.7);
background: #111;
max-width: 100vw; max-height: 100vh;
}
#overlay {
position: fixed; inset: 0; pointer-events: none;
display: flex; align-items: center; justify-content: center;
}
.panel {
pointer-events: auto;
background: linear-gradient(180deg, #2a2118 0%, #1a1410 100%);
border: 3px solid #c9a227;
box-shadow: 0 0 0 2px #1a1410, 0 16px 48px rgba(0,0,0,.75), inset 0 0 40px rgba(0,0,0,.35);
color: #f0e6c8;
padding: 28px 36px;
max-width: 560px;
width: min(92vw, 560px);
}
.panel h1 {
font-size: 42px; letter-spacing: 4px; text-align: center;
color: #f3c14a; text-shadow: 0 2px 0 #5a3208, 0 0 24px rgba(212,160,23,.4);
margin-bottom: 6px;
}
.panel .sub { text-align: center; color: #b9a57a; margin-bottom: 18px; font-style: italic; }
.panel p { line-height: 1.5; margin: 8px 0; font-size: 15px; }
.panel kbd {
display: inline-block; background: #3a2e22; border: 1px solid #7a6544;
padding: 1px 7px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px;
color: #f3e2b0;
}
.btn {
display: block; width: 100%; margin-top: 14px; padding: 12px;
background: linear-gradient(180deg, #c9a227, #8a6a12);
color: #1a1204; font-weight: 700; font-size: 16px; letter-spacing: 2px;
border: 2px solid #f3d98a; cursor: pointer; font-family: inherit;
}
.btn:hover { filter: brightness(1.12); }
.btn.ghost {
background: transparent; color: #e8d5a3; border-color: #7a6544; font-weight: 500;
}
.hidden { display: none !important; }
#toast {
position: fixed; top: 18%; left: 50%; transform: translateX(-50%);
background: rgba(20,14,10,.88); border: 1px solid #c9a227; color: #f3e2b0;
padding: 8px 18px; pointer-events: none; font-size: 15px; letter-spacing: 1px;
opacity: 0; transition: opacity .25s; z-index: 5;
}
#toast.show { opacity: 1; }
</style>
</head>
<body>
<div id="wrap"><canvas id="c" width="960" height="640"></canvas></div>
<div id="toast"></div>
<div id="overlay">
<div class="panel" id="titlePanel">
<h1>EMBERWAKE</h1>
<div class="sub">A one-sitting action-adventure</div>
<p>The village of Brackenford sleeps under a dying sun. Something in the old temple has begun to breathe again. Take up a blade. Walk the wilds. Come back changed.</p>
<p style="text-align:center;line-height:1.9">
<kbd>WASD</kbd> move
<kbd>J</kbd> sword
<kbd>K</kbd> bow
<kbd>L</kbd> dash
<kbd>E</kbd> interact
<kbd>I</kbd> pack
<kbd>H</kbd> tonic
<kbd>B</kbd> bomb
</p>
<button class="btn" id="btnNew">NEW JOURNEY</button>
<button class="btn ghost hidden" id="btnCont">CONTINUE</button>
</div>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const overlay = document.getElementById("overlay");
const titlePanel = document.getElementById("titlePanel");
const btnNew = document.getElementById("btnNew");
const btnCont = document.getElementById("btnCont");
const toastEl = document.getElementById("toast");
const TW = 48, TH = 48;
const COLS = 16, ROWS = 11;
const MAP_OX = 96, MAP_OY = 72;
const VIEW_W = COLS * TW, VIEW_H = ROWS * TH;
const SAVE_KEY = "emberwake_v1";
const TILE = {
GRASS: 0, DIRT: 1, WATER: 2, TREE: 3, STONE: 4, CAVE: 5,
SAND: 6, CRACK: 7, TALL: 8, LAVA: 9, WOOD: 10, FLOWER: 11,
FENCE: 12, DARK: 13, SHRINE: 14, CARPET: 15, WALL: 16,
RUIN: 17, SNOW: 18, ICE: 19, PILLAR: 20, HEARTH: 21, PATH: 22
};
const SOLID = new Set([
TILE.WATER, TILE.TREE, TILE.CAVE, TILE.FENCE,
TILE.WALL, TILE.PILLAR, TILE.HEARTH, TILE.CRACK
]);
// ---------------------------------------------------------------------------
// Audio (synthesized)
// ---------------------------------------------------------------------------
const AudioFX = {
ctx: null,
muted: false,
stepT: 0,
ensure() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) this.ctx = new AC();
}
if (this.ctx && this.ctx.state === "suspended") this.ctx.resume();
},
beep(freq, dur, type, vol, slide, delay) {
if (this.muted || !this.ctx) return;
const t = this.ctx.currentTime + (delay || 0);
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type || "square";
o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, slide), t + dur);
g.gain.setValueAtTime(vol || 0.08, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(this.ctx.destination);
o.start(t); o.stop(t + dur + 0.02);
},
noise(dur, vol, hp) {
if (this.muted || !this.ctx) return;
const n = this.ctx.sampleRate * dur;
const buf = this.ctx.createBuffer(1, n, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
const src = this.ctx.createBufferSource();
src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = "highpass"; f.frequency.value = hp || 400;
const g = this.ctx.createGain();
g.gain.value = vol || 0.06;
src.connect(f); f.connect(g); g.connect(this.ctx.destination);
src.start();
},
walk() {
const now = performance.now();
if (now - this.stepT < 220) return;
this.stepT = now;
this.beep(90 + Math.random() * 30, 0.05, "triangle", 0.03);
},
sword() { this.beep(320, 0.08, "square", 0.07, 140); this.noise(0.06, 0.04, 800); },
bow() { this.beep(520, 0.12, "sawtooth", 0.05, 180); },
hit() { this.beep(180, 0.1, "square", 0.09, 70); this.noise(0.08, 0.07, 200); },
hurt() { this.beep(140, 0.18, "sawtooth", 0.1, 50); },
pickup() { this.beep(660, 0.08, "sine", 0.07); this.beep(880, 0.1, "sine", 0.06, null, 0.07); },
coin() { this.beep(980, 0.06, "square", 0.05); this.beep(1320, 0.08, "square", 0.04, null, 0.05); },
shop() { this.beep(440, 0.08, "triangle", 0.06); this.beep(554, 0.1, "triangle", 0.05, null, 0.08); this.beep(659, 0.14, "triangle", 0.05, null, 0.16); },
save() { this.beep(392, 0.12, "sine", 0.06); this.beep(523, 0.14, "sine", 0.05, null, 0.1); this.beep(659, 0.22, "sine", 0.05, null, 0.2); },
heal() { this.beep(523, 0.1, "sine", 0.06); this.beep(659, 0.14, "sine", 0.05, null, 0.08); },
unlock() { this.beep(300, 0.08, "square", 0.06, 600); },
boom() { this.noise(0.25, 0.12, 80); this.beep(80, 0.25, "sawtooth", 0.1, 40); },
death() { this.beep(220, 0.4, "sawtooth", 0.08, 50); },
win() {
[523, 659, 784, 1046].forEach((f, i) => this.beep(f, 0.28, "triangle", 0.07, null, i * 0.14));
}
};
// ---------------------------------------------------------------------------
// Particles
// ---------------------------------------------------------------------------
const particles = [];
function burst(x, y, n, cols, spd, life, size) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = Math.random() * spd;
particles.push({
x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
life, max: life, r: size * (0.4 + Math.random()),
col: cols[i % cols.length], g: 80 + Math.random() * 220
});
}
}
function spark(x, y, vx, vy, col) {
particles.push({ x, y, vx, vy, life: 0.35, max: 0.35, r: 2.5, col, g: 200 });
}
function tickParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
p.x += p.vx * dt; p.y += p.vy * dt;
p.vy += p.g * dt;
if (p.life <= 0) particles.splice(i, 1);
}
}
function drawParticles() {
for (const p of particles) {
ctx.globalAlpha = Math.max(0, p.life / p.max);
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
const keys = Object.create(null);
const just = Object.create(null);
window.addEventListener("keydown", e => {
if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) e.preventDefault();
if (!keys[e.code]) just[e.code] = true;
keys[e.code] = true;
AudioFX.ensure();
});
window.addEventListener("keyup", e => { keys[e.code] = false; });
function pressed(code) { return !!just[code]; }
function down(code) { return !!keys[code]; }
function flushJust() { for (const k in just) delete just[k]; }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const rand = (a, b) => a + Math.random() * (b - a);
const irand = (a, b) => (a + Math.floor(Math.random() * (b - a + 1)));
const choice = a => a[Math.floor(Math.random() * a.length)];
function toast(msg, ms) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => toastEl.classList.remove("show"), ms || 1800);
}
function heartPath(ctx, x, y, s) {
ctx.beginPath();
ctx.moveTo(x, y + s * 0.3);
ctx.bezierCurveTo(x, y, x - s, y, x - s, y + s * 0.35);
ctx.bezierCurveTo(x - s, y + s * 0.7, x, y + s * 0.95, x, y + s * 1.15);
ctx.bezierCurveTo(x, y + s * 0.95, x + s, y + s * 0.7, x + s, y + s * 0.35);
ctx.bezierCurveTo(x + s, y, x, y, x, y + s * 0.3);
ctx.closePath();
}
// ---------------------------------------------------------------------------
// World generation
// ---------------------------------------------------------------------------
const MAP_W = 5, MAP_H = 4;
// room ids: x + y * MAP_W
// 0 caveW 1 cave 2 caveE 3 peak 4 temple
// 5 forestW 6 forest 7 village 8 meadow 9 ruinsN
// 10 grove 11 lake 12 south 13 cliff 14 ruins
// 15 swamp 16 shrine 17 farm 18 gate 19 ruinsS
const ROOM_META = [
{ name: "West Hollow", biome: "cave", dark: true },
{ name: "Sunken Gallery", biome: "cave", dark: true },
{ name: "Echo Mouth", biome: "cave", dark: true },
{ name: "Ashen Peak", biome: "peak" },
{ name: "Ember Temple", biome: "temple" },
{ name: "Deepwood", biome: "forest" },
{ name: "Old Growth", biome: "forest" },
{ name: "Brackenford", biome: "village" },
{ name: "Sunny Lea", biome: "meadow" },
{ name: "North Cloister", biome: "ruins" },
{ name: "Smith's Grove", biome: "grove" },
{ name: "Mirror Lake", biome: "lake" },
{ name: "South Road", biome: "field" },
{ name: "Windcliff", biome: "cliff" },
{ name: "Broken Court", biome: "ruins" },
{ name: "Mirefen", biome: "swamp" },
{ name: "Wayside Shrine", biome: "shrine" },
{ name: "Barley Farm", biome: "farm" },
{ name: "Ruin Gate", biome: "field" },
{ name: "Catacomb Yard", biome: "ruins" }
];
function fillRoom(id) {
const tiles = new Uint8Array(COLS * ROWS);
const meta = ROOM_META[id];
const biome = meta.biome;
let base = TILE.GRASS;
if (biome === "cave" || biome === "temple") base = TILE.STONE;
if (biome === "peak") base = TILE.SNOW;
if (biome === "ruins") base = TILE.RUIN;
if (biome === "swamp") base = TILE.DIRT;
if (biome === "lake") base = TILE.GRASS;
if (biome === "village" || biome === "farm" || biome === "shrine") base = TILE.GRASS;
if (biome === "cliff") base = TILE.DIRT;
for (let i = 0; i < tiles.length; i++) tiles[i] = base;
const set = (x, y, t) => { if (x >= 0 && y >= 0 && x < COLS && y < ROWS) tiles[y * COLS + x] = t; };
const get = (x, y) => tiles[y * COLS + x];
// scatter
for (let y = 1; y < ROWS - 1; y++) {
for (let x = 1; x < COLS - 1; x++) {
const r = Math.random();
if (biome === "meadow" || biome === "field" || biome === "farm") {
if (r < 0.08) set(x, y, TILE.FLOWER);
else if (r < 0.12) set(x, y, TILE.TALL);
else if (r < 0.16) set(x, y, TILE.DIRT);
} else if (biome === "forest" || biome === "grove") {
if (r < 0.07) set(x, y, TILE.TREE);
else if (r < 0.12) set(x, y, TILE.TALL);
else if (r < 0.16) set(x, y, TILE.FLOWER);
} else if (biome === "swamp") {
if (r < 0.1) set(x, y, TILE.WATER);
else if (r < 0.16) set(x, y, TILE.TALL);
else if (r < 0.22) set(x, y, TILE.GRASS);
} else if (biome === "cave") {
if (r < 0.06) set(x, y, TILE.CAVE);
else if (r < 0.1) set(x, y, TILE.DARK);
} else if (biome === "ruins") {
if (r < 0.07) set(x, y, TILE.WALL);
else if (r < 0.12) set(x, y, TILE.STONE);
} else if (biome === "peak") {
if (r < 0.08) set(x, y, TILE.WALL);
else if (r < 0.12) set(x, y, TILE.LAVA);
} else if (biome === "temple") {
if (r < 0.05) set(x, y, TILE.PILLAR);
else if (r < 0.1) set(x, y, TILE.CARPET);
} else if (biome === "village") {
if (r < 0.1) set(x, y, TILE.PATH);
else if (r < 0.14) set(x, y, TILE.FLOWER);
}
}
}
// borders
const rx = id % MAP_W, ry = Math.floor(id / MAP_W);
const wallTile = (biome === "cave" || biome === "temple") ? TILE.CAVE
: (biome === "peak" || biome === "ruins") ? TILE.WALL
: TILE.TREE;
for (let x = 0; x < COLS; x++) {
set(x, 0, wallTile); set(x, ROWS - 1, wallTile);
}
for (let y = 0; y < ROWS; y++) {
set(0, y, wallTile); set(COLS - 1, y, wallTile);
}
// openings to neighbors
const openN = ry > 0, openS = ry < MAP_H - 1, openW = rx > 0, openE = rx < MAP_W - 1;
const floorOpen = base;
if (openN) { set(7, 0, floorOpen); set(8, 0, floorOpen); }
if (openS) { set(7, ROWS - 1, floorOpen); set(8, ROWS - 1, floorOpen); }
if (openW) { set(0, 5, floorOpen); set(0, 4, floorOpen); }
if (openE) { set(COLS - 1, 5, floorOpen); set(COLS - 1, 4, floorOpen); }
// room-specific architecture
if (id === 7) { // village
for (let x = 3; x <= 6; x++) for (let y = 2; y <= 5; y++) set(x, y, TILE.WOOD);
set(3, 2, TILE.WALL); set(4, 2, TILE.WALL); set(5, 2, TILE.WALL); set(6, 2, TILE.WALL);
set(3, 3, TILE.WALL); set(6, 3, TILE.WALL);
set(3, 4, TILE.WALL); set(6, 4, TILE.WALL);
set(3, 5, TILE.WALL); set(6, 5, TILE.WALL);
set(4, 5, TILE.WOOD); set(5, 5, TILE.WOOD); // door
set(4, 3, TILE.CARPET); set(5, 3, TILE.CARPET); set(4, 4, TILE.CARPET); set(5, 4, TILE.CARPET);
for (let x = 10; x <= 13; x++) for (let y = 2; y <= 5; y++) set(x, y, TILE.WOOD);
set(10, 2, TILE.WALL); set(11, 2, TILE.HEARTH); set(12, 2, TILE.WALL); set(13, 2, TILE.WALL);
set(10, 3, TILE.WALL); set(13, 3, TILE.WALL);
set(10, 4, TILE.WALL); set(13, 4, TILE.WALL);
set(10, 5, TILE.WALL); set(13, 5, TILE.WALL);
set(11, 5, TILE.WOOD); set(12, 5, TILE.WOOD);
for (let x = 6; x <= 9; x++) set(x, 7, TILE.PATH);
set(7, 6, TILE.PATH); set(8, 6, TILE.PATH); set(7, 8, TILE.PATH); set(8, 8, TILE.PATH);
set(8, 8, TILE.SHRINE);
}
if (id === 8) { // meadow
set(12, 3, TILE.FLOWER); set(3, 7, TILE.TALL);
set(11, 7, TILE.TREE); set(4, 3, TILE.TREE);
}
if (id === 6) { // forest
set(3, 3, TILE.TREE); set(4, 3, TILE.TREE); set(12, 3, TILE.TREE);
set(3, 7, TILE.TREE); set(12, 7, TILE.TREE); set(11, 6, TILE.TREE);
set(8, 5, TILE.DIRT);
}
if (id === 5) {
for (let i = 0; i < 8; i++) set(2 + (i % 4) * 3, 2 + Math.floor(i / 4) * 4, TILE.TREE);
}
if (id === 10) { // grove - smith
for (let x = 5; x <= 10; x++) for (let y = 3; y <= 6; y++) set(x, y, TILE.DIRT);
set(7, 3, TILE.HEARTH); set(8, 3, TILE.HEARTH);
set(6, 4, TILE.WALL); set(9, 4, TILE.WALL);
}
if (id === 11) { // lake
for (let y = 3; y <= 7; y++) for (let x = 4; x <= 11; x++) {
if (Math.hypot(x - 7.5, y - 5) < 3.2) set(x, y, TILE.WATER);
}
set(7, 5, TILE.WATER); set(8, 5, TILE.WATER);
}
if (id === 15) { // swamp
set(4, 4, TILE.WATER); set(5, 4, TILE.WATER); set(4, 5, TILE.WATER);
set(10, 6, TILE.WATER); set(11, 6, TILE.WATER); set(11, 7, TILE.WATER);
set(2, 7, TILE.TREE); set(2, 8, TILE.TREE); set(3, 7, TILE.CRACK);
set(4, 7, TILE.TREE); set(4, 8, TILE.TREE); set(4, 9, TILE.TREE);
}
if (id === 16) { // shrine field
set(7, 4, TILE.SHRINE); set(8, 4, TILE.STONE); set(7, 5, TILE.STONE); set(8, 5, TILE.STONE);
set(6, 3, TILE.PILLAR); set(9, 3, TILE.PILLAR);
}
if (id === 17) {
for (let x = 3; x <= 6; x++) for (let y = 3; y <= 6; y++) set(x, y, TILE.WOOD);
set(3, 3, TILE.WALL); set(6, 3, TILE.WALL); set(3, 6, TILE.WALL); set(6, 6, TILE.WALL);
set(4, 6, TILE.WOOD); set(5, 6, TILE.WOOD);
for (let x = 9; x <= 13; x++) set(x, 4, TILE.TALL);
for (let x = 9; x <= 13; x++) set(x, 5, TILE.TALL);
}
if (id === 1) { // cave center
set(7, 5, TILE.SHRINE); set(8, 5, TILE.STONE);
set(4, 3, TILE.CAVE); set(11, 3, TILE.CAVE); set(4, 7, TILE.CAVE); set(11, 7, TILE.CAVE);
}
if (id === 0) {
set(3, 2, TILE.CAVE); set(4, 2, TILE.CAVE); set(2, 2, TILE.CAVE);
set(2, 3, TILE.CAVE); set(2, 4, TILE.CAVE);
set(3, 4, TILE.CRACK); set(4, 4, TILE.CAVE);
set(3, 7, TILE.DARK);
}
if (id === 2) {
set(11, 3, TILE.CAVE); set(12, 3, TILE.CAVE); set(5, 7, TILE.DARK);
}
if (id === 3) { // peak
set(7, 3, TILE.HEARTH); set(8, 3, TILE.HEARTH);
set(5, 6, TILE.LAVA); set(10, 6, TILE.LAVA);
set(6, 2, TILE.WALL); set(9, 2, TILE.WALL);
}
if (id === 4) { // temple
for (let x = 5; x <= 10; x++) set(x, 2, TILE.CARPET);
for (let y = 2; y <= 8; y++) { set(7, y, TILE.CARPET); set(8, y, TILE.CARPET); }
set(4, 3, TILE.PILLAR); set(11, 3, TILE.PILLAR);
set(4, 7, TILE.PILLAR); set(11, 7, TILE.PILLAR);
set(7, 2, TILE.HEARTH); set(8, 2, TILE.HEARTH);
}
if (id === 14) {
set(5, 4, TILE.WALL); set(6, 4, TILE.WALL); set(10, 4, TILE.WALL);
set(7, 6, TILE.CRACK);
}
if (id === 9) {
set(4, 3, TILE.PILLAR); set(11, 3, TILE.PILLAR);
set(7, 5, TILE.STONE); set(8, 5, TILE.STONE);
}
if (id === 19) {
set(3, 3, TILE.WALL); set(12, 3, TILE.WALL);
set(8, 7, TILE.CRACK);
}
if (id === 13) {
for (let x = 2; x < 14; x++) if (x !== 7 && x !== 8) set(x, 6, TILE.WALL);
}
return tiles;
}
// static room tiles cache
const ROOM_TILES = [];
for (let i = 0; i < MAP_W * MAP_H; i++) ROOM_TILES.push(fillRoom(i));
function tileAt(room, x, y) {
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) return TILE.WALL;
return room[y * COLS + x];
}
function solidTile(t) { return SOLID.has(t); }
// ---------------------------------------------------------------------------
// Game state
// ---------------------------------------------------------------------------
const ITEMS = {
potion: { id: "potion", name: "Heart Tonic", kind: "consumable", desc: "Restore 4 health.", icon: "potion" },
bombs: { id: "bombs", name: "Blast Caps", kind: "consumable", desc: "Shatter cracked stone. Press B.", icon: "bomb" },
boots: { id: "boots", name: "Windstride Boots", kind: "gear", desc: "Move faster. Dash farther.", icon: "boots" },
shield: { id: "shield", name: "Iron Shield", kind: "gear", desc: "Halve incoming damage.", icon: "shield" },
compass: { id: "compass", name: "Seeker Compass", kind: "gear", desc: "Reveal secrets and unvisited rooms.", icon: "compass" },
lantern: { id: "lantern", name: "Sunwell Lantern", kind: "gear", desc: "Light the deep dark.", icon: "lantern" },
charm: { id: "charm", name: "Vitality Charm", kind: "gear", desc: "Slowly mend wounds out of combat.", icon: "charm" },
bow: { id: "bow", name: "Ashwood Bow", kind: "weapon", desc: "Ranged shots. Press K.", icon: "bow" }
};
const WEAPONS = {
wood: { id: "wood", name: "Ashen Stick", dmg: 1, cd: 0.42, range: 34, arc: 1.15, color: "#8b5a2b", glow: null },
iron: { id: "iron", name: "Riversteel", dmg: 2, cd: 0.30, range: 40, arc: 1.35, color: "#c5d0dc", glow: null },
ember: { id: "ember", name: "Emberbrand", dmg: 3, cd: 0.36, range: 44, arc: 1.5, color: "#ff6a2a", glow: "#ffaa33" }
};
function freshState() {
return {
mode: "play", // play, inventory, shop, talk, dead, win, pause
roomX: 2, roomY: 1,
player: {
x: 8 * TW, y: 8 * TH, vx: 0, vy: 0, dir: 0, // 0 down 1 left 2 right 3 up
hp: 6, maxHp: 6, hearts: 3,
stamina: 100, maxStamina: 100,
invuln: 0, flash: 0, anim: 0, attacking: 0, attackCd: 0,
bowCd: 0, dash: 0, dashCd: 0, hurtT: 0,
weapon: "wood",
facing: { x: 0, y: 1 }
},
gold: 0,
items: { potion: 1, bombs: 0, boots: false, shield: false, compass: false, lantern: false, charm: false, bow: false },
flags: {
heartMeadow: false, heartRuins: false,
ironGot: false, emberGot: false, bowGot: false,
lanternGot: false, compassGot: false, charmGot: false,
bossDead: false, intro: false,
cracked: {}, // "rx,ry,tx,ty"
chests: {},
kills: 0,
savedAt: null
},
visited: { "2,1": true },
talk: null,
shopSel: 0,
invSel: 0,
floaters: [],
projectiles: [],
bombs: [],
time: 0,
shake: 0,
msg: "",
msgT: 0
};
}
let S = freshState();
let entities = []; // rebuilt per room
function roomId() { return S.roomX + S.roomY * MAP_W; }
function roomTiles() { return ROOM_TILES[roomId()]; }
function crackedKey(tx, ty) { return `${S.roomX},${S.roomY},${tx},${ty}`; }
function currentTileSolid(tx, ty) {
// Out-of-bounds must be walkable so doorways can be crossed.
// (tileAt() treats OOB as WALL, which previously blocked transitions
// one pixel short of the room-change threshold.)
if (tx < 0 || ty < 0 || tx >= COLS || ty >= ROWS) return false;
if (S.flags.cracked[crackedKey(tx, ty)]) return false;
return solidTile(tileAt(roomTiles(), tx, ty));
}
function circleHitsSolid(x, y, r) {
const x0 = Math.floor((x - r) / TW), x1 = Math.floor((x + r) / TW);
const y0 = Math.floor((y - r) / TH), y1 = Math.floor((y + r) / TH);
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
if (!currentTileSolid(tx, ty)) continue;
const rx = clamp(x, tx * TW, tx * TW + TW);
const ry = clamp(y, ty * TH, ty * TH + TH);
if (Math.hypot(x - rx, y - ry) < r) return true;
}
return false;
}
function tryMove(ent, dx, dy, r) {
if (dx && !circleHitsSolid(ent.x + dx, ent.y, r)) ent.x += dx;
else if (dx) {
for (let i = 0; i < 4; i++) {
const s = dx * (1 - i * 0.25);
if (!circleHitsSolid(ent.x + s, ent.y, r)) { ent.x += s; break; }
}
}
if (dy && !circleHitsSolid(ent.x, ent.y + dy, r)) ent.y += dy;
else if (dy) {
for (let i = 0; i < 4; i++) {
const s = dy * (1 - i * 0.25);
if (!circleHitsSolid(ent.x, ent.y + s, r)) { ent.y += s; break; }
}
}
}
function worldX(px) { return MAP_OX + px; }
function worldY(py) { return MAP_OY + py; }
function floater(x, y, text, col) {
S.floaters.push({ x, y, text, col, t: 0.9 });
}
// ---------------------------------------------------------------------------
// Entities per room
// ---------------------------------------------------------------------------
function spawnRoom() {
entities = [];
S.projectiles = S.projectiles.filter(p => p.keep);
const id = roomId();
const chestTaken = (cid) => S.flags.chests[`${S.roomX},${S.roomY}:${cid}`];
const markChest = (cid) => { S.flags.chests[`${S.roomX},${S.roomY}:${cid}`] = true; };
const enemy = (kind, x, y, extra) => {
const proto = {
slime: { hp: 3, dmg: 1, spd: 38, r: 12, gold: [1, 3], xp: 1 },
wolf: { hp: 4, dmg: 1, spd: 70, r: 13, gold: [2, 5], xp: 2 },
bat: { hp: 2, dmg: 1, spd: 80, r: 10, gold: [1, 4], xp: 1 },
skel: { hp: 7, dmg: 2, spd: 48, r: 13, gold: [3, 7], xp: 3 },
mage: { hp: 6, dmg: 2, spd: 30, r: 12, gold: [4, 8], xp: 3 },
guard: { hp: 14, dmg: 2, spd: 42, r: 15, gold: [10, 16], xp: 8 },
boss: { hp: 36, dmg: 2, spd: 46, r: 20, gold: [40, 40], xp: 20 }
}[kind];
entities.push(Object.assign({
type: "enemy", kind, x: x * TW + TW / 2, y: y * TH + TH / 2,
hp: proto.hp, maxHp: proto.hp, dmg: proto.dmg, spd: proto.spd, r: proto.r,
gold: proto.gold, hurt: 0, t: Math.random() * 10, ai: 0, facing: 0
}, extra || {}));
};
const npc = (name, x, y, lines, role) => {
entities.push({ type: "npc", name, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 14, lines, role: role || "talk", i: 0 });
};
const chest = (cid, x, y, loot) => {
if (chestTaken(cid)) return;
entities.push({ type: "chest", cid, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 14, loot, open: false });
};
const shrine = (x, y) => {
entities.push({ type: "shrine", x: x * TW + TW / 2, y: y * TH + TH / 2, r: 18 });
};
const pickup = (kind, x, y, amt) => {
entities.push({ type: "pickup", kind, amt: amt || 1, x: x * TW + TW / 2, y: y * TH + TH / 2, r: 10, bob: Math.random() * 6 });
};
if (id === 7) { // village
npc("Elder Bram", 5, 4, [
"The temple on the far peak stirs. Embers crawl in its stone.",
"Start east, in the lea — a heart of the old kings is buried there.",
"West, the forest hides a lantern. Without light the hollows will swallow you.",
"When you can see in the dark, seek the hunter's bow beneath the mountain.",
"Two blades wait: one in the grove to the southwest, one in the forge of the peak.",
"Rest at any shrine. The world will remember you."
]);
npc("Mara the Peddler", 12, 4, [
"Coin spends cleaner than blood. Browse if you like."
], "shop");
npc("Tovin", 9, 8, [
"J to swing, L to dash, K to loose an arrow — once you find a bow.",
"Cracked walls hate Blast Caps. Buy some from Mara.",
"I opens your pack. E talks, opens, prays."
]);
shrine(8, 8);
}
if (id === 8) {
enemy("slime", 4, 4); enemy("slime", 11, 4); enemy("slime", 10, 7);
if (!S.flags.heartMeadow) chest("heart", 12, 3, "heart");
pickup("gold", 5, 8, 5);
}
if (id === 12) {
enemy("slime", 4, 5); enemy("wolf", 11, 6);
pickup("gold", 3, 3, 4);
}
if (id === 17) {
npc("Millie", 5, 4, [
"Wolves took a ram last night. The road south of the village isn't kind.",
"If you see my brother Tovin, tell him the stew's gone cold."
]);
pickup("potion", 12, 7);
}
if (id === 18) {
enemy("wolf", 5, 4); enemy("wolf", 11, 5); enemy("slime", 8, 7);
}
if (id === 6) {
enemy("wolf", 4, 4); enemy("wolf", 12, 6); enemy("bat", 8, 3);
if (!S.flags.lanternGot) chest("lantern", 8, 5, "lantern");
}
if (id === 5) {
enemy("bat", 4, 3); enemy("bat", 11, 4); enemy("wolf", 8, 7);
pickup("gold", 12, 8, 8);
}
if (id === 10) {
npc("Smith Kael", 8, 5, [
S.flags.ironGot ? "Riversteel suits you. The peak-forge still sleeps hotter metal." :
"Bring me proof you can stand a fight and I'll wake Riversteel for you.",
"Ten wolf-teeth... or just clear the grove of its guardian."
], "smith");
if (!S.flags.ironGot) enemy("guard", 4, 7);
pickup("gold", 12, 3, 6);
}
if (id === 11) {
enemy("bat", 3, 3); enemy("bat", 12, 3); enemy("slime", 12, 8);
pickup("potion", 3, 8);
}
if (id === 15) {
enemy("slime", 6, 3); enemy("slime", 9, 7); enemy("wolf", 11, 4);
if (!S.flags.compassGot) chest("compass", 3, 8, "compass");
}
if (id === 16) {
shrine(7, 4);
npc("Pilgrim", 10, 7, [
"Shrines remember. Kneel, and even death is only a door back.",
"I dreamed of five hearts beating as one. You wear three."
]);
}
if (id === 13) {
enemy("skel", 4, 4); enemy("wolf", 11, 4);
pickup("gold", 12, 8, 10);
}
if (id === 9) {
enemy("skel", 4, 6); enemy("skel", 11, 6); enemy("mage", 8, 3);
}
if (id === 14) {
enemy("skel", 4, 3); enemy("mage", 11, 3); enemy("skel", 11, 7);
if (!S.flags.heartRuins) chest("heart2", 8, 8, "heart");
}
if (id === 19) {
enemy("skel", 5, 5); enemy("mage", 11, 5);
if (!S.flags.charmGot) chest("charm", 3, 8, "charm");
}
if (id === 2) {
enemy("bat", 5, 4); enemy("bat", 11, 6); enemy("skel", 8, 8);
}
if (id === 1) {
shrine(7, 5);
if (!S.flags.bowGot && S.items.lantern) chest("bow", 12, 3, "bow");
else if (!S.flags.bowGot) {
entities.push({ type: "npc", name: "Sealed Niche", x: 12 * TW + TW / 2, y: 3 * TH + TH / 2, r: 14, role: "talk", lines: ["A hunter's bow waits in the dark. Light must touch it first."], i: 0 });
}
enemy("bat", 4, 3); enemy("skel", 4, 8);
}
if (id === 0) {
enemy("skel", 8, 5); enemy("mage", 11, 3);
pickup("gold", 5, 8, 12);
if (!S.items.shield) chest("shield", 3, 3, "shield");
}
if (id === 3) {
npc("Forge-Echo", 8, 4, [
S.flags.emberGot ? "It remembers fire. So will the temple." :
"Offer a worthy blade and a hunter's eye. Then the Emberbrand wakes."
], "forge");
enemy("mage", 4, 7); enemy("skel", 12, 7);
}
if (id === 4) {
if (!S.flags.bossDead) enemy("boss", 8, 4);
else pickup("gold", 8, 4, 0);
}
// cracked walls become bomb targets already in tiles
}
function giveLoot(loot) {
if (loot === "heart") {
if (S.player.hearts < 5) {
S.player.hearts++;
S.player.maxHp = S.player.hearts * 2;
S.player.hp = S.player.maxHp;
if (S.roomX === 3 && S.roomY === 1) S.flags.heartMeadow = true;
else S.flags.heartRuins = true;
announce("A Heart Vessel! Maximum health up.");
AudioFX.heal();
}
} else if (loot === "lantern") {
S.items.lantern = true; S.flags.lanternGot = true;
announce("Sunwell Lantern — the dark recoils.");
AudioFX.unlock();
} else if (loot === "compass") {
S.items.compass = true; S.flags.compassGot = true;
announce("Seeker Compass — secrets shiver.");
AudioFX.unlock();
} else if (loot === "charm") {
S.items.charm = true; S.flags.charmGot = true;
announce("Vitality Charm — wounds forget themselves.");
AudioFX.unlock();
} else if (loot === "bow") {
S.items.bow = true; S.flags.bowGot = true;
announce("Ashwood Bow — press K to loose.");
AudioFX.unlock();
} else if (loot === "shield") {
S.items.shield = true;
announce("Iron Shield — blows land softer.");
AudioFX.unlock();
} else if (loot === "gold") {
S.gold += 10; AudioFX.coin();
}
}
function announce(t) {
S.msg = t; S.msgT = 3.2;
toast(t, 2400);
}
// ---------------------------------------------------------------------------
// Shop
// ---------------------------------------------------------------------------
const SHOP = [
{ item: "potion", price: 12, qty: 1, name: "Heart Tonic", desc: "Restore 4 HP." },
{ item: "bombs", price: 18, qty: 3, name: "Blast Caps x3", desc: "Crack the world open." },
{ item: "boots", price: 40, qty: 0, name: "Windstride Boots", desc: "Swift feet, longer dash.", unique: true },
{ item: "shield", price: 45, qty: 0, name: "Iron Shield", desc: "Halve damage taken.", unique: true }
];
function buyIndex(i) {
const it = SHOP[i];
if (it.unique && S.items[it.item]) { announce("Already yours."); return; }
if (S.gold < it.price) { announce("Not enough coin."); AudioFX.hurt(); return; }
S.gold -= it.price;
if (it.item === "potion") S.items.potion += it.qty;
else if (it.item === "bombs") S.items.bombs += it.qty;
else S.items[it.item] = true;
AudioFX.shop();
burst(S.player.x, S.player.y - 10, 10, ["#f3c14a", "#fff3a0"], 60, 0.5, 3);
announce("Purchased " + it.name + ".");
}
// ---------------------------------------------------------------------------
// Combat
// ---------------------------------------------------------------------------
function weapon() { return WEAPONS[S.player.weapon]; }
function doSword() {
const p = S.player;
if (p.attackCd > 0 || p.attacking > 0) return;
const w = weapon();
p.attacking = 0.18;
p.attackCd = w.cd;
AudioFX.sword();
const fx = p.facing.x, fy = p.facing.y;
const ox = p.x + fx * 18, oy = p.y + fy * 14;
burst(ox + fx * 16, oy + fy * 16, 6, w.glow ? ["#ff6a2a", "#ffd166", "#fff"] : ["#e8d5a3", "#fff"], 80, 0.25, 2);
for (const e of entities) {
if (e.type !== "enemy" || e.hp <= 0) continue;
const dx = e.x - p.x, dy = e.y - p.y;
const d = Math.hypot(dx, dy);
if (d > w.range + e.r) continue;
const ang = Math.atan2(dy, dx);
const fang = Math.atan2(fy, fx);
let diff = Math.abs(ang - fang);
if (diff > Math.PI) diff = Math.PI * 2 - diff;
if (diff < w.arc) hitEnemy(e, w.dmg, Math.cos(ang) * 120, Math.sin(ang) * 120, w.id === "ember");
}
}
function shootBow() {
const p = S.player;
if (!S.items.bow) { announce("No bow yet."); return; }
if (p.bowCd > 0) return;
if (p.stamina < 12) { announce("Winded."); return; }
p.stamina -= 12;
p.bowCd = 0.38;
AudioFX.bow();
const fx = p.facing.x || 0, fy = p.facing.y || 1;
S.projectiles.push({
type: "arrow", x: p.x + fx * 16, y: p.y + fy * 10,
vx: fx * 280, vy: fy * 280, life: 1.1, dmg: 2, friendly: true
});
}
function placeBomb() {
if (S.items.bombs <= 0) { announce("No Blast Caps."); return; }
if (S.bombs.length >= 2) return;
S.items.bombs--;
S.bombs.push({ x: S.player.x, y: S.player.y, t: 1.05 });
AudioFX.beep(180, 0.05, "square", 0.04);
}
function explode(x, y) {
AudioFX.boom();
S.shake = 10;
burst(x, y, 28, ["#ffb347", "#ff6a2a", "#555", "#f3c14a"], 180, 0.55, 4);
for (const e of entities) {
if (e.type !== "enemy" || e.hp <= 0) continue;
if (Math.hypot(e.x - x, e.y - y) < 70) hitEnemy(e, 4, (e.x - x) * 4, (e.y - y) * 4, false);
}
if (Math.hypot(S.player.x - x, S.player.y - y) < 50) damagePlayer(1);
// crack tiles
const tx = Math.floor(x / TW), ty = Math.floor(y / TH);
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
const t = tileAt(roomTiles(), tx + ox, ty + oy);
if (t === TILE.CRACK) S.flags.cracked[crackedKey(tx + ox, ty + oy)] = true;
}
}
function hitEnemy(e, dmg, kx, ky, fire) {
e.hp -= dmg;
e.hurt = 0.18;
e.x += (kx || 0) * 0.04;
e.y += (ky || 0) * 0.04;
floater(e.x, e.y - 16, "−" + dmg, fire ? "#ff6a2a" : "#fff4d0");
AudioFX.hit();
burst(e.x, e.y, 8, fire ? ["#ff6a2a", "#ffd166"] : ["#c23b22", "#fff"], 90, 0.3, 2.4);
if (fire) {
e.burn = 2.2;
burst(e.x, e.y, 10, ["#ff6a2a", "#ffaa33"], 70, 0.4, 2);
}
if (e.hp <= 0) killEnemy(e);
}
function killEnemy(e) {
e.hp = 0;
S.flags.kills++;
const g = irand(e.gold[0], e.gold[1]);
if (g > 0) {
entities.push({ type: "pickup", kind: "gold", amt: g, x: e.x, y: e.y, r: 10, bob: 0 });
}
if (Math.random() < 0.12) {
entities.push({ type: "pickup", kind: "potion", amt: 1, x: e.x + 8, y: e.y, r: 10, bob: 1 });
}
burst(e.x, e.y, 16, ["#6b2a2a", "#c23b22", "#111"], 100, 0.45, 3);
AudioFX.death();
if (e.kind === "guard" && !S.flags.ironGot) {
S.flags.ironGot = true;
S.player.weapon = "iron";
announce("The grove yields Riversteel. Faster. Sharper.");
AudioFX.unlock();
}
if (e.kind === "boss") {
S.flags.bossDead = true;
S.mode = "win";
AudioFX.win();
burst(e.x, e.y, 40, ["#ff6a2a", "#f3c14a", "#fff"], 200, 1.2, 5);
}
}
function damagePlayer(n) {
const p = S.player;
if (p.invuln > 0 || p.hp <= 0) return;
if (S.items.shield) n = Math.max(1, Math.ceil(n * 0.5));
p.hp -= n;
p.invuln = 0.85;
p.flash = 0.2;
p.hurtT = 0.25;
S.shake = 8;
floater(p.x, p.y - 18, "−" + n, "#ff6b6b");
AudioFX.hurt();
burst(p.x, p.y, 12, ["#c23b22", "#ffb3b3"], 100, 0.35, 2.5);
if (p.hp <= 0) {
p.hp = 0;
S.mode = "dead";
AudioFX.death();
}
}
function healPlayer(n) {
const p = S.player;
const before = p.hp;
p.hp = Math.min(p.maxHp, p.hp + n);
if (p.hp > before) {
AudioFX.heal();
burst(p.x, p.y, 10, ["#7dcea0", "#fff"], 50, 0.5, 3);
floater(p.x, p.y - 16, "+" + (p.hp - before), "#7dcea0");
}
}
function usePotion() {
if (S.items.potion <= 0) { announce("No tonics."); return; }
if (S.player.hp >= S.player.maxHp) { announce("Already hale."); return; }
S.items.potion--;
healPlayer(4);
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
function serialize() {
return JSON.stringify({
roomX: S.roomX, roomY: S.roomY,
px: S.player.x, py: S.player.y, dir: S.player.dir,
hp: S.player.hp, maxHp: S.player.maxHp, hearts: S.player.hearts,
weapon: S.player.weapon, gold: S.gold, items: S.items, flags: S.flags,
visited: S.visited
});
}
function applySave(d) {
S = freshState();
S.roomX = d.roomX; S.roomY = d.roomY;
S.player.x = d.px; S.player.y = d.py; S.player.dir = d.dir;
S.player.hp = d.hp; S.player.maxHp = d.maxHp; S.player.hearts = d.hearts;
S.player.weapon = d.weapon; S.gold = d.gold; S.items = d.items; S.flags = d.flags;
S.visited = d.visited;
const dirs = [{x:0,y:1},{x:-1,y:0},{x:1,y:0},{x:0,y:-1}];
S.player.facing = dirs[S.player.dir] || dirs[0];
spawnRoom();
}
function saveGame(silent) {
try { localStorage.setItem(SAVE_KEY, serialize()); } catch (e) {}
S.flags.savedAt = `${S.roomX},${S.roomY}`;
if (!silent) {
announce("The shrine remembers.");
AudioFX.save();
burst(S.player.x, S.player.y, 16, ["#9ad1ff", "#fff"], 40, 0.8, 3);
}
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
applySave(JSON.parse(raw));
return true;
} catch (e) { return false; }
}
function hasSave() {
try { return !!localStorage.getItem(SAVE_KEY); } catch (e) { return false; }
}
// ---------------------------------------------------------------------------
// Interaction
// ---------------------------------------------------------------------------
function interact() {
const p = S.player;
let best = null, bd = 36;
for (const e of entities) {
if (e.type === "enemy") continue;
const d = Math.hypot(e.x - p.x, e.y - p.y);
if (d < bd + (e.r || 10)) { bd = d; best = e; }
}
if (!best) return;
if (best.type === "npc") {
if (best.role === "shop") { S.mode = "shop"; S.shopSel = 0; AudioFX.shop(); return; }
if (best.role === "smith") {
if (!S.flags.ironGot) {
const groveClear = !entities.some(e => e.type === "enemy" && e.hp > 0);
if (groveClear || S.flags.kills >= 8) {
S.flags.ironGot = true;
S.player.weapon = "iron";
announce("Kael lays Riversteel in your hands.");
AudioFX.unlock();
return;
}
}
}
if (best.role === "forge") {
if (!S.flags.emberGot && S.flags.ironGot && S.items.bow) {
S.flags.emberGot = true;
S.player.weapon = "ember";
announce("The peak-forge sings. Emberbrand is yours.");
AudioFX.unlock();
burst(p.x, p.y, 24, ["#ff6a2a", "#ffd166"], 120, 0.8, 4);
return;
}
}
S.mode = "talk";
S.talk = { name: best.name, lines: best.lines, i: best.i || 0, ent: best };
return;
}
if (best.type === "chest" && !best.open) {
best.open = true;
S.flags.chests[`${S.roomX},${S.roomY}:${best.cid}`] = true;
giveLoot(best.loot);
burst(best.x, best.y, 14, ["#f3c14a", "#fff3a0"], 70, 0.5, 3);
AudioFX.pickup();
return;
}
if (best.type === "shrine") {
healPlayer(99);
saveGame(false);
}
}
function collectPickup(e) {
if (e.kind === "gold") {
S.gold += e.amt; AudioFX.coin();
floater(e.x, e.y, "+" + e.amt + "g", "#f3c14a");
} else if (e.kind === "potion") {
S.items.potion += e.amt; AudioFX.pickup();
announce("Heart Tonic acquired.");
}
burst(e.x, e.y, 8, ["#f3c14a", "#fff"], 50, 0.35, 2);
e.dead = true;
}
// ---------------------------------------------------------------------------
// Room transitions
// ---------------------------------------------------------------------------
function changeRoom(dx, dy) {
const nx = S.roomX + dx, ny = S.roomY + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) return;
S.roomX = nx; S.roomY = ny;
S.visited[`${nx},${ny}`] = true;
if (dx > 0) S.player.x = 28;
if (dx < 0) S.player.x = VIEW_W - 28;
if (dy > 0) S.player.y = 28;
if (dy < 0) S.player.y = VIEW_H - 28;
S.projectiles = [];
S.bombs = [];
spawnRoom();
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
function update(dt) {
S.time += dt;
if (S.shake > 0) S.shake = Math.max(0, S.shake - dt * 28);
if (S.msgT > 0) S.msgT -= dt;
if (S.mode === "dead" || S.mode === "win") {
if (pressed("KeyR") || pressed("Enter") || pressed("Space")) {
if (S.mode === "dead") {
if (hasSave() && loadGame()) {
S.mode = "play";
S.player.hp = Math.max(2, S.player.hp);
announce("You wake at the last shrine.");
} else {
S = freshState();
spawnRoom();
S.mode = "play";
}
} else {
overlay.classList.remove("hidden");
titlePanel.classList.remove("hidden");
if (hasSave()) btnCont.classList.remove("hidden");
S.mode = "title";
}
}
flushJust();
return;
}
if (S.mode === "talk") {
if (pressed("KeyE") || pressed("Space") || pressed("Enter") || pressed("KeyJ")) {
S.talk.i++;
if (S.talk.ent) S.talk.ent.i = S.talk.i;
if (S.talk.i >= S.talk.lines.length) { S.mode = "play"; S.talk = null; }
}
if (pressed("Escape")) { S.mode = "play"; S.talk = null; }
flushJust();
return;
}
if (S.mode === "shop") {
if (pressed("ArrowUp") || pressed("KeyW")) S.shopSel = (S.shopSel + SHOP.length - 1) % SHOP.length;
if (pressed("ArrowDown") || pressed("KeyS")) S.shopSel = (S.shopSel + 1) % SHOP.length;
if (pressed("Enter") || pressed("KeyE") || pressed("Space") || pressed("KeyJ")) buyIndex(S.shopSel);
if (pressed("Escape") || pressed("KeyI")) S.mode = "play";
flushJust();
return;
}
if (S.mode === "inventory") {
if (pressed("KeyI") || pressed("Escape")) S.mode = "play";
const slots = invSlots();
if (pressed("ArrowLeft") || pressed("KeyA")) S.invSel = (S.invSel + slots.length - 1) % Math.max(1, slots.length);
if (pressed("ArrowRight") || pressed("KeyD")) S.invSel = (S.invSel + 1) % Math.max(1, slots.length);
if (pressed("Enter") || pressed("Space") || pressed("KeyE") || pressed("KeyJ")) {
const sl = slots[S.invSel];
if (sl && sl.id === "potion") usePotion();
}
flushJust();
return;
}
if (S.mode === "pause") {
if (pressed("Escape") || pressed("KeyP")) S.mode = "play";
flushJust();
return;
}
if (S.mode !== "play") { flushJust(); return; }
if (pressed("KeyI")) { S.mode = "inventory"; S.invSel = 0; flushJust(); return; }
if (pressed("Escape") || pressed("KeyP")) { S.mode = "pause"; flushJust(); return; }
if (pressed("KeyH")) usePotion();
if (pressed("KeyB")) placeBomb();
if (pressed("KeyE")) interact();
if (pressed("KeyJ") || pressed("Space")) doSword();
if (pressed("KeyK")) shootBow();
const p = S.player;
p.attackCd = Math.max(0, p.attackCd - dt);
p.bowCd = Math.max(0, p.bowCd - dt);
p.dashCd = Math.max(0, p.dashCd - dt);
p.invuln = Math.max(0, p.invuln - dt);
p.flash = Math.max(0, p.flash - dt);
p.hurtT = Math.max(0, p.hurtT - dt);
p.attacking = Math.max(0, p.attacking - dt);
p.dash = Math.max(0, p.dash - dt);
let ix = 0, iy = 0;
if (down("KeyA") || down("ArrowLeft")) ix -= 1;
if (down("KeyD") || down("ArrowRight")) ix += 1;
if (down("KeyW") || down("ArrowUp")) iy -= 1;
if (down("KeyS") || down("ArrowDown")) iy += 1;
if (ix || iy) {
const l = Math.hypot(ix, iy);
ix /= l; iy /= l;
p.facing = { x: ix, y: iy };
if (Math.abs(ix) > Math.abs(iy)) p.dir = ix < 0 ? 1 : 2;
else p.dir = iy < 0 ? 3 : 0;
}
if (pressed("KeyL") && p.dashCd <= 0 && p.stamina >= 22) {
p.dash = 0.16;
p.dashCd = 0.55;
p.stamina -= 22;
p.invuln = Math.max(p.invuln, 0.16);
AudioFX.noise(0.08, 0.05, 600);
burst(p.x, p.y, 8, ["#cfe8ff", "#fff"], 70, 0.25, 2);
}
const tired = p.stamina < 18;
let spd = 118;
if (S.items.boots) spd = 148;
if (tired) spd *= 0.72;
if (p.attacking > 0) spd *= 0.45;
if (p.dash > 0) spd = S.items.boots ? 340 : 280;
if (ix || iy) {
tryMove(p, ix * spd * dt, iy * spd * dt, 11);
p.anim += dt * (tired ? 6 : 10);
if (p.dash <= 0) AudioFX.walk();
p.stamina = Math.max(0, p.stamina - dt * (S.items.boots ? 2 : 4));
} else {
p.anim *= 0.9;
p.stamina = Math.min(p.maxStamina, p.stamina + dt * 28);
}
// charm regen
if (S.items.charm && p.hp < p.maxHp) {
p._regen = (p._regen || 0) + dt;
if (p._regen > 6) { p._regen = 0; healPlayer(1); }
}
// lava
const tStanding = tileAt(roomTiles(), Math.floor(p.x / TW), Math.floor(p.y / TH));
if (tStanding === TILE.LAVA) damagePlayer(1);
// room edges — trigger while still inside the 2-tile doorway
if (p.x < 22) changeRoom(-1, 0);
else if (p.x > VIEW_W - 22) changeRoom(1, 0);
else if (p.y < 22) changeRoom(0, -1);
else if (p.y > VIEW_H - 22) changeRoom(0, 1);
p.x = clamp(p.x, 8, VIEW_W - 8);
p.y = clamp(p.y, 8, VIEW_H - 8);
// pickups
for (const e of entities) {
if (e.type === "pickup" && !e.dead && Math.hypot(e.x - p.x, e.y - p.y) < 18) collectPickup(e);
}
entities = entities.filter(e => !e.dead && !(e.type === "enemy" && e.hp <= 0));
// bombs
for (let i = S.bombs.length - 1; i >= 0; i--) {
S.bombs[i].t -= dt;
if (S.bombs[i].t <= 0) { explode(S.bombs[i].x, S.bombs[i].y); S.bombs.splice(i, 1); }
}
// projectiles
for (let i = S.projectiles.length - 1; i >= 0; i--) {
const pr = S.projectiles[i];
pr.x += pr.vx * dt; pr.y += pr.vy * dt; pr.life -= dt;
if (pr.life <= 0 || pr.x < -20 || pr.y < -20 || pr.x > VIEW_W + 20 || pr.y > VIEW_H + 20) {
S.projectiles.splice(i, 1); continue;
}
const tx = Math.floor(pr.x / TW), ty = Math.floor(pr.y / TH);
if (currentTileSolid(tx, ty)) { S.projectiles.splice(i, 1); continue; }
if (pr.friendly) {
let hit = false;
for (const e of entities) {
if (e.type !== "enemy" || e.hp <= 0) continue;
if (Math.hypot(e.x - pr.x, e.y - pr.y) < e.r + 6) {
hitEnemy(e, pr.dmg, pr.vx, pr.vy, false);
hit = true; break;
}
}
if (hit) S.projectiles.splice(i, 1);
} else {
if (Math.hypot(p.x - pr.x, p.y - pr.y) < 12) {
damagePlayer(pr.dmg);
S.projectiles.splice(i, 1);
}
}
}
// enemies
for (const e of entities) {
if (e.type !== "enemy") continue;
e.t += dt;
e.hurt = Math.max(0, e.hurt - dt);
if (e.burn > 0) {
e.burn -= dt;
if (Math.floor(e.t * 3) !== Math.floor((e.t - dt) * 3)) {
e.hp -= 1;
floater(e.x, e.y - 12, "−1", "#ff6a2a");
spark(e.x, e.y, rand(-20, 20), rand(-40, -10), "#ff6a2a");
if (e.hp <= 0) killEnemy(e);
}
}
if (e.hp <= 0) continue;
const dx = p.x - e.x, dy = p.y - e.y;
const d = Math.hypot(dx, dy) || 1;
const darkBlind = ROOM_META[roomId()].dark && !S.items.lantern && d > 70;
if (e.kind === "slime") {
if (e.t % 1.4 < 0.35 && !darkBlind) tryMove(e, (dx / d) * e.spd * dt, (dy / d) * e.spd * dt, e.r);
} else if (e.kind === "wolf" || e.kind === "skel" || e.kind === "guard") {
if (!darkBlind) tryMove(e, (dx / d) * e.spd * dt, (dy / d) * e.spd * dt, e.r);
} else if (e.kind === "bat") {
const ox = Math.cos(e.t * 3) * 30;
const oy = Math.sin(e.t * 2.2) * 24;
const tx = darkBlind ? 0 : dx + ox, ty = darkBlind ? 0 : dy + oy;
const tl = Math.hypot(tx, ty) || 1;
tryMove(e, (tx / tl) * e.spd * dt, (ty / tl) * e.spd * dt, e.r);
} else if (e.kind === "mage") {
if (d < 90) tryMove(e, -(dx / d) * e.spd * dt, -(dy / d) * e.spd * dt, e.r);
else if (!darkBlind) tryMove(e, (dx / d) * e.spd * 0.4 * dt, (dy / d) * e.spd * 0.4 * dt, e.r);
e.ai += dt;
if (e.ai > 1.6 && d < 260 && !darkBlind) {
e.ai = 0;
S.projectiles.push({
type: "bolt", x: e.x, y: e.y, vx: (dx / d) * 140, vy: (dy / d) * 140,
life: 2.2, dmg: 2, friendly: false
});
AudioFX.beep(240, 0.08, "sawtooth", 0.04, 80);
}
} else if (e.kind === "boss") {
const phase = e.hp < e.maxHp * 0.45 ? 2 : 1;
e.ai += dt;
const ang = Math.atan2(dy, dx);
if (phase === 1) {
tryMove(e, Math.cos(ang) * e.spd * dt, Math.sin(ang) * e.spd * dt, e.r);
if (e.ai > 1.4) {
e.ai = 0;
for (let k = 0; k < 4; k++) {
const a = ang + (k - 1.5) * 0.25;
S.projectiles.push({
type: "bolt", x: e.x, y: e.y, vx: Math.cos(a) * 160, vy: Math.sin(a) * 160,
life: 2, dmg: 2, friendly: false
});
}
AudioFX.beep(160, 0.12, "sawtooth", 0.06, 60);
}
} else {
tryMove(e, Math.cos(ang + Math.sin(e.t * 2)) * e.spd * 1.3 * dt,
Math.sin(ang + Math.cos(e.t * 2)) * e.spd * 1.3 * dt, e.r);
if (e.ai > 0.9) {
e.ai = 0;
for (let k = 0; k < 8; k++) {
const a = e.t + k * Math.PI / 4;
S.projectiles.push({
type: "bolt", x: e.x, y: e.y, vx: Math.cos(a) * 130, vy: Math.sin(a) * 130,
life: 2.4, dmg: 2, friendly: false
});
}
}
}
}
if (Math.hypot(e.x - p.x, e.y - p.y) < e.r + 11 && p.invuln <= 0) {
damagePlayer(e.dmg);
const kx = (p.x - e.x), ky = (p.y - e.y), kl = Math.hypot(kx, ky) || 1;
tryMove(p, (kx / kl) * 18, (ky / kl) * 18, 11);
}
}
tickParticles(dt);
for (let i = S.floaters.length - 1; i >= 0; i--) {
S.floaters[i].t -= dt;
S.floaters[i].y -= 22 * dt;
if (S.floaters[i].t <= 0) S.floaters.splice(i, 1);
}
flushJust();
}
function invSlots() {
const s = [];
s.push({ id: "potion", count: S.items.potion });
s.push({ id: "bombs", count: S.items.bombs });
if (S.items.boots) s.push({ id: "boots" });
if (S.items.shield) s.push({ id: "shield" });
if (S.items.compass) s.push({ id: "compass" });
if (S.items.lantern) s.push({ id: "lantern" });
if (S.items.charm) s.push({ id: "charm" });
if (S.items.bow) s.push({ id: "bow" });
return s;
}
// ---------------------------------------------------------------------------
// Draw
// ---------------------------------------------------------------------------
function tileColor(t, x, y) {
const h = ((x * 13 + y * 7) % 5);
switch (t) {
case TILE.GRASS: return h < 2 ? "#3d6b3a" : "#355e34";
case TILE.DIRT: return "#6b4f32";
case TILE.WATER: return h < 2 ? "#2a6a8a" : "#245e7c";
case TILE.TREE: return "#1e3d22";
case TILE.STONE: return "#5a5856";
case TILE.CAVE: return "#2b2624";
case TILE.SAND: return "#c2a66a";
case TILE.CRACK: return "#6a6258";
case TILE.TALL: return "#2f552c";
case TILE.LAVA: return h < 2 ? "#c44512" : "#e05a18";
case TILE.WOOD: return "#7a5330";
case TILE.FLOWER: return "#3d6b3a";
case TILE.FENCE: return "#5a3c22";
case TILE.DARK: return "#3a3836";
case TILE.SHRINE: return "#6e7a88";
case TILE.CARPET: return "#7a2e2e";
case TILE.WALL: return "#3e3a38";
case TILE.RUIN: return "#6a645c";
case TILE.SNOW: return "#d5dde4";
case TILE.ICE: return "#a8c8d4";
case TILE.PILLAR: return "#4a4644";
case TILE.HEARTH: return "#8a3a18";
case TILE.PATH: return "#8a7348";
default: return "#355e34";
}
}
function drawTile(t, sx, sy, tx, ty) {
if (S.flags.cracked[`${S.roomX},${S.roomY},${tx},${ty}`] && t === TILE.CRACK) t = TILE.STONE;
ctx.fillStyle = tileColor(t, tx, ty);
ctx.fillRect(sx, sy, TW, TH);
if (t === TILE.GRASS || t === TILE.FLOWER || t === TILE.TALL) {
ctx.fillStyle = "rgba(0,0,0,0.08)";
ctx.fillRect(sx, sy + TH - 6, TW, 6);
}
if (t === TILE.TREE) {
ctx.fillStyle = "#1a301c";
ctx.fillRect(sx, sy, TW, TH);
ctx.fillStyle = "#2d6b34";
ctx.beginPath(); ctx.arc(sx + 24, sy + 20, 18, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#3e8a44";
ctx.beginPath(); ctx.arc(sx + 16, sy + 16, 10, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#5a3a20";
ctx.fillRect(sx + 20, sy + 30, 8, 16);
}
if (t === TILE.WATER) {
ctx.fillStyle = "rgba(180,230,255,0.18)";
const w = (Math.sin(S.time * 2 + tx + ty) + 1) * 8;
ctx.fillRect(sx + 8, sy + 16 + w * 0.2, 20, 4);
}
if (t === TILE.FLOWER) {
ctx.fillStyle = (tx + ty) % 2 ? "#e07a9a" : "#f3c14a";
ctx.beginPath(); ctx.arc(sx + 22, sy + 20, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#2f552c";
ctx.fillRect(sx + 21, sy + 24, 3, 8);
}
if (t === TILE.TALL) {
ctx.strokeStyle = "#245028"; ctx.lineWidth = 2;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(sx + 10 + i * 9, sy + 40);
ctx.quadraticCurveTo(sx + 12 + i * 9, sy + 20, sx + 8 + i * 9 + Math.sin(S.time * 2 + i) * 3, sy + 10);
ctx.stroke();
}
}
if (t === TILE.WALL || t === TILE.CAVE || t === TILE.PILLAR) {
ctx.fillStyle = "rgba(0,0,0,0.25)";
ctx.fillRect(sx, sy + TH - 10, TW, 10);
ctx.fillStyle = "rgba(255,255,255,0.05)";
ctx.fillRect(sx + 4, sy + 4, TW - 8, 8);
}
if (t === TILE.PILLAR) {
ctx.fillStyle = "#6a6662";
ctx.fillRect(sx + 12, sy + 4, 24, 40);
ctx.fillStyle = "#8a8680";
ctx.fillRect(sx + 8, sy + 2, 32, 8);
}
if (t === TILE.HEARTH) {
ctx.fillStyle = `hsl(${18 + Math.sin(S.time * 8 + tx) * 12},80%,45%)`;
ctx.beginPath(); ctx.arc(sx + 24, sy + 28, 10 + Math.sin(S.time * 10) * 2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#ffd166";
ctx.beginPath(); ctx.arc(sx + 24, sy + 26, 4, 0, Math.PI * 2); ctx.fill();
}
if (t === TILE.SHRINE) {
ctx.fillStyle = "#cfd8e0";
ctx.fillRect(sx + 10, sy + 22, 28, 18);
ctx.fillStyle = "#9ad1ff";
ctx.globalAlpha = 0.55 + Math.sin(S.time * 3) * 0.2;
ctx.beginPath(); ctx.arc(sx + 24, sy + 16, 8, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
if (t === TILE.LAVA) {
ctx.fillStyle = "#ffd166";
ctx.globalAlpha = 0.35 + Math.sin(S.time * 6 + tx * 2) * 0.15;
ctx.fillRect(sx + 8, sy + 16, 12, 6);
ctx.globalAlpha = 1;
}
if (t === TILE.CRACK) {
ctx.fillStyle = "#3e3830";
ctx.fillRect(sx + 2, sy + 2, TW - 4, TH - 4);
ctx.strokeStyle = "#c9a227"; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx + 10, sy + 8); ctx.lineTo(sx + 22, sy + 26); ctx.lineTo(sx + 16, sy + 40);
ctx.moveTo(sx + 32, sy + 10); ctx.lineTo(sx + 28, sy + 30);
ctx.stroke();
}
if (t === TILE.CARPET) {
ctx.fillStyle = "#9a3a3a";
ctx.fillRect(sx + 4, sy + 4, TW - 8, TH - 8);
ctx.fillStyle = "#f3c14a";
ctx.fillRect(sx + 20, sy + 6, 6, TH - 12);
}
if (t === TILE.WOOD) {
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.beginPath(); ctx.moveTo(sx, sy + 24); ctx.lineTo(sx + TW, sy + 24); ctx.stroke();
}
if (t === TILE.FENCE) {
ctx.fillStyle = "#8a6238";
ctx.fillRect(sx + 8, sy + 16, 6, 28);
ctx.fillRect(sx + 32, sy + 16, 6, 28);
ctx.fillRect(sx + 6, sy + 20, 36, 6);
}
}
function drawWeapon(p, wx, wy) {
const w = weapon();
const swing = p.attacking > 0 ? (1 - p.attacking / 0.18) : 0;
ctx.save();
ctx.translate(wx, wy);
let ang = 0;
if (p.dir === 0) ang = 1.2;
if (p.dir === 1) ang = Math.PI + 0.2;
if (p.dir === 2) ang = -0.2;
if (p.dir === 3) ang = -1.4;
ang += swing * (p.dir === 1 || p.dir === 3 ? -2.2 : 2.2);
ctx.rotate(ang);
if (w.glow) {
ctx.shadowColor = w.glow; ctx.shadowBlur = 12;
}
ctx.fillStyle = w.color;
ctx.fillRect(6, -3, 26, 6);
ctx.fillStyle = "#3a2a18";
ctx.fillRect(0, -4, 8, 8);
if (w.id === "ember") {
ctx.fillStyle = "#ffd166";
ctx.fillRect(24, -2, 10, 4);
}
if (w.id === "iron") {
ctx.fillStyle = "#8aa0b4";
ctx.fillRect(28, -5, 6, 10);
}
ctx.restore();
}
function drawPlayer() {
const p = S.player;
const x = worldX(p.x), y = worldY(p.y);
const bob = (p.dash > 0) ? 0 : Math.sin(p.anim) * (S.player.stamina < 18 ? 1.2 : 2.4);
const tired = p.stamina < 18;
// shadow
ctx.fillStyle = "rgba(0,0,0,0.3)";
ctx.beginPath(); ctx.ellipse(x, y + 14, 11, 5, 0, 0, Math.PI * 2); ctx.fill();
ctx.save();
if (p.flash > 0 && Math.floor(S.time * 20) % 2 === 0) ctx.globalAlpha = 0.35;
if (p.hurtT > 0) ctx.translate(Math.sin(S.time * 50) * 2, 0);
const cloak = p.weapon === "ember" ? "#8a2210" : p.weapon === "iron" ? "#2c3a4a" : "#3a4a32";
const skin = "#e6b48a";
const bootC = S.items.boots ? "#c9a227" : "#3a2a18";
// legs
ctx.fillStyle = bootC;
const step = Math.sin(p.anim) * (tired ? 3 : 5);
ctx.fillRect(x - 7, y + 6 + bob, 5, 9);
ctx.fillRect(x + 2, y + 6 + bob - step * 0.3, 5, 9);
// body
ctx.fillStyle = cloak;
ctx.fillRect(x - 9, y - 10 + bob, 18, 18);
// belt
ctx.fillStyle = "#c9a227";
ctx.fillRect(x - 9, y + 4 + bob, 18, 3);
// shield
if (S.items.shield && p.dir !== 2) {
ctx.fillStyle = "#8a929a";
ctx.beginPath();
ctx.ellipse(x - 12, y - 2 + bob, 6, 10, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#d4dce4"; ctx.lineWidth = 1.5;
ctx.stroke();
}
// head
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(x, y - 16 + bob, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#2a1c10";
ctx.beginPath(); ctx.arc(x, y - 18 + bob, 8, Math.PI, 0); ctx.fill();
// eyes
ctx.fillStyle = "#1a120c";
if (p.dir !== 3) {
const ex = p.dir === 1 ? -3 : p.dir === 2 ? 3 : 0;
ctx.fillRect(x - 3 + ex, y - 17 + bob, 2, 2);
ctx.fillRect(x + 2 + ex, y - 17 + bob, 2, 2);
}
// lantern
if (S.items.lantern) {
ctx.fillStyle = "#c9a227";
ctx.fillRect(x + 10, y - 4 + bob, 5, 8);
ctx.fillStyle = "rgba(255,220,120,0.7)";
ctx.beginPath(); ctx.arc(x + 12, y - 6 + bob, 4 + Math.sin(S.time * 6), 0, Math.PI * 2); ctx.fill();
}
// bow on back if owned and not attacking with it
if (S.items.bow && p.dir === 0) {
ctx.strokeStyle = "#6b3a18"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x + 10, y - 4 + bob, 10, -1, 1.2); ctx.stroke();
}
if (p.dir !== 3) drawWeapon(p, x + (p.dir === 1 ? -6 : 6), y - 2 + bob);
else drawWeapon(p, x + 8, y - 8 + bob);
if (S.items.shield && p.dir === 2) {
ctx.fillStyle = "#8a929a";
ctx.beginPath();
ctx.ellipse(x + 12, y - 2 + bob, 6, 10, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// sword arc
if (p.attacking > 0) {
const w = weapon();
ctx.save();
ctx.translate(x, y);
const fang = Math.atan2(p.facing.y, p.facing.x);
ctx.rotate(fang);
ctx.globalAlpha = p.attacking / 0.18 * 0.45;
ctx.strokeStyle = w.glow || "#fff4d0";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(0, 0, w.range - 4, -w.arc, w.arc);
ctx.stroke();
ctx.restore();
}
}
function drawEnemy(e) {
const x = worldX(e.x), y = worldY(e.y);
ctx.save();
if (e.hurt > 0) ctx.globalAlpha = 0.55;
ctx.fillStyle = "rgba(0,0,0,0.28)";
ctx.beginPath(); ctx.ellipse(x, y + e.r, e.r * 0.8, 4, 0, 0, Math.PI * 2); ctx.fill();
if (e.kind === "slime") {
const sq = 1 + Math.sin(e.t * 6) * 0.08;
ctx.fillStyle = "#5bbd4a";
ctx.beginPath(); ctx.ellipse(x, y, 14, 11 * sq, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#163818";
ctx.fillRect(x - 5, y - 4, 3, 4); ctx.fillRect(x + 3, y - 4, 3, 4);
} else if (e.kind === "wolf") {
ctx.fillStyle = "#6a5344";
ctx.fillRect(x - 14, y - 8, 28, 14);
ctx.fillRect(x + 8, y - 14, 12, 10);
ctx.fillStyle = "#1a120c";
ctx.fillRect(x + 16, y - 11, 3, 3);
ctx.fillStyle = "#4a3a30";
ctx.fillRect(x - 14, y + 4, 5, 8); ctx.fillRect(x + 4, y + 4, 5, 8);
} else if (e.kind === "bat") {
ctx.fillStyle = "#4a3060";
const flap = Math.sin(e.t * 12) * 8;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - 18, y - 4 - flap);
ctx.lineTo(x - 8, y + 2);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 18, y - 4 - flap);
ctx.lineTo(x + 8, y + 2);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#2a1838";
ctx.beginPath(); ctx.arc(x, y, 6, 0, Math.PI * 2); ctx.fill();
} else if (e.kind === "skel") {
ctx.fillStyle = "#e8dcc8";
ctx.fillRect(x - 8, y - 8, 16, 18);
ctx.beginPath(); ctx.arc(x, y - 14, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#1a120c";
ctx.fillRect(x - 4, y - 16, 3, 3); ctx.fillRect(x + 2, y - 16, 3, 3);
ctx.fillStyle = "#8a929a";
ctx.fillRect(x + 8, y - 4, 14, 3);
} else if (e.kind === "mage") {
ctx.fillStyle = "#3a2870";
ctx.beginPath();
ctx.moveTo(x, y + 14); ctx.lineTo(x - 14, y + 14); ctx.lineTo(x, y - 16); ctx.lineTo(x + 14, y + 14);
ctx.fill();
ctx.fillStyle = "#e6b48a";
ctx.beginPath(); ctx.arc(x, y - 14, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#221848";
ctx.beginPath(); ctx.moveTo(x - 8, y - 16); ctx.lineTo(x, y - 28); ctx.lineTo(x + 8, y - 16); ctx.fill();
} else if (e.kind === "guard") {
ctx.fillStyle = "#4a5a38";
ctx.fillRect(x - 12, y - 10, 24, 22);
ctx.fillStyle = "#8a929a";
ctx.fillRect(x - 16, y - 8, 6, 18);
ctx.fillStyle = "#e6b48a";
ctx.beginPath(); ctx.arc(x, y - 16, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#3a2a10";
ctx.fillRect(x - 8, y - 24, 16, 6);
} else if (e.kind === "boss") {
const pulse = 1 + Math.sin(e.t * 4) * 0.04;
ctx.fillStyle = "#4a1010";
ctx.beginPath(); ctx.ellipse(x, y, 22 * pulse, 26 * pulse, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#ff6a2a";
ctx.beginPath(); ctx.arc(x - 8, y - 10, 4, 0, Math.PI * 2); ctx.arc(x + 8, y - 10, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#1a0808";
ctx.fillRect(x - 8, y + 2, 16, 4);
ctx.fillStyle = "#c9a227";
ctx.fillRect(x - 18, y - 20, 36, 6);
// hp bar
const bw = 64, bh = 6;
ctx.fillStyle = "#1a0808";
ctx.fillRect(x - bw / 2, y - 42, bw, bh);
ctx.fillStyle = "#c23b22";
ctx.fillRect(x - bw / 2, y - 42, bw * (e.hp / e.maxHp), bh);
}
if (e.burn > 0) {
ctx.fillStyle = "rgba(255,100,20,0.5)";
ctx.beginPath(); ctx.arc(x, y - 8, 10 + Math.sin(S.time * 10) * 2, 0, Math.PI * 2); ctx.fill();
}
if (e.kind !== "boss" && e.hp < e.maxHp) {
ctx.fillStyle = "#1a0808";
ctx.fillRect(x - 12, y - e.r - 12, 24, 4);
ctx.fillStyle = "#c23b22";
ctx.fillRect(x - 12, y - e.r - 12, 24 * (e.hp / e.maxHp), 4);
}
ctx.restore();
}
function drawEntity(e) {
const x = worldX(e.x), y = worldY(e.y);
if (e.type === "enemy") { drawEnemy(e); return; }
if (e.type === "npc") {
ctx.fillStyle = "rgba(0,0,0,0.28)";
ctx.beginPath(); ctx.ellipse(x, y + 12, 10, 4, 0, 0, Math.PI * 2); ctx.fill();
const robe = e.role === "shop" ? "#6a3a78" : e.role === "smith" ? "#6a4a28" : e.role === "forge" ? "#8a3010" : "#3a4a68";
ctx.fillStyle = robe;
ctx.fillRect(x - 8, y - 8, 16, 20);
ctx.fillStyle = "#e6b48a";
ctx.beginPath(); ctx.arc(x, y - 14, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 12px serif";
ctx.textAlign = "center";
ctx.fillText("▾", x, y - 26 + Math.sin(S.time * 3) * 2);
if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 40) {
ctx.fillStyle = "#f0e6c8";
ctx.font = "12px serif";
ctx.fillText(e.name, x, y + 26);
}
}
if (e.type === "chest") {
ctx.fillStyle = e.open ? "#4a3a20" : "#c9a227";
ctx.fillRect(x - 12, y - 8, 24, 16);
ctx.fillStyle = e.open ? "#2a2010" : "#8a6a12";
ctx.fillRect(x - 12, y - 14, 24, 8);
ctx.fillStyle = "#6a2010";
ctx.fillRect(x - 2, y - 4, 4, 6);
}
if (e.type === "shrine") {
ctx.globalAlpha = 0.35 + Math.sin(S.time * 2) * 0.1;
ctx.fillStyle = "#9ad1ff";
ctx.beginPath(); ctx.arc(x, y - 8, 16, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 40) {
ctx.fillStyle = "#cfe8ff";
ctx.font = "12px serif"; ctx.textAlign = "center";
ctx.fillText("E Pray / Save", x, y + 28);
}
}
if (e.type === "pickup") {
const bob = Math.sin(S.time * 4 + (e.bob || 0)) * 4;
if (e.kind === "gold") {
ctx.fillStyle = "#f3c14a";
ctx.beginPath(); ctx.arc(x, y + bob, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#fff3a0";
ctx.beginPath(); ctx.arc(x - 1, y - 1 + bob, 2, 0, Math.PI * 2); ctx.fill();
} else if (e.kind === "potion") {
ctx.fillStyle = "#c23b22";
ctx.fillRect(x - 5, y - 4 + bob, 10, 12);
ctx.fillStyle = "#e8d5a3";
ctx.fillRect(x - 3, y - 8 + bob, 6, 4);
}
}
}
function drawProjectiles() {
for (const pr of S.projectiles) {
const x = worldX(pr.x), y = worldY(pr.y);
if (pr.type === "arrow") {
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.atan2(pr.vy, pr.vx));
ctx.fillStyle = "#d8c8a0";
ctx.fillRect(-8, -1.5, 16, 3);
ctx.fillStyle = "#8a2a12";
ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(2, -4); ctx.lineTo(2, 4); ctx.fill();
ctx.restore();
} else {
ctx.fillStyle = "#c96bff";
ctx.shadowColor = "#c96bff"; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
for (const b of S.bombs) {
const x = worldX(b.x), y = worldY(b.y);
ctx.fillStyle = "#2a2a28";
ctx.beginPath(); ctx.arc(x, y, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = b.t < 0.3 && Math.floor(S.time * 12) % 2 ? "#fff" : "#c23b22";
ctx.beginPath(); ctx.arc(x, y - 4, 3, 0, Math.PI * 2); ctx.fill();
}
}
function drawHearts(x, y) {
const p = S.player;
for (let i = 0; i < p.hearts; i++) {
const hx = x + i * 28;
const hp = p.hp;
const full = hp >= (i + 1) * 2;
const half = !full && hp >= i * 2 + 1;
ctx.save();
if (full || half) {
ctx.fillStyle = "#c23b22";
heartPath(ctx, hx, y, 10);
ctx.fill();
if (half) {
ctx.save();
ctx.beginPath();
ctx.rect(hx, y - 4, 14, 20);
ctx.clip();
ctx.fillStyle = "#2a2118";
heartPath(ctx, hx, y, 10);
ctx.fill();
ctx.restore();
}
ctx.strokeStyle = "#5a120c"; ctx.lineWidth = 1.2;
heartPath(ctx, hx, y, 10);
ctx.stroke();
} else {
ctx.fillStyle = "#2a2118";
heartPath(ctx, hx, y, 10);
ctx.fill();
ctx.strokeStyle = "#5a4030"; ctx.lineWidth = 1; ctx.stroke();
}
ctx.restore();
}
}
function drawItemIcon(id, x, y, s) {
ctx.save();
ctx.translate(x, y);
if (id === "potion") {
ctx.fillStyle = "#c23b22"; ctx.fillRect(-6, -2, 12, 14);
ctx.fillStyle = "#e8d5a3"; ctx.fillRect(-4, -6, 8, 5);
} else if (id === "bomb") {
ctx.fillStyle = "#2a2a28"; ctx.beginPath(); ctx.arc(0, 2, 8, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "#c9a227"; ctx.beginPath(); ctx.moveTo(4, -4); ctx.lineTo(8, -10); ctx.stroke();
} else if (id === "boots") {
ctx.fillStyle = "#c9a227"; ctx.fillRect(-10, 0, 8, 10); ctx.fillRect(2, 0, 8, 10);
} else if (id === "shield") {
ctx.fillStyle = "#8a929a";
ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(10, -4); ctx.lineTo(8, 10); ctx.lineTo(0, 14); ctx.lineTo(-8, 10); ctx.lineTo(-10, -4);
ctx.closePath(); ctx.fill();
} else if (id === "compass") {
ctx.strokeStyle = "#f3c14a"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 9, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = "#c23b22"; ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(3, 6); ctx.lineTo(0, 3); ctx.lineTo(-3, 6); ctx.fill();
} else if (id === "lantern") {
ctx.fillStyle = "#c9a227"; ctx.fillRect(-5, -4, 10, 12);
ctx.fillStyle = "#ffe08a"; ctx.fillRect(-3, -2, 6, 6);
} else if (id === "charm") {
ctx.fillStyle = "#7dcea0";
heartPath(ctx, 0, -6, 7); ctx.fill();
} else if (id === "bow") {
ctx.strokeStyle = "#8b5a2b"; ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(-2, 0, 10, -1.2, 1.2); ctx.stroke();
ctx.strokeStyle = "#ddd"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(-2, -9); ctx.lineTo(-2, 9); ctx.stroke();
}
ctx.restore();
}
function drawHUD() {
// top bar
ctx.fillStyle = "rgba(14,10,8,0.86)";
ctx.fillRect(0, 0, canvas.width, 64);
ctx.fillStyle = "#3a2e22";
ctx.fillRect(0, 64, canvas.width, 2);
drawHearts(24, 18);
// stamina
ctx.fillStyle = "#2a2118";
ctx.fillRect(24, 46, 140, 8);
ctx.fillStyle = S.player.stamina < 18 ? "#c23b22" : "#d4a017";
ctx.fillRect(24, 46, 140 * (S.player.stamina / S.player.maxStamina), 8);
ctx.strokeStyle = "#7a6544"; ctx.strokeRect(24, 46, 140, 8);
// weapon
ctx.fillStyle = "#f0e6c8";
ctx.font = "14px Palatino Linotype, serif";
ctx.textAlign = "left";
ctx.fillText(weapon().name, 180, 28);
ctx.fillStyle = "#b9a57a";
ctx.font = "12px Palatino Linotype, serif";
ctx.fillText(ROOM_META[roomId()].name, 180, 46);
// gold
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 16px Palatino Linotype, serif";
ctx.textAlign = "right";
ctx.fillText("◉ " + S.gold, canvas.width - 24, 30);
// quick items
ctx.textAlign = "right";
ctx.font = "12px Palatino Linotype, serif";
ctx.fillStyle = "#e8d5a3";
ctx.fillText("H tonic ×" + S.items.potion + " B caps ×" + S.items.bombs + (S.items.bow ? " K bow" : ""), canvas.width - 24, 52);
// minimap
const mw = 11, mh = 9, mx = canvas.width - 24 - MAP_W * mw, my = canvas.height - 24 - MAP_H * mh;
ctx.fillStyle = "rgba(14,10,8,0.7)";
ctx.fillRect(mx - 6, my - 6, MAP_W * mw + 12, MAP_H * mh + 12);
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
const vis = S.visited[`${x},${y}`];
const here = x === S.roomX && y === S.roomY;
ctx.fillStyle = here ? "#f3c14a" : vis ? "#5a7a4a" : (S.items.compass ? "#3a3228" : "#1a1612");
ctx.fillRect(mx + x * mw, my + y * mh, mw - 1, mh - 1);
if (S.items.compass && vis) {
const rid = x + y * MAP_W;
// mark shrines
if (rid === 7 || rid === 16 || rid === 1) {
ctx.fillStyle = "#9ad1ff";
ctx.fillRect(mx + x * mw + 3, my + y * mh + 3, 3, 3);
}
}
}
// bottom message
if (S.msgT > 0) {
ctx.globalAlpha = Math.min(1, S.msgT);
ctx.fillStyle = "rgba(14,10,8,0.8)";
ctx.fillRect(canvas.width / 2 - 260, canvas.height - 86, 520, 28);
ctx.fillStyle = "#f3e2b0";
ctx.font = "15px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText(S.msg, canvas.width / 2, canvas.height - 67);
ctx.globalAlpha = 1;
}
// interact prompt
if (S.mode === "play") {
for (const e of entities) {
if (e.type === "enemy" || e.type === "pickup") continue;
if (Math.hypot(e.x - S.player.x, e.y - S.player.y) < 32) {
ctx.fillStyle = "#f3e2b0";
ctx.font = "13px Palatino Linotype, serif";
ctx.textAlign = "center";
const label = e.type === "chest" ? "E Open" : e.type === "shrine" ? "" : "E Speak";
if (label) ctx.fillText(label, worldX(e.x), worldY(e.y) - 34);
}
}
}
}
function drawDarkness() {
const meta = ROOM_META[roomId()];
if (!meta.dark) return;
const p = S.player;
const gx = worldX(p.x), gy = worldY(p.y);
const rad = S.items.lantern ? 180 : 56;
const ink = S.items.lantern ? "rgba(4,6,10,0.62)" : "rgba(2,2,4,0.93)";
ctx.save();
const g = ctx.createRadialGradient(gx, gy, 16, gx, gy, rad);
g.addColorStop(0, "rgba(2,2,4,0)");
g.addColorStop(0.45, "rgba(2,2,4,0.08)");
g.addColorStop(0.75, S.items.lantern ? "rgba(4,6,10,0.35)" : "rgba(2,2,4,0.72)");
g.addColorStop(1, ink);
ctx.fillStyle = g;
ctx.fillRect(MAP_OX, MAP_OY, VIEW_W, VIEW_H);
ctx.restore();
}
function drawPanelBox(x, y, w, h) {
ctx.fillStyle = "rgba(18,12,8,0.94)";
ctx.strokeStyle = "#c9a227";
ctx.lineWidth = 2;
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
function drawUIModes() {
if (S.mode === "talk" && S.talk) {
drawPanelBox(80, 470, 800, 140);
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 16px Palatino Linotype, serif";
ctx.textAlign = "left";
ctx.fillText(S.talk.name, 104, 500);
ctx.fillStyle = "#f0e6c8";
ctx.font = "16px Palatino Linotype, serif";
wrapText(S.talk.lines[Math.min(S.talk.i, S.talk.lines.length - 1)], 104, 530, 750, 22);
ctx.fillStyle = "#b9a57a";
ctx.font = "12px Palatino Linotype, serif";
ctx.textAlign = "right";
ctx.fillText("E continue", 860, 592);
}
if (S.mode === "shop") {
drawPanelBox(200, 80, 560, 460);
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 22px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText("MARA'S WARES", 480, 118);
ctx.fillStyle = "#b9a57a";
ctx.font = "14px Palatino Linotype, serif";
ctx.fillText("Coin: " + S.gold, 480, 142);
SHOP.forEach((it, i) => {
const y = 170 + i * 70;
const owned = it.unique && S.items[it.item];
ctx.fillStyle = i === S.shopSel ? "rgba(201,162,39,0.2)" : "transparent";
ctx.fillRect(230, y - 8, 500, 62);
ctx.strokeStyle = i === S.shopSel ? "#c9a227" : "#3a2e22";
ctx.strokeRect(230, y - 8, 500, 62);
drawItemIcon(it.item === "bombs" ? "bomb" : it.item, 262, y + 22, 1);
ctx.textAlign = "left";
ctx.fillStyle = owned ? "#7a6a50" : "#f0e6c8";
ctx.font = "16px Palatino Linotype, serif";
ctx.fillText(owned ? it.name + " (owned)" : it.name, 292, y + 18);
ctx.fillStyle = "#b9a57a";
ctx.font = "13px Palatino Linotype, serif";
ctx.fillText(it.desc, 292, y + 38);
ctx.textAlign = "right";
ctx.fillStyle = "#f3c14a";
ctx.fillText(it.price + "g", 710, y + 28);
});
ctx.textAlign = "center";
ctx.fillStyle = "#b9a57a";
ctx.font = "13px Palatino Linotype, serif";
ctx.fillText("W/S select E buy Esc leave", 480, 510);
}
if (S.mode === "inventory") {
drawPanelBox(160, 90, 640, 430);
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 22px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText("TRAVELER'S PACK", 480, 128);
const slots = invSlots();
slots.forEach((sl, i) => {
const col = i % 4, row = Math.floor(i / 4);
const x = 220 + col * 130, y = 170 + row * 110;
ctx.strokeStyle = i === S.invSel ? "#c9a227" : "#4a3a28";
ctx.lineWidth = 2;
ctx.fillStyle = "rgba(40,30,20,0.8)";
ctx.fillRect(x, y, 110, 90);
ctx.strokeRect(x, y, 110, 90);
drawItemIcon(sl.id === "bombs" ? "bomb" : sl.id, x + 55, y + 32, 1);
const def = ITEMS[sl.id];
ctx.fillStyle = "#f0e6c8";
ctx.font = "12px Palatino Linotype, serif";
ctx.fillText(def ? def.name : sl.id, x + 55, y + 62);
if (sl.count != null) {
ctx.fillStyle = "#f3c14a";
ctx.fillText("×" + sl.count, x + 55, y + 78);
}
});
const cur = slots[S.invSel];
if (cur && ITEMS[cur.id]) {
ctx.fillStyle = "#b9a57a";
ctx.font = "14px Palatino Linotype, serif";
ctx.fillText(ITEMS[cur.id].desc, 480, 470);
}
ctx.fillStyle = "#7a6a50";
ctx.font = "12px Palatino Linotype, serif";
ctx.fillText("E use tonic I close", 480, 498);
}
if (S.mode === "pause") {
drawPanelBox(300, 200, 360, 200);
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 22px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText("PAUSED", 480, 250);
ctx.fillStyle = "#f0e6c8";
ctx.font = "15px Palatino Linotype, serif";
ctx.fillText("Esc resume", 480, 290);
ctx.fillStyle = "#b9a57a";
ctx.font = "13px Palatino Linotype, serif";
ctx.fillText("Find shrines to keep your story.", 480, 330);
}
if (S.mode === "dead") {
ctx.fillStyle = "rgba(20,4,4,0.55)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#c23b22";
ctx.font = "bold 42px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText("FALLEN", 480, 280);
ctx.fillStyle = "#f0e6c8";
ctx.font = "16px Palatino Linotype, serif";
ctx.fillText("Press R to rise at your last shrine", 480, 330);
}
if (S.mode === "win") {
ctx.fillStyle = "rgba(20,10,4,0.6)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#f3c14a";
ctx.font = "bold 40px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText("THE TEMPLE SLEEPS", 480, 250);
ctx.fillStyle = "#f0e6c8";
ctx.font = "16px Palatino Linotype, serif";
ctx.fillText("The Ember Warden is ash. Brackenford will see another dawn.", 480, 300);
ctx.fillText("Hearts " + S.player.hearts + "/5 Blade: " + weapon().name + " Coin " + S.gold, 480, 340);
ctx.fillStyle = "#b9a57a";
ctx.fillText("Press Enter to return to title", 480, 400);
}
}
function wrapText(text, x, y, max, lh) {
const words = text.split(" ");
let line = "", yy = y;
ctx.textAlign = "left";
for (const w of words) {
const test = line + w + " ";
if (ctx.measureText(test).width > max) {
ctx.fillText(line, x, yy); line = w + " "; yy += lh;
} else line = test;
}
ctx.fillText(line, x, yy);
}
function draw() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// frame
ctx.fillStyle = "#120e0b";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shx = S.shake ? (Math.random() - 0.5) * S.shake : 0;
const shy = S.shake ? (Math.random() - 0.5) * S.shake : 0;
ctx.save();
ctx.translate(shx, shy);
// playfield backdrop
ctx.fillStyle = "#1a1612";
ctx.fillRect(MAP_OX - 4, MAP_OY - 4, VIEW_W + 8, VIEW_H + 8);
const tiles = roomTiles();
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
drawTile(tiles[y * COLS + x], MAP_OX + x * TW, MAP_OY + y * TH, x, y);
}
// sort drawables
const drawList = entities.slice().sort((a, b) => a.y - b.y);
let drawnP = false;
for (const e of drawList) {
if (!drawnP && S.player.y < e.y) { drawPlayer(); drawnP = true; }
drawEntity(e);
}
if (!drawnP) drawPlayer();
drawProjectiles();
ctx.save();
ctx.translate(MAP_OX, MAP_OY);
drawParticles();
ctx.restore();
for (const f of S.floaters) {
ctx.globalAlpha = Math.max(0, f.t / 0.9);
ctx.fillStyle = f.col;
ctx.font = "bold 14px Palatino Linotype, serif";
ctx.textAlign = "center";
ctx.fillText(f.text, worldX(f.x), worldY(f.y));
ctx.globalAlpha = 1;
}
drawDarkness();
ctx.restore();
// vignette
const vg = ctx.createRadialGradient(480, 340, 180, 480, 340, 560);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(0,0,0,0.45)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawHUD();
drawUIModes();
}
// ---------------------------------------------------------------------------
// Loop / boot
// ---------------------------------------------------------------------------
let last = 0, acc = 0;
function frame(t) {
const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
if (S.mode !== "title") update(dt);
else flushJust();
if (S.mode !== "title") draw();
requestAnimationFrame(frame);
}
function startNew() {
AudioFX.ensure();
S = freshState();
spawnRoom();
overlay.classList.add("hidden");
S.mode = "play";
announce("East to the lea. West into trees. The shrine will remember.");
}
function startCont() {
AudioFX.ensure();
if (loadGame()) {
overlay.classList.add("hidden");
S.mode = "play";
announce("The shrine returns you.");
}
}
btnNew.addEventListener("click", startNew);
btnCont.addEventListener("click", startCont);
if (hasSave()) btnCont.classList.remove("hidden");
S.mode = "title";
window.__game = {
get S() { return S; },
get entities() { return entities; },
WEAPONS, ITEMS, SHOP, ROOM_META, TILE,
roomId, spawnRoom, giveLoot, saveGame, loadGame, hasSave,
damagePlayer, healPlayer, hitEnemy, killEnemy, buyIndex,
doSword, shootBow, placeBomb, usePotion, interact, changeRoom,
weapon, announce
};
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"x-ai/grok-4.6","provider":"openrouter","persona":"full","sessionId":"e6955123-c5ed-4ff5-bc0e-68a2101c8255","tokensIn":262202,"tokensOut":5311,"tokensTotal":267513,"cost":0.276526,"turns":7,"toolCalls":7,"failedToolCalls":0,"timestamp":"2026-08-13T16:00:34.334Z"} -->