【Unity】boolの2次元配列をシリアライズしてInspectorに表示する

## はじめに
Unityで当たり判定の管理にboolの2次元配列(`bool[,]`)を使いたかったが、調べても自分の使用したいものがなかったので作成しました。結果的に次のように変数宣言するだけで画像のようなエディターで値を編集できるので満足しています。
```C#
public BoolPlane BoolArray;
```
![](https://camo.githubusercontent.com/1a44e8ad1aea0996d3f0b3adf1a74464f66281c0b5927d7f551fbe49254f30a6/68747470733a2f2f692e696d6775722e636f6d2f5a6730306746482e706e67)![](https://camo.githubusercontent.com/772eecff3d4bb1425a998f379a5d5b783759cdc8b166b09baed4f12238e336e1/68747470733a2f2f692e696d6775722e636f6d2f597638544537442e706e67)


重要な部分以外は省略しているので、使用する際や全文を把握したい場合は[Github](https://github.com/orisaki888/SerializedMultiArray)をご覧ください。

Unityのバージョンは2021.3.5fを使用しています。


## 目次
 - シリアライズ可能なクラスを作成
     - OnBeforeSerialize
     - OnAfterDeserialize
 - EditorWindowを作成
     - 配列のサイズ変更
     - 値の自動保存
 - PropertyDrawerを作成


## シリアライズ可能なクラスを作成
データが保存できなければ、表示できても意味がないので、シリアライズを実装します。
今回は2次元配列を1次元配列に変換することでシリアライズを実現しています。
#### 手順

1. `ISerializationCallbackReceiver`を継承したクラスを作成する
1. `OnBeforeSerialize`にシリアライズ処理を記述する
1. `OnAfterDeserialize`にデシリアライズの処理を記述する


```C#:BoolPlane.cs 

[Serializable]
public class BoolPlane : ISerializationCallbackReceiver
{
    public bool[,] Value;
    private bool[] Serialize;

    private int m_width, m_height;

    public void OnAfterDeserialize()
    {
        Value = new bool[m_width, m_height];

        for (int x = 0; x < m_width; x++)
            for (int y = 0; y < m_height; y++)
            {
                Value[x, y] = Serialize[x + m_width * y];
            }
    }

    public void OnBeforeSerialize()
    {
        var w = Value.GetLength(0);
        var h = Value.GetLength(1);
        m_width = w;
        m_height = h;

        Serialize = new bool[w * h];

        for (int x = 0; x < w; x++)
            for (int y = 0; y < h; y++)
            {
                Serialize[x + w * y] = Value[x, y];
            }
    }
}
```

### OnBeforeSerialize
Unityのスクリプトリファレンスによると、Unityがオブジェクトをシリアライズする前に呼び出されるようで、ここにシリアライズ処理を記述しなければいけません。上記のスクリプトでは`Value`がシリアライズされる対象`Serialize`がシリアライズされた値になります。
下記の処理で`Value`の値を`Serialize`にコピーしています。


>```C#
> //前略
> Serialize[x + w * y] = Value[x, y];
>```
### OnAfterDeserialize
OnBeforeSerializeとは逆でデシリアライズ処理を記述します。下記の処理で`Serialize`の値を`Value`にコピーしています。
>```C#
> //前略
> Value[x, y] = Serialize[x + m_width * y];
>```

## EditorWindowを作成
値を編集するために次のようなウィンドウを作成します。

![](https://camo.githubusercontent.com/772eecff3d4bb1425a998f379a5d5b783759cdc8b166b09baed4f12238e336e1/68747470733a2f2f692e696d6775722e636f6d2f597638544537442e706e67)

```C#:BoolPlaneWindow.cs
 public class BoolPlaneWindow : EditorWindow
 {
     //値の編集・更新
     SerializedObject serialized;
     Object obj;
     BoolPlane box;

     //編集対象
     bool[,] value;

     //EditorGUI用変数
     Vector2 scroll;
     Vector2Int planeSize;

     public static void ShowWindow(FieldInfo field, Object obj, SerializedObject serialized)
     {
         var window = GetWindow<BoolPlaneWindow>("Bool Array");

         //値の設定
         window.serialized = serialized;
         window.obj = obj;
         var box = field.GetValue(obj) as BoolPlane;
         window.box = box;
         window.value = box.Value;
         window.planeSize = new Vector2Int(box.Width, box.Height);
         window.Show();
     }

     private void OnGUI()
     {
         //配列のサイズを設定する
         EditorGUILayout.LabelField("Set array size:");
         EditorGUILayout.BeginHorizontal();
         planeSize = EditorGUILayout.Vector2IntField(GUIContent.none, planeSize);
         if (GUILayout.Button("Set"))
         {
             //配列のサイズ変更
             ResizeArray();

             //変更した配列をBoolPlaneに適用
             serialized.Update();
             box.Value = value;
             serialized.ApplyModifiedPropertiesWithoutUndo();

             //保存処理
             EditorUtility.SetDirty(obj);
             AssetDatabase.SaveAssets();
         }
         EditorGUILayout.EndHorizontal();
         //値を表示するエリア
         scroll = EditorGUILayout.BeginScrollView(scroll);

         for (int y = 0; y < value.GetLength(1); y++)
         {
             EditorGUILayout.BeginHorizontal();
             for (int x = 0; x < value.GetLength(0); x++)
             {
                 value[x, y] = EditorGUILayout.Toggle(value[x, y], GUILayout.Width(15));
             }
             EditorGUILayout.EndHorizontal();
         }
         EditorGUILayout.EndScrollView();
     }

     /// <summary>
     /// 配列をリサイズする
     /// </summary>
     private void ResizeArray()
     {
         var newValue = new bool[planeSize.x, planeSize.y];
         for (int x = 0; x < planeSize.x && x < value.GetLength(0); x++)
             for (int y = 0; y < planeSize.y && y < value.GetLength(1); y++)
             {
                 newValue[x, y] = value[x, y];
             }
         value = newValue;
     }

     private void OnDestroy()
     {
         //ウィンドウを閉じるときに値を保存する
         EditorUtility.SetDirty(obj);
         AssetDatabase.SaveAssets();
     }
 }
```

通常のエディター拡張とあまり変わらないので、重要な点だけ解説します。

#### 配列のサイズ変更
配列のサイズを変更した際に、新しい配列を生成するので下記のように保存処理をする必要があります。

>```C#
> if (GUILayout.Button("Set"))
>        {
>            //配列のサイズ変更
>            ResizeArray();
>
>            //変更した配列をBoolPlaneに適用
>            serialized.Update();
>            box.Value = value;
>            serialized.ApplyModifiedPropertiesWithoutUndo();
>
>            //保存処理
>            EditorUtility.SetDirty(obj);
>            AssetDatabase.SaveAssets();
>        }
>```

#### 値の自動保存
`OnDestroy`に保存処理を書くことで確実に値を保存することができます。
>```C#:
>    private void OnDestroy()
>    {
>        //ウィンドウを閉じるときに値を保存する
>        EditorUtility.SetDirty(obj);
>        AssetDatabase.SaveAssets();
>    }
>```


## PropertyDrawerを作成
PropertyDrawerを使うことでInspector上に`BoolPlane`を表示出来るようになります。
Inspector上にボタンを配置して先程作成したエディターを呼び出します。

```C#:BoolPlaneDrawer.cs
 [CustomPropertyDrawer(typeof(BoolPlane))]
 public class BoolPlaneDrawer : PropertyDrawer
 {
     public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
     {
         EditorGUI.BeginProperty(position, label, property);
         // 子のフィールドをインデントしない 
         var indent = EditorGUI.indentLevel;
         EditorGUI.indentLevel = 0;
         // 矩形を計算
         var labelRect = new Rect(position.x, position.y, position.width * 0.4f, position.height);
         var sizeRect = new Rect(position.x + position.width * 0.4f, position.y, position.width * 0.4f, position.height);
         var buttonRect = new Rect(position.x + position.width * 0.8f + 10, position.y, position.width * 0.2f - 10, position.height);

         var sizeVec = new Vector2Int(property.FindPropertyRelative("m_width").intValue,
                                      property.FindPropertyRelative("m_height").intValue);

         //ラベル表示
         EditorGUI.PrefixLabel(labelRect, GUIUtility.GetControlID(FocusType.Passive), label);

         //サイズ表示
         EditorGUI.BeginDisabledGroup(true);
         EditorGUI.Vector2IntField(sizeRect, GUIContent.none, sizeVec);
         EditorGUI.EndDisabledGroup();

         //編集ウィンドウを表示する
         if (GUI.Button(buttonRect, "Edit"))
         {
             var obj = property.serializedObject.targetObject;
             var fields = obj.GetType().GetFields();

             //BoolPlaneを見つける
             foreach (var field in fields)
             {
                 var val = field.GetValue(obj);

                 if (val != null &&
                     val.GetType() == typeof(BoolPlane) &&
                     field.Name.ToLower() == label.text.Replace(" ", "").ToLower())
                 {
                     BoolPlaneWindow.ShowWindow(field, obj, property.serializedObject, label.text);
                 }
             }


         }
         // インデントを元通りに戻す
         EditorGUI.indentLevel = indent;

         EditorGUI.EndProperty();
     }
}
```

基本的にはUnity公式ドキュメントの[PropertyDrawer](https://docs.unity3d.com/ja/2021.3/Manual/editor-PropertyDrawers.html)のチュートリアルのコピーですが、`GUI.Button()`のブロックでは編集の対象となるオブジェクトを見つけるために、リフレクションを使用して複雑な処理を行っているので詳しく解説します。

始めに、`targetObject`で`BoolPlane`が使用されているオブジェクトを取得します。
>```C#
>var obj = property.serializedObject.targetObject; 
>```
例として次のようなスクリプトがあった場合、`targetObject`は`Test`になります。
```C#:Test.cs
class Test : MonoBehaviour
{
    public BoolPlane planeObject;
}

```
次に`Test`から編集対象となる`BoolPlane`を探したいのでフィールドを全て取得して、以下の条件に一致するフィールドを発見します。

1. 値がNullではない
1. オブジェクトの型が`BoolPlane`である
1. オブジェクト名を小文字に変換した文字列が等しい

3番のように面倒なことをするには理由があります。`label.text`はUnityのInspector上で表示される名称だからです。例えば上記のTest.csの`planeObject`は`label.text`だと"Plane Object"になるため、空白を削除したうえで小文字に揃えないと"planeObject"と"Plane Object"は等しくならないからです。
ただ、一つ注意点があり、"PLANEOBJECT"と"planeobject"といった同名の大文字小文字違いを判断できない点です。(通常は命名規則に違反するので、する人はいないと思います...)

>```C#
>//BoolPlaneを見つける
>foreach (var field in fields)
> {
>      var val = field.GetValue(obj);
>
>      if (val != null &&
>          val.GetType() == typeof(BoolPlane) &&
>          field.Name.ToLower() == label.text.Replace(" ", "").ToLower())
>      {
>          BoolPlaneWindow.ShowWindow(field, obj, property.serializedObject);
>      }
> }
>
>```

最後に対象の`BoolPlane`を発見できたので、`BoolPlaneWindow.ShowWindow()`を呼び出して、編集画面を表示して終了になります。

## おわりに
[Github](https://github.com/orisaki888/SerializedMultiArray)のほうに、bool以外のいくつかの型を用意してあるので興味あれば、ぜひ使用してください。
質問や感想等あれば、ぜひコメントお願いします。



## 参考サイト
・https://docs.unity3d.com/ja/2021.2/ScriptReference/ISerializationCallbackReceiver.html
・https://docs.unity3d.com/ja/2021.2/Manual/script-Serialization-Custom.html
・https://kazupon.org/unity-no-edit-param-view-inspector/
・https://docs.unity3d.com/ja/2021.3/Manual/editor-PropertyDrawers.html
・https://docs.unity3d.com/ja/2021.1/ScriptReference/EditorGUILayout.html
・https://docs.unity3d.com/ja/current/ScriptReference/GUI.html