6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

KLab EngineerAdvent Calendar 2020

Day 3

大量のUnityEditor.MenuItem被害者の会

Last updated at Posted at 2020-12-03

導入

ある朝のこと

Button, Toggle, Slider, Dropdown, Window, …。
徹夜して UI パーツの Prefab を沢山用意したぞ!100個以上はあるかも!これでゲームが作れる!

そうだ!Project タブからドラッグするのも大変だから、Hierarchy で右クリックした時に出るこのメニューに追加して出しやすくしよう!
右クリックした時に出るメニュー.png

これは悪い夢だ.cs
public class MyMenuItems
{
    [MenuItem("GameObject/MyUI/Button/Basic/Normal", false, 0)]
    static void Execute001() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Normal");
    [MenuItem("GameObject/MyUI/Button/Basic/Positive", false, 0)]
    static void Execute002() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Positive");
    [MenuItem("GameObject/MyUI/Button/Basic/Negative", false, 0)]
    static void Execute003() => InstantiatePrefabFromPath("Assets/Prefabs/Button/Basic/Negative");
    /*
    (数が多すぎて省略されました)
    */
    [MenuItem("GameObject/MyUI/Window/Special/RedFrame", false, 0)]
    static void Execute255() => InstantiatePrefabFromPath("Assets/Prefabs/Window/Special/RedFrame");
    [MenuItem("GameObject/MyUI/Window/Special/GreenFrame", false, 0)]
    static void Execute256() => InstantiatePrefabFromPath("Assets/Prefabs/Window/Special/GreenFrame");

    /// <summary>引数のPrefabパスを元にGameObjectを生成</summary>
    static void InstantiatePrefabFromPath(string assetPath)
    {
        var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
        var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
        instance.transform.SetParent(Selection.activeTransform, false);
        Selection.activeObject = instance;
    }
}

はあはあ…いつの間にか夜になっちゃった…
このコード今後のメンテもめんどくさいな…
そうだ!ゲーム作るのやめよう。
ところで記事の続きをお読みください。

Editor 拡張の紹介

指定したディレクトリ配下を対象にした Prefab 一覧を階層構造で表示し、選択した Prefab を配置する Editor 拡張を作成してみました。
以下の gif が実際の動作例です。
画面左側の Project タブの構造が、画面中央に出てくるメニューに反映されています。Prefab を作ったら任意のディレクトリ配下に入れるだけなので楽です。
動作例.gif
プロジェクト内に様々なファイルが多くある場合、Project タブ内で毎回絞り込んでドラッグするのは地味に手間ですから、少し便利になりました。
因みに先ほどの茶番では UI を例に話していましたが特に UI に限らず使えます。

実装ポイント

メニューレイアウトについて

先ほどの見ていただいた Editor 拡張のメニューですが、インスペクタでよく見る Add Component を押した時に出る以下のようなメニューに見た目が近かったと思います。
これのことです.png
実際の Add Component ボタン押下時のメニュー処理は UnityEditor.AddComponent.AddComponentWindow クラスで実装されていますが、こちらは internal になっておりなおかつ拡張性はありません。

UnityEditor.Experimental.GraphView.SearchWindow を利用することで似たような見た目のウィンドウを作成できます。namespace からもわかる通り、本来は ShaderGraph, VFXGraph などで利用されている GraphView の機能の一部で、 2019.4.14f1 時点では Experimental となっております。

利用するまでの手順としては ISearchWindowProvider を実装したクラス作成して SearchWindow.Open に渡すだけとなっており、結構簡単です。

ISearchWindowProviderの実装
public sealed class InstantiatePrefabWindow : ScriptableObject, ISearchWindowProvider
{
    /// <summary>データ構造を作成する</summary>
    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context) => new List<SearchTreeEntry>();

    /// <summary>選択された時の処理</summary>
    public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context) => true;
}
SearchWindow.Openの実行手順
var provider = CreateInstance<InstantiatePrefabWindow>();
SearchWindow.Open(new SearchWindowContext(), provider);

メニューのデータ構造作成について

以下のようなファイルパスのリストをソースとした場合に

var list = new List<string>()
{
    "Button/Basic/Normal.prefab",
    "Button/Basic/Positive.prefab",
    "Button/Basic/Negative.prefab",
    "Button/Special/Special01.prefab",
    "Toggle/Toggle01.prefab",
}

最終的には以下のような構造を求められるので、少しだけ変換する手間が必要です。

var list = new List<SearchTreeEntry>()
{
    new SearchTreeGroupEntry(new GUIContent("Select Prefab")), // title
    new SearchTreeGroupEntry(new GUIContent("Button"       )) {level = 1},
    new SearchTreeGroupEntry(new GUIContent("Basic"        )) {level = 2},
    new SearchTreeEntry     (new GUIContent("Normal"       )) {level = 3},
    new SearchTreeEntry     (new GUIContent("Positive"     )) {level = 3},
    new SearchTreeEntry     (new GUIContent("Negative"     )) {level = 3},
    new SearchTreeGroupEntry(new GUIContent("Special"      )) {level = 2},
    new SearchTreeEntry     (new GUIContent("Special01"    )) {level = 3},
    new SearchTreeGroupEntry(new GUIContent("Toggle"       )) {level = 1},
    new SearchTreeEntry     (new GUIContent("Toggle01"     )) {level = 2},
};

パスのリストについて Split('/') して階層を確認しつつ前後の要素と比較して level も含めて適切に GroupEntry を挿入する必要がありますね。

