答案是:IndexedDB 為主,LocalStorage 當備援。
Day11 把選項系統做起來之後,玩家終於可以真的沿著不同路線推進劇情。下一個很現實的問題就來了:如果玩家今天玩到第一章調查段,明天回來時,遊戲到底要怎麼知道他停在哪一句、身上有哪些證據、目前 BGM 播到哪裡、哪個場景背景該被還原?
這篇就來拆《九重燼》的存檔系統。它不是只有「把文字存起來」這麼單純,而是要保存 Ink、Pinia、UI、視覺與音訊狀態,還要能承受開發中途存檔格式改版。
LocalStorage 的限制很直接:容量通常只有 5-10MB、只能存字串、而且是同步 API,資料稍微大一點就可能卡住主執行緒。拿它存一個簡單旗標沒問題,拿它當完整遊戲存檔系統就有點勉強。
《九重燼》的存檔內容包含:
這些資料加起來已經不是「塞一個字串進去」的規模。所以 StorageAdapter.ts 預設走 IndexedDB;只有在瀏覽器環境偵測不到 indexedDB,或開啟 IndexedDB 失敗時,才 fallback 到 LocalStorage。這樣做不是因為 LocalStorage 好用,而是因為它可以當最後一層保底:功能退化,但玩家不會直接失去存檔能力。
StorageAdapter.ts 開一個名為 jiuzhongjin_storage 的 IndexedDB 資料庫,DB_VERSION 目前是 1。第一次開啟或版本升級時,onupgradeneeded 會確認四個 object store 都存在:
| Store | 用途 |
|---|---|
saveSlots |
存檔資料,key 是 payload.slotId |
settings |
遊戲設定(文字速度、音量等) |
unlocks |
解鎖記錄(結局/番外篇) |
metadata |
其他中繼資料 |
IndexedDB 不可用、開啟失敗或被其他分頁阻擋時,StorageAdapter 會 fallback 到 LocalStorage,用 jiuzhongjin_idb_fallback_ 前綴加 key 名稱儲存,例如 jiuzhongjin_idb_fallback_save_slot_1。fallback 模式下功能可用,但容量限制仍然存在;程式目前只在 console 留警告,不會把玩家帶到一個突兀的錯誤頁。
另一個在 StorageAdapter 上層出現的設計:存 settings 時同樣要先 JSON.parse(JSON.stringify(settings)) 拍平 Pinia Proxy,跟存存檔時一樣——Pinia reactive Proxy 在兩個寫入路徑都需要這個處理。
每筆存檔(SavePayload)除了遊戲資料本身,還帶了三個版本標記:
SAVE_SCHEMA_VERSION = 4 // 存檔資料結構版本
STORY_VERSION = '0.1.0' // 劇本版本
ASSET_VERSION = '0.1.0' // 素材版本
這些版本號是為了「開發中途改了存檔格式,但玩家手上還有舊存檔」這種情況準備的。讀檔時如果 schemaVersion 對不上,會先跑過 SaveMigration.ts 做欄位遷移,而不是直接讀壞或報錯。
另外每筆存檔都會算一個 checksum(用 FNV-1a hash 對整包 payload 算雜湊),讀檔時重新計算比對,確保存檔資料沒有在儲存過程中被截斷或損毀:
function makeChecksum(text: string): string {
let hash = 2166136261 // FNV offset basis
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i)
hash = Math.imul(hash, 16777619) // FNV prime
}
return (hash >>> 0).toString(16).padStart(8, '0') // 輸出 8 位十六進位
}
Math.imul 做 32 位整數乘法,避免 JavaScript 浮點數精度把 hash 算歪;>>> 0 轉成無號整數後再轉成 hex 字串。checksum 是 8 個十六進位字元,長度固定。
這裡的 checksum 不是安全機制,不是拿來防作弊,也不是拿來加密。它比較像讀檔前的健康檢查:如果 payload 在儲存或搬移時被截斷、被手動改壞、或 JSON 結構不完整,讀檔就會先停下來,不會把一包壞資料硬灌回遊戲狀態。
寫存檔邏輯時踩過一個坑,後來直接寫進程式碼註解裡:Pinia 的 reactive 物件是 Proxy,IndexedDB 的 structured clone 演算法沒辦法直接處理 Proxy。所以每次存檔前,都要先 JSON.parse(JSON.stringify(draft)) 把資料拍平成純 plain object,再交給 IndexedDB。
這是一行看起來多餘、實際上不能省的防呆。少了它,存檔在某些瀏覽器裡不是慢一點,而是會直接因為資料不可 clone 而失敗。
story.ts 裡的 _buildSavePayload() 也因此分得很細:inkState 放 Ink 匯出的 runtime state,piniaState 放數值與證據,ui 放當下畫面狀態,visual 和 audio 則讓讀檔後的背景、角色、特效、BGM 可以回到原本的位置。讀檔時會先 importState() 還原 Ink,再把 UI 狀態 assign 回 store,最後重播 BGM 與 ambience,整個畫面才會像「真的回到當下」。
讀檔不是直接把資料吐回去,中間會過一層 SaveMigration.ts 的相容性判斷:
// 三種結果:
// 'current' → 版本吻合,直接用
// 'upgradable' → 版本舊但可以升級,執行遷移後用
// 'incompatible' → 版本過新或格式損毀,拋錯
如果判定為 upgradable,SaveManager.load() 會:
migrateSavePayload() 套用升級腳本backup_${slotId}_v${oldVersion} 的 slotId 另存一份備份這步是關鍵。升級失敗時,玩家的舊存檔不會被蓋掉;升級成功後,新格式會覆寫原本 slot,玩家下次再讀同一格時就不需要重跑 migration。
SaveMigration.ts 目前已有從 V1 到 V4 的完整升級路徑(migrateV1ToV2 → migrateV2ToV3 → migrateV3ToV4),用 while 迴圈跑:存檔版本 1 的資料,會依序套用三輪升級腳本升到版本 4。新版格式加欄位時,只需要在 migration 裡補一個 case N,舊存檔就自動向前相容。
SaveManager.list() 只做 checksum 驗證,不會在列出存檔時強制升級寫入。原因很簡單,打開讀檔選單應該是「看有哪些存檔」,不應該順手改玩家資料。真正升級發生在玩家選擇讀取那一格的時候。
AUTO_SAVE_SLOT_ID = 'auto'
MANUAL_SAVE_SLOT_IDS = ['slot_1' ... 'slot_12'] // 12 個手動存檔位
UI 目前固定顯示 12 個手動欄位;自動存檔則是有資料時才出現在讀檔面板。讀檔面板也會對每一筆 payload 跑 checkSaveCompatibility():如果是版本過新或格式不相容的存檔,介面會標出「不可讀取」,讀取按鈕也會 disabled。這比讓玩家按下去才爆錯友善很多。
這套系統不是一開始就長這樣。早期版本有過比較簡單的單槽 LocalStorage 存檔,所以 story.ts 裡仍然保留 loadLegacySave(),refreshSaveSlots() 也會先把舊資料整理進新的 SaveManager 流程。
這段相容程式碼不華麗,但很必要。Demo 開發中最容易忽略的不是「新玩家能不能存檔」,而是「舊玩家更新後會不會突然讀不到」。只要曾經公開給人玩過,就要把舊格式當成真實存在的資料,而不是可以隨手丟掉的測試垃圾。
IndexedDB 主路徑 + LocalStorage fallback、三個版本號 + checksum 驗證、三狀態相容性判斷 + 升級前備份,每一層複雜度都有它被迫加進來的理由。