学习自定义 Unity 编辑器扩展(入门)

865 阅读9分钟

学习自定义 Unity 编辑器扩展(入门)

自定义功能菜单

所有的的编辑器扩展的代码都要放在 Assets/Editor 目录下面

Editor 下面的代码和资源不会打包在安装包里面

添加自己的功能菜单

在 Editor 目录新建一个脚本,名字可以按照自己的喜好。

image-20230510173718446

using UnityEditor; // 编辑器扩展需要引入 UnityEditor 命名空间
using UnityEngine;

public class Tools
{
    // 通过 MenuItem 可以添加自定义菜单
    // Tools/Build 代表在路径 Tools 添加一个 Build 的菜单,如果 Tools 不存在则自动创建
    // 添加 MenuItem 的方法必须是静态方法
    // 方法是公开还是私有的无所谓
    [MenuItem("Tools/Build")]
    public static void Build()
    {
        Debug.Log("Build");
    }
}

关于 MenuItem

对于不同的两个 MenuItem 的编辑器方法,路径不能一样。

我们可以通过使用和系统一样的路径来放到系统菜单里面比如Window/Tools/Build

每一个 MenuItem 默认的优先级为 1000,优先级越小添加的功能越是可以添加在菜单的最上面。

如果两个相邻的 MenuItem 优先级相差大于等于11 则会进行分组

比如 【MenuItem1 优先级1】 【MenuItem2 优先级2 】 【MenuItem3 优先级13】

则 【MenuItem1 MenuItem2】分为一组,【MenuItem3】分为一组

Hierachy 添加右键的功能菜单

可以添加到GameObject分组下面,设置的优先级请再[0-22]范围内

image-20230511094332736

GameObject 子菜单优先级
Create Empty0
Create Empty Child0
Create Empty Parent0
3D Object1
Effects2
Light3
Audio4
Video5
UI6
UI Tools8
Camera10
Visual Scriping Scene Variables11

Project 的右键菜单添加功能菜单

需要添加在 Assets 的目录下面,优先级范围没有限定

image-20230511094759376

给挂在的脚本添加功能菜单

[MenuItem("CONTEXT/PlayerHealth/InitPlayer")]
static void InitPlayer()
{
    Debug.Log("InitPlayer");
}

路径为CONTEXT/脚本名/菜单名

CONTEXT是固定的路径,PlayerHealth代表是想给那个脚本添加菜单。

MenuCommand

这个有什么作用呢?假设我们通过MenuItem给一个脚本添加了一个功能菜单,但是怎么才能获取到那个脚本的实例做一下操作呢,比如自动实例化我们脚本的一些设置呢?此时 MenuCommand 就派上了用场。

我们看一下官方的一个例子

// 给 Rigibody 添加一个菜单项 "Do Something"
using UnityEngine;
using UnityEditor;

public class Something : EditorWindow
{
    // Add menu item
    [MenuItem("CONTEXT/Rigidbody/Do Something")]
    static void DoSomething(MenuCommand command)
    {
        Rigidbody body = (Rigidbody)command.context;
        body.mass = 5;
        Debug.Log("Changed Rigidbody's Mass to " + body.mass + " from Context Menu...");
    }
}

那么我们初始化玩家的血量为200,射击的速度为10的代码可以写成下面。

[MenuItem("CONTEXT/PlayerHealth/InitPlayer")]
static void InitPlayer(MenuCommand command)
{
    CompleteProject.PlayerHealth playerHealth = command.context as CompleteProject.PlayerHealth;
    playerHealth.startingHealth = 200;
    playerHealth.flashSpeed = 10;
}

Selection

我们可以获取到我们目前选中的游戏物体对象

activeGameObject

Selection.activeGameObject

如果选中一个则输出对应游戏物体的名称

如果选中多个则输出最后一个选中游戏物体的名称

activeObject

Selection.activeObject

