"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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 = `

セーブ / ロード

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは圧縮して短く書き出します。

`; 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 ``; return `
No Image
`; } 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 = `
${slotThumbnailHtml(snapshot)}
スロット ${i}
${htmlEscape(formatSlotSummary(snapshot)).replace(/\n/g, "
")}
`; 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);