First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+369
View File
@@ -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();
},
};
}