Unity 安卓游戏开发学习手册(二)
在本章中,我们了解了 NavMeshes 和寻路。我们还进行了一些与人工智能相关的工作。这可能是最简单的人工智能类型之一,但追逐行为对所有类型的游戏都至关重要。为了利用这些功能,我们创建了一个敌方坦克。它追逐玩家并向他们开火以减少他们的得分。为了给玩家一些优势,我们给敌方坦克增加了生命值。玩家也可以射击敌方坦克以及目标来获得分数。我们还创建了一些生成点,这样每当一辆坦克被摧毁时,就会生成一辆新的。
原文:
zh.annas-archive.org/md5/2F967148E2CB27E3CC5D9AF5E1B4F678译者:飞龙
第五章:穿梭自如 - 路径寻找与人工智能
在上一章中,我们了解了相机和光照效果。我们在坦克大战游戏中添加了天空盒、灯光和阴影。我们创建了光照图来使我们的场景动态化。我们通过给坦克车头灯添加“饼干”效果来了解了投影仪。我们还通过为坦克创建了一个斑点阴影来了解了投影仪。我们还为坦克创建了一个涡轮增压功能。通过调整相机的视角,我们能够让坦克看起来比实际速度快得多。当我们完成这一章时,我们已经拥有了一个动态且令人兴奋的场景。
本章将全面介绍敌人。玩家将不能仅仅待在一个地方来积累分数。我们将向游戏中添加一个敌方坦克。通过使用 Unity 的 NavMesh 系统,坦克将能够进行路径寻找并追逐玩家。一旦发现玩家,坦克就会射击并减少玩家的得分。
在本章中,我们将涵盖以下主题:
-
NavMesh
-
NavMeshAgent
-
路径寻找
-
追逐和攻击 AI
-
出生点
我们将对第 四章《设置舞台 - 相机效果与光照》中的坦克大战游戏进行修改,所以加载它,我们可以开始。
理解人工智能与路径寻找
如你所猜测的,AI 是 人工智能。在最广泛的意义上,这是任何非生命体可能做的,使其看起来像是在做决定。你对此概念最熟悉的可能来自视频游戏。当一个不由玩家控制的角色选择一个武器和一个使用它的目标时,这就是 AI。
在其最复杂的形式中,人工智能试图模仿完整的人类智能和学习。然而,对于这一切真正成功来说,发生的事情仍然太多太快。视频游戏无需达到这一步。我们主要关注的是让我们的角色看起来智能,但仍然能被玩家征服。通常,这意味着不允许角色根据比真实玩家更多的信息采取行动。调整角色拥有和可以采取行动的信息量是调整游戏难度的一个好方法。
路径寻找 是 AI 的一个子集。我们一直在使用它,尽管你可能从未意识到。路径寻找正如其名,是寻找路径的行为。每次你需要找到两点之间的路时,你都在进行路径寻找。就我们的角色而言,最简单的路径寻找形式是直接向目标点直线前进。显然,这种方法在开阔平原上最有效,但当遇到任何障碍物时往往会失败。另一种方法是给游戏覆盖一个网格。使用网格,我们可以找到一个绕过任何障碍物并到达我们目标的路径。
作为路径查找的替代方法,或许最常被选择的一种是使用特殊的导航网格,即 NavMesh。这只是一个玩家永远看不到的特殊模型,但它覆盖了计算机角色可以移动的所有区域。然后以类似于网格的方式导航玩家;不同之处在于,这里使用的是网格的三角形,而不是网格的方形。这就是我们在 Unity 中将使用的方法。Unity 提供了一套很好的工具用于创建和利用 NavMesh。
NavMesh
在 Unity 中创建导航网格非常简单。这个过程与我们用于制作光照图的过程类似。我们只需标记一些要使用的网格,调整特殊窗口中的某些设置,然后点击一个按钮。所以,如果你还没有加载 Unity 中的坦克大战游戏,现在就加载它,我们可以开始操作了。
Unity 可以自动从场景中存在的任何网格生成 NavMesh。为此,首先需要将网格标记为静态,就像我们对光照图所做的那样。然而,我们并不希望或需要能够导航城市的屋顶,因此我们使用一组特殊的设置列表来指定每个对象将是什么类型的静态。让我们从以下步骤开始:
-
从层次结构窗口中选择城市,并在检查器窗口中点击Static右侧的向下箭头:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_01.jpg
我们可以查看以下静态对象的可用选项:
-
无:此选项用于快速取消选中所有其他选项。如果所有其他选项都未被选中,此选项将被选中。
-
一切:使用此选项,你可以快速选择所有其他选项。当所有选项都被选中时,此选项也将被选中。检查器窗口中Static标签旁边的复选框与选中或取消选中一切复选框执行相同的功能。
-
光照图静态:在处理光照图时,需要选中此选项才能使它们正常工作。任何未勾选此选项的网格将不会被光照图处理。
-
遮挡静态:这是用于处理遮挡的选项。遮挡是一种运行时优化方法,只渲染实际上可以看到的对象,无论它们是否在摄像机的视图空间内。遮挡物是会阻止其他对象被看到的对象。它与被遮挡静态选项一起工作。此选项的最佳对象选择是大型且实心的。
-
批量静态:这是另一个运行时优化的选项。批量渲染是将对象组合在一起然后再渲染它们的操作。它大大提高了游戏的整体渲染速度。
-
导航静态:这是我们目前主要关心的选项。任何勾选此选项的网格在计算 NavMesh 时将被使用。
-
遮挡对象静态:正如刚才提到的,这个选项与遮挡器静态配合使用,以实现遮挡的好处。遮挡对象是会被其他对象遮蔽的物体。当被遮挡器覆盖时,这个物体将不会被绘制。
-
离网格链接生成:这个选项同样与 NavMesh 计算有关。离网格链接是 NavMesh 两个不物理连接部分之间的连接,例如屋顶和街道。使用导航窗口中的几个设置和此选项,链接会自动生成。
-
反射探针静态:最后一个选项允许物体被反射探针记录。这些探针记录它们周围的一切,并生成一个可以用作反射着色器的立方体贴图。
-
-
为了使 NavMesh 正常工作,我们需要更改设置,以便只能导航城市的街道。你上次看到坦克从建筑物屋顶跳下或掉下来是什么时候?因此,我们需要更改静态选项,使得只有街道勾选了导航静态。这可以通过以下两种方法之一完成:
-
第一种方法是我们逐一取消要更改的每个对象的选项。
-
第二种方法是,在层级窗口中取消勾选顶级对象的导航静态选项,当 Unity 询问是否要对所有子对象进行更改时,回答“是”。然后,只需对我们希望导航的对象重新勾选该选项。
-
-
现在,通过转到 Unity 的工具栏,点击窗口,然后点击菜单底部的导航来打开导航窗口。以下屏幕截图显示了制作 NavMesh 的所有工作发生的地方:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_02.jpg
-
这个窗口由三个页面和众多设置组成:
当选择一个对象时,设置将出现在对象页面上。这两个复选框直接对应于我们刚才设置的同名的静态选项。在导航区域的下拉列表中,我们可以将 NavMesh 的不同部分分组。这些组可以用来影响路径查找计算。例如,可以设置汽车只在地面上行驶,而人类可以沿着人行道区域行走。
烘焙页面是我们感兴趣的页面;它充满了改变 NavMesh 生成方式的选项。它甚至包括了一个很好的可视化表示,展示了各种设置在顶部:
-
角色半径:这应该设置为最瘦的角色的大小。它用于防止角色走得太靠近墙壁。
-
角色高度:这是你的角色的高度。利用这一点,Unity 可以计算出并移除那些对他们来说太低而无法通过的区域。任何低于这个值的区域都被认为是太小,因此应该将其设置为你的最矮角色的高度。
-
最大坡度:在计算 NavMesh 时,任何比这个值更陡的斜坡都会被忽略。
-
步高:在使用楼梯时,必须使用这个值。这是角色可以踏上的楼梯的最大高度。
-
掉落高度:这是角色能够掉落的高度。有了这个设置,路径将包括从边缘跳下,如果这样做更快的话。
-
跳跃距离:使用这个值,角色可以在.NavMesh 的缺口处跳跃。这个值表示可以跳跃的最远距离。
-
手动体素大小/体素大小:勾选手动体素大小复选框,你可以调整体素大小的值。这是.NavMesh 的细节级别。值越低,与可见网格的匹配度越高,但计算时间会更长,存储所需的内存也更多。
-
最小区域面积:如果.NavMesh 的部分小于这个值,那么在最终的.NavMesh 中将不会使用这些部分。
-
高度网格:勾选此选项后,原始高度信息将在.NavMesh 中保持不变。除非你有特殊需要,否则这个选项应该保持关闭。系统计算需要更长的时间,存储也需要更多的内存。
第三页区域允许我们调整我们定义的每个区域的移动成本。本质上,我们的游戏世界中不同部分的移动难度如何?对于汽车,我们可以调整层次,使其在田野中移动的成本是道路上的两倍。
在窗口底部,我们有以下两个按钮:
-
清除:这个按钮移除之前创建的.NavMesh。使用这个按钮之后,你需要在再次使用路径查找之前重新烘焙.NavMesh。
-
烘焙:这个按钮开始工作并创建.NavMesh。
-
-
我们的城市非常简单,所以默认值对我们来说已经足够适用。点击烘焙,并观察右下角的进度条。完成之后,会出现一个蓝色网格。这就是.NavMesh,它表示角色可以移动的所有区域。
提示
可能你的坦克在移动时会稍微穿过后建筑物墙壁。如果发生这种情况,请在导航窗口中增加代理半径,直到它们不再这样做。
-
我们还需要做最后一件事。我们的导航网格.NavMesh 很完美,但如果你仔细观察,会发现它穿过了城市中心的喷泉。如果敌方坦克开始从喷泉中驶过,那就太不对了。要修复这个问题,首先选择围绕喷泉形成的网格。
-
在 Unity 的工具栏中,点击组件,然后是导航,最后是导航网格障碍。这仅仅添加了一个告诉导航系统在寻找路径时绕道的组件。由于我们已经选择了墙壁,新组件的大小将自动适应;我们只需要从形状下拉列表中选择胶囊。你可以在场景视图中看到一个线框圆柱体表示它。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_03.jpg
我们创建了 NavMesh。我们利用了导航窗口和静态选项,告诉 Unity 在计算 NavMesh 时要使用哪些网格。Unity 团队投入了大量工作,使得这个过程快速而简单。
记住,在第三章 任何游戏的支柱 - 网格、材质和动画 中,当挑战是为玩家创建障碍时,我们鼓励你创建额外的网格,比如坦克陷阱和瓦砾。让敌方坦克也驶过这些障碍是个糟糕的想法。因此,尝试将这些设置为导航系统的障碍,就像对喷泉所做的那样。
NavMeshAgent 组件
你可能会想,我们有了 NavMesh 是很好,但是没有角色来导航它。在本节中,我们将开始创建我们的敌方坦克。在我们可以进行任何 AI 编程之前,我们需要导入并进行一些设置。使用这些步骤,我们可以创建它:
-
从本章的起始资源中选择
Tanks_Type03.png和Tanks_Type03.blend,并将它们导入到Models文件夹下的Tanks文件夹中。 -
Unity 导入完成后,在项目窗口中选择新的坦克,并在检查器窗口中查看它。
-
这个坦克没有动画,所以可以将动画类型设置为无,并分别从骨骼和动画页面取消选中导入动画。
-
将坦克从项目窗口拖到场景窗口;任何街道上的清晰区域都可以。
-
首先,在场景视图中将模型重命名为
EnemyTank。 -
现在,我们需要改变坦克的父子关系,以便炮塔可以转动,炮管跟随,就像我们对玩家坦克所做的那样。为此,创建一个空的游戏对象,并将其重命名为
TurretPivot。 -
将
TurretPivot定位到炮塔底部。 -
在层次结构窗口中,将
TurretPivot拖放到EnemyTank上,使EnemyTank成为它的父对象。 -
接下来,再创建一个空的游戏对象,并将其重命名为
CannonPivot。 -
CannonPivot游戏对象必须设置为TurretPivot的子对象。 -
在层次结构窗口中,将炮塔网格设置为
TurretPivot的子对象,将炮管网格设置为CannonPivot的子对象。当 Unity 询问你是否确定要断开预制件连接时,一定要点击是。 -
这个坦克模型有点大,因此需要在检查器窗口中调整坦克的导入设置中的缩放因子为
0.6,以便得到一个与玩家坦克大小相似的坦克。 -
为了让坦克在我们的新 NavMesh 上导航,我们需要添加一个NavMeshAgent组件。首先,在层次结构窗口中选择
EnemyTank,然后导航到 Unity 的工具栏,选择组件 | 导航 | Nav Mesh Agent。在检查器窗口中,我们可以看到新组件及其相关设置,如下面的截图所示:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_04.jpg所有这些设置让我们可以控制 NavMeshAgent 与游戏世界的交互方式。让我们看看每个设置的作用:
-
半径:这仅表示智能体的大小。结合我们在导航窗口中设置的半径值,可以防止对象部分进入墙壁和其他智能体中。
-
高度:此设置影响编辑器中围绕智能体的圆柱体。它仅设置角色的高度,并影响他们可能能够走下的悬垂部分。
-
基座偏移:这是附加到智能体的碰撞体的垂直偏移量。它允许你调整NavMeshAgent组件认为的角色底部位置。
-
速度:NavMeshAgent组件在拥有路径时自动移动连接的对象。此值决定了对象每秒沿路径移动的单位距离。
-
角速度:这是智能体每秒可以转动的度数。人的角速度会非常高,而汽车的角速度会较低。
-
加速度:这是智能体每秒增加的速度单位数,直到达到其最大容量。
-
停止距离:这是从目标目的地开始,智能体将开始减速并停止的距离。
-
自动刹车:勾选此选项后,由于大多数游戏的帧率通常平均在 60 到 90 FPS 之间,导致不规则帧率,智能体到达目的地时会立即停止,而不会超出目标。
-
避障质量/优先级:质量表示智能体在寻找绕过障碍物的平滑路径时付出的努力程度。质量越高,寻找路径的努力越大。优先级选项决定了谁有先行权。值高的智能体将绕过值低的智能体。
-
自动穿越非网格链接:勾选此选项后,智能体在进行路径寻找时会使用非网格链接,例如跳跃间隙和从边缘跌落。
-
自动重新寻路:如果找到的路径由于任何原因不完整,此复选框允许 Unity 自动尝试寻找新路径。
-
区域遮罩:还记得之前在讨论导航窗口时提到的区域吗?这里我们可以设置智能体能穿越哪些区域。只有在此列表中勾选的区域才会被智能体用于路径寻找。
-
-
现在我们理解了这些设置,让我们来使用它们。对于敌人坦克,Radius设置为
2.4,Height设置为4将会很好。你应该能够在场景窗口中看到另一个线框圆柱体,那是我们的敌人坦克。 -
需要做的最后一件事是将
EnemyTank转变为预制体。就像我们对目标所做的那样,通过从层次结构窗口中拖拽它,并将其放置在项目窗口中的Prefabs文件夹里。
在这里,我们创建了一个敌人坦克。我们还了解了NavMeshAgent组件的设置。但是,如果你现在尝试运行游戏,似乎什么也不会发生。这是因为NavMeshAgent组件没有被指定一个目的地。我们将在下一节解决这个问题。
让敌人追踪玩家
我们下一个任务是让我们的敌人坦克追踪玩家。为此我们需要两个脚本。第一个脚本将简单地广播玩家的当前位置。第二个脚本将使用这个位置和我们之前设置的NavMeshAgent组件找到通往玩家的路径。
揭示玩家的位置
使用一个非常简短的脚本,我们可以轻松地让所有敌人知道玩家的位置。创建它的几个简短步骤如下:
-
首先,在项目窗口的
Scripts文件夹中创建一个新的脚本。将其命名为PlayerPosition。 -
这个脚本将从单一的静态变量开始。这个变量将简单地保存玩家的当前位置。由于它是静态的,我们可以很容易地从其他脚本访问它。
public static Vector3 position = Vector3.zero;注意
我们选择在这里使用静态变量,因为它的简单性和速度。另外,我们也可以为敌人坦克增加几个额外步骤;它可以在游戏开始时使用
FindWithTag函数实际找到玩家坦克并将其存储在一个变量中。然后,在寻找玩家位置时查询该变量。这是我们可以采取的多种方法中的另一种。 -
在接下来的几行代码中,我们将使用
Start函数。这个函数在场景首次加载时自动调用。我们使用它是为了让position变量在游戏开始时就能填充并使用。public void Start() { position = transform.position; } -
代码的最后一段只是简单地更新了每一帧中的
position变量,使其等于玩家的当前位置。我们还选择在LateUpdate函数中这样做,以便在玩家移动后再更新。LateUpdate函数在每一帧的末尾被调用。这样,玩家可以在Update函数中移动,而他们的位置会在稍后更新。public void LateUpdate() { position = transform.position; } -
对于这个脚本需要做的最后一件事是将其添加到玩家的坦克中。因此,回到 Unity,将脚本从项目窗口拖放到坦克上,以添加它作为一个组件,就像我们对所有其他脚本所做的那样。
在这里,我们创建了追逐 AI 所需的首个脚本。这个脚本只是用一个变量更新玩家的当前位置。我们将在下一个脚本中使用它,让敌方坦克四处移动。
追逐玩家
我们下一个脚本将控制我们简单的追逐 AI。由于我们使用了NavMesh和NavMeshAgent组件,我们可以将路径查找的大部分困难部分留给 Unity。通过执行以下步骤来创建脚本:
-
再次,创建一个新的脚本。这次,将其命名为
ChasePlayer。 -
这个脚本的第一行保存了之前设置的NavMeshAgent组件的引用。我们需要访问这个组件以便移动敌方坦克。
public NavMeshAgent agent; -
代码的最后一段首先确保我们有NavMeshAgent的引用,然后更新我们的目标目的地。它使用了之前设置的
PlayerPosition脚本的变量和NavMeshAgent的SetDestination函数。当我们告诉函数去哪里时,NavMeshAgent组件就会完成所有到达那里的艰苦工作。我们在FixedUpdate函数中更新我们的目标目的地,因为我们不需要在每一帧都更新目的地。如果有很多敌人,过于频繁地更新这可能会导致严重的滞后问题。FixedUpdate函数以固定的时间间隔被调用,并且比帧率慢,所以它非常合适。public void FixedUpdate() { if(agent == null) return; agent.SetDestination(PlayerPosition.position); } -
现在我们需要将脚本添加到我们的敌方坦克中。在项目窗口中选择预制体,并将脚本拖放到检查器面板中,位于NavMeshAgent组件下方。
-
确保像之前一样连接引用。将NavMeshAgent组件拖到检查器窗口中的Agent值。
-
现在运行游戏来尝试一下。无论敌人从哪里开始,它都会绕过所有建筑物,到达玩家的位置。当你在周围驾驶时,你可以看到敌人跟随。然而,敌方坦克可能会穿过我们的坦克,我们也可能会驾驶穿过它。
-
修复这个问题的第一步是添加一些碰撞器。使用组件菜单中的物理选项,为炮塔、底盘和每个TreadCase对象添加盒状碰撞器。炮管和履带不需要碰撞器。履带箱体已经覆盖了履带的区域,而炮管作为目标太小,无法被准确射击。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_05.jpg
注意
如果你是在场景视图中进行这些更改,请确保点击检查器窗口中的应用按钮,以更新根预制对象。
-
需要更改的最后一点是NavMeshAgent组件上的停止距离属性。当坦克交战时,它们会移动到射程内并开始开火。除非敌人小而脆弱,否则它们不会试图占据与敌人相同的空间。将停止距离设置为
10,我们将能够复制这种行为。追逐玩家
在本节中,我们创建了一个脚本,使NavMeshAgent组件(在本例中是我们的敌人坦克)追逐玩家。我们添加了碰撞器以防止我们驶过敌人。此外,我们调整了停止距离的值,以获得更好的坦克行为。
尝试为敌人坦克添加一个斑点阴影。这将使它有更好的视觉接地感。你可以直接复制为玩家坦克制作的那个。
被敌人攻击
如果没有一点冲突,游戏还有什么乐趣;是选择战斗至死还是宇宙毁灭的烦恼?每个游戏都需要某种形式的冲突来推动玩家寻求解决方案。我们的游戏将变成一场分数争夺战。之前,这只是涉及射击一些目标并获得一些分数。
现在,我们将使敌人坦克向玩家开火。每次敌人得分,我们都会减少玩家的分数。敌人将以与玩家开火类似的方式射击,但我们将使用一些基本的 AI 来控制方向和射击速度,并替换玩家的输入控制。这些步骤将帮助我们实现它:
-
我们将从名为
ShootAtPlayer的新脚本开始。在Scripts文件夹中创建它。 -
与我们所有的其他脚本一样,我们从两个变量开始。第一个变量将保存敌人坦克的最后位置。如果坦克在移动,它不会射击,因此我们需要存储其最后位置以查看它是否移动。第二个变量将是我们可以移动和射击的最大速度。如果坦克移动速度超过这个速度,它将不会开火。
private Vector3 lastPosition = Vector3.zero; public float maxSpeed = 1f; -
接下来的两个变量决定了坦克准备射击所需的时间。在每一帧都对玩家射击是不现实的。因此,我们使用第一个变量来调整准备射击所需的时间长度,第二个变量来存储射击将准备好的时间:
public float readyLength = 2f; private float readyTime = -1; -
下一个变量包含了炮塔旋转的速度值。当坦克准备射击时,炮塔不会旋转指向玩家。这给了玩家一个移开的机会。然而,我们需要一个速度变量,以防止炮塔在射击完毕后立即转向面对玩家。
public float turretSpeed = 45f; -
这里的最后三个变量引用了坦克其他部分的引用。
turretPivot变量当然是我们要旋转的炮塔的支点。muzzlePoint变量将被用作我们开火的大炮的起点。这些将和玩家坦克的使用方式相同。public Transform turretPivot; public Transform muzzlePoint -
对于脚本的第一个函数,我们将使用
Update函数。它首先调用一个函数来检查是否可以开火。如果我们能开火,将对readyTime变量进行一些检查。如果它小于零,说明我们还没有开始准备射击,并调用一个函数来进行准备。然而,如果它小于当前时间,说明我们已经完成了准备,并调用开火的函数。如果我们无法开火,我们首先调用一个函数来清除任何准备,然后将炮塔转向玩家。public void Update() { if(CheckCanFire()) { if(readyTime < 0) { PrepareFire(); } else if(readyTime <= Time.time) { Fire(); } } else { ClearFire(); RotateTurret(); } } -
接下来,我们将创建我们的
CheckCanFire函数。代码的第一部分检查我们是否移动得太快。首先,我们使用Vector3.Distance来查看自上一帧以来我们移动了多远。通过将距离除以帧的长度,我们能够确定我们移动的速度。接下来,我们用当前的位置更新lastPosition变量,以便为下一帧做好准备。最后,我们将当前速度与maxSpeed进行比较。如果我们在这帧中移动得太快,我们将无法开火,并返回一个false的结果:public bool CheckCanFire() { float move = Vector3.Distance(lastPosition, transform.position); float speed = move / Time.deltaTime; lastPosition = transform.position; if(speed > maxSpeed) return false; -
对于
CheckCanFire函数的另一半,我们将检查炮塔是否指向玩家。首先,我们将找到指向玩家的方向。通过从空间中任意给定点的位置减去第二个点的位置,我们将得到第一个点相对于第二个点的向量值。然后,我们将通过将y值设置为0来使方向扁平化。这样做是因为我们不希望上下看玩家。然后,我们将使用Vector3.Angle来找到指向玩家的方向和我们的炮塔前方方向之间的角度。最后,我们将比较角度与一个低值,以确定我们是否在看着玩家并返回结果:Vector3 targetDir = PlayerPosition.position – turretPivot.position; targetDir.y = 0; float angle = Vector3.Angle(targetDir, turretPivot.forward); return angle < 0.1f; } -
PrepareFire函数简单快捷。它仅仅将我们的readyTime变量设置为一个未来的时间点,那时坦克将准备好射击:public void PrepareFire() { readyTime = Time.time + readyLength; } -
Fire函数首先确保我们有一个从muzzlePoint射击的引用:public void Fire() { if(muzzlePoint == null) return; -
函数继续创建一个
RaycastHit变量来存储我们射击的结果。我们使用Physics.Raycast和SendMessage,就像在FireControls脚本中所做的那样,射击任何东西并告诉它我们击中了它:RaycastHit hit; if(Physics.Raycast(muzzlePoint.position, muzzlePoint.forward, out hit)) { hit.transform.gameObject.SendMessage("RemovePoints", 3, SendMessageOptions.DontRequireReceiver); } -
Fire函数最后通过清除射击准备来完成:ClearFire(); } -
ClearFire函数是另一个简单的函数。它将我们的readyTime变量设置为小于零,表示坦克没有准备开火:public void ClearFire() { readyTime = -1; } -
最后一个函数是
RotateTurret。它首先检查turretPivot变量,如果引用缺失则取消函数。这之后是寻找指向玩家的方向,正如我们之前所做的。通过将y轴设置为0来扁平化这个方向。接下来,我们将创建step变量以指定我们这一帧可以移动多少。我们使用Vector3.RotateTowards来找到一个比当前向前方向更接近指向目标的向量。最后,我们使用Quaternion.LookRotation创建一个特殊的旋转,使我们的炮塔朝向新方向。public void RotateTurret() { if(turretPivot == null) return; Vector3 targetDir = PlayerPosition.position – turretPivot.position; targetDir.y = 0; float step = turretSpeed * Time.deltaTime; Vector3 rotateDir = Vector3.RotateTowards( turretPivot.forward, targetDir, step, 0); turretPivot.rotation = Quaternion.LookRotation(rotateDir); } -
现在,回到 Unity,创建一个空的GameObject并将其重命名为
MuzzlePoint。将MuzzlePoint放置在炮管末端,就像我们对玩家所做的那样。 -
将
MuzzlePoint设置为炮管的子对象,并在Inspector窗口中将可能存在的任何Y轴旋转归零。 -
接下来,将我们新的
ShootAtPlayer脚本添加到敌方坦克中。此外,连接到TurretPivot和MuzzlePoint变量的引用。 -
最后,对于敌方坦克,在Inspector窗口中点击Apply按钮以更新预制体。
-
如果你现在玩这个游戏,你会看到敌人旋转以指向你,但我们的分数不会减少。这是由于两个原因。首先,坦克略微浮空。无论你将其放置在世界上的哪个位置;当你玩游戏时,坦克会略微浮空。这是由于
NavMeshAgent组件的工作方式。修复方法很简单;只需在Inspector窗口中将BaseOffset设置为-0.3。这调整了系统并将坦克放置在地面上。 -
分数没有变化的第二个原因是玩家缺少一个函数。为了解决这个问题,打开
ScoreCounter脚本。 -
我们将添加
RemovePoints函数。给定一个数值,这个函数简单地将玩家分数中那么多点数减掉:public void RemovePoints(int amount) { score -= amount; }提示
如果你的敌方坦克仍然无法击中玩家,可能是因为它太大,射击时越过了玩家。只需将坦克的炮管向下倾斜,这样当它向玩家射击时,也会指向玩家坦克的中心。
如果你看看右上角的分数计数器,当敌人靠近时分数会下降。记住,分数不会立即开始下降,因为敌人需要停止移动,准备好炮管,然后才能射击。
我们赋予了敌人攻击玩家的能力。新的ShootAtPlayer脚本首先检查坦克是否已经减速并且炮管是否对准了玩家。如果是这样,它将定期向玩家开火以减少他们的分数。如果玩家希望在游戏结束时还能留下一些分数,他们就需要不停地移动并快速瞄准目标。
如果你不密切关注你的得分,就很难判断你是否正在被攻击。我们将在未来的章节中处理爆炸效果,但即便如此,玩家需要一些反馈来了解发生了什么。大多数游戏会在玩家被击中时在屏幕上闪烁红色纹理,不管是否有爆炸效果。尝试创建一个简单的纹理,并在玩家被击中时在屏幕上绘制半秒钟。
攻击敌人
当玩家面对一个无法对抗的敌人时,他们往往会很快感到沮丧。因此,我们将赋予玩家伤害和摧毁敌人坦克的能力。这将以与射击目标类似的方式运作。
削弱敌人的最简单方法就是给它们一些生命值,当它们被击中时生命值会减少。然后当它们的生命值耗尽时,我们可以摧毁它们。让我们按照以下步骤创建一个脚本来实现这一点:
-
我们将从创建一个名为
Health的新脚本开始。 -
这个脚本相当简短,从一个变量开始。这个变量将跟踪坦克剩余的生命值。通过将默认值设置为
3,坦克在遭到摧毁前能够承受三次打击。public int health = 3; -
这个脚本也只包含一个函数,
Hit。与目标的情况一样,当玩家向它射击时,这个函数是由BroadcastMessage函数调用的。函数的第一行将health减少一个点数。下一行检查health是否小于零。如果是,通过调用Destroy函数并传递gameObject变量来摧毁坦克。同时我们也给玩家一些分数。public void Hit() { health--; if(health <= 0) { Destroy(gameObject); ScoreCounter.score += 5; } } -
真的就是这么简单。现在,在项目窗口中为
EnemyTank预制体添加新的脚本,它将更新场景中你当前所有的敌人坦克。 -
尝试这样做:向场景中添加几个额外的敌人坦克,观察它们跟随你并在你射击它们时消失。
这里,我们给敌人坦克设置了一个弱点,即生命值。通过创建一个简短的脚本,坦克能够追踪自己的生命值并在被击中时检测到。一旦坦克的生命值耗尽,它就会从游戏中移除。
现在我们有两个目标可以射击:一个是动画目标,另一个是坦克。然而,它们都用红色切片表示。尝试将指向坦克的切片设置为不同的颜色。你需要复制一个IndicatorSlice预制体,并更改IndicatorControl脚本,以便在调用CreateSlice和NewSlice函数时,它能知道应该使用哪种类型的切片。
作为进一步的挑战,一旦我们给一个生物体赋予生命值,玩家应该能够看到他们对它造成了多少伤害。有两种方法可以实现这一点。第一种是在坦克上方放置一组方块。然后,每次坦克失去生命值时,你将移除一个方块。第二种方法稍微复杂一些——在 GUI 中绘制一个条形图,并根据剩余的生命值改变其大小。为了使条形图在摄像机移动时保持在坦克上方,请查看文档中的Camera.WorldToScreenPoint。
生成敌人坦克
游戏初期拥有有限数量的敌人并不适合我们游戏的长久乐趣。因此,我们需要制作一些生成点。随着坦克的摧毁,这些生成点将使玩家保持警惕。
本节中我们将创建的脚本将保持游戏世界中充满玩家可能想要摧毁的敌人坦克:
-
本节我们需要另一个新脚本。创建后,将其命名为
SpawnPoint。 -
这个脚本从几个变量开始。第一个变量将保存对我们
EnemyTank预制体的引用。我们需要它来生成副本。public GameObject tankPrefab; -
第二个变量用于跟踪已生成的坦克。当坦克被摧毁时,我们将创建一个新的。通过这个变量,我们防止游戏因敌人过多而变得混乱。生成的坦克数量将等同于生成点的数量。
private GameObject currentTank; -
第三个变量用于设置玩家与生成坦克之间的距离,以防止坦克在玩家上方生成。如果玩家处于此距离之外,可以生成新坦克。如果玩家在范围内,则不会生成新坦克。
public float minPlayerDistance = 10; -
我们将使用的第一个函数是
FixedUpdate。它会先检查是否需要生成一个新的坦克。如果需要,它会调用SpawnTank函数来进行生成:public coid FixedUpdate() { if(CanSpawn()) SpawnTank(); } -
接下来,我们创建
CanSpawn函数。该函数的第一行检查我们是否已经有了一个坦克,如果有则返回false。第二行使用Vector3.Distance来确定玩家当前的距禨。最后一行将这个距离与玩家需要达到的最小距离进行比较,然后返回结果:public bool CanSpawn() { if(current != null) return false; float currentDistance = Vector3.Distance(PlayerPosition.position, transform.position); return currentDistance > minPlayerDistance; } -
最后一个函数
SpawnTank,首先检查tankPrefab引用是否已连接。如果没有东西可以生成,它就不能继续。第二行使用Instantiate函数来复制预制体。为了将其存储在我们的变量中,我们使用as GameObject以确保正确的类型。最后一行将新坦克移动到生成点的位置,因为我们不希望坦克在随机位置出现。public void SpawnTank() { if(tankPrefab == null) return; currentTank = Instantiate(tankPrefab) as GameObject; currentTank.transform.position = transform.position; }注意:
我们再次选择使用
Instantiate和Destroy函数来处理敌军坦克的创建和销毁,因为它们的简单性和速度。另外,我们也可以创建一个可用敌人列表。然后,每当玩家消灭一个敌人,我们可以将其关闭(而不是完全销毁),只需将一个旧的移动到需要的位置(而不是创建一个新的),重置旧坦克的状态,并重新激活它。编程任何事情都会有多种方法,这只是其中一种替代方案。 -
返回 Unity,创建一个空的GameObject,并将其重命名为
SpawnPoint。 -
向其添加我们刚刚创建的
SpawnPoint脚本。 -
接下来,选择出生点,通过将
EnemyTank预制体从Prefabs文件夹拖拽到相应的值上,连接预制体引用。 -
现在,将
SpawnPoint对象通过从Hierarchy窗口拖拽并放入Prefabs文件夹中,将其转变为预制体。 -
最后,用新的点来填充城市。在每个角落放置一个会工作得很好。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_05_08.jpg
在这里,我们为游戏创建了出生点。每个点都会生成一辆新坦克。当一辆坦克被摧毁时,在出生点会创建一辆新的。随意构建游戏并在你的设备上尝试。这一节和这一章现在完成了,准备收尾。
为每辆坦克设置一个出生点是很好的,直到我们想要很多坦克,或者希望它们都从同一个位置出生。这里的挑战是,你需要让一个出生点跟踪多辆坦克。如果任何一辆坦克被摧毁,应该创建一辆新的。你肯定需要一个数组来跟踪所有坦克。此外,你还可以为出生过程实现一个延迟,这样就不会有多个坦克堆叠在同一个位置出生。这可能导致它们突然跳跃,因为NavMeshAgent组件会尽力防止它们占据同一空间。另外,玩家可能会认为他们只在与一辆坦克战斗,而实际上在同一个位置可能有几辆坦克。
既然你已经拥有所需的知识和工具,作为一个进一步的挑战,尝试创建其他类型的敌军坦克。你可以尝试改变大小和速度。它们也可以有不同的强度,或者你可以让摧毁敌军坦克时获得更多分数。也许,有一辆坦克实际上是在玩家射击时给玩家加分。尽情地玩这个游戏,享受其中的乐趣。
总结
在本章中,我们了解了 NavMeshes 和寻路。我们还进行了一些与人工智能相关的工作。这可能是最简单的人工智能类型之一,但追逐行为对所有类型的游戏都至关重要。为了利用这些功能,我们创建了一个敌方坦克。它追逐玩家并向他们开火以减少他们的得分。为了给玩家一些优势,我们给敌方坦克增加了生命值。玩家也可以射击敌方坦克以及目标来获得分数。我们还创建了一些生成点,这样每当一辆坦克被摧毁时,就会生成一辆新的。就整体游戏玩法而言,我们的坦克大战游戏基本上已经完成。
在下一章中,我们将创建一个新游戏。为了探索移动平台的一些特殊功能,我们将制作一个猴子球游戏。我们将几乎从屏幕上移除所有按钮,转而使用新的控制方法。我们将利用设备的倾斜传感器作为我们的转向方式。此外,我们还将使用触摸屏来摧毁敌人或收集香蕉。
第六章:移动设备的特性——触摸和倾斜
在上一章中,我们学习了路径查找和人工智能。我们将坦克大战游戏扩展到了包括敌方坦克。我们为它们创建了生成点,并让它们向玩家射击。此外,玩家获得了摧毁坦克的能力。一旦坦克被摧毁,玩家将获得一些分数,并且会生成新的敌方坦克。
在本章中,我们将通过探索移动设备的某些特性来开发一个新游戏。我们将创建一个猴子球游戏。玩家将控制一个超大仓鼠球中的猴子,尝试在时间耗尽前到达迷宫的终点,同时收集香蕉。为了移动,他们将不得不倾斜移动设备。为了收集香蕉,玩家将不得不触摸屏幕上香蕉所在的位置。
在本章中,我们将涵盖以下主题:
-
触摸控制
-
倾斜控制
-
猴子球游戏
我们将为本章创建一个新项目,因此启动 Unity,我们将开始。
设置开发环境
与每个项目一样,我们需要做一些准备工作以准备我们的开发环境。别担心,本章的设置简单直接。让我们按照以下步骤进行操作:
-
第一步当然是启动 Unity 并创建一个新项目。它应该是一个 3D 项目,将其命名为
Ch6_MonkeyBall会很合适。 -
当 Unity 完成初始化后,这是设置我们构建设置的完美时机。打开构建设置窗口,从平台列表中选择Android,然后点击切换平台以改变目标平台。
-
当你处于构建设置窗口时,选择玩家设置以在检查器中打开玩家设置。调整公司名称、产品名称,最重要的是捆绑标识符。
-
当用户倾斜他们的设备时,当新的一边成为底部时,整个屏幕将调整其方向。由于整个游戏都是围绕倾斜设备设计的,因此在玩家游戏过程中屏幕方向可能会随时改变,从而破坏他们的游戏体验。因此,在玩家设置中,找到分辨率和展示部分,确保默认方向没有设置为自动旋转,这会导致 Unity 在我们玩游戏时改变游戏的方向。这里的其他任何选项都可以为我们所用。
-
我们需要创建几个文件夹以保持项目的组织性。在项目窗口中应创建
Scripts(脚本)、Models(模型)和Prefabs(预制体)文件夹。由于将来我们可能会有数十个关卡和地图,因此创建一个Scenes(场景)文件夹也是个好主意。 -
最后,我们必须为本项目导入资源。我们需要一个作为玩家的猴子、一个要收集的香蕉、一个示例地图和一些围栏。幸运的是,所有这些资源都已准备就绪,并包含在本章的初始资源中。将
Monkey.blend、Monkey.psd、Ball.psd、Banana.blend、Banana.psd、MonkeyBallMap.blend、Grass.psd、Fence.blend和Wood.psd导入到您刚才创建的Models文件夹中。
我们刚刚完成了本章项目的设置。再次强调,项目开始时的一点点努力将节省时间并避免后期出现挫折;随着项目规模的扩大,开始时的组织工作变得非常重要。
一个基本的环境
在我们深入探讨倾斜和触摸控制的乐趣之前,我们需要一个基本的测试环境。在使用新的控制方案时,最好在一个简单且易于控制的环境中工作,然后再引入真实关卡复杂性。让我们按照以下步骤创建我们的环境:
-
在 Unity 顶部,通过导航到GameObject | 3D Object选择Cube,创建一个新立方体,它将成为我们基本环境的基础。将其重命名为
Ground,以便我们可以跟踪它。 -
在检查器面板中将立方体的位置设置为每个轴上的
0,这样我们就可以围绕世界原点进行操作。同时,将它的X和Z的缩放在检查器中设置为10,为我们提供足够的空间来移动并测试我们的猴子。 -
接下来,我们需要一个名为
Fence的第二个立方体。这个立方体的位置值应为X的-5,Y的1,Z的0,以及X和Y的0.2缩放和Z的10。 -
在层次结构窗口中选择
Fence,您可以按键盘上的Ctrl + D来创建一个副本。我们需要总共四个,沿着我们的Ground立方体的每一边放置:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_01.jpg
现在我们有一个基本的测试环境,它将允许我们使用控制装置,而不必担心整个关卡的所有复杂性。一旦我们的控制装置在这个环境中按照我们想要的方式工作,我们将把我们的猴子引入到一个新的环境。
倾斜控制
现代移动设备提供了各种各样的内部传感器来检测并提供关于周围世界的信息。尽管你可能没有这样想过,但你一定非常熟悉用于打电话的麦克风和扬声器。还有用于连接互联网的 Wi-Fi 接收器和用于拍照的摄像头。此外,你的设备几乎肯定有一个磁力计,用于与 GPS 配合提供方向。
我们现在感兴趣的传感器是陀螺仪。这个传感器可以检测设备的局部旋转。一般来说,它是手机中众多传感器之一,用于确定设备在世界中的方向和移动。我们将使用它来控制我们的猴子。当用户左右倾斜设备时,猴子就会左右移动。当设备上下倾斜时,猴子就会前进和后退。通过这些步骤,我们可以创建一个脚本来让我们以这种方式控制猴子:
-
首先,创建一个新脚本并将其命名为
MonkeyBall。 -
我们第一个变量将保存一个对附加到球体的Rigidbody组件的引用。这是让我们实际让它滚动并与世界中的物体碰撞的关键:
public Rigidbody body; -
接下来的两个变量将让我们控制设备倾斜如何影响游戏中的移动。第一个将允许我们消除任何太小的移动。这让我们避免了来自环境或可能不是完全准确的传感器的随机移动。第二个将让我们在控制感觉过于迟缓或过快时,调整倾斜输入的大小:
public float minTilt = 5f; public float sensitivity = 1f; -
目前最后一个变量将跟踪设备被倾斜了多少。它迫使用户如果想要朝相反方向移动,就需要来回倾斜设备,以抵消移动:
private Vector3 totalRotate = Vector3.zero; -
我们这个脚本的第一个函数非常简短。为了从陀螺仪获取输入,我们首先必须打开它。我们将在
Awake函数中这样做,以便从游戏一开始就跟踪它:public void Awake() { Input.gyro.enabled = true; } -
我们脚本的下一个函数是
Update。它首先从陀螺仪获取rotationRate的值。这是一个每秒弧度的值,表示用户沿着每个轴倾斜设备的速度有多快。为了使它更容易理解,我们在将其存储在变量之前,将rotationRate的值乘以Mathf.Rad2Deg将其转换为每秒度数:public void Update() { Vector3 rotation = Input.gyro.rotationRate * Mathf.Rad2Deg;注意
当你将设备屏幕朝向你握在手中时,设备的x轴指向右边。y轴是垂直向上的,位于设备的顶部,而z轴则直接从屏幕中心指向你。
-
接下来,我们要确保每个轴上的移动足够大,以实际让我们的猴子移动。通过对每个值使用
Mathf.Abs,我们找到轴移动的绝对值。然后,我们将其与我们寻找的最小倾斜量进行比较。如果移动太小,我们在rotation变量中将其归零:if(Mathf.Abs(rotation.x) < minTilt) rotation.x = 0; if(Mathf.Abs(rotation.y) < minTilt) rotation.y = 0; if(Mathf.Abs(rotation.z) < minTilt) rotation.z = 0; -
最后,对于我们的
Update函数,我们通过将新移动添加到我们的totalRotate变量来跟踪新移动。为此,我们需要重新排列这些值。玩家期望能够将设备顶部向自己倾斜以向后移动,远离以向前移动。这是x轴移动,但与我们需要移动猴子相比,它从我们的设备中反方向输入,因此值前有负号。接下来,我们交换y和z轴的旋转,因为玩家将期望通过左右倾斜设备来左右移动,这是y轴移动。如果我们将其应用于猴子的y轴,他只能在原地旋转。因此,移动被视为每秒的速度而不是每帧的速度;我们需要乘以Time.deltaTime:TotalRotate += new Vector3(-rotation.x, rotation.z, -rotation.y) * Time.deltaTime; } -
目前最后一个函数是
FixedUpdate函数。在对刚体进行修改和处理时,最好在FixedUpdate中进行。刚体实际上是将我们连接到 Unity 物理引擎的部分,而且它只在这个函数中更新。我们在这里所做的就是给刚体添加一些扭矩,或者说旋转力。我们使用收集到的总量乘以我们的sensitivity来给玩家提供他们预期的控制速度:public void FixedUpdate() { body.AddTorque(totalRotate * sensitivity); } -
为了使用我们的新脚本,需要对球体进行一些修改。首先创建一个球体供我们操作;可以通过导航至游戏对象 | 3D 对象 | 球体找到。将其重命名为
MonkeyBall,并将其位置稍微设在我们地面方块之上。 -
接下来,给物体赋予
Ball.psd材质,这样我们就能看到它旋转而不仅仅是移动。材质的双色调特性将使我们能够轻松看到它在场景中滚动。 -
刚体组件可以通过在 Unity 顶部导航至组件 | 物理 | 刚体找到。添加一个新的刚体组件。
-
此外,将我们的
MonkeyBall脚本添加到球体上,并将新的刚体组件拖到检查器面板中的Body槽。 -
在这一点上,拥有Unity Remote尤为重要。将设备连接并运行Unity Remote,你可以拿起它来控制球体。随意调整敏感度和最小倾斜度,直到找到感觉自然的控制设置。由于设备、硬件以及所用架构的多样性,不同设备之间的倾斜速率可能很容易有所不同。然而,特别是在这个阶段,你必须找到现在适合你设备的设置,并在游戏更加完善后再考虑其他设备的兼容性。
-
如果你发现球体滚动时的视线不好,移动摄像头以获得更好的视角。但确保它继续沿着世界的z轴向前指。
-
当所有设置完成后,确保保存场景。将其命名为
MonkeyBall。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_02.jpg
我们利用陀螺仪为你提供了球的转向控制。通过测量玩家倾斜设备的方式,我们能够相应地给球添加运动。通过在简单地图上滚动,我们可以微调我们的控制,确保一切正常工作。
与相机一起跟随
为了让玩家真正感觉到他们正在控制球,相机需要跟随球移动。当地图和关卡变得比一个相机镜头能展示的更大更复杂时,这一点尤为重要。最简单的解决方案是将相机设置为球的子对象,但这会使它与球一起旋转,我们的控制也会变得混乱。所以,让我们按照以下步骤设置相机跟随球移动:
-
我们首先需要创建一个新的脚本,并将其命名为
CameraFollow。 -
这个脚本非常简单。它有一个单一变量来跟踪正在跟随的对象:
public Transform ball; -
脚本中唯一的函数是
LateUpdate函数。我们使用这个函数,因为它在所有其他内容有机会进行正常更新之后执行。脚本要做的就是移动到球的新位置:public void LateUpdate() { transform.position = ball.position; } -
为了使用这个脚本,我们需要一个新的空GameObject组件。将其命名为
CameraPivot。 -
将其定位在(大约)球的中心。这是实际上会移动以跟随球的位置。在这一点上,创建的GameObject不需要完美定位;它只需要足够接近,这样更容易对齐相机。
-
接下来,在层次结构窗口中找到主相机,并将其设置为
CameraPivot的子对象。 -
将主相机组件的X位置设置为
0。只要X保持为零,且相机继续沿着z轴相对向前指向,你就可以自由移动它以找到一个观察球的好位置。Y位置为2,Z位置为-2.5,X旋转为35也效果不错。 -
接下来,将
CameraFollow脚本添加到CameraPivot对象上。 -
最后,将场景中的
MonkeyBall拖拽到新的CameraFollow脚本组件的Ball槽中。然后,去试试看!!与相机一起跟随
现在我们有一个滚动的球和一个跟随它的相机。相机只是更新其位置以跟上球的步伐,但它作为一个效果非常好。作为玩家,我们肯定会感觉到我们正在控制球及其运动。
添加猴子
现在我们离球很近并且跟随它移动,我们需要一些更有趣的东西来观察。在本节中,我们将在球上添加猴子。此外,为了确保他不会被疯狂地旋转,我们将制作一个新的脚本来保持他直立。按照以下步骤进行操作:
-
创建一个新的空GameObject,并将其重命名为
MonkeyPivot。 -
将其设置为
MonkeyBall脚本的子对象,并将位置归零。 -
接下来,将猴子添加到场景中,并将其设置为
MonkeyPivotGameObject 的子对象。 -
为了更容易看到球内的猴子,我们需要让猴子稍微透明一些。选择
MonkeyBall并找到材质底部上的渲染模式(Rendering Mode)设置。将其更改为透明(Transparent),我们就能进行调整。 -
现在,点击反照率(Albedo)右侧的颜色选择器(Color Picker)框,并将A滑块,即 alpha 值,调整为
128;这样我们就能透视球体内部了。 -
缩放并移动猴子,直到他填满球体的中心。
提示
你也可以借此机会为猴子摆个姿势。如果展开层级(Hierarchy)窗口中的猴子,你将能够看到构成他骨骼的所有骨头。现在给他一个酷炫的姿势,将使我们的玩家在游戏中的体验更好。
-
目前我们的猴子和球体看起来很酷,但当我们实际播放时,猴子在球内晕头转向地旋转。我们需要打开
MonkeyBall脚本,修复他的旋转动作: -
首先,在脚本顶部我们需要两个新的变量。第一个将追踪我们刚才创建的空的GameObject。第二个将为我们提供更新猴子旋转的速度。我们希望看起来像是猴子在移动球体,所以他需要面向球体移动的方向。这里的速度是指他转向正确方向的速度:
public Transform monkeyPivot; public float monkeyLookSpeed = 10f; -
接下来,我们需要一个新的
LateUpdate函数。这会再次检查monkeyPivot变量是否真的为脚本所填充。如果没有,我们就无法进行其他操作:public void LateUpdate() { if(monkeyPivot != null) { -
我们首先需要弄清楚球体移动的方向。做到这一点最简单的方法是获取刚体(Rigidbody)组件的
velocity,即我们的 body 变量。它是一个Vector3,表示我们当前移动的速度和方向。由于我们不希望猴子指向上下,所以我们为零y轴移动:Vector3 velocity = body.velocity; velocity.y = 0; -
接下来,我们需要弄清楚猴子当前面向的方向。我们之前在使用坦克时已经使用过前进值。它只是我们在 3D 空间中面向的方向。同样,为了避免上下看,我们将y轴归零:
Vector3 forward = monkeyPivot.forward; forward.y = 0; -
为了避免移动时突然改变方向,并与帧率保持一致,我们必须计算一个
step变量。这是基于我们的速度和自上一帧以来经过的时间,这一帧我们可以旋转多少:float step = monkeyLookSpeed * Time.deltaTime; -
然后,我们需要通过使用
Vector3.RotateTowards找到一个新面向的方向。它包括我们当前面向的方向,接着是我们想要面向的方向以及两个速度。第一个速度指定了这一帧中角度可以改变多少,第二个指定了向量的大小或长度可以改变多少。我们不关心向量大小的变化,所以给它赋予零值:Vector3 newFacing = Vector3.RotateTowards(forward, velocity, step, 0); -
最后,通过将
newFacing向量传递给Quaternion.LookRotation来计算新的旋转,并将结果应用到猴子旋转上。这将使猴子面向移动方向,防止它与球一起旋转:monkeyPivot.rotation = Quaternion.LookRotation(newFacing); } } -
要使其工作,请将
MonkeyPivot对象拖放到MonkeyBall脚本组件上的Monkey Pivot槽中。猴子将旋转以面向球的移动方向,同时保持直立:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_05.jpg
我们刚刚完成了将猴子添加到球中的工作。通过给猴子一个酷炫的姿势,玩家会更多地将其作为一个角色来参与。然而,当猴子在球内疯狂旋转时,看起来有点奇怪,因此我们更新了脚本,使他能保持直立并面向球的移动方向。现在,它几乎看起来像是猴子在控制球。
保持猴子在板上
如果游戏中没有失败的风险,那还有什么乐趣?为了测试我们的猴子和倾斜控制,我们在基本环境周围设置了一个安全围栏,防止它们翻倒。然而,每个游戏都需要一点风险来增加刺激感。通过移除安全围栏,我们引入了翻倒和游戏失败的风险。但是,通常如果你掉落了,会有重试游戏的选择。为此,我们现在将创建一个传统上称为kill volume的区域。这只是一个在玩家掉入时重置玩家的区域。让我们按照以下步骤来创建它:
-
首先,创建一个新脚本并将其命名为
KillVolume。 -
这个脚本有一个单一变量。它将跟踪猴子球掉入后放置的位置:
public Transform respawnPoint; -
这个脚本还有一个单一函数
OnTriggerEnter。每当具有Rigidbody组件的对象进入触发器体积时,都会调用此函数。它接收进入的对象作为碰撞器:public void OnTriggerEnter(Collider other) { -
该函数简单地将进入体积的物体的位置更改为我们想要重新生成它的点的位置。我们游戏中唯一会移动的是猴子球,所以我们不需要担心检查进入的是什么。我们还设置了
velocity为zero,这样当玩家重新获得控制时,它就不会突然移动:other.transform.position = respawnPoint.position; other.attachedRigidbody.velocity = Vector3.zero; } -
接下来,我们需要一个名为
RespawnPoint的新空GameObject。 -
将此对象定位在我们球开始的大致位置。这是球在掉出场地后将被放置的点。
-
现在,创建另一个空的GameObject并将其命名为
KillVolume。当玩家掉入该对象时,它将捕捉并重置游戏。 -
将其位置设置为Y 轴的
-10,X 轴和 Z 轴的0。这将使其位于玩家将要到达的位置下方。对于未来关卡来说,重要的是这个体积位于玩家通常所在位置的下方。如果不是这样,他们可能会错过它,永远下落,或者突然跳回到起点,在前往他们应该到达的区域时穿过它。 -
我们需要给对象一个盒子碰撞器组件,并附加我们的
KillVolume脚本。 -
为了让 Unity 调用
OnTriggerEnter函数,我们需要勾选是触发器的选项。否则,它将与体积碰撞,玩家看起来就像是漂浮着。 -
接下来,我们需要使体积足够大,以便在玩家掉入时能够捕捉到他们。为此,将盒子碰撞器组件的大小设置为X 轴和 Z 轴的
100。 -
将层次结构窗口中的
RespawnPoint对象拖到检查器中的KillVolume脚本组件的重生点槽上。如果没有它,玩家在掉出地图后将无法返回。 -
最后,从我们的基础环境中删除
Fence立方体,这样我们就可以测试一下了。你可以移动球体,当它从地面方块掉落时,会撞击KillVolume并返回到RespawnPoint位置。保持猴子在板上
现在我们能够在玩家掉出地图时重置他们。重要的是要检测他们何时不再在地图上,并且在应该重置时不要打断他们。这就是为什么我们做得这么大,并将其放在关卡主要区域的下方。但是,将体积放置得太远低于游戏区域是一个坏主意,否则玩家在游戏重置之前会下落很长时间。
赢得或失去游戏
既然我们已经具备了移动和如果掉出地图就重置的能力,我们只需要找到一种方法来赢得或输掉游戏。这种类型的游戏传统上是根据你从地图一端移动到另一端的速度来判定的。如果你在计时器耗尽之前未能到达终点,那么游戏就结束了。让我们按照以下步骤为游戏创建一个终点线和计时器:
-
我们需要一个新的名为
VictoryVolume的脚本。 -
我们首先用一对变量来跟踪玩家的信息。如果玩家在限定时间内到达终点,第一个变量将被激活并展示给玩家。第二个变量只有在时间耗尽时才会显示:
public GameObject victoryText; public GameObject outOfTimeText; -
下一个变量将跟踪 GUI 中的
Text对象,以显示完成关卡剩余的当前时间:public Text timer; -
这个变量用于设置玩家完成关卡可用的时间,单位为秒。在为大型版本的游戏调整检查器面板时,最好让多人测试关卡,以便了解完成关卡需要多长时间:
public float timeLimit = 60f; -
脚本最后一个变量将简单地跟踪计时器是否能够倒计时。通过将其设置为
private并默认为true,计时器将从关卡加载的那一刻开始计时:private bool countDown = true; -
脚本第一个函数是
Awake,这是初始化的最佳位置。它只做一件事,就是关闭两个消息。稍后我们会根据玩家的表现开启相应的消息:public void Awake() { victoryText.SetActive(false); outOfTimeText.SetActive(false); } -
为了检测玩家是否越过终点线,我们将使用与
KillVolume脚本相同的OnTriggerEnter函数。不过,首先我们会检查是否仍在为玩家计时。如果我们不再为他们计时,那么他们肯定已经用完了时间并且失败了。因此,我们不应该让他们越过终点线并获得胜利:public void OnTriggerEnter(Collider other) { if(countDown) { -
接下来,我们开启告知玩家他们已经获胜的文本。我们总得让他们知道胜利了,现在就是合适的时候:
victoryText.SetActive(true); -
函数接下来要做的是本质上关闭猴子球的物理效果,防止它继续滚动。通过使用
attachedRigidbody,我们访问到与物体连接的Rigidbody组件,这是连接到 Unity 物理引擎的部分。然后,我们将它的isKinematic属性设置为true,基本上告诉它将由脚本控制,而不是由物理引擎控制:other.attachedRigidbody.isKinematic = true; -
最后,该函数停止计算玩家的剩余时间:
countDown = false; } } -
脚本的最后一个函数是
Update函数,它首先检查以确保计时器正在运行:public void Update() { if(countDown) { -
然后它从完成关卡剩余的时间中减去自上一帧以来的时间:
timeLimit -= Time.deltaTime; -
接下来,我们在屏幕上更新剩余的时间。屏幕上的文本必须是字符串形式,或者说是文字。像我们剩余的时间这样的数字并不是文字,所以我们使用
ToString函数将其转换为正确的数据类型以便显示。如果仅此而已,那也是可以的,但它会显示一堆玩家不会关心的额外小数位。因此,我们传递0.00给函数。我们告诉它当数字变成文字时,我们希望它具有的格式和有多少个小数位。这使得它对玩家更有意义,也更容易阅读:timer.text = timeLimit.ToString("0.00"); -
在检查玩家是否超时后,我们开启告知他们已经失败的文本,并关闭时间显示。同时我们也停止计时。如果他们已经超时,继续计时又有什么意义呢?
if(timeLimit <= 0) { outOfTimeText.SetActive(true); timer.gameObject.SetActive(false); countDown = false; } } } -
现在,我们需要回到 Unity,让这个脚本工作。首先创建一个新的空GameObject,并将其命名为
VictoryPoint。 -
它将需要三个子立方体。记住,你可以通过导航到GameObject | 3D Object | Cube来找到它们。
-
第一个方块应定位在X为
1,Y为1,Z为0的位置。此外,将其缩放为X为0.25,Y为2,Z为0.25。 -
第二个方块应具有与第一个相同的所有设置,除了X的位置为
-1,这会将它移动到对象的另一侧。 -
最后一个方块需要X为
0,Y为2.5,Z为0的位置。它的缩放比例需要设置为X为2.25,Y为1,Z为0.25。这这三个方块共同构成了一个基本外观的终点线,它将突出于游戏板的其他部分。 -
接下来,我们需要为 GUI 创建一些文本对象。通过导航到GameObject | UI | Text来创建三个对象。
-
第一个应命名为
Timer;这将处理显示,显示玩家到达终点线还剩多少时间。它需要锚定在左上角,Pos X为80,Pos Y为-20。它还需要宽度为130,高度为30。我们可以将默认文本更改为0.00,以便我们更好地了解在游戏中它的样子。字体大小为20和对齐方式为左中将为我们很好地定位它。 -
第二个文本对象应命名为
Victory;当玩家到达终点线时,它将显示消息。它需要锚定在居中,Pos X和Pos Y为0。它需要宽度为200和高度为60,这样我们就有足够的空间绘制消息。将默认文本更改为You Win!,将字体大小增加到50,并选择居中对齐,以便我们在屏幕中央获得一个清晰的大消息。 -
最后一个文本对象应命名为
OutOfTime;当玩家在计时器归零前未能到达终点时,它将显示消息。除了宽度需要设置为500以适应其更大的默认文本You Ran Out Of Time!外,它与其他对象共享所有相同的设置。 -
接下来,我们需要选择
VictoryPoint并为其添加BoxCollider组件,以及我们的VictoryVolume脚本。 -
BoxCollider组件需要勾选Is Trigger复选框。中心的X需要
0,Y需要1,Z需要0。此外,大小的X应为1.75,Y应为2,Z应为0.25。 -
最后,将我们刚刚创建的每个文本对象拖动到VictoryVolume脚本组件上的适当槽位。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_07.jpg
我们刚刚完成了一个设定,玩家可以通过这个设定赢得或输掉游戏。如果你现在尝试一下,你应该能在屏幕左上角看到计时器倒计时。当你及时到达终点线时,会显示一条好消息来提示你。如果你没能成功到达,则会显示另一条消息。
这是我们将为这款游戏创建的整个界面,但它仍然相当乏味。利用你在第二章中学到的技能,看起来不错——图形界面来设计界面。它应该看起来令人愉悦和兴奋,甚至可以是猴子主题的。为了使其更高级,你可以尝试设置它,让剩余时间接近零时改变颜色和大小,让玩家一眼就能看出完成该关卡剩余的时间。
终点线看起来也很单调,因为它只是由方块组成。尝试自己创建一个新的终点线。它可以在上面横幅上有一个终点线标志,就像比赛中的那样。也许它可以看起来更圆润一些。如果你想让它更高级,你可以考虑在终点线前面创建第二个计时器。这样玩家可以看着世界,他们的主要焦点在这里,并知道剩余的时间。
组装复杂的环境。
一个方块地图并不能提供很有趣的游戏体验。它非常适合我们设置控制,但玩家会觉得这并不有趣。因此,我们需要一些更好的东西。在这里,我们将设置一个更复杂的环境,包括斜坡、桥梁和弯道。我们还将使用一些围栏来帮助和引导玩家。让我们按照以下步骤进行:
-
首先,将
MonkeyBallMap模型添加到场景中。 -
将其缩放属性在每个轴上设置为
100,并将其位置属性在每个轴上设置为0。 -
如果地图看起来是白色的,那么为其应用
Grass纹理。这个地图为我们提供了一个良好的起点平台,一个半管斜坡,几个弯道,以及一座短桥。总的来说,玩家将面临许多基本挑战。 -
为了让我们的球能够实际使用这个地图,它需要一些碰撞器来使其具有物理特性。在层次结构窗口中展开
MonkeyBallMap,并选择FlatBits和HalfPipe。 -
在这些对象上添加一个网格碰撞器组件,就像我们为坦克城市的某些部分所做的那样。记住,可以通过导航到组件 | 物理 | 网格碰撞器来找到它。
-
接下来,我们有
Fence模型。通过这个模型,我们可以在边缘放置护栏或者在玩家路径中设置障碍来帮助或阻碍玩家。首先将Fence模型拖入场景,并将其缩放设置为100,以保持与地图的比例。 -
为了使围栏能够物理地阻挡玩家,它们需要一个碰撞器。对于两个子围栏对象,添加一个 BoxCollider 组件,可以通过导航到 组件 | 物理 | 盒碰撞器 来找到。
-
此外,如果围栏在场景中显示为白色,请确保你将
Wood文理应用到两个围栏部件上。 -
创建一个新的空 GameObject 并将其命名为
Fences。然后,将其 位置 属性在每一个轴上设置为0。这个对象将帮助我们保持组织有序,因为最终我们可能会有很多围栏部件。 -
现在,在 层次结构 窗口中展开
Fence模型,并使Post和PostWithSpokes成为Fences空的 GameObject 的子对象。然后,删除Fence对象。这样做,我们打破了预制体的连接,消除了重新创建它的风险。如果我们只是用Fence对象来组织,那么如果我们对原始模型文件进行更改,就有可能删除我们在场景中设置它们时所做的一切工作。 -
我们需要将围栏放置在战略位置,以影响玩家玩游戏的方式。我们可能想要放置它们的第一个地方是起始区域周围,为玩家提供一个游戏开始的良好安全环境。记住,你可以使用 Ctrl + D 来复制围栏部件,这样你就总会有足够的围栏。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_08.jpg
-
放置围栏的第二个地方是在半管之后,正好在桥前。在这里,它们可以帮助玩家在尝试过小桥之前重新定位自己:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_09.jpg
-
我们可以放置围栏的最后一个地方可能会阻碍玩家。如果我们把它们放在最后平台的中间,我们就会迫使玩家绕行,并在到达终点前冒着跌落的危险。
-
说到终点线,现在其他一切都已布置完毕,我们需要将其移至适当位置。将其放置在较低平台的末端。在这里,玩家必须面对地图上的所有挑战,并在最终达成胜利前多次冒着跌落的危险。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_10.jpg
这就是设置我们复杂环境的全部内容。我们让玩家有机会在强制他们导航一系列挑战并到达终点之前先定位自己。试一试吧。我们的游戏看起来真的很不错。
这里的第一个挑战可能相当明显。尝试自己制作一个带有坡道、桥梁、滑梯和障碍的地图。你可能可以用围栏制作一个大型迷宫。否则,你可以改变关卡,使其实际上要求玩家沿着一些直线路径和坡道向上,这意味着玩家需要足够速度来完成。可能还需要进行几次跳跃。让玩家沿着坡道下滑以获得速度,然后跳到另一个平台上。无论你的新关卡变成什么样,确保KillVolume在它的下方,并且覆盖足够大的区域。你永远不知道玩家会如何玩,以及他们会如何卡住自己。
地图本身看起来很不错,但周围的区域还需要加工。利用你之前章节学到的技能——为世界添加一个天空盒,比默认的看起来更好。同时,调整一下光线。一个单一的定向光不错,但不够有趣。创建一些光源模型放置在地图周围。然后,烘焙光照贴图以产生一些高质量的阴影。
添加香蕉
当涉及到猴子游戏时,玩家最明显要收集的物品就是香蕉。然而,仅仅在世界上拥有可收集的物品是不够的;我们还得向玩家展示这些物品是可以被收集的。通常,这意味着物品在旋转、弹跳、发光、产生火花或展示其他特殊效果。对于我们的游戏,我们将使香蕉在原地旋转的同时上下弹跳。下面是完成这个效果的步骤:
-
首先,我们需要一个新的脚本。创建一个并命名为
BananaBounce。 -
这个脚本从三个变量开始。第一个是香蕉上下移动的速度,单位是每秒米。第二个是香蕉从起始位置会移动多高。第三个是香蕉每秒在原地旋转多少度。这些变量共同使我们能够轻松控制和调整香蕉的运动:
public float bobSpeed = 1.5f; public float bobHeight = 0.75f; public float spinSpeed = 180f; -
下一个变量将跟踪实际移动的对象。通过使用两个对象来设置和控制香蕉,我们能够将位置和旋转分离,使一切变得更容易:
public Transform bobber; -
这个脚本的函数是
Update。它首先检查以确保我们的bobber变量已被填充。如果没有它,我们就无法进行操作使香蕉移动:public void Update() { if(bobber != null) { -
接下来,我们使用
PingPong函数为我们的香蕉计算一个新位置。这个函数会在零和传递给它的第二个值之间反弹一个值。在这个案例中,我们使用当前时间乘以我们的速度来确定在这场游戏中香蕉可能移动了多远。通过给它一个高度,我们得到一个从零到我们最大高度来回移动的值。然后我们将其乘以一个向上向量,并将其应用到我们的localPosition上,使香蕉能够上下移动:float newPos = Mathf.PingPong(Time.time * bobSpeed, bobHeight); bobber.localPosition = Vector3.up * newPos; } -
最后,我们使用之前用于旋转炮塔的同一个
Rotate函数,让香蕉在原地旋转。它会以我们设定的任何速度不断旋转。transform.Rotate(Vector3.up * Time.deltaTime * spinSpeed); } -
接下来,我们需要回到 Unity 并设置这些香蕉。为此,我们首先需要在场景中添加
Banana模型。如果它是白色的,确保为其添加Banana纹理。 -
要让新香蕉弹跳,需要添加我们的
BananaBounce脚本,否则它就不会在那里弹跳。 -
Banana的子对象需要放在我们脚本组件中的Bobber槽位上。 -
然后,把它变成一个预制体,在地图上散布一些:在初始区域放几个,在终点线附近放几个,沿途也放一些。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_11.jpg
如果你现在尝试游戏,你应该会看到有几个快乐弹跳的香蕉。通过使用Mathf.PingPong函数,我们很容易就能创建这种效果。如果没有它,我们需要做很多额外的计算来确定我们是向上还是向下移动以及移动了多远。
收集香蕉作为收藏品很棒,但现在哪个游戏只有一种拾取物品呢?尝试制作一些其他拾取物品的模型。最明显的就是香蕉束,比如你在杂货店可以买到的那些,或者是实际长在香蕉树上的大串香蕉。不过,你还可以选择硬币、能量水晶、古老猴子图腾、检查点、分数乘数,或者任何可能吸引你注意的东西。
使用触摸收集香蕉
现代移动设备最明显的特点之一就是触摸屏。设备使用用户的指尖电导性和许多微小的接触点来确定被触摸的位置。为了探索我们的游戏触摸界面的可能性,我们将让玩家戳屏幕上的香蕉,而不是跑过去收集它们。Unity 为我们提供了轻松访问触摸输入的方法。通过将输入与射线投射结合,就像我们之前让坦克开火一样,我们可以确定 3D 空间中被用户触摸的物体。对于我们来说,这意味着我们可以让玩家触摸并收集那些香蕉。要做到这一点,请按照以下步骤操作:
-
首先,我们需要一个新脚本。创建一个,并将其命名为
BananaTouch。 -
Update函数是这段脚本中唯一的函数。它首先检查玩家是否以任何方式触摸屏幕。Input类为我们提供了touchCount值,这只是一个计数器,用来记录当前触摸设备屏幕的手指数量。如果没有手指触摸,我们不想浪费时间做任何工作,所以我们会提前退出return:并准备好再次检查下一帧,看玩家是否触摸了屏幕。public void Update() { if(Input.touchCount <= 0) return; -
接下来,我们创建一个
foreach循环。这是一个将检查触摸列表中的每个项目的循环,但它不会跟踪触摸的索引。然后我们检查每个触摸的阶段,以判断它是否刚刚开始触摸屏幕。每个触摸都有五个可能的状态:开始,移动,静止,结束和已取消:foreach(Touch next in Input.touches) { if(next.phase == TouchPhase.Began) {这里是每个状态的描述:
-
开始:当用户首次触摸屏幕时,会进入此触摸阶段。
-
移动:当用户在屏幕上移动手指时,会进入此触摸阶段。
-
静止:此触摸阶段与上一个阶段相反;当用户的 finger 在屏幕上不移动时发生。
-
结束:当用户的手指离开屏幕时,会进入此触摸阶段。这是触摸完成的正常方式。
-
已取消:当跟踪触摸时发生错误时,会进入此触摸阶段。这种阶段通常在手指触摸屏幕但不移动一段时间后最常发生。触摸系统并不完美,所以它会假设错过了手指离开屏幕的动作,并取消该触摸。
-
-
接下来,我们创建一对变量。就像我们的坦克一样,第一个变量用于保存被我们的射线投射命中的对象。第二个是一个
Ray类型的变量,它只是一个用于存储空间中的一个点和方向向量的容器。ScreenPointToRay函数是相机专门提供的,用于将屏幕上 2D 空间的触摸位置转换为游戏世界中 3D 空间的位置:RaycastHit hit; Ray touchRay = Camera.main.ScreenPointToRay(next.position); -
函数的最后一步是调用
Raycast函数。我们将射线和跟踪变量传递给该函数。如果击中了对象,我们向它发送一个消息,告诉它已被触摸,就像用我们的坦克射击东西一样。此外,还需要几个花括号来结束if语句、循环和函数:if(Physics.Raycast(touchRay, out hit)) { hit.transform.gameObject.SendMessage("Touched", SendMessageOptions.DontRequireReceiver); } } } } -
在尝试之前,我们需要更新我们的
BananaBounce脚本,为其添加一些生命值,并在生命值耗尽时允许其被摧毁。所以,现在就打开它吧。 -
首先,我们需要一对变量。第一个是
health。实际上,这只是摧毁香蕉所需的触摸次数。如果我们有不同类型的香蕉,它们可以各有不同的生命值。第二个变量是香蕉移动速度的调节器。每次香蕉失去生命值,它的移动速度就会减慢,表明它还剩下多少生命值:public int health = 3; public float divider = 2f; -
接下来,我们需要添加一个新函数。这个
Touched函数将接收来自我们的BananaTouch脚本的消息。它的工作原理类似于我们用坦克射击的方式。它做的第一件事是减少剩余的生命值:public void Touched() { health--; -
在造成一些伤害之后,我们可以通过进行一些除法运算来减慢香蕉的移动速度。这样玩家就能轻松判断他们的触摸是否成功:
bobSpeed /= divider; spinSpeed /= divider; -
最后,函数会检查香蕉是否已经耗尽生命值。如果是,我们使用
Destroy函数来移除它,就像敌方坦克一样:if(health <= 0) { Destroy(gameObject);} } -
当你回到 Unity 时,需要将我们新的
BananaTouch脚本附加到MonkeyBall对象上。由于它的工作原理,它实际上可以放在任何对象上,但最好是将玩家控制脚本保持在一起,并放在它们所控制的对象上。 -
接下来,为其中一个香蕉添加一个球体碰撞器组件,你可以通过导航到组件 | 物理 | 球体碰撞器来找到它。如果我们对一个进行更改并更新预制体,场景中的所有香蕉都将被更新。
-
勾选是触发器复选框,这样香蕉就不会阻挡我们猴子的移动。它们仍然可以被触摸,同时允许我们的猴子穿过它们。
-
碰撞器还需要被放置在玩家在击中时通常会触摸的位置。因此,将中心设置为X的
0,Y的0.375,Z的0。此外,确保将半径设置为0.5。 -
最后,确保点击应用按钮,位于检查器面板右上角,以更新场景中的所有香蕉。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_06_12.jpg
现在尝试游戏,你应该能够触摸任何香蕉。最初,所有香蕉会像之前一样均匀地上下移动。当你触摸它们时,由于我们做的除法运算,你触摸的香蕉会移动得慢一些,然后最终消失。这让我们的玩家能够轻松地看出哪些香蕉被触摸过,哪些没有。
在游戏中拥有可收集物体之后,下一步是给玩家赋予意义。这通常是通过给它们一些积分值来实现的。在这里尝试这样做。它与我们之前摧毁敌方坦克时的积分系统非常相似。如果你之前创建了一些其他的收集物,你可以设置它们每个拥有不同的生命值。因此,它们也可以给你不同的积分。调整数字和设置,直到找到玩家互动起来会感到有趣的东西。
总结
在本章中,我们了解了现代移动设备的特点。我们创建了一个猴子球游戏来尝试这个功能。我们访问了设备的陀螺仪来检测它何时被旋转。这让我们的猴子能够被引导。在为玩家创建了一个更复杂、更有趣的运动环境后,我们创建了一串会原地旋转同时上下浮动的香蕉。我们还利用触摸屏让玩家能够收集香蕉。
在下一章中,我们将暂时放下我们的猴子球游戏。市场上最受欢迎的移动游戏之一,愤怒的小鸟,是一种独特且并不罕见的游戏类型。为了了解 Unity 中的物理学以及 2D 风格游戏的可能性,我们将制作一个愤怒的小鸟克隆版。我们还将探讨视差滚动,以帮助我们创建一个令人愉悦的背景。在你意识到之前,我们将创造出所有你一直希望玩到的愤怒的小鸟关卡。
第七章:重量级投掷——物理与 2D 摄像机
在上一章中,你了解了移动设备的特殊功能以及如何创建触摸和倾斜控制。我们还制作了一个 Monkey Ball 游戏来使用这些新控制。通过倾斜设备来控制球的方向,并通过触摸屏幕收集香蕉。我们还通过创建计时器和终点线,为游戏添加了一些胜利和失败的条件。
在本章中,我们将暂时放下 Monkey Ball 游戏,去探索 Unity 的物理引擎。我们还会看看创建 2D 游戏体验时可用的选项。为此,我们将重新制作市场上最受欢迎的移动游戏之一,愤怒的小鸟。我们将使用物理引擎来投掷小鸟并摧毁结构。我们还将看看如何创建一个关卡选择屏幕。
在本章中,我们将涵盖以下主题:
-
Unity 物理引擎
-
视差滚动
-
2D 管线
-
关卡选择
我们将为本章创建一个新项目,启动 Unity,让我们开始吧!
在 3D 世界中制作 2D 游戏
在游戏开发中,最鲜为人知的事实之一是可以在 3D 游戏引擎中,例如 Unity,制作 2D 风格的游戏。与其它所有事物一样,它也有自己的一套优势和劣势,但为了生成令人满意的游戏体验,这个选择可能是非常值得的。最显著的优势是可以为游戏使用 3D 资源。这使得动态光照和阴影可以轻松地包含在内。然而,在使用 2D 引擎时,任何阴影都需要直接绘制到资源中,而且很难使其具有动态效果。关于劣势,是在 3D 世界中使用 2D 资源。虽然可以使用它们,但为了达到所需的细节并防止其出现像素化,需要较大的文件大小。然而,大多数 2D 引擎都使用矢量艺术,这样无论图像如何缩放,其线条都能保持平滑。此外,可以为 3D 资源使用常规动画,但任何 2D 资源通常都需要逐帧动画。总的来说,对于许多开发者而言,优势已经超过了劣势,他们创造了大量外观精美的 2D 游戏,你可能永远也不会意识到这些游戏实际上是在 3D 游戏引擎中制作的。
为了满足开发者对 2D 游戏支持的不断增长的需求,Unity 团队一直在努力为 3D 引擎创建优化的 2D 流水线。创建项目时,你可以选择 2D 默认设置,优化资产以便在 2D 游戏中使用。尽管 Unity 仍然没有直接支持矢量图形,但许多其他功能已经优化,以便在 2D 世界中更好地工作。最大的功能之一是物理引擎的 2D 优化,我们将在本章重点讨论这个问题。我们将使用所有原则,这些原则同样适用于 3D 物理中,这将节省我们在设置和工作中的麻烦。
设置开发环境
为了探索在主要以 3D 引擎中制作 2D 游戏,以及物理的使用,我们将重新制作一个广受欢迎的 2D 游戏,愤怒的小鸟。然而,在我们深入游戏的核心之前,我们需要设置开发环境,以便我们为 2D 游戏创作进行优化。让我们使用以下步骤来完成此操作:
-
首先,我们需要在 Unity 中创建一个新项目,将其命名为
Ch7_AngryBirds非常合适。同时,我们还需要在 模板 下选择 2D,这样所有默认设置都会为我们的 2D 游戏做好准备。 -
我们还需要确保在 构建设置 字段中将目标平台更改为 Android,并将 捆绑标识符 设置为适当的值。我们不希望以后还要为此担心。
-
你会立即注意到一些不同之处。首先,在场景中移动时,你只能从左右和上下平移。这是一个可以在 场景 视图的顶部中间通过点击小 2D 按钮来切换的设置。此外,如果你在 层次结构 窗口中选择了相机,你可以看到它在 场景 视图中仅显示为一个白色盒子。这是因为它的 投影 设置默认为使用 正交 模式,你可以在 检查器 面板中看到这一点。
注意
每个相机都有两个关于如何渲染游戏的选项。透视相机利用物体与相机的距离来渲染一切,模仿现实世界;距离相机较远的物体绘制得比距离较近的物体小。正交相机在渲染时不考虑这一点;物体的绘制不会基于它们与相机的距离而缩放。
-
接下来,我们需要一个地面。因此,请转到 Unity 的菜单栏,导航到 GameObject | 3D Object | Cube。这将作为一个简单的地面非常合适。
-
为了让它看起来有点像地面,创建一个绿色材质,并将其应用到 Cube GameObject 上。
-
地面立方体需要足够大以覆盖我们的整个游戏区域。为此,将立方体的缩放属性设置为X轴上的
100,Y轴上的10,以及Z轴上的5。同时,将它的位置属性设置为X轴上的30,Y轴上的-5,以及Z轴上的0。由于沿x轴不会有任何移动,地面只需要足够大以供我们场景中的其他物体着陆即可。但是,它需要足够宽和高,以防止摄像机看到边缘。 -
为了优化我们 2D 游戏中地面的立方体,我们需要更改其碰撞器。在层次结构窗口中选择Cube GameObject,并在检查器面板中查看它。右键点击Box Collider组件,选择移除组件。接下来,在 Unity 顶部导航至组件 | 物理 2D | Box Collider 2D。这个组件的工作原理与普通的Box Collider组件一样,只是它没有深度限制。
-
目前,由于缺乏光线,地面看起来相当暗。在 Unity 的菜单栏中,导航至GameObject | Light | Directional Light,以向场景中添加一些亮度。
-
接下来,我们需要确保所有将在场景中飞行的物体不会移动得太远,从而引发问题。为此,我们需要创建一些触发器体积。最简单的方法是创建三个空的GameObjects,并为每个分配一个Box Collider 2D组件。确保勾选Is Trigger复选框,将它们转换为触发器体积。
-
将一个放在地面物体的每个端部,最后一个 GameObject 大约在 50 个单位的高度上。然后,将它们缩放以与地面形成一个盒子。每个的厚度都不应超过一个单位。
-
为了让体积实际上能够阻止物体移动得太远,我们需要创建一个新的脚本。创建一个新的脚本,并将其命名为
GoneTooFar。 -
这个脚本有一个单独的短函数,
OnTriggerEnter2D。我们使用这个函数来销毁可能进入该体积的任何物体。这个函数被 Unity 的物理系统用来检测物体何时进入触发器体积。我们稍后会详细介绍这一点,但现在,需要知道两个对象中的一个,要么是体积本身,要么是进入它的物体,需要有一个Rigidbody组件。在我们的例子中,所有我们可能希望它们进入触发器时移除的东西都将具有Rigidbody组件:public void OnTriggerEnter2D(Collider2D other) { Destroy(other.gameObject); } -
最后,回到 Unity 并将脚本添加到三个触发器体积对象上。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_07_01.jpg
我们已经为我们的 2D 游戏完成了初步设置。通过将项目类型从3D更改为2D,Unity 的默认设置会改变以优化 2D 游戏创作。最立即注意到的是,现在摄像机处于正交视图,使一切看起来都变得扁平。我们还为我们的场景创建了一个地面和一些触发器体积。这些将共同防止我们的鸟类和任何其他物体走得太远。
物理
在 Unity 中,物理模拟主要关注Rigidbody组件的使用。当Rigidbody组件附加到任何对象上时,它将被物理引擎接管。该对象将受到重力影响下落,并撞击任何带有碰撞器的物体。在我们的脚本中,使用OnCollision函数组和OnTrigger函数组至少需要将Rigidbody组件附加到两个交互对象中的一个。然而,Rigidbody组件可能会干扰我们可能让对象进行的任何特定移动。但是,Rigidbody组件可以被标记为运动学,这意味着物理引擎不会移动它,只有当我们的脚本移动它时,它才会移动。我们用于坦克的CharacterController组件是一个特殊的、修改过的Rigidbody。在本章中,我们将大量使用Rigidbody组件,将我们的鸟类、块和猪与物理引擎连接起来。
构建块
我们将创建的第一个物理对象是猪城堡所使用的块。我们会创建三种类型的块:木头、玻璃和橡胶。通过这些简单的块,我们可以轻松构建多种关卡和结构,供鸟类撞击破坏。
我们将创建的每个块在很大程度上都相似。因此,我们将从基本的木制木板开始,然后在此基础上创建其他类型的块。让我们按照以下步骤来创建这些块:
-
首先,我们将创建一个木制木板。为此,我们需要另一个立方体。将其重命名为
Plank_Wood。 -
将木板的缩放值设置为X 轴的
0.25和Y 轴和 Z 轴的2。它在x和y轴上的缩放定义了玩家看到的大小。在z轴上的缩放有助于确保它会被场景中的其他物理对象击中。 -
接下来,使用
plank_wood纹理创建一个新材质,并将其应用到立方体上。 -
为了将这个新的木制木板转变为适合我们游戏的物理对象,我们需要移除立方体的Box Collider组件,并替换为Box Collider 2D组件。同时,添加一个Rigidbody组件。确保你的木板被选中;在 Unity 的菜单栏中,导航到组件 | 物理 2D | Rigidbody 2D。
-
接下来,我们需要让木板在我们的游戏中正常工作;我们需要创建一个新脚本,并将其命名为
Plank。 -
这个脚本以一堆变量开始。前两个变量用于跟踪木板的血量。我们需要将总血量与当前血量分开,这样当对象被削弱到一半血量时,我们就能检测到。在这一点上,我们将使用接下来的三个变量来更改对象材质以显示损坏。最后一个变量用于对象耗尽血量并被销毁时。我们将使用它来增加玩家的得分:
public float totalHealth = 100f; private float health = 100f; public Material damageMaterial; public Renderer plankRenderer; private bool didSwap = false; public int scoreValue = 100; -
对于脚本的第一个功能,我们使用
Awake进行初始化。确保对象当前的血量与其总血量相同,并将didSwap标志设置为false:public void Awake() { health = totalHealth; didSwap = false; } -
接下来,我们使用
OnCollisionEnter2D函数,这是通常在 3D 中使用的OnCollisionEnter函数的 2D 优化版本。这是一个特殊函数,由Rigidbody组件触发,为我们提供了关于对象与何物碰撞以及如何碰撞的信息。我们使用这些信息来查找collision.relativeVelocity.magnitude。这是物体碰撞的速度,我们将其用作伤害以减少当前血量。接下来,函数检查血量是否已经减少到一半,如果是,则调用SwapToDamaged函数。通过使用didSwap标志,我们确保该函数只被调用一次。最后,函数检查血量是否降至零以下。如果是,对象将被销毁,我们调用LevelTracker脚本(我们很快就会制作)以增加玩家的得分:public void OnCollisionEnter2D(Collision2D collision) { health -= collision.relativeVelocity.magnitude; if(!didSwap && health < totalHealth / 2f) { SwapToDamaged(); } if(health <= 0) { Destroy(gameObject); LevelTracker.AddScore(scoreValue); } } -
最后,对于脚本,我们有
SwapToDamaged函数。它首先将didSwap标志设置为true。接下来,它检查以确保plankRenderer和damageMaterial变量有对其他对象的引用。最终,它使用plankRenderer.sharedMaterial值将材质更改为看起来损坏的材质:public void SwapToDamaged() { didSwap = true; if(plankRenderer == null) return; if(damageMaterial != null) { plankRenderer.sharedMaterial = damageMaterial; } } -
在将我们的
Plank脚本添加到对象之前,我们需要创建之前提到的LevelTracker脚本。现在创建它。 -
这个脚本相当简短,从单个变量开始。该变量将跟踪玩家在当前关卡的得分,是静态的,因此当对象被销毁时可以轻松更改得分:
private static int score = 0; -
接下来,我们使用
Awake函数以确保玩家在开始关卡时从零开始:public void Awake() { score = 0; } -
最后,对于脚本,我们添加了
AddScore函数。这个函数简单地接收传递给它的分数并增加玩家的得分。它也是静态的,所以它可以在场景中的任何对象上被调用,而无需引用脚本:public static void AddScore(int amount) { score += amount; } -
回到 Unity,我们需要使用
plank_wood_damaged纹理创建一个新材质。这将是脚本将切换到的材质。 -
我们需要将
Plank脚本添加到我们的Plank_Wood对象中。将Damaged Material引用连接到新材质,将Plank Renderer引用连接到对象的Mesh Renderer组件。 -
当我们创建不同类型的板子时,可以调整总健康值来赋予它们不同的强度。木板的这个值设为
25效果相当不错。 -
接下来,创建一个空的GameObject,并将其重命名为
LevelTracker。 -
将
LevelTracker脚本添加到对象上,它将开始跟踪玩家的分数。 -
如果你想看到木板的实际效果,将其定位在地面上方,然后点击播放按钮。游戏一开始,Unity 的物理引擎就会接管,并让板子受重力落下。如果它一开始足够高,你将能够看到它在失去生命值时切换纹理。
-
为了制作我们需要的另外两种板子,选择
Plank_Wood对象,并按Ctrl + D两次进行复制。将其中一个板子重命名为Plank_Glass,另一个重命名为Plank_Rubber。 -
接下来,创建三种新材料。一种是用于橡胶板,颜色应为紫色;另一种应使用
plank_glass纹理,用于玻璃板;最后一种材料在玻璃板损坏时应使用plank_glass_damaged纹理。将新材料应用到新板子的适当位置。 -
至于新板子的生命值,玻璃的值设为
15,橡胶的值设为100效果会很好。 -
最后,将这三种板子转换为预制体,并使用它们构建一个待击倒的结构。可以自由缩放它们以制作不同大小的块,但不要改变z轴。此外,所有块都应该在z轴上定位为
0,而你的结构应该在大约x轴上的30为中心点。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_07_02.jpg
我们已经为游戏中将要被击倒的结构创建了所需的构建块。我们使用了Rigidbody组件将它们与物理引擎连接起来。同时,我们还创建了一个跟踪它们生命值的脚本,并在生命值降至一半以下时切换到损坏的材料。对于这个游戏,我们坚持使用所有物理组件的 2D 优化版本。它们的工作方式与 3D 版本完全相同,只是没有第三个坐标轴。
木材和玻璃作为基本块非常适用。然而,如果我们打算制作更难的关卡,我们需要一些更坚固的材料。尝试制作一个石块。为其创建两种纹理和材料,以展示其原始和损坏的状态。
物理材料
物理材料是一种特殊的材料,它专门告诉物理引擎两个物体应该如何交互。这不会影响物体的外观。它定义了碰撞体的摩擦力和弹性。我们将使用它们让橡胶板具有弹性,让玻璃板具有滑动性。通过这几个步骤,我们可以快速实现物理材料,创建出令人满意的效果:
-
物理材料与其它所有内容的创建方式相同,即在项目面板中创建。在项目面板内右键点击,导航至创建 | 2D 物理材料。创建两个物理材料,将其中一个命名为
Glass,另一个命名为Rubber。 -
选择其中一个,并在检查器窗口中查看它。2D 版本只有两个值(3D 版本有一些额外的值,但它们只在更复杂的情况下使用):
-
摩擦力:这个属性控制沿着表面滑动时失去的运动量。值为零表示没有摩擦力,比如冰;值为一时表示摩擦力很大,比如橡胶。
-
弹性:这个属性指的是物体在撞击其他物体或被撞击时,有多少能量会被反射回来。零表示没有能量被反射,而值为一时,物体将反射所有能量。
-
-
对于
Glass材料,将摩擦力值设为0.1,弹性设为0。对于Rubber材料,将摩擦力设为1,弹性设为0.8。 -
接下来,选择你的
Plank_Glass预制体,并查看其Box Collider 2D组件。要应用你的新物理材料,只需从项目面板逐个拖放它们到材质槽中。对你的Plank_Rubber预制体做同样的操作,任何时候有物体撞击它们之一,这些材料都将用来控制它们的交互。
我们创建了一对物理材料。它们控制两个碰撞体在相互碰撞时的交互方式。使用这些材料,我们可以控制任何碰撞体拥有的摩擦力和弹性。
角色
拥有一堆通用块只是这个游戏的开始。接下来,我们将创建一些角色来为游戏增添活力。我们需要一些邪恶的猪来摧毁,还需要一些好的鸟来投向它们。
创建敌人
我们的首个角色将是敌人猪。它们本身实际上什么都不做。所以,它们实际上只是我们之前制作的看起来像猪的木块。然而,为了使它们的毁灭成为游戏的目标,我们将扩展我们的LevelTracker脚本来监视它们,如果它们全部被摧毁,则触发游戏结束事件。我们还将扩展脚本以在屏幕上更新分数,并将其保存以供以后使用。与只能看到一面的立方体木板不同,猪是作为平面纹理创建的,并由 Unity 的 2D 管线作为精灵使用。下面是创建我们愤怒的小鸟游戏中的猪的步骤:
-
猪的创建方式与木板的创建方式相似;但是,它们使用了一个特殊的 2D 对象,称为精灵。精灵实际上只是一个始终面向屏幕的平面物体。大多数 2D 游戏都是用一系列的精灵来制作所有对象。你可以通过导航至游戏对象 | 2D 对象 | 精灵来创建一个。将其命名为
Pig。 -
为了让新的精灵看起来像只猪,从项目面板中拖动
pig_fresh图像,并将其拖放到Sprite Renderer组件的Sprite槽中。 -
接下来,添加一个Circle Collider 2D组件和一个Rigidbody 2D组件。Circle Collider 2D组件与我们之前使用的Sphere Collider组件类似,但它是为在 2D 游戏中工作而优化的。
-
在我们能够在游戏中使用猪之前,我们需要更新
Plank脚本,使其能够处理精灵图像以及材质的变化。因此,我们打开它并在开始处添加一个变量。这个变量简单地跟踪要切换到哪个精灵:public Sprite damageSprite; -
然后,我们需要在
SwapToDamaged函数的末尾添加一小部分代码。这个if语句检查是否有可切换的精灵。如果有,我们将通用的渲染器变量转换为SpriteRenderer,这样我们就可以访问它上面的sprite变量,并更新为新图像:if(damageSprite != null) { SpriteRenderer spriteRend = plankRenderer as SpriteRenderer; spriteRend.sprite = damageSprite; } -
将
Plank脚本添加到猪身上,并用Sprite Renderer组件填充Plank Renderer槽。同时,将pig_damage图像放入Damage Sprite槽中。通过稍微修改这个脚本,我们可以在之后节省很多麻烦,比如当我们可能想要追踪不仅仅是猪的摧毁情况时。 -
现在,将猪转换成预制体并添加到你的结构中。记住,你需要将它们在z轴上的位置设为零,但你可以随意调整它们的大小、健康值和分数值,以增加一些多样性。
-
接下来,我们需要扩展
LevelTracker脚本。打开它,我们可以添加一些更多的代码。 -
首先,我们需要在脚本的最开始添加一行,这样我们就可以编辑在 GUI 中显示的文本。就像我们之前做的那样,在脚本的最顶部添加这一行,那里还有以
using开头的另外两行:using UnityEngine.UI; -
我们将在脚本的开始处添加一些变量。第一个变量,顾名思义,将保存我们场景中所有的猪的列表。下一个是一个标志,用来表示游戏已经结束。我们还有三个
Text变量,以便在玩家玩游戏时更新他们的分数,告诉他们游戏结束的原因以及他们的最终得分。最后一个变量将允许你打开或关闭最后的屏幕,告诉玩家他们是否赢了:public Transform[] pigs = new Transform[0]; private gameOver = false; public Text scoreBox; public Text finalMessage; public Text finalScore; public GameObject finalGroup; -
接下来,我们需要在
Awake函数中添加一行。这确保了在游戏开始时,告诉玩家游戏如何结束的 GUI 对象组是关闭的:FinalGroup.SetActive(false); -
在
LateUpdate函数中,我们首先检查游戏是否已经结束。如果没有,我们调用另一个函数来检查是否所有的猪都被摧毁了。同时,我们更新玩家的分数显示,无论是在游戏进行中还是游戏结束屏幕上:public void LateUpdate() { if(!gameOver) { CheckPigs(); scoreBox.text = "Score: " + score; finalScore.text = "Score: " + score; } } -
接下来,我们添加
CheckPigs函数。这个函数遍历猪的列表,查看它们是否都被摧毁。如果它发现有一个没有被摧毁,就会退出函数。否则,游戏被标记为结束,并给玩家一条信息。我们还会关闭游戏内得分并开启游戏结束的一组 GUI 对象:private void CheckPigs() { for(int i=0;i<pigs.Length;i++) { if(pigs[i] != null) return; } gameOver = true; finalMessage.text = "You destroyed the pigs!"; scoreBox.gameObject.SetActive(false); finalGroup.SetActive(true); } -
OutOfBirds函数将由我们稍后要创建的弹弓调用,当玩家没有鸟可以发射到猪身上时。如果游戏尚未结束,该函数将结束游戏并为玩家设置适当的信息。它还会关闭游戏内得分,并开启游戏结束的一组 GUI 对象,就像前一个函数一样:public void OutOfBirds() { if(gameOver) return; gameOver = true; finalMessage.text = "You ran out of birds!"; scoreBox.gameObject.SetActive(false); finalGroup.SetActive(true); } -
最后,我们有
SaveScore函数。这里我们使用PlayerPrefs类。它让你可以轻松地存储和检索少量数据,非常适合我们当前的需求。我们只需要提供一个唯一的键来保存数据。为此,我们使用一个简短字符串与Application.loadedLevel提供的关卡索引组合。接下来,我们使用PlayerPrefs.GetInt来检索上次保存的分数。如果没有,则返回我们传递给函数的零作为默认值。我们将新分数与旧分数进行比较,并使用PlayerPrefs.SetInt来保存更高的新分数。最后,Application.LoadLevel函数可以用来加载我们游戏中的任何其他场景。所有你打算加载的场景都必须添加到文件菜单中的构建设置窗口中,并且可以通过使用它们的名称或索引来加载,如下所示:public void SaveScore() { string key = "LevelScore" + Application.loadedLevel; int previousScore = PlayerPrefs.GetInt(key, 0); if(previousScore < score) { PlayerPrefs.SetInt(key, score); } Application.LoadLevel(0); }注意
请注意,使用
PlayerPrefs是在 Unity 中存储保存信息的最简单方法。然而,它并不是最安全的。如果你有在计算机注册表中更改值的经验,你可以轻松地从游戏外部找到并更改这些PlayerPrefs值。这并不意味着它不适合存储游戏信息。你只需要意识到这一点,以防你制作游戏时希望防止玩家黑客攻击并更改游戏存档中的值。 -
接下来,我们需要创建一些 GUI 对象,以便玩家在游戏中了解自己的表现。记得你可以通过导航到GameObject | UI来找到它们。我们将需要三个文本对象、一个按钮和一个面板。
-
第一个文本对象应命名为
Score。它将在关卡进行时显示玩家的得分。将其锚定在画布区域的左上角。 -
按钮需要成为面板的子对象。它应该锚定在屏幕中心,位置稍低于中心。同时,将按钮的文本更改为有意义的文字;这里使用
返回关卡选择会很合适。 -
对于点击操作,我们需要点击加号来添加新的事件。选择
LevelTracker脚本的SaveScore函数。否则,我们将无法记录玩家的最高分并结束关卡。 -
最后两个文本对象也应该被设置为面板的子对象。其中一个命名为
Message;它会告诉玩家关卡结束的原因。另一个应命名为FinalScore,在玩家完成时显示他们的得分。它们都需要锚定在屏幕中心,同时将FinalScore对象放置在按钮上方,消息在它的上方。 -
最后,我们场景中所有的猪对象都需要通过拖放每个猪到检查器窗口下的
Pigs值来添加到LevelTracker脚本的列表中。同时,将每个文本对象放入其槽位,并将面板放入最终组槽位中。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_07_03.jpg
我们创建了猪,并更新了LevelTracker脚本来跟踪它们。这些猪实际上就像木板,但它们是圆形而不是盒子。更新的LevelTracker脚本监听所有猪被摧毁的实例,并在那时触发游戏结束屏幕。它还在游戏进行时绘制分数,并在关卡结束时保存这个分数。
我们的游戏还没有完全运作起来,但这并不意味着它必须看起来像 Unity 提供的默认设置。使用你之前章节的技能,让已有的界面元素看起来更好。即使只是改变字体,也会让我们的游戏看起来大不相同。也许甚至尝试更改Panel的背景图像,为我们的游戏结束屏幕添加最后的亮点。
创建盟友
接下来,我们需要一些东西来投掷向猪和它们的防御工事。这里,我们将创建最简单的红鸟。红鸟本质上只是一个石头。它没有特殊能力,除了生命值之外,它的代码也没有特别之处。你还会注意到,鸟是一个 3D 模型,这使它拥有了猪所缺少的阴影。让我们按照以下步骤来创建红鸟:
-
红鸟是另一个 3D 模型,因此它的设置方式与木板类似。创建一个空的游戏对象,将其命名为
Bird_Red,并将适当的模型从birds模型中作为子对象添加,将其位置和缩放调整到大约一个单位大小,并将模型沿x轴旋转对齐。如果稍微向摄像机方向旋转,玩家就能看到鸟的脸,同时仍然能够给玩家在看向游戏场地的印象。 -
接下来,给它一个圆形碰撞器 2D组件和一个刚体 2D组件。
-
现在,我们需要创建一个名为
Bird的新脚本。这个脚本将成为我们所有鸟的基础,跟踪它们的生命值并在适当的时候触发它们的特殊能力。 -
脚本从三个变量开始。第一个将跟踪鸟类的当前生命值。第二个是一个标志,这样鸟类只会使用一次特殊能力。它被标记为
protected,这样我们的所有鸟类都可以使用它,同时防止外部干扰。最后一个将保存对我们刚体组件的引用:public float health = 50; protected bool didSpecial = false; public Rigidbody2D body; -
Update函数在激活鸟类的特殊能力之前会进行三次检查。首先,它会检查是否已经完成,然后检查屏幕是否被触摸。我们可以通过检查左键鼠标来轻松检查在本帧中是否进行了触摸操作,Unity 在我们触摸屏幕时会触发这个动作。最后,它会检查鸟类是否有刚体组件,以及是否被其他脚本控制:public void Update() { if(didSpecial) return; if(!Input.GetMouseButtonDown(0)) return; if(body == null || body.isKinematic) return; DoSpecial(); } -
对于红鸟来说,
DoSpecial函数仅将其标志设置为true。它被标记为virtual,这样我们就可以为其他鸟类重写该函数,让它们做一些花哨的事情:protected virtual void DoSpecial() { didSpecial = true; } -
OnCollisionEnter2D函数与木板类似,根据碰撞的强度减少生命值,并在生命值耗尽时销毁鸟类:public void OnCollisionEnter2D(Collision2D collision) { health -= collision.relativeVelocity.magnitude; if(health < 0) Destroy(gameObject); } -
回到 Unity,并将脚本添加到
Bird_Red对象。 -
完成鸟类创建的过程,将其转化为预制体,并从场景中删除。接下来我们将创建的弹弓会在游戏开始时处理鸟类的创建。
我们创建了一只红鸟。它的设置与我们其他的物理对象一样。我们还创建了一个脚本来处理鸟的生命值。这个脚本将在我们为游戏创建其他鸟类时进一步扩展。
控制
接下来,我们将赋予玩家与游戏互动的能力。首先,我们将创建一个弹弓来投掷鸟类。之后,我们将创建相机控制。我们甚至将创建一个漂亮的背景效果,使我们的游戏外观更加完善。
使用弹弓攻击
为了攻击猪堡垒,我们有了基本的鸟类弹药。我们需要创建一个弹弓,将这种弹药投向猪。它还将处理在关卡开始时生成鸟类,并在使用鸟类后自动重新装填。当弹弓中没有鸟类时,它会通知LevelTracker脚本,游戏将结束。最后,我们将创建一个脚本来防止物理模拟持续过长时间。我们不想让玩家坐下来观看一只猪慢慢滚过屏幕。因此,脚本会在一段时间后开始减弱刚体组件的运动,使它们停下来,而不是继续滚动。为了完成所有这些工作,我们将按照以下步骤进行:
-
为了开始创建弹弓,将弹弓模型添加到场景中,并将其定位在原点。如有必要,将其缩放到大约四个单位的高度。为
Fork模型应用浅棕色材质,为Pouch模型应用深棕色材质。 -
接下来,我们需要四个空的 GameObject。将它们都设置为
Slingshot对象的子对象。将第一个 GameObject 命名为
FocalPoint,并将其放置在弹弓叉齿之间。这将是我们发射所有鸟的中心点。第二个 GameObject 是
Pouch。首先,将其X 轴的旋转设置为0,Y 轴的旋转设置为90,Z 轴的旋转设置为0,使蓝色箭头沿着我们的游戏场指向前方。接下来,将pouch模型设置为该对象的子对象,将其X 轴和 Y 轴的位置设置为0,Z 轴的位置设置为-0.5,旋转设置为X 轴的270度,Y 轴的90度,Z 轴的0度。这样,在不制作完整的袋子模型的情况下,袋子将出现在当前鸟的前面。第三个 GameObject 是
BirdPoint;这将定位正在发射的鸟的位置。将其设置为Pouch点的子对象,并将其X 轴的位置设置为0.3,Y 轴和Z 轴的位置设置为0。最后一个 GameObject 是
WaitPoint;待发射的鸟将位于这个点后面。将其X 轴的位置设置为-4,Y 轴的位置设置为0.5,Z 轴的位置设置为0。 -
接下来,旋转
Fork模型,以便我们能够看到叉子的两个叉齿,同时它看起来是指向前方。X 轴的270度,Y 轴的290度,以及Z 轴的0度将会很合适。 -
Slingshot脚本将提供玩家的大部分互动功能。现在创建它。 -
我们从这个脚本开始使用一组变量。第一组变量将保存之前提到的阻尼器的引用。第二组变量将跟踪将在关卡中使用的鸟。接下来是一组变量,用于跟踪准备发射的当前鸟。第四组变量保存我们刚才创建的点的引用。
maxRange变量是从焦点到玩家可以将袋子拖动的距离。最后两个变量定义了鸟被发射的力度:public RigidbodyDamper rigidbodyDamper; public GameObject[] levelBirds = new GameObject[0]; private Rigidbody2D[] currentBirds; private int nextIndex = 0; public Transform waitPoint; public Rigidbody2D toFireBird; public bool didFire = false; public bool isAiming = false; public Transform pouch; public Transform focalPoint; public Transform pouchBirdPoint; public float maxRange = 3; public float maxFireStrength = 25; public float minFireStrength = 5; -
与其他脚本一样,我们使用
Awake函数进行初始化。levelBirds变量将保存所有将在关卡中使用的鸟类预制体的引用。我们首先创建每个预制体的实例,并将其刚体保存在currentBirds变量中。每个鸟的刚体组件上的isKinematic变量设置为true,这样在不使用时它就不会移动。接下来,它准备好第一个要发射的鸟,最后,它将剩余的鸟定位在waitPoint后面:public void Awake() { currentBirds = new Rigidbody2D[levelBirds.Length]; for(int i=0;i<levelBirds.Length;i++) { GameObject nextBird = Instantiate(levelBirds[i]) as GameObject; currentBirds[i] = nextBird.GetComponent<Rigidbody2D>(); currentBirds[i].isKinematic = true; } ReadyNextBird(); SetWaitPositions(); } -
ReadyNextBird函数首先检查是否已经没有鸟可供发射。如果是这样,它会找到LevelTracker脚本来告诉它没有鸟可以发射了。nextIndex变量跟踪列表中待玩家发射的鸟的当前位置。接下来,该函数将下一个鸟存储在toFireBird变量中,并将其设置为之前创建的BirdPoint对象的子对象;其位置和旋转会被重置为零。最后,发射和瞄准标志会被重置:public void ReadyNextBird() { if(currentBirds.Length <= nextIndex) { LevelTracker tracker = FindObjectOfType(typeof(LevelTracker)) as LevelTracker; tracker.OutOfBirds(); return; } toFireBird = currentBirds[nextIndex]; nextIndex++; toFireBird.transform.parent = pouchBirdPoint; toFireBird.transform.localPosition = Vector3.zero; toFireBird.transform.localRotation = Quaternion.identity; didFire = false; isAiming = false; } -
SetWaitPositions函数使用waitPoint的位置来定位弹弓后面所有剩余的鸟:public void SetWaitPositions() { for(int i=nextIndex;i<currentBirds.Length;i++) { if(currentBirds[i] == null) continue; Vector3 offset = Vector3.right * (i – nextIndex) * 2; currentBirds[i].transform.position = waitPoint.position – offset; } } -
Update函数首先检查玩家是否已经发射了一只鸟,并观察rigidbodyDamper.allSleeping变量以判断所有物理对象是否已经停止移动。一旦它们停止,下一只鸟就会被准备好发射。如果我们还没有发射,会检查瞄准标志并调用DoAiming函数来处理瞄准。如果玩家既没有瞄准也没有刚刚发射鸟,我们会检查触摸输入。如果玩家触摸的位置足够接近焦点,我们会标记玩家已经开始瞄准:public void Update() { if(didFire) { if(rigidbodyDamper.allSleeping) { ReadyNextBird(); SetWaitPositions(); } return; } else if(isAiming) { DoAiming(); } else { if(Input.touchCount <= 0) return; Vector3 touchPoint = GetTouchPoint(); isAiming = Vector3.Distance(touchPoint, focalPoint.position) < maxRange / 2f; } } -
DoAiming函数检查玩家是否停止触摸屏幕,并在他们停止时发射当前的鸟。如果他们没有停止,我们会将袋子定位在当前的触摸点。最后,袋子的位置被限制在最大范围内:private void DoAiming() { if(Input.touchCount <= 0) { FireBird(); return; } Vector3 touchPoint = GetTouchPoint(); pouch.position = touchPoint; pouch.LookAt(focalPoint); float distance = Vector3.Distance(focalPoint.position, pouch.position); if(distance > maxRange) { pouch.position = focalPoint.position – (pouch.forward * maxRange); } } -
GetTouchPoint函数使用ScreenPointToRay来找出玩家在 3D 空间中触摸的位置。这类似于我们触摸香蕉时的操作;然而,由于这个游戏是 2D 的,我们只需查看射线原点并返回其z轴值为零:private Vector3 GetTouchPoint() { Ray touchRay = Camera.main.ScreenPointToRay(Input.GetTouch(0).position); Vector3 touchPoint = touchRay.origin; touchPoint.z = 0; return touchPoint; } -
最后,对于这个脚本,我们有
FireBird函数。这个函数首先将我们的didFire标志设置为true。接下来,它通过查找袋子位置到focalPoint的方向来确定需要发射鸟的方向。它还使用它们之间的距离来确定发射鸟所需的力度,并将其限制在我们的最小和最大力度之间。然后,它通过清除其父对象并将isKinematic标志设置为false来释放鸟,找到其Rigidbody组件。为了发射它,我们使用AddForce函数,并传递方向乘以力度。同时传递ForceMode2D.Impulse以使施加的力一次性且立即生效。接下来,袋子被定位在focalPoint,就像它真的在受力下一样。最后,我们调用rigidbodyDamper.ReadyDamp来开始Rigidbody组件移动的阻尼:private void FireBird() { didFire = true; Vector3 direction = (focalPoint.position – pouch.position).normalized; float distance = Vector3.Distance(focalPoint.position, pouch.position); float power = distance <= 0 ? 0 : distance / maxRange; power *= maxFireStrength; power = Mathf.Clamp(power, minFireStrength, maxFireStrength); toFireBird.transform.parent = null; toFireBird.isKinematic = false; toFireBird.AddForce(new Vector2(direction.x, direction.y) * power, ForceMode2D.Impulse); pouch.position = focalPoint.position; rigidbodyDamper.ReadyDamp(); } -
在我们能够使用
Slingshot脚本之前,我们需要创建RigidbodyDamper脚本。 -
这个脚本从以下六个变量开始。前两个变量定义了在抑制移动之前需要等待的时间以及抑制的幅度。接下来的两个变量跟踪是否可以应用抑制以及何时开始。接下来是一个变量,它将被填充为当前场景中所有刚体的列表。最后,有一个
allSleeping标志,当移动停止时,它将被设置为true:public float dampWaitLength = 10f; public float dampAmount = 0.9f; private float dampTime = -1f; private bool canDamp = false; private Rigidbody2D[] rigidbodies = new Rigidbody2D[0]; public bool allSleeping = false; -
ReadyDamp函数首先使用FindObjectsOfType填充刚体列表。当需要开始抑制时,将dampTime标志设置为当前时间与等待时长的总和。它表示脚本可以执行抑制并重置allSleeping标志。最后,它使用StartCoroutine调用CheckSleepingRigidbodies函数。这是一种特殊的调用函数方式,使它们在后台运行,而不会阻止游戏的其余部分运行:public void ReadyDamp() { rigidbodies = FindObjectsOfType(typeof(Rigidbody2D)) as Rigidbody2D[]; dampTime = Time.time + dampWaitLength; canDamp = true; allSleeping = false; StartCoroutine(CheckSleepingRigidbodies()); } -
在
FixedUpdate函数中,我们首先检查是否可以抑制移动以及是否到了执行抑制的时候。如果是,我们会遍历所有刚体,对每个刚体的旋转速度和线性速度应用我们的抑制。那些由脚本控制、已经处于休眠状态(意味着它们已经停止移动)的动力学刚体将被跳过:public void FixedUpdate() { if(!canDamp || dampTime > Time.time) return; foreach(Rigidbody2D next in rigidbodies) { if(next != null && !next.isKinematic && !next.isSleeping()) { next.angularVelocity *= dampAmount; next.velocity *= dampAmount; } } } -
CheckSleepingRigidbodies函数是特殊的,它将在后台运行。这是通过函数开头的IEnumerator标志和中间的yield return null行实现的。这些使得函数可以定期暂停,并在等待函数完成时防止游戏其余部分冻结。函数开始时创建一个检查标志,并使用它来检查所有刚体是否已经停止移动。如果发现有一个仍在移动,标志将被设置为false,函数将暂停到下一帧,届时将再次尝试。当到达末尾时,因为所有刚体都处于休眠状态,它将allSleeping标志设置为true,以便下一次使用弹弓时做好准备。同时,在玩家准备发射下一只鸟时,它也会停止抑制:private IEnumerator CheckSleepingRigidbodies() { bool sleepCheck = false; while(!sleepCheck) { sleepCheck = true; foreach(Rigidbody2D next in rigidbodies) { if(next != null && !next.isKinematic && !next.IsSleeping()) { sleepCheck = false; yield return null; break; } } } allSleeping = true; canDamp = false; } -
最后,我们有
AddBodiesToCheck函数。这个函数将被任何在玩家发射鸟之后生成新物理对象的物体使用。它开始时创建一个临时列表并扩展当前列表。接下来,它将临时列表中的所有值添加到扩展后的列表中。最后,在临时列表之后添加刚体列表:public void AddBodiesToCheck(Rigidbody2D[] toAdd) { Rigidbody2D[] temp = rigidbodies; rigidbodies = new Rigidbody2D[temp.Length + toAdd.Length]; for(int i=0;i<temp.Length;i++) { rigidbodies[i] = temp[i]; } for(int i=0;i<toAdd.Length;i++) { rigidbodies[i + temp.Length] = toAdd[i]; } } -
回到 Unity,将这两个脚本添加到
Slingshot对象中。在Slingshot脚本组件中,连接到Rigidbody Damper脚本组件的引用以及每个点的引用。此外,根据关卡需要,将红色小鸟预制体引用添加到Level Birds列表中。 -
为了防止物体滚回到弹弓中,请在
Slingshot上添加一个Box Collider 2D组件,并将其定位在Fork模型的支架处。 -
为了完成弹弓的外观,我们需要创建将袋子与叉连接的弹性带子。我们首先通过创建
SlingshotBand脚本来实现这一点。 -
脚本从两个变量开始,一个用于带子结束的点,另一个用于引用将绘制它的
LineRenderer变量:public Transform endPoint; public LineRenderer lineRenderer; -
Awake函数确保lineRenderer变量只有两个点,并设置它们的初始位置:public void Awake() { if(lineRenderer == null) return; if(endPoint == null) return; lineRenderer.SetVertexCount(2); lineRenderer.SetPosition(0, transform.position); lineRenderer.SetPosition(1, endPoint.position); } -
在
LateUpdate函数中,我们将lineRenderer变量的端点位置设置为endPoint值。这个点会随着袋子移动,因此我们需要不断更新渲染器:public void LateUpdate() { if(endPoint == null) return; if(lineRenderer == null) return; lineRenderer.SetPosition(1, endPoint.position); } -
返回 Unity,并创建一个空游戏对象。将其命名为
Band_Near,并使其成为Slingshot对象的子对象。 -
作为这个新点的子对象,创建一个圆柱体和一个名为
Band的第二个空游戏对象。 -
给圆柱体一个棕色材质,并将其定位在弹弓叉近端。确保移除胶囊碰撞器组件,以免造成妨碍。同时,不要害怕进行缩放,以便更好地适应弹弓的外观。
-
在
Band对象上,添加位于组件菜单下效果中的线渲染器组件。将其放置在圆柱体的中心后,为对象添加SlingshotBand脚本。 -
在材质下的线渲染器组件中,你可以将棕色材质放入槽中以给带子着色。在参数下,将开始宽度设置为
0.5,将结束宽度设置为0.2,以设置线条的大小。 -
接下来,创建另一个空游戏对象,并将其命名为
BandEnd_Near。使其成为Pouch对象的子对象,并将其定位在袋子内部。 -
现在,将脚本的引用连接到其线渲染器和端点。
-
为了制作第二条带子,复制我们刚才创建的四个对象,并根据叉的另一端定位它们。这条带子的端点只需沿着z轴向后移动,以使其避开小鸟。
-
最后,将其整个转换为预制体,以便在其他关卡中轻松复用。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_07_04.jpg
我们创建了一个用于发射小鸟的弹弓。我们使用了前一章学到的技术来处理触摸输入,并在玩家瞄准和射击时追踪玩家的手指。如果你保存你的场景,并将相机定位在观察弹弓的位置,你会注意到它已经完成,尽管还不是很完善。可以向猪堡垒发射小鸟,尽管我们只能在 Unity 的场景视图中看到破坏效果。
通过相机观看
在这一点上,游戏在技术上是可以玩的,但有点难以看清正在发生的事情。接下来,我们将创建一个控制系统来控制摄像机。该系统将允许玩家将摄像机向左和右拖动,当小鸟被发射时跟随小鸟,并在一切停止移动后返回弹弓位置。还将有一组限制,以防止摄像机走得太远,看到我们不希望玩家看到的东西,比如我们为关卡创建的地形或天空的边缘。我们只需要一个相对简短的脚本来控制和管理工作。让我们按照以下步骤创建它:
-
为了开始并保持一切有序,创建一个新的空GameObject,并将其命名为
CameraRig。同时,为了简化,将其在每个轴上的位置设置为 0。 -
接下来,创建三个空的GameObject,并将它们命名为
LeftPoint、RightPoint和TopPoint。将它们的Z 轴位置设置为-5。将LeftPoint对象定位在弹弓前方,并在Y 轴上设置为3的位置。RightPoint对象需要定位在你创建的猪结构前方。TopPoint对象可以位于弹弓上方,但在Y 轴上需要设置为8。这三个点将定义当拖动和跟随小鸟时,摄像机可以移动的范围限制。 -
将这三个点以及
Main Camera对象设置为CameraRig对象的子对象。 -
现在,我们创建
CameraControl脚本。这个脚本将控制摄像机的所有移动和交互。 -
本脚本的变量从对弹弓的引用开始;我们需要这个引用以便在发射时跟随当前的小鸟。接下来是对我们刚刚创建的点进行引用。接下来的一组变量控制摄像机在没有输入的情况下停留多长时间,然后返回查看弹弓以及返回的速度。
dragScale变量控制当玩家在屏幕上拖动手指时,摄像机实际移动的速度,使场景能够跟随手指移动。最后一组变量控制摄像机是否可以跟随当前的小鸟以及跟随的速度:public Slingshot slingshot; public Transform rightPoint; public Transform leftPoint; public Transform topPoint; public float waitTime = 3f; private float headBackTime = -1f; private Vector3 waitPosition; private float headBackDuration = 3f; public float dragScale = 0.075f; private bool followBird = false; private Vector3 followVelocity = Vector3.zero; public float followSmoothTime = 0.1f; -
在
Awake函数中,我们首先确保摄像机没有跟随小鸟,并让它等待一段时间后再去查看弹弓。这样,当关卡开始时,你可以先将摄像机指向猪堡垒,并在给玩家一个机会观察他们面对的情况之后,移动到弹弓位置:public void Awake() { followBird = false; StartWait(); } -
StartWait函数设置它将开始返回弹弓的时间,并记录它从哪个位置返回。这允许你创建一个平滑的过渡:public void StartWait() { headBackTime = Time.time + waitTime; waitPosition = transform.position; } -
然后,我们有
Update函数。此函数首先检查弹弓是否已经发射。如果没有,它检查玩家是否已经开始瞄准,这意味着应该跟随鸟,并在他们这样做时将速度归零。如果他们还没有开始瞄准,则清除followBird标志。接下来,函数检查是否应该跟随,如果应该,则执行跟随,并调用StartWait函数——以防这是鸟被销毁的帧。如果不应该跟随鸟,它检查触摸输入并拖动摄像机(如果有的话)。如果玩家在这一帧移开手指,将再次开始等待。最后,它检查弹弓是否完成了当前鸟的发射,以及是否是时候返回了。如果两者都正确,摄像机将移回到指向弹弓的位置:public void Update() { if(!slingshot.didFire) { if(slingshot.isAiming) { followBird = true; followVelocity = Vector3.zero; } else { followBird = false; } } if(followBird) { FollowBird(); StartWait(); } else if(Input.touchCount > 0) { DragCamera(); StartWait(); } if(!slingshot.didFire && headBackTime < Time.time) { BackToLeft(); } } -
FollowBird函数首先通过检查Slingshot脚本上的toFireBird变量确保有一个鸟可供跟随,如果没有找到鸟则停止跟随。如果有鸟,该函数就会确定一个新的移动点,该点将直接对准鸟。然后它使用Vector3.SmoothDamp函数平滑地跟随鸟。这个函数类似于弹簧——离目标位置越远,移动物体的速度越快。使用followVelocity变量使其保持平滑移动。最后,它调用另一个函数,以限制摄像机在我们先前设置的限制点内的位置:private void FollowBird() { if(slingshot.toFireBird == null) { followBird = false; return; } Vector3 targetPoint = slingshot.toFireBird.transform.position; targetPoint.z = transform.position.z; transform.position = Vector3.SmoothDamp(transform.position, targetPoint, ref followVelocity, followSmoothTime); ClampPosition(); } -
在
DragCamera函数中,我们使用当前触控的deltaPosition值来确定自上一帧以来它移动了多远。通过缩放这个值并从摄像机位置减去该向量,函数使摄像机随着玩家在屏幕上的拖动而移动。此函数还调用ClampPosition函数,以确保摄像机位置保持在游戏场内:private void DragCamera() { transform.position -= new Vector3(Input.GetTouch(0).deltaPosition.x, Input.GetTouch(0).deltaPosition.y, 0) * dragScale; ClampPosition(); } -
ClampPosition函数首先获取摄像机的当前位置。然后它将x位置夹紧在leftPoint和rightPoint变量的x位置之间。接下来,y位置被夹紧在leftPoint和topPoint变量的y位置之间。最后,将新位置重新应用到摄像机的变换中:private void ClampPosition() { Vector3 clamped = transform.position; clamped.x = Mathf.Clamp(clamped.x, leftPoint.position.x, rightPoint.position.x); clamped.y = Mathf.Clamp(clamped.y, leftPoint.position.y, topPoint.position.y); transform.position = clamped; } -
最后,我们有
BackToLeft函数。它首先使用时间和我们的持续时间变量来确定摄像机返回到弹弓时应该完成多少进度。它记录摄像机的当前位置,并在x和y轴上使用Mathf.SmoothStep找到一个位于waitPosition变量和leftPoint变量之间的适当距离的新位置。最后,应用新位置:private void BackToLeft() { float progress = (Time.time – headBackTime) / headBackDuration; Vector3 newPosition = transform.position; newPosition.x = Mathf.SmoothStep(waitPosition.x, leftPoint.position.x, progress); newPosition.y = Mathf.SmoothStep(waitPosition.y, leftPoint.position.y, progress); transform.position = newPosition; } -
接下来,回到 Unity 并将新脚本添加到
Main Camera对象。连接到弹弓和每个点的引用以完成设置。 -
将摄像机定位以指向你的猪堡垒,并将整个装置转变成一个预制体。
我们创建了一个摄像机装置,让玩家在玩游戏时可以观看所有的动作。现在相机将跟随从弹弓发射的小鸟,并且可以被玩家拖动。通过定位几个对象的位置,这种移动受到了限制,以防止玩家看到我们不想让他们看到的东西;如果相机闲置足够长的时间,它也会返回来观察弹弓。
相机在许多移动游戏中的另一个功能是通过捏合来进行缩放的手势。这对于用户来说是一个非常简单的手势,但对我们来说要实现好可能会很复杂。尝试在这里实现它。你可以使用Input.touchCount来检测是否有两个手指触摸屏幕。然后,使用Vector2.Distance函数,如果你记录了上一帧的距离,就可以确定它们是相互靠近还是远离。一旦确定了缩放方向,只需改变相机的ortographicSize变量,以改变可以看到的范围;确保包括一些限制,这样玩家就不能无限地放大或缩小。
既然我们已经有了制作完整关卡所需的所有部分,我们需要更多的关卡。我们至少还需要两个关卡。你可以使用积木和猪来创建你想要的任何关卡。最好保持结构围绕与我们的第一个关卡相同的中心点,这样玩家处理起来会更简单。同时,在制作关卡时也要考虑关卡的难度,以便最终拥有简单、中等和困难难度的关卡。
创建视差背景
许多 2D 游戏的一个出色特性是视差滚动背景。这仅仅意味着背景是由以不同速度滚动的层次创建的。你可以把它想象成你从汽车窗户向外看。远处的物体看起来几乎不动,而近处的物体则快速移动。在 2D 游戏中,它给人以深度的错觉,并为游戏的外观增添了不错的触感。对于这个背景,我们将在单个平面上叠加几种材质。还有其他几种方法可以创建这种效果,但我们将使用一个脚本来实现,此外它还允许你控制每一层的滚动速度。让我们按照以下步骤来创建它:
-
我们将从创建
ParallaxScroll脚本开始这一部分。 -
这个脚本从三个变量开始。前两个变量跟踪每种材质以及它们滚动的速度。第三个变量记录相机的最后位置,这样我们可以跟踪相机在每一帧中移动了多远:
public Material[] materials = new Material[0]; public float[] speeds = new float[0]; private Vector3 lastPosition = Vector3.zero; -
在
Start函数中,我们记录相机的初始位置。这里我们使用Start而不是Awake,以防止相机在游戏开始时需要进行任何特殊的移动:public void Start() { lastPosition = Camera.main.transform.position; } -
接下来,我们使用
LateUpdate函数在摄像机移动后进行更改。它首先找到摄像机的新的位置,并通过比较x轴的值来确定它移动了多远。接下来,它遍历材质列表。循环首先使用mainTextureOffset收集材质当前纹理的偏移量。然后,将摄像机的移动乘以材质的速度从偏移量的x轴中减去,以找到新的水平位置。接着,将新的偏移量应用到材质上。最后,该函数记录摄像机在上一个帧中的位置,以供下一帧使用:public void LateUpdate() { Vector3 newPosition = Camera.main.transform.position; float move = newPosition.x – lastPosition.x; for(int i=0;i<materials.Length;i++) { Vector2 offset = materials[i].mainTextureOffset; offset.x -= move * speeds[i]; materials[i].mainTextureOffset = offset; } lastPosition = newPosition; } -
回到 Unity,创建六个新的材质。每个背景纹理一个:
sky,hills_tall,hills_short,grass_light,grass_dark和fronds。除了sky之外的所有材质,都需要使用透明的渲染模式。如果不用这种模式,我们将无法在分层时看到所有纹理。 -
在我们能够对背景中的图像进行平铺之前,我们需要调整它们的导入设置。逐个选择它们,并查看检查器窗口。由于我们选择制作一个 2D 游戏,Unity 默认将所有图像作为精灵导入,这会导致我们的图像边缘被夹紧,无法重复。对于所有背景图像,将纹理类型选项更改为纹理,并将环绕模式选项更改为重复。这样我们就可以以无限滚动背景的方式使用它们。
-
我们还需要调整这些新材质的平铺(Tiling)选项。对于所有材质,将Y 轴保持为
1。对于X 轴,将sky设为5,hills_tall设为6,hills_shot设为7,grass_dark设为8,fronds设为9,grass_light设为10。这将偏移所有纹理特征,使得长距离的平移不会看到特征规律地排列。 -
接下来,创建一个新的平面。将其命名为
Background,并移除其网格碰撞器组件。同时,附加我们的ParallaxScroll脚本。 -
将其位置设置为X 轴上的
30,Y 轴上的7,以及Z 轴上的10。将其旋转设置为X 轴上的90,Y 轴上的180,以及Z 轴上的0。同时,将缩放设置为X 轴上的10,Y 轴上的1,以及Z 轴上的1.5。总的来说,这些设置使平面面向摄像机并填充背景。 -
在平面的网格渲染器组件中,展开材质列表,并将大小值设置为
6。按顺序将我们的新材质添加到列表槽中,顺序为sky,hills_tall,hills_short,grass_dark,fronds和grass_light。对视差滚动脚本组件中的材质列表也执行相同的操作。 -
最后,在视差滚动脚本组件中,将速度列表中的大小值设置为
6,并按顺序输入以下值:0.03,0.024,0.018,0.012,0.006和0。这些值将会使材质均匀柔和地移动。 -
在这一点上,将背景变成预制体将使其在以后容易重用。创建视差背景
我们创建了一个视差滚动效果。这个效果将平移一系列背景纹理,为我们的 2D 游戏提供深度的错觉。要轻松查看它的效果,请按播放按钮并在场景视图中抓住相机,左右移动以查看背景变化。
我们还有两个关卡需要添加背景。这里的挑战是创建你自己的背景。使用你在本节中学到的技术来创建一个夜晚风格的背景。它可以包括一个静止的月亮,而其他所有内容在镜头中滚动。为了增加一个技巧,创建一个云层,它随着相机和背景的其他部分慢慢横穿屏幕。
添加更多小鸟
我们还需要为我们的关卡创建最后一组资产:其他的小鸟。我们将创建三只更多的小鸟,每只都有独特的特殊能力:加速的黄色小鸟,分裂成多只小鸟的蓝色小鸟,以及爆炸的黑色小鸟。有了这些,我们的鸟群就完整了。
为了更容易地创建这些小鸟,我们将利用一个称为继承的概念。继承允许脚本在不需要重写的情况下扩展其继承的功能。如果使用得当,这将非常强大,在我们的情况下,它将有助于快速创建多个大致相似的角色。
黄色小鸟
首先,我们将创建黄色小鸟。在很大程度上,这只鸟的功能与红色小鸟完全相同。然而,当玩家第二次触摸屏幕时,小鸟的特殊能力被激活,其速度会增加。通过扩展我们之前创建的Bird脚本,这只鸟的创建变得相当简单。由于继承的强大力量,我们在这里创建的脚本仅包含几行代码。让我们按照以下步骤来创建它:
-
首先,按照创建红色小鸟的相同方式,使用
YellowBird模型来创建黄色小鸟。 -
我们不是使用
Bird脚本,而是将创建YellowBird脚本。 -
这个脚本需要扩展
Bird脚本,因此在我们新脚本的第四行,用Bird替换MonoBehaviour。它应该类似于以下代码片段:public class YellowBird : Bird { -
这个脚本添加了一个单一变量,用于乘以小鸟的当前速度:
public float multiplier = 2f; -
接下来,我们重写
DoSpecial函数,并在调用时乘以小鸟的body.velocity变量:protected override void DoSpecial() { didSpecial = true; body.velocity *= multiplier; } -
回到 Unity,将脚本添加到你的新小鸟中,连接Rigidbody组件引用,并将其变成一个预制体。在你的弹弓列表中添加一些,以便在你的关卡中使用这只鸟。
我们创建了黄色小鸟。这只鸟很简单。当玩家触摸屏幕时,它会直接修改其速度,以突然获得速度的提升。正如你很快会看到的,我们使用这种风格的脚本来创建我们所有的鸟。
蓝色小鸟
接下来,我们将创建蓝色小鸟。当玩家触摸屏幕时,这种鸟会分裂成三只鸟。它还将通过继承扩展Bird脚本,减少编写创建鸟所需的代码量。让我们按照以下步骤进行:
-
同样,像前两只鸟一样开始构建你的蓝色小鸟,替换相应的模型。你还应该调整Circle Collider 2D组件的Radius值,以适应这种小鸟的小尺寸。
-
接下来,我们创建
BlueBird脚本。 -
再次,调整第四行,使脚本扩展
Bird而不是MonoBehaviour:public class BlueBird : Bird { -
这个脚本有三个变量。第一个变量是当鸟分裂时要生成的预制体列表。下一个是每个新发射鸟之间的角度差。最后一个变量是为了避免生成的鸟相互卡住,而将它们稍微提前生成的位置值:
public GameObject[] splitBirds = new GameObject[0]; public float launchAngle = 15f; public float spawnLead = 0.5f; -
接下来,我们重写
DoSpecial函数,像其他鸟一样,首先标记我们完成了特殊动作。接下来,它计算要生成的鸟的一半数量,并创建一个空列表来存储新生成鸟的刚体:protected override void DoSpecial() { didSpecial = true; int halfLength = splitBirds.Length / 2; Rigidbody2D[] newBodies = new Rigidbody2D[splitBirds.Length]; -
函数通过遍历鸟类列表,跳过空槽继续执行。它在它们的位置生成新鸟;尝试存储对象的Rigidbody后,如果缺失,它将继续下一个。然后将在列表中存储新的Rigidbody组件:
for(int i=0;i<splitBirds.Length;i++) { if(splitBirds[i] == null) continue; GameObject next = Instantiate(splitBirds[i], transform.position, transform.rotation) as GameObject; Rigidbody2D nextBody = next.GetComponent<Rigidbody2D>(); if(nextBody == null) continue; newBodies[i] = nextBody; -
使用
Quaternion.Euler,创建一个新的旋转,使新鸟沿着从主路径分叉的路径偏转。新鸟的速度设置为当前鸟的旋转速度。计算偏移量,然后沿着新路径向前移动,以便为其他生成的鸟让路:Quaternion rotate = Quaternion.Euler(0, 0, launchAngle * (i – halfLength)); nextBody.velocity = rotate * nextBody.velocity; Vector2 offset = nextBody.velocity.normalized * spawnLead; next.transform.position += new Vector3(offset.x, offset.y, 0); } -
在循环之后,函数使用
FindObjectOfType查找当前场景中的弹弓。如果找到,将其更改为跟踪第一个新生成的鸟作为被发射的鸟。新的刚体列表也被设置为rigidbodyDamper变量,以便添加到其刚体列表中。最后,脚本销毁其附着的鸟,完成鸟被分裂的错觉:Slingshot slingshot = FindObjectOfType(typeof(Slingshot)) as Slingshot; if(slingshot != null) { slingshot.toFireBird = newBodies[0]; slingshot.rigidbodyDamper.AddBodiesToCheck(newBodies); } Destroy(gameObject); } -
在将脚本添加到你的新鸟之前,我们实际上需要两只蓝色小鸟:一只负责分裂,另一只不分裂。复制你的鸟,并将一个命名为
Bird_Blue_Split,另一个命名为Bird_Blue_Normal。在分裂的鸟上添加新脚本,而在普通鸟上添加Bird脚本。 -
将两只鸟都转变成预制体,并将普通鸟添加到另一只鸟的待分裂鸟列表中。
我们创建了蓝色小鸟。当用户点击屏幕时,这种鸟会分裂成多只鸟。实际上这个效果需要两只看起来完全相同的鸟,一只负责分裂,另一只被分裂成两半但不执行特殊动作。
实际上,我们可以将任何想要生成的对象添加到蓝色小鸟分裂时产生的对象列表中。这里的挑战是创建一个彩虹鸟。这种鸟可以分裂成不同类型的鸟,不仅仅是蓝色。或者,它可能是一个石鸟,分裂成石块。为了增加挑战,创建一个神秘鸟,在分裂时从其列表中随机选择一种鸟。
黑色小鸟
最后,我们有了黑色小鸟。当玩家触摸屏幕时,这只鸟会爆炸。与之前讨论的所有鸟一样,它将扩展Bird脚本;从红色小鸟继承使得黑色小鸟的创建变得容易得多。让我们使用这些步骤来完成它:
-
与其他鸟一样,这个鸟最初是以与红色小鸟相同的方式创建的,调整Circle Collider 2D组件上的Radius值以适应其增加的大小。
-
同样,我们创建一个新的脚本来扩展
Bird脚本。这次,它被称为BlackBird。 -
不要忘记调整第四行以扩展
Bird脚本,而不是MonoBehaviour:public class BlackBird : Bird { -
这个脚本有两个变量。第一个变量是爆炸的大小,第二个是它的强度:
public float radius = 2.5f; public float power = 25f; -
再次,我们重写
DoSpecial函数,首先标记我们已经这样做。接下来,我们使用Physics2D.OverlapCircleAll获取在鸟爆炸范围内的所有对象列表,其 3D 版本是Physics.OverlapSphere。然后,我们计算爆炸来自哪里,这仅仅是我们鸟的位置向下移动三个单位。我们将其向下移动,因为向上抛射碎片比向外推射碎片的爆炸更有趣。然后函数遍历列表,跳过任何空槽和没有刚体的对象:protected override void DoSpecial() { didSpecial = true; Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, radius); Vector2 explosionPos = new Vector2(transform.position.x, transform.position.y) – (Vector2.up * 3); foreach(Collider2D hit in colliders) { if(hit == null) continue; if(hit.attachedRigidbody != null) { -
如果对象存在并且附加了Rigidbody组件,我们需要计算爆炸将如何影响这个对象,模拟爆炸强度随距离增加而减弱的方式。首先,我们通过获取另一个对象的位置来节省一些输入。接下来,我们计算它相对于爆炸位置的位置。通过将相对位置的大小或长度除以我们的
radius变量,我们可以计算出需要向被击中的对象施加多大的力。最后,我们使用AddForceAtPosition给对象一个像在特定位置爆炸一样的踢力。ForceMode2D.Impulse变量用于立即施加力:Vector3 hitPos = hit.attachedRigidbody.transform.position; Vector2 dir = new Vector2(hitPos.x, hitPos.y) – explosionPos; float wearoff = 1 – (dir.magnitude / radius); Vector2 force = dir.normalized * power * wearoff; hit.attachedRigidbody.AddForceAtPosition(force, explosionPos, ForceMode2D.Impulse); } } -
最后,函数销毁了已爆炸的鸟:
Destroy(gameObject); } -
与最后两只鸟一样,将你的新脚本应用于新鸟并将其变成预制体。现在,在每个级别选择弹弓武器库时,你有四种鸟可以选择。
我们创建了第四只也是最后一只鸟:黑色小鸟。当用户触摸屏幕时,这只鸟会爆炸,将附近的一切抛向空中。这可以是一种有趣的鸟来玩,对于摧毁你的猪堡垒非常有效。
我们模仿的游戏中的黑色小鸟具有额外的定时爆炸能力,在撞击到某物后触发。尝试为我们的黑色小鸟创建一个计时器,以重现这一效果。你需要重写OnCollisionEnter函数来启动计时器,并使用LateUpdate来倒计时。一旦计时器时间耗尽,你可以使用我们的DoSpecial函数来实际引发爆炸。
既然你知道如何引发爆炸,我们又有了一个挑战:创建一个爆炸箱子。你需要扩展Plank脚本来实现它,当箱子受到足够伤害时,触发爆炸。为了增加挑战性,你可以配置箱子,使其不是直接爆炸,而是抛出几个炸弹,这些炸弹在撞击到某物时爆炸。
关卡选择
最后,我们需要创建一个关卡选择屏幕。通过这个场景,我们可以访问并开始玩我们之前创建的所有关卡。我们还将显示每个关卡当前的最高分。一个新的场景和一个脚本就能很好地管理我们的关卡选择。让我们按照以下步骤进行操作:
-
最后一部分开始时保存我们当前的场景,并按Ctrl + N创建一个新场景;我们将它命名为
LevelSelect。 -
对于这个场景,我们需要创建一个名为
LevelSelect的简短脚本。 -
这个脚本将和 GUI 中的按钮一起工作,告诉玩家高分和加载关卡。然而,在我们能做到这一点之前,我们需要在脚本的最前面添加一行,和其他需要更新 GUI 的脚本一样,与其他
using行一起:using UnityEngine.UI; -
第一个也是唯一的变量是我们想要更新的所有按钮文本的列表,以及它们关联关卡的分数:
public Text[] buttonText = new Text[0]; -
第一个函数是
Awake函数。这里,它会遍历所有按钮,找到它对应的高分,并更新文本以显示它。PlayerPrefs.GetInt与我们之前用来保存高分的SetInt函数相反:public void Awake() { for(int i=0;i<buttonText.Length;i++) { int levelScore = PlayerPrefs.GetInt("LevelScore" + (i + 1), 0); buttonText[i].text = "Level " + (i + 1) + "\nScore: " + levelScore; } } -
这个脚本的第二个也是最后一个函数是
LoadLevel。它将从 GUI 按钮接收一个数字,并使用它来加载玩家想要玩的关卡:public void LoadLevel(int lvl) { Application.LoadLevel(lvl); } -
回到 Unity,并将脚本添加到
Main Camera对象。 -
接下来,我们需要创建三个按钮。没有这些,我们的玩家将无法选择一个关卡来玩。使每个按钮为
200单位大小,并将它们排成一行放在屏幕中央。同时,将字体大小增加到25,以便文本易于阅读。 -
将每个按钮的
Text子对象拖动到Main Camera组件的Level Select脚本组件中的Button Texts列表。它们在这个列表中的顺序就是它们将改变文本和高分信息显示的顺序。 -
同时,每个按钮都需要一个新的点击事件。为对象选择
主相机,然后导航到LevelSelect | LoadLevel (int) 函数。然后,每个按钮都需要一个数字。在按钮文本列表中的其文本子项的按钮应该有数字1,因为它将显示第一关的信息。第二个按钮有2,第三个有3,依此类推。每个按钮必须有与列表中顺序相同的数字,否则它们将加载与玩家预期不同的关卡。 -
最后,打开构建设置并将你的场景添加到构建中的场景列表中。通过点击并拖动列表中的场景,你可以重新排序它们。确保你的LevelSelect场景排在第一位,并且在右侧的索引为零。其余的场景可以按照你希望的任何顺序出现。但是要注意,它们将与按钮以相同的顺序关联。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_07_07.jpg
我们创建了一个关卡选择屏幕。它有一个与游戏中关卡相关联的按钮列表。当按下按钮时,Application.LoadLevel会开始那个关卡。我们还使用了PlayerPrefs.GetInt来获取每个关卡的高分。
在这里,挑战在于设计 GUI 样式,使屏幕看起来很棒。一个标志和背景将大有帮助。此外,如果你有超过三个关卡,请查看滚动条GUI 对象。这个对象将允许你创建一个函数,当用户浏览比屏幕上容易看到的更大的关卡列表时,可以偏移关卡按钮。
总结
在本章中,我们了解了 Unity 中的物理系统,并重新制作了极其流行的移动游戏,愤怒的小鸟。使用 Unity 的物理系统,我们能够制作出我们想要玩的所有关卡。通过这个游戏,我们还探索了 Unity 的 2D 管线,用于创建优秀的 2D 游戏。我们的鸟和弹弓是 3D 资源,使我们能够对它们进行光照和阴影处理。然而,猪和背景是 2D 图像,这减少了我们的光照选项,但使资源具有更高的细节。2D 图像在创建背景的视差滚动效果方面也至关重要。最后,构成关卡的方块看似 2D,实际上是 3D 方块。我们还创建了一个关卡选择屏幕。从这里,玩家可以看到他们的高分并选择我们创建的任何关卡。
在下一章中,我们将回到上一章开始制作的猴子球游戏。我们将创建并添加所有使游戏完整的特殊效果。我们将添加每个猴子球游戏都需要的声音弹跳和爆裂效果。我们还会添加各种粒子效果。当香蕉被收集时,它们将产生一个小型爆炸,而不是简单地消失。
第八章:特效 - 声音与粒子
在上一章中,我们从 Monkey Ball 游戏中短暂休息,以了解 Unity 中的物理和 2D 游戏。我们创建了一个愤怒的小鸟的克隆版。这些鸟利用物理原理在空中飞行并摧毁猪和它们的结构。我们利用视差滚动制作了一个令人愉悦的背景效果。我们还创建了一个关卡选择屏幕,通过它可以加载游戏的各种场景。
在本章中,我们将回到 Monkey Ball 游戏。我们将添加许多特殊效果,以丰富游戏体验。首先,我们会了解 Unity 在处理音频时提供的控制方法。然后,我们将在游戏中添加背景音乐和猴子移动的声音。接下来,我们将学习粒子系统,为猴子创建尘埃轨迹。最后,我们将结合本章介绍的效果,为用户收集香蕉时创建爆炸效果。
在本章中,我们将涵盖以下重要主题:
-
导入音频剪辑
-
播放音效
-
理解 2D 和 3D 音效
-
创建粒子系统
打开你的 Monkey Ball 项目,让我们开始吧。
理解音频
与其他资源一样,Unity 团队努力工作,使得处理音频变得简单且无忧。Unity 能够导入和利用广泛的音频格式,让您可以在其他程序中以可编辑的格式保存文件。
导入设置
音频剪辑有一系列重要的设置。它们让你可以轻松控制文件类型和压缩。下面的截图展示了我们在导入音频剪辑时要处理的一些设置:
前面截图中的选项如下:
-
强制单声道:这个复选框将导致 Unity 将多声道文件更改为单个声道的音频数据。
-
后台加载:这将导致在将音频文件加载到内存时,不会暂停整个游戏。对于不需要立即使用的大型文件,最好使用这个选项。
-
预加载音频数据:这将导致音频信息尽可能快地加载。这对于需要几乎立即使用的小文件来说是最好的。
-
加载类型:这控制了在游戏播放时文件如何被加载;你可以从以下三个可用选项中选择:
-
加载时解压缩:在第一次需要时从文件中移除压缩。这个选项的开销使得它非常不适合大型文件。这对于你经常听到的短声音来说是最好的选择,比如射击游戏中的枪声。
-
内存中压缩:只有在播放时才会解压缩文件。当文件在内存中暂存时,它保持压缩状态。这对于不常听到的短到中等长度的声音来说是一个好选项。
-
流式传输:这将在播放时加载音频,例如从网络流式传输音乐或视频。这个选项最适合背景音乐等事物。
-
-
压缩格式:这允许你选择用于减少音频文件大小的压缩格式类型。PCM格式将为你提供最大的文件大小和最佳的音频质量。Vorbis格式可以为你提供最小的文件大小,但随着大小的减小,质量也会降低。ADPCM格式会根据音频文件的布局进行调整,以使文件大小处于中等水平。
-
质量:仅当选择Vorbis作为压缩格式时使用。降低此值可以减少项目中文件的大小,但同时也会使音频引入越来越多的失真。
-
采样率设置:这让你可以确定 Unity 中维护的音频文件的细节程度。保留采样率选项将保持原始文件中使用的设置。优化采样率选项将允许 Unity 为你的文件选择一个合适的设置。覆盖采样率选项将让你访问采样率的值并为你音频选择一个特定的设置。较小的值可以减少整个文件的大小,但会降低质量。
音频监听器
为了在游戏中实际听到声音,每个场景都需要一个音频监听器组件。默认情况下,任何新场景中首先包含的主相机对象以及你可能创建的任何新相机都附有音频监听器组件。你的场景中一次只能有一个音频监听器组件。如果有一个以上的组件,或者在没有组件的情况下尝试播放声音,Unity 将在你的控制台日志中填满抱怨和警告。音频监听器组件还为任何 3D 声音效果提供精确的位置定位。
音频源
音频源组件就像一个扬声器,它控制用于播放任何声音效果的设置。如果剪辑是 3D 的,此对象的位置与音频监听器组件以及所选模式的相对位置决定了剪辑的音量。以下屏幕截图显示了音频源组件的各种设置,随后是它们的解释:
-
音频剪辑:这是此音频源组件默认播放的音频文件。
-
输出:对于复杂的音频效果,可以将 Unity 的新音频混合器对象之一放在这里。这些允许你在音频最终播放之前,对音频及其可能应用的效果或混合进行具体控制。
-
静音:这是一种快速切换播放声音的音量开关的方法。
-
绕过效果:这允许你切换应用于音频源组件的任何特殊滤镜。
-
绕过听众效果:这允许音频忽略可能应用于音频监听器的任何特殊效果。这对于不应该被世界扭曲的背景音乐来说是一个好的设置。
-
绕过混响区域:这允许你控制是否让混响区域(控制环境音频的过渡区域)影响声音。
-
唤醒时播放:这将导致音频剪辑在场景加载或对象生成时立即开始播放。
-
循环:这将导致播放的剪辑在播放时重复。
-
优先级:这决定了播放文件的相对重要性。值
0表示最重要的,最适合音乐,而256表示最不重要的文件。根据系统不同,一次只能播放如此多的声音。播放文件的列表从最重要的开始,当达到这个限制时结束,如果有更多的声音超过限制,则排除那些值最低的。 -
音量:这决定了剪辑播放时的音量大小。
-
音调:这缩放了剪辑的播放速度。
-
立体声平衡:这调整了声音在左右扬声器中均匀输出的程度,向左或右扬声器倾斜。
-
空间混合:这是应用于音频源组件的 3D 效果的百分比。这影响诸如衰减和多普勒效应等因素。
-
混响区域混合:(混响区域用于创建环境音频效果之间的过渡。)这个设置让你调整这些区域将对来自这个音频源的声音产生多大影响。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_08_03.jpg
前述截图中的设置如下:
-
3D 声音设置:这包含了一组特定于播放 3D 音频剪辑的设置。音量、空间、扩散和混响选项可以通过使用组末的图表进行调整。这允许你创建更动态的过渡,当玩家接近音频源组件时:
-
多普勒级别:这决定了移动声音需要应用多少多普勒效应。多普勒效应是当声源向你靠近或远离你时,你所经历音调的变化。一个典型的例子是一辆汽车在疾驰而过时鸣喇叭。
-
音量衰减:这控制了声音随距离减小的音量。有三种类型的衰减:
-
对数衰减:这是在声源中心较近的距离处声音突然快速衰减。
-
线性衰减:这是一种与距离成正比的衰减方式,声音最大值为最小距离,最小值为最大距离。
-
自定义衰减:这允许你通过调整组末的图表来创建自定义衰减。当图表被更改时,它也会自动被选择。
-
-
如果音频监听器组件比最小距离值更近,音频将以当前音量水平播放。在此距离之外,声音将根据衰减模式逐渐减小。
-
扩散:这调整了声音在扬声器空间中覆盖的区域量。当使用一个以上的扬声器时,它变得更加重要。
-
超过最大距离值后,声音将停止过渡,基于组底部图表的情况。
-
添加背景音乐
既然我们已经了解了可用的音频设置,现在是把知识付诸实践的时候了。我们将从添加一些背景音乐开始。这将必须是一个 2D 音效,这样无论音频源组件在哪里,我们都能舒适地听到它。我们还将创建一个简短的脚本来淡入音乐,以减少音效对玩家突然而至的冲击。我们将使用以下步骤来完成这个任务:
-
我们将从创建一个新脚本开始,并将其命名为
FadeIn。 -
这个脚本从四个变量开始。第一个变量是脚本需要达到的目标音量。第二个是过渡所需秒数。第三个变量是过渡开始的时间。最后一个变量跟踪与脚本同一对象上附加的音频源组件,允许我们定期更新它,如下所示:
public float maxVolume = 1f; public float fadeLength = 1f; private float fadeStartTime = -1f; private AudioSource source; -
接下来,我们利用
Awake函数。它首先检查是否有附加的音频源组件,并用它来填充我们的source变量。如果找不到,则销毁游戏对象并退出函数:public void Awake() { source = gameObject.GetComponent<AudioSource>(); if(source == null) { Destroy(gameObject); return; } -
Awake函数通过将音量设置为0来结束,并在尚未播放时开始播放音频:source.volume = 0; if(!source.isPlaying) source.Play(); } -
为了随时间引起过渡,我们使用
Update函数。它会检查fadeStartTime变量的值是否小于零,如果是,则将其设置为当前时间。这样可以避免场景初始化可能引起的卡顿:public void Update() { if(fadeStartTime < 0) fadeStartTime = Time.time; -
接下来,函数检查过渡时间是否已经结束。如果结束了,将音频源组件的音量设置为
maxVolume,并销毁脚本以释放资源:if(fadeStartTime + fadeLength < Time.time) { source.volume = maxVolume; Destroy(this); return; } -
最后,通过计算自淡入开始以来经过的时间和过渡的长度之间的比例,来计算当前的进度。进度的百分比乘以
maxVolume的值,并应用于音频源组件的音量:float progress = (Time.time – fadeStartTime) / fadeLength; source.volume = maxVolume * progress; } -
回到 Unity,我们需要创建一个新的空游戏对象并将其命名为
Background。 -
将我们的
FadeIn脚本和一个音频源组件添加到我们的对象中;可以通过导航到组件 | 音频 | 音频源来找到这些。 -
如果你还没有这样做,请在你的 项目 面板中创建一个
Audio文件夹,并导入本章起始资源文件夹中包含的音频文件。由于这些文件体积小,且当前游戏的需求,它们的默认导入设置将完全适用。 -
在 层次结构 窗口中选中你的
Background对象,并将Background音频拖到 AudioClip 插槽。 -
确保在 音频源 组件中勾选了 唤醒时播放 和 循环 复选框。音量 和 空间混合 选项也需要设置为
0,以使文件在游戏中全程播放,但在开始时不会发出声音。
我们为游戏添加了背景音乐。为了让声音保持恒定且不具有方向性,我们将音乐作为 2D 声音使用。我们还创建了一个脚本,以便在游戏开始时渐入音乐。这为玩家提供了平滑过渡到游戏的方式,防止声音的突然冲击。如果你的背景音乐太大以至于无法听到游戏中的其他声音,请在你的 Background 对象的 检查器 面板中降低 最大音量 值,以获得更愉悦的体验。
背景音乐对游戏体验有很大的贡献。没有一些恐怖的音乐,恐怖场景几乎就不那么可怕了。没有那令人敬畏的音乐,老板们也就显得不那么威严了。为你的其他游戏寻找一些好的背景音乐。对于 愤怒的小鸟 来说,一些轻松愉快的音乐非常适合;而对于坦克大战游戏,则应该选择一些更具工业感且快节奏的音乐,以保持心跳加速。
戳香蕉
为了理解 3D 音频效果,我们将为香蕉添加一个声音,每当玩家触碰它们时就会触发。这将使玩家在成功触摸到香蕉时获得额外的反馈,同时还能指示被触摸香蕉的距离和方向。让我们按照以下步骤来创建这个效果:
-
首先,我们需要一个名为
BananaPoke的新脚本。 -
这个脚本有一个变量
source,用于跟踪附加到对象上的 音频源 组件:private AudioSource source; -
与我们之前的脚本一样,我们使用
Awake函数找到对 音频源 组件的引用,为我们节省了一些在编辑器中的工作:public void Awake() { source = gameObject.GetComponent<AudioSource>(); } -
当玩家在屏幕上触摸香蕉时,会向香蕉发送一条消息,调用
Touched函数。我们在第六章《移动设备的特性——触摸和倾斜》中创建的BananaBounce脚本中使用了这个函数来调整其生命值。如果我们有音频源组件,可以再次使用它来播放音效。PlayOneShot函数使用音频源组件的位置和设置来播放快速音效。如果没有这个,我们将无法从同一个音频源组件中快速连续播放许多音效。我们需要传递给它的只是要播放的音频剪辑。在这种情况下,音频剪辑已经附加到音频源组件本身:public void Touched() { if(source != null) source.PlayOneShot(source.clip); } -
然后,我们需要在项目面板中将新的脚本和音频源组件添加到
Banana预设中。 -
需要将
BananaPoke声音文件从Audio文件夹拖拽到新的音频源组件的音频剪辑槽中。 -
为了让游戏一开始不会听到烦人的爆音,取消勾选唤醒时播放选项。
-
接下来,我们想要听到触摸香蕉时距离上的差异。将空间混合设置改为
1,以便将 2D 音效转变为 3D 音效。 -
最后,我们需要将音量衰减的值更改为线性衰减,并将最大距离设置为
50。这让我们根据距离舒适且容易地听到音效的音量变化。
在 3D 世界中,我们期望大多数声音来自一个特定的方向,并且随着距离的增加而衰减。在 3D 游戏中创建类似效果,玩家能够轻松判断游戏世界中事物的位置以及它们可能有多远。这对于需要玩家能够听到潜在的敌人、障碍物或奖励的游戏尤为重要,以便他们能够找到或避开它们。
我们的坦克大战游戏有许多可以轻易潜行接近我们的敌人,因为它们在接近时没有声音。坦克通常不被认为是安静的机器。找一个引擎轰鸣声或者制作一个,并将其添加到敌方坦克中。这将给玩家一些关于敌人可能在哪里以及他们有多远的指示。此外,不同类型的坦克有不同的引擎类型。每个引擎的声音都有点不同。因此,在处理这件事时,为每种类型的坦克找到不同的引擎噪音,给玩家提供更多关于角落处可能存在的危险指示。
了解粒子系统
粒子系统为游戏的最终外观增添了很多效果。它们可以表现为火、魔法波、雨或许多其他你能想到的效果。它们通常很难制作得很好,但如果做得好,它们是值得努力的。特别是在使用移动平台时,请记住,少即是多。较大的粒子比大量粒子更有效。如果你的粒子系统在一个小空间内包含成千上万的粒子,或者为了增强效果而复制自身,你需要重新考虑设计并找到更有效的解决方案。
粒子系统设置
每个粒子系统都包含大量组件,每个组件都有自己的设置。大多数可用的设置有常量、曲线、两个常量之间的随机和两个曲线之间的随机等选项。常量选项将是一个特定的值。曲线选项将是一个随时间沿曲线变化的设定值。两个随机设置在相应的值类型之间选择一个随机值。这在一开始可能看起来有些令人困惑,但随着你使用它们,它们会变得更加易懂。
正如你将在下面的屏幕截图和描述中看到的,我们将逐一了解粒子系统的每个部分:
-
粒子系统中的第一部分,即初始模块,包含了 Unity 中每个发射器使用的所有设置:
-
持续时间:这表示发射器持续的时间。循环系统在此时间后会重复自己。非循环系统在此时间后停止发射新粒子。
-
循环:这个复选框决定了系统是否循环。
-
预加热:如果勾选此复选框,如果循环系统已经有机会循环一段时间,它将开始循环。这对于应该已经点燃的火把来说很有用,而不是在玩家进入房间时开始。
-
启动延迟:当粒子系统首次触发时,这将阻止粒子系统在给定的秒数内发射粒子。
-
起始生命周期:这是一个单独的粒子将持续的秒数。
-
起始速度:这是粒子生成时最初移动的速度。
-
起始大小:这决定了粒子生成时的大小。使用较大的粒子总是比使用较小的粒子更好,因此需要更多的粒子。
-
起始旋转:这将旋转发射的粒子。
-
起始颜色:这是粒子生成时的颜色色调。
-
重力修改器:这会给粒子一个更大或更小的重力效果。
-
继承速度:如果粒子系统在移动,这将导致粒子获得其变换动量的一部分。
-
模拟空间:这决定了粒子是随游戏对象移动而移动(即局部)还是保持在它们在世界中的位置。
-
唤醒时播放:如果勾选此复选框,发射器将在生成或场景开始时立即开始发射粒子。
-
最大粒子数:这限制了该系统在单一时间内支持的粒子总数。只有当粒子的发射速率(或其生命周期)足够大以至于超过其销毁速率时,这个值才会起作用。
-
-
发射模块控制粒子的发射速度:
-
速率:如果设置为时间,它表示每秒创建的粒子数。如果设置为距离,它表示系统移动时每单位距离的粒子数。
-
爆发:这仅在将速率选项设置为时间时使用。它允许你在系统的时序中设置特定数量的粒子发射的点。
-
-
如前一个截图所示,形状模块控制系统如何发射粒子。它具有以下选项:
-
形状:这决定了发射点将采取的形式。每个选项都附带一些决定其大小的附加值字段。
-
球体:这是粒子向所有方向发射的点。半径参数决定了球体的大小。从壳体发射选项指定粒子是从球体表面发射还是从球体内部体积发射。
-
半球体:顾名思义,这是球体的一半。半径参数和从壳体发射选项在这里与球体的工作方式相同。
-
圆锥体:这在一个方向发射粒子。角度参数决定形状更接近圆锥体还是圆柱体。半径参数决定了形状发射点的大小。当从选项设置为体积或体积壳体时,使用长度参数来指定可用于生成粒子的空间量。从选项将决定粒子从哪里发射。基础从形状的底圆盘发射。基础壳体选项从圆锥体的底部但在形状的表面周围发射。体积将从形状内部的任何位置发射,而体积壳体从形状的表面发射。
-
盒子:这从类似立方体的形状发射粒子。盒子 X、盒子 Y和盒子 Z选项决定了盒子的大小。
-
网格:这允许你选择一个模型作为发射点。然后你可以选择从组成网格的每个顶点、边或三角形发射粒子。
-
圆形:这从单个点沿 2D 平面发射粒子。半径决定了发射的大小,弧度决定了使用圆的多少。从边缘发射决定粒子是从圆的内边缘还是外边缘发射。
-
边缘:这会沿着一条线从单一方向发射粒子。半径参数决定了发射区域的长度。
-
随机方向:这决定了粒子的方向是由所选形状的表面法线确定,还是随机选择。
-
-
生命周期内速度变化模块允许你在粒子生成后控制它们的动量:
-
X、Y和Z:这些定义了粒子动量沿每个轴的每秒单位数。
-
空间:这决定了速度是局部应用于系统的变换还是相对于世界。
-
-
生命周期内限制速度模块如果粒子的移动超过指定值,则会减弱其移动:
-
独立轴:这允许你为每个轴定义一个独特的值,以及该值是局部的还是相对于世界的。
-
速度:这是粒子在施加阻尼之前需要移动的速度。
-
阻尼:这是粒子速度减少的百分比。它的值可以是零到一之间的任何值。
-
-
生命周期内力变化模块为每个粒子在其生命周期内添加一个恒定的移动量:
-
X、Y和Z:这些定义了需要沿每个轴施加的力。
-
空间:这决定了力是局部应用于系统的变换,还是在世界空间中应用。
-
随机化:如果X、Y和Z是随机值,这将导致每一帧随机选择施加的力的大小,从而产生随机值的统计平均。
-
-
生命周期内颜色变化模块允许你为粒子在生成后过渡的一系列颜色进行定义。
-
按速度着色模块导致粒子在其速度变化时通过定义的颜色范围过渡:
-
颜色:这是过渡的一系列颜色。
-
速度范围:这定义了粒子必须达到的速度,以便在颜色范围的最小和最大端。
-
-
生命周期内尺寸变化模块会改变粒子在其生命周期内的尺寸。
-
按速度调整尺寸模块根据粒子的速度调整每个粒子的大小,如下所示:
-
尺寸:这是粒子过渡时调整的大小。
-
速度范围:这定义了尺寸值的每个最小和最大值。
-
-
生命周期内旋转模块在粒子被生成后随着时间的推移对粒子进行旋转。
-
按速度旋转模块使得粒子在速度更快时旋转得更多:
-
角速度:这是粒子旋转的每秒度数速度。
-
速度范围:这是如果角速度值未设置为恒定时的最小和最大范围。
-
-
外部力模块增强了风区对象的影响效果。风区模拟了风对粒子系统和 Unity 中树木的影响。
-
碰撞模块允许粒子与物理游戏世界发生碰撞和交互:
-
如果设置为平面,你可以定义多个平面供粒子碰撞。这比世界碰撞的处理速度更快:
-
平面:这是一个定义碰撞表面的变换列表。粒子只会与变换的本地、正 y 侧发生碰撞。任何在点另一侧的粒子将被销毁。
-
可视化:这为你提供了将平面显示为实体表面或网格表面的选项。
-
缩放平面:这调整了可视化选项的大小。它不会影响实际碰撞表面的尺寸。
-
粒子半径:这用于定义用于计算粒子与平面碰撞的球体的大小。
-
-
如果设置为世界,则粒子将与场景中的每个碰撞器发生碰撞。这对处理器来说可能是一个很大的负担。
-
碰撞层:这定义了一个粒子可以与之碰撞的层列表。只有在此列表中勾选的层的碰撞器将用于碰撞计算。
-
碰撞质量:这定义了此粒子系统的碰撞计算的精确度。高选项将精确计算每一个粒子的碰撞。中选项将使用近似值,并在每个帧中限制新的计算次数。低选项的计算频率低于中选项。如果碰撞质量设置为中或低,则体素大小参数决定了系统估算碰撞点的精确度。
-
-
阻尼:当粒子与表面碰撞时,这会从粒子中移除定义的比例速度。
-
弹跳:这允许粒子保持其定义的速度比例,特别是沿着被撞击表面的法线方向。
-
生命周期损失:这是生命周期的百分比。当粒子发生碰撞时,会从这个百分比中移除粒子的生命周期。随着时间的推移,或者通过碰撞,粒子的生命周期降至零时,它将被移除。
-
最小销毁速度:如果粒子在碰撞后的速度低于这个值,粒子将被销毁。
-
发送碰撞消息:如果勾选此复选框,则附加到粒子系统以及与之发生碰撞的对象上的脚本将在每一帧被告知发生碰撞。每帧只发送一条消息,而不是每个粒子。
-
-
子发射器模块允许在粒子系统的每个粒子的生命周期中的点产生额外的粒子系统:
-
出生列表中的任何粒子系统将在粒子首次创建时产生,并跟随粒子。这可以用来创建火球或烟雾轨迹。
-
碰撞列表在粒子撞击某物时产生粒子系统。这可以用于雨滴飞溅效果。
-
死亡列表在粒子被销毁时产生粒子。它可以用来产生烟花爆炸效果。
-
-
纹理图动画模块使得粒子在其生命周期内翻动一系列的粒子。所使用的纹理在渲染器模块中定义:
-
瓷砖:这定义了图中的行数和列数。这将决定可用的总帧数。
-
动画:这为您提供了整张图和单行的选项。如果此选项设置为单行,则所使用的行可以随机选择或通过使用随机行复选框和行的值来指定。
-
随时间帧:这定义了粒子在帧之间的过渡方式。如果设置为常数,系统将只使用一个帧。
-
循环:这是粒子在其生命周期内循环动画的次数。
-
-
渲染器模块决定了每个粒子在屏幕上的绘制方式,如下所示:
-
渲染模式:这定义了粒子在游戏世界中定位自己的方法:
-
广告牌:这将始终直接面向相机。
-
拉伸广告牌:这将使粒子面向相机,但会根据相机的速度、粒子的速度或特定值来拉伸它们。
-
水平广告牌:这在游戏世界的 XZ 平面上是平的。
-
垂直广告牌:这将始终面向玩家,但沿 Y 轴始终保持直立。
-
如果设置为网格,您可以定义一个模型作为粒子使用,而不是平面。
-
-
法线方向:这用于通过调整每个平面的法线来对粒子进行光照和阴影处理。值为1时,法线直接指向相机,而值为0时,法线指向屏幕中心。
-
材质:这定义了用于渲染粒子的材质。
-
排序模式:这决定了绘制粒子的顺序,按距离或年龄排序。
-
排序微调:这导致粒子系统比正常情况下更早地被绘制。值越高,它将在屏幕上越早被绘制。这影响了系统是出现在其他粒子系统或部分透明物体的前面还是后面。
-
投射阴影:这决定了粒子是否能够阻挡光线。
-
接收阴影:这决定了粒子是否会被其他物体投射的阴影影响。
-
最大粒子尺寸:这是单个粒子允许占满的屏幕空间总量。无论粒子的实际大小如何,它都不会占据超过这个屏幕空间。
-
排序层和层内顺序:这些在使用 2D 游戏时很有用。它们分别决定了粒子处于哪个层级以及在该层级中的绘制位置。
-
反射探针:这些也可以用来反射世界,而不仅仅是粒子。当反射的是世界而不是粒子时,可以使用锚点覆盖来定义一个自定义的位置来采样反射。
-
这里有大量的信息。你将最常使用初始、发射和形状模块。它们控制任何粒子系统的主要特性。其次,你可能会使用渲染器模块来改变粒子系统所使用的纹理,以及生命周期颜色模块来调整褪色效果。当这些部分有效地结合在一起时,将为你的游戏带来非常棒的效果,完善游戏的外观。学习它们能做什么的最好方法就是玩转这些设置,看看会发生什么。实验和一些教程,比如接下来的几节,是成为粒子系统创建专家的最佳途径。
创建灰尘轨迹。
为了让玩家更好地感受到角色实际上是处于世界中并与世界接触的,他们常常被赋予在环境中移动时能够踢起小灰尘云的能力。这是一个小效果,但为任何游戏增添了不少润色。我们将给我们的猴子球增加踢起小灰尘云的能力。让我们按照以下步骤进行:
-
首先,我们需要创建一个新的粒子系统,通过导航到GameObject | Particle System。将其命名为
DustTrail。 -
默认情况下,粒子系统会以圆锥形状发射小白球。对于灰尘效果,我们需要更有趣的东西。将本章
Starting Assets文件夹中的纹理导入到你的项目中的Particles文件夹里。这些是由 Unity 提供的粒子纹理,它们在引擎的旧版本中出现过。 -
接下来,我们需要在
Particles文件夹中创建一个新的材质。将其命名为DustPoof。 -
要更改新材质的Shader属性,请转到Particles | Alpha Blended,并将
DustPoof纹理放入Particle Texture图像槽中。这样可以将材质设置为部分透明,并且能够与世界以及其他正在发射的粒子良好融合。 -
要更改我们的
DustPoof粒子系统的外观,请将材质放入Renderer模块的Material槽中。 -
系统中的粒子存在时间过长且移动距离太远,因此将Start Lifetime设置为
0.5和Start Speed设置为0.2。这样粒子会在消失前仅从地面稍微升起一点。 -
我们还需要使粒子更适合我们猴子的大小。将Start Size设置为
0.3,以使它们大小适中。 -
看到所有粒子都是完全相同的方向有点奇怪。为了使方向不同,将Start Rotation更改为Random Between Two Constants,方法是点击输入字段右侧的小下拉箭头。然后,将两个新的输入字段设置为
-180和180,使所有粒子具有随机的旋转。 -
粒子的棕色是可行的,但并不总是与我们的关卡地形的颜色和性质相匹配。点击Start Color旁边的颜色字段,并使用弹出的Color Picker窗口选择基于环境的新颜色。这将使粒子在从游戏场地表面被踢起时更有意义。
-
最后,对于Initial模块,我们需要将Simulation Space设置为World,这样粒子就会随着猴子移动而留在原地,而不是跟随他。
-
在Emission中,我们需要确保有足够的粒子以产生适量的扬尘。将Rate设置为
20以产生轻微的扬尘效果。 -
接下来,我们将调整Shape模块,使粒子能够在球的整个区域下发射。确保将Shape设置为Cone,Angle设置为
25,Radius设置为0.5。 -
使用颜色随生命周期变化模块,我们可以平滑粒子的突然出现和消失。点击模块名称左侧的复选框以激活它。点击颜色右侧的白条,打开渐变编辑器窗口。在渐变编辑器中,点击颜色条上方将添加一个新的标志,该标志将控制粒子在其生命周期内的透明度。此条形的左侧对应于粒子生命的开始,右侧对应于粒子生命的结束。我们需要总共四个标志。最开始的标志,将Alpha值设置为
0,第二个标志,位置值为20,Alpha值为255,第三个标志在位置50处,Alpha为255,最后一个标志在最后,Alpha值为0。这将使尘埃粒子在开始时快速淡入,之后慢慢淡出,平滑它们的出现和消失过渡。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_08_17.jpg -
我们可以通过使用大小随生命周期变化模块,使粒子在出现和消失时增大和缩小,从而进一步平滑过渡。确保通过其名称旁边的复选框激活它。点击大小右侧的曲线条,粒子系统曲线编辑器将在检查器面板底部的预览区域中打开。在这里,我们可以调整任何小钻石形状的键,以控制粒子在其生命周期中的大小。与渐变编辑器的情况一样,左侧是粒子生命的开始,右侧是结束。右键点击它,我们可以添加新的键来控制曲线。要创建弹出效果,请将第一个键放在最左侧底部。第二个键应该放在顶部,与底部的
0.2值相对应。第三个在顶部和底部的0.4值处效果很好。第四个应该在最右侧,大约设置为左侧的0.6,这些数字表示我们在初始模块中设置的开始大小的百分比,如下截图所示:https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_08_18.jpg -
最后,为了完成我们的粒子系统的外观,我们将使用旋转随生命周期变化模块,为粒子增加一点旋转。将值更改为两个常数之间的随机值,并将两个值字段设置为
-45和45,使粒子在其生命周期中稍微旋转。 -
为了让我们的猴子使用粒子系统,将其设置为
MonkeyPivot对象的子对象,并将其位置设置为X为0,Y为-0.5,Z为0。同时,确保旋转设置为X为270,Y为0,Z为0。这将使其位于猴子球的底部并向空中抛出粒子。由于它是MonkeyPivot的子对象,它不会随着球的旋转而旋转,因为我们已经使对象补偿了球的旋转。 -
尝试一下。当我们的猴子四处移动时,他在身后留下了一条很好的灰尘轨迹。如果根据关卡的材质进行定制,这种效果可以非常出色,无论是草地、沙地、木材、金属还是其他任何材质。
-
你可能会注意到,即使我们的猴子从地图边缘飞出去,效果仍然在持续播放。我们将创建一个新脚本来根据猴子球是否真正接触地面来切换粒子效果。现在创建一个名为
DustTrail的新脚本。 -
这个脚本的第一个变量将保存对我们试图控制的粒子系统的引用。第二个变量将是一个标志,表示球是否真正接触地面:
public ParticleSystem dust; private bool isTouching = false; -
我们使用
OnCollisionStay函数来判断球是否触碰到了任何物体。这个函数与上一章中使用的OnCollisionEnter函数类似。不过,那个函数是在我们的鸟撞击到某物的那一刻被 Unity 调用的,而这个函数则是在每一帧球持续接触另一个碰撞体时被调用。当它被调用时,我们只需设置一个标志来标记我们正在触碰某物:public void OnCollisionStay() { isTouching = true; } -
因为物理系统只在
FixedUpdate循环中改变,所以我们使用这个函数来更新我们的粒子系统。在这里,我们首先检查是否正在触碰某物,并且粒子系统当前没有发射任何东西,这由其isPlaying变量指示。如果条件满足,我们使用Play函数开启粒子系统。然而,如果球没有触碰任何物体,并且粒子系统当前正在播放,我们使用Stop函数来关闭它:public void FixedUpdate() { if(isTouching && !dust.isPlaying) { dust.Play(); } else if(!isTouching && dust.isPlaying) { dust.Stop(); } -
在
FixedUpdate函数的最后,我们将标志设置为false,这样它就可以在下一帧更新我们是否需要开启或关闭粒子系统:isTouching = false; } -
接下来,将新脚本添加到
MonkeyBall对象上。正如上一章所学的,如果我们没有将它附加到与球的Rigidbody组件相同的对象上,我们将无法接收到使脚本正常工作的碰撞信息。 -
最后,将你的
DustTrail粒子系统拖放到Dust槽中,这样你的脚本才能真正控制它。 -
再试一次。现在我们的猴子可以轻松地四处移动并产生一些灰尘轨迹,直到它从关卡的边缘掉落,跳下平台,或者以其他方式悬在空中。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_08_19.jpg
我们让我们的猴子球具有扬起灰尘的能力。我们还根据球是否真正接触地面来控制灰尘的开启和关闭。这个小效果使角色在游戏中显得更加脚踏实地。它还可以根据拖尾持续的时间,让你感受到角色的速度。
我们之前讨论过的使角色更加接地气的另一个好效果是阴影。如果你还没有这样做,请确保为你的环境添加一些阴影细节。不过,你可能注意到,由于球体部分透明,实时阴影无法在其上生效。这时,我们之前在坦克上使用的 blob 阴影就派上用场了。
即使球体没有移动,我们的效果也会持续运行。尝试调整粒子系统是否播放,基于其刚体组件的速度。在上一章中,我们稍微调整了刚体组件的速度,如果你需要复习可以看看。作为一个额外的挑战,查看粒子系统的emissionRate变量。尝试让球体速度加快时,效果产生更多的粒子。
组合在一起
到目前为止,我们学习了各自独立的声音效果和粒子系统。它们各自可以为场景增添很多,设定氛围,并赋予游戏独特的润色。然而,有许多效果是无法独立存在的。例如,爆炸效果,如果没有视觉和听觉效果的结合,就不会那么令人印象深刻。
爆炸的香蕉
当事物爆炸时摧毁它们会让人感到更加满足。要制造一次恰当的爆炸,需要同时具备粒子效果和声音效果。我们将从创建一个爆炸预设开始。然后,更新香蕉,使它们在摧毁时产生爆炸。以下步骤将帮助我们创建香蕉爆炸效果:
-
首先,我们需要创建一个新的粒子系统,并将其命名为
Explosion。 -
我们希望我们的爆炸效果看起来更像是一次真正的爆炸。这时,我们的第二个粒子纹理就发挥作用了。为其创建一个新材质,命名为
Smoke。 -
这次,通过选择粒子 | 附加来设置着色器属性。这将使用一种附加混合方法,使粒子整体看起来更亮,同时仍然将粒子的 alpha 与背后的物体混合。
-
确保将新材质的粒子纹理属性设置为
Smoke。 -
同时,将你的
Smoke材质拖放到粒子系统的渲染器模块中的材质槽内。 -
我们不希望这次爆炸持续得太久。因此,在初始模块中,将持续时间设置为
0.5,并将开始生命周期设置为1,使其比原来的时间短得多。注意
当处理像爆炸这样短暂爆发的效果时,可能很难看出我们的更改如何影响粒子系统的外观。完成这个粒子系统后,我们将不得不取消勾选循环复选框,但现在保持勾选状态会使得查看和工作变得更加容易。
-
接下来,为了防止粒子飞得太远,将起始速度设置为
0.5,使爆炸效果集中且局限于一个较小的区域。 -
为了让爆炸有足够的粒子,需在发射模块中将速率设置为
120。 -
为了让爆炸看起来更真实,需要在形状模块中将形状改为球体。同时,将半径设置为
0.5。如果你对改变爆炸的大小感兴趣,可以调整半径和发射速率。两者都增加会得到更大的爆炸效果,而两者都减少则得到较小的爆炸效果。注意
这种基本的爆炸效果仅仅是一种视觉上的爆炸,大多数情况都是如此。要制作根据环境改变或受环境影响而改变外观的爆炸效果,将需要额外的脚本编写和模型考虑,这超出了本书的范围。
-
我们游戏中的爆炸效果仍然不像真正的爆炸,所有的粒子都从边缘突然出现。这时就需要用到生命周期颜色模块。首先,我们需要通过在 alpha 通道添加新标志来消除粒子的突现。在大约边缘向内
20%的位置添加两个新标志,并调整所有四个标志,使粒子在开始时淡入,结束时淡出。 -
在渐变编辑器的渐变条底部的标志控制粒子在其生命周期中过渡的颜色。为了得到一个像样的爆炸效果,我们需要再添加两个标志,一个放在三分之一的位置,另一个放在三分之二的位置,将所有四个标志均匀地间隔开。爆炸通常开始时颜色较亮,接着在爆炸能量达到顶峰时颜色更亮,然后随着能量开始消散时颜色再次变亮,最后能量完全消失时为黑色。你选择的每种颜色都会影响爆炸的颜色。对于普通爆炸,可以选择黄色和橙色。对于科幻空间爆炸,可以选择蓝色或绿色。或者,如果是异形孢子云,可以使用紫色。发挥你的想象力,选择适合你想要爆炸效果的色彩。![爆炸的香蕉]
-
现在我们已经设置好所有参数,确保勾选了Play On Awake,这样爆炸在创建的那一刻就会开始,并取消勾选Looping,这样它就不会永远播放。如果你想在这个时候测试你的粒子系统,可以查看当选择任何粒子系统时,在Scene窗口右下角出现的Stop、Simulate和Pause按钮。这些按钮就像你的音乐播放器按钮一样,控制粒子系统的播放。
-
如果我们现在开始创建爆炸效果,它们在生成初始粒子群后会仅仅停留在场景中,尽管玩家永远看不到它们。这就是为什么我们需要一个新的脚本来在它们完成作用后摆脱它们。创建一个新的脚本,并将其命名为
Explosion。 -
这个脚本有一个单一的变量,即跟踪表示其存在的粒子系统:
public ParticleSystem particles; -
它也只有一个函数。
Update函数每一帧都会检查粒子系统是否存在或者是否已经停止播放。在任一情况下,整体对象都会被销毁,这样我们可以节省资源:public void Update() { if(particles == null || !particles.isPlaying) Destroy(gameObject); } -
然后,我们需要将我们的新脚本添加到
Explosion对象中。同时,将Particle System组件拖到Script组件中的Particles槽位。 -
为了让爆炸声能被听到,我们还需要在
Explosion对象上添加一个Audio Source组件。 -
确保勾选了其Play On Awake选项。为了让声音在 3D 空间中有意义,将Spatial Blend属性设置为
1。同时,设置为Linear Rolloff,并将Max Distance设置为50,这样我们可以听到它。 -
我们的香蕉拥有和汽车一样的爆炸声音是没有意义的。相反,我们有一个很好的小爆裂声,这将使最终效果与那些仅仅减少香蕉健康值的效果区分开来。为此,在Audio Source组件的AudioClip槽位上设置
BananaPop音频文件。 -
在我们设置好所有爆炸参数后,使用
Explosion对象创建一个新的预制体,并将其从场景中删除。 -
接下来,我们需要更新
BananaBounce脚本,当它失去健康时实际生成爆炸效果。现在打开它。 -
首先,在脚本开始部分添加一个新的变量。这将简单地跟踪我们希望在香蕉失去健康后生成的预制体:
public GameObject explosion; -
接下来,我们需要在
Touched函数中使用Destroy函数后立即添加一行。这行代码仅仅在香蕉的位置创建一个新的爆炸实例:Instantiate(explosion, transform.position, transform.rotation); -
最后,在Project面板中找到你的
Banana预制体,并将Explosion预制体拖到新的Explosion槽位中。如果你不这样做,将永远不会创建爆炸效果,而且每当香蕉失去健康时 Unity 都会报错。https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/lrn-unity-andr-gm-dev/img/4691OT_08_21.jpg
如前所述截图所示,我们已经创建了一个爆炸效果。在 Unity 旧的粒子系统的一些纹理的帮助下,我们让它看起来像真正的爆炸,而不是仅仅是一团彩色的球。我们还为爆炸效果添加了声音。结合粒子系统和音频源,我们可以创建许多效果,比如我们的爆炸效果,如果只使用其中一种,效果就会显得较弱。我们还更新了香蕉,使其在被玩家摧毁时产生爆炸。尝试调整香蕉音频的平衡,每次触摸香蕉之间的音量差异以及爆炸本身。我们通过粒子系统在视觉上和通过音频源在听觉上为玩家提供的信息越多,效果就会越好。
香蕉并不是这个世界上唯一可以爆炸的东西。在我们的第二款游戏中,我们摧毁的坦克只是消失了。尝试为《坦克大战》游戏添加一些新的爆炸效果。每次坦克被摧毁时,都应该以壮观的方式爆炸。此外,无论坦克的炮弹击中什么,炮弹往往会爆炸。尝试在炮弹射击点产生爆炸效果,而不是移动红色球体。这将给玩家更好的射击目标和感觉。
《愤怒的小鸟》游戏也可以加入一些爆炸效果,尤其是黑色的小鸟。每当有东西被摧毁时,都应该释放出一些粒子效果,并产生一些声响。否则,当物体突然消失时,游戏会看起来有些奇怪。
总结
在本章中,我们了解了 Unity 中的特效,特别是音频和粒子系统。我们从了解 Unity 如何处理音频文件开始。通过为球添加背景音乐和一些吱吱声,我们将所学内容付诸实践。然后我们继续了解粒子系统,并为球创建了尘埃轨迹。最后,我们将这两种技能结合在一起,为收集香蕉时创建爆炸效果。粒子系统和音频效果为游戏的最终润色和外观增添了很多。
在下一章中,我们将通过查看 Unity 中的优化来共同完善我们的游戏体验。我们将了解一些用于追踪性能的工具。我们还将创建自己的工具来追踪脚本特定部分的性能。我们将探讨资源压缩以及我们可以更改的其他点以最小化应用程序的占用空间。最后,将讨论在使用游戏和 Unity 时最小化延迟的关键点。
更多推荐

所有评论(0)