【Unity】SaveSystem (セーブ機能) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

Unityを触り始めた頃は、つい何でもかんでも Update() に書いてしまいがちですよね。入力処理、移動処理、UI更新、そしてセーブ処理まで全部1つのスクリプトに押し込んでしまうと、次のような問題が出てきます。

  • どこで何がセーブされているのか分かりにくい
  • プレイヤー、敵、ギミックなど、オブジェクトごとにセーブ処理をコピペしてしまう
  • 変数を追加・削除したときに、セーブ処理の修正漏れが発生しやすい

そこでこの記事では、「セーブ処理」を1つの責務として切り出した SaveSystem コンポーネント を用意します。
特定の「グループ」に属するノード(ゲームオブジェクト)の変数を辞書化して JSON に保存・読み込みする仕組みを作っておけば、プレハブ単位でセーブ対応を簡単に行えるようになります。

【Unity】グループ単位でスマートにJSONセーブ!「SaveSystem」コンポーネント

ここで作る SaveSystem は、ざっくり言うと次のような構成です。

  • SaveNode:セーブ対象となるノード。自分の状態(位置や任意の値)を辞書化して渡す役。
  • SaveSystem:指定グループの SaveNode を集めて、JSON ファイルに保存・読み込みする役。

「グループ」という考え方を入れることで、例えば「Stage1」「PlayerOnly」「Settings」など、目的別にファイルを分けて扱うことができます。

フルコード:SaveNode & SaveSystem


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

namespace Sample.SaveSystemExample
{
    /// <summary>
    /// 1つのゲームオブジェクトを「セーブ対象ノード」として扱うためのコンポーネント。
    /// グループ名とノードIDで一意に識別され、辞書形式で状態をやり取りします。
    /// </summary>
    public class SaveNode : MonoBehaviour
    {
        [Header("セーブ識別情報")]
        [Tooltip("このノードが属するセーブグループ名。例: Player, Stage1, Settings など")]
        [SerializeField] private string saveGroup = "Default";

        [Tooltip("グループ内で一意になるID。例: Player, Enemy_01, Door_A など")]
        [SerializeField] private string nodeId = "Node";

        [Header("サンプル: トランスフォームを保存するか")]
        [SerializeField] private bool saveTransform = true;

        [Tooltip("任意の整数値をセーブしたい場合の例")]
        [SerializeField] private int sampleIntValue = 0;

        [Tooltip("任意のフロート値をセーブしたい場合の例")]
        [SerializeField] private float sampleFloatValue = 0f;

        /// <summary>
        /// 外部から参照するためのグループ名プロパティ
        /// </summary>
        public string SaveGroup => saveGroup;

        /// <summary>
        /// 外部から参照するためのノードIDプロパティ
        /// </summary>
        public string NodeId => nodeId;

        /// <summary>
        /// このノードの状態を辞書化して返す。
        /// SaveSystem から呼ばれる想定のメソッドです。
        /// </summary>
        public Dictionary<string, object> CaptureState()
        {
            var dict = new Dictionary<string, object>();

            // トランスフォームを保存する場合
            if (saveTransform)
            {
                dict["positionX"] = transform.position.x;
                dict["positionY"] = transform.position.y;
                dict["positionZ"] = transform.position.z;

                dict["rotationX"] = transform.rotation.eulerAngles.x;
                dict["rotationY"] = transform.rotation.eulerAngles.y;
                dict["rotationZ"] = transform.rotation.eulerAngles.z;
            }

            // サンプルの任意パラメータ
            dict["sampleIntValue"] = sampleIntValue;
            dict["sampleFloatValue"] = sampleFloatValue;

            return dict;
        }

