C--Unity-2021-游戏开发学习指南-全-
C# Unity 2021 游戏开发学习指南(全)
原文:
zh.annas-archive.org/md5/97c119963ed0bf1555ac5d95fe111171译者:飞龙
前言
Unity 是世界上最受欢迎的游戏引擎之一,服务于业余爱好者、专业 AAA 工作室和电影制作公司。虽然 Unity 因其作为 3D 工具的使用而闻名,但它有一系列专门的功能,支持从 2D 游戏和虚拟现实到后期制作和跨平台发布的所有内容。
开发者喜欢它的拖放界面和内置功能,但真正让 Unity 更进一步的是能够编写自定义 C#脚本以实现行为和游戏机制的能力。对于有其他语言经验的资深程序员来说,学习编写 C#代码可能不是巨大的障碍,但对于没有编程经验的你来说,可能会感到有些令人畏惧。这正是本书的用武之地,因为我将带你从头开始学习编程和 C#语言的基础,同时在 Unity 中构建一个有趣且可玩的游戏原型。
本书面向对象
本书是为那些对编程或 C#的基本原则没有任何经验的你而写的。然而,如果你是一个来自其他语言(甚至 C#)的熟练新手或资深专业人士,或者需要亲自动手在 Unity 中进行游戏开发,这里仍然是你的首选之地。
本书涵盖内容
第一章,了解你的环境,从 Unity 的安装过程开始,介绍编辑器的主要功能,以及查找 C#和 Unity 特定主题的文档。我们还将通过 Unity 内部创建 C#脚本,并查看所有代码编辑都发生的应用程序 Visual Studio。
第二章,编程的基本元素,首先介绍了编程的原子级概念,给你机会将变量、方法和类与日常生活中的情况联系起来。从那里,我们继续探讨简单的调试技术、适当的格式化和注释,以及 Unity 如何将 C#脚本转换为组件。
第三章,深入变量、类型和方法,对第二章中的构建块进行了更深入的探讨。这包括 C#数据类型、命名约定、访问修饰符以及程序基础所需的一切。我们还将介绍如何编写方法、添加参数和使用返回类型,最后概述属于MonoBehaviour类的标准 Unity 方法。
第四章,控制流和集合类型,介绍了在代码中做出决策的常见方法,包括if-else和switch语句。从那里,我们继续探讨如何使用数组、列表和字典,以及使用迭代语句遍历集合类型。本章结束时,我们将探讨条件循环语句和一种特殊的 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 账号。例如:“选择材质文件夹。”
代码块设置如下:
public string firstName = "Harrison";
当我们希望您注意代码块中的特定部分时,相关的行或项目会被突出显示:
accessModifier returnType UniqueName(**parameterType parameterName**)
{
method body
}
粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“从层次面板中点击创建 | 3D 对象 | 胶囊。”
联系我们
我们始终欢迎读者的反馈。
一般反馈: 发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果你对这本书的任何方面有疑问,请通过questions@packtpub.com给我们发送电子邮件。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在网上任何形式下发现了我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你感兴趣于撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《通过 Unity 2021 开发游戏学习 C# 第六版》,我们很乐意听听你的想法!请点击此处直接跳转到亚马逊评论页面,并分享你的反馈。
你的评论对我们和科技社区都非常重要,这将帮助我们确保提供高质量的内容。
第一章:了解你的环境
流行文化常常将计算机程序员描绘成局外人、孤独的狼或古怪的黑客。这些人拥有非凡的算法思维天赋,社交智商较低,并且有着奇特的无政府主义倾向。虽然情况并非如此,但学习编码从根本上改变你看世界的方式这一观点是有一定道理的。好消息是,你天生的好奇心已经想要在世界中看到这些类型的模式,你甚至可能会喜欢这种新的思维方式。
从你早上睁开眼睛的那一刻起,到你睡觉前最后一眼看到你的吊扇,你都在无意识地使用分析技能,这些技能可以转化为编程——你只是缺少正确的语言和语法来将这些生活技能映射到代码中。你知道你的年龄,对吧?那是一个变量。当你过马路时,我猜你在离开人行道之前会像我们其他人一样,在两个方向上看看路。那是在评估不同的条件,在编程术语中被称为控制流。当你看一个汽水瓶时,你会本能地识别出它具有某些属性,如形状、重量和内容。那是一个类对象!你明白了。
在你的指尖拥有所有这些现实世界的经验后,你已经准备好进入编程领域。为了开始你的旅程,你需要知道如何设置你的开发环境,与相关的应用程序一起工作,并且当你需要帮助时,确切知道去哪里。
为了达到这些目的,我们将从深入研究以下 C#主题开始:
-
开始使用 Unity 2021
-
使用 C#与 Unity
-
探索文档
让我们开始吧!
技术要求
有时候,从一件事情不是什么,而不是它是什么开始,可能更容易。本书的目的不是教你关于 Unity 游戏引擎或游戏开发的所有知识。根据需要,我们将在旅程的开始阶段对这些主题进行基本介绍,并在第六章“用 Unity 动手实践”中进行更详细的介绍。然而,这些主题的加入是为了提供一种有趣、易于理解的方式来从头学习 C#编程语言。
由于本书面向编程的初学者,如果你对 C#或 Unity 没有任何先前的经验,那么你就在正确的位置!如果你对 Unity 编辑器有一些经验,但对编程没有,猜猜看?这个地方仍然是你的选择。即使你在 C#和 Unity 的混合中有所涉猎,但想要探索更多中级或高级主题,本书的后续章节可以为你提供你所寻找的内容。
如果你是在其他语言中经验丰富的程序员,你可以自由地跳过初学者理论,直接进入你感兴趣的部分,或者留下来巩固你的基础知识。
除了运行 Unity 2021 之外,你还将使用 C# 8.0 和 Visual Studio 来编写你的游戏代码。
开始使用 Unity 2021
如果您尚未安装 Unity 或正在运行早期版本,请按照以下步骤设置您的环境:
-
访问
www.unity.com/。 -
选择 开始(如图所示):
![img/B17573_01_01.png]()
图 1.1:Unity 主页
这将带您到 Unity 商店页面。不要感到不知所措——您可以完全免费获取 Unity!
如果您看到的 Unity 主页与 图 1.1 中的不同,您可以直接访问
store.unity.com。 -
选择 个人 选项。其他付费选项提供更高级的功能和服务,但您可以自己检查这些选项:
![img/B17573_01_02.png]()
图 1.2:Unity 计划和定价
-
在选择 个人 计划后,您将被询问您是首次用户还是回程用户。在 首次用户 下选择 从这里开始:
![img/B17573_01_03.png]()
图 1.3:使用 Unity 站点开始创建
-
选择 同意并下载 以获取您的 Unity Hub 版本!
![img/B17573_01_04.png]()
图 1.4:Unity 条款和条件
下载完成后,按照以下步骤操作:
-
打开安装程序(通过双击)
-
接受用户协议
-
按照安装说明操作
当您收到绿灯时,请启动 Unity Hub 应用程序!
当您首次打开应用程序时,最新版本的 Unity Hub 将提供安装向导。如果您想跟随它,请随意。
以下步骤显示了如何在不依赖应用程序的情况下启动新项目:
-
在左下角选择 跳过安装向导,然后确认 跳过向导:
![img/B17573_01_05.png]()
图 1.5:安装向导
-
从左侧菜单切换到 安装 选项卡,并选择 添加 以选择您的 Unity 版本:
![img/B17573_01_06.png]()
图 1.6:Unity Hub 安装面板
-
选择您想要的 Unity 版本,然后点击 下一步。在撰写本文时,Unity 2021 仍在预发布中,但您应该能够在阅读本文时从 官方发布 列表中选择一个 2021 版本:
![img/B17573_01_07.png]()
图 1.7:添加 Unity 版本弹出窗口
-
您将可以选择添加各种模块到您的安装中。请确保已选择 Visual Studio 模块,然后点击 下一步:
![img/B17573_01_08.png]()
图 1.8:添加安装模块
如果您以后想添加任何模块,您可以在 安装 窗口的任何版本右上角点击 更多 按钮(三个点图标)。
安装完成后,您将在 安装 面板中看到一个新版本,如下所示:

图 1.9:带有 Unity 版本的安装选项卡
您可以在 docs.unity3d.com/Manual/GettingStartedInstallingHub.html 找到有关 Unity Hub 应用程序的更多信息。
总是有出错的可能性,所以如果你使用的是 OS Catalina 或更新的 macOS,它可能会出现问题,请确保查看以下部分。
使用 macOS
如果你使用的是 OS Catalina 或更新的 Mac 操作系统,使用某些版本的 Unity Hub 安装 Unity 时会出现已知问题。如果你遇到这种情况,深呼吸,前往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
-
项目名称:我将我的命名为“英雄降世”
-
位置:你希望项目保存的位置
一旦设置完成,点击创建:

图 1.13:Unity Hub 带有新项目配置弹出窗口
项目创建完成后,你就可以探索 Unity 界面了!你可以在 Unity Hub 的项目面板中随时重新打开你的项目。
编辑器导航
当新项目初始化完成后,你会看到辉煌的 Unity 编辑器!我在以下屏幕截图中标记了重要的标签页(或如果你更喜欢,面板):

图 1.14:Unity 界面
这需要吸收很多信息,所以我们将更详细地查看这些面板:
-
工具栏面板是 Unity 编辑器的最顶部。从这里,你可以操作对象(最左侧的按钮组)和播放/暂停游戏(中间的按钮)。最右侧的按钮组包含 Unity 服务、层遮罩和布局方案功能,在这本书中我们不会使用它们,因为它们与学习 C#无关。
-
层次结构 窗口显示了当前游戏中 场景 中的每个项目。在入门项目中,这仅仅是默认的摄像机和方向光,但当我们创建我们的原型环境时,这个窗口将开始填充。
-
游戏 和 场景 窗口是编辑器中最直观的部分。将 场景 窗口视为你的舞台,你可以在这里移动和排列 2D 和 3D 对象。当你按下 播放 按钮时,游戏 窗口将接管,渲染 场景 视图和任何编程交互。
-
检查器 窗口是查看和编辑场景中对象属性的单一窗口。如果你在 层次结构 中选择 主摄像机 游戏对象,你会看到几个部分(Unity 称之为组件)被显示出来——所有这些都可以从这里访问。
-
项目 窗口包含了项目中当前所有的资源。将这视为你项目文件夹和文件的代表。
-
控制台 窗口是我们希望脚本打印输出的地方。从现在开始,如果我们谈论控制台或调试输出,这个面板就是它们将显示的地方。
如果不小心关闭了这些窗口中的任何一个,你可以随时从 Unity | 窗口 | 通用 中重新打开它们。你可以在 Unity 文档中找到每个窗口功能的更深入分析,网址为 docs.unity3d.com/Manual/UsingTheEditor.html。
在继续之前,确保 Visual Studio 已设置为项目的脚本编辑器。转到 Unity 菜单 | 首选项 | 外部工具,并确认 外部脚本编辑器 设置为 Visual Studio for Mac 或 Windows:

图 1.15:将外部脚本编辑器更改为 Visual Studio
作为最后的提示,如果你想切换亮色和暗色模式,请转到 Unity 菜单 | 首选项 | 通用 并更改 编辑器主题:

图 1.16:Unity 通用首选项面板
我知道如果你是 Unity 新手,这可能会让你感到很多信息需要处理,但请放心,接下来的任何说明都将始终引用必要的步骤。我不会让你困惑于要按哪个按钮。现在我们已经解决了这个问题,让我们开始创建一些实际的 C# 脚本。
在 Unity 中使用 C#
今后,将 Unity 和 C# 视为共生实体是很重要的。Unity 是你创建脚本和游戏对象的引擎,但实际的编程是在另一个名为 Visual Studio 的程序中进行的。现在不用担心这个——我们稍后会提到。
使用 C# 脚本
尽管我们还没有介绍任何基本编程概念,但它们在没有学会如何在 Unity 中创建实际的 C# 脚本之前将无处安放。C# 脚本是一种特殊的 C# 文件,在其中您将编写 C# 代码。这些脚本可以在 Unity 中执行几乎任何操作,从响应玩家输入到创建游戏机制。
从编辑器创建 C# 脚本有几种方法:
-
选择 Assets | 创建 | C# 脚本
-
在 项目 选项卡下方,选择 + 图标并选择 C# 脚本
-
在 项目 选项卡中右键单击 Assets 文件夹,并从弹出菜单中选择 创建 | C# 脚本
-
在 层次结构 窗口中选择任何 GameObject,然后点击 添加组件 | 新脚本
从现在开始,每当您被指示创建 C# 脚本时,请使用您喜欢的任何方法。
除了 C# 脚本之外,您可以使用前面提到的方法在编辑器中创建资源和对象。我不会每次创建新内容时都指出这些变体,所以只需将这些选项记在心里即可。
为了组织起见,我们将把各种资源和脚本存储在各自的标记文件夹中。这不仅仅是一个与 Unity 相关的任务——这是一件您应该始终做的事情,您的同事会感谢您的(我保证):
-
从 项目 选项卡,选择 + | 文件夹(或您最喜欢的任何方法——在图 1.17 中我们选择了 Assets | 创建 | 文件夹)并将其命名为
Scripts:![]()
图 1.17:创建 C# 脚本
-
双击 Scripts 文件夹,创建一个新的 C# 脚本。默认情况下,脚本将被命名为
NewBehaviourScript,但您会看到文件名被突出显示,因此您可以选择立即重命名它。输入LearningCurve并按Enter键:![]()
图 1.18:选择脚本文件夹的项目窗口
您可以使用 项目 选项卡右下角的小滑块来更改文件显示方式。
因此,您已经创建了一个名为 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 同步。最简单的方法是从 项目 选项卡中选择脚本。
双击 LearningCurve.cs,这将打开 Visual Studio 中的 C# 文件:

图 1.19:Visual Studio 中的 LearningCurve C# 脚本
你可以随时从Visual Studio | 视图 | 布局更改 Visual Studio 选项卡。本书的其余部分我将使用设计布局,这样我们就可以在编辑器的左侧看到我们的项目文件。
你会在界面的左侧看到文件夹结构,它反映了 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 的文档,因此了解如何早期访问它是有益的。参考手册将为你提供一个组件或主题的概述,而具体的编程示例可以在脚本参考中找到。
场景中的每个游戏对象(层次结构窗口中的项目)都有一个变换组件,它控制其位置、旋转和缩放。为了保持简单,我们只需在参考手册中查找摄像机的变换组件:
-
在层次结构选项卡中,选择主摄像机游戏对象
-
切换到检查器选项卡,点击变换组件右上角的信息图标(问号)!
![图片]()
图 1.21:在检查器中选中的主摄像机游戏对象
您将在参考手册的变换页面打开一个网页浏览器:

图 1.22:Unity 参考手册
Unity 中的所有组件都具有此功能,所以如果你想知道某物是如何工作的,你知道该怎么做。
因此,我们已经打开了参考手册,但如果我们想找到与变换组件相关的具体编码示例怎么办?这很简单——我们只需要询问脚本参考。
点击组件或类名下方的切换到脚本链接(在本例中为变换):

图 1.23:突出显示 SWITCH TO SCRIPTING 按钮的 Unity 参考手册
通过这样做,参考手册将自动切换到脚本参考:

图 1.24:突出显示 SWITCH TO MANUAL 的 Unity 脚本文档
如您所见,除了编码帮助外,如果需要,还可以切换回参考手册。
脚本参考是一个大文档,因为它是必需的。然而,这并不意味着你必须记住它,甚至不需要熟悉它的所有信息就可以开始编写脚本。正如其名所示,它是一个参考,而不是考试。
如果你在文档中迷失方向,或者只是不知道该往哪里看,你还可以在以下 Unity 丰富的开发社区中找到解决方案:
-
Unity 论坛:
forum.unity.com/ -
Unity 问答:
answers.unity.com/index.html -
Unity Discord:
discord.com/invite/unity
在另一方面,你需要知道在哪里找到任何 C#问题的资源,我们将在下一节中介绍。
定位 C#资源
现在我们已经处理好了 Unity 资源,让我们来看看微软的一些 C#资源。首先,在docs.microsoft.com/en-us/dotnet/csharp的 Microsoft Learn 文档中有大量的优秀教程、快速入门指南和如何操作的文章。你还可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/index找到关于单个 C#主题的精彩概述。
然而,如果你想了解特定 C#语言特性的详细信息,参考指南是你要去的地方。这些参考指南是任何 C#程序员的宝贵资源,但由于它们并不总是容易导航,让我们花几分钟时间学习如何找到我们想要的内容。
让我们加载编程指南链接并查找 C#的String类。你可以执行以下任一操作:
-
在网页左上角的搜索栏中输入
Strings -
滚动到语言部分并直接点击字符串链接!
![img/B17573_01_25.png]()

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

图 1.26:微软的字符串(C#编程指南)页面
与 Unity 的文档不同,C#参考和脚本信息都打包在一起,但它的优点是右侧的子主题列表。好好利用它!当你遇到困难或有问题时,知道在哪里找到帮助非常重要,所以每次遇到障碍时,一定要回到这个部分。
摘要
在本章中,我们涵盖了大量的后勤信息,所以如果你迫不及待地想写一些代码,我可以理解。开始新项目、创建文件夹和脚本、访问文档都是在新冒险的兴奋中容易忘记的话题。只需记住,本章包含了许多你可能在后续页面中需要的资源,所以不要害怕回来再次访问。像程序员一样思考是一种肌肉:你越锻炼它,它就越强壮。
在下一章中,我们将开始介绍你需要为编程大脑预热的理论、词汇和主要概念。尽管材料是概念性的,我们仍然会在LearningCurve脚本中写下我们的第一行代码。准备好吧!
小测验 - 处理脚本
-
Unity 和 Visual Studio 之间有什么类型的关系?
-
脚本参考提供了关于使用特定 Unity 组件或特性的示例代码。你可以在哪里找到更多关于 Unity 组件的详细(非代码相关)信息?
-
脚本参考是一个大文档。在尝试编写脚本之前,你需要记住其中多少内容?
-
何时是给 C#脚本命名的最佳时机?
加入我们的 Discord!
与其他用户、Unity/C# 专家以及哈里森·费罗内一起阅读此书。提问、为其他读者提供解决方案、通过问我任何问题环节与作者聊天,以及更多。
立即加入!

第二章:编程的构建块
对于不熟悉的人来说,任何编程语言一开始看起来都像古希腊文,C#也不例外。好消息是,在最初的神秘感之下,所有编程语言都由相同的本质构建块组成。变量、方法和类(或对象)构成了传统编程的 DNA;理解这些简单概念可以打开一个充满多样性和复杂应用的世界。毕竟,地球上每个人的 DNA 中只有四种不同的核苷酸;然而,我们每个人都是独特的生物体。
如果你刚开始接触编程,那么在这一章中会有大量的信息向你涌来,这可能是你写的第一行代码。重点是不要让你的大脑被事实和数据压垮;而是通过日常生活的例子,给你一个对编程构建块的全面了解。
本章主要介绍构成程序的各种组件的高层次视图。在直接编写代码之前,了解事物的工作原理不仅可以帮助新程序员站稳脚跟,而且还可以通过易于记忆的参考来巩固这些主题。抛开闲话不谈,我们将在本章中关注以下主题:
-
定义变量
-
理解方法
-
介绍类
-
使用注释
-
将构建块组合起来
定义变量
让我们从一个问题开始:什么是变量?根据你的观点,有几种不同的方式来回答这个问题:
-
概念上,变量是编程中最基本的单元,就像原子对物理世界一样(除了弦理论)。一切从变量开始,没有变量程序就无法存在。
-
技术上,变量是计算机内存中一个包含分配值的微小部分。每个变量都跟踪其信息存储的位置(这被称为内存地址),其值和其类型(例如,数字、单词或列表)。
-
实际上,变量是一个容器。你可以随意创建新的变量,填充内容,移动它们,改变它们所持有的内容,并在需要时引用它们。即使它们是空的,也可以是有用的。
你可以在微软 C#文档中找到对变量的深入解释,链接为docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/variables。
变量的一个实际生活例子是邮箱——你还记得吗?

图 2.1:一排五彩缤纷的邮箱快照
它们可以存放字母、纸币、你阿姨 Mabel 的一张照片——任何东西。关键是邮箱里的内容可以变化:它们可以有名字,存放信息(实体邮件),如果你有适当的权限,其内容甚至可以更改。同样,变量可以存储不同类型的信息。C# 中的变量可以存储字符串(文本)、整数(数字),甚至布尔值(代表真或假的二进制值)。
名称很重要
参考图 2.1,如果我问你过去打开邮箱,你可能会问的第一个问题是:哪一个?如果我说 Smith 家族的邮箱,或者向日葵邮箱,或者甚至是远处的低垂邮箱,那么你就有足够的上下文来打开我提到的邮箱。同样,当你创建变量时,你必须给它们起一个独特的名字,以便以后可以引用。我们将在第三章深入变量、类型和方法中详细介绍适当的格式化和描述性命名。
变量充当占位符
当你创建并命名一个变量时,你正在创建一个用于存储你想要存储的值的占位符。以下是一个简单的数学方程式作为例子:
2 + 9 = 11
好的,这里没有秘密,但如果我们想让数字 9 成为它的变量呢?考虑以下代码块:
MyVariable = 9
现在,我们可以使用变量名 MyVariable 代替 9 在任何需要的地方:
2 + MyVariable = 11
如果你想知道变量是否有其他规则或规定,它们确实有。我们将在下一章中介绍这些内容,所以请耐心等待。
尽管这个例子不是真正的 C# 代码,但它说明了变量的力量以及它们作为占位符引用的使用。在下一节中,你将开始创建自己的变量,所以继续前进吧!
好吧,理论就到这里——让我们在我们在第一章了解你的环境中创建的 LearningCurve 脚本中创建一个真正的变量:
-
双击 Unity 项目窗口中的
LearningCurve.cs以在 Visual Studio 中打开它。 -
在第 6 行和第 7 行之间添加一个空格,并添加以下代码行来声明一个新变量:
public int CurrentAge = 30; -
在
Start方法内部,添加两个调试日志来打印出以下计算结果:Debug.Log(30 + 1); Debug.Log(CurrentAge + 1);
让我们分解我们刚刚添加的代码。首先,我们创建了一个名为 CurrentAge 的新变量,并将其赋值为 30。然后,我们添加了两个调试日志来打印出 30 + 1 和 CurrentAge + 1 的结果,以展示变量作为值的存储方式。它们可以像值本身一样使用。
还需要注意的是,public 变量会出现在 Unity 检查器中,而 private 变量则不会。现在不必担心语法,只需确保你的脚本与以下截图所示的脚本相同:

图 2.2:在 Visual Studio 中打开 LearningCurve 脚本
最后,使用 编辑器 | 文件 | 保存 来保存文件。
为了在 Unity 中运行脚本,它们必须附加到场景中的 游戏对象 上。英雄降生 中的示例场景默认包含一个摄像机和一个方向光,这为场景提供了照明,所以让我们将 LearningCurve 附加到摄像机上以保持事情简单:
-
将
LearningCurve.cs拖放到 主摄像机 上。 -
选择 主摄像机,使其出现在 检查器 面板中,并验证
LearningCurve.cs(脚本)组件是否正确附加。 -
点击播放并注意 控制台 面板中的输出:
![img/B17573_02_03.png]()
图 2.3:Unity 编辑器窗口,带有拖放脚本的提示
Debug.Log() 语句打印出了我们放在括号之间的简单数学方程式的结果。正如你在下面的 控制台 截图中可以看到,使用了我们的变量 CurrentAge 的方程式与使用真实数字时的效果相同:

图 2.4:Unity 控制台,带有附加脚本的调试输出
我们将在本章的末尾讨论 Unity 如何将 C# 脚本转换为组件,但首先,让我们来改变我们变量中的一个的值。
由于 CurrentAge 如 图 2.2 所示在第七行被声明为一个变量,因此它可以存储的值可以被改变。更新后的值将随后传播到代码中任何使用该变量的地方;让我们看看这个动作:
-
如果场景仍在运行,请点击 暂停 按钮停止游戏
-
在 检查器 面板中将 当前年龄 改为
18,然后再次播放场景,查看 控制台 面板中的新输出:![img/B17573_02_05.png]()
图 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#中方法是如何工作的。就像变量示例一样,你希望将代码复制到你的脚本中,就像以下截图中显示的那样。我已经删除了之前的示例代码以使事情更整洁,但你当然可以在你的脚本中保留它以供参考:
-
在 Visual Studio 中打开
LearningCurve。 -
在第 8 行添加一个新变量:
public int AddedAge = 1; -
在第 16 行添加一个新方法,该方法将
CurrentAge和AddedAge相加并打印出结果:void ComputeAge() { Debug.Log(CurrentAge + AddedAge); } -
使用以下行在
Start中调用新方法:ComputeAge();在 Unity 中运行脚本之前,请确保你的代码看起来像以下截图:
![]()
图 2.6:使用新的 ComputeAge 方法的学习曲线
-
保存文件,然后返回 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 资源中,脚本和类有时可以互换使用。为了保持一致性,如果 C#文件附加到 GameObject 上,我将将其称为脚本;如果它们是独立的,则称为类。
类是蓝图
对于我们的最后一个例子,让我们思考一下当地邮局。它是一个独立、自包含的环境,具有诸如物理地址(一个变量)等属性,以及执行动作的能力,例如发送你的邮件(方法)。
这使邮局成为一个很好的例子,我们可以用以下伪代码块来概述潜在的类:
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脚本和主摄像机 GameObject。
脚本变成组件
所有 GameObject 组件都是脚本,无论是你自己编写的还是 Unity 团队编写的。唯一的区别是 Unity 特定的组件,如Transform及其相应的脚本,用户不应该对其进行编辑。
当你创建的脚本被拖放到 GameObject 上时,它成为该对象的一个新组件,这就是为什么它出现在检查器面板中的原因。对于 Unity 来说,它就像任何其他组件一样行走、说话和行动,包括组件下可以随时更改的公共变量。尽管我们不应该编辑 Unity 提供的组件,但我们仍然可以访问它们的属性和方法,使它们成为强大的开发工具。
当脚本成为组件时,Unity 也会进行一些自动的可读性调整。你可能已经注意到在图 2.3和2.5中,当我们把LearningCurve添加到主摄像机时,Unity 将其显示为Learning Curve,CurrentAge变为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 文档的熟悉度已经有所提高,我为你准备了一个简短的挑战,供你尝试!
英雄的考验 – MonoBehaviour 在脚本 API 中
现在是时候让你自己熟悉使用 Unity 文档了,还有什么比查找一些常见的 MonoBehaviour 方法更好的方式呢:
-
尝试在脚本 API 中搜索
Start()和Update()方法,以更好地理解它们在 Unity 中的功能以及何时执行 -
如果你感到勇敢,可以再进一步,查看手册中的
MonoBehaviour类以获得更详细的解释
摘要
在短短几页中我们已经走得很远了,但理解诸如变量、方法和类等基本概念的整体理论将为你打下坚实的基础。请记住,这些构建块在现实世界中都有非常真实的对应物。变量存储的值就像邮箱存储的信件一样;方法存储的指令就像食谱,需要遵循以获得预定义的结果;而类就像真正的蓝图。如果你希望房子能稳固地站立,那么没有经过深思熟虑的设计方案,你就不能建造房子。
本书剩余部分将带你从零开始深入了解 C# 语法,下一章将更详细地介绍如何创建变量、管理值类型以及与简单和复杂方法一起工作。
突击测验 – C# 基础知识
-
变量的主要用途是什么?
-
方法在脚本中扮演什么角色?
-
脚本是如何变成组件的?
-
点符号的作用是什么?
在 Discord 上加入我们!
与其他用户、Unity/C# 专家以及哈里森·费罗内一起阅读这本书。提问,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。
立即加入!

第三章:深入变量、类型和方法
任何编程语言的入门步骤都受到一个基本问题的困扰——你可以理解所输入的单词,但无法理解它们的含义。通常,这会导致悖论,但编程是一个特殊情况。
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 检查器面板中显示。
如果你包含一个修饰符,我们在本章开头整理的更新语法食谱将看起来像这样:
accessModifier dataType UniqueName = value;
当声明变量时,虽然显式访问修饰符不是必需的,但作为一个新程序员养成这个习惯是好的。这个词的额外作用对于代码的可读性和专业性来说意义重大。
C#中有四种主要的访问修饰符可用,但作为初学者,你将最常使用以下两个:
-
公共:这可以无限制地提供给任何脚本。
-
私有:这仅在它们被创建的类中可用(称为包含类)。任何没有访问修饰符的变量默认为私有。
两个高级修饰符有以下特点:
-
受保护的:可以从它们的包含类或从它派生的类型中访问
-
内部:仅在当前程序集内可用
每个这些修饰符都有特定的使用场景,但直到我们到达高级章节,不用担心受保护的和内部的。
还存在两种组合修饰符,但在这本书中我们不会使用它们。你可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/access-modifiers找到更多关于它们的信息。
让我们尝试一些自己的访问修饰符!就像现实生活中的信息一样,一些数据需要被保护或与特定的人共享。如果一个变量不需要在检查器窗口中更改或从其他脚本中访问,它是一个很好的私有访问修饰符的候选者。
执行以下步骤以更新LearningCurve:
-
将
CurrentAge前面的访问修饰符从public更改为private并保存文件。 -
返回 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,这样我们就可以在检查器窗口中看到它们。如果您需要灵感,请查看我的代码:
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结尾,例如FirstName和Pi所示。
我们的所有不同变量类型现在都可见。请注意 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:显示调试日志输出的控制台
还可以使用 + 运算符创建插值字符串,我们将在 介绍运算符 部分讨论这一点。
类型转换
我们已经看到变量只能持有其声明类型的值,但会有需要组合不同类型变量值的情况。在编程术语中,这些被称为转换,并且主要有两种类型:
-
隐式转换会自动进行,通常是在一个较小的值可以无任何舍入地放入另一个变量类型时。例如,任何整数都可以隐式地转换为
double或float值,而无需额外的代码: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# 中的命名约定是如何工作的,以及变量在我们的脚本中的位置。
变量命名
在学习了访问修饰符和类型之后,为变量命名可能看起来像是事后考虑的事情,但它不应该是一个简单的选择。代码中的清晰和一致的命名约定不仅会使代码更易读,而且还能确保你的团队成员在不需要询问的情况下就能理解你的意图。
命名变量的第一个规则是,你给它起的名字应该是具有意义的;第二个规则是使用 Pascal 风格。让我们以游戏中的一个常见例子为例,声明一个变量来存储玩家的生命值:
public int Health = 100;
如果你发现自己声明变量是这样的,你应该在脑海中响起警钟。谁的健康?它是存储最大值还是最小值?当这个值改变时,哪些其他代码会受到什么影响?这些都是应该通过有意义的变量名轻松回答的问题;你不想在一周或一个月后发现自己被自己的代码搞糊涂。
话虽如此,让我们尝试使用 Pascal 风格的命名来使它变得更好:
public int MaxPlayerHealth = 100;
记住,Pascal 风格的命名在每个单词的开头字母都要大写。
这样就更好了。经过一番思考,我们用意义和上下文更新了变量名。由于在变量名长度方面没有技术限制,你可能会发现自己写出了过于详细的名字,这会给你带来和简短、不具描述性的名字一样的问题。
作为一般规则,使变量名尽可能具有描述性——既不过多也不过少。找到你的风格并坚持下去。
理解变量作用域
我们即将结束对变量的深入研究,但还有一个更重要的话题需要讨论:作用域。与确定哪些外部类可以获取变量信息的访问修饰符类似,变量作用域是用来描述给定变量在其包含类中存在的地方及其访问点的术语。
C# 中变量作用域主要有三个级别:
-
全局作用域指的是可以被整个程序访问的变量;在这种情况下,是一个游戏。C# 不直接支持全局变量,但在某些情况下,这个概念是有用的,我们将在 第十章,重新审视类型、方法和类 中讨论。
-
类或成员作用域指的是在其包含类中任何地方都可以访问的变量。
-
局部作用域指的是只在其创建的特定代码块内可以访问的变量。
看看下面的截图。如果你不想将其放入 LearningCurve,那就不用了;目前这只是为了可视化目的:

图 3.9:LearningCurve 脚本中不同作用域的示意图
当我们谈论代码块时,我们指的是任何一组花括号内的区域。这些括号在编程中充当一种视觉层次结构;它们越向右缩进,就越深地嵌套在类中。
让我们分析一下前面截图中的类和局部作用域变量:
-
CharacterClass在类的最顶部声明,这意味着我们可以在LearningCurve的任何地方通过名称引用它。你可能听说过这个概念被称为变量可见性,这是一个很好的思考方式。 -
CharacterHealth在Start()方法中声明,这意味着它只在该代码块内部可见。我们仍然可以从Start()中无问题地访问CharacterClass,但如果尝试从Start()之外的地方访问CharacterHealth,就会得到错误。 -
CharacterName和CharacterHealth处于同一境地;它只能从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:在字符串上使用操作符
这种方法往往会产生笨拙的代码,因此在大多数情况下,字符串插值是组合不同文本片段的首选方法。
注意,算术操作符并不适用于所有数据类型。例如,* 和 / 操作符不适用于字符串值,而且这些操作符都不适用于布尔值。了解了类型有规则来规范它们可以进行的操作和交互后,让我们在下一节中尝试一下实践操作。
让我们做一个小实验:我们将尝试将我们的 string 和 float 变量相乘,就像我们之前对数字所做的那样:

图 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中,而你并不知道你将面临什么。这次,让我们有目的地创建一个方法:
-
声明一个名为
GenerateCharacter()的public方法,返回类型为void:public void GenerateCharacter() { } -
在新方法中添加一个简单的
Debug.Log()并打印出你喜欢的游戏或电影中的角色名称:Debug.Log("Character: Spike"); -
在
Start()方法中调用GenerateCharacter()并播放:void Start() { **GenerateCharacter();** }当游戏启动时,Unity 会自动调用
Start(),这反过来又调用我们的GenerateCharacter()方法并将结果打印到控制台窗口。
如果你阅读了足够的文档,你会看到与方法相关的一些不同术语。在这本书的其余部分,当创建或声明一个方法时,我会将其称为定义方法。同样,我会将运行或执行一个方法称为调用该方法。
命名的力量对于整个编程领域至关重要,因此在我们继续前进之前回顾命名规范对于方法来说并不令人惊讶。
命名规范
和变量一样,方法需要独特、有意义的名称来在代码中区分它们。方法驱动动作,因此考虑到这一点命名它们是一个好习惯。例如,GenerateCharacter()听起来像是一个命令,当你在一个脚本中调用它时,读起来很好,而像Summary()这样的名字平淡无奇,并不能清楚地描绘出该方法将完成什么。
方法作为逻辑绕行
我们已经看到代码行是按照它们书写的顺序顺序执行的,但是将方法引入画面引入了一个独特的情况。调用一个方法告诉程序进入方法指令,逐个运行它们,然后从方法被调用处继续顺序执行。
看看下面的截图,看看你是否能弄清楚调试日志将按什么顺序打印到控制台:

图 3.13:考虑调试日志的顺序
这些是发生的步骤:
-
选择一个字符首先打印出来,因为它是最先的代码行。 -
当调用
GenerateCharacter()时,程序跳转到第 23 行,打印出Character: Spike,然后从第 17 行继续执行。 -
A fine choice在所有GenerateCharacter()中的行执行完毕后最后打印出来。

图 3.14:显示角色构建代码输出的控制台
现在,如果我们不能向方法中添加参数值,那么方法本身将不会在简单示例之外非常有用,这正是我们接下来要做的。
指定参数
很可能你的方法不会总是像 GenerateCharacter() 那么简单。为了传递更多信息,我们需要定义方法可以接受和处理参数。每个方法参数都是一个指令,并且需要有两个东西:
-
显式类型
-
唯一的名字
这听起来熟悉吗?方法参数本质上是被简化的变量声明,并执行相同的功能。每个参数就像一个局部变量,只能在它们特定的方法内部访问。
你可以定义任意数量的参数。无论是编写自定义方法还是使用内置方法,定义的参数就是方法执行指定任务所需的内容。
如果参数是方法可以接受的值的类型的蓝图,那么参数就是这些值本身。为了进一步解释,考虑以下内容:
-
传递给方法的参数需要与参数类型匹配,就像变量类型和它的值一样
-
参数可以是字面值(例如,数字 2)或类中其他地方声明的变量
参数名和参数名不需要匹配即可编译。
现在,让我们继续前进,添加一些方法参数,使 GenerateCharacter() 方法变得更加有趣。
让我们更新 GenerateCharacter() 方法,使其能够接受两个参数:
-
添加两个方法参数:一个用于角色的
string类型名称,另一个用于角色的int类型等级:public void GenerateCharacter(string name, int level) -
更新
Debug.Log()以使用这些新参数:Debug.LogFormat("Character: {0} - Level: {1}", name, level); -
在
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() 方法,使其返回一个整数:
-
将方法声明中的返回类型从
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添加到级别参数来计算的。我们没有指定如何或是否要使用这个返回值,这意味着现在脚本不会做任何新的操作。
现在,问题变成了:我们如何捕获和使用新添加的返回值?嗯,我们将在下一节中讨论这个话题。
使用返回值
当涉及到使用返回值时,有两种方法可供选择:
-
创建一个局部变量来捕获(存储)返回值。
-
将调用方法本身用作返回值的替身,就像使用变量一样使用它。调用方法是实际触发指令的实际代码行,在我们的例子中,将是
GenerateCharacter("Spike", CharacterLevel)。如果需要,你甚至可以将调用方法作为参数传递给另一个方法。
在大多数编程领域,首选第一种方法,因为它易于阅读。将方法调用作为变量随意使用会很快变得混乱,尤其是在我们将它们用作其他方法的参数时。
让我们在代码中尝试通过捕获和调试 GenerateCharacter() 返回的返回值来验证这一点。
我们将使用两种捕获和使用返回变量的方式,通过两个简单的调试日志:
-
在
Start方法中创建一个新的局部变量,类型为int,名为NextSkillLevel,并将其赋值给现有的GenerateCharacter()方法调用返回值:int NextSkillLevel = GenerateCharacter("Spike", CharacterLevel); -
添加两个调试日志,第一个打印出
NextSkillLevel,第二个打印出你选择的参数值的新调用方法:Debug.Log(NextSkillLevel); Debug.Log(GenerateCharacter("Faye", CharacterLevel)); -
使用两个反斜杠 (
//) 注释掉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; } -
保存文件并在 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()。
Start 方法
Unity 在脚本首次启用时,会在第一帧调用 Start() 方法。由于 MonoBehaviour 脚本几乎总是附加到场景中的 GameObjects 上,因此当您按下播放时,它们附加的脚本会在加载时同时启用。在我们的项目中,LearningCurve 附加到 主摄像机 GameObject 上,这意味着当主摄像机被加载到场景中时,它的 Start() 方法就会运行。Start() 主要用于设置变量或执行需要在 Update() 首次运行之前发生的逻辑。
我们到目前为止所做的工作示例都使用了 Start(),即使它们没有执行设置操作,这通常不是它的常规用法。然而,它只触发一次,这使得它成为在控制台上显示一次性信息的绝佳工具。
除了 Start() 之外,还有一个主要的 Unity 方法,您会默认遇到:Update()。在我们完成本章之前,让我们熟悉一下它在以下部分是如何工作的。
Update 方法
如果你花足够的时间查看 Unity 脚本参考中的示例代码(docs.unity3d.com/ScriptReference/),你会注意到大部分代码都是使用 Update() 方法执行的。当你的游戏运行时,场景窗口每秒显示多次,这被称为帧率或 每秒帧数(FPS)。
在每个帧显示后,Unity 会调用 Update() 方法,这使得它成为你游戏中执行次数最多的方法之一。这使得它非常适合检测鼠标和键盘输入或运行游戏逻辑。
如果你想了解你机器上的 FPS 评分,在 Unity 中播放并点击 游戏 视图右上角的 统计 选项卡:

图 3.18:Unity 编辑器显示带有图形 FPS 计数的统计面板
你将在大部分初始的 C# 脚本中使用 Start() 和 Update() 方法,所以熟悉它们。话虽如此,你已经带着一袋 C# 编程最基础的构建块结束了这一章。
摘要
本章从编程的基本理论和其构建块快速下降到真实代码和 C# 语法层面。我们看到了代码格式的优劣,学习了如何在 Unity 控制台中调试信息,并创建了我们的第一个变量。
C# 类型、访问修饰符和变量作用域也紧随其后,因为我们开始在检查器窗口中处理成员变量,并开始探索方法和动作的领域。
方法帮助我们理解代码中的书面指令,但更重要的是,如何正确地利用它们的威力来实现有用的行为。输入参数、返回类型和方法签名都是重要的话题,但它们真正提供的礼物是执行新类型动作的潜力。
你现在已经拥有了编程的两个基本构建块;从现在开始,你几乎所做的一切都将是对这两个概念的扩展或应用。
在下一章中,我们将探讨 C# 类型的一个特殊子集,称为集合,它可以存储相关数据组,并学习如何编写基于决策的代码。
突击测验 - 变量和方法
-
在 C# 中如何正确地编写变量名?
-
你如何在 Unity 的检查器窗口中使变量可见?
-
C# 中有四种访问修饰符可用吗?
-
在什么情况下需要在类型之间进行显式转换?
-
定义方法的最小要求是什么?
-
方法名末尾的括号有什么作用?
-
方法定义中
void返回类型意味着什么? -
Unity 多频繁地调用
Update()方法?
加入我们的 Discord!
与其他用户、Unity/C# 专家和哈里森·费罗尼一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天等等。
立即加入!

第四章:控制流和集合类型
计算机的一项基本职责是控制当预定的条件满足时会发生什么。当你点击一个文件夹时,你期望它打开;当你敲击键盘时,你期望文本反映你的按键。为应用程序或游戏编写代码并无不同——它们都需要在一种状态下以某种方式表现,在另一种状态下当条件改变时也是如此。在编程术语中,这被称为控制流,这是恰当的,因为它控制了在不同场景中代码执行的流程。
除了处理控制语句外,我们还将亲手研究集合数据类型。集合是一类允许在单个变量中存储多个值和值组的类型。我们将把本章分解为以下主题:
-
选择语句
-
使用数组、字典和列表集合进行操作
-
使用
for、foreach和while循环的迭代语句 -
解决无限循环
选择语句
最复杂的编程问题通常可以归结为游戏或程序评估并采取的简单选择集。由于 Visual Studio 和 Unity 不能自己做出这些选择,编写这些决策取决于我们。
if-else和switch选择语句允许你根据一个或多个条件指定分支路径,以及在每个情况下你想要采取的操作。传统上,这些条件包括以下内容:
-
检测用户输入
-
评估表达式和布尔逻辑
-
比较变量或文字值
在接下来的部分中,你将开始使用这些条件语句中最简单的一种,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变体:
-
在不需要关心条件不满足时会发生什么的情况下,单个
if语句可以独立存在。在以下示例中,如果hasDungeonKey设置为true,则将打印调试日志;如果设置为false,则不会执行任何代码:public class LearningCurve: MonoBehaviour { public bool hasDungeonKey = true; Void Start() { if(hasDungeonKey) { Debug.Log("You possess the sacred key – enter."); } } }当提到一个条件被满足时,我的意思是它评估为真,这通常被称为通过条件。
-
在需要无论条件是否为真都要采取行动的情况下,添加一个没有条件的
else语句。如果hasDungeonKey为false,则if语句将失败,代码执行将跳转到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."); } } } -
对于需要超过两种可能结果的情况,添加一个带有其括号、条件和花括号的
else-if语句。这最好通过展示而不是解释来说明,我们将在下面这样做。
请记住,if语句可以单独使用,但其他语句不能单独存在。您还可以使用基本的数学运算创建更复杂的条件,例如>(大于)、<(小于)、>=(大于或等于)、<=(小于或等于)和==(等于)。例如,条件(2 > 3)将返回false并失败,而条件(2 < 3)将返回true并通过。
目前不必太担心这个范围之外的事情;你很快就会接触到这些内容。
让我们编写一个if-else语句来检查角色口袋里的金额,为三种不同的情况返回不同的调试日志——大于50、小于15和任何其他情况:
-
打开
LearningCurve并添加一个新的公共int变量,命名为CurrentGold。将其值设置为 1 到 100 之间:public int CurrentGold = 32; -
创建一个没有返回值的
public方法,命名为Thievery,并在Start内部调用它。 -
在新函数内部,添加一个
if语句来检查CurrentGold是否大于50,如果这个条件为真,则在控制台打印一条消息:if(CurrentGold > 50) { Debug.Log("You're rolling in it!"); } -
添加一个
else-if语句来检查CurrentGold是否小于15,并使用不同的调试日志。else if (CurrentGold < 15) { Debug.Log("Not much there to steal..."); } -
添加一个没有条件且为最终默认日志的
else语句。else { Debug.Log("Looks like your purse is in the sweet spot."); } -
保存文件,确保你的方法与下面的代码匹配,然后点击播放:
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,我们可以将代码序列分解如下:
-
由于
CurrentGold不是大于50,因此跳过了if语句和调试日志。 -
由于
CurrentGold不是小于15,因此跳过了else-if语句和调试日志。 -
由于 32 既不小于 15 也不大于 50,因此之前的条件都没有满足。执行
else语句并显示第三个调试日志:![]()
图 4.1:显示调试输出的控制台截图
在自己尝试了CurrentGold的其他值之后,让我们讨论如果我们想测试一个失败的条件会发生什么。
使用 NOT 运算符
并非所有用例都需要检查正的或true条件,这就是NOT运算符发挥作用的地方。用单个感叹号表示的NOT运算符允许if或else-if语句满足负的或false条件。这意味着以下条件是相同的:
if(variable == false)
// AND
if(!variable)
如你所知,你可以在if条件中检查布尔值、字面值或表达式。因此,NOT运算符必须具有适应性。
看看以下示例,其中在if语句中使用了两个不同的负值,hasDungeonKey和weaponType:
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...");
}
}
}
我们可以这样评估每个语句:
-
第一个语句可以翻译为:“如果
hasDungeonKey为false,则if语句评估为真并执行其代码块。”如果你自己在想一个假值如何评估为真,可以这样想:
if语句不是检查值是否为真,而是检查表达式本身是否为真。hasDungeonKey可能被设置为false,但这是我们正在检查的,所以在if条件的上下文中它是真的。 -
第二个语句可以翻译为:“如果
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语句检查我们是否有weaponEquipped。在这个时候,代码只关心它是否为true,而不是它是什么类型的武器。 -
第二个
if语句检查weaponType并打印出相关的调试日志。 -
如果第一个
if语句评估为false,代码将跳转到else语句及其调试日志。如果第二个if语句评估为false,则不会打印任何内容,因为没有else语句。
处理逻辑结果的责任完全在程序员身上。取决于你确定代码可能采取的可能分支或结果。
你到目前为止学到的知识将帮助你处理没有问题的简单用例。然而,你很快就会发现自己需要更复杂的语句,这就是评估多个条件发挥作用的地方。
评估多个条件
除了嵌套语句外,还可以使用 AND 和 OR 逻辑运算符将多个条件检查组合成一个单独的 if 或 else-if 语句:
-
AND使用两个和号字符&&表示。任何使用AND运算符的条件都意味着所有条件都需要评估为真,if语句才能执行。 -
OR使用两个管道字符||表示。使用OR运算符的if语句将在其中一个或多个条件为真时执行。 -
条件总是从左到右进行评估。
在以下示例中,if 语句已被更新,以检查 weaponEquipped 和 weaponType,这两个条件都必须为真,代码块才能执行:
if(weaponEquipped && weaponType == "Longsword")
{
Debug.Log("For the Queen!");
}
AND 和 OR 运算符可以组合起来以任意顺序检查多个条件。你还可以组合任意数量的运算符。但请注意,在使用它们时,不要创建永远不会执行的逻辑条件。
现在是时候测试我们到目前为止关于 if 语句所学的所有内容了。所以,如果你需要的话,回顾这一节,然后继续下一节。
让我们通过一个小宝箱实验来巩固这个主题:
-
在
LearningCurve的顶部声明三个变量:PureOfHeart是一个bool,应该是true,HasSecretIncantation也是一个bool,应该是false,而RareItem是一个字符串,其值由你决定:public bool PureOfHeart = true; public bool HasSecretIncantation = false; public string RareItem = "Relic Stone"; -
创建一个没有返回值的
public方法,命名为OpenTreasureChamber,并在Start()内部调用它。 -
在
OpenTreasureChamber内部声明一个if-else语句,检查PureOfHeart是否为true并且RareItem是否与分配给它的字符串值匹配:if(PureOfHeart && RareItem == "Relic Stone") { } -
在第一个
if-else语句内部创建一个嵌套的if-else语句,检查HasSecretIncantation是否为false:if(!HasSecretIncantation) { Debug.Log("You have the spirit, but not the knowledge."); } -
为每个
if-else情况添加调试日志。 -
保存,检查你的代码是否与下面的代码匹配,然后点击播放:
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 语句,用于不同角色可能采取的操作:
-
创建一个新的字符串变量(成员或局部变量),命名为
CharacterAction,并将其设置为Attack:string CharacterAction = "Attack"; -
创建一个没有返回值的
public方法,命名为PrintCharacterAction,并在Start中调用它。 -
声明一个
switch语句,并使用CharacterAction作为匹配表达式:switch(CharacterAction) { } -
为
Heal和Attack创建两个case语句,并包含不同的调试日志。别忘了在每个语句的末尾包含break关键字:case "Heal": Debug.Log("Potion sent."); break; case "Attack": Debug.Log("To arms!"); break; -
添加一个带有调试日志和
break:的默认情况default: Debug.Log("Shields up."); break; -
保存文件,确保您的代码与下面的截图匹配,然后点击播放:
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 被设置为 Attack,switch 语句执行第二个 case 并打印出其调试日志:

图 4.4:控制台中的 switch 语句输出截图
将 CharacterAction 更改为 Heal 或未定义的操作,以查看第一个和默认情况的实际操作。
有时会需要几个,但不是所有的 switch 情况来执行相同的操作。这些被称为穿透情况,是我们下一节的主题。
穿透情况
switch 语句可以为多个情况执行相同的操作,类似于我们在单个 if 语句中指定多个条件的方式。这种情况下称为跳过(fall-through)或有时称为跳过情况。跳过情况允许你为多个情况定义一组单独的操作。如果一个情况块被留空或者包含没有 break 关键字的代码,它将跳转到直接位于其下的情况。这有助于保持你的 switch 代码干净且高效,避免重复的情况块。
情况可以按任何顺序编写,因此创建跳过情况大大增加了代码的可读性和效率。
让我们通过 switch 语句和跳过情况模拟一个桌面游戏场景,掷骰子的结果将决定特定动作的结果:
-
创建一个名为
DiceRoll的int变量,并给它赋值为7:int DiceRoll = 7; -
创建一个没有返回值的
public方法,命名为RollDice,并在Start中调用它。 -
添加一个以
DiceRoll作为匹配表达式的switch语句:switch(DiceRoll) { } -
为可能的骰子点数
7、15和20添加三个情况,并在最后添加一个默认case语句。 -
情况
15和20应该有自己的调试日志和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; -
保存文件并在 Unity 中运行它。
如果你想看到跳过情况的实际应用,尝试在情况 7 中添加一个调试日志,但不要使用
break关键字。
当 DiceRoll 设置为 7 时,switch 语句将与第一个 case 匹配,然后跳过并执行 case 15,因为它没有代码块和 break 语句。如果你将 DiceRoll 改为 15 或 20,控制台将显示它们各自的消息,任何其他值将触发语句末尾的默认情况:

图 4.5:跳过 switch 语句代码的截图
switch 语句非常强大,甚至可以简化最复杂的决策逻辑。如果你想深入了解 switch 模式匹配,请参阅 docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/switch。
目前我们只需要了解这些关于条件逻辑的知识。所以,如果你需要,请复习这一节,然后在继续学习集合之前,先在下面的测验中测试一下自己!
突击测验 1 – 如果,和,或但是
用以下问题测试你的知识:
-
用来评估
if语句的值是什么? -
哪个运算符可以将真条件转换为假或假条件转换为真?
-
如果需要两个条件都为真才能执行
if语句的代码,你会使用哪个逻辑运算符来连接条件? -
如果只需要两个条件中的一个为真来执行
if语句的代码,你会使用哪个逻辑运算符来连接这两个条件?
完成这些后,你就可以进入集合数据类型的世界了。这些类型将为你的游戏和 C#程序打开全新的编程功能子集!
一眼就能看到收藏夹
到目前为止,我们只需要变量来存储单个值,但有许多情况下需要一组值。C#中的集合类型包括数组、字典和列表——每个都有其优势和劣势,我们将在接下来的章节中讨论。
数组
数组是 C#提供的最基本的数据集合。把它们想象成一组值的容器,在编程术语中称为元素,每个元素都可以单独访问或修改:
-
数组可以存储任何类型的值;所有元素都需要是同一类型。
-
数组的长度,或数组可以拥有的元素数量,在创建时设置,之后不能修改。
-
如果在创建时没有分配初始值,每个元素都将被赋予一个默认值。存储数字类型的数组默认为零,而任何其他类型都被设置为 null 或无。
数组是 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位于索引0,713位于索引1,984位于索引2:

图 4.6:数组索引映射到其值
使用下标运算符可以通过索引定位单个值,它是一对包含元素索引的方括号。例如,要检索并存储topPlayerScores中的第二个数组元素,我们会使用数组名称后跟下标括号和索引1:
// The value of score is set to 713
int score = topPlayerScores[1];
下标运算符也可以用来直接修改数组值,就像任何其他变量一样,或者甚至可以作为一个表达式本身传递:
topPlayerScores[1] = 1001;
topPlayerScores中的值将是452、1001和984。
范围异常
当创建数组时,元素的数目是固定的,不可更改,这意味着我们无法访问不存在的元素。在topPlayerScores的例子中,数组长度是 3,因此有效索引的范围是从0到2。任何大于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 };
元素按添加的顺序存储(而不是值的顺序),是零索引的,并且可以使用下标操作符访问。
让我们开始设置自己的列表以测试这个类提供的基本功能。
让我们通过创建一个虚构角色扮演游戏中的角色列表来进行一次热身练习:
-
在
Start中创建一个新的string类型的List,名为QuestPartyMembers,并用三个角色的名字初始化它:List<string> QuestPartyMembers = new List<string>() { "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight" }; -
添加一个调试日志,使用
Count方法打印出列表中的人数:Debug.LogFormat("Party Members: {0}", QuestPartyMembers.Count); -
保存文件并在 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 现在包含以下元素,索引从 0 到 3:
{ "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类型通过在每个元素中存储值对,而不是单个值,与数组和列表不同。这些元素被称为键值对:键作为其对应值的索引或查找值。与数组和列表不同,字典是无序的。然而,在创建后,它们可以根据各种配置进行排序和排序。
声明字典几乎与声明列表相同,但有一个额外的细节——键和值类型都需要在箭头符号内指定:
Dictionary<keyType, valueType> name = new Dictionary<keyType,
valueType>();
要使用键值对初始化字典,请执行以下操作:
-
在声明末尾使用一对花括号。
-
在其成对的圆括号内添加每个元素,键和值之间用逗号分隔。
-
用逗号分隔元素,除了最后一个元素,那里的逗号是可选的。
它看起来是这样的:
Dictionary<keyType, valueType> name = new Dictionary<keyType,
valueType>()
{
{key1, value1},
{key2, value2}
};
在选择键值时,一个重要的注意事项是每个键必须是唯一的,并且不能更改。如果你需要更新一个键,那么你需要更改变量声明中的其值,或者删除整个键值对并在代码中添加另一个,我们将在下一部分讨论。
就像数组和列表一样,字典可以在单行中初始化,而不会从 Visual Studio 中产生任何问题。然而,像前面示例中那样,将每个键值对写在单独的一行上,是一个好习惯——既有利于可读性,也有利于你的精神健康。
让我们创建一个字典来存储一个角色可能携带的物品:
-
在
Start方法中声明一个名为ItemInventory的Dictionary,其key类型为string,value类型为int。 -
初始化它为
new Dictionary<string, int>(),并添加三个你选择的键值对。确保每个元素都在其成对的花括号内:Dictionary<string, int> `I`temInventory = new Dictionary<string, int>() { { "Potion", 5 }, { "Antidote", 7 }, { "Aspirin", 1 } }; -
添加一个调试日志来打印出
ItemInventory.Count属性,这样我们就可以看到物品是如何存储的:Debug.LogFormat("Items: {0}", `I`temInventory.Count); -
保存文件并播放。
在这里,创建了一个名为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;
}
让我们分解一下:
-
for关键字开始语句,后面跟着一对括号。 -
在括号内是守门人:
initializer、condition和iterator表达式。 -
循环从
initializer表达式开始,这是一个局部变量,用于跟踪循环已执行了多少次——这通常设置为 0,因为集合类型是零索引的。 -
接下来,检查
condition表达式,如果为真,则继续到iterator。 -
iterator表达式用于增加或减少(递增或递减)初始化器,这意味着下一次循环评估其条件时,初始化器将不同。
通过 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]);
}
让我们再次遍历循环,看看它是如何工作的:
-
首先,
for循环中的initializer被设置为名为i的局部int变量,起始值为0。 -
为了确保我们永远不会得到越界异常,
for循环确保只有在i小于QuestPartyMembers中元素的数量时才会再次运行循环:-
对于数组,我们使用
Length属性来确定它有多少项。 -
对于列表,我们使用
Count属性
-
-
最后,每次循环运行时,
i都会通过++运算符增加 1。 -
在
for循环内部,我们刚刚使用i打印出了索引和该索引处的列表元素。 -
注意,
i与集合元素的索引保持一致,因为它们都是从 0 开始的:![img/B17573_04_10.png]()
图 4.10:使用 for 循环打印出的列表值的截图
传统上,字母 i 通常用作初始化变量名。如果你恰好有嵌套的 for 循环,所使用的变量名应该是字母 j、k、l 等等。
让我们在我们现有的集合之一上尝试我们的新迭代语句。
当我们遍历 QuestPartyMembers 时,让我们看看我们是否可以识别出某个元素被迭代的情况,并为这种情况添加一个特殊的调试日志:
-
将
QuestPartyMembers列表和for循环移动到名为FindPartyMember的公共函数中,并在Start中调用它。 -
在
for循环中在调试日志下方添加一个if语句,以检查当前的questPartyMember列表是否匹配"Merlin the Wise":if(QuestPartyMembers[i] == "Merlin the Wise") { Debug.Log("Glad you're here Merlin!"); } -
如果是这样,添加一个你选择的调试日志,检查你的代码是否与下面的截图匹配,然后点击播放:
// 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!"); } } }
控制台输出应该看起来几乎一样,但现在有一个额外的调试日志——当轮到梅林进入循环时只打印了一次。更具体地说,当第二次循环中的 i 等于 1 时,if 语句被触发,打印了两个日志而不是一个:

图 4.11:for 循环打印列表值和匹配 if 语句的截图
在正确的情况下,使用标准的 for 循环非常有用,但在编程中,事情往往没有唯一的方法,这就是 foreach 语句发挥作用的地方。
foreach 循环
foreach 循环将集合中的每个元素取出来,并将每个元素存储在一个局部变量中,使其在语句内部可访问。局部变量类型必须与集合元素类型匹配才能正常工作。foreach 循环可以与数组列表一起使用,但它们在字典中特别有用,因为字典是键值对而不是数字索引。
在蓝图形式中,foreach 循环看起来是这样的:
foreach(elementType localName in collectionVariable)
{
code block;
}
让我们继续使用 Q``uestPartyMembers 列表示例,并对它的每个元素进行点名:
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,完成了操作!![图片]()

这比 for 循环简单得多。然而,当处理字典时,有一些重要的区别我们需要提及——即如何作为局部变量处理键值对。
遍历键值对
要在局部变量中捕获键值对,我们需要使用名为 KeyValuePair 的类型,将键和值类型分配为与字典的对应类型相匹配。由于 KeyValuePair 是其类型,它就像任何其他元素类型一样,作为一个局部变量。
例如,让我们遍历在 Dictionaries 部分中创建的 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);
}
我们指定了一个名为 kvp 的局部变量,这是编程中的一种常见命名约定,就像将 for 循环初始化器命名为 i,并将 key 和 value 类型设置为 string 和 int 以匹配 ItemInventory。
要访问局部变量 kvp 的键和值,我们分别使用 KeyValuePair 的 Key 和 Value 属性。
在这个例子中,键是 string 类型,值是整数,我们可以将其打印出来作为项目名称和项目价格:

图 4.13:打印字典键值对的 foreach 循环截图
如果您特别有冒险精神,请尝试以下可选挑战,以巩固您刚刚学到的知识。
英雄的考验 – 寻找负担得起的物品
使用前面的脚本,创建一个变量来存储您虚构角色拥有的金币数量,并尝试在 foreach 循环中添加一个 if 语句来检查您能否负担得起这些物品。
提示:使用 kvp.Value 来比较价格与您钱包中的金额。
while 循环
while 循环与 if 语句类似,只要单个表达式或条件为真就会运行。
值比较和布尔变量可以用作 while 条件,并且可以使用 NOT 运算符进行修改。
while 循环的语法表示为:“当我的条件为真时,无限期地运行我的代码块”:
Initializer
while (condition)
{
code block;
iterator;
}
在 while 循环中,通常像 for 循环一样声明一个初始化变量,并在循环代码块的末尾手动增加或减少它。我们这样做是为了避免无限循环,我们将在本章末尾讨论这个问题。根据您的具体情况,初始化器通常是循环条件的一部分。
while 循环在 C# 编程中非常有用,但在 Unity 中并不被认为是良好的实践,因为它们可能会对性能产生负面影响,并且通常需要手动管理。
让我们考虑一个常见的用例,其中我们需要在玩家存活时执行代码,然后在该情况不再成立时进行调试:
-
创建一个名为
PlayerLives的初始化变量,其类型为int,并将其设置为3:int PlayerLives = 3; -
创建一个新的公共函数
HealthStatus并在Start中调用它。 -
声明一个
while循环,其条件检查PlayerLives是否大于0(即玩家仍然存活):while(PlayerLives > 0) { } -
在
while循环内部,进行一些调试以让我们知道角色仍然在战斗,然后使用--运算符将PlayerLives减少一:Debug.Log("Still alive!"); PlayerLives--; -
在
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..."); }
由于 PlayerLives 的初始值为 3,while 循环将执行三次。在每次循环中,调试日志 "Still alive!" 被触发,并从 PlayerLives 中减去一条生命。当 while 循环尝试第四次运行时,由于 PlayerLives 为 0,条件失败,因此代码块被跳过,并打印出最后的调试日志:

图 4.14:控制台中的 while 循环输出截图
如果您没有看到多个 "仍存活!" 调试日志,请确保 Console 工具栏中的 Collapse 按钮没有被选中。
现在的问题是,如果循环永远不会停止执行会发生什么?我们将在下一节讨论这个问题。
到无限远方
在结束本章之前,我们需要理解迭代语句中的一个极其重要的概念:无限循环。它们正是其名称所暗示的那样:当循环的条件使得循环无法停止运行并继续程序中的其他部分时。无限循环通常发生在for和while循环中,当迭代器没有被增加或减少时;如果while循环示例中遗漏了PlayerLives代码行,Unity 会冻结和/或崩溃,意识到PlayerLives始终为 3,并无限期地执行循环。
迭代器并不是唯一需要警惕的罪魁祸首;在for循环中设置永远不会失败或评估为假的条件,也可能导致无限循环。在“通过键值对循环”部分提到的党员例子中,如果我们把for循环的条件设置为i < 0而不是i < QuestPartyMembers.Count,i将始终小于0,循环直到 Unity 崩溃。
摘要
随着本章的结束,我们应该反思我们已经取得了多少成就,以及我们可以用这些新知识构建什么。我们知道如何使用简单的if-else检查和更复杂的switch语句,允许在代码中进行决策。我们可以使用数组、列表或字典来创建存储值集合的变量,或者存储键值对。这允许复杂和分组的数据被有效地存储。我们甚至可以为每种集合类型选择正确的循环语句,同时小心避免无限循环崩溃。
如果你感到压力过大,那完全没问题——逻辑性和顺序性思维都是锻炼编程大脑的一部分。
下一章将通过对类、结构和面向对象编程(OOP)的探讨来完成 C#编程的基础。我们将把迄今为止学到的所有知识应用到这些主题中,为理解并控制 Unity 引擎中的对象做好第一次真正的准备。
加入我们的 Discord!
与其他用户、Unity/C#专家和哈里森·费罗恩一起阅读这本书。提问、为其他读者提供解决方案、通过“问我任何问题”的环节与作者聊天,以及更多。
立即加入!

第五章:与类、结构体和 OOP 一起工作
由于显而易见的原因,本书的目标不是让你因为信息过载而头痛欲裂。然而,接下来的这些主题将带你走出初学者的隔间,进入面向对象编程(OOP)的广阔天地。到目前为止,我们一直在依赖 C#语言中作为其一部分的预定义变量类型:底层的字符串、列表和字典都是类,这就是为什么我们可以通过点符号创建它们并使用它们的属性。然而,依赖内置类型有一个明显的弱点——无法偏离 C#已经设定的蓝图。
创建你的类为你提供了定义和配置设计蓝图的自由,捕捉特定于你的游戏或应用程序的信息和驱动动作。本质上,自定义类和 OOP 是编程王国的钥匙;没有它们,独特的程序将寥寥无几。
在本章中,你将亲自动手从头创建类,并讨论类变量、构造函数和方法的工作原理。你还将了解到引用类型和值类型对象的区别,以及这些概念如何在 Unity 中应用。随着你的学习深入,以下主题将更详细地讨论:
-
面向对象编程简介
-
定义类
-
声明结构体
-
理解引用类型和值类型
-
整合面向对象思维
-
在 Unity 中应用 OOP
面向对象编程简介
面向对象编程(OOP)是你在使用 C#进行编码时将使用的主要编程范式。如果类和结构体实例是我们程序的原型,那么 OOP 就是将一切联系起来的架构。当我们提到 OOP 作为编程范式时,我们是在说它有特定的原则来指导整体程序应该如何工作和通信。
从本质上讲,OOP 关注的是对象而不是纯粹的顺序逻辑——它们持有的数据、它们如何驱动动作,以及最重要的是,它们如何相互通信。
定义类
回到第二章,编程的基本元素,我们简要地讨论了类是如何作为对象的原型,并提到它们可以被当作自定义变量类型。我们还了解到LearningCurve脚本是一个类,但它是 Unity 可以附加到场景中对象的特殊类。关于类,我们需要记住的主要事情是它们是引用类型——也就是说,当它们被分配或传递给另一个变量时,引用的是原始对象,而不是一个新的副本。在讨论结构体之后,我们再深入探讨这一点。然而,在所有这些之前,我们需要了解创建类的基础知识。
目前,我们将暂时放下 Unity 中类和脚本的工作方式,专注于在 C#中它们是如何创建和使用的。类是通过使用class关键字创建的,如下所示:
accessModifier class UniqueName
{
Variables
Constructors
Methods
}
在类内部声明的任何变量或方法都属于该类,并且可以通过其唯一的类名来访问。
为了使本章中的示例尽可能连贯,我们将创建和修改一个典型的游戏会有的简单 Character 类。我们还将从代码截图转向让你习惯于像在“野外”看到的那样阅读和解释代码。然而,我们首先需要的是我们自己的自定义类,所以让我们创建一个。
在我们能够理解它们的内部工作原理之前,我们需要一个类来练习,所以让我们创建一个新的 C# 脚本并从头开始:
-
右键单击你在 第一章,了解你的环境 中创建的
Scripts文件夹,然后选择 创建 | C# 脚本。 -
将脚本命名为
Character,在 Visual Studio 中打开它,并删除所有生成的代码。 -
声明一个名为
Character的公共类,然后跟着一组花括号,然后保存文件。你的类代码应该与以下代码完全匹配:using System.Collections; using System.Collections.Generic; using UnityEngine; public class Character { } -
我们删除了生成的代码,因为我们不需要将此脚本附加到 Unity GameObject 上。
Character 现在已注册为公共类蓝图。这意味着项目中的任何类都可以使用它来创建角色。然而,这些只是指令——创建一个角色需要额外的步骤。这个创建步骤被称为 实例化,也是下一节的主题。
实例化类对象
实例化是从一组特定的指令创建对象的行为,这些指令被称为实例。如果类是蓝图,那么实例就是根据它们的指令建造的房屋;每个新的 Character 实例都是它的对象,就像根据相同指令建造的两个房屋仍然是两个不同的物理结构一样。一个发生的事情不会对另一个有任何影响。
在 第四章,控制流和集合类型 中,我们创建了列表和字典,这些是 C# 中的默认类,使用它们的类型和 new 关键字。我们也可以为自定义类,如 Character,做同样的事情,你将在下一节中这样做。
我们将 Character 类声明为公共的,这意味着可以在任何其他类中创建 Character 实例。由于我们已经有了 LearningCurve 在工作,让我们在 Start() 方法中声明一个新的角色。
打开 LearningCurve 并在 Start() 方法中声明一个新的 Character 类型变量,名为 hero:
Character hero = new Character();
让我们一步一步地分解这个过程:
-
变量类型指定为
Character,这意味着该变量是该类的一个实例。 -
变量名为
hero,它使用new关键字、Character类名称和两个括号创建。这是在程序内存中创建实际实例的地方,即使类目前为空。 -
我们可以使用
hero变量就像我们迄今为止使用的任何其他对象一样。当Character类有自己的变量和方法时,我们可以通过点符号从hero访问它们。
你同样可以在创建hero变量时使用推断声明,如下所示:
var hero = new Character();
现在我们没有类字段来工作,我们的角色类几乎什么也做不了。你将在接下来的几节中添加类字段和其他内容。
添加类字段
向自定义类添加变量或字段与我们在LearningCurve中已经做过的操作没有区别。同样的概念适用,包括访问修饰符、变量作用域和值赋值。然而,属于类的任何变量都是与类实例一起创建的,这意味着如果没有分配值,它们将默认为零或 null。一般来说,选择设置初始值取决于它们将存储的信息:
-
如果变量需要在每次创建类实例时具有相同的起始值,设置初始值是一个好主意。这对于像经验值或起始分数这样的东西很有用。
-
如果需要为每个类实例定制变量,如
CharacterName,则保留其值未分配,并使用类构造函数(我们将在使用构造函数部分讨论这个话题)。
每个角色类都需要一些基本字段;你的任务是添加它们在下面的部分。
让我们包含两个变量来存储角色的名称和起始经验点数:
-
在
Character类的花括号内添加两个public变量——一个用于名称的string变量和一个用于经验点的integer变量。 -
将
name值留空,但将经验值设置为0,这样每个角色都从底部开始:public class Character { public string name; public int exp = 0; } -
在
LearningCurve中在Character实例初始化后立即添加一个调试日志。使用它通过点符号打印出新角色的name和exp变量:Character hero = new Character(); Debug.LogFormat("Hero: {0} - {1} EXP", hero.name, hero.exp); -
当
hero初始化时,name被分配一个 null 值,在调试日志中显示为空格,而exp打印出0。注意,我们不需要将Character脚本附加到场景中的任何 GameObject 上;我们只是在LearningCurve中引用了它们,Unity 就完成了剩余的工作。控制台现在将调试我们的角色信息,如下所示:![]()
图 5.1:控制台中打印的自定义类属性截图
到目前为止,我们的类已经可以工作,但使用这些空值并不实用。你需要通过所谓的类构造函数来修复这个问题。
使用构造函数
类构造函数是特殊方法,在创建类实例时自动触发,这与LearningCurve中的Start方法运行方式类似。构造函数根据其蓝图构建类:
-
如果没有指定构造函数,C#将生成一个默认的构造函数。默认构造函数将任何变量设置为它们的默认类型值——数值设置为零,布尔值设置为 false,引用类型(类)设置为 null。
-
可以像任何其他方法一样定义具有参数的自定义构造函数,并用于在初始化时设置类变量值。
-
一个类可以有多个构造函数。
构造函数的编写方式与常规方法类似,但有一些区别;例如,它们需要是公开的,没有返回类型,并且方法名总是类名。作为一个例子,让我们向 Character 类添加一个不带参数的基本构造函数,并将名称字段设置为非空值。
将此新代码直接放在类变量下面,如下所示:
public string name;
public int exp = 0;
**public****Character****()**
**{**
**name =** **"Not assigned"****;**
**}**
在 Unity 中运行项目,你会看到使用这个新构造函数的 hero 实例。调试日志将显示英雄的名称为 未分配 而不是空值:

图 5.2:控制台打印的未分配自定义类变量的截图
这是个不错的进展,但我们还需要使类构造函数更加灵活。这意味着我们需要能够传入值,以便它们可以作为起始值使用,这将是你的下一个任务。
现在,Character 类开始更像一个真实对象的行为,但我们可以通过添加一个接受初始化时名称并设置到 name 字段的第二个构造函数来使其更好:
-
为
Character添加另一个接受string参数的构造函数,称为name。 -
使用
this关键字将参数赋值给类的name变量。这被称为 构造函数重载:public Character(string name) { this.name = name; }为了方便,构造函数通常会具有与类变量共享名称的参数。在这些情况下,使用
this关键字来指定哪个变量属于类。在这个例子中,this.name指的是类的name变量,而name是参数;如果没有this关键字,编译器将抛出警告,因为它无法区分它们。 -
在
LearningCurve中创建一个新的Character实例,称为heroine。使用自定义构造函数在初始化时传入一个名称,并在控制台打印出详细信息:Character heroine = new Character("Agatha"); Debug.LogFormat("Hero: {0} - {1} EXP", heroine.name, heroine.exp);当一个类有多个构造函数或一个方法有多个变体时,Visual Studio 将在自动完成弹出窗口中显示一组箭头,可以使用箭头键滚动浏览:
![img/B17573_05_03.png]()
图 5.3:Visual Studio 中多个方法构造函数的截图
-
现在我们可以选择在初始化新的
Character类时使用基本构造函数或自定义构造函数。Character类本身在配置不同情况下的不同实例方面现在更加灵活了!![img/B17573_05_04.png]()
图 5.4:控制台打印的多个自定义类实例的截图
现在真正的挑战开始了;我们的类需要方法来执行除了作为变量存储设施之外的有用操作。你的下一个任务是将其付诸实践。
声明类方法
向自定义类添加方法与向 LearningCurve 添加方法没有区别。然而,这是一个讨论良好编程的一个基本准则——不要重复自己(DRY)的好机会。DRY 是所有良好代码的标准。本质上,如果你发现自己一遍又一遍地写相同的行或几行,那么是时候重新思考和重新组织了。这通常以一个新的方法的形式出现,以保存重复的代码,使其更容易修改并在当前脚本或甚至在其他脚本中调用该功能。
在编程术语中,你会看到这被称为抽象一个方法或特性。
我们已经有了相当多的重复代码,所以让我们看看我们可以在哪里提高脚本的易读性和效率。
我们的重复调试日志是直接将一些代码抽象到 Character 类中的完美机会:
-
向
Character类添加一个新的public方法,具有void返回类型,名为PrintStatsInfo。 -
将
LearningCurve中的调试日志复制并粘贴到方法体中。 -
将变量改为
name和exp,因为它们现在可以直接从类中引用:public void PrintStatsInfo() { Debug.LogFormat("Hero: {0} - {1} EXP", name, exp); } -
用
PrintStatsInfo方法调用替换我们之前添加到LearningCurve中的角色调试日志,然后点击播放:Character hero = new Character(); **hero.PrintStatsInfo();** Character heroine = new Character("Agatha"); **heroine.PrintStatsInfo();** -
现在
Character类有了方法,任何实例都可以使用点符号自由访问它。由于hero和heroine都是单独的对象,PrintStatsInfo将它们各自的name和exp值调试到控制台。
这种行为比直接在 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
}
类似于类,任何变量和方法都属于结构体,并且通过其唯一名称访问。
然而,结构体有一些限制:
-
除非变量被标记为
static或const修饰符,否则不能在结构体声明中使用其内部的值来初始化变量——你可以在 第十章,重新审视类型、方法和类 中了解更多关于这个内容。 -
不带参数的构造函数是不允许的。
-
结构体自带一个默认构造函数,它会自动根据其类型将所有变量设置为默认值。
每个角色都需要一把好武器,而这些武器对于结构体对象来说比类更合适。我们将在本章的 理解引用和值类型 部分讨论为什么是这样。然而,首先,你需要创建一个来试验一下。
我们的角色将需要好的武器来完成他们的任务,这些任务非常适合简单的结构体:
-
右键点击
Scripts文件夹,选择 创建,然后选择 C# 脚本。 -
将其命名为
Weapon,在 Visual Studio 中打开它,并删除using UnityEngine之后的所有生成的代码。 -
声明一个名为
Weapon的公共结构体,然后是一组花括号,然后保存文件。 -
添加一个
name字段,类型为string,以及一个damage字段,类型为int:你可以将类和结构体嵌套在一起,但这通常是不受欢迎的,因为它会使代码变得混乱。
public struct Weapon { public string name; public int damage; } -
使用
name和damage参数声明一个构造函数,并使用this关键字设置结构体字段:public Weapon(string name, int damage) { this.name = name; this.damage = damage; } -
在构造函数下方添加一个调试方法来打印出武器信息:
public void PrintWeaponStats() { Debug.LogFormat("Weapon: {0} - {1} DMG", name, damage); } -
在
LearningCurve中,使用自定义构造函数和new关键字创建一个新的Weapon结构体:Weapon huntingBow = new Weapon("Hunting Bow", 105); -
我们新的
huntingBow对象使用自定义构造函数,并在初始化时为两个字段提供值。
限制脚本只在一个类中是很不错的想法,但通常可以看到只由一个类使用的结构体被包含在文件中。
现在我们已经有了引用(类)和值(结构体)对象的示例,是时候熟悉它们各自的细节了。更具体地说,你需要了解每个对象是如何在内存中传递和存储的。
理解引用和值类型
除了关键字和初始字段值之外,到目前为止,我们在类和结构体之间并没有看到太大的区别。类最适合于组合程序中会改变复杂动作和数据;结构体更适合于简单对象和大部分保持不变的数据。除了它们的用途之外,它们在关键领域上本质上是不同的——那就是,它们如何在变量之间传递或分配。类是 引用类型,意味着它们是通过引用传递的;结构体是 值类型,意味着它们是通过值传递的。
引用类型
当我们的Character类实例初始化时,hero和heroine变量不持有其类信息——相反,它们持有对象在程序内存中的位置引用。如果我们将hero或heroine赋值给同一类中的另一个变量,分配的是内存引用,而不是角色数据。这有几个含义,其中最重要的是,如果我们有多个变量存储相同的内存引用,对其中一个的更改会影响它们所有。
这样的主题最好是演示而不是解释;这取决于你,在下一个实际示例中尝试一下。
是时候测试Character类是否是一个引用类型了:
-
在
LearningCurve中声明一个新的Character变量,命名为hero2。将hero2赋值给hero变量,并使用PrintStatsInfo方法打印出两组信息。 -
点击播放并查看控制台出现的两个调试日志:
Character hero = new Character(); **Character hero2 = hero;** hero.PrintStatsInfo(); **hero2.PrintStatsInfo();** -
两个调试日志将完全相同,因为当创建
hero2时,它被赋值给了hero。在这个时候,hero2和hero都指向内存中hero的位置!![图片]()
图 5.5:控制台打印出的结构体统计截图
-
现在,将
hero2的名字改为一个有趣的名字,然后再次点击播放:Character hero2 = hero; **hero2.name =** **"Sir Krane the Brave"****;** -
你会看到
hero和hero2现在拥有相同的名字,尽管我们只更改了其中一个角色的数据!![图片]()
图 5.6:控制台打印出的类实例属性的截图
这里的教训是,引用类型需要小心处理,并且在赋值给新变量时不要复制。对任何一个引用的任何更改都会渗透到持有相同引用的所有其他变量中。
如果你试图复制一个类,要么创建一个新的、独立的实例,要么重新考虑结构体是否可能是你对象蓝图更好的选择。你将在下一节中更好地了解值类型。
值类型
当创建结构体对象时,所有数据都存储在其对应的变量中,没有引用或与其内存位置的连接。这使得结构体对于创建需要快速高效复制且保持各自独立身份的对象非常有用。在下面的练习中尝试使用我们的Weapon结构体。
让我们通过将huntingBow复制到一个新变量并更新其数据来创建一个新的武器对象,看看这些更改是否会影响两个结构体:
-
在
LearningCurve中声明一个新的Weapon结构体,并将其初始值设置为huntingBow:Weapon huntingBow = new Weapon("Hunting Bow", 105); **Weapon warBow = huntingBow;** -
使用调试方法打印出每件武器的数据:
**huntingBow.PrintWeaponStats();** **warBow.PrintWeaponStats();** -
现在它们的设置方式是,
huntingBow和warBow将具有相同的调试日志,就像我们在更改任何数据之前我们的两个角色一样!![图片]()
图 5.7:控制台打印出的结构体实例的截图
-
将
warBow.name和warBow.damage字段更改为你选择的值,然后再次点击播放:Weapon warBow = huntingBow; **warBow.name =** **"War Bow"****;** **warBow.damage =** **155****;** -
控制台将显示只有与
warBow相关的数据被更改,而huntingBow保留其原始数据。![图片]()
图 5.8:更新后的结构属性打印到控制台的截图
从这个例子中我们可以得出的结论是,结构体作为独立的对象很容易被复制和修改,而类则保留对原始对象的引用。现在我们更了解结构体和类在底层是如何工作的,并确认了引用和值类型在其自然环境中是如何表现的,我们就可以开始讨论一个最重要的编程主题,即面向对象编程(OOP),以及它如何融入编程领域。
集成面向对象思维
物质世界中的事物在某种程度上与面向对象(OOP)相似;当你想买软饮料时,你会拿一罐汽水,而不是液体本身。罐子是一个对象,将相关信息和操作组合在一个自包含的包中。然而,在处理对象时,无论是编程还是杂货店,都有规则——例如,谁可以访问它们。不同的变体和通用操作都影响着我们周围所有对象的本质。
在编程术语中,这些规则是面向对象编程(OOP)的主要原则:封装、继承和多态。
封装
面向对象编程(OOP)的最好之处在于它支持封装——定义对象的变量和方法对外部代码的访问性(这有时被称为调用代码)。以我们的汽水瓶为例——在自动售货机中,可能的交互是有限的。由于机器是锁着的,不是任何人都可以走过来拿一个;如果你恰好有正确的零钱,你将被允许临时访问它,但数量有限。如果机器本身被锁在一个房间里,只有有门钥匙的人甚至知道汽水瓶的存在。
你现在问自己的问题是,我们如何设置这些限制?简单的答案是,我们一直在通过指定对象变量和方法的作用域修饰符来使用封装。
如果你需要复习,请回到第三章的访问修饰符部分,深入变量、类型和方法。
让我们尝试一个简单的封装示例,以了解这在实践中是如何工作的。我们的Character类是公开的,它的字段和方法也是如此。然而,如果我们想要一个可以将角色的数据重置为其初始值的方法呢?这可能会很有用,但如果意外调用,可能会造成灾难,因此它是一个完美的私有对象成员候选:
-
在
Character类中创建一个名为Reset的private方法,该方法内部没有返回值。将name和exp变量分别设置为"Not assigned"和0:private void Reset() { this.name = "Not assigned"; this.exp = 0; } -
在打印出
hero2数据后尝试从LearningCurve调用Reset():![图片]()
图 5.9:Character 类中不可访问方法的屏幕截图
如果你怀疑 Visual Studio 是否出了问题,其实并没有。将方法或变量标记为私有会使它在这个类或结构体中使用点符号无法访问;如果你手动输入并悬停在Reset()上,你会看到一个关于方法受保护的错误信息。
要实际调用这个私有方法,我们可以在类构造函数中添加一个重置命令:
public Character()
{
Reset();
}
封装确实允许使用对象进行更复杂的可访问性设置;然而,现在,我们将坚持使用public和private成员。在我们下一章开始充实我们的游戏原型时,我们将根据需要添加不同的修饰符。
现在,让我们谈谈继承,这在你在未来游戏中创建类层次结构时将成为你的好朋友。
继承
一个 C#类可以按照另一个类的形象创建,共享其成员变量和方法,但能够定义其独特的数据。在面向对象编程中,我们称这为继承,这是一种无需重复代码就能创建相关类的强大方式。再次以汽水为例——市场上有一些通用的汽水,它们具有所有相同的基本属性,然后还有特殊的汽水。特殊的汽水具有相同的基本属性,但有不同的品牌或包装,使其与众不同。当你将它们并排放在一起看时,很明显它们都是汽水瓶——但它们显然也不相同。
原始类通常被称为基类或父类,而继承的类被称为派生类或子类。任何用public、protected或internal访问修饰符标记的基类成员都会自动成为派生类的一部分——除了构造函数。类构造函数始终属于其包含的类,但它们可以从派生类中使用,以将重复的代码量保持在最低。现在不必太担心不同的基类场景。相反,让我们尝试一个简单的游戏示例。
大多数游戏都有不止一种类型的角色,所以让我们创建一个新的类,称为Paladin,它继承自Character类。你可以将这个新类添加到Character脚本中或创建一个新的脚本。如果你要将新类添加到Character脚本中,确保它位于Character类的花括号之外:
public class Paladin: Character
{
}
就像LearningCurve继承自MonoBehavior一样,我们只需要添加一个冒号和我们要继承的基类,C#就会完成剩下的工作。现在,任何Paladin实例都将能够访问name属性和exp属性以及PrintStatsInfo方法。
通常认为,为不同的类创建新的脚本而不是将它们添加到现有脚本中是最佳实践。这可以分离你的脚本,并避免任何单个文件中有太多的代码行(称为膨胀文件)。
这很好,但是继承的类是如何处理它们的构造的呢?你可以在下面的部分中找到答案。
基础构造函数
当一个类从另一个类继承时,它们形成一种金字塔结构,成员变量从父类流向其任何派生子类。父类不知道任何子类,但所有子类都知道它们的父类。然而,父类构造函数可以直接从子类构造函数中通过简单的语法修改来调用:
public class ChildClass: ParentClass
{
public ChildClass(): **base****()**
{
}
}
base关键字代表父构造函数——在这种情况下,默认构造函数。然而,由于base代表一个构造函数,而构造函数是一个方法,子类可以将参数向上传递到金字塔中的父构造函数。
由于我们希望所有的Paladin对象都有一个名字,而Character已经有一个构造函数来处理这一点,我们可以在Paladin类中直接调用base构造函数,从而避免重写构造函数的麻烦:
-
向
Paladin类添加一个接受名为name的string参数的构造函数。使用冒号和base关键字调用父构造函数,传递name:public class Paladin: Character { **public****Paladin****(****string** **name****):** **base****(****name****)** **{** **}** } -
在
LearningCurve中创建一个新的名为knight的Paladin实例。使用基础构造函数分配值。从knight调用PrintStatsInfo并查看控制台:Paladin knight = new Paladin("Sir Arthur"); knight.PrintStatsInfo(); -
调试日志将与我们的其他
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;**
}
}
由于weapon是Paladin特有的,而不是Character,我们需要在构造函数中设置它的初始值。我们还需要更新knight实例以包含一个Weapon变量。所以,让我们使用huntingBow:
Paladin knight = new Paladin("Sir Arthur", **huntingBow**);
如果你现在运行游戏,你不会看到任何不同,因为我们正在使用来自Character类的PrintStatsInfo方法,它不知道Paladin类的weapon属性。为了解决这个问题,我们需要讨论多态。
多态
多态是希腊语中“多形态”的意思,并且以两种不同的方式应用于面向对象编程(OOP):
-
派生类对象被当作父类对象对待。例如,一个
Character对象的数组也可以存储Paladin对象,因为它们是从Character派生出来的。 -
父类可以标记方法为
virtual,这意味着它们的指令可以被派生类使用override关键字修改。在Character和Paladin的情况下,如果我们能够从每个PrintStatsInfo中调试不同的消息,那将是有用的。
多态允许派生类保持其父类的结构,同时也有自由来调整操作以适应其特定需求。你标记为virtual的任何方法都会给你对象多态的自由。让我们利用这个新知识并将它应用到我们的角色调试方法中。
让我们修改Character和Paladin,使用PrintStatsInfo打印出不同的调试日志:
-
通过在
public和void之间添加virtual关键字来更改Character类中的PrintStatsInfo:public **virtual** void PrintStatsInfo() { Debug.LogFormat("Hero: {0} - {1} EXP", name, exp); } -
使用
override关键字在Paladin类中声明PrintStatsInfo方法。添加一个调试日志,以你喜欢的任何方式打印出Paladin属性:public override void PrintStatsInfo() { Debug.LogFormat("Hail {0} - take up your {1}!", name, weapon.name); }这可能看起来像是重复的代码,我们之前已经说过这是不好的形式,但这是一个特殊情况。我们在
Character类中将PrintStatsInfo标记为virtual所做的是告诉编译器,这个方法可以根据调用类有多种形态。 -
当我们在
Paladin中声明覆盖版本的PrintStatsInfo时,我们添加了仅适用于该类的自定义行为。多态的功劳,我们不需要从Character或Paladin对象中选择要调用的PrintStatsInfo版本——编译器已经知道了!:![img/B17573_05_11.png]()
图 5.11:多态角色属性的截图
我知道这有很多内容需要消化。因此,在我们接近终点时,让我们回顾一下面向对象编程(OOP)的一些主要点:
-
面向对象编程(OOP)的全部内容都是将相关的数据和操作组合成对象——这些对象可以相互通信并独立行动。
-
可以使用访问修饰符设置对类成员的访问,就像变量一样。
-
类可以继承自其他类,创建父/子关系的级联层次结构。
-
类可以有其他类或结构体类型的成员。
-
类可以覆盖标记为
virtual的任何父方法,允许它们执行自定义操作,同时保留相同的蓝图。
面向对象编程(OOP)不是唯一可以与 C#一起使用的编程范式——你可以在以下链接中找到其他主要方法的实用解释:cs.lmu.edu/~ray/notes/paradigms。
本章中你学到的所有面向对象编程知识都直接适用于 C#世界。然而,我们仍然需要将 Unity 与之结合起来,这是本章剩余部分将重点关注的。
在 Unity 中应用面向对象编程
如果你足够了解面向对象的语言,你最终会听到开发者之间像秘密祈祷一样低声说出“万物皆对象”的短语。遵循面向对象的原则,程序中的所有内容都应该是一个对象,但 Unity 中的 GameObject 可以代表你的类和结构体。然而,这并不意味着 Unity 中的所有对象都必须在物理场景中,因此我们仍然可以在幕后使用我们新发现的程序化类。
对象是一流的
在第二章,编程的基本要素中,我们讨论了当脚本被添加到 Unity 中的 GameObject 时,脚本是如何转换为组件的。从面向对象原则的组合角度来考虑这个问题——GameObject 是父容器,它们可以由多个组件组成。这听起来可能有些矛盾,但实际上,这与其说是一个实际要求,不如说是一个更好的可读性的指南。类可以嵌套在彼此内部——但这很快就会变得混乱。然而,将多个脚本组件附加到单个 GameObject 上可能非常有用,尤其是在处理管理类或行为时。
总是尝试将对象简化为其最基本元素,然后使用组合来构建由这些较小的类组成的大而复杂的对象。由小型可互换组件组成的 GameObject 比一个大而笨拙的 GameObject 更容易修改。
让我们看看主摄像机来了解这一动作:

图 5.12:主摄像机对象在检查器中的截图
前一截图中的每个组件(Transform、Camera、Audio Listener和Learning Curve脚本)最初都是 Unity 中的一个类。就像Character或Weapon的实例一样,当我们点击播放时,这些组件成为计算机内存中的对象,包括它们的成员变量和方法。
如果我们将LearningCurve(或任何脚本或组件)附加到 1,000 个 GameObject 上并点击播放,就会在内存中创建并存储 1,000 个单独的LearningCurve实例。
我们甚至可以使用它们的组件名称作为数据类型来创建这些组件的实例。就像类一样,Unity 组件类是引用类型,可以像任何其他变量一样创建。然而,找到和分配这些 Unity 组件的方式与之前看到的不同。为此,你需要了解以下部分中关于 GameObject 工作方式的一些更多内容。
访问组件
既然我们已经知道了组件在 GameObject 上的行为,我们该如何访问它们的特定实例呢?幸运的是,Unity 中的所有 GameObject 都继承自GameObject类,这意味着我们可以使用它们的成员方法在场景中找到我们需要的任何东西。有几种方法可以分配或检索当前场景中活动的 GameObject:
-
通过
GameObject类中的GetComponent()或Find()方法,它们与公共和私有变量一起工作。 -
通过将
Project面板中的 GameObject 本身拖放到检查器选项卡中的变量槽中。此选项仅适用于 C#中的公共变量,因为它们是唯一会出现在检查器中的变量。如果你决定需要在检查器中显示私有变量,你可以使用SerializeField属性对其进行标记。
你可以在 Unity 文档中了解更多关于属性和SerializeField的信息:docs.unity3d.com/ScriptReference/SerializeField.html。
让我们看看第一个选项的语法。
在代码中访问组件
使用GetComponent相当简单,但它的方法签名与我们迄今为止看到的其他方法略有不同:
GameObject.GetComponent<ComponentType>();
我们需要的只是我们正在寻找的组件类型,如果存在,GameObject类将返回该组件,如果不存在,则返回 null。GetComponent方法有其他变体,但这个是最简单的,因为我们不需要知道我们正在寻找的GameObject类的具体信息。这被称为泛型方法,我们将在第十三章探索泛型、委托以及其他内容中进一步讨论。然而,现在,让我们只处理相机的变换。
由于LearningCurve已经附加到主相机对象,让我们获取相机的Transform组件并将其存储在一个公共变量中。Transform组件控制 Unity 中对象的位置、旋转和缩放,因此它是一个方便的例子:
-
将一个新的公共
Transform类型变量CamTransform添加到LearningCurve中:public Transform CamTransform; -
在
Start中使用GameObject类的GetComponent方法初始化CamTransform。使用this关键字,因为LearningCurve附加到与Transform组件相同的GameObject组件。 -
使用点符号访问和调试
CamTransform的localPosition属性:void Start() { CamTransform = this.GetComponent<Transform>(); Debug.Log(CamTransform.localPosition); } -
我们在
LearningCurve的顶部添加了一个未初始化的public Transform变量,并在Start方法中使用GetComponent方法对其进行初始化。GetComponent找到附加到该GameObject组件的Transform组件,并将其返回给CamTransform。现在CamTransform存储了一个Transform对象,我们可以访问其所有类属性和方法——包括以下截图中的localPosition:![]()
图 5.13:Transform 位置打印到控制台的截图
GetComponent方法对于快速检索组件非常出色,但它只能访问调用脚本附加到的 GameObject 上的组件。例如,如果我们从附加到主相机的LearningCurve脚本中使用GetComponent,我们只能访问Transform、Camera和音频监听器组件。
如果我们想要引用一个位于独立 GameObject 上的组件,例如方向光,我们首先需要使用Find方法获取该对象的引用。只需提供 GameObject 的名称,Unity 就会返回相应的 GameObject 供我们存储或操作。
为了参考,当对象被选中时,每个 GameObject 的名称都可以在检查器标签的顶部找到:

图 5.14:检查器中方向光对象的截图
在 Unity 中,查找游戏场景中的对象至关重要,因此你需要练习。让我们拿我们正在处理的对象来练习查找和分配它们的组件。
让我们尝试一下Find方法,并从LearningCurve中检索方向光对象:
-
在
CamTransform下添加两个变量到LearningCurve——一个是GameObject类型,另一个是Transform类型:public GameObject DirectionLight; public Transform LightTransform; -
通过名称查找
DirectionLight组件,并在Start()方法中使用它来初始化DirectionLight:void Start() { DirectionLight = GameObject.Find("Directional Light"); } -
将
LightTransform的值设置为DirectionLight附加的Transform组件,并调试其localPosition。由于DirectionLight现在是它的GameObject,GetComponent工作得非常完美:LightTransform = DirectionLight.GetComponent<Transform>(); Debug.Log(LightTransform.localPosition); -
在运行游戏之前,重要的是要理解方法调用可以串联起来以减少代码步骤的数量。例如,我们可以通过组合
Find和GetComponent,而不必通过DirectionLight来初始化LightTransform,从而在单行中完成:GameObject.Find("Directional Light").GetComponent<Transform>();
提醒一句——长串的代码链在处理复杂应用程序时可能会导致可读性差和混淆。避免超过这个示例的长行是一个好的经验法则。
虽然在代码中查找对象总是可行的,但你也可以直接将对象拖放到检查器标签中。让我们在下一节中演示如何做到这一点。
拖放
现在我们已经介绍了代码密集型的方法,让我们快速看一下 Unity 的拖放功能。尽管拖放比使用代码中的GameObject类要快得多,但 Unity 在保存或导出项目,或者当 Unity 更新时,有时会丢失通过这种方式建立的对象和变量之间的连接。
当你需要快速分配几个变量时,不妨利用这个特性。在大多数情况下,我建议坚持使用代码。
让我们改变LearningCurve来展示如何使用拖放分配GameObject组件:
-
注释掉以下代码行,其中我们使用了
GameObject.Find()来检索并分配Directional Light对象到DirectionLight变量://DirectionLight = GameObject.Find("Directional Light"); -
选择主摄像机GameObject,将方向光拖动到学习曲线组件中的
方向光字段,然后点击播放!![img/B17573_05_15.png]()
图 5.15:将方向光拖动到脚本属性中的截图
-
方向光GameObject 现在被分配给
DirectionLight变量。没有涉及代码,因为 Unity 内部分配了变量,没有改变LearningCurve类。
在决定是否使用拖放或GameObject.Find()来分配变量时,理解以下几点很重要。首先,Find()方法稍微慢一些,如果你在多个脚本中多次调用该方法,可能会使你的游戏面临性能问题。其次,你需要确保场景层次结构中的 GameObject 都具有唯一的名称;如果没有,当有多个同名对象或更改对象名称时,可能会导致一些棘手的错误。
摘要
我们对类、结构和面向对象编程的探索标志着 C#基础知识第一部分的结束。你已经学会了如何声明你的类和结构体,这是你将制作的每个应用程序或游戏的基础。你还确定了这两个对象在传递和访问方面的差异以及它们与面向对象编程的关系。最后,你亲身体验了面向对象编程的原则——使用继承、组合和多态创建类。
识别相关数据和操作,创建蓝图以赋予它们形状,并使用实例来构建交互是处理任何程序或游戏的基础。将访问组件的能力加入其中,你就有了 Unity 开发者的雏形。
下一章将过渡到游戏开发的基础和直接在 Unity 中脚本化对象行为。我们将从细化一个简单开放世界冒险游戏的需求开始,在场景中与 GameObject 一起工作,并以一个为我们的角色准备好的白盒环境结束。
快速问答——所有关于面向对象编程的内容
-
哪个方法处理类内部的初始化逻辑?
-
作为值类型,结构体是如何传递的?
-
面向对象编程的主要原则是什么?
-
你会使用哪个
GameObject类方法来在调用类相同的对象上找到组件?
加入我们的 Discord!
与其他用户、Unity/C#专家和哈里森·费罗尼一起阅读这本书。提问,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。
现在加入我们!

第六章:在 Unity 中亲自动手
创建一个游戏不仅仅是代码中模拟动作。设计、故事、环境、灯光和动画都在为玩家的舞台设置中扮演着重要角色。游戏首先是一种体验,仅靠代码是无法实现的。
Unity 在过去十年中将自己置于游戏开发的前沿,为程序员和非程序员 alike 提供了先进工具。动画和效果、音频、环境设计等等,都可以直接从 Unity 编辑器中获取,无需一行代码。我们将随着定义游戏的要求、环境和游戏机制来讨论这些主题。然而,首先,我们需要一个关于游戏设计的主题介绍。
游戏设计理论是一个庞大的研究领域,学习其所有秘密可能需要消耗整个职业生涯。然而,我们只会接触到基础部分;其余的一切都由你自己去探索!本章将为我们整本书的其余部分打下基础,并涵盖以下主题:
-
游戏设计入门
-
构建关卡
-
灯光基础
-
在 Unity 中动画制作
游戏设计入门
在开始任何游戏项目之前,有一个你想要构建的蓝图非常重要。有时,想法在你脑海中会非常清晰,但当你开始创建角色类别或环境时,事情似乎会偏离你的初衷。这就是游戏设计允许你规划以下触点的地方:
-
概念:游戏的大纲想法和设计,包括其类型和玩法风格。
-
核心机制:角色在游戏中可以采取的可玩功能或交互。常见的游戏机制包括跳跃、射击、解谜或驾驶。
-
控制方案:一张按钮和/或键的图,它赋予玩家控制其角色、环境交互和其他可执行动作的能力。
-
故事:推动游戏的潜在叙事,在玩家和他们在其中玩耍的游戏世界中建立同理心和联系。
-
艺术风格:游戏的整体外观和感觉,从角色和菜单艺术到关卡和环境都保持一致。
-
胜负条件:规定游戏如何获胜或失败的规定,通常包括具有潜在失败重量的目标或目标。
这些主题绝不是设计游戏所需内容的详尽列表。然而,它们是开始充实一个称为游戏设计文档的好地方,这是你的下一个任务!
游戏设计文档
在 Google 上搜索游戏设计文档会导致大量模板、格式规则和内容指南的涌现,这可能会让一个新程序员准备好放弃一切。事实是,设计文档是根据创建它们的团队或公司定制的,这使得它们比互联网上所认为的要容易起草得多。
通常,设计文档有三种类型,如下所示:
-
游戏设计文档(GDD):GDD 包含了从游戏玩法到氛围、故事以及它试图创造的经验的所有内容。根据游戏的不同,这份文档可能只有几页,也可能有几百页。
-
技术设计文档(TDD):这份文档侧重于游戏的所有技术方面,从它将运行的硬件到类和程序架构的构建方式。就像游戏设计文档(GDD)一样,其长度将根据项目而变化。
-
单页文档:通常用于市场营销或推广场合,单页文档本质上是你游戏的快照。正如其名所示,它应该只占用一页纸。
格式化 GDD 没有正确或错误的方式,所以这是一个让你发挥创造力的好地方。加入一些激发你灵感的参考资料图片;在布局上发挥创意——这是你定义愿景的地方。
我们将在本书的剩余部分工作的游戏相对简单,不需要像 GDD 或 TDD 那样详细。相反,我们将创建一个单页文档来跟踪我们的项目目标和一些背景信息。
《英雄降生》的单页文档
为了确保我们继续沿着正确的轨道前进,我已经准备了一份简单的文档,概述了游戏原型的基本内容。在继续阅读之前,请仔细阅读,并尝试想象一下我们迄今为止所学的一些编程概念是如何被应用到实践中的:

图 6.1:《英雄降生》单页文档
现在你已经对我们的游戏框架有了高层次的认识,你就可以开始构建一个原型关卡来容纳游戏体验了。
构建关卡
在构建你的游戏关卡时,尝试从玩家的角度看待事物总是一个好主意。你希望他们如何看待环境,如何与之互动,以及在其中行走时的感受?你实际上正在构建游戏存在的世界,所以要保持一致性。
使用 Unity,你可以选择使用地形工具创建户外环境,用基本的形状和几何形状勾勒出室内场景,或者两者的混合。你甚至可以从 Blender 等其他程序导入 3D 模型,用作场景中的对象。
Unity 在 docs.unity3d.com/Manual/script-Terrain.html 提供了关于地形工具的优秀介绍。如果你选择这条路线,Unity 资产商店中还有一个名为 Terrain Toolkit 2017 的免费资源,可在 assetstore.unity.com/packages/tools/terrain/terrain-toolkit-2017-83490 找到。你还可以使用 Blender 等工具创建游戏资源,这些资源可以在 www.blender.org/features/modeling/ 找到。
对于英雄降生,我们将坚持使用简单的室内竞技场式设置,易于四处走动,但有几个角落可以藏身。你将使用原语——Unity 中提供的基对象形状——将这些拼凑在一起,因为它们在场景中创建、缩放和定位非常容易。
创建原语
看看你可能经常玩的游戏,你可能会想知道你将如何创建看起来如此逼真的模型和对象,以至于你似乎可以穿过屏幕抓住它们。幸运的是,Unity 提供了一套你可以从中选择的原始 GameObject 来快速原型设计。这些可能不会非常复杂或高分辨率,但当你学习如何操作或你的开发团队没有 3D 艺术家时,它们是救命稻草。
如果你打开 Unity,你可以进入层次面板,点击+ | 3D 对象,你会看到所有可用的选项,但其中只有大约一半是原语或常见形状,如下面的截图所示,用红色突出显示:

图 6.2:Unity 层次窗口,已选择创建选项
其他 3D 对象选项,如地形、风区和树,对于我们需要的来说有点过于高级,但如果你感兴趣,可以随意尝试。
你可以在docs.unity3d.com/Manual/CreatingEnvironments.html了解更多关于构建 Unity 环境的信息。
在我们跳得太远之前,当你脚下有地板时,通常更容易四处走动,所以让我们首先使用以下步骤为我们的竞技场创建一个地面平面:
-
在层次面板中,点击+ | 3D 对象 | 平面
-
确保在层次选项卡中选中新对象,在检查器选项卡中将 GameObject 重命名为
Ground -
在变换下拉菜单中,将缩放在X、Y和Z轴上的值更改为
3:![]()
图 6.3:Unity 编辑器,带有地面平面
-
如果你的场景中的光照看起来比前面的截图暗或不同,请在层次面板中选择方向光,并将方向光组件的强度值设置为 1:
![]()
图 6.4:在检查器面板中选中的方向光对象
我们创建了一个平面 GameObject,并将其大小增加以为未来的角色提供更多行走空间。这个平面将像受现实生活物理约束的 3D 对象一样起作用,这意味着其他对象不能随意穿过。我们将在第七章、移动、相机控制和碰撞中更多地讨论 Unity 物理系统及其工作原理。现在,我们需要开始考虑 3D。
3D 思考
现在我们已经在场景中有了第一个对象,我们可以谈论三维空间了——具体来说,是对象在三维空间中的位置、旋转和缩放行为。如果你回想起高中几何学,一个带有 x 和 y 坐标系的图表应该是熟悉的。要在图表上放置一个点,你需要一个 x 值和一个 y 值。
Unity 支持二维和三维游戏开发,如果我们正在制作二维游戏,我们可以在那里结束解释。然而,当在 Unity 编辑器中处理三维空间时,我们有一个额外的轴,称为 z 轴。z 轴映射深度或透视,为我们的空间以及其中的对象赋予三维质感。
这可能一开始会让人感到困惑,但 Unity 有一些不错的视觉辅助工具来帮助你理清思路。在 Scene 面板的右上角,你会看到一个几何形状的图标,上面用红色、绿色和蓝色分别标记了 x、y 和 z 轴。当在 Hierarchy 窗口中选中场景中的所有 GameObject 时,它们都会显示其轴箭头:

