Unity 学习笔记-编辑器扩展

254 阅读9分钟

Unity编辑器扩展 | 编辑器扩展基础入门-腾讯云开发者社区

编辑器拓展

必要操作

  • 在Assets下新建Editor目录

菜单

添加

image_EixZF1NhPm.png

using UnityEditor;

public class Chap1Menu
{
    [MenuItem("Learn/TestMenu")]
    static void TestMenu()
    {
        // 菜单项点击回调
    }
}
验证菜单项是否可点(第2个参数)

image_LTOxq4MRwO.png

[MenuItem("Learn/TestMenu")]
static void TestMenu()
{
    // 菜单项点击回调
}

[MenuItem("Learn/TestMenu", true)]
static bool TestMenuValidate()
{
    return false;
}
排序(第3个参数)

image_N5ctCtiara.png

[MenuItem("Learn/TestMenu", false, 1)]
static void TestMenu()
{
    // 菜单项点击回调
}

[MenuItem("Learn/TestMenu2", false, 0)]
static void TestMenu2()
{
    // 菜单项点击回调
}

添加分隔栏.png

⬆️添加分隔栏

[MenuItem("Learn/TestMenu", false, 11)]
static void TestMenu()
{
    // 菜单项点击回调
}

[MenuItem("Learn/TestMenu2", false, 0)]
static void TestMenu2()
{
    // 菜单项点击回调
}
在Inspector指定组件菜单中添加

image_1-Z2zKqYf_.png

[MenuItem("CONTEXT/Transform/Got/Qux")]
static void ContextMenu(MenuCommand cmd)
{
    var transform = cmd.context as Transform;
    if (transform != null)
    {
        Undo.RecordObject(transform, "Translate");
        transform.Translate(1, 0, 0);
    }
}
在层级视图中添加

image_G2Cj9xsLxb.png

[MenuItem("GameObject/Foo/Bar")]
static void HierarchyMenu() { }
在Project视图中添加

image_1w7YRNED9-.png

[MenuItem("Assets/Cat/Dog")]
static void ProjectMenu() { }
场景视图中添加

image_nPWequYRpj.png

image_1E4wr2wHmY.png

⬇️SceneView.duringSceneGui添加回调

using UnityEditor;


public class Chap2ViewMenu
{
    [InitializeOnLoadMethod]
    static void OnLoad()
    {
        SceneView.duringSceneGui += (sv) =>
        {
            if (Event.current != null
            && Event.current.button == 1
            && Event.current.type == EventType.MouseUp)
            {
                // 定义菜单位置
                Vector2 mousePos = Event.current.mousePosition;
                Rect menuPos = new(mousePos.x, mousePos.y, 0, 0);
                // 定义菜单项列表数据
                GUIContent[] menuOpts = new GUIContent[]{
                    new("Foo"),
                    new("Bar/Qux"),
                };
                // 显示菜单
                EditorUtility.DisplayCustomMenu(menuPos, menuOpts, -1,
                    (data, opt, selected) =>
                    {
                        Debug.LogFormat(@"data: {0}, opt: {1}, 
                        selected: {2}, value: {3}",
                        data, opt, selected, opt[selected]);
                    }, null);
                // 解决右键后光标显示异常的问题
                Event.current.Use();
            }
        };
    }
} 
快捷键

image_CJ7A0c_YEd.png

[MenuItem("Learn/TestMenu %9")]
static void TestMenu()
{
}

[MenuItem("Learn/TestMenu2 %#&i")]
static void TestMenu2()
{
}
windowsmacos
%ctrl
\#shift
&alt
UP/DOWN/LEFT/RIGHT
F1-F12
HOME/END
PGUP/PGDN

拓展Project视图

选中项后添加按钮

image_btXkfetfha.png

⬇️EditorApplication.projectWindowItemOnGUI添加回调

[InitializeOnLoadMethod]
static void OnLoad()
{
    EditorApplication.projectWindowItemOnGUI += (string guid, Rect rect) =>
    {
        Object actObj = Selection.activeObject;
        if (actObj != null)
        {
            // 有选中项的话
            string objPath = AssetDatabase.GetAssetPath(actObj);
            string selGuid = AssetDatabase.AssetPathToGUID(objPath);
            if (selGuid == guid)
            {
                // 自身是选中项
                rect.x = rect.width / 2;
                rect.width = 100;
                if (GUI.Button(rect, "删除"))
                {
                    // 添加按钮
                    // 点击弹出确认框
                    if (EditorUtility.DisplayDialog(
                        "",
                        $"确认删除 {actObj.name} 吗?",
                        "确定",
                        "取消"
                    ))
                    {
                        // 删除资源
                        AssetDatabase.DeleteAsset(objPath);
                    }
                }
            }
        }
    };
} 
资源操作回调(增/改/删/移)

