tarinai/js/save_system.js
2026-06-24 13:48:22 +09:00

323 lines
13 KiB
JavaScript

"use strict";
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v2_";
const EXPORT_PREFIX = "TN2!";
const SLOT_COUNT = 9;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js");
const createSnapshot = (...args) => Snapshot.createSnapshot(...args);
const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args);
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function encodeSnapshot(snapshot) {
const compact = snapshot && snapshot.v === 2 ? snapshot : createSnapshot(global.world);
const json = JSON.stringify(compact);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
const data = JSON.parse(json);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
async function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
async function loadSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) throw new Error("empty slot");
const snapshot = JSON.parse(raw);
if (snapshot?.v !== 2) throw new Error("unsupported slot version");
return restoreSnapshot(snapshot, global.world);
}
function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); }
function readSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function formatSlotSummary(snapshot) {
if (!snapshot) return "\u7a7a\u304d";
const m = snapshot.m || [];
const date = snapshot.c ? new Date(snapshot.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e";
const day = m[0] || 1;
const population = m[1] || 0;
const fieldType = m[2] || "\u5ead";
const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : "";
return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339`;
}
function ensureDialog() {
let dialog = document.getElementById("saveDialog");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "saveDialog";
dialog.className = "field-dialog hidden";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.innerHTML = `
<div class="field-dialog-panel save-dialog-panel">
<h2 id="saveDialogTitle">\u30bb\u30fc\u30d6 / \u30ed\u30fc\u30c9</h2>
<p class="hint save-warning">\u30bb\u30fc\u30d6\u6a5f\u80fd\u306f\u5b9f\u9a13\u4e2d\u3067\u3042\u308a\u3001\u4fdd\u8a3c\u5bfe\u8c61\u5916\u3067\u3059\u3002</p>
<p class="hint">9\u3064\u306e\u30b9\u30ed\u30c3\u30c8\u306b\u73fe\u5728\u306e\u30b3\u30ed\u30cb\u30fc\u3092\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306f\u5225\u7aef\u672b\u3084\u5225\u30d6\u30e9\u30a6\u30b6\u3078\u306e\u79fb\u52d5\u306b\u4f7f\u3048\u307e\u3059\u3002</p>
<div id="saveSlotList" class="save-slot-list"></div>
<div class="save-export-panel">
<div class="save-export-actions">
<button id="saveExportBtn" class="btn" type="button">\u73fe\u5728\u306e\u72b6\u614b\u3092\u66f8\u304d\u51fa\u3057</button>
<button id="saveImportBtn" class="btn primary" type="button">\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f</button>
</div>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="\u3053\u3053\u306b\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304c\u8868\u793a\u3055\u308c\u307e\u3059\u3002\u8aad\u307f\u8fbc\u3080\u5834\u5408\u306f\u3053\u3053\u306b\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"></textarea>
</div>
<div class="field-dialog-actions">
<button id="saveCloseBtn" class="btn" type="button">\u9589\u3058\u308b</button>
</div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function renderSlotList() {
const dialog = ensureDialog();
const list = dialog.querySelector("#saveSlotList");
if (!list) return;
list.innerHTML = "";
for (let i = 1; i <= SLOT_COUNT; i++) {
const snapshot = readSlot(i);
const row = document.createElement("div");
row.className = "save-slot-row";
row.innerHTML = `
<div class="save-slot-title">\u30b9\u30ed\u30c3\u30c8 ${i}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot))}</div>
<div class="save-slot-actions">
<button class="btn mini" data-save-slot="${i}" type="button">\u4fdd\u5b58</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u8aad\u8fbc</button>
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u524a\u9664</button>
</div>`;
list.appendChild(row);
}
}
function openDialog() {
const dialog = ensureDialog();
renderSlotList();
dialog.classList.remove("hidden");
}
function closeDialog() {
document.getElementById("saveDialog")?.classList.add("hidden");
}
function bindSaveSystem() {
const btn = document.getElementById("saveBtn") || global.ui?.saveBtn;
if (!btn || btn.dataset.saveBound === "1") return;
btn.dataset.saveBound = "1";
btn.addEventListener("click", () => {
global.audio?.uiClick?.();
openDialog();
});
const dialog = ensureDialog();
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
const saveBtn = e.target.closest("[data-save-slot]");
const loadBtn = e.target.closest("[data-load-slot]");
const deleteBtn = e.target.closest("[data-delete-slot]");
if (saveBtn) {
const slot = Number(saveBtn.dataset.saveSlot);
try {
await saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
return;
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
try {
await loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
}
return;
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`);
return;
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = await encodeSnapshot(createSnapshot(global.world));
area.focus();
area.select();
}
global.showToast?.("\u73fe\u5728\u306e\u72b6\u614b\u3092\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306b\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002");
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (error) {
console.warn(error);
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30c6\u30ad\u30b9\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
}
});
}
global.TarinaiSaveSystem = {
createSnapshot,
restoreSnapshot,
encodeSnapshot,
decodeSnapshot,
saveSlot,
loadSlot,
deleteSlot,
readSlot,
bindSaveSystem,
openDialog,
};
})(typeof window !== "undefined" ? window : globalThis);