Save/Load

クラッシュ耐性のあるアトミック書き込みとバージョンマイグレーションを備えた、スロットベースの JSON セーブ。

クラッシュ耐性のあるアトミック書き込みとバージョンマイグレーションを備えたスロットベースの JSON セーブ · 1 行のオフスイッチ: NullSaveService

概要

セーブサービスはゲームの状態をディスクに書き込み、後で復元します — セーブの途中でゲームがクラッシュしても、破損はありません。"slot0""autosave" のような名前付きスロット(スロットは単に拡張子なしのファイル名です)を指定して、Save<T>(slot, data) または Load<T>(slot) を呼びます。残りはサービスが処理します: JSON シリアライズ、アトミックなファイル書き込み(一時ファイルに書いてから入れ替え)、そしてデータフォーマットが変わったときのスキーママイグレーション。セーブファイルが破損していたり、そのバージョンがもはやマイグレーションできない場合は、例外ではなく明示的な結果コードを受け取ります — リカバリー体験の主導権はあなたにあります。

簡単な使用例

using CommonGameSystem.Core;
using UnityEngine;
using Logger = CommonGameSystem.Core.Logger;  // Unity has its own Logger type

[DefaultExecutionOrder(100)]
public class SaveLoadDemo : MonoBehaviour
{
    private ISaveService _save;

    void Awake()
    {
        _save = ServiceLocator.Resolve<ISaveService>();
    }

    public void SaveGame(string slot, PlayerData data)
    {
        var result = _save.Save(slot, data);
        if (result.Status != SaveStatus.Ok)
            Logger.Error(Logger.Categories.Save, $"Save failed: {result.Message}");
    }

    public void LoadGame(string slot)
    {
        var result = _save.Load<PlayerData>(slot);
        if (result.Status == SaveStatus.Ok)
            ApplyPlayerData(result.Value);
        else
            Logger.Warning(Logger.Categories.Save, $"Load failed: {result.Message}");
    }

    private void ApplyPlayerData(PlayerData data)
    {
        // Apply the loaded values to your game state here.
    }
}

[System.Serializable]
public class PlayerData
{
    public int level;
    public float health;
}

セーブファイルはデフォルトで {Application.persistentDataPath}/saves/{slot}.json に置かれます。

API の全体像

書き込み

  • SaveResult Save<T>(string slot, T data) where T : classdata をシリアライズし、名前付きスロットへアトミックに書き込みます。成功時は SaveStatus.Ok を、ディスクエラー(ディスクフル、権限)時は IOFailure を返します — その場合、既存ファイルは完全に無傷のまま残ります。不正なスロット名には ArgumentNullException / ArgumentException をスローします — それらは呼び出しコードのバグであって、ディスクの状態ではありません。

  • SaveResult Delete(string slot) — スロットファイルを削除します。成功時は Ok、スロット(またはセーブフォルダ自体)が存在しなければ NotFound — 2 回呼んでも安全で、例外なし — ファイルシステムが削除を拒めば IOFailure を返します。相棒の .bak バックアップファイルは意図的にそのまま残されます。その寿命は、そのスロットへの次の Save が管理します。

  • void RegisterMigration(int fromVersion, ISaveMigration migration) — バージョン v から v + 1 への 1 つのスキーママイグレーションステップを、純粋な JSON テキスト変換として登録します。fromVersion は 1 以上でなければならず、migration は null 不可です。マイグレーションは起動時に一度、最初の Load の前に登録してください。2 つのマイグレーションが同じ fromVersion を共有する場合、後から登録した方が勝ちます。

読み込み

  • SaveLoadResult<T> Load<T>(string slot) where T : class — スロットを読み取ってデシリアライズします。成功時は値付きの Ok を返します。スロットが存在しなければ NotFound、ファイルをパースできなければ(またはマイグレーションステップがスローしたら)Corrupt、ファイルを現在のバージョンまで引き上げられなければ VersionUnsupported を返します。ディスクやデータの状態に対しては決してスローしません — スローするのは不正なスロット名に対してだけです。

  • bool Exists(string slot) — スロットファイルがディスクに存在すれば true を返します。ファイルが読み取り可能か、整形式かはチェックしません — それには Load を使ってください。

  • IReadOnlyList<string> ListSlots() — すべてのスロット名(拡張子なしのファイル名)をアルファベット順で列挙します。セーブがまだ 1 つもなければ空のリストを返します。副作用としてセーブフォルダを作ることは決してありません。

プロパティ

  • int CurrentSchemaVersion { get; } — すべての新しいセーブに刻印されるスキーマバージョン。すでにこのバージョンにあるファイルはマイグレーションなしでロードされます。デフォルトは 1 で、データフォーマットが変わったら SaveServiceOptions を通じて引き上げます。

結果型

どちらの結果型も、public な読み取り専用フィールドを持つ軽量な struct です:

  • SaveResultStatusSaveStatus)と Message(診断用文字列。Ok のときは null。ログ向けで、プレイヤー向けではありません)。
  • SaveLoadResult<T>StatusValueStatus == Ok のときにのみ非 null)、Message

SaveStatus の値:

ステータス意味
Ok操作は正常に完了しました。
NotFoundスロットファイルが存在しません。LoadDelete が返します。
Corruptファイルは存在するがパースまたはデシリアライズできませんでした — 不正な JSON、フィールド欠落、型不一致、あるいはスローしたマイグレーションステップ。ファイルとその .bak はバイト単位で無変更のまま残るので、手作業での復旧は依然として可能です。
VersionUnsupported保存されているバージョンが CurrentSchemaVersion より新しい(セーブは決してダウングレードしません)、またはマイグレーションチェーンに欠落があってファイルを前へ進められない状態です。ファイルは無変更のまま保全されます。
IOFailure書き込みまたは削除中にファイルシステムエラーが発生しました。対象ファイルとその .bak は無変更のまま残り、根本の例外はスローされる代わりにログへ記録されます。