图 6.5:突出显示方向辅助工具的场景视图
这将始终显示场景的当前方向以及放置其中的对象。点击这些彩色轴中的任意一个,将切换场景方向到所选轴。自己试一试,以熟悉切换视角。
如果你查看 Inspector 窗格中 Ground 对象的 Transform 组件,你会看到位置、旋转和缩放都是由这三个轴决定的。
位置确定对象在场景中的位置,旋转控制其角度,缩放负责其大小。这些值可以在 Inspector 窗格或 C# 脚本中随时更改:

图 6.6:在 Hierarchy 中选中的地面对象
目前,地面看起来有点无聊。让我们用材质来改变一下。
材质
我们的地平面现在并不很有趣,但我们可以使用 材质 为这个关卡注入一点生命力。材质控制 GameObject 在场景中的渲染方式,这由材质的 Shader 决定。将 Shader 想象为负责将光照和纹理数据组合成材质外观的表示。
每个 GameObject 都有一个默认的 Material 和 Shader(如图所示来自 Inspector 窗格),将其颜色设置为标准白色:

图 6.7:对象上的默认材质
要改变一个物体的颜色,我们需要创建一个材质并将其拖动到我们想要修改的物体上。记住,在 Unity 中,一切都是对象——材质也不例外。材质可以在所需的任何 GameObject 上重复使用,但任何对材质的更改也会影响到任何附着该材质的物体。如果我们场景中有几个带有将它们全部设置为红色的材质的敌人对象,并且我们将该基本材质颜色更改为蓝色,那么所有敌人都会变成蓝色。
蓝色引人注目;让我们将地面平面的颜色改为与之匹配,并创建一个新的材质,将地面平面从单调的白色变为深邃且鲜艳的蓝色:
-
在项目面板中创建一个新的文件夹,并将其命名为
Materials。 -
在材质文件夹中,右键单击+ | 材质,并将其命名为
Ground_Mat。 -
点击Albedo属性旁边的颜色框,从弹出的颜色选择器窗口中选择你的颜色,然后关闭它。
-
从项目面板中拖动
Ground_Mat对象,并将其拖放到层次面板中的GroundGameObject 上:![img/B17573_06_08.png]
图 6.8:材质颜色选择器
你创建的新材质现在是一个项目资产。将Ground_Mat拖放到GroundGameObject 中会改变平面的颜色,这意味着对Ground_Mat的任何更改都会反映在Ground上:
![img/B17573_06_09.png]
图 6.9:更新了颜色材质的地面平面
地面是我们的画布;然而,在 3D 空间中,它可以在其表面上支持其他 3D 对象。将取决于你,用有趣和有趣的障碍物填充它,为你的未来玩家。
白盒设计
白盒设计是使用占位符布局想法的设计术语,通常目的是在稍后用成品资产替换它们。在关卡设计中,白盒设计的实践是用原始 GameObject 填充环境,以获得你想要的外观的感觉。这是一个很好的开始方式,尤其是在你游戏的原型设计阶段。
在深入 Unity 之前,我想先画一个简单的草图,展示我关卡的基本布局和位置。这给我们提供了一些方向,并有助于更快地布置我们的环境。
在下面的图中,你将能够看到我心中的竞技场,中间有一个可以通过斜坡进入的平台,每个角落都配有小型炮塔:
![img/B17573_06_10.png]
图 6.10:英雄诞生关卡竞技场草图
不要担心你不是艺术家——我也不是。重要的是将你的想法落实到纸上,以巩固你的想法,并在 Unity 忙碌之前解决任何问题。
在你全力以赴将这个草图投入生产之前,你需要熟悉一些 Unity 编辑器快捷键,以使白盒设计更容易。
编辑器工具
当我们在 第一章,了解您的环境 中讨论 Unity 界面时,我们简要地介绍了工具栏的一些功能,我们需要重新审视这些功能,以便我们知道如何高效地操作 GameObject。您可以在 Unity 编辑器的左上角找到这些功能:

图 6.11:Unity 编辑器工具栏
让我们分析一下前一个截图中的工具栏中可用的不同工具:
-
手:这允许您通过点击和拖动鼠标在场景中平移并改变您的位置。
-
移动:这允许您通过拖动相应的箭头将对象沿 x、y 和 z 轴移动。
-
旋转:这允许您通过旋转或拖动相应的标记来调整对象的旋转。
-
缩放:这允许您通过将其拖动到特定轴来修改对象的缩放。
-
矩形变换:这把移动、旋转和缩放工具功能结合到一个包中。
-
变换:这使您能够一次性访问对象的位置、旋转和缩放。
-
自定义编辑器工具:这允许您访问为编辑器构建的任何自定义工具。不用担心这一点,因为它超出了我们的范围。如果您想了解更多信息,请参阅
docs.unity3d.com/2020.1/Documentation/ScriptReference/EditorTools.EditorTool.html中的文档。
您可以在docs.unity3d.com/Manual/PositioningGameObjects.html中找到有关在 场景 面板中导航和定位 GameObject 的更多信息。还值得注意的是,您可以使用我们之前在章节中讨论的 Transform 组件来移动、定位和缩放对象。
平移和导航场景可以使用类似工具完成,尽管不是直接从 Unity 编辑器本身进行:
-
要环顾四周,请按住鼠标右键并拖动以在相机周围平移。
-
在使用相机移动时,请继续按住鼠标右键,并使用 W、A、S 和 D 键分别向前、向后、向左和向右移动。
-
按下 F 键可以放大并聚焦于在 层次 面板中选定的 GameObject。
这种场景导航通常被称为飞行模式,所以当我要您聚焦或导航到特定的对象或视点时,请使用这些功能的组合。
在某些时候,在场景视图中移动本身可能是一项任务,但所有这些都归结为反复练习。有关场景导航功能的更详细列表,请访问docs.unity3d.com/Manual/SceneViewNavigation.html。
尽管地面平面不会让我们的角色穿过它,但我们现在可以走下边缘。您的任务是围住竞技场,以便玩家有一个受限的移动区域。
英雄的考验——安装干墙
使用原始立方体和工具栏,使用移动、旋转和缩放工具在水平面周围放置四面墙,以划分主要竞技场:
-
在层次结构面板中,选择+ | 3D 对象 | 立方体来创建第一个墙并命名为
Wall_01。 -
将其缩放值设置为 30(x 轴),1.5(y 轴)和 0.2(z 轴)。
注意,平面在比对象大 10 倍的比例下操作——因此,长度为 3 的平面与长度为 30 的对象长度相同。
-
在层次结构面板中选择
Wall_01对象,切换到左上角的位置工具,并使用红色、绿色和蓝色箭头将墙放置在地平面边缘。 -
重复步骤 1-3,直到你周围有四面墙:
![图片]()
图 6.12:带有四面墙和地面平面的水平面竞技场
从本章开始,我将给出一些关于墙位置、旋转和缩放的基本值,但请随意发挥创意。我希望你通过实验 Unity 编辑器工具来更快地熟悉它们。
这只是一点建设性的工作,但竞技场开始成形了!在我们继续添加障碍物和平台之前,你想要养成清理对象层次结构的习惯。我们将在下一节中讨论这是如何工作的。
保持层次结构整洁
通常,我会在章节末尾添加这样的建议,但确保你的项目层次结构尽可能有序非常重要,因此需要单独的小节。理想情况下,你希望所有相关的 GameObject 都位于一个父对象下。目前,这并不构成风险,因为我们场景中只有几个对象;然而,当在一个大型项目中达到数百个对象时,你将会感到困难。
保持层次结构整洁的最简单方法是存储相关对象在父对象中,就像你在桌面上的文件夹中存储文件一样。我们的水平面有几个对象需要组织,Unity 通过允许我们创建空 GameObject 来简化这一点。空对象是一个完美的容器(或文件夹),用于存放相关的对象组,因为它没有附带任何组件——它只是一个外壳。
让我们把地面平面和四面墙组合成一个共同的空 GameObject:
-
在层次结构面板中选择+ | 创建空对象并命名新对象为
Environment -
将地面平面和四面墙拖放到环境中,使它们成为子对象
-
选择环境空对象,并确保其X、Y和Z位置都设置为 0!
![图片]()
图 6.13:显示空 GameObject 父对象的层次结构面板
环境作为父对象存在于层次结构标签中,竞技场对象作为其子对象。现在我们可以通过箭头图标展开或关闭环境对象下拉列表,使层次结构面板不那么杂乱。
将环境对象的X、Y和Z位置设置为 0 很重要,因为子对象的位置现在是相对于父位置。这引出了一个有趣的问题:我们设置的这些位置、旋转和缩放的原点是什么?答案是,它们取决于我们使用的相对空间,在 Unity 中,这要么是世界空间,要么是本地空间:
-
世界空间使用场景中的一个固定原点作为所有 GameObject 的常量参考。在 Unity 中,这个原点是(0, 0, 0),或者x、y和z轴上的 0。
-
本地空间使用对象的父
Transform组件作为其原点,本质上改变了场景的视角。Unity 也将这个本地原点设置为(0, 0, 0)。将父变换视为宇宙的中心,其他所有东西都相对于它旋转。
这两种方向在不同的场景中都很有用,但现在是时候在这里重置它们,让每个人都站在同一起跑线上。
与预制体一起工作
预制体是你在 Unity 中遇到的最强大的组件之一。它们不仅在关卡构建中很有用,在脚本编写中也很实用。将预制体想象成可以保存并重复使用,且每个子对象、组件、C#脚本和属性设置都完整的 GameObject。一旦创建,预制体就像是一个类蓝图;场景中使用的每个副本都是该预制体的一个单独实例。因此,对基础预制体的任何更改也将更改场景中所有活动的实例。
这个竞技场看起来有点太简单,完全开放,因此是测试创建和编辑预制体的完美场所。由于我们想在竞技场的每个角落放置四个相同的炮塔,它们是预制体的完美案例,我们可以通过以下步骤创建:
再次强调,我没有包括任何精确的障碍物位置、旋转或缩放值,因为我想让你亲自熟悉 Unity 编辑器的工具。
从现在开始,当你面前有一个不包含具体位置、旋转或缩放值的任务时,我期望你通过实践来学习。
-
在环境父对象内部创建一个空白的父对象,通过选择+ | 创建空对象并命名为
Barrier_01。 -
通过选择+ | 3D 对象 | 立方体创建两个立方体,并将它们定位和缩放成一个 V 形底座。
-
创建两个额外的立方体原形,并将它们放置在炮塔底座的末端!
![图片]()
图 6.14:由立方体组成的炮塔截图
-
在资产下的项目面板中创建一个新的文件夹,并将其命名为
预制件。然后,将层次面板中的Barrier_01GameObject 拖动到项目视图中的预制件文件夹中:![图片]()
图 6.15:预制件文件夹中的障碍物预制件
Barrier_01及其所有子对象现在都是预制件,这意味着我们可以通过从预制件文件夹拖动副本或复制场景中的副本来重用它。Barrier_01在层次选项卡中变为蓝色,以表示其状态变化,并在其名称下方检查器选项卡中添加了一行预制件功能按钮:

图 6.16:在检查器面板中突出显示的Barrier_01预制件
对原始预制件对象Barrier_01的任何编辑现在都将影响场景中的任何副本。由于我们需要第五个立方体来完成障碍物,让我们更新并保存预制件以查看此操作。
现在我们的炮塔中间有一个巨大的缺口,这不利于覆盖我们的角色,所以让我们通过添加另一个立方体并应用更改来更新Barrier_01预制件:
-
创建一个立方体原体并将其放置在炮塔底部的交点处。
-
新的立方体原体会以灰色显示,并在其名称旁边有一个小的+图标,在层次选项卡中。这意味着它还不是预制件的一部分!
![图片]()
图 6.17:在层次窗口中标记的新预制件更新
-
在层次面板中的新立方体原体上右键单击,并选择添加 GameObject | 应用到预制件'Barrier_01':
![图片]()
图 6.18:将预制件更改应用到基础预制件的选择
Barrier_01预制件现在已更新以包含新的立方体,整个预制件层次结构应再次变为蓝色。您现在有一个看起来像前面的截图或如果您愿意,更有创意的炮塔预制件。然而,我们希望它们位于竞技场的每个角落。这将由您来添加它们!
现在我们已经得到了一个可重用的障碍物预制件,让我们构建剩余的水平,以匹配本节开头我们拥有的粗略草图:
-
将Barrier_01预制件复制三次,并将每个副本放置在竞技场的不同角落。您可以通过从预制件文件夹拖动多个Barrier_01对象到场景中,或在层次中的Barrier_01上右键单击并选择复制来完成此操作。
-
在环境父对象内部创建一个新的空 GameObject,并将其命名为
Raised_Platform。 -
创建一个立方体并将其缩放成如图 6.19 所示的平台。
-
创建一个平面并将其缩放成斜坡:
-
提示:围绕x或y轴旋转平面以创建斜面。
-
然后,将其定位以便连接平台和地面
-
-
在 Mac 上使用
Cmd+D或在 Windows 上使用Ctrl+D复制斜面对象。然后,重复旋转和定位步骤。 -
重复之前的步骤两次,直到您有四个总共通向平台的斜坡:
![图片]()
图 6.19:提升平台父 GameObject
您现在已经成功地白盒化(即完全了解)了您的第一个游戏关卡!不过,现在不要过于沉迷其中,我们只是刚刚开始。所有好的游戏都有玩家可以拾取或与之交互的物品。在接下来的挑战中,您的任务是创建一个健康物品并将其制作成 Prefab。
英雄的考验——创建健康拾取
将本章到目前为止所学的所有内容整合起来可能需要您几分钟的时间,但这绝对是值得的。按照以下步骤创建拾取物品:
-
通过选择 + | 3D Object | Capsule 创建一个 Capsule GameObject,并将其命名为
Health_Pickup。 -
将比例设置为 0.3,对于x、y和z轴,然后切换到 Move 工具并将其放置在您的障碍物附近。
-
为 Health_Pickup 对象创建并附加一个新的黄色 Material。
-
将 Health_Pickup 对象从 Hierarchy 面板拖动到 Prefab 文件夹中。
参考以下截图,了解最终产品应该看起来像什么:

图 6.20:场景中的拾取物品和障碍物 Prefab
到此为止,我们关于关卡设计和布局的工作就告一段落了。接下来,您将接受 Unity 中光照的快速课程,我们将在本章的后面学习如何对物品进行动画处理。
照明基础
Unity 中的照明是一个广泛的话题,但可以归结为两类:实时和预计算。这两种类型的灯光都会考虑诸如光线的颜色和强度以及它在场景中的方向等属性,所有这些都可以在 Inspector 面板中进行配置。区别在于 Unity 引擎如何计算灯光的行为。
实时光照 每帧都会进行计算,这意味着任何在其路径上的对象都会投射出逼真的阴影,并且通常表现得像现实世界中的光源。然而,这可能会显著减慢您的游戏速度,并消耗指数级数量的计算能力,具体取决于场景中灯光的数量。另一方面,预计算光照 将场景的光照存储在一个称为 lightmap 的纹理中,然后将其应用到场景中,或者烘焙到场景中。虽然这可以节省计算能力,但烘焙光照是静态的。这意味着它不会在场景中的物体移动时做出真实的反应或改变。
还有一种混合类型的照明称为预计算实时全局照明,它弥合了实时和预计算过程之间的差距。这是一个 Unity 特定的高级主题,所以我们不会在本书中介绍它,但您可以自由地查看docs.unity3d.com/Manual/GIIntro.html上的文档。
现在我们来看看如何在 Unity 场景本身中创建灯光对象。
创建灯光
默认情况下,每个场景都附带一个方向光组件,作为主要照明源,但灯光可以在层次结构中创建,就像任何其他 GameObject 一样。尽管控制光源的想法可能对你来说很新,但它们是 Unity 中的对象,这意味着它们可以被定位、缩放和旋转以适应你的需求:

图 6.21:照明创建菜单选项
让我们看看一些实时灯光对象及其性能的示例:
-
方向光非常适合模拟自然光,如阳光。它们在场景中没有实际的位置,但光线击中一切,就像它们始终指向同一方向一样。
-
点光源基本上是漂浮的球体,从中心点向所有方向发射光线。这些在场景中具有定义的位置和强度。
-
聚光灯向特定方向发射光线,但它们被角度锁定,并聚焦于场景的特定区域。将这些想象成现实世界中的聚光灯或泛光灯。
-
面光源的形状像矩形,从矩形的单侧表面发出光线。
反射探针和光照探针组对于《英雄降世》来说超出了我们的需求;然而,如果你感兴趣,可以在
docs.unity3d.com/Manual/ReflectionProbes.html和docs.unity3d.com/Manual/LightProbes.html了解更多信息。
与 Unity 中的所有 GameObject 一样,灯光具有可调整的属性,可以用来为场景提供特定的氛围或主题。
灯光组件属性
以下截图显示了场景中方向光上的 灯光 组件。所有这些属性都可以配置以创建沉浸式环境,但我们需要了解的基本属性是 颜色、模式和强度。这些属性决定了光的色调、实时或计算效果以及一般强度:

图 6.22:检查器窗口中的光线组件
与其他 Unity 组件一样,这些属性可以通过脚本和 Light 类来访问,该类可以在docs.unity3d.com/ScriptReference/Light.html找到。
通过选择 + | 灯光 | 点光源 来亲自尝试,看看它如何影响区域照明。在调整设置后,通过在 层次 面板中右键单击点光源并选择 删除 来删除点光源。
现在我们对在游戏场景中照明所涉及的内容有了更多的了解,让我们将注意力转向添加一些动画!
在 Unity 中进行动画制作
在 Unity 中对对象进行动画制作可以从简单的旋转效果到复杂的角色动作和动作。你可以通过代码或使用动画和动画师窗口来创建动画:
-
动画窗口是创建和管理动画片段(称为剪辑)的地方,这些片段通过时间轴进行。对象属性沿着这个时间轴记录,然后播放以创建动画效果。
-
动画控制器窗口使用称为动画控制器的对象来管理这些剪辑及其过渡。
你可以在docs.unity3d.com/Manual/AnimatorControllers.html找到有关动画控制器窗口及其控制器的更多信息。
在剪辑中创建和操作目标对象将使你的游戏迅速移动。对于我们简短的 Unity 动画之旅,我们将通过代码和动画控制器创建相同的旋转效果。
在代码中创建动画
首先,我们将通过代码创建一个旋转我们的健康物品拾取的动画。由于所有 GameObject 都有Transform组件,我们可以获取我们的物品的Transform组件并无限旋转它。
要在代码中创建动画,你需要执行以下步骤:
-
在
Scripts文件夹中创建一个新的脚本,命名为ItemRotation,并在 Visual Studio Code 中打开它。 -
在新脚本顶部和类内部,添加一个名为
RotationSpeed的int变量,其值为100,以及一个名为ItemTransform的Transform变量:public int RotationSpeed = 100; Transform ItemTransform; -
在
Start()方法体内部,获取 GameObject 的Transform组件并将其分配给ItemTransform:ItemTransform = this.GetComponent<Transform>(); -
在
Update()方法体内部,调用ItemTransform.Rotate。这个Transform类方法接受三个轴,一个用于你想要执行的x、y和z旋转。由于我们想要物品端到端旋转,我们将使用x轴并将其他设置为0:ItemTransform.Rotate(RotationSpeed * Time.deltaTime, 0, 0);你会注意到我们正在将
RotationSpeed乘以一个称为Time.deltaTime的东西。这是在 Unity 中标准化运动效果的标准方式,无论玩家的电脑运行得多快或多慢,它们看起来都很平滑。一般来说,你应该始终将你的移动或旋转速度乘以Time.deltaTime。 -
回到 Unity 中,在项目面板中选择
Prefabs文件夹中的Health_Pickup对象,并滚动到检查器窗口的底部。点击添加组件,搜索ItemRotation脚本,然后按Enter键:![img/B17573_06_23.png]()
图 6.23:检查器面板中的“添加组件”按钮
-
现在我们已经更新了 Prefab,移动主相机以便可以看到
Health_Pickup对象,并点击播放:![img/B17573_06_24.png]()
图 6.24:聚焦于健康物品的相机截图
如你所见,健康拾取现在在其x轴上以连续和平滑的动画旋转!现在你已经通过代码动画化了物品,我们将使用 Unity 内置的动画系统复制我们的动画。
在 Unity 动画窗口中创建动画
任何你想应用动画片段的游戏对象都需要附加到一个设置了动画控制器的 Animator 组件上。如果在创建新片段时项目中没有控制器,Unity 将创建一个并将其保存在项目面板中,你可以使用它来管理你的片段。你的下一个挑战是为拾取物品创建一个新的动画片段。
我们将通过创建一个新的动画片段来开始动画Health_Pickup预制件,该片段将以无限循环的方式旋转对象。要创建一个新的动画片段,我们需要执行以下步骤:
-
导航到窗口 | 动画 | 动画以打开动画面板,并将动画标签拖放到控制台旁边。
-
确保在层次结构中选择了
Health_Pickup物品,然后在动画面板中的创建上点击:![]()
图 6.25:Unity 动画窗口截图
-
从以下下拉列表中创建一个新的文件夹,命名为
Animations,然后命名新的片段为Pickup_Spin:![]()
图 6.26:创建新动画窗口截图
-
确保新片段显示在动画面板中:
![]()
图 6.27:选择动画窗口截图
-
由于我们没有Animator控制器,Unity 在
Animation文件夹中为我们创建了一个名为Health_Pickup的控制器。选择Health_Pickup后,注意在检查器面板中,当我们创建片段时,一个Animator组件也被添加到了预制件中,但尚未使用Health_Pickup控制器正式保存到预制件中。 -
注意到+图标显示在Animator组件的左上角,这意味着它尚未成为Health_Pickup预制件的一部分:
![]()
图 6.28:检查器面板中的 Animator 组件
-
在右上角选择三个垂直点图标,然后选择添加组件 | 应用到预制件 'Health_Pickup':
![]()
图 6.29:将新组件应用到预制件的截图
现在你已经为Health_Pickup预制件创建并添加了 Animator 组件,是时候开始记录一些动画帧了。当你想到运动片段,就像在电影中一样,你可能想到帧。当片段通过其帧时,动画会前进,产生运动效果。在 Unity 中也是如此;我们需要在不同的帧中记录目标对象的不同位置,以便 Unity 可以播放片段。
记录关键帧
现在我们有了可以工作的剪辑,你会在动画窗口中看到一个空白的时间轴。基本上,当我们修改Health_Pickup预制件的z旋转或任何其他可以动画化的属性时,时间轴会记录这些更改作为关键帧。然后 Unity 将这些关键帧组装成完整的动画,类似于模拟电影上的单个帧一起播放成动态画面。
看看下面的截图,记住记录按钮和时间轴的位置:

图 6.30:动画窗口和关键帧时间轴的截图
现在,让我们让我们的物品开始旋转。对于旋转动画,我们希望Health_Pickup预制件每秒在其z轴上完成 360 度的完整旋转,这可以通过设置三个关键帧并让 Unity 处理其余部分来实现:
-
在层次窗口中选择Health_Pickup对象,选择添加属性 | 变换,然后点击旋转旁边的+符号:
![]()
图 6.31:为动画添加变换属性的截图
-
点击记录按钮开始动画:
-
将光标放在时间轴的0:00处,但保持Health_Pickup预制件的z旋转为 0
-
将光标放在时间轴的0:30处,并将z旋转设置为180
-
将光标放在时间轴的1:00处,并将z旋转设置为360!img/B17573_06_32.png
图 6.32:记录动画关键帧的截图
-
-
点击记录按钮完成动画
-
点击记录按钮右侧的播放按钮以查看动画循环
你会注意到我们的Animator动画覆盖了之前在代码中编写的动画。不用担心,这是预期行为。你可以点击检查器面板中任何组件右侧的小复选框来激活或停用它。如果你停用Animator组件,Health_Pickup将再次使用我们的代码绕x轴旋转。
Health_Pickup对象现在每秒在 0、180 和 360 度之间沿z轴旋转,创建循环旋转动画。如果你现在玩游戏,动画将无限期运行,直到游戏停止:

图 6.33:动画窗口中播放的动画截图
所有动画都有曲线,它们决定了动画执行的具体属性。我们不会对这些做太多,但了解基础知识很重要。我们将在下一节中详细介绍。
曲线和切线
除了动画化对象属性外,Unity 还允许我们管理动画随时间播放的方式,使用动画曲线。到目前为止,我们一直处于Dopesheet模式,你可以在动画窗口底部更改它。如果你点击曲线视图(以下截图所示),你会看到一个不同的图表,其中用突出点代替了我们记录的关键帧。
我们希望旋转动画是平滑的——我们称之为线性的——所以我们将保持一切不变。然而,加快、减慢或在其运行过程中的任何点改变动画可以通过拖动或调整曲线图上的点在任何方向上完成:

图 6.34:动画窗口中曲线时间线的截图
使用动画曲线处理属性随时间的变化,我们仍然需要一种方法来解决每次Health_Pickup动画重复时出现的卡顿。为此,我们需要更改动画的切线,它管理关键帧如何从一个融合到另一个。
这些选项可以通过在Dopesheet模式下的时间线上右键单击任何关键帧来访问,这里可以看到:

图 6.35:关键帧平滑选项的截图
曲线和切线都是中级/高级的,所以我们不会深入探讨它们。如果你感兴趣,可以查看有关动画曲线和切线选项的文档,请访问docs.unity3d.com/Manual/animeditor-AnimationCurves.html。
如果你现在播放旋转动画,当项目完成整个旋转并开始新的旋转时,会有轻微的暂停。你的任务是使其平滑,这是下一个挑战的主题。
让我们调整动画的第一帧和最后一帧的切线,以便旋转动画在重复时能够无缝融合:
-
在动画时间线上右键单击第一个和最后一个关键帧的菱形图标,并选择自动:
![图片]()
图 6.36:更改关键帧平滑选项
-
如果你还没有这样做,请移动主摄像机以便可以看到
Health_Pickup对象,并点击播放:![图片]()
图 6.37:最终平滑动画的截图
将第一个和最后一个关键帧的切线设置为自动告诉 Unity 使它们的过渡平滑,这消除了动画循环时的突然停止/开始运动。
这本书所需的动画就这些了,但我鼓励你们去查看 Unity 在这一领域提供的完整工具箱。你们的游戏将更加吸引人,玩家们也会感谢你们!
摘要
我们已经到达了这一章节的结尾,这一章节包含了很多动态部分,特别是对于那些刚开始接触 Unity 的你们来说可能很多。
尽管这本书专注于 C#语言及其在 Unity 中的实现,我们仍然需要花时间了解游戏开发、文档和引擎的非脚本功能。虽然我们没有时间深入探讨光照和动画,但如果你们打算继续创建 Unity 项目,了解它们是值得的。
在下一章中,我们将把重点转回编程 Hero Born 的核心机制,从设置可移动玩家对象、控制摄像头以及理解 Unity 的物理系统如何管理游戏世界开始。
突击测验 - 基础 Unity 功能
-
立方体、胶囊和球体是哪种 GameObject 的例子?
-
Unity 使用哪个轴来表示深度,从而给场景带来 3D 外观?
-
你如何将一个 GameObject 转换为可重用的 Prefab?
-
Unity 动画系统使用哪种度量单位来记录对象动画?
在 Discord 上加入我们!
与其他用户、Unity/C# 专家和哈里森·费罗内一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会话 与作者聊天等等。
立即加入!

第七章:移动、摄像机控制和碰撞
玩家在开始新游戏时做的第一件事通常是尝试角色移动(如果游戏中有可移动的角色)和摄像机控制。这不仅令人兴奋,而且可以让你的玩家知道他们可以期待什么样的游戏玩法。"英雄降世"中的角色将是一个胶囊对象,可以使用W、A、S、D键或箭头键分别进行移动和旋转。
我们将首先学习如何操作玩家对象的Transform组件,然后使用施加的力来复制相同的玩家控制方案。这会产生更逼真的移动效果。当我们移动玩家时,摄像机将从稍微在玩家后面和上面的位置跟随,这使得在实现射击机制时瞄准更容易。最后,我们将通过使用我们的物品拾取 Prefab 来探索 Unity 的物理系统如何处理碰撞和物理交互。
所有这些都将汇集在一个可玩级别上,尽管目前还没有任何射击机制。这还将让我们第一次尝到使用 C#编程游戏功能的滋味,通过结合以下主题:
-
管理玩家移动
-
使用
Transform组件移动玩家 -
编写摄像机行为脚本
-
使用 Unity 物理系统
管理玩家移动
当你决定如何最好地在你的虚拟世界中移动玩家角色时,考虑什么看起来最逼真,并且不会因为昂贵的计算而让你的游戏陷入困境。这在大多数情况下是一种权衡,Unity 也不例外。
移动GameObject的三个最常见方法及其结果如下:
-
选项 A:使用
GameObject的Transform组件进行移动和旋转。这是最简单的解决方案,也是我们将首先使用的方案。 -
选项 B:通过将Rigidbody组件附加到
GameObject并应用代码中的力来使用现实世界的物理。Rigidbody组件为它们附加到的任何GameObject添加了模拟的现实世界物理。这种解决方案依赖于 Unity 的物理系统来完成繁重的工作,从而产生更逼真的效果。我们将在本章后面更新我们的代码以使用这种方法,以便了解两种方法。Unity 建议在移动或旋转
GameObject时保持一致的方法;要么操作一个对象的Transform或Rigidbody组件,但不要同时操作两者。 -
选项 C:附加一个现成的 Unity 组件或 Prefab,例如 Character Controller 或 First Person Controller。这可以省去样板代码,同时仍然提供逼真的效果,并加快原型设计的时间。
你可以在docs.unity3d.com/ScriptReference/CharacterController.html上找到有关 Character Controller 组件及其用途的更多信息。
第一人称控制器 Prefab 可以从标准资产包中获取,您可以从assetstore.unity.com/packages/essentials/asset-packs/standard-assets-32351下载。
由于你刚开始在 Unity 中使用玩家移动,你将在下一节中使用玩家 Transform 组件,然后在章节的后面部分学习Rigidbody物理。
使用 Transform 组件移动玩家
我们希望为英雄降生设置一个第三人称冒险场景,所以我们将从一个可以通过键盘输入控制的胶囊开始,并设置一个跟随胶囊移动的摄像头。尽管这两个 GameObject 将在游戏中协同工作,但我们将保持它们及其脚本的分离,以便更好地控制。
在我们进行任何脚本编写之前,你需要将玩家胶囊添加到场景中,这是你的下一个任务。
我们可以仅通过几个步骤就创建一个不错的玩家胶囊:
-
在Hierarchy面板中点击+ | 3D Object | Capsule,并将其命名为
Player。 -
选择
PlayerGameObject,然后在Inspector标签页底部点击Add Component。搜索Rigidbody并按Enter键添加它。我们将在稍后使用此组件,但最好从一开始就正确设置好。 -
展开Rigidbody组件底部的Constraints属性:
- 在X、Y和Z轴上勾选Freeze Rotation的复选框,这样玩家就不能通过我们稍后编写的代码以外的任何方式旋转:
![img/B17573_07_01.png]()
图 7.1:Rigidbody 组件
- 在X、Y和Z轴上勾选Freeze Rotation的复选框,这样玩家就不能通过我们稍后编写的代码以外的任何方式旋转:
-
在Project面板中选择
Materials文件夹,然后点击Create | Material。将其命名为Player_Mat。 -
在Hierarchy面板中选择
Player_Mat,然后在Inspector面板中将Albedo属性更改为明亮的绿色,并将材质拖动到Hierarchy面板中的Player对象上:![img/B17573_07_02.png]()
图 7.2:胶囊上的玩家材质
你已经使用胶囊原形、Rigidbody 组件和一种新的明亮的绿色材质创建了一个玩家对象。现在不必担心 Rigidbody 组件是什么——你只需要知道它允许我们的胶囊与物理系统交互。我们将在本章末尾讨论 Unity 的物理系统工作原理时详细介绍。在我们到达那里之前,我们需要讨论 3D 空间中的一个非常重要的话题:向量。
理解向量
现在我们已经设置好了玩家胶囊和摄像头,我们可以开始探讨如何使用其Transform组件来移动和旋转 GameObject。Translate和Rotate方法是 Unity 提供的Transform类的一部分,每个方法都需要一个向量参数来执行其特定的功能。
在 Unity 中,向量用于在 2D 和 3D 空间中保存位置和方向数据,这就是为什么它们有两种类型——Vector2和Vector3。它们可以像我们见过的任何其他变量类型一样使用;它们只是保存不同的信息。由于我们的游戏是 3D 的,我们将使用Vector3对象,这意味着我们需要使用x、y和z值来构建它们。
对于 2D 向量,只需要x和y位置。记住,您 3D 场景中最新的方向将在我们之前章节中讨论的右上角图形中显示,即第六章,用 Unity 动手实践:

图 7.3:Unity 编辑器中的向量图示
如果您想了解更多关于 Unity 中向量的信息,请参考docs.unity3d.com/ScriptReference/Vector3.html的文档和脚本参考。
例如,如果我们想创建一个新的向量来保存场景的原点位置,我们可以使用以下代码:
Vector3 Origin = new Vector(0f, 0f, 0f);
在这里,我们只是创建了一个新的Vector3变量,并按顺序用0初始化其x位置、y位置和z位置。这将在游戏竞技场的原点生成玩家。浮点值可以带小数点或不带小数点,但它们总是需要以小写f结尾。
我们也可以通过使用Vector2或Vector3类的属性来创建方向向量:
Vector3 ForwardDirection = Vector3.forward;
与保持位置不同,ForwardDirection引用了我们场景中沿 3D 空间中的z轴的前进方向。使用 Vector3 方向的一个好处是,无论我们让玩家朝哪个方向看,我们的代码总是会知道哪个方向是前进的。我们将在本章后面讨论如何使用向量,但现在只需习惯于用x、y和z位置和方向来思考 3D 移动。
如果向量的概念对您来说是新的,请不要担心——这是一个复杂的话题。Unity 的向量食谱是一个很好的起点:docs.unity3d.com/Manual/VectorCookbook.html。
现在您对向量有了更多的了解,您就可以开始实现移动玩家胶囊的基本功能了。为此,您需要从键盘收集玩家输入,这是下一节的主题。
获取玩家输入
位置和方向本身很有用,但如果没有玩家的输入,它们不能产生移动。这就是Input类发挥作用的地方,它处理从按键和鼠标位置到加速度和陀螺仪数据的所有事情。
在《英雄降生》中,我们将使用W、A、S、D和箭头键进行移动,并配合一个允许相机跟随玩家鼠标指向的脚本。为此,我们需要了解输入轴的工作原理。
首先,转到 Edit | Project Settings | Input Manager 以打开以下截图所示的 Input Manager 选项卡:

图 7.4:输入管理器窗口
Unity 2021 引入了一个新的输入系统,该系统减少了大量的编码工作,使得在编辑器中设置输入作为动作变得更加容易。由于这是一本编程书籍,我们将从头开始。然而,如果你想了解新输入系统的工作原理,请查看这个优秀的教程:learn.unity.com/project/using-the-input-system-in-unity。
你将看到一个 Unity 默认输入的长列表,已经配置好了,但让我们以 Horizontal 轴为例。你可以看到 Horizontal 输入轴的 Positive 和 Negative 按钮设置为 left 和 right,而 Alt Negative 和 Alt Positive 按钮设置为 a 和 d 键。
代码查询任何输入轴时,其值将在 -1 和 1 之间。例如,当按下左箭头或 A 键时,水平轴注册 -1 值。当这些键释放时,值返回 0。同样,当使用右箭头或 D 键时,水平轴注册 1 的值。这允许我们仅用一行代码捕获单个轴的四个不同输入,而不是为每个输入编写一个长的 if-else 语句链。
捕获输入轴就像调用 Input.GetAxis() 并通过名称指定我们想要的轴一样简单,这就是我们在下一节中将要做的 Horizontal 和 Vertical 输入。作为额外的好处,Unity 应用了一个平滑滤波器,这使得输入帧率独立。
默认输入可以根据需要修改,但你也可以通过在输入管理器中增加 Size 属性并重命名为你创建的副本来创建自定义轴。你必须增加 Size 属性才能添加自定义输入。
让我们开始使用 Unity 的输入系统和我们自己的自定义移动脚本来让玩家移动。
移动玩家
在你让玩家移动之前,你需要将脚本附加到玩家胶囊上:
-
在
Scripts文件夹中创建一个新的 C# 脚本,命名为PlayerBehavior,并将其拖放到 Hierarchy 面板中的 Player 胶囊上。 -
添加以下代码并保存:
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 方法等空方法时,通常为了清晰起见会删除它们。
以下是前面代码的分解:
-
声明两个公共变量作为乘数:
-
MoveSpeed用于设置玩家前后移动的速度 -
RotateSpeed用于设置玩家左右旋转的速度
-
-
声明两个私有变量来保存玩家的输入;最初设置为无值:
-
_vInput将存储垂直轴输入。 -
_hInput将存储水平轴输入。
-
-
Input.GetAxis("Vertical")检测向上箭头、向下箭头、W或S键是否被按下,并将该值乘以MoveSpeed:-
向上箭头和
W键返回值为 1,这将使玩家向前(正方向)移动。 -
向下箭头和
S键返回 -1,使玩家向负方向后退。
-
-
Input.GetAxis("Horizontal")检测左箭头、右箭头、A和D键是否被按下,并将该值乘以RotateSpeed:-
右箭头和
D键返回值为 1,这将使胶囊向右旋转。 -
左箭头和
A键返回 -1,使胶囊向左旋转。如果你想知道是否可以在一行内完成所有移动计算,简单的回答是肯定的。然而,即使只有你自己阅读代码,将代码拆分也是更好的做法。
-
-
使用
Translate方法,该方法接受一个Vector3参数,来移动胶囊的 Transform 组件:-
记住,
this关键字指定了当前脚本附加的 GameObject,在这个例子中,是玩家胶囊。 -
Vector3.forward乘以_vInput和Time.deltaTime提供了胶囊在 z 轴上前进或后退的方向和速度,这是我们计算出的速度。 -
Time.deltaTime总是返回自游戏上一帧执行以来经过的秒数。它通常用于平滑在Update方法中捕获或运行的值,而不是让它由设备的帧率决定。
-
-
使用
Rotate方法来旋转胶囊的 Transform 组件相对于我们传递的参数向量:-
Vector3.up乘以_hInput和Time.deltaTime给我们想要的左右旋转轴。 -
我们在这里使用
this关键字和Time.deltaTime的原因相同。
-
如我们之前讨论的,在 Translate 和 Rotate 函数中使用方向向量是处理此问题的方法之一。我们同样可以创建新的 Vector3 变量从我们的轴输入,并将它们作为参数使用。
当你点击播放时,你可以使用上下箭头键和 W/S 键来前后移动胶囊,同时使用左右箭头键和 A/D 键进行旋转或转向。
通过这几行代码,你已经设置了两个独立的、与帧率无关且易于修改的控制。然而,我们的相机并没有跟随胶囊移动,所以让我们在下一节中修复这个问题。
脚本控制相机行为
要使一个 GameObject 跟随另一个 GameObject,最简单的方法是将其中一个设置为另一个的子对象。当一个对象是另一个对象的子对象时,子对象的位置和旋转相对于父对象。这意味着任何子对象都会随着父对象移动和旋转。
然而,这种方法意味着任何发生在玩家胶囊上的移动或旋转也会影响摄像机,而这并不是我们一定想要的。我们始终希望摄像机位于玩家后方一定的距离,并且始终旋转以面向它,无论发生什么。幸运的是,我们可以通过Transform类的方法轻松设置摄像机相对于胶囊的位置和旋转。在下一个挑战中,你的任务是编写摄像机的逻辑脚本。
由于我们希望摄像机的行为完全独立于玩家的移动方式,我们将通过Inspector选项卡设置一个可以设置的目标来控制摄像机的位置:
-
在
Scripts文件夹中创建一个新的 C#脚本,命名为CameraBehavior,并将其拖放到Hierarchy面板中的Main Camera。 -
添加以下代码并保存:
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); } }
以下是前述代码的分解:
-
声明一个
Vector3变量来存储我们想要在主摄像机和玩家胶囊之间的距离:-
由于它是
public的,我们可以在Inspector中手动设置摄像机偏移的x、y和z位置。 -
这些默认值是我认为看起来最好的,但请随意实验。
-
-
创建一个变量来保存玩家胶囊的 Transform 信息:
-
这将使我们能够访问其位置、旋转和缩放。
-
我们不希望其他脚本能够更改摄像机的目标,这就是为什么它是
private的。
-
-
使用
GameObject.Find通过名称定位胶囊并从场景中检索其 Transform 属性:-
这意味着胶囊的x、y和z位置在每一帧都会更新并存储在
_target变量中。 -
在场景中查找对象是一个计算密集型任务,因此,在
Start方法中只执行一次并存储引用是一个好习惯。永远不要在Update方法中使用GameObject.Find,因为这会尝试不断查找你正在寻找的对象,并可能导致游戏崩溃。
-
-
LateUpdate是一个MonoBehavior方法,类似于Start或Update,它在Update之后执行:- 由于我们的
PlayerBehavior脚本在Update方法中移动胶囊,我们希望在移动发生后运行CameraBehavior中的代码;这保证了_target有最新的位置可以参考。
- 由于我们的
-
每一帧将摄像机的位置设置为
_target.TransformPoint(CamOffset),从而产生以下效果:-
TransformPoint方法计算并返回世界空间中的相对位置。 -
在这种情况下,它返回
target(我们的胶囊)在x轴上偏移0,在y轴上(将摄像机置于胶囊上方)偏移1.2,在z轴上偏移-2.6(将摄像机稍微置于胶囊后方)的位置。
-
-
LookAt方法在每一帧更新胶囊的旋转,聚焦于我们传递的 Transform 参数,在这种情况下,是_target:![]()
图 7.5:播放模式下的胶囊和跟随摄像机
这需要吸收很多信息,但如果将其分解为按时间顺序排列的步骤,则更容易处理:
-
我们为相机创建了一个偏移位置。
-
我们找到了并存储了玩家胶囊的位置。
-
我们手动更新了每一帧的位置和旋转,以确保它始终以固定距离跟随并朝向玩家。
当使用提供平台特定功能的方法时,请始终记得将其分解为最基本的步骤。这将帮助你在新的编程环境中保持清醒。
虽然你编写的用于管理玩家运动代码完全有效,但你可能已经注意到它在某些地方有点不流畅。为了创建更平滑、更逼真的运动效果,你需要了解 Unity 物理系统的基本知识,你将在下一节中深入了解。
与 Unity 物理系统一起工作
到目前为止,我们还没有讨论过 Unity 引擎的工作原理,或者它是如何管理在虚拟空间中创建逼真的交互和运动的。我们将在本章的剩余部分学习 Unity 物理系统的基本知识。
驱动 Unity 的 NVIDIA PhysX 引擎的两个主要组件如下:
- Rigidbody 组件,允许 GameObject 受重力影响并添加如 质量 和 阻力 等属性。如果 Rigidbody 组件附加了 Collider 组件,它还可以受到施加的力的作用,从而产生更逼真的运动:
![img/B17573_07_06.png]()
图 7.6:检查器面板中的 Rigidbody 组件
- Collider 组件,确定 GameObject 如何以及何时进入和退出彼此的物理空间,或者简单地碰撞并弹开。虽然应该只有一个 Rigidbody 组件附加到特定的 GameObject,但如果需要不同的形状或交互,则可以有多个 Collider 组件。这通常被称为复合 Collider 设置:
![img/B17573_07_07.png]()
图 7.7:检查器面板中的盒子碰撞器组件
当两个 Collider 组件相互作用时,Rigidbody 属性决定了产生的交互。例如,如果一个 GameObject 的质量高于另一个,较轻的 GameObject 将以更大的力量弹开,就像现实生活中一样。这两个组件负责 Unity 中所有物理交互和模拟运动。
使用这些组件有一些注意事项,最好在 Unity 允许的运动类型方面理解:
-
运动学运动发生在 Rigidbody 组件附加到 GameObject 上时,但它不会在场景的物理系统中注册。换句话说,运动学对象具有物理交互,但不会对其做出反应,就像现实生活中的墙壁一样。这仅在特定情况下使用,可以通过检查 Rigidbody 组件的 Is Kinematic 属性来启用。由于我们想让我们的胶囊与物理系统交互,我们不会使用这种运动方式。
-
非动力学 移动是指通过施加力而不是手动更改 GameObject 的 Transform 属性来移动或旋转 Rigidbody 组件。本节的目标是更新
PlayerBehavior脚本来实现这种类型的运动。
我们现在的设置,即在使用 Rigidbody 组件与物理系统交互的同时操作胶囊的 Transform 组件,旨在让你思考在 3D 空间中的移动和旋转。然而,这并不是为了生产使用,Unity 建议在代码中避免混合使用动力学和非动力学移动。
你的下一个任务是使用施加的力将当前的移动系统转换为更真实的移动体验。
正在运动的 Rigidbody 组件
由于我们的玩家附加了 Rigidbody 组件,我们应该让物理引擎控制我们的移动,而不是手动平移和旋转 Transform。在施加力方面有两个选择:
-
你可以直接使用 Rigidbody 类方法,如
AddForce和AddTorque来移动和旋转对象,分别。这种方法有其缺点,通常需要额外的代码来补偿意外的物理行为,如碰撞期间的不想要的扭矩或施加的力。 -
或者,你可以使用其他 Rigidbody 类方法,如
MovePosition和MoveRotation,这些方法仍然使用施加的力。
在下一节中,我们将选择第二条路线,让 Unity 为我们处理施加的物理,但如果你对手动施加力和扭矩到你的 GameObject 感兴趣,那么从这里开始:docs.unity3d.com/ScriptReference/Rigidbody.AddForce.html。
这两种方法都会让玩家有更真实的感觉,并允许我们在 第八章,脚本游戏机制 中添加跳跃和冲刺机制。
如果你好奇当一个没有 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);
***/**
}
}
下面是对前面代码的分解:
-
添加一个私有变量,类型为
Rigidbody,它将包含对胶囊的 Rigidbody 组件的引用。 -
Start方法在脚本在场景中初始化时触发,这发生在你点击播放时,并且应该在类开始时设置任何变量时使用。 -
GetComponent方法检查我们正在寻找的组件类型(在本例中为Rigidbody)是否存在于脚本附加的 GameObject 上,并返回它:- 如果组件未附加到 GameObject,则该方法将返回
null,但由于我们知道玩家上有一个,所以我们现在不必担心错误检查。
- 如果组件未附加到 GameObject,则该方法将返回
-
在
Update函数中注释掉Transform和Rotate方法调用,这样我们就不会运行两种不同的玩家控制:- 我们希望保留捕获玩家输入的代码,以便我们以后还能使用它。
您已经初始化并存储了玩家胶囊上的刚体组件,并注释掉了过时的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);
}
以下是前面代码的分解:
-
任何与物理或刚体相关的代码都应该放在
FixedUpdate方法内部,而不是Update或其他MonoBehavior方法中:FixedUpdate与帧率无关,用于所有物理代码。
-
创建一个新的
Vector3变量来存储我们的左右旋转:Vector3.up * _hInput是我们之前在示例中使用Rotate方法的相同旋转向量。
-
Quaternion.Euler接受一个Vector3参数并返回一个欧拉角度的旋转值:-
我们需要
Quaternion值而不是Vector3参数来使用MoveRotation方法。这只是将旋转类型转换为 Unity 更喜欢的类型。 -
我们乘以
Time.fixedDeltaTime的原因与我们在Update中使用Time.deltaTime的原因相同。
-
-
在我们的
_rb组件上调用MovePosition,它接受一个Vector3参数并相应地应用力:-
使用的向量可以分解如下:胶囊的
Transform位置在前进方向上,乘以垂直输入和Time.fixedDeltaTime。 -
刚体组件负责应用运动力以满足我们的向量参数。
-
-
在
_rb组件上调用MoveRotation方法,它也接受一个Vector3参数并在幕后应用相应的力:angleRot已经包含了来自键盘的水平输入,所以我们只需要将当前的刚体旋转乘以angleRot以获得相同的左右旋转。
注意,MovePosition和MoveRotation对于非刚体游戏对象的工作方式不同。您可以在 Rigidbody 脚本参考中找到更多信息,链接为docs.unity3d.com/ScriptReference/Rigidbody.html。
如果您现在点击播放,您将能够朝您所看的方向前后移动,以及围绕y轴旋转。
应用力产生的效果比平移和旋转变换组件更强,因此你可能需要在检查器面板中微调MoveSpeed和RotateSpeed变量。你现在已经重新创建了之前相同类型的运动方案,只是加入了更真实的物理效果。
如果你跑上斜坡或从中央平台掉落,你可能会看到玩家被弹射到空中,或者缓慢地落到地上。尽管刚体组件被设置为使用重力,但它相当弱。我们将在下一章中处理将我们的重力应用到玩家上,当时我们将实现跳跃机制。现在,你的任务是熟悉碰撞组件在 Unity 中处理碰撞的方式。
碰撞和碰撞
碰撞组件不仅允许游戏对象被 Unity 的物理系统识别,而且它们还使得交互和碰撞成为可能。将碰撞组件想象成围绕游戏对象的不可见力场;根据它们的设置,它们可以被穿过或碰撞,并且它们包含一系列在交互过程中执行的方法。
Unity 的物理系统对于 2D 和 3D 游戏的工作方式不同,因此本书中我们只会涵盖 3D 主题。如果你对制作 2D 游戏感兴趣,请参考docs.unity3d.com/Manual/class-Rigidbody2D.html中的Rigidbody2D组件和可用的 2D 碰撞组件列表docs.unity3d.com/Manual/Collider2D.html。
看一下Health_Pickup对象中胶囊的以下截图。如果你想更好地看到胶囊碰撞组件,请增加半径属性:

图 7.8:附加到拾取物品的胶囊碰撞组件
物体周围的绿色形状是胶囊碰撞组件,可以使用中心、半径和高度属性来移动和缩放。
当创建一个原形时,碰撞组件默认匹配原形的形状;由于我们创建了一个胶囊原形,因此它自带胶囊碰撞组件。
碰撞组件还有盒形、球形和网格形状,可以从组件 | 物理菜单或从检查器中的添加组件按钮手动添加。
当碰撞组件接触到其他组件时,它会发出所谓的消息或广播。任何添加了一个或多个这些方法的脚本都会在碰撞组件发出消息时收到通知。这被称为事件,这是我们将在第十四章“旅程继续”中更详细讨论的主题。
例如,当两个具有碰撞器的 GameObject 相互接触时,两个对象都会注册一个 OnCollisionEnter 事件,并附带它们所碰撞的对象的引用。将事件想象成发送出去的消息——如果你选择监听它,当在这个情况下发生碰撞时,你会收到通知。这些信息可以用来跟踪各种交互事件,但最简单的一个是拾取物品。对于想要对象能够穿过其他对象的情况,你可以使用碰撞触发器,我们将在下一节中讨论。
Collider 通知的完整列表可以在 docs.unity3d.com/ScriptReference/Collider.html 的 Messages 标题下找到。
只有当碰撞对象属于特定的 Collider、Trigger 和 RigidBody 组件以及运动学或非运动学运动的组合时,才会发出碰撞和触发事件。你可以在 docs.unity3d.com/Manual/CollidersOverview.html 的 Collision action matrix 部分找到详细信息。
你之前创建的健康物品是测试碰撞工作原理的完美场所。你将在下一个挑战中处理这个问题。
拾取物品
要使用碰撞逻辑更新 Health_Pickup 对象,你需要执行以下操作:
-
在
Scripts文件夹中创建一个新的 C# 脚本,命名为ItemBehavior,然后将它拖放到 Hierarchy 面板中的Health_Pickup对象上:- 任何使用碰撞检测的脚本 必须 附带一个具有 Collider 组件的 GameObject,即使它是预制体的子对象。
-
在 Hierarchy 面板 中选择
Health_Pickup,点击 Inspector 中 Item Behavior (Script) 组件右侧的三个垂直点图标,然后选择 Added Component | Apply to Prefab 'Health_Pickup':![]()
图 7.9:应用预制体更改以拾取物品
-
将
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!"); } } } -
点击播放,并将玩家移动到胶囊上以拾取它!
以下是前述代码的分解:
-
当另一个对象撞到
Item预制体时,Unity 会自动调用OnCollisionEnter方法:-
OnCollisionEnter方法附带一个参数,用于存储与之发生碰撞的 Collider 引用。 -
注意,这里的碰撞类型是
Collision,而不是Collider。
-
-
Collision类有一个名为gameObject的属性,它包含对碰撞 GameObject 的 Collider 的引用:- 我们可以使用这个属性来获取 GameObject 的名称,并使用
if语句检查碰撞对象是否是玩家。
- 我们可以使用这个属性来获取 GameObject 的名称,并使用
-
如果碰撞对象是玩家,我们将调用
Destroy()方法,该方法接受一个 GameObject 参数,并将对象从场景中移除。 -
然后它会在控制台打印出一个简单的日志,表示我们已经收集了一个物品:
![]()
图 7.10:场景中游戏对象被删除的示例
我们已经设置了ItemBehavior来监听与Health_Pickup对象 Prefab 的任何碰撞。每当发生碰撞时,ItemBehavior使用OnCollisionEnter()并检查碰撞对象是否是玩家,如果是,则销毁(或收集)物品。
如果你感到困惑,想想我们编写的碰撞代码作为从Health_Pickup接收通知的接收器;每次被击中时,代码都会触发。
重要的是要理解,我们也可以创建一个具有OnCollisionEnter()方法的类似脚本,将其附加到玩家上,然后检查碰撞对象是否是Health_Pickup Prefab。碰撞逻辑取决于被碰撞对象的视角。
现在的问题是,你将如何设置碰撞,而不会阻止碰撞对象相互穿过?我们将在下一节中解决这个问题。
使用碰撞触发器
默认情况下,碰撞体使用isTrigger属性未勾选,这意味着物理系统将它们视为固体对象,并在碰撞时引发碰撞事件。然而,在某些情况下,你可能希望能够在不停止你的 GameObject 的情况下穿过碰撞体组件。这就是触发器的作用所在。勾选isTrigger后,GameObject 可以穿过它,但碰撞体会发送出OnTriggerEnter、OnTriggerExit和OnTriggerStay通知。
触发器在需要检测 GameObject 进入某个区域或通过某个点时最有用。我们将使用它来设置敌人周围的区域;如果玩家进入触发区域,敌人将会警觉,并在稍后攻击玩家。现在,你将专注于以下挑战中的敌人逻辑。
创建一个敌人
使用以下步骤创建一个敌人:
-
在层次面板中使用+ | 3D 对象 | 胶囊创建一个新的原形,并将其命名为
Enemy。 -
在材质文件夹中,使用+ | 材质,将其命名为
Enemy_Mat,并将其Albedo属性设置为明亮的红色:- 将
Enemy_Mat拖放到EnemyGameObject 中。
- 将
-
选择
Enemy后,点击添加组件,搜索球体碰撞体,然后按Enter键添加:- 勾选isTrigger属性框,并将半径更改为
8:![img/B17573_07_11.png]
图 7.11:附加到敌人对象上的球体碰撞组件
- 勾选isTrigger属性框,并将半径更改为
我们新的Enemy原形现在被一个 8 个单位的触发半径包围,形状像一个球体。每当另一个对象进入、停留在该区域内或退出该区域时,Unity 都会发送出我们可以捕获的通知,就像我们处理碰撞一样。你的下一个挑战将是捕获那个通知并在代码中对其做出反应。
要捕获触发事件,你需要按照以下步骤创建一个新的脚本:
-
在
Scripts文件夹中创建一个新的 C#脚本,命名为EnemyBehavior,然后将它拖放到Enemy上。 -
添加以下代码并保存文件:
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"); } } } -
点击播放并走到敌人那里触发第一个通知,然后走开触发第二个通知。
下面是对前面代码的分解:
-
OnTriggerEnter()在对象进入敌人 Sphere Collider 半径时触发:-
与
OnCollisionEnter()类似,OnTriggerEnter()存储了对违规对象 Collider 组件的引用。 -
注意,
other是Collider类型,而不是Collision。
-
-
我们可以使用
other来访问碰撞 GameObject 的名称,并使用if语句检查它是否是Player。如果是,控制台会打印出日志,说明Player处于危险区域。![图片]()
图 7.12:玩家和敌人对象之间的碰撞检测
-
当对象离开敌人 Sphere Collider 半径时,会触发
OnTriggerExit()。- 此方法还有一个对碰撞对象的 Collider 组件的引用:
-
我们使用另一个
if语句通过名称检查离开 Sphere Collider 半径的对象:- 如果是
Player,我们在控制台打印出另一条日志,说明他们安全了!![图片]()
图 7.13:碰撞触发器的示例
- 如果是
我们敌人的 Sphere Collider 在区域被侵犯时发送通知,EnemyBehavior脚本捕获了其中两个事件。每当玩家进入或退出碰撞半径时,控制台都会出现一个调试日志,让我们知道代码正在工作。我们将在第九章,基本 AI 和敌人行为中继续构建这个内容。
Unity 使用了一种称为组件设计模式的东西。不深入细节,这是一个复杂的方式来说明对象(以及通过扩展,它们的类)应该对其行为负责,而不是将所有代码放在一个巨大的文件中。这就是为什么我们在拾取物品和敌人上放置单独的碰撞脚本,而不是有一个单独的类来处理所有事情。我们将在第十四章,旅程继续中进一步讨论这一点。
由于这本书的目的是尽可能灌输尽可能多的良好编程习惯,所以本章的最后一个任务是确保所有核心对象都转换为预制体。
英雄的考验——所有预制体!
为了为下一章做好准备,请将Player和Enemy对象拖到预制体文件夹中。记住,从现在开始,你总是需要在层次结构面板中右键单击预制体,然后选择添加组件|应用到预制体来巩固对这些 GameObject 所做的任何更改。
完成这些后,继续到物理总结部分,确保你理解了我们之前覆盖的所有主要主题,然后再继续。
物理总结
在我们结束本章之前,这里有一些高级概念来巩固我们迄今为止学到的内容:
-
Rigidbody 组件为它们附加的 GameObject 添加了模拟的真实世界物理。
-
碰撞体组件通过 Rigidbody 组件相互交互,以及与对象交互:
-
如果一个碰撞体组件不是触发器,则它充当一个固体对象。
-
如果一个碰撞体组件是触发器,则可以穿过它。
-
-
如果一个对象使用 Rigidbody 组件并且勾选了“是运动学”,则该对象是运动学的,告诉物理系统忽略它。
-
如果一个对象使用 Rigidbody 组件并施加力或扭矩来驱动其运动和旋转,则该对象是非运动学的。
-
碰撞体根据其交互发送通知。这些通知取决于碰撞体组件是否设置为触发。通知可以从任一碰撞方接收,并且它们带有引用变量,这些变量包含对象的碰撞信息。
记住,像 Unity 物理系统这样广泛且复杂的主题不是一天就能学会的。利用您在这里学到的知识作为跳板,将自己投入到更复杂的话题中!
摘要
这标志着您创建独立游戏行为并将其整合成一个连贯、尽管简单,的游戏原型的第一次体验。您已经使用了向量和基本的向量数学来确定 3D 空间中的位置和角度,并且您熟悉玩家输入以及移动和旋转 GameObject 的两种主要方法。您甚至深入 Unity 物理系统,熟悉 Rigidbody 物理、碰撞、触发和事件通知。总的来说,英雄降生已经取得了良好的开端。
在下一章中,我们将开始处理更多的游戏机制,包括跳跃、冲刺、发射弹丸以及与环境部分交互。这将为您使用 Rigidbody 组件的力、收集玩家输入以及根据所需场景执行逻辑提供更多实际经验。
快速问答 - 玩家控制和物理
-
您会使用什么数据类型来存储 3D 运动和旋转信息?
-
哪个内置 Unity 组件允许您跟踪和修改玩家控制?
-
哪个组件为 GameObject 添加真实世界的物理效果?
-
Unity 建议使用哪种方法在 GameObject 上执行与物理相关的代码?
加入我们的 Discord!
与其他用户、Unity/C#专家和哈里森·费罗内一起阅读这本书。提问,为其他读者提供解决方案,通过问我任何问题会议与作者聊天等等。
现在加入!

第八章:游戏机制脚本化
在上一章中,我们专注于使用代码来移动玩家和摄像机,并在 Unity 物理方面进行了一次探索。然而,仅控制可玩角色还不足以制作出引人入胜的游戏;实际上,这可能是不同游戏标题中相对保持不变的一个领域。
一款游戏独特的火花来自于其核心机制,以及这些机制给玩家带来的力量感和自主感。如果没有有趣且引人入胜的方式来影响您所创造的虚拟环境,您的游戏就不太可能被重复游玩,更不用说有趣了。当我们着手实现游戏机制时,我们也会提升我们对 C#及其中级特性的了解。
本章将基于《英雄降世》原型,重点关注单独实现的游戏机制,以及系统设计和用户界面(UI)的基础知识。您将深入研究以下主题:
-
添加跳跃
-
射击弹丸
-
创建游戏管理器
-
创建 GUI
添加跳跃
记住上一章的内容,Rigidbody 组件为 GameObject 添加了模拟的真实世界物理,而 Collider 组件通过 Rigidbody 对象相互交互。
在上一章中,我们没有讨论的另一件关于使用 Rigidbody 组件来控制玩家移动的伟大事情是,我们可以轻松地添加依赖于施加力的不同机制,例如跳跃。在本节中,我们将让玩家跳跃并编写我们的第一个实用函数。
实用函数是一个类方法,它执行某种类型的粗活,以便我们不会使游戏代码变得杂乱无章——例如,想要检查玩家胶囊是否接触地面以便跳跃。
在此之前,您需要熟悉一种新的数据类型,即枚举类型,您将在下一节中进行学习。
介绍枚举
根据定义,枚举类型是一个集合或集合,其中包含属于同一变量的命名常量。当您想要一组不同的值,但同时又希望它们都属于同一父类型时,这些类型非常有用。
与其说,不如用枚举来展示,让我们看看以下代码片段中的语法:
enum PlayerAction { Attack, Defend, Flee };
让我们按以下方式分解其工作原理:
-
enum关键字声明了类型,后跟变量名。 -
枚举可以具有的不同值写在花括号内,用逗号分隔(最后一项除外)。
-
enum关键字必须以分号结尾,就像我们之前使用过的所有其他数据类型一样。
在这种情况下,我们声明了一个名为PlayerAction的变量,其类型为enum,可以设置为三个值之一——攻击、防御或逃跑。
要声明枚举变量,我们使用以下语法:
PlayerAction CurrentAction = PlayerAction.Defend;
再次,我们可以按以下方式分解:
-
类型设置为
PlayerAction,因为我们的枚举类型就像字符串或整数等其他类型一样。 -
变量名为
currentAction,并设置为PlayerAction的一个值。 -
每个枚举常量都可以使用点符号访问。
我们当前的 currentAction 变量现在设置为 Defend,但它可以随时更改为 Attack 或 Flee。
枚举在第一眼看起来可能很简单,但在适当的情况下它们非常强大。它们最有用的特性之一是能够存储底层类型,这是你将要跳入的下一个主题。
底层类型
枚举带有底层类型,这意味着花括号内的每个常量都有一个关联的值。默认的底层类型是 int,从 0 开始,就像数组一样,每个连续的常量都会得到下一个最高的数字。
并非所有类型都是相同的——枚举的底层类型仅限于 byte、sbyte、short、ushort、int、uint、long 和 ulong。这些被称为整型,用于指定变量可以存储的数值的大小。
这对于本书来说有点高级,但你将在大多数情况下使用 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 等于 6,而 Flee 自动等于 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 ...
}
}
让我们按以下方式分解这段代码:
-
首先,我们创建了两个新的变量——一个公共变量用于存储我们想要施加的跳跃力的大小,一个私有布尔变量用于检查我们的玩家是否应该跳跃。
-
我们将
_isJumping的值设置为Input.GetKeyDown()方法,该方法返回一个bool类型的值,取决于指定的键是否被按下。-
我们使用
|=运算符来设置_isJumping,这是一个逻辑“或”条件。这个运算符确保当玩家跳跃时,连续的输入检查不会相互覆盖。 -
该方法接受一个键参数,可以是
string或KeyCode,它也是一个枚举类型。我们指定我们想要检查KeyCode.Space。在
FixedUpdate中检查输入有时会导致输入丢失甚至双重输入,因为它不是每帧运行一次。这就是为什么我们在Update中检查输入,然后在FixedUpdate中应用力或设置速度的原因。
-
-
我们使用
if语句来检查_isJumping是否为真,如果是,则触发跳跃机制。 -
由于我们已经将 Rigidbody 组件存储起来,我们可以将
Vector3和ForceMode参数传递给RigidBody.AddForce(),从而使玩家跳跃。-
我们指定向量(或施加的力)应在
up方向上,乘以JumpVelocity。 -
ForceMode参数决定了力的应用方式,它也是一个枚举类型。Impulse在考虑物体的质量的同时向物体施加即时力,这对于跳跃机制来说非常完美。其他
ForceMode选择在不同的场景中可能很有用,所有这些都在这里详细说明:docs.unity3d.com/ScriptReference/ForceMode.html。
-
-
在每个
FixedUpdate帧的末尾,我们将_isJumping重置为 false,这样输入检查就知道完整的跳跃和着陆周期已经完成。
如果你现在玩游戏,你将能够移动并在按下空格键时跳跃。然而,这个机制允许你无限期地跳跃,这不是我们想要的。在下一节中,我们将通过使用所谓的层掩码来限制我们的跳跃机制,每次只能跳跃一次。
使用层掩码
将层掩码想象成 GameObject 可以属于的无形组,由物理系统用来确定从导航到相交的碰撞组件等任何事情。虽然层掩码的更高级用法超出了本书的范围,但我们将创建并使用一个来执行简单的检查——检查玩家胶囊是否接触地面,以便限制玩家每次只能跳跃一次。
在我们能够检查玩家胶囊是否接触地面之前,我们需要将我们关卡中的所有环境对象添加到自定义层掩码中。这将使我们能够使用已经附加到玩家上的 Capsule Collider 组件执行实际的碰撞计算。按照以下步骤操作:
-
在层次结构中选择任何环境 GameObject,并在相应的检查器面板中,点击层 | 添加层...,如图所示:
![]()
图 8.1:在检查器面板中选择层
-
通过在第一个可用槽位中输入名称来添加一个名为
Ground的新层,该槽位是层 6。层 0-5 是为 Unity 的默认层保留的,即使层 3 是空的,如以下截图所示!![img/B17573_08_02.png]()
图 8.2:在检查器面板中添加层
-
在层次结构中选择环境父 GameObject,点击层下拉菜单,并选择地面。
![img/B17573_08_03.png]()
图 8.3:设置自定义层
在选择以下截图所示的地面选项后,当出现对话框询问你是否要更改所有子对象时,点击是,更改子对象。在这里,你已定义了一个名为地面的新层,并将环境的每个子对象分配到该层。
从现在开始,所有位于地面层的对象都可以检查是否与特定对象相交。你将在下面的挑战中使用这个功能,确保玩家在地面时可以执行跳跃;这里没有无限跳跃的漏洞。
由于我们不希望代码在Update()方法中变得杂乱,我们将我们的层掩码计算放在一个实用函数中,并根据结果返回一个true或false值。为此,请按照以下步骤操作:
-
将以下高亮代码添加到
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;** **}** **}** -
在选择
PlayerBehavior脚本后,在检查器面板中将地面层从地面层下拉菜单设置为地面,如以下截图所示!![img/B17573_08_04.png]()
图 8.4:设置地面层
让我们按以下方式分解前面的代码:
-
我们为将在玩家胶囊碰撞体和任何地面层对象之间检查的距离创建一个新变量。
-
我们创建一个
LayerMask变量,我们可以在检查器中设置它,并用于碰撞检测。 -
我们创建一个变量来存储玩家的胶囊碰撞体组件。
-
我们使用
GetComponent()来查找并返回附加到玩家的胶囊碰撞体。 -
我们更新
if语句以检查IsGrounded是否返回true以及空格键是否在执行跳跃代码之前被按下。 -
我们声明一个返回
bool类型的IsGrounded()方法。 -
我们创建一个局部
Vector3变量来存储玩家胶囊碰撞体底部的位置,我们将使用它来检查与地面层上任何对象的碰撞。-
所有碰撞体组件都有一个
bounds属性,它为我们提供了访问其x、y和z轴的最小、最大和中心位置的能力。 -
碰撞体的底部是中心x、最小y和中心z的 3D 点。
-
-
我们创建一个局部
bool变量来存储从Physics类中调用的CheckCapsule()方法的结果,该方法接受以下五个参数:-
胶囊的起始位置,我们将其设置为胶囊碰撞体的中间,因为我们只关心底部是否接触地面。
-
胶囊的末端,即我们已经计算出的
capsuleBottom位置。 -
胶囊的半径,即已经设置的
DistanceToGround。 -
我们想要检查碰撞的层掩码,在 检查器 中设置为
GroundLayer。 -
查询触发器交互,它确定方法是否应该忽略设置为触发器的碰撞体。由于我们想忽略所有触发器,我们使用了
QueryTriggerInteraction.Ignore枚举。我们也可以使用
Vector3类的Distance方法来确定我们距离地面的距离,因为我们知道玩家胶囊的高度。然而,我们将坚持使用Physics类,因为这是本章的重点。
-
-
我们在计算结束时返回存储在
grounded中的值。
我们可以手动进行碰撞计算,但这将需要比我们在这里有时间覆盖的更复杂的 3D 数学。然而,当可用时,使用内置方法总是一个好主意。
那是一段复杂的代码,我们刚刚添加到 PlayerBehavior 中,但当你分解它时,我们唯一的新操作是使用 Physics 类的一个方法。用简单的话说,我们向 CheckCapsule() 提供了起始点和终点、碰撞半径以及层掩码。如果终点比碰撞半径更接近层掩码上的对象,该方法返回 true——这意味着玩家正在接触地面。如果玩家处于跳跃中的位置,CheckCapsule() 返回 false。
由于我们在 Update() 中的每一帧都在检查 IsGround,因此我们的玩家的跳跃技能只有在接触地面时才被允许。
这就是跳跃机制的所有操作,但玩家仍然需要一个方式来交互并保护自己免受最终将充满竞技场的敌人群的攻击。在下一节中,你将通过实现简单的射击机制来填补这个空白。
射击弹体
射击机制如此普遍,以至于很难想象一个没有某种变化的第一人称游戏,而《英雄降生》也不例外。在本节中,我们将讨论如何在游戏运行时从 Prefab 实例化 GameObject,并使用我们学到的技能利用 Unity 物理将其推进。
实例化对象
游戏中实例化 GameObject 的概念与实例化类的实例类似——两者都需要起始值,以便 C# 知道我们想要创建什么类型的对象以及它需要在何处创建。为了在运行时创建场景中的对象,我们使用 Instantiate() 方法并提供一个 Prefab 对象、起始位置和起始旋转。
实际上,我们可以告诉 Unity 在这个位置创建一个具有所有组件和脚本的指定对象,朝这个方向看,然后根据需要对其进行操作,一旦它在 3D 空间中诞生。在我们实例化对象之前,你需要创建对象 Prefab 本身,这是你的下一个任务。
在我们可以射击任何投射物之前,我们需要一个用作参考的预制件,所以现在让我们创建它,如下所示:
-
在层次结构面板中选择+ | 3D 对象 | 球体,并将其命名为
Bullet。- 在变换组件中将缩放的x、y和z轴的值更改为 0.15。
-
在检查器中选择子弹,并使用底部的添加组件按钮搜索并添加一个刚体组件,保留所有默认属性。
-
在材质文件夹中使用创建 | 材质创建一个新的材质,并将其命名为
Bullet_Mat:-
将Albedo属性更改为深黄色。
-
将材质文件夹中的材质拖放到层次结构面板中的
BulletGameObject 上![img/B17573_08_05.png]
图 8.5:设置投射物属性
-
-
在层次结构面板中选择子弹,并将其拖动到项目面板中的
Prefabs文件夹中。然后,从层次结构中删除它以清理场景![img/B17573_08_06.png]图 8.6:创建投射物预制件
您创建并配置了一个子弹预制件 GameObject,可以在游戏中根据需要多次实例化并更新。这意味着您已经准备好迎接下一个挑战——射击投射物。
添加射击机制
现在我们有了可以操作的预制件对象,我们可以在按下左鼠标按钮时实例化和移动预制件的副本,从而创建射击机制,如下所示:
-
更新
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 ... } } -
在检查器中,将子弹预制件从项目面板拖动到
PlayerBehavior的子弹属性中,如图所示![img/B17573_08_07.png]图 8.7:设置子弹预制件
-
播放游戏并使用左鼠标按钮向玩家面向的方向发射投射物!
让我们分解以下代码:
-
我们创建了两个变量:一个用于存储子弹预制件,另一个用于存储子弹速度。
-
就像我们的跳跃机制一样,我们在
Update方法中使用布尔值来检查玩家是否应该射击。 -
我们使用逻辑运算符
or和Input.GetMouseButtonDown()来设置_isShooting的值,如果按下指定的按钮,则返回true,就像使用Input.GetKeyDown()一样。GetMouseButtonDown()接受一个int参数,用于确定我们想要检查哪个鼠标按钮;0是左按钮,1是右按钮,2是中间按钮或滚轮。
-
然后我们检查是否应该使用
_isShooting输入检查变量来射击。 -
每次按下左鼠标按钮时,我们都会创建一个局部的 GameObject 变量:
-
我们使用
Instantiate()方法通过传递Bullet预制件将 GameObject 分配给newBullet。我们还使用玩家胶囊的位置将新的Bullet预制件放置在玩家前方,以避免任何碰撞。 -
我们将其作为
GameObject附加,以显式地将返回的对象转换为与newBullet相同的类型,在这种情况下是一个 GameObject。
-
-
我们调用
GetComponent()来返回并存储newBullet上的 Rigidbody 组件。 -
我们将 Rigidbody 组件的
velocity属性设置为玩家transform.forward方向乘以BulletSpeed:- 改变
velocity而不是使用AddForce()确保子弹在发射时不会被重力拉成弧形。
- 改变
-
最后,我们将
_isShooting值设置为false,以便我们的射击输入在下一个输入事件之前被重置。
再次,你已经显著提升了玩家脚本所使用的逻辑。现在你应该能够使用鼠标从玩家位置发射直线飞行的弹丸。
然而,现在的问题是你的游戏场景和层次结构中充满了用过的子弹对象。你的下一个任务是清理这些对象,一旦它们被发射,以避免任何性能问题。
管理对象累积
无论你是编写完全基于代码的应用程序还是 3D 游戏,确保定期删除未使用的对象以避免程序过载是很重要的。子弹在发射后并不扮演特别重要的角色;它们似乎只是存在于地板上,靠近它们碰撞的墙壁或对象。
对于像射击这样的机制,这可能会导致数百甚至数千个子弹,这是我们不想看到的。你的下一个挑战是在设定延迟时间后销毁每个子弹。
对于这个任务,我们可以利用我们已经学到的技能,使子弹负责它们的自毁行为,如下所示:
-
在
Scripts文件夹中创建一个新的 C#脚本,并将其命名为BulletBehavior。 -
将
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); } }
让我们按以下方式分解这段代码:
-
我们声明一个
float变量来存储我们希望子弹预设在场景中保持多长时间。 -
我们使用
Destroy()方法删除 GameObject。-
Destroy()始终需要一个对象作为参数。在这种情况下,我们使用this关键字来指定脚本附加到的对象。 -
Destroy()可以可选地接受一个额外的float参数作为延迟,我们使用它来使子弹在屏幕上保持短暂的时间。
-
再次玩游戏,发射一些子弹,并观察它们在场景中经过特定延迟后自行从层次结构中删除。这意味着子弹执行其定义的行为,而无需另一个脚本告诉它做什么,这是组件设计模式的理想应用。
现在我们已经完成了清理工作,你将学习任何设计良好且组织有序的项目的一个关键组件——管理类。
创建游戏管理器
当学习编程时,一个常见的误解是所有变量都应该自动设置为 public,但一般来说,这并不是一个好主意。根据我的经验,变量应该从一开始就被视为受保护的和私有的,只有在必要时才设置为 public。你将看到经验丰富的程序员通过管理类来保护他们的数据,因此,为了养成良好的习惯,我们将效仿他们。将管理类想象成一个漏斗,其中重要的变量和方法可以安全地访问。
当我说“安全地”时,我的意思就是如此,这在编程环境中可能看起来不熟悉。然而,当你有不同类相互通信并更新彼此的数据时,事情可能会变得混乱。这就是为什么有一个单一的接触点,比如管理器类,可以将其保持在最低限度。我们将在下一节中探讨如何有效地做到这一点。
跟踪玩家属性
英雄诞生 是一个简单的游戏,所以我们只需要跟踪的两个数据点是玩家收集了多少物品以及他们剩余多少生命值。我们希望这些变量是私有的,这样它们就只能从管理器类中修改,这给我们提供了控制和安全性。你的下一个挑战是为 英雄诞生 创建一个游戏管理器,并填充它有用的功能。
游戏管理器类将是您未来开发的任何项目中一个恒定的组成部分,因此让我们学习如何正确创建一个,如下所示:
-
在
Scripts文件夹中创建一个新的 C# 脚本,并将其命名为GameBehavior。通常,这个脚本会被命名为
GameManager,但 Unity 为其自己的脚本保留了该名称。如果你创建了一个脚本,并且旁边出现了一个齿轮图标而不是 C# 文件图标,那说明它是受限的。 -
在 Hierarchy 中创建一个新的空游戏对象,使用 + | Create Empty,并将其命名为
Game_Manager。 -
将
GameBehavior.cs脚本从 Scripts 文件夹拖放到Game_Manager对象上,如图所示:![img/B17573_08_08.png]图 8.8:附加游戏管理器脚本
管理脚本和其他非游戏文件被设置在空对象上,以便将它们放入场景中,即使它们不与实际的 3D 空间交互。
-
将以下代码添加到
GameBehavior.cs文件中:public class GameBehavior : MonoBehaviour { private int _itemsCollected = 0; private int _playerHP = 10; }
让我们分解这段代码。我们添加了两个新的 private 变量来存储拾取的物品数量和玩家剩余的生命值;这些变量是 private 的,因为它们只应该在这个类中可修改。如果它们被设置为 public,其他类可以随意更改它们,这可能导致变量存储不正确或并发数据。
将这些变量声明为 private 意味着你负责它们如何被访问。以下关于 get 和 set 属性的主题将介绍你如何以标准、安全的方式完成这项任务。
获取和设置属性
我们已经设置了管理脚本和私有变量,但如果它们是私有的,我们如何从其他类访问它们?虽然我们可以在GameBehavior中编写单独的公共方法来处理将新值传递给私有变量,但让我们看看是否有更好的方法来做这件事。
在这种情况下,C#为所有变量提供了get和set属性,这些属性非常适合我们的任务。想象一下,这些就像是由 C#编译器自动调用的方法,无论我们是否显式调用它们,就像 Unity 在场景开始时执行Start()和Update()一样。
get和set属性可以添加到任何变量,无论是否有初始值,如下面的代码片段所示:
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
}
}
现在,get和set属性已经设置好,以执行额外的逻辑,具体取决于需要的地方。但我们还没有完成,因为我们还需要处理新的逻辑。
每个 get 代码块都需要返回一个值,而每个 set 代码块都需要
分配一个值;这就是为什么需要一个名为后置变量的私有变量和具有get和set属性的公共变量的组合。私有变量保持受保护状态,而公共变量允许其他类进行受控访问,如下面的代码片段所示:
private string _firstName
public string FirstName {
get {
**return** _firstName;
}
set {
_firstName = **value**;
}
}
让我们按以下方式分解:
-
我们可以从
get属性返回存储在私有变量中的值,当另一个类需要它时,而不必实际上给那个外部类直接访问权限。 -
当外部类将新值分配给公共变量时,我们可以随时更新私有变量,以保持它们同步。
-
value关键字是用于替代所分配的新值的占位符。
没有实际应用的情况下,这可能会显得有些晦涩难懂,所以让我们更新GameBehavior,添加具有 getter 和 setter 属性的公共变量,以配合我们现有的私有变量。
现在我们已经理解了get和set属性访问器的语法,我们可以在我们的管理类中实现它们,以提高效率和代码可读性。
按以下方式更新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);**
**}**
**}**
}
让我们按以下方式分解代码:
-
我们声明一个新的名为
Items的公共变量,具有get和set属性。 -
我们使用
get属性在从外部类访问Items时返回存储在_itemsCollected中的值。 -
我们使用
set属性在更新Items的新值时将_itemsCollected分配给它,并添加了一个Debug.LogFormat()调用,以打印出_itemsCollected的修改后的值。 -
我们设置了一个名为
HP的公共变量,具有get和set属性,以补充私有的_playerHP后置变量。
这两个私有变量现在都是可读的,但只能通过它们的公共对应物来读取;它们只能在GameBehavior中更改。通过这种设置,我们确保我们的私有数据只能从特定的接触点访问和修改。这使得从我们的其他机械脚本与GameBehavior通信以及显示我们在本章末尾创建的简单 UI 中的实时数据变得更加容易。
让我们通过在竞技场中成功与物品拾取互动时更新Items属性来测试一下。
更新物品集合
现在我们已经在GameBehavior中设置了变量,每次我们在场景中收集到Item时,都可以更新Items,如下所示:
-
将以下高亮代码添加到
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****;** } } } -
播放游戏并收集拾取物品,以查看管理脚本中打印出的新控制台日志,如图所示:
![]()
图 8.9:收集拾取物品
让我们按以下方式分解代码:
-
我们创建了一个新的
GameBehavior类型的变量来存储附加脚本的引用。 -
我们使用
Start()通过Find()在场景中查找GameManager并添加对GetComponent()的调用来初始化GameManager。你在 Unity 文档和社区项目中经常会看到这种单行代码。这样做是为了简化,但如果你觉得单独编写
Find()和GetComponent()调用更舒服,那就直接这么做吧;清晰的显式格式化没有问题。 -
在销毁物品预制体后,我们在
GameManager类的OnCollisionEnter()方法中增加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 中的用户界面是基于 Canvas 的,这正好符合其名称。将 Canvas 想象成一块空白画布,你可以在上面绘制,Unity 会为你将其渲染在游戏世界之上。每次你在层级面板中创建第一个 UI 元素时,都会同时创建一个Canvas父对象。
-
在层级面板中右键单击,选择UI | 文本,并将新对象命名为Health。这将一次性创建一个Canvas父对象和新的Text对象!
![img/B17573_08_10.png]()
图 8.10:创建文本元素
-
要正确查看 Canvas,请在上面的场景标签页中选择2D模式。从这种视图来看,我们的整个层级就是左下角的那条细小的白色线条。
- 即使在场景中Canvas和层级没有重叠,当游戏运行时,Unity 会自动正确地将它们叠加在一起!
![img/B17573_08_11.png]()
图 8.11:Unity 编辑器中的 Canvas
- 即使在场景中Canvas和层级没有重叠,当游戏运行时,Unity 会自动正确地将它们叠加在一起!
-
如果你选择层级中的Health对象,你会看到新创建的文本对象默认位于画布的左下角,并且在检查器面板中有一系列可自定义的属性,如文本和颜色!
![img/B17573_08_12.png]()
图 8.12:Unity Canvas 上的文本元素
-
在层级面板中选择Health对象,然后点击检查器中Rect Transform组件的锚点预设,并选择左上角。
- 锚点设置 UI 元素在 Canvas 上的参考点,这意味着无论设备屏幕的大小如何,我们的健康点始终锚定在屏幕的左上角!
![img/B17573_08_13.png]()
图 8.13:设置锚点预设
- 锚点设置 UI 元素在 Canvas 上的参考点,这意味着无论设备屏幕的大小如何,我们的健康点始终锚定在屏幕的左上角!
-
在检查器面板中,将Rect Transform的位置在X轴上设置为100,在Y轴上设置为-30,以将文本定位在右上角。同时,将文本属性更改为玩家健康:。我们将在稍后的步骤中通过代码设置实际值!
![img/B17573_08_14.png]()
图 8.14:设置文本属性
-
重复步骤 1-5 以创建一个新的 UI文本对象,并将其命名为Items:
-
将锚点预设设置为左上角,Pos X设置为100,Pos Y设置为-60
-
将文本设置为收集到的物品:
![图片]()
图 8.15:创建另一个文本元素
-
-
重复步骤 1-5以创建一个新的 UI文本对象,并将其命名为Progress:
-
将锚点预设设置为底居中,Pos X设置为0,Pos Y设置为15,Width设置为280
-
将文本设置为收集所有物品并赢得自由!
![图片]()
-
图 8.16:创建进度文本元素
现在我们已经设置了 UI,让我们将我们在游戏管理器脚本中已有的变量连接起来。按照以下步骤操作:
-
使用以下代码更新
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); } } } -
在层次结构中选择Game_Manager,并将我们的三个文本对象逐个拖放到检查器中对应的
GameBehavior脚本字段中:![图片]()
图 8.17:将文本元素拖放到脚本组件中
-
运行游戏并查看我们新的屏幕 GUI 框,如下面的截图所示:
![图片]()
图 8.18:测试游戏模式下的 UI 元素
让我们分解以下代码:
-
我们添加
UnityEngine.UI命名空间,以便我们可以访问文本变量类型。 -
我们为关卡中的最大物品数量创建了一个新的公共变量。
-
我们创建了三个新的文本变量,并在检查器面板中连接它们。
-
然后,我们使用
Start方法通过+=运算符设置我们的健康和物品文本的初始值。 -
每次收集到物品时,我们都会更新ItemText的
text属性,以显示更新的items计数。 -
我们在
_itemsCollected的设置属性中声明了一个if语句。-
如果玩家收集到的物品数量超过或等于
MaxItems,则他们赢了,并且ProgressText.text会更新。 -
否则,
ProgressText.text会显示还有多少物品需要收集。
-
-
每当玩家的健康受损时(我们将在下一章中介绍),我们就会更新
HealthText的text属性,以显示新的值。
现在我们玩游戏时,我们的三个 UI 元素会显示正确的值;当收集到物品时,ProgressText和_itemsCollected计数会更新,如下面的截图所示:

图 8.19:更新 UI 文本
每个游戏都可以赢或输。在本章的最后部分,你的任务是实现这些条件以及与之相关的用户界面。
胜负条件
我们已经实现了核心游戏机制和简单的 UI,但“英雄诞生”仍然缺少一个重要的游戏设计元素:其胜负条件。这些条件将管理玩家如何赢得或输掉游戏,并根据情况执行不同的代码。
在 第六章 的游戏文档中,用 Unity 搞定一切,我们设置了以下胜利和失败条件:
-
收集关卡中的所有物品,并且至少剩余 1 点生命值以赢得胜利
-
从敌人那里受到伤害直到生命值降至 0 以失败
这些条件将影响我们的 UI 和游戏机制,但我们已经设置了 GameBehavior 以高效地处理这些。我们的 get 和 set 属性将处理任何与游戏相关的逻辑以及玩家胜利或失败时对 UI 的更改。
我们将在本节中实现胜利条件的逻辑,因为我们已经有了拾取系统。当我们进入下一章的敌人 AI 行为时,我们将添加失败条件的逻辑。你的下一个任务是确定在代码中何时游戏胜利。
我们总是希望给玩家提供清晰且即时的反馈,因此我们将首先添加胜利条件的逻辑,如下所示:
-
将
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!"; } } } } -
在 层次结构 中右键单击,选择 UI | 按钮,然后将其命名为 胜利条件:
- 选择 胜利条件 并将 X 坐标 和 Y 坐标 设置为 0,其 宽度 为 225,高度 为 115!
![图片]()
图 8.20:创建 UI 按钮
- 选择 胜利条件 并将 X 坐标 和 Y 坐标 设置为 0,其 宽度 为 225,高度 为 115!
-
点击 胜利条件 按钮右侧的箭头以展开其文本子对象,然后将文本更改为 你赢了:
![图片]()
图 8.21:更新按钮文本
-
再次选择 胜利条件 父对象,然后在 检查器 的右上角点击勾选图标!
![图片]()
图 8.22:停用游戏对象
这将隐藏按钮,直到我们赢得游戏:
![图片]()
图 8.23:测试隐藏的 UI 按钮
-
在 层次结构 中选择 Game_Manager,然后将 胜利条件 按钮从 层次结构 拖到 检查器 中的 游戏行为(脚本),就像我们处理文本对象一样:
![图片]()
图 8.24:将 UI 按钮拖放到脚本组件中
-
在 检查器 中将 最大物品数 更改为
1以测试新屏幕,如图下截图所示:![图片]()
图 8.25:显示胜利界面
让我们分解以下代码:
-
我们创建了一个 UI 按钮变量,用于连接到 层次结构 中的胜利条件按钮。
-
由于我们在游戏开始时将胜利条件按钮设置为 隐藏,因此当游戏胜利时,我们将重新激活它。
将 最大物品数 设置为 1,当收集场景中的唯一 Pickup_Item 时,胜利 按钮将显示出来。点击按钮目前没有任何作用,但我们将在这部分内容中解决它。
使用指令和命名空间暂停和重新启动游戏
目前,我们的胜利条件按预期工作,但玩家仍然可以控制胶囊,并且一旦游戏结束,就没有重启游戏的方法。Unity 在Time类中提供了一个名为timeScale的属性,将其设置为0可以冻结游戏场景。然而,要重启游戏,我们需要访问一个名为SceneManagement的命名空间,默认情况下我们的类无法访问。
命名空间收集并按特定名称组织一组类,以组织大型项目并避免可能具有相同名称的脚本之间的冲突。需要向一个类添加一个using指令来访问命名空间的类。
从 Unity 创建的所有 C#脚本都包含三个默认的using指令,如下面的代码片段所示:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
这些允许访问常见的命名空间,但 Unity 和 C#提供了许多其他可以通过using关键字后跟命名空间名称来添加的命名空间。
由于我们的游戏在玩家获胜或失败时需要暂停和重启,这是一个使用默认情况下不包括在新的 C#脚本中的命名空间的好时机。
-
将以下代码添加到
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 ... } -
从层次结构中选择胜利条件,在检查器中向下滚动到按钮组件的OnClick部分,然后点击加号图标:
-
每个 UI 按钮都有一个OnClick事件,这意味着你可以将脚本中的一个方法分配给按钮,以便在按钮被按下时执行。
-
当按钮被点击时,可以有多个方法被触发,但在这个情况下我们只需要一个!
![图片]()
图 8.26:按钮的 OnClick 部分
-
-
从层次结构中,将Game_Manager拖到Runtime下的槽中,告诉按钮我们想要选择一个来自我们的管理脚本的方法,以便在按钮被按下时触发:
![图片]()
图 8.27:在 OnClick 中设置游戏管理器对象
-
选择无功能下拉菜单,选择GameBehavior | RestartScene ()来设置按钮要执行的方法!
![图片]()
图 8.28:选择按钮点击的重启方法
-
前往窗口 | 渲染 | 照明,在底部选择生成照明。确保自动生成没有被选中:
此步骤是解决 Unity 问题所必需的,该问题在没有任何照明的情况下重新加载场景。

图 8.29:Unity 编辑器中的照明面板
让我们分解以下代码:
-
我们使用
using关键字添加SceneManagement命名空间,它处理所有与场景相关的逻辑,如创建加载场景。 -
我们将
Time.timeScale设置为0,以便在显示胜利屏幕时暂停游戏,这将禁用任何输入或移动。 -
我们创建一个新的方法
RestartScene,并在胜利屏幕按钮被点击时调用LoadScene():-
LoadScene()接受一个场景索引作为int参数。 -
由于我们的项目中只有一个场景,我们使用索引
0从开始重启游戏。
-
-
我们将
Time.timeScale重置为默认值1,这样当场景重新启动时,所有控制和行为将能够再次执行。
现在,当你收集物品并点击胜利屏幕按钮时,关卡将重新开始,所有脚本和组件都将恢复到其原始值,并设置好另一轮!
摘要
恭喜!英雄诞生 现在是一个可玩的原型。我们实现了跳跃和射击机制,管理物理碰撞和对象生成,并添加了一些基本的 UI 元素来显示反馈。我们甚至做到了当玩家获胜时重置关卡。
本章引入了许多新主题,重要的是要回顾并确保你理解了我们编写的代码中包含的内容。特别关注我们对枚举、get 和 set 属性以及命名空间的讨论。从现在开始,随着我们进一步深入 C# 语言的潜力,代码将变得更加复杂。
在下一章中,我们将开始工作,让我们的敌人 GameObject 在我们靠近时注意到我们,从而实现一个跟随和射击协议,这将提高玩家的赌注。
快速问答 - 与机制一起工作
-
枚举存储什么类型的数据?
-
你如何在活动场景中创建一个 Prefab GameObject 的副本?
-
哪些变量属性允许你在引用或修改其值时添加功能?
-
哪个 Unity 方法显示场景中的所有 UI 对象?
加入我们的 Discord!
与其他用户、Unity/C# 专家和 Harrison Ferrone 一起阅读这本书。提出问题,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天等等。
立即加入!

第九章:基本 AI 和敌人行为
虚拟场景需要冲突、后果和潜在奖励才能感觉真实。没有这三样东西,玩家就没有理由关心他们在游戏中的角色会发生什么,更不用说继续玩游戏了。虽然有很多游戏机制能够满足一个或多个这些条件,但没有什么能比得上一个会主动寻找你并试图结束你游戏会话的敌人。
编程一个智能敌人并不容易,这通常与长时间的工作和挫败感相伴。然而,Unity 内置了功能、组件和类,我们可以使用它们以更用户友好的方式设计和实现 AI 系统。这些工具将推动英雄诞生的第一个可玩迭代冲过终点线,并为更高级的 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:
-
选择环境GameObject,在检查器窗口中点击静态旁边的箭头图标,并选择导航静态:
![]()
图 9.1:将对象设置为导航静态
-
当弹出对话框时,点击是,更改子对象,将所有环境子对象设置为导航静态:
![]()
图 9.2:更改所有子对象
-
前往窗口 | AI | 导航,并选择烘焙选项卡。保持所有设置为默认值,然后点击烘焙。一旦烘焙完成,你将在场景文件夹内看到一个新文件夹,其中包含光照、导航网格和反射探针数据:
![]()
图 9.3:烘焙导航网格
我们关卡中的每个对象现在都被标记为导航静态,这意味着我们新烘焙的 NavMesh 已经根据其默认 NavMeshAgent 设置评估了它们的可访问性。在前面的截图中所见到的任何地方都可以看到浅蓝色叠加,这是任何带有 NavMeshAgent 组件的对象的可通行表面,这是你的下一个任务。
设置敌人代理
让我们将敌人预制件注册为 NavMeshAgent:
-
在预制件文件夹中选择敌人预制件,在检查器窗口中点击添加组件,并搜索NavMesh Agent:
![]()
图 9.4:添加 NavMeshAgent 组件
-
从层次结构窗口中点击+ | 创建空对象,并将 GameObject 命名为
Patrol_Route:- 选择
Patrol_Route,点击+ | 创建空对象以添加子 GameObject,并将其命名为Location_1。将Location_1放置在关卡的一个角落中:![]()
图 9.5:创建空巡逻路线对象
- 选择
-
在
Patrol_Route中创建另外三个空子对象,分别命名为Location_2、Location_3和Location_4,并将它们放置在关卡剩余的角落中形成一个正方形:![]()
图 9.6:创建所有空巡逻路线对象
将 NavMeshAgent 组件添加到敌人中,告诉 NavMesh 组件注意并将其注册为具有访问其自主导航功能的对象。在每个角落创建四个空的游戏对象,规划出我们希望敌人最终巡逻的简单路线;将它们组合在一个空父对象中,使得在代码中引用它们更容易,并且使 Hierarchy 窗口更加有序。剩下的只是编写让敌人沿着巡逻路线行走的代码,你将在下一节中添加它。
移动敌人代理
我们的巡逻位置已设置,敌人预制体有一个 NavMeshAgent 组件,但现在我们需要弄清楚如何引用这些位置并让敌人自动移动。为此,我们首先需要讨论软件开发领域中的一个重要概念:过程式编程。
过程式编程
即使这个名字里包含了,直到你真正理解了它,过程式编程背后的想法也可能难以捉摸;一旦你明白了,你就再也不会以同样的方式看待代码挑战了。
任何在单个或多个连续对象上执行相同逻辑的任务都是过程式编程的完美候选者。当你使用 for 和 foreach 循环调试数组、列表和字典时,你已经进行了一点点过程式编程。每次这些循环语句执行时,你都会对 Debug.Log() 执行相同的调用,按顺序遍历每个项目。现在的想法是利用这项技能来获得更有用的结果。
过程式编程最常见的一种用途是将一个集合中的项目添加到另一个集合中,通常在过程中修改它们。这对于我们的目的来说非常适用,因为我们想引用 Patrol_Route 父对象中的每个子对象并将它们存储在列表中。
引用巡逻位置
现在我们已经了解了过程式编程的基础,是时候获取我们的巡逻位置并将它们分配给一个可用的列表了:
-
将以下代码添加到
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 ... } } -
选择
Enemy,并将 Hierarchy 窗口中的Patrol_Route对象拖动到EnemyBehavior中的 Patrol Route 变量上:![img/B17573_09_07.png]()
图 9.7:将 Patrol_Route 拖动到敌人脚本中
-
点击 Inspector 窗口中 Locations 变量旁边的箭头图标,并运行游戏以查看列表填充:
![img/B17573_09_08.png]()
图 9.8:测试过程式编程
让我们分解一下代码:
-
首先,它声明了一个用于存储
PatrolRoute空父 GameObject 的变量。 -
然后,它声明了一个
List变量来存储PatrolRoute中的所有子Transform组件。 -
之后,它使用
Start()在游戏开始时调用InitializePatrolRoute()方法。 -
接下来,它创建了一个名为
InitializePatrolRoute()的私有实用方法,以过程式地填充Locations中的Transform值:- 记住,如果不包含访问修饰符,变量和方法默认为
private。
- 记住,如果不包含访问修饰符,变量和方法默认为
-
然后,我们使用
foreach语句遍历PatrolRoute中的每个子 GameObject 并引用其 Transform 组件:- 每个 Transform 组件都被捕获在
foreach循环中声明的局部child变量中。
- 每个 Transform 组件都被捕获在
-
最后,我们在遍历
PatrolRoute中的子对象时,使用Add()方法将每个连续的childTransform组件添加到位置列表中:- 这样,无论我们在Hierarchy窗口中做出什么更改,
Locations都将始终填充PatrolRoute父对象下的所有child对象。
- 这样,无论我们在Hierarchy窗口中做出什么更改,
虽然我们可以通过直接从Hierarchy窗口拖放每个位置 GameObject 到Inspector窗口来将每个位置 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 ...
}
}
让我们分解一下代码:
-
首先,它添加了
UnityEngine.AIusing指令,这样EnemyBehavior就可以访问 Unity 的导航类,在这种情况下是NavMeshAgent。 -
然后,它声明一个变量来跟踪敌人当前正在走向哪个巡逻位置。由于
List项是零索引的,我们可以让敌人预制体按Locations中存储的顺序在巡逻点之间移动。 -
接下来,它声明一个变量来存储附加到 Enemy GameObject 的 NavMeshAgent 组件。这是
private的,因为其他类不应该能够访问或修改它。 -
之后,它使用
GetComponent()来查找并返回附加到代理的 NavMeshAgent 组件。 -
然后,它在
Start()上调用MoveToNextPatrolLocation()方法。 -
最后,它声明
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 ...
}
让我们分解一下代码:
-
首先,它声明了
Update()方法并添加了一个if语句来检查两个不同的条件是否为真:-
remainingDistance返回 NavMeshAgent 组件当前距离其设定目的地的距离,所以我们在检查它是否小于 0.2。 -
pathPending返回一个true或false布尔值,具体取决于 Unity 是否正在为 NavMeshAgent 组件计算路径。
-
-
如果
_agent非常接近其目的地,并且没有其他路径正在计算,则if语句返回true并调用MoveToNextPatrolLocation()。 -
在这里,我们添加了一个
if语句,以确保在执行MoveToNextPatrolLocation()中的其余代码之前,Locations不为空:-
如果
Locations为空,我们使用return关键字退出方法而不继续执行。这被称为防御性编程,并且,与重构相结合,这是你在向更高级的 C#主题迈进时必须具备的一项基本技能。我们将在本章末尾考虑重构。
-
-
然后,我们将
_locationIndex设置为它的当前值,+1,然后是Locations.Count的取模(%):-
这将使索引从 0 增加到 4,然后重新开始从 0 开始,这样我们的敌人预制体就可以在连续的路径上移动。
-
取模运算符返回两个值相除的余数——当结果为整数时,2 除以 4 的余数为 2,所以 2 % 4 = 2。同样,4 除以 4 没有余数,所以 4 % 4 = 0。
-
将索引除以集合中最大项目数是一种快速找到下一个项目的方法。如果你对取模运算符不太熟悉,请回顾第二章,编程的基石。
我们现在需要检查敌人是否在每一帧的Update()中向其设定的巡逻位置移动;当它接近时,MoveToNextPatrolLocation()被触发,这将增加_locationIndex并将下一个巡逻点设置为目的地。
如果你将场景视图拖到控制台窗口旁边,如图下所示,并播放,你可以看到敌人预制体在关卡角落处连续循环行走:

图 9.9:测试敌人巡逻路线
敌人现在沿着地图外部的巡逻路线移动,但它不会在预设范围内寻找玩家并攻击。你将在下一节中使用 NavAgent 组件来完成这个动作。
敌人游戏机制
现在我们敌人的巡逻路线是连续的,是时候给它一些自己的交互机制了;如果我们让它无动于衷地四处走动,那么风险和回报都不会很大。
寻找并摧毁:改变代理的目标
在本节中,我们将关注当玩家太近时切换敌人 NavMeshAgent 组件的目标,并在发生碰撞时造成伤害。当敌人成功降低玩家的生命值时,它将返回到其巡逻路线,直到下一次与玩家的遭遇。
然而,我们不会让我们的玩家无助;我们还会添加代码来跟踪敌人生命值,检测当敌人被玩家的子弹成功击中时,以及当敌人需要被摧毁时。
现在敌人预制件正在巡逻移动,我们需要获取玩家的位置引用并更改 NavMeshAgent 的目标,如果它太近的话。
-
将以下代码添加到
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 ... } }
让我们分解一下代码:
-
首先,它声明了一个
public变量来保存Player胶囊的Transform值。 -
然后,我们使用
GameObject.Find("Player")来返回场景中玩家对象的引用:- 在同一行中添加
.transform直接引用对象的Transform值。
- 在同一行中添加
-
最后,在
OnTriggerEnter()中,我们将_agent.destination设置为玩家的Vector3位置,每当玩家进入我们之前使用 Collider 组件设置的敌人攻击区域时。
如果你现在玩游戏并且离巡逻的敌人太近,你会看到它从其路径上断开并直接向你冲来。一旦它到达玩家,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****;**
**}**
**}**
}
让我们分解一下代码:
-
首先,它声明了一个
private变量来保存场景中GameBehavior实例的引用。 -
然后,它找到并返回场景中
Game Manager对象附加的GameBehavior脚本:- 在
GameObject.Find()的同一行上使用GetComponent()是一种常见的减少不必要的代码行数的方法。
- 在
-
由于我们的玩家是被碰撞的对象,因此在
PlayerBehavior中声明OnCollisionEnter()是有意义的。 -
接下来,我们检查碰撞对象的名称;如果是敌人预制件,我们执行
if语句的主体。 -
最后,我们使用
_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!"****);**
**}**
**}**
}
让我们分解一下代码:
-
首先,它声明了一个名为
_lives的private int变量,以及一个名为EnemyLives的public辅助变量。这将使我们能够控制如何引用和设置EnemyLives,就像在GameBehavior中一样。 -
然后,我们将
get属性设置为始终返回_lives。 -
接下来,我们使用
private set将EnemyLives的新值分配给_lives,以保持它们同步。我们之前没有见过
private get或set,但它们可以有它们的访问修饰符,就像任何其他可执行代码一样。将get或set声明为private意味着只有父类可以访问它们的功能。 -
然后,我们添加一个
if语句来检查_lives是否小于或等于 0,这意味着敌人应该死了:- 当这种情况发生时,我们销毁
EnemyGameObject 并在控制台打印出一条消息。
- 当这种情况发生时,我们销毁
-
由于
Enemy是子弹击中的对象,因此在EnemyBehavior中包含OnCollisionEnter()检查这些碰撞是有意义的。 -
最后,如果碰撞对象的名称与子弹克隆对象匹配,我们将
EnemyLives减1并打印出另一条消息。
注意,我们正在检查的名称是Bullet(Clone),尽管我们的子弹 Prefab 命名为Bullet。这是因为 Unity 会将(Clone)后缀添加到使用Instantiate()方法创建的任何对象上,这是我们射击逻辑中创建它们的方式。
你也可以检查 GameObject 的标签,但由于这是一个 Unity 特定的功能,我们将保持代码不变,并使用纯 C#进行操作。
现在,当敌人试图夺走玩家的一条生命时,玩家可以通过射击敌人三次并摧毁它来进行反击。再次,我们使用get和set属性来处理附加逻辑,这证明是一个灵活且可扩展的解决方案。完成这些后,你的最终任务是更新游戏管理器以包含失败条件。
更新游戏管理器
要完全实现失败条件,我们需要更新管理器类:
-
打开
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."****;** **}** } } } -
在层次结构窗口中,右键单击胜利条件,选择复制,并将其命名为失败条件:
- 点击失败条件左侧的箭头以展开它,选择文本对象,并将其文本更改为你输了...
-
在层次结构窗口中选择Game_Manager,并将失败条件拖放到游戏行为(脚本)组件中的失败按钮槽位中!![img/B17573_09_11.png]
图 9.10:在检查器面板中完成文本和按钮变量后的游戏行为脚本
让我们分解一下代码:
-
首先,我们声明一个新按钮,当玩家输掉游戏时我们将显示它。
-
然后,我们添加一个
if语句来检查_playerHP是否低于0:-
如果它是
true,则ProgessText和Time.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);
}
}
}
让我们分解一下代码:
-
我们声明了一个名为
UpdateScene的新方法,它接受一个字符串参数,我们希望将其分配给ProgressText,并将Time.timeScale设置为0。 -
我们删除了第一个重复代码的实例,并使用我们的新方法在游戏胜利时更新场景。
-
我们删除了第二个重复代码的实例,并在游戏失败时更新场景。
如果你找对了地方,总还有更多可以重构的地方。
摘要
到此为止,我们的敌人和玩家交互已经完成。我们可以造成伤害,也可以承受伤害,失去生命,并反击,同时更新屏幕上的 GUI。我们的敌人使用 Unity 的导航系统在竞技场周围行走,并在达到玩家指定范围内时切换到攻击模式。每个 GameObject 负责其行为、内部逻辑和对象碰撞,而游戏管理器则跟踪控制游戏状态的变量。最后,我们学习了简单的过程式编程以及当重复指令被抽象到方法中时代码可以多么简洁。
到目前为止,你应该感到一种成就感,尤其是如果你是作为一个完全的初学者开始这本书的。在构建一个可工作的游戏的同时掌握一门新的编程语言并非易事。在下一章中,你将接触到 C# 的一些中级主题,包括新的类型修饰符、方法重载、接口和类扩展。
突击测验 - 人工智能与导航
-
在 Unity 场景中如何创建 NavMesh 组件?
-
哪个组件将 GameObject 识别为 NavMesh?
-
在一个或多个连续对象上执行相同的逻辑是哪种编程技术的例子?
-
DRY 这个缩写代表什么?
加入我们的 Discord!
与其他用户、Unity/C# 专家和哈里森·费罗尼一起阅读这本书。提问,为其他读者提供解决方案,通过 问我任何问题 会话与作者聊天等等。
现在加入我们!

第十章:回顾类型、方法和类
现在您已经用 Unity 内置类编程了游戏机制和交互,是时候扩展我们的核心 C#知识,并专注于我们已奠定基础的中级应用。我们将回顾老朋友——变量、类型、方法和类,但我们将针对它们的深入应用和相关用例。我们将讨论的许多主题在当前状态下的英雄降世中不适用,因此一些示例将是独立的,而不是直接应用于游戏原型。
我将向您提供大量新信息,所以如果您在任何时候感到不知所措,请不要犹豫,回到前几章以巩固这些基础。我们还将利用本章摆脱游戏机制和 Unity 特有的功能,专注于以下主题:
-
中级修饰符
-
方法重载
-
使用
out和ref参数 -
与接口一起工作
-
抽象类和重写
-
扩展类功能
-
命名空间冲突
-
类型别名
让我们开始吧!
访问修饰符
尽管我们已经习惯了将公共和私有访问修饰符与我们的变量声明配对,就像我们在玩家健康和收集到的物品上所做的那样,但仍然有一长串的修饰符关键字我们没有见过。我们无法在本章中详细介绍每一个,但我们将关注的五个将有助于您进一步理解 C#语言,并提升您的编程技能。
本节将介绍以下列表中的前三个修饰符,而剩下的两个将在中级 OOP部分稍后讨论:
-
const -
readonly -
static -
abstract -
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()方法中。
使用静态关键字
我们已经讨论过如何从类蓝图创建对象或实例,以及所有属性和方法都属于特定的实例,就像我们在第一个Character类实例中所做的那样。虽然这对于面向对象的功能来说很棒,但并非所有类都需要实例化,并非所有属性都需要属于特定的实例。然而,静态类是密封的,这意味着它们不能用于类继承。
工具方法正是这种情况的一个好例子,在这种情况下,我们不一定关心实例化特定的Utility类实例,因为它的所有方法都不会依赖于特定的对象。你的任务是创建这样一个工具方法在一个新的脚本中。
让我们创建一个新的类来保存我们未来的一些方法,这些方法涉及原始计算或与游戏玩法无关的重复逻辑:
-
在
Scripts文件夹中创建一个新的 C#脚本,并将其命名为Utilities。 -
打开它并添加以下代码:
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; } } -
从
GameBehavior中的RestartLevel()删除代码,而是用以下代码调用新的utility方法:// 5 public void RestartScene() { Utilities.RestartLevel(); }
让我们分解一下代码:
-
首先,它添加了
using SceneManagement指令,以便我们可以访问LoadScene()方法。 -
然后,它将
Utilities声明为一个公共的static类,它不继承自MonoBehavior,因为我们不需要它在游戏场景中。 -
接下来,它创建了一个公共的
static变量来保存玩家死亡并重新开始游戏的次数。 -
然后,它声明了一个公共的
static方法来保存我们的关卡重启逻辑,这个逻辑目前硬编码在GameBehavior中。 -
最后,我们的
GameBehavior更新在按下胜利或失败按钮时从静态的Utilities类调用RestartLevel()。注意,我们不需要Utilities类的实例来调用该方法,因为它本身就是静态的——它只是点符号。
我们现在已经将重启逻辑从GameBehavior中提取出来,并将其放入其静态类中,这使得它在代码库中更容易重用。将其标记为static也将确保我们在使用其类成员之前永远不需要创建或管理Utilities类的实例。
非静态类可以拥有静态和非静态的属性和方法。然而,如果一个类被标记为静态,那么所有的属性和方法都必须遵循同样的规则。
这就结束了我们对变量和类型的第二次访问,这将使你能够在管理更大、更复杂的项目时构建自己的工具集。现在,是时候继续到方法和它们的中间能力了,这包括方法重载和ref和out参数。
回顾方法
自从我们在第三章,深入变量、类型和方法中学习了如何使用方法以来,方法一直是我们的代码的重要组成部分,但我们还没有涵盖两个中间使用案例:方法重载和使用ref和out参数关键字。
方法重载
方法重载这个术语指的是创建多个具有相同名称但具有不同签名的函数。方法的签名由其名称和参数组成,这是 C#编译器识别它的方式。以下是一个方法的例子:
public bool AttackEnemy(int damage) {}
AttackEnemy()方法的签名如下所示:
AttackEnemy(int)
现在我们知道了AttackEnemy()函数的签名,可以通过改变参数数量或参数类型本身来对其进行重载,同时保持其名称不变。这在你需要为特定操作提供多个选项时提供了额外的灵活性。
Utilities中的RestartLevel()方法是一个很好的例子,说明了方法重载如何派上用场。目前,RestartLevel()只重新启动当前关卡,但如果我们扩展游戏使其包含多个场景会发生什么?我们可以重构RestartLevel()以接受参数,但这通常会导致代码膨胀且难以理解。
RestartLevel()方法再次是一个测试我们新知识的良好候选者。你的任务是重载它以接受不同的参数。
让我们添加一个RestartLevel()的重载版本:
-
打开
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****;** **}** } -
打开
GameBehavior并更新对Utilities.RestartLevel()方法的调用,如下所示:// 4 public void RestartScene() { Utilities.RestartLevel(0); }
让我们分析一下代码:
-
首先,它声明了一个重载版本的
RestartLevel()方法,该方法接受一个int类型的参数并返回一个bool类型的值。 -
然后,它调用
LoadScene()并传入sceneIndex参数,而不是手动硬编码该值。 -
然后,在新场景加载并重置
timeScale属性后,它返回true。 -
最后,我们的
GameBehavior更新调用重载的RestartLevel()方法,并将0作为sceneIndex传入。重载方法会被 Visual Studio 自动检测并以数字形式显示,如下所示:![]()
图 10.1:Visual Studio 中的多个方法重载
RestartLevel()方法的功能现在可以更加定制化,并可以应对你可能以后需要的额外情况。在这种情况下,它是从我们选择的任何场景重新启动游戏。
方法重载不仅限于静态方法——这只是为了与前面的例子保持一致。任何方法都可以重载,只要其签名与原始签名不同。
接下来,我们将介绍两个可以让你将方法游戏提升到全新水平的话题——ref和out参数。
ref参数
当我们在第五章中讨论类和结构体时,使用类、结构体和面向对象编程,我们发现并不是所有对象都是通过相同的方式传递的:值类型是通过复制传递的,而引用类型是通过引用传递的。然而,我们没有讨论当对象或值作为参数传递给方法时是如何使用的。
默认情况下,所有参数都是通过值传递的,这意味着传递给方法的变量将不会受到方法体内对其值所做的任何更改的影响。这保护我们在使用它们作为方法参数时不会对现有变量做出不希望的改变。虽然这适用于大多数情况,但也有一些情况下,你希望通过引用传递方法参数,以便它可以被更新,并且这些更改会在原始变量中得到反映。在参数声明前加上ref或out关键字将标记该参数为引用。
在使用ref关键字时,以下是一些需要记住的关键点:
-
参数在传递给方法之前必须进行初始化。
-
你不需要在方法结束时初始化或分配引用参数的值。
-
具有获取或设置访问器的属性不能用作
ref或out参数。
让我们通过添加一些逻辑来跟踪玩家重启游戏次数来尝试一下。
让我们创建一个方法来更新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;
}
}
让我们分解一下代码:
-
首先,它声明了一个新的
static方法,该方法返回一个string并接受一个通过引用传递的int。 -
然后,它直接更新引用参数,将其值增加
1,并返回一个包含新值的字符串。 -
最后,它在将
PlayerDeaths变量传递给UpdateDeathCount()之前和之后,在RestartLevel(int sceneIndex)中对其进行调试。我们还把从UpdateDeathCount()返回的字符串值存储在message变量中,并将其打印出来。
如果你玩游戏并输了,调试日志将显示在UpdateDeathCount()函数中PlayerDeaths增加了 1,因为它是通过引用传递而不是通过值传递:

图 10.2:引用参数的示例输出
为了清晰起见,我们可以在没有ref参数的情况下更新玩家死亡计数,因为UpdateDeathCount()和PlayerDeaths在同一个脚本中。然而,如果不是这种情况,并且你想要相同的功能,ref参数非常有用。
我们在这个例子中使用ref关键字是为了说明,但我们也可以在UpdateDeathCount()内部直接更新PlayerDeaths,或者添加逻辑到RestartLevel()中,以便仅在重启是由于失败时才触发UpdateDeathCount()。
现在我们知道了如何在项目中使用ref参数,让我们来看看out参数以及它如何服务于稍微不同的目的。
输出参数
out关键字与ref关键字执行相同的工作,但有不同的规则,这意味着它们是类似工具,但它们不是可互换的——每个都有自己的用例:
-
参数在传递到方法之前不需要初始化。
-
在返回之前,引用参数值需要在调用方法中初始化或分配。
例如,我们可以在UpdateDeathCount()中将ref替换为out,只要我们在从方法返回之前初始化或分配了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#语言背后的工作原理至关重要。棘手的部分在于,就 OOP 和设计你的对象而言,类和结构体本身并不是终点。它们始终是代码的构建块,但类限于单继承,这意味着它们只能有一个父类或超类,而结构体则不能继承。所以,你现在应该问自己的问题是简单的:"我如何从相同的蓝图创建对象,并根据特定场景让它们执行不同的操作?"
为了回答这个问题,我们将学习接口、抽象类和类扩展。
接口
将功能组合在一起的一种方法是通过接口。像类一样,接口是数据和行为的蓝图,但有一个重要的区别:它们不能有任何实际的实现逻辑或存储值。相反,它们包含实现蓝图,而填充接口中概述的值和方法的责任则由采用类或结构体承担。你可以使用接口与类和结构体一起使用,并且单个类或结构体可以采用的接口数量没有上限。
记住,一个类只能有一个父类,结构体根本不能进行子类化。将功能分解到接口中让你可以像积木一样构建类,选择你希望它们如何表现,就像从菜单中选择食物一样。这将大大提高你的代码库的效率,摆脱长而混乱的子类化层次结构。
例如,如果我们想让我们的敌人在我们玩家近距离时能够射击回来怎么办?我们可以创建一个父类,玩家和敌人都可以从中派生出来,这样它们就会基于相同的蓝图。然而,这种方法的问题在于,敌人和玩家不一定会有相同的行为和数据。
处理这个问题的更有效的方法是定义一个接口,其中包含可射击对象需要执行的操作的蓝图,然后让敌人和玩家都采用它。这样,它们就有自由独立并表现出不同的行为,同时仍然共享共同的功能。
将射击机制重构为接口是一个挑战,我将留给你们去完成,但我们仍然需要知道如何在代码中创建和采用接口。对于这个例子,我们将创建一个接口,所有管理脚本可能都需要实现以共享一个共同的结构。
在Scripts文件夹中创建一个新的 C#脚本,命名为IManager,并按照以下方式更新其代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 1
public interface IManager
{
// 2
string State { get; set; }
// 3
void Initialize();
}
让我们分解一下代码:
-
首先,它使用
interface关键字声明了一个名为IManager的公共接口。 -
然后,它向
IManager添加了一个名为State的string变量,并提供了get和set访问器来保存采用类的当前状态。所有接口属性至少需要一个 get 访问器才能编译,但如果需要,也可以同时有 get 和 set 访问器。
-
最后,它定义了一个名为
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);**
**}**
}
让我们分解一下代码:
-
首先,它声明
GameBehavior采用IManager接口,使用逗号和其名称,就像在子类化中一样。 -
然后,它添加了一个私有变量,我们将使用它来支持从
IManager中实现的公共State值。 -
接下来,它添加了在
IManager中声明的公共State变量,并使用_state作为其私有后置变量。 -
之后,它声明了
Start()方法并调用了Initialize()方法。 -
最后,它声明了
IManager中声明的Initialize()方法,其实现将设置并打印出公共State变量。
有了这个,我们指定了GameBehavior采用IManager接口并实现了其State和Initialize()成员,如下所示:

图 10.3:接口的示例输出
最好的部分是,实现是针对GameBehavior特定的;如果我们有另一个管理器类,我们可以做同样的事情,但使用不同的逻辑。为了好玩,让我们设置一个新的管理器脚本来测试这一点:
-
在项目中,在脚本文件夹内右键单击,然后选择创建 | C# 脚本,然后将其命名为
DataManager。 -
使用以下代码更新新脚本并采用
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); } } -
图 10.4:附加到 GameObject 的数据管理器脚本
-
然后点击播放:
![]()
图 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();
}
让我们分解一下代码:
-
首先,它使用
abstract关键字声明了一个名为BaseManager的新类。 -
然后,它创建了两个变量:一个名为
_state的受保护字符串,只能由继承自BaseManager的类访问。我们还为_state设置了一个初始值,这是我们在接口中无法做到的。- 我们还有一个名为
State的抽象字符串,它具有get和set访问器,由子类实现。
- 我们还有一个名为
-
最后,它添加了一个
abstract方法Initialize(),也需要在子类中实现。
通过这样做,我们创建了一个与接口做同样事情的抽象类。在这个设置中,BaseManager与IManager有相同的蓝图,允许任何子类使用override关键字定义它们对state和Initialize()的实现:
// 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);
}
}
如果我们分解前面的代码,我们可以看到以下内容:
-
首先,它声明了一个名为
CombatManager的新类,该类继承自BaseManager抽象类。 -
然后,它使用
override关键字添加了State变量实现。 -
最后,它再次使用
override关键字添加了Initialize()方法实现,并设置了受保护的_state变量。
即使这只是接口和抽象类的一小部分,它们的可能性也应该在你的编程大脑中跳跃。接口将允许你在无关对象之间传播和共享功能片段,当涉及到你的代码时,就像积木一样进行组装。
另一方面,抽象类将允许你保持面向对象编程的单继承结构,同时将类的实现与其蓝图分离。这些方法甚至可以混合使用,因为抽象类可以像非抽象类一样采用接口。
就像处理复杂主题时一样,你的第一步应该是查看文档。请查看docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/abstract和docs.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);
}
}
}
让我们分解一下代码:
-
首先,它声明了一个名为
CustomExtensions的命名空间来存放所有的扩展类和方法。 -
然后,它为了组织目的声明了一个名为
StringExtensions的static类;每个类扩展组都应该遵循这种设置。 -
接下来,它在
StringExtensions类中添加了一个名为FancyDebug的static方法:-
第一个参数,
this string str,将方法标记为扩展方法。 -
str参数将保留对FancyDebug()被调用时的实际文本值的引用;我们可以在方法体内部操作str作为所有字符串字面量的替身。
-
-
最后,每当执行
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);
}
}
让我们分解一下代码:
-
首先,它在文件顶部添加了
CustomExtensions命名空间和一个using指令。 -
然后,它在
Initialize()方法中使用点符号在_state字符串变量上调用FancyDebug,以打印出其值中单个字符的数量。
使用 FancyDebug() 扩展整个 string 类意味着任何字符串变量都可以访问它。由于第一个扩展方法参数有一个对 FancyDebug() 被调用时的 string 值的引用,其长度将正确打印出来,如下所示:

图 10.6:自定义扩展的示例输出
使用相同的语法,自定义类也可以被扩展,但如果您控制该类,通常更常见的是直接在类中添加额外的功能。
在本章中,我们将探讨最后一个主题,即命名空间,我们之前在书中简要介绍过。在下一节中,你将了解命名空间在 C#中扮演的更大角色以及如何创建你的类型别名。
命名空间冲突和类型别名
随着你的应用程序变得更加复杂,你将开始将代码分成命名空间,确保你能够控制其访问的位置和时间。你还将使用第三方软件工具和插件来节省时间,实现别人已经提供的功能。这两种情况都表明你在编程知识方面正在进步,但它们也可能导致命名空间冲突。
命名空间冲突发生在有两个或更多具有相同名称的类或类型时,这种情况比你想的要多。
良好的命名习惯往往会产生相似的结果,在你意识到之前,你可能会处理多个名为Error或Extension的类,而 Visual Studio 会抛出错误。幸运的是,C#对这些情况有一个简单的解决方案:类型别名。
定义类型别名让你可以明确选择在给定类中要使用哪个冲突的类型,或者为冗长的现有类型创建一个更用户友好的名称。类型别名通过在类文件顶部添加一个using指令,然后是别名名称和分配的类型来添加:
using AliasName = type;
例如,如果我们想创建一个类型别名来引用现有的Int64类型,我们可以这样写:
using CustomInt = System.Int64;
现在,由于CustomInt是System.Int64类型的类型别名,编译器会将其视为Int64,让我们可以像使用任何其他类型一样使用它:
public CustomInt PlayerHealth = 100;
你可以使用类型别名与你的自定义类型一起使用,或者使用具有相同语法的现有类型,只要它们在脚本文件顶部与using指令一起声明。
有关using关键字和类型别名的更多信息,请查看 C#文档中的docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive。
摘要
在掌握了新的修饰符、方法重载、类扩展和面向对象技能之后,我们离 C#之旅的终点只有一步之遥。记住,这些中级主题旨在让你思考你在本书中收集到的知识的更复杂应用;不要认为你在这章中学到的就是这些概念的全部。把它当作一个起点,并从这里继续前进。
在下一章中,我们将讨论泛型编程的基础,获得一些关于委托和事件的实践经验,并以异常处理的概述结束。
突击测验 – 提升等级
-
哪个关键字会将变量标记为不可修改但需要初始值?
-
你会如何创建一个重载的基类方法?
-
类和接口之间主要的区别是什么?
-
你会如何解决你其中一个类中的命名空间冲突?
加入我们的 Discord 社群!
与其他用户、Unity/C# 专家以及哈里森·费罗内一起阅读这本书。提问,为其他读者提供解决方案,通过 问我任何问题 会话与作者聊天,以及更多。
立即加入!

第十一章:介绍栈、队列和 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 上了解更多关于微软的建议。
你的下一个任务是创建自己的栈,并亲身体验使用其类方法进行操作。
为了测试这一点,你将修改 英雄降生 中现有的物品收集逻辑,使用栈来存储可以收集的可能战利品。在这里,栈工作得很好,因为我们不需要担心为获取战利品项提供索引,我们只需每次获取最后添加的一个:
-
打开
GameBehavior.cs并添加一个名为LootStack的新栈变量:**// 1** public Stack<string> LootStack = new Stack<string>(); -
使用以下代码更新
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"****);** } -
在脚本底部添加一个新方法以打印出栈信息:
**// 3** public void PrintLootReport() { Debug.LogFormat("There are {0} random loot items waiting for you!", LootStack.Count); } -
打开
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();** } }
拆解它,它执行以下操作:
-
创建一个空的栈,其元素类型为字符串,以存储我们将添加的战利品项
-
使用
Push方法向栈中添加字符串元素(即战利品项名称),每次增加其大小 -
每次调用
PrintLootReport方法时打印出栈计数 -
每当玩家收集到一个物品时,在
OnCollisionEnter中调用PrintLootReport,这是我们之前章节中通过 Collider 组件设置的
在 Unity 中播放,收集一个物品预制体,并查看打印出的新战利品报告。