这个和 Selection.activeGameObject 区别还包含在资源里面的预制体

activeTransform

Selection.activeTransform

获取当前选中的 Transform(获取场景里面的游戏物体推荐用这个方法)

count

Selection.count

获取选中的个数

gameObjects

Selection.gameObjects

获取选中的游戏物体对象数组 包括预制体还有不可修改的对象

objects

Selection.objects

获取场景里面所有选中的游戏物体

编写可以撤销删除操作的功能

[MenuItem("GameObject/CustomDelete", false, 1)]
public static void DeleteSelectionObject()
{
    foreach (var obj in Selection.objects)
    {
        // 循环遍历选中的游戏物体
        // 进行删除操作
        Undo.DestroyObjectImmediate(obj);
    }
}

我们为什么要使用 Undo.DestroyObjectImmediate 这个方法而不直接使用 DestroyObjectImmediate 这个方法进行删除呢?因为通过操作 Undo 的操作可以进行正常的撤销操作。

给菜单设置快捷键

设置单个字母的快捷键

通过 MenuItem("xxx/xxx _t") 的方式进行添加

跟在路径的后面中间添加一个空格,_开头带上快捷键的小字母。

比如给一个菜单按钮添加一个快捷键为 T 的

[MenuItem("Tools/Tool Manager _t")]
public static void ToolManager()
{
    Debug.Log("Tool Manager");
}

当我们按下 T 的时候,可能会提示和目前已经存在的快捷键冲突,没关系,这已经代表我们设置的快捷键已经生效了。

设置组个的快捷键

快捷键字符
Ctrl%
Shift#
Alt&

我们把上面的菜单组合为 Ctrl+Shift+Alt+T

[MenuItem("Tools/Tool Manager %#&t")]
public static void ToolManager()
{
    Debug.Log("Tool Manager");
}

控制菜单项是否启用

假设我们需要要求只有选中了游戏物体才能启用删除,我们的代码可以写成如下。

[MenuItem("GameObject/CustomDelete", true, 1)]
public static bool CanDeleteSelectionObject()
{
    return Selection.count > 0;
}

[MenuItem("GameObject/CustomDelete", false, 1)]
public static void DeleteSelectionObject()
{
    foreach (var obj in Selection.objects)
    {
        // 循环遍历选中的游戏物体
        // 进行删除操作
        Undo.DestroyObjectImmediate(obj);
    }
}

为了能做到可以启动或者关闭对应的菜单功能,我们需要新增一个方法,和之前的方法拥有一样的菜单路径,并且返回值返回一个 Bool,如果返回 true 代表启用,如果 false 代表不启用。

[MenuItem("<Menu Path>", true, <优先级>)]
bool EnableMyMenu()
{
	// 是否启用的判断逻辑
}

[MenuItem("<Menu Path>", false, <优先级>)]
bool MyMenu()
{
	// 菜单按钮的操作逻辑
}

没有选中游戏物体

image-20230511140900891

选中了游戏物体

image-20230511140931834

ContextMenu

ContextMenu VS MenuItem

对比项ContextMenuMenuItem
给系统组件添加菜单不能可以
给可以操作源码添加菜单可以可以
菜单执行操作复杂程度简单复杂
是否支持操作私有变量和方法支持不支持

我们通过 ContextMenu 操作 PlayerHealth 来初始化血量和射击的速度

namespace CompleteProject
{
    public class PlayerHealth : MonoBehaviour
    {
      	......
          
        [ContextMenu("Init Player Health")]
        void InitPlayerHealth()
        {
            startingHealth = 200;
            flashSpeed = 10;
        }
    }
}

ContextMenuItem

可以对于编辑器的属性进行扩展设置

public class PlayerHealth : MonoBehaviour
{
    [ContextMenuItem("Add Health", "AddHealth")]
    public int startingHealth = 100;							// 初始化的血量
                                       
    void AddHealth()
    {
        startingHealth += 100;
    }

 }