スキーママイグレーション

セーブデータのフォーマットを変えるときは、CurrentSchemaVersion を上げ、バージョンステップごとに 1 つの ISaveMigration を登録します。各ステップは、生のペイロード JSON テキストをバージョン v から v + 1 へ変換します — ファイルアクセスなし、副作用なし、string を受け取って string を返すだけです。Load では、データが現在のバージョンに達するまでステップを順に適用し、それからデシリアライズします。

using CommonGameSystem.Core;

// v1 stored "oldField"; v2 renamed it to "newField".
public sealed class V1ToV2Migration : ISaveMigration
{
    public string Migrate(string payloadJson)
        => payloadJson.Replace("\"oldField\"", "\"newField\"");
}
// Once at startup, before the first Load:
_save.RegisterMigration(1, new V1ToV2Migration());

マイグレーションステップがスローすると、LoadCorrupt を返し、ディスク上のファイルは無傷のままです。下方向へのマイグレーションはサポートされません: あなたのゲームのより新しいバージョンが書いたファイルは VersionUnsupported を返します。

カスタムの保存先・拡張子・シリアライザー

デフォルトのサービスは Bootstrap が標準オプションで登録します。フォルダ名、ファイル拡張子、バックアップの挙動、スキーマバージョンを変えるには、自分でインスタンスを構築して登録を差し替えます:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class CustomSaveInstaller : MonoBehaviour
{
    void Awake()
    {
        var options = new SaveServiceOptions(
            saveDirectoryName: "mygame_saves",   // folder inside persistentDataPath (default "saves")
            fileExtension: ".sav",               // default ".json"
            backupExtension: ".sav.bak",         // default ".bak"; must differ from fileExtension
            currentSchemaVersion: 2,             // default 1; must be >= 1
            createBackup: true);                 // default true — keep a backup of the prior save

        ServiceLocator.Replace<ISaveService>(
            new SaveService(new JsonUtilitySaveSerializer(), options));
    }
}

コンストラクターはすべての引数を検証し、不正な値には ArgumentException をスローします(パス区切り文字を含むディレクトリ名、先頭のドットがない拡張子、ファイル拡張子とバックアップ拡張子の一致、1 未満のバージョン)。

Newtonsoft JSON や System.Text.Json、暗号化を使うには、ISaveSerializer を実装し、JsonUtilitySaveSerializer の代わりに SaveService のコンストラクターへ渡してください。

オフにする

ServiceLocator.Replace<ISaveService>(new NullSaveService());

この 1 行で、呼び出し側のコードに一切触れずに永続化を無効化できます。Save は(何もせずに)Ok を返し、LoadNotFoundExistsfalseListSlots() は空のリストを返します。ディスクアクセスゼロ、アロケーションゼロ。開発中やヘッドレスなサーバービルドに便利です。なお、Configuration サービスはデフォルトで設定をセーブサービス経由で保存します — そのルーティングは起動時に一度だけ決まるため、セッション中それより前に書かれた設定は、後から NullSaveService へ差し替えても影響を受けません。

挙動とエッジケース

  • メインスレッド専用。 すべてのメソッドは、Unity のメインスレッドで実行されているかをチェックします(このチェックは Release ビルドでは除去されます)。他のスレッドからの呼び出しは InvalidOperationException をスローします。

  • 不正なスロット名は即座にスローします。 スロットはプレーンなファイル名の語幹でなければなりません: null でなく、空や空白でもなく、パス区切り文字なし、.. なし、: なし、そしてファイルシステムがファイル名に禁じる文字もなしです。違反はディスクアクセスの前に ArgumentNullException または ArgumentException をスローします — 結果コードにはラップされません。実際のディスク状態(ファイル欠落、破損、バージョンギャップ)は、代わりに結果コードとして返ります。

  • アトミック書き込み。 サービスはまず .tmp ファイルに書き、それから File.Replace で入れ替えます。入れ替えが(たとえば電源断で)中断されても、元のスロットは無傷のままです。次の Save は残った .tmp ファイルを単に上書きします。さらに、直前のセーブの .bak バックアップも自動で作られます。ディスクの書き込み量を抑えたければ SaveServiceOptions.CreateBackup で無効化できます(スキーマバージョンを上げている最中はお勧めしません — マイグレーション書き込みが失敗したとき、復旧に使える .bak がなくなります)。

  • 失敗がデータを破壊することはありません。 Ok 以外のすべての結果 — CorruptVersionUnsupportedIOFailure — は、スロットファイルとその .bak をバイト単位で無変更のまま残します。サービスが、読めなかったファイルを「後片付け」することは決してありません。

  • IL2CPP / link.xml。 サービス自身の型はすでに IL2CPP のコードストリッピングから保護されています。あなた自身のセーブデータクラス(上の PlayerData など)は、あなたのプロジェクトの link.xml で保護しなければ、ストリップされたビルドでのデシリアライズに失敗します。

  • シリアライズの制限。 デフォルトのシリアライザーは Unity の JsonUtility を使います: public フィールドと [SerializeField] フィールドのみ、自動プロパティ不可、UnityEngine.Object 参照不可、参照循環不可です。フィールドが静かに保存されない場合、たいていの犯人は自動プロパティです — public フィールドに切り替えてください。

関連ページ