图 11.1:使用栈的输出
现在你有一个包含所有游戏战利品的可工作的栈,你就可以通过使用栈类的 Pop 和 Peek 方法来实验如何访问物品了。
弹出和查看
我们已经讨论了栈如何使用 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);
}
下面是发生的事情:
-
在
LootStack上调用Pop方法,移除栈中的下一个元素并将其存储。记住,栈元素按照 LIFO 模型排序。 -
在
LootStack上调用Peek方法,并存储栈中的下一个元素而不移除它。 -
添加一个新的调试日志以打印出被弹出的物品和栈中的下一个物品。
您可以从控制台看到,最后添加到栈中的物品Mythril Bracer首先被弹出,接着是一对翼靴,它被查看但没有被移除。您还可以看到LootStack还剩下四个元素可以访问:

图 11.2:对栈进行弹出和查看的输出
我们的玩家现在可以以它们被添加到栈中的相反顺序拾取战利品物品。例如,第一个拾取的物品将始终是Mythril Bracer,接着是一对翼靴,然后是金钥匙,依此类推。
现在您已经知道了如何创建、添加和查询栈中的元素,我们可以继续讨论一些您可以通过栈类访问的常见方法。
常见方法
本节中的每个方法仅用于示例目的,它们不包括在我们的游戏中,因为我们不需要这些功能。
首先,您可以使用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 上了解更多关于微软推荐的内容。
一个空队列本身并没有什么用处;你希望能够在需要的时候随时添加、删除和查看其元素,这正是下一节的主题。
添加、删除和查看
由于前几节中的 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。这种集合与我们遇到的其他任何集合类型都大不相同:它不能存储重复值,并且未排序,这意味着其元素没有按任何顺序排列。将 HashSet 视为只有键而没有键值对的字典。
它们可以非常快速地执行集合操作和元素查找,我们将在本节末尾探讨,并且最适合元素顺序和唯一性是首要任务的情况。
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"
注意,我在每个操作中使用了两个示例集合的新实例,因为每次操作执行后当前集合都会被修改。如果您在整个过程中一直使用相同的集合,您将得到不同的结果。
现在你已经学会了如何使用 HashSet 进行快速数学运算,是时候结束本章,巩固我们所学的内容了。
中级集合汇总
在你继续到摘要和下一章之前,让我们巩固一下我们刚刚学到的关键点。那些与实际游戏原型没有 1 对 1 关系的话题有时需要一些额外的关注。
在这个阶段,你肯定在问自己一个问题:为什么要在可以使用列表的情况下使用这些其他的集合类型?这是一个非常合理的问题。简单的答案是,当在正确的情况下使用时,栈、队列和 HashSet 比列表提供更好的性能。例如,当你需要以特定顺序存储项目并以特定顺序访问它们时,栈会比列表更有效率。
更复杂的答案是,使用不同的集合类型强制了你的代码如何与它们及其元素交互。这是良好代码设计的标志,因为它消除了你对如何使用集合的任何歧义。当你在不知道要执行什么函数时,列表无处不在,事情会变得混乱。
就像我们在本书中学到的所有内容一样,始终最好使用适合当前任务的正确工具。更重要的是,你需要有不同类型的工具可供选择。
摘要
恭喜你,你几乎到达了终点!在本章中,你学习了三种新的集合类型,以及它们在不同情况下的使用方法。
如果你想要以它们被添加的相反顺序访问你的集合元素,栈是不错的选择;如果你想要以顺序访问你的元素,队列是你的选择;两者都是临时存储的理想选择。这些集合类型与列表或数组之间的重要区别在于它们可以通过弹出和查看操作进行访问。最后,你学习了强大的 HashSet 及其基于性能的数学集合操作。在需要处理唯一值并在大型集合上执行添加、比较或减法操作的情况下,这些是关键。
在下一章中,随着你接近本书的结尾,你将更深入地了解 C#的中级世界,包括委托、泛型等。即使你已经学到了所有这些,最后一页仍然是另一段旅程的开始。
突击测验 - 中级集合
-
哪种集合类型使用 LIFO 模型存储其元素?
-
哪种方法让你能够在不删除它的情况下查询栈中的下一个元素?
-
栈和队列可以存储
null值吗? -
你会如何从一个 HashSet 中减去另一个 HashSet?
加入我们的 Discord!
与其他用户、Unity/C#专家和哈里森·费罗尼一起阅读这本书。提出问题,为其他读者提供解决方案,通过问我任何问题会议与作者聊天等等。
立即加入!

