284 lines
11 KiB
JavaScript
284 lines
11 KiB
JavaScript
import {loadContent} from "./core/content.mjs";
|
|
import {createStore, freshState} from "./core/state.mjs";
|
|
import {createSaveService} from "./core/save.mjs";
|
|
import {AudioDirector} from "./systems/audio.mjs";
|
|
import {Interface} from "./ui/interface.mjs";
|
|
import {createScenes} from "./scenes/game-scenes.mjs";
|
|
|
|
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>`;
|
|
}
|