        /// <summary>
        /// 辞書から状態を復元する。
        /// キーが存在しない場合はスキップする安全な実装にしています。
        /// </summary>
        public void RestoreState(Dictionary<string, object> data)
        {
            if (data == null)
            {
                Debug.LogWarning($"[SaveNode] データが null のため復元をスキップ: {name}");
                return;
            }

            // トランスフォームの復元
            if (saveTransform)
            {
                float x = GetFloat(data, "positionX", transform.position.x);
                float y = GetFloat(data, "positionY", transform.position.y);
                float z = GetFloat(data, "positionZ", transform.position.z);
                transform.position = new Vector3(x, y, z);

                float rx = GetFloat(data, "rotationX", transform.rotation.eulerAngles.x);
                float ry = GetFloat(data, "rotationY", transform.rotation.eulerAngles.y);
                float rz = GetFloat(data, "rotationZ", transform.rotation.eulerAngles.z);
                transform.rotation = Quaternion.Euler(rx, ry, rz);
            }

            // サンプルの任意パラメータ復元
            sampleIntValue = GetInt(data, "sampleIntValue", sampleIntValue);
            sampleFloatValue = GetFloat(data, "sampleFloatValue", sampleFloatValue);
        }

        // --- 辞書から安全に値を取り出すためのヘルパー ---

        private int GetInt(Dictionary<string, object> dict, string key, int defaultValue)
        {
            if (!dict.TryGetValue(key, out var value) || value == null)
            {
                return defaultValue;
            }

            // JSONUtility 経由だと long, double などで戻ることがあるので型を吸収する
            if (value is int i) return i;
            if (value is long l) return (int)l;
            if (value is float f) return Mathf.RoundToInt(f);
            if (value is double d) return (int)Math.Round(d);

            if (int.TryParse(value.ToString(), out int parsed))
            {
                return parsed;
            }

            return defaultValue;
        }

        private float GetFloat(Dictionary<string, object> dict, string key, float defaultValue)
        {
            if (!dict.TryGetValue(key, out var value) || value == null)
            {
                return defaultValue;
            }

            if (value is float f) return f;
            if (value is double d) return (float)d;
            if (value is int i) return i;
            if (value is long l) return l;

            if (float.TryParse(value.ToString(), out float parsed))
            {
                return parsed;
            }

            return defaultValue;
        }
    }

    /// <summary>
    /// JSONにシリアライズするための中間クラス群。
    /// Dictionary<string, object> をそのまま JsonUtility で扱えないため、
    /// ラッパー構造体に変換して保存します。
    /// </summary>

    [Serializable]
    public class SaveEntry
    {
        public string nodeId;
        public List<string> keys = new List<string>();
        public List<string> values = new List<string>();
        public List<string> types = new List<string>();
    }

    [Serializable]
    public class SaveGroupData
    {
        public string groupName;
        public List<SaveEntry> entries = new List<SaveEntry>();
    }

    /// <summary>
    /// 指定グループの SaveNode を検索し、辞書化して JSON ファイルに保存・読み込みするコンポーネント。
    /// シーンに1つ置いて使う想定です。
    /// </summary>
    public class SaveSystem : MonoBehaviour
    {
        [Header("保存対象グループ名(カンマ区切り)")]
        [Tooltip("例: Player,Stage1 など。空の場合はシーン内の全ての SaveNode が対象になります。")]
        [SerializeField] private string targetGroups = "Default";

        [Header("ファイル設定")]
        [Tooltip("セーブファイル名(拡張子は .json が自動で付きます)")]
        [SerializeField] private string fileName = "saveData";

        [Tooltip("アプリケーション終了時に自動セーブするか")]
        [SerializeField] private bool autoSaveOnQuit = true;

        [Tooltip("開始時に自動ロードするか")]
        [SerializeField] private bool autoLoadOnStart = true;

        private readonly Dictionary<string, SaveGroupData> _loadedGroups
            = new Dictionary<string, SaveGroupData>();

        private string FilePath =>
            Path.Combine(Application.persistentDataPath, fileName + ".json");

        private void Start()
        {
            if (autoLoadOnStart)
            {
                LoadAll();
            }
        }

