徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】隣のSerializedPropertyを知りたくて

今回はUnityエディタ拡張のお話です。
カスタムアトリビュートを作って、他の変数のプロパティを取得することに躍起になっています。
f:id:urahimono:20211019114536p:plain


この記事にはUnity2020.3.20f1を使用しています。
.Netのバージョン設定には.Net4.x を使用しています。

カスタムアトリビュートから他のプロパティの情報を知りたい!

さて、このクラスを見てほしい。
変数だけがある簡単なコンポーネントさ。

// GameManager.cs
using UnityEngine;
public class GameManager : MonoBehaviour
{
    [SerializeField]
    private GameObject m_object    = default;
    [SerializeField]
    private int        m_intValue  = 0;
    [SerializeField]
    private bool       m_flagValue = false;

} // class GameManager

そしてアトリビュートを作ってみた。
特に何も仕事をしていないアトリビュートさ。

// TestFindAttribute.cs
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class TestFindAttribute : PropertyAttribute
{

} // class TestFindAttribute

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(TestFindAttribute))]
public class TestFindDrawer : PropertyDrawer
{
    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        Debug.Log(aProperty);
    }
} // class TestFindDrawer
#endif

この作ったアトリビュートを先ほどのコンポーネントの変数につけてあげよう。

// GameManager.cs
[SerializeField]
private GameObject m_object    = default;
[SerializeField, TestFind]
private int        m_intValue  = 0;
[SerializeField]
private bool       m_flagValue = false;

さて、ここからが問題だ。
僕は m_intValue に自作したアトリビュートである TestFindAttribute をつけている。
当然のことながら、アトリビュートの描画用クラスである TestFindDrawer では m_intValue のプロパティの情報は取得できるはずだ。

ではこの状態で、アトリビュートをつけていない m_objectm_flagValue の情報は持ってこれるだろうか?
今回はそれにチャレンジします。
どんな手を使ってでも持ってくるぞ!

えっ、 CustomEditor を使って GameManager ごとエディタ拡張してしまえばいいんじゃないかって?
生憎だがそれは無しだ!

今回僕は PropertyAttribute のみで戦う!

MonoBehaviour など UnityEngine.Object を継承した者

まずはアトリビュートを描画する PropertyDrawer を確認しておこう。
OnGUI() が Inspector などに描画している部分だ。
ここが今回の主戦場になる。

そして肝となるのが引数の SerializedProperty
この子がいろいろと知っているはずだ。
尋問していこう。

今回はint型変数の m_intValue にアトリビュートをつけているため、intValue のプロパティを使って値を取得するのが通例だ。
propertyType でつけている変数の型が取得できる。
ただ、これはアトリビュートをつけている変数の話だ。
今回使うものではなさそうだ。

ポイントは serializedObject というプロパティだ。
m_intValue のシリアライズしたオブジェクトの情報が格納されている。
今回でいうならば GameManager のことか。
これは重要な情報だ。
ここからなら m_intValue の所持者である GameManager の情報を持ってこれそうだ。

serializedObject が持つ targetUnityEngine.Object 型。
これなら GameManager が持ってこれるじゃないか。
試してみよう。

// TestFindAttribute.cs
[CustomPropertyDrawer(typeof(TestFindAttribute))]
public class TestFindDrawer : PropertyDrawer
{
    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        // m_intValue の所持者である GameManager が取得できた!
        Object obj = aProperty.serializedObject.targetObject;
        GameManager gameManager = obj as GameManager;
        Debug.Log(gameManager.name);
    }
} // class TestFindDrawer

GameManager への変換が完了した。
これなら PropertyDrawer 内で GameManager の関数などガンガン使える。
ただ、今回はGameManagerが使いたいのではなく、その中にある変数の情報をしりたいのだ。
すなわち、Objectまで変換する必要がない。
serializedObject の状態で十分だ。

そして、serializedObject には FindProperty() というプロパティを探す関数が用意されている。
これで、GameManager の情報を全て引き出すことが出来るはずだ!

