Unity编辑器拓展:Editor与PropertyDrawer

冰岩作坊 October 23, 2023

1. 啥是编辑器拓展?

让我们想象自己是一个即将进行关卡搭建的策划。开发同学已经写好了相应的脚本,你只需要配置一下相应物体的参数,就能建立一个好玩的关卡了!然而……

你为主角添加了 Player 这个组件。然后你看到了这些东西:  

这些参数都是干啥用的?你用高超的英语技巧推断了一下:

但是Jump Buffer是个啥?跳跃缓冲器?

Coyote Time 呢?丛林狼时间

哦豁,这下如果开发不在的话,这些参数就要一个个自己查、自己调试了,大大降低了游戏的开发效率。

那假如我掏出这个,阁下又该如何面对呢? 

相比于之前的版本,现在的面板都使用中文作为标注,且按照一定的类别进行分类放置,看起来舒服多了;  就算仍有些参数不能理解,也能大概猜出意思,进行调试也更加方便了! 

作为游戏开发,我们不仅仅需要关注逻辑代码是否通顺、是否高效,也应考虑到代码应当让不会写代码策划、美术同学们可以更容易地看得懂我们提供的工具,从而更好地交流协作。

显然,Unity不会良心到把中文都为我们准备好;要实现上述的功能,我们需要对Unity进行一些小小的改动——编辑器拓展。

2. 你在编辑啥?

你打开了Unity,编写了 MyComponent.cs 文件,把他挂载到了一个空物体上。

1
using UnityEngine;public class MyComponent : MonoBehaviour

你的组件看起来是这个样子的:

然后你可以给Speed这个属性一个好看的值,比如5:  

这个过程非常自然,你可以很方便地编辑这些值,然后在游戏里看到效果。  但实际上,Unity在这中间替你做了非常多的事情。有哪些呢?

在编辑器内,当你修改Inspector内的参数时,实际上修改的是序列化对象 SerializedObject 存储的值。同时,我们可以通过拓展 Editor 和 PropertyDrawer 来实现自定义 Inspector 显示

3. 自定义 Editor

如果你只想要自定义PropertyDrawer的话,也不要跳过这一章,这底下会讲一些基础概念,且在PropertyDrawer那里不会重复的。 

当你拓展Editor时,你可以完全重写某一类型的物体在Inspector内的表现,而不影响其他类型的物体的表现。

3.1 先看看效果!

注意:所有对编辑器的拓展脚本(using UnityEditor)都需要放在任意位置下的Editor文件夹里! 

让我们创建一个Editor文件夹,然后在里面创建 MyComponentEditor.cs。  接下来复制粘贴以下内容,看看会发生什么!

1
using UnityEngine;using UnityEditor;[CustomEditor(typeof(MyComponent))]public class MyComponentEditor : Editor");        }        // 画个提示框        EditorGUILayout.HelpBox("这是帮助框,里面可以写帮助信息",MessageType.Info);        // 别忘了把更改保存到serializedObject哦!        serializedObject.ApplyModifiedProperties();    }}

你问我 MyComponent 是哪来的?看看第一节!

他的效果是这样的:  

接下来,我们来分析一下上面那一段代码是如何工作的:

3.2 IMGUI 与 EditorGUILayout

IMGUI 是一个非常流行的GUI库。Unity将IMGUI的相关操作封装进了C#,供开发者更方便地绘制GUI。 

Unity已经提供了更现代化的GUI套件 UI ToolKit 以供UI绘制。这一套系统更加复杂,但是它实现了可视化的编辑器,并且支持更灵活地定制UI的样式、数据绑定等。 

考虑到项目实际应用上,IMGUI 已经能满足绝大多数需求,本文将继续使用 IMGUI。你也可以使用 UI ToolKit 实现类似的功能。

Unity总共提供了四个 IMGUI 的封装类:

 

其中,UnityEngine 命名空间下的两个类在游戏内与编辑器拓展内均可调用,而 UnityEditor 下的两个类只能在编辑器拓展中使用。 

每一个类型都有大量的静态方法,负责绘制各种UI控件。

GUI 与 GUILayout 的区别在于:

 

EditorGUI 与 EditorGUILayout 同理。  继承 Editor 编写自定义绘制内容的时候,通常采用 EditorGUILayout。

3.3 代码解读

3.3.1 基础控件

EditorGUILayout.LabelField(“嗨害嗨!这是自定义的内容!”);这一行代码将会绘制一行文字,对应Inspector中的

实际上,Unity提供了类型 GUIContent 对“要显示的文字”进行封装: 

1
EditorGUILayout.LabelField(    new GUIContent("嗨害嗨!这是自定义的内容!", "这是这句话的ToolTip! "));

