Add standalone export for Two Rivers at Moonrise
All checks were successful
Build Org Website / build (push) Successful in 44s
All checks were successful
Build Org Website / build (push) Successful in 44s
This commit is contained in:
70
assets/scripts/games/two-rivers/core/content.mjs
Normal file
70
assets/scripts/games/two-rivers/core/content.mjs
Normal 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;
|
||||
}
|
||||
109
assets/scripts/games/two-rivers/core/save.mjs
Normal file
109
assets/scripts/games/two-rivers/core/save.mjs
Normal 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");
|
||||
}
|
||||
};
|
||||
}
|
||||
317
assets/scripts/games/two-rivers/core/state.mjs
Normal file
317
assets/scripts/games/two-rivers/core/state.mjs
Normal 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;
|
||||
}
|
||||
282
assets/scripts/games/two-rivers/main.js
Normal file
282
assets/scripts/games/two-rivers/main.js
Normal 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>`;
|
||||
}
|
||||
224
assets/scripts/games/two-rivers/scenes/game-scenes.mjs
Normal file
224
assets/scripts/games/two-rivers/scenes/game-scenes.mjs
Normal 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];
|
||||
}
|
||||
77
assets/scripts/games/two-rivers/systems/audio.mjs
Normal file
77
assets/scripts/games/two-rivers/systems/audio.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
659
assets/scripts/games/two-rivers/ui/interface.mjs
Normal file
659
assets/scripts/games/two-rivers/ui/interface.mjs
Normal 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) =>
|
||||
({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[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 1–4 · 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());
|
||||
}
|
||||
}
|
||||
11
assets/scripts/vendor/two-rivers/gsap-3.13.0.min.js
vendored
Normal file
11
assets/scripts/vendor/two-rivers/gsap-3.13.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
assets/scripts/vendor/two-rivers/howler-2.2.4.min.js
vendored
Normal file
4
assets/scripts/vendor/two-rivers/howler-2.2.4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
assets/scripts/vendor/two-rivers/phaser-3.90.0.min.js
vendored
Normal file
1
assets/scripts/vendor/two-rivers/phaser-3.90.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user