Unityを触り始めた頃は、つい「とりあえず全部Updateに書く」という実装になりがちですよね。移動処理、入力処理、エフェクト、そしてセーブ処理まで、ひとつのスクリプトに詰め込んでしまうと…

  • どこで何をセーブしているのか分かりづらい
  • プレハブごとにセーブ対象の変数をバラバラに管理してしまう
  • セーブ項目を増やすたびに巨大なGodクラスが肥大化する

この状態だと、後から「この敵のHPもセーブしたい」「この宝箱の開封フラグもセーブしたい」となったとき、あちこちのコードを探して修正する羽目になります。

そこでこの記事では、「このオブジェクトのこの値をセーブ対象にする」という責務だけを持つ小さなコンポーネント、SaveDataSync を作ってみましょう。
親オブジェクトが持つ特定の変数を辞書化し、セーブシステムから一括アクセスできるようにグルーピングするコンポーネントです。

【Unity】セーブ対象をコンポーネント単位で整理!「SaveDataSync」コンポーネント

ここで作る SaveDataSync は、ざっくりいうとこんな役割を持ちます。

  • 「どの変数をセーブするか」をインスペクターで宣言的に指定
  • その値を キー文字列 → 値 の辞書としてまとめる
  • シーン内の SaveDataSync を グループID でまとめてセーブシステムに登録

この記事では、最小限の「自前セーブマネージャ」も一緒に用意します。外部ライブラリに依存せず、この記事のコードだけで「セーブ対象の登録~辞書化」まで体験できるようにしてあります。


全体像

  • ISaveVariable:セーブ対象の「1つの値」を表すインターフェイス
  • SaveIntVariable / SaveFloatVariable / SaveBoolVariable / SaveStringVariable
    親コンポーネントのフィールドを参照し、値の取得/設定を行う小さなアダプタ
  • SaveDataSync:複数の ISaveVariable を束ねて「1オブジェクト分のセーブデータ」として扱う
  • SaveGroupManager:グループIDごとに SaveDataSync を管理する簡易セーブマネージャ

ソースコード(フルコード)


using System;
using System.Collections.Generic;
using UnityEngine;

#region セーブ用インターフェイスと共通型

/// <summary>1つのセーブ対象変数を表すインターフェイス</summary>
public interface ISaveVariable
{
    /// <summary>この変数を識別するキー(例: "hp", "isOpened" など)</summary>
    string Key { get; }

    /// <summary>現在の値を object として取得する</summary>
    object GetValue();

    /// <summary>セーブデータから値を復元する</summary>
    void SetValue(object value);
}

#endregion

#region 各型ごとのセーブ変数アダプタ

/// <summary>int型のセーブ対象変数</summary>
[Serializable]
public class SaveIntVariable : ISaveVariable
{
    [SerializeField] private string key = "intKey"; // 辞書のキー
    [SerializeField] private MonoBehaviour targetComponent; // 親のどのコンポーネントか
    [SerializeField] private string fieldName = "fieldName"; // そのコンポーネント内のフィールド名

    public string Key => key;

    public object GetValue()
    {
        if (targetComponent == null) return 0;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveIntVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return 0;
        }

        // 値を取得してintとして返す
        return (int)field.GetValue(targetComponent);
    }

    public void SetValue(object value)
    {
        if (targetComponent == null) return;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveIntVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return;
        }

        // セーブデータからintに変換してセット
        int intValue = 0;
        try
        {
            intValue = Convert.ToInt32(value);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"[SaveIntVariable] 値の変換に失敗しました: {value}, {e.Message}");
        }

        field.SetValue(targetComponent, intValue);
    }
}

/// <summary>float型のセーブ対象変数</summary>
[Serializable]
public class SaveFloatVariable : ISaveVariable
{
    [SerializeField] private string key = "floatKey";
    [SerializeField] private MonoBehaviour targetComponent;
    [SerializeField] private string fieldName = "fieldName";

    public string Key => key;

