💾

[Unity] セーブ機能はもっと楽できるはず

に公開

はじめに

Asset Store | Document

Unityでゲームを作っていると、セーブ機能の実装に頭を悩ませることは少なくありません。
「セーブキーの管理が面倒くさい」「項目が増えるたびにボイラープレートを実装しないといけない」「人気アセットを使っても、便利に使うにはマネージャークラスを自作しないといけない」「暗号化はどうしようか」……。
自分自身も複数のプロジェクトでこれらの問題に直面し、そのたびにカスタム実装を書き直す状況にうんざりしていました。

そこで「セーブデータの構造を定義するだけでいい便利なアセットが欲しい!!」という思いから、SaveDesign というセーブデータ管理アセットを開発しました。
クラスに属性を付けるだけでセーブデータの初期化、読み込み、保存などの機能に加え、どこからでもセーブデータを参照できる仕組みになっています。

この記事では、SaveDesignを開発するに至った経緯や、実際に実装した仕組みの考え方を紹介します。
「Unityでセーブデータ管理をもっと楽にしたい」と考えている方にとって魅力的な選択肢の一つになっているはずです!

なぜSaveDesignを作ったのか

Unityで本格的なゲームを作るとき、セーブデータの扱いは避けて通れません。
しかし実際にプロジェクトに取り組んでみると、毎回次のような課題に直面しました。

  • セーブキーの管理が面倒くさい
    Unityのセーブデータ管理アセットはキーと値を紐づけて保存する「キーベースの保存」が主流です。
    うっかりキーが衝突してしまい、セーブデータが正常に保存されなかった……なんてことがあった人もいるのではないでしょうか

  • 保存するデータが増えるたびに手直しが必要
    保存するデータが増えると、すべてのデータをキーに紐づけて保存するのは現実的ではありません。そのため、保存するデータを1つにまとめた「データクラス」を実装した方も多いのではないでしょうか。
    そうすると次に問題になるのはデータクラスが肥大化して可読性が下がることです。データの役割ごとに小分けにするのが最適ですが、そこで発生するのがデータクラスの読み書き処理などのボイラープレートです

  • セーブスロットシステム
    セーブスロットシステムは需要の高い機能ですが、初心者にとっては実装が難しい機能でもあります。時間を掛ければ実装できますが、人によってはその時間が苦痛に感じるかもしれません

  • セーブデータの前処理、後処理
    セーブデータを読み込んだ後や保存する前に形式をそろえたり他の型に変換したりしたい場合があります。
    読み書き処理の前後にハードコーディングするのが手っ取り早いですが、1か所にすべてのデータの前後処理を書いてしまうと可読性が下がってしまいます。
    データクラスに前後処理を行う関数を定義すればある程度見やすくなりますが、他のセーブデータの値に依存するような処理がある場合は処理順にも気を配らなければなりません

  • 暗号化や改ざん対策を後回しにしがち
    チート対策がしたかったら暗号化機能は欲しいですが、自作するのは手間がかかりますよね

これらの問題はプロジェクトを重ねるたびに何度も繰り返し発生し、毎回「一から書き直し」になっていました。
有名なセーブデータ管理アセットを使えば上記の内容もある程度解決できますが、すべてを解決できるアセットはなかなか見つかりません。値段も高いことが多いです。

そこで開発を始めたのが SaveDesign です。

SaveDesignの基本コンセプト

SaveDesignは、「セーブデータの構造を定義するだけで完結する」をコンセプトに開発しました。

[SharedData]
public class Example
{
   public int value;
}

初期化、読み込み、保存処理は呼び出すだけ。

SD.Initialize.Shared(); // 初期化
SD.Load.Shared();       // 読み込み
SD.Save.Shared();       // 保存

セーブデータの値を参照するには、次のように書きます。

int value = SD.Shared.Example.value // get
SD.Shared.Example.value = 100;      // set

// 一旦ローカル変数に代入して使うこともできる
var example = SD.Shared.Example;
if (example.value < 100) example.value = 100;

このようにセーブデータの構造を表すクラスに対して属性を付与するだけで初期化、読み込み、保存などの機能に加え、どこからでも参照できる仕組みが使用できます。

使い方の例