// TestFindAttribute.cs
public class TestFindDrawer : PropertyDrawer
{
    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        // GameManager の他のプロパティにアクセス出来た!
        var serializedObject = aProperty.serializedObject;
        SerializedProperty property = serializedObject.FindProperty("m_object");
        Debug.Log(property.name);
    }
} // class TestFindDrawer

ついに目的となるアトリビュートがついていない変数の情報を取得することに成功したぞ!
ただこのままではこのアトリビュートは、"m_object"という名前の変数を探すだけのものになるので、今後使うことはないだろう。
外側からどの名前の変数を取得するかを指定できるようにしてあげて、このアトリビュートを利用性を高めよう。

// TestFindAttribute.cs
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class TestFindAttribute : PropertyAttribute
{
    public TestFindAttribute(string aName)
    {
        name = aName;
    }

    public string name { get; private set; } = string.Empty;
} // class TestFindAttribute

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(TestFindAttribute))]
public class TestFindDrawer : PropertyDrawer
{
    private new TestFindAttribute attribute => base.attribute as TestFindAttribute;

    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        // GameManager の他のプロパティにアクセス出来た!
        var serializedObject = aProperty.serializedObject;
        SerializedProperty property = serializedObject.FindProperty(attribute.name);
        Debug.Log(property.displayName);
    }
} // class TestFindDrawer
#endif
// GameManager.cs
using UnityEngine;
public class GameManager : MonoBehaviour
{
    [SerializeField]
    private GameObject m_object    = default;
    [SerializeField, TestFind("m_flagValue")]
    private int        m_intValue  = 0;
    [SerializeField]
    private bool       m_flagValue = false;
} // class GameManager

これでアトリビュートの引数にしていした変数名のプロパティを取得できるようになった。
この方はMonoBehaviourを継承したクラスだけでなく、ScriptableObjectを継承したものでも可能だ。

// MyScriptableObject.cs
using UnityEngine;
[CreateAssetMenu(menuName = "MyScriptableObject", fileName = "MyScriptableObject")]
public class MyScriptableObject : ScriptableObject
{
    [SerializeField, TestFind("m_stringValue")]
    private float m_floatValue = 0.0f;
    [SerializeField]
    private string m_stringValue = string.Empty;
} // class MyScriptableObject

UnityEngine.Object ではない者

さてこれでミッションは完了した。
……と言えるだろうか。
いくつかのことを知ったことで、この世界の全てが分かったと思うのは愚か者の考えだ。

この場合はどうだろうか。
System.Serializable をつけた自前のクラスを GameManager に持たせてみた。

// GameManager.cs
using UnityEngine;
[System.Serializable]
public class SubClass
{
    [SerializeField, TestFind("m_stringValue")]
    private float m_floatValue = 0.0f;
    [SerializeField]
    private string m_stringValue = string.Empty;
} // class SubClass

public class GameManager : MonoBehaviour
{
    [SerializeField]
    private GameObject m_object    = default;
    [SerializeField]
    private SubClass   m_subClass  = null;
    [SerializeField]
    private bool       m_flagValue = false;
} // class GameManager

この場合は TestFindDrawer 側でエラーが起きてしまう。
m_stringValue なんてものは知らん! とね。

f:id:urahimono:20211019114507j:plain

FindProperty() を使って調べているのは GameManager のプロパティさ。
GameManagerm_stringValue は持っていない。
持っているのは、 m_subClass であり、その中にある。

この TestFindDrawer はまだ不完全なのさ。
ではどうすれば良いのだろう?
ここで使うのが SerializedPropertypropertyPath さ。
名前が表している通り、変数のパスが書かれている。

