Unity-5-x-示例-全-

Unity 5.x 示例(全)

原文:zh.annas-archive.org/md5/54b53b5cf0def6a0a788bc44ff9eda9d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

电子游戏是一种文化现象,在过去五十年中吸引了、娱乐了和感动了全球数十亿人。作为一个行业和运动,电子游戏是一个令人兴奋的地方,无论是对于开发者还是艺术家。在这些角色中,你的视野、想法和作品可以影响广泛的受众,以前所未有的方式塑造和改变一代又一代人。在最近的时间里,有一个普遍的趋势是民主化游戏开发,使开发过程更加简单、流畅,并使更广泛的受众能够访问,包括可能在非常有限的预算下在家工作的开发者。在这个运动中起到关键作用的是 Unity 引擎,它是本书的主要内容。Unity 引擎是一个与你的现有资产管道(如 3D 建模软件)一起工作的计算机程序,旨在编译在多个平台和设备上无缝工作的视频游戏,包括 Windows、Mac、Linux、Android、iOS 和 Windows Phone。使用 Unity,开发者可以导入现成的资产(如音乐、纹理和 3D 模型),并将它们组装成一个连贯的整体,形成一个通过统一逻辑工作的游戏世界。Unity 是一个惊人的程序。最新版本对大多数人来说是免费的,并且它与许多其他程序配合良好,包括像 GIMP 和 Blender 这样的免费软件。本书专注于 Unity 引擎及其在制作可玩和有趣的游戏的实际应用中的使用。不需要对 Unity 有先前的知识,尽管对编程和脚本(如 JavaScript、ActionScript、C、C++、Java 或 C#)有一些了解会有所帮助。现在让我们按章节逐一看看这本书涵盖了什么内容。

本书涵盖的内容

本书通过查看具体示例,以实际操作和实用的方式探索如何使用 Unity 引擎,这些示例最终产生了可玩的真实世界游戏。具体来说,它专注于在八个章节中实现四个不同的项目,每个项目两个章节。让我们来看看这些项目是什么:

第一章, 硬币收集游戏 – 第一部分,通过创建一个第一人称收集游戏开始了我们对 Unity 的探索。如果你是 Unity 的完全新手,并准备好创建你的第一个游戏,这是一个很好的起点。

第二章, 项目 A – 收集游戏继续,从上一章继续,并完成了第一个项目。它假设你已经完成了第一章,并为我们的项目画上了圆满的句号,顺利地过渡到下一章。

第三章, 项目 B – 空间射击游戏,标志着我们第二个项目的开始,该项目专注于创建一个空间射击游戏。在这里,我们将创建一个玩家必须射击迎面而来的敌人的项目。

第四章,继续太空射击游戏,完成了太空射击游戏项目,将项目从上一章的状态继续发展,并为其添加了最后的修饰。

第五章,项目 C – 2D 冒险,进入了 2D 和 UI 功能的世界。在这里,我们将探索 Unity 在制作一个依赖于 2D 物理的侧视平台游戏时广泛使用的 2D 功能。

第六章,继续 2D 冒险,完成了上一章开始的 2D 冒险游戏项目,添加了最后的修饰并将其与整体游戏逻辑联系起来。这是一个很好的地方,可以看到游戏的不同部分和方面如何结合成一个整体。

第七章,项目 D – 智能敌人,专注于人工智能和创建能够在相关时间巡逻、追逐和攻击玩家角色的敌人,同时巧妙地绕过关卡。

第八章,继续与智能敌人作战,为上一章开始的 AI 项目以及整本书的内容画上了句号。在这里,我们将看到如何使用有限状态机来实现强大的智能功能,这将帮助我们应对各种场景。

阅读本书所需的条件

本书几乎包含了你需要的一切,以便跟随学习。每一章都考虑了学习 Unity 的实际、现实世界项目,并包括可以下载和使用的配套文件。除了这本书和你的专注力之外,你还需要 Unity 的最新版本。在撰写本书时,这是 Unity 5.3.1。这款软件作为个人版免费提供,可以从 Unity 网站下载,网址为:unity3d.com/。除了 Unity 之外,如果你想要创建道具、角色模型和其他 3D 资产,你还需要 3D 建模和动画软件,如 3DS Max、Maya 或 Blender;你还需要图像编辑软件,如 Photoshop 或 GIMP。Blender 可以从这里免费下载和使用:www.blender.org/。GIMP 可以从这里免费下载和使用:www.gimp.org/

本书面向的对象

本书非常适合没有任何 Unity 或游戏开发经验的读者,他们正在考虑将游戏开发作为爱好或职业。您通常将具备一些基本的编程或脚本知识,可能在游戏开发之外的语言中,例如 C、C++、C#、Java、JavaScript、ActionScript、Python 或其他面向对象的语言。此外,您应该至少对核心游戏开发概念有一些基本了解;例如,我将假设您知道什么是 3D 模型,什么是纹理,什么是音频文件,以及什么是可执行文件。我认为这些概念是基础性的。它们将在本书中简要提及,但不会详细阐述或深入解释。在这里,我们将专注于 Unity 作为软件,作为构建现实世界游戏的工具。每一章都像一篇深入教程,组装一个您可以在此基础上扩展并玩的功能性产品。

以下为约定:

在本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:"为收集硬币游戏创建的环境已仅使用原生原型设计包中包含的网格资源组装。"

代码块设置如下:

using UnityEngine;
using System.Collections;

public class Coin : MonoBehaviour
{

  // Use this for initialization
  void Start () {}

  // Update is called once per frame
  void Update () {}
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

using UnityEngine;
using System.Collections;

public class Coin : MonoBehaviour
 {
  // Use this for initialization
  void Start () {
    Debug.Log ("Object Created");
  }

  // Update is called once per frame
  void Update () {

  }
}

新术语重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将以如下形式出现在文本中:"您需要创建新项目。"

注意

警告或重要提示将以如下框中显示。

小贴士

小贴士和技巧将以如下形式出现。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com上的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

一旦文件下载完成,请确保您使用最新版本的软件解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/Unity5xByExample_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这个问题,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送电子邮件到<copyright@packtpub.com>并附上疑似盗版材料的链接与我们联系。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您在这本书的任何方面遇到问题,您可以通过发送电子邮件到<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 收集硬币游戏 – 第一部分

本章开始我们列表中的第一个项目,这将是一个有趣的游戏收集游戏。记住,即使你以前从未使用过 Unity,也没有关系。我们将一步一步地完成所有必要的步骤。到下一章结束时,你将拼凑出一个简单但完整且功能齐全的游戏。这是实现的一个重要目标,因为你会熟悉从头到尾的游戏开发工作流程。本章将演示以下主题:

  • 游戏设计

  • 项目和文件夹

  • 资产导入和配置

  • 关卡设计

  • 游戏对象

  • 层级

游戏设计

让我们制作一个收集硬币的游戏。在这里,玩家应该以第一人称模式控制一个角色,他必须在时间限制结束前在关卡中收集所有硬币。如果计时器耗尽,游戏失败。另一方面,如果在计时器到期前收集了所有硬币,游戏胜利。第一人称控制将使用默认的 WASD 键盘布局,其中 W 向前移动,AS 向左和向右移动,D 向后移动。头部移动由鼠标控制,硬币通过简单地走进它们来收集。见 图 1.1,展示了在 Unity 编辑器中实际运行的收集硬币游戏。制作这个游戏的巨大好处是它展示了所有核心的 Unity 功能,我们不需要依赖任何外部软件来制作资产,例如纹理、网格和材质。

游戏设计

图 1.1:准备收集硬币的游戏(完成后的游戏)

注意

本章和下一章中讨论的完成后的 CollectionGame 项目可以在书籍配套文件中的 Chapter01/CollectionGame 文件夹中找到。

入门 – Unity 和项目

每次你想制作一个新的 Unity 游戏,包括收集硬币游戏,你都需要创建新项目。一般来说,Unity 使用术语项目来表示游戏。制作新项目有两种主要方式,实际上你选择哪一种都无关紧要,因为两者最终都会到达同一个地方。如果你已经在 Unity 界面中,正在查看现有的场景或关卡,你可以从应用程序菜单中选择文件 | 新项目。见 图 1.2。它可能会询问你是否想要保存当前打开项目的更改,你应该根据需要选择。选择新项目选项后,Unity 会带你进入项目创建向导:

入门 – Unity 和项目

图 1.2:通过主菜单创建新项目

或者,如果你是第一次使用 Unity,你可能会从欢迎对话框开始。见 图 1.3。从这里,你可以通过选择新项目按钮来访问新项目创建向导:

入门 – Unity 和项目

图 1.3:Unity 欢迎界面

当达到 新建项目 向导时,Unity 可以根据一些基本设置为您生成一个新项目。只需填写您项目的名称(例如 CollectionGame),然后选择一个文件夹来包含将自动生成的项目文件。最后,点击 3D 按钮以表示我们将创建一个 3D 游戏,而不是 2D 游戏,然后点击 创建项目 按钮以完成项目生成过程。见图 1.4*:

入门 – Unity 和项目

图 1.4:创建新项目

项目和项目文件夹

Unity 现在创建了一个空白、新且空的项目。这代表任何游戏开发项目的起点,也是开发开始的地方。新创建的项目最初不包含任何内容:没有网格、纹理或任何其他 资源。您可以通过简单地检查编辑器界面底部的 项目 面板区域来确认这一点。此面板显示项目文件夹的完整内容,对应于项目向导之前在您的本地驱动器上创建的实际文件夹。此文件夹应该是空的。见图 1.5*。此面板将随后填充更多项目,我们都可以使用这些项目来构建游戏。

项目和项目文件夹

图 1.5:Unity 项目面板停靠在界面底部

注意

如果您的界面在布局和排列方面与 图 1.5 有显著不同,那么您可以重置 UI 布局为默认设置。为此,请从编辑器界面的右上角点击 布局 下拉菜单,并选择 默认。见图 1.6*。

项目和项目文件夹

图 1.6:切换到默认界面布局

您可以直接通过 Windows 资源管理器或 Mac Finder 查看项目文件夹的内容,通过在 Unity 编辑器项目 面板中右键单击鼠标以显示上下文菜单,然后从那里选择 在资源管理器中显示(Windows)或 在 Finder 中显示(Mac)选项。见图 1.7:

项目和项目文件夹

图 1.7:通过项目面板显示项目文件夹

点击 在资源管理器中显示 将文件夹内容显示在默认的系统文件浏览器中。见图 1.8*。此视图用于检查文件、计数或备份文件。然而,请不要通过这种方式手动更改文件夹内容,即通过资源管理器或 Finder。具体来说,不要从这里移动、重命名或删除文件,因为这样做可能会永久损坏您的 Unity 项目。相反,在 Unity 编辑器项目 面板中需要的地方删除和移动文件。这样,Unity 会根据需要更新其元数据,确保您的项目继续正常工作。

项目和项目文件夹

图 1.8:从操作系统文件浏览器查看项目面板

注意

在操作系统文件浏览器中查看项目文件夹将显示在 项目 面板中不可见的额外文件和文件夹,例如 LibraryProjectSettings,以及可能的一个 Temp 文件夹。这些一起被称为项目元数据。这本身并不是你的项目的一部分,但它包含 Unity 正常工作所需的额外设置和首选项。这些文件夹及其文件不应被编辑或更改。

导入资产

资产是游戏的原材料——它们是由这些材料构建的基石。Assets 包括网格(或 3D 模型),例如角色、道具、树木、房屋等;纹理,这些是图像文件,如 JPEG 和 PNG(这些决定了网格表面的外观);音乐和音效以增强游戏的真实感和氛围,最后是场景,这些是网格、纹理、声音和音乐存在并作为一个单一系统整体协同工作的 3D 空间或世界。因此,没有资产的游戏无法存在——否则它们将看起来完全空旷和毫无生气。因此,我们需要资产来制作我们正在努力制作的收集硬币游戏。毕竟,我们需要一个可以行走的环境和可以收集的硬币!

然而,Unity 是一个 游戏引擎,而不是 资产创建 程序。这意味着像角色和道具这样的资产通常首先由艺术家在外部第三方软件中制作。然后,它们被导出并准备好转移到 Unity 中,Unity 负责将这些资产在可以玩的游戏中生动呈现。第三方资产创建程序包括 Blender(免费),Maya3DS Max 用于制作 3D 模型,PhotoshopGIMP(免费)用于创建纹理,以及 Audacity(免费)用于生成音频。还有许多其他选项。这些程序的详细信息超出了本书的范围。无论如何,Unity 假设你已经有了可以导入以构建游戏的资产。对于收集硬币的游戏,我们将使用 Unity 附带资产。所以让我们将这些导入到我们的项目中。为此,从应用程序菜单中选择 Assets | Import Package。然后选择 CharactersParticleSystemsEnvironmentPrototyping。参见 图 1.9

导入资产

图 1.9:通过导入包菜单导入资产

每次你从菜单中导入一个包时,都会弹出一个 导入 对话框。只需保留所有设置在默认值,然后点击 导入。参见 图 1.10

导入资产

图 1.10:选择要导入的资产

默认情况下,Unity 将包(资源库)中的所有文件解压缩到当前项目中。导入后,许多不同的资源和数据将被添加到项目中,准备使用。这些文件是原始文件的副本。因此,对导入文件所做的任何更改都不会影响或使原始文件无效,Unity 会内部维护这些原始文件。文件包括模型、声音、纹理等。这些在Unity 编辑器项目面板中列出。参见以下截图:

导入资源

图 1.11:从项目面板浏览导入的资源

小贴士

当您从应用程序菜单中选择资源 | 导入时,如果您没有看到所有或任何资源包列出,您可以从 Unity 网站unity3d.com/分别下载和安装它们。从下载页面,选择附加下载选项,然后选择标准资源包。参见图 1.12

导入资源

图 1.12:下载标准资源包

导入的资源在我们的游戏中还不存在。它们不会出现在屏幕上,也不会做任何事情!相反,它们只是被添加到项目面板中,该面板作为一个资源库或存储库,我们可以从中挑选和选择来构建游戏。到目前为止导入的资源已内置到 Unity 中,我们将在后续部分中不断使用它们来制作一个功能性的收集游戏。要获取每个资源的更多信息,您可以单击鼠标选择资源,然后在Unity 编辑器检查器右侧显示资源特定详细信息。检查器是一个属性表编辑器,它出现在界面的右侧。它是上下文相关的,并且始终更改以显示选中对象的属性。参见图 1.13

导入资源

图 1.13:检查器显示当前选中对象的全部属性

开始一个关卡

我们现在创建了一个 Unity 项目,并通过Unity 标准资源包导入了一个大型资源库,包括墙壁、地板、天花板和楼梯的建筑网格。这意味着我们现在可以使用这些资源来构建第一个关卡!记住,在 Unity 中,场景意味着关卡。这里的场景和关卡可以互换使用。它们仅仅指的是一个 3D 空间,即游戏世界的时空——事物存在的地方。由于所有游戏都发生在时空之中,我们需要一个场景来制作收集游戏。要创建一个新的场景,从应用程序菜单中选择文件 | 新建场景,或者按键盘上的Ctrl + N。当您这样做时,将创建一个新的空场景。您可以通过场景标签查看场景的可视化或预览,该标签占据了 Unity 界面的大部分区域。参见图 1.14

开始一个关卡

图 1.14:场景标签显示 3D 世界的预览

小贴士

图 1.14所示,在 Unity 中,除了场景标签之外,还有其他可见和可用的标签。这些包括游戏标签和动画标签;在某些情况下,可能还有更多。目前,我们可以忽略除了场景标签之外的所有标签。场景标签是为了在构建过程中快速轻松地预览一个关卡而设计的。

每个新的场景开始时都是空的;嗯,几乎是这样。默认情况下,每个新的场景开始时包含两个对象。具体来说,一个灯光用于照亮添加的任何其他对象,一个相机用于从特定的视角显示和渲染场景的内容。您可以使用层次结构面板查看场景中存在的所有对象的完整列表,该面板停靠在 Unity 界面的左侧。见图 1.15。此面板显示场景中每个GameObject的名称。在 Unity 中,GameObject一词简单地指代场景中存在的单个、独立且独特的事物,无论是否可见:网格、灯光、相机、道具等等。因此,层次结构面板告诉我们关于场景中的所有内容。

开始一个关卡

图 1.15:层次结构面板

小贴士

您甚至可以通过在层次结构面板中点击它们的名称来选择场景中的对象。

接下来,让我们向场景中添加一个地板。毕竟,玩家需要站立的地方!我们可以使用 Maya、3DS Max 或 Blender 等第三方建模软件从头开始构建地板网格。然而,之前导入的 Unity 标准资产包中包含我们可以使用的地板网格。这非常方便。这些网格是原型设计包的一部分。要通过项目面板访问它们,双击Standard Assets文件夹,然后访问原型设计 | 预制体文件夹。从这里,您可以选择对象并在检查器中预览它们。见图 1.16

小贴士

您也可以通过从应用程序菜单中选择GameObject | 3D Object | Plane来快速向场景中添加一个地板。然而,这仅仅添加了一个单调的灰色地板,并不很有趣。当然,您可以改变它的外观。正如我们稍后将要看到的,Unity 允许您这样做。然而,对于这个教程,我们将通过项目面板中的Standard Assets包使用一个特别建模的地板网格。

开始一个关卡

图 1.16:Standard Assets/原型设计包包含许多用于快速场景构建的网格

命名为FloorPrototype64x01x64(如图 1.16 所示)的网格适合作为地面。要将此网格添加到场景中,只需将对象从项目面板拖放到场景视图中,然后释放鼠标。见图 1.17。当你这样做时,请注意场景视图如何改变以显示新添加的网格在 3D 空间中的位置,并且网格名称也作为列表出现在层次结构面板中:

开始一个关卡

图 1.17:将网格资源从项目面板拖放到场景视图中,将它们添加到场景中

来自项目面板的地面网格资源现在已作为场景中的GameObject实例化。这意味着基于项目面板中的原始网格资产的一个副本或克隆已作为单独的GameObject添加到场景中。场景中地面的实例(或GameObject)仍然依赖于项目面板中的地面资产。然而,资产并不依赖于实例。这意味着通过删除场景中的地面,你不会删除资产。但是,如果你删除了资产,你将使GameObject无效。如果你想要在场景中创建更多地面,也可以通过多次从项目面板拖放地面资产到场景视图中来实现。每次,场景中都会创建一个新的地面实例作为单独且唯一的GameObject,尽管所有添加的实例仍然依赖于项目面板中的单个地面资产。见图 1.18:

开始一个关卡

图 1.18:将多个地面网格实例添加到场景中

实际上我们并不需要重复的地面块。所以让我们删除它们。只需在场景视图中单击重复项,然后在键盘上按删除键来删除它们。记住,你还可以通过在层次结构面板中单击它们的名称并按删除键来选择和删除对象。无论如何,这都让我们只剩下一个地面块,并且为构建我们的场景提供了一个坚实的开始。然而,还有一个问题,那就是地面及其名称。通过仔细查看层次结构面板,我们可以看到地面的名称是FloorPrototype64x01x64。这个名字很长、晦涩、难以操作。我们应该将其更改为更易于管理和有意义的名称。这虽然在技术上不是必需的,但保持我们的工作整洁和有序是良好的实践。有许多方法可以重命名一个对象。一种方法是在对象检查器中的名称字段中首先选择它并输入新名称。我将将其重命名为WorldFloor。见图 1.19:

开始一个关卡

图 1.19:重命名地面网格

变换和导航

已经建立了一个带有地板网格的场景,但仅此而已并不有趣。我们需要添加更多内容,例如建筑、楼梯、柱子,也许还有更多的楼层部件。否则,玩家将没有世界可以探索。然而,在我们继续构建之前,让我们确保现有的楼层部件已经居中于世界原点。场景内的每个点和位置都由一个坐标唯一标识,该坐标是从世界中心(原点)测量的(XYZ)偏移量。所选对象的当前位置始终可以从对象检查器中看到。实际上,对象的位置旋转缩放被组合在一起,在称为变换的(组件)类别下。位置表示对象应从世界中心沿三个轴移动多远。旋转表示对象应围绕其中心轴旋转或旋转多少。缩放表示对象应缩小或放大到更小或更大的尺寸。默认的缩放为 1 表示对象应以正常大小出现,2 表示两倍大小,0.5 表示一半大小,依此类推。对象的位置、旋转和缩放共同构成了其变换。要更改所选对象的位置,您只需在位置XYZ字段中输入新值。要将对象移动到世界中心,只需输入(000),如图 1.20所示:

变换和导航

图 1.20:将对象居中到世界原点

通过输入数值设置对象的位置,就像我们在这里所做的那样,是可接受且适合指定精确位置的。然而,使用基于鼠标的控制来移动对象通常更直观。为此,让我们添加一个第二层的部件并将其放置在第一个实例之外。从项目面板在场景中拖动并放置一个楼层部件以创建第二个楼层游戏对象。然后点击新的楼层部件以选择它并切换到平移工具。为此,按键盘上的W键或点击编辑器界面顶部的工具栏中的平移工具图标。平移工具允许你在场景中重新定位对象。参见图 1.21

变换和导航

图 1.21:访问翻译工具

当平移工具处于活动状态且选中了一个对象时,一个** Gizmo** 将出现在对象中心(在场景选项卡中可见三个彩色轴)。平移 Gizmo 显示为三个彩色的垂直轴:红色、绿色和蓝色分别对应于XYZ。要移动一个对象,将光标悬停在三个轴之一(或轴之间的平面)上,然后点击并按住鼠标,同时移动鼠标以在该方向上滑动对象。您可以根据需要重复此过程,以确保对象位于所需的位置。使用平移工具将二楼部件移离一楼。参见图 1.22

变换和导航

图 1.22:使用平移 Gizmo 平移对象

您还可以像平移一样使用鼠标旋转和缩放对象。按E键访问旋转工具或按R键访问缩放工具,或者您可以从编辑器顶部的工具栏图标激活这些工具。当这些工具被激活时,一个 Gizmo 将出现在对象中心,您可以通过点击并拖动鼠标在每个特定的轴上旋转或缩放对象,以满足需要。参见图 1.23

变换和导航

图 1.23:访问旋转和缩放工具

在 Unity 中工作时,能够通过鼠标和键盘的组合快速进行平移、旋转和缩放对象非常重要。因此,将使用键盘快捷键养成习惯,而不是不断地从工具栏中访问工具。然而,除了移动、旋转和缩放对象之外,您还经常需要在场景视图中移动自己,以便从不同的位置、角度和视角查看世界。这意味着您将经常需要重新定位场景预览相机在世界中的位置。您可能需要放大和缩小世界,以更好地查看对象并更改您的观看角度,以查看对象如何正确对齐和配合。为此,您需要同时大量使用键盘和鼠标。

要从您正在查看的对象更近或更远地缩放,只需上下滚动鼠标滚轮——向上缩放,向下缩放。参见图 1.24

变换和导航

图 1.24:缩放

要在场景视图中左右或上下平移,请按住鼠标中键,同时将鼠标移动到适当的方向。或者,您可以从应用程序工具栏中访问平移工具(或按键盘上的Q键),然后当工具处于活动状态时,在场景视图中单击并拖动。平移不会缩放或放大;它只是将相机左右或上下滑动。

变换和导航

图 1.25:访问平移工具

有时候,在构建关卡时,你可能会完全看不到你需要的对象。例如,你的视口相机可能正聚焦在一个与你要点击或查看的对象完全不同的地方。在这种情况下,你通常会想要自动调整视口相机以聚焦于那个特定的对象。具体来说,你需要根据需要重新定位和旋转视口,将所需的对象移至视图中心。为此,通过在层次面板中点击其名称来选择要聚焦的对象(或框架)。然后,按键盘上的 F 键。或者,你也可以在层次面板中双击其名称。见图 1.26:

变换和导航

图 1.26:框架选定的对象

在框架一个对象之后,你通常会想要围绕它旋转,以便快速轻松地从所有重要的角度观察它。为了实现这一点,按住键盘上的 Alt 键,同时点击并拖动鼠标来旋转视图。见图 1.27:

变换和导航

图 1.27:围绕框架对象旋转

最后,使用第一人称控制导航场景视图是有帮助的,即模仿第一人称游戏如何玩控制的。这有助于你以更个人化和沉浸式的水平体验场景。为此,按住鼠标右键,并(在按钮按下时)使用键盘上的 WASD 键来控制前进、后退和侧移。鼠标移动控制头部方向。你还可以在移动时按住 Shift 键以增加移动速度。见图 1.28:

变换和导航

图 1.28:使用第一人称控制

学习多功能的转换和导航控制的好处在于,一旦理解了它们,你就可以以任何方式移动和定位几乎任何对象,你还可以从几乎任何位置和角度移动和观察世界。能够做到这一点对于快速构建高质量关卡至关重要。所有这些控制,以及我们很快就会看到的其他一些控制,将在这本书的整个过程中被频繁使用,以创建场景并在 Unity 中进行一般工作。

场景构建

现在我们已经看到了如何成功转换对象和导航场景视口,让我们继续完成收集金币游戏的第一个关卡。让我们在空间中将两个地板网格分开,在它们之间留出一段间隙,我们将通过创建一座桥梁来修复这个间隙,玩家将能够穿越桥梁,在地板空间之间移动,就像岛屿一样。我们可以使用平移工具(W)来移动对象。见图 1.29:

场景构建

图 1.29:将地板网格分开成岛屿

小贴士

如果你想创建更多的楼层对象,你可以使用我们之前看到的方法,通过在场景视图中项目面板中拖放网格资产来实现。或者,你也可以通过在键盘上按Ctrl + D来在视图中复制选定的对象。这两种方法会产生相同的结果。

接下来,我们将向场景添加一些道具和障碍物。将一些房子对象拖放到地板上。房子对象(HousePrototype16x16x24)位于Assets | Standard Assets | Prototyping | Prefabs文件夹中。参见图 1.30

场景构建

图 1.30:向场景添加房子道具

在场景中拖放房子时,它可能很好地与地板对齐,底部紧贴地板,也可能不会这样对齐。如果它这样对齐,那真是太棒了,运气真好!然而,我们不应该每次都依赖运气,因为我们专业的游戏开发者!幸运的是,我们可以使用顶点捕捉在 Unity 中轻松地将任何两个网格对象对齐。该功能通过在场景中将两个对象的位置对齐,并在特定和公共点上重叠它们的顶点来实现。

例如,考虑图 1.31。在这里,一个房子对象尴尬地悬浮在地板上方,我们自然希望它与地板对齐,并且可能对齐到地板的角落。为了实现这一点,首先选择房子对象(点击它或从层次结构面板中选择它)。要选择的对象是应该移动以对齐的对象,而不是目的地(即地板),它应该保持原位。

场景构建

图 1.31:使用顶点捕捉将未对齐的对象对齐到位置

接下来,激活平移工具(W)并按住V键以启用顶点捕捉。按住V键,移动光标并观察 Gizmo 光标如何粘附到所选网格最近的顶点上。参见图 1.32。Unity 要求你选择一个用于捕捉的源顶点:

场景构建

图 1.32:按住 V 以激活顶点捕捉

按住V键,将光标移动到房子的底部角落,然后从角落开始点击并拖动到地板网格的角落。然后房子将精确地与地板对齐,角落对角落。以这种方式对齐后,释放V键,两个网格将精确地对齐在顶点上。参见图 1.33

场景构建

图 1.33:通过顶点对齐两个网格

现在你可以使用包含在Prototyping包中的网格资产来组装一个完整的场景。将道具拖放到场景中,并使用平移、旋转和缩放来重新定位、重新对齐和旋转这些对象;使用顶点捕捉,你可以将它们对齐到你需要的任何位置。多加练习。参见图 1.34,这是我仅使用这些工具和资产制作的场景布局:

场景构建

图 1.34:构建完整的关卡

照明和天空

在建筑模型和布局方面,基本关卡已经创建完成;这是通过仅使用几个网格资产和一些基本工具实现的。尽管如此,这些工具功能强大,为我们提供了多种组合和选项,以在游戏世界中创造丰富的多样性和逼真度。然而,我们缺少一个重要的成分。这个成分是照明。你会从图 1.34中注意到,一切看起来相对平坦,没有高光、阴影或明暗区域。这是因为场景照明没有正确配置以获得最佳效果,尽管我们已经在场景中有一个光,这是最初默认创建的。

让我们通过启用天空来开始设置硬币收集游戏的场景,如果尚未启用的话。为此,点击场景视图中顶部工具栏的额外下拉菜单。从上下文菜单中选择天空盒以启用天空盒查看。天空盒简单来说是指围绕整个场景的一个大立方体。每个内部侧面都应用了连续的纹理(图像)来模拟周围天空的外观。因此,点击天空盒选项会在场景视图中显示默认的天空。参见图 1.35

照明和天空

图 1.35:启用天空

现在,尽管天空盒已经启用,场景看起来比以前好,但它仍然没有得到适当的照明——物体缺少阴影和高光。为了解决这个问题,确保通过在场景视图中顶部切换照明图标来启用场景的照明。参见图 1.36。此设置仅用于显示目的。它只影响是否在场景视图中显示照明效果,并不影响最终游戏是否真正启用了照明。

照明和天空

图 1.36:在场景视图中启用场景照明

启用视口中的照明显示将导致场景外观的一些差异,而且,场景应该比以前看起来更好。你可以通过从层次面板中选择方向光并旋转它来确认场景照明正在生效。这样做可以控制一天中的时间,旋转光周期在白天和夜晚之间变化,并改变光强度和氛围。这会改变场景的渲染方式。参见图 1.37

照明和天空

图 1.37:旋转场景方向光改变一天中的时间

按下键盘上的 Ctrl + Z 来撤销对方向光的任何旋转。为了准备最终和最优化的照明,场景中所有不可移动的物体(如墙壁、地板、椅子、桌子、天花板、草地、山丘、塔楼等)应标记为静态。这向 Unity 表明,无论游戏过程中发生什么,这些物体都不会移动。通过提前标记不可移动的物体,你可以帮助 Unity 优化渲染和照明的场景。要将物体标记为静态,只需选择所有不可移动的物体(实际上到目前为止几乎包括整个关卡),然后通过对象检查器启用静态复选框。请注意,您不需要为每个对象单独启用静态设置。在选择对象时按住 Shift 键,您可以一起选择多个对象,通过对象检查器批量调整它们的属性。参见 图 1.38

照明和天空

图 1.38:为多个不可移动对象启用静态选项可改善照明和性能

当您为几何体启用静态复选框时,Unity 会自动在后台计算场景照明——如阴影、间接照明等效果。它生成了一组称为全局光照缓存(GI Cache)的数据,其中包含光照传播路径,它指导 Unity 如何使光线在场景中反弹和移动,以实现更高的真实感。即便如此,像我们这样启用静态复选框仍然不会为对象产生阴影,这严重影响了真实感。这是因为大多数网格对象都禁用了投射阴影选项。为了解决这个问题,选择场景中的所有网格。然后,从对象检查器中,点击网格渲染器组件中的投射阴影复选框,并从上下文菜单中选择开启选项。当你这样做时,所有网格对象都应该投射阴影。参见 图 1.39

照明和天空

图 1.39:启用从网格渲染器组件投射阴影

哇!你的网格现在可以投射阴影了。做得好:到目前为止,你已经创建了一个新项目,用网格填充了一个场景,并成功地用方向光照亮了它们。这很棒。然而,如果我们能在第一人称模式下探索我们的环境会更好。我们将在下一节中看到如何做到这一点。

游戏测试和游戏选项卡

到目前为止为收集金币游戏创建的环境仅使用原生 原型设计 包中包含的网格资产组装而成。我的环境,如图 图 1.40 所示,有两个主要楼层岛屿和房屋,岛屿本身通过一块步石桥连接在一起。你的版本可能略有不同,这是正常的。

游戏测试和游戏选项卡

图 1.40:到目前为止创建的场景包含两个岛屿区域

总体来说,场景制作得很好。非常值得保存。要保存场景,请按键盘上的 Ctrl + S 或者在应用程序菜单中选择 文件 | 保存场景。见 图 1.41。如果你是第一次保存场景,Unity 会显示一个弹出 保存 对话框,提示你以描述性的名称命名场景(我将其命名为 Level_01)。

游戏测试和游戏标签页

图 1.41:保存场景

保存场景后,它成为项目的场景资产,并出现在 项目 面板中。见 图 1.42。这意味着场景现在已经成为项目的真正和不可或缺的部分,而不再是之前的那种临时工作状态。注意,保存场景在概念上与保存项目是不同的。例如,应用程序菜单中有 保存场景保存项目 的条目。请记住,项目 是文件和文件夹的集合,包括资产和场景。相比之下,场景是项目中的一个资产,代表一个完整的 3D 地图,可能包含其他资产,如网格、纹理和声音。因此,保存项目会保存文件和资产之间的配置,包括场景。相比之下,保存场景只是保留指定场景中的级别更改。

游戏测试和游戏标签页

图 1.42:保存的场景作为项目中的资源添加

小贴士

图 1.42 可以看出,我已经将我的场景保存在名为 Scenes 的文件夹中。您可以通过在 项目 面板中的任何空白区域右键单击并从上下文菜单中选择 新建文件夹,或者从应用程序菜单中选择 资产 | 创建 | 文件夹 来在项目中创建文件夹。您可以通过简单地拖放它们来轻松移动和重新排列文件夹中的资源。

现在,这个级别目前实际上没有任何可玩的内容。它只是一个使用 编辑器 工具制作的静态、无生命力和非交互式的 3D 环境。让我们通过使场景可玩来纠正这一点,允许玩家以第一人称模式四处游荡并探索世界,使用键盘上的标准 WASD 键进行控制。为了实现这一点,我们将向场景添加一个第一人称角色控制器。这是一个包含在 Unity 中的现成资源,它包含创建快速有效的第一人称控制所需的一切。打开 Standard Assets | Characters | FirstPersonCharacter | Prefabs 文件夹。然后从场景的 项目 面板中拖放 FPSController 资产。见 图 1.43

游戏测试和游戏标签页

图 1.43:将 FPSController 添加到场景中

添加第一人称控制器后,点击 Unity 工具栏中的播放按钮以以第一人称模式测试游戏。见 图 1.44

游戏测试和游戏标签页

图 1.44:通过点击工具栏中的播放按钮可以测试 Unity 场景

点击播放后,Unity 通常会从场景选项卡切换到游戏选项卡。正如我们所见,场景选项卡是导演视角的活跃场景;这是编辑、制作和设计场景的地方。相比之下,游戏选项卡是从玩家的视角播放和测试活跃场景的地方。从这个视角来看,场景通过主游戏摄像头显示。在播放模式激活时,如果您将游戏选项卡置于焦点,您可以使用默认的游戏控制来测试您的游戏。第一人称控制器使用键盘上的 WASD 键和鼠标移动控制头部方向。请参阅图 1.45

测试游戏和游戏选项卡

图 1.45:在游戏选项卡中测试关卡

小贴士

在播放模式下,您可以切换回场景选项卡。您甚至可以在那里编辑场景,更改、移动和删除对象!然而,在播放模式下进行的任何场景更改在播放模式结束时都会自动恢复到其原始设置。这种行为是有意为之的。它让您可以在游戏过程中编辑属性以观察其效果,并调试任何问题,而不会永久更改场景。

恭喜!你的关卡现在应该可以在第一人称模式下行走。完成之后,你可以通过再次点击播放按钮或按键盘上的 Ctrl + P 来轻松停止播放。这样做会将你返回到场景选项卡。

小贴士

Unity 还提供了一个切换暂停按钮来暂停和恢复游戏。

你应该注意,在用第一人称控制器玩关卡时,你会在控制台窗口中看到一个信息消息。默认情况下,此窗口位于Unity 编辑器底部,停靠在项目面板旁边。您也可以通过应用程序菜单手动访问此窗口,窗口 | 控制台控制台窗口是显示所有遇到错误或警告以及信息消息的地方。错误以红色打印,警告以黄色打印,信息消息以默认灰色显示。有时,消息只会出现一次,有时会反复多次出现。请参阅图 1.46

测试游戏和游戏选项卡

图 1.46:控制台输出信息、警告和错误

如前所述,控制台窗口输出三种不同类型的信息:信息、警告和错误。信息消息通常是 Unity 根据你的项目当前工作情况提出最佳实践建议或建议的方式。警告稍微严重一些,代表你的代码或场景中存在的问题,如果不纠正,可能会导致意外的行为和次优性能。最后,错误描述了场景或代码中需要仔细和立即注意的区域。有时,错误甚至可能完全阻止游戏运行,有时错误发生在运行时,可能导致游戏崩溃或冻结。因此,控制台窗口非常有用,因为它帮助我们调试和解决游戏中的问题。图 1.46已经识别了一个关于重复的音频监听器的问题。

音频监听器是一个附加到相机对象上的组件。具体来说,每个相机默认情况下都有一个音频监听器组件附加。这代表了一个耳朵点,即从相机的位置在场景中听到声音的能力。不幸的是,Unity 不支持同一场景中的多个活动音频监听器,这意味着你一次只能从一个地方听到音频。这个问题发生是因为我们的场景现在包含两个相机,一个是在创建场景时自动添加的,另一个包含在第一人称控制器中。为了确认这一点,在层次结构面板中选择第一人称控制器对象,并点击其名称旁边的三角形图标以揭示下面的更多对象,这些对象是第一人称控制器的一部分。参见图 1.47

游戏测试和游戏选项卡

图 1.47:在第一人称控制器上找到相机

选择FirstPersonCharacter对象,它在FPSController对象之下(如图 1.47所示)。FirstPersonCharacter对象是FPSController的子对象,FPSController是父对象。这是因为FPSController层次结构面板中包含或包围了FirstPersonCharacter对象。子对象继承其父对象的变换。这意味着当父对象移动和旋转时,所有变换都会向下级联到所有子对象。从对象检查器中,你可以看到该对象有一个音频监听器组件。参见图 1.48

游戏测试和游戏选项卡

图 1.48:FirstPersonController 对象包含一个 AudioListener 组件

我们可以从FPSController中移除音频监听器组件,但这将阻止玩家以第一人称视角听到声音。因此,我们将会删除场景中默认创建的原始相机。为此,在层级中选择原始相机对象,然后在键盘上按Delete键。参见图 1.49。这将消除游戏过程中的控制台中的音频监听器警告。现在给游戏进行一次试玩测试!

试玩和游戏标签页

图 1.49:删除相机对象

添加水面

收集游戏进展顺利。我们现在有了一些可玩的内容,我们可以以第一人称模式在环境中四处走动和探索。然而,环境可以从额外的润色中受益。例如,目前地板网格看起来悬浮在空中,下面没有任何支撑物。参见图 1.50。此外,有可能走到边缘并掉入无限深渊。因此,让我们在地板下添加一些水面,以补充场景作为一个完整的环境。

添加水面

图 1.50:世界地板看起来悬浮且没有支撑

要添加水面,我们可以在项目面板中使用另一个现成的 Unity 资源。打开标准资源 | 环境 | 水面 | 水面 | 预制体文件夹。然后从场景中的项目面板中拖放WaterProDaytime资源。参见图 1.51。它显示为一个圆形对象,最初比所需的要小。

添加水面

图 1.51:向环境中添加水面

添加Water预制体后,将其放置在地板水平以下,并使用缩放工具(R)增加其平面尺寸(XZ)以向外填充环境直至远处的地平线。这营造出地板网格是位于广阔水域中的小岛屿的感觉。参见图 1.52

添加水面

图 1.52:缩放和调整环境中的水面大小

现在,让我们在游戏标签页中进行另一次测试运行。在工具栏上按播放,以第一人称模式导航角色。参见图 1.53。你应该在关卡中看到水面。当然,你不能在水面上行走!你也不能在水下游泳或潜水。如果你尝试在水面上行走,你将直接穿过它,仿佛水从未存在过一样,无限地下降。目前,水面是一个完全的装饰性特征,但它使场景看起来好多了。

添加水面

在 FPS 模式下测试带有水面的环境

水实际上是一种无形的、空灵的对象,玩家可以轻易通过。Unity 不将其识别为实体或半实体对象。正如我们稍后将要更详细地看到的,你可以通过将盒子碰撞体组件附加到对象上来非常快速地使其成为实体。从第三章,“项目 B – 空间射击者”开始,更深入地介绍了碰撞体和物理。现在,我们可以通过首先从层次结构面板(或在场景视图中)选择Water对象,然后从应用程序菜单中选择组件 | 物理 | 盒子碰撞体来给水添加实体性。参见图 1.54。将组件附加到选定的对象会改变对象本身;它会改变其行为方式。本质上,组件为对象添加行为和功能,使其以不同的方式表现。即便如此,不要在没有理由和认为它们会使对象更灵活或更强大的观点下,随意给对象添加很多组件。最好让对象上的组件尽可能少。这种偏好相关简单性的策略可以使你的工作流程更整洁、更简单、更优化。

添加水面

图 1.54:将盒子碰撞体附加到水面对象

当向水面添加盒子碰撞体时,会出现一个周围的绿色笼子或网格。这近似了Water对象的体积和形状,并代表其物理体积,即 Unity 识别为实体的对象体积。参见图 1.55

添加水面

图 1.55:盒子碰撞体近似物理体积

如果你现在玩游戏,你的角色将能在水面上行走而不是穿过。诚然,角色应该能够正确游泳,但行走可能比坠落更好。要实现完整的游泳行为需要做大量的工作,这里没有涉及。如果你想移除盒子碰撞体功能并将水恢复到其原始的空灵状态,那么选择Water对象,点击盒子碰撞体组件上的齿轮图标,然后从上下文菜单中选择移除组件。参见图 1.56

添加水面

图 1.56:移除组件

添加一枚硬币进行收集

到目前为止,我们的游戏已经具有许多功能,即一个完整的环境、第一人称控制器和水。然而,我们本应制作一个收集硬币的游戏,但目前还没有硬币供玩家收集。现在,为了实现完全可收集的硬币,我们需要编写一些 C# 脚本,这将在本书的下一章中介绍。然而,我们至少可以开始创建硬币对象本身。为此,我们将使用一个缩放成硬币形状的 圆柱体 原始形状。要创建圆柱体,从应用程序菜单中选择 GameObject | 3D Object | Cylinder

向收集区域添加硬币

图 1.57:创建圆柱体

初始时,圆柱体看起来根本不像硬币。然而,通过在 Z 轴上非均匀缩放使其变薄,可以轻松地改变这一点。切换到缩放工具(R),然后缩放 圆柱体 向内。参见 图 1.58

向收集区域添加硬币

图 1.58:缩放圆柱体以制作可收集的硬币

在重新缩放硬币后,其碰撞体不再代表其体积。它看起来比应有的要大得多(参见 图 1.58)。默认情况下,圆柱体 是创建时带有 胶囊碰撞体 而不是 盒子碰撞体。当选择硬币时,可以通过调整 对象检查器 中的 半径 字段来更改 胶囊碰撞体 组件的大小。将 半径 字段降低以缩小碰撞体到更具有代表性的尺寸和体积。参见 图 1.59。或者,您可以完全移除 胶囊碰撞体 并添加 盒子碰撞体。两种方式都行;通常在可能的情况下选择更简单的形状。在下一章的脚本中,将使用这些碰撞体来检测玩家何时与硬币碰撞以收集它们:

向收集区域添加硬币

图 1.59:调整硬币的胶囊碰撞体

到这里我们就完成了!我们现在有了硬币的基本形状和结构。当然,我们将在下一章中从许多方面仔细和批判性地改进它。例如,我们将使其可收集,并分配一个材质使其看起来闪亮。然而,在这里,仅使用基本的 Unity 原始形状和缩放工具,我们就能生成一个真正类似于硬币的形状。

摘要

恭喜!到达这个阶段,你已经为下一章将完成的、功能齐全的收集游戏打下了基础。在这里,我们学习了如何从头创建一个 Unity 项目,并填充它,例如网格、纹理和场景等资源。此外,我们还了解了如何为我们的游戏创建场景,并使用一系列资源来填充它,这些资源是 Unity 引擎自带的有用功能,例如水、第一人称控制器和环境原型资源。在下一章中,我们将从这里结束的地方继续工作,制作一个可收集的硬币,并建立一套游戏规则和逻辑,使得游戏能够实现赢和输。

第二章. 项目 A – 收集游戏继续

本章从上一章继续,通过使用 Unity 构建一个收集游戏。在这个游戏中,玩家以第一人称模式在环境中漫步,在全局计时器耗尽之前寻找并收集场景中的所有硬币。如果所有硬币在计时器耗尽之前被收集,则游戏胜利。然而,如果计时器在所有硬币被收集之前耗尽,则游戏失败。到目前为止创建的项目包括一个完整的环境,有地板、道具和水,还包括一个第一人称控制器和一个基本的硬币对象,形状和形式看起来正确,但仍然不能被收集。

本章通过创建一个可收集的硬币对象并添加一个计时器系统来确定总游戏时间是否已过时来完成项目。本质上,本章是关于定义一个逻辑和规则系统来管理游戏。为了实现这一点,我们需要用 C#进行编码,因此本章需要基本的编程知识。这本书是关于 Unity 和用该引擎开发游戏的。然而,作为主题的编程基础超出了本书的范围。所以我会假设你已经对编程有了一定的了解,但之前没有在 Unity 中编码过。总的来说,本章将演示以下主题:

  • 材质创建

  • 预制体

  • 使用 C#进行编码

  • 编写脚本文件

  • 使用粒子系统

  • 构建和编译游戏

创建硬币材质

上一章通过从非均匀缩放的圆柱原形创建一个基本的硬币对象来结束。这个对象是通过从应用程序菜单中选择GameObject | 3D Object | Cylinder来创建的。见图 2.1。作为一个概念,硬币对象代表了我们游戏逻辑中的一个基本或基本单位,因为玩家角色应该在计时器耗尽之前积极地在关卡中寻找可以收集的硬币。这意味着硬币不仅仅是外观;它在游戏中的目的不仅仅是视觉上的吸引力,而是具有功能性。硬币是否被玩家收集对游戏结果的影响极大。因此,目前的硬币对象在两个方面存在不足。首先,它看起来单调且灰色——它并没有真正突出并吸引玩家的注意力。其次,硬币实际上还不能被收集。当然,玩家可以走进硬币,但没有任何适当的反应发生。

创建硬币材质

图 2.1:到目前为止的硬币对象

注意

如本章和下一章所讨论的,完成的CollectionGame项目可以在本书配套文件中的Chapter02/CollectionGame文件夹中找到。

在本节中,我们将专注于使用材质来改进硬币的外观。材质定义了一个算法(或指令集),指定了硬币应该如何渲染。材质不仅说明硬币在颜色方面的外观,还定义了表面是光滑还是闪亮的,与粗糙和漫反射相对。这一点很重要,这也是为什么纹理和材质指的是不同的事物。纹理只是一个加载到内存中的图像文件,可以通过其 UV 映射将其包裹在 3D 对象上。相比之下,材质定义了如何将一个或多个纹理组合在一起并应用于对象以塑造其外观。要在 Unity 中创建新的材质资产,请在项目面板的空白区域右键单击,然后从上下文菜单中选择创建 | 材质。参见图 2.2。您也可以从应用程序菜单中选择资产 | 创建 | 材质

创建硬币材质

图 2.2:创建材质

注意

有时,一种材料被称为着色器。如果需要,您可以使用着色器语言创建自定义材料,或者您可以使用 Unity 插件,例如Shader Forge

创建新的材质后,从项目面板中为其分配一个合适的名称。由于我目标是金色外观,我将材质命名为mat_GoldCoin。在资产名称前加上mat前缀有助于我仅从资产名称中知道它是一个材质资产。只需在文本编辑字段中键入新名称即可命名材质。您还可以双击材质名称,在任何时候稍后编辑名称。参见图 2.3

创建硬币材质

图 2.3:命名材质资产

接下来,如果尚未选择,请选择项目面板中的材质资产,其属性将立即在对象检查器中显示。列出了许多属性!此外,材质预览显示在对象检查器的底部,根据其当前设置显示材质的外观,如果将其应用于 3D 对象(如球体),则会显示。您从检查器更改材质设置时,预览面板会自动更新以反映您的更改,提供关于材质外观的即时反馈。参见以下截图:

创建硬币材质

图 2.4:从对象检查器更改材质属性

现在我们为硬币创建一个金色材质。在创建任何材质时,首先要选择的设置是着色器类型,因为此设置会影响您可用的所有其他参数。着色器类型确定将用于着色对象的算法。有众多不同的选择,但大多数材质类型可以使用标准标准(镜面设置)来近似。对于金币,我们可以将着色器保留为标准。参见以下截图:

创建硬币材质

图 2.5:设置材质着色器类型

目前,预览面板显示的材料为一种暗灰色,这远非我们所需要的。为了定义金色,我们必须指定阿尔贝托。为此,点击阿尔贝托颜色槽以显示颜色选择器,并从颜色选择器对话框中选择金色。材料预览会根据变化进行更新。参见图下所示截图:

创建硬币材料

图 2.6:为阿尔贝托通道选择金色

硬币材料看起来比之前好,但它仍然应该代表一个金属表面,这种表面通常是闪亮的且具有反射性的。为了给我们的材料添加这种特性,在对象检查器中点击并拖动金属滑块到右侧,将其值设置为1。这表示该材料代表一个完全金属表面,而不是像布料或头发这样的漫反射表面。同样,预览面板将更新以反映变化。参见图 2.7

创建硬币材料

图 2.7:创建金属材质

现在我们已经创建了一个金色材料,并且在预览面板中看起来不错。如果需要,您可以更改用于预览的对象类型。默认情况下,Unity 将创建的材料分配给球体,但允许使用其他原始对象,包括立方体、圆柱体和环面。这有助于您在不同条件下预览材料。您可以通过点击预览面板正上方的几何按钮来更改对象,以循环浏览它们。参见图 2.8

创建硬币材料

图 2.8:在对象上预览材料

当您的材料准备就绪时,您可以直接通过拖放将其分配到场景中的网格上。让我们将硬币材料分配给硬币。将材料从项目面板拖放到场景中的硬币对象上。放下材料后,硬币的外观将发生变化。参见图 2.9

创建硬币材料

图 2.9:将材料分配给硬币

您可以通过选择场景中的Coin对象并从对象检查器中查看其网格渲染器组件来确认材料分配是否成功,甚至可以识别出分配了哪种材料。网格渲染器组件负责确保当相机正在查看时,网格对象在场景中实际上是可见的。网格渲染器组件包含一个材料字段。该字段列出了当前分配给对象的全部材料。通过点击材料字段中的材料名称,Unity 会自动在项目面板中选择该材料,这使得查找材料变得快速且简单。参见图 2.10网格渲染器组件列出了分配给对象的全部材料:

创建硬币材料

图 2.10:网格渲染器组件列出了分配给对象的全部材质

注意

网格对象可能具有多个材质,不同材质分配给不同的面。为了获得最佳的游戏性能,尽量在对象上使用尽可能少的独特材质。如果可能的话,请努力在多个对象之间共享材质。这样做可以显著提高游戏性能。有关优化渲染性能的更多信息,请参阅在线文档docs.unity3d.com/Manual/OptimizingGraphicsPerformance.html

就这样!你现在已经拥有了一个完整且功能齐全的金色材质,用于收藏硬币。看起来不错。然而,我们还没有完成硬币的制作。硬币的外观是正确的,但它的行为却不正确。具体来说,当被触摸时,它不会消失,而且我们还没有记录玩家总共收集了多少硬币。因此,我们需要编写脚本。

Unity 中的 C#脚本编写

定义游戏逻辑、规则和行为通常需要脚本编写。具体来说,要将静态且无生命的场景中的对象转换成具有某种行为的环境,开发者需要编写行为代码。这需要有人定义在特定条件下事物应该如何行动和反应。收集硬币的游戏也不例外。特别是,它需要三个主要功能:

  • 要知道玩家何时收集到硬币

  • 要跟踪游戏过程中收集了多少硬币

  • 确定计时器是否已过期

Unity 没有包含处理此场景的默认功能。因此,我们必须编写一些代码来实现它。Unity 支持两种语言,即 UnityScript(有时称为 JavaScript)和 C#。这两种语言都是强大且有用的语言,但本书使用 C#,因为从现在开始,对 JavaScript 的支持最终将被放弃。让我们按顺序开始编写这三个功能。要创建一个新的脚本文件,请在项目面板的空白区域右键单击,并在上下文菜单中选择创建 | C#脚本。或者,您可以从应用程序菜单导航到资产 | 创建 | C#脚本。参见图 2.11

Unity 中的 C#脚本编写

图 2.11:创建一个新的 C#脚本

文件创建后,您需要给它赋予一个描述性的名称。我会叫它Coin.cs。在 Unity 中,每个脚本文件代表一个具有匹配名称的单个、离散的类。因此,Coin.cs文件编码了Coin类。Coin类将封装Coin对象的行为,并最终被附加到场景中的Coin对象上。参见图 2.12

Unity 中的 C#脚本编写

图 2.12:命名脚本文件

双击对象检查器中的Coin.cs文件以打开它,在MonoDevelop中进行编辑,这是一个随 Unity 一起提供的第三方 IDE 应用程序。此程序允许您编辑和为您的游戏编写代码。一旦在 MonoDevelop 中打开,源文件将显示出来,如图代码示例 2.1所示:

using UnityEngine;
using System.Collections;

public class Coin : MonoBehaviour
{

  // Use this for initialization
  void Start () {}

  // Update is called once per frame
  void Update () {}
}

小贴士

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的支持选项卡上。

  • 点击代码下载与勘误表

  • 搜索框中输入书籍名称。

  • 选择您想要下载代码文件的书籍。

  • 从下拉菜单中选择您购买此书的来源。

  • 点击代码下载

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

默认情况下,所有新创建的类都从MonoBehavior派生,它定义了一组所有组件共享的通用功能。Coin类有两个自动生成的函数,即StartUpdate。这些函数是由 Unity 自动调用的事件。StartGameObject(脚本附加到的对象)在场景中创建时立即调用一次。Update在附加脚本的每个对象上每帧调用一次。Start对于初始化代码很有用,而Update对于创建随时间变化的行为很有用,例如运动和变化。现在,在继续之前,让我们将新创建的脚本文件附加到场景中的Coin对象。为此,从项目面板拖动并放下Coin.cs脚本文件到Coin对象上。当您这样做时,对象将添加一个新的Coin组件。这意味着脚本被实例化并存在于对象上。参见图 2.13,将脚本文件附加到对象:

Unity 中的 C#脚本

图 2.13:将脚本文件附加到对象

当脚本附加到对象上时,它作为组件存在于对象上。脚本文件通常可以添加到多个对象上,甚至可以添加到同一个对象上多次。每个组件代表类的独立且唯一的实例化。当以这种方式附加脚本时,Unity 会自动调用其事件,如StartUpdate。你可以通过在Start函数中包含一个Debug.Log语句来确认你的脚本是否正常工作。这会在场景中创建GameObject时将调试消息打印到控制台窗口。请考虑代码示例 2.2,它实现了这一点:

using UnityEngine;
using System.Collections;

public class Coin : MonoBehaviour
 {
  // Use this for initialization
  void Start () {
    Debug.Log ("Object Created");
  }

  // Update is called once per frame
  void Update () {

  }
}

如果你按工具栏上的播放按钮(Ctrl + P)运行你的游戏,并将前面的脚本附加到对象上,你将在控制台窗口中看到消息,对象已创建——每次创建类的实例时都会打印一次。请参见图 2.14

Unity 中的 C#脚本

图 2.14:向控制台窗口打印消息

干得好!我们现在已经为Coin类创建了一个基本的脚本并将其附加到硬币上。接下来,让我们定义其功能,以便在收集硬币时跟踪它们。

计数硬币

如果游戏中只有一个硬币,那么收集硬币的游戏就不会是真正的游戏。核心思想是,一个关卡应该有多个硬币,玩家应该在计时器到期前收集所有硬币。现在,为了知道是否已经收集了所有硬币,我们需要知道场景中总共有多少个硬币。毕竟,如果我们不知道有多少硬币,那么我们就无法知道是否已经收集了所有硬币。因此,我们在脚本编写中的第一个任务是配置Coin类,以便我们可以在任何时刻轻松地知道场景中硬币的总数。请考虑代码示例 2.3,它将Coin类适配以实现这一点:

//-------------------------
using UnityEngine;
using System.Collections;
//-------------------------
public class Coin : MonoBehaviour 
{
  //-------------------------
  //Keeps track of total coin count in scene
  public static int CoinCount = 0;
  //-------------------------
  // Use this for initialization
  void Start () 
{
    //Object created, increment coin count
    ++Coin.CoinCount;
  }
  //-------------------------
  //Called when object is destroyed
  void OnDestroy()
  {
    //Decrement coin count
    --Coin.CoinCount;

    //Check remaining coins
    if(Coin.CoinCount <= 0)
    {
      //We have won
    }
  }
  //-------------------------
}
//-------------------------

代码示例 2.3

以下要点总结了代码示例:

  • Coin类维护一个静态成员变量CoinCount,由于它是静态的,因此跨所有类的实例共享。这个变量统计场景中硬币的总数,每个实例都可以访问它。

  • 当对象在场景中创建时,Start函数会针对每个Coin实例调用一次。对于场景开始时存在的硬币,Start事件在场景启动时被调用。这个函数会将CoinCount变量增加一,从而统计所有硬币。

  • 当对象被销毁时,OnDestroy函数会针对每个实例调用一次。这会减少CoinCount变量的值,减少每个被销毁的硬币的数量。

总的来说,代码示例 2.3维护一个CoinCount变量。简而言之,这个变量允许我们始终跟踪硬币的总数。我们可以轻松查询它以确定剩余多少硬币。这是好的,但这只是完成收集硬币功能的第一步。

收集硬币

之前,我们开发了一个计数变量,告诉我们场景中有多少硬币。然而,无论计数如何,玩家在游戏过程中仍然无法收集硬币。现在让我们解决这个问题。首先,我们需要考虑碰撞。仔细思考后,我们知道只要玩家走进硬币,硬币就被认为是收集到了,也就是说,当玩家和硬币相交或碰撞时,硬币就被收集了。

为了确定何时发生此类碰撞,我们必须近似玩家和硬币的体积,以确定两个体积在空间中何时重叠。在 Unity 中,这是通过碰撞器实现的。碰撞器是附加到网格的特殊物理对象。它们告诉我们两个网格何时相交。FPSController对象(第一人称控制器)已经通过其角色控制器组件附加了一个碰撞器。这近似了一个普通人的物理身体。这可以通过在场景中选择FPSController并检查围绕主相机的绿色线框笼来确认。它是胶囊形状的。参见图 2.15角色控制器具有一个碰撞器来近似玩家身体:

收集硬币

图 2.15:角色控制器具有碰撞器来近似玩家身体

FPSController组件包含一个角色控制器组件,默认配置了半径高度中心设置,这些设置定义了场景中角色的物理范围。参见图 2.16FPSController组件包含角色控制器。这些设置可以保持不变,适用于我们的游戏:

收集硬币

图 2.16:FPSController 具有角色控制器

相比之下,Coin对象仅具有一个胶囊碰撞器组件,这是在我们之前创建圆柱原语时自动添加的,以模拟硬币。这近似了场景中硬币的物理体积,而不添加任何特定于角色和运动的额外功能,如角色控制器组件中找到的。这是可以的,因为硬币是一个静态对象,而不是像FPSController那样的移动和动态对象。参见图 2.17圆柱原语具有胶囊碰撞器组件:

收集硬币

图 2.17:圆柱原语具有胶囊碰撞器组件

对于这个项目,我将坚持使用胶囊碰撞器组件为Coin对象。但是,如果您想将附加的碰撞器更改为不同的形状,例如盒子或球体,您可以通过首先删除硬币上的任何现有碰撞器组件来实现这一点——点击对象检查器中组件的齿轮图标,然后在上下文菜单中选择移除组件。参见图 2.18

收集硬币

图 2.18:从物体中移除组件

然后,你可以通过从应用程序菜单中选择组件 | 物理,然后选择一个合适形状的碰撞体,将新的碰撞体组件添加到选定的对象中。参见图 2.19

收集金币

图 2.19:向选定的对象添加组件

不论使用哪种碰撞体类型,都存在一个小问题。如果你现在玩游戏并尝试穿过金币,它会阻挡你的路径。金币作为一个固体、物理对象,FPSController无法通过。然而,对于我们的目的来说,金币不应该这样表现。它应该是一个可收集的对象。我们的想法是,当我们穿过它时,金币被收集并消失。我们可以通过选择Coin对象,并在对象检查器中的胶囊碰撞体组件中启用Is Trigger复选框来轻松解决这个问题。Is Trigger设置几乎适用于所有碰撞体类型。它允许我们检测与其他碰撞体的碰撞和交点,同时允许它们通过。参见图 2.20

收集金币

图 2.20:Is Trigger 设置允许对象通过碰撞体

如果你现在玩游戏,FPSController将轻松地穿过场景中的所有金币对象。这是一个好的开始。然而,金币在被触摸时并没有真正消失;它们仍然没有被收集。为了实现这一点,我们需要在Coin.cs文件中添加更多的脚本。具体来说,我们将添加一个OnTriggerEnter函数。当像玩家这样的对象进入碰撞体时,这个函数会自动被调用。目前,我们将添加一个Debug.Log语句,在玩家进入碰撞体时打印一个调试信息,仅用于测试目的。参见代码示例 2.4

//-------------------------
using UnityEngine;
using System.Collections;
//-------------------------
public class Coin : MonoBehaviour 
{
  //-------------------------
  public static int CoinCount = 0;
  //-------------------------
  // Use this for initialization
  void Start () {
    //Object created, increment coin count
    ++Coin.CoinCount;
  }
  //-------------------------
  void OnTriggerEnter(Collider Col)
  {
    Debug.Log ("Entered Collider");
  }
  //-------------------------
  //Called when object is destroyed
  void OnDestroy()
  {
    //Decrement coin count
    --Coin.CoinCount;

    //Check remaining coins
    if(Coin.CoinCount <= 0)
    {
      //We have won
    }
  }
  //-------------------------
}
//-------------------------

注意

关于OnTriggerEnter函数的更多信息,可以在以下在线 Unity 文档中找到:

docs.unity3d.com/ScriptReference/MonoBehaviour.OnTriggerEnter.html

通过工具栏上的播放按钮测试代码示例 2.4。当你遇到代币时,OnTriggerEnter函数将被执行并显示消息。然而,问题仍然存在,即最初是什么对象触发了这个函数。确实,有东西与代币发生了碰撞,但具体是什么?是玩家、敌人、下落的砖块,还是其他东西?为了检查这一点,我们将使用标签标签功能允许你使用特定的标签或标签标记场景中的特定对象,使得这些对象在代码中容易被识别,以便我们可以快速检查是玩家而不是其他对象与代币发生了碰撞。毕竟,只有玩家才能收集代币。因此,首先,我们将玩家对象标记为名为Player的标签。为此,在场景中选择FPSController对象,然后点击对象检查器中的标签下拉框。从这里,选择Player标签。这标志着FPSControllerPlayer对象。参见图 2.21

收集代币

图 2.21:将 FPSController 标记为 Player

由于FPSController现在被标记为Player,我们可以细化Coin.cs文件,如代码示例 2.5所示。这处理了代币收集,使得代币在触摸时消失并减少代币计数。

//-------------------------
using UnityEngine;
using System.Collections;
//-------------------------
public class Coin : MonoBehaviour 
{
  //-------------------------
  public static int CoinCount = 0;
  //-------------------------
  // Use this for initialization
  void Start () {
    //Object created, increment coin count
    ++Coin.CoinCount;
  }
  //-------------------------
  void OnTriggerEnter(Collider Col)
  {
    //If player collected coin, then destroy object
    if(Col.CompareTag("Player"))
      Destroy(gameObject);
  }
  //-------------------------
  //Called when object is destroyed
  void OnDestroy()
  {
    //Decrement coin count
    --Coin.CoinCount;

    //Check remaining coins
    if(Coin.CoinCount <= 0)
    {
      //We have won
    }
  }
  //-------------------------
}
//-------------------------

代码示例 2.5

以下要点总结了代码示例:

  • Unity 每次FPSControllerCoin碰撞器相交时都会自动调用一次OnTriggerEnter

  • 当调用OnTriggerEnter时,Col参数包含有关此次进入碰撞器的对象的信息。

  • 使用CompareTag函数来确定碰撞的对象是否是Player而不是其他对象。

  • 调用Destroy函数是为了销毁Coin对象本身,该对象在内部通过继承的成员变量gameObject表示。

    当调用Destroy函数时,会自动触发OnDestroy事件,该事件会减少Coin的数量。

优秀的工作!你刚刚创建了你第一个工作的代币。现在玩家可以跑向代币,收集它,并将其从场景中移除。这是一个很好的开始,但场景应该包含不止一个代币。我们可以通过多次复制现有的代币并将每个副本重新定位到不同的位置来解决这个问题。然而,还有更好的方法,我们将在下面看到。

代币和预制体

现在已经创建了基本的硬币功能,但场景需要不止一个硬币。简单地复制一个硬币并将副本散布开来存在的问题是,如果我们稍后对其中一个硬币进行更改并需要将此更改传播到所有其他硬币,我们需要删除之前的副本并手动用较新和修正的副本替换它们。为了避免这种繁琐的重复,我们可以使用预制件。预制件允许您将场景中的对象转换为项目面板中的Assets。这可以在场景中按需实例化,就像它是一个网格资产一样。优点是,对资产所做的更改将自动应用于所有实例,即使在多个场景中也是如此。

这样做使得与自定义资产的工作更加容易,所以现在就预制硬币吧。为此,在场景中选择Coin对象,然后将其拖放到项目面板中。当这样做时,将创建一个新的prefab。场景中的对象将自动更新为prefab的实例。这意味着如果从项目面板中删除了资产,实例将变得无效。参见图 2.22

硬币和预制件

图 2.22:创建硬币预制件

在创建prefab之后,您可以通过将prefab项目面板拖放到场景中,轻松地将更多硬币实例添加到关卡中。每个实例都与原始prefab资产相关联,这意味着对资产所做的所有更改将立即应用于所有实例。考虑到这一点,现在请继续添加尽可能多的Coin预制件到关卡中,以适应您的硬币收集游戏。参考以下图示进行布局:

硬币和预制件

图 2.23:将硬币预制件添加到关卡中

一个自然而然出现的问题是,您如何将预制件转换回不再与prefab资产连接的独立GameObject。如果您希望某些对象基于prefab但略有偏差,这样做是有用的。为了实现这一点,在场景中选择一个prefab实例,然后从应用程序菜单导航到GameObject | 分解预制件实例。参见图 2.24

硬币和预制件

图 2.24:分解预制件实例

注意

如果您将prefab实例添加到场景中并对它进行了您喜欢的更改,并希望将这些更改向上游分发回prefab资产,那么请选择该对象并选择GameObject | 应用到预制件

计时器和倒计时

您现在应该有一个包含几何和硬币对象的游戏关卡。多亏了我们新添加的 Coin.cs 脚本,硬币现在既可计数也可收集。即便如此,关卡对玩家来说仍然几乎没有挑战,因为没有赢得或输掉关卡的方法。具体来说,玩家没有要实现的目标。这就是为什么游戏中的时间限制很重要:它定义了胜利和失败条件。也就是说,在计时器到期之前收集所有硬币会导致胜利条件,而未能实现这一点则会导致失败条件。让我们开始为关卡创建计时器倒计时。为此,通过选择 GameObject | Create Empty 创建一个新的空游戏对象,并将其重命名为 LevelTimer。参见 图 2.25

计时器和倒计时

图 2.25:重命名计时器对象

注意

请记住,空的游戏对象玩家是看不到的,因为它们没有网格渲染器组件。它们特别有用于创建不直接对应于物理和可见实体的功能和行为,例如计时器、管理器和游戏逻辑控制器。

接下来,创建一个名为 Timer.cs 的新脚本文件,并将其添加到 场景 中的 LevelTimer 对象中。通过这样做,计时器功能将存在于场景中。但是,请确保计时器脚本只添加到一个对象中,并且不超过一个。否则,场景中实际上会有多个相互竞争的计时器。您可以通过使用 层次结构 面板来搜索场景以找到指定类型的所有组件。为此,点击 层次结构 搜索框并输入 t:Timer。然后按键盘上的 Enter 键确认搜索。这将搜索场景中所有附加了计时器类型组件的对象,并将结果显示在 层次结构 面板中。具体来说,层次结构 面板被过滤以仅显示匹配的对象。搜索字符串中的 t 前缀表示按类型进行搜索操作。参见 图 2.26

计时器和倒计时

图 2.26:搜索具有匹配类型组件的对象

您可以通过单击搜索字段右侧的小交叉图标轻松取消搜索,并将 层次结构 面板恢复到原始状态。这个按钮可能很难找到。参见 图 2.27

计时器和倒计时

图 2.27:取消类型搜索

如果要使计时器脚本有用,则必须编写计时器脚本。Timer.cs 文件的完整源代码在以下 代码示例 2.6 中给出。如果您以前从未在 Unity 中编写过脚本,这段源代码非常重要。它展示了许多关键特性。请参阅注释以获得更全面的解释。

//-------------------------
using UnityEngine;
using System.Collections;
//-------------------------
public class Timer : MonoBehaviour
{
  //-------------------------
  //Maximum time to complete level (in seconds)
  public float MaxTime = 60f;
  //-------------------------
  //Countdown
  [SerializeField]
  private float CountDown = 0;
  //-------------------------
  // Use this for initialization
  void Start () 
  {
    CountDown = MaxTime;
  }
  //-------------------------
  // Update is called once per frame
  void Update () 
  {
    //Reduce time
    CountDown -= Time.deltaTime;

    //Restart level if time runs out
    if(CountDown <= 0)
    {
      //Reset coin count
      Coin.CoinCount=0;
      Application.LoadLevel(Application.loadedLevel);
    }
  }
  //-------------------------
}
//-------------------------

代码示例 2.6

以下要点总结了代码示例:

  • 在 Unity 中,声明为public(例如public float MaxTime)的类变量在编辑器的对象检查器中显示为可编辑字段。然而,这仅适用于支持的多种数据类型,但这是一个非常有用的功能。这意味着开发者可以直接从检查器中监控和设置类的public变量,而不必每次需要更改时都修改和重新编译代码。相比之下,private变量默认情况下被隐藏在检查器中。但是,如果需要,可以使用SerializeField属性强制它们可见。带有此属性的前缀的private变量,例如CountDown变量,将在对象检查器中显示,就像一个public变量一样,尽管变量的作用域仍然是private

  • Update函数是 Unity 原生事件,支持所有从MonoBehaviour派生的类。Update函数会自动在每一帧调用一次场景中所有活动的GameObjects。这意味着所有活动的游戏对象都会收到帧更改事件的通知。简而言之,Update因此每秒被调用多次;游戏 FPS 是每秒调用次数的一般指标。实际上,每秒的调用次数会因实际情况而有所不同。无论如何,Update特别适用于在一段时间内动画化、更新和改变对象。对于CountDown类,跟踪时间流逝,每秒更新是有用的。关于Update函数的更多信息可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html

    注意

    除了每帧调用的Update函数之外,Unity 还支持两个其他相关函数,即FixedUpdateLateUpdateFixedUpdate在编写物理代码时使用,我们将在后面看到,并且每帧调用固定次数。LateUpdate每帧为每个活动对象调用一次,但LateUpdate调用总是在每个对象收到Update事件之后发生。因此,它发生在Update周期之后,使其成为延迟更新。这种延迟更新的原因将在本书后面的章节中看到。

    关于FixedUpdate的更多信息可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html。关于LateUpdate函数的更多信息可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/MonoBehaviour.LateUpdate.html

  • 在编写脚本时,静态的Time.deltaTime变量始终可用,并由 Unity 自动更新。它始终描述了自上一帧结束以来经过的时间(以秒为单位)。例如,如果你的游戏帧率为 2 FPS(一个非常低的帧率!)那么deltaTime将是0.5。这是因为,在每一秒中,会有两个帧,因此每个帧将是半秒。deltaTime很有用,因为,如果随着时间的推移而累加,它就会告诉你自游戏开始以来总共经过了多少时间。因此,deltaTime浮点变量在Update函数中被大量使用,以便从倒计时总数中减去经过的时间。有关deltaTime的更多信息,可以在在线文档中找到,网址为docs.unity3d.com/ScriptReference/Time-deltaTime.html

  • 静态的Application.LoadLevel函数可以在代码的任何地方调用,以在运行时更改活动场景。因此,这个函数对于将玩家从一个关卡移动到另一个关卡非常有用。它会导致 Unity 终止活动场景,销毁其所有内容,并加载一个新的场景。它也可以通过重新加载活动关卡来重新启动活动场景。Application.LoadLevel对于具有明确定义且彼此分离的关卡,并且有明确开始和结束的游戏最为合适。然而,它并不适合大型开放世界游戏,在这些游戏中,广阔的环境似乎没有中断或断开。有关Application.LoadLevel的更多信息,可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/Application.LoadLevel.html

在创建计时器脚本后,选择场景中的LevelTimer对象。从对象检查器中,你可以设置玩家完成关卡允许的最大时间(以秒为单位)。参见图 2.28。我已经将总时间设置为60秒。这意味着所有金币必须在关卡开始后的60秒内完成。如果计时器到期,关卡将重新启动。

代码示例 2.6

图 2.28:设置关卡总时间

伟大的工作!你现在应该有一个带有工作倒计时的完成关卡。你可以收集金币,计时器可以到期。总的来说,游戏正在成形。然而,还有一个进一步的问题,我们将在下一部分解决。

庆祝和烟花!

硬币收集游戏几乎完成了。可以收集硬币,计时器到期,但胜利条件本身并没有真正处理。也就是说,在时间到期前收集到所有硬币时,实际上并没有发生任何事情来向玩家显示他们已经获胜。倒计时仍在继续,甚至重新开始关卡,好像根本未满足胜利条件。现在让我们解决这个问题。具体来说,当发生胜利场景时,我们应该删除计时器对象以防止进一步的倒计时并显示视觉反馈以表明关卡已完成。在这种情况下,我将添加一些烟花!所以,让我们先创建烟花。您可以从 Unity 5 的 粒子系统 包中轻松添加这些。导航到 Standard Assets | ParticleSystems | Prefabs 文件夹。然后,将 Fireworks 粒子系统拖放到 场景 中。如果您想要的话,还可以添加第二个或第三个。

庆祝和烟花!

图 2.29:添加两个烟花预制体

默认情况下,所有烟花粒子系统将在关卡开始时播放。您可以通过在工具栏上按播放来测试这一点。这不是我们想要的行为。我们只想在满足胜利条件时播放烟花。要禁用在关卡启动时的播放,请在 场景 中选择 粒子系统 对象,并在 对象检查器 中禁用 播放唤醒 复选框,该复选框位于 粒子系统 组件中。参见 图 2.30,禁用 播放唤醒

庆祝和烟花!

图 2.30:禁用播放唤醒

禁用 播放唤醒 防止粒子系统在关卡启动时自动播放。这很好,但如果它们要播放,必须在正确的时间手动启动它们。我们可以通过代码来实现这一点。然而,在求助于编码解决方案之前,我们首先将所有烟花对象标记为适当的标签。这样做的原因是,在代码中,我们将想要在场景中搜索所有烟花对象并在需要时触发它们播放。为了将烟花对象与其他所有对象隔离开来,我们将使用标签。因此,让我们创建一个新的 Fireworks 标签并将它们仅分配给 场景 中的烟花对象。标签是在本章早期配置玩家角色以进行硬币碰撞时创建的。参见 图 2.31

庆祝和烟花!

图 2.31:标记烟花对象

通过标记烟花对象,我们现在可以细化 Coin.cs 脚本类以处理场景的胜利条件,如 代码示例 2.7 所示。注释如下:

//-------------------------
using UnityEngine;
using System.Collections;
//-------------------------
public class Coin : MonoBehaviour
{
  //-------------------------
  public static int CoinCount = 0;
  //-------------------------
  // Use this for initialization
  void Awake () 
  {
    //Object created, increment coin count
    ++Coin.CoinCount;
  }
  //-------------------------
  void OnTriggerEnter(Collider Col)
  {
    //If player collected coin, then destroy object
    if(Col.CompareTag("Player"))
      Destroy(gameObject);
  }
  //-------------------------
  void OnDestroy()
  {
    --Coin.CoinCount;

    //Check remaining coins
    if(Coin.CoinCount <= 0)
    {
      //Game is won. Collected all coins
      //Destroy Timer and launch fireworks
      GameObject Timer = GameObject.Find("LevelTimer");
      Destroy(Timer);

      GameObject[] FireworkSystems =         GameObject.FindGameObjectsWithTag("Fireworks");
      foreach(GameObject GO in FireworkSystems)
      GO.GetComponent<ParticleSystem>().Play();
    }
  }
  //-------------------------
}
//-------------------------

代码示例 2.7

以下要点总结了代码示例:

  • OnDestroy 函数至关重要。它发生在收集到硬币时,并包含一个 if 语句以确定何时收集到所有硬币(即胜利场景)。

  • 当发生胜利场景时,会调用GameObject.Find函数来在整个场景层次结构中搜索任何名为LevelTimer的活动对象。如果找到,该对象将被删除。这会导致删除计时器并防止在关卡胜利后进行进一步的倒计时。如果场景包含多个同名对象,则只返回第一个对象。这是场景应该只包含一个计时器的一个原因。

    注意

    尽可能避免使用GameObject.Find函数。它在性能上很慢。相反,使用FindGameObjectsWithTag。这里只使用它来演示其存在和目的。有时,你需要使用它来找到单个、无特定标签的杂项对象。

  • 除了删除LevelTimer对象外,OnDestroy函数还会在场景中找到所有烟花对象并启动它们。它使用GameObject.FindGameObjectsWithTag函数找到所有匹配标签的对象。此函数返回所有带有Fireworks标签的对象数组,并通过调用Play函数为每个对象启动ParticleSystem

    注意

    如前所述,Unity 中的每个GameObject实际上是由一组附加的相关组件组成的。一个对象是其组件的总和。例如,一个标准立方体(使用GameObject | 3D Object | Cube)由Transform组件、Mesh Filter组件、Mesh Renderer组件和Box Collider组件组成。这些组件共同构成了立方体,并决定了它的行为。

    可以在脚本中调用GetComponent函数来检索对任何指定组件的引用,从而直接访问其公共属性。前述代码中的OnDestroy函数使用GetComponent来检索附加到对象上的ParticleSystem组件的引用。GetComponent是一个高度有用且重要的函数。有关GetComponent的更多信息,可以在 Unity 在线文档中找到,链接为docs.unity3d.com/ScriptReference/GameObject.GetComponent.html

游戏测试

你现在已经在 Unity 中完成了你的第一个游戏!是时候对其进行测试运行,然后最终构建它了。在 Unity 中进行测试首先是通过工具栏上的播放按钮来播放游戏,以查看游戏是否按预期从玩家的角度工作。除了播放之外,你还可以从对象检查器中启用调试模式,在运行时密切关注所有publicprivate变量,确保没有变量被分配了意外的值。要激活调试模式,点击对象检查器右上角的菜单图标,然后从出现的上下文菜单中选择调试选项。参见图 2.32

游戏测试

图 2.32:从对象检查器激活调试模式

在激活Debug模式后,对象检查器中一些变量和组件的外观可能会改变。通常,您将获得更详细和准确的变量视图,您还将能够看到大多数private变量。参见图 2.33以查看Debug模式下的Transform组件:

Play 测试

图 2.33:在调试模式下查看变换组件

另一个在运行时非常有用的调试工具是Stats面板。您可以通过点击工具栏中的Stats按钮从Game标签页访问它。参见图 2.34

Play 测试

图 2.34:从 Game 标签页访问 Stats 面板

Stats面板仅在游戏模式下有用。在此模式下,它详细说明了游戏的关键性能统计信息,例如帧率(FPS)和内存使用情况。这使您能够诊断或确定是否有任何问题可能影响您的游戏。FPS 代表您的游戏每秒可以平均维持的总帧数(滴答或周期)。没有正确、错误或神奇的 FPS,但较高的值比较低的值好。较高的值代表更好的性能,因为它意味着您的游戏可以在一秒内维持更多的周期。如果您的 FPS 低于 20 或 15,那么您的游戏可能会出现卡顿或延迟,因为每个周期的性能权重意味着它需要更长的时间来处理。许多变量可以影响 FPS,有些是游戏内部的,有些是游戏外部的。内部因素包括场景中的灯光数量、网格的顶点密度、指令数量和代码的复杂性。一些外部因素包括您计算机硬件的质量、同时运行的其他应用程序和进程的数量、硬盘空间的大小等。

简而言之,如果您的 FPS(每秒帧数)低,那么这表明需要关注的问题。该问题的解决方案取决于上下文,您需要运用判断力,例如,您的网格是否过于复杂?它们是否有太多的顶点?您的纹理是否太大?是否有太多的声音在播放?参见图 2.35以查看正在运行的收集硬币游戏。完成的游戏可以在书籍配套文件中的Chapter02/End文件夹中找到。

Play 测试

图 2.35:测试收集硬币游戏

构建

因此,现在是时候构建游戏了!也就是说,将游戏编译并打包成独立和可执行的形式,玩家可以在不使用 Unity 编辑器的情况下运行和播放。通常,在开发游戏时,您会在设计阶段而不是开发结束时决定您的目标平台(如 Windows、iOS、Android 等)。经常有人说 Unity 是一个“一次开发,到处部署”的工具。这个口号可能会让人联想到一个不幸的图像,即游戏制作完成后,它将在 Unity 支持的所有平台上像在桌面平台上一样轻松地工作。

不幸的是,事情并不那么简单;在桌面系统上运行良好的游戏不一定在移动设备上表现同样出色,反之亦然。这主要归因于它们之间在目标硬件和行业标准方面的巨大差异。由于这些差异,我将在此处将我们的注意力集中在 Windows 和 Mac 桌面平台上,忽略移动设备、游戏机和其他平台。要为桌面平台创建构建,请从文件菜单中选择文件 | 构建设置

Building

图 2.36:访问项目的构建设置

构建设置对话框显示,其界面由三个主要区域组成。构建中的场景列表是包含在构建中的所有场景的完整列表,无论玩家是否会在游戏中访问它们。它代表了游戏中可能访问到的所有场景的总体。简而言之,如果您需要在您的游戏中添加或需要场景,那么它需要在这个列表中。最初,列表是空的。请参阅图 2.37

Building

图 2.37:构建设置对话框

您可以通过简单地从项目面板拖放场景资产到构建中的场景列表中轻松地将场景添加到列表中。对于金币收集游戏,我会将Level_01场景拖放到列表中。随着场景的增加,Unity 会根据它们在列表中的顺序自动为它们分配一个数字。0代表列表中最顶部的项目,1 代表下一个项目,以此类推。这个数字对于0项目来说很重要。最顶部的场景(场景 0)始终是起始场景。也就是说,当构建运行时,Unity 会自动从场景 0 开始执行。因此,场景 0 通常将是您的启动或介绍场景。请参阅图 2.38,向构建设置对话框添加一个级别:

Building

图 2.38:向构建设置对话框添加一个级别

接下来,请确保从构建设置对话框左下角的平台列表中选择您的目标平台。对于桌面平台,选择PC、Mac & Linux 独立,这应该是默认选中的。然后,从选项中设置目标平台下拉列表为WindowsLinuxMac OS X,具体取决于您的系统。请参阅图 2.39

构建

图 2.39:选择目标构建平台

如果您之前已经为多个平台测试过您的游戏或尝试过其他平台,如AndroidiOS,当您选择独立选项时,切换平台按钮(位于构建设置对话框的左下角)可能会变得可用。如果是这样,点击切换 平台按钮以向 Unity 确认您打算为所选平台进行构建。点击此按钮后,Unity 可能需要几分钟来配置您的资产以适应所选平台:

构建

图 2.40:切换平台

在第一次构建之前,您可能希望查看玩家设置选项以微调重要的构建参数,例如游戏分辨率、质量设置、可执行图标和信息,以及其他设置。要访问玩家设置,您可以直接从构建对话框中点击玩家设置按钮。这将在对象检查器中显示玩家设置。相同的设置也可以通过应用程序菜单访问,方法是导航到编辑 | 项目设置 | 玩家。参见图 2.4

构建

图 2.41:访问玩家设置选项

玩家设置选项中,设置公司名称产品名称,因为这些信息将被嵌入并存储在构建的可执行文件中。您还可以指定可执行文件的图标图像以及默认鼠标光标(如果需要的话)。然而,对于集合游戏,后两个设置将被留空。参见图 2.42

构建

图 2.42:设置出版商名称和产品名称

分辨率和显示选项卡特别重要,因为它指定了游戏屏幕大小以及是否在应用程序启动时显示默认的启动画面(分辨率对话框)。从此选项卡,确保已启用默认全屏选项,这意味着游戏将以系统屏幕的完整尺寸运行,而不是一个较小且可移动的窗口。此外,启用显示分辨率对话框下拉列表。参见图 2.43。当此选项启用时,您的应用程序将在启动时显示一个选项屏幕,允许用户选择目标分辨率和屏幕大小以及自定义控制。对于最终构建版本,您可能希望禁用此选项,而是在游戏中通过您自己的定制选项屏幕提供相同的设置。然而,对于测试构建,分辨率对话框可以非常有帮助。它让您能够轻松地在不同尺寸下测试您的构建。

构建

图 2.43:启用分辨率对话框

现在你可以准备进行第一次编译构建了。因此,从构建设置对话框中点击构建按钮,或者从应用程序菜单中选择文件 | 构建 & 运行。当你这样做时,Unity 会弹出一个保存对话框,询问你指定计算机上构建的目标位置。选择一个位置并选择保存,构建过程将完成。偶尔,这个过程可能会生成错误,这些错误会在控制台窗口中以红色打印出来。例如,当你保存到只读驱动器、硬盘空间不足或没有在计算机上必要的管理员权限时,可能会发生这种情况。然而,一般来说,如果你的游戏在编辑器中运行正常,构建过程就会成功。参见图 2.44

Building

图 2.44:构建和运行游戏

构建完成后,Unity 会在你的目标位置生成新的文件。对于 Windows,它生成一个可执行文件和数据文件夹。参见图 2.45。这两个都是必需的,并且相互依赖。也就是说,如果你想分发你的游戏并让其他人玩而不需要安装 Unity,那么你需要发送给用户可执行文件以及相关的数据文件夹及其所有内容。

Building

图 2.45:Unity 构建了几个文件

运行你的游戏时,如果从玩家设置中启用了显示分辨率 对话框选项,将显示分辨率对话框。从这里,用户可以选择游戏分辨率、质量和输出监视器,并配置玩家控制:

Building

图 2.46:从分辨率对话框准备运行你的游戏

点击播放按钮后,你的游戏将默认在全屏模式下运行。恭喜!你的游戏现在已经完成并构建好了,你可以发送给你的朋友和家人进行游戏测试!参见图 2.47

Building

图 2.47:全屏模式运行硬币收集游戏

但等等!当你玩完游戏后,如何退出游戏?游戏中没有退出按钮或主菜单选项。对于 Windows,你只需按键盘上的Alt + F4。对于 Mac,按cmd + Q,而对于 Ubuntu,则是Ctrl + Q

摘要

优秀的作品!到达这个阶段,你已经完成了收集硬币游戏以及你在 Unity 中的第一个游戏。在实现这一点后,你看到了 Unity 的广泛功能,包括关卡编辑和设计、预制体、粒子系统、网格、组件、脚本文件和构建设置。这已经很多了!当然,对于所有这些领域还有很多可以说的和探索的,但无论如何,我们已经将它们整合在一起制作了一个游戏。接下来,我们将着手制作一个完全不同的游戏,在这个过程中,我们将看到相同功能的创造性重用以及全新功能的引入。简而言之,我们将从入门级 Unity 开发的领域过渡到中级。

第三章项目 B – 太空射击游戏

本章现在进入了一个新的领域,因为我们开始开发我们的第二个游戏,这是一个双摇杆太空射击游戏。双摇杆类型简单指的是任何玩家输入动作跨越两个维度或轴的游戏,通常一个轴用于移动,一个轴用于旋转。例如,双摇杆游戏包括Zombies Ate My NeighborsGeometry Wars。我们的游戏将大量依赖 C#编程,正如我们将看到的。这样做的主要目的是通过示例展示,即使不使用编辑器和关卡构建工具,也可以通过 Unity 过程(即通过脚本)实现多少。我们仍然会在一定程度上使用这些工具,但在这里不会过多,这是一个故意的而不是偶然的选择。因此,本章假设您不仅完成了前两个章节中创建的游戏项目,而且对 C#脚本有良好的、基本的知识,尽管不一定是在 Unity 中。所以,让我们卷起袖子,如果有,开始制作双摇杆射击游戏。本章还涵盖了以下以及其他重要主题:

  • 生成和预制体

  • 双摇杆控制和轴向移动

  • 玩家控制器和射击机制

  • 基本敌人移动和 AI

注意

记住,以抽象的方式看待这里创建的游戏及其相关工作,即作为具有多种应用的通用工具和概念。对于您自己的项目,您可能不想制作双摇杆射击游戏,这完全可以。我无法知道您想要制作的所有类型的游戏。然而,将这里使用到的想法和工具视为可转移的,作为您可以创造性地用于您自己游戏的工具,这一点非常重要。当使用 Unity 或任何引擎工作时,能够看到这一点非常重要。

展望未来 – 完成的项目

在深入探讨双摇杆射击游戏之前,让我们先看看完成的项目的样子以及它是如何工作的。见图 3.1。即将创建的游戏将只包含一个场景。在这个场景中,玩家控制一艘可以射击迎面敌人的宇宙飞船。方向键和 WASD 键可以移动飞船在关卡中的位置,并且它总是会转向面对鼠标指针。点击左鼠标按钮将发射弹药。

展望未来 – 完成的项目

图 3.1:完成的双摇杆射击游戏

注意

如本章和下一章所讨论的,完成的TwinStickShooter项目可以在本书配套文件中的Chapter03/TwinStickShooter文件夹中找到。

本游戏的多数资源(包括声音和纹理)均来源于免费可访问的网站,OpenGameArt.org。在这里,您可以找到许多通过公共领域或创意共享许可或其他许可方式提供的游戏资源。

开始制作太空射击游戏

要开始,创建一个没有任何包或特定资源的空白 Unity 3D 项目。有关创建新项目的详细信息,请参阅第一章,硬币收集游戏 – 第一部分。这次我们将从头开始编写所有代码。一旦生成了一个项目,就创建一些基本的文件夹来从一开始就组织和结构化项目资源。这对于你在工作中跟踪文件非常重要。为TexturesScenesMaterialsAudioPrefabsScripts创建文件夹。参见图 3.2

开始制作太空射击游戏

图 3.2:创建文件夹以进行结构和组织

接下来,我们的游戏将依赖于一些图形和音频资源。这些资源包含在本书的配套文件中,位于Chapter03/Assets文件夹,也可以从OpenGameArt.org在线下载。让我们从玩家飞船、敌对飞船和星场背景的纹理开始。将Textures从 Windows 资源管理器或 Finder 拖放到Textures文件夹中的 Unity 项目面板。Unity 会自动导入和配置纹理。见图 3.3

开始制作太空射击游戏

图 3.3:导入飞船、敌人、星背景和弹药纹理资源

注意

提供的资源的使用是可选的。如果你愿意,可以创建自己的。只需将你自己的纹理拖放到包含的资产的位置,你仍然可以很好地跟随教程。

默认情况下,Unity 将图像文件导入为常规纹理,用于 3D 对象,并假设它们的像素尺寸是 2 的幂次大小(4、8、16、32、64、128、256 等等)。如果大小实际上不是这些之一,那么 Unity 将放大或缩小纹理到最近的合法大小。然而,对于应该以原始(导入)大小显示,没有任何缩放或自动调整的 2D 俯视太空射击游戏来说,这不是适当的行为。为了解决这个问题,选择所有导入的纹理,从对象检查器中,将它们的纹理类型纹理更改为精灵(2D 和 UI)。一旦更改,点击应用按钮更新设置,纹理将保留其导入的尺寸。参见图 3.4

开始制作太空射击游戏

图 3.4:更改导入纹理的纹理类型

在将纹理类型设置更改为精灵(2D 和 UI)后,也请从生成 Mip 映射框中取消勾选,以防此框被启用。这将防止 Unity 根据场景中纹理与摄像机的距离自动降低纹理质量。这确保了您的纹理保持最高质量。有关 2D 纹理设置和 Mip 映射的更多信息,请参阅在线 Unity 文档docs.unity3d.com/Manual/class-TextureImporter.html见图 3.5

开始使用太空射击游戏

图 3.5:从导入的纹理中移除 MipMapping

现在您可以将纹理轻松拖放到场景中,将它们作为精灵对象添加。您不能从项目面板拖放到视图中,但可以从项目面板拖放到层次结构面板。当您这样做时,纹理将自动作为精灵对象添加到场景中。在我们创建飞船对象的过程中,我们将频繁使用这个功能。见图 3.6

开始使用太空射击游戏

图 3.6:将精灵添加到场景中

接下来,让我们导入音乐和音效,这些内容也包含在本书的配套文件中,位于Chapter03/Assets/Audio文件夹中。这些资产是从OpenGameArt.org下载的。要导入音频,只需将文件从 Windows 资源管理器或 Mac Finder 拖放到项目面板中。当您这样做时,Unity 会自动导入并配置这些资产。您可以通过在对象检查器的预览工具栏中按播放来在 Unity 编辑器内测试音频。见图 3.7

开始使用太空射击游戏

图 3.7:在对象检查器中预览音频

与纹理文件一样,Unity 使用一组默认参数导入音频文件。这些参数通常适用于短音效,如脚步声、枪声和爆炸声,但对于较长的轨道,如音乐,它们可能存在问题,会导致长时间的水平加载时间。为了解决这个问题,在项目面板中选择音乐轨道,并在对象检查器中禁用预加载音频数据复选框。从加载类型下拉菜单中选择流式传输选项。这确保音乐轨道以流式传输而不是在关卡启动时完全加载到内存中。见图 3.8

开始使用太空射击游戏

图 3.8:配置音乐轨道以进行流式传输

创建玩家对象

我们现在已导入大多数双摇杆射击游戏资产,并准备好创建玩家飞船对象,即玩家将控制并移动的对象。创建此对象可能看似只是简单地从项目面板将相关的玩家精灵拖放到场景中这么简单的事情,但实际上并非如此。玩家是一个具有许多不同行为的复杂对象,正如我们将看到的。因此,在创建玩家时需要更加小心。要开始,通过从应用程序菜单导航到GameObject | Create Empty 在场景中创建一个空游戏对象,并将对象命名为Player。参见图 3.9

创建玩家对象

图 3.9:开始创建玩家

新创建的对象可能或可能不在世界原点 (0, 0, 0) 处居中,其旋转属性在 XYZ 轴上可能不一致。为确保完全归零变换,您可以通过在对象检查器中直接输入值来手动将这些值设置为0。然而,您可以通过单击变换组件左上角的齿轮图标并从上下文菜单中选择重置来自动将它们全部设置为0。参见图 3.10

创建玩家对象

图 3.10:重置变换组件

接下来,将Player飞船精灵(位于Textures文件夹中)从项目面板拖放到层次面板,使其成为空玩家对象的子对象。然后,将飞船精灵在X轴上旋转90度,在Y轴上旋转-90度。这使得精灵朝向其父对象的正向向量,并在地面平面上展开。游戏摄像机将采用俯视视角。参见图 3.11

创建玩家对象

图 3.11:对齐玩家飞船

您可以通过选择Player对象并查看蓝色正向向量箭头来确认飞船精灵相对于其父对象的定位是否正确。飞船精灵的前部和蓝色正向向量应该指向同一方向。如果不是,则继续将精灵旋转 90 度,直到它们对齐。这在编写玩家移动代码,使飞船朝向其注视的方向移动时将非常重要。参见图 3.12

创建玩家对象

图 3.12:蓝色箭头被称为正向向量

接下来,Player 对象应该对物理力做出反应,也就是说,Player 对象是实心的,会受到物理力的影响。它必须与其他固体碰撞,并在被击中时受到敌人弹药的伤害。为此,应向 Player 对象添加两个额外的组件,具体来说,是一个 RigidbodyCollider。要做到这一点,选择 Player 对象(而不是 Sprite 对象),从应用程序菜单导航到 Component | Physics | Rigidbody。然后,从菜单中选择 Component | Physics | Capsule Collider。这会添加一个 RigidbodyCollider。参见 图 3.13

创建玩家对象

图 3.13:向玩家对象添加 Rigidbody 和 Capsule Collider

Collider 组件用于近似对象的体积,而 Rigibody 组件则使用 Collider 来确定物理力应该如何真实地应用。让我们稍微调整一下 Capsule Collider,因为默认设置通常与预期的 Player 精灵不匹配。具体来说,调整 方向半径高度 值,直到 Capsule 包围 Player 精灵并代表玩家的体积。参见 图 3.14

创建玩家对象

图 3.14:调整飞船 Capsule Collider

默认情况下,Rigidbody 组件被配置为近似受重力影响的对象,这些对象会落到地面上,撞击并反应场景中的其他固体。这对于在空中飞行的飞船来说是不合适的。因此,应该调整 Rigidbody。具体来说,取消勾选 Use Gravity 复选框以防止对象落到地面上。此外,启用 Freeze Position Y 复选框和 Freeze Rotation Z 复选框以防止飞船在 2D 俯视游戏中沿不希望轴移动和旋转。参见 图 3.15

创建玩家对象

图 3.15:配置玩家飞船的 Rigidbody 组件

优秀的工作!我们现在已经成功配置了玩家飞船对象。当然,它仍然不会移动或做任何特定的游戏动作。这仅仅是因为我们还没有添加任何代码。这正是我们接下来要做的——使玩家对象对用户输入做出反应。

玩家输入

Player对象现在已在场景中创建,配置了RigidbodyCollider组件。然而,此对象不会对玩家控制做出反应。在双摇杆射击游戏中,玩家在两个轴向上提供输入,通常可以射击武器。这通常意味着 WASD 键盘按钮控制玩家向上、向下、向左和向右移动。此外,鼠标移动控制玩家查看和瞄准的方向,而左鼠标按钮通常用于射击武器。这是我们游戏所需的控制方案。为了实现这一点,我们需要创建一个PlayerController脚本文件。在项目面板的Scripts文件夹上右键单击,创建一个名为PlayerController.cs的新 C#脚本文件。参见图 3.16

玩家输入

图 3.16:创建玩家控制器 C# 脚本文件

PlayerController.cs脚本文件中,以下代码(如代码示例 3.1所示)应该被包含。注释跟在此示例之后:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class PlayerController : MonoBehaviour
{
  //------------------------------
  private Rigidbody ThisBody = null;
  private Transform ThisTransform = null;

  public bool MouseLook = true;
  public string HorzAxis = "Horizontal";
  public string VertAxis = "Vertical";
  public string FireAxis = "Fire1";
  public float MaxSpeed = 5f;

  //------------------------------
  // Use this for initialization
  void Awake ()
  {
    ThisBody = GetComponent<Rigidbody>();
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  // Update is called once per frame
  void FixedUpdate ()
  {
    //Update movement
    float Horz = Input.GetAxis(HorzAxis);
    float Vert = Input.GetAxis(VertAxis);
    Vector3 MoveDirection = new Vector3(Horz, 0.0f, Vert);
    ThisBody.AddForce(MoveDirection.normalized * MaxSpeed);

    //Clamp speed
    ThisBody.velocity = new Vector3(Mathf.Clamp(ThisBody.velocity.x, -MaxSpeed, MaxSpeed),
      Mathf.Clamp(ThisBody.velocity.y, -MaxSpeed, MaxSpeed),
      Mathf.Clamp(ThisBody.velocity.z, -MaxSpeed, MaxSpeed));

    //Should look with mouse?
    if(MouseLook)
    {
      //Update rotation - turn to face mouse pointer
      Vector3 MousePosWorld = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x,Input.mousePosition.y, 0.0f));
        MousePosWorld = new Vector3(MousePosWorld.x, 0.0f, MousePosWorld.z);
      //Get direction to cursor
      Vector3 LookDirection = MousePosWorld - ThisTransform.position;

      //FixedUpdate rotation
      ThisTransform.localRotation = Quaternion.LookRotation(LookDirection.normalized,Vector3.up);
    }

  }
}
//------------------------------

代码示例 3.1

以下要点总结了代码示例:

  • PlayerController类应该附加到场景中的Player对象上。总体而言,它接受玩家的输入并将控制太空船的移动。

  • 当对象在关卡开始时创建时,会调用一次Awake函数。在此函数期间,检索了两个组件,即用于控制器玩家旋转的Transform组件和用于控制器玩家移动的Rigidbody组件。Transform组件可以通过Position属性来控制玩家移动,但这会忽略碰撞和固体物体。相比之下,Rigidbody组件可以防止玩家对象穿过其他固体物体。

  • FixedUpdate函数在物理系统每次更新时都会被调用一次,这是每秒固定次数的调用。FixedUpdateUpdate不同,Update函数每次帧调用一次,并且会根据帧率的变化而变化。如果你需要通过物理系统控制对象,使用如Rigidbody之类的组件,那么你应该始终在FixedUpdate中这样做,而不是在Update中。这是 Unity 的一个约定,你应该记住以获得最佳效果。

  • Input.GetAxis 函数在每次 FixedUpdate 调用中都会被调用,用于从输入设备(如键盘或游戏手柄)读取轴向输入数据。此函数从两个命名轴读取数据,Horizontal(左右)和Vertical(上下)。这些轴在一个归一化空间中工作,范围从 -11。这意味着当按下并保持左键时,Horizontal 轴返回 -1,而当按下并保持右键时,Horizontal 轴返回 1。值为 0 表示没有按下相关键或同时按下左右键,相互抵消。对于 Vertical 轴,向上表示 1,向下表示 -1,没有按键按下对应于 0。有关 GetAxis 函数的更多信息,可以在 Unity 文档的在线文档中找到,网址为 docs.unity3d.com/ScriptReference/Input.GetAxis.html

  • Rigidbody.AddForce 函数用于对 Player 对象施加物理力,使其沿特定方向移动。AddForce 编码一个速度,通过特定的强度将对象移动到特定方向。方向编码在 MoveDirection 向量中,该向量基于来自 HorizontalVertical 轴的玩家输入。这个方向乘以我们的最大速度,以确保对象以所需的速度移动。有关 AddForce 的更多信息,请参阅 Unity 在线文档,网址为 docs.unity3d.com/ScriptReference/Rigidbody.AddForce.html

  • Camera.ScreenToWorldPoint 函数用于将游戏窗口中鼠标光标的屏幕位置转换为游戏世界中的位置,为玩家提供一个目标目的地进行观察。此代码负责使玩家始终看向鼠标光标。然而,正如我们很快将看到的,需要对代码进行一些调整才能使其正常工作。有关 ScreenToWorldPoint 的更多信息,请参阅 Unity 在线文档,网址为 docs.unity3d.com/ScriptReference/Camera.ScreenToWorldPoint.html

配置游戏相机

上述代码允许您控制 Player 对象,但存在一些问题。其中之一是玩家似乎没有面向鼠标光标的位置,尽管我们的代码旨在实现这种行为。原因是默认情况下,相机没有配置为适用于俯视 2D 游戏所需的配置。我们将在本节中修复这个问题。要开始,场景相机应该以俯视视角查看场景。为此,通过单击 ViewCube(位于 Scene 视口右上角的向上箭头),将 Scene 视口切换到俯视 2D 视图。这将切换您的视口到俯视图。参见 图 3.17

配置游戏相机

图 3.17:视图立方体可以改变视口视角

你可以看到视口处于俯视图,因为视图立方体将列出Top作为当前视图。见图 3.18:

配置游戏相机

图 3.18:场景视口中的俯视图

从这里,你可以使场景相机与视口相机完全一致,为你提供游戏中的即时俯视图。为此,在场景(或从层次面板)中选择相机,然后从应用程序菜单中选择GameObject | Align With View。见图 3.19:

配置游戏相机

图 3.19:将相机与场景视口对齐

这使你的游戏看起来比以前好得多,但仍然存在问题。当游戏运行时,飞船仍然没有按照预期看向鼠标光标。这是因为相机是透视相机,屏幕点与世界点之间的转换导致了意外的结果。我们可以通过将相机更改为正交相机来解决这个问题,这是一个真正的 2D 相机,它不允许透视失真。为此,在场景中选择相机,然后在对象检查器中,将投影设置从透视更改为正交

配置游戏相机

图 3.20:将相机切换到正交模式

每个正交相机在对象检查器中都有一个大小字段,而透视相机则没有。该字段控制世界视图中多少单位对应屏幕上的像素。我们希望世界单位与像素之间有一个 1:1 的比例或关系,以确保我们的纹理以正确的尺寸显示,并且光标移动产生预期效果。我们游戏的目标分辨率将是全高清,即 1920 x 1080,其宽高比为 16:9。对于这个分辨率,正交的大小应该是5.4。这个值的原因超出了本书的范围,但得出这个值的公式是屏幕高度(以像素为单位)/ 2 / 100。因此,1080 / 2 / 100 = 5.4。见图 3.21:

配置游戏相机

图 3.21:为 1:1 像素到屏幕比例更改正交大小

最后,确保你的游戏标签视图配置为以16:9宽高比显示游戏。如果不是,点击游戏视图左上角的宽高比下拉列表,并选择16:9选项。见图 3.22:

配置游戏相机

图 3.22:以 16:9 宽高比显示游戏

现在尝试运行游戏,你将有一个基于 WASD 输入移动并转向面对鼠标光标的玩家飞船。干得好!见图 3.23。游戏真的开始成形了。然而,还有很多工作要做。

配置游戏相机

图 3.23:转向面对光标!

边界锁定

在预览到目前为止的游戏时,飞船可能看起来太大。我们可以通过更改Player对象的比例来轻松解决这个问题。我在XYZ轴上使用了0.5的值。见图 3.24。然而,即使比例更合理,仍然存在一个问题。具体来说,玩家可以无限制地移动到屏幕边界之外。这意味着玩家可以飞到远处,消失在视野中,并且永远不再被看到。相反,相机应该保持静止,玩家的移动应该限制在相机视图或边界内,以确保它永远不会退出视图。

边界锁定

图 3.24:调整玩家大小

实现边界锁定有多种方法,其中大多数涉及脚本编写。一种方法是将Player对象的位置值简单地限制在指定的范围内,即最小值和最大值。考虑一下名为BoundsLock的新 C#类,如代码示例 3.2所示。此脚本文件应附加到玩家身上。

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class BoundsLock : MonoBehaviour 
{
  //------------------------------
  private Transform ThisTransform = null;
  public Vector2 HorzRange = Vector2.zero;
  public Vector2 VertRange = Vector2.zero;
  //------------------------------
  // Use this for initialization
  void Awake ()
  {
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  // Update is called once per frame
  void LateUpdate () 
  {
    //Clamp position
    ThisTransform.position = new Vector3(Mathf.Clamp(ThisTransform.position.x, HorzRange.x, HorzRange.y),
    ThisTransform.position.y,
    Mathf.Clamp(ThisTransform.position.z, VertRange.x, VertRange.y));
  }
  //------------------------------
}
//------------------------------

代码示例 3.2

以下是对代码示例的总结:

  • LateUpdate函数总是在所有FixedUpdateUpdate调用之后被调用,允许对象在渲染到屏幕之前修改其位置。有关LateUpdate的更多信息,请参阅docs.unity3d.com/ScriptReference/MonoBehaviour.LateUpdate.html

  • Mathf.Clamp函数确保指定的值在最小值和最大值范围内。

  • 要使用BoundsLock脚本,只需将文件拖放到Player对象上,并指定其位置的最小值和最大值。这些值以世界坐标指定,可以通过临时移动Player对象到相机的极限位置并记录其从变换组件的位置来确定:

代码示例 3.2

图 3.25:设置边界锁定

现在通过工具栏上的播放按钮运行游戏测试。玩家飞船应该保持在视野中,并且无法移出屏幕。太棒了!

健康值

玩家飞船和敌人都需要健康值。健康值是衡量角色在场景中的存在感和合法性的指标,通常以 0-100 之间的值来衡量。0 表示死亡,100 表示满血。虽然健康值在许多方面都是针对每个实例特定的(玩家有一个独特的健康条,每个敌人也有自己的),但玩家和敌人健康值在行为上有很多共同之处,因此将健康值编码为一个单独的组件和类,可以附加到所有需要健康值的对象上是有意义的。考虑代码示例 3.3,它应附加到玩家和所有敌人或需要健康值的对象上。注释如下:

using UnityEngine;
using System.Collections;
//------------------------------
public class Health : MonoBehaviour
{
  public GameObject DeathParticlesPrefab = null;
  private Transform ThisTransform = null;
  public bool ShouldDestroyOnDeath = true;
  //------------------------------
  void Start()
  {
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  public float HealthPoints
  {
    get
    {
      return _HealthPoints;
    }

    set
    {
      _HealthPoints = value;

      if(_HealthPoints <= 0)
      {
        SendMessage("Die", SendMessageOptions.DontRequireReceiver);

        if(DeathParticlesPrefab != null)
          Instantiate(DeathParticlesPrefab, ThisTransform.position, ThisTransform.rotation);

        if(ShouldDestroyOnDeath)
          Destroy(gameObject);
      }
    }
  }
  //------------------------------
  [SerializeField]
  private float _HealthPoints = 100f;
}
//------------------------------

代码示例 3.3

以下是对代码示例的总结:

  • Health 类通过一个 private 变量 _HealthPoints 维护对象的生命值,该变量通过 C# 属性 HealthPoints 访问。这个属性具有 getset 访问器,用于返回和设置 Health 变量。

  • _HealthPoints 变量被声明为 SerializedField,这使得其值在 Inspector 中可见。这有助于我们在运行时查看玩家的生命值,并调试和测试代码的效果。

  • Health 类是事件驱动编程的一个例子。这是因为该类本可以在 Update 函数中持续检查对象的生命状态;检查对象的生命值是否下降到 0 以判断其是否已死亡。相反,死亡检查是在 C# 属性 set 方法中进行的。这样做是有道理的,因为 set 是生命值唯一可能改变的地方。这意味着 Unity 在每一帧中节省了大量工作。这是一个很好的性能提升!

  • Health 类使用 SendMessage 函数。这个函数允许你通过指定函数名作为字符串来调用任何附加到对象上的组件的任何公共函数。在这种情况下,一个名为 Die 的函数将在每个附加到对象上的组件上执行(如果存在这样的函数)。如果没有匹配名称的函数,则该组件不会发生任何操作。这是一个快速简单的方法,可以在不使用任何多态的情况下以类型无关的方式在对象上运行自定义行为。缺点是 SendMessage 在内部使用一个称为 Reflection 的过程,这个过程很慢,并且会降低性能。因此,SendMessage 应该仅在不频繁的情况下使用,例如仅在死亡事件和类似事件中,而不是每帧都使用。有关 SendMessage 的更多信息,可以在 Unity 在线文档中找到,网址为 docs.unity3d.com/ScriptReference/GameObject.SendMessage.html

  • 当生命值下降到 0 以下,触发死亡条件时,代码将实例化一个死亡粒子系统以显示死亡效果(关于这一点稍后会有更多说明)。

Health 脚本附加到玩家飞船上时,它会在 Inspector 中作为一个组件出现。它包含一个用于 Death Particles Prefab 的字段。这是一个可选字段(它可以设置为 null),用于指定在对象死亡时生成粒子系统的粒子系统。这使得在对象死亡时轻松创建爆炸或血液飞溅效果。请参见 图 3.26

代码示例 3.3

图 3.26:附加 Health 脚本

死亡与粒子

在这个双摇杆射击游戏中,玩家和敌人都是宇宙飞船。当它们被摧毁时,应该以火球的形式爆炸。这确实是唯一可能令人信服的效果。为了实现爆炸效果,我们可以使用粒子系统。这简单指的是一种具有两个主要部分的特殊对象,即软管(或发射器)和粒子。发射器指的是将新粒子生成到世界中的部分,而粒子是许多小对象或碎片,一旦生成,就会移动并沿着自己的轨迹移动。简而言之,粒子系统非常适合创建雨、雪、雾、闪光和爆炸。我们可以通过使用菜单选项GameObject | 粒子系统从头开始创建自己的粒子系统,或者我们可以使用 Unity 附带的所有预制的粒子系统。让我们使用一些预制的粒子系统。为此,通过从应用程序菜单导航到资产 | 导入包 | 粒子系统ParticleSystems包导入到项目中。见图 3.27

死亡与粒子

图 3.27:将粒子系统导入到项目中

导入对话框出现后,请保持所有设置为默认值,并简单地点击导入以导入完整包,包括所有粒子系统。ParticleSystems将被添加到项目面板中的标准资产 | 粒子系统 | 预制体文件夹中。见图 3.28。您可以通过将每个预制体简单地拖放到场景中来测试每个粒子系统。请注意,您只能在场景视图中选择粒子系统时预览它。

死亡与粒子

图 3.28:导入到项目面板中的粒子系统

图 3.28 可以看出,爆炸 系统包含在默认资源中,这是一个好消息!为了测试,我们只需将爆炸拖放到场景中,在工具栏上按下播放,就可以看到爆炸效果。很好!我们几乎完成了,但还有一些工作要做。我们已经看到,有一个合适的粒子系统可用,并且我们可以直接将这个系统拖放到 Inspector 中的 Health 组件的 Death Particles Prefab 槽中。这在技术上是可以工作的:当玩家或敌人死亡时,爆炸系统将被生成,产生爆炸效果。然而,粒子系统永远不会被销毁!这是问题所在,因为,在每次敌人死亡时,都会生成一个新的粒子系统。这可能导致,在经历了多次死亡后,场景中充满了未使用的粒子系统。我们不希望这样;场景中充满了闲置的对象,这对性能和内存使用都是不利的。为了解决这个问题,我们将稍微修改爆炸系统,创建一个新的、修改过的预制件,以满足我们的需求。为了创建这个预制件,将现有的爆炸系统拖放到场景中的任何位置,并将其放置在世界原点。参见 图 3.29

死亡和粒子

图 3.29:将爆炸系统添加到场景中进行修改

接下来,我们必须细化粒子系统,使其在实例化后不久自行销毁。通过从这个配置创建一个预制件,每个生成的爆炸最终都会自行销毁。为了使对象在指定的时间间隔后自行销毁,我们将创建一个新的 C# 脚本。我将把这个脚本命名为 TimeDestroy.cs。参考 代码示例 3.4 中的以下代码:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class TimedDestroy : MonoBehaviour 
{
  public float DestroyTime = 2f;

  //------------------------------
  // Use this for initialization
  void Start ()
  {
    Invoke("Die", DestroyTime);
  }

  void Die () 
  {
    Destroy(gameObject);
  }
  //------------------------------
}
//------------------------------

代码示例 3.4

以下要点总结了代码示例:

  • TimeDestroy 类简单地在指定的时间间隔 (DestroyTime) 过去后销毁它附加的对象。

  • Invoke 函数在 Start 事件中被调用。Invoke 将在指定的时间间隔过去后执行指定名称的函数一次,并且仅执行一次。间隔以秒为单位。

  • SendMessage 类似,Invoke 函数依赖于 Reflection。因此,为了获得最佳性能,应谨慎使用。

  • Die 函数将在指定的时间间隔后由 Invoke 执行,以销毁 gameobject(如粒子系统)。

现在,将 TimedDestroy 脚本文件拖放到场景中的爆炸粒子系统中,然后在工具栏上按下播放以测试代码是否工作,并且对象在指定的时间间隔后销毁,这个时间间隔可以从 Inspector 中进行调整。参见 图 3.30

代码示例 3.4

图 3.30:将 TimeDestroy 脚本添加到爆炸粒子系统中

TimeDestroy 脚本应在延迟过期后移除爆炸粒子系统。因此,让我们从这个修改版本中创建一个新的独立预制体。为此,在层次结构面板中将爆炸系统重命名为 ExplosionDestroy,然后从层次结构拖放到 Prefabs 文件夹中的项目面板。Unity 自动创建一个新的预制体,代表修改后的粒子系统。请参阅 图 3.31

代码示例 3.4

图 3.31:创建定时爆炸预制体

现在,将新创建的预制体从项目面板拖放到对象检查器玩家健康组件的死亡粒子系统槽位。这确保了当玩家死亡时预制体会被实例化。请参阅 图 3.32

代码示例 3.4

图 3.32:配置健康脚本

如果你现在运行游戏,你会看到你不能启动玩家死亡事件来测试粒子系统生成。场景中没有任何东西可以摧毁或伤害玩家,而且你不能从检查器中手动将健康点数设置为0,这种方式会被 C# 属性的 set 函数检测到。然而,目前我们可以将一些测试死亡功能插入到 Health 脚本中,当按下空格键时触发立即死亡。请参阅 代码示例 3.5 以获取修改后的 Health 脚本:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class Health : MonoBehaviour
{
  public GameObject DeathParticlesPrefab = null;
  private Transform ThisTransform = null;
  public bool ShouldDestroyOnDeath = true;
  //------------------------------
  void Start()
  {
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  public float HealthPoints
  {
    get
    {
      return _HealthPoints;
    }

    set
    {
      _HealthPoints = value;

      if(_HealthPoints <= 0)
      {
        SendMessage("Die", SendMessageOptions.DontRequireReceiver);

        if(DeathParticlesPrefab != null)
          Instantiate(DeathParticlesPrefab, ThisTransform.position, ThisTransform.rotation);

        if(ShouldDestroyOnDeath)Destroy(gameObject);
      }
    }
  }
  //------------------------------
  void Update()
  {
    if(Input.GetKeyDown(KeyCode.Space))
      HealthPoints = 0;
  }
  //------------------------------
  [SerializeField]
  private float _HealthPoints = 100f;
}
//------------------------------

现在运行游戏,使用修改后的 Health 脚本,你可以通过按下键盘上的空格键来触发玩家的立即死亡。当你这样做时,玩家对象将被销毁,粒子系统也会生成,直到计时器将其销毁。做得好!我们现在有一个可玩、可控制的玩家角色,它支持健康和死亡功能。一切看起来都很顺利。请参阅 图 3.33

代码示例 3.4

图 3.33:触发爆炸粒子系统

敌人

下一步是为玩家创建可以射击和摧毁的东西,这也可以摧毁我们,即敌人角色。这些以游荡飞船的形式出现,将定期生成到场景中,并跟随玩家,越来越近。本质上,每个敌人代表一个由多个行为共同作用而成的复杂体,这些应该作为单独的脚本实现。让我们逐一考虑它们:

  • 健康:每个敌人支持健康功能。它们在场景开始时具有指定数量的健康值,当健康值降至 0 以下时将被销毁。我们已经有了一个 Health 脚本来处理这种行为。

  • 移动:每个敌人将始终处于运动状态,沿着前进轨迹直线行进。也就是说,每个敌人将不断朝向其注视的方向前进。

  • 转向:即使玩家移动,每个敌人也会旋转并转向玩家。这确保了敌人始终面向玩家,并且与移动功能结合,将始终朝向玩家移动。

  • 得分:每个敌人在被摧毁时都会给玩家奖励一个分数值。因此,敌人的死亡会增加玩家的分数。

  • 伤害:每个敌人在碰撞时都会对玩家造成伤害。敌人不能射击,但会在接近时伤害玩家。

现在我们已经确定了适用于敌人的行为范围,让我们在场景中创建一个敌人。我们将创建一个特定的敌人,从该敌人创建一个预制件,并将其用作实例化许多敌人的基础。首先,在场景中选择玩家角色,并使用 Ctrl + D 或从应用程序菜单中选择 编辑 | 复制 来复制对象。这最初创建了一个第二个玩家。参见 图 3.34

敌人

图 3.34:复制玩家对象

将对象重命名为 Enemy,并确保它没有被标记为 Player,因为场景中应该只有一个带有 Player 标签的对象,即真正的玩家。此外,暂时禁用 Player 游戏对象,以便我们可以在 场景 选项卡中更清晰地关注 Enemy 对象。参见 图 3.35

敌人

图 3.35:如果适用,从敌人中移除玩家标签

选择复制的敌人精灵子对象,并在 对象检查器 中点击 Sprite 字段,选择 Sprite Renderer 组件的新精灵。为敌人角色选择一个较暗的帝国飞船精灵,精灵将在视图中更新。参见 图 3.36

敌人

图 3.36:为精灵渲染器组件选择精灵

在将精灵更改为敌人角色后,您可能需要调整旋转值,以确保精灵与父对象的向前向量对齐,确保精灵朝向与向前向量相同的方向。参见 图 3.37

敌人

图 3.37:调整敌人精灵的旋转

现在,选择敌人的父对象,并移除 RigidbodyPlayerControllerBoundsLock 组件,但保留 Health 组件,因为敌人应该支持生命值。参见 图 3.38。此外,您可以随意调整 Capsule Collider 组件的大小,以更好地逼近 Enemy 对象。

敌人

图 3.38:调整敌人精灵的旋转

让我们从编写敌人代码开始,重点关注移动。具体来说,敌人应该以指定的速度持续向前移动。为了实现这一点,创建一个名为 Mover.cs 的新脚本文件。这个文件应该附加到 Enemy 对象上。该类的代码包含在 代码示例 3.6 中。

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class Mover : MonoBehaviour
{
  //------------------------------
  private Transform ThisTransform = null;
  public float MaxSpeed = 10f;
  //------------------------------
  // Use this for initialization
  void Awake () 
  {
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  // Update is called once per frame
  void Update () 
  {
    ThisTransform.position += ThisTransform.forward * MaxSpeed * Time.deltaTime;
  }
  //------------------------------
}
//------------------------------

代码示例 3.6

以下要点总结了代码示例:

  • Mover 脚本以指定的速度(每秒 MaxSpeed)沿着其前进向量移动一个对象。为此,它使用 Transform 组件。

  • Update 函数负责更新对象的位置。简而言之,它将前进向量乘以对象速度,并将其加到现有位置上,以使对象沿着其视线移动得更远。Time.deltaTime 值用于使运动帧率独立——每秒移动对象而不是每帧移动。有关 deltaTime 的更多信息,请参阅在线 Unity 文档,网址为 docs.unity3d.com/ScriptReference/Time-deltaTime.html

在工具栏上按播放按钮以测试运行您的代码。频繁测试此类代码总是好的实践。您的敌人可能移动得太慢或太快。如果是这样,停止播放以退出游戏模式,并在场景中选择敌人。从 对象检查器 中调整 Mover 组件的 最大速度 值。参见 图 3.39

代码示例 3.6

图 3.39:调整敌人速度

除了直线移动外,敌人还应不断转向以面对玩家,无论他们移动到哪里。为了实现这一点,我们需要另一个与玩家控制器脚本类似工作的脚本文件。当玩家转向面对光标时,敌人转向面对玩家。这种功能应编码在一个名为 ObjFace.cs 的新脚本文件中。此脚本应附加到敌人上。参见 代码示例 3.7

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class ObjFace : MonoBehaviour
{
  //------------------------------
  public Transform ObjToFollow = null;
  public bool FollowPlayer = false;
  private Transform ThisTransform = null;
  //------------------------------
  // Use this for initialization
  void Awake () 
  {
    //Get local transform
    ThisTransform = GetComponent<Transform>();

    //Should face player?
    if(!FollowPlayer)return;

    //Get player transform
    GameObject PlayerObj = GameObject.FindGameObjectWithTag("Player");
    if(PlayerObj != null)
      ObjToFollow = PlayerObj.GetComponent<Transform>();
  }
  //------------------------------
  // Update is called once per frame
  void Update ()
  {
    //Follow destination object
    if(ObjToFollow==null)return;

    //Get direction to follow object
    Vector3 DirToObject = ObjToFollow.position - ThisTransform.position;

    if(DirToObject != Vector3.zero)
      ThisTransform.localRotation = Quaternion.LookRotation(DirToObject.normalized,Vector3.up);
  }
  //------------------------------
}
//------------------------------

代码示例 3.7

以下要点总结了代码示例:

  • ObjFace 脚本将始终旋转对象,使其前进向量指向场景中的目标点。

  • Awake 事件中,调用 FindGameObjectWithTag 函数以获取场景中标记为玩家的唯一对象的引用,这应该是玩家飞船。玩家代表敌人对象的默认注视目标。

  • Update 函数每帧自动调用一次,并从对象位置到目标位置生成一个位移向量,这代表了对象应该朝向的方向。Quaternion.LookRotation 函数接受一个方向向量,并将对象旋转以使前进向量与提供的方向对齐。这使对象始终朝向目标。有关 LookRotation 的更多信息,请参阅在线 Unity 文档,网址为 docs.unity3d.com/ScriptReference/Quaternion.LookRotation.html

看起来非常好!然而,在测试此代码之前,请确保场景中的Player对象被标记为Player,处于启用状态,并且敌方角色与玩家偏移。务必从对象检查器中的Obj Face组件启用跟随玩家复选框。当你这样做时,敌方角色将始终转向面对玩家。参见图 3.40

代码示例 3.7

图 3.40:敌方飞船向玩家移动

现在,如果敌方角色最终与玩家发生碰撞,它应该造成伤害并可能杀死玩家。为了实现这一点,必须检测敌方角色和玩家之间的碰撞。让我们先配置敌方角色。选择敌方对象,并在对象检查器中,在胶囊碰撞器组件中启用是触发器复选框。这会将胶囊碰撞器组件更改为允许玩家和敌方角色之间进行真实交叠,并防止 Unity 阻止碰撞。参见图 3.41

代码示例 3.7

图 3.41:将敌方碰撞器更改为触发器

接下来,我们将创建一个脚本,用于检测碰撞,并在发生碰撞时以及碰撞状态持续期间不断对玩家造成伤害。请参考以下代码(ProxyDamage.cs),该代码应附加到敌方角色上:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class ProxyDamage : MonoBehaviour
{
  //------------------------------
  //Damage per second
  public float DamageRate = 10f;
  //------------------------------
  void OnTriggerStay(Collider Col)
  {
    Health H = Col.gameObject.GetComponent<Health>();

    if(H == null)return;

    H.HealthPoints -= DamageRate * Time.deltaTime;
  }
  //------------------------------
}
//------------------------------

代码示例 3.8

以下要点总结了代码示例:

  • ProxyDamage脚本应附加到敌方角色上,并将对任何具有Health组件的碰撞对象造成伤害。

  • OnTriggerStay事件在交叠状态持续期间每帧被调用一次。在此函数中,Health组件的HealthPoints值会减少DamageRate(以每秒伤害量衡量)。

在将ProxyDamage脚本附加到敌方角色后,使用对象检查器设置代理伤害组件的损伤率。这表示在碰撞期间,每秒应该减少玩家多少健康值。为了增加挑战性,我将值设置为100健康点。参见图 3.42

代码示例 3.8

图 3.42:设置代理伤害组件的损伤率

现在我们来测试一下。在工具栏上按播放,尝试玩家和敌方角色的碰撞。一秒后,玩家应该被摧毁。一切进展顺利。然而,为了使游戏更具挑战性,我们需要不止一个敌方角色。

敌方生成

为了使游戏级别既有趣又具有挑战性,我们需要的不仅仅是单个敌人。实际上,对于一个本质上无限的游戏,我们需要不断地添加敌人。这些敌人应该随着时间的推移逐渐添加。本质上,我们需要敌人的定期或间歇性生成,本节将添加这一功能。然而,在我们能够做到这一点之前,我们需要从敌人对象制作一个预制件。这可以轻松实现。在层次面板中选择敌人,然后将其拖放到Prefabs文件夹中的项目面板。这样就创建了一个Enemy预制件。参见图 3.43

敌人生成

图 3.43:创建敌人预制件

现在,我们将创建一个新的脚本(Spawner.cs),该脚本将在玩家飞船指定半径内随时间生成新的敌人。这个脚本应该附加到场景中的一个新空游戏对象上。参见代码示例 3.9

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class Spawner : MonoBehaviour
{
  public float MaxRadius = 1f;
  public float Interval = 5f;
  public GameObject ObjToSpawn = null;
  private Transform Origin = null;
  //------------------------------
  void Awake()
  {
  Origin = GameObject.FindGameObjectWithTag("Player").GetComponent<Transform>();
  }
  //------------------------------
  // Use this for initialization
  void Start () 
  {
    InvokeRepeating("Spawn", 0f, Interval);
  }
  //------------------------------
  void Spawn () 
  {
    if(Origin == null)return;

    Vector3 SpawnPos = Origin.position + Random.onUnitSphere * MaxRadius;
    SpawnPos = new Vector3(SpawnPos.x, 0f, SpawnPos.z);
    Instantiate(ObjToSpawn, SpawnPos, Quaternion.identity);
  }
  //------------------------------
}
//------------------------------

代码示例 3.9

以下要点总结了代码示例:

  • Spawner类将在每个间隔内生成ObjToSpawn(一个预制件)的实例。间隔以秒为单位测量。生成的对象将在以中心点Origin为起点的随机半径内创建。

  • Start事件期间,InvokeRepeating函数被调用,以在每个间隔内持续执行Spawn函数来生成新的敌人。

  • Spawn函数将在场景中创建敌人的实例,这些实例从起点以随机半径生成。一旦生成,敌人将像正常一样行动,朝向玩家进行攻击。

Spawner类是一个全局行为,适用于整个场景。它不依赖于特定的玩家,也不依赖于任何特定的敌人。因此,它应该附加到一个空的游戏对象上。通过从应用程序菜单中选择GameObject | Create Empty来创建一个这样的对象。将其命名为Spawner,并将Spawner脚本附加到它上。参见图 3.44

代码示例 3.9

图 3.44:创建空游戏对象

将其添加到场景后,从对象检查器中,将Enemy预制件拖放到Spawner组件中的Obj To Spawn字段。将间隔设置为2秒,并将最大半径增加到5。参见图 3.45

代码示例 3.9

图 3.45:为敌人对象配置 Spawner

现在(掌声),让我们尝试这个级别。在工具栏上按播放,进行游戏测试运行。你现在应该有一个带有完全可控制玩家角色的级别,周围是不断增长的追踪敌人飞船的军队!干得好!参见图 3.46

代码示例 3.9

图 3.46:生成的敌人对象向玩家移动

摘要

做得很好,已经走到这一步了!太空射击游戏现在真的有形了,它拥有一个可控的玩家角色,该角色依赖于原生物理,双摇杆机制,敌舰,以及用于生成敌人的场景级生成器。所有这些元素加在一起仍然不能构成一个游戏:我们无法射击,无法增加分数,也无法摧毁敌人。这些问题需要解决,还有我们肯定会遇到的其他技术问题。尽管如此,我们现在有一个坚实的基础来继续前进,在下一章中,我们将完成射击游戏。

第四章:继续太空射击游戏

本章从上一章继续讲述创建双摇杆太空射击游戏。目前,我们有一个可以工作的游戏。至少,玩家可以使用两个轴来控制一艘太空船:移动和旋转。键盘上的 WASD 键控制移动(上、下、左、右),鼠标光标控制旋转——太空船总是旋转以面对光标。除了玩家控制外,关卡还包含在固定间隔内生成的敌人角色,它们在关卡中飞行,并带着敌意向玩家移动。最后,玩家和敌人都支持健康组件,这意味着它们都容易受到伤害,可以被摧毁。然而,目前玩家缺少两个重要功能:不能开火和不能增加分数。本章将解决这些问题以及其他更多问题。正如我们将看到的,开火武器代表了一个特别有趣的问题。总体而言,本章涵盖了以下主题:

  • 武器和弹药生成

  • 内存管理和池化

  • 声音和音频

  • 得分

  • 调试和测试

  • 构建和分发

注意

到目前为止的完整项目可以在书的配套文件中的Chapter04/Start文件夹中找到。如果您还没有自己的项目,可以从这里开始,并跟随本章进行。

枪械和枪塔

让我们详细地解决武器问题。具体来说,关卡包含一个玩家和敌人飞船。玩家必须射击敌人,但现在还不能这样做。参见图 4.1。仔细思考武器后,我们确定了三个主要的概念或需要开发的事物。首先,有一个生成器或生成器——当按下开火按钮时,在场景中实际发射弹药的对象。其次,有弹药本身,一旦生成,就会在关卡中自行移动。第三,弹药能够与其他对象碰撞并造成伤害。

枪械和枪塔

图 4.1:到目前为止的游戏

按顺序解决每个区域,我们首先从炮塔开始——子弹生成和发射的点。对于这个游戏,玩家将只有一个炮塔,但理想情况下,游戏应该支持添加更多,如果需要的话,允许玩家进行双发射或更多!要创建第一个炮塔,从应用程序菜单中选择GameObject | Create Empty,在场景中添加一个新的空游戏对象。将其命名为Turret。然后,将Turret对象定位在太空船的前面,确保蓝色前向向量箭头指向弹药将被发射的方向。最后,通过拖放它到Hierarchy面板中,使炮塔成为太空船的子对象。参见图 4.2

枪械和枪塔

图 4.2:将炮塔对象作为太空船的子对象定位

为弹药创建一个作为生成位置的 Turret 对象是一个很好的开始,但为了实际发射弹药,我们需要一个弹药对象。具体来说,我们将创建一个 Ammo 预制件,在需要时可以实例化为弹药。我们将在下一步进行操作。

弹药预制件

当玩家按下射击按钮时,宇宙飞船应该在场景中发射弹药对象。这些对象将基于一个 Ammo 预制件。现在让我们创建这个预制件。首先,我们将配置用作弹药图形的纹理。在项目面板中打开 Textures 文件夹,并选择 Ammo 纹理。这个纹理展示了几个不同版本的弹药精灵,并排排列。参见图 4.3。当发射弹药时,我们不想显示完整的纹理;相反,我们只想显示其中的一张图片或图片作为动画序列逐帧播放。

弹药预制件

图 4.3:准备创建弹药预制件

目前,Unity 将纹理(以及每个弹药元素)识别为一个完整的单元。然而,我们可以使用精灵编辑器来分离每个部分。为此,在项目中选择纹理(如果尚未选择),然后(从对象检查器)将精灵模式下拉菜单从单个更改为多个。这表示纹理空间中包含多个精灵。参见图 4.4

弹药预制件

图 4.4:选择具有多个精灵的纹理中的多个精灵

点击应用按钮,然后从对象检查器中点击精灵编辑器按钮。这会打开精灵编辑器,允许您分离每个精灵。为此,点击并拖动鼠标选择每个精灵,确保锚点与对象中心对齐。参见图 4.5。然后,点击应用以接受更改。

弹药预制件

图 4.5:在精灵编辑器中分离多个精灵

在接受精灵编辑器中的更改后,Unity 会自动将相关的精灵切割成单独的单位,每个单位现在都可以在项目面板中作为一个单独的对象选择。点击纹理旁边的右箭头,纹理内的所有精灵都会向外展开。参见图 4.6

弹药预制件

图 4.6:展开纹理内的所有精灵

现在,将项目面板中的一个精灵拖放到场景中,通过层次结构面板进行。这样做后,它将被添加为一个精灵对象。精灵本身可能最初没有面向游戏摄像头向上。如果是这样,旋转精灵 90 度直到它看起来正确。参见图 4.7

弹药预制件

图 4.7:对齐弹药精灵

现在在场景中创建一个新的空游戏对象(从应用程序菜单 GameObject | Create Empty),并将其重命名为 Ammo。使这个新对象成为 Ammo_Sprite 的父对象,并确保其局部前进向量指向弹药应该移动的方向。我们将很快在弹药上重用上一章中创建的 Mover 脚本,使其移动。

弹药预制件

图 4.8:构建弹药对象

项目 面板中的 Mover.cs 脚本拖放到 层次 面板中的 Ammo 父对象,以将其添加为组件。然后,选择 Ammo 对象,从 对象检查器 中,将 移动器 组件中的弹药 最大速度 更改为 7。最后,向对象添加一个 盒子碰撞器 以近似其体积(从应用程序菜单 组件 | 物理 | 盒子碰撞器),然后在视口中通过按工具栏上的播放按钮测试所有这些。Ammo 对象应该向前射击,就像是从武器中发射出来的一样。如果它向上或向下移动不正确,那么请确保父对象已旋转,使其蓝色前进向量真正指向前方。参见 图 4.9

弹药预制件

图 4.9:使用弹药预制件(移动器和碰撞器)前进

接下来,将一个刚体组件添加到弹药中,使其成为 Unity 物理系统的一部分。为此,选择 Ammo 对象,从应用程序菜单导航到 组件 | 物理 | 刚体。然后,在 检查器中的 刚体 组件中,取消选中 使用重力 复选框,以防止弹药在游戏过程中掉落到地面。就我们的目的而言,重力不需要作用于弹药,因为它应该简单地沿着路径移动,最终被销毁。这突出了游戏开发中的一般重要观点:现实世界的物理不一定需要精确地应用于每个对象。我们只需要足够的物理效果,使对象在玩家看来是正确的。参见 图 4.10

弹药预制件

图 4.10:从弹药对象中移除重力

除了添加 Mover 脚本和物理组件外,我们还需要弹药具有独特的表现。具体来说,它应该损坏与它碰撞的对象,并且在碰撞时应该销毁或禁用自己。为了实现这一点,必须创建一个新的脚本文件,Ammo.cs。整个代码包含在 代码示例 4.1 中,如下所示:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class Ammo : MonoBehaviour
{
  public float Damage = 100f;
  public float LifeTime = 2f;
  //------------------------------
  void OnEnable()
  {
    CancelInvoke();
    Invoke("Die", LifeTime);
  }
  //------------------------------
  // Update is called once per frame
  void OnTriggerEnter(Collider Col)
  {
    //Get health component
      Health H = Col.gameObject.GetComponent<Health>();

    if(H == null)return;

    H.HealthPoints -= Damage;
  }
  //------------------------------
  void Die()
  {
    gameObject.SetActive(false);
  }

  //------------------------------
}
//------------------------------

代码示例 4-1

以下要点总结了代码示例:

  • Ammo 类应该附加到 Ammo 预制件对象上,并将为所有创建的弹药对象实例化。其主要目的是损坏与它碰撞的任何对象。

  • 当弹药进入一个连接到可移动单位(如玩家或敌人)的触发器时,会调用OnTriggerEnter函数。具体来说,它会检索对象上附加的Health组件(如果有的话),并减少其健康值。Health组件是在上一章中创建的。

  • 注意,每个弹药对象都将有一个生命周期。这代表弹药在发射并生成到场景中后应该保持活跃的时间(以秒为单位)。生命周期结束后,弹药应该被完全销毁或停用(关于这一点稍后会有更多说明)。

  • 使用Invoke函数在LifeTime间隔后停用弹药对象。这发生在OnEnable事件期间。Unity 会在每次对象被激活时自动调用这个函数(即,从禁用状态变为启用状态)。

现在,将Ammo脚本文件从项目面板中的Scripts文件夹拖放到Ammo对象上,然后最后,将整个Ammo对象从场景面板拖放到Prefabs文件夹中的项目面板,以创建一个新的Ammo预制体。参见图 4.11

代码示例 4-1

图 4.11:创建弹药预制体

恭喜!你现在已经创建了一个Ammo预制体,可以从武器点生成以直接攻击敌人。这是好的,但我们还没有处理生成过程本身,我们将在下一部分解决这个问题。

弹药生成

到目前为止创建的Ammo预制体给我们带来了一个技术问题,如果这个问题不被认真对待,可能会对我们的游戏造成一些严重的性能惩罚。具体来说,当太空船武器发射时,我们需要生成弹药,使其进入场景并在碰撞时摧毁敌人。这在一般情况下是可以的,但问题是玩家可能会连续多次按下射击按钮,甚至可能长时间按住射击按钮,从而产生可能成百上千的弹药预制体。当然,我们可以使用之前看到的Instantiate函数动态生成这些预制体,但这样做是有问题的,因为实例化在计算上很昂贵。当连续生成许多物品时,它通常会导致令人难以忍受的减速,将帧数降低到不可接受的水平。我们需要避免这种情况!

这种解决方案被称为池化对象池化对象缓存。本质上,这意味着我们必须在关卡启动时(一个对象池)生成大量可回收的弹药对象,最初它们是隐藏的或停用的,我们只需在需要时激活对象(当玩家开火时)。当弹药与敌人碰撞或其生命周期结束时,我们不会完全销毁对象,而是再次停用它,如果需要的话,将其返回池中以供以后重用。这样,我们就避免了所有对Instantiate的调用,并简单地回收我们拥有的所有弹药对象。为了开始编写此功能,我们将创建一个AmmoManager类。这个类将负责两个功能:首先,在场景启动时生成弹药对象池,其次,在需要时(例如在武器开火时)从池中提供一个有效且可用的弹药对象。以下是一个AmmoManager 代码示例 4.2,以实现这一目标:

//------------------------------
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
//------------------------------
public class AmmoManager : MonoBehaviour
{
  //------------------------------
  //Reference to ammo prefab
  public GameObject AmmoPrefab = null;

  //Ammo pool count
  public int PoolSize = 100;

  public Queue<Transform> AmmoQueue = new Queue<Transform>();

  //Array of ammo objects to generate
  private GameObject[] AmmoArray;

  public static AmmoManager AmmoManagerSingleton = null;
  //------------------------------
  // Use this for initialization
  void Awake ()
  {
    if(AmmoManagerSingleton != null)
    {
      Destroy(GetComponent<AmmoManager>());
      return;
    }

    AmmoManagerSingleton = this;
    AmmoArray = new GameObject[PoolSize];

    for(int i=0; i<PoolSize; i++)
    {
      AmmoArray[i] = Instantiate(AmmoPrefab, Vector3.zero, Quaternion.identity) as GameObject;
      Transform ObjTransform = AmmoArray[i].GetComponent<Transform>();
      ObjTransform.parent = GetComponent<Transform>();
      AmmoQueue.Enqueue(ObjTransform);
      AmmoArray[i].SetActive(false);
    }
  }
  //------------------------------
  public static Transform SpawnAmmo(Vector3 Position, Quaternion Rotation)
  {
    //Get ammo
    Transform SpawnedAmmo = AmmoManagerSingleton.AmmoQueue.Dequeue();

    SpawnedAmmo.gameObject.SetActive(true);
    SpawnedAmmo.position = Position;
    SpawnedAmmo.localRotation = Rotation;

    //Add to queue end
    AmmoManagerSingleton.AmmoQueue.Enqueue(SpawnedAmmo);

    //Return ammo
    return SpawnedAmmo;
  }
  //------------------------------
}
//------------------------------

代码示例 4.2

以下要点总结了代码示例:

  • AmmoManager具有一个AmmoArray成员变量,它保存了在启动时(在Awake事件期间)要生成的所有弹药对象的完整列表(引用的顺序数组)。

  • AmmoArray的大小将设置为PoolSize。这指的是要生成的弹药对象的总数。Awake函数在关卡开始时生成弹药对象,并使用Enqueue将它们添加到队列中。

  • 一旦生成,每个弹药对象都会通过SetActive(false)被停用,并保留在池中,直到需要时使用。

  • AmmoManager使用来自Mono库的Queue类来管理在按下射击键时如何从池中选择特定的弹药对象进行激活。队列作为一个先进先出FIFO)对象工作。也就是说,弹药对象逐个添加到队列中,并在被选中激活时移除。从队列中移除的对象总是位于队列前端的对象。有关Queue类的更多信息,可以在网上找到,链接为msdn.microsoft.com/en-us/library/7977ey2c%28v=vs.110%29.aspx

  • Queue对象中,Enqueue函数在Awake阶段被调用,用于逐个将生成的对象最初添加到队列中。

  • 应该调用SpawnAmmo函数来在场景中生成新的弹药项目。此函数不依赖于Instantiate函数,而是使用Queue对象。它从队列中移除第一个弹药对象,激活它,然后将其再次添加到队列的末尾,位于所有其他弹药对象之后。这样,就发生了一个生成和再生的循环,允许所有弹药对象得到回收。

  • AmmoManager 被编码为一个单例对象,这意味着在任何时候场景中都应该只有一个此类对象实例。这种功能是通过静态成员 AmmoManagerSingleton 实现的。有关单例对象的更多信息,请参阅 Packt Publishing 出版的 Mastering Unity Scriptingwww.packtpub.com/game-development/mastering-unity-5x-scripting)。

要使用此类,在场景中创建一个新的 GameObject,命名为 AmmoManager,通过从应用程序菜单中选择 GameObject | Create Empty 来实现。然后,从 Project 面板拖放 AmmoManager 脚本到场景中的对象上。一旦创建,将 Prefabs 文件夹中的 Ammo 预制体拖放到 Object Inspector 中的 Ammo Manager 组件的 Ammo Prefab 槽中。参见 图 4.12

代码示例 4.2

图 4.12:将弹药管理器添加到对象中

现在,场景中有一个 AmmoManager 对象来存储弹药池,它位于屏幕外且不可见。然而,关于我们现有的功能实际上并没有将玩家的射击按钮按下与场景中弹药生成连接起来。也就是说,我们没有代码来使弹药变得可见并工作!这种连接现在应该通过我们在上一章中开始的 PlayerController 脚本来实现。这个类现在应该修改以处理弹药生成。修改后的 PlayerController 类包含在下面的 代码示例 4.3 中。修改内容已突出显示:

//------------------------------
using UnityEngine;
using System.Collections;
//------------------------------
public class PlayerController : MonoBehaviour
{
  //------------------------------
  private Rigidbody ThisBody = null;
  private Transform ThisTransform = null;

  public bool MouseLook = true;
  public string HorzAxis = "Horizontal";
  public string VertAxis = "Vertical";
  public string FireAxis = "Fire1";

  public float MaxSpeed = 5f;
  public float ReloadDelay = 0.3f;
  public bool CanFire = true;

  public Transform[] TurretTransforms;
  //------------------------------
  // Use this for initialization
  void Awake ()
  {
    ThisBody = GetComponent<Rigidbody>();
    ThisTransform = GetComponent<Transform>();
  }
  //------------------------------
  // Update is called once per frame
  void FixedUpdate ()
  {
    //Update movement
    float Horz = Input.GetAxis(HorzAxis);
    float Vert = Input.GetAxis(VertAxis);
    Vector3 MoveDirection = new Vector3(Horz, 0.0f, Vert);
    ThisBody.AddForce(MoveDirection.normalized * MaxSpeed);

    //Clamp speed
    ThisBody.velocity = new Vector3(Mathf.Clamp(ThisBody.velocity.x, -MaxSpeed, MaxSpeed),
    Mathf.Clamp(ThisBody.velocity.y, -MaxSpeed, MaxSpeed),
    Mathf.Clamp(ThisBody.velocity.z, -MaxSpeed, MaxSpeed));

    //Should look with mouse?
    if(MouseLook)
    {
      //Update rotation - turn to face mouse pointer
      Vector3 MousePosWorld = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 0.0f));
      MousePosWorld = new Vector3(MousePosWorld.x, 0.0f, MousePosWorld.z);

      //Get direction to cursor
      Vector3 LookDirection = MousePosWorld - ThisTransform.position;

      //FixedUpdate rotation
      ThisTransform.localRotation = Quaternion.LookRotation(LookDirection.normalized,Vector3.up);
    }

    //Check fire control
    if(Input.GetButtonDown(FireAxis) && CanFire)
    {
      foreach(Transform T in TurretTransforms)
        AmmoManager.SpawnAmmo(T.position, T.rotation);

      CanFire = false;
      Invoke ("EnableFire", ReloadDelay);
    }
  }
  //------------------------------
  void EnableFire()
  {
    CanFire = true;
  }
  //------------------------------
  public void Die()
  {
    Destroy(gameObject);
  }
}
//------------------------------

代码示例 4.3

以下要点总结了代码示例:

  • PlayerController 现在具有一个 TurretTransform 数组变量,列出了所有用作炮塔生成位置的子空对象。

  • Update 函数期间,PlayerController 检查射击按钮的按下。如果检测到,代码将遍历所有炮塔并在每个炮塔位置生成一个弹药对象。

  • 一旦发射了弹药,ReloadDelay 就会被激活(设置为 true)。这意味着在再次发射新弹药之前,必须首先等待延迟时间结束。

在将此代码添加到 PlayerController 之后,选择场景中的 Player 对象,然后将 Turret 空对象拖放到 TurretTransform 槽中。此示例仅使用一个炮塔,但如果需要,可以添加更多。参见 图 4.13

代码示例 4.3

图 4.13:配置 TurretTransform 以生成弹药

现在你可以进行游戏测试并发射弹药。通过播放场景并按键盘或鼠标(左键)的射击按钮,将生成弹药。太棒了!然而,在测试时,你可能注意到两个主要问题。首先,弹药看起来太大或太小。其次,弹药有时会弹跳、翻转或对玩家的宇宙飞船做出反应。让我们依次修复这些问题。

如果弹药看起来大小不正确,您可以简单地更改预制体的缩放比例。在项目面板中选择Ammo预制体,然后从对象检查器中在变换组件中输入新的缩放比例。参见图 4.14

代码示例 4.3

图 4.14:更改弹药预制体的缩放比例

如果弹药看起来会弹跳或对玩家飞船做出反应,那么我们需要使弹药对玩家免疫或不敏感。为了实现这一点,我们可以使用物理层。简而言之,玩家飞船和弹药应该被添加到单个层中,并且该层上的所有对象在物理反应方面都应定义为相互免疫。首先,在场景中选择Player对象。然后,从对象检查器中点击下拉菜单,并在上下文菜单中选择添加层。参见图 4.15

代码示例 4.3

图 4.15:为物理排除创建新层

将层命名为Player。这是为了表明所有附加到该层的对象都与Player相关联。参见图 4.16

代码示例 4.3

图 4.16:创建层

现在,将场景中的Player对象和项目面板中的Ammo预制体分配到新创建的玩家层。选择每个对象,然后简单地点击下拉菜单,选择玩家选项。参见图 4.17。如果出现弹出对话框,请选择更改子对象。这确保了所有子对象也与父对象的相同相关联。

代码示例 4.3

图 4.17:将玩家和弹药分配到玩家层

现在玩家弹药已经被分配到相同的层。从这里,我们可以使同一层上的所有对象在物理方面相互免疫。要做到这一点,从应用程序菜单导航到编辑 | 项目设置 | 物理。参见图 4.18

代码示例 4.3

图 4.18:访问物理选项

全局物理设置显示在对象检查器中。在检查器底部, 碰撞矩阵显示层如何相互影响。带有勾选标记的交叉层可以并且将相互影响。因此,取消玩家层的勾选标记以防止该层上的对象之间发生碰撞。参见图 4.19

代码示例 4.3

图 4.19:设置层碰撞矩阵以改善碰撞

通过从对象检查器设置层碰撞矩阵,按工具栏上的播放按钮测试运行到目前为止的游戏。当你这样做并按射击键时,弹药将从炮塔中发射出来,并且不再对玩家飞船做出反应。然而,弹药应该会与敌人碰撞并摧毁它们。参见图 4.20

代码示例 4.3

图 4.20:通过射击枪支摧毁敌人!

优秀的工作!我们现在有一个可以发射武器并摧毁敌人的宇宙飞船,物理效果也如预期。也许您想稍微自定义玩家控制,或者可能想使用游戏手柄。下一节将进一步探讨这个问题。

用户控制

也许您不喜欢默认的控件和与输入轴关联的默认按键组合——水平垂直Fire1。也许您想更改它们。这些输入轴是通过Input.GetAxis函数(之前已展示)读取的,并且由可读名称指定,但并不立即清楚 Unity 如何将特定的输入按钮和设备映射到这些虚拟轴。在这里,我们将简要介绍如何自定义这些设置。要开始,让我们通过从应用程序菜单导航到编辑 | 项目设置 | 输入来访问输入设置。请参阅图 4.21

用户控制

图 4.21:访问输入菜单

选择此选项后,一系列自定义定义的输入轴将作为列表出现在对象检查器中。请参阅图 4.22。这定义了输入系统使用的所有轴。水平垂直轴应列在这里。

用户控制

图 4.22:探索输入轴

通过在对象检查器中展开每个轴,您可以轻松地自定义用户输入的映射方式,即特定的键和硬件设备上的控件(如键盘和鼠标)如何映射到轴。例如,水平轴被定义了两次。对于第一个定义,水平被映射到键盘上的AD键。右和D被映射为正按钮,因为当按下时,它们从Input.GetAxis函数产生正的浮点值(0-1)。左和A被映射为负按钮,因为当按下时,它们对Input.GetAxis产生负的浮点值。这使得使用负数和正数轻松地左右移动对象变得容易。请参阅图 4.23

用户控制

图 4.23:配置输入轴

注意,在对象检查器水平被定义了两次——一次在列表顶部附近,一次在底部附近。这两个定义是累积的,而不是矛盾的——它们堆叠在一起。这允许您将多个设备映射到同一个轴,从而让您能够跨平台和多设备控制您的游戏。默认情况下,水平在第一个定义中被映射到键盘上的AD键,在第二个定义中,被映射到操纵杆运动。这两个定义都是有效的,并且可以一起工作。您可以为同一个轴定义尽可能多的定义,具体取决于您需要支持的控件。请参阅图 4.24

用户控制

图 4.24:定义两个水平轴

对于这个项目,控制将保持默认设置,但如果您想支持不同的配置,请继续更改或添加额外的控制。有关玩家输入和自定义控制的更多信息,请参阅在线 Unity 文档中的docs.unity3d.com/Manual/class-InputManager.html

得分和计分 – UI 和文本对象

让我们继续讨论计分系统,在创建这个系统时,我们将创建GameControllerGameController只是一个管理整个游戏和全局行为的脚本或类。这包括得分,因为在这个游戏中,得分指的是代表玩家成就和进度的单个和全局数字。在开始实现之前,首先创建一个简单的 GUI 来显示游戏得分。GUI 是图形用户界面的缩写,这指的是位于游戏窗口上方并提供给玩家的所有 2D 图形元素。为此,从应用程序菜单中选择GameObject | UI | Canvas来创建一个新的 GUI 画布对象。参见图 4.25。有关 GUI 的更多详细信息,请参阅下一章。

得分和计分 – UI 和文本对象

图 4.25:将 Canvas 对象添加到场景中

Canvas对象定义了 GUI 存在的总面积或区域,包括所有按钮、文本和其他小部件。在场景中生成后,Canvas也出现在层次结构面板中。最初,Canvas对象可能太大或太小,在视图中看不清楚,因此请在层次结构面板中选择Canvas对象,然后在键盘上按F键以聚焦对象。它应该显示为一个大的垂直对齐矩形。参见图 4.26

得分和计分 – UI 和文本对象

图 4.26:在视图中检查 Canvas 对象

游戏选项卡中,Canvas对象本身是不可见的。它仅仅作为一个容器。即便如此,它对包含的对象在屏幕上的大小、位置和缩放方式有强烈的影响。因此,在添加对象和细化界面设计之前,首先配置您的Canvas对象是有帮助的。为此,在场景中选择Canvas对象,然后从对象检查器中,点击Canvas Scaler组件中的UI 缩放模式下拉选项。从下拉列表中,选择随屏幕大小缩放选项,并在参考分辨率字段中输入高清分辨率,即对于X字段指定1920,对于Y字段指定1080。参见图 4.27

得分和计分 – UI 和文本对象

图 4.27:调整 Canvas Scaler 组件

通过将画布缩放器调整为与屏幕大小缩放,游戏的用户界面将自动拉伸和收缩(向上和向下缩放),以适应目标分辨率,确保每个元素按相同比例缩放,保持整体外观和感觉。这是一个快速简单的方法,一次创建 UI,然后调整大小以适应几乎任何分辨率。这可能不是始终是保持最高质量图形保真度的最佳解决方案,但它功能性强,在许多情况下都适用。在任何情况下,在进行 UI 设计之前,在界面中(或如果你有多个显示器配置,则跨两个显示器)并排查看场景视口和游戏标签都是很有帮助的。这允许我们在场景视口中构建界面,然后在游戏标签中预览其效果。你可以通过在Unity 编辑器中将游戏标签拖放到场景标签旁边来重新排列场景游戏标签。参见图 4.28

得分和计分 – UI 和文本对象

图 4.28:将场景和游戏标签并排停靠

接下来,让我们将文本小部件添加到 GUI 中以显示游戏得分。为此,在层次结构面板中选择画布对象,然后在该对象上(在层次结构面板中)右键单击以显示上下文菜单。从这里,选择UI | 文本。这将在画布对象下创建一个新的文本对象,而不是一个没有父对象的顶层对象。参见图 4.29文本对象用于在屏幕上绘制具有特定颜色、大小和字体设置的文本。

得分和计分 – UI 和文本对象

图 4.29:为 UI 创建文本对象

默认情况下,文本对象可能最初在场景或视图中不可见,尽管它在层次结构面板中被列为一个对象。然而,仔细观察场景,你可能会看到非常小且暗淡的文本,这些文本出现在画布游戏标签中。参见图 4.30。默认情况下,新文本对象具有黑色文本和较小的字体大小。对于这个项目,这些设置需要更改。

得分和计分 – UI 和文本对象

图 4.30:新创建的文本对象有时难以看到

如果尚未选择,请在层次结构面板中选择文本对象,然后从对象检查器(在文本组件中),将文本颜色更改为白色,并将字体大小更改为20。参见图 4.31

得分和计分 – UI 和文本对象

图 4.31:更改文本大小和颜色

然而,即使更改了大小,文本仍然显得太小。但是,如果你进一步增加大小,文本可能会从视图中消失。这是因为每个Text对象都有一个定义其边界的矩形边界,当字体大小增加到超出边界能容纳的范围时,文本会自动完全隐藏。为了解决这个问题,我们将增加文本边界。为此,使用 T 键切换到矩形变换工具或从工具栏中选择该工具。参见图 4.32

得分和评分 – UI 和文本对象

图 4.32:选择矩形变换工具

激活矩形变换工具后,将在场景视图中围绕选定的Text对象绘制一个清晰定义的边界,指示其矩形范围。让我们增加边界大小以适应更大的文本。为此,只需用鼠标点击并拖动边界边缘,按需扩展它们。参见图 4.33。这将增加边界大小,现在你可以增加字体大小以提高文本可读性。

得分和评分 – UI 和文本对象

图 4.33:调整文本矩形以支持更大的字体大小

除了设置文本边界大小外,文本还可以垂直对齐到边界中心。只需单击垂直组的中心对齐按钮。对于水平对齐,文本应保持左对齐,以便显示得分。参见图 4.34

得分和评分 – UI 和文本对象

图 4.34:在边界内对齐文本

虽然文本现在在其包含边界内垂直对齐,但我们仍需要将其整体对齐到画布容器,以确保即使在游戏窗口调整大小和重新对齐时,它也保持在屏幕上的相同位置和方向。为此,我们将使用锚点。首先,使用变换工具(W)将Text对象重新定位到屏幕右上角,即得分应该出现的位置。对象将自动在二维平面上移动,而不是在三维空间中。当你将Text对象在场景视图中移动时,检查游戏标签中的外观,以确保它看起来正确且合适。参见图 4.35

得分和评分 – UI 和文本对象

图 4.35:在游戏标签中定位得分文本

为了确保 Text 对象在屏幕上的位置(防止它滑动或移动),即使用户调整了 Game 选项卡的大小,我们也可以将对象的锚点位置设置为屏幕的右上角。这确保了文本始终以恒定的、成比例的偏移量从其锚点定位。为此,在 Object Inspector 中的 Rect Transform 组件上的 Anchor Presets 按钮上单击。当你这样做时,一个预设菜单会出现,你可以从中选择一系列对齐位置。每个预设都通过一个小图表示图形化展示,包括一个位于锚点对齐位置的红点。选择右上角的预设。参见 图 4.36

得分和计分 - UI 和文本对象

图 4.36:将文本对象对齐到屏幕

优秀的作品!现在 Text 对象已经创建并准备好使用。当然,在播放模式下,文本保持不变,不会显示真实的得分。这是因为我们需要添加一些代码。然而,总的来说,Text 对象已经到位,我们可以继续前进。

与得分一起工作 - 使用文本脚本

要在 GUI 中显示得分,我们首先需要得分功能,即创建得分系统的代码。本质上,得分功能将被添加到一个通用的、全面的 GameController 类中,该类负责所有游戏逻辑和功能。GameController 及其得分功能集的代码包含在 代码示例 4.4 中,如下所示。此文件应添加到项目的 Scripts 文件夹中。

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
//------------------------------
public class GameController : MonoBehaviour
{
  //Game score
  public static int Score;

  //Prefix
  public string ScorePrefix = string.Empty;

  //Score text object
  public Text ScoreText = null;

  //Game over text
  public Text GameOverText = null;

  public static GameController ThisInstance = null;
  //------------------------------
  void Awake()
  {
    ThisInstance = this;
  }
  //------------------------------
  void Update()
  {
    //Update score text
    if(ScoreText!=null)
      ScoreText.text = ScorePrefix + Score.ToString();
  }
  //------------------------------
  public static void GameOver()
  {
    if(ThisInstance.GameOverText!=null)
    ThisInstance.GameOverText.gameObject.SetActive(true);
  }
  //------------------------------
}

代码示例 4.4

以下要点总结了代码示例:

  • GameController 类使用 UnityEngine.ui 命名空间。这很重要,因为它包括了访问 Unity 中所有 UI 类和对象的方法。如果你没有在你的源文件中包含这个命名空间,那么你将无法从该脚本中使用 UI 对象。

  • GameController 类有两个公共文本成员,即 ScoreTextGameOverText。这些成员在 GameController 代码中是可选的,因为即使成员为空,代码也能正常工作。ScoreText 是一个指向用于显示得分文本的文本 GUI 对象的引用,而 GameOverText 用于在游戏结束条件发生时显示任何消息。

要使用 GameController 代码,在场景中创建一个新的空对象,命名为 GameController。然后,将 GameController 脚本文件拖放到该对象上。一旦添加,将 ScoreText 对象拖放到 Object Inspector 中的 GameControllerScore Text 字段。参见 图 4.37。在 Score Prefix 字段中,输入应加在 Score 前面的文本。得分本身只是一个数字(如 1,000)。前缀允许你在得分前面添加文本,向玩家说明这些数字的含义。

代码示例 4.4

图 4.37:创建一个用于维护游戏得分的 GameController

现在,进行游戏测试运行,你会在游戏标签页的右上角看到使用 GUI 文本对象显示的分数。这是可以的,但现在分数总是保持在0。这是因为我们还没有编写增加分数的代码。对于我们的游戏,当敌人对象被销毁时,分数应该增加。为了实现这一点,我们将创建一个新的脚本文件,ScoreOnDestroy。这包含在代码示例 4.5中,如下所示:

using UnityEngine;
using System.Collections;
//------------------------------
public class ScoreOnDestroy : MonoBehaviour
{
  //------------------------------
  public int ScoreValue = 50;
  //------------------------------
  void OnDestroy()
  {
    GameController.Score += ScoreValue;
  }
  //------------------------------
}
//------------------------------

脚本应该附加到任何在销毁时为你分配分数的对象上,例如敌人。分配的分数总数由ScoreValue指定。要将脚本附加到敌人预制体上,在项目面板中选择预制体,然后在对象检查器中点击添加组件按钮。然后在搜索字段中输入ScoreOnDestroy以将组件添加到预制体。一旦添加,指定销毁敌人时要分配的总分数。对于这个游戏,分配了 50 分的值。见图 4.38

代码示例 4.4

图 4.38:向敌人预制体添加分数组件

干得好!你现在有了可破坏的敌人,它们在销毁时为你分配分数。这意味着你终于可以拥有游戏中的分数,甚至可以扩展游戏以包括高分功能和排行榜。这也意味着我们的游戏几乎完成,准备构建。接下来,我们将添加一些最后的修饰。

磨光

在本节中,我们将为游戏添加最后的修饰。首先,我们要解决游戏背景的问题!到目前为止,背景只是简单地显示了与游戏摄像机关联的默认背景颜色。然而,由于游戏设定在太空中,我们应该显示太空背景。为此,在场景中创建一个新的四边形对象,该对象将显示太空图像。从菜单中选择游戏对象 | 3D 对象 | 四边形。然后旋转对象并向下移动,使其显示一个平坦的、垂直对齐的背景。你可能需要调整对象的大小以使其看起来正确。见图 4.39

磨光

图 4.39:为关卡创建背景并构建四边形

现在,将项目面板中的太空纹理拖放到场景中的四边形上,将其作为材质应用。一旦分配,选择四边形,并在对象检查器中的材质属性中更改平铺设置。将XY平铺增加到3。见图 4.40

磨光

图 4.40:配置纹理平铺

如果纹理平铺看起来有问题,那么请务必检查纹理导入设置。为此,在项目面板中选择纹理,然后在对象检查器中,确保纹理类型设置为纹理包裹模式设置为重复。见图 4.41

磨光

图 4.41:配置纹理以实现无缝平铺

现在关卡有了合适的背景。让我们添加一些背景音乐,它将循环播放。为此,首先在音频文件夹中的项目面板中选择音乐轨道。选择后,确保从对象检查器中设置音乐的加载类型流式传输,并且禁用预加载音频数据。参见图 4.42。这提高了加载时间,因为 Unity 不需要在场景开始时将所有音乐数据加载到内存中。

抛光

图 4.42:配置音频数据以准备播放

接下来,在场景中创建一个新的、空的GameObject,命名为Music,然后将Music轨道从项目面板拖放到Music对象中,添加它作为音频源组件。音频源组件播放声音效果和音乐。参见图 4.43

抛光

图 4.43:创建具有音频源组件的 GameObject

对象检查器中的音频源组件,启用播放唤醒循环复选框,以确保音乐从关卡开始播放并且无限循环,直到游戏运行结束。空间混合字段应设置为0,表示 2D。简而言之,2D 声音在整个关卡中保持一致的音量,不受玩家位置的影响。这是因为 2D 声音没有空间定位。相比之下,3D 声音用于枪声、脚步声、爆炸声和其他存在于 3D 空间中的声音,其音量应根据玩家在播放时距离它们的远近而变化。参见图 4.44

抛光

图 4.44:循环音乐轨道

现在,让我们对游戏进行测试运行!点击工具栏上的播放按钮并测试它。如果音乐没有播放,请检查游戏标签页上的静音音频按钮是否未启用。参见图 4.45

抛光

图 4.45:玩游戏 – 如有必要,禁用静音音频

测试和诊断

对于几乎所有游戏,你需要花费相当多的时间进行测试和调试,以尽可能减少错误和漏洞。使用这个示例程序,你需要进行的调试和测试非常少,但这并不是因为游戏很简单。这是因为我在将材料呈现给你之前,已经预先检查和测试了大部分代码和功能,确保你获得流畅的学习体验。然而,对于你自己的项目,你将需要进行大量的测试。一种开始的方式是使用统计面板。要打开它,点击游戏标签页上的统计按钮。参见图 4.46

测试和诊断

图 4.46:通过统计面板查看游戏性能信息

关于统计信息面板的更多细节包含在本书的第二章,项目 A – 收藏品游戏的继续中,更多信息可以在 Unity 文档的docs.unity3d.com/Manual/RenderingStatistics.html在线找到。

另一个调试工具是分析器。当统计信息面板已经帮助你识别了一个一般问题,例如低帧率,而你想要进一步挖掘以找到问题可能所在的位置时,这很有用。关于分析器的更多细节将在第六章,继续 2D 冒险中介绍,但在这里简要介绍是值得的。要访问分析器工具,从应用程序菜单中选择窗口 | 分析器。这会显示分析器窗口。见图 4.47

测试和诊断

图 4.47:访问分析器窗口

在打开分析器窗口的情况下,点击工具栏上的播放按钮来测试你的游戏。当你这样做时,分析器窗口会填充上色编码的性能数据图表。见图 4.48。绿色代表渲染(图形)数据的性能。阅读和理解图表需要一些经验,但一般来说,要注意图表中的山峰和顶峰,即注意图表中的急剧波动(急剧上升和下降),因为这可能表明存在问题,尤其是在帧率下降时。

测试和诊断

图 4.48:在游戏过程中,分析器窗口会填充数据

如果你想进一步调查,只需暂停游戏,然后在图表中点击。水平轴(X 轴)代表最近的帧,垂直轴代表工作量。当你点击图表时,会添加一个线标记来指示正在调查的帧。在图表下方,列出了该帧的所有主要进程,通常按工作量的轻重和占帧时间的比例从上到下排序。较重的进程列在顶部。见图 4.49

测试和诊断

图 4.49:使用分析器调查性能数据

注意

关于分析器的更多信息可以在 Unity 在线文档的docs.unity3d.com/Manual/Profiler.html找到。

构建

现在,最后,我们终于准备好将我们的游戏构建成独立的形式,准备发送给朋友、家人和测试人员!执行此操作的过程与第二章中详细描述的相同,项目 A – 收集游戏继续,以构建收集游戏。从应用程序菜单中选择文件 | 构建设置。从构建对话框中,通过简单地点击添加当前按钮将我们的关卡添加到关卡列表中。否则,从项目面板拖放关卡到关卡列表中。见图 4.50

Building

图 4.50:准备构建太空射击游戏

对于这个游戏,目标平台将是 Windows。因此,如果尚未选择,请从平台列表中选择PC, Mac & Linux Standalone选项。如果切换平台按钮(在左下角)未禁用,那么您需要按下此按钮,向 Unity 确认它应该为所选平台构建,而不是其他平台。然后,点击构建并运行按钮。点击此按钮后,Unity 会提示您选择一个文件夹,构建文件将输出并保存到该文件夹中。一旦生成,双击可执行文件以运行并测试。见图 4.51

Building

图 4.51:以标准 Windows 可执行文件运行游戏的测试

摘要

太棒了!我们现在真的在一条好路上,已经完成了两个坚实的 Unity 项目。第一个项目是一个收集游戏,第二个是一个双摇杆射击游戏。从本质上讲,这两个游戏都很简单,因为它们不依赖于高级机制或展示复杂的功能。然而,即使是非常复杂的游戏,当简化到其基本成分时,也可以发现它们建立在类似的基础概念之上,就像我们到目前为止所涵盖的那些。这就是为什么我们的项目对于深入理解 Unity 至关重要。接下来,我们将继续创建一个更注重 2D 的游戏,考虑界面、精灵和物理,以及更多内容!

第五章. 项目 C – 二维冒险

在本章中,我们将开始一个全新的项目;具体来说,是一个玩家控制外星角色的二维冒险游戏。在这个游戏中,玩家将探索和导航一个充满任务和交互元素的危险世界。该项目将结合前几章的元素和想法,并专注于新的技术,如复杂碰撞、2D 物理学、单例和静态等。简而言之,我们将涵盖以下主题:

  • 2D 角色和玩家移动

  • 组装复杂和多部分角色

  • 级别设计

  • 2D 物理和碰撞检测

    注意

    起始项目和资源可以在书的配套文件中找到,位于Chapter05/Start文件夹中。如果您还没有自己的项目,可以从这里开始,并跟随本章内容进行操作。

二维冒险之旅 – 开始

冒险游戏要求玩家运用他们的机智、敏捷、思维敏锐和洞察力来取得进展。这类游戏具有危险的障碍、挑战性的任务和角色互动,与许多第一人称射击游戏中的全面战斗形成对比。我们的冒险游戏也不例外。见图 5.1,了解我们将要创建的游戏。在这个游戏中,玩家可以使用键盘上的箭头键或WASD键来移动。此外,他们可以按空格键跳跃,并通过接近角色与角色互动。在游戏中,玩家将接到来自 NPC 角色的任务,收集隐藏在关卡某个地方的古老宝石。然后,玩家必须穿越危险的障碍物寻找宝石,并在返回 NPC 之前最终收集它,完成游戏。

二维冒险之旅 – 开始

图 5.1:要创建的二维冒险游戏

要开始创建冒险,请创建一个全新的空 Unity 项目,然后导入ParticlesEffectsCharacters2DParticleSystemsCrossPlatformInput包。您可以从项目创建向导或从应用程序菜单通过资产 | 导入包选项导入这些包。见图 5.2。有关导入标准资产的详细信息包含在第一章,硬币收集游戏 – 第一部分中。

二维冒险之旅 – 开始

图 5.2:从项目创建屏幕导入包到新项目

资产导入

从上一节创建的空项目开始,现在让我们导入我们将要使用的纹理资产,包括玩家角色和环境。要导入的资产包含在本书配套文件中的Chapter05/Assets文件夹中。从这里,在 Windows 资源管理器或 Mac Finder 中一起选择所有纹理,并将它们拖放到指定的Textures文件夹中的 Unity 项目面板。 (如果您还没有创建,请创建一个!)这会将所有相关纹理导入到活动项目中。请参见图 5.3

导入资产

图 5.3:将纹理资产导入项目

注意

请记住,您始终可以使用缩略图大小滑块(位于项目面板的右下角)来调整缩略图预览的大小,以便更容易查看您的纹理资产。

默认情况下,Unity 假设所有导入的纹理最终都将用作常规纹理,应用于场景中的 3D 模型,例如立方体、球体和网格。在大多数情况下,这个假设是正确的,因为大多数游戏都是 3D 的。然而,对于像我们正在制作的 2D 游戏,设置应该不同。在我们的案例中,对象不会随着距离的增加而退远,只是保持与摄像机的恒定偏移。因此,我们必须调整所有导入纹理的一些关键属性。具体来说,选择所有导入的纹理,从对象检查器中,将纹理类型字段从纹理更改为2D 精灵和 UI。然后,取消勾选生成 Mip 贴图框。然后,点击应用按钮。当您这样做时,Unity 会将资产标记为具有内部2D 使用。它允许在适用的地方应用透明背景(例如 PNG 精灵),并且对图形渲染的性能也有重要影响,我们将在后面看到。请参见图 5.4

导入资产

图 5.4:配置导入的纹理

现在我们已经导入了项目所需的所有基本纹理,让我们配置我们的主场景、游戏摄像机和目标分辨率。切换到游戏选项卡,将分辨率设置为1024 x 600;这在许多设备上都适用。为此,点击游戏选项卡工具栏中的自由宽高比按钮,从下拉菜单中选择1024 x 600(如果它作为选项出现)。如果没有,点击列表底部的+按钮添加新的分辨率。请参见图 5.5

导入资产

图 5.5:添加游戏分辨率

要添加新的分辨率,请在名称字段中输入自定义名称,从类型下拉菜单中选择固定分辨率,然后在宽度 & 高度字段中输入您的分辨率尺寸。完成后,点击确定。您的目标分辨率应随后作为游戏选项卡中的一个可选选项添加。请参见图 5.6

导入资产

图 5.6:创建自定义分辨率

接下来,我们将为 2D 设置配置场景相机,以便我们的纹理作为精灵添加到屏幕上时,将以 1:1 的比例显示,像素对应像素。为此,在场景中选择主相机,无论是通过在场景视口中单击它,还是在场景层次结构中选择它。然后,从对象检查器中,将投影更改为正交。这确保相机以真实 2D 显示对象,并移除了透视和缩短效果。然后,将相机大小更改为3。此字段的公式是屏幕高度/2/像素到世界。在这种情况下,屏幕高度600。因此,600/2=300。然后,300/100=3100指的是应用于精灵纹理的像素到世界比例;这详细说明了纹理中的多少像素将被映射到世界中的平方米。值为1表示1 像素=1 米。此值可以通过在项目面板中选择一个精灵并更改对象检查器中的像素到世界比例字段来查看和更改。参见图 5.7

导入资源

图 5.7:配置相机正交大小

要测试相机和场景设置,只需将项目面板中的一个背景纹理拖放到场景中。背景纹理的大小正好为 1024 x 600,以适应场景背景。因此,当添加到场景中并且相机配置正确时,背景纹理应该填满屏幕。参见图 5.8

导入资源

图 5.8:使用纹理测试相机设置

创建环境 - 开始

我们的冒险游戏将包含三个独立但相互关联的场景,玩家可以探索这些场景,从一个场景移动到下一个场景。玩家可以通过走出一个场景的边缘然后进入下一个场景来在场景之间旅行。每个场景主要由平台和凸起组成,在某些情况下,还有危险和障碍。在图形资源方面,每个场景由两个纹理或精灵组成:背景和前景。场景 1 的示例在图 5.9图 5.10中展示。图 5.9代表背景场景,图 5.10代表前景,其中包括玩家必须穿越的所有平台和凸起的完整布局。这些文件包含在书的配套文件中,位于Chapter05/Assets文件夹:

创建环境 - 开始

图 5.9:场景背景 - tex_level01_bck.png

创建环境 - 开始

图 5.10:场景前景 - tex_level01_design.png

现在让我们根据 图 5.9图 5.10 中的精灵创建第一个级别。为此,使用现有的空场景,或创建一个新的场景,确保场景相机配置为以原生大小显示纹理。然后,将背景和前景精灵从 项目 面板拖放到场景中。它们都将作为单独的精灵对象添加到场景中。然后,将它们都放置在 World 原点(0,0,0)。见 图 5.11

创建环境 – 开始

图 5.11:添加场景背景和前景

注意

如果您将背景和前景纹理作为一个选择从 项目 面板拖放到场景中,当您释放鼠标时,Unity 可能会要求您创建一个动画。在这种情况下,Unity 假设您想要创建一个动画精灵,其中每个选定的纹理都成为动画序列中播放的一帧。您不想这样做;相反,将每个精灵拖放到单独的 层次结构 面板中,这样就可以同时看到前景和背景。

两个精灵对象现在都被添加到了场景中的相同世界位置(0,0,0)。现在的问题是,由于两个精灵相互重叠,Unity 应该显示哪个精灵在上方。目前的情况是,存在深度顺序的冲突和模糊性,我们无法依赖 Unity 一致地显示正确的精灵在上方。我们可以用两种方法解决这个问题:一种是将精灵在 Z 轴上向前移动,靠近正交相机;另一种是将其 Order 设置从 对象检查器 中更改。Order 的较高值会导致精灵出现在较低顺序的精灵之上。在这里,我将使用这两种方法,这也很好!见 图 5.12。然而,请注意,Order 总是优先于 Position。这意味着较高顺序的对象将始终出现在较低顺序的对象之上,即使较高顺序的对象位于较低顺序的对象之后。

创建环境 – 开始

图 5.12:在场景中排序精灵层

在继续之前,让我们在场景层次结构方面进行组织,以防止以后发生过度复杂化和混淆。选择每个环境对象并适当地命名它们。我将背景命名为 scene_background,前景命名为 scene_foreground。完成此操作后,创建一个新的空 GameObject 命名为 Env(代表环境),它将成为环境中所有静态(不可移动)对象的最终父对象或祖先。这使得我们可以轻松地将所有相关对象分组在一起。为此,从应用程序菜单中选择 GameObject | 创建空对象,将创建的空对象放置在世界原点,并将背景和前景对象作为其子对象拖放。见 图 5.13

创建环境 – 开始

图 5.13:组织场景层次结构

通过切换到游戏标签,我们可以提前预览级别,从游戏玩家的情绪和情感共鸣角度来看它将如何呈现。这种感觉可以通过添加一些相机后处理效果进一步增强。这些效果是指可以应用于相机的基于像素的效果,以增强每帧最终渲染图像的气氛。图像效果包含在效果包中,你应在项目创建阶段导入该包。如果你当时没有导入该包,现在可以通过导航到资产 | 导入包 | 效果来导入。图像效果资产存储在Standard Assets/Effects文件夹中。一旦导入,你可以通过应用程序菜单选择组件 | 图像效果来向所选相机添加图像效果,然后选择要添加的效果。参见图 5.14

创建环境 – 开始

图 5.14:向所选相机添加图像效果

对于这个级别,以及游戏中所有其他级别,我将添加以下两种图像效果:Bloom OptimizedNoise and Grain。一旦添加,你通常需要调整检查器中的滑块和设置以达到你想要的外观,并在游戏标签中不断预览结果。场景标签不受图像效果的影响。我在两种图像效果中使用的完整设置范围包括在图 5.15中。在许多情况下,这些设置可能需要一些尝试和错误——调整值并观察效果。

创建环境 – 开始

图 5.15:应用于游戏相机的图像效果

干得好。到目前为止的场景包括从纹理文件中获取的背景和前景,以及使用图像效果资产包增强特殊效果。这是一个很好的开始,但还有很多工作要做,所以让我们继续吧!

环境物理

目前我们关卡的主要问题是缺乏交互性。具体来说,如果我们把玩家对象拖放到关卡中,并在工具栏上按下播放,玩家会因为前景纹理未被 Unity 识别为实体对象而穿过地板和墙壁。它只是一个纹理,只存在于外观上,而不存在于实质上。在本节中,我们将使用物理和碰撞体来纠正这个问题。要开始,我们将在场景中创建一个临时玩家对象(不是最终版本,而只是一个用于测试的临时 白色盒子 版本)。为此,从应用程序菜单导航到 GameObject | 3D Object | Capsule 来生成胶囊对象。将变换的 Z 位置设置为与前景纹理匹配(对我来说,这是 -2)。一旦生成,从对象中移除 Capsule Collider。默认情况下,胶囊被分配了一个 3D 碰撞体(如 Capsule Collider),这对于 3D 物理非常有用,但我们的游戏将是 2D。要移除碰撞体,点击 Object InspectorCapsule Collider 组件上的齿轮图标,并从菜单中选择 Remove Component。参见 图 5.16

环境物理

图 5.16:移除胶囊碰撞体组件

要使对象与 2D 物理兼容,通过应用程序菜单选择 Component | Physics 2D | Circle Collider 来添加圆形碰撞体组件。一旦添加,使用检查器中圆形碰撞体组件的 OffsetRadius 设置来调整圆形的大小和位置,以便相对于胶囊对象来近似玩家角色的脚部。为了帮助您更容易地定位 Circle Collider,如果需要,可以将 Scene 视图模式切换到 Wireframe2D。为此,使用视口工具栏中的 2D Toggle 按钮和 Scene Render 模式下拉按钮。参见 图 5.17

环境物理

图 5.17:调整玩家角色的圆形碰撞体

接下来,为了使 Circle Collider 与 2D 物理一起工作,向胶囊添加一个 RigidBody2D 组件。为此,从应用程序菜单选择 Component | Physics 2D | RigidBody2D。您可以通过在 Play 模式下预览游戏来确认这已经生效。当您点击播放图标时,胶囊对象应该在重力作用下落并通过前景地板。参见 图 5.18

环境物理

图 5.18:向测试角色添加 Rigidbody 2D 组件

现在,是时候配置前景纹理,使其与物理效果作为一个统一的整体。目前,我们的测试玩家角色会穿过地板,这不是我们想要的效果。为了解决这个问题,我们需要在前景环境中添加一个碰撞体。一种方法是使用二维边碰撞体。这允许你手动在你的地面图像周围绘制一个低多边形网格碰撞体,从而近似地形。要开始,请在场景中选择前景,然后从应用程序菜单中选择 组件 | 物理 2D | 二维边碰撞体。这样做将为前景对象添加一个二维边碰撞体组件。参见 图 5.19

环境物理学

图 5.19:添加边碰撞体

默认情况下,添加二维边碰撞体似乎对所选对象或任何其他对象影响很小,除了在场景宽度上绘制的一条单一直线。当选择 前景 对象时,可以在 场景 选项卡中看到这一点,如果启用了 Gizmos 工具按钮,则可以在 游戏 选项卡中看到。如果玩家位于水平线之上,并在工具栏上按下播放,玩家角色将向下坠落,并将水平边缘视为一个实体平台。参见 图 5.20

环境物理学

图 5.20:边碰撞体对于近似平台和实体表面非常有用

当然,我们的地形不仅仅是直边表面。相反,它有高度、隆起和平台。这些可以使用 碰撞体编辑 模式与二维边碰撞体组件进行近似。要访问此模式,请从 对象检查器 中点击 编辑碰撞体 按钮。参见 图 5.21

环境物理学

图 5.21:编辑碰撞体模式允许你更改二维边碰撞体的形状

编辑碰撞体 模式激活时,你可以重新塑造碰撞体以符合地形。让我们关注地形的一个区域,比如地形的右下角。通过将鼠标光标移至 边碰撞体 的边缘点上(绿色线条),你可以点击并拖动以重新定位它。为了近似地形的右下岛屿,点击并拖动最右侧的边缘点到场景的右侧。参见 图 5.22

环境物理学

图 5.22:开始重新塑造边碰撞体以近似地形

接下来,点击并拖动碰撞体的左侧点以与右手岛屿最左侧的边缘对齐。参见 图 5.23

环境物理学

图 5.23:定位右手岛屿最左侧的位置

现在左右边缘点已经定位,让我们在线条之间添加一些额外的点来重塑它,使其符合右手岛屿。将光标移至线条上的任何位置,点击并拖动以插入一个新点,并将其重新定位以匹配岛屿。重复此过程,根据需要添加额外的点来重塑线条。参见图 5.24

环境物理

图 5.24:将边缘碰撞体塑形到最右侧的岛屿

现在,你已经得到了一条完全塑形的线,与地形的最右侧岛屿相匹配。创建完成后,通过简单地再次点击对象检查器中的编辑碰撞体按钮退出编辑碰撞体模式。为了为地形的剩余岛屿创建碰撞体,向同一对象添加一个新的边缘碰撞体。你可以在单个对象上添加任意数量的边缘碰撞体,并且每个碰撞体都应该用来近似完整地形中单个、孤立的岛屿的拓扑结构。参见图 5.25

环境物理

图 5.25:一个对象上的多个边缘碰撞体可以用来近似复杂的地形

现在已经将多个边缘碰撞体组件添加到单个前景对象中,近似场景中的完整地形。我们可以通过在工具栏上按下播放图标来测试玩家胶囊对象与地形的碰撞,并观察胶囊如何与地形互动。这次,胶囊将碰撞并与地面互动,而不是穿过。这证实了地形已适当地配置了物理系统。参见图 5.26

环境物理

图 5.26:胶囊对象与由边缘碰撞体构成的地形交互

恭喜!在本节中,我们使用边缘碰撞体组件创建了一个单个场景的完整地形。这个地形不仅适合屏幕并按预期显示,而且作为玩家角色和其他基于物理的对象的物理障碍。当然,到目前为止,我们一直在使用对玩家的大致近似,现在是时候扩展这一点了。

创建玩家

玩家角色是一个小型的绿色外星生物,可以通过游戏玩家使用许多传统的平台游戏机制(如行走、跳跃和互动)来控制和引导。在前一节中,我们构建了一个白箱(原型)角色来测试与环境的物理交互,但在这里,我们将更深入地开发玩家角色。图 5.27展示了本章早期导入的角色纹理,代表玩家的所有肢体和部分:

创建玩家

图 5.27:在统一纹理中角色的特征和肢体

图 5.27所示,玩家纹理被称为图集纹理精灵表,因为它在单个纹理空间中包含所有帧或角色的部分。这个纹理的问题在于,当从项目面板拖放到场景中时,它将被添加为一个单一的自包含精灵。这是因为 Unity 将所有单独的部分识别为一个单一的精灵。相反,这些应该被分割成独立的单元。参见图 5.28

创建玩家

图 5.28:玩家精灵纹理需要被分割成单独的部分

要在每条肢体上分别将角色纹理分割成单独的部分,我们将使用精灵编辑器。要访问此工具,请在项目面板中选择角色纹理。然后,从对象检查器中,将精灵模式单个更改为多个。然后,点击应用。接下来,点击精灵编辑器按钮以打开精灵编辑器工具,允许你将整个纹理切割成特定的切片。参见图 5.29

创建玩家

图 5.29:指定精灵为多个

使用精灵编辑器工具,你可以将纹理的不同部分分离成离散的单独单元。实现这一目标的一种方法是在每个应单独的图像区域周围绘制一个矩形,然后简单地点击并拖动鼠标来绘制纹理区域。参见图 5.30

创建玩家

图 5.30:手动绘制精灵

现在,尽管精灵可以像我们刚才看到的这样手动分离,但 Unity 通常可以自动切割纹理,识别像素的孤立区域,节省我们大量时间。我们将在这里为玩家角色执行此操作。要做到这一点,请点击位于精灵编辑器窗口左上角的切片按钮。参见图 5.31

创建玩家

图 5.31:访问切片工具

切片工具窗口中,确保类型设置为自动,这意味着 Unity 将自动检测单独精灵的位置。中心点可以保留在中心,这将确定每个精灵的旋转中心点。方法应该是删除现有,这意味着在纹理空间中任何现有的精灵或切片将被删除,并完全由新自动生成的切片替换。然后,点击切片按钮以确认操作,纹理将被切割成带有清晰边框的单独精灵。参见图 5.32

创建玩家

图 5.32:一个完全切片的精灵

纹理现在被分割成几个精灵:头部、身体、手臂和腿部。场景中的最终角色显然有两只手臂和两条腿,但它们将由复制的精灵形成。现在的最终步骤是为每个精灵设置轴点——精灵将围绕这个点旋转的点。这将对我们稍后正确动画化角色非常重要,正如我们将看到的。让我们先设置头部的轴点。在编辑器中选择头部精灵,然后点击并拖动轴点句柄(蓝色圆圈)以重新定位精灵的旋转中心。点击并拖动句柄到头部的底部中间,大致是头部与颈部相连的地方。这很有意义,因为头部将围绕这个点旋转和铰接。当你移动轴点时,你应该会看到XY值从精灵属性对话框的自定义轴点字段中改变,该对话框显示在精灵编辑器窗口的右下角。见图 5.33

创建玩家

图 5.33:重新定位精灵轴点

接下来,定位手臂的轴点,它应该在肩膀关节处,手臂与躯干相连的地方;然后是腿部,它应该在臀部附近,腿部与躯干相连的地方,最后是躯干本身,其轴点应该在臀部关节处。见图 5.34

创建玩家

图 5.34:定位躯干的轴点

完成后,点击应用按钮以确认更改,然后关闭精灵编辑器。返回主 Unity 界面后,项目面板中角色的纹理外观将已更改。具体来说,角色纹理特征是一个附在右侧的小箭头图标。当你点击它时,纹理会展开以查看一排所有单独的精灵,这些精灵可以单独拖放到场景中。见图 5.35

创建玩家

图:5.35:预览角色精灵

现在我们已经隔离了所有玩家精灵纹理,我们可以在场景中开始构建游戏角色。首先,从应用程序菜单使用GameObject | Create Empty命令创建一个空的游戏对象。将对象命名为Player,并在检查器中分配一个玩家标签。此对象将作为玩家角色的最终或最顶层父对象。作为此对象的子对象将包含角色的各个部分:躯干、手臂和腿部。因此,让我们将项目面板中的躯干精灵拖放到层次面板中,作为Player对象的子对象。见图 5.36

创建玩家

图 5.36:开始创建玩家角色

在添加了躯干之后,我们可以添加腿和手臂。手臂应该作为躯干的孩子添加,因为躯干决定了手臂的位置。然而,腿应该作为玩家对象的孩子添加,因此它们是躯干的兄弟,因为躯干可以独立于腿旋转。参见图 5.37以了解完整的层次结构安排。当你添加每个肢体时,你希望偏移其位置,以便它在与其他肢体相对的位置上正确显示——头部应该位于脚部之上,依此类推。

创建玩家

图 5.37:构建角色

身体部分的渲染顺序默认可能不正确,因为每个项目在 Sprite Renderer 组件中都将具有相同的顺序。这意味着 Unity 可能会以任何顺序渲染每个肢体,使得手臂可能出现在头部之前,腿可能出现在身体之前,等等。为了纠正这一点,我们将依次选择每个肢体并分配适当的顺序值,注意确保它高于世界背景顺序且低于世界前景顺序。我已经将身体分配了顺序值103,头部105,左臂102,右臂104,左腿100,右腿101。参见图 5.38

创建玩家

图 5.38:排序身体部分

肢体的渲染顺序现在已成功配置。让我们为玩家设置碰撞和物理属性。为此,添加两个碰撞器——一个圆形碰撞器来近似角色脚部,使我们能够确定角色何时接触地面,以及一个近似身体大部分(包括头部)的盒子碰撞器。这些碰撞器可以通过选择玩家对象(最顶层的对象)然后导航到组件 | 物理 2D | 圆形碰撞器 2D组件 | 物理 2D | 盒子碰撞器 2D来添加。参见图 5.39

创建玩家

图 5.39:向玩家对象添加两个碰撞器:圆形碰撞器和盒子碰撞器

圆形碰撞器特别重要,因为它是确定角色是否接触地面的主要手段,并且当角色移动时也会接触地面。因此,应为此碰撞器分配一个物理材质,以防止摩擦效果在角色在场景中移动时停止或破坏角色运动。为此,通过在项目面板中的空白区域右键单击并从上下文菜单中选择创建 | 物理 2D 材质来创建一个新的物理材质。将材质命名为低摩擦。参见图 5.40

创建玩家

图 5.40:创建新的物理材质

项目面板中选择 Physics2D 材质,并在检查器中,将摩擦设置更改为0.1。然后,将 Physics2D 材质从项目面板拖放到玩家对象上的 CircleCollider2D 组件的材质槽中。参见图 5.41。使用这些设置,角色将表现得更加逼真。

创建玩家

图 5.41:为玩家角色分配物理材质

然后,最后将 RigidBody2D 分配给玩家对象,并将线性阻尼重力缩放都设置为3。此外,将碰撞检测设置为连续以实现最精确的碰撞检测,并将对象的Z冻结旋转,因为玩家角色不应该旋转。现在,您已经拥有了一个代表玩家的完整物理对象。参见图 5.42。做得好!

创建玩家

图 5.42:为物理配置玩家角色

脚本化玩家移动

到目前为止,游戏包含一个具有碰撞数据的场景和一个多部分玩家对象,该对象与环境交互并对此做出响应。然而,玩家目前还不能被控制,本节将进一步探讨控制器功能。用户将有两个主要输入机制,即移动(左右走动)和跳跃。可以使用CrossPlatformInputManager无缝且轻松地读取这些输入,这是一个本地的 Unity 资产包。该包在项目创建阶段已导入,但现在可以通过应用程序菜单导入,即资产 | 导入包 | 跨平台输入。导入后,打开Standard Assets | CrossPlatformInput | Prefabs文件夹,并将MobileTiltControlRig预制件拖放到场景中。这个预制件允许您读取跨多种设备的输入数据,直接映射到我们在前几章中已经看到的水平和垂直轴。参见图 5.43

脚本化玩家移动

图 5.43:跨平台输入预制件提供多设备控制

现在让我们编写玩家控制脚本。为此,创建一个新的名为PlayerControl.cs的 C#脚本,并将其附加到玩家角色上。此文件的完整源代码在代码示例 5.1中给出:

//--------------------------------
using UnityEngine;
using System.Collections;
using UnityStandardAssets.CrossPlatformInput;
//--------------------------------
public class PlayerControl : MonoBehaviour
{
  //--------------------------------
  public enum FACEDIRECTION {FACELEFT = -1, FACERIGHT = 1};
  //Which direction is the player facing - left or right?
  public FACEDIRECTION Facing = FACEDIRECTION.FACERIGHT;
  //Which objects are tagged as ground
  public LayerMask GroundLayer;
  //Reference to rigidbody
  private Rigidbody2D ThisBody = null;
  //Reference to transform
  private Transform ThisTransform = null;
  //Reference to feet collider
  public CircleCollider2D FeetCollider = null;
  //Are we touching the ground?
  public bool isGrounded = false;
  //What are the main input axes
  public string HorzAxis = "Horizontal";
  public string JumpButton = "Jump";
  //Speed variables
  public float MaxSpeed = 50f;
  public float JumpPower = 600;
  public float JumpTimeOut = 1f;
  //Can we jump right now?
  private bool CanJump = true;
  //Can we control player?
  public bool CanControl = true;
  public static PlayerControl PlayerInstance = null;
  //--------------------------------
  public static float Health
  {
    get
    {
      return _Health;
    }

    set
    {
      _Health = value;

      //If we are dead, then end game
      if(_Health <= 0)
      {
        Die();
      }
    }
  }

  [SerializeField]
  private static float _Health = 100f;
  //--------------------------------
  // Use this for initialization
  void Awake ()
  {
    //Get transform and rigid body
    ThisBody = GetComponent<Rigidbody2D>();
    ThisTransform = GetComponent<Transform>();

    //Set static instance
    PlayerInstance = this;
  }
  //--------------------------------
  //Returns bool - is player on ground?
  private bool GetGrounded()
  {
    //Check ground
    Vector2 CircleCenter = new Vector2(ThisTransform.position.x, ThisTransform.position.y) + FeetCollider.offset;
    Collider2D[] HitColliders = Physics2D.OverlapCircleAll(CircleCenter, FeetCollider.radius, GroundLayer);
    if(HitColliders.Length > 0) return true;
    return false;
  }
  //--------------------------------
  //Flips character direction
  private void FlipDirection()
  {
    Facing = (FACEDIRECTION) ((int)Facing * -1f);
    Vector3 LocalScale = ThisTransform.localScale;
    LocalScale.x *= -1f;
    ThisTransform.localScale = LocalScale;
  }
  //--------------------------------
  //Engage jump
  private void Jump()
  {
    //If we are grounded, then jump
    if(!isGrounded || !CanJump)return;

    //Jump
    ThisBody.AddForce(Vector2.up * JumpPower);
    CanJump = false;
    Invoke ("ActivateJump", JumpTimeOut);
  }
  //--------------------------------
  //Activates can jump variable after jump timeout
  //Prevents double-jumps
  private void ActivateJump()
  {
    CanJump = true;
  }
  //--------------------------------
  // Update is called once per frame
  void FixedUpdate ()
  {
    //If we cannot control character, then exit
    if(!CanControl || Health <= 0f)
    {
      return;
    }

    //Update grounded status
    isGrounded = GetGrounded();
    float Horz = CrossPlatformInputManager.GetAxis(HorzAxis);
    ThisBody.AddForce(Vector2.right * Horz * MaxSpeed);

    if(CrossPlatformInputManager.GetButton(JumpButton))
      Jump();

    //Clamp velocity
    ThisBody.velocity = new Vector2(Mathf.Clamp(ThisBody.velocity.x, -MaxSpeed, MaxSpeed), 
      Mathf.Clamp(ThisBody.velocity.y, -Mathf.Infinity, JumpPower));

    //Flip direction if required
    if((Horz < 0f && Facing != FACEDIRECTION.FACELEFT) || (Horz > 0f && Facing != FACEDIRECTION.FACERIGHT))
      FlipDirection();
  }
  //--------------------------------
  void OnDestroy()
  {
    PlayerInstance = null;
  }
  //--------------------------------
  //Function to kill player
  static void Die()
  {
    Destroy(PlayerControl.PlayerInstance.gameObject);
  }
  //--------------------------------
  //Resets player back to defaults
  public static void Reset()
  {
    Health = 100f;
  }
  //--------------------------------
}
//--------------------------------

代码示例 5.1

以下要点总结了代码示例:

  • PlayerControl 类负责处理所有玩家输入,使角色左右移动和跳跃。

  • 要实现玩家移动,需要在ThisBody变量中保留对 RigidBody2D 组件的引用,该引用在Awake函数中检索。使用RigidBody2D.Velocity变量设置玩家的移动和运动。有关此变量的更多信息,可以在网上找到docs.unity3d.com/ScriptReference/Rigidbody2D-velocity.html

  • FlipDirection 函数用于反转精灵的水平缩放,使其根据需要面向左或右(例如,反转图像方向,1-1)。从 Unity 5.3 开始,可以使用 SpriteRenderer 组件的 Flip 属性来代替。

  • 使用 FixedUpdate 函数而不是 Update 来更新玩家角色的移动,因为我们正在使用 RigidBody2D——一个基于物理的组件。所有基于物理的功能都应该在 FixedUpdate 中更新,它每秒以固定间隔调用,而不是每帧调用。更多信息可以在 Unity 在线文档中找到,网址为 docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html

  • GetGrounded 函数检测任何 CircleCollider 与场景中特定图层上的任何其他碰撞器相交和重叠的位置。简而言之,此函数指示玩家角色是否在脚部位置接触地面。如果是这样,玩家可以跳跃;否则,玩家不能跳跃,因为他们已经在空中。本游戏中不允许双跳!

为了使前面的代码正确运行,需要对场景和玩家角色进行一些调整。具体来说,GetGrounded 函数要求关卡的地板区域在单个图层上分组。这意味着关卡前景应该与其他对象在不同的图层上。为了实现这一点,创建一个名为 Ground 的新图层,然后将前景对象分配到这个图层。要创建新图层,选择前景对象,然后从 对象检查器 中点击名为 图层 的下拉菜单。然后,从上下文菜单中选择添加图层。参见 图 5.44

代码示例 5.1

图 5.44:添加新图层

然后,通过在可用的输入字段中输入 Ground 来添加一个名为 Ground 的新图层。参见 图 5.45

代码示例 5.1

图 5.45:创建新的地面图层

现在,将前景对象分配到 Ground 图层。只需选择前景对象,然后从 对象检查器 中的 图层 下拉菜单中选择 Ground 图层。在前景对象分配到地面图层后,PlayerControl 脚本要求我们指出哪个图层被指定为地面。为此,选择 Player 对象,然后从 对象检查器 中选择 Ground 图层作为 地面图层 字段。参见 图 5.46

代码示例 5.1

图 5.46:选择用于碰撞检测的地面图层

此外,脚底碰撞器 槽也需要分配,以指示应使用哪个碰撞器对象进行地面碰撞检测。对于此字段,您需要将 CircleCollider 组件拖放到 脚底碰撞器 槽中。参见 图 5.47

代码示例 5.1

图 5.47:脚部碰撞体检测角色是否接触地面

现在,对玩家角色进行测试运行。只需在工具栏上单击播放图标,测试玩家角色的控制。WASD(或箭头键)可以移动玩家角色。空格键使角色跳跃。参见图 5.48

代码示例 5.1

图 5.48:玩家角色的游戏测试

优化

我们到目前为止的工作已经产生了一个有趣的环境和在这个环境中的可控角色。在继续前进之前,让我们将注意力转向优化——这是一个在开发早期就应该考虑的问题。优化指的是我们可以应用的技巧和窍门,以提高运行时性能以及我们的工作流程。在这里,我们将考虑预制件来改进我们的工作流程,以及精灵打包来提高运行时性能。让我们从预制件开始。

预制件是一种 Unity 资源,允许你将场景中的多个对象组合在一起,并将它们打包成一个单独的单元,可以将其作为资产添加到项目面板中。从这里,预制件可以作为完整的单元添加到任何其他场景或环境中,就像它是一个独立且完整的事物。玩家角色是预制件的理想候选者,因为它必须出现在我们创建的所有其他场景中。让我们从玩家角色创建一个预制件。为此,只需将玩家对象拖放到名为Prefabs的单独文件夹中的项目面板即可。参见图 5.49

优化

图 5.49:生成玩家预制件

预制件创建后,层次面板中的玩家对象名称将变为蓝色,表示它与预制件资产连接。这意味着如果你在项目面板中选择预制件并更改检查器中的内容,那么场景中的玩家对象将自动更改以匹配这些更改。然而,你可以通过选择场景中的玩家对象并从应用程序菜单中选择GameObject | Break Prefab Instance来断开玩家预制件之间的连接。这会将场景对象转换为预制件的独立且独立的副本。参见图 5.50

优化

图 5.50:断开预制件实例

然而,大多数时候,你希望保持对象与其预制件之间的连接。有时,你可能会在场景中的对象上进行更改,然后希望这些更改反馈到项目面板中的预制件资产,如果存在其他链接实例,则影响所有其他链接实例。为此,选择已更改的对象,然后从应用程序菜单中选择GameObject | Apply Changes to Prefab。参见图 5.51

优化

图 5.51:应用预制件更改

除了制作预制件外,你还希望优化 2D 游戏的渲染性能。目前,当运行游戏时,Unity 将为屏幕上每个独特的纹理或精灵执行独特的、独立的绘制调用。绘制调用简单来说就是 Unity 必须运行的一个步骤或过程周期,以正确地在屏幕上显示图形,如网格、材质或纹理。绘制调用代表计算开销,因此最好尽可能减少它们。

对于 2D 游戏,我们可以通过将相关的纹理批量在一起来减少绘制调用,例如场景中的所有道具、所有敌人或所有武器。也就是说,通过告诉 Unity 一组纹理属于一起,Unity 可以执行内部优化,从而提高渲染性能。具体来说,Unity 将将所有相关纹理粘贴到一个更大的内部纹理中,而不是使用多个纹理。为了实现这种优化,选择所有 道具 纹理。在我们的游戏中,我将包括 玩家房屋平台宝石 作为道具。这些纹理都在 项目 面板中,尽管并非所有都已在游戏中使用。选择这些纹理,并在检查器中为 打包标签 字段分配相同的名称(道具)。然后,点击 应用。见图 5.52:

优化

图 5.52:将多个纹理分配给相同的打包标签

现在,重复此过程为 背景,选择所有背景并将它们分配给 背景打包 标签。然后,点击 应用。见图 5.53:

优化

图 5.53:创建纹理批次的背景

就这样!现在,当你点击播放图标时,Unity 将会根据你的分组自动批量组织和优化纹理,以实现最佳性能。这项技术可以显著减少绘制调用。在点击播放图标时,你可能会看到一个加载条或进度条,因为 Unity 正在内部生成新的纹理集。在播放模式期间,你可以通过 Sprite Packer 窗口查看 Unity 如何组织纹理。要访问它,请选择 窗口 | Sprite Packer。见图 5.54:

优化

图 5.54:Unity 将所有具有相同标签的纹理组织到与 Atlas 相同的纹理空间中

摘要

了不起的工作!在这一章中,我们已经取得了长足的进步,从空白项目到有效的 2D 游戏,玩家角色可以导航一个完整的 2D 环境,并使用 2D 物理进行游戏。角色可以左右移动和跳跃,精灵纹理也会根据移动方向改变。此外,精灵打包已被用于优化运行时性能,这对于移动设备来说非常理想。在下一章中,我们将继续添加障碍物、可收集物品等更多内容!

第六章 继续 2D 冒险

在上一章中,我们开始了一个 2D 冒险游戏。到达这个阶段,我们现在已经创建了一个可控角色,该角色可以使用物理和碰撞检测以及重力来导航关卡。本章通过添加剩余的特性来完成 2D 游戏项目。具体来说,本章将涵盖以下主题:

  • 移动障碍物和如电梯平台等特性

  • 用于攻击玩家的枪塔

  • 带有任务系统的 NPC

    注意

    起始项目和资源可以在本书配套文件中的Chapter06/Start文件夹中找到。如果您还没有自己的项目,可以从这里开始,并跟随本章进行操作。

移动平台

现在让我们通过向现有场景添加一个移动元素来进一步细化冒险;具体来说,是一个移动平台对象。这个平台应该在循环中上下移动,在极端之间弹跳。玩家将能够跳上平台以搭乘,并且该对象将作为预制件构建,允许它在场景之间重用。请参阅图 6.1查看结果:

移动平台

图 6.1:创建移动平台

首先,在项目面板中选择平台纹理,确保在对象检查器中将其指定为精灵(2D 和 UI)纹理类型。精灵模式应设置为单个。将平台纹理拖放到场景中,并将其缩放设置为(0.7, 0.5, 1)。请参阅图 6.2

移动平台

图 6.2:构建移动平台

接下来,平台应该是一个固体对象,玩家可以与之碰撞的对象。记住,玩家应该能够站在平台上。因此,必须添加一个碰撞器。在这种情况下,2D 盒子碰撞器是合适的。要添加此碰撞器,请选择场景中的平台对象,并从菜单中选择组件 | 物理 2D | 盒子碰撞器 2D。请参阅图 6.3

移动平台

图 6.3:向平台添加碰撞器

在将碰撞器添加到平台后,您可能需要从对象检查器中调整其属性;具体来说,调整其偏移大小字段,以便使碰撞器与平台精灵的大小紧密匹配。然后,最后,通过进入游戏模式并在平台上放置玩家角色来测试平台。这样做的话,玩家不应该会穿过平台!请参阅图 6.4

移动平台

图 6.4:测试平台碰撞

到目前为止,平台是静态的,没有运动,它应该反复上下移动。为了解决这个问题,我们可以通过动画编辑器使用窗口|动画菜单选项创建一个预定义的动画序列。然而,我们将使用脚本文件。在制作动画时,你通常会需要做出决定:选择哪种选项最佳:C#动画烘焙动画。通常,当动画应该简单且必须应用于许多对象且各不相同时,应选择脚本。以下脚本文件PingPongMotion.cs应创建并附加到平台。参见代码示例 6.1,代码注释如下:

using UnityEngine;
using System.Collections;
//--------------------------------
public class PingPongMotion : MonoBehaviour 
{
  //--------------------------------
  //This transformation
  private Transform ThisTransform = null;

  //Original position
  private Vector3 OrigPos = Vector3.zero;

  //Axes to move on
  public Vector3 MoveAxes = Vector2.zero;

  //Speed
  public float Distance = 3f;
  //--------------------------------
  // Use this for initialization
  void Awake ()
  {
    //Get transform component
    ThisTransform = GetComponent<Transform>();

    //Copy original position
    OrigPos = ThisTransform.position;
  }
  //--------------------------------
  // Update is called once per frame
  void Update () 
  {
    //Update platform position with ping pong
    ThisTransform.position = OrigPos + MoveAxes * Mathf.PingPong(Time.time, Distance);
  }
  //--------------------------------
}
//--------------------------------

代码示例 6.1

以下要点总结了代码示例:

  • PingPongMotion类负责将GameObject从一个原始起始点来回移动。

  • Awake函数使用OrigPos变量记录GameObject的起始位置。

  • Update函数依赖于Mathf.PingPong函数在最小值和最大值之间平滑地转换一个值。这个函数在时间上反复连续地在一个最小值和最大值之间波动一个值,允许你线性地移动对象。有关更多信息,请参阅 Unity 在线文档docs.unity3d.com/ScriptReference/Mathf.PingPong.html

完成的代码应附加到场景中的平台对象上,并且可以轻松地用于任何其他需要定期上下(或左右)移动的对象。

创建其他场景 – 级别 2 和 3

与书中迄今为止创建的其他游戏不同,我们的冒险游戏将跨越多个场景。也就是说,我们的游戏有几个不同的屏幕,玩家可以通过从一个屏幕的边缘走开并从另一个屏幕的边缘进入来在这些屏幕之间移动。支持此功能使我们接触到一些新的有趣问题,这些问题在 Unity 中值得探索,正如我们稍后将会看到的。现在,让我们为游戏创建第二个和第三个场景,使用剩余的背景和前景对象,并为每个级别配置碰撞,使玩家预制物能够与每个环境无缝工作。创建具有碰撞(边缘碰撞器)的级别的详细信息在前一章中进行了深入探讨。最终的完成场景如下:

  • 第二级被分成两个垂直排列的台面,下面台面上有一组移动平台。这些平台是从上一节中创建的移动平台预制件中创建的。目前,上面的台面是无害的,但稍后我们将添加可以射击玩家角色的枪塔,这将改变这一点。这个级别可以通过从第一个原始级别的左侧边缘走开进入。参见图 6.5创建其他场景 – 级别 2 和 3

    图 6.5:场景 2 – 危险的台面和移动平台

  • 第三级是通过从第一个原始级别向屏幕右侧边缘走过去达到的。它由一个地面平面组成,上面有一座房子。它将成为一个 NPC 角色的家,玩家可以遇到并接受收集物品的任务。这个角色将在本章后面创建。参见 图 6.6创建其他场景 – 级别 2 和 3

    图 6.6:场景 3 – 一个 NPC 的孤独房子

第二级和第三级完全使用迄今为止看到的技巧创建。然而,为了给每个场景增添其独特的魅力和个性,必须添加一些独特元素——其中一些是特定于每个场景的,而另一些则更通用。现在让我们逐一考虑这些元素。

杀戮区域

所有场景都需要的一个常见脚本功能,但尚未实现,是杀戮区域。也就是说,在级别中标记出 2D 空间区域的功能,当玩家进入该区域时,会杀死他们或对他们造成伤害。这在玩家从地面上的洞中掉下来时特别有用。因此,每个级别都需要杀戮区域,因为到目前为止创建的每个级别都包含地面上的坑和洞。要实现此功能,在任意场景中创建一个新的空GameObject。(这无关紧要,因为我们将会创建一个可以在任何地方重复使用的预制对象。)如前所述,使用菜单选项GameObject | Create Empty创建新的GameObject。一旦创建,将对象命名为KillZone,然后将其定位在世界原点(0,0,0),最后,使用菜单命令Component | Physics 2D | Box Collider 2D附加一个 Box Collider 2D 组件。Box Collider 将定义杀戮区域区域。请确保将Box Collider 2D配置为触发器,通过在Box Collider 2D组件的检查器中勾选Is Trigger复选框来实现。参见 图 6.7。触发器与碰撞体不同;碰撞体阻止对象穿过,而触发器检测对象是否穿过,允许您执行自定义行为。

杀戮区域

图 6.7:创建杀戮区域对象和触发器

接下来,创建一个新的脚本文件 KillZone.cs,该文件应附加到场景中的杀戮区域对象上。此脚本文件负责在玩家处于杀戮区域期间对其健康造成伤害。在此阶段,有几种方法可以实现杀戮区域。一种方法是在玩家进入杀戮区域时立即摧毁他们。另一种方法是在玩家处于杀戮区域期间持续对其造成伤害。这里更倾向于第二种方法,因为它具有多功能性和代码重用的贡献。具体来说,我们有机会通过以特定速度减少玩家的健康值(如果需要的话)以及通过增加减少速度来立即杀死玩家。让我们在以下 代码示例 6.2 中看看它是如何工作的:

//--------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------
public class KillZone : MonoBehaviour {
    //--------------------------------
    //Amount to damage player per second
    public float Damage = 100f;
    //--------------------------------
    void OnTriggerStay2D(Collider2D other)
    {
        //If not player then exit
        if(!other.CompareTag("Player"))return;

        //Damage player by rate
        if(PlayerControl.PlayerInstance!=null)
            PlayerControl.Health -= Damage * Time.deltaTime;
    }
    //--------------------------------
}
//--------------------------------

代码示例 6.2

  • KillZone 类负责在标记为 PlayerGameObject 进入并保持在触发体积内时,持续损害玩家健康。

  • OnTriggerStay2D 函数由 Unity 自动调用,每次帧更新时,当具有 RigidBody 的对象进入并保持在触发体积内时。因此,当一个物理对象进入 Kill Zone 触发器时,OnTriggerStay2D 函数将被调用,其频率与 Update 函数相同。有关 OnTriggerStay2D 的更多信息,请参阅在线 Unity 文档 docs.unity3d.com/ScriptReference/MonoBehaviour.OnTriggerStay2D.html

  • Damage 变量通过调整 PlayerControl 类中的公共静态属性 Health 来编码玩家健康的减少。当 Health 达到 0 时,玩家将被销毁。

现在,运行游戏进行测试,在场景中标记出 Kill Zone,并在游戏模式中将玩家走进去。进入时,玩家角色应该被销毁或损坏。为了确保玩家被立即杀死,将伤害增加到非常高的数值,例如 9000!测试完成后,从场景的 Hierarchy 面板拖放到 Prefab 文件夹中的 Project 面板,创建 Kill Zone 的预制件。然后,将 Kill Zone 预制件添加到每个级别,根据需要调整和调整 Collider。参见 图 6.8

代码示例 6.2

图 6.8:配置接触时销毁的 Kill Zone

UI 健康条

在上一节中,我们介绍了游戏中的第一个危险和危害;即一个可以损害并可能杀死玩家的 Kill Zone。因此,他们的健康有可能从起始状态减少。因此,对于我们作为开发人员和玩家来说,可视化健康状态非常有用。因此,让我们专注于将玩家健康渲染到屏幕上的 UI 健康条。这种对象配置也将被制作成预制件,以便在多个场景中重复使用,这将是一个非常有用的功能。图 6.9 展示了未来的一个缩影,显示了我们将要完成的工作的结果:

UI 健康条

图 6.9:准备创建玩家健康

要开始,请从应用程序菜单中选择 GameObject | UI | Canvas 在场景(任何场景)中创建一个新的 GUI 画布。选择此选项将在场景中自动创建一个 EventSystem 对象(如果尚未存在)。此对象对于正确使用 UI 系统至关重要。如果您意外删除它,可以从应用程序菜单中选择 GameObject | UI | Event System 来重新创建 EventSystem。新创建的画布对象代表 GUI 将被绘制到的表面。参见 图 6.10

UI 健康条

图 6.10:创建 GUI 画布和事件系统

接下来,我们将为 UI 创建一个新的独立相机对象,将其添加为新创建的画布的子对象。通过为 UI 渲染创建一个独立的相机,如果我们需要的话,我们可以单独对 UI 应用相机效果和其他图像调整。要创建一个作为子对象的相机,在层次结构面板中右键单击画布对象,从上下文菜单中选择相机。这将在场景中添加一个新的相机对象,作为所选对象的子对象。参见图 6.11

UI 健康条

图 6.11:创建相机子对象

现在,配置 UI 相机为正交相机。我们之前章节以及更早的章节中已经看到了如何做到这一点。图 6.12显示了正交相机的相机设置。记住,正交相机在去除渲染结果的透视和缩短效果方面确实是 2D 的,这对于 GUI 和其他在屏幕空间中存在和工作的对象是合适的。此外,从对象检查器中的相机深度字段,应该比主游戏相机高,以确保它渲染在所有其他内容之上。否则,GUI 可能会潜在地渲染在下面,并且在游戏中无效。

UI 健康条

图 6.12:为 GUI 渲染配置正交相机

创建的相机几乎准备好了!然而,目前,它被配置为像任何其他相机一样渲染场景中的所有内容。这意味着场景实际上正由两个独立的相机渲染两次。这不仅浪费资源且对性能不利,而且使第二个相机完全不必要的。相反,我们希望第一个和原始相机在角色和环境方面显示场景中的所有内容,但忽略 GUI 对象,同样,新创建的 GUI 相机应仅显示 GUI 对象。要修复此问题,选择主游戏相机,并在对象检查器中,点击相机组件中的剔除遮罩下拉列表。从这里,取消勾选 UI 层的勾选标记。此下拉列表允许您从所选相机中选择要忽略的层。参见图 6.13

UI 健康条

图 6.13:对于主相机忽略 UI 层

现在,选择 GUI 相机对象,并在相机组件中的剔除遮罩字段中,选择选项以取消选择所有选项,然后启用 UI 层以仅渲染 UI 层对象。参见图 6.14。做得好!

UI 健康条

图 6.14:对于 GUI 相机忽略所有层除了 UI 层

默认情况下,任何新创建的画布都配置为在屏幕空间叠加模式下工作,这意味着它渲染在场景中与任何特定相机无关的所有其他内容之上。此外,所有 GUI 元素都将基于此进行尺寸和缩放。因此,为了使我们的工作更简单,让我们首先通过配置画布对象与新建的 GUI 相机一起工作来开始创建 GUI。为此,选择画布对象,并从对象检查器中的画布组件,将渲染模式屏幕空间 - 叠加更改为屏幕空间 - 相机。然后,将 GUI 相机对象拖放到相机字段。见图 6.15:

UI 健康条

图 6.15:配置画布组件以进行相机渲染

接下来,让我们配置附加到画布对象的画布缩放器组件。该组件负责当屏幕大小改变时 GUI 的显示方式,无论是放大还是缩小。简而言之,对于我们的游戏,GUI 应该相对于屏幕大小进行放大和缩小。因此,将UI 缩放模式下拉菜单更改为与屏幕大小缩放,然后在参考分辨率字段中输入游戏分辨率1024 x 600。见图 6.16:

UI 健康条

图 6.16:调整画布缩放器以实现响应式 UI 设计

现在,我们可以开始向游戏中添加 GUI 元素,知道它们在添加到场景时将正确显示。要显示健康状态,玩家的表示将很有用。通过在层次结构面板中右键单击画布对象并从上下文菜单中选择UI | 图像来创建一个新的图像对象。创建后,选择图像对象,并从对象检查器(在图像组件中),将项目面板中的玩家头部精灵拖放到源图像字段。然后,使用矩形变换工具(键盘上的T键)在屏幕左上角就地调整图像大小。见图 6.17:

UI 健康条

图 6.17:将头部图像添加到 GUI 画布

注意

如果你看不到添加的头部图像,请记住将 UI 层分配给 UI 相机进行渲染。此外,你可能需要将 GUI 相机沿Z轴向后偏移,以便将头部精灵包含在相机视锥体(视区)内。

最后,通过在对象检查器中的矩形变换组件上点击锚点预设按钮,将头部精灵固定到屏幕的左上角。选择左上对齐。这将锁定头部精灵到屏幕的左上角,确保在多个分辨率下界面看起来一致。见图 6.18:

UI 健康条

图 6.18:固定头部位置

要创建健康条,通过在 Canvas 上右键单击并从上下文菜单中选择 UI | Image 来在 GUI 画布中添加一个新的 Image 对象。对于此对象,保留 Source Image 字段为空,并将 Color 字段选择为红色,RGB (255,0,0)。这将代表健康条完全耗尽时的背景或 红色状态。然后,使用 Rect Transform 工具根据需要调整条的尺寸,并锚定到屏幕的左上角。参见 图 6.19

UI 健康条

图 6.19:创建红色健康状态

要完成健康条,我们需要使用脚本。具体来说,我们将在两个相同的健康条上方重叠,一个红色,一个绿色。随着健康的减少,我们将调整绿色条的尺寸,以便露出下面的红色条。在编写脚本之前,还需要进行进一步的配置。具体来说,让我们将健康条的支点从中心移动到中间左侧的点——这是健康条应该根据其减少和增加而缩放的点。为此,选择 Health bar 对象,并在 Object Inspector 中为 X 输入新的 Pivot0,为 Y 输入 0.5。参见 图 6.20

UI 健康条

图 6.20:重新定位健康条的支点

要创建健康条的绿色覆盖层,选择红色健康条并复制它。将复制品命名为 Health_Green 并将其拖放到 Hierarchy 面板中红色版本下方。层次结构中对象的顺序与 GUI 元素的绘制顺序相关——较低顺序的对象在较高顺序的对象上方绘制。参见 图 6.21

UI 健康条

图 6.21:创建一个重复的绿色条

现在,我们需要创建一个新的脚本文件,将绿色条的宽度与玩家的健康状态相链接。这意味着健康状态的减少将减少绿色条的宽度,从而露出下面的红色条。创建一个名为 HealthBar.cs 的新脚本文件,并将其附加到绿色条上。以下是为 HealthBar 类提供的 代码示例 6.3

using UnityEngine;
using System.Collections;

public class HealthBar : MonoBehaviour
{
  //Reference to this transform component
  private RectTransform ThisTransform = null;

  //Catch up speed
  public float MaxSpeed = 10f;

    void Awake()
    {
      //Get transform component
      ThisTransform = GetComponent<RectTransform>();
    }

    void Start()
    {
      //Set Start Health
      if(PlayerControl.PlayerInstance!=null)
        ThisTransform.sizeDelta = new Vector2(Mathf.Clamp(PlayerControl.Health,0,100),ThisTransform.sizeDelta.y);
    }

    // Update is called once per frame
    void Update () 
    {
      //Update health property
      float HealthUpdate = 0f;

      if(PlayerControl.PlayerInstance!=null)
        HealthUpdate = Mathf.MoveTowards(ThisTransform.rect.width, PlayerControl.Health, MaxSpeed);

        ThisTransform.sizeDelta = new Vector2(Mathf.Clamp(HealthUpdate,0,100),ThisTransform.sizeDelta.y);
    }
}

代码示例 6.3

以下要点总结了代码示例:

最后,通过将 Hierarchy 面板中最顶部的 Canvas 对象拖放到 Prefab 文件夹中的 Project 面板中,创建 UI 对象的预制件。这允许 UI 系统在多个场景中重复使用。

弹药和危险

第 2 级别是一个危险的地方。它不仅应该有通向死亡区域的坑洞和洞穴,还应该有固定危险,如可以射击玩家的炮塔。本节重点介绍它们的创建。要开始,让我们制作一个枪炮塔。现在,课程配套文件不包括枪炮塔的纹理或图像,但当我们使用这里使用的暗影轮廓风格时,我们可以很容易地从原始形状中制作出一致的炮塔道具。特别是,创建一个新的立方体对象(GameObject | 3D Object | Cube),将其缩放以近似枪炮塔,然后将其放置在场景中上方的边缘,使其成为风景的一部分。参见 图 6.22。注意,您还可以使用 Rect Transform 工具调整原始形状的大小!

弹药和危险

图 6.22:创建枪炮塔的道具

当然,到目前为止创建的枪炮塔是一个显眼的灰色。为了解决这个问题,创建一个新的黑色材质。在 Project 面板中右键单击,并从上下文菜单中选择 Create | Material。从 Object Inspector 中的 Albedo 字段分配黑色颜色,然后将材质从 Project 面板拖放到场景中的 Turret 对象上。确保将黑色材质的 Smoothness 字段降低到 0 以防止出现闪亮或发光的外观。材质分配后,炮塔将与场景混合,其颜色方案将变得更好!参见 图 6.23

弹药和危险

图 6.23:为炮塔分配黑色材质

现在,炮塔必须发射弹药。为了实现这一点,它需要一个空的游戏对象来生成弹药。让我们现在通过选择 GameObject | Create Empty 并将对象拖放到 Hierarchy 面板中的炮塔立方体上,使其成为炮塔的子对象。然后,将空对象放置在炮管的尖端。一旦定位好,为空对象分配一个图标表示,使其在视图中可见。确保空对象被选中,并在检查器中点击立方体图标(在对象名称旁边)以分配一个图形表示。参见 图 6.24

弹药和危险

图 6.24:为炮塔生成点分配图标

在进一步处理弹药生成之前,我们实际上需要一些弹药来生成。也就是说,炮塔必须发射某种东西,现在是创建这种东西的时候了。弹药应呈现为发光且脉动的等离子球。要构建这个,从应用程序菜单中选择GameObject | ParticleSystem来创建一个新的粒子系统。记住,粒子系统对于创建特殊效果,如雨、火、灰尘、烟雾、火花等非常有用。当您从主菜单创建一个新的粒子系统时,场景中会创建一个新的对象,并且自动选中。选中后,您可以在场景视图中预览粒子系统的工作方式和外观。默认情况下,系统将生成类似小滴的粒子。参见图 6.25

弹药和危险

图 6.25:创建粒子系统

有时,在为 2D 游戏创建粒子系统时,粒子本身可能不可见,因为它们出现在场景中其他 2D 对象之后,例如背景和角色。您可以从对象检查器中控制粒子系统的深度顺序。在对象检查器中向下滚动并单击渲染器展开标题以展开更多选项,使它们可见。在渲染器组中,将层内顺序字段设置为比其他对象渲染顺序更高的值,以便将粒子渲染在前面。参见图 6.26

弹药和危险

图 6.26:控制粒子的渲染顺序

很好,我们现在应该在视图中看到粒子。要使粒子系统看起来和表现正确,需要进行一些调整和尝试错误。这涉及到测试设置,在视图中预览其效果,对所需内容做出判断,然后根据需要调整和修改。为了开始创建一个更可信的弹药对象,我希望粒子以多个方向缓慢生成,而不仅仅是单一方向。为了实现这一点,从对象检查器中展开形状字段以控制生成表面的形状。将形状圆锥更改为球体,并将半径设置为0.01。这样做后,粒子将从球体表面发出的所有方向生成并移动。参见图 6.27

弹药和危险

图 6.27:更改粒子系统发射器的形状

现在,调整主要粒子系统属性以创建能量球效果。从对象检查器中,将开始寿命设置为0.19开始速度设置为0.88开始大小设置为0.59。然后,将开始颜色设置为青色(浅蓝色)。参见图 6.28

弹药和危险

图 6.28:配置粒子系统的主要属性

太好了!现在粒子系统应该看起来正是我们所需要的。然而,如果我们按工具栏上的播放按钮,它不会移动。弹药当然应该猛冲穿过空气并与其目标碰撞。所以,让我们创建一个Mover脚本,并将其附加到对象上。以下代码如下:

using UnityEngine;
using System.Collections;
//--------------------------------
public class Mover : MonoBehaviour 
{
    //--------------------------------
    public float Speed = 10f;
    private Transform ThisTransform = null;
    //--------------------------------
    // Use this for initialization
    void Awake() 
    {
        ThisTransform = GetComponent<Transform>();
    }
    //--------------------------------
    // Update is called once per frame
    void Update () 
    {
        //Update object position
        ThisTransform.position += ThisTransform.forward * Speed * Time.deltaTime;
    }
    //--------------------------------
}
//-------------------------------- 

Mover 功能没有我们之前没有多次见过的。它沿着其前向向量移动一个对象(弹药)。因此,由于我们的游戏是二维的,粒子系统对象可能需要旋转,以便将前向向量沿X轴对齐。参见图 6.29

弹药和危险

图 6.29:将前向向量对齐到 X 轴

接下来,除了在关卡中移动外,弹药对象在碰撞时还必须与玩家角色碰撞并造成伤害。为了实现这一点,必须采取几个步骤。首先,必须将 Rigidbody 组件附加到弹药上,使其能够与其他对象碰撞。要添加 Rigidbody,请在场景中选择弹药对象,然后从应用程序菜单中选择组件 | 物理 | Rigidbody2D。一旦添加,从对象检查器中的 Rigidbody 组件中启用是运动学复选框。这确保了对象将根据 Mover 脚本移动,并且仍然与物理对象交互,而不会受到重力的影响。参见图 6.30

弹药和危险

图 6.30:标记 Rigidbody 为运动学

现在为弹药对象添加一个圆形碰撞器,使其在物理上具有形状、形式和大小,以便检测弹药与其目标之间的碰撞。为此,从应用程序菜单中选择组件 | 物理 2D 圆形碰撞器。一旦添加,将碰撞器标记为触发器,并更改半径,直到它大致等于弹药对象的大小。参见图 6.31

弹药和危险

图 6.31:配置弹药对象的圆形碰撞器

弹药应支持两种最终和附加行为。首先,弹药应损坏并可能销毁它与之碰撞的任何目标,其次,弹药应在经过一段时间后以及如果它与目标碰撞时销毁自身。为了实现这一点,将创建两个额外的脚本;具体来说,是CollideDestroy.csAmmo.cs。以下代码列出了Ammo.cs文件:

using UnityEngine;
using System.Collections;
//-----------------------------------------
public class Ammo : MonoBehaviour
{    
    //-----------------------------------------
    //Damage inflicted on Player
    public float Damage = 100f;

    //Lifetime for ammo
    public float LifeTime = 1f;
    //-----------------------------------------
    void Start()
    {
        Invoke ("Die", LifeTime);
    }//-----------------------------------------

    void OnTriggerEnter2D(Collider2D other)
    {
        //If not player then exit
        if(!other.CompareTag("Player"))return;

        //Inflict damage
        PlayerControl.Health -= Damage;
    }
    //-----------------------------------------
    public void Die()
    {
        Destroy(gameObject);
    }
}
//-----------------------------------------

以下代码列出了CollideDestroy.cs文件:

//--------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------
public class CollideDestroy : MonoBehaviour
{
    //--------------------------------
    //When hit objects with associated tag, then destroy
    public string TagCompare = string.Empty;
    //--------------------------------
    void OnTriggerEnter2D(Collider2D other)
    {
        if(!other.CompareTag(TagCompare))return;

        Destroy(gameObject);
    }
    //--------------------------------
}
//-------------------------------- 

涵盖这些文件的代码是我们之前在制作 Twin-stick Space Shooter 时遇到的功能。这两个文件都应该附加到场景中的弹药对象上。完成后,只需将场景视图中弹药对象拖放到项目面板中的预制体文件夹即可。这样就创建了一个弹药预制体,可以添加到任何场景中。参见图 6.32

弹药和危险

图 6.32:将销毁和弹药组件添加到弹药中,然后创建预制体

太棒了!你现在有一个可以发射、移动并与玩家发生碰撞的弹药对象。通过将伤害设置得足够高,你将能够在碰撞时摧毁玩家。现在通过向场景添加一个弹药对象并按下播放图标来测试一下。当然,目前场景中实际上并没有东西在发射弹药。我们将在下一节中探讨这个问题。

枪塔和弹药

我们现在已经创建了一个弹药对象(一个投射物),并且已经开始设计一个炮塔对象,但它还没有生成弹药。现在让我们创建这个功能。我们在炮塔父对象前面放置了一个作为子对象的生成点。我们将一个名为AmmoSpawner.cs的新脚本文件附加到这个对象上。这个脚本负责定期生成弹药。参考以下代码:

//--------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------
public class AmmoSpawner : MonoBehaviour 
{
    //--------------------------------
    //Reference to ammo prefab
    public GameObject AmmoPrefab = null;

    //Reference to transform
    private Transform ThisTransform = null;

    //Vector for time range
    public Vector2 TimeDelayRange = Vector2.zero;

    //Lifetime for ammo spawned
    public float AmmoLifeTime = 2f;

    //Ammo Speed
    public float AmmoSpeed = 4f;

    //Ammo Damage
    public float AmmoDamage = 100f;
    //--------------------------------
    void Awake()
    {
        ThisTransform = GetComponent<Transform>();
    }
    //--------------------------------
    void Start()
    {
        FireAmmo();
    }
    //--------------------------------
    public void FireAmmo()
    {
        GameObject Obj = Instantiate(AmmoPrefab, ThisTransform.position, ThisTransform.rotation) as GameObject;
        Ammo AmmoComp = Obj.GetComponent<Ammo>();
        Mover MoveComp = Obj.GetComponent<Mover>();
        AmmoComp.LifeTime = AmmoLifeTime;
        AmmoComp.Damage = AmmoDamage;
        MoveComp.Speed = AmmoSpeed;

        //Wait until next random interval
        Invoke("FireAmmo", Random.Range(TimeDelayRange.x, TimeDelayRange.y));
    }
    //--------------------------------
}
//--------------------------------

之前的代码依赖于在随机间隔内使用Random.Range调用的Invoke函数,以便在场景中实例化一个新的弹药预制件。这个代码可以通过使用上一章中讨论的对象池(或缓存)来改进,但在这种情况下,代码的表现是可以接受的。参见图 6.33

枪塔和弹药

图 6.33:时间延迟为(0,0)会在一条光束中不断生成弹药。增加值以在弹药生成之间插入合理的间隔

太棒了!我们现在已经创建了一个炮塔,就像弹药本身一样,可以被转换成预制件。确保将时间延迟范围(弹药生成的间隔时间)设置为大于零的值;否则,弹药将不断生成,玩家几乎无法避免。如果需要,可以继续放置更多炮塔,以平衡场景的难度。

NPC 和任务

NPC代表非玩家角色,通常指除玩家控制的角色之外的所有友好或中立角色。在我们的冒险中,第 3 级应该有一个站在房子外面的 NPC 角色,他们为我们提供任务;具体来说,是从第 2 级收集宝石物品,第 2 级有许多危险,包括坑和枪塔,正如我们所见。为了创建 NPC 角色,我们只需复制玩家并调整角色颜色,使他们看起来与众不同。因此,只需将玩家预制件从项目面板拖放到第 2 级场景中,并将其放置在房子附近。然后,移除所有其他组件(如玩家控制器和碰撞器),将这个角色恢复为标准精灵,不再是玩家控制的。参见图 6.34

NPC 和任务

图 6.34:从玩家角色预制件创建 NPC

现在,让我们反转角色的X轴缩放,使其面向左边而不是右边。选择 NPC 父对象而不是其组成部分,如手和手臂,并反转其X轴缩放。所有子对象都将翻转以面向其父对象的方向。参见图 6.35

NPCs and quests

图 6.35:翻转 NPC 角色的 X 轴缩放

我们还应该将 NPC 的颜色从绿色改为红色,以便与玩家区分开来。现在,这个角色是一个由几个 sprite renderers 组成的 multipart 对象。我们可以选择每个对象并单独通过Object Inspector更改其颜色。然而,选择所有对象一起更改它们的颜色会更简单;Unity 5 支持多对象编辑以编辑常见属性。见图 6.36

NPCs and quests

图 6.36:设置 NPC 颜色

NPC 应该在接近玩家时与玩家交谈。这意味着当玩家接近 NPC 时,NPC 应该显示对话框文本。要显示的文本根据他们的任务状态而变化。在第一次访问时,NPC 会给玩家一个任务。在第二次访问时,NPC 会根据在此期间任务是否完成而有所不同。为了开始创建这个功能,我们需要确定玩家何时接近 NPC。这是通过使用 Collider 实现的。因此,在场景中选择 NPC 对象,然后从应用程序菜单中选择Component | Physics 2D | Box Collider 2D。调整碰撞器的大小,使其不精确地近似 NPC,而是近似 NPC 周围的区域,玩家应该进入该区域进行对话。确保将碰撞器标记为 Trigger 对象,允许玩家进入并通过。见图 6.37

NPCs and quests

图 6.37:配置 NPC 碰撞器

在这个阶段,我们需要一个 GUI 元素作为对话面板来显示 NPC 说话时的对话文本。这个配置简单由一个 GUI Canvas 对象及其子 Text 对象组成。这两个对象都可以通过应用程序菜单中的GameObject | UI | Canvas and GameObject | UI | Text分别创建。Canvas 对象还应通过Component | Layout | CanvasGroup菜单选项附加一个 CanvasGroup 组件。这允许你将面板及其子对象的 alpha 透明度作为一个整体设置。Alpha 成员可以从Object Inspector中更改。值为1表示完全可见,值为0表示完全透明。见图 6.38

NPCs and quests

图 6.38:将 Canvas Group 组件添加到 GUI 对话面板

极好。我们现在有了能力,如果需要的话,可以通过在一段时间内将 Alpha 值从 0 动画到 1 来简单地淡入淡出面板。然而,我们仍然需要功能来维护任务信息,以确定任务是否已被分配,以及根据任务完成状态确定应该显示在对话中的文本。为此,必须创建一个新的类,QuestManager.cs。这个类将允许我们创建和维护任务信息。请参阅 代码示例 6.8

//--------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------
[System.Serializable]
public class Quest
{
    //Quest completed status
    public enum QUESTSTATUS {UNASSIGNED=0,ASSIGNED=1,COMPLETE=2};
    public QUESTSTATUS Status = QUESTSTATUS.UNASSIGNED;
    public string QuestName = string.Empty;
}
//--------------------------------
public class QuestManager : MonoBehaviour
{
    //--------------------------------
    //All quests in game
    public Quest[] Quests;
    private static QuestManager SingletonInstance = null;
    public static QuestManager ThisInstance
    {
        get{
                if(SingletonInstance==null)
                {
                    GameObject QuestObject = new GameObject ("Default");
                    SingletonInstance = QuestObject.AddComponent<QuestManager>();
                }
                return SingletonInstance;
            }
    }
    //--------------------------------
    void Awake()
    {
        //If there is an existing instance, then destory
        if(SingletonInstance)
        {
            DestroyImmediate(gameObject);
            return;
        }

        //This is only instance
        SingletonInstance = this;
        DontDestroyOnLoad(gameObject);
    }
    //--------------------------------
    public static Quest.QUESTSTATUS GetQuestStatus(string QuestName)
    {
        foreach(Quest Q in ThisInstance.Quests)
        {
            if(Q.QuestName.Equals(QuestName))
                return Q.Status;
        }

        return Quest.QUESTSTATUS.UNASSIGNED;
    }
    //--------------------------------
    public static void SetQuestStatus(string QuestName, Quest.QUESTSTATUS NewStatus)
    {
        foreach(Quest Q in ThisInstance.Quests)
        {
            if(Q.QuestName.Equals(QuestName))
            {
                Q.Status = NewStatus;
                return;
            }
        }
    }
    //--------------------------------
    //Resets quests back to unassigned state
    public static void Reset()
    {
        if(ThisInstance==null)return;

        foreach(Quest Q in ThisInstance.Quests)
            Q.Status = Quest.QUESTSTATUS.UNASSIGNED;

    }
    //--------------------------------
}
//--------------------------------

代码示例 6.8

以下要点总结了代码示例:

  • QuestManager 维护所有任务(Quest)的列表。也就是说,游戏内所有可能任务列表,而不仅仅是已分配或完成的任务列表。Quest 类定义了单个特定任务的名称和状态。

  • 任何单个任务可以是 UNASSIGNED(意味着玩家尚未收集它),ASSIGNED(玩家已收集但未完成),或者 COMPLETE(玩家已收集并完成)。

  • GetQuestStatus 函数检索指定任务的完成状态。SetQuestStatus 函数将新状态分配给指定任务。这些是静态函数,因此任何脚本都可以在任何地方设置或获取这些数据。

要使用此对象,在场景中(游戏的第一场景)创建一个实例,然后通过 Object Inspector 定义所有可以收集的任务。在我们的游戏中,只有一个任务可用:由 NPC 角色给出的任务,从第 2 级收集被盗的宝石,该场景由机枪炮塔保护。请参阅 图 6.39 了解如何配置与 Quest Manager 一起工作的任务:

代码示例 6.8

图 6.39:通过 QuestManager 定义游戏中的任务

QuestManager 定义了游戏中所有可能的任务,无论玩家是否收集。然而,NPC 仍然需要在接近时将任务分配给玩家。这可以通过脚本文件 QuestGiver.cs 实现。请参阅以下代码。此脚本文件应附加到任何提供任务的物品上,例如 NPC:

//--------------------------------
using UnityEngine;
using System.Collections;
using UnityEngine.UI;
//--------------------------------
public class QuestGiver : MonoBehaviour
{
    //--------------------------------
    //Human readable quest name
    public string QuestName = string.Empty;
    //Reference to UI Text Box
    public Text Captions = null;
    //List of strings to say
    public string[] CaptionText;
    //--------------------------------
    void OnTriggerEnter2D(Collider2D other) 
    {
        if(!other.CompareTag("Player"))return;

        Quest.QUESTSTATUS Status = QuestManager.GetQuestStatus(QuestName);
        Captions.text = CaptionText[(int) Status]; //Update GUI text
    }
    //--------------------------------
    void OnTriggerExit2D(Collider2D other) 
    {
        Quest.QUESTSTATUS Status = QuestManager.GetQuestStatus(QuestName);
        if(Status == Quest.QUESTSTATUS.UNASSIGNED)
            QuestManager.SetQuestStatus(QuestName, Quest.QUESTSTATUS.ASSIGNED);

        if(Status == Quest.QUESTSTATUS.COMPLETE)
            Application.LoadLevel(5); //Game completed, go to win screen
    }
}
//--------------------------------

在将此脚本附加到 NPC 后,通过在工具栏上按下播放图标来测试游戏。接近 NPC,GUI 文本应更改为为 QuestGiver 组件的 QuestName 字段定义的指定任务,该字段位于 Object Inspector 中。此名称应与 QuestManager 类中定义的 QuestName 匹配。请参阅 图 6.40

代码示例 6.8

图 6.40:定义 QuestGiver 组件

分配的任务是收集宝石,但我们的关卡缺少石头。现在让我们添加一个,让玩家去收集。为此,将项目面板(Texture文件夹)中的 GemStone 纹理拖放到场景 2 的最高边缘,这样玩家就必须爬上去才能拿到它(一个挑战!)。参见图 6.41。务必将一个圆形碰撞体触发器附加到该对象上,以便它能够与玩家发生碰撞。

代码示例 6.8

图 6.41:创建任务对象

最后,我们需要一个QuestItem脚本,当收集到物品时在QuestManager类上设置任务状态,以便QuestGiver能够在玩家下次访问时确定宝石是否已被收集。QuestItem脚本应附加到宝石对象上。请参考以下代码:

//--------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------
public class QuestItem : MonoBehaviour 
{
    //--------------------------------
    public string QuestName;
    private AudioSource ThisAudio = null;
    private SpriteRenderer ThisRenderer = null;
    private Collider2D ThisCollider = null;
    //--------------------------------
    void Awake()
    {
        ThisAudio = GetComponent<AudioSource>();
        ThisRenderer = GetComponent<SpriteRenderer>();
        ThisCollider = GetComponent<Collider2D>();
    }
    //--------------------------------
    // Use this for initialization
    void Start () 
    {
        //Hide object
        gameObject.SetActive(false);

        //Show object if quest is assigned
        if(QuestManager.GetQuestStatus(QuestName) == Quest.QUESTSTATUS.ASSIGNED)
            gameObject.SetActive(true);
    }
    //--------------------------------
    //If item is visible and collected
    void OnTriggerEnter2D(Collider2D other) 
    {
        if(!other.CompareTag("Player"))return;

        if(!gameObject.activeSelf)return;

        //We are collected. Now complete quest
        QuestManager.SetQuestStatus(QuestName, Quest.QUESTSTATUS.COMPLETE);

        ThisRenderer.enabled=ThisCollider.enabled=false;

        if(ThisAudio!=null)ThisAudio.Play(); //Play sound if any attached
    }
    //-------------------------------
}

上述代码负责在玩家进入触发体积时,当收集到宝石(任务物品)对象时将任务状态设置为完成。这是通过QuestManager类实现的。

优秀的工作!你现在拥有了一个完整的集成任务系统和 NPC 角色。本项目的完整文件可以在Chapter06/End文件夹中找到。我强烈建议您查看它们并玩玩游戏。参见图 6.42

代码示例 6.8

图 6.42:游戏完成!

摘要

干得好!我们现在已经完成了 2D 冒险游戏。为了清晰和简洁,本章没有涵盖一些细节,因为我们已经在前面的章节中看到了这些方法或内容。因此,打开课程文件并查看完成的项目,了解代码是如何工作的,这一点很重要。总的来说,在本书中达到这一步,你已经拥有了三个完成的项目。所以,在下一章,我们将总结到目前为止所看到的一切,并开始最后的盛宴:第四个项目!

第七章项目 D – 智能敌人

在本章中,我们将开始最终项目,该项目将涵盖广泛的地面。与前面的三个项目不同,这个项目将不是一个完整的游戏,没有明确的胜负条件,而将是一个功能原型和概念验证风格的项目,突出显示游戏中普遍存在的一系列重要编码技术和思想。具体来说,我们将创建一个包含地形、第一人称角色和一些敌人的世界。敌人将具有人工智能AI),在关卡中巡逻寻找玩家,并在找到玩家时攻击玩家。在本章中,我们将探讨以下主题:

  • 如何使用地形工具构建关卡和景观

  • 如何生成和使用导航网格

  • 如何为人工智能开发做准备

    注意

    起始项目和资产可以在本书配套文件中的Chapter07/Start文件夹中找到。如果您还没有自己的项目,可以从这里开始,并跟随本章进行。

