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

284 lines
11 KiB
JavaScript

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", `
<div class="tr-actions">
<button type="button" class="tr-primary" data-resume>Return to the story</button>
<button type="button" data-pause-journal>Journal</button>
<button type="button" data-pause-save>Save slots</button>
<button type="button" data-pause-settings>Settings</button>
<button type="button" data-pause-title>Return to title</button>
</div>`);
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 = `<div><strong>The festival could not begin.</strong><p>${String(error.message || error)}</p><button type="button" onclick="location.reload()">Try again</button><a href="/play/play.html">Return to playroom</a></div>`;
}