データによって「セーブスロット共通で使用したい」「セーブスロットごとに分けて使用したい」などの要件があると思います。

SaveDesignではそういった要件に応えるため、用途ごとにデータを分離できる仕組みを用意しています。

  • [SharedData]:全体で共有されるデータ(例:設定、解放した実績)
  • [SlotData]:セーブスロットごとのデータ(例:プレイヤー進行度)
  • [SlotMetaData]:セーブスロットのメタ情報(例:プレイ時間、シナリオ進行度)
  • [TempData]:一時的に使う保存されないデータ(例:セッション中のキャッシュ)

初期化、読み込み、保存の前後に処理を挟む

セーブデータを保存する前や読み込んだ後に、データの値を整形したり、他の値に変換したりしたいことがあります。

SaveDesignではインターフェースを実装することで初期化、読み込み、保存などの処理の前後に処理を組み込むことが可能です。

具体的な例として、「音量設定とグラフィック設定」を実装してみます。

後述しますが、SaveDesignでは4つのシリアライザーから1つを選んでセーブデータを読み書きします。下記の例ではUnity標準のJSONライブラリである JsonUtility を使用した実装例です。

// [SharedData]属性の引数にデータを参照する際の階層を設定できる
[SharedData("Settings"), Serializable]
public class Audio : IAfterLoadCallback
{
    [SerializeField] float volume; // 音量

    public float Volume
    {
        get => volume;
        set
        {
            volume = value;
            /* AudioMixerへ音量を設定する処理 */
        }
    }

    // コールバックは明示的に実装することでIDE補完に表示されなくなり見やすくなる
    void IAfterLoadCallback.OnAfterLoad()
    {
        /* AudioMixerへ音量を設定する処理 */
    }
}

[SharedData("Settings"), Serializable]
public class Graphic : IAfterLoadCallback
{
    [SerializeField] FullScreenMode fullScreenMode;

    public FullScreenMode FullScreenMode
    {
        get => fullScreenMode;
        set
        {
            fullScreenMode = value;
            Screen.fullScreenMode = fullScreenMode;
        }
    }

    void IAfterLoadCallback.OnAfterLoad()
    {
        // 読み込まれたときにフルスクリーンモードを適用
        Screen.fullScreenMode = fullScreenMode;
    }
}

セーブスロット共有データはゲーム起動時に読み込むのが最適です。

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void OnBeforeSceneLoad()
{
    if (!SD.Load.Shared()) // 共有データを読み込む
    {
        SD.Initialize.Shared(); // 共有データが読み込めなかったら初期化する
    }
}

上記のコードだけで、セーブデータを読み込むと自動的に音量をAudioMixerに設定し、フルスクリーンモードを設定します。

コールバックの種類は下記のとおりです。

  • IAfterInitializeCallback:セーブデータの初期化時に1度だけ呼び出されます
  • IAfterLoadCallback:セーブデータを読み込んだ直後に呼び出されます
  • IBeforeSaveCallback:セーブデータを保存する直前に呼び出されます

ちなみに、上記の実装例で SharedData("Settings") のように文字列を渡すと、セーブデータを参照する階層を分けて整理でき、データの関連性がはっきりします。

スラッシュ区切りでより深い階層も設定できます。

SD.Shared.Settings.Audio.Volume = 10f;
SD.Shared.Settings.Graphic.FullScreenMode = FullScreenMode.Windowed;

セーブスロットごとにデータを保存する

SaveDesignでは、複数のセーブスロットを持つゲームに最適な機能を用意しています。
スロットごとのデータは、番号指定文字列指定の2つの方法で読み書きできます。

1. 番号でスロットを指定する方法

最もシンプルな方法は、整数のスロット番号を使う方法です。

// スロット0をロード
SD.Load.Slot(0);

// 値を更新
SD.Slot.XXX.value = 10;

// 保存
SD.Save.Slot(0);

クリックされたセーブスロットの番号を使って読み書きする場合に最適です。

2. 文字列でスロットを指定する方法

柔軟にスロットを管理したい場合は、文字列キーを使うこともできます。

オートセーブやチェックポイント機能などを実装する場合に最適です。

// オートセーブ用のセーブデータを保存
SD.Save.Slot("auto");

// オートセーブで保存したデータを読み込む
SD.Load.Slot("auto");