项目概述

要创建的项目是一个第一人称原型,玩家角色可以在其中漫步和探索地形环境。地形包括山丘、山谷和多种地形元素。在地形中会散布几个敌人角色(NPC)。每个角色都具备人工智能。具体来说,每个角色会在周围徘徊(巡逻模式)寻找玩家。如果发现玩家,NPC 将会追逐和追击玩家(追逐模式)。如果在追逐过程中,敌人失去了玩家的视线,他们将会返回巡逻。另一方面,如果在追逐过程中敌人接近玩家,敌人将会攻击玩家(攻击模式)。简而言之,因此,AI 具有三个主要状态:巡逻、追逐和攻击。这,简而言之,构成了敌人的 AI,并代表了玩家在这个项目中的主要挑战。参见图 7.1中完成的项目:

项目概述

图 7.1:构建智能 NPC 的世界

开始

要从头开始,创建一个新的项目。关于这方面的详细信息在前面的所有章节中都有充分的介绍。在整个项目中,我们将使用 Unity 包含的三个主要资产包。具体来说,这些是角色、效果和环境。这些可以通过应用程序菜单导入,通过资产 | 导入包。参见图 7.2

开始

图 7.2:导入资产包

要开始,我们需要创建游戏世界本身(即陆地),这将是一个户外(外部)环境。换句话说,我们将创建一个拥有草原、丘陵和山脉的游戏世界。这样的景观可以在 3D 建模软件中创建,例如 3DS Max、Maya 或 Blender,然后导入到 Unity 中。然而,Unity 具有本地的地形设计工具,尽管在某些重要方面有限(我们将在后面看到),但仍然功能强大且灵活。要创建新的地形,从应用程序菜单导航到GameObject | 3D Object | Terrain。参见图 7.3

