4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】SerializeReferenceの候補を日本語表示させるOdinInspectorの拡張Attributeを作りました。

Last updated at Posted at 2023-03-21

Untitled (1).png
OdinInspector環境でSerializeReferenceを使用した際、日本語で候補が出てくれるアセットを作りました。細かい説明は後ほどします。

事前条件

  • Unity開発環境であること
  • Odin Inspectorを導入済みであること

OdinInspectorでは過去にも触れていますが、個人的には必須アセットです。これなしでは仕事できない…!というレベルですね。

成果物

unitypackage直置きです。後日Githubに載せるかも。
クラス名等は今ゴリゴリ進めているプロジェクトの一部をもじったものなのでお気になさらず。

使用例

①ラベルとなるクラスでは[POSubclassLabel]をつけます。

クラス定義側
    public abstract class TestDisplay
    {
        public abstract string Display();
    }

    //ドロップダウンに表示したい内容を定義します。
    [POSubclassLabel("文字列/名前")]
    public class NameDisplay : TestDisplay
    {
        public string name = "hako 生活";
        public override string Display() => name;
    }

    //アイコンも設定できます。
    [POSubclassLabel("数値/HP", "Assets/anyicons/icon.png")]
    public class HPDisplay : TestDisplay
    {
        public int hp = 99;
        public override string Display() => hp;
    }

②SerializeReferenceと一緒に[POSubclass]を付けます。

使用側
    [SerializeReference, POSubclass]
    TestDisplay test = new HPDisplay();

結果

▼適用前
image.png

▼適用後
image.png
SerializeReferenceの候補をOdinのValueDropdownのように日本語の候補として出すことができます。

実装の経緯

以前は安定性が怪しげだったSerializeReferenceも、近年はかなり安定してきたように思います。
噂によると2021LTS(Unity 2021.3)で結構がっつりとメスが入り、より安定する(してる)とのこと。ポリモーフィズム大好物な僕としてはうれしい限り。

チーム制作では特に、ゲームデザイナーやUIデザイナー等に、ツールとして多岐にわたる機能を提供することが多いです。
そのため、ValueDropDownのような日本語でのドロップダウン表示機能はプログラムに慣れていない人とも意思疎通がとりやすく、とても重宝しています。
また、ポリモーフィズムおよびSerializeReferenceを使うと、設定のみでいろいろと柔軟性のある挙動を実装できると思います。
SerializeReferenceではクラス名がむき出しだったこともあり、日本語表示のドロップダウンを表示したいと考えていました。
エディタ拡張をしない場合はラッパークラスを作ってごまかす必要がありました。

ポリモーフィズムのラッパークラス
    [System.Serializable]
    public class TestSetting
    {
        [OnValueChanged("OnChangeType")]
        TestType type;
        [SerializeReference, HideReferenceObjectPicker]
        TestInfo info = new InheritTestInfoA(); //ポリモーフィズムの値
        public void OnChangeType()
        {
            info = type switch
            {
                TestType.TypeA => new InheritTestInfoA(),
                TestType.TypeB => new InheritTestInfoB(),
                _=>null,
            };
        }
    }
    public abstract class TestInfo { }
    public class InheritTestInfoA : TestInfo { }
    public class InheritTestInfoB : TestInfo { }
    public enum TestType
    {
        [InspectorName("タイプA")]
        TypeA,
        [InspectorName("タイプB")]
        TypeB,
    }

これだとまわりくどい!! ということで、今回の実装に至りました。

SerializeReferenceの自前拡張として"SubclassSelector"というものを実装している方がいたので、今回はそちらを参考にさせていただきながら作りました。

実装編:エディタ拡張

作るもの

  • ドロップダウンのアトリビュート
  • ラベルのアトリビュート
  • ドロップダウンのドロワー

ドロップダウンのアトリビュート

まずはSerializeReferenceラベル側のアトリビュート。こちらSubclassSelectorをほぼそのまま使用させていただきました。。

アトリビュートはランタイム側のフォルダ(/Editor/をパスに含まないフォルダ)に配置する必要があります。

Runtime/POSubclassLabelAttribute.cs
using Sirenix.OdinInspector;
using System;

