tarinai/js/save_system.js
2026-06-23 11:27:55 +09:00

417 lines
17 KiB
JavaScript

"use strict";
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v1_";
const EXPORT_PREFIX = "TARINAI_SAVE_V1:";
const EXPORT_PREFIX_COMPRESSED = "TARINAI-SAVE-V11:";
// Alpha note: save/import compatibility is not guaranteed yet.
// Hash imports intentionally accept only the current V11 format during alpha.
// V11 stores compact semantic early-game saves when possible, falls back to the V10 binary packet for complex states, and encodes bytes with printable ASCII excluding \, *, ' and _.
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 htmlEscape = global.TarinaiUIHelpers?.htmlEscape || ((v) => String(v ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])));
const BASE94_ALPHABET = (() => {
const excluded = new Set(["\\", "*", "'", "_"]);
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (!excluded.has(ch)) out += ch;
}
return out;
})();
const BASE94_RADIX = BASE94_ALPHABET.length;
const BASE94_DECODE = (() => {
const map = Object.create(null);
for (let i = 0; i < BASE94_ALPHABET.length; i++) map[BASE94_ALPHABET[i]] = i;
return map;
})();
function bytesToBase94(bytes) {
if (!bytes || !bytes.length) return "!";
let zeros = 0;
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
const digits = [0];
for (let i = zeros; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j++) {
const x = digits[j] * 256 + carry;
digits[j] = x % BASE94_RADIX;
carry = Math.floor(x / BASE94_RADIX);
}
while (carry > 0) {
digits.push(carry % BASE94_RADIX);
carry = Math.floor(carry / BASE94_RADIX);
}
}
let out = BASE94_ALPHABET[0].repeat(zeros);
for (let i = digits.length - 1; i >= 0; i--) out += BASE94_ALPHABET[digits[i]];
return out || BASE94_ALPHABET[0];
}
function base94ToBytes(text) {
const str = String(text || "").replace(/\s/g, "");
if (!str) return new Uint8Array();
let zeros = 0;
while (zeros < str.length && str[zeros] === BASE94_ALPHABET[0]) zeros++;
const bytes = [0];
for (let i = zeros; i < str.length; i++) {
const digit = BASE94_DECODE[str[i]];
if (digit === undefined) throw new Error("invalid base94 character");
let carry = digit;
for (let j = 0; j < bytes.length; j++) {
const x = bytes[j] * BASE94_RADIX + carry;
bytes[j] = x & 255;
carry = x >> 8;
}
while (carry > 0) {
bytes.push(carry & 255);
carry >>= 8;
}
}
const out = new Uint8Array(zeros + bytes.length);
for (let i = 0; i < zeros; i++) out[i] = 0;
for (let i = 0; i < bytes.length; i++) out[out.length - 1 - i] = bytes[i];
return out;
}
function bytesToBase64(bytes) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function stripExportPrefix(text, prefixes) {
let value = String(text || "").trim();
const hashless = value.replace(/^#+/, "");
if (prefixes.some(prefix => hashless.startsWith(prefix))) value = hashless;
for (const prefix of prefixes) {
if (value.startsWith(prefix)) return value.slice(prefix.length);
}
return value;
}
function base64ToBytes(text) {
let b64 = stripExportPrefix(text, [EXPORT_PREFIX]).replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function gzipBytes(bytes) {
if (typeof CompressionStream !== "function") return null;
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function gunzipBytes(bytes) {
if (typeof DecompressionStream !== "function") throw new Error("compressed import is not supported in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function encodeSnapshotLegacy(snapshot) {
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
return EXPORT_PREFIX + bytesToBase64(bytes);
}
async function encodeSnapshot(snapshot) {
if (snapshot?.__tarinaiCompactBytes && snapshot.bytes) {
const raw = snapshot.bytes instanceof Uint8Array ? snapshot.bytes : new Uint8Array(snapshot.bytes);
const compressed = await gzipBytes(raw);
if (compressed && compressed.length < raw.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed);
return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(raw);
}
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
const compressed = await gzipBytes(bytes);
if (compressed && compressed.length < bytes.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed);
return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(bytes);
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
const normalized = raw.replace(/^#+/, "");
if (!normalized) throw new Error("empty import text");
if (!normalized.startsWith(EXPORT_PREFIX_COMPRESSED)) throw new Error("unsupported save hash version");
const body = stripExportPrefix(normalized, [EXPORT_PREFIX_COMPRESSED]);
const mode = body[0] || "r";
const payload = body.slice(1);
const bytes = base94ToBytes(payload);
const packet = mode === "z" ? await gunzipBytes(bytes) : bytes;
if (!Snapshot.decodeV11SnapshotBytes) throw new Error("V11 decoder is not available");
return Snapshot.decodeV11SnapshotBytes(packet);
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function makeThumbnail() {
const source = global.canvas || document.getElementById("gameCanvas");
if (!source || !source.width || !source.height) return "";
try {
const tw = 168;
const th = 96;
const c = document.createElement("canvas");
c.width = tw;
c.height = th;
const ctx = c.getContext("2d", { alpha: false });
if (!ctx) return "";
ctx.fillStyle = "#f6efe3";
ctx.fillRect(0, 0, tw, th);
const scale = Math.min(tw / source.width, th / source.height);
const w = Math.max(1, Math.round(source.width * scale));
const h = Math.max(1, Math.round(source.height * scale));
const x = Math.floor((tw - w) / 2);
const y = Math.floor((th - h) / 2);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(source, x, y, w, h);
return c.toDataURL("image/jpeg", 0.58);
} catch (_) {
return "";
}
}
function makeSaveSnapshot() {
const snapshot = createSnapshot(global.world);
snapshot.thumbnail = makeThumbnail();
return snapshot;
}
function saveSlot(slot) {
const snapshot = makeSaveSnapshot();
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
function loadSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) throw new Error("empty slot");
return restoreSnapshot(JSON.parse(raw), 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; }
}
function formatSlotSummary(snapshot) {
if (!snapshot) return "空き";
const d = snapshot.summary || {};
const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明";
return `${date}\n${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""}\n${d.population || 0}匹 / 道具${d.items || 0}`;
}
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">セーブ / ロード</h2>
<p class="hint">9つのスロットに現在のコロニーを保存できます。ハッシュテキストは圧縮して短く書き出します。</p>
<div id="saveSlotList" class="save-slot-list save-slot-grid"></div>
<div class="save-export-panel">
<div class="save-export-actions">
<button id="saveExportBtn" class="btn" type="button">現在の状態を書き出し</button>
<button id="saveImportBtn" class="btn primary" type="button">ハッシュテキストを読み込み</button>
</div>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="ここに短縮ハッシュテキストが表示されます。読み込む場合はここに貼り付けてください。"></textarea>
</div>
<div id="saveConfirmBox" class="save-confirm-box hidden" role="alertdialog" aria-modal="false">
<div class="save-confirm-message"></div>
<div class="save-confirm-actions">
<button class="btn" data-save-confirm="cancel" type="button">やめる</button>
<button class="btn danger" data-save-confirm="ok" type="button">実行</button>
</div>
</div>
<div class="field-dialog-actions">
<button id="saveCloseBtn" class="btn" type="button">閉じる</button>
</div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function confirmInGame(message, okLabel = "実行") {
const dialog = ensureDialog();
const box = dialog.querySelector("#saveConfirmBox");
if (!box) return Promise.resolve(false);
box.querySelector(".save-confirm-message").textContent = message;
const ok = box.querySelector('[data-save-confirm="ok"]');
if (ok) ok.textContent = okLabel;
box.classList.remove("hidden");
return new Promise(resolve => {
const finish = (value) => {
box.classList.add("hidden");
box.removeEventListener("click", onClick);
resolve(value);
};
const onClick = (e) => {
const btn = e.target.closest("[data-save-confirm]");
if (!btn) return;
finish(btn.dataset.saveConfirm === "ok");
};
box.addEventListener("click", onClick);
});
}
function slotThumbnailHtml(snapshot) {
const src = snapshot?.thumbnail || "";
if (src) return `<img src="${htmlEscape(src)}" alt="" loading="lazy">`;
return `<div class="save-slot-thumb-empty">No Image</div>`;
}
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-card ${snapshot ? "filled" : "empty"}`;
row.innerHTML = `
<div class="save-slot-thumb">${slotThumbnailHtml(snapshot)}</div>
<div class="save-slot-title">スロット ${i}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot)).replace(/\n/g, "<br>")}</div>
<div class="save-slot-actions">
<button class="btn mini" data-save-slot="${i}" type="button">保存</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>読込</button>
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>削除</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; }
if (e.target.closest("#saveConfirmBox")) 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 {
saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.showToast?.(`スロット${slot}に保存しました。`);
} catch (error) {
console.warn(error);
global.showToast?.("保存に失敗しました。空き容量を確認してください。");
}
return;
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!await confirmInGame(`スロット${slot}を読み込みます。現在の状態は上書きされます。`, "読み込む")) return;
try {
loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.showToast?.(`スロット${slot}を読み込みました。`);
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。");
}
return;
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!await confirmInGame(`スロット${slot}を削除しますか?`, "削除")) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.showToast?.(`スロット${slot}を削除しました。`);
return;
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const area = dialog.querySelector("#saveHashText");
const exportBtn = dialog.querySelector("#saveExportBtn");
try {
if (exportBtn) exportBtn.disabled = true;
if (area) {
area.value = "書き出し中…";
const fullSnapshot = createSnapshot(global.world);
const exportSnapshot = Snapshot.createCompactSnapshot ? Snapshot.createCompactSnapshot(global.world) : fullSnapshot;
const text = await encodeSnapshot(exportSnapshot);
area.value = text;
area.focus();
area.select();
const legacy = encodeSnapshotLegacy(fullSnapshot);
const rate = legacy.length ? Math.round((1 - text.length / legacy.length) * 100) : 0;
global.showToast?.(rate > 0 ? `軽量ハッシュを書き出しました(約${rate}%短縮)。` : "現在の状態を書き出しました。");
}
} catch (error) {
console.warn(error);
global.showToast?.("書き出しに失敗しました。");
} finally {
if (exportBtn) exportBtn.disabled = false;
}
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; }
if (!await confirmInGame("ハッシュテキストを読み込みます。現在の状態は上書きされます。", "読み込む")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();
global.audio?.notify?.();
global.showToast?.("ハッシュテキストから読み込みました。");
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。テキストを確認してください。");
}
}
});
}
global.TarinaiSaveSystem = {
createSnapshot,
restoreSnapshot,
encodeSnapshot,
decodeSnapshot,
saveSlot,
loadSlot,
deleteSlot,
readSlot,
bindSaveSystem,
openDialog,
};
})(typeof window !== "undefined" ? window : globalThis);