入门

图 7.3:创建新的地形

创建后,地形对象将添加到场景中的世界原点(0,0,0)。由于其尺寸,它可能不会立即出现在视图中。要解决这个问题,在层次结构面板中选择地形,然后在键盘上按F键将其居中在视图中。它最初看起来像一个平坦的平面对象,但与平面不同,它可以被重塑和雕刻,正如我们很快将看到的。参见图 7.4

入门

图 7.4:将地形添加到场景中

在雕刻和塑造地形之前,您应该首先从对象检查器中设置一些初始拓扑设置,以确保地形拓扑适合并且大小适中,以支持您所需的地形类型。为此,在视图中选择地形,然后点击对象检查器中的齿轮图标以显示地形设置。参见图 7.5

入门

图 7.5:查看和编辑地形设置

默认情况下,地形对于大多数用途来说太大(500 x 500 米)。让我们将其缩小到 256 x 256,或者如果您喜欢,甚至更小!只需在宽度长度字段中输入256高度字段表示任何地形山峰或山脉可能达到的最大高度。出于优化原因,地形不应超过所需的大小,因为地形对象高度细分且性能密集。参见图 7.6。在雕刻之前务必设置地形尺寸,因为之后调整大小可能会使雕刻工作无效或被删除。

入门

图 7.6:设置地形的宽度和长度分辨率

