今回はC#シリアライザであるMessagePack-C#のUnity (2022.3.8f1)での使い方についてまとめたいと思います。著者もまだまだ使い始めであり、あくまで最低限使えるようになるまでの入門編ですので、ご了承ください。(約3500文字)
[追記] この記事を書いた直後にMessagePackの上位互換とも言えるMemoryPackの存在を知りまして、これも当然Unityで簡単に使うことが出来て本記事で書いたような事前のコード生成も必要ありません。なので素直にMemoryPackを使った方が良いでしょう。後日にMemoryPackについても記事を作成しようかと思いますが、本記事は記録として残しておきます。
[追記終わり]
MessagePackについては、GitHub上の公式リリースページにUnityパッケージが用意されており、それをダウンロードしてインポートすれば直ぐに使えるようになります!落とし穴がありますが。今回の記事はその落とし穴を埋める方法についてです。
なお、本記事ではファイルへの直接的な読み書きを扱います。その際のトラブルについては一切責任を負えませんので、十分注意して行ってください。
jsonの上位互換として
きっかけはUI ToolKitのサンプルプロジェクト"UI ToolKit Sample"内のとあるコードにあったコメントからでした。意訳すると『あくまでサンプルプロジェクト用にJsonUtilityを使ってるけど本番環境ではMessagePackとかを使った方が良いよ!』という内容です。
このサンプルプロジェクトではゲームデータ(プレイヤーデータやオプション設定)の保存にjsonを使っています。ゲームではプレイデータ等をセーブ&ロードする必要が出てきますが、jsonは文字列を使っているため直接的に人間がデータを読むことが出来る反面、容量が大きくなったり処理が重くなるというデメリットがあります。
それに対して、MessagePackはbyte列としてデータをシリアライズするので、データ量が少なく処理も軽いのが売りです。ずっと気になっていたのですが、ようやく触ることが出来ました。
MessagePackの落とし穴
しかし、MessagePackを使う上で落とし穴があります。それはUnityには大きく分けてMono環境とIL2CPP環境がありますが、IL2CPP環境だとランタイム中のコード生成が出来ないため、あらかじめ事前にコード生成をしないと使えない、ということです。
詳しくは後述しますが、ここで一瞬折れそうになりました。MessagePackの使い方に関しての記事は、検索すれば分かるように結構あります。しかし、自分が調べた限りではこの落とし穴について書いた記事はなかったのです・・・。というわけで自分のこの経験と成果を記事として書いていきます。
まずは対象となるクラスを書く
今回はゲームデータをMessagePackを使ってシリアライズして、セーブ&ロードできるようにすることを目標にしています。
それではMessagePackで取り扱うクラスを設計します。MonoBehaviourを継承しない普通のC#クラスです。
MessagePackの基本的な使い方としては、対象となるクラスに[MessagePackObject]アトリビュートを付け、[Key]アトリビュートで各データメンバーにインデックスか文字列を振り分ける、というものです。コンストラクタを作る場合は[SerializationConstructor]アトリビュートを付けます。また、シリアライズに含みたくないメンバーには[IgnoreMenber]を付けます。
という風に、クラス設計に関してはこれだけでいいので簡単ですよね。
コード生成
ですが、問題はここからです。前述の通り、環境設定によっては自分で必要なコードを生成する必要があります。
MessagePackのUnityPackageにはコードジェネレーターが付属しており、エディタ上部のWindowsメニューからWindow > MessagePack > Code Generatorでジェネレータを起動できます。
ただし、初回起動時には先にインストールする必要があるので注意。インストール後に上画像の通りジェネレーターが使えるようになります。ここで入力パスと出力パスを入力します。注意点はファイルの拡張子まで書くということです。
../はそのプロジェクト・フォルダまでのパスを省略して入力してくれます。そのプロジェクトに含まれるC#プロジェクト・ファイルを指定するので、input pathは・・・
../Assembly-CSharp.csproj
と入力します。何を入力するかについては結局まだ良くわからない所があるのですが、目ぼしいC# Projectファイルはコレくらいでしょう。とりあえずは自分の環境ではコレで動いてくれました。
次にoutput pathですが、出力する場所とファイル名を含みます。ここではGeneratedMSPとしたので、output pathは・・・
../Assets/Scripts/TestScripts/GeneratedMSP.cs
としました。ここではプロジェクト・フォルダのAssets以下のいくつかのフォルダを含んだパスになっていますが、好きな場所に出力できます。
Generateを押すとコンソールに処理の経過が表示され、Completedと表示されれば指定したパスにcsファイル(C# script)が生成されているはずです。
指定したフォルダに無い場合、生成されているがUnityに認識されていない時があるので、その場合はエクスプローラーでフォルダを開き、ファイルがあることを確認した後でUnityに戻れば読み込まれて、Unityエディタ上で表示されるようになると思います。
この生成コードについては、対象となるクラスを書き直す度に改めて出力する必要があります。
StaticCompositeResolverに登録する
こうして生成したコードをStaticCompositeResolverに登録する必要があります。このコードについてはGitHubの公式ドキュメントに載っているデモコードほぼそのままですが、これで十分動きます。
これもMonoBehaviourを継承したクラスではありませんが、[RuntimeInitializeOnLoadMethod]アトリビュートがあるので、プロジェクト内にスクリプトがあれば、そのままで実行されます。
以上で、MessagePackを使う基本的な準備は整いました。それでは次項ではMessagePackを使ってシリアライズ、デシリアライズしてデータを読み書きする、ということに挑戦したいと思います。
シリアライズしたデータをセーブロードする
データ(ここではGameDataTestオブジェクト)をMessagePackSerializer.Serializeメソッドを使ってbyte arrayにシリアライズし、それをSystem.IOのファイル操作を使ってdatファイルに書き込みます。
OnApplicationQuitを使って、ゲームを閉じた時にGameDataTestオブジェクトをシリアライズして、File.WriteAllBytesを使ってプレイデータを保存するようにします。
Application.persistentPathでそのアプリケーションの内部データを保存するフォルダのパスを取得し、そこにファイル名と拡張子を書き足します。エディタでは、
C:\Users\(ユーザー名)\AppData\LocalLow\(Company Name)\(Product Name)
という感じのProject SettingsのPlayerページで設定できる、Company NameとProduct Nameに基づいたパスに記録されます。このパスについてはプラットフォーム毎に変わるようです。
persistentPathを使えばデータの保存場所についても本番を想定できますね。
そして、ゲーム起動時にそのファイルを当該のパスからFile.ReadAllBytesで読み込み、DeserializeメソッドでGameDataTestオブジェクトに戻し、ゲーム内データに反映させます。この例ではプレイヤーのリスポーン地点も保存しているので、プレイヤーの位置をそこに移動させています。
まとめ
一度は挫折しかけてしまいましたが、分かってしまえば軽く使う分には簡単に使えるシリアライザでした。ちょっとしたゲームの簡単なセーブ機能ならば、コレで十分ではないかと思います。
ただし、本番環境で使うにはセキュリティ的な問題も当然考慮しなければならず、そこら辺は非常に頭が痛くなりますね。とは言え、セーブ機能の実装については気になるところではあったので自分なりの取っ掛かりにはなったのは良かったです。