// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time. // Modes: "main" (before a game) and "game" (pause menu while a game is running). // The menu never changes the game itself; it hands results to main.js through `handlers`. import { t, tx, getLang, dateParts } from "../i18n/i18n.js"; 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 export function createMenu(root, { content, storage, getState, getSettings, handlers }) { let mode = "main"; let screen = "home"; let message = null; // { text, kind: "ok" | "error" } 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(); }; function disarm() { armed = null; clearTimeout(armTimer); } function arm(key) { armed = key; clearTimeout(armTimer); armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS); render(); } // ---- Reading saves ---- const autosave = () => readSave(storage, KEYS.auto); function describe(entry) { const scenario = content.scenarios[entry.meta.scenarioId]; let date = ""; if (scenario) { const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso); date = ` · ${p.day} ${p.month} ${p.year}`; } const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt)); const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date }); return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}${esc(saved)}`; } // Turns a stored or imported state into a playable one, or explains why not. function restore(rawState) { const migrated = migrate(rawState); if (!migrated.ok) return migrated; return restoreState(migrated.state, content); } function loadEntry(entry) { const r = entry ? restore(entry.state) : { ok: false, error: "invalid" }; if (!r.ok) { say(t("load.unreadable"), "error"); render(); return; } handlers.onLoadState(r.state); } // ---- Screens ---- const button = (action, label, extra = "", cls = "") => ``; const back = () => ``; const notice = () => (message ? `` : ""); function home() { const state = getState(); if (mode === "game") { return `

${t("menu.paused")}

`; } const auto = autosave(); const canContinue = auto && !auto.meta.over; return `

${t("app.title")}

${notice()} `; } function newGame() { const ids = Object.keys(content.scenarios); const first = content.scenarios[ids[0]]; return `

${t("new.title")}

${ids.length > 1 ? `` : ``} ${back()}`; } function slotRow(label, entry, actions) { return ` `; } const confirmable = (action, arg, label, enabled = true) => { const isArmed = armed === `${action}:${arg}`; return ``; }; function load() { const rows = []; const auto = autosave(); rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : "")); for (let n = 1; n <= SLOT_COUNT; n++) { const entry = readSave(storage, KEYS.slot(n)); rows.push(slotRow(t("load.slot", { n }), entry, entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : "")); } return `

${t("load.title")}

${mode === "game" ? `` : ""} ${notice()} ${back()}`; } function save() { const rows = []; for (let n = 1; n <= SLOT_COUNT; n++) { const entry = readSave(storage, KEYS.slot(n)); rows.push(slotRow(t("load.slot", { n }), entry, confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here")))); } return `

${t("save.title")}

${notice()} ${back()}`; } function exportScreen() { return `

${t("export.title")}

${notice()} ${back()}`; } function importScreen() { return `

${t("import.title")}

${notice()} ${back()}`; } function settings() { const s = getSettings(); const lang = getLang(); return `

${t("settings.title")}

${storage.persistent ? "" : ``} ${back()}`; } function credits() { return `

${t("credits.title")}

${t("credits.fiction")}

${t("credits.tech")}

${t("credits.fonts")}

${back()}`; } // The rules are plain Markdown files (data/rules/rules..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" ? `` : body ?? ``; return `
${html}
${back()}`; } const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules }; function render() { root.innerHTML = ` `; } // ---- Actions ---- async function copyExport() { const box = $(root, "#export-text"); try { await navigator.clipboard.writeText(exportText); say(t("export.copied")); } catch { box.select(); say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok"); } render(); } function download() { const state = getState(); const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game"; const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" })); const a = document.createElement("a"); a.href = url; a.download = `${slug}-day${state.day}.sav`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function doImport() { const text = $(root, "#import-text").value; if (!text.trim()) { say(t("import.error.empty"), "error"); render(); return; } const decoded = decodeSave(text); const r = decoded.ok ? restoreState(decoded.state, content) : decoded; if (!r.ok) { say(t("import.error." + r.error), "error"); const keep = text; render(); $(root, "#import-text").value = keep; return; } handlers.onLoadState(r.state); } function start() { const id = $(root, "#new-scenario").value; const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they"; const name = $(root, "#new-name").value.trim(); handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns }); } const ACTIONS = { continue: () => loadEntry(autosave()), new: () => go("new"), load: () => go("load"), save: () => go("save"), export: () => { exportText = encodeSave(getState()); go("export"); }, import: () => go("import"), settings: () => go("settings"), rules: () => go("rules"), credits: () => go("credits"), back: () => go("home"), resume: () => handlers.onResume(), quit: () => handlers.onQuit(), start, copy: copyExport, download, doImport, lang: (arg) => { handlers.onSettings({ lang: arg }); render(); }, loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))), deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); }, saveSlot: (arg) => { const ok = writeSave(storage, KEYS.slot(arg), getState()); disarm(); say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error"); render(); }, }; // Destructive actions need a second click on the same button. const NEEDS_CONFIRM = { loadEntry: () => mode === "game", deleteSlot: () => true, saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null, }; root.addEventListener("click", (e) => { const el = e.target.closest("[data-action]"); if (!el || el.disabled) return; const { action, arg } = el.dataset; const key = `${action}:${arg}`; if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) { arm(key); return; } disarm(); ACTIONS[action](arg); }); 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(); } }); document.addEventListener("keydown", (e) => { if (root.hidden || e.key !== "Escape") return; if (screen !== "home") go("home"); else if (mode === "game") handlers.onResume(); }); return { show(nextMode, nextScreen = "home") { mode = nextMode; screen = nextScreen; message = null; disarm(); render(); root.hidden = false; }, hide() { root.hidden = true; root.innerHTML = ""; disarm(); }, // Re-render after the language changed. refresh() { if (!root.hidden) render(); }, }; }