iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄系列 第 12

Day12 存檔與讀檔機制:LocalStorage 還是 IndexedDB?

  • 分享至 

  • xImage
  •  

答案是:IndexedDB 為主,LocalStorage 當備援

Day11 把選項系統做起來之後,玩家終於可以真的沿著不同路線推進劇情。下一個很現實的問題就來了:如果玩家今天玩到第一章調查段,明天回來時,遊戲到底要怎麼知道他停在哪一句、身上有哪些證據、目前 BGM 播到哪裡、哪個場景背景該被還原?

這篇就來拆《九重燼》的存檔系統。它不是只有「把文字存起來」這麼單純,而是要保存 Ink、Pinia、UI、視覺與音訊狀態,還要能承受開發中途存檔格式改版。


為什麼不能只用 LocalStorage

LocalStorage 的限制很直接:容量通常只有 5-10MB、只能存字串、而且是同步 API,資料稍微大一點就可能卡住主執行緒。拿它存一個簡單旗標沒問題,拿它當完整遊戲存檔系統就有點勉強。

《九重燼》的存檔內容包含:

  • Ink runtime 匯出的劇情狀態
  • Pinia 裡的能力值、關係值、證據、設定與 log
  • 當前 UI 狀態,例如說話者、文字、選項、面板
  • 場景視覺狀態,例如背景、角色、表情、CG、特效
  • 音訊狀態,例如 BGM 與環境音

這些資料加起來已經不是「塞一個字串進去」的規模。所以 StorageAdapter.ts 預設走 IndexedDB;只有在瀏覽器環境偵測不到 indexedDB,或開啟 IndexedDB 失敗時,才 fallback 到 LocalStorage。這樣做不是因為 LocalStorage 好用,而是因為它可以當最後一層保底:功能退化,但玩家不會直接失去存檔能力。


IndexedDB 的資料庫結構:四個 Object Store

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 在兩個寫入路徑都需要這個處理。


存檔的資料結構:三個版本號 + checksum

每筆存檔(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 放當下畫面狀態,visualaudio 則讓讀檔後的背景、角色、特效、BGM 可以回到原本的位置。讀檔時會先 importState() 還原 Ink,再把 UI 狀態 assign 回 store,最後重播 BGM 與 ambience,整個畫面才會像「真的回到當下」。


讀檔時的三狀態相容性判斷

讀檔不是直接把資料吐回去,中間會過一層 SaveMigration.ts 的相容性判斷:

// 三種結果:
// 'current'      → 版本吻合,直接用
// 'upgradable'   → 版本舊但可以升級,執行遷移後用
// 'incompatible' → 版本過新或格式損毀,拋錯

如果判定為 upgradableSaveManager.load() 會:

  1. migrateSavePayload() 套用升級腳本
  2. 備份舊存檔:把原始存檔以 backup_${slotId}_v${oldVersion} 的 slotId 另存一份
  3. 覆寫新格式的存檔

備份這步是關鍵。升級失敗時,玩家的舊存檔不會被蓋掉;升級成功後,新格式會覆寫原本 slot,玩家下次再讀同一格時就不需要重跑 migration。

SaveMigration.ts 目前已有從 V1 到 V4 的完整升級路徑(migrateV1ToV2migrateV2ToV3migrateV3ToV4),用 while 迴圈跑:存檔版本 1 的資料,會依序套用三輪升級腳本升到版本 4。新版格式加欄位時,只需要在 migration 裡補一個 case N,舊存檔就自動向前相容。

SaveManager.list() 只做 checksum 驗證,不會在列出存檔時強制升級寫入。原因很簡單,打開讀檔選單應該是「看有哪些存檔」,不應該順手改玩家資料。真正升級發生在玩家選擇讀取那一格的時候。


存檔位置設計:12 個手動存檔

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 驗證、三狀態相容性判斷 + 升級前備份,每一層複雜度都有它被迫加進來的理由。


上一篇
Day11 選項系統實作:Choice、Flag 與劇情控制
下一篇
Day13 遊戲 UI 設計:打造沉浸式文字冒險介面
系列文
《九重燼》Vue3 + PixiJS + Ink.js 視覺小說遊戲開發全紀錄15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言