本文重点: 1、让生成自动化 2、为生命周期创建必不可少的区域 3、控制区域来影响形状 4、集中更新关卡对象并添加编辑器支持 5、使用局部类
这是关于对象管理系列的第12篇也是最后一篇教程。它涵盖了kill区域的增加和更严格的关卡对象管理。
本教程是CatLikeCoding系列的一部分,原文地址见文章底部。
本教程使用Unity 2017.4.12f1制作。
(塑造着生与死)
教程更新
在前面添加行为删除时,我忘记添加一行代码来回收该行为。如果你也还没这样做,请在Shape.GameUpdate中添加一个循环调用。
1 自动化生成区域
要杀掉形状,必须首先生成它们。我们已经有生成区域,但是默认情况下它们是惰性的。玩家必须手动提高创建速度或生成形状。如果生成区域可以自己激活,那么会更方便的展示生成区域和kill区域之间的相互作用。
1.1 生成速度
并非所有的生成区域都必须始终处于活动状态。自动区域和手动区域之间可能会有区别。因此,让我们向SpawnZone添加生成速度配置选项。给它一个范围很大的滑块,例如0–50。
为了完成这项工作,SpawnZone现在需要跟踪其生成进度,并像Game一样,以FixedUpdate方法对其进行更新。
(自动生成速度设置为50)
1.2 保存进度
从现在开始,保存游戏时,生成区域还需要追踪其生成进度。为此添加所需的Save和Load方法。
每个具有正生成速度的生成区域都必须包含在其关卡的持久对象列表中,否则将不会被保存和加载。
(持久化自动生成区)
请注意,区域可以是自动的,也可以由Player控制。这两者并不相互影响。
1.3 复合生成区
CompositeSpawnZone已经拥有自己的Save和Load方法,因为它必须跟踪其下一个顺序索引。我们需要确保这些方法调用其Base版本,这样的话,它也可以保存复合区域的生成进度。
但是旧的保存文件不包括生成进度,因此我们仅应针对新的保存游戏(版本7)执行此操作。
增加Game中的保存版本以匹配。
2 Kill区域
一个Kill区域是指一个会杀死所有进入它的形状的空间。这意味着我们必须弄清楚一个形状是否进入了一个区域。可以使用collider triggers和Unity的3D物理引擎来检测游戏对象。
2.1 物理触发器
创建一个新的KillZone组件类型,并给它一个带有Collider参数的OnTriggerEnter方法。当某些东西进入到带有此组件的游戏对象的触发器时,该方法将被调用,collider将作为一个参数。
在此方法中,用collider检索形状组件。如果它存在,就消灭它。
现在,我们可以通过向一个关卡添加一个空的游戏对象并为其提供Collider和一个kill zone组件来创建一个kill区域。它必须是特定类型的Collider,例如盒式或球形Collider。确保启用了“Is Trigger ”选项。
(Box Kill Zone)
这还不足以检测输入的形状。尽管区域和所有形状都具有碰撞体,但是在物理引擎使它们相互作用之前,每种形状中的至少还需要附加一个刚体组件。哪种类型的刚体无关紧要,因此让我们将其添加到区域中,以使形状尽可能简单。
在某物上添加刚体会使它像物理对象一样工作,其中就包括受重力影响。但我们暂时不需要,因此启用刚体的“Is Kinematic”选项。这表明,就物理引擎而言,该对象是不可移动的。
(Kill Zone设置)
现在,进入区域或在区域中生成的形状都会立即消失。因此,你既可以使用它在生成区挖个洞,也可以使用它来移除哪些不想要的形状。
(Kill区域展示)
2.2 放慢死亡
kill区域的作用不必是立即的。与手动或自动破坏形状一样,我们可以为该区域增加一个持续时间。如果此持续时间为正,那么我们将向该形状添加濒死行为。
同样,我们只会在形状尚未消失的情况下执行此操作。
(死亡时间设置为2秒)
为什么每次调用OnTriggerEnter都会分配内存? 这是因为它调用了GetComponent,后者会分配一点内存。这种内存分配只发生在Unity编辑器中,因为它动态地创建一个错误消息字符串,即使它没有被使用。它不会在构建中发生,这就是为什么对构建进行概要分析而不是只在编辑器中进行概要分析很重要的原因之一。
2.3 让Kill区域动起来
就像生成区一样,Kill区也不需要固定。可以通过使它们成为旋转对象的子对象而动画化。
(旋转Kill 区域)
2.4 生存区域
我们也可以把Kill区的概念颠倒过来。结果是一个生存区域,其中的对象存活下来,但一旦离开就会死亡。除了我们需要使用OnTriggerExit方法而不是OnTriggerEnter方法外,它的工作原理完全相同。复制KillZone ,并把它变成一个 LifeZone 组件类型。
(离开区域即死亡)
请注意,生存区域只影响离开的形状,这意味着它们必须先进入。因此,在区域之外生成的形状并不受其影响。但一旦进入该区域,再离开就意味着死亡。
2.5 Gizmos
就像生成区域一样,在设计关卡时需要可以直观地看到Kill区域和生存区域的位置。因此,我们也给每个方法一个OnDrawGizmos方法。但是,尽管每个生成区域都有其自己的形状,但Kill区域和生存区域是由其collider 定义的。因此,我们必须检索collider ,然后找出它是什么类型。首先为KillZone标识一个洋红色。
让我们先来支持盒子和球形对撞机,因为它们最简单。尝试将Collider投射到BoxCollider。如果可以,则绘制一个线框并返回。如果失败,则尝试SphereCollider。如果你想支持更多的可视化效果,可以在此之后添加它们。
as 是做什么的? 它是一个检查对象是否可以强制转换为特定类型的运算符。如果是,则执行cast。如果不是,则结果为空。
可以使用is操作符来检查是否可以进行类型转换,如果True,则进行类型转换,但这需要进行冗余检查。
将方法复制到LifeZone并将颜色更改为黄色。
(Spawn Kill 和Life区域)
2.6 碰撞和缩放
这些gizmos似乎工作正常,但当你给一个区域一个不统一的比例时,就会出问题。我们可以用球体碰撞器尝试一下。gizmos像预期的那样发生了变形,但碰撞器的可视化仍然是一个球体。这是因为物理引擎不支持变形碰撞器。当你运行的时候,你会发现碰撞器的视觉效果确实与受区域影响的空间相匹配。
(不正确的球形缩放)
最终发生的是,碰撞器缩放尺度的最大分量被用作它的统一尺度。为了重现这个情况,我们需要为球面gizmos创建我们自己的变换矩阵。首先,删除localToWorldMatrix的使用。
然后,使用Matrix4x4.TRS方法构造一个自定义矩阵,并将世界空间位置,旋转和有损比例作为单独的参数。对box和球形碰撞器都执行此操作。到这里已经足以修复box类型了,但是球型还需要更多的工作。
什么是有损缩放? 它是世界空间中物体尺度的近似值。这是一种近似,因为该对象可以是在非均匀缩放范围内旋转的对象层次结构中的子对象,这会使该对象变形。这不能仅仅用一个尺度来表示,因此wold-space尺度被定义为有损的。
接下来,将球体的比例设置为有损比例的最大绝对值。
(正确的球形缩放)
对KillZone和LifeZone应用相同的更改。
2.7 形状碰撞器
当我们使用碰撞器处理区域时候,需要看下我们的形状所使用的碰撞器。简单的形状很好,但是复杂的形状每个都由多个对象组成,所以也会有多个碰撞器。触发器事件方法将被所有碰撞器调用,但只有附加到具有Shape组件的根游戏对象的碰撞器才会导致死亡。例如,只使用复合胶囊的碰撞器。
(复合胶囊 3个碰撞器)
我们可以通过从两个子对象中移除碰撞器并将它们添加到根对象中来解决这个问题。但我们可以更进一步。因为我们只关心与区域的交互,这并不需要非常精确。所以我们可以用一个球体碰撞器来代替,这样可以减少形状的内存占用,加快物理引擎的速度。
(只有一个碰撞器)
一个默认的球体碰撞器可以适配它里面的整个形状,但还是有很大一部分是空余出来的。我们把它的半径减小到0.9。
(半径减小到0.9)
同样,我们可以为半径为0.8的复合立方体使用单个球体碰撞器。
(复合的立方体,一个碰撞器)
在立方体和球体的情况下,我们可以简单地移除它的子对象的球体碰撞器,只使用盒碰撞器即可。
2.8 Layer
通过混合生成区域,杀死区域和生命区域,我们可以创建有趣的形状图案和行为,但是我们受到杀死区域和生命区域影响与它们接触的所有形状这一事实的限制。例如,我们当前无法创建一个区域,在该区域中某些形状可以生存而其他形状将消失。但是可以使用Layer来控制哪些物理实体能够进行交互。因此,我们要做的就是为形状和区域分配图层。
我们将按照生成区域来定义形状,而不是按照形状预制来定义层。区域的层可以在检查器窗口的顶部设置。
(Spawn zone 在default 层)
当SpawnZone生成形状时,让它将形状移动到自己的层。可以通过将layer属性从一个游戏对象复制到另一个游戏对象来完成。
Unity具有一些预定义的层,它们相互之间进行交互。我们将保留这些不变,而是添加一些新层。这是通过“Tags & Layers ”窗口完成的,你可以通过游戏对象的“图层”下拉菜单打开该窗口,然后选择“Add Layer... ”选项。我将仅添加两层,分别命名为A和B。
(自定义A和B层)
可以通过“Edit / Project Settings”下的“Physics”窗口调整交互的层。它包含具有交互切换的矩阵。禁用相关层的交互。
(设置层级交互)
现在你可以控制哪些区域杀死哪些形状。A区产生的形状会被A区杀死,但不会被B区杀死,反之亦然。在默认层上由区域生成的形状被A和B区域杀死。和区域在默认层杀死所有形状。
(选择性杀掉)
3 更新关卡对象
拥有大量自动生成区域和旋转对象意味着Unity将再次在多个对象上调用FixedUpdate方法。就像我们对shapes所做的那样,我们也可以用自己的GameUpdate方法来整合这些调用。除了对复杂的关卡有潜在的性能提升,这也可以精确控制游戏中所有内容的更新顺序。
3.1 Game Level 对象
引入新的GameLevelObject类型,继承了PersistableObject并添加了virtual GameUpdate方法。
更改RotatingObject,使它继承GameLevelObject而不是PersistableObject。然后更改它的FixedUpdate方法,使其成为GameUpdate。
对SpawnZone执行相同的操作。
如果还有有其他激活的关卡对象类型,也要更改它们。
3.2 重构Game Level
为了使关卡对象再次更新,我们还需要调用其GameUpdate方法。为此,请将GameLevel.persistentObjects元素的类型更改为GameLevelObject。因为它继承了PersistableObject,所以关卡场景中的所有引用均保持不变。
由于persistentObject名称描述不再准确,因此有必要重构该字段,将其重命名为levelObjects。但是,如果我们这样做,场景会丢失它们的数据。为了防止这种情况,我们可以告诉Unity我们希望它使用旧数据,如果它仍然存在于场景资产中。这是通过从UnityEngine中提供FormerlySerializedAs属性来实现的。序列化名称空间,以其旧名称作为字符串参数。
(现在他们会被当做Level objects)
我们必须保留FormerlySerializedAs属性多长时间? 你可以永远保存它,因为它不会妨碍任何事情。一旦你确定没有旧的场景留下,就可以删除它。仅仅打开一个场景并直接保存它是不够的,你需要做一些修改,这样编辑器才会决定是否需要重新编写场景资产文件。
3.3 更新对象
现在由GameLevel来更新它所有的关卡对象。为此,给它自己的GameUpdate方法添加public权限。
最后,让Game调用当前关卡的GameUpdate方法,作为其更新循环的一部分。在形状之后更新关卡,这样就不会自动更新自动生成的形状。
4 编辑Game Level Objects
集中更新关卡对象让我们拥有全面的控制权,但它也要求我们保持每个关卡的level objects数组的最新。这需要手动完成,但我们可以添加一个小编辑器功能来简化此操作。
4.1 丢失的对象
如果我们忘记向数组中添加level objects,那么level仍然有效。只是对象不会更新,但这一点我们很快就会注意到。在设计一个关卡时,删除对象是很常见的,如果对象已经被添加到数组中,就会产生麻烦。丢失的对象会产生空指针,这些空指针将在游戏模式下生成异常。
(一个对象丢失)
我们可以让GameLevel跳过丢失的对象,但是在设计过程中应注意此类错误。检查关卡对象的检查人员应该足以发现丢失的对象,但是可能很难注意到它们。因此,需要让它变得更加明显。
首先,我们需要一种方法来确定是否缺少关卡对象。添加一个HasMissingLevelObjects getter属性来检查这个,当发现空时返回true,否则返回false。因为我们将在Unity编辑器中使用这个属性,levelObjects数组可能还不存在,所以我们也必须检查这个。
接下来,在编辑器文件夹中为GameLevel创建一个自定义检查器类。这是通过扩展Editor并将CustomEditor属性附加到GameLevel类型作为参数来完成的。我们将通过重写OnInspectorGUI方法来调整检查器。通过调用DrawDefaultInspector重现默认检查器。
可以通过target属性访问正在编辑的组件。将其投射到GameLevel之后,我们可以检查它是否缺少关卡对象。如果是这的话,请在默认检查器下方显示错误消息,以使其在视觉上显而易见。这是通过使用字符串和错误消息类型调用EditorGUILayout.HelpBox来完成的。
(检查到错误)
4.2 移除丢失的元素
切勿删除关卡对象,因为这将导致无法加载关卡的旧数据。但是,当设计一个未发布的关卡时,我们可以按照自己的意愿做。因为缺少对象时我们已经显示了一条消息,所以让我们更进一步,并提供一种简单的方法来消除数组中的所有空引用。
将公共RemoveMissingLevelObjects方法添加到GameLevel。首先循环遍历数组,然后仅计算空引用数。
每当我们遇到一个空引用的时候都需要关闭它,方法就是通过移动数组的其余部分向上一个元素。我们可以调用System.Array.Copy来实现。它的第一个和第三个参数是源数组和目标数组,在本例中都是levelobject。第二个参数是开始复制的索引,第四个参数是应该复制到的第一个索引。它的最后一个参数是要复制的元素数量,也就是数组的长度减去迭代器和空引用。
每次我们移动数组之后,应该再次访问相同的索引,以防我们跳过了某个索引,所以移除元素之后要递减迭代器。但我们只处理了一个元素,所以应该减少匹配的迭代次数。这可以通过从循环条件中数组的长度减去迄今为止遇到的空引用的数量来实现。同样地,我们不必复制数组末尾的冗余元素,直接通过减去要复制的空引用数来避免。
一旦完成,就需要通过减少空引用的数量来消除数组多余的尾部。我们可以为此使用System.Array.Resize,将数组及其新长度作为引用参数。
如果我们使用List会不会更容易?
是的,但是levelObjects是一个数组,因为这样的想法是在播放过程中它永远不会改变。因此,除了在这种仅限编辑器的情况下,我们不需要List提供的额外功能和开销。将其列入List将表明在运行过程中进行更改是可以的,这不是我们设计的方式。
通过使用标签调用GUILayout.Button,在我们的自定义检查器中的错误消息下方添加一个按钮。当按下按钮时,它将返回true,在这种情况下,我们将调用新的RemoveMissingLevelObjects方法。
要在Unity的撤消系统中使用此功能,请在进行更改之前调用具有game level 和标签的Undo.RecordObject。
(移除丢失元素的按钮)
这个想法是RemoveMissingLevelObjects仅在编辑关卡时被调用。让我们通过检查Application.isPlayer是否返回true来强制执行该操作。如果是的话,请记录错误并中止该方法。
4.3 注册Game Level Objects
我们还可以更轻松地将关卡对象添加到关卡的数组中。为此,使用关卡对象参数将公共RegisterLevelObject方法添加到GameLevel。如果还没有levelObjects数组,请使用提供的对象创建一个。否则,将数组的大小增加一并将对象分配给它的最后一个元素。同样,我们仅在播放模式下才支持此功能。
每个关卡对象只能在数组中包含一次。添加一个公共的HasLevelObject方法,以检查数组是否已包含提供的对象。这样就可以检查调用RegisterLevelObject是否正确,而且还可以让该方法自行验证并在需要时中止。
4.4 注册按钮条目
我们将在Unity菜单中添加一个项目,以将选定的关卡对象注册到适当的游戏关卡。让我们将菜单项的代码放在自己的静态类中的Editor 文件夹中。通过将MenuItem属性附加到静态方法(以菜单项的菜单路径作为参数)来创建菜单项。我们将通过GameObject/ Register Level Object使它可用。
可以通过Selection.activeGameObject访问当前选择的游戏对象。
如果没有这样的对象,则记录警告并中止。
如果选择了游戏对象,则它可以是场景对象,也可以是预制资产的一部分。我们只能在场景中注册对象,因此如果结果是预制的,则应该中止。我们可以通过使用对象作为参数调用PrefabUtility.GetPrefabType来进行检查。如果结果表明是预制件,那么我们应该在记录警告后中止。记录时提供该对象作为附加参数,以便在编辑器中将其临时突出显示。
接下来,获取GameLevelObject组件。如果没有,请中止。
如果我们走到了这一步,我们必须找到合适的游戏关卡进行注册。假设关卡对象始终是其场景的根对象。通过其scene属性获取对象的场景。然后遍历场景的根对象数组,该数组可通过其GetRootGameObjects方法访问。如果找到游戏关卡,请立即返回。否则,记录警告。
foreach是如何工作的?
如果不需要索引,foreach是for循环的一种方便的替代方法。当与数组一起使用时,它只是语法糖。你可以用下面的写法替代:
但是,当循环遍历其他集合或枚举数(包括List)时,情况就不是这样了。在这些情况下,foreach创建一个临时迭代器对象,用于分配内存。所以经验法则就是不要依赖foreach来获取游戏逻辑。这对于数组来说很好,但是如果它们被重构成列表,你就会在游戏中突然得到临时的内存分配。
如果我们找到了游戏关卡,检查对象是否已经被注册,如果是这样就终止。
如果我们继续往下,那么在记录撤消系统的游戏关卡之后,最终可以注册该对象。我们还要记录在哪里注册的内容,以便设计人员可以确定它可以正常工作,并且不会编译失败。
4.5 多选模式
我们不必限制菜单项仅可用于单个对象。让设计人员可以选择多个关卡的对象,然后一次注册所有对象,即使它们属于不同关卡也是如此。我们通过遍历Selection.objects而不是仅使用Selection.activeGameObject来做到这一点。在这个时候,我们要处理对象引用。因此,如果可能的话,将其强制转换为GameObject并将结果传递给原始代码,并移至独立的方法。
现在,可以在选择资产和场景对象混合的同时调用我们的菜单项,这没有任何意义。理想情况下,仅当选择游戏对象以外的任何东西时才应启用菜单项。我们可以通过验证方法来强制执行。
验证方法与常规菜单项方法的工作原理相同,不同之处在于验证方法的属性具有true作为附加参数,并且返回是否应启用菜单项。默认情况下,所有项目始终处于启用状态。
我们的项目适用于选择,因此,如果未选择任何内容(数组的长度为零),则不应启用它。
并且当至少一个选定的对象不是游戏对象时,我们的菜单项也应被禁用。
现在我们可以取消null检查,因为我们保证可以处理的是游戏对象。
4.6 仅编辑器 Game Level 代码
所有这些都可以,但是我们现在在GameLevel中有一些代码只能在Unity编辑器中使用,因此不需要将其包含在构建中。我们可以通过使用条件编译来确保这一点。但是,这仍然将仅编辑器的代码与其他代码混合在一起。如果我们可以提取仅编辑器的代码并将其放在单独的资产文件中,将会很方便。还可以使用局部类。
什么是局部类? 这是将类(或结构)定义拆分为多个部分(存储在不同文件中)的一种方法。唯一的目的是组织代码。典型的用例是将自动生成的代码与手动编写的代码分开。就编译器而言,它们都是同一类定义的一部分。
首先,添加部分关键字GameLevel。就其本身而言,这不会改变任何东西。
接下来,复制GameLevel资产文件并重命名。部分类的典型命名约定是将ClassName.Purpose.cs用于其他部分类文件。当我们分离仅编辑器的代码时,将其命名为GameLevel.Editor。
(两个资产指向同一个类)
打开新文件并删除所有代码,但类定义本身,HasMissingLevelObjects,HasLevelObject,RegisterLevelObject和RemoveMissingLevelObjects除外。或以一个空文件开始并添加所需的代码。类定义只必须包括部分类GameLevel。你也可以添加public和扩展声明,但这不是必需的。要么全部删除,要么使用完全相同的类声明。
现在,我们可以使用单个条件编译块进行处理,将整个类包装起来。
最后,从原始类定义中删除相同的代码,因为这已成为重复的代码。
对象管理系列文章到此结束。此时,你应该已经很好地掌握了如何在Unity中管理对象了。
欢迎扫描二维码,查看更多精彩内容。点击 阅读原文 可以跳转原教程。
本文翻译自 Jasper Flick的系列教程
原文地址:
https://catlikecoding.com/unity/tutorials