From 083ade12eb3807a7cd19924110f19bcca3847923 Mon Sep 17 00:00:00 2001 From: Zaine Arch Date: Sat, 9 May 2026 22:52:16 +0100 Subject: [PATCH] rpg fleshing out --- assets/scripts/ash-below-lake.js | 955 +++++++++++++++++++++++++++++++ assets/scripts/play.js | 1 + assets/styles/play.css | 218 ++++++- build-site.el | 1 + play.org | 2 +- play/rpg.org | 26 +- posts/posts-list.org | 2 +- recently-updated.org | 2 +- rpg.md | 320 +++++++++++ sitemap.org | 8 +- 10 files changed, 1499 insertions(+), 36 deletions(-) create mode 100644 assets/scripts/ash-below-lake.js create mode 100644 rpg.md diff --git a/assets/scripts/ash-below-lake.js b/assets/scripts/ash-below-lake.js new file mode 100644 index 0000000..77da0bc --- /dev/null +++ b/assets/scripts/ash-below-lake.js @@ -0,0 +1,955 @@ +(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) => `
  • ${items[id].name}
  • `).join("") : "
  • Empty
  • "; + els.journal.innerHTML = state.memories.map((memory) => `
  • ${memory.erased ? "[entry missing]" : memory.text}
  • `).join(""); + ui.actionButtons.forEach((button) => { + button.disabled = state.mode !== "battle"; + }); + } + + function drawProp(prop, now) { + const x = prop.x * tile; + const y = prop.y * tile; + if (prop.kind === "lake") return drawLake(x, y, (prop.w || 1) * tile, (prop.h || 1) * tile, now, 1); + if (prop.kind === "stall") { + ctx.fillStyle = "#7f2d3a"; ctx.fillRect(x, y + 8, 64, 34); + ctx.fillStyle = "#e0a451"; ctx.fillRect(x - 4, y, 72, 12); + } + if (prop.kind === "flowers") for (let i = 0; i < 6; i += 1) { ctx.fillStyle = `rgba(133, 255, 214, ${0.45 + Math.sin(now / 400 + i) * 0.2})`; ctx.fillRect(x + i * 8, y + 12 + (i % 2) * 8, 5, 5); } + if (prop.kind === "sign" || prop.kind === "poster") { ctx.fillStyle = "#12151d"; ctx.fillRect(x, y, 38, 32); ctx.fillStyle = "#f3b15d"; ctx.fillRect(x + 5, y + 6, 28, 3); } + if (prop.kind === "stage") { ctx.fillStyle = "#210d18"; ctx.fillRect(x, y + 10, 96, 46); ctx.fillStyle = "#d64d62"; ctx.fillRect(x + 8, y, 80, 14); } + if (prop.kind === "archive") { ctx.fillStyle = "#4b3a25"; ctx.fillRect(x, y, 58, 52); ctx.fillStyle = "#d8cba8"; for (let i = 0; i < 4; i += 1) ctx.fillRect(x + 8, y + 8 + i * 10, 42, 3); } + if (prop.kind === "root") { ctx.strokeStyle = "#6aa27d"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(x, y + 28); ctx.bezierCurveTo(x + 26, y - 8, x + 52, y + 48, x + 80, y + 6); ctx.stroke(); } + if (prop.kind === "home") { ctx.fillStyle = "#263332"; ctx.fillRect(x, y, 54, 48); ctx.fillStyle = "#080a0c"; ctx.fillRect(x + 20, y + 18, 14, 30); } + if (prop.kind === "pew") { ctx.fillStyle = "#33251d"; ctx.fillRect(x, y + 10, 76, 20); } + if (prop.kind === "glass") { ctx.fillStyle = "rgba(139,211,255,0.55)"; ctx.fillRect(x, y, 52, 72); ctx.fillStyle = "rgba(255,255,255,0.3)"; ctx.fillRect(x + 23, y, 5, 72); } + if (prop.kind === "rift") { ctx.strokeStyle = `rgba(248,239,224,${0.5 + Math.sin(now / 200) * 0.3})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x + 20, y); ctx.lineTo(x + 5, y + 22); ctx.lineTo(x + 27, y + 40); ctx.lineTo(x + 10, y + 70); ctx.stroke(); } + } + + function drawLake(x, y, w, h, now, alpha) { + const g = ctx.createLinearGradient(0, y, 0, y + h); + g.addColorStop(0, `rgba(7, 18, 30, ${0.7 * alpha})`); + g.addColorStop(1, `rgba(0, 0, 0, ${0.95 * alpha})`); + ctx.fillStyle = g; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = `rgba(114, 169, 191, ${0.2 * alpha})`; + for (let i = 0; i < 8; i += 1) { + ctx.beginPath(); + const yy = y + 12 + i * 17 + Math.sin(now / 700 + i) * 4; + ctx.moveTo(x, yy); + for (let xx = 0; xx < w; xx += 24) ctx.lineTo(x + xx, yy + Math.sin(now / 350 + xx / 35 + i) * 3); + ctx.stroke(); + } + } + + function drawDoor(x, y, color) { + ctx.fillStyle = color; + ctx.fillRect(x * tile + 7, y * tile + 4, 18, 26); + ctx.fillStyle = "#05070c"; + ctx.fillRect(x * tile + 19, y * tile + 17, 3, 3); + } + + function drawItem(id, item, now) { + const x = item.x * tile; + const y = item.y * tile + Math.sin(now / 220) * 2; + ctx.fillStyle = id === "bell" ? "#d6a24a" : id === "photo" ? "#dbeafe" : id === "cinderKey" ? "#f59e0b" : "#f8efe0"; + ctx.fillRect(x + 10, y + 10, 13, 13); + ctx.fillStyle = "rgba(255,255,255,0.45)"; + ctx.fillRect(x + 13, y + 7, 7, 5); + } + + function drawEncounter(e, now) { + const x = e.x * tile; + const y = e.y * tile; + ctx.fillStyle = `rgba(210, 225, 245, ${0.35 + Math.sin(now / 260) * 0.16})`; + ctx.fillRect(x + 8, y + 4, 16, 27); + ctx.clearRect(x + 12, y + 10, 3, 4); + ctx.clearRect(x + 19, y + 10, 3, 4); + } + + function drawSprite(id, x, y, scale, now) { + const c = characters[id].colors; + const bob = Math.sin(now / 260 + x) * 1.5; + const s = scale; + ctx.fillStyle = "rgba(0,0,0,0.28)"; + ctx.fillRect(x + 5 * s, y + 30 * s, 12 * s, 3 * s); + ctx.fillStyle = c[0]; ctx.fillRect(x + 7 * s, y + bob + 2 * s, 10 * s, 7 * s); + ctx.fillStyle = c[1]; ctx.fillRect(x + 6 * s, y + bob + 8 * s, 12 * s, 10 * s); + ctx.fillStyle = c[2]; ctx.fillRect(x + 5 * s, y + bob + 18 * s, 14 * s, 13 * s); + ctx.fillStyle = c[3]; ctx.fillRect(x + 8 * s, y + bob + 12 * s, 2 * s, 2 * s); ctx.fillRect(x + 14 * s, y + bob + 12 * s, 2 * s, 2 * s); + ctx.fillStyle = c[0]; ctx.fillRect(x + 4 * s, y + bob + 22 * s, 3 * s, 9 * s); ctx.fillRect(x + 17 * s, y + bob + 22 * s, 3 * s, 9 * s); + } + + function drawBattleEnemy(id, now) { + if (id === "first_erased") drawEncounter({ x: 14, y: 4 }, now); + if (id === "root_husk") { ctx.strokeStyle = "#79c89d"; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(440, 180); ctx.bezierCurveTo(470, 120, 510, 230, 540, 160); ctx.stroke(); } + if (id === "chapel_memory") { ctx.fillStyle = "rgba(139,211,255,0.5)"; for (let i = 0; i < 5; i += 1) ctx.fillRect(420 + i * 28, 150 + Math.sin(now / 300 + i) * 8, 16, 54); } + } + + function drawEnding(now) { + ctx.fillStyle = "rgba(0,0,0,0.72)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#f8efe0"; + ctx.font = "700 42px Georgia, serif"; + ctx.textAlign = "center"; + ctx.fillText(state.route, 480 + Math.sin(now / 120) * (state.ending === "forgotten" ? 3 : 0), 202); + ctx.font = "18px Georgia, serif"; + wrapText(state.dialogue.line, 250, 250, 460, 28); + } + + function drawVignette(now) { + if (state.forgets > 0) { + ctx.fillStyle = `rgba(0,0,0,${0.06 + state.forgets * 0.05 + Math.sin(now / 800) * 0.02})`; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + const g = ctx.createRadialGradient(480, 245, 120, 480, 245, 560); + g.addColorStop(0, "rgba(0,0,0,0)"); + g.addColorStop(1, "rgba(0,0,0,0.48)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + function wrapText(text, x, y, maxWidth, lineHeight) { + ctx.textAlign = "left"; + const words = text.split(" "); + let line = ""; + for (const word of words) { + const test = `${line}${word} `; + if (ctx.measureText(test).width > maxWidth && line) { + ctx.fillText(line, x, y); + line = `${word} `; + y += lineHeight; + } else { + line = test; + } + } + ctx.fillText(line, x, y); + } + }; + + function makeAudio() { + let ctx = null; + let master = null; + let track = "title"; + let timer = null; + const patterns = { + title: [220, 277, 330, 277], + harbor: [262, 330, 392, 330], + red: [196, 233, 294, 349], + roots: [174, 220, 261, 220], + chapel: [147, 196, 247, 294], + shore: [110, 165, 220, 165], + battle: [330, 311, 294, 247], + ending: [262, 392, 440, 523], + void: [110, 98, 82, 73] + }; + function start() { + if (ctx) return; + ctx = new (window.AudioContext || window.webkitAudioContext)(); + master = ctx.createGain(); + master.gain.value = 0.055; + master.connect(ctx.destination); + loop(); + } + function loop() { + clearInterval(timer); + let i = 0; + timer = setInterval(() => { + if (!ctx) return; + const notes = patterns[track] || patterns.title; + tone(notes[i % notes.length], 0.28, track === "battle" ? "sawtooth" : "sine"); + if (track !== "battle") tone(notes[(i + 2) % notes.length] / 2, 0.7, "triangle", 0.45); + i += 1; + }, track === "battle" ? 230 : 520); + } + function tone(freq, dur, type, gainScale) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0.25 * (gainScale || 1), ctx.currentTime + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); + osc.connect(gain); + gain.connect(master); + osc.start(); + osc.stop(ctx.currentTime + dur + 0.03); + } + return { + start, + setTrack(next) { + track = next; + if (ctx) loop(); + }, + sfx(kind) { + if (!ctx) return; + const map = { item: 660, talk: 440, battle: 130, hit: 180, listen: 523, forget: 96, hurt: 80 }; + tone(map[kind] || 330, kind === "forget" ? 0.8 : 0.18, kind === "forget" ? "sawtooth" : "square", kind === "hurt" ? 1.3 : 0.8); + } + }; + } + + function rects(sources) { + const set = new Set(); + sources.forEach(([x, y, w, h]) => { + for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`); + }); + return set; + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } +})(); diff --git a/assets/scripts/play.js b/assets/scripts/play.js index 442d051..90b15b1 100644 --- a/assets/scripts/play.js +++ b/assets/scripts/play.js @@ -493,6 +493,7 @@ } function rpg(root) { + if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root); const canvas = $("[data-rpg-canvas]", root); const ctx = canvas.getContext("2d"); const els = { diff --git a/assets/styles/play.css b/assets/styles/play.css index 5307e3a..5f16f40 100644 --- a/assets/styles/play.css +++ b/assets/styles/play.css @@ -497,11 +497,63 @@ .rpg-shell { display: grid; - grid-template-columns: minmax(0, 768px) minmax(260px, 1fr); + grid-template-columns: minmax(0, 960px) minmax(280px, 1fr); gap: 1rem; align-items: start; } +.ash-rpg-root { + --ash-black: #05070c; + --ash-ink: #0d1420; + --ash-panel: #121a27; + --ash-line: #344154; + --ash-gold: #e0a451; + --ash-cold: #8bd3ff; + --ash-red: #db4f62; + --ash-text: #f8efe0; + --ash-muted: #a8b4c2; + max-width: 1380px; +} + +.ash-rpg-root .play-page-head { + padding: 1rem 0; + border-bottom: 1px solid rgba(139, 211, 255, 0.18); +} + +.ash-rpg-root .play-page-head h1 { + color: var(--ash-text); + font-family: Georgia, "Times New Roman", serif; + text-shadow: 0 0 18px rgba(139, 211, 255, 0.22); +} + +.ash-rpg-root .play-page-head p, +.ash-rpg-root .play-back { + color: var(--ash-muted); +} + +.ash-rpg-shell { + position: relative; + border-color: rgba(139, 211, 255, 0.22); + background: + linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)), + var(--ash-black); + box-shadow: + 0 0 0 1px rgba(224, 164, 81, 0.14) inset, + 0 28px 80px rgba(0, 0, 0, 0.42); +} + +.ash-rpg-shell::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: 8px; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px); + mix-blend-mode: screen; + opacity: 0.35; +} + .rpg-stage { display: grid; gap: 0.75rem; @@ -510,40 +562,53 @@ .rpg-canvas { display: block; width: 100%; - aspect-ratio: 1.5; + aspect-ratio: 16 / 9; image-rendering: pixelated; - border: 2px solid #18130f; + border: 2px solid var(--ash-line); border-radius: 8px; - background: #14110e; + background: var(--ash-black); + box-shadow: + 0 0 0 4px #05070c, + 0 0 34px rgba(139, 211, 255, 0.16); +} + +.ash-rpg-canvas { + min-height: 360px; } .rpg-dialogue, .rpg-panel { - border: 1px solid var(--border); + border: 1px solid var(--ash-line); border-radius: 8px; - background: color-mix(in oklab, var(--surface) 86%, #18130f 14%); + background: + linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)), + var(--ash-panel); + color: var(--ash-text); } .rpg-dialogue { - min-height: 118px; - padding: 0.9rem; + min-height: 130px; + padding: 1rem; + box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset; } .rpg-dialogue strong, .rpg-list h2 { - color: var(--heading); + color: var(--ash-gold); } .rpg-dialogue p, .rpg-status { margin: 0.35rem 0 0; - color: var(--muted); + color: var(--ash-muted); + line-height: 1.55; } .rpg-panel { display: grid; gap: 1rem; padding: 1rem; + align-content: start; } .rpg-actions, @@ -553,6 +618,27 @@ gap: 0.5rem; } +.ash-rpg-root button { + border-color: rgba(139, 211, 255, 0.28); + background: linear-gradient(180deg, #1a2638, #0c121c); + color: var(--ash-text); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-weight: 800; + text-transform: uppercase; +} + +.ash-rpg-root button:hover:not(:disabled), +.ash-rpg-root button:focus-visible { + background: linear-gradient(180deg, #31435f, #142236); + color: #ffffff; + box-shadow: 0 0 18px rgba(139, 211, 255, 0.22); +} + +.ash-rpg-root button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + .rpg-mobile-pad { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -585,13 +671,13 @@ justify-content: space-between; gap: 1rem; padding-bottom: 0.45rem; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid rgba(139, 211, 255, 0.16); } .rpg-stats dt, .rpg-list h2 { margin: 0; - color: var(--muted); + color: var(--ash-muted); font-size: 0.78rem; font-weight: 800; text-transform: uppercase; @@ -612,14 +698,105 @@ .rpg-list li { padding: 0.55rem; - border: 1px solid var(--border); + border: 1px solid rgba(139, 211, 255, 0.16); border-radius: 6px; - background: var(--surface); + background: rgba(5, 7, 12, 0.62); + color: var(--ash-text); + font-size: 0.92rem; } .rpg-list li.is-done { - border-color: color-mix(in oklab, var(--play-green) 70%, var(--border)); - background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%); + border-color: rgba(102, 199, 162, 0.42); + background: rgba(45, 91, 77, 0.34); +} + +.rpg-list li.is-erased { + border-color: rgba(219, 79, 98, 0.32); + color: rgba(248, 239, 224, 0.42); + background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px); + text-decoration: line-through; +} + +.ash-choicebar { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.ash-choicebar button { + min-height: 50px; +} + +.ash-memory-meter { + position: relative; + display: grid; + gap: 0.45rem; + color: var(--ash-muted); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.ash-memory-meter::after { + content: ""; + display: block; + height: 10px; + border: 1px solid rgba(139, 211, 255, 0.24); + border-radius: 999px; + background: #05070c; +} + +.ash-memory-meter i { + position: absolute; + left: 1px; + right: auto; + bottom: 1px; + width: 0; + height: 10px; + border-radius: 999px; + background: linear-gradient(90deg, var(--ash-cold), var(--ash-red)); + transition: width 220ms ease; +} + +.ash-name-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: end; +} + +.ash-name-form label { + display: grid; + gap: 0.35rem; + color: var(--ash-muted); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.ash-name-form input { + width: 100%; + border: 1px solid rgba(139, 211, 255, 0.24); + border-radius: 6px; + padding: 0.65rem; + background: #05070c; + color: var(--ash-text); + font: inherit; +} + +.ash-rpg-root.is-lake-awake .rpg-dialogue, +.ash-rpg-root.is-lake-awake .rpg-panel { + border-color: rgba(219, 79, 98, 0.36); +} + +.ash-rpg-root.is-lake-awake .ash-rpg-canvas { + box-shadow: + 0 0 0 4px #05070c, + 0 0 38px rgba(219, 79, 98, 0.22); +} + +.ash-rpg-root.is-ending .rpg-panel { + box-shadow: 0 0 34px rgba(224, 164, 81, 0.16); } @media (max-width: 760px) { @@ -643,4 +820,13 @@ width: calc(50% - 0.4rem); min-height: 160px; } + + .ash-choicebar, + .ash-name-form { + grid-template-columns: 1fr; + } + + .ash-rpg-canvas { + min-height: auto; + } } diff --git a/build-site.el b/build-site.el index c78ddcf..ea55e8a 100755 --- a/build-site.el +++ b/build-site.el @@ -225,6 +225,7 @@ FMT / ARGS are passed to `format'." "wird-tracker.js" "zhd.js" "play.js" + "ash-below-lake.js" ) "\n") (format " diff --git a/play.org b/play.org index eb2b044..3c5d098 100644 --- a/play.org +++ b/play.org @@ -51,7 +51,7 @@ 08Archive Terminal 09Study Lamp 10Sigil Press - 11Hollow Archive + 11Ash Below the Lake #+END_EXPORT diff --git a/play/rpg.org b/play/rpg.org index d53feaa..d4cb983 100644 --- a/play/rpg.org +++ b/play/rpg.org @@ -1,23 +1,23 @@ -#+TITLE: Hollow Archive +#+TITLE: Ash Below the Lake #+OPTIONS: num:nil title:nil toc:nil #+NO_SIDENOTES: t #+DATE: <2026-05-09 Sat> #+BEGIN_EXPORT html -
    +
    Back to Play

    11 / Role Playing Game

    -

    Hollow Archive

    -

    A tiny 2D RPG base: move through rooms, speak with characters, collect items, complete quests, save progress, and steer the ending.

    +

    Ash Below the Lake

    +

    A 2D story RPG set in Morrow's End: a town beside a black lake that remembers names, grief, and everyone the world tries to forget.

    -
    +
    - +
    - Archivist -

    Press Start to enter the archive.

    + The Letter +

    Don't let the lake remember your name.

    @@ -26,7 +26,7 @@ - +
    @@ -38,13 +38,13 @@
    -
    Room
    Entrance
    +
    District
    Harbor Row
    Route
    Undecided
    -
    Hearts
    3
    +
    Resolve
    20
    -

    Quests

    +

    Memory Journal

      @@ -53,7 +53,7 @@
        -

        Keyboard: arrows or WASD to move, Space or Enter to act.

        +

        Move with arrows or WASD. Act with Space or Enter. Sound starts after your first input.

        diff --git a/posts/posts-list.org b/posts/posts-list.org index b08b4cd..5019c21 100755 --- a/posts/posts-list.org +++ b/posts/posts-list.org @@ -4,7 +4,7 @@ See the categories: @@html:Categories@@ * Posts: -- [[file:career/career-list.org][Career List]] @@html:@@ +- [[file:career/career-list.org][Career List]] @@html:@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/restful-api.org][Restful API]] @@html:@@ @@html:@@ @@html:@@ diff --git a/recently-updated.org b/recently-updated.org index 345ba03..0316036 100755 --- a/recently-updated.org +++ b/recently-updated.org @@ -15,7 +15,7 @@ - [[file:play/terminal.org][Archive Terminal]] @@html:@@ - [[file:play/study.org][Study Lamp]] @@html:@@ - [[file:play/sigil.org][Sigil Press]] @@html:@@ -- [[file:play/rpg.org][Hollow Archive]] @@html:@@ +- [[file:play/rpg.org][Ash Below the Lake]] @@html:@@ - [[file:play/recipe.org][Supper Wheel]] @@html:@@ - [[file:play/poem.org][Marginalia Machine]] @@html:@@ - [[file:play/memory.org][Memory Cabinet]] @@html:@@ diff --git a/rpg.md b/rpg.md new file mode 100644 index 0000000..0da7be2 --- /dev/null +++ b/rpg.md @@ -0,0 +1,320 @@ +## Title: **Ash Below the Lake** + +### Premise + +A quiet town sits beside a black lake that never reflects the sky. + +Every year, one person disappears into the water. + +Nobody talks about it. + +You play as a teenager named **Ilyas**, who arrives in the isolated town of **Morrow’s End** after receiving a letter from a sibling who vanished there years ago. The letter contains only one sentence: + +> “Don’t let the lake remember your name.” + +The game begins normally — odd townsfolk, quirky humor, strange shops — but slowly becomes darker as the player realizes the town itself is alive in a subtle, dreamlike way. + +--- + +# Core Themes + +* Memory vs identity +* Grief and denial +* The fear of being forgotten +* Whether pain should be erased or accepted + +Like *Undertale*, the game balances: + +* humor +* emotional character writing +* eerie atmosphere +* choices that genuinely matter + +--- + +# The World + +## Morrow’s End + +A sleepy lakeside town with: + +* crooked houses +* underground tunnels +* abandoned train stations +* old flood barriers +* glowing flowers near the shore + +The town is split into districts: + +1. **Harbor Row** – fishermen, markets, friendly NPCs +2. **The Red Streets** – entertainment district with unsettling performers +3. **The Roots** – underground caverns beneath the town +4. **The Drowned Chapel** – sunken cathedral inside the lake +5. **The Hollow Shore** – final area where reality begins collapsing + +--- + +# Main Gameplay Twist + +The lake “remembers” people. + +If someone is forgotten by everybody, they slowly become erased from reality. + +Not dead. + +Erased. + +Photos change. +Dialogue changes. +Rooms disappear. +Music distorts. + +As the player progresses, NPCs begin forgetting characters the player met earlier. + +Eventually the player questions: + +* Is the town cursed? +* Or is the lake protecting people from painful memories? + +--- + +# Main Characters + +## Ilyas (Player) + +Quiet protagonist searching for their sibling. + +The player can shape Ilyas: + +* compassionate +* detached +* manipulative +* fearful + +Choices affect dialogue, endings, and combat resolution. + +--- + +## Mira + +A sarcastic runaway girl who joins you early. + +She uses humor to hide panic and becomes emotionally attached to the player. + +She remembers things others forget. + +That should be impossible. + +--- + +## The Ferryman + +A masked boatman who appears throughout the game. + +He speaks in riddles and always knows where the player has been. + +Sometimes he appears before important choices. + +He may not be human. + +--- + +## Brother Cinder + +Leader of the Drowned Chapel. + +Believes forgetting pain is mercy. + +Wants to feed memories to the lake so humanity can become “peaceful.” + +Not purely evil — deeply tragic. + +--- + +## Your Sibling + +You spend the whole game searching for them. + +Late-game reveal: +They willingly entered the lake. + +And they may no longer fully exist. + +--- + +# Combat System + +Similar vibe to *Undertale*: + +* turn-based encounters +* bullet-hell dodging +* dialogue options during fights + +But instead of MERCY/FIGHT: + +## Three Main Actions + +* **Confront** — direct resistance +* **Listen** — understand enemies emotionally +* **Forget** — erase enemies from memory + +Using “Forget” makes encounters easier… + +…but causes side effects in the world. + +NPCs disappear. +Music tracks vanish. +Areas become inaccessible. + +The player slowly realizes: +They are becoming part of the lake. + +--- + +# Major Story Beats + +## Act 1 — Strange Town + +* Meet Mira +* Discover townsfolk avoid speaking certain names +* Encounter first erased person +* Learn your sibling stayed at the old chapel + +Tone: +cozy + unsettling + +--- + +## Act 2 — The Roots + +You descend beneath the town. + +Find: + +* memory archives +* abandoned homes nobody remembers +* creatures made from forgotten people + +The player discovers: +The lake feeds on emotional suffering. + +--- + +## Act 3 — The Drowned Chapel + +Brother Cinder reveals the truth: +The lake was created long ago to contain humanity’s collective grief. + +Without it: +people would drown in their pain. + +But now it has grown hungry. + +--- + +## Final Act — The Hollow Shore + +Reality breaks down: + +* menus glitch +* save points speak +* NPC dialogue rewrites itself +* your own name occasionally changes + +The player must choose: + +### Ending 1 — “Silence” + +Join the lake. +All pain disappears. +The town becomes peaceful… empty. + +--- + +### Ending 2 — “Carry” + +Destroy the lake. +Everyone regains painful memories. +The town survives but suffers. + +Hopeful but bittersweet. + +--- + +### Ending 3 — “Forgotten” + +Use “Forget” too often. + +Eventually: +the protagonist themselves is erased. + +The final scene shows the game continuing without you. + +No one remembers the player existed. + +--- + +# Tone & Style + +Visual Style: + +* pixel art +* heavy shadows +* warm interiors vs cold outdoor areas +* subtle environmental animations + +Music: + +* soft piano +* detuned music-box melodies +* distorted ambient tracks late-game + +Humor: + +* weird NPC dialogue +* absurd side quests +* fake advertisements +* awkward interactions + +The humor makes the emotional moments hit harder. + +--- + +# Extra Mechanics + +## Memory Journal + +Instead of a quest log, the player keeps memories. + +Entries can: + +* change +* disappear +* rewrite themselves + +The player cannot always trust it. + +--- + +## Name System + +NPCs react differently depending on whether you tell them your real name. + +Some characters literally gain power if they know it. + +--- + +# Optional Secret Route + +If the player never uses “Forget” and resolves conflicts peacefully: + +* hidden areas appear +* erased characters return temporarily +* Mira reveals she died years ago and is only remembered because of you + +This leads to the true ending: +The lake is not destroyed. + +It is forgiven. + +And finally allowed to rest. diff --git a/sitemap.org b/sitemap.org index f456950..4c8c206 100755 --- a/sitemap.org +++ b/sitemap.org @@ -50,18 +50,18 @@ - tags - [[file:tags/life.sync-conflict-20260417-233423-VT6366A.org][Tag: life]] - [[file:tags/review.sync-conflict-20260328-203248-VT6366A.org][Tag: review]] - - [[file:tags/learning.org][Tag: learning]] - [[file:tags/introduction.org][Tag: introduction]] + - [[file:tags/learning.org][Tag: learning]] - [[file:tags/notes.org][Tag: notes]] - [[file:tags/website.org][Tag: website]] - [[file:tags/review.org][Tag: review]] - - [[file:tags/life.org][Tag: life]] - [[file:tags/emacs.org][Tag: emacs]] - [[file:tags/education.org][Tag: education]] - [[file:tags/update.org][Tag: update]] + - [[file:tags/life.org][Tag: life]] - [[file:tags/reading.org][Tag: reading]] - - [[file:tags/insights.org][Tag: insights]] - [[file:tags/maths.org][Tag: maths]] + - [[file:tags/insights.org][Tag: insights]] - lima - [[file:lima/lima-list.org][Lima]] - play @@ -75,4 +75,4 @@ - [[file:play/timeline.org][Timeline Tangle]] - [[file:play/recipe.org][Supper Wheel]] - [[file:play/terminal.org][Archive Terminal]] - - [[file:play/rpg.org][Hollow Archive]] \ No newline at end of file + - [[file:play/rpg.org][Ash Below the Lake]] \ No newline at end of file