このガイドでは、こんなものを動かすところまで行きます。
- ゲームの状態 (プレイヤー名、HP、ゴールド、章番号) を 1 つの構造体にまとめる
- 「セーブする」「ロードする」を呼ぶと JSON ファイルに書き出し / 読み戻し
- バージョン 1 → 2 へのスキーマ変更 (例: ゴールドを
gold/silverに分割) を、マイグレーション関数 を 1 つ書くだけで吸収 - バージョン 1 で保存した古いセーブが、新バージョンでもそのまま読める
時間の目安は 20 〜 30 分。
アクション、RPG、シミュレーション、どのジャンルでも必要になる機能です。MitiruEngine では mitiru::data::SaveSchema<T> と MigrationChain でこれを データ駆動 に書きます。
事前に はじめに を済ませ、mitiru doctor が OK を返すことを確認してください。
全体の構成
| やること | どこに書く |
|---|---|
| セーブする型 | C++ の struct + nlohmann/json マクロ |
| バージョン番号と互換変換 | C++ の MigrationChain |
| 読み書きの本体 | C++ の SaveSchema<T> |
| ディスク I/O | 標準 <fstream> (お好みで OS API) |
| UI (スロット選択など) | HTML 側で組み、cefQuery で C++ へ |
「セーブの形」は C++ で 型として 持ちます。これでコンパイラがフィールドの抜けを見つけてくれます。
手順 1: プロジェクトを作る
mitiru new my-save
cd my-save
手順 2: セーブの型を書く (v1)
src/save_types.hpp を新規作成します。mitiru build は src/*.cpp を自動で拾うので、.hpp を追加するだけで CMake を触る必要はありません。
#pragma once
#include <string>
#include <nlohmann/json.hpp>
namespace mygame {
struct SaveV1 {
std::string playerName;
int hp = 100;
int gold = 0;
int chapterIndex = 0;
};
NLOHMANN_DEFINE_TYPE_INTRUSIVE(SaveV1, playerName, hp, gold, chapterIndex);
} // namespace mygame
NLOHMANN_DEFINE_TYPE_INTRUSIVE は 構造体の中で 書きます。これで nlohmann/json が自動で to_json / from_json を生やしてくれます。
手順 3: 書く・読むを呼んでみる (まだ v1 だけ)
src/main.cpp を開いて以下の内容に書き換えます。
#include <fstream>
#include <iostream>
#include <mitiru/data/SaveSchema.hpp>
#include "save_types.hpp"
namespace data = mitiru::data;
int main() {
// currentVersion=1、まだ migration なし
data::SaveSchema<mygame::SaveV1> schema{ /*currentVersion=*/1 };
// 書く
mygame::SaveV1 s;
s.playerName = "プレイヤー1号";
s.hp = 80;
s.gold = 120;
s.chapterIndex = 3;
std::string blob = schema.serialize(s);
std::ofstream("save_01.json") << blob;
// 読む
std::ifstream in("save_01.json");
std::string json((std::istreambuf_iterator<char>(in)), {});
auto loaded = schema.deserialize(json);
if (loaded) {
std::cout << "loaded: " << loaded->playerName
<< " hp=" << loaded->hp
<< " gold=" << loaded->gold
<< " ch=" << loaded->chapterIndex << "\n";
}
}
schema.serialize() は メタ情報 (version) と本体を 1 つの JSON にまとめた blob を返します。ファイル名や保存場所は呼び出し側の自由。
ビルドして実行:
mitiru run
save_01.json が生成され、loaded: プレイヤー1号 hp=80 gold=120 ch=3 のような出力が出れば OK です。
手順 4: スキーマを変える (v2)
リリースから数か月、「ゴールドだけだと味気ない、銀貨を追加したい」「チュートリアル進行を別フラグで持ちたい」となったとします。
新しい型 SaveV2 を src/save_types.hpp に 古い型と共存させて 追加します。古い SaveV1 は削除しません。
// save_types.hpp に追加
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);
手順 4: マイグレーションを書く
「v1 で保存された JSON を v2 の形に変換する」関数を 1 つ書きます。これが MigrationChain に登録される単位です。
#include <mitiru/data/MigrationChain.hpp>
data::MigrationChain<mygame::SaveV2> migrations;
migrations.add(1, [](const nlohmann::json& v1) {
// v1 → v2: 新フィールドを default 値で埋める
auto v2 = v1;
v2["silver"] = 0;
v2["tutorialDone"] = false;
return v2;
});
data::SaveSchema<mygame::SaveV2> schemaV2{
/*currentVersion=*/2,
std::move(migrations)
};
migrations.add(1, ...) の 1 は「from バージョン」。「ファイルに version=1 と書いてあったら、このラムダを通せば v2 になる」という意味です。
複数段のマイグレーション (v1 → v2 → v3) を書きたいときは、migrations.add(1, ...) と migrations.add(2, ...) の 2 つを登録すれば、SaveSchema が自動で順番に適用します。
手順 5: 読み戻して、v1 が v2 として読めるか確認
// 既存の save_01.json (v1) を SchemaV2 で読み戻す
std::ifstream in("save_01.json");
std::string json((std::istreambuf_iterator<char>(in)), {});
auto loaded = schemaV2.deserialize(json);
if (loaded) {
std::cout << "loaded as V2: " << loaded->playerName
<< " gold=" << loaded->gold
<< " silver=" << loaded->silver // 0
<< " tutorialDone=" << loaded->tutorialDone // false
<< "\n";
}
loaded->silver は 0、loaded->tutorialDone は false で復元されます。プログラマーは “v1 の JSON を読んでいる” ことを意識せずに済みます 。古いセーブを壊さず、機能を増やせるのがこの仕組みの売りです。
これ以降は schemaV2.serialize() で書き出せば、新規セーブは v2 として保存されます。
UI と組み合わせるとき
セーブのスロット選択 UI は CEF 側 HTML で組み、「セーブする」「スロットを選んでロード」のイベントだけを cefQuery で C++ に送ります。スロットの中身 (時刻、章タイトル、プレイ時間) は C++ がディスクから読んだ後、executeJavaScript か state push で JS に渡します。
// C++ → JS にスロット情報を push する例
state.set("save.slot.1.exists", true);
state.set("save.slot.1.label",
loaded->playerName + " — Ch." + std::to_string(loaded->chapterIndex));
セーブ UI 自体は HTML / CSS で組めるので、ジャンルに合った見た目 (パステル調 / 武骨 / レトロ) を後から差し替えられます。
どこに保存するか
このチュートリアルではカレントディレクトリに save_01.json を作りましたが、本番では OS のアプリデータディレクトリ (Windows なら %APPDATA%、macOS なら ~/Library/Application Support/) に置くのが一般的です。mitiru::Engine 自体には現状 OS 別ディレクトリの helper は無いので、自分で std::filesystem を組むか、サードパーティ (PlatformFolders など) を使ってください。
何が分かったか
- セーブは 「型」「バージョン」「マイグレーション」の 3 点セット にすると、後から壊れにくい。
- マイグレーションは 1 つの関数 = 1 ステップ。複数段は自動で連結される。
- フィールドの追加は default 値を入れるだけで済む。削除 や 意味の変更 だけは丁寧に扱う必要がある (フィールドを
playerName→displayNameに変える時など)。
同じ仕組みは他用途にも使える
SaveSchema + MigrationChain は「セーブ専用」というより、バージョンつきの JSON 永続化フレームワーク です。
- 設定ファイル: 音量、キーバインド、解像度などをユーザー単位で持つ。
- コンテンツ定義: アイテム表、敵パラメータ、ステージ定義。バージョン違いの mod を切り替える時にも便利。
- ステージのリプレイ / ゴーストデータ: 各バージョンでフィールドが増えても古いリプレイが読める。
読み取り専用のコンテンツに関しては mitiru::data::ContentLoader<T> を使うと「JSON ファイルから型に流し込む」だけがもっと短く書けます。cpp_data_driven_minimal サンプルがそれを実演しています。
次の一歩
- セーブの整合性チェック ⇒
SchemaValidatorで「HP は 0 以上」「章番号は 0 から 12 まで」のような制約を JSON 側で書ける。 - 暗号化 / 署名 ⇒ チート対策が要るならセーブ blob の前後にハッシュやキーを足す。
- スロット UI ⇒
mitiru::cef::StateStore経由で C++ → JS に slot 情報を push し、HTML 側でリスト描画する構成が素直です。
関連 API
mitiru::data::SaveSchema<T>mitiru::data::MigrationChainmitiru::data::ContentLoader<T>mitiru::data::SchemaValidator
もっと深く知る
- 6. セーブとロード (Reference Chapter) — 各 API の網羅的な解説
- アーキテクチャ — セーブ UI 操作も発火だけを JS から送る (signal-only bridge)