    public object GetValue()
    {
        if (targetComponent == null) return 0f;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveFloatVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return 0f;
        }

        return (float)field.GetValue(targetComponent);
    }

    public void SetValue(object value)
    {
        if (targetComponent == null) return;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveFloatVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return;
        }

        float floatValue = 0f;
        try
        {
            floatValue = Convert.ToSingle(value);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"[SaveFloatVariable] 値の変換に失敗しました: {value}, {e.Message}");
        }

        field.SetValue(targetComponent, floatValue);
    }
}

/// <summary>bool型のセーブ対象変数</summary>
[Serializable]
public class SaveBoolVariable : ISaveVariable
{
    [SerializeField] private string key = "boolKey";
    [SerializeField] private MonoBehaviour targetComponent;
    [SerializeField] private string fieldName = "fieldName";

    public string Key => key;

    public object GetValue()
    {
        if (targetComponent == null) return false;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveBoolVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return false;
        }

        return (bool)field.GetValue(targetComponent);
    }

    public void SetValue(object value)
    {
        if (targetComponent == null) return;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveBoolVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return;
        }

        bool boolValue = false;
        try
        {
            boolValue = Convert.ToBoolean(value);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"[SaveBoolVariable] 値の変換に失敗しました: {value}, {e.Message}");
        }

        field.SetValue(targetComponent, boolValue);
    }
}

/// <summary>string型のセーブ対象変数</summary>
[Serializable]
public class SaveStringVariable : ISaveVariable
{
    [SerializeField] private string key = "stringKey";
    [SerializeField] private MonoBehaviour targetComponent;
    [SerializeField] private string fieldName = "fieldName";

    public string Key => key;

    public object GetValue()
    {
        if (targetComponent == null) return string.Empty;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveStringVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return string.Empty;
        }

        return (string)field.GetValue(targetComponent);
    }

    public void SetValue(object value)
    {
        if (targetComponent == null) return;

        var type = targetComponent.GetType();
        var field = type.GetField(fieldName,
            System.Reflection.BindingFlags.Instance |
            System.Reflection.BindingFlags.Public |
            System.Reflection.BindingFlags.NonPublic);

        if (field == null)
        {
            Debug.LogWarning($"[SaveStringVariable] フィールドが見つかりません: {type.Name}.{fieldName}");
            return;
        }

        string stringValue = value?.ToString() ?? string.Empty;
        field.SetValue(targetComponent, stringValue);
    }
}

#endregion

#region SaveDataSync 本体

/// <summary>
/// 親オブジェクトの特定変数を辞書化し、
/// セーブシステムがアクセスできるグループに登録するコンポーネント。
/// </summary>
[DisallowMultipleComponent]
public class SaveDataSync : MonoBehaviour
{
    [Header("グループ設定")]
    [SerializeField]
    private string groupId = "DefaultGroup"; // 例: "Player", "Stage1", "EnemyWaveA" など

    [Header("このオブジェクト固有のID")]
    [SerializeField]
    private string objectId = "Object_001"; // 例: "Player1", "Chest_A_01" など

    [Header("int型のセーブ対象リスト")]
    [SerializeField]
    private List<SaveIntVariable> intVariables = new List<SaveIntVariable>();

    [Header("float型のセーブ対象リスト")]
    [SerializeField]
    private List<SaveFloatVariable> floatVariables = new List<SaveFloatVariable>();

    [Header("bool型のセーブ対象リスト")]
    [SerializeField]
    private List<SaveBoolVariable> boolVariables = new List<SaveBoolVariable>();

    [Header("string型のセーブ対象リスト")]
    [SerializeField]
    private List<SaveStringVariable> stringVariables = new List<SaveStringVariable>();

