いい感じのUnity用セーブデータ管理クラス🎍

## はじめに
こんにちは。トコロテンです。
少し早めになりますが、皆さんにお年玉💰を授けたいと思います。
今回は、自分用に作成したUnityのセーブデータを管理するクラスを公開します。実装はC#です。
Unity用とありますが、少し改造すればUnity以外にも使える汎用性の高いクラスになるので自由に改造してください。
また、無駄なusingステートメント(Unity周り)があります。こちらも気になる方は削除してくだい。

## 主な機能
主に以下の3つの機能をサポートします。

- データを一時的、永続的に保存
    - データを外部ファイルに保存し、永続的に保存します。
- データを圧縮、解凍
    - ユーザーのディスク領域を圧迫しません。
- データを暗号化、復号化
    - ユーザーによる不正なセーブデータの改竄を防ぎます。

## DataBank

データを一時的、永続的に保存します。
シングルトンで実装されているため、ゲーム中のどこからでも同じインスタンスにアクセスできます。

### プロパティ一覧

| プロパティ名 | 意味 |
|:-----------------|:------------------|
| SavePath | 永続的なデータの保存先ディレクトリ |

### メソッド一覧

| メソッド名 | 機能 |
|:-----------------|:------------------|
| Open() | セーブデータを管理するデータバンクのインスタンスを取得します(シングルトン) |
| IsEmpty() | 一時的なデータが何も保存されていないか判定します。 |
| ExistsKey(string key) | キーに対応するデータがあるか判定します。 |
| Store(string key, object obj) | データをキーに紐付けて一時的に保存します。 |
| Clear() | 一時的なデータを全て破棄します。 |
| Remove(string key) | キーに対応する一時的なデータを破棄します。 |
| Get<DataType>(string key) | キーに対応する一時的なデータを取得します。 |
| SaveAll() | 一時的なデータを全て永続化します。 |
| Save(string key) | キーに対応する一時的なデータを永続化します。 |
| Load<DataType>(string key) | 永続化されたデータを一時的なデータとして読み込みます。 |

### ソースコード

```csharp:DataBank.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using System.IO;
using System.Text;

public class DataBank
{
    static DataBank instance = new DataBank();
    static Dictionary<string, object> bank = new Dictionary<string, object>();

    static readonly string path = "SaveData";
    static readonly string fullPath = $"{ Application.persistentDataPath }/{ path }";
    static readonly string extension = "dat";

    public string SavePath
    {
        get
        {
            return fullPath;
        }
    }

    DataBank() { }

    public static DataBank Open()
    {
        return instance;
    }

    public bool IsEmpty()
    {
        return bank.Count == 0;
    }

    public bool ExistsKey(string key)
    {
        return bank.ContainsKey(key);
    }

    public void Store(string key, object obj)
    {
        bank[key] = obj;
    }

    public void Clear()
    {
        bank.Clear();
    }

    public void Remove(string key)
    {
        bank.Remove(key);
    }

    public DataType Get<DataType>(string key)
    {
        if (ExistsKey(key))
        {
            return (DataType)bank[key];
        }
        else
        {
            return default(DataType);
        }
    }

    public void SaveAll()
    {
        foreach(string key in bank.Keys)
        {
            Save(key);
        }
    }

    public bool Save(string key)
    {
        if (!ExistsKey(key))
        {
            return false;
        }

        string filePath = $"{ fullPath }/{ key }.{ extension }";

        string json = JsonUtility.ToJson(bank[key]);

        byte[] data = Encoding.UTF8.GetBytes(json);
        data = Compressor.Compress(data);
        data = Cryptor.Encrypt(data);

        if (!Directory.Exists(fullPath))
        {
            Directory.CreateDirectory(fullPath);
        }

        using (FileStream fileStream = File.Create(filePath))
        {
            fileStream.Write(data, 0, data.Length);
        }

        return true;
    }

    public bool Load<DataType>(string key)
    {
        string filePath = $"{ fullPath }/{ key }.{ extension }";

        if (!File.Exists(filePath))
        {
            return false;
        }

        byte[] data = null;
        using (FileStream fileStream = File.OpenRead(filePath))
        {
            data = new byte[fileStream.Length];
            fileStream.Read(data, 0, data.Length);
        }

        data = Cryptor.Decrypt(data);
        data = Compressor.Decompress(data);

        string json = Encoding.UTF8.GetString(data);

        bank[key] = JsonUtility.FromJson<DataType>(json);

        return true;
    }
}
```

## Compressor

バイナリデータをgzipフォーマットに従って圧縮、解凍します。

### メソッド一覧

| メソッド名 | 機能 |
|:--|:--|
| Compress(byte[] rawData) | データを圧縮します。 |
| Decompress(byte[] compressedData) | データを解凍します。 |

### ソースコード

```csharp:Compressor.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using System.IO;
using System.IO.Compression;

public class Compressor
{
    public static byte[] Compress(byte[] rawData)
    {
        byte[] result = null;

        using (MemoryStream compressedStream = new MemoryStream())
        {
            using (GZipStream gZipStream = new GZipStream(compressedStream, CompressionMode.Compress))
            {
                gZipStream.Write(rawData, 0, rawData.Length);
            }
            result = compressedStream.ToArray();
        }

        return result;
    }

    public static byte[] Decompress(byte[] compressedData)
    {
        byte[] result = null;

        using (MemoryStream compressedStream = new MemoryStream(compressedData))
        {
            using (MemoryStream decompressedStream = new MemoryStream())
            {
                using (GZipStream gZipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
                {
                    gZipStream.CopyTo(decompressedStream);
                }
                result = decompressedStream.ToArray();
            }
        }

        return result;
    }
}
```

## Cryptor

