import {loadContent} from "./core/content.js"; import {createStore, freshState} from "./core/state.js"; import {createSaveService} from "./core/save.js"; import {AudioDirector} from "./systems/audio.js"; import {Interface} from "./ui/interface.js"; import {createScenes} from "./scenes/game-scenes.js"; const root = document.querySelector("[data-two-rivers]"); if (root) { start().catch((error) => fatal(error)); } async function start() { if (!window.Phaser || !window.gsap || !window.Howl) throw new Error("A required local game library did not load."); const saves = createSaveService(); const loadingEl = root.querySelector("[data-tr-loading]"); const progress = root.querySelector("[data-tr-progress]"); const loadingCopy = root.querySelector("[data-tr-loading-copy]"); loadingEl.hidden = false; const content = await loadContent((value, file) => { progress.value = value * 0.4; loadingCopy.textContent = `Reading ${file.replace(".json", "").replaceAll("_", " ")}…`; }); const savedSettings = saves.settings(); const store = createStore(freshState(savedSettings)); const audio = new AudioDirector(content.assets.audio, () => store.get().settings); const ui = new Interface(root, store, content, saves, audio); let game; let ready = false; let paused = false; let lastTick = performance.now(); let recoveryShown = false; const controller = { content, store, saves, audio, ui, touchDirection: 0, loading(value, copy) { progress.value = 40 + value * 0.6; loadingCopy.textContent = copy; }, assetError(src) { ui.toast(`An artwork could not be loaded: ${src.split("/").pop()}`); }, locationLoading(active, copy = "Painting the city…") { loadingEl.hidden = !active; if (active) { progress.removeAttribute("value"); loadingCopy.textContent = copy; } else { progress.value = 100; } }, ready() { ready = true; loadingEl.hidden = true; showGateOrMenu(); }, uiBusy() { return !ui.dialogueEl.hidden || !ui.overlay.hidden || paused; }, locationReady(location) { paused = false; root.classList.add("tr-exploring"); root.querySelector("[data-tr-hud]").hidden = false; root.querySelector("[data-tr-chapter]").textContent = location.act; root.querySelector("[data-tr-location]").textContent = location.name; root.querySelector("[data-tr-objective]").textContent = location.objective; audio.playLocation(location.music, location.ambience); controller.autosave(); controller.interaction(null); root.querySelector("#two-rivers-game").focus(); checkOrientation(); }, interaction(label) { const element = root.querySelector("[data-tr-interact]"); element.hidden = !label; if (label) element.querySelector("span").textContent = label; }, dialogue(id, done) { ui.startDialogue(id, done); }, puzzle(id, done) { id === "flower_sequence" ? ui.flowerSequence(done) : ui.cipher(done); }, gifts(done) { ui.gifts(done); }, minigame(type, done) { controller.pendingMiniDone = done; store.dispatch({type: "minigameStart", id: type}); controller.autosave(); game.scene.pause("Story"); game.scene.start(type[0].toUpperCase() + type.slice(1)); }, runMiniOverlay(type, sceneDone) { ui.minigame(type, () => { sceneDone(); game.scene.resume("Story"); controller.pendingMiniDone?.(); controller.pendingMiniDone = null; }); }, goto(location) { store.dispatch({type: "goto", location, position: content.locations[location].spawn}); controller.autosave(); game.scene.stop("Story"); game.scene.start("Story"); }, ending() { game.scene.stop("Story"); game.scene.start("Ending"); }, runEnding() { ui.ending(() => { root.classList.remove("tr-exploring"); root.querySelector("[data-tr-hud]").hidden = true; checkOrientation(); game.scene.stop("Ending"); showMenu(); }); }, openJournal() { if (!ready || ui.dialogue || !ui.overlay.hidden) return; paused = true; ui.onResume = () => { paused = false; ui.onResume = null; }; ui.journal(); }, pause() { if (!ready || ui.dialogue || !ui.overlay.hidden) return; paused = true; ui.onResume = () => { paused = false; ui.onResume = null; }; ui.openPanel("Moonlit menu", `
`); ui.panelBody.querySelector("[data-resume]").addEventListener("click", () => ui.closePanel()); ui.panelBody.querySelector("[data-pause-journal]").addEventListener("click", () => ui.journal()); ui.panelBody.querySelector("[data-pause-save]").addEventListener("click", () => ui.savesPanel(loadState)); ui.panelBody.querySelector("[data-pause-settings]").addEventListener("click", () => ui.settings()); ui.panelBody.querySelector("[data-pause-title]").addEventListener("click", () => { ui.overlay.hidden = true; game.scene.stop("Story"); root.querySelector("[data-tr-hud]").hidden = true; checkOrientation(); showMenu(); }); }, toast(text) { ui.toast(text); }, autosave() { saves.save("auto", store.get()); } }; const scenes = createScenes(controller); game = new Phaser.Game({ type: Phaser.AUTO, parent: "two-rivers-game", width: 1280, height: 720, transparent: true, render: {antialias: true, roundPixels: false, powerPreference: "high-performance"}, scale: {mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH}, audio: {noAudio: true}, scene: scenes }); store.subscribe((state, command) => { root.classList.toggle("tr-high-contrast", state.settings.highContrast); if (command.type === "relationship") ui.toast(`${ui.observation(command.key, state.relationships[command.key])}`); }); function showGateOrMenu() { const age = root.querySelector("[data-tr-age]"); if (saves.ageConfirmed() || root.dataset.ageConfirmed === "true") { age.hidden = true; showMenu(); } else { age.hidden = false; root.querySelector("[data-tr-age-confirm]").focus(); } } function showMenu() { paused = false; root.classList.remove("tr-exploring"); const menu = root.querySelector("[data-tr-menu]"); menu.hidden = false; const continueButton = root.querySelector("[data-tr-continue]"); continueButton.disabled = !saves.hasSave(); window.gsap.fromTo(".tr-menu__copy > *", {y: 18, opacity: 0}, { y: 0, opacity: 1, duration: store.get().settings.reducedMotion ? 0 : 0.55, stagger: 0.08, ease: "power2.out" }); const recovery = saves.recoveryInfo(); if (recovery && !recoveryShown) { recoveryShown = true; window.setTimeout(() => ui.recovery(recovery), 120); } } function hideMenu() { root.querySelector("[data-tr-menu]").hidden = true; } function newGame() { audio.unlock(); const settings = store.get().settings; store.replace(freshState(settings)); hideMenu(); game.scene.stop("Story"); game.scene.start("Story"); window.setTimeout(() => controller.dialogue("arrival", () => { store.dispatch({type: "flag", id: "done_lima_arrival"}); controller.autosave(); }), 420); } function loadState(state) { audio.unlock(); store.replace(state); hideMenu(); ui.overlay.hidden = true; game.scene.stop("Story"); game.scene.start("Story"); if (state.dialogue?.id) window.setTimeout(() => controller.dialogue(state.dialogue.id, () => {}), 350); else if (state.minigameCheckpoint) window.setTimeout(() => controller.minigame(state.minigameCheckpoint, () => {}), 500); } root.querySelector("[data-tr-age-confirm]").addEventListener("click", () => { saves.confirmAge(); root.querySelector("[data-tr-age]").hidden = true; loadingEl.hidden = true; audio.unlock(); showMenu(); }); root.querySelector("[data-tr-new]").addEventListener("click", newGame); root.querySelector("[data-tr-continue]").addEventListener("click", () => { const state = saves.load("auto"); if (state) loadState(state); else ui.toast("No valid autosave was found."); }); root.querySelector("[data-tr-journal]").addEventListener("click", () => controller.openJournal()); root.querySelector("[data-tr-save]").addEventListener("click", () => ui.savesPanel(loadState)); root.querySelector("[data-tr-pause]").addEventListener("click", () => controller.pause()); root.querySelector("[data-tr-interact-button]").addEventListener("click", () => game.scene.getScene("Story")?.interact()); root.querySelector("[data-tr-touch-interact]").addEventListener("click", () => game.scene.getScene("Story")?.interact()); root.querySelector("[data-tr-slots]").addEventListener("click", () => ui.savesPanel(loadState)); root.querySelector("[data-tr-gallery]").addEventListener("click", () => ui.gallery()); root.querySelector("[data-tr-achievements]")?.addEventListener("click", () => ui.achievements()); root.querySelector("[data-tr-settings]").addEventListener("click", () => ui.settings()); root.querySelector("[data-tr-credits]").addEventListener("click", () => ui.credits()); const bindDirection = (selector, direction) => { const button = root.querySelector(selector); ["pointerdown", "touchstart"].forEach((eventName) => button.addEventListener(eventName, (event) => { event.preventDefault(); controller.touchDirection = direction; }, {passive: false})); ["pointerup", "pointercancel", "pointerleave", "touchend"].forEach((eventName) => button.addEventListener(eventName, () => { controller.touchDirection = 0; })); }; bindDirection("[data-tr-left]", -1); bindDirection("[data-tr-right]", 1); if (matchMedia("(pointer: coarse)").matches) root.querySelector("[data-tr-touch]").hidden = false; const checkOrientation = () => { root.querySelector("[data-tr-rotate]").hidden = !(innerWidth < 700 && innerHeight > innerWidth && !root.querySelector("[data-tr-hud]").hidden); }; addEventListener("resize", checkOrientation); checkOrientation(); window.setInterval(() => { const now = performance.now(); if (!document.hidden && ready && !root.querySelector("[data-tr-hud]").hidden) { store.dispatch({type: "tick", seconds: Math.round((now - lastTick) / 1000)}); } lastTick = now; }, 5000); } function fatal(error) { console.error(error); const loading = root.querySelector("[data-tr-loading]"); loading.hidden = false; loading.innerHTML = `
The festival could not begin.

${String(error.message || error)}

Return to playroom
`; }