該当箇所の実装はこのような感じになりました
IEnumerable<SearchTreeEntry> DataToEntries(IEnumerable<Data> dataList)
{
    yield return new SearchTreeGroupEntry(new GUIContent("Select Prefab"));
    var data2 = default(Data); // 1 = current, 2 = prev
    foreach (var data1 in dataList)
    {
        // DirectoryNames は、アセットパスを Path.GetDirectoryName したのち Split('/') した配列です
        var directoryNames1 = data1?.DirectoryNames;
        var directoryNames2 = data2?.DirectoryNames;
        var level = 1;
        var max = Max
        (
            directoryNames1?.Length ?? 0,
            directoryNames2?.Length ?? 0
        );
        for (var i = 0; i < max; i++)
        {
            var name1 = directoryNames1?.ElementAtOrDefault(i);
            var name2 = directoryNames2?.ElementAtOrDefault(i);
            if (string.IsNullOrEmpty(name1))
            {
                break;
            }
            if (string.IsNullOrEmpty(name2))
            {
                yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
            }
            else if (name1 != name2)
            {
                yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                i = max;
            }
            level++;
        }
        yield return new SearchTreeEntry(new GUIContent(data1.FileName, Constants.IconPrefab)) {level = level, userData = data1.AssetPath};
        data2 = data1;
    }
}

完成品

  • Unity 2019.4.14f1 で動作確認しました
  • すでに触れていますが Experimental な機能を利用しています
InstantiatePrefabWindow.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using static UnityEngine.Mathf;

/// <summary>
/// 指定したディレクトリ配下を対象にした Prefab 一覧を表示し、選択した Prefab を配置するメニューウィンドウ
/// </summary>
public sealed class InstantiatePrefabWindow : ScriptableObject, ISearchWindowProvider
{
    /// <summary>
    /// 指定したディレクトリパスとアセットパスから得られる情報群
    /// </summary>
    class Data
    {
        const string Separator = "/";
        public string AssetPath { get; }
        public string FileName { get; }
        public string[] DirectoryNames { get; }

        public Data(string assetPath, string basePath)
        {
            var fileName = Path.GetFileNameWithoutExtension(assetPath);
            var directory = Path.GetDirectoryName(assetPath.Remove(0, basePath.Length + Separator.Length));
            AssetPath = assetPath;
            FileName = fileName;
            DirectoryNames = directory.Split(new[] {Separator}, StringSplitOptions.None);
        }
    }

    class Constants
    {
        public static readonly Texture IconPrefab = EditorGUIUtility.IconContent("GameObject Icon").image;
    }

    string basePath;

    /// <summary>
    /// メニューを開く
    /// </summary>
    /// <param name="basePath">指定したディレクトリ配下を対象にメニューを作成する</param>
    public static void Open(string basePath = "Assets")
    {
        var hierarchyWindow = Resources.FindObjectsOfTypeAll<EditorWindow>().First(window => window.GetType().Name == "SceneHierarchyWindow");
        var provider = CreateInstance<InstantiatePrefabWindow>();
        provider.basePath = basePath;
        var position = new Vector2(hierarchyWindow.position.x + hierarchyWindow.position.width / 2, hierarchyWindow.position.y + 56);
        SearchWindow.Open(new SearchWindowContext(position, hierarchyWindow.position.width), provider);
    }

    /// <summary>
    /// データ構造の作成
    /// </summary>
    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
    {
        var assetPaths = AssetDatabase.FindAssets("t:prefab", new[] {basePath}).Select(AssetDatabase.GUIDToAssetPath);
        var data = assetPaths.Select(assetPath => new Data(assetPath, basePath));
        var entries = DataToEntries(data);
        return entries.ToList();
    }

    /// <summary>
    /// 選択時の処理
    /// </summary>
    public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
    {
        DestroyImmediate(this);
        var assetPath = entry.userData as string;
        var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
        var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
        instance.transform.SetParent(Selection.activeTransform, false);
        Selection.activeObject = instance;
        return true;
    }

    /// <summary>
    /// パスリストからメニューのデータ構造を作成
    /// </summary>
    IEnumerable<SearchTreeEntry> DataToEntries(IEnumerable<Data> dataList)
    {
        yield return new SearchTreeGroupEntry(new GUIContent("Select Prefab"));
        var data2 = default(Data); // 1 = current, 2 = prev
        foreach (var data1 in dataList)
        {
            var directoryNames1 = data1?.DirectoryNames;
            var directoryNames2 = data2?.DirectoryNames;
            var level = 1;
            var max = Max
            (
                directoryNames1?.Length ?? 0,
                directoryNames2?.Length ?? 0
            );
            for (var i = 0; i < max; i++)
            {
                var name1 = directoryNames1?.ElementAtOrDefault(i);
                var name2 = directoryNames2?.ElementAtOrDefault(i);
                if (string.IsNullOrEmpty(name1))
                {
                    break;
                }
                if (string.IsNullOrEmpty(name2))
                {
                    yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                }
                else if (name1 != name2)
                {
                    yield return new SearchTreeGroupEntry(new GUIContent(name1)) {level = level};
                    i = max;
                }
                level++;
            }
            yield return new SearchTreeEntry(new GUIContent(data1.FileName, Constants.IconPrefab)) {level = level, userData = data1.AssetPath};
            data2 = data1;
        }
    }
}

使い方としては以下のような形で登録します。

public class MyMenuItems
{
    [MenuItem("GameObject/Create UI", false, 0)]
    static void Execute() => InstantiatePrefabWindow.Open("Assets/Prefabs");
}

終わりに

この記事は KLab 2020 Advent Calendar の 12/3 の記事でした。

6
4
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?