データをAESを用いて暗号化、復号化します。
暗号化キーとブロックサイズは初期状態では256bits, 128bitsです。
暗号化キーと初期化ベクトルは`EncryptionKey`と`EncryptionIV`に対応しています。
ここは各自変更してください。
また、**絶対にこのソースコードをそのままGitHub等に上げないでください。**
なぜなら、暗号化キーと初期化ベクトルがばれてしまうと暗号としての意味を成さなくなってしまうからです。
ソースコードを公開したい場合は`EncryptionKey`と`EncryptionIV`をハードコーディングではなく、ファイル等から読み込むようにしてください。
公開しない場合は問題ありません。

### メソッド一覧

| メソッド名 | 機能 |
|:-----------------|:------------------|
| Encrypt(byte[] rawData) | データを規定のパラメータを用いて暗号化します。 |
| Encrypt(byte[] rawData, string key, string iv) | 指定された暗号化キーと初期化ベクトルを利用してデータを暗号化します。 |
| Decrypt(byte[] encryptedData) | データを規定のパラメータを用いて復号化します。 |
| Decrypt(byte[] encryptedData, string key, string iv) | 指定された暗号化キーと初期化ベクトルを利用してデータを復号化します。 |

### ソースコード

```csharp:Cryptor.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using System.IO;
using System.Text;
using System.Security.Cryptography;

public class Cryptor 
{
    static readonly int KeySize = 256;
    static readonly int BlockSize = 128;
    static readonly string EncryptionKey = "0123456789ABCDEFGHIJKLMNOPQRSTUV";
    static readonly string EncryptionIV = "0123456789ABCDEF";

    public static byte[] Encrypt(byte[] rawData)
    {
        return Encrypt(rawData, EncryptionKey, EncryptionIV);
    }
       
    public static byte[] Encrypt(byte[] rawData, string key, string iv)
    {
        byte[] result = null;

        using (AesManaged aes = new AesManaged())
        {
            SetAesParams(aes, key, iv);

            ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);

            using(MemoryStream encryptedStream = new MemoryStream())
            {
                using(CryptoStream cryptStream = new CryptoStream(encryptedStream, encryptor, CryptoStreamMode.Write))
                {
                    cryptStream.Write(rawData, 0, rawData.Length);
                }
                result = encryptedStream.ToArray();
            }
        }

        return result;
    }

    public static byte[] Decrypt(byte[] encryptedData)
    {
        return Decrypt(encryptedData, EncryptionKey, EncryptionIV);
    }

    public static byte[] Decrypt(byte[] encryptedData, string key, string iv)
    {
        byte[] result = null;

        using (AesManaged aes = new AesManaged())
        {
            SetAesParams(aes, key, iv);

            ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);

            using (MemoryStream encryptedStream = new MemoryStream(encryptedData))
            {
                using (MemoryStream decryptedStream = new MemoryStream())
                {
                    using (CryptoStream cryptoStream = new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read))
                    {
                        cryptoStream.CopyTo(decryptedStream);
                    }
                    result = decryptedStream.ToArray();
                }
            }
        }

        return result;
    }

    static void SetAesParams(AesManaged aes, string key, string iv)
    {
        aes.KeySize = KeySize;
        aes.BlockSize = BlockSize;
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;

        aes.Key = Encoding.UTF8.GetBytes(CreateKeyFromString(key));
        aes.IV = Encoding.UTF8.GetBytes(CreateIVFromString(iv));
    }

    static string CreateKeyFromString(string str)
    {
        return PaddingString(str, KeySize / 8);
    }

    static string CreateIVFromString(string str)
    {
        return PaddingString(str, BlockSize / 8);
    }

    static string PaddingString(string str, int len)
    {
        const char PaddingCharacter = '.';

        if(str.Length < len)
        {
            string key = str;
            for(int i = 0; i < len - str.Length; ++i)
            {
                key += PaddingCharacter;
            }
            return key;
        }
        else if(str.Length > len)
        {
            return str.Substring(0, len);
        }
        else
        {
            return str;
        }
    }
}
```

## PlayerData

永続化したいデータのサンプルクラスです。
`List<T>`等のジェネリックを用いたコレクションも正常に扱うことができます。

### ソースコード

```csharp:PlayerData
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class PlayerData
{
    public string name;
    public int level;
    public List<int> statusList;

    public override string ToString()
    {
        return $"{ base.ToString() } { JsonUtility.ToJson(this) }";
    }
}
```

## 使い方

以下に使い方の簡単な例としてコードを示します。
実際に利用する際には、データを永続化する際に`Save()`または`SaveAll()`を呼び出すのを忘れないようにしてください。
また、`Get<DataType>(string key)`と`Load<DataType>(string key)`がジェネリックであることも忘れないでください。

```csharp:Example
DataBank bank = DataBank.Open();
Debug.Log("DataBank.Open()");
Debug.Log($"save path of bank is { bank.SavePath }");

PlayerData playerData = new PlayerData()
{
    name = "Tokoroten",
    level = 1,
    statusList = new List<int>
    {
        10, 20, 30, 40, 50
    }
};
Debug.Log(playerData);

bank.Store("player", playerData);
Debug.Log("bank.Store()");

bank.SaveAll();
Debug.Log("bank.SaveAll()");

playerData = new PlayerData();
Debug.Log(playerData);

playerData = bank.Get<PlayerData>("player");
Debug.Log(playerData);

bank.Clear();
Debug.Log("bank.Clear()");

playerData = bank.Get<PlayerData>("player");
Debug.Log(playerData);

bank.Load<PlayerData>("player");
Debug.Log("bank.Load()");

playerData = bank.Get<PlayerData>("player");
Debug.Log(playerData);
```