(function () { const slot = "ash-below-the-lake"; const fallbackKey = "play:rpg:ash-below-the-lake"; const tile = 32; const cols = 30; const rows = 15; const worldH = tile * rows; const uiY = worldH; const actions = ["Confront", "Listen", "Forget"]; const areas = { harbor: { name: "Harbor Row", act: "Act 1", track: "harbor", floor: ["#222c36", "#263541", "#304650"], accent: "#e0a451", walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 15], [29, 0, 1, 15], [3, 3, 5, 2], [12, 2, 4, 3], [20, 3, 6, 2], [5, 11, 7, 1], [17, 10, 5, 1]]), exits: [{ x: 28, y: 7, to: "red", px: 1, py: 7 }], props: [{ kind: "lake", x: 1, y: 11, w: 28, h: 3 }, { kind: "stall", x: 9, y: 5 }, { kind: "flowers", x: 23, y: 9 }], start: { x: 3, y: 8 } }, red: { name: "The Red Streets", act: "Act 1", track: "red", floor: ["#2f1f2c", "#442237", "#5b283f"], accent: "#db4f62", walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 6], [0, 9, 1, 6], [29, 0, 1, 15], [4, 3, 4, 3], [12, 4, 3, 8], [20, 2, 5, 3], [22, 9, 4, 2]]), exits: [{ x: 0, y: 7, to: "harbor", px: 28, py: 7 }, { x: 28, y: 12, to: "roots", px: 1, py: 12 }], props: [{ kind: "sign", x: 9, y: 3 }, { kind: "stage", x: 17, y: 6 }, { kind: "poster", x: 26, y: 4 }], start: { x: 2, y: 7 } }, roots: { name: "The Roots", act: "Act 2", track: "roots", floor: ["#182725", "#203832", "#2e493e"], accent: "#66c7a2", walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 11], [0, 13, 1, 2], [29, 0, 1, 15], [5, 2, 2, 10], [11, 5, 7, 1], [15, 8, 2, 5], [22, 3, 3, 9]]), exits: [{ x: 0, y: 12, to: "red", px: 28, py: 12 }, { x: 28, y: 4, to: "chapel", px: 1, py: 4 }], props: [{ kind: "archive", x: 8, y: 8 }, { kind: "root", x: 18, y: 3 }, { kind: "home", x: 25, y: 11 }], start: { x: 2, y: 12 } }, chapel: { name: "The Drowned Chapel", act: "Act 3", track: "chapel", floor: ["#172232", "#1c3043", "#263b4f"], accent: "#8bd3ff", walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 3], [0, 6, 1, 9], [29, 0, 1, 15], [6, 4, 3, 7], [13, 2, 4, 3], [19, 4, 3, 7], [10, 12, 10, 1]]), exits: [{ x: 0, y: 4, to: "roots", px: 28, py: 4 }, { x: 28, y: 10, to: "shore", px: 1, py: 10 }], props: [{ kind: "pew", x: 10, y: 7 }, { kind: "pew", x: 16, y: 7 }, { kind: "glass", x: 14, y: 5 }], start: { x: 2, y: 4 } }, shore: { name: "The Hollow Shore", act: "Final Act", track: "shore", floor: ["#111827", "#1b2440", "#211c33"], accent: "#f1f5f9", walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 9], [0, 12, 1, 3], [29, 0, 1, 15], [7, 3, 3, 2], [16, 2, 2, 10], [22, 7, 4, 1]]), exits: [{ x: 0, y: 10, to: "chapel", px: 28, py: 10 }], props: [{ kind: "lake", x: 2, y: 10, w: 27, h: 4 }, { kind: "flowers", x: 5, y: 6 }, { kind: "rift", x: 24, y: 4 }], start: { x: 2, y: 10 } } }; const characters = { ilyas: { name: "Ilyas", colors: ["#3d2b2f", "#e3b27f", "#334b7a", "#f2f0e6"] }, mira: { name: "Mira", colors: ["#21191e", "#d98d70", "#b73550", "#f6d36f"] }, ferryman: { name: "Ferryman", colors: ["#151719", "#dad9cd", "#56616d", "#0b0d10"] }, cinder: { name: "Brother Cinder", colors: ["#1d1512", "#d59667", "#6b2e2f", "#f4c35c"] }, sibling: { name: "Your Sibling", colors: ["#1d2531", "#bec8d8", "#52616f", "#dbeafe"] }, vendor: { name: "Maribel", colors: ["#2a2023", "#d5a06f", "#3d7b65", "#ffd166"] }, performer: { name: "Velvet Noon", colors: ["#2a1724", "#f2bd93", "#8f244d", "#ffe6a8"] }, archivist: { name: "Memory Archivist", colors: ["#151f1d", "#b9ddc9", "#386b5c", "#dff7eb"] } }; const npcs = { mira: { character: "mira", area: "harbor", x: 10, y: 8 }, ferryman: { character: "ferryman", area: "harbor", x: 25, y: 10 }, vendor: { character: "vendor", area: "harbor", x: 9, y: 6 }, performer: { character: "performer", area: "red", x: 17, y: 8 }, archivist: { character: "archivist", area: "roots", x: 9, y: 9 }, cinder: { character: "cinder", area: "chapel", x: 15, y: 6 }, sibling: { character: "sibling", area: "shore", x: 24, y: 8 } }; const items = { letter: { name: "Wet Letter", area: "harbor", x: 5, y: 10 }, bell: { name: "Brass Bell", area: "red", x: 27, y: 5 }, photo: { name: "Blurred Photo", area: "roots", x: 13, y: 9 }, cinderKey: { name: "Chapel Key", area: "chapel", x: 22, y: 12 } }; const encounters = { first_erased: { title: "First Erased Person", area: "red", x: 6, y: 9, hp: 18, text: "A person-shaped absence blocks the alley. Their outline mouths a name no one else can hear.", bullets: "drift" }, root_husk: { title: "Root Husk", area: "roots", x: 21, y: 9, hp: 24, text: "A house nobody remembers learned to walk. It wants its rooms back.", bullets: "rain" }, chapel_memory: { title: "Choir of Ash", area: "chapel", x: 11, y: 10, hp: 30, text: "Voices rise from the drowned pews. Every note sounds like an apology.", bullets: "spiral" } }; window.AshBelowLakeRpg = function init(root) { const canvas = root.querySelector("[data-rpg-canvas]"); const ctx = canvas.getContext("2d"); const els = { speaker: root.querySelector("[data-rpg-speaker]"), line: root.querySelector("[data-rpg-line]"), area: root.querySelector("[data-rpg-room]"), route: root.querySelector("[data-rpg-route]"), resolve: root.querySelector("[data-rpg-hearts]"), journal: root.querySelector("[data-rpg-quests]"), inventory: root.querySelector("[data-rpg-inventory]"), status: root.querySelector("[data-rpg-status]") }; const ui = buildUi(root); const audio = makeAudio(); let state = freshState(); let keys = new Set(); let last = performance.now(); let saveBusy = false; bind(); load(); requestAnimationFrame(frame); function freshState() { return { started: false, mode: "title", area: "harbor", player: { x: 3, y: 8, facing: "down", step: 0 }, route: "Undecided", resolve: 20, toldName: null, flags: {}, inventory: [], memories: [ { id: "letter", text: "Your sibling's letter says: Don't let the lake remember your name.", stable: true }, { id: "town", text: "Morrow's End smiles like a town in a postcard someone tried to burn.", stable: true } ], forgets: 0, listens: 0, confronts: 0, companion: false, battle: null, ending: null, dialogue: { speaker: "The Letter", line: "Don't let the lake remember your name." } }; } function buildUi(rootEl) { const panel = rootEl.querySelector(".rpg-panel"); const actionsWrap = document.createElement("div"); actionsWrap.className = "ash-choicebar"; actionsWrap.innerHTML = actions.map((name) => ``).join(""); panel.insertBefore(actionsWrap, panel.querySelector(".rpg-list")); const meter = document.createElement("div"); meter.className = "ash-memory-meter"; meter.innerHTML = "Lake Memory"; panel.insertBefore(meter, actionsWrap); const nameBox = document.createElement("form"); nameBox.className = "ash-name-form"; nameBox.innerHTML = ""; panel.insertBefore(nameBox, meter); return { actionButtons: Array.from(actionsWrap.querySelectorAll("button")), memoryFill: meter.querySelector("[data-ash-memory-fill]"), nameForm: nameBox, nameInput: nameBox.querySelector("[data-ash-name]") }; } function bind() { root.querySelector("[data-rpg-start]").addEventListener("click", start); root.querySelector("[data-rpg-save]").addEventListener("click", save); root.querySelector("[data-rpg-load]").addEventListener("click", load); root.querySelector("[data-rpg-reset]").addEventListener("click", newGame); root.querySelector("[data-rpg-act]").addEventListener("click", act); root.querySelectorAll("[data-rpg-move]").forEach((button) => { button.addEventListener("click", () => move(button.dataset.rpgMove)); }); ui.actionButtons.forEach((button) => button.addEventListener("click", () => chooseBattle(button.dataset.ashAction))); ui.nameForm.addEventListener("submit", (event) => { event.preventDefault(); tellName(ui.nameInput.value.trim() || "Ilyas"); }); window.addEventListener("keydown", (event) => { if (!root.isConnected || ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return; if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "w", "a", "s", "d", " ", "Enter", "Escape"].includes(event.key)) event.preventDefault(); keys.add(event.key.toLowerCase()); audio.start(); if (event.key === " " || event.key === "Enter") act(); if (event.key === "Escape" && state.mode === "battle") leaveBattle("You step back from the memory and let it breathe."); }); window.addEventListener("keyup", (event) => keys.delete(event.key.toLowerCase())); } function start() { audio.start(); state.started = true; state.mode = "world"; state.dialogue = { speaker: "Ilyas", line: "The letter is damp, but the ink has not run. Morrow's End waits beyond the harbor lamps." }; audio.setTrack(areas[state.area].track); save(); } async function newGame() { state = freshState(); localStorage.removeItem(fallbackKey); try { await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" }); } catch (_error) { /* Local reset is enough when offline. */ } audio.setTrack("title"); renderHud(); } async function save() { localStorage.setItem(fallbackKey, JSON.stringify(state)); if (saveBusy) return; saveBusy = true; try { const response = await fetch(`/api/play/rpg/save/${slot}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ payload: state }) }); els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save."; } catch (_error) { els.status.textContent = "Saved locally. Backend save API was not reachable."; } finally { saveBusy = false; } } async function load() { try { const response = await fetch(`/api/play/rpg/save/${slot}`); if (response.ok) { const data = await response.json(); state = normalize(data.payload); els.status.textContent = "Loaded from backend."; audio.setTrack(state.started ? areas[state.area].track : "title"); return; } } catch (_error) { /* Fall through to localStorage. */ } const local = localStorage.getItem(fallbackKey); if (local) { state = normalize(JSON.parse(local)); els.status.textContent = "Loaded local save."; } audio.setTrack(state.started ? areas[state.area].track : "title"); } function normalize(candidate) { const base = freshState(); const next = Object.assign(base, candidate || {}); next.player = Object.assign(base.player, (candidate && candidate.player) || {}); next.flags = Object.assign({}, (candidate && candidate.flags) || {}); next.inventory = Array.isArray(next.inventory) ? next.inventory.filter((id) => items[id]) : []; next.memories = Array.isArray(next.memories) ? next.memories : base.memories; next.area = areas[next.area] ? next.area : "harbor"; next.dialogue = Object.assign(base.dialogue, (candidate && candidate.dialogue) || {}); return next; } function frame(now) { const dt = Math.min(48, now - last); last = now; update(dt); draw(now); renderHud(); requestAnimationFrame(frame); } function update(dt) { if (state.mode !== "world") return; const dir = keyDirection(); if (!dir) return; state.player.step += dt; if (state.player.step < 115) return; state.player.step = 0; move(dir); } function keyDirection() { if (keys.has("arrowup") || keys.has("w")) return "up"; if (keys.has("arrowdown") || keys.has("s")) return "down"; if (keys.has("arrowleft") || keys.has("a")) return "left"; if (keys.has("arrowright") || keys.has("d")) return "right"; return null; } function move(direction) { if (state.mode !== "world" || !state.started || state.ending) return; const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction]; state.player.facing = direction; const nx = state.player.x + delta[0]; const ny = state.player.y + delta[1]; const area = areas[state.area]; const exit = area.exits.find((item) => item.x === nx && item.y === ny); if (exit && canEnter(exit.to)) { state.area = exit.to; state.player.x = exit.px; state.player.y = exit.py; audio.setTrack(areas[state.area].track); remember(`Entered ${areas[state.area].name}.`, true); say("Morrow's End", areaEnterLine(state.area)); save(); return; } if (area.walls.has(`${nx},${ny}`) || npcAt(nx, ny) || nx < 0 || nx >= cols || ny < 0 || ny >= rows) return; state.player.x = nx; state.player.y = ny; const found = itemAt(nx, ny); if (found) take(found); const encounter = encounterAt(nx, ny); if (encounter && !state.flags[`encounter_${encounter}`]) startBattle(encounter); } function canEnter(to) { if (to === "chapel" && !state.flags.rootsTruth && state.forgets < 2) { say("Ferryman", "The chapel is under the lake. It only opens for truth, or for forgetting."); return false; } if (to === "shore" && !state.flags.cinderMet) { say("Brother Cinder", "The shore is not a place. It is a decision. Hear me first."); return false; } return true; } function act() { audio.start(); if (!state.started) return start(); if (state.mode === "battle") return chooseBattle("listen"); if (state.ending) return; const front = inFront(); const npc = npcAt(front.x, front.y) || npcAt(state.player.x, state.player.y); if (npc) return talk(npc); const found = itemAt(state.player.x, state.player.y); if (found) return take(found); const special = propAt(front.x, front.y) || propAt(state.player.x, state.player.y); if (special) return inspectProp(special); if (state.area === "shore" && state.player.x > 22 && state.player.y > 8) return finalChoice(); say("Ilyas", "The town holds its breath. Somewhere, water knocks against stone."); } function inFront() { const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1]; return { x: state.player.x + d[0], y: state.player.y + d[1] }; } function npcAt(x, y) { return Object.keys(npcs).find((id) => { const npc = npcs[id]; return npc.area === state.area && npc.x === x && npc.y === y && !forgottenNpc(id); }); } function itemAt(x, y) { return Object.keys(items).find((id) => { const item = items[id]; return item.area === state.area && item.x === x && item.y === y && !state.inventory.includes(id); }); } function encounterAt(x, y) { return Object.keys(encounters).find((id) => { const e = encounters[id]; return e.area === state.area && e.x === x && e.y === y; }); } function propAt(x, y) { return areas[state.area].props.find((prop) => x >= prop.x && x < prop.x + (prop.w || 1) && y >= prop.y && y < prop.y + (prop.h || 1)); } function take(id) { state.inventory.push(id); audio.sfx("item"); if (id === "letter") remember("The wet letter refuses to dry.", true); if (id === "photo") remember("A photo shows you beside someone whose face keeps changing.", true); if (id === "cinderKey") state.flags.chapelKey = true; say("Found", `${items[id].name} added to your bag.`); save(); } function talk(id) { audio.sfx("talk"); if (id === "mira") { state.companion = true; state.flags.metMira = true; remember("Mira remembers what other people lose.", true); say("Mira", state.forgets > 1 ? "You keep looking through people like windows. Don't do that to me, okay?" : "You look lost. Good. Everyone interesting does."); } if (id === "ferryman") { remember("The Ferryman knows where you have been before you arrive.", state.forgets < 2); say("Ferryman", state.toldName ? `${state.toldName} is a fine name to keep away from water.` : "Names are hooks. I would not throw yours into the lake."); } if (id === "vendor") say("Maribel", "Today's special: one jar of fog, two apologies, and a fish that predicts Thursday badly."); if (id === "performer") { remember("A performer advertised a show called YOUR MISSING YEARS.", state.forgets < 3); say("Velvet Noon", "Clap if you remember me. Clap twice if you are pretending."); } if (id === "archivist") { state.flags.rootsTruth = true; remember("The lake feeds on suffering, but calls it mercy.", true); say("Memory Archivist", "The archives are homes with the people removed. The lake calls that tidying."); } if (id === "cinder") { state.flags.cinderMet = true; remember("Brother Cinder believes forgetting pain is mercy.", true); say("Brother Cinder", "I have seen grief make animals of gentle people. The lake did not begin as a hunger. It began as a kindness."); } if (id === "sibling") { remember("Your sibling entered the lake willingly.", true); say("Your Sibling", "I gave it my name because I was tired of carrying it. Please don't give it yours."); finalChoice(); } save(); } function inspectProp(prop) { audio.sfx("talk"); if (prop.kind === "lake") say("Black Lake", state.toldName ? `It almost says ${state.toldName}. Almost.` : "It reflects nothing. Not clouds. Not you."); if (prop.kind === "stall") say("Market Sign", "Fresh eels. Older than regret. Discount if they remember your birthday."); if (prop.kind === "flowers") say("Glowing Flowers", "They lean toward the water like they are listening."); if (prop.kind === "sign") say("Advertisement", "TONIGHT: The Amazing Vanishing Audience. Refunds unavailable after erasure."); if (prop.kind === "stage") say("Stage", "The curtain twitches though there is no wind indoors."); if (prop.kind === "poster") say("Poster", "A smiling face has been scratched out. The scratch is smiling too."); if (prop.kind === "archive") { state.flags.rootsTruth = true; remember("Some memory shelves are empty because nobody dared open them.", true); say("Memory Archive", "A drawer opens by itself. Inside: houses, birthdays, funeral songs, a ticket stub from a train that never arrived."); } if (prop.kind === "root") say("Roots", "They pulse slowly under the town, carrying lake-water like blood."); if (prop.kind === "home") say("Abandoned Home", "Every room is furnished except for the idea of a family."); if (prop.kind === "pew") say("Drowned Pew", "The wood is wet. The prayer book is dry."); if (prop.kind === "glass") say("Sunken Window", "Saints in blue glass lower their eyes toward the lake."); if (prop.kind === "rift") finalChoice(); save(); } function tellName(name) { state.toldName = name.slice(0, 18); remember(`You told Morrow's End your name is ${state.toldName}.`, false); say("Morrow's End", `The town repeats ${state.toldName} very softly. Too softly.`); state.route = state.route === "Undecided" ? "Named" : state.route; save(); } function startBattle(id) { const e = encounters[id]; state.mode = "battle"; state.battle = { id, hp: e.hp, maxHp: e.hp, soul: { x: 480, y: 382 }, bullets: [], t: 0, grace: 0, turn: 1, message: e.text }; audio.setTrack("battle"); audio.sfx("battle"); say(e.title, e.text); save(); } function chooseBattle(action) { if (state.mode !== "battle" || !state.battle) return; const battle = state.battle; if (action === "confront") { battle.hp -= 9; state.confronts += 1; state.route = "Confronting"; battle.message = "You refuse to move. The memory recoils from your shape."; audio.sfx("hit"); } if (action === "listen") { battle.hp -= 7; state.listens += 1; state.route = state.forgets === 0 ? "Carry" : "Listening"; battle.message = "You listen until the ache becomes words. The attack softens."; remember(`Listened to ${encounters[battle.id].title}.`, true); audio.sfx("listen"); } if (action === "forget") { battle.hp -= 18; state.forgets += 1; state.route = state.forgets >= 3 ? "Forgotten" : "Silence"; battle.message = "You let the lake take the hard part. Something else goes with it."; rewriteMemory(); audio.sfx("forget"); } battle.turn += 1; if (battle.hp <= 0) resolveBattle(action); else say(encounters[battle.id].title, battle.message); save(); } function resolveBattle(action) { const id = state.battle.id; state.flags[`encounter_${id}`] = action; if (id === "root_husk") state.flags.rootsTruth = true; if (action === "forget" && state.forgets >= 3) return end("forgotten"); const line = action === "listen" ? "The memory steps aside, still hurting, but no longer alone." : action === "forget" ? "The memory becomes easy to pass. Behind you, a street sign loses a letter." : "The memory breaks apart like ash under rain."; leaveBattle(line); } function leaveBattle(line) { state.mode = "world"; state.battle = null; audio.setTrack(areas[state.area].track); say("Afterimage", line); save(); } function finalChoice() { if (state.forgets >= 3) return end("forgotten"); if (state.forgets === 0 && state.listens >= 3 && state.flags.metMira) return end("rest"); if (state.route === "Silence" || state.forgets > state.listens) return end("silence"); end("carry"); } function end(kind) { state.ending = kind; state.mode = "ending"; state.route = kind === "rest" ? "True: Rest" : kind === "carry" ? "Carry" : kind === "silence" ? "Silence" : "Forgotten"; audio.setTrack(kind === "forgotten" ? "void" : "ending"); say("Ending", endingLine(kind)); remember(endingLine(kind), kind !== "forgotten"); save(); } function endingLine(kind) { if (kind === "silence") return "Silence: you join the lake. Morrow's End becomes peaceful, clean, and almost empty."; if (kind === "carry") return "Carry: the lake breaks. Everyone remembers. The town survives with tears in every lit window."; if (kind === "forgotten") return "Forgotten: the story continues politely around the space where Ilyas used to be."; return "Rest: the lake is forgiven, not destroyed. Mira laughs once, real and afraid, as the shore finally reflects the sky."; } function rewriteMemory() { const mutable = state.memories.find((memory) => !memory.stable && !memory.erased) || state.memories.find((memory) => !memory.erased); if (mutable) { mutable.text = state.forgets > 2 ? "There was someone here. There was no one here. Both feel true." : mutable.text.replace(/[aeiou]/gi, "_"); mutable.erased = state.forgets > 2; } if (state.forgets === 2) state.flags.vendorForgotten = true; if (state.forgets >= 3) state.flags.performerForgotten = true; } function forgottenNpc(id) { return (id === "vendor" && state.flags.vendorForgotten) || (id === "performer" && state.flags.performerForgotten); } function remember(text, stable) { if (!state.memories.some((memory) => memory.text === text)) state.memories.push({ id: `m${Date.now()}`, text, stable: Boolean(stable) }); if (state.memories.length > 8) state.memories.splice(2, 1); } function say(speaker, line) { state.dialogue = { speaker, line }; } function areaEnterLine(area) { if (area === "red") return "The Red Streets glow warmly, but the laughter arrives half a second late."; if (area === "roots") return "The Roots smell like wet stone and bedrooms left untouched for years."; if (area === "chapel") return "The Drowned Chapel rings with a bell under water."; if (area === "shore") return "The Hollow Shore bends at the edges, as if the world has been folded too many times."; return "Harbor Row keeps its lamps low and its names lower."; } function draw(now) { ctx.imageSmoothingEnabled = false; if (!state.started || state.mode === "title") drawTitle(now); else drawWorld(now); if (state.mode === "battle") drawBattle(now); if (state.mode === "ending") drawEnding(now); drawVignette(now); } function drawTitle(now) { const g = ctx.createLinearGradient(0, 0, 0, canvas.height); g.addColorStop(0, "#05070c"); g.addColorStop(0.55, "#101b28"); g.addColorStop(1, "#020306"); ctx.fillStyle = g; ctx.fillRect(0, 0, canvas.width, canvas.height); drawLake(0, 345, 960, 195, now, 0.4); ctx.fillStyle = "#f8efe0"; ctx.font = "700 50px Georgia, serif"; ctx.textAlign = "center"; ctx.fillText("Ash Below the Lake", 480, 142); ctx.font = "18px Georgia, serif"; ctx.fillStyle = "#b9c6d4"; ctx.fillText("A quiet town. A black lake. A name you should not give away.", 480, 178); ctx.fillStyle = "#e0a451"; ctx.font = "700 18px ui-monospace, monospace"; ctx.fillText("Press Start or Space", 480, 246); drawSprite("ilyas", 424, 288, 3, now); drawSprite("mira", 500, 288, 3, now); } function drawWorld(now) { const area = areas[state.area]; for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const wall = area.walls.has(`${x},${y}`); const c = area.floor[(x + y + Math.floor(now / 800)) % area.floor.length]; ctx.fillStyle = wall ? "#0b0d10" : c; ctx.fillRect(x * tile, y * tile, tile, tile); if (!wall) { ctx.fillStyle = "rgba(255,255,255,0.025)"; ctx.fillRect(x * tile, y * tile + 30, tile, 2); } } } area.exits.forEach((exit) => drawDoor(exit.x, exit.y, area.accent)); area.props.forEach((prop) => drawProp(prop, now)); Object.keys(items).forEach((id) => { const item = items[id]; if (item.area === state.area && !state.inventory.includes(id)) drawItem(id, item, now); }); Object.keys(encounters).forEach((id) => { const e = encounters[id]; if (e.area === state.area && !state.flags[`encounter_${id}`]) drawEncounter(e, now); }); Object.keys(npcs).forEach((id) => { const npc = npcs[id]; if (npc.area === state.area && !forgottenNpc(id)) drawSprite(npc.character, npc.x * tile, npc.y * tile - 12, 2, now); }); drawSprite("ilyas", state.player.x * tile, state.player.y * tile - 12, 2, now); if (state.companion && state.area !== "chapel") { const offset = state.player.facing === "left" ? 1 : -1; drawSprite("mira", (state.player.x + offset) * tile, state.player.y * tile - 12, 2, now + 200); } drawTopBar(area); } function drawTopBar(area) { ctx.fillStyle = "rgba(3, 6, 10, 0.82)"; ctx.fillRect(0, 0, canvas.width, 42); ctx.fillStyle = area.accent; ctx.font = "700 14px ui-monospace, monospace"; ctx.textAlign = "left"; ctx.fillText(`${area.act} / ${area.name}`, 18, 26); ctx.textAlign = "right"; ctx.fillStyle = "#f8efe0"; ctx.fillText(`Resolve ${state.resolve} Lake ${state.forgets}`, 940, 26); } function drawBattle(now) { const battle = state.battle; battle.t += 1; updateBullets(battle, now); ctx.fillStyle = "rgba(4, 5, 8, 0.88)"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = "#f8efe0"; ctx.lineWidth = 3; ctx.strokeRect(296, 252, 368, 190); ctx.fillStyle = "#f8efe0"; ctx.font = "700 24px Georgia, serif"; ctx.textAlign = "center"; ctx.fillText(encounters[battle.id].title, 480, 82); ctx.font = "16px ui-monospace, monospace"; ctx.fillText(`Memory ${Math.max(0, battle.hp)} / ${battle.maxHp}`, 480, 116); drawBattleEnemy(battle.id, now); ctx.fillStyle = "#f35f5f"; ctx.beginPath(); ctx.moveTo(battle.soul.x, battle.soul.y - 8); ctx.bezierCurveTo(battle.soul.x - 13, battle.soul.y - 17, battle.soul.x - 20, battle.soul.y + 4, battle.soul.x, battle.soul.y + 18); ctx.bezierCurveTo(battle.soul.x + 20, battle.soul.y + 4, battle.soul.x + 13, battle.soul.y - 17, battle.soul.x, battle.soul.y - 8); ctx.fill(); ctx.fillStyle = "#dbeafe"; battle.bullets.forEach((b) => { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill(); }); ctx.fillStyle = "#f8efe0"; ctx.font = "15px Georgia, serif"; wrapText(battle.message, 148, 480, 660, 22); } function updateBullets(battle) { const speed = state.forgets > 0 ? 1.15 : 1.55; const move = keyDirection(); if (move === "up") battle.soul.y -= 3.3; if (move === "down") battle.soul.y += 3.3; if (move === "left") battle.soul.x -= 3.3; if (move === "right") battle.soul.x += 3.3; battle.soul.x = clamp(battle.soul.x, 312, 648); battle.soul.y = clamp(battle.soul.y, 270, 426); if (battle.t % 26 === 0) { if (encounters[battle.id].bullets === "rain") battle.bullets.push({ x: 315 + Math.random() * 330, y: 256, vx: 0, vy: speed * 2.4, r: 5 }); else if (encounters[battle.id].bullets === "spiral") { const a = battle.t / 14; battle.bullets.push({ x: 480, y: 346, vx: Math.cos(a) * speed * 2, vy: Math.sin(a) * speed * 2, r: 5 }); } else battle.bullets.push({ x: Math.random() > 0.5 ? 304 : 656, y: 275 + Math.random() * 130, vx: (Math.random() > 0.5 ? -1 : 1) * speed * 2, vy: Math.sin(battle.t) * 0.6, r: 5 }); } battle.bullets.forEach((b) => { b.x += b.vx; b.y += b.vy; if (battle.grace <= 0 && Math.hypot(b.x - battle.soul.x, b.y - battle.soul.y) < b.r + 10) { state.resolve = Math.max(0, state.resolve - 1); battle.grace = 24; audio.sfx("hurt"); if (state.resolve <= 0) end("forgotten"); } }); battle.grace -= 1; battle.bullets = battle.bullets.filter((b) => b.x > 280 && b.x < 680 && b.y > 230 && b.y < 455); } function renderHud() { root.classList.toggle("is-lake-awake", state.forgets > 1); root.classList.toggle("is-ending", Boolean(state.ending)); els.speaker.textContent = state.dialogue.speaker; els.line.textContent = state.dialogue.line; els.area.textContent = areas[state.area].name; els.route.textContent = state.route; els.resolve.textContent = String(state.resolve); ui.memoryFill.style.width = `${Math.min(100, state.forgets * 34)}%`; els.inventory.innerHTML = state.inventory.length ? state.inventory.map((id) => `