🦊

複数のJSONからScriptableObjectを自動生成する

2021/07/07に公開

目的

マスターデータ管理にScriptableObjectが便利らしいのでJSONから自動生成します。SO出力には下記エントリがとても参考になりました。変更点はcsvからJSONに、実行はメニューから、また特定のJSONではなく多くのJSONに対応させます。
https://ekulabo.com/csv-to-scriptable-object

ScriptableObjectとは?

マスターデータなど、基本的に変更がされない大量の共有データを管理するのに便利なクラスです。

例えば同一キャラクターのパラメーターを複数インスタンス化した場合、その分メモリを必要としてしまいますが、ScriptableObjectに設定されたキャラクターのパラメータを参照する様にすればキャラクターの数によってメモリ使用量は増えずメモリの節約になります。詳しくは下記エントリを読んで見てください。
https://qiita.com/4_mio_11/items/a7d8967b853cef4385cd
https://ekulabo.com/about-scriptable-object

まずは手動で作ってみると良いかもしれません。

なにが出来る?

  • 条件に合う複数のJSONをそのままScriptableObjectに一括出力が可能
  • Unityのメニューから実行が可能

条件に合うJSON

  • 下記の様なオブジェクト直下にキーと値のシンプルなメンバーまたはオブジェクト配列が含まれるJSON
    ※オブジェクト配列の中にオブジェクト配列が入れ子になっているJSONは不可
AnimalData.json
{
	"id": 1,
	"catData": [
		{
			"id": 1,
			"name": "イエネコ",
			"engName": "cat",
			"maxHP": 60
		},
		{
			"id": 2,
			"name": "ヤマネコ",
			"engName": "Wildcat",
			"maxHP": 100
		}
	]
}

生成されるScriptableObjectは下記

おおまかな解説

若干複雑なので、はじめに項目別に説明します。

1.ファイルの種類

使うファイルは下記5種類になります。1と2はそれぞれ一つだけでどちらもEditorフォルダに入れます。4と5は対応するJSON毎に作成する事になります。

  1. ScriptableObject出力スクリプト
  2. 1を呼び出すスクリプト(エディタ拡張)
  3. 対象のJSON
  4. JSON受取用クラスの名前
  5. ScriptableObject用クラス

2.メニューから任意のスクリプトを実行させる下準備

簡単にScriptableObjectを生成する為にEditor拡張機能を使ってメニューに出力スクリプトの実行ボタンを作る事になります。Editor拡張?なにそれ?と言う人は下記エントリを見ておくと良いです。
https://zenn.dev/noco/articles/3be016d4cf22fa

3.JSONからScriptableObject生成の根本的な流れ

根本的な流れは下記の様になっています。

  1. JSONをJsonUtilityを用いてJSON受取用クラス型でデシリアライズ
  2. JSON受取用クラスからScriptableObject用クラスへデータ受渡し
  3. データを受け取ったScriptableObject用クラスでScriptableObjectアセット生成

4.JSONと2種類のクラスの名前

JSONとJSON受取クラスのファイル名はファイル検索に利用するで同一にします。ScriptableObject用クラスはJSON受取クラス名に任意のキーワードを追加したファイル名にします。自分は語尾に"_SO"とつけています。

5.JSON受取用クラスとScriptableObject用クラス

JSON受取用クラス(以下受取用クラス)はJSONのデータをデシリアライズする際に使用するクラスです。ScriptableObject用クラス(以下SO用クラス)は受取用クラスからデータを引き継ぎ、ScriptableObjectの生成に使用するクラスです。双方の関係はデータ引き継ぎにあるのでフィールド等は同一です。

親と子
JSON内にオブジェクト配列が存在するとそれはListとなり指定の型を定義する必要があります。今回のコードではフィールドにListがあれば親クラス、そのListの型引数となるクラスを子クラスとしています。

dynamic型の多用

今回のコードはメンテナンス性も考慮し、条件に合う全てのJSONをScriptableObjectに変換出来るコードで記述します。その為、静的型付け言語としていかがな物とは思いますがdynamic型をかなり多用しています。まぁ、Editorフォルダ内でゲームに直接影響ないし良いかなと思っていますが、他に良い方法があれば教えていただきたいです。

コード

若干長いので部分毎に説明します。ScriptableObjectはSOと以下略。
コード全体は下記。面倒な人はリポジトリと同じ様な構成にして実行すれば出力できるはず。
https://github.com/nocolabo/json2ScriptableObject

ScriptableObject出力スクリプト

CreateAssets()

クラス全体だと長すぎるので、メインの処理コード記述し流れを説明します。メイン処理内のメソッドは無駄な処理もありますが、興味がリポジトリからお読みください。

CreateScriptableObjectFromJSON.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Text;
using System.Reflection;

