インスペクタでクラス定数をenumのように選択できるようにする [Unityエディター拡張, Odin対応]

`string`型の値を特定クラスの定数のみに制限してドロップダウンで選択できる属性を実装します。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/6b844d6a-0da5-f103-7f07-25004c25febe.png">

# はじめに
Unityのインスペクタでは、以下のような列挙型(enum)で定義されたフィールドはドロップダウンで値を選択することができます。

```CS:SampleEnumMonoBehaviour.cs
using UnityEngine;

public enum SampleEnum
{
    None,
    A,
    B,
    C,
    D,
}

public class SampleEnumMonoBehaviour : MonoBehaviour
{
    [SerializeField] private SampleEnum sampleEnum;
}
```

<img width=240 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/921a1ba9-6f94-78a1-6004-305fe5473cb1.png"><img width=240 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/efc732f9-8fb7-4289-dbb5-7e4099fc3353.png"><img width=240 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/42fd8842-d316-3e67-6a25-a59acc75da4c.png">

これは非常に便利な機能ですが、一方で値に意味を持たせたい場合(アセットのパスを指定するときなど)は列挙型を使わずに以下のような定数を列挙したような定義したクラスを作る場合もあると思います。

```CS:SamplePath.cs
public class SamplePath
{
    public const string PLAYER_PATH = "Assets/Prefabs/Characters/Player";
    public const string ENEMY_PATH = "Assets/Prefabs/Characters/Enemy_01";
    public const string SWORD_PATH = "Assets/Prefabs/Items/Weapons/Sword";
    // 参考：https://learn.microsoft.com/ja-jp/dotnet/csharp/fundamentals/coding-style/coding-conventions
    // C#ではアッパースネークケースを使用することは基本的にないようなので、
    // 本来はconstなフィールドも以下のようにパスカルケースで書くらしい
    // public const string SwordPath = "Assets/Prefabs/Items/Weapons/Sword";
}
```

`string`型で定義されたフィールドは基本的に手入力になるので、何もしない場合例えば上記の`SamplePath`の値のみに制限して入力をさせるようなことはできません。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/87bcac63-5ca1-0a17-7b9a-6434aab0c8a5.png">
これを以下のようにドロップダウンで入力できるようにする属性の実装を紹介します。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/3c3a2938-ecd9-60a9-fd91-3d240833540a.png">

# 実装
属性`DropdownConstantsAttribute`の実装を示します。
通常の実装と、人気のアセット`Odin`に対応したものの2つを示します。

## 通常
```CS:DropdownConstantsAttribute.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

[AttributeUsage(AttributeTargets.Field)]
public class DropdownConstantsAttribute : PropertyAttribute
{
    // 定数の名前
    private readonly string[] menuNames;
    // 定数の値
    private readonly string[] menuValues;

    /// <summary>
    /// 引数で指定された型の定数のみをドロップダウンで入力できるようにする属性
    /// </summary>
    /// <param name="constantsClass">定数を取得するクラス</param>
    /// <param name="bindingFlags">取得する定数の設定用フラグ</param>
    public DropdownConstantsAttribute(Type constantsClass,
        BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
    {
        var fields = constantsClass.GetFields(bindingFlags);
        var menuDictionary = new Dictionary<string, string>(fields.Length);
        foreach (var field in fields)
        {
            if (field.FieldType == typeof(string))
            {
                menuDictionary.Add(field.Name, field.GetValue(constantsClass) as string);
            }
        }

        menuNames = menuDictionary.Keys.ToArray();
        menuValues = menuDictionary.Values.ToArray();
    }

#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(DropdownConstantsAttribute))]
    public class DropdownConstantsAttributeDrawer : PropertyDrawer
    {
        // 現在選択されている定数のインデックス
        private int index = -1;

        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            if (property.type != "string") return;

            var targetAttribute = (DropdownConstantsAttribute)attribute;

            if (index < 0)
            {
                index = string.IsNullOrEmpty(property.stringValue)
                    ? 0
                    : Array.IndexOf(targetAttribute.menuValues, property.stringValue);
            }

            // ドロップダウンメニューを表示
            index = EditorGUI.Popup(position, label.text, index, targetAttribute.menuNames);
            property.stringValue = targetAttribute.menuValues[index];
        }
    }
#endif
}
```

