您想找什么?
Engine & platform

节约时间的高级编辑器编程黑科技(第二部)

JORDI CABALLOL / UNITYSenior Software Engineer
Nov 8, 2022|15 Min
Hero image

我又回来写第二部了!如果你还没看过编辑器编程黑科技的第一部,可以在这里查看。这篇两部分的文章将带你了解高级的编辑器使用技巧、改善你的工作流程,让你下一个项目进展更顺利。

每条技巧都建立在一个类RTS(即时战略)的游戏原型上,游戏里的每个单位都会自动攻击敌方建筑及其他单位。下方是最初版的原型:

上一篇里,我介绍了怎样为项目导入并设置好美术资产。现在我们再到游戏中使用这些资产,同时尽可能地节省时间。

首先是游戏元素的拆包。在准备游戏里的各个元素时,我们通常会遇到以下情况:

一方面,我们从美术团队那拿到预制件,它可以是FBX Importer生成的预制件,也可能是手动用材质和动画小心建立的预制件,用于给层级结构添加道具等。要在游戏里使用预制件,合理的做法是创建这个预制体的预制件变体(Prefab Variant),再把所有游戏相关的组件加进去。这样一来,当美术团队修改或更新预制件时,所有改动可以立即应用到游戏中。这种方法的确能奏效,如果对象的组件较少、设置较简单。但如果它非常复杂,每次都要从头开始配置就非常的麻烦。

另一方面,许多的组件其实会有同样的属性,比如所有的“汽车”预制件或相似的敌人。这时,用同一个基础预制件来制作所有变体是可行的。也就是说,如果预制件的美术设置起来很方便(即模型网格与材质),那这种方法就很理想。

接着来看看怎样简化游戏玩法组件的设置过程,以便快速添加并直接使用。

技巧七:提早设立好组件

对于游戏里较为复杂的元素,最常见的方法是用一个“主要”组件(比如“enemy”、“pickup”或“door”)作为与对象互动的接口,用一堆可重复使用的小组件来实现各种功能,比如“selectable”、“CharacterMovement”或“UnitHealth”,以及renderer、collider这些Unity内置组件。

有些组件依赖于其他组件工作。比如,角色可能要有一个NavMeshAgent(代理)才能移动。而Unity的[RequireComponent]特性正好能写入这些依赖项。如果特定对象有一个“主要”组件,你可以用[RequireComponent]来添加对象所必须的组件。

例如,我原型的单位们有以下特性:

并且,我还能在AddComponentMenu轻松找到其他需要的额外组件。这里,Locomotion负责移动,AttackComponent负责攻击其他单位。

另外,该类还会继承基础类(与建筑共享)的其他RequireComponent特性,比如Health(血量)组件。有了这些,我只需再手动加上Soldier组件,其他组件都会自动被添加。如果我再为某个组件添加一条新的RequireComponnet特性,Unity会将新组件更新到所有现存的游戏对象上,帮助扩展现有对象。

[RequireComponent]还有一个隐蔽的好处:如果“组件A”需要“组件B”,则添加A不仅能保证B会被添加,还能让B先于A被添加。如果组件A调用了Reset方法,组件B依旧会存在并且对数据的访问仍会保留。我们可以引用这个组件,记录持续性的UnityEvents,再完成对象的设置。同时使用RequireComponent特性与Reset方法,我们可以只加一个组件就完成对象的配置。

技巧八:与没有关联的预制件分享数据

上个方法最大的缺点在于,如果我们想修改某个值,就必须一个个手动更改。如果所有的设置都用代码完成,设计师们要改起来会非常困难。

在前一篇文章里,我们讨论了怎样用AssetPostprocessor在导入期间添加依赖项、修改对象。我们同样能用它在预制件上应用数值。

为了让设计师们能轻松修改这些数值,我们需要从预制件上读取它们,以便通过修改预制件来更改整个项目中的数值。

如果是编辑器代码,你可以利用Preset类把一个组件的数值复制到另一个。

像这样用原组件创建一个预设,再应用到另一个组件:

在生效时,它会覆盖预制件的数值,不过这并不是我们的目的。我们只想复制部分数值,其他的不动。此时,我们可以用Preset.ApplyTo覆盖方法,它接收一个一定要应用的属性列表。虽然我们能硬写出一份待覆盖的属性列表,这对大部分项目来说都没问题,但如何让这个流程更为通用呢。

我先是创建了一个带有所有组件的基础预制件,然后以其作为模板创建了一个变体。接着我再从变体的覆盖列表里确定需要应用的数值。

你可以用PrefabUtility.GetPropertyModifications来获取覆盖值。该方法会抓取整个预制件上的所有覆盖值,你需要筛选出与组件相关的那几个。要注意的是我们修改的是基础预制件的组件,而非变体,所以得用GetCorrespondingObjectFromSource来引用它。

这一段会将模板的所有覆盖值应用到预制件上。另一个细节问题是模板可能是一个变体的变体,所以我们也得应用上一层变体的覆盖值。

为此,我们得让这段操作循环往复:

接着我们找到预制件的模板。理想情况下,不同类型的对象会有不同的模板。我们可以将模板及待修改的对象放在同一文件夹下来提高效率。

在放预制件的文件夹下查找名为Template.prefab的对象。如果没有,就在上级文件夹里重复查找:

到这里,只要模板预制件被修改,所有改动都能自动应用到同一文件夹内的预制件上,即便它们并非模板的变体。在例中,我修改了默认的玩家颜色(当单位未被指派给任意玩家时)。注意看所有对象都更新了:

技巧九:用ScriptableObject(可编程对象)和表格来平衡游戏数据

在平衡游戏时,需要调整的数据往往散布在各个组件上,存在每个角色的预制件或ScriptableObject里。这使得细节调整非常低效。

使用表格来简化平衡过程是一种常见的做法。它可以搜集所有的数据,还能用公式来计算一些额外数据。手动将数据输入到Unity里可能会非常麻烦。

而表格此时就能发挥一定作用。表格可以被导出为CSV (.csv)或TSV (.tsv),再用ScriptedImporter导入。下方截图展示了原型单位的统计数据:

Example of a spreadsheet | Tech from the Trenches

这段代码非常简单:用单位的所有统计数据创建一个ScriptableObject,再读取文件。你可以根据表的每一行创建一个ScriptableObject的实例,填入行内的数据。

最后,利用上下文将ScriptableObject添加到导入后的资产上。另外,我们还得添加一个主资产,这里我创建了一个空的TextAsset(我们不会真的用这个对象干什么)。

这个方法同时适用于建筑和单位,你只需要留心那些数据更多的单位。

这步完成后,你就有了包含所有表格数据的ScriptableObject。

Imported data from the spreadsheet

生成的ScriptableObject可以随时被用到游戏里。你也能借助之前编写的PrefabPostprocessor来应用它们。

OnPostprocessPrefab方法里,我们能加载该资产,并自动在组件的参数上填入输入。不仅如此,如果数据资产设有依赖项,预制件会在数据被修改时重新导入,自动更新所有内容。

技巧十:加快编辑器内的迭代

在搭建关卡时,快速修改并测试、重复微调并实验非常关键。因此,快速迭代、简化测试步骤十分重要。

在讨论Unity迭代时间时,我们最先想到的可能就是Domain Reload(域重载)。Domain Reload与两种关键情形相关:代码编译完成、加载动态链接库(DLL)时,以及进入与退出Play Mode(运行模式)时。编译产生的域重载不可避免,但你可以在Project Settings > Editor > Enter Play Mode Settings里禁用Play Mode的重载。

如果你的代码编写得不合适,禁用这部分重载会产生一些问题,最常见的有静态变量在运行后不会重置。如果你的代码能适应,那就禁用它吧。在我的原型里,Domain Reload已被禁用,你可以瞬间进入Play Mode。

技巧十一:自动生成数据

迭代时间的另一个问题在于重新计算运行所需的数据。我们需要选中这些组件,点击对应的按钮来触发重新计算。比如,在我的原型里,每支队伍都有一个TeamController。这个控制程序以列表列出了所有敌方建筑,并会派出单位攻击建筑。要想自动填写这些数据,我们可以用IProcessSceneWithReport接口。我在两种情形下调用这个接口:游戏打包时,在Play Mode里加载场景时。这时我有机会创建、摧毁或修改任意对象。不过,这些改动只会影响运行版和Play Mode。

这次回调会创建控制器、设定建筑列表。而我就不必再手动调整任何东西。当游戏开始时,控制器会带有一份更新后的建筑列表,任何对列表的修改也会被自动更新。

在原型里,我编写了一个方法来获取场景内某一组件的所有实例。你能用它来抓取所有的建筑:

剩下的就简单了:抓取所有建筑,找到建筑的所有所属队伍,为每支队伍创建一个带有敌方建筑列表的控制器。

技巧十二:处理多个场景

除了编辑中的场景,游戏还会加载其他场景(每个场景都包含管理程序、UI等等)。编辑这些场景会占据一定的宝贵时间。在我的原型里,展示血条的Canvas被放在了另一个称为InGameUI的场景中。

为了高效地利用好这个场景,我在场景里加了一个组件,其中以列表列出了需要一并加载的场景。如果你在Awake里同时加载这些场景,UI场景也会被加载,它所有的Awake方法都会被触发。等到调用Start方法时,所有的场景就已经完成了加载和初始化,让你能访问其中的数据,比如管理器单例。

当然,在进入Play Mode时部分场景是已经加载了的,所以有必要在加载前检查个别场景是否已加载:

总结

第一部和第二部两篇文章里,我已经展示了怎样利用起那些鲜为人知的Unity特色功能。这里所列出的方法只是一个个小步骤,但我希望它们能在你的下一个项目里发挥作用,或至少成为候选。

原型所用到的资产都能在资源商店上免费下载:

如果你有兴趣讨论下文章,或者分享自己的感想,请前往我们的Scripting论坛。这里我先下线了,但你可以在Twitter(@CaballolD)联系我。未来将有更多Unity开发者发布Tech from the Trenches系列技术博文,请持续关注