Tsuの雑記¯\_(ツ)_/¯

主に製作メモ・備忘録として使用。製作したアプリのリンクもあります。

【Unity】複数選択式プルダウンメニューを階層化する【エディター拡張】

Unity Editor の拡張機能には,ドロップダウンメニューの作成方法が複数用意されています。

その中には,メニューの内容を階層化できる物があります。

本稿では,その使い方について御説明いたします。

(Unity 2018.3.0f2)

EditorGUILayout.MaskField

今回は,こちらの関数で確認します。

docs.unity3d.com

文字列配列string[]から項目を選択し,フラグを10進数intで返します。

(もちろん,ToString()関数によって文字列stringを取得する事も出来ます。)

階層化自体は簡単だが……

階層化は実に単純で,リストの要素に半角スラッシュ/を加えるだけです。

ただしリストの内容によっては不具合が生じるため,対策が必要となります。

まずは,特に対策を取らなかった場合のスクリプトを御覧ください。

using UnityEngine;

// ここからエディター上でのみ有効
#if UNITY_EDITOR
using UnityEditor;

// エディター拡張クラス
[CustomEditor(typeof(Extend))]
public class ExtendedEditor : Editor
{// Editor クラスを継承
    // Extend クラスの変数を扱うために宣言
    Extend extend;

    void OnEnable()
    {// 最初に実行
        // Extend クラスに target を代入
        extend = (Extend)target;
    }

    public override void OnInspectorGUI()
    {// Inspector に表示
        // これ以降の要素に関してエディタによる変更を記録
        EditorGUI.BeginChangeCheck();
        // ラベルの作成
        var label = "List";
        // 初期値として表示する項目のインデックス番号
        var selectedIndex = extend.index;
        // プルダウンメニューに登録する文字列配列
        var displayOptions = Extend.list;
        // プルダウンメニューの作成
        var index = EditorGUILayout.MaskField(label, selectedIndex, displayOptions);

        if (EditorGUI.EndChangeCheck())
        {// 操作を Undo に登録
            // Extend クラスの変更を記録
            var objectToUndo = extend;
            // Undo メニューに表示する項目名
            var name = "Extend";
            // 記録準備
            Undo.RecordObject(objectToUndo, name);
            // Undo に記録したい変数を登録
            extend.index = index;
        }
    }
}
// ここまでエディター上でのみ有効
#endif

public class Extend : MonoBehaviour
{// エディター拡張の中身
    // リスト
    public static readonly string[] list = { "a/a", "a/ /a", "a/", "a", "b", "b/a" };
    // 初期値は“a/ /a”
    public int index = 2;
}

上記のスクリプトによって,次のようなドロップダウンメニューが生成されます。

UnityEditor の Inspector ウィンドウでプルダウンメニューを開いている画像
list 配列の要素と異なる結果に

問題点

  • 選択してもチェックが付かずエラーになる物がある(a/ /a)
  • 半角スラッシュ/以降に文字がなかったり半角スペースのみだったりした場合に区切り線として扱われてしまう(a/)
  • 階層化した要素の上層と同じ文字列の要素がリスト後方にあっても後者は表示されない(a)

改善策

これらの問題を解決した物が,以下のコードになります。

using UnityEngine;
// 正規表現の使用
using System.Text.RegularExpressions;
// ジェネリックリストの使用
using System.Collections.Generic;
// LINQ の使用
using System.Linq;

// ここからエディター上でのみ有効
#if UNITY_EDITOR
using UnityEditor;

// エディター拡張クラス
[CustomEditor(typeof(Extend))]
public class ExtendedEditor : Editor
{// Editor クラスを継承
    // Extend クラスの変数を扱うために宣言
    Extend extend;

    void OnEnable()
    {// 最初に実行
        // Extend クラスに target を代入
        extend = (Extend)target;
    }

