1009 lines
43 KiB
JavaScript
1009 lines
43 KiB
JavaScript
(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;
|
|
let backendAvailable = true;
|
|
|
|
bind();
|
|
loadLocal();
|
|
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(true));
|
|
root.querySelector("[data-rpg-reset]").addEventListener("click", newGame);
|
|
root.querySelector("[data-rpg-sound]").addEventListener("click", () => {
|
|
audio.start();
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
els.status.textContent = audio.supported() ? "Sound on." : "This browser does not expose Web Audio.";
|
|
});
|
|
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);
|
|
if (backendAvailable) {
|
|
try {
|
|
const response = await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" });
|
|
if (response.status === 404) els.status.textContent = "New game started locally. There was no backend save to clear.";
|
|
} catch (_error) {
|
|
backendAvailable = false;
|
|
}
|
|
}
|
|
audio.setTrack("title");
|
|
if (!els.status.textContent.includes("backend save to clear")) {
|
|
els.status.textContent = backendAvailable ? "New game started and local save cleared." : "New game started locally. Backend save endpoint is not reachable.";
|
|
}
|
|
renderHud();
|
|
}
|
|
|
|
async function save() {
|
|
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
if (saveBusy || !backendAvailable) {
|
|
els.status.textContent = backendAvailable ? "Save already running." : "Saved locally. Backend save endpoint is not available.";
|
|
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 })
|
|
});
|
|
if (response.status === 404) {
|
|
backendAvailable = false;
|
|
els.status.textContent = "Saved locally. Backend save endpoint is not deployed yet.";
|
|
} else {
|
|
els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save.";
|
|
}
|
|
} catch (_error) {
|
|
backendAvailable = false;
|
|
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
|
} finally {
|
|
saveBusy = false;
|
|
}
|
|
}
|
|
|
|
async function load(allowBackend) {
|
|
if (loadLocal("Loaded local save.")) return;
|
|
if (!allowBackend || !backendAvailable) {
|
|
els.status.textContent = backendAvailable ? "No local save found." : "No local save found. Backend save endpoint is not available.";
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`/api/play/rpg/save/${slot}`);
|
|
if (response.status === 404) {
|
|
els.status.textContent = "No save file exists yet. Press Save after starting a game to create one.";
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
els.status.textContent = "No local save found. Backend did not return a save.";
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
state = normalize(data.payload);
|
|
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
els.status.textContent = "Loaded from backend.";
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
} catch (_error) {
|
|
backendAvailable = false;
|
|
els.status.textContent = "No local save found. Backend save API was not reachable.";
|
|
}
|
|
}
|
|
|
|
function loadLocal(message) {
|
|
const local = localStorage.getItem(fallbackKey);
|
|
if (local) {
|
|
state = normalize(JSON.parse(local));
|
|
if (message) els.status.textContent = message;
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
return true;
|
|
}
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
return false;
|
|
}
|
|
|
|
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) % area.floor.length];
|
|
ctx.fillStyle = wall ? "#0b0d10" : c;
|
|
ctx.fillRect(x * tile, y * tile, tile, tile);
|
|
if (!wall) {
|
|
ctx.fillStyle = (x + y) % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(0,0,0,0.035)";
|
|
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() {
|
|
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
if (!Audio) return;
|
|
if (ctx) {
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
return;
|
|
}
|
|
ctx = new Audio();
|
|
master = ctx.createGain();
|
|
master.gain.value = 0.14;
|
|
master.connect(ctx.destination);
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
loop();
|
|
const notes = patterns[track] || patterns.title;
|
|
tone(notes[0], 0.22, "triangle", 0.9);
|
|
}
|
|
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,
|
|
supported() {
|
|
return Boolean(window.AudioContext || window.webkitAudioContext);
|
|
},
|
|
setTrack(next) {
|
|
track = next;
|
|
if (ctx && ctx.state === "suspended") ctx.resume();
|
|
if (ctx) loop();
|
|
if (ctx) tone((patterns[track] || patterns.title)[0], 0.16, track === "battle" ? "sawtooth" : "triangle", 0.65);
|
|
},
|
|
sfx(kind) {
|
|
if (!ctx) return;
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
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));
|
|
}
|
|
})();
|