Unity 2020 游戏开发实用指南(三)

原文:zh.annas-archive.org/md5/36713AD44963422C9E116C94116EA8B8

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:使用动画师、电影机和时间轴创建动画

在我们当前的游戏状态下,除了考虑着色器和粒子动画外,我们大部分时间都处于静态场景中。在下一章中,当我们为游戏添加脚本时,一切都将根据我们想要的行为开始移动。但有时,我们需要以预定的方式移动对象,例如通过过场动画,或者特定的角色动画,例如跳跃、奔跑等。本章的目的是介绍几种 Unity 动画系统,以创建所有可能的对象运动,而无需脚本。

在本章中,我们将研究以下动画概念:

  • 使用动画师进行骨骼动画

  • 使用电影机创建动态摄像机

  • 使用时间轴创建过场动画

通过本章结束时,您将能够创建过场动画来讲述游戏的故事或突出显示级别的特定区域,以及创建能够准确展示游戏外观的动态摄像机,无论情况如何。

使用动画师进行骨骼动画

到目前为止,我们使用的是静态网格,这些是实心的三维模型,不应该以任何方式弯曲或动画化(除了单独移动,如汽车的门)。我们还有另一种网格,称为蒙皮网格,它们具有根据骨骼弯曲的能力,因此可以模拟人体肌肉的运动。我们将探讨如何将动画人形角色整合到我们的项目中,以创建敌人和玩家的动作。

在本节中,我们将研究以下骨骼网格概念:

  • 了解蒙皮

  • 导入蒙皮网格

  • 使用动画师控制器进行整合

我们将探讨蒙皮的概念以及它如何使您能够为角色添加动画。然后,我们将把动画网格引入我们的项目,最终对其应用动画。让我们从讨论如何将骨骼动画引入我们的项目开始。

了解蒙皮

为了获得动画网格,我们需要四个部分,从网格本身和将要进行动画的模型开始,这与任何其他网格的创建方式相同。然后,我们需要骨骼,这是一组骨骼,将与所需的网格拓扑匹配,例如手臂、手指、脚等。在图 12.1中,您可以看到一组骨骼与我们的目标网格对齐的示例。您会注意到这类网格通常是用T姿势建模的,这将有助于动画制作过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.1 – 忍者网格与其默认姿势匹配的骨骼

一旦艺术家创建了模型及其骨骼,下一步就是进行蒙皮,即将模型的每个顶点与一个或多个骨骼相关联的过程。这样,当您移动骨骼时,相关的顶点也会随之移动。这样做是因为动画化少量骨骼比动画化模型的每个单独顶点更容易。在下一个截图中,您将看到网格的三角形根据受其影响的骨骼的颜色进行着色,以可视化骨骼的影响。您将注意到颜色之间的混合,这意味着这些顶点受不同骨骼的不同影响,以使关节附近的顶点能够很好地弯曲。此外,截图还说明了用于二维游戏的二维网格的示例,但概念是相同的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.2 – 网格蒙皮权重以颜色形式可视化表示

最后,你需要的最后一部分是实际的动画,它将简单地由网格的不同姿势混合而成。艺术家将在动画中创建关键帧,确定模型在不同时刻需要采取哪种姿势,然后动画系统将简单地在它们之间进行插值。基本上,艺术家将对骨骼进行动画处理,而蒙皮系统将把这个动画应用到整个网格上。你可以有一个或多个动画,之后你可以根据你想要匹配角色动作的动画来在它们之间切换(比如站立、行走、跌倒等)。

为了获得这四个部分,我们需要获取包含它们的适当资产。在这种情况下,通常的格式是FilmboxFBX),这与我们迄今为止用来导入 3D 模型的格式相同。这种格式可以包含我们需要的每一部分——模型、带有蒙皮的骨骼和动画——但通常,我们会将部分拆分成多个文件以重复利用这些部分。

想象一个城市模拟游戏,我们有几个市民网格,外观各异,所有这些网格都必须进行动画处理。如果每个市民的单个 FBX 包含网格、蒙皮和动画,那么每个模型都会有自己的动画,或者至少是相同动画的克隆,重复出现。当我们需要更改动画时,我们需要更新所有网格市民,这是一个耗时的过程。与此相反,我们可以为每个市民准备一个 FBX,其中包含网格和骨骼,以及一个单独的 FBX 文件用于每个动画,其中包含所有市民都具有的相同骨骼和适当动画,但不包含网格。这将允许我们混合和匹配市民 FBX 和动画的 FBX 文件。也许你会想为什么模型 FBX 和动画 FBX 都必须有网格。这是因为它们需要匹配才能使两个文件兼容。在下一个截图中,你可以看到文件应该是什么样子的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.3 – 我们将在项目中使用的包的动画和模型 FBX 文件

另外,值得一提的是一个叫做重定向的概念。正如我们之前所说,为了混合模型和动画文件,我们需要它们具有相同的骨骼结构,这意味着相同数量的骨骼、层次结构和名称。有时,这是不可能的,特别是当我们混合我们的艺术家创建的自定义模型与使用动作捕捉技术从演员那里记录下来的外部动画文件,或者只是购买一个 Mocap 库。在这种情况下,很可能会遇到 Mocap 库中的骨骼结构与您的角色模型不同,这就是重定向发挥作用的地方。这种技术允许 Unity 创建两种不同的仅限于人形的骨骼结构之间的通用映射,使它们兼容。一会儿,我们将看到如何启用这个功能。

现在我们了解了有关蒙皮网格的基础知识,让我们看看如何获取带有骨骼和动画的模型资产。

导入骨骼动画

让我们从如何从资产商店导入一些带有动画的模型开始,在3D | Characters | Humanoids部分。你也可以使用外部网站,比如 Mixamo,来下载它们。但现在,我会坚持使用资产商店,因为你在使资产工作时会遇到更少的麻烦。在我的情况下,我已经下载了一个包,正如你在下面的截图中所看到的,其中包含了模型和动画。

请注意,有时您需要单独下载它们,因为某些资产将仅为模型或动画。另外,请注意,本书中使用的软件包可能在您阅读时不可用;在这种情况下,您可以寻找另一个具有类似资产(角色和动画)的软件包,或者从书的 GitHub 存储库中下载项目文件,并从那里复制所需的文件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.4 - 我们游戏的士兵模型

在我的包内容中,我可以在Animations文件夹中找到动画的 FBX 文件,而在Model中找到单个模型的 FBX 文件。请记住,有时您不会将它们分开,动画可能位于与模型相同的 FBX 中,如果有任何动画的话。现在我们有了所需的文件,让我们讨论如何正确配置它们。

让我们开始选择模型文件并检查骨骼选项卡。在此选项卡中,您将找到一个名为动画类型的设置,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.5 - 骨骼属性

此属性包含以下选项:

  • :非动画模型的模式;您游戏中的每个静态网格将使用此模式。

  • 传统:用于旧 Unity 项目和模型的模式;不要在新项目中使用此模式。

  • 通用:一种新的动画系统,可以用于各种模型,但通常用于非人形模型,如马、章鱼等。如果使用此模式,模型和动画 FBX 文件必须具有完全相同的骨骼名称和结构,从而减少了来自外部来源的动画组合的可能性。

  • 人形:设计用于人形模型的新动画系统。它启用了重新定位和反向运动学IK)等功能。这使您能够将具有不同骨骼的模型与动画结合,因为 Unity 将在这些结构和一个通用结构之间创建映射,称为阿凡达。请注意,有时自动映射可能会失败,您将需要手动更正;因此,如果您的通用模型具有您需要的一切,我建议您坚持使用通用,如果那是 FBX 的默认配置。

在我的情况下,我的软件包中的 FBX 文件的模式设置为Humanoid,所以很好,但请记住,只有在绝对必要时才切换到其他模式(例如,如果您需要组合不同的模型和动画)。现在我们已经讨论了骨骼设置,让我们谈谈动画设置。

为此,请选择任何动画 FBX 文件,并查找检视器窗口中的动画部分。您会发现几个设置,例如导入动画复选框,如果文件有动画(而不是模型文件),必须标记该复选框,以及剪辑列表,您将在其中找到文件中的所有动画。在下面的截图中,您可以看到我们一个动画文件的剪辑列表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.6 - 动画设置中的剪辑列表

带有动画的 FBX 文件通常包含单个大动画轨道,其中可以包含一个或多个动画。无论如何,默认情况下,Unity 将基于该轨道创建单个动画,但如果该轨道包含多个动画,则您需要手动拆分它们。在我们的情况下,我们的 FBX 已经由软件包创建者拆分为多个动画,但为了学习如何手动拆分,请执行以下操作:

  1. HumanoidCrouchIdle

  2. 看一下动画时间轴下方的开始结束值,并记住它们;我们将使用它们来重新创建此剪辑:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.7 - 剪辑设置

  1. 单击片段列表底部右侧的减号按钮以删除所选的片段。

  2. 使用加号按钮创建一个新的片段并选择它。

  3. 使用Take 001输入字段将其重命名为与原始名称类似的内容。在我的例子中,我会将其命名为空闲

  4. 开始设置为319,将结束设置为264。这些信息通常来自艺术家,但您可以尝试最适合的数字,或者简单地在时间轴上拖动蓝色标记到这些属性上。

  5. 您可以通过单击检视器窗口底部的标题栏上的条形图来预览片段,然后单击播放按钮来预览您的动画(在我的例子中是HumanoidIdle)。您将看到默认的 Unity 模型,但是您可以通过将模型文件拖放到预览窗口中来查看自己的模型,因为检查我们的模型是否正确配置是很重要的。如果动画没有播放,您需要检查动画类型设置是否与动画文件匹配:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.8 - 动画预览

现在,打开动画文件,单击箭头,然后检查子资产。您会看到这里有一个与您的动画标题相对应的文件,以及剪辑列表中的其他动画,其中包含了剪辑。一会儿,我们将播放它们。在下面的截图中,您可以看到我们.fbx文件中的动画:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.9 - 生成的动画片段

现在我们已经介绍了基本配置,让我们看看如何集成动画。

使用动画控制器进行集成

在为角色添加动画时,我们需要考虑动画的流程,这意味着考虑必须播放哪些动画,每个动画何时处于活动状态,以及动画之间的过渡应该如何发生。在以前的 Unity 版本中,您需要手动编写复杂的 C#代码脚本来处理复杂的情景;但现在,我们有了动画控制器。

动画控制器是基于状态机的资产,我们可以使用名为动画师的可视编辑器来绘制动画之间的转换逻辑。其思想是每个动画都是一个状态,我们的模型将有多个状态。一次只能激活一个状态,因此我们需要创建转换来改变它们,这些转换将具有必须满足的条件才能触发转换过程。条件是关于要进行动画的角色的数据的比较,例如其速度、是否在射击或蹲下等。

因此,动画控制器或状态机基本上是一组带有转换规则的动画,它将决定哪个动画应处于活动状态。让我们通过以下步骤开始创建一个简单的动画控制器:

  1. 点击“播放器”。记得将您的资产放在一个文件夹中以便进行适当的组织;我会把我的称为“动画师”。

  2. 双击资产以打开动画师窗口。不要将此窗口与动画窗口混淆;动画窗口有不同的功能。

  3. 将您角色的空闲动画片段拖放到动画师窗口中。这将在控制器中创建一个框,表示将连接到控制器的默认动画,因为这是我们拖动的第一个动画。如果您没有空闲动画,我建议您找一个。我们至少需要一个空闲和一个行走/奔跑的动画片段:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.10 - 从 FBX 资产中拖动动画片段到动画控制器

  1. 以相同的方式拖动奔跑动画。

  2. 右键点击Idle动画,选择Create Transition,然后左键点击Run动画。这将在IdleRun之间创建一个过渡。

  3. 以相同的方式从RunIdle创建另一个过渡:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.11 – 两个动画之间的过渡

过渡必须有条件,以防止动画不断切换,但为了创建条件,我们需要数据进行比较。我们将向我们的 Controller 添加属性,这些属性将代表过渡所使用的数据。稍后在第三部分中,我们将设置这些数据以匹配对象的当前状态。但现在,让我们创建数据并测试 Controller 对不同值的反应。为了基于属性创建条件,做如下操作:

  1. 点击Animator窗口左上角的Parameters选项卡。如果你没有看到它,点击交叉眼按钮显示选项卡。

  2. 点击Velocity。如果你错过了重命名部分,只需左键点击变量并重命名:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.12 – 具有浮点速度属性的参数选项卡

  1. 在检查器窗口中点击Conditions属性。

  2. 点击0。这告诉我们过渡将从0执行。我建议你设置一个稍高一点的值,比如0.01,以防止任何浮点舍入错误(常见的 CPU 问题)。还要记住,Velocity的实际值需要通过脚本手动设置,这将在第三部分中进行:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.13 – 检查速度是否大于 0.01 的条件

  1. 0.01做同样的操作:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.14 – 检查值是否小于 0.01 的条件

现在我们已经设置好了第一个 Animator Controller,是时候将它应用到一个对象上了。为了做到这一点,我们需要一系列的组件。首先,当我们有一个动画角色时,我们使用蒙皮网格渲染器而不是普通的网格渲染器。如果你将角色模型拖到场景中并探索它的子级,你会看到一个组件,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.15 – 一个蒙皮网格渲染器组件

这个组件将负责将骨骼的移动应用到网格上。如果你搜索模型的子级,你会发现一些骨骼;你可以尝试旋转、移动和缩放它们,以查看效果,如下面的截图所示。请注意,如果你从资产商店下载了另一个包,你的骨骼层次结构可能与我的不同:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.16 – 旋转颈骨

我们需要的另一个组件是Animator,它会自动添加到其根 GameObject 的蒙皮网格上。这个组件将负责应用我们在 Animator Controller 中创建的状态机,如果动画 FBX 文件按照我们之前提到的方式正确配置的话。为了应用 Animator Controller,做如下操作:

  1. 如果场景中还没有角色模型,将角色模型拖到场景中。

  2. 选择它并定位根 GameObject 中的Animator组件。

  3. 点击Controller属性右侧的圆圈,选择之前创建的Player控制器。你也可以直接从项目窗口拖动它。

  4. 确保Avatar属性设置为 FBX 模型内的 avatar;这将告诉动画师我们将使用该骨架。您可以通过其人物图标来识别 avatar 资源,如下面的屏幕截图所示。通常,当您将 FBX 模型拖到场景中时,此属性会自动正确设置:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.17 - 动画师使用玩家控制器和机器人 avatar

  1. Camera游戏对象设置为朝向玩家并播放游戏,您将看到角色执行其Idle动画。

  2. 在不停止游戏的情况下,再次通过双击打开动画控制器资源,并在Hierarchy窗格中选择角色。通过这样做,您应该看到该角色正在播放的动画的当前状态,使用条形图表示动画的当前部分:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.18 - 在选择对象时播放模式下的动画控制器,显示当前动画及其进度

  1. 使用1.0并查看转换的执行方式:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.19 - 设置控制器的速度以触发转换

根据Run动画的设置方式,您的角色可能会开始移动。这是由根动作引起的,这是一个根据动画移动角色的功能。有时这是有用的,但由于我们将完全使用脚本移动角色,我们希望关闭该功能。您可以通过取消Character对象的Animator组件中的Apply Root Motion复选框来实现。:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.20 - 禁用根动作

  1. 您还会注意到更改Velocity值和动画转换开始之间存在延迟。这是因为默认情况下,Unity 会等待原始动画结束后再执行转换,但在这种情况下,我们不希望如此。我们需要立即开始转换。为了做到这一点,选择控制器的每个转换,并在检查器窗口中取消选中Has Exit Time复选框:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.21 - 取消“具有退出时间”复选框以立即执行转换

您可以开始将其他动画拖入控制器并创建复杂的动画逻辑,例如添加跳跃、下落或蹲伏动画。我邀请您尝试其他参数类型,例如布尔值,它使用复选框而不是数字。此外,随着游戏的进一步开发,您的控制器将增加其动画数量。为了管理它,还有其他值得研究的功能,例如混合树和子状态机,但这超出了本书的范围。

现在我们了解了 Unity 中角色动画的基础知识,让我们讨论如何创建动态摄像机动画来跟随我们的玩家。

使用 Cinemachine 创建动态摄像机

摄像机在视频游戏中是一个非常重要的主题。它们允许玩家看到周围的环境,以便根据所见做出决策。游戏设计师通常定义其行为方式,以获得他们想要的确切游戏体验,这并不容易。必须层叠许多行为才能获得确切的感觉。此外,在过场动画期间,控制摄像机将要穿越的路径以及摄像机的焦点是重要的,以便在这些不断移动的场景中聚焦动作。

在本章中,我们将使用 Cinemachine 软件包创建两个动态摄像机,这些摄像机将跟随玩家的动作,我们将在第三部分中编写,并且还将用于过场动画中使用的摄像机。

在本节中,我们将研究以下 Cinemachine 概念:

  • 创建摄像机行为

  • 创建摄影机轨道

让我们首先讨论如何创建一个 Cinemachine 控制的摄像机,并在其中配置行为。

创建摄像机行为

Cinemachine 是一组不同的行为,可以用于摄像机中,当正确组合时可以生成各种常见的视频游戏摄像机类型,包括从后面跟随玩家,第一人称摄像机,俯视摄像机等。为了使用这些行为,我们需要了解大脑和虚拟摄像机的概念。

在 Cinemachine 中,我们将只保留一个主摄像机,就像我们迄今为止所做的那样,该摄像机将由虚拟摄像机控制,这些虚拟摄像机是分开的游戏对象,具有先前提到的行为。我们可以有几个虚拟摄像机,并且可以随意在它们之间切换,但是活动虚拟摄像机将是唯一控制我们主摄像机的摄像机。这对于在游戏的不同点之间切换摄像机非常有用,例如在我们玩家的第一人称摄像机之间切换。为了使用虚拟摄像机控制主摄像机,它必须具有Brain组件。

要开始使用 Cinemachine,首先我们需要从软件包管理器中安装它,就像我们之前安装其他软件包一样。如果您不记得如何做到这一点,只需执行以下操作:

  1. 转到窗口 | 软件包管理器

  2. 确保窗口左上角的软件包选项设置为Unity Registry外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.22 – 软件包过滤模式

  1. 等待左侧面板从服务器中填充所有软件包(需要互联网)。

  2. 查找列表中的Cinemachine软件包并选择它。在撰写本书时,我们使用的是 Cinemachine 2.6.0。

  3. 单击屏幕右下角的安装按钮。

让我们开始创建一个虚拟摄像机来跟随我们之前制作的角色,这将是我们的玩家英雄。执行以下操作:

  1. 单击CM vcam1外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.23 – 虚拟摄像机创建

  1. 如果您从CinemachineBrain组件中选择了主摄像机,那么我们的主摄像机将自动添加到其中,使我们的主摄像机跟随虚拟摄像机。尝试移动创建的虚拟摄像机,您将看到主摄像机如何跟随它:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.24 – CinemachineBrain 组件

  1. 选择虚拟摄像机,并将角色拖动到 Cinemachine 虚拟摄像机组件的跟随看向属性中。这将使移动和观察行为使用该对象来完成它们的工作:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.25 – 设置我们摄像机的目标

  1. 您可以看到03-3)值:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.26 – 摄像机从后面跟随角色

  1. 图 12.26 显示01.50很好地使摄像机看向胸部:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.27 – 改变瞄准偏移

正如您所看到的,使用 Cinemachine 非常简单,在我们的情况下,默认设置大多已经足够满足我们需要的行为。但是,如果您探索其他BodyAim模式,您会发现您可以为任何类型的游戏创建任何类型的摄像机。我们不会在本书中涵盖其他模式,但我强烈建议您查看 Cinemachine 的文档,以了解其他模式的功能。要打开文档,请执行以下操作:

  1. 通过转到窗口 | 包管理器来打开包管理器。

  2. 在左侧列表中找到Cinemachine。如果没有显示,请稍等一会。请记住,您需要互联网连接才能使用它。

  3. 一旦选择了Cinemachine,请查找蓝色的查看文档链接。单击它:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.28 - Cinemachine 文档链接

  1. 您可以使用左侧的导航菜单来探索文档:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.29 - Cinemachine 文档

就像您在 Cinemachine 中所做的那样,您也可以以同样的方式找到其他软件包的文档。现在我们已经实现了我们需要的基本摄像机行为,让我们探索如何使用 Cinemachine 为我们的开场动画创建摄像机。

创建推车轨道

当玩家开始关卡时,我们希望有一个小的过场动画,展示我们的场景和战斗之前的基地。这将需要摄像机沿着固定路径移动,这正是 Cinemachine 的推车摄像机所做的。它创建了一个我们可以附加虚拟摄像机的路径,以便它会跟随它。我们可以设置 Cinemachine 自动沿着轨道移动或者跟随目标到轨道最近的点;在我们的情况下,我们将使用第一个选项。