    /// <summary>登録されている全変数をまとめて返す</summary>
    public Dictionary<string, object> CollectAllValues()
    {
        var dict = new Dictionary<string, object>();

        // 各リストから値を収集し、キーを重複させないように辞書へ格納
        AddVariablesToDict(dict, intVariables);
        AddVariablesToDict(dict, floatVariables);
        AddVariablesToDict(dict, boolVariables);
        AddVariablesToDict(dict, stringVariables);

        return dict;
    }

    /// <summary>セーブデータから復元する</summary>
    public void ApplyValues(Dictionary<string, object> savedDict)
    {
        if (savedDict == null) return;

        ApplyToVariables(savedDict, intVariables);
        ApplyToVariables(savedDict, floatVariables);
        ApplyToVariables(savedDict, boolVariables);
        ApplyToVariables(savedDict, stringVariables);
    }

    /// <summary>グループIDを返す</summary>
    public string GroupId => groupId;

    /// <summary>このオブジェクトのIDを返す</summary>
    public string ObjectId => objectId;

    private void OnEnable()
    {
        // シーンに存在する間、SaveGroupManagerに登録する
        SaveGroupManager.Register(this);
    }

    private void OnDisable()
    {
        SaveGroupManager.Unregister(this);
    }

    private void AddVariablesToDict<T>(Dictionary<string, object> dict, List<T> list)
        where T : ISaveVariable
    {
        foreach (var v in list)
        {
            if (v == null) continue;
            if (string.IsNullOrEmpty(v.Key)) continue;

            // 同じキーがあったら上書きされるので注意
            dict[v.Key] = v.GetValue();
        }
    }

    private void ApplyToVariables<T>(Dictionary<string, object> dict, List<T> list)
        where T : ISaveVariable
    {
        foreach (var v in list)
        {
            if (v == null) continue;
            if (string.IsNullOrEmpty(v.Key)) continue;

            if (dict.TryGetValue(v.Key, out var value))
            {
                v.SetValue(value);
            }
        }
    }
}

#endregion

#region グループ管理用の簡易セーブマネージャ

/// <summary>
/// グループIDごとに SaveDataSync を管理する簡易マネージャ。
/// 実プロジェクトでは、ここからファイルIOやJson化などに繋げていきます。
/// </summary>
public static class SaveGroupManager
{
    /// <summary>groupId -> (objectId -> セーブ辞書) の構造</summary>
    private static readonly Dictionary<string, Dictionary<string, Dictionary<string, object>>> savedData
        = new Dictionary<string, Dictionary<string, Dictionary<string, object>>>();

    /// <summary>シーン上に存在する SaveDataSync の登録リスト</summary>
    private static readonly HashSet<SaveDataSync> activeSyncs = new HashSet<SaveDataSync>();

    /// <summary>シーン上の SaveDataSync を登録</summary>
    public static void Register(SaveDataSync sync)
    {
        if (sync == null) return;
        activeSyncs.Add(sync);
    }

    /// <summary>シーン上の SaveDataSync を登録解除</summary>
    public static void Unregister(SaveDataSync sync)
    {
        if (sync == null) return;
        activeSyncs.Remove(sync);
    }

    /// <summary>
    /// 指定グループをセーブ(辞書化)する。
    /// 実際のプロジェクトでは、この戻り値をJson化してファイルに書き出すなどします。
    /// </summary>
    public static Dictionary<string, Dictionary<string, object>> SaveGroup(string groupId)
    {
        var groupDict = new Dictionary<string, Dictionary<string, object>>();

        foreach (var sync in activeSyncs)
        {
            if (sync == null) continue;
            if (sync.GroupId != groupId) continue;

            // 1オブジェクト分のセーブ辞書
            var objDict = sync.CollectAllValues();
            groupDict[sync.ObjectId] = objDict;
        }

        // メモリ上に保持しておく例(上書き)
        savedData[groupId] = groupDict;

        Debug.Log($"[SaveGroupManager] グループ '{groupId}' をセーブしました。オブジェクト数: {groupDict.Count}");
        return groupDict;
    }