第十二章:保存、加载和序列化数据
你玩过的每一款游戏都使用数据,无论是你的玩家统计数据、游戏进度,还是在线多人排行榜。你最喜欢的游戏也管理内部数据,这意味着程序员使用了硬编码的信息来构建关卡、跟踪敌人统计数据,并编写有用的工具。换句话说,数据无处不在。
在本章中,我们将从 C#和 Unity 如何处理你电脑上的文件系统开始,然后继续介绍读取、写入和序列化我们的游戏数据。我们的重点是处理你可能会遇到的最常见的三种数据格式:文本文件、XML 和 JSON。
到本章结束时,你将对你电脑的文件系统、数据格式和基本读写功能有一个基础的了解。这将是你构建游戏数据的基础,为你的玩家创造更加丰富和吸引人的体验。你也将处于一个很好的位置来思考哪些游戏数据足够重要,值得保存,以及你的 C#类和对象在不同数据格式中的样子。
在此过程中,我们将涵盖以下主题:
-
介绍文本、XML 和 JSON 格式
-
理解文件系统
-
处理不同的流类型
-
阅读和写入游戏数据
-
序列化对象
介绍数据格式
在编程中,数据可以采取不同的形式,但在你数据之旅的开始阶段,你应该熟悉以下三种格式:
-
文本,就是你现在正在阅读的内容
-
XML(可扩展标记语言),是一种编码文档信息的方式,使其对你和电脑都是可读的
-
JSON(JavaScript 对象表示法),是一种由属性值对和数组组成的人可读文本格式
这些数据格式各自都有其优势和劣势,以及在编程中的应用。例如,文本通常用于存储更简单、非层次结构化或嵌套的信息。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>
让我们分解上面的例子,以确保我们理解正确:
-
XML 文档以正在使用的版本开始
-
根元素使用名为
ArrayOfWeapon的开标签声明,它将包含所有元素项 -
一个名为
Weapon的开标签创建了一个武器项目 -
其子属性通过在单行上使用开闭标签添加,用于
name和damage -
武器项目关闭,并添加了两个更多武器项目
-
数组关闭,标志着文档的结束
好消息是,我们的应用程序不需要手动以这种格式写入数据。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类:
-
右键点击层次结构并选择创建空文件,然后命名为数据管理器:
![图片]()
图 12.1:数据管理器在层次结构中
-
在层次结构中选择数据管理器对象,并将我们在第十章“回顾类型、方法和类”中创建的
DataManager脚本从脚本文件夹拖放到检查器中:![图片]()
图 12.2:数据管理器在检查器中
-
打开
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());** } }
让我们分解一下代码:
-
首先,我们添加了
System.IO命名空间,其中包含我们与文件系统一起工作所需的所有类和方法。 -
我们将调用我们在下一步中创建的
FilesystemInfo方法。 -
我们创建了
FilesystemInfo方法来打印出一些文件系统属性。每个操作系统处理其文件系统路径的方式都不同——路径是目录或文件在字符串中的位置。在 Mac 上:-
路径由冒号(
:)分隔 -
目录由正斜杠(
/)分隔 -
当前目录路径是存储英雄诞生项目的地方
-
临时路径是文件系统临时文件夹的位置
-
如果你使用的是其他平台和操作系统,在处理文件系统之前,请确保自己检查Path和Directory方法。
运行游戏并查看输出:

图 12.3:数据管理器的控制台消息
Path和Directory类是我们将在以下部分构建的基础,我们将使用它们来存储数据。然而,它们都是大型类,所以我鼓励你在继续你的数据之旅时查看它们的文档。
你可以在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 ...
}
让我们分解我们的代码更新:
-
我们创建了一个私有变量来保存数据路径字符串
-
我们将数据路径字符串设置为应用程序的
persistentDataPath值,使用开放和闭合的斜杠添加了一个名为 Player_Data 的新文件夹名称,并打印出了完整的路径:- 重要的是要注意,
Application.persistentDataPath只能在MonoBehaviour方法(如Awake()、Start()、Update()等)中使用,并且游戏需要运行,Unity 才能返回一个有效的路径。![img/B17573_12_04.png]()
图 12.4:Unity 持久数据文件的文件路径
- 重要的是要注意,
由于我使用的是 Mac,我的持久数据文件夹嵌套在我的 /Users 文件夹中。如果你使用的是不同设备,请记住查看 docs.unity3d.com/ScriptReference/Application-persistentDataPath.html 以了解你的数据存储位置。
当你不在使用预定义的资产路径,如 Unity 的持久数据目录时,C# 中的 Path 类提供了一个方便的 Combine 方法来自动配置路径变量。Combine() 方法可以接受最多四个字符串作为输入参数,或者是一个表示路径组件的字符串数组。例如,你的 User 目录的路径可能看起来像这样:
var path = Path.Combine("/Users", "hferrone", "Chapter_12");
这解决了路径和目录中分隔字符以及前后斜杠的任何潜在跨平台问题。
现在我们有了存储数据的路径,让我们在文件系统中创建一个新的目录或文件夹。这将使我们能够安全地存储数据,并在游戏运行之间保持数据,而不是在临时存储中,那里数据会被删除或覆盖。
创建和删除目录
创建新的目录文件夹很简单——我们检查是否在相同的路径上已经存在具有相同名称的目录,如果没有,我们告诉 C#为我们创建它。每个人在处理文件和文件夹中的重复项都有自己的方法,所以在本章的其余部分,我们将重复大量的重复检查代码。
我仍然建议在现实世界的应用中遵循DRY(不要重复自己)原则;重复检查代码在这里只重复是为了使示例完整且易于理解:
-
将以下方法添加到
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!"); } -
在
Initialize()中调用新方法:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); **NewDirectory();** }
让我们分解我们所做的:
-
首先,我们使用上一步创建的路径检查目录文件夹是否已经存在
-
如果它已经被创建,我们在控制台中发送给自己一条消息,并使用
return关键字退出方法,不再继续 -
如果目录文件夹不存在,我们将数据路径传递给
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()函数中将NewDirectory()替换为DeleteDirectory()。
空的目录文件夹并不特别有用,所以让我们创建我们的第一个文本文件,并将其保存在我们的新位置。
创建、更新和删除文件
与文件一起工作与创建和删除目录相似,所以我们已经有了需要的所有基本构建块。为了确保我们不重复数据,我们将检查文件是否已经存在,如果没有,我们将在新的目录文件夹中创建一个新的文件。
在本节中,我们将使用File类,它包含许多有用的方法来帮助我们实现我们的功能。你可以在这里找到完整的列表:docs.microsoft.com/en-us/dotnet/api/system.io.file。
在我们开始处理文件之前,有一个重要的问题需要强调,那就是在添加文本之前需要打开文件,在完成操作后需要关闭文件。如果你没有关闭你正在程序中处理的文件,它将保持在程序的内存中。这不仅会消耗计算资源,用于你未积极编辑的内容,还可能创建潜在的内存泄漏。关于这些内容,我们将在本章后面进行更多介绍。
我们将为每个要执行的操作(创建、更新和删除)编写单独的方法。我们还将检查我们正在处理的文件是否存在,这在每种情况下都是重复的。我已经将本书的这一部分结构化,以便你可以牢固掌握每个程序。然而,在你掌握了基础知识之后,你可以绝对地将它们合并成更经济的方法。
按照以下步骤操作:
-
为新文本文件添加一个新的私有字符串路径,并在
Awake中设置其值:private string _dataPath; **private****string** **_textFile;** void Awake() { _dataPath = Application.persistentDataPath + "/Player_Data/"; Debug.Log(_dataPath); **_textFile = _dataPath +** **"Save_Data.txt"****;** } -
在
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!"); } -
在
Initialize()中调用新方法:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); **NewTextFile();** }
让我们分析我们的新代码:
-
我们检查文件是否已存在,如果存在,我们
return出方法以避免重复:- 值得注意的是,这种方法对于不会更改的新文件效果很好。我们将在下一项练习中介绍如何更新和覆盖文件中的数据。
-
我们使用
WriteAllText()方法,因为它将我们需要的所有操作都集成在一个方法中:-
使用我们的
_textFile路径创建了一个新文件 -
我们添加一个标题字符串,表示
<SAVE DATA>,并添加两个带有\n字符的新行 -
然后文件会自动为我们关闭
-
-
我们打印一条日志消息,让我们知道一切顺利
现在你玩游戏时,你将在控制台中看到调试日志,并在你的持久数据文件夹位置看到新的文本文件:

图 12.8:创建新文件的控制台消息

图 12.9:在桌面上创建的新文件
为了更新我们的新文本文件,我们将执行一系列类似的操作。知道何时开始新游戏总是很令人愉快,因此你的下一个任务是添加一个方法,将此信息写入我们的存档数据文件:
-
在
DataManager的顶部添加一个新的using指令:using System.Collections; using System.Collections.Generic; using UnityEngine; using System.IO; **using** **System;** -
在
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!"); } -
在
Initialize()中调用新方法:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); NewTextFile(); **UpdateTextFile();** }
让我们分析上述代码:
-
如果文件存在,我们不希望重复它,所以我们只需退出方法而不采取任何进一步的操作
-
如果文件确实存在,我们使用另一个名为
AppendAllText()的集成方法来添加游戏开始时间:-
此方法打开文件
-
它添加一个新行文本,该文本作为方法参数传入
-
它关闭文件
-
-
打印一条日志消息,让我们知道一切顺利
再次玩游戏,你将看到我们的控制台消息和文本文件中的新行,包含新游戏的日期和时间:

图 12.10:更新文本文件的控制台消息