セーブスロットのメタ情報

セーブスロットシステムを実装する場合、セーブスロットには「プレイ時間」「ゲームの進行度」などのメタ情報を表示することが多いです。

SaveDesignでは [SlotMetaData] 属性を付与するだけで読み書き処理が実装できます。

[SlotMetaData]
public class ExampleMetaData : IBeforeSaveCallback
{
    public string town;
    public int money;

    void IBeforeSaveCallback.OnBeforeSave()
    {
        var player = SD.Slot.Player;
        town = player.currentTown;
        money = player.money;
    }
}

メタ情報はセーブスロットごとのデータを保存するときに自動で保存されます。

読み込む際は次のように書きます。

if (SD.Load.SlotMeta(out var meta))
{
    ui.text = $"街:{meta.town}", お金:{meta.money}";
}
else
{
    // メタ情報がなければ保存されていないスロット
    ui.text = "空きスロット";
}

保存されない一時的データ

保存しなくていいデータもSaveDesignによって管理することで、ライフサイクルを厳格に管理し、初期化忘れによっておこるバグを排除できます。

SaveDesignで一時データを管理するには [TempData] 属性を使います。

[TempData] 属性は引数に TempDataResetTiming を受け取り、自動的に初期化、リセットすることが可能です。

リセットタイミングは下記のとおりです。

  • OnGameStart:ゲーム起動時に一度だけリセットする
  • OnSharedDataLoad:セーブスロット共有データの初期化時または読み込み時にリセットする
  • OnSlotDataLoad:セーブスロットの初期化時または読み込み時にリセットする
  • Manual:手動でリセットする

暗号化機能を組み込む

SaveDesignでは数ステップで簡単に AES(難読化) + HMAC(改ざん検知) 機能を組み込むことができます。

エディタ上部のメニューバーから暗号化ウィンドウを開き、暗号キーと名前空間を記入して保存先ディレクトリを選択するだけです。

独自の暗号化処理を組み込みたい場合も [Encryptor] 属性を静的クラスに付与し、 Encrypt 関数と Decrypt 関数を実装するだけです。

ただし、SaveDesignが提供する暗号化機能はあくまで難読化と改ざん検知をするだけのものであり、ゲーム自体を逆コンパイルされてしまうと容易に解読されてしまいます。

機密情報はセーブデータに含めず、サーバー等で管理してください。

対応する型

SaveDesignで保存できる型は使用するシリアライザーに依存します。

使用できるシリアライザーは下記のとおりです。

  • UnityEngine.JsonUtility:Unity標準のJSONライブラリ
  • Newtonsoft.Json:高機能JSONライブラリ
  • MessagePack for C#:高速シリアライザー
  • MemoryPack:高速シリアライザー

JsonUtilityだと辞書や DateTime などの型が保存できませんが、他のシリアライザーを使うことで様々な型を保存できます。

まとめ

この記事では、Unity向けセーブデータ管理アセット SaveDesign を開発した経緯と、その設計思想・仕組みについて紹介しました。

  • なぜ作ったのか
    既存の実装では「セーブキーの管理が面倒」「ボイラープレートを書くのが億劫」「暗号化の後回し」といった課題が繰り返し発生していたため、汎用的な仕組みが必要だった

  • 基本コンセプト
    「セーブデータの構造を定義するだけで完結する」、属性ベースで設計。
    データクラスを定義するだけで読み書き処理を書かずに済み、大幅に時間を短縮できる

  • 設計上の工夫
    学習コストを低く抑えつつ、データの役割を明確に分ける仕組みやデータ階層、暗号化など痒い所に手が届く設計

  • 導入と使い方
    アセットをインポートしてから画面上部のメニューから数クリックで導入完了。
    属性を付与するだけで初期化、読み込み、保存ができ、キーを使わないセーブデータの参照方法も直感的

SaveDesignの目的は、**「セーブデータの扱いで悩む時間を減らし、ゲーム開発の本質に集中できるようにする」**ことです

もしUnityでセーブデータ管理に悩んでいる方がいれば、ぜひSaveDesignを選択肢として加えてみてください。
また、実際に使ってみてのフィードバックや改善アイデアもぜひお寄せください!

Asset Store | Document

Discussion