feat: Doing more polishing (M8b)
This commit is contained in:
+128
-33
@@ -1,9 +1,13 @@
|
||||
// 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,
|
||||
@@ -20,6 +24,8 @@ 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";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||
@@ -48,6 +54,8 @@ 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 });
|
||||
|
||||
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||
|
||||
@@ -58,6 +66,8 @@ 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"),
|
||||
@@ -68,13 +78,39 @@ const el = {
|
||||
cal: document.getElementById("calendar"),
|
||||
news: document.getElementById("newspaper"),
|
||||
menu: document.getElementById("menu"),
|
||||
shelf: document.getElementById("shelf"),
|
||||
};
|
||||
const pad = document.getElementById("pad");
|
||||
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||
|
||||
// A weekly pause needs a card; with none eligible the week is simply empty.
|
||||
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
|
||||
function prepareWeek() {
|
||||
ensureCard(state, balance, cards);
|
||||
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() {
|
||||
@@ -87,13 +123,19 @@ function autosave() {
|
||||
|
||||
function build() {
|
||||
panels?.decision.destroy();
|
||||
document.title = t("app.title");
|
||||
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, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
|
||||
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
|
||||
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 }),
|
||||
news: createNewspaper(el.news, newsTpl),
|
||||
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||
@@ -101,12 +143,34 @@ function build() {
|
||||
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;
|
||||
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", Boolean(state.pending.review && !state.pending.profile && !state.over));
|
||||
pad.classList.toggle("is-endgame", Boolean(state.over));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -128,6 +192,9 @@ function enterGame(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();
|
||||
@@ -144,7 +211,6 @@ function closeMenu() {
|
||||
menuOpen = false;
|
||||
pad.classList.remove("is-menu");
|
||||
menu.hide();
|
||||
clock.acc = 0;
|
||||
}
|
||||
|
||||
function quitToMenu() {
|
||||
@@ -152,7 +218,8 @@ function quitToMenu() {
|
||||
panels?.decision.destroy();
|
||||
state = null;
|
||||
panels = null;
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf"]) el[id].innerHTML = "";
|
||||
document.title = t("app.title");
|
||||
el.ledger.hidden = true;
|
||||
el.alerts.hidden = true;
|
||||
el.endgame.hidden = true;
|
||||
@@ -166,6 +233,7 @@ function applySettings(patch) {
|
||||
const langChanged = patch.lang && patch.lang !== getLang();
|
||||
settings = { ...settings, ...patch };
|
||||
saveSettings(storage, settings);
|
||||
if (patch.sound === true || patch.volume !== undefined) audio.play("click");
|
||||
if (!langChanged) return;
|
||||
setLang(patch.lang);
|
||||
document.title = t("app.title");
|
||||
@@ -192,11 +260,12 @@ const menu = createMenu(el.menu, {
|
||||
function onSpeed(speed) {
|
||||
if (speed > 0) lastSpeed = speed;
|
||||
setSpeed(state, balance, speed);
|
||||
audio.play("click");
|
||||
render();
|
||||
}
|
||||
|
||||
function onAnswer(choice) {
|
||||
answerCard(state, balance, cards, choice);
|
||||
function onAnswer(choice, id) {
|
||||
answerCard(state, balance, cards, choice, id);
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -207,6 +276,7 @@ function onProfile(pronouns) {
|
||||
|
||||
function onDismiss() {
|
||||
dismissAlert(state);
|
||||
audio.play("paper");
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -224,7 +294,11 @@ document.addEventListener("keydown", (e) => {
|
||||
});
|
||||
|
||||
addEventListener("pagehide", autosave);
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) autosave();
|
||||
else if (state) render(); // redraw what was skipped while hidden
|
||||
updateTitle();
|
||||
});
|
||||
|
||||
// ---- Dev shortcuts ----
|
||||
|
||||
@@ -240,13 +314,12 @@ function devGame() {
|
||||
s.pending.review = false;
|
||||
while (s.pending.alert) dismissAlert(s);
|
||||
prepareWeek();
|
||||
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||
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.pendingCard = { id: params.get("card"), day: s.day };
|
||||
s.pending.weeklyCard = true;
|
||||
s.inbox.unshift({ id: params.get("card"), day: s.day, category: cardsById.get(params.get("card")).category });
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -259,25 +332,47 @@ let last = performance.now();
|
||||
if (devRun) enterGame(devGame());
|
||||
else quitToMenu();
|
||||
|
||||
function frame(now) {
|
||||
// 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)) {
|
||||
const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay;
|
||||
let days = consumeClock(clock, dt, msPerDay, state.speed);
|
||||
while (days-- > 0) {
|
||||
const r = advanceDay(state, balance);
|
||||
if (!r.advanced) break;
|
||||
if (r.events.weeklyCard) prepareWeek();
|
||||
render();
|
||||
if (isWaiting(state) || state.over) {
|
||||
clock.acc = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clock.acc = 0;
|
||||
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) 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);
|
||||
|
||||
function frame(now) {
|
||||
step(now);
|
||||
if (panels) {
|
||||
const p = Math.min(1, clock.acc / msPerDay());
|
||||
panels.cal.clockHand(p);
|
||||
panels.shelf.clockHand(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();
|
||||
|
||||
Reference in New Issue
Block a user