土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#Unity JsonUtiltiyでモンスターデータのJSONファイルを構造体配列に読み込む

 ゲームでは大量の(本当に大量の)数値データを管理する必要があり、そのデータをどのように管理するかは、ゲーム開発では常に重要なテーマとなります。ここで言う数値データとは、武器のデータやモンスターのデータ(構造体)などのことです。これらが容易に数十~数百個スケールの構造体配列になることは想像がつくかと思います。
 とはいえ、地味なテーマなのもあり、深堀りしている記事は少ない印象です。もしかしたら今はMySQLをデータストアにしていることがあるのかも(本当に……?)。この辺について自前実装が必要になりつつありまして、勉強も兼ねて試したことを不定期に書いていきます。

データコンバートとは

 商業ゲーム開発では、慣例的にエクセルで各構造体配列ごとのデータテーブルを作り、それをバイナリデータにコンバートし、実行時にはそのバイナリファイルをメモリ上にベタで展開し、構造体配列のポインタを割り当てるという処理を行っていました(メモリマッピングと言います)。C#を使うUnityではこの処理を簡単には出来ないのですが、protobufとかMessagePack-CSharpとかを使えばなんとかなるでしょう(多分。知らない。今後検証していきます)。

JsonUtility

 上に書いた事はいったん忘れて(!)、ひとまず外部ファイルのデータをUnityに取り込みたいので手軽な方法を考えます。上にも書きましたがゲームで使う数値データの8割は構造体配列だと言い切って良いでしょう。そこで今回はJsonUitlityを使ってJSONデータを読み込み、構造体配列のインスタンスに格納してみます。

JSONとは

 JSONは"JavaScript Object Notation"の略です。JavaScriptにはネスト可能な連想配列(みたいな物)を記述する言語仕様があり、これを汎用データ交換フォーマットとして再定義したのがJSONです。ビジネスのWebアプリでサーバークライアント間で情報をやりとりするのに重用されていて、最近ではクエリに対してJSONデータを直接返すデータベースなども台頭しています。

JSONデータの作成

 こんな感じのJSONデータを作って"test01.json"という名前で保存します。

{
    "creature" : [
        {
            "Name" : "goblin",
            "SummonCost" : 1,
            "Power" : 1.2,
            "Toughness" : 3.5
        },
        {
            "Name" : "kobold",
            "SummonCost" : 3,
            "Power" : 3.5,
            "Toughness" : 5.12
        }
    ]
}

 JSONデータに使用できるエンコードはutf8のみです(忘れがちなので注意)。ここではモンスターデータを想定し、各モンスターごとにName(型はstring)、SummonCost(int)、Power(float)、Toughness(float)を持つとしました。ここでは"goblin"と"kobold"の2キャラを記述しています。
 JSONでは"{ }"がオブジェクト(≒連想配列)、"[ ]"が配列を指します。連想配列のキーは文字列型が必須です(区切り文字はダブルクオーテーションのみ対応。シングルクオーテーションや区切り文字なしは不可)。
 上記のJSONデータでは、rootの要素をオブジェクトとし、キー"creature"の値として2キャラ分のオブジェクトを配列で格納しています。実は、本来のJSONの仕様ではrootに直接配列を記述出来るのですが、今回使用するライブラリJsonUtilityがその記述形式に対応していないため、やむを得ずこのような書き方になっています。

JSONの文法上の注意:末尾のカンマ

 C#などの(JSONではない)言語で、配列の記述時によく使うイディオムに「末尾のカンマ」という物があります。これは配列の最終要素の末尾にもカンマを書き、要素の追加/入れ替え/削除を簡単にできるようにするという物で非常に便利なのですが、JSONではこの記法が許されていないために、末尾のカンマがあるとパースに失敗します(JavaScriptではES5から可能になってるけど、JSONはダメ)。
 土屋は、普段のコーディングで末尾カンマに慣れているため、エラーが出ている理由がわからずハマりました……。Unityで読み込む前にパースチェックするバッチを通すようにした方がいいかも。これも将来的に方法を考えます。

JSONデータの保存先について

 ファイルの保存先は、Assetsフォルダ直下に作った"StreamingAssets"フォルダ配下です。このフォルダ配下にされたファイルは、パブリッシュ時にもそのまま維持されます。テストなのでどこに配置しても良いのですが(本番ではJSONのままにはしない訳だし)、勉強を兼ねてこうしておきます。