地形构建

现在,让我们开始雕刻地形。选择地形对象后,点击对象检查器中最左侧的调色板图标(提升/降低地形工具),该工具来自地形组件。这允许您选择画笔形状来绘制地形细节。选择一个柔软的圆形画笔,并使用大画笔尺寸(使用画笔大小滑块),以及不透明度设置来设置画笔强度。在地面上点击并拖动以绘制景观细节。为景观创建一些丘陵和山脉。参见图 7.7。记住,如果您需要,可以在点击时按住Shift键来反转(或降低)地形绘制。

地形构建

图 7.7:查看和编辑地形设置

如果地形看起来过于粗糙而不自然,您可以通过切换到平滑高度工具轻松地平滑出细节。为此,点击地形组件中的第三个按钮。参见图 7.8。当您选择此工具时,您可以选择与之前相同的刷形刷大小不透明度,但点击在地形上将会平滑出地形高度的变化。

地形构建

图 7.8:访问平滑高度工具

现在地形已经雕刻、塑形并达到所需的平滑度,我们准备开始绘制它。目前,地形是灰色、暗淡且定义不清晰。它没有清晰的纹理或外观,如草地或岩石。我们将使用绘制纹理工具来解决这个问题。要访问此工具,请从对象检查器中点击地形组件的绘制纹理按钮(第四个按钮)。当您第一次这样做时,您需要加载并准备一套纹理用于绘制。参见图 7.9