ContextMenuItem 函数的第一个参数代表给属性添加菜单的名称,第二个代表需要执行的方法。

我们同样可以按照上面添加快捷键的方式添加快捷键,但是我试过是没有反应的,不知道是不是不支持。

显示对话框

如何弹出一个对话框

[MenuItem("Tools/Show Editor Dialog")]
public static void ShowEditorDialog()
{
    /*
    DisplayWizard 方法有三个参数分别是
    title: 对话框标题
    createButtonName: 创建按钮显示的文本
    otherButtonName: 其他按钮显示的文本,不设置则不会显示出来
    */
    ScriptableWizard.DisplayWizard<EditorDialog>("Show Editor Dialog");
}

我们 EditorDialog 写的所有的参数都会像属性面板一样在对话框一样被解析展示出来。

监听点击 Create/Others 按钮的点击事件

只需要实现下面的对应函数即可

// 当点击了 [CreateButton] 的点击方法则会调用 [OnWizardCreate] 方法
private void OnWizardCreate()
{
    Debug.Log("OnWizardCreate");
}

// 当点击了 [OtherButton] 的点击方法则会调用 [OnWizardOtherButton] 方法
private void OnWizardOtherButton()
{
    Debug.Log("OnWizardOtherButton");
}

点击了 Create 按钮会调用 OnWizardCreate 方法,对话框会自动消失

点击了 Others 按钮会调用 OnWizardOtherButton 方法,对话框不会自动消失

通过输入框统一给敌人新增 100% 的血量

private void OnWizardCreate()
{
    // 遍历当前选中的游戏物体
    foreach (var prefab in Selection.gameObjects)
    {
        // 从游戏物体获取 [CompleteProject.EnemyHealth] 组件
        CompleteProject.EnemyHealth enemyHealth = prefab.GetComponent<CompleteProject.EnemyHealth>();
        // 如果组件为空,则代表不是敌人的预制体,跳过。
        if (enemyHealth == null) continue;
        // 让当前敌人的初始化的血量增加一倍
        enemyHealth.startingHealth *= 2;
    }
}

自定义撤销

对于上面我们修改了敌人的血量 我们怎么才能自定义撤销呢,则需要用上 Undo.RecordObject

......
// 在修改 [enemyHealth] 对象之前进行记录 撤销则将 [enemyHealth] 恢复到记录的状态了
Undo.RecordObject(enemyHealth, "change enemy health");
// 让当前敌人的初始化的血量增加一倍
enemyHealth.startingHealth *= 2;

OnWizardUpdate

/*
当对话框被创建出来的时候会被调用
当对话框里面的属性值被修改的时候会被调用
这个方法可以干什么呢?比如初始化一些值,当修改一个值的时候,会自动修改其他的值
*/
private void OnWizardUpdate()
{
    Debug.Log("OnWizardUpdate");
}

帮助信息/错误信息

对于现实帮助信息和错误信息就需要用到 OnWizardUpdate 方法

private void OnWizardUpdate()
{
    if (Selection.count == 0)
    {
        errorString = "你没有选中任何物体";
    }
    else
    {
        helpString = "你当前选中了 " + Selection.gameObjects.Length + " 个物体";
    }
}

没有选择一个物体的报错

image-20230511160936937

当选择了游戏物体

image-20230511161024147

但是此时有一个问题,当弹出框已经弹出,改动选中的物体之后我们的逻辑不会再次触发,我们可以选择将逻辑放在 Update 方法里面。

private void Update()
{
    if (Selection.count == 0)
    {
        errorString = "你没有选中任何物体";
    }
    else
    {
        helpString = "你当前选中了 " + Selection.gameObjects.Length + " 个物体";
    }
}

image-20230511161355802

我们会发现我们的提示信息和错误信息回同时的出现,我们只需要显示错误信息的时候讲提示信息为空,或者现实提示信息的时候讲错误信息为空。

