目录
· 1 建造塔
· 1.1 瓦片内容
· 1.2 预制体
· 1.3 放置塔
· 1.4 阻挡路径
· 1.5 替换墙
· 2 锁敌
· 2.1 目标点
· 2.2 Enemy 层
· 2.3 更新瓦片内容
· 2.4 目标范围
· 2.5 获得目标
· 2.6 目标锁定
· 2.7 同步物理
· 2.8 忽略海拔
· 2.9 避免内容分配
· 3 射击敌人
· 3.1 瞄准
· 3.2 发射激光
· 3.3 敌人的血量
· 3.4 DPS(每秒伤害)
· 3.5 随机目标
本文重点内容:
1、把塔放入面板
2、借助物理手段瞄准敌人
3、尽可能长的时间追踪他们
4、用激光束射击它们
这是有关创建简单塔防游戏的系列教程的第三部分。它涵盖了塔的创作以及它们如何瞄准和射击敌人。
本教程是CatLikeCoding系列的一部分,原文地址见文章底部 。
本教程是用Unity 2018.3.0f2制作的。
(敌人感受到了高温)
1 建造塔
墙壁只会通过增加敌人行进的路径来减慢敌人的速度。但是游戏的目标是在敌人到达目
的地之前消灭它们。这可以通过在面板上放置射击塔来完成的。1.1 瓦片内容
塔是瓦片内容的另一种类型,因此将它们的条目添加到GameTileContent。
在本教程中,我们仅支持一种塔,因此可以通过给GameTileContentFactory一个对塔架预制件的引用来实现,也可以通过Get实例化。
但是塔需要射击,因此它们将需要更新并需要自己的代码。为此目的创建一个Tower类,以扩展GameTileContent。
通过将工厂字段的类型更改为Tower,可以强制Tower预制组件包含此组件。由于它仍然算作GameTileContent,因此我们无需更改其他任何内容。
1.2 预制体
为塔创建一个预制件。你可以从复制墙预置开始,用塔组件替换它的GameTileContent组件,并将其类型设置为塔。为了使塔与墙体相适应,保持现有的立方体墙体作为塔的基础。然后再在上面放一个立方体来代表塔。我把它的比例设置为0.5。再在上面放一个相同大小的立方体,代表炮塔,这是瞄准和射击的部分。
(三个立方体组成了塔)
塔会旋转,因为它有一个碰撞器,物理引擎需要追踪它。但我们不需要那么精确,因为我们使用塔碰撞器只是为了选择单元格。可以凑合用一个近似值。移除塔架立方体的碰撞器,调整塔身立方体的碰撞器,让它覆盖两个。
(塔立方体碰撞器)
我们的塔会发射激光束。有许多种方法可以可视化它,但是我们仅使用拉伸后的半透明立方体来形成光束。每个塔将需要一个自己的光束,因此将其添加到塔的预制件中。将其放置在塔内,以便默认情况下处于隐藏状态,并使其较小,例如0.2。使它成为预制根的子节点,而不是转塔立方体的子节点。
(隐藏激光束立方体)
给激光束适当的材质。我只是使用标准的半透明黑色材质,并关闭了所有反射,同时给其提供红色。
(激光束材质)
确保激光束立方体没有碰撞器,同时关闭阴影投射和接收。
(激光束不需要和阴影交互)
塔预制完成后,将其添加到工厂。
(塔在工厂中引用)
1.3 放置塔
我们通过另一个切换方法添加和移除塔。可以简单地复制游戏板。ToggleWall并更改方法的名称和内容类型。
在Game.HandleTouch中,如果玩家按住Shift键,则切换塔而不是墙。
(面板上的塔)
1.4 阻挡路径
目前只有墙壁阻碍寻路,所以敌人会穿过塔。在GameTileContent中添加一个方便的属性,该属性指示它是否阻塞路径。如果它是墙壁或塔,则阻碍。
在GameTile.GrowPathTo中使用此属性,而不是检查确切的内容类型。
(墙和塔现在都可以阻碍路径)
1.5 替换墙
玩家很可能会以大量的塔楼替换墙壁。首先必须移除墙壁是很不方便的,而且敌人可能会从临时的空隙中偷偷溜走。我们可以通过GameBoard实现直接替换。ToggleTower还可以检查瓦片当前是否有墙。如果是的话,直接用塔代替它。在这种情况下,我们不需要寻找新的路径,因为瓦片仍然是可以阻塞它们的。
2 锁敌
塔只有找到敌人,才能发挥作用。一旦发现敌人,它还必须决定将目标对准敌人的哪一部分。
2.1 目标点
我们将使用物理引擎来检测目标。就像塔的碰撞器一样,我们不需要敌人的对撞机来完全匹配其形状。可以用简单的碰撞器来做,比如球体。一旦检测到,我们将使用附着有碰撞器的游戏对象的位置作为瞄准点。
我们不能将碰撞器附加到敌人的根对象上,因为碰撞器一直都与模型的位置不匹配,并且会使塔瞄准地面。因此,我们必须将碰撞器放在模型中的某个位置。物理引擎将为我们提供对该对象的引用,我们可以将其用于目标定位,但是我们还需要访问根对象上的Enemy组件。让我们创建一个TargetPoint组件来简化这一过程。给它一个属性以供私人设置和公开获取敌人组件,以及另一个属性以获取其世界位置。
给它一个Awake方法,该方法将引用设置为其Enemy组件。我们可以通过transform.root直接进入其根对象。如果“Enemy”组件不存在,那么我们会得到一个设计错误,因此让我们为其添加一个断言。
同样,碰撞器应该与TargetPoint连接到相同的游戏对象。
添加组件和碰撞器到敌人的立方体预制上。这将使塔瞄准立方体的中心。使用半径为0.25的球体碰撞器。由于立方体的比例为0.5,碰撞器的有效半径为0.125。这就使得敌人必须在塔成为有效目标之前就在视觉上锁定了它的射程。碰撞器的大小也会受到敌人的随机比例的影响,所以它在游戏中的大小也会发生变化。
(带有目标点的敌人,碰撞器在立方体内部)
2.2 Enemy 层
塔只关心敌人,不应该瞄准其他东西,因此我们将所有敌人放在一个专用的层上。我们将使用第9层。通过“Layers & Tags窗口将其名称设置为Enemy,可以通过编辑器右上角的Layers下拉菜单中的Edit Layers选项打开该窗口。
(第9层给敌人用)
该层仅用于检测敌人,不适用于物理相互作用。让我们通过在Layer Collision Matrix中禁用它来表明这一点,你可以在项目设置的Physics面板下找到它。
(层碰撞矩阵)
确保目标点的游戏对象在正确的图层上。敌方预制件的其余部分可以在其他层上,但是最好保持一致,将整个预制件放置在enemy层上。如果你要更改根对象的层,则可以选择更改其所有子对象。
(enemy在正确的层上)
断言TargetPoint确实在正确的层上。
同时,播放器交互应该忽略敌人的碰撞。我们可以通过给物理添加一个layer Mask参数来做到这一点。Raycast GameBoard.GetTile。它有一个变体,以射线距离和Layermask作为附加参数。提供默认图的最大范围和
layer mask,即1。
layer mask不应该为零吗?
默认的层索引为0,但是我们提供了一个 layer mask。如果图层应该被包括在内,这个掩码的工作原理是将整数的个位设置为1。在这种情况下,只需要设置第一个比特,即它的最不重要的比特,它定义了数字2的0次方,即1。
2.3 更新瓦片内容
塔只有更新后才能执行其工作。即使当前我们的其他内容不执行任何操作,一般情况下也适用于瓦片内容。因此,让我们向GameTileContent添加一个虚拟的GameUpdate方法,该方法默认情况下不执行任何操作。
让Tower覆盖它,最初只是记录它正在寻找目标。
GameBoard负责瓦片及其内容,因此它还将追踪需要更新哪些内容。为此提供一个列表,以及一个公共的GameUpdate方法,该方法更新该列表中的所有内容。
在本教程中,仅需更新塔。调整ToggleTower,使其适当地添加和删除内容。如果其他内容也需要更新,那么我们将需要一种更通用的方法,但是目前就足够了。
为了完成这项工作,我们现在还必须在Game.Update中更新棋盘。在敌人之后更新棋盘。这样,塔将瞄准敌人当前所在的位置。如果我们以相反的方式进行操作,则塔将瞄准目标早于一帧的位置。
2.4 目标范围
塔仅具有有限的目标范围。通过向塔添加字段来使其可配置。距离是从塔的瓦片中心测得的,因此0.5的范围仅覆盖其自身的瓦片。因此,合理的最小和默认范围应为1.5,覆盖大多数相邻图块。
(目标范围设置为2.5)
让我们用Gizmo可视化范围。我们不需要一直看到它,因此让我们创建一个OnDrawGizmosSelected方法,该方法仅针对选定对象被调用。以塔为中心绘制一个半径范围为黄色的球形线。将其放置在地面上方一点,以便始终清晰可见。
(目标范围 gizmo)
现在我们可以看到哪些敌人是每个塔的有效目标。但是在场景窗口中选择塔不方便,因为我们最终选择了一个子立方体,然后需要将选择更改为塔Root对象。其他瓦片内容也遇到相同的问题。通过将SelectionBase属性添加到GameTileContent,我们可以在场景窗口中强制选择内容根。
2.5 获得目标
向塔中添加一个TargetPoint字段,以便它可以跟踪其获取的目标。然后更改GameUpdate,以便它调用新的AquireTarget方法,该方法返回是否找到目标。如果是,请记录此事实。
在AcquireTarget中,通过以塔的位置和范围作为参数调用Physics.OverlapSphere来检索所有有效目标。结果是一个Collider数组,其中包含与所述球体重叠的所有碰撞体。如果数组的长度为正,则至少有一个目标点,我们只需选择第一个即可。抓住其应始终存在的TargetPoint组件,将其分配给目标字段,并指示成功。否则,请清除目标并指示失败。
仅当我们仅考虑enemy层上的碰撞体时,才能保证获得有效的目标点。是第9层,因此提供相应的layer mask。
位掩码如何工作?
由于敌人层的索引为9,因此位掩码必须将其第十位设置为1。相应的整数是2的9次方,即512。但这不是编写位掩码的直观方法。我们还可以编写一个二进制文字,例如0b10_0000_0000,但是我们必须从零计数。在这种情况下,最方便的表示法是使用左移运算符<<将位向左移位,如果将其应用于1,则表示2的幂。
我们可以通过在塔的位置和目标之间绘制一条Gizmo线来可视化获取的目标。
(可视化目标)
为什么不使用OnTriggerEnter之类的方法?
手动检查重叠目标的优点是,我们只需要在必要时进行检查。如果一个塔已经有了目标,就没有理由去检查目标。同时,通过一次获取所有潜在目标,我们不必管理每个塔的潜在目标列表,因为它总是在变化的。
2.6 目标锁定
获取哪个目标取决于物理引擎显示它们的顺序,实际上是任意的。结果,获得的目标似乎会随意改变。一旦塔有了目标,就可以继续追踪一个目标而不是切换到另一个目标。添加一个TrackTarget方法来执行此追踪并返回是否成功。首先仅指示是否已获取目标。
仅在无法成功调用AcquireTarget时才在GameUpdate中调用此方法。如果任何一个成功,那么我们就有一个目标。可以通过使用OR运算符将两个方法调用都放入if校验中来完成此操作,因为如果第一个操作数的结果为true,则不会对第二个操作数求值,因此将跳过其调用。AND运算符的行为类似。
(追踪目标)
其结果是塔会锁定目标,直到目标到达目的地并被摧毁。如果要重用敌人,则必须检查有效引用,就像 对象管理 系列中的 形状引用 处理方式一样。
为了只追踪在射程内的目标,追踪目标必须检查塔和目标之间的距离。如果超出范围,则清除目标并返回失败。我们可以使用Vector3.Distance检查方法。
但是,这并未考虑碰撞器的半径。因此,发射塔最终可能无法跟踪目标,然后立即再次获取它,而仅在下一帧停止追踪它,依此类推。我们可以通过将碰撞器的半径添加到范围上来防止这种情况。
这给了我们正确的结果,但只有当敌人的缩放不变的时候。当我们给每个敌人一个随机的范围,我们应该考虑到射程调整。要做到这一点,我们必须记住我们给敌人的缩放,并通过getter属性暴露它。
现在,我们可以在Tower.TrackTarget中检查适当的距离。
2.7 同步物理
看起来一切正常,但现在的炮塔能够获取可以瞄准面板中心,已经超出范围的目标。他们追踪这些目标最后会失败,因为每个目标只能锁定一个帧。
(不正确的目标)
发生这种情况是因为物理引擎的状态与我们的游戏状态未完全同步。所有敌人都在世界原点实例化,该原点与面板中心重合。然后,我们将它们移动到它们的生成点,但是物理引擎并没有立即意识到这一点。
通过将Physics.autoSyncTransforms设置为true,可以在对象的变换更改时立即强制立即同步。但是默认情况下它是关闭的,因为在需要时一次同步所有内容效率更高。在我们的情况下,我们仅需要在更新塔时进行同步。可以通过在更新敌人和Game.Update中的棋盘之间调用Physics.SyncTransforms来实现此目的。
2.8 忽略海拔
我们的游戏玩法本质上是2D。因此,让我们更改Tower,以便在定位和跟踪时仅考虑X和Z尺寸。物理引擎在3D空间中工作,但是我们可以通过向上拉伸球体来有效地在AcquireTarget 2D中进行检查,因此无论其垂直位置如何,它都应覆盖所有碰撞体。这可以通过使用胶囊来完成,胶囊的第二点在地面上几个单位,比方说三个。
我们不能使用2D物理引擎吗?
问题在于我们的游戏是在XZ平面中定义的,而2D物理引擎在XY平面中工作。你可以通过重新调整整个游戏的方向或仅出于物理目的创建单独的2D表示来使其工作。但是,仅使用3D物理学会更简单。
我们还需要调整TrackTarget。虽然我们可以创建2D向量并使用Vector2.Distance,但我们还是自己做数学并比较平方距离,这就是我们所需要的。这样就消除了平方根运算。
这是怎么算出来的?
它依靠勾股定理来计算2D距离,但省略了平方根。取而代之的是半径的平方,因此我们最终比较了平方长度。这样就足够了,因为我们只需要检查相对长度,就不需要确切的差异。
2.9 避免内容分配
使用Physics.OverlapCapsule的缺点是,每次调用都会分配一个新的数组。通过一次分配一个数组并在半径之后调用替代OverlapCapsuleNonAlloc方法(将数组作为额外的参数),可以避免这种情况。提供的数组的长度限制了我们获得多少结果。超出限制的任何潜在目标都将被忽略。由于我们仍然只使用第一个元素,因此我们可以处理长度为1的数组。
OverlapCapsuleNonAlloc不是数组,返回发生的命中次数(达到允许的最大值),我们必须检查这个而不是数组的长度。
3 射击敌人
现在我们有了一个有效的目标,该射击它了。这涉及瞄准炮塔,发射激光并造成伤害。
3.1 瞄准
为了将炮塔指向目标,炮塔需要引用炮塔的transform组件。为其添加一个配置字段,并将其连接到塔预置中。
(炮台引用)
在GameUpdate中,如果我们有一个有效的目标,我们应该射击它。将该代码放在单独的方法中。通过使用目标点作为参数调用其Transform.LookAt方法,使炮塔旋转以面向目标。
(只是瞄准)
3.2 发射激光
放置激光束那么Tower也需要引用。
(激光束连接)
将立方体变成正确的激光束需要三个步骤。首先,其方向必须与炮塔的方向匹配。我们可以通过复制旋转来做到这一点。
其次,我们对激光束进行缩放,使其与转塔的本地原点和目标点之间的距离一样长。我们缩放其Z尺寸,这是指向目标的局部轴。为了保持原始XY标尺,请在塔Awake时存储原始标尺。
第三,将激光束定位在转塔和目标点之间的中间位置。
(发射激光)
不能让激光束成为塔的子节点吗?
如果这样做的话,我们将不需要分别旋转激光束,也不需要其前向矢量。但是,炮塔的缩放也会对其造成影响,因此我们需要对此进行补偿。将它们分开更容易。
只要转塔保持锁定在目标上就可以使用。但是,当没有目标可用时,激光保持激活状态。如果我们不射击,我们可以通过在GameUpdate中将激光的比例设置为零来从视觉上关闭激光。
(idle状态下 塔不攻击)
3.3 敌人的血量
目前,我们的激光束只是射向敌人,没有其他效果。但敌人应该被激光束伤害才对。我们不想立即消灭敌人,所以要给敌人一个健康属性。我们可以使用任意数量来代表一个健康的敌人,所以就使用100吧。但是有意义的是,较大的敌人应该能够承受更多的伤害,因此,请考虑缩放。
为了支持敌人可以遭受伤害,请添加一个公共ApplyDamage方法,该方法将从运行状况中减去其参数。我们假设损害不是负的,所以需要断言。
当敌人的生命值达到零时,我们不会立即销毁它。而是在GameUpdate开始时检查健康状况是否耗尽,如果是则终止运行。
这样做可以使所有塔楼同时有效地开火,而不是按顺序进行切换,以防万前面的塔摧毁了他们已经瞄准的敌人。
3.4 DPS(每秒伤害)
现在我们必须确定激光束会造成多大的损害。为此,将配置字段添加到Tower。由于激光束会造成持续的损坏,因此我们将其表示为每秒的损坏。在Shoot中,将其应用于目标的敌人组件,再乘以时间增量。
(每个塔每秒20伤害)
3.5 随机目标
因为我们总是在每个塔中选择第一个可用的目标,所以目标行为取决于物理引擎检查重叠碰撞器的顺序。这种依赖关系不好,因为我们不了解细节,无法控制它,而且看起来也很奇怪和不一致。它通常会导致集中起火,但并非总是如此。
与其完全受物理引擎的支配,不如给它添加一些随机性。我们通过增加我们能收到的点击量来达到这个目的,比如说100。这可能不足以让所有的潜在目标进入一个非常拥挤的游戏面板,但应该给我们足够的空间来改进目标行为。
现在,不再总是选择第一个潜在目标,而是从数组中选择一个随机元素。
(随机目标)
我们还可以使用其他目标选择标准吗?
当然,例如,你可以选择健康状况最低或最高的一个。或跟踪针对每个敌人的塔数,以集中火力或分散活力。或结合多个条件。但是,很难提出良好的定位目标的标准,并且比仅从每个塔中随机选择一个目标更好。
下一章,弹道。
本文翻译自 Jasper Flick的系列教程
原文地址:
https://catlikecoding.com/unity/tutorials
本文分享自微信公众号 - 壹种念头(OneDay1Idea)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
来源:oschina
链接:https://my.oschina.net/u/4589313/blog/4760804