原文:
zh.annas-archive.org/md5/D5230158773728FED97C67760D6D7EA0
译者:飞龙
前言
Unity 是世界上最受欢迎的游戏引擎之一,迎合业余爱好者、专业 AAA 工作室和电影制作公司。虽然以其用作 3D 工具而闻名,但 Unity 拥有一系列专门功能,支持从 2D 游戏和虚拟现实到后期制作和跨平台发布的一切。
开发人员喜欢它的拖放界面和内置功能,但正是编写自定义 C#脚本以实现行为和游戏机制的能力真正使 Unity 脱颖而出。学习编写 C#代码对于已经掌握其他语言的经验丰富的程序员来说可能并不是一个巨大的障碍,但对于那些没有编程经验的人来说可能是令人望而却步的。这就是这本书的用武之地,因为我将带领你从头开始学习编程和 C#语言的基础知识,同时在 Unity 中构建一个有趣且可玩的游戏原型。
这本书适合谁
这本书是为那些没有编程或 C#基本原则经验的人写的。然而,如果你是一个有能力的新手或经验丰富的专业人士,来自其他语言,甚至是 C#,但需要在 Unity 中进行游戏开发,这本书仍然适合你。
本书涵盖的内容
第一章《了解您的环境》,从 Unity 安装过程开始,介绍了编辑器的主要功能,以及查找 C#和 Unity 特定主题的文档。我们还将介绍如何在 Unity 内创建 C#脚本,并了解 Visual Studio,这是我们所有代码编辑的应用程序。
第二章《编程的基本构件》,首先阐述了编程的原子级概念,让你有机会将变量、方法和类与日常生活中的情况联系起来。然后,我们将介绍简单的调试技术、适当的格式和注释,以及 Unity 如何将 C#脚本转换为组件。
第三章《深入变量、类型和方法》,深入探讨了第二章的基本知识。这包括 C#数据类型、命名约定、访问修饰符以及程序基础所需的其他内容。我们还将介绍如何编写方法、添加参数和使用返回类型,并以对属于MonoBehaviour
类的标准 Unity 方法的概述结束。
第四章《控制流和集合类型》,介绍了在代码中做出决策的常见方法,包括if-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 用户名。例如:“选择Materials
文件夹。”
代码块设置如下:
public string firstName = "Harrison";
当我们希望引起您对代码块的特定部分的注意时,相关行或项将被突出显示:
accessModifier returnType UniqueName(**parameterType parameterName**) { method body }
粗体:表示新术语、重要单词或屏幕上看到的单词,例如菜单或对话框中的单词。例如:“点击创建 | 3D 对象 | 胶囊,从层次结构面板中。”
第一章:了解您的环境
流行文化经常将计算机程序员宣传为局外人、独行侠或怪异的黑客。他们拥有非凡的算法思维能力,社交智商低,有点反叛。虽然事实并非如此,但学习编程的确会从根本上改变您看待世界的方式。好消息是,您天生的好奇心已经希望在世界中看到这些模式,您甚至可能会喜欢这种新的思维方式。
从早上睁开眼睛到晚上睡觉前看到天花板风扇的最后一眼,您无意识地使用分析技能,这些技能可以转化为编程 - 您只是缺少正确的语言和语法将这些生活技能映射到代码中。您知道自己的年龄,对吧?那是一个变量。当您过马路时,我假设您会像我们其他人一样在踏上路边之前向两个方向看一眼。这是评估不同条件,更为程序术语中的控制流。当您看着一罐汽水时,您本能地识别出它具有形状、重量和内容等特定属性。那就是一个类对象!您明白了吧。
凭借您掌握的丰富实际经验,您已经准备好进入编程的领域了。为了开始您的旅程,您需要知道如何设置您的开发环境,使用涉及的应用程序,并确切地知道在需要帮助时该去哪里。
为此,我们将首先深入以下 C#主题:
开始使用 Unity 2021
使用 C#与 Unity
探索文档
让我们开始吧!
技术要求
有时候,从一件事物不是什么开始,比从它是什么开始更容易。本书的目标不是教会您关于 Unity 游戏引擎或游戏开发的所有知识。出于必要,我们将在旅程开始时以基本水平涵盖这些主题,并在第六章,与 Unity 一起动手中进行更详细的讨论。然而,这些主题包括在内,是为了以一种有趣、易于理解的方式从零开始学习 C#编程语言。
由于本书面向完全没有编程经验的初学者,如果您之前没有接触过 C#或 Unity,那么您来对地方了!如果您之前有一些 Unity Editor 的经验,但没有编程经验,猜猜怎么着?这依然是您应该来的地方。即使您之前尝试过一些 C#混合 Unity,但想要探索一些中级或高级主题,本书的后几章也可以为您提供所需的内容。
如果您是其他语言的有经验的程序员,可以随意跳过初学者理论,直接进入您感兴趣的部分,或者留下来复习您的基础知识。
除了运行 Unity 2021,您还将使用 C# 8.0 和 Visual Studio 来编写游戏代码。
开始使用 Unity 2021
如果您尚未安装 Unity,或者正在运行早期版本,请按照以下步骤设置您的环境:
选择开始(如下图所示):
图 1.1:Unity 首页
这将带您到 Unity 商店页面。不要感到不知所措 - 您可以完全免费获得 Unity!
如果 Unity 首页对您来说与图 1.1中所见不同,您可以直接前往store.unity.com
。
- 选择个人选项。其他付费选项提供更高级的功能和服务,但您可以自行查看:
图 1.2:Unity 计划和定价
- 选择个人计划后,将询问您是否是第一次或返回用户。在第一次用户下选择从这里开始:
图 1.3:使用 Unity 门户开始创建
- 选择同意并下载以获取您的 Unity Hub 副本:
图 1.4:Unity 条款和条件
下载完成后,请按照以下步骤操作:
打开安装程序(双击打开)
接受用户协议
按照安装说明操作
当您得到绿灯时,继续启动 Unity Hub 应用程序!
最新版本的 Unity Hub 在您首次打开应用程序时将提供安装向导。如果您想要跟随,可以随意选择。
以下步骤向您展示如何在不借助应用程序的帮助下开始一个新项目:
- 在左下角选择跳过安装向导,然后确认跳过向导:
图 1.5:安装向导
- 从左侧菜单切换到安装选项卡,然后选择添加以选择您的 Unity 版本:
图 1.6:Unity Hub 安装面板
- 选择您想要的 Unity 版本,然后单击下一步。在撰写本文时,Unity 2021 仍处于预发布阶段,但在您阅读本文时,您应该能够从官方发布列表中选择 2021 版本:
图 1.7:添加 Unity 版本弹出窗口
- 然后,您将有选择将各种模块添加到您的安装中。确保选择了 Visual Studio 模块,然后单击下一步:
图 1.8:添加安装模块
如果您想以后添加任何模块,可以单击更多按钮(安装窗口右上角的三点图标)。
安装完成后,您将在安装面板中看到一个新版本,如下所示:
图 1.9:带有 Unity 版本的安装选项卡
您可以在docs.unity3d.com/Manual/GettingStartedInstallingHub.html
找到有关 Unity Hub 应用程序的其他信息和资源。
事情总会出错的可能性,所以如果您使用的是 macOS Catalina 或更高版本,可能会出现问题,请务必查看以下部分。
使用 macOS
如果您在使用某些版本的 Unity Hub 安装 Unity 时在 Mac 上遇到 OS Catalina 或更高版本的问题,那么请深呼吸,转到Unity 下载存档,并获取您需要的 2021 版本(unity3d.com/get-unity/download/archive
)。记住使用**下载(Mac)**选项而不是 Unity Hub 下载:
图 1.10:Unity 下载存档
如果您在 Windows 上工作并遇到类似的安装问题,下载 Unity 的存档副本也可以正常工作。
由于它是一个.dmg
文件,下载是一个普通的应用程序安装程序。打开它,按照说明操作,您将很快就可以开始了!
图 1.11:从下载管理器成功安装 Unity
本书的所有示例和截图都是使用 Unity 2021.1.0b8 创建和捕获的。如果您使用的是更新版本,Unity 编辑器中的外观可能会略有不同,但这不应影响您的跟进。
现在 Unity Hub 和 Unity 2021 已安装完成,是时候创建一个新项目了!
创建一个新项目
启动 Unity Hub 应用程序以开始一个新项目。如果您有 Unity 帐户,请继续登录;如果没有,您可以创建一个或在屏幕底部单击跳过。
现在,让我们通过在右上角的新建按钮旁边选择箭头图标来设置一个新项目:
图 1.12:Unity Hub 项目面板
选择您的 2021 版本并设置以下字段:
模板:项目将默认为3D
项目名称:我将称我的为
Hero Born
位置:您希望项目保存在哪里
设置完成后,点击创建:
图 1.13:带有新项目配置弹出窗口的 Unity Hub
创建项目后,您可以随时从 Unity Hub 的项目面板中重新打开项目。
导航编辑器
当新项目完成初始化时,您将看到美妙的 Unity 编辑器!我在以下截图中标记了重要的标签(或面板,如果您喜欢的话):
图 1.14:Unity 界面
这是很多内容,所以我们将更详细地查看每个面板:
工具栏面板是 Unity 编辑器的最顶部部分。从这里,您可以操作对象(最左边的按钮组)并播放和暂停游戏(中间按钮)。最右边的按钮组包含 Unity 服务、LayerMasks和布局方案功能,这本书中我们不会使用,因为它们与学习 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 常规首选项面板
我知道如果您是新手,这可能需要一些时间来理解,但请放心,以后的任何说明都会提到必要的步骤。我不会让您猜测要按哪个按钮。说了这些,让我们开始创建一些实际的 C#脚本。
在 Unity 中使用 C#
未来,将 Unity 和 C#视为共生实体是很重要的。Unity 是您将创建脚本和游戏对象的引擎,但实际的编程发生在另一个名为 Visual Studio 的程序中。现在不用担心这个问题,我们马上就会解决。
使用 C#脚本
尽管我们还没有涵盖任何基本的编程概念,但在我们知道如何在 Unity 中创建实际的 C#脚本之前,它们将没有用武之地。C#脚本是一种特殊类型的 C#文件,在其中您将编写 C#代码。这些脚本可以在 Unity 中用于几乎任何事情,从响应玩家输入到创建游戏机制。
有几种从编辑器创建 C#脚本的方法:
选择Assets | Create | C# Script
在Project选项卡下方,选择**+图标并选择C# Script**
在Project选项卡中的Assets文件夹上右键单击,然后从弹出菜单中选择Create | C# Script
在Hierarchy窗口中选择任何 GameObject,然后单击Add Component | New Script
今后,每当您被指示创建 C#脚本时,请使用您喜欢的任何方法。
除了使用上述方法在编辑器中创建 C#脚本之外,还可以创建资源和其他对象。我不会每次创建新内容时都提到这些变化,所以请将选项记在心中。
为了组织起见,我们将把各种资产和脚本存储在它们标记的文件夹内。这不仅仅是一个与 Unity 相关的任务 - 这是您应该始终执行的任务,您的同事会感谢您(我保证):
- 从Project选项卡中,选择**+** | Folder(或您最喜欢的任何方法 - 在图 1.17中,我们选择了Assets | Create | Folder)并将其命名为
Scripts
:
图 1.17:创建 C#脚本
- 双击Scripts文件夹并创建一个新的 C#脚本。默认情况下,脚本将被命名为
NewBehaviourScript
,但您会看到文件名被突出显示,因此您可以立即重命名它。键入LearningCurve
并按Enter
:
图 1.18:选择 Scripts 文件夹的项目窗口
您可以使用Project选项卡底部右侧的小滑块来更改文件的显示方式。
所以,您刚刚创建了一个名为Scripts
的子文件夹,如前面的屏幕截图所示。在该父文件夹中,您创建了一个名为LearningCurve.cs
的 C#脚本(文件类型为.cs
代表 C-Sharp,以防您想知道),现在它作为我们Hero Born项目资产的一部分保存了下来。现在只需在 Visual Studio 中打开它!
介绍 Visual Studio 编辑器
Unity 可以创建和存储 C#脚本,但需要使用 Visual Studio 进行编辑。Unity 预先打包了 Visual Studio 的副本,并且当您从编辑器内部双击任何 C#脚本时,它将自动打开。
打开 C#文件
Unity 将在您第一次打开文件时与 Visual Studio 同步。最简单的方法是从Project选项卡中选择脚本。
双击LearningCurve.cs
,这将在 Visual Studio 中打开 C#文件:
图 1.19:Visual Studio 中的 LearningCurve C#脚本
您可以随时从Visual Studio | View | Layout更改 Visual Studio 选项卡。我将在本书的其余部分中使用Design布局,这样我们就可以在编辑器的左侧看到项目文件。
您将在界面的左侧看到一个与 Unity 中的文件夹结构相同的文件夹结构,您可以像访问其他文件夹一样访问它。右侧是实际的代码编辑器,其中发生了魔术。Visual Studio 应用程序有更多功能,但这是我们开始所需的全部。
Visual Studio 界面在 Windows 和 Mac 环境下有所不同,但本书中将使用的代码在两者上都能很好地工作。本书中的所有屏幕截图都是在 Mac 环境中拍摄的,因此如果您的计算机上看起来不同,那就没必要担心。
注意命名不匹配
一个常见的陷阱是文件命名 - 更具体地说是命名不匹配,我们可以使用 Visual Studio 中 C#文件的图 1.19第 5 行来说明:
public class LearningCurve : MonoBehaviour
LearningCurve
类名与LearningCurve.cs
文件名相同。这是一个基本要求。如果你现在不知道类是什么,没关系。重要的是要记住,在 Unity 中,文件名和类名需要相同。如果你在 Unity 之外使用 C#,文件名和类名不需要匹配。
当你在 Unity 中创建一个 C#脚本文件时,项目选项卡中的文件名已经处于编辑模式,准备重命名。养成当时就重命名的好习惯。如果以后重命名脚本,文件名和类名将不匹配。
如果以后重命名文件,文件名会改变,但第 5 行将如下所示:
public class NewBehaviourScript : MonoBehaviour
如果你不小心这样做了,也不是世界末日。你只需要进入 Visual Studio,将NewBehaviourScript
更改为你的 C#脚本的名称,以及桌面上.meta
文件的名称。你可以在Assets | Scripts文件夹下的项目文件夹中找到.meta
文件:
图 1.20:查找 META 文件
同步 C#文件
作为它们共生关系的一部分,Unity 和 Visual Studio 会相互通信以同步它们的内容。这意味着,如果你在一个应用程序中添加、删除或更改脚本文件,另一个应用程序会自动看到这些更改。
那么,当墨菲定律(它规定“任何可能出错的事情都会出错”)发生,同步似乎不正常时会发生什么?如果你遇到这种情况,深呼吸,选择 Unity 中的有问题的脚本,右键单击,然后选择刷新。
现在你已经掌握了脚本创建的基础知识,所以是时候谈谈如何找到并有效地使用有用的资源了。
探索文档
我们在这次对 Unity 和 C#脚本的初次尝试中要谈到的最后一个主题是文档。我知道这不够吸引人,但在处理新的编程语言或开发环境时,养成良好的习惯是很重要的。
访问 Unity 的文档
一旦你开始认真地编写脚本,你会经常使用 Unity 的文档,所以早点知道如何访问它是有益的。参考手册会给你一个组件或主题的概述,而具体的编程示例可以在脚本参考中找到。
场景中的每个游戏对象(Hierarchy窗口中的一个项目)都有一个控制其位置、旋转和缩放的Transform组件。为了简单起见,我们只查找参考手册中相机的Transform组件:
在Hierarchy选项卡中,选择Main Camera游戏对象
切换到Inspector选项卡,然后点击Transform组件右上角的信息图标(问号):
图 1.21:在检查器中选择了主摄像机游戏对象
你会看到一个网页浏览器打开到参考手册的Transforms页面:
图 1.22:Unity 参考手册
Unity 中的所有组件都有这个功能,所以如果你想了解更多关于某个东西如何工作的信息,你知道该怎么做。
所以,我们已经打开了参考手册,但如果我们想要与Transform组件相关的具体编码示例怎么办?很简单——我们只需要查看脚本参考。
点击组件或类名(在这种情况下是Transform)下方的切换到脚本链接:
图 1.23:突出显示了切换到脚本的 Unity 参考手册按钮
通过这样做,参考手册会自动切换到脚本参考:
图 1.24:突出显示了切换到手册的 Unity 脚本文档
如你所见,除了编码帮助,还有一个选项可以在必要时切换回参考手册。
脚本参考是一份庞大的文件,因为它必须是。然而,这并不意味着你必须记住它,甚至熟悉它的所有信息才能开始编写脚本。正如其名称所示,它是一个参考,而不是一份测试。
如果你在文档中迷失了,或者对于在哪里查找想法耗尽了,你也可以在以下地方找到丰富的 Unity 开发社区中的解决方案:
Unity 论坛:
forum.unity.com/
Unity Answers:
answers.unity.com/index.html
Unity Discord:
discord.com/invite/unity
另一方面,你需要知道在任何 C#问题上找到资源的位置,我们将在下面介绍。
寻找 C#资源
既然我们已经处理了 Unity 资源,让我们来看看微软的 C#资源。首先,微软学习文档docs.microsoft.com/en-us/dotnet/csharp
中有大量的教程、快速入门指南和操作文章。你还可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/index
找到有关 C#主题的概述。
然而,如果你想要关于特定 C#语言特性的详细信息,参考指南是去的地方。这些参考指南对于任何 C#程序员来说都是重要的资源,但由于它们并不总是最容易导航,让我们花几分钟时间学习如何找到我们要找的内容。
让我们加载编程指南链接,查找 C#的String
类。执行以下操作之一:
在网页左上角的搜索栏中输入
Strings
向下滚动到语言部分,直接点击Strings链接:
图 1.25:浏览微软的 C#参考指南
对于类描述页面,你应该看到类似以下内容:
图 1.26:微软的字符串(C#编程指南)页面
与 Unity 的文档不同,C#参考和脚本信息都被捆绑在一起,但它的救星是右侧的子主题列表。好好利用它!当你陷入困境或有问题时,知道在哪里寻求帮助是非常重要的,所以确保在遇到障碍时回到这一部分。
总结
在本章中,我们涵盖了相当多的后勤信息,所以我可以理解如果你渴望编写一些代码。开始新项目、创建文件夹和脚本以及访问文档是在新冒险的兴奋中很容易被遗忘的主题。只要记住,本章有很多你可能在接下来的页面中需要的资源,所以不要害怕回来查看。像程序员一样思考是一种能力:你越多地使用它,它就会变得越强大。
在下一章中,我们将开始阐述你需要准备编码大脑的理论、词汇和主要概念。即使材料是概念性的,我们仍将在LearningCurve
脚本中编写我们的第一行代码。准备好!
小测验-处理脚本
Unity 和 Visual Studio 之间有什么样的关系?
脚本参考提供了关于使用特定 Unity 组件或功能的示例代码。你在哪里可以找到更详细的(非代码相关)关于 Unity 组件的信息?
脚本参考是一份庞大的文件。在尝试编写脚本之前,你需要记住多少内容?
什么时候是给 C#脚本命名的最佳时机?
加入我们的 Discord!
与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交谈等等。
立即加入!
第二章:编程的构建模块
任何编程语言对于不熟悉的人来说都像古希腊语一样难以理解,C#也不例外。好消息是,在最初的神秘之下,所有编程语言都由相同的基本构建模块组成。变量、方法和类(或对象)构成了传统编程的 DNA;理解这些简单的概念将打开一个多样和复杂应用的全新世界。毕竟,地球上每个人的 DNA 中只有四种不同的核碱基;然而,我们每个人都是独特的生物。
如果你是编程新手,在本章中会有大量的信息涌向你,这可能标志着你写下的第一行代码。重点不是用事实和数字来过载你的大脑,而是通过日常生活中的例子给你一个编程构建模块的整体观。
本章主要讨论构成程序的各个部分的高层视图。在直接进入代码之前,了解事物如何运作不仅能帮助新手程序员找到自己的位置,还能通过易于记忆的参考加强对主题的理解。撇开闲话,本章将重点讨论以下主题:
定义变量
理解方法
引入类
使用注释
将构建模块组合在一起
定义变量
让我们从一个简单的问题开始:什么是变量?根据你的观点,有几种不同的回答方式:
概念上,变量是编程的最基本单元,就像原子是物理世界一样(除了弦理论)。一切都始于变量,没有它们程序就无法存在。
技术上,变量是计算机内存的一个小部分,它保存了一个分配的值。每个变量都会跟踪它的信息存储位置(这称为内存地址)、它的值和它的类型(比如数字、单词或列表)。
实际上,变量就是一个容器。你可以随意创建新的变量,填充它们,移动它们,改变它们的内容,并根据需要引用它们。它们甚至可以是空的,但仍然有用。
你可以在微软 C#文档中找到关于变量的深入解释docs.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/variables
。
变量的一个实际生活例子是邮箱——还记得吗?
图 2.1:一排色彩斑斓的邮箱快照
它们可以装信件、账单、姨妈梅贝尔的照片——任何东西。重点是邮箱里的东西可能会有所不同:它们可以有名称,装信息(实体邮件),如果你有适当的安全许可,它们的内容甚至可以被更改。同样,变量可以容纳不同类型的信息。C#中的变量可以容纳字符串(文本)、整数(数字),甚至布尔值(代表真或假的二进制值)。
名称很重要
参考图 2.1,如果我让你过去打开邮箱,你可能会问的第一件事是:哪一个?如果我说史密斯家的邮箱,或者向日葵邮箱,甚至是最右边的垂头丧气的邮箱,那么你就有了打开我所指的邮箱所需的上下文。同样,当你创建变量时,你必须给它们一个你以后可以引用的唯一名称。我们将在第三章,深入变量、类型和方法中详细讨论适当的格式和描述性命名。
变量充当占位符
当你创建并命名一个变量时,你就创建了一个存储数值的占位符。让我们以以下简单的数学方程为例:
2 + 9 = 11
好了,这里没有什么神秘的,但如果我们想让数字9
成为它的变量呢?考虑以下代码块:
MyVariable = 9
现在我们可以使用变量名MyVariable
来替代我们需要的9
:
2 + MyVariable = 11
如果你想知道变量是否有其他规则或规定,答案是肯定的。我们将在下一章中介绍这些内容,所以请耐心等待。
尽管这个例子不是真正的 C#代码,但它说明了变量的威力以及它们作为占位符引用的用途。在下一节中,你将开始创建自己的变量,所以继续前进吧!
好了,理论够了,让我们在我们在第一章,了解你的环境中创建的LearningCurve
脚本中创建一个真正的变量:
从 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
(脚本)组件是否正确附加。点击播放并观察控制台面板中的输出:
图 2.3:Unity 编辑器窗口,带有拖放脚本的标注
Debug.Log()
语句打印出了我们放在括号中的简单数学方程的结果。正如你在下面的控制台截图中所看到的,使用我们的变量CurrentAge
的方程的工作方式与它是一个实际数字一样:
图 2.4:Unity 控制台显示了附加脚本的调试输出
我们将在本章末讨论 Unity 如何将 C#脚本转换为组件,但首先让我们来改变其中一个变量的值。
由于CurrentAge
在第 7 行被声明为一个变量,如图 2.2所示,它存储的值可以被改变。更新后的值将传递到代码中使用变量的任何地方;让我们看看这个过程:
如果场景仍在运行,请点击暂停按钮停止游戏
在检视器面板中将Current Age更改为
18
,然后再次播放场景,观察控制台面板中的新输出:
图 2.5:Unity 控制台显示了调试日志和附加到主摄像机的 LearningCurve 脚本
第一个输出仍然是31
,因为我们在脚本中没有改变任何东西,但第二个输出现在是19
,因为我们在检视面板中改变了CurrentAge
的值。
这里的目标不是讨论变量语法,而是展示变量如何作为容器,可以创建一次并在其他地方引用。我们将在第三章,深入变量、类型和方法中详细讨论。
现在我们知道如何在 C#中创建变量并赋值,我们准备好深入下一个重要的编程构建块:方法!
理解方法
单独的变量不能做更多的事情,只能跟踪其分配的值。虽然这很重要,但它们单独来说在创建有意义的应用程序方面并不是非常有用。那么,我们如何创建动作并在代码中驱动行为呢?简短的答案是使用方法。
在我们讨论方法是什么以及如何使用它们之前,我们应该澄清一个术语的小细节。在编程世界中,你经常会看到术语方法和函数被交替使用,特别是在 Unity 方面。
由于 C#是一种面向对象的语言(这是我们将在第五章使用类、结构和面向对象编程中介绍的内容),我们将在本书的其余部分使用术语方法,以符合标准的 C#指南。
当你在脚本参考或其他文档中遇到函数这个词时,想到方法。
方法驱动行为
与变量类似,定义编程方法可能会非常冗长或非常简短;这里有另外一个三方面的方法来考虑:
概念上,方法是应用程序中完成工作的方式。
技术上,方法是一个包含可执行语句的代码块,当通过名称调用方法时运行。方法可以接受参数(也称为参数),这些参数可以在方法的范围内使用。
实际上,方法是一组指令的容器,每次执行时都会运行。这些容器还可以接受变量作为输入,这些变量只能在方法内部引用。
总的来说,方法是任何程序的骨架——它们连接一切,几乎所有的东西都是基于它们的结构构建的。
你可以在 Microsoft C#文档中找到有关方法的深入指南,网址为docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/methods
。
方法也是占位符
让我们以一个过于简化的例子来加深概念。在编写脚本时,你实际上是按顺序放置代码行,让计算机执行。第一次需要将两个数字相加时,你可以像下面的代码块中那样直接相加:
SomeNumber + AnotherNumber
但是然后你得出结论,这些数字需要在其他地方相加。
与其复制和粘贴相同的代码行,导致杂乱或“意大利面”代码并且应该尽量避免,你可以创建一个命名的方法来处理这个动作:
AddNumbers() { SomeNumber + AnotherNumber }
现在AddNumbers
就像一个变量一样占据着内存中的位置;但是,它不是一个值,而是一系列指令。在脚本中的任何地方使用方法的名称(或调用它)都可以让你立即使用存储的指令,而无需重复任何代码。
如果你发现自己一遍又一遍地写相同的代码行,你很可能错过了简化或将重复操作合并为常见方法的机会。
这会产生程序员开玩笑称之为意大利面代码的东西,因为它可能会变得混乱。你也会听到程序员提到一个叫做不要重复自己(DRY)原则的解决方案,这是一个你应该牢记的口头禅。
和以前一样,一旦我们在伪代码中看到了一个新概念,最好是自己实现一下,这就是我们将在下一节中做的事情。
让我们再次打开LearningCurve
,看看 C#中的方法是如何工作的。就像变量示例一样,你会想要将代码粘贴到你的脚本中,就像下面的截图中显示的那样。我已经删除了以前的示例代码,以使事情更整洁,但你当然可以将其保留在脚本中以供参考:
在 Visual Studio 中打开
LearningCurve
。在第 8 行添加一个新变量:
public int AddedAge = 1;
- 在第 16 行添加一个新的方法,将
CurrentAge
和AddedAge
相加并打印出结果:
void ComputeAge() { Debug.Log(CurrentAge + AddedAge); }
- 在
Start
中调用新方法,使用以下行:
ComputeAge();
在 Unity 中运行脚本之前,请确保您的代码看起来像以下截图:
图 2.6:具有新的 ComputeAge 方法的 LearningCurve
- 保存文件,然后返回 Unity 并点击播放,看看新的控制台输出。
您在第 16 到 19 行定义了您的第一个方法,并在第 13 行调用了它。现在,无论何时调用ComputeAge()
,这两个变量都将被相加并打印到控制台上,即使它们的值发生变化。请记住,您在 Unity 检视器中将CurrentAge
设置为18
,检视器的值将始终覆盖 C#脚本中的值:
图 2.7:更改检视器中变量值的控制台输出
继续尝试在检视器面板中尝试不同的变量值,看看它是如何运作的!关于您刚刚编写的实际代码语法的更多细节将在下一章中介绍。
在我们掌握了方法的整体概念之后,我们准备好着手处理编程领域中最大的主题——类!
介绍类
我们已经看到变量存储信息,方法执行操作,但是我们的编程工具包仍然有些有限。我们需要一种创建一种超级容器的方法,其中包含可以从容器内部引用的变量和方法。输入类:
概念上,类在单个容器内保存相关信息、操作和行为。它们甚至可以相互通信。
技术上,类是数据结构。它们可以包含变量、方法和其他编程信息,当类的对象被创建时,所有这些信息都可以被引用。
实际上,类是一个蓝图。它为使用类蓝图创建的任何对象(称为实例)制定了规则和法规。
您可能已经意识到类不仅在 Unity 中存在,而且在现实世界中也存在。接下来,我们将看一下最常见的 Unity 类以及类在实际中的功能。
您可以在 Microsoft C#文档中找到有关类的深入指南docs.microsoft.com/en-us/dotnet/csharp/fundamentals/types/classes
。
一个常见的 Unity 类
在您想知道 C#中的类是什么样子之前,您应该知道您在整个本章中一直在使用一个类。默认情况下,Unity 中创建的每个脚本都是一个类,您可以从第 5 行的class
关键字中看到:
public class LearningCurve: MonoBehaviour
MonoBehaviour
只是意味着这个类可以附加到 Unity 场景中的 GameObject 上。
类可以独立存在,当我们在第五章中创建独立类时,我们将看到这一点。
有时在 Unity 资源中,脚本和类这两个术语是可以互换使用的。为了保持一致,我将在脚本附加到 GameObject 时将 C#文件称为脚本,并在它们是独立的类时称为类。
类是蓝图
对于我们的最后一个例子,让我们想想一个当地的邮局。它是一个独立的、自包含的环境,具有属性,比如物理地址(一个变量),以及执行动作的能力,比如寄出您的邮件(方法)。
这使得邮局成为一个潜在类的绝佳例子,我们可以在以下伪代码块中概述:
public class PostOffice { // Variables public string address = "1234 Letter Opener Dr." // Methods DeliverMail() {} SendMail() {} }
这里的主要要点是,当信息和行为遵循预定义的蓝图时,复杂的操作和类间通信变得可能。例如,如果我们有另一个类想要通过我们的PostOffice
类发送一封信,它不必想知道去哪里执行此操作。它可以简单地从PostOffice
类中调用SendMail
函数,如下所示:
PostOffice().SendMail()
或者,您可以使用它查找邮局的地址,这样您就知道在哪里寄信:
PostOffice().address
如果你对单词之间使用句点(称为点表示法)有疑问,我们将在下一节中详细介绍。
类之间的通信
到目前为止,我们已经将类和 Unity 组件描述为独立的实体;实际上,它们是紧密相连的。要创建任何有意义的软件应用程序,都需要在类之间进行某种形式的交互或通信。
如果你还记得之前的邮局例子,示例代码使用句点(或点)来引用类、变量和方法。如果你把类想象成信息目录,那么点表示法就是索引工具:
PostOffice().Address
类中的任何变量、方法或其他数据类型都可以用点表示法访问。这也适用于嵌套或子类信息,但我们将在第五章“使用类、结构和面向对象编程”中讨论所有这些主题。
点表示法也是驱动类之间通信的工具。每当一个类需要另一个类的信息或想要执行它的方法时,都会使用点表示法:
PostOffice().DeliverMail()
点表示法有时被称为.
运算符,所以如果在文档中看到这种提法,不要感到困惑。
如果点表示法还没有完全理解,不要担心,它会的。它是整个编程体系的血脉,将信息和上下文传递到需要的地方。
现在你对类有了更多了解,让我们谈谈你在编程生涯中最常用的工具——注释!
处理注释
你可能已经注意到LearningCurve
有一行奇怪的文本(图 2.6中的10),以两个斜杠开头,这是脚本默认创建的。
这些是代码注释!在 C#中,有几种方法可以用来创建注释,而 Visual Studio(和其他代码编辑应用程序)通常会通过内置快捷方式使其更加容易。
一些专业人士可能不认为注释是编程的基本构建块,但我不得不尊重地不同意。正确地用有意义的信息注释你的代码是新程序员可以养成的最基本的习惯之一。
单行注释
以下单行注释与我们在LearningCurve
中包含的注释类似:
// This is a single-line comment
Visual Studio 不会将以两个斜杠开头(没有空格)的行编译为代码,因此你可以根据需要使用它们来向他人或未来的自己解释你的代码。
多行注释
由于名称中有,你可以合理地假设单行注释只适用于一行代码。如果你想要多行注释,你需要在注释文本周围使用斜杠和星号(分别作为开头和结尾字符):/*
和*/
。
/* this is a multi-line comment */
你也可以通过在 macOS 上使用Cmd
+ /
快捷键和在 Windows 上使用Ctrl
+ K
+ C
来对代码块进行注释和取消注释。
Visual Studio 还提供了一个方便的自动生成注释功能;在任何代码行(变量、方法、类等)的前一行输入三个斜杠,将出现一个摘要注释块。
看到示例注释是好的,但在你的代码中加入它们总是更好的。现在开始注释永远不会太早!
添加注释
打开LearningCurve
,在ComputeAge()
方法上方添加三个反斜杠:
图 2.8:为方法自动生成的三行注释
你应该看到一个三行注释,其中包含由 Visual Studio 从方法名称生成的方法描述,夹在两个<summary>
标签之间。当然,你可以通过按Enter
键添加新行来更改文本,但一定不要触碰<summary>
标签,否则 Visual Studio 将无法正确识别注释。
这些详细注释的有用之处在于,当您想了解自己编写的方法时,它就会变得清晰。如果您使用了三个斜杠的注释,只需将鼠标悬停在类或脚本中调用方法的任何位置,Visual Studio 就会弹出您的摘要:
图 2.9:带有注释摘要的 Visual Studio 弹出信息框
您的基本编程工具包现在已经完成(至少是理论抽屉)。然而,我们仍然需要了解本章中所学内容在 Unity 游戏引擎中的应用,这将是我们下一节的重点!
组合基本组件
在处理完基本组件之后,现在是时候在结束本章之前进行一些 Unity 特定的整理工作了。具体来说,我们需要更多地了解 Unity 如何处理附加到游戏对象的 C#脚本。
在这个例子中,我们将继续使用我们的LearningCurve
脚本和 Main Camera 游戏对象。
脚本变成组件
所有的游戏对象组件都是脚本,无论是你自己编写的还是 Unity 团队编写的。唯一的区别是 Unity 特定的组件,比如Transform
,以及它们各自的脚本,不应该被用户编辑。
一旦您创建的脚本被放置到游戏对象上,它就会成为该对象的另一个组件,这就是为什么它会出现在检视面板中。对于 Unity 来说,它像任何其他组件一样行走、交谈和行动,包括组件下面的公共变量,可以随时更改。尽管我们不应该编辑 Unity 提供的组件,但我们仍然可以访问它们的属性和方法,使它们成为强大的开发工具。
当脚本成为组件时,Unity 还会进行一些自动的可读性调整。您可能已经注意到在图 2.3和2.5中,当我们将LearningCurve
添加到 Main Camera 时,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 的文档熟悉度有了很大提升,我为您准备了一个简短的可选挑战!
英雄的试炼-脚本 API 中的 MonoBehaviour
现在是时候让您自己熟悉使用 Unity 文档了,还有什么比查找一些常见的MonoBehaviour
方法更好的方法呢:
尝试在脚本 API 中搜索
Start()
和Update()
方法,以更好地了解它们在 Unity 中的作用,以及何时如果您感到勇敢,可以进一步查看手册中的
MonoBehaviour
类,以获得更详细的解释
总结
我们在短短的几页中走了很长的路,但是理解变量、方法和类等基本概念的总体理论将为您打下坚实的基础。请记住,这些构建块在现实世界中有非常真实的对应物。变量保存值,就像邮箱保存信件一样;方法存储指令,就像食谱一样,用于预定义的结果;类就像真实的蓝图一样。如果您希望房子能够屹立不倒,就不能没有经过深思熟虑的设计来遵循。
本书的其余部分将带您深入学习 C#语法,从头开始,从下一章开始更详细地介绍如何创建变量、管理值类型以及使用简单和复杂的方法。
小测验-C#构建块
变量的主要目的是什么?
方法在脚本中扮演什么角色?
脚本如何成为组件?
点符号的目的是什么?
加入我们的 Discord!
与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事会话与作者交谈,以及更多。
立即加入!
第三章:深入变量、类型和方法
进入任何编程语言的初始步骤都会受到一个基本问题的困扰——你可以理解打出的字,但不知道它们背后的含义。通常情况下,这会导致悖论,但编程是一个特殊情况。
C#并不是一种独立的语言;它是用英语编写的。你每天使用的词语和在 Visual Studio 中的代码之间的差异来自于缺少上下文,这是需要重新学习的东西。你知道如何说和拼写 C#中使用的词语,但你不知道的是它们在语言的语法中是如何组成的,以及最重要的是如何组成的。
这一章标志着我们离开了编程理论,开始了我们进入实际编码的旅程。我们将讨论接受的格式化、调试技术,并组合更复杂的变量和方法示例。有很多内容要涵盖,但当你达到最后的测验时,你将对以下高级主题感到舒适:
写正确的 C#
调试你的代码
理解变量
引入运算符
定义方法
让我们开始吧!
写正确的 C#
代码行就像句子一样,意味着它们需要有某种分隔或结束字符。每一行 C#代码,称为语句,必须以分号结尾,以便编译器对其进行处理。
然而,你需要注意一个问题。与我们熟悉的书面语言不同,C#语句在技术上不一定要在一行上;空格和换行符会被代码编译器忽略。例如,一个简单的变量可以这样写:
public int FirstName = "Harrison";
或者,它也可以这样写:
public int FirstName = "Harrison";
这两个代码片段对 Visual Studio 来说都是完全可以接受的,但第二个选项在软件社区中是极为不鼓励的,因为它使得代码极其难以阅读。理念是尽可能高效和清晰地编写你的程序。
有时候一条语句会太长,无法合理地放在一行上,但这种情况很少。只要确保它的格式能让别人理解,并且不要忘记分号。
你需要牢记的第二个格式化规则是使用花括号:{}
。方法、类和接口在声明后都需要一对花括号。我们稍后会详细讨论这些内容,但是早点把标准格式化记在脑海中是很重要的。
C#的传统做法是将每个括号放在新的一行,就像下面的方法所示:
public void MethodName() { }
然而,你可能会看到第一个花括号与声明在同一行的情况。这完全取决于个人偏好:
public void MethodName() { }
虽然这不是什么让你抓狂的事情,但重要的是要保持一致。在本书中,我们将坚持使用“纯粹”的 C#代码,它总是将每个括号放在新的一行,而与 Unity 和游戏开发有关的 C#示例通常会遵循第二个例子。
良好、一致的格式化风格在编程初学者中至关重要,但能够看到你的工作成果也同样重要。在下一节中,我们将讨论如何将变量和信息直接打印到 Unity 控制台。
调试你的代码
当我们通过实际示例进行工作时,我们需要一种方法将信息和反馈打印到 Unity 编辑器中的控制台窗口。这个程序术语称为调试,C#和 Unity 都提供了辅助方法,使开发人员更容易进行这个过程。你已经从上一章调试了你的代码,但我们并没有详细讨论它是如何工作的。让我们来解决这个问题。
每当我要求你调试或打印出某些东西时,使用以下方法之一:
- 对于简单的文本或单个变量,使用标准的
Debug.Log()
方法。文本需要放在一对括号内,变量可以直接使用,不需要添加其他字符;例如:
Debug.Log("Text goes here."); Debug.Log(CurrentAge);
这将在控制台面板中产生以下结果:
图 3.1:观察 Debug.Log 输出
- 对于更复杂的调试,使用
Debug.LogFormat()
。这将允许您在打印的文本中使用占位符来放置变量。这些占位符用一对花括号标记,每个花括号包含一个索引。索引是一个常规数字,从 0 开始,依次递增 1。在下面的示例中,{0}
占位符被CurrentAge
的值替换,{1}
被FirstName
替换,依此类推:
Debug.LogFormat("Text goes here, add {0} and {1} as variable placeholders", CurrentAge, FirstName);
这将在控制台面板中产生以下结果:
图 3.2:观察 Debug.LogFormat 输出
您可能已经注意到我们在调试技术中使用了点符号,没错!Debug 是我们使用的类,Log()
和LogFormat()
是我们可以从该类中使用的不同方法。本章末尾将详细介绍这一点。
有了调试的能力,我们可以安全地继续深入了解变量声明的方式,以及语法可以如何发挥作用。
理解变量
在上一章中,我们看到了变量的写法,并简要介绍了它们提供的高级功能。然而,我们仍然缺少使所有这些成为可能的语法。
声明变量
变量不会只是出现在 C#脚本的顶部;它们必须根据特定的规则和要求进行声明。在最基本的层面上,变量声明需要满足以下要求:
需要指定变量将存储的数据类型
变量必须有一个唯一的名称
如果有一个赋值,它必须与指定的类型匹配
变量声明需要以分号结束
遵守这些规则的结果是以下语法:
dataType UniqueName = value;
变量需要唯一的名称,以避免与 C#已经使用的关键字发生冲突。您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/index
找到受保护关键字的完整列表。
这很简单,整洁,高效。然而,如果只有一种方式创建如此普遍的变量,那么编程语言在长期内将毫无用处。复杂的应用程序和游戏有不同的用例和场景,所有这些都有独特的 C#语法。
类型和值的声明
创建变量最常见的情况是在声明时已经有了所有必需的信息。例如,如果我们知道玩家的年龄,存储它就像这样简单:
int CurrentAge = 32;
在这里,所有基本要求都得到了满足:
指定了数据类型,即
int
(整数的缩写)使用了一个唯一的名称,即
CurrentAge
32
是一个整数,与指定的数据类型匹配该语句以分号结束
然而,有时候你会想要声明一个变量,但并不知道它的值。我们将在接下来的部分讨论这个话题。
仅类型声明
考虑另一种情况——你知道你想要一个变量存储的数据类型和它的名称,但不知道它的值。值将在其他地方计算和赋值,但你仍然需要在脚本的顶部声明变量。这种情况非常适合仅类型声明:
int CurrentAge;
只有类型(int
)和唯一名称(CurrentAge
)被定义,但语句仍然有效,因为我们遵循了规则。没有分配的值,将根据变量的类型分配默认值。在这种情况下,CurrentAge
将被设置为0
,这与int
类型匹配。一旦变量的实际值变得可用,就可以通过引用变量名并为其分配一个值来轻松地在单独的语句中设置它:
CurrentAge = 32;
您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/default-values
找到所有 C#类型及其默认值的完整列表。
此时,您可能会问为什么到目前为止,我们的变量还没有包括public
关键字,即访问修饰符,这是我们在早期脚本示例中看到的。答案是我们没有必要的基础来清楚地谈论它们。现在我们有了这个基础,是时候详细讨论它们了。
使用访问修饰符
现在基本语法不再是一个谜,让我们深入了解变量语句的细节。由于我们从左到右阅读代码,因此从传统上来说,从关键字开始进行变量深入研究是有意义的。
快速回顾一下我们在前一章中在LearningCurve
中使用的变量,您会发现它们在语句的开头有一个额外的关键字:public
。这就是变量的访问修饰符。将其视为安全设置,确定谁和什么可以访问变量的信息。
任何没有标记为public
的变量都默认为private
,并且不会显示在 Unity Inspector 面板中。
如果包括一个修饰符,我们在本章开头组合的更新语法配方将如下所示:
accessModifier dataType UniqueName = value;
在声明变量时,明确的访问修饰符并不是必需的,但作为新程序员,养成这样的习惯是很好的。这个额外的词在代码的可读性和专业性方面有很大帮助。
C#中有四种主要的访问修饰符,但作为初学者,您最常使用的两种是以下两种:
Public:对任何脚本都是可用的,没有限制。
Private:仅在创建它们的类中可用(称为包含类)。任何没有访问修饰符的变量默认为私有。
两个高级修饰符具有以下特点:
Protected:可从包含类或从中派生的类型访问
Internal:仅在当前程序集中可用
每个修饰符都有特定的用例,但在我们进入高级章节之前,不要担心protected和internal。
两种组合修饰符也存在,但在本书中我们不会使用它们。您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/access-modifiers
找到更多关于它们的信息。
让我们尝试一些自己的访问修饰符!就像现实生活中的信息一样,有些数据需要受到保护或与特定人分享。如果变量在Inspector窗口中不需要更改或从其他脚本中访问,那么它就是私有访问修饰符的一个很好的选择。
执行以下步骤来更新LearningCurve
:
图 3.3:附加到主摄像机的 LearningCurve 脚本组件
由于CurrentAge
现在是私有的,它不再在检视器窗口中可见,只能在LearningCurve
脚本中的代码中访问。如果我们点击播放,脚本仍然会像以前一样正常工作。
这是我们进入变量的旅程的一个良好开端,但我们仍然需要了解它们可以存储什么类型的数据。这就是数据类型的作用,我们将在下一节中进行讨论。
使用类型
将特定类型分配给变量是一个重要的选择,它会影响变量在整个生命周期中的每次交互。由于 C#是所谓的强类型或类型安全语言,每个变量都必须有一个数据类型,没有例外。这意味着在执行特定类型的操作时有特定的规则,并且在将给定变量类型转换为另一个类型时有规定。
常见的内置类型
C#中的所有数据类型都从一个共同的祖先System.Object
(在编程术语中称为派生)派生下来。这个层次结构称为公共类型系统(CTS),意味着不同类型有很多共享功能。下表列出了一些最常见的数据类型选项以及它们存储的值:
图 3.4:变量的常见数据类型
除了指定变量可以存储的值的类型之外,类型还包含有关自身的其他信息,包括以下内容:
所需的存储空间
最小和最大值
允许的操作
内存中的位置
可访问的方法
基本(派生)类型
如果这看起来令人不知所措,请深呼吸。使用 C#提供的所有类型是使用文档而不是记忆的完美示例。很快,即使是最复杂的自定义类型的使用也会变得轻而易举。
您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/types/index
找到所有 C#内置类型及其规格的完整列表。
在类型列表成为难点之前,最好先尝试它们。毕竟,学习新东西的最佳方式是使用它,打破它,然后学会修复它。
打开LearningCurve
并根据前面图表中常见内置类型部分的每种类型添加一个新变量。您使用的名称和值由您决定;只需确保它们标记为公共,以便我们可以在检视器窗口中看到它们。如果需要灵感,可以看看我的代码:
public class LearningCurve : MonoBehaviour { private int CurrentAge = 30; public int AddedAge = 1; **public****float** **Pi =** **3.14f****;** **public****string** **FirstName =** **"Harrison"****;** **public****bool** **IsAuthor =** **true****;** // Start is called before the first frame update void Start() { ComputeAge(); } /// <summary> /// Time for action - adding comments /// Computes a modified age integer /// </summary> void ComputeAge() { Debug.Log(CurrentAge + AddedAge); } }
在处理字符串类型时,实际文本值需要放在一对双引号中,而浮点值需要以小写f
结尾,就像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#中命名约定的工作方式,以及变量在我们的脚本中的位置。
变量命名
为变量选择名称可能看起来像是在考虑访问修饰符和类型之后的事情,但它不应该是一个简单的选择。在代码中清晰一致的命名约定不仅会使其更易读,而且还会确保团队中的其他开发人员了解您的意图,而无需询问。
在命名变量时的第一个规则是,您给出的名称应该是有意义的;第二个规则是使用帕斯卡命名法。让我们以游戏中常见的一个例子来看,声明一个变量来存储玩家的健康:
public int Health = 100;
如果发现自己声明变量像这样,你的脑中应该响起警报。谁的健康?它是存储最大值还是最小值?当此值更改时,将受到影响的其他代码是什么?这些都是有意义的变量名称应该很容易回答的问题;你不希望在一周或一个月后被自己的代码搞糊涂。
说到这一点,让我们尝试使用帕斯卡命名法使其更好一些:
public int MaxPlayerHealth = 100;
记住,帕斯卡命名法将变量名称中的每个单词的首字母大写。
这样好多了。经过一点思考,我们已经更新了变量名称并赋予了意义和上下文。由于在变量名称的长度方面没有技术限制,您可能会发现自己过度并写出过于描述性的名称,这将给您带来问题,就像短的、不描述性的名称一样。
一般规则是,将变量名称描述得尽可能清楚——不多也不少。找到您的风格并坚持下去。
理解变量范围
我们已经深入了解了变量,但还有一个重要的主题需要讨论:范围。类似于访问修饰符,确定外部类可以获取变量信息的方式,变量范围是用来描述给定变量存在的位置及其在其包含类中的访问点的术语。
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
中,而你并不知道你在做什么。这一次,让我们有意识地创建一个方法:
- 声明一个带有 void 返回类型的
public
方法,名为GenerateCharacter()
:
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
添加到 level 参数来计算的。我们还没有指定如何或是否要使用这个返回值,这意味着现在脚本不会做任何新的事情。
现在,问题是:我们如何捕获和使用新添加的返回值?嗯,我们将在下一节中讨论这个话题。
使用返回值
在使用返回值时,有两种可用的方法:
创建一个本地变量来捕获(存储)返回的值。
使用调用方法本身作为返回值的替代,就像使用变量一样。调用方法是实际触发指令的代码行,在我们的示例中,就是
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()
。
开始方法
Unity 在脚本第一次启用时的第一帧调用Start()
方法。由于MonoBehaviour
脚本几乎总是附加到场景中的GameObjects上,它们的附加脚本在加载时同时启用。在我们的项目中,LearningCurve
附加到Main CameraGameObject上,这意味着它的Start()
方法在主摄像机加载到场景时运行。Start()
主要用于设置变量或执行需要在Update()
第一次运行之前发生的逻辑。
到目前为止,我们所做的示例都使用了Start()
,即使它们并没有执行设置操作,这并不是通常的使用方式。然而,它只会执行一次,使其成为在控制台上显示一次性信息的绝佳工具。
除了Start()
,默认情况下你会遇到另一个重要的 Unity 方法:Update()
。在我们完成本章之前,让我们在下一节中熟悉一下它的工作原理。
更新方法
如果你花足够的时间查看 Unity 脚本参考中的示例代码(docs.unity3d.com/ScriptReference/
),你会注意到绝大多数代码都是使用Update()
方法执行的。当你的游戏运行时,场景窗口会以每秒多次的频率显示,这被称为帧率或每秒帧数(FPS)。
每帧显示后,Unity 都会调用Update()
方法,使其成为游戏中执行次数最多的方法之一。这使其非常适合检测鼠标和键盘输入或运行游戏逻辑。
如果你对你的机器的 FPS 评级感到好奇,那就在 Unity 中点击Stats选项卡,并在Game视图的右上角点击播放:
图 3.18:Unity 编辑器显示带有图形 FPS 计数的 Stats 面板
在你最初的 C#脚本中,你将会大量使用Start()
和Update()
方法,所以要熟悉它们。话虽如此,你已经掌握了本章提供的 C#编程中最基本的构建模块。
摘要
这一章从编程的基本理论和构建模块迅速下降到了真实代码和 C#语法的层面。我们看到了代码格式的好坏形式,学会了如何在 Unity 控制台中调试信息,并创建了我们的第一个变量。
C#类型、访问修饰符和变量作用域紧随其后,当我们在检视器窗口中使用成员变量并开始涉足方法和操作领域时。
方法帮助我们理解代码中的书面指令,但更重要的是,如何正确地利用它们的力量来实现有用的行为。输入参数、返回类型和方法签名都是重要的主题,但它们真正提供的是执行新类型行为的潜力。
你现在掌握了编程的两个基本构建模块;从现在开始,你所做的几乎都是这两个概念的延伸或应用。
在下一章中,我们将看一下 C#类型的一个特殊子集,称为集合,它可以存储相关数据组,并学习如何编写基于决策的代码。
小测验-变量和方法
在 C#中正确书写变量名的方法是什么?
如何使一个变量出现在 Unity 的检视器窗口中?
C#中有哪四种访问修饰符?
在类型之间何时需要显式转换?
定义方法的最低要求是什么?
方法名后面的括号的目的是什么?
方法定义中
void
的返回类型意味着什么?Unity 中
Update()
方法被调用的频率是多少?
加入我们的 Discord!
与其他用户一起阅读本书,与 Unity/C#专家和 Harrison Ferrone 一起阅读。提问,为其他读者提供解决方案,通过Ask Me Anything sessions与作者交流等等。
立即加入!
第四章:控制流和集合类型
计算机的一个核心职责是在满足预定条件时控制发生的事情。当你点击一个文件夹时,你期望它会打开;当你在键盘上输入时,你期望文本会反映你的击键。为应用程序或游戏编写代码也是一样的——它们在一种状态下需要以某种方式行为,而在条件改变时则需要另一种方式。在编程术语中,这被称为控制流,这很合适,因为它控制了代码在不同情况下的执行流程。
除了使用控制语句,我们还将亲自了解集合数据类型。集合是一类允许在单个变量中存储多个值和值组合的类型。我们将把本章分解为以下主题:
选择语句
使用数组、字典和列表集合
使用
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."); } } }
当提到条件被满足时,我的意思是它评估为 true,这通常被称为通过条件。
- 在需要无论条件是否为真都需要采取行动的情况下,可以添加一个
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
,我们可以将代码序列分解如下:
if
语句和调试日志被跳过,因为CurrentGold
不大于50
。else-if
语句和调试日志也被跳过,因为CurrentGold
不小于15
。由于 32 既不小于 15 也不大于 50,之前的条件都没有满足。
else
语句执行并显示第三个调试日志:
图 4.1:控制台截图显示调试输出
在自己尝试一些其他CurrentGold
值之后,让我们讨论一下如果我们想测试一个失败的条件会发生什么。
使用 NOT 运算符
用例并不总是需要检查正条件或true
条件,这就是NOT
运算符发挥作用的地方。用一个感叹号写成的NOT
运算符允许if
或else-if
语句满足负条件或 false 条件。这意味着以下条件是相同的:
if(variable == false) // AND if(!variable)
正如您已经知道的那样,您可以在if
条件中检查布尔值、文字值或表达式。因此,NOT
运算符必须是可适应的。
看一下以下两个不同负值hasDungeonKey
和weaponType
在if
语句中的使用示例:
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
语句评估为 true 并执行其代码块。”
如果你在想一个 false 值怎么能评估为 true,可以这样想:if
语句并不是检查值是否为 true,而是检查表达式本身是否为 true。hasDungeonKey
可能被设置为 false,但这就是我们要检查的,所以在if
条件的上下文中是 true。
- 第二条语句可以翻译为,“如果
weaponType
的字符串值不等于
Longsword
,则执行此代码块。”
您可以在以下截图中看到调试结果:
图 4.2:控制台截图显示 NOT 运算符的输出
但是,如果你还是感到困惑,可以将我们在本节中看到的代码复制到LearningCurve
中,并尝试改变变量的值,直到弄明白为止。
到目前为止,我们的分支条件相当简单,但 C#也允许条件语句嵌套在彼此内部,以处理更复杂的情况。
嵌套语句
if-else
语句最有价值的功能之一是它们可以嵌套在彼此内部,从而在代码中创建复杂的逻辑路线。在编程中,我们称它们为决策树。就像真正的走廊一样,后面可能有门,从而创造出一系列可能性的迷宫:
public class LearningCurve : MonoBehaviour { public bool weaponEquipped = true; public string weaponType = "Longsword"; void Start() { if(weaponEquipped) { if(weaponType == "Longsword") { Debug.Log("For the Queen!"); } } else { Debug.Log("Fists aren't going to work against armor..."); } } }
让我们分解前面的例子:
首先,一个
if
语句检查我们是否装备了武器。此时,代码只关心它是否为true
,而不关心它是什么类型的武器。第二个
if
语句检查weaponType
并打印出相关的调试日志。如果第一个
if
语句评估为false
,代码将跳转到else
语句及其调试日志。如果第二个if
语句评估为false
,则不会打印任何内容,因为没有else
语句。
处理逻辑结果的责任完全在程序员身上。你需要确定代码可能走的分支或结果。
到目前为止,你学到的东西将让你轻松应对简单的用例。然而,你很快会发现自己需要更复杂的语句,这就是评估多个条件的地方。
评估多个条件
除了嵌套语句,还可以将多个条件检查组合成单个 if
或 else-if
语句,使用 AND
OR
逻辑运算符:
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
语句,检查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
语句中指定多个条件。这个术语叫做贯穿,有时也叫贯穿案例。贯穿案例允许您为多个情况定义一组操作。如果一个case
块为空或者有没有break
关键字的代码,它将贯穿到直接下面的case
。这有助于保持switch
代码的清晰和高效,避免重复的case
块。
case
可以以任何顺序编写,因此创建贯穿案例大大增加了代码的可读性和效率。
让我们模拟一个桌面游戏场景,使用switch
语句和贯穿案例,其中骰子的点数决定了特定动作的结果:
- 创建一个
int
变量,名为DiceRoll
,并将其赋值为7
:
int DiceRoll = 7;
创建一个没有返回值的
public
方法,名为RollDice
,并在Start
内调用它。添加一个
switch
语句,使用DiceRoll
作为匹配表达式:
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,and,or but
通过以下问题测试您的知识:
用于评估
if
语句的值是什么?哪个运算符可以将真条件变为假,假条件变为真?
如果
if
语句的代码需要两个条件都为真才能执行,您会使用什么逻辑运算符来连接这些条件?如果
if
语句的代码只需要两个条件中的一个为真才能执行,您会使用什么逻辑运算符来连接这两个条件?
完成后,您就可以进入集合数据类型的世界了。这些类型将为您的游戏和 C#程序打开全新的编程功能子集!
一览集合
到目前为止,我们只需要变量来存储单个值,但有许多情况需要一组值。C#中的集合类型包括数组、字典和列表,每种类型都有其优势和劣势,我们将在接下来的部分讨论。
数组
数组是 C#提供的最基本的集合。将它们视为一组值的容器,在编程术语中称为元素,每个元素都可以单独访问或修改:
数组可以存储任何类型的值;所有元素都需要是相同的类型。
数组的长度或元素数量在创建时确定,之后不能修改。
如果在创建数组时未分配初始值,则每个元素将被赋予默认值。存储数字类型的数组默认为零,而任何其他类型都设置为 null 或 nothing。
数组是 C#中最不灵活的集合类型。这主要是因为元素在创建后无法添加或删除。然而,当存储不太可能改变的信息时,它们特别有用。这种缺乏灵活性使它们与其他集合类型相比更快。
声明数组与我们之前使用的其他变量类型类似,但有一些修改:
数组变量需要指定的元素类型、一对方括号和一个唯一的名称。
new
关键字用于在内存中创建数组,后跟值类型和另一对方括号。保留的内存区域的大小与您打算存储在新数组中的数据的确切大小相同。数组将存储的元素数量放在第二对方括号中。
在蓝图形式中,它看起来像这样:
elementType[] name = new elementType[numberOfElements];
让我们举一个例子,我们需要存储游戏中的前三个最高分:
int[] topPlayerScores = new int[3];
topPlayerScores
被分解为一个将存储三个整数元素的整数数组。由于我们没有添加任何初始值,topPlayerScores
中的三个值都是0
。但是,如果更改数组大小,则原始数组的内容将丢失,因此要小心。
您可以在变量声明的末尾将值直接赋给数组,方法是将它们添加到一对花括号中。C#有一种长格式和短格式的做法,但两者都是有效的:
// Longhand initializer int[] topPlayerScores = new int[] {713, 549, 984}; // Shortcut initializer int[] topPlayerScores = { 713, 549, 984 };
使用简写语法初始化数组非常常见,因此我将在本书的其余部分中使用它。但是,如果您想提醒自己有关细节,可以随时使用显式措辞。
现在声明语法不再是一个谜了,让我们来谈谈数组元素是如何存储和访问的。
索引和下标
每个数组元素都按分配顺序存储,这称为它的索引。数组是从零开始索引的,这意味着元素顺序从零开始而不是从一开始。将元素的索引视为其引用或位置。
在topPlayerScores
中,第一个整数452
位于索引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
内部创建一个名为QuestPartyMembers
的string
类型的新List
,并用三个角色的名称初始化它:
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<keyType, valueType> name = new Dictionary<keyType, valueType>();
要使用键值对初始化字典,请执行以下操作:
在声明的末尾使用一对花括号。
将每个元素添加到其花括号对中,键和值用逗号分隔。
用逗号分隔元素,最后一个元素的逗号是可选的。
它看起来像这样:
Dictionary<keyType, valueType> name = new Dictionary<keyType, valueType>() { {key1, value1}, {key2, value2} };
在选择键值时需要考虑的一个重要注意事项是,每个键必须是唯一的,且不能更改。如果需要更新键,则需要在变量声明中更改其值,或者在代码中删除整个键值对并添加另一个,我们将在下面看到。
就像数组和列表一样,字典可以在一行上初始化,而不会受到来自 Visual Studio 的问题。然而,像前面的例子中那样在每一行上写出每个键值对,是一个良好的习惯——无论是为了可读性还是为了你的理智。
让我们创建一个字典来存储角色可能携带的物品:
在
Start
方法中声明一个key
类型为string
,value
类型为int
的Dictionary
,名为ItemInventory
。将其初始化为
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
表达式用于增加或减少(递增或递减)initializer
,这意味着下次循环评估其条件时,initializer
将不同。
通过增加和减少 1 来增加和减少一个值分别称为递增和递减(--
将一个值减少 1,++
将一个值增加 1)。
这听起来很复杂,让我们用我们之前创建的QuestPartyMembers
列表来看一个实际的例子:
List<string> QuestPartyMembers = new List<string>() { "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight"}; for (int i = 0; i < QuestPartyMembers.Count; i++) { Debug.LogFormat("Index: {0} - {1}", i, QuestPartyMembers[i]); }
让我们再次通过循环并看看它是如何工作的:
首先,在
for
循环中的initializer
被设置为一个名为i
的本地int
变量,初始值为0
。为了确保我们永远不会得到超出范围的异常,
for
循环确保只有在i
小于QuestPartyMembers
中元素的数量时才运行另一次:
对于数组,我们使用
Length
属性来确定它有多少项。对于列表,我们使用
Count
属性
最后,
i
每次循环运行时都会增加 1,使用++
运算符。在
for
循环内部,我们刚刚使用i
打印出了该索引和该索引处的列表元素。注意,
i
与集合元素的索引保持一致,因为两者都从 0 开始!
图 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!"); } } }
控制台输出应该几乎相同,只是现在有一个额外的调试日志——当 Merlin 轮到他通过循环时,这个日志只打印了一次。更具体地说,当i
在第二次循环时等于1
时,if
语句触发了,打印出了两个日志而不是一个:
图 4.11:打印列表值和匹配 if 语句的 for 循环的屏幕截图
在适当的情况下,使用标准的for
循环可能非常有用,但在编程中很少只有一种方法,这就是foreach
语句发挥作用的地方。
foreach 循环
foreach
循环获取集合中的每个元素,并将每个元素存储在本地变量中,使其在语句内可访问。本地变量类型必须与集合元素类型匹配才能正常工作。foreach
循环可以与数组和列表一起使用,但与字典一起使用尤其有用,因为字典是键值对而不是数字索引。
以蓝图形式,foreach
循环看起来像这样:
foreach(elementType localName in collectionVariable) { code block; }
让我们继续使用QuestPartyMembers
列表示例,并为其每个元素进行点名:
List<string> QuestPartyMembers = new List<string>() { "Grim the Barbarian", "Merlin the Wise", "Sterling the Knight"}; foreach(string partyMember in QuestPartyMembers) { Debug.LogFormat("{0} - Here!", partyMember); }
我们可以将其分解如下:
元素类型声明为
string
,与QuestPartyMembers
中的值匹配。创建一个名为
partyMember
的本地变量,以便在循环重复时保存每个元素。
图 4.12:打印列表值的 foreach 循环的屏幕截图
这比for
循环简单得多。但是,在处理字典时,有一些重要的区别需要提到,即如何处理键值对作为本地变量。
循环遍历键值对
要在本地变量中捕获键值对,我们需要使用名为KeyValuePair
的类型,将键和值类型分配为与字典对应类型相匹配。由于KeyValuePair
是其类型,它就像任何其他元素类型一样,作为本地变量。
例如,让我们循环遍历我们在字典部分中创建的ItemInventory
字典,并调试每个键值对,就像商店物品描述一样:
Dictionary<string, int> `I`temInventory = new Dictionary<string, int>() { { "Potion", 5}, { "Antidote", 7}, { "Aspirin", 1} }; foreach(KeyValuePair<string, int> kvp in `I`temInventory) { Debug.LogFormat("Item: {0} - {1}g", kvp.Key, kvp.Value); }
我们指定了一个名为KeyValuePair
的本地变量kvp
,这是编程中的一种常见命名惯例,就像将for
循环初始化器称为i
,并将key
和value
类型设置为string
和int
以匹配ItemInventory
。
要访问本地kvp
变量的键和值,我们分别使用KeyValuePair
的Key
和Value
属性。
在这个例子中,键是字符串
,值
是整数,我们可以将其打印出来作为项目名称和项目价格:
图 4.13:打印字典键值对的 foreach 循环的屏幕截图
如果你感到特别有冒险精神,可以尝试以下可选挑战,以加深你刚刚学到的知识。
英雄的试炼-寻找实惠的物品
使用前面的脚本,创建一个变量来存储你虚构角色拥有的金币数量,并查看是否可以在foreach
循环内添加一个if
语句来检查你能负担得起的物品。
提示:使用kvp.Value
来比较你的钱包中的价格。
while 循环
while
循环类似于if
语句,因为它们只要单个表达式或条件为真就会运行。
值比较和布尔变量可以用作while
条件,并且可以用NOT
运算符进行修改。
while
循环语法是这样说的,只要我的条件为真,就无限运行我的代码块:
Initializer while (condition) { code block; iterator; }
使用while
循环时,通常会声明一个初始化变量,就像for
循环一样,并在循环代码块的末尾手动增加或减少它。我们这样做是为了避免无限循环,我们将在本章末讨论这个问题。根据您的情况,初始化变量通常是循环条件的一部分。
在 C#编程中,while
循环非常有用,但在 Unity 中并不被认为是良好的实践,因为它们可能会对性能产生负面影响,并且通常需要手动管理。
让我们来看一个常见的用例,我们需要在玩家还活着时执行代码,然后在不再是这种情况时进行调试:
- 创建一个名为
PlayerLives
的int
类型的初始化变量,并将其设置为3
:
int PlayerLives = 3;
创建一个名为
HealthStatus
的新公共函数,并在Start
中调用它。声明一个
while
循环,检查PlayerLives
是否大于0
(也就是玩家还活着):
while(PlayerLives > 0) { }
- 在
while
循环内,调试一些内容,让我们知道角色仍然活着,然后使用--
运算符将PlayerLives
减 1:
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 循环输出的屏幕截图
如果您没有看到多个"Still alive!"的调试日志,请确保控制台工具栏中的折叠按钮没有被选中。
现在的问题是,如果循环永远不停止执行会发生什么?我们将在下一节讨论这个问题。
到无穷大和更远
在完成本章之前,我们需要了解一个非常重要的概念,即迭代语句:无限循环。这正是它们的名字:当循环的条件使得它无法停止运行并继续在程序中执行时。无限循环通常发生在for
和while
循环中,当迭代器没有增加或减少时;如果在while
循环示例中省略了PlayerLives
代码行,Unity 将会冻结和/或崩溃,因为PlayerLives
将永远是 3,并且循环会一直执行下去。
迭代器并不是唯一需要注意的问题;在for
循环中设置永远不会失败或评估为 false 的条件也会导致无限循环。在遍历键值对部分的团队成员示例中,如果我们将for
循环的条件设置为i < 0
而不是i < QuestPartyMembers.Count
,i
将永远小于0
,循环直到 Unity 崩溃。
总结
随着本章的结束,我们应该反思我们取得了多少成就,以及我们可以用这些新知识构建什么。我们知道如何使用简单的if-else
检查和更复杂的switch
语句,在代码中进行决策。我们可以使用数组和列表存储值的集合,或者使用字典存储键值对。这样可以高效地存储复杂和分组的数据。我们甚至可以为每种集合类型选择合适的循环语句,同时小心避免无限循环崩溃。
如果您感到不知所措,那完全没问题——逻辑、顺序思维都是锻炼您编程大脑的一部分。
下一章将完成 C#编程的基础知识,介绍类、结构体和面向对象编程(OOP)。我们将把迄今为止学到的所有内容都应用到这些主题中,为我们第一次真正深入理解和控制 Unity 引擎中的对象做准备。
加入我们的 Discord!
与其他用户、Unity/C#专家和 Harrison Ferrone 一起阅读本书。提出问题,为其他读者提供解决方案,通过问我任何事与作者交流,以及更多。
立即加入!