        private void OnApplicationQuit()
        {
            if (autoSaveOnQuit)
            {
                SaveAll();
            }
        }

        /// <summary>
        /// シーン内の対象グループの SaveNode をすべて保存する。
        /// </summary>
        [ContextMenu("Save All")]
        public void SaveAll()
        {
            // シーン内の全 SaveNode を取得
            SaveNode[] allNodes = FindObjectsOfType<SaveNode&gt();

            // グループ名のフィルタを用意(空なら全て対象)
            HashSet<string> targetGroupSet = BuildTargetGroupSet();

            var groupDict = new Dictionary<string, SaveGroupData>();

            foreach (var node in allNodes)
            {
                // グループフィルタ
                if (targetGroupSet.Count > 0 && !targetGroupSet.Contains(node.SaveGroup))
                {
                    continue;
                }

                if (!groupDict.TryGetValue(node.SaveGroup, out var groupData))
                {
                    groupData = new SaveGroupData
                    {
                        groupName = node.SaveGroup
                    };
                    groupDict[node.SaveGroup] = groupData;
                }

                // ノードの状態をキャプチャ
                Dictionary<string, object> state = node.CaptureState();

                SaveEntry entry = ConvertDictToEntry(node.NodeId, state);
                groupData.entries.Add(entry);
            }

            // ルートとなるラッパークラスを作る
            var root = new SaveRootWrapper
            {
                groups = new List<SaveGroupData>(groupDict.Values)
            };

            string json = JsonUtility.ToJson(root, true);

            try
            {
                File.WriteAllText(FilePath, json);
                Debug.Log($"[SaveSystem] セーブ完了: {FilePath}");
            }
            catch (Exception e)
            {
                Debug.LogError($"[SaveSystem] セーブ失敗: {e.Message}");
            }
        }

        /// <summary>
        /// セーブファイルから読み込み、シーン内の SaveNode に反映する。
        /// </summary>
        [ContextMenu("Load All")]
        public void LoadAll()
        {
            if (!File.Exists(FilePath))
            {
                Debug.LogWarning($"[SaveSystem] セーブファイルが存在しません: {FilePath}");
                return;
            }

            string json;
            try
            {
                json = File.ReadAllText(FilePath);
            }
            catch (Exception e)
            {
                Debug.LogError($"[SaveSystem] ロード失敗: {e.Message}");
                return;
            }

            var root = JsonUtility.FromJson<SaveRootWrapper>(json);
            _loadedGroups.Clear();

            if (root != null && root.groups != null)
            {
                foreach (var group in root.groups)
                {
                    _loadedGroups[group.groupName] = group;
                }
            }

            // シーン内の SaveNode を取得して反映
            SaveNode[] allNodes = FindObjectsOfType<SaveNode&gt();
            HashSet<string> targetGroupSet = BuildTargetGroupSet();

            foreach (var node in allNodes)
            {
                if (targetGroupSet.Count > 0 && !targetGroupSet.Contains(node.SaveGroup))
                {
                    continue;
                }

                if (!_loadedGroups.TryGetValue(node.SaveGroup, out var groupData))
                {
                    // 指定グループのデータが存在しない
                    continue;
                }

                // ノードIDで検索
                SaveEntry entry = groupData.entries.Find(e => e.nodeId == node.NodeId);
                if (entry == null)
                {
                    continue;
                }

                Dictionary<string, object> dict = ConvertEntryToDict(entry);
                node.RestoreState(dict);
            }

            Debug.Log($"[SaveSystem] ロード完了: {FilePath}");
        }

        /// <summary>
        /// targetGroups からグループ名の集合を作る。
        /// 空文字列の場合は空セットを返し、「全て対象」の意味になります。
        /// </summary>
        private HashSet<string> BuildTargetGroupSet()
        {
            var set = new HashSet<string>();
            if (string.IsNullOrWhiteSpace(targetGroups))
            {
                return set; // 空のセット => フィルタなし
            }

            string[] parts = targetGroups.Split(',');
            foreach (var part in parts)
            {
                string trimmed = part.Trim();
                if (!string.IsNullOrEmpty(trimmed))
                {
                    set.Add(trimmed);
                }
            }
            return set;
        }

