2026-06-22 02:03:19 +09:00
"use strict" ;
( function ( global ) {
const STORAGE _PREFIX = "tarinai_save_slot_v1_" ;
const EXPORT _PREFIX = "TARINAI_SAVE_V1:" ;
2026-06-23 11:27:55 +09:00
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 _.
2026-06-22 02:03:19 +09:00
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 ) ;
2026-06-23 11:27:55 +09:00
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 ;
}
2026-06-22 02:03:19 +09:00
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 , "" ) ;
}
2026-06-23 11:27:55 +09:00
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 ;
}
2026-06-22 02:03:19 +09:00
function base64ToBytes ( text ) {
2026-06-23 11:27:55 +09:00
let b64 = stripExportPrefix ( text , [ EXPORT _PREFIX ] ) . replace ( /-/g , "+" ) . replace ( /_/g , "/" ) ;
2026-06-22 02:03:19 +09:00
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 ;
}
2026-06-23 11:27:55 +09:00
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 ) {
2026-06-22 02:03:19 +09:00
const json = JSON . stringify ( snapshot ) ;
const bytes = new TextEncoder ( ) . encode ( json ) ;
return EXPORT _PREFIX + bytesToBase64 ( bytes ) ;
}
2026-06-23 11:27:55 +09:00
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 ) {
2026-06-22 02:03:19 +09:00
const raw = String ( text || "" ) . trim ( ) ;
2026-06-23 11:27:55 +09:00
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 ) ;
2026-06-22 02:03:19 +09:00
}
function slotKey ( slot ) { return ` ${ STORAGE _PREFIX } ${ slot } ` ; }
2026-06-23 11:27:55 +09:00
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 ( ) {
2026-06-22 02:03:19 +09:00
const snapshot = createSnapshot ( global . world ) ;
2026-06-23 11:27:55 +09:00
snapshot . thumbnail = makeThumbnail ( ) ;
return snapshot ;
}
function saveSlot ( slot ) {
const snapshot = makeSaveSnapshot ( ) ;
2026-06-22 02:03:19 +09:00
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" } ) : "日時不明" ;
2026-06-23 11:27:55 +09:00
return ` ${ date } \n ${ d . fieldType || "庭" } / ${ d . day || 1 } 日目 ${ d . time || "" } \n ${ d . population || 0 } 匹 / 道具 ${ d . items || 0 } ` ;
2026-06-22 02:03:19 +09:00
}
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" > セーブ / ロード < / h 2 >
2026-06-23 11:27:55 +09:00
< p class = "hint" > 9 つのスロットに現在のコロニーを保存できます 。 ハッシュテキストは圧縮して短く書き出します 。 < / p >
< div id = "saveSlotList" class = "save-slot-list save-slot-grid" > < / d i v >
2026-06-22 02:03:19 +09:00
< div class = "save-export-panel" >
< div class = "save-export-actions" >
< button id = "saveExportBtn" class = "btn" type = "button" > 現在の状態を書き出し < / b u t t o n >
< button id = "saveImportBtn" class = "btn primary" type = "button" > ハッシュテキストを読み込み < / b u t t o n >
< / d i v >
2026-06-23 11:27:55 +09:00
< textarea id = "saveHashText" class = "save-hash-text" spellcheck = "false" placeholder = "ここに短縮ハッシュテキストが表示されます。読み込む場合はここに貼り付けてください。" > < / t e x t a r e a >
< / d i v >
< div id = "saveConfirmBox" class = "save-confirm-box hidden" role = "alertdialog" aria - modal = "false" >
< div class = "save-confirm-message" > < / d i v >
< div class = "save-confirm-actions" >
< button class = "btn" data - save - confirm = "cancel" type = "button" > やめる < / b u t t o n >
< button class = "btn danger" data - save - confirm = "ok" type = "button" > 実行 < / b u t t o n >
< / d i v >
2026-06-22 02:03:19 +09:00
< / d i v >
< div class = "field-dialog-actions" >
< button id = "saveCloseBtn" class = "btn" type = "button" > 閉じる < / b u t t o n >
< / d i v >
< / d i v > ` ;
document . body . appendChild ( dialog ) ;
return dialog ;
}
2026-06-23 11:27:55 +09:00
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> ` ;
}
2026-06-22 02:03:19 +09:00
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" ) ;
2026-06-23 11:27:55 +09:00
row . className = ` save-slot-card ${ snapshot ? "filled" : "empty" } ` ;
2026-06-22 02:03:19 +09:00
row . innerHTML = `
2026-06-23 11:27:55 +09:00
< div class = "save-slot-thumb" > $ { slotThumbnailHtml ( snapshot ) } < / d i v >
2026-06-22 02:03:19 +09:00
< div class = "save-slot-title" > スロット $ { i } < / d i v >
2026-06-23 11:27:55 +09:00
< div class = "save-slot-meta" > $ { htmlEscape ( formatSlotSummary ( snapshot ) ) . replace ( /\n/g , "<br>" ) } < / d i v >
2026-06-22 02:03:19 +09:00
< div class = "save-slot-actions" >
< button class = "btn mini" data - save - slot = "${i}" type = "button" > 保存 < / b u t t o n >
< button class = "btn mini" data - load - slot = "${i}" type = "button" $ { snapshot ? "" : "disabled" } > 読込 < / b u t t o n >
< button class = "btn mini danger" data - delete - slot = "${i}" type = "button" $ { snapshot ? "" : "disabled" } > 削除 < / b u t t o n >
< / d i v > ` ;
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 ( ) ;
2026-06-23 11:27:55 +09:00
dialog . addEventListener ( "click" , async ( e ) => {
2026-06-22 02:03:19 +09:00
if ( e . target === dialog ) { closeDialog ( ) ; return ; }
2026-06-23 11:27:55 +09:00
if ( e . target . closest ( "#saveConfirmBox" ) ) return ;
2026-06-22 02:03:19 +09:00
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 ) ;
2026-06-23 11:27:55 +09:00
if ( ! await confirmInGame ( ` スロット ${ slot } を読み込みます。現在の状態は上書きされます。 ` , "読み込む" ) ) return ;
2026-06-22 02:03:19 +09:00
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 ) ;
2026-06-23 11:27:55 +09:00
if ( ! await confirmInGame ( ` スロット ${ slot } を削除しますか? ` , "削除" ) ) return ;
2026-06-22 02:03:19 +09:00
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" ) ;
2026-06-23 11:27:55 +09:00
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 ;
2026-06-22 02:03:19 +09:00
}
}
if ( e . target . closest ( "#saveImportBtn" ) ) {
const area = dialog . querySelector ( "#saveHashText" ) ;
const text = area ? . value || "" ;
if ( ! text . trim ( ) ) { global . showToast ? . ( "読み込むハッシュテキストを貼り付けてください。" ) ; return ; }
2026-06-23 11:27:55 +09:00
if ( ! await confirmInGame ( "ハッシュテキストを読み込みます。現在の状態は上書きされます。" , "読み込む" ) ) return ;
2026-06-22 02:03:19 +09:00
try {
2026-06-23 11:27:55 +09:00
restoreSnapshot ( await decodeSnapshot ( text ) , global . world ) ;
2026-06-22 02:03:19 +09:00
closeDialog ( ) ;
2026-06-23 11:27:55 +09:00
global . audio ? . notify ? . ( ) ;
2026-06-22 02:03:19 +09:00
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 ) ;