地形构建

图 7.9:为地形绘制准备纹理

点击编辑纹理按钮,然后从出现的上下文菜单中选择添加纹理…。之后,将出现一个纹理配置对话框,允许您将新纹理添加到调色板中。参见图 7.10

地形构建

图 7.10:将纹理添加到纹理绘制调色板

纹理选择对话框打开并准备好加载我们的第一个纹理时,使用项目面板查找 Unity 环境资产包中包含的本地地形纹理。这些可以在标准资产 | 环境 | 地形资产 | 表面纹理文件夹中找到。在这个例子中,我将选择一个草地纹理。这个纹理将被用作基础纹理来填充地形。将草地纹理从项目面板拖动到纹理选择对话框的 Albedo 槽中。法线通道可以留空。参见图 7.11

地形构建

图 7.11:选择基础纹理

纹理选择对话框中添加第一个纹理后,务必设置纹理大小。这指的是纹理单块应该覆盖的大小(以米为单位)。较小的值会减少纹理平铺,但会使每个纹理块看起来更大。较大的值会增加纹理平铺,但每个纹理块看起来更小。正确设置平铺值是一个试错的过程——调整值直到在地形上看起来正确。在这个例子中,我使用了 75 x 75 的值。然后,点击添加按钮。参见图 7.12

