Files
Country-Game---The-Desk/web/js/main.js
T

426 lines
15 KiB
JavaScript

// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
// The loop is driven by animation frames while the tab is visible and by a worker heartbeat while it
// is hidden, so the game keeps running in the background (it still stops at a budget review, a danger
// warning, or when the in-tray holds its maximum of proposals).
//
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
// ?msPerDay=N (real milliseconds per in-game day at speed 1, for tests) ?untilAlert (stop ?skip at the first warning)
import {
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat,
} from "./engine/index.js";
import { initI18n, setLang, getLang, t } from "./i18n/i18n.js";
import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js";
import { tilt } from "./ui/desk.js";
import { createDashboard } from "./ui/dashboard.js";
import { createCalendar } from "./ui/calendar.js";
import { createNewspaper } from "./ui/newspaper.js";
import { createDecision } from "./ui/decision.js";
import { createLedger } from "./ui/ledger.js";
import { createTelegram } from "./ui/telegram.js";
import { createEndgame } from "./ui/endgame.js";
import { createMenu } from "./ui/menu.js";
import { createShelf } from "./ui/shelf.js";
import { createAudio } from "./ui/audio.js";
import { createMusic } from "./ui/music.js";
import { createWalkman } from "./ui/walkman.js";
import { DEFAULT_MIX } from "./save/storage.js";
const params = new URLSearchParams(location.search);
const getJson = (url) => fetch(url).then((r) => r.json());
// ---- Content ----
const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([
getJson("data/balance.json"),
getJson("data/newspaper.json"),
getJson("data/endgame.json"),
getJson("data/characters.json"),
getJson("data/cards/index.json"),
getJson("data/scenarios/index.json"),
]);
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat();
const scenarios = Object.fromEntries(
await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])),
);
const cardsById = indexCards(cards);
const content = { scenarios, balance, cards };
// ---- Settings and storage ----
const storage = createStorage();
let settings = loadSettings(storage);
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume, mix: () => settings.mix });
// Background music from the music folder, driven by the cassette player on the desk.
const music = createMusic({
getVolume: () => settings.volume * settings.mix.music,
onUserChoice: (on) => { if (settings.musicOn !== on) applySettings({ musicOn: on }); },
});
let gestured = false;
const startMusic = () => gestured && music.unlockedByGesture(settings.musicOn);
for (const ev of ["pointerdown", "keydown"]) {
document.addEventListener(ev, (e) => {
if (e.target.closest?.("#walkman")) return; // pressing a walkman key is its own choice
gestured = true;
startMusic();
}, { passive: true });
}
music.load().then(startMusic);
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
// ---- Game and screen state ----
let state = null; // the running game, or null in the main menu
let scenario = null;
let panels = null;
let menuOpen = true;
let lastSpeed = 1;
let view = "tray"; // phone layout: which paper is on the desk (tray, brief, news, clock, ledger)
let seen = null; // pending flags and tray size at the last change, to play sounds on transitions
const el = {
dash: document.getElementById("dashboard"),
folder: document.getElementById("folder"),
ledger: document.getElementById("ledger"),
alerts: document.getElementById("alerts"),
endgame: document.getElementById("endgame"),
cal: document.getElementById("calendar"),
news: document.getElementById("newspaper"),
menu: document.getElementById("menu"),
shelf: document.getElementById("shelf"),
walkman: document.getElementById("walkman"),
};
const pad = document.getElementById("pad");
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
tilt(el.walkman, "walkman", 0.5);
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
function prepareWeek() {
if (state.pending.weeklyCard) ensureCard(state, balance, cards);
}
function updateTitle() {
const base = t("app.title");
if (!state || state.over) {
document.title = base;
return;
}
const waiting = document.hidden && isWaiting(state) && !state.pending.profile;
const n = state.inbox.length;
document.title = `${waiting ? "● " : ""}${n ? `(${n}) ` : ""}${base}`;
}
// Sounds for what just changed: a new proposal, a budget review, a warning, a full tray, the end.
function notify() {
const p = state.pending;
const now = { review: p.review, alert: p.alert, inboxFull: p.inboxFull, over: Boolean(state.over), inbox: state.inbox.length };
if (seen) {
if (now.over && !seen.over) audio.play(state.over.type === "term" ? "fanfare" : "fall");
else if (now.alert && !seen.alert) audio.play("alert");
else if (now.review && !seen.review) audio.play("page");
else if (now.inboxFull && !seen.inboxFull) audio.play("chime");
else if (now.inbox > seen.inbox) audio.play("paper");
}
seen = now;
}
function autosave() {
if (!state || devRun) return;
if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue
else writeSave(storage, KEYS.auto, state);
}
// ---- Panels ----
function build() {
panels?.decision.destroy();
state.lang = getLang();
pad.classList.add("is-dealing"); // the papers land on the desk
setTimeout(() => pad.classList.remove("is-dealing"), 1100);
panels = {
dash: createDashboard(el.dash, scenario),
decision: createDecision(el.folder, {
cardsById, characters, inboxMax: balance.cards.inboxMax, onAnswer, onProfile,
onStamp: (choice) => audio.play(choice === "yes" ? "stampYes" : "stampNo"),
keysEnabled: () => !menuOpen,
}),
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, onSpeed, onMenu: openMenu }),
walkman: createWalkman(el.walkman, { music, onPress: () => audio.play("click") }),
news: createNewspaper(el.news, newsTpl),
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
};
render();
}
// Phone layout: the ledger takes the screen when a review opens and hands it back to the tray afterwards.
function syncView(ledgerOpen) {
if (ledgerOpen && !seen?.review) view = "ledger";
else if (!ledgerOpen && view === "ledger") view = "tray";
if (state.pending.inboxFull && !seen?.inboxFull && !ledgerOpen) view = "tray";
pad.dataset.view = view;
}
function setView(next) {
view = next;
pad.dataset.view = view;
panels?.shelf.update(state, balance);
audio.play("click");
}
function render() {
if (!state) return;
const ledgerOpen = Boolean(state.pending.review && !state.pending.profile && !state.over);
syncView(ledgerOpen);
notify();
// A hidden tab keeps playing but skips the drawing; it redraws everything when it comes back.
if (!document.hidden) {
for (const p of Object.values(panels)) p.update(state, balance);
// The ledger covers the folder and the side column; the end screen covers everything.
pad.classList.toggle("is-ledger", ledgerOpen);
pad.classList.toggle("is-endgame", Boolean(state.over));
}
updateTitle();
autosave();
}
// ---- Game lifecycle ----
function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) {
return createGame({
scenario: scenarios[scenarioId],
balance,
seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0],
lang: getLang(),
countryName,
pronouns,
});
}
function enterGame(next) {
state = next;
scenario = scenarios[state.scenarioId];
if (params.has("dev")) window.__game = state;
lastSpeed = state.speed || 1;
clock.acc = 0;
view = "tray";
seen = null;
prepareWeek();
closeMenu();
build();
}
function openMenu() {
if (!state) return;
menuOpen = true;
pad.classList.add("is-menu");
menu.show("game");
}
function closeMenu() {
menuOpen = false;
pad.classList.remove("is-menu");
menu.hide();
}
function quitToMenu() {
autosave();
panels?.decision.destroy();
state = null;
panels = null;
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf", "walkman"]) el[id].innerHTML = "";
document.title = t("app.title");
el.ledger.hidden = true;
el.alerts.hidden = true;
el.endgame.hidden = true;
pad.classList.remove("is-ledger", "is-endgame");
pad.classList.add("is-menu");
menuOpen = true;
menu.show("main");
}
function applySettings(patch) {
const langChanged = patch.lang && patch.lang !== getLang();
const { resetMix, ...rest } = patch;
settings = { ...settings, ...rest, mix: resetMix ? { ...DEFAULT_MIX } : { ...settings.mix, ...rest.mix } };
if (resetMix) settings.volume = 0.6;
saveSettings(storage, settings);
if (patch.sound === true) audio.play("click");
if (patch.volume !== undefined || patch.mix?.music !== undefined || resetMix) music.applyVolume();
if (!langChanged) return;
setLang(patch.lang);
document.title = t("app.title");
if (state) build();
menu.refresh();
}
// The sound heard when a mixer slider is released.
const PREVIEW = {
master: "chime", stamp: "stampYes", paper: "paper", page: "page", sign: "sign", telegram: "telegram", alert: "alert",
chime: "chime", tick: "tick", countdown: "clockTick", click: "click", ending: "fanfare",
};
const menu = createMenu(el.menu, {
content,
storage,
getState: () => state,
getSettings: () => settings,
handlers: {
onStart: (options) => enterGame(newGame(options)),
onLoadState: enterGame,
onResume: closeMenu,
onQuit: quitToMenu,
onSettings: applySettings,
onPreview: (key) => audio.play(PREVIEW[key] ?? "click"),
},
});
// ---- Player actions ----
function onSpeed(speed) {
if (speed > 0) lastSpeed = speed;
setSpeed(state, balance, speed);
audio.play("click");
render();
}
function onAnswer(choice, id) {
answerCard(state, balance, cards, choice, id);
render();
}
function onProfile(pronouns) {
setPronouns(state, pronouns);
render();
}
function onDismiss() {
dismissAlert(state);
audio.play("paper");
render();
}
document.addEventListener("keydown", (e) => {
if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
if (menuOpen) return; // the menu handles its own keys
if (e.key === "Escape" && !state.over) {
openMenu();
} else if (e.code === "Space") {
e.preventDefault();
onSpeed(state.speed > 0 ? 0 : lastSpeed);
} else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) {
onSpeed(balance.time.speeds[Number(e.key) - 1]);
}
});
addEventListener("pagehide", autosave);
document.addEventListener("visibilitychange", () => {
if (document.hidden) autosave();
else if (state) render(); // redraw what was skipped while hidden
updateTitle();
});
// ---- Dev shortcuts ----
function devGame() {
const s = newGame({ pronouns: params.get("pronouns") ?? undefined });
state = s; // the helpers below act on `state`
if (params.has("alloc")) {
const k = Number(params.get("alloc"));
setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) });
}
for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) {
if (s.pending.profile) setPronouns(s, "they");
s.pending.review = false;
while (s.pending.alert) dismissAlert(s);
prepareWeek();
while (s.inbox.length) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
advanceDay(s, balance);
if (params.has("untilAlert") && s.pending.alert) break; // stop on the first danger warning
}
if (cardsById.has(params.get("card"))) {
s.inbox.unshift({ id: params.get("card"), day: s.day, category: cardsById.get(params.get("card")).category });
}
return s;
}
// ---- Start ----
const clock = createClock();
let last = performance.now();
if (devRun) enterGame(devGame());
else quitToMenu();
// One step of the game: turns elapsed real time into in-game days. Returns true when a day passed.
// A long gap since the last step (a frozen background tab waking up) is caught up within a limit.
function step(now) {
const dt = now - last;
last = now;
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state)) return false;
let days = consumeClock(clock, dt, msPerDay(), state.speed, balance.time.catchUpMs);
let advanced = false;
while (days-- > 0) {
const r = advanceDay(state, balance);
if (!r.advanced) break;
advanced = true;
if (r.events.weeklyCard) prepareWeek();
if (!document.hidden && state.speed <= 2 && !isDayBeforeIssue()) audio.play(state.day % 2 ? "tick" : "tock");
if (isWaiting(state) || state.over) break;
}
if (advanced) render();
return advanced;
}
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
// The day before a new proposal arrives (the Sunday before each Monday), a small clock ticks while that day runs.
const isDayBeforeIssue = () => (state.day + 1) % balance.time.daysPerWeek === 0;
let lastCountdown = "";
function countdownTicks(p) {
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state) || document.hidden || !isDayBeforeIssue()) return;
const perDay = state.speed >= 4 ? 10 : 20; // the ticks follow the dial: faster at higher speeds
const n = Math.floor(p * perDay);
const key = `${state.day}:${n}`;
if (key === lastCountdown) return;
lastCountdown = key;
audio.play(n % 2 ? "clockTock" : "clockTick");
}
function frame(now) {
step(now);
if (panels) {
const p = Math.min(1, clock.acc / msPerDay());
panels.cal.clockHand(p);
panels.shelf.clockHand(p);
countdownTicks(p);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Background play: animation frames stop in a hidden tab, so a worker's timer keeps the game going.
function startHeartbeat() {
const beat = () => { if (document.hidden) step(performance.now()); };
try {
new Worker(new URL("./timer-worker.js", import.meta.url)).onmessage = beat;
} catch {
setInterval(beat, 1000); // no worker support: the slower main-thread timer still works
}
}
startHeartbeat();