Add standalone export for Two Rivers at Moonrise
All checks were successful
Build Org Website / build (push) Successful in 44s

This commit is contained in:
gitea-actions
2026-07-30 23:37:18 +01:00
parent 563d4b63cc
commit 4659edc658
186 changed files with 4799 additions and 8 deletions

View File

@@ -0,0 +1,70 @@
const ROOT = "/assets/games/two-rivers/data";
const FILES = {
assets: "assets.json",
characters: "characters.json",
locations: "locations.json",
items: "items.json",
quests: "quests.json",
achievements: "achievements.json",
endings: "endings.json",
dialogue: ["dialogue/story.json", "dialogue/scenes.json", "dialogue/chapters.json"]
};
export async function loadContent(onProgress = () => {}) {
const content = {};
const entries = Object.entries(FILES);
for (let index = 0; index < entries.length; index += 1) {
const [key, file] = entries[index];
const files = Array.isArray(file) ? file : [file];
const payloads = [];
for (const source of files) {
const response = await fetch(`${ROOT}/${source}`, {cache: "no-cache"});
if (!response.ok) throw new Error(`Could not load ${source} (${response.status})`);
payloads.push(await response.json());
}
content[key] = Array.isArray(file) ? Object.assign({}, ...payloads) : payloads[0];
onProgress(Math.round(((index + 1) / entries.length) * 100), files.at(-1));
}
const errors = validateContent(content);
if (errors.length) throw new Error(`Content validation failed: ${errors.join("; ")}`);
return Object.freeze(content);
}
export function validateContent(content) {
const errors = [];
["assets", "characters", "locations", "items", "quests", "achievements", "endings", "dialogue"].forEach((key) => {
if (!content[key] || typeof content[key] !== "object") errors.push(`missing ${key}`);
});
if (errors.length) return errors;
Object.entries(content.locations).forEach(([id, location]) => {
if (!location.name || !Array.isArray(location.hotspots)) errors.push(`invalid location ${id}`);
if (!content.assets.images.some((asset) => asset.id === location.background)) errors.push(`unknown background ${id}/${location.background}`);
location.hotspots?.forEach((spot) => {
if (!spot.id || typeof spot.x !== "number" || spot.x < 0 || spot.x > 1) errors.push(`invalid hotspot ${id}/${spot.id || "unknown"}`);
if (spot.dialogue && !content.dialogue[spot.dialogue]) errors.push(`unknown dialogue ${spot.dialogue}`);
if (spot.goto && !content.locations[spot.goto]) errors.push(`unknown location ${spot.goto}`);
});
});
const effectTypes = new Set(["relationship", "flag", "item", "achievement", "unlockCG", "bias", "quest", "ending", "goto", "discoverGift"]);
Object.entries(content.dialogue).forEach(([dialogueId, dialogue]) => {
if (!dialogue.nodes?.[dialogue.start]) errors.push(`invalid start ${dialogueId}`);
Object.entries(dialogue.nodes || {}).forEach(([nodeId, node]) => {
if (!node.text || !node.speaker) errors.push(`invalid node ${dialogueId}/${nodeId}`);
if (!content.characters[node.speaker]) errors.push(`unknown speaker ${dialogueId}/${nodeId}/${node.speaker}`);
if (node.next && !dialogue.nodes[node.next]) errors.push(`unknown next ${dialogueId}/${nodeId}`);
if (node.conditions && !Array.isArray(node.conditions)) errors.push(`invalid conditions ${dialogueId}/${nodeId}`);
node.choices?.forEach((choice) => {
if (!dialogue.nodes[choice.next]) errors.push(`unknown choice next ${dialogueId}/${nodeId}`);
if (choice.conditions && !Array.isArray(choice.conditions)) errors.push(`invalid choice conditions ${dialogueId}/${nodeId}`);
choice.effects?.forEach((effect) => {
if (!effectTypes.has(effect.type)) errors.push(`invalid effect ${effect.type}`);
});
});
node.effects?.forEach((effect) => {
if (!effectTypes.has(effect.type)) errors.push(`invalid effect ${effect.type}`);
});
});
});
if (Object.keys(content.endings).length !== 4) errors.push("exactly four endings required");
return errors;
}

View File

@@ -0,0 +1,109 @@
import {normalizeState} from "./state.mjs";
export const SAVE_KEY = "zxh_two_rivers_saves_v1";
export const SETTINGS_KEY = "zxh_two_rivers_settings_v1";
export const AGE_KEY = "zxh_two_rivers_age_gate_v1";
function checksum(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
}
export function createSaveService(storage = window.localStorage) {
let recovery = null;
const parseEnvelope = (raw, source) => {
if (!raw) return null;
try {
const envelope = JSON.parse(raw);
if (!envelope || typeof envelope !== "object") throw new Error("Save data is not an object");
if (envelope.version !== 1) throw new Error(`Unknown save version ${String(envelope.version)}`);
if (!envelope.slots || typeof envelope.slots !== "object") throw new Error("Save slots are missing");
return envelope;
} catch (error) {
recovery = {source, message: error.message};
return null;
}
};
const readEnvelope = () => {
recovery = null;
const primaryRaw = storage.getItem(SAVE_KEY);
if (!primaryRaw) {
const pending = parseEnvelope(storage.getItem(`${SAVE_KEY}:pending`), "pending");
if (pending) {
recovery = {source: "pending", message: "Recovered an interrupted save write."};
return pending;
}
return {version: 1, slots: {}};
}
const primary = parseEnvelope(primaryRaw, "primary");
if (primary) return primary;
const primaryIssue = recovery;
const backup = parseEnvelope(storage.getItem(`${SAVE_KEY}:backup`), "backup");
if (backup) {
recovery = {source: "backup", message: `Recovered the previous valid save. ${primaryIssue?.message || ""}`.trim()};
return backup;
}
recovery = primaryIssue || {source: "primary", message: "Save data could not be read."};
return {version: 1, slots: {}};
};
const writeEnvelope = (envelope) => {
const previous = storage.getItem(SAVE_KEY);
if (previous) storage.setItem(`${SAVE_KEY}:backup`, previous);
storage.setItem(`${SAVE_KEY}:pending`, JSON.stringify(envelope));
storage.setItem(SAVE_KEY, JSON.stringify(envelope));
storage.removeItem(`${SAVE_KEY}:pending`);
};
return {
list() {
return readEnvelope().slots;
},
recoveryInfo() {
readEnvelope();
return recovery ? {...recovery} : null;
},
save(slot, state) {
const normalized = normalizeState(state);
if (!normalized) throw new Error("Refused invalid save state");
const envelope = readEnvelope();
const payload = JSON.stringify(normalized);
envelope.slots[slot] = {
version: 1,
timestamp: new Date().toISOString(),
checksum: checksum(payload),
location: normalized.location,
playTimeSeconds: normalized.playTimeSeconds,
state: normalized
};
writeEnvelope(envelope);
return envelope.slots[slot];
},
load(slot) {
const record = readEnvelope().slots[slot];
if (!record?.state) return null;
const normalized = normalizeState(record.state);
if (!normalized || checksum(JSON.stringify(normalized)) !== record.checksum) return null;
return normalized;
},
hasSave() {
return Boolean(readEnvelope().slots.auto);
},
settings(value) {
if (value) storage.setItem(SETTINGS_KEY, JSON.stringify(value));
try { return JSON.parse(storage.getItem(SETTINGS_KEY) || "{}"); } catch (_error) { return {}; }
},
ageConfirmed() {
return storage.getItem(AGE_KEY) === "yes";
},
confirmAge() {
storage.setItem(AGE_KEY, "yes");
}
};
}

View File

