rpg fleshing out
All checks were successful
Build Org Website / build (push) Successful in 46s

This commit is contained in:
2026-05-09 22:52:16 +01:00
parent a2bc77805c
commit 083ade12eb
10 changed files with 1499 additions and 36 deletions

View File

@@ -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) => `<button type="button" data-ash-action="${name.toLowerCase()}">${name}</button>`).join("");
panel.insertBefore(actionsWrap, panel.querySelector(".rpg-list"));
const meter = document.createElement("div");
meter.className = "ash-memory-meter";
meter.innerHTML = "<span>Lake Memory</span><i data-ash-memory-fill></i>";
panel.insertBefore(meter, actionsWrap);
const nameBox = document.createElement("form");
nameBox.className = "ash-name-form";
nameBox.innerHTML = "<label>Name told to town <input maxlength=\"18\" placeholder=\"Ilyas\" data-ash-name></label><button type=\"submit\">Tell</button>";
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) => `<li>${items[id].name}</li>`).join("") : "<li>Empty</li>";
els.journal.innerHTML = state.memories.map((memory) => `<li class="${memory.erased ? "is-erased" : memory.stable ? "is-done" : ""}">${memory.erased ? "[entry missing]" : memory.text}</li>`).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));
}
})();

View File

@@ -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 = {

View File

@@ -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;
}
}

View File

@@ -225,6 +225,7 @@ FMT / ARGS are passed to `format'."
"wird-tracker.js"
"zhd.js"
"play.js"
"ash-below-lake.js"
)
"\n")
(format "<script src=\"%s\"></script>

View File

@@ -51,7 +51,7 @@
<a href="/play/terminal.html" data-kind="Story" data-desc="Explore a tiny command-line adventure hidden in the archive."><span>08</span><strong>Archive Terminal</strong></a>
<a href="/play/study.html" data-kind="Timer" data-desc="Run a focus timer that grows a little desk scene as time passes."><span>09</span><strong>Study Lamp</strong></a>
<a href="/play/sigil.html" data-kind="Maker" data-desc="Generate a small personal sigil from initials, colors, and motto."><span>10</span><strong>Sigil Press</strong></a>
<a href="/play/rpg.html" data-kind="RPG" data-desc="Explore a 2D archive, gather items, resolve quests, save progress, and choose a route."><span>11</span><strong>Hollow Archive</strong></a>
<a href="/play/rpg.html" data-kind="RPG" data-desc="Enter Morrow's End, survive memory battles, choose whether to confront, listen, or forget, and decide the lake's fate."><span>11</span><strong>Ash Below the Lake</strong></a>
</nav>
</main>
#+END_EXPORT

View File

@@ -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
<main class="play-root play-page rpg-root" data-play-page="rpg">
<main class="play-root play-page rpg-root ash-rpg-root" data-play-page="rpg">
<a class="play-back" href="/play.html">Back to Play</a>
<header class="play-page-head">
<p class="play-kicker">11 / Role Playing Game</p>
<h1>Hollow Archive</h1>
<p>A tiny 2D RPG base: move through rooms, speak with characters, collect items, complete quests, save progress, and steer the ending.</p>
<h1>Ash Below the Lake</h1>
<p>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.</p>
</header>
<section class="play-tool rpg-shell" aria-label="Hollow Archive game">
<section class="play-tool rpg-shell ash-rpg-shell" aria-label="Ash Below the Lake game">
<div class="rpg-stage">
<canvas class="rpg-canvas" width="768" height="512" data-rpg-canvas></canvas>
<canvas class="rpg-canvas ash-rpg-canvas" width="960" height="540" data-rpg-canvas></canvas>
<div class="rpg-dialogue" data-rpg-dialogue aria-live="polite">
<strong data-rpg-speaker>Archivist</strong>
<p data-rpg-line>Press Start to enter the archive.</p>
<strong data-rpg-speaker>The Letter</strong>
<p data-rpg-line>Don't let the lake remember your name.</p>
</div>
</div>
@@ -26,7 +26,7 @@
<button type="button" data-rpg-start>Start</button>
<button type="button" data-rpg-save>Save</button>
<button type="button" data-rpg-load>Load</button>
<button type="button" data-rpg-reset>Reset</button>
<button type="button" data-rpg-reset>New Game</button>
</div>
<div class="rpg-mobile-pad" aria-label="Movement controls">
@@ -38,13 +38,13 @@
</div>
<dl class="rpg-stats">
<div><dt>Room</dt><dd data-rpg-room>Entrance</dd></div>
<div><dt>District</dt><dd data-rpg-room>Harbor Row</dd></div>
<div><dt>Route</dt><dd data-rpg-route>Undecided</dd></div>
<div><dt>Hearts</dt><dd data-rpg-hearts>3</dd></div>
<div><dt>Resolve</dt><dd data-rpg-hearts>20</dd></div>
</dl>
<section class="rpg-list">
<h2>Quests</h2>
<h2>Memory Journal</h2>
<ul data-rpg-quests></ul>
</section>
@@ -53,7 +53,7 @@
<ul data-rpg-inventory></ul>
</section>
<p class="rpg-status" data-rpg-status>Keyboard: arrows or WASD to move, Space or Enter to act.</p>
<p class="rpg-status" data-rpg-status>Move with arrows or WASD. Act with Space or Enter. Sound starts after your first input.</p>
</aside>
</section>
</main>

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">09-05-2026 22:35</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">09-05-2026 22:36</span>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/restful-api.org][Restful API]] @@html:<span class="post-date">15-02-2026 23:00</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -15,7 +15,7 @@
- [[file:play/terminal.org][Archive Terminal]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/study.org][Study Lamp]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/sigil.org][Sigil Press]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/rpg.org][Hollow Archive]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/rpg.org][Ash Below the Lake]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/recipe.org][Supper Wheel]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/poem.org][Marginalia Machine]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
- [[file:play/memory.org][Memory Cabinet]] @@html:<span class="post-date">2026-05-09 00:00</span>@@

320
rpg.md Normal file
View File

@@ -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 **Morrows End** after receiving a letter from a sibling who vanished there years ago. The letter contains only one sentence:
> “Dont 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
## Morrows 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 humanitys 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.

View File

@@ -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]]
- [[file:play/rpg.org][Ash Below the Lake]]