Files
org_web/assets/scripts/games/two-rivers/ui/interface.js
gitea-actions 08516fda14
All checks were successful
Build Org Website / build (push) Successful in 45s
Serve Two Rivers modules with production MIME type
2026-08-11 13:40:14 +01:00

660 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {applyEffects, relationshipStage, RELATIONSHIPS, requirementMet, resolveEnding} from "../core/state.js";
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());
}
}