    /// <summary>
    /// 区切り線と重複要素の対策
    /// </summary>
    /// <param name="list">改変対象の配列</param>
    void CustomizeList(string[] list)
    {
        // 複製開始位置
        const int StartIndex_0 = 0;
        // 配列の複製
        extend.list.CopyTo(list, StartIndex_0);
        for (var i = 0; i < list.Length; i++)
        {// 表記変更
            if (list[i] == null)
            {// null
                list[i] = "\"Null\"";
            }
            if (list[i] == string.Empty)
            {// 空文字
                list[i] = "\"\"";
            }
            if (Regex.IsMatch(list[i], "^ +$"))
            {// 半角スペースのみ
                list[i] = list[i].Replace(list[i], $"\"{list[i]}\"");
            }
            if (Regex.IsMatch(list[i], "^/"))
            {// 先頭に半角スラッシュ
                list[i] = Regex.Replace(list[i], "^", "\"\"");
            }
            if (Regex.IsMatch(list[i], "^ +/"))
            {// 半角スペースと半角スラッシュ
                list[i] = Regex.Replace(list[i], "^ +", "\"$0\"");
            }
            if (Regex.IsMatch(list[i], "//"))
            {// 連続半角スラッシュ
                list[i] = Regex.Replace(list[i], "//", "/\"\"/");
            }
            if (Regex.IsMatch(list[i], "/ +/"))
            {// 半角スペースの間に半角スペースのみ
                list[i] = Regex.Replace(list[i], "/( +)/", "/\"$1\"/");
            }
            if (Regex.IsMatch(list[i], "/$"))
            {// 末尾に半角スラッシュ
                list[i] = Regex.Replace(list[i], "$", "\"\"");
            }
            if (Regex.IsMatch(list[i], "/ +$"))
            {// 半角スラッシュと半角スペース
                list[i] = Regex.Replace(list[i], " +$", "\"$0\"");
            }
        }
        // 文字列パターンリスト
        var pattern = new List<string>();
        for (var i = 0; i < list.Length; i++)
        {// 全要素チェック
            // 連番初期化
            var count = 0;
            for (var j = i + 1; j < list.Length; j++)
            {// 重複チェック
                if (list[i] == list[j])
                {// 完全一致
                    // 連番指定
                    count = pattern.Count((string s) => Regex.IsMatch(s, $"{list[i]}/*$")) + 1;
                    // パターン記録
                    pattern.Add(list[i]);
                    // 連番付与
                    list[j] = list[j].Replace(list[j], $"{list[j]}({count})");
                }
                else if (Regex.IsMatch(list[i], $"^{list[j]}/"))
                {// 半角スラッシュ前の部分一致
                    count = pattern.Count((string s) => s == list[j]) + 1;
                    pattern.Add(list[j]);
                    list[j] = list[j].Replace(list[j], $"{list[j]}({count})");
                }
                else if (Regex.IsMatch(list[j], $"^{list[i]}/"))
                {// 半角スラッシュがあれば部分一致
                    count = pattern.Count((string s) => s == list[j]) + 1;
                    pattern.Add(list[i] + "/");
                    list[j] = list[j].Replace(list[i], $"{list[i]}({count})");
                }
            }
        }
    }

    public override void OnInspectorGUI()
    {// Inspector に表示
        // これ以降の要素に関してエディタによる変更を記録
        EditorGUI.BeginChangeCheck();

        // SerializedObject の内容を更新
        serializedObject.Update();
        // Inspector のコンポーネントに表示する項目名
        var text = "List";
        // ツールチップのテキスト
        var tooltip = "Popup list \"Item\" will be made out of this.";
        // ツールチップ入りラベルの作成
        var label = new GUIContent(text, tooltip);
        // 配列の中身も表示する
        var includeChildren = true;
        // リストを取得
        var property = serializedObject.FindProperty(nameof(extend.list));
        // 配列フィールドの作成
        EditorGUILayout.PropertyField(property, label, includeChildren);

        // SerializedObject の変更を適用
        serializedObject.ApplyModifiedProperties();

        // 複製先の配列
        var list = new string[extend.list.Length];
        // 配列の複製と変更
        CustomizeList(list);
        // Inspector のコンポーネントに表示する項目名
        text = "Item";
        // ツールチップのテキスト
        tooltip = "Select some items.";
        // ツールチップ入りラベルの作成
        label = new GUIContent(text, tooltip);
        // 初期値として表示する項目のインデックス番号
        var selectedIndex = extend.index;
        // プルダウンメニューに登録する文字列配列
        var displayOptions = list;
        // プルダウンメニューの作成
        var index = extend.list.Length > 0 ? EditorGUILayout.MaskField(label, selectedIndex, displayOptions)
            : 0;

        for (var i = 0; i < displayOptions.Length; i++)
        {// ビット演算で選択状態を判定
            if ((index & 1 << i) != 0)
            {// 選択状態の要素をログ出力
                Debug.Log($"{displayOptions[i]} is on!");
            }
        }

        if (EditorGUI.EndChangeCheck())
        {// 操作を Undo に登録
            // Extend クラスの変更を記録
            var objectToUndo = extend;
            // Undo メニューに表示する項目名
            var name = "Extend";
            // 記録準備
            Undo.RecordObject(objectToUndo, name);
            // Undo に記録したい変数を登録
            extend.index = index;
        }
    }
}
// ここまでエディター上でのみ有効
#endif

// 同一オブジェクトへの複数追加を禁止
[DisallowMultipleComponent]
public class Extend : MonoBehaviour
{// エディター拡張の中身
    // リスト
    public string[] list = { };
    // 初期値は“Nothing”
    public int index = 0;
}

上記のスクリプトによって生成されたドロップダウンメニューは,次のようになります。

UnityEditor の Inspector ウィンドウでリストが階層化されている画像
メニューが階層化されている

重複要素の消失や,区切り線の問題が解消されています。

ラベルのツールチップや動的リストは,個人的な好みとして追加しております。

SerializedObject の代入に関しては,nameof演算子を使用しています。

C# のバージョンが 5 以下であれば,次のように記述してください。

// リストを取得
var property = serializedObject.FindProperty("list");

docs.microsoft.com

連番を付与する際に,文字列補間機能を使用しています。

C# のバージョンが 5 以下であれば,次のように記述してください。

// 連番付与
list[j] = list[j].Replace(list[j], list[j] + "(" + count + ")");

docs.microsoft.com

また今回は文字列のパターン一致を判定して置換するため,Regex.Ismatch()Regex.Replace()を使用しております。

docs.microsoft.com

docs.microsoft.com

以上,複数選択式プルダウンメニューを階層化する方法でした。

択一選択式プルダウンメニューの場合は,こちらの記事をお読みください。

tsu-games.hatenablog.com