为了创建推车摄像机,请执行以下操作:

  1. 让我们开始用一个推车创建轨道,这是一个小物体,将沿着轨道移动,这将是摄像机跟随的目标。要做到这一点,请单击Cinemachine | 创建带有推车的推车轨道外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.30 - 默认直线路径的推车摄像机

  1. 如果选择DollyTrack1对象,您可以看到两个带有数字01的圆圈。这些是轨道的控制点。选择其中一个并像移动其他对象一样移动它,使用平移图标的箭头。

  2. 您可以通过单击DollyTrack1对象的CinemachineSmoothPath组件来创建更多的控制点:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.31 - 添加路径控制点

  1. 创建尽可能多的航点,以创建一个将在开场动画中遍历您希望摄像机监视的区域的路径。请记住,您可以通过单击它们并使用平移图标来移动航点:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.32 - 我们场景中的推车轨道。它在角色的后面结束

  1. 创建一个新的虚拟摄像机。创建后,如果您转到游戏视图,您会注意到角色摄像机将处于活动状态。为了测试新摄像机的外观,选择它并在检查器窗口中单击独奏按钮:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.33 - 在编辑时临时启用虚拟摄像机的“独奏”按钮

  1. 设置我们之前使用轨道创建的DollyCart1对象。

  2. 000设置为使摄像机保持在与推车相同的位置。

  3. Aim设置为与跟随目标相同,使摄像机朝着相同的方向看,这将跟随轨道曲线:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.34 – 配置使虚拟相机跟随推车轨道

  1. 选择DollyCart1对象,并更改位置值,以查看推车沿着轨道移动的情况。在游戏窗口聚焦且CM vcam2处于独立模式时执行此操作,以查看相机的外观:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.35 – 推车组件

有了正确设置的推车轨道,我们可以使用时间轴来创建我们的剧情场景。

使用时间轴创建剧情场景

我们有我们的开场相机,但这还不足以创建一个剧情场景。一个合适的剧情场景是一系列在应该发生的确切时刻发生的动作,协调多个对象以按预期方式行动。我们可以有启用和禁用对象、切换相机、播放声音、移动对象等动作。为此,Unity 提供了时间轴,这是一个协调这种类型剧情场景的动作的序列器。我们将使用时间轴为我们的场景创建一个开场剧情,展示游戏开始前的关卡。

在本节中,我们将研究以下时间轴概念:

  • 创建动画剪辑

  • 安排我们的开场剧情

我们将看到如何在 Unity 中创建自己的动画剪辑,以动画我们的游戏对象,然后将它们放入一个剧情场景中,使用时间轴序列工具协调它们的激活。让我们开始创建一个相机动画,以便稍后在时间轴中使用。

创建动画剪辑

这实际上不是时间轴特定的功能,而是一个与时间轴很好配合的 Unity 功能。当我们下载角色时,它带有使用外部软件创建的动画剪辑,但您可以使用 Unity 的动画窗口创建自定义动画剪辑。不要将其与动画师窗口混淆,后者允许我们创建根据游戏情况做出反应的动画过渡。这对于创建您稍后将在时间轴中与其他对象的动画协调的小对象特定动画非常有用。

这些动画可以控制对象组件属性的任何值,例如位置、颜色等。在我们的情况下,我们想要动画推车轨道的位置属性,使其在给定时间内从起点到终点。为了做到这一点,请执行以下操作:

  1. 选择DollyCart1对象。

  2. 打开动画(而不是动画师)窗口,方法是转到窗口 | 动画 | 动画

  3. 单击动画窗口中心的创建按钮。记住在选择推车(而不是轨道)时执行此操作:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.36 – 创建自定义动画剪辑

  1. 完成此操作后,系统将提示您在某个位置保存动画剪辑。我建议您在项目中(在Assets文件夹内)创建一个Animations文件夹,并将其命名为IntroDollyTrack

如果你注意到,推车现在有一个带有创建的动画控制器的动画师组件,其中包含我们刚刚创建的动画。与任何动画剪辑一样,您需要将其应用到具有动画控制器的对象上;自定义动画也不例外。所以,动画窗口为您创建了它们。

在此窗口中进行动画操作包括在给定时刻指定其属性的值。在我们的情况下,我们希望在动画的开始时在时间轴的第 0 秒处为0,并在动画结束时在第5秒处为240。我选择了240,因为这是我的手推车的最后可能位置,但这取决于您的手推车轨道的长度。只需测试一下您的最后可能位置是什么。此外,我选择第5秒,因为我觉得这是动画的正确长度,但随时可以根据需要进行更改。现在,在动画的05秒之间发生的任何事情都是0240值的插值,这意味着在2.5秒时,值为120。动画始终包括在不同时刻对对象的不同状态进行插值。

为了做到这一点,执行以下操作:

  1. 动画窗口中,单击记录按钮(位于左上角的红色圆圈)。这将使 Unity 检测对象的任何更改并将其保存到动画中。记得在选择手推车时进行此操作。

  2. 设置1,然后设置为0。将其更改为任何值,然后再次更改为0将创建一个关键帧,这是动画中的一个点,表示在0秒时,我们希望0。如果值已经为0,则首先将其设置为任何其他值。您会注意到位置属性已添加到动画中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.37 - 在将位置值更改为 0 后,记录模式下的动画

  1. 使用鼠标滚轮,将时间轴向右缩小到顶部栏的5秒:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.38 - 显示 5 秒的动画窗口时间轴

  1. 单击时间轴顶部的5秒标签,将播放头定位到该时刻。这将定位我们在该时刻进行的下一个更改。

  2. 设置240。记得将动画窗口设置为记录模式:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.39 - 在动画的第 5 秒创建一个值为 240 的关键帧

  1. 点击CM vcam2左上角的播放按钮,它处于独奏模式。

现在,如果我们点击播放,动画将开始播放,但这并不是我们想要的。在这种情况下,想法是将过场动画的控制权交给过场动画系统 Timeline,因为这个动画不是我们需要在过场动画中进行排序的唯一内容。防止Animator组件自动播放我们创建的动画的一种方法是在控制器中创建一个空动画状态,并通过以下方式将其设置为默认状态:

  1. 搜索我们创建动画时创建的动画控制器并打开它。如果找不到它,只需选择手推车,然后双击我们游戏对象的Animator组件的Controller属性以打开资产。

  2. 在控制器中的空状态上右键单击,然后选择创建状态 | 。这将在状态机中创建一个新状态,就好像我们创建了一个新动画,但这次是空的:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.40 - 在动画控制器中创建一个空状态

  1. 右键单击新状态,然后单击设置为层默认状态。状态应变为橙色:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.41 - 将控制器的默认动画更改为空状态

  1. 现在,如果点击播放,由于我们手推车的默认状态为空,不会播放任何动画。

现在我们已经创建了我们的摄像机动画,让我们开始创建一个通过时间轴从 intro 片段摄像机切换到玩家摄像机的片段。

对我们的 intro 片段进行排序

时间轴已经安装在您的项目中,但是如果您转到时间轴的包管理器,您可能会看到一个“更新”按钮,以获取最新版本,如果您需要一些新功能。在我们的情况下,我们将保留包含在我们项目中的默认版本(在撰写本书时为 1.3.4)。

我们要做的第一件事是创建一个片段资产和一个负责播放它的场景中的对象。要做到这一点,请按照以下步骤进行:

  1. 使用“GameObject” | “Create Empty”选项创建一个空的 GameObject。

  2. 选择空对象并将其命名为“导演”。

  3. 转到“窗口” | “排序” | “时间轴”以打开“时间轴”编辑器。

  4. 在“导演”对象被选中时,单击“时间轴”窗口中间的“创建”按钮,将该对象转换为片段播放器(或导演)。

  5. 完成此操作后,将弹出一个窗口询问您保存文件。这个文件将是片段或时间轴;每个片段将保存在自己的文件中。将其保存在项目中的Cutscenes文件夹中(Assets文件夹)。

  6. 现在,您可以看到“导演”对象具有“可播放导演”组件,并且在“可播放”属性中设置了上一步保存的“Intro”片段资产,这意味着这个片段将由导演播放:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.42 - 准备播放 Intro 时间轴资产的可播放导演

现在我们已经准备好使用时间轴资产进行工作,让我们让它排序动作。首先,我们需要排序两件事 - 首先是我们在上一步中做的 cart 位置动画,然后是 dolly 轨道摄像机(CM vcam2)和玩家摄像机(CM vcam1)之间的摄像机切换。正如我们之前所说,片段是在给定时刻执行的一系列动作,为了安排动作,您需要轨道。在时间轴中,我们有不同类型的轨道,每种轨道都允许您在特定对象上执行某些动作。我们将从动画轨道开始。

动画轨道将控制特定对象播放哪个动画;我们需要为每个要进行动画处理的对象创建一个轨道。在我们的情况下,我们希望 dolly 轨道播放我们创建的“Intro”动画,所以让我们这样做:

  1. 右键单击时间轴编辑器的左侧并单击“动画轨道”创建动画轨道:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.43 - 创建动画轨道

  1. 选择“导演”对象并检查检查器窗口中“可播放导演”组件的“绑定”列表。

  2. 拖动“Cart”对象以指定我们希望动画轨道控制其动画:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.44 - 使动画轨道控制 dolly cart 动画

重要提示:

时间轴是一个通用资产,可以应用到任何场景,但是由于轨道控制特定对象,您需要在每个场景中手动绑定它们。在我们的情况下,我们有一个期望控制单个动画师的动画轨道,因此在每个场景中,如果我们想应用这个片段,我们需要将特定的动画师拖放到“绑定”列表中。

  1. 将我们创建的“Intro”动画资产拖放到“时间轴”窗口中的动画轨道中。这将在轨道中创建一个剪辑,显示动画将播放的时间和持续时间。您可以将许多动画拖放到轨道中,以便在不同时刻对不同动画进行排序;但是现在,我们只需要这一个:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.45 – 使动画师轨道播放介绍剪辑

  1. 你可以拖动动画来改变你想要它播放的确切时刻。将它拖到轨道的开头。

  2. 点击时间轴窗口左上角的播放按钮来查看它的运行情况。你也可以手动拖动时间轴窗口中的白色箭头来查看不同时刻的过场动画:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.46 – 播放时间轴并拖动播放头

重要提示:

请记住,你不需要使用时间轴来播放动画。在这种情况下,我们是这样做的,以便精确控制我们希望动画播放的时刻。你也可以使用脚本来控制动画师。

现在,我们将使我们的介绍时间轴资产告诉CinemachineBrain组件(主摄像头)在过场动画的每个部分时使用哪个摄像头,一旦摄像头动画结束就切换到玩家摄像头。我们将创建第二个轨道—Cinemachine 轨道—专门用于使特定的CinemachineBrain组件在不同的虚拟摄像头之间切换。要做到这一点,请按照以下步骤进行:

  1. 右键单击动画轨道下方的空白处,然后单击Cinemachine 轨道。请注意,你可以安装不带 Cinemachine 的时间轴,但在这种情况下,这种轨道不会显示出来:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.47 – 创建新的 Cinemachine 轨道

  1. Playable Director组件的Bindings列表中,将主摄像头拖到Cinemachine 轨道,以使该轨道控制在过场动画的不同时刻哪个虚拟摄像头将控制主摄像头:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.48 – 使 Cinemachine 轨道控制我们场景的主摄像头

  1. 下一步指示了时间轴的特定时刻将使用哪个虚拟摄像头。为此,我们的 Cinemachine 轨道允许我们将虚拟摄像头拖到其中,这将创建虚拟摄像头剪辑。按顺序将CM vcam2CM vcam1拖到 Cinemachine 轨道中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.49 – 拖动虚拟摄像头到 Cinemachine 轨道

  1. 如果你点击播放按钮或者只是拖动时间轴播放头,你可以看到当播放头到达第二个虚拟摄像头剪辑时,活动虚拟摄像头是如何改变的。记得在游戏视图中查看。

  2. 如果你将鼠标放在剪辑的末端附近,会出现一个调整大小的光标。如果你拖动它们,你可以调整剪辑的持续时间。在我们的情况下,我们需要将CM vcam2剪辑的长度与Cart动画剪辑匹配,然后通过拖动将CM vcam1放在其末端,这样当手推车动画结束时摄像头就会激活。在我的情况下,它们已经是相同的长度,但是尝试改变一下也是练习。另外,你可以使CM vcam1剪辑变短;我们只需要它播放几个时刻来执行摄像头切换。

  3. 你也可以让剪辑有一点重叠,以使两个摄像头之间有一个平滑的过渡,而不是一个突然的切换,这看起来会很奇怪:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.50 – 调整大小和重叠剪辑以插值它们

如果你等待完整的过场动画结束,你会注意到在最后,CinemachineBrain组件会选择具有最高优先级值的虚拟摄像机。我们可以更改虚拟摄像机的优先级属性,以确保CM vcam1(玩家摄像机)始终是最重要的,或者将Playable Director组件的包裹模式设置为保持,这将保持一切,就像时间轴的最后一帧指定的那样。

在我们的案例中,我们将使用后一种选项来测试时间轴特定的功能:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 12.51 - 包裹模式设置为保持模式

大多数不同类型的轨道都遵循相同的逻辑;每个轨道将控制特定对象的特定方面,使用剪辑在设定的时间内执行。我鼓励你测试不同的轨道,看看它们的作用,比如激活,它可以在过场动画期间启用和禁用对象。记住,你可以在包管理器中查看时间轴包的文档。

总结

在本章中,我们介绍了 Unity 提供的不同动画系统,以满足不同的需求。我们讨论了导入角色动画并使用动画控制器控制它们的方法。我们还看到了如何制作可以根据游戏当前情况(如玩家位置)做出反应的摄像机,或者在过场动画中使用的摄像机。最后,我们看了时间轴和动画系统如何为游戏创建开场过场动画。这些工具对于让我们团队中的动画师直接在 Unity 中工作非常有用,而无需整合外部资产(除了角色动画),也可以避免程序员创建重复的脚本来创建动画,从而节省时间。

现在,你可以在 Unity 中导入和创建动画剪辑,并将它们应用到游戏对象上,使它们根据剪辑移动。此外,你还可以将它们放置在时间轴序列中进行协调,并为游戏创建过场动画。最后,你可以创建动态摄像机在游戏中或过场动画中使用。

到目前为止,我们已经讨论了许多 Unity 系统,允许我们在不编码的情况下开发游戏的不同方面,但迟早需要编写脚本。Unity 提供了通用工具来处理通用情况,但我们游戏独特的玩法通常需要手动编码。在下一章中,也就是第三部分的第一章,我们将开始学习如何使用 C#在 Unity 中编码。

第十三章:使用 C#介绍 Unity 脚本

Unity 有很多内置工具来解决游戏开发中最常见的问题,就像我们迄今所见过的那些问题。即使是同一类型的两个游戏也有各自的小差异,使得游戏独一无二,而 Unity 无法预见到这一点,这就是为什么我们需要脚本。通过编码,我们可以以多种方式扩展 Unity 的功能,以实现我们需要的确切行为,而这一切都是通过一种众所周知的语言——C#。我们将介绍如何使用 C#脚本创建自定义组件。

这里我要指出的一件事是,本章主要是对 Unity 的 C#脚本基础知识的回顾,但在其中的某一节中,我将解释一些针对有经验的程序员的高级技巧。因此,如果你有编程经验但不熟悉 Unity,请尽量不要跳过本章。

在本章中,我们将讨论以下脚本概念:

  • 创建 C#脚本

  • 使用事件和指令

我们将创建我们自己的 Unity 组件,学习类的基本结构以及我们可以执行操作和暴露属性以进行配置的方式。让我们从讨论脚本创建的基础知识开始。

创建 C#脚本

本书面向具有一定编程知识的读者,但在本节中,我们将讨论 C#脚本结构,以确保你对我们将在接下来的章节中编写的行为有坚实的基础。

在本节中,我们将讨论以下脚本创建概念:

  • 初始设置

  • 创建一个基于 MonoBehaviour 的类

  • 添加字段

我们将创建我们的第一个 Unity 脚本,这将用于创建我们的组件,讨论所需的工具,并探讨如何将我们的类字段暴露给编辑器。让我们从脚本创建的基础知识开始。

初始设置

在创建我们的第一个脚本之前,有一件事需要考虑,那就是 Unity 如何编译代码。在编码时,我们习惯于使用集成开发环境IDE),这是一个用于创建我们的代码并编译或执行它的程序。在 Unity 中,我们只会将 IDE 作为一个工具来轻松创建带有着色和自动补全的脚本,因为 Unity 没有自定义的代码编辑器,如果你以前从未编写过代码,这些对初学者来说是宝贵的工具。脚本将被创建在 Unity 项目中,如果进行了任何更改,Unity 将检测并编译它们,因此你不需要在 IDE 中进行编译。不用担心——你仍然可以在这种方法中使用断点。

我们可以使用 Visual Studio、Visual Studio Code、Rider 或者你喜欢使用的任何 C# IDE,但当你安装 Unity 时,你可能会看到一个选项自动安装 Visual Studio,这样你就可以拥有一个默认的 IDE。这将安装 Visual Studio 的免费版本,所以不用担心许可证问题。如果你的电脑上没有 IDE,并且在安装 Unity 时没有勾选 Visual Studio 选项,你可以这样做:

  1. 打开Unity Hub

  2. 转到安装部分。

  3. 点击 Unity 版本右上角的三个点,然后点击添加模块外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.1 – 将模块添加到 Unity 安装中

  1. 勾选Visual Studio选项;选项的描述将根据你使用的 Unity 版本而有所不同。

  2. 点击右下角的下一步按钮:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.2 – 安装 Visual Studio

  1. 等待操作结束。这可能需要几分钟的时间。

如果你有自己喜欢的 IDE,你可以自行安装并配置 Unity 来使用它。如果你有能力支付或者你是一名教师或学生(在这些情况下是免费的),我推荐 Rider。它是一个功能强大的 IDE,拥有许多你会喜欢的 C#和 Unity 功能;然而,对于这个练习来说并不是必不可少的。为了设置 Unity 使用自定义 IDE,你可以这样做:

  1. 打开项目。

  2. 转到编辑器的顶部菜单中的编辑 | 首选项

  3. 从左侧面板中选择外部工具菜单。

  4. 从外部脚本编辑器中选择您喜欢的 IDE;Unity 将自动检测到支持的 IDE:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.3 – 选择自定义 IDE

  1. 如果在列表中找不到您的 IDE,可以使用**浏览…**选项,但通常需要使用此选项的 IDE 支持不是很好——但值得一试。

最后,一些 IDE,如 Visual Studio、Visual Studio Code 和 Rider,具有 Unity 集成工具,您需要在项目中安装这些工具,这是可选的,但可能很有用。通常,Unity 会自动安装这些工具,但如果您想确保它们已安装,请执行以下操作:

  1. 打开包管理器窗口 | 包管理器)。

  2. 搜索列表中的您的 IDE,或者使用搜索栏过滤列表。在我的情况下,我使用了 Rider,并且我可以找到一个名为JetBrains Rider Editor的包:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.4 – 自定义 IDE 编辑器扩展安装——在这种情况下是 Rider

  1. 通过查看包管理器底部右侧的按钮来检查是否安装了 IDE 集成包。如果看到安装更新按钮,请单击它,但如果显示已安装,则一切都设置好了。

现在我们已经配置了 IDE,让我们创建我们的第一个脚本。

创建基于 MonoBehaviour 的类

C#是一种面向对象的语言,在 Unity 中也是如此。每当我们想要扩展 Unity 时,我们都需要创建自己的类——一个包含我们想要添加到 Unity 的指令的脚本。如果我们想要创建自定义组件,我们需要创建一个从MonoBehaviour继承的类,这是每个自定义组件的基类。

我们可以直接在 Unity 项目中使用编辑器创建 C#脚本文件,并且可以将它们排列在其他资产文件夹旁边的文件夹中。创建脚本的最简单方法是按照以下步骤进行:

  1. 选择要创建组件的任何游戏对象。由于我们只是在测试这个功能,所以选择任何对象。

  2. 单击检查器底部的添加组件按钮,并查找新脚本选项,该选项显示在单击添加组件后的列表底部:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.5 – 新脚本选项

  1. MyFirstScript中,但是对于您将用于游戏的脚本,请尝试输入描述性名称,而不管长度如何:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.6 – 命名脚本

重要提示:

建议您使用帕斯卡命名法来命名脚本。在帕斯卡命名法中,玩家射击功能的脚本将被称为PlayerShoot。名称的每个单词的第一个字母都是大写的,而且不能使用空格。

  1. 您可以看到在项目视图中创建了一个名为脚本的新资产。请记住,每个组件都有自己的资产,我建议您将每个组件放在Scripts文件夹中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.7 – 脚本资产

  1. 现在,您还会看到您的游戏对象在检查器窗口中有一个新的组件,该组件的名称与您的脚本相同。因此,您现在已经创建了您的第一个组件类:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.8 – 我们的脚本添加到游戏对象

