データをセーブする(1)(Unityメモ)
大枠を作った
セーブデータの扱い方
必要な機能
ある程度の規模のゲームを続けるうえで必要となるのが、ゲームを終了しても途中から再開できる機能です。その際に必要なのがゲームの進捗のセーブとロードの機能です。
実装したい機能の要件は以下の通りです。
・ゲーム進捗(所持ユニットなど)のセーブ・ロード
・セーブデータの改ざん対策
・容量を小さく抑える
性能の要件は以下の通りになります。
・100件単位の規模のデータをセーブ・ロード
・容量の上限はスマホ環境のストレージに収まる程度
以下の項目については今回は考えません。
・PvP要素に対するチート対策(他プレイヤーと関わる要素がないため)
・セーブ・ロード処理の高速化
チート対策に関して、制作中のゲームは制作からプレイまで独りで完結し、ランキング機能もつけないので、ゲームが他人へ影響を及ぼすことはおそらくありません。しかし技術的な興味があるので、セーブデータの改ざん対策だけは考えて実装してみることにしました。
なお改ざん対策については、文章が長くなったので別の記事に書きます。
検討したライブラリ
制作中のゲームはUnity+WebGLでビルドする構成にしています。
Unityの場合、セーブデータはPlayerPrefsを使うことも出来ます。しかし保存容量が小さいのと、複雑なデータ構造を保存するのは煩雑です。PlayerPrefsはゲーム設定の保存を想定しているようなので仕方ないです。
チート対策について、ソースコードとアセットについてはWebGLのビルド時に一応の難読化がされるようです。しかしセーブデータは自前で改ざん対策する必要があります。
そこでUnityのアセットからセーブデータ周りを実装できそうなライブラリをいくつか検討してみました。
Easy Saveは有料なので今回は見送りました。商用のゲームを作るのなら使っていたと思います。
Quick Save は無料のセーブ用ライブラリです。当初はこれを使おうと思ったのですが、自前のクラスのシリアライザを自分で書く必要がある点、WebGLに対応していない点から、残念ながら使用は見送ることにしました。またセーブデータ周りの勉強をするにつれて、暗号化の実装が弱いと感じた点もあります。
結局自作へ
QuickSaveが内部でJson.NETを使用しているので、これを参考にセーブデータ周りのライブラリを自作することにしました。Json.NETだと自前のクラスでもある程度はコードなしでシリアライズできるので、実装の手間が少し減ります。(QuickSaveでもJson.NETのメソッドを直接使えばよい、という話でもある)
セーブデータ周りの実装は記事も色々あるので、大まかな処理の流れをつかむことが出来ました。
Storageライブラリ
構成
セーブデータの保存手順に関しては、おおむね以下のようになります。そして外部保存したセーブデータのロードは、保存と逆の手順で行います。
1. 保存したいデータのシリアライズ
2. (オプション)シリアライズしたデータの圧縮
3. (オプション)圧縮したデータの暗号化
4. 外部ストレージに保存
このうち、効率やチート対策を考えなければ、最低限必要な処理はシリアライズと外部保存の2ステップです。圧縮は容量削減のため、暗号化はチート対策のための処理です。圧縮と暗号化の順序は圧縮が先です。暗号化したデータは規則性のないデータ列になるので、先に暗号化すると圧縮の効果が出ません。
個々の処理の実装はセーブとロードとで対になるようにまとめると、実装の見通しが良くなります。
シリアライザ
シリアライズとデシリアライズを行います。ゲームシステムではJson.NETを使ってシリアライズとデシリアライズを行います。
シリアライズとは、内部表現を一続きのデータ列に変換する処理です。特に木構造など高次の構造を持つデータを変換します。デシリアライズはその逆で、シリアライズしたデータ列から元の構造を持つ内部表現に変換する処理です。データをJSONに変換するのがよくある実装です。
シリアライズの考慮点は、ポインタのアドレスなど実行時のみ有効なデータを実行時でなくても有効なように変換することです。場合によってはインデックス用のメンバを元データの定義に追加したりします。使うライブラリによっては参照関係を保持するような機能を持っている場合もあります。
ちなみに、データの内部表現をJSONに変換する場合、バイナリをテキストに変換することになります。データサイズは増えるのですが、JSONライブラリによってはデシリアライズ時に不正な入力データ(特に実行コードの埋め込み)のバリデーションができる、という安全面のメリットがあります。
バイナリからバイナリに変換するシリアライザもあるのですが、少なくとも.NETのBinaryFormatterはデシリアライズ時の脆弱性があり、廃止(obsolete)されています。
Unityで使うなら、MessagePack辺りが良さそう。圧縮もできるようです。
暗号化は自前での実装が要るのと、使うまでの準備が大変そうですが…
アーカイバ
圧縮と解凍(展開)を行います。gzipを使う予定です。(未実装)
クリプタ
暗号化(encrypt)と復号(decrypt)を行います。(未実装)
暗号化周りはかなり検討が必要そうなので、別の記事に分けて書きます。
ストアラ
外部ストレージへの保存と、ストレージからの読み込みを行います。
ストアラがストレージのAPIを直接呼び出します。今回はUnityのAPIを使います。
Unityの場合、データの保存方法がいくつかありますが、プラットフォームごとに保存方法が異なります。
PlayerPrefsを使う場合
デスクトップ:プラットフォームごとに異なる
Windows:レジストリに保存→容量が小さい
Linux, Mac:ホーム下のディレクトリに保存
スマホ:特定のディレクトリに保存
WebGL:容量は1MBまで。IndexedDBを使用→ブラウザのキャッシュに保存
PlayerPrefsについては、もともとシステム設定を保存する用途が想定されています。そのため保存容量は小さいです(特にWindowsのレジストリ)。
外部ファイルに保存する場合
デスクトップ:Application.persistentDataPath以下のディレクトリに保存可
スマホ:Application.persistentDataPath以下に保存可(Androidでは外部ストレージ)
WebGL:Application.persistentDataPathを利用可能だが、内部でIndexedDBを使用→サブディレクトリは作れない
所持ユニットデータのような大量のデータを保存したい場合は、外部ファイルを使うことになりますが、自分でプラットフォームの違いに対応する必要があります。基本的にはApplication.persistentDataPathにアクセスすればよさそうです。
現時点の実装
現時点では、ライブラリの各インタフェースを定義し、テスト用にシリアライザとストアラを実装しました。
シリアライザにはJson.NETを使いました。ストアラにはPlayerPrefsを使いましたが、今後は外部ファイル(Application.persistentDataPath)を扱う実装を組みたいです。
/* インタフェース定義 */
public interface ISerializer<ObjectType>
{
public string Serialize(ObjectType obj);
public ObjectType Deserialize(string json);
}
public interface IArchiver
{
public byte[] Compress(string data);
public string Decompress(byte[] data);
public byte[] CompressBinary(byte[] data);
public byte[] DecompressBinary(byte[] data);
}
public interface ICryptor
{
public byte[] Encrypt(byte[] data);
public byte[] Decrypt(byte[] data);
}
public interface IStorer
{
public void Save(string key, byte[] data);
public byte[] Load(string key);
}
public class StorageManager<ObjectType>
{
public string EncryptKey;
public string EncryptInitialVector;
public string EncryptSalt;
public ISerializer<ObjectType> Serializer;
public IArchiver Archiver;
public ICryptor Cryptor;
public IStorer Storer;
//データのセーブ
public void SaveUsingJSON(string key, ObjectType data)
{
string jsonToWrite = Serializer.Serialize(data);
byte[] compress = Archiver.Compress(jsonToWrite);
byte[] crypt = Cryptor.Encrypt(compress);
Storer.Save(key, crypt);
}
//データのロード
public ObjectType LoadUsingJSON(string path)
{
byte[] crypt = Storer.Load(path);
byte[] compress = Cryptor.Decrypt(crypt);
string json = Archiver.Decompress(compress);
return Serializer.Deserialize(json);
}
}
/* シリアライザとストアラの仮実装 */
using Newtonsoft.Json;
using UnityEngine;
public class JsonNETSerializer<ObjectType> : ISerializer<ObjectType>
{
public string Serialize(ObjectType obj)
{
return JsonConvert.SerializeObject(obj, Formatting.Indented);
}
public ObjectType Deserialize(string json)
{
return JsonConvert.DeserializeObject<ObjectType>(json);
}
}
public class DummyArchiver: IArchiver
{
public byte[] Compress(string data) { return System.Text.Encoding.UTF8.GetBytes(data); }
public string Decompress(byte[] data) { return System.Text.Encoding.UTF8.GetString(data); }
public byte[] CompressBinary(byte[] data) { return data; }
public byte[] DecompressBinary(byte[] data) { return data; }
}
public class DummyCryptor: ICryptor
{
public byte[] Encrypt(byte[] data) { return data; }
public byte[] Decrypt(byte[] data) { return data; }
}
public class StoragePlayerPrefs: IStorer
{
public void Save(string key, byte[] value)
{
string base64 = System.Convert.ToBase64String(value);
PlayerPrefs.SetString(key, base64);
PlayerPrefs.Save();
}
public byte[] Load(string key)
{
if(PlayerPrefs.HasKey(key))
{
string value = PlayerPrefs.GetString(key, "");
return System.Convert.FromBase64String(value);
}
else
{
Debug.Log($"StoragePlayerPrefs: key '{key}' is not exist");
return null;
}
}
}
/* GUIから呼び出す */
using UnityEngine;
public class StorageBehaviour : MonoBehaviour
{
[SerializeField] private string StorageName = "TestStorage";
[SerializeField] private string EncryptKey = "MustNotBePlainText"; //未使用
[SerializeField] private string EncryptInitialVector = "42"; //未使用
[SerializeField] private string EncryptSalt = "0123456789"; //未使用
private StorageManager<List<TestUnitData>> Manager { get; set; }
public void InitSetting()
{
Manager = new StorageManager<List<TestUnitData>>()
{
EncryptKey = EncryptKey,
EncryptInitialVector = EncryptInitialVector,
EncryptSalt = EncryptSalt,
Serializer = new JsonNETSerializer<List<TestUnitData>>(),
Archiver = new DummyArchiver(),
Cryptor = new DummyCryptor(),
Storer = new StoragePlayerPrefs(),
};
}
void Awake()
{
InitSetting();
//テストデータを作成
var unitdata = new List<TestUnitData>()
{
/* 省略 */
};
Manager.SaveUsingJSON(StorageName, unitdata);
}
public List<TestUnitData> OnLoad(string key)
{
return Manager.LoadUsingJSON(StorageName);
}
public void OnSave(List<TestUnitData> unitdata)
{
Manager.SaveUsingJSON(StorageName, unitdata);
}
}
今後の予定
大枠だけを組んだ状態なので、今後は肉付けが必要です。
・セーブデータの暗号化の仕様を決める
・アーカイバを実装する
・クリプタを実装する
・Applicatin.persistentDataPathを介したストアラを実装する
・(Storageだけでなくシステム全体)イベントハンドラの実装、およびPub/Subアーキテクチャの実装を行う