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

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

View File

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

View File

@@ -493,6 +493,7 @@
}
function rpg(root) {
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
const canvas = $("[data-rpg-canvas]", root);
const ctx = canvas.getContext("2d");
const els = {

View File

@@ -497,11 +497,63 @@
.rpg-shell {
display: grid;
grid-template-columns: minmax(0, 768px) minmax(260px, 1fr);
grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
.ash-rpg-root {
--ash-black: #05070c;
--ash-ink: #0d1420;
--ash-panel: #121a27;
--ash-line: #344154;
--ash-gold: #e0a451;
--ash-cold: #8bd3ff;
--ash-red: #db4f62;
--ash-text: #f8efe0;
--ash-muted: #a8b4c2;
max-width: 1380px;
}
.ash-rpg-root .play-page-head {
padding: 1rem 0;
border-bottom: 1px solid rgba(139, 211, 255, 0.18);
}
.ash-rpg-root .play-page-head h1 {
color: var(--ash-text);
font-family: Georgia, "Times New Roman", serif;
text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
}
.ash-rpg-root .play-page-head p,
.ash-rpg-root .play-back {
color: var(--ash-muted);
}
.ash-rpg-shell {
position: relative;
border-color: rgba(139, 211, 255, 0.22);
background:
linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
var(--ash-black);
box-shadow:
0 0 0 1px rgba(224, 164, 81, 0.14) inset,
0 28px 80px rgba(0, 0, 0, 0.42);
}
.ash-rpg-shell::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 8px;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
mix-blend-mode: screen;
opacity: 0.35;
}
.rpg-stage {
display: grid;
gap: 0.75rem;
@@ -510,40 +562,53 @@
.rpg-canvas {
display: block;
width: 100%;
aspect-ratio: 1.5;
aspect-ratio: 16 / 9;
image-rendering: pixelated;
border: 2px solid #18130f;
border: 2px solid var(--ash-line);
border-radius: 8px;
background: #14110e;
background: var(--ash-black);
box-shadow:
0 0 0 4px #05070c,
0 0 34px rgba(139, 211, 255, 0.16);
}
.ash-rpg-canvas {
min-height: 360px;
}
.rpg-dialogue,
.rpg-panel {
border: 1px solid var(--border);
border: 1px solid var(--ash-line);
border-radius: 8px;
background: color-mix(in oklab, var(--surface) 86%, #18130f 14%);
background:
linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
var(--ash-panel);
color: var(--ash-text);
}
.rpg-dialogue {
min-height: 118px;
padding: 0.9rem;
min-height: 130px;
padding: 1rem;
box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
}
.rpg-dialogue strong,
.rpg-list h2 {
color: var(--heading);
color: var(--ash-gold);
}
.rpg-dialogue p,
.rpg-status {
margin: 0.35rem 0 0;
color: var(--muted);
color: var(--ash-muted);
line-height: 1.55;
}
.rpg-panel {
display: grid;
gap: 1rem;
padding: 1rem;
align-content: start;
}
.rpg-actions,
@@ -553,6 +618,27 @@
gap: 0.5rem;
}
.ash-rpg-root button {
border-color: rgba(139, 211, 255, 0.28);
background: linear-gradient(180deg, #1a2638, #0c121c);
color: var(--ash-text);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 800;
text-transform: uppercase;
}
.ash-rpg-root button:hover:not(:disabled),
.ash-rpg-root button:focus-visible {
background: linear-gradient(180deg, #31435f, #142236);
color: #ffffff;
box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
}
.ash-rpg-root button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.rpg-mobile-pad {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -585,13 +671,13 @@
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgba(139, 211, 255, 0.16);
}
.rpg-stats dt,
.rpg-list h2 {
margin: 0;
color: var(--muted);
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
@@ -612,14 +698,105 @@
.rpg-list li {
padding: 0.55rem;
border: 1px solid var(--border);
border: 1px solid rgba(139, 211, 255, 0.16);
border-radius: 6px;
background: var(--surface);
background: rgba(5, 7, 12, 0.62);
color: var(--ash-text);
font-size: 0.92rem;
}
.rpg-list li.is-done {
border-color: color-mix(in oklab, var(--play-green) 70%, var(--border));
background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
border-color: rgba(102, 199, 162, 0.42);
background: rgba(45, 91, 77, 0.34);
}
.rpg-list li.is-erased {
border-color: rgba(219, 79, 98, 0.32);
color: rgba(248, 239, 224, 0.42);
background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
text-decoration: line-through;
}
.ash-choicebar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.ash-choicebar button {
min-height: 50px;
}
.ash-memory-meter {
position: relative;
display: grid;
gap: 0.45rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-memory-meter::after {
content: "";
display: block;
height: 10px;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 999px;
background: #05070c;
}
.ash-memory-meter i {
position: absolute;
left: 1px;
right: auto;
bottom: 1px;
width: 0;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
transition: width 220ms ease;
}
.ash-name-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: end;
}
.ash-name-form label {
display: grid;
gap: 0.35rem;
color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
}
.ash-name-form input {
width: 100%;
border: 1px solid rgba(139, 211, 255, 0.24);
border-radius: 6px;
padding: 0.65rem;
background: #05070c;
color: var(--ash-text);
font: inherit;
}
.ash-rpg-root.is-lake-awake .rpg-dialogue,
.ash-rpg-root.is-lake-awake .rpg-panel {
border-color: rgba(219, 79, 98, 0.36);
}
.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
box-shadow:
0 0 0 4px #05070c,
0 0 38px rgba(219, 79, 98, 0.22);
}
.ash-rpg-root.is-ending .rpg-panel {
box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
}
@media (max-width: 760px) {
@@ -643,4 +820,13 @@
width: calc(50% - 0.4rem);
min-height: 160px;
}
.ash-choicebar,
.ash-name-form {
grid-template-columns: 1fr;
}
.ash-rpg-canvas {
min-height: auto;
}
}