安卓-Unity-游戏开发入门手册-二-

237 阅读1小时+

安卓 Unity 游戏开发入门手册(二)

原文:Beginning Unity Android Game Development

协议:CC BY-NC-SA 4.0

五、构建我们的第一款 Android 游戏:球形射手游戏

就这样,我们现在准备在 Unity 中构建一个真正的 3D 手机游戏!在这一章,我们要做一个简单的游戏。基本上,我们的游戏角色将是一个有炮塔的立方体(我们称之为坦克)。使用两个操纵杆,玩家可以移动坦克和发射子弹。接下来,我们要制造一个敌人,并产生它的副本。敌人将试图与玩家的坦克走在同一方向,游戏的目的是在他们成功之前摧毁他们。

5.1 渲染管道

将图形绘制到屏幕(或渲染纹理)的过程称为渲染。这个过程是影响游戏性能的关键因素之一。默认情况下,Unity 中的主摄像头会将其视图渲染到屏幕上。

最近,Unity 发布了可脚本化渲染管道(SRP)。SRP 旨在允许开发人员通过脚本控制渲染,从而提供高度的定制化。

在许多可以使用 SRP 创建的渲染管道中,Unity 提供了两个预建的 SRP:高清渲染管道(HDRP)和通用渲染管道(URP)。

虽然 HDRP 可以让你为高端平台创建尖端的高保真图形,但我们不会用它来制作手机游戏,因为它的性能成本很高。

5.1.1 通用渲染管道(URP)

URP 是制作手机游戏的一个非常优雅的解决方案。它提供了几个图形/质量选项,可以很容易地进行调整,在许多类型的游戏中,它被证明比 Unity 用于制作新项目的默认渲染管道提供了明显的性能提升。

您可以创建一个默认使用 URP 的新项目,但是为了解释如何将它添加到现有项目中,我们将选择标准选项(图 5-1 )。

img/491558_1_En_5_Fig1_HTML.jpg

图 5-1

制作新项目

首先,我将为我们将要制作的游戏设定 1920 × 1080 的分辨率或 16:9 的纵横比(图 5-2 )。

img/491558_1_En_5_Fig2_HTML.jpg

图 5-2

我的编辑器的布局

最后,对于这一部分,我们将添加 URP 包到我们的游戏和切换我们的游戏项目,以利用它。前往➤窗口软件包管理器。在长长的软件包列表加载之前,您可能需要等待一段时间。如果看起来所有的东西都已经被加载了,但没有出现这种情况,请在包管理器窗口中点击。

滚动或搜索通用 RP 包。完成后,点击它。点击 install 并等待所有东西被导入(图 5-3 )。完成后,您可以关闭软件包管理器窗口,因为我们不再需要任何软件包。

img/491558_1_En_5_Fig3_HTML.jpg

图 5-3

从软件包管理器窗口安装通用 RP 软件包

最后,要允许我们的项目使用 URP,我们必须告诉它这样做。首先,右键单击项目窗口中的任意位置,然后单击“创建➤渲染➤通用渲染管道➤管道资源(正向渲染器)”。这将为 URP 创造一个素材,许多属性可以调整,以轻松地改变我们的游戏的图形/质量。两个新素材应该出现在您的项目窗口中(图 5-4 )。

img/491558_1_En_5_Fig4_HTML.jpg

图 5-4

URP 管道素材

此时,您必须知道不同属性的作用。我们现在剩下要做的就是将我们刚刚创建的 URP 管道素材拖放到编辑➤项目设置➤图形中的可脚本化渲染管道设置选项卡(图 5-5 )中。

img/491558_1_En_5_Fig5_HTML.jpg

图 5-5

在项目设置的图形部分添加 SRP

要记住的一件重要事情是,如果您正在处理一个项目,并且您决定切换其渲染管道,您必须确保您正在处理的所有材质都使用与您要使用的新渲染管道兼容的着色器。否则,你的场景/游戏窗口中的所有东西都将呈现粉红色。幸运的是,Unity 提供了一个简单的解决方案。

如果您要切换到的新渲染管道是 URP(您必须为 HDRP 做类似的事情),除了我在本节前面讨论的所有内容,您还必须单击编辑➤渲染管道➤通用渲染管道➤升级项目材质到通用 RP 材质。这样做将自动升级项目中的所有材质,以使用 URP 提供的等效着色器。您也可以选择第二个选项,即根据您的需要,仅升级您选择的材质。

要完成这一部分并开始有趣的部分,只需将项目的构建平台切换到 Android。打开构建设置(Ctrl+Shift+B 或文件➤构建设置),单击 Android 选项,确保它高亮显示,并点击切换平台(在左下角附近找到)。一个 Unity 的 logo 应该会出现在它右边的 Android 标签旁边,你可以关闭构建设置窗口(图 5-6 )。通常,如果切换到不同平台的步骤是在游戏开发的后期完成的,许多素材,如精灵或纹理,将不得不再次进行,这将花费相当多的时间。这就是为什么最好在游戏开发的早期阶段就切换平台,如果你确定你主要开发什么平台的话。

img/491558_1_En_5_Fig6_HTML.jpg

图 5-6

切换到 Android 构建平台

5.2 环境

目前,这款游戏只会有一个地面和一些看不见的墙。地面本身只会是一个大立方体。右键单击等级选项卡,然后单击 3D 对象➤立方体。选择后者后,确保在“检查器”标签中将它的位置和旋转设定为(0,0,0)。给它一个(150,0,150)的标度。图 5-7 显示了它的转换应该是什么样子。

img/491558_1_En_5_Fig7_HTML.jpg

图 5-7

地面游戏对象的变换组件

接下来,前往编辑➤项目设置➤标签和层,并创建一个地面标签。将平面重命名为 Ground,并为其指定该标签。您也可以将其标记为静态(图 5-8 )。

img/491558_1_En_5_Fig8_HTML.jpg

图 5-8

将地面游戏对象标记为静态

对于不可见的墙,创建一个空的游戏对象,将其命名为墙,并重置其变换组件,使其位置和旋转为(0,0,0),比例为(1,1,1)。您也可以将其标记为静态(图 5-9 )。

img/491558_1_En_5_Fig9_HTML.jpg

图 5-9

墙壁游戏对象的变换组件

创建一个新的立方体游戏对象作为墙的子对象,并将其命名为墙 1。给它一个位置(0,0,-75),一个旋转(0,0,0),一个刻度(150,50,1)。如果你看着你的场景窗口,立方体通常应该在你地面的前沿(图 5-10 )。

img/491558_1_En_5_Fig10_HTML.jpg

图 5-10

场景中的地面和墙壁 1 游戏对象

在 Wall 1 游戏对象上,禁用它的网格渲染器组件(勾选网格渲染器标签旁边的复选框),这样墙实际上是不可见的。图 5-11 显示了我们在第一面墙上发现的所有组件。

img/491558_1_En_5_Fig11_HTML.jpg

图 5-11

墙上的组件 1 游戏对象

现在,简单地复制(Ctrl+D)墙 1 游戏对象三次,并将新的实例放置在地面的剩余边缘。下表将为您提供必要的转换值。

|

名字

|

位置

|

循环

|

规模

| | --- | --- | --- | --- | | 墙壁 1 | (0, 0, -75) | (0, 0, 0) | (150, 50, 1) | | 墙壁 2 | (-75, 0, 0) | (0, 90, 0) | (150, 50, 1) | | 墙壁 3 | (0, 0, 75) | (0, 0, 0) | (150, 50, 1) | | 墙壁 4 | (75, 0, 0) | (0, 90, 0) | (150, 50, 1) |

到目前为止,我们的层级应该是这样的(图 5-12 ):

img/491558_1_En_5_Fig12_HTML.jpg

图 5-12

当前出现在场景中的游戏对象,如层级中所示

如果你选择了墙壁游戏对象或者所有真实的 3D 墙壁,你的场景应该是这样的(图 5-13 ):

img/491558_1_En_5_Fig13_HTML.jpg

图 5-13

预览地面和不可见的墙壁游戏对象

为了完成这一节,我们只需要添加一些其他类型的材质到我们的地面。如果它保持这样,玩家可能很难察觉他们的坦克在移动(如果屏幕上只有地面和坦克)。作为一个解决方案,我们可以使用网格纹理的材质。为了让游戏看起来更有趣,让我们使用一个卡通风格的石头纹理。

在项目窗口中,创建两个新文件夹:一个名为 Materials,另一个名为 Textures。然后,前往素材商店(Ctrl+9 或窗口➤素材商店),搜索石材地板,按价格排序(从低到高),下载并导入如图 5-14 所示的素材。

如果该素材不再可用,请从以下链接下载: https://raw.githubusercontent.com/EdgeKing810/SphereShooter/master/Assets/Textures/Stone_floor_09.png 。通过将它从文件管理器拖放到项目窗口的 Unity 窗口,将其导入编辑器。然后,将导入的纹理放在名为 Textures 的文件夹中。

img/491558_1_En_5_Fig14_HTML.jpg

图 5-14

素材商店中的石材地面纹理瓷砖素材

当您从商店导入素材时,名为 stone_floor_texture 的新文件夹一定已经形成。将 Stone_floor_09 纹理(看起来像正方形的)移动到您在上一步中创建的纹理文件夹中(拖放),并删除 stone_floor_texture 文件夹。

在您的材质文件夹中,右键单击并点击创建➤材质。命名为地面。将 Stone_floor_09 纹理拖放到地面材质上底图标签旁边的小方块中,或者单击同一标签旁边的圆形图标并选择该纹理。将底图的颜色设置为 RGBA (255,255,255,255)或十六进制 FFFFFF。金属和平滑滑块都应该设置为 0,这样游戏会有更好的外观。最后,将两个耕作值(XY)设置为 15(图 5-15 )。这将使纹理在我们的地面上水平和垂直重复 15 次。只需在场景或层级窗口中拖放地面游戏对象上的材质并保存即可。

img/491558_1_En_5_Fig15_HTML.jpg

图 5-15

地面游戏物体的材质

地面现在应该如图 5-16 所示。恭喜你,我们简单的游戏环境已经准备好了!

img/491558_1_En_5_Fig16_HTML.jpg

图 5-16

你的地面游戏对象应该是什么样子

5.3 我们的玩家(坦克)

在这一部分,我们将用一个立方体、一个球体和一个圆柱体来创建我们的玩家坦克。我们还将编写我们的第一个脚本,允许我们的坦克移动,瞄准,并用双操纵杆设置射击。

制造水箱

参考图 5-22 来了解一下我们玩家的坦克会是什么样子。

img/491558_1_En_5_Fig17_HTML.jpg

图 5-17

玩家游戏对象上的组件

  1. 让我们从做一个立方体开始。将其命名为 Player,并赋予其位置(0,1,0)。其旋转和缩放将分别为默认值(0,0,0)和(1,1,1)。

  2. 给它分配玩家标签。(默认已经存在。)

  3. 不要将玩家坦克标记为静态,因为这将阻止它以后移动。

  4. 制作两个新材质,随心所欲的命名,给它们一个自己选择的底图颜色。我将制作一个青色(0,110,255)和一个黄色(255,255,255)材质,并将它们的金属色和平滑度滑块降低到 0。

  5. 在玩家游戏对象上拖放你创建的两个材质中的一个(在我的例子中,是青色的那个)。

  6. 给玩家添加一个刚体组件,并检查所有约束(除了位置XZ),这样玩家坦克就不会在我们不希望的轴上旋转或移动(图 5-17 )。

现在,创建一个球体作为玩家游戏对象的子对象。贴上旋转体的标签。在接下来的步骤中,它将收到一个模仿坦克炮塔的圆柱体,当玩家(你)试图瞄准时,它将成为旋转的对象。给它一个位置(0,0.5,0),一个旋转(0,0,0),一个刻度(0.75,0.75,0.75)。移除它的球体碰撞器组件,让它使用我们之前创建的两种材质中的第二种。在我的情况下,我会给它黄色的材质(图 5-18 )。

img/491558_1_En_5_Fig18_HTML.jpg

图 5-18

旋转体游戏对象上的组件