图 12.11:文本文件数据已更新
为了读取我们新的文件数据,我们需要一个方法来获取所有文件文本并将其作为字符串返回给我们。幸运的是,File 类有方法可以做到这一点:
-
向
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)); } -
在
Initialize()中调用新方法,并将_textFile作为参数传入:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); NewTextFile(); UpdateTextFile(); **ReadFromFile(_textFile);** }
让我们分解一下新方法的代码:
-
我们创建了一个新方法,该方法接受一个字符串参数,用于指定我们想要读取的文件
-
如果文件不存在,则不需要采取任何操作,所以我们退出方法
-
我们使用
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压缩数据以便于存储和下载
在接下来的章节中,我们将学习如何管理流资源,使用名为 StreamReader 和 StreamWriter 的辅助类来创建、读取、更新和删除文件。你还将学习如何使用 XmlWriter 类更轻松地格式化 XML。
管理你的流资源
我们还没有讨论的一个重要主题是资源分配。这意味着你的代码中的一些进程会将计算能力和内存放在一种类似分期付款的计划中,你无法触及它。这些进程将等待你明确告诉程序或游戏关闭并返回分期付款的资源,这样你就能恢复到全功率状态。流就是这样一种进程,使用完毕后需要关闭。如果你没有正确关闭你的流,即使你不再使用,程序也会继续使用这些资源。
幸运的是,C#有一个名为IDisposable的方便接口,所有Stream类都实现了这个接口。这个接口只有一个方法,即Dispose(),它告诉流何时将资源归还给你。
你不必过于担心这个问题,因为我们将介绍一种自动确保你的流始终正确关闭的方法。资源管理只是理解良好的编程概念。
在本章的剩余部分,我们将使用FileStream,但我们将使用名为StreamWriter和StreamReader的便利类。这些类省略了手动将数据转换为字节的步骤,但仍然使用FileStream对象本身。
使用 StreamWriter 和 StreamReader
StreamWriter和StreamReader类都作为使用属于FileStream的对象来写入和读取特定文件的辅助工具。这些类非常有帮助,因为它们创建、打开并返回一个你可以使用的流,而无需编写大量的样板代码。我们之前讨论的示例代码对于小型数据文件来说是可以的,但如果你处理的是大型和复杂的数据对象,流就是最佳选择。
我们只需要知道我们想要写入或读取的文件名,然后我们就可以设置了。你的下一个任务是使用流将文本写入新文件:
-
为新的流文本文件添加一个新的私有字符串路径,并在
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"****;** } -
向
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!"); } -
删除或注释掉上一节中使用的
Initialize()方法,并添加我们的新代码:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); **WriteToStream(_streamingTextFile);** }
让我们分析上面代码中的新方法:
-
首先,我们检查文件是否存在
-
如果文件尚未创建,我们添加一个新的
StreamWriter实例,称为newStream,它使用CreateText()方法创建和打开新文件 -
一旦文件打开,我们使用
WriteLine()方法添加一个标题,关闭流,并打印出调试信息 -
如果文件已经存在,我们只想更新它,我们通过一个新的
StreamWriter实例使用AppendText()方法来获取我们的文件,这样我们的现有数据就不会被覆盖 -
最后,我们写入一行包含游戏数据的新行,关闭流,并打印出调试信息
![图片]()
图 12.14:使用流写入和更新文本的控制台消息
![图片]()
图 12.15:使用流创建和更新新文件
从流中读取几乎与我们在上一节中创建的 ReadFromFile() 方法完全相同。唯一的区别是我们将使用 StreamReader 实例来打开和读取信息。同样,当你处理大型数据文件或复杂对象时,你想要使用流而不是使用 File 类手动创建和写入文件:
-
向
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()); } -
在
Initialize()中调用新方法,并将_streamingTextFile作为参数传递:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); WriteToStream(_streamingTextFile); **ReadFromStream(_streamingTextFile);** }
让我们分解我们的新代码:
-
首先,我们检查文件是否存在,如果不存在,则打印控制台消息并退出方法
-
如果文件存在,我们创建一个新的
StreamReader实例,其名称是我们想要访问的文件,并使用ReadToEnd方法打印出整个内容!![图 12.16:控制台打印从流中读取的保存数据]()
图 12.16:控制台打印出从流中读取的保存数据
如您将开始注意到的那样,我们的大部分代码开始看起来很相似。唯一的区别是我们使用流类来执行实际的读写工作。然而,重要的是要记住不同的用例将决定你选择哪种路线。回顾本节的开头,了解每种流类型的不同之处。
到目前为止,我们已经介绍了使用文本文件的基本 CRUD(创建、读取、更新和删除)应用程序的功能。但在 C# 游戏 和应用程序中,您将使用不止一种数据格式。一旦您开始与数据库和您自己的复杂数据结构一起工作,您很可能会看到大量的 XML 和 JSON,这在效率或存储方面是文本无法比拟的。
在下一节中,我们将处理一些基本的 XML 数据,然后讨论管理流的一种更简单的方法。
创建一个 XMLWriter
有时候,你不仅要从文件中写入和读取普通文本。您的项目可能需要 XML 格式的文档,在这种情况下,您需要了解如何使用常规的 FileStream 来保存和加载 XML 数据。
将 XML 数据写入文件与我们在文本和流中做过的事情并没有太大的不同。唯一的区别是我们将显式创建一个 FileStream 并使用它来创建一个 XmlWriter 实例。将 XmlWriter 类想象成一个包装器,它接受我们的数据流,应用 XML 格式化,并将我们的信息作为 XML 文件输出。一旦我们有了这个,我们就可以使用 XmlWriter 类的方法来以正确的 XML 格式结构化文档,并关闭文件。
您的下一个任务是创建一个新 XML 文档的文件路径,并使用 DataManager 类添加将 XML 数据写入该文件的能力:
-
将高亮的
using指令添加到DataManager类的顶部:using System.Collections; using System.Collections.Generic; using UnityEngine; using System.IO; using System; **using** **System.Xml;** -
为新的 XML 文件添加一个新的私有字符串 path 并在
Awake()中设置其值:// ... No other variable changes needed ... **private****string** **_xmlLevelProgress;** void Awake() { // ... No other changes needed ... **_xmlLevelProgress = _dataPath +** **"Progress_Data.xml"****;** } -
在
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(); } } -
在
Initialize()中调用新方法,并将_xmlLevelProgress作为参数传递:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); **WriteToXML(_xmlLevelProgress);** }
让我们分解我们的 XML 编写方法:
-
首先,我们检查文件是否已经存在
-
如果文件不存在,我们使用我们创建的新路径变量创建一个新的
FileStream -
我们然后创建一个新的
XmlWriter实例,并将我们的新FileStream传递给它 -
接下来,我们使用
WriteStartDocument方法指定 XML 版本 1.0 -
然后我们调用
WriteStartElement方法来添加名为level_progress的开根元素标签 -
现在我们可以使用
WriteElementString方法将单个元素添加到我们的文档中,将level作为元素标签,使用for循环及其索引值i来指定层级编号 -
要关闭文档,我们使用
WriteEndElement方法添加一个关闭的level标签 -
最后,我们关闭写入器和流以释放我们一直在使用的流资源
如果你现在运行游戏,你会在我们的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 类的一个实例。每种武器都有自己的名称和伤害属性以及相关值,这被称为其状态。对象的状态是唯一的,这使得程序能够区分它们。
对象的状态还包括属性或字段,它们是引用类型。例如,如果我们有一个具有 Weapon 属性的 Character 类,在序列化和反序列化时,C# 仍然会识别武器的 name 和 damage 属性。在编程世界中,你可能听到具有引用属性的物体被称为对象图。
在我们深入之前,值得注意的是,如果你没有密切注意确保对象属性与文件中的数据匹配,序列化对象可能会变得很棘手。例如,如果你的类对象属性与正在反序列化的数据不匹配,序列化器将返回一个空对象。我们将在本章后面尝试将 C# 列表序列化为 JSON 时更详细地介绍这一点。
为了真正掌握这个,让我们将我们的 Weapon 示例转换为工作代码。
序列化和反序列化 XML
本章剩余的任务是将武器列表序列化和反序列化为 XML 和 JSON,XML 需要先进行!
-
在
DataManager类的顶部添加一个新的using指令:using System.Collections; using System.Collections.Generic; using UnityEngine; using System.IO; using System; using System.Xml; **using** **System.Xml.Serialization;** -
在
Weapon类中添加一个可序列化属性,以便 Unity 和 C# 知道该对象可以序列化:**[****Serializable****]** public struct Weapon { // ... No other changes needed ... } -
添加两个新变量,一个用于 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****),** **};** -
在
Awake中设置 XML 文件路径值:void Awake() { // ... No other changes needed ... **_xmlWeapons = _dataPath +** **"WeaponInventory.xml"****;** } -
在
DataManager类的底部添加一个新的方法:public void SerializeXML() { // 1 var xmlSerializer = new XmlSerializer(typeof(List<Weapon>)); // 2 using(FileStream stream = File.Create(_xmlWeapons)) { // 3 xmlSerializer.Serialize(stream, weaponInventory); } } -
在
Initialize中调用新方法:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); **SerializeXML();** }
让我们分解我们的新方法:
-
首先,我们创建一个
XmlSerializer实例,并传入我们将要转换的数据类型。在这种情况下,_weaponInventory是List<Weapon>类型,这是我们使用typeof运算符的地方:XmlSerializer类是另一个有用的格式化包装器,就像我们之前使用的XmlWriter类一样
-
然后,我们使用
_xmlWeapons文件路径创建一个FileStream,并用using代码块包装,以确保它被正确关闭。 -
最后,我们调用
Serialize()方法,并传入流以及我们想要转换的数据。
再次运行游戏,看看我们创建的新 XML 文档,而无需指定任何额外的格式!

图 12.20:武器库存文件中的 XML 输出
为了将我们的 XML 读回到武器列表中,我们设置了一切几乎完全相同,只是我们使用XmlSerializer类的Deserialize()方法代替:
-
将以下方法添加到
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); } } } } -
在
Initialize中调用新方法,并将_xmlWeapons作为参数传递:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); SerializeXML(); **DeserializeXML();** }
让我们分解一下deserialize()方法:
-
首先,我们检查文件是否存在
-
如果文件存在,我们创建一个
XmlSerializer对象,并指定我们将 XML 数据放回一个List<Weapon>对象中 -
然后,我们打开一个名为
_xmlWeapons的FileStream:- 我们使用
File.OpenRead()来指定我们想要打开文件进行读取,而不是写入
- 我们使用
-
接下来,我们创建一个变量来保存我们的反序列化武器列表:
- 在调用
Deserialize()之前,我们在前面明确地进行了List<Weapon>的强制类型转换,以确保从序列化器返回正确的类型
- 在调用
-
最后,我们使用
foreach循环在控制台打印出每件武器的名称和伤害值
当你再次运行游戏时,你会看到我们为从 XML 列表中反序列化的每一件武器在控制台得到一条消息。

图 12.21:反序列化 XML 的控制台输出
这就是我们处理 XML 数据所需做的全部工作,但在我们完成本章之前,我们仍然需要学习如何处理 JSON!
序列化和反序列化 JSON
当涉及到序列化和反序列化 JSON 时,Unity 和 C#并不完全同步。本质上,C#有一个自己的JsonSerializer类,它的工作方式与我们在前例中使用的XmlSerializer类完全相同。
为了访问 JSON 序列化器,你需要System.Text.Json的using指令。这里的问题是——Unity 不支持该命名空间。相反,Unity 使用System.Text命名空间,并实现了自己的 JSON 序列化器类,称为JsonUtility。
由于我们的项目在 Unity 中,我们将使用 Unity 支持的序列化类。然而,如果你在非 Unity 的 C#项目中工作,这些概念与我们所写的 XML 代码相同。
你可以在 Microsoft 的文档中找到一个完整的指南,包括代码:docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-how-to#how-to-write-net-objects-as-json-serialize。
你的下一个任务是序列化单个武器,以熟悉JsonUtility类:
-
在
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;** -
为新的 XML 文件添加一个新的私有字符串路径,并在
Awake()中设置其值:**private****string** **_jsonWeapons;** void Awake() { **_jsonWeapons = _dataPath +** **"WeaponJSON.json"****;** } -
在
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); } } -
在
Initialize()中调用新方法,并将_jsonWeapons作为参数传递:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); **SerializeJSON();** }
这是序列化方法的分解:
-
首先,我们需要一个武器来操作,所以我们使用我们的类初始化器创建一个
-
然后我们声明一个变量来保存当 JSON 数据格式化为字符串时的翻译数据,并调用
ToJson()方法:- 我们使用的
ToJson()方法接受我们想要序列化的sword对象和一个布尔值true,这样字符串将以适当的缩进格式化。如果我们没有指定true值,JSON 仍然会打印出来,但它将是一个普通的字符串,这并不容易阅读。
- 我们使用的
-
现在我们有一个要写入文件的文本字符串,我们创建一个
StreamWriter流并将_jsonWeapons文件名传递给它 -
最后,我们使用
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 时使用它!
-
打开
Weapon.cs文件,并将以下可序列化的WeaponShop类添加到文件底部。请务必将新类放在Weapon类的大括号之外:[Serializable] public class WeaponShop { public List<Weapon> inventory; } -
在
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); } }
让我们分解我们刚刚所做的更改:
-
首先,我们创建一个新的变量
shop,它是WeaponShop类的一个实例 -
然后将
inventory属性设置为已经声明的weaponInventory武器列表 -
最后,我们将
shop对象传递给ToJson()方法,并将新的字符串数据写入 JSON 文件
再次运行游戏并查看我们创建的漂亮打印的武器列表:

图 12.24:正确序列化到 JSON 的列表对象
将 JSON 文本反序列化为对象是我们刚才做的过程的逆过程:
-
在
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); } } } } -
在
Initialize()中调用新方法,并将_jsonWeapons作为参数传递:public void Initialize() { _state = "Data Manager initialized.."; Debug.Log(_state); FilesystemInfo(); NewDirectory(); SerializeJSON(); **DeserializeJSON();** }
让我们分解下面的DeserializeJSON()方法:
-
首先,我们检查文件是否存在
-
如果文件存在,我们使用
using代码块将_jsonWeapons文件路径包装起来创建一个流 -
然后,我们使用流的
ReadToEnd()方法从文件中获取整个 JSON 文本 -
接下来,我们创建一个变量来保存我们的反序列化武器列表,并调用
FromJson()方法:- 注意,我们在将 JSON 字符串变量传递之前,使用
<WeaponShop>语法指定我们想要将 JSON 转换为WeaponShop对象
- 注意,我们在将 JSON 字符串变量传递之前,使用
-
最后,我们遍历武器店的
inventory列表属性,并在控制台打印出每个武器的名称和伤害值
再次运行游戏,您将在控制台消息中看到我们 JSON 数据中的每个武器的打印信息:

图 12.25:反序列化 JSON 对象的控制台输出
数据汇总
本章中我们涵盖的每个模块和主题都可以单独使用或组合使用以满足您项目的需求。例如,您可以使用文本文件来存储角色对话,并且只有在需要时才加载它。这将比游戏每次运行时都跟踪信息更有效率。
您也可以将角色数据或敌人统计数据放入 XML 或 JSON 文件中,并在需要提升角色或生成新怪物时从文件中读取。最后,您可以从第三方数据库获取数据并将其序列化到您自己的自定义类中。这是一个非常常见的场景,用于存储玩家账户和外部游戏数据。
您可以在docs.microsoft.com/en-us/dotnet/framework/wcf/feature-details/types-supported-by-the-data-contract-serializer找到可以序列化的数据类型列表。Unity 处理序列化的方式略有不同,所以请确保您检查docs.unity3d.com/ScriptReference/SerializeField.html中可用的类型。
我试图说明的是,数据无处不在,而您的任务是创建一个系统来以游戏所需的方式处理它,一块砖接一块砖。
摘要
这样,我们就完成了数据操作基础的学习!恭喜你完整地通过了这一章。在任何编程环境中,数据都是一个很大的话题,所以将本章学到的所有内容作为一个起点。
你已经知道如何导航文件系统,以及如何创建、读取、更新和删除文件。你还学习了如何有效地处理文本、XML 和 JSON 数据格式,以及数据流。你知道如何将整个对象的状态序列化或反序列化成 XML 和 JSON。总的来说,学习这些技能并非易事。不要忘记多次复习和回顾这一章;这里有很多内容可能不会在第一次阅读时变得自然而然。
在下一章中,我们将讨论泛型编程的基础,获得一些关于委托和事件的实践经验,并以异常处理概述结束。
突击测验 - 数据管理
-
哪个命名空间可以让你访问
Path和Directory类? -
在 Unity 中,你使用哪个文件夹路径在游戏运行之间保存数据?
-
Stream对象使用什么数据类型来读取和写入文件中的信息? -
当你将对象序列化为 JSON 时,会发生什么?
加入我们的 Discord!
与其他用户、Unity/C#专家和哈里森·费罗尼一起阅读这本书。提问,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。
立即加入!

第十三章:探索泛型、委托以及更多
你编程的时间越长,你就越开始思考系统。如何结构化类和对象之间的交互、通信和数据交换是我们迄今为止所处理的一些系统;现在的问题是,如何使它们更安全、更高效。
由于这将本书的最后一章,我们将讨论泛型编程概念、委托、事件创建和错误处理的例子。每个主题本身都是一个庞大的研究领域,所以请将这里学到的知识应用到你的项目中。完成我们的实际编码后,我们将简要概述设计模式及其在你未来编程旅程中的作用。
在本章中,我们将涵盖以下主题:
-
泛型编程
-
使用委托
-
创建事件和订阅
-
抛出和处理错误
-
理解设计模式
介绍泛型
我们迄今为止的所有代码在定义和使用类型方面都非常具体。然而,会有一些情况,你需要一个类或方法以相同的方式处理其实体,而不管其类型如何,同时仍然保持类型安全。泛型编程允许我们使用占位符而不是具体类型来创建可重用的类、方法和变量。
当在编译时创建泛型类实例或使用方法时,将分配一个具体类型,但代码本身将其视为泛型类型。当你需要以相同的方式处理不同的对象类型时,能够编写泛型代码是一个巨大的好处,例如,需要能够对元素执行相同操作的定制集合类型,或者需要相同底层功能的类。虽然你可能想知道为什么我们不直接使用子类或接口,但你将在我们的例子中看到泛型以不同的方式帮助我们。
我们已经通过 List 类型看到了这一点,它是一个泛型类型。我们可以访问它的所有添加、删除和修改功能,无论它存储的是整数、字符串还是单个字符。
泛型对象
创建泛型类与创建非泛型类的工作方式相同,但有一个重要的区别:它的泛型类型参数。让我们看看一个我们可能想要创建的泛型集合类的例子,以更清楚地了解它是如何工作的:
public class SomeGenericCollection**<****T****>** {}
我们已经声明了一个名为 SomeGenericCollection 的泛型集合类,并指定其类型参数将被命名为 T。现在,T 将代表泛型列表将存储的元素类型,并且可以在泛型类内部像任何其他类型一样使用。
每当我们创建一个 SomeGenericCollection 的实例时,我们需要指定它可以存储的值的类型:
SomeGenericCollection**<****int****>** highScores = new SomeGenericCollection<int>();
在这种情况下,highScores存储整数值,而T代表int类型,但SomeGenericCollection类将对待任何元素类型相同。
你可以完全控制泛型类型参数的命名,但在许多编程语言中,行业标准是使用大写T。如果你打算以不同的方式命名你的类型参数,请考虑以大写T开头,以保持一致性和可读性。
接下来,让我们通过以下步骤创建一个更专注于游戏的示例,使用泛型Shop类来存储一些虚构的库存项目:
-
在
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>(); } -
在
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."); } }
让我们分解一下代码:
-
声明一个新的名为
IShop的泛型类,具有类型参数T -
添加一个类型为
T的库存List<T>来存储我们初始化泛型类时使用的任何项目类型 -
在
GameBehavior中创建一个新的Shop<string>实例,并指定字符串值作为泛型类型 -
输出带有库存数量的调试信息:
![img/B17573_13_01.png]()
图 13.1:泛型类的控制台输出
在功能方面,这里还没有发生任何新的事情,但 Visual Studio 由于泛型类型参数T,将Shop识别为泛型类。这使我们能够包含额外的泛型操作,如添加库存项目或查找每种项目的可用数量。
值得注意的是,Unity 序列化器默认不支持泛型。如果你想要序列化泛型类,就像我们在上一章中自定义类那样,你需要在类顶部添加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脚本中使用它。
由于我们已经有了一个具有定义类型参数的泛型类,让我们添加一个非泛型方法来看看它们是如何一起工作的:
-
打开
Shop并按照以下方式更新代码:public class Shop<T> { public List<T> inventory = new List<T>(); **// 1** **public****void****AddItem****(****T newItem****)** **{** **inventory.Add(newItem);** **}** } -
进入
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."); } }
让我们分解一下代码:
-
声明一个用于向库存添加
newItems类型T的方法 -
使用
AddItem()向itemShop添加两个字符串项并打印出调试日志:![img/B17573_13_02.png]()
图 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;
}
让我们分解我们的新方法:
-
声明一个返回库存中找到的匹配类型
U的物品数量的 int 值的方法- 泛型类型参数的命名完全取决于你,就像命名变量一样。传统上,它们从
T开始,并从那里按字母顺序继续。
- 泛型类型参数的命名完全取决于你,就像命名变量一样。传统上,它们从
-
创建一个变量来保存我们找到的匹配库存物品的数量,并最终从库存中返回
-
使用
foreach循环遍历库存列表,每次找到匹配项时增加库存值 -
返回匹配库存物品的数量
这里的问题是我们将字符串值存储在我们的商店中,如果我们尝试查找有多少字符串类型的物品,我们将得到完整的库存:
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 的新类型:
-
在
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类型,这样我们就可以进行比较并找到匹配项。 -
打开
Shop并更新类声明:public class Shop<T> **where****T** **:** **Collectable** -
更新
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; } -
在
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 脚本和 GameObject 一起使用。例如,我们可以轻松地创建一个泛型可销毁类,用于从场景中删除我们想要删除的任何 MonoBehaviour 或对象 Component。如果这听起来很熟悉,这就是我们的 BulletBehavior 为我们做的事情,但它不适用于除该脚本之外的任何东西。为了使这个功能更具可扩展性,让我们使任何从 MonoBehaviour 继承的脚本都成为可销毁的。
-
在
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); } } -
删除
BulletBehavior中的所有代码,并从新的泛型类继承:public class BulletBehavior : **Destroyable****<****BulletBehavior****>** { }
现在,我们已经将 BulletBehavior 脚本转换成了一个泛型可销毁对象。子弹预制体没有变化,但我们可以通过从泛型 Destroyable 类继承来使任何其他对象可销毁。在我们的例子中,如果我们创建了多个弹道预制体并希望它们都能在特定时间被销毁,这将提高代码的效率和可重用性。
泛型编程是我们工具箱中的强大工具,但在掌握了基础知识之后,随着你在编程旅程中的进步,现在是时候讨论一个同样重要的主题了——委托!
委托动作
有时候,你需要将方法从文件 A 传递到文件 B,或者委托执行。在 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);**
**}**
}
让我们分解一下代码:
-
声明一个名为
DebugDelegate的public delegate类型,用于存储接受一个string参数并返回void的方法 -
创建一个新的名为
debug的DebugDelegate实例,并将其分配给一个具有匹配签名的名为Print()的方法 -
将
Initialize()中的Debug.Log(_state)代码替换为对debug委托实例的调用 -
将
Print()声明为接受一个string参数并将其记录到控制台中的static方法:![img/B17573_13_06.png]()
图 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..."****);**
**}**
}
让我们分解一下代码:
-
调用
LogWithDelegate()并传入我们的debug变量作为其类型参数 -
声明一个新的方法,它接受一个
DebugDelegate类型的参数 -
调用委托参数的函数,并传入一个要打印的字符串字面量:
![img/B17573_13_07.png]()
图 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
}
让我们分解一下代码:
-
声明一个新的返回
void且不接受任何参数的delegate类型 -
创建一个名为
playerJump的JumpingEvent类型事件,它可以被视为一个与前面委托的void返回值和没有参数签名匹配的方法 -
在
Update()中应用力后调用playerJump
我们已经成功创建了一个不接受任何参数且不返回任何内容的简单委托类型,以及一个每当玩家跳跃时执行的事件类型。每次玩家跳跃时,playerJump事件都会发送给所有订阅者,以通知他们该动作。
事件触发后,处理它并执行任何附加操作的责任就交给了其订阅者;我们将在下一节“处理事件订阅”中看到这一点。
处理事件订阅
目前,我们的playerJump事件没有订阅者,但改变这一点很简单,并且与我们在上一节中为委托类型分配方法引用的方式非常相似:
someClass.eventInstance += EventHandler;
由于事件是它们声明的类中的变量,而订阅者是其他类,因此订阅需要事件包含类的引用。使用 += 操作符分配一个在事件执行时触发的方法,就像设置自动回复邮件一样。像分配委托一样,事件处理方法的方法签名必须与事件类型匹配。在我们的先前的语法示例中,这意味着 EventHandler 需要是以下形式:
public void EventHandler(int param1, string param2) {}
在需要取消订阅事件的情况下,你只需使用 -= 操作符执行赋值的逆操作:
someClass.eventInstance -= EventHandler;
事件订阅通常在类初始化或销毁时处理,这使得管理多个事件而无需编写混乱的代码实现变得容易。
现在你已经知道了订阅和取消订阅事件的语法,现在是时候在 GameBehavior 脚本中将这些应用到实践中了。
现在我们的事件在玩家跳跃时每次都会触发,我们需要一种方法来捕捉这个动作:
-
返回到
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 ... }
让我们分解一下代码:
-
创建一个类型为
PlayerBehavior的公共变量 -
声明
OnEnable()方法,该方法在脚本附加的对象在场景中变为活动状态时被调用OnEnable是MonoBehaviour类中的一个方法,因此所有 Unity 脚本都可以访问它。这是一个放置事件订阅的好地方,而不是使用Awake,因为它只在对象活动时执行,而不是在加载过程中。 -
在场景中找到
Player对象,并将它的GameObject存储在一个局部变量中 -
使用
GetComponent()获取附加到Player的PlayerBehavior类的引用,并将其存储在playerBehavior变量中 -
使用
+=操作符订阅PlayerBehavior中声明的playerJump事件,并使用名为HandlePlayerJump的方法 -
声明
HandlePlayerJump()方法,其签名与事件类型匹配,并在每次接收到事件时使用调试委托记录成功消息!:![img/B17573_13_08.png]()
图 13.9:委托事件订阅的控制台输出
为了在 GameBehavior 中正确订阅并接收事件,我们必须获取附加到玩家上的 PlayerBehavior 类的引用。我们本可以一行完成这个操作,但将其拆分会使代码更易读。然后我们为 playerJump 事件分配了一个方法,该方法在接收到事件时执行,并完成订阅过程。
现在每次你跳跃时,你都会看到一个包含事件消息的调试信息:

图 13.10:委托事件触发的控制台输出
由于事件订阅是在脚本中配置的,而脚本附加到 Unity 对象上,我们的工作还没有完成。我们还需要处理当对象被销毁或从场景中移除时如何清理订阅,这将在下一节中介绍。
清理事件订阅
尽管在我们的原型中玩家永远不会被销毁,但在游戏中失败时这是一个常见的功能。始终重要的是要清理事件订阅,因为它们会占用分配的资源,正如我们在第十二章,保存、加载和序列化数据中讨论的那样。
我们不希望订阅的对象被销毁后还有任何订阅存在,所以让我们清理我们的跳跃事件。在OnEnable方法之后向GameBehavior添加以下代码:
// 1
private void OnDisable()
{
// 2
playerBehavior.playerJump -= HandlePlayerJump;
debug("Jump event unsubscribed...");
}
让我们分解一下我们的新代码添加部分:
-
声明
OnDisable()方法,它属于MonoBehavior类,是之前使用的OnEnable()方法的配套方法- 你需要编写的任何清理代码通常都应该放在这个方法中,因为它在脚本附加的对象不活动时执行
-
使用
-=运算符从HandlePlayerJump取消订阅playerJump事件,并打印出控制台消息
现在我们的脚本在 GameObject 启用和禁用时正确地订阅和取消订阅事件,在我们的游戏场景中不留任何未使用的资源。
这就结束了我们对事件的讨论。现在你可以从单个脚本中广播事件到游戏的每个角落,并应对玩家生命值减少、收集物品或更新 UI 等场景。然而,我们仍然需要讨论一个非常重要的主题,没有这个主题任何程序都无法成功,那就是错误处理。
异常处理
高效地将错误和异常集成到你的代码中,是你编程旅程中专业和个人基准的一部分。在你开始大喊“我花了这么多时间试图避免错误,为什么要添加错误?!”之前,你应该知道,我的意思并不是添加错误来破坏你现有的代码。恰恰相反——包括错误或异常,并在功能部件使用不当时适当地处理它们,会使你的代码库更强大,更不容易崩溃,而不是更弱。
抛出异常
当我们谈论添加错误时,我们将其过程称为异常抛出,这是一个恰当的视觉类比。抛出异常是防御性编程的一部分,本质上意味着你积极地、有意识地保护你的代码免受不正确或不计划的操作。为了标记这些情况,你从一个方法中抛出一个异常,然后由调用代码处理。
让我们举一个例子:假设我们有一个if语句,在允许玩家注册之前检查玩家的电子邮件地址是否有效。如果输入的电子邮件地址无效,我们希望我们的代码抛出异常:
public void ValidateEmail(string email)
{
if(!email.Contains("@"))
{
**throw****new** **System.ArgumentException(****"Email is invalid"****);**
}
}
我们使用throw关键字来抛出异常,该异常是通过在new关键字后跟指定的异常来创建的。默认情况下,System.ArgumentException()会记录异常执行的位置和时间信息,但也可以接受一个自定义字符串,如果你想要更具体的话。
ArgumentException是Exception类的子类,并且可以通过之前显示的System类访问。C#提供了许多内置的异常类型,包括用于检查空值、出界或范围集合值以及无效操作的子类。异常是正确使用工具的绝佳例子。我们的例子只需要基本的ArgumentException,但你可以找到完整的描述性列表在docs.microsoft.com/en-us/dotnet/api/system.exception#Standard。
在我们第一次尝试异常处理时,让我们保持简单,并确保只有当我们提供一个正的场景索引数字时,我们的级别才会重新启动:
-
打开
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; } } -
将
GameBehavior中的RestartLevel()修改为接受负的场景索引并输掉游戏:// 3 public void RestartScene() { Utilities.RestartLevel(**-1**); }
让我们分解一下代码:
-
声明一个
if语句来检查sceneIndex是否不小于 0 或负数 -
如果传入的参数是负的场景索引,则抛出一个带有自定义信息的
ArgumentException -
使用场景索引
-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...");
}
}
}
让我们分解代码:
-
声明
try块,并使用debug命令将RestartLevel()的调用移至其中,以打印出重启是否完成且没有任何异常。 -
声明
catch块,并定义System.ArgumentException为它将处理的异常类型,并将exception作为局部变量名。 -
如果抛出异常,则在默认场景索引处重新启动游戏:
- 使用
debug代理打印自定义消息,以及异常信息,这些信息可以通过exception访问,并使用ToString()方法转换为字符串。
由于
exception是ArgumentException类型,与Exception类相关联的属性和方法有几个,你可以访问。这些在需要有关特定异常的详细信息时通常很有用。 - 使用
-
添加一个带有调试信息的
finally块,以表示异常处理代码的结束:![img/B17573_13_11.png]()
图 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#
-
泛型类和非泛型类之间的区别是什么?
-
在将值赋给委托类型时,需要匹配什么?
-
你会如何从事件中取消订阅?
-
你会使用哪个 C# 关键字在代码中抛出异常?
加入我们的 Discord 社群!
与其他用户、Unity/C# 专家以及哈里森·费罗内一起阅读这本书。提问,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,以及更多。
立即加入!

第十四章:旅程继续
如果你以一个编程世界的完全新手开始这本书,恭喜你的成就!如果你对 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#中开发游戏或应用程序时,我总是将 Microsoft 文档打开在一个我可以轻松访问的窗口中。如果我不能找到特定问题的答案或解决方案,我会开始检查我最常用的社区网站:
-
C# Corner:
www.c-sharpcorner.com -
Dot Net Perls:
www.dotnetperls.com -
Stack Overflow:
stackoverflow.com
由于我的大多数 C# 问题都与 Unity 有关,我倾向于倾向于这些类型的资源,这些资源我在下一节中已经列出。
Unity 资源
最好的 Unity 学习资源来自源头;视频教程、文章、免费资源和文档都可以在 unity3d.com 获取。
然而,如果你在寻找社区答案或针对编程问题的特定解决方案,请访问以下网站:
-
Unity 论坛:
forum.unity.com -
Unity Learn:
learn.unity.com -
Unity Answers:
answers.unity.com -
Unity Discord 频道:
discord.com/invite/unity -
Stack Overflow:
stackoverflow.com
如果你更喜欢视频教程社区,YouTube 上也有一个巨大的视频教程社区;以下是我的前五名:
-
Brackeys:
www.youtube.com/user/Brackeys -
BurgZerg Arcade:
www.youtube.com/user/BurgZergArcade
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 资产商店(
assetstore.unity.com)
无论你的激情项目是什么,都要将其展示给世界。
摘要
你可能会觉得这标志着你的编程旅程的结束,但你大错特错了。学习没有尽头,只有起点。我们着手理解编程的基石,C#语言的基础,以及如何将这种知识转化为 Unity 中的有意义的行为。如果你已经翻到了最后一页,我确信你已经实现了这些目标,你也应该这样认为。
最后一点建议,这是我希望在我刚开始时有人告诉我的:如果你说你是一名程序员,那么你就是。社区中会有很多人告诉你你是业余的,你没有成为“真正的”程序员所必需的经验,或者更好的是,你需要某种无形的专业认可。这是错误的:如果你经常像程序员一样思考,致力于以高效和整洁的代码解决问题,并且热爱学习新事物,那么你就是一名程序员。拥有这个身份;这将使你的旅程变得非常精彩。
加入我们的 Discord!
与其他用户、Unity/C#专家以及哈里森·费罗内一起阅读这本书。提出问题,为其他读者提供解决方案,通过问我任何问题的环节与作者聊天,以及更多。
现在加入!

第十五章:突击测验答案
第一章:– 了解你的环境
突击测验 – 处理脚本
| Q1 | Unity 和 Visual Studio 之间存在共生关系 |
|---|---|
| Q2 | 参考手册 |
| Q3 | 没有,因为它是一个参考文档,而不是测试 |
| Q4 | 当新文件出现在 项目 选项卡中,并且文件名处于编辑模式时,这将使类名与文件名相同,从而防止命名冲突 |
第二章:– 编程的构建块
突击测验 – C# 构建块
| Q1 | 在 C# 文件的其他地方存储特定类型的数据以供使用 |
|---|---|
| Q2 | 方法存储可执行的代码行,以便快速高效地重用 |
| Q3 | 通过将 MonoBehaviour 作为其父类并附加到 GameObject 上 |
| Q4 | 要访问不同 GameObject 附加的组件或文件中的变量和方法 |
第三章:– 深入变量、类型和方法
突击测验 #1 – 变量和方法
| Q1 | 使用驼峰命名法 |
|---|---|
| Q2 | 将变量声明为 public |
| Q3 | public、private、protected 和 internal |
| Q4 | 当隐式转换不存在时 |
| Q5 | 方法返回的数据类型,方法的名称(带括号),以及一对大括号用于代码块 |
| Q6 | 允许将参数数据传递到代码块中 |
| Q7 | 该方法不会返回任何数据 |
| Q8 | Update() 方法在每一帧都会被调用 |
第四章:– 控制流和集合类型
突击测验 #1 – 如果,和,或但是
| Q1 | 是或不是 |
|---|---|
| Q2 | NOT 操作符,用感叹号符号(!)表示 |
| Q3 | AND 操作符,用双 ampersand 符号(&&)表示 |
| Q4 | OR 操作符,用双竖线(||)表示 |
突击测验 #2 – 所有关于集合
| Q1 | 数据存储的位置 |
|---|---|
| Q2 | 数组或列表中的第一个元素是 0,因为它们都是零索引的 |
| Q3 | 不 – 当声明数组或列表时,它存储的数据类型是定义好的,这使得元素不可能有不同的类型 |
| Q4 | 一旦初始化,数组无法动态扩展,这就是为什么列表是一个更灵活的选择,因为它们可以动态修改 |
第五章:– 使用类、结构体和面向对象编程
突击测验 – 所有关于面向对象编程
| 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 | get 和 set 访问器 |
| Q4 | OnGUI() |
第九章:– 基本人工智能和敌人行为
突击测验 – 人工智能和导航
| Q1 | 它自动从关卡几何形状生成 |
|---|---|
| Q2 | NavMeshAgent |
| Q3 | 过程式编程 |
| Q4 | 不要重复自己 |
第十章:– 重温类型、方法和类
突击测验 – 等级提升
| Q1 | Readonly |
|---|---|
| Q2 | 更改方法参数的数量或它们的参数类型 |
| Q3 | 接口不能有方法实现或存储变量 |
| Q4 | 创建类型别名以区分冲突的命名空间 |
第十一章:– 介绍栈、队列和哈希集合
突击测验 – 中级集合
| Q1 | 栈 |
|---|---|
| Q2 | 查看下一个元素(Peek) |
| Q3 | 是 |
| Q4 | ExceptWith |
第十二章:– 保存、加载和序列化数据
突击测验 – 数据管理
| Q1 | System.IO 命名空间 |
|---|---|
| Q2 | Application.persistentDataPath |
| Q3 | 流以字节的形式读取和写入数据 |
| Q4 | 将整个 C# 类对象转换为 JSON 格式 |
第十三章:– 探索泛型、委托以及其他
突击测验 – 中级 C#
| Q1 | 通用类需要有一个定义的类型参数 |
|---|---|
| Q2 | values 方法及其 delegates 方法签名 |
| Q3 | -= 操作符 |
| Q4 | throw 关键字 |
在 Discord 上加入我们!
与其他用户、Unity/C# 专家和哈里森·费罗尼一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天等等。
立即加入!

















































































































浙公网安备 33010602011771号