    /// <summary>
    /// 指定グループをロード(メモリ上の savedData から復元)する。
    /// 実際のプロジェクトでは、ここでファイルからJsonを読み込んで savedData に流し込む形になります。
    /// </summary>
    public static void LoadGroup(string groupId)
    {
        if (!savedData.TryGetValue(groupId, out var groupDict))
        {
            Debug.LogWarning($"[SaveGroupManager] グループ '{groupId}' のセーブデータがありません。");
            return;
        }

        foreach (var sync in activeSyncs)
        {
            if (sync == null) continue;
            if (sync.GroupId != groupId) continue;

            if (groupDict.TryGetValue(sync.ObjectId, out var objDict))
            {
                sync.ApplyValues(objDict);
            }
        }

        Debug.Log($"[SaveGroupManager] グループ '{groupId}' をロードしました。");
    }

    /// <summary>全てのメモリ上セーブデータをクリア</summary>
    public static void ClearAllSavedData()
    {
        savedData.Clear();
        Debug.Log("[SaveGroupManager] すべてのセーブデータをクリアしました。");
    }
}

#endregion

使い方の手順

① セーブ対象にしたいスクリプトを用意する

例として、プレイヤーのHPや名前を持つシンプルなコンポーネントを用意します。


using UnityEngine;

public class PlayerStatus : MonoBehaviour
{
    [SerializeField] private int hp = 100;
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private bool isAlive = true;
    [SerializeField] private string playerName = "Hero";

    // デバッグ用に値をいじる
    private void Update()
    {
        // スペースでHPを減らすだけの簡易テスト
        if (Input.GetKeyDown(KeyCode.Space))
        {
            hp -= 10;
            Debug.Log($"[PlayerStatus] HP: {hp}");
        }
    }
}

ここでは hp / moveSpeed / isAlive / playerName をセーブ対象にしてみましょう。

② GameObject に SaveDataSync をアタッチする

  1. プレイヤーの GameObject(例: Player)を選択
  2. Add Component から SaveDataSync を追加
  3. Group Id を「PlayerGroup」などに設定
  4. Object Id を「Player_001」など、一意なIDに設定

この groupIdobjectId は、セーブデータ上のアドレスのようなイメージです。
例えば、「ステージ1のプレイヤー1」のような単位で区別したい場合に役立ちます。

③ インスペクターで「どの変数をセーブするか」を設定する

SaveDataSync コンポーネントのインスペクターを開き、以下のように設定します。

  • Int Variables に要素を追加
    • Key: hp
    • Target Component: PlayerStatus(ドラッグ&ドロップ)
    • Field Name: hpフィールド名を正確に
  • Float Variables に要素を追加
    • Key: moveSpeed
    • Target Component: PlayerStatus
    • Field Name: moveSpeed
  • Bool Variables に要素を追加
    • Key: isAlive
    • Target Component: PlayerStatus
    • Field Name: isAlive
  • String Variables に要素を追加
    • Key: playerName
    • Target Component: PlayerStatus
    • Field Name: playerName

これで、「PlayerGroup / Player_001」というオブジェクトに、hp, moveSpeed, isAlive, playerName が紐づいた状態になります。

④ 実際にセーブ&ロードしてみる

テスト用に、キーボード操作でセーブ&ロードを呼び出すコンポーネントを作ります。


using UnityEngine;

public class SaveTestController : MonoBehaviour
{
    private void Update()
    {
        // Sキーでセーブ
        if (Input.GetKeyDown(KeyCode.S))
        {
            SaveGroupManager.SaveGroup("PlayerGroup");
        }

        // Lキーでロード
        if (Input.GetKeyDown(KeyCode.L))
        {
            SaveGroupManager.LoadGroup("PlayerGroup");
        }

        // Cキーでメモリ上のセーブデータをクリア
        if (Input.GetKeyDown(KeyCode.C))
        {
            SaveGroupManager.ClearAllSavedData();
        }
    }
}