public class CreateScriptableObjectFromJSON<T> 
{
	// ScriptableObjectを出力するディレクトリ
	private string outPutDir = Path.Combine("Assets", "MyProject", "Common", "Resources","SO");
	// ScriptableObjectに付与された名前
	private string nameForSO = "_SO";

	public void CreateAssets(string jsonPath, string parentClassName)
	{
		// JSONパスからJSONを読み込み、指定の型に合うインスタンスを生成する
		dynamic jsonObj = JsonReaderToCreateInstance(jsonPath);

		//受け取り先の親クラス名を取得
		string SOParentClassName = GetClassNameForSO(parentClassName, nameForSO);

		// リスト除くフィールド名、リストのみフィールド名、小クラスのフィールド名を受渡し元と先両方取得
		var ( parentFieldNames, parentFieldListNames, childClassNames) = GetFieldName(parentClassName);
		var (soParentFieldNames, soParentFieldListNames, soChildClassNames) = GetFieldName(SOParentClassName);

		// リストがあるかどうか判定
		if (parentFieldListNames.Count == 0)
		{
			// データ型のみの場合の処理

			// 受け取り先の親クラスをインスタンス化
			dynamic sopObj = UseClassNameCreateInstance(SOParentClassName);
			// 親クラスのフィールドに指定のデータを設定する
			SetFiledsData(sopObj, jsonObj);
			// 行数をidとしてファイル名作成
			string filename =  parentClassName + ".asset";
			string path = Path.Combine(outPutDir, filename);
			// Scriptable Object作成
			CreateScriptableAsset(path, sopObj);
		}
		else
		{
			// データ型とリストが混ざっている場合の処理
			List<dynamic> listOfJsonObj = GetFieldsFromObj(jsonObj, true);

			// 受け取り先の小クラスの指定カウンタ
			int childCnt = 0;
			// ファイル出力用カウンタ
			int fileNameCnt = 0;

			// 受け取り先の親クラスをインスタンス化 ※ここでないとダメ!
			dynamic sopObj = UseClassNameCreateInstance(SOParentClassName);

			// 親クラスのListを受け取り先に格納
			foreach (string listName in parentFieldListNames) // リスト毎
			{
				// 親オブジェクトからリストデータを取得
				var listDatas = listOfJsonObj[childCnt];


				// 親オブジェクトのリストを渡す
				foreach (var data in listDatas) // リスト無いのデータ毎
				{
					//受け取り先の子クラスのインスタンス化しリストに格納  ※ここでないとダメ!
					dynamic socObj = UseClassNameCreateInstance(soChildClassNames[childCnt]);

					// 親クラスのフィールドに指定のデータを設定する
					SetFiledsData(sopObj, jsonObj);

					//子クラスのフィールドに指定のデータを設定する
					SetFiledsData(socObj, data);

					// 親クラスのリストにデータを追加する
					SetListData(sopObj, socObj, listName);

					// 次のデータ作成の為にカウントアップ
					++fileNameCnt;
				}

				// 次の送り先の子クラスを選択する為のカウントアップ
				++childCnt;
			}
			// 行数をidとしてファイル名作成
			string filename = parentClassName  + ".asset";
			string path = Path.Combine(outPutDir, filename);

			// Scriptable Object作成
			CreateScriptableAsset(path, sopObj);
		}
	}
	
		-------------------  省略  ----------------- 
	

CreateAssets()の流れ

やってる事は異なるクラス間のデータ移行が大部分で単純です。

1.JSONパスからCreateScriptableObjectFromJSON<T>の型引数のクラス型でデシリアライズ(JsonReaderToCreateInstance)
2.JSONファイル名からSO用の親クラス名を取得(GetClassNameForSO)
3.受取用とSO用双方の親クラス内のリスト名、子クラス名を取得(GetFieldName)
4.受取用親クラスにリストが有るか判別

親クラスにリストが存在しない場合
1.SO用親クラスをインスタンス化(UseClassNameCreateInstance)
2.受取用親クラスからSO用親クラスへフィールドデータ移行(SetFiledsData)
3.ファイル名・パスを作成し、Scriptable Object作成(CreateScriptableAsset)

親クラスにリストが存在する場合
1.受取用親クラスに含まれるListのリストを作成
2.SO用親クラスをインスタンス化(UseClassNameCreateInstance)
3.親クラスのリスト名リストでforeach
4.1のリストでforeach
5.SO用子クラスを子クラスリストを使用しインスタンス化(UseClassNameCreateInstance)
6.受取用親クラスからSO用親クラスへフィールドデータ移行(SetFiledsData)
7.受取用親クラスのListデータからSO用子クラスへデータ移行(SetFiledsData)
8.SO用親クラスにリストを追加する(SetListData)
9.3が終了後ファイル名・パスを作成し、Scriptable Object作成(CreateScriptableAsset)

呼び出し用Editor拡張スクリプト

CreateAllScriptableAssets

このスクリプトでメニューに上記スクリプトを実行させるボタンを表示させる事が出来ます。ただ実行するだけでなく、JSON保存フォルダから一覽を取得・ScriptableObject生成を連続して行います。

MyMenuItems.cs
using UnityEngine;
using UnityEditor; // 追加
using System.IO;
using System;
using System.Reflection;

/// <summary>
/// メニューに任意の処理ボタンを設置する
/// </summary>
public class MyMenuItems
{
    // JSONディレクトリ
    static private string dirPath = Path.Combine(Application.dataPath, "MyProject", "Common", "JSON", "masterData");