@@ -0,0 +1,317 @@
export const RELATIONSHIPS = ["trust", "respect", "comfort", "humour", "adventure", "romance", "familyApproval"];
export const SAVE_VERSION = 1;
const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number(value) || 0));
const clone = (value) => JSON.parse(JSON.stringify(value));
export function freshState(settings = {}) {
return normalizeState({
version: SAVE_VERSION,
location: "courtyard",
checkpoint: "courtyard",
position: 0.14,
relationships: {
trust: 24, respect: 28, comfort: 18, humour: 20,
adventure: 22, romance: 12, familyApproval: 20
},
inventory: [],
gifts: [],
discoveredGifts: [],
flags: {},
choices: [],
quests: {
garland: {status: "active", count: 0, rewarded: false},
letters: {status: "locked", count: 0, rewarded: false}
},
puzzles: {},
minigames: {},
unlockedCGs: [],
achievements: [],
unlockedEndings: [],
readNodes: [],
dialogueHistory: [],
dialogue: null,
endingBias: {},
finalChoice: null,
currentEnding: null,
minigameCheckpoint: null,
playTimeSeconds: 0,
settings: {
sound: false,
masterVolume: 0.65,
musicVolume: 0.55,
ambienceVolume: 0.35,
sfxVolume: 0.7,
textSpeed: "normal",
autoDelay: 1800,
reducedMotion: matchMediaSafe("(prefers-reduced-motion: reduce)"),
highContrast: false,
assistedMinigames: false,
effectsQuality: "full",
...settings
}
});
}
function matchMediaSafe(query) {
return typeof window !== "undefined" && window.matchMedia ? window.matchMedia(query).matches : false;
}
export function normalizeState(candidate) {
if (!candidate || typeof candidate !== "object") return null;
const base = {
version: SAVE_VERSION,
location: String(candidate.location || "courtyard"),
checkpoint: String(candidate.checkpoint || candidate.location || "courtyard"),
position: clamp(candidate.position ?? 0.14, 0.04, 0.96),
relationships: {},
inventory: uniqueStrings(candidate.inventory, 80),
gifts: uniqueStrings(candidate.gifts, 6),
discoveredGifts: uniqueStrings(candidate.discoveredGifts, 6),
flags: cleanObject(candidate.flags),
choices: uniqueStrings(candidate.choices, 160),
quests: candidate.quests && typeof candidate.quests === "object" ? clone(candidate.quests) : {},
puzzles: cleanObject(candidate.puzzles),
minigames: candidate.minigames && typeof candidate.minigames === "object" ? clone(candidate.minigames) : {},
unlockedCGs: uniqueStrings(candidate.unlockedCGs, 16),
achievements: uniqueStrings(candidate.achievements, 24),
unlockedEndings: uniqueStrings(candidate.unlockedEndings, 4),
readNodes: uniqueStrings(candidate.readNodes, 400),
dialogueHistory: normalizeHistory(candidate.dialogueHistory),
dialogue: candidate.dialogue && typeof candidate.dialogue === "object" ? clone(candidate.dialogue) : null,
endingBias: candidate.endingBias && typeof candidate.endingBias === "object" ? clone(candidate.endingBias) : {},
finalChoice: typeof candidate.finalChoice === "string" ? candidate.finalChoice : null,
currentEnding: typeof candidate.currentEnding === "string" ? candidate.currentEnding : null,
minigameCheckpoint: typeof candidate.minigameCheckpoint === "string" ? candidate.minigameCheckpoint : null,
playTimeSeconds: clamp(candidate.playTimeSeconds, 0, 999999),
settings: {
sound: Boolean(candidate.settings?.sound),
masterVolume: clamp(candidate.settings?.masterVolume ?? 0.65, 0, 1),
musicVolume: clamp(candidate.settings?.musicVolume ?? 0.55, 0, 1),
ambienceVolume: clamp(candidate.settings?.ambienceVolume ?? 0.35, 0, 1),
sfxVolume: clamp(candidate.settings?.sfxVolume ?? 0.7, 0, 1),
textSpeed: ["slow", "normal", "fast", "instant"].includes(candidate.settings?.textSpeed) ? candidate.settings.textSpeed : "normal",
autoDelay: clamp(candidate.settings?.autoDelay ?? 1800, 800, 5000),
reducedMotion: Boolean(candidate.settings?.reducedMotion),
highContrast: Boolean(candidate.settings?.highContrast),
assistedMinigames: Boolean(candidate.settings?.assistedMinigames),
effectsQuality: ["full", "reduced", "minimal"].includes(candidate.settings?.effectsQuality) ? candidate.settings.effectsQuality : "full"
}
};
RELATIONSHIPS.forEach((key) => { base.relationships[key] = clamp(candidate.relationships?.[key] ?? 20); });
["garland", "letters"].forEach((id) => {
const value = base.quests[id] || {};
base.quests[id] = {
status: ["locked", "active", "complete"].includes(value.status) ? value.status : (id === "garland" ? "active" : "locked"),
count: clamp(value.count, 0, id === "garland" ? 5 : 3),
rewarded: Boolean(value.rewarded)
};
});
return base;
}
function uniqueStrings(value, cap) {
return Array.isArray(value)
? [...new Set(value.filter((item) => typeof item === "string" && /^[a-z0-9_:-]{1,80}$/i.test(item)))].slice(0, cap)
: [];
}
function cleanObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).filter(([key, item]) =>
/^[a-z0-9_:-]{1,80}$/i.test(key) && ["boolean", "number", "string"].includes(typeof item)).slice(0, 200));
}
function normalizeHistory(value) {
if (!Array.isArray(value)) return [];
return value.filter((entry) =>
entry && typeof entry === "object" &&
typeof entry.speaker === "string" && typeof entry.text === "string")
.slice(-200)
.map((entry) => ({
speaker: entry.speaker.slice(0, 80),
text: entry.text.slice(0, 1200)
}));
}
export function createStore(initial) {
let state = normalizeState(initial) || freshState();
const listeners = new Set();
return {
get: () => clone(state),
dispatch(command) {
state = reduce(state, command);
listeners.forEach((listener) => listener(clone(state), command));
return clone(state);
},
replace(next) {
const normalized = normalizeState(next);
if (!normalized) throw new Error("Invalid game state");
state = normalized;
listeners.forEach((listener) => listener(clone(state), {type: "replace"}));
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
}
export function reduce(current, command) {
const state = clone(current);
if (!command || typeof command.type !== "string") return normalizeState(state);
switch (command.type) {
case "relationship":
if (RELATIONSHIPS.includes(command.key)) {
state.relationships[command.key] = clamp(state.relationships[command.key] + Number(command.amount || 0));
if (command.key === "trust" && state.relationships.trust >= 75 && !state.achievements.includes("open_book")) state.achievements.push("open_book");
if (command.key === "familyApproval" && state.relationships.familyApproval >= 75 && !state.achievements.includes("royal_favourite")) state.achievements.push("royal_favourite");
}
break;
case "flag":
if (/^[a-z0-9_:-]+$/i.test(command.id)) state.flags[command.id] = command.value !== false;
break;
case "goto":
state.location = command.location;
state.checkpoint = command.location;
state.position = clamp(command.position ?? 0.08, 0.04, 0.96);
state.dialogue = null;
break;
case "position":
state.position = clamp(command.value, 0.04, 0.96);
break;
case "item":
if (!state.inventory.includes(command.id)) state.inventory.push(command.id);
break;
case "choice":
if (!state.choices.includes(command.id)) state.choices.push(command.id);
break;
case "read":
if (!state.readNodes.includes(command.id)) state.readNodes.push(command.id);
break;
case "history":
state.dialogueHistory.push({speaker: String(command.speaker || "").slice(0, 80), text: String(command.text || "").slice(0, 1200)});
state.dialogueHistory = state.dialogueHistory.slice(-200);
break;
case "dialogue":
state.dialogue = command.value ? clone(command.value) : null;
break;
case "questStart":
if (state.quests[command.id] && state.quests[command.id].status === "locked") state.quests[command.id].status = "active";
break;
case "questProgress":
progressQuest(state, command.id, command.item, command.target, command.reward);
break;
case "puzzle":
state.puzzles[command.id] = true;
state.flags[`${command.id}_solved`] = true;
break;
case "minigameStart":
state.minigameCheckpoint = command.id;
break;
case "minigame":
{
const previous = state.minigames[command.id];
const score = clamp(command.score, 0, 999);
state.minigames[command.id] = {
complete: true,
score: Math.max(previous?.score || 0, score),
perfect: Boolean(previous?.perfect || command.perfect),
attempts: clamp((previous?.attempts || 0) + 1, 1, 99)
};
state.minigameCheckpoint = null;
}
break;
case "gift":
if (!state.gifts.includes(command.id)) state.gifts.push(command.id);
break;
case "discoverGift":
if (!state.discoveredGifts.includes(command.id)) state.discoveredGifts.push(command.id);
break;
case "achievement":
if (!state.achievements.includes(command.id)) state.achievements.push(command.id);
break;
case "unlockCG":
if (!state.unlockedCGs.includes(command.id)) state.unlockedCGs.push(command.id);
break;
case "bias":
state.endingBias[command.id] = clamp((state.endingBias[command.id] || 0) + Number(command.amount || 0), 0, 50);
state.finalChoice = command.id;
break;
case "ending":
state.currentEnding = command.id;
if (!state.unlockedEndings.includes(command.id)) state.unlockedEndings.push(command.id);
break;
case "setting":
if (Object.hasOwn(state.settings, command.key)) state.settings[command.key] = command.value;
break;
case "tick":
state.playTimeSeconds = clamp(state.playTimeSeconds + Number(command.seconds || 0), 0, 999999);
break;
}
return normalizeState(state);
}
function progressQuest(state, id, item, target, reward = {}) {
const quest = state.quests[id];
if (!quest || quest.status === "complete" || state.inventory.includes(item)) return;
state.inventory.push(item);
if (quest.status === "locked") quest.status = "active";
quest.count = Math.min(target, quest.count + 1);
if (quest.count < target) return;
quest.status = "complete";
if (quest.rewarded) return;
quest.rewarded = true;
Object.entries(reward).forEach(([key, amount]) => {
if (RELATIONSHIPS.includes(key)) state.relationships[key] = clamp(state.relationships[key] + Number(amount));
});
if (reward.cg && !state.unlockedCGs.includes(reward.cg)) state.unlockedCGs.push(reward.cg);
const achievement = id === "garland" ? "garland_for_all" : "letters_home";
if (!state.achievements.includes(achievement)) state.achievements.push(achievement);
}
export function applyEffects(store, effects = []) {
effects.forEach((effect) => {
if (effect.type === "relationship") store.dispatch(effect);
else if (effect.type === "flag") store.dispatch(effect);
else if (effect.type === "item") store.dispatch(effect);
else if (effect.type === "achievement") store.dispatch(effect);
else if (effect.type === "unlockCG") store.dispatch(effect);
else if (effect.type === "bias") store.dispatch(effect);
else if (effect.type === "goto") store.dispatch({type: "goto", location: effect.location, position: effect.position});
else if (effect.type === "discoverGift") store.dispatch(effect);
else if (effect.type === "quest" && effect.action === "start") store.dispatch({type: "questStart", id: effect.id});
});
}
export function relationshipStage(value) {
if (value >= 75) return "Deep";
if (value >= 50) return "Warm";
if (value >= 25) return "Growing";
return "New";
}
export function resolveEnding(state, endings) {
const order = ["scholar", "laughter", "road", "families"];
const scores = Object.fromEntries(order.map((id) => {
const weighted = Object.entries(endings[id].weights).reduce((sum, [key, weight]) => sum + state.relationships[key] * weight, 0);
return [id, weighted + Number(state.endingBias[id] || 0)];
}));
return order.sort((a, b) =>
scores[b] - scores[a] ||
Number(Boolean(state.endingBias[b])) - Number(Boolean(state.endingBias[a])) ||
state.relationships.romance - state.relationships.romance ||
state.relationships.trust - state.relationships.trust ||
order.indexOf(a) - order.indexOf(b))[0];
}
export function requirementMet(state, requirement) {
const [kind, id] = requirement.split(":");
if (kind === "flag") return Boolean(state.flags[id]);
if (kind === "dialogue") return Boolean(state.flags[`dialogue_${id}`]);
if (kind === "minigame") return Boolean(state.minigames[id]?.complete);
if (kind === "item") return state.inventory.includes(id);
if (kind === "quest") return state.quests[id]?.status === "complete";
if (kind === "puzzle") return Boolean(state.puzzles[id]);
return false;
}

View File

@@ -0,0 +1,282 @@
import {loadContent} from "./core/content.mjs";
import {createStore, freshState} from "./core/state.mjs";
import {createSaveService} from "./core/save.mjs";
import {AudioDirector} from "./systems/audio.mjs";
import {Interface} from "./ui/interface.mjs";
import {createScenes} from "./scenes/game-scenes.mjs";
const root = document.querySelector("[data-two-rivers]");
if (root) {
start().catch((error) => fatal(error));
}
async function start() {
if (!window.Phaser || !window.gsap || !window.Howl) throw new Error("A required local game library did not load.");
const saves = createSaveService();
const loadingEl = root.querySelector("[data-tr-loading]");
const progress = root.querySelector("[data-tr-progress]");
const loadingCopy = root.querySelector("[data-tr-loading-copy]");
loadingEl.hidden = false;
const content = await loadContent((value, file) => {
progress.value = value * 0.4;
loadingCopy.textContent = `Reading ${file.replace(".json", "").replaceAll("_", " ")}`;
});
const savedSettings = saves.settings();
const store = createStore(freshState(savedSettings));
const audio = new AudioDirector(content.assets.audio, () => store.get().settings);
const ui = new Interface(root, store, content, saves, audio);
let game;
let ready = false;
let paused = false;
let lastTick = performance.now();
let recoveryShown = false;
const controller = {
content, store, saves, audio, ui,
touchDirection: 0,
loading(value, copy) {
progress.value = 40 + value * 0.6;
loadingCopy.textContent = copy;
},
assetError(src) {
ui.toast(`An artwork could not be loaded: ${src.split("/").pop()}`);
},
locationLoading(active, copy = "Painting the city…") {
loadingEl.hidden = !active;
if (active) {
progress.removeAttribute("value");
loadingCopy.textContent = copy;
} else {
progress.value = 100;
}
},
ready() {
ready = true;
loadingEl.hidden = true;
showGateOrMenu();
},
uiBusy() {
return !ui.dialogueEl.hidden || !ui.overlay.hidden || paused;
},
locationReady(location) {
paused = false;
root.classList.add("tr-exploring");
root.querySelector("[data-tr-hud]").hidden = false;
root.querySelector("[data-tr-chapter]").textContent = location.act;
root.querySelector("[data-tr-location]").textContent = location.name;
root.querySelector("[data-tr-objective]").textContent = location.objective;
audio.playLocation(location.music, location.ambience);
controller.autosave();
controller.interaction(null);
root.querySelector("#two-rivers-game").focus();
checkOrientation();
},
interaction(label) {
const element = root.querySelector("[data-tr-interact]");
element.hidden = !label;
if (label) element.querySelector("span").textContent = label;
},
dialogue(id, done) { ui.startDialogue(id, done); },
puzzle(id, done) { id === "flower_sequence" ? ui.flowerSequence(done) : ui.cipher(done); },
gifts(done) { ui.gifts(done); },
minigame(type, done) {
controller.pendingMiniDone = done;
store.dispatch({type: "minigameStart", id: type});
controller.autosave();
game.scene.pause("Story");
game.scene.start(type[0].toUpperCase() + type.slice(1));
},
runMiniOverlay(type, sceneDone) {
ui.minigame(type, () => {
sceneDone();
game.scene.resume("Story");
controller.pendingMiniDone?.();
controller.pendingMiniDone = null;
});
},
goto(location) {
store.dispatch({type: "goto", location, position: content.locations[location].spawn});
controller.autosave();
game.scene.stop("Story");
game.scene.start("Story");
},
ending() {
game.scene.stop("Story");
game.scene.start("Ending");
},
runEnding() {
ui.ending(() => {
root.classList.remove("tr-exploring");
root.querySelector("[data-tr-hud]").hidden = true;
checkOrientation();
game.scene.stop("Ending");
showMenu();
});
},
openJournal() {
if (!ready || ui.dialogue || !ui.overlay.hidden) return;
paused = true;
ui.onResume = () => { paused = false; ui.onResume = null; };
ui.journal();
},
pause() {
if (!ready || ui.dialogue || !ui.overlay.hidden) return;
paused = true;
ui.onResume = () => { paused = false; ui.onResume = null; };
ui.openPanel("Moonlit menu", `
<div class="tr-actions">
<button type="button" class="tr-primary" data-resume>Return to the story</button>
<button type="button" data-pause-journal>Journal</button>
<button type="button" data-pause-save>Save slots</button>
<button type="button" data-pause-settings>Settings</button>
<button type="button" data-pause-title>Return to title</button>
</div>`);
ui.panelBody.querySelector("[data-resume]").addEventListener("click", () => ui.closePanel());
ui.panelBody.querySelector("[data-pause-journal]").addEventListener("click", () => ui.journal());
ui.panelBody.querySelector("[data-pause-save]").addEventListener("click", () => ui.savesPanel(loadState));
ui.panelBody.querySelector("[data-pause-settings]").addEventListener("click", () => ui.settings());
ui.panelBody.querySelector("[data-pause-title]").addEventListener("click", () => {
ui.overlay.hidden = true;
game.scene.stop("Story");
root.querySelector("[data-tr-hud]").hidden = true;
checkOrientation();
showMenu();
});
},
toast(text) { ui.toast(text); },
autosave() { saves.save("auto", store.get()); }
};
const scenes = createScenes(controller);
game = new Phaser.Game({
type: Phaser.AUTO,
parent: "two-rivers-game",
width: 1280,
height: 720,
transparent: true,
render: {antialias: true, roundPixels: false, powerPreference: "high-performance"},
scale: {mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH},
audio: {noAudio: true},
scene: scenes
});
store.subscribe((state, command) => {
root.classList.toggle("tr-high-contrast", state.settings.highContrast);
if (command.type === "relationship") ui.toast(`${ui.observation(command.key, state.relationships[command.key])}`);
});
function showGateOrMenu() {
const age = root.querySelector("[data-tr-age]");
if (saves.ageConfirmed()) {
age.hidden = true;
showMenu();
} else {
age.hidden = false;
root.querySelector("[data-tr-age-confirm]").focus();
}
}
function showMenu() {
paused = false;
root.classList.remove("tr-exploring");
const menu = root.querySelector("[data-tr-menu]");
menu.hidden = false;
const continueButton = root.querySelector("[data-tr-continue]");
continueButton.disabled = !saves.hasSave();
window.gsap.fromTo(".tr-menu__copy > *", {y: 18, opacity: 0}, {
y: 0, opacity: 1, duration: store.get().settings.reducedMotion ? 0 : 0.55, stagger: 0.08, ease: "power2.out"
});
const recovery = saves.recoveryInfo();
if (recovery && !recoveryShown) {
recoveryShown = true;
window.setTimeout(() => ui.recovery(recovery), 120);
}
}
function hideMenu() {
root.querySelector("[data-tr-menu]").hidden = true;
}
function newGame() {
audio.unlock();
const settings = store.get().settings;
store.replace(freshState(settings));
hideMenu();
game.scene.stop("Story");
game.scene.start("Story");
window.setTimeout(() => controller.dialogue("arrival", () => {
store.dispatch({type: "flag", id: "done_lima_arrival"});
controller.autosave();
}), 420);
}
function loadState(state) {
audio.unlock();
store.replace(state);
hideMenu();
ui.overlay.hidden = true;
game.scene.stop("Story");
game.scene.start("Story");
if (state.dialogue?.id) window.setTimeout(() => controller.dialogue(state.dialogue.id, () => {}), 350);
else if (state.minigameCheckpoint) window.setTimeout(() => controller.minigame(state.minigameCheckpoint, () => {}), 500);
}
root.querySelector("[data-tr-age-confirm]").addEventListener("click", () => {
saves.confirmAge();
root.querySelector("[data-tr-age]").hidden = true;
audio.unlock();
showMenu();
});
root.querySelector("[data-tr-new]").addEventListener("click", newGame);
root.querySelector("[data-tr-continue]").addEventListener("click", () => {
const state = saves.load("auto");
if (state) loadState(state);
else ui.toast("No valid autosave was found.");
});
root.querySelector("[data-tr-journal]").addEventListener("click", () => controller.openJournal());
root.querySelector("[data-tr-save]").addEventListener("click", () => ui.savesPanel(loadState));
root.querySelector("[data-tr-pause]").addEventListener("click", () => controller.pause());
root.querySelector("[data-tr-interact-button]").addEventListener("click", () => game.scene.getScene("Story")?.interact());
root.querySelector("[data-tr-touch-interact]").addEventListener("click", () => game.scene.getScene("Story")?.interact());
root.querySelector("[data-tr-slots]").addEventListener("click", () => ui.savesPanel(loadState));
root.querySelector("[data-tr-gallery]").addEventListener("click", () => ui.gallery());
root.querySelector("[data-tr-achievements]")?.addEventListener("click", () => ui.achievements());
root.querySelector("[data-tr-settings]").addEventListener("click", () => ui.settings());
root.querySelector("[data-tr-credits]").addEventListener("click", () => ui.credits());
const bindDirection = (selector, direction) => {
const button = root.querySelector(selector);
["pointerdown", "touchstart"].forEach((eventName) => button.addEventListener(eventName, (event) => {
event.preventDefault();
controller.touchDirection = direction;
}, {passive: false}));
["pointerup", "pointercancel", "pointerleave", "touchend"].forEach((eventName) => button.addEventListener(eventName, () => {
controller.touchDirection = 0;
}));
};
bindDirection("[data-tr-left]", -1);
bindDirection("[data-tr-right]", 1);
if (matchMedia("(pointer: coarse)").matches) root.querySelector("[data-tr-touch]").hidden = false;
const checkOrientation = () => {
root.querySelector("[data-tr-rotate]").hidden = !(innerWidth < 700 && innerHeight > innerWidth && !root.querySelector("[data-tr-hud]").hidden);
};
addEventListener("resize", checkOrientation);
checkOrientation();
window.setInterval(() => {
const now = performance.now();
if (!document.hidden && ready && !root.querySelector("[data-tr-hud]").hidden) {
store.dispatch({type: "tick", seconds: Math.round((now - lastTick) / 1000)});
}
lastTick = now;
}, 5000);
}
function fatal(error) {
console.error(error);
const loading = root.querySelector("[data-tr-loading]");
loading.hidden = false;
loading.innerHTML = `<div><strong>The festival could not begin.</strong><p>${String(error.message || error)}</p><button type="button" onclick="location.reload()">Try again</button><a href="/play/play.html">Return to playroom</a></div>`;
}

View File

@@ -0,0 +1,224 @@
import {requirementMet} from "../core/state.mjs";
const WIDTH = 1280;
const HEIGHT = 720;
export function createScenes(controller) {
class BootScene extends Phaser.Scene {
constructor() { super("Boot"); }
create() {
this.scene.start("Preload");
}
}
class PreloadScene extends Phaser.Scene {
constructor() { super("Preload"); }
preload() {
const images = controller.content.assets.images.filter((record) =>
record.type === "character" || record.id === "courtyard");
images.forEach((record) => this.load.image(record.id, record.url));
this.load.on("progress", (value) => controller.loading(Math.round(value * 100), "Painting the city…"));
this.load.on("loaderror", (file) => controller.assetError(file.src));
}
create() {
controller.ready();
this.scene.sleep();
}
}
class StoryScene extends Phaser.Scene {
constructor() {
super("Story");
this.locked = false;
this.near = null;
this.move = 0;
this.velocity = 0;
}
create() {
this.location = controller.content.locations[controller.store.get().location];
const bundle = [this.location.background, ...(this.location.actors || []).map((actor) => actor.id)];
const missing = bundle.filter((id) => !this.textures.exists(id));
if (missing.length) {
controller.locationLoading(true, `Painting ${this.location.name}`);
missing.forEach((id) => {
const record = controller.content.assets.images.find((asset) => asset.id === id);
if (record) this.load.image(record.id, record.url);
});
this.load.once("complete", () => {
controller.locationLoading(false);
this.createWorld();
});
this.load.once("loaderror", (file) => controller.assetError(file.src));
this.load.start();
return;
}
this.createWorld();
}
createWorld() {
this.background = this.add.image(WIDTH / 2, HEIGHT / 2, this.location.background).setDisplaySize(WIDTH, HEIGHT);
this.background.setTint(this.location.background === "pavilion" ? 0xc9d5ff : 0xffffff);
const nightAlpha = this.location.act.startsWith("Act III") ? 0.2 : this.location.name === "Moon Garden" ? 0.1 : 0;
if (nightAlpha) this.add.rectangle(WIDTH / 2, HEIGHT / 2, WIDTH, HEIGHT, 0x18315f, nightAlpha).setDepth(2);
this.player = this.add.image(WIDTH * controller.store.get().position, 577, "z").setOrigin(0.5, 1).setDisplaySize(142, 300).setDepth(5);
this.companion = this.add.image(Math.max(160, this.player.x - 128), 577, "lima").setOrigin(0.5, 1).setDisplaySize(146, 305).setDepth(4);
this.location.actors?.forEach((actor, index) => {
this.add.image(WIDTH * actor.x, 577, actor.id)
.setOrigin(0.5, 1)
.setDisplaySize(118, 270)
.setDepth(3 + (index % 2) * 0.1);
});
this.hotspots = this.location.hotspots.map((definition) => ({
definition,
x: WIDTH * definition.x
}));
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys("W,A,S,D,E,ENTER,J,ESC");
this.input.on("pointerdown", (pointer) => {
if (this.locked || controller.uiBusy()) return;
const target = Phaser.Math.Clamp(pointer.x, 56, WIDTH - 56);
if (Math.abs(target - this.player.x) < 48) this.interact();
else this.walkTarget = target;
});
this.input.keyboard.on("keydown-E", () => this.interact());
this.input.keyboard.on("keydown-ENTER", () => this.interact());
this.input.keyboard.on("keydown-J", () => controller.openJournal());
this.input.keyboard.on("keydown-ESC", () => controller.pause());
controller.locationReady(this.location);
this.createForeground();
this.createAtmosphere();
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
controller.store.dispatch({type: "position", value: this.player.x / WIDTH});
});
}
createAtmosphere() {
if (controller.store.get().settings.effectsQuality === "minimal" || controller.store.get().settings.reducedMotion) return;
const texture = this.make.graphics({x: 0, y: 0, add: false});
texture.fillStyle(0xffe8a3, 1);
texture.fillCircle(5, 5, 4);
texture.generateTexture("tr-firefly", 10, 10);
texture.destroy();
const particles = this.add.particles(0, 0, "tr-firefly", {
x: {min: 0, max: WIDTH}, y: {min: 120, max: 580},
lifespan: {min: 2800, max: 5200}, speedX: {min: -6, max: 6}, speedY: {min: -7, max: 2},
alpha: {start: 0, ease: "Sine.easeInOut", yoyo: true, end: 0.8},
scale: {start: 0.25, end: 0.7}, frequency: controller.store.get().settings.effectsQuality === "full" ? 420 : 850,
blendMode: "ADD"
}).setDepth(3);
if (!particles) return;
}
createForeground() {
const foreground = this.add.graphics().setDepth(8);
foreground.fillStyle(this.location.name === "Royal Library" ? 0x16110d : 0x071c17, 0.72);
foreground.fillEllipse(30, 710, 250, 170);
foreground.fillEllipse(1245, 710, 290, 190);
foreground.fillStyle(0x102d25, 0.58);
for (let index = 0; index < 8; index += 1) {
foreground.fillEllipse(index < 4 ? 42 + index * 25 : 1160 + index * 18, 620 + (index % 3) * 34, 44, 115);
}
}
update(_time, delta) {
if (!this.player || !this.cursors || this.locked || controller.uiBusy()) return;
const left = this.cursors.left.isDown || this.keys.A.isDown;
const right = this.cursors.right.isDown || this.keys.D.isDown;
if (left || right) {
this.walkTarget = null;
this.move = left ? -1 : 1;
} else if (this.walkTarget != null) {
const distance = this.walkTarget - this.player.x;
this.move = Math.abs(distance) < 7 ? 0 : Math.sign(distance);
if (!this.move) this.walkTarget = null;
} else this.move = controller.touchDirection;
const targetVelocity = this.move * 0.31;
this.velocity = Phaser.Math.Linear(this.velocity, targetVelocity, Math.min(1, delta / (this.move ? 130 : 90)));
if (Math.abs(this.velocity) < 0.008 && !this.move) this.velocity = 0;
this.player.x = Phaser.Math.Clamp(this.player.x + this.velocity * delta, 52, WIDTH - 52);
if (this.velocity) this.player.scaleX = Math.abs(this.player.scaleX) * (this.velocity < 0 ? -1 : 1);
const companionTarget = this.player.x - 105 * (this.velocity < 0 ? -1 : 1);
this.companion.x = Phaser.Math.Linear(this.companion.x, companionTarget, Math.min(1, delta / 260));
this.companion.scaleX = Math.abs(this.companion.scaleX) * (this.player.x < this.companion.x ? -1 : 1);
const bob = this.velocity ? Math.sin(performance.now() / 115) * 3 : Math.sin(performance.now() / 600) * 1.1;
this.player.y = 577 + bob;
this.companion.y = 577 - bob * 0.55;
if (this.velocity && _time > (this.nextFootstep || 0)) {
controller.audio.sfx("footstep");
this.nextFootstep = _time + 390;
}
if (!controller.store.get().settings.reducedMotion) this.background.x = WIDTH / 2 - (this.player.x - WIDTH / 2) * 0.025;
this.updateNearest();
}
updateNearest() {
const state = controller.store.get();
const candidates = this.hotspots.filter(({definition, x}) =>
(!definition.once || !state.flags[`done_${definition.id}`]) &&
(definition.requires || []).every((requirement) => requirementMet(state, requirement)) &&
Math.abs(x - this.player.x) < 92);
const priority = (definition) => {
if (Number.isFinite(definition.priority)) return definition.priority;
if (definition.dialogue || definition.puzzle || definition.minigame) return 0;
if (definition.gift) return 1;
if (definition.collect) return 2;
return 3;
};
candidates.sort((a, b) => priority(a.definition) - priority(b.definition) || Math.abs(a.x - this.player.x) - Math.abs(b.x - this.player.x));
const near = candidates[0] || null;
if (near?.definition.id !== this.near?.definition.id) {
this.near = near;
controller.interaction(near?.definition.label || null);
}
}
interact() {
if (!this.near || this.locked || controller.uiBusy()) return;
const definition = this.near.definition;
this.locked = true;
controller.interaction(null);
const release = () => {
if (definition.once) controller.store.dispatch({type: "flag", id: `done_${definition.id}`});
this.locked = false;
this.updateNearest();
};
if (definition.dialogue) controller.dialogue(definition.dialogue, (ending) => {
if (ending) {
controller.ending();
return;
}
release();
});
else if (definition.goto) controller.goto(definition.goto);
else if (definition.puzzle) controller.puzzle(definition.puzzle, release);
else if (definition.minigame) controller.minigame(definition.minigame, release);
else if (definition.gift) controller.gifts(release);
else if (definition.collect) {
const quest = controller.content.quests[definition.quest];
const before = controller.store.get().quests[definition.quest].status;
controller.store.dispatch({
type: "questProgress", id: definition.quest, item: definition.collect,
target: quest.target, reward: quest.reward
});
controller.audio.sfx("pickup");
const after = controller.store.get().quests[definition.quest].status;
controller.toast(after === "complete" && before !== "complete" ? `${quest.name} complete.` : `${definition.label.replace("Gather ", "").replace("Recover ", "")} added to the journal.`);
controller.autosave();
release();
} else release();
}
}
class ArcheryScene extends Phaser.Scene {
constructor() { super("Archery"); }
create() { controller.runMiniOverlay("archery", () => this.scene.stop()); }
}
class CookingScene extends Phaser.Scene {
constructor() { super("Cooking"); }
create() { controller.runMiniOverlay("cooking", () => this.scene.stop()); }
}
class DanceScene extends Phaser.Scene {
constructor() { super("Dance"); }
create() { controller.runMiniOverlay("dance", () => this.scene.stop()); }
}
class EndingScene extends Phaser.Scene {
constructor() { super("Ending"); }
create() { controller.runEnding(); }
}
return [BootScene, PreloadScene, StoryScene, ArcheryScene, CookingScene, DanceScene, EndingScene];
}