要制作炮塔,请创建一个圆柱体对象作为旋转体的子对象,并将其命名为炮塔。再次,删除它的碰撞器组件(在这种情况下,胶囊碰撞器一),并给它相同的材质是用在旋转器上,使这两个物体看起来是一个单一的。刀架的位置必须为(0,0.2,0.8),旋转角度必须为(90,0,0),刻度必须为(0.4,0.8,0.4)(图 5-19 )。

img/491558_1_En_5_Fig19_HTML.jpg

图 5-19

炮塔游戏对象上的组件

我们的子弹需要从炮塔顶端射出。我们将稍后对此进行编码,但现在,只需创建一个空的游戏对象作为炮塔的子对象。它的位置为(0,1,0),旋转角度为(-90,0,0),刻度为(1,1,1)。将其命名为 bulletEnd(图 5-20 )。

img/491558_1_En_5_Fig20_HTML.jpg

图 5-20

bulletEnd 游戏对象的变换组件

此时,您的层级窗口应该如下所示(图 5-21 ):

img/491558_1_En_5_Fig21_HTML.jpg

图 5-21

在我们的场景中当前出现的游戏对象,如层级中所见

您为玩家坦克选择的颜色可能会有所不同,但在此阶段它应该类似于图 5-22 。

img/491558_1_En_5_Fig22_HTML.jpg

图 5-22

玩家坦克游戏对象的外观

设置我们的场景

在我们的游戏中实现操纵杆相关行为的一个快速而优雅的解决方案是从素材存储中导入简单的输入系统素材(图 5-23 )。

img/491558_1_En_5_Fig23_HTML.jpg

图 5-23

导入简单输入系统素材

接下来,我们希望游戏中有两个操纵杆:一个用于移动我们的坦克,另一个用于瞄准它的炮塔。创建一个 UI ➤画布。将其 Canvas Scaler 组件设置为具有屏幕大小 UI 缩放模式的缩放。您可以自由使用您选择的参考分辨率和屏幕匹配模式,但我将使用 1920 × 1080 的分辨率,并且只匹配高度(1080)(图 5-24 )。

img/491558_1_En_5_Fig24_HTML.jpg

图 5-24

画布游戏对象的组件

从你的项目窗口,拖放插件➤简单输入➤预置➤操纵杆预置在你的场景中,作为画布的孩子。你会注意到,在你的层次窗口中,新操纵杆的标签带有蓝色。这是因为它目前仍然是一个预置。您对预设(项目窗口中的实例)所做的任何更改都将应用于它在任何其他地方的任何实例,例如,在您的场景中。但是,我们不需要这种能力。你可以在你的场景中右键点击游戏杆,然后点击“解包预设”或“完全解包预设”。它会像一个普通的游戏对象那样运作。重命名为移动操纵杆。

将移动操纵杆的矩形变换的宽度和高度设置为 300。将其位置设置为(300,300)。它的孩子命名为 Thumb,应该有 150 的宽度和高度(图 5-25 )。同样,你可以自由选择其他值。

img/491558_1_En_5_Fig25_HTML.jpg

图 5-25

移动操纵杆的矩形变换

不需要更改任何其他属性,例如图像组件颜色的轴心点/锚点。您可能还会注意到游戏杆上有一个同名的脚本。这是一个脚本,它将负责使游戏杆具有交互性,并将我们的动作转化为游戏中的输入(图 5-26 )。

img/491558_1_En_5_Fig26_HTML.jpg

图 5-26

移动游戏杆的游戏杆脚本

X 轴和 Y 轴字段转换为轴的名称,用于分别表示操纵杆水平和垂直方向的-1 到 1 值。值标签将显示其数值。在运动轴中选择的选项将定义操纵杆将作用于哪些轴。价值乘数是不言自明的。如果设置为 5,操纵杆沿一个轴的数值范围将为-5 到 5。Thumb 表示操纵杆的子对象,该对象将移动以提供玩家指向操纵杆的方向的视觉反馈。移动区域半径是拇指可以移动到的离操纵杆中心的最大距离。动态操纵杆选项只是在一定的延迟后使操纵杆不可见,没有交互,并允许玩家使操纵杆出现在他们触摸屏幕的任何地方(或定义的区域)。

我们将保留这些选项,因为它们适用于移动操纵杆。复制移动操纵杆游戏对象,将新实例命名为 Look 操纵杆,并将其定位在相同的 Y 位置,但在不同的 X 位置,这等于移动操纵杆从屏幕左边缘到屏幕右边缘的距离。这些操纵杆游戏对象在左下角有一个枢轴点,使它们的 X 和 Y 位置相对于画布上的那个点。因为我已经将屏幕宽度设置为 1920(在画布缩放器中),所以我的 Look 操纵杆的新 X 位置将是 1920–300,等于 1620。只需将 Look 操纵杆上脚本的轴更改为 X 轴的 MouseX 和 Y 轴的 MouseY 即可(图 5-27 )。

img/491558_1_En_5_Fig27_HTML.jpg

图 5-27

Look 操纵杆的操纵杆脚本

我们的游戏将遵循自上而下的摄像机视角。要做到这一点,把你的主相机游戏物体放在你的坦克上面,旋转它,让它看起来向下。我的主摄像头的位置是(0,12.5,0),旋转是(90,0,0),缩放是(1,1,1)。其其他组件上的所有其他属性都设置为默认值。我还将在环境下为它的相机组件设置一个纯色,这样当坦克到达地面边缘时,就有了一个更合适的背景。我使用的是(60,70,60,255)的颜色,十六进制为 3C463C(图 5-28 )。

img/491558_1_En_5_Fig28_HTML.jpg

图 5-28

主相机游戏对象上的组件

我还旋转了我的方向灯,让地面上形成的阴影看起来更适合我(图 5-29 )。然而,这不是必需的。

img/491558_1_En_5_Fig29_HTML.jpg

图 5-29

我的平行光游戏物体上的变换组件

你的游戏窗口应该看起来像下面的截图(图 5-30 ),添加了操纵杆,相机在这一点上重新定位/旋转。

img/491558_1_En_5_Fig30_HTML.jpg

图 5-30

游戏窗口当前应该是什么样子

球员移动

是时候让我们的玩家坦克动起来了!为了保持我们的资源有组织,在项目窗口中创建一个名为 Scripts 的新文件夹。在该文件夹中,右键单击并创建一个 C#脚本。命名为 playerMovement。同样,如果你想选择另一个应用来编辑脚本,前往编辑➤首选项➤外部工具,并选择一个你想要的。然后,双击脚本将其打开。

在第三章的最后一节,我讨论了空白 Unity C#脚本中所有内容的用途,所以我不再赘述。首先,在第四行添加行using SimpleInputNamespace;,就在using UnityEngine;之后。这将使我们能够将操纵杆轴上的输入与游戏中的实际动作相匹配。

正如我们对 playerMovement 类中的第一行所做的那样,我们将创建一些变量来保存值或引用其他组件。此外,我们不会使用void Update() {},我们将删除所有评论。使您的代码看起来像下面这样:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SimpleInputNamespace;

public class playerMovement : MonoBehaviour {
 public Transform rotator;
 private Rigidbody cubeRb;

 public float speed = 5.0f;

 private Vector2 input;

 void Start() {

 }
}

rotator变量将引用一个变换组件,我们稍后将基于输入旋转它,这样我们的坦克可以用它的炮塔瞄准。由于它被标记为 public,我们可以稍后在检查器中自己将它可视化地分配给我们的脚本。cubeRb是私有变量,在检查器中不可见。我们将从脚本本身给它分配玩家坦克的刚体组件。虽然在游戏中有许多移动角色的方法,但是我们将使用的一种方法是根据移动操纵杆的输入来修改坦克(其刚体)的速度。

speed变量将包含一个浮点值,并在检查器中可见。我们将把操纵杆的输入值乘以这个值,使玩家的坦克移动得更快或更慢。

最后,我们将把我们的输入存储在一个Vector2变量中。因为我们的坦克只是沿着 X 和 Z 轴移动,所以我们不需要使用Vector3变量。注意,花括号也可以放在新的一行上(默认情况下是这样的)。当涉及到编码时,个人偏好有很多。

该脚本将被附加到我们的球员坦克,并将作为一个组件稍后。它将执行的许多移动或炮塔旋转将与我们玩家坦克的刚体有关,这将在CubeRb变量中引用。为了实现这一点,我们可以在我们的Start函数中添加一行,这样当游戏开始时,cubeRb就会被引用。

void Start() {
 cubeRb = GetComponent<Rigidbody>();
}

这一行可以解释为“获取当前游戏对象上的刚体组件,并在我们的cubeRb变量中引用它。”现在,每次我们对cubeRb变量做什么,都会直接影响到我们玩家坦克上的刚体组件。不一定要用变量,但是比每次都输入GetComponent<Rigidbody>()要方便。我们还通过在当前系统中缓存 MonoBehaviour 组件来节省性能。

为了保持我们的代码有组织和干净,我们将利用许多功能,并有一个更加模块化的方法。为了获得操纵杆输入,我们将使用下面的函数。你可以把它加在Start后面。

bool GetInput(string horizontal, string vertical) {
 input.x = SimpleInput.GetAxisRaw(horizontal) * speed;
 input.y = SimpleInput.GetAxisRaw(vertical) * speed;

 return (Mathf.Abs(input.x) > 0.01f) || (Mathf.Abs(input.y) > 0.01f);
}

基本上,我们正在创建一个名为GetInput的函数。我们将向它传递两个字符串,每个字符串分别对应于操纵杆的水平轴和垂直轴。

然后,我们将使用SimpleInput.GetAxisRaw(<axisName>)获取这些轴的当前数值,将它们乘以速度变量中保存的浮点值,并将它们存储在input的 X 或 Y 位置;,我们的Vector2变了。

此外,该函数将返回一个布尔值。如果我们的Vector2变量input的两个值中至少有一个值大于或小于但不等于 0,它将返回true。返回的值true可以被解释为“操纵杆正在被交互”,因为简单输入操纵杆在没有被保持/触摸时,其两个轴的值都是 0。

由于操纵杆轴输入可以小于 0 (-1 到 1),我们可以制定一个公式,例如“如果水平轴小于 0 或水平轴大于 0 或垂直轴小于 0 或垂直轴大于 0,则返回true,否则返回 false”,在 UnityScript 中,该公式可以写成:

if (input.x < 0 || input.x > 0 || input.y < 0 || input.y > 0) {
 return true;
} else {
 return false;
}

也许你已经注意到了,if语句中的条件本身会给出一个truefalse值,所以我们可以自己返回这个值,而不是生成一个又长又大的if-else语句。现在整个陈述已经简化为

return (input.x < 0 || input.x > 0 || input.y < 0 || input.y > 0);

我们可以通过使用已经可用的Mathf.Abs()函数来进一步简化。Abs部分代表“绝对”。这意味着,对于传递给该函数的任何数字,它都将返回其绝对值。如果你给它传递一个正值,将不会有任何变化,但如果你传递一个负值,它将被转换成一个正数。例如,向函数一次传递一个值 0、-9.88、12.5 和-78.489 将返回 0、9.88、12.5 和 78.489。这就是我如何获得前面图片中的 return 语句。请随意使用任意数量的括号,以保持代码的整洁。

为了真正移动玩家,我们将再次创建并使用另一个函数。

void MovePlayer() {
 cubeRb.velocity = Vector3.Normalize(new Vector3(input.x, 0, input.y)) * speed;
}

简而言之,我们将设置玩家坦克刚体的速度(通过使用引用它的cubeRb变量)来匹配我们的水平和垂直输入。由于我们的刚体需要一个Vector3的速度值(在 3D 轴上),我们的Vector2变量inputY值将对应于这里的 Z 轴。我们还将使我们的Vector3值正常化,这样玩家坦克在对角移动时不会跑得更快。这将迫使我们的Vector3的大小为 1,所以我们将再次乘以速度变量中的值。你还会注意到我们不退还任何东西。这是因为我们的函数被标记为void