现在我们已经创建了一个component类,请记住类不是组件本身。它是组件应该是什么的描述 - 组件应该如何工作的蓝图。要实际使用组件,我们需要通过创建基于该类的组件来实例化它。每次我们使用编辑器向对象添加组件时,我们都在实例化它。通常,我们不使用 new 来实例化,而是使用编辑器或专门的函数。现在,您可以像使用Add Component按钮一样添加您的组件到任何其他组件中,并在检视器窗口中的Scripts类别中查找它或通过名称搜索它:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.9 - 在 Scripts 类别中添加自定义组件

这里需要考虑的一点是,我们可以将相同的组件添加到多个游戏对象中。我们不需要为每个使用该组件的游戏对象创建一个类。我知道这是基本的程序员知识,但请记住我们正在尝试回顾基础知识。在下一章中,我们将研究更有趣的主题。

现在我们有了我们的组件,让我们探索它的外观,并通过以下方式进行类结构回顾:

  1. 在 Project View 中找到脚本资源并双击打开。记住它应该位于您之前创建的Scripts文件夹中。

  2. 等待 IDE 打开;这可能需要一段时间。当您看到您的脚本代码及其关键字正确着色时,您将知道 IDE 已完成初始化,这取决于所需的 IDE。在 Rider 中,它看起来如下截图。在我的情况下,我知道 Rider 已经完成初始化,因为 MonoBehaviour 类型和脚本名称都着色相同:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.10 - 在 Rider IDE 中打开的新脚本

  1. 前三行 - 以using关键字开头的行 - 包括常见的命名空间。命名空间就像代码容器,也就是在这种情况下,由他人创建的代码(如 Unity,C#创建者等)。我们将经常使用命名空间来简化我们的任务;它们已经包含了我们将使用的解决算法。我们将根据需要添加和删除using组件;在我的情况下,Rider 建议前两个using组件是不必要的,因为我没有在其中使用任何代码,所以它们是灰色的。但是现在,保留它们,因为您将在本书的后面章节中使用它们。记住,它们应该始终位于类的开头:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.11 - using 部分

  1. 下一行,以public class开头的行,是我们声明正在创建一个继承自MonoBehaviour的新类的地方,这是每个自定义组件的基类。我们知道这是因为它以:MonoBehaviour结尾。您可以看到代码的其余部分位于该行的下方括号内,这意味着括号内的代码属于该组件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.12 - MyFirstScript 类定义继承自 MonoBehaviour

现在我们有了第一个组件,让我们编辑它,从字段开始。

添加字段

当我们添加Rigidbody或不同类型的碰撞体作为组件时,仅仅添加组件是不够的。我们需要正确配置它们以实现我们需要的确切行为。例如,RigidbodyMass属性来控制物体的重量,碰撞体有Size属性来控制它们的形状。这样,我们可以在不同的场景中重复使用相同的组件,避免重复相似组件。使用Box碰撞体,我们可以通过更改大小属性来表示正方形或矩形框。我们的组件也不例外;如果我们有一个移动物体的组件,并且我们希望两个物体以不同的速度移动,我们可以使用相同的组件进行不同的配置。

每个配置都是一个类字段,一个特定类型的变量,我们可以在其中保存参数的值。我们可以创建可以在编辑器中编辑的类字段的两种方式——通过将字段标记为public,但违反封装原则,或者通过创建一个私有字段并使用属性公开它。现在,我们将涵盖这两种方法,但如果您不熟悉面向对象编程(OOP)概念,比如封装,我建议您使用第一种方法。

假设我们正在创建一个移动脚本。我们将使用第一种方法添加一个可编辑的数字字段,表示速度,即通过添加public字段。我们将按照以下步骤进行操作:

  1. 双击打开脚本,就像之前一样。

  2. 在类括号内,但在其中的任何括号之外,添加以下代码:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.13 - 在我们的组件中创建一个速度字段

重要提示:

public关键字指定变量可以在类的范围之外被看到和编辑。代码中的float部分表示变量使用十进制数类型,speed是我们为字段选择的名称——这可以是任何您想要的。您可以使用其他值类型来表示其他类型的数据,比如bool表示复选框或布尔值,string表示文本。

  1. 要应用更改,只需在 IDE 中保存文件(通常通过按下Ctrl + Scommand + S),然后再返回 Unity。当您这样做时,您会注意到编辑器底部右侧有一个小加载轮,表示 Unity 正在编译代码。直到加载轮完成,您才能测试更改。请记住,Unity 将编译代码;不要在 IDE 中编译:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.14 - 加载轮

  1. 编译完成后,您可以在检视器窗口中看到您的组件,Speed变量应该在那里,允许您设置您想要的速度。当然,现在这些变量什么都不做。Unity 不会根据变量的名称识别您的意图;我们需要以某种方式设置它以供后续使用,但我们稍后会这样做:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.15 - 一个用于编辑组件稍后将使用的公共字段

  1. 尝试将相同的组件添加到其他对象,并设置不同的速度。这将向您展示不同游戏对象中的组件是独立的,允许您通过不同的设置更改它们的一些行为。

定义属性的第二种方法类似,但是我们创建一个私有字段,鼓励封装,并使用SerializeField属性公开它,如下面的屏幕截图所示。这些屏幕截图展示了两种方法——两种方法都会产生相同的结果;唯一的区别是样式。使用最符合您编码标准的方法:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.16 - 在检视器窗口中公开私有属性的两种方法

如果你不熟悉面向对象编程的封装概念,只需使用第一种方法,对初学者来说更灵活。如果你创建了一个private字段,它将不可被其他脚本访问,因为SerializeField属性只会将变量暴露给编辑器。记住,Unity 不允许你使用构造函数,所以设置初始数据和注入依赖的唯一方法是通过序列化的私有字段或公共字段,并在编辑器中设置它们(或使用依赖注入框架,但这超出了本书的范围)。为简单起见,我们将在本书的大部分练习中使用第一种方法。

如果你愿意,尝试创建其他类型的变量,并查看它们在检视器中的样子。尝试用boolstring替换float,就像之前建议的那样。现在我们知道如何通过数据配置我们的组件,让我们使用这些数据来创建一些行为。

使用事件和指令

现在我们有了一个脚本,我们准备对其进行一些操作。在本章中,我们不会实现任何有用的东西,但我们会解决一些概念,以便在接下来的章节中为我们即将创建的脚本添加一些类型的行为。

在本节中,我们将涵盖以下概念:

  • 事件和指令

  • 在指令中使用字段

  • 常见的初学者错误

我们将探索 Unity 事件系统,它将允许我们通过执行 Unity 函数来响应这些情况。这些函数也会受到编辑器的值的影响,我们脚本中暴露的字段将是可配置的。最后,我们将讨论常见的脚本错误以及如何解决它们。让我们先介绍 Unity 事件的概念。

事件和指令

Unity 允许我们以因果关系的方式创建行为,通常称为事件系统。事件是 Unity 正在监视的情况,例如,当两个对象发生碰撞或被销毁时,Unity 会告诉我们这种情况,从而允许我们根据我们的需求做出反应。例如,当玩家与子弹发生碰撞时,我们可以减少玩家的生命。在这里,我们将探索如何监听这些事件并通过使用一些简单的操作来测试它们。

如果你习惯于事件系统,你会知道它们通常要求我们订阅某种监听器或委托,但在 Unity 中,有一种更简单的方法可用。我们只需要为我们正在寻找的事件编写确切的函数——我是说确切的。如果名称中的一个字母大小写不正确,它将不会执行,也不会引发任何警告。这是最常见的初学者错误,所以要注意。

在 Unity 中有很多事件或消息可以监听,所以让我们从最常见的一个开始——Update。这个事件会告诉你当 Unity 希望你更新你的对象时,根据你的行为目的而定;有些行为不需要它们。Update逻辑通常是需要不断执行的东西;更准确地说,是在每一帧中。记住,每个游戏就像一部电影——屏幕快速切换的一系列图像,看起来就像我们有连续的运动。在Update事件中常见的操作是让对象移动一点,通过这样做,每一帧都会让你的对象不断移动。

我们将在以后学习关于Update和其他事件或消息可以做的事情。现在,让我们专注于如何使我们的组件至少监听这个事件。实际上,基本组件已经带有两个准备好使用的事件函数,一个是Update,另一个在脚本中。如果你不熟悉 C#中函数的概念,我们指的是下面截图中已经包含在我们脚本中的代码片段。试着在你的脚本中找到它。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.17 - 一个名为 Update 的函数,将在每一帧中执行

您会注意到void Update()行上方通常有一行绿色的文本(取决于 IDE)-这称为注释。这些基本上被 Unity 忽略。它们只是您可以留给自己的注释,必须始终以//开头,以防止 Unity 尝试执行它们并失败。我们将在以后使用这个来临时禁用代码行。

现在,为了测试这是否真的有效,让我们添加一个将一直执行的指令。没有比print更好的测试函数了。这是一个简单的指令,告诉 Unity 在控制台中打印一条消息,开发人员可以在其中看到各种消息,以检查一切是否正常工作。用户永远不会看到这些消息。它们类似于经典的日志文件,有时当游戏出现问题并且您正在报告问题时,开发人员会要求您提供这些日志文件。

为了使用函数测试事件,请执行以下操作:

  1. 通过双击打开脚本。

  2. 为了测试,添加print("test");到事件函数中。在下面的屏幕截图中,您可以看到如何在Update事件中执行此操作的示例。记得精确写出指令,包括正确的大小写,空格和引号符号:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.18 - 在所有帧中打印消息

  1. 保存文件,转到 Unity,并播放游戏。

重要提示:

在从 IDE 切换回 Unity 之前记得保存文件。这是 Unity 知道您的文件已更改的唯一方式。一些 IDE,如 Rider,会自动为您保存文件,但我不建议您使用自动保存,至少在大型项目中不要这样做(您不希望在有很多脚本的项目中意外重新编译未完成的工作;这需要太长时间)。

  1. 查找控制台选项卡并选择它。这通常可以在项目视图选项卡旁边找到。如果找不到,请转到窗口 | 常规 | 控制台,或按下Ctrl + Shift + C(macOS 上为command + shift + C)。

  2. 您会看到在控制台选项卡的每一帧中都打印出"test"的许多消息。如果您没有看到这个,请记得在播放游戏之前保存脚本文件。

  3. 让我们也测试Start函数。在其中添加print("test Start");,保存文件,并播放游戏。完整的脚本应如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.19 - 测试开始和更新函数的脚本

如果现在检查控制台并向上滚动,您会看到一个单独的"test Start"消息和许多随后的"test"消息。您可以猜到,Start事件告诉您游戏已经开始,并允许您执行需要在游戏开始时仅执行一次的代码。我们将在本书的后面使用这个。

对于void Update()语法,我们将告诉 Unity,该行下方括号中包含的内容是一个将在所有帧中执行的函数。重要的是将打印指令放在Update括号内(类的括号内)。此外,print函数期望在其括号内接收文本,称为参数或参数,并且 C#中的文本必须用引号括起来。最后,UpdateStart等函数内的所有指令必须以分号结束。

在这里,我挑战你尝试添加另一个名为OnDestroy的事件,使用print 函数来发现它何时执行。一个小建议是播放并停止游戏,然后查看控制台底部以测试这个。

对于高级用户,如果您的 IDE 允许,您还可以使用断点。断点允许您在执行特定代码行之前完全冻结 Unity,以查看我们的字段数据随时间如何变化并检测错误。在这里,我将向您展示在 Rider 中使用断点的步骤,但 Visual Studio 版本应该类似:

  1. 单击要添加断点的行左侧的垂直条:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.20 - 打印指令中的断点

  1. 转到运行 | 附加到 Unity 进程(在 Visual Studio 中,转到调试 | 附加 Unity 调试器。请记住,您需要 Visual Studio Unity 插件和包管理器的 Visual Studio 集成包):外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.21 - 攻击我们的 IDE 与 Unity 进程

  1. 从列表中查找您想要测试的特定 Unity 实例。列表将显示其他已打开的编辑器或正在执行调试构建。

停止调试过程不会关闭 Unity。它只会将 IDE 与编辑器分离。

现在我们已经创建了字段和指令,让我们将它们结合起来制作可配置的组件。

在指令中使用字段

我们已经创建了字段来配置组件的行为,但到目前为止我们还没有使用它们。我们将在下一章中创建有意义的组件,但我们经常需要做的一件事是使用我们创建的字段来改变对象的行为。到目前为止,我们还没有真正使用我们创建的speed字段。然而,遵循测试代码是否工作的想法(也称为调试),我们可以学习如何使用字段内的数据与函数一起测试值是否符合预期,并根据字段的值改变控制台中print的输出。

在我们当前的脚本中,我们的speed值在运行时不会改变。然而,举个例子,如果您正在创建一个具有护盾伤害吸收的生命系统,并且您想要测试减少的伤害计算是否正常工作,您可能希望将计算值打印到控制台并检查它们是否正确。这里的想法是用字段替换print函数内的固定消息。当您这样做时,print将在控制台中显示字段的值。因此,如果您在speed中设置了5的值并将其打印出来,您将在控制台中看到大量显示5的消息,并且print函数的输出由字段控制。为了测试这一点,您Update函数中的print消息应该如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.22 - 使用字段作为打印函数参数

如您所见,我们只是将字段的名称放在引号中。如果您使用引号,将打印一个"speed"消息。在其他情况下,您可以在一些移动函数中使用speed值来控制移动速度,或者您可以创建一个名为"fireRate"的字段(字段使用驼峰命名法而不是帕斯卡命名法,第一个字母小写)来控制一颗子弹和下一颗子弹之间的冷却时间:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.23 - 打印当前速度

重要提示:

您可以看到我的编辑器被涂成红色,这是因为我配置它在游戏中播放时变成红色,以便轻松检测到。您可以通过转到编辑 > 首选项 > 颜色并更改播放模式色调来实现这一点。

有了所有这些,我们现在有了开始创建实际组件所需的工具。在继续之前,让我们回顾一些常见的错误,如果这是您第一次在 C#中创建脚本,您可能会遇到这些错误。

常见初学者错误

如果您是一名经验丰富的程序员,我敢打赌您对这些非常熟悉,但让我们回顾一下在开始脚本编写时会让您浪费大量时间的常见错误。其中大部分是由于未精确复制所示代码引起的。如果代码中有错误,Unity 将在控制台中显示红色消息,并且不允许您运行游戏,即使您没有使用该脚本。因此,永远不要留下任何未完成的事情。

让我们从一个经典错误开始,即缺少分号,这导致了许多程序员的笑话和段子。所有字段和大多数函数内的指令(如print)在调用时都需要在末尾加上分号。如果不加分号,Unity 将显示错误,例如下图左侧截图中的控制台中的错误。您还会注意到下图右侧的截图中还有一个糟糕的代码示例,IDE 显示了一个红色图标,表明该位置有问题:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.24 - IDE 和 Unity 控制台提示的打印行错误

您会注意到错误显示了确切的脚本(MyFirstScript.cs),代码的确切行号(在本例中为18),通常还有一个描述性消息 - 在本例中为;[分号]预期。您只需双击错误,Unity 将打开 IDE 并突出显示有问题的行。您甚至可以单击堆栈中的链接,跳转到您想要的堆栈行。

我已经提到了为每个指令使用确切大小写非常重要的原因。然而,根据我教授初学者的经验,我需要更加强调这一特定方面。这种情况可能发生的第一个场景是在指令中。在下面的截图中,您可以看到一个糟糕编写的print函数的样子 - 也就是说,您可以看到控制台将显示的错误以及 IDE 将建议存在问题的方式。首先,在 Rider 的情况下,指令被标记为红色,表示该指令未被识别(在 Visual Studio 中,它将显示为红色线)。然后,错误消息表示Print在当前上下文中不存在,这意味着 Unity(或实际上是 C#)不认识任何名为Print的指令。在另一种类型的脚本中,大写的Print可能是有效的,但在常规组件中不是有效的,这就是为什么当前上下文澄清存在的原因:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.25 - 写指令错误时的错误提示

现在,如果您使用错误的大小写编写事件,情况就更糟了。您可以创建诸如StartUpdate之类的函数,并为其他目的使用任何名称。编写updatestart是完全有效的,因为 C#会认为您将使用这些函数而不是事件作为常规函数。因此,不会显示任何错误,并且您的代码将无法正常工作。尝试编写update而不是Update,看看会发生什么:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.26 - Update 函数中的错误大小写将编译函数但不会执行

另一个错误是将指令放在函数括号外,比如在类的括号内或外部。这样做将不会给函数提示,告诉它何时需要执行。因此,在Event函数外部的print函数是没有意义的,它会显示类似以下截图中的错误。这次,错误并不是非常描述性的。标识符预期表示 C#希望您创建一个函数或字段 - 可以直接放在类中的结构类型:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.27 – 指令或函数调用放错位置

最后,另一个经典错误是忘记关闭开放的括号。如果你不关闭一个括号,C#就不知道一个函数在哪里结束,另一个函数在哪里开始,或者类函数在哪里结束。这可能听起来有些多余,但 C#需要完全定义。在下面的截图中,你可以看到这会是什么样子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.28 – 缺少闭合括号

这个有点难以捕捉,因为代码中的错误显示在实际错误之后很久。这是由于 C#允许你在函数内部放置函数(不经常使用),所以 C#会在后面检测到错误,并要求你添加一个闭合括号。然而,由于我们不想把update放在Start内部,我们需要在Start的末尾修复错误。控制台中的错误消息会很具体,但是不要按照消息建议的位置放置闭合括号,除非你 100%确定该位置是正确的。

除了这些错误,你可能会遇到很多其他错误,但它们都是一样的。IDE 会显示提示,控制台会显示消息;你会随着时间学会它们。只要有耐心,因为每个程序员都会经历这个过程。还有其他类型的错误,比如运行时错误,编译时代码出现错误,由于某些错误配置而在执行时失败,或者最糟糕的是逻辑错误,你的代码编译和执行都没有错误,但却没有达到你的意图。

总结

在本章中,我们探讨了创建脚本时会用到的基本概念。我们讨论了类和实例的概念,以及它们必须继承自 MonoBehaviour 才能被 Unity 接受来创建我们自己的脚本。我们还看到了如何混合事件和指令来为对象添加行为,以及如何在指令中使用字段来自定义它们的功能。

我们刚刚探讨了 C#脚本的基础知识,以确保每个人都在同一起跑线上。然而,从现在开始,我们将假设你在某种编程语言中具有基本的编码经验,并且知道如何使用诸如ifforarray等结构。如果没有,你仍然可以阅读本书,并尝试用 C#入门书籍来补充你不理解的部分。

在下一章中,我们将开始看如何利用我们所学到的知识来创建移动和生成脚本。

第十四章:实现移动和生成

现在我们已经准备好开始编码了,让我们创建我们的第一个行为。我们将看到如何通过使用Transform组件来移动对象的基础知识,这将应用于我们的玩家的移动,子弹的恒定移动以及其他对象的移动。此外,我们还将看到如何在游戏过程中创建和销毁对象,例如玩家和敌人射击的子弹以及敌人波次生成器。这些操作可以在其他场景中使用,所以我们将探索一些来加强这个想法。

在本章中,我们将探讨以下脚本概念:

  • 实现移动

  • 实现生成

我们将开始编写脚本来执行先前提到的移动行为,然后我们将继续进行对象的创建和销毁。

实现移动

几乎游戏中的每个对象都以某种方式移动,玩家角色通过键盘移动,敌人通过 AI 移动,子弹简单地向前移动,等等。在 Unity 中有几种移动对象的方式,所以我们将从最简单的方式开始,即通过Transform组件。

在本节中,我们将探讨以下移动概念:

  • 通过 Transform 移动对象

  • 使用输入

  • 理解 Delta Time

首先,我们将探索如何在我们的脚本中访问 Transform 组件来驱动玩家的移动,然后根据玩家的键盘输入应用移动。最后,我们将探索 Delta Time 的概念,以确保在每台电脑上移动速度保持一致。我们将开始学习 Transform API 来掌握简单的移动。

通过 Transform 移动对象

Transform是一个持有对象的平移、旋转和缩放的组件,因此每个移动系统,如物理或路径查找,都会影响这个组件。无论如何,有时我们想以特定的方式移动一个对象,根据我们的游戏创建我们自己的脚本,它将处理我们需要的移动计算并修改 Transform 来应用它们。

这里暗示的一个概念是组件改变其他组件。在 Unity 中编码的主要方式是创建与其他组件交互的组件。在这里,想法是创建一个访问另一个组件并告诉它做某事的组件,这种情况下是移动。要创建一个告诉Transform移动的脚本,做如下操作:

  1. 创建并添加一个名为Player Movement的脚本到我们的角色。在这种情况下,它将是我们之前创建的动画机器人对象。记得在创建后将脚本移动到Scripts文件夹中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.1 - 为角色创建一个玩家移动脚本

  1. 双击创建的脚本资源以打开 IDE 编辑代码。

  2. 我们正在移动,移动是每帧应用的,所以这个脚本只会使用update函数或方法,我们可以移除Start(移除未使用的函数是一个好习惯):外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.2 - 一个只有 update 事件函数的组件

  1. 要沿着对象的前向轴(Z 轴)移动我们的对象,将transform.Translate(0,0,1);行添加到update函数中,如下图所示。

重要提示

每个组件都继承了一个transform字段(具体来说是一个 getter),它是对放置组件的游戏对象的 Transform 的引用,它代表我们组件的兄弟 Transform。通过这个字段,我们可以访问 Transform 的Translate函数,它将接收要在 X、Y、Z 本地坐标中应用的偏移量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.3 - 一个简单的向前移动脚本

  1. 保存文件并播放游戏以查看移动。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.4 - 暂时禁用导演并增加玩家摄像机优先级

重要提示

我建议您暂时禁用可播放导演对象并增加 CM vcam1 的优先级,这将禁用引入过场动画并使角色跟随摄像机默认激活,减少测试游戏所需的时间。另一个选项是创建一个用于测试玩家移动的辅助场景,这实际上在真实项目中是做的,但现在,让我们保持简单。

您会注意到玩家移动得太快了,这是因为我们使用了固定的 1 米速度,而且因为update正在执行所有帧,所以我们每帧移动 1 米。在标准的 30 FPS 游戏中,玩家每秒移动 30 米,这太多了。我们可以通过添加一个“速度”字段并使用编辑器中设置的值来控制玩家速度,而不是固定的 1 的值。您可以在下一个截图中看到如何做到这一点,但请记住我们在上一章讨论的其他选项(使用 Serialize Field 属性):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.5 - 创建速度字段并将其用作移动脚本的 Z 速度

现在,如果您保存脚本以应用更改并设置为0.1,但您可能需要另一个值(稍后会详细介绍):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.6 - 设置每帧 0.1 米的速度

您会注意到玩家会自动移动。现在让我们看看如何基于玩家输入(如键盘和鼠标输入)执行移动。

使用输入

与 NPC 不同,我们希望玩家的移动是由玩家的输入驱动的,基于他们按下的键,鼠标移动等。我们可以回想我们在第一章**从零开始设计游戏中设计的原始键映射,从下面的两个表中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

表 14.1 - 键盘映射

请查看以下表格中的鼠标映射:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

表 14.2 - 鼠标映射

重要提示

最新的 Unity 版本有一个新的输入系统,但在使用之前需要进行一些设置。现在我们将使用默认的输入系统来简化我们的脚本

要知道是否按下某个特定键,比如上箭头,我们可以使用Input.GetKey(KeyCode.W)这一行,它将返回一个布尔值,指示是否按下了KeyCode枚举中指定的键。我们可以更改键以检查KeyCode枚举值的更改,并将GetKey函数与“if”语句结合使用,使翻译仅在满足该条件时执行(当前按下该键时)。

重要提示

最新的 Unity 版本有一个新的输入系统,但在使用之前需要进行一些设置。现在我们将使用默认的输入系统来简化我们的脚本。

让我们通过以下方式开始实现键盘移动:

  1. 使前进运动仅在按下W键时执行,如下截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.7 - 仅在按下 W 键时执行移动

  1. 我们可以通过更多的If语句添加其他移动方向。我们可以使用S向后移动,AD向左和向右移动,如下截图所示。请注意,当需要沿相反轴方向移动时,我们使用减号来反转速度:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.8 - 检查 W、A、S 和 D 键的压力

重要提示

记住,如果不使用括号的if语句,意味着只有if语句内部的一行将紧跟在if语句后面,也就是说,transform.Translate的调用。无论如何,在最终的代码中,我建议保留括号。

  1. 如果你还想考虑箭头键,可以在if语句中使用 OR,如下面的截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.9 - 检查 W、A、S、D 和箭头键的压力

  1. 保存更改并在播放模式下测试移动。

需要考虑的一点是,首先,我们可以通过配置输入管理器来将多个键映射到单个操作的另一种方式,输入管理器是可以创建动作映射的地方;其次,在撰写本文时,Unity 发布了一个实验性的新输入系统,将取代这个输入管理器。目前,我们将使用这个输入管理器,因为它足够简单,可以启动一个基本的游戏,而且实验性的 Unity 软件包可能存在错误或工作方式的变化。在复杂输入的游戏中,建议使用更高级的工具来进行控制。

现在,让我们实现鼠标控制。在这一部分,我们只会涵盖鼠标移动的旋转;下一部分我们会讨论射击子弹。在鼠标移动的情况下,我们可以得到一个值,表示鼠标水平或垂直移动的程度。这个值不是布尔值,而是一个数字,通常被称为轴的输入类型,这个数字将表示移动的强度和数字的符号表示方向。例如,如果 Unity 的"Mouse X"轴的值为 0.5,意味着鼠标以适度的速度向右移动,但如果值为-1,表示鼠标向左快速移动,如果没有移动,值为 0。游戏手柄的摇杆也是一样;Horizontal轴表示常见游戏手柄左摇杆的水平移动,所以如果玩家将摇杆完全向左拉,值将为-1。

我们可以创建自己的轴来映射其他常见游戏手柄的压力控制,但对于我们的游戏来说,默认的足够了。要检测鼠标移动,做如下操作:

  1. update中使用Input.GetAxis函数,紧挨着移动的if语句,如下面的截图所示,将这一帧的鼠标移动值存储到一个变量中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.10 获取鼠标的水平移动

  1. 使用transform.Rotate函数来旋转角色。这个函数按 X、Y、Z 轴的顺序接收旋转的度数。在这种情况下,我们需要水平旋转,所以我们将使用鼠标移动值作为 Y 轴的旋转,如下面的截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.11 - 根据鼠标移动水平旋转对象

  1. 如果你保存并测试这个,你会注意到玩家会旋转,但速度很快或很慢,这取决于你的电脑。记住,这种值需要可配置,所以让我们在编辑器中创建一个rotationSpeed字段来配置玩家的速度:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.12 - 速度和旋转速度字段

  1. 现在我们需要将鼠标移动值乘以速度,这样,根据rotationSpeed,我们可以增加或减少旋转的量。例如,如果我们将旋转速度设置为 0.5,将这个值乘以鼠标移动值将使对象以之前速度的一半旋转,如下面的截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.13 - 将鼠标移动乘以旋转速度

  1. 保存代码,回到编辑器设置旋转速度值。如果不这样做,对象就不会旋转,因为浮点类型字段的默认值是 0:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.14 – 设置旋转速度

  1. 您可能还注意到,由 Cinemachine 控制的摄像机可能需要延迟来适应新的玩家位置。您可以像我在下一个截图中所做的那样调整插值速度,以获得更灵敏的行为:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.15 – 减少角色虚拟摄像机身体和瞄准部分的阻尼

现在我们已经完成了我们的移动脚本,我们需要通过探索 Delta Time 的概念来完善它,使其在每台机器上都能工作。

理解 Delta Time

Unity 的更新循环以计算机的速度执行。您可以在 Unity 中指定所需的帧率,但实现这一点完全取决于您的计算机是否能达到这一点,这取决于许多因素,不仅仅是硬件,因此您不能期望始终具有一致的 FPS。您必须编写脚本来处理每种可能的情况。我们当前的脚本是以每帧一定的速度移动的,这里的“每帧”部分很重要。

我们已经将移动速度设置为 0.1,所以如果我的计算机以 120 FPS 运行游戏,玩家将每秒移动 12 米。那么在游戏以 60 FPS 运行的计算机上会发生什么呢?您可能会猜到,它只会每秒移动 6 米,使我们的游戏在不同的计算机上具有不一致的行为。这就是 Delta Time 拯救了我们的地方。

Delta Time 是一个告诉我们自上一帧以来经过了多少时间的值。这个时间很大程度上取决于我们游戏的图形、实体数量、物理体、音频和无数方面,这些将决定您的计算机可以处理一帧的速度有多快。例如,如果您的游戏以 10 FPS 运行,这意味着在一秒内,您的计算机可以处理更新循环 10 次,这意味着每个循环大约需要 0.1 秒;在那一帧中,Delta Time 将提供该值。在下一个图表中,您可以看到 4 帧需要不同的时间来处理的示例,这在现实情况下可能会发生:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.16 – 游戏不同帧的 Delta Time 值变化

在这里,我们需要以一种方式编码,将移动的“每帧”部分改为“每秒”; 我们需要在不同的计算机上每秒有一致的移动。一种方法是与 Delta Time 成比例地移动:Delta Time 值越高,那一帧就越长,移动量应该越大,以匹配自上次更新以来经过的真实时间。我们可以根据每秒 0.1 米的速度字段当前值来思考;我们的 Delta Time 为 0.5,意味着已经过去了半秒,所以我们应该移动一半的速度,0.05。两帧后,一秒已经过去,帧的移动总和(2 x 0.05)与目标速度 0.1 相匹配。Delta Time 可以被解释为已经过去的秒数的百分比。

为了使 Delta Time 影响我们的移动,我们应该在每一帧简单地将我们的速度乘以 Delta Time,因为 Delta Time 每一帧都可能不同,所以让我们这样做:

  1. 我们使用 Time.deltaTime 访问 Delta Time。我们可以通过在每个 Translate 中乘以 Delta Time 来开始影响移动:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.17 – 通过 Delta Time 乘以速度

  1. 我们可以对旋转速度做同样的操作,将鼠标和速度相乘:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.18 – 将 Delta Time 应用于旋转代码

  1. 如果你保存并播放游戏,你会注意到移动速度比以前慢了,这是因为现在每秒移动 0.1,意味着每秒 10 厘米,这相当慢;尝试提高这些值。在我的情况下,速度为 10,旋转速度为 180 就足够了,但旋转速度取决于玩家的首选灵敏度,这是可以配置的,但让我们留到另一个时间。

我们刚学会了如何将 Unity 的输入系统(告诉我们键盘、鼠标和其他输入设备的状态)与基本的变换移动函数相结合。这样,我们可以开始让我们的游戏感觉更加动态。

现在我们已经完成了玩家的移动,让我们讨论如何使用 Instantiate 函数让玩家发射子弹。

实现生成

我们在编辑器中创建了许多定义我们级别的对象,但一旦游戏开始,并根据玩家的操作,必须创建新的对象以更好地适应玩家交互生成的场景。敌人可能需要在一段时间后出现,或者根据玩家的输入创建子弹;即使敌人死亡,也有可能生成一些增益道具。这意味着我们不能预先创建所有必要的对象,而应该动态创建它们,这是通过脚本完成的。

在本节中,我们将研究以下生成概念:

  • 生成对象

  • 计时动作

  • 销毁对象

我们将开始看到 Unity 的Instantiate函数,它允许我们在运行时创建预制体的实例,例如按下键时,或者按时间安排,例如使我们的敌人每隔一段时间生成子弹。此外,我们将学习如何销毁这些对象,以防止场景由于处理太多对象而开始表现不佳。

让我们从如何根据玩家的输入射击子弹开始。

生成对象

要在运行时或播放模式下生成一个对象,我们需要一个对象的描述,它有哪些组件,它的设置以及可能的子对象。你可能会在这里考虑到预制体,你是对的,我们将使用一条指令告诉 Unity 通过脚本创建一个预制体的实例。记住,预制体的实例是基于预制体创建的对象,基本上是原始对象的克隆。

我们将开始射击玩家的子弹,所以首先让我们通过以下步骤创建子弹预制:

  1. GameObject | 3D Object | Sphere中创建一个球体。如果你愿意,你可以用另一个子弹模型替换球体网格,但在这个例子中我们暂时保留球体。

  2. 将球体重命名为Bullet

  3. 通过单击Bullet来创建一个材质。记得将它放在Materials文件夹中。

  4. 在材质中勾选Emission复选框,并将emission MapBase Map颜色设置为红色。记住,发射颜色会使子弹发光,特别是在我们的后期处理体积中的泛光效果下:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.19 – 创建一个带发光颜色的红色子弹材质

  1. 通过将材质拖放到球体上,将材质应用到球体上。

  2. 将比例设置为较小的值—(0.3, 0.3, 0.3)在我的情况下有效:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.20 – 小红色子弹

  1. 创建一个名为ForwardMovement的脚本,以使子弹以固定速度不断向前移动。

我建议你先自己尝试解决这个问题,然后在下一步中查看屏幕截图以获取解决方案,这是一个小挑战,可以回顾我们之前看到的运动概念。如果你不记得如何创建脚本,请阅读第十三章**,使用 C#介绍 Unity 脚本编写,并检查前一节以了解如何移动对象。

  1. 下一张截图向你展示了脚本应该是什么样子的:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.21 – 简单的向前移动脚本

  1. 将脚本(如果尚未存在)添加到子弹上,并将速度设置为您认为合适的值。通常,子弹比玩家更快,但这取决于您想要获得的玩家体验(记住第一章**中的问题,从零开始设计游戏)。在我的情况下,20 效果很好。通过将子弹放在玩家附近并播放游戏来进行测试:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.22 – 子弹中的前进运动脚本

  1. 将子弹GameObject实例拖到Prefabs文件夹中创建一个子弹Prefab。记住,Prefab 是一个描述创建的子弹的资产,就像创建子弹的蓝图:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.23 – 创建一个 Prefab

  1. 从场景中移除原始子弹;当玩家按下按键时,我们将使用 Prefab 来创建子弹(如果需要的话)。

现在我们有了子弹 Prefab,是时候在玩家按下按键时实例化它(克隆它)了。为此,请执行以下操作:

  1. 创建并添加一个脚本到玩家的GameObject(机器人)上,名为PlayerShooting,然后打开它。

我们需要一种方式让脚本访问 Prefab,以了解从我们项目中可能有的几十个 Prefab 中使用哪一个。我们脚本所需的所有数据都取决于所需的游戏体验,都以字段的形式存在,比如到目前为止使用的速度字段,因此在这种情况下,我们需要一个GameObject类型的字段,一个可以引用或指向特定 Prefab 的字段,可以使用编辑器进行设置。

  1. 添加字段代码将如下所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.24 – Prefab 引用字段

重要提示

你可能会猜到,我们可以使用GameObject类型来引用 Prefab,也可以引用其他对象。想象一下,敌人 AI 需要引用玩家对象来获取其位置,使用一个 GameObject 来链接这两个对象。关键在于考虑 Prefab 只是场景之外的常规 GameObject;你看不到它们,但它们存在于内存中,准备好被复制或实例化。你只能通过脚本或通过编辑器放置在场景中的副本或实例来看到它们,就像我们到目前为止所做的那样。

  1. 在编辑器中,单击属性右侧的圆圈,并选择BulletPrefab。另一个选项是将BulletPrefab 直接拖到属性中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.25 – 设置 Prefab 引用指向子弹

这样,我们告诉我们的脚本要射击的子弹就是这个。记得拖动 Prefab 而不是场景中的子弹(那应该已经被删除了)。

按照设计文档中指定的方式,当玩家按下鼠标左键时,我们将射击子弹,因此让我们在update事件函数中放置适当的if语句来处理,就像下一张截图中所示的那样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.26 – 检测鼠标左键的压力

你会注意到这次我们使用了GetKeyDown而不是GetKey,前者是一种检测按键开始的确切帧的方法;这个if语句只会在那一帧执行它的代码,并且直到按键释放并重新按下,它才会再次进入。这是防止子弹在每一帧生成的一种方法,但只是为了好玩,你可以尝试使用GetKey来看看它会如何表现。另外,零是属于左键点击的鼠标按钮编号,一是右键点击,二是中键点击。

我们可以使用Instantiate函数来克隆预制品,将其引用作为第一个参数传递。这将在场景中创建一个所述预制品的克隆:

图 14.27 – 实例化预制品

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-unity20-gm-dev/img/Figure_14.27_B14199.jpg)

图 14.27 – 实例化预制品

如果你保存脚本并播放游戏,你会注意到当你按鼠标时,子弹会生成,但可能不是在你期望的位置,如果你没有看到它,尝试在层次结构中查找新对象;它会在那里。问题在于我们没有指定期望的生成位置,我们有两种设置的方法,我们将在接下来的步骤中看到。

第一种方法是使用从 MonoBehaviour 继承的transform.positiontransform.rotation字段,它们会告诉我们当前的位置和旋转。我们可以将它们作为Instantiate函数的第二个和第三个参数传递,函数会理解这是我们希望子弹出现的地方。记住,设置旋转是很重要的,让子弹面向与玩家相同的方向,这样它就会朝着那个方向移动:

图 14.28 – 在我们的位置和旋转实例化预制品

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-unity20-gm-dev/img/Figure_14.28_B14199.jpg)

