feat: Doing more polishing (M8b)

This commit is contained in:
gauvainboiche
2026-10-25 18:50:17 +01:00
parent c2103c32e7
commit c55e5a2484
34 changed files with 1605 additions and 106 deletions
+25 -16
View File
@@ -51,12 +51,15 @@ export function drawCard(state, bal, cards) {
const fiscal = computeFiscal(state, bal);
const lastAnswered = new Map();
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
const waiting = new Set(state.inbox.map((e) => e.id));
// the last few categories seen, answered or still in the tray
const recent = [...state.cardHistory, ...state.inbox].slice(-bal.cards.recentWindow).map((h) => h.category);
const pool = [];
const weights = [];
for (const card of cards) {
if (card.followUpOnly) continue;
if (card.followUpOnly || waiting.has(card.id)) continue;
if (card.scenarios && !card.scenarios.includes(state.scenarioId)) continue;
const last = lastAnswered.get(card.id);
if (last !== undefined) {
if (card.once !== false) continue;
@@ -70,28 +73,34 @@ export function drawCard(state, bal, cards) {
return pickWeighted(state, pool, weights);
}
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
// A due follow-up always comes first. With no eligible card the week is simply empty.
// Called when a new week starts (state.pending.weeklyCard): draws the week's proposal and puts it
// at the bottom of the in-tray (state.inbox). A due follow-up always comes first. With no eligible
// card the week is simply empty. Proposals pile up without stopping the clock; the game only waits
// once the tray holds bal.cards.inboxMax of them (pending.inboxFull), until one is answered.
export function ensureCard(state, bal, cards) {
if (!state.pending.weeklyCard || state.pendingCard) return null;
if (!state.pending.weeklyCard) return null;
state.pending.weeklyCard = false;
const byId = indexCards(cards);
const waiting = new Set(state.inbox.map((e) => e.id));
let card = null;
const due = state.followUps.findIndex((f) => f.due <= state.day);
const due = state.followUps.findIndex((f) => f.due <= state.day && !waiting.has(f.id));
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
card ??= drawCard(state, bal, cards);
if (!card) return null;
if (!card) {
state.pending.weeklyCard = false;
return null;
}
state.pendingCard = { id: card.id, day: state.day };
state.inbox.push({ id: card.id, day: state.day, category: card.category });
if (state.inbox.length >= bal.cards.inboxMax) state.pending.inboxFull = true;
return card;
}
export function answerCard(state, bal, cards, choice) {
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
const card = indexCards(cards).get(state.pendingCard.id);
// Answers the proposal `id` (default: the one on top of the tray, the oldest).
export function answerCard(state, bal, cards, choice, id) {
if (choice !== "yes" && choice !== "no") return { ok: false };
const at = id === undefined ? 0 : state.inbox.findIndex((e) => e.id === id);
const entry = state.inbox[at];
if (!entry) return { ok: false };
const card = indexCards(cards).get(entry.id);
if (!card) return { ok: false };
const option = card[choice];
@@ -101,7 +110,7 @@ export function answerCard(state, bal, cards, choice) {
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
state.pendingCard = null;
state.pending.weeklyCard = false;
state.inbox.splice(at, 1);
state.pending.inboxFull = state.inbox.length >= bal.cards.inboxMax;
return { ok: true, card };
}
+4 -3
View File
@@ -47,11 +47,12 @@ export function createGame({ scenario, balance, seed = Date.now(), countryName,
world: { cycle: 0 }, // global business cycle, see world.js
flags: {},
cardHistory: [],
pendingCard: null,
inbox: [], // proposals waiting in the tray, oldest first: { id, day, category }
followUps: [],
// Day 0 opens with the pronoun question, the initial budget review and a first card.
// Any true flag here pauses time (see advanceDay).
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
// Any true flag here pauses time (see advanceDay). weeklyCard is momentary: the caller answers it
// at once with ensureCard(); inboxFull stays until the tray is below its limit.
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false, inboxFull: false },
over: null,
// days in each danger zone; emergency = an unscheduled budget session is open (see advanceDay)
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false, emergency: false, lastEmergency: -999 },
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared constants and tiny helpers. No game logic here.
export const STATE_VERSION = 2;
export const STATE_VERSION = 3;
export const IND_IDS = [
"order", "stability", "civilRights", "opinion", "economy",
+5 -5
View File
@@ -5,11 +5,11 @@ export function createClock() {
}
// Feed it the real elapsed ms; it returns how many in-game days are due.
// dt is capped so a background tab doesn't fast-forward the game on return, and
// floored at 0: the first animation frame can carry a timestamp slightly before
// the one the caller recorded, and a negative step would pre-load a whole day.
export function consumeClock(clock, dtMs, msPerDay, speed) {
clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed;
// dt is capped (capMs) so a frozen tab doesn't fast-forward the game without limit when it wakes
// up, and floored at 0: the first animation frame can carry a timestamp slightly before the one the
// caller recorded, and a negative step would pre-load a whole day.
export function consumeClock(clock, dtMs, msPerDay, speed, capMs = 1000) {
clock.acc += Math.max(0, Math.min(dtMs, capMs)) * speed;
const days = Math.floor(clock.acc / msPerDay);
clock.acc -= days * msPerDay;
return days;
+8 -3
View File
@@ -9,7 +9,7 @@ const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "tax
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert", "inboxFull"];
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
@@ -73,7 +73,11 @@ export function restoreState(s, { scenarios, balance, cards }) {
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
}
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
if (!Array.isArray(s.inbox) || s.inbox.length > balance.cards.inboxMax) return fail();
for (const e of s.inbox) {
if (!isObj(e) || typeof e.id !== "string" || !isInt(e.day, 0, balance.time.termDays)) return fail();
if (e.category !== undefined && typeof e.category !== "string") return fail();
}
// Pauses, alerts and endings
if (!isObj(s.pending)) return fail();
@@ -95,7 +99,8 @@ export function restoreState(s, { scenarios, balance, cards }) {
// Repairs: content that changed since the save was made
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
s.inbox = s.inbox.filter((e) => cardIds.has(e.id));
s.pending.inboxFull = s.inbox.length >= balance.cards.inboxMax;
s.pending.alert = s.alerts.length > 0;
return { ok: true, state: s };
}
+17 -1
View File
@@ -186,5 +186,21 @@
"credits.title": "Credits",
"credits.fiction": "Valdoria and everyone in it are fictional.",
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game."
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game.",
"inbox.full": "The tray is full. Time is stopped until you answer one.",
"shelf.nav": "Choose a display",
"shelf.tray": "Tray",
"shelf.brief": "Briefing",
"shelf.news": "News",
"shelf.clock": "Calendar",
"shelf.ledger": "Ledger",
"shelf.menu": "Menu",
"shelf.waiting": "{n} waiting",
"menu.rules": "Rules",
"rules.title": "Rules of the desk",
"rules.loading": "Opening the rule book…",
"rules.error": "The rule book could not be opened.",
"settings.sound": "Sounds",
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
"settings.volume": "Volume"
}
+17 -1
View File
@@ -186,5 +186,21 @@
"credits.title": "Crédits",
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu."
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu.",
"inbox.full": "La corbeille est pleine. Le temps est arrêté tant que vous n'en traitez pas une.",
"shelf.nav": "Choisir l'affichage",
"shelf.tray": "Corbeille",
"shelf.brief": "Note",
"shelf.news": "Journal",
"shelf.clock": "Calendrier",
"shelf.ledger": "Registre",
"shelf.menu": "Menu",
"shelf.waiting": "{n} en attente",
"menu.rules": "Règles",
"rules.title": "Règlement du bureau",
"rules.loading": "Ouverture du règlement…",
"rules.error": "Le règlement n'a pas pu être ouvert.",
"settings.sound": "Sons",
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
"settings.volume": "Volume"
}
+128 -33
View File
@@ -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();
+12 -1
View File
@@ -46,6 +46,13 @@ export function encodeSave(state) {
export const MIGRATIONS = {
// v2 added the world cycle: older saves start it at neutral.
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
// v3 replaced the single pending card by the in-tray queue.
2: ({ pendingCard, ...s }) => ({
...s,
version: 3,
inbox: pendingCard ? [{ id: pendingCard.id, day: pendingCard.day }] : [],
pending: { ...s.pending, weeklyCard: Boolean(s.pending?.weeklyCard) && !pendingCard, inboxFull: false },
}),
};
export function migrate(state) {
@@ -55,7 +62,11 @@ export function migrate(state) {
while (s.version < STATE_VERSION) {
const step = MIGRATIONS[s.version];
if (!step) return { ok: false, error: "version" };
s = step(s);
try {
s = step(s);
} catch {
return { ok: false, error: "format" }; // a damaged or hostile save must never throw
}
}
return { ok: true, state: s };
}
+3 -1
View File
@@ -88,7 +88,7 @@ export function readSave(store, key) {
// ---- Settings ----
const DEFAULT_SETTINGS = { lang: null, slow: false };
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6 };
export function loadSettings(store) {
try {
@@ -96,6 +96,8 @@ export function loadSettings(store) {
return {
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
slow: s.slow === true,
sound: s.sound !== false,
volume: typeof s.volume === "number" && s.volume >= 0 && s.volume <= 1 ? s.volume : DEFAULT_SETTINGS.volume,
};
} catch {
return { ...DEFAULT_SETTINGS };
+3
View File
@@ -0,0 +1,3 @@
// A heartbeat for the game loop. Browsers stop animation frames in a background tab and slow
// down its timers, but a worker's timer keeps going, so the game can keep running while hidden.
setInterval(() => postMessage(0), 100);
+136
View File
@@ -0,0 +1,136 @@
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
let ctx = null;
let master = null;
let noiseBuf = null;
function context() {
if (!ctx) {
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch {
return null;
}
master = ctx.createGain();
master.connect(ctx.destination);
}
return ctx;
}
function unlock() {
const c = context();
if (c && c.state === "suspended") c.resume?.().catch?.(() => {});
}
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
// One note: a sine (or other) wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, at);
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(g).connect(master);
o.start(at);
o.stop(at + dur + 0.05);
}
// Filtered noise: paper, pages, thumps and clicks.
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
noiseBuf ??= (() => {
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
})();
const src = c.createBufferSource();
src.buffer = noiseBuf;
src.loop = true;
const f = c.createBiquadFilter();
f.type = type;
f.Q.value = q;
f.frequency.setValueAtTime(freq, at);
if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur);
const g = c.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(f).connect(g).connect(master);
src.start(at);
src.stop(at + dur + 0.05);
}
const SOUNDS = {
stampYes: (c, t) => {
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
},
stampNo: (c, t) => {
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
},
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
page: (c, t) => {
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
},
sign: (c, t) => {
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
},
telegram: (c, t) => {
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
},
alert: (c, t) => {
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
},
chime: (c, t) => {
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
},
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
click: (c, t) => {
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
},
fall: (c, t) => {
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
},
fanfare: (c, t) => {
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
},
};
function play(name) {
if (!enabled()) return;
const make = SOUNDS[name];
const c = context();
if (!make || !c) return;
if (c.state === "suspended") {
c.resume?.().catch?.(() => {});
return; // still locked: nothing to hear yet
}
master.gain.value = Math.max(0, Math.min(1, volume()));
try {
make(c, c.currentTime + 0.005);
} catch {
/* a failing sound never stops the game */
}
}
return { play, unlock, names: Object.keys(SOUNDS) };
}
+23 -8
View File
@@ -1,17 +1,22 @@
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
// controls and the way into the menu.
// Desk calendar: a dial clock that turns once per in-game day, the date, term progress, next
// decision dates, the brass speed controls and the way into the menu.
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $ } from "./desk.js";
import { $, dialSvg, setDial } from "./desk.js";
export function createCalendar(root, bal, { onSpeed, onMenu }) {
const speeds = bal.time.speeds;
root.innerHTML = `
<div class="cal__head"><span class="cal__weekday"></span></div>
<div class="cal__body">
<div class="cal__day"></div>
<div class="cal__month"></div>
<div class="cal__top">
${dialSvg("cal__dial")}
<div class="cal__date">
<div class="cal__day"></div>
<div class="cal__month"></div>
</div>
</div>
<div class="cal__count"></div>
<div class="cal__term"><span></span></div>
<dl class="cal__next">
@@ -38,17 +43,27 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
};
const dial = $(root, ".cal__dial");
let lastDay = -1;
function update(state) {
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".cal__weekday").textContent = p.weekday;
$(root, ".cal__day").textContent = p.day;
const dayEl = $(root, ".cal__day");
dayEl.textContent = p.day;
if (lastDay >= 0 && state.day !== lastDay && state.speed > 0 && state.speed <= 2) {
dayEl.classList.remove("is-flip");
void dayEl.offsetWidth; // restart the animation
dayEl.classList.add("is-flip");
}
lastDay = state.day;
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
const shown = Math.min(state.day + 1, bal.time.termDays);
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
const over = Boolean(state.over);
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
for (const b of root.querySelectorAll("button[data-speed]")) {
@@ -56,5 +71,5 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
}
}
return { update };
return { update, clockHand: (progress) => setDial(dial, progress) };
}
+9 -1
View File
@@ -63,6 +63,7 @@ export function createDashboard(root, scenario) {
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
}]));
const sparkLen = {};
const shownValue = {};
function trend(series, eps, goodUp) {
if (!series || series.length < 2) return ["", ""];
@@ -82,7 +83,14 @@ export function createDashboard(root, scenario) {
g.fill.style.width = `${v}%`;
g.el.classList.toggle("is-low", v < 30);
g.el.classList.toggle("is-high", v >= 70);
g.value.textContent = Math.round(v);
const r = Math.round(v);
g.value.textContent = r;
if (shownValue[id] !== undefined && shownValue[id] !== r) {
g.value.classList.remove("is-up", "is-down");
void g.value.offsetWidth; // restart the animation
g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down");
}
shownValue[id] = r;
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
const series = state.history.series[id] ?? [];
+48 -13
View File
@@ -1,26 +1,34 @@
// Centre folder: the weekly decision card as a paper document with two rubber
// stamps. Also shows the idle and end-of-term messages.
// Centre folder: the in-tray. The oldest waiting proposal is the document on top, answered with
// two rubber stamps; the others show as a pile of sheets under it. Also shows the idle and
// end-of-term messages.
import { t, tx, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, esc } from "./desk.js";
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
const STAMP_MS = 600; // how long the stamp animation plays before the paper leaves the tray
const LEAVE_MS = 320; // and how long the paper takes to slide away
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
export function createDecision(root, { cardsById, characters, inboxMax = 10, onAnswer, onStamp = () => {}, onProfile, keysEnabled = () => true }) {
root.innerHTML = `
<h2 class="inbox__tab">${t("inbox.title")}</h2>
<h2 class="inbox__tab">${t("inbox.title")} <span class="inbox__count" data-count></span></h2>
<p class="inbox__full" role="status" hidden>${t("inbox.full")}</p>
<div class="inbox__body"></div>`;
const body = $(root, ".inbox__body");
const countEl = $(root, "[data-count]");
const fullEl = $(root, ".inbox__full");
let shown = "";
let locked = false;
let latest = null;
let lastCount = 0;
let timers = [];
function choose(choice) {
if (locked) return;
const card = $(body, ".card");
if (!card) return;
const id = latest?.inbox[0]?.id;
if (!card || !id) return;
locked = true;
card.dataset.stamped = choice;
for (const b of card.querySelectorAll("button")) b.disabled = true;
@@ -28,7 +36,9 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
stamp.className = `card__stamp card__stamp--${choice}`;
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
card.append(stamp);
setTimeout(() => onAnswer(choice), STAMP_MS);
onStamp(choice);
timers.push(setTimeout(() => card.classList.add("card--leave"), STAMP_MS));
timers.push(setTimeout(() => onAnswer(choice, id), STAMP_MS + LEAVE_MS));
}
body.addEventListener("click", (e) => {
@@ -40,7 +50,7 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
const onKey = (e) => {
if (!keysEnabled() || !latest?.pendingCard) return;
if (!keysEnabled() || !latest?.inbox.length) return;
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target.closest?.("input, textarea, select")) return;
@@ -61,10 +71,10 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
function cardHtml(card, state) {
const from = characters[card.sender];
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
const date = dateParts(dateOfDay(state.startDate, state.inbox[0].day).iso);
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
return `
<article class="card card--${esc(card.doc)}">
<article class="card card--enter card--${esc(card.doc)}" data-pile="${Math.min(3, state.inbox.length - 1)}">
<header class="card__head">
<span class="card__kind">${t("kind." + card.doc)}</span>
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
@@ -80,15 +90,34 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
</article>`;
}
// The tab counts the proposals waiting; the pile under the top paper grows with them.
function updateTray(state) {
const n = state.inbox.length;
countEl.textContent = n ? `${n}/${inboxMax}` : "";
countEl.classList.toggle("is-warn", n >= inboxMax - 2);
if (n > lastCount) {
countEl.classList.remove("is-bump");
void countEl.offsetWidth; // restart the animation
countEl.classList.add("is-bump");
}
lastCount = n;
fullEl.hidden = !(state.pending.inboxFull && !state.over);
const card = $(body, ".card");
if (card) card.dataset.pile = Math.min(3, Math.max(0, n - 1));
}
function update(state) {
latest = state;
updateTray(state);
let kind = "idle";
if (state.over) kind = "over";
else if (state.pending.profile) kind = "profile";
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
else if (state.inbox.length) kind = `card:${state.inbox[0].id}`;
if (kind === shown) return;
shown = kind;
locked = false;
timers.forEach(clearTimeout);
timers = [];
body.classList.toggle("is-card", kind.startsWith("card:"));
if (kind === "profile") {
@@ -105,9 +134,15 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
} else if (kind === "over") {
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
} else {
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
body.innerHTML = cardHtml(cardsById.get(state.inbox[0].id), state);
}
}
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
return {
update,
destroy: () => {
timers.forEach(clearTimeout);
document.removeEventListener("keydown", onKey);
},
};
}
+19
View File
@@ -16,3 +16,22 @@ export const $ = (root, sel) => root.querySelector(sel);
// Escape text for use inside innerHTML templates.
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
// A 12-hour dial whose single hand makes one full turn per in-game day. progress is 0..1.
export function dialSvg(className = "") {
const ticks = Array.from({ length: 12 }, (_, i) => {
const long = i % 3 === 0;
return `<line x1="50" y1="${long ? 8 : 10}" x2="50" y2="${long ? 18 : 15}" transform="rotate(${i * 30} 50 50)"/>`;
}).join("");
return `
<svg class="dial ${className}" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
<circle class="dial__face" cx="50" cy="50" r="46"/>
<g class="dial__ticks">${ticks}</g>
<line class="dial__hand" x1="50" y1="54" x2="50" y2="16" transform="rotate(0 50 50)"/>
<circle class="dial__pin" cx="50" cy="50" r="4"/>
</svg>`;
}
export function setDial(svg, progress) {
svg?.querySelector(".dial__hand")?.setAttribute("transform", `rotate(${(progress * 360).toFixed(1)} 50 50)`);
}
+66
View File
@@ -0,0 +1,66 @@
// A tiny Markdown renderer for the rules. It supports exactly what the rules use: #, ##, ### headings,
// paragraphs, - and 1. lists (no nesting), **bold**, *italic*, `code`, pipe tables, --- and > quotes.
// Everything is escaped first, so a rules file can never inject markup.
import { esc } from "./desk.js";
function inline(text) {
return esc(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?![*\w])/g, "$1<em>$2</em>");
}
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
export function renderMarkdown(src) {
const lines = src.replace(/\r\n?/g, "\n").split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
const h = /^(#{1,3})\s+(.*)$/.exec(line);
if (h) {
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
i++;
continue;
}
if (/^-{3,}\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
if (/^\|.*\|\s*$/.test(line) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
out.push(
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
`<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`,
);
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ""));
out.push(`<blockquote>${quote.map(inline).join(" ")}</blockquote>`);
continue;
}
const list = /^(-|\d+\.)\s+/.exec(line);
if (list) {
const tag = list[1] === "-" ? "ul" : "ol";
const re = tag === "ul" ? /^-\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const items = [];
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])[1]);
out.push(`<${tag}>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|-{3,}\s*$|>|\||(-|\d+\.)\s)/.test(lines[i])) para.push(lines[i++]);
out.push(`<p>${inline(para.join(" "))}</p>`);
}
return out.join("\n");
}
+31 -1
View File
@@ -7,6 +7,7 @@ import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
import { renderMarkdown } from "./markdown.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
@@ -17,6 +18,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
let armed = null; // "action:arg" waiting for a second click
let armTimer = 0;
let exportText = "";
const rulesCache = {}; // language -> rendered html, or "error"
const say = (text, kind = "ok") => { message = { text, kind }; };
const go = (next) => { screen = next; message = null; disarm(); render(); };
@@ -85,6 +87,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("load", t("menu.load"))}
${button("export", t("menu.export"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("quit", t("menu.quit"))}
</div>`;
@@ -100,6 +103,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${button("new", t("menu.new"))}
${button("load", t("menu.load"))}
${button("import", t("menu.import"))}
${button("rules", t("menu.rules"))}
${button("settings", t("menu.settings"))}
${button("credits", t("menu.credits"))}
</div>`;
@@ -213,6 +217,13 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
</label>
<label class="menu__field menu__check">
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
</label>
<label class="menu__field">${t("settings.volume")}
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
</label>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
@@ -226,7 +237,23 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
// The rules are plain Markdown files (data/rules/rules.<lang>.md), fetched on first use.
function rules() {
const lang = getLang();
const body = rulesCache[lang];
if (body === undefined) {
rulesCache[lang] = null; // loading
fetch(`data/rules/rules.${lang}.md`)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
.catch(() => { rulesCache[lang] = "error"; })
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
}
const html = body === "error" ? `<p class="menu__msg menu__msg--error">${t("rules.error")}</p>` : body ?? `<p class="menu__sub">${t("rules.loading")}</p>`;
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
function render() {
root.innerHTML = `
@@ -296,6 +323,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
export: () => { exportText = encodeSave(getState()); go("export"); },
import: () => go("import"),
settings: () => go("settings"),
rules: () => go("rules"),
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
@@ -336,6 +364,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
root.addEventListener("change", async (e) => {
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
+3
View File
@@ -77,6 +77,9 @@ export function createNewspaper(root, tpl) {
if (key === shown) return;
shown = key;
root.classList.remove("is-fresh");
void root.offsetWidth; // restart the fade
root.classList.add("is-fresh");
const fill = (s) => s.replaceAll("{country}", state.countryName);
const [lead, ...rest] = computeHeadlines(state, tpl);
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
+56
View File
@@ -0,0 +1,56 @@
// Phone layout only: the shelf along the top of the desk. It shows the date and a small dial,
// cycles the speed, and picks which single paper is on the desk (tray, briefing, news,
// calendar, and the ledger while a budget review is open).
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, dialSvg, setDial } from "./desk.js";
const VIEWS = ["tray", "brief", "news", "clock"];
export function createShelf(root, bal, { getView, onView, onSpeed, onMenu }) {
const speeds = [0, ...bal.time.speeds];
root.innerHTML = `
<div class="shelf__time">
${dialSvg("shelf__dial")}
<div class="shelf__date"><b></b><span></span></div>
<button class="brass shelf__speed" data-speed-cycle></button>
</div>
<div class="shelf__tabs" role="group" aria-label="${t("shelf.nav")}">
${VIEWS.map((v) => `<button class="shelf__tab" data-view="${v}"><span>${t("shelf." + v)}</span>${v === "tray" ? `<i class="shelf__badge" data-badge></i>` : ""}</button>`).join("")}
<button class="shelf__tab shelf__tab--ledger" data-view="ledger" hidden><span>${t("shelf.ledger")}</span></button>
<button class="shelf__tab shelf__tab--menu" data-menu><span>${t("shelf.menu")}</span></button>
</div>`;
const dial = $(root, ".shelf__dial");
let speed = 0;
root.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.menu !== undefined) onMenu();
else if (b.dataset.speedCycle !== undefined) onSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length]);
else if (b.dataset.view) onView(b.dataset.view);
});
function update(state) {
const d = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".shelf__date b").textContent = `${d.weekday} ${d.day} ${d.month}`;
$(root, ".shelf__date span").textContent = d.year;
speed = state.speed;
const sp = $(root, ".shelf__speed");
sp.textContent = speed === 0 ? "II" : `${speed}×`;
sp.setAttribute("aria-label", speed === 0 ? t("cal.pause") : t("cal.speed", { n: speed }));
const view = getView();
const ledger = Boolean(state.pending.review && !state.pending.profile && !state.over);
$(root, '[data-view="ledger"]').hidden = !ledger;
for (const b of root.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === view));
const badge = $(root, "[data-badge]");
badge.textContent = state.inbox.length || "";
badge.hidden = state.inbox.length === 0;
badge.classList.toggle("is-warn", state.inbox.length >= bal.cards.inboxMax - 2);
}
return { update, clockHand: (p) => setDial(dial, p) };
}