Unity-高级游戏开发教程-全-

Unity 高级游戏开发教程(全)

原文:Advanced Unity Game Development

协议:CC BY-NC-SA 4.0

一、简介和入门

欢迎来到高级 Unity 游戏开发正文第一章。Unity 是一个强大的游戏创作工具。在许多方面,它几乎太强大和复杂了。这使得一些游戏开发者很难充分发挥该软件的潜力。文档、书籍和教程是降低学习曲线的好方法。编程经验,尤其是 C# 编程经验,以及对 Visual Studio 的熟悉也将极大地提高您使用 Unity 的时间。

有了所有这些不同的培训选择,其中许多是免费的,这本书提供了什么,其他人没有?是什么让它脱颖而出?嗯,在大多数情况下,前面提到的学习材料会告诉你如何在 Unity 中完成一个简单的任务。他们会给你看一个未完成的小游戏或演示来说明手头的材料。这本书与其他书的不同之处在于,它包含了一个完整的代码审查,详细介绍了一个全功能和完整的 Unity 游戏。这包括以下一般主题:

  • 运行完整游戏的代码和类

  • 代码结构要点

  • 项目结构要点

  • 水平/轨道建筑

  • 人工智能对手/玩家

  • 完整的 HUD 和菜单系统

  • 音乐和音效

  • 玩家偏好

  • 触摸、鼠标、键盘、游戏手柄输入

  • 模型、预设和脚本

这本书将引导你通过游戏的代码,脚本,模型,预置,和整体结构,同时向你展示代码如何与 Unity 引擎一起定义一个完整的,精致的游戏。在本文结束时,您将在以下专业领域获得经验,因为您将在指导下实现一个悬停赛车游戏:

  • C#:具有类、类管理和类代码集中化的项目级方面的经验

  • Unity 编码:使用扩展 Unity Monobehaviour 类的类的经验。在基于组件的游戏引擎中使用组件

  • Visual Studio:体验导航项目、查看和编辑类文件

  • Unity C# 项目管理:拥有完整的 Unity 游戏和相关 C# 代码的工作经验

  • Unity 环境:体验导航复杂 Unity 项目的模型、预设、资源、脚本文件和场景

  • Unity 项目管理:处理较小场景和相关类、预设和模型的经验

我们将回顾游戏功能的细节以及我们在整个旅程中遇到的 Unity 中的关键概念。这将给你一个坚实的基础,在此基础上建立你的 Unity 游戏开发的未来。我们将采取的一般路径如下:

  1. 回顾汽车模型、赛道、赛道特征以及它们之间的交互。

  2. 回顾驱动游戏的代码。代码分为以下几组:

    1. 基类

    2. 交互类

    3. 高级交互类

    4. 助手类

    5. 输入类

    6. 菜单系统类别

    7. 玩家和游戏状态类

  3. 回顾以下与 Unity 相关的主题:

    1. 输入映射

    2. 场景结构和不活跃的游戏对象

    3. GameObject 标签

    4. 多摄像机设置

    5. 场景照明

    6. 音乐和音效

    7. 人工智能对手

    8. 将其应用到一个新的水平/轨道

在旅程的最后,你将拥有制作下一个伟大游戏所需的所有知识和经验。既然我们已经知道了摆在我们面前的是什么,让我们开始我们旅程的第一步,让我们的游戏开发环境启动并运行起来。

设置您的环境

在开始之前,我们需要正确设置和配置我们的环境。我们需要做的第一件事是打开浏览器并导航到 Unity 网站。导航至 www.unity.com 并创建一个新账户(如果您尚未创建)。完成这个过程,并确保您完成帐户验证步骤,因为您需要一个活跃的帐户,然后才能开始与包括赛车游戏项目。

在撰写本文时,使用 Unity 的正确方式是通过 Unity Hub 应用。该应用充当 Unity 项目的抽象层和集中点。该软件允许你管理多个项目,每个项目使用不同版本的 Unity。在 Unity 网站上找到下载页面并下载最新版本的 Unity Hub。安装软件。完成后,打开 Unity Hub 并使用您刚刚创建的帐户登录。

目前,Unity Hub 可以在 Windows、macOS 和某些 Linux 发行版上运行。有关如何安装 Unity Hub 以及支持哪些操作系统的快速参考,请在浏览器中导航至以下 URL:

docs。unity3d。com/Manual/getting start in stalling hub。html

接下来,我们将安装 Unity 的最新版本。打开 Unity Hub(如果您尚未打开),然后选择屏幕左侧的“安装”选项卡。选择最新版本的 Unity,并选择您想要与 Unity 编辑器一起安装的模块。

出于本文的目的,我们建议您仅选择下列模块。当然,如果你对你想要安装的模块有自己的想法,请随意。我们唯一的要求是将“Visual Studio”配置为 Unity 编辑器的默认脚本 IDE。您可以单独安装 Visual Studio,但我将只提供如何将其作为 Unity 模块安装的说明。

  1. 在“开发工具”下,选择“Microsoft Visual Studio 社区”;这是必需的。

  2. 为您的操作系统选择本机构建模块。根据您的操作系统选择以下选项之一:

    1. Linux 构建支持(IL2CPP 或 Mono)

    2. Mac 构建支持(单声道)

    3. Windows 生成支持(IL2CPP)

您可以通过 Unity Hub 软件安装新模块或卸载现有模块。选择“安装”选项卡,然后单击目标 Unity editor 版本的图标上的三个点。从上下文菜单中选择“添加模块”,您可以自定义为该版本的 Unity editor 安装的模块。尝试将 Android 和 WebGL 构建模块的安装添加到您的设置中。安装了它们之后,您将有一些有趣的构建目标可以使用。既然我们已经解决了这个问题,让我们获取一份与本文相关的赛车游戏项目的副本。将您的浏览器导航到以下 URL:

www.middlemind.net/urgbook/[BOOK PUBLICATIONhttps://github.com/Apress/Advanced-Unity-Game-Development

查找游戏项目的最新版本。将列出的 Unity 版本与您刚刚安装的 Unity editor 的当前版本进行比较。如果游戏项目版本较旧,尝试使用前面列出的过程安装该版本的 Unity 编辑器。如果旧版本在 Unity editor 版本列表中不可用,则安装最早的可用版本。

如果你发现自己在第二种情况下,一个旧版本不可用,那么我建议按照这个过程来升级游戏项目。首先,用刚刚安装的旧版本 Unity 打开项目。应该会提示您升级项目。这样做,当升级过程完成时,保存项目。完成这些步骤后,执行相同步骤,只是这次使用您安装的最新版本的 Unity editor。第二次升级完成后,保存项目。

这种方法可以安全地将项目升级到 Unity 的最新版本。让我们打开项目,检查一些东西。一旦赛车游戏项目完成加载,打开“首选项”窗口,“编辑”➤“首选项”,并选择“外部工具”标签。确保“外部脚本编辑器”首选项设置为“Visual Studio”

如果您在可用编辑器列表中没有看到“Visual Studio ”,请返回并检查您的 Unity 编辑器版本的已安装模块,并确保安装了“Visual Studio”。如果您仍然遇到问题,请重新安装 Unity 编辑器,并确保选择“Visual Studio”模块。

玩悬浮赛车/做好准备

现在我们已经解决了这个问题,让我们在打开 Unity 编辑器的同时测试一下这个游戏。让我们检查一下默认情况下哪个场景被打开了,如果有的话。查看 Unity 编辑器窗口的标题栏。当前打开场景的名称应该列在窗口标题中。如果你在标题中看到单词“Main13”或“Main14 ”,那么我们就可以开始了。如果没有,那么我们就必须打开正确的场景。去“项目”面板,或者如果你没有看到它,去主菜单,选择“窗口”➤“面板”➤“项目”,并打开它。

找到名为“场景”的文件夹并打开它。双击名为“Main13”的场景。一旦场景加载完毕,我们需要定位“游戏”面板。如果找不到,就按照前述步骤打开。一旦面板打开,选择它,你应该在屏幕上看到一堆 UI 和菜单。这很好。找到编辑器窗口顶部中间的播放按钮。在你按下播放键之前,让我来回顾一下游戏的控制。

要控制汽车的方向,请左右移动鼠标。要加速汽车,按键盘上的向上箭头。你可以用左右箭头键来左右“扫射”汽车。要减慢车速,请使用返回箭头键。好了,现在你已经掌握了基本知识,按 play,然后在游戏中点击以确保输入是活动的。单击主菜单的“赛道 1”按钮,玩几场比赛。

既然你已经有机会玩这个游戏,让我们停止称它为“赛车游戏”我们现在称之为“悬浮赛车”所以从现在开始,当你看到“悬浮赛车”这几个字时,我最有可能指的是这个项目或这个游戏,这取决于上下文。我将使用汽车、玩家、当前玩家、赛车、悬停赛车或赛车来描述游戏的玩家,包括人类和人工智能。根据上下文,这可能意味着当前玩家、人类玩家或游戏中的任何对手玩家。注意上下文。

关于这个游戏的一点点。悬停赛车是一个完整的赛车游戏,支持三种比赛类型:简单,经典,和战斗。三个难度:容易,中等,困难。它有两条内置轨道。正如我们所看到的,游戏有一个完整的 UI 实现,包括游戏中的 HUD 和菜单系统。此外,游戏有背景音乐、音效和获胜条件。这里主要的要点是游戏是精致的、专业的和完整的。这不是一个演示或辅导项目。这是一个完整的游戏,有一套完整实现的功能。

通过这本书的结论,你会对 Hover Racers 游戏的工作原理有一个完整的理解。在这一点上,你将能够看到这个项目更像是一块被塑造的粘土,而不是一个最终的雕塑。您将能够看到项目的本来面目,并能够使用您在此获得的知识添加您自己的功能来塑造它。此外,您可以将这些知识应用到任何 Unity 游戏开发项目中。包括你自己的,下一个伟大的游戏。

我们会一起回顾绝大多数代码,所以你不一定需要精通 C#,但建议有一些编程经验。这里有一些教程,你可以阅读,以获得对 Unity、Visual Studio 和 C# 的基本理解:

(C# 入门教程)

(Visual Studio 入门教程)

(Unity 编辑器简介)

(Unity 碰撞简介)

这就引出了本节的结论。在下一节中,我们将以对我们所学内容的总结来结束这一章。

第二章结论

这就引出了本章的结论。在这小小的介绍章节中,我们实际上涵盖了相当多的内容。让我们来看看到目前为止我们都做了些什么:

  1. 列出了 Unity 游戏开发主题,您可以通过阅读和阅读本文获得经验

  2. 列出了通过使用本文你将获得经验的一般的、支配性的技能

  3. 制定了一个处理我们需要覆盖的材料的计划

  4. 设置我们的环境,包括安装 Unity Hub、Unity 编辑器和 Visual Studio

  5. 玩过悬浮赛车

现在我们已经处理好了所有的问题,我们准备开始审查游戏的代码。但是等等!我们必须概述我们正在进行的游戏。当然,我们有一个游戏的成品副本,所以这似乎有点多余,不是吗?好吧,我们真的需要把这当成一次有指导的游戏回顾之旅。因此,我们将在下一章研究盘旋赛车游戏规范,并在后续章节详细回顾游戏代码。

二、游戏规约

在这一章,我们将概述悬停赛车的游戏规约。这是游戏开发的重要一步。你应该总是花时间列出一些通用的游戏规范。在某些情况下,这似乎是不必要的,但我还是建议你花时间去做。当你列出一些规约时,往往会有一个非常宝贵的想法出现在你的脑海中。这就是我们想要的,围绕手头的主题形成的新思想和新概念。让我们看看我们的规约清单。

  1. 一种赛车游戏,赛车在跑道上跑一定的圈数,争夺最快的速度和第一名

  2. 游戏中的 HUD 显示当前比赛和汽车状态的信息,即修改器

  3. 支持六个玩家,通过视频游戏人工智能来管理不受玩家控制的汽车

  4. 赛道特性,包括保险杠屏障、助推面板(涡轮)、跳跃面板和战斗模式修改器

  5. 支持三种困难:低、中、高

  6. 支持三种游戏模式:简单、经典和战斗模式

  7. 检测偏离轨道、卡住和方向错误的汽车状态的能力

  8. 完整的菜单系统,包括主菜单、游戏中的 HUD、结束游戏和暂停游戏屏幕

  9. 像用户偏好、输入处理程序、音乐、音效和多条赛道这样的收尾工作

以下部分定义了在与游戏状态管理和输入类相结合时驱动游戏所需的基本游戏机制。

  • 型号:汽车、轨道、传感器

  • 模型:汽车,助推,跳跃,反弹修改器

乍一看,这个列表似乎不完整,但实际上在这一点上它是非常正确的。我们并不试图创建一个包含 100 个要点的列表来详细描述游戏的每个方面。即使这样的事情是可能的,它几乎肯定会导致某种僵硬的、机械的游戏。我们的目标是给游戏下一个宽泛的定义,这样我们就可以清晰地想象游戏中的大部分画面,而不会被太多的细节所困扰。

例如,考虑到前面的描述,您可能会把游戏的开始想象成一个静态的菜单屏幕,玩家在比赛前与之交互。或者你可以把它想象成一个类似于街机游戏柜的动画场景,在那里游戏自己玩,直到用户与游戏交互。这里的要点是,我们对自己想要的东西有一个大致的轮廓。细节将在以后补充。

花点时间回顾一下前面列出的规约。当你阅读它们的时候,让你的想象驰骋。当我们处理前面列出的某个具体条目时,我会试着记下来,但我会不时地回头看看这个列表,看看我们已经从列表中去掉了多少点,以防我漏掉一些。接下来,我们将看看这个游戏的一些机制,并详细描述描述这个问题的模型。

型号:汽车、轨道、传感器

我们要看的第一个模型涉及赛车、赛道和赛车的传感器。这个模型构建练习将帮助我们详细描述游戏的一些机制,而不必求助于冗长的描述或要点列表。让我们来看看。

img/520710_1_En_2_Fig1_HTML.png

图 2-1

汽车、轨道、传感器模型图描绘悬停赛车、轨道碰撞盒和汽车碰撞盒的图

之前展示的模型有一个侧视图,显示了 hover racer 在赛道上的汽车传感器碰撞盒。赛道模型 A 也有一个碰撞盒 Ac ,用来检测赛车是否在赛道上。如果汽车偏离了轨道,轨道碰撞盒会检测到这种变化,并将汽车标记为偏离轨道。另一个碰撞盒是汽车传感器, C ,如前所示。这个碰撞盒用于检测当前汽车前面的汽车。

这个模型非常简单,但是它回答了我们的一些问题。我们现在有了赛车和赛道如何相互作用的想法。这给了我们一种方法来确定当前玩家的车是否偏离了轨道。这是赛车游戏的一个共同特点,也是我们无论如何都要解决的问题,所以我们有一个工作计划是件好事。接下来,我们将看看一个模型,它描述了汽车应该如何与某些赛道特性进行交互。

模型:汽车,助推,跳跃,反弹修改器

在这个模型中,第二个这样的模型,我们试图描述,概述,玩家的汽车如何与某些轨道特征相互作用。在这种情况下,我们将查看跳跃、反弹和助推修改器。这些功能都改变了悬浮车的物理特性。跳跃修改器将赛车弹出到空中,而反弹修改器将汽车反弹回它来的方向。

我们将在这个模型中包括的最后一个修改器是助推修改器。与我们在这里使用的其他修改器类似,助推修改器也改变了悬浮车的物理特性,并向前推动悬浮车。让我们来看看我们对这些游戏机制的计划,以及它们将如何与游戏的赛车交互。

img/520710_1_En_2_Fig2_HTML.png

图 2-2

汽车助推和跳跃修改器模型图描绘悬停赛车、启动标记和跳跃标记的图

本节的第一个模型,如前所示,演示了助推和跳跃修改器如何与悬停赛车交互。显示的修改器将使用标准的 Unity 碰撞交互来触发。 BcDc 功能指示碰撞盒,当悬停赛车越过助推或跳跃修改器时,碰撞盒会进行检测。作为这次碰撞的结果,一个力将被施加到悬停赛车上。 Bf 车型特征表示施加到汽车上的助力方向。类似地, Df 模型特征指示施加到赛车上的跳跃力的方向。我们要看的下一个模型是反弹修改器模型。

img/520710_1_En_2_Fig3_HTML.png

图 2-3

汽车弹跳修改器模型图描述一个悬停赛车与弹跳屏障交互的图

这个部分的第二个模型在前面已经展示过了。在这个模型中,我们将看看反弹修改器以及它将如何与玩家的悬停赛车交互。“反弹”修改器的作用类似于“助推”和“跳跃”修改器,因为它使用碰撞盒来检测经过它的悬停赛车。当检测到这种碰撞时,会向汽车施加一个反映碰撞角度的力。这导致悬停赛车从反弹对象反弹并远离反弹对象。

我们要看的最后一个模型是 hover racer 的输入处理模型。我们将从鼠标和键盘的角度来解释控制汽车的输入。我们这样做是因为我们将鼠标和键盘视为 Windows、macOS 和 Linux 的默认本机支持。游戏支持多种输入源,但是我们会在后面的文章中提到。让我们看看!

img/520710_1_En_2_Fig4_HTML.png

图 2-4

汽车控制/输入图表描述基本悬停赛车控制的图表

在前面显示的图表中,我们从自上而下的角度绘制了基本输入。基本的键盘和鼠标控制显示在悬停赛车的有效运动旁边。这些模型和图表就像是游戏规范列表的可视化扩展。它们用于帮助可视化交互,这种交互在单独用文本描述时会更加复杂或混乱。

我们将在本文中回顾的游戏使用输入映射将多个输入转换成一组游戏输入。一个例子,汽车左转或右转,可以通过使用键盘、鼠标或游戏手柄来完成。输入被映射到由一系列游戏类处理的游戏控件,以创建第三人称悬停汽车控件。我们将在接下来的章节中详细回顾每个类和所有的控件。

第二章结论

在这一章中,我们已经制定了游戏的一般规范。第一套规范负责创建游戏的整体概观。我们的规约列表描述了实际的游戏,人工智能对手,菜单系统,音效,音乐,等等。这个列表粗略地描述了一些游戏机制和交互。

根据规约清单,我们回顾了一系列描述悬停赛车和不同赛道特性之间相互作用的图表。让我们在下面的列表中总结一下我们在本章中复习过的图表:

  1. 汽车、赛道、传感器图:描绘了用于检测悬停赛车的赛道上/赛道外事件的赛道模型和赛道传感器。还描述了用于检测当前汽车前方的其他悬停赛车的汽车传感器。

  2. 助推、跳跃图:描述了助推和跳跃修改器,以及它们的碰撞盒如何用于检测与当前悬停赛车的交互,并随后对汽车施加力以改变其在赛道上的路径。

  3. 弹跳图:描述了“弹跳”修改器,以及与悬停赛车的碰撞如何产生一个施加到汽车上的力,使其从轨迹的弹跳障碍处弹开。

  4. 悬停赛车输入/控制图:该图描述了基本的鼠标和键盘输入,以及它如何改变悬停赛车的运动。

本章中讨论的模型描述了一个实施功能的总体计划,例如通过轨道传感器实现轨道上/下功能,以及汽车检测其前方悬停赛车的能力。我们还为如何处理由轨迹物体和障碍物触发的助推、跳跃和反弹修改器制定了一个总体计划。最后,我们设计了一个简单的输入方案来控制悬停赛车。在接下来的章节中,我们将回顾驱动 Hover Racers 游戏的代码。通过查看代码和运行特殊的演示场景,您将详细了解每个游戏特性是如何实现的。

三、基础类

基类是 Hover Racers 游戏中大部分职业的基础。例如,如果你打开一个类,在类声明中看到下面的内容,“: BaseScript”,那么这个类是通过BaseScript类的扩展得到的MonoBehaviour。让我们使用下一节中讨论的评审模板快速评审基本脚本类。

课堂复习模板

描述代码类的功能和使用可能很困难。某些类可能涉及许多上下文和配置。Unity 项目也不例外。我们将尝试通过使用以下模板详细复习课程来克服这个困难。为了阐明被上下文和配置模糊的类的各个方面,我们将尽可能演示类是如何工作的。

  1. 静态/常量/只读类成员

  2. 枚举

  3. 类别字段

  4. 相关的方法大纲/类头

  5. 支持方法详细信息

  6. 主要方法详细信息

  7. 示范

并非每堂课都有课堂复习模板每一部分的讨论主题。在这种情况下,我们将简单地省略那些没有提及的部分。如果你注意到有一节不见了,可以肯定地认为这一节不适用于当前的课程复习。

课堂复习:基础脚本

在这一节中,我们将回顾一下BaseScript类,这样你就可以了解在本文中类回顾是如何处理的。BaseScript类被用来方便地集中公共功能。使用这个类作为基类简化了游戏类,因为它负责每个类都需要的默认准备、字段和方法。我们不需要关心枚举,所以我们将在检查过程中跳过它,并遵循下面列出的步骤:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

可以在“项目”面板的“标准资源”文件夹的三个目录之一中找到类文件:

  1. \角色控制器\源\

  2. \ mmg _ 脚本\

  3. \ mmg _ scripts \演示脚本\

演示脚本不是本文的一部分,但是当您完成本文时,您将是代码库方面的专家,因此您可能打算自己查看它们。该过程将为相关演示场景带来更多价值。

静态/常量/只读类成员:BaseScript

BaseScript类有几个非常重要的静态类成员让我们看看。首先,我们来看看这个类的静态类字段,SCRIPT_ACTIVE_LIST

public static Dictionary<string, bool> SCRIPT_ACTIVE_LIST = new Dictionary<string, bool>();

Listing 3-1BaseScript Static/Constants/Read-Only Class Members 1

该字段用于存储扩展BaseScript并使用其准备方法的每个类的类初始化结果。这样,类初始化代码是集中的,而类初始化成功数据仅特定于扩展类。

1 public static bool IsActive(string sName) {
2    if (SCRIPT_ACTIVE_LIST.ContainsKey(sName)) {
3       return SCRIPT_ACTIVE_LIST[sName];
4    } else {
5       return false;
6    }
7 }

1 public static void MarkScriptActive(string sName, bool val) {
2    if (SCRIPT_ACTIVE_LIST.ContainsKey(sName)) {
3       SCRIPT_ACTIVE_LIST.Remove(sName);
4    }
5    SCRIPT_ACTIVE_LIST.Add(sName, val);
6 }

Listing 3-2BaseScript Static/Constants/Read-Only Class Members 2

前面列出了两个静态类方法供我们回顾。第一个条目是一个非常重要的方法,在 Hover Racer 的所有代码库中都被使用,即IsActive方法。该方法被设计成将当前类的名称作为参数,并检查该类是否已经在活动脚本列表中注册。这种注册是BaseScript类准备方法的一部分。列出的第二种方法是MarkScriptActive方法。此方法用于设置脚本的关联值是真还是假,是活动的还是非活动的。这就是本复习部分的结论。接下来,我们将查看该类的字段。

类字段:BaseScript

BaseScript类有许多类字段供我们查看。这些字段对于每个扩展了BaseScript类的类都是通用的。我应该注意到,尽管它们可以通过扩展来使用,但并不是每个类都使用它们。

public PlayerState p = null;
public GameState gameState = null;
public bool scriptActive = true;
public string scriptName = "";
public AudioSource audioS = null;

Listing 3-3BaseScript Class Fields 1

p字段是一个PlayerState实例,用于引用相关玩家的状态信息。下一个条目是gameState字段,它用于保存对游戏中央 state 类实例的引用。这恰好是GameState MonoBehaviour的一个实例,也是一个 Unity 脚本组件,附加到一个标记为“GameState”的 Unity 游戏对象上下面的条目scriptActive是一个布尔标志,其值表示当前脚本是否已经正确初始化。

scriptName字段是一个字符串实例,它保存扩展了BaseScript类的类的名称。最后,还有一个audioS字段,它是AudioSource组件的一个实例。因为音效很常见,所以这个字段被添加到了BaseScript类中,以简化游戏代码库中的其他类。这就是本复习部分的结论。接下来,我们将看看这个类的相关方法和类定义。

相关的方法大纲/类头:BaseScript

这个BaseScript类有几个方法需要我们介绍。让我们来看看。

//Main Methods
public bool Prep(string sName);
public bool PrepPlayerInfo(string sName);

//Support Methods
public void MarkScriptActive(bool val);

Listing 3-4BaseScript Pertinent Method Outline/Class Headers 1

随后列出的这个类头显示了类声明和任何基类或由BaseScript类使用的接口。

using System.Collections.Generic;
using UnityEngine;

public class BaseScript : MonoBehaviour { }

Listing 3-5BaseScript Pertinent Method Outline/Class Headers 2

注意,这个类使用一些 C# 库来支持它的数据结构。还要注意,这个类扩展了MonoBehaviour类。这意味着每一个扩展了BaseScript类的类也是一个MonoBehaviour,脚本组件,并且可以附加到 Unity GameObject上。

支持方法详细信息:BaseScript

BaseScript 类只有一个我们需要关注的支持方法。让我们来看看!

1 public void MarkScriptActive(bool val) {
2    scriptActive = val;
3    MarkScriptActive(scriptName, scriptActive);
4 }

Listing 3-6BaseScript Support Method Details 1

MarkScriptActive法短而甜。该方法更新第 2 行的scriptActive类字段,然后通过调用第 3 行的静态版本的MarkScriptActive方法来更新活动脚本注册表中的当前脚本。注意,方法调用使用类字段scriptNamescriptActive在脚本注册表中将脚本注册为活动或不活动。这就是支持方法回顾部分的结论。接下来,让我们把注意力转向类的主要方法。

主要方法详细信息:BaseScript

在课程的主要方法回顾部分,我们有两个方法要看。两者都用于通过查找和加载必要的 Unity GameObject及其相关组件来准备BaseScript类。

1 public bool Prep(string sName) {
2    scriptName = sName;
3    scriptActive = (bool)Utilities.LoadStartingSet(scriptName, out gameState)[2];
4    MarkScriptActive(scriptName, scriptActive);
5    return scriptActive;
6 }

1 public bool PrepPlayerInfo(string sName) {
2    scriptName = sName;
3    scriptActive = (bool)Utilities.LoadStartingSetAndLocalPlayerInfo(scriptName, out gameState, out PlayerInfo pi, out int playerIndex, out p, gameObject, true)[2];
4    MarkScriptActive(scriptName, scriptActive);
5    return scriptActive;
6 }

Listing 3-7BaseScript Main Method Details 1

前面列出的第一种方法是Prep方法。这个方法是大多数扩展BaseScript类的类使用的基本初始化调用。第 2 行,设置类'scriptName'字段,然后调用实用程序类'LoadStartingSet方法。这个方法执行寻找游戏对象、脚本组件等的实际工作。请注意,此方法在结果数组中返回的第二个值是一个布尔值,它指示初始化代码是否成功。该值存储在第 3 行的scriptActive类字段中。在第 4 行,BaseScript类的注册表被更新为调用MarkScriptActive方法,该方法接收扩展类名和活动标志,第 4 行。活动标志在方法结束时返回。

列出的第二种方法类似于我们刚刚回顾的方法,但是它支持为我们获取更多的信息。有几个类使用了PrepPlayerInfo方法,它们需要在初始化时加载起始集和当前玩家的状态。我们在这里进行区分的唯一原因是因为效率。为什么要做比我们需要的更多的工作?这就是Prep方法的用途。请注意第 3 行的调用,它也返回一个布尔标志,指示存储在数组索引 2 中的操作成功。

还要注意,“out p”方法参数用于从使用out关键字的方法调用中更新类的“PlayerState字段、p。其余的方法与Prep方法相同,所以我让你自己检查一下。这就是本复习部分的结论。在下一节中,我们将看一看运行中的类。

演示:BaseScript

虽然没有这个类的直接演示,但它几乎在任何地方都被使用,所以我们可以加载任何演示场景并获得相同的效果。打开 Hover Racer 的 Unity 项目并导航到“项目”面板。找到“场景”文件夹,找到名为“DemoCollideTrack”的场景。双击场景并加载它。一旦加载完成,导航到 Unity 编辑器的“层次”面板。请注意,场景元素是使用父对象和子对象来组织的,以创建类似于保存游戏对象的文件夹结构。

这不是一件坏事。不要认为添加这些空白的GameObject和创建父母/孩子的关系会减慢你的游戏或任何事情。我们最多在场景中添加几个空的物体,这不会导致任何方式的减速。相反,你应该试着组织你的场景元素,这样你就可以快速找到你要找的东西。在这种情况下,我们正在寻找“环境”游戏对象。将其展开,并找到名为“Street_30m”的孩子。选择它,然后将注意力转向“检查器”面板。

在“检查器”面板中找到“道路脚本”条目并展开它。请注意,此处列出的字段没有设置。pgameStatescriptActivescriptName字段都没有初始化。我想说的是,我们可以使用 Unity 编辑器而不是代码来连接它。例如,如果我将GameState对象从层次结构中拖放到检查器的gameState字段中,并填充到scriptName字段中,该数据将被设置并可供游戏使用。所以为什么不这样做,看起来更容易,对不对?

这样做的原因与你正在开发的游戏的范围和需求有很大关系。如果你的游戏很简单或者只是一个概念验证,那么使用这种方法是可以的。问题是在 Unity 编辑器中手工连接东西并不灵活,也不能适应复杂的游戏。例如,如果我改变场景并破坏GameState对象,这将会破坏我建立的所有有线连接。一旦我完成了我正在处理的场景,我就必须回去调整它们。

通过使用编程方法,没有什么需要调整的。代码为我们找到GameObject及其相关组件,并在出错时报告问题。这给了我们更多的力量和对比赛的控制。这就是我们想要的这种规模的游戏。游戏实际上是数据驱动的,这里的数据是一些预先配置的 Unity 游戏对象和脚本组件。赛道和球员是作为游戏的集中初始化过程的一部分加载的数据。这种方法确保每个类在运行时都有必需的组件引用。

回到演示。打开“检查器”面板,显示“道路脚本”组件的详细信息,运行演示场景。请注意,一旦场景启动,gameStatescriptName字段现在已正确设置。

考虑一下这个问题。这是一个重要的区别,您可能会遇到许多情况,您必须决定我是应该在编辑器中进行连接还是使用代码来连接。这就把我们带到了复习部分的结尾。

第二章结论

在这一章中,我们完成了一些重要的基础工作。我们回顾了被许多游戏类扩展的BaseScript

  1. 课程复习模板:我们看了我们将用来复习游戏课程的框架,并试图解释和理解它们是如何运作和使用的。

  2. BaseScript 类回顾:我们详细回顾了BaseScript类,它是游戏中许多类扩展的重要基类。

  3. 使用模板:我们演示了如何将课堂复习模板应用到实际的课堂中。

  4. 硬连线和代码连线之间的区别:我们很快讨论了硬连线连接和代码连线连接之间的区别,硬连线连接使用 Unity 编辑器来设置,代码连线连接使用脚本组件来建立GameObject和组件之间的连接。

这就引出了本章的结论。尽管这是一个很短的章节,但它包含了很多关于在 Hover Racers 游戏中使用和连接GameObject的重要信息,同时也包含了在 Unity 中构建游戏的一般信息。

四、交互类

欢迎来到交互类这一章。在这一章中,我们将深入这个项目,并开始回顾支持悬停赛车修改器和交互的类。我有时将这些交互称为游戏机制,反之亦然,但这有点不准确。让我澄清一下。我们在这里讨论的交互是由 Unity 引擎驱动的。他们使用基于MonoBehaviour的类和碰撞盒来确定悬停赛车何时触发碰撞事件。

此时,玩家悬停赛车的状态被更改,以应用由碰撞触发的修改器。从 Unity 引擎交互到游戏状态调整的转换是游戏机制的实际应用。因为这一切都是按顺序发生的,而且非常快,所以很容易忽略这种区别。打开 Hover Racers Unity 项目,找到“项目”面板。如果面板不可见,转到主菜单,选择“窗口”➤“面板”➤项目一旦面板可见,展开文件夹,直到您可以在根目录中看到“标准资产”文件夹。在我们看任何代码之前,让我们先看一下我们要复习的类的列表。我们将在本章中回顾的类如下:

  1. 反弹脚本

  2. loadscript

  3. 航点检查

  4. TrackHelpScript

现在我们已经解决了所有这些问题,让我们直接进入一些代码。双击位于“资产”➤“标准资产”➤“mmg _ scripts”中名为“BounceScript”的脚本,在 Visual Studio 中打开它。

课堂回顾:反弹脚本

BounceScript类负责触发悬停赛车的反弹修改器。这种游戏机制出现在使用反弹屏障的赛道上。你可以知道你何时撞上了一个活动的反弹屏障,因为你的悬停赛车将向屏障的相反方向飞去。我们将使用前面提到的课程复习模板来复习课程。相关部分如下所示:

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

没有相关的枚举可言,所以我们将跳过回顾部分。当我们回顾代码时,我希望你试着想象这个类和它的方法是如何被使用的。不要带着任何具体的观察走开,直到你把你的想法和这个班级的演示场景放在一起。

类字段:BounceScript

我将根据上下文列出大约 5 到 15 个类别的字段。有些字段是私有的,仅供类方法内部使用。我不会总是列出这些字段,也不会总是详细介绍它们。把它们看作是局部变量,但是在类级别注册。因为这个设计决策在代码中出现了很多次,所以我想稍微讨论一下。Unity 使用 C# 作为主要的脚本语言。原来 C# 是一种托管内存语言。

这意味着 C# 程序运行垃圾收集器,监视对象引用计数,并从未引用的对象中清除任何未使用的内存。这是对一个相当复杂的过程的一句话描述,所以让我们忽略任何不准确的地方。这里的要点是,仅仅因为有一个垃圾收集器在运行,并不意味着我们应该给它任何工作。

一个被滥用的垃圾收集器最终会有很多工作要做。这反过来会对您的游戏性能产生负面影响,并可能影响帧速率和用户体验。为了尽可能地防止垃圾收集器运行,局部方法变量被移到私有类字段中。这避免了在方法调用时分配和释放内存的需要,因为使用中的对象仍然被该类引用。

我在更新调用和事件处理程序中使用这种有意义的方法。在我看来,任何有可能每秒运行几次的代码都应该调整为使用私有类字段,并减少垃圾收集。尽管如此,也不要滥用这种方法。如果一个方法可以很好地处理局部变量,就让它去吧。你可以随时回来重构代码。让我们看看如下列出的第一组类字段。

public float bounceDampener = 0.75f;
public float minBounceVelocityHor = 20.0f;
public float minBounceVelocityVer = 20.0f;
private bool useReflect = false;

Listing 4-1BounceScript Class Fields 1

bounceDampener字段用于减小 X 和 Z 轴上的初始速度。应用此修改器时,Y 轴(垂直轴)保持为零。接下来的两个类字段,minBounceVelocityHorminBounceVelocityVer,用于保持反弹修改器速度的最小强度。这确保了在较低的碰撞速度下反弹效果显著。如果有疑问,就用漫画式的、稍微夸张的物理学。它们在游戏中更有趣,如果需要的话,你可以随时把它调回来。

字段列表中的下一个是useReflect布尔标志。该字段控制用于确定汽车弹跳速度向量的计算。通常在游戏开发中,你会遇到这样的情况,你可以用更复杂但更精确的数学来描述游戏的物理特性。我的建议是首先使用一个简单的“模拟”方法,看看你是否能让事情正常运行。

我推荐这种方法的唯一原因是,通常情况下,简化的方法无法察觉高级数学。在这种情况下,您将受益于更高效的实现。我提出这个建议的第二个原因是,现实世界的物理学并不有趣。再次,想想卡通物理学。无论如何,让我们回到课堂实地审查。其余的类字段是私有的,由类方法内部使用。让我们来看看。

//***** Internal Variables: BounceObjOff *****
private Vector3 v3;
private float x;
private float y;
private float z;

//***** Internal Variables: OnTriggerEnter *****
private CharacterMotor cm = null;

Listing 4-2BounceScript Class Fields 2

如前所述,前面列出的字段由类'BounceOffObjOnTriggerEnter方法内部使用。v3字段用于表示最终的反弹速度矢量。后续字段xyz用于计算v3Vector3字段分量的速度。最终结果是一个三维向量,其 x 和 z 分量的值和 y 分量的值为零。这构成了弹跳效果,并应用于悬停赛车的运动。

我们要看的最后一个类字段由OnTriggerEnter方法使用。通常,在整个游戏代码中,我们需要从一条信息、一个游戏对象或一个碰撞事件连接回保存玩家和游戏状态信息的类。CharacterMotor区域cm是一个允许玩家的悬浮赛车移动的组件。这个类是 Unity 2.x 的FPSWalker.js脚本的继承者,也是 Unity 5.x 的FirstPersonController.cs脚本的前身。是的,曾经有一段时间,你可以同时使用 JavaScript 和 C# 在 Unity 中编码。实际上,这个游戏最初是作为 JavaScript 和 C# 混合项目实现的。接下来,我们将看看类的相关方法列表和类头。

相关的方法大纲/类头:BounceScript

BounceScript类的相关方法列表如下。

//Support Methods
public void OnTriggerEnter(Collider otherObj);

//Main Methods
public void BounceObjOff(GameObject go, Collider otherObj, PlayerState p, CharacterMotor cm);

void Start();

Listing 4-3BounceScript Pertinent Method Outline/Class Headers 1

class '头显示了 import 语句和 class '声明,包括它扩展的任何基类或它实现的接口。

using UnityEngine;

public class BounceScript : BaseScript {}

Listing 4-4BounceScript Pertinent Method Outline/Class Headers 2

注意,BounceScript类是一个MonoBehaviour类,因为它扩展了基类BaseScript,后者又扩展了 Unity 引擎的MonoBehaviour类。我们将首先看一下类的支持方法。我将从第一个开始标记每个方法的行号。当我们回顾不同的类时,请随意跟随 Unity 编辑器和 Visual Studio。

支持方法详细信息:BounceScript

BounceScript类有一个支持方法,我们接下来会看到。有时,我们会遇到一个拥有大量简单支持方法的类,比如 get、set 和 show、hide 方法。方法,因为这些方法简单而直接,所以只需查看很少的代码。在这种情况下,我们需要检查一些代码。我们来看看下面这个方法。

01 public void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    cm = null;
07    if (otherObj != null && otherObj.gameObject != null && otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
08       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out p, otherObj.gameObject, gameState, false);
09       if (p != null) {
10          cm = p.cm;
11       }
12
13       if (p != null && cm != null && p.isBouncing == false) {
14          BounceObjOff(otherObj.gameObject, otherObj, p, cm);
15       }
16    }
17 }

Listing 4-5BounceScript Support Method Details 1

这个方法是一个碰撞检测回调方法,作为 Unity 游戏引擎的游戏对象交互的一部分被触发。我在简化事情。碰撞检测涉及到更多的组件,但是我们将在课程回顾的演示部分更详细地介绍它们。这个方法获得一个碰撞器对象作为碰撞事件的一部分传递给它。在第 2–4 行,如果该类遇到了配置问题,并在BaseScript类的初始化结果DictionarySCRIPT_ACTIVE_LIST中注册了一个假值,则该类不做任何工作就被转义。

如果定义了otherObj参数,具有有效的gameObject字段,并标记为“玩家”GameObject,那么我们处理碰撞,第 7 行。在第 8 行,对实用方法LoadPlayerInfo的调用使用GameObjectPlayerInfo脚本组件来找出玩家的索引,然后使用该信息在游戏的中央状态类GameState中查找玩家的状态类PlayerState。结果是类字段p被设置,并可用于检查该玩家的角色运动cm是否被定义,第 9 行。

如果定义了必要的字段,并且玩家的悬停赛车还没有弹跳,那么我们通过调用BounceOffObj方法来应用弹跳修改器,第 13–14 行。这就结束了我们对类的支持方法的回顾。接下来我们将看看这个类的主要方法。

主要方法详细信息:BounceScript

我们复习的方法主要有两种。首先是Start法。该方法是 Unity 引擎组件架构的一部分。简而言之,游戏中的每个组件都有一个Start和一个Update回调方法。在组件生命周期的开始,调用一次Start方法。特别是,这种方法被认为是准备和配置的要点。让我们开始写代码吧!

01 void Start() {
02    base.Prep(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07
08    audioS = GetComponent<AudioSource>();
09    if (audioS == null) {
10       Utilities.wrForce(scriptName + ": audioS is null!");
11    }
12 }

Listing 4-6BounceScript Main Method Details 1

Start方法负责初始化类,同样,如果不满足初始化要求并且类被标记为不活动,它也能够停用类。通过调用第 2 行的Prep方法进行初始化。注意,该方法的第一个参数是这个类的名称。该字符串用于注册初始化的结果,并将类标记为活动或非活动。第 3 行,我们测试初始化结果是否成功。

我选择添加 disable-class 特性来防止类抛出大量异常。如果这个类碰巧用在了一个Update方法中,那么可能会记录大量的异常。这种情况会堵塞“控制台”面板,隐藏异常的最初原因。最后,在第 8–11 行,我们加载了一个AudioSource组件,如果可用的话,在应用反弹修改器时用作声音效果。我们要看的下一个主要方法是BounceOffObj方法。让我们开始写代码吧!

01 public void BounceObjOff(GameObject go, Collider otherObj, PlayerState p, CharacterMotor cm) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    v3 = Vector3.zero;
07    x = 0;
08    y = 0;
09    z = 0;
10    p.isBouncing = true;
11
12    x = cm.movement.velocity.x;
13    if (useReflect == true) {
14       x = x * bounceDampener;
15    } else {
16       x = x * -1 * bounceDampener;
17    }
18
19    if (x < 0) {
20       if (x > -minBounceVelocityHor) {
21          x = -minBounceVelocityHor;
22       }
23    } else if (x >= 0) {
24       if (x < minBounceVelocityHor) {
25          x = minBounceVelocityHor;
26       }
27    }
28
29    z = cm.movement.velocity.z;
30    if (useReflect == true) {
31       z = z * bounceDampener;
32    } else {
33       z = z * -1 * bounceDampener;
34    }
35
36    if (z < 0) {
37       if (z > -minBounceVelocityHor) {
38          z = -minBounceVelocityHor;
39       }
40    } else if (z >= 0) {
41       if (z < minBounceVelocityHor) {
42          z = minBounceVelocityHor;
43       }
44    }
45
46    if (useReflect == true) {
47       v3 = Vector3.Reflect(v3, otherObj.ClosestPointOnBounds(go.transform.position).normalized);
48    } else {
49       v3 = new Vector3(x, y, z);
50    }
51
52    cm.movement.velocity = v3;
53    if (audioS != null) {
54       if (audioS.isPlaying == false) {
55          audioS.Play();
56       }
57    }
58    p.isBouncing = false;
59 }

Listing 4-7BounceScript Main Method Details 2

类似于OnTriggerEnter方法,BounceObjOff方法受到在第 2-4 行执行的IsActive方法调用和检查的保护。第 6–10 行的小代码块用于初始化方法的局部变量,在本例中是私有类字段。该方法创建一个反弹向量,调整速度以使悬停赛车弹开。反弹向量的x分量在第 12–27 行计算。本地x组件用悬停赛车的x组件的速度初始化。

如果useReflect被启用,则执行不同的计算。首先,如果useReflect为假,则x分量被抑制和反射。这是违反直觉的;让我解释一下。如果useReflect为真,我们使用统一反映计算。如果没有,我们使用一个简单的模拟反射。第 19–27 行的代码块用于在xz轴上强制最小反弹速度。您也可以将它们视为一个Vector3实例的xz组件。

一个非常相似的过程用于设置第 29–44 行的速度矢量的z分量。因为我们在游戏中排除了垂直调整,这就是我们准备反弹速度向量所要做的。如果你看一下第 46–50 行,你会看到反弹速度向量的创建。在这段代码中,如果useReflect标志为真,则该类使用 Unity reflect 方法;否则,使用非常简单的模拟反射,第 47 行。

在第 52 行可以找到一行微妙但重要的代码。这是悬停赛车游戏对象的角色马达调整其运动向量的地方。最后,在第 52-56 行,如果设置了音效AudioSource,则播放声音以指示弹跳,并且玩家状态标志isBouncing被设置为假,第 58 行。在该方法的第 10 行,该字段被设置为 true。使用该标志可以防止反弹修改器重叠。我们对BounceScript类的复习到此结束。接下来,我们将看到这个类是如何工作的。

演示:反弹脚本

有一个场景是专门设计来演示BounceScript的动作的。找到“项目”面板,并找到“场景”文件夹。找到并打开名为“DemoCollideBounce”的场景。在开始演示之前,我们先来说说这里是怎么回事。几秒钟后,你将能够启动场景并控制悬停赛车,就像在游戏的正常使用中一样。你可以使用第二章中列出的基本键盘和鼠标控制来控制悬停赛车。

在演示场景中,有三根彩色柱子被四个反弹屏障包围。每个组都以不同的方式配置。绿色柱子周围的栅栏是用来弹跳汽车的。这些屏障有一个勾选了“触发”复选框的碰撞箱。这表明碰撞物理将由脚本而不是默认的物理引擎来处理。碰到这个障碍将会运行我们刚刚复习过的类中的可操作代码。汽车会被弹开。

下一个要讨论的支柱是红色支柱。这个柱子周围也有屏障,但这些屏障的配置有点不同。这些障碍也有BoxCollider,但是它们没有选中“是触发器”框。为了表明脚本不再重要,障碍使脚本失效。碰撞这些障碍将导致默认的物理行为。汽车会直接撞上并停在栅栏前。试试看!

最后一组要讨论的障碍围绕着紫色柱子。这些障碍是错误配置的一个例子。在这种情况下,障碍有一个勾选了“是触发”框的BoxCollider,就像绿色柱子一样。这里的区别在于,这些障碍没有处理碰撞事件的活动脚本。你会注意到在这种情况下,汽车可以直接穿过护栏。

img/520710_1_En_4_Fig1_HTML.png

图 4-1

弹跳脚本演示场景从自上而下的角度显示弹跳演示场景的图像

前面显示的图像描述了用于本课程的演示场景。这就是我们对BounceScript课复习的总结。下一个要复习的课程是RoadScript。这个脚本的行为有点类似于BounceScript

课堂回顾:道路脚本

RoadScript类负责检测玩家的悬停车是否主动在赛道上。在游戏中使用时,赛道的每一块都有一个附带的RoadScript组件。这确保玩家的悬停赛车在赛道上或赛道外的状态一直受到监控。本课程复习的相关部分如下所示:

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

没有相关的枚举或静态类成员,所以我们将跳过这一部分。同样,当你阅读课程回顾时,试着想象正在使用的课程。试着保留最后的判断,直到你看了班上的示范。

类字段:RoadScript

RoadScript类有几个字段供我们回顾。其中一些字段是私有的,仅供某些类方法内部使用。对于私有的内部字段,我们就不赘述了。这种设计决策的原因是私有类字段由该类引用,只要该类在使用中,垃圾收集器就不会触及这些字段。

//***** Class Fields *****
private float delay = 5.0f;
private PlayerState sdp;

//***** Internal Variables: OnTrigger Methods *****
private PlayerState pEntr = null;
private PlayerState pStay = null;
private PlayerState pExit = null;

Listing 4-8RoadScript Class Fields 1

第一个类字段delay用于为玩家的悬停赛车添加 5 秒的延迟,使其脱离赛道设置为真。接下来的字段sdp是一个PlayerState对象,它引用了玩家汽车的当前状态。接下来的三个类字段由类的碰撞事件处理程序用来保存对与碰撞对象相关的播放器状态的引用(如果有的话)。

相关的方法大纲/类头:RoadScript

相关方法的列表如下。

//Main Methods
void Start();

//Support Methods
public void OnTriggerEnter(Collider otherObj);
public void OnTriggerStay(Collider otherObj);
public void OnTriggerExit(Collider otherObj);

public void SpeedUp(PlayerState p);
public void SlowDown(PlayerState p);
public void RunSlowDown();

Listing 4-9RoadScript Pertinent Method Outline/Class Headers 1

随后列出的这个类头显示了类声明和任何基类或由RoadScript类使用的接口。

using UnityEngine;

public class RoadScript : BaseScript {}

Listing 4-10RoadScript Pertinent Method Outline/Class Headers 2

让我们来看看这个类的支持方法。

支持方法详细信息:RoadScript

RoadScript类有一些支持方法供我们回顾。我们将从查看碰撞事件处理程序开始。让我们跳到一些代码中。

01 public void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out pEntr, otherObj.gameObject, gameState, false);
08       if (pEntr != null) {
09          SpeedUp(pEntr);
10       }
11    }
12 }

01 public void OnTriggerStay(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null) {
07       Utilities.LoadPlayerInfo(scriptName, out PlayerInfo pi, out int playerIndex, out pStay, otherObj.gameObject, gameState, false);
08       if (pStay != null) {
09          SpeedUp(pStay);
10       }
11    }
12 }

01 public void OnTriggerExit(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out pExit, otherObj.gameObject, gameState, false);
08       if (pExit != null && pExit.isJumping == false && pExit.boostOn == false) {
09          sdp = pExit;
10          Invoke(nameof(RunSlowDown), delay);
11       }
12    }
13 }

Listing 4-11RoadScript Support Method Details 1

因为前面列出的所有三种方法都遵循几乎相同的模式,所以我在这里只详细回顾一下OnTriggerExit方法。将这些知识应用到OnTriggerEnterOnTriggerStay方法中。首先,在第 2–4 行,我们有活动脚本检查。如果返回 false,那么该方法不做任何工作就返回。这是该类“故障安全锁定”的一部分。请注意,锁定会影响脚本的所有实例。同样,当类配置失败时,我们锁定类的功能。这可以防止一连串错误堵塞控制台输出。请记住,这个特性总是可以在生产版本中被注释掉。

在第 6 行,如果定义了碰撞对象,我们用它来加载相关玩家的PlayerState类,如果有的话,在第 7 行调用 Utilities 类的LoadPlayerInfo方法。如果合适的标准匹配,在第 8 行,我们将本地字段sdp设置为我们刚刚碰撞的游戏对象的PlayerState,并调用RunSlowDown方法。因为OnTriggerExit方法在玩家的悬停赛车离开道路传感器时触发,所以我们调用减速方法来激活脱离赛道速度惩罚。

碰撞事件处理程序方法的 stay 和 enter 版本会将玩家的汽车加速到正常速度。exit collision 事件处理程序方法将使玩家的汽车减速到我们刚刚看到的偏离赛道的速度。注意,在第 10 行,通过用要运行的方法的名称和一个时间延迟调用类'Invoke,间接地调用了RunSlowDown方法。在这种情况下,玩家或 AI 对手在减速惩罚触发前有几秒钟的时间回到赛道。

1 public void SlowDown(PlayerState p) {
2    if (p != null && p.offTrack == false && p.controller.isGrounded == true) {
3       p.offTrack = true;
4       p.SetSlow();
5    }
6 }

1 public void RunSlowDown() {
2    SlowDown(sdp);
3 }

1 public void SpeedUp(PlayerState p) {
2    if (p != null && p.offTrack == true) {
3       p.offTrack = false;
4       p.SetNorm();
5    }
6 }

Listing 4-12RoadScript Support Method Details 2

前面详述的一组支持方法中列出的第一种方法是SlowDown方法。该方法通过调用PlayerState class' SetSlow方法将玩家状态设置为脱离赛道,并降低悬停赛车的最大速度。列出的下一个方法RunSlowDown,用于从对Invoke方法的调用中执行SlowDown方法,正如我们前面看到的。

我们要看的最后一个方法是SpeedUp方法。这种方法实质上逆转了SlowDown方法的效果。在该方法的第 3-4 行,悬停赛车被标记为在赛道上,其速度被设置为悬停赛车在赛道上可以达到的正常速度。我们要看的下一部分是主要的方法回顾。让我们看一看,好吗?

主要方法详细信息:RoadScript

RoadScript类只有一个主要方法让我们复习。无处不在的Start方法。

1 void Start() {
2    base.Prep(this.GetType().Name);
3    if (BaseScript.IsActive(scriptName) == false) {
4       Utilities.wrForce(scriptName + ": Is Deactivating...");
5       return;
6    }
7 }

Listing 4-13RoadScript Main Method Details 1

这个方法应该看起来很熟悉。我们以前看到过类似的实现,我们肯定会再次看到它们。在该方法的第 2 行,我们看到对基类BaseScript的集中式类准备方法Prep的调用。这个方法调用加载所有需要的默认类字段,并引用游戏对象和脚本组件。如果准备调用失败,那么在BaseScript类的注册表中,这个名为的类被注册为一个假值。请注意,在第 4 行,有一个日志条目表明该类有问题。这就结束了主要的方法评审。接下来,我们将演示该类的实际操作。

演示:RoadScript

有一个场景是专门设计来演示道路脚本的。找到“项目”面板,并找到“场景”文件夹。找到并打开名为“DemoCollideTrack”的场景。在我们开始演示之前,让我描述一下场景是如何工作的。在这个场景中是一段 30 米长的轨道,有一个RoadScript脚本组件和一个碰撞器,具有正确的触发标志设置。

您会注意到在运行演示的左下角有一些额外的文本。该文本指示悬停赛车的脱离赛道状态以及赛车脱离赛道的时间(毫秒)。请注意,当您偏离轨道时,偏离轨道标志不会立即触发。在标志翻转之前有几秒钟的延迟,所以请耐心等待。运行演示并检查东西。用户控制需要几秒钟才能激活。这个游戏实际上在运行一个看不见的倒计时,就像在真实游戏中一样。接下来,我们将看看WaypointCheck脚本。

img/520710_1_En_4_Fig2_HTML.png

图 4-2

RoadScript 演示场景以第三人称视角展示赛道上/赛道外演示场景的图像

课堂复习:中途检查

WaypointCheck脚本负责检测赛道上赛车的方向和大致位置。我想花点时间对游戏的航路点系统做一个深入的了解。游戏中的 AI 对手使用航路点系统在赛道上导航。让我们来看看这个模型的示意图。

img/520710_1_En_4_Fig3_HTML.png

图 4-3

赛道航路点模型图描述使用航路点定义赛道的图表

这些路点为游戏的人工智能玩家提供了一个脚手架。它还提供了一种快速确定悬停赛车在赛道上的大致位置的方法。它可以用来指示汽车的方向,并随后检测汽车是否在错误的方向上行驶。让我们来看看人工智能的对手是如何确定前进方向的。下图显示了简化模型中当前实现的逻辑。

img/520710_1_En_4_Fig4_HTML.png

图 4-4

人工智能路点逻辑模型图描述使用路点定义人工智能玩家方向的图

在此图中,悬停赛车使用指向下两个航路点中心的已知矢量。取这些向量的平均值, G ,并用于引导汽车。这些路点也有可以让人工智能汽车减速的指示器。这有助于在转弯、转弯和跳跃时控制车辆。这涵盖了所有的先决条件的材料。让我们开始课堂复习。我们将涉及的相关部分如下所示:

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

现在,让我们从第一部分开始复习。

类字段:航路点检查

我们要查看的第一组类字段是公开的,可以通过 Unity 编辑器的“Inspector”面板查看。

public int waypointRoute = 0;
public int waypointIndex = 0;
public float waypointStartY = 4;
public float waypointSlowDown = 1.0f;
public bool isSlowDown = false;
public float slowDownDuration = 100.0f;

Listing 4-14WaypointCheck Class Field 1

前面列出的第一个类字段是waypointRoute字段。这个字段实际上在游戏中并没有使用,所有的东西都默认为路线 0,但是如果你想进一步实现路点路线,我们还是会回顾一下。下面列出的类字段是waypointIndex字段。该字段应在 Unity 编辑器的等级构建过程中设置。重要的是用递增的索引对航路点进行适当的编号。但是,您可以等到航路点放置过程结束时再这样做。

waypointStartY域用于给航迹航路点分配一个 Y 值。当玩家的悬停赛车脱离赛道、倒退太久或落入水中后,将该赛车放回赛道时使用。它可以用来确保悬停赛车在更换时处于正确的高度。waypointsSlowDown字段设置一个值,指示应用于 AI 玩家的减速量。布尔型isSlowDown是一个标志字段,表明减速动作是活跃的,应该适用于人工智能控制的悬停赛车。该组中列出的最后一个字段是slowDownDuration字段。该字段控制减速应用于汽车的时间长度。在下一个复习部分,我们将看看这个类的相关方法。

相关的方法大纲/类头:WaypointCheck

下面列出了WaypointCheck类的相关方法。

//Main Methods
void Start();

//Support Methods
public void OnTriggerEnter(Collider otherObj);
public void ProcessWaypoint(Collider otherObj);

Listing 4-15WaypointCheck Pertinent Method Outline/Class Headers 1

随后列出的类头显示了类声明和任何基类或接口。

using UnityEngine;

public class WaypointCheck : BaseScript {}

Listing 4-16WaypointCheck Pertinent Method Outline/Class Headers 2

让我们先看看类的支持方法。

支持方法详细信息:航点检查

WaypointCheck类很少有支持方法让我们回顾。我们将看看触发器事件处理程序及其相关的支持方法。

01 public void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null) {
07       ProcessWaypoint(otherObj);
08    }
09 }

01 public void ProcessWaypoint(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null && otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out p, otherObj.gameObject, gameState, false);
08       if (p != null) {
09          if ((waypointIndex + 1) < p.aiWaypointIndex && ((waypointIndex + 1) - p.aiWaypointIndex) <= 3) {
10             p.wrongDirection = true;
11          } else {
12             p.wrongDirection = false;
13          }
14
15          if ((waypointIndex + 1) > p.aiWaypointIndex && ((waypointIndex + 1) - p.aiWaypointIndex) <= 5) {
16             if (p.aiWaypointLastIndex != p.aiWaypointIndex) {
17                p.aiWaypointPassCount++;
18             }
19
20             p.aiWaypointLastIndex = p.aiWaypointIndex;
21             p.StampWaypointTime();
22
23             if (p.IsValidWaypointIndex(waypointIndex + 1) == true) {
24                p.aiWaypointIndex = (waypointIndex + 1);
25             } else {
26                if (p == gameState.GetCurrentPlayer() && gameState.gameWon == false) {
27                   gameState.LogLapTime(p);
28                   p.lapComplete = true;
29                }
30
31                p.aiWaypointJumpCount = 0;
32                p.aiWaypointPassCount = 0;
33                p.aiWaypointIndex = 0;
34                if (p.currentLap + 1 <= p.totalLaps) {
35                   p.currentLap++;
36                }
37                p.ResetTime();
38             }
39
40             if (p.aiWaypointIndex == 1 && p.currentLap == gameState.totalLaps && playerIndex == gameState.currentIndex) {
41                //game over
42                if (gameState.IsStartMenuShowing() == false) {
43                   gameState.gameWon = true;
44                   gameState.SetPositions();
45                   gameState.ShowEndMenu();
46                }
47             }
48          } else {
49             p.skippedWaypoint = true;
50          }
51
52          if (p.aiOn == true) {
53             if (isSlowDown == true) {
54                p.aiSlowDownTime = 0f;
55                p.aiSlowDownDuration = slowDownDuration;
56                p.aiSlowDownOn = true;
57                p.aiSlowDown = waypointSlowDown;
58             }
59          }
60       }
61    }
62 }

Listing 4-17WaypointCheck Support Method Details 1

正如我们之前所见,调用OnTriggerEnter回调方法是为了响应WaypointCheck组件的盒子碰撞器和悬停赛车之间的碰撞。正如我们之前看到的,如果类没有正确配置,第 2–4 行的代码会阻止事件处理方法做任何工作。在第 6–8 行,如果给定的碰撞对象不为空,则调用ProcessWaypoint方法,第 7 行。

ProcessWaypoint方法在第 2–4 行具有相同的逸出故障保护。如果定义了碰撞对象,并且它有玩家标签,第 6 行,那么我们继续加载与该玩家相关的PlayerState数据,第 7 行。如果在第 8 行找到了玩家的状态对象,那么在处理路点的过程中,我们需要检查一些东西。我们需要检查的第一件事是汽车是否在倒车。该检查在第 9–13 行处理。航路点索引增加 1,以使检测更灵活,与汽车的最后一个航路点进行比较。

如果汽车的航路点在当前航路点之前,并且仅领先三个或更少,那么我们在第 10 行将悬停赛车标记为反向行驶。如果没有,我们将该字段的值设置为 false,第 12 行。在第 15 行,我们检查该航路点是否比悬停参赛者的当前航路点领先 5 或更少的值;然后我们继续处理它。执行快速检查以查看悬停赛车是否通过了一个航路点,确保赛车的前一个航路点不等于其当前航路点。如果是,悬停赛车的aiWaypointPassCount将递增。

接下来,在第 20 行,玩家的aiWaypointLastIndex被更新为汽车的前一个路点的值,在第 21 行,调用StampWaypointTime方法来更新路点时间戳。请注意,有时我可能会将悬停赛车称为玩家,因为赛车是玩家在游戏中的代表。代码片段,第 23–38 行,用于检测我们是否到达了赛道上的最后一个路点,并为下一圈重置一些值。在第 26-29 行,如果当前玩家正在与最后一个路点交互,而比赛还没有结束,那么我们记录一圈时间并标记该圈已经完成。

这个方法的下一个责任是检查游戏是否已经结束。这由第 40–47 行的代码片段处理。如果我们已经到达当前圈的最后一个路点,这是比赛的最后一圈,我们检查开始菜单是否显示在第 42 行。如果没有,意味着我们没有运行比赛的 AI 演示,那么我们标记游戏结束,刷新赛车的位置,然后显示结束菜单屏幕。

最后但同样重要的是,该方法负责通过将减速数据复制到第 53 到 58 行的 AI 玩家状态中来指导 AI 玩家。请注意,仅当该航路点的isSlowDown字段设置为真时,才会设置数据。这就结束了该类的支持方法。接下来,我们将看看这个类的主要方法。

主要方法详细信息:航路点检查

别担心,WaypointCheck类只有一个主要方法让我们看一看。花点时间看看这里列出的类的开始方法。

1 void Start() {
2    base.Prep(this.GetType().Name);
3    if (BaseScript.IsActive(scriptName) == false) {
4       Utilities.wrForce(scriptName + ": Is Deactivating...");
5       return;
6    }
7 }

Listing 4-18WaypointCheck Main Method Details 1

这个 start 方法提供了与我们之前看到的其他类的 start 方法相同的类初始化。因此,我不会在这里赘述。在继续下一步之前,请务必通读该方法并理解其工作原理。你会在每个扩展了BaseScript类的游戏类中看到非常相似的代码。本章中我们将讨论的最后一个类是TrackHelpScript。就像我们到目前为止已经讨论过的类一样,TrackHelpScript类是一个独立的碰撞检测器。它提供了在音轨首次使用时触发帮助通知的交互。在我们开始课程复习之前,让我们先来看一下WaypointCheck课程的演示场景。

演示:航路点检查

WaypointCheck班有演示场景让我们去看看。在我们看它之前,让我们先讨论一下这个场景是做什么的。首先,像其他每个演示场景一样,在你实际控制悬停赛车之前会有一点延迟。别担心,这不是一个 bug,游戏代码已经调整为支持演示场景,但它仍然有一些游戏功能,如比赛开始时的倒计时。你不会看到倒计时数字,但在短短几秒钟内,你就能控制汽车。

如果你沿着与场景的路点相反的方向驾驶汽车,柱子会变红或保持红色。如果你沿同一方向比赛,增加路标指数,柱子会变成绿色或保持绿色。如果你点击“层级”面板中的每个航路点,并在“检查器”面板中查看其详细信息,你会注意到其中一个配置了减速信息。这不会影响你的车,因为减速信息只被人工智能玩家使用。

你可以在“项目”面板的“场景”文件夹中找到这个演示场景。寻找名为“DemoCollideWaypoint”的场景。打开它,玩一会儿。看看场景是如何设置的,并特别注意路点及其与玩家汽车的交互。这个类的演示场景截图如下。

img/520710_1_En_4_Fig6_HTML.png

图 4-6

航路点检查演示场景 2A 航路点演示场景截图

img/520710_1_En_4_Fig5_HTML.png

图 4-5

航路点检查演示场景 1A 航路点演示场景的俯视图

课堂回顾:TrackHelpScript

TrackHelpScript类被设计用来在玩家第一次比赛时显示帮助信息。我想谈谈一个小小的实现警告。游戏的 HUD 支持三个这样的帮助信息。游戏的 HUD 屏幕上显示加速、减速和转弯帮助通知。然而,加速帮助通知不受TrackHelpScript控制。

三个支持的帮助消息中的第一个实际上显示为比赛开始代码的一部分。正如我之前提到的,TrackHelpScript类是一个独立的碰撞事件处理程序,与我们在本章中看到的其他类非常相似。我们将使用下面的课程回顾部分来介绍本课程。

  1. 相关的方法大纲/类头

  2. 支持方法详细信息

  3. 主要方法详细信息

  4. 示范

没有相关的静态类成员、枚举或类字段需要查看,所以我们将跳过这些部分。同样,当我们逐步浏览TrackHelpScript类的不同复习部分时,试着想象一下正在使用的类。我们要看的第一部分是课程的相关方法大纲。让我们来看看。

相关的方法大纲/类头:TrackHelpScript

TrackHelpScript类的相关方法如下。

//Main Methods
void Start();

//Support Methods
public void OnTriggerEnter(Collider otherObj);
public void ProcessTrackHelp(Collider otherObj);

Listing 4-19TrackHelpScript Pertinent Method Outline/Class Headers 1

接下来,我将列出类头、导入语句和类声明。请特别注意所使用的任何基类。如果需要的话,花点时间复习一下这些课程。

using UnityEngine;

public class TrackHelpScript : BaseScript {}

Listing 4-20TrackHelpScript Pertinent Method Outline/Class Headers 2

在下一节,我们将看看类的支持方法。

支持方法详细信息:TrackHelpScript

TrackHelpScript类的实现类似于其他独立碰撞交互脚本的实现。OnTriggerEnter事件回调处理一个冲突事件,并调用一个 worker 方法来处理必要的冲突响应。

01 public void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null) {
07       if (gameState.trackHelpOn == true) {
08          ProcessTrackHelp(otherObj);
09       }
10    }
11 }

01 public void ProcessTrackHelp(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj != null && otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out p, otherObj.gameObject, gameState, false);
08       if (p != null) {
09          if (p == gameState.GetCurrentPlayer() && p.aiOn == false) {
10             if (gameObject.CompareTag("TrackHelpSlow")) {
11                if (gameState.hudNewScript != null) {
12                   gameState.hudNewScript.HideHelpAccel();
13                   gameState.hudNewScript.HideHelpTurn();
14                   gameState.hudNewScript.ShowHelpSlow();
15                }
16                gameState.trackHelpSlowOn = true;
17                gameState.trackHelpSlowTime = 0f;
18             } else if (gameObject.CompareTag("TrackHelpTurn")) {
19                if (gameState.hudNewScript != null) {
20                   gameState.hudNewScript.HideHelpAccel();
21                   gameState.hudNewScript.ShowHelpTurn();
22                   gameState.hudNewScript.HideHelpSlow();
23                }
24                gameState.trackHelpTurnOn = true;
25                gameState.trackHelpTurnTime = 0f;
26                gameState.trackHelpOn = false;
27             }
28          }
29       }
30    }
31 }

Listing 4-21TrackHelpScript Support Method Details 1

我们先来看看OnTriggerEnter方法。在该方法的第 2–4 行中可以找到我们之前见过的相同的故障安全检查。如果碰撞物体被定义,第 6 行,在继续之前,我们在第 7 行检查跟踪帮助标志是否被设置为真。如果启用了跟踪帮助,那么在第 8 行调用ProcessTrackHelp方法。继续讨论ProcessTrackHelp方法。第 2-4 行应该看起来很熟悉,所以我们将跳过它。该方法设置为忽略与未标记为“玩家”的对象的碰撞。这确保了事件只有在玩家的车与它相撞时才会触发,第 6 行。

在第 7 行,我们使用了一个非常重要的方法,LoadPlayerInfo,它将为玩家触发碰撞的悬停赛车找到并加载状态。你会看到这个方法在整个游戏代码中被大量使用,所以确保你理解它是如何被使用的。这个方法调用的关键特性之一是“out p”参数。这个特殊的方法参数用于更新p字段,而不使用方法的返回值。如果调用成功并且定义了p字段,则执行第 9 到 28 行的代码。

在第 9 行,我们检查碰撞的玩家是否是当前玩家,我们确保该玩家不是 AI 玩家。你能想出我们为什么这样做吗?一个原因是,如果碰撞的玩家不是当前活跃的玩家或者 AI 控制为真,我们不需要显示帮助通知。这是因为 HUD 只连接到当前的玩家,没有必要显示其他汽车的帮助通知,或者如果这些汽车是人工智能控制的。TrackHelpScript能够切换轨道帮助“慢”和“转”HUD 通知。

随后,在第 10 行,我们检查当前的 Unity GameObjectgameObject字段是否有标签“TrackHelpSlow”;然后我们准备显示减速帮助通知。我们需要隐藏任何当前的帮助通知,所以第 11–15 行的代码检查我们是否可以通过GameStategameState访问游戏的 HUD。如果是这样,我们就隐藏任何其他的帮助通知,显示有效的通知,即第 14 行。第 16–17 行重置控制帮助通知显示的字段。这些字段负责在游戏的 HUD 上显示通知的时间。

第 18–27 行的类似代码用于控制轨道转向帮助信息。在继续下一步之前,看一下代码,确保你理解了它。这就是支持方法回顾部分的结论。在下一节中,我们将看看这个类的主要方法。

主方法详细信息:TrackHelpScript

TrackHelpScript类有一个主要的方法供我们回顾。我们以前见过这种方法。它用于初始化类和设置gameState字段。让我们来看看代码!

1 void Start() {
2    base.Prep(this.GetType().Name);
3    if (BaseScript.IsActive(scriptName) == false) {
4       Utilities.wrForce(scriptName + ": Is Deactivating...");
5       return;
6    }
7 }

Listing 4-22TrackHelpScript Main Method Details 1

正如我们之前讨论的,start 方法负责初始化类。它的主要职责是定位作为 GameState Unity GameObject组件的GameState脚本组件。您将在场景的层次中看到该对象。如果一切都成功了,那么这个类就通过名字注册为活动的。换句话说,当类初始化时,对IsActive方法的检查使用这里创建的结果。这就到了本复习部分的结尾。接下来,我们将看看TrackHelpScript的运行情况。

演示:TrackHelpScript

组件的演示场景可以在“项目”面板的“场景”文件夹中找到。找到名为“DemoCollideTrackHelp”的场景并打开它。让我花点时间来描述一下演示场景是如何工作的。场景开始几秒钟后,玩家就可以控制汽车了。一旦玩家控制了赛车,场景应该以赛道帮助加速通知标志开启开始。通知只会持续几秒钟。

当 track help acceleration 信息打开时,第一组柱子将显示为绿色。当信息关闭时,柱子会变成红色。如果你缓慢地向前行驶通过柱子,你将触发下一个轨道帮助通知,轨道帮助减速信息。为了表示第二个通知处于活动状态,中间的一组柱子会变绿几秒钟。检查后视镜,看看你后面的柱子变回来了。

将汽车移动到轨道末端会打开最后一个轨道帮助通知,即轨道帮助转向。请注意,因为演示场景没有连接 HUD,所以我们通过改变不同柱子组的颜色来表示何时显示通知。演示场景在游戏面板的左下角显示与赛道帮助消息相关的游戏状态。场景将只在帮助通知中循环一次。

如果你想重置场景,要么点击左下角的“重启”按钮,要么使用 Unity editor“游戏”面板控件启动和停止场景。下面的屏幕截图描述了这个类的演示场景。

img/520710_1_En_4_Fig8_HTML.png

图 4-8

跟踪帮助脚本演示场景 2 跟踪帮助脚本场景的俯视图

img/520710_1_En_4_Fig7_HTML.png

图 4-7

TrackHelpScript 演示场景 1 描述使用中的跟踪帮助演示场景的图像

这就引出了本章的结论。在进入下一个话题之前,让我们先来看一下本章已经介绍过的信息。

第二章结论

在这一章中,我们已经讨论了很多内容。让我们回顾一下我们在这里讨论过的不同课程。

  1. BounceScript:一个独立的碰撞检测器,为与特定游戏对象碰撞的玩家汽车添加反弹修改器。最常见的支持反弹修改器的游戏对象是反弹屏障。该类的演示场景展示了激活和停用反弹碰撞脚本的不同方式。

  2. RoadScript:这个类是一个独立的碰撞检测器,它跟踪玩家的汽车是在赛道上还是在赛道外。这个脚本附在游戏中的每一条赛道上。这个类的演示场景展示了一段切换当前玩家赛道标志的赛道。

  3. WaypointCheck:这个类用于处理游戏机制的一些不同方面。航路点系统提供了人工智能控制的汽车可以用来导航赛道的脚手架。航路点系统还提供减速队列,可以在弯道和赛道上其他棘手的部分减慢人工智能控制的汽车。航路点系统还用于确定汽车是否行驶在错误的方向上,以及在哪里恢复脱离轨道的汽车。本课程的演示场景展示了一小段赛道,有三个测试点。

  4. TrackHelpScript:这个类负责打开和关闭不同的赛道信息消息,以帮助用户学习如何快速控制 hover racer,以便他们能够胜任地玩游戏。这个类的演示场景显示了一系列的帮助信息,用彩色的柱子表示,当玩家在一小段赛道上行驶时,这些信息被打开和关闭。

至此,我们结束了对简单、独立交互的回顾。我们已经解决了游戏规约列表和模型图中的一些问题。在下一章中,我们将看一看集中的、高级的交互处理程序。这将为我们总结所有交互驱动的游戏机制。

五、高级交互类

在这一章中,我们将看看两个重要的集中式交互处理程序:CollideScriptCarSensorScript类。这两个脚本完善了游戏的交互处理程序及其相关的游戏机制。CollideScript处理许多不同的交互,这些交互发生在不同的游戏对象与悬停赛车发生碰撞时。

CarSensorScript与此类似,但只适用于悬浮赛车。它的主要职责是处理不同类型的车对车交互。我们先来回顾一下CollideScript

课堂回顾:碰撞脚本

CollideScript类负责处理许多不同的碰撞交互和它们相关的游戏机制。我将在此列出支持的交互类型:

  1. 助推器标记

  2. SmallBoostMarker

  3. TinyBoostMarker

  4. MediumBoostMarker

  5. TinyBoostMarker2

  6. 跳跃标记

  7. 健康标记

  8. 枪标记

  9. 无敌标记

  10. 装甲标记

  11. 未加标签的

  12. 可击中的

  13. HittableNoY

  14. 演员

那是很多的交互。花点时间看一下。有什么让你印象深刻的吗?在大多数情况下,它们看起来很简单。不过,名为“Players”的列表看起来确实有点有趣。当我们复习CollideScript课程时,我们必须留意这一点。

正如我之前提到的,CollideScript是一个与玩家的悬浮赛车进行集中交互的游戏。在比赛过程中,当玩家的汽车与赛道上的不同物体发生碰撞时,游戏机制会激活以调整汽车的物理特性。我们将使用以下课堂回顾模板来介绍本课程:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

关于CollideScript类没有相关的枚举,所以我们将省略这一部分。除此之外,这是一个比我们之前回顾的更复杂的类。别担心,一点点努力就能走很长的路。让我们开始写代码吧!

静态/常量/只读类成员:碰撞脚本

CollideScript类有几个纯实数字段供我们查看。我把它们列在这里。

public readonly float BOUNCE_DURATION = 80.0f;
public readonly float BOOST_DURATION = 200.0f;
public readonly float MIN_JUMP_FORCE = 18.0f;
public readonly float MAX_JUMP_FORCE = 22.0f;

Listing 5-1CollideScript Static/Constants/Read-Only Class Members 1

之前列出的只读类字段集用于控制类碰撞交互的某些方面。第一个条目BOUNCE_DURATION用于控制反弹修改器应用于玩家悬停赛车的时间长度。类似地,BOOST_DURATION字段用于控制一个增强修改器应用于一辆汽车的时间长度。以下两个条目用于设置与跳跃修改器相关的力的限制。

类字段:碰撞脚本

CollideScript有许多用来管理它负责处理的不同游戏机制的类字段。其中一些字段是私有的,由某些类方法在内部使用。

private float maxSpeed = 200.0f;
private GameObject player = null;
private CharacterController controller = null;
private CharacterMotor cm = null;

Listing 5-2CollideScript Class Fields 1

列出的第一个类字段maxSpeed用于跟踪当前玩家的悬停赛车的非加速最大速度。列出的下一个字段是玩家字段。它引用了当前玩家的悬停赛车的GameObject。该参考用于根据应用的游戏机制调整汽车的运动。接下来的两个字段也用于移动悬停赛车。CharacterController实例controller用于沿着CharacterMotor字段 cm 移动汽车。这给了我们三种不同的方法来控制游戏的悬停赛车模型,以应对不同的碰撞驱动的游戏机制。

我们要查看的下一组类字段与在某些碰撞交互中应用于悬停赛车的力有关。我不严格地使用“力”这个术语。我们采用了一些不同的技术来使汽车弹跳、颠簸、跳跃和加速。在这样做的时候,我们将应用不同的力、速度和位置调整来满足游戏力学的要求,我们将它们统称为“力”或“速度”

public float forceMultiplier = 2.5f;
public float minForce = 20.0f;
public float maxForce = 80.0f;
public bool lockAxisY = false;
public float bounceDampener = 1.0f;
public float minBounceVelocityHor = 25.0f;
public float minBounceVelocityVer = 25.0f;
public float jump = 9.0f;

Listing 5-3CollideScript Class Fields 2

前面列出的第一个字段forceMultiplier,是一个浮点值,用于增加跳跃的垂直力。接下来的两个类字段,minForcemaxForce,用于设置当玩家的赛车与可击中的物体发生碰撞时所施加的力的范围。随后的字段lockAxisY是一个布尔标志,控制在确定碰撞效果时是否使用 Y 轴(垂直轴)。bounceDampener场用于减少反弹事件中的力。

以下两个字段minBounceVelocityHorminBounceVelocityVer,用于确保反弹修改器有足够的力来真正反弹汽车。请注意,该修改器作用于水平轴和垂直轴。我应该注意到这个反弹修改器不同于我们之前讨论过的BounceScript类。该类负责由悬停赛车与物体碰撞激活的反弹修改器。

该反弹修改器由两个相互碰撞的悬停赛车激活。集合中列出的最后一个类字段是jump字段。该字段用于设置基线垂直力,该力在应用跳跃修改器时使用。下一组类字段是私有的,在一些类的方法中作为局部变量在内部使用。让我们快速地看一下它们,并描述它们是如何使用的。

//***** Internal Variables: Mod Markers *****
private GameObject lastHealthMarker = null;
private GameObject lastGunMarker = null;
private GameObject lastInvcMarker = null;
private GameObject lastArmorMarker = null;

Listing 5-4CollideScript Class Fields 3

下一组职业字段全部用于替换玩家可以在赛道上获得的战斗模式汽车修改器。如果你跑一场战斗模式的比赛,这将打开赛道上的许多赛车修改器。为了在几秒钟后替换修改器,我们保留了最后激活的修改器标记的副本。如你所见,它支持四个战斗模式修改器。我们要查看的下一组类字段由类的 start 方法使用。

//***** Internal Variables: Start *****
private AudioSource audioJump = null;
private AudioSource audioBounce = null;
private AudioSource audioBoost = null;
private AudioSource audioPowerUp = null;

Listing 5-5CollideScript Class Fields 4

前面列出的四个条目是附加到CollideScript脚本组件的父GameObjectAudioSource组件。这些声音效果被加载到类字段中,以便在该类处理碰撞交互时使用。我们要看的下一个字段块是在可碰撞物体碰撞时使用的。

//***** Internal Variables: PerformHit *****
private float collideStrength;
private float jumpHit = 15.0f;
private Rigidbody body = null;
private Vector3 moveDirection = Vector3.zero;
private Vector3 rotateDirection = Vector3.zero;
private AudioSource phAudioS = null;

Listing 5-6CollideScript Class Fields 5

首先,我们有collideStrength字段,用于计算赛车在赛道上撞上物体时的强度。在游戏中,这种机制体现在经典模式下散落在赛道上的可击中的油桶中。浮动实例jumpHit是一个类字段,当汽车与可击中的轨迹对象碰撞时,它用于计算 Y 轴、垂直轴的力。body字段是RigidBody类的一个实例,用于检测可击对象是否应该被施加力。

接下来的两个条目moveDirectionrotateDirection用于确定碰撞后可击中的物体以何种方向和何种旋转飞离悬停赛车。最后,phAudioS字段用于在碰撞事件中播放声音效果。下一组要查看的类字段是一个大字段。它们由用于为反弹机制提供动力的场组成。现在,我们已经看到了反弹机制应用于反弹障碍。

在这种情况下,汽车在碰撞事件中从护栏上弹开。正如我前面提到的,在这种情况下,我们实现了一个反弹机制,但这次是在两辆车之间,而不是一辆车和一个障碍之间。为了支持这种类型的反弹,我们需要几个字段来保存与我们相撞的汽车的不同信息,并在其上激活一个反弹修改器。

//***** Internal Variables: PerformBounce *****
private PlayerInfo lpi = null;
private int lpIdx = 0;
private PlayerState lp = null;
private CollideScript lc = null;
private Vector3 v3;
private float x;
private float y;
private float z;
private bool isBouncing = false;
private bool useReflect = false;
private bool useInverse = false;
private bool bounceHandOff = false;
private Vector3 bounceV3 = Vector3.zero;
private float bounceTime = 0.0f;

Listing 5-7CollideScript Class Fields 6

前面清单中的前三个字段用于在游戏状态中查找玩家信息,使用的是被撞汽车的脚本组件。lc类字段用于保存与 hover racer 的CollideScript冲突的引用。以下四个字段v3xyz,都是用来计算碰撞结果所涉及的力。计算结束时,矢量分量值xyz存储在矢量场v3,中。isBouncing字段是一个布尔标志,指示悬停赛车是否被反弹。

随后列出的字段useReflectuseInverse是用于改变反弹力计算的布尔标志。下一个字段bounceHandOff是一个布尔值,用于触发来自外部源的反弹。你能猜到我们什么时候会用这个吗?如果你认为你的车在碰撞事件中触发了另一辆车的反弹,你认为是对的。bounceV3是一个Vector3实例,指示最终反弹的方向和力度。bounceTime向量记录反弹修改器应用于汽车的时间。我们要复习的最后一组职业字段对应于助推机制。

//***** Internal Variables: PerformBoost *****
private float pbAbsX = 0.0f;
private float pbAbsZ = 0.0f;
private bool boostOn = false;
private bool boostHandOff = false;
private Vector3 boostV3 = Vector3.zero;
private float boostTime = 0.0f;

Listing 5-8CollideScript Class Fields 7

前面清单中的一组职业字段用于增强游戏机制。助推机制听起来就像它一样。它为悬停赛车提供了动力,以新的最大速度向前射击。前两个条目用于确定悬停赛车的当前 X 轴速度的绝对值。类似地,pbAbsZ字段跟踪悬停赛车的当前 Z 速度的绝对值。boostOn字段是一个布尔标志,表示汽车的助推修改器已激活。boostHandOff字段用于从另一个源触发一个增强修改器。最后,boostV3向量保存应用于汽车的计算出的推进,而boostTime浮动实例跟踪推进的持续时间。接下来,我们将看看控制跳跃修改器的字段。

//***** Internal Variables: PerformJump *****
private bool isJumping = false;
private bool jumpHandOff = false;
private Vector3 jumpV3 = Vector3.zero;
private float jumpStrength;
private float gravity = 10.0f;

Listing 5-9CollideScript Class Fields 8

这个集合中的前两个字段类似于它们的 boost 等价物。isJumping字段是一个布尔标志,指示当前悬停赛车是否正在跳跃。jumpHandOff字段是另一个布尔标志,它将触发当前汽车上的跳跃修改器。jumpV3字段是应用于悬停赛车的计算跳跃向量。浮动实例jumpStrength看起来是一个跟踪计算的跳转强度的字段。最后,我们有gravity场,负责估计重力,并慢慢地把车拉回赛道。接下来,我们将看看这个类的相关方法列表。

相关的方法大纲/类头:冲突脚本

CollideScript的相关方法概述如下。

//Main Methods
void Start();
void Update();
public void OnControllerColliderHit(ControllerColliderHit hit);

//Support Methods
public void RecreateHealthMarker();
public void RecreateGunMarker();
public void RecreateInvcMarker();
public void RecreateArmorMarker();

private void CalcCollideStrength();
private float GetMinForce(float v);
private float GetMaxForce(float v);
private float GetBounceVelHor(float v);
private float GetBoostVelHor(int mode, float movVel);

public void PerformHit(GameObject go, ControllerColliderHit hit);

public void PerformBounce(GameObject go, ControllerColliderHit hit)

public void PerformBoost(GameObject go, ControllerColliderHit hit, int mode);

public void PerformJump(GameObject go, ControllerColliderHit hit);

Listing 5-10CollideScript Pertinent Method Outline/Class Headers 1

接下来,我将列出CollideScript类的导入语句和类声明。密切注意使用的任何基类。

using UnityEngine;

public class CollideScript : BaseScript {}

Listing 5-11CollideScript Pertinent Method Outline/Class Headers 2

如您所见,CollideScript类扩展了BaseScript类,因此是一个MonoBehaviour实例,换句话说,是一个脚本组件。这意味着您可以将它附加到场景中的不同游戏对象。我们要复习的一些课程不是MonoBehaviour s。请留意它们。

支持方法详细信息:碰撞脚本

这个课程是我们要复习的较大的课程之一。因为支持方法很少,我们将分组列出并回顾它们。让我们来看看前九种更简单的支持方法。

01 public void RecreateHealthMarker() {
02    lastHealthMarker.SetActive(true);
03 }

01 public void RecreateGunMarker() {
02    lastGunMarker.SetActive(true);
03 }

01 public void RecreateInvcMarker() {
02    lastInvcMarker.SetActive(true);
03 }

01 public void RecreateArmorMarker() {
02    lastArmorMarker.SetActive(true);
03 }

01 private void CalcCollideStrength() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (p == null) {
07       collideStrength = 0;
08    } else {
09       collideStrength = (p.speed * forceMultiplier) / maxSpeed;
10    }
11 }

01 private float GetMinForce(float v) {
02    if (Mathf.Abs(v) < minForce) {
03       if (v < 0) {
04          return -minForce;
05       } else {
06          return minForce;
07       }
08    }
09    return v;
10 }

01 private float GetMaxForce(float v) {
02    if (Mathf.Abs(v) > maxForce) {
03       if (v < 0) {
04          return -maxForce;
05       } else {
06          return maxForce;
07       }
08    }
09    return v;
10 }

01 private float GetBounceVelHor(float v) {
02    if (useReflect == true) {
03       v = v * bounceDampener;
04    } else {
05       if (useInverse == true) {
06          v = v * -1 * bounceDampener;
07       } else {
08          v = v * bounceDampener;
09       }
10    }
11
12    if (v < 0) {
13       if (v > -minBounceVelocityHor) {
14          v = -minBounceVelocityHor;
15       }
16    } else if (v >= 0) {
17       if (v < minBounceVelocityHor) {
18          v = minBounceVelocityHor;
19       }
20    }
21    return v;
22 }

01 private float GetBoostVelHor(int mode, float movVel) {
02    float v3 = 0.0f;
03    if (mode == 0) {
04       v3 = 200;
05    } else if (mode == 1) {
06       v3 = 50;
07    } else if (mode == 2) {
08       v3 = 25;
09    } else if (mode == 3) {
10       v3 = 100;
11    } else if (mode == 4) {
12       v3 = 15;
13    }
14
15    if (movVel < 0) {
16       v3 *= -1;
17    }
18    return v3;
19 }

Listing 5-12CollideScript Support Method Details 1

这组中列出的前四种方法几乎完全相同。这些方法被设计成在玩家与一个战斗模式修改器碰撞几秒钟后触发,导致修改器的游戏对象标记被禁用,变得不可见。这四种方法之间的唯一区别是要重新激活哪个标记。下一个方法是CalcCollideStrength方法,这个方法依赖于可能没有正确初始化的p类字段。

因此,第 2–4 行具有预期的逸出检查。该方法简单、直接。如果p为空,则collideStrength字段被设置为零;否则,使用公式来确定正确的值,第 9 行。下面列出的两种支持方法GetMinForceGetMaxForce非常相似。这两种方法都限制传入的力,并尊重力的符号。这些方法相当直接。通读它们,确保你理解这些方法是如何工作的。

下一个要回顾的方法是GetBounceVelHor方法。该方法用于计算水平轴上的反弹速度。第 2-10 行的代码减小了初始力,同时考虑到了反向调整和反射调整的使用。第 12–20 行的代码是为了确保计算的速度有一个标准的最小值,同时考虑它的符号。第 21 行返回最终值。

这组中我们要回顾的最后一个方法是GetBoostVelHor方法。该方法用于计算增强修改器的水平速度分量。这种方法支持五种不同的升压类型。基于mode方法参数,确定速度,第 2–13 行。如果速度是负的,新的加速速度被调整并返回,第 5–18 行。

接下来我们要回顾的一组方法是负责实际制定修饰符的。这些方法在长度上有点长,所以我们将一个一个地回顾它们。第一种方法是PerformHit方法。此方法用作可碰撞对象碰撞处理的一部分。

01 public void PerformHit(GameObject go, ControllerColliderHit hit) {
02    if (BaseScript.IsActive(scriptName) == false || go == null || hit == null) {
03       return;
04    }
05
06    body = hit.collider.attachedRigidbody;
07    if (body == null || body.isKinematic) {
08       return;
09    }
10
11    moveDirection = Vector3.zero;
12    CalcCollideStrength();
13    if (lockAxisY == false) {
14       moveDirection.y = (jumpHit * collideStrength);
15    } else {
16       moveDirection.y = 0;
17    }
18    moveDirection.x = (cm.movement.velocity.x * collideStrength);
19    moveDirection.z = (cm.movement.velocity.z * collideStrength);
20
21    if (minForce > 0) {
22       moveDirection.x = GetMinForce(moveDirection.x);
23       moveDirection.z = GetMinForce(moveDirection.z);
24
25       if (lockAxisY == false) {
26          moveDirection.y = GetMinForce(moveDirection.y);
27       }
28    }
29
30    if (maxForce > 0) {
31       moveDirection.x = GetMaxForce(moveDirection.x);
32       moveDirection.z = GetMaxForce(moveDirection.z);
33
34       if (lockAxisY == false) {
35          moveDirection.y = GetMaxForce(moveDirection.y);
36       }
37    }
38
39    rotateDirection = (moveDirection * 1);
40    body.rotation = Quaternion.Euler(rotateDirection);
41    body.velocity = moveDirection;
42
43    phAudioS = go.GetComponent<AudioSource>();
44    if (phAudioS != null) {
45       if (phAudioS.isPlaying == false) {
46          phAudioS.Play();
47       }
48    }
49 }

Listing 5-13CollideScript Support Method Details 2

如您所料,第 2–4 行检查该类是否已经正确配置。否则,该方法不做任何工作就返回。在第 6–9 行,我们检查了 hit 参数的主体,以查看值是否为空,或者bodyisKinematic标志是否设置为真。如果是的话,那么力和碰撞将不再影响刚体。我们尊重这一点,并在我们的代码中进行检查。

通过调用CalcCollideStrength方法在第 12 行设置collideStrength的值。第 13–17 行的小代码块控制运动向量的 Y 分量是否是碰撞计算的一部分。初始水平力设置在第 18 行和第 19 行。第 21–28 行的代码块过滤分力,以确保它们具有最小值。

做同样的事情来确保水平值不大于最大允许值,第 30–37 行。rotateDirection向量基于moveDirection向量。被击中对象的实际旋转设置在第 40 行,而移动速度设置在第 41 行。第 43–48 行的代码用于在碰撞发生时播放声音效果。接下来,我们来看看PerformBounce方法。

01 public void PerformBounce(GameObject go, ControllerColliderHit hit) {
02    if (BaseScript.IsActive(scriptName) == false || go == null || hit == null) {
03       return;
04    }
05
06    x = GetBounceVelHor(cm.movement.velocity.x);
07    y = cm.movement.velocity.y;
08    z = GetBounceVelHor(cm.movement.velocity.z);
09
10    if (useReflect == true) {
11       v3 = Vector3.Reflect(v3, hit.collider.ClosestPointOnBounds(player.transform.position).normalized);
12    } else {
13       v3 = new Vector3(x, y, z);
14    }
15
16    Utilities.LoadPlayerInfo(GetType().Name, out lpi, out lpIdx, out lp, hit.gameObject, gameState, false);
17    if (lp != null) {
18       lc = lp.player.GetComponent<CollideScript>();
19       lc.bounceHandOff = true;
20       lc.bounceV3 = v3;
21    }
22 }

Listing 5-14CollideScript Support Method Details 3

顾名思义,PerformBounce方法负责将反弹修改器应用于碰撞的悬停赛车。如果配置步骤失败,方法开头的代码行 2–4 会阻止方法执行任何工作。反弹向量的xyz分量在第 6–8 行设置。第 10–14 行使用第 11 和 13 行显示的两种技术中的一种来完成反弹向量。

看看第 16–21 行的代码块。这是一个标准的玩家查找,我们已经见过一次又一次,除了在这种情况下,我们正在查找与我们碰撞的玩家的玩家状态数据。注意第 17–20 行的代码。我们通过设置bounceHandOff标志和bounceV3字段值,得到一个与玩家碰撞的CollideScript的引用,并触发玩家汽车的反弹。我们要看的下一个方法是PerfectBoost方法。

01 public void PerformBoost(GameObject go, ControllerColliderHit hit, int mode) {
02    if (BaseScript.IsActive(scriptName) == false || go == null || hit == null) {
03       return;
04    }
05
06    pbAbsX = Mathf.Abs(p.cm.movement.velocity.x);
07    pbAbsZ = Mathf.Abs(p.cm.movement.velocity.z);
08    boostV3 = Vector3.zero;
09
10    if (pbAbsX > pbAbsZ) {
11       boostV3.x = GetBoostVelHor(mode, p.cm.movement.velocity.x);
12    } else {
13       boostV3.z = GetBoostVelHor(mode, p.cm.movement.velocity.z);
14    }
15
16    boostHandOff = true;
17    if (audioBoost != null) {
18       if (audioBoost.isPlaying == false) {
19          audioBoost.Play();
20       }
21    }
22
23    if (p != null) {
24       p.flame.SetActive(true);
25    }
26 }

Listing 5-15CollideScript Support Method Details 4

PerformBoost的方法签名类似于我们之前看到的“执行”方法,但是它需要一个额外的参数,一个模式值。现在,第 2-4 行对您来说应该很熟悉了,所以我们将继续。方法变量在第 6–8 行初始化。在第 10 行,确定哪个水平方向是主导方向。矢量分量的速度在第 11 行和第 13 行使用我们前面提到的GetBoostVelHor方法设置。

在第 16 行上,boostHandOff标志被设置为真。这用于打开在类的 update 方法中应用的 boost 修饰符。第 17–21 行播放声音效果。最后,在第 23–25 行,粒子效果被打开,如果可用的话,以指示增强修改器。接下来我们要复习的方法是PerformJump方法。让我们来看看一些代码。

01 public void PerformJump(GameObject go, ControllerColliderHit hit) {
02    if (BaseScript.IsActive(scriptName) == false || go == null || hit == null) {
03       return;
04    }
05
06    jumpStrength = ((p.speed) * forceMultiplier) / maxSpeed;
07    jumpV3 = Vector3.zero;
08    jumpV3.y = (jump * jumpStrength);
09
10    if (jumpV3.y < MIN_JUMP_FORCE) {
11       jumpV3.y = MIN_JUMP_FORCE;
12    }
13
14    if (jumpV3.y >= MAX_JUMP_FORCE) {
15       jumpV3.y = MAX_JUMP_FORCE;
16    }
17
18    jumpHandOff = true;
19    if (audioJump != null) {
20       if (audioJump.isPlaying == false) {
21          audioJump.Play();
22       }
23    }
24 }

Listing 5-16CollideScript Support Method Details 5

PerformJump方法遵循的模式与我们为这个类回顾的前面的方法相似。该方法采用我们在其他“执行”方法中见过的相同参数。同样,我们在第 2–4 行有方法保护代码。jumpStrength字段的值基于悬停赛车的速度,并在第 6 行计算。跳跃速度变量在第 7 行初始化,而垂直力在第 8 行设置。

跳跃力的强度由第 10-16 行的代码调节在最小和最大范围内。第 18 行上的jumpHandOff标志被设置为真。这将启用该类的 update 方法中的跳转修饰符。跳跃音效在第 19–23 行处理。这就引出了支持方法细节部分的结论。

主要方法细节:碰撞脚本

CollideScript类有几个主要的方法让我们复习。我们要看的第一个方法是Start方法。这个方法被 Unity 游戏引擎称为MonoBehaviour生命周期的一部分。

01 void Start() {
02    base.PrepPlayerInfo(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    } else {
07       player = p.player;
08       maxSpeed = p.maxSpeed;
09       controller = p.controller;
10       cm = p.cm;
11
12       if (controller == null) {
13          Utilities.wrForce("CollideScript: controller is null! Deactivating...");
14          MarkScriptActive(false);
15          return;
16       }
17
18       if (player == null) {
19          Utilities.wrForce("CollideScript: player is null! Deactivating...");
20          MarkScriptActive(false);
21          return;
22       }
23
24       if (cm == null) {
25          Utilities.wrForce("CollideScript: cm is null! Deactivating...");
26          MarkScriptActive(false);
27          return;
28       }
29
30       AudioSource[] audioSetDst = Utilities.LoadAudioResources(GetComponentsInParent<AudioSource>(), new string[] { Utilities.SOUND_FX_JUMP, Utilities.SOUND_FX_BOUNCE, Utilities.SOUND_FX_BOOST, Utilities.SOUND_FX_POWER_UP });
31       if (audioSetDst != null) {
32          audioJump = audioSetDst[0];
33          audioBounce = audioSetDst[1];
34          audioBoost = audioSetDst[2];
35          audioPowerUp = audioSetDst[3];
36       }
37    }
38 }

Listing 5-17CollideScript Main Method Details 1

Start方法比我们到目前为止讨论过的大多数方法都要长一点。别担心,没有看起来那么复杂。第 2 行的代码用于加载与当前玩家相关的标准类和PlayerState。如果配置成功,第 3 行的活动标志检查将返回 true,然后我们继续在类字段中存储对玩家的悬停赛车模型、最大速度、控制器和角色运动的引用。第 12–28 行的代码片段用于检查是否定义了该类的必填字段,如果没有,它将该类标记为非活动并返回。

代码的音频资源加载部分从第 30 行到第 36 行。在这种情况下,我们使用实用程序方法LoadAudioResources,并向它传递一个对连接的AudioSource数组的引用和一个要搜索的名称数组。该方法搜索音频源并寻找每个搜索目标。结果是一个定制的AudioSource实例数组。在第 32 到 35 行,从音频资源的结果阵列中设置单独的交互声音效果。我们要看的下一个方法是Update方法。

01 void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (gameState != null) {
07       if (gameState.gamePaused == true) {
08          return;
09       } else if (gameState.gameRunning == false) {
10          return;
11       }
12    }
13
14    //bounce code
15    if (bounceHandOff == true) {
16       bounceTime = 0f;
17       isBouncing = true;
18       p.isBouncing = true;
19
20       if (audioBounce != null) {
21          if (audioBounce.isPlaying == false) {
22             audioBounce.Play();
23          }
24       }
25    }
26
27    if (isBouncing == true) {
28       bounceTime += (Time.deltaTime * 100);
29       bounceHandOff = false;
30       controller.Move(bounceV3 * Time.deltaTime);
31    }
32
33    if (isBouncing == true && bounceTime >= BOUNCE_DURATION) {
34       isBouncing = false;
35       p.isBouncing = false;
36    }
37
38    //boost code
39    if (boostHandOff == true) {
40       boostTime = 0f;
41       boostOn = true;
42       p.offTrack = false;
43       p.boostOn = true;
44       p.SetBoost();
45    }
46
47    if (boostOn == true) {
48       boostTime += (Time.deltaTime * 100);
49       boostHandOff = false;
50       controller.Move(boostV3 * Time.deltaTime);
51    }
52
53    if (boostOn == true && boostTime >= BOOST_DURATION) {
54       boostOn = false;
55       p.boostOn = false;
56       p.SetNorm();
57       p.flame.SetActive(false);
58    }
59
60    //jump code
61    if (controller.isGrounded == true) {
62       cm.jumping.jumping = false;
63       p.isJumping = false;
64       isJumping = false;
65    }
66
67    if (jumpHandOff == true) {
68       p.offTrack = false;
69       cm.jumping.jumping = true;
70       p.isJumping = true;
71       isJumping = true;
72    }
73
74    if (isJumping == true) {
75       jumpHandOff = false;
76       controller.Move(jumpV3 * Time.deltaTime);
77    }
78
79    //gravity code
80    if ((controller.isGrounded == false || cm.movement.velocity.y > 0) && isJumping == true) {
81       jumpV3.y -= gravity * Time.deltaTime;
82    }
83
84    if (player != null && player.transform.position.y >= Utilities.MAX_XFORM_POS_Y && cm.movement.velocity.y > Utilities.MIN_XFORM_POS_Y) {
85       cm.movement.velocity.y -= gravity * Time.deltaTime;
86    } else if (controller.isGrounded == false || cm.movement.velocity.y > 0 || p.player.transform.position.y > 0) {
87       cm.movement.velocity.y -= gravity * Time.deltaTime;
88    }
89
90    if (controller.isGrounded == false) {
91       cm.movement.velocity.y -= gravity * Time.deltaTime;
92    }
93 }

Listing 5-18CollideScript Main Method Details 2

Update方法由 Unity 游戏引擎调用游戏的每一帧。关于CollideScript类,Update方法负责将主动修改器应用于悬停赛车。该方法具有我们之前见过的相同的安全检查。第 6–12 行的代码块用于检查游戏的状态,如果存在特定的游戏状态,则退出该方法。

我们遇到的第一个修改器是第 14 行的反弹修改器。第 15–25 行的代码用于启动反弹修改器并播放声音效果。我要提一下,这个弹跳音效和用PerformBounce方法播放的是不一样的。声音效果是从碰撞的汽车上播放的,而不是像这种情况下当前玩家的汽车。注意在第 17-18 行,CollideScript类的修改器状态与当前玩家的PlayerState实例保持一致。如果反弹修改器处于活动状态,则执行第 27–31 行的代码。这段代码应用了反弹修改器,第 30 行;阻止它再次启动,第 29 行;并监控它活动的时间,第 28 行。

反弹修改器逻辑的最后一部分是第 33–36 行的一段代码,它在BOUNCE_DURATION到期后关闭修改器。boost 修饰符代码从第 38 行开始。这部分修改代码与反弹代码非常相似。让我们看一下起始代码,第 39–45 行。如果boostHandOff标志为真,则boostTime被重置,第 40 行,并且boostOn标志都被设置为真,第 41-42 行。在第 43 行,当前玩家的悬浮赛车的加速被打开。

接下来,在第 47 行,处理一个主动增强修改器。增强修改器的持续时间在第 48 行递增,并且通过将boostHandOff标志设置为假来防止修改器重新打开。改性剂应用于第 50 行。下一个 boost 修饰符代码块在第 53–58 行。如果增强持续时间到期,增强修改器被停用,第 54–56 行。

在第 60 行,跳转修饰符部分开始。第 61–65 行有一段代码。这个代码的目的是当汽车接触地面时关闭跳跃修改器。与该方法中处理的其他修改器不同,当重力将汽车拉回到地面时,“跳跃”修改器关闭。跳跃修改器由jumpHandOff标志以类似于反弹和加速修改器的方式启动。

在第 74–77 行,应用了跳转修饰符。第 79–92 行的最后一个代码块用于向汽车施加重力,并使其返回地面。跳跃力也会随着时间的推移而减弱,以帮助汽车从跳跃中漂浮回来。这就结束了对该类的 update 方法的回顾。最后要看的主要方法是OnControllerColliderHit碰撞事件处理程序。让我们来看看。

001 public void OnControllerColliderHit(ControllerColliderHit hit) {
002    if (BaseScript.IsActive(scriptName) == false) {
003       return;
004    }
005
006    if (hit.gameObject.CompareTag(Utilities.TAG_UNTAGGED)) {
007       return;
008    } else if (hit.gameObject.CompareTag(Utilities.TAG_HITTABLE)) {
009       lockAxisY = false;
010       PerformHit(hit.gameObject, hit);
011    } else if (hit.gameObject.CompareTag(Utilities.TAG_HITTABLE_NOY)) {
012       lockAxisY = true;
013       PerformHit(hit.gameObject, hit);
014       lockAxisY = false;
015    } else if (hit.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
016       if (hit != null && hit.gameObject != null) {
017          PerformBounce(hit.gameObject, hit);
018       }
019    } else if (hit.gameObject.CompareTag(Utilities.TAG_BOOST_MARKER)) {
020       if (p.boostOn == true || p.aiIsPassing == false) {
021          PerformBoost(hit.gameObject, hit, 0);
022       }
023    } else if (hit.gameObject.CompareTag(Utilities.TAG_SMALL_BOOST_MARKER)) {
024       if (p.boostOn == true || p.aiIsPassing == false) {
025          PerformBoost(hit.gameObject, hit, 1);
026       }
027    } else if (hit.gameObject.CompareTag(Utilities.TAG_TINY_BOOST_MARKER)) {
028       if (p.boostOn == true || p.aiIsPassing == false) {
029          PerformBoost(hit.gameObject, hit, 2);
030       }
031    } else if (hit.gameObject.CompareTag(Utilities.TAG_MEDIUM_BOOST_MARKER)) {
032       if (p.boostOn == true || p.aiIsPassing == false) {
033          PerformBoost(hit.gameObject, hit, 3);
034       }
035    } else if (hit.gameObject.CompareTag(Utilities.TAG_TINY_BOOST_2_MARKER)) {
036       if (p.boostOn == true || p.aiIsPassing == false) {
037          PerformBoost(hit.gameObject, hit, 4);
038       }
039    } else if (hit.gameObject.CompareTag(Utilities.TAG_JUMP_MARKER)) {
040       if (p.isJumping == false) {
041          PerformJump(hit.gameObject, hit);
042       }
043    } else if (hit.gameObject.CompareTag(Utilities.TAG_HEALTH_MARKER)) {
044       if (audioPowerUp != null) {
045          if (audioPowerUp.isPlaying == false) {
046             audioPowerUp.Play();
047          }
048       }
049
050       if (p.damage - 1 >= 0) {
051          p.damage -= 1;
052       }
053
054       p.aiHasGainedLife = true;
055       p.aiHasGainedLifeTime = 0;
056       hit.gameObject.SetActive(false);
057       lastHealthMarker = hit.gameObject;
058       Invoke(nameof(RecreateHealthMarker), Random.Range(Utilities.MARKER_REFRESH_MIN, Utilities.MARKER_REFRESH_MAX));
059    } else if (hit.gameObject.CompareTag(Utilities.TAG_GUN_MARKER)) {
060       if (audioPowerUp != null) {
061          if (audioPowerUp.isPlaying == false) {
062             audioPowerUp.Play();
063          }
064       }
065
066       if (p.ammo <= Utilities.MAX_AMMO) {
067          p.ammo += Utilities.AMMO_INC;
068       }
069
070       p.gunOn = true;
071       p.ShowGun();
072       hit.gameObject.SetActive(false);
073       lastGunMarker = hit.gameObject;
074       Invoke(nameof(RecreateGunMarker), Random.Range(Utilities.MARKER_REFRESH_MIN, Utilities.MARKER_REFRESH_MAX));
075    } else if (hit.gameObject.CompareTag(Utilities.TAG_INVINC_MARKER)) {
076       if (audioPowerUp != null) {
077          if (audioPowerUp.isPlaying == false) {
078             audioPowerUp.Play();
079          }
080       }
081
082       p.invincOn = true;
083       p.invincTime = 0;
084       p.ShowInvinc();
085       hit.gameObject.SetActive(false);
086       lastInvcMarker = hit.gameObject;
087       Invoke(nameof(RecreateInvcMarker), Random.Range(Utilities.MARKER_REFRESH_MIN, Utilities.MARKER_REFRESH_MAX));
088    } else if (hit.gameObject.CompareTag(Utilities.TAG_ARMOR_MARKER)) {
089       if (audioPowerUp != null) {
090          if (audioPowerUp.isPlaying == false) {
091             audioPowerUp.Play();
092          }
093       }
094
095       p.armorOn = true;
096       hit.gameObject.SetActive(false);
097       lastArmorMarker = hit.gameObject;
098       Invoke(nameof(RecreateArmorMarker), Random.Range(Utilities.MARKER_REFRESH_MIN, Utilities.MARKER_REFRESH_MAX));
099    }
100 }

Listing 5-19CollideScript Main Method Details 3

方法是处理冲突和决定采取什么行动的中心点。处理的第一种碰撞是与未标记对象的碰撞。在这段代码中,什么都不做,方法返回。接下来是可打碰撞类型。在lockAxisY字段被设置为真之后,这个修饰符被应用于第 10 行。

可碰撞碰撞类型之后是一个类似的碰撞类型,HittableNoY,除了它不在应用的修改器向量中使用 Y 轴力。第 12 行在调用PerformHit方法之前将lockAxisY字段设置为真。随后,第 14 行上的lockAxisY被设置回假。在第 15–18 行,处理反弹修改器。这个修改器与其他修改器略有不同,它适用于被碰撞的玩家的车,而不是当前玩家的车。

从第 19 行到第 38 行的代码块用于处理增强标记冲突。可以处理五种不同类型的增强标记。我将在这里列出它们以及它们相关的速度。除了传递给PerformBoost方法的模式值之外,所有的条目都是相同的。请注意,如果修改器已经打开,或者汽车处于超车模式,则助推修改器不会触发。

  • 助推器马克:200

  • SmallBoostMarker: 50

  • TinyBoostMarker: 25

  • MediumBoostMarker: 100

  • TinyBoostMarker2: 15

最后一个物理修饰符从第 39 行开始。如果当前玩家的悬停赛车还没有跳跃,那么在第 41 行执行跳跃修改器。剩下的碰撞类型都是游戏战斗模式特有的。在战斗模式中,悬浮赛车可以使用自动射击系统互相射击。在战斗模式比赛中,赛道上有标记可以激活生命值、枪械、护甲和无敌属性。

健康标记是第 43 行的OnControllerColliderHit方法处理的第一个战斗模式标记。每个战斗模式标记被触发时都会发出声音。这可以在第 44–48 行的健康标记中看到。如果玩家有伤害需要治疗,这在 50-52 行处理。在行 54 和 55 上进行更多的玩家状态调整。接下来的三行代码很重要,因为它们会出现在每一个战斗模式标记中。

这段代码的作用是停用标记,存储对标记的引用,然后安排一个方法调用来重新激活标记。剩下的战斗模式标记(枪、无敌和盔甲)有相似的结构代码。通读剩余的方法代码,并确保在继续之前理解它。这就结束了对OnControllerColliderHit方法和CollideScript主方法评审部分的评审。在下一节中,我们将看一看本课程的演示场景。

演示:碰撞脚本

CollideScript类有一个有趣的演示场景,名为“DemoCollideScript”。您可以在“项目”面板的“场景”文件夹中找到该场景。在你打开它之前,让我稍微讲一下这个场景是如何工作的。几秒钟后,你就可以控制悬浮赛车了。演示场景中有许多不同的对象可以与之交互。从赛车的起始位置开始,在左侧,有许多可以尝试的加速标记。它们被一组红色柱子包围着。

紧挨着它,在右边,在两个紫色柱子之间有一组跳跃坡道。这些斜坡会触发跳跃修改器,让你检查它是如何工作的。在这些特征之后是一组绿色柱子。这些柱子实际上标志着一系列的路点和一个人工智能控制的对手,悬停赛车。这需要一点点努力,但是你可以把车排成一排,然后把它们撞在一起。这是汽车反弹修改器工作的一个例子。

在交互特征的中心线之外,有两叠油桶。你可以撞上它们,让它们飞起来。这是一个可点击修饰符的例子。最后但同样重要的是,有一系列的战斗模式标记排列在远处的墙上。如果你撞到这些,它们会消失一段时间。其中一个甚至可以启动汽车的枪。尝试一下。它们构成了我们在本课中讨论过的战斗模式标记修改器。

这节课的复习到此结束。我们要看的下一个类是第二个集中式交互类;我们将回顾并总结游戏中所有碰撞驱动的交互和游戏机制。随着我们完成越来越多的游戏功能,请花点时间回头看看游戏规约列表。

课堂回顾:CarSensorScript

我们要看的最后一个交互类是CarSensorScript。该脚本为汽车传感器供电,该传感器用于跟踪当前玩家的悬停赛车前方和附近的对手汽车。使用这种传感器设置,如果后面的汽车在足够长的时间内足够近地跟踪一辆汽车,它可以触发“超车”修改器。

CarSensorScript的另一个职责是运行模拟射击另一辆悬停赛车的自动枪。如果后面的车有弹药,并能让它前面的车保持在传感器内,直到目标跟踪完成,枪走火,就会发生这种情况。随机掷骰子决定命中,如果命中的车没有更多生命值,作为惩罚,它会返回几个点。我们将使用以下课堂回顾模板来介绍本课程:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

让我们来看看一些代码!

静态/常量/只读类成员:CarSensorScript

CarSensorScript类有许多我们需要查看的静态和只读类字段。

public static float BASE_BOOST = 200.0f;
public static float BASE_NON_BOOST = 25.0f;
public static string AUDIO_SOURCE_NAME_GUN_SHOT = "explosion_dirty_rnd_01";

public static string AUDIO_SOURCE_NAME_TARGETTING = "alien_crickets_lp_01";

public static readonly float TRIGGER_TIME_DRAFTING = 2.5f;

public static readonly float TRIGGER_TIME_PASSING = 2.5f;

Listing 5-20CarSensorScript Static/Constants/Read-Only Class Members 1

列出的前两个字段BASE_BOOSTBASE_NON_BOOST用于跟踪增强和非增强默认力。后续字段AUDIO_SOURCE_NAME_GUN_SHOTAUDIO_SOURCE_NAME_TARGETTING类字段用于加载AudioSource组件,并应反映所用音频资源的名称。集合中的最后两个字段TRIGGER_TIME_DRAFTINGTRIGGER_TIME_PASSING,用于控制通过游戏机制的时间和持续时间。

public static float TRIGGER_SPEED_PASSING = 0.90f;
public static readonly float SAFE_FOLLOW_DIST = 80.0f;

public static readonly float GUN_SHOT_DIST = 160.0f;
public static readonly float GUN_RELOAD_TIME = 500.0f;

public static readonly float MIN_TARGET_TO_FIRE_TIME = 100.0f;

public static readonly float MAX_EXPLOSION_TIME = 120.0f;

Listing 5-21CarSensorScript Static/Constants/Read-Only Class Members 2

列出的第一个字段TRIGGER_SPEED_PASSING,用于触发汽车超车游戏机械师。作为触发要求的一部分,你需要有至少 90%的最大速度。SAFE_FOLLOW_DIST字段是你可以跟随对手的车触发路过的机械师的最大距离。GUN_SHOT_DIST是仍然能够向你前面的汽车开枪的最大距离。

GUN_RELOAD_TIME字段是为下一次射击重新装弹所需的时间,以毫秒为单位。下一个字段表示将汽车锁定在传感器上并向其开火所需的最短时间。MAX_EXPLOSION_TIME代表运行枪击爆炸效果的最长时间。这就是本复习部分的结论。接下来,我们将看看该类的其余字段。

类字段:CarSensorScript

CarSensorScript类有几个类字段供我们查看。

//***** Class Fields *****
private AudioSource audioGunShot = null;
private AudioSource audioTargetting = null;
private ArrayList cars = null;
private GameObject player = null;

Listing 5-22CarSensorScript Class Fields 1

前两个字段是对连接到玩家汽车上用作音效的音频组件的引用,audioGunShotaudioTargetting。这个集合中列出的下一个字段是cars ArrayList实例。该数据结构用于跟踪当前在汽车传感器中的对手。player类字段用于保存对玩家的GameObject实例的引用。我们将回顾的下一组类字段是那些由SetBoostVectors方法使用的字段。让我们看看。

//***** Internal Variables: SetBoostVectors *****
private float absX = 0;
private float absZ = 0;
private Vector3 passLeftV3 = Vector3.zero;
private Vector3 passGoV3 = Vector3.zero;
private Vector3 passV3 = Vector3.zero;

Listing 5-23CarSensorScript Class Fields 2

集合中列出的前两个字段是通过SetBoostVectors方法在力计算中使用的absXabsZ字段。接下来的三个字段都是Vector3实例:passLeftV3passGoV3passV3。这些字段用于设置向量的力,这些向量用于在超车时使汽车绕过另一辆汽车。接下来我们将看看PerformShot方法使用的类字段。

//***** Internal Variables: PerformShot *****
private PlayerState p2 = null;
private int r2 = 0;

Listing 5-24CarSensorScript Class Fields 3

前面列出的字段用于查找“射击”玩家的状态信息,以潜在地应用射击命中修改器。p2字段存储对相关玩家状态对象的引用,而整数r2用于支持在确定命中时使用的随机骰子滚动。下一组,也是最后一组要检查的字段由 class' Update方法使用。

//***** Internal Variables: Update *****
private Collider obj = null;
private int i2 = 0;
private int l2 = 0;
private bool tb = false;
private Vector3 t1 = Vector3.zero;
private Vector3 t2 = Vector3.zero;
private float dist = 0.0f;
private float moveTime = 0.0f;
private bool explosionOn = false;
private float explosionTime = 0.0f;
private Collider target = null;

Listing 5-25CarSensorScript Class Fields 4

Collider字段obj用于引用Collider对象,当汽车传感器与另一个玩家的悬停赛车相撞时,该对象被记录下来。i2l2字段用于控制通过传感器采集的汽车列表的循环。tb字段是一个布尔标志,用于指示当前玩家的汽车应该开启自动超车技工。t1t2字段用于确定被跟踪车辆的距离。dist字段用于保存当前玩家的悬停赛车和被跟踪的汽车之间的计算距离。

接下来,moveTime字段用于控制如何随时间应用传递机制。接下来的三个字段与枪击机械师有关。这些字段跟踪在拍摄过程中是否需要运行任何效果。explosionOn字段表示应该显示枪击爆炸效果。explosionTime字段跟踪爆炸效果已经运行了多长时间。最后,target区域代表被射击的汽车。在下一个复习部分,我们将看一下课程的相关方法大纲。

相关的方法大纲/类头:CarSensorScript

通过BaseScript类的扩展,CarSensorScript是一个MonoBehaviour,它有许多主方法和支持方法供我们回顾。方法概述如下。

//Main Methods
void Start();
void OnTriggerEnter(Collider otherObj);
void OnTriggerExit(Collider otherObj);
void Update();

//Support Methods
public void SetBoostVectors();
public void PerformGunShotAttempt(Collider otherObj);
public void CancelTarget();

Listing 5-26CarSensorScript Pertinent Method Outline/Class Headers 1

下面列出了CarSensorScript类的导入语句和头文件。

using System.Collections;
using UnityEngine;

public class CarSensorScript : BaseScript {}

Listing 5-27CarSensorScript Pertinent Method Outline/Class Headers 2

接下来,我们将看看类的支持方法。

支持方法详细信息:CarSensorScript

CarSensorScript类有一些支持方法,用于支持汽车通过和射击游戏机制。我们先来看看SetBoostVectors方法。

01 public void SetBoostVectors() {
02    if (p == null) {
03       return;
04    } else if (BaseScript.IsActive(scriptName) == false) {
05       return;
06    }
07
08    absX = Mathf.Abs(p.cm.movement.velocity.x);
09    absZ = Mathf.Abs(p.cm.movement.velocity.z);
10    passLeftV3 = Vector3.zero;
11    passGoV3 = Vector3.zero;
12    passV3 = Vector3.zero;
13
14    if (absX > absZ) {
15       passGoV3.x = BASE_BOOST;
16       if (p.cm.movement.velocity.x < 0) {
17          passGoV3.x *= -1;
18       }
19
20       passLeftV3.z = BASE_NON_BOOST;
21       if (p.cm.movement.velocity.z < 0) {
22          passLeftV3.z *= -1;
23       }
24
25       passV3.z = passLeftV3.z;
26       passV3.x = passGoV3.x;
27    } else {
28       passGoV3.z = BASE_BOOST;
29       if (p.cm.movement.velocity.z < 0) {
30          passGoV3.z *= -1;
31       }
32
33       passLeftV3.x = BASE_NON_BOOST;
34       if (p.cm.movement.velocity.x < 0) {
35          passLeftV3.x *= -1;
36       }
37
38       passV3.x = passLeftV3.x;
39       passV3.z = passGoV3.z;
40    }
41 }

Listing 5-28CarSensorScript Support Method Details 1

SetBoostVectors方法用于设置一个Vector3实例的某些力分量,该实例用于使当前玩家的悬停赛车超过其前面的目标汽车。如果不满足某些先决条件,第 2–6 行的代码会阻止该方法执行任何工作。我们要看的下一小段代码准备了方法的局部变量。absXabsZ类字段被设置为当前玩家的速度。我们只关心 X 和 z 的水平轴。

随后,在传递游戏机制中使用的三个Vector3对象在第 10-12 行被重置,为当前计算的结果做准备。所涉及的速度是绝对值。我们这样做是为了简化汽车行驶方向的检测。如果 X 轴是第 14 行的主要部分,我们执行第 15–26 行的代码。因为 X 轴是主导轴,我们将推断它是悬停赛车移动的主要方向。

因此,我们将passGoV3场的 X 分量的速度设置为BASE_BOOST速度。在第 16–18 行,我们考虑给定速度的原始符号。向前的速度向量已经设定好了,但是我们需要一点侧向运动来帮助经过的车绕过它经过的车。在第 20 行,passLeftV3字段将其 Z 轴速度设置为BASE_NON_BOOST的值。同样,第 21–23 行考虑了原始速度的符号。

在第 25–26 行,我们计算出 X 和 Z 轴的速度,并将它们存储在passV3字段中。第 28–39 行的代码遵循与我们刚刚检查的代码相同的模式,除了我们在这里使用 Z 轴。我们将在Update方法回顾中看到这些向量的使用。下一个要审查的方法是PerformGunShotAttempt方法。

01 public void PerformGunShotAttempt(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi2, out int playerIndex2, out p2, otherObj.gameObject, gameState, false);
08       if (p2 != null) {
09          r2 = Random.Range(1, 6);
10
11          explosionOn = true;
12          explosionTime = 0f;
13          if (p2 != null && p2.gunExplosion != null) {
14             p2.gunExplosion.SetActive(true);
15          }
16
17          if (audioGunShot != null) {
18             if (audioGunShot.isPlaying == false) {
19                audioGunShot.Play();
20             }
21          }
22
23          if (r2 == 1 || r2 == 2 || r2 == 4 || r2 == 5) {
24             p2.isHit = true;
25             p2.isMiss = false;
26             p2.isMissTime = 0f;
27             p2.PerformGunShotHit();
28          } else {
29             p2.isHit = false;
30             p2.isMiss = true;
31             p2.isHitTime = 0f;
32          }
33
34          CancelTarget();
35       }
36    }
37 }

Listing 5-29CarSensorScript Support Method Details 2

PerformGunShotAttempt方法开始的方式与您预期的差不多,第 2–4 行的安全检查防止方法在类没有正确配置的情况下做任何工作。我们检查第 6 行的参数otherObj是否是一个玩家对象。在第 7 行,标准的实用方法调用加载了GameStatePlayerState引用,除了在这种情况下,我们将它应用于otherObj方法参数。在第 9 行,随机数被生成并存储在r2字段中。

因为开枪是为了执行射击,所以我们将explosionOn字段设置为真,将explosionTime字段设置为零。如果玩家和爆炸效果被定义,爆炸效果在第 14 行被激活。接下来,在第 17-21 行,播放一个声音效果来表示发生了枪击。随后,我们必须应用射击的结果,第 23–32 行。如果命中掷骰的结果是 1、2、4 或 5,则该击球是命中。很有可能。如果没有,射击是未命中的,并且对目标汽车没有任何影响。

在第 24–27 行,点击被记录在目标汽车上,相关玩家的PlayerState对象的PerformGunShotHit方法被调用。在第 29–31 行,处理未命中。最后但同样重要的是,调用CancelTarget方法来清除目标系统。

01 public void CancelTarget() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (audioTargetting != null) {
07       audioTargetting.Stop();
08    }
09
10    target = null;
11    if (p != null) {
12       p.aiHasTarget = false;
13       p.aiHasTargetTime = 0f;
14       p.aiCanFire = false;
15    }
16 }

Listing 5-30CarSensorScript Support Method Details 3

CancelTarget方法开始时很像我们期望的快速安全检查。任何音频都在第 7 行停止。target类字段被设置为空,当前玩家的目标字段在第 11–15 行被重置。这就结束了对类的支持方法的回顾。

主要方法细节:CarSensorScript

CarSensorScript的主要方法通常更适合 Unity 游戏引擎或碰撞检测事件使用的回调方法。让我们看看这个类的主要方法。

01 void Start() {
02    cars = new ArrayList();
03    base.PrepPlayerInfo(this.GetType().Name);
04    if (BaseScript.IsActive(scriptName) == false) {
05       Utilities.wrForce(scriptName + ": Is Deactivating...");
06       return;
07    } else {
08       player = p.player;
09       AudioSource[] audioSetDst = Utilities.LoadAudioResources(GetComponentsInParent<AudioSource>(), new string[] { AUDIO_SOURCE_NAME_GUN_SHOT, AUDIO_SOURCE_NAME_TARGETTING });
10       if (audioSetDst != null) {
11          audioGunShot = audioSetDst[0];
12          audioTargetting = audioSetDst[1];
13       }
14    }
15 }

Listing 5-31CarSensorScript Main Method Details 1

Start方法从初始化汽车ArrayList开始,汽车ArrayList用于保存由汽车传感器跟踪的悬停赛车的参考,第 2 行。在第 3 行,基类的更复杂的配置方法PrepPlayerInfo被调用来初始化GameStatePlayerState引用。如果类配置以某种方式失败,该方法在第 4–7 行打印一些调试文本后返回。如果类别配置成功,玩家类别字段被设置为当前玩家的游戏对象,第 8 行。

通过调用实用方法LoadAudioResources,在第 9 行设置找到的音频资源数组。该方法将一组AudioSource组件和一组目标字符串作为参数。如果有结果要处理,我们提取射击和瞄准游戏机制的音效,第 11-12 行。我们要看的下一个方法处理类的冲突事件。

01 void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (p != null && otherObj.CompareTag(Utilities.TAG_PLAYERS)) {
07       if (cars.Contains(otherObj) == false) {
08          cars.Add(otherObj);
09       }
10
11       if (cars.Count > 0) {
12          p.SetDraftingBonusOn();
13       }
14    }
15 }

Listing 5-32CarSensorScript Main Method Details 2

当玩家的汽车在赛道上比赛时,CarSensorScript接收来自不同物体的碰撞事件。如果类配置有错误,该方法会被转义而不做任何工作。如果被碰撞的游戏对象有一个设置为“玩家”的标签,那么执行第 7-13 行的代码。如果遇到的玩家还没有被目标系统注册,它将被添加到第 8 行的cars ArrayList中。第 11-13 行的最后一小段代码激活了一个小的制图奖励,如果当前玩家的追踪系统中有任何汽车的话。接下来,我们来看看OnTriggerExit法。

01 void OnTriggerExit(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (p != null && otherObj.CompareTag(Utilities.TAG_PLAYERS)) {
07       if (cars.Contains(otherObj) == true) {
08          cars.Remove(otherObj);
09       }
10
11       if (cars.Count == 0) {
12          p.SetDraftingBonusOff();
13       }
14
15       if (target == otherObj) {
16          CancelTarget();
17       }
18    }
19 }

Listing 5-33CarSensorScript Main Method Details 3

当对手的汽车退出当前玩家的汽车传感器时,OnTriggerExit方法触发。标准安全检查代码可在第 2–4 行找到。如果离开悬停赛车传感器的物体被标记为玩家的游戏物体,将执行第 7–17 行的代码。如果cars列表包含给定的游戏对象,第 7 行,它将从玩家的跟踪系统中删除。在第 11-13 行,如果没有被跟踪的汽车,绘图奖励被关闭。

看看第 15–17 行的代码。如果退出跟踪传感器的玩家的汽车是目标,则在第 16 行调用CancelTarget方法。我们最后要复习的主要方法是CarSensorScriptUpdate法。该方法由 Unity 游戏引擎在每个游戏帧调用一次。这个方法有点长,所以我将把它分成一些代码片段让我们看看。

01 void Update() {
02    if (p == null) {
03       return;
04    } else if (BaseScript.IsActive(scriptName) == false) {
05       return;
06    } else {
07       if (gameState != null) {
08          if (gameState.gamePaused == true) {
09             return;
10          } else if (gameState.gameRunning == false) {
11             return;
12          }
13       }
14    }
15

Listing 5-34CarSensorScript Main Method Details 4

第一段代码来自前面列出的Update方法的开头,它处理在不满足某些先决条件的情况下对方法调用的转义。这段代码与我们之前看到的代码块略有不同。在这种情况下,如果游戏暂停或没有运行,要小心防止方法做任何工作。

16    //Process Car Sensor Targets
17    if (p.aiPassingMode == 0 && p.aiIsPassing == false) {
18       l2 = cars.Count;
19       tb = false;
20       for (i2 = 0; i2 < l2; i2++) {
21          obj = (Collider)cars[i2];
22          t1 = obj.gameObject.transform.position;
23          t2 = player.transform.position;
24          dist = Vector3.Distance(t1, t2);
25
26          //Auto Passing Check
27          if (dist <= SAFE_FOLLOW_DIST && p.speed >= (TRIGGER_SPEED_PASSING * p.maxSpeed)) {
28             tb = true;
29             break;
30          }
31
32          if (gameState.gameSettingsSet == 2) {
33             //No Gun Play
34             continue;
35          }
36
37          //Targeting Check
38          if (dist <= GUN_SHOT_DIST && p.gunOn == true && p.ammo > 0 && p.aiHasTarget == false && p.aiIsReloading == false) {
39             target = obj;
40             p.aiHasTarget = true;
41             p.aiHasTargetTime = 0f;
42
43             if (audioTargetting != null) {
44                if (audioTargetting.isPlaying == false) {
45                   audioTargetting.Play();
46                }
47             }
48          } else if (dist <= GUN_SHOT_DIST && p.gunOn == true && p.ammo > 0 && p.aiHasTarget == true && p.aiCanFire == true && p.aiIsReloading == false) {
49             p.aiHasTarget = false;
50             p.aiHasTargetTime = 0f;
51             p.aiCanFire = false;
52             p.ammo--;
53
54             if (p.ammo <= 0) {
55                p.ammo = 0;
56                p.HideGun();
57             }
58
59             if (audioTargetting != null) {
60                audioTargetting.Stop();
61             }
62
63             PerformGunShotAttempt(obj);
64             p.aiIsReloading = true;
65             p.aiIsReloadingTime = 0f;
66          } else if (dist > GUN_SHOT_DIST) {
67             if (audioTargetting != null) {
68                audioTargetting.Stop();
69             }
70             target = null;
71             p.aiHasTarget = false;
72             p.aiHasTargetTime = 0f;
73             p.aiCanFire = false;
74          }
75       }  //end for loop
76

Listing 5-35CarSensorScript Main Method Details 5

在前面列出的下一段代码中,Update方法处理当前被汽车定位系统跟踪的悬停赛车。如果当前玩家的汽车不在超车模式,第 17 行,局部变量l2被目标系统当前跟踪的汽车数量更新,同时布尔标志tb被设置为假,第 18–19 行。第 20–21 行用于在被跟踪的车辆上循环。接下来,第 21–24 行设置目标对象,目标的位置,当前玩家的位置,最后在第 24 行设置目标和玩家之间的距离。在第 27 行检查自动通过游戏机制。

如果目标在安全跟随距离内,并且当前游戏者移动得足够快,则自动通过标志被触发,并且 for 循环从第 28 和 29 行中断。在第 32-35 行,有一个检查来确保当前的比赛支持战斗模式的游戏机制;如果没有,我们跳过目标玩家的条目,第 34 行。检查瞄准系统以查看当前是否有目标,行 38。目标和玩家状态信息在第 39–41 行更新。这个代码块通过设置目标来启动定位过程。

接下来,在第 48 行,如果目标悬停赛车仍然在射程内,并且当前玩家的车可以开火,那么就进行射击。第 49–52 行的一小块代码重置了玩家的汽车传感器脚本的瞄准和发射区域。玩家的弹药在 52 线减少。在第 54-57 行,我们检查当前玩家是否没有弹药了。如果是这样,我们要确保弹药设置为零,然后藏起枪。目标声音效果在第 59–61 行停止。在第 63 行执行注射,在第 64–65 行设置重新加载状态。

这段代码中的最后一段代码从第 66 行到第 74 行。这段代码用于关闭目标定位。目标声音效果被停止,第 68 行,并且第 70 行的target字段被设置为空。玩家状态目标字段在第 71–73 行被重置。我们要看的下一段代码处理 hover racer 的自动通过代码的开始。

77       //Auto Passing Start
78       if (tb == true) {
79          p.aiPassingTime += Time.deltaTime;
80          if (p.aiPassingTime > TRIGGER_TIME_PASSING && p.aiPassingMode == 0) {
81             p.aiPassingMode = 1;
82             p.aiIsPassing = true;
83             p.aiPassingTime = 0f;
84             moveTime = 0f;
85             p.SetBoost();
86             p.SetCurrentSpeed();
87             SetBoostVectors();
88          }
89       } else {
90          p.aiPassingMode = 0;
91          p.aiIsPassing = false;
92          p.aiPassingTime = 0f;
93          moveTime = 0f;
94          p.SetNorm();
95          p.SetCurrentSpeed();
96       }
97    } //main if statement
98

Listing 5-36CarSensorScript Main Method Details 6

如果先前在Update方法中设置的tb字段在第 78 行被设置为真,则启动自动传递,并且第 79 行的aiPassingTime字段递增。经过所需的时间后,传递模式从 0 变为 1,并执行第 81–87 行的代码。看一下代码,确保它对你有意义。如果tb标志为假,则执行第 90–95 行的代码,关闭任何通过模式标志并重置任何计时器。接下来,我们将看看自动传球游戏机制的应用。

099    //Auto Passing Applied
100    if (p.aiIsPassing == true) {
101       moveTime += Time.deltaTime * 100;
102       if (p.aiPassingMode == 1) {
103          p.controller.Move(passLeftV3 * Time.deltaTime);
104          if (moveTime >= 50) {
105             p.aiPassingMode = 2;
106             moveTime = 0;
107          }
108       } else if (p.aiPassingMode == 2) {
109          p.controller.Move(passGoV3 * Time.deltaTime);
110          if (moveTime >= 100) {
111             p.aiPassingMode = 0;
112             p.aiIsPassing = false;
113             p.aiPassingTime = 0f;
114             p.SetNorm();
115             moveTime = 0f;
116          }
117       }
118    }
119
120    //Auto Passing End
121    if (p.isJumping == true) {
122       p.aiPassingMode = 0;
123       p.aiIsPassing = false;
124       p.aiPassingTime = 0f;
125       p.SetNorm();
126       moveTime = 0f;
127    }
128

Listing 5-37CarSensorScript Main Method Details 7

CarSensorScriptUpdate方法中的下一段代码负责应用自动传球游戏机制。在第 100 行,如果aiIsPassing布尔标志被设置为真,我们开始更新第 101 行的moveTime字段。如果aiPassingMode字段等于 1,第 102 行,当前玩家的汽车向左移动 50 毫秒,然后aiPassingMode被设置为 2 并且moveTime字段被重置以跟踪自动超车游戏机制中的下一次移动的持续时间。

如果超车模式的值为 2,行 108,那么当前玩家的汽车在接下来的 100 毫秒内向前推进,行 109 和 110。在 100 毫秒的时间间隔到期后,通过模式、速度和移动时间都被重置,第 111-115 行。第 121–127 行的最后一位自动通过代码负责在当前玩家的悬停赛车跳跃时关闭自动通过模式。这就把我们带到了自动传递代码的末尾。接下来,我们将用一些与目标相关的代码来结束方法回顾。

129    //Targetting to Fire
130    if (p.aiHasTarget == true && p.aiIsReloading == false) {
131       p.aiHasTargetTime += Time.deltaTime * 100;
132       if (p.aiHasTargetTime >= MIN_TARGET_TO_FIRE_TIME) {
133          p.aiHasTargetTime = 0f;
134          p.aiCanFire = true;
135       }
136    } else if (p.aiIsReloading == true) {
137       p.aiIsReloadingTime += Time.deltaTime * 100;
138       if (p.aiIsReloadingTime >= GUN_RELOAD_TIME) {
139          p.aiIsReloading = false;
140          p.aiIsReloadingTime = 0f;
141       }
142    }
143
144    //Targetting Gun Explosion Effect
145    if (explosionOn == true) {
146       explosionTime += Time.deltaTime * 100;
147    }
148
149    if (explosionOn == true && explosionTime >= MAX_EXPLOSION_TIME) {
150       explosionOn = false;
151       explosionTime = 0f;
152       p.isHit = false;
153       p.isMiss = false;
154       if (p != null && p.gunExplosion != null) { // && p.gunExplosionParticleSystem != null)
155          p.gunExplosion.SetActive(false);
156          //p.gunExplosionParticleSystem.emit = false;
157       }
158    }
159 } //method end

Listing 5-38CarSensorScript Main Method Details 8

我们要查看的最后一段代码处理目标责任,如重新加载,检查当前汽车是否可以开火,以及显示枪击爆炸效果(如果粒子效果已经实现)。我把一些粒子效果的定制留给了你。如果跟踪系统有一个目标并且枪没有重新装弹,则执行第 130–136 行的代码。这段代码在第 134 行将玩家的aiCanFire字段设置为 true。第 136 到 142 行的代码处理枪的重新加载机制的时间。第 145–147 行的代码处理爆炸效果持续时间的跟踪。第 144–158 行的最后一个代码块处理持续时间到期后关闭爆炸效果。

在继续之前,请务必仔细阅读并理解这些代码。这就把我们带到了主方法回顾部分的末尾。接下来,我们将看看CarSensorScript的实际演示。

演示:CarSensorScript

CarSensorScript类有点复杂,所以实际上有两个演示场景供我们回顾。像往常一样,对汽车的控制需要几秒钟的时间。第一个演示场景命名为“DemoCarSensorScriptAutoPass”。你可以在“场景”文件夹的“项目”面板中找到它。在这个场景中,如果你慢慢靠近你前面的悬停赛车,自动通过功能将会激活。尝试一下,并确保在尝试时考虑代码。

在第二个演示场景“democarsensorscriptshooting”中,您必须使用棋盘上两个战斗模式修改器中的一个来武装您的悬停赛车。接下来,靠近对手的车,等一会儿,直到你听到瞄准的声音效果。接下来,听听枪声。如果你开火多次,对手的车将会跳跃,因为它会重新定位到先前的航路点进行重新射击。

img/520710_1_En_5_Fig1_HTML.png

图 5-1

汽车传感器脚本演示场景 1 描述汽车传感器脚本射击演示场景的屏幕截图

img/520710_1_En_5_Fig2_HTML.png

图 5-2

汽车传感器脚本演示场景 2 描述汽车传感器脚本自动超车演示场景的屏幕截图

前面显示的屏幕截图描述了用于演示该类的两个不同场景。这就是我们课程复习的结论。

第二章结论

这就引出了本章的结论。让我们来看看我们在这里讨论过的材料。这应该总结了第二章游戏规范中列出的所有游戏交互驱动的游戏机制。

  1. CollideScript:这个类是一个集中的交互点,支持一些不同的游戏机制:

    1. 未标记:忽略的游戏对象。

    2. 可击中的:碰撞时飞出的物体;想想“油桶”

    3. 助推:几个不同的助推标记,当碰撞时加速汽车。

    4. 跳跃:一个交互标记,当与玩家的车发生碰撞时,会导致玩家的车跳跃。

    5. 战斗模式标记:交互标记只在战斗模式下可用。这些标记控制战斗模式修改器,例如:

      1. 弹药

      2. 健康

      3. 装甲

      4. 无敌

  2. CarSensorScript:这个类是第二个集中的交互点,支持一些不同的游戏机制:

    1. 自动超车:以一定的速度和距离紧跟一辆车会触发自动超车游戏机制。

    2. 射击尝试:当你打开枪的时候,在足够长的时间内紧紧跟随一辆车,你的车会瞄准并试图射击你前面的车。

有了这些新的游戏机制,我们几乎有了一个完整的赛车游戏。我们需要的只是一个中央游戏状态、输入处理程序和一些助手类。请注意不同的脚本组件如何通过我们在使用中看到的集中式GameStatePlayerState类实例来控制当前玩家的汽车和 AI 控制的汽车。这种查找玩家状态数据的方法有助于悬停赛车手与他们的环境以及其他人进行交互。在下一章,我们将放慢一点速度,看看一些助手类。

六、助手类

在这一章中,我们将回顾支持悬停赛车游戏不同特性的各种辅助类。一般来说,辅助类是较小的类,用于辅助某些游戏相关的进程。总的来说,这一章应该没有前一章那么紧张,因为大多数课程都是简单直接的。在某些情况下,我会试着提前记下来,我们会放弃课堂复习模板,以换取更短、更直接的过程。我们将回顾的前两个类用于处理飞出轨道的汽车或物体。

课堂回顾:DestroyScript

DestroyScript类负责处理从棋盘上掉落的游戏对象。不要让它们无限下降,你应该设计你的关卡来处理出现在奇怪地方的物体。悬浮赛车的默认赛道旨在处理物体和悬浮赛车从赛道甚至棋盘上掉落的情况。让我们来看看一些代码!在这种情况下,我们将在一个复习步骤中列出整个班级。

1 public class DestroyScript : WaterResetScript {
2    public override void OnTriggerEnter(Collider otherObj) {
3       if (otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
4          base.OnTriggerEnter(otherObj);
5       } else {
6          Destroy(otherObj.gameObject);
7       }
8    }
9 }

Listing 6-1DestroyScript Class Review 1

注意,该类扩展了WaterResetScript类。这里定义并被覆盖的唯一方法是OnTriggerEnter方法。这个方法在与另一个单位GameObject碰撞时触发。如果碰撞的对象被标记为玩家对象,则调用基类方法。对于任何其他类型的游戏对象,该对象被销毁,第 6 行。正如你所看到的,这个类是为清理事物而设计的。接下来我们来看看WaterResetScript级。

课堂回顾:WaterResetScript

当悬停选手飞入水障碍时,WaterResetScript级负责将他们推回赛道。该课程比我们刚刚复习的DestroyScript稍长,因此我们将采用 stand 课程复习流程:

  1. 相关的方法大纲/类头

  2. 支持方法详细信息

  3. 主要方法详细信息

  4. 示范

WaterResetScript类扩展了BaseScript类,因此是一个MonoBehaviour。没有静态的类成员、枚举或类字段,所以我们将从相关的方法概述部分开始类回顾过程。

相关的方法大纲/类头:WaterResetScript

WaterResetScript类的相关方法概述如下。

//Main Methods
void Start();
public virtual void OnTriggerEnter(Collider otherObj);

//Support Methods
public void ProcessWaterReset(Collider otherObj);

Listing 6-2WaterResetScript Pertinent Method Outline/Class Headers 1

接下来,我将列出该类的导入语句和声明。

using UnityEngine;

public class WaterResetScript : BaseScript {}

Listing 6-3WaterResetScript Pertinent Method Outline/Class Headers 2

这就引出了方法和类声明大纲的结论。下一个要复习的部分是类的支持方法。

支持方法详细信息:WaterResetScript

WaterResetScript类有一个支持方法供我们回顾,如下所示。

01 public void ProcessWaterReset(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (otherObj.gameObject.CompareTag(Utilities.TAG_PLAYERS)) {
07       Utilities.LoadPlayerInfo(GetType().Name, out PlayerInfo pi, out int playerIndex, out p, otherObj.gameObject, gameState, false);
08       if (p != null) {
09          if (p.waypoints != null && p.waypoints.Count > 0) {
10             //move car to waypoint center
11             if (p.aiWaypointIndex - 5 >= 0) {
12                p.aiWaypointIndex -= 5;
13             } else if (p.aiWaypointIndex - 4 >= 0) {
14                p.aiWaypointIndex -= 4;
15             } else if (p.aiWaypointIndex - 3 >= 0) {
16                p.aiWaypointIndex -= 3;
17             } else if (p.aiWaypointIndex - 2 >= 0) {
18                p.aiWaypointIndex -= 2;
19             } else if (p.aiWaypointIndex - 1 >= 0) {
20                p.aiWaypointIndex -= 1;
21             } else {
22                p.aiWaypointIndex = 0;
23             }
24
25             if (p.aiWaypointIndex >= 0 && p.aiWaypointIndex < p.waypoints.Count) {
26                p.MoveToCurrentWaypoint();
27             }
28
29             p.offTrack = false;
30             p.offTrackTime = 0;
31          }
32       }
33    }
34 }

Listing 6-4WaterResetScript Support Method Details 1

在第 2–4 行,如果检测到配置问题,该方法将被转义。第 6 行检查碰撞对象是否被标记为“玩家”。如果是这样,碰撞对象的关联玩家的玩家状态被加载到第 7 行。关键结果存储在从BaseScript类继承的p类字段中。如果第 8 行定义了p,如果第 9 行定义了轨迹的航点数据,我们将查找之前的航点索引。在第 11–23 行,我们从当前航路点索引后面的五个索引开始寻找前一个航路点。

如果找到有效的索引,则更新PlayerState类的aiWaypointIndex字段的值。既然已经选择了一个航路点索引,我们再次检查它确实是有效的,第 25 行;然后我们将碰撞玩家的悬停赛车移动到第 26 行的航路点索引处。最后,玩家的离轨状态在第 29–30 行被重置。

主要方法详细信息:WaterResetScript

WaterResetScript类有两个主要的方法供我们回顾:StartOnTriggerEnter方法。我将它们枚举如下,然后我们快速回顾一下。

01 void Start() {
02    base.Prep(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07 }

01 public virtual void OnTriggerEnter(Collider otherObj) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05    ProcessWaterReset(otherObj);
06 }

Listing 6-5WaterResetScript Main Method Details 1

在 Unity 游戏引擎中,Start方法作为MonoBehaviour生命周期的一部分被调用。在这种情况下,该方法作为MonoBehaviour初始化的一部分被调用。调用Prep方法为当前玩家加载GameStatePlayerState信息。如果遇到任何问题,该类将按名称标记为非活动。对这个结果的快速测试在第 3 行执行,然后在第 4 行记录一些日志。这是我们以前见过的BaseScript class' Start方法的一个标准、简单的扩展实现。

前面列出的第二个主要方法是OnTriggerEnter回调方法。响应冲突事件时调用此方法。如果脚本处于非活动状态,该方法不做任何工作就返回,第 2–4 行。如果没有,那么在第 5 行调用ProcessWaterReset方法。这就是我们对主要方法回顾的结论。接下来,我们将看看课堂演示部分。

演示:WaterResetScript

因为DestroyScript扩展了WaterResetScript,所以我决定在这个回顾部分演示这两个脚本。DestroyScript在功能上与WaterResetScript非常相似,所以我们不会在这里明确演示。然而,我们将明确展示WaterResetScript的作用。

进入“项目”面板,找到“场景”文件夹。找到“DemoWaterResetScript”场景,但暂时不要打开它。我想先描述一下演示场景是如何工作的。几秒钟后,你就可以控制悬浮赛车了。在游戏窗口内点击,并确保它被激活输入。汽车前面会有两根绿色的柱子,开车穿过它们,瞄准紫色柱子之间的跳跃点。你将在空中飞行一会儿,直到你撞上隐形的WaterResetScript启动的飞机。

这个游戏对象在“层级”面板中被标记为“水重置”。该脚本将在碰撞时触发,并将玩家送回几个航路点。除了它会破坏任何与它碰撞并且没有“玩家”标签的物体之外,DestroyScript类的功能是一样的。在继续之前,先玩一会演示场景,确保您理解它是如何工作的。下一个要复习的课程是控制 hover racer 的引擎音效。

课程回顾:工程描述

EngineWhineScript类负责控制引擎音效的音频、音量、音高。脚本组件调整AudioSource组件的回放属性,以更快的速度创建更大、更高音调的引擎声音。花点时间在我们复习过的所有其他课程的背景下思考这个课程。有什么突出的吗?

您可能已经注意到的一件事是,脚本组件在某种程度上是分隔开的。它们在Update方法调用或其他碰撞事件回调方法期间对自己进行配置并对游戏对象进行调整。让我们从以下复习步骤开始课堂复习:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

事不宜迟,我们来看看一些代码吧!

静态/常量/只读类成员:EngineWhineScript

EngineWhineScript类有一个静态字段供我们查看,随后列出。

public static string AUDIO_SOURCE_NAME_WHINE = "car_idle_lp_01";

Listing 6-6EngineWhineScript Static/Constants/Read-Only Class Members 1

列出的字段具有将用于引擎音效的AudioSource组件的名称。接下来,我们将看看其余的类字段。

类字段:EngineWhineScript

EngineWhineScript类有一个由类的‘Update方法内部使用的字段。

//***** Internal Variables: Update *****
private float pTmp = 0.0f;

Listing 6-7EngineWhineScript Class Fields 1

该类使用pTmp字段来保存玩家速度百分比的副本。该值用于设置由audioS基类字段引用的AudioSource组件的pitchvolume

相关的方法大纲/类头:EngineWhineScript

EngineWhineScript类的相关方法概述如下。

//Main Methods
void Start();
void Update();

Listing 6-8EngineWhineScript Pertinent Method Outline/Class Headers 1

这里列出了类导入语句和声明。

using UnityEngine;

public class EngineWhineScript : BaseScript { }

Listing 6-9EngineWhineScript Pertinent Method Outline/Class Headers 2

在接下来的复习部分,我们将看看这个类的主要方法。

主要方法详细信息:EngineWhineScript

EngineWhineScript类有两个主要的方法让我们复习。这两种方法应该看起来很熟悉。让我们来看看。

01 void Start() {
02    base.PrepPlayerInfo(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07
08    audioS = Utilities.LoadAudioResources(GetComponents<AudioSource>(), new string[] { AUDIO_SOURCE_NAME_WHINE })[0];
09    if (audioS != null) {
10       audioS.volume = 0.2f;
11       audioS.pitch = 0.2f;
12    }
13 }

Listing 6-10EngineWhineScript Main Method Details 1

这个Start方法比默认的标准方法稍微复杂一些,默认的标准方法只通过调用基类的Prep方法来加载游戏状态信息。这个方法调用更复杂的PrepPlayerInfo基类方法。这个方法也将载入玩家状态信息和游戏状态。它还使用LoadAudioResources实用程序方法来查找并加载对名称与AUDIO_SOURCE_NAME_WHINE静态类字段匹配的AudioSource组件的引用。如果在第 9 行找到了音频资源,那么音效的音量和音高将被调整为默认值 0.2。

注意,每个扩展了BaseScript类并使用了两种类准备方法之一PrepPrepPlayerInfo的类都调用了GameState类的PrepGame方法。这种冗余的原因是我们无法保证哪个GameObjectStart方法会被首先调用。为了安全起见,游戏中的每个脚本组件都会检查以确保游戏已经作为类配置的一部分准备好了。接下来我们来看看Update方法。

01 void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (p != null) {
07       pTmp = p.speedPrct;
08       if (audioS != null) {
09          audioS.pitch = Mathf.Clamp(pTmp * 4.1f, 0.5f, 4.1f); //p is clamped to sane values
10          audioS.volume = Mathf.Clamp(pTmp * 0.6f, 0.2f, 0.6f);
11       }
12    }
13 }

Listing 6-11EngineWhineScript Main Method Details 2

第 2–4 行中的Update方法被转义,就像我们一次又一次看到的一样。在第 6 行,我们检查是否定义了PlayerState实例p。如果是这样的话,那么我们用第 7 行中的 hover racer 的当前速度百分比更新pTmp类字段。因为我们明确地调整了 hover racer 的引擎声音,我们只能在音频资源audioS已经被正确定义的情况下继续,第 8 行。如果字段不为空,那么我们调整第 9 行和第 10 行的发动机呜呜声效果的pitchvolume。注意,这些值是基于pTmp字段的。换句话说,音高和音量是基于悬停赛车的当前速度。这就把我们带到了主方法回顾部分的末尾。在下一节中,我们将看看本课程的演示场景。

演示:引擎描述

这个类的演示场景可以在“项目”面板的“场景”文件夹中找到。找到名为“DemoEngineWhineScript”的场景。这个演示简单而直接。绕着棋盘开,尽可能快地跑。请注意屏幕左下角显示的音高和音量值。请记住,这些值是基于悬停赛车的当前速度。这就是我们对EngineWhineScript班复习的总结。我们要看的下一个类是LapTime类。

课堂回顾:LapTime

LapTime类不是基于MonoBehaviour的类;只是一个飞机老 C# 类。该类负责包装赛道时间信息,并提供了将该类的单圈时间与字符串表示形式进行序列化和反序列化的方法。这节课有些长,但我还是觉得可以一口气复习完。接下来我们将直接复习该课程。

01 public class LapTime {
02    public string time = "";
03    public int timeNum = 0;
04    public int track = 0;
05    public int type = 0; //0=easy, 1=battle, 2=classic
06    public int diff = 0; //0=low, 1=med, 2=high
07    public int lap = 0;
08
09    public string Serialize() {
10       string t = time;
11       string tN = timeNum + "";
12       string tr = track + "";
13       string ty = type + "";
14       string df = diff + "";
15       string l = lap + "";
16       string col = "~";
17       return (t + col + tN + col + tr + col + ty + col + df + col + l);
18    }
19
20    public override string ToString() {
21       string ret = "";
22       ret += "Time: " + time + "\n";
23       ret += "Time Number: " + timeNum + "\n";
24       ret += "Track: " + track + "\n";
25       ret += "Type: " + type + "\n";
26       ret += "Difficulty: " + diff + "\n";
27       ret += "Lap: " + lap + "\n";
28       return ret;
29    }
30
31    public bool Deserialize(string s) {
32       if (s != null && s != "") {
33          char[] c = "~".ToCharArray();
34          string[] cs = s.Split(c);
35          if (cs.Length == 6) {
36             time = cs[0] + "";
37             timeNum = int.Parse(cs[1]);
38             track = int.Parse(cs[2]);
39             type = int.Parse(cs[3]);
40             diff = int.Parse(cs[4]);
41             lap = int.Parse(cs[5]);
42             return true;
43          } else {
44             return false;
45          }
46       } else {
47          return false;
48       }
49    }
50 }

Listing 6-12LapTime Class Review 1

第 1 行显示了LapTime类声明。类别字段列在第 2–7 行。第一个字段time,保存当前圈速的字符串表示。timeNum字段将圈速表示为一个大整数。track字段是一个整数值,表示正在哪个赛道上比赛。type字段是一个表示比赛类型的整数值:简单赛、战斗赛或经典赛。接下来,diff字段用于跟踪比赛的难度:低、中、高。最后,我们有lap类字段。这表示与当前分段时间相关的分段数。

LapTime类有三个方法让我们复习。前两个方法,SerializeToString,用于将类表示为字符串。Serialize方法将类字段的所有字符串表示转换成一个长字符串,其条目由col变量分隔,第 17 行。ToString方法类似于Serialize方法。

ToString方法中,类字段被给定标签并由换行符分隔,第 21–27 行。结果字符串在第 28 行返回。我们最后要复习的方法是Deserialize法。此方法反转序列化过程,并根据解码的数据更新类字段的值。传入的字符串参数在第 34 行被拆分。如果找到了期望的长度,数据被转换并赋值,第 36–41 行。方法返回一个布尔值,指示反序列化过程成功。我们将跳过这个类的演示部分,因为我们将在接下来复习LapTimeManager类时看到它的使用。

课堂回顾:LapTimeManager

LapTimeManager类负责管理一组记录的圈速。与LapTime级类似,LapTimeManager级不是MonoBehaviour;只是一个飞机老 C# 类。LapTimeManager类足够复杂,使用我们以前用过的更结构化的类评审过程会更好。

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

关于这个类没有什么特别要讨论的,所以事不宜迟,让我们看看一些代码吧!

静态/常量/只读类成员:LapTimeManager

LapTimeManager类有两个静态成员供我们查看。

public static int CLEANING_START_INDEX = 33;
public static int LAST_NOT_CLEANED_INDEX = 32;

Listing 6-13LapTimeManager Static/Constants/Read-Only Class Members 1

列出的第一个字段用于标记索引,该索引将在尝试从分段时间管理器中清除条目时使用。列出的第二个字段是类的清理过程不会清理的最后一个索引。这意味着,随着保存的分段时间的增加和清理代码的执行,只有前 32 个条目将被保留。条目 33 及以上都要进行清理。注意,LapTimeManagerLapTime类没有扩展BaseScript类。你能想出原因吗?

主要原因是这些类不是 Unity MonoBehaviours,脚本组件。它们是标准的 C# 类。因此,类是通过调用类构造函数和其他类配置方法来初始化的。然而,MonoBehaviour s 是 Unity 游戏引擎的组件,由组件生命周期回调方法初始化,如AwakeStart。这些初始化方法作用于真实的游戏环境,并且必须能够在运行时处理缺失的配置资源。这就是为什么我们支持禁用扩展了MonoBehaviour类功能的类,方法是将该类注册为非活动的。让我们转到班上的其他领域。

类字段:LapTimeManager

LapTimeManager类有一些公共字段和一些私有字段,用作局部方法变量。

//***** Class Fields *****
public List<LapTime> lapTimes = new List<LapTime>();
public LapTime lastEntry = null;
public LapTime bestEntry = null;

//***** Internal Variables: FindBestLapTime *****
private LapTime retFbt = null;
private int lFbt = 0;
private int iFbt = 0;

//***** Internal Variables: CleanTimes *****
private LapTime ltFbt = null;
private int lCt = 0;
private int iCt = 0;

Listing 6-14LapTimeManager Class Fields 1

集合中的第一个类字段是lapTimers列表。该列表负责存储个人单圈时间记录。以下两个字段用于参考重要的单圈时间。lastEntry字段保存上次记录的单圈时间,而bestEntry字段保存最佳记录的单圈时间。下一组类字段由FindBestLapTimeCleanTimes方法使用。retFbt字段是一个用于保存返回值的LapTime实例。

lFbtiFbt字段是循环控制变量,用于循环记录的圈速列表。ltFbt字段是另一个LapTime实例,用于在遍历一个列表时保存当前的圈速。最后,lCtiCt字段用于在清除旧条目时控制记录圈速列表的循环。接下来的两组字段由SerializeDeserialize方法使用。

//***** Internal Variables: Serialize/ToString *****
private string retSer = "";
private int lSer = 0;
private int iSer = 0;
private string retTs = "";
private int lTs = 0;
private int iTs = 0;

//***** Internal Variables: Deserialize *****
private char[] cDes = null;
private string[] csDes = null;
private int lDes = 0;
private int iDes = 0;
private string tmpDes = "";
private LapTime ltDes = null;

Listing 6-15LapTimeManager Class Fields 2

前面列出的第一个字段retSer是一个字符串,用于构建记录的圈速的序列化列表。在序列化过程中,lSeriSet字段被用作循环控制变量。类似地,retTs字段用于构建类的 to-string 表示,lTsiTs字段在 to-string 操作中用作循环控制变量。下一组字段由Deserialize方法使用。

cDes字段用于表示分割字符,该字符用于分割字符串形式的圈速列表。随后,csDes字段用于保存所述分割的结果。lDesiDes字段是在反序列化过程中使用的循环控制变量。tmpDes字段用于在集合循环时保存反序列化的字符串。最后,ltDes字段是一个LapTime实例,用于将字符串反序列化为存储在lapTimes列表中的LapTime对象。在下一节中,我们将看看这个类的相关方法大纲。

相关的方法大纲/类头:LapTimeManager

LapTimeManager类的相关方法概述如下。

//Main Methods
public string Serialize();
public bool Deserialize(string s);
public override string ToString();
public void CleanTimes();

//Support Methods
public void AddEntry(LapTime lt);
public LapTime FindBestLapTimeByLastEntry();
public LapTime FindBestLapTime(int track, int type, int diff, int timeNum);
public string ToStringShort();

Listing 6-16LapTimeManager Pertinent Method Outline/Class Headers 1

接下来,我将列出该类的导入语句和声明。注意这个类没有扩展 Unity 的MonoBehaviour类。

using System.Collections.Generic;

public class LapTimeManager {}

Listing 6-17LapTimeManager Pertinent Method Outline/Class Headers 2

在下一个复习部分,我们将看看类的支持方法。

支持方法详细信息:LapTimeManager

LapTimeManager类有一些支持方法供我们回顾。让我们来看看。

01 public void AddEntry(LapTime lt) {
02    lapTimes.Add(lt);
03    lastEntry = lt;
04 }

01 public LapTime FindBestLapTimeByLastEntry() {
02    return FindBestLapTime(lastEntry.track, lastEntry.type, lastEntry.diff, lastEntry.timeNum);
03 }

01 public LapTime FindBestLapTime(int track, int type, int diff, int timeNum) {
02    retFbt = null;
03    ltFbt = null;
04    if (lapTimes != null) {
05       lFbt = lapTimes.Count;
06       for (iFbt = 0; iFbt < lFbt; iFbt++) {
07          ltFbt = (LapTime)lapTimes[iFbt];
08          if (ltFbt != null) {
09             if (ltFbt.track == track && ltFbt.type == type && ltFbt.diff == diff) {
10                if (ltFbt.timeNum < timeNum) {
11                   retFbt = ltFbt;
12                }
13             }
14          }
15       }
16    }
17    bestEntry = retFbt;
18    return retFbt;
19 }

01 public string ToStringShort() {
02    return "Lap Times: " + lapTimes.Count;
03 }

Listing 6-18LapTimeManager Support Method Details 1

列出的第一种方法AddEntry用于将新的单圈时间记录添加到当前单圈时间列表的第 2 行。注意,当调用这个方法时,lastEntry字段被更新,第 3 行。我们要回顾的下一个方法是一个方便的方法,FindBestLapTimeByLastEntry,它用关于最后一次单圈时间的信息调用类'FindBestLapTime方法,第 2 行。这就把我们带到了FindBestLapTime方法。

FindBestLapTime方法用于搜索已知圈速列表,以找到匹配比赛设置(赛道、类型和难度参数)且持续时间最短的条目。在第 2–3 行,方法变量retFbtltFbt被重置为空。在第 4 行,如果lapTimes字段不为空,那么我们遍历条目,搜索持续时间最短的匹配项,即第 5–6 行。lapTimes列表中的每个条目被分配给第 7 行的ltFbt局部变量。如果分段时间条目已定义,与搜索标准匹配,并且持续时间较短,则分段时间被设置为该方法返回的数据,第 8–11 行。

集合中的最后一个方法是ToStringShort方法。这个方法非常简单,它返回一个字符串,指示由该类管理的圈数。如您所见,这个类在处理单圈时间数据时非常有用。接下来,我们将看看LapTimeManager类的主要方法。

主要方法细节:LapTimeManager

LapTimeManager类有几个主要的方法用于管理存储在类中的圈速记录。我在这里一组列出主要的方法。

01 public string Serialize() {
02    retSer = "";
03    if (lapTimes != null) {
04       lSer = lapTimes.Count;
05       for (iSer = 0; iSer < lSer; iSer++) {
06          if (lapTimes[iSer] != null) {
07             retSer += ((LapTime)lapTimes[iSer]).Serialize();
08             if (iSer < lSer - 1) {
09                retSer += "^";
10             }
11          }
12       }
13    }
14    return retSer;
15 }

01 public bool Deserialize(string s) {
02    lapTimes = new List<LapTime>();
03    if (s != null && s != "") {
04       cDes = "^".ToCharArray();
05       csDes = s.Split(cDes);
06       if (csDes != null && csDes.Length > 0) {
07          lDes = csDes.Length;
08          for (iDes = 0; iDes < lDes; iDes++) {
09             tmpDes = csDes[iDes];
10             if (tmpDes != null) {
11                ltDes = new LapTime();
12                ltDes.Deserialize(tmpDes);
13                lapTimes.Add(ltDes);
14             }
15          }
16          return true;
17       } else {
18          return false;
19       }
20    } else {
21       return false;
22    }
23 }

01 public override string ToString() {
02    retTs = "";
03    if (lapTimes != null) {
04       lTs = lapTimes.Count;
05       for (iTs = 0; iTs < lTs; iTs++) {
06          if (lapTimes[iTs] != null) {
07             retTs += "Lap Time Entry: " + (iTs + 1) + "\n";
08             retTs += ((LapTime)lapTimes[iTs]).ToString() + "\n";
09          }
10       }
11    }
12    return retTs;
13 }

01 public void CleanTimes() {
02    if (lapTimes != null && lapTimes.Count > 1) {
03       if (lapTimes.Count > LAST_NOT_CLEANED_INDEX) {
04          lCt = lapTimes.Count;
05          for (iCt = CLEANING_START_INDEX; iCt < lCt; iCt++) {
06             lapTimes.Remove(lapTimes[iCt]);
07             lCt--;
08          }
09       }
10    }
11 }

Listing 6-19LapTimeManager Main Method Details 1

该组中的第一个方法是Serialize方法。该方法用于将LapTimeManager类转换为字符串表示,这样它就可以存储在游戏的玩家首选项中。这将允许它在游戏的多次使用中保持不变。为什么要经历这些序列化的麻烦呢?为什么不直接存储数据呢?事实证明,依靠序列化更容易也更强大,因为它简化了游戏、记录的圈数和 Unity 的PlayerPrefs简单数据持久化类之间的交互。

如果你回想一下我们对LapTime类的回顾,你会记得它也支持字符串形式的相互转换。这意味着你可以将一个字符串转换成一个 C# 对象实例,反之亦然。这里的强大之处在于,您可以通过编程方式与类进行交互,并且只需一次方法调用,就可以将类转换为字符串形式。了解这一点后,让我们看看圈速管理器如何利用这一功能。

用于返回值的变量在第 2 行被重置。如果定义了lapTimes字段,我们准备循环控制变量并循环存储的圈速,第 3-5 行。如果第 6 行定义了lapTimes条目,我们将序列化该类并将其添加到第 7–10 行的retSer变量中。如果我们有更多的条目要处理,我们会在它们之间添加一个特殊的字符,'^'。结果是存储在LapTimeManager类中的分段时间的序列化。

Deserialize方法中,我们反转这个过程,将一个字符串,一个圈数字符串的序列化列表,转换成一个LapTime对象实例的列表。在该方法的第 2 行重置了lapTimes类字段。如果方法参数 s 已定义并有数据,我们准备方法变量,第 4-5 行。如果在特殊字符上分割的字符串返回结果,我们准备循环控制变量并继续在分割的字符串上循环。tmpDes变量用于保存第 9 行的每个字符串片段。

在第 10–12 行,如果定义了条目,我们将它用作新的lapTime实例的Deserialize方法的参数。新配置的LapTime实例被添加到第 13 行的lapTimes类字段中。该方法的其余部分返回一个布尔值,表明操作成功。当这个方法完成时,我们将把这个类恢复到它的Serialize方法被调用时的状态。

ToString方法用于为日志记录创建类的字符串表示。方法返回变量retTs在第 2 行被重置,如果lapTimes字段被定义,我们在第 3–5 行遍历列表。每个条目都被处理并附加到第 6–9 行的返回变量中。生成的字符串在第 12 行返回。接下来,我们将看看我们如何修剪记录的圈速。

CleanTimes方法用于修剪由 manager 类存储的圈数。如果有分段时间要处理,并且条目的数量超过了指定的数量,我们准备循环处理超出的条目,第 3–5 行。对于发现的每个超额条目,我们删除第 6–7 行的值并减少循环控制变量。这就结束了我们对LapTimeManager类的回顾。接下来,我们将检查这个类实际上是如何使用的。

演示:LapTimeManager

为了演示LapTimeManager类,以及随后的LapTime类,我们将看看一些类的功能在实际游戏中是如何使用的。首先,我们将看到如何使用管理器来保存单圈时间数据。如果你加载了GameState类并搜索了LogLapTime方法,看看这个方法的最后几行代码。

1 lapTimeManager.AddEntry(lt);
2 lapTimeManager.CleanTimes();
3 lapTimeManager.FindBestLapTimeByLastEntry();
4 PlayerPrefs.SetString("LapTimes", lapTimeManager.Serialize());

Listing 6-20LapTimeManager Demonstration 1

这有点乏味,但那是因为我们的类封装得很好,并且在设计上很专注。在前面列出的例子中,一个新的圈时间条目被添加到列表中。然后,管理器清理这个列表,以确保它的长度有上限。最后,单圈时间的序列化列表存储在 Unity APIPlayerPrefs类的第 4 行。接下来,我们将看看如何使用反序列化。

1 string tmpStr = PlayerPrefs.GetString("LapTimes", "");
2 lapTimeManager = new LapTimeManager();
3 if (tmpStr != null && tmpStr != "") {
4    Utilities.wr("Found lap times: " + tmpStr);
5    lapTimeManager.Deserialize(tmpStr);
6 }

Listing 6-21LapTimeManager Demonstration 2

序列化过程的逆过程也一样简单。我们加载最后存储的圈速字符串;如果不存在,则返回一个空字符串,第 1 行。然后我们初始化lapTimeManager类字段,并检查是否有数据要处理,第 2–3 行。我们找到的数据被记录下来,并且通过调用Deserialize方法来恢复lapTimeManager类。我们的LapTimeManager课复习到此结束。下一个要复习的课是PopupMsgTracker课。

课堂回顾:PopupMsgTracker

PopupMsgTracker类用于跟踪 HUD 屏幕上显示的弹出通知,以响应某些跟踪事件。因为多个通知可能会快速连续地触发,所以我们最终可能会显示多个通知图像。为了帮助我们管理这些信息,我们引入了PopupMsgTracker类。让我们来看看。因为这个课程非常简洁,我们将放弃通常的课程复习过程,只列出下一个课程。

1 public class PopupMsgTracker {
2    public int index = 0;
3    public Image image = null;
4    public int posIdx = 0;
5    public bool movingUp = false;
6    public int type = 0;
7 }

Listing 6-22PopupMsgTracker Class Review 1

这个类非常简单;它只是一组类字段。让我们快速复习一下。第一个字段是一个整数值index,它用于跟踪图像通知在通知垂直列表中的位置。下一个类字段是 Unity Image类的一个实例。该字段引用屏幕上显示的图像,即实际通知。下一个字段posIdx是一个整数值,表示通知的位置索引,或者在可用显示槽的预设列表中的显示槽的索引。

下一个类字段是布尔标志,指示该通知在可用显示槽列表中向上移动,movingUp。最后,type字段是一个整数值,表示该类正在跟踪的通知类型:警告、修饰符、帮助消息等。这就是这门课的全部内容。注意,它不是基于MonoBehaviour类;只是一个普通的老 C# 类。我们要看的下一个类是集中式实用程序类。但是在我们开始之前,让我们看一个PopupMsgTracker类的演示。

演示:PopupMsgTracker

为了演示PopupMsgTracker类,我们将看看GameHUDNewScript类是如何使用它的。这个脚本负责在游戏中的 HUD 上显示通知等。让我们看看如何使用消息跟踪器类。

1 public void UndimYourHit() {
2    if (imgYourHit.gameObject.activeSelf == false) {
3       imgYourHit.gameObject.SetActive(true);
4       imgYourHit.gameObject.transform.position = posPopupMsg[posPopupMsgIdx];
5       AddPopupMsgTracker(imgYourHit, posPopupMsgIdx, 0);
6       CheckPopupMsgIdx();
7    }
8 }

1 public void AddPopupMsgTracker(Image i, int idx, int tp) {
2    pt = new PopupMsgTracker();
3    pt.index = posPopupMsgVis.Count;
4    pt.posIdx = idx;
5    pt.image = i;
6    pt.type = tp;
7    posPopupMsgVis.Add(pt);
8    posPopupMsgIdx++;
9 }

Listing 6-23PopupMsgTracker Demonstration 1

我们要看的第一个方法是UndimYourHit方法。这种方法被 HUD 用来在战斗模式比赛中显示“你被击中了”的通知。如果第 2 行的通知图像不可见,请在第 3 行激活它。通知图像的位置基于由第 4 行的posPopupMsgIdx字段跟踪的可用显示槽来设置。请注意,索引用于在显示位置数组中查找设置的显示位置。

接下来,在第 5 行,通过调用AddPopupMsgTracker方法来跟踪弹出通知。最后一行代码检查下一个posPopupMsgIdx值是否有效,如果无效,则将索引值循环回零。这将重置下一个通知的显示位置。列出的下一个演示方法是AddPopupMsgTracker方法。请注意,索引是通知在可见通知列表中的位置,第 3 行。

posIdx字段被设置为参数idx,它是posPopupMsgIdx的值。这表示通知的实际呈现位置,第 4 行。弹出消息被添加到第 7 行的可视通知列表中,并且popupMsgIdx随后在第 8 行递增。这就是我们对PopupMsgTracker课的总结。接下来,我们将注意力集中在Utilities类上。

课程回顾:实用工具

Utilities类也不是MonoBehaviour。它只是一个普通的 C# 类,碰巧处理一些重要的职责。Utilities 类用作中央初始化、日志记录和设置类。该类被设计为一个静态的快速访问类。因此,它只有静态类成员。我们将稍微调整一下我们的复习过程,以考虑到这个类的静态性质。我们将采用以下审查步骤:

  1. 静态类字段

  2. 静态类方法

  3. 示范

我们将从查看类的静态字段开始。让我们看看我们有什么。

静态类成员:实用程序

有相当多的静态类字段需要查看,所以我们将分组查看它们。

public static bool LOGGING_ON = true;
public static string SOUND_FX_JUMP = "buzzy_jump_01";
public static string SOUND_FX_BOUNCE = "cute_bounce_01";

public static string SOUND_FX_BOOST = "rocket_lift_off_rnd_06";

public static string SOUND_FX_POWER_UP = "powerup_01";

public static string TAG_TRACK_HELP_SLOW = "TrackHelpSlow";

public static string TAG_TRACK_HELP_TURN = "TrackHelpTurn";

public static string TAG_PLAYERS = "Players";

Listing 6-24Utilities Static Class Fields 1

前面列出了我们要查看的第一组静态字段。第一个条目LOGGING_ON,控制类的日志功能。下一个字段SOUND_FX_JUMP,是游戏中使用的目标跳跃音效的名称。SOUND_FX_BOUNCE是用作反弹音效的目标反弹AudioSource的名称。接下来的两个条目是目标AudioSource的名字,分别用作游戏加速和加电机制的音效。

下一组三个字段以TAG_开始,用于检查分配给游戏对象的标签。标签用于对游戏对象进行分类。它们是一种区分游戏对象的低级方法,通常用于碰撞处理。如果你回想一下到目前为止我们已经检查过的一些代码,我们已经检查了很多标签。前两个标签条目用于检测帮助系统标记的对象。最后一个条目TAG_PLAYERS,是一个非常重要的字段。它用于确定一个游戏对象是否是一个悬浮赛车。

public static string TAG_UNTAGGED = "Untagged";
public static string TAG_HITTABLE = "Hittable";
public static string TAG_HITTABLE_NOY = "HittableNoY";

public static string TAG_BOOST_MARKER = "BoostMarker";

public static string TAG_SMALL_BOOST_MARKER = "SmallBoostMarker";

public static string TAG_TINY_BOOST_MARKER = "TinyBoostMarker";

public static string TAG_MEDIUM_BOOST_MARKER = "MediumBoostMarker";

public static string TAG_TINY_BOOST_2_MARKER = "TinyBoostMarker2";

Listing 6-25Utilities Static Class Fields 2

该集合中列出的第一个字段TAG_UNTAGGED用于标识标记为未标记的游戏对象。有默认的系统标签,无标签是其中之一,然后还有用户自定义标签。默认情况下,Unity 游戏对象被标记为“未标记”。当我们回顾这些标签时,我会提到与它们相关的游戏机制。接下来的两个条目是用于可点击对象的标签。这种标签和游戏机制最明显的例子是油桶,当被碰撞时会飞起来。

接下来的几个条目都用于定义不同的增强标记。如果你还记得我们对游戏交互类的评论。助推标记以不同的速度加速玩家的车辆前进。使用这个系统,您需要做的全部工作就是更改一个增强标记,将其标签更改为不同的增强标记标签类型。

public static string TAG_JUMP_MARKER = "JumpMarker";
public static string TAG_HEALTH_MARKER = "HealthMarker";

public static string TAG_GUN_MARKER = "GunMarker";
public static string TAG_INVINC_MARKER = "InvincibilityMarker";

public static string TAG_ARMOR_MARKER = "ArmorMarker";

public static int MAX_AMMO = 6;
public static int AMMO_INC = 3;

Listing 6-26Utilities Static Class Fields 3

前一组中列出的接下来的五个字段都是与战斗模式相关的标签。每个标签用于识别不同的战斗模式标记,当碰撞时,修改玩家的状态。接下来的两个静态字段定义了玩家可以持有的最大弹药量,随后的AMMO_INC字段显示了从枪/弹药标记添加的弹药量。还有一组字段需要查看,如下所示。

public static string NAME_GAME_STATE_OBJ = "GameState";

public static string NAME_PLAYER_ROOT = "HoverCar";
public static string NAME_START_ROOT = "StartPosition";

public static float MAX_XFORM_POS_Y = 50.0f;
public static float MIN_XFORM_POS_Y = 12.0f;
public static int MARKER_REFRESH_MIN = 60;
public static int MARKER_REFRESH_MAX = 90;

Listing 6-27Utilities Static Class Fields 3

下一个静态类字段很重要。我们以前在很多类中见过这个字段,在BaseScript类的准备方法中。NAME_GAME_STATE_OBJ字段用于查找同名的游戏对象。在 Hover Racers 游戏中,游戏状态是由GameState类跟踪的。该类已被添加到同名的空 Unity GameObject中。

这就是为什么游戏中的每一个MonoBehaviour都会搜索GameState游戏对象,看看它是否有一个GameState脚本组件与之相关联。这样的例子可以在Utilities类的准备方法中看到,它被BaseScript类使用。接下来的两个字段MAX_XFORM_POS_YMIN_XFORM_POS_Y用于确定玩家的悬停赛车是否在空中,是否应该被重力拉下来。最后但并非最不重要的是两个字段,用于确定给定战斗模式标记的刷新时间。

当玩家的悬停赛车与标记碰撞时,该标记会消失几秒钟,然后再次可见。MARKER_REFRESH_MINMARKER_REFRESH_MAX字段用于生成标记再次可见之前等待的随机时间。这就结束了该类的静态字段。接下来,我们将看看这个类的静态类方法。

1 public static void wr(string s) {
2    if (LOGGING_ON) {
3       Debug.Log(s);
4    }
5 }

1 public static void wrForce(string s) {
2    Debug.Log(s);
3 }

1 public static void wr(string s, string sClass, string sMethod, string sNote) {
2    if (LOGGING_ON) {
3       Debug.Log(sClass + "." + sMethod + ": " + sNote + ": " + s);
4    }
5 }

1 public static void wrErr(string s) {
2    if (LOGGING_ON) {
3       Debug.LogError(s);
4    }
5 }

1 public static void wrErr(string s, string sClass, string sMethod, string sNote) {
2    if (LOGGING_ON) {
3       Debug.LogError(sClass + "." + sMethod + ": " + sNote + ": " + s);
4    }
5 }

Listing 6-28Utilities Static Class Methods 1

Utilities类有许多有用的日志记录方法供我们研究。列出的第一个方法是wr,用于记录与日志记录控制变量相关的调用。wrForce方法用于记录必须显示的调用。这个方法忽略了类的日志控制字段LOGGING_ON。列出的第二个版本接受更多的参数;它处理将格式化的文本写入日志,但在其他方面的功能与它的对应物类似。随后的两种方法,wrErr和更复杂版本的wrErr方法,使用方式与它们的非错误表亲完全相同。这里的主要区别是wr方法调用 Unity 的Debug.Log方法,而wrErr调用Debug.LogError方法。接下来,让我们看看课堂的音频支持方法。

01 public static void SafePlaySoundFx(AudioSource audioS, string sClass, string sMethod, string sNote, string name) {
02    Utilities.wr("Playing sound " + name, sClass, sMethod, sNote);
03    SafePlaySoundFx(audioS);
04 }

01 public static void SafePlaySoundFx(AudioSource audioS) {
02    if (audioS != null) {
03       if (audioS.isPlaying == false) {
04          audioS.Play();
05       }
06    }
07 }

01 public static AudioSource[] LoadAudioResources(AudioSource[] audioSetSrc, string[] audioSetNames) {
02    AudioSource[] audioSetDst = null;
03    int count = 0;
04    if (audioSetSrc != null && audioSetNames != null) {
05       audioSetDst = new AudioSource[audioSetNames.Length];
06       for (int i = 0; i < audioSetSrc.Length; i++) {
07          AudioSource aS = (AudioSource)audioSetSrc[i];
08          for (int j = 0; j < audioSetNames.Length; j++) {
09             if (aS != null && aS.clip.name == audioSetNames[j]) {
10                Utilities.wr("Found audio clip: " + audioSetNames[j]);
11                audioSetDst[j] = aS;
12                count++;
13                break;
14             }
15          }
16
17          if (count == audioSetNames.Length) {
18             break;
19          }
20       }
21    }
22    return audioSetDst;
23 }

Listing 6-29Utilities Static Class Methods 2

前面列出的方法中的前两个条目支持集中式音频回放。第一个方法接受几个额外的参数,用于日志记录目的,第 2 行,然后在第 3 行调用简单版本的SafePlaySoundFx方法。将我们的注意力引向接受单个参数的SafePlaySoundFx方法,一个AudioSource实例audioS。此方法检查音频资源是否已定义,如果已定义,则播放声音。这一组中的最后一个方法是LoadAudioResources方法。我们在加载音频资源的某些类Start方法中遇到过几次这种方法。

该方法采用两个数组作为参数。第一个是要搜索的音频资源数组。第二个是字符串数组,这些字符串构成了要从第一个数组中提取的目标音频资源。这种方法相当简单。如果定义了方法参数,那么我们准备循环控制变量并在第 2–5 行返回数组。对于audioSetSrc数组中的每个音频资源,我们循环遍历audioSetNames数组来寻找匹配。如果找到一个,我们将它添加到结果数组中,并递增一个计数器,第 11–12 行。然后我们跳出内部循环,进入下一个音频资源。如果我们已经找到了我们要找的所有东西,我们就退出外部循环,第 17–19 行。该方法的结果在第 22 行返回,大概是在定位了所有需要的音频资源之后。接下来我们要回顾的一组方法是用来加载游戏和玩家状态数据的。让我们看一看。

01 public static object[] LoadStartingSet(string className, out GameState gameState) {
02    GameObject gameStateObj = GameObject.Find(Utilities.NAME_GAME_STATE_OBJ);
03    if (gameStateObj != null) {
04       gameState = gameStateObj.GetComponent<GameState>();
05       if (gameState != null) {
06          gameState.PrepGame();
07          return new object[] { gameStateObj, gameState, true };
08       } else {
09          Utilities.wrForce(className + ": gameState is null! Deactivating...");
10          return new object[] { gameStateObj, gameState, false };
11       }
12    } else {
13       Utilities.wrForce(className + ": gameStateObj is null! Deactivating...");
14       gameState = null;
15       return new object[] { gameStateObj, gameState, false };
16    }
17 }

01 public static object[] LoadStartingSetAndLocalPlayerInfo(string className, out GameState gameState, out PlayerInfo pi, out int playerIndex, out PlayerState p, GameObject g, bool inParent) {
02    GameObject gameStateObj = GameObject.Find(Utilities.NAME_GAME_STATE_OBJ);
03    if (gameStateObj != null) {
04       gameState = gameStateObj.GetComponent<GameState>();
05       if (gameState != null) {
06          gameState.PrepGame();
07       } else {
08          Utilities.wrForce(className + ": gameState is null! Deactivating...");
09          pi = null;
10          playerIndex = -1;
11          p = null;
12          return new object[] { gameStateObj, gameState, false, pi, playerIndex, p };
13       }
14    } else {
15       Utilities.wrForce(className + ": gameStateObj is null! Deactivating...");
16       gameState = null;
17       pi = null;
18       playerIndex = -1;
19       p = null;
20       return new object[] { gameStateObj, gameState, false, pi, playerIndex, p };
21    }
22
23    if (g != null) {
24       if (inParent) {
25          pi = g.GetComponentInParent<PlayerInfo>();
26       } else {
27          pi = g.GetComponent<PlayerInfo>();
28       }
29       if (pi != null) {
30          playerIndex = pi.playerIndex;
31          p = gameState.GetPlayer(playerIndex);
32          if (p != null) {
33             return new object[] { gameStateObj, gameState, true, pi, playerIndex, p };
34          } else {
35            Utilities.wrForce(className + ": p is null! Deactivating...");
36             p = null;
37             return new object[] { gameStateObj, gameState, false, pi, playerIndex, p };
38          }
39       } else {
40          Utilities.wrForce(className + ": pi is null! Deactivating...");
41          pi = null;
42          playerIndex = -1;
43          p = null;
44          return new object[] { gameStateObj, gameState, false, pi, playerIndex, p };
45       }
46    } else {
47       Utilities.wrForce(className + ": g is null! Deactivating...");
48       pi = null;
49       playerIndex = -1;
50       p = null;
51       return new object[] { gameStateObj, gameState, false, pi, playerIndex, p };
52    }
53 }

01 public static object[] LoadPlayerInfo(string className, out PlayerInfo pi, out int playerIndex, out PlayerState p, GameObject g, GameState gameState, bool inParent, bool verbose = false) {
02    if (g != null && gameState != null) {
03       if (inParent) {
04          pi = g.GetComponentInParent<PlayerInfo>();
05       } else {
06          pi = g.GetComponent<PlayerInfo>();
07       }
08       if (pi != null) {
09          playerIndex = pi.playerIndex;
10
11          p = gameState.GetPlayer(playerIndex);
12          if (p != null) {
13             return new object[] { pi, playerIndex, true, p };
14          } else {
15             if (verbose) {
16                Utilities.wrForce(className + ": p is null! Deactivating...");
17             }
18             p = null;
19             return new object[] { pi, playerIndex, false, p };
20          }
21       } else {
22          if (verbose) {
23             Utilities.wrForce(className + ": pi is null! Deactivating...");
24          }
25          pi = null;
26          playerIndex = -1;
27          p = null;
28          return new object[] { pi, playerIndex, false, p };
29       }
30    } else {
31       if (verbose) {
32          Utilities.wrForce(className + ": g is null! Deactivating...");
33       }
34       pi = null;
35       playerIndex = -1;
36       p = null;
37       return new object[] { pi, playerIndex, false, p };
38    }
39 }

Listing 6-30Utilities Static Class Methods 3

前面列出的三个方法是非常重要的实用方法,在游戏代码库的不同地方使用。第一个条目LoadStartingSetBaseScript类的Prep方法使用。这意味着它被用在许多扩展了BaseScript类的类中。该方法的主要目的是定位GameState游戏对象并找到相关的GameState脚本组件。该方法返回一个结果数组,数组中的最后一项是指示操作成功的布尔值。

列出的下一个方法LoadStartingSetAndLocalPlayerInfo,与我们刚刚审查的方法类似,只是它更进一步,在加载游戏状态后,它将寻找本地PlayerInfo组件。该组件用于指示某个游戏对象属于哪个玩家。然后,该方法尝试加载与该玩家相关联的玩家状态数据。如果GameState已经正确加载,第 24–38 行的代码定位相关的PlayerState对象。看看这两种方法,确保你很好地理解了它们。如果遇到问题,这两种方法中的大部分代码都可以安全退出。

该组中列出的最后一种方法是LoadPlayerInfo方法。该方法用于加载与游戏对象相关的玩家状态数据。请注意,该代码与前面介绍的方法的第二部分非常相似。本质上,这个过程使用来自一个PlayerInfo对象实例的播放器索引来定位播放器的PlayerState实例,该实例存储在GameState类中。查找 Unity 组件时遇到的任何问题都会导致指示错误的响应。看看这些方法中使用的参数。特别注意使用out参数来更新某些对象引用,而不依赖于方法的返回对象。接下来,让我们看看这个类的运行情况。

演示:实用程序

因为Utilities类是普通的 C# 类,所以我们不会使用演示场景。相反,我们将回顾一些相关的代码,这些代码使用了我们刚刚回顾的一些方法。对于这个特定的演示,我们将看一下显示了运行中的Utilities类的代码片段。下面的代码来自BaseScript的 Start 方法。

1 public bool Prep(string sName) {
2    scriptName = sName;
3    scriptActive = (bool)Utilities.LoadStartingSet(scriptName, out gameState)[2];
4    MarkScriptActive(scriptName, scriptActive);
5    return scriptActive;
6 }

1 public bool PrepPlayerInfo(string sName) {
2    scriptName = sName;
3    scriptActive = (bool)Utilities.LoadStartingSetAndLocalPlayerInfo(scriptName, out gameState, out PlayerInfo pi, out int playerIndex, out p, gameObject, true)[2];
4    MarkScriptActive(scriptName, scriptActive);
5    return scriptActive;
6 }

Listing 6-31Utilities Demonstration 1

列出的第一种方法是Prep方法。这个方法被大多数扩展了BaseScript类的类调用。请特别注意第 3 行的方法调用。这里发生了三件事。用调用LoadStartingSet方法返回的数组索引 2 中的值更新scriptActive字段。另外,请注意,gameState方法参数是用关键字out修饰的。这意味着本地对象可以通过方法调用来更新。这样,我们可以更新许多重要的类字段,并引用操作的布尔结果,返回数组索引 2。不算太寒酸。

前面列出的第二种方法PrepPlayerInfo与我们刚刚讨论的方法非常相似。它使用了LoadStartingSetAndLocalPlayerInfo类的准备方法。请注意,gameStateplayerInfoplayerIndexplayerState参数都是 out 方法参数,这意味着它们都可以通过方法调用来更新。这让我们可以在最小的空间内完成大量的配置步骤。我认为它很强大,很有效,但是看看代码有多不清晰。一些更长但更具描述性的东西会更合我的口味。在继续之前,请确保您理解了本课程。它非常重要,贯穿于整个代码库。接下来,我们将看看飞艇摄像机背后的代码。

班级点评:CameraFollowXz

CameraFollowXz类是一个MonoBehaviour类,设计用来跟随当前玩家的汽车在赛道上的移动。该课程相当简洁,但也有足够的复杂性,我们应该使用下列组中列出的标准课程复习步骤:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

这个类没有任何枚举或支持方法,所以我们将省略这些部分。让我们从查看类的静态成员开始审查过程。

静态/常量/只读类成员:CameraFollowXz

CameraFollowXz类有几个静态字段供我们讨论。我们开始吧。

public static readonly float BLIMP_FLY_HEIGHT = 110f;
public static readonly float BLIMP_FLY_MIN = 30f;

Listing 6-32CameraFollowXz Static/Constants/Read-Only Class Members 1

前面列出的第一个静态字段是BLIMP_FLY_HEIGHT静态字段。该字段表示飞艇摄像机跟随悬停赛车时的高度。高度根据悬停赛车的速度进行调整。这意味着相机在较慢的速度下较低,在较快的速度下较高。下一个字段是BLIMP_FLY_MIN字段。这个字段设置摄像机跟随玩家的最小高度。它包装了类的静态字段。接下来,让我们回顾一下该课程的剩余字段。

类字段:CameraFollowXz

这个类有三个字段供我们查看,如下所示。

private float x = 0.0f;
private float y = 0.0f;
private float z = 0.0f;

Listing 6-33CameraFollowXz Class Fields 1

该组类字段匹配一个Vector3对象的组件值。这些字段用于根据当前玩家的位置和速度计算飞艇摄像机的位置。

相关的方法大纲/类头:CameraFollowXz

CameraFollowXz类有下面的方法大纲。

//Main
void Start();
void Update();

Listing 6-34CameraFollowXz Pertinent Method Outline/Class Headers 1

随后,我将列出该类的导入语句和声明。

using UnityEngine;

public class CameraFollowXz : BaseScript {}

Listing 6-35CameraFollowXz Pertinent Method Outline/Class Headers 2

在下一个复习部分,我们将看看这个类的主要方法。

主要方法详细信息:CameraFollowXz

下面一组中列出的两个主要方法是类'StartUpdate方法。让我们看看。

01 void Start() {
02    base.Prep(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07 }

01 void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (gameState != null) {
07       p = gameState.GetCurrentPlayer();
08       if (p != null) {
09          if (p.player != null && p.player.transform != null) {
10             x = p.player.transform.position.x;
11             y = (p.player.transform.position.y + ((BLIMP_FLY_HEIGHT * p.speedPrct) + BLIMP_FLY_MIN));
12             z = p.player.transform.position.z;
13             transform.position = new Vector3(x, y, z);
14          }
15       }
16    }
17 }

Listing 6-36CameraFollowXz Main Method Details 1

Start方法作为MonoBehaviour生命周期的一部分被调用。它在组件的初始化阶段被调用。该方法的第 2 行调用基类的'Prep'方法。此方法用于配置类并注册结果。在第 3 行检查类配置是否成功。这是扩展了BaseScript类并且不需要玩家状态数据的类的标准初始化。

列出的第二个方法是类'Update方法。如果组件处于活动状态,Unity 游戏引擎会在每个游戏帧中调用此方法。在第 2-4 行,我们有标准的安全检查。如果没有正确配置该类,那么该方法不做任何工作就退出。如果gameState字段不为空,那么我们在第 7 行存储一个对当前玩家状态的引用。如果定义了当前玩家状态,第 8 行,并且定义了玩家的游戏对象和变换,那么我们基于当前玩家的位置行 10-13 设置xyz类字段。

请注意,垂直轴y是基于当前玩家的y坐标加上由悬停赛车手的当前speedPrct确定的值。这将导致当当前玩家的悬停赛车移动得更快时,飞艇摄像机以更高的高度跟随该赛车。然而,相机不会低于最低高度。在第 13 行,飞艇摄像机的位置被更新。这一行实际上会设置CameraFollowXz脚本所附着的游戏对象的位置。我们只把它附加到一个Camera物体上,但是它也可以和其他游戏物体一起工作。在下一节中,我们将看一下CameraFollowXz类的演示。

演示:CameraFollowXz

CameraFollowXz类的演示是一个名为“DemoCameraFollowXz”的场景。该场景位于“项目”面板的“场景”文件夹中。在运行场景之前,让我解释一下它是如何工作的。这个演示实际上非常类似于我们之前检查的“DemoCollideScript”场景。这里的主要区别是,这个场景有全套的菜单屏幕和游戏中的 HUD。

因此,场景将以一个可见的菜单屏幕开始。为了开始场景,你需要点击“轨迹 1”或“轨迹 2”按钮。你也可以点击游戏画面左下角的重启按钮。你会注意到,当这个演示场景开始时,我们可以看到一个完整的游戏 HUD,包括一个飞艇摄像机。飞艇摄像机将跟随当前玩家在棋盘上移动,移动的高度取决于悬浮赛车的速度。开车兜一圈,看看飞艇摄像机是如何工作的。我们将通过查看WaypointCompare类来结束这一章。

课堂复习:航点比较

WaypointCompare类用于对从当前轨迹加载的路点标记数组进行排序。该类相当简短和直接,所以我们将直接列出该类,如下所示,并对其进行回顾。让我们来看看一些代码!

01 public class WaypointCompare : IComparer {
02    private WaypointCheck obj1 = null;
03    private WaypointCheck obj2 = null;
04
05    public int Compare(object o1, object o2) {
06       obj1 = (WaypointCheck)o1;
07       obj2 = (WaypointCheck)o2;
08       if (obj1.waypointIndex > obj2.waypointIndex) {
09          return 1;
10       } else if (obj1.waypointIndex < obj2.waypointIndex) {
11          return -1;
12       } else {
13          return 0;
14       }
15    }
16 }

Listing 6-37CameraFollowXz Class Review 1

注意,WaypointCompare类没有扩展 Unity 的MonoBehaviour或我们的BaseScript类。相反,因为用于类比较,WaypointCompare类扩展了IComparer接口。在第 2–3 行,列出了类别字段obj1obj2。它们用于保存转换为适当类类型的比较对象。在第 5 行,定义了比较方法。它将两个通用对象作为比较的参数,并由数组排序方法自动调用。

注意,在第 6–7 行,方法参数被转换为WaypointCheck对象实例。请注意,私有类字段被用作局部变量来避开垃圾收集器。第 8–14 行确定了两个WaypointCheck对象之间的关系。与WaypointCheckwaypointIndex进行比较。根据它们之间的关系,指示顺序的方法返回 1、-1 或 0。让我们来看看这个类的一个演示。

演示:航路点比较

因为WaypointCompare类不是MonoBehaviour类的扩展,我们不会用场景来演示它。相反,我们将看看运行中的类。下面的代码片段来自于GameState类的FindWaypoints方法。

1 public WaypointCompare wpc = new WaypointCompare();
...
2 object[] ar = row.ToArray();
3 System.Array.Sort(ar, wpc);

Listing 6-38CameraFollowXz Demonstration 1

这个例子很简单。第 1 行显示了第 3 行中使用的wpc类字段的声明。它接受一个对象数组,这些对象是从赛道的游戏对象(第 2 行)加载的WaypointCheck实例。接下来,调用数组排序方法,第 3 行,使用路点数组和一个WaypointCompare类实例作为参数。

第二章结论

在这一章中,我们花了一点时间来回顾一下 Hover Racers 游戏中的“其他”职业。这给我们介绍了一些有趣的话题。我将列出一些潜在的项目如下。花点时间去思考它们。

  1. 清理脚本:确保游戏对象在脱离棋盘或轨道时得到管理。此外,它们可以用于水障碍和其他赛道功能,以重置球员。

  2. 以编程方式调整声音:引擎声音控制用于增加游戏的真实感,并且是添加到工具箱的有用效果。

  3. C# 支持类:使用普通的 C# 类,以快速、易用的实用程序的形式为游戏添加功能。

  4. 集中控制代码:Utilities类充当游戏设置和重要的核心配置功能的集中点。

  5. 相机技巧:我们用一个简单的MonoBehaviour连接到一个 Unity Camera对象上,创建了一个飞艇相机效果。

在本章中,Unity 引擎有许多有用的应用。更上一层楼,创造一些你自己的。在下一章,我们将看看输入类。你应该意识到不同的组件,主要是脚本组件(MonoBehaviour s),可以通过它们的Update方法改变游戏对象——每一帧。请注意,对GameObject或脚本组件的更改可以在代码的许多不同位置进行。我发现集中式设计模型,比如Utilities类,对我来说更好,但是,也许一个更大的团队会从分布式模型中受益更多。你必须找到最适合你情况的方法。

七、输入类

在这一章中,我们将详细回顾用于驱动游戏控件的输入类。我应该提到的是,Hover Racers 支持键盘、鼠标、控制器和触摸输入。我们在这一章要看的类如下:

  1. 字符马达

  2. FPS 输入控制器

  3. MouseLookNew

  4. GameState(仅触摸输入片段)

CharacterMotor类可能是列出的类中最复杂的。它包含以下子类:

  1. 性格运动

  2. 字符电机滑动

  3. 角色机动跳跃

这些类用于管理悬停赛车运动的不同组件。剩下的类FPSInputControllerMouseLookNew不太复杂,没有定义任何子类。让我们从CharacterMotor的子类开始回顾。我们在本章中学习的类可以在下面列出的两个位置中找到:

  1. \标准资源\角色控制器\源\脚本\

  2. \标准资产\mmg_scripts\

如果你在寻找一个特定的类时遇到了麻烦,只需在 Unity 编辑器中使用“项目”面板的搜索栏来搜索它,或者在 Visual Studio 的“解决方案资源管理器”面板中寻找它。

课程回顾:角色运动

CharacterMotorMovement类用于控制角色马达以移动一个对象。这个类很简洁,所以我们将放弃更冗长的类回顾过程,只在这里列出这个类。

01 [System.Serializable]
02 public class CharacterMotorMovement {
03    public float maxForwardSpeed = 3.0f;
04    public float maxSidewaysSpeed = 10.0f;
05    public float maxBackwardsSpeed = 10.0f;
06    public AnimationCurve slopeSpeedMultiplier = new AnimationCurve(new Keyframe(-90, 1), new Keyframe(0, 1), new Keyframe(90, 0.70f));
07
08    public float maxGroundAcceleration = 30.0f;
09    public float maxAirAcceleration = 20.0f;
10    public float gravity = 10.0f;
11    public float maxFallSpeed = 22.0f;
12
13    [System.NonSerialized]
14    public CollisionFlags collisionFlags;
15
16    [System.NonSerialized]
17    public Vector3 velocity;
18
19    [System.NonSerialized]
20    public Vector3 frameVelocity = Vector3.zero;
21
22    [System.NonSerialized]
23    public Vector3 hitPoint = Vector3.zero;
24
25    [System.NonSerialized]
26    public Vector3 lastHitPoint = new Vector3(Mathf.Infinity, 0, 0);
27 }

Listing 7-1CharacterMotorMovement Class Review 1

在我深入研究代码评审之前,我想花点时间提一下前面列出的类中使用的一些代码属性。属性用来明确地告诉 Unity 某些类字段的值应该被序列化并用来驱动“Inspector”面板值。硬币反面是System.NonSerialized属性。此属性用于从序列化和显示过程中对通常会被序列化的字段进行转义。简而言之,“检查器”面板不会显示具有非序列化属性的字段,即使它们通常会显示。

在课堂评论上。CharactorMotor类由一系列公共类字段组成。前三个字段用于设置影响悬停赛车运动的向前、向后和侧向速度的最大速度。请记住,作为游戏初始化过程的一部分,这些值在代码的其他部分进行了细化。字段是 Unity 的AnimationCurve类的一个实例。它用于修改悬停赛车在地面遇到斜坡或斜面时的速度。

maxGroundAcceleration类字段用于控制汽车的水平加速度。下一个字段,maxAirAcceleration,以同样的方式工作,除了它在悬停赛车离开地面时应用。gravity场被用来施加一个向下的力,使悬停赛车回到地面。maxFallSpeed场控制汽车在重力作用下落回地面的速度。接下来,collisionFlag字段用于报告悬停赛车的碰撞信息。

velocity向量跟踪悬停赛车的当前速度。frameVelocity字段类似,但在内部使用,大概是为了跟踪每一帧游戏的速度。最后列出的两个字段,hitPointlastHitPoint,用于在CharacterMotor类中跟踪内部速度控制的运动过程中的碰撞点。接下来我们来看看CharacterMotorJumping类。

课程回顾:角色摩托跳跃

CharacterMotorJumping用于帮助管理跳跃过程中的角色移动。我在这里枚举一下班级,供大家复习。

01 [System.Serializable]
02 public class CharacterMotorJumping {
03    public bool enabled = true;
04    public float baseHeight = 1.0f;
05    public float extraHeight = 4.1f;
06    public float perpAmount = 0.0f;
07
08    public float steepPerpAmount = 0.5f;
09
10    [System.NonSerialized]
11    public bool jumping = false;
12
13    [System.NonSerialized]
14    public bool holdingJumpButton = false;
15
16    [System.NonSerialized]
17    public float lastStartTime = 0.0f;
18
19    [System.NonSerialized]
20    public float lastButtonDownTime = -100.0f;
21
22    [System.NonSerialized]
23    public Vector3 jumpDir = Vector3.up;
24 }

Listing 7-2CharacterMotorJumping Class Review 1

需要注意的是,我们正在复习的类并没有被游戏主动使用。我想覆盖它,所以你可以在你的下一个游戏中添加不同种类的跳跃。第一个字段enabled表示跳转功能激活。以下两个类字段baseHeightextraHeight设置跳跃基础高度和额外跳跃高度值。随后,在计算中使用perpAmountsteepPerpAmount来确定角色如何在斜坡和陡坡上跳跃。跳跃标志用于指示字符是否被标记为跳跃。

游戏中的其他类跟踪玩家状态并记录悬停赛车是否跳跃。在我们的例子中,与跳跃标记的碰撞决定了悬停赛车是否在跳跃。因为参赛者不会在按下按钮时跳跃,这很有趣,因为游戏没有使用默认的跳跃修改代码。因此,这个类可能无法准确描述角色的跳跃状态。下一个字段holdingJumpButton是一个布尔值,表示跳转按钮被按下。最后但同样重要的是,jumpDir字段指示跳转的方向。下一堂课,我们将看看模型的悬停赛车的滑动或侧向运动。

课程回顾:角色摩托滑行

CharacterMotorSliding类类似于我们刚刚讨论过的子类。让我们来看看。

1 [System.Serializable]
2 public class CharacterMotorSliding {
3    public bool enabled = true;
4    public float slidingSpeed = 15.0f;
5    public float sidewaysControl = 1.0f;
6    public float speedControl = 0.4f;
7 }

Listing 7-3CharacterMotorSliding Class Review 1

正如我前面提到的,这个类用于控制一个悬停赛车的左右运动。第一个字段指示是否启用滑动。第二个条目slidingSpeed表示滑动的速度,而sidewaysControl字段用于跟踪玩家对横向移动的控制程度。最后,speedControl域用于控制玩家可以在多大程度上影响悬浮赛车的滑行速度。子类评审到此结束。接下来,我们将仔细看看CharacterMotor类。

课程回顾:角色马达

CharacterMotor类负责悬停赛车的基本动作。这个类管理的跳跃和重力实际上被游戏禁用了。启用滑动和水平移动。这个类的顶部列出了一个代码属性:

[RequireComponent(typeof(CharacterController))]

这个代码属性表明这个MonoBehaviour组件需要存在一个附加到同一个GameObjectCharacterController组件。这是通过 Unity 编辑器处理的。如果角色 motor 似乎知道 hover racer 的碰撞状态,那是因为它从CharacterController类中获得了该信息,我们稍后将对此进行介绍。我还应该提到的是FPSInputController脚本驱动了CharacterContoller。这样,输入向量由FPSInputController更新。

游戏中的输入和移动设置有点复杂,所以我们在这里概述一下。

img/520710_1_En_7_Fig1_HTML.png

图 7-1

Hover Racers 输入图

不算太坏。如果我们从头开始编写自己的控制器,这看起来可能会有点不同。但在这种情况下,我们使用的是 Unity 的一些传统控制器类,所以这就是我们所得到的。话虽如此,我们还是来略述一下我们的课堂复习吧。

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

没有静态类成员或枚举可言,所以我们将跳过审查过程中的这些部分。我们有一些工作要做;我们开始吧。

类别字段:角色马达

第一个CharacterMotor类字段如下。

public bool aiOn = false;
bool canControl = true;
bool useFixedUpdate = true;

Listing 7-4CharacterMotor Class Fields 1

aiOn字段是一个布尔标志,指示当前悬停赛车的 AI 控制是否有效。canControl字段是另一个布尔标志,用于指示玩家是否可以控制悬停赛车。集合中的最后一个条目是useFixedUpdate布尔。如果此字段设置为 true,该类将使用固定更新调用。固定更新呼叫仅仅是固定速率的更新呼叫。这不同于我们目前所见的标准Update方法。标准的Update方法在每个游戏帧被调用一次。然而,由于游戏帧速率的波动,其速率是可变的。对于重要的物理计算,我们使用FixedUpdate回调方法,因为它更稳定可靠。

public Vector3 inputMoveDirection = Vector3.zero;
public bool inputJump = false;
public CharacterMotorMovement movement = new CharacterMotorMovement();

public CharacterMotorJumping jumping = new CharacterMotorJumping();

public CharacterMotorSliding sliding = new CharacterMotorSliding();

public bool grounded = true;

Listing 7-5CharacterMotor Class Fields 2

inputMoveDirection矢量代表由FPSInputController设定的运动方向。inputJump字段是一个布尔标志,用于指示跳转按钮已被按下。通常这个输入会来自于FPSInputController,但是我们已经改变了那个脚本的默认功能,使用了一个更适合赛车游戏的输入映射,禁用了基于按钮按压的跳跃。在inputJump字段之后是用于模拟角色运动的三个字段。我们刚刚回顾了它们,所以我假设你对它们的内容有一个坚实的概念。最后,我们有接地字段,用来表示角色是接地的。

public Vector3 groundNormal = Vector3.zero;
private Vector3 lastGroundNormal = Vector3.zero;
private Transform tr;
private CharacterController controller;

Listing 7-6CharacterMotor Class Fields 3

我们要查看的最后一组类字段从groundNormal向量开始。该字段是一个与地面垂直相交的Vector3实例,用于确定地面坡度等。该字段之后是lastGroundNormal字段,用于跟踪之前的地面法向量。Transformtr用于在计算过程中保存对 Unity Transform对象的引用。变换对象用于定位和缩放 Unity GameObjects

CharacterController字段controller是对所需的CharacterController组件的引用。最终是控制器被这个类移动,它是碰撞数据的来源,指示角色被固定或遇到斜坡或陡坡。最后两个字段是我们多次遇到的标准类初始化步骤的一部分。接下来,我们将检查该类的相关方法。

相关的方法大纲/类头:字符马达

CharacterMotor类有许多方法供我们研究,如下所列。

//Main Methods
void Awake();
private void UpdateFunction();
void FixedUpdate();
void Update();
void OnControllerColliderHit(ControllerColliderHit hit);

//Support Methods
private Vector3 ApplyInputVelocityChange(Vector3 velocity);

private Vector3 ApplyGravityAndJumping(Vector3 velocity);

private Vector3 GetDesiredHorizontalVelocity();
private Vector3 AdjustGroundVelocityToNormal(Vector3 hVelocity, Vector3 groundNormal);

private bool IsGroundedTest();
float GetMaxAcceleration(bool grounded);
float CalculateJumpVerticalSpeed(float targetJumpHeight);

bool TooSteep();
float MaxSpeedInDirection(Vector3 desiredMovementDirection);

Listing 7-7CharacterMotor Pertinent Method Outline/Class Headers 1

该类的导入语句和头文件如下。

using UnityEngine;

//Require a character controller to be attached to the same game object
[RequireComponent(typeof(CharacterController))]
[AddComponentMenu("Character/Character Motor")]

public class CharacterMotor : BaseScript {}

Listing 7-8CharacterMotor Pertinent Method Outline/Class Headers 2

注意我们之前讨论过的RequireComponent条目。该条目下面是一个 Unity 特有的代码属性,用于在查看CharacterMotor组件所连接的GameObject时定制“检查器”面板。在这种情况下,它只是添加一个列出了名称的菜单标题。在下一节,我们将看看类的支持方法。还要注意,这个类扩展了BaseScript类,因此是一个MonoBehaviour实例,具有BaseScript类提供的所有默认功能。

支持方法详细信息:字符马达

类的支持方法相当复杂。有许多向量运算需要从代码和数学两方面进行大量解释。我不准备在这篇文章中深入讨论这些材料。我们将从一个更高的层次来回顾这些方法,并尝试关注这些方法实际上做了什么,而不是数学是如何工作的。我邀请你分解任何复杂的计算,自己对所涉及的数学做出决定。

我们要回顾的第一个方法是ArrayInputVelocityChange方法。这个方法采用一个Vector3速度参数,它应该代表输入驱动的速度向量。该方法考虑了地面坡度、滑动方向和速度。它强制执行最大速度变化,并检查角色是否在跳跃。现在,把你的注意力放在ApplyGravityAndJumping方法上,快速浏览一下代码。ApplyGravityAndJumping方法负责处理关于跳跃、地面坡度、最大下落速度和重力的垂直轴调整。请记住,这种方法并没有在游戏中使用,而是设计用于基于按钮按下的跳跃。悬停赛车游戏,因为是,使用基于碰撞的跳跃标记,而不是按钮按下跳跃控制。

GetDesiredHorizontalVelocity方法负责获取inputMoveDirection并确定角色在给定方向的最大速度。这种方法将基于斜坡速度乘数曲线修改汽车在斜坡上的最大速度,这是我们之前讨论过的CharacterMotorMovement类字段。下面的方法AdjustGroundVelocityToNormal用于通过一系列叉积矢量运算来调整地面速度矢量。这为基于输入的地面运动建立了一个清晰的、正确定向的矢量。

这个条目后面是IsGroundedTest方法。该方法返回一个布尔值,指示角色是否接触地面。随后,GetMaxAcceleration方法根据汽车的状态返回运动类的最大地面或空中加速度。根据targetJumpHeight和当前重力计算出CalculateJumpVerticalSpeed方法。TooSteep方法使用groundNormal向量和控制器字段的slopeLimit来确定地面坡度对赛车来说是否太陡。

MaxSpeedInDirection是一种用于确定角色在给定方向上移动的最大速度的方法,考虑了横向和向后的速度。同样,我们没有像通常那样详细地讨论支持方法,以避免冗长的数学讨论。在审查代码时,请关注所列出的支持方法的用法。这让我们得出了CharacterMotor类的支持方法的结论。在下一个复习部分,我们将看看这个类的主要方法。

主要方法详细信息:字符马达

我们复习的第一个主要方法是Awake法。Awake方法是一种类似于Start方法的 Unity 组件生命周期回调方法。AwakeStart方法的区别在于Start方法仅在脚本被启用并且所有对象都被初始化之后才被调用,这样你就可以在加载脚本实例时调用Awake的同时连接到它们。这两个函数都是在第一个Update方法之前调用的,两者没有性能差异。

01 void Awake() {
02    controller = GetComponent<CharacterController>();
03    tr = transform;
04    base.PrepPlayerInfo(this.GetType().Name);
05    if (BaseScript.IsActive(scriptName) == false) {
06       Utilities.wrForce(scriptName + ": Is Deactivating...");
07       return;
08    } else {
09       aiOn = p.aiOn;
10    }
11 }

Listing 7-9CharacterMotor Main Method Details 1

前两行代码根据附加的、必需的CharacterController组件和当前游戏对象的转换来设置controllertr类字段。第 4 行的代码通过加载GameStatePlayerState数据来准备类。第 5–10 行的代码片段负责检查类的初始化是否成功。如果没有,该类打印一些日志并返回,第 6 行和第 7 行。如果初始化成功,则aiOn字段被更新以匹配第 9 行上当前玩家的状态。下一个要回顾的主要方法是UpdateFunction方法。

这个方法处理类的更新,并根据useFixedUpdate字段的值由FixedUpdateUpdate方法调用。通过这种方式,我们将实际的更新代码抽象为一个级别,这样它就可以被两个不同的回调方法访问。这减少了冗余。让我们跳到一些代码中。

01 private void UpdateFunction() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    // We copy the actual velocity into a temporary variable that we can manipulate.
07    Vector3 velocity = movement.velocity;
08
09    // Update velocity based on input
10    velocity = ApplyInputVelocityChange(velocity);
11
12    // Apply gravity and jumping force
13    velocity = ApplyGravityAndJumping(velocity);
14
15    // Save lastPosition for velocity calculation.
16    Vector3 lastPosition = tr.position;
17
18    // We always want the movement to be framerate independent.  Multiplying by Time.deltaTime does this.
19    Vector3 currentMovementOffset = velocity * Time.deltaTime;
20
21    // Find out how much we need to push towards the ground to avoid losing grounding
22    // when walking down a step or over a sharp change in slope.
23    float pushDownOffset = Mathf.Max(controller.stepOffset, new Vector3(currentMovementOffset.x, 0, currentMovementOffset.z).magnitude);
24    if (grounded) {
25       currentMovementOffset -= pushDownOffset * Vector3.up;
26    }
27
28    // Reset variables that will be set by collision function
29    //movingPlatform.hitPlatform = null;
30    groundNormal = Vector3.zero;
31
32    // Move our character!
33    movement.collisionFlags = controller.Move(currentMovementOffset);
34
35    movement.lastHitPoint = movement.hitPoint;
36    lastGroundNormal = groundNormal;
37
38    // Calculate the velocity based on the current and previous position.
39    // This means our velocity will only be the amount the character actually moved as a result of collisions.
40    Vector3 oldHVelocity = new Vector3(velocity.x, 0, velocity.z);
41    movement.velocity = (tr.position - lastPosition) / Time.deltaTime;
42    Vector3 newHVelocity = new Vector3(movement.velocity.x, 0, movement.velocity.z);
43
44    // The CharacterController can be moved in unwanted directions when colliding with things.
45    // We want to prevent this from influencing the recorded velocity.
46    if (oldHVelocity == Vector3.zero) {
47       movement.velocity = new Vector3(0, movement.velocity.y, 0);
48    } else {
49       float projectedNewVelocity = Vector3.Dot(newHVelocity, oldHVelocity) / oldHVelocity.sqrMagnitude;
50       movement.velocity = oldHVelocity * Mathf.Clamp01(projectedNewVelocity) + movement.velocity.y * Vector3.up;
51    }
52
53    if (movement.velocity.y < velocity.y - 0.001) {
54       if (movement.velocity.y < 0) {
55          // Something is forcing the CharacterController down faster than it should.
56          // Ignore this
57          movement.velocity.y = velocity.y;
58       } else {
59          // The upwards movement of the CharacterController has been blocked.
60          // This is treated like a ceiling collision - stop further jumping here.
61          jumping.holdingJumpButton = false;
62       }
63    }
64
65    // We were grounded but just lost grounding
66    if (grounded && !IsGroundedTest()) {
67       grounded = false;
68
69       // We pushed the character down to ensure it would stay on the ground if there was any.
70       // But there wasn't so now we cancel the downwards offset to make the fall smoother.
71       tr.position += pushDownOffset * Vector3.up;
72    }
73    // We were not grounded but just landed on something
74    else if (!grounded && IsGroundedTest()) {
75       grounded = true;
76    }
77 }

Listing 7-10CharacterMotor Main Method Details 2

如果该类被标记为非活动的,则该方法被转义而不做任何工作,第 2–4 行。在第 2 行,我们将运动场的速度实例CharacterMotorMovement复制到局部变量velocity。运动向量可以通过任意数量的MonoBehaviour进行调整。同样,它在方法开始时被复制到一个局部变量中。在这种情况下,可以认为速度向量是由玩家输入决定的。在第 10 行,我们通过ApplyInputVelocityChange方法传递了velocity向量。此方法调整向量的分量值,以说明方向、滑动、跳跃和地面坡度。

随后,通过ApplyGravityAndJumping方法进一步调整velocity向量。虽然游戏不使用这种方法,但如果启用,它会对velocity向量应用跳跃、重力和陡坡探测。请记住,我们从玩家想要的输入开始改变这个向量,然后根据地形、滑动、跳跃和重力进行调整。最终,这个向量将被应用于悬停赛车,以在游戏运行时调整其位置。

回想一下,我们没有使用这个类的跳跃能力,而是选择使用基于碰撞检测的跳跃。接下来,在第 16 行设置lastPosition变量。在第 19 行,通过乘以Time.deltaTime的值,速度被转换为独立于帧速率的值。当您将速度向量乘以Time.deltaTime的值时,您实际上是将向量分量的值更改为每秒的速率。向下倾斜的地形在第 23-26 行通过将悬停车手推向地面来处理。

在第 30 行,我们将groundNormal类字段重置为零向量,为处理新的碰撞信息做准备。通过应用独立于帧的运动向量,悬停赛车在线 33 上移动。碰撞结果存储在运动对象的collisionFlags字段中。随后,lastHitPointlastGroundNormal字段用当前的一组值更新。

旧的和新的水平速度是在第 40-42 行计算的。旧的速度基于velocity字段的值。新的速度是基于悬停参赛者的当前位置与其先前位置(线 41)相比而计算的。注意,我们除以Time.deltaTime的值,将分量值从独立于帧速率扩展到全值。根据更新的movement速度矢量的 X 和 Z 分量值在第 42 行设置newHVelocity

第 46–63 行的代码负责考虑由于物体碰撞导致的不必要的运动方向,包括天花板检测,这由类的 jump 实现使用,但不是由整个游戏使用。最后一段代码,第 65–76 行,用于检测地面状态的变化,包括失去地面、起飞、获得地面、着陆。接下来我们将看看剩下的主要方法。

01 void FixedUpdate() {
02    if (useFixedUpdate) {
03       UpdateFunction();
04    }
05 }

01 void Update() {
02    if (!useFixedUpdate) {
03       UpdateFunction();
04    }
05 }

01 void OnControllerColliderHit(ControllerColliderHit hit) {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (hit.normal.y > 0 && hit.normal.y > groundNormal.y && hit.moveDirection.y < 0) {
07       if ((hit.point - movement.lastHitPoint).sqrMagnitude > 0.001 || lastGroundNormal == Vector3.zero) {
08          groundNormal = hit.normal;
09       } else {
10          groundNormal = lastGroundNormal;
11       }
12
13       movement.hitPoint = hit.point;
14       movement.frameVelocity = Vector3.zero;
15    }
16 }

Listing 7-11CharacterMotor Main Method Details 3

这个集合中列出的前两个方法是 Unity 回调方法FixedUpdateUpdate。根据useFixedUpdate字段的值,使用一种或另一种方法。我们要复习的最后一个方法是OnControllerColliderHit方法。该方法用于确定groundNormal Vector3字段的值。注意,移动对象的hitPointframeVelocity字段是在方法结束时设置的。

这门课中的许多计算都很复杂,难以想象,而且非常可怕。不要害怕!您可以通过添加调试和运行游戏来接近任何计算或字段值,以查看这些值如何对应于悬停赛车的实际运动。使用这些信息来确定如何使用该字段以及它包含什么类型的数据。接下来,我们将详细说明如何演示CharacterMotor类。

演示:角色马达

演示CharacterMotor类的最好方法是玩我们到目前为止看过的任何演示场景或实际游戏,场景“Main13”或“Main14”。当游戏在“游戏”面板中运行时,您可以查看“层级”面板并展开StartingSet条目。选择HoverCar0子条目。在“检查器”面板中找到“字符马达”条目并展开它。当汽车在赛道上行驶时,观察字段值的变化。

执行这个演示的一个非常好的方法是运行实际的游戏,让计算机模式运行,同时在“检查器”面板中仔细查看“角色运动”组件的值。试着看看当人工智能玩家在赛道上比赛时,某些值是如何变化的。我们要看的下一个类是FPSInputController类。

课程回顾:FPSInputController

FPSInputController类负责响应用户输入。基于输入,CharacterMotor中的某些值被设置,这又导致CharacterController移动角色。我们将使用以下复习步骤来涵盖本课程:

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 主要方法详细信息

  4. 示范

我们要看的第一个复习部分是类字段。记住这个类的位置在下面的文件夹中:" \标准资产\角色控制器\资源\脚本"。

类字段:FPSInputController

FPSInputController类有许多字段,帮助它管理在控制CharacterMotor时要考虑的用户输入。

private CharacterMotor motor;
public bool aiOn = false;
public Vector3 directionVector = Vector3.zero;
public float directionLength = 0.0f;
public Vector3 inputMoveDirection = Vector3.zero;
public float touchSpeed = 0.0f;
public float touchSpeedDie = 0.065f;

Listing 7-12FPSInputController Class Fields 1

正如我们之前看到的,CharacterMotor字段获取 FPS 输入控制器的运动向量,并在各种计算中使用它,最终以移动角色结束,在这种情况下,是一个悬停赛车模型。布尔标志aiOn负责与其他输入类一起打开和关闭 AI 控制。下一个字段directionVector,表示运动的方向。随后,directionLength字段表示运动矢量的大小。inputMoveDirection字段是一个表示输入移动方向的Vector3实例。最后两个条目用于管理使用触摸输入时的加速。用于加速汽车的touchSpeed字段和用于在触摸输入移除后减缓基于触摸的加速的touchSpeedDie字段。接下来,我们将看看这个类的相关方法大纲。

相关的方法大纲/类头:FPSInputController

这门课只有两个主要的方法让我们复习。

//Main Methods
void Awake();
void Update();

Listing 7-13FPSInputController Pertinent Method Outline/Class Headers 1

该类的导入语句和声明如下。

using UnityEngine;

// Require a character controller to be attached to the same game object
[RequireComponent(typeof(CharacterMotor))]
[AddComponentMenu("Character/FPS Input Controller")]

public class FPSInputController : BaseScript {}

Listing 7-14FPSInputController Pertinent Method Outline/Class Headers 2

注意,类声明使用了“RequireComponent”代码属性来要求将CharacterMotor组件附加到父游戏对象。还要注意,这个类扩展了BaseScript类,正如我们之前看到的,这个类又扩展了MonoBehaviour类。在下一节中,我们将看看这个类的主要方法。

主要方法详细信息:FPSInputController

本节包含我们将详细讨论的两种方法,如下所示。

01 void Awake() {
02    motor = GetComponent<CharacterMotor>();
03    base.PrepPlayerInfo(this.GetType().Name);
04    if (BaseScript.IsActive(scriptName) == false) {
05       Utilities.wrForce(scriptName + ": Is Deactivating...");
06       return;
07    } else {
08       aiOn = p.aiOn;
09    }
10 }

01 void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    // Get the input vector from keyboard or analog stick
07    directionVector = Vector3.zero;
08    if (gameState.gamePaused == true) {
09       return;
10    } else if (gameState.gameRunning == false) {
11       return;
12    }
13
14    if (aiOn == true && p != null) {
15       if (p.pause == true) {
16          return;
17       }
18       directionVector = p.UpdateAiFpsController();
19    } else {
20       if (Input.touchSupported == true && gameState.accelOn == true) {
21          if (Input.touchSupported == true) {
22             if (gameState.accelOn == true) {
23                touchSpeed = 1.0f;
24                directionVector = new Vector3(Input.GetAxis("Horizontal"), 0, touchSpeed);
25             } else {
26                touchSpeed -= (touchSpeed * touchSpeedDie);
27                if (touchSpeed < 0.0) {
28                   touchSpeed = 0.0f;
29                }
30                directionVector = new Vector3(Input.GetAxis("Horizontal"), 0, touchSpeed);
31             }
32          }
33       } else {
34          if (Input.GetAxis("Turn") < 0.0f) {
35             if (Input.GetAxis("Horizontal") < 0.0f) {
36                transform.Rotate(0, -1.75f, 0);
37             } else {
38                transform.Rotate(0, -1.25f, 0);
39             }
40          }
41
42          if (Input.GetAxis("Turn") > 0.0f) {
43             if (Input.GetAxis("Horizontal") > 0.0f) {
44                transform.Rotate(0, 1.75f, 0);
45             } else {
46                transform.Rotate(0, 1.25f, 0);
47             }
48          }
49
50          if (Input.GetAxis("Vertical") > 0.0f) {
51             touchSpeed = 1.0f;
52             directionVector = new Vector3(Input.GetAxis("Horizontal"), 0, touchSpeed);
53          } else if (Input.GetAxis("Vertical") < 0.0f) {
54             touchSpeed = -0.65f;
55             directionVector = new Vector3(Input.GetAxis("Horizontal"), 0, touchSpeed);
56          } else {
57             touchSpeed -= (touchSpeed * touchSpeedDie);
58             if (touchSpeed < 0.0f) {
59                touchSpeed = 0.0f;
60             }
61             directionVector = new Vector3(Input.GetAxis("Horizontal"), 0, touchSpeed);
62          }
63       }
64    }
65
66    if (directionVector != Vector3.zero) {
67       // Get the length of the direction vector and then normalize it
68       // Dividing by the length is cheaper than normalizing when we already have the length anyway
69       directionLength = directionVector.magnitude;
70       directionVector = directionVector / directionLength;
71
72       // Make sure the length is no bigger than 1
73       directionLength = Mathf.Min(1.0f, directionLength);
74
75       // Make the input vector more sensitive towards the extremes and less sensitive in the middle
76       // This makes it easier to control slow speeds when using analog sticks
77       directionLength = directionLength * directionLength;
78
79       // Multiply the normalized direction vector by the modified length
80       directionVector = directionVector * directionLength;
81    }
82
83    // Apply the direction to the CharacterMotor
84    inputMoveDirection = transform.rotation * directionVector;
85    motor.inputMoveDirection = inputMoveDirection;
86 }

Listing 7-15FPSInputController Main Method Details 1

我们要看的第一个方法是Awake类初始化方法。第 2 行设置了motor字段。接下来,在第 3 行,我们调用基类的准备方法的 player info 版本。它负责设置我们的标准字段、游戏和玩家状态数据,同时还注册操作的结果。在第 4 行,我们检查类的配置是否正确。如果没有,我们写一些日志并退出。如果是这样,我们用来自PlayerState实例的当前值更新aiOn字段,基类字段,p,第 8 行。

我们要回顾的下一个方法是 Unity 引擎回调方法Update。每个游戏帧调用一次这个方法。第 2-4 行现在应该非常熟悉了。代码是为了在类配置由于某种原因失败时对方法进行转义。在第 7–12 行,directionVector被重置为零向量,如果游戏暂停或者没有运行,该方法被转义。AI 控制在第 14–19 行处理,调用第 18 行的UpdateAIFpsController方法,完成计算下一个运动向量的大部分工作。

接下来,让我们看看第 20–33 行的一小段代码。这个代码负责处理触摸输入。在这种情况下,加速度由触摸输入的存在来控制,并且方向通过处理触摸输入的水平变化来处理。请注意第 34–48 行的代码。这段代码负责将 hover racer 左转或右转。

从第 50-64 行,我们确定输入是向前还是向后移动悬停赛车。请注意,在触摸输入的相同过程中,我们不会让汽车倒车,第 28 行。然而,当使用键盘或控制器输入时,我们可以反过来,第 54 行。看一下第 56 行到第 62 行的代码。你能弄清楚这段代码是做什么的吗?当没有检测到触摸时,它会慢慢地将车速降低到零。第 66-85 行的剩余代码块清除了方向向量,对播放器的最终应用发生在第 84-85 行。这就使我们结束了这一审查步骤。

演示:FPSInputController

很像CharacterMotor类,FPSInputController类没有专门的演示场景。相反,任何场景都可以。运行场景时,花点时间在 Unity 编辑器的“层次”面板中找到并展开“起始集”条目。选择子条目HoverCar0,在“检查器”面板中找到“FPS 输入控制器”脚本组件条目并展开它。

现在回去玩游戏和/或演示场景,同时在检查器面板中观察职业属性的变化。这是一个很好的方法来了解实际的输入如何转化为类中的值和悬停赛车的运动。这节课的复习到此结束。接下来,我们将看看鼠标输入处理程序。

课堂回顾:MouseLookNew

MouseLookNew类用于处理鼠标输入并将其转换为旋转数据,这样用户就可以用鼠标操纵悬停赛车。我们将使用以下步骤来复习本课程:

  1. 枚举

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 主要方法详细信息

  5. 示范

这堂课短而甜。我们很快就会完成审查。我们将从查看类的枚举开始。

枚举:MouseLookNew

MouseLookNew类有一个枚举用于讨论。正在讨论的枚举如下。

1 public enum RotationAxes {
2    MouseXAndY = 0,
3    MouseX = 1,
4    MouseY = 2
5 }

Listing 7-16MouseLookNew Enumerations 1

枚举用于描述输入驱动旋转的可用类型。我应该提到的是,仅仅因为它们被列在这里并不意味着游戏支持所有的选项。在任何情况下,RotationAxes枚举都是根据鼠标输入描述某种旋转的便捷方式。接下来,我们将更详细地了解类字段如何模拟鼠标输入。

类字段:MouseLookNew

MouseLookNew类主要有一些字段来帮助模拟一个灵敏度过滤的鼠标输入。让我们看看下面列出的类字段。

public RotationAxes axes = RotationAxes.MouseXAndY;
public float sensitivityX = 12.0f;
public float sensitivityY = 12.0f;
public float minimumX = -360.0f;
public float maximumX = 360.0f;

Listing 7-17MouseLookNew Class Fields 1

列出的第一个字段axes用于描述应该检索哪些轴数据。这是一个描述性字段。它不驱动功能性;它反映了当前的配置。随后,有两个字段minimumXmaximumX,用于描述应用于 X 和 Y 轴输入的灵敏度。接下来的两个字段可用于约束 X 轴值。我将在这里列出剩余的字段。

public float minimumY = -60.0f;
public float maximumY = 60.0f;
public float mouseX = 0f;
public float mouseY = 0f;
public bool aiOn = false;

Listing 7-18MouseLookNew Class Fields 2

列出的前两个字段minimumYmaximumY可用于约束计算的 Y 轴值。接下来,mouseXmouseY字段用于保存 X 和 Y 轴的原始鼠标输入数据。aiOn字段是一个布尔标志,用于切换鼠标的 AI 控制。最后,rigidBodyTmp字段用于引用当前玩家的刚体组件。

相关的方法大纲/类头:MouseLookNew

这个类只有两个方法让我们担心,但是我们将完成相关的方法回顾部分来更彻底。

//Main Methods
void Start();
void Update();

Listing 7-19MouseLookNew Pertinent Method Outline/Class Headers 1

该类的导入语句和声明如下。

using UnityEngine;

public class MouseLookNew : BaseScript {}

Listing 7-20MouseLookNew Pertinent Method Outline/Class Headers 2

注意,这个类扩展了BaseScript类,这意味着它是一个MonoBehaviour,具有一组标准的基本字段,用于插入游戏的游戏状态对象。在下一节中,我们将详细看看主要的方法。

主要方法细节:MouseLookNew

该类的主要方法在下一组中列出。我应该提到,如果设备支持触摸,触摸屏输入(如在屏幕上拖动一个手指)将被解释为鼠标操纵输入。让我们开始写代码吧!

01 void Start() {
02    base.PrepPlayerInfo(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    } else {
07       aiOn = p.aiOn;
08    }
09
10    // Make the rigid body not change rotation
11    rigidBodyTmp = GetComponent<Rigidbody>();
12    if (rigidBodyTmp != null) {
13       rigidBodyTmp.freezeRotation = true;
14    }
15
16    if (Input.touchSupported == true) {
17       sensitivityX = 5.0f;
18       sensitivityY = 5.0f;
19    }
20 }

01 void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (gameState.gamePaused == true) {
07       return;
08    } else if (gameState.gameRunning == false) {
09       return;
10    }
11
12    if (aiOn == true && p != null) {
13       if (p.pause == true) {
14          return;
15       }
16       p.UpdateAiMouseLook();
17    } else {
18       if (gameState.newTouch == false) {
19          mouseX = Input.GetAxis("Mouse X");
20          transform.Rotate(0, mouseX * sensitivityX, 0);
21       }
22    }
23 }

Listing 7-21MouseLookNew Main Method Details 1

这个方法有几个要点供我们讨论。在第 2–6 行,我们通过扩展需要游戏状态和玩家状态数据的BaseScript类,对MonoBehaviour进行了标准初始化,第 2 行。如果类的配置有任何问题,我们退出该方法。否则,我们切换 class' aiOn字段以匹配玩家的状态对象,第 7 行。接下来,在第 11-14 行,我们冻结游戏对象的Rigidbody的旋转,如果它可用的话。一般来说,在这个游戏中,我们不会让玩家在 Y 轴以外的轴上旋转。最后,在第 16–19 行,如果当前设备支持触摸,我们将 X 轴和 Y 轴的灵敏度从 12.0f 降低到 5.0f。

演示:MouseLookNew

MouseLookNew类的最佳演示是玩游戏或运行任何演示,同时在“Inspector”面板中监控当前玩家的MouseLookNew组件。为此,启动游戏或演示场景,然后进入“层级”面板,找到名为StartingSet的条目。展开它并选择HoverCar0子条目。选择它,然后看看“检查”面板。

现在,找到“Mouse Look New”组件条目并展开它。回到游戏继续玩。请注意,当您在玩游戏时使用鼠标时,类的值会发生变化。请留意检查器中显示的是什么类型的值。这就是我们这节课复习的结论。接下来,我们将看看驻留在GameState类中的一些触摸输入代码来结束这一章。

课程回顾:游戏状态(仅触摸输入片段)

GameState类是游戏的主要控制类。因此,它提供了一个关于球员、HUD、菜单系统等数据交换的集中点。因为我们想要集中触摸输入数据,所以我们决定在GameState类的Update方法中包含触摸输入代码。让我们看看它是如何工作的。

01 if (Input.touchCount == 1) {
02    touchScreen = true;
03
04    if (Input.GetTouch(0).phase == TouchPhase.Began) {
05       newTouch = true;
06       accelOn = true;
07    } else if (Input.GetTouch(0).phase == TouchPhase.Moved) {
08       newTouch = false;
09    } else if (Input.GetTouch(0).phase == TouchPhase.Stationary) {
10       newTouch = false;
11    } else if ((Input.GetTouch(0).phase == TouchPhase.Ended || Input.GetTouch(0).phase == TouchPhase.Canceled)) {
12       newTouch = false;
13       accelOn = false;
14    }
15 } else {
16    newTouch = false;
17    accelOn = false;
18 }

Listing 7-22GameState Touch Input Snippet 1

第一行代码用于检测触摸屏上是否有一个手指。Hover Racers 的设计是通过一个手指来工作,当触摸屏幕时加速汽车,当没有触摸屏幕时减速。此外,当触摸屏幕时,向左或向右移动手指将使汽车向左或向右转向。在第 2 行上,touchScreen布尔域被设置为真,以指示激活的触摸屏。

在第 4-6 行,如果新的触摸已经开始,布尔字段newTouchaccelOn被设置为真。如果手指已经移动,第 7 行,新的触摸布尔被设置为假。这表明应该处理触摸转向输入。类似地,如果触摸没有移动,newTouch字段被设置为假。当触摸交互结束时,行 11,然后newTouchaccelOn字段被设置为假。第 16–17 行的最后一位代码用于在没有检测到输入时关闭触摸输入。

第二章结论

我们在这一章中已经讲了很多内容;具体来说,我们回顾了游戏输入处理背后的类。这些类接受触摸、鼠标、键盘和控制器输入,并使用它们来移动、转向和扫射悬停赛车。如果仔细观察,您会注意到所有的输入都是使用描述特定映射的字符串常量来查找的。例如,在MouseLookNew类中,我们使用“鼠标 X”作为鼠标 X 轴输入。关于游戏使用的输入映射的更多信息将在后面的文本中提供。我们在本章中讨论的输入处理程序类如下:

  1. 字符电机:该类由FPSInputController驱动,驱动CharacterContoller组件。该级别主要用于在考虑地面坡度的情况下控制地面的基本运动。在我们的例子中,滑动也是计算的一部分,但是跳跃和重力是无效的。

  2. FPSInputController:这个类及其子类从不同的来源获取用户输入,并将其传递给CharacterMotor类,最终驱动CharacterController和 hover racer 模型。

  3. MouseLookNew:顾名思义,这个类使用鼠标输入来驾驶汽车。如果触摸屏输入在设备上处于活动状态,它还能够处理触摸屏输入来驾驶悬停赛车。

  4. GameState(触摸输入):GameState类由于其集中化,是存储需要共享的数据的好地方。因此,该类用于检测触摸输入,设置几个类字段来指示触摸输入是活动的。

这些类使用的一些数学和向量计算很复杂。如果您计划对其进行更改,请花时间添加日志记录和其他代码来监视和理解您正在更改的代码是如何工作的。请记住,当你在 Unity 编辑器中玩游戏的时候,你可以并且应该监控脚本组件。请注意,当游戏停止时,使用“检查器”面板所做的数值更改将会丢失。在微调输入控件类字段时,请记住这一点。在下一章,我们将看看游戏的菜单系统类。

八、菜单系统类

在这一章中,我们将回顾游戏的菜单系统类,但是在此之前,我想回顾一下游戏类的结构。在这一章中,我们将介绍一个新的基类,它为其他菜单屏幕类提供核心菜单屏幕支持。由于事情变得有点复杂,我们最好在这里回顾一下游戏的职业结构。

img/520710_1_En_8_Fig1_HTML.png

图 8-1

Hover Racers 类继承图 1A Hover Racers 类图显示基类和独立类的扩展

正如您在前面列出的图表中所看到的,Hover Racers 代码库中的大多数类都扩展了BaseScript类,并通过扩展扩展扩展了MonoBehaviour类。这意味着所有那些列在BaseScript类条目下的树形结构中的类都是可以附加到 Unity 游戏对象上的脚本组件。到目前为止,您可以在任何演示场景中看到这一点。注意,有几个菜单系统类扩展了一个新的二阶基类,即BasePromptScript类。在上图中,有两个二阶基类:BasePromptScriptWaterResetScript类。

我们不会在这里讨论任何 Unity 编辑器菜单系统的细节,但是我们会在后面的正文中讨论。实际上有三个类比其他菜单系统类更相似。我们将会详细讨论这些课程。我把它们列在这里。

  1. GamePauseMenu

  2. GameOverMenu

  3. GameExitMenu

游戏还使用了一些其他的菜单系统类,其中一些我们会在本章中稍微讨论一下。

  1. 游戏帮助菜单

  2. GameHUDNewScript

  3. GameHUDNewUiScript

  4. 游戏开始菜单

我们将从前面列出的第一组类扩展的基类开始,即BasePromptScript类。这个脚本很简洁,所以我们将跳过更复杂的课程回顾,只列出完整的课程。

课堂复习:BasePromptScript

如前面列出的图表所示,BasePromptScrip t 类是GameExitMenuGameHelpMenuGameOverMenuGamePauseMenuGameStartMenu类的基类。这个基类扩展了我们熟知的另一个基类BaseScript。因此,任何扩展类都是MonoBehaviour类,通过继承,在它们可用的两个基类中都定义了功能。因为“提示”菜单屏幕都有相似的功能,两个按钮、一个声音效果和一个文本提示等。将功能、字段和方法集中到一个基类中是有意义的。我们将使用以下复习步骤来涵盖本课程。

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

就这样,我们开始吧,好吗?第一部分是类字段。

类字段:BasePromptScript

BasePromptScript类有许多类字段,用于跟踪键盘、鼠标、控制器或触摸输入与菜单屏幕的交互。听起来我们做了很多,但是让我们考虑一下。Unity 游戏引擎支持鼠标,这意味着所有的 UI 按钮都会响应鼠标点击事件。此外,Unity 支持触摸输入,因此触摸屏设备本身将支持按钮点击交互。所以我们可以免费得到这么多。还不错。我们必须关心的输入是键盘和控制器输入,你会看到这反映在类的字段和方法中。

public bool keyBrdInput = false;
public int keyBrdInputIdx = 0;
public int keyBrdInputPrevIdx = -1;
public int keyBrdInputIdxMax = 2;
public int keyBrdInputIdxMin = 0;
public Text txt = null;
public bool btnPressed = false;
public Button btnOne;
public Button btnTwo;

Listing 8-1BasePromptScript Class Fields 1

keyBrdInput字段用于确定输入映射“MenuSelectUp”或“MenuSelectDown”是否被使用。这样,控制器输入和键盘输入可以映射到前面列出的关键字,从而为不同的输入源创建无缝的抽象。这意味着,我们将游戏配置为将某些键盘按键(上下箭头)路由到与控制器方向板的菜单输入上下箭头相同的输入映射。列表中的下一个字段keyBrdInputIdx用于跟踪该类当前高亮显示的 UI 元素。keyBrdInputPrevIdx用于跟踪先前高亮显示的 UI 元素。与这些字段相关的是接下来的两个类字段,keyBrdInputIdxMinkeyBrdInputIdxMax。这些字段用于指定当前菜单屏幕上可用的 UI 元素的最小和最大索引。

扩展了BasePromptScript类的屏幕主要是带有文本提示的 yes 或 no 菜单屏幕。在这种情况下,最大索引设置为 2。这些字段之后是 Unity UI Text类实例txt。这个类用于在菜单屏幕上显示文本提示。布尔标志btnPressed用于跟踪菜单按钮是否被按下。最后,btnOnebtnTwo字段是 Unity UI 类Button的实例,用于显示菜单屏幕的按钮选项。本复习部分到此结束。接下来,我们将看看这个类的相关方法大纲。

相关的方法大纲/类头:BasePromptScript

BasePromptScript类有一些主要的和支持的方法供我们回顾,在这里列出。

//Main Methods
public void Update();
public void InvokeClick(int current);

//Support Methods
public void SetBtnTextColor(int prev, int current);

Listing 8-2BasePromptScript Pertinent Method Outline/Class Headers 1

该类的导入语句和头文件如下。注意,BasePromptScript类扩展了我们之前提到的BaseScript类。另外,花点时间注意一下导入语句,特别是UnityEngine.UI名称空间。

using UnityEngine;
using UnityEngine.UI;

public class BasePromptScript : BaseScript {}

Listing 8-3BasePromptScript Pertinent Method Outline/Class Headers 2

这就把我们带到了本节的结尾。接下来让我们从类的支持方法开始方法回顾。

支持方法详细信息:BasePromptScript

这个类只有一个支持方法供我们讨论,列在这里。

01 public void SetBtnTextColor(int prev, int current) {
02    if (prev == 0) {
03       txt = btnOne.transform.GetChild(0).GetComponent<Text>();
04       txt.color = Color.white;
05    } else if (prev == 1) {
06       txt = btnTwo.transform.GetChild(0).GetComponent<Text>();
07       txt.color = Color.white;
08    }
09
10    if (current == 0) {
11       txt = btnOne.transform.GetChild(0).GetComponent<Text>();
12       txt.color = Color.red;
13    } else if (current == 1) {
14       txt = btnTwo.transform.GetChild(0).GetComponent<Text>();
15       txt.color = Color.red;
16    }
17 }

Listing 8-4BasePromptScript Support Method Details 1

SetBtnTextColor方法用于改变菜单屏幕的按钮文本颜色,以指示通过使用键盘或控制器输入哪个按钮被高亮显示。在这个方法中,在第 2–8 行,我们将先前突出显示的 UI 元素的文本颜色重置为白色。注意,我们获取了对Text组件的引用,然后调整了它的color字段,第 4 行和第 7 行。类似地,我们需要调整当前突出显示的 UI 元素,以表明它是选中的元素。第 10–16 行的代码与前面的代码块相同,只是在这种情况下,我们将文本更改为红色。红色表示突出显示的 UI 元素。在下一节中,我们将介绍这个类的主要方法。

主要方法详细信息:BasePromptScript

BasePromptScript类有两个主要的方法让我们复习。我们将在随后的清单中详细介绍这两者。

01 public void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (keyBrdInput == false) {
07       if (Input.GetButtonUp("MenuSelectUp")) {
08          keyBrdInput = true;
09          keyBrdInputIdx = 0;
10          keyBrdInputPrevIdx = -1;
11          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
12       } else if (Input.GetButtonDown("MenuSelectDown")) {
13          keyBrdInput = true;
14          keyBrdInputIdx = (keyBrdInputIdxMax - 1);
15          keyBrdInputPrevIdx = -1;
16          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
17       }
18    } else {

19       if (Input.GetButtonUp("MenuSelectUp")) {
20          if (keyBrdInputIdx + 1 < keyBrdInputIdxMax) {
21             keyBrdInputPrevIdx = keyBrdInputIdx;
22             keyBrdInputIdx++;
23          } else {
24             keyBrdInputPrevIdx = (keyBrdInputIdxMax - 1);
25             keyBrdInputIdx = 0;
26          }
27          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
28       } else if (Input.GetButtonDown("MenuSelectDown")) {
29          if (keyBrdInputIdx - 1 >= keyBrdInputIdxMin) {
30             keyBrdInputPrevIdx = keyBrdInputIdx;
31             keyBrdInputIdx--;
32          } else {
33             keyBrdInputPrevIdx = keyBrdInputIdx;
34             keyBrdInputIdx = (keyBrdInputIdxMax - 1);
35          }
36          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
37       } else if (Input.GetButtonDown("Submit")) {
38          InvokeClick(keyBrdInputIdx);
39       }
40    }
41 }

01 public void InvokeClick(int current) {
02    if (current == 0) {
03       btnOne.onClick.Invoke();
04    } else if (current == 1) {
05       btnTwo.onClick.Invoke();
06    }
07 }

Listing 8-5BasePromptScript Main Method Details 1

我们要看的第一个方法是类的'Update方法。每个游戏帧都会调用这个方法,它负责调整菜单屏幕的状态以响应用户输入。在第 2–4 行,如您所料,如果类配置失败,方法会被转义而不做任何工作。让我描述一下菜单屏幕是怎么回事,以及它是如何处理键盘和控制器输入的。这将使审查下一个代码块更加有效。当菜单屏幕第一次显示时,没有选定的 UI 元素。如果检测到正确的键盘或控制器输入、箭头键和方向板按钮,则菜单通过选择第一个 UI 元素并高亮显示它来做出反应。从这一点开始,这种类型的进一步输入将改变所选择的 UI 元素。如果突出显示的元素是菜单中的最后一个元素,则第一个 UI 元素会突出显示,反之亦然。

记住这一点,让我们看看负责实现我们刚刚描述的功能的方法。第 6–18 行的代码用于处理初始键盘或控制器输入,并选择一个 UI 元素。如果检测到“MenuSelectUp”输入,则keyBrdInput标志设置为真,第一个菜单按钮高亮显示,第 8–11 行。或者,如果检测到“MenuSelectDown ”,那么keyBrdInput标志也被设置为真,但是我们选择最后一个菜单按钮,第 13–16 行。如果keyBrdInput标志为真,则执行第 18–40 行的下一个代码块。在这种情况下,如果检测到“MenuSelectUp”输入,我们将选定的 UI 元素上移一位,如果我们移过最后一个 UI 元素(第 20–27 行),则循环回到第一个元素。以类似的方式,如果检测到“MenuSelectDown”输入,我们将选定的 UI 元素下移一个,如果我们移过第一个 UI 元素,则循环回到最后一个元素,第 29-36 行。

最后,在第 37–39 行,如果检测到“提交”输入,我们通过调用第 38 行的InvokeClick方法调用当前选中按钮上的点击事件来提交菜单屏幕。集合中的最后一个方法是 Invoke 方法。此方法用于提供一种方式,以编程方式在菜单屏幕的两个按钮中的任何一个上引发单击事件。本复习部分到此结束。在下一节中,我们将看看如何演示这个类的功能。

演示:BasePromptScript

要清楚地演示BasePromptScript类的运行有点困难,因为它是一个由游戏的一些菜单屏幕使用的基类。也就是说,我们当然可以看到一些菜单屏幕在运行。我们最好的选择是运行完整的游戏。如果你在 Unity 编辑器中打开这个项目,并注意到“项目”面板,你应该会看到一个名为“场景”的条目。打开这个文件夹,找到名为“Main13”或“Main14”的场景。这两个场景将开始整个游戏。一旦游戏运行,开始一场比赛,然后在倒计时完成后点击游戏左下角的退出按钮。您应该会看到GameExitMenu屏幕。使用键盘或控制器与它交互,以可视化我们刚刚查看的代码。接下来,我们将看看一些特定的菜单屏幕。

课堂回顾:GamePauseMenu

GamePauseMenu类是一个双按钮提示屏幕的例子,它扩展了我们刚刚讨论过的BasePromptScript类。因此,它从BasePromptScriptBaseScript基类中获得了很多功能。我们将使用以下复习步骤来涵盖本课程。

  1. 相关的方法大纲/类头

  2. 支持方法详细信息

  3. 主要方法详细信息

  4. 示范

我们只有四个审查步骤的原因是因为我们已经扩展了多个基类并继承了它们的功能,从而简化了类的实现。接下来让我们看看课程大纲。

相关的方法大纲/类头:GamePauseMenu

这个类有一个主方法和一些支持方法供我们检查,没有其他的了。让我们看一看,好吗?

//Main Methods
void Start();

//Support Methods
public void PerformResumeGameUI();
public void PerformResumeGame();
public void PerformEndGameUI();
public void PerformEndGame();

Listing 8-6GamePauseMenu Pertinent Method Outline/Class Headers 1

该类的导入语句和声明如下。

using UnityEngine;
using UnityEngine.SceneManagement;
using static GameState;

public class GamePauseMenu : BasePromptScript {}

Listing 8-7GamePauseMenu Pertinent Method Outline/Class Headers 2

注意,GamePauseMenu类扩展了BasePrompScript类,正如我们之前提到的。另外,注意这个类比我们之前看到的有更多的导入,特别是UnityEngine.SceneManager名称空间和GameState导入。“using static GameState”行允许这个类从GameState类中访问枚举。你会看到它们被用在类的方法中,例如,在PerformEndGame方法中使用GameStateIndex.NONE值。

支持方法详细信息:GamePauseMenu

GamePauseMenu类有许多支持方法供我们回顾。让我们开始写代码吧!

01 public void PerformResumeGameUI() {
02    PerformResumeGame();
03 }

01 public void PerformResumeGame() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05    gameState.PlayMenuSound();
06    gameState.HidePauseMenu();
07 }

01 public void PerformEndGameUI() {
02    PerformEndGame();
03 }

01 public void PerformEndGame() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05    PlayerPrefs.SetInt("GameStateIndex", (int)GameStateIndex.NONE);
06    PlayerPrefs.Save();
07    gameState.PlayMenuSound();
08    gameState.ResetGame();
09    SceneManager.LoadScene(gameState.sceneName);
10 }

Listing 8-8GamePauseMenu Support Method Details 1

我们要回顾的这组支持方法负责处理点击输入事件,因为 UI 系统被设计成允许不同的输入来激活按钮点击事件。为了支持这一点,按钮的功能从输入事件处理程序中抽象出一个层次,我们很快就会看到。我们细读的第一个方法是PerformResumeGameUI法。该方法直接连接到GamePauseMenu屏幕的按钮上。当点击菜单屏幕按钮时,PerformResumeGameUI方法调用PerformResumeGame方法来完成恢复游戏的实际工作。

这允许我们直接调用PerformResumeGame方法来完成相同的任务,以响应键盘和控制器输入。列出的下一个方法是PerformResumeGame,负责在游戏暂停后恢复游戏。在第 2–4 行,我们有标准的转义码,如果类的配置有问题,它会阻止方法做任何工作。在第 5 行,我们播放了一个菜单声音,来自GameState类实例,表明用户输入已经收到,在第 6 行,我们隐藏了暂停菜单,继续游戏。请注意,菜单屏幕非常依赖于由GameState类提供的集中功能。

这个条目后面是PerformEndGameUi方法。该方法直接连接到菜单屏幕上的按钮,并在响应用户输入时被调用。它调用PerformEndGame方法来完成结束游戏的实际工作。请注意,在这两种情况下,直接用户输入事件处理程序都必须再进行一次方法调用来执行必要的工作。这是我前面提到的一个抽象层次。

标准转义码在PerformEndGame方法的第 2–4 行。为了正确地结束游戏,我们需要重置游戏的“GameStateIndex”玩家偏好并保存更改,第 5–6 行。接下来,我们播放一个菜单声音效果来指示用户输入被接收,第 7 行,并在第 8 行重置游戏。我们需要做的最后一件事是重置整个场景,这是通过第 9 行的方法调用完成的。这个调用将要求场景管理器为我们重新加载当前场景。在下一节,我们将看看这个类的主要方法。

主要方法详细信息:GamePauseMenu

GamePauseMenu类有一个主要的方法供我们回顾。在文本的这一点上,它应该看起来非常熟悉。不过,我们还是会谈到的。最好是彻底的。

01 void Start() {
02    base.Prep(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07 }

Listing 8-9GamePauseMenu Main Method Details 1

这是一个简单的Start方法的例子。该类在第 2 行准备好,如果有任何地方出错,该方法会写一些日志并退出,第 4–5 行。除此之外就没什么了。接下来的复习部分,看看我们能不能拿出一个像样的这个类的演示。

演示:游戏暂停

在行动中演示GamePauseMenu的最好方式是运行主游戏。找到“项目”面板,找到“场景”文件夹,并打开它。寻找名为“Main13”或“Main14”的场景。打开场景并运行它。您需要通过点击“赛道 1”或“赛道 2”按钮来开始一场由玩家控制的比赛。比赛倒计时开始后,点击 Unity 编辑器以外的其他应用。请注意,游戏暂停菜单屏幕弹出,游戏停止。

返回 Unity 编辑器,使用向上和向下键盘按钮更改所选的暂停菜单按钮。请注意,您可以使用鼠标或回车键单击按钮。这是我们工作中的抽象层。GameOverMenuGameExitMenu类与我们刚刚复习的类非常相似。我让你自己去看。在你继续学习之前,确保你已经很好的理解了这些课程。

课堂回顾:游戏助手菜单

GameHelpMenu用于显示一系列帮助屏幕,描述关于 Hover Racers 游戏的不同细节。这个菜单屏幕比我们之前看到的要复杂一些。我们将使用以下步骤来复习这门课。

  1. 类别字段

  2. 相关的方法大纲/类头

  3. 支持方法详细信息

  4. 主要方法详细信息

  5. 示范

我们将看到一些与 UI 交互、选择、点击等类似的代码,就像我们之前看到的一样,但是稍微复杂一些。

类字段:游戏帮助菜单

GameHelpMenu有许多用于控制用户与帮助菜单交互的类字段。不同的帮助菜单屏幕用于显示不同的图像,这些图像提供了关于如何玩游戏的信息。

//***** Class Fields: Images *****
public Image help1 = null;
public Image help2 = null;
public Image help3 = null;
public Image help4 = null;
public Image help5 = null;
public Image help6 = null;
public Image help7 = null;
public Image help8 = null;

//***** Internal Variables *****
private Image img = null;
private int idx = 0;
private int MAX_INDEX = 8;

//***** Class Fields *****
public Button btnPrev = null;
public Button btnNext = null;
public Button btnThree = null;

Listing 8-10GameHelpMenu Class Fields 1

前八个条目都是 Unity Image实例,用于显示关于如何玩游戏的不同帮助屏幕。这些图像在 Unity 编辑器中使用“检查器”面板进行配置,而不是以编程方式进行配置。下面的条目img用作临时占位符,帮助更改菜单不同按钮的文本颜色。MAX_INDEX字段用于指示该菜单屏幕显示的帮助图像的最大数量。其余的类字段是用于导航帮助菜单屏幕的按钮。接下来我们将查看相关的方法大纲。

相关的方法大纲/类头:GameHelpMenu

GameHelpMenu类的相关方法概述如下。

//Main Methods
void Start();
new void Update();
public new void InvokeClick(int current);
public new void SetBtnTextColor(int prev, int current);

//Support Methods
public void EnablePrev();
public void DisablePrev();
public void EnableNext();
public void DisableNext();
private void ShowHelpScreen(int i);

//Support Methods: Input Handlers
public void PerformMainMenuUI();
public void PerformMainMenu()
public void PerformNextUI();
public void PerformNext();
public void PerformPrevUI();
public void PerformPrev();

Listing 8-11GameHelpMenu Pertinent Method Outline/Class Headers 1

在这个节骨眼上,实际上有几个要点我想讨论一下。首先,请注意,有些方法条目用 new 关键字进行了修饰。这是因为这些方法是由GameHelpMenu类扩展的基类之一定义的。new 关键字用于告诉编译器该方法正在被重定义。我想提到的另一件事是,我们有一个新的方法部分,即“Support Methods: Input Handlers”。这样做的原因是有很多输入处理程序,我认为我们应该把它们分开,因为它们的功能和用途是相似的。接下来,我们将看看下面清单中详细列出的类的导入和声明。

using UnityEngine;
using UnityEngine.UI;

public class GameHelpMenu : BasePromptScript {}

Listing 8-12GameHelpMenu Pertinent Method Outline/Class Headers 2

这个类使用来自UnityEngineUnityEngine.UI名称空间的导入。注意,GameHelpMenu类扩展了BasePromptScript基类。我们以前见过这个类的使用。它向扩展它的菜单类添加了一些默认字段和功能。

支持方法详情:GameHelpMenu

GameHelpMenu类有许多支持方法。这些方法有两种风格:支持方法和输入处理程序方法。我们将从下面列出的标准支持方法开始。

01 public void EnablePrev() {
02    if (btnPrev != null) {
03       btnPrev.interactable = true;
04    }
05 }

01 public void DisablePrev() {
02    if (btnPrev != null) {
03       btnPrev.interactable = false;
04    }
05 }

01 public void EnableNext() {
02    if (btnNext != null) {
03       btnNext.interactable = true;
04    }

05 }

01 public void DisableNext() {
02    if (btnNext != null) {
03       btnNext.interactable = false;
04    }
05 }

01 private void ShowHelpScreen(int i) {
02    if (help1 != null) {
03       help1.gameObject.SetActive(false);
04    }
05
06    if (help2 != null) {
07       help2.gameObject.SetActive(false);
08    }
09
10    if (help3 != null) {
11       help3.gameObject.SetActive(false);
12    }
13
14    if (help4 != null) {
15       help4.gameObject.SetActive(false);
16    }
17
18    if (help5 != null) {
19       help5.gameObject.SetActive(false);
20    }
21
22    if (help6 != null) {
23       help6.gameObject.SetActive(false);
24    }
25
26    if (help7 != null) {
27       help7.gameObject.SetActive(false);
28    }
29
30    if (help8 != null) {
31       help8.gameObject.SetActive(false);
32    }
33
34    if (i == 0) {

35       help1.gameObject.SetActive(true);
36       DisablePrev();
37       EnableNext();
38    } else if (i == 1) {
39       help2.gameObject.SetActive(true);
40       EnablePrev();
41       EnableNext();
42    } else if (i == 2) {
43       help3.gameObject.SetActive(true);
44       EnablePrev();
45       EnableNext();
46    } else if (i == 3) {
47       help4.gameObject.SetActive(true);
48       EnablePrev();
49       EnableNext();
50    } else if (i == 4) {
51       help5.gameObject.SetActive(true);
52       EnablePrev();
53       EnableNext();
54    } else if (i == 5) {
55       help6.gameObject.SetActive(true);
56       EnablePrev();
57       EnableNext();
58    } else if (i == 6) {
59       help7.gameObject.SetActive(true);
60       EnablePrev();
61       EnableNext();
62    } else if (i == 7) {
63       help8.gameObject.SetActive(true);
64       EnablePrev();
65       DisableNext();
66    }
67 }

Listing 8-13GameHelpMenu Support Method Details 1

前几个方法用于启用或禁用表单的一些 UI 元素。在这种情况下,我们有方法来控制启用或禁用nextprev按钮。这些方法非常简单,本质上是相似的。在继续下一步之前,花点时间仔细检查一下,确保你理解了它们。我们要看的下一个方法是ShowHelpScreen方法。

ShowHelpScreen方法类似于我们刚刚看到的启用/禁用方法,除了它被设计为与所有菜单的帮助图像一起工作,它负责禁用所有图像,然后只启用指定的图像。在该方法的第一个代码块(第 2–32 行)中,检查每个图像字段的空值,如果定义了空值,则随后将其禁用。第 34–66 行的下一个代码块用于根据图像列表中的当前位置启用指定的图像以及相关的“上一页”和“下一页”按钮。第一个和最后一个图像分别禁用“上一个”和“下一个”按钮。这就结束了该类的基本支持方法。在下一节中,我们将看看这个类的输入处理程序支持方法。

输入处理程序支持方法详细信息:GameHelpMenu

我们要介绍的第二组支持方法是输入处理程序支持方法。这些方法遵循与我们之前在两个按钮提示菜单屏幕中看到的相似的模式。一个例子是GamePauseMenu类。我在这里详细介绍一下方法。

01 public void PerformMainMenuUI() {
02    PerformMainMenu();
03 }

01 public void PerformMainMenu() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05    gameState.PlayMenuSound();
06    gameState.ShowStartMenu();
07    gameState.HideHelpMenu();
08 }

01 public void PerformNextUI() {
02    PerformNext();
03 }

01 public void PerformNext() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }

05    gameState.PlayMenuSound();
06    if ((idx + 1) < MAX_INDEX) {
07       idx++;
08    }
09    ShowHelpScreen(idx);
10 }

01 public void PerformPrevUI() {
02   PerformPrev();
03 }

01 public void PerformPrev() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05    gameState.PlayMenuSound();
06    if ((idx - 1) >= 0) {
07       idx--;
08    }
09    ShowHelpScreen(idx);
10 }

Listing 8-14GameHelpMenu Input Handler Support Method Details 1

GameHelpMenu菜单有三个按钮,所以我们将有三组两个方法,每个都遵循我们在GamePauseMenu类的输入处理程序中看到的相同抽象。在这种情况下,我们有主菜单、上一个和下一个按钮。请注意,在每种情况下,实际的工作都是由本地类方法完成的,除非正确配置了类,否则每个这样的方法都不会做任何工作。仔细检查这些方法,确保它们对你有意义,然后再继续。

主要方法详细信息:GameHelpMenu

这个类有四个方法供我们阅读。有两种MonoBehaviour生命周期回调方法,StartUpdate,以及两种 UI 管理方法,InvokeClickSetBtnTextColor

01 void Start() {
02    keyBrdInputIdxMax = 3;
03    base.Prep(this.GetType().Name);
04    if (BaseScript.IsActive(scriptName) == false) {
05       Utilities.wrForce(scriptName + ": Is Deactivating...");
06       return;
07    }
08 }

01 new void Update() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (keyBrdInput == false) {
07       if (Input.GetButtonUp("MenuSelectUp")) {
08          keyBrdInput = true;
09          if (idx == 0) {
10             keyBrdInputIdx = 1;
11             keyBrdInputPrevIdx = -1;
12          } else {
13             keyBrdInputIdx = 0;
14             keyBrdInputPrevIdx = -1;
15          }
16          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
17       } else if (Input.GetButtonDown("MenuSelectDown")) {
18          keyBrdInput = true;
19          if (idx == MAX_INDEX - 1) {
20             keyBrdInputIdx = (keyBrdInputIdxMax - 1);
21             keyBrdInputPrevIdx = -1;
22          } else {
23             keyBrdInputIdx = 1;
24             keyBrdInputPrevIdx = -1;
25          }
26          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
27       }
28    } else {

29       if (Input.GetButtonDown("MenuSelectUp")) {
30          if (keyBrdInputIdx + 1 < keyBrdInputIdxMax) {
31             keyBrdInputPrevIdx = keyBrdInputIdx;
32             keyBrdInputIdx++;
33          } else {
34             keyBrdInputPrevIdx = (keyBrdInputIdxMax - 1);
35             keyBrdInputIdx = 0;
36          }
37
38          if (idx == 0 && keyBrdInputIdx == 0) {
39             keyBrdInputIdx++;
40          } else if (idx == (MAX_INDEX - 1) && keyBrdInputIdx == (keyBrdInputIdxMax - 1)) {
41             keyBrdInputIdx = 0;
42          }
43
44          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
45       } else if (Input.GetButtonDown("MenuSelectDown")) {
46          if (keyBrdInputIdx - 1 >= keyBrdInputIdxMin) {
47             keyBrdInputPrevIdx = keyBrdInputIdx;
48             keyBrdInputIdx--;
49          } else {
50             keyBrdInputPrevIdx = keyBrdInputIdx;
51             keyBrdInputIdx = (keyBrdInputIdxMax - 1);
52          }
53
54          if (idx == 0 && keyBrdInputIdx == 0) {
55             keyBrdInputIdx = (keyBrdInputIdxMax - 1);
56          } else if (idx == (MAX_INDEX - 1) && keyBrdInputIdx == (keyBrdInputIdxMax - 1)) {
57             keyBrdInputIdx--;
58          }
59
60          SetBtnTextColor(keyBrdInputPrevIdx, keyBrdInputIdx);
61       } else if (Input.GetButtonDown("Submit")) {
62          InvokeClick(keyBrdInputIdx);
63       }
64    }
65 }

Listing 8-15GameHelpMenu Main Method Details 1

我们要看的第一个方法是Start方法。第一行代码(第 2 行)用于设置菜单的最大选择索引,以便在使用键盘或控制器导航帮助菜单的按钮时控制突出显示的 UI 元素。剩下的代码行,第 3–7 行,执行我们已经多次看到的标准类配置。我们要看的下一个方法是类的'Update方法。

正如所料,第 2–4 行的代码用于在类的配置遇到问题时阻止方法执行。第 6 行 if 语句的第一个分支(从第 7 行到第 27 行)意在将菜单高亮显示的 UI 元素从初始状态设置为 false。来自键盘或控制器的用户输入将导致第一个或最后一个可选 UI 元素被高亮显示。从第 29 行到第 63 行的主 if 语句的第二个分支用于在选择初始 UI 元素后处理键盘或控制器输入。这段代码支持向前或向后循环可选的 UI 元素。最后,在第 61–63 行,检测到“提交”输入,调用InvokeClick方法,将当前选择的菜单项索引作为参数。该方法将调用目标按钮上的 click 事件。

01 public new void InvokeClick(int current) {
02    if (current == 0) {
03       if (idx > 0) {
04          btnOne.onClick.Invoke();
05       }
06    } else if (current == 1) {
07       btnTwo.onClick.Invoke();
08    } else if (current == 2) {
09       if (idx < MAX_INDEX - 1) {
10          btnThree.onClick.Invoke();
11       }
12    }
13 }

01 public new void SetBtnTextColor(int prev, int current) {
02    if (prev == 0) {
03       img = btnOne.GetComponent<Image>();
04       img.color = Color.white;
05    } else if (prev == 1) {

06       txt = btnTwo.transform.GetChild(0).GetComponent<Text>();
07       txt.color = Color.black;
08    } else if (prev == 2) {
09       img = btnThree.GetComponent<Image>();
10       img.color = Color.white;
11    }
12
13    if (current == 0) {
14       img = btnOne.GetComponent<Image>();
15       img.color = Color.red;
16    } else if (current == 1) {
17       txt = btnTwo.transform.GetChild(0).GetComponent<Text>();
18       txt.color = Color.red;
19    } else if (current == 2) {
20       img = btnThree.GetComponent<Image>();
21       img.color = Color.red;
22    }
23 }

Listing 8-16GameHelpMenu Main Method Details 2

前面列出的第二组主要方法包含两个方法。这些方法用于调用按钮单击事件,并设置菜单按钮的文本颜色以指示选定的 UI 元素。我们要研究的第一个方法是InvokeClick方法。我们以前在BasePromptScriptGamePauseMenu类中见过这个方法。注意方法声明中的new关键字。这向编译器表明,这个继承自BasePromptScript类的方法在这里被重新定义。如果我们被要求点击第一个按钮,方法的第 2 行,这对应于“上一步”按钮。请注意,只有当帮助菜单屏幕索引大于 0 时,我们才调用 click 事件。这意味着我们不在第一个屏幕上,所以我们可以返回。

在第 6–8 行,如果当前选择的按钮索引是 1,对应于“主菜单”按钮,我们不加判断地处理点击事件。如果有更多的帮助菜单屏幕要查看,第 8–12 行的最后一段代码执行“下一步”按钮单击。下一个方法SetBtnTextColor负责设置指定按钮上的按钮文本颜色。它还重置上一个按钮的文本颜色。文本颜色的变化用于突出显示菜单按钮,表示它已被选中。第 2–11 行的小代码块用于恢复先前选择的按钮的文本颜色,而第 13–22 行的代码块用于设置当前选择的按钮的文本颜色。这就是本复习部分的结论。在下一节中,我们将看一看班级的示范。

演示:GameHelpMenu

为了演示GameHelpMenu类的运行,我们最好运行完整的游戏,场景“Main13”或“Main14 ”,点击开始菜单上的“help”按钮启动帮助菜单。在 Unity 编辑器中打开游戏项目,注意“项目”面板,找到“场景”文件夹。打开一个主场景,按下 Unity 编辑器的播放按钮运行游戏。当游戏开始时,点击上面提到的“帮助”按钮,启动帮助菜单屏幕。尝试使用键盘、控制器或鼠标与菜单的 UI 元素进行交互。当你这样做的时候,记住你刚刚检查的代码。

剩余菜单类别

其余的菜单系统类如下所示:

  1. 游戏开始菜单

  2. GameHUDNewScript

  3. GameHUDNewUiScript

    我不会在这里详细回顾这些类。剩下的三个类与我们刚刚复习的类非常相似。复习它们并不能获得任何新的常识;然而,你应该仔细阅读它们,确保你熟悉它们。请务必这样做。通过使用本章演示部分概述的方法,尝试在实际游戏中查看每个菜单屏幕。

就 Unity 编辑器中的对象和组件而言,我们没有涉及到设置菜单屏幕,但是我们将在稍后的文本中介绍。这就引出了本章的结论。在我们继续之前,我想回顾一下我们讲过的内容。

第二章结论

在这一章,我们设法涵盖了游戏菜单系统的主要方面。在这个过程中,我们从游戏规约列表中去掉了几个点。让我们总结一下本章所讲的内容。

  1. BasePromptScript:大部分游戏的菜单系统使用的基类。该类包含核心的共享功能,以简化扩展类的实现。

  2. GamePauseMenu:这个类是一个双选项菜单屏幕的具体实现,它使用BasePromptScript作为基类。我们还回顾了 UI 事件抽象,通过类调用菜单屏幕按钮上的点击事件的能力来证明。

  3. 这个类是一个更复杂的菜单屏幕实现的例子。虽然这个类也扩展了BasePromptScript类,但它覆盖了基类的大部分功能来支持三按钮菜单屏幕。

虽然我们没有涵盖游戏中的每个菜单屏幕,但我们涵盖了一组核心示例,这些示例带我们了解了菜单系统实现的关键公共方面。请务必查看一下我们在这里没有介绍的课程。仔细阅读它们,并跟随完整游戏场景的实际菜单屏幕,“Main13”或“Main14”。我们几乎完成了文本的代码部分,但是我们还有很多重要的内容要讲,所以坚持一下。在下一章,我们将开始回顾游戏的核心,管理玩家和游戏状态的类。

九、玩家和游戏状态类:第一部分

在这一章中,我们将看看负责跟踪玩家和游戏状态的类。有很多代码需要我们回顾,所以我把回顾分成了两章。这两章复习所涉及的课程如下:

  1. PlayerInfo(第 09 章:第一部分)

  2. TrackScript(第章第 9 :第一部分)

  3. PlayerState(第章第 9 :第一部分)

  4. 游戏状态(第十章第十章:第二部分)

列出的前两个类别PlayerInfoTrackScript附属于随后的两个类别PlayerStateGameState。因为前两个类非常简单和直接,我们将从它们开始。我们开始吧!

课堂回顾:PlayerInfo

正如我们之前见过几次的,类负责松散地关联一个 Unity 游戏对象和一个玩家。为此,该类在存储在GameState类中的可用播放器数组中保存相关播放器的索引。这个课比较短,我就在这里枚举一下吧。

01 using UnityEngine;
02
03 public class PlayerInfo : MonoBehaviour {
04    public int playerIndex = 0;
05 }

Listing 9-1PlayerInfo Class Review 1

是的,就这么简单。这其实非常简单。因为PlayerInfo类扩展了MonoBehaviour类,所以它是一个脚本组件,可以附加到 Unity 游戏对象上。当在 Unity 编辑器中分配给一个游戏对象时,你可以设置playerIndex字段的值。这些信息可以从父游戏对象中找到,并用来查找存储在游戏主类GameState中的玩家的PlayerState对象。在我们回顾交互类的时候,我们已经看到过这个用例。接下来,我们将看看TrackScript类。这个类是另一个用于保存游戏状态相关信息的MonoBehaviour,更具体地说是与当前赛道相关的设置。

课堂回顾:音轨脚本

TrackScript类是另一个简单的状态类,用于保存当前赛道的基本配置信息。这堂课又短又甜,所以我们就把它完整地列在这里。

1 using UnityEngine;
2
3 public class TrackScript : MonoBehaviour {
4    public int index = 0;
5    public bool headLightsOn = false;
6    public int laps = 3;
7    public string sceneName = "";
8 }

Listing 9-2TrackScript Class Review 1

预计TrackScript将驻留在GameState Unity 游戏对象上,紧挨着GameStatePlayerState组件的实例,如此处所示。

img/520710_1_En_9_Fig1_HTML.png

图 9-1

GameState Unity 游戏对象配置描述 GameState 游戏对象设置的屏幕截图。它显示了 GameState、PlayerState 和 TrackScript MonoBehaviours 脚本组件,根据需要附加到相同的父游戏对象

GameState类负责加载和处理存储在关联的TrackScript组件中的数据。职业的第一个领域,index,目前没有被游戏使用。随意实现一些索引特定的代码,如大气效果或不同的背景音乐。列出的下一个字段headLightsOn是一个布尔标志,用于指示当前赛道是否应该打开悬停赛车的前灯。随后,laps字段用于显示当前赛道的圈数。我说建议是因为赛道难度和模式也会影响给定赛道的圈数。最后,sceneName字段可用于为当前轨道/场景提供名称。我们要看的下一个类是PlayerState类。这个类是一个怪物,所以要准备好一个冗长的类视图。

课堂回顾:玩家状态

PlayerState类是通过BaseScript类的扩展得到的MonoBehaviour类。它用于在整个游戏过程中跟踪玩家的状态。因此,该类有大量字段跟踪与汽车运动和状态相关的各种值。因为PlayerState类非常复杂,我们将使用更结构化的审查过程,并按照以下步骤来审查它:

  1. 静态/常量/只读类成员

  2. 类别字段

  3. 相关的方法大纲/类头

  4. 支持方法详细信息

  5. 主要方法详细信息

  6. 示范

没有相关的枚举可言,所以我们将省略这一部分。我应该花点时间提一下,有很多,几乎太多,类字段要回顾。不要对此感到不知所措,我们会慢慢地、详细地介绍一切。不要以为第一遍读完就一定要全记在脑子里。在处理游戏代码时,你很可能需要参考几次这个类的评论。在大多数情况下,您根本不需要调整这些字段,所以就把它看作是我们的彻底。就这样,让我们开始吧。

静态/常量/只读类成员:PlayerState

PlayerState类有许多静态和只读的类字段。这些字段用于查找资源、跟踪某些修饰符以及定义正在进行的比赛类型的值。

public static bool SHOW_AI_LOGIC = true;
public static readonly int TYPE_SPEED_SLOW = 0;
public static readonly int TYPE_SPEED_NORM = 1;
public static readonly int TYPE_SPEED_BOOST = 2;
public static readonly int TYPE_PLAYER_HUMAN = 0;
public static readonly int TYPE_PLAYER_COMPUTER = 1;
public static readonly int TYPE_CAR_HOVER_GREEN = 0;
public static readonly int TYPE_CAR_HOVER_BLACK = 3;
public static readonly int TYPE_CAR_HOVER_RED = 4;
public static readonly int TYPE_CAR_HOVER_PURPLE = 5;
public static readonly int DEFAULT_POSITION = 0;
public static readonly float DEFAULT_MAX_SPEED = 200.0f;

public static readonly float DEFAULT_GRAVITY = 11.0f;
public static float LIMIT_MAX_SPEED = 300.0f;

Listing 9-3PlayerState Static/Constants/Read-Only Class Members 1

前面列出了我们要查看的第一组静态类字段。第一个条目SHOW_AI_LOGIC,用于控制 AI 控制的汽车驾驶计算的显示。这实际上是一个非常酷的功能。在运行游戏的完整版本,场景“Main13”或“Main14”之前,将该字段的值设置为 true。打开场景并运行它。当 AI 悬浮赛车在赛道上奔跑时,切换到 Unity 编辑器的“场景”面板。确保你有一个自上而下的赛道视图,并缩小以便你能看到赛道的大部分。当盘旋赛车在赛道上前进时,跟随他们,你会注意到从赛车到下几个路点的小绿线。这些线是汽车的 AI 驾驶逻辑计算的一部分。

既然我们已经讨论了这个问题,让我们回到课堂上来。接下来是三个条目,用于表示慢速、正常或加速速度。接下来的两个条目,以“TYPE_PLAYER_”开头的,用来表示当前玩家的类型,AI 还是人类控制的。随后,有四个条目用于指示给定玩家使用的赛车类型。DEFAULT_POSITION字段用于为当前玩家提供默认位置值。接下来的两个条目,DEFAULT_MAX_SPEEDDEFAULT_GRAVITY,用于为悬停赛车的重力和最大速度值提供默认值。这个集合中列出的最后一个条目用于表示对玩家最大速度的限制。让我们继续查看下一组字段。

public readonly int MAX_IS_MISS_TIME = 2;
public readonly int MAX_IS_SHOT_TIME = 2;
public readonly int MAX_IS_HIT_TIME = 2;
public readonly int MAX_IS_LAP_COMPLETE_TIME = 6;
public static readonly int DEFAULT_LIFE_AMOUNT = 3;
public readonly int INVINC_SECONDS = 10;
public static readonly int DEFAULT_DAMAGE_AMOUNT = 0;
public readonly float MIN_STUCK_DISTANCE = 30.0f;
public readonly float MIN_STUCK_TIME = 3.0f;
public readonly float MIN_WAYPOINT_DISTANCE = 30.0f;
public readonly int MAX_SPEED_BONUS_DRAFTING = 4;
public readonly int MAX_SPEED_BONUS_PASSING = 20;
public readonly float MAX_SMOKE_TIME = 1000.0f;
public readonly int MAX_GAINED_LIFE_TIME = 2;
public static readonly string AUDIO_CLIP_CAR_SOUND1 = "CarAirNonGapless";

public static readonly string AUDIO_CLIP_CAR_SOUND2 = "car_idle_lp_01";

public static readonly string AUDIO_CLIP_GUN_SHOT = "explosion_short_blip_rnd_01";

Listing 9-4PlayerState Static/Constants/Read-Only Class Members 2

前面列出的静态/只读类字段集以四个最大计时值开始。这些字段以文本“MAX_IS_”开头。这些字段保存战斗模式通知未命中、命中、命中和单圈完成时间通知的最大计时值。接下来,我们有DEFAULT_LIFE AMOUNT字段,它为玩家设置了总生命值。INVINC_SECONDS字段被用来设置无敌修改器激活的最大时间限制。在这个区域之后是DEFAULT_DAMAGE_AMOUNT区域,用来准备每个玩家的伤害点。

随后,我们有一组最小值只读字段。MIN_STUCK_DISTANCE字段表示用于确定当前玩家的悬停赛车是否没有移动足够的距离并且现在被卡住的距离值。类似地,MIN_STUCK_TIME字段用于设置玩家的悬停赛车没有移动到被标记为卡住的最短时间。这两个字段都用于确定玩家是否被卡住。最后一个最小值输入,MIN_WAYPOINT_DISTANCE,是在被认为是航迹上的“下一个”航路点之前,你离一个航路点的最小距离。

接下来我们要看的四个字段是最大值,用于设置悬停赛车手一些行为的限制。这些条目的前两个用于设置从起草和通过另一个悬停赛车获得的速度奖励的限制。下一个最大值字段MAX_SMOKE_TIME,用于设置射击烟雾效果持续时间的限制。这个粒子效果已经被注释掉了,留给读者去实现。类似地,MAX_GAINED_LIFE_TIME字段用于设置寿命延长通知持续时间的限制。

这组静态字段中的最后三个条目用于定位具有指定名称的AudioSource组件。这就是静态类成员回顾部分的结论。在下一部分,我们将开始复习该课程的剩余字段。

类字段:PlayerState

PlayerState类有许多字段留给我们回顾。他们有很多人。它们用于控制、跟踪和模拟人工智能和人类控制的玩家的悬停赛车行为的所有方面。我们开始吧!

//***** Class Fields *****
public int index = 0;
public GameObject player;
public bool active = true;
public float offTrackSeconds = 6.0f;
public float wrongDirectionSeconds = 6.0f;

//***** Input Class Fields *****
public CharacterController controller

;
public CharacterMotor cm;
public MouseLookNew mouseInput;
public FPSInputController fpsInput;
public Transform home;
private bool hasSetHome = false;
public bool pause = false;

//***** Car Descriptor Class Fields *****
//0 = slow, 1 = norm, 2 = boost
public int speedType = TYPE_SPEED_SLOW;

//0 = human, 1 = computer
public int playerType = TYPE_PLAYER_HUMAN;

//0 = green hover, 1 = red hover, 2 = black hover,
//3 = black hover, 4 = red hover, 5 = purple hover
public int carType = TYPE_CAR_HOVER_GREEN;

//***** Speed Class Fields *****
public float speed = 0.0f;
public float speedPrct = 0.0f;
public float speedPrctLimit = 0.0f;
public float maxSpeed = DEFAULT_MAX_SPEED;
public int position = DEFAULT_POSITION;
public float gravity = DEFAULT_GRAVITY;

Listing 9-5PlayerState Class Fields 1

我们要查看的第一组字段从一组通用类字段开始。第一个字段是最重要的index字段。该字段用于指示该玩家状态实例与可用玩家数组中的哪个玩家相关联。下一个字段是一个GameObject实例player,用于保存对代表玩家的 Unity 游戏对象的引用。在这种情况下,这是悬停赛车模型。

布尔标志active用于指示玩家是否活动。该字段之后是offTrackSeconds字段。该字段描述在显示脱离轨道通知之前,车辆脱离轨道的秒数。随后,wrongDirectionSeconds表示在显示错误方向通知之前,汽车必须朝错误方向行驶的秒数。

我们要查看的下一组字段是输入类字段。controller字段是当前玩家的一个CharacterController实例。接下来,有一个CharacterMotor实例,cm。我们还有MouseLookNewmouseInputFPSInputControllerfpsInput的实例来完善我们的控制类。home字段是一个Transform实例,用于记录玩家的家或起始位置。

布尔标志hasSetHome用于指示是否已经设置了初始位置。接下来,pause字段是指示播放器是否暂停的布尔标志。下面一组类字段是汽车描述符类字段。第一个这样的字段是speedType字段。该字段的值为 0、1 或 2,表示速度缓慢、正常或加速。类似地,playerType字段是一个整数值,0 代表人类玩家,1 代表计算机玩家。

该组中的最后一个字段是carType字段。该字段用于描述玩家驾驶的汽车类型。在我们的例子中,它描述了悬停赛车的颜色。这些值由前面列出的字段定义记录在注释中。我们必须查看的最后一组字段是速度等级字段。这些字段用于表示不同的速度相关特性、值和限制。speed字段代表玩家汽车的速度。speedPrct字段用于表示汽车当前行驶速度占悬停赛车最大速度的百分比。speedPrctLimit字段类似,只是它表示汽车当前行驶的LIMIT_MAX_SPEED值的百分比。

maxSpeed字段用于保存汽车的当前最大速度。接下来,position字段表示赛车在比赛中的当前位置,gravity字段用于表示赛车的重力。这将我们带到下一组要查看的类字段。我把它们列在这里。

//***** Time Class Fields *****
public string time;
public int timeNum;
public float totalTime = 0;
public float hour = 0;
public float min = 0;
public float s = 0;
public float ms = 0;

//***** Off Track Class Fields *****
public bool offTrack = false;
public float offTrackTime = 0.0f;

//***** Wrong Direction Class Fields *****
public bool wrongDirection = false;
public float wrongDirectionTime = 0.0f;

//***** Skipped Waypoint Class Fields *****
public bool skippedWaypoint = false;
public float skippedWaypointTime = 0.0f;

//***** Cameras and Objects Class Fields *****
public GameObject gun;
public GameObject gunBase;
public new Camera camera;
public Camera rearCamera;
public GameObject car;
public GameObject carSensor;

Listing 9-6PlayerState Class Fields 2

我们要查看的下一组类字段是当前玩家在赛道上比赛的时间。第一个条目time,是玩家的圈速的字符串表示。接下来的字段timeNum,是一个单一的大整数表示玩家的圈速。接下来,totalTime字段代表给定圈上的总持续时间,以毫秒为单位。随后会列出当前曲目时间的各个组成部分。第一个条目hour表示在当前曲目上花费的小时数。希望不要太多。

接下来,min类字段记录在当前赛道上花费的分钟数,而接下来的两个字段,sms,用于指示在当前赛道上花费的时间的秒和毫秒部分。接下来的三组字段用于跟踪玩家的偏离轨迹、错误方向和跳过的路点状态。下一个要审查的是跑道外的场地。该组中的第一个条目是offTrack字段。该字段是一个布尔标志,用于指示玩家离开了跑道。该组中的第二个字段表示当前玩家离开赛道的时间。

这组字段之后是错误方向类字段。与偏离轨道字段的模式类似,错误方向字段由布尔标志和定时值、wrongDirectionwrongDirectionTime字段组成。下一组字段,即跳过的路点类字段,也由布尔标志和时间追踪字段组成。该组包含wrongDirectionwrongDirectionTime字段。类似地,下一组字段,即跳过的路点类字段,也由布尔标志和时间跟踪字段组成。这些是skippedWaypointskippedWaypointTime类字段。这种模式经常用于测量某些功能的持续时间,然后切换一个布尔字段来打开或关闭该功能。

接下来是一组字段,表示与玩家的悬停赛车相关联的相机和对象。第一个这样的字段是GameObject实例gun。该字段是对汽车的枪对象的引用。这个物体在游戏的战斗模式中出现。下一个字段是GameObject实例gunBase。这也是对一个游戏对象的引用,在这种情况下,gun依赖的基础模型。接下来我们列出了一个特别重要的字段,camera。这个摄像头安装在悬停赛车的驾驶舱内。在玩游戏时,它被用作主摄像头。

下一个字段是rearCamera字段。这款相机被用作哈弗赛车的后视相机。接下来,我们有非常重要的car字段。该字段引用代表比赛中玩家的游戏对象。他们的悬停赛车。该组中的下一个也是最后一个字段是代表汽车前视传感器的carSensor游戏对象。我们已经在前面的高级交互课程复习第五章 ?? 中讨论过这个问题。让我们转到下一组类字段。

//***** Car Status Class Fields *****
public int ammo = 0;
public bool gunOn = false;
public bool isBouncing = false;
public bool isJumping = false;
public bool isDrafting = false;
public bool isShot = false;
public float isShotTime = 0.0f;
public bool isHit = false;
public float isHitTime = 0.0f;
public bool isMiss = false;
public float isMissTime = 0.0f;

public bool lapComplete = false;
public float lapCompleteTime = 0.0f;
public bool armorOn = false;
public bool boostOn = false;
public bool invincOn = false;
public float invincTime = 0.0f;
public int lifeTotal = DEFAULT_LIFE_AMOUNT;
public int damage = DEFAULT_DAMAGE_AMOUNT;
public int points = 0;
public bool alive = true;

Listing 9-7PlayerState Class Fields 3

这组类字段与悬停赛车的当前状态有关。第一个字段显示玩家拥有的弹药数量。下一个字段gunOn用于指示枪修改器是否激活。接下来的三个字段(isBouncingisJumpingisDrafting)的使用方式与我们刚刚查看的gunOn字段相同。该组中接下来的六个字段遵循我们之前看到的关于轨迹和航路点指示器的相同模式。

在每种情况下,都有一个布尔标志和一个时间跟踪字段,用于射击、命中和未命中事件。请注意,这些事件仅在游戏的战斗模式中可用。接下来的两个字段lapCompletelapCompleteTime,遵循我们刚刚看到的完全相同的模式。下一个字段lapComplete表示该圈已经完成,而下一个字段lapCompleteTime记录该圈的持续时间。armorOnboostOn字段是布尔标志,用于指示给定的修改器是活动的还是非活动的。invinvOninvincTime字段遵循我们看到的相同模式,包括一个布尔标志和一个计时字段。

lifeTotal栏显示玩家的总生命值。damage字段是一个整数值,表示当前汽车受到的损坏量。游戏并没有主动使用points区域,但是你可以在你认为合适的时候使用。类似地,布尔标志alive被用来指示当前玩家是否还活着,但是它并没有被游戏主动使用。我们要看的下一组类字段是速度和航路点相关的字段。我把它们列在这里。

//***** Speed Class Fields *****
public int maxForwardSpeedSlow = 50;
public int maxSidewaysSpeedSlow = 12;
public int maxBackwardsSpeedSlow = 5;
public int maxGroundAccelerationSlow = 25;
public int maxForwardSpeedNorm = 200;
public int maxSidewaysSpeedNorm = 50;
public int maxBackwardsSpeedNorm = 20;
public int maxGroundAccelerationNorm = 100;
public int maxForwardSpeedBoost = 250;
public int maxSidewaysSpeedBoost = 60;
public int maxBackwardsSpeedBoost = 30;
public int maxGroundAccelerationBoost = 120;

//***** Waypoint Class Fields *****
public ArrayList waypoints = null;
public float waypointDistance = 0.0f;
public float waypointDistancePrev = 0.0f;

Listing 9-8PlayerState Class Fields 4

我们要查看的下一组类字段,如前面所列,包含两组字段。这些字段用于跟踪玩家的速度和路点交互。不同的速度场分为三类:慢速、正常和加速。每个类别有四个条目。关于慢速,我们有一个maxForwardSpeedSlow字段,它保存一个用于正向慢速的值。在此之后,我们有慢速类别的横向和向后速度。这一类别的最后一个字段是地面加速速度慢字段。

“正常”和“加速”类别也有相同的条目。标准速度用于在轨运行。慢速用于偏离轨道的运动。最后,当汽车有一个激活的加速修改器时,加速速度用于运动。接下来的三个字段与航迹的航路点系统相关联。第一个条目是一个ArrayList实例,用于保存对所有路点的引用。

以下两个字段用于跟踪汽车和下一个航路点之间的距离,waypointDistancewaypointDistancePrev。正如你可能已经猜到的,先前的航路点距离计算存储在waypointDistancePrev字段中。这些计算被用作航路点计算和人工智能系统的一部分,这将我们带到下一组职业领域供我们回顾,人工智能领域。

//***** AI Class Fields Part 1 *****
public bool aiOn = false;
public int aiWaypointTime = 0;
public int aiWaypointLastIndex = -1;
public int aiWaypointIndex = 0; //0 = first node
public int aiWaypointRoute = 0;
public float aiTurnStrength = 1.0f;

public float aiSpeedStrength = 1.0f;
public float aiStrafeStrength = 0.0f;
public float aiSlide = 0.0f;

//0 = looking, 1 = testing, 2 = acting
public int aiIsStuckMode = 0;
public bool aiIsStuck = false;
public float aiWaypointDistance = 0f;

public Vector3 aiRelativePoint = Vector3.zero;
public float aiTime1 = 0.0f;
public float aiTime2 = 0.0f;
public float aiSlowDownTime = 0.0f;
public float aiSlowDown = 0.0f;
public bool aiSlowDownOn = false;

Listing 9-9PlayerState Class Fields 5

我们要复习的下一组字段是第一组 AI 类字段。游戏的人工智能系统使用这些字段来移动、计算人工智能控制的悬停赛车的移动或报告其状态。第一个条目aiOn,是一个布尔标志,指示悬停赛车的 AI 模式是否开启。aiWaypointTime字段保存一个整数表示,它是当前玩家的当前timeNum字段的副本。接下来的两个字段,aiWaypointpointLastIndexaiWaypointIndex,用于跟踪玩家之前和当前的航点索引。这些由玩家的悬停赛车触发,与航路点对象交互。我应该注意到,虽然我说的是“玩家的”,这也意味着一个 AI 对手玩家,而不仅仅是一个人类玩家。

aiWaypointRoute字段用于加载指定路线的航路点。这个特性在游戏中实际上并没有使用;相反,路由总是被设置为零。如果您愿意,可以随意扩展这个功能。下一个字段aiTurnStrength,目前游戏还没有实现,但是可以作为人工智能转向计算的一部分。下面的字段aiSpeedStrength,被游戏的人工智能计算用来控制悬停赛车手的速度矢量。

类似地,aiStrafeStrength字段用于控制 AI 控制的悬停赛车的计算速度矢量的扫射分量。aiSlide字段为 AI 提供了一个用于速度计算的滑动组件。在处理“卡住”的悬停赛车时,我们会随着时间的推移进行一些检查,以表明赛车被卡住了。aiIsStuckMode字段有助于跟踪正在执行的停滞检查。

最终,“停滞”计算的结果存储在aiIsStuck字段中。接下来,我们有aiWaypointDistance字段,用于在 AI 模式下跟踪到下一个航路点的距离。aiRelativePoint字段用于确定当 AI 驾驶悬停赛车时应该使用什么aiStrafeStrength。接下来的两个字段aiTime1aiTime2用于跟踪时间间隔,例如在检查不同的aiIsStuckMode时。该组中的最后两个字段是aiSlowDownTimeaiSlowDown字段。这些字段用于控制盘旋赛车在转弯时的速度。aiSlowDownTime字段测量当前减速的持续时间。下一个字段aiSlowDown是一个指示汽车应该减速多少的值。该值由轨迹的航路点标记决定。最后,aiSlowDownOn字段用于指示减速修改器当前打开。我们还有几个人工智能领域要复习。我在这里列出下一套。

//***** AI Class Fields Part 2 *****
public float aiSlowDownDuration = 100.0f;
public bool aiIsPassing = false;
public float aiPassingTime = 0.0f;
public int aiPassingMode = 0;
public bool aiHasTarget = false;
public float aiHasTargetTime = 0.0f;

public bool aiIsReloading = false;
public float aiIsReloadingTime = 0.0f;
public bool aiHasGainedLife = false;
public float aiHasGainedLifeTime = 0.0f;
public bool aiIsLargeTurn = false;
public float aiIsLargeTurnSpeed = 0.0f;

public float aiLastLookAngle = 0.0f;
public float aiNextLookAngle = 0.0f;
public float aiNext2LookAngle = 0.0f;
public float aiMidLookAngle = 0.0f;
public float aiMid2LookAngle = 0.0f;
public bool aiCanFire = false;

public float aiBoostTime = 0.0f;
public int aiBoostMode = 0;
public int aiWaypointJumpCount = 0;
public int aiWaypointPassCount = 0;

Listing 9-10PlayerState Class Fields 6

我们已经讨论了一大堆课程领域,并且几乎就要完成了,所以坚持住。我们还有两组字段要复习。其余的 AI 字段已在前面列出。第一个条目把我们带回到减速领域。aiSlowDownDuration字段用于跟踪悬停赛车的减速。这个值是在 AI 控制的车上由赛道的路点标记设定的。接下来的三个条目是与 AI 悬停赛车的通过修改器有关的字段。aiIsPassing字段是一个布尔标志,表示 AI 汽车处于超车模式。然后,aiPassingTime字段用于测量悬停赛车超过当前赛车的持续时间。与这两个字段相关的是设置传球尝试模式的aiPassingMode字段。

接下来的两个变量遵循我们之前见过的模式。aiHasTarget字段指示目标已经设置,而aiHasTargetTime字段用于测量目标已经激活的持续时间。在这一对之后是另外两个布尔标志和持续时间字段集。看看他们,重装和生命增益。从人工智能控制的汽车的转弯角度可以检测到大转弯。角度决定了aiLargeTurnSpeed字段的值。这种机制用于帮助控制汽车在大角度转弯时的速度。

查看接下来的五个字段。这些都是浮点值,旨在保持基于悬停赛车方向和到下一个航路点的距离之间的计算的角度。aiCanFire布尔用于指示汽车是否能够开火。这只有在游戏的战斗模式变异中才有。下一对字段aiBoostModeaiBoostTime,遵循与之前看到的布尔标志、持续时间字段集类似的结构。在这种情况下,模式决定行为,时间字段跟踪持续时间。aiWaypointPassCount字段以一种有点愚蠢的方式跟踪经过的路点数量。它不能很好地跟踪重复和跳转,但是它可以用于根据其值的变化做出决定。还有一组剩余的类字段可供查看。

//***** Other Class Fields *****
public GameObject gunExplosion = null;
public GameObject gunHitSmoke = null;
public bool gunSmokeOn = false;
public float gunSmokeTime = 0.0f;
//public ParticleEmitter gunExplosionParticleSystem = null;

//public ParticleEmitter gunHitSmokeParticleSystem = null;

public GameObject flame = null;
public int totalLaps = 3;
public int currentLap = 0;
public bool prepped = false;
public GameObject lightHeadLight = null;

public AudioListener audioListener = null;
public AudioSource audioGunHit = null;
public AudioSource audioCarSound1 = null;
public AudioSource audioCarSound2 = null;

Listing 9-11PlayerState Class Fields 7

我们要查看的最后一组类字段是“其他”类字段。gunExplosiongunHitSmoke字段是游戏对象引用,指向玩家的悬停赛车中的对象,特别是汽车的统一层次GameObjects。接下来的两个字段应该看起来很熟悉。它们遵循我们之前见过的相同模式。这一对场,gunSmokeOngunSmokeTime,用来控制枪械的烟雾效果。该功能目前在游戏中被禁用。我会解释的。这个特性是以一种遗留的方式实现的,但现在已经过时了。我们保留了为它提供动力的代码,但是注释掉了,这样你就可以用它来为游戏和支持代码添加新的最新粒子效果。下一个字段是引用了 hover racer 模型结构的一部分的GameObject flametotalLaps字段的值表示当前比赛的圈数。

类似地,currentLap字段指示当前玩家在第几圈。布尔标志prepped用于表示玩家已经准备好,可以出发了。正如你可能已经想到的,lightHeadLight场是用来打开或关闭汽车的头灯。这组字段和字段查看部分是玩家的悬停赛车使用的音频监听器和声音资源字段。祝贺您,您已经完成了本复习部分的学习。在下一节中,我们将看看这个类的相关方法大纲。

相关的方法大纲/类头:PlayerState

PlayerState类让我们复习的方法很少。我把它们列在这里。

//Main Methods
void Start();
public bool PerformGunShotHit();
public Vector3 UpdateAiFpsController();
public void MoveToCurrentWaypoint();
public void MoveToWaypoint(int index);
public void UpdateAiMouseLook();
public void Update();
public void Reset();

//Support Methods
public void LoadAudio();
public void PauseSound();
public void UnPauseSound();
public bool IsValidWaypointIndex(int index);
public void StampWaypointTime();
public void PlayGunShotHitAudio();
public WaypointCheck GetCurrentWaypointCheck();
public void PerformLookAt(WaypointCheck wc);
public void ResetTime();
public int GetLife();
public int GetLifeHUD();
public int GetLapsLeft();
public void SetDraftingBonusOn();
public void SetDraftingBonusOff();
public void SetBoostOn();
public void SetBoostOff();
public void SetCurrentSpeed();
public void ShowInvinc();
public void HideInvinc();
public void ShowGun();
public void HideGun();
public void SetSlow();
public void SetNorm();
public void SetBoost();
private int GetPastWaypointIndex(int wpIdx);

Listing 9-12PlayerState Pertinent Method Outline/Class Headers 1

当然有很多支持方法需要我们回顾,但是由于大多数方法都非常简单和直接,我们将很快完成它们。

using System.Collections;
using UnityEngine;

public class PlayerState : BaseScript {}

Listing 9-13PlayerState Pertinent Method Outline/Class Headers 2

注意,PlayerState类扩展了BaseScript类,继承了一些我们在之前的类回顾中看到的标准功能。在下一个复习部分,我们将看看类的支持方法。

支持方法详细信息:PlayerState

PlayerState类有许多支持方法供我们研究。这些方法中有几个非常简单,所以我在这里列出来,但是由于它们的简单性,我们不会对它们做深入的回顾。请花时间仔细阅读。除非你了解这些方法的作用和使用方法,否则不要继续。

01 public void PauseSound() {
02    if (audioCarSound1 != null) {
03       audioCarSound1.Stop();
04    }
05
06    if (audioCarSound2 != null) {
07       audioCarSound2.Stop();
08    }
09 }

01 public void UnPauseSound() {
02    if (audioCarSound1 != null) {
03       audioCarSound1.Play();
04    }
05
06    if (audioCarSound2 != null) {
07       audioCarSound2.Play();
08    }
09 }

01 public bool IsValidWaypointIndex(int index) {
02    if (waypoints == null) {
03       waypoints = gameState.GetWaypoints(aiWaypointRoute);
04    }

05
06    if (waypoints != null && index >= 0 && index <= (waypoints.Count - 1)) {
07       return true;
08    } else {
09       return false;
10    }
11 }

01 public void StampWaypointTime() {
02    aiWaypointTime = timeNum;
03 }

01 public void PlayGunShotHitAudio() {
02    if (audioGunHit != null) {
03       if (audioGunHit.isPlaying == false) {
04          audioGunHit.Play();
05       }
06    }
07 }

01 public WaypointCheck GetCurrentWaypointCheck() {
02    if (waypoints != null) {
03       return (WaypointCheck)waypoints[aiWaypointIndex];
04    } else {
05       return null;
06    }
07 }

01 public void PerformLookAt(WaypointCheck wc) {
02    wcVpla = wc.transform.position;
03    wcVpla.y = player.transform.position.y;
04    player.transform.LookAt(wcVpla);
05 }

01 public void ResetTime() {
02    totalTime = 0f;
03 }

01 public int GetLife() {
02    return (lifeTotal - damage);
03 }

01 public int GetLifeHUD() {
02    return (lifeTotal - damage);
03 }

01 public int GetLapsLeft() {
02    return (totalLaps - currentLap);
03 }

01 public void SetDraftingBonusOn() {
02    isDrafting = true;
03    SetCurrentSpeed();
04 }

01 public void SetDraftingBonusOff() {
02    isDrafting = false;
03    SetCurrentSpeed();
04 }

01 public void SetBoostOn() {
02    boostOn = true;
03    SetBoost();
04 }

01 public void SetBoostOff() {
02    boostOn = false;
03    SetNorm();
04 }

01 public void SetCurrentSpeed() {
02    if (speedType == 0) {
03       SetSlow();
04    } else if (speedType == 1) {
05       SetNorm();
06    } else if (speedType == 2) {
07       SetBoost();
08    }

09 }

01 public void ShowInvinc() {
02    invincOn = true;
03    invincTime = 0f;
04 }

01 public void HideInvinc() {
02    invincOn = false;
03    invincTime = 0f;
04 }

01 public void ShowGun() {
02    gunOn = true;
03    if (gun != null) {
04       gun.SetActive(true);
05    }
06
07    if (gunBase != null) {
08       gunBase.SetActive(true);
09    }
10 }

01 public void HideGun() {
02    gunOn = false;
03    if (gun != null) {
04       gun.SetActive(false);
05    }
06
07    if (gunBase != null) {
08       gunBase.SetActive(false);
09    }
10 }

Listing 9-14PlayerState Support Method Details 1

这些支持方法本质上很简单。大多数只有几行代码。我不会在这里详细介绍它们。请通读一遍,确保你理解了它们,并且在你继续下一步之前,它们对你有意义。我们将继续讨论更复杂的支持方法。让我们来看看!

01 public void LoadAudio() {
02    audioSetLa = player.GetComponents<AudioSource>();
03    if (audioSetLa != null) {
04       lLa = audioSetLa.Length;
05       for (iLa = 0; iLa < lLa; iLa++) {
06          aSLa = (AudioSource)audioSetLa[iLa];
07          if (aSLa != null) {
08             if (aSLa.clip.name == AUDIO_CLIP_GUN_SHOT) {
09                audioGunHit = aSLa;
10             } else if (aSLa.clip.name == AUDIO_CLIP_CAR_SOUND1) {
11                audioCarSound1 = aSLa;
12             } else if (aSLa.clip.name == AUDIO_CLIP_CAR_SOUND2) {
13                audioCarSound2 = aSLa;
14             }
15          }
16       }
17    }
18 }

01 public void SetSlow() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (cm == null) {
07       return;
08    }
09
10    speedType = 0;
11    cm.movement.maxForwardSpeed = maxForwardSpeedSlow;
12    if (isDrafting == true) {
13       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_DRAFTING;
14    }
15
16    if (aiIsPassing == true) {
17       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_PASSING;
18    }
19    cm.movement.maxSidewaysSpeed = maxSidewaysSpeedSlow;
20    cm.movement.maxBackwardsSpeed = maxBackwardsSpeedSlow;
21    cm.movement.maxGroundAcceleration = maxGroundAccelerationSlow;
22 }

01 public void SetNorm() {

02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (cm == null) {
07       return;
08    }
09
10    speedType = 1;
11    cm.movement.maxForwardSpeed = maxForwardSpeedNorm;
12    if (isDrafting == true) {
13       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_DRAFTING;
14    }
15
16    if (aiIsPassing == true) {
17       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_PASSING;
18    }
19    cm.movement.maxSidewaysSpeed = maxSidewaysSpeedNorm;
20    cm.movement.maxBackwardsSpeed = maxBackwardsSpeedNorm;
21    cm.movement.maxGroundAcceleration = maxGroundAccelerationNorm;
22 }

01 public void SetBoost() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }
05
06    if (cm == null) {
07       return;
08    }
09
10    speedType = 2;
11    cm.movement.maxForwardSpeed = maxForwardSpeedBoost;
12    if (isDrafting == true) {
13       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_DRAFTING;
14    }
15
16    if (aiIsPassing == true) {
17       cm.movement.maxForwardSpeed += MAX_SPEED_BONUS_PASSING;
18    }

19    cm.movement.maxSidewaysSpeed = maxSidewaysSpeedBoost;
20    cm.movement.maxBackwardsSpeed = maxBackwardsSpeedBoost;
21    cm.movement.maxGroundAcceleration = maxGroundAccelerationBoost;
22 }

01 private int GetPastWaypointIndex(int wpIdx) {
02    if (wpIdx - 5 >= 0) {
03       wpIdx -= 5;
04    } else if (wpIdx - 4 >= 0) {
05       wpIdx -= 4;
06    } else if (wpIdx - 3 >= 0) {
07       wpIdx -= 3;
08    } else if (wpIdx - 2 >= 0) {
09       wpIdx -= 2;
10    } else if (wpIdx - 1 >= 0) {
11       wpIdx -= 1;
12    } else {
13       wpIdx = 0;
14    }
15    return wpIdx;
16 }

Listing 9-15PlayerState Support Method Details 2

前面列出的第一种更复杂的支持方法是LoadAudio方法。此方法用于加载音频资源,以用作悬停赛车的某些声音效果。在该方法的第 2 行,我们获得了一个附加到 player 对象的AudioSource组件列表。在生成的数组中循环,我们寻找三个特定的声音文件,并将引用存储在类字段中,第 9、11 和 13 行。

前面列出的下面三种方法用于更新当前玩家汽车的速度。第一个条目SetSlow,用于在 hover racer 脱离赛道时将其设置为慢速。第 2–4 行非常熟悉的代码(或者应该是这样的代码)会阻止这个方法在类没有正确配置的情况下执行任何工作。在第 6–8 行,如果字符运动字段cm未定义,我们退出该方法。

接下来,用第 10 行上代表慢速、正常或加速速度的值更新speedType字段。计算新的前进速度时考虑了 11–18 行上的牵引和通过。在该方法的最后,在第 19–21 行更新了悬停赛车的其余速度相关字段。我在SetSlow方法后面列出了SetNormSetBoost方法。这些方法与SetSlow方法几乎相同,所以我们在这里不再赘述。相反,我们会把他们的评论留给你。请确保您在继续之前了解该方法。

本节列出的最后一种方法是GetPastWaypointIndex方法。这个方法负责寻找过去的路点。它试图在玩家当前航点索引后面五个索引处找到一个有效的航点。如果所确定的索引值无效,则检查玩家当前航点索引后面四个索引的航点,依此类推。这就是本复习部分的结论。接下来,我们将看看这个类的主要方法。

主要方法细节:PlayerState

PlayerState类有几个主要的方法,负责配置和更新类字段。让我们来看看第一组主要的方法。

01 void Start() {
02    base.Prep(this.GetType().Name);
03    if (BaseScript.IsActive(scriptName) == false) {
04       Utilities.wrForce(scriptName + ": Is Deactivating...");
05       return;
06    }
07 }

01 public bool PerformGunShotHit() {

02    if (armorOn == true) {
03       armorOn = false;
04       isShot = true;
05       gunSmokeOn = true;
06       gunSmokeTime = 0.0f;
07       gunHitSmoke.SetActive(true);
08       //gunHitSmokeParticleSystem.Emit();
09       return true;
10    } else {
11       if (invincOn == true) {
12          return false;
13       } else {
14          damage++;
15          isShot = true;
16          gunSmokeOn = true;
17          gunSmokeTime = 0.0f;
18          gunHitSmoke.SetActive(true);
19          //gunHitSmokeParticleSystem.Emit();
20          PlayGunShotHitAudio();
21
22          if (GetLife() <= 0) {
23             aiWaypointIndex = GetPastWaypointIndex(aiWaypointIndex);
24             damage = 0;
25             if (aiWaypointIndex >= 0 && aiWaypointIndex < waypoints.Count) {
26                MoveToCurrentWaypoint();
27             }
28          }
29          return true;
30       }
31    }
32 }

01 public void MoveToWaypoint(int index) {
02    aiWaypointIndex = index;
03    MoveToCurrentWaypoint();
04 }

01 public void MoveToCurrentWaypoint() {
02    if (BaseScript.IsActive(scriptName) == false) {
03       return;
04    }

05
06    pause = true;
07    WaypointCheck wc = (WaypointCheck)waypoints[aiWaypointIndex];
08    Vector3 wcV = wc.transform.position;
09    wcV.y = wc.waypointStartY;
10
11    cm.movement.velocity = Vector3.zero;
12    player.transform.position = wcV;
13    isDrafting = false;
14    isJumping = false;
15    isBouncing = false;
16    SetNorm();
17    ShowInvinc();
18
19    if (aiWaypointIndex + 1 >= 0 && aiWaypointIndex + 1 < waypoints.Count) {
20       wc = (WaypointCheck)waypoints[aiWaypointIndex + 1];
21    }
22    aiWaypointJumpCount++;
23    PerformLookAt(wc);
24    pause = false;
25 }

Listing 9-16PlayerState Main Method Details 1

我们要看的第一个主要方法是Start方法。这个方法的实现遵循我们以前见过很多次的标准过程。对Prep方法的调用加载了一组标准变量,然后进行测试以查看该类是否正确初始化。下面列出的方法,PerformGunShotHit,用于对当前玩家应用射击命中结果。

第 2-9 行的第一个代码块处理当前玩家有主动护甲调整值时的命中。请注意,装甲修改器被设置为假,isShot标志被设置为真,一些效果字段被重置为显示一股烟的枪击。我应该再次提到,这些粒子效果已经被禁用,留给你来实现。在第 11 行到第 30 行的下一大块代码中,该方法处理没有装甲修改器激活时的射击命中。

这个大代码块的第一部分,第 11-13 行,处理当前玩家的无敌属性设置为真时的枪击事件。这段代码的第二部分从第 14 行到第 29 行。这段代码处理枪击事件。当前玩家的伤害和isShot旗在 14-15 行调整。枪击效果在第 16-19 行准备。在第 20 行,播放音频声音效果,表示有效的、破坏性的击打。

如果玩家没有更多的生命点,第 22 行,那么玩家在赛道上被重置,与击中水障碍的玩家被重置的方式相同。这在第 23–27 行处理。这个过程的第一步是找到一个先前的路点让玩家返回,作为受到致命一击的惩罚。我应该提一下,这个游戏的这些特性只有在战斗模式下才有。

GetPastWaypointIndex方法的调用决定了我们可以将当前玩家向后移动多远。在第 24 行,当前玩家的伤害被设置为零,hover racer 通过调用第 26 行的MoveToCurrentWaypoint方法被重新定位。列出的下一个方法MoveToWaypoint,是方法集中第一个玩家重新定位的方法。这是一个传递方法,它更新第 2 行的aiWaypointIndex字段,然后通过调用MoveToCurrentWaypoint方法来移动播放器。我们现在来看看这个方法。

列出的第二个玩家重新定位方法MoveToCurrentWaypoint,实际上是移动玩家的悬停赛车。如果类没有正确配置,前几行代码(2–4)会阻止该方法执行任何工作。第 6–24 行的代码负责重新定位播放器。第一,玩家暂停,第 6 行;然后新玩家的位置由当前航点和第 9 行航点对象的waypointStartY字段的值决定。

第 11–17 行的代码设置玩家的速度、位置和修改器值。注意玩家在第 17 行收到无敌修正值。我们需要弄清楚给定玩家的新位置,玩家应该面向哪个方向。为此,我们找到第 19–21 行确定的下一个航路点。玩家的跳跃计数字段增加,玩家的方向在第 22-23 行调整。最后但同样重要的是,玩家在第 24 行没有暂停。在下一组要回顾的主要方法中,我们将看看UpdateReset方法。

001 public void Update() {
002    if (BaseScript.IsActive(scriptName) == false) {
003       return;
004    }
005
006    if (prepped == false || cm == null) {
007       return;
008    } else if (hasSetHome == false && player != null) {
009       home = player.transform;
010       hasSetHome = true;
011    }
012
013    //speed calculations
014    speed = cm.movement.velocity.magnitude;
015    if (boostOn == true || aiIsPassing == true) {
016       speed = LIMIT_MAX_SPEED;
017    }
018    speedPrct = (speed / maxSpeed);
019    speedPrctLimit = (speed / LIMIT_MAX_SPEED);
020
021    position = gameState.GetPosition(index, position);
022
023    //timing values
024    totalTime += Time.deltaTime;
025    ms = Mathf.RoundToInt((totalTime % 1) * 1000);
026    s = Mathf.RoundToInt(Mathf.Floor(totalTime));
027    min = Mathf.RoundToInt(Mathf.Floor((s * 1f) / 60f));
028    s -= (min * 60f);
029    hour = Mathf.RoundToInt(Mathf.Floor((min * 1f) / 60f));
030    min -= (hour * 60f);
031    time = string.Format("{0:00}:{1:00}:{2:000}", min, s, ms);
032    timeNum = int.Parse(string.Format("{0:00}{1:00}{2:00}{3:000}", hour, min, s, ms));
033
034    //waypoint distance calculations
035    if (waypoints != null && waypoints.Count > 0) {
036       wc = (WaypointCheck)waypoints[aiWaypointIndex];
037       if (wc != null) {
038          wcV = wc.transform.position;
039          wcV.y = player.transform.position.y;
040          waypointDistancePrev = waypointDistance;
041          waypointDistance = Vector3.Distance(wcV, player.transform.position);
042       }

043    }
044
045    //invincibility modifier
046    if (invincOn == true) {
047       invincTime += Time.deltaTime;
048    } else {
049       invincTime = 0f;
050    }
051
052    if (invincOn == true && invincTime >= INVINC_SECONDS) {
053       invincOn = false;
054    }
055
056    //has gained life
057    if (aiHasGainedLife == true) {
058       aiHasGainedLifeTime += Time.deltaTime;
059    } else {
060       aiHasGainedLifeTime = 0f;
061    }
062
063    if (aiHasGainedLife == true && aiHasGainedLifeTime >= MAX_GAINED_LIFE_TIME) {
064       aiHasGainedLife = false;
065    }
066
067    //gun smoke effect
068    if (gunSmokeOn == true) {
069       gunSmokeTime += Time.deltaTime * 100f;
070    } else {
071       gunSmokeTime = 0f;
072    }
073
074    if (gunSmokeOn == true && gunSmokeTime >= MAX_SMOKE_TIME) {
075       gunSmokeOn = false;
076       gunSmokeTime = 0f;
077       gunHitSmoke.SetActive(false);
078       //gunHitSmokeParticleSystem.emit = false;
079    }

080
081    //is shot time
082    if (isShot == true) {
083       isShotTime += Time.deltaTime;
084    } else {
085       isShotTime = 0f;
086    }
087
088    if (isShot == true && isShotTime >= MAX_IS_SHOT_TIME) {
089       isShot = false;
090    }
091
092    //is hit time
093    if (isHit == true) {
094       isHitTime += Time.deltaTime;
095    } else {
096       isHitTime = 0f;
097    }
098
099    if (isHit == true && isHitTime >= MAX_IS_SHOT_TIME) {
100       isHit = false;
101    }
102
103    //is miss time
104    if (isMiss == true) {
105       isMissTime += Time.deltaTime;
106    } else {
107       isMissTime = 0f;
108    }
109
110    if (isMiss == true && isMissTime >= MAX_IS_SHOT_TIME) {
111       isMiss = false;
112    }
113
114    //lap complete time
115    if (lapComplete == true) {
116       lapCompleteTime += Time.deltaTime;
117    } else {
118       lapCompleteTime = 0f;
119    }

120
121    if (lapComplete == true && lapCompleteTime >= MAX_IS_LAP_COMPLETE_TIME) {
122       lapComplete = false;
123    }
124
125    //off track checks
126    if (offTrack == true) {
127       offTrackTime += Time.deltaTime;
128    } else {
129       offTrackTime = 0f;
130    }
131
132    if (offTrack == true && offTrackTime >= offTrackSeconds) {
133       if (waypoints != null && waypoints.Count > 0) {
134          //move car to waypoint center
135          aiWaypointIndex = GetPastWaypointIndex(aiWaypointIndex);
136          if (aiWaypointIndex >= 0 && aiWaypointIndex < waypoints.Count) {
137             MoveToCurrentWaypoint();
138          }
139          offTrack = false;
140          offTrackTime = 0f;
141       }
142    }
143
144    //wrong direction checks
145    if (wrongDirection == true) {
146       wrongDirectionTime += Time.deltaTime;
147    } else {
148       wrongDirectionTime = 0f;
149    }
150
151    if (wrongDirection == true && wrongDirectionTime >= wrongDirectionSeconds) {
152       if (waypoints != null && waypoints.Count > 0) {
153          //move car to waypoint center
154          aiWaypointIndex = GetPastWaypointIndex(aiWaypointIndex);
155          if (aiWaypointIndex >= 0 && aiWaypointIndex < waypoints.Count) {
156             MoveToCurrentWaypoint();
157          }
158          wrongDirection = false;
159          wrongDirectionTime = 0;
160       }
161    }
162 }

001 public void Reset() {

002    totalTime = 0f;
003    min = 0f;
004    s = 0f;
005    ms = 0f;
006    hour = 0f;
007    ammo = 0;
008    damage = 0;
009    points = 0;
010
011    boostOn = false;
012    invincOn = false;
013    invincTime = 0.0f;
014    gunOn = false;
015    armorOn = false;
016    offTrack = true;
017    gunSmokeOn = false;
018    gunSmokeTime = 0f;
019
020    prepped = false;
021    offTrack = false;
022    offTrackTime = 0.0f;
023    wrongDirection = false;
024    wrongDirectionTime = 0.0f;
025    skippedWaypoint = false;
026    skippedWaypointTime = 0.0f;
027    position = 6;
028    currentLap = 0;
029    waypointDistance = 0.0f;
030    waypointDistancePrev = 0.0f;
031    alive = true;
032
033    isBouncing = false;
034    isJumping = false;
035    isDrafting = false;
036    isShot = false;
037    isShotTime = 0.0f;
038    isHit = false;
039    isHitTime = 0.0f;
040    isMiss = false;
041    isMissTime = 0.0f;
042
043    aiIsStuck = false;
044    aiIsPassing = false;
045    aiPassingTime = 0.0f;
046    aiPassingMode = 0;
047    aiHasTarget = false;
048    aiHasTargetTime = 0.0f;
049    aiIsReloading = false;
050    aiIsReloadingTime = 0.0f;
051
052    aiIsLargeTurn = false;
053    aiIsLargeTurnSpeed = 0.0f;
054    aiCanFire = false

;
055    aiBoostTime = 0.0f;
056    aiBoostMode = 0;
057    aiWaypointTime = 0;
058    aiWaypointLastIndex = -1;
059    aiWaypointIndex = 0;
060    aiWaypointJumpCount = 0;
061    aiWaypointPassCount = 0;
062 }

Listing 9-17PlayerState Main Method Details 2

前面列出的下一组主要方法,有两个重要的方法,我们现在来看看。我们要研究的第一个方法是非常重要的Update方法。这个方法为我们处理一些不同的职责。它负责计算当前速度、到航路点的距离,并跟踪不同修改器的持续时间。值得注意的是PlayerState类为人类和人工智能控制的玩家做着同样的工作。

Update方法的主要目的是跟踪悬停赛车的状态,并监控不同修改器所涉及的计时持续时间。修改器通过悬停赛车与其他汽车的交互以及跳跃和加速标记等轨迹功能来设置。注意,这个类不插入或驱动游戏的 HUD。这个过程由GameState类处理,我们马上就会看到。现在让我们来看一下Update方法的细节。

如果类没有正确配置,前几行代码保护方法不被执行,第 2-4 行。接下来,在第 6–7 行,如果该类没有被标记为已正确初始化,或者没有正确定义字符马达字段cm,则该方法返回。第 8–11 行的代码根据初始位置设置悬停赛车的初始位置。速度计算在第 14-19 行进行。

悬停赛车的速度被设置为等于汽车速度矢量的大小。如果汽车处于加速模式或者aiIsPassing标志为真,那么汽车的速度被设置为LIMIT_MAX_SPEED值。在第 18–19 行,速度百分比值被更新。第 24 行增加了总单圈时间,第 21 行更新了赛车在比赛中的当前位置。Update方法中的下一个代码块处理分段计时值,第 25–32 行。毫秒、秒和分钟由totalTime字段决定。请注意,在第 28 行,我们减去了所有可以用分钟表示的秒。在第 30 行执行类似的计算,减去所有可以用小时表示的分钟。

第 31 和 32 行更新了两个重要的类字段timetimeNumtime字段是当前圈速的字符串表示。timeNum字段是一个特殊的编码,它将当前的圈速保存为一个整数。下一个代码块是第 35–43 行的航路点距离计算。此代码负责获取当前航路点的中心位置和推荐的 Y 位置。

当前和先前的航路点距离设置在第 40-41 行。虽然人类玩家不使用这些值,但人工智能玩家使用它们来控制悬停赛车。在这段代码之后是无敌修改代码,第 46–54 行。这段代码遵循一个简单的模式,我们将在这个方法中多次看到。如果无敌修改器是激活的,第 46 行,那么我们增加修改器的计时值,第 47 行。如果不是,修改量时间被设置为零,第 49 行。第 52–54 行的最后一位代码重置了无敌修改器,如果它的活动时间超过了指定的时间。

下一个代码块,第 57–65 行,即“已经获得生命”部分,其功能与我们刚刚查看的代码相同。看一看它,并确保在继续之前理解它。包括这一段代码,剩下的代码块都很相似,你应该自己快速复习一下。代码块如下所示:

  • 枪烟效果:68–79

  • 拍摄时间:82–90 分钟

  • 击中时间:93–101

  • 错过时间是:104–112

  • 单圈完成时间:115–123

代码非常直接。你会毫不费力地跟上它。尽管如此,还是要花点时间仔细看看。这种方法剩下的两个责任是偏离轨道检查和错误方向检查。这两个代码块遵循相同的模式,所以我将首先检查一个代码块,并将第二个代码块留给您来检查。请注意第 126–142 行代码中的“非跟踪检查”部分。第 126–130 行的 if 语句遵循我们之前见过的相同模式。如果布尔标志为真,我们递增偏离轨道时间;否则,我们将其值设置为零。

在第 132 行,如果 off-track 标志设置为 true,并且我们已经到达了offTrackSeconds时间,那么我们必须调整 hover racer 的位置,因为我们已经离开了赛道。如果有要处理的路点,第 133 行,那么我们将汽车移动到由第 135 行的方法调用确定的路点。如果确定的索引是有效的,我们移动汽车并重置偏离轨道标志和计时字段。

Update方法中的最后一段代码“错误方向检查”非常接近我们刚刚检查过的代码,所以我将把它留给您来检查。这组主方法中的最后一个方法是Reset方法。该方法只是将类字段重置为默认值。关于这个没什么好讨论的了。快速回顾一下,让我们继续研究剩下的最后两个主要方法,人工智能控制方法。

001 public Vector3 UpdateAiFpsController() {
002    if (player == null || prepped == false || cm == null) {
003       return Vector3.zero;
004    }
005
006    if (waypoints == null) {
007       waypoints = gameState.GetWaypoints(aiWaypointRoute);
008    }
009
010    if (waypoints == null) {
011       return Vector3.zero;
012    }
013
014    //calculate strafe strength
015    aiStrafeStrength = 0.0f;
016    aiSpeedStrength = 1.0f;
017
018    if (waypoints != null) {
019       fpsWc = (WaypointCheck)waypoints[aiWaypointIndex];
020       aiRelativePoint = player.transform.InverseTransformPoint(fpsWc.transform.position);
021
022       if (aiRelativePoint.x <= -30.0f) {
023          aiStrafeStrength = -0.30f;
024       } else if (aiRelativePoint.x >= 30.0f) {
025          aiStrafeStrength = 0.30f;
026       } else if (aiRelativePoint.x <= -20.0f) {
027          aiStrafeStrength = -0.20f;
028       } else if (aiRelativePoint.x >= 20.0f) {
029          aiStrafeStrength = 0.20f;
030       } else if (aiRelativePoint.x <= -15.0f) {
031          aiStrafeStrength = -0.15f;
032       } else if (aiRelativePoint.x >= 15.0f) {
033          aiStrafeStrength = 0.15f;
034       } else if (aiRelativePoint.x <= -10.0f) {
035          aiStrafeStrength = -0.10f;
036       } else if (aiRelativePoint.x >= 10.0f) {
037          aiStrafeStrength = 0.10f;
038       } else if (aiRelativePoint.x <= -5.0f) {

039          aiStrafeStrength = -0.05f;
040       } else if (aiRelativePoint.x >= 5.0f) {
041          aiStrafeStrength = 0.05f;
042       } else if (aiRelativePoint.x <= -1.0f) {
043          aiStrafeStrength = -0.01f;
044       } else if (aiRelativePoint.x >= 1.0f) {
045          aiStrafeStrength = 0.01f;
046       }
047    }
048
049    //calculate side, above, collisions
050    sidesUfp = (int)(cm.movement.collisionFlags & CollisionFlags.Sides);
051    aboveUfp = (int)(cm.movement.collisionFlags & CollisionFlags.Above);
052
053    if (sidesUfp == 0) {
054       collSidesUfp = false;
055    } else {
056       collSidesUfp = true;
057    }
058
059    if (aboveUfp == 0) {
060       collAboveUfp = false;
061    } else {
062       collAboveUfp = true;
063    }
064
065    //calculate is stuck data
066    if (aiTime2 > 1 && cm.movement.collisionFlags == CollisionFlags.None) {
067       aiTime2 = 0;
068       aiIsStuckMode = 0;
069       aiTime1 = 0;
070       aiIsStuck = false;
071    } else if (aiTime2 > 1 && Mathf.Abs(waypointDistance - aiWaypointDistance) > MIN_STUCK_DISTANCE && !(collAboveUfp || collSidesUfp)) {
072       aiTime2 = 0;
073       aiIsStuckMode = 0;
074       aiTime1 = 0;
075       aiIsStuck = false;
076    } else if (collAboveUfp || collSidesUfp) {
077       aiTime2 = 0;
078       aiIsStuckMode = 1;
079       aiWaypointDistance = waypointDistance;
080       aiIsStuck = true;
081    }

082
083    //test and apply is stuck data
084    if (aiIsStuckMode == 1 && aiTime1 >= MIN_STUCK_TIME && cm.movement.velocity.magnitude <= 30 && Mathf.Abs(waypointDistance - aiWaypointDistance) <= MIN_STUCK_DISTANCE) {
085       aiIsStuckMode = 2;
086       aiTime2 = 0f;
087       aiTime1 = 0f;
088       aiIsStuck = true;
089    } else if (aiIsStuckMode == 1 && aiTime1 > MIN_STUCK_TIME) {
090       aiIsStuckMode = 0;
091       aiTime2 = 0f;
092       aiTime1 = 0f;
093       aiIsStuck = false;
094    }
095
096    //process aiIsStuckMode
097    if (aiIsStuckMode == 1) {
098       aiTime1 += Time.deltaTime;
099    } else if (aiIsStuckMode == 2) {
100       if (waypoints != null && waypoints.Count > 0) {
101          //move car to waypoint center
102          aiWaypointIndex = GetPastWaypointIndex(aiWaypointIndex);
103          if (!(aiWaypointIndex >= 0 && aiWaypointIndex < waypoints.Count)) {
104             fpsV = new Vector3(0, 0, 0);
105             return fpsV;
106          }
107          MoveToCurrentWaypoint();
108          aiIsStuckMode = 0;
109          aiIsStuck = false;
110          aiTime2 = 0f;
111          aiTime1 = 0f;
112          aiStrafeStrength = 0f;
113       }
114
115       fpsV = new Vector3(0, 0, 0);
116       return fpsV;
117    }

118
119    if (aiIsStuckMode != 0) {
120       aiTime2 += Time.deltaTime;
121    }
122
123    //apply waypoint slow down
124    if ((aiSlowDownOn == true && aiSlowDown < 1.0f && speedPrct > 0.3f) || (aiSlowDown >= 1.0f)) {
125       aiSlowDownTime += (Time.deltaTime * 100);
126       aiSpeedStrength = aiSlowDown;
127       if (aiSlowDownTime > aiSlowDownDuration) {
128          aiSlowDownOn = false;
129          aiSlowDownTime = 0.0f;
130       }
131    }
132
133    //handle large turn
134    if (aiIsLargeTurn == true) {
135       if (aiSpeedStrength > aiIsLargeTurnSpeed) {
136          aiSpeedStrength = aiIsLargeTurnSpeed;
137       }
138    }
139
140    fpsV = new Vector3(aiStrafeStrength, 0, aiSpeedStrength);
141    return fpsV;
142 }

001 public void UpdateAiMouseLook() {
002    if (BaseScript.IsActive(scriptName) == false) {
003       return;
004    }
005
006    if (waypoints == null) {
007       waypoints = gameState.GetWaypoints(aiWaypointRoute);
008    }
009
010    if (waypoints == null || player == null || prepped == false || !(aiWaypointIndex >= 0 && aiWaypointIndex < waypoints.Count)) {
011       return;
012    }

013
014    wc1 = (WaypointCheck)waypoints[aiWaypointIndex];
015    if (SHOW_AI_LOGIC) {
016       Debug.DrawRay(player.transform.position, (wc1.transform.position - player.transform.position), Color.green);
017    }
018
019    umlA = 0.0f;
020    umlForward = (player.transform.TransformDirection(Vector3.forward) * 20);
021
022    if (SHOW_AI_LOGIC) {
023       Debug.DrawRay(player.transform.position, umlForward, Color.magenta);
024    }
025
026    if (waypointDistance >= MIN_WAYPOINT_DISTANCE) {
027       wcV1 = wc1.transform.position;
028       wcV1.y = player.transform.position.y;
029       umlA = Vector3.Angle(umlForward, (wcV1 - player.transform.position));
030       aiLastLookAngle = umlA;
031
032       umlTmpIdx = 0;
033       if (aiWaypointIndex + 1 >= 0 && aiWaypointIndex + 1 < waypoints.Count) {
034          umlTmpIdx = (aiWaypointIndex + 1);
035       } else {
036          umlTmpIdx = 0;
037       }
038
039       wc2 = (WaypointCheck)waypoints[umlTmpIdx];
040       wcV2 = wc2.transform.position;
041       wcV2.y = player.transform.position.y;
042       umlA = Vector3.Angle(umlForward, (wcV2 - player.transform.position));
043       aiNextLookAngle = umlA;
044
045       if (SHOW_AI_LOGIC) {
046          Debug.DrawRay(player.transform.position, (wc2.transform.position - player.transform.position), Color.green);
047       }
048
049       umlTmpIdx = 0;
050       if (aiWaypointIndex + 2 >= 0 && aiWaypointIndex + 2 < waypoints.Count) {
051          umlTmpIdx = (aiWaypointIndex + 2);
052       } else {

053          umlTmpIdx = 0;
054       }
055
056       wc5 = (WaypointCheck)waypoints[umlTmpIdx];
057       wcV5 = wc5.transform.position;
058       wcV5.y = player.transform.position.y;
059       umlA = Vector3.Angle(umlForward, (wcV5 - player.transform.position));
060       aiNext2LookAngle = umlA;
061
062       if (SHOW_AI_LOGIC) {
063          Debug.DrawRay(player.transform.position, (wc5.transform.position - player.transform.position), Color.green);
064       }
065
066       if (speedPrct > 0.2f) {
067          umlAngle = Mathf.Abs(aiNextLookAngle);
068
069          if (umlAngle > 80) {
070             aiIsLargeTurn = true;
071             aiIsLargeTurnSpeed = 0.65f;
072
073          } else if (umlAngle >= 65 && umlAngle <= 80) {
074             aiIsLargeTurn = true;
075
076             if (speedPrct >= 0.95f) {
077                aiIsLargeTurnSpeed = 0.05f;
078             } else if (speedPrct >= 0.85f) {
079                aiIsLargeTurnSpeed = 0.10f;
080             } else {
081                aiIsLargeTurnSpeed = 0.15f;
082             }
083
084          } else if (umlAngle >= 60) {
085             aiIsLargeTurn = true;
086
087             if (speedPrct >= 0.95f) {
088                aiIsLargeTurnSpeed = 0.10f;
089             } else if (speedPrct >= 0.85f) {
090                aiIsLargeTurnSpeed = 0.15f;
091             } else {
092                aiIsLargeTurnSpeed = 0.25f;
093             }
094
095          } else if (umlAngle >= 45) {
096             aiIsLargeTurn = true;
097
098             if (speedPrct >= 0.95f) {
099                aiIsLargeTurnSpeed = 0.20f;
100             } else if (speedPrct >= 0.85f) {
101                aiIsLargeTurnSpeed = 0.25f;
102             } else {

103                aiIsLargeTurnSpeed = 0.35f;
104             }
105
106          } else if (umlAngle >= 30) {
107             aiIsLargeTurn = true;
108
109             if (speedPrct >= 0.95f) {
110                aiIsLargeTurnSpeed = 0.40f;
111             } else if (speedPrct >= 0.85f) {
112                aiIsLargeTurnSpeed = 0.45f;
113             } else {
114                aiIsLargeTurnSpeed = 0.55f;
115             }
116
117          } else if (umlAngle >= 15) {
118             aiIsLargeTurn = true;
119
120             if (speedPrct >= 0.95f) {
121                aiIsLargeTurnSpeed = 0.60f;
122             } else if (speedPrct >= 0.85f) {
123                aiIsLargeTurnSpeed = 0.65f;
124             } else {
125                aiIsLargeTurnSpeed = 0.75f;
126             }
127
128          } else {
129             aiIsLargeTurn = false;
130          }
131       } else {
132          aiIsLargeTurn = false;
133       }
134
135       tr = Quaternion.LookRotation(wcV1 - player.transform.position);
136       player.transform.rotation = Quaternion.Slerp(player.transform.rotation, tr, Time.deltaTime * 5.0f);
137    } else {
138       aiLastLookAngle = 0.0f;
139       aiNextLookAngle = 0.0f;
140       aiMidLookAngle = 0.0f;
141    }

142 }

Listing 9-18PlayerState Main Method Details 3

我们要看的第一个人工智能方法是UpdateAiFpsController方法。这种方法负责控制悬停赛车的水平速度矢量。换句话说,它控制 X 和 Z 轴速度。前几行代码构成了我们之前见过很多次的标准保护代码,第 2-4 行。接下来,第 6–12 行的代码用于确保类路点设置正确。注意,该方法在转义时返回一个空的Vector3实例。

我们要看的第一段代码是一段相当长的代码,第 15–47 行。它负责决定悬停赛车的扫射,左右移动,力量。在第 15–16 行,新速度向量的分量aiStrafeStrengthaiSpeedStrength分别默认为 0 和 1。这相当于全速前进,没有横向速度。如果定义了路点,第 18 行,那么我们得到一个对当前路点的引用,我们调用InverseTransformPoint方法来从汽车的当前位置确定相对点aiRelativePoint。这让我们知道我们是否需要对悬停赛车应用扫射速度。在第 22–46 行,根据相对点的距离设置aiStrafeStrength字段的值。

该方法的下一个职责是计算与悬停赛车相关的侧面和上方碰撞。在第 50 行和第 51 行,通过检查角色运动运动字段的碰撞标志来确定侧面和上面的碰撞。一个简单的检查导致设置类字段collSidesUfpcollAboveUfp的值,第 53–57 行和第 59–63 行。该方法负责的下一个计算由第 66–81 行的“计算停滞数据”部分处理。

检查的前两个条件,第 66–71 行和第 71–76 行,重置了停滞数据。在第一种情况下,没有检测到冲突。在第二种情况下,悬停赛车远离航路点,并且没有检测到侧面或上方碰撞。如果发现侧面或上方碰撞,检查的第三个条件将启动“停滞 AI”修改器。将类字段aiIsStuckMode设置为 1 开始该过程,第 78 行。

如果当前模式是 1 并且悬停赛车的速度很慢,则 AI 驱动的 is-stuck 过程中的下一个计算将把 is-stuck 模式升级到 2。它还会考虑汽车是否仍在当前航路点附近,以及MIN_STUCK_TIME持续时间是否已过,第 84–89 行。在检查的下一个条件中,如果停滞模式为 1 并且aiTime1的值已经超过了MIN_STUCK_TIME持续时间,我们通过在第 90 行将aiIsStuckMode的值设置为 0 来重置 AI 停滞模式过程。与 is-stuck AI 修饰符相关的最后几行代码“process aiIsStuckMode”从第 97 行运行到第 117 行。

在第一种情况下,我们检查停滞模式的值是否为 1,然后第 98 行的aiTime1字段增加帧时间。如果可能的话,这部分过程会给汽车一点时间来摆脱困境。我们之前看到,如果经过了足够长的时间,字段aiIsStuckMode被设置为值 2。第 99–117 行处理了aiIsStuckMode的值为 2 的情况。在这段代码中,如果可能的话,悬停赛车会被移动,并且所有的停滞修改器字段都会被重置,第 107–112 行。注意,如果某些值没有意义,该方法返回一个零Vector3值,第 105 和 116 行。

第 119–121 行的代码负责增加aiTime2类字段的值。在使用这种方法之前,我们还需要承担一些责任。下一个代码片段从第 124 行运行到第 131 行,负责应用航路点减速。这些是当 AI 控制的玩家的悬停赛车通过某些路点时设置的减速提示。如果航路点的aiSlowDownOn字段设置为真,由WaypointCheck类处理,并且存在某些减速和速度值,则应用 AI 减速修改器。

在第 125 行,减速时间跟踪器递增,而在第 126 行,根据当前减速值设置aiSpeedStrength值。在第 127-130 行,如果经过了足够的时间,减速字段将被重置。最后但同样重要的是,我们还有最后一个责任要考虑,即“处理大转弯”的计算。代码从第 134 行运行到第 138 行。如果aiLongTurn标志被设置为真,则盘旋参赛者的计算速度强度被设置为aiIsLargeTurnSpeed字段的当前值,第 136 行。该方法中的最后一段代码基于第 140–141 行的aiStrafeStengthaiSpeedStrength字段的值创建一个新的Vector3实例,并返回它。

这就把我们带到了PlayerState类的一组主要方法中的最后一个方法,即UpdateAiMouseLook方法。这个方法的第一行,2–4 行,是我们多次看到的标准类配置检查。在第 6–8 行,如果 class' waypoints'字段为空,则初始化该字段。接下来,我们检查以确保所有必填字段和值都设置正确,第 10–12 行。方法变量wc1基于当前航路点索引(第 14 行)设置,并且umlAumlForward字段在第 19 和 20 行初始化。请注意,umlForward字段是一个指向悬停赛车前方的向量。

第 15–17 行的调试代码负责绘制一个从汽车中心到下一个路点中心的箭头。如果SHOW_AI_LOGIC字段设置为真,那么当游戏在 Unity 编辑器中运行时,“场景”面板中将显示一个绿色箭头。如果 AI 逻辑调试打开,正向指示向量umlForward在第 22–24 行上绘制为洋红色线条。接下来,我们检查当前的航路点距离是否大于第 26 行的MIN_WAYPOINT_DISTANCE字段的值。如果是,则执行第 26–136 行的代码。如果没有,悬停赛车的转弯角度在第 138–140 行被重置为零。

看一下第 27–65 行;从悬停赛车的当前位置到当前和接下来两个路点的中心的角度在这里计算。第 45-47 行和第 62-64 行有一个调试调用,以绿线的形式显示 AI 逻辑,将汽车连接到我们在这里处理的两个路点。在第 66–130 行,如果汽车移动得足够快,并且转弯角度足够大,则aiIsLargeTurn标志被设置为真。第 135–136 行计算最终旋转值。这种方法实质上是根据悬停赛车与接下来的三个航路点的相对距离和所涉及的角度来驾驶悬停赛车。

演示:玩家状态

我已经为PlayerState类建立了一个非常详细的演示。打开 Unity 编辑器,进入“项目”面板。找到并打开“场景”文件夹。接下来,找到并打开名为“Main13Demonstration”的场景。在 Unity 编辑器中播放场景,你会在屏幕上注意到大量的汽车状态调试信息。如果您单击“开始”菜单上的任何轨迹按钮,演示场景将自动运行。我建议让街机演示模式 AI race 一边运行,一边监控屏幕上显示的调试值。这对于了解汽车的人工智能如何计算该做什么非常有用。

这个演示场景也是为了显示汽车的人工智能计算转弯,路点和速度。如果你让比赛在人工智能模式下运行,并点击“场景”面板,你会注意到有绿色的线从盘旋的参赛者发出,还有一条紫色的线表示前进的方向向量。这些线表示基于当前和即将到来的路点的人工智能计算。这就是我们这节课复习的结论。如果你第一次没有吸收所有的东西,不要担心。这里发生了很多事。慢慢来。

第二章结论

在这一章中,我们回顾了玩家和游戏状态类回顾的第一部分。这一章都是关于跟踪或捕捉游戏状态的类。让我们花点时间来总结一下本章中我们复习过的内容。

  1. PayerInfo:一个微妙的状态类。这个MonoBehaviour用于在活动玩家数组中提供相关玩家的索引,这有助于从主GameState对象实例中查找PlayerState数据。这个类用于连接游戏中的对象和游戏中的玩家。

  2. TrackScript:这个类是一个驻留在GameState对象上的MonoBehaviour,这个 Unity game 对象保存了对重要游戏和玩家数据的引用。它定义了当前赛道的一些属性。

  3. PlayerState:一个非常重要的状态类,PlayerState类是一个MonoBehaviour,用于跟踪与它相关的玩家状态的各种信息。

因为这只是游戏和玩家状态回顾的第一部分,我们没有看到GameState类。这门课是另一门非常重要的课。它本质上是整个游戏的大脑。因此,它相当复杂,我认为最好用一整章来回顾它。你几乎已经完成了游戏中每个职业的详细复习!一旦我们完成了GameState的回顾,我们将回顾一些关于如何让你的下一个游戏变得“专业”的提示然后我们会找点乐子,建一条新的赛道来比赛。敬请关注!

十、玩家和游戏状态类:第二部分

在这一章中,我们将通过观察游戏的大脑来继续回顾游戏的状态类,即GameState类。这节课复杂而漫长,深呼吸,做好心理准备。我们有艰巨的工作要做。我们开始吧。

课堂回顾:游戏状态

嗯,我们已经设法复习了整个游戏中几乎所有的职业,非常详细,有一些相当不错的演示场景。我们刚刚复习完游戏中最长、最重要的职业之一,我们还有一个同样复杂的职业要复习。playerState类通过轨迹交互更新,导致悬停赛车修改器,它还从用户输入接收一些信息。

所有这些数据都由GameState类和游戏的 HUD 屏幕组织、共享、存储和呈现。该类负责将活动的悬停赛车连接到游戏的 HUD,以便正确显示所有的修改器、状态字段和通知。这个类还负责管理不同的菜单屏幕和执行游戏状态。我们将遵循下面列出的审查步骤:

  1. 枚举

  2. 静态/常量/只读类成员

  3. 类别字段

  4. 相关的方法大纲/类头

  5. 支持方法详细信息

  6. 主要方法详细信息

  7. 示范

我们要看的第一个复习部分是枚举部分。

枚举:游戏状态

GameState类有两个枚举供我们细读。它们用于游戏状态管理和准备赛道。

public enum GameStateIndex {
   FIRST,
   NONE,
   MAIN_MENU_SCREEN,
   GAME_OVER_SCREEN,
   GAME_PAUSE_SCREEN,
   GAME_PLAY_SCREEN,
   GAME_EXIT_PROMPT
};

public enum GameDifficulty {
   LOW,
   MED,
   HIGH
};

Listing 10-1GameState Enumerations 1

列出的第一个枚举GameStateIndex,用于帮助管理游戏的当前状态。它也有助于改变状态。在这种情况下,游戏状态代表游戏中唯一的屏幕。例如,开始菜单是一种状态,而帮助菜单和实际游戏是其他游戏状态。请注意,有一个NONE状态、FIRST状态和每个菜单屏幕状态的条目。列出的下一个枚举是GameDifficulty枚举,用于帮助跟踪游戏的当前难度。在下一节中,我们将看看这个类的静态成员。

静态/常量/只读类成员:GameState

GameState类有一些我们需要查看的静态和只读类成员。让我们来看看。

private static bool FIRST_RUN = true;
public static bool ON_GUI_SHOW_CAR_DETAILS = false;
public static bool ON_GUI_SHOW_EXIT_BUTTON = false;
public static bool SHOW_WAYPOINT_OUTPUT = false;
public static readonly float START_GAME_SECONDS = 5.0f;

public static readonly float TRACK_HELP_SECONDS = 2.0f;

public static readonly int DEFAULT_TOTAL_LAPS = 10;

Listing 10-2PlayerState Static/Constants/Read-Only Class Members 1

FIRST_RUN字段是一个布尔标志,表示这是否是游戏的第一次运行。以下字段用于“main 13 演示”场景。如果ON_GUI_SHOW_CAR_DETAILS字段设置为真,屏幕上将显示大量玩家和游戏状态信息。下一个字段ON_GUI_SHOW_EXIT_BUTTON用于控制调试退出按钮的显示。接下来,SHOW_WAYPOINT_OUTPUT字段用于控制航路点调试输出。

START_GAME_SECONDS字段控制比赛开始前显示的秒数。类似地,TRACK_HELP_SECONDS字段保存显示帮助通知的秒数。最后,DEFAULT_TOTAL_LAPS字段用于保存每条赛道的默认圈数。静态/只读类成员审查到此结束。在下一个复习部分,我们将讨论该课程的剩余字段。

类别字段:游戏状态

GameState类是一个重要的中央集权和国家管理类,因此,它有大量的类字段供我们查看。我们将在这里详细讨论它们。我们将从回顾中省略类的内部变量,因为这些字段被用作局部方法变量。我们有很多材料要讲,所以慢慢来。如果你没有在一次阅读中吸收全部,不要沮丧。可能需要一点时间才能真正适应这门课。让我们看看第一组字段,好吗?

//***** Class Fields *****
public ArrayList players = null;
public PlayerState p0;
public PlayerState p1;
public PlayerState p2;
public PlayerState p3;
public PlayerState p4;
public PlayerState p5;
public PlayerState currentPlayer = null;

Listing 10-3GameState Class Fields 1

players字段是一个ArrayList实例,用于保存游戏活动玩家的一组PlayerState对象实例。这包括人类和人工智能控制的玩家。请注意,该游戏配置为支持六名玩家。最后一个条目是引用当前玩家的PlayerStatecurrentPlayer字段。当前玩家是其状态和摄像机插入游戏显示器的玩家。我们要研究的下一组变量是类字段和相关责任的随机分类。

//***** Internal Variables: Start *****
public int[] positions = null;
public bool sortingPositions = false;
public int currentIndex = 0;
public int player1Index = 0;
public LapTimeManager lapTimeManager = null;
public int totalLaps = DEFAULT_TOTAL_LAPS;
public int gameSettingsSet = 0;    //track type
public int gameSettingsSubSet = 0; //track difficulty
public bool debugOn = false;
public bool forceGameStart = false;
public bool scsMode = false;
private GUIStyle style1 = null;
private GUIStyle style2 = null;
public WaypointCompare wpc = new WaypointCompare();
public ArrayList waypointRoutes = null;

public ArrayList waypointData = null;
public AudioSource audioBgroundSound = null;
private AudioSource audioS = null;
private bool nightTime = false;
private bool player1AiOn = true;
private bool prepped = false;
private bool ready = false;
private bool startGame = false;
private float startGameTime = 0.0f;
public bool gameWon = false;
public bool gameRunning = false;
public bool gamePaused = false;

Listing 10-4GameState Class Fields 2

这个集合中列出的第一个字段positions是一个整数数组,用于存储悬停赛车的位置索引,并进行正确排序。通过这种方式,可以在索引零处找到比赛中的领先汽车。下一个字段是一个布尔标志,用于指示位置数组当前正在排序。接下来的两个字段看起来是多余的,但是仔细观察,我们会发现它们不是多余的。currentIndex字段表示可用玩家数组中当前活动玩家的数组索引。下一个字段player1Index,是玩家一的索引,默认为零。因此,虽然这两个字段看起来像是重复的,但请记住,当前玩家的索引可以改变,但玩家 1 的索引将始终为零。

集合中的下一个字段应该是熟悉的。它是LapTimeManager类的一个实例,用于管理存储在游戏首选项中的一圈时间。该字段被恰当地命名为lapTimeManagertotalLaps字段代表当前赛道配置的总圈数。该值根据赛道相关的TrackScript、比赛类型和比赛难度进行设置。接下来列出的两个字段,gameSettingsSetgameSettingSubSet,是用于设置曲目类型和难度的类别字段。下一个字段debugOn用于打开GameState类的调试文本。该字段与我们之前讨论过的ON_GUI_SHOW_CAR_DETAILS静态类字段一起工作。

forceGameState字段是一个布尔标志,用于绕过某些正常的GameState类功能,并被大多数游戏演示场景使用。该值通常通过 Unity 编辑器的“检查器”面板设置,并保存为场景配置的一部分。scsMode字段用于帮助设置游戏,以便可以在不触发特定菜单屏幕(如游戏暂停菜单屏幕)的情况下拍摄游戏截图。style1style2字段是 Unity 的GUIStyle类的实例,由类的OnGUI方法用来直接在游戏屏幕上显示调试信息。

wc类字段是WaypointCompare类的一个实例,用于对给定轨迹的路点数组进行排序。下一个字段是一个ArrayList实例,存储在当前轨迹上找到的不同的路点路线。我应该提一下,这个游戏,在它目前的状态下,不使用路点路线,所以这个领域不会得到太好的支持。下一个区域waypointData用于保存在航迹上发现的所有航路点条目。接下来的两个条目由班级的音频责任使用。第一个是audioBgroundSound,用于保存音轨的背景音乐。第二个条目audioS,用于保存菜单音效。当菜单屏幕接收到用户输入时,它播放声音效果来指示输入事件。很多情况下,菜单画面会要求游戏状态类播放菜单音效。

随后,接下来的五个类字段是布尔标志,用于指示游戏的当前状态。nightTime字段是由轨道的TrackScript MonoBehaviour和它的headLightsOn字段设置的布尔标志。下一个条目,player1AiOn,是一个布尔标志,指示玩家一辆车应该由 AI 控制。这在游戏第一次加载时就出现了,“街机”风格的人工智能竞赛开始了。prepped字段用于指示游戏已经通过初始化players数组中的所有PlayerState实例而被正确准备好。一旦这个标志被设置为 true,对类'Prep方法的调用将被转义。

ready布尔字段是表示游戏准备开始的标志。startGame字段用于指示比赛是否开始,并应启动倒计时定时器。下一个字段startGameTime,用于记录比赛开始的倒计时。该集合中的最后三个字段是gameWongameRunninggamePaused布尔标志。gameWon区域表示当前玩家已经完成比赛。这并不一定意味着他们排在第一位。随后,gameRunning字段表明游戏正在运行,正如您所料。最后,gamePaused字段表示游戏正在运行,但已经暂停。

//***** Track Help Variables *****
public bool trackHelpAccelOn = false;
public float trackHelpAccelTime = 0.0f;
public bool trackHelpSlowOn = false;
public float trackHelpSlowTime = 0.0f;
public bool trackHelpTurnOn = false;
public float trackHelpTurnTime = 0.0f;

//***** Track Settings *****
public int raceTrack = 0;
public bool easyOn = false;
public int raceType = 0;
public int waypointWidthOverride = 6;
public int waypointZeroOverride = 1;
public bool trackHelpOn = false;
private TrackScript trackScript = null;
public GameDifficulty difficulty = GameDifficulty.LOW;
public GameStateIndex gameStateIndex = GameStateIndex.FIRST;

//***** Camera Variables *****
private GameObject blimpCamera = null;
public string sceneName = "";
public Camera gameCamera = null;
public Camera rearCamera = null;

Listing 10-5GameState Class Fields 3

前面列出了我们要查看的下一组类字段。该组中的第一组字段是“跟踪帮助变量”组。该组中的第一个字段trackHelpAccelOn和随后的条目trackHelpAccelTime用于打开帮助通知图像并跟踪其显示持续时间。类似地,下面列出的四个字段用于控制帮助减速和帮助转向通知,如果它们是由当前玩家触发的话。我们要看的下一组字段是“Track Settings”组。

“轨道设置”组从raceTrack字段开始。这个字段是我们当前正在比赛的赛道的数字表示。easyOn字段是一个布尔标志,指示当前比赛的难度设置是否为简单。随后的字段raceType用于指示当前比赛的模式。接下来的两个字段用于标准化轨迹的航路点的某些方面。waypointWidthOverride用于设置当前轨道上航路点的标准宽度。

以类似的方式,waypointZeroOverride域用于覆盖 Y 值为零的航路点标记的 Y 位置。trackHelpOn字段是一个布尔标志,用于控制音轨是否支持显示帮助通知。接下来的两个条目,difficultygameStateIndex,用于管理赛道的难度设置和游戏的状态。该组中要查看的最后一组字段是相机字段。blimpCamera字段是一个GameObject实例,用于引用游戏的飞艇摄像机功能。列表中的下一个字段sceneName是一个表示当前场景名称的字符串。最后,gameCamerarearCamera类字段用于支持游戏的标准和后视摄像头。在结束本复习部分之前,我们还有一组课程字段要复习。

//***** Menu System Variables *****
private GameObject gamePauseMenu = null;
private GameObject gameStartMenu = null;
private GameObject gameOverMenu = null;
private GameObject gameExitMenu = null;
private GameObject gameHelpMenu = null;
public GameHUDNewScript hudNewScript = null;
public GameOverMenu gameOverMenuScript = null;

//***** Touch screen Variables *****
public bool accelOn = false;
public bool newTouch = false;
public bool touchScreen = false;

//***** Input Variables *****
private bool handleKeyA = false;
private bool handleKeyD = false;
private bool handleKey1 = false;
private bool handleKey2 = false;
private bool handleKey3 = false;
private bool handleKey4 = false;
private bool handleKey5 = false;
private bool handleKey6 = false;

Listing 10-6GameState Class Fields 4

该组中的第一组字段是“菜单系统”字段。有五个条目代表游戏支持的不同菜单屏幕。hudNewScript字段是对与游戏 HUD 相关联的脚本组件的引用。下一个字段gameOverMenuScript是对与菜单屏幕上的游戏相关联的脚本组件的引用。注意,在我们需要更细粒度控制的情况下,我们得到对MonoBehaviour实例的引用。在其他情况下,有一个对相关游戏对象的引用就足够了。

在这个组之后,我们有“触摸屏”字段。accelOn条目是一个布尔标志,表示触摸屏加速输入处于活动状态。newTouch字段指示新的触摸交互正在发生。该组中的最后一个条目touchScreen是一个布尔标志,表示触摸屏输入处于活动状态。我们要查看的最后一组字段是“输入”组。这一套不言自明。每个条目启用或禁用某些键盘键的输入。这就引出了“类字段回顾”部分的结论。在下一节中,我们将看看相关的方法大纲和类头回顾部分。

相关的方法大纲/类头:GameState

这个GameState类的方法大纲有一系列的方法供我们回顾。别担心,我们将在详细的回顾中省略简单的支持方法,以加快速度。我们仍然会在这里列出它们。由于它们很简单,我们就不详细介绍了。让我们开始吧。

//Main Methods
public void PauseGame();
public void UnPauseGame();
public void FindWaypoints();
public void SetCarDetails();
public void ResetGame();
public void SetCarDetailsByGameType(PlayerState player);

public void SetActiveCar(int j);
public void PrepGame();
public void OnApplicationPause(bool pauseStatus);
void Start();
void Update();

//Support Methods
private int GetOnGuiPosY(int idx, int rowHeight);
public void OnGUI();

//Support Methods Menu Is Showing
private bool AreMenusShowing();
public bool IsHelpMenuShowing();
public bool IsPauseMenuShowing();
public bool IsEndMenuShowing();
public bool IsStartMenuShowing();
public bool IsExitMenuShowing();
public bool IsTrackHelpOn();

//Support Methods Show/Hide Menu
public void HideHelpMenu();
public void ShowHelpMenu();
public void HidePauseMenu();
public void ShowPauseMenu();
public void HideExitMenu();
public void ShowExitMenu();
public void HideStartMenu();
public void ShowStartMenu();
public void HideEndMenu();
public void ShowEndMenu();

//Support Methods Misc. 1
public void PlayMenuSound();
public void PrintWaypoints();
public ArrayList GetWaypoints(int index);
public void ToggleDebugOn();
public void ToggleCurrentCarAi();
private bool PlayerStateIdxCheck(int idx);
public PlayerState GetPlayer1();
public PlayerState GetCurrentPlayer();
public PlayerState GetPlayer(int i);

//Support Methods Track Features On/Off

private void TurnOffArmorMarkers();
private void TurnOnArmorMarkers();
private void TurnOffGunMarkers();
private void TurnOnGunMarkers();
private void TurnOffHealthMarkers();
private void TurnOnHealthMarkers();
private void TurnOffInvincMarkers();
private void TurnOnInvincMarkers();
private void TurnOffHittableMarkers();
private void TurnOnHittableMarkers();
private void TurnOffOilDrumStackMarkers();
private void TurnOnOilDrumStackMarkers();
private void TurnOffFunBoxMarkers();
private void TurnOnFunBoxMarkers();

//Support Methods Misc. 2
private void AdjustTagActive(bool active, string tag);

public void LogLapTime(PlayerState p);
public void StartDemoScene();
public int GetPosition(int idx, int currentPosition);
public void SetPositions();
public int PlayerStateCompare(int i1, int i2);

Listing 10-7GameState Pertinent Method Outline/Class Headers 1

花点时间看看与这个类相关的方法。发挥你的想象力,试着想象这个类及其使用的方法。我们一会儿将回顾这些方法。在我们继续之前,让我们看看这个类的 import 语句和声明。

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

public class GameState : MonoBehaviour {}

Listing 10-8GameState Pertinent Method Outline/Class Headers 2

在接下来的回顾部分,我们将讨论类的支持方法。

支持方法详细信息:游戏状态

我们要看的第一组支持方法简单而直接。我把方法列在这里给你看看。由于它们的简单性,我不会详细讨论它们。请确保您在继续之前回顾并理解了这些方法。

01 private int GetOnGuiPosY(int idx, int rowHeight) {
02    return (idx * rowHeight);
03 }

01 private bool AreMenusShowing() {
02    if (IsStartMenuShowing() == true || IsEndMenuShowing() == true || IsHelpMenuShowing() == true || IsExitMenuShowing() == true) {
03       return true;
04    } else {
05       return false;
06    }
07 }

01 public bool IsHelpMenuShowing() {
02    if (gameHelpMenu != null) {
03       return gameHelpMenu.activeSelf;
04    } else {
05       return false;
06    }

07 }

01 public bool IsPauseMenuShowing() {
02    if (gamePauseMenu != null) {
03       return gamePauseMenu.activeSelf;
04    } else {
05       return false;
06    }
07 }

01 public bool IsEndMenuShowing() {
02    if (gameOverMenu != null) {
03       return gameOverMenu.activeSelf;
04    } else {
05       return false;
06    }
07 }

01 public bool IsStartMenuShowing() {
02    if (gameStartMenu != null) {
03       return gameStartMenu.activeSelf;
04    } else {
05       return false;
06    }
07 }

01 public bool IsExitMenuShowing() {
02    if (gameExitMenu != null) {
03       return gameExitMenu.activeSelf;
04    } else {
05       return false;
06    }
07 }

01 public bool IsTrackHelpOn() {
02    return trackHelpOn;
03 }

01 public void HideHelpMenu() {
02    if (gameHelpMenu != null) {
03       gameHelpMenu.SetActive(false);
04       UnPauseGame();
05    }

06 }

01 public void ShowHelpMenu() {
02    if (gameHelpMenu != null) {
03       gameHelpMenu.SetActive(true);
04       PauseGame();
05    }
06 }

01 public void HidePauseMenu() {
02    if (gamePauseMenu != null) {
03       gamePauseMenu.SetActive(false);
04       UnPauseGame();
05    }
06 }

01 public void ShowPauseMenu() {
02    if (gamePauseMenu != null) {
03       gamePauseMenu.SetActive(true);
04       PauseGame();
05    }
06 }

01 public void HideExitMenu() {
02    if (gameExitMenu != null) {
03       gameExitMenu.SetActive(false);
04       UnPauseGame();
05    }
06 }

01 public void ShowExitMenu() {
02    if (gameExitMenu != null) {
03       gameExitMenu.SetActive(true);
04       PauseGame();
05    }
06 }

01 public void HideStartMenu() {
02    if (gameStartMenu != null) {

03       gameStartMenu.SetActive(false);
04    }
05 }

01 public void ShowStartMenu() {
02    if (gameStartMenu != null) {
03       gameStartMenu.SetActive(true);
04    }
05 }

01 public void HideEndMenu() {
02    if (gameOverMenu != null) {
03       gameOverMenu.SetActive(false);
04    }
05 }

01 public void ShowEndMenu() {
02    if (gameOverMenu != null) {
03       gameOverMenu.SetActive(true);
04    }
05 }

01 public void PlayMenuSound() {
02    if (audioS != null) {
03      audioS.Play();
04    }
05 }

01 public void ToggleDebugOn() {
02    if (debugOn == true) {
03       debugOn = false;
04    } else {
05       debugOn = true;
06    }
07 }

01 private bool PlayerStateIdxCheck(int idx) {
02    if (players != null && idx >= 0 && idx < players.Count) {
03       return true;
04    } else {
05       return false;
06    }
07 }

01 public PlayerState GetPlayer1() {
02    return (PlayerState)players[player1Index];
03 }

01 public PlayerState GetCurrentPlayer() {
02    return (PlayerState)players[currentIndex];
03 }

01 public PlayerState GetPlayer(int i) {
02    if (i >= 0 && i < players.Count) {
03       return (PlayerState)players[i];
04    } else {
05       return null;
06    }
07 }

01 private void TurnOffArmorMarkers() {
02    AdjustTagActive(false, "ArmorMarker");
03 }

01 private void TurnOnArmorMarkers() {
02    AdjustTagActive(true, "ArmorMarker");
03 }

01 private void TurnOffGunMarkers() {
02    AdjustTagActive(false, "GunMarker");
03 }

01 private void TurnOnGunMarkers() {
02    AdjustTagActive(true, "GunMarker");
03 }

01 private void TurnOffHealthMarkers() {
02    AdjustTagActive(false, "HealthMarker");
03 }

01 private void TurnOnHealthMarkers() {
02    AdjustTagActive(true, "HealthMarker");
03 }

01 private void TurnOffInvincMarkers() {
02    AdjustTagActive(false, "InvincibilityMarker");
03 }

01 private void TurnOnInvincMarkers() {
02    AdjustTagActive(true, "InvincibilityMarker");
03 }

01 private void TurnOffHittableMarkers() {
02    AdjustTagActive(false, "Hittable");
03 }

01 private void TurnOnHittableMarkers() {
02    AdjustTagActive(true, "Hittable");
03 }

01 private void TurnOffOilDrumStackMarkers() {
02    AdjustTagActive(false, "OilDrumStack");
03 }

01 private void TurnOnOilDrumStackMarkers() {
02    AdjustTagActive(true, "OilDrumStack");
03 }

01 private void TurnOffFunBoxMarkers() {
02    AdjustTagActive(false, "FullFunBox");
03 }

01 private void TurnOnFunBoxMarkers() {
02    AdjustTagActive(true, "FullFunBox");
03 }

01 public void StartDemoScene() {
02    PlayerPrefs.SetInt("GameStateIndex", 5);
03    PlayerPrefs.Save();
04    ResetGame();
05    SceneManager.LoadScene(SceneManager.GetActiveScene().name);
06 }

01 public int GetPosition(int idx, int currentPosition) {
02    int i = 0;
03    int l = 0;
04    l = positions.Length;
05
06    for (i = 0; i < l; i++) {
07       if (positions[i] == idx) {
08          return (i + 1);
09       }
10    }
11
12    return 6;
13 }

01 public void SetPositions() {
02    sortingPositions = true;
03    System.Array.Sort(positions, PlayerStateCompare);
04    sortingPositions = false;
05 }

Listing 10-9GameState Support Method Details 1

前面列出的类支持方法非常简单,所以我将它们留给您自己去回顾。在接下来的支持方法列表中,我们将回顾这些方法来解释它们是如何工作的。这些支持方法稍微复杂一点,所以它们值得更多的关注。我应该很快提到,我没有在主方法列表或支持方法列表中列出OnGUI方法。这是因为该方法相当长,除了将调试值打印到屏幕上之外,它没做什么。该方法的审查是可选的。我将把它留给你来判断。

01 public void PrintWaypoints() {
02    if (waypointData != null && waypointData.Count > 0) {
03       ArrayList data = (ArrayList)waypointData[0];
04       int l = data.Count;
05       WaypointCheck wc = null;
06       for (int j = 0; j < l; j++) {
07          wc = (WaypointCheck)data[j];
08          if (SHOW_WAYPOINT_OUTPUT) {
09             Utilities.wr(j + " Found waypoint: " + wc.waypointIndex + ", Center: " + wc.transform.position);
10          }
11       }

12    }
13 }

01 public ArrayList GetWaypoints(int index) {
02    if (waypointData == null) {
03       return null;
04    } else {
05       if (index >= 0 && index < waypointData.Count) {
06          return (ArrayList)waypointData[index];
07       } else {
08          return null;
09       }
10    }
11 }

01 public void ToggleCurrentCarAi() {
02    PlayerState player;
03    player = GetCurrentPlayer();
04
05    if (player1AiOn == true) {
06       player1AiOn = false;
07    } else {
08       player1AiOn = true;
09    }
10
11    if (player1AiOn == true) {
12       player.aiOn = true;
13       player.cm.aiOn = true;
14       player.fpsInput.aiOn = true;
15       player.mouseInput.aiOn = true;
16       player.offTrackSeconds = 5.0f;
17    } else {
18       player.aiOn = false;
19       player.cm.aiOn = false;
20       player.fpsInput.aiOn = false;
21       player.mouseInput.aiOn = false;
22       player.offTrackSeconds = 10.0f;
23    }
24
25    if (forceGameStart) {
26       player.offTrackSeconds = 10000.0f;
27       player.wrongDirectionSeconds = 10000.0f;
28    }
29 }

Listing 10-10GameState Support Method Details 2

前面列出了第一组更复杂的支持方法。我们有两个航路点方法和一个人工智能相关的方法要复习。列出的第一种方法PrintWaypoints用于列出所有与默认航路相关的航路点。如果在第 2 行定义并填充了waypointData字段,那么我们在该方法的第 3 行加载默认路线的路点。对于找到的数据中的每个航路点,我们打印出其设置的摘要,第 7-10 行。我们要回顾的下一个航路点是GetWaypoints方法。如果定义了waypointData字段,那么我们为给定的路线索引找到一组路点。如果未定义数据的索引,则该方法返回空值。这一套方法中我们要复习的最后一个方法是ToggleCurrentAi法。

这个方法用于切换当前玩家汽车的 AI 状态。注意,AI 标志随后被设置在与当前玩家相关联的所有输入类上。只有当forceGameStart布尔值被设置为真时,最后一位代码才会运行。我们设置了一些大的定时值,以防止偏离轨道和错误的方向修改器触发。还有几个支持方法让我们看看。我把它们列在这里。

01 private void AdjustTagActive(bool active, string tag) {
02    GameObject[] gos = GameObject.FindGameObjectsWithTag(tag);
03    int l = gos.Length;
04    for (int i = 0; i < l; i++) {
05       gos[i].SetActive(active);
06    }
07 }

01 public void LogLapTime(PlayerState p) {
02    string time = p.time;
03    int timeNum = p.timeNum;
04    int lap = p.currentLap;
05    int track = raceTrack;
06    int type = gameSettingsSet;
07    int diff = gameSettingsSubSet;
08
09    LapTime lt = new LapTime();
10    lt.time = time

;
11    lt.timeNum = timeNum;
12    lt.lap = lap;
13    lt.type = type;
14    lt.diff = diff;
15    lt.track = track;
16
17    lapTimeManager.AddEntry(lt);
18    lapTimeManager.CleanTimes();
19    lapTimeManager.FindBestLapTimeByLastEntry();
20    PlayerPrefs.SetString("LapTimes", lapTimeManager.Serialize());
21 }

01 public int PlayerStateCompare(int i1, int i2) {
02    if (!PlayerStateIdxCheck(i1) || !PlayerStateIdxCheck(i2)) {
03       return 0;
04    }
05
06    PlayerState obj1 = (PlayerState)players[i1];
07    PlayerState obj2 = (PlayerState)players[i2];
08
09    if (obj1.currentLap > obj2.currentLap) {
10       return -1;
11    } else if (obj1.currentLap < obj2.currentLap) {
12       return 1;
13    } else {
14       if (obj1.aiWaypointIndex > obj2.aiWaypointIndex) {
15          return -1;
16       } else if (obj1.aiWaypointIndex < obj2.aiWaypointIndex) {
17          return 1;
18       } else {
19          if (obj1.aiWaypointTime < obj2.aiWaypointTime) {
20             return 1;
21          } else if (obj1.aiWaypointTime > obj2.aiWaypointTime) {
22             return -1;
23          } else {
24             return 0;
25          }
26       }
27    }
28 }

Listing 10-11GameState Support Method Details 3

前面列出的最后一组支持方法是一个组合组。列出的第一种方法非常有用。AdjustTagActive方法用于定位所有具有指定标签的GameObjects。这些对象将它们的活动标志设置为第 5 行提供的参数值。下面列出的方法用于将一圈时间添加到玩家的圈时间日志中。在第 2-7 行,根据提供的PlayerState值、p和赛道的当前配置设置存储圈速所需的值。接下来,在第 9–15 行,创建了一个新的LapTime对象实例,并根据准备好的方法变量设置了对象的字段。单圈时间被添加到游戏的单圈时间管理器的第 17 行。第 18 行清除了分段时间,第 19 行的方法调用确定了最佳分段时间。最后,用第 20 行的lapTimeManager字段中新的序列化值更新玩家偏好。

我们将在本节中回顾的最后一个方法是PlayerStateCompare方法。这种方法用于比较两个玩家,以确定哪个玩家在当前比赛中处于哪个位置。首先,在第 2-4 行检查提供的玩家索引i1i2的有效性。在第 9-27 行,两名选手在比赛中的顺序由当前圈决定,然后是当前路点索引,最后是最快圈速。这就是本复习部分的结论。接下来,我们将看看这个类的主要方法。

主要方法细节:GameState

GameState类有几个主要的方法让我们复习。让我们开始写代码吧!

01 public void PauseGame() {
02    gamePaused = true;
03    Time.timeScale = 0;
04
05    if (players != null) {
06       iPg = 0;
07       pPg = null;
08       lPg = players.Count;
09       for (iPg = 0; iPg < lPg; iPg++) {
10          pPg = (PlayerState)players[iPg];
11          if (pPg != null) {
12             pPg.PauseSound();
13          }
14       }
15    }
16
17    if (audioBgroundSound != null) {
18       audioBgroundSound.Stop();
19    }
20 }

01 public void UnPauseGame() {
02    gamePaused = false;
03    Time.timeScale = 1;
04
05    if (players != null) {
06       iUpg = 0;
07       pUpg = null;
08       lUpg = players.Count;
09       for (iUpg = 0; iUpg < lUpg; iUpg++) {
10          pUpg = (PlayerState)players[iUpg];
11          if (pUpg != null) {
12             pUpg.UnPauseSound();
13          }
14       }
15    }

16
17    if (audioBgroundSound != null) {
18       audioBgroundSound.Play();
19    }
20 }

01 public void FindWaypoints() {
02    GameObject[] list = GameObject.FindGameObjectsWithTag("Waypoint");
03    ArrayList routes = new ArrayList();
04    int l = list.Length;
05    WaypointCheck wc = null;
06    int i = 0;
07    int j = 0;
08
09    for (i = 0; i < l; i++) {
10       if (list[i].activeSelf == true) {
11          wc = (list[i].GetComponent<WaypointCheck>());
12          if (wc != null) {
13             if (routes.Contains(wc.waypointRoute + "") == false) {
14                routes.Add(wc.waypointRoute + "");
15             }
16          }
17       }
18    }
19
20    ArrayList waypoints = new ArrayList();
21    ArrayList row = new ArrayList();
22    l = routes.Count;
23
24    for (i = 0; i < l; i++) {
25       row.Clear();
26       int l2 = list.Length;
27       for (j = 0; j < l2; j++) {
28          if (list[j].activeSelf == true) {
29             if (waypointWidthOverride != -1) {
30                if (list[j].transform.localScale.z < 10) {
31                   list[j].transform.localScale.Set(list[j].transform.localScale.x, list[j].transform.localScale.y, waypointWidthOverride);
32                }
33             }
34
35             if (waypointZeroOverride != -1) {
36                if (list[j].transform.localPosition.y == 0) {
37                    list[j].transform.localScale.Set(list[j].transform.localScale.x, 1, waypointWidthOverride);
38                }
39             }

40
41             wc = (list[j].GetComponent<WaypointCheck>());
42             if (wc != null) {
43                if ((wc.waypointRoute + "") == (routes[i] + "")) {
44                   row.Add(wc);
45                }
46             }
47          }
48       }
49
50       object[] ar = row.ToArray();
51       System.Array.Sort(ar, wpc);
52       row = new ArrayList(ar);
53       l2 = row.Count;
54
55       for (j = 0; j < l2; j++) {
56          wc = (WaypointCheck)row[j];
57          wc.waypointIndex = j;
58       }
59
60       waypoints.Add(row);
61    }
62
63    waypointRoutes = routes;
64    waypointData = waypoints;
65 }

Listing 10-12GameState Main Method Details 1

我们要看的第一组主要方法包括游戏的暂停和取消暂停方法以及路点加载方法。让我们来看看!PauseGame方法用于在游戏窗口失去焦点时停止游戏。这可以在 Unity 编辑器中通过运行主游戏并在运行时切换到另一个应用来测试。注意第 2 行的gamePaused布尔标志被设置为真,并且Time.timeScale字段的值被设置为零。

这有停止游戏引擎的效果,第 3 行。如果定义了活动玩家的数组,则在第 6–8 行设置循环控制变量。循环遍历游戏的玩家,并暂停每个玩家的音频,第 9-14 行。随后,在第 17-19 行,背景音乐暂停。集合中列出的第二种方法是UnPauseGame方法。这个方法颠倒了游戏的暂停方法。仔细检查这个方法,注意时间刻度恢复为 1。列出的最后一种方法负责查找和准备所有的路点。所有带有“航路点”标签的 Unity GameObjects位于第 2 行。在第 3-7 行设置了临时路线ArrayList和一些回路控制变量。接下来,在第 9–18 行,我们遍历所有的路点,并将任何唯一的路点路线添加到路线数组中。在第 20–22 行,我们准备了一些方法变量。row变量用作航路点数据的临时保存器,而waypoints变量保存最终的航路点数据。

我们在第 24 行的已知路线上循环,变量temp在第 25 行被重置,一个新的长度变量在第 26 行被设置。然后在第 27 行,我们循环遍历已知航路点的列表。如果航路点是激活的,我们检查是否必须应用航路点宽度覆盖,第 29-33 行,和航路点零点覆盖,第 35-39 行。第 41 行设置了WaypointCheck脚本组件。如果该航路点是航路点组的成员,我们将其添加到row变量,第 42–46 行。在第 50–52 行,我们对找到的路点进行排序,并重置 row 变量。在第 53 行设置l2变量,在第 55-58 行重置航路航路点索引。在第 60 行,路线航点被添加到waypoints变量中。最后,在第 63-64 行,找到的航路点数据存储在类别字段waypointRouteswaypointData中。

01 public void SetCarDetails() {
02    PlayerState player = null;
03    int i = 0;
04    int l = players.Count;
05
06    for (i = 0; i < l; i++) {
07       if (PlayerStateIdxCheck(i)) {
08          player = (PlayerState)players[i];
09          if (player != null) {
10             if (i == player1Index) {
11                gameCamera = player.camera;
12                player.camera.enabled = true;
13
14                rearCamera = player.rearCamera;
15                player.rearCamera.enabled = true;
16                player.audioListener.enabled = true;
17
18                if (player1AiOn == true) {
19                   player.aiOn = true;
20                   player.cm.aiOn = true;
21                   player.fpsInput.aiOn = true;
22                   player.mouseInput.aiOn = true;
23                } else {
24                   player.aiOn = false;
25                   player.cm.aiOn = false;
26                   player.fpsInput.aiOn = false;
27                   player.mouseInput.aiOn = false;
28                }
29             } else {
30                player.camera.enabled = false;
31                player.rearCamera.enabled = false;
32                player.audioListener.enabled = false;
33                player.aiOn = true;
34                player.cm.aiOn = true;
35                player.fpsInput.aiOn = true;
36                player.mouseInput.aiOn = true;
37             }
38             player.waypoints = GetWaypoints(0);
39          }
40       }
41    }
42 }

01 public void ResetGame() {
02    gamePaused = true;
03    Time.timeScale = 0;
04
05    prepped = false;
06    ready = false;
07    startGame = false;
08    startGameTime = 0.0f;
09    gameRunning = false;
10    gameWon = false;
11
12    Time.timeScale = 1;
13    gamePaused = false;
14 }

01 public void SetCarDetailsByGameType(PlayerState player) {
02    int idx = player.index;
03    player.player = GameObject.Find(Utilities.NAME_PLAYER_ROOT + idx);
04    player.player.transform.position = GameObject.Find(Utilities.NAME_START_ROOT + idx).transform.position;
05
06    player.maxSpeed = PlayerState.DEFAULT_MAX_SPEED;
07    player.gravity = PlayerState.DEFAULT_GRAVITY;
08
09    player.maxForwardSpeedSlow = 50;
10    player.maxSidewaysSpeedSlow = 12;
11    player.maxBackwardsSpeedSlow = 5;
12    player.maxGroundAccelerationSlow = 25;
13
14    player.maxForwardSpeedNorm = 200;
15    player.maxSidewaysSpeedNorm = 50;
16    player.maxBackwardsSpeedNorm = 20;
17    player.maxGroundAccelerationNorm = 100;
18
19    player.maxForwardSpeedBoost = 250;
20    player.maxSidewaysSpeedBoost = 60;
21    player.maxBackwardsSpeedBoost = 30;
22    player.maxGroundAccelerationBoost = 120;
23
24    if (idx != player1Index) {
25       if (difficulty == GameDifficulty.LOW) {
26          player.maxSpeed = PlayerState.DEFAULT_MAX_SPEED;
27          player.maxGroundAccelerationNorm += 5;
28       } else if (difficulty == GameDifficulty.MED) {
29          player.maxSpeed = PlayerState.DEFAULT_MAX_SPEED + 5;
30          player.maxForwardSpeedNorm += 10;
31          player.maxGroundAccelerationNorm += 10;
32       } else if (difficulty == GameDifficulty.HIGH) {
33          player.maxSpeed = PlayerState.DEFAULT_MAX_SPEED + 10;
34          player.maxForwardSpeedNorm += 15;
35          player.maxGroundAccelerationNorm += 40;
36          player.maxForwardSpeedBoost += 15;
37          player.maxGroundAccelerationBoost += 15;
38       }

39    } else if (idx == player1Index) {
40       player.maxSpeed += Random.Range(0, 12);
41       player.maxForwardSpeedNorm += Random.Range(0, 6);
42       player.maxGroundAccelerationNorm += Random.Range(0, 6);
43       player.maxForwardSpeedBoost += Random.Range(0, 6);
44       player.maxGroundAccelerationBoost += Random.Range(0, 6);
45    }
46 }

01 public void SetActiveCar(int j) {
02    if (debugOn == false) {
03       Utilities.wr("Method SetActiveCar says: debugOn is false, returning.");
04       return;
05    }
06
07    PlayerState player = null;
08    int l = players.Count;
09    for (int i = 0; i < l; i++) {
10       player = (PlayerState)players[i];
11       if (player != null && player.player != null) {
12          if (j == i) {
13             currentIndex = i;
14             currentPlayer = (PlayerState)players[currentIndex];
15             player.camera.enabled = true;
16             player.rearCamera.enabled = true;
17             player.audioListener.enabled = true;
18          } else {
19             player.camera.enabled = false;
20             player.rearCamera.enabled = false;
21             player.audioListener.enabled = false;
22          }

23       }
24    }
25 }

Listing 10-13GameState Main Method Details 2

这组主要方法中列出的第一个方法是SetCarDetails方法。这个方法用于将每辆车配置为 AI 或玩家控制的车。在第 2–4 行,该方法准备了一些局部变量,用于在活动玩家数组中循环。循环从第 6 行开始,如果PlayerState实例player不为空,那么我们在第 9–39 行处理这个玩家。关于这个代码块,第 10–28 行代码的第一个分支被应用到应该由玩家控制的汽车上。在第 11-165 行,悬停赛车被插入游戏的 HUD。如果这辆车应该是人工智能控制的,那么它在第 19-22 行被配置成这样。否则,第 24–27 行的代码会关闭这辆车的 AI。剩下的悬浮赛车都在 30-36 行被配置为人工智能控制。

列出的下一个主要方法是ResetGame方法。请注意该方法的第 2-3 行。请注意,游戏被标记为暂停,游戏的时间刻度被设置为零。在第 5–10 行,该方法重置键类字段。在第 12–13 行的方法结束时,游戏暂停标记被设置为 false,时间刻度返回到值 1。集合中列出的第三种方法是SetCarDetailsByGameType,它用于根据当前游戏类型为每个悬停参赛者准备正确的设置。第一行代码从第 2 行传入的PlayerState实例中获取玩家的索引。玩家的GameObjectTransform在 3、4 线得到强化。悬停赛车的最大速度、重力以及慢速、正常和加速速度设置在第 6-22 行。如果赛车的指数不是玩家一的指数,那么我们根据赛道的难度调整赛车的配置,第 25-38 行。

第 40–44 行的最后一个代码块为人类玩家配置汽车。这就把我们带到了这一组中的最后一个方法,即SetActiveCar方法。这个方法用于设置比赛中当前活跃的玩家。这不会使汽车受到人工智能或人类的控制,但它会将汽车连接到游戏的 HUD 中。要检查这个功能,在 Unity 编辑器中运行“Main13Demonstration”场景,并尝试按键盘上的数字键,数字 1 到 6。你会注意到,你可以使用这个功能切换到不同的汽车。回到代码,注意如果debugOn布尔字段被设置为 false,那么这个方法被转义。

如果没有,则在第 7–8 行准备循环控制变量。我们循环当前的一组玩家,如果定义了当前玩家,我们将它设置为游戏的当前玩家,游戏现在将显示汽车的摄像头、后视摄像头和飞艇摄像头,它们会相应地进行调整。在第 19–21 行,如果悬停赛车与指定的玩家索引不匹配,它将被设置为非活动模式。在GameState类中还有一些主要的方法需要复习。下一个我们将要讨论的是非常重要的PrepGame方法。这个方法被许多类调用,以确保游戏和它的玩家被正确配置。由于该方法的长度,我们将分块回顾它。让我们看一下该方法的第一个块。

001 public void PrepGame() {
002    if (prepped == true) {
003       return;
004    }
005    prepped = true;
006
007    //Prep waypoints and track script settings
008    FindWaypoints();
009    trackScript = GetComponent<TrackScript>();
010    totalLaps = trackScript.laps;
011    nightTime = trackScript.headLightsOn;
012    sceneName = trackScript.sceneName;
013
014    //Prep menu screens
015    if (hudNewScript == null) {
016       if (GameObject.Find("GameHUD") != null) {
017          hudNewScript = GameObject.Find("GameHUD").GetComponent<GameHUDNewScript>();
018       }

019    }
020
021    if (hudNewScript != null) {
022       hudNewScript.HideAll();
023    }
024
025    if (gameOverMenuScript == null) {
026       if (GameObject.Find("GameOverMenu") != null) {
027          gameOverMenuScript = GameObject.Find("GameOverMenu").GetComponent<GameOverMenu>();
028       }
029    }
030
031    if (gameOverMenuScript != null) {
032       gameOverMenuScript.HideWinImage();
033       gameOverMenuScript.ShowLoseImage();
034    }
035
036    if (audioBgroundSound == null) {
037       if (GameObject.Find("BgMusic") != null) {
038          audioBgroundSound = GameObject.Find("BgMusic").GetComponent<AudioSource>();
039       }
040    }
041
042    if (gamePauseMenu == null) {
043       gamePauseMenu = GameObject.Find("GamePauseMenu");
044       if (gamePauseMenu != null) {
045          gamePauseMenu.SetActive(false);
046       }
047    }
048
049    if (gameStartMenu == null) {
050       gameStartMenu = GameObject.Find("GameStartMenu");
051       if (gameStartMenu != null) {
052          gameStartMenu.SetActive(false);
053       }
054    }

055
056    if (gameOverMenu == null) {
057       gameOverMenu = GameObject.Find("GameOverMenu");
058       if (gameOverMenu != null) {
059          gameOverMenu.SetActive(false);
060       }
061    }
062
063    if (gameExitMenu == null) {
064       gameExitMenu = GameObject.Find("GameExitMenu");
065       if (gameExitMenu != null) {
066          gameExitMenu.SetActive(false);
067       }
068    }
069
070    if (gameHelpMenu == null) {
071       gameHelpMenu = GameObject.Find("GameHelpMenu");
072       if (gameHelpMenu != null) {
073          gameHelpMenu.SetActive(false);
074       }
075    }
076
077    //Prep player prefs default values
078    if (FIRST_RUN && gameStateIndex == GameStateIndex.FIRST) {
079       PlayerPrefs.DeleteKey("GameStateIndex");
080       if (PlayerPrefs.HasKey("EasyOn") == false && PlayerPrefs.HasKey("BattleOn") == false && PlayerPrefs.HasKey("ClassicOn") == false) {
081          PlayerPrefs.SetInt("EasyOn", 1);
082          PlayerPrefs.SetInt("BattleOn", 0);
083          PlayerPrefs.SetInt("ClassicOn", 0);
084       }
085
086       if (PlayerPrefs.HasKey("LowOn") == false && PlayerPrefs.HasKey("MedOn") == false && PlayerPrefs.HasKey("HighOn") == false) {
087          PlayerPrefs.SetInt("LowOn", 1);
088          PlayerPrefs.SetInt("MedOn", 0);
089          PlayerPrefs.SetInt("HighOn", 0);
090       }
091       PlayerPrefs.Save();
092    }
093
094    //Prep lap time manager
095    string tmpStr = PlayerPrefs.GetString("LapTimes", "");
096    lapTimeManager = new LapTimeManager();
097    if (tmpStr != null && tmpStr != "") {
098       Utilities.wr("Found lap times: " + tmpStr);
099       lapTimeManager.Deserialize(tmpStr);
100    }
101

Listing 10-14GameState Main Method Details 3

顾名思义,这种方法的主要目的是为比赛做准备。让我们一次一个地了解不同的职责。首先,如果已经调用了PrepGame方法,就对其进行转义,第 2–4 行。航路点被加载,任何TrackScript值被应用于第 8-12 行的准备航路点部分。对所有菜单系统游戏对象的引用配置在第 15–75 行。代码很简单。每个菜单屏幕游戏对象都是按名称加载的,如果有定义,随后会被停用。注意,在第 16–18 行,为hudNewScript字段创建了一个脚本组件引用。在第 27 行,同样的过程用于加载对gameOverMenuScript的引用。使用这些对象,我们可以调用类方法来调整菜单屏幕上的 HUD 和游戏的设置。

第 78–92 行的下一部分代码负责通过为比赛类型和难度设置一些默认值来准备玩家偏好。接下来,在第 95–100 行的代码块中,lapTimeManager被初始化,当前存储的分段时间(如果有的话)被反序列化并在第 99 行的lapTimeManager实例中激活。我们将在下面列出的下一个代码块中继续回顾这个方法。

102    //Prep difficulty
103    if (PlayerPrefs.HasKey("LowOn") == true && PlayerPrefs.GetInt("LowOn") == 1) {
104       difficulty = GameDifficulty.LOW;
105    } else if (PlayerPrefs.HasKey("MedOn") == true && PlayerPrefs.GetInt("MedOn") == 1) {
106       difficulty = GameDifficulty.MED;
107    } else if (PlayerPrefs.HasKey("HighOn") == true && PlayerPrefs.GetInt("HighOn") == 1) {
108       difficulty = GameDifficulty.HIGH;
109    }

110
111    //Prep track configuration
112    if (PlayerPrefs.HasKey("EasyOn") && PlayerPrefs.GetInt("EasyOn") == 1) {
113       gameSettingsSet = 0;
114       totalLaps = 2;
115       TurnOffArmorMarkers();
116       TurnOffGunMarkers();
117       TurnOffInvincMarkers();
118       TurnOffHealthMarkers();
119       TurnOffHittableMarkers();
120       if (difficulty == GameDifficulty.LOW) {
121          gameSettingsSubSet = 0;
122          TurnOffOilDrumStackMarkers();
123          TurnOffFunBoxMarkers();
124       } else if (difficulty == GameDifficulty.MED) {
125          gameSettingsSubSet = 1;
126          TurnOffOilDrumStackMarkers();
127          TurnOnFunBoxMarkers();
128       } else if (difficulty == GameDifficulty.HIGH) {
129          gameSettingsSubSet = 2;
130          TurnOnOilDrumStackMarkers();
131          TurnOnFunBoxMarkers();
132       }
133    } else if (PlayerPrefs.HasKey("BattleOn") && PlayerPrefs.GetInt("BattleOn") == 1) {
134       gameSettingsSet = 1;
135       totalLaps = trackScript.laps;
136       TurnOnArmorMarkers();
137       TurnOnGunMarkers();
138       TurnOnInvincMarkers();
139       TurnOnHealthMarkers();
140       TurnOnHittableMarkers();
141       if (difficulty == GameDifficulty.LOW) {
142          gameSettingsSubSet = 0;
143          TurnOffOilDrumStackMarkers();
144          TurnOffFunBoxMarkers();
145       } else if (difficulty == GameDifficulty.MED) {
146          gameSettingsSubSet = 1;
147          TurnOffOilDrumStackMarkers();
148          TurnOnFunBoxMarkers();
149       } else if (difficulty == GameDifficulty.HIGH) {
150          gameSettingsSubSet = 2;
151          TurnOnOilDrumStackMarkers();
152          TurnOnFunBoxMarkers();
153       }

154    } else if (PlayerPrefs.HasKey("ClassicOn") && PlayerPrefs.GetInt("ClassicOn") == 1) {
155       gameSettingsSet = 2;
156       totalLaps = 4;
157       TurnOffArmorMarkers();
158       TurnOffGunMarkers();
159       TurnOffInvincMarkers();
160       TurnOffHealthMarkers();
161       TurnOnHittableMarkers();
162       if (difficulty == GameDifficulty.LOW) {
163          gameSettingsSubSet = 0;
164          TurnOffOilDrumStackMarkers();
165          TurnOffFunBoxMarkers();
166       } else if (difficulty == GameDifficulty.MED) {
167          gameSettingsSubSet = 1;
168          TurnOffOilDrumStackMarkers();
169          TurnOnFunBoxMarkers();
170       } else if (difficulty == GameDifficulty.HIGH) {
171          gameSettingsSubSet = 2;
172          TurnOnOilDrumStackMarkers();
173          TurnOnFunBoxMarkers();
174       }
175    }
176
177    //Prep game state
178    if (!FIRST_RUN && PlayerPrefs.HasKey("GameStateIndex") == true) {
179       gsiTmp = PlayerPrefs.GetInt("GameStateIndex");
180       if (gsiTmp == 0) {
181          gameStateIndex = GameStateIndex.FIRST;
182       } else if (gsiTmp == 1) {
183          gameStateIndex = GameStateIndex.NONE;
184       } else if (gsiTmp == 2) {
185          gameStateIndex = GameStateIndex.MAIN_MENU_SCREEN;
186       } else if (gsiTmp == 3) {
187          gameStateIndex = GameStateIndex.GAME_OVER_SCREEN;
188       } else if (gsiTmp == 4) {
189          gameStateIndex = GameStateIndex.GAME_PAUSE_SCREEN;
190       } else if (gsiTmp == 5) {
191          gameStateIndex = GameStateIndex.GAME_PLAY_SCREEN;
192       } else if (gsiTmp == 6) {
193          gameStateIndex = GameStateIndex.MAIN_MENU_SCREEN;
194       }

195    }
196
197    if (gameStateIndex == GameStateIndex.NONE || gameStateIndex == GameStateIndex.FIRST) {
198       gameStateIndex = GameStateIndex.MAIN_MENU_SCREEN;
199    }
200
201    if (gameStateIndex == GameStateIndex.MAIN_MENU_SCREEN) {
202       player1AiOn = true;
203       ShowStartMenu();
204       HidePauseMenu();
205       HideEndMenu();
206    } else if (gameStateIndex == GameStateIndex.GAME_OVER_SCREEN) {
207       player1AiOn = true;
208       ShowStartMenu();
209       HidePauseMenu();
210       HideEndMenu();
211    } else if (gameStateIndex == GameStateIndex.GAME_PAUSE_SCREEN) {
212       ShowPauseMenu();
213    } else if (gameStateIndex == GameStateIndex.GAME_PLAY_SCREEN) {
214       HidePauseMenu();
215       HideEndMenu();
216       HideStartMenu();
217    }
218

Listing 10-15GameState Main Method Details 4

类别字段difficulty根据当前曲目难度的玩家偏好值进行设置。该值在方法行 112–175 的下一段代码中被广泛使用。Hover Racers 游戏能够调整赛道特性,以反映比赛类型和难度。这段代码非常直接。跟踪它,你会看到基于不同的轨道设置打开或关闭了哪些轨道功能。这就把我们带到了第 178–217 行代码的一个重要部分。这段代码负责通过隐藏或显示游戏的菜单屏幕来准备游戏状态。该方法中的下一个代码块如下所示。

219    //Prep blimp camera
220    if (blimpCamera == null) {
221       blimpCamera = GameObject.Find("BlimpCamera");
222    }
223
224    //Prep track settings
225    raceTrack = PlayerPrefs.GetInt("RaceTrack");
226    int tmp = PlayerPrefs.GetInt("EasyOn");
227    if (tmp == 0) {
228       easyOn = false;
229    } else {
230       easyOn = true;
231    }
232
233    raceType = PlayerPrefs.GetInt("RaceType");
234    Utilities.wr("RaceTrack: " + raceTrack);
235    Utilities.wr("EasyOn: " + easyOn);
236    Utilities.wr("RaceType: " + raceType);
237
238    if (PlayerPrefs.GetInt("RaceTrackHelp" + raceTrack) != 1) {
239       trackHelpOn = true;
240    } else {
241       trackHelpOn = false;
242    }
243
244    //Prep player positions
245    positions = new int[6];
246    positions[0] = 0;
247    positions[1] = 1;
248    positions[2] = 2;
249    positions[3] = 3;
250    positions[4] = 4;
251    positions[5] = 5;
252    players = new ArrayList();
253    players.AddRange(GameObject.Find("GameState").GetComponents<PlayerState>());
254
255    //Prep player states

256    int l = players.Count;
257    PlayerState player;
258    Transform t;
259    for (int i = 0; i < l; i++) {
260       Utilities.wr("Setting up player " + i);
261       player = (PlayerState)players[i];
262       if (player != null) {
263          player.index = i;
264          player.carType = i;
265          player.position = i;
266          SetCarDetailsByGameType(player); //sets the model and speeds
267
268          if (player.player != null) {
269             player.active = true;
270             player.controller = player.player.GetComponent<CharacterController>();
271             player.cm = player.player.GetComponent<CharacterMotor>();
272             player.camera = player.player.transform.Find("Main Camera").GetComponent<Camera>();
273             player.rearCamera = player.player.transform.Find("Rear Camera").GetComponent<Camera>();
274             player.audioListener = player.player.transform.Find("Main Camera").GetComponent<AudioListener>();
275             player.mouseInput = player.player.GetComponent<MouseLookNew>();
276             player.fpsInput = player.player.GetComponent<FPSInputController>();
277
278             t = player.player.transform.Find("Car");
279             if (t != null) {
280                player.gun = (GameObject)t.Find("Minigun_Head").gameObject;
281                player.gunBase = (GameObject)t.Find("Minigun_Base").gameObject;
282             }
283
284             player.lightHeadLight = (GameObject)player.player.transform.Find("HeadLight").gameObject;
285             if (player.lightHeadLight != null && nightTime == false) {
286                player.lightHeadLight.SetActive(false);
287             } else {
288                player.lightHeadLight.SetActive(true);
289             }

290
291             player.totalLaps = totalLaps;
292             player.currentLap = 0;
293             player.aiWaypointIndex = 0;
294             player.aiWaypointRoute = 0;
295             player.waypoints = GetWaypoints(player.aiWaypointRoute);
296             player.flame = (GameObject)player.player.transform.Find("Flame").gameObject;
297             player.gunExplosion = (GameObject)player.player.transform.Find("GunExplosion").gameObject;
298             //TODO //player.gunExplosionParticleSystem = player.gunExplosion.GetComponent<ParticleEmitter>();
299             player.gunHitSmoke = (GameObject)player.player.transform.Find("GunHitSmoke").gameObject;
300             //TODO //player.gunHitSmokeParticleSystem = player.gunHitSmoke.GetComponent<ParticleEmitter>();
301
302             if (player.gunOn == true) {
303                player.gun.SetActive(true);
304                player.gunBase.SetActive(true);
305             } else {
306                player.gun.SetActive(false);
307                player.gunBase.SetActive(false);
308             }
309
310             player.flame.SetActive(false);
311             player.gunExplosion.SetActive(false);
312             //TODO //player.gunExplosionParticleSystem.emit = false;
313             player.gunHitSmoke.SetActive(false);
314             //TODO //player.gunHitSmokeParticleSystem.emit = false;
315             player.LoadAudio();
316          } else {
317             Utilities.wr("Player model " + i + " is NULL. Deactivating...");
318             player.active = false;
319             player.prepped = false;
320          }
321       } else {
322          Utilities.wr("Player " + i + " is NULL. Removing...");
323          players.RemoveAt(i);
324          l--;
325       }
326
327       player.prepped = true;
328    }
329    SetCarDetails();
330
331    //Start game //line 324
332    ready = true;
333    FIRST_RUN = false;
334 }

Listing 10-16GameState Main Method Details 5

在前面列出的代码块的开头,在第 220–222 行配置了飞艇摄像机。第 225-242 行处理了更多的轨道设置,我们已经处理了所有的游戏准备工作。接下来,玩家位置数组被初始化为默认的六个悬浮赛车。玩家状态对象实例被加载到第 252 和 253 行的初始化玩家的ArrayList中。现在我们必须配置每个PlayerState对象。如果一切设置正确,我们将有六个PlayerState对象,每个玩家一个。

“准备玩家状态”标题下的下一部分代码可以说是最重要的。这个代码负责准备球员和他们的汽车的所有方面。局部变量设置在第 256 到 258 行,玩家数组从第 259 到 261 行开始迭代。玩家的索引、汽车类型和位置是在第 263–265 行设置的,它们的模型和速度值是通过调用SetCarDetailsByGameType方法配置的。

如果汽车的模型被成功加载,那么从 268 到 315 的代码就会执行。如果不是,则通过将prepped字段设置为假来停用数组条目。仔细查看这段代码,注意PlayerState类是如何准备好所有字段的;模型、摄像机和控制器都在这里设置。最后的设置是通过调用SetCarDetails方法来执行的,第 329 行。该方法负责设置主摄像机和后视摄像机、音频监听器以及 AI 或用户输入控件。在将ready字段设置为真并将FIRST_RUN字段设置为假之后,该方法返回。游戏现在可以运行了!

到目前为止,我们已经在本章中讲述了大量的代码,但是我们还没有脱离险境。我想讨论几个剩下的方法。下面列出了下一组要检查的方法。

01 public void OnApplicationPause(bool pauseStatus) {
02    if (AreMenusShowing()) {
03       if (pauseStatus == true) {
04          PauseGame();
05       } else {
06          UnPauseGame();
07       }
08    } else {
09       if (pauseStatus == true) {
10          if (gameStateIndex == GameStateIndex.GAME_PLAY_SCREEN) {
11             ShowPauseMenu();
12          } else {
13             PauseGame();
14          }
15       } else {
16          if (gameStateIndex == GameStateIndex.GAME_PLAY_SCREEN) {
17             HidePauseMenu();
18          } else {
19             UnPauseGame();
20          }
21       }
22    }
23 }

01 void Start() {
02    if (style1 == null) {
03       style1 = new GUIStyle();
04       style1.normal.textColor = Color.red;
05       style1.fontStyle = FontStyle.Bold;
06       style1.fontSize = 16;
07    }
08
09    if (style2 == null) {
10       style2 = new GUIStyle();
11       style2.normal.textColor = Color.black;
12       style2.fontStyle = FontStyle.Bold;
13       style2.fontSize = 16;
14    }

15
16    if (forceGameStart == true) {
17       if (SceneManager.GetActiveScene().name == "DemoCollideTrackHelp") {
18          PlayerPrefs.DeleteAll();
19          PlayerPrefs.Save();
20       } else if (SceneManager.GetActiveScene().name == "DemoCollideScript") {
21          PlayerPrefs.DeleteAll();
22          PlayerPrefs.SetInt("BattleOn", 1);
23          PlayerPrefs.SetInt("HighOn", 1);
24          PlayerPrefs.Save();
25       } else if (SceneManager.GetActiveScene().name == "DemoCarSensorScriptAutoPass") {
26          CarSensorScript.TRIGGER_SPEED_PASSING = 0.00f;
27       } else if (SceneManager.GetActiveScene().name == "DemoCarSensorScriptGunShot") {
28          PlayerPrefs.DeleteAll();
29          PlayerPrefs.SetInt("BattleOn", 1);
30          PlayerPrefs.SetInt("HighOn", 1);
31          PlayerPrefs.Save();
32       } else if (SceneManager.GetActiveScene().name == "DemoCameraFollowXz") {
33          GameStartMenu.TRACK_NAME_1 = "DemoCameraFollowXz";
34          GameStartMenu.TRACK_NAME_2 = "DemoCameraFollowXz";
35       } else if (SceneManager.GetActiveScene().name == "Main13Demonstration") {
36          GameState.ON_GUI_SHOW_CAR_DETAILS = true;
37          debugOn = true;
38          PlayerState.SHOW_AI_LOGIC = true;
39       }
40    }
41
42    audioS = GetComponent<AudioSource>();
43    if (audioS == null) {
44       Utilities.wrForce("GameState: audioS is null!");
45    }
46 }

Listing 10-17GameState Main Method Details 6

OnApplicationPause方法是一个 Unity 游戏引擎回调方法,在游戏失去焦点时触发。如果有菜单显示并且pauseStatus为真,那么我们想通过调用第 4 行的PauseGame方法暂停游戏。如果没有,我们想通过调用第 6 行的UnPauseGame方法来解除游戏暂停。但是,如果没有菜单显示,则执行第 9–21 行的代码。在这种情况下,如果pauseStatus为真,游戏在主游戏屏幕上,那么我们只显示暂停菜单屏幕,第 11 行。如果没有,那么我们暂停游戏。类似地,在第 16–20 行,如果pauseStatus参数为假并且游戏处于活动状态,我们调用HidePauseMenu方法。否则,我们取消游戏暂停,第 19 行。

这一组中的下一个方法是Start方法。这种方法的主要职责是为课堂准备一些东西。首先,该方法加载一些在OnGUI方法中使用的样式,以在屏幕上显示调试文本,第 2–14 行。接下来,在第 16 行,如果游戏被配置为演示场景,那么forceGameStart标志将为真。第 17–39 行的代码用于准备代码支持的不同演示场景。最后,在第 42–45 行,音轨的背景音乐被加载。

这个类中还有最后一个方法我们还没有介绍,那就是Update方法。尽管这是一个相当长的方法,但代码非常简单。这个方法更新游戏的 HUD 回想一下,Update方法运行每个游戏帧,以反映当前玩家在赛道上比赛时汽车的变化,体验不同的交互并触发不同的修改器。仔细阅读这个方法,确保在继续之前理解它是如何工作的。这就是我们对GameState课的总结。这意味着我们已经仔细检查了游戏中的每一个职业、领域和方法。

演示:游戏状态

演示GameState类最好的方法就是打开“Main13Demonstration”场景并播放。在街机演示模式运行时,使用数字键 1-6 在汽车之间跳跃,并注意 HUD 如何自动更新以显示当前所选悬停赛车的状态。另一个很好的示范可能是玩游戏,让你所有关于游戏如何运作的知识在你的脑海中流动,就像你在游戏中实际体验一样。

第二章结论

在这一章中,我们通过完成GameState代码回顾,看完了玩家和游戏状态类。这不是一个小壮举。这个类中有很多东西在进行,因为它是整个游戏的中心控制点。花点时间拍拍自己的背。这给我们带来了游戏的代码审查的结论。我们已经涵盖了第二章中概述的所有游戏规范,在文本的这一点上,你应该已经很好地理解了游戏对象、物理、碰撞和脚本组件如何相互作用来创建一个游戏。如果你第一次没有完全吸收,不要沮丧。发生了很多事情,你可能需要给自己更多的时间来真正掌握这一切。

十一、使其专业化

欢迎来到第十一章。如果您已经做到了这一步,那么您已经审查了大量的代码。我认为你已经赢得了你的军衔。在剩下的章节中,我们将不会回顾太多的代码,至少不会太长。相反,我们将密切关注 Unity 编辑器和游戏创作的各个方面,这些方面使你的游戏与众不同。这一章是关于使你的游戏专业化的特征、步骤和机制。我们将讨论以下主题以及如何在 Unity 中解决这些问题:

  1. 构建设置

  2. 输入映射

  3. 用户界面/菜单系统

  4. 数据持久性

  5. 内存管理

  6. 声音和音乐

  7. 静态对象

  8. 标签和层

  9. 人工智能对手

  10. 摄像机

  11. 项目设置

这是一个相当多样的主题列表。其中一些,如果仔细研究,可以自己写满一整本书。我们要让事情变得轻松,把注意力集中在手头主题的重要的、一般的方面。我们要处理的第一个主题是构建设置。这是一个简洁的、特定于 Unity 的主题,非常适合我们的第一次讨论。就这样,让我们开始吧。

构建设置

Unity 构建设置用于选择目标平台、配置您的构建以及启动构建和测试过程。这是任何严肃的 Unity 项目的重要组成部分,因为它用于创建游戏的开发和生产版本。Unity 支持许多构建目标,但我们将关注最常见的目标,以及我认为对于迭代开发构建和生产构建质量最重要的设置。我将重点关注影响游戏开发过程效率和游戏本身性能的构建设置。我们将看看以下平台的一些选择构建设置:

  1. 通用平台

  2. PC、Mac 和 Linux 桌面

  3. 通用 Windows 平台(UWP)

  4. ios

  5. 机器人

  6. web GL(web GL)

Unity 还支持其他构建目标,我们不会在这里讨论,但是这篇评论会让你对管理任何平台的构建设置有所了解和信心。

通用平台设置

通用平台设置是一组适用于所有平台的生成设置。

开发版本:此设置用于在项目的版本中启用脚本调试和探查器支持。你可能想知道为什么你会使用这个选项,当你可以在 Unity 编辑器中调试和分析你的游戏时。事实是,目标设备的行为可以而且将会与您的开发环境不同。在项目开发过程中,尽早开始在目标设备上测试游戏非常重要。

脚本调试:该选项仅在“开发构建”设置被激活且在 WebGL 平台上不可用时可用。如果希望调试脚本组件代码,请启用此选项。我个人在需要调试的时候会启用这样的选项。我尽量保持我的开发测试尽可能的纯净。

只构建脚本:对于拥有大量资产的项目来说,这是一个非常有用的特性。如果您的项目构建时间阻碍了您的测试迭代,那么尝试使用这个构建选项。为了使用此设置,您必须进行项目的完整构建。然而,一旦完成了这些,您将能够重建项目,只需要脚本,从而更快地解决代码问题。此设置要求启用“开发构建”设置。

压缩方法:压缩方法设置很重要。根据您的目标平台,您可以设置一些压缩选项。全部测试。找到最适合您的目标设备的版本。不要忽视这个设定。你的选择会对你的游戏加载时间产生显著的影响。

PC、Mac 和 Linux 桌面设置

正如您可能已经猜到的,这类构建设置适用于桌面构建目标。

架构:macOS 上没有这个选项。它适用于 Windows 和 Linux。优化此设置以匹配目标设备的体系结构。再次,测试不同的设置,找到最适合你的游戏。

复制 PDB 文件:复制 PDB 文件选项仅在您的目标是 Windows 平台时可用。这是一个很有用的设置,可以将调试信息添加到游戏版本中。这可以让你在追踪开发过程中出现的顽固错误时获得优势。不用说,对于生产版本,应该关闭这个设置。

创建 Visual Studio 解决方案/创建 XCode 项目:此设置分别适用于 Windows 和 Mac。虽然您可能并不是在所有情况下都需要此功能,但是如果您需要对生成的项目进行更多的控制,此功能可能会有所帮助。如果您想要创建一个项目,并且该项目在编译后将生成您的最终产品,请使用它。

通用 Windows 平台(UWP)设置

本节介绍 UWP 构建设置。除了我们在这里讨论的选项之外,还有一些选项可用,所以我鼓励你看看 Unity 文档以获得更多信息。

架构:UWP 版本的这个构建设置有几个不同的目标供你选择。您可以指定 x86、x64、ARM 和 ARM64,但仅当与 Unity 的“构建和运行”选项一起使用时。这很可能是由于这个平台的普遍性。它很可能包含了之前在开发或生产版本中列出的所有架构的二进制文件。该选项允许您使用特定的架构进行测试、调试和评估。

生成类型:此设置用于控制如何根据 UWP 和 Visual Studio 生成您的项目。您可以选择 XAML、直接 3D 或仅可执行。如果您想在项目中使用 Windows XAML,您的性能会受到影响,但您可以在项目中 XAML 元素。对于大多数游戏来说,这可能是一个不常见的选择。直接 3D 选项提供最佳性能,并在基本应用窗口中呈现游戏。最后一个选项,仅可执行,是一个有趣的特性。此设置在不生成 Visual Studio 项目的预生成可执行文件中承载项目。使用这个选项来减少你的构建时间,这样你可以更快地迭代,更快地完成你的测试和调试。

构建配置:这个构建设置只适用于 Unity 的“构建和运行”特性。该设置的选项与 Unity 生成的 Visual Studio 项目中的选项相同。调试选项包括调试符号并启用 Unity Profiler。release 选项没有调试代码,但也启用了探查器。最后,主选项针对发布版本进行了全面优化。使用此构建设置来优化您的游戏并准备发布。

深度分析:该选项用于分析所有脚本代码,包括记录函数调用。使用此设置来查明游戏中的性能问题,但要小心;它使用大量的内存,可能无法像预期的那样处理非常复杂的脚本。

自动连接分析器:自动将分析器连接到游戏版本。此设置要求启用“开发构建”选项。

iOS 设置

iOS 平台有许多构建设置与我们已经介绍过的平台重叠,所以我们在这里不再赘述。但是,我们将回顾一些特定于 iOS 的选项。

在 XCode 中运行:此选项仅在 macOS 上可用,用于指定用于运行结果项目的 XCode 版本。

以 XCode 身份运行:此选项允许您指定项目是以调试模式还是发布模式运行,从而帮助您调试 iOS 游戏。如果您需要调试代码并希望使用 XCode 来完成,请使用此功能。在开始设备测试之前,您还可以使用它来运行发布版本,以检查 XCode 中的功能。

Symlink Unity 库:该选项允许您引用 Unity 库,而不是将它们复制到项目中。使用这个特性可以减小 XCode 项目的大小,并且由于项目构建时间更短,可以帮助您更快地迭代。

Android 设置

与 iOS 设置类似,Android build 设置与我们已经讨论过的选项部分重叠,因此我们在此不再赘述。我们将着重于帮助你优化和测试你的游戏的设置。

纹理压缩:在撰写本文时,Android 平台支持以下纹理压缩格式:DXT、PVRTC 等、ETC2 和 ASTC。默认设置是 ETC,但您应该了解目标设备的功能,并选择一个能为您提供最佳支持和效率平衡的设置。

ETC2 回退:这个设置我就不细说了。如果使用 ETC2 纹理压缩格式,请注意该选项。它可以帮助您的游戏在不支持 ETC2 和 OpenGL ES 3 的设备上更高效地运行。

运行设备:这个构建设置允许您指定目标附加的 Android 设备,然后您可以使用它来测试和调试您的构建。

网络光设置

WebGL 平台有许多构建设置与我们已经介绍过的选项重叠。您可以在为此平台配置构建设置时应用这些知识。

输入映射

在我看来,输入映射是职业游戏的一个重要方面。通过使用输入映射,您可以在输入和游戏之间创建一个抽象层。这允许您将相似的输入映射到一个输入标签。为什么我的游戏需要这个?好吧,如果你正在经历构建一个游戏的麻烦,为什么要把它的输入限制在一个或两个直接映射的输入。花时间改进和使用输入映射来无缝地支持多个输入。下面的屏幕截图演示了在 Hover Racers 游戏中使用的这种输入映射配置。

img/520710_1_En_11_Fig1_HTML.png

图 11-1

输入映射示例该图像描述了映射到输入标签的多个原始输入

正如您在前面列出的图像中看到的,我们已经将键盘输入和操纵杆输入映射到同一个标签“水平”现在,让我们看看一些输入代码,看看标签是如何使用的。

01 if (Input.GetAxis("Turn") < 0.0f) {
02    if (Input.GetAxis("Horizontal") < 0.0f) {
03       transform.Rotate(0, -1.75f, 0);
04    } else {
05       transform.Rotate(0, -1.25f, 0);
06    }
07 }
08
09 if (Input.GetAxis("Turn") > 0.0f) {
10    if (Input.GetAxis("Horizontal") > 0.0f) {
11       transform.Rotate(0, 1.75f, 0);
12    } else {
13       transform.Rotate(0, 1.25f, 0);
14    }
15 }

Listing 11-1Input Mapping in Use

请注意,在前面列出的代码中,无论输入源是什么,都会使用“水平”输入映射。玩家可以使用键盘、控制器或鼠标来使悬停赛车转弯;我们不在乎哪个。花点时间在你的游戏输入上努力吧!一个伟大的游戏可能会被糟糕的控制毁掉。相反,一个看起来不怎么样的游戏,如果控制正确,可能真的很有趣,会让人上瘾。

用户界面/菜单系统

菜单系统是另一个功能,如果它没有很好的实现,会影响你的游戏。用户习惯于在他们的游戏中使用相当不错的 UI。这是你在制作游戏时应该记住的事情。菜单系统应该简单直观。限制任何给定菜单屏幕上的选项和信息的数量,让你的玩家更容易理解正在发生的事情。

除了提供实际游戏中的菜单系统作为一个坚实的例子,我想谈谈 Unity UI 系统的两个方面,它们将帮助你更快地启动和运行。第一个是设置一个新的Canvas,第二个是设置一个带有一些按钮的Panel。我们还会将这些按钮连接到相关的脚本组件。打开主项目并创建一个名为“MyMenuSystemSample”的新场景打开场景,你会看到一个默认的,有点空白的“层次”面板。

右键单击“层级”面板,选择上下文菜单的“UI”部分,然后选择Canvas条目。您将看到层次结构的两个版本:一个Canvas和一个EventSystem对象。我们现在将关注于Canvas对象。选择它,并将您的注意力转向“检查器”面板。展开“画布”条目,并将“渲染模式”设置为“屏幕空间–覆盖”这将在屏幕上显示菜单,是你的菜单系统的一个好的起点。如果您想确保菜单图形尽可能清晰地缩放,请选中“像素完美”选项。确保“目标显示”设置为“显示 1”

img/520710_1_En_11_Fig2_HTML.jpg

图 11-2

Canvas Hierarchy 示例描述添加 Canvas 和 Panel 对象后的层次的图像片段

接下来,展开“Canvas Scaler”条目,并将“UI Scale Mode”设置为“Scale with Screen Size”值。我们将进行设置,使菜单屏幕居中,并随着游戏的屏幕大小上下缩放。“参考分辨率”条目应该与用于创建菜单屏幕资产的尺寸相匹配,特别是所使用的背景图像。在这种情况下,我们将“X”值设置为 640,“Y”值设置为 960。“屏幕匹配模式”应该设置为“匹配宽度或高度”,并且“匹配”选项的值应该为 0.5。这是宽度和高度的平衡。最后,“每单位像素”条目应该与原始菜单背景图像的像素密度相匹配。在这种情况下,将其设置为 326。

img/520710_1_En_11_Fig3_HTML.png

图 11-3

画布层次和设置示例描述完整演示场景层次并关注画布设置的屏幕截图

现在我们将向我们的Canvas添加一个Panel对象。在层级中选择Canvas对象,并右键单击。选择“UI”选项,然后选择“Panel”条目。您的Canvas对象现在将有一个Panel子对象。选择新的子对象,并将注意力放在“检查器”面板上。展开“矩形变换”条目,并将“宽度”和“高度”值分别设置为 460 和 240。这些是我们将使用的背景图像的自然尺寸。

我们希望我们的菜单场景保持居中,所以我们接下来将研究“锚”和“枢轴”选项。“X”和“Y”的“最小”和“最大”锚值应该设置为 0.5。这些值代表一个百分比,0.0 到 1.0 或 0%到 100%,如果你想这样想的话。现在将“X”和“Y”的“轴”值也设置为 0.5。这将把轴心点和锚点设置到菜单的中心。“旋转”、“PosY”、“PosX”和“PosZ”字段都应设置为零。

接下来让我们看看Panel对象在“检查器”面板中的“图像”条目。展开它并选择“源图像”选项,单击选择按钮,在弹出窗口中键入“菜单”并找到名为“MenuPanel_512x512”的条目在这一步之后,您可能需要重新设置“矩形变换”条目的“宽度”和“高度”值,所以一定要仔细检查它们。将“图像类型”选项设置为“切片”并选中“填充中心”复选框。

img/520710_1_En_11_Fig4_HTML.png

图 11-4

面板层次和设置示例显示面板对象的“矩形变换”和“图像”设置的屏幕截图

我们在这一部分要做的最后一件事是添加菜单屏幕特性、一些文本和两个按钮。然后我们将按钮连接到一个脚本,运行一些测试,然后就到此为止。

步骤 1:添加一个文本对象

  • 从层级中选择Panel对象并右键单击。

  • 选择“UI”选项,然后选择Text条目。一个新的Text对象将被添加为Panel对象的子对象。

  • 选择它,然后注意“检查器”面板中的“矩形变换”部分。

步骤 2:配置新的文本对象

  • 将“PosY”字段的值设为 60。

  • 现在向下滚动到“文本”部分并展开它。将“文本”字段的值更改为“Hello World”将“字体样式”改为“粗体”,将“字体大小”改为 20。

  • 在“段落”小节下,将“对齐”选项设置为“文本居中”。

步骤 3:添加两个按钮对象

  • 遵循与添加Text对象到Panel相同的过程,除了这次添加两个Button对象。所有三个 UI 元素都应该是Panel的子对象。

步骤 4:配置按钮对象

  • 将第一个按钮对象重命名为“ButtonOk”,将第二个按钮命名为“ButtonCancel”

  • 选择ButtonOk对象,在“检查器”面板中展开“矩形变换”部分。将“PosX”字段的值设置为–90。对ButtonCancel对象做同样的事情,除了使用值 90。

简单的菜单屏幕正在形成。请注意,这两个按钮本质上是父对象。展开第一个按钮并选择Text子对象。将其“文本”值设置为“确定”对第二个按钮重复此步骤,只是将其“文本”值设置为“取消”

img/520710_1_En_11_Fig5_HTML.jpg

图 11-5

具有 UI 元素的面板层次示例描述添加了面板对象和 UI 元素的场景层次的图像片段

我们将向Canvas对象添加一个脚本组件。在层次中选择Canvas对象。现在,转到“项目”面板,搜索以下字符串,“DemoMenuSystemSample”。找到同名的脚本组件,并将其添加到Canvas对象中。接下来,右击Canvas对象并选择“属性...”入口。将产生的弹出窗口向旁边移动一点。选择ButtonOk对象,在“检查器”面板中展开“按钮”部分。

向下滚动到“点击时”部分,然后单击“+”按钮。将结果行条目的类型设置为“编辑器和运行时”将“Demo Menu System Simple”脚本组件从属性弹出菜单拖到“On Click”行条目的“Object”字段。选择“BtnOkClick”功能,将“无功能”的值更改为“DemoMenuSystemSample”。对“取消”按钮做同样的事情,只是这次选择“BtnCancelClick”功能。我们把所有东西都装好了。拿着它转一转,检查日志中哪个按钮被点击的指示。打开“DemoMenuSystemSample”场景可以找到这个简单屏幕的演示。

img/520710_1_En_11_Fig7_HTML.png

图 11-7

完成的 UI 层次示例描述完成的 UI 演示的层次和场景的屏幕截图

img/520710_1_En_11_Fig6_HTML.png

图 11-6

面板层次结构和设置示例描述 ButtonOk 对象配置的屏幕截图

数据持久性

我们在审查 Hover Racers 代码库时讨论了数据持久性。这是使用 Unity APIPlayerPrefs类的数据持久性的简化形式。虽然它非常适合存储简单的数据,但对于复杂的信息,它可能不是最佳的解决方案。序列化技术,就像我们用来存储跟踪时间数据的那种,可能是一种选择,但你不应该把它用于大量数据或非常复杂的数据。在这些情况下,您应该探索数据文件的读写。

内存管理

既然你是用 C#,一种内存管理语言来编写你的 Unity 游戏,那么你就不用担心内存管理,对吗?错了。垃圾收集使用资源,垃圾收集器要做的工作越多,它破坏游戏流畅帧速率的机会就越大。确保跟踪每一帧运行的方法,并尽量减少悬挂对象的创建,这些对象在其他任何地方都不会被引用,并且在方法完成时会丢失。

在 Hover Racers 游戏中,我们使用私有类字段作为局部方法变量的替代,以避开垃圾收集器。然而,这种方法很快会变得很麻烦,不推荐用于更复杂的方法、类。当你编码的时候,记住内存管理,你已经完成了一半。通过剖析你的游戏,仔细检查 Unity 引擎的Update方法或其他频繁触发的方法(如碰撞回调方法)所涉及的方法和类,清理任何遗留问题。

声音和音乐

这似乎是显而易见的,但我还是要回顾一下。音效和音乐对任何游戏都非常重要,包括你的。如果您无法创建音频资源,请不要担心。包括 Unity store 在内,有很多地方可以让你接触到美妙的音乐和声音。一般来说,玩家的每一次交互,有时通过他们的角色,都应该引出某种声音效果。如果可以的话,你还应该找一个像样的背景音乐和环境声音。我知道这对于一个游戏版本来说是一个很大的挑战,但是如果你记住这一点,并且在开发过程中使用占位符,那么当需要润色和完善你的项目时,你将会处于一个很好的位置。

静态对象

如果你在 Unity 编辑器中选择任何GameObject并在“检查器”面板中检查该对象的配置,你会注意到面板右上角名为“静态”的标签旁边有一个小复选框。如果你游戏中的一个物体不移动,不与角色或 AI 对手交互,也不与其他物体交互,你应该将其标记为静态。这样做可以提高游戏的效率,因为静态对象会从某些运行时计算中逃逸出来。

标签和层

标签和层是在游戏中组织交互的重要功能。你可以找到他们的管理屏幕下的“编辑”➤“项目设置……”主菜单选项。在出现的设置窗口左侧选择“标签和层”条目。标签是您可以分配给一个或多个GameObject的参考名称。例如,游戏中所有的悬停赛车都有标签“玩家”标签帮助你识别特定的GameObject,并且可以帮助以编程方式将游戏对象连接到脚本字段。

Unity 中的层用于定义哪些GameObject可以相互交互。正如 Unity 文档中提到的,它们通常被Camera对象用来渲染场景的一部分,被灯光用来照亮场景的一部分。我们并没有真正在 Hover Racers 代码库中使用层。然而,如果你看看游戏屏幕右上方的飞艇摄像机,你可以想象一个场景,摄像机没有显示赛道的完整渲染,就像现在这样。例如,可以使用层对其进行配置,以仅显示某些对象,仅此而已。在游戏开发过程中,请记住这些特性。

人工智能对手

这是一个有点棘手的话题。首先,在这种情况下,AI 有点用词不当。目前,或许还有一段时间,游戏人工智能不是真正的人工智能或机器学习。就像某些物理计算可以被近似从而被简化一样,游戏人工智能意味着尽可能地模仿人类玩家,并且这样做看起来很聪明。在不太遥远的未来,每台像样的计算机都将拥有类似于今天 GPU 工作方式的专用 AI/ML 硬件,在某些情况下,它们已经存在。看看谷歌的 TPU 和苹果的 M1 芯片。

但是现在,我们将不得不接受近似和简化的游戏人工智能。这是一个很大的话题,很大程度上依赖于你正在制作的游戏类型。游戏人工智能实现的一个主要方面是模仿用户输入并管理这些输入来创建一个真实的人工智能玩家。考虑到这一点,您可能希望从实际的输入中抽象出输入处理,以便可以通过编程实现相同的功能。

当实现悬停赛车的人工智能,我们有简单的好处。盘旋赛车手有一个固定的,指定的,他们可以移动的地方,赛道。没有它,你将不得不使用像 A*或 Unity 的导航网格系统的寻路技术。此外,参赛者只能加速、减速或转弯。赛道的航路点系统告诉他们向哪个方向前进,赛道的中心点在哪里,转弯多少,以及何时减速。这就是我们在这场比赛中像样的人工智能对手所需要的一切。我建议在项目的早期就考虑和规划人工智能。

img/520710_1_En_11_Fig8_HTML.png

图 11-8

Hove Racer 人工智能逻辑示例一个截图,描述了人工智能对手的计算,决定向哪里移动以及转弯多少

摄像机

摄像机可以为你的游戏增添一份精彩。很多游戏使用两个或更多的摄像头来提供当前关卡的不同视角。你最喜欢的 FPS 上的导航 HUD 很可能是一个摄像机,它被设置为只能看到特定的对象层,这些对象层被用来描述给定关卡上玩家环境或位置的简化版本。用新的独特的方式给你的游戏添加摄像头真的可以让你的游戏脱颖而出。关于如何定位和调整相机大小的例子,请查看“Main13”或“Main14”场景,这是该项目的主要游戏场景。

项目绩效

最后但同样重要的是,还有项目设置。你的 Unity 项目涉及到很多设置,其中一些我们已经提到过了。有比我希望在这篇文章中解决的更多的问题。然而,我想花一点时间来讨论“质量”设置。您可以在主菜单的“编辑”条目下找到项目设置选项。可以从弹出窗口的左侧选择“质量”部分。

花点时间测试这些设置,以获得游戏质量和性能的正确平衡。您可以使用一些 Unity 性能监控工具来检查游戏的运行情况。我们要看的第一个工具是“游戏”面板的“统计”功能。打开主游戏场景,“Main13”或“Main14”,在 Unity 编辑器中运行游戏。请注意,在面板的右上角有一个“Stats”按钮。点击它,你应该会看到类似下面的截图。

img/520710_1_En_11_Fig9_HTML.png

图 11-9

描述场景统计对话框的截图

这个小弹出窗口有很多关于你的游戏的有用的高级信息,可以用来识别你的项目的问题。“为什么我的游戏只能以 30 FPS 的速度运行?”你问。问得好。要获得游戏性能的真实画面,请停止游戏,然后单击“统计”按钮旁边的“游戏时最大化”按钮。现在重新开始游戏,再次打开“统计”弹出窗口。此功能的一个示例如下。

img/520710_1_En_11_Fig10_HTML.png

图 11-10

Hove Racers 最大化统计数据示例一个截图,描述了在 Unity 编辑器中运行的主游戏,最大化,显示了统计数据弹出窗口

看看前面列出的图像中的帧速率。请注意,它在 1441 × 731 像素下以每秒 91 帧的速度运行。那还不算太寒酸。计划定期检查游戏的性能,尤其是在添加新功能和游戏机制之后。但是如果我在 Unity 编辑器中测试时发现性能问题,会发生什么呢?统计弹出窗口没有给我足够的信息来解决这个问题。不要害怕!Unity Profiler 可以提供帮助。可以在以下主菜单位置找到该分析器:“窗口”➤“分析”➤“分析器”让我们再次运行这个游戏,观察数据流入分析器的图表和摘要部分。看看下面的例子截图。

img/520710_1_En_11_Fig11_HTML.png

图 11-11

Hove Racers Profiler 示例描述 Unity profiler 的屏幕截图,其中包含 Hover Racer 游戏的运行数据

花点时间玩玩分析器。在左侧切换不同的指标,以查明是什么导致您的游戏行为不当。单击图表将在窗口的底部面板中显示详细信息。您可以访问关于垃圾收集器占用了多少时间、某些方法调用完成了多少时间等信息。分析器是一个强大的工具。知道如何使用它的开发者可以胜任并快速地解决他们游戏中的低效问题。成为那些开发者中的一员。

第二章结论

这就引出了本章的结论。在这一章中,我们看了一些主题,我觉得它们会帮助你把下一个 Unity 游戏做得更好。我们讨论了各种各样的主题,涉及到游戏效率和优化实现方面的问题。让我们在这里回顾一下这些主题。

构建设置:在这一部分,我们介绍了一些关键的构建设置,并指出了一些会影响游戏性能的有用选项。我们还讨论了许多选项,可以帮助你在一系列不同的平台上测试和调试你的游戏,随后列出。

  1. 通用平台设置

  2. PC、Mac、Linux 桌面设置

  3. UWP 设置

  4. IOS 设置

  5. Android 设置

  6. 网络光设置

输入映射:输入映射部分讨论了如何设置输入,以便输入映射特性能够对它们进行抽象。我们讨论并观察了应用于一个游戏的多个功能相同的输入是如何共享同一个标签的。这有效地创建了一个抽象层,允许您针对输入标签而不是直接针对输入源进行编码。

UI/菜单系统:在这一部分,我们讨论了一个坚固的 UI 如何增强你的游戏,并为你的玩家提供良好的体验。我们还逐步完成了构建一个简单的双按钮菜单屏幕的过程,附带了处理按钮单击事件的脚本,并提供了该场景的完整演示版本供您阅读。

数据持久性:我们花了一点时间讨论了数据持久性的主题,并提到了 Hover Racers 游戏使用的PlayerPrefs类。我们还简要讨论了使用序列化/反序列化技术来存储稍微复杂一些的数据。最后,我们建议对高度复杂和/或大量的数据使用数据文件。

内存管理:关于内存管理,我们提出了一些关于如何控制垃圾收集的想法,正如在所提供的游戏项目中实现的那样。我们还强调了了解并主动解决代码如何影响垃圾收集器的重要性。

声音和音乐:在这一部分,我们讨论了井...声音和音乐。我们建议尽可能为所有玩家交互、背景音乐和环境声音设置音效。我们还提到了在开发过程中使用占位符,允许您专注于游戏代码,同时让您可以在以后灵活地润色和完善您的游戏声音。

静态对象:静态对象部分提醒你尽可能花时间让游戏中的对象保持静态。当 Hover Racers 游戏项目在“Main13”和“Main14”场景中使用静态对象时,您可以查看该功能的使用情况。看一看。

标签和层:在这一节中,我们简要地谈到了标签和层,并讨论了如何在游戏中使用它们。Hover Racers 代码库经常使用标签来帮助以编程方式识别某些游戏对象。

人工智能对手:人工智能对手部分列出了一些关于游戏人工智能的一般想法,并谈到了应用于悬停赛车游戏的具体实现。

相机:在这一部分,我们讨论了相机以及如何使用它们来增强你的游戏。我们还提到了检查游戏的多摄像机设置是如何实现的。

项目设置:有大量的项目设置,要涵盖所有的设置需要相当多的时间和很多页的文字。我们所做的是把重点放在项目设置的“质量”部分,并把它作为 Unity 编辑器的统计弹出和剖析工具的一个延续。

我希望这一章为你提供了一些思考的素材。至少,当你开发下一个伟大的游戏或者改进当前的游戏时,你需要记住一些事情。在这篇文章的下一章,我们将看看如何添加一个新的赛道到悬停赛车游戏!

十二、增加一条新赛道

我们已经从头到尾审查了代码。你已经看到了每一个游戏机制和交互,现在我们要在 Unity 编辑器中建立一个新的轨道,并将其插入到游戏中。这将强化你在代码审查过程中学到的概念,并向你展示GameObject在哪里遇到代码。我们将一步一步地进行,将预设的游戏对象添加到场景中,建造一个新的赛道,然后通过将赛道连接到游戏的开始菜单屏幕,最终将赛道添加到游戏中。好了,我们已经有了计划,让我们开始吧!

跟踪环境和清理脚本

我们要做的第一件事是创建一个名为“MyTrack15”的新场景并打开它。一旦完成,我们将需要一个地方来放置我们的新赛道。让我们把注意力转向“层次”面板。右键单击面板内部并选择“创建空白”将新的GameObject重命名为“特性”再执行两次该操作,并创建以下两个对象:“Menus”和“SceneOther”将默认的Main CameraDirection Light游戏对象移动到刚刚创建的SceneOther对象中。您的层次结构面板应该在层次结构的根处有以下条目。

img/520710_1_En_12_Fig1_HTML.jpg

图 12-1

新赛道层级示例 1 描述新赛道开发过程中场景层级的图像片段

我们将使用这些空的GameObject就像文件系统中的文件夹一样。从性能的角度来看,这是非常好的,并且实际上是保持项目的游戏对象有组织的一个很好的方法。为了给赛道设置一个简单的环境,我们将在场景中创建一个现有预设对象的实例。在“项目”面板中,找到“预设”文件夹,并找到名为“板”的条目把它拖到“层级”面板,然后放到“特色”游戏对象中。对名为“毁灭者”的预设重复这个过程您的层次结构应该如下所示。

img/520710_1_En_12_Fig2_HTML.jpg

图 12-2

新赛道层级示例 2 添加棋盘和破坏者对象后,描述场景层级的图像片段

我们需要“棋盘”游戏对象稍微宽一点。在层次中选择它,并将检查器中的“缩放 X”值从 1 更改为 1.5。那会让板子更宽一点,让我们的轨道能放进去。您的设置应该如下图所示。游戏对象应该比棋盘大得多,并且位于棋盘下方。

img/520710_1_En_12_Fig3_HTML.png

图 12-3

新赛道场景示例 1 描述棋盘和破坏者游戏对象及其相对位置的屏幕截图

显然,它不必完全匹配,但您的设置应该类似于前面列出的屏幕截图所示。在下一节中,我们将设置赛车和游戏状态对象以及相关的脚本。

悬停赛车和游戏状态对象

在本节中,我们将向场景中添加赛车手、游戏状态对象和相关脚本。在我们拥有一条功能齐全的综合赛道之前,我们还需要完成一些步骤,但这将使我们离目标更近一步。我们将从悬停赛车出发。把你的注意力放在“项目”面板上,找到“预设”文件夹。搜索名为“StartingSet”的预设,并将其拖动到“Hierarchy”面板中,使其成为一个根GameObject条目,如下图所示。

img/520710_1_En_12_Fig4_HTML.png

图 12-4

新赛道层次示例 3 描述添加 StartingSet 对象后的场景层次的图像片段

执行相同的步骤,除了这一次找到名为“游戏状态”的预设,并将其拖动到层次结构中,使其成为SceneOther对象的子对象。下图描述了当前场景层次。在你的层级面板中应该有一个匹配的设置。

img/520710_1_En_12_Fig5_HTML.jpg

图 12-5

新赛道层级示例 4 添加 GameState 对象后描述层级的图像片段

我们必须调整StartingSet对象的位置。选择它并将“位置 Y”值调整到–66。您可以有稍微不同的定位,因此如果值-66 没有使参赛者靠近棋盘表面,请使用“场景”面板并重新定位他们,以便他们靠近棋盘表面但不接触它。接下来,展开SceneOther父对象并选择Main Camera子对象。在“检查器”面板中,点按“名称”栏左侧的复选框以停用摄像机。

当我们这样做的时候,让我们为一些我们知道不会移动的场景对象切换静态标志。展开Features游戏对象,并将BoardDestroyer游戏对象设置为静态,如果它们还没有这样配置的话。如果出现提示,将静态标志应用于所有子对象。我们来测试一下场景。单击播放按钮;你应该通过玩家的摄像头看到棋盘。等待几秒钟,你就可以控制赛车了。这类似于您以前使用过的演示场景。当我们完成设置后,会有一个倒计时显示,表明这一时间的流逝。现在,我们只能等待几秒钟。

运行场景后,如果悬停赛车在场景开始时向下浮动,那么它有点太高了。将StartingSet向下调整一点,然后再次测试。同样,如果比赛者在场景开始时弹跳到空中,或者落到棋盘表面以下,那么比赛者就有点太低了。将StartingSet调高一点,然后再次测试。请记住,当场景演示停止时,在场景运行时所做的场景更改将会丢失。停止场景后进行调整,以便正确存储和保存。在场景运行时进行调整,以便在运行时进行测试。接下来,我们将处理赛道和航路点。

轨迹和航路点对象

新跑道开始成形了。我们已经设置了许多GameObject和脚本组件。我们可以运行场景,开车四处转转,但事情相当贫瘠。只有一个灰色的大白板,没别的了。让我们在我们的板上添加一个轨迹和航路点。我创建了一个简单的轨道供我们使用。通常情况下,你可以将跑道模型或预设拖到层级上来建立一个跑道。在这种情况下,我已经为你做了工作。

将你的注意力转移到“项目”面板中的“预设”文件夹,并找到“简单轨迹”预设。将预设拖放到层级中,使其成为Features游戏对象的子对象。将SimpleTrack对象的位置设置为“X”= 35,“Y”= 0,“Z”=-9。你可以对你的板有稍微不同的定位,如果是这样,不要担心,只要移动轨道,使它在板的中心。确保道路是可见的,并且轨迹没有在棋盘表面上盘旋。设置的截图如下。

img/520710_1_En_12_Fig6_HTML.png

图 12-6

新的赛道场景示例 2A 屏幕截图描绘了赛道在棋盘内居中的场景

现在我们有了一条赛道,让我们为这条赛道重新定位悬停赛车到一个好的起点。将StartingSet对象的位置设置为" X" = -430," Y" = -66," Z" = -254。这应该定位在前面列出的截图中显示的赛车。如果你的对象的位置不同,那么就不要使用这里列出的值。相反,使用 Unity 编辑器重新定位汽车,使其类似于所示的设置。

接下来让我们添加赛道的航点。找到“SimpleWaypoints”预设,并将其拖动到层次结构中,使其成为Features游戏对象的直接子对象。如有必要,调整路点的位置,使其与赛道的弯道和直道对齐。调整StartingSet的位置,使悬停赛车位于赛道该侧的航路点之间。在我们进入下一部分之前,我还想做最后一件事。

展开SceneOther父游戏对象并选择GameState子对象。在“检查器”面板中展开“TrackScript”脚本组件条目。将“圈数”字段设置为一个较大的数字;因为这条赛道不是很大,我们会比平时多跑几圈。用八圈之类的。下图描述了场景的当前设置。在你的场景中应该有一些非常相似的东西。

img/520710_1_En_12_Fig7_HTML.png

图 12-7

新的赛道场景示例 3A 屏幕截图描述了配置了板、赛道和航路点以及一组悬停赛车的场景

我应该提到的是,在某些情况下,比赛类型会覆盖您刚刚配置的圈数。如果你查看赛道上的路点列表,你会注意到其中一些会触发帮助通知。一个这样的帮助通知航路点TrackHelpTurn,实际上关闭了航迹帮助通知系统,应该是航迹上最后一个这样的航路点。否则,它将过早关闭帮助通知。我还想指出,包含所有悬停赛车的StartingSet位于最后一个航路点之后,第一个航路点之前。确保在你的场景中有一个相似的设置。

跳转、增强、菜单屏幕等等

在这一节中,我们将为曲目添加一些有趣的功能。我们还会将菜单屏幕添加到曲目中,并将我们的曲目连接到开始菜单。请注意,我们已经能够添加新的特点和功能到轨道,它只是无缝地插入到游戏中。当我们在游戏板上添加了悬浮赛车后,这个场景就可以玩了。如果你回忆一下代码回顾章节,代码被设计成通过关闭一个不能被正确配置的类来对丢失的脚本组件和游戏对象做出反应。这使得我们可以分阶段建造新的赛道,每个阶段结束时都有一个功能场景。请记住,您总是可以在项目的发布版本上注释掉这些检查,以确保最高的性能。

要添加一组新的轨道功能,包括助推、跳跃、趣味框和战斗模式标记,请进入“项目”面板中的“预设”文件夹,找到“简单功能”预设。将条目拖动到层次结构中,使其成为Features父游戏对象的子对象。确保新的轨迹对象与轨迹正确对齐,如下图所示。

img/520710_1_En_12_Fig8_HTML.png

图 12-8

新的赛道场景示例 4A 截图描绘了配置了助推、跳跃、趣味框和战斗模式标记的赛道

我们将对下面的预设、BgMusicBlimpCamera做同样的事情,除了我们将它们拖放到SceneOther父对象中,这样它们就是SceneOther父游戏对象的直接子对象。将这些对象添加到场景中会为场景启用背景音乐和飞艇摄像机功能。接下来,我们将添加菜单系统,并配置代码从开始菜单运行您的曲目。为了设置菜单系统,我们需要将以下预设对象拖放到场景层次中的Menus父游戏对象中:

  • GameExitMenu

  • 游戏帮助菜单

  • GameHUD

  • GameOverMenu

  • GamePauseMenu

  • 游戏开始菜单

我们需要一个EventSystem对象来让 UI/菜单系统正常运行。右键点击Menus父游戏对象,选择“UI”选项,然后选择EventSystem条目。你应该有一个EventSystem游戏对象作为父菜单的子菜单,以及所有支持的菜单屏幕。您应该有一个类似于下面截图的设置。

img/520710_1_En_12_Fig9_HTML.jpg

图 12-9

新赛道层级示例 5 描述配置菜单系统后场景层级的图像片段

花点时间想想我们迄今为止为让赛道恢复运行做了些什么。我们主要使用预置,但这只是为了节省时间,减少启动和运行所需的步骤。发展的阶段是相同的;它们只是为了加快我们的速度而压缩的。我们需要做的最后一件事是将我们的新曲目插入菜单系统代码,这样我们就可以将它作为游戏的一部分,然后进行测试。

img/520710_1_En_12_Fig10_HTML.png

图 12-10

新赛道场景示例 5A 描述配置了菜单系统的场景的当前状态的屏幕截图

要将我们的新赛道连接到游戏,打开GameStartMenuGameOverMenu类进行编辑。更改每个类中的TRACK_NAME_4类字段,并用你正在处理的场景名称替换列出的场景名称。如果你严格遵守文本,那么这个名字应该是“MyTrack15”我们还需要将场景添加到项目构建配置中。为此,请从以下菜单位置打开构建设置:“文件”➤“构建设置”在结果窗口的顶部是项目构建过程中包含的场景列表。确保“MyTrack15”场景已打开,然后单击“添加打开的场景”按钮。如果需要,可以在列表中上下拖动场景条目来重新定位它。

img/520710_1_En_12_Fig11_HTML.png

图 12-11

新赛道构建设置示例描述项目构建设置中包含的场景列表的屏幕截图

为了比较和调试的目的,可以使用“开始”菜单上的“赛道 3”按钮来访问这个赛道的演示版本。恭喜你!您已经成功地在游戏中添加了一条新赛道!

第二章结论

这就引出了本章的结论。我们看到了如何在 Unity 编辑器中使用预设对象来加速赛道的创建。预设允许我们使用预先配置的对象作为构建模块,大大减少了开发时间。请注意,我们已经详细讨论过的所有不同脚本都与正确的游戏对象相关联,当我们将它们拖放到场景的层次结构中时,它们可以插入到我们的核心游戏代码中。

这是通过设计实现的。我们已经回顾过的所有方法转义代码,我们已经多次提到这个短语,用于关闭代码库中未使用或配置不当的类,以在整个赛道开发过程中尽可能保持游戏的功能。让我们回顾一下创建新赛道的步骤。

轨迹环境和清理脚本:在轨迹开发的这个阶段,我们为轨迹创建了一个简单的位置。我们还添加了一个销毁器脚本来处理删除不小心从板上掉下来的对象。你自己试试。如果你在触发一个助推调节器后击中一个跳跃,你可以设法飞离棋盘。看看你做的时候会发生什么。

Hover Racers 和 GameState 对象:赛道开发过程的这一部分将 hover racers 的初始集合添加到棋盘上,并创建了游戏大脑的一个实例,即带有关联的GameState脚本组件的GameState对象。在赛道开发的这一点上,你将能够演示冲浪板并在场景中驾驶。

轨迹和航路点对象:添加轨迹和航路点对象会打开正在开发的轨迹上的许多功能。人工智能的对手现在开始在赛道上比赛,并且像偏离赛道,错误的方式和卡住的赛车支持这样的功能现在已经启用。

跳跃、助推、菜单屏幕等等:如果没有一些跳跃和其他酷的方面,悬浮车赛道有什么好的?在赛道开发的这一阶段,我们通过在赛道上添加背景音乐、飞艇摄像机、游戏菜单屏幕以及许多助推、跳跃、趣味框和战斗模式标记来完成事情。最后,我们通过在菜单系统中注册来连接赛道和比赛。

花点时间思考一下我们刚刚完成的曲目创建流程。浏览不同的游戏对象,看看有哪些脚本在使用。回想一下我们对这些脚本的审查,并尝试将您在赛道上比赛时看到的功能与驱动它的代码联系起来。

十三、总结

欢迎到本文结尾!如果你正在阅读这篇文章,那么我假设你已经完成了对一个完整的、相当复杂的 Unity 赛车游戏的深入审查。这没什么可大惊小怪的。这是一个严峻的挑战,审查这么多的材料,并建立自己的游戏,一个小小的示范赛道。你克服了挑战,我赞扬你。让我们花点时间来回顾一下我们在这篇课文中共同完成的一些事情。

造诣

在这篇课文中,我们已经设法涵盖了很多内容。我们从基础开始,建立并运行我们的 Unity 开发环境。我们对书中包含的游戏项目进行了测试,然后认真地、详细地审查了大量代码。以下是我们在这段旅程中取得的一些显著成就。

游戏规范:我们对游戏开发过程采取了专业的方法,并概述了在赛道上比赛时游戏机制遇到的所有不同的交互。这些概念被描述为带有支持文本的图表,以清楚地描述相关的游戏情况。

简单的交互脚本:您必须详细了解驱动赛道和弹跳障碍游戏机制的更简单的交互脚本。这些类向我们展示了代码是如何通过碰撞处理程序与游戏对象交互的,并给了我们简单的例子,演示场景,我们可以通过游戏来可视化。

复杂的交互脚本:复杂的交互脚本为游戏的战斗模式和许多与碰撞相关的游戏机制提供动力,如助推、跳跃和战斗模式标记。事情变得有点复杂,但是我们详细地回顾了代码,并且通过尝试不同的演示场景,有机会看到代码的运行。

助手类:我们深入研究了游戏代码库使用的助手类。这给了我们一个很好的例子,说明常规 C# 类如何与脚本组件混合来处理像排序和序列化/反序列化数据这样的深奥任务。

代码结构:虽然我们没有直接解决这个问题,但它一直在幕后。我们回顾了代码库中几乎每个脚本组件都使用的基类。我们还看到,作为菜单系统类的一部分,专门的基类集中了几个菜单屏幕的类似功能。最后,我们熟悉了游戏代码库和类的整体结构。

项目结构:有组织的项目结构的一个很好的例子,包括在“项目”和“层次”面板中组织资源和游戏对象,通过 Hover Racers 项目本身来表达。从项目的组织方面、层次结构和代码来看,花时间将项目作为一个整体进行评审绝对是值得的。

输入类:我们一起回顾了所有不同的输入类,包括驱动 Hover Racers 游戏的输入映射。这为您提供了一个处理来自键盘、鼠标、控制器和触摸屏的输入的很好的工作示例。游戏项目提供了一个处理来自不同来源的抽象输入的很好的例子。一定要复习课文的这个方面,因为它将来肯定会派上用场。

人工智能对手:你必须亲眼看到人工智能对手的实现。最初是通过实现路点来引导人工智能控制的赛车,随后是抽象的输入处理程序和模拟用户输入的人工智能特定的输入方法。游戏人工智能总是一个挑战,我遵循的规则是通过向你的人工智能控制的角色提供尽可能多的数据来尽可能多地作弊。我的第二个经验法则,前面提到过,是从实际的功能代码中抽象出输入处理程序,这样 AI 逻辑和人类玩家都可以使用它。

菜单系统类:我们回顾了菜单系统类,展示了如何通过游戏状态和玩家状态类将与菜单屏幕相关的脚本组件连接到游戏的其余部分。我们还构建了一个简单的菜单屏幕,演示基本的定位、调整大小和基于脚本的事件处理。

游戏和玩家状态管理:通过使用BaseScript类的初始化方法,你可以直接看到游戏状态控制在项目范围内的实现。我们看到了每个主脚本组件是如何扩展这个类的,并且如果检测到配置错误,就会“关闭”该类的每个实例。如果应用得当,这个特性会增加代码的稳定性,并允许游戏在场景中缺少脚本组件或游戏对象时运行。我们在第十二章中为游戏建造一个新的赛马场时经历了这一点。您还可以体验这种方法的集中化优势,因为代码库中的每个主要脚本组件都建立了对GameState对象和相关脚本的引用。

Unity 提示:我们讨论了一些 Unity 提示,你可以用它们来让你的下一个游戏变得更好。我们讨论了提高效率和加快构建时间的技巧,这样您可以更快地迭代,完成更多的测试和调试。我们还谈到了被动提示,也就是你在构建游戏时应该记住的东西。

添加一个新的轨道:你获得了一些直接的经验,可以使用预设的对象为悬停赛车游戏构建一个新的轨道。你可能没有意识到的是,每一条赛道其实都是一个完整的游戏。主菜单屏幕提供了一些按钮,可以让你跳到游戏中不同的主场景,因为没有更好的词了。这个练习向我们展示了 Unity GameObject如何与相关的脚本组件和代码库一起工作来制作一个完整的游戏。

承认

  • Katia Pouleva:一位出色的艺术家,他创作了大量的悬停赛车游戏艺术,还清理了本文中的所有截图。链接:https://katiapouleva.com

-亚采克·扬科夫斯基:在“Main13”场景和其他演示场景中使用的“简单模块化街道套件”的创造者。

链接:不适用

  • Unity Technologies:游戏主场景中使用的“棚户区:混凝土墙粗糙”和“棚户区:混凝土管道”模型的创造者。

链接:不适用

  • Reikan Studio:游戏中用于悬停赛车手的“Hover9k”原始模型的创造者。

链接:不适用

  • BOXY KU:创作了一些在 Hover Racers 游戏中用作背景音乐的音乐。

链接: assetstore。团结。com/packages/audio/music/electronic/electronic-future-city-free-21756

  • Duane's Mind:“混凝土护栏、木制托盘和油桶道具”模型的创造者,该模型用于主轨道和一些演示场景。

链接: assetstore。团结。com/packages/3d/props/industrial/concrete-barrier-wood-pallet-oil-drum-props-2698

-发行人 971:在游戏的两个主要场景“主 13”和“主 14”中使用的“混凝土屏障”的创造者链接:不适用

-盖伊·科克罗夫特:游戏中一些音效所用的“8 位复古狂暴:免费版”的创造者。

链接: assetstore。团结。com/ packages/ audio/ sound-fx/ 8 位-复古-横行-自由版-7946

-MoppySound:“8 位自由动作”的创造者,它是游戏中一些音效的来源。

链接: assetstore。团结。com/packages/audio/music/electronic/8-bit-action-free-19827

你将何去何从

从这里你可以进入很多方向。请允许我提几点建议。

修改现有的游戏:您可以添加由新游戏对象驱动的新游戏机制,以及它们与现有对象集之间的交互。您可以创建新的赛道进行比赛,或者为 Hover Racers 游戏添加多人支持。天空是无限的。

给游戏添加粒子效果:最初的粒子效果是用来给赛车添加尘埃云,触发助推修改器的火焰条纹,以及当赛车的大炮开火时爆炸的云,这些都被否决了,所以我把它们注释掉了,但保留了注释。一个很好的练习就是在游戏中加入新的更新的粒子效果。

创建一个全新的赛车游戏:使用悬停赛车项目作为一个新的赛车游戏的起点,或者把整个事情扔出去,从头开始;需要的时候随时可以作为参考。

添加新的模型、音乐和音效:访问 Unity 资产商店,寻找新的模型、音乐或音效,并将它们添加到游戏中。

创建一个全新的游戏:利用你已经获得的知识,开始做你一直想做的游戏。

说再见

嗯,我该走了。我希望这本书能在你的游戏开发之旅中帮助你,并为你提供一些知识、娱乐或智慧。我祝你在未来的努力中好运和成功!再见,再见。

posted @ 2024-08-10 19:05  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报