图 14.28 – 在我们的位置和旋转实例化预制品

第二种方式会更长,但会让我们有更多的灵活性来改变对象的其他方面,就是使用之前版本的 Instantiate,但保存函数返回的引用,这个引用将指向预制品的克隆。拥有实例化子弹的引用允许我们改变任何我们想要的东西,不仅仅是位置,还有旋转,但现在,让我们限制在位置和旋转上。在这种情况下,我们将需要以下三行;第一行将实例化并捕获克隆引用,第二行将设置克隆的位置,第三行将设置旋转。你会注意到我们还将使用克隆的transform.position字段,但这次是通过使用=(赋值)运算符来改变它的值:

图 14.29 – 在特定位置实例化预制品的较长版本

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/hsn-unity20-gm-dev/img/Figure_14.29_B14199.jpg)

图 14.29 – 在特定位置实例化预制品的较长版本

使用你喜欢的版本——两者都是一样的。记住,你可以检查项目存储库以查看完整的脚本。现在你可以用其中一个版本保存文件并尝试射击。

如果你尝试到目前为止的脚本,你应该会看到子弹在玩家的位置生成,但在我们的情况下,它可能是在地板上。问题在于机器人的枢轴在那里,通常每个人形角色的枢轴都在那里。我们有几种方法来解决这个问题,最灵活的方法是创建一个射击点,一个空的玩家子对象,放在我们希望子弹生成的位置。我们可以使用该对象的位置而不是玩家的位置,方法如下:

  1. ShootPoint中创建一个空的GameObject

  2. 将其作为玩家机器人角色对象的子对象,并将其放在你希望子弹出现的位置,可能比原始生成位置稍高和稍向前:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.30 – 放置在角色内部的空 ShootPoint 对象

  1. 像往常一样,要访问另一个对象的数据,我们需要一个对它的引用,比如 Prefab 引用,但这次需要指向我们的ShootPoint。我们可以创建另一个GameObject类型的字段,但这次拖动ShootPoint而不是 Prefab。脚本和对象设置如下截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.31 - Prefab 和 Shoot Point 字段以及它们在编辑器中的设置

  1. 我们可以再次使用transform.position字段访问shootPoint的位置,如下截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.32 - Prefab 和 ShootPoint 字段以及它们在编辑器中的设置

您会注意到现在用鼠标射击和旋转有一个问题;当移动鼠标进行旋转时,指针会落在游戏视图之外,当点击时,您会意外地点击到编辑器,失去了对游戏视图的焦点,因此您需要再次点击游戏视图以恢复焦点并再次使用输入。防止这种情况发生的方法是在游戏进行时禁用鼠标。要做到这一点,请按照以下步骤操作:

  1. 为我们的 Player Movement Script 添加一个Start事件函数。

  2. 将您在脚本中看到的两行添加到您的脚本中。第一行将使光标可见,第二行将锁定光标在屏幕中央,因此它永远不会离开游戏视图。请考虑后者;当您切换回主菜单或暂停菜单时,您将需要重新启用光标,以允许鼠标点击 UI 按钮:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.33 - 禁用鼠标光标

  1. 保存并测试。如果要停止游戏,您可以按Ctrl + Shift + P(Mac 上为command + Shift + P)或按Esc键重新启用鼠标。这两种方法只在编辑器中有效;在真实游戏中,您将需要手动重新启用。

现在我们已经介绍了对象生成的基础知识,让我们通过将其与定时器结合来看一个高级示例。

定时动作

与生成不完全相关,但通常一起使用,定时动作是游戏中的常见任务。其思想是安排某些事情在以后发生;也许我们希望子弹在一段时间后被销毁以防止内存溢出,或者我们想控制敌人的生成速率或它们应该何时生成,这正是我们将在本节中要做的事情,从第二个开始,敌人波次。

我们的想法是,我们希望在游戏的不同时刻以一定的速率生成敌人;也许我们想在第 1 到 5 秒生成敌人,每秒 2 个,得到 10 个敌人,然后给玩家 20 秒的时间来完成它们,并编程另一个波次在第 25 秒开始。当然,这在很大程度上取决于您想要的确切游戏,您可以从这样的想法开始,并在一些测试后修改它,找到您想要波次系统工作的确切方式。在我们的案例中,我们将用先前提到的逻辑来说明定时。

