C#
Unity

ローカルでセーブデータを管理する方法をまとめた【Unity】【JsonUtility】

こちらの記事を参考にセーブデータをローカルのファイルに書き込む際、自分なりの書き方を紹介します。

PlayerPrefsと同じような使い方で独自クラスもセーブできる機能実装【Unity】【セーブ】【Json】

自分の場合、セーブしたいデータのほとんどをGameManager classで管理してます。
Save用メソッドとLoad用メソッドも同じ場所に書きます。
(どこかにまとまっていた方が管理しやすいと思います)

GameManagerのサンプル

GameManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameManager : MonoBehaviour {
    //プレイヤーの情報
    public Player player = new Player();
    //手持ちキャラクターの情報
    public List<Character> ownCharacters = new List<Character>();
    //バトルの情報
    public Battle battle = new Battle();
    //フラグ情報
    public GameFlags gameFlags = new GameFlags();
    //マスタデータ
    [SerializeField] private MasterData _MasterData;
    public MasterData masterData
    {
        get
        {
            if (_MasterData == null)
            {
                _MasterData = Resources.Load<Entity_Enemy>("ScriptableObjects/MasterData");
            }
            return _MasterData;
        }
    }

    //こうしておくとGameManager.instanceでGetComponentせずに取得できる
    public static GameManager instance;

    void Awake(){
        //GameManager.instanceでの取得用の初期化
        instance = this;
        //SceneをまたいでもGameObjectを残すため
        DontDestroyOnLoad(gameObject);
        //開発用にEditorでは毎回初期化する,念を入れてDebug.isDebugBuildもif条件に。
        #if UNITY_EDITOR
        if (Debug.isDebugBuild)
        {
            SaveData.Clear();
        }
        #endif
        //ロード処理
        Load();
    }

    public void Save()
    {
        Debug.Log("Save");
        //セーブデータの設定
        SaveData.SetClass("player", player);
        SaveData.SetList("ownCharacters", ownCharacters);
        SaveData.SetClass("gameFlags", gameFlags);
        SaveData.SetClass("battle", battle);
        //セーブ
        SaveData.Save();
    }

    public void Load()
    {
        Debug.Log("Load");
        //ロード
        player = SaveData.GetClass("player", new Player());
        ownCharacters = SaveData.GetList("ownCharacters", new List<Character>());
        gameFlags = SaveData.GetClass("gameFlags", new GameFlags());
        battle = SaveData.GetClass("battle", new Battle());

        //IDを元にMasterDataからScriptableObjectを取得し直す
        for (int i = 0; i < ownCharacters.Count; i++)
        {
            //キャラクターデータ
            var ch = ownCharacters[i];
            ch.data = masterData.characterDataTable[ch.dataId];
            //覚えているスキル
            ch.skills.Clear();
            for (int j = 0; j < ch.skillIds.Count; j++)
            {
                ch.skills.Add(masterData.skillBaseTable[skillIds[j]]);
            }
        }
    }
}

その他のclass(一部抜粋)

Character.cs
[System.Serializable]
public class Character
{
    //キャラクター固有の情報 CharacterDataはScriptableObjectを継承
    public CharacterData data;
    public string dataId = 0;
    //覚えているスキル SkillBaseはScriptableObjectを継承
    public List<SkillBase> skills = new List<SkillBase>();
    public List<string> skillIds = new List<string>();
}
MasterData.cs
/* ScriptableObjectとして必要な記述をかなり端折ってます。
  参照する際に必要なフィールドが分かればいいレベルで記載。 */
public class MasterData : ScriptableObject
{
    public List<CharacterData> CharacterDataList;
    //Listでも参照できるけど、検索用にDictionaryも用意しておく
    Dictionary<int, CharacterData> _CharacterDataTable = new Dictionary<int, CharacterData>();
    public Dictionary<int, CharacterData> characterDataTable
    {
        get
        {
            if (_CharacterDataTable.Count < 1)
            {
                for (int i = 0; i < CharacterDataList.Count; i++)
                {
                    _CharacterDataTable.Add(CharacterDataList[i].id, CharacterDataList[i]);
                }
            }
            return _CharacterDataTable;
        }
    }

