This commit is contained in:
955
assets/scripts/ash-below-lake.js
Normal file
955
assets/scripts/ash-below-lake.js
Normal 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));
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -493,6 +493,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rpg(root) {
|
function rpg(root) {
|
||||||
|
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
|
||||||
const canvas = $("[data-rpg-canvas]", root);
|
const canvas = $("[data-rpg-canvas]", root);
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
const els = {
|
const els = {
|
||||||
|
|||||||
@@ -497,11 +497,63 @@
|
|||||||
|
|
||||||
.rpg-shell {
|
.rpg-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 768px) minmax(260px, 1fr);
|
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
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 {
|
.rpg-stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -510,40 +562,53 @@
|
|||||||
.rpg-canvas {
|
.rpg-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1.5;
|
aspect-ratio: 16 / 9;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
border: 2px solid #18130f;
|
border: 2px solid var(--ash-line);
|
||||||
border-radius: 8px;
|
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-dialogue,
|
||||||
.rpg-panel {
|
.rpg-panel {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--ash-line);
|
||||||
border-radius: 8px;
|
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 {
|
.rpg-dialogue {
|
||||||
min-height: 118px;
|
min-height: 130px;
|
||||||
padding: 0.9rem;
|
padding: 1rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-dialogue strong,
|
.rpg-dialogue strong,
|
||||||
.rpg-list h2 {
|
.rpg-list h2 {
|
||||||
color: var(--heading);
|
color: var(--ash-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-dialogue p,
|
.rpg-dialogue p,
|
||||||
.rpg-status {
|
.rpg-status {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
color: var(--muted);
|
color: var(--ash-muted);
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-panel {
|
.rpg-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-actions,
|
.rpg-actions,
|
||||||
@@ -553,6 +618,27 @@
|
|||||||
gap: 0.5rem;
|
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 {
|
.rpg-mobile-pad {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -585,13 +671,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding-bottom: 0.45rem;
|
padding-bottom: 0.45rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rpg-stats dt,
|
.rpg-stats dt,
|
||||||
.rpg-list h2 {
|
.rpg-list h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--ash-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -612,14 +698,105 @@
|
|||||||
|
|
||||||
.rpg-list li {
|
.rpg-list li {
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(139, 211, 255, 0.16);
|
||||||
border-radius: 6px;
|
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 {
|
.rpg-list li.is-done {
|
||||||
border-color: color-mix(in oklab, var(--play-green) 70%, var(--border));
|
border-color: rgba(102, 199, 162, 0.42);
|
||||||
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
|
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) {
|
@media (max-width: 760px) {
|
||||||
@@ -643,4 +820,13 @@
|
|||||||
width: calc(50% - 0.4rem);
|
width: calc(50% - 0.4rem);
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ash-choicebar,
|
||||||
|
.ash-name-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ash-rpg-canvas {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ FMT / ARGS are passed to `format'."
|
|||||||
"wird-tracker.js"
|
"wird-tracker.js"
|
||||||
"zhd.js"
|
"zhd.js"
|
||||||
"play.js"
|
"play.js"
|
||||||
|
"ash-below-lake.js"
|
||||||
)
|
)
|
||||||
"\n")
|
"\n")
|
||||||
(format "<script src=\"%s\"></script>
|
(format "<script src=\"%s\"></script>
|
||||||
|
|||||||
2
play.org
2
play.org
@@ -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/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/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/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>
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
#+END_EXPORT
|
#+END_EXPORT
|
||||||
|
|||||||
26
play/rpg.org
26
play/rpg.org
@@ -1,23 +1,23 @@
|
|||||||
#+TITLE: Hollow Archive
|
#+TITLE: Ash Below the Lake
|
||||||
#+OPTIONS: num:nil title:nil toc:nil
|
#+OPTIONS: num:nil title:nil toc:nil
|
||||||
#+NO_SIDENOTES: t
|
#+NO_SIDENOTES: t
|
||||||
#+DATE: <2026-05-09 Sat>
|
#+DATE: <2026-05-09 Sat>
|
||||||
|
|
||||||
#+BEGIN_EXPORT html
|
#+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>
|
<a class="play-back" href="/play.html">Back to Play</a>
|
||||||
<header class="play-page-head">
|
<header class="play-page-head">
|
||||||
<p class="play-kicker">11 / Role Playing Game</p>
|
<p class="play-kicker">11 / Role Playing Game</p>
|
||||||
<h1>Hollow Archive</h1>
|
<h1>Ash Below the Lake</h1>
|
||||||
<p>A tiny 2D RPG base: move through rooms, speak with characters, collect items, complete quests, save progress, and steer the ending.</p>
|
<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>
|
</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">
|
<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">
|
<div class="rpg-dialogue" data-rpg-dialogue aria-live="polite">
|
||||||
<strong data-rpg-speaker>Archivist</strong>
|
<strong data-rpg-speaker>The Letter</strong>
|
||||||
<p data-rpg-line>Press Start to enter the archive.</p>
|
<p data-rpg-line>Don't let the lake remember your name.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<button type="button" data-rpg-start>Start</button>
|
<button type="button" data-rpg-start>Start</button>
|
||||||
<button type="button" data-rpg-save>Save</button>
|
<button type="button" data-rpg-save>Save</button>
|
||||||
<button type="button" data-rpg-load>Load</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>
|
||||||
|
|
||||||
<div class="rpg-mobile-pad" aria-label="Movement controls">
|
<div class="rpg-mobile-pad" aria-label="Movement controls">
|
||||||
@@ -38,13 +38,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="rpg-stats">
|
<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>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>
|
</dl>
|
||||||
|
|
||||||
<section class="rpg-list">
|
<section class="rpg-list">
|
||||||
<h2>Quests</h2>
|
<h2>Memory Journal</h2>
|
||||||
<ul data-rpg-quests></ul>
|
<ul data-rpg-quests></ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<ul data-rpg-inventory></ul>
|
<ul data-rpg-inventory></ul>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* 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/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/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>@@
|
- [[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>@@
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- [[file:play/terminal.org][Archive Terminal]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
|
- [[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/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/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/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/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>@@
|
- [[file:play/memory.org][Memory Cabinet]] @@html:<span class="post-date">2026-05-09 00:00</span>@@
|
||||||
|
|||||||
320
rpg.md
Normal file
320
rpg.md
Normal 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 **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.
|
||||||
@@ -50,18 +50,18 @@
|
|||||||
- tags
|
- tags
|
||||||
- [[file:tags/life.sync-conflict-20260417-233423-VT6366A.org][Tag: life]]
|
- [[file:tags/life.sync-conflict-20260417-233423-VT6366A.org][Tag: life]]
|
||||||
- [[file:tags/review.sync-conflict-20260328-203248-VT6366A.org][Tag: review]]
|
- [[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/introduction.org][Tag: introduction]]
|
||||||
|
- [[file:tags/learning.org][Tag: learning]]
|
||||||
- [[file:tags/notes.org][Tag: notes]]
|
- [[file:tags/notes.org][Tag: notes]]
|
||||||
- [[file:tags/website.org][Tag: website]]
|
- [[file:tags/website.org][Tag: website]]
|
||||||
- [[file:tags/review.org][Tag: review]]
|
- [[file:tags/review.org][Tag: review]]
|
||||||
- [[file:tags/life.org][Tag: life]]
|
|
||||||
- [[file:tags/emacs.org][Tag: emacs]]
|
- [[file:tags/emacs.org][Tag: emacs]]
|
||||||
- [[file:tags/education.org][Tag: education]]
|
- [[file:tags/education.org][Tag: education]]
|
||||||
- [[file:tags/update.org][Tag: update]]
|
- [[file:tags/update.org][Tag: update]]
|
||||||
|
- [[file:tags/life.org][Tag: life]]
|
||||||
- [[file:tags/reading.org][Tag: reading]]
|
- [[file:tags/reading.org][Tag: reading]]
|
||||||
- [[file:tags/insights.org][Tag: insights]]
|
|
||||||
- [[file:tags/maths.org][Tag: maths]]
|
- [[file:tags/maths.org][Tag: maths]]
|
||||||
|
- [[file:tags/insights.org][Tag: insights]]
|
||||||
- lima
|
- lima
|
||||||
- [[file:lima/lima-list.org][Lima]]
|
- [[file:lima/lima-list.org][Lima]]
|
||||||
- play
|
- play
|
||||||
@@ -75,4 +75,4 @@
|
|||||||
- [[file:play/timeline.org][Timeline Tangle]]
|
- [[file:play/timeline.org][Timeline Tangle]]
|
||||||
- [[file:play/recipe.org][Supper Wheel]]
|
- [[file:play/recipe.org][Supper Wheel]]
|
||||||
- [[file:play/terminal.org][Archive Terminal]]
|
- [[file:play/terminal.org][Archive Terminal]]
|
||||||
- [[file:play/rpg.org][Hollow Archive]]
|
- [[file:play/rpg.org][Ash Below the Lake]]
|
||||||
Reference in New Issue
Block a user