格納クラス(構造体)の作成

 次に、このデータをUnity上で格納するための構造体を定義します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public struct CreatureList
{
    [System.Serializable]
    public struct Creature
    {
        public string Name;
        public int SummonCost;
        public float Power;
        public float Toughness;
    }
    
    public Creature[] creature;

    public void print()
    {
        foreach(var item in creature)
        {
            Debug.Log("[Name:" + item.Name +
                      "][SummonCost:" + item.SummonCost +
                      "][Power:" + item.Power +
                      "][Toughness:" + item.Toughness + "]");
        }
    }
}

 CreatureListとCreatureという二つの構造体を定義しています。
 CreatureList構造体は、Creature構造体の配列を持っているだけで、これは前述したJsonUtilityがルートに配列を持てないため苦肉の策で用意している構造体になります。print()はデバッグ出力用です。
 Creature構造体の方が本体で、JSONに記述した各キーに対応する変数Name、SummonCost、Power、Toughnessを宣言しています。
 どちらの構造体も、"[System.Serializable]"属性をアノテートし、このクラスがシリアライズ可能である事を宣言します。「シリアライズ可能」というのは、クラスの情報をなんらかの形でファイル化できる(=シリアライアズ。厳密にはファイルである必要はない)、またはその逆でなんらかの形のファイルからインスタンスを構築できる(=デシアライズ)ことを言います。

JsonUtilityでJSONデータを読み込む

 準備が終わったので、JSONデータを読み込む処理を実装します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class NewBehaviourScript : MonoBehaviour
{
    void Start()
    {
        FileInfo info = new FileInfo(Application.streamingAssetsPath + "/test01.json");
        StreamReader reader = new StreamReader(info.OpenRead());
        string json = reader.ReadToEnd();
        //JSONデータをパースし、CreatureListとしてデシリアライズ
        CreatureList data = JsonUtility.FromJson<CreatureList>(json);
        //CreatureList構造体をダンプ
        data.print();
        //CreatureList構造体をJSON形式にシリアライズ
        Debug.Log(JsonUtility.ToJson(data));
    }
}

 Application.streamingAssetsPathプロパティにはStreamingAssetsフォルダへのパスが格納されています。test01.jsonを文字列として読み込んだ後、JsonUtiltiyでパースし、CreatureList構造体として取り込みます。その後中身をコンソールにダンプしています。
 上記のコードを適当なGameObjectに割り当てて実行すると、以下のようにコンソールが出力されます(モンスターの名前がサンプルと一致してませんごめんなさい)。
f:id:t_tutiya:20190629222637p:plain
 JSONデータがCreatureList構造体(が持つCreature構造体配列)に格納されている(デシリアライズされている)こと、そして、それがまたJSON形式に変換されている(シリアライズされている)ことが確認できます。"5.12"がシリアライズ時に"5.119999885559082"になっていますが、これは浮動小数点を扱う以上回避できない事(だと思われる)ので、運用時には注意が必要です(よほどの事が無い限り実数は使わない方が無難かな……)。

発展

 今回はここまで。以下発展課題になります。

rootに配列を置けない問題の回避方法の検討

 JsonUtilityはUnityの標準ライブラリなので、外部ライブラリのダウンロードなどの事前準備をせずに使えます。また、Unityに十分に最適化しているようです(速度面のことはよくしらぬ)。このライブラリはクセがなく使いやすいのですが(こちらの記事を見る限り、深いところでは色々クセがあるようです)、rootに配列を置けないのがどうにも困ります。これのために本来書かなくていいコードとJSON構造を書いているわけで、できればなんとかしたい。
 root配列配置問題の対応として、こちらの記事では読み込み処理時にrootを追加するというアクロバティックな事をしています。次回はこれを試してみようかなと思っています。
 他のJSONライブラリを使う手もあります。オススメは「最速」と言われるUtf8json。当然(?)、rootの配列にも対応しています。

JSONファイルの配置場所の検討

 また、コーディング中に気づいたのですが、StreamingAssetsにJSONファイルを配置しておくと、ファイルを書き直すたびにmetaファイルが更新されて処理が止まりますね。データファイルの書き直しは本当に頻繁に行うのでイチイチ止まるのはストレスです。
 これについては以前の記事で書いたApplication.persistentDataPathを使って、Unityプロジェクト管理外のフォルダに配置した方が良さげです。フォルダが別になってコラボレートで管理しきれなくなるのがちょっと残念だけど、リソースデータについてはしょうがないかな……。というか、そもそも開発中のリソースフォルダは統一パスを作った方が良いかも。次はそうします。

参考リンク(適宜追加)

qiita.com