660 lines
34 KiB
JavaScript
660 lines
34 KiB
JavaScript
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) =>
|
||
({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[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());
|
||
}
|
||
}
|