チャプター 06 / 06

6. セーブとロード

MitiruEngine のデータ駆動なセーブ / ロード。SaveSchema + MigrationChain + SchemaValidator + ContentLoader を組み合わせて、互換性のあるセーブを書く。

「途中で電源を落としても続きから始められる」はジャンルを問わない必須機能です。MitiruEngine はセーブを 「JSON blob + スキーマ + マイグレーション」 の組として扱います。

  • セーブの型は C++ 側で struct として定義する
  • nlohmann/json の NLOHMANN_DEFINE_TYPE_INTRUSIVE (or NON_INTRUSIVE) でシリアライズを生やす
  • SaveSchema<T> でバージョンつきの読み書きにする
  • 旧 → 新の互換性は MigrationChain で一段ずつ書く

これでアップデートで型が変わっても、古いセーブを壊さずに読めます。

スキーマ進化と MigrationChain SaveV1 の JSON blob は MigrationChain によって V1 → V2 → V3 と段階的に変換され、最終的に現行 SaveSchema に流し込まれる。

SaveV1 blob playerName / hp gold / chapterIndex version: 1

migrate 1 → 2 + silver = 0 + tutorialDone = false

migrate 2 → 3 (将来の変更)

現行 SaveSchema<T> deserialize() → T struct

SchemaValidator マイグレーション後に整合性を検査 (フィールド不足 / 型不一致を検出)。失敗すれば deserialize は std::nullopt を返す。

図: 旧バージョンのセーブは MigrationChain で順番に新しい形に変換され、SchemaValidator を通って現行 struct に入る。

セーブの型を書く

#include <mitiru/data/SaveSchema.hpp>

struct SaveV1 {
    std::string playerName;
    int hp = 100;
    int gold = 0;
    int chapterIndex = 0;
};
NLOHMANN_DEFINE_TYPE_INTRUSIVE(SaveV1, playerName, hp, gold, chapterIndex);

バージョンとマイグレーション

ある日「ゴールド → 貨幣 (gold / silver) に分割」を入れたいとき:

struct SaveV2 {
    std::string playerName;
    int hp = 100;
    int gold = 0;
    int silver = 0;          // 新フィールド
    int chapterIndex = 0;
    bool tutorialDone = false; // 新フィールド
};
NLOHMANN_DEFINE_TYPE_INTRUSIVE(
    SaveV2, playerName, hp, gold, silver, chapterIndex, tutorialDone);

// 旧 V1 から V2 へのマイグレーション
mitiru::data::MigrationChain<SaveV2> migrations;
migrations.add(1, [](const nlohmann::json& v1) {
    auto v2 = v1;
    v2["silver"] = 0;
    v2["tutorialDone"] = false;
    return v2;
});

mitiru::data::SaveSchema<SaveV2> schema{
    /*currentVersion=*/2,
    std::move(migrations)
};

読み書き

// 書く
std::string blob = schema.serialize(currentSave);
writeToDisk("save_slot_01.json", blob);

// 読む (旧バージョンは自動でマイグレートされる)
auto loaded = schema.deserialize(readFromDisk("save_slot_01.json"));
if (loaded) {
    currentSave = *loaded;
}

UI と組み合わせる

セーブスロット UI は CEF 側 HTML で組み、「セーブする」「スロット選んでロード」のシグナルを C++ に送るだけにします。スロットの中身 (時刻、章タイトル、プレイ時間) は C++ が読み込んでから JS に push します。

state.set("save.slot.1.exists", true);
state.set("save.slot.1.label",  loaded->playerName + " — Ch." + std::to_string(loaded->chapterIndex));

ContentLoader と SchemaValidator

セーブとは別に、「読み取り専用のコンテンツ (アイテム表、ステージ定義、敵パラメータ)」も同じ仕組みで読めます。ContentLoader<T> で構造体に流し込み、SchemaValidator でロード前に整合性チェック ── というのが推奨パターンです。

関連 API

もっと深く知る