323 lines
13 KiB
JavaScript
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);
|