// 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();