feat: Doing more polishing (M8b)
This commit is contained in:
+25
-16
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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)`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user