🔗

【エディタ拡張】Unity Editor 上でも interface を参照したい!

2024/10/13に公開

目的

同じinterfaceを実装しているならMonoBehaviourScriptableObjectを区別せずにエディタから参照したい!

最終的な形

以下のような使い方で型指定したinterfaceを実装したオブジェクトを参照できるようにした。

using UnityEngine;

public interface ITarget {}

public class User : MonoBehaviour
{
    [SerializeField]
    InterfaceReference<ITarget> container; // 単一の参照

    [SerializeField]
    InterfaceReference<ITarget>[] containers; // 配列の場合
}

これでエディタからITargetインターフェースを実装したMonoBehaviourScriptableObjectのみをエディタから与えられるようにしたい。

MonoBehaviour
ScriptableObject

実装

今回の実装において最も重要なのはInterfaceReferenceをエディタ上に表示するためのエディタ拡張である。

また、筆者の環境はUnity 2022.3である。

InterfaceReference

InterfaceReferenceは以下のようにとってもシンプルな構造。

Assets/Runtime/InterfaceReference.cs
[Serializable]
public class InterfaceReference<Interface>
{
    [SerializeField]
    UnityEngine.Object _reference;
    public Interface current
    {
        get
        {
            if (_reference is Interface value) return value;
            throw new InvalidCastException();
        }
    }
}

Unity.ObjectMonoBehaviourScriptableObjectなどの Unity 上のさまざまなオブジェクトの大元で、これによってどちらの参照も保有できる。

InterfaceReferenceにスクリプトから参照を入れる仕組みがないが、そもそもスクリプト内で参照を解決するのであればITargetの変数なりを定義すればいいだけなので、これを使う必要がないと判断。
どうしても欲しい場合は各自実装すること。そんなに難しくないはずである。

InterfaceReferenceDrawer

上のInterfaceReferenceだけでは、UnityEngine.Objectを参照しているだけなので、エディタ上から型引数で指定された interface を実装しているか検証できていない。
これでは実行時でないと検証されないので、それは困る。
ということでエディタ拡張が必要で、エディタ上の表示を変えるためにCustomPropertyDrawerを作る。

私が実装したのは以下のようにすることでやりたいことができた。

Assets/Editor/InterfaceReferenceDrawer.cs
[CustomPropertyDrawer(typeof(InterfaceReference<>), true)]
public class InterfaceReferenceDrawer : PropertyDrawer
{
    private static float LineHeight { get { return EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; } }

    // フィールドの型の取得(配列の場合、その各要素の型を取得)
    protected Type FieldType
    {
        get
        {
            var fieldType = fieldInfo.FieldType;
            if (fieldType.IsArray)
            {
                fieldType = fieldType.GetElementType();
            }
            else if (typeof(IList).IsAssignableFrom(fieldType))
            {
                fieldType = fieldType.GetGenericArguments()[0];
            }
            return fieldType;
        }
    }

    // 型引数で指定した interface の型の獲得
    protected Type InterfaceType => FieldType.GetGenericArguments()[0];

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var referenceProp = property.FindPropertyRelative("_reference");
        // GUI の描画
        EditorGUI.BeginProperty(position, label, property);
        EditorGUI.ObjectField(position, referenceProp, label);
        EditorGUI.EndProperty();
        // 入力値の検証
        var reference = referenceProp.objectReferenceValue;
        if (reference is null) return;
        if (reference is GameObject go)
        {
            // GameObject が入力された場合はアタッチされているコンポーネントを検索する
            if (go.TryGetComponent(InterfaceType, out Component component))
            {
                reference = component;
            }
            else
            {
                reference = null;
            }
        }
        if (!IsValid(reference))
        {
            reference = null;
            Debug.LogError($"'{property.displayName}' is able to reference ONLY Object implemented '{InterfaceType}' <at {property.serializedObject.targetObject}>");
        }
        referenceProp.objectReferenceValue = reference;
    }

    // reference オブジェクトが InterfaceType を 継承 / 実装 しているかどうかチェックする関数
    protected virtual bool IsValid(UnityEngine.Object reference)
    {
        if (reference is null) return false;
        var refType = reference.GetType();
        return InterfaceType.IsAssignableFrom(refType);
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return LineHeight;
    }
}

最初どのように型引数を取得するのかわからず右往左往した。SerializedPropertyからどうにか型情報を引っ張り出そうとしたが、うまくいかなかった。
公式リファレンスと睨めっこしていたら、PropertyDrawerfieldInfoというプロパティが存在することを発見し、ことなきを得た。

ただ、fieldInfo.FieldTypeを直接使うと以下の配列の場合にうまくいかない。

[SerializeField]
InterfaceReference<ITarget>[] references;

なのでfieldInfo.FieldTypeが配列なのかを判定し、配列の場合GetElementType()を使うことで、配列要素の型を取得することで回避している。

課題

Serializableなオブジェクトの中にInterfaceReferenceがある状況、つまりネストが深くなると表示が崩れてしまう。

参考

https://zenn.dev/murnana/articles/unity-property-attribute
https://inoookov.hatenablog.com/entry/2022/06/09/231754
https://qiita.com/ninomiya_shota/items/d38aa81b92d7c487b6aa

Discussion