NarraLeaf

localStorage によるセーブシステム

serialize と deserialize を土台に、パースと互換性チェックを備えた 5 スロットのセーブシステムを localStorage に実装する

NarraLeaf が提供するのはセーブデータであり、保存用の UI ではありません。serialize()SavedGame を返し、deserialize() はそれを復元します。この例では、5 つの通常スロットと 1 つのクイックセーブスロットを localStorage に保持します。

localStorage は端末ローカルかつ同期的で、ユーザーが直接書き換えるのも容易です。小規模なブラウザゲームでの利用に向いています。セーブデータが大きい場合や、端末間で同期する必要がある場合は、IndexedDB、ファイル、またはサーバーを使用してください。

ストレージのヘルパー

パース処理と互換性チェックは 1 か所にまとめておきます。

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 などのバリデーターを使用してください。

セーブ・ロード用フック

現在のゲームを置き換える前に、ストーリーの 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;
}

QuickSaveKeysGameProviders の内側にマウントします。結果をゲーム側で知らせたい場合は、2 つの分岐に liveGame.notify() を追加してください。

セーブに含まれる内容

SavedGame には、Persistent の名前空間、シーンローカルの状態、要素の状態、アクションスタック、非同期スタック、プレイヤーの舞台状態、バックログ、登録済み Service のデータが含まれます。Preference は別扱いで、game.preference.exportPreferences() を使って個別にエクスポートします。

セーブフォーマット v3(0.26.0)以降、書き出される要素の状態はスクリプトの記述と異なる部分だけになり、読み込み時にはセーブを適用する前にすべての要素がリセットされます。そのため、セーブのサイズはプロジェクトの規模ではなく、舞台上に何があるかで決まります。0.26.0 で書き出されたセーブは、それより古いエンジンでは正しく読み込めません。詳細は SavedGame を参照してください。

このページの目次