对于上下文,我们将再次获取输入,但稍后将它们存储在输入Vector2变量中,用于负责旋转刀架的轴。rotator是一个引用带有转换组件的游戏对象的变量(我们立方体上的球体)。我们想让它绕 Y 轴旋转。默认情况下,旋转以四元数格式表示,而不是以Vector3格式表示。因此,要使用它们的变换组件根据Vector3旋转游戏对象,我们需要修改它们的eulerAngles属性。

void RotateTurret() {
 rotator.eulerAngles = new Vector3(0, Mathf.Atan2(input.x, input.y) * 180 / Mathf.PI, 0);
}

如果你学过一点三角学,你就会知道,为了求出两条线之间的角度,我们用 tan。我们正在做完全相同的事情:找到 X 和 Y 操纵杆输入之间的角度。因为我们要获得的角度是弧度形式的,我们必须把它转换成度。我们可以将该值乘以 180,然后除以 pi ( Mathf.PI)或者只乘以Mathf.Rad2Deg,本质上做的是同样的事情。最后,在获得以度为单位的角度后,我们只需创建一个新的Vector3变量,赋予它的Y值一个与我们的角度相等的值,并将其赋给我们希望旋转的游戏对象的变换的eulerAngles属性——在我们的例子中,是我们的旋转体。

为了完成这个脚本,我们必须调用我们的函数,以便使用它们。之前,我们讨论了一个名为Update()的游戏循环,它运行每一帧并执行放在其花括号内的代码。因为我们现在正在处理刚体,因此,物理相关的东西,最好利用另一个名为FixedUpdate()的函数,它每隔一段时间运行一次,而不是每帧运行一次。这会让我们的游戏看起来更流畅。

void FixedUpdate() {
 if (GetInput("Horizontal", "Vertical")) {
  MovePlayer();
}

 if (GetInput("MouseX", "MouseY")) {
  RotateTurret();
 }
}

在第一行,我们使用了GetInput函数,传递了"Horizontal""Vertical"轴。输入变量Vector2将保存这两个轴的当前值。if语句将确保MovePlayer()函数仅在玩家当前正在与移动操纵杆交互时被调用。

类似地,我们再次调用GetInput函数,但是这一次,传递 Look 操纵杆的轴。如果玩家与后者互动,那么只有炮塔(旋转体)才会旋转。如果我们没有这个检查,那么每次我们放开 Look 操纵杆时,炮塔都会跳回原来的位置(指向上),这有点破坏游戏性。

如果你被困在某个地方,这里有完整的代码。但是,总是建议您自己键入代码。函数不必在另一个函数之前或之后键入。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SimpleInputNamespace;

public class playerMovement : MonoBehaviour {
 public Transform rotator;
 private Rigidbody cubeRb;
 public float speed = 5.0f;
 private Vector2 input;

 void Start() {
  cubeRb = GetComponent<Rigidbody>();
 }

 void FixedUpdate() {
  if (GetInput("Horizontal", "Vertical")) {
   MovePlayer();
  }

  if (GetInput("MouseX", "MouseY")) {
   RotateTurret();
  }
 }

 bool GetInput(string horizontal, string vertical) {
  input.x = SimpleInput.GetAxisRaw(horizontal) * speed;
  input.y = SimpleInput.GetAxisRaw(vertical) * speed;

  return (Mathf.Abs(input.x) > 0.01f) || (Mathf.Abs(input.y) > 0.01f);
 }

 void MovePlayer() {
  cubeRb.velocity = Vector3.Normalize(new Vector3(input.x, 0, input.y)) * speed;
 }

 void RotateTurret() {
  rotator.eulerAngles = new Vector3(0, Mathf.Atan2(input.x, input.y) * 180 / Mathf.PI, 0);
 }
}

完成后,只需保存脚本并返回 Unity 编辑器。将脚本拖放到玩家坦克上或添加组件➤玩家移动。当玩家坦克被选中时,从脚本的 rotator 字段的层次中拖放 Rotator 游戏对象。进入游戏模式,并尝试与移动和查看操纵杆互动。一个应该使玩家坦克移动并向指定的方向前进,而另一个应该使炮塔看起来在旋转。

摄像机定位

在测试上一部分的游戏时,你可能已经注意到玩家坦克会离开屏幕。这不是我们想要的,所以,在这一节中,我们将配置主摄像机平滑地跟随玩家坦克。这一次,创建一个名为 cameraFollow 的脚本并打开它。

我们将只使用两个变量:一个名为player的转换变量,它将引用我们的玩家坦克的转换,以及一个浮点变量height

public Transform player;
public float height = 12.5f;

为了让摄像机跟随玩家,我们必须使用一个函数,比如Update(),将摄像机的位置设置为玩家的位置,除了Y值,我们将把它设置为保存在height变量中的值,这样我们就可以有一个自上而下的视图。

void LateUpdate() {
 this.transform.position = new Vector3(player.position.x, height, player.position.z);
}

代替传统的Update(),我们将使用LateUpdate(),它非常类似,但是在其他更新循环运行之后运行。将与摄像机运动相关的代码放入其中是一个很好的做法,因为这意味着在摄像机必须移动之前,所有与运动相关的代码已经被首先执行了。这是完整的代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class cameraFollow : MonoBehaviour {

 public Transform player;
 public float height = 12.5f;

 void LateUpdate() {
  this.transform.position = new Vector3(player.position.x, height, player.position.z);
 }
}

保存脚本,将它添加到主相机游戏对象中,将玩家游戏对象从层级中拖放到脚本的玩家字段中,然后点击播放按钮。相机现在应该跟随玩家坦克。

5.3.5 让玩家射出子弹

本节分为两部分:制作子弹和射击。要制作子弹,首先在你的层级中创建一个球体(3D 物体➤球体)游戏物体。将其命名为 Bullet,并赋予其位置为(0,1,2),旋转为(0,0,0),缩放为(0.3,0.3,0.3)。接下来,制作一个名为 Bullet 的标签,并将其分配给游戏对象。此外,在子弹游戏对象的网格渲染器组件中的照明属性下,将投射阴影设置为关闭,以便子弹看起来没有阴影(图 5-31 )。

img/491558_1_En_5_Fig31_HTML.jpg

图 5-31

子弹游戏对象#1 上的组件

保持球体碰撞器属性不变,然后给子弹游戏对象添加一个刚体组件。取消勾选使用重力,仅勾选限制条件下的冻结位置 Y。此时,您可能还想为子弹游戏对象创建/添加一个材质。我将使用浅绿色的(图 5-32 )。

img/491558_1_En_5_Fig32_HTML.jpg

图 5-32

子弹游戏对象#2 上的组件

最后,我们希望我们的子弹最终被销毁,这样它们就不会一直留在我们的游戏中,导致游戏性能下降。为此,创建一个名为 destroyer 的脚本,等待它完成编译(见右下角的小加载图标),然后将其添加到 Bullet 组件上。打开脚本。以下是完整的代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class destroyer : MonoBehaviour {
 public float delay = 3.0f;

 void Start() {
  Destroy(this.gameObject, delay);
 }
}

在第一行,在类内部,我们创建了一个新的名为delay的公共 float 变量,并赋予它一个初始值3。然后,在我们脚本的Start函数中,我们告诉 Unity 给Destroy这个脚本所附加的游戏对象,在对应于当前保存在delay变量中的值的几秒钟之后。如果我们不向Destroy函数传递任何第二个参数,它会在游戏一开始就立即破坏我们的 GameObject。最后,在你的项目窗口中,创建一个名为 Prefabs 的文件夹,并将子弹游戏对象从你的层级中拖放到该文件夹中。您已经成功制作了一个预制组件!你现在可以安全地从你的场景中摧毁子弹游戏物体。

在接下来的步骤中,您还需要在发射子弹时播放声音效果。你可以从 https://raw.githubusercontent.com/EdgeKing810/SphereShooter/master/Assets/Sounds/fireBullets.wav 下载我要用的那个(右击另存为)。创建一个名为 Sounds 或 Sound Effects 的文件夹,并将.wav文件或您将要使用的声音文件从文件管理器拖放到 Unity 编辑器中。接下来,将一个音频源组件添加到你的玩家坦克游戏对象中,取消勾选“唤醒时播放”,并在 AudioClip 属性中分配你刚刚导入的音频文件(图 5-33 )。

img/491558_1_En_5_Fig33_HTML.jpg

图 5-33

玩家游戏对象上的音频源组件

是时候给我们的玩家坦克发射子弹的能力了!创建一个名为 bulletSystem 的新脚本并打开它。现在与 Look 操纵杆交互只会导致旋转器旋转,从而将炮塔瞄准我们想要的方向。然而,如果我们想要拍摄,操纵杆的手柄(拇指)必须离操纵杆的中心超过一个规定的距离。接下来,我们要检查从玩家最后一次射击开始是否已经过了足够的时间,以便能够再次射击。最后,如果满足这两个条件,我们只需在 bulletEnd 位置实例化(生成)一颗子弹(空的游戏对象,是我们炮塔的子对象),给子弹一个力,推动它向前,并发出射击声。

首先,将Using SimpleInputNamespace;行添加到脚本中,因为我们稍后也将获取操纵杆输入。以下是我们将在该脚本中使用的变量:

public Transform bulletEnd;
public Rigidbody bulletPrefab;

public float force = 500.0f;

float currentTime;
public float delay = 0.5f;

AudioSource audioSource;

将引用我们的炮塔游戏对象的子对象的变换组件,在那里项目符号应该被实例化。不出所料,bulletPrefab将参考我们创建的子弹预制体。force变量中的浮点值将定义已经实例化的子弹的推进力。currentTimedelay分别代表从游戏开始发射最后一颗子弹的秒数和玩家必须等待发射另一颗子弹的秒数。最后,audioSource private变量将引用玩家坦克上的音源,稍后播放指定的音效。

void Start() {
 audioSource = GetComponent<AudioSource>();
}

Start函数中,我们将在audioSource变量中引用脚本附加到的游戏对象(我们的玩家坦克)上的音频源。

由于我们的脚本必须处理施加力,因此,物理,我们将使用FixedUpdate

 void FixedUpdate() {
  if (((Mathf.Abs(SimpleInput.GetAxisRaw("MouseX")) > 0.75f) ||
       (Mathf.Abs(SimpleInput.GetAxisRaw("MouseY")) > 0.75f)) &&
     ((Time.time - currentTime > delay) || (currentTime < 0.01f))) {

    currentTime = Time.time;
    audioSource.Play();

    Rigidbody bulletInstance = Instantiate(bulletPrefab, bulletEnd.position, bulletEnd.rotation) as Rigidbody;
    bulletInstance.AddForce(bulletEnd.forward * force);
  }
 }
}

让我们首先分析一下,如果只有true,允许FixedUpdate循环中所有指令运行的条件。

((Mathf.Abs(SimpleInput.GetAxisRaw("MouseX")) > 0.75f) || (Mathf.Abs(SimpleInput.GetAxisRaw("MouseY")) > 0.75f))

只有当MouseX和/或MouseY当前具有大于 0.75 或小于 0.75 的值时,该语句才会产生true。在前面几节中,我已经解释了 playerMovement 脚本的类似语句。接下来,我们将从该语句中获得的布尔值与下面的一个链接起来:

((Time.time - currentTime > delay) || (currentTime < 0.01f))

只有当从最后一次发射子弹起已经过了比 delay 变量中保存的值更多的秒数,或者如果currentTime小于 0.01,这意味着这是我们第一次发射子弹(所以不需要等待),这个条件才会返回true。如果这两个条件都是true(因此有了&&符号),只有这样我们才会在if语句中运行代码。

将运行的前两行将把自游戏开始以来经过的秒数的值赋给 currentTime 变量,以指示子弹最后一次发射的时间是现在,并播放在 AudioSource 组件中分配的音频剪辑。

最后,我们正在创建一个名为bulletInstance的新刚体变量,当我们在场景中的bulletEnd位置和旋转实例化(克隆)子弹预设时,我们将其分配给该变量。bulletInstance,它现在在场景中拿着我们的子弹预制的副本,将被给予一个力,该力等于在类似命名的变量中存在的值,并且在我们炮塔的向前方向上(或者在这种情况下是bulletEnd)。