这样,当鼠标移动到这句话上时,就会弹出 ToolTip 了:  

GUIContent 也有一个包含 Texture 的构造函数。如果传入了一张图片,那么这张图片就会作为图标显示在文字的左侧。

类似地,EditorGUILayout.HelpBox(string, MessageType) 会绘制一个帮助框,如上方截图所示。

3.3.2 按钮

1
if (GUILayout.Button("按我!"))");}

这几行代码要求Unity绘制一个按钮,并且在按下按钮的时候打印这句话。这里有两个地方有一丢丢奇怪:

第一个很好解释:EditorGUILayout 里根本没有封装 Button 方法!  为啥?因为按钮也经常在游戏内使用,所以添加到 GUILayout 供玩家在游戏内添加显然是更好的选择

第二个需要解释一下:  与直觉相悖的是, OnInspectorGUI() (什么,你问我这是啥?再仔细看看前面的内容!)的调用其实非常频繁。你的鼠标划过Inspector对应位置,Unity都需要检查一下:

当你的鼠标按下时,Unity会检查鼠标的位置是否在按钮内(以判定鼠标是否按了按钮),并向 GUILayout.Button(string) 返回一个布尔值供用户判断。

3.3.3 属性框

1
var spd = serializedObject.FindProperty("speed");EditorGUILayout.PropertyField(spd, new GUIContent("速度"));

在这两行代码中,我们找到了当前编辑的 serializedObject 的指定属性,以 SerializedProperty 类型实例的形式返回。 

都看到这里了,不妨复习一下前面的流程图?

序列化属性 SerializedProperty

SerializedProperty 依附于 SerializedObject 存在。

获取它的实例有两种方式:

 

前者是获取目标 SerializedObject 的指定属性,后者是获取目标 SerializedProperty 的子属性。 

子属性是在 SerializedObject 嵌套的时候使用的概念。例如,当一个物体(称为【obj】)成为另一个物体(称为【anotherObj】)的属性时,我们可以通过 anotherObj.FindProperty(“obj”) 找到这个物体(保存到【obj】变量),再通过 obj.FindPropertyRelative(“属性名称”) 获取到这个物体自身的属性

序列化属性是对所有类型的属性的抽象,它并不关心自己保存的值的类型。你可以通过这样一系列的C#接口(其实,在C#中,它们也称为【属性】,为了避免混淆才这么称呼的)来获取或写入它的实际值:    

哦当然,你自己心里肯定是清楚这个属性“实际上是什么类型的”,才调用对应的 xxxValue 的

PropertyField 与指定属性控件

EditorGUILayout.PropertyField这个方法将会让Unity自己去找合适的 PropertyDrawer 并绘制指定的 SerializedProperty;同时,在这个值被用户修改的时候自动保存到 SerializedProperty 去。

这个方法相对特殊,因为 EditorGUILayout 提供的其他控件使用起来会略微更复杂一丢丢: 

1
// 找到 serializedObject 对应的 serializedProperty,即 speed 属性var spd = serializedObject.FindProperty("speed");// 绘制属性编辑框EditorGUILayout.PropertyField(spd, new GUIContent("速度"));// 用指定的控件绘制,效果一模一样spd.floatValue = EditorGUILayout.FloatField(new GUIContent("速度"), spd.floatValue);

可以看到,我们使用了 EditorGUILayout.FloatField 绘制了一个一模一样的属性框。只不过,EditorGUILayout.FloatField 需要在绘制时给定一个数值,并且最终得到的结果不能自动更新到 spd 这个属性中去。 

换句话说,EditorGUILayout.PropertyField 这个方法使用起来更方便,可以自动寻找合适的控件绘制,并自动更新数值;其他控件的泛用性更强,它们可以从其他地方读取数值,或者实现更灵活的功能。

4. 自定义 PropertyDrawer

自定义 Editor 会改变某一类物体在Inspector面板中显示的样式,而自定义 PropertyDrawer 则会改变某一种属性显示的样式。

4.1 还是先看看效果!

为了避免干扰,如果你先完成了自定义 Editor里的内容,那么可以先把他删掉!  在Editor文件夹下创建 FloatDrawer.cs,复制粘贴以下代码:

1
using UnityEngine;using UnityEditor;[CustomPropertyDrawer(typeof(float))]public class FloatDrawer : PropertyDrawer}

编译后,看看 MyComponent 的面板怎么样了?

干得好!

所有的 float 类型的属性框都变成这样了

4.2 指定谁需要被自定义!

4.2.1 指定一个类型

