First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
// 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";
|
||||
|
||||
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 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("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("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>
|
||||
${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()}`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
|
||||
|
||||
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"),
|
||||
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 === "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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user