以下是完整的代码,如果你错过了什么。保存脚本并返回 Unity 编辑器。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using SimpleInputNamespace;

public class bulletSystem : MonoBehaviour {
 public Transform bulletEnd;
 public Rigidbody bulletPrefab;

 public float force = 500.0f;

 float currentTime;
 public float delay = 0.5f;

 AudioSource audioSource;

 void Start() {
 audioSource = GetComponent<AudioSource>();
 }

 void FixedUpdate() {
 if (((Mathf.Abs(SimpleInput.GetAxisRaw("MouseX")) > 0.75f) || (Mathf.Abs(SimpleInput.GetAxisRaw("MouseY")) > 0.75f)) &&
 ((Time.time - currentTime > delay) || (currentTime < 0.01f))) {

   currentTime = Time.time;
   audioSource.Play();

   Rigidbody bulletInstance = Instantiate(bulletPrefab, bulletEnd.position, bulletEnd.rotation) as Rigidbody;
   bulletInstance.AddForce(bulletEnd.forward * force);
  }
 }
}

将脚本分配给玩家坦克游戏对象。在层次中展开玩家游戏对象的子对象,并将 bulletEnd 游戏对象拖放到玩家游戏对象上脚本实例的 bulletEnd 字段中。以类似的方式,从项目窗口的项目符号预置字段中拖放项目符号游戏对象。进入播放模式,测试一切正常。你现在应该会射子弹了。

5.4 敌人

在本节中,我们将制作一个球形敌人,在游戏中实例化它的副本,并使这些副本以我们的玩家坦克为目标并向其移动。当敌人与玩家或子弹相撞时,也应该被消灭。让我们马上迈出第一步。

5.4.1 树敌

首先创建一个球体。创建并给它分配一个敌人的标签,并将这个新的球体游戏对象命名为敌人。把它放在(0,1.15,10)的位置,给它一个(0,0,0)的旋转,一个(1.5,1.5,1.5)的刻度。它的网格渲染器或球体碰撞器组件不需要修改任何属性。接下来,添加一个刚体组件,取消选中使用重力,并从约束选项卡冻结游戏对象的 Y 位置。

此外,添加一个音频源组件,并取消勾选唤醒时播放。这个音频源组件将会播放敌人被消灭的声音。下载以下音频文件,并将其导入到项目先前创建的声音文件夹中:

https://github.com/EdgeKing810/SphereShooter/blob/master/Assets/Sounds/explosion0.wav

在音频源的音频片段栏中分配“爆炸 0”。此时,你可能还想在敌人的游戏对象上创建/放置一个材质。我将创建和使用一个红色的(图 5-34 和 5-35 )。

img/491558_1_En_5_Fig35_HTML.jpg

图 5-35

敌人游戏对象#2 上的组件

img/491558_1_En_5_Fig34_HTML.jpg

图 5-34

敌人游戏对象#1 上的组件

为了让我们的游戏看起来更有趣,让我们给敌人添加一个轨迹渲染器。出于某种原因,我将在稍后的脚本阶段解释,创建一个新的空游戏对象作为我们的敌人游戏对象的子对象,并将其命名为 Trail Renderer。仅编辑其变换组件,并将其放置在(0,0,0)的位置。如果我们把它放得太高,轨迹渲染器会在玩家坦克的顶部渲染。

向子 GameObject 添加一个 Trail Renderer 组件。尝试在看起来像图形的东西上设置一个大约 0.35 的宽度值(首先右键单击,以设置精确的值),在“材质”下的元素 0 位置为其指定一个您选择的材质,并将“投射阴影”设置为“关闭”,在“照明”下(图 5-36 和 5-37 )。

img/491558_1_En_5_Fig37_HTML.jpg

图 5-37

轨迹渲染器游戏对象#2 上的组件

img/491558_1_En_5_Fig36_HTML.jpg

图 5-36

轨迹渲染器游戏对象#1 上的组件

5.4.2 从商店导入另一项素材

当我们的敌人与我们的玩家或子弹相撞时,我们会想要摧毁它(我们已经可以使用Destroy()做到这一点)。我们还可以添加一些视觉效果,比如一个爆炸粒子系统。幸运的是,素材商店里有一个包,可以提供我们需要的一切。下载并导入简单外汇素材(图 5-38 )。

img/491558_1_En_5_Fig38_HTML.jpg

图 5-38

素材存储中的简单 FX-卡通粒子素材

5.4.3 使我们的敌人移动并爆炸

敌人需要能够处理和做的一切都将被放入一个脚本中。从脚本文件夹中创建并打开一个名为“敌人”的脚本。我们会制造和使用许多变量。

const string playerTag = "Player";
const string bulletTag = "Bullet";
public float minSpeed = 1.0f;
public float maxSpeed = 6.0f;
float speed;
GameObject player;
public GameObject enemyExplosionPrefab;
AudioSource audioSource;

我们的玩家和项目符号使用的标签将存储在两个字符串常量中,分别标识为playerTagbulletTag。因为我们将在我们的代码中进一步使用这些常量,所以从长远来看使用这些常量会更容易引用它们,因为如果我们将来改变这些游戏对象的标签,我们将只拥有保存在这些常量中的值,而不是我们代码中的所有引用。

我们要做的另一件事是让我们的敌人以随机速度移动,让游戏更有趣。这个随机速度将在包含在minSpeedmaxSpeed变量中的两个浮点值的范围内,并存储在一个名为speed的变量中,以备后用。

玩家游戏对象变量将被用来包含对我们的玩家坦克游戏对象的引用。由于敌人将使用预设来制造,并在我们的场景中进行实例化,所以将玩家变量设为公共变量是没有用的,因为我们无法将玩家从我们的场景拖放到我们项目中的敌人预设中。这样做是没有意义的,例如,如果一个不同的场景被打开,一个预置不能从那个场景中引用一个游戏对象。取而代之的是,我们将编写一些东西,当它被实例化时,敌人可以自动找到玩家。

下一个游戏对象变量是enemyExplosionPrefab,它将被用来引用我们之前导入的简单 FX 素材中的爆炸预设。

audioSource只是一个变量,它将引用敌人上的音频源组件来播放我们在其音频剪辑字段中分配给它的爆炸声音。

我们将在我们的Start函数中放置一些代码,这样它只被执行一次,在我们的敌人游戏对象生命周期的开始。

void Start() {
 speed = Random.Range(minSpeed, maxSpeed);
 audioSource = GetComponent<AudioSource>();
 player = GameObject.FindWithTag(playerTag);
}

首先,我们将从我们设置的最小和最大值计算一个随机速度,并使用Random.Range函数将该浮点值存储在speed变量中。Random.Range将返回一个大于等于minSpeed但小于maxSpeed的随机值。

接下来,我们将在audioSource变量中存储一个对当前游戏对象(在我们的例子中,是我们的敌人)的音频源组件的引用。我们还使用了GameObject.FindWithTag函数,将playerTag常量中的字符串作为参数传递,以引用玩家坦克中的游戏对象。将搜索一个带有我们作为参数传递的标签的游戏对象,一旦找到一个符合标准的,它将返回它。

对于游戏循环,我们可以利用Update或者FixedUpdate

void FixedUpdate() {
 if (player) {
  transform.position = Vector3.MoveTowards(transform.position, player.transform.position, speed * Time.deltaTime);
 } else {
  GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0);
 }
}

在 e 中,我们将执行两个动作中的一个,这取决于我们的场景中是否有玩家坦克游戏对象。例如,如果我们的玩家坦克游戏对象在当前场景中被摧毁,player变量将保存一个值null,而不是一个实际的游戏对象引用。

检查“如果player变量当前正在引用一个游戏对象”的方法可以是if (player != null)或简单的if (player)。从逻辑上讲,如果player变量没有null值,那么它必须对应于一个游戏对象,在我们的例子中是玩家坦克,因为它是唯一一个使用playerTag常量中的值作为标签的游戏对象。

因此,如果player变量实际上对应于某个东西,我们希望敌人向其变换组件中的位置移动。要做到这一点,我们可以简单地将敌人游戏对象的变换位置值设置为等于由Vector3.MoveTowards函数返回的Vector3值。在我们的例子中,Vector3.MoveTowards使用了三个参数。第一个是 a Vector3值(我们敌人的当前位置),我们想把它逐渐变成作为第二个参数传递的值(玩家坦克的位置)。第三个值定义了我们希望第一个值变成第二个值的速率或速度;因此,我们传递了speed变量。每次FixedUpdate运行时,将返回一个更接近玩家位置的Vector3值。当使用Update运行每一帧时,将该值乘以Time.deltaTime会使过渡更加线性和平滑。在FixedUpdate不会有什么影响。

否则,如果我们的player变量对应于null,我们将希望让我们的敌人游戏对象停止移动并留在原地。如果我们一开始没有包含那个if语句,那么如果玩家坦克游戏对象被摧毁,我们会收到很多错误,因为脚本会试图将敌人移动到null的位置,这是无效的。

我们还将利用另外两个函数。接下来是OnCollisionEnter,当敌人与任何东西发生碰撞时,它会在我们的脚本中自动运行。我们传递给这个函数的参数对应于游戏对象的碰撞器与脚本所在的游戏对象的碰撞器所造成的碰撞。在我们的例子中,该参数将等于任何游戏对象的碰撞器与我们的敌人游戏对象(的碰撞器)所造成的碰撞。我们将把 Collider 引起的碰撞称为局部变量col

void OnCollisionEnter(Collision col) {
 if (col.gameObject.CompareTag(bulletTag)) {
  Destroy(col.gameObject);
 }

 if (col.gameObject.CompareTag(playerTag) ||
     col.gameObject.CompareTag(bulletTag)) {
  DestroyEnemy();
 }
}

第一个if条件检查与我们的敌人相撞的游戏对象是否是子弹游戏对象。我们通过访问导致碰撞的碰撞器的游戏对象,然后使用保存在bulletTag常量中的字符串值作为参数,检查它是否与子弹游戏对象具有相同的标签。我们也可以编写if (col.gameObject.tag == bulletTag),但是我的编写方式是推荐的方式,这也提供了一些性能上的好处。

如果是这种情况,我们将希望摧毁刚刚碰撞的子弹游戏对象。在下一个if条件中,我们检查敌人的游戏对象是否与子弹或玩家的坦克相撞。如果发生了这种情况,我们希望调用一个名为DestroyEnemy的函数,它将决定当敌人“死亡”时应该发生什么。

void DestroyEnemy() {
 GameObject explosionInstance = Instantiate(enemyExplosionPrefab, transform.position, enemyExplosionPrefab.transform.rotation);
 Destroy(explosionInstance, 5.0f);

 audioSource.Play();

 Transform trailRenderer = transform.GetChild(0);
 if (trailRenderer) {
  trailRenderer.parent = null;
  Destroy(trailRenderer.gameObject,   trailRenderer.GetComponent<TrailRenderer>().time);
 }

Destroy(this.gameObject);
}

DestroyEnemy函数中,我们要做的第一件事是从简单的 FX 实例化敌人的爆炸预设游戏对象,在enemyExplosionPrefab变量中引用,在敌人游戏对象的位置,但是在爆炸预设本身的旋转,并且在一个新的本地GameObject变量中存储一个引用,我们将创建这个引用并命名为explosionInstance

在爆炸粒子系统被实例化并在场景中运行后,我们将在五秒钟后销毁爆炸粒子系统的游戏对象,而不是用许多无用的游戏对象来膨胀我们的场景(这只是系统完全运行的充足时间)。然后,我们将播放敌方音源组件中持有的音频片段(explosion0)。

因为我们想让我们的轨迹渲染器自动消失,而不是在敌人“死亡”时立即被摧毁,所以我们在一个名为trailRenderer的新变换变量中创建了对它的引用。调用transform.GetChild(0)返回当前游戏对象(我们的敌人游戏对象)变换的第一个子对象(0 是第一个索引)。

