428 lines
16 KiB
JavaScript
428 lines
16 KiB
JavaScript
// 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";
|
|
import { MIX_GROUPS } from "./audio.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")}` : ""}<small>${esc(saved)}</small>`;
|
|
}
|
|
|
|
// 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 = "") =>
|
|
`<button class="brass brass--wide ${cls}" data-action="${action}" ${extra}>${label}</button>`;
|
|
const back = () => `<footer class="menu__foot">${button("back", t("menu.back"))}</footer>`;
|
|
const notice = () => (message ? `<p class="menu__msg menu__msg--${message.kind}" role="status">${esc(message.text)}</p>` : "");
|
|
|
|
function home() {
|
|
const state = getState();
|
|
if (mode === "game") {
|
|
return `
|
|
<h3>${t("menu.paused")}</h3>
|
|
<p class="menu__sub">${esc(t("menu.info", { country: state.countryName, day: state.day }))}</p>
|
|
<div class="menu__list">
|
|
${button("resume", t("menu.resume"))}
|
|
${button("save", t("menu.save"))}
|
|
${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>`;
|
|
}
|
|
const auto = autosave();
|
|
const canContinue = auto && !auto.meta.over;
|
|
return `
|
|
<h1 class="menu__title">${t("app.title")}</h1>
|
|
<p class="menu__sub">${t("menu.tagline")}</p>
|
|
${notice()}
|
|
<div class="menu__list">
|
|
${canContinue ? button("continue", `${t("menu.continue")}<small>${esc(t("menu.info", { country: auto.meta.countryName, day: auto.meta.day }))}</small>`) : ""}
|
|
${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>`;
|
|
}
|
|
|
|
function newGame() {
|
|
const ids = Object.keys(content.scenarios);
|
|
const first = content.scenarios[ids[0]];
|
|
return `
|
|
<h3>${t("new.title")}</h3>
|
|
<label class="menu__field">${t("new.name")}
|
|
<input id="new-name" type="text" maxlength="40" placeholder="${esc(tx(first.name))}" autocomplete="off">
|
|
</label>
|
|
${ids.length > 1
|
|
? `<label class="menu__field">${t("new.scenario")}
|
|
<select id="new-scenario">${ids.map((id) => `<option value="${esc(id)}">${esc(tx(content.scenarios[id].name))}</option>`).join("")}</select>
|
|
</label>`
|
|
: `<input id="new-scenario" type="hidden" value="${esc(ids[0])}">`}
|
|
<fieldset class="menu__field menu__radios">
|
|
<legend>${t("new.pronouns")}</legend>
|
|
${PRONOUNS.map((p) => `<label><input type="radio" name="pronouns" value="${p}" ${p === "they" ? "checked" : ""}> ${t("profile." + p)}</label>`).join("")}
|
|
</fieldset>
|
|
<div class="menu__list">${button("start", t("new.start"))}</div>
|
|
${back()}`;
|
|
}
|
|
|
|
function slotRow(label, entry, actions) {
|
|
return `
|
|
<li class="menu__slot">
|
|
<span class="menu__slotname">${label}</span>
|
|
<span class="menu__slotinfo">${entry ? describe(entry) : `<em>${t("load.empty")}</em>`}</span>
|
|
<span class="menu__slotbtns">${actions}</span>
|
|
</li>`;
|
|
}
|
|
|
|
const confirmable = (action, arg, label, enabled = true) => {
|
|
const isArmed = armed === `${action}:${arg}`;
|
|
return `<button class="brass ${isArmed ? "brass--armed" : ""}" data-action="${action}" data-arg="${arg}" ${enabled ? "" : "disabled"}>${isArmed ? t("load.sure") : label}</button>`;
|
|
};
|
|
|
|
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 `
|
|
<h3>${t("load.title")}</h3>
|
|
${mode === "game" ? `<p class="menu__sub">${t("load.replace")}</p>` : ""}
|
|
${notice()}
|
|
<ul class="menu__slots">${rows.join("")}</ul>
|
|
${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 `
|
|
<h3>${t("save.title")}</h3>
|
|
${notice()}
|
|
<ul class="menu__slots">${rows.join("")}</ul>
|
|
${back()}`;
|
|
}
|
|
|
|
function exportScreen() {
|
|
return `
|
|
<h3>${t("export.title")}</h3>
|
|
<p class="menu__sub">${t("export.hint")}</p>
|
|
<textarea class="menu__text" id="export-text" readonly rows="8" spellcheck="false">${esc(exportText)}</textarea>
|
|
${notice()}
|
|
<div class="menu__row">
|
|
${button("copy", t("export.copy"))}
|
|
${button("download", t("export.download"))}
|
|
</div>
|
|
${back()}`;
|
|
}
|
|
|
|
function importScreen() {
|
|
return `
|
|
<h3>${t("import.title")}</h3>
|
|
<p class="menu__sub">${t("import.hint")}${mode === "game" ? ` ${t("load.replace")}` : ""}</p>
|
|
<textarea class="menu__text" id="import-text" rows="8" spellcheck="false" placeholder="CG1:…"></textarea>
|
|
${notice()}
|
|
<div class="menu__row">
|
|
<label class="brass brass--wide menu__file">${t("import.file")}<input type="file" id="import-file" accept=".sav,.txt,text/plain" hidden></label>
|
|
${button("doImport", t("import.go"))}
|
|
</div>
|
|
${back()}`;
|
|
}
|
|
|
|
function settings() {
|
|
const s = getSettings();
|
|
const lang = getLang();
|
|
return `
|
|
<h3>${t("settings.title")}</h3>
|
|
<div class="menu__field">
|
|
<span>${t("settings.language")}</span>
|
|
<div class="menu__row">
|
|
<button class="brass" data-action="lang" data-arg="en" aria-pressed="${lang === "en"}">English</button>
|
|
<button class="brass" data-action="lang" data-arg="fr" aria-pressed="${lang === "fr"}">Français</button>
|
|
</div>
|
|
</div>
|
|
<label class="menu__field menu__check">
|
|
<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>
|
|
<div class="menu__list">${button("mixer", t("settings.mixer"))}</div>
|
|
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
|
${back()}`;
|
|
}
|
|
|
|
function credits() {
|
|
return `
|
|
<h3>${t("credits.title")}</h3>
|
|
<p>${t("credits.fiction")}</p>
|
|
<p>${t("credits.tech")}</p>
|
|
<p>${t("credits.fonts")}</p>
|
|
${back()}`;
|
|
}
|
|
|
|
// 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()}`;
|
|
}
|
|
|
|
// One slider per sound group, plus the overall volume and the music. Levels apply as you drag.
|
|
function mixer() {
|
|
const s = getSettings();
|
|
const rows = [["master", s.volume], ["music", s.mix.music], ...MIX_GROUPS.map((g) => [g, s.mix[g]])];
|
|
return `
|
|
<h3>${t("mixer.title")}</h3>
|
|
<p class="menu__sub">${t("mixer.hint")}</p>
|
|
<div class="mixer">
|
|
${rows.map(([key, v]) => `
|
|
<label class="mixer__row ${key === "master" ? "mixer__row--master" : ""}">
|
|
<span>${t("mixer." + key)}</span>
|
|
<input type="range" min="0" max="1" step="0.05" value="${v}" data-mix="${key}" aria-label="${t("mixer." + key)}">
|
|
<output>${Math.round(v * 100)}%</output>
|
|
</label>`).join("")}
|
|
</div>
|
|
<div class="menu__list">${button("mixerReset", t("mixer.reset"))}</div>
|
|
<footer class="menu__foot">${button("settings", t("menu.back"))}</footer>`;
|
|
}
|
|
|
|
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules, mixer };
|
|
|
|
function render() {
|
|
root.innerHTML = `
|
|
<div class="menu__folder">
|
|
<h2 class="inbox__tab">${t("menu.secret")}</h2>
|
|
<span class="stamp menu__stamp" aria-hidden="true">${t("menu.secret")}</span>
|
|
<div class="menu__sheet">${SCREENS[screen]()}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ---- 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"),
|
|
mixer: () => go("mixer"),
|
|
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
|
|
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("input", (e) => {
|
|
const key = e.target.dataset?.mix;
|
|
if (!key) return;
|
|
const v = Number(e.target.value);
|
|
e.target.parentElement.querySelector("output").textContent = `${Math.round(v * 100)}%`;
|
|
handlers.onSettings(key === "master" ? { volume: v } : { mix: { [key]: v } });
|
|
});
|
|
|
|
root.addEventListener("change", async (e) => {
|
|
if (e.target.dataset?.mix) handlers.onPreview?.(e.target.dataset.mix);
|
|
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 === "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();
|
|
},
|
|
};
|
|
}
|