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) => `
  • ${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", `

    What is growing

    Festival errands

    Clues and keepsakes

    ${clues || "

    The pockets are, for once, empty.

    "}

    Gift ideas noticed

    `, "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.

    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)}` : "
    ?
    "}
    ${escapeHtml(id.replaceAll("_", " "))}
    `; }).join(""); this.openPanel("Moonlit Gallery", ``, `${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.

    `, "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"}
    `; }).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) => ``).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) => ``).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) => ``).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.

    " : ""} `, "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

    `, "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) => ``).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) => ``).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, `
    Z and Lima together after the Festival of Two Rivers

    ${escapeHtml(ending.epilogue)}

    “Tomorrow?” Z asks.
    “Obviously,” Lima says. “We have several footnotes left to ruin.”
    `, "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()); } }