[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class POSubclassAttribute: HideReferenceObjectPickerAttribute
{
	bool m_includeMono;

	public POSubclassAttribute( bool includeMono = false)
	{
		m_includeMono = includeMono;
	}

	public bool IsIncludeMono()
	{
		return m_includeMono;
	}
}

OdinのデフォルトのObjectPickerを消すアトリビュートとして[HideReferenceObjectPicker]というものがあるのですが、
今回のドロップダウンを使用する際にこれもいちいち一緒につけるのは億劫なので、
HideReferenceObjectPickerAttributeを継承することで、今回の機能とHideReferenceObjectPickerを兼ねることにしました。
この継承により、OdinのデフォルトのObjectPickerを消してます。割と強引ですね。
ところでAttributeってリフレクションで動的に追加とかできるのかな…?

ラベルのアトリビュート

お次はラベル側のアトリビュート。実装クラスに日本語名を設定できるようにします。
OdinInspectorのセレクタはドロップダウンがアイコンに対応しているので、アイコンを指定できるようなオーバーロードも追加しました。

こちらもランタイム側のフォルダに置きましょう。

Runtime/POSubclassLabelAttribute.cs
#if UNITY_EDITOR
using System;
using UnityEditor;
using UnityEngine;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class POSubclassLabelAttribute : PropertyAttribute
{
	public string displayName = "";
    public Texture icon;

	public POSubclassLabelAttribute(string name)
	{
		displayName = name;
	}

	public POSubclassLabelAttribute(string name, string iconPath)
	{
		displayName = name; 
		icon = EditorGUIUtility.Load(iconPath) as Texture2D;
	}
}
#endif

ドロップダウンのドロワー

最後に、SubclassSelectorをOdinAttributeDrawerの形式に軽く整えたもの。
(実際はもっとOdinDrawerフレームワークに則った書き方もあるっぽいのですが、今回はSubclassSelectorを流用してUnityEditor.SerializedProperty経由で直接更新をかけています。)

ScerializeReferenceが対象とするインスタンスの宣言は、Arrayの子要素であるパターンがある可能性に注意します。

public class Hoge{}
[ScerializeReference]
Hoge value; //通常の抽象クラス
[ScerializeReference]
List<Hoge> values = new List<Hoge>(); //配列やリストの場合は<>の型を対象とするする。(リスト等は除外する)

エディター側に配置します。

Editor/POdinSubclassSelectorDrawer.cs
#if ODIN_INSPECTOR
#if UNITY_2019_3_OR_NEWER

using Sirenix.OdinInspector.Editor;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;

[DrawerPriority(DrawerPriorityLevel.AttributePriority)]
public class POdinSubclassSelectorDrawer : OdinAttributeDrawer<POSubclassAttribute>
{
    public List<TypeLabel> typeLabels = new List<TypeLabel>();
    int currentTypeIndex;
    int nextIndex;
    public class TypeLabel
    {
        public const string NULL_LABEL = "<null>";
        public Type inheritedType;
        public string dipslayName;
        public string typeFullName;
        public Texture icon;

        public static TypeLabel Null = new TypeLabel()
        {
            inheritedType = null,
            dipslayName = NULL_LABEL,
            typeFullName = "",
        };
    }


    protected override void Initialize()
    {
        base.Initialize();
        var prop = Property.Tree.UnitySerializedObject.FindProperty(Property.UnityPropertyPath);
        if (prop.isArray) { return ; }
        POSubclassAttribute utility = this.Attribute;
        GetAllInheritedTypes(Property.Info.TypeOfValue, utility.IsIncludeMono());
        GetCurrentTypeIndex(prop.managedReferenceFullTypename);
        nextIndex = currentTypeIndex;
    }


    public override bool CanDrawTypeFilter(Type type)
    {
        return !type.IsArray && !type.IsAbstract && !type.IsGenericType;    // ジェネリクスのフィルターはいらないかも。
    }



    protected override void DrawPropertyLayout(GUIContent label)
    {
        var prop = Property.Tree.UnitySerializedObject.FindProperty(Property.UnityPropertyPath);
        if (prop.isArray || currentTypeIndex >= typeLabels.Count) {
            CallNextDrawer(label);
            return;
        }
        var typeLabel = typeLabels[currentTypeIndex];
        if (EditorGUILayout.DropdownButton(new GUIContent(typeLabel.dipslayName, typeLabel.icon), FocusType.Passive))
        {
            var selector = new SubTypeSelector(typeLabels);
            selector.SelectionConfirmed += col =>
            {
                nextIndex = col.FirstOrDefault();
            };
            selector.ShowInPopup();
        }
        UpdatePropertyToSelectedTypeIndex(prop, nextIndex);
        CallNextDrawer(label);
    }


    private void GetCurrentTypeIndex(string typeFullName)
    {
        currentTypeIndex = typeLabels.FindIndex(r => r.typeFullName == typeFullName);
    }

    void GetAllInheritedTypes(Type baseType, bool includeMono)
    {
        Type monoType = typeof(MonoBehaviour);
        var inheritedTypes = AppDomain.CurrentDomain.GetAssemblies()
            .SelectMany(s => s.GetTypes())
            .Where(p => baseType.IsAssignableFrom(p) && p.IsClass && (!monoType.IsAssignableFrom(p) || includeMono) && !p.IsAbstract)
            .Prepend(null)
            .ToArray();


        foreach(var type in inheritedTypes)
        {
            if (type == null)
            {
                typeLabels.Add(TypeLabel.Null);
            }
            else
            {
                bool hasLabel = TryGetLabelAttribute(type, out var label);
                typeLabels.Add(
                    new TypeLabel()
                    {
                        inheritedType = type,
                        dipslayName = hasLabel ? label.displayName : type.Name,
                        typeFullName = string.Format("{0} {1}", type.Assembly.ToString().Split(',')[0], type.FullName),
                        icon = hasLabel ? label.icon : null,
                    });
            }
        }
    }

    private static bool TryGetLabelAttribute(Type type, out POSubclassLabelAttribute subclassLabelAttribute)
    {
        subclassLabelAttribute = type.GetCustomAttribute<POSubclassLabelAttribute>();
        return subclassLabelAttribute != null;
    }


    //プルダウンで選ばれたものをリフレクションで生成
    public void UpdatePropertyToSelectedTypeIndex(SerializedProperty property, int selectedTypeIndex)
    {
        if (currentTypeIndex == selectedTypeIndex)
        {
            return;
        }
        currentTypeIndex = selectedTypeIndex;
        Type selectedType = typeLabels[selectedTypeIndex].inheritedType;
        property.managedReferenceValue =
            selectedType == null ? null : Activator.CreateInstance(selectedType);
    }

    //Odinが用意しているDropDownを実装
    public class SubTypeSelector : OdinSelector<int>
    {
        List<TypeLabel> labels;
        public SubTypeSelector(List<TypeLabel> labels)
        {
            this.labels = labels;
            this.EnableSingleClickToSelect();
        }

        protected override void BuildSelectionTree(OdinMenuTree tree)
        {
            tree.Selection.SupportsMultiSelect = false;
            int labelCount = labels.Count;
            for (int i = 0; i < labelCount; i++)
            {
                tree.Add(labels[i].dipslayName, i , labels[i].icon);
            }
        }
    }
}
#endif
#endif

実装内容ですが、基本的にはType型をフィルタしたり収集したりしています。
インスタンスを動的に生成はおなじみActivator.CreateInstance(selectedType);を使用しています。

また、SubclassSelectorではPropertyのパスを見て型の推測をしていましたが、OdinAttributeDrawerではAttributeの型を取得できるので、そちらを使います。
入れ子等で複雑化したpropertyPathを見なくて済むので、よりシンプルになりますね。

TypeLabelというクラスに各実装クラスのラベル情報を格納します。タイプ、アイコンだけでなく色などを持たせておけば、プルダウンで選択した後に色を変えられるなどいろいろ拡張できるかと思います。

拡張案

  • ラベルに色情報をつける
  • ディスプレイ名に加えてクラス名も表示するオプションをつける
    などができますね。

-- 2023/04/10追記
改修したType、クラスはキャッシュするとインスペクターの表示速度が安定しました。コードはまた後日載せるかも。
-- 2024/09/7追記
キャッシュのサンプルコードは次の記事に乗せています。

できてないこと(解決策募集)

  • インスタンスがNullの時、このドロワーは機能せずOdinInspectorの[ScerializeReference]のドロワーが表示されます。(解決策募集です。)
    デフォルトで何かインスタンスを入れておくといいかもしれない。
  • ドロップダウンは現状マウス位置を起点に出現します。Odinの[ValueDropDown]のようにタブの下にきれいに表示するためのポジション取得が必要そうです。
     Unity標準のCustomPropertyDrawerと違いEditorGUILayout系になっているので費用対効果を考えて未実装です。
     取得したPositionかselector.ShowInPopup(Vector2 position);としてつっこめば良いはず。
    -- 2024/09/7追記
    ⇒ 次の記事のサンプルコードでは解決しています。

以上です。
Qiita初投稿でした。お手柔らかに。

次の記事

下記の記事でこのドロワーをさらに便利にしています。(キャッシュ等も実装しています。)

参考

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?