ケーススタディ 06: セーブデータ¶
素朴な実装¶
// C++20 — 構造体を丸ごとバイナリ書き出し
struct SaveData { int hp; int gold; float x, y; int stage; };
void Save(const SaveData& d) {
std::ofstream f("save.bin", std::ios::binary);
f.write(reinterpret_cast<const char*>(&d), sizeof(d)); // memcpy的な直書き
}
機能追加で破綻する過程¶
- 項目追加でセーブ互換が壊れる:
int levelを足した瞬間、旧セーブが読めない(サイズもオフセットもズレる)。アップデートで全員のセーブが消えるのは炎上事故 - 構造体直書きはパディング・エンディアン・型サイズが実装依存——コンパイラ更新や機種違いで読めなくなる
- 「何を保存するか」がゲーム中に散らばる: Player・Inventory・QuestSystemがそれぞれ勝手にファイルを書き始め、タイミング不整合(HPは保存されたのに所持金は保存前にクラッシュ)
- 「クラウド保存も」「スロット3つ」「オートセーブ」→ 保存先・タイミングの要求が保存形式のコードに絡みつく
設計の核: 3つの関心を分ける(責務のページの実戦版)¶
[1. 収集] ゲーム状態 → SaveData(スナップショット構造体) … Mementoの発想
[2. 変換] SaveData → バイト列(JSON / バイナリ+バージョン) … 形式の関心
[3. 保存] バイト列 → ファイル / クラウド / スロット管理 … 保存先の関心
案A: Memento+シリアライザ分離(定石)¶
// [1] 各システムが自分のスナップショットを提供([Memento](../03_design_patterns/memento.md))
struct PlayerSave { int hp = 100; float x = 0, y = 0; };
struct InventorySave { std::vector<int> itemIds; };
struct SaveData {
int version = 3; // ★バージョン番号は最初から入れる
PlayerSave player;
InventorySave inventory;
};
// Player::CreateSave() / Player::Restore(const PlayerSave&) を各システムが実装
// [2] 変換(名前付きフォーマット: JSONなど。フィールド欠落に強い)
std::string ToJson(const SaveData&);
SaveData FromJson(std::string_view, /*out*/ bool& migrated); // 旧版は既定値+移行処理
// [3] 保存先の抽象([DIP](../02_solid/dip.md)) — テスト・クラウド対応の差し替え点
class ISaveStorage { public: virtual ~ISaveStorage() = default;
virtual bool Write(std::string_view bytes) = 0;
virtual std::optional<std::string> Read() = 0; };
- バージョン+名前付きフォーマットが互換性の要: 項目追加は「旧データでは既定値」で自動吸収、大変更はversionで移行コードへ分岐
- 利点: 3つの関心が独立に変更可能。保存内容のテストがファイルなしで可能
- 欠点: スナップショット構造体の維持(ゲーム状態と二重管理)——だがこの「二重化」こそがゲーム内部の変更とセーブ互換を切り離す壁(内部リファクタリングがセーブを壊さない)
案B: リフレクション/自動シリアライズ案¶
C#(JsonUtility等)やUE(UPROPERTYの自動シリアライズ → 第10部)ではゲームオブジェクトを直接直列化できる。
- 利点: スナップショット構造体が不要(二重管理消滅)
- 欠点: 内部構造の変更が即セーブ互換の破壊になる(壁がない)。「エディタでの変数リネームでセーブが壊れた」はUnity/UEの定番事故。大規模・長期運用ほど案Aの「壁」が効く
案C: パターンを使わない簡潔案¶
PlayerPrefs的なキーバリュー保存(SetInt("hp", hp))。項目が10個以下の小規模ならこれが最速で、キー名がそのまま互換性を担保します。構造が育ったら案Aへ。
破綻ポイントへの個別回答¶
- タイミング不整合 → 保存は「全システムから収集して1回で書く」1関数に集約(Facade)。書き込みは「一時ファイル→リネーム」でアトミックに(書き込み中クラッシュで両方失う事故の防止)
- スロット・オートセーブ → [3]保存層の仕事(SaveDataは何も知らない)
- チート耐性 → 変換層でチェックサム/暗号化(これも[2]に閉じる)
規模別の判断¶
| 規模 | 推奨 |
|---|---|
| 小 | 案C(キーバリュー)。ただしバイナリ直書きだけは避ける |
| 中 | 案A(version+JSON)。エディタ(Unity)なら案Bも可だがリネーム規律を決める |
| 大・運用 | 案A+移行テスト(全旧バージョンのセーブを読み込むCIテスト) |
この題材の教訓¶
- セーブはYAGNIの例外:「後から変えるのが極端に高い決定」(後方互換)なので、version番号と名前付き形式だけは初日に入れる
- 「動くこと」と「更新しても壊れないこと」は別要件で、後者はリリース後にしかテストできない——だからCIに旧セーブ読み込みテストを積む