ガイド

セーブとロード

HP / スコア / 進行度を JSON に書き出して、次の起動で復元する。SaveSchema と MigrationChain を使うと、後でセーブの形を変えても古いセーブが読める。

このガイドでは、こんなものを動かすところまで行きます。

  • ゲームの状態 (プレイヤー名、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 buildsrc/*.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)

リリースから数か月、「ゴールドだけだと味気ない、銀貨を追加したい」「チュートリアル進行を別フラグで持ちたい」となったとします。

新しい型 SaveV2src/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->silver0loaded->tutorialDonefalse で復元されます。プログラマーは “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 値を入れるだけで済む。削除意味の変更 だけは丁寧に扱う必要がある (フィールドを playerNamedisplayName に変える時など)。

同じ仕組みは他用途にも使える

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

もっと深く知る