今回 TestFindAttributeSubClassm_floatValueについている。 そしてGameManagerSubClassを持っている。 この場合propertyPath` には m_subClass.m_floatValue と書かれているんだ。
親の変数名を取得することが可能になるんだ。

f:id:urahimono:20211019114527j:plain

これさえ分かればこっちのもの。
文字列操作で親の変数名がある場合の対応を追加してみよう。

// TestFindAttribute.cs
[CustomPropertyDrawer(typeof(TestFindAttribute))]
public class TestFindDrawer : PropertyDrawer
{
    private new TestFindAttribute attribute => base.attribute as TestFindAttribute;

    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        // アトリビュートの変数のクラスまでパスを辿るよ。
        string path     = attribute.name;
        string rootPath = aProperty.propertyPath;
        int splitIndex  = rootPath.LastIndexOf(".");
        if (splitIndex >= 0)
            path = $"{rootPath.Substring(0, splitIndex)}.{path}";


        var serializedObject = aProperty.serializedObject;
        SerializedProperty property = serializedObject.FindProperty(path);
        Debug.Log(property.displayName);
    }
} // class TestFindDrawer

これなら取得できるようだ。
更に入れ子になっても大丈夫。

// GameManager.cs
using UnityEngine;
[System.Serializable]
public class SubSubClass
{
    [SerializeField, TestFind("m_v3Value")]
    private Vector2 m_v2Value = Vector2.zero;
    [SerializeField]
    private Vector3 m_v3Value = Vector3.zero;
} // class SubClass

[System.Serializable]
public class SubClass
{
    [SerializeField]
    private float m_floatValue        = 0.0f;
    [SerializeField]
    private string m_stringValue      = string.Empty;
    [SerializeField]
    private SubSubClass m_subsubClass = default;
} // class SubClass

public class GameManager : MonoBehaviour
{
    [SerializeField]
    private GameObject m_object    = default;
    [SerializeField]
    private SubClass   m_subClass  = null;
    [SerializeField]
    private bool       m_flagValue = false;
} // class GameManager

しかし、配列のことを考えるとさらに頭が痛くなる。
配列の場合のパスは 変数名.Array.data[] の形になるので、
単純に "." で親子判定はできない。
なおかつ string は SerializedProperty では char の配列扱いになるので
isArray は常時 true になる という厄介な仕様なのだ。
なので変数の親子関係で考えねばならない以下の6パターンをすべて網羅する計算式を作る必要がある。

  • 変数
  • 配列変数
  • クラス -> 変数
  • クラス -> 配列変数
  • 配列クラス -> 変数
  • 配列クラス -> 配列変数

もう頭が回らなくなってきたのでこんな感じで締めます。

[CustomPropertyDrawer(typeof(TestFindAttribute))]
public class TestFindDrawer : PropertyDrawer
{
    private new TestFindAttribute attribute => base.attribute as TestFindAttribute;

    public override void OnGUI(
        Rect               aPosition, 
        SerializedProperty aProperty, 
        GUIContent         aLabel)
    {
        string path       = aVariableName;
        string fullPath   = aBaseProperty.propertyPath;
        string parentPath = fullPath;

        // string は char の配列扱いになるので、isArray は常時true.
        // やむを得ないのでパス最後の文字列で判断する。
        if (aBaseProperty.isArray && fullPath.EndsWith("]"))
        {
            int dotIndex = parentPath.LastIndexOf(".Array.data");
            if (dotIndex >= 0)
                parentPath = parentPath.Substring(0, dotIndex);
            // 複数の場合は name が data になっているのでパスが取得できないので
            // "."以降を消すことで変数名を消す。
            // "."が無い場合は親子関係が無いものとみなし全消し。
            int parentIndex = parentPath.LastIndexOf(".");
            if (parentIndex < 0)
                parentIndex = 0;
            parentPath = parentPath.Substring(0, parentIndex);
        }
        else
        {
            int nameIndex = parentPath.LastIndexOf(aBaseProperty.name);
            parentPath    = parentPath.Substring(0, nameIndex);
            // 変数に親子関係になっているときの"."消し
            int parentIndex = parentPath.LastIndexOf(".");
            if (parentIndex >= 0)
                parentPath = parentPath.Substring(0, parentIndex);
        }

        if (!string.IsNullOrEmpty(parentPath))
            path = $"{parentPath}.{path}";

        var serializedObject = aBaseProperty.serializedObject;
        SerializedProperty property = serializedObject.FindProperty(path);
        Debug.Log(property.displayName);
    }
} // class TestFindDrawer

絶対もっときれいなコードができると思うの……。
とりあえずこれらの情報がいつか役に立つ日が来るかもしれない。
いつの日か……。