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) =>
`
${LABELS[key]} ${relationshipStage(state.relationships[key])} ${this.observation(key, state.relationships[key])} `).join("");
const quests = Object.entries(this.content.quests).map(([id, quest]) => {
const progress = state.quests[id];
return `${escapeHtml(quest.name)} ${progress.status === "complete" ? "Complete" : `${progress.count}/${quest.target}`} ${escapeHtml(quest.description)} `;
}).join("");
const clues = state.inventory.map((id) => `${escapeHtml(id.replaceAll("_", " "))} `).join("");
const gifts = this.content.items.gifts.filter((gift) => state.discoveredGifts.includes(gift.id))
.map((gift) => `${escapeHtml(gift.name)} ${escapeHtml(gift.hint)} `).join("");
this.openPanel("Z's Journal", `
Clues and keepsakes ${clues || "
The pockets are, for once, empty.
"}
Gift ideas noticed ${gifts || "Listen closely; Lima reveals what she values. "}
`, "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", `
`);
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", `
An original, locally hosted romantic adventure created for zainezq.com.
Story and direction Two Rivers at Moonrise
Engine Phaser 3.90.0
Interface Tailwind CSS 4
Motion GSAP 3.13.0
Audio Howler.js 2.2.4
Artwork Original AI-assisted painterly assets
Bengali and Pakistani influences are treated as distinct sources of clothing, food, architecture, and visual language inside a wholly fictional fantasy setting.
`);
}
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 `${unlocked ? ` ` : "?
"}${escapeHtml(id.replaceAll("_", " "))} `;
}).join("");
this.openPanel("Moonlit Gallery", `${cards}
`, `${state.unlockedCGs.length + state.unlockedEndings.length} memories found`);
}
achievements() {
const state = this.store.get();
const rows = Object.entries(this.content.achievements).map(([id, description]) =>
`${state.achievements.includes(id) ? "✦" : "◇"} ${escapeHtml(id.replaceAll("_", " "))} ${state.achievements.includes(id) ? escapeHtml(description) : "Not yet discovered"} `).join("");
this.openPanel("Achievements", ``, `${state.achievements.length}/${Object.keys(this.content.achievements).length} earned`);
}
recovery(info) {
this.openPanel("Save recovery", `
${escapeHtml(info.message)}
Your current game can still begin safely. A recovery copy was used when available; existing Princess Lima saves were not touched.
Continue `, "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", `${["slot1", "slot2", "slot3"].map((slot, index) => {
const record = slots[slot];
return `
Slot ${index + 1} ${record ? escapeHtml(this.content.locations[record.location]?.name || record.location) : "Empty"} ${record ? new Date(record.timestamp).toLocaleString() : "A fresh page"}
Save
Load `;
}).join("")}
`);
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 = `${index + 1} ${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) => `${escapeHtml(entry.speaker)} ${escapeHtml(entry.text)}
`).join("");
this.openPanel("Conversation history", `${list} `);
}
cipher(done) {
const answer = ["Dawn", "Rain", "Lantern", "Moon"];
let progress = [];
const render = () => {
this.openPanel("The lantern cipher", `
The indigo knot named an order. Touch the carved shelf emblems from first light to moonrise.
${answer.map((word) => `${word} `).join("")}
${progress.length ? progress.join(" · ") : "The lantern waits."}
${this.store.get().settings.assistedMinigames ? "Assisted hint: Dawn → Rain → Lantern → Moon " : ""}`, "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", `
Four carved blooms follow the old festival day: welcome at dawn, fragrance at dusk, fire at the feast, memory at moonrise.
${symbols.map((flower) => `${flower} `).join("")}
${progress.length ? progress.join(" · ") : "The stone petals wait beneath your hand."}
${this.store.get().settings.assistedMinigames ? "Assisted hint: Jasmine → Tuberose → Marigold → Bakul " : ""}`,
"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) =>
`${escapeHtml(gift.name)} ${escapeHtml(gift.hint)} `).join("");
this.openPanel("A gift for Lima", `Choose from the ideas Z noticed in conversation. No sincere gift is punished, and only one gift is exchanged in this act.
${cards}
`, "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, `
${escapeHtml(instructions.copy)}
${escapeHtml(instructions.input)}
${this.store.get().settings.assistedMinigames ? "Assisted mode is on: cues move more slowly or reveal the next correct input.
" : ""}
Begin `, "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", `
Loose five arrows when the moving glint crosses the gold centre. Press Space or tap Loose.
Arrows 0/5 · Score 0
Loose
Pause
`, "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", `
Build Rayhan's fragrant rice in the order hidden in Nadia's rhyme: foundation, gold, brightness, green.
${labels.map((label) => `${label} `).join("")}
${index}/4 prepared · ${mistakes} ${mistakes === 1 ? "correction" : "corrections"}
${this.store.get().settings.assistedMinigames ? "Assisted order: Rice, Saffron, Citrus, Pistachio. " : ""}`, "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", `
Follow Lima's sequence. Grace is welcome; recovery is irresistible.
${pattern[index]}
${["←", "↑", "↓", "→"].map((arrow) => `${arrow} `).join("")}
Step ${index + 1}/${pattern.length} · Rhythm ${score}
${assisted ? `Assisted: choose ${pattern[index]} ` : ""}`, "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, `
${escapeHtml(ending.epilogue)}
“Tomorrow?” Z asks. “Obviously,” Lima says. “We have several footnotes left to ruin.”
Return to title
View gallery
`, "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());
}
}