NarraLeaf

使用 localStorage 实现存档系统

基于 serialize 与 deserialize,在 localStorage 中实现带校验的五槽位存档系统

NarraLeaf 提供存档数据,不提供存储界面。serialize() 返回 SavedGamedeserialize() 恢复状态。下面实现五个普通槽位和一个快速存档槽位

localStorage 只保存在当前设备,读写是同步的,而且用户可以直接修改。小型网页游戏可以使用;存档较大或需要跨设备同步时,应改用 IndexedDB、文件或服务器

存储工具

把解析和兼容性检查集中在一个文件中

save-storage.ts
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() 不会自动做这项检查

use-save-system.ts
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

本页目录