接下来,如果敌人有一个子游戏对象,trailRenderer不应该等于null。只有这样,我们才会将trailRenderer游戏对象的父对象或敌人游戏对象的第一个子对象设置为等于null。这将使游戏对象没有父对象,因此,不再是任何游戏对象的子对象。然而,正如我之前所讨论的,对于爆炸游戏对象的实例,我们也将销毁trailRenderer的游戏对象,但是这一次,不是考虑并给出一个合适的值,我们将获取轨迹渲染器组件本身清除其轨迹所需的时间,或者换句话说, 轨迹达到长度/宽度为 0 所需的时间,并将其作为第二个参数传递给Destroy函数,这将导致轨迹渲染器的游戏对象在轨迹长度/宽度为 0 时立即被销毁。 请记住,默认情况下,轨迹会随着时间的推移自动销毁自己的一部分。现在,你可能明白为什么我们之前为轨迹渲染器组件制作了一个新的游戏对象,而不是把它放在主要的敌人游戏对象上。

最后,我们立即摧毁敌人的游戏对象。以下是完整的脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class enemy : MonoBehaviour {
 const string playerTag = "Player";
 const string bulletTag = "Bullet";
 public float minSpeed = 1.0f;
 public float maxSpeed = 6.0f;
 float speed;
 GameObject player;
 public GameObject enemyExplosionPrefab;
 AudioSource audioSource;

 void Start() {
  speed = Random.Range(minSpeed, maxSpeed);
  audioSource = GetComponent<AudioSource>();
  player = GameObject.FindWithTag(playerTag);
 }

 void FixedUpdate() {
  if (player) {
   transform.position = Vector3.MoveTowards(transform.position, player.transform.position, speed * Time.deltaTime);
 } else {
   GetComponent<Rigidbody>().velocity = new Vector3(0, 0, 0);
  }
 }

 void OnCollisionEnter(Collision col) {
  if (col.gameObject.CompareTag(bulletTag)) {
   Destroy(col.gameObject);
  }

  if (col.gameObject.CompareTag(playerTag) ||
      col.gameObject.CompareTag(bulletTag)) {
   DestroyEnemy();
  }
 }

 void DestroyEnemy() {
  GameObject explosionInstance = Instantiate(enemyExplosionPrefab, transform.position, enemyExplosionPrefab.transform.rotation);
  Destroy(explosionInstance, 5.0f);

  audioSource.Play();

  Transform trailRenderer = transform.GetChild(0);
  if (trailRenderer) {
   trailRenderer.parent = null;
   Destroy(trailRenderer.gameObject,   trailRenderer.GetComponent<TrailRenderer>().time);
  }

  Destroy(this.gameObject);
 }
}

保存脚本。当你回到 Unity 编辑器时,把脚本放到敌人的游戏对象上,在脚本的 enemyExplosionPrefab 字段中拖放 SimpleFX ➤预设➤ FX_Fireworks_Blue_Small 预设,或者一个类似的。如果你点击 Play,你应该会看到我们场景中唯一的敌人会向玩家坦克移动,并发出声音,当它被摧毁时会产生爆炸,无论是被子弹击中还是与玩家游戏对象碰撞。在第六章中,我们将通过在设定的繁殖点随机繁殖一些敌人来改进游戏,增加(玩家的)生命值和高分,为游戏开始和玩家失败制作菜单,等等。

六、改进和构建球形射手

虽然从技术上来说,游戏可以按照上一章的配置来玩,但在这一章,我们将增加几个新的机制和特性。最后,我将讨论游戏中可以包含的其他功能,如果你希望继续开发它并触发一个版本,以便我们有一个可以安装在 Android 设备上的独立应用。

6.1 产卵的敌人

一个敌人很好,但如果我们有更多的敌人,游戏会更好。在这一节中,我们将在场景中定义的位置放置空的游戏对象,并在设定的延迟时间内,在定义的随机位置实例化(繁殖)敌人。

创建一个空的游戏对象,命名为 SpawnPoints,放在(0,1.15,0)的位置。它的旋转和缩放已经设置为(0,0,0)和(1,1,1)。我们可以将其标记为静态,但这不会有什么不同。

创建四个空的游戏对象作为 SpawnPoints 的子对象(图 6-1 )。你想怎么命名就怎么命名,分别放在(0,0,15),(15,0,0),(0,0,-15),和(-15,0,0)。

img/491558_1_En_6_Fig1_HTML.jpg

图 6-1

产卵点游戏对象

接下来,将敌人游戏对象从场景中拖放到项目窗口的预设文件夹中,并将其从场景中删除。

创建一个新的脚本,将其命名为 enemySpawner,等待它编译,将其放置在场景中的 SpawnPoints 游戏对象上,然后打开它。这就是在我们的场景中滋生敌人的原因。

public float delay;
public GameObject enemy;

我们将在一个名为delay的公共浮动变量中存储生成敌人之间的延迟,并在enemy中存储对敌人预设游戏对象的引用。

void SpawnEnemy() {
    int randomPos = (int)Random.Range(0, transform.childCount);
    Instantiate(enemy, transform.GetChild(randomPos).position, enemy.transform.rotation);
}

为了制造敌人,我们将使用一个名为SpawnEnemy的函数。在这个函数中,我们要做的第一件事是随机选取一个游戏对象的子对象的索引。有四个可能的产卵点可用,所以我们将最小值(0)和最大值(transform.childCount)传递给Random.Rage函数,以随机选择一个。通过这样做,我们可以添加尽可能多的种子点,而不用每次都编辑代码。由于将要返回的值将是一个浮点数,我们将其转换为一个整数(强制转换)并存储在本地变量randomPos中。在下一步中,我们在刚刚得到的索引处找到 SpawnPoints 的子对象,并在该位置和敌人的旋转处实例化一个敌人的游戏对象。

void Start() {
    InvokeRepeating("SpawnEnemy", 0.0f, delay);
}

最后,一旦游戏开始,每隔<delay>秒就会调用SpawnEnemy函数,使用InvokeRepeating函数,它有以下三个参数:

  1. 要调用的函数

  2. 开始时间

  3. 应该多久调用一次函数

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class enemySpawner : MonoBehaviour {
   public float delay;
   public GameObject enemy;

   void Start() {
       InvokeRepeating("SpawnEnemy", 0.0f, delay);
   }

   void SpawnEnemy() {
       int randomPos = (int)Random.Range(0, transform.childCount);
       Instantiate(enemy, transform.GetChild(randomPos).position, enemy.transform.rotation);
   }
}

只需设置一个你选择的延迟值,然后把敌人的预设拖回编辑器的敌人区域。测试敌人是否正在繁殖,向玩家坦克移动,并被消灭(图 6-2 )。

img/491558_1_En_6_Fig2_HTML.jpg

图 6-2

作为组件的enemySpawner脚本

6.2 评分

现在,当我们杀死一个敌人时,它就会被摧毁并消失。然而,如果我们能保留一个类似分数的东西,并随着敌人被杀而递增,那就太好了。

首先,在 Canvas GameObject 下创建一个 UI 文本元素。将其命名为 ScoreText,放置在(-660,350,0),并分别赋予其宽度和高度 375 和 100(图 6-3 )。

img/491558_1_En_6_Fig3_HTML.jpg

图 6-3

ScoreText UI 元素的 Rect 转换组件

对于文本组件本身,我们不会使用 Best Fit,因为随着我们的游戏进行,游戏中的文本 UI 元素似乎变得更大/更小,这看起来有点尴尬。因此,我们将字体大小设置为一个最大值,该值预计足以容纳我们希望存储在该 UI 元素中的所有信息。

我还添加了虚拟文本,并将字体设置为粗体。此外,我将文本水平对齐设置为左侧,垂直对齐设置为中间,并赋予黄色(图 6-4 )。

img/491558_1_En_6_Fig4_HTML.jpg

图 6-4

coretext ui 元素的文本组件

我们将希望我们的敌人发送一个调用,每次他们被子弹击中时增加分数,并相应地更新 ScoreText UI 元素。正如我之前提到的,我们不能直接引用场景中的东西到项目窗口中的预设。这是介绍实例的好时机。使用静态属性并使它们在场景中可公开访问,可以从场景中的任何地方访问和调用类和函数。

为了演示这一点,创建一个新的 GameObject,将其命名为 ScriptManager,并创建和打开一个名为 scoreManager 的脚本。将using UnityEngine.UI行添加到脚本中,以便我们能够稍后修改scoreTexttext值。

public static scoreManager instance;
public Text scoreText;
int score;

我们将使用三个变量。第一个将被命名为instance,它实际上对应于我们脚本的一个实例,可以从其他脚本中调用这个实例。另外两个变量,scoreTextscore,分别引用我们之前创建和配置的 UI 文本元素,并存储当前得分。

void UpdateScore() {
    scoreText.text = "Score: " + score.ToString();
}

我们也将有一个函数来更新scoreText的内容,这样我们就不必使用一种形式的Update()函数来不断地检查或更新。请注意,score 变量中的内容必须转换为字符串格式。

void Awake() {
    if (instance) {
        Destroy(this.gameObject);
    } else {
        instance = this;
    }
    UpdateScore();
}

public void IncreaseScore(int amount) {
    score += amount;
    UpdateScore();
}

在 Awake 函数中,我们将在场景中初始化其他脚本之前为该脚本创建实例。我们首先检查这个脚本的实例是否已经存在,如果存在,我们销毁当前的实例。否则,我们将场景中的脚本实例设置为当前实例。最后,我们更新了我们的scoreText,这样它就不会保留我们的虚拟文本。

public void IncreaseScore(int amount) {
    score += amount;
    UpdateScore();
}

我们还会有一个last函数,这样我们就可以改变分数值。我们可以向该函数传递一个值来指定分数的增量。我们只需要更新scoreText就可以了。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class scoreManager : MonoBehaviour {
   public static scoreManager instance;
   public Text scoreText;
   int score;

   void Awake() {
       if (instance) {
           Destroy(this.gameObject);
       } else {
           instance = this;
       }
       UpdateScore();
   }

   public void IncreaseScore(int amount) {
       score += amount;
       UpdateScore();
   }

   void UpdateScore() {
       scoreText.text = "Score: " + score.ToString();
   }
}

回到编辑器中,我们将脚本放在 ScriptManager 游戏对象上,并将实际的 ScoreText UI 元素拖动到脚本的scoreText字段中(图 6-5 )。

img/491558_1_En_6_Fig5_HTML.jpg

图 6-5

作为组件的 scoreManager 脚本

如果你进入游戏模式,当你杀死敌人时不会有太大变化。这是因为敌人还没有做任何与增加分数相关的事情。打开敌人脚本,并在负责销毁与敌人碰撞的子弹的脚本后添加以下行:

scoreManager.instance.IncreaseScore(1);

这一行将导致敌人访问场景中的scoreManager实例并调用IncreaseScore函数,传入一个值1,这将使分数增加 1。

void OnCollisionEnter(Collision col) {
     if (col.gameObject.CompareTag(bulletTag))  {
         Destroy(col.gameObject);
         scoreManager.instance.IncreaseScore(1);
     }
}

保存脚本,这次进入播放模式,每杀死一个敌人分数就要加 1(图 6-6 )。如果和你相撞的是敌人,什么都不会改变。

img/491558_1_En_6_Fig6_HTML.jpg

图 6-6

杀死两个敌人后分数显示应该是什么样子

6.3 制作菜单

在这一部分,我们将构建玩家可以与之交互的三个菜单。第一个会在游戏开始时显示,另一个会在玩家失败时显示,最后一个会在玩家暂停游戏时显示。然而,我们将首先编码我们将需要这些菜单的 UI 按钮执行的一切。

6.3.1 所需的编码工具

创建并打开名为 utilityScript 的脚本。因为 UI 按钮已经可以做很多事情了,比如禁用游戏对象,我们不需要编写所有我们需要的行为。将行using UnityEngine.SceneManagement;添加到脚本中,以便我们稍后可以调用相关的方法/函数。

public void Restart() {
  SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}

第一个功能将允许玩家在失败时重新开始游戏。使用 Unity 的 SceneManager,我们只需再次加载当前场景。我们通过使用LoadScene函数并传递当前场景的名称作为参数来实现。

