大家好,欢迎来到IT知识分享网。
原文:zh.annas-archive.org/md5/2FE2CB27E3CC5D9AF5E1B4F678
译者:飞龙
协议:CC BY-NC-SA 4.0
第四章:设置舞台 – 相机效果与照明
在上一章中,你已经学习了构成任何游戏的基础模块:网格、材质和动画。我们创建了一个名为Tank Battle的游戏,该游戏利用了所有这些模块。
在本章中,我们将扩展 Tank Battle 游戏。我们从添加天空盒和距离雾开始。通过使用第二个相机的目标指示器叠加,继续探索相机效果。为坦克创建涡轮增压效果,将完成我们对相机效果的探讨。继续关注照明,我们将通过添加光照图和阴影来完成坦克环境的制作。
在本章中,我们将涵盖以下主题:
- 天空盒
- 距离雾
- 使用多个相机
- 调整视野
- 添加灯光
- 创建光照图
- 添加饼干(cookies)
我们将直接沿用第三章的项目,任何游戏的核心 – 网格、材质和动画。所以,在 Unity 中打开项目,我们将开始操作。
相机效果
你应该添加许多出色的相机效果,以使你的游戏达到最后的完美。在本章中,我们将介绍一些容易添加的选项。这些也将给我们的坦克游戏一个完成的外观。
天空盒和距离雾
当相机渲染游戏的帧时,它会首先清除屏幕。Unity 中的默认相机通过用渐变色给一切上色来实现这一点,模拟天空盒的外观。然后,所有的游戏网格都会绘制在这块空白的屏幕上。尽管渐变色比单一颜色看起来更好,但对于坦克的激烈战斗来说,它仍然相当乏味。幸运的是,Unity 允许我们更改天空盒。天空盒只是指形成任何游戏背景天空的一系列图像的华丽说法。距离雾与天空盒配合使用,通过在模型和背景之间平滑视觉过渡。
我们首先需要的是一个新天空盒。我们可以创建自己的,但是 Unity 为我们提供了几个非常适合我们需求的优秀天空盒。以下是获取天空盒的步骤:
- 在 Unity 编辑器的顶部,选择资源,然后点击导入包。在这个列表大约一半的位置,选择天空盒。
- 经过一点处理,一个新的窗口会弹出。在 Unity 中,包只是一个已经设置好的压缩资产组。这个窗口显示内容并允许你选择性地导入它们。我们想要全部,所以只需点击此窗口右下角的导入。
- 在项目窗口中,将添加一个名为
Standard Assets
的新文件夹。其中包含一个名为Skyboxes
的文件夹,里面包含各种天空盒材质。选择其中任何一个。你可以在检查器窗口看到,它们是使用天空盒着色器的普通材质。它们每个都有六张图片,代表一个立方体的每个方向。 - 你还会注意到,每张图片下方都有带有立即修复按钮的警告信息。这是因为所有图片都被压缩以节省导入时间和空间,但天空盒着色器需要它们以不同的格式。只需每次点击立即修复按钮,Unity 就会自动为你修复。它还将消除材质预览中的所有奇怪的黑色。
- 要将你选择的天空盒添加到游戏中,首先确保你已经加载了正确的场景。如果没有,只需在项目窗口中双击场景。这是必要的,因为我们即将更改的设置是针对每个场景的。
- 在 Unity 编辑器顶部,选择编辑,然后点击场景渲染设置。新的设置组将出现在检查器窗口中。
- 目前,我们关注的是顶部的值,即天空盒材质。只需将新的天空盒材质拖放到天空盒材质槽中,它就会自动更新。这个更改可以在游戏和场景窗口立即查看。
- 要添加距离雾,我们还需在场景渲染设置中调整这个设置。要开启它,只需勾选使用雾复选框。
- 下一个设置,雾颜色,允许你为雾选择一个颜色。选择一个接近天空盒总体颜色的颜色是好的。
- 雾模式设置是一个下拉列表,其中包含决定 Unity 计算距离雾的方法的选项。在几乎所有情况下,默认设置指数平方是适用的。
- 接下来的三个设置,密度、开始和结束,决定了有多少雾以及它从多近的距离开始。它们只会在使用这些设置的雾模式下出现。密度用于指数和指数平方雾模式,而其他设置用于线性雾模式。将雾设置在视线边缘通常会产生最佳视觉效果。将这些设置保持在指数平方,并将密度选择为
0.03
以获得良好的视觉效果。
我们已经导入了几种天空盒,并将它们添加到了场景中。距离雾设置也已开启并调整。现在,我们的场景开始看起来像一个真正的游戏。
目标指示器
另一个相当有趣的摄像头效果是使用多个摄像头。第二个摄像头可以用来制作 3D GUI、小地图,或者可能是安全摄像头弹出窗口。在下一节中,我们将创建一个系统,该系统将指向附近的的目标。使用第二个摄像头,我们将使指示器出现在玩家坦克的上方。
创建指针
我们将从创建一个指向目标的物体开始。我们将制作一个可以重复使用的预制体。但是,为此章节,你需要导入IndicatorSliceMesh.blend
起始资源,这样我们才有东西供玩家查看。它是一个饼状切片形状的网格。下面我们执行以下步骤来创建指针:
- 导入网格后,将其添加到场景中。
- 创建一个空的GameObject组件,并将其重命名为
IndicatorSlice
。 - 将网格设置为
IndicatorSlice
的子对象,并将其定位,使其沿着GameObject
的z轴指向,饼状切片的小端位于IndicatorSlice
的位置。IndicatorSlice
GameObject 将位于我们指示器的中心。创建的每个切片都将使其z轴指向目标的方向,如下图所示: - 现在,我们需要创建一个新的脚本来控制我们的指示器。在项目窗口中创建一个名为
TargetIndicator
的新脚本。 - 我们从这个脚本开始使用一对变量。第一个变量将保存对此指示器片段将指向的目标的引用。指示器也会根据目标距离的远近来增长和缩小。第二个变量将控制指示器开始增长的距离:
public Transform target; public float range = 25;
- 下一个函数将在创建指示器片段时用来设置
target
变量:public void SetTarget(Transform newTarget) { target = newTarget; }
- 最后一组代码放入
LateUpdate
函数中。使用LateUpdate
函数,指示器片段可以在我们的坦克在Update
函数中移动后指向目标:public void LateUpdate() {
- 我们首先检查
target
变量是否有值。如果是 null,则销毁指示器片段。Destroy
函数可以用来移除游戏中的任何对象。gameObject
变量由MonoBehaviour
类自动提供,并保存了对脚本组件所附加的GameObject组件的引用。销毁这个组件也会销毁所有其子对象(或附加的对象):if(target == null) { Destroy(gameObject); return; }
- 接下来,我们确定这个指示器片段距离其目标有多远。通过使用
Vector3.Distance
,我们可以轻松地计算出距离,而不需要我们自己进行数学计算:float distance = Vector3.Distance(transform.position, target.position);
- 这行代码通过使用一些精心应用的数学和
Mathf.Clamp01
函数,确定了片段的垂直缩放,即y轴。这个函数将提供的值限制在零和一之间:float yScale = Mathf.Clamp01((range – distance) / range);
- 我们使用计算出的比例来设置指示器切片的局部比例。通过调整局部比例,我们可以通过改变父对象的比例轻松控制整个指示器的大小:
transform.localScale = new Vector3(1, yScale, 1);
transform.LookAt
函数只是一种花哨、自动的方式,用于旋转 GameObject,使其z轴指向世界中的特定位置。但是,我们希望所有的指示器切片都平铺在地面上,而不是指向可能在上方我们任何目标。因此,我们首先收集目标的位置。通过将变量的y
值设置为切片的位置,我们确保切片保持平坦。当然,最后一行结束了LateUpdate
函数:Vector3 lookAt = target.position; lookAt.y = transform.position.y; transform.LookAt(lookAt); }
- 上述代码是此脚本的最后一段代码。回到 Unity,并将
TargetIndicator
脚本添加到场景中的IndicatorSlice
对象。 - 为了完成指示器,请创建它的预制件。就像我们对目标对象所做的那样。
- 最后,从场景中删除
IndicatorSlice
对象。游戏开始时,我们将动态创建切片。这需要预制件,但不需要场景中的那个。
我们创建了一个预制件,该预制件将用于指示目标的方向。创建并附加的脚本将旋转每个预制件实例,使其指向场景中的目标。它还将调整比例,以显示目标与玩家之间的距离。
控制指示器
现在我们需要创建一个控制指示器切片的脚本。这将包括在需要时创建新的切片。此外,它所附加的GameObject组件将作为我们刚刚创建的指示器切片围绕旋转的中心点。让我们执行以下步骤来完成这些操作:
- 创建一个新脚本,并将其命名为
IndicatorControl
。 - 我们从这个脚本开始使用一对变量。第一个变量将保存对刚刚创建的预制件的引用。这样,我们就可以在需要时生成预制件的实例。第二个是一个静态变量,这意味着它可以轻松访问,无需引用场景中存在的组件。游戏开始时,它将被填充为场景中此脚本实例的引用:
public GameObject indicatorPrefab; private static IndicatorControl control;
- 下一个函数将由目标使用。很快,我们将更新目标的脚本,以便在游戏开始时调用这个函数。该函数是静态的,就像前面的变量一样:
public static void CreateSlice(Transform target) {
- 这个函数首先检查静态变量中是否有对任何对象的引用。如果它是空的,等于
null
,则使用Object.FindObjectOfType
填充变量。通过告诉它我们想要查找什么类型的对象,它将在游戏中搜索并尝试找到。这是一个相对较慢的过程,不应该经常使用,但我们使用这个过程和变量,以便始终确保系统能找到脚本:if(control == null) { control = Object.FindObjectOfType(typeof(IndicatorControl)) as IndicatorControl; }
CreateSlice
函数的第二部分检查以确保我们的静态变量不为空。如果为空,它告诉实例创建一个新的指示器切片,并将目标传递给切片:if(control != null) { control.NewSlice(target); } }
- 这个脚本还有一个函数:
NewSlice
。NewSlice
函数如其名所示,当被调用时会创建新的指示器切片:public void NewSlice(Transform target) {
- 函数首先使用
Instantiate
函数创建indicatorPrefab
的副本:GameObject slice = Instantiate(indicatorPrefab) as GameObject;
- 接下来,函数将新的切片设置为控制转换的子对象,这样在我们移动时它会跟随着我们。通过将新切片的本地位置归零,我们还确保它会与我们的控制处于同一位置:
slice.transform.parent = transform; slice.transform.localPosition = Vector3.zero;
- 函数的最后一条线使用切片的
SendMessage
函数调用我们之前创建的SetTarget
函数,并传递所需的目标对象:slice.SendMessage("SetTarget", target); }
- 现在脚本已经创建好了,我们需要使用它。创建一个空的GameObject组件,并将其命名为
IndicatorControl
。 - 新的GameObject组件需要设置为坦克的子对象,然后将其在每个轴上的位置设置为 0。
- 将我们刚刚创建的脚本添加到
IndicatorControl
对象上。 - 最后,选择 GameObject,将
IndicatorSlice
预制体的引用添加到Inspector窗口中适当的槽位,通过从Project窗口拖动预制体来实现。
我们创建了一个脚本来控制目标指示器切片的生成。我们最后创建的GameObject组件还能轻松地让我们控制整个指示器的大小。我们几乎完成了目标指示器的工作。
使用第二个摄像头
如果你现在运行游戏,看起来不会有任何不同。这是因为目标还没有调用创建指示器切片的命令。我们还将在这个部分添加第二个摄像头,完成目标指示器的工作。以下步骤将帮助我们做好这件事:
- 首先打开
Target
脚本,在Awake
函数的末尾添加以下代码行。这行代码告诉IndicatorControl
脚本为这个目标创建一个新的指示器切片:IndicatorControl.CreateSlice(transform);
- 如果你现在运行游戏,可以看到指示器的运行效果。但是它可能太大,而且肯定显示在坦克内部。一个糟糕的解决方案是将
IndicatorControl
对象移动,直到整个指示器显示在坦克上方。然而,当发生爆炸,物体开始空中飞散时,它们会再次遮挡目标指示器。一个更好的解决方案是添加第二个摄像头。现在你可以通过从 Unity 编辑器顶部选择GameObject,然后点击Camera来添加。 - 此外,将摄像头设置为
Main Camera
的子对象。确保将新摄像头的位置和旋转值设置为0
。 - 默认情况下,Unity 中的每个摄像机都会有一系列组件:摄像机、光晕层、GUI 层和音频监听器。除了摄像机组件之外,其他组件通常对其他每个摄像机来说都不重要,整个场景中应该只有一个音频监听器组件。从摄像机中移除多余的组件,只留下摄像机组件。
- 在我们对摄像机进行任何其他操作之前,需要更改
IndicatorSlice
预制体的层。层用于对象之间的选择性交互。它们主要用于物理和渲染。首先在项目窗口中选择预制体。 - 在检查器窗口的顶部是标签为层的下拉列表,默认显示默认。点击下拉列表,并从列表中选择添加层…。
- 在检查器窗口中将会出现一个层列表。这些是游戏中使用的所有层。前几个被 Unity 保留使用,因此它们被灰显。其余的供我们使用。点击用户层 8右侧的输入框,并将其命名为
Indicator
。 - 再次选择
IndicatorSlice
预制体。这次,从层下拉列表中选择新的指示器层。 - Unity 会询问你是否也想改变所有子对象的层。我们希望整个对象在此层上渲染,因此我们需要选择是的,改变子对象,这样我们就能做到。
- 现在,让我们回到第二个摄像机。选择摄像机并查看检查器窗口。
- 摄像机组件的第一个属性是清除标志。这个选项列表决定了摄像机在绘制游戏中的所有模型之前,将用什么来填充背景。第二个摄像机不应该挡住第一个摄像机绘制的一切。我们从清除标志下拉列表中选择仅深度。这意味着它不会在背景中放置天空盒,而是保留已经渲染的内容,只在新内容上绘制。
- 下一个属性,剔除遮罩,控制摄像机渲染哪些层。前两个选项,无和全部,用于快速取消选择和选择所有层。对于这个摄像机,取消选择其他所有层,使得只有指示器层旁边有勾选标记。
- 要做的最后一件事是调整
IndicatorControl
的缩放,使得目标指示器不会太大或太小。
我们创建了一个系统来指示潜在目标的方向。为此,我们使用了第二个摄像机。通过调整剔除遮罩属性中的层,我们可以使摄像机只渲染场景的一部分。同时,将清除标志属性更改为仅深度,第二个摄像机可以在第一个摄像机绘制的内容上绘制。
通过移动摄像头,可以改变指示器显示的位置。如果你移动IndicatorControl
对象,它将改变目标距离和目标方向的计算方式。移动并调整第二个摄像头的角度,以便更美观地显示目标指示器。
当你移动第二个摄像头或使用加速功能(下一节内容)时,你可能会注意到目标指示器仍然可以在坦克内看到。调整主摄像头,使其不渲染目标指示器。这类似于我们如何让第二个摄像头只渲染目标指示器对象。
涡轮加速
在本章中,我们要看的最后一个摄像头效果是涡轮增压。它将在屏幕上是一个按钮,可以迅速推动玩家向前移动一段短暂的时间。摄像头效果之所以出现,是因为简单调整视野属性,就能让我们看起来移动得更快。电影中在汽车追逐场景中使用了类似的方法,让追逐看起来比实际更快。
在这一节中,我们只会制作一个脚本。该脚本将使坦克以类似于上一章中创建的ChassisControls
脚本的方式移动。不同之处在于,我们不需要按下一个按钮就能使加速生效。下面是操作步骤:
- 首先,创建一个新脚本,并将其命名为
TurboBoost
。 - 要开始脚本,我们需要四个变量。第一个变量是对坦克上
CharacterController
组件的引用。我们需要它来进行移动。第二个变量是我们在加速时移动的速度。第三个变量是加速持续的时间,以秒为单位。最后一个变量用于内部判断我们是否可以加速以及何时应该停止:public CharacterController controller; public float boostSpeed = 50; public float boostLength = 5; public float startTime = -1;
StartBoost
函数非常简单。它检查startTime
变量是否小于零。如果是,将该变量设置为由Time.time
提供的当前时间。该变量小于零意味着我们当前没有在加速:public void StartBoost() { if(startTime < 0) startTime = Time.time; }
- 我们将要使用的最后一个函数是
Update
函数。它首先检查startTime
以确定我们当前是否正在加速。如果我们没有在加速,函数会提前退出。下一行代码检查以确保我们拥有CharacterController
的引用。如果该变量为空,那么我们就无法让坦克移动:public void Update() { if(startTime < 0) return; if(controller == null) return;
- 下一行代码应该看起来很熟悉。这是让坦克移动的那一行:
controller.Move(controller.transform.forward * boostSpeed * Time.deltaTime);
- 接下来,检查我们是否处于加速的前半秒。通过将当前时间与开始加速时记录的时间进行比较,我们可以轻松地计算出我们已经加速了多长时间:
if(Time.time – startTime < 0.5f)
- 如果时间合适,我们会通过调整
fieldOfView
值来转换相机。Camera.main
值是 Unity 提供的一个引用,指向场景中使用的主相机。Mathf.Lerp
函数根据零到一之间的第三个值,从起始值向目标值移动。使用这个,相机的fieldOfView
值会在半秒内向我们的目标值移动。Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 130, (Time.time – startTime) * 2);
- 下一段代码与前面两段相同,除了在加速的最后半秒,并使用相同的方法将
fieldOfView
值恢复到默认:else if(Time.time – startTime > boostLength – 0.5f) Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, 60, (Time.time – startTime – boostLength + 0.5f) * 2);
- 最后一段代码检查我们是否完成了加速。如果是这样,将
startTime
设置为-1
以表示我们可以开始另一次加速。最后的那个大括号,当然,关闭了Update
函数:if(Time.time > startTime + boostLength) startTime = -1; }
- 接下来,将脚本添加到你的坦克上,并连接
CharacterController
引用。 - 我们快完成了。我们需要创建一个新的按钮。我们可以像以前一样做。将按钮锚定到画布的右下角,并将其定位在底盘移动控制之上。
- 最后,确保为OnClick对象选择
Tank
,并在功能中导航至涡轮增压 | StartBoost()。 - 尝试这个吧。
我们在这里创建了一个涡轮增压。我们在前一章中使用的方法同样用于移动这里的坦克。通过调整相机的视野属性,我们使坦克看起来移动得更快。
在玩游戏时,你可能会注意到即使在加速时也可以转向。尝试在ChassisControls
脚本中添加一个检查,以在加速时锁定控制。为此,你需要添加对TurboBoost
脚本的引用。
为了增加额外的挑战,尝试给加速添加一个冷却时间。让玩家不能持续使用加速。另外,如果坦克撞到某物,尝试取消加速。这是一个难题,所以这里有一个提示:先查看 Unity 文档中的OnControllerColliderHit
。
光
Unity 提供了多种光类型来照亮游戏世界。它们分别是方向光、聚光灯、点光源和区域光。这些光以不同的方式投射光线;以下是它们的详细解释:
- 方向光:这类似于太阳。它将所有光线沿单一方向投射。光的位置无关紧要,重要的是旋转。光线以一个方向投射到整个场景。这使得它非常适合初步为场景添加光线。
- 聚光灯:这就像舞台上的灯光一样工作。光线以类似圆锥的形状沿特定方向投射。因此,这也是系统计算中最复杂的光类型。Unity 对其计算光线的方式进行了重大改进,但应避免过度使用这些灯光。
- 点光源:这是在游戏中主要使用的光源类型。它向四面八方发射光线。这就像一个灯泡。
- 区域光源:这是一种特殊用途的光源。它从平面单一方向发射光线。可以把它想象成用来为酒店或餐厅做广告的大型霓虹灯。由于它们的复杂性,这些光源只能在烘焙阴影时使用。当游戏运行时,它们的计算量太大,无法使用。
在讨论灯光时,下一个明显的问题涉及到阴影,尤其是实时阴影。虽然实时阴影为场景增加了许多效果,并且在任何平台上技术上都是可能的,但它们的成本非常高。除此之外,对于所有光源类型,除了方向光,它们都是 Unity Pro 功能。总的来说,这对于一般的移动游戏来说有点过于昂贵了。
另一方面,有一些成本几乎不高的完美替代方案,它们通常比实时阴影看起来更真实。第一种替代方案是针对你的游戏环境。通常情况下,游戏中的环境在特定场景内不会移动也不会改变。为此,我们有光照图。它们是包含阴影数据的额外纹理。使用 Unity,你可以在制作游戏时创建这些纹理。然后,当游戏运行时,它们会自动应用,阴影就会出现。然而,这对于动态物体(任何会移动的东西)并不适用。
对于动态物体,我们有“cookies”。这可不是你奶奶做的饼干。在照明中,cookie 是一个黑白图像,它被投影到游戏中的网格上。这类似于皮影戏。皮影戏使用剪片来阻挡部分光线,而 cookies 则使用黑白图像来告诉光线可以投射光亮的位置。
Cookies 还可以用来创建其他静态和动态效果,比如在场景中移动的云层覆盖,或者从笼子中投射出的光线。或者,你可以使用它们来制作手电筒不均匀的焦点。
添加更多灯光
向场景中添加额外的灯光相当简单。而且,只要坚持使用点光源,渲染它们的成本就会保持较低。让我们使用以下步骤来照亮我们的游戏:
- 在 Unity 编辑器顶部,导航到 GameObject | Light | Point Light。
- 选择新的光源后,在检查器窗口中我们应该关注以下几个属性:
- 范围:这是光线从物体发射的距离。从这个点发出的光在中心位置最亮,在达到范围极限时逐渐消失。范围在场景视图中还以黄色线框球体表示。
- 颜色:这仅仅是光线的颜色。默认情况下,它是白色;然而,这里可以使用任何颜色。这个设置在所有光源类型之间共享。
- 强度:这表示光的亮度。光的强度越大,其中心的亮度也就越亮。这个设置对所有类型的灯光都是共享的。
- 创建并定位更多的灯光,沿着街道排列,为环境添加一些更有趣的元素。
- 按下 Ctrl + D 来复制选定的对象。这可以大大加快创建过程(如下面的截图所示):
- 在添加这些灯光时,你可能注意到了它们的一个主要缺点。实时影响一个表面的灯光数量是有限制的。通过使用更复杂的网格,可以在一定程度上解决这个问题。更好的选择是使用光照图,我们将在下一节中看到。
- 再次在 Unity 编辑器顶部,导航到游戏对象 | 灯光 | 聚光灯。
- 选择一盏新灯,并在检查器窗口中查看它。
聚光角度:这是这种类型灯光独有的。它决定了发射光的锥形有多宽。与范围一起,在场景视图中由一个黄色线框锥形表示。
- 在坦克大战城市中心的喷泉周围添加几个聚光灯,如下面的截图所示:
- 场景中拥有如此多的对象使得层次结构窗口显得杂乱无章,难以找到任何东西。为了组织它们,你可以使用空的游戏对象。创建一个游戏对象,并将其命名为
PointLights
。 - 通过将所有点光源设置为这个空游戏对象的子对象,层次结构窗口将变得不再那么杂乱。
我们为游戏添加了几盏灯。通过改变灯光的颜色,我们使场景看起来更有趣,玩起来也更吸引人。然而,这也揭示了照明系统的一个缺点。我们使用的城市非常简单,同时影响一个平面的灯光数量是有限制的。尽管如此,我们的场景外观得到了改善,但许多令人印象深刻的元素还是因为这一缺点而大打折扣。
光照图
光照图对于复杂的照明设置非常有效,这些设置在运行时可能过于昂贵或根本无法工作。它们还允许你在不消耗实时阴影的情况下为游戏世界添加详细的阴影。然而,这种方法只适用于在整个游戏过程中不会移动的对象。
光照图对于任何游戏环境都是一个很好的效果,但我们需要明确告诉 Unity 哪些对象不会移动,然后创建光照图。以下步骤将帮助我们完成此操作:
- 要做的第一件事是使你的环境网格静态化。为此,首先选择城市的一部分。
- 在检查器窗口的右上角,对象名称字段右侧有一个复选框和静态标签。勾选此复选框将使对象变为静态。
- 按照以下步骤将城市的所有网格设为静态:
- 如果你进行了任何形式的分组(正如我们对灯光所做的那样),那么这一步可以更快地完成,而不是逐个选择每个复选框。选择你城市的根对象,即所有城市部件、建筑和街道的父对象。
- 现在,勾选静态复选框。
- 在新的弹出窗口中,选择是,更改子对象,使所有子对象也变为静态。
- 当 Unity 生成光照图时,任何未展开或具有规范化 UV 空间外 UV 位置的网格都将被跳过。在模型导入设置窗口中,有一个选项可以让 Unity 自动生成光照图坐标,即生成光照图 UV。如果你正在使用
TankBattleCity
作为你的环境,现在应该开启这个选项。 - 转到 Unity 编辑器顶部,选择窗口,然后点击底部的光照。
- 当你查看这个窗口时,大部分时间将花在场景页面上。选择窗口顶部的场景以切换到该页面。
- 你会首先注意到这个页面的一个特点是,它具有我们在场景渲染设置中看到的相同的天光部分,我们在那里更改了天空盒。在窗口底部,我们还有所有的雾设置。
- 我们感兴趣的部分是通用 GI 设置,如下面的截图所示:
前面的截图有以下设置:
- 工作流程:此设置决定了你将使用哪种方法来处理光照图。默认选择的是旧版方法。我们希望将其更改为按需。(迭代与按需相同,但在你调整设置时尝试更新光照图。只有当你的计算机足够强大以处理此操作时,才推荐使用此选项。)
- 全局参数:此设置允许你创建你可能想要快速选择的设置。如果你有许多需要更改的场景,这将特别有用。然而,我们只有一个场景,所以现在可以忽略它。
- 天光:此设置影响场景中的环境光量。较低的值会使整个场景变暗,可能给你一个夜晚的场景。较高的值会使一切变得更亮,可能是白天的场景。此设置下面的实时天光复选框决定了此计算是在游戏运行时进行,还是在烘焙光照图时进行。取消勾选此框将节省处理资源,但勾选它将允许你在游戏运行时更改场景的亮度。因此,如果你想在游戏中看到你的灯光,将天光设置为
0.2
,并取消勾选实时天光。 - 反照率缩放:这个设置影响光线从表面反射的多少。间接光照缩放选项影响场景中来自非直接照射物体的光源的整体光照。出于我们的目的,这两个选项可以保持默认值。
- 实时 GI 设置:这一部分仅在 Unity 的新光照贴图系统中可用。它包含在游戏运行时计算的光照贴图的控件。实时分辨率和实时图集大小选项调整这些光照贴图中的细节量。CPU 使用率选项控制在游戏运行时系统将投入多少努力来计算你所看到的值。由于我们是在移动平台上工作,我们需要保持处理成本降低,所以将这些选项保持在其低默认值对我们来说很好。
- 烘焙 GI 设置:这些设置包含调整预计算光照贴图的控件。这里是你大部分调整发生的地方。首先,我们有一个方向模式复选框,它决定了当我们未选中时是否使用单一组光照贴图。或者,如果我们使用两组,其中一组用于颜色和直接光照,第二组用于间接光照。使用两组光照贴图可以提供更高的细节,尤其是在暗区,但计算和使用成本更高。所以,我们打算保持它未选中。
- 烘焙分辨率:此设置根据物体的大小控制其包含的细节量。在数字字段后面,你可以看到每单位纹理像素设置。纹理像素只是一个花哨的光照贴图像素。所以,它实际上是场景中每个单位在光照贴图中的像素细节量。出于我们的目的,
30
的值可以为我们提供足够的细节,而不会让计算机过载。
提示
烘焙分辨率设置将最直接影响实际烘焙光照贴图所需的时间。最好先使用低值开始工作,并且只有当你的光照设置接近你想要的最终产品的样子时,才增加这些值。
- 烘焙图集大小:此设置控制最终光照贴图图像的分辨率。较小的分辨率更容易处理,但你需要限制场景中最大物体的总体细节。无论你选择哪种分辨率,你的模型的单个平面都不能比单个光照贴图图集拥有更多的细节。默认的1024在细节和处理成本之间取得了很好的平衡。
- 填充:此设置调整光照贴图中对象之间的空间。过低的值将导致阴影渗透到共享光照贴图的其他对象的边缘。过高的值将导致光照贴图中浪费大量空间。同样,默认值对我们来说就很好。
- 直接缩放: 这个设置将在烘焙到光照贴图中的灯光强度进行缩放。它允许你改变场景的整体亮度。默认设置在这里同样可以正常工作。
- 环境光指数: 这个设置调整环境光照的对比度。这将使你场景中的暗区看起来更暗,亮区看起来更亮。将其保留为默认的
1
对我们来说就很好。
- 在页面底部有一个烘焙按钮。点击这个按钮将开始渲染过程。在 Unity 的右下角会出现一个加载条,这样你可以监控进度。
注意
提醒一下,这个过程可能需要一些时间。特别是随着环境复杂度和灯光数量的增加以及细节设置的提升,这个过程运行的时间会越来越长。另外,除非你有一台高性能的计算机,否则在它运行的时候在 Unity 里你几乎什么也做不了。
- 如果你点击了按钮并意识到你犯了一个错误,不要慌张。选择烘焙后,该按钮会变为取消。此时,可以选择它并停止进程继续进行。然而,一旦纹理被创建并且 Unity 开始导入它们,就无法停止这一过程。
- 在烘焙按钮的左侧是清除。这个按钮是删除和移除场景中当前使用的所有光照贴图的最快和最简单的方法。这个操作无法撤销。
- 为了给你的建筑物添加阴影,在层级中选择你场景中的方向光,并查看检查器窗口。
- 从阴影类型下拉列表中选择软阴影。这简单地为这个灯光开启了阴影。它为光照贴图和实时光照都开启阴影。开启阴影的灯光数量越多,渲染成本就越高。为你的光照贴图开启阴影是个好主意,但之后一定要关闭它们。这将为你最终的游戏节省处理资源,同时还能让你的静态场景看起来很好。
- 当你的所有灯光和设置都符合你的预期时,选择烘焙,一旦处理完成,就可以惊奇地欣赏你面前现在这个美丽的场景,如下图所示:
我们为游戏世界添加了光照贴图。仅处理这一步所需的时间就使得进行微小调整变得困难。然而,通过几次点击,我们的光照效果得到了极大的改善。之前灯光被网格破坏,现在我们有了平滑的颜色和光照区域。
当玩游戏时,人们唯一不会质疑来源的光线类型是阳光。如果看不到来源,其他任何光线看起来都会很奇怪。创建一个网格并将其添加到游戏中,以便为你要使用的灯光提供一个理由。这可以是类似于火把、路灯,甚至是发光的外星粘液球的东西。无论它们最终是什么,拥有它们都能增加完整性,这是让游戏看起来不错与看起来很棒之间的区别。
作为第二个挑战,看看你的光照贴图的质量。玩弄我们讨论的各种质量设置,看看有什么不同。还要找出分辨率可以低到什么程度,你才会注意到像素化。在运行小型移动设备屏幕时,设置是否可以更低?去发现吧。
饼干效果
饼干是增强游戏中灯光兴趣的好方法。它们使用纹理来调整光线如何发射。这种效果可以涵盖广泛的使用范围,从闪烁的晶体到笼式工业灯光,在我们的案例中,是车头灯。
通过给我们的坦克添加车头灯,我们让玩家能够控制他们世界中的灯光。使用饼干效果,我们可以让它们看起来比简单的光圈更有趣。按照以下步骤添加这些灯光:
- 从创建一个聚光灯开始。
- 将灯光放置在坦克前方并指向外侧。
- 在Inspector窗口中,将强度属性的值增加到
3
。这将使我们的车头灯更亮,就像真正的车头灯一样。 - 现在,我们需要一些饼干纹理。在 Unity 编辑器顶部,导航到Assets | Import Package | Light Cookies。
- 在新窗口中,点击Import并等待加载条完成。
- 现在我们有几个选项可以选择。在
Standard Assets
文件夹内,创建了一个名为Light Cookies
的新文件夹,其中包含了新的纹理。将Project窗口中的Flashlight拖放到Inspector窗口中Spotlight的Cookie字段上。这样就可以简单地为灯光添加一个饼干效果。 - 你可能仍然无法看到你的饼干效果。这是之前我们遇到的问题的同样结果;太多的灯光不能对同一个物体进行阴影处理。不幸的是,意味着要移动的灯光不能烘焙到光照贴图中。为了解决这个问题,在Inspector面板中将灯光的渲染模式属性更改为重要。这将给灯光优先级,使其在场景中的其他物体之前照亮一个物体。
- 如果你现在再次烘焙你的灯光,你最终会得到一个贴在建筑物墙上的饼干形状。我们需要将GI 模式更改为实时,这样光线就能被光照贴图过程忽略,但仍然能够影响场景。
- 最后,复制第二个车头灯的灯光,并使它们都成为坦克的子物体。如果车头灯不跟着我们,那它们还有什么用?
我们通过几个简短的步骤,使用 cookies 为我们的坦克创建了一对大灯。这正是许多其他游戏,尤其是恐怖游戏,创建手电筒效果的方式。
尝试编写一个脚本,允许玩家打开和关闭大灯。它应该是一个简单的按钮,用来切换灯光。查看作为灯光一部分提供的enabled
变量。
作为一项简单的挑战,创建一个位于坦克炮塔上的灯。给它一个光源。这样,玩家可以指向他们射击的地方,而不仅仅是坦克所指的方向。
阴影
阴影是一种简单且成本较低的方法,通过它你可以为角色添加阴影。自从视频游戏诞生以来,它们就一直存在。普通阴影是将物体的固体、暗色投影到另一个表面上。阴影的轮廓与物体的形状完全匹配。当角色开始随机移动时,这变得计算起来很昂贵。
阴影是一个位于角色或物体下方的黑色纹理块。它通常没有明确的形状,并且不会与它所要表示的物体的形状完全匹配。阴影通常也不会改变大小。这使得它计算起来明显更容易,成为许多代视频游戏的阴影选择。这也意味着它更适合我们的移动设备,因为在这些设备上处理速度可能很快就会成为一个问题。
我们将为我们的坦克添加一个阴影。Unity 已经为我们完成了大部分工作;我们只需将其添加到坦克上。通过以下步骤,我们可以添加阴影:
- 我们从导入 Unity 的阴影开始。回到 Unity 编辑器顶部,导航到资源 | 导入包 | 投影仪。
- 在新窗口中点击导入,并在项目窗口中查看名为
Projectors
的新文件夹,该文件夹位于Standard Assets
下创建。 - 从项目窗口将
Blob Shadow Projector
预制体拖到场景中,并将其放置在坦克上方,如下图所示: - 不幸的是,阴影出现在我们坦克的顶部。为了解决这个问题,我们再次需要利用图层。所以,选择坦克。
- 从图层下拉列表中选择添加图层…。
- 点击用户图层 9右侧的文本框,为其命名
PlayerTank
。 - 再次选择你的坦克,但这次从图层下拉列表中选择PlayerTank。
- 当新窗口弹出时,请确保选择是,更改子对象以改变整个坦克的图层。如果你不选择这个,阴影可能会出现在坦克的某些部分,而其他部分可能不会出现。
- 现在,从层次结构窗口中选择
Blob Shadow Projector
。
注意
blob 阴影是由Projector组件创建的。这个组件的工作方式与Camera组件类似。然而,它将图像投影到世界上,而不是将世界转换成图像并显示在你的屏幕上。
- 看一下Inspector窗口。我们现在关心的是Ignore Layers的值。目前,它被设置为Nothing。
- 点击Nothing,并从Layers下拉列表中选择
PlayerTank
。这将使投影仪忽略坦克,只在其下方产生 blob 阴影。 - 下一步是调整阴影的大小,使其大致与坦克的大小相匹配。调整Field of View属性的值,直到大小差不多合适。从
70
开始似乎是一个不错的选择。 - 最后一步是使
Blob Shadow Projector
成为坦克的子对象。我们需要能够带着我们的阴影移动;我们可不想失去它。
我们为坦克添加了阴影。阴影对于使物体,尤其是角色看起来实际接触地面非常有用。我们使用的 blob 阴影优于实时阴影,因为它的处理速度更快。
blob 阴影自带的纹理是圆形的,但我们的坦克大多是方形的。尝试为 blob 阴影创建自己的纹理并使用它。某种矩形纹理应该会很合适。如果最终场景中出现了长长的黑色条纹,请确保你的纹理在图像边缘周围有完全白色的边框。
如果你成功地为 blob 阴影添加了自己的纹理,那么不妨看看那门炮?炮管伸出我们的坦克,破坏了其原本的方形轮廓。使用第二个 blob 阴影,附着在炮塔上,为炮管投射阴影。这个纹理也将必须是矩形形状的。
总结
在这一点上,你应该已经非常熟悉摄像机效果和灯光。
在本章中,我们首先查看了使用多个摄像头的用法。然后,我们玩弄了涡轮增压摄像机效果。接着,我们继续对城市进行照明。当我们使用光照图时,灯光效果得到了极大的提升。最后,我们通过一些特殊的照明效果来查看饼干和 blob 阴影。
在下一章中,我们将看到为我们的游戏创建敌人。我们将使用 Unity 的寻路系统使它们四处移动并追逐玩家。在此之后,如果玩家希望保持积分,他们需要变得更加积极。
第五章:穿梭自如 – 路径寻找与人工智能
在上一章中,我们了解了相机和光照效果。我们在坦克大战游戏中添加了天空盒、灯光和阴影。我们创建了光照图来使我们的场景动态化。我们通过给坦克车头灯添加“饼干”效果来了解了投影仪。我们还通过为坦克创建了一个斑点阴影来了解了投影仪。我们还为坦克创建了一个涡轮增压功能。通过调整相机的视角,我们能够让坦克看起来比实际速度快得多。当我们完成这一章时,我们已经拥有了一个动态且令人兴奋的场景。
本章将全面介绍敌人。玩家将不能仅仅待在一个地方来积累分数。我们将向游戏中添加一个敌方坦克。通过使用 Unity 的 NavMesh 系统,坦克将能够进行路径寻找并追逐玩家。一旦发现玩家,坦克就会射击并减少玩家的得分。
在本章中,我们将涵盖以下主题:
- NavMesh
- NavMeshAgent
- 路径寻找
- 追逐和攻击 AI
- 出生点
我们将对第 四章《设置舞台 – 相机效果与光照》中的坦克大战游戏进行修改,所以加载它,我们可以开始。
理解人工智能与路径寻找
如你所猜测的,AI 是 人工智能。在最广泛的意义上,这是任何非生命体可能做的,使其看起来像是在做决定。你对此概念最熟悉的可能来自视频游戏。当一个不由玩家控制的角色选择一个武器和一个使用它的目标时,这就是 AI。
在其最复杂的形式中,人工智能试图模仿完整的人类智能和学习。然而,对于这一切真正成功来说,发生的事情仍然太多太快。视频游戏无需达到这一步。我们主要关注的是让我们的角色看起来智能,但仍然能被玩家征服。通常,这意味着不允许角色根据比真实玩家更多的信息采取行动。调整角色拥有和可以采取行动的信息量是调整游戏难度的一个好方法。
路径寻找 是 AI 的一个子集。我们一直在使用它,尽管你可能从未意识到。路径寻找正如其名,是寻找路径的行为。每次你需要找到两点之间的路时,你都在进行路径寻找。就我们的角色而言,最简单的路径寻找形式是直接向目标点直线前进。显然,这种方法在开阔平原上最有效,但当遇到任何障碍物时往往会失败。另一种方法是给游戏覆盖一个网格。使用网格,我们可以找到一个绕过任何障碍物并到达我们目标的路径。
作为路径查找的替代方法,或许最常被选择的一种是使用特殊的导航网格,即 NavMesh。这只是一个玩家永远看不到的特殊模型,但它覆盖了计算机角色可以移动的所有区域。然后以类似于网格的方式导航玩家;不同之处在于,这里使用的是网格的三角形,而不是网格的方形。这就是我们在 Unity 中将使用的方法。Unity 提供了一套很好的工具用于创建和利用 NavMesh。
NavMesh
在 Unity 中创建导航网格非常简单。这个过程与我们用于制作光照图的过程类似。我们只需标记一些要使用的网格,调整特殊窗口中的某些设置,然后点击一个按钮。所以,如果你还没有加载 Unity 中的坦克大战游戏,现在就加载它,我们可以开始操作了。
Unity 可以自动从场景中存在的任何网格生成 NavMesh。为此,首先需要将网格标记为静态,就像我们对光照图所做的那样。然而,我们并不希望或需要能够导航城市的屋顶,因此我们使用一组特殊的设置列表来指定每个对象将是什么类型的静态。让我们从以下步骤开始:
- 从层次结构窗口中选择城市,并在检查器窗口中点击Static右侧的向下箭头:
我们可以查看以下静态对象的可用选项:
- 无:此选项用于快速取消选中所有其他选项。如果所有其他选项都未被选中,此选项将被选中。
- 一切:使用此选项,你可以快速选择所有其他选项。当所有选项都被选中时,此选项也将被选中。检查器窗口中Static标签旁边的复选框与选中或取消选中一切复选框执行相同的功能。
- 光照图静态:在处理光照图时,需要选中此选项才能使它们正常工作。任何未勾选此选项的网格将不会被光照图处理。
- 遮挡静态:这是用于处理遮挡的选项。遮挡是一种运行时优化方法,只渲染实际上可以看到的对象,无论它们是否在摄像机的视图空间内。遮挡物是会阻止其他对象被看到的对象。它与被遮挡静态选项一起工作。此选项的最佳对象选择是大型且实心的。
- 批量静态:这是另一个运行时优化的选项。批量渲染是将对象组合在一起然后再渲染它们的操作。它大大提高了游戏的整体渲染速度。
- 导航静态:这是我们目前主要关心的选项。任何勾选此选项的网格在计算 NavMesh 时将被使用。
- 遮挡对象静态:正如刚才提到的,这个选项与遮挡器静态配合使用,以实现遮挡的好处。遮挡对象是会被其他对象遮蔽的物体。当被遮挡器覆盖时,这个物体将不会被绘制。
- 离网格链接生成:这个选项同样与 NavMesh 计算有关。离网格链接是 NavMesh 两个不物理连接部分之间的连接,例如屋顶和街道。使用导航窗口中的几个设置和此选项,链接会自动生成。
- 反射探针静态:最后一个选项允许物体被反射探针记录。这些探针记录它们周围的一切,并生成一个可以用作反射着色器的立方体贴图。
- 为了使 NavMesh 正常工作,我们需要更改设置,以便只能导航城市的街道。你上次看到坦克从建筑物屋顶跳下或掉下来是什么时候?因此,我们需要更改静态选项,使得只有街道勾选了导航静态。这可以通过以下两种方法之一完成:
- 第一种方法是我们逐一取消要更改的每个对象的选项。
- 第二种方法是,在层级窗口中取消勾选顶级对象的导航静态选项,当 Unity 询问是否要对所有子对象进行更改时,回答“是”。然后,只需对我们希望导航的对象重新勾选该选项。
- 现在,通过转到 Unity 的工具栏,点击窗口,然后点击菜单底部的导航来打开导航窗口。以下屏幕截图显示了制作 NavMesh 的所有工作发生的地方:
- 这个窗口由三个页面和众多设置组成:
当选择一个对象时,设置将出现在对象页面上。这两个复选框直接对应于我们刚才设置的同名的静态选项。在导航区域的下拉列表中,我们可以将 NavMesh 的不同部分分组。这些组可以用来影响路径查找计算。例如,可以设置汽车只在地面上行驶,而人类可以沿着人行道区域行走。
烘焙页面是我们感兴趣的页面;它充满了改变 NavMesh 生成方式的选项。它甚至包括了一个很好的可视化表示,展示了各种设置在顶部:
- 角色半径:这应该设置为最瘦的角色的大小。它用于防止角色走得太靠近墙壁。
- 角色高度:这是你的角色的高度。利用这一点,Unity 可以计算出并移除那些对他们来说太低而无法通过的区域。任何低于这个值的区域都被认为是太小,因此应该将其设置为你的最矮角色的高度。
- 最大坡度:在计算 NavMesh 时,任何比这个值更陡的斜坡都会被忽略。
- 步高:在使用楼梯时,必须使用这个值。这是角色可以踏上的楼梯的最大高度。
- 掉落高度:这是角色能够掉落的高度。有了这个设置,路径将包括从边缘跳下,如果这样做更快的话。
- 跳跃距离:使用这个值,角色可以在.NavMesh 的缺口处跳跃。这个值表示可以跳跃的最远距离。
- 手动体素大小/体素大小:勾选手动体素大小复选框,你可以调整体素大小的值。这是.NavMesh 的细节级别。值越低,与可见网格的匹配度越高,但计算时间会更长,存储所需的内存也更多。
- 最小区域面积:如果.NavMesh 的部分小于这个值,那么在最终的.NavMesh 中将不会使用这些部分。
- 高度网格:勾选此选项后,原始高度信息将在.NavMesh 中保持不变。除非你有特殊需要,否则这个选项应该保持关闭。系统计算需要更长的时间,存储也需要更多的内存。
第三页区域允许我们调整我们定义的每个区域的移动成本。本质上,我们的游戏世界中不同部分的移动难度如何?对于汽车,我们可以调整层次,使其在田野中移动的成本是道路上的两倍。
在窗口底部,我们有以下两个按钮:
- 清除:这个按钮移除之前创建的.NavMesh。使用这个按钮之后,你需要在再次使用路径查找之前重新烘焙.NavMesh。
- 烘焙:这个按钮开始工作并创建.NavMesh。
- 我们的城市非常简单,所以默认值对我们来说已经足够适用。点击烘焙,并观察右下角的进度条。完成之后,会出现一个蓝色网格。这就是.NavMesh,它表示角色可以移动的所有区域。
提示
可能你的坦克在移动时会稍微穿过后建筑物墙壁。如果发生这种情况,请在导航窗口中增加代理半径,直到它们不再这样做。
- 我们还需要做最后一件事。我们的导航网格.NavMesh 很完美,但如果你仔细观察,会发现它穿过了城市中心的喷泉。如果敌方坦克开始从喷泉中驶过,那就太不对了。要修复这个问题,首先选择围绕喷泉形成的网格。
- 在 Unity 的工具栏中,点击组件,然后是导航,最后是导航网格障碍。这仅仅添加了一个告诉导航系统在寻找路径时绕道的组件。由于我们已经选择了墙壁,新组件的大小将自动适应;我们只需要从形状下拉列表中选择胶囊。你可以在场景视图中看到一个线框圆柱体表示它。
我们创建了 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。在检查器窗口中,我们可以看到新组件及其相关设置,如下面的截图所示:所有这些设置让我们可以控制 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对象添加盒状碰撞器。炮管和履带不需要碰撞器。履带箱体已经覆盖了履带的区域,而炮管作为目标太小,无法被准确射击。
注意
如果你是在场景视图中进行这些更改,请确保点击检查器窗口中的应用按钮,以更新根预制对象。
- 需要更改的最后一点是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
文件夹中,将其转变为预制体。 - 最后,用新的点来填充城市。在每个角落放置一个会工作得很好。
在这里,我们为游戏创建了出生点。每个点都会生成一辆新坦克。当一辆坦克被摧毁时,在出生点会创建一辆新的。随意构建游戏并在你的设备上尝试。这一节和这一章现在完成了,准备收尾。
为每辆坦克设置一个出生点是很好的,直到我们想要很多坦克,或者希望它们都从同一个位置出生。这里的挑战是,你需要让一个出生点跟踪多辆坦克。如果任何一辆坦克被摧毁,应该创建一辆新的。你肯定需要一个数组来跟踪所有坦克。此外,你还可以为出生过程实现一个延迟,这样就不会有多个坦克堆叠在同一个位置出生。这可能导致它们突然跳跃,因为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
立方体的每一边放置:
现在我们有一个基本的测试环境,它将允许我们使用控制装置,而不必担心整个关卡的所有复杂性。一旦我们的控制装置在这个环境中按照我们想要的方式工作,我们将把我们的猴子引入到一个新的环境。
倾斜控制
现代移动设备提供了各种各样的内部传感器来检测并提供关于周围世界的信息。尽管你可能没有这样想过,但你一定非常熟悉用于打电话的麦克风和扬声器。还有用于连接互联网的 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
。
我们利用陀螺仪为你提供了球的转向控制。通过测量玩家倾斜设备的方式,我们能够相应地给球添加运动。通过在简单地图上滚动,我们可以微调我们的控制,确保一切正常工作。
与相机一起跟随
为了让玩家真正感觉到他们正在控制球,相机需要跟随球移动。当地图和关卡变得比一个相机镜头能展示的更大更复杂时,这一点尤为重要。最简单的解决方案是将相机设置为球的子对象,但这会使它与球一起旋转,我们的控制也会变得混乱。所以,让我们按照以下步骤设置相机跟随球移动:
- 我们首先需要创建一个新的脚本,并将其命名为
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
脚本的子对象,并将位置归零。 - 接下来,将猴子添加到场景中,并将其设置为
MonkeyPivot
GameObject 的子对象。 - 为了更容易看到球内的猴子,我们需要让猴子稍微透明一些。选择
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槽中。猴子将旋转以面向球的移动方向,同时保持直立:
我们刚刚完成了将猴子添加到球中的工作。通过给猴子一个酷炫的姿势,玩家会更多地将其作为一个角色来参与。然而,当猴子在球内疯狂旋转时,看起来有点奇怪,因此我们更新了脚本,使他能保持直立并面向球的移动方向。现在,它几乎看起来像是猴子在控制球。
保持猴子在板上
如果游戏中没有失败的风险,那还有什么乐趣?为了测试我们的猴子和倾斜控制,我们在基本环境周围设置了一个安全围栏,防止它们翻倒。然而,每个游戏都需要一点风险来增加刺激感。通过移除安全围栏,我们引入了翻倒和游戏失败的风险。但是,通常如果你掉落了,会有重试游戏的选择。为此,我们现在将创建一个传统上称为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脚本组件上的适当槽位。
我们刚刚完成了一个设定,玩家可以通过这个设定赢得或输掉游戏。如果你现在尝试一下,你应该能在屏幕左上角看到计时器倒计时。当你及时到达终点线时,会显示一条好消息来提示你。如果你没能成功到达,则会显示另一条消息。
这是我们将为这款游戏创建的整个界面,但它仍然相当乏味。利用你在第二章中学到的技能,看起来不错——图形界面来设计界面。它应该看起来令人愉悦和兴奋,甚至可以是猴子主题的。为了使其更高级,你可以尝试设置它,让剩余时间接近零时改变颜色和大小,让玩家一眼就能看出完成该关卡剩余的时间。
终点线看起来也很单调,因为它只是由方块组成。尝试自己创建一个新的终点线。它可以在上面横幅上有一个终点线标志,就像比赛中的那样。也许它可以看起来更圆润一些。如果你想让它更高级,你可以考虑在终点线前面创建第二个计时器。这样玩家可以看着世界,他们的主要焦点在这里,并知道剩余的时间。
组装复杂的环境。
一个方块地图并不能提供很有趣的游戏体验。它非常适合我们设置控制,但玩家会觉得这并不有趣。因此,我们需要一些更好的东西。在这里,我们将设置一个更复杂的环境,包括斜坡、桥梁和弯道。我们还将使用一些围栏来帮助和引导玩家。让我们按照以下步骤进行:
- 首先,将
MonkeyBallMap
模型添加到场景中。 - 将其缩放属性在每个轴上设置为
100
,并将其位置属性在每个轴上设置为0
。 - 如果地图看起来是白色的,那么为其应用
Grass
纹理。这个地图为我们提供了一个良好的起点平台,一个半管斜坡,几个弯道,以及一座短桥。总的来说,玩家将面临许多基本挑战。 - 为了让我们的球能够实际使用这个地图,它需要一些碰撞器来使其具有物理特性。在层次结构窗口中展开
MonkeyBallMap
,并选择FlatBits
和HalfPipe
。 - 在这些对象上添加一个网格碰撞器组件,就像我们为坦克城市的某些部分所做的那样。记住,可以通过导航到组件 | 物理 | 网格碰撞器来找到它。
- 接下来,我们有
Fence
模型。通过这个模型,我们可以在边缘放置护栏或者在玩家路径中设置障碍来帮助或阻碍玩家。首先将Fence
模型拖入场景,并将其缩放设置为100
,以保持与地图的比例。 - 为了使围栏能够物理地阻挡玩家,它们需要一个碰撞器。对于两个子围栏对象,添加一个 BoxCollider 组件,可以通过导航到 组件 | 物理 | 盒碰撞器 来找到。
- 此外,如果围栏在场景中显示为白色,请确保你将
Wood
文理应用到两个围栏部件上。 - 创建一个新的空 GameObject 并将其命名为
Fences
。然后,将其 位置 属性在每一个轴上设置为0
。这个对象将帮助我们保持组织有序,因为最终我们可能会有很多围栏部件。 - 现在,在 层次结构 窗口中展开
Fence
模型,并使Post
和PostWithSpokes
成为Fences
空的 GameObject 的子对象。然后,删除Fence
对象。这样做,我们打破了预制体的连接,消除了重新创建它的风险。如果我们只是用Fence
对象来组织,那么如果我们对原始模型文件进行更改,就有可能删除我们在场景中设置它们时所做的一切工作。 - 我们需要将围栏放置在战略位置,以影响玩家玩游戏的方式。我们可能想要放置它们的第一个地方是起始区域周围,为玩家提供一个游戏开始的良好安全环境。记住,你可以使用 Ctrl + D 来复制围栏部件,这样你就总会有足够的围栏。
- 放置围栏的第二个地方是在半管之后,正好在桥前。在这里,它们可以帮助玩家在尝试过小桥之前重新定位自己:
- 我们可以放置围栏的最后一个地方可能会阻碍玩家。如果我们把它们放在最后平台的中间,我们就会迫使玩家绕行,并在到达终点前冒着跌落的危险。
- 说到终点线,现在其他一切都已布置完毕,我们需要将其移至适当位置。将其放置在较低平台的末端。在这里,玩家必须面对地图上的所有挑战,并在最终达成胜利前多次冒着跌落的危险。
这就是设置我们复杂环境的全部内容。我们让玩家有机会在强制他们导航一系列挑战并到达终点之前先定位自己。试一试吧。我们的游戏看起来真的很不错。
这里的第一个挑战可能相当明显。尝试自己制作一个带有坡道、桥梁、滑梯和障碍的地图。你可能可以用围栏制作一个大型迷宫。否则,你可以改变关卡,使其实际上要求玩家沿着一些直线路径和坡道向上,这意味着玩家需要足够速度来完成。可能还需要进行几次跳跃。让玩家沿着坡道下滑以获得速度,然后跳到另一个平台上。无论你的新关卡变成什么样,确保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槽位上。- 然后,把它变成一个预制体,在地图上散布一些:在初始区域放几个,在终点线附近放几个,沿途也放一些。
如果你现在尝试游戏,你应该会看到有几个快乐弹跳的香蕉。通过使用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
。 - 最后,确保点击应用按钮,位于检查器面板右上角,以更新场景中的所有香蕉。
现在尝试游戏,你应该能够触摸任何香蕉。最初,所有香蕉会像之前一样均匀地上下移动。当你触摸它们时,由于我们做的除法运算,你触摸的香蕉会移动得慢一些,然后最终消失。这让我们的玩家能够轻松地看出哪些香蕉被触摸过,哪些没有。
在游戏中拥有可收集物体之后,下一步是给玩家赋予意义。这通常是通过给它们一些积分值来实现的。在这里尝试这样做。它与我们之前摧毁敌方坦克时的积分系统非常相似。如果你之前创建了一些其他的收集物,你可以设置它们每个拥有不同的生命值。因此,它们也可以给你不同的积分。调整数字和设置,直到找到玩家互动起来会感到有趣的东西。
总结
在本章中,我们了解了现代移动设备的特点。我们创建了一个猴子球游戏来尝试这个功能。我们访问了设备的陀螺仪来检测它何时被旋转。这让我们的猴子能够被引导。在为玩家创建了一个更复杂、更有趣的运动环境后,我们创建了一串会原地旋转同时上下浮动的香蕉。我们还利用触摸屏让玩家能够收集香蕉。
在下一章中,我们将暂时放下我们的猴子球游戏。市场上最受欢迎的移动游戏之一,愤怒的小鸟,是一种独特且并不罕见的游戏类型。为了了解 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 并将脚本添加到三个触发器体积对象上。
我们已经为我们的 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
为中心点。
我们已经为游戏中将要被击倒的结构创建了所需的构建块。我们使用了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
脚本的列表中。同时,将每个文本对象放入其槽位,并将面板放入最终组槽位中。
我们创建了猪,并更新了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轴向后移动,以使其避开小鸟。
- 最后,将其整个转换为预制体,以便在其他关卡中轻松复用。
我们创建了一个用于发射小鸟的弹弓。我们使用了前一章学到的技术来处理触摸输入,并在玩家瞄准和射击时追踪玩家的手指。如果你保存你的场景,并将相机定位在观察弹弓的位置,你会注意到它已经完成,尽管还不是很完善。可以向猪堡垒发射小鸟,尽管我们只能在 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场景排在第一位,并且在右侧的索引为零。其余的场景可以按照你希望的任何顺序出现。但是要注意,它们将与按钮以相同的顺序关联。
我们创建了一个关卡选择屏幕。它有一个与游戏中关卡相关联的按钮列表。当按下按钮时,Application.LoadLevel
会开始那个关卡。我们还使用了PlayerPrefs.GetInt
来获取每个关卡的高分。
在这里,挑战在于设计 GUI 样式,使屏幕看起来很棒。一个标志和背景将大有帮助。此外,如果你有超过三个关卡,请查看滚动条GUI 对象。这个对象将允许你创建一个函数,当用户浏览比屏幕上容易看到的更大的关卡列表时,可以偏移关卡按钮。
总结
在本章中,我们了解了 Unity 中的物理系统,并重新制作了极其流行的移动游戏,愤怒的小鸟。使用 Unity 的物理系统,我们能够制作出我们想要玩的所有关卡。通过这个游戏,我们还探索了 Unity 的 2D 管线,用于创建优秀的 2D 游戏。我们的鸟和弹弓是 3D 资源,使我们能够对它们进行光照和阴影处理。然而,猪和背景是 2D 图像,这减少了我们的光照选项,但使资源具有更高的细节。2D 图像在创建背景的视差滚动效果方面也至关重要。最后,构成关卡的方块看似 2D,实际上是 3D 方块。我们还创建了一个关卡选择屏幕。从这里,玩家可以看到他们的高分并选择我们创建的任何关卡。
在下一章中,我们将回到上一章开始制作的猴子球游戏。我们将创建并添加所有使游戏完整的特殊效果。我们将添加每个猴子球游戏都需要的声音弹跳和爆裂效果。我们还会添加各种粒子效果。当香蕉被收集时,它们将产生一个小型爆炸,而不是简单地消失。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/123082.html