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>();
// グループ名のフィルタを用意(空なら全て対象)
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>();
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;
}
}
}
使い方の手順
-
① シーンに SaveSystem を配置する
空の GameObject を作成し、名前をSaveSystemなどにします。
そこに上記のSaveSystemコンポーネントをアタッチします。File Name:例としてstage1などにTarget Groups:例としてPlayer,Stage1などカンマ区切りで指定Auto Load On Start:起動時にロードしたいならオンAuto Save On Quit:終了時にセーブしたいならオン
-
② セーブしたいオブジェクトに SaveNode を付ける
例としてプレイヤーに付ける場合:- プレイヤーのプレハブ or シーン上の Player オブジェクトを選択
SaveNodeコンポーネントをアタッチSave Group:PlayerNode Id:PlayerSave Transform:プレイヤーの位置・向きを保存したいならオン
同様に、動く床やドアなどギミックにも SaveNode を付けておくと便利です。
- 動く床1:
Save Group = Stage1,Node Id = MovingPlatform_A - ドア:
Save Group = Stage1,Node Id = Door_Main
-
③ 任意の値をセーブしたい場合は 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); }実際には継承や別コンポーネントに切り出して責務を分けても良いですね。
-
④ 実際にセーブ/ロードを実行する
シーン再生中に、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() を実行すれば、複数のセーブデータを扱えるようになります。
コンポーネントを小さく分けておくと、こうした拡張も局所的な変更で済むので、ぜひコンポーネント指向でセーブ機能を育てていきましょう。