地形构建

图 7.12:设置纹理平铺大小

在单击添加按钮后,基础纹理将平铺在地形上。从远处看,平铺在地景视图中可能看起来很明显且不愉快。基于这一点,您可能会想调整平铺设置。然而,从第一人称视角来看,地形看起来会非常不同。因此,使用来自原生资源的 First-person Controller 预制件(prefab)来以第一人称模式预览地形,查看纹理平铺在地面的外观。参见图 7.15:

地形构建

图 7.13:在地形上预览纹理平铺

如果您需要编辑现有的纹理平铺,只需从对象检查器中的地形组件的纹理调色板中选择纹理缩略图,然后选择编辑纹理按钮。参见图 7.10。

在这个阶段,地形对象具有草地纹理,无缝地平铺在整个表面上,这是基础纹理。虽然这看起来可以接受,但包括更多纹理在地形中会更好,包括一些草地、岩石,甚至可能还有沙漠风格的纹理。这是通过通过地形选择对话框添加更多纹理来实现的。只需单击编辑纹理按钮,然后从上下文菜单中选择添加纹理。然后,将新的不同纹理拖放到纹理选择对话框的 Albedo 槽中,并最终重复此过程以添加所需的所有纹理。关闭对话框后,所有添加的纹理都将出现在对象检查器纹理调色板中。参见图 7.14:

地形构建

图 7.14:向纹理调色板添加纹理

分配给画笔刷的当前纹理在检查器中以蓝色边框突出显示。您可以通过单击纹理缩略图来选择不同的纹理。当您这样做时,所选纹理将被分配给画笔刷,并且只需单击即可将其应用于地形。在地面上单击并拖动将纹理绘制到地面上。您还可以使用刷形状刷大小不透明度目标强度值来控制纹理的强度以及它如何与地下的地形混合。参见图 7.15:

地形构建

图 7.15:分层绘制和混合纹理

现在,继续完成地形绘制,创建一个您喜欢的视觉效果。完成后,从层次结构面板中选择场景的方向光,并更改其旋转以定位太阳的位置。顺便说一句,请注意,您可以通过旋转光线 360 度来控制完整的一天和夜晚周期(就光照和外观而言)。因此,您可以通过使用前面章节中看到的动画窗口来动画化方向光,为游戏创建一个简单的日夜周期。参见图 7.16:

地形构建

图 7.16:完成的地形

最后,使用第一人称控制器资产来游览地形。在工具栏上按下播放图标,并在关卡周围探索!恭喜你,你现在拥有了一个包含地形的游戏世界。参见图 7.17

地形构建

图 7.17:第一人称地形探索

在继续前进之前,让我们考虑 Unity 地形的技術限制以及这可能会对你游戏产生的潜在影响。具体来说,Unity 地形是基于高度图。这意味着地形的 elevation(高低起伏)是基于图像文件中的灰度像素(高度图)内部生成的。当使用检查器中的刷子绘制地形时,你实际上是在高度图上绘制像素,该高度图用于变形地形。这是一个巧妙且迷人的过程,但它有一个重要的限制。即高度图是一个 2D 地形纹理。结果是,Unity 地形在程序层面上并不是真正的 3D;它们不能包含洞穴、裂缝、洞穴或任何内向的切割。玩家不能进入任何东西。相反,它只由上下部分组成,其中没有内部空间。现在,在许多情况下,这不会成为问题。然而,有时你需要这些内部空间,当你需要时,你会想要考虑替代原生地形系统的方案。替代方案包括 Asset Store 插件,但还包括在 3D 建模软件(如 3DS Max、Maya 和 Blender)中的手动地形。

导航和导航网格

世界地形现在已经完全创建完成。在达到这一阶段后,我们现在必须开始思考我们项目的核心目标。具体来说,这个关卡应该是一个 AI 实验:我们希望创建可以自由在地图上漫步的敌方 NPC 角色,并且当玩家进入他们的视野时,他们会追逐并攻击玩家。为了实现这一点,关卡必须正确配置以进行路径查找,这一点在此处被考虑。

在思考 NPC AI 和 NPC 在关卡中的移动时,很明显地形是崎岖的,有许多山丘、山脉、凹地和斜坡。为了使 NPC 角色能够成功穿越这片地形,涉及许多复杂性。例如,NPC 不能简单地从 A 点到 B 点直线移动,因为这样做会导致 NPC 穿过固体物体和地形。NPC 需要智能地在地形周围、下方和上方移动,就像人类智能一样。这对于创建可信的角色非常重要。计算 NPC 适当路径的计算过程称为路径查找,使角色沿着这些路径移动的过程称为导航。Unity 内置了路径查找和导航功能,这使得 NPC 计算和穿越路径变得容易。