⬇️继承AssetModificationProcessor

using UnityEditor;

public class Chap4ProjectViewEvt : AssetModificationProcessor
{
    public static void OnWillCreateAsset(string path)
    {
        Debug.Log("资源被创建: " + path);
    }

    public static string[] OnWillSaveAssets(string[] paths)
    {
        Debug.Log("资源被保存: " + string.Join(", ", paths));
        return paths;
    }

    public static AssetMoveResult OnWillMoveAsset(string sourcePath, string destinationPath)
    {
        Debug.Log("资源被移动: " + sourcePath + " -> " + destinationPath);
        return AssetMoveResult.DidNotMove;
    }

    public static AssetDeleteResult OnWillDeleteAsset(string path, RemoveAssetOptions options)
    {
        // 每次删除前都会确认一下
        string objName = System.IO.Path.GetFileNameWithoutExtension(path);
        if (EditorUtility.DisplayDialog(
            "",
            $"确认删除 {objName} 吗?",
            "确定",
            "取消"
        ))
        {
            // AssetDatabase.DeleteAsset(path);
            Debug.Log("资源被删除: " + path);
            return AssetDeleteResult.DidNotDelete;
        }

        return AssetDeleteResult.DidDelete;
    }
} 

拓展Hierarchy视图

选中项后添加按钮

image_aEVrUiujHr.png

⬇️EditorApplication.hierarchyWindowItemOnGUI添加回调

using UnityEditor;

public class Chap5ExtendHierarchyView
{
    [InitializeOnLoadMethod]
    static void OnLoad()
    {
        EditorApplication.hierarchyWindowItemOnGUI += (int instanceID, Rect rect) =>
        {
            Object actObj = Selection.activeObject;
            if (actObj != null && instanceID == actObj.GetInstanceID())
            {
                // 自身是选中的对象
                rect.x = rect.xMax / 2 + 70;
                rect.width = 70;
                if (GUI.Button(rect, "删除"))
                {
                    // 支持撤销的删除操作
                    Undo.DestroyObjectImmediate(actObj);
                }
            }
        };
    }
} 
自定义菜单(Alt+鼠标左键)

image_iqaPB1b1MO.png

⬇️EditorUtility.DisplayPopupMenu

using UnityEditor;

public class Chap6MyViewMenu
{
    [InitializeOnLoadMethod]
    static void OnLoad()
    {
        EditorApplication.hierarchyWindowItemOnGUI +=
        (int instanceID, Rect rect) =>
        {
            if (Event.current != null
            && Event.current.modifiers.HasFlag(EventModifiers.Alt)
            // mac 上鼠标右键好像检测不了
            // && Event.current.button == 1
            && Event.current.type == EventType.MouseUp)
            {
                // 消费掉事件,避免事件传递到Hierarchy上
                Event.current.Use();
                // 显示自己的菜单
                var mousePos = Event.current.mousePosition;
                Rect popupRect = new Rect(mousePos.x, mousePos.y, 0, 0);
                // 显示的是之前创建好的 Learn 菜单
                EditorUtility.DisplayPopupMenu(popupRect, "Learn", null);
            }
        };
    }
} 

已经创建好的菜单.png

⬆️已经创建好的菜单

拓展Inspector(指定组件)

image_wLdFdDzEBC.png

using UnityEditor;

[CustomEditor(typeof(Transform))]
public class Chap7ExtendInspectorView : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
        var transform = target as Transform;
        var btnStyle = new GUIStyle(GUI.skin.button)
        {
            fixedWidth = 100,
        };
        if (GUILayout.Button("Foo", btnStyle))
        {
            // 点击按钮移动目标(支持撤销)
            Undo.RecordObject(transform, "Move Transform");
            transform.Translate(1, 0, 0);
        }
    }
}

📌UI Toolkit的实现方法:扩展至脚本的Inspector中

拓展Scene视图

自定义Gizmos

挂了脚本的对象,显示自定义Gizmos.png

⬆️挂了脚本的对象,显示自定义Gizmos

public class Chap8Gizmos : MonoBehaviour
{
// 注意脚本不放在Editor目录中
// 只在编辑器下生效
#if UNITY_EDITOR

