使用 localStorage 实现存档系统
基于 serialize 与 deserialize,在 localStorage 中实现带校验的五槽位存档系统
NarraLeaf 提供存档数据,不提供存储界面。serialize() 返回 SavedGame,deserialize() 恢复状态。下面实现五个普通槽位和一个快速存档槽位
localStorage 只保存在当前设备,读写是同步的,而且用户可以直接修改。小型网页游戏可以使用;存档较大或需要跨设备同步时,应改用 IndexedDB、文件或服务器
存储工具
把解析和兼容性检查集中在一个文件中
import type { SavedGame } from "narraleaf-react";
const PREFIX = "my-game:save:";
export function writeSave(slot: string, save: SavedGame) {
localStorage.setItem(PREFIX + slot, JSON.stringify(save));
}
export function readSave(slot: string): SavedGame | null {
const raw = localStorage.getItem(PREFIX + slot);
if (!raw) return null;
try {
const value: unknown = JSON.parse(raw);
if (!isSavedGame(value)) return null;
return value;
} catch {
return null;
}
}
export function deleteSave(slot: string) {
localStorage.removeItem(PREFIX + slot);
}
function isSavedGame(value: unknown): value is SavedGame {
if (!value || typeof value !== "object") return false;
const save = value as Partial<SavedGame>;
return typeof save.name === "string"
&& typeof save.meta?.updated === "number"
&& typeof save.meta?.storyHash === "string"
&& typeof save.game === "object";
}这只是最低限度的类型检查。存档来自上传或服务器时,建议使用 Zod、Valibot 等工具校验完整结构
保存与读取 Hook
覆盖当前游戏前先检查故事 hash。deserialize() 不会自动做这项检查
import { useLiveGame } from "narraleaf-react";
import { readSave, writeSave } from "./save-storage";
export function useSaveSystem() {
const liveGame = useLiveGame();
function save(slot: string) {
const data = liveGame.serialize();
writeSave(slot, data);
return data.meta;
}
function load(slot: string) {
const data = readSave(slot);
if (!data || !liveGame.story) return false;
if (data.meta.storyHash !== liveGame.story.hash()) {
throw new Error("该存档来自不兼容的故事版本。 ");
}
liveGame.deserialize(data);
return true;
}
return { save, load };
}修改动作顺序、分支、场景或其他故事结构后,旧存档可能失效。只修改台词通常不会改变非严格模式下的 hash
存档槽位 UI
读取元数据显示保存时间和最后一句台词。写入后重新读取一次本地状态
import { useState } from "react";
import type { SavedGameMetaData } from "narraleaf-react";
import { readSave } from "./save-storage";
import { useSaveSystem } from "./use-save-system";
const SLOT_IDS = ["1", "2", "3", "4", "5"];
export function SaveSlots() {
const { save, load } = useSaveSystem();
const [slots, setSlots] = useState(() => readMetadata());
function saveSlot(slot: string) {
save(slot);
setSlots(readMetadata());
}
return (
<div className="grid grid-cols-2 gap-3">
{SLOT_IDS.map((slot) => {
const meta = slots[slot];
return (
<section key={slot} className="rounded border p-3">
<strong>槽位 {slot}</strong>
{meta && (
<>
<p>{new Date(meta.updated).toLocaleString()}</p>
<p>{meta.lastSpeaker ?? "旁白"}:{meta.lastSentence}</p>
</>
)}
<button onClick={() => saveSlot(slot)}>{meta ? "覆盖" : "保存"}</button>
<button disabled={!meta} onClick={() => load(slot)}>读取</button>
</section>
);
})}
</div>
);
}
function readMetadata(): Record<string, SavedGameMetaData | null> {
return Object.fromEntries(
SLOT_IDS.map((slot) => [slot, readSave(slot)?.meta ?? null]),
);
}快速保存与读取
在 effect 中注册键盘事件,并在清理函数中移除。F5、Ctrl+S 等按键有浏览器默认行为,除非明确需要覆盖,否则选择其他组合
import { useEffect } from "react";
import { useSaveSystem } from "./use-save-system";
export function QuickSaveKeys() {
const { save, load } = useSaveSystem();
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "F6") {
event.preventDefault();
save("quick");
} else if (event.key === "F9") {
event.preventDefault();
load("quick");
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [save, load]);
return null;
}把 QuickSaveKeys 挂载在 GameProviders 内。需要提示结果时,在两个分支中调用 liveGame.notify()
存档包含什么
SavedGame 包含 Persistent 命名空间、场景局部状态、元素状态、动作栈、异步动作栈、播放器舞台状态、回溯历史和已注册 Service 的数据。偏好设置不在其中,需要通过 game.preference.exportPreferences() 单独导出
自存档格式 v3(0.26.0)起,写出的元素状态只包含与剧本所写不同的部分,读档会在应用存档之前重置每一个元素 —— 因此存档的大小取决于舞台上有什么,而不是项目有多大。0.26.0 写出的存档无法被更旧的引擎正确读取,参见 SavedGame