## Odin版
```CS:DropdownConstantsAttribute.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
#if UNITY_EDITOR
using Sirenix.OdinInspector.Editor;
using UnityEditor;
#endif

[AttributeUsage(AttributeTargets.Field)]
public class DropdownConstantsAttribute : Attribute
{
    private readonly string[] menuNames;
    private readonly string[] menuValues;
    private readonly bool viewValue;

    public DropdownConstantsAttribute(Type constantsClass, bool viewValue = false,
        BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
    {
        this.viewValue = viewValue;
        var fields = constantsClass.GetFields(bindingFlags);
        var menuDictionary = new Dictionary<string, string>(fields.Length);
        foreach (var field in fields)
        {
            if (field.FieldType == typeof(string))
            {
                menuDictionary.Add(field.Name, field.GetValue(constantsClass) as string);
            }
        }

        menuNames = menuDictionary.Keys.ToArray();
        menuValues = menuDictionary.Values.ToArray();
    }

#if UNITY_EDITOR
    [DrawerPriority(DrawerPriorityLevel.AttributePriority)]
    private class DropdownConstantsAttributeDrawer : OdinAttributeDrawer<DropdownConstantsAttribute, string>
    {
        private int index;

        protected override void Initialize()
        {
            if (string.IsNullOrEmpty(ValueEntry.SmartValue))
            {
                index = 0;
                ValueEntry.SmartValue = Attribute.menuValues.Length > 0 ? Attribute.menuValues[0] : "";
            }
            else
            {
                index = Array.IndexOf(Attribute.menuValues, ValueEntry.SmartValue);
            }
        }

        protected override void DrawPropertyLayout(GUIContent label)
        {
            index = EditorGUILayout.Popup(label, index, Attribute.menuNames);
            ValueEntry.SmartValue = Attribute.menuValues[index];
            if (Attribute.viewValue)
            {
                GUI.enabled = false;
                EditorGUILayout.LabelField($"{Attribute.menuNames[index]} :", Attribute.menuValues[index]);
                GUI.enabled = true;
            }
        }
    }
#endif
}
```

# 使い方
以下のように`SerializeField`などと併用して使用します。
引数でドロップダウンで表示したい定数が定義されているクラスの型を指定します。

```SamplePathMonoBehaviour.cs
using UnityEngine;

public class SamplePathMonoBehaviour : MonoBehaviour
{
    [SerializeField] private string path;
    [SerializeField] [DropdownConstants(typeof(SamplePath))]
    private string dropdownPath;
}
```

これで前述のように定数名をドロップダウンで表示して選択できるようになります。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/3c3a2938-ecd9-60a9-fd91-3d240833540a.png">

## 注意点
注意点がいくつかあります。
まず、表示するのは定数名ですが、実際にフィールドに格納されているのは定数の値(画像の`PLAYER_PATH`なら`Assets/Prefabs/Characters/Player`)です。
定数の値もインスペクタに表示したい場合は、LabelFieldなどを使って下に表示する、マウスオーバーした時にツールチップとして表示するようにするなど少し工夫が必要です。
Odin版ではラベルを下に表示する方法で対応しているのでご覧ください。
また当たり前の話ですが、これはインスペクタ上から値を設定する場合は制限として機能しますが、スクリプトからの値変更される場合の制限にはなりません。
そして、`BindingFlags`を引数で受け取れるようにすることで取得する定数をある程度制御できるようにしていますが、静的な定数以外を想定していない実装になっているためこれで`Instance`などの指定を行うと例外が発生します。
アクセス修飾の指定程度にとどめて使用してください。

# おわりに
いかがだったでしょうか。
BGMやSEの配置場所を指定するときなどにも使えると思うので、ぜひ作ってみてください。