1. 啥是编辑器拓展?
让我们想象自己是一个即将进行关卡搭建的策划。开发同学已经写好了相应的脚本,你只需要配置一下相应物体的参数,就能建立一个好玩的关卡了!然而……
你为主角添加了 Player 这个组件。然后你看到了这些东西:
这些参数都是干啥用的?你用高超的英语技巧推断了一下:
Speed → 速度
Jump Height → 跳跃高度
但是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.GUI
UnityEngine.GUILayout
UnityEditor.EditorGUI
UnityEditor.EditorGUILayout
其中,UnityEngine 命名空间下的两个类在游戏内与编辑器拓展内均可调用,而 UnityEditor 下的两个类只能在编辑器拓展中使用。
每一个类型都有大量的静态方法,负责绘制各种UI控件。
GUI 与 GUILayout 的区别在于:
GUI 绘制UI的方法都需要你主动为其提供一个 Rect 对象,以表示这个UI所在的位置
GUILayout 绘制UI时会自动计算其位置。
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绘制一个按钮,并且在按下按钮的时候打印这句话。这里有两个地方有一丢丢奇怪:
为啥使用的是 GUILayout 而不是 EditorGUILayout ?
为啥 GUILayout.Button(string) 的返回值可以表示【按钮是否被按下】?
第一个很好解释: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.FindProperty(“属性路径(通常是名称)”)
serializedProperty.FindPropertyRelative(“属性路径”)
前者是获取目标 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 | [CustomPropertyDrawer(typeof(TitleAttribute))] |
修改 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 | [CustomPropertyDrawer(typeof(TitleAttribute))]public class TitleDrawer : PropertyDrawer |
这样,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. 写在最后
开发和策划、美术的交流是一个非常大的问题。希望上面这些内容可以稍微缓解一下!