        /// <summary>
        /// 辞書を SaveEntry に変換する。
        /// JsonUtility では Dictionary<string, object> を直接扱えないため、
        /// キー・値・型名をそれぞれ配列として保存します。
        /// </summary>
        private SaveEntry ConvertDictToEntry(string nodeId, Dictionary<string, object> dict)
        {
            var entry = new SaveEntry
            {
                nodeId = nodeId
            };

            foreach (var kvp in dict)
            {
                entry.keys.Add(kvp.Key);

                // 値は文字列として保存(型名も一緒に持つ)
                if (kvp.Value == null)
                {
                    entry.values.Add(string.Empty);
                    entry.types.Add("null");
                }
                else
                {
                    entry.values.Add(kvp.Value.ToString());
                    entry.types.Add(kvp.Value.GetType().FullName);
                }
            }

            return entry;
        }

        /// <summary>
        /// SaveEntry を辞書に戻す。
        /// 型名からある程度推測して int / float / bool / string に変換します。
        /// </summary>
        private Dictionary<string, object> ConvertEntryToDict(SaveEntry entry)
        {
            var dict = new Dictionary<string, object>();

            for (int i = 0; i < entry.keys.Count; i++)
            {
                string key = entry.keys[i];
                string valueStr = entry.values.Count > i ? entry.values[i] : string.Empty;
                string typeStr = entry.types.Count > i ? entry.types[i] : "string";

                object value = ParseValue(typeStr, valueStr);
                dict[key] = value;
            }

            return dict;
        }

        /// <summary>
        /// 型名と文字列からオブジェクトにパースする。
        /// 必要に応じて型を追加していきましょう。
        /// </summary>
        private object ParseValue(string typeName, string valueStr)
        {
            if (typeName == "null")
            {
                return null;
            }

            // よく使う型を優先的に判定
            if (typeName.Contains("System.Int32"))
            {
                if (int.TryParse(valueStr, out int i)) return i;
                return 0;
            }

            if (typeName.Contains("System.Single") || typeName.Contains("System.Double"))
            {
                if (float.TryParse(valueStr, out float f)) return f;
                return 0f;
            }

            if (typeName.Contains("System.Boolean"))
            {
                if (bool.TryParse(valueStr, out bool b)) return b;
                return false;
            }

            // それ以外は文字列として扱う
            return valueStr;
        }

        /// <summary>
        /// JsonUtility で複数グループを扱うためのルートラッパークラス。
        /// </summary>
        [Serializable]
        private class SaveRootWrapper
        {
            public List<SaveGroupData> groups;
        }
    }
}