上述代码的第4行指定了应当被下方代码绘制的数据类型:[CustomPropertyDrawer(typeof(float))]  这告诉了 Unity:所有的 float 类型的属性都要按照我的规则绘制!

很好,相信聪明的你已经发现哪里不对劲了:又不是只有 MyComponent 才有 float 类型的属性啊?

哦豁,其他的组件遭殃了!

这并不是预期的功能:我们希望只为 MyComponent 的属性框改变标签。  咋办呢?

4.2.2 指定一个 Attribute

Attribute 本身是C#的语法特性,可以看看官方文档!

[CustomPropertyDrawer(Type)] 不仅可以接受一个特殊的类型,也可以接受一个 Attribute 

如果指定了后者,那么Unity会筛选出所有 具有这个Attribute的任意类型的属性 并按照规则绘制。

让我们编写这样一个Attribute:TitleAttribute.cs (它并不是编辑器拓展的一部分,不要放到Editor文件夹里)

1
using System;using UnityEngine;[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]public class TitleAttribute : PropertyAttribute}

可以观察到,这个 Attribute 本质上只接受一个 string 类型的变量,并保存在其中以供调用。

有了它,我们就可以改写先前的效果了:删除 FloatDrawer.cs 并在同位置创建 TitleDrawer.cs,编写(复制粘贴)如下内容:

1
2
3
4
5
[CustomPropertyDrawer(typeof(TitleAttribute))]

public class TitleDrawer : PropertyDrawer

}

修改 MyComponent.cs 成这样:

1
public class MyComponent : MonoBehaviour

于是…

好耶!

让我们再看看 TitleDrawer 的代码:  在 TitleDrawer.cs 的 OnGUI(Rect, SerializedProperty, GUIContent) 回调中,相比于之前多了一行代码,是用来获取 TitleAttribute 里保存的字符串的:  label.text = ((TitleAttribute)attribute).Label;

其中,attribute 是 PropertyDrawer 基类中提供的成员,它代表了当前正在被自定义绘制的Attribute  将它转型成 TitleAttribute 就能读取我们想要的数据了!

4.3 Bigger, Better, Stronger!

截至目前,我们自定义的PropertyDrawer只能绘制一行内容。有没有办法让他绘制多行呢?很简单:重写 GetPropertyHeight(SerializedProperty, GUIContent)

1
2
3
[CustomPropertyDrawer(typeof(TitleAttribute))]public class TitleDrawer : PropertyDrawer

// 看这里! public override float GetPropertyHeight(SerializedProperty property, GUIContent label) }

这样,Unity会为这个属性留出两行的空间 ,供你绘制其他控件。  要想在多出来的这两行绘制控件也很简单:创建一个新的 Rect 对象,使之的位置比 OnGUI 给出的 position 向下偏移一个 EditorGUIUtility.singleLineHeight 即可:

1
[CustomPropertyDrawer(typeof(TitleAttribute))]public class TitleDrawer : PropertyDrawer    // 看这里!    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)    }

5. 应用:SceneName

在游戏搭建的过程中,策划往往需要配置【场景如何跳转】。然而,万恶的Unity只能使用场景的名称,或者在 Build Settings 里的下标指定要加载的场景!

那么,如果开发写一个【场景切换器】,他只能暴露一个 string 类型的变量,让策划找到场景的名字并填写进去。这样容易出错!  万一策划打错了一个字没有发现,那么就直接产生了一个bug!  就算策划没有输入错误,那场景名称比较长时,搭建的效率也降低了。怎么办呢?

思路:拓展PropertyDrawer,使之将【需要输入场景名称的 string 属性输入框】绘制成一个 【选择器】 ,让策划选择场景而非输入名称。

1
[CustomPropertyDrawer(typeof(SceneNameAttribute))]public class SceneNamePropertyDrawer : PropertyDrawer).ToList();        // 将列表中场景名称转换成 GUIContent        var display = l.Select(i => new GUIContent(i)).ToList();                // 提示策划,如果找不到想要的场景,可能是没有在 Build Settings 里添加        display.Add(new GUIContent("找不到? 去BuildSettings添加对应场景"));        // 绘制选择框 PopUp,并将前面得到的列表作为选项列表输入        int sel = EditorGUI.Popup(position, label,                            l.IndexOf(property.stringValue) == -1 ? 0 : l.IndexOf(property.stringValue),                            display.ToArray());                // 不让策划选择最后一个,即上面那一句提示        property.stringValue = sel < l.Count ? l[sel] : l[0];    }}

他的效果:  

好极了!

6. 写在最后

开发和策划、美术的交流是一个非常大的问题。希望上面这些内容可以稍微缓解一下!