First commit, done on M6 from the PLAN.MD
This commit is contained in:
+282
@@ -0,0 +1,282 @@
|
||||
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
|
||||
//
|
||||
// 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)
|
||||
|
||||
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";
|
||||
|
||||
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 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;
|
||||
|
||||
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"),
|
||||
};
|
||||
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.
|
||||
function prepareWeek() {
|
||||
ensureCard(state, balance, cards);
|
||||
}
|
||||
|
||||
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();
|
||||
document.title = t("app.title");
|
||||
state.lang = getLang();
|
||||
panels = {
|
||||
dash: createDashboard(el.dash, scenario),
|
||||
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
|
||||
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
|
||||
cal: createCalendar(el.cal, balance, { 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 }),
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
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();
|
||||
clock.acc = 0;
|
||||
}
|
||||
|
||||
function quitToMenu() {
|
||||
autosave();
|
||||
panels?.decision.destroy();
|
||||
state = null;
|
||||
panels = null;
|
||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
|
||||
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();
|
||||
settings = { ...settings, ...patch };
|
||||
saveSettings(storage, settings);
|
||||
if (!langChanged) return;
|
||||
setLang(patch.lang);
|
||||
document.title = t("app.title");
|
||||
if (state) build();
|
||||
menu.refresh();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Player actions ----
|
||||
|
||||
function onSpeed(speed) {
|
||||
if (speed > 0) lastSpeed = speed;
|
||||
setSpeed(state, balance, speed);
|
||||
render();
|
||||
}
|
||||
|
||||
function onAnswer(choice) {
|
||||
answerCard(state, balance, cards, choice);
|
||||
render();
|
||||
}
|
||||
|
||||
function onProfile(pronouns) {
|
||||
setPronouns(state, pronouns);
|
||||
render();
|
||||
}
|
||||
|
||||
function onDismiss() {
|
||||
dismissAlert(state);
|
||||
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(); });
|
||||
|
||||
// ---- 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();
|
||||
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
|
||||
advanceDay(s, balance);
|
||||
}
|
||||
if (cardsById.has(params.get("card"))) {
|
||||
s.pendingCard = { id: params.get("card"), day: s.day };
|
||||
s.pending.weeklyCard = true;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- Start ----
|
||||
|
||||
const clock = createClock();
|
||||
let last = performance.now();
|
||||
|
||||
if (devRun) enterGame(devGame());
|
||||
else quitToMenu();
|
||||
|
||||
function frame(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;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
Reference in New Issue
Block a user