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());
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long