    public List<SkillBase> SkillBaseList;

    Dictionary<int, SkillBase> _SkillBaseTable = new Dictionary<int, SkillBase>();
    //Listでも参照できるけど、検索用にDictionaryも用意しておく
    public Dictionary<int, SkillBase> skillBaseTable
    {
        get
        {
            if (_SkillBaseTable.Count < 1)
            {
                for (int i = 0; i < SkillBaseList.Count; i++)
                {
                    _SkillBaseTable.Add(SkillBaseList[i].id, _SkillBaseTable[i]);
                }
            }
            return _SkillBaseTable;
        }
    }
}

JsonUtilityでセーブする意義

Unityでセーブデータを管理する場合、EasySaveなどのアセットを使う方法もあります。
しかし、それを使う場合、データの持ち方によってはセーブできない場合があります。

特に問題なのが、EasySaveだと自作したclassのListをセーブできません。
簡単なゲームであれば回避しやすいですが、RPGやカードゲームのようにデータが複雑になりやすいゲームだと回避が難しい問題です。

今回のGameManagerで言えば、
public List<Character> ownCharacters = new List<Character>();
が当てはまります。

更にCharacter classを見ると
public List<SkillBase> skills = new List<SkillBase>();
を持っており、更に入れ子になってます。

こうなってしまうとEasySaveではお手上げに近いです。

しかし、JsonUtilityを使ってJsonにして丸ごと保存!とすればいくら入れ子になっていても大丈夫です。

ただ、JsonUtilityでファイルに書き込む部分の実装を自分で書くと大変なので、冒頭で紹介したコードを拝借するのがいいと思います。

また、セーブデータをバックアップしたり、データ移行する際もJsonファイルごと移し替えれば済むので、シンプルに解決しやすいメリットも大きいです。

セーブ時の注意点

1.セーブするclassには忘れずに[System.Serializable]を付けましょう。
  [SerializeField]ではダメです(※冒頭のリンク先では間違って記載されてます)

2.最後にSaveData.Save();を忘れず実行しましょう。

ロード時の注意点

1.セーブ時のkeyと同じkeyを使いましょう(書き直しやコピペで書いてるとうっかりミスしやすいです)

2.ScriptableObjectもセーブされますが、そのままだとアップデートで問題が発生します。
書き込まれたセーブデータファイルを見てみると分かりますが、ScriptableObjectSpriteなどのフィールドは、オブジェクトID(正確な用語は違うかもしれません...)としてシリアライズされてます。

アップデート時にこれらが変更される場合があるようで(具体的なトリガーは正確に把握してません)、アップデートしてアプリを立ち上げると参照先がぐちゃぐちゃになったり、Spriteをうまく参照できずに画像が表示されなくなったりする不具合を確認しています。

自分の解決方法は、ScriptableObjectがキャラクターやスキルなら、それらのIDもintやstringでセーブして、アプリ起動時にIDをkeyにマスタデータから取得し直すようにしました。

なお、IDはScriptableObject継承classを取得する際、同時にScriptableObject継承クラスに設けたidフィールドを取得して初期化します。

ScriptableObjectを積極的に使う意義

「ロード時の注意点」で紹介したように、ScriptableObjectがオブジェクトIDとしてシリアライズされるせいでハマる落とし穴はありますが、メリットもあります。

それは、セーブデータのファイルサイズをコンパクトにできることです。

もしScriptableObjectでないCharacterclassにキャラクター名やキャラクターの説明、その他ステータスといった情報も全て記載してしまうと、それらも全部Jsonとしてシリアライズされてファイルに書き込まれます。

これはキャラクターが増えるごとに膨れていくため、キャラクターが10体くらいなら問題は小さいですが、これが何百体にも及ぶようだとサイズが大変なことになります。

自分のゲーム『ReRotation』はポケモン風のキャラクターデータがあり、多い人だと300体以上のキャラクターを保有できますが、それでもセーブデータのサイズは300KBに届きません。この工夫がなければきっと10倍以上になったことでしょう・・・

解説は以上になります。
駆け足で書いたので不足があれば追って補いたいと思います。