private void Update()
{
    if (Selection.count == 0)
    {
        errorString = "你没有选中任何物体";
        // 如果当前没有选中游戏物体 则不显示当前选中多少个游戏物体的提示
        helpString = "";
    }
    else
    {
        helpString = "你当前选中了 " + Selection.gameObjects.Length + " 个物体";
        // 如果当前有选中的游戏物体 则不显示错误提示
        errorString = "";
    }
}

显示提示信息

private void OnWizardOtherButton()
{
    // 展示一条文本提示信息
    ShowNotification(new GUIContent("你点击了 Others 按钮"));
  
    // RemoveNotification(); 可以立即移除提示信息 UI
}

image-20230511165727680

使用 EditorPrefs 保存数据

设置值

// 设置一个布尔值
EditorPrefs.SetBool("EditorDialog", true);
// 设置一个浮点数
EditorPrefs.SetFloat("EditorDialogX", Input.mousePosition.x);
// 设置一个整数
EditorPrefs.SetInt("Age", 18);
// 设置一个字符串
EditorPrefs.SetString("Name", "张三");

获取值

// 获取一个布尔值
EditorPrefs.GetBool("EditorDialog");
// 获取一个浮点数
EditorPrefs.GetFloat("EditorDialogX");
// 获取一个整数
EditorPrefs.GetInt("Age");
// 获取一个字符串
EditorPrefs.GetString("Name");

显示进度条

创建代码控制隐藏的进度条

private async void ShowAutoDismissProgressBar()
{
    // 通过 [EditorUtility.DisplayProgressBar] 创建不能点击 [cancel] 按钮进行取消 需要通过 [EditorUtility.ClearProgressBar] 进行取消

    float progress = 0.0f;
    while (progress < 1)
    {
        EditorUtility.DisplayProgressBar("Creating Progress Bar", "Current Progress", progress);
        // 每 1 秒让进度条增加 20%
        await Task.Delay(1_000);
        progress += 0.2f;
    }
    // 当进度条完成 100% 的时候就自动关闭进度条
    EditorUtility.ClearProgressBar();
}

创建用户主动取消的进度条

    private async void UserCanClearProgressBar()
    {
        float progress = 0.0f;
        // 如果当前进度没有到 100% 则继续展示进度
        while (progress < 1)
        {
            // 获取到用户是否点击了 [cancel] 按钮
            bool result = EditorUtility.DisplayCancelableProgressBar("Creating Progress Bar", "Current Progress", progress);
            // 如果点击了 [cancel] 按钮 则可以立马取消进度条
            if (result)
            {
                break;
            }
            // 延时 1 秒
            await Task.Delay(1_000);
            // 当前进度增加 20%
            progress += 0.2f;
        }
        // 关闭进度条
        EditorUtility.ClearProgressBar();
    }

自定义窗口

自定义窗口需要继承 EditorWindow 这个类

展示自定义窗口

// [MyCustomWindow] 不需要我们进行创建,只需要我们通过 [GetWindow] 方法获取即可
MyCustomWindow window = EditorWindow.GetWindow<MyCustomWindow>();
window.Show();

自定义 UI 显示

private void OnGUI()
{
    // 通过 [GUILayout.Label] 显示一个文本
    GUILayout.Label("Hello World!");

    // 通过 [GUILayout.TextField] 显示一个输入框 通过 [inputText] 显示一个文本内容
    // 通过返回值获取输入框输入的内容
    // ⚠️ 为什么要用一个变量接收,因为每一次输入都会重新调用 [OnGUI] 方法,那么我们内容就会被重置,导致输入框没有任何的变化
    inputText = GUILayout.TextField(inputText);

    // 通过 [GUILayout.Button] 显示一个按钮
    // 通过返回值判断按钮是否被点击
    if (GUILayout.Button("OK"))
    {
        Debug.Log("按钮被点击了,输入框的内容是:" + inputText);
    }
}