    /// <summary>
    /// 指定ディレクトリ全てのJSONからScriptabelAssetを作成・更新する
    /// </summary>
    [MenuItem("MyMenuItems/CreateAllScriptableAssets %h")]
    private static void CreateAllScriptableAssets()
    {
        // JSONディレクトリ内のJsonファイルパスを取得
        string[] jsonFilePaths = Directory.GetFiles(dirPath, "*.json");

        // JSONパスからCreateScriptableのアセットを作成する
        foreach (string path in jsonFilePaths)
        {
            // JSONとクラスのファイル名は同一とし、ファイル名から取得
            string parentClassName = Path.GetFileNameWithoutExtension(path);

            Type type = GetTypeByClassName(parentClassName);
            var genericType = typeof(CreateScriptableObjectFromJSON<>).MakeGenericType(type);
            dynamic obj = Activator.CreateInstance(genericType);
            obj.CreateAssets(path, parentClassName);
        }
    }
	
    /// <summary>
    /// クラス名から型を取得する
    /// </summary>
    /// <param name="className"></param>
    /// <returns></returns>
    public static Type GetTypeByClassName(string className)
    {
        foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
        {
            foreach (Type type in assembly.GetTypes())
            {
                // type.Nameだと既存のタイプとかぶる場合がる為、完全一致で判定
                if (type.ToString() == className)
                {
                    return type;
                }
            }
        }
        return null;
    }
}	

CreateAllScriptableAssets()の流れ

Editor拡張の基本的な使い方は上で説明してるので省きます。4以下がやっている事は、型が記述出来ないため文字列からクラスの型引数を生成、インスタンスして実行している感じです。

1.指定のJSONの保存ディレクトリからJSONパス一覽を取得
2.JSONパスのリストでforeach
3.JSONパスからJSONファイル名を取得
4.ファイル名から型を取得※JSONのファイル名と受取用クラス名が同一である理由
5.受取用クラスの型でCreateScriptableObjectFromJSON用のジェネリクスタイプを生成(MakeGenericType)
6.生成したジェネリクスタイプでインスタンスを生成
7.生成したインスタンスでCreateAssetsを実行

受取用クラス

JSONのデシリアライズする際に必要になります。JSONと同じメンバーを用意し、オブジェクト配列が有るなら子クラスも定義する必要があります。子クラスには[Serializable]を設定が必要なので注意です。

AnimalData.cs
using System.Collections;
using System.Collections.Generic;
using System;

public class AnimalData
{
    public string id;
    public List<CatData> catData;
}

[Serializable]
public class CatData
{
    public int id;
    public string name;
    public string engName;
    public int maxHP;
}

SO用クラス

ScriptableObjectを生成する為のクラスです。受取用クラスと同様にJSONと同じメンバーが必要です。このクラスは受取用クラスと区別する為に末尾に"_SO"をつけた名称にしています。親クラスに"ScriptableObject"はScriptableObject生成に必要なので必ず継承します。また、SO用クラスのリストは定義するだけでなくインスタンス化しておく必要があります。[CreateAssetMenu()]は今回のスクリプトには不要ですが、設定するとプロパティを右クリックから空のScriptableObjectを作成が出来ます。

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

[CreateAssetMenu(menuName = "MyScriptable/Create AnimalData_SO")]
public class AnimalData_SO : ScriptableObject
{
    public string id;
    public List<CatData_SO> catData = new List<CatData_SO>();
}

[Serializable]
public class CatData_SO : CatData { }

もっと楽な異なるクラス間のデータ移行方法とかありそうな気がしますし、明らかいらない処理も有るのですがとりあえず出来たので良しとします。以前作ったスプレッドシートからJSONを作るスクリプトと合わせると楽にScriptableObjectの作成と管理が出来そうです。受取用クラスとSO用クラスを作るの面倒なので、これもJSONと一緒に自動生成出来るとさらに便利かもしれません。
https://zenn.dev/noco/articles/30743a55e06035

Discussion