unity-save-system について
unity-save-systemは、Unityゲームのセーブ・ロード設計に関するリファレンススキルです。シリアライズ、保存先、移行、クラウド同期、自動保存の方法を比較し、データ損失やプラットフォーム固有の保存問題への対処を扱います。
主な機能
- 01シリアライズ形式の選択
- 02セーブデータとDTOの設計
- 03保存先とPlayerPrefsの指針
- 04バージョン管理と移行パターン
- 05ローカル優先のクラウド同期
- 06自動保存とモバイルでの永続化
活用シーン
- 01ゲームのセーブデータ形式を選択
- 02ゲームシステムごとにセーブファイルを整理
- 03データ構造の変更後に旧セーブを移行
- 04ローカルセーブをクラウドと同期
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ nice-wolf-studio/ unity-claude-skills --skill unity-save-systemインストール後、Claude Code で/unity-save-systemを入力して読み込まれたか確認します。
同梱ファイルreferences/save-load-implementation.md
詳細
セーブ/ロードシステム — 判断パターン
前提となるスキル:
unity-lifecycle(OnApplicationPause、OnApplicationQuit、終了シーケンス)、unity-data-driven(JSON シリアル化、バージョン管理)、unity-packages-services(Cloud Save API)、unity-async-patterns(BackgroundThreadAsync)
これらのパターンは、セーブシステムで最もよくある問題に対処します。Claude が何にでも PlayerPrefs を使い、バージョン管理のない壊れやすいシリアル化を行い、モバイル固有の永続化要件を無視する問題です。
パターン:シリアル化形式の選択
タイミング:セーブデータをディスクにシリアル化する方法を選ぶとき
判断:
- JSON(
JsonUtility) — 人が読みやすく、デバッグしやすく、セーブファイルが小さくなります。辞書とポリモーフィズムには対応していません。ほとんどのゲームで最適なデフォルトです。 - JSON(Newtonsoft) — JSON の全機能(辞書、ポリモーフィズム、LINQ)を利用できます。依存関係が少し大きくなります。セーブデータが複雑な場合に最適です。
- バイナリ(カスタムまたはMessagePack) — パフォーマンスが重要な場合、大きなセーブデータ、チート対策(編集が困難)に適しています。人が読むことはできません。対戦ゲームや非常に広大なワールドに最適です。
- BinaryFormatter — 絶対に使用しないでください。セキュリティ上の脆弱性があり、デシリアル化時に任意のコードが実行される可能性があります。Unity と Microsoft は非推奨にしています。
スキャフォールド(JSON のセーブ/ロード):
using System.IO;
using UnityEngine;
public static class SaveFileIO
{
public static void SaveJson<T>(T data, string fileName)
{
string path = GetSavePath(fileName);
string json = JsonUtility.ToJson(data, prettyPrint: true);
// Write to temp file first, then rename (atomic write)
string tempPath = path + ".tmp";
File.WriteAllText(tempPath, json);
if (File.Exists(path))
File.Replace(tempPath, path, path + ".bak");
else
File.Move(tempPath, path);
}
public static T LoadJson<T>(string fileName) where T : new()
{
string path = GetSavePath(fileName);
if (!File.Exists(path)) return new T();
string json = File.ReadAllText(path);
return JsonUtility.FromJson<T>(json);
}
public static bool SaveExists(string fileName) =>
File.Exists(GetSavePath(fileName));
public static void DeleteSave(string fileName)
{
string path = GetSavePath(fileName);
if (File.Exists(path)) File.Delete(path);
if (File.Exists(path + ".bak")) File.Delete(path + ".bak");
}
static string GetSavePath(string fileName) =>
Path.Combine(Application.persistentDataPath, fileName);
}
注意点:BinaryFormatter はセキュリティ上の欠陥です。デシリアル化によって任意のコードが実行される可能性があります。Unity と Microsoft は非推奨にしています。既存のコードで見つけた場合は、すぐに置き換えてください。JsonUtility は辞書をシリアル化できません。List<SerializableKeyValue<K,V>> に変換するか、Newtonsoft を使用してください。アトミック書き込みパターン(一時ファイル .tmp に書き込んでから名前を変更)を使うと、書き込み中にアプリがクラッシュした場合のデータ破損を防げます。
パターン:セーブデータのアーキテクチャ(DTO パターン)
タイミング:何をシリアル化するか、どのように構造化するかを決めるとき
判断:
- 単一のモノリシックなセーブデータ — すべてを含む
SaveDataクラスを1つ用意します。ロード/セーブはアトミックです。ロード/セーブが高速な小規模ゲームに最適です。 - システムごとのセーブファイル —
PlayerSave.json、InventorySave.json、WorldSave.json。一部のみロードでき、書き込みサイズが小さく、個々のシステムを移行しやすくなります。
スキャフォールド(ISaveable インターフェース):
// Save Data Transfer Object -- plain C# class, NOT MonoBehaviour
[System.Serializable]
public class GameSaveData
{
public int version = 1;
public PlayerSaveData player;
public InventorySaveData inventory;
public WorldSaveData world;
}
[System.Serializable]
public class PlayerSaveData
{
public float[] position = new float[3]; // Vector3 as array for JSON compat
public int health;
public int level;
public int xp;
public void FromPlayer(Transform t, int hp, int lvl, int xp)
{
position[0] = t.position.x;
position[1] = t.position.y;
position[2] = t.position.z;
health = hp; level = lvl; this.xp = xp;
}
public Vector3 GetPosition() => new(position[0], position[1], position[2]);
}
// Systems implement ISaveable to participate in save/load
public interface ISaveable
{
void GatherSaveData(GameSaveData data); // Write state to DTO
void ApplySaveData(GameSaveData data); // Read state from DTO
}
注意点:セーブ用 DTO は [System.Serializable] を付けた通常の C# クラスでなければなりません。MonoBehaviour や ScriptableObject は使用しないでください。含められるのはシリアル化可能な型(プリミティブ型、配列、リスト、ネストされた [Serializable] クラス)のみです。Vector3 などの Unity 型は JsonUtility では問題なくシリアル化できますが、カスタムコンバーターなしでは Newtonsoft でシリアル化できません。移植性を高めるには float[] を使用してください。
パターン:セーブファイルの保存場所
タイミング:セーブファイルの書き込み先を選ぶとき
判断:
Application.persistentDataPath(正しいデフォルト)— 書き込み可能で、アプリのアップデート後も保持され、プラットフォームに適しています。すべてのセーブデータに使用してください。Application.streamingAssetsPath— ビルドでは読み取り専用です。ここには書き込まないでください。バンドルされた読み取り専用データにのみ使用します。PlayerPrefs— キーと値のみを保存できます。設定や環境設定に使用します。ゲームの状態には使用しないでください。
スキャフォールド:
// Always use Path.Combine -- never concatenate with "/"
string savePath = Path.Combine(Application.persistentDataPath, "saves", "slot1.json");
// Ensure directory exists before writing
string dir = Path.GetDirectoryName(savePath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
注意点:プラットフォームによって persistentDataPath の場所は異なります。Windows では AppData/LocalLow/<company>/<product>、macOS では ~/Library/Application Support/<bundleID>、Android/iOS では内部ストレージです。WebGL では IndexedDB を使用します(非同期で、プライベートブラウジングでは失敗する場合があります)。必ず Path.Combine を使用してください。macOS/Linux ではスラッシュが使えますが、Path.Combine ならクロスプラットフォームで安全です。
パターン:PlayerPrefs のスコープ設定
使用場面:設定や少量の永続データに PlayerPrefs を使用する場合
判断:
- PlayerPrefs を直接呼び出す ― 設定が10個未満の小規模なプロジェクト向けです。シンプルです。
- ラッパークラス ― 設定が10個以上ある場合や、型安全性、変更イベント、デフォルト値が必要な場合に適しています。キーの衝突を防げます。
スキャフォールド(設定ラッパー):
public static class GameSettings
{
// Prefixed keys prevent collision between systems
private const string Prefix = "settings.";
public static float MasterVolume
{
get => PlayerPrefs.GetFloat(Prefix + "masterVolume", 1f);
set { PlayerPrefs.SetFloat(Prefix + "masterVolume", value); OnSettingsChanged?.Invoke(); }
}
public static bool Fullscreen
{
get => PlayerPrefs.GetInt(Prefix + "fullscreen", 1) == 1;
set { PlayerPrefs.SetInt(Prefix + "fullscreen", value ? 1 : 0); OnSettingsChanged?.Invoke(); }
}
public static int QualityLevel
{
get => PlayerPrefs.GetInt(Prefix + "quality", QualitySettings.GetQualityLevel());
set { PlayerPrefs.SetInt(Prefix + "quality", value); OnSettingsChanged?.Invoke(); }
}
public static event Action OnSettingsChanged;
public static void Save() => PlayerPrefs.Save();
public static void ResetToDefaults()
{
PlayerPrefs.DeleteKey(Prefix + "masterVolume");
PlayerPrefs.DeleteKey(Prefix + "fullscreen");
PlayerPrefs.DeleteKey(Prefix + "quality");
OnSettingsChanged?.Invoke();
}
}
注意点:PlayerPrefs のキーはグローバルな文字列であり、名前空間はありません。衝突を防ぐため、プレフィックス付きのキー("audio.masterVolume"、"video.fullscreen")を使用してください。PlayerPrefs に bool 型はありません。int(0/1)を使用してください。Windows では、PlayerPrefs は Windows レジストリに保存されるため、大きな値は避けてください。PlayerPrefs.Save() は OnApplicationQuit で自動的に実行されますが、モバイルでは重要な変更の後に手動で呼び出してください(アプリが終了処理なしに終了させられる場合があります)。
パターン:セーブデータのバージョン管理と移行
使用場面:ゲームのアップデートによってセーブ形式が変わる場合(フィールドの追加、名前変更、構造変更)
判断:
- バージョンフィールド+順次マイグレーター ― 互換性のない変更に適しています。セーブデータに
int versionを保存します。読み込み時に、v1 -> v2 -> v3 -> 現在のバージョンという移行チェーンを実行します。 - 寛容なリーダー ― 追加のみの変更に適しています。新しいフィールドにはデフォルト値が設定され、削除されたフィールドは無視されます。
JsonUtilityで自動的に処理されます。
スキャフォールド(移行チェーン):
public static class SaveMigrator
{
private static readonly int CurrentVersion = 3;
public static GameSaveData LoadAndMigrate(string json)
{
// Parse version from raw JSON first
var versionCheck = JsonUtility.FromJson<VersionOnly>(json);
int version = versionCheck.version;
if (version == CurrentVersion)
return JsonUtility.FromJson<GameSaveData>(json);
// Sequential migration using Newtonsoft for raw JSON manipulation
var jObj = Newtonsoft.Json.Linq.JObject.Parse(json);
if (version < 2) MigrateV1ToV2(jObj);
if (version < 3) MigrateV2ToV3(jObj);
jObj["version"] = CurrentVersion;
return Newtonsoft.Json.JsonConvert.DeserializeObject<GameSaveData>(jObj.ToString());
}
static void MigrateV1ToV2(Newtonsoft.Json.Linq.JObject data)
{
// v2 moved "playerX/Y/Z" into "player.position" array
var player = (Newtonsoft.Json.Linq.JObject)data["player"];
if (player != null && player["playerX"] != null)
{
float x = (float)player["playerX"];
float y = (float)player["playerY"];
float z = (float)player["playerZ"];
player["position"] = new Newtonsoft.Json.Linq.JArray(x, y, z);
player.Remove("playerX"); player.Remove("playerY"); player.Remove("playerZ");
}
}
static void MigrateV2ToV3(Newtonsoft.Json.Linq.JObject data)
{
// v3 added inventory system (default to empty)
if (data["inventory"] == null)
data["inventory"] = Newtonsoft.Json.Linq.JObject.FromObject(new InventorySaveData());
}
[System.Serializable]
private class VersionOnly { public int version; }
}
注意点:古い型定義がコードに存在しなくなっている可能性があるため、移行は新しい C# 型にデシリアライズする前の生の JSON に対して行う必要があります。構造変更には Newtonsoft の JObject を使用してください。各移行ステップは、バージョン N から N+1 への関数です。これらを順番に組み合わせてください。各バージョンのセーブ済み JSON を使って移行をテストしてください(テスト用フィクスチャを保存します)。
パターン:クラウドセーブの統合
使用場面:セーブデータをデバイス間で同期したり、ローカルストレージを超えて保存したりする場合
判断:
- Unity Cloud Save(UGS) ― 管理型サービスで、キーと値、またはファイルのストレージを利用できます。プレイヤーごとに200KBまで無料です。API については
unity-packages-servicesを参照してください。ほとんどのインディーゲームや中規模ゲームに最適です。 - カスタムバックエンド ― 完全な制御、無制限のストレージ、カスタムの競合解決が可能です。構築と保守は自分で行います。
スキャフォールド(ローカル優先のクラウド同期):
public class SaveManager : MonoBehaviour
{
public async Awaitable Save(GameSaveData data)
{
// Always save locally first (fast, reliable)
SaveFileIO.SaveJson(data, "savegame.json");
// Then sync to cloud in background (best-effort)
try
{
var cloudData = new Dictionary<string, object>
{
{ "savegame", JsonUtility.ToJson(data) }
};
await Unity.Services.CloudSave.CloudSaveService.Instance
.Data.Player.SaveAsync(cloudData);
}
catch (Exception e)
{
Debug.LogWarning($"Cloud save failed (local save preserved): {e.Message}");
}
}
}
注意点:クラウドセーブではオフラインプレイへの対応が必要です。必ず最初にローカルへ保存し、バックグラウンドでクラウドに同期してください。CloudSaveException は適切に処理してください。ネットワーク障害によってローカルプレイが妨げられたり、ゲームがクラッシュしたりしてはなりません。競合解決には「最終書き込み優先」が最もシンプルです。マージ処理が必要な場合はタイムスタンプを使用してください。
パターン:オートセーブ戦略
使用場面:セーブを実行するタイミングを決める場合
判断:
- チェックポイント方式 ― レベルクリア、休憩地点、手動セーブなど、特定のゲームイベントで保存します。明示的で、デザイナーが制御できます。従来から使われている方法です。
- 定期的なオートセーブ ― N秒ごと、またはシーン遷移時に保存します。クラッシュに備える安全策です。チェックポイント方式との併用も可能です。
- イベント駆動方式 ― アイテムの取得やクエストの完了など、重要な状態が変化するたびに保存します。粒度は細かいものの、I/O が増えます。
スキャフォールド(オートセーブ+モバイル向け安全対策):
public class AutoSaveManager : MonoBehaviour
{
[SerializeField] private float autoSaveInterval = 60f; // seconds
private float _timeSinceLastSave;
void Update()
{
_timeSinceLastSave += Time.unscaledDeltaTime;
if (_timeSinceLastSave >= autoSaveInterval)
{
_timeSinceLastSave = 0f;
_ = PerformAutoSave(); // Fire-and-forget with error handling
}
}
// Mobile: save on app background (OnApplicationQuit may not fire)
void OnApplicationPause(bool paused)
{
if (paused)
_ = PerformAutoSave();
}
void OnApplicationQuit()
{
// Synchronous save on quit (async may not complete)
var data = GatherSaveData();
SaveFileIO.SaveJson(data, "autosave.json");
}
async Awaitable PerformAutoSave()
{
try
{
await Awaitable.BackgroundThreadAsync();
var data = GatherSaveData();
SaveFileIO.SaveJson(data, "autosave.json");
await Awaitable.MainThreadAsync();
}
catch (Exception e)
{
Debug.LogError($"Auto-save failed: {e.Message}");
}
}
GameSaveData GatherSaveData()
{
var data = new GameSaveData();
// Gather from all ISaveable systems
return data;
}
}
注意点:セーブは I/O に依存する処理です。モバイルでは OnApplicationPause(true) を使用してください。OS がアプリを終了させる場合、OnApplicationQuit は呼び出されないことがあります。OnApplicationQuit では同期 I/O を使用してください(プロセス終了前に非同期処理が完了しない可能性があります)。Update 内で同期的に保存してはいけません。BackgroundThreadAsync またはタイマーを使用してください。関連項目:終了シーケンスについては unity-lifecycle を参照してください。
アンチパターン
| アンチパターン | 問題 | 修正方法 |
|---|---|---|
BinaryFormatter | セキュリティ上の脆弱性があり、非推奨です | JsonUtility または Newtonsoft を使用します |
| PlayerPrefs にゲームの状態を保存する | 構造がなく、バージョン管理もできず、キーが衝突します | JSON ファイルと persistentDataPath を使用します |
| MonoBehaviour を直接保存する | シリアライズできず、セーブデータがシーンに依存します | [Serializable] を付けたプレーンな C# DTO を使用します |
| セーブデータにバージョンフィールドがない | 形式が変わったときに移行できません | 常に int version を含めます |
| Update 内で同期的にセーブする | 特にモバイルでフレームの停止が発生します | BackgroundThreadAsync またはタイマーを使用します |
| バックアップ/アトミック書き込みがない | 書き込み中のクラッシュでセーブデータが破損します | .tmp に書き込んでから、アトミックに名前を変更します |
関連スキル
- unity-lifecycle -- OnApplicationPause、OnApplicationQuit、終了シーケンスのタイミング
- unity-packages-services -- Unity Cloud Save API(重複して扱わないでください)
- unity-async-patterns -- 非同期 I/O のための BackgroundThreadAsync
- unity-data-driven -- JsonUtility と Newtonsoft の使い分け、設定データのバージョン管理パターン