首先,我们需要一个敌人,目前我们将简单地使用与玩家相同的机器人角色,但添加一个前进运动脚本来使其向前移动;稍后在本书中,我们将为我们的敌人添加 AI 行为。我建议您尝试自己创建这个 Prefab,并在尝试后查看下一步,以查看正确答案:

  1. 将 Robot FBX 模型拖到场景中以创建另一个机器人角色,但这次将其重命名为Enemy

  2. 将为子弹创建的ForwardMovement脚本添加到Enemy,并将其速度设置为 10。

  3. Enemy游戏对象拖到项目中,以创建基于该对象的预制件;我们稍后需要生成它。记得选择预制件变体,这样将保持预制件与原始模型链接,使对模型的更改自动应用到预制件。还记得销毁场景中的原始敌人。

现在,为了安排行动,我们将使用Invoke函数套件,一组用于创建定时器的函数,这些函数基本但足够满足我们的要求。让我们通过以下方式使用它:

  1. 在基地的一端创建一个空游戏对象,并将其命名为Wave1a

  2. 创建并添加一个名为WaveSpawner的脚本。

  3. 我们的生成器将需要四个字段:要生成的敌人预制件,开始波浪的游戏时间,结束波浪生成的endTime,以及敌人的生成速率 - 基本上,在给定生成期间每次生成之间应该经过多长时间。脚本和设置将如下截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.34 - 波浪生成器脚本的字段

我们将使用InvokeRepeating函数来安排一个自定义函数定期重复。您只需要安排重复一次;Unity 会记住这一点,所以不要每帧都这样做。这是使用Start事件函数的好时机。函数的第一个参数是一个字符串(引号之间的文本),其中包含要定期执行的其他函数的名称,与 Start 或 update 不同,您可以随意命名函数。第二个参数是开始重复的时间,我们的startTime字段,在这种情况下。最后,函数的第三个参数是函数的重复率,每次重复之间需要经过多长时间,这是spawnRate字段。您可以在下一个截图中找到如何调用该函数,以及自定义的Spawn函数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.35 - 安排生成函数重复

  1. Spawn函数内部,我们可以像我们知道的那样放置生成代码,使用Instantiate函数。想法是以一定的速率调用这个函数,每次调用生成一个敌人。这次,生成位置将与生成器的位置相同,所以要小心放置:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.36 - 在生成函数中实例化

如果您测试此脚本,将startTimespawnRate字段设置为一些测试值,您会注意到敌人将开始生成但永远不会停止,并且您会看到我们到目前为止还没有使用endTime字段。想法是调用CancelInvoke函数,一个函数,将取消我们所做的所有InvokeRepeating调用,但在一段时间后使用Invoke函数,这个函数与InvokeRepeating类似,但这个函数只执行一次。在下一个截图中,您可以看到我们如何在Start中添加了一个Invoke调用到CancelInvoke函数,使用endTime字段作为执行CancelInvoke的时间。这将在一段时间后执行CancelInvoke,取消生成预制件的第一个InvokeRepeating调用:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.37 - 安排生成重复但在一段时间后取消

重要提示

这次,我们使用了CancelInvoke。我们没有使用自定义函数,因为CancelInvoke不接收参数。如果您需要安排带参数的函数,您需要创建一个无参数的包装函数,调用所需的函数并安排那个函数,就像我们在Spawn中所做的那样,那里的唯一目的是使用特定的参数调用Instantiate

  1. 现在您可以保存并为我们的生成器设置一些真实值。在我的情况下,我使用了以下截图中显示的值:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.38 – 在游戏进行的 1 到 5 秒内每 0.5 秒生成一次敌人,每秒 2 个

您应该看到敌人一个接一个地生成,因为它们向前移动,它们将形成一排敌人。这种行为稍后将随 AI 而改变:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.39 – 生成敌人

如果您愿意,可以创建几个 Wave Spawner 对象,安排后期游戏的波次。记住我们在[第一章](B14199_01_Final_SK_ePub.xhtml#_idTextAnchor015)中讨论的难度平衡,从零开始设计游戏;您需要尝试使用最终的敌人 AI,但波次的数量、时间和生成速率将决定游戏的难度,这就是为什么设置这些值很重要。此外,有很多方法可以创建敌人的波次;这只是我能找到的最简单的方法。您可能需要根据您的游戏进行更改。

现在我们已经讨论了定时和生成,让我们讨论定时和销毁对象,以防止我们的子弹永远存在于内存中。

销毁对象

这将非常简短,但是这是一个广泛使用的功能,因此它值得有自己的部分。我们可以使用Destroy函数来销毁对象实例。这个想法是让子弹有一个脚本,在一段时间后安排它们自动销毁,以防止它们永远存在。我们将通过以下步骤创建脚本:

  1. 选择Bullet的预制件,并像使用添加组件 | 新脚本选项一样,为其添加一个名为Autodestroy的脚本。这次,脚本将被添加到预制件中,并且您生成的每个预制件实例都将拥有它。

  2. 您可以使用Destroy函数如下一张截图所示,在Start中仅一次销毁对象。

Destroy函数期望将要销毁的对象作为第一个参数,这里,我们使用gameObject引用,一种指向我们要销毁的 GameObject 的方式。如果您使用this指针,我们将只销毁Autodestroy组件;请记住,在 Unity 中,您永远不会创建 Gameobjects,而是创建要添加到它们的组件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.40 – 当对象启动时销毁对象

当然,我们不希望子弹在生成后立即被销毁,因此我们需要延迟销毁。您可能会考虑使用Invoke,但与 Unity 中的大多数函数不同,Destroy可以接收第二个参数,即等待销毁的时间。

  1. 创建一个delay字段,用作Destroy的第二个参数,如下一张截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.41 – 使用字段配置延迟销毁对象

  1. delay字段设置为适当的值;在我的情况下,5 就足够了。现在通过查看它们从层次结构中被移除来检查子弹在一段时间后消失。

现在,我们可以随意创建和销毁对象,这在 Unity 脚本中非常常见。

重要提示

研究对象池的概念;您会发现有时创建和销毁对象并不那么高效。

总结

我们已经创建了我们的第一个真正的脚本,它提供了有用的行为。我们讨论了如何通过脚本实例化预制件,根据游戏情况随意创建对象。此外,我们还看到了如何安排动作,这种情况下是生成,但这可以用于安排任何事情。最后,我们看到了如何销毁创建的对象,以防止对象数量增加到无法管理的水平。我们将使用这些操作来创建本书后面的其他类型的对象,例如声音和效果。

现在,您可以创建任何类型的运动或生成逻辑,您的对象将需要确保这些对象在需要时被销毁。您可能会认为所有游戏以相同的方式移动和创建射击系统,虽然它们相似,但能够创建自己的运动和射击脚本使您能够定制游戏的这些方面,使其行为如预期,并创造您所寻找的确切体验。

在下一章中,我们将讨论如何检测碰撞,以防止玩家和子弹穿过墙壁等等。

第十五章:物理碰撞和健康系统

由于游戏试图模拟现实世界的行为,模拟物理是一个重要的方面,它决定了对象如何移动以及它们如何相互碰撞,比如玩家和墙壁的碰撞或子弹和敌人的碰撞。由于碰撞后可能发生的各种反应,物理可能很难控制,因此我们将学习如何正确配置它以获得半准确的物理效果,这将产生期望的街机运动感觉,但会使碰撞生效——毕竟,有时候现实生活并不像视频游戏那样有趣。

在本章中,我们将讨论以下碰撞概念:

  • 配置物理

  • 检测碰撞

  • 使用物理移动

首先,我们将学习如何正确配置物理,这是检测对象之间碰撞的必要步骤,我们将使用新的事件来学习。然后,我们将讨论使用Transform移动和使用 Rigidbody 移动之间的区别,以及每个版本的优缺点。让我们开始讨论物理设置。

配置物理

Unity 的物理系统准备好覆盖各种可能的游戏应用,因此正确配置它对于获得期望的结果非常重要。

在本节中,我们将讨论以下物理设置概念:

  • 设置形状

  • 物理对象类型

  • 过滤碰撞

我们将开始学习 Unity 提供的不同类型的碰撞器,然后学习不同的配置方式来检测不同类型的物理反应(碰撞和触发)。最后,我们将讨论如何忽略特定对象之间的碰撞,以防止玩家的子弹伤害玩家等情况发生。

设置形状

在本书的开头,我们学到对象通常有两种形状,一种是视觉形状,基本上就是 3D 网格,另一种是物理形状,也就是碰撞器,物理系统将使用它来计算碰撞。请记住,这样做的目的是让你拥有高度详细的视觉模型,同时拥有简化的物理形状以提高性能。

Unity 有几种类型的碰撞器,因此我们将回顾常见的碰撞器,从基本类型开始,即盒子、球体和胶囊体。这些形状是最便宜的(性能方面)来检测碰撞,因为它们之间的碰撞是通过数学公式进行的,不像其他碰撞器,比如 Mesh Collider,它允许你使用任何网格作为对象的物理主体,但代价更高且有一些限制。理念是你应该使用基本类型来表示你的对象,或者它们的组合,例如,一个平面可以用两个盒子碰撞器来做,一个用于主体,另一个用于翅膀。你可以在下面的截图中看到一个例子,其中你可以看到由基本形状制作的武器碰撞器:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.1 - 复合碰撞器

无论如何,尽量避免这样做;如果我们只是希望武器掉落到地面上,也许一个覆盖整个武器的盒子碰撞器就足够了,考虑到这些类型的碰撞不需要精确,从而提高性能。此外,有些形状甚至不能用基本形状的组合来表示,比如坡道或金字塔,你唯一的解决方案就是使用 Mesh Collider,它需要一个 3D 网格用于碰撞,但我们不会在本书中使用它们;我们将用基本形状来解决所有的物理碰撞器。

现在,让我们为场景添加必要的碰撞器,以便正确计算碰撞。请注意,如果您使用的是除了我的之外的 Asset Store 环境包,您可能已经具有带有碰撞器的场景模块;我将展示我需要在我的情况下做的工作,但请尝试将这里的主要思想推广到您的场景中。要添加碰撞器,请按照以下步骤操作:

  1. 在基础中选择一面墙,并检查对象和可能的子对象是否有碰撞器组件;在我的情况下,我没有碰撞器。如果检测到任何网格碰撞器,可以保留它,但我建议您删除它,并在下一步中用另一个选项替换它。想法是给它添加碰撞器,但我在这里检测到的问题是,由于我的墙不是预制体的实例,我需要给每面墙都添加碰撞器。

  2. 一种选择是创建一个预制体,并将所有墙替换为预制体的实例(推荐的解决方案),或者只需在层次结构中选择所有墙(按住Ctrl或 Mac 上的Cmd并单击它们),然后在选择它们时使用Box Collider组件,该组件将使碰撞器的大小适应网格。如果它不适应,您可以只需更改 Box Collider 的 Size 和 Center 属性以覆盖整个墙:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.2 - 添加到墙上的盒子碰撞器

  1. 对于角落、地板瓷砖和任何其他会阻碍玩家和敌人移动的障碍,重复步骤 1 和 2

对于我们的敌人和玩家,我们将添加胶囊碰撞器,这是可移动角色中常用的碰撞器,因为其圆形底部将允许对象顺利爬坡,并且横向圆形允许对象在拐角处轻松旋转而不会卡住,还有其他这种形状的便利之处。请记住,敌人是一个预制体,所以您需要将碰撞器添加到预制体中,而我们的玩家是场景中的一个简单对象,所以您需要将碰撞器添加到该对象中。

重要提示

您可能会诱惑地在角色的骨骼上添加几个盒子碰撞器,以创建对象的真实形状,虽然我们可以这样做,根据敌人被击中的身体部位应用不同的伤害,但我们只是创建了移动碰撞器;胶囊足够了。在高级伤害系统中,胶囊和骨骼碰撞器将共存,一个用于移动,另一个用于伤害检测;但在我们的游戏中,我们将简化这一过程。

此外,有时碰撞器无法很好地适应对象的视觉形状,在我的情况下,胶囊碰撞器对角色来说形状不好。我需要通过设置其值来修复其形状,如下面的截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.3 - 角色碰撞器

我们用球体创建的子弹已经有了一个球体碰撞器,但如果您用另一个替换了子弹的网格,您可能需要更改碰撞器。目前,我们的游戏不需要其他对象,所以现在每个对象都有了适当的碰撞器,让我们看看如何为每个对象设置不同的物理设置以启用适当的碰撞检测。

物理对象类型

现在,通过使对象在物理模拟中存在,我们已经为每个对象添加了碰撞器,是时候配置它们以获得我们想要的确切物理行为了。我们有许多可能的设置组合,但我们将讨论一组常见的配置文件,涵盖大多数情况。请记住,除了碰撞器,我们在本书的开头看到了 Rigidbody 组件,它是将物理应用于对象的组件。以下配置文件是通过碰撞器和 Rigidbody 设置的组合完成的:

  • “刚体”组件,因此它们在物理模拟中存在,但没有任何物理作用;它们不能被其他物体移动,它们不会有物理效应,无论如何它们都会固定在它们的位置。需要注意的是,这与编辑器右上角的静态复选框无关;那些是用于之前看到的系统(比如照明和其他系统),所以如果需要的话,你可以有一个未选中该复选框的静态碰撞体。

重要提示

需要考虑的是,这些物体可以通过脚本移动,但你不应该这样做。Unity 对它们应用了一种优化技术,每当静态碰撞体移动时,优化就会失效,需要进一步计算来更新它,而且每一帧都这样做是很昂贵的。

我们刚提到地形作为一个例子,如果你检查地形的组件,你会发现它有自己的一种碰撞体,地形碰撞体。对于地形来说,这是唯一要使用的碰撞体。

  • “刚体”组件,就像我们在本书的第一部分中所做的掉落球的例子。这些是完全由物理驱动的物体,具有重力,并且可以通过力移动;其他物体可以推动它们,并且它们会执行你可以期望的每一个物理反应。你可以用它来控制玩家、手榴弹移动,或者掉落的板条箱,或者在像“不可思议的机器”这样的重度物理游戏中的所有物体。

  • “刚体”组件但有transform.Translate)而没有性能损失。需要考虑的是,由于它们没有物理效应,它们也不会有碰撞,所以它们可以穿过墙壁。这些可以用于需要使用动画或自定义脚本移动的物体,比如移动平台,考虑到在这种情况下,平台不会与其他物体发生碰撞,但是玩家通常会与它们发生碰撞,因为玩家通常会有一个物理碰撞体,实际上,物理碰撞体是会与各种碰撞体发生碰撞的。

  • “触发器”事件,这是可以通过脚本捕获的事件,告诉我们有东西在碰撞体内。这可以用来创建按钮或触发物体,在游戏中当玩家通过某些事件发生的区域时,比如生成一波敌人、打开门,或者在玩家到达目标位置时赢得游戏。需要考虑的是,普通的静态碰撞体在通过这种类型的碰撞体时不会生成触发事件,因为它们不应该移动。

  • “触发器运动碰撞体”:运动碰撞体不会生成碰撞,所以它们会穿过任何其他物体,但它们会生成触发事件,所以我们可以通过脚本做出反应。这可以用来创建可移动的能量增强道具,当触碰时消失并给我们分数,或者子弹通过自定义脚本移动而没有物理效应,就像我们的子弹一样直线前进,但在接触时会对其他物体造成伤害。

  • 我们可以有一个触发器物理碰撞体,一个带有刚体但勾选了“是触发器”的碰撞体,通常它没有真正的用途;它将是一个永远下落的物体,在世界中生成触发事件,但通过一切。当然,除了指定的这些配置外,还可以存在其他配置,用于一些具有特定游戏玩法要求的游戏,但是考虑到所有可能的物理设置组合是由你来实验的,看看哪些对你的情况有用,描述的配置将涵盖 99%的情况。

  • 为了总结之前的情景,我给你留下以下表格,显示了所有类型的碰撞体之间的接触反应。你会发现每个可以移动的配置文件都有一行;记住静态配置文件不应该移动。每一列代表了它们与其他类型碰撞时的反应,“Nothing”表示物体会毫无影响地穿过,“Trigger”表示物体会穿过但会触发触发事件,“Collision”表示物体无法穿过物体:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

表 15.4 碰撞反应矩阵

考虑到这一点,让我们开始配置场景物体的物理。

墙壁、角落、地板砖和障碍物应该使用静态碰撞体配置文件,所以它们上面没有Rigidbody组件,它们的碰撞体将不勾选Is Trigger复选框:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.5 - 地板砖的配置;记住静态复选框只是用于照明

玩家应该移动并与物体发生碰撞,所以我们需要它具有动态配置文件。这个配置文件将会生成一个有趣的行为与我们当前的移动脚本(我鼓励你去测试),特别是当与墙壁碰撞时,它不会像你期望的那样行为。我们将在本章后面处理这个问题:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.6 - 玩家的动态设置

对于Enemy Prefab,我们将在这里使用 Kinematic 配置文件,因为我们稍后将使用 Unity 的 AI 系统移动这个物体,所以我们这里不需要物理,而且我们希望玩家与它们发生碰撞,所以这里需要一个碰撞反应,所以这里没有Trigger

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.7 - 敌人的运动设置

对于Bullet Prefab,它会移动,但是通过脚本进行简单的移动(只是向前移动),而不是物理。我们不需要碰撞;我们将编写代码,使子弹在触碰到物体时立即销毁,并且会对碰撞到的物体造成伤害(如果可能的话),所以对于这个物体来说,Kinematic Trigger 配置文件就足够了;我们将使用Trigger事件来编写接触反应:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.8 - 我们子弹的运动触发器设置;Is Trigger 和 Is Kinematic 都被勾选了

现在我们已经正确配置了物体,让我们来看看如何过滤掉某些物体类型之间不需要的碰撞。

过滤碰撞

在配置物体的所有麻烦之后,我们是否想要阻止碰撞?实际上,有时我们希望某些物体忽略彼此。例如,玩家射出的子弹不应该与玩家自身发生碰撞,敌人的子弹也不应该击中它们。我们可以在 C#脚本中使用If语句来过滤,检查击中的物体是否来自对立的队伍或者其他你想要的过滤逻辑,但那时已经太迟了,物理系统已经浪费了资源来检查本来不应该碰撞的物体之间的碰撞。这就是图层碰撞矩阵可以帮助我们的地方。

图层碰撞矩阵听起来很可怕,但它是物理系统的一个简单设置,允许我们指定哪些对象组应该与其他组发生碰撞,例如,玩家的子弹应该与敌人发生碰撞,敌人的子弹应该与玩家发生碰撞。这个想法是创建这些组并将我们的对象放在其中,在 Unity 中,这些组被称为图层。我们可以创建图层并设置 GameObject 的图层属性(检查器的顶部部分)以将对象分配到该组或图层。请注意,您拥有有限数量的图层,因此请明智地使用它们。

创建图层并分配对象后,我们可以转到物理设置并指定哪些图层将与其他图层发生碰撞。我们可以通过以下方式实现这一点:

  1. 转到编辑 | 项目设置,在其中,从左侧窗格中查找标签和图层选项:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.9 - 标签和图层设置

  1. PlayerEnemyPlayerBulletPlayerEnemy外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.10 - 创建图层

  1. 选择Player,并从检查器的顶部部分将图层属性更改为Player。还要将Enemy预制件更改为Enemy图层。将显示一个窗口询问您是否要更改子对象;选择该选项:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.11 - 更改玩家和敌人预制件的图层

对于子弹,我们有一个问题;我们有一个预制件,但有两个图层,而预制件只能有一个图层。我们有两个选择,即根据射手通过脚本更改图层或具有两个具有不同图层的子弹预制件。为简单起见,我将选择后者,同时也有机会将另一个材料应用于敌人子弹,使其看起来不同。

我们将创建玩家子弹的预制件变体。请记住,变体是基于原始预制件的预制件,就像类继承一样。当原始预制件更改时,变体将更改,但变体可以有差异,这将使其成为独特的:

  1. 将子弹放入场景中创建一个实例。

  2. 再次将实例拖放到预制件文件夹中,这次选择预制件变体选项。将其命名为敌人子弹。记得销毁场景中的预制件实例。

  3. 创建第二种类似于玩家子弹的材质,但是黄色或您喜欢的任何颜色,并将其放在敌人子弹预制件变体上。

  4. 选择敌人子弹的变体,设置其图层(EnemyBullet),并对原始预制件(PlayerBullet)执行相同操作。即使您更改了原始预制件的图层,由于变体修改了它,修改后的版本(或覆盖)将占上风,从而使每个预制件都有自己的图层。

  5. 转到编辑 | 项目设置,查找物理设置(不是物理 2D)。

  6. 向下滚动,直到看到图层碰撞矩阵,一个半复选框网格。您会注意到每一列和行都标有图层的名称,因此在行和列的交叉处的每个复选框都允许我们指定这两个是否应该发生碰撞。在我们的情况下,我们将其配置如下截图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.12 - 使玩家子弹与敌人发生碰撞,敌人子弹与玩家发生碰撞

