在之前的篇章里面,我们一直在编辑器里面干活,然后做好资源的编辑和代码开发后,我们可以直接在编辑器内点击那个播放按钮就能真实的把游戏跑起来,但是有时候,我们可能希望在菜单里面加个按钮,这样我们可以直接执行一些批量的编辑动作,又或者我们希望像数组元素显示在Inspector面板上的效果一样,为我们的自定义数据结构也画一个特殊的编辑界面,那么这个时候我们就需要扩展Unity编辑器。
给编辑器加个菜单
我们知道Unity编辑器窗口顶部有一系列菜单,我们可以通过编写C#代码来增加我们自定义的菜单,现在我们新建一个脚本资源,叫MenuTest吧,代码如下:
using UnityEditor;
using UnityEngine;
public static class MenuTest
{
[MenuItem("MenuTest/Say Hello World")]
public static void SayHelloWorld()
{
Debug.Log("Hello World!");
}
}
可以看到我们新建了一个C#类,但是并不继承自MonoBehaviour,而是直接是纯粹的C#类,写不写成static没关系,但是我们用来作为菜单的方法得是一个static方法,所以我们写了一个SayHelloWorld方法,执行后会输出一行日志到Console窗口中,为了让这个功能在菜单里面出现,我们加了一个属性MenuItem,这个属性来自UnityEditor模块,属性的内容就是我们菜单所在的位置,以/分割,第一部分就是直接显示在界面上的菜单名,后面每个/都是这个菜单内部的选项层级,我们看一下效果:
如果我们点击一下这个菜单,就会执行我们编写的那个static函数,输出一个Hello World!到我们的Console窗口中。
如果我们希望有更深层级的菜单,我们可以写成类似
[MenuItem("MenuTest/Say Hello World/Say1/Say2")]
那么我们就会看到这样的菜单:
OK,我们现在知道了菜单如何制作,这样我们就可以做一些我们想要的批量操作,比如说我们之前给游戏打包的时候,总是要一个个场景打开,然后添加到我们的Build Settings里面,很难受,那我们是不是可以直接通过一个菜单去做这个事情呢?当然可以!我们稍微修改一下我们的代码:
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
public static class MenuTest
{
[MenuItem("MenuTest/Add All Scenes to Build Settings")]
public static void AddAllScenesToBuildSettings()
{
//获取所有场景
string[] scenes = AssetDatabase.FindAssets("t:Scene");
if (scenes == null || scenes.Length == 0)
{
Debug.Log("No scenes found.");
return;
}
List<EditorBuildSettingsScene> editorBuildSettingsScenes = new List<EditorBuildSettingsScene>();
//将所有场景添加到BuildSettings中
foreach (string sceneGuid in scenes)
{
string scenePath = AssetDatabase.GUIDToAssetPath(sceneGuid);
EditorBuildSettingsScene buildScene = new EditorBuildSettingsScene(scenePath, true);
editorBuildSettingsScenes.Add(buildScene);
}
EditorBuildSettings.scenes = editorBuildSettingsScenes.ToArray();
Debug.Log("All scenes have been added to Build Settings.");
}
}
这里我们调用了AssetDatabase.FindAssets这个API,这是专门用来在编辑器内查找资源的方法,类似Resources.Load,但是范围是工程内的全体资源,参数t:Scene表示类型是场景资源,关于这里面的搜索用的魔法字符串,Unity官方的API文档其实说的很模糊,但是基本上可以理解要怎么写。
找到所有工程里面的场景类型的资源后,得到的是一堆guid字符串,因为我们要设置打包所包含场景的API要求我们提供的是场景资源的路径,所以我们需要通过guid字符串转换资源路径,也就是通过AssetDatabase.GUIDToAssetPath这个API,然后通过这个路径新建一个EditorBuildSettingsScene结构体。
为每个场景资源都新建好这个结构体后传递给EditorBuildSettings.scenes这个数组赋值。
OK,我们点一下这个菜单执行后再打开Build Settings看看发生了什么:
好家伙,不仅是我们Assets文件夹下的进来了,连Packages里面其他功能包带的场景资源也一起放进来了,当然我们不需要这些资源,可以手动删一下。
由此我们可以了解到,Unity除了提供给我们游戏运行时所需的API之外,还提供了一系列API帮助我们调用编辑器的功能,这样能够更好的完成我们的工作,而这些编辑器相关的API都在UnityEditor这个模块下。
UnityEngin和UnityEditor
既然讲到UnityEditor,我们需要明确一下UnityEditor的概念和使用范围,如果我们现在直接打包游戏,会得到一个编译报错:
Assets\MenuTest.cs(7,6): error CS0246: The type or namespace name 'MenuItemAttribute' could not be found (are you missing a using directive or an assembly reference?)
Assets\MenuTest.cs(7,6): error CS0246: The type or namespace name 'MenuItem' could not be found (are you missing a using directive or an assembly reference?)
初看起来很奇怪,报错提示MenuItemAttribute和MenuItem的定义在我们刚写的MenuTest代码里面没找到,但是我们刚刚明显跑起来我们的代码了呀!
但是仔细想想也合理,因为我们调用的是UnityEditor模块提供的功能,而不是UnityEngine,这意味着我们的功能只在编辑器下可用,但是我们游戏打包肯定不会带上编辑器的功能,其结果就显然会报错找不到相关类型定义。
那么为了解决这个问题,Unity提供了两个方案:
- 将所有涉及编辑器功能的代码,放在Editor文件夹下,这个跟Resources文件夹类似的规则,只要父目录叫Editor就行了,不管是几级父目录,也就是说任何叫Editor的文件夹下面所有的代码都不会在打包的时候打包进去。
- 使用C#里面预处理器宏,跟C++很类似,但是Unity会预先定义一些,UNITY_EDITOR就是其中之一,例如:
#if UNITY_EDITOR
// 这里面的代码打包不会带上
#endif
这种写法非常适合在游戏需要执行的代码例如某个组件里面,调用一些编辑器功能,以方便提供在编辑器下跑起来的debug能力,但是又不会带到游戏打包里面去,当然这样写的时候要注意,using UnityEditor;这个部分也得用#if UNITY_EDITOR包裹起来,这个很容易忘记。
扩展编辑器界面
Unity也支持开发者自己绘制编辑器界面,例如数组序列化出来的编辑界面就是以前一个三方组件的功能,后来纳入到了官方引擎内作为默认的展示方法。
在学习如何扩展之前,我们需要了解编辑器用的是什么绘制方案:IMGUI,如果没接触过的话可能会比较懵逼,我们来了解一下官方的说明:
void OnGUI() {
if (GUILayout.Button("Press Me"))
Debug.Log("Hello!");
}
上面这个代码就可以在界面中绘制这样一个按钮,而这个按钮的点击状态会直接通过绘制函数返回回来,进而直接当场判断并执行输出一个Hello的逻辑。
当然这样说其实是不准确的,这样的UI绘制方法其实会将OnGUI跑多遍,第一遍进行布局计算,第二遍处理用户输入之类的事件,第三遍提交渲染,当然我说的可能也会随着不同的IMGUI的实现而不同,但是大同小异。
如果对Unity的这么小点代码还是不太能理解,可以直接看IMGUI中比较有名的GUI库:
https://github.com/ocornut/imguigithub.com/ocornut/imgui
OK,这种UI写作方式,其实不去深入了解的话只是用,还是上手很快。
Unity提供了多种方法给我们扩展编辑器:
- 直接自己画一个窗口出来,就像画一个Inspector面板一样,这个窗口内部所有的内容都是我们自己画的
- 在已有的窗口画额外的内容,其实和1类似,只是我们需要先找到这个窗口,然后继续画
- 对序列化的成员的编辑器下编辑控件展示进行自定义
1和2,都可以直接通过参考官方文档(Unity - Manual: Editor Windows)可以学习到,因为都是GUI.xxx接口绘制,相对来说很好理解,这里就让大家自己学了。
我们这里主要讲解3。
针对我们在组件里面写的成员变量,我们是可以通过写成public来让它参与到序列化中,这样Inspector面板上就会显示编辑控件,例如Int就会显示出来一个输入框给我们输入数据等。
回想一下,我们之前有一个FireController需要填写Bullet的资源路径,从而能用Resources.Load加载它,但是这个路径就是一个纯粹的字符串,我们很容易写错,如果写错了,跑到游戏里面触发对应逻辑才能发现,如果游戏流程比较长,这无疑会增加返工的成本。
所以我们期望像类似拖拽prefab赋值一样去赋值这个路径,但是存储到序列化的文件中我们希望还是路径,不能是资源引用(这样做看起来似乎没意义,但是对于资源的动态加载来说是必须的)。
OK,我们现在了解一下Unity提供的方法:Property Drawers
我们新建一个脚本叫PrefabPathDrawer,同时我们也需要新建一个脚本叫PrefabPathVariableAttribute
PrefabPathVariableAttribute的代码比较简单:
using UnityEngine;
public class PrefabPathVariableAttribute : PropertyAttribute
{
}
继承自PropertyAttribute这个类,这个PropertyAttribute其实继承自C#的Attribute,顾名思义就是一个标记用的属性,因为我们也没什么额外需求,所以这个属性没有参数,就是个空的,只用来标记需要这样自定义绘制的成员变量。
然后我们需要编写PrefabPathDrawer:
using UnityEditor;
using UnityEngine;
[CustomPropertyDrawer(typeof(PrefabPathVariableAttribute))]
public class PrefabPathDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
base.OnGUI(position, property, label);
}
}
PrefabPathDrawer继承自PropertyDrawer,并且有一个属性CustomPropertyDrawer,参数就是我们刚才用来标记的属性类型PrefabPathVariableAttribute,显然Unity也是通过CustomPropertyDrawer这个属性来收集到我们所有需要自定义绘制的PropertyDrawer以及这个自定义绘制影响的范围。
我们要重写的回调函数是OnGUI,这个函数给了我们几个参数,position表示目前界面绘制到哪里了,我们应该从position的位置继续绘制,property则是受我们重绘逻辑影响的数据序列化的体现,通过操作property能够直接操作数据序列化的结果,label则是上层传来的label字段,我们一般也不需要动。
OK,想象一下,我们现在为了达到目的,需要做什么事情:
- 通过SerializedProperty获取到当前填的是啥路径字符串
- 通过这个路径拿到资源本身
- 绘制一个拖拽赋值Prefab的输入框,而不是文本输入框,这个输入框以2里面找到的资源当作当前的输入
- 拿到3的输入框的当前值,对比是否变更,如果变更了则获取这个资源的路径
- 通过4的路径更新SerializedProperty的数据
那么我们修改一下代码:
using UnityEditor;
using UnityEngine;
[CustomPropertyDrawer(typeof(PrefabPathVariableAttribute))]
public class PrefabPathDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
// 1. 获取当前填的字符串是啥
string prefabPath = property.stringValue;
// 2. 找资源
GameObject res = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
// 3. 绘制Object的输入框,而非文本的,同时开启检查变更
EditorGUI.BeginChangeCheck();
Object newRes = EditorGUI.ObjectField(position, label, res, typeof(GameObject), false);
if (EditorGUI.EndChangeCheck())
{
// 4. 如果发生变更了,获取这个新资源的路径
string newPath = AssetDatabase.GetAssetPath(newRes);
// 5. 更新数据
property.stringValue = newPath;
}
}
}
这里用了AssetDatabase来做资源的加载和路径查找,EditorGUI.ObjectField则是可以帮我们绘制一个拖拽放入GameObject对象的输入框,这个API最后一个false比较重要,代表允不允许赋值场景内的物体,由于我们需要赋值的是资源,所以填不允许,如果填允许的话,那么带有这个成员的组件所在物体只能在场景里面存在,毕竟给一个脱离场景存在的资源赋值场景内的物体,本身这个引用就有问题。
EditorGUI.BeginChangeCheck()和EditorGUI.EndChangeCheck()可以帮我们检查中间有没有发生GUI的内容变化,如果有的话我们才尝试更新序列化的值。
接下来我们需要将我们要自定义绘制的那个成员变量加上这个属性:
public class FireController : MonoBehaviour
{
private bool isMouseDown = false;
private float lastFireTime = 0f;
private Vector3 fireDirection;
private AddVelocity bullet;
[PrefabPathVariable]
public string bulletResourcesPath;
public float fireInterval = 0.1f;
public Transform fireBeginPosition;
这里我们将bulletResourcesPath加上了PrefabPathVariable属性,注意看,Attribute没有了,其实这就是C#的一个规则,属性的类型声明是PrefabPathVariableAttribute,但是实际用来标记的时候需要省略尾巴上的Attribute。
然后我们不用跑游戏,直接在场景里面选中FireController就可以看到效果:
原本这里第一个是输入字符串的输入框,现在已经变成了可以拖拽放入Prefab的输入框,我们可以随意拖放GameObject上去,保存后,然后可以查看这个场景序列化出来的结果:
可以看到界面上我们虽然是赋值物体的形式,但是实际上我们序列化存储的确实是资源路径。
但是这里有个问题,我们使用AssetDatabase系列API获取的资源路径其实是Assets目录下的,但是我们的资源加载却是用的Resrouces.Load,这样路径会不符合要求,所以我们在资源加载的地方也需要做一下适配:
- 裁掉前面的Resources文件夹路径
- 去掉文件后缀
我们稍微调整一下FireController里面加载资源的代码:
private void Start()
{
if (bulletResourcesPath != null)
{
int lastIndex = bulletResourcesPath.LastIndexOf("Resources/", StringComparison.OrdinalIgnoreCase);
if (lastIndex != -1)
{
bulletResourcesPath = bulletResourcesPath.Substring(lastIndex + "Resources/".Length);
string fileExtension = Path.GetExtension(bulletResourcesPath);
bulletResourcesPath = bulletResourcesPath.Substring(0, bulletResourcesPath.Length - fileExtension.Length);
bullet = Resources.Load<AddVelocity>(bulletResourcesPath);
}
}
}
同样的Fire函数里面也记得给bullet资源判空。
OK,现在再跑一下游戏,就应该可以正常发射子弹了。
One more thing:
PropertyDrawer也来自UnityEditor,我们需要把这个代码也移动到Editor文件夹下:
下一章
基本的引擎使用方法其实已经教了不少,通过这些使用方法的学习,其实可以整理出一些学习的方法论,下一章我们将会整理一下本大章所学的所有内容,拼凑出一些基本的思路和方法,这样我们的引擎入门基本算是摸到了一定的方向,为后面更深入的学习也能打下更好的基础。