    // 选中对象才会绘制
    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawLine(
            transform.position,
            transform.position + Vector3.up * 2
            );
    }

    // 任何情况都绘制
    private void OnDrawGizmos()
    {
        // 在对象上面绘制一个半透明的圆
        Gizmos.color = new Color(0, 0, 1, 0.5f);
        var pos = transform.position + Vector3.up * transform.localScale.y;
        Gizmos.DrawSphere(pos, 0.5f);
        // 绘制一个图标
        Gizmos.DrawIcon(pos, "Assets/UI/Sprite/Star Center.png");
    }

#endif
}
 
自定义UI

选中挂了指定组件的对象.png

⬆️选中挂了指定组件的对象

⬇️实现OnSceneGUI方法

using UnityEditor;

[CustomEditor(typeof(BoxCollider))]
public class Chap9SceneViewGUI : Editor
{
    public void OnSceneGUI()
    {
        // 下面这句注释掉好像也没问题(下同)
        // Handles.BeginGUI();

        GUILayout.BeginArea(new Rect(50, 10, 150, 50));
        if (GUILayout.Button("Test Button"))
        {
            Debug.Log("Button clicked: " + target.name);
        }
        GUILayout.EndArea();

        // Handles.EndGUI();
    }
}

一直显示的UI.png

⬆️一直显示的UI

⬇️给SceneView.duringSceneGui添加回调

[InitializeOnLoadMethod]
static void OnLoad()
{
    SceneView.duringSceneGui += (sceneView) =>
    {
        GUILayout.BeginArea(new Rect(50, 10, 150, 500));
        if (GUILayout.Button("一直显示", GUILayout.Width(80)))
        {
            Debug.Log("Button clicked");
        }
        GUILayout.EndArea();
    };
} 

拓展Game视图

image_ALqK_BJdif.png

⬇️实现OnGUI方法,挂在场景对象上

[ExecuteInEditMode]
public class Chap10ExtendGameView : MonoBehaviour
{
    private void OnGUI()
    {
        GUI.Box(new Rect(10, 10, 100, 90), "关卡列表");
        if (GUI.Button(new Rect(20, 40, 80, 20), "关卡1"))
        {
            Debug.Log("开始关卡1");
        }

        if (GUI.Button(new Rect(20, 70, 80, 20), "关卡2"))
        {
            Debug.Log("开始关卡2");
        }
    }
} 

编辑器窗口(EditorWindow)

添加自定义菜单

image_FrajbhjtSg.png

⬇️实现IHasCustomMenu接口

public class TestWindow : EditorWindow , IHasCustomMenu
{
    public void AddItemsToMenu(GenericMenu menu)
    {
        menu.AddItem(new GUIContent("test1", false, () => {
          Debug.Log("test1 clicked");
        });
        menu.AddDisabledItem(new GUIContent("test2"));
    }
} 
预览对象

image_2pWeIU9Osj.png

⬇️Editor.CreateEditor(target)和targetEditor.OnPreviewGUI

public class TestPreview : EditorWindow
{
    private Object target;
    private Object currTarget;
    private Editor targetEditor;
    
    private void OnGUI()
    {
        // 选择目标对象
        target = EditorGUILayout.ObjectField(target, typeof(Object), false);
        if (target != null && currTarget != target)
        {
            // 给目标对象创建一个编辑器
            targetEditor = Editor.CreateEditor(target);
            currTarget = target;
        }
        if (targetEditor != null && targetEditor.HasPreviewGUI())
        {
            // 目标有预览的话,显示预览
            targetEditor.OnPreviewGUI(
                GUILayoutUtility.GetRect(400, 400),
                EditorStyles.label
                );
        }
    }
} 

常用工具类

2024-02-25 Unity 编辑器开发之编辑器拓展5 —— Selection

2024-02-25 Unity 编辑器开发之编辑器拓展6 —— Event

2024-02-25 Unity 编辑器开发之编辑器拓展7 —— Inspector

2024-06-08 Unity 编辑器开发之编辑器拓展9 —— EditorUtility

2024-06-10 Unity 编辑器开发之编辑器拓展10 —— 其他常见工具类

UI Toolkit

优点

  • 标准UI开发工作流
  • Flexbox系统,轻松实现UI自动适配
  • USS样式表,让UI样式的修改更便捷
  • 高性能
  • 高适用性
  • 适合JRPG项目

缺点

