「途中で電源を落としても続きから始められる」はジャンルを問わない必須機能です。MitiruEngine はセーブを 「JSON blob + スキーマ + マイグレーション」 の組として扱います。
- セーブの型は C++ 側で
structとして定義する - nlohmann/json の
NLOHMANN_DEFINE_TYPE_INTRUSIVE(orNON_INTRUSIVE) でシリアライズを生やす SaveSchema<T>でバージョンつきの読み書きにする- 旧 → 新の互換性は
MigrationChainで一段ずつ書く
これでアップデートで型が変わっても、古いセーブを壊さずに読めます。
セーブの型を書く
#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
mitiru::data::SaveSchemamitiru::data::MigrationChainmitiru::data::ContentLoadermitiru::data::SchemaValidatormitiru::data::SchemaImporter
もっと深く知る
- アーキテクチャ — セーブ操作も発火だけを JS から送る (signal-only bridge)
- docs/cpp-gameplay-api-gaps.md