为了准备这个,必须生成一个导航网格。这是一个特殊的网格资源,包含在场景中,它使用不可渲染的几何形状来近似场景的总可通行表面。路径查找和导航过程使用它来移动角色。要开始生成导航网格,从应用程序菜单中选择窗口 | 导航。参见图 7.18

导航和导航网格

图 7.18:访问导航窗口

导航窗口的目的是生成一个低保真度的地形网格,它实际上近似于水平地面。为了使此过程有效,场景中所有不可移动的地形网格都必须标记为导航静态。为此,在层次面板中选择地形,然后在检查器中点击静态下拉菜单并启用导航静态选项。参见图 7.19

导航和导航网格

图 7.19:将不可移动的地形对象标记为静态

现在访问导航窗口(我通常将其停靠在检查器中)。从这里,点击烘焙选项卡以访问主要的导航设置。从此面板,你可以控制一系列设置来影响导航网格(NavMesh)的生成。参见图 7.20

导航和导航网格

图 7.20:烘焙包含导航网格生成的主要设置

要开始,我们只需生成一个初始导航网格,看看默认设置看起来如何。如果需要,我们可以轻松擦除并在新设置下重新生成网格。为此,从检查器中点击烘焙按钮。当你这样做时,将生成一个默认的导航网格,并从场景视图中以蓝色显示在地形上方。参见图 7.21

导航和导航网格

图 7.21:默认导航网格

默认的导航网格存在问题。它应该代表关卡的全部可行走区域。本质上,这是 NPC 在移动时将被限制的区域。您将从前面的图像中看到导航网格在许多地方是断裂和破碎的——一些区域完全孤立,与其他区域断开连接。这通常是不理想的,因为这意味着任何在一个孤立区域内行走的 NPC 无法访问或移动到另一个区域,因为这两个区域之间没有连接,NPC 只能在地形网格上移动。为了正确修复这个问题,必须调整两个设置。首先,调整代理半径设置。这控制着平均代理(NPC)的大小,并影响导航网格可以扩展到周围网格地板及其边缘的接近程度。较低的(较小的)设置允许网格更接近网格边缘,从而扩展导航网格。尝试降低代理半径,然后再次点击烘焙以观察结果。见图 7.22:

导航和导航网格

图 7.22:通过代理半径细化网格

这提高了网格质量,但我们仍然有断裂或破碎的区域。这也是因为最大坡度设置,它控制着表面在变得对 NPC 不可行走之前应该有多陡(例如,山丘的倾斜度)。增加此设置可以进一步扩展导航网格,然后点击烘焙

导航和导航网格

图 7.23:增加最大坡度以在地面扩展导航网格

恭喜。你现在已经为该关卡构建了一个导航网格。NavMesh 资产本身存储在以场景名称命名的文件夹中。在项目面板中选择时,你可以预览描述导航网格的各种只读属性,例如高度可行走半径设置。见图 7.24:

导航和导航网格

图 7.24:从项目面板预览导航网格属性

创建 NPC

现在我们将构建一个将展示人工智能的 NPC 角色。要开始,我们将使用 Unity 原生伴侣资产中包含的 Ethan 网格。这可以在项目面板的标准资产 | 角色 | 第三人称角色 | 模型文件夹下找到。从这里,将 Ethan 模型拖放到场景中,并将其放置在地面上。我们将细化并编辑此模型,并最终从它创建一个预制体来表示 NPC 角色。见图 7.25:

创建 NPC

图 7.25:开始创建 NPC 角色

当将 Ethan 模型添加到关卡中时,确保角色的蓝色前向向量指向前方,面对角色实际注视的方向。如果前向向量没有对齐前方,则创建一个空对象,并将角色模型作为子对象对齐到该对象,以便父对象的前向向量指向正前方,沿着角色的视线方向。也就是说,蓝色前向向量应与角色的眼睛(朝同一方向看)对齐。这对于使你的角色移动起来更加逼真至关重要。参见图 7.26

构建 NPC

图 7.26:前向向量(蓝色箭头)指向角色的脚下

NPC 应使用为关卡生成的导航网格智能导航和行走。为此,应将 NavMesh agent 组件附加到角色上。在关卡中选择 Ethan 模型,并从应用程序菜单中选择Component | Navigation NavMesh Agent。NavMesh Agent 组件包含路径查找和转向(导航)行为,允许 GameObject 在导航网格中移动。参见图 7.27

构建 NPC

图 7.27:将 NavMeshAgent 组件附加到 NPC

默认情况下,导航网格将圆柱形碰撞体积分配给代理——即将在关卡中导航和移动的对象。这并不是一个与物理系统交互的真实碰撞体,而是一个用于确定角色何时接近导航网格边缘的伪碰撞体。选择 Ethan NPC,并在 NavMesh Agent 组件的Inspector中设置Height1.66Radius0.22。这更接近网格。参见图 7.28

构建 NPC

图 7.28:调整代理碰撞体大小

为了测试目的,让我们让网格移动;只是为了看看一切是否按预期工作。为此,我们需要编写一个新的脚本。首先,创建一个新的空对象,它将作为目标,即 NPC 应该到达的目标对象,无论它在何处。从应用程序菜单中选择GameObject | Create Empty。将其命名为Destination,然后分配一个 Gizmo 图标以便在视图中可见。参见图 7.29。只需在对象选择状态下点击Object Inspector左上角的立方体图标,然后选择图标表示。

构建 NPC

图 7.29:创建目标对象

接下来,创建一个新的 C#脚本文件(FollowDestination.cs),并将其附加到场景中的 NPC 对象上。代码包含在代码示例 7.1中,注释如下:

using UnityEngine;
using System.Collections;

public class FollowDestination : MonoBehaviour
{
  private NavMeshAgent ThisAgent = null;
  public Transform Destination = null;

  // Use this for initialization
  void Awake () 
  {
    ThisAgent = GetComponent<NavMeshAgent>();
  }

  // Update is called once per frame
  void Update () 
  {
    ThisAgent.SetDestination(Destination.position);
  }
}

代码示例 7.1

以下要点总结了代码示例:

  • FollowDestination类可以附加到任何具有 NavMeshAgent 的对象。该对象应随着目标对象的移动而跟随。

  • Destination变量维护要跟随的目标对象。

一旦附加到 NPC 对象,将目标空对象拖放到检查器中 FollowDestination 组件的目标槽中。这为脚本分配了一个目标。见图 7.30

代码示例 7.1

图 7.30:配置 FollowDestination 对象

现在给游戏进行测试运行。在游戏过程中,通过场景标签移动目标对象,看看 NPC 如何响应。NPC 应该持续追逐目标对象。此外,如果您在检查器中打开导航窗口,并在层次结构面板中选择 NPC,场景视图将显示诊断信息和工具,让您预览并可视化 NPC 计算出的路线。见图 7.31

代码示例 7.1

图 7.31:测试 NPC 导航

创建巡逻 NPC

现在我们已经有一个跟随目标对象的 NPC,这本身就是一个有价值的练习,但我们还需要比这更复杂的行为。具体来说,我们需要 NPC 进行巡逻,即通过航点系统在多个目标之间移动,按顺序从一个目标移动到下一个目标。为了实现这一点,可以采取多种方法。一种方法是通过脚本。通过这种方法,我们会创建一个包含不同航点对象的数组,并在循环中遍历它们,这样当 NPC 到达一个目标时,它们就会移动到下一个目标。现在,这种方法可以非常高效和有效,但还有另一种方法。具体来说,我们不是使用脚本,而是可以创建一个动画,在一段时间内将单个目标对象移动到不同的航点位置,因为 NPC 始终跟随目标对象移动,所以它会持续巡逻。

让我们采用第二种方法。首先通过选择应用程序菜单中的窗口 | 动画来打开动画窗口。见图 7.32。如果您喜欢,可以将动画窗口停靠在项目面板中的水平视图中,以便于查看。

创建巡逻 NPC

图 7.32:访问动画窗口

接下来,从层次结构面板中选择要动画化的对象(目标对象),然后在动画窗口中点击创建按钮。从这里,您将被要求命名并保存动画。我将其命名为anim_DestPatrol。见图 7.33

创建巡逻 NPC

图 7.33:创建新的动画

一旦创建了动画,您就可以继续定义动画通道。对于目标对象,我们需要一个用于位置字段的通道,因为对象应该在场景中改变位置。从动画窗口中点击添加属性按钮,然后选择变换|位置来添加一个新的位置通道。这将自动在时间轴上创建起始和结束关键帧,它们是相同的,并保持对象位置。参见图 7.34

创建巡逻 NPC

图 7.34:创建新的动画

现在,只需在动画窗口的时间轴上点击并拖动垂直的红色时间滑块,在 0-1 范围内,然后在场景选项卡中将目标对象的位置更改为新位置。当你这样做时,Unity 会记录该关键帧的对象位置。在整个时间轴上重复此过程,每次将目标对象移动到不同的位置,这样就创建了一个完整的巡逻动画。参见图 7.35

创建巡逻 NPC

图 7.35:构建巡逻动画...

通过从动画窗口或工具栏中按播放按钮来播放动画。默认情况下,动画可能会播放得太快(这是一个容易解决的问题,我们将在后面看到),但请注意,正如预期的那样,目标对象是插值的。也就是说,Unity 动画在时间轴上的关键帧之间进行插值,导致目标对象在航点之间平滑滑动或移动。然而,对于这种动画,我们只想让目标对象在航点之间立即传送或瞬间移动,没有任何过渡。为了实现这一点,我们需要调整动画曲线的插值模式。点击动画窗口左下角的曲线按钮。默认情况下,动画窗口处于DopeSheet模式,允许我们轻松地看到关键帧并重新定位它们。然而,曲线模式允许我们调整关键帧之间的插值。参见图 7.36

创建巡逻 NPC

图 7.36:访问动画曲线

现在,在图表视图中选择所有关键帧(点击并拖动选择框)。然后,右键单击以显示关键帧上下文菜单,并从菜单中选择右切线|恒定,将所有处理器更改为平坦的恒定形状,这意味着所有关键帧在目标对象上保留其值,直到下一个关键帧为止。参见图 7.37

创建巡逻 NPC

图 7.37:更改关键帧处理器的插值

当从菜单中选择恒定选项时,关键帧之间的曲线在图表中看起来会非常不同——连接它们的直线。参见图 7.38

创建巡逻 NPC

图 7.38:恒定插值

现在通过工具栏上的播放按钮进行测试。当您这样做时,随着动画的进行,目的地应该在路点之间跳跃,NPC 将持续移动并朝着目的地前进。由于动画的默认速度,NPC 可能看起来困惑或疯狂,因为他被快速变化的地点所撕裂。为了解决这个问题,在 层次结构 面板中选择目的地对象,并从 对象检查器 中双击 Animator 组件的 控制器 字段,打开附加到对象的动画器图,该图控制特定动画何时播放。参见 图 7.39

创建巡逻 NPC

图 7.39:访问动画器资产

您也可以通过从应用程序菜单中选择 窗口 | 动画器 来手动显示 动画器 窗口。在 动画器 窗口中,默认节点以橙色突出显示。此节点(动画)将在对象首次在关卡中激活时播放,这通常是在关卡启动时。参见 图 7.40

创建巡逻 NPC

图 7.40:在动画器窗口中,默认的 DestPatrol 动画为橙色

在图中选择 DestPatrol 节点,并在 对象检查器 中降低其 速度。在我的情况下,我使用了 0.2 的值,效果很好。一旦速度改变,重新播放您的游戏以观察效果。参见 图 7.41

创建巡逻 NPC

图 7.41:降低动画速度

按下播放按钮后,NPC 应该现在以可信的速度在目的地之间移动,从一个路点移动到下一个。如果 NPC 在路点之间移动得太快或太慢,请进一步增加或减少动画速度,以获得您需要的成果。恭喜!您现在拥有了一个完整的、动画化的路点系统。参见 图 7.42

创建巡逻 NPC

图 7.42:路点系统在行动中

摘要

干得好!我们现在已经完成了 AI 项目的第一部分:构建地形、生成导航网格,并在其中创建一个基本的路点系统,角色可以在目的地之间移动。这是一个很好的开始来模拟智能,但为了达到预期的效果,还有很多代码需要工作。我们将在下一章和最后一章中关注这一点。

第八章。继续智能敌人

这最后一章继续上一章的内容,通过关注智能敌人的理论和相关编码来完善 AI 项目。敌人将展示三种主要行为:巡逻、追逐和攻击。在本章中,我们将深入以下主题:

  • 如何规划和编码敌方角色的 AI 系统

  • 如何编码有限状态机(FSM)

  • 如何创建视线功能

    注意

    起始项目和资源可以在本书配套文件中的 Chapter08/Start 文件夹中找到。如果您还没有自己的项目,可以从这里开始,并跟随本章内容进行学习。

敌方 AI – 视野范围

让我们现在通过思考我们的功能需求来开始开发敌方 AI。场景中的敌人将开始巡逻模式,在关卡中四处游荡,寻找玩家角色。如果发现玩家,敌人将改变巡逻状态,开始追逐玩家,试图靠近他们进行攻击。如果敌人到达玩家的攻击范围内,敌人将改变追逐状态,转为攻击状态。如果玩家跑得比敌人快并成功摆脱他们,敌人应该停止追逐并再次回到巡逻状态,像最初一样寻找玩家。总的来说,这描述了我们需要的敌方 AI 行为。

为了实现这种行为,我们需要为敌人编码视线功能。敌人依赖于能够看到玩家角色或确定玩家在任何时刻是否对敌人可见。这有助于敌人决定他们是否应该巡逻或追逐玩家角色。为了编码这个功能,请参考以下来自源文件 LineSight.cs 的代码。这个脚本文件应该附加到上一章创建的敌方角色上。

using UnityEngine;
using System.Collections;
//------------------------------------------
public class LineSight : MonoBehaviour
{
  //------------------------------------------
  //How sensitive should we be to sight
  public enum SightSensitivity {STRICT, LOOSE};

  //Sight sensitivity
  public SightSensitivity Sensitity = SightSensitivity.STRICT;

  //Can we see target
  public bool CanSeeTarget = false;

  //FOV
  public float FieldOfView = 45f;

  //Reference to target
  private Transform Target = null;

  //Reference to eyes
  public Transform EyePoint = null;

  //Reference to transform component
  private Transform ThisTransform = null;

  //Reference to sphere collider
  private SphereCollider ThisCollider = null;

  //Reference to last know object sighting, if any
  public Vector3 LastKnowSighting = Vector3.zero;
  //------------------------------------------
  void Awake()
  {
    ThisTransform = GetComponent<Transform>();
    ThisCollider = GetComponent<SphereCollider>();
    LastKnowSighting = ThisTransform.position;
    Target = GameObject.FindGameObjectWithTag("Player").GetComponent<Transform>();
  }
  //------------------------------------------
  bool InFOV()
  {
    //Get direction to target
    Vector3 DirToTarget = Target.position - EyePoint.position;

    //Get angle between forward and look direction
    float Angle = Vector3.Angle(EyePoint.forward, DirToTarget);

    //Are we within field of view?
    if(Angle <= FieldOfView)
      return true;

    //Not within view
    return false;
  }
  //------------------------------------------
  bool ClearLineofSight()
  {
    RaycastHit Info;

    if(Physics.Raycast(EyePoint.position, (Target.position - EyePoint.position).normalized, out Info, ThisCollider.radius))
    {
      //If player, then can see player
      if(Info.transform.CompareTag("Player"))
        return true;
    }

    return false;
  }
  //------------------------------------------
  void UpdateSight()
  {
    switch(Sensitity)
    {
      case SightSensitivity.STRICT:
        CanSeeTarget = InFOV() && ClearLineofSight();
      break;

      case SightSensitivity.LOOSE:
        CanSeeTarget = InFOV() || ClearLineofSight();
      break;
    }
  }
  //------------------------------------------
  void OnTriggerStay(Collider Other)
  {
    UpdateSight();

    //Update last known sighting
    if(CanSeeTarget)
      LastKnowSighting =  Target.position;
  }
  //------------------------------------------
  void OnTriggerExit(Collider Other)
  {
    if(!Other.CompareTag("Player"))return;

    CanSeeTarget = false;
  }
  //------------------------------------------
}
//------------------------------------------

代码示例 8.1

以下要点总结了代码示例:

  • LineSight 类应该附加到任何敌方角色对象上。它的目的是计算玩家和敌人之间是否存在直接的视线。

  • CanSeeTarget 变量是一个布尔值(True/False),它每帧更新一次,以描述敌人是否现在(对于这一帧)可以看到玩家。True 表示玩家在敌人的视线中,而 false 表示玩家不可见。

  • FieldOfView 变量是一个浮点值,它决定了敌人眼睛点两侧的角边距,在这个范围内可以看见对象(如玩家)。这个值越高,敌人看到玩家的机会就越大。

  • InFOV 函数返回 truefalse,以指示玩家是否在敌人的视野范围内。这忽略了玩家是否被墙壁或固体物体(如柱子)遮挡。它只是取敌人眼睛的位置,确定指向玩家的向量,并测量前向向量与玩家之间的角度。它将这个角度与视野进行比较,如果敌人与玩家之间的角度小于 FieldOfView 变量,则返回 true。简而言之,这个函数可以告诉你如果视线清晰,敌人是否能看到玩家。

  • ClearLineOfSight 函数返回 truefalse,以指示敌人眼睛点和玩家之间是否存在任何物理障碍(碰撞器),如墙壁或道具。这不考虑玩家是否在敌人的视野范围内。这个函数与 InFOV 函数结合使用,可以确定敌人是否对玩家有清晰的视线并且在其视野范围内,从而确定玩家是否可见。

  • 当玩家位于围绕敌人的触发体积内时,会调用 OnTriggerStayOnTriggerExit 函数,而当玩家离开这个体积时也会调用。正如我们将看到的,可以将球体碰撞器附加到敌人角色对象上,以表示其视野范围。这意味着敌人可以看到玩家的总距离,或半径,前提是他们位于视野范围内并且存在清晰的视线。

现在,将 LineSight.cs 脚本文件附加到场景中的敌人角色上,以及一个球体碰撞器组件(标记为触发器),以近似敌人的观察视野。参见 图 8.1。将 Field of View 设置保留在 45,尽管如果需要,可以增加到大约 90 以调整敌人观察范围的有效性。

代码示例 8.1

图 8.1:为 NPC 添加视野

默认情况下,Eye Point 字段设置为 None,表示空值。这应该指的是敌人角色上的一个特定位置,该位置充当眼睛点——角色可以从中看到的地方。要创建这个点,使用应用程序菜单中的 GameObject | Create Empty 添加一个新的空游戏对象到场景中。将对象命名为 Eye Point,通过 Gizmo 图标从 Inspector 中激活其可见性(即使未选中也能可见),然后将它作为子对象添加到敌人上。之后,将对象定位到角色眼睛点,确保前向向量朝向相同的方向。参见 图 8.2

代码示例 8.1

图 8.2:添加 EyePoint

现在,将层次面板中的 Eye Point 对象拖放到检查器中 LineSight 组件的Eye Point字段。这指定 Eye Point 对象为敌人角色的眼睛点。这将用于确定敌人是否能看见玩家。与使用通常位于脚部位置的字符位置相比,拥有这样一个独立的眼睛点对象是有用的。请参见图 8.3

代码示例 8.1

图 8.3:定义 NPC 的眼睛点

最后,LineSight脚本通过首先在场景中使用Player标签找到玩家对象来确定玩家位置。因此,请确保Player使用Player标签进行了标记或标记。请参见图 8.4

代码示例 8.1

图 8.4:标记玩家对象

现在运行一下你的游戏。当你接近 NPC 对象时,Can See Target字段将被启用。请参见图 8.5。做得好!视线功能现在已完成。让我们继续前进!

代码示例 8.1

图 8.5:测试视线功能

有限状态机的概述

要为 NPC 对象创建 AI,除了我们已有的视线代码外,我们还需要使用有限状态机FSM)。FSM 不是 Unity 的东西或功能,也不是 C#语言的实体方面。相反,FSM 是一个概念、框架或想法,我们可以将其应用于代码以实现特定的 AI 行为。它源于对智能角色的特定思考方式。具体来说,我们可以总结我们的 NPC 在任意时刻存在于三种可能的状态之一。这些是巡逻(当敌人四处游荡时)、追逐(当敌人追击玩家时)和攻击(当敌人到达玩家并攻击时)。这些模式中的每一个都是一个状态,需要独特和特定的行为,敌人一次只能处于这三个状态中的一个。例如,敌人不能同时巡逻和追逐,或者巡逻和攻击,因为这不符合世界的逻辑和游戏。

除了状态本身之外,还有一组规则或状态之间的连接组,它决定了何时一个状态应该改变或移动到另一个状态。例如,NPC 只有在可以看到玩家且尚未攻击的情况下,才应该从巡逻状态移动到追逐状态。同样,NPC 只有在看不到玩家且尚未巡逻或追逐的情况下,才应该从攻击状态移动到巡逻状态。因此,状态及其连接规则的组合形成了一个有限状态机。因此,任何在功能上表示这种行为的代码实现都是 FSM。编码 FSM 本身没有对错之分。只是有不同方式,其中一些对于特定目的来说更好或更差。

在本节中,我们将使用协程编写 FSM。让我们首先创建主结构。参考文件 AI_Enemy.cs 中的以下代码:

using UnityEngine;
using System.Collections;
//------------------------------------------
public class AI_Enemy : MonoBehaviour
{
  //------------------------------------------
  public enum ENEMY_STATE {PATROL, CHASE, ATTACK};
  //------------------------------------------
  public ENEMY_STATE CurrentState
  {
    get{return currentstate;}

    set
    {
      //Update current state
      currentstate = value;

      //Stop all running coroutines
      StopAllCoroutines();

      switch(currentstate)
      {
        case ENEMY_STATE.PATROL:
          StartCoroutine(AIPatrol());
        break;

        case ENEMY_STATE.CHASE:
          StartCoroutine(AIChase());
        break;

        case ENEMY_STATE.ATTACK:
          StartCoroutine(AIAttack());
        break;
      }
    }
  }
  //------------------------------------------
  [SerializeField]
  private ENEMY_STATE currentstate = ENEMY_STATE.PATROL;

  //Reference to line of sight component
  private LineSight ThisLineSight = null;

  //Reference to nav mesh agent
  private NavMeshAgent ThisAgent = null;

  //Reference to player transform
  private Transform PlayerTransform = null;

  //------------------------------------------
  void Awake()
  {
    ThisLineSight = GetComponent<LineSight>();
    ThisAgent = GetComponent<NavMeshAgent>();
    PlayerTransform = GameObject.FindGameObjectWithTag("Player").GetComponent<Transform>();
  }
  //------------------------------------------
  void Start()
  {

    //Configure starting state
    CurrentState = ENEMY_STATE.PATROL;
  }
  //------------------------------------------
  public IEnumerator AIPatrol()
  {
      yield break;

  }
  //------------------------------------------
  public IEnumerator AIChase()
  {

      yield break;
  }
  //------------------------------------------
  public IEnumerator AIAttack()
  {
    yield break;
  }
  //------------------------------------------
}
//------------------------------------------

注意

更多关于协程的信息可以在 Unity 在线文档中找到:docs.unity3d.com/Manual/Coroutines.html

代码示例 8.2

以下要点总结了代码示例:

  • 到目前为止创建的 AI_Enemy 类并不代表完整的完整状态机(FSM),而只是其开始阶段的框架。它展示了总体结构。它为每个状态提供了一个协程。

  • CurrentState 变量定义了一个属性,用于选择活动状态,终止所有现有协程并启动相关的协程。

  • 每个状态协程将在状态激活期间在一个帧安全的无穷循环中运行,允许敌人对象更新其行为,正如我们很快将看到的。

在继续之前,请确保 AI_Enemy 脚本已附加到 NPC 对象上。参见 图 8.6

代码示例 8.2

图 8.6:将 AI 脚本附加到 NPC 角色上

巡逻状态

对于 NPC AI 需要实现的三个状态中的第一个是巡逻状态。在上一章中,我们配置了一个动画巡逻对象,NPC 应在此状态下持续跟随。巡逻对象在关卡内从预定义的动画资源中移动,从一个位置移动到下一个位置。然而,之前 NPC 只是简单地跟随这个对象,而没有尽头,而巡逻状态要求 NPC 考虑玩家是否在其路线上可见。如果可见,状态应该改变。为了支持这个功能,已经编写了 AI_Enemy 类的巡逻状态和启动函数,如下面的代码所示:

  void Start()
  {
    //Get random destination
    GameObject[] Destinations = GameObject.FindGameObjectsWithTag("Dest");
    PatrolDestination = Destinations[Random.Range(0, Destinations.Length)].GetComponent<Transform>();

    //Configure starting state
    CurrentState = ENEMY_STATE.PATROL;
  }
  //------------------------------------------
  public IEnumerator AIPatrol()
  {
    //Loop while patrolling
    while(currentstate == ENEMY_STATE.PATROL)
    {
      //Set strict search
      ThisLineSight.Sensitity = LineSight.SightSensitivity.STRICT;

      //Chase to patrol position
      ThisAgent.Resume();
      ThisAgent.SetDestination(PatrolDestination.position);

      //Wait until path is computed
      while(ThisAgent.pathPending)
        yield return null;

      //If we can see the target then start chasing
      if(ThisLineSight.CanSeeTarget)
      {
        ThisAgent.Stop();
        CurrentState = ENEMY_STATE.CHASE;
        yield break;
      }

      //Wait until next frame
      yield return null;
    }
  }

代码示例 8.3

以下要点总结了代码示例:

  • Start 函数将敌人角色的初始状态设置为巡逻。协程 AIPatrol 处理此状态。

  • AIPatrol协程在Patrol状态活动期间无限循环。请记住,在协程中使用无限循环并不一定是坏事,尤其是在与yield语句结合使用时。这允许长时间的行为可以整洁且易于随时间编码。

  • 调用SetDestination函数将NavMeshAgent发送到指定的目标。这之后是一个pathPending检查,这是NavMeshAgent的一个变量。这个检查等待pathPending变量变为 false,这表示从源到目标已经计算出一个完整的可穿越路径。对于短而简单的旅程,路径可能几乎立即计算出来,但对于更复杂的路径,这可能需要更长的时间。

  • 在巡逻状态期间,我们不断检查LineSight组件以确定敌人是否有直接视线到玩家。如果有,敌人将从Patrol状态变为Chase状态。

  • 请记住,yield return null语句将暂停协程直到下一帧。

现在,如果您还没有这样做,请将AIEnemy脚本拖放到场景中的 NPC 角色上。Patrol模式被配置为跟踪一个移动对象,也就是说,敌人将跟随一个移动的目标。在上一章中,使用动画窗口移动一个对象在场景中随时间移动,从一个地方跳到另一个地方创建了一个移动目标。见图 8.7

代码示例 8.3

图 8.7:创建可移动对象

要实现可移动对象,在场景中创建一个或多个目标对象并分配它们一个Dest标签。请记住,AIEnemystart函数在场景中搜索所有标记为Dest的对象,并使用这些作为目标点。见图 8.8

代码示例 8.3

图 8.8:标记目标对象

追逐状态

Chase 状态是敌人有限状态机(FSM)中的第二个状态。此状态直接连接到巡逻和攻击状态。它可以通过两种方式之一到达。如果一个巡逻的 NPC 建立了与玩家的直接视线,那么 NPC 将从巡逻状态变为追逐状态。相反,如果一个攻击的 NPC 超出了玩家的攻击范围(可能是因为他在逃跑),NPC 将再次采取追逐状态。从追逐状态本身,可以移动到巡逻或攻击状态,条件与追逐相反。也就是说,如果 NPC 失去了对玩家的视线,它将返回巡逻状态,如果 NPC 到达玩家攻击范围内,它将切换到攻击状态。考虑以下代码示例,它修改了AIEnemy类以支持追逐行为:

  public IEnumerator AIChase()
  {
    //Loop while chasing
    while(currentstate == ENEMY_STATE.CHASE)
    {
      //Set loose search
      ThisLineSight.Sensitity = LineSight.SightSensitivity.LOOSE;

      //Chase to last known position
      ThisAgent.Resume();
      ThisAgent.SetDestination(ThisLineSight.LastKnowSighting);

      //Wait until path is computed
      while(ThisAgent.pathPending)
        yield return null;

      //Have we reached destination?
      if(ThisAgent.remainingDistance <= ThisAgent.stoppingDistance)
      {
        //Stop agent
        ThisAgent.Stop();

        //Reached destination but cannot see player
        if(!ThisLineSight.CanSeeTarget)
          CurrentState = ENEMY_STATE.PATROL;
        else //Reached destination and can see player. Reached attacking distance
          CurrentState = ENEMY_STATE.ATTACK;

        yield break;
      }

      //Wait until next frame
      yield return null;
    }
  }

代码示例 8.4

以下要点总结了代码示例:

  • 当进入追逐状态时,将启动AIChase协程,并且像巡逻状态一样,只要状态处于活动状态,它就会在一个帧安全的无限循环中重复。

  • NavMeshAgent类的remainingDistance成员变量用于确定 NPC 是否已经到达攻击玩家的距离范围内。

  • LineSight类的CanSeeTarget布尔变量指示玩家是否可见,并在选择 NPC 是否应该返回巡逻状态时具有影响力。

优秀的工作!现在在编辑器中运行代码,你将拥有一个可以巡逻和追逐的敌人角色。太棒了!

攻击状态

NPC 的第三个也是最后一个状态是攻击状态,在这个状态下,NPC 会持续攻击玩家。这个状态只能从追逐状态到达。在追逐过程中,NPC 必须确定他们是否已经到达攻击距离。如果是这样,NPC 必须从追逐状态变为攻击状态。如果在攻击过程中,玩家离开了攻击距离,那么 NPC 必须从攻击状态变为追逐状态。考虑以下代码示例,它包括了完整的EnemyAI类,以及所有编码和完成的状态:

using UnityEngine;
using System.Collections;
//------------------------------------------
public class AI_Enemy : MonoBehaviour
{
  //------------------------------------------
  public enum ENEMY_STATE {PATROL, CHASE, ATTACK};
  //------------------------------------------
  public ENEMY_STATE CurrentState
  {
    get{return currentstate;}

    set
    {
      //Update current state
      currentstate = value;

      //Stop all running coroutines
      StopAllCoroutines();

      switch(currentstate)
      {
        case ENEMY_STATE.PATROL:
          StartCoroutine(AIPatrol());
        break;

        case ENEMY_STATE.CHASE:
          StartCoroutine(AIChase());
        break;

        case ENEMY_STATE.ATTACK:
          StartCoroutine(AIAttack());
        break;
      }
    }
  }
  //------------------------------------------
  [SerializeField]
  private ENEMY_STATE currentstate = ENEMY_STATE.PATROL;

  //Reference to line of sight component
  private LineSight ThisLineSight = null;

  //Reference to nav mesh agent
  private NavMeshAgent ThisAgent = null;

  //Reference to player health
  private Health PlayerHealth = null;

  //Reference to player transform
  private Transform PlayerTransform = null;

  //Reference to patrol destination
  private Transform PatrolDestination = null;

  //Damage amount per second
  public float MaxDamage = 10f;
  //------------------------------------------
  void Awake()
  {
    ThisLineSight = GetComponent<LineSight>();
    ThisAgent = GetComponent<NavMeshAgent>();
    PlayerHealth = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();
    PlayerTransform = PlayerHealth.GetComponent<Transform>();
  }
  //------------------------------------------
  void Start()
  {
    //Get random destination
    GameObject[] Destinations = GameObject.FindGameObjectsWithTag("Dest");
    PatrolDestination = Destinations[Random.Range(0, Destinations.Length)].GetComponent<Transform>();

    //Configure starting state
    CurrentState = ENEMY_STATE.PATROL;
  }
  //------------------------------------------
  public IEnumerator AIPatrol()
  {
    //Loop while patrolling
    while(currentstate == ENEMY_STATE.PATROL)
    {
      //Set strict search
      ThisLineSight.Sensitity = LineSight.SightSensitivity.STRICT;

      //Chase to patrol position
      ThisAgent.Resume();
      ThisAgent.SetDestination(PatrolDestination.position);

      //Wait until path is computed
      while(ThisAgent.pathPending)
        yield return null;

      //If we can see the target then start chasing
      if(ThisLineSight.CanSeeTarget)
      {
        ThisAgent.Stop();
        CurrentState = ENEMY_STATE.CHASE;
        yield break;
      }

      //Wait until next frame
      yield return null;
    }
  }
  //------------------------------------------
  public IEnumerator AIChase()
  {
    //Loop while chasing
    while(currentstate == ENEMY_STATE.CHASE)
    {
      //Set loose search
      ThisLineSight.Sensitity = LineSight.SightSensitivity.LOOSE;

      //Chase to last known position
      ThisAgent.Resume();
      ThisAgent.SetDestination(ThisLineSight.LastKnowSighting);

      //Wait until path is computed
      while(ThisAgent.pathPending)
        yield return null;

      //Have we reached destination?
      if(ThisAgent.remainingDistance <= ThisAgent.stoppingDistance)
      {
        //Stop agent
        ThisAgent.Stop();

        //Reached destination but cannot see player
        if(!ThisLineSight.CanSeeTarget)
          CurrentState = ENEMY_STATE.PATROL;
        else //Reached destination and can see player. Reached attacking distance
          CurrentState = ENEMY_STATE.ATTACK;

        yield break;
      }

      //Wait until next frame
      yield return null;
    }
  }
  //------------------------------------------
  public IEnumerator AIAttack()
  {
    //Loop while chasing and attacking
    while(currentstate == ENEMY_STATE.ATTACK)
    {
      //Chase to player position
      ThisAgent.Resume();
      ThisAgent.SetDestination(PlayerTransform.position);

      //Wait until path is computed
      while(ThisAgent.pathPending)
        yield return null;

      //Has player run away?
      if(ThisAgent.remainingDistance > ThisAgent.stoppingDistance)
      {
        //Change back to chase
        CurrentState = ENEMY_STATE.CHASE;
        yield break;
      }
      else
      {
        //Attack
        PlayerHealth.HealthPoints -= MaxDamage * Time.deltaTime;
      }

      //Wait until next frame
      yield return null;
    }

    yield break;
  }
  //------------------------------------------
}
//------------------------------------------

代码示例 8.5

以下要点总结了代码示例:

  • AIAttack协程在攻击状态激活期间(在这个状态下敌人将会攻击)运行在一个帧安全的无限循环中。

  • MaxDamage变量指定了敌人每秒对玩家造成的伤害量。

  • AIAttack协程依赖于Health组件来造成伤害。这是一个额外的自定义组件,用于编码健康值。玩家和敌人都应该有一个健康组件来表示他们的健康状态。

健康脚本(Health.cs)被AIEnemy类(攻击状态)引用,用于对玩家造成伤害。因此,玩家角色需要附加一个Health组件。该组件的代码包含在下面的代码示例中:

using UnityEngine;
using System.Collections;

public class Health : MonoBehaviour 
{
  public float HealthPoints
  {
    get{return healthPoints;}
    set
    {
      healthPoints = value;

      //If health is < 0 then die
      if(healthPoints <= 0)
        Destroy(gameObject);
    }
  }

  [SerializeField]
  private float healthPoints = 100f;
}

Health脚本相当简单。它维护一个数值健康值,当减少到0或以下时,将销毁宿主游戏对象。这至少应该附加到玩家角色上,允许 NPC 在接近时造成伤害。然而,它也可以附加到 NPC 对象上,允许玩家进行反击。参见图 8.9

代码示例 8.5

图 8.9:配置玩家健康

太好了,我们几乎准备好测试这个项目了。首先,如果你还没有这样做,通过将 NPC 游戏对象从场景视图或层次结构面板拖放到项目面板来从敌人对象创建一个预制体。然后,将你想要的敌人数量添加到关卡中。参见图 8.10

代码示例 8.5

图 8.10:创建 NPC 预制体

现在,通过在工具栏上按下播放图标来测试水平,你应该拥有一个完整的环境,其中智能敌人可以以相当逼真的程度寻找、追逐和攻击玩家。在某些情况下,你可能需要调整或细化敌人的视野范围(FOV),以更好地匹配你的环境和角色类型。干得好!参见图 8.11

代码示例 8.5

图 8.11:完成的关卡

摘要

太棒了!你现在已经完成了 AI 项目,也完成了这本书。在完成这个项目的过程中,你组装了一个完整的地图、一个 NPC 预制件以及一系列协同工作的脚本,这些脚本策略性地创建出智能的外观,这对于 AI 来说已经足够好了。对于游戏来说,AI 不过是指那些看起来智能的对象以及创建它们的技巧。太棒了!此外,在完成这本书并完成所有四个项目后,你现在已经具备了一组多样且至关重要的技能,可以开发 2D 和 3D 的专业级游戏。

posted @ 2025-10-25 10:31  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报