值得注意的是,有时过滤逻辑可能不那么固定或可预测,例如,我们的子弹可能只会击中具有一定生命值的对象,或者不具有临时隐身增益的对象,或者在游戏过程中可能会发生变化且难以为所有可能的组生成所有可能的层。因此,在这些情况下,我们应该依靠触发或碰撞事件后的手动过滤。

现在我们已经过滤了碰撞,让我们通过在下一节对碰撞做出反应来检查我们的设置是否正常工作。

检测碰撞

正如您所看到的,正确的物理设置可能会很复杂且非常重要,但现在我们已经解决了这个问题,让我们通过以不同的方式对接触做出反应并在此过程中创建一个健康系统来利用这些设置。

在本节中,我们将研究以下碰撞概念:

  • 检测触发事件

  • 修改其他对象

首先,我们将探索 Unity 提供的不同碰撞和触发事件,以对两个对象之间的接触做出反应。这使我们能够执行任何我们想要放置的反应代码,但我们将探索如何使用GetComponent函数修改接触对象的组件。

检测触发事件

如果对象被正确配置,就像之前讨论的那样,我们可以得到两种反应,触发和碰撞。碰撞反应有一个默认效果,即阻止对象的移动,但我们可以使用脚本添加自定义行为,但是触发器,除非我们添加自定义行为,否则不会产生任何明显的效果。无论哪种方式,我们都可以对两种可能的情况进行脚本反应,比如添加得分、减少生命和输掉游戏。为此,我们可以使用物理事件套件。

这些事件分为两组,碰撞事件和触发事件,因此根据您的对象设置,您将需要选择适当的组。两个组都有三个主要事件,进入停留退出,告诉我们碰撞或触发何时开始(进入),它们是否仍在发生或仍在接触(停留),以及何时停止接触(退出)。例如,我们可以在进入事件中编写一个行为,比如在两个对象开始接触时播放声音,比如摩擦声音,并在退出事件中停止它。

通过创建我们的第一个接触行为来测试这一点,也就是说,当子弹接触到某物时被销毁。请记住,子弹被配置为触发器,因此它们在接触任何物体时都会生成触发事件。您可以按照以下步骤进行操作:

  1. 在子弹玩家预制件上创建并添加一个名为ContactDestroyer的脚本;因为子弹敌人预制件是它的变体,它也会有相同的脚本。

  2. 要检测触发发生的时候,就像使用 Start 和 Update 一样,创建一个名为OnTriggerEnter的事件函数。

  3. 在事件中,使用Destroy(gameObject);行使子弹在接触到物体时自我销毁:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.13 - 与某物接触时自动销毁

  1. 保存并射击子弹,看看它们在接触墙壁时如何消失而不是穿过它。同样,在这里,我们没有碰撞,而是触发了接触时销毁子弹。因此,通过这种方式,我们可以确保子弹永远不会穿过任何东西,但我们仍然没有使用物理运动。

目前,我们不需要其他碰撞事件,但如果您需要它们,它们将类似工作;只需使用OnCollisionEnter即可。现在,让我们探索相同函数的另一个版本。它不仅告诉我们我们击中了什么,还告诉我们我们接触了什么。我们将使用这个来使我们的接触销毁器也销毁其他对象。要做到这一点,请按照以下步骤进行:

  1. 用以下截图中的方法签名替换OnTriggerEnter方法签名。这个方法接收Collider类型的参数,指示精确撞击我们的碰撞体:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.14 - 告诉我们与之发生碰撞的对象的触发事件的版本

  1. 我们可以使用gameObject setter 来访问碰撞体的整个对象,因此我们也可以使用它来摧毁另一个对象,如下截图所示。如果我们只是通过传递other引用来使用Destroy,那么它只会摧毁Collider组件:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.15 - 摧毁两个对象

  1. 保存并测试脚本。您会注意到子弹会摧毁它接触到的一切。

当然,我们不希望子弹在接触时摧毁一切,只摧毁自身和其他对象,如果符合某些条件,比如在对立队伍或其他情况下,根据我们的游戏。在我们的情况下,我们将向前迈进一步,而不是直接在接触时摧毁对象,而是使敌人和玩家具有生命值,因此子弹将减少生命值直到达到 0。

修改其他对象

到目前为止,我们使用transform字段来访问对象的特定组件,但是当我们需要访问其他组件时会发生什么?在我们的场景中,为了使子弹损坏碰撞的对象,它将需要访问其Life组件以改变生命值。请记住,Unity 并没有游戏的所有可能行为。因此,在我们的情况下,Life组件就是我们要创建的组件,只是用来保存一个带有生命值的浮点字段。拥有此组件的每个对象都将被视为可损坏对象。这就是GetComponent函数将帮助我们的地方。

如果您有一个对 GameObject 或 Component 的引用,您可以使用GetComponent来访问目标组件的引用,如果对象包含它(如果没有,它将返回 null)。让我们看看如何使用该函数来使子弹降低其他对象的生命值,如果它受到损坏,按照以下步骤进行:

  1. 在玩家和敌人上创建并添加一个Life组件,其中包含一个名为amountpublic float字段。记得在检查器中为两个对象的 amount 字段设置值:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.16 - 生命组件

  1. 从玩家子弹中删除ContactDestroyer组件,这也会将其从敌人子弹变体中删除,并添加一个名为ContactDamager的新组件;您可能稍后需要ContactDestroyer行为。因此,我们正在创建另一个组件。

  2. 添加一个OnTriggerEnter事件,接收其他碰撞体,并只添加Destroy函数调用,自动摧毁自身,而不是摧毁其他对象的那个;我们的脚本不会负责摧毁它,只是减少它的生命值。

  3. 添加一个名为 damage 的浮点字段,这样我们就可以配置对其他对象造成的伤害量。在继续之前,请记得保存文件并设置一个值。

  4. 在对其他碰撞体的引用上使用GetComponent来获取其life组件的引用并将其保存在一个变量中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.17 - 访问碰撞对象的生命组件

  1. 在减少对象的生命之前,我们必须检查生命引用是否不为空,如果其他对象没有Life组件,就会发生这种情况,比如墙壁和障碍物。子弹将在任何碰撞时摧毁自身,并减少其他对象的生命,如果它是包含Life组件的可损坏对象。

在下面的截图中,您将找到完整的脚本完成:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.18 - 减少碰撞对象的生命值

  1. 在场景中放置一个基于预制件的敌人,并将实例速度(场景中的速度)设置为0,以防止其移动。

  2. 在点击播放之前选择它并开始向其射击。

您可以在检查器中看到生命值的减少。您还可以在播放模式下按Esc键重新获得鼠标控制权,并在编辑器中查看运行时生命字段的变化。

现在,您会注意到生命值正在减少,但它将变为负数;我们希望对象在生命值低于 0 时自行销毁。我们可以通过两种方式实现这一点,一种是向Life组件添加Update,它将检查所有帧是否生命值低于 0,并在发生时销毁自身。第二种方法是通过封装life字段,并在 setter 内部进行检查,以防止检查所有帧。我更喜欢第二种方式,但我们将实现第一种方式,以使我们的脚本对初学者尽可能简单。要做到这一点,请按照以下步骤操作:

  1. Life组件添加Update

  2. 将“如果”添加到检查amount字段是否低于0

  3. if条件为真的情况下添加Destroy

  4. 完整的Life脚本将如下截图所示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.19 - 生命组件

  1. 保存并查看对象在其生命值变为 0 时被销毁。

可选地,您可以在发生这种情况时实例化一个对象,比如声音、粒子或者一个道具。我将把这留给您作为一个挑战。

通过使用类似的脚本,您可以制作增加生命值的生命力道具,或者访问PlayerMovement脚本并增加速度字段的速度道具;从现在开始,尽情发挥想象力,创造出令人兴奋的行为。

现在我们已经探讨了如何检测碰撞并对其做出反应,让我们探索一下当玩家撞到墙壁时如何修复玩家下落的问题。

使用物理移动

到目前为止,唯一使用动态碰撞器配置文件移动的对象是玩家,也是将使用物理移动的对象,实际上是通过使用 Transform API 进行自定义脚本编写移动。每个动态对象都应该使用 Rigidbody API 函数移动,以便物理系统更好地理解,因此在这里我们将探讨如何移动对象,这次是通过 Rigidbody 组件。

在本节中,我们将研究以下物理运动概念:

  • 应用力量

  • 调整物理

我们将开始看到如何以正确的物理方式移动对象,通过力量,并将这个概念应用到我们玩家的移动上。然后,我们将探讨为什么真实的物理并不总是有趣,以及如何调整我们对象的物理属性以获得更具响应性和吸引力的行为。

应用力量

通过力量的物理准确移动对象的方式是通过影响对象的速度。要应用力量,我们需要访问Rigidbody而不是Transform,并使用AddForceAddTorque函数分别移动和旋转。这些是函数,您可以在其中指定要应用到位置和旋转的每个轴上的力量量。这种移动技术将产生完整的物理反应;力量将累积到速度上开始移动,并且将遭受减速效果,使速度缓慢减小,这里最重要的一点是它将与墙壁发生碰撞,阻挡对象的路径。

要获得这种移动方式,我们可以这样做:

  1. PlayerMovement脚本中创建一个Rigidbody字段,但这次将其设置为private,意思是在字段中不写public关键字,这将使其在编辑器中消失;我们将以另一种方式获取引用。

某些编码标准规定您需要明确用private关键字替换public关键字,但在 C#中,使用private和不使用它具有相同的效果,所以这取决于您的偏好:

![图 15.20–私有刚体引用字段

![图 15.20_B14199.jpg]

图 15.20–私有刚体引用字段

  1. Start事件函数中使用GetComponent,获取我们的Rigidbody并将其保存在字段中。我们将使用此字段来缓存GetComponent函数的结果;每帧调用该函数以访问刚体的性能不佳。此外,您还可以注意到GetComponent函数不仅可用于从其他对象(如碰撞示例)检索组件,还可以用于检索自己的组件:![图 15.21–缓存刚体引用以供将来使用

![图 15.21_B14199.jpg]

图 15.21–缓存刚体引用以供将来使用

  1. rb.AddRelativeForce替换transform.Translate调用。这将调用刚体的添加力函数,具体来说是相对的力函数,它将考虑对象的当前旋转。例如,如果您在 z 轴(第三个参数)上指定一个力,对象将沿着它的前向矢量施加力。

  2. rb.AddRelativeTorque替换transform.Rotate调用,这将应用旋转力:

![图 15.22–使用刚体力 API

![图 15.22_B14199.jpg]

图 15.22–使用刚体力 API

重要提示

如果您熟悉 Unity,您可能会认为我需要在 Fixed Update 中执行此操作,虽然这是正确的,但在 Update 中执行此操作不会产生任何显着效果。我更喜欢在初学者脚本中使用Update来防止在FixedUpdate中使用GetKeyDownGetKeyUp时可能发生的问题。

现在,如果您保存并测试结果,您可能会发现玩家正在下落,这是因为现在我们正在使用真正的物理,其中包含地板摩擦力,并且由于力被施加在重心上,它将使对象下落。请记住,在物理学上,您是一个胶囊;您没有腿来移动,这就是标准物理学不适合我们的游戏的地方。解决方案是调整物理以模拟我们需要的行为。

调整物理

为了使我们的玩家像常规平台游戏中一样移动,我们需要冻结某些轴以防止对象下落。去除地面摩擦力,并增加空气摩擦力(阻力),以使玩家在释放按键时自动减速。要做到这一点,请按照以下步骤进行:

  1. Rigidbody组件中,查看底部的Constraints部分,并检查Freeze Rotation属性的XZ轴:![图 15.23–冻结旋转轴

![图 15.23_B14199.jpg]

图 15.23–冻结旋转轴

这将防止对象侧倾,但允许对象水平旋转。如果您不希望玩家跳跃,可以冻结Freeze Position属性的 y 轴,以防止在碰撞时发生一些不希望的垂直移动。

  1. 您可能需要更改速度值,因为您从每秒米的值更改为每秒牛顿的值,旋转速度中的45预期值对我来说已经足够了。

  2. 现在,你可能会注意到速度和旋转会随着时间的推移而大幅增加。记住,你正在使用力量,这会影响你的速度。当你停止施加力时,速度会保持不变,这就是为什么即使你不移动鼠标,玩家仍然会保持旋转。解决这个问题的方法是增加阻力角阻力值,这模拟了空气摩擦,当不施加力时,将分别减少移动和旋转。尝试适合你的值;在我的情况下,我使用了2作为阻力10作为角阻力,需要将旋转速度增加到150来补偿阻力的增加:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.24 – 设置旋转和移动的空气摩擦

  1. 现在,如果你在触摸墙壁时移动,你的玩家不会像大多数游戏那样滑动,而是会因为接触摩擦而粘在障碍物上。我们可以通过创建一个物理材质来消除这种情况,这是一个可以分配给碰撞体以控制它们在这些情况下如何反应的资源。通过点击物理材质(不是 2D 版本)来开始创建一个。将其命名为玩家,并记得将其放在专门的资源文件夹中。

  2. 选择它并设置为0最小,这将使物理系统选择两个碰撞物体的最小摩擦,始终是最小的—在我们的情况下是零:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.25 – 创建物理材质

  1. 选择玩家并将此资源拖到胶囊碰撞体材质属性中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.26 – 设置玩家的物理材质

  1. 如果现在玩游戏,你可能会注意到玩家移动得比以前快,因为现在地板上没有任何摩擦,所以你可能需要减少移动力。

  2. 你可能会发现一个小错误,就是相机后期处理对玩家应用的运动模糊效果有些小问题,有些帧是物体在移动,有些帧是不移动的。问题在于由于性能和确定性,物理不是在每一帧都执行的(默认情况下是每帧 50 次),但渲染是执行的,这影响了后期处理。你可以将刚体的插值属性设置为插值值,使刚体以自己的速率计算物理,但每帧插值位置以模拟流畅度:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.27 – 使刚体插值其位置

正如你所看到的,我们需要弯曲物理规则来允许玩家的灵活移动。通过增加阻力和力量,可以获得更高的灵敏度,使速度更快地应用和减少,但这取决于你希望游戏拥有的体验。有些游戏希望立即响应,没有速度插值,从 0 到全速度,反之亦然,而在这种情况下,你可以直接覆盖玩家的速度和旋转向量,甚至在物理系统之外使用其他系统,比如角色控制器组件,它对平台角色有特殊的物理效果;但现在让我们保持简单。

总结

每个游戏都以某种方式具有物理特性,用于移动、碰撞检测或两者兼而有之。在本章中,我们学习了如何使用物理系统来处理这些情况,了解适当的设置以使系统正常工作,对碰撞做出反应以生成游戏系统,并以使玩家与障碍物发生碰撞,保持其物理上不准确的移动方式。我们利用这些概念来创建我们的玩家和子弹移动,并使我们的子弹对敌人造成伤害,但我们可以重复利用这些知识来满足各种其他可能的游戏需求,因此我建议你在这里玩一下物理概念;你可以发现许多有趣的用例。

在下一章中,我们将讨论如何编程游戏的视觉方面,例如效果,并使用户界面对输入做出反应。

第十六章:赢和输的条件

既然我们已经有了基本的游戏体验,现在是时候让游戏在某个时候结束,无论是赢还是输。一种常见的实现方法是通过分离的组件来监视一组对象,以检测需要发生的特定情况,比如玩家生命值变为 0 或者所有波次都被清除。我们将通过管理者的概念来实现这一点,管理者组件将管理多个对象并监控它们。

在本章中,我们将研究以下管理器概念:

  • 创建对象管理器

  • 创建游戏模式

  • 通过事件改进我们的代码

有了这些知识,你不仅能够创建游戏的胜利和失败条件,还能以正确的结构方式使用设计模式,比如单例和事件监听器。这些技能不仅对创建游戏的胜利和失败功能的代码有用,对任何代码都有用。

创建对象管理器

场景中并非每个对象都是可以看到、听到或碰撞的。有些对象也可以存在于概念上,而不是实体的东西。想象一下,你需要记录敌人的数量,你会把它保存在哪里?你还需要一个地方来保存玩家的当前分数,你可能会认为它可以保存在玩家身上,但如果玩家死亡并重生会发生什么?数据会丢失!在这种情况下,管理者的概念可以是解决我们的第一个游戏中的有用方式,所以让我们来探索一下。

在本章中,我们将看到以下对象管理器的概念:

  • 实现单例设计模式

  • 使用单例创建管理器

我们将从讨论单例设计模式是什么以及它如何帮助我们简化对象之间的通信开始。通过它,我们将创建管理者对象,这将允许我们集中一组对象的信息,等等。让我们开始讨论单例设计模式。

实现单例设计模式

设计模式通常被描述为常见问题的常见解决方案。在编写游戏代码时,你将不得不做出许多编码设计决策,但幸运的是,解决最常见情况的方法是众所周知和有文档记录的。在本节中,我们将讨论最常见的设计模式之一,即单例模式,这是一个非常有争议但在简单项目中实现起来非常方便的设计模式。

当我们需要一个对象的单个实例时,就会使用单例模式,这意味着一个类不应该有多个实例,并且我们希望它易于访问(不一定,但在我们的场景中很有用)。在我们的游戏中有很多情况可以应用这个模式,例如ScoreManager,一个将保存当前分数的组件。在这种情况下,我们永远不会有多个分数,所以我们可以利用单例管理器的好处。

一个好处是确保我们不会有重复的分数,这使我们的代码更不容易出错。此外,到目前为止,我们需要创建公共引用并通过编辑器拖动对象来连接两个对象或使用GetComponent来查找它们,但是通过这种模式,我们将全局访问我们的单例组件,这意味着你只需写组件的名称,就可以访问它。最后,只有一个ScoreManager组件,因此通过编辑器指定它是多余的。这类似于Time.deltaTime,负责管理时间的类——我们只有一个时间。

重要提示

如果你是一个高级程序员,现在可能会考虑代码测试和依赖注入,你是对的,但请记住,我们试图写简单的代码,所以我们将坚持这个简单的解决方案。

让我们创建一个 Score Manager 对象,负责处理分数,以示例展示单例模式,具体操作如下:

  1. 创建一个空的游戏对象(ScoreManager;通常,管理器会放在空对象中,与场景中的其他对象分开。

  2. 在这个对象上添加一个名为ScoreManager的脚本,其中包含一个名为amountint字段,用于保存当前分数。

  3. 添加一个名为instanceScoreManager类型字段,但在其前面加上static关键字;这将使变量成为全局变量,意味着可以通过简单地写出其名称在任何地方访问它:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.1 – 可以在代码中任何地方访问的静态字段

  1. Awake中,检查instance字段是否不为空,如果是,则使用this引用将自己设置为实例引用。

  2. 在空检查if语句的else子句中,打印一条消息,指示存在第二个ScoreManager实例必须被销毁:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.2 – 检查是否只有一个单例实例

这个想法是将唯一的ScoreManager实例的引用保存在静态字段instance中,但如果用户错误地创建了两个带有ScoreManager组件的对象,这个if语句将会检测到并通知用户错误,并要求他们采取行动。在这种情况下,第一个ScoreManager实例执行Awake时会发现没有设置实例(字段为空),所以它会将自己设置为当前实例,而第二个ScoreManager实例会发现实例已经设置,并会打印消息。请记住,instance是一个静态字段,是所有类之间共享的字段,不同于常规引用字段,其中每个组件都有自己的引用,所以在这种情况下,我们在场景中添加了两个ScoreManagers,它们都将共享相同的实例字段。

为了稍微改进示例,最好有一种简单的方法来找到游戏中的第二个ScoreManager。它将被隐藏在层次结构的某个地方,很难找到。我们可以用Debug.Log替换print,基本上是一样的,但允许我们向函数传递第二个参数,即一个对象,在控制台中点击消息时可以突出显示。在这种情况下,我们将传递gameObject引用,以允许控制台突出显示重复的对象:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.3 – 使用 Debug.Log 在控制台中打印消息

  1. 点击日志消息后,此游戏对象将在层次结构中突出显示:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.4 – 点击消息后突出显示的对象

  1. 最后,可以通过将Debug.Log替换为Debug.LogError来进行一些改进,这样也会打印消息,但会带有错误图标。在真实的游戏中,控制台中会有大量的消息,将错误消息突出显示在信息消息之上将有助于我们快速识别它们:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.5 – 使用 LogError 打印错误消息

  1. 尝试运行代码并观察控制台中的错误消息:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.6 – 控制台中的错误消息

下一步将是在某个地方使用这个单例模式,所以在这种情况下,我们将使敌人在被杀死时给予分数,具体操作如下:

  1. Enemy预制体添加一个名为ScoreOnDeath的脚本,其中包含一个名为amountint字段,它将指示敌人被杀时将给出的积分数。记得在预制体的编辑器中将值设置为非 0 的值。

  2. 创建OnDestroy事件函数,当这个对象被销毁时,Unity 将自动调用它;在我们的情况下,是敌人:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.7 – OnDestroy 事件函数

重要提示

考虑到OnDestroy函数在我们切换场景或游戏退出时也会被调用,所以在这种情况下,也许我们会在切换场景时得到积分,这是不正确的。到目前为止,在我们的情况下这不是问题,但是在本章的后面,我们将看到一种防止这种情况发生的方法。

  1. 通过编写ScoreManager.instanceOnDestroy函数中访问单例引用,并将我们脚本的amount字段添加到单例的amount字段中,以增加在杀死敌人时的得分:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.8 – 完整的 ScoreOnDeath 组件类内容

  1. 在层次结构中选择ScoreManager,点击播放,杀死一些敌人,看到得分随着每次杀敌而增加。记得设置预制体的ScoreOnDeath组件的amount字段。

正如你所看到的,单例简化了访问ScoreManager的方式,并防止我们拥有两个相同对象的版本,这将有助于减少我们代码中的错误。需要注意的是,现在你可能会诱惑只是把所有东西都变成单例,比如玩家生命或玩家子弹,并且只是为了让你的生活更容易地创建游戏玩法,比如道具,虽然这样完全可行,但要记住你的游戏会改变,我的意思是,会有很多变化;任何真正的项目都会经历这些。也许今天,游戏只有一个玩家,但也许在未来,你会想要添加第二个玩家或一个 AI 伙伴,并且你希望道具也能影响到他们,所以如果你滥用单例模式,你将很难处理这些情况。也许伙伴会试图拿到道具,但主要玩家会被治愈!

关键是尽量少地使用这种模式,只有在没有其他解决问题的办法时才使用。老实说,总是有办法可以解决问题而不使用单例,但对于初学者来说,这些方法实现起来可能会更加困难,所以我更倾向于简化一下你的生活,让你保持动力。通过足够的练习,你将达到一个可以提高编码标准的水平。

现在我们知道如何创建单例了,让我们完成一些游戏后期需要的其他管理器。

使用单例创建管理器

有时,我们需要一个地方来整合一组类似的对象的信息,例如,一个敌人管理器,用来检查敌人的数量并可能访问它们的数组来迭代它们并执行某些操作,或者MissionManager,用来访问游戏中所有的活动任务。同样,这些情况可以被视为单例,即不会重复出现的单个对象(在我们当前的游戏设计中),所以让我们创建我们游戏中需要的那些,即EnemyManagerWaveManager

在我们的游戏中,EnemyManagerWaveManager只是保存游戏中现有敌人和波的引用数组的地方,只是一种了解它们当前数量的方式。有一些方法可以搜索特定类型的所有对象来计算它们的数量,但这些函数很昂贵,不建议使用,除非你真的知道自己在做什么。因此,具有一个单独更新的引用列表的单例,将需要更多的代码,但性能会更好。此外,随着游戏功能的增加,这些管理器将具有更多的功能和辅助函数来与这些对象交互。

让我们从敌人管理器开始,做以下操作:

  1. 将名为Enemy的脚本添加到敌人预制件中;这将是将此对象与EnemyManager连接的脚本。

  2. 创建一个名为EnemyManager的空GameObject,并向其添加名为EnemiesManager的脚本。

  3. 在脚本内创建一个名为instanceEnemiesManager类型的公共静态字段,并在Awake中添加与ScoreManager中相同的单例重复检查。

  4. 创建一个名为enemiesList<Enemy>类型的公共字段:!图 16.9-敌人组件列表

图 16.9-敌人组件列表

C#中的列表表示动态数组,可以添加和删除对象的数组。您会发现您可以在编辑器中向此列表添加和删除元素,但保持列表为空;我们将以另一种方式添加敌人。请注意,ListSystem.Collections.Generic命名空间中;您将在我们的脚本开头找到using语句。此外,请考虑您可以将列表设置为私有,并通过 getter 将其暴露给代码,而不是将其设置为公共字段;但通常情况下,我们将尽可能简化我们的代码。

重要提示

请记住,List是一个类类型,因此必须实例化,但由于此类型在编辑器中具有暴露支持,Unity 将自动实例化它。在您想要一个非编辑器暴露的列表,例如私有列表或常规非组件 C#类中的列表的情况下,您必须使用 new 关键字进行实例化。

C#列表在内部实际上是作为数组实现的。如果需要链表,请查看LinkedList集合类型。

  1. Enemy脚本的Start函数中,访问EnemyManager单例,并使用敌人列表的Add函数,将此对象添加到列表中。这将在管理器中“注册”此敌人为活动状态,以便其他对象可以访问管理器并检查当前的敌人。 Start函数在所有Awake函数调用之后调用,这很重要,因为我们需要确保在敌人的Start函数之前执行管理器的Awake函数,以确保有一个管理器设置为实例。

重要提示

我们通过Start函数解决的问题称为竞争条件,即两段代码不能保证以相同的顺序执行,而Awake执行顺序可能会因不同原因而改变。代码中有很多情况会发生这种情况,因此请注意代码中可能出现的竞争条件。此外,您可能考虑在这里使用更高级的解决方案,例如延迟初始化,这可以为您提供更好的稳定性,但出于简单起见并探索 Unity API,我们现在将使用Start函数方法。

  1. OnDestroy函数中,从列表中移除敌人,以保持列表中只有活动的敌人:

!图 16.10-注册自己为活动敌人的敌人脚本

图 16.10-注册自己为活动敌人的敌人脚本

有了这个,现在我们有了一个集中的地方以简单而有效的方式访问所有活动的敌人。我向你挑战,用WaveManager做同样的事情,它将拥有所有活动波的集合,以后检查所有波是否完成工作以考虑游戏是否获胜。花点时间解决这个问题;你将在以下截图中找到解决方案,从WavesManager开始:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.11 - 完整的 WavesManager 脚本

你还需要WavesSpawner脚本:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.12 - 修改后的 WaveSpawner 脚本以支持 WavesManager

正如你所看到的,WaveManager的创建方式与EnemyManager相同,只是一个具有WaveSpawner引用列表的单例,但WaveSpawner是不同的。我们在WaveSpawnerStart事件中执行列表的Add函数,将波注册为活动波,但Remove函数需要更多的工作。

这个想法是在生成器完成其工作后,当波完成生成所有敌人时,从活动波列表中注销该波。在进行这种修改之前,我们使用Invoke在一段时间后调用CancelInvoke函数来停止生成,但现在在结束时间之后我们需要做更多的事情。我们将在指定的波结束时间后调用CancelInvoke,而是调用一个名为EndSpawner的自定义函数,它将调用CancelInvoke来停止生成器,Invoke Repeating,但也将调用WavesManager列表中的Remove函数,以确保在WaveSpawner完成其工作时确切地调用从列表中移除。

使用对象管理器,我们现在集中了关于一组对象的信息,并且可以在这里添加各种对象组逻辑,但除了拥有这些信息以更新 UI(我们将在下一章中进行),我们还可以使用这些信息来检测我们游戏的胜利和失败条件是否满足,创建一个游戏模式对象来检测这一点。

创建游戏模式

我们已经创建了对象来模拟游戏中许多方面的玩法,但游戏总归需要在某个时候结束,无论是赢还是输。就像往常一样,问题是在哪里放置这个逻辑,这导致了更多的问题。主要问题是,我们是否总是以相同的方式赢得或输掉游戏?我们是否会有一个特殊的级别,其标准不同于杀死所有波,比如定时生存?只有你知道这些问题的答案,但如果现在的答案是否定的,这并不意味着以后不会改变,因此最好是准备我们的代码以无缝适应变化。

重要提示

老实说,让我们的代码无缝适应变化几乎是不可能的;没有办法编写完美的代码来考虑每种可能的情况,我们总是需要迟早重写一些代码。我们将尽量使代码尽可能适应变化;总是这样做并不会消耗大量的开发时间,有时快速编写简单的代码比缓慢编写复杂的代码更可取,而且可能并不必要,因此明智地平衡你的时间预算。

为此,我们将胜利和失败条件的逻辑分离到自己的对象中,我喜欢称之为“游戏模式”(不一定是行业标准)。这将是一个组件,将监督游戏,检查需要满足的条件以考虑游戏结束。它将像我们游戏的裁判一样。游戏模式将不断检查对象管理器中的信息,也许还有其他信息来源,以检测所需的条件。将这个对象与其他对象分离允许我们创建具有不同游戏模式的不同级别;只需在该级别中使用另一个游戏模式脚本,就可以了。

在我们的情况下,目前我们将只有一个游戏模式,它将检查波次和敌人的数量是否变为 0,这意味着我们已经杀死了所有可能的敌人并且游戏获胜。此外,它还将检查玩家的生命值是否达到 0,在这种情况下认为游戏失败。让我们通过以下方式创建它:

  1. 创建一个GameMode空对象,并向其添加一个WavesGameMode脚本。正如您所看到的,我们使用了一个描述性的名称来命名脚本,考虑到我们可以添加其他游戏模式。

  2. 在其Update函数中,使用敌人和波次管理器检查敌人和波次的数量是否达到了0;在这种情况下,目前只需在控制台中print一条消息。所有列表都有一个Count属性,它将告诉您存储在其中的元素数量。

  3. 添加一个名为PlayerLifeLife类型的public字段,并将玩家拖放到其中;这样也可以检测失败条件。

  4. Update中,添加另一个检查,以检测PlayerLife引用的生命值是否达到了0,如果是,就在控制台中print一个失败消息:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.13 - 在 WavesGameMode 中检查胜利和失败条件

  1. 玩游戏并测试两种情况,即玩家生命值是否达到 0 或者您是否已经杀死了所有敌人和波次。

重要提示

请记住,我们不希望有两个此对象的实例,因此我们也可以将其设置为单例,但由于其他对象不会访问此对象,这可能是多余的;我会把这个决定留给您。无论如何,请记住,这不会阻止您实例化两个不同的GameModes;为此,您可以创建一个GameMode基类,其中包含单例功能,以防止在同一场景中出现两个GameModes

现在,是时候用更有趣的东西替换这些消息了。目前,我们只会将当前场景更改为一个胜利场景和失败场景,它们只会有一个带有胜利和失败消息以及一个再玩一次按钮的 UI。将来,您可以添加一个主菜单场景,并提供返回选项。让我们通过以下方式做到这一点:

  1. 创建一个新场景(WinScreen)。

  2. 添加一个 UI 文本,并将其与文本居中,写上“你赢了!”。

  3. 在文本下方添加一个 UI 按钮,并将其文本更改为“再玩一次”:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.14 - WinScreen

  1. 在项目视图中选择场景,按Ctrl + D(Mac 上为Cmd + D)来复制场景。将其重命名为LoseScreen

  2. 双击LoseScreen场景以打开它,然后将“你赢了!”文本更改为“你输了!”文本。

  3. 进入WinScreenLoseScreen,以及我们迄今为止创建的游戏场景,我称之为Game,所以只需将这些场景从项目视图拖动到构建设置窗口的列表中;我们需要这样做来确保游戏模式脚本能够正确地改变场景。另外,请注意,这个列表中的第一个场景将是在最终版本(即构建版本)中打开的第一个场景,因此您可能希望根据这一点重新排列列表:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.15 - 注册要包含在游戏构建中的场景

  1. WavesGameMode中,添加一个using语句,用于启用此脚本中的场景更改功能的UnityEngine.SceneManagement命名空间。

  2. SceneManager.LoadScene函数替换控制台print消息,该函数将接收一个字符串,其中包含要加载的场景的名称;在这种情况下,它将是WinScreenLoseScreen。您只需要场景名称,而不是整个文件路径。

如果你想链接不同的关卡,可以创建一个public字符串字段,允许你通过编辑器指定要加载哪些场景。记得将场景添加到构建设置中,否则当你尝试更改场景时,控制台会收到错误消息:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.16 – 使用SceneManager更改场景

  1. 玩游戏,检查场景是否正确更改。

重要提示

现在,我们选择了最简单的方式来显示我们是输了还是赢了,但也许在未来,你会希望有比突然改变场景更温和的方式,比如可能使用Invoke等待一段时间来延迟改变,或者直接在游戏中显示获胜消息而不改变场景。在测试游戏时,考虑到玩家在玩游戏时是否理解发生了什么,游戏反馈对于让玩家知晓正在发生的事情是很重要的,这并不是一件容易的事情。

现在我们有一个完全功能的简单游戏,具有机制和胜利和失败条件,虽然这已经足够开始开发游戏的其他方面,但我想讨论一下我们当前的管理器方法存在的一些问题,以及如何通过事件解决这些问题。

通过事件改进我们的代码

到目前为止,我们使用了 Unity 事件函数来检测游戏中可能发生的情况,比如AwakeUpdate。这些函数是 Unity 用来让两个组件进行通信的方式,比如OnTriggerEnter,这是刚体通知游戏对象中的其他组件发生了碰撞的一种方式。在我们的情况下,我们在 Update 中使用if来检测其他组件的变化,比如GameMode检查敌人数量是否达到 0。但是,如果敌人管理器在发生变化时通知我们,我们可以在那一刻进行检查,就像刚体告诉我们碰撞发生的情况一样,而不是每帧都检查碰撞。

有时,我们依赖 Unity 事件来执行逻辑,比如在OnDestroy事件中给予分数,该事件通知我们对象被销毁时,但由于事件的性质,它可能在我们不希望加分的情况下被调用,比如场景改变或游戏关闭时。在这些情况下对象被销毁,但不是因为玩家杀死了敌人,导致分数被提高,这时候就需要一个事件告诉我们玩家的生命值已经达到 0,以执行这个逻辑,而不是依赖通用的销毁事件。

事件的理念是改进我们对象之间的通信模型,确保在某个情况发生时,对该情况感兴趣的部分被通知以做出相应反应。Unity 有很多事件,但我们可以创建特定于我们游戏逻辑的事件。让我们从之前讨论的分数场景中开始看到这个应用;想法是让Life组件有一个事件来通知其他组件,对象被销毁是因为它的生命值达到了 0。

有几种方法可以实现这一点,我们将使用与AwakeUpdate方法略有不同的方法;我们将使用UnityEvent字段类型。这是一种能够保存引用函数的字段类型,当我们想要执行时,就像 C#委托一样,但具有其他好处,比如更好的 Unity 编辑器集成。要实现这一点,按照以下步骤进行:

  1. Life组件中,创建一个名为onDeathUnityEvent类型的public字段。这个字段将代表一个事件,其他类可以订阅它以便在Life达到 0 时知晓:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.17 – 创建自定义事件字段

  1. 如果你保存脚本并进入编辑器,你可以在检视器中看到事件。Unity 事件支持在编辑器中订阅方法,这样我们可以连接两个对象。我们将在 UI 脚本章节中使用这个功能,所以现在就忽略它吧:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.18 – UnityEvents 显示在检视器中

重要提示

你可以使用通用委托动作或自定义委托来创建事件,而不是使用UnityEvent,除了某些性能方面的差异之外,唯一显著的区别是UnityEvent会显示在编辑器中,就像步骤 2中演示的那样。

  1. 当生命值达到0时,调用事件的Invoke函数,这样我们就告诉任何对该事件感兴趣的人,事件已经发生:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.19 – 执行事件

  1. ScoreOnDeath中,将OnDestroy函数重命名为GivePoints或者你喜欢的其他名称;这里的想法是停止在OnDestroy事件中给分。

  2. ScoreOnDeath脚本的Awake函数中,使用GetComponent获取Life组件并将其保存在一个局部变量中。

  3. 调用Life引用的onDeath字段的AddListener函数,并将GivePoints函数作为第一个参数传递。这样做的想法是告诉LifeonDeath事件被调用时执行GivePoints。这样,Life会通知我们发生了什么情况。记住,你不需要调用GivePoints,只需要将函数作为字段传递即可:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.20 – 订阅 OnDeath 事件以在该场景中给分

重要提示

考虑在OnDestroy中调用RemoveListener;通常情况下,尽可能取消订阅监听器是方便的,以防止内存泄漏(引用阻止 GC 释放内存)。在这种情况下,这并不是完全必要的,因为LifeScoreOnDeath组件将同时被销毁,但尽量养成这种良好的习惯。

  1. 保存,在编辑器中选择ScoreManager,然后点击播放进行测试。尝试在播放模式下从层次结构中删除一个敌人,以检查分数不会上升,因为敌人被摧毁不是因为生命值变为 0;你必须通过射击摧毁敌人才能看到分数上升。

现在Life有了onDeath事件,我们也可以将WavesGameMode中对玩家Life的检查替换为使用事件,方法如下:

  1. WavesGameMode脚本中创建一个OnLifeChanged函数,并将生命检查条件从Update移动到这个函数中。

  2. Awake中,订阅玩家Life组件引用的onDeath事件到这个新函数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.21 – 使用事件检查失败条件

如你所见,创建自定义事件可以让你检测到 Unity 中默认情况之外的更具体的情况,并且保持你的代码清晰,而不需要在Update函数中不断询问条件,这并不一定是坏事,但事件方法可以生成更清晰的代码。

记住,我们也可以通过玩家的基础生命值达到 0 来输掉游戏,我们将在本书的后面探讨玩家基础的概念,但现在,让我们创建一个立方体,代表敌人将攻击以减少基础生命值的对象,就像基础核心一样。考虑到这一点,我挑战你将这个额外的失败条件添加到我们的脚本中。完成后,你可以在以下截图中检查解决方案:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.22 – 完整的 WavesGameMode 失败条件

正如你所看到的,我们只是重复了生活事件的订阅:记得创建一个对象来代表玩家基地的伤害点,给它添加一个Life脚本,并将其作为WavesGameMode的玩家基地生命引用拖放进去。

现在,让我们通过将其应用于管理器来继续说明这个概念,以防止游戏模式每帧检查条件:

  1. EnemyManager中添加一个UnityEvent字段,称为onChanged。每当敌人被添加或从列表中移除时,将执行此事件。

  2. 创建两个函数,AddEnemyRemoveEnemy,都接收Enemy类型的参数。想法是,Enemy不直接向列表中添加和移除自己,而是应该使用这些函数。

  3. 在这两个函数中,调用onChanged事件通知其他人敌人列表已经更新。想法是任何想要向列表中添加或移除敌人的人都需要使用这些函数:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.23 – 当敌人被添加或移除时调用事件

重要提示

在这里,我们的问题是没有什么能阻止我们绕过这两个函数直接使用列表。你可以通过将列表设置为私有,并使用IReadOnlyList接口来公开它来解决这个问题。请记住,这种方式,列表不会出现在编辑器中以进行调试。

  1. 更改Enemy脚本以使用这些函数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.24 – 使敌人使用 Add 和 Remove 函数

  1. WaveManagerWaveSpawner执行相同的过程,创建一个onChanged事件,并创建AddWaveRemoveWave函数,并在WaveSpawner中调用它们,而不是直接访问列表。这样,我们可以确保在必要时调用事件,就像我们在EnemyManager中所做的那样。尝试自己解决这一步,然后在下面的屏幕截图中检查解决方案,从WavesManager开始:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.25 – Wave Manager On Changed 事件实现

此外,WavesSpawner 需要更改:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.26 – 实现 AddWave 和 RemoveWave 函数

  1. WavesGameMode中,将Update重命名为CheckWinCondition,并订阅此函数到EnemyManageronChanged事件和WavesManageronChanged事件。想法是只在必要时检查敌人和波数的变化。请记住在Start函数中订阅事件,因为单例在Awake中初始化:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.27 – 当敌人或波数发生变化时检查胜利条件

是的,这种方式,我们需要比以前写更多的代码,而且在功能上,我们并没有得到任何新的东西,但在更大的项目中,通过Update检查来管理条件会导致之前讨论过的不同种类的问题,比如竞争条件和性能问题。有一个可扩展的代码库有时需要更多的代码,这就是这种情况之一。

在我们完成之前,需要考虑的一点是,Unity 事件并不是在 Unity 中创建这种事件通信的唯一方式;你会发现一种类似的方法叫做Action,这是 Unity 事件的本地 C#版本,我建议你去寻找一下,如果你想看看所有的选择。

总结

在本章中,我们完成了游戏的重要部分,结局,无论是胜利还是失败。我们讨论了一种简单但强大的方法,通过使用通过单例创建的管理器来分离不同的责任层,以确保每种类型的管理器都不会有多个实例,并通过静态访问简化它们之间的连接(这是在发现代码测试的那一天要考虑的事情)。此外,我们讨论了事件的概念,以简化对象之间的通信,以防止问题并创建更有意义的对象之间的通信。

有了这些知识,你现在不仅能够检测游戏的胜利和失败条件,还能以更好的结构方式来做到这一点。这些模式可以用来改进我们的游戏代码,我建议你尝试将其应用到其他相关场景中。

在下一章中,我们将探讨如何创建视觉和音频反馈以响应我们的游戏玩法,结合本书第二部分中集成的脚本和资产。

第十七章:UI、声音和图形脚本

在游戏中,即使玩家通过摄像机看到游戏,也有一些重要信息是肉眼不可见的,比如剩余子弹的确切数量、他们的生命、敌人、是否有敌人在他们身后等等。我们已经讨论过如何通过 UI、声音和视觉效果(VFX)来解决这些问题,但随着我们在游戏中开始进行脚本编写,这些元素也需要适应游戏。本章的理念是通过脚本使我们的 UI、声音和 VFX 对游戏情况做出反应,反映世界上正在发生的事情。

在本章中,我们将讨论以下反馈脚本概念:

  • UI 脚本

  • 脚本反馈

在本章结束时,您将能够使 UI 对游戏情况做出反应,以文本和条形图的形式显示相关信息,并且还能够使游戏对与 UI 的交互做出反应,比如按钮。此外,您还将能够使游戏通过其他媒介向用户传达这些信息,比如声音和粒子图形,这些可以和 UI 一样有效,但更具吸引力。

UI 脚本

我们之前创建了一个包含条形、文本和按钮等元素的 UI 布局,但到目前为止,它们都是静态的。我们需要使它们适应游戏的实际状态。在本章中,我们将讨论以下 UI 脚本概念:

  • 在 UI 中显示信息

  • 编写暂停菜单的程序

我们将首先看看如何使用脚本在我们的 UI 上显示信息,这些脚本修改了与 Canvas 元素一起显示的文本和图像。之后,我们将创建暂停功能,该功能将在整个 UI 中使用。

在 UI 中显示信息

如前所述,我们将使用 UI 向用户显示信息,以便他们做出明智的决定,因此让我们从看看如何使玩家的生命条对我们之前创建的Life脚本中剩余的生命做出反应开始:

  1. 添加一个名为Image的新脚本,用于表示生命条:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.1 – 玩家 HealthBar 画布中的生命条组件

  1. Life Bar脚本中添加一个Life类型字段。这样,我们的脚本将询问编辑器我们将监视哪个Life组件。保存脚本:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.2 – 可在编辑器中配置的对生命组件的引用

  1. 在编辑器中,将Player游戏对象从targetlife属性拖动到生命条引用玩家的Life组件,并记得在拖动LifeBar脚本之前选择HealthBar对象,以检查玩家剩余的生命。有趣的是,敌人也有相同的Life组件,所以我们可以轻松地使用这个组件为游戏中具有生命的其他对象创建生命条:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.3 – 拖动 Player 以引用其生命组件

  1. 在脚本的前几行的using语句之后添加using UnityEngine.UI;行。这将告诉 C#我们将与 UI 脚本进行交互:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.4 – 我们脚本中的所有 using 语句。我们现在不会使用它们,但让我们暂时保留它们

  1. 创建一个private字段(不带public关键字),类型为Image。我们将在这里保存对组件的引用:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.5 – 对图像的私有引用

  1. Awake中使用GetComponent,访问我们游戏对象(HealthBar)中Image组件的引用,并将其保存在image字段中。通常情况下,想法是只获取一次这个引用,并在Update函数中保存以供以后使用。当然,当你将这个组件放在一个带有Image组件的对象中时,这将总是有效。如果不是的话,另一个选择就是创建一个Image类型的公共字段,并将图像组件拖放到其中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.6 – 在此对象中保存对 Image 组件的引用

  1. LifeBar脚本中创建一个Update事件函数。我们将使用这个函数来根据玩家的生命不断更新生命条。

  2. Update事件中,将生命值除以100,以便在01范围内表示我们当前的生命百分比(假设我们的最大生命是100),并将结果设置在Image组件的fillAmount字段中,如下面的截图所示。请记住,fillAmount期望一个在01之间的值,0表示条是空的,1表示条是满的:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.7 – 根据 Life 组件更新 LifeBar 脚本的 Image 组件的填充量

重要提示:

记住,在代码中放入100被认为是硬编码(也被称为魔术数字),这意味着以后更改该值需要我们在代码中查找该值,这在大型项目中是一项复杂的任务。这就是为什么它被认为是不好的实践。最好在Life组件中有一个Maximum Life字段,或者至少有一个包含这个值的常量。

  1. 保存脚本,并在编辑器中选择玩家并开始游戏。在播放模式下,按下Esc键以重新获得鼠标访问权限,并在检查器窗口中更改玩家的生命值,以查看生命条如何相应更新。你也可以通过让玩家受到伤害来测试这一点,比如让敌人生成子弹(稍后会详细介绍敌人):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.8 – 完整的 LifeBar 脚本

重要提示:

在上一章中,我们探讨了事件的概念,以便检测其他对象状态的变化。生命条是另一个使用事件的例子,因为我们可以在生命实际改变时改变图像的填充量。我向你挑战,尝试创建一个生命改变时触发事件,并使用我们在上一章中看到的脚本来实现这个脚本。

你可能会认为这个 UI 行为可以直接编码在Life组件中,这是完全可能的,但这里的想法是创建简单的脚本,减少对我们代码的压力。每个脚本应该只有一个修改的原因,将 UI 行为和游戏行为混合在一个脚本中会使脚本具有两个责任,这将导致脚本有两个可能的修改原因。通过这种方法,我们还可以通过将相同的脚本添加到其生命条中并将我们在上一章中创建的基础伤害对象拖放为目标生命,来设置玩家的基础生命条。

重要提示:

我们刚提到的单一对象责任原则是作为 SOLID 的五个面向对象编程原则之一。如果你不知道 SOLID 是什么,我强烈建议你查一下,以改进你的编程最佳实践。

现在我们已经解决了玩家的生命条,让我们根据玩家剩余的子弹数量更新Bullets标签。这里需要考虑的是,我们当前的玩家射击脚本有无限的子弹,所以让我们通过以下步骤来改变这一点:

  1. 在 Player Shooting 脚本中添加一个名为bulletsAmount的公共int类型字段。

  2. 在检查左鼠标按钮的压力的if语句中,添加一个条件来检查子弹数量是否大于0

  3. if语句中,减少子弹数量1外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.9 - 限制射击的子弹数量

现在我们有一个字段指示剩余子弹的数量,我们可以创建一个脚本来显示该数字在 UI 中,方法如下:

  1. PlayerBulletsUI脚本添加到子弹的Text游戏对象中。在我的案例中,我将其称为Bullets Label

  2. 添加using UnityEngine.UI语句,并在Awake中添加一个Text类型的私有字段,将其保存在我们自己的Text组件的引用中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.10 - 缓存对我们自己的文本组件的引用

  1. 创建一个名为targetShootingPlayerShooting类型的公共字段,并将Player拖放到编辑器中的此属性中。就像生命条组件一样,我们的 UI 脚本将访问具有剩余子弹的脚本以更新文本,以保持两个脚本(TextPlayerShooting)的责任分离。

  2. 创建一个Update语句,在其中,使用文本引用的text字段(我知道,令人困惑)与targetShooting引用的bulletsAmount字段的连接来设置它。这样,我们将根据当前的子弹数量替换标签的文本:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.11 - 更新子弹文本标签

重要提示:

请记住,连接字符串会分配内存,所以我再次敦促您只在必要时使用事件来做这件事。

如果您查看这两个脚本,您会发现一个模式。您可以访问UIGameplay组件,并相应地更新UI组件,大多数 UI 脚本都会以相同的方式运行。牢记这一点,我挑战您创建必要的脚本来使用using UnityEngine.UI来使用Text组件。完成后,您可以将您的解决方案与以下截图中的解决方案进行比较,从ScoreUI开始:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.12 - ScoreUI 脚本

此外,我们还需要WavesUI组件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.13 - WavesUI 脚本

最后,我们需要EnemiesUI

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.14 - EnemiesUI 脚本

正如您所看到的,我们已经使用了管理器中已编码的事件来仅在必要时更改 UI。现在我们已经编写了 UI 标签和条,让我们编写Pause菜单。

编写暂停菜单

回想一下我们在上一章中创建了一个暂停菜单,但它目前被禁用了,所以让我们让它工作起来。首先,我们需要编写Pause,这可能会相当复杂。因此,我们将再次使用一个简单的方法来暂停大多数行为,即停止时间!请记住,我们的大多数移动脚本都使用时间功能,比如timeScale

这个字段将影响 Unity 的时间系统的速度,我们可以将其设置为0来模拟时间已经停止,这将暂停动画,停止粒子,并减少0,使我们的移动停止。所以,让我们来做吧:

  1. 创建一个名为Pause的脚本,并将其添加到场景中的一个新对象中,也称为Pause

  2. Update中,检测当按下Esc键时,然后在这种情况下,将Time.timeScale设置为0外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.15 - 停止时间以模拟暂停

  1. 保存并测试这个。

您会注意到几乎所有东西都会停止,但您可以看到射击功能仍在工作。这是因为玩家射击脚本不依赖于时间。这里的一个解决方案可能是简单地检查Time.timeScale是否大于0以防止这种情况发生:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.16 - 在 PSlayer 射击脚本中检查暂停

重要提示:

像往常一样,我们在这里追求了最简单的方法,但有更好的方法。我挑战您尝试创建PauseManager,其中包含一个布尔值,指示游戏是否暂停,从而改变timeScale

现在我们有了一个简单但有效的暂停游戏的方法,让我们通过以下方式使暂停菜单可见以取消暂停游戏:

  1. Pause脚本中添加一个名为pauseMenuGameObject类型字段。想法是将暂停菜单拖放到这里,以便我们有一个启用和禁用它的引用。

  2. Awake中,添加pauseMenu.SetActive(false);以在游戏开始时禁用暂停菜单。即使我们在编辑器中禁用了暂停菜单,我们也添加了这个以防我们错误地重新启用它。它必须始终处于禁用状态。

  3. 使用相同的函数,但将true作为第一个参数传递,启用UnityEventsButton脚本。我们的OnClick事件,这是一个通知我们特定按钮已被按下的事件。按下这些按钮时让游戏恢复,做如下操作:

  4. 在我们的Pause脚本中创建一个Button类型的字段,名为resumeButton,并将resumeButton拖放到其中;这样,我们的Pause脚本就有了对按钮的引用。

  5. Awake中,为resumeButtononClick事件添加名为OnResumePressed的监听函数。

  6. 使OnResumePressed函数将timeScale设置为1并禁用Awake

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.18 - 取消暂停游戏

如果您保存并测试此代码,您会注意到当您恢复时无法单击“暂停”并禁用它:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.19 - 在暂停时显示和隐藏光标

现在您知道如何编写按钮,我挑战您编写“退出”按钮的行为。同样,记得添加using UnityEngine.UI。此外,您需要调用Application.Quit();来退出游戏,但请注意这在编辑器中不起作用;我们不希望在创建游戏时关闭编辑器。此函数仅在构建游戏时起作用。因此,现在只需调用它,如果您想要打印一条消息以确保按钮正常工作,解决方案在以下截图中提供:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.20 - 退出按钮脚本

此解决方案建议您直接将此脚本添加到其Button兄弟组件的onClick事件上,并在这种情况下执行Quit函数。您还可以将此行为添加到Pause脚本中,虽然这样也可以工作,但请记住,如果一个脚本可以分成两个因为它执行两个不相关的任务,最好将其拆分为两个不相关的行为。在这里,暂停行为与退出行为无关。

现在我们已经使用 UI 和按钮设置了暂停系统,让我们继续探讨其他视觉和听觉方式,让玩家意识到发生了什么。

脚本化反馈

我们只是使用 UI 向用户传递数据,以便他们知道发生了什么,但有时这还不够。我们可以使用其他类型的反馈来加强游戏事件,例如声音和爆炸,这些在之前的章节中已经集成了。

在本节中,我们将探讨以下反馈概念:

  • 脚本化视觉反馈

  • 脚本化音频反馈

  • 脚本化动画

我们将开始看到如何使我们的游戏玩法具有更多的反馈,使用在正确时刻使用的不同视觉效果,比如音频和粒子系统。然后,我们将使我们角色的动画与这些时刻相匹配,例如,我们将创造他们实际上正在行走的幻觉。

脚本化视觉反馈

视觉反馈是使用不同的 VFX 概念,比如粒子和 VFX 图表,来加强发生的事情。例如,比如现在我们正在射击,我们知道这是发生的,因为我们可以看到子弹。但这并不完全感觉像真正的射击,因为一个合适的射击模拟需要我们的枪显示枪口闪光效果。另一个例子是敌人死亡——它只是消失了!这并不像应该的那样令人满意。我们可以改为添加一点爆炸效果(考虑到它们是机器人)。

让我们开始使我们的敌人在死亡时生成爆炸,方法如下:

  1. 创建一个爆炸效果或从资产商店下载一个。它不应该循环,并且在爆炸结束时需要自动销毁(确保在主模块中销毁)。

  2. 资产商店中的一些爆炸可能使用不兼容 URP 的着色器。您可以通过将“编辑” | “渲染管线” | “通用渲染管线” | “升级所选材料”选项设置为“UniversalRP 材料”来修复它们,同时保持所选材料。

  3. 手动升级未自动升级的材料。

  4. Enemy预制体中添加一个名为ExplosionOnDeath的脚本。这将负责在敌人死亡时生成粒子预制体。

  5. 添加一个名为particlePrefab的 GameObject 类型字段,并将爆炸预制体拖放到其中。

重要提示:

您可能希望将爆炸生成添加到“生命”组件中。在这种情况下,您假设任何与生命有关的东西在死亡时都会生成一个粒子,但请考虑角色以下落动画死亡的情况,或者可能是一个物体在没有任何效果的情况下消失。如果某种行为在大多数情况下都没有使用,最好将其编码为一个单独的可选脚本,以允许我们混合和匹配不同的组件,并获得我们想要的确切行为。

  1. 使脚本访问“生命”组件并订阅其onDeath事件。

  2. listener函数中,在相同位置生成粒子系统:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.21 – 爆炸生成器脚本

正如你所看到的,我们只是在以前的章节中学到的概念中,以新的方式进行组合。这就是编程的全部内容。让我们继续进行枪口效果,这也将是一个粒子系统,但这次我们将采取另一种方法。

  1. 从资产商店下载一个武器模型并将其实例化,使其成为玩家手的父级。记住我们的角色是绑定的,并且有一个手骨,所以你应该把武器放在那里:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.22 – 将武器放在手骨中

  1. 创建或获取一个枪口粒子系统。在这种情况下,我的枪口粒子系统是作为一个短粒子系统创建的,它有一阵粒子然后自动停止。尝试获取一个具有这种行为的粒子系统,因为还有其他的粒子系统会循环,处理这种情况的脚本会有所不同。

  2. 在编辑器中创建一个粒子系统预制体的实例,并将其放置在武器内,位于枪管的前方。确保粒子系统的主模块的“自动播放”属性未选中;我们不希望枪口在我们按下开火键之前就发射:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.23 – 枪口与武器相连

  1. PlayerShooting中创建ParticleSystem类型的字段,命名为muzzleEffect,并将父级为枪的枪口效果拖动到其中。现在,我们有了对枪口的ParticleSystem组件的引用来管理它。

  2. 在检查是否正在射击的if语句中,执行muzzleEffect.Play();以播放粒子系统。它将自动停止,并且足够短,可以在按键压力之间完成:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.24 - 枪口与武器相连

重要提示:

在这里,我们再次面临同样的问题:所有武器在射击时都会有枪口吗?在这种情况下,由于我们项目的范围,我会说是的,所以我会保持代码不变。但是,在将来,如果您需要其他组件知道此脚本是否在射击,可以创建一个onShoot事件。这样,您可以扩展射击行为。考虑使用事件作为启用脚本中的插件的一种方式。

现在我们已经有了一些 VFX,让我们添加音效。

脚本音频反馈

VFX 为游戏中发生的事情增加了很好的沉浸感,但我们可以通过声音进一步改进。让我们开始通过以下方式向爆炸效果添加声音:

  1. 下载爆炸音效。

  2. 选择爆炸预制件并向其添加Audio Source

  3. 将下载的爆炸音频剪辑设置为音频源的AudioClip属性。

  4. 确保Play On Awake已选中,并且Loop未选中在Audio Source下。

  5. Spatial Blend滑块设置为3D并测试声音,根据需要配置3D 声音设置:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.25 - 爆炸时添加声音

正如您在这里所看到的,我们不需要使用任何脚本。由于声音已添加到预制件中,它将在实例化预制件的那一刻自动播放。现在,让我们通过以下方式集成射击声音:

  1. 下载射击声音,并通过音频源添加到玩家的武器中,这次取消Play On Awake复选框,并再次将Spatial Blend设置为3D

  2. PlayerShooting脚本中,创建AudioSource类型的字段,命名为shootSound,并将武器拖动到此属性中,以将脚本与武器中的AudioSource变量连接起来。

  3. 在检查是否可以射击的if语句中,添加shootSound.Play();行以执行射击时的声音,使用相同的逻辑应用于粒子系统:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.26 - 射击时添加声音

另一种方法是与我们处理爆炸时的方法相同;只是将射击声音添加到子弹中,但如果子弹与墙壁碰撞,很快声音就会被切断。或者,如果将来我们想要自动武器声音,就需要将其实现为一个单一的循环声音,当我们按下相关按键时开始,松开按键时停止。这样,当我们射出太多子弹时,可以防止太多声音实例重叠。在选择脚本反馈的方法时,请考虑这些情景。

现在我们已经完成了音频反馈,让我们完成集成我们在第十二章中准备的动画资产,使用 Animator、Cinemachine 和 Timeline 创建动画

脚本动画

第十二章使用 Animator、Cinemachine 和 Timeline 创建动画,我们创建了一个动画控制器,作为整合多个动画的一种方式,并为其添加了参数,以控制动画之间的过渡何时执行。现在,是时候做一些脚本,使这些参数受到玩家实际行为的影响,并通过以下方式匹配玩家当前状态:

  1. PlayerShooting脚本中,使用Awake中的GetComponent添加对Animator的引用,并将其缓存在字段中:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.27 - 缓存 Animator 引用

  1. 在检查我们是否在射击的if语句中调用animator.SetBool("Shooting", true);函数,并在if语句的else子句中添加相同的函数,但将false作为第二个参数传递。此函数将修改动画控制器的"Shooting"参数:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.28 - 根据我们是否在射击来设置 Shooting 布尔值

如果您测试此功能,可能会注意到一个错误-动画没有播放。如果您检查脚本,您会注意到它只会在一个帧中为true,因为我们使用GetKeyDown,所以 Shooting 布尔值将立即在下一帧被设置为false。我们可以在这里实现的几种解决方案之一是,使我们的射击脚本在按住键时重复射击动作,而不是释放并再次点击以射出另一颗子弹。

  1. 查看以下截图以获取解决方案,并尝试理解逻辑:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.29 - 重复射击脚本

如您所见,我们的脚本现在使用GetKey来保持按住射击按钮时持续射击,并且为了防止在每一帧中射击,我们将当前时间与上次射击时间进行比较,以检查自上次射击以来经过了多少时间。我们创建了fireRate字段来控制射击之间的时间。

对于动画控制器的Velocity参数,我们可以检测Rigidbody的速度矢量的大小(以米/秒为单位),并将其设置为当前值。这可以完全与PlayerMovement脚本分离,因此在其他情况下我们可以重复使用这个。因此,我们需要一个脚本,如下所示,它只是将Rigidbody组件的速度与animatorVelocity参数连接起来:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 17.30 - 重复射击脚本

您可能需要稍微增加动画控制器的转换条件中使用的0.01过渡阈值,因为Rigidbody在释放键后仍在移动。对我来说,使用1效果非常好。另一个选择是增加玩家的阻力和速度,使角色更快地停下来。选择最适合您的方法。

如您所见,我们可以收集有关玩家实际移动和射击动作的数据,以通知动画控制器其状态,以便它可以做出相应的反应。

总结

反馈是视频游戏中的一个重要主题。它为玩家提供宝贵的信息,例如敌人的位置(如果有 3D 声音设置)、远处射击的枪口火光、生命条指示玩家即将死亡、根据玩家动作反应的动画等。在本章中,我们看到了不同形式的反馈,声音、VFX、动画和 UI,这些都是我们在本书的第二部分中已经创建的。在这里,我们学习了如何使用脚本将 UI 连接到游戏中。

现在,您可以编写脚本来让界面、粒子系统和声音根据游戏状态做出反应,包括更改界面上的得分文本或生命条,或在角色射击时播放粒子和声音效果。这将提高玩家在游戏中的沉浸体验。

在下一章中,我们将讨论如何为我们的敌人创建具有挑战性的人工智能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值