  • 不依赖GameObject,难以制作旋转在3D世界中的可互动UI
  • 不支持Shader,难以制作特效
  • 不支持Animator组件,无法制作实时循环动画(自带了Transition系统)

2023.2新功能

数据绑定

通过UI Builder

image_siO21lDGRv.png

image_KaH2TEgQu_.png

父元素共享数据给子元素
  • 给父节点设置Data Source,所有子元素的默认Data Source会自动使用父节点的
创建属性(用于暴露想绑定的非序列化字段)

image_k8DKRtVh6v.png

public class CharInfoData : ScriptableObject
{
    [SerializeField] string charName;
    [SerializeField] int charLvl;
    // ...
    // 不这样处理的话,UI Builder中将无法找到这个属性
    [CreateProperty] string CharLvlString => $"lvl: {charLvl}";
}
隐藏属性(与上面的作用相反)

image_TW5XwN_jTK.png

public class CharInfoData : ScriptableObject
{
    // 隐藏下面2个属性
    [DontCreateProperty, SerializeField] string charName;
    [DontCreateProperty, SerializeField] int charLvl;
    // ...
}
通过UXML进行绑定

其实就是通过UI Builder来绑定的结果

在脚本中进行绑定
  • 挂了UIDocument组件的对象挂上此脚本
public class CharInfoUI : MonoBehaviour
{
    [SerializeField] CharInfoData data;
    
    void OnEnable()
    {
        VisualElement root = GetComponent<UIDocument>().rootVisualElement;
        Label healthLabel = root.Q<Label>("charHealthText");
        
        // 给目标UI控件设置数据绑定
        healthLabel.SetBinding(nameof(healthLabel.text), new DataBinding
        {
            dataSource = data,
            dataSourcePath = new PropertyPath(nameof(data.HealthString)),
            bindingMode = BindingMode.ToTarget
        })
    }
}

image_tW_yvv4GnX.png

创建自定义控件

  • 使用UxmlElement特性partial关键字,就能将这个控件暴露至UI Builder

image_BIBp8hkUiY.png

[UxmlElement]
public partial class HealthBar : VisualElement
{
    readonly VisualElement background;
    readonly VisualElement foreground;
    
    public HealthBar()
    {
        background = new VisualElement();
        foreground = new VisualElement();
        Add(background);
        background.Add(foreground);
        // 将 foreground 的高设为 100%
        foreground.style.height =
          new StyleLength(new Length(100, LengthUnit.Percent));
    }
}

声明自定义控件属性

  • 使用UxmlAttribute特性将属性暴露到UI Builder中
  • 如果需要为该属性绑定数据,则需要添加CreateProperty

image_FHLzBet-Gw.png

[UxmlElement]
public partial class HealthBar : VisualElement
{
    // ...
    int fillPercent = 50;
    
    [CreateProperty, UxmlAttribute, Range(0, 100)]
    int FillPercent
    {
        get => fillPercent;
        set
        {
            fillPercent = value;
            // 更新宽度百分比
            foreground.style.width =
              new StyleLength(new Length(fillPercent, LengthUnit.Percent));
        }
    }
    
    public HealthBar()
    {
        // ...
    }
}

编辑器扩展

EditorWindow

EditorWindow - Unity 脚本 API 从此类派生以创建编辑器窗口

  • 计划任务
var schedule = root.schedule.Execute(onSchedule);
// schedule.ExecuteLater
// schedule.Every
// schedule.ForDuration

扩展至脚本的Inspector中

Unity - Manual: Create a binding with the Inspector

效果图.png

⬆️效果图

步骤(以扩展TestScript脚本为例):

  • (不在Assets/Editor下)新建组件脚本TestScript,并新建两个公共属性(myName和myScale)

⬇️TextScript.cs(挂对象上的)

[ExecuteInEditMode]
public class TestScript : MonoBehaviour
{
    public string myName = "Foo";
    public float myScale = 1.0f;

    void Update()
    {
        gameObject.name = myName;
        gameObject.transform.localScale = new Vector3(myScale, myScale, myScale);
    }
} 
  • 在Assets/Editor下新建脚本

⬇️MyInspector.cs(拓展编辑器的)

using UnityEditor;

[CustomEditor(typeof(TestScript))]
public class MyInspector : Editor
{
    [SerializeField]
    VisualTreeAsset visualTree;

    public override VisualElement CreateInspectorGUI()
    {
        var ve = visualTree.CloneTree();
        return ve;
    }
} 
  • 在Assets/Editor下新建UI文档并编辑
    • 添加属性字段(Property Field) image_W31qZwNY4_.png
    • 绑定脚本中的属性名 image_zlxHaR0TRp.png
  • 在Project视图中选中MyInspector脚本
  • 将上面编辑好的UI文档(.uxml)拖到Inspector中的Visual Tree 中

image_q3744J9ZRm.png