View File

@@ -0,0 +1,77 @@
export class AudioDirector {
constructor(records, getSettings) {
this.getSettings = getSettings;
this.records = records;
this.sounds = new Map();
this.music = null;
this.ambience = null;
this.unlocked = false;
records.forEach((record) => {
this.sounds.set(record.id, new window.Howl({
src: record.urls || [record.url],
loop: record.type !== "sfx",
preload: record.type === "sfx",
volume: 0
}));
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) window.Howler?.mute(true);
else if (this.getSettings().sound) window.Howler?.mute(false);
});
}
unlock() {
this.unlocked = true;
window.Howler?.ctx?.resume?.();
this.sync();
}
sync() {
const settings = this.getSettings();
window.Howler.volume(settings.masterVolume);
window.Howler.mute(!settings.sound);
if (this.music) this.sounds.get(this.music)?.volume(settings.musicVolume);
if (this.ambience) this.sounds.get(this.ambience)?.volume(settings.ambienceVolume * 0.35);
}
playLocation(music, ambience) {
if (!this.unlocked) return;
this.crossfade("music", music);
this.crossfade("ambience", ambience);
}
crossfade(channel, nextId) {
const currentId = this[channel];
if (currentId === nextId) return;
const settings = this.getSettings();
const target = channel === "music" ? settings.musicVolume : settings.ambienceVolume * 0.35;
if (currentId && this.sounds.has(currentId)) {
const old = this.sounds.get(currentId);
old.fade(old.volume(), 0, 500);
window.setTimeout(() => old.stop(), 520);
}
this[channel] = nextId;
const next = this.sounds.get(nextId);
if (next) {
next.volume(0);
if (!next.playing()) next.play();
next.fade(0, target, 700);
}
}
sfx(id) {
if (!this.unlocked || !this.getSettings().sound) return;
const sound = this.sounds.get(id);
if (sound) {
sound.volume(this.getSettings().sfxVolume);
sound.play();
}
}
duck(active) {
if (!this.music) return;
const sound = this.sounds.get(this.music);
const volume = this.getSettings().musicVolume * (active ? 0.45 : 1);
sound?.fade(sound.volume(), volume, 250);
}
}