下一个函数是关于退出游戏的。我们用Application.Quit()

public void Quit() {
    Application.Quit();
}

为了暂停和取消暂停游戏,我们将Time.timeScale值设置为 0 或 1。值为 0 将使所有东西都无法移动。

public void Pause() {
    Time.timeScale = 0.0f;
}

public void UnPause() {
    Time.timeScale = 1.0f;
}

最后,我们在游戏开始时暂停游戏,以便我们可以在第一个菜单上选择要做的事情。

void Start() {
    Pause();
}

以下是完整的代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class utilityScript : MonoBehaviour {
   void Start() {
       Pause();
   }

   public void Restart() {    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
   }

   public void Quit() {
       Application.Quit();
   }

   public void Pause() {
       Time.timeScale = 0.0f;
   }

   public void UnPause() {
       Time.timeScale = 1.0f;
   }
}

将脚本放在 ScriptManager 游戏对象本身上。

开始菜单

每当加载场景时,都会显示该菜单。在场景中,禁用 SpawnPoints 游戏对象(选中时,在检查器中取消选中其名称左侧的复选框)。我们不希望在我们点击播放按钮之前就有敌人出现。

在 Canvas 下,创建一个新的名为 StartMenu 的空游戏对象。确保它位于层次结构中画布子元素列表的底部,以便它显示在所有其他元素的顶部。

使名为 Background 的 UI 图像成为 StartMenu 的子图像。给它一个比你在游戏窗口中使用的分辨率更大的宽度和高度。我使用的是 1920 × 1080 的分辨率,所以我将宽度指定为 2500,高度指定为 1250。如果你愿意,给 UI 图像组件一个你选择的颜色,并降低它的不透明度。我使用的是有点青色的颜色,不透明度为 190(图 6-7 )。

img/491558_1_En_6_Fig7_HTML.jpg

图 6-7

背景用户界面元素

接下来,您可以将一个文本 UI 元素作为 StartMenu 的子元素(图 6-8 )。命名为 Title。它将被用作我们游戏名称的标签。使用最佳拟合将其调至您想要的大小,并在两个轴的中心对齐(图 6-9 )。

img/491558_1_En_6_Fig9_HTML.jpg

图 6-9

标题 UI 元素的文本组件

img/491558_1_En_6_Fig8_HTML.jpg

图 6-8

标题 UI 元素的 Rect 转换组件

我们现在只需要两个按钮。创建一个 UI 按钮元素,仍然作为 StartMenu 的子元素,并将其命名为 PlayButton。我已经为 PlayButton 的图像组件选择了绿色,并将其设为 400 单位宽,100 单位高,并将其放置在(0,-180,0)(图 6-10 )。

img/491558_1_En_6_Fig10_HTML.jpg

图 6-10

PlayButton UI 元素