  • 将 .uss文件拖到Inspector中的Style Sheet 中(如有)

运行时UI

自定义控件中添加模板

  • 将UI模板文件放在Resources目录下,以方便加载。如:

注意:Resources目录并非一定要在Assets下 注意:Resources目录并非一定要在Assets下.png

using UnityEngine.UIElements;

public class CharDataItem : VisualElement
{
    private TemplateContainer tpl;

    public CharDataItem()
    {
        // 模板路径(相对于Resources)
        string tplPath = "Foo/TplPropItem";
        // 加载并实例化
        tpl = Resources
            .Load<VisualTreeAsset>(tplPath)
            .Instantiate();
        // 设置模板样式,让它自动撑满
        tpl.style.flexGrow = 1f;
        // 添加到自身上
        hierarchy.Add(tpl);
    }
}

将自定义控件暴露至UI Builder

可直接在UI Builder中添加自定义控件.png

⬆️可直接在UI Builder中添加自定义控件

image_z9rJDS8bOP.png

方法:在类中添加一句代码即可

public class CharDataItem : VisualElement
{
    // 暴露到UI Builder的Project中
    public new class UxmlFactory : UxmlFactory<CharDataItem> { }
    
    //...
} 

自定义控件绑定数据

image_vDDvl8EY7h.png

方法:

  • 列表Item绑定单个数据并展示
public class CharDataItem : VisualElement
{
    // ...
    private List<VisualElement> propList;

    public CharDataItem() { ... }
    
    // 添加带数据参数的构造函数
    public CharDataItem(CharData data) : this()
    {
        // 绑定数据
        userData = data;
        // 更新固定信息(如形象、名字)
        // ...
        // 更新不固定信息
        propList = tpl.Query("TplPropItem").ToList();
        UpdateCharProps();
    }
    private void UpdateCharProps()
    {
        CharData data = (CharData)userData;
        SetCharProp(0, "等级", data.Lvl);
        SetCharProp(1, "行动力", data.CharStat.initiative);
        // ...
    }

    private void SetCharProp(int propItemIdx, string propName, int propValue)
    {
        VisualElement propItem = propList[propItemIdx];
        propItem.Q<Label>("propNameLbl").text = propName;
        propItem.Q<Label>("propValueLbl").text = propValue.ToString();
    }
}
 
  • 面板展示全部数据
public class PartyPanelCtrl : MonoBehaviour
{
    [SerializeField] PartyData partyData;
    VisualElement rootVe;
    
    private void Awake()
    {
        rootVe = GetComponent<UIDocument>().rootVisualElement;
    }

    private void Start()
    {
        var bodyBox = rootVe.Q("bodyBox");
        bodyBox.Clear();

        foreach (CharData cdata in partyData.CharDataList)
        {
            var item = new CharDataItem(cdata);
            bodyBox.Add(item);
        }
    }
} 
  • 给挂有UI Document组件的对象挂上上面的组件,设置好数据

image_uBi_M5hkFi.png

用户交互处理-操纵器(Manipulator)

    public CharDataItem(CharData data) : this()
    {
        // ...
        UpdateCharProps();
        // 添加鼠标左键点击的回调
        Clickable leftClick = new Clickable(OnMouseClick);
        leftClick.activators.Clear();
        leftClick.activators.Add(new ManipulatorActivationFilter()
        {
            button = MouseButton.LeftMouse,
        });
        tpl.AddManipulator(leftClick);
    }
    
    private void OnMouseClick(EventBase evt)
    {
        var mevt = (MouseUpEvent)evt;
        int key = mevt.button;
        if (key == (int)MouseButton.LeftMouse)
        {
            // 让绑定的角色数据升一级
            ((CharData)userData).LvlUp();
        }
        else
        {
            ((CharData)userData).LvlDown();
        }
        // 更新角色信息
        UpdateCharProps();
    }

用户交互处理-事件系统

    public CharDataItem(CharData data) : this()
    {
        // ...
        UpdateCharProps();
        // 添加鼠标点击的回调
        tpl.RegisterCallback<MouseUpEvent>(OnMouseClick);
    }
    
    private void OnMouseClick(MouseUpEvent evt)
    {
        if (evt.button == (int)MouseButton.LeftMouse)
        {
            ((CharData)userData).LvlUp();
        }
        else
        {
            ((CharData)userData).LvlDown();
        }
        UpdateCharProps();
    }