View File

@@ -0,0 +1,659 @@
import {applyEffects, relationshipStage, RELATIONSHIPS, requirementMet, resolveEnding} from "../core/state.mjs";
const LABELS = {
trust: "Trust", respect: "Respect", comfort: "Comfort", humour: "Humour",
adventure: "Adventure", romance: "Romance", familyApproval: "Family approval"
};
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (match) =>
({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#039;"}[match]));
export class Interface {
constructor(root, store, content, saves, audio) {
this.root = root;
this.store = store;
this.content = content;
this.saves = saves;
this.audio = audio;
this.overlay = root.querySelector("[data-tr-overlay]");
this.panel = root.querySelector("[data-tr-panel]");
this.panelTitle = root.querySelector("[data-tr-panel-title]");
this.panelKicker = root.querySelector("[data-tr-panel-kicker]");
this.panelBody = root.querySelector("[data-tr-panel-body]");
this.dialogueEl = root.querySelector("[data-tr-dialogue]");
this.dialogue = null;
this.typing = null;
this.fullText = "";
this.displayed = "";
this.auto = false;
this.autoTimer = null;
this.previousFocus = null;
this.onDialogueDone = null;
this.onResume = null;
root.querySelector("[data-tr-close]").addEventListener("click", () => this.closePanel());
root.querySelector("[data-tr-advance]").addEventListener("click", () => this.advance());
root.querySelector("[data-tr-auto]").addEventListener("click", (event) => this.toggleAuto(event.currentTarget));
root.querySelector("[data-tr-history]").addEventListener("click", () => this.showHistory());
root.querySelector("[data-tr-skip]").addEventListener("click", () => this.skipRead());
root.addEventListener("keydown", (event) => this.trapFocus(event));
}
announce(text) {
this.root.querySelector("[data-tr-live]").textContent = text;
}
toast(text) {
const toast = document.createElement("p");
toast.textContent = text;
this.root.querySelector("[data-tr-toasts]").appendChild(toast);
window.gsap.fromTo(toast, {y: 18, opacity: 0}, {y: 0, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.35});
window.setTimeout(() => window.gsap.to(toast, {opacity: 0, duration: 0.25, onComplete: () => toast.remove()}), 2600);
}
openPanel(title, html, kicker = "Two Rivers at Moonrise", closable = true) {
this.previousFocus = document.activeElement;
this.panel.onkeydown = null;
this.panelTitle.textContent = title;
this.panelKicker.textContent = kicker;
this.panelBody.innerHTML = html;
this.root.querySelector("[data-tr-close]").hidden = !closable;
this.overlay.hidden = false;
window.gsap.fromTo(this.panel, {scale: 0.97, opacity: 0}, {scale: 1, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.25});
requestAnimationFrame(() => this.panelBody.querySelector("button, input, select, a")?.focus());
}
trapFocus(event) {
if (event.key !== "Tab") return;
const region = !this.overlay.hidden ? this.panel : !this.dialogueEl.hidden ? this.dialogueEl : null;
if (!region) return;
const focusable = [...region.querySelectorAll("button:not([disabled]):not([hidden]), input:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])")]
.filter((element) => element.getClientRects().length);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
closePanel() {
if (this.root.querySelector("[data-tr-close]").hidden) return;
this.overlay.hidden = true;
this.panelBody.innerHTML = "";
this.previousFocus?.focus?.();
this.onResume?.();
}
journal() {
const state = this.store.get();
const relationships = RELATIONSHIPS.map((key) =>
`<li><span>${LABELS[key]}</span><strong>${relationshipStage(state.relationships[key])}</strong><small>${this.observation(key, state.relationships[key])}</small></li>`).join("");
const quests = Object.entries(this.content.quests).map(([id, quest]) => {
const progress = state.quests[id];
return `<li><span>${escapeHtml(quest.name)}</span><strong>${progress.status === "complete" ? "Complete" : `${progress.count}/${quest.target}`}</strong><small>${escapeHtml(quest.description)}</small></li>`;
}).join("");
const clues = state.inventory.map((id) => `<span class="tr-chip">${escapeHtml(id.replaceAll("_", " "))}</span>`).join("");
const gifts = this.content.items.gifts.filter((gift) => state.discoveredGifts.includes(gift.id))
.map((gift) => `<li><span>${escapeHtml(gift.name)}</span><small>${escapeHtml(gift.hint)}</small></li>`).join("");
this.openPanel("Z's Journal", `
<div class="tr-tabs">
<section><h3>What is growing</h3><ul class="tr-ledger">${relationships}</ul></section>
<section><h3>Festival errands</h3><ul class="tr-ledger">${quests}</ul></section>
<section><h3>Clues and keepsakes</h3><div class="tr-chips">${clues || "<p>The pockets are, for once, empty.</p>"}</div></section>
<section><h3>Gift ideas noticed</h3><ul class="tr-ledger">${gifts || "<li><small>Listen closely; Lima reveals what she values.</small></li>"}</ul></section>
</div>`, "Private observations · numbers are deliberately omitted");
}
observation(key, value) {
const stage = relationshipStage(value);
const copy = {
trust: {New: "Careful truths.", Growing: "Candour is becoming easy.", Warm: "She trusts your judgement.", Deep: "Nothing important is hidden."},
respect: {New: "Testing each other's measure.", Growing: "Ideas receive real attention.", Warm: "Disagreement feels generous.", Deep: "A partnership of equals."},
comfort: {New: "Formal edges remain.", Growing: "Silences feel less guarded.", Warm: "Nearness feels natural.", Deep: "Home has become a person."},
humour: {New: "A smile, quickly hidden.", Growing: "Private jokes are forming.", Warm: "Dignity is frequently endangered.", Deep: "Laughter needs no explanation."},
adventure: {New: "A cautious first step.", Growing: "Curiosity is shared.", Warm: "The road calls to both.", Deep: "Every horizon looks possible."},
romance: {New: "An inconvenient spark.", Growing: "Glances linger.", Warm: "Neither is pretending now.", Deep: "Moonrise has chosen sides."},
familyApproval: {New: "The family is observing.", Growing: "Warmth replaces ceremony.", Warm: "A place is being made.", Deep: "Two tables are becoming one."}
};
return copy[key][stage];
}
settings() {
const settings = this.store.get().settings;
const checked = (key) => settings[key] ? "checked" : "";
this.openPanel("Settings", `
<form class="tr-settings" data-settings-form>
<label><span>Sound</span><input type="checkbox" name="sound" ${checked("sound")}></label>
<label><span>Master volume</span><input type="range" name="masterVolume" min="0" max="1" step=".05" value="${settings.masterVolume}"></label>
<label><span>Music</span><input type="range" name="musicVolume" min="0" max="1" step=".05" value="${settings.musicVolume}"></label>
<label><span>Ambience</span><input type="range" name="ambienceVolume" min="0" max="1" step=".05" value="${settings.ambienceVolume}"></label>
<label><span>Effects</span><input type="range" name="sfxVolume" min="0" max="1" step=".05" value="${settings.sfxVolume}"></label>
<label><span>Text speed</span><select name="textSpeed">${["slow", "normal", "fast", "instant"].map((value) => `<option ${settings.textSpeed === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
<label><span>Reduced motion</span><input type="checkbox" name="reducedMotion" ${checked("reducedMotion")}></label>
<label><span>High contrast</span><input type="checkbox" name="highContrast" ${checked("highContrast")}></label>
<label><span>Assisted minigames</span><input type="checkbox" name="assistedMinigames" ${checked("assistedMinigames")}></label>
<label><span>Effects quality</span><select name="effectsQuality">${["full", "reduced", "minimal"].map((value) => `<option ${settings.effectsQuality === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
</form>`);
this.panelBody.querySelectorAll("input, select").forEach((control) => control.addEventListener("change", () => {
const value = control.type === "checkbox" ? control.checked : control.type === "range" ? Number(control.value) : control.value;
this.store.dispatch({type: "setting", key: control.name, value});
this.saves.settings(this.store.get().settings);
this.root.classList.toggle("tr-high-contrast", this.store.get().settings.highContrast);
this.audio.sync();
}));
}
credits() {
this.openPanel("Credits", `
<p class="tr-panel-lede">An original, locally hosted romantic adventure created for zainezq.com.</p>
<ul class="tr-ledger">
<li><span>Story and direction</span><strong>Two Rivers at Moonrise</strong></li>
<li><span>Engine</span><strong>Phaser 3.90.0</strong></li>
<li><span>Interface</span><strong>Tailwind CSS 4</strong></li>
<li><span>Motion</span><strong>GSAP 3.13.0</strong></li>
<li><span>Audio</span><strong>Howler.js 2.2.4</strong></li>
<li><span>Artwork</span><strong>Original AI-assisted painterly assets</strong></li>
</ul>
<p>Bengali and Pakistani influences are treated as distinct sources of clothing, food, architecture, and visual language inside a wholly fictional fantasy setting.</p>`);
}
gallery() {
const state = this.store.get();
const cards = ["garland", "library", "scholar", "laughter", "road", "families"].map((id) => {
const unlocked = state.unlockedCGs.includes(id) || state.unlockedEndings.includes(id);
return `<figure class="${unlocked ? "" : "is-locked"}">${unlocked ? `<img src="/assets/games/two-rivers/images/cg/${id}.webp" alt="${escapeHtml(id)}">` : "<div>?</div>"}<figcaption>${escapeHtml(id.replaceAll("_", " "))}</figcaption></figure>`;
}).join("");
this.openPanel("Moonlit Gallery", `<div class="tr-gallery">${cards}</div>`, `${state.unlockedCGs.length + state.unlockedEndings.length} memories found`);
}
achievements() {
const state = this.store.get();
const rows = Object.entries(this.content.achievements).map(([id, description]) =>
`<li><span>${state.achievements.includes(id) ? "✦" : "◇"} ${escapeHtml(id.replaceAll("_", " "))}</span><small>${state.achievements.includes(id) ? escapeHtml(description) : "Not yet discovered"}</small></li>`).join("");
this.openPanel("Achievements", `<ul class="tr-ledger">${rows}</ul>`, `${state.achievements.length}/${Object.keys(this.content.achievements).length} earned`);
}
recovery(info) {
this.openPanel("Save recovery", `
<p class="tr-panel-lede">${escapeHtml(info.message)}</p>
<p>Your current game can still begin safely. A recovery copy was used when available; existing Princess Lima saves were not touched.</p>
<button type="button" class="tr-primary" data-recovery-close>Continue</button>`, "A save needed attention", false);
this.panelBody.querySelector("[data-recovery-close]").addEventListener("click", () => {
this.overlay.hidden = true;
this.panelBody.innerHTML = "";
});
}
savesPanel(onLoad) {
const slots = this.saves.list();
const state = this.store.get();
this.openPanel("Save slots", `<div class="tr-save-slots">${["slot1", "slot2", "slot3"].map((slot, index) => {
const record = slots[slot];
return `<article><div><span>Slot ${index + 1}</span><strong>${record ? escapeHtml(this.content.locations[record.location]?.name || record.location) : "Empty"}</strong><small>${record ? new Date(record.timestamp).toLocaleString() : "A fresh page"}</small></div>
<button type="button" data-save-slot="${slot}">Save</button>
<button type="button" data-load-slot="${slot}" ${record ? "" : "disabled"}>Load</button></article>`;
}).join("")}</div>`);
this.panelBody.querySelectorAll("[data-save-slot]").forEach((button) => button.addEventListener("click", () => {
this.saves.save(button.dataset.saveSlot, state);
this.toast("Story saved.");
this.savesPanel(onLoad);
}));
this.panelBody.querySelectorAll("[data-load-slot]").forEach((button) => button.addEventListener("click", () => {
const loaded = this.saves.load(button.dataset.loadSlot);
if (loaded) {
this.overlay.hidden = true;
onLoad(loaded);
} else this.toast("That save could not be recovered.");
}));
}
startDialogue(id, done) {
const definition = this.content.dialogue[id];
if (!definition) return done?.();
this.dialogue = {id, node: this.store.get().dialogue?.id === id ? this.store.get().dialogue.node : definition.start};
this.previousFocus = document.activeElement;
this.onDialogueDone = done;
this.dialogueEl.hidden = false;
this.audio.duck(true);
this.renderDialogue();
}
renderDialogue() {
window.clearTimeout(this.autoTimer);
const definition = this.content.dialogue[this.dialogue.id];
let node = definition.nodes[this.dialogue.node];
while (node?.conditions && !node.conditions.every((condition) => requirementMet(this.store.get(), condition))) {
this.dialogue.node = node.else || node.next;
node = definition.nodes[this.dialogue.node];
}
if (!node) return this.finishDialogue();
this.audio.sfx("page-turn");
const character = this.content.characters[node.speaker] || {name: node.speaker};
const nodeKey = `${this.dialogue.id}:${this.dialogue.node}`;
this.store.dispatch({type: "read", id: nodeKey});
this.store.dispatch({type: "dialogue", value: this.dialogue});
this.store.dispatch({type: "history", speaker: character.name, text: node.text});
this.root.querySelector("[data-tr-speaker]").textContent = character.name;
const portrait = this.root.querySelector("[data-tr-portrait]");
const portraitId = this.content.assets.images.some((asset) => ["character", "npc"].includes(asset.type) && asset.id === node.speaker) ? node.speaker : (node.speaker === "z" ? "z" : "lima");
portrait.style.backgroundImage = `url('/assets/games/two-rivers/images/characters/${portraitId}.webp')`;
portrait.setAttribute("aria-label", `${character.name}, ${node.expression || "speaking"}`);
const choices = this.root.querySelector("[data-tr-choices]");
choices.innerHTML = "";
this.typeLine(node.text);
const availableChoices = node.choices?.filter((choice) =>
!choice.conditions || choice.conditions.every((condition) => requirementMet(this.store.get(), condition)));
this.activeChoices = availableChoices || null;
this.root.querySelector("[data-tr-advance]").hidden = Boolean(availableChoices?.length);
if (availableChoices?.length) {
availableChoices.forEach((choice, index) => {
const button = document.createElement("button");
button.type = "button";
button.innerHTML = `<span>${index + 1}</span>${escapeHtml(choice.text)}`;
button.addEventListener("click", () => this.choose(index));
choices.appendChild(button);
});
}
this.announce(`${character.name}: ${node.text}`);
window.gsap.fromTo(this.dialogueEl, {y: 20, opacity: 0}, {y: 0, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.28});
requestAnimationFrame(() => (availableChoices?.length ? choices.querySelector("button") : this.root.querySelector("[data-tr-advance]"))?.focus());
}
typeLine(text) {
window.clearInterval(this.typing);
this.fullText = text;
this.displayed = "";
const target = this.root.querySelector("[data-tr-line]");
const speed = {slow: 38, normal: 24, fast: 10, instant: 0}[this.store.get().settings.textSpeed];
if (!speed || this.store.get().settings.reducedMotion) {
target.textContent = text;
this.displayed = text;
return;
}
let index = 0;
target.textContent = "";
this.typing = window.setInterval(() => {
index += 1;
this.displayed = text.slice(0, index);
target.textContent = this.displayed;
if (index >= text.length) {
window.clearInterval(this.typing);
this.queueAuto();
}
}, speed);
}
completeLine() {
if (this.displayed === this.fullText) return false;
window.clearInterval(this.typing);
this.displayed = this.fullText;
this.root.querySelector("[data-tr-line]").textContent = this.fullText;
this.queueAuto();
return true;
}
advance() {
if (!this.dialogue || this.completeLine()) return;
const definition = this.content.dialogue[this.dialogue.id];
const node = definition.nodes[this.dialogue.node];
if (this.activeChoices?.length) return;
applyEffects(this.store, node.effects);
if (node.effects?.some((effect) => effect.type === "ending")) {
this.finishDialogue(true);
return;
}
if (node.next) {
this.dialogue.node = node.next;
this.renderDialogue();
} else this.finishDialogue();
}
choose(index) {
const definition = this.content.dialogue[this.dialogue.id];
const node = definition.nodes[this.dialogue.node];
const choice = this.activeChoices?.[index];
if (!choice) return;
const choiceId = `${this.dialogue.id}:${this.dialogue.node}:${choice.id || choice.next || index}`;
if (!this.store.get().choices.includes(choiceId)) {
applyEffects(this.store, choice.effects);
this.store.dispatch({type: "choice", id: choiceId});
}
this.audio.sfx("ui");
this.dialogue.node = choice.next;
this.saves.save("auto", this.store.get());
this.renderDialogue();
}
finishDialogue(ending = false) {
window.clearInterval(this.typing);
window.clearTimeout(this.autoTimer);
const id = this.dialogue?.id;
this.dialogue = null;
this.dialogueEl.hidden = true;
this.store.dispatch({type: "dialogue", value: null});
if (id) this.store.dispatch({type: "flag", id: `dialogue_${id}`});
this.audio.duck(false);
this.previousFocus?.focus?.();
this.saves.save("auto", this.store.get());
const done = this.onDialogueDone;
this.onDialogueDone = null;
done?.(ending);
}
toggleAuto(button) {
this.auto = !this.auto;
button.setAttribute("aria-pressed", String(this.auto));
button.classList.toggle("is-active", this.auto);
if (this.auto) this.queueAuto();
else window.clearTimeout(this.autoTimer);
}
queueAuto() {
if (!this.auto || !this.dialogue) return;
const node = this.content.dialogue[this.dialogue.id].nodes[this.dialogue.node];
if (this.activeChoices?.length) return;
window.clearTimeout(this.autoTimer);
this.autoTimer = window.setTimeout(() => this.advance(), this.store.get().settings.autoDelay);
}
skipRead() {
if (!this.dialogue) return;
const key = `${this.dialogue.id}:${this.dialogue.node}`;
const node = this.content.dialogue[this.dialogue.id].nodes[this.dialogue.node];
if (this.store.get().readNodes.includes(key) && !node.choices) {
this.completeLine();
this.advance();
} else this.toast("Only previously read lines can be skipped.");
}
showHistory() {
const list = this.store.get().dialogueHistory.map((entry) => `<li><strong>${escapeHtml(entry.speaker)}</strong><p>${escapeHtml(entry.text)}</p></li>`).join("");
this.openPanel("Conversation history", `<ol class="tr-history">${list}</ol>`);
}
cipher(done) {
const answer = ["Dawn", "Rain", "Lantern", "Moon"];
let progress = [];
const render = () => {
this.openPanel("The lantern cipher", `
<p class="tr-panel-lede">The indigo knot named an order. Touch the carved shelf emblems from first light to moonrise.</p>
<div class="tr-puzzle-sequence">${answer.map((word) => `<button type="button" data-symbol="${word}">${word}</button>`).join("")}</div>
<p data-puzzle-progress>${progress.length ? progress.join(" · ") : "The lantern waits."}</p>
${this.store.get().settings.assistedMinigames ? "<small>Assisted hint: Dawn → Rain → Lantern → Moon</small>" : ""}`, "Library puzzle", false);
this.panelBody.querySelectorAll("[data-symbol]").forEach((button) => button.addEventListener("click", () => {
const value = button.dataset.symbol;
if (value === answer[progress.length]) {
progress.push(value);
this.audio.sfx("puzzle");
if (progress.length === answer.length) {
this.store.dispatch({type: "puzzle", id: "cipher"});
this.store.dispatch({type: "achievement", id: "lantern_reader"});
this.store.dispatch({type: "item", id: "lantern_rubbing"});
this.overlay.hidden = true;
this.toast("The shelves answer with a hidden garden map.");
this.saves.save("auto", this.store.get());
done();
} else render();
} else {
progress = [];
this.toast("The lanterns dim. Begin again.");
render();
}
}));
};
render();
}
flowerSequence(done) {
const answer = ["Jasmine", "Tuberose", "Marigold", "Bakul"];
const symbols = ["Bakul", "Jasmine", "Marigold", "Tuberose"];
let progress = [];
const render = () => {
this.openPanel("The founders' flower mosaic", `
<p class="tr-panel-lede">Four carved blooms follow the old festival day: welcome at dawn, fragrance at dusk, fire at the feast, memory at moonrise.</p>
<div class="tr-puzzle-sequence">${symbols.map((flower) => `<button type="button" data-flower="${flower}">${flower}</button>`).join("")}</div>
<p data-puzzle-progress>${progress.length ? progress.join(" · ") : "The stone petals wait beneath your hand."}</p>
${this.store.get().settings.assistedMinigames ? "<small>Assisted hint: Jasmine → Tuberose → Marigold → Bakul</small>" : ""}`,
"Moon garden puzzle · untimed", false);
this.panelBody.querySelectorAll("[data-flower]").forEach((button) => button.addEventListener("click", () => {
const value = button.dataset.flower;
if (value === answer[progress.length]) {
progress.push(value);
this.audio.sfx("puzzle");
if (progress.length === answer.length) {
this.store.dispatch({type: "puzzle", id: "flower_sequence"});
this.store.dispatch({type: "item", id: "anwara_note"});
this.overlay.hidden = true;
this.toast("The mosaic opens around a note and an archer's brass token.");
this.saves.save("auto", this.store.get());
done();
} else render();
} else {
progress = [];
this.toast("The carved vine returns to its beginning.");
render();
}
}));
};
render();
}
gifts(done) {
const discovered = this.store.get().discoveredGifts;
const available = this.content.items.gifts.filter((gift) => discovered.includes(gift.id));
const cards = available.map((gift) =>
`<button type="button" class="tr-gift" data-gift="${gift.id}"><img src="/assets/games/two-rivers/images/items/${gift.id}.webp" alt=""><strong>${escapeHtml(gift.name)}</strong><small>${escapeHtml(gift.hint)}</small></button>`).join("");
this.openPanel("A gift for Lima", `<p class="tr-panel-lede">Choose from the ideas Z noticed in conversation. No sincere gift is punished, and only one gift is exchanged in this act.</p><div class="tr-gifts">${cards}</div>`, "Old marketplace", false);
this.panelBody.querySelectorAll("[data-gift]").forEach((button) => button.addEventListener("click", () => {
const gift = this.content.items.gifts.find((item) => item.id === button.dataset.gift);
if (!this.store.get().gifts.includes(gift.id)) {
Object.entries(gift.effects).forEach(([key, amount]) => this.store.dispatch({type: "relationship", key, amount}));
this.store.dispatch({type: "gift", id: gift.id});
this.store.dispatch({type: "achievement", id: "thoughtful_gift"});
}
this.overlay.hidden = true;
this.toast(`Lima accepts the ${gift.name.toLowerCase()} with a smile that lingers.`);
this.saves.save("auto", this.store.get());
done();
}));
}
minigame(type, done) {
const instructions = {
archery: {
title: "Moonlit Archery",
copy: "Five arrows, one moving sight. Press Space or tap Loose when the glint reaches the gold centre. Your result changes the banter, never the route.",
input: "Keyboard: Space · Pointer/touch: Loose button · Pause is available between shots."
},
cooking: {
title: "The Festival Kitchen",
copy: "Choose four ingredients in the order hidden by Nadia's rhyme: foundation, gold, brightness, green. There is no timer.",
input: "Keyboard: number keys 14 · Pointer/touch: ingredient buttons."
},
dance: {
title: "Dance at Moonrise",
copy: "Follow twelve directional cues. A missed step earns different dialogue, not failure, and the sequence is untimed.",
input: "Keyboard: arrow keys · Pointer/touch: direction buttons."
}
}[type];
this.openPanel(instructions.title, `
<p class="tr-panel-lede">${escapeHtml(instructions.copy)}</p>
<p>${escapeHtml(instructions.input)}</p>
${this.store.get().settings.assistedMinigames ? "<p><strong>Assisted mode is on:</strong> cues move more slowly or reveal the next correct input.</p>" : ""}
<button type="button" class="tr-primary" data-mini-begin>Begin</button>`, "Practice instructions · pause safely · story progress is guaranteed", false);
this.panelBody.querySelector("[data-mini-begin]").addEventListener("click", () => {
if (type === "archery") this.archery(done);
else if (type === "cooking") this.cooking(done);
else this.dance(done);
});
}
archery(done) {
const firstCompletion = !this.store.get().minigames.archery?.complete;
let shots = 0;
let score = 0;
let direction = 1;
let position = 0;
const assisted = this.store.get().settings.assistedMinigames;
this.openPanel("Moonlit Archery", `
<p class="tr-panel-lede">Loose five arrows when the moving glint crosses the gold centre. Press Space or tap Loose.</p>
<div class="tr-archery"><i data-archer-mark></i><b></b></div>
<p data-mini-score>Arrows 0/5 · Score 0</p>
<div class="tr-actions tr-actions--row">
<button type="button" class="tr-primary" data-mini-action>Loose</button>
<button type="button" data-mini-pause>Pause</button>
</div>`, "Practice is included · the story always continues", false);
const marker = this.panelBody.querySelector("[data-archer-mark]");
const output = this.panelBody.querySelector("[data-mini-score]");
let frame;
let paused = false;
const animate = () => {
if (!paused) position += direction * (assisted ? 0.6 : 1.15);
if (position >= 100 || position <= 0) direction *= -1;
position = Math.max(0, Math.min(100, position));
marker.style.left = `${position}%`;
frame = requestAnimationFrame(animate);
};
const shoot = () => {
const accuracy = Math.max(0, 20 - Math.abs(50 - position));
score += Math.round(accuracy);
shots += 1;
this.audio.sfx("archery");
output.textContent = `Arrows ${shots}/5 · Score ${score}`;
if (shots === 5) {
cancelAnimationFrame(frame);
const bullseyes = Math.round(score / 20);
this.store.dispatch({type: "minigame", id: "archery", score, perfect: score >= 88});
if (firstCompletion) {
this.store.dispatch({type: "relationship", key: "adventure", amount: 4 + bullseyes});
if (score >= 78) this.store.dispatch({type: "achievement", id: "true_aim"});
}
this.overlay.hidden = true;
this.toast(score >= 70 ? "Lima bows with exaggerated solemnity. “Acceptable.”" : "Lima grins. “The target survived. Barely.”");
this.saves.save("auto", this.store.get());
done();
}
};
this.panelBody.querySelector("[data-mini-action]").addEventListener("click", shoot);
this.panelBody.querySelector("[data-mini-pause]").addEventListener("click", (event) => {
paused = !paused;
event.currentTarget.textContent = paused ? "Resume" : "Pause";
this.panelBody.querySelector("[data-mini-action]").disabled = paused;
this.announce(paused ? "Archery paused." : "Archery resumed.");
});
this.panel.onkeydown = (event) => { if (event.code === "Space") { event.preventDefault(); shoot(); } };
animate();
}
cooking(done) {
const firstCompletion = !this.store.get().minigames.cooking?.complete;
const recipe = ["Rice", "Saffron", "Citrus", "Pistachio"];
let index = 0;
let mistakes = 0;
const labels = [...recipe].sort(() => 0.5 - Math.random());
const render = () => {
this.openPanel("The festival kitchen", `
<p class="tr-panel-lede">Build Rayhan's fragrant rice in the order hidden in Nadia's rhyme: foundation, gold, brightness, green.</p>
<div class="tr-cooking">${labels.map((label) => `<button type="button" data-ingredient="${label}" ${recipe.slice(0, index).includes(label) ? "disabled" : ""}>${label}</button>`).join("")}</div>
<p>${index}/4 prepared · ${mistakes} ${mistakes === 1 ? "correction" : "corrections"}</p>
${this.store.get().settings.assistedMinigames ? "<small>Assisted order: Rice, Saffron, Citrus, Pistachio.</small>" : ""}`, "Cooperative cooking", false);
this.panelBody.querySelectorAll("[data-ingredient]").forEach((button) => button.addEventListener("click", () => {
if (button.dataset.ingredient === recipe[index]) {
index += 1;
this.audio.sfx("cooking");
if (index === recipe.length) {
this.store.dispatch({type: "minigame", id: "cooking", score: Math.max(0, 100 - mistakes * 20), perfect: mistakes === 0});
if (firstCompletion) {
this.store.dispatch({type: "relationship", key: "comfort", amount: mistakes === 0 ? 7 : 4});
this.store.dispatch({type: "relationship", key: "humour", amount: mistakes ? 5 : 2});
if (!mistakes) this.store.dispatch({type: "achievement", id: "kitchen_conspiracy"});
}
this.overlay.hidden = true;
this.toast(mistakes ? "The dish is delicious. The flour on Lima's cheek is a separate triumph." : "Rayhan declares the dish—and the teamwork—suspiciously perfect.");
this.saves.save("auto", this.store.get());
done();
} else render();
} else {
mistakes += 1;
this.toast("Lima catches your wrist. “Bold. Incorrect, but bold.”");
render();
}
}));
this.panel.onkeydown = (event) => {
const number = Number(event.key);
if (number >= 1 && number <= 4) this.panelBody.querySelectorAll("[data-ingredient]")[number - 1]?.click();
};
};
render();
}
dance(done) {
const firstCompletion = !this.store.get().minigames.dance?.complete;
const pattern = ["←", "↑", "→", "↓", "←", "→", "↑", "↓", "→", "←", "↓", "↑"];
let index = 0;
let score = 0;
const assisted = this.store.get().settings.assistedMinigames;
const render = () => {
this.openPanel("Dance at moonrise", `
<p class="tr-panel-lede">Follow Lima's sequence. Grace is welcome; recovery is irresistible.</p>
<div class="tr-dance-cue">${pattern[index]}</div>
<div class="tr-dance">${["←", "↑", "↓", "→"].map((arrow) => `<button type="button" data-step="${arrow}">${arrow}</button>`).join("")}</div>
<p>Step ${index + 1}/${pattern.length} · Rhythm ${score}</p>
${assisted ? `<small>Assisted: choose ${pattern[index]}</small>` : ""}`, "Festival dance", false);
this.panelBody.querySelectorAll("[data-step]").forEach((button) => button.addEventListener("click", () => {
if (button.dataset.step === pattern[index]) score += 10;
else score += 3;
index += 1;
this.audio.sfx("dance");
if (index >= pattern.length) {
this.store.dispatch({type: "minigame", id: "dance", score, perfect: score === 120});
if (firstCompletion) {
this.store.dispatch({type: "relationship", key: "romance", amount: score >= 90 ? 8 : 5});
this.store.dispatch({type: "relationship", key: "comfort", amount: score >= 90 ? 4 : 6});
this.store.dispatch({type: "achievement", id: "two_left_feet"});
}
this.overlay.hidden = true;
this.toast(score >= 90 ? "For one breath, the entire pavilion follows your rhythm." : "You miss a turn. Lima catches you—and does not let go.");
this.saves.save("auto", this.store.get());
done();
} else render();
}));
this.panel.onkeydown = (event) => {
const arrows = {ArrowLeft: "←", ArrowUp: "↑", ArrowDown: "↓", ArrowRight: "→"};
if (arrows[event.key]) {
event.preventDefault();
this.panelBody.querySelector(`[data-step="${arrows[event.key]}"]`)?.click();
}
};
};
render();
}
ending(done) {
const state = this.store.get();
const id = resolveEnding(state, this.content.endings);
const ending = this.content.endings[id];
this.store.dispatch({type: "ending", id});
this.store.dispatch({type: "unlockCG", id});
if (this.store.get().unlockedEndings.length === 4) this.store.dispatch({type: "achievement", id: "every_current"});
this.saves.save("auto", this.store.get());
this.audio.playLocation("ending", "water");
this.openPanel(ending.title, `
<figure class="tr-ending">
<img src="/assets/games/two-rivers/images/cg/${id}.webp" alt="Z and Lima together after the Festival of Two Rivers">
<figcaption><p>${escapeHtml(ending.epilogue)}</p><blockquote>“Tomorrow?” Z asks.<br>“Obviously,” Lima says. “We have several footnotes left to ruin.”</blockquote></figcaption>
</figure>
<div class="tr-actions tr-actions--row">
<button type="button" class="tr-primary" data-ending-menu>Return to title</button>
<button type="button" data-ending-gallery>View gallery</button>
</div>`, "A romantic ending · the story continues", false);
this.panelBody.querySelector("[data-ending-menu]").addEventListener("click", () => {
this.overlay.hidden = true;
done();
});
this.panelBody.querySelector("[data-ending-gallery]").addEventListener("click", () => this.gallery());
}
}