使い方の手順

  1. ① シーンに SaveSystem を配置する
    空の GameObject を作成し、名前を SaveSystem などにします。
    そこに上記の SaveSystem コンポーネントをアタッチします。
    • File Name:例として stage1 などに
    • Target Groups:例として Player,Stage1 などカンマ区切りで指定
    • Auto Load On Start:起動時にロードしたいならオン
    • Auto Save On Quit:終了時にセーブしたいならオン
  2. ② セーブしたいオブジェクトに SaveNode を付ける
    例としてプレイヤーに付ける場合:
    • プレイヤーのプレハブ or シーン上の Player オブジェクトを選択
    • SaveNode コンポーネントをアタッチ
    • Save GroupPlayer
    • Node IdPlayer
    • Save Transform:プレイヤーの位置・向きを保存したいならオン

    同様に、動く床やドアなどギミックにも SaveNode を付けておくと便利です。

    • 動く床1:Save Group = Stage1, Node Id = MovingPlatform_A
    • ドア:Save Group = Stage1, Node Id = Door_Main
  3. ③ 任意の値をセーブしたい場合は SaveNode を拡張する
    例えば敵の HP や、ドアが開いているかどうかを保存したい場合、
    SaveNode にフィールドを追加し、CaptureState() / RestoreState() で辞書に出し入れします。
    [Header("敵用の追加パラメータ")]
    [SerializeField] private int currentHp = 100;
    [SerializeField] private bool isDead = false;
    
    public Dictionary<string, object> CaptureState()
    {
        var dict = base.CaptureState(); // 継承している場合は base から呼ぶイメージ
    
        dict["currentHp"] = currentHp;
        dict["isDead"] = isDead;
    
        return dict;
    }
    
    public void RestoreState(Dictionary<string, object> data)
    {
        base.RestoreState(data);
    
        currentHp = GetInt(data, "currentHp", currentHp);
        isDead = (bool)(data.ContainsKey("isDead") ? data["isDead"] : isDead);
    }

    実際には継承や別コンポーネントに切り出して責務を分けても良いですね。

  4. ④ 実際にセーブ/ロードを実行する
    シーン再生中に、SaveSystem コンポーネントのインスペクタ右上の「⋮」から
    Save All / Load All を呼び出すか、
    コンテキストメニュー(コンポーネント右上の「︙」)にある Save All, Load All をクリックして実行できます。
    また、ゲーム内の UI ボタンなどから呼びたい場合は、次のような簡単なラッパーを用意します。
    using UnityEngine;
    using Sample.SaveSystemExample;
    
    public class SaveSystemInvoker : MonoBehaviour
    {
        [SerializeField] private SaveSystem saveSystem;
    
        public void Save()
        {
            saveSystem.SaveAll();
        }
    
        public void Load()
        {
            saveSystem.LoadAll();
        }
    }

    これを Canvas 上の GameObject に付けておき、Button の OnClick から Save() / Load() を呼び出せばOKです。

メリットと応用

この SaveSystem コンポーネントを使うと、セーブ処理をオブジェクトごとにバラして管理できるようになります。

  • プレハブ単位で完結:プレイヤー、敵、動く床など、それぞれのプレハブに SaveNode(+独自の状態保持コンポーネント)を付けておくだけでセーブ対象になります。
  • レベルデザインが楽:ステージを組む人は「ここは保存したいオブジェクトだから SaveNode を付ける」という判断だけでよく、セーブ処理の中身を意識しなくて済みます。
  • グループ単位でファイルを分けられるPlayer グループと Stage1 グループを分けておけば、プレイヤーの状態だけ別ファイルにするなどの拡張も容易です。
  • Godクラスを避けられる:巨大な「GameManager」にすべてのセーブ処理を書くのではなく、SaveSystem は「ファイルI/Oとルーティング」、SaveNode は「自分の状態の入出力」と責務を分離できます。

応用としては、次のような拡張が考えられます。

  • セーブスロット(複数ファイル)対応
  • オートセーブのトリガー(チェックポイント通過時に SaveAll を呼ぶ)
  • よりリッチな型サポート(Vector3, Quaternion, Color などを専用の書式で保存)
  • 暗号化や圧縮を挟む

例えば「セーブスロット」を簡易的に追加する改造案は、次のように SaveSystem にメソッドを1つ足すだけでも実現できます。

public void SetSaveSlot(int slotIndex)
{
    // スロット番号をファイル名に反映させる簡易実装
    // 例: saveData_0.json, saveData_1.json ...
    fileName = $"saveData_{slotIndex}";
}

このメソッドを UI から呼び出してスロットを切り替え、その後に SaveAll() / LoadAll() を実行すれば、複数のセーブデータを扱えるようになります。
コンポーネントを小さく分けておくと、こうした拡張も局所的な変更で済むので、ぜひコンポーネント指向でセーブ機能を育てていきましょう。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!