在 PlayButton 按钮组件的 OnClick()部分(图 6-11 ,点击三次加号图标(+)。在第一个槽中,拖放开始菜单并选择GameObject.SetActive(bool)功能,同时确保将要出现的复选框保持未选中状态。在第二个例子中,做类似的事情,除了这次使用 SpawnPoints 游戏对象,并确保复选框被选中。最后,在 ScriptManager 中拖动并选择utilityScript.UnPause()

img/491558_1_En_6_Fig11_HTML.jpg

图 6-11

PlayButton 的按钮组件的OnClick功能

要完成 playButton 元素,请选择它的子元素(名为 Text),使它显示 Play,并根据需要更改它的颜色。使用加粗字体并勾选最佳匹配(图 6-12 )。

img/491558_1_En_6_Fig12_HTML.jpg

图 6-12

PlayButton 的子级的文本组件

我们现在只需要退出按钮。复制 PlayButton,把新元素命名为 ExitButton,给它一个红色,放在(0,-315,0),给它的 Text UI 元素子元素一个 Exit 的值(图 6-13 )。

img/491558_1_En_6_Fig13_HTML.jpg

图 6-13

ExitButton 子级的文本组件

在 ExitButton 的 OnClick()中,引用 ScriptManager 并调用utilityScript.Quit()函数(图 6-14 )。

img/491558_1_En_6_Fig14_HTML.jpg

图 6-14

ExitButton 的按钮组件的OnClick()功能

你的层级应该包含以下元素和游戏对象作为画布游戏对象的子对象(图 6-15 ):

img/491558_1_En_6_Fig15_HTML.jpg

图 6-15

当前构成画布中的子对象的游戏对象

请注意,在编辑器中单击 Exit 按钮不会有任何作用。您可以进入播放模式并确保按钮现在工作(图 6-16 )。

img/491558_1_En_6_Fig16_HTML.jpg

图 6-16

开始菜单的外观

6.3.3 暂停菜单

在我们制作真正的暂停菜单之前,让我们制作一个可以在游戏中按下的暂停按钮。首先禁用开始菜单游戏对象,这样我们可以在游戏/场景视图中更容易地预览我们所做的一切。在项目窗口的纹理文件夹中下载并导入如下图片: https://raw.githubusercontent.com/EdgeKing810/SphereShooter/master/Asseimg/Pause.png 。选择它并在检查器中将其标记为 Sprite2D,然后点击应用(图 6-17 )。

img/491558_1_En_6_Fig17_HTML.jpg

图 6-17

导入 PauseButton 的纹理

接下来,创建一个 UI 按钮元素作为 Canvas GameObject 的子元素,并将其放在 StartMenu GameObject 之上或所有子元素的顶部。命名为 PauseButton。复制开始菜单游戏对象,命名新实例 PauseMenu,并启用它的游戏对象(图 6-18 )。

img/491558_1_En_6_Fig18_HTML.jpg

图 6-18

当前构成画布中的子对象的游戏对象

删除 PauseButton 的子级文本 UI 元素。除非禁用开始菜单和暂停菜单的游戏对象,否则您可能看不到暂停按钮。将 PauseButton 放置在(0,400,0)处,并使其宽度和高度都为 100。在其图像组件中,确保它使用暂停精灵作为源图像(图 6-19 )。

img/491558_1_En_6_Fig19_HTML.jpg

图 6-19

PauseButton 的矩形和图像组件

其按钮组件的OnClick函数应该禁用自己的 GameObject,启用实际 PauseMenu 的 game object,并从 ScriptManager 上的 utilityScript 调用Pause函数(图 6-20 )。

img/491558_1_En_6_Fig20_HTML.jpg

图 6-20

暂停按钮的按钮组件的OnClick功能

如果您禁用了 PauseMenu 游戏对象,现在重新启用它,同时保持开始菜单禁用。如果你愿意,PauseMenu 的背景可以换成另一种颜色。接下来,将标题改为暂停,并删除退出按钮游戏对象。重命名 PlayButton ResumeButton,给它一个蓝色,文本 Resume 在里面。也可以将它向底部移动一点(图 6-21 )。

img/491558_1_En_6_Fig21_HTML.jpg

图 6-21

当前构成画布中的子对象的游戏对象

ResumeButton 的 OnClick 应该启用 PauseButton 的 GameObject,禁用 PauseMenu 的 game object,从 ScriptManager 上的 utilityScript 调用UnPause函数(图 6-22 )。

img/491558_1_En_6_Fig22_HTML.jpg

图 6-22

ResumeButton 的按钮组件的OnClick功能

最后要做的是在场景中禁用 PauseMenu 游戏对象,并启用 StartMenu 的游戏对象。我们做了相反的事情,只是为了能够预览它会是什么样子。下面是我在玩游戏时点击暂停按钮时我的暂停菜单的样子(图 6-23 ):

img/491558_1_En_6_Fig23_HTML.jpg

图 6-23

PauseMenu 看起来怎么样

游戏结束菜单

你应该已经猜到这个菜单的用途了。复制 StartMenu,将新实例命名为 GameOverMenu,并禁用 StartMenu 和 PauseMenu 的 GameObjects。更改背景游戏对象的图像组件的颜色,改为在标题文本中显示游戏结束,并删除 PlayButton。重命名 ExitButton RestartButton,更改其颜色,并将其文本命名为子显示重新启动。最后,让它在 OnClick()中调用 ScriptManager 上的 utilityScript 的Restart函数(图 6-24 )。

img/491558_1_En_6_Fig24_HTML.jpg

图 6-24

RestartButton 按钮组件的OnClick功能

创建一个新的文本 UI 元素作为 GameOverMenu 的子元素,并将其命名为 ScoreLabel。将其放置在(-200,-25,0)处,宽度和高度分别为 365 和 80。让它显示分数:,水平左对齐,垂直底部对齐。给它 65 的字体大小,标记为粗体,改变颜色(图 6-25 )。

img/491558_1_En_6_Fig25_HTML.jpg

图 6-25

ScoreLabel 的 Rect 转换和文本组件

复制文本 UI 元素,将其命名为 Value,并使其成为 ScoreLabel 子元素。更改其颜色,将其水平右对齐,使其使用正常字体样式,并更改其 Rect Transform 属性,使其位置为(465,0,0),宽度和高度分别为 250 和 80(图 6-26 )。

img/491558_1_En_6_Fig26_HTML.jpg

图 6-26

值的矩形转换和文本组件

复制 ScoreLabel GameObject,将新实例重命名为 HighScoreLabel,使其位置为(-200,-125,0),将其文本值改为 High Score:,并更改其颜色(图 6-27 )。

img/491558_1_En_6_Fig27_HTML.jpg

图 6-27

HighScoreLabel 的 Rect 转换和文本组件

对于 GameOverMenu 游戏对象的子对象,我的层级窗口如下所示(图 6-28 ):

img/491558_1_En_6_Fig28_HTML.jpg

图 6-28

GameOverMenu 的子游戏对象

这是我的 GameOverMenu 之后的样子,当健康机制实现后,玩家输了一局(图 6-29 ):

img/491558_1_En_6_Fig29_HTML.jpg

图 6-29

GameOverMenu 看起来怎么样

同样,禁用 PauseMenu 和 GameOverMenu 的游戏对象,启用 StartMenu 的游戏对象。

6.4 添加健康

我们将采取类似的方法来实现对玩家健康的评分。玩家坦克上有一个独特的脚本实例,它有一个功能,所以敌人可以呼叫来降低它的生命值。我们还将实例化一个玩家失败时的爆炸,并创建必要的东西来给游戏菜单更多的上下文。

首先,复制 ScoreText UI 元素,并将其命名为 HealthText。更改文本组件的颜色,将其文本更改为 Health:,并将其放置在(-660,450,0)。确保 HealthText GameObject 与 ScoreText 的索引在同一个索引附近,这样它就不会出现在不需要的元素的下面或上面。创建一个名为 healthManager 的新脚本,将其放在 Player GameObject 上,然后打开它。将using UnityEngine.UI;行添加到脚本中,以便我们能够修改稍后将使用的文本元素的文本值。

public static healthManager instance;
public Text healthText;

public Text scoreText;
public Text highScoreText;

int health = 5;

public GameObject explosionPrefab;

同样,我们创建的第一个变量将对应于我们脚本的一个实例,这样就可以从其他脚本中调用它。healthText稍后将引用我们 HealthText 游戏对象的文本组件。至于scoreTexthighScoreText,会对应 GameOverMenu 中对应游戏对象的文本组件。整数变量health将记录玩家坦克当前的生命值。它最初的值为 5。最后,当玩家死亡时,我们将引用一个爆炸预置来实例化。

void Awake() {
    if (instance) {
        Destroy(this.gameObject);
    } else {
        instance = this;
    }
    UpdateHealth();
}

在我们脚本的Awake函数中,我们将确保场景中只有它的一个实例。如果您不理解这段代码,请参考第 6.2 节(“评分”)。

void UpdateHealth() {
    if (health <= 0) { GameOver(); return; }
    healthText.text = "Health: " + health.ToString();
}

在名为UpdateHealth的函数中,我们将更新在healthText变量中引用的文本,以显示玩家的健康状况。如果健康小于或等于 0,我们将调用一个函数来执行游戏结束。

public void ChangeHealth(int amount) {
    health += amount;
    UpdateHealth();
}

我们也将有另一个公开的功能,这样敌人就可以造成伤害。当然,敌人在调用这个函数时会传递一个负值,比如-1。

void GameOver() {
    healthText.text = "Health: 0";
    Instantiate(explosionPrefab, transform.position, explosionPrefab.transform.rotation);
    Destroy(this.gameObject);

    scoreText.transform.parent.parent.gameObject.SetActive(true);

    int score = scoreManager.instance.GetCurrentScore();
    scoreText.text = score.ToString();

    int highScore = PlayerPrefs.GetInt("HighScore", 0);
    if (score > highScore) {
        highScore = score;
        PlayerPrefs.SetInt("HighScore", highScore);
    }
    highScoreText.text = highScore.ToString();
}

GameOver函数中,简而言之,我们将不得不销毁玩家,并在GameOverMenu变量中更新相应游戏对象的分数/高分文本。

我们要做的第一件事是设置healthText文本值来显示生命值为 0,在玩家的位置产生爆炸,并摧毁玩家坦克游戏对象。你应该熟悉这些语法。

接下来,为了显示实际的 GameOverMenu 屏幕,我们使用带有参数truegameObject.SetActive方法作为 scoreText 转换本身的父转换的父 GameObject。如果你看看你的层次结构,你会看到 GameOverMenu 是 ScoreLabel 的父,它本身是 Value 的父,也就是我们的 scoreText。我们可以使用变量直接引用 GameOverMenu GameObject,但是这种方法对你很有用。

然后,我们将创建一个名为score的局部整数变量,我们将把从 scoreManager 实例(我们将在接下来的步骤中实现)调用的函数返回的值赋给它,以获得分数。我们将在scoreText变量中引用的文本组件中显示这一点。

对于高分,我们将首先创建另一个局部变量。对于这个变量,我们将获取并存储以前的高分。我们通过使用 PlayerPrefs 来做到这一点,即使在游戏关闭时,player prefs 也能够存储数据。取整数数据的语法是PlayerPrefs.GetInt(<ID>, <defaultValue>)。在我们的例子中,ID 是 HighScore。PlayerPrefs将搜索并返回与该 ID 相关联的存储值。如果该 ID 不存在,或者没有使用该 ID 保存数据,将返回默认值 0 并存储在highScore变量中。

然后,我们将检查我们在刚刚玩的游戏中的当前分数是否大于最高分数。如果是,我们将把与 HighScore ID 对应的变量highScorePlayerPref的值都设置为score的值。最后,我们更新在highScoreText变量中引用的文本组件的值。

完整的脚本如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class healthManager : MonoBehaviour {
   public static healthManager instance;
   public Text healthText;

   public Text scoreText;
   public Text highScoreText;

   int health = 5;

   public GameObject explosionPrefab;

   void Awake() {
       if (instance) {
           Destroy(this.gameObject);
       } else {
           instance = this;
       }
       UpdateHealth();
   }

   public void ChangeHealth(int amount) {
       health += amount;
       UpdateHealth();
   }

   void UpdateHealth() {
       if (health <= 0) { GameOver(); return; }
       healthText.text = "Health: " + health.ToString();
   }

   void GameOver() {
       healthText.text = "Health: 0";
       Instantiate(explosionPrefab, transform.position, explosionPrefab.transform.rotation);
       Destroy(this.gameObject);

       scoreText.transform.parent.parent.gameObject.SetActive(true);

       int score = scoreManager.instance.GetCurrentScore();
       scoreText.text = score.ToString();

       int highScore = PlayerPrefs.GetInt("HighScore", 0);
       if (score > highScore) {
           highScore = score;
           PlayerPrefs.SetInt("HighScore", highScore);
       }
       highScoreText.text = highScore.ToString();
   }
}

为了让脚本正确编译和运行,我们必须首先在我们的 scoreManager 脚本中创建GetCurrentScore函数。

public int GetCurrentScore() {
  return score;
}

该函数可以被公开调用,并将返回保存在变量score中的整数值;

保存这两个脚本。回到编辑器中,将相应的组件分配给 healthManager 的字段。别忘了这个脚本应该在你的玩家游戏对象上(图 6-30 )。

img/491558_1_En_6_Fig30_HTML.jpg

图 6-30

作为组件的 healthManager 脚本

健康文本将对应于您在本节开始时创建的 Health Text 游戏对象的文本组件。分数文本和高分文本将分别对应于名为 Value 的游戏对象的文本组件,它是 ScoreLabel 和 HighScoreLabel 游戏对象的子对象。作为爆炸预设,我使用的是来自简单 FX ➤预设的 FX 爆炸碎石预设。

此外,我们必须修改敌人的脚本,这样敌人就可以造成伤害。编辑OnCollisionEnter函数,验证碰撞的物体是否是玩家,如果是,调用healthManager实例上的ChangeHealth函数。

  if (col.gameObject.CompareTag(playerTag))  {
      healthManager.instance.ChangeHealth(-1);
  }

我还修改了OnCollisionEnter函数,因此代码重复少了一点,整体看起来更整洁。但是,您不需要这样做。

void OnCollisionEnter(Collision col) {
     if (col.gameObject.CompareTag(bulletTag))  {
         Destroy(col.gameObject);
         scoreManager.instance.IncreaseScore(1);
         DestroyEnemy();
     }

     if (col.gameObject.CompareTag(playerTag))  {
         healthManager.instance.ChangeHealth(-1);
         DestroyEnemy();
     }
 }

现在,如果你玩这个游戏,你会看到当敌人与你碰撞时,健康文本会更新。当你的生命值达到 0 时,将会看到一个爆炸,并显示正确的scorehighScore值。

当你输的时候你也会注意到错误(图 6-31 )。

img/491558_1_En_6_Fig31_HTML.jpg

图 6-31

cameraFollow 脚本导致的错误

这些与 cameraFollow 脚本在玩家坦克被摧毁时无法跟随它有关,因为它对应的是一个值null。编辑 cameraFollow 脚本,并添加一个检查,以防止当玩家坦克被摧毁,因此不存在时出现该错误。

void LateUpdate() {
  if (player) {
    this.transform.position = new Vector3(player.position.x, height, player.position.z);
  }
}

6.5 新的敌人

虽然目前的敌人做得很好,但让我们介绍一个需要三枪才能被摧毁的新敌人。如果和玩家碰撞也会造成更大的伤害。

从复制项目窗口中的敌人预设开始。将新实例命名为 EnemyBig,原始实例命名为 EnemySmall。双击打开 EnemyBig 预置(图 6-32 )。给它一个(3,3,3)的比例,并通过使用新的材质来改变它的颜色和它的轨迹渲染器子对象的颜色。我还将使用 0.75 的宽度使轨迹渲染器更宽。最后,我们还想把敌人游戏对象上敌人脚本的最大速度值降低到 3,并使用另一个爆炸预设。为此,我将使用简单 FX ➤预设 fx _ 烟花 _ 蓝色 _ 大,但用橙色。

img/491558_1_En_6_Fig32_HTML.jpg

图 6-32

新敌人游戏对象上的组件

现在让我们修改负责产生敌人的脚本,enemySpawner,这样它可以随机产生我们的两个敌人预置中的任何一个。我们要做的第一件事是将public GameObject enemy语句转换成一个引用带有标识符“敌人”的游戏对象数组的语句:public GameObject[] enemies

然后,在包含 instantiate 指令的代码行之前添加一行代码,创建一个名为enemy的本地 GameObject 变量,该变量将从enemies数组中随机分配一个(enemy) GameObject。

void SpawnEnemy() {
    int randomPos = (int)Random.Range(0, transform.childCount);
    GameObject enemy = enemies[(int)Random.Range(0, enemies.Length)];
    Instantiate(enemy, transform.GetChild(randomPos).position, enemy.transform.rotation);
}

回到编辑器中,将两个敌人预设拖到 SpawnPoints 游戏对象上的 enemySpawner 组件的相应槽中(图 6-33 )。

img/491558_1_En_6_Fig33_HTML.jpg

图 6-33

更新的 enemySpawner 脚本作为一个组件

你现在应该可以看到新的敌人在你玩的时候被实例化了,但是它的行为仍然和原来的一样。打开敌方脚本。是时候做些改变了。

我们将使用两个新变量:healthdamageToCause。这两个变量都是全局整型变量,可以公开访问,默认值为 1。

public int health = 1;
public int damageToCause = 1;

保存脚本,然后在项目窗口中的 EnemyBig 预置上,将这两个变量的值都设置为 3(图 6-34 )。

img/491558_1_En_6_Fig34_HTML.jpg

图 6-34

EnemyBig 游戏对象组件的最终属性

然后,修改OnCollisionEnter函数,这样当敌人与子弹相撞时,这减少其生命值一(health--),而不是调用DestroyEnemy函数。如果敌人与玩家发生碰撞,它应该会造成相当于保存在damageToCause变量中的值的伤害,所以只需将传递给 healthManager 脚本实例的ChangeHealth函数的-1值替换为-damageToCause。最后加一个 check,这样敌人的生命值小于等于 0 就调用DestroyEnemy函数。

void OnCollisionEnter(Collision col) {
     if (col.gameObject.CompareTag(bulletTag))  {
         Destroy(col.gameObject);
         scoreManager.instance.IncreaseScore(1);
         health--;
     }

     if (col.gameObject.CompareTag(playerTag))  {
   healthManager.instance.ChangeHealth(-damageToCause);
         DestroyEnemy();
     }

     if (health <= 0) {
         DestroyEnemy();
     }
 }

保存脚本,并前往播放模式。你应该看到敌人的游戏对象需要三发子弹才能被摧毁,如果他们与玩家坦克相撞,会造成 3 点伤害。

为了完成这一部分,让我们在敌人的脚本中添加更多的东西,这样敌人会随着时间的推移变得更快,让游戏不那么无聊(不是说它是,但无论如何)。

敌方脚本的Start函数的第一行,除了速度变量,还要加上(Time.time / 25)的值。我们调用的第一个方法将返回秒数,因为游戏开始了,简而言之,我们要确保被繁殖的敌人的速度每 25 秒增加 1。

void Start() {
 speed = Random.Range(minSpeed, maxSpeed) + (Time.time / 25);
 audioSource = GetComponent<AudioSource>();
 player = GameObject.FindWithTag(playerTag);
}

6.6 健康盒子

这是我们将对游戏进行修改的最后一部分。我将会介绍一个小立方体,它将会沿着地面游戏对象的区域以确定的间隔随机产生。如果玩家坦克撞上了这些小立方体中的一个,我们将称之为生命盒,我们将增加他们的生命值。

6.6.1 制作健康盒子

在你的场景中,创建一个 3D 对象➤立方体游戏对象。将其命名为 healthBox,并赋予其位置为(0,1,3),旋转为(0,0,0),缩放为(0.4,0.4,0.4)。您也可以为其指定绿色材质。然后,检查其箱式碰撞器组件的isTrigger属性(图 6-35 )。

img/491558_1_En_6_Fig35_HTML.jpg

图 6-35

健康盒游戏对象上的组件

由于我们让健康盒游戏对象有一个 isTrigger 碰撞器,敌人可以直接通过它,而不需要我们弄乱标签和层属性。我们的玩家坦克也可以捡起它,继续它的路线,不受我们想要它去的方向的影响,因为没有碰撞发生。

添加健康

现在,如果你玩,玩家坦克只是通过健康盒游戏对象,真的没有更多的事情发生。为了使坦克具有交互性,并赋予它在游戏中的意义,我们必须编写一个脚本来定义应该发生什么。创建一个脚本,命名为 healthBox,并打开它。

我们将需要一个函数在发生 isTrigger 碰撞时运行,如果这是玩家坦克的碰撞器导致的,我们必须调用该函数来增加玩家的生命值并摧毁生命盒的游戏对象。如果我们不执行这最后一步,玩家可以不断地进出同一个生命盒来增加他们的寿命。我们还会增加玩家的生命值 2,但是你可以选择另一个值。

完整的脚本如下。保存它,并将其放置在场景中的健康盒游戏对象上。您还会注意到,我们使用的OnTriggerEnter函数带有一个作为参数传递的碰撞器类型引用。该函数的执行方式与我们一直使用的著名的OnCollisionEnter函数类似,但这次是为了处理触发冲突。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class healthBox : MonoBehaviour {
   void OnTriggerEnter(Collider col) {
       if (col.gameObject.CompareTag("Player")) {
           healthManager.instance.ChangeHealth(2);
           Destroy(this.gameObject);
       }
   }
}

当你现在进入游戏模式时,你应该会注意到 healthBox GameObject 被破坏,当你与它“碰撞”时,你的生命值增加 2(或你在上一步中选择的值)。

6.6.3 沿地图生成生命盒

在一个预设中转动健康盒游戏对象,并将其从你的场景中删除(图 6-36 )。

img/491558_1_En_6_Fig36_HTML.jpg

图 6-36

生命盒游戏对象在预置中被打开后

创建另一个脚本,将其命名为 healthBoxSpawner,并打开它。我们将使用三个变量,分别用于引用健康盒预设的游戏对象,我们场景中地面游戏对象的转换,并保存一个值,该值将定义以何种间隔生成健康盒游戏对象,与 enemySpawner 脚本类似。

public GameObject healthBox;
public Transform ground;
public float delay = 3.0f;

就像 enemySpawner 脚本中的Start函数一样,我们必须每隔 x 秒调用一次函数,正如 delay 变量中定义的那样,以实例化一个 healthBox 游戏对象。

void Start() {
    InvokeRepeating("SpawnHealthBox", 0.0f, delay);
}

由于我们的地面游戏对象沿其 x 和 z 轴的比例为 150,原点(0,0,0)位于其中心,因此任何位于其边缘的游戏对象的 x 和/或 z 位置都必须为 75 或-75。因此,我们将在 75 到-75 之间为 x 和 y 值选择一个随机值,我们希望在SpawnHealthBox函数中实例化一个健康盒。

例如,要获得沿 x 轴位置的随机值,我们将使用以下代码:

float xPos = Random.Range(-1.0f, 1.0f) * (ground.localScale.x / 2);

游戏对象的比例总是局部表示的。除以 2 将得到 75,我们只需将其乘以-1 到 1 之间的一个随机值。

我们将做同样的事情来获得 z 轴的值。y 轴值将为 1。然后,只需要创建一个Vector3变量,并在那个位置实例化一个 healthBox。Quaternion.identity可以解释为沿所有轴 0°旋转。

void SpawnHealthBox() {
    float xPos = Random.Range(-1.0f, 1.0f) * (ground.localScale.x / 2);
    float zPos = Random.Range(-1.0f, 1.0f) * (ground.localScale.z / 2);

    Vector3 spawnPos = new Vector3(xPos, 1, zPos);

    Instantiate(healthBox, spawnPos, Quaternion.identity);
}

这是完整的脚本,以防你漏掉了什么。保存它并将其拖动到场景中的 SpawnPoints 游戏对象上,因为我们希望它只在我们玩游戏时运行(而不是在玩家单击“玩”、暂停游戏或失败之前)。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class healthBoxSpawner : MonoBehaviour {

   public GameObject healthBox;
   public Transform ground;
   public float delay = 3.0f;

   void Start() {
       InvokeRepeating("SpawnHealthBox", 0.0f, delay);
   }

   void SpawnHealthBox() {
       float xPos = Random.Range(-1.0f, 1.0f) * (ground.localScale.x / 2);
       float zPos = Random.Range(-1.0f, 1.0f) * (ground.localScale.z / 2);

       Vector3 spawnPos = new Vector3(xPos, 1, zPos);

       Instantiate(healthBox, spawnPos, Quaternion.identity);
   }
}

将生命盒预置拖放到生命盒区域,将地面游戏对象拖放到地面区域。您可以为延迟设置另一个值,而不是 3(图 6-37 )。

img/491558_1_En_6_Fig37_HTML.jpg

图 6-37

作为组件的 healthBoxSpawner 脚本

6.7 将游戏导出为。apk 文件

我们现在有了一个全功能的游戏!让我们做一个。apk 文件,这样我们就可以把它安装在我们的 Android 手机上,并向我们的朋友炫耀!

如果你按照这本书,从我们从 Hub 下载一个版本的 Unity 编辑器并安装 Android 模块开始,你的编辑➤首选项➤外部工具标签应该是这样的(图 6-38 ):

img/491558_1_En_6_Fig38_HTML.jpg

图 6-38

用于构建 Android 平台的工具

保存场景和项目。进入构建设置窗口(文件➤构建设置,或 Ctrl+Shift+B)。要做的第一件事是添加您希望出现在中的所有场景。apk(图 6-39 )。我们只有一个场景,所以单击添加开放场景。如果我们有多个场景,我们会把它们都拖进来,第一个场景是我们希望玩家在游戏打开时看到的场景(图 6-39 )。

img/491558_1_En_6_Fig39_HTML.jpg

图 6-39

“构件设置”窗口

点按该窗口左下角的“播放器设置”按钮。在点击 Build 之前,我们将首先修改一些设置。项目设置窗口将出现,你将在播放器标签。玩家是我们为 Unity 构建的最终游戏定制各种选项的地方。我们将只关注构建 Android 游戏最常用的选项。我们可以做的第一件事是设置一个公司名(也许是你作为开发者的名字),一个产品名(我们游戏的名字),并设置一个版本号(比如 1,2,3.5 等。).这些可以设置成你想要的任何值。

您已经知道如何从我们为暂停按钮导入纹理的步骤中导入纹理(第 6.3.3 节,“暂停菜单”)。然后我们可以给游戏分配一个图标和一个光标。默认情况下,如果留空,图标将是 Unity 的标志,光标将是空白的(图 6-40 )。

img/491558_1_En_6_Fig40_HTML.jpg

图 6-40

播放器选项卡中的第一个选项

我不会讨论图标下的设置,但是,简而言之,你可以指定不同分辨率的纹理来匹配不同手机上的最终图标大小。它会相应地缩放我们在默认图标属性中添加的图像,如果没有调整的话,所以这不是你必须做的事情。

在分辨率和显示(图 6-41 )下,您可以个性化您希望游戏在手机上显示的方式。这些选项非常简单易懂。我们不会改变这一部分的任何东西,除了我们希望我们的游戏只能在风景模式下玩。我们可以将默认分辨率设置为自动旋转,但取消下面的纵向模式,或者只选择另一个选项而不是自动旋转。通过将鼠标光标停留在一个设置上几秒钟,您也可以了解许多设置的作用。

img/491558_1_En_6_Fig41_HTML.jpg

图 6-41

播放器设置中的分辨率和演示

在 Splash Image 标签页(图 6-42 )下,你可以选择在用户进入你的游戏时显示一些东西,比如你的游戏开发商公司的封面图片等等。您不能将其设置为在个人版中不显示由 Unity 制造的徽标。您可以设置闪屏样式或动画,甚至设置背景(而不是纯色),以定制您的闪屏。

img/491558_1_En_6_Fig42_HTML.jpg

图 6-42

播放器设置中的启动画面

如果您想要显示其他图像,请将它们添加到徽标列表中,并注明您希望它们在屏幕上显示的时间。

在其他设置下,我们首先有一些关于渲染和图形的设置,我们真的不用乱来(图 6-43 )。我们也有我们游戏的包名,这将是我们手机上游戏的完整标识符。没有游戏或应用应该有相同的包名。虽然版本是用来识别你的游戏版本的,但你对你的游戏所做的每一次更新都应该有一个比你的 Android 手机上一次更新更高的捆绑版本号,以避免出现错误并安装它。最低和目标 API 级别代表了游戏可以安装的 Android 手机版本的范围。如果您尝试安装。Android 版本不在此范围内的 Android 手机上的 apk 将会失败。

img/491558_1_En_6_Fig43_HTML.jpg

图 6-43

其他环境中的识别和配置

至于配置部分,您必须切换到 IL2CPP 脚本后端,以具有 ARM64 架构的目标设备(在目标架构下;请参见图 6-43 )如果您希望您的游戏稍后被接受(如果您提交)到 Google 的 Play Store。我们的游戏将会有很好的表现;没有必要调整任何其他选项。

最后,我们可以制作一个 Keystore 并“签名”我们的游戏,这样它就有了制作它的人的身份。手机和 Play Store 也将拒绝更新游戏/应用,如果使用的密钥不是提交的第一个版本所用的密钥。

单击 Keystore Manager 按钮,创建一个新的 keystone,填写详细信息,然后单击 Add Key 按钮(图 6-44 )。

img/491558_1_En_6_Fig44_HTML.jpg

图 6-44

密钥库管理器窗口

然后,在播放器的发布设置部分输入相同的详细信息(图 6-45 )。

img/491558_1_En_6_Fig45_HTML.jpg

图 6-45

在播放器中发布设置

如果以后你做的游戏建成后有。占用超过 100MB 的 apk 文件,您必须勾选 Split Application Binary,这将创建一个额外的扩展文件(OBB 文件)并减少文件的大小。apk 文件,以便 Google Play 接受它。

最后,在 Build Settings 窗口中单击 Build,并指定。将要生成的 apk 文件(图 6-46 )。然后,就等着吧。如果构建失败,检查控制台窗口中的错误,然后简单地搜索它们。

img/491558_1_En_6_Fig46_HTML.jpg

图 6-46

单击“构建”时运行的进程之一

Windows 上的一个常见错误与 Android SDK 模块的许可证没有被接受有关。要解决这个问题,您必须转到下载/安装 Android SDK 的位置,并在 CMD 窗口中执行以下命令。用您自己的版本替换 Unity Editor 版本。

cd "Program Files\Unity\Hub\Editor\2019.3.0b12\Editor\Data\PlaybackEngines\AndroidPlayer\SDK\tools\bin"

sdkmanager.bat --licenses

如果您得到任何其他错误,只需搜索并尝试找到解决方案。如果你仍然不能让你的游戏正常运行,下载并手动安装 Android SDK、NDK、JDK 和 Gradle,然后在编辑➤首选项窗口中将 Unity 指向它们的位置。

Note

下面是我的外部工具选项卡在 Linux 上的样子(图 6-47 )。我无法让它与我在 Hub 中随 Unity 下载的内置 Android 工具一起工作,所以我手动解压/安装了所有这些工具,现在我的游戏可以正常运行了。如果你进行到这一步,你可以跟着不太过时的论坛帖子,很容易找到文章。请注意,对于一些工具,如 JDK 和 NDK,Unity 支持非常具体的版本。

img/491558_1_En_6_Fig47_HTML.jpg

图 6-47

我的外部工具窗口

一旦你有了。apk 文件,发到你手机上。在手机上的文件管理器应用中,只需浏览到。apk 文件并安装它(图 6-48 )。你现在可以玩你做的游戏,并展示给你的朋友看!

img/491558_1_En_6_Fig48_HTML.jpg

图 6-48

决赛。apk 文件

如果你想建立你的技能,做出一个更好的游戏,在 Sphere Shooter 中还有很多事情可以做。这里有一个简短的列表:

  • 添加更多的音效和粒子系统。

  • 实现一个功能,允许玩家从地面游戏物体的纹理中选择一对外观。

  • 增加更多的敌人:一个更小更快的,一个在被杀死时产生更小的敌人,一个可以粘住玩家并降低他们的最大速度的。

  • 向游戏中添加硬币,允许玩家为他们的坦克购买更多的炮塔。

  • 玩家存活的时间越长,增加杀死的价值。

  • 添加更多带有激光、射弹和火焰喷射器的坦克。

  • 在游戏中实现类似挑战的东西,完成后有奖励。

你也可以参考一个叫球和炮塔的游戏,它的核心是球体射手。在 Google Play 上找到。

我希望你像我写这本书一样喜欢它!我们看了几个游戏开发的概念,以及如何使用 Unity 提供的许多功能。我们甚至制作了一个演示游戏,可以对其进行大量改进和构建。如果你在读完这本书后考虑从事游戏开发,我会很高兴。请不要犹豫,了解更多的主题,并建立伟大和有趣的游戏。与我分享。我很想看看你的作品。