このコンポーネントを空の GameObject(例: SaveController)にアタッチしておきます。

  1. 再生開始
  2. スペースキーでプレイヤーのHPを何回か減らす(PlayerStatusのログを確認)
  3. Sキーでセーブ
  4. さらにHPを減らしたり、インスペクターから値をいじる
  5. Lキーでロード → セーブした時点の値に戻る

この流れで、「SaveDataSync が親の変数を辞書化し、グループ単位でセーブ/ロードできている」ことが確認できます。


別の具体例:敵や宝箱にも簡単に適用できる

  • 敵キャラ:HP、現在のパトロールポイントindex、ドロップテーブルの状態などをセーブ対象に
  • 宝箱isOpened(開封済みか)、hasKey(鍵が入っているか)などをセーブ対象に
  • 動く床:現在位置インデックスや、スイッチのON/OFF状態をセーブ対象に

いずれも、元のロジックはそのままで、SaveDataSyncSaveXxxVariable の設定を追加するだけでセーブ対象化できます。
これが「セーブ処理をGodクラスに書かない」コンポーネント指向の良さですね。


メリットと応用

メリット1:プレハブ単位で「セーブ対象」を完結できる

セーブ対象を プレハブのインスペクターで完結させられるのが大きなメリットです。

  • プレイヤーのプレハブを複製しても、セーブ対象設定ごとコピーされる
  • レベルデザイナーが、コードを書かずに「この宝箱は開封状態をセーブしてほしい」と設定できる
  • セーブ項目を増やしたいときは、SaveDataSync の配列に要素を足すだけ

巨大なセーブマネージャクラスに「プレイヤーのHP」「宝箱のフラグ」などを直書きする必要がなくなり、責務が各オブジェクトに分散します。

メリット2:グループIDでシーンやチャプターを切り替えやすい

groupId を「Stage1」「Stage2」「Player」などに分けておけば、

  • シーン遷移時に「Stage1」だけセーブしておく
  • プレイヤーは常に「PlayerGroup」としてセーブ/ロードする
  • DLCやチャプター追加時に新しいグループを増やす

といった形で、セーブデータのスコープを柔軟に管理できます。

メリット3:セーブシステムの実装を後から差し替えやすい

今回の例では SaveGroupManager がメモリ上に Dictionary を持つだけですが、実際にはここを

  • Json にシリアライズしてファイル保存
  • バイナリ形式で暗号化して保存
  • クラウドセーブAPIに送信

などに差し替えればOKです。
SaveDataSync は「オブジェクトの変数を辞書化する」責務だけに絞ってあるので、セーブの下回りをいじってもゲーム側のコンポーネントには影響が出にくくなります。


改造案:ポジションや回転をまとめてセーブするヘルパー

よくある要望として、「Transform の位置や回転を一括でセーブしたい」というものがあります。
そのためのヘルパーメソッドを SaveDataSync に追加する例です。


public void RegisterTransformPositionRotation(string positionKey = "pos", string rotationKey = "rot")
{
    // Transform を対象にした float 変数を3つ + 4つ登録する例
    var t = transform;

    // X, Y, Z をそれぞれ別キーで保存したい場合
    intVariables.Add(new SaveIntVariable()); // ここではintではなくfloatにしたいので、本来はSaveFloatVariableで同様の処理を行う

    // 実際には SaveFloatVariable を new して、
    // key, targetComponent, fieldName を設定するための
    // コンストラクタや初期化メソッドを用意するとより使いやすくなります。
}

上のコードはあくまで方向性の例ですが、

  • SaveFloatVariable にコンストラクタを追加してコードから登録しやすくする
  • Transform 専用の SaveTransformVariable を作る

といった拡張をしていくと、より実戦的なセーブコンポーネント群になっていきます。


セーブ機能はつい「ゲーム全体の巨大な仕組み」にしがちですが、1オブジェクト=1 SaveDataSync という小さな単位に分解していくと、保守性がぐっと上がります。
Godクラスを避けて、コンポーネント指向でセーブシステムを育てていきましょう。