C--和-Unity-2021-游戏开发学习手册-全-

C# 和 Unity 2021 游戏开发学习手册(全)

原文:zh.annas-archive.org/md5/D5230158773728FED97C67760D6D7EA0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Unity 是世界上最受欢迎的游戏引擎之一,迎合业余爱好者、专业 AAA 工作室和电影制作公司。虽然以其用作 3D 工具而闻名,但 Unity 拥有一系列专门功能,支持从 2D 游戏和虚拟现实到后期制作和跨平台发布的一切。

开发人员喜欢它的拖放界面和内置功能,但正是编写自定义 C#脚本以实现行为和游戏机制的能力真正使 Unity 脱颖而出。学习编写 C#代码对于已经掌握其他语言的经验丰富的程序员来说可能并不是一个巨大的障碍,但对于那些没有编程经验的人来说可能是令人望而却步的。这就是这本书的用武之地,因为我将带领你从头开始学习编程和 C#语言的基础知识,同时在 Unity 中构建一个有趣且可玩的游戏原型。

这本书适合谁

这本书是为那些没有编程或 C#基本原则经验的人写的。然而,如果你是一个有能力的新手或经验丰富的专业人士,来自其他语言,甚至是 C#,但需要在 Unity 中进行游戏开发,这本书仍然适合你。

本书涵盖的内容

第一章《了解您的环境》,从 Unity 安装过程开始,介绍了编辑器的主要功能,以及查找 C#和 Unity 特定主题的文档。我们还将介绍如何在 Unity 内创建 C#脚本,并了解 Visual Studio,这是我们所有代码编辑的应用程序。

第二章《编程的基本构件》,首先阐述了编程的原子级概念,让你有机会将变量、方法和类与日常生活中的情况联系起来。然后,我们将介绍简单的调试技术、适当的格式和注释,以及 Unity 如何将 C#脚本转换为组件。

第三章《深入变量、类型和方法》,深入探讨了第二章的基本知识。这包括 C#数据类型、命名约定、访问修饰符以及程序基础所需的其他内容。我们还将介绍如何编写方法、添加参数和使用返回类型,并以对属于MonoBehaviour类的标准 Unity 方法的概述结束。

第四章《控制流和集合类型》,介绍了在代码中做出决策的常见方法,包括if-elseswitch语句。然后,我们继续使用数组、列表和字典,并结合迭代语句循环遍历集合类型。我们以查看条件循环语句和一种特殊的 C#数据类型枚举结束本章。

第五章《使用类、结构和面向对象编程》,详细介绍了我们与构建和实例化类和结构的第一次接触。我们将介绍创建构造函数、添加变量和方法以及子类和继承的基本步骤。本章将以对面向对象编程的全面解释以及它如何应用于 C#结束。

第六章《动手使用 Unity》,标志着我们从 C#语法进入游戏设计、关卡构建和 Unity 的特色工具世界。我们将首先介绍游戏设计文档的基础知识,然后开始阻塞我们的关卡几何结构,并添加照明和简单的粒子系统。

第七章《移动、摄像机控制和碰撞》,解释了移动玩家对象和设置第三人称摄像机的不同方法。我们将讨论整合 Unity 物理引擎以获得更真实的运动效果,以及如何处理碰撞器组件并捕捉场景内的交互。

第八章编写游戏机制,介绍了游戏机制的概念以及如何有效地实现它们。我们将从添加简单的跳跃动作开始,创建射击机制,并通过添加逻辑来处理物品收集来构建前几章的代码。

第九章基本人工智能和敌人行为,从游戏中人工智能的简要概述开始,并介绍了我们将应用于《英雄诞生》的概念。本章涵盖的主题包括在 Unity 中进行导航,使用级别几何和导航网格,智能代理和自动化敌人移动。

第十章重新审视类型、方法和类,更深入地研究了数据类型、中级方法特性以及可用于更复杂类的附加行为。本章将让你更深入地了解 C#语言的多功能性和广度。

第十一章介绍堆栈、队列和哈希集,深入介绍了中级集合类型及其特性。本章涵盖的主题包括使用堆栈、队列和哈希集,以及它们各自独特适用的不同开发场景。

第十二章保存、加载和序列化数据,让你准备好处理游戏信息。本章涵盖的主题包括使用文件系统、创建、删除和更新文件。我们还将涵盖包括 XML、JSON 和二进制数据在内的不同数据类型,并最后进行关于将 C#对象直接序列化为数据格式的实际讨论。

第十三章探索泛型、委托和更多,详细介绍了 C#语言的中级特性以及如何在实际的现实场景中应用它们。我们将从泛型编程的概述开始,逐渐深入到委托、事件和异常处理等概念。

第十四章旅程继续,回顾了你在整本书中学到的主要内容,并为你提供了进一步学习 C#和 Unity 的资源。这些资源包括在线阅读材料、认证信息以及我最喜欢的视频教程频道。

为了充分利用本书

为了充分利用即将到来的 C#和 Unity 冒险,你唯一需要的就是一颗好奇的心和学习的意愿。话虽如此,如果你希望巩固你所学到的知识,那么做所有的代码练习、英雄的试炼和测验部分是必不可少的。最后,在继续学习之前重新学习主题和整章内容以刷新或巩固你的理解总是一个好主意。在不稳定的基础上建造房子是没有意义的。

你还需要在你的计算机上安装当前版本的 Unity — 推荐使用 2021 年或更高版本。所有的代码示例都经过了 Unity 2021.1 的测试,并且应该可以在未来的版本中正常工作。

本书涵盖的软件/硬件
Unity 2021.1 或更高版本
Visual Studio 2019 或更高版本
C# 8.0 或更高版本

在开始之前,请检查你的计算机设置是否符合 Unity 的系统要求,网址为docs.unity3d.com/2021.1/Documentation/Manual/system-requirements.html

下载示例代码文件

本书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-C-by-Developing-Games-with-Unity-Sixth-Edition。我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含了本书中使用的屏幕截图/图表的彩色图片。你可以在这里下载:static.packt-cdn.com/downloads/9781801813945_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“选择Materials文件夹。”

代码块设置如下:

public string firstName = "Harrison"; 

当我们希望引起您对代码块的特定部分的注意时,相关行或项将被突出显示:

accessModifier returnType UniqueName(**parameterType parameterName**) 
{
    method body
} 

粗体:表示新术语、重要单词或屏幕上看到的单词,例如菜单或对话框中的单词。例如:“点击创建 | 3D 对象 | 胶囊,从层次结构面板中。”

第一章:了解您的环境

流行文化经常将计算机程序员宣传为局外人、独行侠或怪异的黑客。他们拥有非凡的算法思维能力,社交智商低,有点反叛。虽然事实并非如此,但学习编程的确会从根本上改变您看待世界的方式。好消息是,您天生的好奇心已经希望在世界中看到这些模式,您甚至可能会喜欢这种新的思维方式。

从早上睁开眼睛到晚上睡觉前看到天花板风扇的最后一眼,您无意识地使用分析技能,这些技能可以转化为编程 - 您只是缺少正确的语言和语法将这些生活技能映射到代码中。您知道自己的年龄,对吧?那是一个变量。当您过马路时,我假设您会像我们其他人一样在踏上路边之前向两个方向看一眼。这是评估不同条件,更为程序术语中的控制流。当您看着一罐汽水时,您本能地识别出它具有形状、重量和内容等特定属性。那就是一个类对象!您明白了吧。

凭借您掌握的丰富实际经验,您已经准备好进入编程的领域了。为了开始您的旅程,您需要知道如何设置您的开发环境,使用涉及的应用程序,并确切地知道在需要帮助时该去哪里。

为此,我们将首先深入以下 C#主题:

  • 开始使用 Unity 2021

  • 使用 C#与 Unity

  • 探索文档

让我们开始吧!

技术要求

有时候,从一件事物不是什么开始,比从它是什么开始更容易。本书的目标不是教会您关于 Unity 游戏引擎或游戏开发的所有知识。出于必要,我们将在旅程开始时以基本水平涵盖这些主题,并在第六章与 Unity 一起动手中进行更详细的讨论。然而,这些主题包括在内,是为了以一种有趣、易于理解的方式从零开始学习 C#编程语言。

由于本书面向完全没有编程经验的初学者,如果您之前没有接触过 C#或 Unity,那么您来对地方了!如果您之前有一些 Unity Editor 的经验,但没有编程经验,猜猜怎么着?这依然是您应该来的地方。即使您之前尝试过一些 C#混合 Unity,但想要探索一些中级或高级主题,本书的后几章也可以为您提供所需的内容。

如果您是其他语言的有经验的程序员,可以随意跳过初学者理论,直接进入您感兴趣的部分,或者留下来复习您的基础知识。

除了运行 Unity 2021,您还将使用 C# 8.0 和 Visual Studio 来编写游戏代码。

开始使用 Unity 2021

如果您尚未安装 Unity,或者正在运行早期版本,请按照以下步骤设置您的环境:

  1. 前往www.unity.com/

  2. 选择开始(如下图所示):

图 1.1:Unity 首页

这将带您到 Unity 商店页面。不要感到不知所措 - 您可以完全免费获得 Unity!

如果 Unity 首页对您来说与图 1.1中所见不同,您可以直接前往store.unity.com

  1. 选择个人选项。其他付费选项提供更高级的功能和服务,但您可以自行查看:

图 1.2:Unity 计划和定价

  1. 选择个人计划后,将询问您是否是第一次或返回用户。在第一次用户下选择从这里开始

图 1.3:使用 Unity 门户开始创建

  1. 选择同意并下载以获取您的 Unity Hub 副本:

图 1.4:Unity 条款和条件

下载完成后,请按照以下步骤操作:

  1. 打开安装程序(双击打开)

  2. 接受用户协议

  3. 按照安装说明操作

当您得到绿灯时,继续启动 Unity Hub 应用程序!

最新版本的 Unity Hub 在您首次打开应用程序时将提供安装向导。如果您想要跟随,可以随意选择。

以下步骤向您展示如何在不借助应用程序的帮助下开始一个新项目:

  1. 在左下角选择跳过安装向导,然后确认跳过向导

图 1.5:安装向导

  1. 从左侧菜单切换到安装选项卡,然后选择添加以选择您的 Unity 版本:

图 1.6:Unity Hub 安装面板

  1. 选择您想要的 Unity 版本,然后单击下一步。在撰写本文时,Unity 2021 仍处于预发布阶段,但在您阅读本文时,您应该能够从官方发布列表中选择 2021 版本:

图 1.7:添加 Unity 版本弹出窗口

  1. 然后,您将有选择将各种模块添加到您的安装中。确保选择了 Visual Studio 模块,然后单击下一步

图 1.8:添加安装模块

如果您想以后添加任何模块,可以单击更多按钮(安装窗口右上角的三点图标)。

安装完成后,您将在安装面板中看到一个新版本,如下所示:

图 1.9:带有 Unity 版本的安装选项卡

您可以在docs.unity3d.com/Manual/GettingStartedInstallingHub.html找到有关 Unity Hub 应用程序的其他信息和资源。

事情总会出错的可能性,所以如果您使用的是 macOS Catalina 或更高版本,可能会出现问题,请务必查看以下部分。

使用 macOS

如果您在使用某些版本的 Unity Hub 安装 Unity 时在 Mac 上遇到 OS Catalina 或更高版本的问题,那么请深呼吸,转到Unity 下载存档,并获取您需要的 2021 版本(unity3d.com/get-unity/download/archive)。记住使用下载(Mac)选项而不是 Unity Hub 下载:

图 1.10:Unity 下载存档

如果您在 Windows 上工作并遇到类似的安装问题,下载 Unity 的存档副本也可以正常工作。

由于它是一个.dmg文件,下载是一个普通的应用程序安装程序。打开它,按照说明操作,您将很快就可以开始了!

图 1.11:从下载管理器成功安装 Unity

本书的所有示例和截图都是使用 Unity 2021.1.0b8 创建和捕获的。如果您使用的是更新版本,Unity 编辑器中的外观可能会略有不同,但这不应影响您的跟进。

现在 Unity Hub 和 Unity 2021 已安装完成,是时候创建一个新项目了!

创建一个新项目

启动 Unity Hub 应用程序以开始一个新项目。如果您有 Unity 帐户,请继续登录;如果没有,您可以创建一个或在屏幕底部单击跳过

现在,让我们通过在右上角的新建按钮旁边选择箭头图标来设置一个新项目:

图 1.12:Unity Hub 项目面板

选择您的 2021 版本并设置以下字段:

  • 模板:项目将默认为3D

  • 项目名称:我将称我的为Hero Born

  • 位置:您希望项目保存在哪里

设置完成后,点击创建

图 1.13:带有新项目配置弹出窗口的 Unity Hub

创建项目后,您可以随时从 Unity Hub 的项目面板中重新打开项目。

导航编辑器

当新项目完成初始化时,您将看到美妙的 Unity 编辑器!我在以下截图中标记了重要的标签(或面板,如果您喜欢的话):

图 1.14:Unity 界面

这是很多内容,所以我们将更详细地查看每个面板:

  1. 工具栏面板是 Unity 编辑器的最顶部部分。从这里,您可以操作对象(最左边的按钮组)并播放和暂停游戏(中间按钮)。最右边的按钮组包含 Unity 服务、LayerMasks和布局方案功能,这本书中我们不会使用,因为它们与学习 C#无关。

  2. 层次结构窗口显示当前游戏场景中的每个项目。在起始项目中,这只是默认摄像机和定向光,但当我们创建原型环境时,这个窗口将开始填充。

  3. 游戏场景窗口是编辑器最直观的部分。将场景窗口视为舞台,您可以在其中移动和排列 2D 和 3D 对象。当您点击播放按钮时,游戏窗口将接管,渲染场景视图和任何编程交互。

  4. 检查器窗口是查看和编辑场景中对象属性的一站式商店。如果您在层次结构中选择主摄像机 游戏对象,您将看到显示了几个部分(Unity 称其为组件)—所有这些都可以从这里访问。

  5. 项目窗口包含当前项目中的每个资产。将其视为项目文件夹和文件的表示。

  6. 控制台窗口是我们希望脚本打印的任何输出都会显示的地方。从现在开始,如果我们谈论控制台或调试输出,这个面板就是显示的地方。

如果意外关闭了任何这些窗口,您可以随时从Unity | 窗口 | 常规重新打开它们。您可以在 Unity 文档中找到关于每个窗口功能的更深入的分析docs.unity3d.com/Manual/UsingTheEditor.html

在继续之前,重要的是将 Visual Studio 设置为项目的脚本编辑器。转到Unity 菜单 | 首选项 | 外部工具,检查外部脚本编辑器是否设置为 Visual Studio for Mac 或 Windows:

图 1.15:将外部脚本编辑器更改为 Visual Studio

最后的提示,如果您想在浅色和深色模式之间切换,转到Unity 菜单 | 首选项 | 常规,更改编辑器主题

图 1.16:Unity 常规首选项面板

我知道如果您是新手,这可能需要一些时间来理解,但请放心,以后的任何说明都会提到必要的步骤。我不会让您猜测要按哪个按钮。说了这些,让我们开始创建一些实际的 C#脚本。

在 Unity 中使用 C#

未来,将 Unity 和 C#视为共生实体是很重要的。Unity 是您将创建脚本和游戏对象的引擎,但实际的编程发生在另一个名为 Visual Studio 的程序中。现在不用担心这个问题,我们马上就会解决。

使用 C#脚本

尽管我们还没有涵盖任何基本的编程概念,但在我们知道如何在 Unity 中创建实际的 C#脚本之前,它们将没有用武之地。C#脚本是一种特殊类型的 C#文件,在其中您将编写 C#代码。这些脚本可以在 Unity 中用于几乎任何事情,从响应玩家输入到创建游戏机制。

有几种从编辑器创建 C#脚本的方法:

  • 选择Assets | Create | C# Script

  • Project选项卡下方,选择+图标并选择C# Script

  • Project选项卡中的Assets文件夹上右键单击,然后从弹出菜单中选择Create | C# Script

  • Hierarchy窗口中选择任何 GameObject,然后单击Add Component | New Script

今后,每当您被指示创建 C#脚本时,请使用您喜欢的任何方法。

除了使用上述方法在编辑器中创建 C#脚本之外,还可以创建资源和其他对象。我不会每次创建新内容时都提到这些变化,所以请将选项记在心中。

为了组织起见,我们将把各种资产和脚本存储在它们标记的文件夹内。这不仅仅是一个与 Unity 相关的任务 - 这是您应该始终执行的任务,您的同事会感谢您(我保证):

  1. Project选项卡中,选择+ | Folder(或您最喜欢的任何方法 - 在图 1.17中,我们选择了Assets | Create | Folder)并将其命名为Scripts

图 1.17:创建 C#脚本

  1. 双击Scripts文件夹并创建一个新的 C#脚本。默认情况下,脚本将被命名为NewBehaviourScript,但您会看到文件名被突出显示,因此您可以立即重命名它。键入LearningCurve并按Enter

图 1.18:选择 Scripts 文件夹的项目窗口

您可以使用Project选项卡底部右侧的小滑块来更改文件的显示方式。

所以,您刚刚创建了一个名为Scripts的子文件夹,如前面的屏幕截图所示。在该父文件夹中,您创建了一个名为LearningCurve.cs的 C#脚本(文件类型为.cs代表 C-Sharp,以防您想知道),现在它作为我们Hero Born项目资产的一部分保存了下来。现在只需在 Visual Studio 中打开它!

介绍 Visual Studio 编辑器

Unity 可以创建和存储 C#脚本,但需要使用 Visual Studio 进行编辑。Unity 预先打包了 Visual Studio 的副本,并且当您从编辑器内部双击任何 C#脚本时,它将自动打开。

打开 C#文件

Unity 将在您第一次打开文件时与 Visual Studio 同步。最简单的方法是从Project选项卡中选择脚本。

双击LearningCurve.cs,这将在 Visual Studio 中打开 C#文件:

图 1.19:Visual Studio 中的 LearningCurve C#脚本

您可以随时从Visual Studio | View | Layout更改 Visual Studio 选项卡。我将在本书的其余部分中使用Design布局,这样我们就可以在编辑器的左侧看到项目文件。

您将在界面的左侧看到一个与 Unity 中的文件夹结构相同的文件夹结构,您可以像访问其他文件夹一样访问它。右侧是实际的代码编辑器,其中发生了魔术。Visual Studio 应用程序有更多功能,但这是我们开始所需的全部。

Visual Studio 界面在 Windows 和 Mac 环境下有所不同,但本书中将使用的代码在两者上都能很好地工作。本书中的所有屏幕截图都是在 Mac 环境中拍摄的,因此如果您的计算机上看起来不同,那就没必要担心。

注意命名不匹配

一个常见的陷阱是文件命名 - 更具体地说是命名不匹配,我们可以使用 Visual Studio 中 C#文件的图 1.19第 5 行来说明:

public class LearningCurve : MonoBehaviour 

LearningCurve类名与LearningCurve.cs文件名相同。这是一个基本要求。如果你现在不知道类是什么,没关系。重要的是要记住,在 Unity 中,文件名和类名需要相同。如果你在 Unity 之外使用 C#,文件名和类名不需要匹配。

当你在 Unity 中创建一个 C#脚本文件时,项目选项卡中的文件名已经处于编辑模式,准备重命名。养成当时就重命名的好习惯。如果以后重命名脚本,文件名和类名将不匹配。

如果以后重命名文件,文件名会改变,但第 5 行将如下所示:

public class NewBehaviourScript : MonoBehaviour 

如果你不小心这样做了,也不是世界末日。你只需要进入 Visual Studio,将NewBehaviourScript更改为你的 C#脚本的名称,以及桌面上.meta文件的名称。你可以在Assets | Scripts文件夹下的项目文件夹中找到.meta文件:

图 1.20:查找 META 文件

同步 C#文件

作为它们共生关系的一部分,Unity 和 Visual Studio 会相互通信以同步它们的内容。这意味着,如果你在一个应用程序中添加、删除或更改脚本文件,另一个应用程序会自动看到这些更改。

那么,当墨菲定律(它规定“任何可能出错的事情都会出错”)发生,同步似乎不正常时会发生什么?如果你遇到这种情况,深呼吸,选择 Unity 中的有问题的脚本,右键单击,然后选择刷新

现在你已经掌握了脚本创建的基础知识,所以是时候谈谈如何找到并有效地使用有用的资源了。

探索文档

我们在这次对 Unity 和 C#脚本的初次尝试中要谈到的最后一个主题是文档。我知道这不够吸引人,但在处理新的编程语言或开发环境时,养成良好的习惯是很重要的。

访问 Unity 的文档

一旦你开始认真地编写脚本,你会经常使用 Unity 的文档,所以早点知道如何访问它是有益的。参考手册会给你一个组件或主题的概述,而具体的编程示例可以在脚本参考中找到。

场景中的每个游戏对象(Hierarchy窗口中的一个项目)都有一个控制其位置旋转缩放Transform组件。为了简单起见,我们只查找参考手册中相机的Transform组件:

  1. Hierarchy选项卡中,选择Main Camera游戏对象

  2. 切换到Inspector选项卡,然后点击Transform组件右上角的信息图标(问号):

图 1.21:在检查器中选择了主摄像机游戏对象

你会看到一个网页浏览器打开到参考手册的Transforms页面:

图 1.22:Unity 参考手册

Unity 中的所有组件都有这个功能,所以如果你想了解更多关于某个东西如何工作的信息,你知道该怎么做。

所以,我们已经打开了参考手册,但如果我们想要与Transform组件相关的具体编码示例怎么办?很简单——我们只需要查看脚本参考。

点击组件或类名(在这种情况下是Transform)下方的切换到脚本链接:

图 1.23:突出显示了切换到脚本的 Unity 参考手册按钮

通过这样做,参考手册会自动切换到脚本参考:

图 1.24:突出显示了切换到手册的 Unity 脚本文档

如你所见,除了编码帮助,还有一个选项可以在必要时切换回参考手册。

脚本参考是一份庞大的文件,因为它必须是。然而,这并不意味着你必须记住它,甚至熟悉它的所有信息才能开始编写脚本。正如其名称所示,它是一个参考,而不是一份测试。

如果你在文档中迷失了,或者对于在哪里查找想法耗尽了,你也可以在以下地方找到丰富的 Unity 开发社区中的解决方案:

另一方面,你需要知道在任何 C#问题上找到资源的位置,我们将在下面介绍。

寻找 C#资源

既然我们已经处理了 Unity 资源,让我们来看看微软的 C#资源。首先,微软学习文档docs.microsoft.com/en-us/dotnet/csharp中有大量的教程、快速入门指南和操作文章。你还可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/index找到有关 C#主题的概述。

然而,如果你想要关于特定 C#语言特性的详细信息,参考指南是去的地方。这些参考指南对于任何 C#程序员来说都是重要的资源,但由于它们并不总是最容易导航,让我们花几分钟时间学习如何找到我们要找的内容。

让我们加载编程指南链接,查找 C#的String类。执行以下操作之一:

  • 在网页左上角的搜索栏中输入Strings

  • 向下滚动到语言部分,直接点击Strings链接:

图 1.25:浏览微软的 C#参考指南

对于类描述页面,你应该看到类似以下内容:

图 1.26:微软的字符串(C#编程指南)页面

与 Unity 的文档不同,C#参考和脚本信息都被捆绑在一起,但它的救星是右侧的子主题列表。好好利用它!当你陷入困境或有问题时,知道在哪里寻求帮助是非常重要的,所以确保在遇到障碍时回到这一部分。

总结

在本章中,我们涵盖了相当多的后勤信息,所以我可以理解如果你渴望编写一些代码。开始新项目、创建文件夹和脚本以及访问文档是在新冒险的兴奋中很容易被遗忘的主题。只要记住,本章有很多你可能在接下来的页面中需要的资源,所以不要害怕回来查看。像程序员一样思考是一种能力:你越多地使用它,它就会变得越强大。

在下一章中,我们将开始阐述你需要准备编码大脑的理论、词汇和主要概念。即使材料是概念性的,我们仍将在LearningCurve脚本中编写我们的第一行代码。准备好!

小测验-处理脚本

  1. Unity 和 Visual Studio 之间有什么样的关系?

  2. 脚本参考提供了关于使用特定 Unity 组件或功能的示例代码。你在哪里可以找到更详细的(非代码相关)关于 Unity 组件的信息?

  3. 脚本参考是一份庞大的文件。在尝试编写脚本之前,你需要记住多少内容?

  4. 什么时候是给 C#脚本命名的最佳时机?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交谈等等。

立即加入!

packt.link/csharpunity2021

第二章:编程的构建模块

任何编程语言对于不熟悉的人来说都像古希腊语一样难以理解,C#也不例外。好消息是,在最初的神秘之下,所有编程语言都由相同的基本构建模块组成。变量、方法和类(或对象)构成了传统编程的 DNA;理解这些简单的概念将打开一个多样和复杂应用的全新世界。毕竟,地球上每个人的 DNA 中只有四种不同的核碱基;然而,我们每个人都是独特的生物。

如果你是编程新手,在本章中会有大量的信息涌向你,这可能标志着你写下的第一行代码。重点不是用事实和数字来过载你的大脑,而是通过日常生活中的例子给你一个编程构建模块的整体观。

本章主要讨论构成程序的各个部分的高层视图。在直接进入代码之前,了解事物如何运作不仅能帮助新手程序员找到自己的位置,还能通过易于记忆的参考加强对主题的理解。撇开闲话,本章将重点讨论以下主题:

  • 定义变量

  • 理解方法

  • 引入类

  • 使用注释

  • 将构建模块组合在一起

定义变量

让我们从一个简单的问题开始:什么是变量?根据你的观点,有几种不同的回答方式:

  • 概念上,变量是编程的最基本单元,就像原子是物理世界一样(除了弦理论)。一切都始于变量,没有它们程序就无法存在。

  • 技术上,变量是计算机内存的一个小部分,它保存了一个分配的值。每个变量都会跟踪它的信息存储位置(这称为内存地址)、它的值和它的类型(比如数字、单词或列表)。

  • 实际上,变量就是一个容器。你可以随意创建新的变量,填充它们,移动它们,改变它们的内容,并根据需要引用它们。它们甚至可以是空的,但仍然有用。

你可以在微软 C#文档中找到关于变量的深入解释docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/variables

变量的一个实际生活例子是邮箱——还记得吗?

图 2.1:一排色彩斑斓的邮箱快照

它们可以装信件、账单、姨妈梅贝尔的照片——任何东西。重点是邮箱里的东西可能会有所不同:它们可以有名称,装信息(实体邮件),如果你有适当的安全许可,它们的内容甚至可以被更改。同样,变量可以容纳不同类型的信息。C#中的变量可以容纳字符串(文本)、整数(数字),甚至布尔值(代表真或假的二进制值)。

名称很重要

参考图 2.1,如果我让你过去打开邮箱,你可能会问的第一件事是:哪一个?如果我说史密斯家的邮箱,或者向日葵邮箱,甚至是最右边的垂头丧气的邮箱,那么你就有了打开我所指的邮箱所需的上下文。同样,当你创建变量时,你必须给它们一个你以后可以引用的唯一名称。我们将在第三章深入变量、类型和方法中详细讨论适当的格式和描述性命名。

变量充当占位符

当你创建并命名一个变量时,你就创建了一个存储数值的占位符。让我们以以下简单的数学方程为例:

2 + 9 = 11 

好了,这里没有什么神秘的,但如果我们想让数字9成为它的变量呢?考虑以下代码块:

MyVariable = 9 

现在我们可以使用变量名MyVariable来替代我们需要的9

2 + MyVariable = 11 

如果你想知道变量是否有其他规则或规定,答案是肯定的。我们将在下一章中介绍这些内容,所以请耐心等待。

尽管这个例子不是真正的 C#代码,但它说明了变量的威力以及它们作为占位符引用的用途。在下一节中,你将开始创建自己的变量,所以继续前进吧!

好了,理论够了,让我们在我们在第一章了解你的环境中创建的LearningCurve脚本中创建一个真正的变量:

  1. 从 Unity 项目窗口中双击LearningCurve.cs,在 Visual Studio 中打开它。

  2. 在第 6 行和第 7 行之间添加一个空格,并添加以下代码行来声明一个新变量:

public int CurrentAge = 30; 
  1. Start方法中,添加两个调试日志,打印出以下计算结果:
 Debug.Log(30 + 1);
    Debug.Log(CurrentAge + 1); 

让我们分解刚刚添加的代码。首先,我们创建了一个名为CurrentAge的新变量,并将其赋值为30。然后,我们添加了两个调试日志,打印出30 + 1CurrentAge + 1的结果,以展示变量是值的存储器。它们可以与值本身完全相同地使用。

还要注意的是,public变量会出现在 Unity 检视面板中,而private变量不会。现在不用担心语法,只需确保你的脚本与下面截图中显示的脚本相同:

图 2.2:在 Visual Studio 中打开的 LearningCurve 脚本

最后,使用编辑器 | 文件 | 保存保存文件。

要在 Unity 中运行脚本,它们必须附加到场景中的游戏对象上。英雄诞生中的示例场景默认包含摄像机和定向光,这为场景提供了照明,所以让我们将LearningCurve附加到摄像机上,以保持简单:

  1. LearningCurve.cs拖放到主摄像机上。

  2. 选择主摄像机,使其出现在检视器面板中,并验证LearningCurve.cs(脚本)组件是否正确附加。

  3. 点击播放并观察控制台面板中的输出:

图 2.3:Unity 编辑器窗口,带有拖放脚本的标注

Debug.Log()语句打印出了我们放在括号中的简单数学方程的结果。正如你在下面的控制台截图中所看到的,使用我们的变量CurrentAge的方程的工作方式与它是一个实际数字一样:

图 2.4:Unity 控制台显示了附加脚本的调试输出

我们将在本章末讨论 Unity 如何将 C#脚本转换为组件,但首先让我们来改变其中一个变量的值。

由于CurrentAge在第 7 行被声明为一个变量,如图 2.2所示,它存储的值可以被改变。更新后的值将传递到代码中使用变量的任何地方;让我们看看这个过程:

  1. 如果场景仍在运行,请点击暂停按钮停止游戏

  2. 检视器面板中将Current Age更改为18,然后再次播放场景,观察控制台面板中的新输出:

图 2.5:Unity 控制台显示了调试日志和附加到主摄像机的 LearningCurve 脚本

第一个输出仍然是31,因为我们在脚本中没有改变任何东西,但第二个输出现在是19,因为我们在检视面板中改变了CurrentAge的值。

这里的目标不是讨论变量语法,而是展示变量如何作为容器,可以创建一次并在其他地方引用。我们将在第三章深入变量、类型和方法中详细讨论。

现在我们知道如何在 C#中创建变量并赋值,我们准备好深入下一个重要的编程构建块:方法!

理解方法

单独的变量不能做更多的事情,只能跟踪其分配的值。虽然这很重要,但它们单独来说在创建有意义的应用程序方面并不是非常有用。那么,我们如何创建动作并在代码中驱动行为呢?简短的答案是使用方法。

在我们讨论方法是什么以及如何使用它们之前,我们应该澄清一个术语的小细节。在编程世界中,你经常会看到术语方法函数被交替使用,特别是在 Unity 方面。

由于 C#是一种面向对象的语言(这是我们将在第五章 使用类、结构和面向对象编程中介绍的内容),我们将在本书的其余部分使用术语方法,以符合标准的 C#指南。

当你在脚本参考或其他文档中遇到函数这个词时,想到方法。

方法驱动行为

与变量类似,定义编程方法可能会非常冗长或非常简短;这里有另外一个三方面的方法来考虑:

  • 概念上,方法是应用程序中完成工作的方式。

  • 技术上,方法是一个包含可执行语句的代码块,当通过名称调用方法时运行。方法可以接受参数(也称为参数),这些参数可以在方法的范围内使用。

  • 实际上,方法是一组指令的容器,每次执行时都会运行。这些容器还可以接受变量作为输入,这些变量只能在方法内部引用。

总的来说,方法是任何程序的骨架——它们连接一切,几乎所有的东西都是基于它们的结构构建的。

你可以在 Microsoft C#文档中找到有关方法的深入指南,网址为docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/methods

方法也是占位符

让我们以一个过于简化的例子来加深概念。在编写脚本时,你实际上是按顺序放置代码行,让计算机执行。第一次需要将两个数字相加时,你可以像下面的代码块中那样直接相加:

SomeNumber + AnotherNumber 

但是然后你得出结论,这些数字需要在其他地方相加。

与其复制和粘贴相同的代码行,导致杂乱或“意大利面”代码并且应该尽量避免,你可以创建一个命名的方法来处理这个动作:

AddNumbers() 
{
    SomeNumber + AnotherNumber
} 

现在AddNumbers就像一个变量一样占据着内存中的位置;但是,它不是一个值,而是一系列指令。在脚本中的任何地方使用方法的名称(或调用它)都可以让你立即使用存储的指令,而无需重复任何代码。

如果你发现自己一遍又一遍地写相同的代码行,你很可能错过了简化或将重复操作合并为常见方法的机会。

这会产生程序员开玩笑称之为意大利面代码的东西,因为它可能会变得混乱。你也会听到程序员提到一个叫做不要重复自己DRY)原则的解决方案,这是一个你应该牢记的口头禅。

和以前一样,一旦我们在伪代码中看到了一个新概念,最好是自己实现一下,这就是我们将在下一节中做的事情。

让我们再次打开LearningCurve,看看 C#中的方法是如何工作的。就像变量示例一样,你会想要将代码粘贴到你的脚本中,就像下面的截图中显示的那样。我已经删除了以前的示例代码,以使事情更整洁,但你当然可以将其保留在脚本中以供参考:

  1. 在 Visual Studio 中打开LearningCurve

  2. 在第 8 行添加一个新变量:

public int AddedAge = 1; 
  1. 在第 16 行添加一个新的方法,将CurrentAgeAddedAge相加并打印出结果:
void ComputeAge() 
{
    Debug.Log(CurrentAge + AddedAge);
} 
  1. Start中调用新方法,使用以下行:
 ComputeAge(); 

在 Unity 中运行脚本之前,请确保您的代码看起来像以下截图:

图 2.6:具有新的 ComputeAge 方法的 LearningCurve

  1. 保存文件,然后返回 Unity 并点击播放,看看新的控制台输出。

您在第 16 到 19 行定义了您的第一个方法,并在第 13 行调用了它。现在,无论何时调用ComputeAge(),这两个变量都将被相加并打印到控制台上,即使它们的值发生变化。请记住,您在 Unity 检视器中将CurrentAge设置为18,检视器的值将始终覆盖 C#脚本中的值:

图 2.7:更改检视器中变量值的控制台输出

继续尝试在检视器面板中尝试不同的变量值,看看它是如何运作的!关于您刚刚编写的实际代码语法的更多细节将在下一章中介绍。

在我们掌握了方法的整体概念之后,我们准备好着手处理编程领域中最大的主题——类!

介绍类

我们已经看到变量存储信息,方法执行操作,但是我们的编程工具包仍然有些有限。我们需要一种创建一种超级容器的方法,其中包含可以从容器内部引用的变量和方法。输入类:

  • 概念上,类在单个容器内保存相关信息、操作和行为。它们甚至可以相互通信。

  • 技术上,类是数据结构。它们可以包含变量、方法和其他编程信息,当类的对象被创建时,所有这些信息都可以被引用。

  • 实际上,类是一个蓝图。它为使用类蓝图创建的任何对象(称为实例)制定了规则和法规。

您可能已经意识到类不仅在 Unity 中存在,而且在现实世界中也存在。接下来,我们将看一下最常见的 Unity 类以及类在实际中的功能。

您可以在 Microsoft C#文档中找到有关类的深入指南docs.microsoft.com/en-us/dotnet/csharp/fundamentals/types/classes

一个常见的 Unity 类

在您想知道 C#中的类是什么样子之前,您应该知道您在整个本章中一直在使用一个类。默认情况下,Unity 中创建的每个脚本都是一个类,您可以从第 5 行的class关键字中看到:

public class LearningCurve: MonoBehaviour 

MonoBehaviour只是意味着这个类可以附加到 Unity 场景中的 GameObject 上。

类可以独立存在,当我们在第五章中创建独立类时,我们将看到这一点。

有时在 Unity 资源中,脚本和类这两个术语是可以互换使用的。为了保持一致,我将在脚本附加到 GameObject 时将 C#文件称为脚本,并在它们是独立的类时称为类。

类是蓝图

对于我们的最后一个例子,让我们想想一个当地的邮局。它是一个独立的、自包含的环境,具有属性,比如物理地址(一个变量),以及执行动作的能力,比如寄出您的邮件(方法)。

这使得邮局成为一个潜在类的绝佳例子,我们可以在以下伪代码块中概述:

public class PostOffice
{
    // Variables
    public string address = "1234 Letter Opener Dr."
    // Methods
    DeliverMail() {}
    SendMail() {}
} 

这里的主要要点是,当信息和行为遵循预定义的蓝图时,复杂的操作和类间通信变得可能。例如,如果我们有另一个类想要通过我们的PostOffice类发送一封信,它不必想知道去哪里执行此操作。它可以简单地从PostOffice类中调用SendMail函数,如下所示:

PostOffice().SendMail() 

或者,您可以使用它查找邮局的地址,这样您就知道在哪里寄信:

PostOffice().address 

如果你对单词之间使用句点(称为点表示法)有疑问,我们将在下一节中详细介绍。

类之间的通信

到目前为止,我们已经将类和 Unity 组件描述为独立的实体;实际上,它们是紧密相连的。要创建任何有意义的软件应用程序,都需要在类之间进行某种形式的交互或通信。

如果你还记得之前的邮局例子,示例代码使用句点(或点)来引用类、变量和方法。如果你把类想象成信息目录,那么点表示法就是索引工具:

PostOffice().Address 

类中的任何变量、方法或其他数据类型都可以用点表示法访问。这也适用于嵌套或子类信息,但我们将在第五章“使用类、结构和面向对象编程”中讨论所有这些主题。

点表示法也是驱动类之间通信的工具。每当一个类需要另一个类的信息或想要执行它的方法时,都会使用点表示法:

PostOffice().DeliverMail() 

点表示法有时被称为.运算符,所以如果在文档中看到这种提法,不要感到困惑。

如果点表示法还没有完全理解,不要担心,它会的。它是整个编程体系的血脉,将信息和上下文传递到需要的地方。

现在你对类有了更多了解,让我们谈谈你在编程生涯中最常用的工具——注释!

处理注释

你可能已经注意到LearningCurve有一行奇怪的文本(图 2.6中的10),以两个斜杠开头,这是脚本默认创建的。

这些是代码注释!在 C#中,有几种方法可以用来创建注释,而 Visual Studio(和其他代码编辑应用程序)通常会通过内置快捷方式使其更加容易。

一些专业人士可能不认为注释是编程的基本构建块,但我不得不尊重地不同意。正确地用有意义的信息注释你的代码是新程序员可以养成的最基本的习惯之一。

单行注释

以下单行注释与我们在LearningCurve中包含的注释类似:

// This is a single-line comment 

Visual Studio 不会将以两个斜杠开头(没有空格)的行编译为代码,因此你可以根据需要使用它们来向他人或未来的自己解释你的代码。

多行注释

由于名称中有,你可以合理地假设单行注释只适用于一行代码。如果你想要多行注释,你需要在注释文本周围使用斜杠和星号(分别作为开头和结尾字符):/**/

/* this is a 
      multi-line comment */ 

你也可以通过在 macOS 上使用Cmd + /快捷键和在 Windows 上使用Ctrl + K + C来对代码块进行注释和取消注释。

Visual Studio 还提供了一个方便的自动生成注释功能;在任何代码行(变量、方法、类等)的前一行输入三个斜杠,将出现一个摘要注释块。

看到示例注释是好的,但在你的代码中加入它们总是更好的。现在开始注释永远不会太早!

添加注释

打开LearningCurve,在ComputeAge()方法上方添加三个反斜杠:

图 2.8:为方法自动生成的三行注释

你应该看到一个三行注释,其中包含由 Visual Studio 从方法名称生成的方法描述,夹在两个<summary>标签之间。当然,你可以通过按Enter键添加新行来更改文本,但一定不要触碰<summary>标签,否则 Visual Studio 将无法正确识别注释。

这些详细注释的有用之处在于,当您想了解自己编写的方法时,它就会变得清晰。如果您使用了三个斜杠的注释,只需将鼠标悬停在类或脚本中调用方法的任何位置,Visual Studio 就会弹出您的摘要:

图 2.9:带有注释摘要的 Visual Studio 弹出信息框

您的基本编程工具包现在已经完成(至少是理论抽屉)。然而,我们仍然需要了解本章中所学内容在 Unity 游戏引擎中的应用,这将是我们下一节的重点!

组合基本组件

在处理完基本组件之后,现在是时候在结束本章之前进行一些 Unity 特定的整理工作了。具体来说,我们需要更多地了解 Unity 如何处理附加到游戏对象的 C#脚本。

在这个例子中,我们将继续使用我们的LearningCurve脚本和 Main Camera 游戏对象。

脚本变成组件

所有的游戏对象组件都是脚本,无论是你自己编写的还是 Unity 团队编写的。唯一的区别是 Unity 特定的组件,比如Transform,以及它们各自的脚本,不应该被用户编辑。

一旦您创建的脚本被放置到游戏对象上,它就会成为该对象的另一个组件,这就是为什么它会出现在检视面板中。对于 Unity 来说,它像任何其他组件一样行走、交谈和行动,包括组件下面的公共变量,可以随时更改。尽管我们不应该编辑 Unity 提供的组件,但我们仍然可以访问它们的属性和方法,使它们成为强大的开发工具。

当脚本成为组件时,Unity 还会进行一些自动的可读性调整。您可能已经注意到在图 2.32.5中,当我们将LearningCurve添加到 Main Camera 时,Unity 将其显示为Learning CurveCurrentAge变为Current Age

变量作为占位符部分,我们看了如何在检视面板中更新变量,但重点是要了解这是如何工作的。有三种情况可以修改属性值:

  • 在 Unity 编辑器窗口中的播放模式

  • 在 Unity 编辑器窗口中的开发模式

  • 在 Visual Studio 代码编辑器中

在播放模式下进行的更改会实时生效,这对于测试和微调游戏性非常有用。然而,需要注意的是,在播放模式下进行的任何更改在停止游戏并返回开发模式时都将丢失。

当您处于开发模式时,您对变量所做的任何更改都将被 Unity 保存。这意味着,如果您退出 Unity 然后重新启动它,更改将被保留。

在播放模式下,您在检视面板中对值所做的更改不会修改您的脚本,但它们会覆盖您在开发模式下分配的任何值。

在播放模式下进行的任何更改都会在停止播放模式时自动重置。如果您需要撤消在检视面板中所做的任何更改,可以将脚本重置为其默认(有时称为初始)值。单击任何组件右侧的三个垂直点图标,然后选择重置,如下面的屏幕截图所示:

图 2.10:检视面板中的脚本重置选项

这应该让您放心——如果您的变量失控,总是可以进行硬重置。

MonoBehaviour 的帮助

由于 C#脚本是类,Unity 如何知道要将某些脚本转换为组件而不是其他脚本呢?简短的答案是LearningCurve(以及 Unity 创建的任何脚本)继承自MonoBehaviour(Unity 提供的默认类)。这告诉 Unity,这个 C#类可以被转换为组件。

类继承的主题对于您的编程之旅来说有点高级;把MonoBehaviour类想象成向LearningCurve借用一些变量和方法。第五章使用类、结构和面向对象编程,将详细介绍类继承。

我们使用的Start()Update()方法属于MonoBehaviour,Unity 会自动在附加到 GameObject 的任何脚本上运行它们。Start()方法在场景开始播放时运行一次,而Update()方法在每帧运行一次(取决于您的机器的帧率)。

现在您对 Unity 的文档熟悉度有了很大提升,我为您准备了一个简短的可选挑战!

英雄的试炼-脚本 API 中的 MonoBehaviour

现在是时候让您自己熟悉使用 Unity 文档了,还有什么比查找一些常见的MonoBehaviour方法更好的方法呢:

  • 尝试在脚本 API 中搜索Start()Update()方法,以更好地了解它们在 Unity 中的作用,以及何时

  • 如果您感到勇敢,可以进一步查看手册中的MonoBehaviour类,以获得更详细的解释

总结

我们在短短的几页中走了很长的路,但是理解变量、方法和类等基本概念的总体理论将为您打下坚实的基础。请记住,这些构建块在现实世界中有非常真实的对应物。变量保存值,就像邮箱保存信件一样;方法存储指令,就像食谱一样,用于预定义的结果;类就像真实的蓝图一样。如果您希望房子能够屹立不倒,就不能没有经过深思熟虑的设计来遵循。

本书的其余部分将带您深入学习 C#语法,从头开始,从下一章开始更详细地介绍如何创建变量、管理值类型以及使用简单和复杂的方法。

小测验-C#构建块

  1. 变量的主要目的是什么?

  2. 方法在脚本中扮演什么角色?

  3. 脚本如何成为组件?

  4. 点符号的目的是什么?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事会话与作者交谈,以及更多。

立即加入!

packt.link/csharpunity2021

第三章:深入变量、类型和方法

进入任何编程语言的初始步骤都会受到一个基本问题的困扰——你可以理解打出的字,但不知道它们背后的含义。通常情况下,这会导致悖论,但编程是一个特殊情况。

C#并不是一种独立的语言;它是用英语编写的。你每天使用的词语和在 Visual Studio 中的代码之间的差异来自于缺少上下文,这是需要重新学习的东西。你知道如何说和拼写 C#中使用的词语,但你不知道的是它们在语言的语法中是如何组成的,以及最重要的是如何组成的。

这一章标志着我们离开了编程理论,开始了我们进入实际编码的旅程。我们将讨论接受的格式化、调试技术,并组合更复杂的变量和方法示例。有很多内容要涵盖,但当你达到最后的测验时,你将对以下高级主题感到舒适:

  • 写正确的 C#

  • 调试你的代码

  • 理解变量

  • 引入运算符

  • 定义方法

让我们开始吧!

写正确的 C#

代码行就像句子一样,意味着它们需要有某种分隔或结束字符。每一行 C#代码,称为语句,必须以分号结尾,以便编译器对其进行处理。

然而,你需要注意一个问题。与我们熟悉的书面语言不同,C#语句在技术上不一定要在一行上;空格和换行符会被代码编译器忽略。例如,一个简单的变量可以这样写:

public int FirstName = "Harrison"; 

或者,它也可以这样写:

public
int
FirstName
= 
"Harrison"; 

这两个代码片段对 Visual Studio 来说都是完全可以接受的,但第二个选项在软件社区中是极为不鼓励的,因为它使得代码极其难以阅读。理念是尽可能高效和清晰地编写你的程序。

有时候一条语句会太长,无法合理地放在一行上,但这种情况很少。只要确保它的格式能让别人理解,并且不要忘记分号。

你需要牢记的第二个格式化规则是使用花括号:{}。方法、类和接口在声明后都需要一对花括号。我们稍后会详细讨论这些内容,但是早点把标准格式化记在脑海中是很重要的。

C#的传统做法是将每个括号放在新的一行,就像下面的方法所示:

public void MethodName() 
{
} 

然而,你可能会看到第一个花括号与声明在同一行的情况。这完全取决于个人偏好:

public void MethodName() {
} 

虽然这不是什么让你抓狂的事情,但重要的是要保持一致。在本书中,我们将坚持使用“纯粹”的 C#代码,它总是将每个括号放在新的一行,而与 Unity 和游戏开发有关的 C#示例通常会遵循第二个例子。

良好、一致的格式化风格在编程初学者中至关重要,但能够看到你的工作成果也同样重要。在下一节中,我们将讨论如何将变量和信息直接打印到 Unity 控制台。

调试你的代码

当我们通过实际示例进行工作时,我们需要一种方法将信息和反馈打印到 Unity 编辑器中的控制台窗口。这个程序术语称为调试,C#和 Unity 都提供了辅助方法,使开发人员更容易进行这个过程。你已经从上一章调试了你的代码,但我们并没有详细讨论它是如何工作的。让我们来解决这个问题。

每当我要求你调试或打印出某些东西时,使用以下方法之一:

  • 对于简单的文本或单个变量,使用标准的Debug.Log()方法。文本需要放在一对括号内,变量可以直接使用,不需要添加其他字符;例如:
Debug.Log("Text goes here.");
Debug.Log(CurrentAge); 

这将在控制台面板中产生以下结果:

图 3.1:观察 Debug.Log 输出

  • 对于更复杂的调试,使用Debug.LogFormat()。这将允许您在打印的文本中使用占位符来放置变量。这些占位符用一对花括号标记,每个花括号包含一个索引。索引是一个常规数字,从 0 开始,依次递增 1。在下面的示例中,{0}占位符被CurrentAge的值替换,{1}FirstName替换,依此类推:
Debug.LogFormat("Text goes here, add {0} and {1} as variable
   placeholders", CurrentAge, FirstName); 

这将在控制台面板中产生以下结果:

图 3.2:观察 Debug.LogFormat 输出

您可能已经注意到我们在调试技术中使用了点符号,没错!Debug 是我们使用的类,Log()LogFormat()是我们可以从该类中使用的不同方法。本章末尾将详细介绍这一点。

有了调试的能力,我们可以安全地继续深入了解变量声明的方式,以及语法可以如何发挥作用。

理解变量

在上一章中,我们看到了变量的写法,并简要介绍了它们提供的高级功能。然而,我们仍然缺少使所有这些成为可能的语法。

声明变量

变量不会只是出现在 C#脚本的顶部;它们必须根据特定的规则和要求进行声明。在最基本的层面上,变量声明需要满足以下要求:

  • 需要指定变量将存储的数据类型

  • 变量必须有一个唯一的名称

  • 如果有一个赋值,它必须与指定的类型匹配

  • 变量声明需要以分号结束

遵守这些规则的结果是以下语法:

dataType UniqueName = value; 

变量需要唯一的名称,以避免与 C#已经使用的关键字发生冲突。您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/index找到受保护关键字的完整列表。

这很简单,整洁,高效。然而,如果只有一种方式创建如此普遍的变量,那么编程语言在长期内将毫无用处。复杂的应用程序和游戏有不同的用例和场景,所有这些都有独特的 C#语法。

类型和值的声明

创建变量最常见的情况是在声明时已经有了所有必需的信息。例如,如果我们知道玩家的年龄,存储它就像这样简单:

int CurrentAge = 32; 

在这里,所有基本要求都得到了满足:

  • 指定了数据类型,即int(整数的缩写)

  • 使用了一个唯一的名称,即CurrentAge

  • 32是一个整数,与指定的数据类型匹配

  • 该语句以分号结束

然而,有时候你会想要声明一个变量,但并不知道它的值。我们将在接下来的部分讨论这个话题。

仅类型声明

考虑另一种情况——你知道你想要一个变量存储的数据类型和它的名称,但不知道它的值。值将在其他地方计算和赋值,但你仍然需要在脚本的顶部声明变量。这种情况非常适合仅类型声明:

int CurrentAge; 

只有类型(int)和唯一名称(CurrentAge)被定义,但语句仍然有效,因为我们遵循了规则。没有分配的值,将根据变量的类型分配默认值。在这种情况下,CurrentAge将被设置为0,这与int类型匹配。一旦变量的实际值变得可用,就可以通过引用变量名并为其分配一个值来轻松地在单独的语句中设置它:

CurrentAge = 32; 

您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/default-values找到所有 C#类型及其默认值的完整列表。

此时,您可能会问为什么到目前为止,我们的变量还没有包括public关键字,即访问修饰符,这是我们在早期脚本示例中看到的。答案是我们没有必要的基础来清楚地谈论它们。现在我们有了这个基础,是时候详细讨论它们了。

使用访问修饰符

现在基本语法不再是一个谜,让我们深入了解变量语句的细节。由于我们从左到右阅读代码,因此从传统上来说,从关键字开始进行变量深入研究是有意义的。

快速回顾一下我们在前一章中在LearningCurve中使用的变量,您会发现它们在语句的开头有一个额外的关键字:public。这就是变量的访问修饰符。将其视为安全设置,确定谁和什么可以访问变量的信息。

任何没有标记为public的变量都默认为private,并且不会显示在 Unity Inspector 面板中。

如果包括一个修饰符,我们在本章开头组合的更新语法配方将如下所示:

accessModifier dataType UniqueName = value; 

在声明变量时,明确的访问修饰符并不是必需的,但作为新程序员,养成这样的习惯是很好的。这个额外的词在代码的可读性和专业性方面有很大帮助。

C#中有四种主要的访问修饰符,但作为初学者,您最常使用的两种是以下两种:

  • Public:对任何脚本都是可用的,没有限制。

  • Private:仅在创建它们的类中可用(称为包含类)。任何没有访问修饰符的变量默认为私有。

两个高级修饰符具有以下特点:

  • Protected:可从包含类或从中派生的类型访问

  • Internal:仅在当前程序集中可用

每个修饰符都有特定的用例,但在我们进入高级章节之前,不要担心protectedinternal

两种组合修饰符也存在,但在本书中我们不会使用它们。您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/access-modifiers找到更多关于它们的信息。

让我们尝试一些自己的访问修饰符!就像现实生活中的信息一样,有些数据需要受到保护或与特定人分享。如果变量在Inspector窗口中不需要更改或从其他脚本中访问,那么它就是私有访问修饰符的一个很好的选择。

执行以下步骤来更新LearningCurve

  1. CurrentAge前面的访问修饰符从public更改为private并保存文件。

  2. 返回 Unity,选择主摄像机,并查看LearningCurve部分中的更改!

图 3.3:附加到主摄像机的 LearningCurve 脚本组件

由于CurrentAge现在是私有的,它不再在检视器窗口中可见,只能在LearningCurve脚本中的代码中访问。如果我们点击播放,脚本仍然会像以前一样正常工作。

这是我们进入变量的旅程的一个良好开端,但我们仍然需要了解它们可以存储什么类型的数据。这就是数据类型的作用,我们将在下一节中进行讨论。

使用类型

将特定类型分配给变量是一个重要的选择,它会影响变量在整个生命周期中的每次交互。由于 C#是所谓的强类型类型安全语言,每个变量都必须有一个数据类型,没有例外。这意味着在执行特定类型的操作时有特定的规则,并且在将给定变量类型转换为另一个类型时有规定。

常见的内置类型

C#中的所有数据类型都从一个共同的祖先System.Object(在编程术语中称为派生)派生下来。这个层次结构称为公共类型系统CTS),意味着不同类型有很多共享功能。下表列出了一些最常见的数据类型选项以及它们存储的值:

图 3.4:变量的常见数据类型

除了指定变量可以存储的值的类型之外,类型还包含有关自身的其他信息,包括以下内容:

  • 所需的存储空间

  • 最小和最大值

  • 允许的操作

  • 内存中的位置

  • 可访问的方法

  • 基本(派生)类型

如果这看起来令人不知所措,请深呼吸。使用 C#提供的所有类型是使用文档而不是记忆的完美示例。很快,即使是最复杂的自定义类型的使用也会变得轻而易举。

您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/types/index找到所有 C#内置类型及其规格的完整列表。

在类型列表成为难点之前,最好先尝试它们。毕竟,学习新东西的最佳方式是使用它,打破它,然后学会修复它。

打开LearningCurve并根据前面图表中常见内置类型部分的每种类型添加一个新变量。您使用的名称和值由您决定;只需确保它们标记为公共,以便我们可以在检视器窗口中看到它们。如果需要灵感,可以看看我的代码:

public class LearningCurve : MonoBehaviour
{
    private int CurrentAge = 30;
    public int AddedAge = 1;

**public****float** **Pi =** **3.14f****;**
**public****string** **FirstName =** **"Harrison"****;**
**public****bool** **IsAuthor =** **true****;**

    // Start is called before the first frame update
    void Start()
    {
        ComputeAge(); 
    }

    /// <summary>
    /// Time for action - adding comments
    /// Computes a modified age integer
    /// </summary>
    void ComputeAge()
    {
        Debug.Log(CurrentAge + AddedAge);
    }
} 

在处理字符串类型时,实际文本值需要放在一对双引号中,而浮点值需要以小写f结尾,就像FirstNamePi一样。

我们的不同变量类型现在都是可见的。请注意 Unity 显示为复选框的bool变量(选中为 true,未选中为 false)。

图 3.5:带有常见变量类型的 LearningCurve 脚本组件

请记住,您声明为私有的任何变量都不会显示在检视器窗口中。在我们继续进行转换之前,我们需要提及字符串数据类型的一个常见且强大的应用,即创建随意插入变量的字符串。

虽然数字类型的行为与小学数学中的预期相同,但字符串则是另一回事。可以通过以$字符开头直接在文本中插入变量和文字值,这称为字符串插值。您已经在LogFormat()调试中使用了插值字符串;添加$字符可以让您随时使用它们!

让我们在LearningCurve中创建一个简单的插值字符串,以便看到它的效果。在ComputeAge()之后直接在Start()方法中打印插值字符串:

void Start()
{
    ComputeAge();
    **Debug.Log(****$"A string can have variables like** **{FirstName}** **inserted directly!"****);**
} 

由于$字符和花括号,FirstName的值被视为一个值,并在插值字符串中打印出来。如果没有这种特殊格式,字符串将只包括FirstName作为文本,而不是变量值。

图 3.6:控制台显示调试日志输出

还可以使用+运算符创建插值字符串,我们将在介绍运算符部分讨论。

类型转换

我们已经看到变量只能保存其声明类型的值,但会有情况需要组合不同类型的变量。在编程术语中,这些称为转换,有两种主要类型:

  • 隐式转换通常在较小的值适合到另一个变量类型中时自动进行,通常不需要四舍五入。例如,任何整数都可以隐式转换为doublefloat值而无需额外的代码:
int MyInteger = 3;
float MyFloat = MyInteger;

Debug.Log(MyInteger);
Debug.Log(MyFloat); 

控制台窗格中的输出可以在以下截图中看到:

图 3.7:隐式类型转换调试日志输出

  • 显式转换是在转换过程中存在丢失变量信息风险时需要的。例如,如果我们想要将double值转换为int值,我们必须通过在要转换的值之前加上目标类型的括号来显式地进行转换。

  • 这告诉编译器,我们知道数据(或精度)可能会丢失:

int ExplicitConversion = (int)3.14; 

在这个显式转换中,3.14将被四舍五入为3,丢失小数值:

图 3.8:显式类型转换调试日志输出

C#提供了用于显式转换值为常见类型的内置方法。例如,任何类型都可以使用ToString()方法转换为字符串值,而Convert类可以处理更复杂的转换。您可以在docs.microsoft.com/en-us/dotnet/api/system.convert?view=netframework-4.7.2方法部分找到有关这些功能的更多信息。

到目前为止,我们已经了解到类型在交互、操作和转换方面有规则,但是当我们需要存储未知类型的变量时,我们该如何处理呢?这听起来很疯狂,但想想数据下载的情景——你知道信息正在进入你的游戏,但不确定它将采取什么形式。我们将在接下来的部分讨论如何处理这种情况。

推断声明

幸运的是,C#可以从分配的值中推断出变量的类型。例如,var关键字可以让程序知道数据CurrentAge的类型需要根据其值32来确定,这是一个整数:

**var** CurrentAge = 32; 

虽然在某些情况下这很方便,但不要被懒惰的编程习惯所迷惑,使用推断变量声明来处理所有事情。这会给你的代码增加很多猜测,而应该是清晰明了的。

在我们结束关于数据类型和转换的讨论之前,我们确实需要简要涉及创建自定义类型的想法,我们将在下一步中进行。

自定义类型

当我们谈论数据类型时,早期理解数字和单词(称为文字值)不是变量可以存储的唯一种类的值是很重要的。例如,类、结构或枚举可以存储为变量。我们将在第五章使用类、结构和面向对象编程中介绍这些主题,并在第十章重新审视类型、方法和类中更详细地探讨它们。

类型很复杂,唯一熟悉它们的方法是使用它们。然而,这里有一些重要的事情需要记住:

  • 所有变量都需要指定类型(无论是显式还是推断)

  • 变量只能保存其分配类型的值(string值不能分配给int变量)

  • 如果需要将变量分配或与不同类型的变量组合,需要进行转换(隐式或显式)

  • C#编译器可以使用var关键字从其值推断变量的类型,但应该仅在创建时类型未知时使用

我们刚刚在几个部分中塞入了很多细节,但我们还没有完成。我们还需要了解 C#中命名约定的工作方式,以及变量在我们的脚本中的位置。

变量命名

为变量选择名称可能看起来像是在考虑访问修饰符和类型之后的事情,但它不应该是一个简单的选择。在代码中清晰一致的命名约定不仅会使其更易读,而且还会确保团队中的其他开发人员了解您的意图,而无需询问。

在命名变量时的第一个规则是,您给出的名称应该是有意义的;第二个规则是使用帕斯卡命名法。让我们以游戏中常见的一个例子来看,声明一个变量来存储玩家的健康:

public int Health = 100; 

如果发现自己声明变量像这样,你的脑中应该响起警报。谁的健康?它是存储最大值还是最小值?当此值更改时,将受到影响的其他代码是什么?这些都是有意义的变量名称应该很容易回答的问题;你不希望在一周或一个月后被自己的代码搞糊涂。

说到这一点,让我们尝试使用帕斯卡命名法使其更好一些:

public int MaxPlayerHealth = 100; 

记住,帕斯卡命名法将变量名称中的每个单词的首字母大写。

这样好多了。经过一点思考,我们已经更新了变量名称并赋予了意义和上下文。由于在变量名称的长度方面没有技术限制,您可能会发现自己过度并写出过于描述性的名称,这将给您带来问题,就像短的、不描述性的名称一样。

一般规则是,将变量名称描述得尽可能清楚——不多也不少。找到您的风格并坚持下去。

理解变量范围

我们已经深入了解了变量,但还有一个重要的主题需要讨论:范围。类似于访问修饰符,确定外部类可以获取变量信息的方式,变量范围是用来描述给定变量存在的位置及其在其包含类中的访问点的术语。

C#中有三个主要的变量范围级别:

  • 全局范围指的是整个程序(在本例中是游戏)都可以访问的变量。C#不直接支持全局变量,但这个概念在某些情况下是有用的,我们将在第十章“重新审视类型、方法和类”中介绍。

  • 成员范围指的是可以在其包含类中的任何地方访问的变量。

  • 局部范围指的是只能在其创建的特定代码块内部访问的变量。

看一下以下的屏幕截图。如果你不想把它放到LearningCurve中,你不需要;目前它只是用于可视化目的:

图 3.9:LearningCurve 脚本中不同范围的图表

当我们谈论代码块时,我们指的是任何一组花括号内部的区域。这些括号在编程中充当一种视觉层次结构;它们向右缩进得越多,它们在类中嵌套得越深。

让我们来分解一下前面屏幕截图中的类和局部范围变量:

  • CharacterClass在类的顶部声明,这意味着我们可以在LearningCurve的任何地方通过名称引用它。您可能会听到这个概念被称为变量可见性,这是一个很好的思考方式。

  • CharacterHealthStart()方法中声明,这意味着它只能在该代码块内部可见。我们仍然可以毫无问题地从Start()中访问CharacterClass,但如果我们试图从Start()之外的任何地方访问CharacterHealth,我们将会收到一个错误。

  • CharacterNameCharacterHealth处于相同的境地;它们只能从CreateCharacter()方法中访问。这只是为了说明在单个类中可以有多个,甚至是嵌套的本地作用域。

如果你在程序员周围花足够的时间,你会听到关于声明变量的最佳位置的讨论(或争论,取决于一天中的时间)。答案比你想象的要简单:变量应该根据它们的使用情况进行声明。如果你有一个需要在整个类中访问的变量,那就把它作为类变量。如果你只需要一个变量在特定的代码段中,那就声明它为局部变量。

请注意,只有类变量可以在检查器窗口中查看,这对于局部或全局变量来说不是一个选项。

有了命名和作用域的工具,让我们把自己带回到中学数学课堂,重新学习算术运算是如何工作的!

介绍操作符

编程语言中的操作符符号代表类型可以执行的算术赋值关系逻辑功能。算术运算符代表基本的数学函数,而赋值运算符在给定值上执行数学和赋值功能。关系和逻辑运算符评估多个值之间的条件,例如大于小于等于

C#还提供了位和杂项运算符,但这些对你来说只有在你开始创建更复杂的应用程序时才会发挥作用。

在这一点上,只有涵盖算术和赋值运算符才有意义,但当它在下一章变得相关时,我们将介绍关系和逻辑功能。

算术和赋值

您已经熟悉了学校中的算术运算符符号:

  • +表示加法

  • -表示减法

  • /表示除法

  • *表示乘法

C#操作符遵循常规的运算顺序,即首先计算括号,然后是指数,然后是乘法,然后是除法,然后是加法,最后是减法。例如,以下方程将提供不同的结果,即使它们包含相同的值和运算符:

5 + 4 - 3 / 2 * 1 = 8
5 + (4 - 3) / 2 * 1 = 5 

当应用于变量时,操作符的工作方式与应用于文字值时相同。

赋值运算符可以作为任何数学运算的简写替代,方法是将任何算术和等号符号结合在一起。例如,如果我们想要对一个变量进行乘法运算,可以使用以下代码:

int CurrentAge = 32;
CurrentAge = CurrentAge * 2; 

第二种替代方法如下所示:

int CurrentAge = 32;
CurrentAge *= 2; 

等号符号在 C#中也被认为是一个赋值运算符。其他赋值符号遵循与我们之前的乘法示例相同的语法模式:+=-=/=分别用于加和赋值,减和赋值,以及除和赋值。

在操作符方面,字符串是一个特殊情况,因为它们可以使用加号来创建拼接文本,如下所示:

string FullName = "Harrison " + "Ferrone"; 

当登录到控制台面板时,将产生以下结果:

图 3.10:在字符串上使用操作符

这种方法往往会产生笨拙的代码,使得字符串插值成为大多数情况下拼接不同文本的首选方法。

请注意,算术运算符不适用于所有数据类型。例如,*/运算符不适用于字符串值,而这些运算符都不适用于布尔值。在了解了类型有规则来规定它们可以进行的操作和交互之后,让我们在下一节的实践中试一试。

让我们做一个小实验:我们将尝试将我们的stringfloat变量相乘,就像我们之前对数字做的那样:

图 3.11:Visual Studio 不正确的类型操作错误消息

看看 Visual Studio,您会看到我们收到了一个错误消息,告诉我们string类型和float类型不能相乘。这个错误也会显示在 Unity 控制台中,并且不会让项目构建。

图 3.12:控制台显示不兼容数据类型的运算符错误

每当您看到这种类型的错误时,回去检查变量类型是否不兼容。

我们必须清理这个示例,因为编译器现在不允许我们运行游戏。在Debug.Log(FirstName*Pi)行的开头选择一对反斜杠(//),或者将其完全删除。

这就是我们在变量和类型方面需要了解的全部内容。在继续之前,请务必在本章的测验中进行测试!

定义方法

在上一章中,我们简要介绍了方法在我们的程序中扮演的角色;即,它们存储和执行指令,就像变量存储值一样。现在,我们需要理解方法声明的语法以及它们如何在我们的类中驱动行为和动作。

与变量一样,方法声明具有其基本要求,如下所示:

  • 方法将返回的数据类型

  • 一个以大写字母开头的唯一名称

  • 方法名后面跟着一对括号

  • 一对花括号标记方法体(其中存储指令)

将所有这些规则放在一起,我们得到一个简单的方法蓝图:

returnType UniqueName() 
{ 
    method body 
} 

让我们分解LearningCurve中的默认Start()方法作为一个实际示例:

void Start() 
{
} 

在前面的输出中,我们可以看到以下内容:

  • 方法以void关键字开头,如果它不返回任何数据,则用作方法的返回类型。

  • 方法在类中具有唯一的名称。您可以在不同的类中使用相同的名称,但无论如何,您都应该始终使您的名称唯一。

  • 方法在其名称后面有一对括号,用于保存任何潜在的参数。

  • 方法体由一组花括号定义。

一般来说,如果一个方法有一个空的方法体,最好将其从类中删除。您总是希望修剪您的脚本中未使用的代码。

与变量一样,方法也可以具有安全级别。但是,它们也可以有输入参数,我们将在下一节讨论这两个方面!

声明方法

方法也可以有与变量相同的四个访问修饰符,以及输入参数。参数是可以传递到方法中并在其中访问的变量占位符。您可以使用的输入参数数量没有限制,但每个参数都需要用逗号分隔,显示其数据类型,并具有唯一的名称。

将方法参数视为变量占位符,其值可以在方法体内使用。

如果我们应用这些选项,我们的更新后的蓝图将如下所示:

**accessModifier** returnType UniqueName(**parameterType parameterName**) 
{ 
    method body 
} 

如果没有显式的访问修饰符,方法默认为私有。私有方法,就像私有变量一样,不能从其他脚本中调用。

要调用方法(即运行或执行其指令),我们只需使用其名称,后面跟一对括号,带有或不带有参数,并以分号结束:

// Without parameters
UniqueName();
// With parameters
UniqueName(parameterVariable); 

与变量一样,每个方法都有一个指纹,描述其访问级别、返回类型和参数。这称为方法签名。基本上,方法的签名将其标记为编译器的唯一标识,因此 Visual Studio 知道如何处理它。

现在我们了解了方法的结构,让我们创建一个自己的方法。

上一章的方法也是占位符部分让你盲目地将一个名为ComputeAge()的方法复制到LearningCurve中,而你并不知道你在做什么。这一次,让我们有意识地创建一个方法:

  1. 声明一个带有 void 返回类型的public方法,名为GenerateCharacter()
public void GenerateCharacter() 
{
} 
  1. 在新方法中添加一个简单的Debug.Log(),并打印出你最喜欢的游戏或电影中的角色名:
Debug.Log("Character: Spike"); 
  1. Start()方法中调用GenerateCharacter()并点击播放:
void Start()
{
    **GenerateCharacter();**
} 

当游戏启动时,Unity 会自动调用Start(),然后调用我们的GenerateCharacter()方法,并将结果打印到控制台窗口。

如果你已经阅读了足够的文档,你会看到与方法相关的不同术语。在本书的其余部分中,当一个方法被创建或声明时,我会称之为定义一个方法。同样,我会称运行或执行一个方法为调用该方法。

命名的力量对整个编程领域至关重要,所以在继续之前,我们将重新审视方法的命名约定。

命名约定

像变量一样,方法需要独特而有意义的名称,以在代码中加以区分。方法驱动操作,因此最好的做法是以此为考量来命名它们。例如,GenerateCharacter()听起来像一个命令,在脚本中调用时读起来很好,而Summary()这样的名称很平淡,不太清楚方法将实现什么。像变量一样,方法名称采用帕斯卡命名法。

方法作为逻辑的绕道

我们已经看到代码行按照它们编写的顺序依次执行,但是引入方法会引入一种独特的情况。调用一个方法告诉程序进入方法指令,逐个运行它们,然后在调用方法的地方恢复顺序执行。

看一下以下的截图,看看你能否弄清楚调试日志将以什么顺序打印到控制台:

图 3.13:考虑调试日志的顺序

以下是发生的步骤:

  1. 选择一个角色首先打印出来,因为它是代码的第一行。

  2. 当调用GenerateCharacter()时,程序跳转到第 23 行,打印出Character: Spike,然后在第 17 行恢复执行。

  3. A fine choiceGenerateCharacter()中的所有行都运行完毕后打印出来。

图 3.14:控制台显示角色构建代码的输出

如果我们不能给方法添加参数值,那么方法本身就不会比这些简单的示例更有用。

指定参数

你的方法可能并不总是像GenerateCharacter()这样简单。为了传入额外的信息,我们需要定义方法可以接受和处理的参数。每个方法参数都是一条指令,需要具备两个要素:

  • 一个明确的类型

  • 一个独特的名称

这听起来很熟悉吗?方法参数本质上是简化的变量声明,具有相同的功能。每个参数都像一个局部变量,只能在其特定方法内部访问。

你可以拥有任意数量的参数。无论你是编写自定义方法还是使用内置方法,定义的参数是方法执行其指定任务所需的。

如果参数是方法可以接受的值类型的蓝图,那么参数就是这些值本身。为了进一步解释这一点,考虑以下内容:

  • 传入方法的参数需要与参数类型匹配,就像变量类型和它的值一样

  • 参数可以是字面值(例如数字 2)或在类中其他地方声明的变量

参数名和参数名不需要匹配就能编译。

现在,让我们继续并添加一些方法参数,使GenerateCharacter()变得更有趣一些。

让我们更新GenerateCharacter(),使其可以接受两个参数:

  1. 添加两个方法参数:一个是string类型的角色名称,另一个是int类型的角色等级:
public void GenerateCharacter(string name, int level) 
  1. 更新Debug.Log(),使其使用这些新参数:
Debug.LogFormat("Character: {0} - Level: {1}", name, level); 
  1. Start()中更新GenerateCharacter()方法调用,使用你的参数,可以是文字值或已声明的变量:
int CharacterLevel = 32;
GenerateCharacter("Spike", CharacterLevel); 

你的代码应该如下所示:

图 3.15:更新 GenerateCharacter()方法

在这里,我们定义了两个参数,name(字符串)和level(整数),并在GenerateCharacter()方法中使用它们,就像本地变量一样。当我们在Start()中调用方法时,我们为每个参数添加了相应类型的参数值。在前面的截图中,你可以看到使用引号中的文字字符串值产生了与使用characterLevel相同的结果。

图 3.16:控制台显示方法参数输出

在方法中传递值并再次传递出来,你可能会想知道我们如何做到这一点。这将引出我们下一节关于返回值的内容。

指定返回值

除了接受参数,方法可以返回任何 C#类型的值。我们之前的所有示例都使用了void类型,它不返回任何东西,但能够编写指令并传回计算结果是方法的亮点所在。

根据我们的蓝图,方法返回类型在访问修饰符之后指定。除了类型之外,方法需要包含return关键字,后面跟着返回值。返回值可以是变量、文字值,甚至是表达式,只要它与声明的返回类型匹配即可。

具有返回类型为void的方法仍然可以使用没有值或表达式分配的 return 关键字。一旦到达带有 return 关键字的行,方法将停止执行。这在你想要避免某些行为或防止程序崩溃的情况下非常有用。

接下来,给GenerateCharacter()添加一个返回类型,并学习如何将其捕获到一个变量中。让我们更新GenerateCharacter()方法,使其返回一个整数:

  1. 将方法声明中的返回类型从void更改为int,并使用return关键字将返回值设置为level += 5
public **int** GenerateCharacter(string name, int level)
{
        Debug.LogFormat("Character: {0} - Level: {1}", name, level);

        **return** **level +=** **5****;**
} 

GenerateCharacter()现在将返回一个整数。这是通过将5添加到 level 参数来计算的。我们还没有指定如何或是否要使用这个返回值,这意味着现在脚本不会做任何新的事情。

现在,问题是:我们如何捕获和使用新添加的返回值?嗯,我们将在下一节中讨论这个话题。

使用返回值

在使用返回值时,有两种可用的方法:

  • 创建一个本地变量来捕获(存储)返回的值。

  • 使用调用方法本身作为返回值的替代,就像使用变量一样。调用方法是实际触发指令的代码行,在我们的示例中,就是GenerateCharacter("Spike", CharacterLevel)。如果需要,甚至可以将调用方法作为参数传递给另一个方法。

大多数编程圈子更喜欢第一种选项,因为它更易读。随意使用方法调用作为变量可能会很快变得混乱,特别是当我们将它们用作其他方法的参数时。

让我们在代码中尝试一下,捕获和调试GenerateCharacter()返回的返回值。

我们将使用两种捕获和使用返回变量的方法来进行简单的调试日志:

  1. Start方法中创建一个新的本地变量,类型为int,名为NextSkillLevel,并将其分配给我们已经放置的GenerateCharacter()方法调用的返回值:
int NextSkillLevel = GenerateCharacter("Spike", CharacterLevel); 
  1. 添加两个调试日志,第一个打印出NextSkillLevel,第二个打印出一个新的调用方法,参数值由你选择:
Debug.Log(NextSkillLevel);
Debug.Log(GenerateCharacter("Faye", CharacterLevel)); 
  1. 用两个斜杠(//)注释掉GenerateCharacter()内部的调试日志,以减少控制台输出的混乱。你的代码应该如下所示:
//  Start is called before the first frame update
void Start()
{
    int CharacterLevel = 32;
    int NextSkillLevel = GenerateCharacter("Spike", CharacterLevel);
    Debug.Log(NextSkillLevel);
    Debug.Log(GenerateCharacter("Faye", CharacterLevel));
}
public int GenerateCharacter(string name, int level)
{
    // Debug.LogFormat("Character: {0} – Level: {1}", name, level);
    return level += 5;
} 
  1. 保存文件并在 Unity 中点击播放。对于编译器来说,NextSkillLevel变量和GenerateCharacter()方法调用者代表相同的信息,即一个整数,这就是为什么两个日志都显示数字37的原因:

图 3.17:角色生成代码的控制台输出

这是很多内容,特别是考虑到带有参数和返回值的方法的指数可能性。然而,我们将在这里放慢节奏一分钟,考虑一下 Unity 最常用的一些方法,给自己喘口气。

但首先,看看你是否能在下一个英雄的试炼中应对一个挑战!

英雄的试炼-方法作为参数

如果你感到勇敢,为什么不尝试创建一个接受int参数并简单打印到控制台的新方法?不需要返回类型。当你做到这一点时,在Start中调用该方法,将GenerateCharacter方法调用作为其参数传入,并查看输出。

解剖常见的 Unity 方法

我们现在已经到了一个可以真实讨论任何新的 Unity C#脚本都带有的最常见默认方法的地步:Start()Update()。与我们自己定义的方法不同,属于MonoBehaviour类的方法根据其各自的规则由 Unity 引擎自动调用。在大多数情况下,至少需要在脚本中有一个MonoBehaviour方法来启动你的代码。

你可以在docs.unity3d.com/ScriptReference/MonoBehaviour.html找到所有可用的 MonoBehaviour 方法及其描述的完整列表。你还可以在docs.unity3d.com/Manual/ExecutionOrder.html找到每个方法执行的顺序。

就像故事一样,从头开始总是一个好主意。因此,自然而然地,我们应该看一下每个 Unity 脚本的第一个默认方法——Start()

开始方法

Unity 在脚本第一次启用时的第一帧调用Start()方法。由于MonoBehaviour脚本几乎总是附加到场景中的GameObjects上,它们的附加脚本在加载时同时启用。在我们的项目中,LearningCurve附加到Main Camera GameObject上,这意味着它的Start()方法在主摄像机加载到场景时运行。Start()主要用于设置变量或执行需要在Update()第一次运行之前发生的逻辑。

到目前为止,我们所做的示例都使用了Start(),即使它们并没有执行设置操作,这并不是通常的使用方式。然而,它只会执行一次,使其成为在控制台上显示一次性信息的绝佳工具。

除了Start(),默认情况下你会遇到另一个重要的 Unity 方法:Update()。在我们完成本章之前,让我们在下一节中熟悉一下它的工作原理。

更新方法

如果你花足够的时间查看 Unity 脚本参考中的示例代码(docs.unity3d.com/ScriptReference/),你会注意到绝大多数代码都是使用Update()方法执行的。当你的游戏运行时,场景窗口会以每秒多次的频率显示,这被称为帧率或每秒帧数FPS)。

每帧显示后,Unity 都会调用Update()方法,使其成为游戏中执行次数最多的方法之一。这使其非常适合检测鼠标和键盘输入或运行游戏逻辑。

如果你对你的机器的 FPS 评级感到好奇,那就在 Unity 中点击Stats选项卡,并在Game视图的右上角点击播放:

图 3.18:Unity 编辑器显示带有图形 FPS 计数的 Stats 面板

在你最初的 C#脚本中,你将会大量使用Start()Update()方法,所以要熟悉它们。话虽如此,你已经掌握了本章提供的 C#编程中最基本的构建模块。

摘要

这一章从编程的基本理论和构建模块迅速下降到了真实代码和 C#语法的层面。我们看到了代码格式的好坏形式,学会了如何在 Unity 控制台中调试信息,并创建了我们的第一个变量。

C#类型、访问修饰符和变量作用域紧随其后,当我们在检视器窗口中使用成员变量并开始涉足方法和操作领域时。

方法帮助我们理解代码中的书面指令,但更重要的是,如何正确地利用它们的力量来实现有用的行为。输入参数、返回类型和方法签名都是重要的主题,但它们真正提供的是执行新类型行为的潜力。

你现在掌握了编程的两个基本构建模块;从现在开始,你所做的几乎都是这两个概念的延伸或应用。

在下一章中,我们将看一下 C#类型的一个特殊子集,称为集合,它可以存储相关数据组,并学习如何编写基于决策的代码。

小测验-变量和方法

  1. 在 C#中正确书写变量名的方法是什么?

  2. 如何使一个变量出现在 Unity 的检视器窗口中?

  3. C#中有哪四种访问修饰符?

  4. 在类型之间何时需要显式转换?

  5. 定义方法的最低要求是什么?

  6. 方法名后面的括号的目的是什么?

  7. 方法定义中void的返回类型意味着什么?

  8. Unity 中Update()方法被调用的频率是多少?

加入我们的 Discord!

与其他用户一起阅读本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读。提问,为其他读者提供解决方案,通过Ask Me Anything sessions与作者交流等等。

立即加入!

packt.link/csharpunity2021

第四章:控制流和集合类型

计算机的一个核心职责是在满足预定条件时控制发生的事情。当你点击一个文件夹时,你期望它会打开;当你在键盘上输入时,你期望文本会反映你的击键。为应用程序或游戏编写代码也是一样的——它们在一种状态下需要以某种方式行为,而在条件改变时则需要另一种方式。在编程术语中,这被称为控制流,这很合适,因为它控制了代码在不同情况下的执行流程。

除了使用控制语句,我们还将亲自了解集合数据类型。集合是一类允许在单个变量中存储多个值和值组合的类型。我们将把本章分解为以下主题:

  • 选择语句

  • 使用数组、字典和列表集合

  • 使用forforeachwhile循环的迭代语句

  • 修复无限循环

选择语句

最复杂的编程问题通常可以归结为一系列简单选择,游戏或程序会评估并执行。由于 Visual Studio 和 Unity 不能自己做出这些选择,编写这些决策就取决于我们。

if-elseswitch选择语句允许您根据一个或多个条件指定分支路径,以及在每种情况下要执行的操作。传统上,这些条件包括以下内容:

  • 检测用户输入

  • 评估表达式和布尔逻辑

  • 比较变量或文字值

你将从最简单的条件语句if-else开始,在下一节中。

if-else 语句

if-else语句是代码中做出决策的最常见方式。当剥离所有语法时,基本思想是,“如果我的条件满足,执行这一段代码;如果不满足,执行另一段代码”。把这些语句想象成门,或者说是门,条件就是它们的钥匙。要通过,钥匙必须有效。否则,入口将被拒绝,代码将被发送到下一个可能的门。让我们来看看声明这些门的语法。

有效的if-else语句需要以下内容:

  • 在行首的if关键字

  • 一对括号来保存条件

  • 花括号内的语句体

它看起来像这样:

if(condition is true)
{
    Execute code of code 
} 

可选地,可以添加一个else语句来存储当if语句条件失败时要采取的操作。else语句也适用相同的规则:

else 
    Execute single line of code
// OR
else 
{
    Execute multiple lines
    of code
} 

以蓝图形式,语法几乎读起来像一句话,这就是为什么这是推荐的方法:

if(condition is true)
{
    Execute this code
    block
}
else 
{
    Execute this code 
    block
} 

由于这些是逻辑思维的很好入门,至少在编程中,我们将更详细地解释三种不同的if-else变体:

  1. 单个if语句可以独立存在,如果不关心条件不满足时会发生什么。在下面的例子中,如果hasDungeonKey设置为true,那么会打印出一个调试日志;如果设置为false,则不会执行任何代码:
public class LearningCurve: MonoBehaviour 
{
    public bool hasDungeonKey = true;
    Void Start() 
    {
        if(hasDungeonKey) 
        {
            Debug.Log("You possess the sacred key – enter.");
        }
    }
} 

当提到条件被满足时,我的意思是它评估为 true,这通常被称为通过条件。

  1. 在需要无论条件是否为真都需要采取行动的情况下,可以添加一个else语句。如果hasDungeonKeyfalseif语句将失败,代码执行将跳转到else语句:
public class LearningCurve: MonoBehaviour 
{
    public bool hasDungeonKey = true;
    void Start() 
    {
        if(hasDungeonKey) 
        {
            Debug.Log("You possess the sacred key – enter.");
        } 
        else 
        {
            Debug.Log("You have not proved yourself yet.");
        }
    }
} 
  1. 对于需要有两个以上可能结果的情况,可以添加一个else-if语句,其中包括括号、条件和花括号。这最好通过示例来展示,而不是解释,我们将在下一节中做。

请记住,if语句可以单独使用,但其他语句不能单独存在。您还可以使用基本的数学运算符创建更复杂的条件,例如>(大于),<(小于),>=(大于或等于),<=(小于或等于)和==(等于)。例如,条件(2>3)将返回false并失败,而条件(2<3)将返回true并通过。

现在不要太担心任何其他事情;你很快就会接触到这些东西。

让我们写一个if-else语句来检查角色口袋里的钱数,对三种不同情况返回不同的调试日志——大于50,小于15,以及其他任何情况:

  1. 打开LearningCurve并添加一个新的公共int变量,名为CurrentGold。将其值设置在 1 到 100 之间:
public int CurrentGold = 32; 
  1. 创建一个没有返回值的public方法,名为Thievery,并在Start中调用它。

  2. 在新函数中,添加一个if语句来检查CurrentGold是否大于50,如果是,则向控制台打印一条消息:

if(CurrentGold > 50)
{
    Debug.Log("You're rolling in it!");
} 
  1. 添加一个else-if语句来检查CurrentGold是否小于15,并添加一个不同的调试日志:
else if (CurrentGold < 15)
{
    Debug.Log("Not much there to steal...");
} 
  1. 添加一个没有条件的else语句和一个最终的默认日志:
else
{
    Debug.Log("Looks like your purse is in the sweet spot.");
} 
  1. 保存文件,检查您的方法是否与下面的代码匹配,并点击播放:
public void Thievery()
{
    if(CurrentGold > 50)
    {
        Debug.Log("You're rolling in it!");
    } else if (CurrentGold < 15)
    {
        Debug.Log("Not much there to steal...");
    } else
    {
        Debug.Log("Looks like your purse is in the sweet spot.");
    }
} 

在我的示例中,将CurrentGold设置为32,我们可以将代码序列分解如下:

  1. if语句和调试日志被跳过,因为CurrentGold不大于50

  2. else-if语句和调试日志也被跳过,因为CurrentGold不小于15

  3. 由于 32 既不小于 15 也不大于 50,之前的条件都没有满足。else语句执行并显示第三个调试日志:

图 4.1:控制台截图显示调试输出

在自己尝试一些其他CurrentGold值之后,让我们讨论一下如果我们想测试一个失败的条件会发生什么。

使用 NOT 运算符

用例并不总是需要检查正条件或true条件,这就是NOT运算符发挥作用的地方。用一个感叹号写成的NOT运算符允许ifelse-if语句满足负条件或 false 条件。这意味着以下条件是相同的:

if(variable == false)
// AND
if(!variable) 

正如您已经知道的那样,您可以在if条件中检查布尔值、文字值或表达式。因此,NOT运算符必须是可适应的。

看一下以下两个不同负值hasDungeonKeyweaponTypeif语句中的使用示例:

public class LearningCurve : MonoBehaviour
{
    public bool hasDungeonKey = false;
    public string weaponType = "Arcane Staff";
    void Start()
    {
        if(!hasDungeonKey)
        {
            Debug.Log("You may not enter without the sacred key.");
        }
        if(weaponType != "Longsword")
{
            Debug.Log("You don't appear to have the right type of weapon...");
}
    }
} 

我们可以对每个语句进行评估:

  • 第一条语句可以翻译为,“如果hasDungeonKeyfalseif语句评估为 true 并执行其代码块。”

如果你在想一个 false 值怎么能评估为 true,可以这样想:if语句并不是检查值是否为 true,而是检查表达式本身是否为 true。hasDungeonKey可能被设置为 false,但这就是我们要检查的,所以在if条件的上下文中是 true。

  • 第二条语句可以翻译为,“如果weaponType的字符串值不等于 Longsword,则执行此代码块。”

您可以在以下截图中看到调试结果:

图 4.2:控制台截图显示 NOT 运算符的输出

但是,如果你还是感到困惑,可以将我们在本节中看到的代码复制到LearningCurve中,并尝试改变变量的值,直到弄明白为止。

到目前为止,我们的分支条件相当简单,但 C#也允许条件语句嵌套在彼此内部,以处理更复杂的情况。

嵌套语句

if-else 语句最有价值的功能之一是它们可以嵌套在彼此内部,从而在代码中创建复杂的逻辑路线。在编程中,我们称它们为决策树。就像真正的走廊一样,后面可能有门,从而创造出一系列可能性的迷宫:

public class LearningCurve : MonoBehaviour 
{
    public bool weaponEquipped = true;
    public string weaponType = "Longsword";
    void Start()
    {
        if(weaponEquipped)
        {
            if(weaponType == "Longsword")
            {
                Debug.Log("For the Queen!");
            }
        }
        else 
        {
            Debug.Log("Fists aren't going to work against armor...");
        }
    }
} 

让我们分解前面的例子:

  • 首先,一个 if 语句检查我们是否装备了武器。此时,代码只关心它是否为 true,而不关心它是什么类型的武器。

  • 第二个 if 语句检查 weaponType 并打印出相关的调试日志。

  • 如果第一个 if 语句评估为 false,代码将跳转到 else 语句及其调试日志。如果第二个 if 语句评估为 false,则不会打印任何内容,因为没有 else 语句。

处理逻辑结果的责任完全在程序员身上。你需要确定代码可能走的分支或结果。

到目前为止,你学到的东西将让你轻松应对简单的用例。然而,你很快会发现自己需要更复杂的语句,这就是评估多个条件的地方。

评估多个条件

除了嵌套语句,还可以将多个条件检查组合成单个 ifelse-if 语句,使用 AND OR 逻辑运算符:

  • AND 用两个和字符 && 写成。使用 AND 运算符的任何条件都意味着所有条件都需要为 if 语句评估为真才能执行。

  • OR 用两个竖线字符 || 写成。使用 OR 运算符的 if 语句将在其条件中的一个或多个为真时执行。

  • 条件总是从左到右进行评估。

在下面的示例中,if 语句已更新为检查 weaponEquippedweaponType,两者都需要为真才能执行代码块:

if(weaponEquipped && weaponType == "Longsword")
{
    Debug.Log("For the Queen!");
} 

AND OR 运算符可以组合在一起以任意顺序检查多个条件。你可以组合的运算符数量也没有限制。只是当一起使用它们时要小心,不要创建永远不会执行的逻辑条件。

现在是时候将到目前为止学到的关于 if 语句的一切付诸实践了。所以,如果需要的话,请复习本节,然后继续下一节。

让我们通过一个小宝箱实验来巩固这个主题:

  1. LearningCurve 的顶部声明三个变量:PureOfHeart 是一个 bool,应该为 trueHasSecretIncantation 也是一个 bool,应该为 falseRareItem 是一个字符串,其值由你决定:
public bool PureOfHeart = true;
public bool HasSecretIncantation = false;
public string RareItem = "Relic Stone"; 
  1. 创建一个没有返回值的 public 方法,名为 OpenTreasureChamber,并从 Start() 中调用它。

  2. OpenTreasureChamber 中,声明一个 if-else 语句来检查 PureOfHeart 是否为 true 并且 RareItem 是否与你分配给它的字符串值匹配:

if(PureOfHeart && RareItem == "Relic Stone")
{
} 
  1. 在第一个内部创建一个嵌套的 if-else 语句,检查 HasSecretIncantation 是否为 false
if(!HasSecretIncantation)
{
    Debug.Log("You have the spirit, but not the knowledge.");
} 
  1. 为每个 if-else 情况添加调试日志。

  2. 保存,检查你的代码是否与下面的代码匹配,然后点击播放:

public class LearningCurve : MonoBehaviour
{
    public bool PureOfHeart = true;
    public bool HasSecretIncantation  = false;
    public string RareItem = "Relic Stone";
    // Use this for initialization
    void Start()
    {
        OpenTreasureChamber();
    }
    public void OpenTreasureChamber()
    {
        if(PureOfHeart && RareItem == "Relic Stone")
        {
            if(!HasSecretIncantation)
            {
                Debug.Log("You have the spirit, but not the knowledge.");
            }
            else
            {
                Debug.Log("The treasure is yours, worthy hero!");
            }
        }
        else
        {
            Debug.Log("Come back when you have what it takes.");
        }
    }
} 

如果你将变量值与前面的截图匹配,嵌套的 if 语句调试日志将被打印出来。这意味着我们的代码通过了检查两个条件的第一个 if 语句,但未通过第三个条件:

图 4.3:控制台中的调试输出截图

现在,你可以停在这里,甚至为你所有的条件需求使用更大的 if-else 语句,但从长远来看这并不高效。良好的编程是关于使用正确的工具来完成正确的工作,这就是 switch 语句的用武之地。

switch 语句

if-else 语句是编写决策逻辑的好方法。然而,当你有三四个以上的分支动作时,它们就不可行了。在你意识到之前,你的代码可能会变得像一个难以理解的纠结,更新起来也会很头疼。

switch语句接受表达式,并让我们为每种可能的结果编写操作,但格式比if-else更简洁。

switch语句需要以下元素:

  • switch关键字后面跟着一对括号,括号中是条件

  • 一对大括号

  • 每个可能路径的case语句以冒号结尾:单行代码或方法,后跟break关键字和分号

  • 以冒号结尾的默认case语句:单行代码或方法,后跟break关键字和分号

以蓝图形式,它看起来像这样:

switch(matchExpression)
{
    **case** matchValue1:
        Executing code block
        **break****;**
    **case** matchValue2:
        Executing code block
        **break****;**
    **default****:**
        Executing code block
        **break****;**
} 

在前面的蓝图中,突出显示的关键字是重要的部分。当定义一个case语句时,在其冒号和break关键字之间的任何内容都像if-else语句的代码块一样。break关键字只是告诉程序在选择的case触发后完全退出switch语句。现在,让我们讨论语句如何确定执行哪个case,这被称为模式匹配。

模式匹配

switch语句中,模式匹配指的是如何将匹配表达式与多个case语句进行验证。匹配表达式可以是任何非空或非空的类型;所有case语句的值都需要与匹配表达式的类型匹配。

例如,如果我们有一个switch语句,正在评估一个整数变量,那么每个case语句都需要指定一个整数值来检查。

具有与表达式匹配的值的case语句将被执行。如果没有匹配的case,则默认的case将被执行。让我们自己试一试!

这是很多新的语法和信息,但看到它在实际中运行会有所帮助。让我们为角色可能采取的不同行动创建一个简单的switch语句:

  1. 创建一个新的字符串变量(成员或本地),名为CharacterAction,并将其设置为Attack:
string CharacterAction = "Attack"; 
  1. 创建一个没有返回值的public方法,名为PrintCharacterAction,并在Start内调用它。

  2. 声明一个switch语句,并使用CharacterAction作为匹配表达式:

switch(CharacterAction)
{
} 
  1. HealAttack创建两个case语句,其中包含不同的调试日志。不要忘记在每个末尾包括break关键字:
case "Heal":
    Debug.Log("Potion sent.");
    break;
case "Attack":
    Debug.Log("To arms!");
    break; 
  1. 添加一个带有调试日志和break的默认情况:
default:
    Debug.Log("Shields up.");
    break; 
  1. 保存文件,确保您的代码与下面的截图匹配,然后点击播放:
string CharacterAction = "Attack";
// Start is called before the first frame update
void Start()
{
    PrintCharacterAction();
}
public void PrintCharacterAction()
{
    switch(CharacterAction)
    {
        case "Heal":
            Debug.Log("Potion sent.");
            break;
        case "Attack":
            Debug.Log("To arms!");
            break;
        default:
            Debug.Log("Shields up.");
            break;
    }
} 

由于CharacterAction设置为Attackswitch语句执行第二个case并打印其调试日志:

图 4.4:控制台中switch语句输出的截图

CharacterAction更改为Heal或未定义的动作,以查看第一个和默认情况的执行情况。

有时您需要几个,但不是所有的switch情况都执行相同的操作。这些被称为贯穿案例,是我们下一节的主题。

贯穿案例

switch语句可以为多个情况执行相同的操作,类似于我们在单个if语句中指定多个条件。这个术语叫做贯穿,有时也叫贯穿案例。贯穿案例允许您为多个情况定义一组操作。如果一个case块为空或者有没有break关键字的代码,它将贯穿到直接下面的case。这有助于保持switch代码的清晰和高效,避免重复的case块。

case可以以任何顺序编写,因此创建贯穿案例大大增加了代码的可读性和效率。

让我们模拟一个桌面游戏场景,使用switch语句和贯穿案例,其中骰子的点数决定了特定动作的结果:

  1. 创建一个int变量,名为DiceRoll,并将其赋值为7
int DiceRoll = 7; 
  1. 创建一个没有返回值的public方法,名为RollDice,并在Start内调用它。

  2. 添加一个switch语句,使用DiceRoll作为匹配表达式:

switch(DiceRoll)
{
} 
  1. 为可能的骰子点数71520添加三种情况,并在最后添加一个默认的case语句。

  2. 1520的情况应该有它们自己的调试日志和break语句,而情况7应该通过到情况15

case 7:
case 15:
    Debug.Log("Mediocre damage, not bad.");
    break;
case 20:
    Debug.Log("Critical hit, the creature goes down!");
    break;
default:
    Debug.Log("You completely missed and fell on your face.");
    break; 
  1. 保存文件并在 Unity 中运行它。

如果要查看穿透情况的情况,请尝试在情况 7 中添加调试日志,但不使用break关键字。

DiceRoll设置为7switch语句将与第一个case匹配,然后通过并执行case 15,因为它缺少代码块和break语句。如果将DiceRoll更改为1520,控制台将显示它们各自的消息,而任何其他值都将触发语句末尾的默认情况:

图 4.5:穿透 switch 语句代码的屏幕截图

switch语句非常强大,甚至可以简化最复杂的决策逻辑。如果您想深入了解 switch 模式匹配,请参考docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/switch

这就是我们目前需要了解的有关条件逻辑的全部内容。因此,在继续学习集合之前,请复习本节内容,然后在进行下一步之前进行以下测验!

快速测验 1 - if,and,or but

通过以下问题测试您的知识:

  1. 用于评估if语句的值是什么?

  2. 哪个运算符可以将真条件变为假,假条件变为真?

  3. 如果if语句的代码需要两个条件都为真才能执行,您会使用什么逻辑运算符来连接这些条件?

  4. 如果if语句的代码只需要两个条件中的一个为真才能执行,您会使用什么逻辑运算符来连接这两个条件?

完成后,您就可以进入集合数据类型的世界了。这些类型将为您的游戏和 C#程序打开全新的编程功能子集!

一览集合

到目前为止,我们只需要变量来存储单个值,但有许多情况需要一组值。C#中的集合类型包括数组、字典和列表,每种类型都有其优势和劣势,我们将在接下来的部分讨论。

数组

数组是 C#提供的最基本的集合。将它们视为一组值的容器,在编程术语中称为元素,每个元素都可以单独访问或修改:

  • 数组可以存储任何类型的值;所有元素都需要是相同的类型。

  • 数组的长度或元素数量在创建时确定,之后不能修改。

  • 如果在创建数组时未分配初始值,则每个元素将被赋予默认值。存储数字类型的数组默认为零,而任何其他类型都设置为 null 或 nothing。

数组是 C#中最不灵活的集合类型。这主要是因为元素在创建后无法添加或删除。然而,当存储不太可能改变的信息时,它们特别有用。这种缺乏灵活性使它们与其他集合类型相比更快。

声明数组与我们之前使用的其他变量类型类似,但有一些修改:

  • 数组变量需要指定的元素类型、一对方括号和一个唯一的名称。

  • new关键字用于在内存中创建数组,后跟值类型和另一对方括号。保留的内存区域的大小与您打算存储在新数组中的数据的确切大小相同。

  • 数组将存储的元素数量放在第二对方括号中。

在蓝图形式中,它看起来像这样:

elementType[] name = new elementType[numberOfElements]; 

让我们举一个例子,我们需要存储游戏中的前三个最高分:

int[] topPlayerScores = new int[3]; 

topPlayerScores被分解为一个将存储三个整数元素的整数数组。由于我们没有添加任何初始值,topPlayerScores中的三个值都是0。但是,如果更改数组大小,则原始数组的内容将丢失,因此要小心。

您可以在变量声明的末尾将值直接赋给数组,方法是将它们添加到一对花括号中。C#有一种长格式和短格式的做法,但两者都是有效的:

// Longhand initializer
int[] topPlayerScores = new int[] {713, 549, 984};
// Shortcut initializer
int[] topPlayerScores = { 713, 549, 984 }; 

使用简写语法初始化数组非常常见,因此我将在本书的其余部分中使用它。但是,如果您想提醒自己有关细节,可以随时使用显式措辞。

现在声明语法不再是一个谜了,让我们来谈谈数组元素是如何存储和访问的。

索引和下标

每个数组元素都按分配顺序存储,这称为它的索引。数组是从零开始索引的,这意味着元素顺序从零开始而不是从一开始。将元素的索引视为其引用或位置。

topPlayerScores中,第一个整数452位于索引0713位于索引1984位于索引2

图 4.6:数组索引映射到它们的值

使用下标运算符,可以通过其索引找到各个值,下标运算符是一对包含元素索引的方括号。例如,要检索并存储topPlayerScores中的第二个数组元素,我们将使用数组名称,后跟下标括号和索引1

// The value of score is set to 713
int score = topPlayerScores[1]; 

下标运算符也可以用于直接修改数组值,就像任何其他变量一样,甚至可以作为表达式传递:

topPlayerScores[1] = 1001; 

topPlayerScores中的值将是4521001984

范围异常

创建数组时,元素的数量是固定的,无法更改,这意味着我们无法访问不存在的元素。在topPlayerScores示例中,数组长度为 3,因此有效索引的范围是从02。任何3或更高的索引都超出了数组的范围,并将在控制台中生成一个名为IndexOutOfRangeException的错误:

图 4.7:索引超出范围异常的屏幕截图

良好的编程习惯要求我们通过检查我们想要的值是否在数组的索引范围内来避免范围异常,这将在迭代语句部分中介绍。

您可以使用Length属性始终检查数组的长度,即它包含多少项:

topPlayerScores.Length; 

在我们的例子中,topPlayerScores的长度为 4。

数组并不是 C#提供的唯一集合类型。在下一节中,我们将处理列表,它们在编程领域中更加灵活和常见。

列表

列表与数组密切相关,可以在单个变量中收集相同类型的多个值。在添加、删除和更新元素时,它们要处理起来更加容易,但它们的元素并不是按顺序存储的。它们也是可变的,这意味着您可以更改正在存储的项目的长度或数量,而不必覆盖整个变量。这有时可能会导致与数组相比更高的性能成本。

性能成本是指给定操作占用计算机时间和能量的多少。如今,计算机速度很快,但仍然可能因大型游戏或应用程序而过载。

列表类型变量需要满足以下要求:

  • List关键字,其元素类型在左右箭头字符内,以及一个唯一的名称

  • 使用new关键字在内存中初始化列表,使用List关键字和箭头字符之间的元素类型

  • 由分号结束的一对括号

在蓝图形式中,它的读法如下:

List<elementType> name = new List<elementType>(); 

列表长度总是可以修改的,因此在创建时不需要指定它最终将容纳多少元素。

与数组一样,列表可以在变量声明中初始化,方法是在一对花括号中添加元素值:

List<elementType> name = new List<elementType>() { value1, value2 }; 

元素按添加顺序存储(而不是值本身的顺序),从零开始索引,并且可以使用下标运算符进行访问。

让我们开始设置自己的列表,以测试该类提供的基本功能。

让我们通过创建一个虚构角色扮演游戏中的成员列表来进行热身练习:

  1. Start内部创建一个名为QuestPartyMembersstring类型的新List,并用三个角色的名称初始化它:
List<string> QuestPartyMembers = new List<string>()
    {
        "Grim the Barbarian",
        "Merlin the Wise",
        "Sterling the Knight"
    }; 
  1. 添加一个调试日志,使用Count方法打印出列表中的成员数量:
Debug.LogFormat("Party Members: {0}", QuestPartyMembers.Count); 
  1. 保存文件并在 Unity 中播放它。

我们初始化了一个名为QuestPartyMembers的新列表,其中现在包含三个字符串值,并使用List类的Count方法打印出元素的数量。请注意,您对列表使用Count,但对数组使用Length

图 4.8:控制台中列表项输出的屏幕截图

知道列表中有多少元素非常有用;但是,在大多数情况下,这些信息是不够的。我们希望能够根据需要修改我们的列表,接下来我们将讨论这一点。

访问和修改列表

列表元素可以像数组一样使用下标运算符和索引进行访问和修改,只要索引在List类的范围内。但是,List类具有各种方法来扩展其功能,例如添加、插入和删除元素。

继续使用QuestPartyMembers列表,让我们向团队添加一个新成员:

 QuestPartyMembers.Add("Craven the Necromancer"); 

Add()方法将新元素附加到列表末尾,这将使QuestPartyMembers计数为四,并且元素顺序如下:

{ "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight",
    "Craven the Necromancer"}; 

要将元素添加到列表中的特定位置,我们可以将索引和要添加到Insert()方法的值传递:

 QuestPartyMembers.Insert(1, "Tanis the Thief"); 

当元素插入到先前占用的索引时,列表中的所有元素的索引都增加了1。在我们的例子中,"Tanis the Thief"现在位于索引1,这意味着"Merlin the Wise"现在位于索引2而不是1,依此类推:

{ "Grim the Barbarian", "Tanis the Thief", "Merlin the Wise", "Sterling
    the Knight", "Craven the Necromancer"}; 

删除元素同样简单;我们只需要索引或文字值,List类就会完成工作:

// Both of these methods would remove the required element
QuestPartyMembers.RemoveAt(0); 
QuestPartyMembers.Remove("Grim the Barbarian"); 

在我们的编辑结束时,QuestPartyMembers现在包含以下从03的元素:

{ "Tanis the Thief", "Merlin the Wise", "Sterling the Knight", "Craven
    the Necromancer"}; 

List类有许多其他方法,允许进行值检查、查找和排序元素,并处理范围。可以在此处找到完整的方法列表和描述:docs.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1?view=netframework-4.7.2

虽然列表非常适合单个值元素,但有些情况下,您需要存储包含多个值的信息或数据。这就是字典发挥作用的地方。

字典

字典类型通过在每个元素中存储值对而不是单个值,而不是数组和列表。这些元素被称为键值对:键充当其对应值的索引或查找值。与数组和列表不同,字典是无序的。但是,它们可以在创建后以各种配置进行排序和排序。

声明字典几乎与声明列表相同,但有一个额外的细节——需要在箭头符号内指定键和值类型:

Dictionary<keyType, valueType> name = new Dictionary<keyType,
  valueType>(); 

要使用键值对初始化字典,请执行以下操作:

  • 在声明的末尾使用一对花括号。

  • 将每个元素添加到其花括号对中,键和值用逗号分隔。

  • 用逗号分隔元素,最后一个元素的逗号是可选的。

它看起来像这样:

Dictionary<keyType, valueType> name = new Dictionary<keyType,
  valueType>()
{
    {key1, value1},
    {key2, value2}
}; 

在选择键值时需要考虑的一个重要注意事项是,每个键必须是唯一的,且不能更改。如果需要更新键,则需要在变量声明中更改其值,或者在代码中删除整个键值对并添加另一个,我们将在下面看到。

就像数组和列表一样,字典可以在一行上初始化,而不会受到来自 Visual Studio 的问题。然而,像前面的例子中那样在每一行上写出每个键值对,是一个良好的习惯——无论是为了可读性还是为了你的理智。

让我们创建一个字典来存储角色可能携带的物品:

  1. Start方法中声明一个key类型为stringvalue类型为intDictionary,名为ItemInventory

  2. 将其初始化为new Dictionary<string, int>(),并添加三个自己选择的键值对。确保每个元素都在其花括号对中:

Dictionary<string, int> `I`temInventory = new Dictionary<string, int>()
    {
        { "Potion", 5 },
        { "Antidote", 7 },
        { "Aspirin", 1 }
    }; 
  1. 添加一个调试日志以打印出ItemInventory.Count属性,以便我们可以看到物品是如何存储的:
Debug.LogFormat("Items: {0}", `I`temInventory.Count); 
  1. 保存文件并播放。

在这里,创建了一个名为ItemInventory的新字典,并用三个键值对进行了初始化。我们将键指定为字符串,对应的值为整数,并打印出ItemInventory当前持有的元素数量:

图 4.9:控制台中字典计数的截图

与列表一样,我们需要能够做的不仅仅是打印出给定字典中键值对的数量。我们将在下一节中探讨添加、删除和更新这些值。

处理字典对

键值对可以使用下标和类方法从字典中添加、删除和访问。使用下标运算符和元素的键来检索元素的值,在下面的例子中,numberOfPotions将被赋予5的值:

int numberOfPotions = `I`temInventory["Potion"]; 

可以使用相同的方法更新元素的值——与"Potion"相关联的值现在将是10

`I`temInventory["Potion"] = 10; 

可以通过Add方法和下标运算符的两种方式向字典中添加元素。Add方法接受一个键和一个值,并创建一个新的键值元素,只要它们的类型与字典声明相对应:

`I`temInventory.Add("Throwing Knife", 3); 

如果使用下标运算符为字典中不存在的键分配一个值,编译器将自动将其添加为新的键值对。例如,如果我们想要为"Bandage"添加一个新元素,我们可以使用以下代码:

`I`temInventory["Bandage"] = 5; 

这带来了一个关键的问题,关于引用键值对:最好在尝试访问之前确定元素是否存在,以避免错误地添加新的键值对。将ContainsKey方法与if语句配对是一个简单的解决方案,因为ContainsKey根据键是否存在返回一个布尔值。在下面的例子中,我们确保在修改其值之前使用if语句检查"Aspirin"键是否存在:

if(`I`temInventory.ContainsKey("Aspirin"))
{
    `I`temInventory["Aspirin"] = 3;
} 

最后,可以使用Remove()方法从字典中删除一个键值对,该方法接受一个键参数:

`I`temInventory.Remove("Antidote"); 

与列表一样,字典提供了各种方法和功能,使开发更加容易,但我们无法在这里覆盖它们所有。如果你感兴趣,官方文档可以在docs.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=netframework-4.7.2找到。

集合已经安全地放在我们的工具包中,所以现在是时候进行另一个测验,以确保你已经准备好转向下一个重要主题:迭代语句。

快速测验 2——关于集合的一切

  • 数组或列表中的元素是什么?

  • 数组或列表中第一个元素的索引号是多少?

  • 单个数组或列表可以存储不同类型的数据吗?

  • 如何向数组中添加更多元素以为更多数据腾出空间?

由于集合是项目的组或列表,它们需要以有效的方式访问。幸运的是,C#有几个迭代语句,我们将在下一节中讨论。

迭代语句

我们通过下标运算符访问了单个集合元素,以及集合类型的方法,但是当我们需要逐个遍历整个集合元素时该怎么办呢?在编程中,这称为迭代,C#提供了几种语句类型,让我们可以循环遍历(或者如果你想要更严谨一些,可以说迭代)集合元素。迭代语句就像方法一样,它们存储要执行的代码块;与方法不同的是,它们可以根据条件重复执行它们的代码块。

for 循环

for循环在程序在继续之前需要执行一定次数的代码块时最常用。语句本身包含三个表达式,每个表达式在循环执行之前执行特定的功能。由于for循环跟踪当前迭代,因此最适合于数组和列表。

看一下以下循环语句的蓝图:

for (initializer; condition; iterator)
{
    code block;
} 

让我们来分解一下:

  1. for关键字开始语句,后面跟着一对括号。

  2. 括号内是守门人:initializerconditioniterator表达式。

  3. 循环从initializer表达式开始,这是一个本地变量,用于跟踪循环执行的次数——通常设置为 0,因为集合类型是从零开始索引的。

  4. 接下来,将检查condition表达式,如果为真,则继续进行迭代。

  5. iterator表达式用于增加或减少(递增或递减)initializer,这意味着下次循环评估其条件时,initializer将不同。

通过增加和减少 1 来增加和减少一个值分别称为递增和递减(--将一个值减少 1,++将一个值增加 1)。

这听起来很复杂,让我们用我们之前创建的QuestPartyMembers列表来看一个实际的例子:

List<string> QuestPartyMembers = new List<string>()
{ "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight"}; 
for (int i = 0; i < QuestPartyMembers.Count; i++)
{
    Debug.LogFormat("Index: {0} - {1}", i, QuestPartyMembers[i]);
} 

让我们再次通过循环并看看它是如何工作的:

  1. 首先,在for循环中的initializer被设置为一个名为i的本地int变量,初始值为0

  2. 为了确保我们永远不会得到超出范围的异常,for循环确保只有在i小于QuestPartyMembers中元素的数量时才运行另一次:

  • 对于数组,我们使用Length属性来确定它有多少项。

  • 对于列表,我们使用Count属性

  1. 最后,i每次循环运行时都会增加 1,使用++运算符。

  2. for循环内部,我们刚刚使用i打印出了该索引和该索引处的列表元素。

  3. 注意,i与集合元素的索引保持一致,因为两者都从 0 开始!

图 4.10:使用 for 循环打印出列表值的屏幕截图

传统上,字母i通常用作初始化变量名。如果你碰巧有嵌套的for循环,那么使用的变量名应该是字母 j、k、l 等。

让我们在我们现有的集合中尝试一下我们的新迭代语句。

当我们循环遍历QuestPartyMembers时,让我们看看是否能够确定何时迭代某个元素,并为该情况添加一个特殊的调试日志:

  1. QuestPartyMembers列表和for循环移动到名为FindPartyMember的公共函数中,并在Start中调用它。

  2. for循环中的调试日志下面添加一个if语句,以检查当前的questPartyMember列表是否与"Merlin the Wise"匹配:

if(QuestPartyMembers[i] == "Merlin the Wise")
{
    Debug.Log("Glad you're here Merlin!");
} 
  1. 如果是,添加一个你选择的调试日志,检查你的代码是否与下面的屏幕截图匹配,然后点击播放:
// Start is called before the first frame update
void Start()
{
    FindPartyMember();
}
public void FindPartyMember()
{
    List<string> QuestPartyMembers = new List<string>()
    {
        "Grim the Barbarian",
        "Merlin the Wise",
        "Sterling the Knight"
    };
    Debug.LogFormat("Party Members: {0}", QuestPartyMembers.Count);
    for(int i = 0; i < QuestPartyMembers.Count; i++)
    {
        Debug.LogFormat("Index: {0} - {1}", i, QuestPartyMembers[i]);
        if(QuestPartyMembers[i] == "Merlin the Wise")
        {
            Debug.Log("Glad you're here Merlin!");
        }
    }
} 

控制台输出应该几乎相同,只是现在有一个额外的调试日志——当 Merlin 轮到他通过循环时,这个日志只打印了一次。更具体地说,当i在第二次循环时等于1时,if语句触发了,打印出了两个日志而不是一个:

图 4.11:打印列表值和匹配 if 语句的 for 循环的屏幕截图

在适当的情况下,使用标准的for循环可能非常有用,但在编程中很少只有一种方法,这就是foreach语句发挥作用的地方。

foreach 循环

foreach循环获取集合中的每个元素,并将每个元素存储在本地变量中,使其在语句内可访问。本地变量类型必须与集合元素类型匹配才能正常工作。foreach循环可以与数组和列表一起使用,但与字典一起使用尤其有用,因为字典是键值对而不是数字索引。

以蓝图形式,foreach循环看起来像这样:

foreach(elementType localName in collectionVariable)
{
    code block;
} 

让我们继续使用QuestPartyMembers列表示例,并为其每个元素进行点名:

List<string> QuestPartyMembers = new List<string>()
{ "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight"};

foreach(string partyMember in QuestPartyMembers)
{
    Debug.LogFormat("{0} - Here!", partyMember);
} 

我们可以将其分解如下:

  • 元素类型声明为string,与QuestPartyMembers中的值匹配。

  • 创建一个名为partyMember的本地变量,以便在循环重复时保存每个元素。

  • in关键字,后跟我们想要循环遍历的集合,这种情况下是QuestPartyMembers,完成了一切!

图 4.12:打印列表值的 foreach 循环的屏幕截图

这比for循环简单得多。但是,在处理字典时,有一些重要的区别需要提到,即如何处理键值对作为本地变量。

循环遍历键值对

要在本地变量中捕获键值对,我们需要使用名为KeyValuePair的类型,将键和值类型分配为与字典对应类型相匹配。由于KeyValuePair是其类型,它就像任何其他元素类型一样,作为本地变量。

例如,让我们循环遍历我们在字典部分中创建的ItemInventory字典,并调试每个键值对,就像商店物品描述一样:

Dictionary<string, int> `I`temInventory = new Dictionary<string, int>()
{
    { "Potion", 5},
    { "Antidote", 7},
    { "Aspirin", 1}
};

foreach(KeyValuePair<string, int> kvp in `I`temInventory)
{
     Debug.LogFormat("Item: {0} - {1}g", kvp.Key, kvp.Value);
} 

我们指定了一个名为KeyValuePair的本地变量kvp,这是编程中的一种常见命名惯例,就像将for循环初始化器称为i,并将keyvalue类型设置为stringint以匹配ItemInventory

要访问本地kvp变量的键和值,我们分别使用KeyValuePairKeyValue属性。

在这个例子中,键是字符串是整数,我们可以将其打印出来作为项目名称和项目价格:

图 4.13:打印字典键值对的 foreach 循环的屏幕截图

如果你感到特别有冒险精神,可以尝试以下可选挑战,以加深你刚刚学到的知识。

英雄的试炼-寻找实惠的物品

使用前面的脚本,创建一个变量来存储你虚构角色拥有的金币数量,并查看是否可以在foreach循环内添加一个if语句来检查你能负担得起的物品。

提示:使用kvp.Value来比较你的钱包中的价格。

while 循环

while循环类似于if语句,因为它们只要单个表达式或条件为真就会运行。

值比较和布尔变量可以用作while条件,并且可以用NOT运算符进行修改。

while循环语法是这样说的,只要我的条件为真,就无限运行我的代码块

Initializer
while (condition)
{
    code block;
    iterator;
} 

使用while循环时,通常会声明一个初始化变量,就像for循环一样,并在循环代码块的末尾手动增加或减少它。我们这样做是为了避免无限循环,我们将在本章末讨论这个问题。根据您的情况,初始化变量通常是循环条件的一部分。

在 C#编程中,while循环非常有用,但在 Unity 中并不被认为是良好的实践,因为它们可能会对性能产生负面影响,并且通常需要手动管理。

让我们来看一个常见的用例,我们需要在玩家还活着时执行代码,然后在不再是这种情况时进行调试:

  1. 创建一个名为PlayerLivesint类型的初始化变量,并将其设置为3
int PlayerLives = 3; 
  1. 创建一个名为HealthStatus的新公共函数,并在Start中调用它。

  2. 声明一个while循环,检查PlayerLives是否大于0(也就是玩家还活着):

while(PlayerLives > 0)
{
} 
  1. while循环内,调试一些内容,让我们知道角色仍然活着,然后使用--运算符将PlayerLives减 1:
Debug.Log("Still alive!");
PlayerLives--; 
  1. while循环的大括号后添加一个调试日志,以便在生命耗尽时打印一些内容:
Debug.Log("Player KO'd..."); 

您的代码应该如下所示:

int PlayerLives = 3;
// Start is called before the first frame update
void Start()
{
    HealthStatus();
}
public void HealthStatus()
{
    while(PlayerLives > 0)
    {
        Debug.Log("Still alive!");
        PlayerLives--;
    }
    Debug.Log("Player KO'd...");
} 

PlayerLives3开始时,while循环将执行三次。在每次循环中,调试日志"Still alive!"会触发,并且会从PlayerLives中减去一条生命。当while循环要执行第四次时,我们的条件失败了,因为PlayerLives0,所以代码块被跳过,最终的调试日志打印出来:

图 4.14:控制台中 while 循环输出的屏幕截图

如果您没有看到多个"Still alive!"的调试日志,请确保控制台工具栏中的折叠按钮没有被选中。

现在的问题是,如果循环永远不停止执行会发生什么?我们将在下一节讨论这个问题。

到无穷大和更远

在完成本章之前,我们需要了解一个非常重要的概念,即迭代语句:无限循环。这正是它们的名字:当循环的条件使得它无法停止运行并继续在程序中执行时。无限循环通常发生在forwhile循环中,当迭代器没有增加或减少时;如果在while循环示例中省略了PlayerLives代码行,Unity 将会冻结和/或崩溃,因为PlayerLives将永远是 3,并且循环会一直执行下去。

迭代器并不是唯一需要注意的问题;在for循环中设置永远不会失败或评估为 false 的条件也会导致无限循环。在遍历键值对部分的团队成员示例中,如果我们将for循环的条件设置为i < 0而不是i < QuestPartyMembers.Counti将永远小于0,循环直到 Unity 崩溃。

总结

随着本章的结束,我们应该反思我们取得了多少成就,以及我们可以用这些新知识构建什么。我们知道如何使用简单的if-else检查和更复杂的switch语句,在代码中进行决策。我们可以使用数组和列表存储值的集合,或者使用字典存储键值对。这样可以高效地存储复杂和分组的数据。我们甚至可以为每种集合类型选择合适的循环语句,同时小心避免无限循环崩溃。

如果您感到不知所措,那完全没问题——逻辑、顺序思维都是锻炼您编程大脑的一部分。

下一章将完成 C#编程的基础知识,介绍类、结构体和面向对象编程OOP)。我们将把迄今为止学到的所有内容都应用到这些主题中,为我们第一次真正深入理解和控制 Unity 引擎中的对象做准备。

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,以及更多。

立即加入!

packt.link/csharpunity2021

第五章:使用类、结构和 OOP

出于明显的原因,本书的目标不是让您因信息过载而头痛欲裂。然而,接下来的主题将把您从初学者的小隔间带到面向对象编程OOP)的开放空间。到目前为止,我们一直在依赖 C#语言中预定义的变量类型:底层的字符串、列表和字典都是类,这就是为什么我们可以创建它们并通过点表示法使用它们的属性。然而,依赖内置类型有一个明显的弱点——无法偏离 C#已经设定的蓝图。

创建您自己的类使您能够定义和配置设计的蓝图,捕获信息并驱动特定于您的游戏或应用程序的操作。实质上,自定义类和 OOP 是编程王国的关键;没有它们,独特的程序将寥寥无几。

在本章中,您将亲身体验从头开始创建类,并讨论类变量、构造函数和方法的内部工作原理。您还将了解引用类型和值类型对象之间的区别,以及这些概念如何在 Unity 中应用。随着您的学习,以下主题将会更详细地讨论:

  • 引入 OOP

  • 定义类

  • 声明结构

  • 理解引用类型和值类型

  • 整合面向对象的思维方式

  • 在 Unity 中应用 OOP

引入 OOP

在 C#编程时,OOP 是您将使用的主要编程范式。如果类和结构实例是我们程序的蓝图,那么 OOP 就是将所有东西都组合在一起的架构。当我们将 OOP 称为编程范式时,我们是说它对整个程序的工作和通信有特定的原则。

实质上,OOP 关注的是对象而不是纯粹的顺序逻辑——它们所持有的数据,它们如何驱动行动,以及最重要的是它们如何相互通信。

定义类

回到第二章编程的基本组成部分,我们简要讨论了类是对象的蓝图,并提到它们可以被视为自定义变量类型。我们还了解到LearningCurve脚本是一个类,但是 Unity 可以将其附加到场景中的对象。关于类的主要事情要记住的是它们是引用类型——也就是说,当它们被分配或传递给另一个变量时,引用的是原始对象,而不是一个新的副本。在我们讨论结构之后,我们将深入讨论这一点。然而,在此之前,我们需要了解创建类的基础知识。

现在,我们将暂时搁置 Unity 中类和脚本的工作方式,专注于它们在 C#中是如何创建和使用的。类是使用class关键字创建的,如下所示:

accessModifier class UniqueName
{
    Variables 
    Constructors
    Methods
} 

在一个类内声明的任何变量或方法都属于该类,并通过其独特的类名访问。

为了使本章中的示例尽可能连贯,我们将创建和修改一个典型游戏可能拥有的简单Character类。我们还将摆脱代码截图,让您习惯于阅读和解释代码,就像在实际环境中看到的那样。然而,我们首先需要自己的自定义类,所以让我们创建一个。

在我们理解它们的内部工作原理之前,我们需要一个类来进行实践,所以让我们创建一个新的 C#脚本,从头开始。

  1. 右键单击您在第一章中创建的Scripts文件夹,然后选择创建 | C#脚本

  2. 将脚本命名为Character,在 Visual Studio 中打开它,并删除所有生成的代码。

  3. 声明一个名为Character的公共类,后面跟着一对花括号,然后保存文件。您的类代码应该与以下代码完全匹配:

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

public class Character
{ 
} 
  1. 我们删除了生成的代码,因为我们不需要将这个脚本附加到 Unity 游戏对象上。

Character现在被注册为一个公共类蓝图。这意味着项目中的任何类都可以使用它来创建角色。然而,这些只是指示——创建角色需要额外的步骤。这个创建步骤被称为实例化,并且是下一节的主题。

实例化类对象

实例化是根据特定一组指令创建对象的行为,这个对象被称为实例。如果类是蓝图,实例就是根据它们的指令建造的房屋;每个新的Character实例都是它的对象,就像根据相同指令建造的两个房屋仍然是两个不同的物理结构一样。一个的变化对另一个没有任何影响。

第四章控制流和集合类型中,我们创建了列表和字典,这些是 C#默认的类,使用它们的类型和new关键字。我们可以对自定义类(比如Character)做同样的事情,这就是你接下来要做的。

我们将Character类声明为公共的,这意味着在任何其他类中都可以创建Character实例。由于我们已经在LearningCurve中工作了,让我们在Start()方法中声明一个新的角色。

Start()方法中打开LearningCurve并声明一个名为hero的新的Character类型变量:

Character hero = new Character(); 

让我们一步一步来分解这个问题:

  1. 变量类型被指定为Character,这意味着变量是该类的一个实例。

  2. 变量名为hero,它是使用new关键字创建的,后面跟着Character类名和两个括号。这是实例在程序内存中创建的地方,即使类现在是空的。

  3. 我们可以像处理到目前为止的任何其他对象一样使用hero变量。当Character类有了自己的变量和方法时,我们可以使用点符号从hero中访问它们。

在创建hero变量时,你也可以使用推断声明,就像这样:

var hero = new Character(); 

现在我们的角色类没有任何类字段的话就不能做太多事情。在接下来的几节中,你将添加类字段,以及更多内容。

添加类字段

向自定义类添加变量或字段与我们在LearningCurve中已经做过的事情没有什么不同。相同的概念适用,包括访问修饰符、变量作用域和值分配。然而,属于类的任何变量都是与类实例一起创建的,这意味着如果没有分配值,它们将默认为零或空。一般来说,选择设置初始值取决于它们将存储的信息:

  • 如果一个变量在创建类实例时需要具有相同的起始值,设置初始值是一个很好的主意。这对于像经验点或起始分数之类的东西会很有用。

  • 如果一个变量需要在每个类实例中进行自定义,比如CharacterName,就将其值保持未分配,并使用类构造函数(这是我们将在使用构造函数部分讨论的一个主题)。

每个角色类都需要一些基本字段;在接下来的部分中,你需要添加它们。

让我们加入两个变量来保存角色的名称和起始经验点数:

  1. Character类的大括号内添加两个public变量——一个用于名称的string变量,一个用于经验点的integer变量。

  2. name值留空,但将经验点设置为0,这样每个角色都从零开始:

public class Character
{
    public string name;
    public int exp = 0; 
} 
  1. Character实例初始化后,在LearningCurve中添加一个调试日志。使用它来打印出新角色的nameexp变量,使用点符号表示法:
Character hero = new Character(); 
Debug.LogFormat("Hero: {0} - {1} EXP", hero.name, hero.exp); 
  1. hero被初始化时,name被分配一个空值,在调试日志中显示为空格,而exp打印出0。请注意,我们不需要将Character脚本附加到场景中的任何游戏对象上;我们只是在LearningCurve中引用它们,Unity 会完成其余的工作。控制台现在将调试输出我们的角色信息,如下所示:

图 5.1:控制台中打印的自定义类属性的屏幕截图

到目前为止,我们的类可以工作,但是使用这些空值并不是很实用。您需要使用所谓的类构造函数来解决这个问题。

使用构造函数

类构造函数是特殊的方法,当创建类实例时会自动触发,这类似于LearningCurveStart方法的运行方式。构造函数根据其蓝图构建类:

  • 如果没有指定构造函数,C#会生成一个默认构造函数。默认构造函数将任何变量设置为它们的默认类型值——数值变量设置为零,布尔变量设置为 false,引用类型(类)设置为 null。

  • 自定义构造函数可以带有参数,就像任何其他方法一样,并且用于在初始化时设置类变量的值。

  • 一个类可以有多个构造函数。

构造函数的编写方式与常规方法相似,但有一些区别;例如,它们需要是公共的,没有返回类型,方法名始终是类名。例如,让我们向Character类添加一个没有参数的基本构造函数,并将名称字段设置为非 null 值。

将这段新代码直接添加到类变量下面,如下所示:

public string name;
public int exp = 0;
**public****Character****()**
**{**
 **name =** **"Not assigned"****;**
**}** 

在 Unity 中运行项目,您将看到hero实例使用这个新的构造函数。调试日志将显示英雄的名称为未分配,而不是空值:

图 5.2:控制台中打印的未分配的自定义类变量的屏幕截图

这是一个很好的进展,但是我们需要使类构造函数更加灵活。这意味着我们需要能够传入值,以便它们可以作为起始值使用,接下来您将要做的就是这个。

现在,Character类开始表现得更像一个真正的对象,但我们可以通过添加第二个构造函数来使其更好,以便在初始化时接受一个名称并将其设置为name字段:

  1. Character中添加另一个接受string参数的构造函数,称为name

  2. 使用this关键字将参数分配给类的name变量。这被称为构造函数重载

public Character(string name)
{
    this.name = name;
} 

为了方便起见,构造函数通常会具有与类变量同名的参数。在这些情况下,使用this关键字指定变量属于类。在这个例子中,this.name指的是类的名称变量,而name是参数;如果没有this关键字,编译器会发出警告,因为它无法区分它们。

  1. LearningCurve中创建一个新的Character实例,称为heroine。在初始化时使用自定义构造函数传入一个名称,并在控制台中打印出详细信息:
Character heroine = new Character("Agatha");
Debug.LogFormat("Hero: {0} - {1} EXP", heroine.name,
        heroine.exp); 

当一个类有多个构造函数或一个方法有多个变体时,Visual Studio 会在自动完成弹出窗口中显示一组箭头,可以使用箭头键滚动浏览:

图 5.3:Visual Studio 中多个方法构造函数的屏幕截图

  1. 现在,我们可以在初始化新的Character类时选择基本构造函数或自定义构造函数。Character类本身在配置不同情况下的不同实例时现在更加灵活了:

图 5.4:控制台中打印的多个自定义类实例的屏幕截图

现在真正的工作开始了;除了作为变量存储设施之外,我们的类还需要方法才能做任何有用的事情。您的下一个任务是将这个付诸实践。

声明类方法

将自定义类添加方法与将它们添加到LearningCurve没有任何区别。然而,这是一个很好的机会来谈谈良好编程的基本原则——不要重复自己DRY)。DRY 是所有良好编写代码的基准。基本上,如果你发现自己一遍又一遍地写同样的代码行,那么是时候重新思考和重新组织了。这通常以创建一个新方法来保存重复的代码形式出现,这样可以更容易地修改和调用该功能,无论是在当前脚本中还是在其他脚本中。

在编程术语中,你会看到这被称为抽象出一个方法或特性。

我们已经有了相当多的重复代码,所以让我们看看在哪里可以增加脚本的可读性和效率。

我们重复的调试日志是一个很好的机会,可以将一些代码直接抽象到Character类中:

  1. Character类添加一个名为PrintStatsInfo的新public方法,返回类型为void

  2. LearningCurve中的调试日志复制粘贴到方法体中。

  3. 将变量更改为nameexp,因为现在可以直接从类引用它们。

public void PrintStatsInfo()
{
      Debug.LogFormat("Hero: {0} - {1} EXP", name, exp);
} 
  1. 用对PrintStatsInfo的方法调用替换我们之前添加到LearningCurve中的角色调试日志,然后点击播放:
 Character hero = new Character();
 **hero.PrintStatsInfo();**
 Character heroine = new Character("Agatha");
 **heroine.PrintStatsInfo();** 
  1. 现在Character类有了一个方法,任何实例都可以使用点表示法自由访问它。由于heroheroine都是独立的对象,PrintStatsInfo会将它们各自的nameexp值调试到控制台。

这种行为比直接在LearningCurve中拥有调试日志要好。将功能组合到一个类中并通过方法驱动操作总是一个好主意。这样可以使代码更易读——因为我们的Character对象在打印调试日志时发出了命令,而不是重复代码。

整个Character类应该如下所示的代码:

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

public class Character
{
    public string name;
    public int exp = 0;

    public Character()
    {
        name = "Not assigned";
    }

    public Character(string name)
    {
        this.name = name;
    }

    public void PrintStatsInfo()
    {
        Debug.LogFormat("Hero: {0} - {1} EXP", name, exp);
    }
} 

通过对类的讲解,你已经可以写出可读性强、轻量级且可重用的模块化代码了。现在是时候来解决类的表亲对象——结构体了!

声明结构体

结构体与类似,它们也是你想在程序中创建的对象的蓝图。主要区别在于它们是值类型,这意味着它们是按值传递而不是按引用传递的,就像类一样。当结构体被分配或传递给另一个变量时,会创建结构体的一个新副本,因此原始结构体根本没有被引用。我们将在下一节中更详细地讨论这一点。首先,我们需要了解结构体的工作原理以及创建它们时适用的具体规则。

结构体的声明方式与类相同,可以包含字段、方法和构造函数。

accessModifier struct UniqueName 
{
    Variables
    Constructors
    Methods
} 

与类一样,任何变量和方法都属于结构体,并且通过其唯一名称访问。

然而,结构体有一些限制:

  • 除非标记为staticconst修饰符,否则不能在结构体声明中使用值初始化变量——你可以在第十章重新审视类型、方法和类中了解更多信息。

  • 不允许没有参数的构造函数。

  • 结构体带有一个默认构造函数,它会自动将所有变量设置为它们的默认值,根据它们的类型。

每个角色都需要一把好武器,这些武器是结构体对象的完美选择。我们将在本章的理解引用和值类型部分讨论为什么这样做。然而,首先,你要创建一个结构体来玩耍。

我们的角色需要好的武器来完成任务,这对于一个简单的结构体来说是一个很好的选择:

  1. 右键单击Scripts文件夹,选择创建,然后选择C#脚本

  2. 将其命名为Weapon,在 Visual Studio 中打开它,然后删除using UnityEngine后面生成的所有代码。

  3. 声明一个名为Weapon的公共结构体,然后保存文件。

  4. 添加一个string类型的name字段和一个int类型的damage字段:

你可以在彼此嵌套的类和结构中,但这通常是不被赞同的,因为它会使代码变得混乱。

public struct Weapon
{
    public string name;
    public int damage;
} 
  1. 使用namedamage参数声明一个构造函数,并使用this关键字设置结构字段:
public Weapon(string name, int damage)
{
    this.name = name;
    this.damage = damage;
} 
  1. 在构造函数下面添加一个调试方法来打印武器信息:
public void PrintWeaponStats()
{
    Debug.LogFormat("Weapon: {0} - {1} DMG", name, damage);
} 
  1. LearningCurve中,使用自定义构造函数和new关键字创建一个新的Weapon结构:
Weapon huntingBow = new Weapon("Hunting Bow", 105); 
  1. 我们的新huntingBow对象使用了自定义构造函数,并在初始化时为两个字段提供了值。

将脚本限制为单个类是一个好主意,但看到仅由一个类专用的结构体包含在文件中是相当常见的。

现在我们已经有了引用(类)和值(结构)对象的例子,是时候熟悉它们各自的细节了。更具体地说,你需要了解这些对象是如何在内存中传递和存储的。

理解引用类型和值类型

除了关键字和初始字段值之外,到目前为止我们还没有看到类和结构之间有太大的区别。类最适合将复杂的操作和数据组合在一起,并且这些数据在程序运行过程中会发生变化;而结构更适合简单的对象和数据,这些数据在大部分时间内都保持不变。除了它们的用途之外,在一个关键领域它们有根本的不同——那就是它们是如何在变量之间传递或赋值的。类是引用类型,意味着它们是通过引用传递的;结构是值类型,意味着它们是通过值传递的。

引用类型

当我们的Character类的实例被初始化时,heroheroine变量并不持有它们的类信息——相反,它们持有对象在程序内存中的位置的引用。如果我们将heroheroine分配给同一类中的另一个变量,那么内存引用就会被分配,而不是角色数据。这有几个影响,其中最重要的是,如果我们有多个变量存储相同的内存引用,对其中一个的更改会影响它们全部。

这样的话题最好是通过演示而不是解释来展示;接下来就由你来在实际例子中尝试一下。

现在是时候测试Character类是引用类型了:

  1. LearningCurve中,声明一个名为hero2的新Character变量。将hero2分配给hero变量,并使用PrintStatsInfo方法打印出两组信息。

  2. 点击播放并查看在控制台中显示的两个调试日志:

Character hero = new Character();
**Character hero2 = hero;**

hero.PrintStatsInfo();
**hero2.PrintStatsInfo();** 
  1. 两个调试日志将是相同的,因为在创建hero2时它被赋值给了hero。此时,hero2hero都指向内存中hero所在的位置!

图 5.5:控制台打印的结构体统计信息的屏幕截图

  1. 现在,将hero2的名字改成有趣的东西,然后再次点击播放:
Character hero2 = hero;
**hero2.name =** **"Sir Krane the Brave"****;** 
  1. 你会看到现在herohero2都有相同的名字,即使只有一个角色的数据被改变了!

图 5.6:控制台打印的类实例属性的屏幕截图

这里的教训是,引用类型需要小心处理,不要在分配给新变量时进行复制。对一个引用的任何更改都会影响所有持有相同引用的其他变量。

如果你想复制一个类,要么创建一个新的独立实例,要么重新考虑是否结构体可能是你对象蓝图的更好选择。在接下来的部分中,你将更好地了解值类型。

值类型

当创建一个结构对象时,它的所有数据都存储在相应的变量中,没有引用或连接到它的内存位置。这使得结构对于创建需要快速高效地复制的对象非常有用,同时仍保留它们各自的身份。在接下来的练习中,尝试使用我们的Weapon结构来实现这一点。

让我们通过将huntingBow复制到一个新变量中并更新其数据来创建一个新的武器对象,以查看更改是否影响两个结构体:

  1. LearningCurve中声明一个新的Weapon结构,并将huntingBow分配为其初始值:
Weapon huntingBow = new Weapon("Hunting Bow", 105);
**Weapon warBow = huntingBow;** 
  1. 使用调试方法打印出每个武器的数据:
**huntingBow.PrintWeaponStats();**
**warBow.PrintWeaponStats();** 
  1. 现在它们的设置方式是,huntingBowwarBow将有相同的调试日志,就像我们在改变任何数据之前的两个角色一样!

图 5.7:控制台中打印的结构体实例的屏幕截图

  1. warBow.namewarBow.damage字段更改为你选择的值,然后再次点击播放:
 Weapon warBow = huntingBow;
 **warBow.name =** **"War Bow"****;**
 **warBow.damage =** **155****;** 
  1. 控制台将显示只有与warBow相关的数据被更改,而huntingBow保留其原始数据。

图 5.8:打印到控制台的更新后的结构体属性的屏幕截图

从这个例子中可以得出的结论是,结构体很容易被复制和修改为它们各自的对象,而类则保留对原始对象的引用。现在我们对结构体和类在底层是如何工作有了一些了解,并确认了引用和值类型在它们的自然环境中的行为,我们可以开始谈论编程中最重要的一个主题,OOP,以及它如何适应编程领域。

整合面向对象的思维方式

物理世界中的事物在 OOP 的类似级别上运行;当你想要买一罐软饮料时,你拿的是一罐苏打水,而不是液体本身。这个罐子是一个对象,将相关信息和动作组合在一个自包含的包中。然而,在处理对象时有一些规则,无论是在编程中还是在杂货店——例如,谁可以访问它们。不同的变化和通用的动作都影响着我们周围所有对象的性质。

在编程术语中,这些规则是 OOP 的主要原则:封装继承多态

封装

OOP 最好的一点是它支持封装——定义对象的变量和方法对外部代码的可访问性(有时被称为调用代码)。以我们的苏打罐为例——在自动售货机中,可能的互动是有限的。由于机器被锁住,不是每个人都可以过来拿一罐;如果你碰巧有合适的零钱,你将被允许有条件地访问它,但数量是有限制的。如果机器本身被锁在一个房间里,只有拿着门钥匙的人才会知道苏打罐的存在。

你现在要问自己的问题是,我们如何设置这些限制?简单的答案是,我们一直在使用封装,通过为我们的对象变量和方法指定访问修饰符。

如果你需要复习,请回到第三章深入变量、类型和方法中的访问修饰符部分。

让我们尝试一个简单的封装示例,以了解这在实践中是如何工作的。我们的Character类是公共的,它的字段和方法也是公共的。但是,如果我们想要一个方法来将角色的数据重置为其初始值,会怎样呢?这可能会很方便,但如果意外调用了它,可能会造成灾难,这就是一个私有对象成员的完美候选者:

  1. Character类中创建一个名为Resetprivate方法,没有返回值。将nameexp变量分别设置为"Not assigned"0
private void Reset()
{
    this.name = "Not assigned";
    this.exp = 0;
} 
  1. 尝试在打印出hero2数据后从LearningCurve中调用Reset()

图 5.9:Character 类中一个无法访问的方法的屏幕截图

如果你想知道 Visual Studio 是否出了问题,它没有。将方法或变量标记为私有将使其在这个类或结构体内部使用点表示法时无法访问;如果你手动输入并悬停在Reset()上,你会看到有关该方法受保护的错误消息。

要实际调用这个私有方法,我们可以在类构造函数中添加一个重置命令:

public Character()
{
    Reset();
} 

封装确实允许对象具有更复杂的可访问性设置;然而,现在我们将坚持使用 publicprivate 成员。在下一章中,当我们开始完善游戏原型时,我们将根据需要添加不同的修饰符。

现在,让我们谈谈继承,在创建未来游戏中的类层次结构时,它将成为您的好朋友。

继承

C# 类可以按照另一个类的形象创建,共享其成员变量和方法,但能够定义其独特的数据。在面向对象编程中,我们将这称为继承,这是一种创建相关类的强大方式,而无需重复代码。再次以汽水为例,市场上有通用汽水,它们具有相同的基本属性,还有特殊汽水。特殊汽水共享相同的基本属性,但具有不同的品牌或包装,使它们与众不同。当您将两者并排看时,很明显它们都是汽水罐,但它们显然不是同一种。

原始类通常称为基类或父类,而继承类称为派生类或子类。任何使用 publicprotectedinternal 访问修饰符标记的基类成员都自动成为派生类的一部分,除了构造函数。类构造函数始终属于其包含类,但可以从派生类中使用,以将重复的代码最小化。现在不要太担心不同的基类情况。相反,让我们尝试一个简单的游戏示例。

大多数游戏都有多种类型的角色,因此让我们创建一个名为 Paladin 的新类,该类继承自 Character 类。您可以将这个新类添加到 Character 脚本中,也可以创建一个新的脚本。如果要将新类添加到 Character 脚本中,请确保它在 Character 类的花括号之外:

public class Paladin: Character
{
} 

就像 LearningCurveMonoBehavior 继承一样,我们只需要添加一个冒号和我们想要继承的基类,C# 就会完成剩下的工作。现在,任何 Paladin 实例都可以访问 name 属性和 exp 属性,以及 PrintStatsInfo 方法。

通常最好为不同的类创建新的脚本,而不是将它们添加到现有的脚本中。这样可以分离脚本,并避免在任何单个文件中有太多行代码(称为臃肿文件)。

这很好,但继承类如何处理它们的构造?您可以在下一节中找到答案。

基础构造函数

当一个类从另一个类继承时,它们形成一种金字塔结构,成员变量从父类流向任何派生子类。父类不知道任何子类,但所有子类都知道它们的父类。然而,父类构造函数可以直接从子类构造函数中调用,只需进行简单的语法修改:

public class ChildClass: ParentClass
{
    public ChildClass(): **base****()**
    {
    }
} 

base 关键字代表父构造函数,这种情况下是默认构造函数。然而,由于 base 代表一个构造函数,构造函数是一个方法,子类可以将参数传递给其父类构造函数。

由于我们希望所有 Paladin 对象都有一个名称,而 Character 已经有一个处理这个问题的构造函数,我们可以直接从 Paladin 类调用 base 构造函数,而不必重写构造函数:

  1. Paladin 类添加一个构造函数,该构造函数接受一个 string 参数,称为 name。使用 colonbase 关键字调用父构造函数,传入 name
public class Paladin: Character
{
**public****Paladin****(****string** **name****):** **base****(****name****)**
 **{**

 **}**
} 
  1. LearningCurve 中,创建一个名为 knight 的新 Paladin 实例。使用基础构造函数来分配一个值。从 knight 调用 PrintStatsInfo,并查看控制台:
Paladin knight = new Paladin("Sir Arthur");
knight.PrintStatsInfo(); 
  1. 调试日志将与我们的其他Character实例相同,但名称将与我们分配给Paladin构造函数的名称相同:

图 5.10:基本角色构造函数属性的屏幕截图

Paladin构造函数触发时,它将name参数传递给Character构造函数,从而设置name值。基本上,我们使用Character构造函数来为Paladin类做初始化工作,使Paladin构造函数只负责初始化其独特的属性,而在这一点上它还没有。

除了继承,有时你会想要根据其他现有对象的组合来创建新对象。想想乐高积木;你不是从零开始建造——你已经有了不同颜色和结构的积木块可以使用。在编程术语中,这被称为“组合”,我们将在下一节讨论。

构成

除了继承,类还可以由其他类组成。以我们的Weapon结构为例,Paladin可以在自身内部轻松包含一个Weapon变量,并且可以访问其所有属性和方法。让我们通过更新Paladin来接受一个起始武器并在构造函数中分配其值:

public class Paladin: Character
{
   **public** **Weapon weapon;**

    public Paladin(string name, **Weapon weapon**): base(name)
    {
        **this****.weapon = weapon;**
    }
} 

由于weaponPaladin独有的,而不是Character的,我们需要在构造函数中设置其初始值。我们还需要更新knight实例以包含一个Weapon变量。所以,让我们使用huntingBow

Paladin knight = new Paladin("Sir Arthur", **huntingBow**); 

如果现在运行游戏,你不会看到任何不同,因为我们使用的是Character类的PrintStatsInfo方法,它不知道Paladin类的weapon属性。为了解决这个问题,我们需要谈谈多态性。

多态性

多态是希腊词“多形”的意思,在面向对象编程中有两种不同的应用方式:

  • 派生类对象被视为与父类对象相同。例如,一个Character对象数组也可以存储Paladin对象,因为它们是从Character派生而来的。

  • 父类可以将方法标记为virtual,这意味着它们的指令可以被派生类使用override关键字修改。在CharacterPaladin的情况下,如果我们可以为每个类从PrintStatsInfo中调试不同的消息,那将是有用的。

多态性允许派生类保留其父类的结构,同时也可以自由地调整动作以适应其特定需求。你标记为virtual的任何方法都将给你对象多态性的自由。让我们利用这个新知识并将其应用到我们的角色调试方法中。

让我们修改CharacterPaladin以使用PrintStatsInfo打印不同的调试日志:

  1. Character类中通过在publicvoid之间添加virtual关键字来更改PrintStatsInfo
public **virtual** void PrintStatsInfo()
{
    Debug.LogFormat("Hero: {0} - {1} EXP", name, exp);
} 
  1. 使用override关键字在Paladin类中声明PrintStatsInfo方法。添加一个调试日志,以你喜欢的方式打印Paladin属性:
public override void PrintStatsInfo()
{
    Debug.LogFormat("Hail {0} - take up your {1}!", name, 
             weapon.name);
} 

这可能看起来像重复的代码,我们已经说过这是不好的形式,但这是一个特殊情况。通过在Character类中将PrintStatsInfo标记为virtual,我们告诉编译器这个方法可以根据调用类的不同而有不同的形式。

  1. 当我们在Paladin中声明了PrintStatsInfo的重写版本时,我们添加了仅适用于该类的自定义行为。多亏了多态性,我们不必选择从CharacterPaladin对象调用哪个版本的PrintStatsInfo——编译器已经知道了:

图 5.11:多态角色属性的屏幕截图

我知道这是很多内容,所以让我们在接近终点时回顾一些面向对象编程的主要要点:

  • 面向对象编程是将相关数据和操作分组到对象中——这些对象可以相互通信并独立行动。

  • 可以使用访问修饰符来设置对类成员的访问,就像变量一样。

  • 类可以继承自其他类,创建父/子关系的层级结构。

  • 类可以拥有其他类或结构类型的成员。

  • 类可以覆盖任何标记为virtual的父方法,允许它们执行自定义操作同时保留相同的蓝图。

OOP 并不是 C#唯一可用的编程范式,你可以在这里找到其他主要方法的实际解释:cs.lmu.edu/~ray/notes/paradigms

在本章学到的所有 OOP 都直接适用于 C#世界。然而,我们仍需要将其与 Unity 放在适当的位置,这将是你在本章剩余时间里专注的内容。

在 Unity 中应用 OOP

如果你在 OOP 语言中待得足够长,你最终会听到像一切都是对象这样的短语在开发者之间像秘密祈祷一样被低声诉说。遵循 OOP 原则,程序中的一切都应该是一个对象,但 Unity 中的 GameObject 可以代表你的类和结构。然而,并不是说 Unity 中的所有对象都必须在物理场景中,所以我们仍然可以在幕后使用我们新发现的编程类。

对象是一个优秀的类

回到第二章编程的基本组成部分,我们讨论了当脚本添加到 Unity 中的 GameObject 时,脚本会被转换为组件。从 OOP 的组合原则来看,GameObject 是父容器,它们可以由多个组件组成。这可能与每个脚本一个 C#类的想法相矛盾,但事实上,这更多是为了更好的可读性而不是实际要求。类可以嵌套在彼此内部——只是会变得很混乱。然而,将多个脚本组件附加到单个 GameObject 上可能非常有用,特别是在处理管理类或行为时。

总是试图将对象简化为它们最基本的元素,然后使用组合来构建更大、更复杂的对象。这样做比修改由大型笨重组成的 GameObject 更容易,因为 GameObject 由小型、可互换的组件组成。

让我们来看看Main Camera,看看它是如何运作的:

图 5.12:检视器中主摄像机对象的屏幕截图

在前面的屏幕截图中,每个组件(TransformCameraAudio ListenerLearning Curve脚本)最初都是 Unity 中的一个类。就像CharacterWeapon的实例一样,当我们点击播放时,这些组件在计算机内存中变成对象,包括它们的成员变量和方法。

如果我们将LearningCurve(或任何脚本或组件)附加到 1,000 个 GameObject 上并点击播放,将创建并存储 1,000 个单独的LearningCurve实例。

我们甚至可以使用组件名称作为数据类型来创建这些组件的实例。与类一样,Unity 组件类是引用类型,可以像其他变量一样创建。然而,查找和分配这些 Unity 组件与你迄今为止所见到的略有不同。为此,你需要在下一节更多地了解 GameObject 的工作原理。

访问组件

现在我们知道了组件如何作用于 GameObject,那么我们如何访问它们的特定实例呢?幸运的是,Unity 中的所有 GameObject 都继承自GameObject类,这意味着我们可以使用它们的成员方法来在场景中找到我们需要的任何东西。有两种方法可以分配或检索当前场景中活动的 GameObject:

  1. 通过GameObject类中的GetComponent()Find()方法,这些方法可以使用公共和私有变量。

  2. 通过将游戏对象直接从Project面板拖放到Inspector选项卡中的变量槽中。这个选项只适用于 C#中的公共变量,因为它们是唯一会出现在检查器中的变量。如果您决定需要在检查器中显示一个私有变量,可以使用SerializeField属性进行标记。

您可以在 Unity 文档中了解有关属性和SerializeField的更多信息:docs.unity3d.com/ScriptReference/SerializeField.html

让我们来看看第一个选项的语法。

在代码中访问组件

使用GetComponent相当简单,但它的方法签名与我们迄今为止看到的其他方法略有不同:

GameObject.GetComponent<ComponentType>(); 

我们只需要寻找的组件类型,GameObject类将返回该组件(如果存在)和null(如果不存在)。GetComponent方法还有其他变体,但这是最简单的,因为我们不需要了解我们要查找的GameObject类的具体信息。这被称为通用方法,我们将在第十三章“探索泛型、委托和更多内容”中进一步讨论。然而,现在让我们只使用摄像机的变换。

由于LearningCurve已经附加到Main Camera对象上,让我们获取摄像机的Transform组件并将其存储在一个公共变量中。Transform组件控制 Unity 中对象的位置、旋转和缩放,因此这是一个很好的例子:

  1. LearningCurve中添加一个新的公共Transform类型变量,称为CamTransform
public Transform CamTransform; 
  1. Start中使用GetComponent方法从GameObject类初始化CamTransform。使用this关键字,因为LearningCurve附加到与Transform组件相同的GameObject组件上。

  2. 使用点表示法访问和调试CamTransformlocalPosition属性:

void Start()
{
    CamTransform = this.GetComponent<Transform>();
    Debug.Log(CamTransform.localPosition); 
} 
  1. 我们在LearningCurve的顶部添加了一个未初始化的public Transform变量,并在Start中使用GetComponent方法进行了初始化。GetComponent找到了附加到此GameObject组件的Transform组件,并将其返回给CamTransform。现在CamTransform存储了一个Transform对象,我们可以访问它的所有类属性和方法,包括以下屏幕截图中的localPosition

图 5.13:控制台中打印的 Transform 位置的屏幕截图

GetComponent方法非常适用于快速检索组件,但它只能访问调用脚本所附加到的游戏对象上的组件。例如,如果我们从附加到Main CameraLearningCurve脚本中使用GetComponent,我们只能访问TransformCameraAudio Listener组件。

如果我们想引用另一个游戏对象上的组件,比如Directional Light,我们需要先使用Find方法获取对该对象的引用。只需要游戏对象的名称,Unity 就会返回适当的游戏对象供我们存储或操作。

要参考每个游戏对象的名称,可以在所选对象的Inspector选项卡顶部找到:

图 5.14:检查器中的 Directional Light 对象的屏幕截图

在 Unity 中找到游戏场景中的对象是至关重要的,因此您需要进行练习。让我们拿到手头的对象并练习查找和分配它们的组件。

让我们尝试一下Find方法,并从LearningCurve中检索Directional Light对象:

  1. CamTransform下面的LearningCurve中添加两个变量,一个是GameObject类型,一个是Transform类型:
public GameObject DirectionLight;
public Transform LightTransform; 
  1. 通过名称找到Directional Light组件,并在Start()方法中用它初始化DirectionLight
void Start()
{
    DirectionLight = GameObject.Find("Directional Light"); 
} 
  1. LightTransform的值设置为附加到DirectionLightTransform组件,并调试其localPosition。由于DirectionLight现在是它的GameObjectGetComponent完美地工作:
LightTransform = DirectionLight.GetComponent<Transform>();
Debug.Log(LightTransform.localPosition); 
  1. 在运行游戏之前,重要的是要理解方法调用可以链接在一起,以减少代码步骤。例如,我们可以通过组合FindGetComponent来在一行中初始化LightTransform,而不必经过DirectionLight
GameObject.Find("Directional Light").GetComponent<Transform>(); 

警告:长串链接的代码会导致可读性差和混乱,特别是在处理复杂应用程序时。避免超过这个示例的长行是个好的经验法则。

虽然在代码中查找对象总是有效的,但你也可以简单地将对象本身拖放到Inspector选项卡中。让我们在下一节中演示如何做到这一点。

拖放

既然我们已经介绍了代码密集的做事方式,让我们快速看一下 Unity 的拖放功能。虽然拖放比在代码中使用GameObject类要快得多,但 Unity 有时会在保存或导出项目时,或者在 Unity 更新时,丢失通过这种方式建立的对象和变量之间的连接。

当你需要快速分配几个变量时,尽管利用这个功能。但大多数情况下,我建议坚持编码。

让我们改变LearningCurve来展示如何使用拖放来分配一个GameObject组件:

  1. 注释掉下面的代码行,我们使用GameObject.Find()来检索并将Directional Light对象分配给DirectionLight变量:
//DirectionLight = GameObject.Find("Directional Light"); 
  1. 选择Main Camera GameObject,将Directional Light拖到Learning Curve组件的Direction Light字段中,然后点击播放:

图 5.15:将 Directional Light 拖到脚本属性的截图

  1. Directional Light GameObject 现在分配给了DirectionLight变量。没有涉及任何代码,因为 Unity 在内部分配了变量,而LearningCurve类没有发生变化。

在决定是使用拖放还是GameObject.Find()来分配变量时,理解一些重要的事情是很重要的。首先,Find()方法速度较慢,如果在多个脚本中多次调用该方法,可能会导致性能问题。其次,你需要确保你的 GameObject 在场景层次结构中都有唯一的名称;如果没有,可能会在有多个相同名称的对象或更改对象名称本身的情况下导致一些严重的错误。

总结

我们对类、结构和面向对象编程的探索标志着 C#基础知识的第一部分的结束。你已经学会了如何声明你的类和结构,这是你将来制作的每个应用程序或游戏的支架。你还确定了这两种对象在如何传递和访问上的差异,以及它们与面向对象编程的关系。最后,你通过继承、组合和多态来实现面向对象编程的原则。

识别相关的数据和操作,创建蓝图来赋予它们形状,并使用实例来构建交互,这是处理任何程序或游戏的坚实基础。再加上访问组件的能力,你就成为了一个 Unity 开发者的基础。

下一章将过渡到游戏开发的基础知识,并直接在 Unity 中编写对象行为脚本。我们将从详细说明简单的开放世界冒险游戏的要求开始,在场景中使用 GameObject,并最终完成一个为我们的角色准备好的白盒环境。

小测验 - 面向对象编程的一切

  1. 哪个方法处理类内的初始化逻辑?

  2. 作为值类型,结构是如何传递的?

  3. 面向对象编程的主要原则是什么?

  4. 你会使用哪个GameObject类方法来在调用类的同一对象上找到一个组件?

加入我们的 Discord!

与其他用户一起阅读本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,等等。

立即加入!

packt.link/csharpunity2021

第六章:在 Unity 中动手实践

创建游戏涉及的远不止在代码中模拟动作。设计、故事、环境、灯光和动画都在为玩家设定舞台中扮演着重要的角色。游戏首先是一种体验,单靠代码是无法实现的。

在过去的十年里,Unity 通过为程序员和非程序员带来先进的工具,将自己置于游戏开发的最前沿。动画和特效、音频、环境设计等等,所有这些都可以直接从 Unity 编辑器中获得,而不需要一行代码。我们将在定义我们的游戏的需求、环境和游戏机制时讨论这些话题。然而,首先,我们需要一个游戏设计的主题介绍。

游戏设计理论是一个庞大的研究领域,学习它的所有秘密可能需要整个职业生涯。然而,我们只会动手实践基础知识;其他一切都取决于你去探索!这一章将为我们打下基础,并涵盖以下话题:

  • 游戏设计入门

  • 建造一个关卡

  • 灯光基础

  • 在 Unity 中制作动画

游戏设计入门

在着手任何游戏项目之前,重要的是要有一个你想要构建的蓝图。有时,想法会在你的脑海中变得清晰明了,但一旦你开始创建角色类别或环境,事情似乎会偏离你最初的意图。这就是游戏设计允许你规划以下接触点的地方:

  • 概念:游戏的大局观念和设计,包括它的类型和玩法风格。

  • 核心机制:角色在游戏中可以进行的可玩特性或互动。常见的游戏机制包括跳跃、射击、解谜或驾驶。

  • 控制方案:给玩家控制他们的角色、环境互动和其他可执行动作的按钮和/或键的地图。

  • 故事:推动游戏的潜在叙事,创造玩家和他们所玩的游戏世界之间的共鸣和连接。

  • 艺术风格:游戏的整体外观和感觉,从角色和菜单艺术到关卡和环境都保持一致。

  • 胜利和失败条件:规定游戏如何获胜或失败的规则,通常包括潜在失败的目标或目标。

这些话题绝不是游戏设计所涉及的全部内容的详尽列表。然而,它们是开始构思所谓的游戏设计文件的好地方,这是你下一个任务!

游戏设计文件

谷歌游戏设计文件会得到一大堆模板、格式规则和内容指南,这可能会让新程序员准备放弃。事实上,设计文件是根据创建它们的团队或公司量身定制的,比互联网上的想象要容易得多。

一般来说,有三种类型的设计文档,如下:

  • 游戏设计文件GDD):GDD 包含了游戏的玩法、氛围、故事以及它试图创造的体验。根据游戏的不同,这个文件可能只有几页长,也可能有几百页。

  • 技术设计文件TDD):这个文件关注游戏的所有技术方面,从它将在哪种硬件上运行到类别和程序架构需要如何构建。和 GDD 一样,长度会根据项目的不同而变化。

  • 一页纸:通常用于营销或推广情况,一页纸本质上是你游戏的快照。顾名思义,它应该只占据一页纸。

没有一种正确或错误的方式来格式化 GDD,所以这是一个让你的创造力茁壮成长的好地方。加入一些启发你的参考材料的图片;在布局上发挥创意——这是你定义你的愿景的地方。

我们将在本书的其余部分中一直致力于开发的游戏相当简单,不需要像 GDD 或 TDD 那样详细的东西。相反,我们将创建一个一页来跟踪我们的项目目标和一些背景信息。

Hero Born 一页

为了使我们在前进时保持在正确的轨道上,我已经准备了一个简单的文档,概述了游戏原型的基础知识。在继续之前,请仔细阅读一遍,并尝试想象我们迄今学到的一些编程概念如何付诸实践:

图 6.1:Hero Born 一页文档

现在你已经对我们游戏的骨架有了一个高层次的了解,你可以开始建立一个原型关卡来容纳游戏体验。

建立一个关卡

在构建游戏关卡时,尝试从玩家的角度看事物总是一个好主意。你希望他们如何看待环境,如何与之交互,以及在其中行走时的感受?你实际上正在构建你的游戏存在的世界,所以要保持一致。

使用 Unity,你可以选择使用地形工具创建室外环境,用基本形状和几何图形来阻挡室内设置,或者两者的混合。你甚至可以从其他程序(如 Blender)导入 3D 模型,用作场景中的对象。

Unity 在docs.unity3d.com/Manual/script-Terrain.html上有一个很好的地形工具介绍。如果你选择这条路线,Unity Asset Store 上还有一个名为 Terrain Toolkit 2017 的免费资产,可以在assetstore.unity.com/packages/tools/terrain/terrain-toolkit-2017-83490找到。你也可以使用 Blender 等工具来创建你的游戏资产,可以在www.blender.org/features/modeling/找到。

对于Hero Born,我们将坚持简单的室内竞技场设置,这样可以轻松移动,但也有一些角落可以藏身。你将使用primitives——Unity 提供的基本对象形状——将所有这些组合在一起,因为它们在场景中创建、缩放和定位起来非常容易。

创建 primitives

看着你经常玩的游戏,你可能会想知道如何才能创建看起来如此逼真,以至于似乎可以伸手进屏幕抓住它们的模型和物体。幸运的是,Unity 有一组基本的 GameObject 可以供你选择,以便更快地创建原型。这些可能不会很华丽或高清,但当你在学习或开发团队中没有 3D 艺术家时,它们是救命稻草。

如果你打开 Unity,你可以进入Hierarchy面板,点击+ | 3D Object,你会看到所有可用的选项,但其中只有大约一半是 primitives 或常见形状,如下面的截图所示,用红色标出:

图 6.2:Unity Hierarchy 窗口,选择 Create 选项

其他 3D 对象选项,如TerrainWind ZoneTree,对我们的需求来说有点太高级了,但如果你感兴趣,可以随意尝试它们。

你可以在docs.unity3d.com/Manual/CreatingEnvironments.html找到更多关于构建 Unity 环境的信息。

在我们跳得太远之前,当你脚下有地板时,四处走动通常更容易,所以让我们从以下步骤开始为我们的竞技场创建一个地面平面:

  1. Hierarchy面板中,点击+ | 3D Object | Plane

  2. 确保在Hierarchy选项卡中选择了新对象,在Inspector选项卡中将 GameObject 重命名为Ground

  3. Transform下拉菜单中,将Scale更改为3,在XYZ轴上:

图 6.3:Unity 编辑器中的地面平面

  1. 如果你的场景中的光线看起来比之前的截图暗或不同,选择层次面板中的定向光,并将定向光组件的强度值设置为 1:

图 6.4:在检视器窗格中选择定向光对象

我们创建了一个平面 GameObject,并增加了它的大小,以便为我们未来的角色提供更多活动空间。这个平面将像一个受现实物理约束的 3D 对象一样,意味着其他物体不能穿过它。我们将在第七章“移动、摄像机控制和碰撞”中更多地讨论 Unity 物理系统及其工作原理。现在,我们需要开始以 3D 思维。

以 3D 思考

现在我们在场景中有了第一个对象,我们可以谈论 3D 空间——具体来说,一个对象的位置、旋转和比例在三维空间中的行为。如果你回想一下高中几何学,应该对具有xy坐标系的图表很熟悉。要在图表上标出一个点,你必须有一个x值和一个y值。

Unity 支持 2D 和 3D 游戏开发,如果我们制作 2D 游戏,我们可以在这里结束解释。然而,在 Unity 编辑器中处理 3D 空间时,我们有一个额外的轴,称为z轴。z轴映射深度或透视,赋予了我们的空间和其中的物体 3D 的特性。

这可能一开始会让人困惑,但 Unity 有一些很好的视觉辅助工具,可以帮助你理清思路。在场景面板的右上方,你会看到一个几何图标,上面标有红色、绿色和蓝色的xyz轴。当在层次窗口中选择 GameObject 时,场景中的所有 GameObject 都会显示它们的轴箭头:

图 6.5:带有定向图标的场景视图

这将始终显示场景的当前方向和放置在其中的对象的方向。单击任何这些彩色轴将切换场景方向到所选轴。自己尝试一下,以便熟悉切换视角。

如果你在检视器窗格中查看Ground对象的Transform组件,你会看到位置、旋转和比例都由这三个轴决定。

位置决定了物体在场景中的放置位置,旋转决定了它的角度,而比例则决定了它的大小。这些值可以随时在检视器窗格或 C#脚本中进行更改:

图 6.6:在层次中选择的地面对象

现在,地面看起来有点无聊。让我们用材质来改变它。

材质

我们的地面平面现在并不是很有趣,但我们可以使用材质为关卡注入一些生气。材质控制着 GameObject 在场景中的渲染方式,这由材质的着色器决定。将着色器视为负责将光照和纹理数据组合成材质外观的部分。

每个 GameObject 都以默认的材质着色器开始(在此处从检视器窗格中显示),将其颜色设置为标准白色:

图 6.7:对象上的默认材质

要改变对象的颜色,我们需要创建一个材质并将其拖到我们想要修改的对象上。记住,在 Unity 中一切都是对象——材质也不例外。材质可以在需要时重复使用在许多 GameObject 上,但对材质的任何更改也会传递到附加了该材质的任何对象上。如果我们在场景中有几个敌人对象,它们都使用一个将它们都设置为红色的材质,然后我们将基础材质颜色更改为蓝色,那么所有的敌人都会变成蓝色。

蓝色很吸引人;让我们将地面平面的颜色改成蓝色,并创建一个新的材质,将地面平面从沉闷的白色变成深沉而充满活力的蓝色:

  1. 项目面板中创建一个新文件夹,并将其命名为Materials

  2. 材质文件夹中,右键单击+ | 材质,并将其命名为Ground_Mat

  3. 点击反照率属性旁边的颜色框,从弹出的颜色选择窗口中选择您的颜色,然后关闭它。

  4. 项目面板中拖动Ground_Mat对象,并将其放到层次结构面板中的Ground游戏对象上:

图 6.8:材质颜色选择器

您创建的新材质现在是一个项目资产。将Ground_Mat拖放到Ground游戏对象中改变了平面的颜色,这意味着对Ground_Mat的任何更改都将反映在Ground中。

图 6.9:更新颜色材质的地面平面

地面是我们的画布;然而,在 3D 空间中,它可以支持其表面上的其他 3D 对象。将由您来用有趣的障碍物来填充它,以供未来的玩家使用。

白盒设计

白盒设计是一个设计术语,用于使用占位符布置想法,通常是为了在以后用成品替换它们。在关卡设计中,白盒设计的做法是用原始游戏对象来阻挡环境,以便了解你想要它看起来的感觉。这是一个很好的开始方式,特别是在游戏原型阶段。

在深入研究 Unity 之前,我想先用简单的草图来描述我的关卡的基本布局和位置。这给了我们一点方向,并将有助于更快地布置我们的环境。

在下面的图中,您将能够看到我心目中的竞技场,中间有一个可以通过坡道进入的高台,每个角落都有小炮塔:

图 6.10:《英雄诞生》关卡竞技场的草图

不用担心如果你不是一个艺术家——我也不是。重要的是把你的想法写下来,巩固在你的脑海中,并在忙于在 Unity 中工作之前解决任何问题。

在全力以赴之前,您需要熟悉一些 Unity 编辑器的快捷方式,以使白盒设计更容易。

编辑器工具

当我们在第一章中讨论 Unity 界面时,我们略过了一些工具栏功能,现在我们需要重新讨论一下,以便知道如何有效地操作游戏对象。你可以在 Unity 编辑器的左上角找到它们:

图 6.11:Unity 编辑器工具栏

让我们分解一下在前面截图中从工具栏中可以使用的不同工具:

  1. : 这允许您通过单击和拖动鼠标来平移和改变场景中的位置。

  2. 移动:这让你通过拖动它们的相应箭头来沿着xyz轴移动物体。

  3. 旋转:这让你通过转动或拖动其相应的标记来调整物体的旋转。

  4. 缩放:这让你通过将其拖动到特定轴来修改物体的比例。

  5. 矩形变换:这将移动、旋转和缩放工具功能合并为一个包。

  6. 变换:这让你一次性访问物体的位置、旋转和缩放。

  7. 自定义编辑器工具:这允许您访问您为编辑器构建的任何自定义工具。不用担心这个,因为它远远超出了我们的范围。如果您想了解更多,请参阅docs.unity3d.com/2020.1/Documentation/ScriptReference/EditorTools.EditorTool.html中的文档。

你可以在场景面板中找到有关导航和定位游戏对象的更多信息,网址是docs.unity3d.com/Manual/PositioningGameObjects.html。值得注意的是,你可以使用Transform组件来移动、定位和缩放对象,就像我们在本章前面讨论的那样。

在场景中进行平移和导航可以使用类似的工具,尽管不是来自 Unity 编辑器本身:

  • 要四处看,按住鼠标右键并拖动以使相机移动。

  • 在使用相机时移动,继续按住鼠标右键,使用WASD键分别向前、向后、向左和向右移动。

  • 按下F键,可以放大并聚焦在层次面板中已选择的游戏对象上。

这种场景导航更常被称为飞行模式,所以当我要求你专注于或导航到特定对象或视点时,请使用这些功能的组合。

在场景视图中移动有时可能是一项任务,但这一切都归结于反复练习。有关场景导航功能的更详细列表,请访问docs.unity3d.com/Manual/SceneViewNavigation.html

尽管地面平面不会让我们的角色穿过它,但在这一点上我们仍然可以走到边缘。你的任务是将竞技场围起来,这样玩家就有了一个有限的移动区域。

英雄的试炼——安装石膏板

使用基本立方体和工具栏,使用移动旋转缩放工具将四面墙围绕主竞技场分隔开:

  1. 层次面板中,选择+ | 3D 对象 | 立方体来创建第一面墙,并将其命名为Wall_01

  2. 将其比例值设置为x轴 30,y轴 1.5,z轴 0.2。

请注意,平面的操作比对象大 10 倍——所以我们的长度为 3 的平面与长度为 30 的对象长度相同。

  1. 层次面板中选择Wall_01对象,切换到左上角的位置工具,并使用红色、绿色和蓝色箭头将墙定位在地面平面的边缘。

  2. 重复步骤 1-3,直到你的区域周围有四面墙为止:

图 6.12:四面墙和地面平面的竞技场

从本章开始,我将给出一些墙的位置、旋转和缩放的基本值,但请随意尝试并发挥你的创造力。我希望你能尝试使用 Unity 编辑器工具,这样你就能更快地熟悉它们。

这有点施工,但竞技场开始成形了!在继续添加障碍和平台之前,你需要养成整理对象层次结构的习惯。我们将在下一节讨论这是如何工作的。

保持层次结构清晰

通常,我会把这种建议放在部分的结尾,但确保你的项目层次结构尽可能有条理是非常重要的,所以它需要有自己的小节。理想情况下,你会希望所有相关的游戏对象都在一个父对象下面。现在,这并不是一个风险,因为我们场景中只有几个对象;然而,在一个大型项目中,当数量增加到几百个时,你会很吃力。

保持层次结构清晰的最简单方法是将相关对象存储在一个父对象中,就像你在桌面上的文件夹中一样。我们的场景有一些需要组织的对象,Unity 通过让我们创建空的游戏对象来使这变得容易。空对象是一个完美的容器(或文件夹),用于保存相关的对象组,因为它不附带任何组件——它只是一个外壳。

让我们把我们的地面平面和四面墙都放在一个共同的空游戏对象下:

  1. 层次结构面板中选择+ | 创建空对象,并将新对象命名为环境

  2. 将地面和四面墙拖放到环境中,使它们成为子对象

  3. 选择环境空对象,并检查其XYZ位置是否都设置为 0:

图 6.13:显示空 GameObject 父对象的层次结构面板

环境在层次结构选项卡中作为父对象存在,其子对象是竞技场对象。现在我们可以通过箭头图标展开或关闭环境对象的下拉列表,使层次结构面板变得不那么凌乱。

环境对象的XYZ位置设置为 0 是很重要的,因为子对象的位置现在是相对于父对象位置的。这带来了一个有趣的问题:我们设置的这些位置、旋转和缩放的原点是什么?答案是它们取决于我们使用的相对空间,而在 Unity 中,这些空间要么是世界空间,要么是本地空间:

  • 世界空间使用场景中的一个固定原点作为所有 GameObject 的恒定参考。在 Unity 中,这个原点是 (0, 0, 0),或者 xyz 轴上的 0。

  • 本地空间使用对象的父级Transform组件作为其原点,从本质上改变了场景的透视。Unity 还将本地原点设置为 (0, 0, 0)。可以将其视为父级变换是宇宙的中心,其他所有东西都围绕它而轨道运行。

这两种方向在不同情况下都很有用,但是现在,在这一点上重置它会让每个人都从同一起跑线开始。

使用 Prefabs

Prefabs 是 Unity 中最强大的组件之一。它们不仅在关卡构建中很有用,而且在脚本编写中也很有用。将 Prefabs 视为 GameObject,可以保存并重复使用每个子对象、组件、C#脚本和属性设置。创建后,Prefab 就像一个类蓝图;在场景中使用的每个副本都是该 Prefab 的单独实例。因此,对基本 Prefab 的任何更改也会更改场景中所有活动实例。

竞技场看起来有点太简单,完全是敞开的,这使得它成为测试创建和编辑 Prefabs 的完美场所。由于我们希望在竞技场的每个角落都有四个相同的炮塔,它们是 Prefab 的完美案例,我们可以通过以下步骤创建:

我没有包含任何精确的屏障位置、旋转或缩放值,因为我希望你能亲自接触 Unity 编辑器工具。

未来,当你看到一个任务在你面前时,不包括特定的位置、旋转或缩放值,我希望你能通过实践学习。

  1. 通过选择+ | 创建空对象环境父对象内创建一个空的父对象,并将其命名为屏障 _01

  2. 使用+ | 3D 对象 | 立方体选择创建两个立方体,并将它们定位和缩放成 V 形的底座。

  3. 创建两个更多的立方体原语,并将它们放在炮塔底座的两端:

图 6.14:由立方体组成的炮塔的屏幕截图

  1. 项目面板下的资产下创建一个名为Prefabs的新文件夹。然后,将层次结构面板中的屏障 _01 GameObject 拖到项目视图中的Prefabs文件夹中:

图 6.15:Prefabs 文件夹中的屏障 Prefab

屏障 _01 及其所有子对象现在都是 Prefabs,这意味着我们可以通过从Prefabs文件夹中拖动副本或复制场景中的副本来重复使用它。屏障 _01层次结构选项卡中变成蓝色,表示其状态发生了变化,并在检查器选项卡中其名称下方添加了一排 Prefab 功能按钮:

图 6.16:在检查器窗格中突出显示的屏障 _01 Prefab

对原始预制件对象Barrier_01的任何编辑现在都会影响场景中的任何副本。由于我们需要第五个立方体来完成屏障,让我们更新并保存预制件,看看它的效果。

现在我们的炮塔中间有一个巨大的缺口,这对于保护我们的角色来说并不理想,所以让我们通过添加另一个立方体并应用更改来更新Barrier_01预制件:

  1. 创建一个立方体原始对象,并将其放置在炮塔底座的交叉点处。

  2. 新的立方体原始对象将在层次结构选项卡中以灰色标记,并在其名称旁边有一个小+图标。这意味着它还没有正式成为预制件的一部分!

图 6.17:层次结构窗口中标记的新预制件更新

  1. 层次结构面板中右键单击新的立方体原始对象,然后选择添加 游戏对象 | 应用于预制件'Barrier_01'

图 6.18:将预制件更改应用到基本预制件的选项

Barrier_01预制件现在已更新,包括新的立方体,并且整个预制件层次结构应该再次变为蓝色。现在你有一个看起来像前面截图的炮塔预制件,或者如果你感到有冒险精神,也可以是更有创意的东西。然而,我们希望这些在竞技场的每个角落都有。你的任务是添加它们!

现在我们有了一个可重复使用的屏障预制件,让我们构建出与本节开头的草图相匹配的关卡的其余部分:

  1. 通过复制Barrier_01预制件三次,并将每个预制件放置在竞技场的不同角落。你可以通过将多个Barrier_01对象从预制件文件夹拖放到场景中,或者在层次结构中右键单击Barrier_01并选择复制来完成这个操作。

  2. 环境父对象内创建一个新的空游戏对象,并将其命名为Raised_Platform

  3. 创建一个立方体,并按下面的图 6.19所示进行缩放,形成一个平台。

  4. 创建一个平面,并将其缩放成一个斜坡:

  • 提示:围绕xy轴旋转平面,可以创建一个倾斜的平面

  • 然后,将其位置调整,使其连接平台和地面。

  1. 通过在 Mac 上使用Cmd + D,或在 Windows 上使用Ctrl + D,复制斜坡对象。然后,重复旋转和定位步骤。

  2. 重复上一步骤两次,直到总共有四个斜坡通向平台!

图 6.19:提升平台父游戏对象

你现在已经成功地创建了你的第一个游戏关卡的白盒模型!不过,不要太沉迷其中——我们只是刚刚开始。所有好的游戏都有玩家可以拾取或与之交互的物品。在接下来的挑战中,你的任务是创建一个生命值道具并将其制作成预制件。

英雄的试炼-创建一个生命值道具

将我们在本章中学到的一切放在一起可能需要你花费几分钟的时间,但这是非常值得的。按照以下步骤创建拾取物品:

  1. 通过选择+ | 3D 对象 | 胶囊体,创建一个名为Health_Pickup胶囊体游戏对象。

  2. xyz轴的比例设置为 0.3,然后切换到移动工具,并将其位置放置在你的屏障之一附近。

  3. Health_Pickup对象创建并附加一个新的黄色材质

  4. Health_Pickup对象从层次结构面板拖动到预制件文件夹中。

参考以下截图,了解最终产品的样子:

图 6.20:场景中的拾取物品和屏障预制件

这就暂时结束了我们对关卡设计和布局的工作。接下来,你将在 Unity 中快速学习灯光,并且我们将在本章后面学习如何为我们的物品添加动画。

灯光基础知识

Unity 中的照明是一个广泛的主题,但可以归结为两类:实时和预计算。这两种类型的光都考虑了光的颜色和强度等属性,以及它在场景中的方向,这些都可以在检视器窗格中配置。区别在于 Unity 引擎计算光的方式。

实时照明是每帧计算的,这意味着任何通过其路径的物体都会投射出逼真的阴影,并且通常会像真实世界的光源一样行为。然而,这可能会显著减慢游戏速度,并且根据场景中的光源数量,会消耗大量的计算资源。另一方面,预计算照明将场景的照明存储在称为光照贴图的纹理中,然后将其应用或烘烤到场景中。虽然这节省了计算资源,但烘烤的照明是静态的。这意味着当物体在场景中移动时,它不会实时反应或改变。

还有一种混合类型的照明称为预计算实时全局照明,它弥合了实时和预计算过程之间的差距。这是一个高级的 Unity 特定主题,所以我们不会在本书中涵盖它,但可以随时查看docs.unity3d.com/Manual/GIIntro.html上的文档。

现在让我们看看如何在 Unity 场景中创建光对象。

创建光

默认情况下,每个场景都带有一个定向光组件,用作主要的照明源,但光可以像其他游戏对象一样在层次结构中创建。尽管控制光源的概念可能对您来说是新的,但它们是 Unity 中的对象,这意味着它们可以被定位,缩放和旋转以适应您的需求。

图 6.21:光照创建菜单选项

让我们看一些实时光对象及其性能的例子:

  • 定向光非常适合模拟自然光,比如阳光。它们在场景中没有实际位置,但它们的光会像永远指向同一个方向一样照射到所有物体上。

  • 点光源本质上是浮动的球体,从球体的中心点向所有方向发出光线。它们在场景中有定义的位置和强度。

  • 聚光灯向特定方向发出光线,但它们受其角度的限制,并专注于场景的特定区域。可以将其视为现实世界中的聚光灯或泛光灯。

  • 区域光的形状类似矩形,从矩形的一侧表面发出光线。

反射探针光探针组超出了我们在英雄诞生中所需的范围;但是,如果您感兴趣,可以在docs.unity3d.com/Manual/ReflectionProbes.htmldocs.unity3d.com/Manual/LightProbes.html上了解更多。

像 Unity 中的所有游戏对象一样,光具有可以调整的属性,以赋予场景特定的氛围或主题。

光组件属性

以下截图显示了我们场景中定向光的组件。所有这些属性都可以配置,以创建沉浸式环境,但我们需要注意的基本属性是颜色模式强度。这些属性控制光的色调,实时或计算效果以及一般强度:

图 6.22:检视器窗口中的光组件

与其他 Unity 组件一样,这些属性可以通过脚本和Light类访问,该类可以在docs.unity3d.com/ScriptReference/Light.html找到。

通过选择+ | Light | Point Light来尝试一下,看看它对区域照明有什么影响。在调整了设置之后,通过在Hierarchy面板中右键单击它并选择Delete来删除点光源。

现在我们对如何点亮游戏场景有了更多了解,让我们把注意力转向添加一些动画!

在 Unity 中制作动画

在 Unity 中对对象进行动画处理可以从简单的旋转效果到复杂的角色移动和动作。你可以在代码中创建动画,也可以使用 Animation 和 Animator 窗口:

  • 动画窗口是动画片段(称为片段)使用时间轴创建和管理的地方。对象属性沿着这个时间轴记录,然后播放回来创建动画效果。

  • Animator窗口使用叫做动画控制器的对象来管理这些片段及其转换。

你可以在docs.unity3d.com/Manual/AnimatorControllers.html找到有关 Animator 窗口及其控制器的更多信息。

在片段中创建和操作目标对象将使你的游戏很快就动起来。对于我们在 Unity 动画中的短暂旅程,我们将在代码中和使用 Animator 创建相同的旋转效果。

在代码中创建动画

首先,我们将在代码中创建一个动画来旋转我们的生命物品拾取。由于所有的 GameObject 都有一个Transform组件,我们可以获取我们物品的Transform组件并无限旋转它。

要在代码中创建动画,需要执行以下步骤:

  1. Scripts文件夹中创建一个新的脚本,命名为ItemRotation,并在 Visual Studio Code 中打开它。

  2. 在新脚本的顶部和类内部,添加一个包含值100int变量,名为RotationSpeed,和一个名为ItemTransformTransform变量:

public int RotationSpeed = 100;
Transform ItemTransform; 
  1. Start()方法体内,获取 GameObject 的Transform组件并将其分配给ItemTransform
ItemTransform = this.GetComponent<Transform>(); 
  1. Update()方法体内,调用ItemTransform.Rotate。这个Transform类方法接受三个轴,分别是XYZ旋转,你想要执行。由于我们希望物品绕着末端旋转,我们将使用x轴,其他轴设置为0
ItemTransform.Rotate(RotationSpeed * Time.deltaTime, 0, 0); 

您会注意到我们将RotationSpeed乘以一个叫做Time.deltaTime的东西。这是在 Unity 中标准化移动效果的方法,这样无论玩家的电脑运行速度快慢,效果都会看起来很平滑。一般来说,你应该总是将你的移动或旋转速度乘以Time.deltaTime

  1. 回到 Unity,在项目面板的Prefabs文件夹中选择Health_Pickup对象,滚动到检视窗口的底部。点击添加组件,搜索ItemRotation脚本,然后按Enter

图 6.23:检视面板中的添加组件按钮

  1. 现在我们的预制已经更新,移动Main Camera,这样你就可以看到Health_Pickup对象并点击播放!

图 6.24:焦点在生命物品上的相机截图

如你所见,生命物品现在围绕其x轴连续而平滑地旋转!现在你已经在代码中为物品添加了动画,我们将使用 Unity 内置的动画系统来复制我们的动画。

在 Unity 动画窗口中创建动画

任何你想要应用动画片段的 GameObject 都需要附加到一个设置了动画控制器的 Animator 组件上。如果在创建新片段时项目中没有控制器,Unity 将创建一个并保存在项目面板中,然后你可以用它来管理你的片段。你的下一个挑战是为拾取物品创建一个新的动画片段。

我们将开始通过创建一个新的动画片段来为Health_PickupPrefab 添加动画,该动画将使对象无限循环旋转。要创建一个新的动画片段,我们需要执行以下步骤:

  1. 导航到窗口 | 动画 | 动画,打开动画面板,并将动画选项卡拖放到控制台旁边。

  2. 确保在Hierarchy中选择了Health_Pickup项目,然后在Animation面板中单击Create

图 6.25:Unity 动画窗口的屏幕截图

  1. 从下拉列表中创建一个名为Animations的新文件夹,然后将新片段命名为Pickup_Spin

图 6.26:创建新动画窗口的屏幕截图

  1. 确保新片段出现在Animation面板中:

图 6.27:动画窗口的屏幕截图,选择了一个片段

  1. 由于我们没有任何Animator控制器,Unity 为我们在Animation文件夹中创建了一个名为Health_Pickup的控制器。选择Health_Pickup后,在检查器窗格中注意到,当我们创建了片段时,Animator组件也被添加到了 Prefab 中,但尚未使用Health_Pickup控制器正式保存到 Prefab 中。

  2. 注意,+图标显示在Animator组件的左上角,这意味着它还没有成为Health_PickupPrefab 的一部分:

图 6.28:检查器面板中的 Animator 组件

  1. 选择右上角的三个垂直点图标,选择添加组件 | 应用于 Prefab 'Health_Pickup'

图 6.29:应用于 Prefab 的新组件的屏幕截图

现在,您已经创建并添加了一个 Animator 组件到Health_PickupPrefab,是时候开始记录一些动画帧了。当您想到动作片段时,就像电影一样,您可能会想到帧。当片段通过其帧移动时,动画会前进,产生移动的效果。在 Unity 中也是一样的;我们需要在不同的帧中记录我们的目标对象在不同的位置,这样 Unity 才能播放片段。

记录关键帧

现在我们有了一个可以使用的片段,您将在Animation窗口中看到一个空白的时间轴。基本上,当我们修改Health_PickupPrefab 的z旋转,或者任何其他可以被动画化的属性时,时间轴将记录这些更改作为关键帧。然后 Unity 将这些关键帧组合成完整的动画,类似于模拟电影中的单个帧一起播放成为移动图片。

看一下以下的屏幕截图,并记住记录按钮和时间轴的位置:

图 6.30:动画窗口和关键帧时间轴的屏幕截图

现在,让我们让我们的物品旋转起来。对于旋转动画,我们希望Health_PickupPrefab 在其z轴上每秒完成 360 度的旋转,这可以通过设置三个关键帧并让 Unity 处理其余部分来完成:

  1. Hierarchy窗口中选择Health_Pickup对象,选择添加属性 | 变换,然后单击旋转旁边的+号:

图 6.31:添加用于动画的变换属性的屏幕截图

  1. 单击记录按钮开始动画:
  • 将光标放在时间轴上的0:00处,但将Health_PickupPrefab 的z旋转保持在 0

  • 将光标放在时间轴上的0:30处,并将z旋转设置为180

  • 将光标放在时间轴上的1:00处,并将z旋转设置为360

图 6.32:记录动画关键帧的屏幕截图

  1. 单击记录按钮完成动画

  2. 点击记录按钮右侧的播放按钮,查看动画循环

您会注意到我们的Animator动画覆盖了我们之前在代码中编写的动画。不用担心,这是预期的行为。您可以单击Inspector面板中任何组件右侧的小复选框来激活或停用它。如果停用Animator组件,Health_Pickup将再次使用我们的代码围绕x轴旋转。

Health_Pickup对象现在在z轴上每秒在 0、180 和 360 度之间旋转,创建循环旋转动画。如果您现在播放游戏,动画将无限期地运行,直到游戏停止:

图 6.33:在动画窗口中播放动画的屏幕截图

所有动画都有曲线,这些曲线决定了动画执行的特定属性。我们不会对这些做太多处理,但了解基础知识很重要。我们将在下一节中深入了解它们。

曲线和切线

除了对对象属性进行动画处理外,Unity 还允许我们使用动画曲线管理动画随时间的播放方式。到目前为止,我们一直处于Dopesheet模式,您可以在动画窗口底部进行更改。如果您点击Curves视图(如下屏幕截图所示),您将看到一个不同的图形,其中有重点放置在我们记录的关键帧的位置。

我们希望旋转动画是平滑的,也就是我们所说的线性,所以我们会保持一切不变。然而,可以通过拖动或调整曲线图上的点来加快、减慢或改变动画的运行过程中的任何时点的动画:

图 6.34:动画窗口中曲线时间轴的屏幕截图

虽然动画曲线处理了属性随时间的变化,但我们仍然需要一种方法来解决每次Health_Pickup动画重复时出现的停滞。为此,我们需要更改动画的切线,这会管理关键帧之间的平滑过渡。

这些选项可以通过在Dopesheet模式下右键单击时间轴上的任何关键帧来访问,您可以在这里看到:

图 6.35:关键帧平滑选项的屏幕截图

曲线和切线都是中级/高级内容,所以我们不会深入研究它们。如果您感兴趣,可以查看有关动画曲线和切线选项的文档:docs.unity3d.com/Manual/animeditor-AnimationCurves.html

如果您按照现在的旋转动画播放,物品完成完整旋转并开始新旋转之间会有轻微的暂停。您的任务是使其平滑,这是下一个挑战的主题。

让我们调整动画的第一帧和最后一帧的切线,使得旋转动画在重复时能无缝衔接:

  1. 右键单击动画时间轴上第一个和最后一个关键帧的菱形图标,然后选择Auto

图 6.36:更改关键帧平滑选项的屏幕截图

  1. 如果您还没有这样做,请移动Main Camera,以便您可以看到Health_Pickup对象并点击播放:

图 6.37:最终平滑动画播放的屏幕截图

将第一个和最后一个关键帧的切线更改为Auto告诉 Unity 使它们的过渡平滑,从而消除动画循环时的突然停止/开始运动。

这就是本书中您需要的所有动画,但我鼓励您查看 Unity 在这个领域提供的全部工具。您的游戏将更具吸引力,您的玩家会感谢您!

总结

我们已经完成了另一个章节,其中有很多组成部分,对于那些对 Unity 还不太熟悉的人来说可能会有很多内容。

尽管这本书侧重于 C#语言及其在 Unity 中的实现,我们仍然需要花时间来了解游戏开发、文档和引擎的非脚本功能。虽然我们没有时间深入涉及照明和动画,但如果您打算继续创建 Unity 项目,了解它们是值得的。

在下一章中,我们将把重点转回到编程《英雄诞生》的核心机制,从设置可移动的玩家对象、控制摄像机,以及理解 Unity 的物理系统如何管理游戏世界开始。

弹出测验-基本 Unity 功能

  1. 立方体、胶囊体和球体是什么类型的 GameObject 的例子?

  2. Unity 使用哪个轴来表示深度,从而赋予场景其 3D 外观?

  3. 如何将 GameObject 转换为可重用的 Prefab?

  4. Unity 动画系统使用什么单位来记录对象动画?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。通过问我任何事会话与作者交流,提出问题,为其他读者提供解决方案,等等。

立即加入!

packt.link/csharpunity2021

第七章:移动、摄像机控制和碰撞

当玩家开始新游戏时,首先要做的事情之一就是尝试角色移动(当然,如果游戏有可移动的角色),以及摄像机控制。这不仅令人兴奋,而且让你的玩家知道他们可以期待什么样的游戏玩法。Hero Born中的角色将是一个可以使用WASD或箭头键分别移动和旋转的胶囊体对象。

我们将首先学习如何操作玩家对象的Transform组件,然后使用施加的力复制相同的玩家控制方案。这会产生更真实的移动效果。当我们移动玩家时,摄像机将从稍微在玩家后面和上方的位置跟随,这样在实现射击机制时瞄准会更容易。最后,我们将通过使用物品拾取预制件来探索 Unity 物理系统如何处理碰撞和物理交互。

所有这些将在可玩的水平上汇聚在一起,尽管目前还没有任何射击机制。这也将让我们初次尝试使用 C#来编写游戏功能,将以下主题联系在一起:

  • 管理玩家移动

  • 使用Transform组件移动玩家

  • 编写摄像机行为

  • 使用 Unity 物理系统。

管理玩家移动

当你决定如何最好地在虚拟世界中移动你的玩家角色时,请考虑什么看起来最真实,而不会因昂贵的计算而使游戏陷入困境。在大多数情况下,这在某种程度上是一种权衡,Unity 也不例外。

移动GameObject的三种最常见方式及其结果如下:

  • 选项 A:使用GameObjectTransform组件进行移动和旋转。这是最简单的解决方案,也是我们首先要使用的解决方案。

  • 选项 B:通过在GameObject上附加Rigidbody组件并在代码中施加力来使用真实世界的物理。Rigidbody组件为其附加的任何GameObject添加了模拟的真实世界物理。这种解决方案依赖于 Unity 的物理系统来进行繁重的工作,从而产生更真实的效果。我们将在本章后面更新我们的代码以使用这种方法,以便了解两种方法的感觉。

Unity 建议在移动或旋转GameObject时坚持一致的方法;要么操作对象的Transform组件,要么操作Rigidbody组件,但不能同时操作两者。

  • 选项 C:附加一个现成的 Unity 组件或预制件,如 Character Controller 或 First Person Controller。这样可以减少样板代码,同时在加快原型设计时间的同时仍提供逼真的效果。

你可以在docs.unity3d.com/ScriptReference/CharacterController.html找到有关 Character Controller 组件及其用途的更多信息。

第一人称控制器预制件可从标准资产包中获得,你可以从assetstore.unity.com/packages/essentials/asset-packs/standard-assets-32351下载。

由于你刚刚开始在 Unity 中进行玩家移动,你将在下一节开始使用玩家 Transform 组件,然后在本章后面转移到Rigidbody物理。

使用 Transform 组件移动玩家

我们希望为Hero Born创建一个第三人称冒险设置,因此我们将从一个可以通过键盘输入控制的胶囊体和一个可以跟随胶囊体移动的摄像机开始。尽管这两个 GameObject 将在游戏中一起工作,但我们将它们及其脚本分开以获得更好的控制。

在我们进行任何脚本编写之前,你需要在场景中添加一个玩家胶囊体,这是你的下一个任务。

我们可以在几个步骤中创建一个漂亮的玩家胶囊体:

  1. 层次结构面板中单击+ | 3D 对象 | 胶囊,然后命名为Player

  2. 选择Player GameObject,然后在检视器选项卡底部单击添加组件。搜索Rigidbody并按Enter添加。我们暂时不会使用这个组件,但是在开始时正确设置东西是很好的。

  3. 展开Rigidbody组件底部的约束属性:

  • 勾选XYZ轴上的冻结旋转复选框,以便玩家除了通过我们稍后编写的代码之外不能以任何其他方式旋转:

图 7.1:刚体组件

  1. 项目面板中选择Materials文件夹,然后单击创建 | 材质。命名为Player_Mat

  2. 层次结构中选择Player_Mat,然后在检视器中更改反照率属性为明亮绿色,并将材质拖动到层次结构面板中的Player对象上:

图 7.2:附加到胶囊的玩家材质

您已经使用胶囊原语、刚体组件和新的明亮绿色材质创建了Player对象。现在暂时不用担心刚体组件是什么——您现在需要知道的是它允许我们的胶囊与物理系统互动。在本章末尾讨论 Unity 的物理系统工作原理时,我们将详细介绍更多内容。在进行这些讨论之前,我们需要谈论 3D 空间中一个非常重要的主题:向量。

理解向量

现在我们有了一个玩家胶囊和摄像机设置,我们可以开始看如何使用其Transform组件移动和旋转 GameObject。TranslateRotate方法是 Unity 提供的Transform类的一部分,每个方法都需要一个向量参数来执行其给定的功能。

在 Unity 中,向量用于在 2D 和 3D 空间中保存位置和方向数据,这就是为什么它们有两种类型——Vector2Vector3。这些可以像我们见过的任何其他变量类型一样使用;它们只是保存不同的信息。由于我们的游戏是 3D 的,我们将使用Vector3对象,这意味着我们需要使用xyz值来构造它们。

对于 2D 向量,只需要xy位置。请记住,您的 3D 场景中最新的方向将显示在我们在上一章第六章中讨论的右上方图形中:

图 7.3:Unity 编辑器中的向量图标

如果您想了解有关 Unity 中向量的更多信息,请参阅文档和脚本参考docs.unity3d.com/ScriptReference/Vector3.html

例如,如果我们想要创建一个新的向量来保存场景原点的位置,我们可以使用以下代码:

Vector3 Origin = new Vector(0f, 0f, 0f); 

我们所做的只是创建了一个新的Vector3变量,并用* x 位置为0 y 位置为0 z *位置为0进行了初始化,按顺序排列。这将使玩家生成在游戏竞技场的原点。Float值可以带有或不带有小数点,但它们总是需要以小写f结尾。

我们还可以使用Vector2Vector3类属性创建方向向量:

Vector3 ForwardDirection = Vector3.forward; 

ForwardDirection不是保存位置,而是引用我们场景中沿着 3D 空间中z轴的前进方向。使用 Vector3 方向的好处是,无论我们让玩家朝向哪个方向,我们的代码始终知道前进的方向。我们将在本章后面讨论使用向量,但现在只需习惯以xyz位置和方向来思考 3D 移动。

如果向量的概念对你来说是新的,不要担心——这是一个复杂的主题。Unity 的向量手册是一个很好的起点:docs.unity3d.com/Manual/VectorCookbook.html

现在你对向量有了一些了解,你可以开始实现移动玩家胶囊的基本功能。为此,你需要从键盘上获取玩家输入,这是下一节的主题。

获取玩家输入

位置和方向本身是有用的,但没有玩家的输入,它们无法产生移动。这就是Input类的作用,它处理从按键和鼠标位置到加速度和陀螺仪数据的一切。

Hero Born中,我们将使用WASD和箭头键进行移动,同时使用一个允许摄像机跟随玩家鼠标指向的脚本。为此,我们需要了解输入轴的工作原理。

首先,转到Edit | Project Settings | Input Manager,打开如下截图所示的Input Manager选项卡:

图 7.4:输入管理器窗口

Unity 2021 有一个新的输入系统,可以减少很多编码工作,使得在编辑器中设置输入动作更容易。由于这是一本编程书,我们将从头开始做事情。但是,如果你想了解新的输入系统是如何工作的,请查看这个很棒的教程:learn.unity.com/project/using-the-input-system-in-unity

你会看到一个很长的 Unity 默认输入已经配置好的列表,但让我们以Horizontal轴为例。你可以看到Horizontal输入轴的PositiveNegative按钮设置为leftright,而Alt NegativeAlt Positive按钮设置为ad键。

每当从代码中查询输入轴时,它的值将在-1 和 1 之间。例如,当按下左箭头或A键时,水平轴会注册一个-1 的值。当释放这些键时,值返回到 0。同样,当使用右箭头或D键时,水平轴会注册一个值为 1 的值。这使我们能够使用一行代码捕获单个轴的四个不同输入,而不是为每个输入写出一个长长的if-else语句链。

捕获输入轴就像调用Input.GetAxis()并通过名称指定我们想要的轴一样,这就是我们将在接下来的部分中对HorizontalVertical输入所做的事情。作为一个附带的好处,Unity 应用了一个平滑滤波器,这使得输入与帧率无关。

默认输入可以按照需要进行修改,但你也可以通过增加输入管理器中的Size属性并重命名为你创建的副本来创建自定义轴。你必须增加Size属性才能添加自定义输入。

让我们开始使用 Unity 的输入系统和自定义的运动脚本让我们的玩家移动起来。

移动玩家

在让玩家移动之前,你需要将一个脚本附加到玩家胶囊上:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为PlayerBehavior,并将其拖放到Hierarchy面板中的Player胶囊上。

  2. 添加以下代码并保存:

using System.Collections;
using System.Collections.Generic;
using UnityEngine; 
public class PlayerBehavior : MonoBehaviour 
{
    **// 1**
    public float MoveSpeed = 10f;
    public float RotateSpeed = 75f;
    **// 2**
    private float _vInput;
    private float _hInput;
    void Update()
    {
        **// 3**
        _vInput = Input.GetAxis("Vertical") * MoveSpeed;
        **// 4**
        _hInput = Input.GetAxis("Horizontal") * RotateSpeed;
        **// 5**
        this.transform.Translate(Vector3.forward * _vInput * 
        Time.deltaTime);
        **// 6**
        this.transform.Rotate(Vector3.up * _hInput * 
        Time.deltaTime);
    }
} 

使用this关键字是可选的。Visual Studio 2019 可能会建议你删除它以简化代码,但我更喜欢保留它以增加清晰度。当你有空的方法,比如Start,在这种情况下,删除它们是为了清晰度。

以下是上述代码的详细说明:

  1. 声明两个公共变量用作乘数:
  • MoveSpeed 用于控制玩家前后移动的速度

  • RotateSpeed 用于控制玩家左右旋转的速度

  1. 声明两个私有变量来保存玩家的输入;最初没有值:
  • _vInput将存储垂直轴输入。

  • _hInput将存储水平轴输入。

  1. Input.GetAxis("Vertical")检测上箭头、下箭头、WS键被按下时,并将该值乘以MoveSpeed
  • 上箭头和W键返回值 1,这将使玩家向前(正方向)移动。

  • 下箭头和S键返回-1,这会使玩家向负方向后退。

  1. Input.GetAxis("Horizontal")检测左箭头、右箭头、AD键被按下时,并将该值乘以RotateSpeed
  • 右箭头和D键返回值 1,这将使胶囊向右旋转。

  • 左箭头和A键返回-1,将胶囊向左旋转。

如果您想知道是否可能在一行上进行所有的移动计算,简单的答案是肯定的。然而,最好将您的代码分解,即使只有您自己在阅读它。

  1. 使用Translate方法,它接受一个Vector3参数,来移动胶囊的 Transform 组件:
  • 请记住,this关键字指定了当前脚本所附加的 GameObject,这种情况下是玩家胶囊。

  • Vector3.forward乘以_vInputTime.deltaTime提供了胶囊需要沿着z轴向前/向后移动的方向和速度,速度是我们计算出来的。

  • Time.deltaTime将始终返回自游戏上一帧执行以来的秒数。它通常用于平滑值,这些值在Update方法中捕获或运行,而不是由设备的帧速率确定。

  1. 使用Rotate方法来旋转相对于我们传递的向量的胶囊的 Transform 组件:
  • Vector3.up乘以_hInputTime.deltaTime给我们想要的左/右旋转轴。

  • 我们在这里使用this关键字和Time.deltaTime是出于同样的原因。

正如我们之前讨论的,使用TranslateRotate函数中的方向向量只是其中一种方法。我们可以从我们的轴输入创建新的 Vector3 变量,并且像参数一样使用它们,同样容易。

当您点击播放时,您将能够使用上/下箭头键和W/S键向前/向后移动胶囊,并使用左/右箭头键和A/D键旋转或转向。

通过这几行代码,您已经设置了两个独立的控件,它们与帧速率无关,并且易于修改。然而,我们的摄像机不会随着胶囊的移动而移动,所以让我们在下一节中修复这个问题。

脚本化摄像机行为

让一个 GameObject 跟随另一个 GameObject 的最简单方法是将它们中的一个设置为另一个的子对象。当一个对象是另一个对象的子对象时,子对象的位置和旋转是相对于父对象的。这意味着任何子对象都会随着父对象的移动和旋转而移动和旋转。

然而,这种方法意味着发生在玩家胶囊上的任何移动或旋转也会影响摄像机,这并不是我们一定想要的。我们始终希望摄像机位于玩家的后方一定距离,并始终旋转以朝向玩家,无论发生什么。幸运的是,我们可以很容易地使用Transform类的方法相对于胶囊设置摄像机的位置和旋转。您的任务是在下一个挑战中编写摄像机逻辑。

由于我们希望摄像机行为与玩家移动完全分离,我们将控制摄像机相对于可以从“检视器”选项卡中设置的目标的位置:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为CameraBehavior,并将其拖放到“层次结构”面板中的“主摄像机”中。

  2. 添加以下代码并保存:

using System.Collections;
using System.Collections.Generic;
using UnityEngine; 
public class CameraBehavior : MonoBehaviour 
{
    **// 1**
    public Vector3 CamOffset= new Vector3(0f, 1.2f, -2.6f);
    **// 2**
    private Transform _target;
    void Start()
    {
        **// 3**
        _target = GameObject.Find("Player").transform;
    }
    **// 4**
    void LateUpdate()
    {
        **// 5**
        this.transform.position = _target.TransformPoint(CamOffset);
        **// 6**
        this.transform.LookAt(_target);
    } 
} 

以下是前面代码的分解:

  1. 声明一个Vector3变量来存储主摄像机玩家胶囊之间的距离:
  • 我们将能够在检视器中手动设置摄像头偏移的xyz位置,因为它是public的。

  • 这些默认值是我认为看起来最好的,但请随意尝试。

  1. 创建一个变量来保存玩家胶囊体的 Transform 信息:
  • 这将使我们能够访问其位置、旋转和比例。

  • 我们不希望任何其他脚本能够更改摄像头的目标,这就是为什么它是“私有”的原因。

  1. 使用GameObject.Find按名称定位胶囊体并从场景中检索其 Transform 属性:
  • 这意味着胶囊体的xyz位置在每一帧都会更新并存储在_target变量中。

  • 在场景中查找对象是一项计算密集型的任务,因此最好的做法是只在Start方法中执行一次并存储引用。永远不要在Update方法中使用GameObject.Find,因为那样会不断地尝试找到你要找的对象,并有可能导致游戏崩溃。

  1. LateUpdate是一个MonoBehavior方法,就像StartUpdate一样,在Update之后执行:
  • 由于我们的PlayerBehavior脚本在其Update方法中移动胶囊体,我们希望CameraBehavior中的代码在移动发生后运行;这确保了_target具有最新的位置以供参考。
  1. 为每一帧设置摄像头的位置为_target.TransformPoint(CamOffset),从而产生以下效果:
  • TransformPoint方法计算并返回世界空间中的相对位置。

  • 在这种情况下,它返回target(我们的胶囊体)的位置,偏移了x轴上的0y轴上的1.2(将摄像头放在胶囊体上方),以及z轴上的-2.6(将摄像头略微放在胶囊体后方)。

  1. LookAt方法每一帧更新胶囊体的旋转,聚焦于我们传入的 Transform 参数,这种情况下是_target

图 7.5:在播放模式下的胶囊体和跟随摄像头

这是很多内容,但如果你把它分解成按时间顺序的步骤,就会更容易处理:

  1. 我们为摄像头创建了一个偏移位置。

  2. 我们找到并存储了玩家胶囊体的位置。

  3. 我们手动更新它的位置和旋转,以便它始终以固定距离跟随并注视玩家。

在使用提供特定平台功能的类方法时,始终记得将事情分解为最基本的步骤。这将帮助你在新的编程环境中保持头脑清醒。

虽然你编写的代码可以很好地管理玩家移动,但你可能已经注意到它在某些地方有点抖动。为了创建更平滑、更逼真的移动效果,你需要了解 Unity 物理系统的基础知识,接下来你将深入研究。

使用 Unity 物理系统

到目前为止,我们还没有讨论 Unity 引擎的工作原理,或者它如何在虚拟空间中创建逼真的交互和移动。我们将在本章的其余部分学习 Unity 物理系统的基础知识。

驱动 Unity 的 NVIDIA PhysX 引擎的两个主要组件如下:

  • 刚体组件,允许游戏对象受到重力的影响,并添加质量阻力等属性。如果刚体组件附加了碰撞器组件,它还可以受到施加的力的影响,从而产生更逼真的移动:

图 7.6:检视器窗格中的刚体组件

  • 碰撞器组件,确定游戏对象如何以及何时进入和退出彼此的物理空间,或者简单地碰撞并弹开。虽然给定游戏对象只能附加一个刚体组件,但如果需要不同的形状或交互,可以附加多个碰撞器组件。这通常被称为复合碰撞器设置:

图 7.7:检视器窗格中的盒碰撞器组件

当两个 Collider 组件相互作用时,Rigidbody 属性决定了结果的互动。例如,如果一个 GameObject 的质量比另一个高,较轻的 GameObject 将以更大的力量弹开,就像在现实生活中一样。这两个组件负责 Unity 中的所有物理交互和模拟运动。

使用这些组件有一些注意事项,最好从 Unity 允许的运动类型的角度来理解:

  • 运动学运动发生在一个 GameObject 上附加了 Rigidbody 组件,但它不会在场景中注册到物理系统。换句话说,运动学物体有物理交互,但不会对其做出反应,就像现实生活中的墙壁一样。这只在某些情况下使用,并且可以通过检查 Rigidbody 组件的Is Kinematic属性来启用。由于我们希望我们的胶囊与物理系统互动,我们不会使用这种运动。

  • 非运动学运动是指通过施加力来移动或旋转 Rigidbody 组件,而不是手动更改 GameObject 的 Transform 属性。本节的目标是更新PlayerBehavior脚本以实现这种类型的运动。

我们现在的设置,也就是在使用 Rigidbody 组件与物理系统交互的同时操纵胶囊的 Transform 组件,是为了让你思考在 3D 空间中的移动和旋转。然而,这并不适用于生产,Unity 建议避免在代码中混合使用运动学和非运动学运动。

你的下一个任务是使用施加的力将当前的运动系统转换为更真实的运动体验。

运动中的 Rigidbody 组件

由于我们的玩家已经附加了 Rigidbody 组件,我们应该让物理引擎控制我们的运动,而不是手动平移和旋转 Transform。在应用力时有两个选项:

  • 你可以直接使用 Rigidbody 类的方法,比如AddForceAddTorque来分别移动和旋转一个物体。这种方法有它的缺点,通常需要额外的代码来补偿意外的物理行为,比如在碰撞期间产生的不需要的扭矩或施加的力。

  • 或者,你可以使用其他 Rigidbody 类的方法,比如MovePositionMoveRotation,它们仍然使用施加的力。

在下一节中,我们将采用第二种方法,让 Unity 为我们处理施加的物理效果,但如果你对手动施加力和扭矩到你的 GameObject 感兴趣,那么从这里开始:docs.unity3d.com/ScriptReference/Rigidbody.AddForce.html

这两种方法都会让玩家感觉更真实,并且允许我们在第八章 脚本游戏机制中添加跳跃和冲刺机制。

如果你好奇一个没有 Rigidbody 组件的移动物体与装备了 Rigidbody 组件的环境物体互动时会发生什么,可以从玩家身上移除该组件并在竞技场周围跑一圈。恭喜你——你是一个鬼魂,可以穿墙走了!不过别忘了重新添加 Rigidbody 组件!

玩家胶囊已经附加了 Rigidbody 组件,这意味着你可以访问和修改它的属性。不过,首先你需要找到并存储该组件,这是你下一个挑战。

在修改之前,你需要访问并存储玩家胶囊上的 Rigidbody 组件。更新PlayerBehavior如下更改:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerBehavior : MonoBehaviour 
{
    public float MoveSpeed = 10f;
    public float RotateSpeed = 75f;
    private float _vInput;
    private float _hInput;
    **// 1**
    **private** **Rigidbody _rb;**
    **// 2**
    **void****Start****()**
    **{**
        **// 3**
        **_rb = GetComponent<Rigidbody>();**
    **}**
    void Update()
    {
      _vInput = Input.GetAxis("Vertical") * MoveSpeed;
      _hInput = Input.GetAxis("Horizontal") * RotateSpeed;
      **/***
      this.transform.Translate(Vector3.forward * _vInput * 
      Time.deltaTime);
      this.transform.Rotate(Vector3.up * _hInput * Time.deltaTime);
      ***/**
    }
} 

以下是前面代码的详细说明:

  1. 添加一个私有变量,类型为Rigidbody,它将包含对胶囊 Rigidbody 组件的引用。

  2. Start方法在脚本在场景中初始化时触发,这发生在你点击播放时,并且应该在类的开始时使用任何需要设置的变量。

  3. GetComponent方法检查我们正在查找的组件类型(在本例中为Rigidbody)是否存在于脚本所附加的游戏对象上,并返回它:

  • 如果组件没有附加到游戏对象上,该方法将返回null,但由于我们知道玩家上有一个组件,所以我们现在不用担心错误检查。
  1. Update函数中注释掉TransformRotate方法的调用,这样我们就不会运行两种不同的玩家控制:
  • 我们希望保留捕捉玩家输入的代码,以便以后仍然可以使用它。

您已经初始化并存储了玩家胶囊上的刚体组件,并注释掉了过时的Transform代码,为基于物理的运动做好了准备。角色现在已经准备好迎接下一个挑战,即添加力。

使用以下步骤移动和旋转刚体组件。在Update方法下面的PlayerBehavior中添加以下代码,然后保存文件:

// 1
void FixedUpdate()
{
    // 2
    Vector3 rotation = Vector3.up * _hInput;
    // 3
    Quaternion angleRot = Quaternion.Euler(rotation *
        Time.fixedDeltaTime);
    // 4
    _rb.MovePosition(this.transform.position +
        this.transform.forward * _vInput * Time.fixedDeltaTime);
     // 5
     _rb.MoveRotation(_rb.rotation * angleRot);
} 

以下是前面代码的详细说明:

  1. 任何与物理或刚体相关的代码都应该放在FixedUpdate方法中,而不是Update或其他MonoBehavior方法中:
  • FixedUpdate是与帧率无关的,用于所有物理代码。
  1. 创建一个新的Vector3变量来存储我们的左右旋转:
  • Vector3.up * _hInput是我们在上一个示例中使用Rotate方法的相同旋转向量。
  1. Quaternion.Euler接受一个Vector3参数并返回欧拉角中的旋转值:
  • 我们需要一个Quaternion值而不是Vector3参数来使用MoveRotation方法。这只是一种转换为 Unity 所偏爱的旋转类型。

  • 我们乘以Time.fixedDeltaTime的原因与我们在Update中使用Time.deltaTime的原因相同。

  1. 在我们的_rb组件上调用MovePosition,它接受一个Vector3参数并相应地施加力:
  • 使用的向量可以分解如下:胶囊在前进方向上的Transform位置,乘以垂直输入和Time.fixedDeltaTime

  • 刚体组件负责施加移动力以满足我们的向量参数。

  1. _rb组件上调用MoveRotation方法,该方法还接受一个Vector3参数,并在幕后应用相应的力:
  • angleRot已经具有来自键盘的水平输入,因此我们所需要做的就是将当前的刚体旋转乘以angleRot,以获得相同的左右旋转。

请注意,对于非运动学游戏对象,MovePositionMoveRotation的工作方式是不同的。您可以在刚体脚本参考中找到更多信息docs.unity3d.com/ScriptReference/Rigidbody.html

如果现在点击播放,您将能够向前和向后移动,以及围绕y轴旋转。

施加的力产生的效果比转换和旋转 Transform 组件更强,因此您可能需要微调Inspector窗格中的MoveSpeedRotateSpeed变量。现在,您已经重新创建了与之前相同类型的运动方案,只是使用了更真实的物理。

如果您跑上斜坡或从中央平台掉下来,您可能会看到玩家跳入空中,或者缓慢落到地面上。即使刚体组件设置为使用重力,它也相当弱。当我们实现跳跃机制时,我们将在下一章中处理将重力应用于玩家。现在,您的工作是熟悉 Unity 中 Collider 组件如何处理碰撞。

碰撞体和碰撞

碰撞体组件不仅允许 Unity 的物理系统识别游戏对象,还使交互和碰撞成为可能。将碰撞体想象成围绕游戏对象的无形力场;它们可以根据其设置被穿过或撞击,并且在不同的交互过程中会执行一系列方法。

Unity 的物理系统对 2D 和 3D 游戏有不同的工作方式,因此我们只会在本书中涵盖 3D 主题。如果你对制作 2D 游戏感兴趣,请参考docs.unity3d.com/Manual/class-Rigidbody2D.html中的Rigidbody2D组件以及docs.unity3d.com/Manual/Collider2D.html中可用的 2D 碰撞体列表。

看一下Health_Pickup对象中Capsule的以下屏幕截图。如果你想更清楚地看到胶囊碰撞体,增加半径属性:

图 7.8:附加到拾取物品的胶囊碰撞体组件

对象周围的绿色形状是胶囊碰撞体,可以使用中心半径高度属性进行移动和缩放。

创建一个原始对象时,默认情况下,碰撞体与原始对象的形状匹配;因为我们创建了一个胶囊原始对象,它带有一个胶囊碰撞体。

碰撞体还有盒形球形网格形状,并且可以从组件 | 物理菜单或检视器中的添加组件按钮手动添加。

当碰撞体与其他组件接触时,它会发送所谓的消息或广播。任何添加了这些方法中的一个或多个的脚本都会在碰撞体发送消息时收到通知。这被称为事件,我们将在第十四章 旅程继续中更详细地讨论这个主题。

例如,当两个带有碰撞体的游戏对象接触时,两个对象都会注册一个OnCollisionEnter事件,并附带对它们碰到的对象的引用。想象一下事件就像发送出的消息-如果你选择监听它,你会在这种情况下得到碰撞发生时的通知。这些信息可以用来跟踪各种交互事件,但最简单的是拾取物品。对于希望对象能够穿过其他对象的情况,可以使用碰撞触发器,我们将在下一节讨论。

可以在docs.unity3d.com/ScriptReference/Collider.html消息标题下找到碰撞体通知的完整列表。

只有当碰撞的对象属于特定的碰撞体、触发器和刚体组件的组合以及动力学或非动力学运动时,才会发送碰撞和触发事件。你可以在docs.unity3d.com/Manual/CollidersOverview.html碰撞动作矩阵部分找到详细信息。

你之前创建的生命值物品是一个测试碰撞如何工作的完美场所。你将在下一个挑战中解决这个问题。

拾取物品

要使用碰撞逻辑更新Health_Pickup对象,需要执行以下操作:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为ItemBehavior,然后将其拖放到层次结构面板中的Health_Pickup对象上:
  • 任何使用碰撞检测的脚本必须附加到带有碰撞体组件的游戏对象上,即使它是预制体的子对象。
  1. 层次结构面板中选择Health_Pickup,点击检视器右侧项目行为(脚本)组件旁边的三个垂直点图标,并选择添加组件 | 应用于预制体'Health_Pickup'

图 7.9:将预制体更改应用到拾取物品

  1. ItemBehavior中的默认代码替换为以下内容,然后保存:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ItemBehavior : MonoBehaviour 
{
    **// 1**
    void OnCollisionEnter(Collision collision)
    {
        **// 2**
        if(collision.gameObject.name == "Player")
        {
            **// 3**
            Destroy(this.transform.gameObject);
            **// 4**
            Debug.Log("Item collected!");
        }
    }
} 
  1. 点击播放并将玩家移动到胶囊体上以拾取它!

以下是前面代码的详细说明:

  1. 当另一个对象碰到Item预制件时,Unity 会自动调用OnCollisionEnter方法:
  • OnCollisionEnter带有一个参数,用于存储撞到它的碰撞体的引用。

  • 注意,碰撞的类型是Collision,而不是Collider

  1. Collision类有一个名为gameObject的属性,它保存着与碰撞的游戏对象的碰撞体的引用:
  • 我们可以使用这个属性来获取游戏对象的名称,并使用if语句来检查碰撞对象是否为玩家。
  1. 如果碰撞对象是玩家,我们将调用Destroy()方法,该方法接受一个游戏对象参数并从场景中移除该对象。

  2. 然后,它会在控制台上打印出一个简单的日志,说明我们已经收集了一个物品:

图 7.10:游戏对象被从场景中删除的示例

我们已经设置了ItemBehavior来监听与Health_Pickup对象预制件的任何碰撞。每当发生碰撞时,ItemBehavior使用OnCollisionEnter()并检查碰撞对象是否为玩家,如果是,则销毁(或收集)该物品。

如果你感到迷茫,可以将我们编写的碰撞代码视为Health_Pickup的通知接收器;每当它被击中时,代码就会触发。

还需要理解的是,我们可以创建一个类似的脚本,其中包含一个OnCollisionEnter()方法,将其附加到玩家上,然后检查碰撞对象是否为Health_Pickup预制件。碰撞逻辑取决于被碰撞对象的视角。

现在的问题是,如何设置碰撞而不会阻止碰撞对象相互穿过?我们将在下一节中解决这个问题。

使用碰撞体触发器

默认情况下,碰撞体的isTrigger属性未选中,这意味着物理系统将其视为实体对象,并在碰撞时触发碰撞事件。然而,在某些情况下,你可能希望能够通过碰撞体组件而不会停止你的游戏对象。这就是触发器的作用。勾选isTrigger后,游戏对象可以穿过它,但碰撞体将发送OnTriggerEnterOnTriggerExitOnTriggerStay通知。

当你需要检测游戏对象进入特定区域或通过特定点时,触发器是最有用的。我们将使用它来设置围绕我们敌人的区域;如果玩家走进触发区域,敌人将受到警报,并且稍后会攻击玩家。现在,你将专注于以下挑战中的敌人逻辑。

创建一个敌人

使用以下步骤创建一个敌人:

  1. 层次结构面板中使用+ | 3D 对象 | 胶囊体创建一个新的原语,并将其命名为Enemy

  2. Materials文件夹中,使用+ | Material,命名为Enemy_Mat,并将其Albedo属性设置为鲜艳的红色:

  • Enemy_Mat拖放到Enemy游戏对象中。
  1. 选择Enemy,点击添加组件,搜索Sphere Collider,然后按Enter添加:
  • 勾选isTrigger属性框,并将Radius更改为8

图 7.11:附加到敌人对象的球体碰撞器组件

我们的新Enemy原语现在被一个 8 单位的球形触发半径所包围。每当另一个对象进入、停留在内部或离开该区域时,Unity 都会发送通知,我们可以捕获,就像我们处理碰撞时那样。你下一个挑战将是捕获该通知并在代码中对其进行操作。

要捕获触发器事件,需要按照以下步骤创建一个新的脚本:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为EnemyBehavior,然后将其拖放到Enemy中。

  2. 添加以下代码并保存文件:

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

public class EnemyBehavior : MonoBehaviour 
{
    **// 1**
    void OnTriggerEnter(Collider other)
    {
        **//2** 
        if(other.name == "Player")
        {
            Debug.Log("Player detected - attack!");
        }
    }
    **// 3**
    void OnTriggerExit(Collider other)
    {
        **// 4**
        if(other.name == "Player")
        {
            Debug.Log("Player out of range, resume patrol");
        }
    }
} 
  1. 点击播放并走到敌人旁边以触发第一个通知,然后走开以触发第二个通知。

以下是前面代码的详细说明:

  1. 当一个对象进入敌人球形碰撞体半径时,会触发OnTriggerEnter()
  • OnCollisionEnter()类似,OnTriggerEnter()存储了侵入对象的碰撞体组件的引用。

  • 请注意,otherCollider类型,而不是Collision类型。

  1. 我们可以使用other来访问碰撞游戏对象的名称,并使用if语句检查它是否是Player。如果是,控制台会打印出一个日志,说明Player处于危险区域。

图 7.12:玩家和敌人对象之间的碰撞检测

  1. 当一个对象离开敌人球形碰撞体半径时,会触发OnTriggerExit()
  • 这种方法还有一个引用到碰撞对象的碰撞体组件:
  1. 我们使用另一个if语句通过名称检查离开球形碰撞体半径的对象:
  • 如果是Player,我们会在控制台打印出另一个日志,说明他们是安全的!

图 7.13:碰撞触发器的示例

我们敌人的球形碰撞体在其区域被入侵时发送通知,而EnemyBehavior脚本捕获了其中的两个事件。每当玩家进入或离开碰撞半径时,控制台中会出现调试日志,以告诉我们代码正在运行。我们将在第九章“基本 AI 和敌人行为”中继续构建这一点。

Unity 使用了一种叫做组件设计模式的东西。不详细讨论,这是一种说对象(以及其类)应该负责其行为而不是将所有代码放在一个巨大文件中的花哨方式。这就是为什么我们在拾取物品和敌人上分别放置了单独的碰撞脚本,而不是让一个类处理所有事情。我们将在第十四章“旅程继续”中进一步讨论这个问题。

由于本书的目标是尽可能灌输良好的编程习惯,本章的最后一个任务是确保所有核心对象都转换为预制体。

英雄的试炼-所有的预制体!

为了让项目准备好迎接下一章,继续将PlayerEnemy对象拖入Prefabs文件夹中。请记住,从现在开始,您总是需要右键单击Hierarchy面板中的预制体,然后选择Added Component | Apply to Prefab来巩固对这些游戏对象所做更改。

完成后,继续到物理学总结部分,确保在继续之前已经内化了我们所涵盖的所有主要主题。

物理学总结

在我们结束本章之前,这里有一些高层概念,以巩固我们到目前为止所学到的内容:

  • 刚体组件为附加到其上的游戏对象添加了模拟真实世界的物理效果。

  • 碰撞体组件与刚体组件以及对象进行交互:

  • 如果碰撞体组件不是一个触发器,它就会作为一个实体对象。

  • 如果碰撞体组件是一个触发器,它可以被穿过。

  • 如果一个对象使用了刚体组件并且勾选了“Is Kinematic”,告诉物理系统忽略它,那么它就是运动学的。

  • 如果一个对象使用了刚体组件并施加了力或扭矩来驱动其运动和旋转,那么它就是非运动学的。

  • 碰撞体根据它们的交互发送通知。这些通知取决于碰撞体组件是否设置为触发器。通知可以从任一碰撞方接收,并且它们带有引用变量,保存了对象的碰撞信息。

请记住,像 Unity 物理系统这样广泛而复杂的主题不是一天就能学会的。将您在这里学到的知识作为一个跳板,让自己进入更复杂的主题!

总结

这结束了你第一次创建独立游戏行为并将它们整合成一个连贯但简单的游戏原型的经历。你已经使用向量和基本的向量数学来确定 3D 空间中的位置和角度,并且你熟悉玩家输入以及移动和旋转游戏对象的两种主要方法。你甚至深入了解了 Unity 物理系统的刚体物理、碰撞、触发器和事件通知。总的来说,《英雄诞生》有了一个很好的开端。

在下一章中,我们将开始解决更多的游戏机制,包括跳跃、冲刺、发射抛射物以及与环境的交互。这将让你更多地实践使用刚体组件的力量、收集玩家输入,并根据所需的情景执行逻辑。

小测验 - 玩家控制和物理

  1. 你会使用什么数据类型来存储 3D 移动和旋转信息?

  2. Unity 内置的哪个组件允许你跟踪和修改玩家控制?

  3. 哪个组件可以给游戏对象添加真实世界的物理效果?

  4. Unity 建议使用什么方法来执行游戏对象上与物理相关的代码?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过“问我任何事”会话与作者交流等等。

立即加入!

packt.link/csharpunity2021

第八章:脚本化游戏机制

在上一章中,我们专注于使用代码移动玩家和摄像机,并在旁边进行了 Unity 物理的探索。然而,控制可玩角色并不足以制作一个引人入胜的游戏;事实上,这可能是在不同游戏中保持相对恒定的一个领域。

游戏的独特魅力来自其核心机制,以及这些机制赋予玩家的力量和代理感。如果没有有趣和引人入胜的方式来影响你所创建的虚拟环境,你的游戏就没有重复玩的机会,更不用说有趣了。当我们着手实现游戏机制时,我们也将提升我们对 C#及其中级特性的了解。

本章将在英雄诞生原型的基础上,重点关注单独实现的游戏机制,以及系统设计和用户界面(UI)。你将深入以下主题:

  • 添加跳跃

  • 射击抛射物

  • 创建游戏管理器

  • 创建 GUI

添加跳跃

还记得上一章中 Rigidbody 组件为游戏对象添加了模拟真实世界物理,Collider 组件使用 Rigidbody 对象相互交互的内容。

我们在上一章没有讨论的另一个很棒的事情是,使用 Rigidbody 组件来控制玩家移动,我们可以很容易地添加依赖于施加力的不同机制,比如跳跃。在本节中,我们将让玩家跳跃,并编写我们的第一个实用函数。

实用函数是执行某种繁重工作的类方法,这样我们就不会在游戏代码中弄乱了——比如,想要检查玩家胶囊是否接触地面以进行跳跃。

在此之前,你需要熟悉一种称为枚举的新数据类型,你将在下一节中进行。

引入枚举

根据定义,枚举类型是属于同一变量的一组或集合命名常量。当你想要一组不同值的集合,但又希望它们都属于相同的父类型时,这些是很有用的。

枚举更容易通过展示而不是告诉来理解,所以让我们看一下以下代码片段中它们的语法。

enum PlayerAction { Attack, Defend, Flee }; 

让我们来分解一下它是如何工作的,如下所示:

  • enum关键字声明了类型,后面跟着变量名。

  • 枚举可以具有的不同值写在花括号内,用逗号分隔(最后一项除外)。

  • enum必须以分号结尾,就像我们处理过的所有其他数据类型一样。

在这种情况下,我们声明了一个名为PlayerAction的变量,类型为enum,可以设置为三个值之一——AttackDefendFlee

要声明一个枚举变量,我们使用以下语法:

PlayerAction CurrentAction = PlayerAction.Defend; 

同样,我们可以将其分解如下:

  • 类型设置为PlayerAction,因为我们的枚举就像任何其他类型一样,比如字符串或整数。

  • 变量名为currentAction,设置为PlayerAction值。

  • 每个枚举常量都可以使用点表示法访问。

我们的currentAction变量现在设置为Defend,但随时可以更改为AttackFlee

枚举乍看起来可能很简单,但在适当的情况下它们非常强大。它们最有用的特性之一是能够存储底层类型,这也是你将要学习的下一个主题。

底层类型

枚举带有底层类型,意味着花括号内的每个常量都有一个关联值。默认的底层类型是int,从 0 开始,就像数组一样,每个连续的常量都得到下一个最高的数字。

并非所有类型都是平等的——枚举的底层类型限制为bytesbyteshortushortintuintlongulong。这些被称为整数类型,用于指定变量可以存储的数值的大小。

这对于本书来说有点高级,但在大多数情况下,您将使用int。有关这些类型的更多信息可以在这里找到:docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/enum

例如,我们的PlayerAction枚举值现在列出如下,尽管它们没有明确写出:

enum PlayerAction { Attack = 0, Defend = 1, Flee = 2 }; 

没有规定基础值需要从0开始;实际上,您只需要指定第一个值,然后 C#会为我们递增其余的值,如下面的代码片段所示:

enum PlayerAction { Attack = 5, Defend, Flee }; 

在上面的示例中,Defend等于6Flee等于7。但是,如果我们希望PlayerAction枚举包含非连续的值,我们可以显式添加它们,就像这样:

enum PlayerAction { Attack = 10, Defend = 5, Flee = 0}; 

我们甚至可以通过在枚举名称后添加冒号来将PlayerAction的基础类型更改为任何经批准的类型,如下所示:

enum PlayerAction :  **byte** { Attack, Defend, Flee }; 

检索枚举的基础类型需要显式转换,但我们已经涵盖了这些内容,所以下面的语法不应该让人感到意外:

enum PlayerAction { Attack = 10, Defend = 5, Flee = 0};
PlayerAction CurrentAction = PlayerAction.Attack;
**int** ActionCost = **(****int****)**CurrentAction; 

由于CurrentAction设置为Attack,在上面的示例代码中,ActionCost将是10

枚举是您编程工具中非常强大的工具。您下一个挑战是利用您对枚举的了解,从键盘上收集更具体的用户输入。

现在我们已经基本掌握了枚举类型,我们可以使用KeyCode枚举来捕获键盘输入。更新PlayerBehavior脚本,添加以下突出显示的代码,保存并点击播放:

public class PlayerBehavior : MonoBehaviour 
{
    // ... No other variable changes needed ...

    **// 1**
    **public****float** **JumpVelocity =** **5f****;**
    **private****bool** **_isJumping;**

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

    void Update()
    {
        **// 2**
        **_isJumping |= Input.GetKeyDown(KeyCode.Space);**
        // ... No other changes needed ...
    }

    void FixedUpdate()
    {
        **// 3**
        **if****(_isJumping)**
        **{**
            **// 4**
            **_rb.AddForce(Vector3.up * JumpVelocity, ForceMode.Impulse);**
        **}**
        **// 5**
        **_isJumping =** **false****;**
        // ... No other changes needed ...
    }
} 

让我们来分解这段代码:

  1. 首先,我们创建两个新变量——一个公共变量来保存我们想要应用的跳跃力量的数量,一个私有布尔变量来检查我们的玩家是否应该跳跃。

  2. 我们将_isJumping的值设置为Input.GetKeyDown()方法,根据指定的键是否被按下返回一个bool值。

  • 我们使用|=运算符来设置_isJumping,这是逻辑条件。该运算符确保当玩家跳跃时,连续的输入检查不会互相覆盖。

  • 该方法接受一个键参数,可以是stringKeyCode,它是一个枚举类型。我们指定要检查KeyCode.Space

FixedUpdate中检查输入有时会导致输入丢失,甚至会导致双重输入,因为它不是每帧运行一次。这就是为什么我们在Update中检查输入,然后在FixedUpdate中应用力或设置速度。

  1. 我们使用if语句来检查_isJumping是否为真,并在其为真时触发跳跃机制。

  2. 由于我们已经存储了 Rigidbody 组件,我们可以将Vector3ForceMode参数传递给RigidBody.AddForce(),使玩家跳跃。

  • 我们指定向量(或应用的力)应该是“上”方向,乘以JumpVelocity

  • ForceMode参数确定了如何应用力,并且也是一个枚举类型。Impulse会立即对物体施加力,同时考虑其质量,这非常适合跳跃机制。

其他ForceMode选择在不同情况下可能会有用,所有这些都在这里详细说明:docs.unity3d.com/ScriptReference/ForceMode.html

  1. 在每个FixedUpdate帧的末尾,我们将_isJumping重置为 false,以便输入检查知道完成了一次跳跃和着陆循环。

如果您现在玩游戏,您将能够在按下空格键时移动和跳跃。但是,该机制允许您无限跳跃,这不是我们想要的。在下一节中,我们将通过使用称为层蒙版的东西来限制我们的跳跃机制一次跳跃。

使用层蒙版

将图层蒙版视为游戏对象可以属于的不可见组,由物理系统用于确定从导航到相交碰撞器组件的任何内容。虽然图层蒙版的更高级用法超出了本书的范围,但我们将创建并使用一个来执行一个简单的检查——玩家胶囊是否接触地面,以限制玩家一次只能跳一次。

在我们检查玩家胶囊是否接触地面之前,我们需要将我们级别中的所有环境对象添加到一个自定义图层蒙版中。这将让我们执行与已经附加到玩家的胶囊碰撞体组件的实际碰撞计算。操作如下:

  1. 层次结构中选择任何环境游戏对象,并在相应的检视器窗格中,单击 | 添加图层...,如下截图所示:

图 8.1:在检视器窗格中选择图层

  1. 通过在第一个可用的槽中输入名称来添加一个名为Ground的新图层,该槽是第 6 层。尽管第 3 层为空,但层 0-5 保留给 Unity 的默认层,如下截图所示:

图 8.2:在检视器窗格中添加图层

  1. 在“层次结构”中选择环境父游戏对象,单击下拉菜单,然后选择Ground

图 8.3:设置自定义图层

在选择了下图中显示的Ground选项后,当出现对话框询问是否要更改所有子对象时,单击是,更改子对象。在这里,您已经定义了一个名为Ground的新图层,并将环境的每个子对象分配到该图层。

从现在开始,所有Ground图层上的对象都可以被检查是否与特定对象相交。您将在接下来的挑战中使用这个功能,以确保玩家只能在地面上执行跳跃;这里没有无限跳跃的作弊。

由于我们不希望代码混乱Update()方法,我们将在实用函数中进行图层蒙版计算,并根据结果返回truefalse值。操作如下:

  1. 将以下突出显示的代码添加到PlayerBehavior中,然后再次播放场景:
public class PlayerBehavior : MonoBehaviour 
{
    **// 1**
    **public****float** **DistanceToGround =** **0.1f****;**
    **// 2** 
    **public** **LayerMask GroundLayer;**
    **// 3**
    **private** **CapsuleCollider _col;**
    // ... No other variable changes needed ...

    void Start()
    {
        _rb = GetComponent<Rigidbody>();

        **// 4**
        **_col = GetComponent<CapsuleCollider>();**
    }

    void Update()
    {
        // ... No changes needed ...
    }

    void FixedUpdate()
    {
        **// 5**
        if(**IsGrounded() &&** _isJumping)
        {
            _rb.AddForce(Vector3.up * JumpVelocity,
                 ForceMode.Impulse);
         }
         // ... No other changes needed ...
    }

    **// 6**
    **private****bool****IsGrounded****()**
    **{**
        **// 7**
        **Vector3 capsuleBottom =** **new** **Vector3(_col.bounds.center.x,**
             **_col.bounds.min.y, _col.bounds.center.z);**

        **// 8**
        **bool** **grounded = Physics.CheckCapsule(_col.bounds.center,**
            **capsuleBottom, DistanceToGround, GroundLayer,**
               **QueryTriggerInteraction.Ignore);**

        **// 9**
        **return** **grounded;**
    **}**
**}** 
  1. 选择PlayerBehavior脚本,将检视器窗格中的Ground Layer设置为Ground,从Ground Layer下拉菜单中选择,如下截图所示:

图 8.4:设置地面图层

让我们按照以下方式分解前面的代码:

  1. 我们为将检查玩家胶囊碰撞体与任何Ground Layer对象之间的距离创建一个新变量。

  2. 我们创建一个LayerMask变量,可以在检视器中设置,并用于碰撞体检测。

  3. 我们创建一个变量来存储玩家的胶囊碰撞体组件。

  4. 我们使用GetComponent()来查找并返回附加到玩家的胶囊碰撞体。

  5. 我们更新if语句,以检查IsGrounded是否返回true并且在执行跳跃代码之前按下了空格键。

  6. 我们声明了IsGrounded()方法,返回类型为bool

  7. 我们创建一个本地的Vector3变量来存储玩家胶囊碰撞体底部的位置,我们将用它来检查与Ground图层上的任何对象的碰撞。

  • 所有碰撞体组件都有一个bounds属性,它使我们可以访问其xyz轴的最小、最大和中心位置。

  • 碰撞体的底部是 3D 点,在中心x,最小y和中心z

  1. 我们创建一个本地的bool来存储我们从Physics类中调用的CheckCapsule()方法的结果,该方法接受以下五个参数:
  • 胶囊的开始,我们将其设置为胶囊碰撞体的中间,因为我们只关心底部是否接触地面。

  • 胶囊的末端,即我们已经计算过的capsuleBottom位置。

  • 胶囊体的半径,即已设置的DistanceToGround

  • 我们要检查碰撞的图层蒙版,设置为检视器中的GroundLayer

  • 查询触发交互,确定方法是否应忽略设置为触发器的碰撞体。由于我们想要忽略所有触发器,我们使用了QueryTriggerInteraction.Ignore枚举。

我们还可以使用Vector3类的Distance方法来确定我们离地面有多远,因为我们知道玩家胶囊的高度。然而,我们将继续使用Physics类,因为这是本章的重点。

  1. 我们在计算结束时返回存储在grounded中的值。

我们本可以手动进行碰撞计算,但那将需要比我们在这里有时间涵盖的更复杂的 3D 数学。然而,使用内置方法总是一个好主意。

我们刚刚在PlayerBehavior中添加的代码是一个复杂的代码片段,但是当你分解它时,我们做的唯一新的事情就是使用了Physics类的一个方法。简单来说,我们向CheckCapsule()提供了起始点和终点、碰撞半径和图层蒙版。如果终点距离图层蒙版上的对象的碰撞半径更近,该方法将返回true——这意味着玩家正在接触地面。如果玩家处于跳跃中的位置,CheckCapsule()将返回false

由于我们在Update()中的if语句中每帧检查IsGround,所以我们的玩家的跳跃技能只有在接触地面时才允许。

这就是你要用跳跃机制做的一切,但玩家仍然需要一种方式来与并最终占领竞技场的敌人进行互动和自卫。在接下来的部分,你将通过实现一个简单的射击机制来填补这个空白。

发射抛射物

射击机制是如此普遍,以至于很难想象一个没有某种变化的第一人称游戏,Hero Born也不例外。在本节中,我们将讨论如何在游戏运行时从预制件中实例化游戏对象,并使用我们学到的技能来利用 Unity 物理学将它们向前推进。

实例化对象

在游戏中实例化一个游戏对象的概念类似于实例化一个类的实例——都需要起始值,以便 C#知道我们要创建什么类型的对象以及需要在哪里创建它。为了在运行时在场景中创建对象,我们使用Instantiate()方法,并提供一个预制对象、一个起始位置和一个起始旋转。

基本上,我们可以告诉 Unity 在这个位置创建一个给定的对象,带有所有的组件和脚本,朝着这个方向,然后一旦它在 3D 空间中诞生,就可以根据需要对其进行操作。在我们实例化一个对象之前,你需要创建对象的预制本身,这是你的下一个任务。

在我们射击任何抛射物之前,我们需要一个预制件作为参考,所以现在让我们创建它,如下所示:

  1. 层次结构面板中选择+ | 3D 对象 | 球体,并将其命名为Bullet
  • Transform组件中将其比例xyz轴上更改为 0.15。
  1. 检视器中选择Bullet,并在底部使用添加组件按钮搜索并添加一个刚体组件,将所有默认属性保持不变。

  2. 材质文件夹中使用创建 | 材质创建一个新的材质,并将其命名为Bullet_Mat

  • Albedo属性更改为深黄色。

  • 层次结构面板中,将材质文件夹中的材质拖放到Bullet游戏对象上。

图 8.5:设置抛射物属性

  1. 层次结构面板中选择Bullet,并将其拖放到项目面板中的预制件文件夹中。然后,从层次结构中删除它以清理场景:

图 8.6:创建一个抛射物预制件

您创建并配置了一个可以根据需要在游戏中实例化多次并根据需要更新的Bullet预制体游戏对象。这意味着您已经准备好迎接下一个挑战——射击抛射物。

添加射击机制

现在我们有一个预制体对象可以使用,我们可以在按下鼠标左键时实例化并移动预制体的副本,以创建射击机制,如下所示:

  1. 使用以下代码更新PlayerBehavior脚本:
public class PlayerBehavior : MonoBehaviour 
{
    **// 1**
    **public** **GameObject Bullet;**
    **public****float** **BulletSpeed =** **100f****;**

    **// 2**
    **private****bool** **_isShooting**;

    // ... No other variable changes needed ...

    void Start()
    {
        // ... No changes needed ...
    }

    void Update()
    {
        **// 3**
        **_isShooting |= Input.GetMouseButtonDown(****0****);**
        // ... No other changes needed ...
    }

    void FixedUpdate()
    {
        // ... No other changes needed ...

        **// 4**
        **if** **(_isShooting)**
        **{**
            **// 5**
            **GameObject newBullet = Instantiate(Bullet,**
                **this****.transform.position +** **new** **Vector3(****1****,** **0****,** **0****),**
                   **this****.transform.rotation);**
            **// 6**
            **Rigidbody BulletRB =** 
                 **newBullet.GetComponent<Rigidbody>();**

            **// 7**
            **BulletRB.velocity =** **this****.transform.forward *** 
                                            **BulletSpeed;**
        **}**
        **// 8**
        **_isShooting =** **false****;**
    }

    private bool IsGrounded()
    {
        // ... No changes needed ...
    }
} 
  1. 检查器中,将Bullet预制体从项目面板拖放到PlayerBehaviorBullet属性中,如下截图所示:

图 8.7:设置子弹预制体

  1. 玩游戏,并使用鼠标左键向玩家面对的方向发射抛射物!

让我们来分解这段代码,如下所示:

  1. 我们创建了两个变量:一个用于存储子弹预制体,另一个用于保存子弹速度。

  2. 像我们的跳跃机制一样,我们在Update方法中使用布尔值来检查我们的玩家是否应该射击。

  3. 我们使用or逻辑运算符和Input.GetMouseButtonDown()来设置_isShooting的值,如果我们按下指定的按钮,则返回true,就像使用Input.GetKeyDown()一样。

  • GetMouseButtonDown()接受一个int参数来确定我们要检查哪个鼠标按钮;0是左键,1是右键,2是中间按钮或滚轮。
  1. 然后我们检查我们的玩家是否应该使用_isShooting输入检查变量进行射击。

  2. 每次按下鼠标左键时,我们创建一个本地的 GameObject 变量:

  • 我们使用Instantiate()方法通过传入Bullet预制体来为newBullet分配一个 GameObject。我们还使用玩家胶囊体的位置将新的Bullet预制体放在玩家前面,以避免任何碰撞。

  • 我们将其附加为GameObject,以将返回的对象显式转换为与newBullet相同类型的对象,这种情况下是一个 GameObject。

  1. 我们调用GetComponent()来返回并存储newBullet上的 Rigidbody 组件。

  2. 我们将 Rigidbody 组件的velocity属性设置为玩家的transform.forward方向乘以BulletSpeed

  • 改变velocity而不是使用AddForce()确保我们的子弹在被射出时不会被重力拉成弧线。
  1. 最后,我们将_isShooting的值设置为false,这样我们的射击输入就会为下一个输入事件重置。

再次,您显著升级了玩家脚本正在使用的逻辑。现在,您应该能够使用鼠标射击抛射物,这些抛射物直线飞出玩家的位置。

然而,现在的问题是,您的游戏场景和层次结构中充斥着已使用的子弹对象。您的下一个任务是在它们被发射后清理这些对象,以避免任何性能问题。

管理对象的积累

无论您是编写完全基于代码的应用程序还是 3D 游戏,都很重要确保定期删除未使用的对象,以避免过载程序。我们的子弹在被射出后并不起重要作用;它们只是继续存在于靠近它们碰撞的墙壁或物体附近的地板上。

对于这样的射击机制,这可能导致成百上千甚至数千颗子弹,这是我们不想要的。你的下一个挑战是在设定延迟时间后销毁每颗子弹。

对于这个任务,我们可以利用已经学到的技能,让子弹自己负责其自毁行为,如下所示:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为BulletBehavior

  2. BulletBehavior脚本拖放到Prefabs文件夹中的Bullet预制体上,并添加以下代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BulletBehavior : MonoBehaviour 
{
    // 1
    public float OnscreenDelay = 3f;

    void Start () 
    {
        // 2
        Destroy(this.gameObject, OnscreenDelay);
    }
} 

让我们来分解这段代码,如下所示:

  1. 我们声明一个float变量来存储我们希望子弹预制体在被实例化后场景中保留多长时间。

  2. 我们使用Destroy()方法来删除 GameObject。

  • Destroy()总是需要一个对象作为参数。在这种情况下,我们使用this关键字来指定脚本所附加的对象。

  • Destroy()可以选择以额外的float参数作为延迟,我们用它来让子弹在屏幕上停留一小段时间。

再次玩游戏,射击一些子弹,观察它们在特定延迟后自动从层次结构中删除。这意味着子弹执行了其定义的行为,而不需要另一个脚本告诉它该做什么,这是组件设计模式的理想应用。

现在我们的清理工作已经完成,你将学习到任何精心设计和组织的项目中的一个关键组件——管理器类。

创建游戏管理器

在学习编程时一个常见的误解是所有变量都应该自动设为公共的,但一般来说,这不是一个好主意。根据我的经验,变量应该从一开始就被视为受保护和私有的,只有在必要时才设为公共的。你会看到有经验的程序员通过管理器类来保护他们的数据,因为我们想养成良好的习惯,所以我们也会这样做。把管理器类看作一个漏斗,重要的变量和方法可以安全地被访问。

当我说安全时,我的意思就是这样,这在编程环境中可能看起来不熟悉。然而,当你有不同的类相互通信和更新数据时,情况可能会变得混乱。这就是为什么有一个单一的联系点,比如一个管理器类,可以将这种情况降到最低。我们将在下一节中学习如何有效地做到这一点。

跟踪玩家属性

英雄诞生是一个简单的游戏,所以我们需要跟踪的唯一两个数据点是玩家收集了多少物品和剩余多少生命值。我们希望这些变量是私有的,这样它们只能从管理器类中修改,给我们控制和安全性。你的下一个挑战是为英雄诞生创建一个游戏管理器,并为其添加有用的功能。

游戏管理器类将是你未来开发的任何项目中的一个不变的组成部分,所以让我们学习如何正确地创建一个,如下所示:

  1. Scripts文件夹中创建一个新的 C#脚本,并命名为GameBehavior

通常这个脚本会被命名为GameManager,但 Unity 保留了这个名称用于自己的脚本。如果你创建了一个脚本,而其名称旁边出现了齿轮图标而不是 C#文件图标,那就表示它是受限制的。

  1. 使用+ | 创建空对象层次结构中创建一个新的空游戏对象,并命名为Game_Manager

  2. Scripts文件夹中将GameBehavior.cs脚本拖放到Game_Manager对象上,如下截图所示:

图 8.8:附加游戏管理器脚本

管理器脚本和其他非游戏文件被设置在空对象上,尽管它们不与实际的 3D 空间交互。

  1. 将以下代码添加到GameBehavior.cs中:
public class GameBehavior : MonoBehaviour 
{
    private int _itemsCollected = 0;
    private int _playerHP = 10;
} 

让我们来分解这段代码。我们添加了两个新的private变量来保存捡起的物品数量和玩家剩余的生命值;这些是private的,因为它们只能在这个类中被修改。如果它们被设为public,其他类可以随意改变它们,这可能导致变量存储不正确或并发数据。

将这些变量声明为private意味着你有责任控制它们的访问。下一个关于getset属性的主题将向你介绍一种标准、安全的方法来完成这项任务。

获取和设置属性

我们已经设置好了管理器脚本和私有变量,但如果它们是私有的,我们如何从其他类中访问它们呢?虽然我们可以在GameBehavior中编写单独的公共方法来处理将新值传递给私有变量,但让我们看看是否有更好的方法来做这些事情。

在这种情况下,C#为所有变量提供了getset属性,这非常适合我们的任务。将这些视为方法,无论我们是否显式调用它们,C#编译器都会自动触发它们,类似于 Unity 在场景启动时执行Start()Update()

getset属性可以添加到任何变量中,无论是否有初始值,如下面的代码片段所示:

public string FirstName { get; set; };
// OR
public string LastName { get; set; } = "Smith"; 

然而,像这样使用它们并没有添加任何额外的好处;为此,您需要为每个属性包括一个代码块,如下面的代码片段所示:

public string FirstName
{
    get {
        // Code block executes when variable is accessed
    }
    set {
        // Code block executes when variable is updated
    }
} 

现在,getset属性已经设置好,可以根据需要执行额外的逻辑。然而,我们还没有完成,因为我们仍然需要处理新逻辑。

每个get代码块都需要返回一个值,而每个set代码块都需要

分配一个值;这就是拥有一个私有变量(称为支持变量)和具有getset属性的公共变量的组合发挥作用的地方。私有变量保持受保护状态,而公共变量允许从其他类进行受控访问,如下面的代码片段所示:

private string _firstName
public string FirstName {
    get { 
        **return** _firstName;
    }
    set {
        _firstName = **value**;
    }
} 

让我们来分解一下,如下所示:

  • 我们可以使用get属性随时从私有变量中return值,而不实际给予外部类直接访问。

  • 每当外部类分配新值给公共变量时,我们可以随时更新私有变量,使它们保持同步。

  • value关键字是被分配的任何新值的替代品。

如果没有实际应用,这可能看起来有点晦涩,所以让我们使用具有 getter 和 setter 属性的公共变量来更新GameBehavior中的私有变量。

现在我们了解了getset属性访问器的语法,我们可以在我们的管理器类中实现它们,以提高效率和代码可读性。

根据以下方式更新GameBehavior中的代码:

public class GameBehavior : MonoBehaviour 
{
    private int _itemsCollected = 0; 
    private int _playerHP = 10;

    **// 1**
    **public****int** **Items**
    **{**
        **// 2**
        **get** **{** **return** **_itemsCollected; }**
        **// 3**
        **set** **{** 
               **_itemsCollected =** **value****;** 
               **Debug.LogFormat(****"Items: {0}"****, _itemsCollected);**
        **}**
    **}**
    **// 4**
    **public****int** **HP** 
    **{**
        **get** **{** **return** **_playerHP; }**
        **set** **{** 
               **_playerHP =** **value****;** 
               **Debug.LogFormat(****"Lives: {0}"****, _playerHP);**
         **}**
    **}**
} 

让我们来分解一下代码,如下所示:

  1. 我们声明了一个名为Items的新public变量,带有getset属性。

  2. 每当从外部类访问Items时,我们使用get属性来return存储在_itemsCollected中的值。

  3. 我们使用set属性将_itemsCollected分配给Items的新value,每当它更新时,还添加了Debug.LogFormat()调用以打印出_itemsCollected的修改值。

  4. 我们设置了一个名为HPpublic变量,带有getset属性,以补充私有的_playerHP支持变量。

现在,两个私有变量都是可读的,但只能通过它们的公共对应变量进行访问;它们只能在GameBehavior中进行更改。通过这种设置,我们确保我们的私有数据只能从特定的接触点进行访问和修改。这使得我们更容易从其他机械脚本与GameBehavior进行通信,以及在本章末尾创建的简单 UI 中显示实时数据。

让我们通过在竞技场成功与物品拾取交互时更新Items属性来测试一下。

更新物品集合

现在我们在GameBehavior中设置了变量,我们可以在场景中每次收集一个Item时更新Items,如下所示:

  1. 将以下突出显示的代码添加到ItemBehavior脚本中:
public class ItemBehavior : MonoBehaviour 
{
    **// 1**
    **public** **GameBehavior GameManager;**
    **void****Start****()**
    **{**
          **// 2**
          **GameManager = GameObject.Find(****"Game_Manager"****).GetComponent<GameBehavior>();**
    **}**
    void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.name == "Player")
        {
            Destroy(this.transform.parent.gameObject);
            Debug.Log("Item collected!");
            **// 3**
            **GameManager.Items +=** **1****;**
        }
    }
} 
  1. 点击播放并收集拾取物品,以查看经理脚本中的新控制台日志打印输出,如下面的屏幕截图所示:

图 8.9:收集拾取物品

让我们来分解一下代码,如下所示:

  1. 我们创建一个新的GameBehavior类型变量来存储对附加脚本的引用。

  2. 我们使用Start()来通过Find()在场景中查找GameManager并添加一个GetComponent()调用来初始化它。

你会经常在 Unity 文档和社区项目中看到这种代码以一行的形式完成。这是为了简单起见,但如果你更喜欢单独编写Find()GetComponent()调用,那就尽管去做吧;清晰、明确的格式没有错。

  1. OnCollisionEnter()中,在 Item Prefab 被销毁后,我们会在GameManager类中递增Items属性。

由于我们已经设置了ItemBehavior来处理碰撞逻辑,修改OnCollisionEnter()以在玩家拾取物品时与我们的管理类通信变得很容易。请记住,像这样分离功能是使代码更灵活,并且在开发过程中进行更改时不太可能出错的原因。

英雄诞生缺少的最后一部分是一种向玩家显示游戏数据的接口。在编程和游戏开发中,这被称为 UI。本章的最后一个任务是熟悉 Unity 如何创建和处理 UI 代码。

创建 GUI

在这一点上,我们有几个脚本一起工作,让玩家可以移动、跳跃、收集和射击。然而,我们仍然缺少任何一种显示或视觉提示,来显示我们玩家的统计数据,以及赢得和输掉游戏的方法。在我们结束这一节时,我们将专注于这两个主题。

显示玩家统计数据

UI 是任何计算机系统的视觉组件。鼠标光标、文件夹图标和笔记本电脑上的程序都是 UI 元素。对于我们的游戏,我们希望有一个简单的显示,让我们的玩家知道他们收集了多少物品,他们当前的生命值,并且在某些事件发生时给他们更新的文本框。

Unity 中的 UI 元素可以通过以下两种方式添加:

  • 直接从层次结构面板中的+菜单中,就像任何其他 GameObject 一样

  • 使用代码中内置的 GUI 类

我们将坚持第一种选择,因为内置的 GUI 类是 Unity 传统 UI 系统的一部分,我们希望保持最新,对吧?这并不是说你不能通过编程的方式做任何事情,但对于我们的原型来说,更新的 UI 系统更合适。

如果你对 Unity 中的程序化 UI 感兴趣,请自行查看文档:docs.unity3d.com/ScriptReference/GUI.html

你的下一个任务是在游戏场景中添加一个简单的 UI,显示存储在GameBehavior.cs中的已收集物品、玩家生命和进度信息变量。

首先,在我们的场景中创建三个文本对象。Unity 中的用户界面是基于画布的,这正是它的名字。把画布想象成一块空白的画布,你可以在上面绘画,Unity 会在游戏世界的顶部渲染它。每当你在层次结构面板中创建你的第一个 UI 元素时,一个Canvas父对象会与之一起创建。

  1. 层次结构面板中右键单击,选择UI | Text,并将新对象命名为Health。这将同时创建一个Canvas父对象和新的Text对象:

图 8.10:创建一个文本元素

  1. 为了正确查看画布,请在“场景”选项卡顶部选择2D模式。从这个视图中,我们整个级别就是左下角的那条微小的白线。
  • 即使Canvas和级别在场景中不重叠,当游戏运行时 Unity 会自动正确地叠加它们。

图 8.11:Unity 编辑器中的 Canvas

  1. 如果你在“层次结构”中选择Health对象,你会看到默认情况下新的文本对象被创建在画布的左下角,并且它有一整套可定制的属性,比如文本和颜色,在检视器窗格中:

图 8.12:Unity 画布上的文本元素

  1. Hierarchy窗格中选择Health对象,单击检视器Rect Transform组件的Anchor预设,选择左上角
  • 锚点设置了 UI 元素在画布上的参考点,这意味着无论设备屏幕的大小如何,我们的健康点始终锚定在屏幕的左上角!

图 8.13:设置锚点预设

  1. 检视器窗格中,将Rect Transform位置更改为X轴上的100Y轴上的-30,以将文本定位在右上角。还将Text属性更改为Player Health:。我们将在以后的步骤中在代码中设置实际值!

图 8.14:设置文本属性

  1. 重复步骤 1-5 以创建一个新的 UI Text对象,并命名为Items
  • 将锚点预设设置为左上角Pos X设置为100Pos Y设置为-60

  • Text设置为Items Collected:

图 8.15:创建另一个文本元素

  1. 重复步骤 1-5以创建一个新的 UI Text对象,并命名为Progress
  • 将锚点预设设置为底部中心Pos X设置为0Pos Y设置为15Width设置为280

  • Text设置为收集所有物品并赢得你的自由!

图 8.16:创建进度文本元素

现在我们的 UI 已经设置好了,让我们连接已经在游戏管理器脚本中拥有的变量。请按照以下步骤进行:

  1. 使用以下代码更新GameBehavior以收集物品并在屏幕上显示文本:
// 1
using UnityEngine.UI; 
public class GameBehavior : MonoBehaviour 
{
    // 2
    public int MaxItems = 4;
    // 3
    public Text HealthText;     
    public Text ItemText;
    public Text ProgressText;
    // 4
    void Start()
    { 
        ItemText.text += _itemsCollected;
        HealthText.text += _playerHP;
    }
    private int _itemsCollected = 0;
    public int Items
    {
        get { return _itemsCollected; }
        set { 
            _itemsCollected = value; 
            **// 5**
            ItemText.text = "Items Collected: " + Items;
            // 6
            if(_itemsCollected >= MaxItems)
            {
                ProgressText.text = "You've found all the items!";
            } 
            else
            {
                ProgressText.text = "Item found, only " + (MaxItems - _itemsCollected) + " more to go!";
            }
        }
    }

    private int _playerHP = 10;
    public int HP 
    {
        get { return _playerHP; }
        set { 
            _playerHP = value;
            // 7
            HealthText.text = "Player Health: " + HP;
            Debug.LogFormat("Lives: {0}", _playerHP);
        }
    }
} 
  1. Hierarchy中选择Game_Manager,并将我们的三个文本对象依次拖到检视器中的相应GameBehavior脚本字段中:

图 8.17:将文本元素拖到脚本组件

  1. 运行游戏,看看我们新的屏幕 GUI 框,如下截图所示:

图 8.18:在播放模式中测试 UI 元素

让我们来分解代码,如下所示:

  1. 我们添加了UnityEngine.UI命名空间,以便可以访问Text变量类型。

  2. 我们为关卡中物品的最大数量创建了一个新的公共变量。

  3. 我们创建了三个新的Text变量,将它们连接到检视器面板中。

  4. 然后,我们使用Start方法使用+=运算符设置我们的健康和物品文本的初始值。

  5. 每次收集一个物品,我们都会更新ItemTexttext属性,显示更新后的items计数。

  6. 我们在_itemsCollected的设置属性中声明了一个if语句。

  • 如果玩家收集的物品数量大于或等于MaxItems,他们就赢了,ProgressText.text会更新。

  • 否则,ProgressText.text显示还有多少物品可以收集。

  1. 每当玩家的健康受到损害时,我们将在下一章中介绍,我们都会更新HealthTexttext属性,显示新值。

现在玩游戏时,我们的三个 UI 元素显示出了正确的值;当收集一个物品时,ProgressText_itemsCollected计数会更新,如下截图所示:

图 8.19:更新 UI 文本

每个游戏都可以赢得或输掉。在本章的最后一节,您的任务是实现这些条件以及与之相关的 UI。

胜利和失败条件

我们已经实现了核心游戏机制和简单的 UI,但是Hero Born仍然缺少一个重要的游戏设计元素:胜利和失败条件。这些条件将管理玩家如何赢得或输掉游戏,并根据情况执行不同的代码。

回到第六章的游戏文档,使用 Unity 忙碌起来,我们将我们的胜利和失败条件设置如下:

  • 在剩余至少 1 个健康点的情况下收集所有物品以获胜

  • 从敌人那里受到伤害,直到健康点数为 0 为止

这些条件将影响我们的 UI 和游戏机制,但我们已经设置了GameBehavior来有效处理这一点。我们的getset属性将处理任何与游戏相关的逻辑和 UI 更改,当玩家赢得或输掉游戏时。

我们将在本节中实现赢得游戏的逻辑,因为我们已经有了拾取系统。当我们在下一章中处理敌人 AI 行为时,我们将添加失败条件逻辑。您的下一个任务是在代码中确定游戏何时赢得。

我们始终希望给玩家清晰和即时的反馈,因此我们将首先添加赢得游戏的逻辑,如下所示:

  1. 更新GameBehavior以匹配以下代码:
public class GameBehavior : MonoBehaviour 
{ 
    **// 1**
    **public** **Button WinButton;**
    private int _itemsCollected = 0;
    public int Items
    {
        get { return _itemsCollected; }
        set
        {
            _itemsCollected = value;
            ItemText.text = "Items Collected: " + Items;

            if (_itemsCollected >= MaxItems)
            {
                ProgressText.text = "You've found all the items!";

                **// 2**
                **WinButton.gameObject.SetActive(****true****);**
            }
            else
            {
                ProgressText.text = "Item found, only " + (MaxItems - _itemsCollected) + " more to go!";
            }
        }
    }
} 
  1. 右键单击Hierarchy,然后选择UI | Button,然后将其命名为Win Condition
  • 选择Win Condition,将Pos XPos Y设置为0,将Width设置为225,将Height设置为115

图 8.20:创建 UI 按钮

  1. 单击Win Condition按钮右侧的箭头以展开其文本子对象,然后更改文本为You won!

图 8.21:更新按钮文本

  1. 再次选择Win Condition父对象,然后单击Inspector右上角的复选标志。

图 8.22:停用游戏对象

这将在我们赢得游戏之前隐藏按钮:

图 8.23:测试隐藏的 UI 按钮

  1. Hierarchy中选择Game_Manager,然后将Win Condition按钮从Hierarchy拖动到Inspector中的Game Behavior (Script),就像我们在文本对象中所做的那样!

图 8.24:将 UI 按钮拖动到脚本组件上

  1. Inspector中将Max Items更改为1,以测试新屏幕,如下截图所示:

图 8.25:显示赢得游戏的屏幕

让我们来分解代码,如下所示:

  1. 我们创建了一个 UI 按钮变量,以连接到Hierarchy中的Win Condition按钮。

  2. 由于我们在游戏开始时将 Win Condition 按钮设置为隐藏,因此当游戏赢得时,我们会重新激活它。

Max Items设置为1Win按钮将在收集场景中唯一的Pickup_Item时显示出来。目前单击按钮不会产生任何效果,但我们将在下一节中解决这个问题。

使用指令和命名空间暂停和重新开始游戏

目前,我们的赢得条件按预期工作,但玩家仍然可以控制胶囊,并且在游戏结束后没有重新开始游戏的方法。Unity 在Time类中提供了一个名为timeScale的属性,当设置为0时,会冻结游戏场景。但是,要重新开始游戏,我们需要访问一个名为SceneManagement命名空间,这在默认情况下无法从我们的类中访问。

命名空间收集并将一组类分组到特定名称下,以组织大型项目并避免可能共享相同名称的脚本之间的冲突。需要向类中添加using指令才能访问命名空间的类。

从 Unity 创建的所有 C#脚本都带有三个默认的using指令,如下面的代码片段所示:

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

这些允许访问常见的命名空间,但 Unity 和 C#还提供了许多其他可以使用using关键字后跟命名空间名称添加的命名空间。

由于我们的游戏在玩家赢或输时需要暂停和重新开始,这是一个很好的时机来使用默认情况下新的 C#脚本中不包括的命名空间。

  1. 将以下代码添加到GameBehavior并播放:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
**// 1**
**using** **UnityEngine.SceneManagement;**
public class GameBehavior : MonoBehaviour 
{
    // ... No changes needed ...
    private int _itemsCollected = 0;
    public int Items
    {
        get { return _itemsCollected; }
        set { 
            _itemsCollected = value;

            if (_itemsCollected >= MaxItems)
            {
                ProgressText.text = "You've found all the items!";
                WinButton.gameObject.SetActive(true);

                **// 2**
                **Time.timeScale =** **0f****;**
            }
            else
            {
                ProgressText.text= "Item found, only " + (MaxItems – _itemsCollected) + " more to go!";
            }
        }
    }
    **public****void****RestartScene****()**
    **{**
        **// 3**
        **SceneManager.LoadScene(****0****);**
        **// 4**
        **Time.timeScale =** **1f****;**
    **}**

    // ... No other changes needed ...
} 
  1. Hierarchy中选择Win Condition,在Inspector中向下滚动到Button组件的OnClick部分,然后单击加号图标:
  • 每个 UI 按钮都有一个OnClick事件,这意味着您可以将来自脚本的方法分配给在按钮被按下时执行。

  • 您可以在单击按钮时触发多个方法,但在这种情况下我们只需要一个!

图 8.26:按钮的 OnClick 部分

  1. Hierarchy中,将Game_Manager拖放到Runtime下方的插槽中,告诉按钮我们要选择一个来自我们管理器脚本的方法在按钮被按下时触发!

图 8.27:在点击时设置游戏管理器对象

  1. 选择No Function下拉菜单,选择GameBehavior | RestartScene ()来设置我们希望按钮执行的方法!

图 8.28:选择按钮点击的重新启动方法

  1. 转到Window | Rendering | Lighting,并在底部选择Generate Lighting。确保未选择Auto Generate

这一步是必要的,以解决 Unity 重新加载场景时没有任何照明的问题。

图 8.29:Unity 编辑器中的照明面板

让我们来分解代码,如下所示:

  1. 我们使用using关键字添加了SceneManagement命名空间,该命名空间处理所有与场景相关的逻辑,如创建加载场景。

  2. 当显示胜利屏幕时,我们将Time.timeScale设置为0,这将暂停游戏,禁用任何输入或移动。

  3. 我们创建了一个名为RestartScene的新方法,并在单击胜利屏幕按钮时调用LoadScene()

  • LoadScene()int参数形式接受场景索引。

  • 因为我们的项目中只有一个场景,所以我们使用索引0从头开始重新启动游戏。

  1. 我们将Time.timeScale重置为默认值1,以便在场景重新启动时,所有控件和行为都能够再次执行。

现在,当您收集物品并单击胜利屏幕按钮时,关卡将重新开始,所有脚本和组件都将恢复到其原始值,并准备好进行另一轮!

摘要

恭喜!英雄诞生现在是一个可玩的原型。我们实现了跳跃和射击机制,管理了物理碰撞和生成对象,并添加了一些基本的 UI 元素来显示反馈。我们甚至已经实现了玩家赢得比赛时重置关卡的功能。

本章介绍了许多新主题,重要的是要回过头去确保您理解了我们编写的代码中包含了什么。特别注意我们对枚举、getset属性以及命名空间的讨论。从现在开始,随着我们进一步深入 C#语言的可能性,代码将变得更加复杂。

在下一章中,我们将开始着手让我们的敌人游戏对象在我们离得太近时注意到我们的玩家,从而导致一种跟随和射击协议,这将提高我们玩家的赌注。

快速测验-与机械一起工作

  1. 枚举类型的数据存储什么类型的数据?

  2. 您将如何在活动场景中创建预制游戏对象的副本?

  3. 哪些变量属性允许您在引用或修改它们的值时添加功能?

  4. 哪个 Unity 方法显示场景中的所有 UI 对象?

加入我们的 Discord!

与其他用户一起阅读本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读,通过问我任何事会话与作者交流,提出问题,为其他读者提供解决方案,等等。

立即加入!

packt.link/csharpunity2021

第九章:基本 AI 和敌人行为

虚拟场景需要冲突、后果和潜在奖励才能感觉真实。没有这三样东西,玩家就没有动力去关心他们游戏中的角色发生了什么,更不用说继续玩游戏了。虽然有很多游戏机制可以满足这些条件中的一个或多个,但没有什么能比得上一个会寻找你并试图结束你游戏的敌人。

编写一个智能敌人并不容易,并且通常需要长时间的工作和挫折。然而,Unity 内置了我们可以使用的功能、组件和类,以更用户友好的方式设计和实现 AI 系统。这些工具将推动Hero Born的第一个可玩版本完成,并为更高级的 C#主题提供一个跳板。

在本章中,我们将重点关注以下主题:

  • Unity 导航系统

  • 静态对象和导航网格

  • 导航代理

  • 程序化编程和逻辑

  • 承受和造成伤害

  • 添加失败条件

  • 重构和保持 DRY

让我们开始吧!

在 Unity 中导航 3D 空间

当我们谈论现实生活中的导航时,通常是关于如何从 A 点到 B 点的对话。在虚拟 3D 空间中导航基本上是一样的,但我们如何考虑自从我们第一次开始爬行以来积累的经验知识呢?从在平坦表面行走到爬楼梯和跳台阶,这些都是我们通过实践学会的技能;我们怎么可能在游戏中编程所有这些而不发疯呢?

在回答这些问题之前,您需要了解 Unity 提供了哪些导航组件。

导航组件

简短的答案是,Unity 花了很多时间完善其导航系统,并提供了我们可以用来控制可玩和不可玩角色如何移动的组件。以下每个组件都是 Unity 的标准组件,并且已经内置了复杂的功能:

  • NavMesh本质上是给定级别中可行走表面的地图;NavMesh 组件本身是从级别几何中创建的,在一个称为烘焙的过程中。将 NavMesh 烘焙到您的级别中会创建一个持有导航数据的独特项目资产。

  • 如果NavMesh是级别地图,那么NavMeshAgent就是棋盘上的移动棋子。任何附有 NavMeshAgent 组件的对象都会自动避开其接触到的其他代理或障碍物。

  • 导航系统需要意识到级别中任何可能导致 NavMeshAgent 改变其路线的移动或静止对象。将 NavMeshObstacle 组件添加到这些对象可以让系统知道它们需要避开。

虽然这对 Unity 导航系统的描述远非完整,但对于我们继续进行敌人行为已经足够了。在本章中,我们将专注于向我们的级别添加 NavMesh,将敌人预制件设置为 NavMeshAgent,并让敌人预制件以看似智能的方式沿着预定义路线移动。

在本章中,我们只会使用 NavMesh 和 NavMeshAgent 组件,但如果您想为您的级别增添一些趣味,可以查看如何在这里创建障碍物:docs.unity3d.com/Manual/nav-CreateNavMeshObstacle.html

在设置“智能”敌人的第一个任务是在竞技场的可行走区域上创建一个 NavMesh。让我们设置和配置我们级别的 NavMesh:

  1. 选择环境游戏对象,单击检视器窗口中静态旁边的箭头图标,并选择导航静态

图 9.1:将对象设置为导航静态

  1. 点击是,更改子对象当对话框弹出时,将所有环境子对象设置为导航静态

图 9.2:更改所有子对象

  1. 转到窗口 | AI | 导航,并选择烘焙选项卡。将所有设置保持为默认值,然后单击烘焙。烘焙完成后,你将在场景文件夹内看到一个新文件夹,其中包含照明、导航网格和反射探针数据:

图 9.3:烘焙导航网格

我们级别中的每个对象现在都标记为导航静态,这意味着我们新烘焙的 NavMesh 已根据其默认 NavMeshAgent 设置评估了它们的可访问性。在前面的屏幕截图中,你可以看到浅蓝色覆盖的地方是任何附有 NavMeshAgent 组件的对象的可行走表面,这是你的下一个任务。

设置敌人代理

让我们将敌人预制件注册为 NavMeshAgent:

  1. 预制件文件夹中选择敌人预制件,在检视器窗口中单击添加组件,并搜索NavMesh Agent

图 9.4:添加 NavMeshAgent 组件

  1. 层次结构窗口中单击+ | 创建空对象,并将游戏对象命名为Patrol_Route
  • 选择Patrol_Route,单击+ | 创建空对象以添加一个子游戏对象,并将其命名为Location_1。将Location_1放置在级别的一个角落中:

图 9.5:创建一个空的巡逻路线对象

  1. Patrol_Route中创建三个空的子对象,分别命名为Location_2Location_3Location_4,并将它们放置在级别的剩余角落,形成一个正方形:

图 9.6:创建所有空的巡逻路线对象

向敌人添加 NavMeshAgent 组件告诉 NavMesh 组件注意并将其注册为具有访问其自主导航功能的对象。在每个级别角落创建四个空游戏对象,布置我们希望敌人最终巡逻的简单路线;将它们分组在一个空的父对象中,使得在代码中更容易引用它们,并使得层次结构窗口更加有组织。现在剩下的就是编写代码让敌人走巡逻路线,这将在下一节中添加。

移动敌人代理

我们的巡逻地点已经设置好,敌人预制件有一个 NavMeshAgent 组件,但现在我们需要找出如何引用这些地点并让敌人自行移动。为此,我们首先需要谈论软件开发世界中的一个重要概念:程序化编程。

程序化编程

尽管在名称中有,但程序化编程的概念可能难以理解,直到你完全掌握它;一旦你掌握了,你就永远不会以相同的方式看待代码挑战。

任何在一个或多个连续对象上执行相同逻辑的任务都是程序化编程的完美候选者。当你调试数组、列表和字典时,已经做了一些程序化编程,使用forforeach循环。每次执行这些循环语句时,都会对每个项目进行相同的Debug.Log()调用,依次迭代每个项目。现在的想法是利用这种技能获得更有用的结果。

程序化编程的最常见用途之一是将一个集合中的项目添加到另一个集合中,并在此过程中经常对其进行修改。这对我们的目的非常有效,因为我们希望引用Patrol_Route父对象中的每个子对象,并将它们存储在一个列表中。

参考巡逻地点

现在我们了解了程序化编程的基础知识,是时候获取对我们巡逻地点的引用,并将它们分配到一个可用的列表中了:

  1. 将以下代码添加到EnemyBehavior中:
public class EnemyBehavior : MonoBehaviour
{ 
    **// 1** 
    **public** **Transform PatrolRoute;**
    **// 2** 
    **public** **List<Transform> Locations;**
    **void****Start****()** 
    **{** 
        **// 3** 
        **InitializePatrolRoute();**
    **}** 
          **// 4** 
    **void****InitializePatrolRoute****()** 
    **{** 
        **// 5** 
        **foreach****(Transform child** **in** **PatrolRoute)** 
        **{** 
            **// 6** 
            **Locations.Add(child);**
        **}** 
    **}**
    void OnTriggerEnter(Collider other) 
    { 
        // ... No changes needed ... 
    } 
    void OnTriggerExit(Collider other) 
    { 
        // ... No changes needed ... 
    } 
} 
  1. 选择Enemy,并将Patrol_Route对象从层次结构窗口拖放到EnemyBehavior中的Patrol Route变量上:

图 9.7:将 Patrol_Route 拖到敌人脚本中

  1. 点击检视器窗口中位置变量旁边的箭头图标,并运行游戏以查看列表填充:

图 9.8:测试过程式编程

让我们来分解一下代码:

  1. 首先,声明一个变量来存储PatrolRoute空父级 GameObject。

  2. 然后,声明一个List变量来保存PatrolRoute中所有子Transform组件。

  3. 之后,它使用Start()在游戏开始时调用InitializePatrolRoute()方法。

  4. 接下来,创建InitializePatrolRoute()作为一个私有的实用方法,以过程化地填充LocationsTransform值:

  • 记住,不包括访问修饰符会使变量和方法默认为private
  1. 然后,使用foreach语句循环遍历PatrolRoute中的每个子 GameObject 并引用其 Transform 组件:
  • 每个 Transform 组件都在foreach循环中声明的本地child变量中捕获。
  1. 最后,通过使用Add()方法将每个顺序的child Transform组件添加到位置列表中,以便在PatrolRoute中循环遍历子对象时使用。
  • 这样,无论我们在Hierarchy窗口中做出什么更改,Locations都将始终填充所有PatrolRoute父级下的child对象。

虽然我们可以通过直接从Hierarchy窗口将每个位置 GameObject 分配给Locations,通过拖放的方式,但是很容易丢失或破坏这些连接;对位置对象名称进行更改、对象的添加或删除,或项目的更新都可能导致类的初始化出现问题。通过在Start()方法中以过程化的方式填充 GameObject 列表或数组,更加安全和可读。

由于这个原因,我也倾向于在Start()方法中使用GetComponent()来查找并存储附加到给定类的组件引用,而不是在Inspector窗口中分配它们。

现在,我们需要让敌人对象按照我们制定的巡逻路线移动,这是你的下一个任务。

移动敌人

Start()中初始化了一个巡逻位置列表后,我们可以获取敌人 NavMeshAgent 组件并设置它的第一个目的地。

更新EnemyBehavior使用以下代码并点击播放:

**// 1** 
**using** **UnityEngine.AI;** 
public class EnemyBehavior : MonoBehaviour  
{ 
    public Transform PatrolRoute;
    public List<Transform> Locations;
    **// 2** 
    **private****int** **_locationIndex =** **0****;** 
    **// 3** 
    **private** **NavMeshAgent _agent;** 
    void Start() 
    { 
        **// 4** 
        **_agent = GetComponent<NavMeshAgent>();** 
        InitializePatrolRoute(); 
        **// 5** 
        **MoveToNextPatrolLocation();** 
    }
    void InitializePatrolRoute()  
    { 
         // ... No changes needed ... 
    } 
    **void****MoveToNextPatrolLocation****()** 
    **{** 
        **// 6** 
        **_agent.destination = Locations[_locationIndex].position;** 
    **}** 
    void OnTriggerEnter(Collider other) 
    { 
        // ... No changes needed ... 
    } 
    void OnTriggerExit(Collider other) 
    { 
        // ... No changes needed ... 
    }
} 

让我们来分解一下代码:

  1. 首先,添加UnityEngine.AIusing指令,以便EnemyBehavior可以访问 Unity 的导航类,这种情况下是NavMeshAgent

  2. 然后,声明一个变量来跟踪敌人当前正在向其行走的巡逻位置。由于List项是从零开始索引的,我们可以让 Enemy Prefab 在Locations中存储的顺序中移动巡逻点之间移动。

  3. 接下来,声明一个变量来存储附加到 Enemy GameObject 的 NavMeshAgent 组件。这是private的,因为没有其他类应该能够访问或修改它。

  4. 之后,它使用GetComponent()来查找并返回附加的 NavMeshAgent 组件给代理。

  5. 然后,在Start()方法中调用MoveToNextPatrolLocation()方法。

  6. 最后,声明MoveToNextPatrolLocation()为一个私有方法并设置_agent.destinat``ion

  • destination是 3D 空间中的Vector3位置。

  • Locations[_locationIndex]获取Locations中给定索引处的 Transform 项。

  • 添加.position引用了 Transform 组件的Vector3位置。

现在,当我们的场景开始时,位置被填充了巡逻点,并且MoveToNextPatrolLocation()被调用以将 NavMeshAgent 组件的目标位置设置为位置列表中的第一个项目_locationIndex 0。下一步是让敌人对象从第一个巡逻位置移动到所有其他位置。

我们的敌人移动到第一个巡逻点没问题,但然后停下了。我们希望它能够在每个顺序位置之间持续移动,这将需要在Update()MoveToNextPatrolLocation()中添加额外的逻辑。让我们创建这个行为。

添加以下代码到EnemyBehavior并点击播放:

public class EnemyBehavior : MonoBehaviour  
{ 
    // ... No changes needed ... 
    **void****Update****()** 
    **{** 
        **// 1** 
        **if****(_agent.remainingDistance <** **0.2f** **&& !_agent.pathPending)** 
        **{** 
            **// 2** 
            **MoveToNextPatrolLocation();**
        **}**
    **}**
    void MoveToNextPatrolLocation() 
    { 
        **// 3** 
        **if** **(Locations.Count ==** **0****)** 
            **return****;** 

        _agent.destination = Locations[_locationIndex].position;
        **// 4** 
        **_locationIndex = (_locationIndex +** **1****) % Locations.Count;**
    }
    // ... No other changes needed ... 
} 

让我们来分解一下代码:

  1. 首先,它声明Update()方法,并添加一个if语句来检查两个不同条件是否为真:
  • remainingDistance返回 NavMeshAgent 组件当前距离其设定目的地的距离,所以我们检查是否小于 0.2。

  • pathPending根据 Unity 是否为 NavMeshAgent 组件计算路径返回truefalse布尔值。

  1. 如果_agent非常接近目的地,并且没有其他路径正在计算,if语句将返回true并调用MoveToNextPatrolLocation()

  2. 在这里,我们添加了一个if语句来确保在执行MoveToNextPatrolLocation()中的其余代码之前,Locations不为空:

  • 如果Locations为空,我们使用return关键字退出方法而不继续执行。

这被称为防御性编程,结合重构,这是在向更中级的 C#主题迈进时必不可少的技能。我们将在本章末考虑重构。

  1. 然后,我们将_locationIndex设置为它的当前值,+1,然后取Locations.Count的模(%):
  • 这将使索引从 0 增加到 4,然后重新从 0 开始,这样我们的敌人预制就会沿着连续的路径移动。

  • 模运算符返回两个值相除的余数——当结果为整数时,2 除以 4 的余数为 2,所以 2 % 4 = 2。同样,4 除以 4 没有余数,所以 4 % 4 = 0。

将索引除以集合中的最大项目数是一种快速找到下一个项目的方法。如果你对模运算符不熟悉,请回顾第二章编程的基本组成部分

现在我们需要在Update()中每帧检查敌人是否朝着设定的巡逻位置移动;当它靠近时,将触发MoveToNextPatrolLocation(),这会增加_locationIndex并将下一个巡逻点设置为目的地。

如果你将Scene视图拖到Console窗口旁边,如下截图所示,然后点击播放,你可以看到敌人预制在关卡的拐角处连续循环行走:

图 9.9:测试敌人巡逻路线

敌人现在沿着地图外围巡逻路线,但当它在预设范围内时,它不会寻找玩家并发动攻击。在下一节中,您将使用 NavAgent 组件来做到这一点。

敌人游戏机制

现在我们的敌人正在持续巡逻,是时候给它一些互动机制了;如果我们让它一直在走动而没有对抗我们的方式,那就没有太多的风险或回报了。

寻找并摧毁:改变代理的目的地

在本节中,我们将专注于在玩家靠近时切换敌人 NavMeshAgent 组件的目标,并在发生碰撞时造成伤害。当敌人成功降低玩家的健康时,它将返回到巡逻路线,直到下一次与玩家相遇。

然而,我们不会让我们的玩家束手无策;我们还将添加代码来跟踪敌人的健康状况,检测敌人是否成功被玩家的子弹击中,以及何时需要摧毁敌人。

现在敌人预制正在巡逻移动,我们需要获取玩家位置的引用,并在它靠近时改变 NavMeshAgent 的目的地。

  1. 将以下代码添加到EnemyBehavior中:
public class EnemyBehavior : MonoBehaviour  
{ 
    **// 1** 
    **public** **Transform Player;**
    public Transform PatrolRoute;
    public List<Transform> Locations;
    private int _locationIndex = 0;
    private NavMeshAgent _agent;
    void Start() 
    { 
        _agent = GetComponent<NavMeshAgent>();
        **// 2** 
        **Player = GameObject.Find(****"Player"****).transform;** 
        // ... No other changes needed ... 
    } 
    /* ... No changes to Update,  
           InitializePatrolRoute, or  
           MoveToNextPatrolLocation ... */ 
    void OnTriggerEnter(Collider other) 
    { 
        if(other.name == "Player") 
        { 
            **// 3** 
            **_agent.destination = Player.position;**
            Debug.Log("Enemy detected!");
        } 
    } 
    void OnTriggerExit(Collider other)
    { 
        // .... No changes needed ... 
    }
} 

让我们来分解这段代码:

  1. 首先,它声明一个public变量来保存Player胶囊体的Transform值。

  2. 然后,我们使用GameObject.Find("Player")来返回场景中玩家对象的引用:

  • 直接添加.transform引用了同一行中对象的Transform值。
  1. 最后,在OnTriggerEnter()中,当玩家进入我们之前设置的敌人攻击区域时,我们将_agent.destination设置为玩家的Vector3位置。

如果你现在玩游戏并离巡逻的敌人太近,你会发现它会中断原来的路径直接向你走来。一旦它到达玩家,Update()方法中的代码将再次接管,敌人预制件将恢复巡逻。

我们仍然需要让敌人以某种方式伤害玩家,我们将在下一节中学习如何做到这一点。

降低玩家生命值

虽然我们的敌人机制已经取得了长足的进步,但当敌人预制件与玩家预制件发生碰撞时什么都不发生仍然让人失望。为了解决这个问题,我们将新的敌人机制与游戏管理器联系起来。

使用以下代码更新PlayerBehavior并点击播放:

public class PlayerBehavior : MonoBehaviour  
{ 
    // ... No changes to public variables needed ... 
    **// 1** 
    **private** **GameBehavior _gameManager;**
    void Start() 
    { 
        _rb = GetComponent<Rigidbody>();
        _col = GetComponent<CapsuleCollider>();
        **// 2** 
        **_gameManager = GameObject.Find(****"Game_Manager"****).GetComponent<GameBehavior>();**
    **}** 
    /* ... No changes to Update,  
           FixedUpdate, or  
           IsGrounded ... */ 
    **// 3** 
    **void****OnCollisionEnter****(****Collision collision****)**
    **{**
        **// 4** 
        **if****(collision.gameObject.name ==** **"Enemy"****)**
        **{**
            **// 5** 
            **_gameManager.HP -=** **1****;**
        **}**
    **}**
} 

让我们来分解一下代码:

  1. 首先,它声明一个private变量来保存我们在场景中拥有的GameBehavior实例的引用。

  2. 然后,它找到并返回附加到场景中的Game Manager对象的GameBehavior脚本:

  • 在同一行上使用GetComponent()GameObject.Find()是减少不必要的代码行的常见方法。
  1. 由于我们的玩家是发生碰撞的对象,因此在PlayerBehavior中声明OnCollisionEnter()是有道理的。

  2. 接下来,我们检查碰撞对象的名称;如果是敌人预制件,我们执行if语句的主体。

  3. 最后,我们使用_gameManager实例从公共HP变量中减去1

现在每当敌人跟踪并与玩家发生碰撞时,游戏管理器将触发 HP 的设置属性。UI 将使用新的玩家生命值更新,这意味着我们有机会为失败条件后期添加一些额外的逻辑。

检测子弹碰撞

现在我们有了失败条件,是时候为我们的玩家添加一种反击敌人攻击并幸存下来的方式了。

打开EnemyBehavior并使用以下代码进行修改:

public class EnemyBehavior : MonoBehaviour  
{ 
    //... No other variable changes needed ... 
    **// 1** 
    **private****int** **_lives =** **3****;** 
    **public****int** **EnemyLives** 
    **{** 
        **// 2** 
        **get** **{** **return** **_lives; }**
        **// 3** 
        **private****set** 
        **{** 
            **_lives =** **value****;** 
            **// 4** 
            **if** **(_lives <=** **0****)** 
            **{** 
                **Destroy(****this****.gameObject);** 
                **Debug.Log(****"Enemy down."****);** 
            **}**
        **}**
    **}**
    /* ... No changes to Start,  
           Update,  
           InitializePatrolRoute,  
           MoveToNextPatrolLocation,  
           OnTriggerEnter, or  
           OnTriggerExit ... */ 
    **void****OnCollisionEnter****(****Collision collision****)** 
    **{** 
        **// 5** 
        **if****(collision.gameObject.name ==** **"Bullet(Clone)"****)** 
        **{** 
            **// 6** 
            **EnemyLives -=** **1****;**
            **Debug.Log(****"Critical hit!"****);**
        **}**
    **}**
} 

让我们来分解一下代码:

  1. 首先,它声明了一个名为_livesprivate int变量,并声明了一个名为EnemyLivespublic后备变量。这将使我们能够控制EnemyLives的引用和设置方式,就像在GameBehavior中一样。

  2. 然后,我们将get属性设置为始终返回_lives

  3. 接下来,我们使用private setEnemyLives的新值分配给_lives,以保持它们两者同步。

我们之前没有见过private getset,但它们可以像任何其他可执行代码一样具有访问修饰符。将getset声明为private意味着只有父类才能访问它们的功能。

  1. 然后,我们添加一个if语句来检查_lives是否小于或等于 0,这意味着敌人应该死了:
  • 在这种情况下,我们销毁Enemy游戏对象并在控制台上打印一条消息。
  1. 因为Enemy是被子弹击中的对象,所以在EnemyBehavior中包含对这些碰撞的检查是合理的,使用OnCollisionEnter()

  2. 最后,如果碰撞对象的名称与子弹克隆对象匹配,我们将EnemyLives减少1并打印出另一条消息。

请注意,我们检查的名称是Bullet(Clone),即使我们的子弹预制件的名称是Bullet。这是因为 Unity 会在使用Instantiate()方法创建的任何对象后添加(Clone)后缀,而我们的射击逻辑就是这样创建的。

你也可以检查游戏对象的标签,但由于这是 Unity 特有的功能,我们将保持代码不变,只用纯 C#来处理事情。

现在,玩家可以在敌人试图夺取其生命时进行反击,射击三次并摧毁敌人。再次,我们使用getset属性来处理额外的逻辑,证明这是一个灵活且可扩展的解决方案。完成这些后,你的最后任务是更新游戏管理器的失败条件。

更新游戏管理器

要完全实现失败条件,我们需要更新管理器类:

  1. 打开GameBehavior并添加以下代码:
public class GameBehavior : MonoBehaviour  
{ 
    // ... No other variable changes... 
    **// 1** 
    **public** **Button LossButton;** 
    private int _itemsCollected = 0; 
    public int Items 
    { 
        // ... No changes needed ... 
    } 
    private int _playerHP = 10; 
    public int HP 
    { 
        get { return _playerHP; } 
        set {  
            _playerHP = value; 
                HealthText.text = "Player Health: " + HP; 
            **// 2** 
            **if****(_playerHP <=** **0****)** 
            **{** 
                **ProgressText.text=** **"You want another life with** **that?"****;**
    **LossButton.gameObject.SetActive(****true****);** 
                **Time.timeScale =** **0****;** 
            **}** 
            **else** 
            **{** 
                **ProgressText.text =** **"Ouch... that's got hurt."****;** 
            **}**
        }
    }
} 
  1. Hierarchy窗口中,右键单击Win Condition,选择Duplicate,并将其命名为Loss Condition
  • 单击Loss Condition左侧的箭头以展开它,选择Text对象,并将文本更改为You lose...
  1. Hierarchy窗口中选择Game_Manager,并将Loss Condition拖放到Game Behavior(Script)组件中的Loss Button插槽中:

图 9.10:在检查器窗格中完成了带有文本和按钮变量的游戏行为脚本

让我们分解一下代码:

  1. 首先,我们声明了一个新的按钮,当玩家输掉游戏时我们想要显示它。

  2. 然后,我们添加一个if语句来检查_playerHP何时下降到0以下:

  • 如果为true,则更新ProgessTextTime.timeScale,并激活失败按钮。

  • 如果玩家在敌人碰撞后仍然存活,ProgessText会显示不同的消息:“哎呀...那一定很疼。”。

现在,在GameBehavior.cs中将_playerHP更改为 1,并让敌人预制件与您发生碰撞,观察发生了什么。

完成了!您已成功添加了一个可以对玩家造成伤害并受到反击的“智能”敌人,以及通过游戏管理器的失败界面。在我们完成本章之前,还有一个重要的主题需要讨论,那就是如何避免重复的代码。

重复的代码是所有程序员的梦魇,因此学会如何在项目中尽早避免它是有意义的!

重构和保持 DRY

不要重复自己(DRY)首字母缩写是软件开发者的良心:它告诉您何时有可能做出糟糕或可疑的决定,并在工作完成后给您一种满足感。

在实践中,重复的代码是编程生活的一部分。试图通过不断思考未来来避免它会在项目中设置许多障碍,这似乎不值得继续。处理重复代码的更有效和理智的方法是快速识别它何时何地发生,然后寻找最佳的移除方法。这个任务被称为重构,我们的GameBehavior类现在可以使用一些它的魔力。

您可能已经注意到我们在两个不同的地方设置了进度文本和时间刻度,但我们可以很容易地在一个地方为自己创建一个实用方法来完成这些工作。

要重构现有的代码,您需要按照以下步骤更新GameBehavior.cs

public class GameBehavior: MonoBehaviour
{
    **// 1**
    **public****void****UpdateScene****(****string** **updatedText****)**
    **{**
        **ProgressText.text = updatedText;**
        **Time.timeScale =** **0f****;**
    **}**
    private int _itemsCollected = 0;
    public int Items
    {
        get { return _itemsCollected; }
        set
        {
            _itemsCollected = value;
            ItemText.text = "Items Collected: " + Items;
            if (_itemsCollected >= MaxItems)
            {
                WinButton.gameObject.SetActive(true);
                **// 2**
                **UpdateScene(****"You've found all the items!"****);**
            }
            else
            {
                ProgressText.text = "Item found, only " + (MaxItems - _itemsCollected) + " more to go!";
            }
        }
    }
    private int _playerHP = 10;
    public int HP
    {
        get { return _playerHP; }
        set
        {
            _playerHP = value;
            HealthText.text = "Player Health: " + HP;
            if (_playerHP <= 0)
            {
                LossButton.gameObject.SetActive(true);
                **// 3**
                **UpdateScene(****"You want another life with that?"****);**
            }
            else
            {
                ProgressText.text = "Ouch... that's got hurt.";
            }
            Debug.LogFormat("Lives: {0}", _playerHP);
        }
    }
} 

让我们分解一下代码:

  1. 我们声明了一个名为UpdateScene的新方法,它接受一个字符串参数,我们想要将其分配给ProgressText,并将Time.timeScale设置为0

  2. 我们删除了重复代码的第一个实例,并使用我们的新方法在游戏获胜时更新了场景。

  3. 我们删除了重复代码的第二个实例,并在游戏失败时更新了场景。

如果您在正确的地方寻找,总是有更多的重构工作可以做。

总结

通过这样,我们的敌人和玩家互动就完成了。我们可以造成伤害,也可以承受伤害,失去生命,并进行反击,同时更新屏幕上的 GUI。我们的敌人使用 Unity 的导航系统在竞技场周围行走,并在玩家指定范围内时切换到攻击模式。每个 GameObject 负责其行为、内部逻辑和对象碰撞,而游戏管理器则跟踪管理游戏状态的变量。最后,我们学习了简单的过程式编程,以及当重复指令被抽象成它们的方法时,代码可以变得更加清晰。

在这一点上,您应该感到有所成就,特别是如果您作为一个完全的初学者开始阅读本书。在构建一个可工作的游戏的同时熟悉一种新的编程语言并不容易。在下一章中,您将被介绍一些 C#中级主题,包括新的类型修饰符、方法重载、接口和类扩展。

小测验-人工智能和导航

  1. 在 Unity 场景中如何创建 NavMesh 组件?

  2. 什么组件将 GameObject 标识为 NavMesh?

  3. 在一个或多个连续对象上执行相同的逻辑是哪种编程技术的例子?

  4. DRY 首字母缩写代表什么?

加入我们的 Discord!

与其他用户一起阅读这本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,以及更多内容。

立即加入!

packt.link/csharpunity2021

第十章:重新审视类型、方法和类

现在您已经使用 Unity 内置类编写了游戏的机制和交互,是时候扩展我们的核心 C#知识,专注于我们所奠定的基础的中级应用。我们将重新审视旧朋友——变量、类型、方法和类——但我们将针对它们的更深层次应用和相关用例。我们将要讨论的许多主题并不适用于Hero Born的当前状态,因此一些示例将是独立的,而不是直接应用于游戏原型。

我将向您介绍大量新信息,所以如果您在任何时候感到不知所措,请不要犹豫,重新阅读前几章以巩固这些基础知识。我们还将利用本章来摆脱游戏机制和特定于 Unity 的功能,而是专注于以下主题:

  • 中级修饰符

  • 方法重载

  • 使用outref参数

  • 使用接口

  • 抽象类和重写

  • 扩展类功能

  • 命名空间冲突

  • 类型别名

让我们开始吧!

访问修饰符

虽然我们已经习惯了将公共和私有访问修饰符与变量声明配对,就像我们对玩家健康和收集的物品所做的那样,但我们还有一长串修饰符关键字没有看到。在本章中,我们无法详细介绍每一个,但我们将专注于五个关键字,这将进一步加深您对 C#语言的理解,并提高您的编程技能。

本节将涵盖以下列表中的前三个修饰符,而剩下的两个将在中级 OOP部分讨论:

  • const

  • readonly

  • 静态

  • 抽象

  • override

您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/modifiers找到可用修饰符的完整列表。

让我们从前面列表中提供的前三个访问修饰符开始。

常量和只读属性

有时您需要创建存储常量、不变值的变量。在变量的访问修饰符后添加const关键字就可以做到这一点,但只适用于内置的 C#类型。例如,您不能将我们的Character类的实例标记为常量。GameBehavior类中MaxItems是一个常量值的好选择:

public **const** int MaxItems = 4; 

上面的代码本质上将MaxItems的值锁定为4,使其不可更改。常量变量的问题在于它们只能在声明时分配一个值,这意味着我们不能让MaxItems没有初始值。作为替代方案,我们可以使用readonly,它不允许您写入变量,这意味着它不能被更改:

public **readonly** int MaxItems; 

使用readonly关键字声明变量将为我们提供与常量相同的不可修改的值,同时仍然允许我们随时分配其初始值。这个关键字的一个好地方可能是我们脚本中的Start()Awake()方法。

使用static关键字

我们已经讨论了如何从类蓝图创建对象或实例,以及所有属性和方法都属于特定的实例,就像我们的第一个Character类实例一样。虽然这对于面向对象的功能非常有用,但并非所有类都需要被实例化,也不是所有属性都需要属于特定的实例。但是,静态类是封闭的,这意味着它们不能用于类继承。

实用方法是这种情况的一个很好的例子,我们不一定关心实例化特定的Utility类实例,因为它的所有方法都不依赖于特定对象。您的任务是在一个新的脚本中创建这样一个实用方法。

让我们创建一个新的类来保存一些未来处理原始计算或重复逻辑的方法,这些方法不依赖于游戏玩法:

  1. Scripts文件夹中创建一个新的 C#脚本,并将其命名为Utilities

  2. 打开它并添加以下代码:

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

// 1 
using UnityEngine.SceneManagement; 

// 2 
public static class Utilities  
{ 
    // 3 
    public static int PlayerDeaths = 0; 

    // 4 
    public static void RestartLevel() 
    { 
        SceneManager.LoadScene(0); 
        Time.timeScale = 1.0f; 
    } 
} 
  1. GameBehavior中删除RestartLevel()中的代码,而是使用以下代码调用新的utility方法:
// 5
public void RestartScene()
{
    Utilities.RestartLevel();
} 

让我们来分解一下代码:

  1. 首先,它添加了using SceneManagement指令,以便我们可以访问LoadScene()方法。

  2. 然后,它将Utilities声明为一个不继承自MonoBehavior的公共static类,因为我们不需要它在游戏场景中。

  3. 接下来,它创建一个公共的static变量来保存我们的玩家死亡并重新开始游戏的次数。

  4. 然后,它声明一个公共的static方法来保存我们的级别重启逻辑,这目前是硬编码在GameBehavior中的。

  5. 最后,我们在GameBehavior中对RestartLevel()的更新在赢或输按钮被按下时从静态的Utilities类调用。请注意,我们不需要Utilities类的实例来调用该方法,因为它是静态的——只需使用点符号。

我们现在已经将重启逻辑从GameBehavior中提取出来,并放入其静态类中,这样可以更容易地在整个代码库中重用。将其标记为static也将确保我们在使用其类成员之前永远不必创建或管理Utilities类的实例。

非静态类可以具有静态和非静态的属性和方法。但是,如果整个类标记为静态,所有属性和方法都必须遵循相同的规则。

这就结束了我们对变量和类型的第二次访问,这将使您能够在未来管理更大更复杂的项目时构建自己的一套工具和实用程序。现在是时候转向方法及其中级功能,其中包括方法重载和refout参数。

重温方法

自从我们在第三章学习如何使用方法以来,方法一直是我们代码的重要组成部分,但有两种中级用例我们还没有涵盖:方法重载和使用refout参数关键字。

方法重载

术语方法重载指的是创建多个具有相同名称但不同签名的方法。方法的签名由其名称和参数组成,这是 C#编译器识别它的方式。以以下方法为例:

public bool AttackEnemy(int damage) {} 

AttackEnemy()的方法签名如下所示:

AttackEnemy(int) 

现在我们知道了AttackEnemy()的签名,可以通过改变参数的数量或参数类型本身来重载它,同时保持其名称不变。这在您需要给定操作的多个选项时提供了额外的灵活性。

Utilities中的RestartLevel()方法是方法重载派上用场的一个很好的例子。目前,RestartLevel()只重新启动当前级别,但如果我们扩展游戏,使其包含多个场景会怎么样?我们可以重构RestartLevel()以接受参数,但这通常会导致臃肿和混乱的代码。

RestartLevel()方法再次是测试我们新知识的一个很好的候选项。您的任务是重载它以接受不同的参数。

让我们添加一个重载版本的RestartLevel()

  1. 打开Utilities并添加以下代码:
public static class Utilities  
{
    public static int PlayerDeaths = 0;
    public static void RestartLevel()
    {
        SceneManager.LoadScene(0);
        Time.timeScale = 1.0f;
    }
    **// 1** 
    **public****static****bool****RestartLevel****(****int** **sceneIndex****)**
    **{** 
        **// 2** 
        **SceneManager.LoadScene(sceneIndex);**
        **Time.timeScale =** **1.0f****;**
        **// 3** 
        **return****true****;**
    **}** 
} 
  1. 打开GameBehavior并将对Utilities.RestartLevel()方法的调用更新为以下内容:
// 4
public void RestartScene()
{
    Utilities.RestartLevel(0);
} 

让我们来分解一下代码:

  1. 首先,它声明了一个重载版本的RestartLevel()方法,该方法接受一个int参数并返回一个bool

  2. 然后,它调用LoadScene()并传入sceneIndex参数,而不是手动硬编码该值。

  3. 接下来,在新场景加载后,它将返回true并且timeScale属性已被重置。

  4. 最后,我们对GameBehavior的更新调用了重载的RestartLevel()方法,并将0作为sceneIndex传入。重载方法会被 Visual Studio 自动检测到,并按数字显示,如下所示:

图 10.1:Visual Studio 中的多个方法重载

RestartLevel()方法中的功能现在更加可定制,可以处理以后可能需要的其他情况。在这种情况下,它是从我们选择的任何场景重新开始游戏。

方法重载不仅限于静态方法——这只是与前面的示例一致。只要其签名与原始方法不同,任何方法都可以进行重载。

接下来,我们将介绍另外两个可以提升你的方法游戏水平的主题——refout参数。

ref 参数

当我们在第五章 使用类、结构体和面向对象编程中讨论类和结构体时,我们发现并非所有对象都是以相同的方式传递的:值类型是按副本传递的,而引用类型是按引用传递的。然而,我们没有讨论当对象或值作为参数传递到方法中时,它们是如何使用的。

默认情况下,所有参数都是按值传递的,这意味着传递到方法中的变量不会受到方法体内对其值所做更改的影响。当我们将它们用作方法参数时,这可以保护我们免受不需要的变量更改。虽然这对大多数情况都适用,但在某些情况下,您可能希望通过引用传递方法参数,以便可以更新它并在原始变量中反映出这种更改。在参数声明前加上refout关键字将标记参数为引用。

以下是使用ref关键字时需要牢记的几个关键点:

  • 参数在传递到方法之前必须初始化。

  • 在结束方法之前,您不需要初始化或分配引用参数值。

  • 具有 get 或 set 访问器的属性不能用作refout参数。

让我们通过添加一些逻辑来跟踪玩家重新开始游戏的次数来尝试一下。

让我们创建一个方法来更新PlayerDeaths,以查看正在通过引用传递的方法参数的作用。

打开Utilities并添加以下代码:

public static class Utilities  
{ 
    public static int PlayerDeaths = 0; 
    **// 1** 
    **public****static****string****UpdateDeathCount****(****ref****int** **countReference****)** 
    **{** 
        **// 2** 
        **countReference +=** **1****;** 
        **return****"Next time you'll be at number "** **+ countReference;**
    **}**
    public static void RestartLevel()
    { 
       // ... No changes needed ...   
    } 
    public static bool RestartLevel(int sceneIndex)
    { 
        **// 3** 
        **Debug.Log(****"Player deaths: "** **+ PlayerDeaths);** 
        **string** **message = UpdateDeathCount(****ref** **PlayerDeaths);**
        **Debug.Log(****"Player deaths: "** **+ PlayerDeaths);**
        **Debug.Log(message);**
        SceneManager.LoadScene(sceneIndex);
        Time.timeScale = 1.0f;
        return true;
    }
} 

让我们来分解一下代码:

  1. 首先,声明一个新的static方法,返回一个string并接受一个通过引用传递的int

  2. 然后,它直接更新引用参数,将其值增加 1,并返回一个包含新值的字符串。

  3. 最后,它在将PlayerDeaths变量传递给UpdateDeathCount()之前和之后,在RestartLevel(int sceneIndex)中对其进行调试。我们还将UpdateDeathCount()返回的字符串值的引用存储在message变量中并打印出来。

如果你玩游戏并且失败,调试日志将显示UpdateDeathCount()内的PlayerDeaths增加了 1,因为它是通过引用而不是通过值传递的:

图 10.2:ref参数的示例输出

为了清晰起见,我们可以在没有ref参数的情况下更新玩家死亡计数,因为UpdateDeathCount()PlayerDeaths在同一个脚本中。但是,如果情况不是这样,而你希望具有相同的功能,ref参数非常有用。

在这种情况下,我们使用ref关键字是为了举例说明,但我们也可以直接在UpdateDeathCount()内更新PlayerDeaths,或者在RestartLevel()内添加逻辑,只有在由于失败而重新开始时才触发UpdateDeathCount()

现在我们知道如何在项目中使用ref参数,让我们来看看out参数以及它如何起到略有不同的作用。

out 参数

out关键字和ref执行相同的工作,但有不同的规则,这意味着它们是相似的工具,但不能互换使用,每个都有自己的用例。

  • 参数在传递到方法之前不需要初始化。

  • 引用的参数值在调用方法中返回之前不需要初始化或赋值。

例如,我们可以在UpdateDeathCount()中用out替换ref,只要在方法返回之前初始化或赋值countReference参数:

public static string UpdateDeathCount(**out** int countReference) 
{ 
     countReference = 1;
     return "Next time you'll be at number " + countReference;
} 

使用out关键字的方法更适合需要从单个函数返回多个值的情况,而ref关键字在只需要修改引用值时效果最好。它也比ref关键字更灵活,因为在方法中使用参数之前不需要设置初始参数值。out关键字在需要在更改之前初始化参数值时特别有用。尽管这些关键字有点晦涩,但对于特殊用例来说,将它们放入你的 C#工具包中是很重要的。

有了这些新的方法特性,现在是重新审视面向对象编程OOP)的时候了。这个主题涉及的内容太多,不可能在一两章中覆盖所有内容,但在你的开发生涯初期,有一些关键工具会很有用。OOP 是一个你鼓励在完成本书后继续学习的主题。

中级 OOP

面向对象的思维方式对于创建有意义的应用程序和理解 C#语言在幕后的工作方式至关重要。棘手的部分在于,类和结构本身并不是面向对象编程和设计对象的终点。它们始终是你的代码的构建块,但是类在单一继承方面受到限制,这意味着它们只能有一个父类或超类,而结构根本不能继承。因此,你现在应该问自己的问题很简单:“我如何才能根据特定情况创建出相同蓝图的对象,并让它们执行不同的操作?”

为了回答这个问题,我们将学习接口、抽象类和类扩展。

接口

将功能组合在一起的一种方法是通过接口。与类一样,接口是数据和行为的蓝图,但有一个重要的区别:它们不能有任何实际的实现逻辑或存储值。相反,它们包含了实现蓝图,由采用的类或结构填写接口中概述的值和方法。你可以在类和结构中使用接口,一个类或结构可以采用的接口数量没有上限。

记住,一个类只能有一个父类,结构根本不能有子类。将功能分解为接口可以让你像从菜单中选择食物一样构建类,选择你希望它们表现的方式。这将极大地提高你的代码库的效率,摆脱了冗长、混乱的子类层次结构。

例如,如果我们希望我们的敌人在靠近时能够还击我们的玩家,我们可以创建一个父类,玩家和敌人都可以从中派生,这将使它们都基于相同的蓝图。然而,这种方法的问题在于敌人和玩家不一定会共享相同的行为和数据。

更有效的处理方式是定义一个接口,其中包含可射击对象需要执行的蓝图,然后让敌人和玩家都采用它。这样,它们就可以自由地分开并展示不同的行为,同时仍然共享共同的功能。

将射击机制重构为接口是一个我留给你的挑战,但我们仍然需要知道如何在代码中创建和采用接口。在这个例子中,我们将创建一个所有管理器脚本可能需要实现的接口,以共享一个公共结构。

Scripts文件夹中创建一个新的 C#脚本,命名为IManager,并更新其代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine; 
// 1 
public interface IManager  
{ 
    // 2 
    string State { get; set; } 
    // 3 
    void Initialize();
} 

让我们来分解一下代码:

  1. 首先,它使用interface关键字声明了一个名为IManager的公共接口。

  2. 然后,它在IManager中添加了一个名为Statestring变量,带有getset访问器来保存采用类的当前状态。

所有接口属性至少需要一个 get 访问器才能编译,但如果需要的话也可以有 get 和 set 访问器。

  1. 最后,它定义了一个名为Initialize()的方法,没有返回类型,供采用类实现。但是,你绝对可以在接口内部的方法中有一个返回类型;没有规定不允许这样做。

你现在为所有管理器脚本创建了一个蓝图,这意味着采用这个接口的每个管理器脚本都需要有一个状态属性和一个初始化方法。你的下一个任务是使用IManager接口,这意味着它需要被另一个类采用。

为了保持简单,让游戏管理器采用我们的新接口并实现其蓝图。

使用以下代码更新GameBehavior

**// 1** 
public class GameBehavior : MonoBehaviour, **IManager** 
{ 
    **// 2** 
    **private****string** **_state;** 
    **// 3** 
    **public****string** **State**  
    **{** 
        **get** **{** **return** **_state; }** 
        **set** **{ _state =** **value****; }** 
    **}**
    // ... No other changes needed ... 
    **// 4** 
    **void****Start****()** 
    **{** 
        **Initialize();** 
    **}**
    **// 5** 
    **public****void****Initialize****()**  
    **{** 
        **_state =** **"Game Manager initialized.."****;**
        **Debug.Log(_state);**
    **}**
} 

让我们来分解一下代码:

  1. 首先,它声明了GameBehavior采用IManager接口,使用逗号和它的名称,就像子类化一样。

  2. 然后,它添加了一个私有变量,我们将用它来支持我们必须从IManager实现的公共State值。

  3. 接下来,它添加了在IManager中声明的公共State变量,并使用_state作为其私有备份变量。

  4. 之后,它声明了Start()方法并调用了Initialize()方法。

  5. 最后,它声明了在IManager中声明的Initialize()方法,其中包含一个设置和打印公共State变量的实现。

通过这样做,我们指定GameBehavior采用IManager接口,并实现其StateInitialize()成员,如下所示:

图 10.3:接口的示例输出

这样做的好处是,实现是特定于GameBehavior的;如果我们有另一个管理器类,我们可以做同样的事情,但逻辑不同。只是为了好玩,让我们设置一个新的管理器脚本来测试一下:

  1. Project中,在Scripts文件夹内右键单击,选择Create | C# Script,然后命名为DataManager

  2. 使用以下代码更新新脚本并采用IManager接口:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class DataManager : MonoBehaviour, IManager
{
    private string _state;
    public string State
    {
        get { return _state; }
        set { _state = value; }
    }
    void Start()
    {
        Initialize();
    }
    public void Initialize()
    {
        _state = "Data Manager initialized..";
        Debug.Log(_state);
    }
} 
  1. 将新脚本拖放到Hierarchy面板中的Game_Manager对象上:

图 10.4:附加到 GameObject 的数据管理器脚本

  1. 然后点击播放:

图 10.5:数据管理器初始化的输出

虽然我们可以通过子类化来完成所有这些工作,但我们将受到一个父类限制,适用于所有我们的管理器。相反,我们可以选择添加新的接口。我们将在第十二章“保存、加载和序列化数据”中重新讨论这个新的管理器脚本。这为构建类打开了一整个新的可能性世界,其中之一是一个称为抽象类的新面向对象编程概念。

抽象类

另一种分离常见蓝图并在对象之间共享它们的方法是抽象类。与接口类似,抽象类不能包含任何方法的实现逻辑;但是,它们可以存储变量值。这是与接口的关键区别之一——在可能需要设置初始值的情况下,抽象类将是一种选择。

任何从抽象类继承的类都必须完全实现所有标记为abstract关键字的变量和方法。在想要使用类继承而不必编写基类默认实现的情况下,它们可能特别有用。

例如,让我们将刚刚编写的IManager接口功能作为抽象基类来看看它会是什么样子。不要更改我们项目中的任何实际代码,因为我们仍然希望保持事情的正常运行:

// 1 
public abstract class BaseManager  
{ 
    // 2 
    protected string _state = "Manager is not initialized...";
    public abstract string State { get; set; }
    // 3 
    public abstract void Initialize();
} 

让我们分解一下代码:

  1. 首先,使用abstract关键字声明了一个名为BaseManager的新类。

  2. 然后,它创建了两个变量:一个名为_stateprotected string,只能被从BaseManager继承的类访问。我们还为_state设置了一个初始值,这是我们在接口中无法做到的。

  • 我们还有一个名为State的抽象字符串,带有要由子类实现的getset访问器。
  1. 最后,它将Initialize()作为abstract方法添加,也要在子类中实现。

这样做,我们创建了一个与接口相同的抽象类。在这种设置中,BaseManager具有与IManager相同的蓝图,允许任何子类使用override关键字定义它们对stateInitialize()的实现:

// 1 
public class CombatManager: BaseManager  
{ 
    // 2 
    public override string State 
    { 
        get { return _state; } 
        set { _state = value; } 
    }
    // 3 
    public override void Initialize() 
    { 
        _state = "Combat Manager initialized..";
        Debug.Log(_state);
    }
} 

如果我们分解前面的代码,我们可以看到以下内容:

  1. 首先,它声明了一个名为CombatManager的新类,该类继承自BaseManager抽象类。

  2. 然后,它使用override关键字添加了从BaseManager中实现的State变量。

  3. 最后,它再次使用override关键字从BaseManager中添加了Initialize()方法的实现,并设置了受保护的_state变量。

尽管这只是接口和抽象类的冰山一角,但它们的可能性应该在你的编程大脑中跳动。接口将允许您在不相关的对象之间传播和共享功能片段,从而在代码方面形成类似构建块的组装。

另一方面,抽象类将允许您保持 OOP 的单继承结构,同时将类的实现与其蓝图分离。这些方法甚至可以混合使用,因为抽象类可以像非抽象类一样采用接口。

对于复杂的主题,您的第一站应该是文档。在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/abstractdocs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/interface上查看它。

您并不总是需要从头开始构建一个新类。有时,向现有类添加您想要的功能或逻辑就足够了,这称为类扩展。

类扩展

让我们远离自定义对象,谈谈如何扩展现有类,使它们符合我们自己的需求。类扩展的理念很简单:取一个现有的内置 C#类,并添加任何您需要的功能。由于我们无法访问 C#构建的底层代码,这是获取对象语言已经具有的自定义行为的唯一方法。

类只能通过方法进行修改——不允许变量或其他实体。然而,尽管这可能有所限制,但它使语法保持一致:

public **static** returnType MethodName(**this** **ExtendingClass** localVal) {} 

扩展方法的声明与普通方法相同,但有一些注意事项:

  • 所有扩展方法都需要标记为static

  • 第一个参数需要是this关键字,后面跟着我们想要扩展的类的名称和一个本地变量名称:

  • 这个特殊的参数让编译器识别该方法为扩展方法,并为我们提供了现有类的本地引用。

  • 任何类方法和属性都可以通过局部变量访问。

  • 将扩展方法存储在静态类中是常见的,而静态类又存储在其命名空间中。这使您可以控制其他脚本可以访问您的自定义功能。

您的下一个任务是通过向内置的 C# String类添加一个新方法来将类扩展付诸实践。

通过向String类添加自定义方法来实践扩展。在Scripts文件夹中创建一个新的 C#脚本,命名为CustomExtensions,并添加以下代码:

using System.Collections; 
using System.Collections.Generic;
using UnityEngine;  
// 1 
namespace CustomExtensions  
{ 
    // 2 
    public static class StringExtensions 
    { 
        // 3 
        public static void FancyDebug(this string str)
        { 
            // 4 
            Debug.LogFormat("This string contains {0} characters.", str.Length);
        }
    }
} 

让我们来分解一下代码:

  1. 首先,它声明了一个名为CustomExtensions的命名空间,用于保存所有扩展类和方法。

  2. 然后,为了组织目的,它声明了一个名为StringExtensionsstatic类;每组类扩展都应遵循此设置。

  3. 接下来,它向StringExtensions类添加了一个名为FancyDebugstatic方法:

  • 第一个参数this string str标记该方法为扩展。

  • str参数将保存对FancyDebug()所调用的实际文本值的引用;我们可以在方法体内操作str,作为所有字符串文字的替代。

  1. 最后,每当执行FancyDebug时,它都会打印出一个调试消息,使用str.Length来引用调用该方法的字符串变量。

实际上,这将允许您向现有的 C#类或甚至您自己的自定义类添加任何自定义功能。现在扩展是String类的一部分,让我们来测试一下。要使用我们的新自定义字符串方法,我们需要在想要访问它的任何类中包含它。

打开GameBehavior并使用以下代码更新类:

using System.Collections; 
using System.Collections.Generic; 
using UnityEngine; 
**// 1** 
**using** **CustomExtensions;** 

public class GameBehavior : MonoBehaviour, IManager 
{ 
    // ... No changes needed ... 
    void Start() 
    { 
        // ... No changes needed ... 
    } 
    public void Initialize()  
    { 
        _state = "Game Manager initialized..";
        **// 2** 
        **_state.FancyDebug();**
        Debug.Log(_state);
    }
} 

让我们来分解一下代码:

  1. 首先,在文件顶部使用using指令添加CustomExtensions命名空间。

  2. 然后,它在Initialize()内部使用点表示法在_state字符串变量上调用FancyDebug,以打印出其值具有的个体字符数。

通过FancyDebug()扩展整个string类意味着任何字符串变量都可以访问它。由于第一个扩展方法参数引用了FancyDebug()所调用的任何string值,因此其长度将被正确打印出来,如下所示:

图 10.6:自定义扩展的示例输出

也可以使用相同的语法扩展自定义类,但如果您控制一个类,直接将额外功能添加到类中更常见。

本章我们将探讨的最后一个主题是命名空间,我们在本书的前面简要了解过。在下一节中,您将了解命名空间在 C#中扮演的更大角色,以及如何创建您自己的类型别名。

命名空间冲突和类型别名

随着您的应用程序变得更加复杂,您将开始将代码分成命名空间,确保您可以控制何时何地访问它。您还将使用第三方软件工具和插件,以节省实现已经可用的功能的时间。这两种情况都表明您正在不断提高您的编程知识,但它们也可能引起命名空间冲突。

命名空间冲突发生在有两个或更多具有相同名称的类或类型时,这种情况比你想象的要多。

良好的命名习惯往往会产生类似的结果,不知不觉中,您将处理多个名为ErrorExtension的类,而 Visual Studio 则会抛出错误。幸运的是,C#对这些情况有一个简单的解决方案:类型别名

定义类型别名可以让您明确选择在给定类中要使用的冲突类型,或者为现有的冗长名称创建一个更用户友好的名称。类型别名是通过using指令在类文件顶部添加的,后跟别名和分配的类型:

using AliasName = type; 

例如,如果我们想要创建一个类型别名来引用现有的Int64类型,我们可以这样说:

using CustomInt = System.Int64; 

现在CustomIntSystem.Int64类型的类型别名,编译器将把它视为Int64,让我们可以像使用其他类型一样使用它:

public CustomInt PlayerHealth = 100; 

你可以使用类型别名来使用你的自定义类型,或者使用相同的语法来使用现有的类型,只要它们在脚本文件的顶部与其他using指令一起声明。

有关using关键字和类型别名的更多信息,请查看 C#文档docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive

摘要

有了新的修饰符、方法重载、类扩展和面向对象的技能,我们离 C#之旅的终点只有一步之遥。记住,这些中级主题旨在让你思考知识的更复杂应用;不要认为你在本章学到的就是这些概念的全部。把它当作一个起点,然后继续前进。

在下一章中,我们将讨论泛型编程的基础知识,获得一些委托和事件的实际经验,并最后概述异常处理。

小测验-升级

  1. 哪个关键字会将变量标记为不可修改,但需要初始值?

  2. 你会如何创建一个基本方法的重载版本?

  3. 类和接口之间的主要区别是什么?

  4. 你会如何解决类中的命名空间冲突?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流等等。

立即加入!

packt.link/csharpunity2021

第十一章:介绍堆栈、队列和 HashSet

在上一章中,我们重新访问了变量、类型和类,看看它们在书的开头介绍的基本功能之外还提供了什么。在本章中,我们将更仔细地研究新的集合类型,并了解它们的中级能力。请记住,成为一个好的程序员并不是关于记忆代码,而是选择合适的工具来完成合适的工作。

本章中的每种新集合类型都有特定的目的。在大多数需要数据集合的情况下,列表或数组都可以很好地工作。然而,当您需要临时存储或控制集合元素的顺序,或更具体地说,它们被访问的顺序时,可以使用堆栈和队列。当您需要执行依赖于集合中每个元素都是唯一的操作时,可以使用 HashSet。

在您开始下一节中的代码之前,让我们列出您将要学习的主题:

  • 介绍堆栈

  • 查看和弹出元素

  • 使用队列

  • 添加、移除和查看元素

  • 使用 HashSet

  • 执行操作

介绍堆栈

在其最基本的层面上,堆栈是相同指定类型的元素集合。堆栈的长度是可变的,这意味着它可以根据它所持有的元素数量而改变。堆栈与列表或数组之间的重要区别在于元素的存储方式。而列表或数组按索引存储元素,堆栈遵循后进先出LIFO)模型,这意味着堆栈中的最后一个元素是第一个可访问的元素。这在您想要以相反顺序访问元素时非常有用。您应该注意它们可以存储null和重复值。一个有用的类比是一叠盘子——您放在堆栈上的最后一个盘子是您可以轻松拿到的第一个盘子。一旦它被移除,您堆叠的倒数第二个盘子就可以访问,依此类推。

本章中的所有集合类型都是System.Collections.Generic命名空间的一部分,这意味着您需要在要在其中使用它们的任何文件的顶部添加以下代码:

using System.Collections.Generic; 

现在您知道您将要处理的内容,让我们来看一下声明堆栈的基本语法。

堆栈变量声明需要满足以下要求:

  • Stack关键字,其元素类型在左右箭头字符之间,以及一个唯一名称

  • new关键字用于在内存中初始化堆栈,后跟Stack关键字和箭头字符之间的元素类型

  • 由分号结束的一对括号

在蓝图形式中,它看起来像这样:

Stack<elementType> name = new Stack<elementType>(); 

与您之前使用过的其他集合类型不同,堆栈在创建时不能用元素初始化。相反,所有元素都必须在创建堆栈后添加。

C#支持不需要定义堆栈中元素类型的非通用版本:

Stack myStack = new Stack(); 

然而,这比使用前面的通用版本更不安全且更昂贵,因此建议使用上面的通用版本。您可以在github.com/dotnet/platform-compat/blob/master/docs/DE0006.md上阅读有关 Microsoft 的建议的更多信息。

您的下一个任务是创建自己的堆栈,并亲自体验使用其类方法。

为了测试这一点,您将使用堆栈修改英雄诞生中的现有物品收集逻辑,以存储可以收集的可能战利品。堆栈在这里很有效,因为我们不必担心提供索引来获取战利品,我们可以每次都获取最后添加的战利品:

  1. 打开GameBehavior.cs并添加一个名为LootStack的新堆栈变量:
**// 1**
public Stack<string> LootStack = new Stack<string>(); 
  1. 使用以下代码更新Initialize方法以向堆栈添加新项:
public void Initialize() 
{
    _state = "Game Manager initialized..";
    _state.FancyDebug();
    Debug.Log(_state);
    **// 2**
    **LootStack.Push(****"Sword of Doom"****);**
    **LootStack.Push(****"HP Boost"****);**
    **LootStack.Push(****"Golden Key"****);**
    **LootStack.Push(****"Pair of Winged Boots"****);**
    **LootStack.Push(****"Mythril Bracer"****);**
} 
  1. 在脚本底部添加一个新方法来打印堆栈信息:
**// 3**
public void PrintLootReport()
{
    Debug.LogFormat("There are {0} random loot items waiting 
       for you!", LootStack.Count);
} 
  1. 打开ItemBehavior.cs,并从GameManager实例中调用PrintLootReport
void OnCollisionEnter(Collision collision)
{
    if(collision.gameObject.name == "Player")
    {
        Destroy(this.transform.parent.gameObject);
        Debug.Log("Item collected!");
        GameManager.Items += 1;

        **// 4**
        **GameManager.PrintLootReport();**
    }
} 

将其分解,它执行以下操作:

  1. 创建一个空堆栈,其中包含字符串类型的元素,用于保存我们接下来要添加的战利品

  2. 使用Push方法向堆栈中添加字符串元素(即战利品名称),每次增加其大小

  3. 每当调用PrintLootReport方法时,都会打印出堆栈计数

  4. OnCollisionEnter中调用PrintLootReport,每当玩家收集一个物品时都会调用,我们在之前的章节中使用 Collider 组件进行了设置。

在 Unity 中点击播放,收集一个物品预制件,并查看打印出来的新战利品报告。

图 11.1:使用堆栈的输出

现在您已经有一个可以保存所有游戏战利品的工作堆栈,您可以开始尝试使用堆栈类的PopPeek方法访问物品。

弹出和窥视

我们已经讨论过堆栈如何使用 LIFO 方法存储元素。现在,我们需要看一下如何访问熟悉但不同的集合类型中的元素——通过窥视和弹出:

  • Peek方法返回堆栈中的下一个物品,而不移除它,让您可以在不改变任何内容的情况下“窥视”它

  • Pop方法返回并移除堆栈中的下一个物品,实质上是“弹出”它并交给您

这两种方法可以根据您的需要单独或一起使用。在接下来的部分中,您将亲身体验这两种方法。

您的下一个任务是抓取添加到LootStack中的最后一个物品。在我们的示例中,最后一个元素是在Initialize方法中以编程方式确定的,但您也可以在Initialize中以编程方式随机排列添加到堆栈中的战利品的顺序。无论哪种方式,都要在GameBehavior中更新PrintLootReport(),使用以下代码:

public void PrintLootReport()
{
    **// 1**
    **var** **currentItem = LootStack.Pop();**
    **// 2**
    **var** **nextItem = LootStack.Peek();**
    **// 3**
    **Debug.LogFormat(****"You got a {0}! You've got a good chance of finding a {1} next!"****, currentItem, nextItem);**
    Debug.LogFormat("There are {0} random loot items waiting for you!", LootStack.Count);
} 

以下是正在发生的事情:

  1. LootStack上调用Pop,移除堆栈中的下一个物品,并存储它。请记住,堆栈元素是按照 LIFO 模型排序的。

  2. LootStack上调用Peek,并存储堆栈中的下一个物品,而不移除它。

  3. 添加一个新的调试日志,打印出弹出的物品和堆栈中的下一个物品。

您可以从控制台看到,秘银护腕是最后添加到堆栈中的物品,被最先弹出,接着是一双翅膀靴,它被窥视但没有被移除。您还可以看到LootStack还有四个剩余的可以访问的元素:

图 11.2:从堆栈中弹出和窥视的输出

我们的玩家现在可以按照堆栈中添加的相反顺序拾取战利品。例如,首先拾取的物品将始终是秘银护腕,然后是一双翅膀靴,然后是金色钥匙,依此类推。

现在您知道如何创建、添加和查询堆栈中的元素,我们可以继续学习通过堆栈类可以访问的一些常见方法。

常见方法

本节中的每个方法仅用于示例目的,它们不包括在我们的游戏中,因为我们不需要这些功能。

首先,您可以使用Clear方法清空或删除堆栈的全部内容:

// Empty the stack and reverting the count to 0
LootStack**.Clear();** 

如果您想知道您的堆栈中是否存在某个元素,请使用Contains方法并指定您要查找的元素:

// Returns true for "Golden Key" item
var itemFound = LootStack**.Contains(****"Golden Key"****);** 

如果您需要将堆栈的元素复制到数组中,CopyTo方法将允许您指定目标和复制操作的起始索引。当您需要在数组的特定位置插入堆栈元素时,这个功能非常有用。请注意,您要将堆栈元素复制到的数组必须已经存在:

// Creates a new array of the same length as LootStack
string[] CopiedLoot = new string[5]; 
/* 
Copies the LootStack elements into the new CopiedLoot array at index 0\. The index parameter can be set to any index where you want the copied elements to be stored
*/
LootStack**.CopyTo(copiedLoot,** **0****);** 

如果您需要将堆栈转换为数组,只需使用ToArray()方法。这种转换会从您的堆栈中创建一个新数组,这与CopyTo()方法不同,后者将堆栈元素复制到现有数组中:

// Copies an existing stack to a new array
LootStack.ToArray(); 

您可以在 C#文档中找到完整的堆栈方法列表docs.microsoft.com/dotnet/api/system.collections.generic.stack-1?view=netcore-3.1

这就结束了我们对堆栈的介绍,但是我们将在下一节中讨论它的堂兄,队列。

使用队列

与堆栈一样,队列是相同类型的元素或对象的集合。任何队列的长度都是可变的,就像堆栈一样,这意味着随着元素的添加或移除,其大小会发生变化。但是,队列遵循先进先出FIFO)模型,这意味着队列中的第一个元素是第一个可访问的元素。您应该注意,队列可以存储null和重复的值,但在创建时不能用元素初始化。本节中的代码仅用于示例目的,不包括在我们的游戏中。

队列变量声明需要具备以下内容:

  • Queue关键字,其元素类型在左右箭头字符之间,以及一个唯一名称

  • 使用new关键字在内存中初始化队列,然后是Queue关键字和箭头字符之间的元素类型

  • 一对括号,以分号结束

以蓝图形式,队列如下所示:

Queue<elementType> name = new Queue<elementType>(); 

C#支持队列类型的非泛型版本,无需定义存储的元素类型:

Queue myQueue = new Queue(); 

但是,这比使用前面的泛型版本更不安全且更昂贵。您可以在github.com/dotnet/platform-compat/blob/master/docs/DE0006.md上阅读有关 Microsoft 建议的更多信息。

一个空的队列本身并不那么有用;您希望能够在需要时添加、移除和查看其元素,这是下一节的主题。

添加、移除和查看

由于前几节中的LootStack变量很容易成为队列,我们将保持以下代码不包含在游戏脚本中以提高效率。但是,您可以自由地探索这些类在您自己的代码中的差异或相似之处。

要创建一个字符串元素的队列,请使用以下方法:

// Creates a new Queue of string values.
Queue<string> activePlayers = new Queue<string>(); 

要向队列添加元素,请使用Enqueue方法并提供要添加的元素:

// Adds string values to the end of the Queue.
activePlayers**.Enqueue(****"Harrison"****);**
activePlayers**.Enqueue(****"Alex"****);**
activePlayers**.Enqueue(****"Haley"****);** 

要查看队列中的第一个元素而不移除它,请使用Peek方法:

// Returns the first element in the Queue without removing it.
var firstPlayer = activePlayers**.Peek();** 

要返回并移除队列中的第一个元素,请使用Dequeue方法:

// Returns and removes the first element in the Queue.
var firstPlayer = activePlayers**.Dequeue();** 

现在您已经了解了如何使用队列的基本特性,请随意探索队列类提供的更中级和高级方法。

常见方法

队列和堆栈几乎具有完全相同的特性,因此我们不会再次介绍它们。您可以在 C#文档中找到完整的方法和属性列表docs.microsoft.com/dotnet/api/system.collections.generic.queue-1?view=netcore-3.1

在结束本章之前,让我们来看看 HashSet 集合类型及其独特适用的数学运算。

使用 HashSets

本章中我们将接触的最后一个集合类型是 HashSet。这个集合与我们遇到的任何其他集合类型都非常不同:它不能存储重复的值,也不是排序的,这意味着它的元素没有以任何方式排序。将 HashSets 视为只有键而不是键值对的字典。

它们可以执行集合操作和元素查找非常快,我们将在本节末尾进行探讨,并且最适合元素顺序和唯一性是首要考虑的情况。

HashSet 变量声明需要满足以下要求:

  • HashSet关键字,其元素类型在左右箭头字符之间,以及一个唯一名称

  • 使用new关键字在内存中初始化 HashSet,然后是HashSet关键字和箭头字符之间的元素类型

  • 由分号结束的一对括号

在蓝图形式中,它看起来如下:

HashSet<elementType> name = new HashSet<elementType>(); 

与栈和队列不同,你可以在声明变量时使用默认值初始化 HashSet:

HashSet<string> people = new HashSet<string>();
// OR
HashSet<string> people = new HashSet<string>() { "Joe", "Joan", "Hank"}; 

添加元素时,使用Add方法并指定新元素:

people**.Add(****"Walter"****);**
people**.Add(****"Evelyn"****);** 

要删除一个元素,调用Remove并指定你想要从 HashSet 中删除的元素:

people**.Remove(****"Joe"****);** 

这就是简单的内容了,在你的编程之旅中,这一点应该开始感觉相当熟悉了。集合操作是 HashSet 集合真正发光的地方,这是接下来章节的主题。

执行操作

集合操作需要两样东西:一个调用集合对象和一个传入的集合对象。

调用集合对象是你想要根据使用的操作修改的 HashSet,而传入的集合对象是由集合操作进行比较使用的。我们将在接下来的代码中详细介绍这一点,但首先,让我们先了解一下在编程场景中最常见的三种主要集合操作。

在以下定义中,currentSet指的是调用操作方法的 HashSet,而specifiedSet指的是传入的 HashSet 方法参数。修改后的 HashSet 始终是当前集合:

currentSet.Operation(specifiedSet); 

在接下来的这一部分,我们将使用三种主要操作:

  • UnionWith将当前集合和指定集合的元素添加在一起。

  • IntersectWith仅存储当前集合和指定集合中都存在的元素

  • ExceptWith从当前集合中减去指定集合的元素

还有两组处理子集和超集计算的集合操作,但这些针对特定用例,超出了本章的范围。你可以在docs.microsoft.com/dotnet/api/system.collections.generic.hashset-1?view=netcore-3.1找到所有这些方法的相关信息。

假设我们有两组玩家名称的集合——一个是活跃玩家的集合,另一个是非活跃玩家的集合:

HashSet<string> activePlayers = new HashSet<string>() { "Harrison", "Alex", "Haley"};
HashSet<string> inactivePlayers = new HashSet<string>() { "Kelsey", "Basel"}; 

我们将使用UnionWith()操作来修改一个集合,以包括两个集合中的所有元素:

activePlayers.UnionWith(inactivePlayers);
/* activePlayers now stores "Harrison", "Alex", "Haley", "Kelsey", "Basel"*/ 

现在,假设我们有两个不同的集合——一个是活跃玩家的集合,另一个是高级玩家的集合:

HashSet<string> activePlayers = new HashSet<string>() { "Harrison", "Alex", "Haley"};
HashSet<string> premiumPlayers = new HashSet<string>() { "Haley", "Basel"}; 

我们将使用IntersectWith()操作来查找任何既是活跃玩家又是高级会员的玩家:

activePlayers.IntersectWith(premiumPlayers);
// activePlayers now stores only "Haley" 

如果我们想找到所有活跃玩家中不是高级会员的玩家怎么办?我们将通过调用ExceptWith来执行与IntersectWith()操作相反的操作:

HashSet<string> activePlayers = new HashSet<string>() { "Harrison", "Alex", "Haley"};
HashSet<string> premiumPlayers = new HashSet<string>() { "Haley",
  "Basel"};
activePlayers.ExceptWith(premiumPlayers);
// activePlayers now stores "Harrison" and "Alex" but removed "Haley" 

请注意,我在每个操作中使用了两个示例集合的全新实例,因为当前集合在执行每个操作后都会被修改。如果你一直使用相同的集合,你会得到不同的结果。

现在你已经学会了如何使用 HashSets 执行快速数学运算,是时候结束我们的章节,总结我们所学到的知识了。

中间集合总结

在你继续阅读总结和下一章之前,让我们再次强调一些我们刚刚学到的关键点。有时,与我们正在构建的实际游戏原型不总是一对一关系的主题需要额外的关注。

在这一点上,我确定你会问自己一个问题:为什么在任何情况下都要使用这些其他集合类型,而不是只使用列表呢?这是一个完全合理的问题。简单的答案是,当在正确的情况下应用时,栈、队列和 HashSets 比列表提供更好的性能。例如,当你需要按特定顺序存储项目并按特定顺序访问它们时,栈比列表更有效。

更复杂的答案是,使用不同的集合类型会强制规定您的代码如何与它们及其元素进行交互。这是良好代码设计的标志,因为它消除了您计划如何使用集合的任何歧义。到处都是列表,当您不记得要求它们执行什么功能时,事情就会变得混乱。

与本书中学到的一切一样,最好始终使用合适的工具来完成手头的工作。更重要的是,您需要有不同的工具可供选择。

摘要

恭喜,您几乎到达终点了!在本章中,您了解了三种新的集合类型,以及它们在不同情况下的用法。

如果您想以添加顺序的相反顺序访问集合元素,则堆栈非常适合,如果您想以顺序顺序访问元素,则队列是您的选择,两者都非常适合临时存储。这些集合类型与列表或数组之间的重要区别在于它们如何通过弹出和查看操作进行访问。最后,您了解了强大的 HashSet 及其基于性能的数学集合操作。在需要处理唯一值并对大型集合执行添加、比较或减法操作的情况下,这些是关键。

在下一章中,您将深入了解 C#的中级世界,包括委托、泛型等,因为您接近本书的结尾。即使您已经学到了所有知识,最后一页仍然只是另一段旅程的开始。

中级集合小测验

  1. 哪种集合类型使用 LIFO 模型存储其元素?

  2. 哪种方法让您查询堆栈中的下一个元素而不移除它?

  3. 堆栈和队列能存储null值吗?

  4. 如何从一个 HashSet 中减去另一个 HashSet?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事会话与作者交流等等。

立即加入!

packt.link/csharpunity2021

第十二章:保存、加载和序列化数据

您玩过的每个游戏都使用数据,无论是您的玩家统计数据、游戏进度还是在线多人游戏积分榜。您最喜欢的游戏还管理内部数据,这意味着程序员使用硬编码信息来构建级别、跟踪敌人统计数据并编写有用的实用程序。换句话说,数据无处不在。

在本章中,我们将从 C#和 Unity 如何处理计算机上的文件系统开始,并继续阅读、写入和序列化我们的游戏数据。我们的重点是处理您可能会遇到的三种最常见的数据格式:文本文件、XML 和 JSON。

在本章结束时,您将对计算机的文件系统、数据格式和基本的读写功能有一个基础的理解。这将是您构建游戏数据的基础,为玩家创造更丰富和引人入胜的体验。您还将有一个很好的起点,开始思考哪些游戏数据是重要的,以及您的 C#类和对象在不同的数据格式中会是什么样子。

在这个过程中,我们将涵盖以下主题:

  • 介绍文本、XML 和 JSON 格式

  • 了解文件系统

  • 使用不同的流类型

  • 阅读和写入游戏数据

  • 序列化对象

介绍数据格式

数据在编程中可以采用不同的形式,但您在数据旅程开始时应熟悉的三种格式是:

  • 文本,这就是您现在正在阅读的内容

  • XML可扩展标记语言),这是一种编码文档信息的方式,使其对您和计算机可读

  • JSONJavaScript 对象表示),这是一种由属性-值对和数组组成的可读文本格式

每种数据格式都有其自身的优势和劣势,以及在编程中的应用。例如,文本通常用于存储更简单、非分层或嵌套的信息。XML 更擅长以文档格式存储信息,而 JSON 在数据库信息和应用程序的服务器通信方面具有更广泛的能力。

您可以在www.xml.com找到有关 XML 的更多信息,以及在www.json.org找到有关 JSON 的信息。

数据在任何编程语言中都是一个重要的主题,因此让我们从下两节中实际了解 XML 和 JSON 格式是什么样子开始。

分解 XML

典型的 XML 文件具有标准化格式。XML 文档的每个元素都有一个开放标签(<element_name>),一个关闭标签(</element_name>),并支持标签属性(<element_name attribute= "attribute_name"></element_name>)。一个基本文件将以正在使用的版本和编码开始,然后是起始或根元素,然后是元素项列表,最后是关闭元素。作为蓝图,它将如下所示:

<?xml version="1.0" encoding="utf-8"?>
<root_element>
    <element_item>[Information goes here]</element_item>
    <element_item>[Information goes here]</element_item>
    <element_item>[Information goes here]</element_item>
</root_element> 

XML 数据还可以通过使用子元素存储更复杂的对象。例如,我们将使用我们在本书中早些时候编写的Weapon类,将武器列表转换为 XML。由于每个武器都有其名称和伤害值的属性,它将如下所示:

// 1
<?xml version="1.0"?>
// 2
<ArrayOfWeapon>
     // 3
    <Weapon>
     // 4
        <name>Sword of Doom</name>
        <damage>100</damage>
     // 5
    </Weapon>
    <Weapon>
        <name>Butterfly knives</name>
        <damage>25</damage>
    </Weapon>
    <Weapon>
        <name>Brass Knuckles</name>
        <damage>15</damage>
    </Weapon>
// 6
</ArrayOfWeapon> 

让我们分解上面的示例,确保我们理解正确:

  1. XML 文档以正在使用的版本开头

  2. 根元素使用名为ArrayOfWeapon的开放标签声明,它将保存所有我们的元素项

  3. 使用开放标签Weapon创建了一个武器项目

  4. 其子属性是通过单行上的开放和关闭标签添加的,用于namedamage

  5. 武器项目已关闭,并添加了两个武器项目

  6. 数组关闭,标志着文档的结束

好消息是我们的应用程序不必手动以这种格式编写我们的数据。C#有一个完整的类和方法库,可以帮助我们直接将简单文本和类对象转换为 XML。

稍后我们将深入实际的代码示例,但首先我们需要了解 JSON 的工作原理。

解析 JSON

JSON 数据格式类似于 XML,但没有标签。相反,一切都基于属性-值对,就像我们在第四章“控制流和集合类型”中使用的Dictionary集合类型一样。每个 JSON 文档都以一个父字典开始,其中包含您需要的许多属性-值对。字典使用开放和关闭的大括号({}),冒号分隔每个属性和值,每个属性-值对之间用逗号分隔:

// Parent dictionary for the entire file
{
    // List of attribute-value pairs where you store your data
    "attribute_name": value,
    "attribute_name": value
} 

JSON 也可以通过将属性-值对的值设置为属性-值对数组来具有子结构。例如,如果我们想要存储一把武器,它会是这样的:

// Parent dictionary
{
    // Weapon attribute with its value set to an child dictionary
    "weapon": {
          // Attribute-value pairs with weapon data
          "name": "Sword of Doom",
          "damage": 100
    }
} 

最后,JSON 数据通常由列表、数组或对象组成。继续我们的例子,如果我们想要存储玩家可以选择的所有武器的列表,我们将使用一对方括号来表示一个数组:

// Parent dictionary
{
    // List of weapon attribute set to an array of weapon objects
    "weapons": [
        // Each weapon object stored as its own dictionary
        {
            "name": "Sword of Doom",
            "damage": 100
        },
        {
            "name": "Butterfly knives",
            "damage": 25
        },
        {
            "name": "Brass Knuckles",
            "damage": 15
        }
    ]
} 

您可以混合和匹配这些技术来存储您需要的任何类型的复杂数据,这是 JSON 的主要优势之一。但就像 XML 一样,不要被新的语法所吓倒——C#和 Unity 都有辅助类和方法,可以将文本和类对象转换为 JSON,而无需我们做任何繁重的工作。阅读 XML 和 JSON 有点像学习一门新语言——您使用得越多,它就会变得越熟悉。很快它就会成为第二天性!

现在我们已经初步了解了数据格式化的基础知识,我们可以开始讨论计算机上的文件系统是如何工作的,以及我们可以从 C#代码中访问哪些属性。

了解文件系统

当我们说文件系统时,我们指的是您已经熟悉的东西——文件和文件夹如何在计算机上创建、组织和存储。当您在计算机上创建一个新文件夹时,您可以为其命名并将文件或其他文件夹放入其中。它也由图标表示,这既是一种视觉提示,也是一种拖放和移动到任何您喜欢的位置的方式。

您可以在桌面上做的任何事情都可以在代码中完成。您只需要文件夹的名称,或者称为目录,以及存储它的位置。每当您想要添加文件或子文件夹时,您都需要引用父目录并添加新内容。

为了更好地理解文件系统,让我们开始构建DataManager类:

  1. Hierarchy中右键单击并选择Create Empty,然后命名为Data_Manager

图 12.1:Hierarchy 中的 Data_Manager

  1. Hierarchy中选择Data_Manager对象,并将我们在第十章“重新审视类型、方法和类”中创建的DataManager脚本从Scripts文件夹拖放到Inspector中:

图 12.2:Inspector 中的 Data_Manager

  1. 打开DataManager脚本,并使用以下代码更新它以打印出一些文件系统属性:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

**// 1**
**using** **System.IO;**

public class DataManager : MonoBehaviour, IManager
{
    // ... No variable changes needed ...

    public void Initialize()
    {
        _state = "Data Manager initialized..";
        Debug.Log(_state);

        **// 2**
        **FilesystemInfo();**
    }
    public void FilesystemInfo()
    {
        **// 3**
        **Debug.LogFormat(****"Path separator character: {0}"****,**
          **Path.PathSeparator);**
        **Debug.LogFormat(****"Directory separator character: {0}"****,**
          **Path.DirectorySeparatorChar);**
        **Debug.LogFormat(****"Current directory: {0}"****,**
          **Directory.GetCurrentDirectory());**
        **Debug.LogFormat(****"Temporary path: {0}"****,**
          **Path.GetTempPath());**
    }
} 

让我们分解代码:

  1. 首先,我们添加System.IO命名空间,其中包含了我们需要处理文件系统的所有类和方法。

  2. 我们调用我们在下一步创建的FilesystemInfo方法。

  3. 我们创建FilesystemInfo方法来打印出一些文件系统属性。每个操作系统都以不同的方式处理其文件系统路径——路径是以字符串形式写入的目录或文件的位置。在 Mac 上:

  • 路径由冒号(:)分隔

  • 目录由斜杠(/)分隔

  • 当前目录路径是Hero Born项目存储的位置

  • 临时路径是您文件系统的临时文件夹的位置

如果您使用其他平台和操作系统,请在使用文件系统之前自行检查PathDirectory方法。

运行游戏并查看输出:

图 12.3:来自数据管理器的控制台消息

PathDirectory类是我们将在接下来的部分中用来存储数据的基础。然而,它们都是庞大的类,所以我鼓励您在继续数据之旅时查阅它们的文档。

您可以在docs.microsoft.com/en-us/dotnet/api/system.io.path找到Path类的更多文档,以及在docs.microsoft.com/en-us/dotnet/api/system.io.directory找到Directory类的更多文档。

现在我们在DataManager脚本中打印出了文件系统属性的简单示例,我们可以创建一个文件系统路径,将数据保存到我们想要保存数据的位置。

处理资源路径

在纯 C#应用程序中,您需要选择要保存文件的文件夹,并将文件夹路径写入字符串中。然而,Unity 提供了一个方便的预配置路径作为Application类的一部分,您可以在其中存储持久游戏数据。持久数据意味着信息在每次程序运行时都会被保存和保留,这使得它非常适合这种玩家信息。

重要的是要知道,Unity 持久数据目录的路径是跨平台的,这意味着为 iOS、Android、Windows 等构建游戏时会有所不同。您可以在 Unity 文档中找到更多信息docs.unity3d.com/ScriptReference/Application-persistentDataPath.html

我们需要对DataManager进行的唯一更新是创建一个私有变量来保存我们的路径字符串。我们将其设置为私有,因为我们不希望任何其他脚本能够访问或更改该值。这样,DataManager负责所有与数据相关的逻辑,而不会有其他东西。

DataManager.cs中添加以下变量:

public class DataManager : MonoBehaviour, IManager
{
    // ... No other variable changes needed ...

    **// 1**
    **private****string** **_dataPath;**
    **// 2**
    **void****Awake****()**
    **{**
        **_dataPath = Application.persistentDataPath +** **"/Player_Data/"****;**

        **Debug.Log(_dataPath);**
    **}**

    // ... No other changes needed ...
} 

让我们分解一下我们的代码更新:

  1. 我们创建了一个私有变量来保存数据路径字符串

  2. 我们将数据路径字符串设置为应用程序的persistentDataPath值,使用开放和关闭的斜杠添加了一个名为Player_Data的新文件夹,并打印出完整路径:

  • 重要的是要注意,Application.persistentDataPath只能在MonoBehaviour方法中使用,如Awake()Start()Update()等,游戏需要运行才能让 Unity 返回有效的路径。

图 12.4:Unity 持久数据文件的文件路径

由于我使用的是 Mac,我的持久数据文件夹嵌套在我的/Users文件夹中。如果您使用不同的设备,请记得查看docs.unity3d.com/ScriptReference/Application-persistentDataPath.html以找出您的数据存储在何处。

当您不使用类似 Unity 持久数据目录这样的预定义资源路径时,C#中有一个名为Combine的便利方法,位于Path类中,用于自动配置路径变量。Combine()方法最多可以接受四个字符串作为输入参数,或者表示路径组件的字符串数组。例如,指向您的User目录的路径可能如下所示:

var path = Path.Combine("/Users", "hferrone", "Chapter_12"); 

这解决了路径和目录中的分隔字符和反斜杠或正斜杠的任何潜在跨平台问题。

现在我们有了一个存储数据的路径,让我们在文件系统中创建一个新目录,或文件夹。这将使我们能够安全地存储我们的数据,并在游戏运行之间进行存储,而不是在临时存储中被删除或覆盖。

创建和删除目录

创建新目录文件夹很简单-我们检查是否已经存在具有相同名称和相同路径的目录,如果没有,我们告诉 C#为我们创建它。每个人都有自己处理文件和文件夹中重复内容的方法,因此在本章的其余部分中我们将重复相当多的重复检查代码。

我仍然建议在现实世界的应用程序中遵循DRY不要重复自己)原则;重复检查代码只是为了使示例完整且易于理解而在这里重复。

  1. DataManager中添加以下方法:
public void NewDirectory()
{
    // 1
    if(Directory.Exists(_dataPath))
    {
        // 2
        Debug.Log("Directory already exists...");
        return;
    }
    // 3
    Directory.CreateDirectory(_dataPath);
    Debug.Log("New directory created!");
} 
  1. Initialize()中调用新方法:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);
    **NewDirectory();**
} 

让我们分解一下我们所做的事情:

  1. 首先,我们使用上一步创建的路径检查目录文件夹是否已经存在

  2. 如果已经创建,我们会在控制台中发送消息,并使用return关键字退出方法,不再继续执行

  3. 如果目录文件夹不存在,我们将向CreateDirectory()方法传递我们的数据路径,并记录它已被创建

运行游戏,并确保您在控制台中看到正确的调试日志,以及您的持久数据文件夹中的新目录文件夹。

如果找不到它,请使用我们在上一步中打印出的_dataPath值。

图 12.5:新目录创建的控制台消息

图 12.6:在桌面上创建的新目录

如果您第二次运行游戏,将不会创建重复的目录文件夹,这正是我们想要的安全代码。

图 12.7:重复目录文件夹的控制台消息

删除目录与创建方式非常相似-我们检查它是否存在,然后使用Directory类删除我们传入路径的文件夹。

DataManager中添加以下方法:

public void DeleteDirectory()
{
    // 1
    if(!Directory.Exists(_dataPath))
    {
        // 2
        Debug.Log("Directory doesn't exist or has already been
deleted...");

        return;
    }
    // 3
    Directory.Delete(_dataPath, true);
    Debug.Log("Directory successfully deleted!");
} 

由于我们想保留我们刚刚创建的目录,您现在不必调用此函数。但是,如果您想尝试它,您只需要在Initialize()函数中用DeleteDirectory()替换NewDirectory()

空目录文件夹并不是很有用,所以让我们创建我们的第一个文本文件并将其保存在新位置。

创建、更新和删除文件

与创建和删除目录类似,处理文件也是如此,因此我们已经拥有了我们需要的基本构件。为了确保我们不重复数据,我们将检查文件是否已经存在,如果不存在,我们将在新目录文件夹中创建一个新文件。

在本节中,我们将使用File类来处理文件,该类具有大量有用的方法来帮助我们实现我们的功能。您可以在docs.microsoft.com/en-us/dotnet/api/system.io.file找到整个列表。

在我们开始之前,关于文件的一个重要观点是,在添加文本之前需要打开文件,并且在完成后需要关闭文件。如果不关闭正在程序化处理的文件,它将保持在程序的内存中。这既使用了计算能力,又可能导致内存泄漏。稍后在本章中会详细介绍。

我们将为我们想要执行的每个操作(创建、更新和删除)编写单独的方法。我们还将在每种情况下检查我们正在处理的文件是否存在,这是重复的。我构建了本书的这一部分,以便您可以牢固掌握每个过程。但是,在学会基础知识后,您绝对可以将它们合并为更经济的方法。

采取以下步骤:

  1. 为新文本文件添加一个新的私有字符串路径,并在Awake中设置其值:
private string _dataPath;
**private****string** **_textFile;**
void Awake()
{
    _dataPath = Application.persistentDataPath + "/Player_Data/";

    Debug.Log(_dataPath);

    **_textFile = _dataPath +** **"Save_Data.txt"****;**
} 
  1. DataManager中添加一个新方法:
public void NewTextFile()
{
    // 1
    if (File.Exists(_textFile))
    {
        Debug.Log("File already exists...");
        return;
    }
    // 2
    File.WriteAllText(_textFile, "<SAVE DATA>\n\n");
    // 3
    Debug.Log("New file created!");
} 
  1. Initialize()中调用新方法:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    **NewTextFile();**
} 

让我们分解一下我们的新代码:

  1. 我们检查文件是否已经存在,如果存在,我们将使用return退出方法以避免重复:
  • 值得注意的是,这种方法适用于不会被更改的新文件。我们将在下一个练习中讨论更新和覆盖文件数据。
  1. 我们使用WriteAllText()方法,因为它可以一次完成所有需要的操作:
  • 使用我们的_textFile路径创建一个新文件

  • 我们添加一个标题字符串,写着<SAVE DATA>,并添加两个新行,使用\n字符

  • 然后文件会自动关闭

  1. 我们打印一个日志消息,让我们知道一切顺利进行

现在玩游戏,你会在控制台看到调试日志和持久数据文件夹位置中的新文本文件:

图 12.8:新文件创建的控制台消息

图 12.9:在桌面上创建的新文件

要更新我们的新文本文件,我们将进行类似的操作。知道新游戏何时开始总是很好,所以你的下一个任务是添加一个方法将这些信息写入我们的保存数据文件:

  1. DataManager的顶部添加一个新的using指令:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
**using** **System;** 
  1. DataManager中添加一个新方法:
public void UpdateTextFile()
{
    // 1
    if (!File.Exists(_textFile))
    {
        Debug.Log("File doesn't exist...");
        return;
    }

    // 2
    File.AppendAllText(_textFile, $"Game started: {DateTime.Now}\n");
    // 3
    Debug.Log("File updated successfully!");
} 
  1. Initialize()中调用新方法:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    NewTextFile();
    **UpdateTextFile();**
} 

让我们来分解上面的代码:

  1. 如果文件存在,我们不想重复创建,所以我们只是退出方法而不采取进一步的行动

  2. 如果文件存在,我们使用另一个名为AppendAllText()的一体化方法来添加游戏的开始时间:

  • 这个方法打开文件

  • 它添加一个作为方法参数传入的新文本行

  • 它关闭文件

  1. 打印一个日志消息,让我们知道一切顺利进行

再次玩游戏,你会看到我们的控制台消息和文本文件中的新行,显示了新游戏的日期和时间:

图 12.10:更新文本文件的控制台消息

图 12.11:更新的文本文件数据

为了读取我们的新文件数据,我们需要一个方法来获取文件的所有文本并以字符串形式返回给我们。幸运的是,File类有相应的方法:

  1. DataManager中添加一个新方法:
// 1
public void ReadFromFile(string filename)
{
    // 2
    if (!File.Exists(filename))
    {
        Debug.Log("File doesn't exist...");
        return;
    }

    // 3
    Debug.Log(File.ReadAllText(filename));
} 
  1. Initialize()中调用新方法,并将_textFile作为参数传入:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    NewTextFile();
    UpdateTextFile();
    **ReadFromFile(_textFile);**
} 

让我们来分解下面的新方法代码:

  1. 我们创建一个接受文件名参数的新方法

  2. 如果文件不存在,就不需要采取任何行动,所以我们退出方法

  3. 我们使用ReadAllText()方法将文件的所有文本数据作为字符串获取并打印到控制台

玩游戏,你会看到一个控制台消息,显示我们之前的保存和一个新的保存!

图 12.12:从文件中读取的保存文本数据的控制台消息

最后,让我们添加一个方法来删除我们的文本文件。实际上,我们不会使用这个方法,因为我们想保持我们的文本文件不变,但你可以自己尝试一下:

public void DeleteFile(string filename)
{
    if (!File.Exists(filename))
    {
        Debug.Log("File doesn't exist or has already been deleted...");

        return;
    }

    File.Delete(_textFile);
    Debug.Log("File successfully deleted!");
} 

现在我们已经深入了一点文件系统的水域,是时候谈谈一个稍微升级的处理信息方式了——数据流!

使用流进行操作

到目前为止,我们一直让File类来处理我们的数据。我们还没有讨论的是File类,或者任何其他处理读写数据的类是如何在底层工作的。

对于计算机来说,数据由字节组成。把字节想象成计算机的原子,它们构成了一切——甚至有一个 C#的byte类型。当我们读取、写入或更新文件时,我们的数据被转换为字节数组,然后使用Stream将这些字节流到文件中或从文件中流出。数据流负责将数据作为字节序列传输到文件中或从文件中传输,充当我们的游戏应用程序和数据文件之间的翻译器或中介。

图 12.13:将数据流到文件的图示

File类自动为我们使用Stream对象,不同的Stream子类有不同的功能:

  • 使用FileStream来读取和写入文件数据

  • 使用MemoryStream来读取和写入数据到内存

  • 使用NetworkStream来读取和写入数据到其他网络计算机

  • 使用GZipStream来压缩数据以便更容易存储和下载

在接下来的章节中,我们将深入了解管理流资源,使用名为StreamReaderStreamWriter的辅助类来创建、读取、更新和删除文件。您还将学习如何使用XmlWriter类更轻松地格式化 XML。

管理您的流资源

我们还没有谈论的一个重要主题是资源分配。这意味着您的代码中的一些进程将把计算能力和内存放在一种类似分期付款的计划中,您无法触及它。这些进程将等待,直到您明确告诉您的程序或游戏关闭并将分期付款资源归还给您,以便您恢复到全功率。流就是这样一个进程,它们在使用完毕后需要关闭。如果您不正确地关闭流,您的程序将继续使用这些资源,即使您不再使用它们。

幸运的是,C#有一个方便的接口叫做IDisposable,所有的Stream类都实现了这个接口。这个接口只有一个方法,Dispose(),它告诉流何时将使用的资源归还给您。

您不必太担心这个问题,因为我们将介绍一种自动方式来确保您的流始终正确关闭。资源管理只是一个很好的编程概念需要理解。

在本章的其余部分,我们将使用FileStream,但我们将使用称为StreamWriterStreamReader的便利类。这些类省去了将数据手动转换为字节的步骤,但仍然使用FileStream对象本身。

使用 StreamWriter 和 StreamReader

StreamWriterStreamReader类都是FileStream的辅助类,用于将文本数据写入和读取到特定文件。这些类非常有帮助,因为它们创建、打开并返回一个流,您可以使用最少的样板代码。到目前为止,我们已经涵盖的示例代码对于小型数据文件来说是可以的,但是如果您处理大型和复杂的数据对象,流是最好的选择。

我们只需要文件的名称,我们就可以开始了。您的下一个任务是使用流将文本写入新文件:

  1. 为新的流文本文件添加一个新的私有字符串路径,并在Awake()中设置其值:
private string _dataPath;
private string _textFile;
**private****string** **_streamingTextFile;**

void Awake()
{
    _dataPath = Application.persistentDataPath + "/Player_Data/";
    Debug.Log(_dataPath);

    _textFile = _dataPath + "Save_Data.txt";
    **_streamingTextFile = _dataPath +** **"Streaming_Save_Data.txt"****;**
} 
  1. DataManager添加一个新的方法:
public void WriteToStream(string filename)
{
    // 1
    if (!File.Exists(filename))
    {
        // 2
        StreamWriter newStream = File.CreateText(filename);

        // 3
        newStream.WriteLine("<Save Data> for HERO BORN \n\n");
        newStream.Close();
        Debug.Log("New file created with StreamWriter!");
    }

    // 4
    StreamWriter streamWriter = File.AppendText(filename);

    // 5
    streamWriter.WriteLine("Game ended: " + DateTime.Now);
    streamWriter.Close();
    Debug.Log("File contents updated with StreamWriter!");
} 
  1. 删除或注释掉我们在上一节中使用的Initialize()中的方法,并添加我们的新代码:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    **WriteToStream(_streamingTextFile);**
} 

让我们分解上述代码中的新方法:

  1. 首先,我们检查文件是否不存在

  2. 如果文件尚未创建,我们添加一个名为newStream的新StreamWriter实例,该实例使用CreateText()方法创建和打开新文件

  3. 文件打开后,我们使用WriteLine()方法添加标题,关闭流,并打印出调试消息

  4. 如果文件已经存在,我们只想要更新它,我们通过使用AppendText()方法的新StreamWriter实例来获取我们的文件,以便我们的现有数据不被覆盖

  5. 最后,我们写入游戏数据的新行,关闭流,并打印出调试消息!

图 12.14:使用流写入和更新文本的控制台消息

图 12.15:使用流创建和更新的新文件

从流中读取几乎与我们在上一节中创建的ReadFromFile()方法几乎完全相同。唯一的区别是我们将使用StreamReader实例来打开和读取信息。同样,当处理大数据文件或复杂对象时,您希望使用流,而不是使用File类手动创建和写入文件:

  1. DataManager添加一个新的方法:
public void ReadFromStream(string filename)
{
    // 1
    if (!File.Exists(filename))
    {
        Debug.Log("File doesn't exist...");
        return;
    }

    // 2
    StreamReader streamReader = new StreamReader(filename);
    Debug.Log(streamReader.ReadToEnd());
} 
  1. Initialize()中调用新方法,并将_streamingTextFile作为参数传入:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    WriteToStream(_streamingTextFile);
    **ReadFromStream(_streamingTextFile);**
} 

让我们分解一下我们的新代码:

  1. 首先,我们检查文件是否不存在,如果不存在,我们打印出一个控制台消息并退出方法

  2. 如果文件存在,我们使用要访问的文件的名称创建一个新的StreamReader实例,并使用ReadToEnd方法打印出整个内容!

图 12.16:控制台打印出从流中读取的保存数据

正如你将开始注意到的,我们的很多代码开始看起来一样。唯一的区别是我们使用流类来进行实际的读写工作。然而,重要的是要记住不同的用例将决定你采取哪种路线。回顾本节开头,了解每种流类型的不同之处。

到目前为止,我们已经介绍了使用文本文件的CRUD创建读取更新删除)应用程序的基本功能。但文本文件并不是你在 C#游戏和应用程序中使用的唯一数据格式。一旦你开始使用数据库和自己的复杂数据结构,你可能会看到大量的 XML 和 JSON,这些文本无法比拟的效率和存储。

在下一节中,我们将使用一些基本的 XML 数据,然后讨论一种更容易管理流的方法。

创建 XMLWriter

有时候你不只是需要简单的文本来写入和读取文件。你的项目可能需要 XML 格式的文档,这种情况下你需要知道如何使用常规的FileStream来保存和加载 XML 数据。

将 XML 数据写入文件并没有太大的不同,与我们之前使用文本和流的方式相似。唯一的区别是我们将显式创建一个FileStream并使用它来创建一个XmlWriter的实例。将XmlWriter类视为一个包装器,它接受我们的数据流,应用 XML 格式,并将我们的信息输出为 XML 文件。一旦我们有了这个,我们可以使用XmlWriter类的方法在适当的 XML 格式中构造文档并关闭文件。

你的下一个任务是为新的 XML 文档创建一个文件路径,并使用DataManager类的能力来将 XML 数据写入该文件:

  1. DataManager类的顶部添加突出显示的using指令:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System;
**using** **System.Xml;** 
  1. 为新的 XML 文件添加一个新的私有字符串路径,并在Awake()中设置其值:
// ... No other variable changes needed ...
**private****string** **_xmlLevelProgress;**
void Awake()
{
     // ... No other changes needed ...
     **_xmlLevelProgress = _dataPath +** **"Progress_Data.xml"****;**
} 
  1. DataManager类的底部添加一个新的方法:
public void WriteToXML(string filename)
{
    // 1
    if (!File.Exists(filename))
    {
        // 2
        FileStream xmlStream = File.Create(filename);

        // 3
        XmlWriter xmlWriter = XmlWriter.Create(xmlStream);

        // 4
        xmlWriter.WriteStartDocument();
        // 5
        xmlWriter.WriteStartElement("level_progress");

        // 6
        for (int i = 1; i < 5; i++)
        {
            xmlWriter.WriteElementString("level", "Level-" + i);
        }

        // 7
        xmlWriter.WriteEndElement();

        // 8
        xmlWriter.Close();
        xmlStream.Close();
    }
} 
  1. Initialize()中调用新方法,并传入_xmlLevelProgress作为参数:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    **WriteToXML(_xmlLevelProgress);**
} 

让我们分解一下我们的 XML 写入方法:

  1. 首先,我们检查文件是否已经存在

  2. 如果文件不存在,我们使用我们创建的新路径变量创建一个新的FileStream

  3. 然后我们创建一个新的XmlWriter实例,并将其传递给我们的新的FileStream

  4. 接下来,我们使用WriteStartDocument方法指定 XML 版本 1.0

  5. 然后我们调用WriteStartElement方法添加名为level_progress的根元素标签

  6. 现在我们可以使用WriteElementString方法向我们的文档添加单独的元素,通过使用for循环和其索引值i传入level作为元素标签和级别数字

  7. 为了关闭文档,我们使用WriteEndElement方法添加一个闭合的level标签

  8. 最后,我们关闭写入器和流,释放我们一直在使用的流资源

如果现在运行游戏,你会在我们的Player_Data文件夹中看到一个新的.xml文件,其中包含了级别进度信息:

图 12.17:使用文档数据创建的新 XML 文件

你会注意到没有缩进或格式化,这是预期的,因为我们没有指定任何输出格式。在这个例子中,我们不会使用任何输出格式,因为我们将在下一节中讨论一种更有效的写入 XML 数据的方法,即序列化。

你可以在docs.microsoft.com/dotnet/api/system.xml.xmlwriter#specifying-the-output-format找到输出格式属性的列表。

好消息是,读取 XML 文件与读取任何其他文件没有任何区别。您可以在initialize()内部调用readfromfile()readfromstream()方法,并获得相同的控制台输出:

public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);
    FilesystemInfo();
    NewDirectory();
    WriteToXML(_xmlLevelProgress);
    **ReadFromStream(_xmlLevelProgress);**
} 

图 12.18:从读取 XML 文件数据的控制台输出

现在我们已经编写了一些使用流的方法,让我们看看如何高效地,更重要的是自动地关闭任何流。

自动关闭流

当您使用流时,将它们包装在using语句中会通过从我们之前提到的IDisposable接口调用Dispose()方法来自动关闭流。

这样,您就永远不必担心程序可能会保持打开但未使用的分配资源。

语法几乎与我们已经完成的内容完全相同,只是在行的开头使用using关键字,然后在一对括号内引用一个新的流,然后是一组花括号。我们想要流执行的任何操作,比如读取或写入数据,都是在花括号的代码块内完成的。例如,创建一个新的文本文件,就像我们在WriteToStream()方法中所做的那样:

// The new stream is wrapped in a using statement
using(StreamWriter newStream = File.CreateText(filename))
{
     // Any writing functionality goes inside the curly braces
     newStream.WriteLine("<Save Data> for HERO BORN \n");
} 

一旦流逻辑在代码块内部,外部的using语句将自动关闭流并将分配的资源返回给您的程序。从现在开始,我建议您始终使用这种语法来编写您的流代码。这样更有效率,更安全,并且将展示您对基本资源管理的理解!

随着我们的文本和 XML 流代码的运行,是时候继续前进了。如果你想知道为什么我们没有流传输任何 JSON 数据,那是因为我们需要向我们的数据工具箱中添加一个工具——序列化!

序列化数据

当我们谈论序列化和反序列化数据时,我们实际上在谈论翻译。虽然在之前的章节中我们一直在逐步翻译我们的文本和 XML,但能够一次性地将整个对象翻译成另一种格式是一个很好的工具。

根据定义:

  • 序列化对象的行为是将对象的整个状态转换为另一种格式

  • 反序列化的行为是相反的,它将数据从文件中恢复到其以前的对象状态

图 12.19:将对象序列化为 XML 和 JSON 的示例

让我们从上面的图像中拿一个实际的例子——我们的Weapon类的一个实例。每个武器都有自己的名称和伤害属性以及相关的值,这被称为它的状态。对象的状态是独一无二的,这使得程序可以区分它们。

对象的状态还包括引用类型的属性或字段。例如,如果我们有一个Character类,它有一个Weapon属性,那么当序列化和反序列化时,C#仍然会识别武器的namedamage属性。您可能会在编程世界中听到具有引用属性的对象被称为对象图。

在我们开始之前,值得注意的是,如果您没有密切关注确保对象属性与文件中的数据匹配,反之亦然,那么序列化对象可能会很棘手。例如,如果您的类对象属性与正在反序列化的数据不匹配,序列化程序将返回一个空对象。当我们尝试在本章后面将 C#列表序列化为 JSON 时,我们将更详细地介绍这一点。

为了真正掌握这一点,让我们以我们的Weapon示例并将其转换为可工作的代码。

序列化和反序列化 XML

本章剩下的任务是将武器列表序列化和反序列化为 XML 和 JSON,首先是 XML!

  1. DataManager类的顶部添加一个新的using指令:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System;
using System.Xml;
**using** **System.Xml.Serialization;** 
  1. Weapon类添加一个可序列化的属性,以便 Unity 和 C#知道该对象可以被序列化:
**[****Serializable****]**
public struct Weapon
{
    // ... No other changes needed ...
} 
  1. 添加两个新变量,一个用于 XML 文件路径,一个用于武器列表:
// ... No other variable changes needed ...
**private****string** **_xmlWeapons;**
**private** **List<Weapon> weaponInventory =** **new** **List<Weapon>**
**{**
    **new** **Weapon(****"Sword of Doom"****,** **100****),**
    **new** **Weapon(****"Butterfly knives"****,** **25****),**
    **new** **Weapon(****"Brass Knuckles"****,** **15****),**
**};** 
  1. Awake中设置 XML 文件路径值:
void Awake()
{
    // ... No other changes needed ...
    **_xmlWeapons = _dataPath +** **"WeaponInventory.xml"****;**
} 
  1. DataManager类的底部添加一个新方法:
public void SerializeXML()
{
    // 1
    var xmlSerializer = new XmlSerializer(typeof(List<Weapon>));

    // 2
    using(FileStream stream = File.Create(_xmlWeapons))
    {
        // 3
        xmlSerializer.Serialize(stream, weaponInventory);
    }
} 
  1. Initialize中调用新方法:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    **SerializeXML();**
} 

让我们来分解我们的新方法:

  1. 首先,我们创建一个XmlSerializer实例,并传入我们要翻译的数据类型。在这种情况下,weaponInventory的类型是List<Weapon>,这是我们在typeof运算符中使用的类型:
  • XmlSerializer类是另一个有用的格式包装器,就像我们之前使用的XmlWriter类一样
  1. 然后,我们使用FileStream创建一个_xmlWeapons文件路径,并包装在using代码块中以确保它被正确关闭。

  2. 最后,我们调用Serialize()方法,并传入流和我们想要翻译的数据。

再次运行游戏,并查看我们创建的新 XML 文档,而无需指定任何额外的格式!

图 12.20:武器清单文件中的 XML 输出

要将我们的 XML 读回武器列表,我们几乎设置了完全相同的一切,只是我们使用了XmlSerializer类的Deserialize()方法:

  1. DataManager类的底部添加以下方法:
public void DeserializeXML()
{
    // 1
    if (File.Exists(_xmlWeapons))
    {
        // 2
        var xmlSerializer = new XmlSerializer(typeof(List<Weapon>));

        // 3
        using (FileStream stream = File.OpenRead(_xmlWeapons))
        {
           // 4
            var weapons = (List<Weapon>)xmlSerializer.Deserialize(stream);

           // 5
           foreach (var weapon in weapons)
           {
               Debug.LogFormat("Weapon: {0} - Damage: {1}", 
                 weapon.name, weapon.damage);
           }
        }
    }
} 
  1. Initialize中调用新方法,并将_xmlWeapons作为参数传入:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    SerializeXML();
    **DeserializeXML();**
} 

让我们来分解deserialize()方法:

  1. 首先,我们检查文件是否存在

  2. 如果文件存在,我们创建一个XmlSerializer对象,并指定我们将把 XML 数据放回List<Weapon>对象中

  3. 然后,我们用FileStream打开_xmlWeapons文件名:

  • 我们使用File.OpenRead()来指定我们要打开文件进行读取,而不是写入
  1. 接下来,我们创建一个变量来保存我们反序列化的武器列表:
  • 我们在Deserialize()调用前放置了显式的List<Weapon>转换,以便我们从序列化程序中获得正确的类型
  1. 最后,我们使用foreach循环在控制台中打印出每个武器的名称和伤害值

当您再次运行游戏时,您会看到我们从 XML 列表中反序列化的每个武器都会得到一个控制台消息。

图 12.21:从反序列化 XML 中的控制台输出

这就是我们对 XML 数据所需做的一切,但在我们完成本章之前,我们仍然需要学习如何处理 JSON!

序列化和反序列化 JSON

在序列化和反序列化 JSON 方面,Unity 和 C#并不完全同步。基本上,C#有自己的JsonSerializer类,它的工作方式与我们在先前示例中使用的XmlSerializer类完全相同。

为了访问 JSON 序列化程序,您需要System.Text.Jsonusing指令。这就是问题所在——Unity 不支持该命名空间。相反,Unity 使用System.Text命名空间,并实现了自己的 JSON 序列化程序类JsonUtility

因为我们的项目在 Unity 中,我们将使用 Unity 支持的序列化类。但是,如果您正在使用非 Unity 的 C#项目,概念与我们刚刚编写的 XML 代码相同。

您可以在docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-how-to#how-to-write-net-objects-as-json-serialize找到包含来自 Microsoft 的完整操作指南和代码。

您的下一个任务是序列化单个武器,以熟悉JsonUtility类:

  1. DataManager类的顶部添加一个新的using指令:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System;
using System.Xml;
using System.Xml.Serialization;
**using** **System.Text;** 
  1. 为新的 XML 文件添加一个私有字符串路径,并在Awake()中设置其值:
**private****string** **_jsonWeapons;**
void Awake()
{
    **_jsonWeapons = _dataPath +** **"WeaponJSON.json"****;**
} 
  1. DataManager类的底部添加一个新方法:
public void SerializeJSON()
{
    // 1
    Weapon sword = new Weapon("Sword of Doom", 100);
    // 2
    string jsonString = JsonUtility.ToJson(sword, true);

    // 3
    using(StreamWriter stream = File.CreateText(_jsonWeapons))
    {
        // 4
        stream.WriteLine(jsonString);
    }
} 
  1. Initialize()中调用新方法,并将_jsonWeapons作为参数传入:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    **SerializeJSON();**
} 

这是序列化方法的分解:

  1. 首先,我们需要一个要处理的武器,因此我们使用我们的类初始化器创建一个

  2. 然后,我们声明一个变量来保存格式化为字符串的翻译 JSON 数据,并调用ToJson()方法:

  • 我们正在使用的ToJson()方法接受我们要序列化的sword对象和一个布尔值true,以便字符串以正确的缩进方式漂亮打印。如果我们没有指定true值,JSON 仍然会打印出来,只是一个常规字符串,不容易阅读。
  1. 现在我们有一个要写入文件的文本字符串,我们创建一个StreamWriter流,并传入_jsonWeapons文件名

  2. 最后,我们使用WriteLine()方法,并将jsonString值传递给它以写入文件。

运行程序并查看我们创建并写入数据的新 JSON 文件!

图 12.22:序列化武器属性的 JSON 文件

现在让我们尝试序列化我们在 XML 示例中使用的武器列表,看看会发生什么。

更新SerializeJSON()方法,使用现有的武器列表而不是单个sword实例:

public void SerializeJSON()
{
    string jsonString = JsonUtility.ToJson(**weaponInventory,** true);

    using(StreamWriter stream = 
      File.CreateText(_jsonWeapons))
    {
        stream.WriteLine(jsonString);
    }
} 

当你再次运行游戏时,你会看到 JSON 文件数据被覆盖,我们最终得到的只是一个空数组:

图 12.23:序列化后为空对象的 JSON 文件

这是因为 Unity 处理 JSON 序列化的方式不支持单独的列表或数组。任何列表或数组都需要作为类对象的一部分,以便 Unity 的JsonUtility类能够正确识别和处理它。

不要惊慌,如果我们考虑一下,这是一个相当直观的修复方法——我们只需要创建一个具有武器列表属性的类,并在将数据序列化为 JSON 时使用它!

  1. 打开Weapon.cs并在文件底部添加以下可序列化的WeaponShop类。一定要小心将新类放在Weapon类花括号之外:
[Serializable]
public class WeaponShop
{
    public List<Weapon> inventory;
} 
  1. DataManager类中,使用以下代码更新SerializeJSON()方法:
public void SerializeJSON()
{
    // 1
    **WeaponShop shop =** **new** **WeaponShop();**
    **// 2**
    **shop.inventory = weaponInventory;**

    // 3
    string jsonString = JsonUtility.ToJson(**shop**, true);

    using(StreamWriter stream = File.CreateText(_jsonWeapons))
    {
        stream.WriteLine(jsonString);
    }
} 

让我们来分解刚刚做的更改:

  1. 首先,我们创建一个名为shop的新变量,它是WeaponShop类的一个实例

  2. 然后,我们将“库存”属性设置为我们已经声明的武器列表weaponInventory

  3. 最后,我们将shop对象传递给ToJson()方法,并将新的字符串数据写入 JSON 文件

再次运行游戏,并查看我们创建的漂亮打印的武器列表:

图 12.24:列表对象正确序列化为 JSON

将 JSON 文本反序列化为对象是刚才所做的过程的逆过程:

  1. DataManager类的底部添加一个新方法:
public void DeserializeJSON()
{
    // 1
    if(File.Exists(_jsonWeapons))
    {
        // 2
        using (StreamReader stream = new StreamReader(_jsonWeapons))
        {
            // 3
            var jsonString = stream.ReadToEnd();

            // 4
            var weaponData = JsonUtility.FromJson<WeaponShop>
              (jsonString);

            // 5
            foreach (var weapon in weaponData.inventory)
            {
                Debug.LogFormat("Weapon: {0} - Damage: {1}", 
                  weapon.name, weapon.damage);
            }
        }
    }
} 
  1. Initialize()中调用新方法,并将_jsonWeapons作为参数传递:
public void Initialize()
{
    _state = "Data Manager initialized..";
    Debug.Log(_state);

    FilesystemInfo();
    NewDirectory();
    SerializeJSON();
    **DeserializeJSON();**
} 

让我们来分解下面的DeserializeJSON()方法:

  1. 首先,我们检查文件是否存在

  2. 如果存在,我们创建一个包装在using代码块中的_jsonWeapons文件路径的流

  3. 然后,我们使用流的ReadToEnd()方法从文件中获取整个 JSON 文本

  4. 接下来,我们创建一个变量来保存我们反序列化的武器列表,并调用FromJson()方法:

  • 请注意,在传入 JSON 字符串变量之前,我们指定要将我们的 JSON 转换为WeaponShop对象的<WeaponShop>语法
  1. 最后,我们循环遍历武器商店的“库存”列表属性,并在控制台中打印出每个武器的名称和伤害值

再次运行游戏,你会看到我们的 JSON 数据中为每个武器打印出一个控制台消息:

图 12.25:反序列化 JSON 对象列表的控制台输出

数据汇总

本章中涵盖的每个单独的模块和主题都可以单独使用,也可以组合使用以满足项目的需求。例如,您可以使用文本文件存储角色对话,并且只在需要时加载它。这比游戏每次运行时都跟踪它更有效,即使信息没有被使用。

你也可以将角色数据或敌人统计数据放入 XML 或 JSON 文件中,并在需要升级角色或生成新怪物时从文件中读取。最后,你可以从第三方数据库中获取数据并将其序列化为你自己的自定义类。这在存储玩家账户和外部游戏数据时非常常见。

你可以在docs.microsoft.com/en-us/dotnet/framework/wcf/feature-details/types-supported-by-the-data-contract-serializer找到 C#中可以序列化的数据类型列表。Unity 处理序列化的方式略有不同,所以确保你在docs.unity3d.com/ScriptReference/SerializeField.html上检查可用的类型。

我想要表达的是,数据无处不在,你的工作就是创建一个能够按照你的游戏需求处理数据的系统,一步一步地构建。

总结

关于处理数据的基础知识就介绍到这里了!恭喜你成功地完成了这一庞大的章节。在任何编程环境中,数据都是一个重要的话题,所以把这一章学到的东西当作一个起点。

你已经知道如何浏览文件系统,创建、读取、更新和删除文件。你还学会了如何有效地处理文本、XML 和 JSON 数据格式,以及数据流。你知道如何将整个对象的状态序列化或反序列化为 XML 和 JSON。总的来说,学习这些技能并不是一件小事。不要忘记多次复习和重温这一章;这里有很多东西可能不会在第一次阅读时变得很熟悉。

在下一章中,我们将讨论泛型编程的基础知识,获得一些关于委托和事件的实践经验,并最后概述异常处理。

快速测验-数据管理

  1. 哪个命名空间让你可以访问PathDirectory类?

  2. 在 Unity 中,你使用什么文件夹路径来在游戏运行之间保存数据?

  3. Stream对象使用什么数据类型来读写文件中的信息?

  4. 当你将一个对象序列化为 JSON 时会发生什么?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事会话与作者交流,以及更多。

立即加入!

packt.link/csharpunity2021

第十三章:探索通用、委托和更多

你在编程中花费的时间越多,你就会开始思考系统。构建类和对象如何相互交互、通信和交换数据,这些都是我们迄今为止所使用的系统的例子;现在的问题是如何使它们更安全、更高效。

由于这将是本书的最后一个实用章节,我们将介绍通用编程概念、委托、事件创建和错误处理的示例。每个主题都是一个独立的大领域,所以在你的项目中学到的东西,可以进一步扩展。在完成我们的实际编码后,我们将简要概述设计模式以及它们在你未来编程之旅中的作用。

在本章中,我们将涵盖以下主题:

  • 通用编程

  • 使用委托

  • 创建事件和订阅

  • 抛出和处理错误

  • 理解设计模式

介绍通用

到目前为止,我们的所有代码在定义和使用类型方面都非常具体。然而,会有一些情况,你需要一个类或方法以相同的方式处理其实体,而不管其类型,同时仍然是类型安全的。通用编程允许我们使用占位符而不是具体类型来创建可重用的类、方法和变量。

当在编译时创建通用类实例或使用方法时,将分配一个具体类型,但代码本身将其视为通用类型。能够编写通用代码是一个巨大的好处,当你需要以相同的方式处理不同的对象类型时,例如需要能够对元素执行相同操作的自定义集合类型,或者需要相同底层功能的类。虽然你可能会问为什么我们不只是子类化或使用接口,但在我们的例子中,你会看到通用类以不同的方式帮助我们。

我们已经在List类型中看到了这一点,它是一种通用类型。无论它存储整数、字符串还是单个字符,我们都可以访问它的所有添加、删除和修改函数。

通用对象

创建通用类的方式与创建非通用类的方式相同,但有一个重要的区别:它的通用类型参数。让我们看一个我们可能想要创建的通用集合类的例子,以更清晰地了解它是如何工作的:

public class SomeGenericCollection**<****T****>** {} 

我们声明了一个名为SomeGenericCollection的通用集合类,并指定其类型参数将被命名为T。现在,T将代表通用列表将存储的元素类型,并且可以在通用类内部像任何其他类型一样使用。

每当我们创建一个SomeGenericCollection的实例时,我们需要指定它可以存储的值的类型:

SomeGenericCollection**<****int****>** highScores = new SomeGenericCollection<int>(); 

在这种情况下,highScores存储整数值,T代表int类型,但SomeGenericCollection类将以相同的方式处理任何元素类型。

你完全可以控制通用类型参数的命名,但在许多编程语言中,行业标准是使用大写的T。如果你要为你的类型参数命名不同的名称,考虑以大写的T开头以保持一致性和可读性。

让我们接下来创建一个更加游戏化的例子,使用通用的Shop类来存储一些虚构的库存物品,具体步骤如下:

  1. Scripts文件夹中创建一个新的 C#脚本,命名为Shop,并将其代码更新为以下内容:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 1
public class Shop<T>
{
    // 2
    public List<T> inventory = new List<T>();
} 
  1. GameBehavior中创建一个Shop的新实例:
public class GameBehavior : MonoBehaviour, IManager
{
    // ... No other changes needed ...

    public void Initialize()
    {
        // 3
        var itemShop = new Shop<string>();
        // 4
        Debug.Log("There are " + itemShop.inventory.Count + " items for sale.");
    }
} 

让我们来分解一下代码:

  1. 声明一个名为IShop的新通用类,带有T类型参数

  2. 添加一个类型为T的库存List<T>,用于存储我们用通用类初始化的任何物品类型

  3. GameBehavior中创建一个Shop<string>的新实例,并指定字符串值作为通用类型

  4. 打印出一个带有库存计数的调试消息:

图 13.1:来自泛型类的控制台输出

在功能方面还没有发生任何新的事情,但是 Visual Studio 因为其泛型类型参数T而将Shop识别为泛型类。这使我们能够包括其他泛型操作,如添加库存项目或查找每种项目的数量。

值得注意的是,Unity Serializer 默认不支持泛型。如果要序列化泛型类,就像我们在上一章中对自定义类所做的那样,您需要在类的顶部添加Serializable属性,就像我们在Weapon类中所做的那样。您可以在docs.unity3d.com/ScriptReference/SerializeReference.html找到更多信息。

泛型方法

一个独立的泛型方法可以有一个占位符类型参数,就像一个泛型类一样,这使它可以根据需要包含在泛型或非泛型类中:

public void GenericMethod**<****T****>**(**T** genericParameter) {} 

T类型可以在方法体内使用,并在调用方法时定义:

GenericMethod**<****string****>(****"Hello World!"****)**; 

如果要在泛型类中声明泛型方法,则不需要指定新的T类型:

public class SomeGenericCollection<T> 
{
    public void NonGenericMethod(**T** genericParameter) {}
} 

当调用使用泛型类型参数的非泛型方法时,没有问题,因为泛型类已经处理了分配具体类型的问题:

SomeGenericCollection**<****int****>** highScores = new SomeGenericCollection
<int> ();
highScores.NonGenericMethod(**35**); 

泛型方法可以被重载并标记为静态,就像非泛型方法一样。如果您想要这些情况的具体语法,请查看docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/generic-methods

您的下一个任务是创建一个方法,将新的泛型项目添加到库存,并在GameBehavior脚本中使用它。

由于我们已经有了一个具有定义类型参数的泛型类,让我们添加一个非泛型方法来看它们如何一起工作:

  1. 打开Shop并按以下方式更新代码:
public class Shop<T>
{
    public List<T> inventory = new List<T>();
    **// 1**
    **public****void****AddItem****(****T newItem****)**
    **{**

        **inventory.Add(newItem);**
    **}**
} 
  1. 进入GameBehavior并向itemShop添加一个项目:
public class GameBehavior : MonoBehaviour, IManager
{
    // ... No other changes needed ...

     public void Initialize()
    {
        var itemShop = new Shop<string>();
        **// 2**
        itemShop**.AddItem(****"Potion"****);**
        itemShop**.AddItem(****"Antidote"****);**
       Debug.Log("There are " + itemShop.inventory.Count + " items for sale.");
    }
} 

让我们来分解代码:

  1. 声明一个添加newItems的类型T到库存的方法

  2. 使用AddItem()itemShop添加两个字符串项目,并打印出调试日志:

图 13.2:向泛型类添加项目后的控制台输出

我们编写了AddItem()以接受与我们的泛型Shop实例相同类型的参数。由于itemShop被创建为保存字符串值,我们可以毫无问题地添加"Potion""Antidote"字符串值。

然而,如果尝试添加一个整数,例如,您将收到一个错误,指出itemShop的泛型类型不匹配:

图 13.3:泛型类中的转换错误

现在,您已经编写了一个泛型方法,需要知道如何在单个类中使用多个泛型类型。例如,如果我们想要向Shop类添加一个方法,找出库存中有多少个给定项目?我们不能再次使用类型T,因为它已经在类定义中定义了。那么我们该怎么办呢?

将以下方法添加到Shop类的底部:

// 1
public int GetStockCount<U>()
{
    // 2
    var stock = 0;
    // 3
    foreach (var item in inventory)
    {
        if (item is U)
        {
            stock++;
        }
    }
    // 4
    return stock;
} 

让我们来分解我们的新方法:

  1. 声明一个方法,返回我们在库存中找到的类型U的匹配项目的 int 值
  • 泛型类型参数的命名完全取决于您,就像命名变量一样。按照惯例,它们从T开始,然后按字母顺序继续。
  1. 创建一个变量来保存我们找到的匹配库存项目的数量,并最终从库存中返回

  2. 使用foreach循环遍历库存列表,并在找到匹配时增加库存值

  3. 返回匹配库存项目的数量

问题在于我们在商店中存储字符串值,因此如果我们尝试查找我们有多少字符串项目,我们将得到完整的库存:

Debug.Log("There are " + itemShop.GetStockCount<string>() + " items for sale."); 

这将在控制台上打印出类似以下内容:

图 13.4:使用多个泛型字符串类型的控制台输出

另一方面,如果我们试图在我们的库存中查找整数类型,我们将得不到结果,因为我们只存储字符串:

Debug.Log("There are " + itemShop.GetStockCount<int>() + " items for sale."); 

这将在控制台上打印类似以下内容:

图 13.5:使用多个不匹配的泛型类型的控制台输出

这两种情况都不理想,因为我们无法确保我们的商店库存既存储又可以搜索相同的物品类型。但这就是泛型真正发挥作用的地方——我们可以为我们的泛型类和方法添加规则,以强制执行我们想要的行为,我们将在下一节中介绍。

约束类型参数

泛型的一大优点是它们的类型参数可以受限制。这可能与我们迄今为止学到的有所矛盾,但只是因为一个类可以包含任何类型,并不意味着应该允许它这样做。

为了约束泛型类型参数,我们需要一个新关键字和一个我们以前没有见过的语法:

public class SomeGenericCollection<T> where T: ConstraintType {} 

where关键字定义了T必须通过的规则,然后才能用作泛型类型参数。它基本上说SomeGenericClass可以接受任何T类型,只要它符合约束类型。约束规则并不神秘或可怕;它们是我们已经涵盖的概念:

  • 添加class关键字将限制T为类类型

  • 添加struct关键字将限制T为结构类型

  • 添加接口,如IManager,作为类型将限制T为采用该接口的类型

  • 添加自定义类,如Character,将限制T仅为该类类型

如果您需要更灵活的方法来考虑具有子类的类,您可以使用where T:U,它指定泛型T类型必须是U类型或派生自U类型。这对我们的需求来说有点高级,但您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters找到更多详细信息。

只是为了好玩,让我们将Shop限制为只接受一个名为Collectable的新类型:

  1. Scripts文件夹中创建一个新脚本,命名为Collectable,并添加以下代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Collectable
{
    public string name;
}

public class Potion : Collectable
{
    public Potion()
    {
        this.name = "Potion";
    }
}

public class Antidote : Collectable
{
    public Antidote()
    {
        this.name = "Antidote";
    }
} 

我们在这里所做的只是声明一个名为Collectable的新类,具有一个名称属性,并为药水和解毒剂创建了子类。有了这个结构,我们可以强制我们的Shop只接受Collectable类型,并且我们的库存查找方法也只接受Collectable类型,这样我们就可以比较它们并找到匹配项。

  1. 打开Shop并更新类声明:
public class Shop<T> **where****T** **:** **Collectable** 
  1. 更新GetStockCount()方法以将U约束为与初始泛型T类型相等:
public int GetStockCount<U>() **where** **U : T**
{
    var stock = 0;
    foreach (var item in inventory)
    {
        if (item is U)
        {
            stock++;
        }
    }
    return stock;
} 
  1. GameBehavior中,将itemShop实例更新为以下代码:
var itemShop = new Shop<**Collectable**>();
itemShop.AddItem(**new** **Potion()**);
itemShop.AddItem(**new** **Antidote()**);
Debug.Log("There are " + itemShop.GetStockCount<**Potion**>() + " items for sale."); 

这将导致类似以下的输出:

图 13.6:更新后的 GameBehavior 脚本输出

在我们的示例中,我们可以确保只有可收集类型被允许在我们的商店中。如果我们在代码中意外地尝试添加不可收集类型,Visual Studio 将警告我们尝试违反我们自己的规则!

向 Unity 对象添加泛型

泛型也适用于 Unity 脚本和游戏对象。例如,我们可以轻松地创建一个通用的可销毁类,用于删除场景中的任何MonoBehaviour或对象Component。如果这听起来很熟悉,那就是我们的BulletBehavior为我们做的事情,但它不适用于除该脚本之外的任何东西。为了使其更具可扩展性,让我们使任何从MonoBehaviour继承的脚本都可销毁。

  1. Scripts文件夹中创建一个新脚本,命名为Destroyable,并添加以下代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Destroyable<T> : MonoBehaviour where T : MonoBehaviour
{
    public int OnscreenDelay;

    void Start()
    {
        Destroy(this.gameObject, OnscreenDelay);
    }
} 
  1. 删除BulletBehavior中的所有代码,并继承自新的通用类:
public class BulletBehavior : **Destroyable****<****BulletBehavior****>**
{
} 

现在,我们已经将我们的BulletBehavior脚本转换为通用的可销毁对象。在 Bullet Prefab 中没有任何更改,但我们可以通过从通用的Destroyable类继承来使任何其他对象可销毁。在我们的示例中,如果我们创建了多个抛射物 Prefab 并希望它们都在不同的时间被销毁,那么这将提高代码效率和可重用性。

通用编程是我们工具箱中的一个强大工具,但是在掌握了基础知识之后,是时候谈谈编程旅程中同样重要的一个主题——委托了!

委托操作

有时您需要将一个文件中的方法执行委托给另一个文件。在 C#中,可以通过委托类型来实现这一点,它存储对方法的引用,并且可以像任何其他变量一样对待。唯一的限制是委托本身和任何分配的方法都需要具有相同的签名——就像整数变量只能保存整数和字符串只能保存文本一样。

创建委托是编写函数和声明变量的混合:

public **delegate** returnType DelegateName(int param1, string param2); 

您首先使用访问修饰符,然后是delegate关键字,这将其标识为delegate类型。delegate类型可以像常规函数一样具有返回类型和名称,如果需要还可以有参数。但是,这种语法只是声明了delegate类型本身;要使用它,您需要像使用类一样创建一个实例:

public **DelegateName** someDelegate; 

声明了一个delegate类型变量后,很容易分配一个与委托签名匹配的方法:

public DelegateName someDelegate = **MatchingMethod**;
public void **MatchingMethod****(****int** **param1,** **string** **param2****)** 
{
    // ... Executing code here ...
} 

请注意,在将MatchingMethod分配给someDelegate变量时,不要包括括号,因为此时并不是在调用该方法。它所做的是将MatchingMethod的调用责任委托给someDelegate,这意味着我们可以如下调用该函数:

someDelegate(); 

在您的 C#技能发展到这一点时,这可能看起来很麻烦,但我向您保证,能够将方法存储和执行为变量将在未来派上用场。

创建一个调试委托

让我们创建一个简单的委托类型来定义一个接受字符串并最终使用分配的方法打印它的方法。打开GameBehavior并添加以下代码:

public class GameBehavior : MonoBehaviour, IManager
{
    // ... No other changes needed ...

    **// 1**
    **public****delegate****void****DebugDelegate****(****string** **newText****)****;**

    **// 2**
    **public** **DebugDelegate debug = Print;**

    public void Initialize() 
    {
        _state = "Game Manager initialized..";
        _state.FancyDebug();
        **// 3**
        **debug(_state);**
   // ... No changes needed ...
    }
    **// 4**
    **public****static****void****Print****(****string** **newText****)**
    **{**
        **Debug.Log(newText);**
    **}**
} 

让我们分解一下代码:

  1. 声明一个名为DebugDelegatepublic delegate类型,用于保存一个接受string参数并返回void的方法

  2. 创建一个名为debug的新DebugDelegate实例,并为其分配一个具有匹配签名的方法Print()

  3. debug委托实例替换Initialize()中的Debug.Log(_state)代码

  4. 声明Print()为一个接受string参数并将其记录到控制台的static方法

图 13.7:委托操作的控制台输出

控制台中没有任何变化,但是现在Initialize()中不再直接调用Debug.Log(),而是将该操作委托给了debug委托实例。虽然这是一个简单的例子,但是当您需要存储、传递和执行方法作为它们的类型时,委托是一个强大的工具。

在 Unity 中,我们已经通过使用OnCollisionEnter()OnCollisionExit()方法来处理委托的示例,这些方法是通过委托调用的。在现实世界中,自定义委托在与事件配对时最有用,我们将在本章的后面部分看到。

委托作为参数类型

既然我们已经看到如何创建委托类型来存储方法,那么委托类型本身也可以作为方法参数使用就是合情合理的。这与我们已经做过的并没有太大的不同,但是涵盖基础知识是个好主意。

让我们看看委托类型如何作为方法参数使用。使用以下代码更新GameBehavior

public class GameBehavior : MonoBehaviour, IManager
{
    // ... No changes needed ...
    public void Initialize() 
    {
        _state = "Game Manager initialized..";
        _state.FancyDebug();
        debug(_state);
        **// 1**
        **LogWithDelegate(debug);**
    }
    **// 2**
    **public****void****LogWithDelegate****(****DebugDelegate del****)**
    **{**
        **// 3**
        **del(****"Delegating the debug task..."****);**
    **}**
} 

让我们分解一下代码:

  1. 调用LogWithDelegate()并将我们的debug变量作为其类型参数传递

  2. 声明一个新的方法,接受DebugDelegate类型的参数

  3. 调用委托参数的函数,并传入一个字符串文字以打印出来:

图 13.8:委托作为参数类型的控制台输出

我们创建了一个接受DebugDelegate类型参数的方法,这意味着传入的实际参数将表示一个方法,并且可以被视为一个方法。将这个例子视为一个委托链,其中LogWithDelegate()距离实际进行调试的方法Print()有两个步骤。创建这样的委托链并不总是在游戏或应用程序场景中常见的解决方案,但是当您需要控制委托级别时,了解涉及的语法是很重要的。在涉及到委托链跨多个脚本或类的情况下,这一点尤为重要。

如果您错过了重要的心理联系,很容易在委托中迷失,所以回到本节开头的代码并查看文档:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/

现在您知道如何处理基本委托了,是时候谈谈事件如何用于在多个脚本之间高效地传递信息了。老实说,委托的最佳用例是与事件配对使用,接下来我们将深入探讨。

触发事件

C#事件允许您基本上创建一个基于游戏或应用程序中的操作的订阅系统。例如,如果您想在收集物品时发送事件,或者当玩家按下空格键时,您可以这样做。然而,当事件触发时,并不会自动有一个订阅者或接收者来处理任何需要在事件动作之后执行的代码。

任何类都可以通过调用事件被触发的类来订阅或取消订阅事件;就像在手机上注册接收 Facebook 上分享新帖子通知一样,事件形成了一种分布式信息高速公路,用于在应用程序中共享操作和数据。

声明事件类似于声明委托,因为事件具有特定的方法签名。我们将使用委托来指定我们希望事件具有的方法签名,然后使用delegate类型和event关键字创建事件:

public delegate void EventDelegate(int param1, string param2);
public **event** EventDelegate eventInstance; 

这个设置允许我们将eventInstance视为一个方法,因为它是一个委托类型,这意味着我们可以随时调用它来发送它:

eventInstance(35, "John Doe"); 

你的下一个任务是在PlayerBehavior内部创建一个自己的事件并在适当的位置触发它。

创建和调用事件

让我们创建一个事件,以便在玩家跳跃时触发。打开PlayerBehavior并添加以下更改:

public class PlayerBehavior : MonoBehaviour 
{
    // ... No other variable changes needed ...
    **// 1**
    **public****delegate****void****JumpingEvent****()****;**
    **// 2**
    **public****event** **JumpingEvent playerJump;**
    void Start()
    {
        // ... No changes needed ...
    }
    void Update() 
    {
        // ... No changes needed ...
;
    }
    void FixedUpdate()
    {
        if(IsGrounded() &&  _isJumping)
        {
            _rb.AddForce(Vector3.up * jumpVelocity,
               ForceMode.Impulse);
            **// 3**
            **playerJump();**
        }
    }
    // ... No changes needed in IsGrounded or OnCollisionEnter
} 

让我们来分解一下代码:

  1. 声明一个返回void并且不带任何参数的新delegate类型

  2. 创建一个JumpingEvent类型的事件,名为playerJump,可以被视为一个方法,与前面的委托的void返回和无参数签名相匹配

  3. Update()中施加力后调用playerJump

我们已成功创建了一个简单的委托类型,它不带任何参数并且不返回任何内容,以及一个该类型的事件,以便在玩家跳跃时执行。每次玩家跳跃时,playerJump事件都会发送给所有订阅者,通知它们该操作。

事件触发后,由订阅者来处理它并执行任何额外的操作,我们将在处理事件订阅部分中看到。

处理事件订阅

现在,我们的playerJump事件没有订阅者,但更改很简单,非常类似于我们在上一节中将方法引用分配给委托类型的方式:

someClass.eventInstance += EventHandler; 

由于事件是属于声明它们的类的变量,而订阅者将是其他类,因此需要引用包含事件的类来进行订阅。+=运算符用于分配一个方法,当事件执行时将触发该方法,就像设置一个外出邮件一样。与分配委托一样,事件处理程序方法的方法签名必须与事件的类型匹配。在我们之前的语法示例中,这意味着EventHandler需要是以下内容:

public void EventHandler(int param1, string param2) {} 

在需要取消订阅事件的情况下,您只需使用-=运算符执行分配的相反操作:

someClass.eventInstance -= EventHandler; 

事件订阅通常在类初始化或销毁时处理,这样可以轻松管理多个事件,而不会出现混乱的代码实现。

现在您已经知道了订阅和取消订阅事件的语法,现在轮到您在GameBehavior脚本中将其付诸实践了。

现在,我们的事件每次玩家跳跃时都会触发,我们需要一种捕获该动作的方法:

  1. 返回到GameBehavior并更新以下代码:
public class GameBehavior : MonoBehaviour, IManager
{
    // 1
    public PlayerBehavior playerBehavior;

    // 2
    void OnEnable()
    {
        // 3
        GameObject player = GameObject.Find("Player");
        // 4
        playerBehavior = player.GetComponent<PlayerBehavior>();
        // 5
        playerBehavior.playerJump += HandlePlayerJump;
        debug("Jump event subscribed...");
    }

    // 6
    public void HandlePlayerJump()
    {
         debug("Player has jumped...");
    **}**
    // ... No other changes ...
} 

让我们来分解一下代码:

  1. 创建一个PlayerBehavior类型的公共变量

  2. 声明OnEnable()方法,每当附加了脚本的对象在场景中变为活动状态时都会调用该方法

OnEnableMonoBehaviour类中的一个方法,因此所有 Unity 脚本都可以访问它。这是一个很好的地方来放置事件订阅,而不是在Awake中执行,因为它只在对象活动时执行,而不仅仅是在加载过程中执行。

  1. 在场景中查找Player对象并将其GameObject存储在一个局部变量中

  2. 使用GetComponent()检索附加到PlayerPlayerBehavior类的引用,并将其存储在playerBehavior变量中

  3. 使用+=运算符订阅了在PlayerBehavior中声明的playerJump事件,并使用名为HandlePlayerJump的方法

  4. 声明HandlePlayerJump()方法,其签名与事件的类型匹配,并使用调试委托每次接收到事件时记录成功消息!

图 13.9:委托事件订阅的控制台输出

为了正确订阅和接收GameBehavior中的事件,我们必须获取到玩家附加的PlayerBehavior类的引用。我们本可以一行代码完成所有操作,但将其拆分开来更加可读。然后,我们分配了一个方法给playerJump事件,每当接收到事件时都会执行该方法,并完成订阅过程。

现在每次跳跃时,您都会看到带有事件消息的调试消息:

图 13.10:委托事件触发的控制台输出

由于事件订阅是在脚本中配置的,并且脚本附加到 Unity 对象上,我们的工作还没有完成。当对象被销毁或从场景中移除时,我们仍然需要处理如何清理订阅,这将在下一节中介绍。

清理事件订阅

即使在我们的原型中,玩家永远不会被销毁,但在游戏中失去玩家是一个常见的特性。清理事件订阅非常重要,因为它们占用了分配的资源,正如我们在第十二章“保存、加载和序列化数据”中讨论的流一样。

我们不希望在订阅对象被销毁后仍然保留任何订阅,因此让我们清理一下我们的跳跃事件。在OnEnable方法之后,将以下代码添加到GameBehavior中:

// 1
private void OnDisable()
{
    // 2
    playerBehavior.playerJump -= HandlePlayerJump;
    debug("Jump event unsubscribed...");
} 

让我们来分解我们的新代码添加:

  1. 声明OnDisable()方法,它属于MonoBehavior类,并且是我们之前使用的OnEnable()方法的伴侣
  • 您需要编写的任何清理代码通常应该放在这个方法中,因为它在附加了脚本的对象处于非活动状态时执行
  1. 使用-=运算符取消HandlePlayerJump中的playerJump事件的订阅,并打印出控制台消息

现在我们的脚本在游戏对象启用和禁用时正确订阅和取消订阅事件,不会在我们的游戏场景中留下未使用的资源。

这就结束了我们对事件的讨论。现在你可以从一个脚本广播它们到游戏的每个角落,并对玩家失去生命、收集物品或更新 UI 等情况做出反应。然而,我们仍然需要讨论一个非常重要的话题,没有它,没有程序能成功,那就是错误处理。

处理异常

高效地将错误和异常纳入代码中,是你编程之旅中的专业和个人标杆。在你开始大喊“我花了这么多时间避免错误,为什么要添加错误?!”之前,你应该知道我并不是指添加错误来破坏你现有的代码。相反,包括错误或异常,并在功能部分被错误使用时适当处理它们,会使你的代码库更加强大,更不容易崩溃,而不是更弱。

抛出异常

当我们谈论添加错误时,我们将这个过程称为异常抛出,这是一个恰当的视觉类比。抛出异常是防御性编程的一部分,这基本上意味着你在代码中积极有意识地防范不当或非计划的操作。为了标记这些情况,你从一个方法中抛出一个异常,然后由调用代码处理。

举个例子:假设我们有一个if语句,检查玩家的电子邮件地址是否有效,然后才允许他们注册。如果输入的电子邮件无效,我们希望我们的代码抛出异常:

public void ValidateEmail(string email)
{
    if(!email.Contains("@"))
    {
        **throw****new** **System.ArgumentException(****"Email is invalid"****);**
    }
} 

我们使用throw关键字来抛出异常,异常是使用new关键字后跟我们指定的异常创建的。System.ArgumentException()默认会记录关于异常在何时何地执行的信息,但也可以接受自定义字符串,如果你想更具体。

ArgumentExceptionException类的子类,并且通过之前显示的System类访问。C#带有许多内置的异常类型,包括用于检查空值、超出范围的集合值和无效操作的子类。异常是使用正确的工具来做正确的工作的一个典型例子。我们的例子只需要基本的ArgumentException,但你可以在docs.microsoft.com/en-us/dotnet/api/system.exception#Standard找到完整的描述列表。

在我们第一次尝试异常处理时,让事情保持简单,并确保我们只有在提供正的场景索引号时才重新开始关卡:

  1. 打开Utilities并将以下代码添加到重载版本的RestartLevel(int)中:
public static class Utilities 
{
    // ... No changes needed ...
    public static bool RestartLevel(int sceneIndex) 
    {
        **// 1**
        **if****(sceneIndex <** **0****)**
        **{**
            **// 2**
            **throw****new** **System.ArgumentException(****"Scene index cannot be negative"****);**
         **}**

        Debug.Log("Player deaths: " + PlayerDeaths);
        string message = UpdateDeathCount(ref PlayerDeaths);
        Debug.Log("Player deaths: " + PlayerDeaths);
        Debug.Log(message);

        SceneManager.LoadScene(sceneIndex);
        Time.timeScale = 1.0f;

        return true;
    }
} 
  1. GameBehavior中将RestartLevel()更改为接受负场景索引并且输掉游戏:
// 3
public void RestartScene()
{
    Utilities.RestartLevel(**-1**);
} 

让我们来分解一下代码:

  1. 声明一个if语句来检查sceneIndex是否不小于 0 或负数

  2. 如果传入一个负的场景索引作为参数,抛出一个带有自定义消息的ArgumentException

  3. 使用场景索引为-1调用RestartLevel()

图 13.11:抛出异常时的控制台输出

现在当我们输掉游戏时,会调用RestartLevel(),但由于我们使用-1作为场景索引参数,我们的异常会在任何场景管理逻辑执行之前被触发。我们目前游戏中没有配置其他场景,但这个防御性代码作为保障,不让我们执行可能导致游戏崩溃的操作(Unity 在加载场景时不支持负索引)。

现在你成功地抛出了一个错误,你需要知道如何处理错误的后果,这将引导我们进入下一节和try-catch语句。

使用 try-catch

现在我们已经抛出了一个错误,我们的工作是安全地处理调用RestartLevel()可能产生的可能结果,因为在这一点上,这没有得到适当的处理。要做到这一点,需要使用一种新的语句,称为try-catch

try
{
    // Call a method that might throw an exception
}
catch (ExceptionType localVariable)
{
    // Catch all exception cases individually
} 

try-catch语句由连续的代码块组成,这些代码块在不同的条件下执行;它就像一个专门的if/else语句。我们在try块中调用可能引发异常的任何方法——如果没有引发异常,代码将继续执行而不中断。如果引发异常,代码将跳转到与抛出异常匹配的catch语句,就像switch语句与其 case 一样。catch语句需要定义它们要处理的异常,并指定一个本地变量名,该变量将在catch块内表示它。

您可以在try块之后链接多个catch语句,以处理从单个方法抛出的多个异常,只要它们捕获不同的异常。例如:

try
{
    // Call a method that might throw an exception
}
catch (ArgumentException argException)
{
    // Catch argument exceptions here
}
catch (FileNotFoundException fileException)
{
    // Catch exceptions for files not found here
} 

还有一个可选的finally块,可以在任何catch语句之后声明,无论是否抛出异常,它都将在try-catch语句的最后执行:

finally
{
    // Executes at the end of the try-catch no matter what
} 

您的下一个任务是使用try-catch语句处理重新启动关卡时抛出的任何错误。现在我们有一个在游戏失败时抛出的异常,让我们安全地处理它。使用以下代码更新GameBehavior,然后再次失败游戏:

public class GameBehavior : MonoBehaviour, IManager
{
    // ... No variable changes needed ...
    public void RestartScene()
    {
        // 1 
        try
        {
            Utilities.RestartLevel(-1);
            debug("Level successfully restarted...");
        }
        // 2
        catch (System.ArgumentException exception)
        {
            // 3
            Utilities.RestartLevel(0);
            debug("Reverting to scene 0: " + exception.ToString());
        }
        // 4
        finally
        {
            debug("Level restart has completed...");
        }
    }
} 

让我们分解一下代码:

  1. 声明try块,并将调用RestartLevel()移至其中,并使用debug命令打印出重新启动是否完成而没有任何异常。

  2. 声明catch块,并将System.ArgumentException定义为它将处理的异常类型,exception作为局部变量名。

  3. 如果抛出异常,则在默认场景索引处重新启动游戏:

  • 使用debug委托打印出自定义消息,以及可以从exception访问并使用ToString()方法将其转换为字符串的异常信息

由于exceptionArgumentException类型,因此与Exception类关联的有几个属性和方法,您可以访问这些属性和方法。当您需要关于特定异常的详细信息时,这些通常很有用。

  1. 添加一个带有调试消息的finally块,以表示异常处理代码的结束!

图 13.12:完整的 try-catch 语句的控制台输出

当现在调用RestartLevel()时,我们的try块安全地允许其执行,如果出现错误,则在catch块内捕获。catch块在默认场景索引处重新启动关卡,代码继续执行到finally块,该块只是为我们记录一条消息。

了解如何处理异常很重要,但不应该养成在代码中随处放置异常的习惯。这将导致臃肿的类,并可能影响游戏的处理时间。相反,您应该在最需要的地方使用异常——无效或数据处理,而不是游戏机制。

C#允许您自由创建自己的异常类型,以满足代码可能具有的任何特定需求,但这超出了本书的范围。这只是一个未来要记住的好事情:docs.microsoft.com/en-us/dotnet/standard/exceptions/how-to-create-user-defined-exceptions

摘要

虽然本章将我们带到了 C#和 Unity 2020 的实际冒险的尽头,但我希望您的游戏编程和软件开发之旅刚刚开始。您已经学会了从创建变量、方法和类对象到编写游戏机制、敌人行为等方方面面的知识。

本章涵盖的主题已经超出了我们在大部分书中处理的水平,这是有充分理由的。你已经知道你的编程大脑是需要锻炼的肌肉,才能进入下一个阶段。泛型、事件和设计模式都只是编程阶梯上的下一个台阶。

在下一章中,我将为你提供资源、进一步阅读以及有关 Unity 社区和软件开发行业的大量其他有用(我敢说,很酷)的机会和信息。

编程愉快!

弹出测验-中级 C#

  1. 泛型和非泛型类之间有什么区别?

  2. 在为委托类型分配值时需要匹配什么?

  3. 你如何取消订阅事件?

  4. 在你的代码中,你会使用哪个 C#关键字来发送异常?

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,以及更多。

立即加入!

packt.link/csharpunity2021

第十四章:旅程继续

如果你作为一个完全的编程新手开始阅读这本书,恭喜你的成就!如果你对 Unity 或其他脚本语言有一些了解,猜猜看?恭喜你。如果你开始时对我们已经涵盖的所有主题和概念都有牢固的理解,你猜对了:恭喜你。没有什么学习经历是微不足道的,无论你认为自己学到了多少或多少。享受你花在学习新东西上的时间,即使最终只是学到了一个新的关键词。

当你到达这段旅程的尽头时,重温你一路学到的技能是很重要的。就像所有的教学内容一样,总是有更多的东西可以学习和探索,所以这一章将专注于巩固以下主题,并为你的下一个冒险提供资源:

  • 深入挖掘

  • 面向对象编程及更多

  • 设计模式

  • 接近 Unity 项目

  • C#和 Unity 资源

  • Unity 认证

  • 下一步和未来学习

深入挖掘

虽然我们在这本书中做了大量关于变量、类型、方法和类的工作,但仍有一些 C#的领域没有被探索。

学习新技能不应该是简单的信息轰炸,而是应该是一个谨慎的积木堆叠,每一块积木都建立在已经获得的基础知识之上。

以下是你在使用 C#进行编程旅程中需要深入了解的一些概念,无论是在 Unity 中还是其他脚本语言中:

  • 可选和动态变量

  • 调试方法

  • 并发编程

  • 网络和 RESTful API

  • 递归和反射

  • 设计模式

  • LINQ

  • 函数式编程

当你重新审视我们在这本书中编写的代码时,不要只考虑我们取得了什么成就,还要考虑我们项目的不同部分是如何协同工作的。我们的代码是模块化的,意味着行为和逻辑是自包含的。我们的代码是灵活的,因为我们使用了面向对象编程(OOP)技术,这使得改进和更新变得容易。我们的代码是干净的,不重复,这使得任何查看它的人都能轻松阅读,即使是我们自己。

这里的要点是消化基本概念需要时间。事情并不总是一次就能理解,而且“啊哈!”的时刻也不总是在你期待的时候出现。关键是要不断学习新东西,但始终牢记你的基础。

让我们听从自己的建议,在下一节重新审视面向对象编程的原则。

回顾你的面向对象编程

面向对象编程是一个广阔的专业领域,它的掌握不仅需要学习,还需要花时间将其原则应用到现实软件开发中。

通过这本书学到的所有基础知识,可能会让你觉得这是一座你最好根本不要尝试攀登的山。然而,当你感到这样的时候,退一步重新审视这些概念:

  • 类是你想在代码中创建的对象的蓝图

  • 它们可以包含属性、方法和事件

  • 它们使用构造函数来定义它们如何被实例化

  • 从类蓝图实例化对象会创建该类的唯一实例

  • 类是引用类型,这意味着当引用被复制时,它不是一个新的实例

  • 结构体是值类型,这意味着当结构体被复制时,会创建一个全新的实例

  • 类可以使用继承与子类共享公共行为和数据

  • 类使用访问修饰符来封装它们的数据和行为

  • 类可以由其他类或结构类型组成

  • 多态性允许子类被视为其父类

  • 多态性也允许改变子类的行为而不影响父类

一旦掌握了面向对象编程,就可以探索其他编程范式,如函数式和响应式编程。简单的在线搜索将让你朝着正确的方向前进。

设计模式入门

在我们结束本书之前,我想谈谈一个将在你的编程生涯中扮演重要角色的概念:设计模式。搜索设计模式或软件编程模式将给你提供大量的定义和示例,如果你以前从未遇到过它们,可能会让你感到不知所措。让我们简化这个术语,并定义设计模式如下:

解决编程问题或在任何应用程序开发过程中经常遇到的情况的模板。这些不是硬编码的解决方案——它们更像是经过测试的指导方针和最佳实践,可以适应特定情况。

设计模式成为编程词汇的重要部分背后有着悠久的历史,但挖掘这一点取决于你自己。

如果这个概念触动了你的编程思维,可以从书籍设计模式:可复用面向对象软件的元素和其作者四人组:Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 开始。

这只是设计模式在现实世界编程情况下所能做的冰山一角。我强烈鼓励你深入了解它们的历史和应用——它们将是你未来的最佳资源之一。

接下来,即使本书的目标是教你 C#,我们也不能忘记我们学到的关于 Unity 的一切。

接近 Unity 项目

即使 Unity 是一个 3D 游戏引擎,它仍然必须遵循构建在其上的代码所制定的原则。当你考虑你的游戏时,请记住屏幕上看到的 GameObject、组件和系统只是类和数据的可视化表示;它们并不神奇或未知——它们是将你在本书中学到的编程基础知识发展到高级结论的结果。

Unity 中的一切都是对象,但这并不意味着所有的 C#类都必须在引擎的MonoBehaviour框架内工作。不要局限于只考虑游戏内机制;拓展思路,根据项目的需要定义数据或行为。

最后,始终要问自己如何将代码最好地分离成功能块,而不是创建庞大、臃肿、千行代码的类。相关的代码应该负责其行为并一起存储。这意味着创建单独的MonoBehaviour类并将它们附加到受其影响的 GameObject 上。我在本书开头就说过,我会再次重申:编程更多是一种心态和语境框架,而不是语法记忆。继续训练你的大脑像程序员一样思考,最终,你将无法以其他方式看待世界。

我们没有涵盖的 Unity 功能

我们在第六章《与 Unity 一起动手》中简要介绍了许多 Unity 的核心功能,但引擎还有很多其他功能。这些主题并不按重要性顺序排列,但如果你要继续使用 Unity 开发,你至少应该对以下内容有所了解:

  • 着色器和特效

  • 可编程对象

  • 编辑器扩展脚本

  • 非编程 UI

  • ProBuilder 和地形工具

  • PlayerPrefs 和数据保存

  • 模型绑定

  • 动画师状态和转换

你还应该回过头去深入了解编辑器中的照明、导航、粒子效果和动画功能。

下一步

现在您已经具备了 C#语言的基本读写能力,您可以寻求额外的技能和语法。这通常以在线社区、教程网站和 YouTube 视频的形式出现,但也可以包括教科书,比如这本书。从读者转变为软件开发社区的积极成员可能很困难,尤其是在众多选择的情况下,因此我列出了一些我最喜欢的 C#和 Unity 资源,以帮助您入门。

C#资源

在我用 C#开发游戏或应用程序时,我总是把微软文档打开在一个我可以轻松访问的窗口中。如果我找不到特定问题或问题的答案,我会开始查看我经常使用的社区网站:

由于我大部分的 C#问题都与 Unity 有关,我倾向于使用这类资源,我已经在下一节中列出了这些资源。

Unity 资源

最好的 Unity 学习资源来自于视频教程、文章、免费资产和文档,都可以在unity3d.com找到。

但是,如果您正在寻找社区答案或特定的编程问题解决方案,请访问以下网站:

如果您更喜欢视频教程,YouTube 上也有一个庞大的视频教程社区;以下是我的前五个:

Packt 图书馆还有大量关于 Unity、游戏开发和 C#的书籍和视频,可以在www.packtpub.com/all-products找到。

Unity 认证

Unity 现在为程序员和艺术家提供各种级别的认证,这将为您的简历增添一定的可信度和经验技能排名。如果您试图以自学或非计算机科学专业的身份进入游戏行业,这些认证非常有用,它们有以下几种类型:

  • 认证助理

  • 认证用户:程序员

  • 认证程序员

  • 认证艺术家

  • 认证专家-游戏程序员

  • 认证专家-技术艺术家:绑定和动画

  • 认证专家-技术艺术家:着色和特效

Unity 还提供内部和通过第三方提供者提供的预备课程,以帮助您为各种认证做好准备。您可以在certification.unity.com找到所有信息。

永远不要让认证,或者缺乏认证,定义您的工作或您发布到世界上的东西。您最后的英雄试炼是加入开发社区,并开始留下您的印记。

英雄试炼-将某物发布到世界上

我在这本书中给你的最后一个任务可能是最难的,但也是最有回报的。您的任务是利用您的 C#和 Unity 知识,创建一些内容并发布到软件或游戏开发社区中。无论是一个小的游戏原型还是一个大型的手机游戏,以以下方式将您的代码发布出去:

  • 加入 GitHub(github.com

  • 在 Stack Overflow、Unity Answers 和 Unity 论坛上积极参与

  • 注册在 Unity Asset Store 上发布自定义资产(assetstore.unity.com

无论你的激情项目是什么,都要让它走向世界。

摘要

你可能会觉得这标志着你的编程之旅的结束,但你错了。学习永无止境,只有一个开始。我们开始理解编程的基本构建块,C#语言的基础知识,以及如何将这些知识转化为 Unity 中有意义的行为。如果你已经到了这最后一页,我相信你已经实现了这些目标,你也应该这样认为。

当我刚开始时,我希望有人告诉我一句话:如果你说你是程序员,那么你就是程序员。社区中会有很多人告诉你,你是业余的,你缺乏被认为是“真正”的程序员所需的经验,或者更好的是,你需要某种无形的专业认可。这是错误的:如果你经常练习像程序员一样思考,努力用高效和干净的代码解决问题,并且热爱学习新事物,那么你就是程序员。拥有这个身份;这将使你的旅程变得非常刺激。

加入我们的 Discord!

与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,以及更多。

立即加入!

packt.link/csharpunity2021

第十五章:快速测验答案

第一章-了解您的环境

快速测验-处理脚本

Q1 Unity 和 Visual Studio 有一种共生关系
Q2 参考手册
Q3 没有,因为它是一个参考文档,而不是一个测试
Q4 当新文件以编辑模式出现在项目选项卡中时,将使类名与文件名相同,并防止命名冲突

第二章-编程的基本构件

快速测验-C#构建模块

Q1 存储特定类型的数据以供 C#文件中的其他地方使用
Q2 方法存储可执行的代码行,以便快速高效地重用
Q3 通过将MonoBehaviour作为其父类并将其附加到 GameObject 来实现
Q4 访问组件或附加到不同 GameObject 的文件的变量和方法

第三章-深入变量、类型和方法

快速测验#1-变量和方法

Q1 使用驼峰命名法
Q2 将变量声明为public
Q3 publicprivateprotectedinternal
Q4 当隐式转换不存在时
Q5 从方法返回的数据类型,带括号的方法名称,以及代码块的一对大括号
Q6 允许将参数数据传递到代码块中
Q7 该方法不会返回任何数据
Q8 Update()方法在每一帧都会被调用

第四章-控制流和集合类型

快速测验#1-如果、而且、或者

Q1 真或假
Q2 用感叹号符号(!)写的 NOT 运算符
Q3 与双和符号(&&)写的 AND 运算符
Q4 用双竖线(`

快速测验#2-关于集合的一切

Q1 数据存储的位置
Q2 数组或列表中的第一个元素是 0,因为它们都是从零开始索引的
Q3 不是-当数组或列表声明时,定义了它存储的数据类型,使得元素不可能是不同类型的
Q4 一旦初始化,数组就无法动态扩展,这就是为什么列表是更灵活的选择,因为它们可以动态修改

第五章-使用类、结构和面向对象编程

快速测验-所有关于 OOP 的事情

Q1 构造函数
Q2 通过复制,而不是像类一样通过引用
Q3 封装、继承、组合和多态
Q4 GetComponent

第六章-开始使用 Unity

快速测验-基本 Unity 功能

Q1 原语
Q2 z
Q3 将 GameObject 拖入Prefabs文件夹中
Q4 关键帧

第七章-运动、摄像机控制和碰撞

快速测验-玩家控制和物理

Q1 Vector3
Q2 InputManager
Q3 Rigidbody组件
Q4 FixedUpdate

第八章-脚本游戏机制

快速测验-处理机制

Q1 一组或一系列属于同一变量的命名常量
Q2 使用Instantiate()方法与现有的 Prefab
Q3 getset访问器
Q4 OnGUI()

第九章-基本 AI 和敌人行为

快速测验-AI 和导航

Q1 它是从级别几何体自动生成的
Q2 NavMeshAgent
Q3 过程式编程
Q4 不要重复自己

第十章-重新审视类型、方法和类

快速测验-升级

Q1 Readonly
Q2 更改方法参数的数量或它们的参数类型
Q3 接口不能有方法实现或存储变量
Q4 创建类型别名以区分冲突的命名空间

第十一章-介绍堆栈、队列和哈希集

快速测验-中级集合

Q1 堆栈
Q2 窥视
Q3
Q4 ExceptWith

第十二章-保存、加载和序列化数据

快速测验-数据管理

Q1 System.IO命名空间
Q2 Application.persistentDataPath
Q3 流以字节形式读取和写入数据
Q4 整个 C#类对象被转换为 JSON 格式

第十三章 - 探索泛型,委托等等

中级 C#知识问答

Q1 泛型类需要有一个定义的类型参数
Q2 values 方法和 delegates 方法签名
Q3 -= 运算符
Q4 throw 关键字

加入我们的 Discord!

与其他用户一起阅读本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读,提问,为其他读者提供解决方案,通过问我任何事与作者交流等等。

立即加入!

packt.link/csharpunity2021

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(22)  评论(0编辑  收藏  举报