"use strict"; (function (global) { const STORAGE_PREFIX = "tarinai_save_slot_v1_"; const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; 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); 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 base64ToBytes(text) { let b64 = String(text || "").trim().replace(/^#+/, "").replace(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; } function encodeSnapshot(snapshot) { const json = JSON.stringify(snapshot); const bytes = new TextEncoder().encode(json); return EXPORT_PREFIX + bytesToBase64(bytes); } function decodeSnapshot(text) { const raw = String(text || "").trim(); if (!raw) throw new Error("empty import text"); if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw); const bytes = base64ToBytes(raw); const json = new TextDecoder().decode(bytes); return JSON.parse(json); } function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } function saveSlot(slot) { const snapshot = createSnapshot(global.world); 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; } } const htmlEscape = global.TarinaiUIHelpers.htmlEscape; 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} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 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 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 = `
スロット ${i}
${htmlEscape(formatSlotSummary(snapshot))}
`; 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", (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 { 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 (!global.confirm?.(`スロット${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 (!global.confirm?.(`スロット${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"); if (area) { area.value = encodeSnapshot(createSnapshot(global.world)); area.focus(); area.select(); } global.showToast?.("現在の状態をハッシュテキストに書き出しました。"); } if (e.target.closest("#saveImportBtn")) { const area = dialog.querySelector("#saveHashText"); const text = area?.value || ""; if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } if (!global.confirm?.("ハッシュテキストを読み込みます。現在の状態は上書きされます。")) return; try { restoreSnapshot(decodeSnapshot(text), global.world); closeDialog(); 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);