精通-Unity-脚本编程-全-

精通 Unity 脚本编程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

精通 Unity 脚本编写是对使用 Unity 中的 C#进行游戏脚本编写的一些高级、非常规和强大方法的简洁且专注的探索。这使得这本书在当前非常重要,因为尽管存在大量的“入门”文献和教程,但关于更高级主题的讨论相对较少,且形式结构化。本书假设你已经熟悉 Unity 的基础知识,例如资产导入、关卡设计、光照贴图和 C#或 JavaScript 中的基本脚本编写。从一开始,它就研究如何将脚本创造性地应用于实现更复杂的目标的实际案例研究和示例,包括调试、人工智能、定制渲染、编辑器扩展、动画和运动等主题。本书的核心目的不是在理论层面展示抽象原理和技巧,而是展示理论如何在现实世界的例子中得到应用,帮助你最大限度地发挥编程知识,构建不仅能够工作而且能够优化工作的坚实游戏。为了最大限度地利用这本书,请按顺序从开头到结尾阅读每一章,并在阅读时保持一种普遍和抽象的心态。也就是说,将每一章视为一个特定例子和演示,这些例子和演示展示了跨越时间和空间的更普遍原则;你可以从我在特定上下文中使用的具体环境中提取这些原则,并在其他地方重新部署以满足你的需求。简而言之,将这里的知识不仅视为与我选择的特定例子和案例研究相关,而且认为它与你的项目高度相关。那么,让我们开始吧。

本书涵盖的内容

第一章,Unity C# 快速回顾,以非常简短的方式总结了 C#和 Unity 中的脚本编写基础知识。它并不旨在成为基础知识的完整或全面指南。相反,它旨在为那些以前学习过基础知识但可能已经有一段时间没有编写脚本,并且希望在开始后续章节之前快速回顾的人提供复习课程。如果你对脚本编写的基础知识(如类、继承、属性和多态)感到舒适,那么你可能可以跳过这一章。

第二章,调试,深入探讨了调试。能够编写坚实有效的代码部分取决于你发现并成功修复错误的能力,这些错误在它们发生时出现。这使得调试成为一项关键技能。本章不仅将探讨基础知识,还将通过 MonoDevelop 界面深入了解调试,并建立一个有用的错误记录系统。

第三章,单例、静态、游戏对象和世界,探讨了访问、更改和管理游戏对象的广泛功能。具体来说,我们将看到用于构建全局和持久对象的单例设计模式,以及许多其他用于搜索、列出、排序和排列对象的技巧。在 Unity 中,脚本依赖于在统一的游戏世界或坐标系中操作对象,以实现令人信服的结果。

第四章,事件驱动编程,将事件驱动编程视为重新构思游戏架构以进行优化的重要途径。通过将繁重的工作负载从更新和频繁的事件转移到事件驱动系统中,我们将为完成其他任务释放大量宝贵的处理时间。

第五章,摄像机、渲染和场景,深入探讨了如何工作摄像机,不仅从表面上看,而且如何深入其架构并自定义其渲染输出。我们将探索视锥体测试、剔除问题、视线、正交投影、深度和层级、后期处理效果等。

第六章,使用 Mono,概述了庞大的 Mono 库及其一些最有用的类,从字典、列表和堆栈到其他功能,如字符串、正则表达式和 Linq。到本章结束时,你将更好地定位自己,能够快速有效地处理大量数据。

第七章,人工智能,成功地将之前涵盖的所有内容应用于一个单一的示例项目中,该项目考虑了人工智能:创建一个聪明的敌人,执行一系列行为,从徘徊、追逐、巡逻、攻击、逃跑和寻找健康加成。在创建这个角色时,我们将涵盖视线问题、近距离检测和路径查找。

第八章,自定义 Unity 编辑器,专注于 Unity 编辑器,它在许多方面都功能丰富,但有时你需要或希望它做得更多。本章探讨了如何创建用于自定义编辑器本身的编辑器类,以不同的方式表现并更好地工作。我们将创建自定义检查器属性,甚至创建一个完全功能化的本地化系统,以便无缝地在多种语言之间切换你的游戏。

第九章,处理纹理、模型和 2D,探讨了您可以使用 2D 元素(如精灵、纹理和 GUI 元素)做很多事情。即使是 3D 游戏,2D 元素也扮演着重要的角色,在这里我们将探讨一系列 2D 问题,并探索有效且强大的解决方案。

第十章,源代码控制和其它技巧,以一般性笔记结束本书。它考虑了广泛的各种各样的技巧和窍门(有用的概念和应用),虽然它们不属于任何特定类别,但整体上却至关重要。我们将看到良好的编码实践、编写清晰代码的技巧、数据序列化、源代码和版本控制集成等等。

您需要为此书准备的内容

这本书是一本以 Unity 为重点的书籍,这意味着您只需要一份 Unity 副本。Unity 包含了您跟随本书所需的一切,包括代码编辑器。可以从unity3d.com/下载 Unity。Unity 是一个支持两种主要许可证的单个应用程序,免费版和专业版。免费版限制了某些功能的访问,但仍然提供了强大的功能集。一般来说,本书的大多数章节和示例都与免费版兼容,这意味着您通常可以使用免费版跟随。然而,一些章节和示例将需要专业版。

这本书面向的对象

这是一本面向熟悉 Unity 基础知识以及脚本基础的学生、教育工作者和专业人士的高级书籍。无论您使用 Unity 的时间长短,或是有经验的用户,这本书都有一些重要且有价值的内容,可以帮助您改进游戏开发工作流程。

习惯用法

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“一旦创建,新的脚本文件将在项目文件夹内生成,文件扩展名为.cs。”

代码块以如下方式设置:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyNewScript : MonoBehaviour 
05 {

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

//We should hide this object if its Y position is above 100 units
bool ShouldHideObject = (transform.position.y > 100) ? true : false;

//Update object visibility
gameObject.SetActive(!ShouldHideObject);

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“一种方法是,从应用程序菜单中选择资产 | 创建 | C# 脚本。”

注意

警告或重要注意事项如下所示。

小贴士

小贴士和技巧看起来像这样。

读者反馈

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

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

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

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有多个方面可以帮助您从您的购买中获得最大收益。

下载示例代码

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

下载本书的颜色图像

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

勘误

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

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

盗版

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

请通过链接将疑似盗版材料发送至 <copyright@packtpub.com> 与我们联系。

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

问题

如果您对本书的任何方面有问题,您可以联系 <questions@packtpub.com>,我们将尽力解决问题。

第一章. Unity C# 复习

本书关于掌握 Unity 脚本编写,特别是掌握在 Unity 游戏开发背景下的 C#。在进一步讨论之前,需要定义和限定“掌握”的概念。通过“掌握”,我的意思是这本书将帮助你从拥有中级和理论知识过渡到拥有更流利、实用和高级的脚本知识。流利是关键词。从学习任何编程语言的开始,重点总是转向语言的语法和规则和法律——语言的正式部分。这包括诸如变量、循环和函数等概念。然而,随着程序员经验的积累,重点从语言本身转移到语言在解决现实世界问题中的应用方式上。重点从语言导向的问题转变为上下文敏感的应用问题。因此,本书的大部分内容将不会主要关于 C# 的正式语言语法。

在这一章之后,我将假设你已经掌握了基础知识。相反,本书将关于案例研究和 C# 的实际应用案例。然而,在转向这一点之前,本章将专注于 C# 的基础知识。这是故意的。它将快速、简要地涵盖你需要跟随后续章节有效进行的所有 C# 基础知识。我强烈建议你从头到尾阅读一遍,无论你的经验如何。本书主要面向那些对 C# 比较陌生但想深入学习的读者。然而,它对经验丰富的开发者来说也很有价值,可以帮助他们巩固现有知识,也许还能在过程中获得新的建议和想法。因此,在这一章中,我将从零开始,以逐步、总结的方式概述 C# 的基础知识。我将假设你已经理解了编程的基本概念,可能使用另一种语言,但从未接触过 C#。那么,让我们开始吧。

为什么选择 C#?

当涉及到 Unity 脚本编写时,在制作新游戏时早期的一个问题就是选择哪种语言,因为 Unity 提供了选择。官方的选择是 C# 或 JavaScript。然而,由于对语言进行的 Unity 特定适配,关于 JavaScript 是否应该更恰当地被称为 "JavaScript" 或 "UnityScript" 存在争议。这一点在此处不是我们的关注点。问题是应该为你的项目选择哪种语言。现在,最初看起来,既然我们有选择,我们实际上可以选择这两种语言,并在一种语言中编写一些脚本文件,在另一种语言中编写其他脚本文件,从而有效地混合语言。这当然是技术上可行的。Unity 不会阻止你这样做。然而,这是一个“不好的”做法,因为它通常会导致混淆以及编译冲突;这就像同时尝试用英里和公里计算距离一样。

相反,建议选择三种语言中的一种,并在整个项目中一致地应用它作为权威语言。这是一个更流畅、更高效的流程,但这意味着必须选择一种语言,而牺牲其他语言。这本书选择了 C#。为什么?首先,并不是因为 C# 比“其他”更好。在我看来,没有绝对的“更好”或“更差”。每种语言都有其自身的优点和用途,并且所有 Unity 语言在制作游戏方面都是同样有用的。主要原因可能是 C# 是最广泛使用和受支持的 Unity 语言,因为它与大多数开发者接触 Unity 时已有的现有知识联系最为紧密。大多数 Unity 教程都是针对 C# 编写的,因为它在其他应用开发领域有很强的存在感。C# 历史上与 .NET 框架相关联,该框架也用于 Unity(在那里称为 Mono),C# 与 C++ 最相似,C++ 通常在游戏开发中占有很强的地位。此外,通过学习 C#,你更有可能发现你的技能集与当代游戏行业中 Unity 程序员的需求相匹配。因此,我选择了 C#,以使这本书具有最广泛的吸引力,并且与大量的外部教程和文献相连接。这让你在阅读这本书之后更容易将你的知识进一步深化。

创建脚本文件

如果你需要为你的游戏定义逻辑或行为,那么你需要编写脚本。Unity 中的脚本编写从创建一个新的脚本文件开始,这是一个添加到项目中的标准文本文件。该文件定义了一个程序,列出了 Unity 需要遵循的所有指令。如前所述,指令可以用 C#、JavaScript 或 Boo 编写;对于这本书,语言将是 C#。在 Unity 中创建脚本文件有多种方法。

一种方法是前往应用程序菜单中的资产 | 创建 | C# 脚本,如图下所示:

创建脚本文件

通过应用程序菜单创建脚本文件

另一种方法是右键单击项目面板中的任何空白区域,然后在上下文菜单中选择创建菜单中的C# 脚本选项,如图下所示。这将在当前打开的文件夹中创建资产。

创建脚本文件

通过项目面板上下文菜单创建脚本文件

创建后,将在 Project 文件夹内生成一个新的脚本文件,文件扩展名为 .cs(代表 C Sharp)。文件名尤为重要,并且对脚本文件的有效性有重大影响,因为 Unity 使用文件名来确定文件内要创建的 C# 类的名称。类将在本章后面更深入地讨论。简而言之,务必给你的文件起一个独特且具有意义的名称。

当我们说独特时,指的是在整个项目中,无论文件位于不同的文件夹中与否,都不应有其他脚本文件与它同名。所有脚本文件在整个项目中都应具有唯一名称。名称还应具有意义,清楚地表达脚本打算做什么。此外,C#中还有关于文件名以及类名的有效性规则。这些规则的正式定义可以在网上找到,地址为msdn.microsoft.com/en-us/library/aa664670%28VS.71%29.aspx。简而言之,文件名应以字母或下划线字符开头(不允许以数字开头),并且名称中不应包含空格,尽管允许使用下划线(_):

创建脚本文件

以独特的方式并根据 C#类命名约定命名脚本文件

Unity 脚本文件可以在任何文本编辑器或 IDE 中打开和检查,包括 Visual Studio 和 Notepad++,但 Unity 还提供了免费的开放源代码编辑器MonoDevelop。此软件是安装包中的主要 Unity 部分,无需单独下载。通过双击项目面板中的脚本文件,Unity 将自动在 MonoDevelop 中打开该文件。如果您后来决定或需要重命名脚本文件,您还需要将文件中的 C#类重命名为与文件名完全匹配,如下面的截图所示。如果不这样做,将导致代码无效和编译错误或问题,当将脚本文件附加到对象时会出现问题。

创建脚本文件

将类重命名为与重命名的脚本文件匹配

注意

编译代码

要在 Unity 中编译代码,您只需在 MonoDevelop 中保存您的脚本文件,通过从应用程序菜单中选择文件菜单中的保存选项(或按键盘上的Ctrl + S)然后返回到主 Unity 编辑器。当重新聚焦到 Unity 窗口时,Unity 会自动检测文件中的代码更改,然后响应地编译您的代码。如果有错误,游戏无法运行,错误将打印到控制台窗口。如果编译成功,您不需要做任何事情,只需在编辑器工具栏上按播放并测试运行您的游戏。请注意,如果您在修改代码后忘记在 MonoDevelop 中保存文件,Unity 仍然会使用较旧的、编译过的代码版本。因此,出于此原因以及备份的目的,定期保存您的作品非常重要,所以请确保按Ctrl + S在 MonoDevelop 中保存。

实例化脚本

Unity 中的每个脚本文件定义了一个主类,类似于蓝图或设计,可以被实例化。它是一系列相关的变量、函数和事件(我们很快就会看到)。默认情况下,脚本文件就像任何其他类型的 Unity 资产,例如网格和音频文件。具体来说,它保持在Project文件夹中处于休眠状态,不执行任何操作,直到它被添加到特定的场景中(通过将其作为组件添加到对象中),在那里它在运行时变得活跃。现在,由于脚本本质上是逻辑和数学的,它们不会像网格那样作为有形的、独立的对象添加到场景中。您无法直接看到或听到它们,因为它们没有可见或可听的存在。相反,它们作为组件添加到现有的游戏对象上,定义了这些对象的行为。将脚本作为特定对象上的特定组件激活的过程被称为实例化。当然,单个脚本文件可以在多个对象上实例化,以复制它们的行为,从而避免为每个对象创建多个脚本文件,例如当多个敌人角色必须使用相同的人工智能时。理想情况下,脚本文件的目的是为对象定义一个抽象公式或行为模式,该模式可以在所有可能的场景中成功地在许多类似的对象之间重用。要将脚本文件添加到对象上,只需将脚本从Project面板拖放到场景中的目标对象即可。脚本将以组件的形式实例化,并且其公共变量将在选择对象时在Object Inspector中可见,如下面的截图所示:

实例化脚本

将脚本作为组件附加到游戏对象上

变量将在下一节中做更深入的讨论。

小贴士

在线可以找到更多关于在 Unity 中创建和使用脚本的详细信息,请访问docs.unity3d.com/412/Documentation/Manual/Scripting.html

变量

在编程和 C# 中,核心概念可能是变量。变量通常对应于代数中使用的字母,并代表数值量,如 XYZabc。如果你需要跟踪信息,例如玩家名称、分数、位置、方向、弹药、健康以及众多其他可量化的数据类型(由名词表示),那么变量将是你的朋友。变量代表单个信息单位。这意味着需要多个变量来存储多个单位,每个变量对应一个。此外,每个单位都将具有特定的类型或种类。例如,玩家的名字代表一系列字母,如 "John"、"Tom" 和 "David"。相比之下,玩家的健康指的是数值数据,如 100%(1)或 50%(0.5),这取决于玩家是否受到伤害。因此,每个变量必然有一个数据类型。在 C# 中,变量使用特定的语法或语法创建。考虑以下代码示例 1-1,它定义了一个名为 MyNewScript 的新脚本文件和类,该类声明了三个具有类作用域的不同变量,每个变量具有唯一的数据类型。单词 "声明" 的意思是我们,作为程序员,正在告诉 C# 编译器所需的变量:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyNewScript : MonoBehaviour 
05 {
06     public string PlayerName = "";
07     public int PlayerHealth = 100;
08     public Vector3 Position = Vector3.zero;
09 
10     // Use this for initialization
11     void Start () {
12 
13     }
14 
15     // Update is called once per frame
16     void Update () {
17 
18     }
19 }

注意

变量数据类型

每个变量都有一个数据类型。其中一些最常见的数据类型包括 intfloatboolstringVector3。以下是一些这些类型的示例:

  • int(整数或整个数字)= -3、-2、-1、0、1、2、3…

  • float(浮点数或小数)= -3.0、-2.5、0.0、1.7、3.9…

  • bool(布尔或 true/false)= truefalse(1 或 0)

  • string(字符序列)= "hello world"、"a"、"another word…"

  • Vector3(位置值)= (0, 0, 0)、(10, 5, 0)…

从代码示例 1-1 的第 06-08 行中可以看出,每个变量都被分配了一个起始值,并且其数据类型被明确声明为 int(整数)、stringVector3,它们代表 3D 空间中的点(以及我们将看到的方向)。没有可能的完整数据类型列表,因为这将根据你的项目而广泛变化(你也会创建自己的数据类型!)。在这本书的整个过程中,我们将使用最常见的数据类型,所以你会看到很多示例。最后,每个变量声明行都以关键字 public 开头。通常,变量可以是 publicprivate(还有一个叫做 protected 的,这里没有涉及)。public 变量将在 Unity 的对象检查器中可访问和可编辑(我们很快就会看到,你还可以参考前面的截图),并且其他类也可以访问它们。

变量之所以被这样命名,是因为它们的值可能会随时间变化(或改变)。当然,它们不会以任意和不可预测的方式改变。相反,它们会在我们明确更改它们时改变,无论是通过代码中的直接赋值、从对象检查器,还是通过方法和函数调用。它们可以直接和间接地改变。变量可以直接赋值,如下所示:

PlayerName = "NewName";

它们也可以通过表达式间接赋值,即,最终值必须在赋值最终完成到变量之前评估的语句,如下所示:

//Variable will result to 50, because: 100 x 0.5 = 50
PlayerHealth = 100 * 0.5;

注意

变量作用域

每个变量都使用隐式作用域声明。作用域决定了变量的生命周期,即,在源文件内可以成功引用和访问变量的地方。作用域由变量声明的位置决定。代码示例 1-1 中声明的变量具有类作用域,因为它们是在类的顶部声明的,并且在任何函数之外。这意味着它们可以在整个类中访问,并且(因为是公共的)也可以从其他类中访问。变量也可以在特定的函数内部声明。这些被称为局部变量,因为它们的作用域限制在函数内,即,局部变量不能在其声明的函数外部访问。类和函数将在本章的后面讨论。

关于变量及其在 C# 中的使用的更多信息,请参阅msdn.microsoft.com/en-us/library/aa691160%28v=vs.71%29.aspx

条件语句

变量可能在许多不同的情况下发生变化:当玩家改变他们的位置,当敌人被摧毁,当关卡改变,等等。因此,你将经常需要检查变量的值,以便根据值分支执行你的脚本,执行不同的动作集。例如,如果 PlayerHealth 达到 0%,你将执行死亡序列,但如果 PlayerHealth 在 20%,你可能只显示一个警告消息。在这个特定的例子中,PlayerHealth 变量驱动脚本向指定的方向执行。C# 提供了两种主要的条件语句来实现这种程序分支。这些是 if 语句和 Switch 语句。两者都非常有用。

if 语句

if 语句有多种形式。最基本的形式是检查一个条件,如果且仅当该条件为 true 时,将执行后续的代码块。考虑以下代码示例 1-2:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     public string PlayerName = "";
07     public int PlayerHealth = 100;
08     public Vector3 Position = Vector3.zero;
09 
10     // Use this for initialization
11     void Start () {
12     }
13 
14     // Update is called once per frame
15     void Update () 
16     {
17         //Check player health - the braces symbol {} are option for one-line if-statements
18         if(PlayerHealth == 100)
19         {
20         Debug.Log ("Player has full health");
21         }
22     }
23 }

前面的代码与其他类型的 Unity 代码一样执行,通过从工具栏中按下播放按钮,只要脚本文件之前已经在活动场景中的对象上实例化。第 18 行的if语句持续检查PlayerHealth类变量的当前值。如果PlayerHealth变量正好等于(==100,则在大括号{}(第 19-21 行)内的代码将被执行。这是因为所有条件检查都会产生truefalse的布尔值;条件语句实际上检查查询的条件(PlayerHealth == 100)是否为true。理论上,大括号内的代码可以跨越多行和多个表达式。然而,在这里,第 20 行只有一行:Debug.Log Unity 函数将玩家健康值满字符串输出到控制台,如下面的屏幕截图所示。当然,if语句可能以另一种方式执行,即如果PlayerHealth不等于100(可能是99101),则不会打印任何消息。它的执行始终取决于前面的if语句评估为true

if 语句

Unity 控制台对于打印和查看调试消息非常有用

在线可以找到有关 C#中的if语句、if-else语句及其使用的更多信息,请访问msdn.microsoft.com/en-GB/library/5011f09h.aspx

注意

Unity 控制台

如前一个屏幕截图所示,控制台是 Unity 中的一个调试工具。这是一个可以通过Debug.Log语句(或Print函数)从代码中打印消息的地方,以便开发者查看。它们有助于在运行时和编译时诊断问题。如果您遇到编译时或运行时错误,它应该列在控制台选项卡中。默认情况下,控制台选项卡在 Unity 编辑器中应该是可见的,但可以通过从 Unity 应用程序文件菜单中选择窗口菜单中的控制台来手动显示。有关Debug.Log函数的更多信息,请访问docs.unity3d.com/ScriptReference/Debug.Log.html

您当然可以检查比仅仅等于(==)更多的条件,就像我们在代码示例 1-2 中所做的那样。您可以使用><运算符来检查一个变量是否大于或小于另一个值。您还可以使用!=运算符来检查一个变量是否不等于另一个值。此外,您甚至可以使用&&(AND)运算符和||(OR)运算符将多个条件检查组合到同一个if语句中。例如,查看以下if语句。它仅在PlayerHealth变量在0100之间且不等于50的情况下执行大括号{}之间的代码块,如下所示:

if(PlayerHealth >= 0 && PlayerHealth <= 100 && PlayerHealth !=50)
{
Debug.Log ("Player has full health");
}

注意

if-else 语句

if 语句的一种变体是 if-else 语句。如果 if 语句的条件评估为 true,则执行代码块。然而,if-else 语句扩展了这一点。如果其条件为 true,则执行 X 代码块;如果条件为 false,则执行 Y 代码块:

if(MyCondition)
{
//X - perform my code if MyCondition is true
}
else
{
//Y – perform my code if MyCondition is false
}

switch 语句

正如我们所见,if 语句用于确定单个特定条件是 true 还是 false,并根据此执行特定的代码块。相比之下,switch 语句允许您检查变量的多个可能条件或状态,然后允许您根据许多可能的方向之一分支程序,而不仅仅是 if 语句中的一个或两个方向。例如,如果您正在创建一个可以处于许多可能动作状态之一(CHASEFLEEFIGHTHIDE 等)的敌人角色,您可能需要适当地分支代码以处理每个状态。break 关键字用于退出状态,返回到 switch 语句的末尾。以下代码示例 1-3 使用枚举处理了一个示例敌人:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     //Define possible states for enemy using an enum
07     public enum EnemyState {CHASE, FLEE, FIGHT, HIDE};
08 
09     //The current state of enemy
10     public EnemyState ActiveState = EnemyState.CHASE;
11 
12     // Use this for initialization
13     void Start () {
14     }
15 
16     // Update is called once per frame
17     void Update () 
18     {
19          //Check the ActiveState variable
20          switch(ActiveState)
21          {
22          case EnemyState.FIGHT:
23          {
24                //Perform fight code here
25                Debug.Log ("Entered fight state");
26          }
27                break;
28 
29 
30          case EnemyState.FLEE:
31          case EnemyState.HIDE:
32          {
33               //Flee and hide performs the same behaviour
34               Debug.Log ("Entered flee or hide state");
35          }
36               break;
37 
38          default:
39          {
40               //Default case when all other states fail
41               //This is used for the chase state
42               Debug.Log ("Entered chase state");
43           }
44               break;
45           }
46     }
47 }

注意

枚举

代码示例 1-3 中的第 07 行声明了一个名为 EnemyState 的枚举(enum)。枚举是一种特殊结构,用于存储一个或多个其他变量的潜在值范围。它本身不是一个变量,而是一种指定变量可能具有的值范围的方式。在代码示例 1-3 中,第 10 行声明的 ActiveState 变量使用了 EnemyState。它的值可以是 ActiveState 枚举中的任何有效值。枚举是帮助您验证变量、限制它们在特定范围和一系列选项中的值的一种很好的方式。

枚举的另一个巨大好处是,基于它们的变量在对象检查器中显示为可选择的选项,如下面的截图所示:

 语句

枚举为您在对象检查器中提供了变量下拉选项

在线可以找到有关枚举及其在 C# 中使用的更多信息,请访问 msdn.microsoft.com/en-us/library/sbbt4032.aspx

以下是对代码示例 1-3 的注释:

  • 第 20 行: switch 语句开始。括号 () 用于选择需要检查其值或状态的变量。在这种情况下,正在查询 ActiveState 变量。

  • 行 22: 第一个 case 语句是在 switch 语句内部做出的。如果 ActiveState 变量被设置为 EnemyState.Fight,则以下代码块(第 24 和 25 行)将被执行。否则,代码将被忽略。

  • 第 30 和 31 行: 在这里,两个 case 语句依次出现。如果 ActiveStateEnemyState.FleeEnemyState.Hide,则第 33 和 34 行的代码块将被执行。

  • 第 38 行:对于switch语句,默认语句是可选的。当包含时,如果没有其他情况语句为true,它将被进入。在这种情况下,如果ActiveStateEnemyState.Chase,它将适用。

  • 第 27、36 和 44 行break语句应出现在情况语句的末尾。当它被达到时,它将退出它所属的完整switch语句,然后从switch语句之后的行恢复程序执行,在这种情况下,是第 45 行。

    小贴士

    关于switch语句及其在 C#中的使用的更多信息,可以在msdn.microsoft.com/en-GB/library/06tc147t.aspx找到。

数组

列表和序列在游戏中无处不在。因此,你经常会需要跟踪同一类型的数据列表:关卡中的所有敌人、收集到的所有武器、可能收集到的所有升级、库存中的所有法术和物品等等。列表的一种类型是数组。本质上,数组中的每个项目都是一个信息单元,在游戏过程中可能发生变化,因此变量适合存储每个项目。然而,将所有相关变量(所有敌人、所有武器等等)收集到一个单一、线性且可遍历的列表结构中是有用的。这正是数组所实现的。在 C#中,有两种类型的数组:静态和动态。静态数组可能在内存中保留一个固定和最大的可能条目数,这是预先决定的,并且在整个程序执行过程中,即使你只需要存储少于容量数的项目,这个容量也不会改变。这意味着一些槽位或条目可能会被浪费。动态数组可以根据需要增长和缩小容量,以适应所需的确切项目数。静态数组通常性能更好且速度更快,但动态数组看起来更整洁,避免了内存浪费。本章仅考虑静态数组,动态数组将在后续章节中讨论,如下面的代码示例 1-4 所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     //Array of game objects in the scene
07     public GameObject[] MyObjects;
08 
09      // Use this for initialization
10      void Start ()
11      {
12       }
13 
14       // Update is called once per frame
15       void Update () 
16       {
17       }
18 }

在代码示例 1-4 中,第 07 行声明了一个完全空的GameObjects数组,命名为MyObjects。为了创建这个数组,它在GameObject数据类型之后使用[]语法来指定一个数组,即表示正在声明一个GameObjects列表,而不是单个GameObject。在这里,声明的数组将是场景中所有对象的列表。它开始是空的,但你可以使用 Unity 编辑器中的对象检查器手动构建数组,通过设置其最大容量并用所需的任何对象填充它。为此,在场景中选择附加脚本的对象,并在My Objects字段中输入一个大小值来指定数组的容量。这应该是你想要持有的对象总数。然后,只需将场景层次结构面板中的对象单独拖放到对象检查器中的数组槽位中,以用项目填充列表,如图所示:

数组

从 Unity 对象检查器构建数组

您也可以通过 Start 函数手动在代码中构建数组,而不是使用对象检查器。这确保了数组在级别开始时构建。两种方法都很好,如下面的代码示例 1-5 所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     //Array of game objects in the scene
07     public GameObject[] MyObjects;
08 
09     // Use this for initialization
10     void Start ()
11     {
12          //Build the array manually in code
13          MyObjects = new GameObject[3];
14          //Scene must have a camera tagged as MainCamera
15          MyObjects[0] = Camera.main.gameObject; 

16       //Use GameObject.Find function to
17       //find objects in scene by name
18       MyObjects[1] = GameObject.Find("Cube"); 
19       MyObjects[2] = GameObject.Find("Cylinder"); 
20     }
21 
22     // Update is called once per frame
23     void Update ()
24     {
25     }
26 }

以下是对代码示例 1-5 的注释:

  • 第 10 行: Start 函数在启动级别执行。函数将在本章后面更深入地讨论。

  • 第 13 行: 使用 new 关键字创建一个容量为三个的新数组。这意味着列表在任何时候都不能容纳超过三个元素。默认情况下,所有元素都设置为起始值 null(表示无内容)。它们是空的。

  • 第 15 行: 在这里,数组中的第一个元素被设置为场景中的主相机对象。这里应该注意两个重要点。首先,可以使用数组索引运算符 [] 访问数组中的元素。因此,MyObjects 的第一个元素可以通过 MyObjects[0] 访问。其次,C# 数组是“零索引”的。这意味着第一个元素始终位于位置 0,下一个位于 1,再下一个位于 2,依此类推。对于 MyObjects 的三个元素数组,每个元素都可以通过 MyObjects[0]MyObjects[1]MyObjects[2] 访问。请注意,最后一个元素是 2 而不是 3

  • 第 18 行和第 19 行: 使用 GameObject.Find 函数在 MyObjects 数组的元素 12 中填充对象。这将在活动场景中搜索具有指定名称(区分大小写)的游戏对象,并在 MyObjects 数组的指定元素中插入它们的引用。如果没有找到匹配名称的对象,则插入 null

小贴士

在线可以找到有关数组和它们在 C#中使用的更多信息,请参阅msdn.microsoft.com/en-GB/library/9b9dty7d.aspx

循环

循环是编程中最强大的工具之一。想象一个整个关卡都可以被核爆的游戏。当这种情况发生时,您会希望销毁场景中的几乎所有内容。现在,您可以通过在代码中逐行删除每个对象来实现这一点。如果您这样做,那么只有几个对象的场景只需要几行代码,这不会成问题。然而,对于可能包含数百个对象的较大场景,您将不得不编写大量的代码,并且如果更改场景内容,则需要更改此代码。这将很繁琐。循环可以将过程简化为几行代码,无论场景复杂度或对象数量如何。它们允许您对可能许多对象重复执行操作。C# 中有几种循环类型。让我们看看一些示例。

foreach 循环

也许,在 C#中最简单的循环类型就是foreach循环。使用foreach,你可以按顺序遍历数组中的每个元素,从开始到结束,按需处理每个项目。考虑以下代码示例 1-6;它销毁了GameObject数组中的所有GameObjects

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     //Array of game objects in the scene
07     public GameObject[] MyObjects;
08 
09     // Use this for initialization
10     void Start ()
11     {
12          //Repeat code for all objects in array, one by one
13          foreach(GameObject Obj in MyObjects)
14          {
15              //Destroy object
16              Destroy (Obj);
17          }
18    }
19 
20    // Update is called once per frame
21    void Update () 
22    {
23    }
24 }

注意

下载示例代码

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

foreach循环重复第 14-17 行之间的代码块{},对于数组MyObjects中的每个元素执行一次。循环的每次通过或循环称为一次迭代。循环依赖于数组大小;这意味着较大的数组需要更多的迭代和更多的时间。循环还包含一个局部变量obj。它在第 13 行的foreach语句中声明。当循环通过每个迭代时,这个变量代表数组中被选择或激活的元素,因此obj代表第一次迭代的第一个元素,第二次迭代的第二个元素,依此类推。

小贴士

在 C#中关于foreach循环及其使用的更多信息可以在msdn.microsoft.com/en-GB/library/ttw7t8t6.aspx找到。

for循环

当您需要按顺序从开始到结束遍历单个数组时,foreach循环非常方便。但有时您需要对迭代有更多的控制。您可能需要从末尾到开始处理循环,您可能需要同时处理两个长度相等的数组,或者您可能需要处理每隔一个数组元素而不是每个元素。您可以使用for循环实现这一点,如下所示:

//Repeat code backwards for all objects in array, one by one
for(int i = MyObjects.Length-1; i >= 0; i--)
{
   //Destroy object
   DestroyMyObjects[i]);
}

以下是对前面代码片段的注释:

  • 在这里,for循环从MyObjects数组的末尾开始向前遍历,删除场景中的每个GameObject。它使用局部变量i来完成这项工作。这有时也被称为Iterator变量,因为它控制着循环的进展。

  • for循环行有三个主要部分,每个部分由分号字符分隔:

    • i:它初始化为MyObjects.Length – 1(数组的最后一个元素)。请记住,数组是零索引的,所以最后一个元素总是Array Length -1。这确保了循环迭代从数组的末尾开始。

    • i >= 0:这个表达式表示循环应该终止的条件。i变量像一个倒计时变量,通过数组向后递减。在这种情况下,当i不再大于或等于0时,循环应该结束,因为0代表数组的开始。

    • i--: 这个表达式控制变量 i 在循环的每次迭代中如何变化,从数组末尾向开头移动。在这里,i 将在每次迭代中减一,也就是说,每次循环迭代都会从 i 中减去一个值 1。相比之下,++ 语句将添加 1

  • 在循环期间,表达式 MyObjects[i] 用于访问数组元素。

提示

关于 for 循环及其在 C# 中的使用,更多信息可以在 msdn.microsoft.com/en-gb/library/ch45axte.aspx 找到。

while 循环

for 循环和 foreach 循环在遍历数组时特别有用,在每次迭代上执行特定操作。相比之下,while 循环用于不断重复特定的行为,直到指定的条件评估为 false。例如,如果你必须在对玩家造成伤害,只要他们站在热熔岩上,或者不断移动车辆直到刹车被应用,那么 while 循环可能正是你所需要的,如下面的代码示例 1-7 所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     // Use this for initialization
07     void Start ()
08    {
09         //Will count how many messages have been printed
10         int NumberOfMessages = 0;
11 
12         //Loop until 5 messages have been printed to the console
13         while(NumberOfMessages < 5) 
14         {
15              //Print message

16              Debug.Log ("This is Message: " + NumberOfMessages.ToString());

17 
18              //Increment counter
19              ++NumberOfMessages;
20         }
21    }
22 
23    // Update is called once per frame
24    void Update () 
25    {
26    }
27 }

注意

ToString

Unity 中的许多类和对象都有一个 ToString 函数(参见代码示例 1-7 的第 16 行)。这个函数将对象,例如整数(整数),转换为人类可读的单词或语句,可以打印到 控制台调试 窗口。这在调试时将对象和数据打印到控制台时非常有用。请注意,将数值对象转换为字符串需要隐式转换。

以下是对代码示例 1-7 的注释:

  • 第 13 行开始 while 循环,条件是它重复执行,直到整数变量 NumberOfMessages 大于或等于 5

  • 代码块在第 15 行和第 19 行之间被重复用作 while 循环的主体

  • 第 19 行在每次迭代中增加变量 NumberOfMessages

当在游戏模式下执行代码示例 1-7 的结果时,会在关卡开始时将五条文本消息打印到 Unity 控制台,如下面的截图所示:

while 循环

while 循环中打印消息到控制台

提示

关于 while 循环及其在 C# 中的使用,更多信息可以在 msdn.microsoft.com/en-gb/library/2aeyhxcd.aspx 找到。

无限循环

使用循环的一个危险,尤其是 while 循环,是意外创建一个无限循环,即一个无法结束的循环。如果你的游戏进入无限循环,它通常会冻结,可能是永久性的,需要你通过终止应用程序来强制退出!通常,Unity 会捕捉到这个问题并退出,但不要依赖这一点。例如,删除代码示例 1-7 中的第 19 行将创建一个无限循环,因为 NumberOfMessages 变量永远不会增加到满足 while 循环条件的水准,从而引起退出。本节的要点首先是,“在编写和规划循环时要小心,以避免无限循环。”以下是一个经典的无限循环示例,它肯定会给你的游戏带来问题,所以请务必避免它们:

//Loop forever
while(true)
{
}

然而,信不信由你,在某些条件下,无限循环实际上可能是你游戏所需要的!如果你需要一个可以无限上下移动的平台,一个不断旋转的魔法球,或者一个永无止境的昼夜循环,那么无限循环可能是可用的,只要它被适当地实现。在本书的后续部分,我们将看到无限循环如何被良好地利用。循环是强大而有趣的构造,但如果不恰当地编写,无论是无限循环还是非无限循环,都可能是崩溃、停滞和性能问题的来源,所以请小心。在本书中,我们将看到创建循环的良好实践。

函数

我们在本章中已经使用了函数,例如 StartUpdate 函数。然而,现在,我们需要更正式和精确地考虑它们。本质上,一个函数是一系列语句的组合,作为一个单一的、可识别的块,被赋予一个集体名称,可以在需要时执行,函数中的每一行按顺序执行。当你思考你游戏的逻辑时,有时你需要对你的对象重复执行某些操作,例如,开火、跳跃、杀死敌人、更新分数和播放声音。你可以在源文件中复制和粘贴你的代码,无论你需要在哪里重用它;这不是一个应该培养的好习惯。将可重用的代码合并到一个函数中,当你需要时可以通过名称来执行它,这比在源文件中复制粘贴代码要容易得多,如下面的代码示例 1-8 所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     //Private variable for score
07     //Accessible only within this class
08     private int Score = 0;
09 
10     // Use this for initialization
11     void Start ()
12    {
13       //Call update score
14       UpdateScore(5, false); //Add five points
15       UpdateScore (10, false); //Add ten points

16       int CurrentScore = UpdateScore (15, false); //Add fifteen points and store result

17 
18       //Now double score
19        UpdateScore(CurrentScore);
20     }
21 
22     // Update is called once per frame
23     void Update () 
24     {
25     }
26 
27     //Update game score

28     public int UpdateScore (int AmountToAdd, bool PrintToConsole = true)

29     {
30       //Add points to score
31       Score += AmountToAdd;
32 
33       //Should we print to console?

34       if(PrintToConsole){Debug.Log ("Score is: " + Score.ToString());}

35 
36       //Output current score and exit function
37       return Score;
38     }
39 }

以下是代码示例 1-8 中现有代码的分解:

  • 行 08:声明了一个私有的整型类变量 Score,用于跟踪样本分数值。这个变量将在后续的 UpdateScore 函数中使用。

  • 第 11 行、第 23 行和第 28 行: 类 MyScriptFile 有三个函数(有时也称为方法或成员函数)。这些是 StartUpdateUpdateScoreStartUpdate 是 Unity 提供的特殊函数,我们将在稍后看到。UpdateScoreMyScriptFile 的自定义函数。

  • 第 28 行: UpdateScore 函数代表从第 29 行到第 38 行之间的一个完整的代码块。这个特定的函数应该在游戏分数必须更改时每次被调用。当被调用时,代码块(第 29-38 行)将按顺序执行。通过这种方式,函数为我们提供了代码的可复用性。

  • 第 14-19 行: 在 Start 函数中,UpdateScore 函数被多次调用。对于每次调用,Start 函数的执行将暂停,直到 UpdateScore 函数完成。此时,执行将从下一行继续。

  • 第 28 行: UpdateScore 接受两个参数或参数。这些是一个整数 AmountToAdd 和一个布尔值 PrintToConsole。参数就像我们可以插入到函数中以影响其操作的输入。AmountToAdd 变量表示应该添加到当前 Score 变量的数值,而 PrintToConsole 决定在函数执行时是否应该在 Console 窗口中显示 Score 变量。理论上,函数可以有的参数数量没有限制,一个函数也可以没有任何参数,例如 StartUpdate 函数。

  • 第 31-34 行: 在这里,分数实际上被更新并打印到 Console,如果需要的话。注意,PrintToConsole 参数在行 28 的函数声明中已经分配了一个默认值 true。这使得在调用函数时参数是可选的。第 14、15 和 16 行明确地通过传递一个值为 false 来覆盖默认值。相比之下,第 19 行省略了第二个值,因此接受默认的 true

  • 第 28 行和第 37 行: UpdateScore 函数有一个返回值,这是一个在函数名之前第 28 行指定的数据类型。在这里,值是一个 int。这意味着在退出或完成时,函数将输出一个整数。在这种情况下,这个整数将是当前的 Score。实际上,这是在第 37 行使用 return 语句输出的。函数不一定要返回一个值,这不是必需的。如果不需要返回值,返回类型应该是 void,就像 StartUpdate 一样。

    提示

    关于函数及其在 C#中的使用,更多信息可以在csharp.net-tutorials.com/basics/functions/找到。

事件

事件本质上是在特定方式下使用的函数。我们之前已经看到的StartUpdate函数,更准确地描述应该是 Unity 特定的事件。事件是在某些关键时刻被调用的函数,用来通知一个对象发生了重要的事情:关卡开始,新帧开始,敌人死亡,玩家跳跃,以及其他情况。在这些关键时刻被调用,它们为对象提供了必要的响应机会。Start函数在对象首次创建时由 Unity 自动调用,通常在关卡启动时。Update函数也会自动调用,每帧调用一次。因此,Start函数为我们提供了在关卡开始时执行特定操作的机会,而Update函数每秒可以多次调用。因此,Update函数特别适用于在游戏中实现运动和动画。请参考代码示例 1-9,它会在一段时间内旋转一个对象:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyScriptFile : MonoBehaviour 
05 {
06     // Use this for initialization
07     void Start ()
08     {
09     }
10 
11     // Update is called once per frame
12     void Update () 
13     {
14       //Rotate object by 2 degrees per frame around the Y axis
15       transform.Rotate(new Vector3(0.0f, 2.0f, 0.0f));
16     }
17 }

代码示例 1-9 的第 15 行每帧调用一次。它持续围绕y轴旋转对象 2 度。这段代码依赖于帧率,这意味着在帧率更高的机器上运行时,它会更快地旋转对象,因为Update会被更频繁地调用。有一些技术可以实现帧率无关性,确保你的游戏在所有机器上都能保持一致的性能,无论帧率如何。我们将在下一章中看到这些技术。你可以直接从 Unity 编辑器的游戏标签中轻松检查游戏的帧率。选择游戏标签,然后在工具栏右上角点击统计信息按钮。这将显示统计信息面板,提供游戏性能的一般统计概述。此面板显示游戏的每秒帧数FPS),这表明了Update在对象上被调用的频率以及游戏在系统上的整体性能。一般来说,低于 15 的 FPS 表明存在重大的性能问题。努力实现 30 或更高的 FPS。请参考以下截图以访问统计信息面板:

事件

访问游戏标签页的“统计信息”面板以查看每秒帧数(FPS)

小贴士

事件类型太多,无法一一列举。然而,在 Unity 中,一些常见的事件,如StartUpdate,可以在MonoBehaviour类中找到。有关MonoBehaviour的更多信息,请参阅docs.unity3d.com/ScriptReference/MonoBehaviour.html

类和面向对象编程

类是由许多相关变量和函数组成的混合体,所有这些都被组合成一个自包含的单元或“事物”。换句话说,如果你考虑一个游戏(例如幻想 RPG),它充满了许多独立的事物,如法师、精灵、树木、房屋、玩家、任务、库存物品、武器、法术、门、桥梁、力场、传送门、守卫等等。其中许多对象与现实世界中的对象平行。然而,关键的是,这些事物中的每一个都是一个独立的对象;法师不同于力场,守卫不同于树木。然后,这些事物可以被视为对象——一个自定义类型。如果我们专注于一个特定的对象,例如一个精灵敌人,我们可以在该对象中识别出属性和行为。精灵将有一个位置、旋转和缩放;这些对应于变量。

精灵可能也有几种攻击方式,例如用斧头进行的近战攻击和用弩进行的远程攻击。这些攻击是通过函数来执行的。通过这种方式,一系列变量和函数被组合在一起,形成一个有意义的关联。将这些事物组合在一起的过程被称为封装。在这个例子中,精灵已经被封装成一个类。在这种情况下,这个类代表了一个通用、抽象的精灵(精灵的概念)。相比之下,对象是Orc类在关卡中的具体、实例化的体现。在 Unity 中,脚本文件定义了一个类。要将类作为关卡中的对象实例化,需要将其添加到GameObject中。正如我们所看到的,类作为组件附加到游戏对象上。组件是对象,多个组件组合在一起形成一个GameObject。请参考代码示例 1-10,以获取一个示例Orc类框架:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class Orc : MonoBehaviour 
05 {
06 //Reference to the transform component of orc (position, rotation, scale)
07 private Transform ThisTransform = null;
08 
09 //Enum for states of orc
10 public enum OrcStates {NEUTRAL, ATTACK_MELEE, ATTACK_RANGE};
11 
12 //Current state of orc
13 public OrcStates CurrentState = OrcStates.NEUTRAL;
14 
15 //Movement speed of orc in meters per second
16 	public float OrcSpeed = 10.0f;
17 
18 //Is orc friendly to player
19 public bool isFriendly = false;
20 
21 //--------------------------------------------------
22 // Use this for initialization
23 void Start ()
24 {
25       //Get transform of orc
26       ThisTransform = transform;
27 }
28 //--------------------------------------------------
29 // Update is called once per frame
30 void Update ()
31 {
32 }
33 //--------------------------------------------------
34 //State actions for orc
35 public void AttackMelee()
36 {
37        //Do melee attack here
38 }
39 //--------------------------------------------------
40 public void AttackRange()
41 {
42        //Do range attack here
43 }
44 //--------------------------------------------------
45 }

以下是对代码示例 1-10 的注释:

  • 第 04 行:在这里,使用class关键字定义了一个名为Orc的类。这个类继承自MonoBehaviour。下一节将更深入地探讨继承和派生类。

  • 第 09-19 行:在Orc类中添加了几个变量和一个枚举。这些变量的类型不同,但都与精灵的概念相关。

  • 第 35-45 行:精灵有两个方法:AttackMeleeAttackRange

    小贴士

    关于类及其在 C#中的使用的更多信息,请参阅msdn.microsoft.com/en-gb/library/x9afc042.aspx

类和继承

想象一个场景,你创建了一个 Orc 类来在游戏中编码一个兽人对象。完成之后,你决定创建两个升级类型。一个是兽人首领,拥有更好的盔甲和武器,另一个是兽人法师,正如其名所示,是一个施法者。两者都能做普通兽人能做的所有事情,但不仅如此。现在,为了实现这一点,你可以通过在它们之间复制和粘贴共同代码来创建三个单独的类,OrcOrcWarlordOrcMage

问题在于,由于兽人首领和兽人法师与兽人有很多共同的基础和行为,因此会浪费地复制和粘贴大量代码来复制这些共同行为。此外,如果你在某个类的共享代码中发现了错误,你需要将修复复制粘贴到其他类中,以传播它。这既麻烦又技术上危险,因为它可能会浪费时间,引入错误,并造成不必要的混淆。相反,面向对象的概念——继承可以帮助我们。继承允许你创建一个完全新的类,该类隐式地吸收或包含另一个类的功能,即它允许你创建一个扩展现有类的新类,而不会影响原始类。当发生继承时,两个类之间就建立了一种关系。原始类(如 Orc 类)被称为案例类或祖先类。扩展祖先类的新类(如兽人首领或兽人法师),被称为超类或派生类。

提示

在 C# 中有关继承的更多信息可以在 msdn.microsoft.com/en-gb/library/ms173149%28v=vs.80%29.aspx 找到。

默认情况下,每个新的 Unity 脚本文件都会创建一个从 MonoBehaviour 继承的新类。这意味着每个新脚本都包含所有 MonoBehaviour 功能,并且有可能根据你添加的额外代码进行扩展。为了证明这一点,请参考以下代码示例 1-11:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class NewScript : MonoBehaviour 
05 {
06 //--------------------------------------------------
07    // Use this for initialization
08    void Start ()
09    {
10       name = "NewObject";
11 }
12    //--------------------------------------------------
13    // Update is called once per frame
14    void Update ()
15    {
16    }
17 }

以下是对代码示例 1-11 的注释:

  • 第 04 行: 类 NewScript 继承自 MonoBehaviour。然而,你可以用几乎任何有效的类名替换 MonoBehaviour,只要你想要从它那里继承。

  • 第 10 行: 在 Start 事件中,变量名被赋予了一个字符串。但是,请注意,该名称在 NewScript 源文件的任何地方都没有明确声明为变量。如果 NewScript 是一个完全新的类,且在第 04 行没有定义任何祖先,那么第 10 行将是无效的。然而,因为 NewScript 继承自 MonoBehaviour,它自动继承了所有变量,这使得我们能够从 NewScript 中访问和编辑它们。

    注意

    何时继承

    只有在真正合适的情况下才使用继承;否则,你会使你的类变得庞大、笨重且难以理解。如果你正在创建一个与另一个类共享大量公共功能并且建立它们之间联系是有意义的类,那么请使用继承。继承的另一个用途,正如我们将看到的,是在你想覆盖特定函数时。

类和多态性

为了说明 C#中的多态性,让我们首先考虑以下代码示例 1-12。这个示例并不立即展示多态性,但它代表了多态性将变得有用的场景的开始,正如我们将看到的。在这里,为通用 RPG 游戏中的潜在非玩家角色NPC)定义了一个基本的骨架类。这个类故意不是全面的,只包含标记角色起点的基本变量。这里最重要的是,该类具有一个SayGreeting函数,当玩家与 NPC 进行对话时应该调用它。它向控制台显示一个通用的欢迎消息,如下所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyCharacter
05 {
06 public string CharName = "";
07 public int Health = 100;
08 public int Strength = 100;
09 public float Speed = 10.0f;
10 public bool isAwake = true;
11 
12     //Offer greeting to the player when entering conversation
13     public virtual void SayGreeting()
14     {
15         Debug.Log ("Hello, my friend");
16     }
17 }

第一个出现的问题与MyCharacter类的多样性和可信度有关,如果我们尝试想象它在游戏中的实际工作方式。具体来说,从MyCharacter实例化的每个角色在调用SayGreeting时都会提供完全相同的问候:男性、女性、兽人和所有人。他们都会说同样的话,即“你好,我的朋友”。这既不可信也不理想。也许,最优雅的解决方案就是给类添加一个公共字符串变量,从而允许对打印的消息进行定制。然而,为了清楚地说明多态性,让我们尝试一个不同的解决方案。我们可以创建几个额外的类,所有这些类都从MyCharacter派生而来,每个新 NPC 类型一个,每个都从SayGreeting函数提供独特的问候。这是可能的,因为SayGreeting已经使用虚拟关键字(第 13 行)声明。这允许派生类覆盖MyCharacter类中SayGreeting的行为。这意味着派生类中的SayGreeting函数将替换基类中原始函数的行为。这样的解决方案可能看起来类似于代码示例 1-13:

01 using UnityEngine;
02 using System.Collections;
03 //-------------------------------------------
04 public class MyCharacter
05    {
06    public string CharName = "";
07    public int Health = 100;
08 public int Strength = 100;
09 public float Speed = 10.0f;
10 public bool isAwake = true;
11 
12 //Offer greeting to the player when entering conversation
13 public virtual void SayGreeting()
14 {
15        Debug.Log ("Hello, my friend");
16 	}
17 }
18 //-------------------------------------------
19 public class ManCharacter: MyCharacter
20 {
21 public override void SayGreeting()
22 {
23        Debug.Log ("Hello, I'm a man");
24 }
25 }
26 //-------------------------------------------
27 public class WomanCharacter: MyCharacter
28 {
29 public override void SayGreeting()
30 {
31        Debug.Log ("Hello, I'm a woman");
32 }
33 }
34 //-------------------------------------------
35 public class OrcCharacter: MyCharacter
36 {
37 public override void SayGreeting()
38 {
39        Debug.Log ("Hello, I'm an Orc");
40 }
41 }
42 //-------------------------------------------

使用此代码,进行了一些改进,即为每种 NPC 类型创建不同的类,即ManCharacterWomanCharacterOrcCharacter。每个类在SayGreeting函数中提供不同的问候。此外,每个 NPC 从共享的基类MyCharacter继承所有共同的行为。然而,关于类型特定性的技术问题出现了。现在,想象一下在酒馆内部创建一个位置,其中有许多不同类型的 NPC,到目前为止,他们都在享受一桶麦酒。当玩家进入酒馆时,所有 NPC 都应该提供他们独特的问候。为了实现这个功能,如果我们能够有一个包含所有 NPC 的单个数组,并简单地从循环中调用他们的SayGreeting函数,每个 NPC 提供他们自己的问候,那就太好了。然而,最初似乎不能这样做。这是因为单个数组中的所有元素必须是相同的数据类型,例如MyCharacter[]OrcCharacter[]。我们不能在同一个数组中混合类型。当然,我们可以为每种 NPC 类型声明多个数组,但这感觉很不方便,并且不容易在编写数组代码后无缝创建更多 NPC 类型。为了解决这个问题,我们需要一个特定且专门的解决方案。这就是多态性发挥作用的地方。参考以下示例 1-14,它在一个完全独立的脚本文件中定义了一个新的Tavern类:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class Tavern : MonoBehaviour 
05 {
06 //Array of NPCs in tavern
07 public MyCharacter[] Characters = null;
08 //-------------------------------------------------------
09 // Use this for initialization
10 void Start () {
11 
12       //New array - 5 NPCs in tavern
13       Characters = new MyCharacter[5];
14 
15        //Add characters of different types to array MyCharacter
16        Characters[0] = new ManCharacter();
17        Characters[1] = new WomanCharacter();
18        Characters[2] = new OrcCharacter();
19        Characters[3] = new ManCharacter();
20        Characters[4] = new WomanCharacter();
21 
22        //Now run enter tavern functionality
23        EnterTavern();
24 }
25 //-------------------------------------------------------
26 //Function when player enters Tavern
27 public void EnterTavern()
28 {
29       //Everybody say greeting
30       foreach(MyCharacter C in Characters)
31       {
32              //call SayGreeting in derived class
33              //Derived class is accessible via base class
34             C.SayGreeting();
35       }
36 }
37 //-------------------------------------------------------
38 }

以下是对代码示例 1-14 的注释:

  • 第 07 行: 为了跟踪酒馆中的所有 NPC,无论 NPC 的类型如何,声明了一个类型为MyCharacter的单个数组(Characters)。

  • 第 16-20 行: Characters数组被填充了多种不同类型的多个 NPC。这是因为尽管它们属于不同的类型,但每个 NPC 都派生自同一个基类。

  • 第 27 行: 在等级启动时调用EnterTavern函数。

  • 第 34 行: 一个foreach循环遍历Characters数组中的所有 NPC,调用SayGreeting函数。结果如下面的截图所示。每个 NPC 的独特消息被打印出来,而不是基类中定义的通用消息。多态性允许在派生类中调用重写的方法。类和多态

    多态性在共享共同血统的数据类型之间产生向后透明性

小贴士

C#中的多态性方面更多信息可以在此处找到。

C#属性

当将值赋给类变量,例如MyClass.x = 10;时,有几个重要的事项需要注意。首先,你通常会想要验证被赋予的值,确保变量始终有效。典型的情况包括将整数限制在最小和最大范围之间,或者只允许字符串变量具有有限的字符串集。其次,你可能需要检测变量何时发生变化,从而启动其他依赖函数和行为。C#属性让你实现这两个功能。参考以下代码示例 1-15,它将整数限制在110之间,并在值发生变化时向控制台打印一条消息:

01 using UnityEngine;
02 using System.Collections;
03 //------------------------------------------------------
04 //Sample class - can be attached to object as a component
05 public class Database : MonoBehaviour 
06 {
07 //------------------------------------------------------
08 //Public property for private variable iMyNumber
09 //This is a public property to the variable iMyNumber
10 public int MyNumber
11 {
12        //Called when retrieving value
13        get
14       {
15               return iMyNumber; //Output iMyNumber
16       }
17 
18         //Called when setting value
19        set
20       {
21              //If value is within 1-10, set number else ignore
22             if(value >= 1 && value <= 10)
23             {
24                    //Update private variable
25                    iMyNumber = value;
26 
27                    //Call event
28                    NumberChanged();
29             }
30        }
31 }
32 //------------------------------------------------------
33 //Internal reference a number between 1-10
34 private int iMyNumber = 0;
35 //------------------------------------------------------
36 // Use this for initialization
37 void Start () 
38 {
39        //Set MyNumber
40        MyNumber = 11; //Will fail because number is > 10
41 
42        //Set MyNumber
43         MyNumber = 7; //Will succeed because number is between 1-10
44 }
45 //------------------------------------------------------
46 //Event called when iMyNumber is changed
47 void NumberChanged()
48 {

49        Debug.Log("Variable iMyNumber changed to : " + iMyNumber.ToString());

50 }
51 //------------------------------------------------------
52 }
53 //------------------------------------------------------

以下是对代码示例 1-15 的注释:

  • 第 10 行:声明了一个公共整数属性。这个属性不是一个独立的变量,而是一个wrapperaccessor接口,用于访问在第 34 行声明的私有变量iMyNumber

  • 第 13 行:当使用或引用MyNumber时,会调用内部的get函数。

  • 第 14 行:当MyNumber被赋予一个值时,会调用内部的set函数。

  • 第 25 行set函数具有一个隐含的参数值,它代表要赋予的值。

  • 第 28 行:当iMyNumber变量被赋予一个值时,会调用NumberChanged事件。

    注意

    属性和 Unity

    属性对于验证和控制变量值的赋值非常有用。在 Unity 中使用它们的主要问题在于它们在对象检查器中的可见性。具体来说,C#属性在对象检查器中不会显示。你既不能在编辑器中获取也不能设置它们的值。然而,社区制作的脚本和解决方案可以改变这种默认行为,例如暴露 C#属性。这些脚本和解决方案可以在wiki.unity3d.com/index.php?title=Expose_properties_in_inspector找到。

关于 C#中属性的更多信息,可以在msdn.microsoft.com/en-GB/library/x9fsa0sw.aspx找到。

注释

注释是将可读性强的消息插入到你的代码中的实践,纯粹用于注释、描述,并使读者更清楚。在 C# 中,单行注释以 // 符号开头,多行注释以 /* 开头,以 */ 结尾。本书中的代码示例中使用了注释。注释很重要,如果你还没有养成使用注释的习惯,我建议你养成这个习惯。它们不仅对团队中的其他开发者有益(如果你与他人一起工作),对你自己也有好处!它们有助于你在几周或几个月后再次回到代码时回忆起代码的功能,甚至有助于你清晰地了解你现在正在编写的代码。当然,所有这些好处都取决于你编写简洁且富有意义的注释,而不是充满无关紧要的长篇大论。然而,MonoDevelop 也提供了基于 XML 的注释来描述函数和参数,并且与代码补全集成。它可以显著提高你的工作效率,尤其是在团队工作中。让我们看看如何使用它。首先,编写你的函数或任何函数,如下面的截图所示:

注释

在 MonoDevelop 中编写一个函数(AddNumbers)(准备代码注释)

然后在函数标题上方的行插入三个正斜杠字符(///),如下面的截图所示:

注释

在函数标题上方插入 /// 以创建 XML 注释

当你这样做时,MonoDevelop 会自动插入一个模板 XML 注释,供你完成适当的描述。它会创建一个摘要部分,一般描述该函数,并为函数中的每个参数创建参数条目,如下面的截图所示:

注释

在函数标题上方插入 /// 将自动生成 XML 注释

接下来,填写你的函数的 XML 模板,并添加注释。务必为每个参数也提供一个适当的注释,如下面的截图所示:

注释

使用 XML 注释注释你的函数

现在,当你在代码的其他地方调用 AddNumbers 函数时,代码补全弹出助手将显示函数的摘要注释以及参数注释的上下文敏感信息,如下所示:

注释

在调用函数时查看注释

变量可见性

Unity 的一个特别出色的功能是,它在 Unity 编辑器的对象检查器中公开显示公共类变量,允许你在运行时编辑和预览变量。这对于调试来说特别方便。然而,默认情况下,对象检查器不会公开私有变量。它们通常被检查器隐藏起来。这并不总是好事,因为在很多情况下,你可能希望从检查器中调试或至少监控私有变量,而不必将它们的范围更改为公共。有两种主要方法可以轻松解决这个问题。

如果你想查看类中的所有公共和私有变量,第一个解决方案将很有用。你可以在调试模式下切换对象检查器。为此,点击检查器窗口右上角的下拉菜单图标,并从下拉菜单中选择调试,如下面的截图所示。当选择调试时,类中的所有公共和私有变量都将显示。

变量可见性

在对象检查器中启用调试模式将显示类中的所有变量

第二种解决方案对于显示特定的私有变量很有用,这些变量是你明确标记为希望在对象检查器中显示的。这些变量将在正常调试模式下都显示。为了实现这一点,请使用属性[SerializeField]声明私有变量。C#属性将在本书的后面部分进行讨论,如下所示:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyClass : MonoBehaviour 
05 {
06 //Will always show
07 public int PublicVar1;
08 
09 //Will always show
10 [SerializeField]
11 private int PrivateVar1;
12 
13 //Will show only in Debug Mode
14 private int PrivateVar2;
15 
16 //Will show only in Debug Mode
17 private int PrivateVar3;
18 }

小贴士

你还可以使用[HideInInspector]属性来从检查器中隐藏全局变量。

?运算符

if-else语句在 C#中非常常见且广泛使用,因此有一个专门的简写符号可用于编写更简单的语句,而无需使用完整的多行if-else语句。这个简写符号称为?运算符。这个语句的基本形式如下:

//If condition is true then do expression 1, else do expression 2
(condition) ? expression_1 : expression_2;

让我们通过以下示例看看?运算符的实际应用:

//We should hide this object if its Y position is above 100 units
bool ShouldHideObject = (transform.position.y > 100) ? true : false;

//Update object visibility
gameObject.SetActive(!ShouldHideObject);

小贴士

?运算符对于简短的语句很有用,但对于长且复杂的语句,它可能会使你的代码更难阅读。

SendMessage 和 BroadcastMessage

Unity API 中包含的MonoBehaviour类,作为大多数新脚本的基础类,提供了SendMessageBroadcastMessage方法。使用这些方法,你可以通过指定要运行的函数的名称来轻松地在对象的所有组件上执行函数。要调用类的某个方法,通常需要一个对该类的本地引用,以便访问和运行其函数以及访问其变量。然而,SendMessageBroadcastMessage函数允许你通过简单地指定要运行的函数的名称来使用字符串值运行函数。这非常方便,并且可以使你的代码看起来更简单、更短,但如我们稍后所见,这会牺牲效率。请参考以下代码示例 1-16:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class MyClass : MonoBehaviour 
05 {
06 void start()
07 {
08        //Will invoke MyFunction on ALL components/scripts attached to this object (where the function is present)

09         SendMessage("MyFunction", SendMessageOptions.DontRequireReceiver);

10 }
11 
12 //Runs when SendMessage is called
13 void MyFunction()
14 {
15        Debug.Log ("hello");
16 }
17 }

以下是对代码示例 1-16 的注释:

  • 行 09:调用 SendMessage 来调用函数 MyFunctionMyFunction 不仅会在本类中调用,如果 GameObject 上其他组件(包括 Transform 组件等)有 MyFunction 成员,也会在其他组件上调用。

  • 行 09:参数 SendMessageOptions.DontRequireReceiver 定义了如果组件上不存在 MyFunction 时会发生什么。在这里,它指定 Unity 应该忽略该组件,并继续在找到的任何地方调用 MyFunction

    提示

    当函数属于一个类时,术语函数和成员函数意思相同。属于类的函数被称为成员函数。

我们已经看到,SendMessage 在单个 GameObject 所附着的所有组件中调用指定的函数。BroadcastMessage 结合了 SendMessage 的行为,并更进一步,即它为 GameObject 上的所有组件调用指定的函数,然后递归地对场景层次结构中所有子对象重复此过程,向下级联到所有子对象。

关于 SendMessageBroadcastMessage 的更多信息可以在 docs.unity3d.com/ScriptReference/GameObject.SendMessage.htmldocs.unity3d.com/ScriptReference/Component.BroadcastMessage.html 找到。

注意

反射

SendMessageBroadcastMessage 是促进对象间通信和组件间通信的有效方法,也就是说,如果组件需要相互通信、同步行为和回收功能,这是一个非常好的方法。然而,SendMessageBroadcastMessage 都依赖于一个名为 反射 的 C# 功能。通过使用字符串调用一个函数,你的应用程序需要在运行时查看自身(进行反射),搜索其代码以找到要运行的预期函数。与正常方式运行函数相比,这个过程计算成本较高。因此,尽量减少使用 SendMessageBroadcastMessage,尤其是在 Update 事件或其他基于帧的场景中,因为它们对性能的影响可能很大。这并不意味着你永远不应该使用它们。可能会有一些时候,它们的使用很少、不频繁且方便,实际上没有明显的负面影响。然而,本书的后续章节将展示使用代理和接口的替代和更快的技术。

如果你在继续阅读本书之前需要更多关于 C# 及其使用的详细信息,那么我推荐以下资源:

以下是一些在线资源:

摘要

本章提供了一个通用的、针对 Unity 的 C#概述,探讨了游戏开发中最常见和广泛使用的语言特性。后续章节将以更深入的方式回顾一些这些主题,但这里涵盖的所有内容对于理解和使用后续章节中的代码都将是关键的。

第二章:调试

调试是查找、识别和修复代码中错误(错误或错误)的过程,并且有许多方法可以实现这一点。为了有效地编写脚本,你需要了解你可用于在 Unity 中进行调试的最常见的工作流程和工具集。在进一步考虑它们之前,了解调试的一般限制和它无法实现的事情是很重要的。调试不是一种万能的魔法药,可以消除所有错误并保证应用程序无错误。计算机科学家埃德加·W·迪杰斯特拉说:“程序测试可以用来显示错误的存在,但永远不能显示它们的缺失”。关键点是,在测试过程中,你可能会遇到一个或多个错误。这些错误可以通过调试来识别、测试和修复。然而,尽管你的测试可能非常广泛和细致,但你永远无法覆盖所有可能的案例或场景,因为这些组合在实际上可能是无限的。因此,你永远不能绝对确定已经找到了所有可能的错误。即使在发布当天,你的游戏中也可能仍然存在测试无法检测到的错误。当然,实际上可能根本没有任何错误遗留,但你无法绝对确定这一点。因此,调试不是关于实现无错误的应用程序。它的目标更加谦虚。它关于在许多常见和合理的情况下系统地测试你的游戏,以找到和纠正你遇到的所有错误,或者至少是时间预算允许的所有严重错误。无论如何,调试是脚本编写的一个关键部分,因为没有它,你就无法追踪和修复错误。调试技术从简单到复杂,在本章中,我们将涵盖它们的一个广泛范围。

编译错误和控制台

调试通常指的是运行时使用的错误排除技术;也就是说,它指的是你在游戏运行时可以做的事情来查找和纠正错误。当然,这种对调试的理解前提是你的代码已经是有效且编译过的。隐含的假设是你能够用 C#编写有效的语句并编译代码,而你只是想找到由程序逻辑引起的运行时错误。因此,重点不在于语法,而在于逻辑,这确实是正确的。然而,在本节中,我将非常简要地谈谈代码编译、编写有效代码以及使用控制台查找和纠正有效性错误。这很重要,不仅是为了一般性地介绍控制台窗口,也是为了为更深入地思考调试打下坚实的基础。考虑以下代码示例 2-1 脚本文件(ErrorScript.cs):

01 using UnityEngine;
02 using System.Collections;
03 
04 public class ErrorScript : MonoBehaviour 
05 {
06 int MyNumber = 5;
07 
08 // Use this for initialization
09 void Start () {
10 
11        mynumber = 7;
12 }
13 
14 // Update is called once per frame
15 void Update () {
16        mynumber = 10;
17 }
18 }

要编译前面的代码示例 2-1,只需在 MonoDevelop 中保存脚本文件(Ctrl + S)然后重新聚焦 Unity 编辑器窗口。从这里,编译将自动发生。如果它没有发生,你也可以从 项目 面板右键单击脚本文件,并在上下文菜单中选择 重新导入。对于代码示例 2-1,将生成两个错误,这些错误将在 控制台 窗口中显示。如果你还没有打开 控制台 窗口,可以通过从应用程序菜单中选择 窗口 > 控制台 来显示它。控制台 窗口非常重要,你几乎总是希望它在界面的某个地方打开。这是 Unity 作为引擎与作为开发者的你沟通的地方。因此,如果你的代码有编译错误,Unity 会将它们列在 控制台 中,让你知道它们是什么。

代码示例 2-1 生成两个编译时错误,如下截图所示。这些错误发生是因为第 11 行和第 16 行引用了不存在的变量 mynumber,尽管 MyNumber 存在(大小写敏感)。这种编译时错误是关键的,因为它们会使你的代码无效。这意味着你无法编译你的代码并运行你的游戏,直到错误被纠正。

编译错误和控制台

在控制台窗口中查看编译错误

如果编译错误没有按预期显示在 控制台 中,请确保错误过滤器已启用。要启用此功能,请单击 控制台 窗口右上角的错误过滤器图标(一个红色的感叹号图标)。控制台 窗口有三个过滤器,注释(A)、警告(B)和错误(C),如下截图所示,用于隐藏和显示特定消息。这些切换在 控制台 窗口中每种消息类型的可见性。注释是指你作为程序员,使用 Debug.Log 语句从你的代码中显式打印到 控制台 窗口的消息。我们很快就会看到这方面的例子(你也可以使用 Print 函数)。警告是指检测到你的代码中的潜在问题或浪费。这些在语法上是有效的,即使你忽略它们,也可以编译,但它们可能会引起问题或产生意外和浪费的结果。错误是指任何影响代码编译有效性的编译时错误,例如代码示例 2-1 中的错误。

编译错误和控制台

启用/禁用控制台窗口过滤器

当控制台充满了多个错误时,错误通常是按照编译器检测到的顺序列出的,即从上到下。被认为是一种最佳实践,按顺序处理错误,因为早期错误可能会引起后续错误。因此,解决早期错误可能会,潜在地,解决后续错误。要解决一个错误,首先双击控制台窗口中的错误,MonoDevelop 将自动打开,突出显示错误本身被发现或首次检测到的行。需要注意的是,MonoDevelop 会带您到错误首次被检测到的行,尽管解决错误并不总是涉及编辑该行。根据问题,您可能需要更改到突出显示的行以外的行。如果您双击由代码示例 2-1 生成的控制台中的顶部错误(第一个错误),MonoDevelop 将打开并突出显示第 11 行。您可以通过两种方式修复这个错误:要么在第 11 行将mynumber重命名为MyNumber,要么在第 6 行将变量MyNumber重命名为mynumber。现在,考虑以下代码示例 2-2:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class ErrorScript : MonoBehaviour 
05 {
06 int MyNumber = 5;
07 
08 // Use this for initialization
09 void Start () {
10 	
11       MyNumber = 7;
12 }
13 
14 // Update is called once per frame
15 void Update () {
16       MyNumber = 10;
17 }
18 }

代码示例 2-2 修复了代码示例 2-1 中的错误。然而,它给我们留下了一个警告(如以下截图所示)。这表明变量MyNumber从未被使用。它在第 11 行和第 16 行被赋值,但这种赋值对应用程序的最终结果没有任何影响。在这里,这个警告可以被忽略,代码仍然有效。警告应该主要被视为编译器对您的代码提出的建议。您如何处理它们是您最终的选择,但我建议您在可能的情况下尝试消除错误和警告。

编译错误和控制台

尝试消除错误和警告

使用 Debug.Log 进行调试——自定义消息

也许,在 Unity 中最古老且最著名的调试技术是使用Debug.Log语句将诊断信息打印到控制台,从而说明程序流程和对象属性。这项技术非常灵活且吸引人,因为它可以在几乎每一个集成开发环境IDE)中使用,而不仅仅是 MonoDevelop。此外,所有 Unity 对象,包括向量和颜色对象,都有一个方便的ToString函数,它允许它们的内部成员(如XYZ)被打印成人类可读的字符串——这种字符串可以轻松地发送到控制台进行调试。例如,考虑以下代码示例 2-3。这个代码示例演示了一个重要的调试工作流程,即打印关于对象实例化的状态信息。当这个脚本附加到场景对象上时,它会将其世界位置打印到控制台,并附带一条描述性信息:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class CubeScript : MonoBehaviour 
05 {
06 // Use this for initialization
07 void Start () {

08        Debug.Log ("Object created in scene at position: " + transform.position.ToString());transform.position.ToString());

09 }
10 }

以下截图展示了当附加到立方体GameObject时,此代码在控制台中的输出。Debug.Log消息打印在主控制台消息列表中。如果用鼠标选择该消息,控制台还将指示与该语句相关的脚本文件和行。

使用 Debug.Log 进行调试 – 自定义消息

Debug.Log 消息可以将对象转换为字符串,控制台窗口也会指示相关的脚本文件和行

Debug.Log作为调试技术的局限性主要与代码整洁性和程序复杂性相关。首先,Debug.Log语句要求您明确地将代码添加到源文件中。当您完成调试后,您需要手动删除Debug.Log语句,或者保留它们,这既浪费资源又会导致混淆,尤其是如果您需要在许多其他地方添加额外的Debug.Log语句。其次,尽管Debug.Log在针对特定问题或监控特定变量随时间变化时很有用,但最终要获得代码及其执行的高级视图以跟踪您检测到但位置完全未知的问题是很尴尬的。然而,这些批评不应被视为完全避免使用Debug.Log语句的建议。它们应仅被视为适当使用它们的考虑。Debug.Log在错误或问题可以追溯到主要嫌疑人对象,并且您想观察或监控其值以查看它们如何变化或更新时效果最佳,尤其是在OnStart等事件期间。

注意

移除 Debug.Log 语句

当您的游戏准备构建和发布时,请记住删除或注释掉任何Debug.Log语句以获得额外的整洁性。

重写 ToString 方法

以下代码示例 2-3 展示了在结合使用Debug.Log调试时,ToString方法的便利性。ToString允许您将对象转换为人类可读的字符串,并将其输出到控制台。在 C#中,每个类默认继承ToString方法。这意味着通过继承和多态,您可以重写您类的ToString方法,按需定制它,并生成一个更易读、更准确的调试字符串,该字符串代表您的类成员。考虑以下代码示例 2-4,它重写了ToString。如果您养成为每个类重写ToString的习惯,您的类将更容易调试:

using UnityEngine;
using System.Collections;
//--------------------------------------------
//Sample enemy Ogre class
public class EnemyOgre : MonoBehaviour 
{
//--------------------------------------------
//Attack types for OGRE
public enum AttackType {PUNCH, MAGIC, SWORD, SPEAR};

//Current attack type being used
public AttackType CurrentAttack = AttackType.PUNCH;

//Health
public int Health = 100;

//Recovery Delay (after attacking)
public float RecoveryTime = 1.0f;

//Movement speed of Ogre - metres per second
public float Speed = 1.0f;

//Name of Ogre
public string OgreName = "Harry";
//--------------------------------------------
//Override ToString method
public override string ToString ()
{
    //Return a string representing the class

          return string.Format ("***Class EnemyOgre*** OgreName: {0} | Health: {1} | Speed: {2} | CurrentAttack: {3} | RecoveryTime: {4}", 
          OgreName, Health, Speed, CurrentAttack, RecoveryTime);
}

//--------------------------------------------
void Start()
{

            Debug.Log (ToString());
}
   //--------------------------------------------
}
//--------------------------------------------

前述代码的输出可以在以下所示的控制台窗口中看到:

重写 ToString 方法

重写 ToString 方法以自定义类的调试消息

注意

String.Format

代码示例 2-3 的第 30 行使用了String.Format函数来构建一个完整的字符串。这个函数在你需要创建一个包含字面语句和变量值的字符串时非常有用,这些值可能是不同类型的。通过在字符串参数中插入标记{0}, {1}, {2}…Format函数将按照提供的顺序将它们替换为后续函数参数;也就是说,String.Format将在标记的位置连接你的字符串参数,以及你的函数参数的字符串形式。因此,字符串{0}将被替换为OgreName.ToString()。有关String.Format的更多信息,请参阅msdn.microsoft.com/en-us/library/system.string.format%28v=vs.110%29.aspx在线文档。

你可以在发布和调试版本之间划分并隔离代码块,这样当特定的标志被启用时,你可以运行针对调试的代码。例如,在调试游戏时,你通常会开发两组或多种代码:发布代码和调试代码。想象一个常见的场景,当你需要找到并解决代码中的错误时,你会求助于插入Debug.Log语句来打印变量和类的状态值。你可能还会插入额外的行,例如if语句和循环,来测试不同的场景并探索对象如何反应。修改代码一段时间后,问题似乎得到了修复,因此你移除了额外的调试代码并继续像之前一样进行测试。然而,后来你发现问题又出现了,或者出现了类似的问题。所以现在你希望你最终还是保留了调试代码,因为它们可能再次有用。你可能会对自己承诺说,下次你将简单地注释掉调试代码,而不是完全删除它。这样,如果代码再次需要,你可以简单地移除注释。然而,当然,必须注释和取消注释代码也是很麻烦的,尤其是如果有很多行,并且它们散布在多个文件和文件的部分。然而,你可以使用自定义的全局定义来解决此问题以及类似的问题。本质上,全局定义是一个特殊的预处理器标志,你可以启用或禁用它来有条件地编译或排除你的代码块。通过将标志设置为true,Unity 将自动编译你的代码的一个版本,而将标志设置为false,Unity 将编译另一个版本。这将允许你只在一组源文件中维护两个版本或变体:一个用于调试,一个用于发布。让我们在 Unity 中实际看看这个例子。考虑以下代码示例 2-5:

01 using UnityEngine;
02 using System.Collections;
03 
04 public class CubeScript : MonoBehaviour 
05 {
06 // Use this for initialization
07 void Start ()
08 {
09        #if SHOW_DEBUG_MESSAGES
10        //runs ONLY if the Define SHOW_DEBUG_MESSAGES is active
11        Debug.Log ("Pos: " + transform.position.ToString());
12       #endif
13 
14       //runs because it's outside the #if #endif block
15        Debug.Log ("Start function called");
16 }
17 }

第 09-12 行使用预处理指令 #if#endif 进行条件功能。这个条件不是在运行时像常规的 if 语句那样执行,而是在编译时执行。在编译时,Unity 将决定全局定义 SHOW_DEBUG_MESSAGES 是否指定或激活。如果,并且只有当它是激活的,那么第 10 和 11 行将被编译,否则编译器将忽略这些行,将它们视为注释。使用这个功能,你可以将所有调试代码隔离在检查调试定义的 #if #endif 块内,并根据 SHOW_DEBUG_MESSAGES 定义激活和停用代码,该定义适用于项目范围内的所有源文件。那么剩下的问题是定义是如何设置的。要设置全局定义,从应用程序菜单导航到编辑 | 项目设置 | 玩家。然后,在脚本定义符号字段中输入定义名称,确保在输入名称后按Enter键确认更改,如图所示:

覆盖 ToString 方法

从 Unity 编辑器添加全局自定义定义,这允许你条件编译代码

注意

移除定义并添加多个定义

只需在脚本定义符号字段中输入你的全局定义名称即可使其生效并应用于你的代码。你可以删除名称来移除定义,但也可以在名称前加上斜杠 /(例如,/SHOW_DEBUG_MESSAGE)来禁用定义,这样就可以更容易地稍后重新启用它。你还可以添加多个定义,每个定义之间用分号符号分隔(例如,DEFINE1;DEFINE2;DEFINE3…)。

可视调试

使用数据的抽象或文本表示(如 Debug.Log)进行调试通常足够,但并不总是最优的。有时,一张图片胜过千言万语。所以,例如,当为敌人和其他角色编写视线功能,允许他们在进入范围时看到玩家和其他对象时,获取视图中视线实际位置的实时和图形表示非常有用。这个功能是以线条或线框立方体的形式绘制的。同样,如果一个对象正在沿着路径移动,那么在视图中绘制这条路径并以彩色线条显示它将非常棒。这个目的不是为了创建最终游戏中真正显示的视觉辅助工具,而是为了简化调试过程,让我们更好地了解游戏的工作方式。这些类型的辅助工具或图示是可视调试的一部分。Unity 已经为我们提供了许多自动的图示,例如碰撞体的线框边界框和摄像机的视锥体。然而,我们也有能力为我们自己的对象创建自己的图示。本节将进一步探讨图示。

如前所述,许多 Unity 对象,如碰撞体、触发体积、NavMesh 代理、摄像机和灯光,在选择时已经提供了自己的视觉辅助和 Gizmo。除非您将其关闭或将其大小减小到零,否则这些默认显示在场景视图中。因此,如果您已添加了原生 Unity 对象但在场景视图中看不到 Gizmo,请务必检查从场景工具栏通过Gizmo按钮可访问的Gizmo面板。启用您想要看到的所有 Gizmo,并调整大小滑块以增加或减少 Gizmo 的大小(选择最适合您的大小),如图所示:

视觉调试

在场景视图中启用 Gizmo

注意

游戏标签页中的 Gizmo

Gizmos 默认不会在游戏标签页中显示。然而,您可以通过游戏标签页工具栏右上角的Gizmo按钮轻松更改此行为。此菜单的工作方式与场景标签页的Gizmo菜单类似,如前一张截图所示。

考虑以下代码示例 2-6。这是一个可以附加到对象上的示例类,它依赖于 Unity 的 Gizmo 类来绘制自定义范围的辅助 Gizmo。更多信息可以在网上找到,请参阅docs.unity3d.com/ScriptReference/Gizmos.html。在此示例中,这个类绘制了一个以指定半径为中心的边界线框球体,代表对象的攻击范围。此外,它还绘制了一条视线向量,代表对象的正面方向,提供了对象朝向的视觉指示。所有这些 Gizmo 都是在MonoBehaviourOnDrawGizmos事件中绘制的,条件是变量DrawGizmostrue

using UnityEngine;
using System.Collections;

public class GizmoCube : MonoBehaviour
{
//Show debugging info?
 public bool DrawGizmos = true;

    //Called to draw gizmos. Will always draw.
    //If you want to draw gizmos for only selected object, then call

    //OnDrawGizmosSelected
 void OnDrawGizmos() 
    {
        if(!DrawGizmos) return;

         //Set gizmo color
         Gizmos.color = Color.blue;

        //Draw front vector - show the direction I'm facing
 Gizmos.DrawRay(transform.position, transform.forward.normalized *  4.0f);

          //Set gizmo color
          //Show proximity radius around cube
          //If cube were an enemy, they would detect the player within this radius

Gizmos.color = Color.red;
 Gizmos.DrawWireSphere(transform.position, 4.0f);

          //Restore color back to white
          Gizmos.color = Color.white;
   }
}

以下截图显示了如何绘制帮助调试的 Gizmo:

视觉调试

绘制 Gizmo

错误日志

当你编译并构建游戏以分发给测试人员时,无论他们是在办公室集中还是分布在全球各地,你都需要一种方法来记录游戏过程中发生的错误和异常。一种方法是通过日志文件。日志文件是由游戏在本地计算机上运行时生成的人类可读文本文件,它们记录了错误发生的详细信息,如果发生的话。你记录的信息量是一个需要仔细考虑的问题,因为记录过多的细节可能会使文件变得难以理解,而记录过少则可能使文件无用。然而,一旦达到平衡,测试人员就可以将日志发送给你进行检查,这有望让你能够快速定位代码中的错误并有效地修复它们,也就是说,不会引入新的错误!在 Unity 中实现日志行为有许多方法。一种方法是通过使用本地的Application类通过委托接收异常通知。考虑以下代码示例 2-7:

 01 //------------------------------------------------
 02 using UnityEngine;
 03 using System.Collections;
 04 using System.IO;
 05 //------------------------------------------------
 06 public class ExceptionLogger : MonoBehaviour 
 07 {
 08      //Internal reference to stream writer object
 09      private System.IO.StreamWriter SW;
 10 
 11      //Filename to assign log
 12      public string LogFileName = "log.txt";
 13 
 14      //------------------------------------------------
 15      // Use this for initialization
 16      void Start () 
 17      {
 18             //Make persistent
 19             DontDestroyOnLoad(gameObject);
 20 
 21      //Create string writer object

 22       SW = new System.IO.StreamWriter(Application.persistentDataPath + "/" + LogFileName);

 23 
 24      Debug.Log(Application.persistentDataPath + "/" + LogFileName);

 25       }
 26       //------------------------------------------------
 27      //Register for exception listening, and log exceptions
 28      void OnEnable() 
 29      {
 30            Application.RegisterLogCallback(HandleLog);
 31      }
 32      //------------------------------------------------
 33      //Unregister for exception listening
 34      void OnDisable() 
 35      {
 36           Application.RegisterLogCallback(null);
 37      }
 38       //------------------------------------------------
 39       //Log exception to a text file

 40       void HandleLog(string logString, string stackTrace, LogType type)

 41      {
 42      //If an exception or error, then log to file
 43      if(type == LogType.Exception || type == LogType.Error)
 44             {

 45                  SW.WriteLine("Logged at: " + System.DateTime.Now.ToString() + " - Log Desc: " + logString + " - Trace: " + stackTrace + " - Type: " + type.ToString());

 46             }
 47       }
 48       //------------------------------------------------
 49       //Called when object is destroyed
 50       void OnDestroy()
 51       {
 52             //Close file
 53             SW.Close();
 54       }
 55       //------------------------------------------------
 56 }
 57 //------------------------------------------------

以下是对代码示例 2-7 的注释:

  • 第 22 行:创建一个新的StreamWriter对象,用于将调试字符串写入计算机上的文件。该文件位于Application.persistentDataPath内部;这指向一个始终可写的系统位置。

  • 第 30 行:使用函数引用HandleLog作为参数调用Application.RegisterLogCallBack方法。这依赖于委托。简而言之,传递了HandleLog函数的引用,当发生错误或异常时,这个引用将被调用,允许我们将详细信息写入日志文件。

  • 第 45 行:当发生错误时,调用StreamWriterWriteLine方法将文本数据打印到日志文件。错误信息由 Unity 通过HandleLog参数提供:logStringstackTraceLogTypeStreamWriter类是 Mono Framework 的一部分,Mono Framework 是 Microsoft .NET Framework 的开源实现。有关StreamWriter的更多信息,请参阅msdn.microsoft.com/en-us/library/system.io.streamwriter%28v=vs.110%29.aspx

    小贴士

    测试错误记录器的一种快速方法就是创建一个除以零的错误。别忘了在代码中某处插入一行Debug.Log (Application.persistentDataPath);,以便将日志文件路径打印到控制台窗口。这可以帮助你通过 Windows 资源管理器或 Mac 查找器快速找到系统中的日志文件。请注意,使用的是persistentDataPath变量而不是绝对路径,因为它们在不同的操作系统之间是不同的。

以下截图显示了如何将错误打印到基于文本的日志文件:

错误记录

将错误打印到基于文本的日志文件可以使调试和错误修复更容易

C#中的委托是什么?想象一下,你能够创建一个变量并将其分配一个函数引用而不是一个常规值。完成此操作后,你可以像调用函数一样调用该变量,在稍后时间调用引用的函数。你甚至可以在以后重新分配变量以引用新的不同函数。本质上,这就是委托的工作方式。如果你熟悉 C++,委托实际上与函数指针相当。因此,委托是特殊类型,可以引用和调用函数。它们非常适合创建可扩展的回调系统和事件通知。例如,通过保持委托类型的列表或数组,许多不同的类可以通过将自己添加到列表中作为回调的监听器来注册自己。有关 C#的更多信息,请参阅msdn.microsoft.com/en-gb/library/ms173171.aspx。以下代码示例 2-8 展示了在 Unity 中 C#委托使用的一个例子:

using UnityEngine;
using System.Collections;
//---------------------------------------------------
public class DelegateUsage : MonoBehaviour 
{
 //Defines delegate type: param list
 public delegate void EventHandler(int Param1, int Param2);
//---------------------------------------------------
//Declare array of references to functions from Delegate type - max 10 events

public EventHandler[] EH = new EventHandler[10];
//---------------------------------------------------
/// <summary>
/// Awake is called before start. Will add my Delegate HandleMyEvent to list
/// </summary>
void Awake()
{
    //Add my event (HandleMyEvent) to delegate list
    EH[0] = HandleMyEvent;
}
//---------------------------------------------------
/// <summary>
/// Will cycle through delegate list and call all events
/// </summary>
 void Start()
 {
    //Loop through all delegates in list
    foreach(EventHandler e in EH) 
    {
          //Call event here, if not null
          if(e!=null)
               e(0,0); //This calls the event
    }
 }
//---------------------------------------------------
/// <summary>
/// This is a sample delegate event. Can be referenced by Delegate Type EventHandler
/// </summary>
/// <param name="Param1">Example param</param>
/// <param name="Param2">Example param</param>
 void HandleMyEvent (int Param1, int Param2)
 {
    Debug.Log ("Event Called");
 }
//---------------------------------------------------

编辑器调试

有时候人们声称 Unity 编辑器没有内置的调试工具,但这并不完全正确。使用 Unity,你可以在游戏运行时同时播放游戏和编辑场景。你甚至可以观察和编辑对象检查器中的属性,无论是私有的还是公共的,就像我们之前看到的。这可以给你一个关于游戏运行时的完整和图形化的视图;并允许你检测和观察广泛潜在的错误。这种调试形式不应被低估。要充分利用编辑器中的调试,请通过点击检查器右上角的上下文菜单图标,从对象检查器激活调试模式,然后从菜单中选择调试,如图所示:

编辑器调试

从对象检查器访问调试模式

接下来,请确保你的视口配置得当,以便在播放模式下同时看到场景游戏视图,以及统计面板。为了实现这一点,如果游戏选项卡工具栏中的最大化播放按钮被激活,请将其禁用。然后,在界面中将场景游戏选项卡并排排列,或者如果你有多个显示器,可以将它们跨多个显示器排列。如果你的预算允许,强烈建议使用多个显示器,但单个显示器也可以很好地工作,只要你投入额外的时间来调整和调整每个窗口的大小,以最好地满足你的需求。此外,你通常希望控制台窗口可见,而项目面板隐藏,以防止意外选择和移动资源,如下面的截图所示。记住,你还可以自定义 Unity GUI 布局。更多信息请参阅docs.unity3d.com/Manual/CustomizingYourWorkspace.html

编辑器调试

使用单显示器布局从编辑器调试游戏

一旦你准备好在编辑器中进行调试,点击工具栏上的播放按钮,如果你需要停止游戏事件来检查特定对象及其值,可以使用暂停。记住,你仍然可以在游戏中使用变换(位置、旋转和缩放)工具来重新定位玩家或敌人,从而尝试新的值并查看哪些有效,哪些无效。然而,最重要的是,在游戏模式下通过对象检查器或变换工具对场景的所有编辑都是临时的,并在播放模式结束后恢复。因此,如果你需要永久更改设置,那么你需要在编辑模式中做出更改。当然,你可以随时使用组件上下文菜单在播放编辑模式之间复制和粘贴值,如下面的截图所示。记住,快捷键(Ctrl + P)在播放模式之间切换,(Ctrl + Shift + P)在暂停和未暂停之间切换。Unity 的完整快捷键列表可以在docs.unity3d.com/Manual/UnityHotkeys.html找到。

编辑器调试

通过组件上下文菜单复制和粘贴组件值

使用性能分析器

一个部分用于调试和部分用于优化的额外工具是性能分析器窗口,它仅在 Unity Pro 中通过在应用程序菜单的窗口中点击性能分析器选项卡来访问,如下面的截图所示。简而言之,性能分析器为你提供了一个从上到下的统计视图,展示了时间和工作负载如何在游戏的各个部分以及系统硬件组件(如 CPU 和显卡)之间分布。使用性能分析器,你可以确定,例如,场景中相机渲染所消耗的时间与物理计算或音频功能相比,以及与其他类别相比。它让你能够测量性能,比较数字,并评估性能可以改进的地方。性能分析器并不是一个专门提醒你代码中存在错误的具体工具。然而,如果你在运行游戏时遇到性能问题,如卡顿和冻结,那么它可能能引导你找到可以进行优化的地方。因此,如果你决定性能是你的游戏问题,并且需要经过教育、研究分析的改进起点,性能分析器是一个你会转向的工具。

使用性能分析器

性能分析器通常用于诊断性能问题

当你使用打开的Profiler窗口运行游戏时,图表会填充关于最近帧的统计数据。性能分析器通常不会记录自游戏开始以来的所有帧的信息,而只记录最近的一些帧,这些帧可以合理地放入内存中。在Profiler窗口的上部工具栏中有一个可切换的“深度分析”方法,理论上它允许你获取关于游戏的额外信息,但我建议你避免使用此模式。它可能会在运行资源密集型和代码密集型游戏时导致 Unity 编辑器的性能问题,甚至可能完全冻结编辑器。相反,我建议你只使用默认模式。当使用此模式时,在大多数情况下,你可能会想要从CPU Usage区域禁用VSync的可视化,以获得更好的其他性能统计信息视图,包括RenderingScripts,如下面的截图所示。为此,只需在图表索引区域单击VSync图标:

使用性能分析器

在 Profiler 窗口的 CPU 使用区域禁用 VSync 显示

图表的横轴代表帧——最近添加到内存缓冲区的帧。当游戏运行时,这个轴会持续填充新的数据。纵轴代表时间或计算开销:更高的值表示更苛刻且更慢的帧时间。在播放模式下,当图表填充了一些数据后,你可以暂停游戏来检查其状态。从图表中选择帧以查看该帧的游戏性能的更多信息。当你这样做时,Profile窗口下半部分的Hierarchy面板会填充关于在选定帧上执行的代码的功能数据。在查看图表时,注意观察突然的增加(峰值或尖峰),如下面的截图所示。这些表示突然且强烈的活动帧。有时,它们可能是由于硬件操作而无法避免的一次性事件,或者它们是合法发生的,并不是性能问题的来源,例如场景转换或加载界面。

然而,它们也可能表示问题,尤其是如果它们经常发生。因此,在诊断性能问题时,寻找峰值是一个好的开始。

使用性能分析器

从性能分析器图表中选择帧

Hierarchy视图列出了在选定帧上执行的代码中的所有主要函数和事件。对于每个函数,都有几个关键属性,如TotalSelfTime msSelf ms,如下所示:

使用性能分析器

检查选定帧中的函数调用

让我们更详细地讨论这些关键属性:

  • Total 和 Time ms: Total 列表示函数消耗的帧时间的比例。例如,49.1% 的值意味着所选帧所需的总时间的 49.1% 被函数消耗,包括调用子函数(函数内部调用的函数)所花费的时间。Time ms 列以绝对值表达帧消耗时间,以毫秒为单位。这两个值共同提供了对在每一帧和总体上调用函数代价的相对和绝对度量。

  • Self 和 Self ms: TotalTotal ms 设置衡量所选帧中函数的消耗,但它们包括从函数内部调用的其他函数所花费的总时间。SelfSelf ms 排除这部分时间,仅表达函数内部所花费的总时间,减去等待其他函数完成的多余时间。这些值在试图确定导致性能问题的特定函数时通常是最重要的。

更多关于 Unity Profiler 的信息可以在 docs.unity3d.com/Manual/ProfilerWindow.html 找到。

使用 MonoDevelop 进行调试 – 入门

之前,我们遇到了调试的 Debug.Log 方法,在代码的关键时刻打印辅助消息到控制台,以帮助我们了解程序的执行情况。然而,这种方法虽然有效,但存在一些显著的缺点。首先,当编写包含许多 Debug.Log 语句的大型程序时,很容易有效地“垃圾邮件”控制台,导致难以区分所需的和不必要的消息。其次,通过插入 Debug.Log 语句来更改代码以监控程序流程和查找错误通常是一种不良的做法。理想情况下,我们应该能够在不更改代码的情况下进行调试。因此,我们有许多理由去寻找替代的调试方法。MonoDevelop 可以在这里帮助我们。具体来说,在 Unity 的最新版本中,MonoDevelop 可以原生地附加到正在运行的 Unity 进程。通过这样做,我们可以访问一系列常见的调试工具,就像在开发其他类型的软件时遇到的那样,例如断点和跟踪。然而,目前 MonoDevelop 和 Unity 之间的连接可能存在一些问题,对于某些系统上的某些用户来说。但是,当按预期工作的时候,MonoDevelop 可以提供丰富且有用的调试体验,使我们能够超越仅仅编写 Debug.Log 语句的简单方法。

要使用 MonoDevelop 开始调试,让我们考虑断点。在调试代码时,您可能会需要观察程序在到达指定行时的流程。断点允许您在 MonoDevelop 中标记源文件中的一行或多行,当程序在 Unity 中运行时,其执行将在第一个断点处暂停。在此暂停时,您有机会检查代码和变量的状态,以及检查和编辑它们的值。您还可以通过单步执行继续执行。这允许您按正常程序逻辑逐行推进执行。您有机会在代码经过每一行时检查它。让我们看一个示例案例。以下代码示例 2-9 显示了一个简单的脚本文件。当附加到对象时,它会检索场景中所有对象(包括自身)的列表,然后在Start函数执行时将它们的位置设置为世界原点(0, 0, 0),该函数在关卡启动时发生:

using UnityEngine;
using System.Collections;

public class DebugTest : MonoBehaviour 
{
   // Use this for initialization
void Start () 
    {
         //Get all game objects in scene
         Transform[] Objs = Object.FindObjectsOfType<Transform>();

         //Cycle through all objects
         for(int i=0; i<Objs.Length; i++)
         {
               //Set object to world origin
 Objs[i].position = Vector3.zero;
         }
    }
 }

让我们在高亮行上设置一个断点,通过 MonoDevelop。当程序执行到达此行时,它将暂停。要设置断点,将鼠标光标置于高亮行上,在左侧灰色边缘处右键单击,并选择新断点。否则,可以使用 MonoDevelop 应用程序菜单在运行中选择新断点选项,或者您也可以按F9键(或者您也可以左键单击行号),如图所示:

使用 MonoDevelop 进行调试 – 入门

在 MonoDevelop 中创建新的断点

断点行将以红色突出显示。为了使断点在游戏运行时与 Unity 正常工作,您需要将 MonoDevelop 附加到正在运行的 Unity 进程。为此,请确保 Unity 编辑器与 MonoDevelop 同时运行,然后从 MonoDevelop 应用程序菜单中选择附加到进程选项,如以下截图所示:

使用 MonoDevelop 进行调试 – 入门

附加到进程

附加到进程对话框出现,Unity Editor应列在进程名称中,MonoDevelop 可以附加到该进程。窗口左下角的调试器下拉列表应指定为Unity Debugger。选择Unity Editor选项,然后选择附加按钮,如图所示:

使用 MonoDevelop 进行调试 – 入门

附加到进程对话框中选择 Unity 编辑器

当 MonoDevelop 作为进程附加到 Unity 时,两个新的底部对齐的面板将自动停靠到 MonoDevelop 界面中,这些面板包括监视窗口和立即窗口,如以下截图所示。当您的游戏在 Unity 编辑器中运行时,这些窗口提供了额外的调试信息和视图,我们将在下一节中看到。

使用 MonoDevelop 进行调试 – 入门

当附加到 Unity 进程时,两个新的面板会自动停靠到 MonoDevelop 中

接下来,返回到 Unity 编辑器,并确保脚本文件 DebugTest.cs(如代码示例 2-9 所示)已附加到场景中的对象上,并且场景中包含其他对象(任何对象,例如立方体或圆柱体)。然后,使用 Unity 工具栏中的播放按钮运行您的游戏,如图所示:

使用 MonoDevelop 调试 – 入门

从 Unity 编辑器运行以准备使用 MonoDevelop 调试

当您在附加 MonoDevelop 时按下 Unity 工具栏上的播放按钮,当达到断点时(断点模式),Unity 的执行将暂停。焦点将切换到带有断点行(在源文件中用黄色突出显示)的 MonoDevelop 窗口,该行指示当前的执行步骤,如图以下截图所示。在此模式下,您不能使用 Unity 编辑器,并且您不能在视图中切换或甚至像在编辑器调试中那样在对象检查器内编辑设置。MonoDevelop 正在等待您的输入以恢复执行。接下来的几节将考虑一些在断点模式下可用的有用调试工具。

使用 MonoDevelop 调试 – 入门

从 MonoDevelop 内进入断点模式

使用 MonoDevelop 调试 – 观察窗口

观察窗口允许您查看当前步骤中内存中活动的变量的值,这包括局部和全局变量。在断点模式下快速为变量添加监视的一种方法是在代码编辑器中突出显示它,然后将鼠标悬停在其上。当您这样做时,将鼠标悬停几秒钟,将自动出现一个弹出窗口。此窗口允许完全检查变量,如图以下截图所示。您可以收缩和展开类的成员,并检查所有变量的状态。

使用 MonoDevelop 调试 – 观察窗口

在断点模式下使用悬停监视检查变量

您可以使用此悬停方法检查任何活动对象的几乎所有变量值。然而,通常,您可能希望在一个变量甚至一组变量上放置一个更持久的监视,以便您可以在列表中一起查看它们的值。为此,您可以使用位于 MonoDevelop 界面左下角的观察窗口。要在该窗口中添加新的监视,请右键单击观察列表,然后从上下文菜单中选择添加监视,如图所示:

使用 MonoDevelop 调试 – 观察窗口

向观察窗口添加监视

在添加新的观察时,您可以在名称字段中输入任何有效的表达式或变量名,结果值将在列中显示,如下面的截图所示。在观察字段中显示的值仅适用于当前执行行,并且随着程序的进行而改变。请记住,您可以添加任何在当前作用域中可以引用的有效变量的观察,包括nametagtransform.position等。

使用 MonoDevelop 进行调试 – 观察窗口

在观察窗口中添加观察

您可以使用观察窗口来检查任何有效的变量和表达式,无论它们是否与活动类或代码行相关。这意味着您可以看到全局变量的值以及与其它类或对象相关的任何变量,只要它们是有效的并且在内存中。然而,如果您只对查看局部变量感兴趣,即变量的作用域与当前步骤中正在执行的代码块相关,那么您可以使用局部变量窗口而不是观察窗口。此窗口会自动为所有局部变量添加观察。您不需要手动添加。在这里,局部变量窗口默认情况下位于观察窗口旁边:

使用 MonoDevelop 进行调试 – 观察窗口

仅使用局部变量窗口检查局部变量

如果您在 MonoDevelop 界面中没有看到任何相关的调试窗口,例如观察窗口或局部变量窗口,您可以通过点击 MonoDevelop 应用程序菜单中的视图下的调试窗口选项来手动显示或隐藏它们:

使用 MonoDevelop 进行调试 – 观察窗口

观察局部变量窗口的一个优点是它们提供了对变量的读写访问。这意味着您不仅限于查看变量值,还可以写入它们,在 MonoDevelop 内部更改变量。要这样做,只需从观察局部变量窗口的双击字段,然后为变量输入新值:

使用 MonoDevelop 进行调试 – 观察窗口

从观察窗口编辑值

使用 MonoDevelop 进行调试 – 继续和单步执行

在达到断点并检查你的代码后,你很可能会想要退出断点模式并以某种方式继续程序执行。你可能想继续程序执行,这实际上将程序控制权交还给 Unity。这允许执行继续正常进行,直到遇到下一个断点(如果有的话)。这种方法有效地以正常方式恢复执行,并且除非遇到新的断点,否则永远不会再次暂停。要从 MonoDevelop 以这种方式继续,请按 F5 键或从 MonoDevelop 工具栏中按播放按钮。否则,从 MonoDevelop 应用程序菜单中选择 Run 中的 Continue Debugging 选项,如图所示:

使用 MonoDevelop 调试 – 继续和单步执行

退出断点模式并使用继续调试恢复

然而,有许多情况下,你不想以这种方式继续执行。相反,你希望逐行执行代码,逐行评估每行,并检查程序流程以查看变量如何变化以及受语句的影响。单步执行模式有效地让你观察程序流程的实时情况。在调试中主要有三种单步执行方式:单步执行、进入和退出。单步执行指示调试器移动到下一行代码,然后再次暂停,等待你的检查,就像下一行是一个新的断点一样。如果在下一行遇到外部函数调用,调试器会像往常一样调用该函数,然后跳到下一行而不进入函数。这样,函数就是“单步执行”的。函数仍然会发生,但它在继续模式下发生,并且下一个步骤或断点设置在函数之后的下一行。要单步执行,请按 F10,从应用程序菜单中选择 Run 中的 Step Over 命令,或按 MonoDevelop 工具栏中的 Step Over 按钮,如图所示:

使用 MonoDevelop 调试 – 继续和单步执行

单步执行代码会将执行移动到下一语句,而不进入外部函数

如果遇到外部函数调用,Step Into (F11) 命令允许调试进入此函数。这实际上在进入函数的第一行设置了下一个断点,允许在下一个步骤中继续调试。如果你需要观察多少个函数协同工作,这可能会很有用。在任何时候,如果你想通过在继续模式下向前移动来退出进入的函数,可以使用 Step Out (Shift + F11) 命令,并且执行将在外部函数的下一行继续。

使用 MonoDevelop 调试 – 调用堆栈

更复杂的程序通常涉及大量的函数和函数调用。在执行过程中,函数可以调用其他函数,而这些函数可以继续在函数内部的复杂函数链中调用更多函数。这意味着当在函数内部设置断点时,您永远不知道函数在运行时实际调用时是如何被最初调用的。断点告诉您程序执行已到达指定的行,但它不会告诉您执行最初是如何到达那里的。有时,这可能很容易推断,但有时可能会困难得多,尤其是在函数在循环、条件语句和嵌套循环和条件语句中调用时。考虑以下代码示例 2-10,它已被从早期的代码示例 2-9 中修改。这个类包含几个调用其他函数的函数:

using UnityEngine;
 using System.Collections;

 public class DebugTest : MonoBehaviour 
 {
    // Use this for initialization
    void Start () 
    {
          //Get all game objects in scene
          Transform[] Objs = Object.FindObjectsOfType<Transform>();

         //Cycle through all objects
         for(int i=0; i<Objs.Length; i++)
         {
                //Set object to world origin
                Objs[i].position = Vector3.zero;
         }

         //Enter Function 01
         Func01();
    }
    //-------------------------------------
    //Function calls func2
 void Func01()
    {
           Func02();
    }
    //-------------------------------------
    //Function calls func3
    void Func02()
    {
           Func03();
    }
    //-------------------------------------
    //Function prints message
    void Func03()
    {
 Debug.Log ("Entered Function 3");
    }
    //-------------------------------------
 }

如果在代码示例 2-10 的第 38 行设置了断点(已高亮显示),当执行到这一行时程序将暂停。通过阅读这个示例,我们可以看到到达该函数的一条路径是通过Start函数调用Func01,然后Func01调用Func02,最后Func02最终调用Func03。但是,我们如何知道这是唯一的路径呢?技术上,例如,项目中的另一个类和函数可以直接调用Func03。那么,在调试过程中,我们如何知道我们到达这个函数的路径呢?基于到目前为止检查的工具,我们无法知道。然而,我们可以使用调用栈窗口。这个窗口默认显示在 MonoDevelop 界面的右下角,列出了为达到当前步骤的激活函数而进行的所有函数调用,从而返回到第一个或初始函数调用。它为我们提供了一个从激活函数到第一个或初始函数的函数名称的面包屑路径。因此,调用栈以倒序列出函数名称,最活跃或最近的函数位于栈顶,向下延伸到栈底最早或第一个函数。您还可以访问这些函数的位置,以评估它们作用域内的变量,如下所示:

使用 MonoDevelop 进行调试 – 调用栈

使用调用栈追踪程序执行期间函数的启动过程

使用 MonoDevelop 进行调试 – 立即窗口

对于游戏,即时窗口类似于许多第一人称射击游戏(如UnrealHalf LifeCall of Duty)中的控制台窗口。即时窗口默认停靠在 MonoDevelop 界面的右下角。它在断点模式下变为活动状态。使用它,我们可以输入表达式和语句,它们将被立即评估,就像它们是此步骤的源代码的一部分一样。我们可以获取和设置活动变量的值,以及执行其他操作。我们可以编写任何有效的表达式,例如2+210*5。这些表达式的结果将在即时窗口的下一行输出,如图所示:

使用 MonoDevelop 进行调试 – 即时窗口

在即时窗口中评估表达式

当然,你不仅限于编写涉及基本算术运算(如加法和减法)的孤立语句。你可以编写包含活动变量的完整表达式:

使用 MonoDevelop 进行调试 – 即时窗口

在即时窗口中编写更高级的表达式

总体而言,即时窗口特别适用于测试代码,在即时窗口中编写替代场景,并查看它们的评估结果。

使用 MonoDevelop 进行调试 – 条件断点

断点对于调试至关重要,它代表了应用程序执行暂停进入调试状态的开始点。通常,断点正是你设置断点并开始调试所需的东西!然而,有时断点在其默认配置下可能会变得令人烦恼。一个例子是在循环内部设置断点。有时,你可能只想在循环超过指定次数迭代后使断点生效或暂停执行,而不是从开始就在每次迭代后生效。默认情况下,循环内的断点会在每次迭代时暂停执行,如果循环很长,这种暂停行为可能会很快变得令人厌烦。为了解决这个问题,你可以设置断点条件,指定断点生效必须为真的状态。要设置断点条件,右键单击断点,从上下文菜单中选择断点属性,如图所示:

使用 MonoDevelop 进行调试 – 条件断点

通过访问断点属性来设置条件

选择断点属性将显示断点属性对话框,其中可以指定断点的条件。在条件部分,选择当条件为真时中断选项,然后使用条件表达式字段指定确定断点的条件。对于循环条件,表达式i>5将在循环迭代器超过5时触发断点。当然,变量i应替换为您自己的变量名。

使用 MonoDevelop 进行调试 – 条件断点

设置断点的条件

使用 MonoDevelop 进行调试 – 跟踪点

跟踪点可以为您提供比使用Debug.Log语句更整洁的替代方案,正如我们所看到的,这迫使我们修改正在调试的代码。跟踪点的工作方式类似于断点,即在您的源文件中标记行。它们不会更改代码本身,但(与断点不同)当调试器遇到它们时,它们不会暂停程序执行。相反,它们会自动执行指定的指令。通常,它们会将调试语句打印到 MonoDevelop 的应用程序输出窗口中,但不会打印到 Unity 的控制台。要在代码示例 2-10 的第 16 行设置断点,请将光标置于第 16 行,然后从应用程序菜单中选择运行中的添加跟踪点(或按Ctrl + Shift + F9),如下所示:

使用 MonoDevelop 进行调试 – 跟踪点

将跟踪点添加到 MonoDevelop 中选定的行

在选择添加跟踪点选项时,MonoDevelop 将显示添加跟踪点对话框。跟踪文本字段允许你在运行时遇到跟踪点时将文本打印到应用程序输出窗口。您还可以插入大括号开闭符号来定义字符串中应评估表达式的区域。这可以让您将变量的值打印到调试字符串中,例如"循环计数器是 {i}",如下所示:

使用 MonoDevelop 进行调试 – 跟踪点

设置跟踪点文本

点击确定后,跟踪点将被添加到所选行。在 MonoDevelop 中,该行将以菱形标记,而不是圆形;这个菱形形状表示一个断点:

使用 MonoDevelop 进行调试 – 跟踪点

插入跟踪点

在代码编辑器中设置所选行的跟踪点并通过 MonoDevelop 的附加程序运行应用程序后,游戏将正常运行,直接从 Unity 编辑器中运行。然而,当遇到跟踪点时,应用程序不会暂停或进入断点模式,就像使用断点时那样。相反,跟踪点会自动将打印的语句输出到 MonoDevelop 的应用程序输出窗口,而不会造成暂停。默认情况下,此窗口停靠在 MonoDevelop 界面的底部:

使用 MonoDevelop 进行调试 – 跟踪点

Tracepoints 可以将诸如 Debug.Log 这样的语句打印到 MonoDevelop 的应用程序输出窗口中

跟踪点是使用 Unity 内部的 Debug.Log 语句的有效且有用的替代方案,并且你不需要以使用 Debug.Log 时的方式修改代码来使用它们。不幸的是,它们不会直接打印到 Unity 的 控制台。相反,它们出现在 MonoDevelop 的 应用程序输出 窗口中。然而,只要你认识到这一点,使用跟踪点可以是一种强大且有用的方法来查找和移除错误。

摘要

本章讨论了调试过程,这个过程主要关于从你的游戏中查找和移除错误。在 Unity 中,实现这一目标有许多方法,特别是考虑了以下方法:Debug.Log 语句,可能是所有调试方法中最简单的一种。使用这种技术,Debug.Log 语句被插入到代码的关键行中,并将诊断信息打印到 Unity 的 控制台。接下来,我们探讨了自定义定义:使用它们,你可以在发布和调试版本之间隔离和分离代码块;这允许你在启用特定标志时运行特定的调试代码。然后,我们讨论了错误日志。本章演示了如何创建一个与原生 Unity 应用程序类集成的错误记录器类,使用委托。我们还看到了分析器;Unity 分析器是一个专业功能,它为我们提供了对处理如何随时间分布以及系统资源的统计洞察。此外,我们还探讨了编辑器调试和可视化调试,以获得对场景更清晰的视觉洞察,以及影响对象行为因素。最后,我们看到了 MonoDevelop 调试,这不需要我们编辑代码。这些包括断点、跟踪点、步骤和监视器。接下来,我们将探讨如何与 GameObjects 一起工作。

第三章:单例、静态、GameObject 和世界

在 Unity 中,每个级别或游戏世界都由一个场景表示,而场景是由位于笛卡尔 3D 坐标系中、具有xyz轴的游戏对象集合组成的。场景中的单位以 Unity 单位计量,在实际应用中相当于米。为了能够熟练地使用 Unity 进行脚本编写,理解场景和对象的解剖结构以及对象间通信的方式至关重要;也就是说,了解场景中独立且分离的对象如何相互通信以实现预期的效果非常重要。因此,本章重点介绍了在场景中优化搜索、引用和访问对象的本地 Unity 方法。它还关注了诸如静态和单例等额外概念,用于创建在场景间移动并保留其数据的对象。当然,本章不仅会单独考虑这些方法,还会尝试在考虑性能和效率的实用环境中评估它们。

GameObject

在许多意义上,GameObject是场景中的基本单元或实体。它最自然地对应于我们日常所说的“事物”。实际上,你需要在游戏中实现的具体的上下文特定行为或事物类型并不重要,因为在所有情况下,你都需要GameObjects来实现它们。GameObjects不需要对玩家可见;它们可以是,并且通常是不可见的。声音、碰撞体和管理类是一些不可见的GameObjects的例子。另一方面,许多GameObjects将是可见的:网格、动画网格、精灵等等。然而,在所有情况下,无论是可见的还是不可见的,GameObject都是在场景中以相关组件的集合形式实例化的。组件本质上是从MonoBehaviour派生出的类,并且可以附加到场景中的GameObject上以改变其行为。每个GameObject至少有一个共同的最小组件,并且无法移除,即变换组件(或对于 GUI 对象而言是 RectTransform)。这个组件负责跟踪对象的位置、旋转和缩放。例如,如果你通过从应用程序菜单中选择GameObject | Create Empty来在场景中创建一个空白、空的游戏对象,如图下所示,你将在场景中得到一个新的游戏对象,它只包含一个变换组件。因此,即使是新的空GameObject在严格意义上也不是空的,但它几乎是GameObject可能达到的最空状态。该对象仍然需要一个变换组件来保持其在场景中的物理位置。

The GameObject

所有 GameObject 都具备变换组件

当然,一个 GameObject 可以有多个组件,一个对象的行为是由其组件的组合和交互产生的。你可以使用 组件 菜单向对象添加更多预制的组件,也可以通过将你的脚本拖放到对象上来添加自己的自定义组件。

The GameObject

组件附加到 GameObject

因此,GameObjects 由组件组成。在更高层次上,场景由单个世界空间内的多个 GameObjects 集合组成。此外,对象之间存在着由场景层次结构定义的重要关系。对象可以是其他对象的子对象,而这些对象反过来又是它们的父对象(transform.parent)。这种关系对对象的移动和变换有重要影响。简而言之,对象的变换组件的值会向下级联并添加到所有子对象的变换中。通过这种方式,子 GameObject 总是相对于其父对象进行偏移和变换;父对象的位置是子对象位置的起点。然而,如果一个对象没有父对象,那么它就会从世界原点(0, 0, 0)进行变换。以下截图显示了 层次结构 面板:

The GameObject

GameObjects 存在于一个场景层次结构中,该结构决定了它们的变换

组件交互

我们已经看到了 GameObject 的解剖结构,它是一个组件的集合,没有更多。这引发了一些关于组件如何相互交互和通信的物流问题。每个组件实际上都是一个自包含的脚本文件,与其他组件分开,但组件通常需要与其他组件交互。具体来说,你经常需要访问同一 GameObject 上其他组件的变量并调用函数,甚至可能需要在每一帧都这样做。本节探讨了这种组件间的通信。

在其他组件上调用函数的一种方法是通过使用 SendMessageBroadcastMessage,如第一章Unity C# 快速入门中所示。这些函数是无类型的。具体来说,它们是我们可能在脚本中的任何地方调用的函数,用于通过名称在 所有 附加到同一对象的组件上调用方法,无论它们的类型如何。这些函数根本不关心组件类型。这使得 SendMessageBroadcastMessage 都很方便使用。然而,它们的问题有两个方面。首先,它们是全有或全无的事情;我们可能对所有组件或根本不调用按名称的函数。我们不能挑选和选择消息被发送到的组件,因为它总是发送到所有组件。其次,这两种方法(SendMessageBroadcastMessage)在内部依赖于反射,这在使用频繁时可能会引起性能问题,例如在 Update 事件中调用这些函数,或者在更糟糕的情况下,在 OnGUI 事件中。因此,在可能的情况下,寻求使用替代方法。让我们在以下章节中考虑这些方法。

GetComponent

如果你需要直接访问对象上的特定单个组件并且你知道其数据类型,尝试使用以下代码示例 3-1 中的 GetComponent。这个函数让你可以访问附加到 GameObject 的匹配类型的第一个组件。一旦你获得了它的引用,你就可以像访问任何常规对象一样访问该组件,设置和获取其公共变量,并调用其方法:

01 using UnityEngine;
02 using System.Collections;
03 //-----------------------------------------------------
04 public class MyCustomComponent : MonoBehaviour
05 {
06   //Reference to transform of object
07   private Transform ThisTransform = null;
08 //-----------------------------------------------------
09   // Use this for initialization
10   void Start ()
11   {
12     //Get cached reference to transform
13     ThisTransform = GetComponent<Transform>();
14   }
15 //-----------------------------------------------------
16   // Update is called once per frame
17   void Update ()
18   {
19     //Update position

20     if(ThisTransform !=null) {ThisTransform.localPosition += Time.deltaTime * 10.0f * ThisTransform.forward;}

21   }
22 //-----------------------------------------------------
23 }
24 //-----------------------------------------------------

以下是对代码示例 3-1 的注释:

  • 第 07 行和第 13 行:变量 ThisTransform 被声明为私有。这个变量被分配了一个指向附加到 GameObject 的 Transform 组件的引用,并且它是在 Start 事件内部使用 GetComponent 函数实现的。在特定访问 Transform 组件的情况下,我们也可以使用继承的 transform 属性,例如 ThisTransform = transform;

  • 第 20 行:在这里,ThisTransform 变量被直接用来设置 GameObjectlocalPosition。同样,对于 Transform 组件来说,我们也可以使用 transform.localPosition。然而,这种方法在内部会调用额外的函数调用,因为成员 transform 是一个 C# 属性,而不是一个标准变量。更多关于属性的信息可以在第一章Unity C# 快速入门中找到。因此,在 StartAwake 事件中使用 GetComponent 来检索私有类变量的组件引用通常是访问外部组件最有效的方法之一,特别是如果组件必须定期访问,例如在 Update 函数中。

    注意

    localPosition 与 position 的区别

    Transform 组件公开了两个主要的位置成员:positionlocalPosition。设置这两个中的任何一个都会以特定和独特的方式改变对象的位置。位置成员始终定义对象在全局空间中的位置,作为从世界原点测量的值。因此,在脚本中设置此变量可能不会对应于当对象被选中时在对象检查器中看到的 Transform 组件的实际数字。例如,如果您的对象是另一个未定位到世界原点的对象的子对象,那么 Unity 会通过必要的任何方式偏移对象相对于父对象的位置,以便将其定位到指定的全局空间位置。相比之下,localPosition 成员直接对应于在对象检查器中显示的 Transform 组件的 position 值。具体来说,它指定了对象的位置,作为从其父位置或(如果对象没有父对象)从世界原点的测量偏移量。在后一种情况下,positionlocalPosition 成员将是相同的。

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

    您还可以通过 MonoDevelop 的 帮助 菜单访问 Unity 文档,方法是导航到 帮助 | Unity API 参考

获取多个组件

有时,您可能希望在一个列表中检索多个组件:有时是所有组件的列表,有时是仅匹配特定类型的组件的列表。您可以使用 GetComponents 函数来实现这一点。请参阅以下代码示例 3-2。与 GetComponent 函数一样,在一次性事件(如 StartAwake)期间调用 GetComponents 是一种良好的实践,而不是在频繁事件(如 Update)期间调用:

01 using UnityEngine;
02 using System.Collections;
03 //-----------------------------------------------------
04 public class MyCustomComponent : MonoBehaviour
05 {
06   //Reference to all components as array
07   private Component[] AllComponents = null;
08   //-----------------------------------------------------
09   // Use this for initialization
10   void Start ()
11   {
12     //Gets a list of all components attached to this object
13     AllComponents = GetComponents<Component>();
14
15     //Loops through each and list it to the console
16     foreach(Component C in AllComponents)
17     {
18       //Print to console
19       Debug.Log (C.ToString());
20     }
21   }
22 }
23 //-----------------------------------------------------

注意

关于组件的更多内容

Unity 提供了 GetComponentGetComponents 函数的额外变体,这些变体有助于实现对象间的通信,而不仅仅是同一对象内组件之间的通信。这些函数包括 GetComponentsInChildren,用于检索所有子对象中所有组件的累积列表,以及 GetComponentsInParent,用于检索对象父对象中的所有组件。

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

组件和消息

GetComponent 函数族运行良好,应该能满足您几乎所有的组件间通信需求。当适当使用时,它们的确比 SendMessageBroadcastMessage 表现得更好。然而,在某些情况下,如果给定一个 GameObject,您能够仅对 SendMessage 上的一个组件而不是所有组件调用方法,而不需要事先知道组件类型,这将是非常理想的。现在,您可以通过使用代理和接口(下一章将介绍)在一定程度上实现这种行为。然而,在这里,我们将考虑 SendMessage 方法。一个特别有用的场景是创建可扩展的行为。例如,也许您的游戏有许多敌人类型,您需要保留添加更多类型的可能性,所有这些类型都可以以不同的方式实现。尽管它们不同,但所有敌人都需要在游戏保存时将数据保存到持久文件中。那么,对于敌人来说,处理一个 OnSave 函数将非常有用,该函数将由特定的组件实现。这是可以的,但您希望仅由该组件上的 SendMessage 系统调用 OnSave 函数。您不希望调用对象上其他组件的方法,以防它们也处理一个您不希望意外调用的 OnSave 函数。简而言之,您可以使用 Invoke 方法实现这一点。考虑以下代码示例 3-3:

01 using UnityEngine;
02 using System.Collections;
03 //-----------------------------------------------------
04 public class MyCustomComponent : MonoBehaviour
05 {
06   //Reference to component on which function must be called
07   public MonoBehaviour Handler = null;
08
09   //-----------------------------------------------------
10   // Use this for initialization
11   void Start ()
12   {
13     //Call function immediately
14     Handler.Invoke("OnSave",0.0f);
15   }
16 }
17 //-----------------------------------------------------

以下是对代码示例 3-3 的注释:

  • 第 07 行: 此类具有一个公共引用变量 Handler。使用此字段,您可以通过对象检查器将任何组件拖放到 Handler 槽中。这表示将要向其发送消息的组件。请注意,其类类型为 MonoBehaviour 或任何从该类派生的类。这意味着实现了类型无关性,我们不需要事先知道对象类型。

  • 第 14 行: 调用 MonoBehaviourInvoke 方法来运行任何具有匹配名称的方法。第二个浮点数参数指定了在多少秒后调用该函数。0 秒表示立即调用。

    小贴士

    关于 Invoke 函数的更多信息可以在 Unity 在线文档中找到,链接为docs.unity3d.com/ScriptReference/MonoBehaviour.Invoke.html

游戏对象和世界

在 Unity 中,另一个关键任务是在脚本中搜索场景中的对象,尤其是如果对象在运行时实例化。例如,“获取玩家对象”和“获取场景中所有敌人”等任务对于许多操作都很重要,从复活敌人和增益到重新定位玩家和检查对象之间的碰撞。要检索特定 GameObjects 的引用,Unity 提供了一组与 GameObject 类相关的函数。这些函数可能很有用但成本高昂,因此请确保在可能的情况下,在 StartAwake 等一次性事件中调用它们。让我们进一步探讨这些内容,以及与其他技术和方法一起使用找到的对象的其他技术。

查找游戏对象

在场景中查找对象可以通过 GameObject.FindGameObject.FindObjectWithTag 函数实现。在这两个函数中,出于性能考虑,后者几乎总是首选。然而,让我们首先考虑 GameObject.Find。此函数在场景中搜索与名称完全匹配的第一个对象(区分大小写),然后返回该对象。搜索的名称应与 层次 面板中显示的对象名称相匹配。不幸的是,该函数执行字符串比较以确定匹配,因此它是一个缓慢且繁琐的选项。此外,它仅对保证具有唯一名称的对象真正有效,而很多时候对象并没有。然而,话虽如此,GameObject.Find 在对象名称适当的情况下仍然非常有用:

//Find Object with the name of player
ObjPlayer = GameObject.Find ("Player");

注意

游戏对象查找

如果你注意到 GameObject,你就会意识到 Find 函数是静态的。这意味着你不需要任何特定 GameObject 的实例化就可以调用该函数。你可以通过 GameObject.Find 直接从任何源文件中调用它。静态和全局作用域的概念将在本章的后面讨论。

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

小贴士

GameObject.Find 可能是一个较慢的函数。因此,仅在一触即发的事件中使用它,例如 AwakeStart

通过标签进行搜索更有效。场景中的每个对象都有一个默认分配为 Untagged 的标签成员。这个成员是一个唯一标识符,可以标记单个对象或多个对象,将它们组合成一个集合。通常,为了通过标签搜索对象,你首先需要显式地给对象分配一个标签。你可以在脚本中使用 GameObject.tag 公共成员来完成此操作。然而,你更常用 Unity 编辑器。你可以在 Unity 编辑器中通过点击对象检查器中的 Tag 下拉列表并选择一个标签来为选定的对象分配一个标签。此外,你可以通过选择 Add Tag 选项来创建新的、自定义的标签。常见的标签包括 PlayerEnemyWeaponBonusPropEnvironmentLightSoundGameController 等。请查看以下截图:

寻找 GameObjects

为对象分配标签

在场景中为一个或多个对象分配了标签之后,你可以在代码中有效地通过标签搜索对象。GameObject.FindGameObjectWithTag 函数在场景中搜索具有匹配标签的对象,并返回第一个匹配的对象。GameObject.FindObjectsWithTag 返回所有匹配的对象数组。请参见以下代码示例 3-4 以获取示例。请注意,尽管 FindGameObjectsWithTag 函数需要一个字符串参数,但 Unity 内部将字符串转换为数值形式,以提高标签比较的速度:

using UnityEngine;
using System.Collections;
//-----------------------------------------------------
public class ObjectFinder : MonoBehaviour
{
  //Tag name of objects to find
  public string TagName = "Enemy";
  //Array of found objects matching tag
  public GameObject[] FoundObjects;

  //-----------------------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Find objects of matching tag

    FoundObjects = GameObject.FindGameObjectsWithTag(TagName);

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

小贴士

有时候,你可能希望给单个对象分配多个标签。不幸的是,Unity 目前还不支持这种行为。然而,你可以通过将空游戏对象作为你的主要对象的父对象,并给每个子对象分配所需的标签来绕过这个限制。但是,当通过标签搜索对象时,请记住获取父对象的引用,实际上这个对象才是你需要的那一个。

对象比较

当你在场景中搜索特定对象时,GameObject 搜索函数非常有用,但有时你需要比较你已经找到的两个对象。通常,你想要比较两个对象的名称或标签。你可以使用 CompareTag 函数来实现标签比较:

//Compares tag of this object with another Obj_Y
bool bMatch = gameObject.CompareTag(Obj_Y.tag);

此外,你有时可能想要比较两个对象以确定它们是否是同一个对象,而不仅仅是它们是否具有相同的标签。这在编码决策行为时尤为重要。例如,在确定敌人角色在战斗中应该与玩家战斗还是逃跑时,了解敌人附近是否有支援单位帮助他就很有帮助。为了回答这个问题,你可以使用标签搜索找到场景中的所有敌人,正如我们之前看到的。然而,结果也将包括最初发出调用并现在正在决定做什么的敌人,因此我们希望将其排除在结果之外。代码示例 3-4 演示了GetInstanceID如何帮助我们:

01 //Find objects of matching tag
02 FoundObjects = GameObject.FindGameObjectsWithTag(TagName);
03
04 //Search through all objects and exclude ourselves
05 foreach(GameObject O in FoundObjects)
06 {
07   //If two objects are the same
08   if(O.GetInstanceID() == gameObject.GetInstanceID())
09     continue; //Skip this iteration
10
11   //[...] Do stuff here
12 }

获取最近的对象

给定一个GameObjects数组,可能是从搜索中返回的,你如何找到场景中离你最近的那个对象,从线性距离的角度来看?下面的代码示例 3-5 演示了如何使用Vector3.Distance函数来找到这个对象,该函数可以检索场景中任意两点之间的最短距离(以米为单位):

//Returns the nearest game object
GameObject GetNearestGameObject(GameObject Source, GameObject[] DestObjects)
{
  //Assign first object
  GameObject Nearest = DestObjects[0];

  //Shortest distance
  float ShortestDistance = Vector3.Distance(Source.transform.position, DestObjects[0].transform.position);

  //Loop through all objects
  foreach(GameObject Obj in DestObjects)
  {
    //Calculate distance
    float Distance = Vector3.Distance(Source.transform.position, Obj.transform.position);
    //If this is shortest, then update
    if(Distance < ShortestDistance)
    {
      //Is shortest, now update
      Nearest = Obj;
      ShortestDistance = Distance;
    }
  }

  //Return nearest
  return Nearest;
}

查找指定类型的任何对象

有时候,你可能只想获取场景中指定类型的所有组件列表,而不考虑它们实际附加到哪个游戏对象上;这些组件包括所有敌人、所有可收集对象、所有变换组件、所有碰撞体等等。从脚本中实现这一点很简单,但成本较高,如下面的代码示例 3-6 所示。具体来说,通过调用Object.FindObjectsOfType函数,你可以检索场景中指定对象的所有实例的完整列表,除非对象被禁用。由于这种方法成本较高,应避免在基于帧的事件,如Update期间调用它。使用StartAwake事件,以及不频繁调用的函数代替:

void Start()
{
  //Get a list of all colliders in the scene
  Collider[] Cols = Object.FindObjectsOfType<Collider>();
}

清除 GameObject 之间的路径

给定场景中的任意两个GameObjects,例如玩家和一个敌人角色,通常需要测试它们之间的清晰路径,即测试是否存在任何碰撞体与两个对象之间绘制的想象线相交。这有助于视线系统,正如我们稍后将要看到的,但也可以更普遍地用于对象剔除,以确定 AI 功能和其他功能。

清除 GameObject 之间的路径

使用 Physics.LineCast 测试两个 GameObject 之间的清晰路径

实现这种行为有许多方法。一种方法是使用Physics.LineCast函数,如下面的代码示例 3-7 所示:

01 using UnityEngine;
02 using System.Collections;
03 //Determines if a clear line or path exists between two objects
04 public class ObjectPath : MonoBehaviour
05 {
06   //Reference to sample enemy object
07   public GameObject Enemy = null;
08
09   //Layer mask to limit line detection
10   public LayerMask LM;
11   //----------------------------------------------------
12   // Update is called once per frame
13   void Update ()
14   {
15     //Check if clear path between objects

16     if(!Physics.Linecast(transform.position, Enemy.transform.position, LM))

17     {
18       //There is clear path
19       Debug.Log ("Path clear");
20     }
21   }
22   //----------------------------------------------------
23   //Show helper debug line in viewport
24   void OnDrawGizmos()
25   {
26     Gizmos.DrawLine(transform.position, Enemy.transform.position);
27   }
28   //----------------------------------------------------
29 }

以下是对代码示例 3-7 的注释:

  • 第 07 行:此示例类应附加到Player;否则,另一个源对象接受一个公共成员变量Enemy,该变量应测试是否有清晰路径。

  • 第 10 行LayerMask变量指定了一个位掩码,表示碰撞测试应用于场景中的哪些层。有关位掩码的更多信息可以在 Unity 在线文档中找到,链接为docs.unity3d.com/Manual/Layers.html

  • 第 16 行Physics.Linecast函数用于确定场景中两个对象之间是否存在清晰且不间断的路径。请注意,如果两个对象本身具有碰撞器,例如BoxColliders,则这些碰撞器将包含在碰撞检测中;它们不会被忽略。换句话说,一个对象的自身碰撞器可以影响任何LineCast调用的结果。因此,使用LayerMask变量来包含或排除特定的层。

    小贴士

    书籍配套文件中的Chapter03/LineCast文件夹包含了一个Physics.LineCast项目。

访问对象层次结构

Unity 中的层次结构面板提供了一个场景中所有GameObjects之间父子关系的图形化展示。这种关系很重要,因为子对象包含并继承其父对象的变换。然而,通常在编辑器中定义和编辑层次关系是不够的。你经常需要在代码中将一个对象作为父对象附加到另一个对象上,并遍历指定对象的全部子对象以处理数据或对其调用功能。让我们首先看看如何将对象设置为父对象。以下代码示例 3-8 展示了如何通过变换组件将一个对象 X 附加到另一个对象 Y 作为其子对象:

using UnityEngine;
using System.Collections;
//----------------------------------------------------
public class Parenter : MonoBehaviour
{
  //Reference to child object in scene
  private GameObject Child;
  //Reference to parent object in scene
  private GameObject Parent;
  //----------------------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Get parent and child objects
    Child = GameObject.Find("Child");
    Parent = GameObject.Find("Parent");

    //Now parent them
    Child.transform.parent = Parent.transform;
  }
  //----------------------------------------------------
}
//----------------------------------------------------

现在,让我们看看如何遍历附加到父对象的所有子对象。同样,这也是通过变换组件实现的,如下面的代码示例 3-9 所示:

using UnityEngine;
using System.Collections;
//------------------------------------------
public class CycleChildren : MonoBehaviour
{
  //------------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Cycle though children of this object
    for(int i=0; i<transform.childCount; i++)
    {
      //Print name of child to console
      Debug.Log (transform.GetChild(i).name);
    }
  }
  //------------------------------------------
}
//------------------------------------------

世界、时间和更新

Unity 场景表示在相同 3D 空间内的一组有限的GameObjects,并且它们也共享相同的时间框架。每个游戏都需要建立一个统一的时间概念,以实现同步的动画和变化,因为动画意味着随时间的变化。在 Unity 中,Time类可用于在脚本中读取和理解时间及其流逝。因此,使用这个类是创建可预测和一致运动的关键技能。关于这一点,我们稍后会详细讨论。

每个游戏都有一个帧率,这以每秒帧数(FPS)的形式定义。这个速率可以在游戏标签页的统计面板中查看。FPS 告诉你 Unity 在 1 秒内能够循环或迭代多少次来从相机到屏幕绘制新的渲染。每次迭代被称为。帧率随时间和不同计算机而显著变化。它受计算机性能、可能正在运行的其他进程以及当前帧需要渲染的内容等因素的影响。这意味着你永远不能依赖 FPS 随时间保持一致或在不同的计算机上相同;通常会有不同的 FPS。请查看以下截图:

世界、时间和更新

每秒帧数(FPS)对于创建基于时间的动作和动画非常重要

为了近似帧的概念,Unity 提供了三个类事件,每个MonoBehaviour类都可以实现这些事件来执行必须持续更新或随时间变化的函数。这些事件之前已经介绍过,但现在我们将更深入地探讨它们,特别是UpdateFixedUpdateLateUpdate

  • UpdateUpdate事件在每个场景中每个活动的GameObject上的每个活动组件上每帧调用一次。如果一个对象被MonoBehaviour.SetActive方法禁用,那么直到该对象被激活,该对象的Update事件将不会调用。简而言之,Update事件最准确地代表了 Unity 中帧的概念,因此它适用于执行重复的行为或功能,这些行为或功能需要随时间更新和监控,例如玩家输入事件、键盘按键和鼠标点击。请注意,Update事件在每一帧中所有组件的调用顺序是不确定的;也就是说,你无法确定在某一帧中对象 X 的Update函数是否会在对象 Y 的Update函数之前被调用。

  • FixedUpdate:与Update一样,这个事件通常每帧调用多次。然而,它的调用模式是规则和规范的,每次调用之间有固定的时间间隔。FixedUpdate最常见的使用是与 Unity 物理交互。如果你需要随时间更新 Rigidbody 组件的速度或属性,那么应该使用FixedUpdate而不是Update

  • LateUpdate:这个事件与Update一样,在每一帧都会被调用。然而,LateUpdate总是在UpdateFixedUpdate之后被调用。这意味着当LateUpdate被调用时,你可以确定UpdateFixedUpdate已经为当前帧上的每个对象调用过了。这使得LateUpdate成为更新相机移动的有用地方,特别是第三人称相机,确保相机始终跟随对象在当前帧上的最新位置。

UpdateFixedUpdateLateUpdate的细节,结合时间和 FPS 的概念,对你在创建运动时应该如何或不应如何编写游戏代码有重大影响。具体来说,出现了两条主要准则,这些准则将在接下来的两个小节中讨论。

规则#1 – 帧是宝贵的

帧应该每秒发生多次;如果不这样,你的游戏看起来会卡顿且不完整。在每一帧,都会为场景中的每个活动MonoBehaviour调用一次Update事件。这意味着每一帧场景的计算复杂度(和性能)在很大程度上取决于你在Update事件内部做了什么。更多的功能需求意味着更多的处理时间和工作量,无论是 CPU 还是 GPU。对于包含许多对象和组件的大场景,如果不通过仔细的代码规划来减少Update函数中的工作量,事情很容易失控。因此,将Update事件或任何定期调用的基于帧的事件视为宝贵的是非常重要的。简而言之,你应该只在真正需要时在它们内部放置代码,例如读取玩家输入或观察光标移动。将事件驱动编程作为思考起点是有帮助的,因为这可以帮助你严重减少插入到Update函数中的工作量。下一章将考虑事件驱动编程和事件系统。

规则#2 – 运动必须相对于时间

由于你无法保证帧的频率(帧率随时间和计算机而变化),因此你需要非常小心地编码运动和变化,以实现为玩家提供一致的游戏体验。考虑这样一个简单的情况:随着时间的推移,在场景中平滑地移动一个立方体对象。创建运动的一种方法(一种不好的方法)将如下所示,在以下代码示例 3-10 中:

using UnityEngine;
using System.Collections;

public class Mover : MonoBehaviour
{
  //Amount to move cube per frame
  public float AmountToMove = 1.0f;

  // Update is called once per frame
  void Update ()
  {
    //Move cube along x axis
    transform.localPosition += new Vector3(AmountToMove,0,0);
  }
}

此代码在将附加对象通过变量AmountToMove在每一帧移动方面是有效的。问题是它是依赖于帧率的。现在,由于帧在时间和计算机之间不一致,每个用户最终都会收到不同的体验;具体来说,他们会看到立方体以不同的速度移动。这是不好的,因为我们根本无法预测游戏对任何特定用户将如何运行。为了解决这个问题,我们需要将运动映射到时间而不是帧上。帧是可变的,但时间是恒定的;一秒就是那样。为了实现这一点,我们可以使用deltaTime变量,它是Time类的一部分。请参见以下代码示例 3-11。这是示例 3-10 的修改版。

using UnityEngine;
using System.Collections;

public class Mover : MonoBehaviour
{
  //Speed of cube
  public float Speed = 1.0f;

  // Update is called once per frame
  void Update ()
  {
    //Move cube along forward direction by speed
 transform.localPosition += transform.forward * Speed * Time.deltaTime;
  }
}

deltaTime变量是一个浮点值,总是表示自上次调用Update函数以来经过的时间(以秒为单位)。例如,值为 0.5 表示自上次帧以来已经过去了半秒,依此类推。这很有用,因为deltaTime可以作为乘数。通过在每一帧将速度变量乘以deltaTime,我们可以知道对象应该移动多远,因为距离 = 速度 x 时间。因此,deltaTime为我们提供了对象运动的帧率独立性。

不朽的对象

默认情况下,Unity 将每个对象视为存在于场景的自封闭时间和空间中。场景之间的差异就像不同宇宙之间的差异。因此,对象不会在它们所属的场景之外存活;这意味着每当活动场景改变时,它们就会死亡。这通常是您希望对象表现的方式,因为场景通常非常不同且相互独立。然而,即便如此,仍然会有一些您不希望销毁的对象。会有一些您需要在场景之间传递的对象,例如玩家角色、高分系统或GameManager类。这些通常是高级对象,它们的生存不应局限于特定的场景;它们应该跨越或弧形跨越多个场景。您可以使用DontDestroyOnLoad函数轻松创建对象持久性,但它有重要的后果值得考虑。请看以下代码示例 3-12:

using UnityEngine;
using System.Collections;
//-------------------------------------------
//This object will survive scene changes
public class PersistentObj : MonoBehaviour
{
  //-------------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Make this object survive
 DontDestroyOnLoad(gameObject);
  }
}
//-------------------------------------------

场景之间的对象持久性很重要,但移动的对象在场景之间移动时会带着它们的行李。这意味着任何所有子对象都将与持久对象一起存活,以及它使用的任何资产或资源,例如网格、纹理、声音等。这本身并不是问题,但重要的是要意识到这一点。因此,许多持久对象都是轻量级创建的,也就是说,作为没有子对象的空游戏对象,只包含它们正常工作所需的基本组件组成。这确保了只有必要的关键数据在场景变化之间存活。

注意

场景切换

要在 Unity 中更改活动场景,请使用Application.LoadLevel函数。这个函数有多种变体,包括LoadLevelAsyncLoadLevelAdditiveLoadLevelAdditiveAsync。有关级别加载函数的更多信息,可以在docs.unity3d.com/ScriptReference/Application.html在线找到。

如我们之前所见,DontDestroyOnLoad函数在活动场景中的现有对象上被调用,防止该对象在未来的场景变化中被销毁。然而,由此有时会出现关于对象复制的問題。具体来说,如果你后来重新加载或返回到持久对象最初存在的原始场景,则会创建该对象的持久副本,即从上一个场景带过来的持久原始对象和为场景的新实例创建的新对象实例。当然,每次重新进入场景都会放大这个问题,因为每次都会创建一个新的副本。这种复制通常不是你想要的。你通常希望在任何时候只有一个对象实例存在:一个玩家、一个游戏管理器或一个高分排行榜。为了实现这一点,你需要创建一个单例对象,正如下一节所解释的。

理解单例对象和静态

有些类在实例化方式上与其他类根本不同。大多数类定义了一个属性和行为集合的模板,这些属性和行为可能在场景中以GameObjects的形式实例化多次。敌对类可以用来实例化许多敌对对象,提升类用于许多提升对象,等等。然而,一些类如GameManagerHighScoreManagerAudioManagerSaveGameManager旨在作为一个独立实体存在,它整合了一套统一的行为。简而言之,在任何时候,该类只应有一个实例,而不应该有多个实例。拥有多个实例要么没有意义,要么以某种方式损害对象的权威和实用性。这类对象被称为单例。单例通常是跨场景持久存在的对象,尽管它们不必如此。单例(使其成为单例)的唯一必要成分是,在任何时候内存中不能有该类的多个实例。现在,让我们在创建示例GameManager类的上下文中创建一个单例对象。

实际上,每个游戏都有一个GameManagerGameController类;并且这些几乎总是持久存在的单例对象。GameManager基本上负责游戏中的所有高级功能。它必须确定游戏是否已暂停,是否满足胜利条件,以及有可靠的方式在任何时候了解游戏中的情况,等等。以下是对代码示例 3-13 中GameManager示例的初步了解:

using UnityEngine;
using System.Collections;
//-----------------------------------------
//Sample Game Manager class
public class GameManager : MonoBehaviour
{
  //-----------------------------------------
  //High score
  public int HighScore = 0;

  //Is game paused
  public bool IsPaused = false;

  //Is player input allowed
  public bool InputAllowed = true;
  //-----------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Make game manager persistent
    DontDestroyOnLoad(gameObject);
  }
  //-----------------------------------------
}
//-----------------------------------------

此对象将在场景之间持续存在,但如何使其(或任何类似类)成为一个单例对象呢?以下代码示例 3-14 演示了如何实现:

01 using UnityEngine;
02 using System.Collections;
03 //-----------------------------------------
04 //Sample Game Manager class - Singleton Object
05 public class GameManager : MonoBehaviour
06 {
07   //-----------------------------------------
08   //C# Property to get access to singleton instance
09   //Read only - only has get accessor
10   public static GameManager Instance
11   {
12     //return reference to private instance
13     get
14     {
15       return instance;
16     }
17   }
18
19   //-----------------------------------------
20   private static GameManager instance = null;
21   //-----------------------------------------
22   //High score
23   public int HighScore = 0;
24
25   //Is game paused
26   public bool IsPaused = false;
27
28   //Is player input allowed
29   public bool InputAllowed = true;
30   //-----------------------------------------
31   // Use this for initialization
32   void Awake ()
33   {
34     //Check if existing instance of class exists in scene
35     //If so, then destroy this instance
36     if(instance)
37     {
38       DestroyImmediate(gameObject);
39       return;
40     }
41
42     //Make this active and only instance
43     instance = this;
44
45     //Make game manager persistent
46     DontDestroyOnLoad(gameObject);
47   }
48   //-----------------------------------------
49 }
50 //-----------------------------------------

以下是对代码示例 3-14 的注释:

  • 第 10-20 行:在Manager类中添加了一个私有成员instance,该成员被声明为static。这意味着如果存在多个实例,变量将在所有类的实例之间共享,而不是每个实例都有其特定值的变量。这允许每个新实例在创建时确定内存中是否存在该类的现有实例。这个变量也通过Instance属性公开访问,该属性只有一个get成员,使其为只读。

  • 第 36-43 行:在这里,在对象创建时调用的Awake事件中,检查实例变量以查看当前场景中是否已经存在该类的有效实例。如果存在,则当前对象将被删除,因为只允许一个此类实例存在,并且已经存在。这意味着GameManager将跨场景持续存在,场景中始终只有一个原始对象实例。

注意

Awake 与 Start 的区别

在代码示例 3-12 中,GameManager类使用的是Awake函数,而不是StartStartAwake之间的区别如下:

Awake总是在Start之前被调用。

Awake总是在对象创建时被调用。StartGameObject变为活动状态的第一个帧上被调用。如果一个GameObject在场景开始时未激活,则Start将不会在对象激活之前被调用。对于默认激活的对象,Start在场景开始时被调用,在Awake事件之后。

如果你需要将组件引用缓存到类的局部变量中,例如ThisTransform中的 Transform 组件,那么请使用Awake事件而不是Start。在Start事件期间,应该假设所有对象的局部引用都已经有效。

GameManager拥有一个全局静态的Instance属性带来的巨大好处是,它能够立即且直接地被任何其他脚本文件访问,无需任何局部变量或对象引用。这意味着每个类都可以立即访问所有GameManager属性,并可以调用高级游戏功能。例如,要从不同的类设置GameManager上的游戏分数变量,可以使用以下代码示例 3-15:

using UnityEngine;
using System.Collections;
//-------------------------------------------
public class ScoreSetter : MonoBehaviour
{
  //-------------------------------------------
  // Use this for initialization
  void Start ()
  {
    //Set score on GameManager
    GameManager.Instance.HighScore = 100;
  }
  //-------------------------------------------
}
//-------------------------------------------

提示

更多关于单例对象的信息可以在网上找到,链接为unitypatterns.com/singletons/

摘要

本章讨论了GameObject、场景和组件,以及它们在场景中的通用用法。这些问题表面上可能看起来很简单,但理解它们的用法并能够使用它们来管理对象是一项强大的技能,这在几乎所有的 Unity 游戏开发项目中都是必需的。具体来说,我们看到了GameObject,它是一组相互作用的组件集合,以产生统一的行为。变换组件尤为重要。我们还探讨了场景。场景是在其中GameObject存在的单一时间和空间。通常,场景是一个自我封闭的实体,防止任何对象存在于其外部。此外,每个场景都通过一个时间概念来工作,这使得变化和动画成为可能。时间可以通过deltaTime来衡量,它像一个乘数,使我们能够实现帧率无关的运动。最后,我们探讨了单例设计模式,它使用静态成员来定义类,在实践中,这些类在任何时候都只能有一个活跃的实例存在于内存中。在下一章中,我们将继续探讨事件驱动编程。

第四章:事件驱动编程

MonoBehaviour 对象的 Update 事件似乎提供了一个方便的位置来执行应该定期执行、跨越多个帧和可能多个场景的代码。当创建需要持续一段时间的行为,例如敌人的人工智能或连续运动时,似乎没有其他替代方案,只能通过在 Update 函数中填充许多 ifswitch 语句,根据对象当前需要执行的操作将代码分支到不同的方向。但是,当以这种方式看待 Update 事件,将其视为实现长期行为的默认位置时,它可能会给更大、更复杂的游戏带来严重的性能问题。在深入分析后,不难看出为什么会这样。通常,游戏中充满了许多行为,在任何单个场景中同时发生的事情也很多,通过 Update 函数实现所有这些行为几乎是不切实际的。仅考虑敌人角色,它们需要知道玩家何时进入和离开它们的视线,当它们的健康值低时,当它们的弹药耗尽时,当它们站在有害地形上时,当它们受到伤害时,当它们在移动或停止移动时,以及更多。在最初思考这一系列行为时,似乎它们都需要持续和连续的关注,因为敌人应该始终能够立即知道这些属性的变化是由于玩家输入而发生的。这可能是 Update 函数在这些情况下似乎是最合适的位置的主要原因,但还有更好的替代方案,即事件驱动编程。通过将你的游戏和应用程序视为事件,你可以显著提高性能。因此,本章将考虑事件的问题以及如何在游戏中管理它们。

事件

游戏世界是完全确定性的系统;在 Unity 中,场景代表一个共享的 3D 笛卡尔空间和时间线,其中存在有限的GameObjects。只有当游戏逻辑和代码允许时,事情才会在这个空间内发生。例如,只有当某处有代码告诉它们这样做,并且在特定条件下,比如玩家按下键盘上的特定按钮时,物体才能移动。从示例中可以看出,行为不是简单的随机,而是相互关联的;物体只有在键盘事件发生时才会移动。在这些动作之间建立了一个重要的联系,其中一个动作导致另一个动作。这些联系或链接被称为事件;每个独特的连接都是一个单独的事件。事件不是主动的,而是被动的;它们代表机会的时刻,但本身并不代表行动,例如按键、鼠标点击、物体进入碰撞器体积、玩家被攻击等等。这些都是事件的例子,但它们都没有说明程序实际上应该做什么,而只是刚刚发生的场景类型。事件驱动编程从将事件作为一个一般概念来认识开始,并将游戏中的几乎每一种情况都视为事件的实例化;也就是说,作为一个在特定时间发生的事件,而不仅仅是一个事件概念。理解这类游戏事件是有帮助的,因为这样就可以将游戏中的所有动作视为对事件直接且及时的响应。具体来说,事件与响应相连接;事件发生并触发响应。进一步地,响应可以继续成为触发进一步响应的事件,依此类推。换句话说,游戏世界是一个完整、集成的由事件和响应组成的系统。一旦以这种方式看待世界,接下来就会产生一个问题,那就是如何通过仅仅依赖Update函数在每一帧上推进行为来提高性能。而方法就是通过找到减少事件发生频率的方法。现在,这样表述可能听起来像是一种粗略的策略,但它很重要。为了说明这一点,让我们考虑一个例子,即敌人在战斗中对玩家开火的场景。

在整个游戏过程中,敌人需要跟踪许多属性。首先,他们的健康,因为当它变低时,敌人应该寻找医疗包和辅助工具来恢复他们的健康。其次,他们的弹药,因为当它变低时,敌人应该寻求收集更多,而且敌人还需要对何时向玩家开火做出合理的判断,例如只有当有清晰的视线时。现在,仅仅通过思考这个场景,我们已经确定了可能被识别为事件的动作之间的某些联系。但在进一步考虑这一点之前,让我们看看我们如何可能使用Update函数来实现这种行为,如下面的代码示例 4-1 所示。然后,我们将看看事件如何帮助我们改进这种实现:

 // Update is called once per frame
 void Update () 
 {
    //Check enemy health
    //Are we dead?
    if(Health <= 0)
    {
          //Then perform die behaviour
          Die();
          return;
    }

    //Check for health low
    if(health <= 20)
    {
          //Health is low, so find first-aid
          RunAndFindHealthRestore();
          return;
    }

    //Check ammo

    //Have we run out of ammo?
    if(Ammo <= 0)
    {
          //Then find more
          SearchMore();
          return;
    }

    //Health and ammo are fine. Can we see player? If so, shoot
    if(HaveLineOfSight)
    {
            FireAtPlayer();
   }
 }

前面的代码示例 4-1 展示了充满大量条件检查和响应的Update函数。本质上,Update函数试图将事件处理和响应行为合并为一个,导致了一个不必要的昂贵过程。如果我们考虑这些不同过程(如健康和弹药检查)之间的事件连接,我们可以看到代码如何被更整洁地重构。例如,弹药只在两种情况下改变:当武器被发射或当收集到新的弹药时。同样,健康也只在两种情况下改变:当玩家成功攻击敌人或当敌人收集到急救包时。在前一种情况下,会有减少,而在后一种情况下,会有增加。

由于这些是属性变化的唯一时间(即事件),因此这些是它们值需要验证的唯一点。请参阅以下代码示例 4-2,其中重构了敌人,包括 C#属性和大大减少的Update函数:

 using UnityEngine;
 using System.Collections;

 public class EnemyObject : MonoBehaviour 
 {
    //-------------------------------------------------------
    //C# accessors for private variables
 public int Health
    {
          get{return _health;}
          set
          {
                //Clamp health between 0-100
               _health = Mathf.Clamp(value, 0, 100);

                //Check if dead
                if(_health <= 0)
                {
                      OnDead();
                      return;
                }

                //Check health and raise event if required
                if(_health <= 20)
                {
                      OnHealthLow();
                      return;
                }
          }
    }
    //-------------------------------------------------------
 public int Ammo
    {
          get{return _ammo;}
          set
          {
               //Clamp ammo between 0-50
              _ammo = Mathf.Clamp(value,0,50);
                //Check if ammo empty
                if(_ammo <= 0)
                {
                      //Call expired event
                      OnAmmoExpired();
                      return;
                }
          }
    }
    //-------------------------------------------------------
   //Internal variables for health and ammo
 private int _health = 100;
 private int _ammo = 50;
    //-------------------------------------------------------
    // Update is called once per frame
    void Update () 
    {
    }
    //-------------------------------------------------------
    //This event is called when health is low
   void OnHealthLow()
    {
          //Handle event response here
    }
    //-------------------------------------------------------
    //This event is called when enemy is dead
 void OnDead()
    {
        //Handle event response here
    }
    //-------------------------------------------------------
    //Ammo run out event
 void OnAmmoExpired()
    {
        //Handle event response here
    }
    //-------------------------------------------------------
 }

代码示例 4-2 中的敌人类已经被重构为事件驱动设计,其中属性如AmmoHealth的验证不是在Update函数内部,而是在赋值时进行。从这里,根据新分配的值,在适当的地方引发事件。通过采用事件驱动设计,我们引入了性能优化和代码整洁性;我们减少了与代码示例 4-1 中的Update函数中发现的过剩负担和价值检查,而只允许特定值的事件驱动我们的代码,因为我们知道它们只会在相关时间被调用。

事件管理

事件驱动编程可以使我们的生活变得更加简单。但是,当我们接受事件进入设计时,我们很快就会遇到一系列需要彻底解决的新问题。具体来说,我们在代码示例 4-2 中看到了如何使用 C#的属性来验证和检测相关变化,并在适当的时候引发事件(例如OnDead)。在原则上,这工作得很好,至少当敌人需要通知自己发生的事件时是这样。然而,如果敌人需要知道其他敌人的死亡,或者需要知道指定数量的其他敌人被击杀时怎么办?当然,考虑到这个具体案例,我们可以回到代码示例 4-2 中的敌人类,并对其进行修改,使其不仅为当前实例调用OnDead事件,而且为所有其他敌人调用,就像我们在前面的章节中看到的那样,使用SendMessage等函数。但这并没有真正解决我们的问题。事实上,让我们直接陈述理想情况;我们希望每个对象都能选择性地监听每种类型的事件,并在事件发生时得到通知,就像事件发生在它们身上一样容易。因此,我们现在面临的问题是如何编写一个优化的系统,以便轻松管理此类事件。简而言之,我们需要一个EventManager类,允许对象监听特定事件。这个系统依赖于以下三个核心概念:

  • EventListener:监听器指的是任何希望在事件发生时得到通知的对象,即使是其自己的事件。在实践中,几乎每个对象都将是至少一个事件的监听器。例如,一个敌人可能希望得到有关低血量和低弹药的通知等。在这种情况下,它至少是两个单独事件的监听器。因此,每当一个对象期望在事件发生时被告知,它就变成了一个监听器。

  • EventPoster:与监听器相反,当一个对象检测到事件发生时,它必须宣布或发布一个公共通知,允许所有其他监听器得到通知。在代码示例 4-2 中,敌人类使用属性检测AmmoHealth事件,并在需要时调用内部事件。但为了在这个意义上成为一个真正的发布者,我们需要对象在全局级别引发事件。

  • EventManager:最后,有一个贯穿所有级别的全局单例EventManager对象,它可以在不同级别之间持续存在。这个对象有效地将监听器与发布者联系起来。它接受发布者发送的事件通知,然后立即将通知以事件的形式分发给所有适当的监听器。

从接口开始事件管理

事件处理系统中的第一个或原始实体是监听者——当特定事件发生时,应该通知其关于这些事件的东西。潜在地,监听者可以是任何类型的对象或任何类型的类;它只是期望被通知关于特定事件。简而言之,监听者需要将自己注册到EventManager上,作为对一或多个特定事件的监听者。然后,当事件实际发生时,监听者应该通过函数调用直接被通知。因此,从技术上讲,监听者为EventManager提出了一个类型特定性问题,即如果监听者可能是任何类型的对象,那么管理者应该如何在监听者上调用事件。当然,这个问题可以通过我们看到的SendMessageBroadcastMessage来解决。确实,网上有免费的事件处理系统,例如依赖于这些函数的NotificationCenter。然而,在本章中,我们将避免使用它们,而是使用接口和多态,因为SendMessageBroadcastMessage都严重依赖于反射(反射的信息将在第八章中介绍,自定义 Unity 编辑器)。具体来说,我们将创建一个所有监听对象都将从中派生的接口。

提示

关于免费可用的NotificationCenter(C#版本)的更多信息,可以在 Unity 维基上找到:wiki.unity3d.com/index.php?title=CSharpNotificationCenter

在 C#中,接口就像一个空心的抽象基类。就像一个类一样,接口将一组方法和函数组合成一个类似于模板的单个单元。但是,与类不同,接口只允许你定义函数原型,例如函数的名称、返回类型和参数。它不允许你定义函数体。原因在于接口仅仅定义了派生类将拥有的全部函数集。派生类可以按需实现这些函数,而接口的存在只是为了使得其他对象可以通过多态调用这些函数,而不必知道每个派生类的具体类型。这使得接口成为创建Listener对象的合适候选者。通过定义一个所有对象都将从中派生的Listener接口,每个对象都有能力成为事件的监听者。

以下代码示例 4-3 演示了一个示例Listener接口:

01 using UnityEngine;
02 using System.Collections;
03 //-----------------------------------------------------------
04 //Enum defining all possible game events
05 //More events should be added to the list
06 public enum EVENT_TYPE {GAME_INIT, 
07                                 GAME_END,
08                                 AMMO_EMPTY,
09                                 HEALTH_CHANGE,
10                                 DEAD};
11 //-----------------------------------------------------------
12 //Listener interface to be implemented on Listener classes
13 public interface IListener
14 {
15 //Notification function invoked when events happen
16 void OnEvent(EVENT_TYPE Event_Type, Component Sender, Object Param = null);

17 }
18 //-----------------------------------------------------------

以下是对代码示例 4-3 的注释:

  • 行 06-10:此枚举应定义所有可能引发的游戏事件的完整列表。示例代码仅列出了五个游戏事件:GAME_INITGAME_ENDAMMO_EMPTYHEALTH_CHANGEDEAD。您的游戏可能还有更多。实际上,您不需要使用枚举来编码事件;您可以直接使用整数。但我使用枚举来提高代码中事件的可读性。

  • 行 13-17:使用 C#接口定义了Listener接口为IListener。它支持一个事件,即OnEvent。此函数将由所有派生类继承,并在发生注册监听器的事件时由管理者调用。请注意,OnEvent只是一个函数原型;它没有主体。

    提示

    msdn.microsoft.com/en-us/library/ms173156.aspx可以找到有关 C#接口的更多信息。

使用IListener接口,我们现在能够仅通过类继承从任何对象创建监听器;也就是说,任何对象现在都可以声明自己为监听器,并可能接收事件。例如,可以将新的MonoBehaviour组件通过以下代码示例 4-4 转换为监听器。此代码,如前几章所述,使用了多重继承,即从两个类继承。有关多重继承的更多信息,请参阅www.dotnetfunda.com/articles/show/1185/multiple-inheritance-in-csharp

using UnityEngine;
 using System.Collections;

 public class MyCustomListener : MonoBehaviour, IListener
 {
    // Use this for initialization
    void Start () {}
    // Update is called once per frame
    void Update () {}
    //---------------------------------------
    //Implement OnEvent function to receive Events 
 public void OnEvent(EVENT_TYPE Event_Type, Component Sender, Object Param = null)
    {
    }
    //---------------------------------------
 }

创建一个EventManager

如我们所见,任何对象现在都可以转换为监听器。但监听器仍然必须以某种方式向管理对象注册自己。因此,当事件实际发生时,调用监听器上的事件是管理者的责任。现在让我们转向管理者本身及其实现细节。该管理类将被命名为EventManager,如下面的代码示例 4-5 所示。作为一个持久化的单例对象,这个类应该附加到场景中的一个空GameObject上,这样它就可以通过静态实例属性直接被场景中的其他对象访问。关于这个类及其使用的更多信息将在后续注释中讨论:

001 using UnityEngine;
002 using System.Collections;
003 using System.Collections.Generic;
004 //-----------------------------------
005 //Singleton EventManager to send events to listeners
006 //Works with IListener implementations
007 public class EventManager : MonoBehaviour
008 {
009      #region C# properties
010 //-----------------------------------
011     //Public access to instance
012     public static EventManager Instance
013       {
014             get{return instance;}
015             set{}
016       }
017    #endregion
018 
019    #region variables
020       // Notifications Manager instance (singleton design pattern)
021    private static EventManager instance = null;
022 
023      //Array of listeners (all objects registered for events)
024      private Dictionary<EVENT_TYPE, List<IListener>> Listeners = new Dictionary<EVENT_TYPE, List<IListener>>();

025     #endregion
026 //-----------------------------------------------------------
027     #region methods
028      //Called at start-up to initialize
029     void Awake()
030     {
031             //If no instance exists, then assign this instance
032             if(instance == null)
033            {
034                   instance = this;
035                   DontDestroyOnLoad(gameObject); 
036            }
037             else
038                   DestroyImmediate(this);
039      }
040//-----------------------------------------------------------
041      /// <summary>
042      /// Function to add listener to array of listeners
043      /// </summary>
044      /// <param name="Event_Type">Event to Listen for</param>
045     /// <param name="Listener">Object to listen for event</param>
046     public void AddListener(EVENT_TYPE Event_Type, IListener Listener)
047     {
048            //List of listeners for this event
049            List<IListener> ListenList = null;
050 
051            // Check existing event type key. If exists, add to list
052            if(Listeners.TryGetValue(Event_Type, out ListenList))
053            {
054                   //List exists, so add new item
055                   ListenList.Add(Listener);
056                   return;
057            }
058 
059            //Otherwise create new list as dictionary key
060            ListenList = new List<IListener>();
061            ListenList.Add(Listener);
062            Listeners.Add(Event_Type, ListenList); 
063      }
064 //-----------------------------------------------------------
065       /// <summary>
066       /// Function to post event to listeners
067       /// </summary>
068       /// <param name="Event_Type">Event to invoke</param>
069       /// <param name="Sender">Object invoking event</param>
070       /// <param name="Param">Optional argument</param>
071       public void PostNotification(EVENT_TYPE Event_Type, Component Sender, Object Param = null)

072       {
073            //Notify all listeners of an event
074 
075            //List of listeners for this event only
076            List<IListener> ListenList = null;
077 
078            //If no event exists, then exit 
079            if(!Listeners.TryGetValue(Event_Type, out ListenList))
080                    return;
081 
082             //Entry exists. Now notify appropriate listeners
083             for(int i=0; i<ListenList.Count; i++)
084             {
085                   if(!ListenList[i].Equals(null)) 
086 
ListenList[i].OnEvent(Event_Type, Sender, Param);
087             }
088      }
089 //-----------------------------------------------------------
090      //Remove event from dictionary, including all listeners
091      public void RemoveEvent(EVENT_TYPE Event_Type)
092      {
093            //Remove entry from dictionary
094            Listeners.Remove(Event_Type);
095      }
096 //-----------------------------------------------------------
097       //Remove all redundant entries from the Dictionary
098      public void RemoveRedundancies()
099      {
100             //Create new dictionary
101             Dictionary<EVENT_TYPE, List<IListener>> TmpListeners = new Dictionary<EVENT_TYPE, List<IListener>>();

102 
103             //Cycle through all dictionary entries
104             foreach(KeyValuePair<EVENT_TYPE, List<IListener>> Item in Listeners)

105             {
106                   //Cycle all listeners, remove null objects
107                   for(int i = Item.Value.Count-1; i>=0; i--)
108                   {
109                         //If null, then remove item
110                         if(Item.Value[i].Equals(null))
111                                 Item.Value.RemoveAt(i);
112                   }
113 
114            //If items remain in list, then add to tmp dictionary
115                    if(Item.Value.Count > 0)
116                          TmpListeners.Add (Item.Key, Item.Value);
117             }
118 
119             //Replace listeners object with new dictionary
120             Listeners = TmpListeners;
121       }
122 //-----------------------------------------------------------
123       //Called on scene change. Clean up dictionary
124       void OnLevelWasLoaded()
125       {
126            RemoveRedundancies();
127       }
128 //-----------------------------------------------------------
129      #endregion
130 }

提示

关于OnLevelWasLoaded事件的更多信息,请参阅docs.unity3d.com/ScriptReference/MonoBehaviour.OnLevelWasLoaded.html

以下是对代码示例 4-5 的注释:

  • 行 003: 注意添加了 System.Collections.Generic 命名空间,这使我们能够访问额外的 mono 类,包括 Dictionary 类。这个类将在整个 EventManager 类中使用。关于 mono 及其类的更多信息将在第六章 Chapter 6,Working with Mono 中解释。简而言之,Dictionary 类是一种特殊的二维数组,允许我们根据键值对存储值数据库。关于 Dictionary 类的更多信息可以在 msdn.microsoft.com/en-us/library/xfhwa508%28v=vs.110%29.aspx 找到。

  • 行 007: EventManager 类是从 MonoBehaviour 派生的,并且应该附加到场景中的一个空 GameObject 上,在那里它将作为一个持久的单例存在。

  • 行 024: 使用 Dictionary 类声明了一个私有成员变量 Listeners。这种结构维护了一个键值对的哈希表数组,可以像数据库一样进行查找和搜索。EventManager 类的键值对形式为 EVENT_TYPEList<Component>。简而言之,这意味着可以存储一系列事件类型(如 HEALTH_CHANGE),对于每种类型,可能有零个、一个或多个组件正在监听,并且在事件发生时应该被通知。实际上,Listeners 成员是 EventManager 依赖的主要数据结构,用于维护谁在监听什么。有关 Mono 框架及其常见类的更详细信息,请参阅第六章 Chapter 6,Working with Mono

  • 行 029-039: Awake 函数负责单例功能,即使 EventManager 类成为一个跨场景持久存在的单例对象。有关持久单例的更多信息,请参阅第三章 Chapter 3,Singletons, Statics, GameObjects, and the World

  • 行 046-063: EventManagerAddListener 方法应由 Listener 对象为每个需要监听的事件调用一次。该方法接受两个参数:要监听的事件(Event_Type)和监听者对象本身的引用(从 IListener 派生),如果事件发生,则应通知该对象。AddListener 函数负责访问 Listeners 字典并生成一个新的键值对来存储事件与监听者之间的连接。

  • 行 071-088: PostNotification 函数可以被任何对象调用,无论是否为监听者,只要检测到事件即可。当被调用时,EventManager 会遍历字典中所有匹配的条目,寻找与当前事件连接的所有监听者,并通过 IListener 接口调用 OnEvent 方法来通知他们。

  • 第 098-127 行: EventManager 类的最终方法负责在场景变化时保持 Listeners 结构的数据完整性,并且当 EventManager 类持续存在时。尽管 EventManager 类在场景之间持续存在,但 Listeners 变量中的监听器对象可能不会持续存在。它们可能在场景变化时被销毁。如果是这样,场景变化将使一些监听器无效,留下 EventManager 中无效的条目。因此,调用 RemoveRedundancies 方法来查找并消除所有无效条目。Unity 在每次场景变化时都会自动调用 OnLevelWasLoaded 事件。

注意

字典

字典的伟大之处不仅在于它们作为动态数组的访问速度(这相对较快),还在于您通过对象类型和数组索引操作符与它们交互的方式。在典型的数组中,每个元素必须通过其数值和整数索引来访问,例如 MyArray[0]MyArray[1]。但与字典不同。具体来说,您可以使用代表键值对键部分的 EVENT_TYPE 对象来访问元素,例如 MyArray[EVENT_TYPE.HEALTH_CHANGE]。有关字典的更多信息,请参阅 Microsoft 官方文档msdn.microsoft.com/en-us/library/xfhwa508%28v=vs.110%29.aspx

使用 #region 和 #endregion 在 MonoDevelop 中进行代码折叠

两个预处理器指令 #region#endregion(与代码折叠功能结合使用)对于提高代码的可读性和提高导航源文件的速度非常有用。它们在不影响代码的有效性或执行的情况下为源代码添加组织和结构。实际上,#region 标记代码块的顶部,而 #endregion 标记其结束。一旦标记了一个区域,它就变得可折叠,也就是说,可以使用 MonoDevelop 代码编辑器将其折叠,前提是启用了代码折叠功能。折叠代码区域对于隐藏它以供查看非常有用,这允许您集中精力阅读与您的需求相关的其他区域,如下面的截图所示:

MonoDevelop 中的 #region 和 #endregion 代码折叠

在 MonoDevelop 中启用代码折叠

小贴士

要在 MonoDevelop 中启用代码折叠,从应用程序菜单中选择 工具 中的 选项。这会显示 选项 窗口。从这里,在 文本编辑器 选项中选择 通用 选项卡,并点击 启用代码折叠 以及 默认折叠 #region

使用事件管理器

现在,让我们从单个场景中的监听者和发布者的角度,看看如何在实际环境中使用EventManager类。首先,为了监听一个事件(任何事件),监听者必须将自己注册到EventManager单例实例中。通常,这将在最早的机会发生,比如Start函数。不要使用Awake函数;这个函数是为对象的内部初始化保留的,而不是指向当前对象之外的功能,比如其他对象的状态和设置。查看以下代码示例 4-6,注意它依赖于Instance静态属性来检索活动EventManager单例的引用:

//Called at start-up
void Start()
{
//Add myself as listener for health change events
EventManager.Instance.AddListener(EVENT_TYPE.HEALTH_CHANGE, this);
}

在注册了一个或多个事件的监听者之后,对象可以在检测到事件时向EventManager发布通知,如下面的代码示例 4-7 所示:

public int Health
{
get{return _health;}
set
{
   //Clamp health between 0-100
   _health = Mathf.Clamp(value, 0, 100);

   //Post notification - health has been changed EventManager.Instance.PostNotification(EVENT_TYPE.HEALTH_CHANGE, this, _health);
}
}

最后,在为事件发布通知之后,所有相关的监听者将通过EventManager自动更新。具体来说,EventManager将调用每个监听者的OnEvent函数,给监听者提供解析事件数据和响应所需的机会,如下面的代码示例 4-8 所示:

//Called when events happen
public void OnEvent(EVENT_TYPE Event_Type, Component Sender, object Param = null)
{
//Detect event type
switch(Event_Type)
{
    case EVENT_TYPE.HEALTH_CHANGE:
         OnHealthChange(Sender, (int)Param);
    break;
}
}

小贴士

关于使用EventManager的演示,请参阅本章代码包中的events文件夹项目。

使用委托的替代方案

接口是实现事件处理系统的有效且简洁的方式,但并非唯一的方式。我们还可以使用 C#的一个特性,称为委托。本质上,我们可以在变量中创建一个函数并存储对其的引用。这个变量允许你将函数视为引用类型变量。也就是说,使用委托,你可以存储函数的引用,稍后可以用来调用该函数本身。其他语言,如 C++,通过函数指针提供类似的行为。通过使用委托实现事件系统,我们消除了接口的需求。考虑以下代码示例 4-7,这是使用委托的EventManager的替代实现。相关的代码更改被突出显示,以帮助说明接口和委托实现之间的差异。除了对委托类型的微小更改外,所有其他函数保持不变,如下所示:

001 using UnityEngine;
002 using System.Collections;
003 using System.Collections.Generic;
004 //-----------------------------------------------------------
005 //Enum defining all possible game events
006 //More events should be added to the list
007 public enum EVENT_TYPE {GAME_INIT, 
008       GAME_END,
009       AMMO_CHANGE,
010        HEALTH_CHANGE,
011        DEAD};
012 //-----------------------------------------------------------
013 //Singleton EventManager to send events to listeners
014 //Works with delegate implementations
015 public class EventManager : MonoBehaviour
016 {
017       #region C# properties
018 //-----------------------------------------------------------
019      //Public access to instance
020      public static EventManager Instance
021      {
022             get{return instance;}
023             set{}
024      }
025      #endregion
026 
027      #region variables
028      //Notifications Manager instance (singleton design pattern)
029      private static EventManager instance = null;
030 
031      // Declare a delegate type for events
032      public delegate void OnEvent(EVENT_TYPE Event_Type, Component Sender, object Param = null);

033 
034       //Array of listener objects
035       private Dictionary<EVENT_TYPE, List<OnEvent>> Listeners = new Dictionary<EVENT_TYPE, List<OnEvent>>();

036       #endregion
037 //-----------------------------------------------------------
038      #region methods
039      //Called at start-up to initialize
040      void Awake()
041      {
042            //If no instance exists, then assign this instance
043           if(instance == null)
044            {
045                   instance = this;
046                   DontDestroyOnLoad(gameObject); 
047            }
048            else
049                   DestroyImmediate(this);
050      }
051 //-----------------------------------------------------------
052      /// <summary>
053      /// Add listener-object to array of listeners
054      /// </summary>
055      /// <param name="Event_Type">Event to Listen for</param>
056      /// <param name="Listener">Object to listen for event</param>
057     public void AddListener(EVENT_TYPE Event_Type, OnEvent Listener)

058     {
059             //List of listeners for this event
060             List<OnEvent> ListenList = null;
061 
062            // Check existing event. If one exists, add to list
063           if(Listeners.TryGetValue(Event_Type, out ListenList))
064            {
065                   //List exists, so add new item
066                   ListenList.Add(Listener);
067                   return;
068             }
069 
070            //Otherwise create new list as dictionary key
071            ListenList = new List<OnEvent>();
072            ListenList.Add(Listener);
073            Listeners.Add(Event_Type, ListenList); 
074      }
075 //-----------------------------------------------------------
076       /// <summary>
077       /// Function to post event to listeners
078       /// </summary>
079       /// <param name="Event_Type">Event to invoke</param>
080       /// <param name="Sender">Object invoking event</param>
081       /// <param name="Param">Optional argument</param>
082       public void PostNotification(EVENT_TYPE Event_Type, Component Sender, object Param = null)

083       {
084             //Notify all listeners of an event
085 
086             //List of listeners for this event only
087             List<OnEvent> ListenList = null;
088 
089             //If no entry exists, then exit 
090            if(!Listeners.TryGetValue(Event_Type, out ListenList))
091                   return;
092 
093            //Entry exists. Now notify appropriate listeners
094            for(int i=0; i<ListenList.Count; i++)
095             {
096                 if(!ListenList[i].Equals(null)) 
097                         ListenListi;
098            }
099      }
100 //-----------------------------------------------------------
101       //Remove event from dictionary, including all listeners
102       public void RemoveEvent(EVENT_TYPE Event_Type)
103       {
104            //Remove entry from dictionary
105            Listeners.Remove(Event_Type);
106       }
107 //-----------------------------------------------------------
108       //Remove all redundant entries from the Dictionary
109       public void RemoveRedundancies()
110       {
111             //Create new dictionary
112             Dictionary<EVENT_TYPE, List<OnEvent>> TmpListeners = new Dictionary<EVENT_TYPE, List<OnEvent>>();

113 
114            //Cycle through all dictionary entries
115            foreach(KeyValuePair<EVENT_TYPE, List<OnEvent>> Item in Listeners)

116            {
117                   //Cycle through all listeners
118                   for(int i = Item.Value.Count-1; i>=0; i--)
119                   {
120                        //If null, then remove item
121                       if(Item.Value[i].Equals(null))
122                            Item.Value.RemoveAt(i);
123                   }
124 
125                  //If items remain, then add to tmp dictionary
126                  if(Item.Value.Count > 0)
127                       TmpListeners.Add (Item.Key, Item.Value);
128            }
129 
130            //Replace listeners with new dictionary
131            Listeners = TmpListeners;
132      }
133 //-----------------------------------------------------------
134      //Called on scene change. Clean up dictionary
135      void OnLevelWasLoaded()
136      {
137              RemoveRedundancies();
138      }
139 //-----------------------------------------------------------
140       #endregion
141 }

小贴士

关于 C#委托的更多信息,可以在微软文档中找到,网址为msdn.microsoft.com/en-gb/library/aa288459%28v=vs.71%29.aspx

以下是对代码示例 4-7 的注释:

  • 行 005-011:在这里,事件类型枚举已经从原始的IListener类移动到了EventManager文件中。由于委托实现避免了接口的需求,特别是对于IListener,枚举可以被移动到管理源文件中。

  • 第 032 行:公共成员 OnEvent 被声明为代表类型。注意,声明是混合的,因为它结合了变量声明风格和函数原型。这指定了可以分配给代表变量的函数原型;任何具有该结构的函数都可以从任何类或任何脚本文件中分配。因此,OnEvent 函数成为了一个代表类型,并在创建内部字典的下一条语句中使用。

  • 第 035 行:声明了私有字典监听器,并且对于每种事件类型,存储了一个代表(而不是接口)的数组;每个代表指向一个在事件发生时应被调用的函数。

  • 第 097 行:关键的是,在 EventManager 上调用 PostNotification 函数来在事件发生时调用所有代表(监听函数)。这发生在第 097 行的语句 ListenListi; 中。这就像调用函数一样调用代表,如下面的截图所示:使用代表的替代方案

    探索 EventManager 项目

前面的截图显示了 EventManager 项目。

小贴士

要查看 EventManager 代表实现的实际应用,请参阅本章代码包中的 events_delgateversion 文件夹项目。

MonoBehaviour 事件

为了结束这一章,让我们考虑一些 Unity 已经为我们提供的用于事件驱动编程的事件。MonoBehaviour 类已经公开了一系列在特定条件下自动调用的事件。这些函数或事件以 On 前缀开始,包括 OnGUIOnMouseEnterOnMouseDownOnParticleCollision 等事件。本节考虑了一些常见事件类型的细节。

小贴士

MonoBehaviour 事件的完整列表可以在 Unity 文档中找到,链接为 docs.unity3d.com/ScriptReference/MonoBehaviour.html

鼠标和触摸事件

一组有用的事件是鼠标输入和触摸输入事件集。这些包括OnMouseDownOnMouseEnterOnMouseExit。在 Unity 的早期版本中,这些事件仅针对鼠标特定事件触发,而不是触摸输入。但最近,触摸输入已经映射到它们;这意味着点击现在默认注册为鼠标事件。为了澄清,OnMouseDown在鼠标按钮按下且光标悬停在对象上时调用一次。然而,事件不会在按钮释放之前重复调用。同样,OnMouseEnter在光标第一次悬停在对象上且未退出时调用一次,而OnMouseExit在光标从之前进入的对象上移开时调用。这些事件的成功取决于对象是否附加了碰撞器组件,以近似其体积,其中检测鼠标事件。这意味着如果没有对象附加了碰撞器,则不会触发任何鼠标事件。

然而,有时即使附加了碰撞器,MouseEvents也不会触发,因为其他对象(具有碰撞器)会遮挡你根据当前视图从活动相机需要点击的对象。也就是说,可点击的对象在背景中。当然,你可以通过简单地将前景对象分配到IgnoreRaycast层来解决这个问题(至少在许多情况下),使它们免受物理射线投射操作的影响。

要将对象分配到IgnoreRaycast层,只需在场景中选择对象,然后点击对象检查器中的下拉菜单,将对象分配到忽略射线投射层,如下面的截图所示:

鼠标和点击事件

将对象分配到忽略射线投射层

但有时这并不可行。通常你需要多个相机和许多具有碰撞器的对象,它们有时会遮挡你想要选择或根据鼠标输入事件调整的对象。在这些情况下,你可能需要手动处理鼠标输入事件。以下代码示例 4-8 实现了这些功能,根据输入手动调用特定的鼠标事件。本质上,此代码使用Raycast系统将手动检测到的输入事件重定向到MonoBehaviour鼠标事件。此代码还使用了协程;在代码示例之后考虑:

using UnityEngine;
 using System.Collections;
 //---------------------
 public class ManualMouse : MonoBehaviour
 {
    //---------------------
    //Get collider attached to this object
    private Collider Col = null;
    //---------------------
    //Awake function - called at start up
    void Awake()
    {
         //Get collider
         Col = GetComponent<Collider>();
    }
    //---------------------
    //Start Coroutine
    void Start()
    {
          StartCoroutine(UpdateMouse());
   }
   //---------------------
   public IEnumerator UpdateMouse()
   {
         //Are we being intersected
         bool bIntersected = false;

         //Is button down or up
         bool bButtonDown = false;

         //Loop forever
         while(true)
         {
         //Get mouse screen position in terms of X and Y
         //You may need to use a different camera Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
         RaycastHit hit;

               //Test ray for collision against this collider
               if (Col.Raycast(ray, out hit, Mathf.Infinity))
               {
   //Object was interesected  if(!bIntersected) SendMessage("OnMouseEnter", SendMessageOptions.DontRequireReceiver);

                       bIntersected = true;

               //Test for mouse events
               if(!bButtonDown && Input.GetMouseButton(0))
{
bButtonDown = true; SendMessage("OnMouseDown", SendMessageOptions.DontRequireReceiver);
}

                if(bButtonDown && !Input.GetMouseButton(0))
{
bButtonDown = false; SendMessage("OnMouseUp", SendMessageOptions.DontRequireReceiver);
}
                }
                else
                {
                      //Was previously entered and now leaving
                      if(bIntersected) SendMessage("OnMouseExit", SendMessageOptions.DontRequireReceiver);

                      bIntersected = false;
                      bButtonDown = false;
                }

                //Wait until next frame
                yield return null;
           }
    }
    //---------------------
 }
 //---------------------

注意

协程

协程是一种特殊类型的函数。它们的行为类似于线程,因为它们似乎与主游戏循环并行或异步运行,也就是说,一旦执行它们,它们似乎在后台运行。执行不会暂停或等待函数完成,就像传统函数那样。这使得协程非常适合创建看起来异步的行为。技术上,所有协程都必须返回 IEnumerator 类型,其体中至少包含一个 yield 语句,并且必须使用 StartCoroutine 函数启动。yield 语句是一个特殊语句,它将协程的执行挂起,直到满足其条件。语句 yield return new WaitForSeconds(x) 将暂停执行 x 秒,在间隔后从下一行恢复。相比之下,语句 yield return null 将暂停当前帧的执行,在下一帧的下一行恢复执行。有关协程及其使用的更多信息,请参阅 Unity 文档中的 docs.unity3d.com/Manual/Coroutines.html

应用焦点和暂停

三种额外的 MonoBehaviour 事件因其操作而引起混淆或惊讶。它们是:OnApplicationPauseOnApplicationFocusOnApplicationQuit

OnApplicationQuit 事件在游戏退出之前发送到场景中的所有对象,但在场景及其内容被有效销毁之前。如果在编辑器中测试游戏,则在播放停止时调用 OnApplicationQuit。然而,值得注意的是,对于 iOS 设备,OnApplicationQuit 可能不会被调用,因为 iOS 设备通常不会退出或退出应用程序,而是将它们挂起,以便用户做其他事情,这允许他们返回并从离开的地方继续。如果您需要在挂起时接收 OnApplicationQuit 事件,您需要从 Player Settings 窗口中启用相关选项。要访问此选项,从应用程序菜单导航到 Edit | Project Settings | Player,然后从对象检查器中展开 Other Settings 选项卡,为 iOS 构建启用 Exit on Suspend 复选框,如图所示:

应用焦点和暂停

启用 iOS 的挂起时退出选项

OnApplicationFocus 是在游戏失去焦点时发送到场景中所有对象的事件,通常在桌面计算机上的多任务操作中游戏窗口被停用时。这可以是一个重要的游戏事件,尤其是在多人游戏中,即使一个或多个玩家没有积极参与,共享世界中的动作和事件也会继续。在这些情况下,您可能需要暂停或恢复特定的行为或淡入或淡出游戏音乐。

OnApplicationPause是一个模糊的事件,因为 Unity 中暂停的概念并没有明确定义。我相信,存在两种不同的暂停类型,即最终暂停和相对暂停。最终暂停是指游戏中每个活动和每个事件都被完全暂停;在这种状态下,时间不会流逝,什么都不能前进。相对暂停则相反,是最常见的一种。在这种情况下,游戏是自我意识或意识到处于暂停状态的;它暂停了一些事件,例如世界内的事件,但允许其他事件继续,例如 GUI 交互和用户输入,这可以取消暂停游戏。OnApplicationPause事件指的是第一种暂停,而不是后者。当满足几个条件时,此事件将被调用。这些条件将在下一节中讨论。

首先,如果“在后台运行”选项在“玩家设置”标签页下的“分辨率”组中未启用,则OnApplicationPause仅在桌面版上被调用。此选项禁用时,桌面游戏将在窗口失去焦点时自动暂停。这意味着OnApplicationPause将遵循OnApplicationFocus事件。

应用程序焦点和暂停

禁用“在后台运行”选项

在 iOS 中,每当应用被最小化或推入后台时,都会调用OnApplicationPause

小贴士

不要依赖于OnApplicationPause事件来创建自己的相对暂停功能。为了实现这一点,请使用Time.timeScale变量或编写一个更全面的系统,在该系统中您可以有选择地控制哪些元素被暂停。

摘要

本章重点介绍了通过一致地通过EventManager类采用事件驱动框架,为您的应用程序提供的多种好处。在实现此类管理器时,我们能够依赖于接口或委托,并且两种方法都很强大且可扩展。具体来说,我们看到了如何轻松地将更多功能添加到Update函数中,但这样做可能会导致严重的性能问题。更好的做法是分析功能之间的联系,将其重构为事件驱动框架。本质上,事件是事件驱动系统的原材料。它们代表了一个动作(原因)与另一个动作(反应)之间的必要联系。为了管理事件,我们创建了EventManager类——一个将发布者与听众链接的集成类或系统。它接收发布者关于事件的即时通知,然后立即向所有听众发出事件函数调用。在下一章中,我们将检查相机和渲染。

第五章. 相机、渲染和场景

本章重点介绍你可以使用相机、渲染和场景以及它们之间有趣的组合做的一些事情。一般来说,相机是一个视点,从该视点渲染场景。它是一个 3D 空间中的点,从该点以特定的视角和视场捕捉场景视图,并将其光栅化成像素形式的纹理。然后,它通过与其他任何相机的渲染混合和合成渲染到屏幕上。因此,相机、渲染和场景是紧密相连的过程。在本章中,我们将了解如何动画化相机和构建飞行动画,如何沿着曲线路径移动相机,以及如何了解对象是否被看到以及何时被特定相机看到。此外,我们还将了解如何手动编辑和处理相机渲染以创建后处理效果,以及如何配置正交相机以渲染用于 2D 游戏和图形用户界面的像素完美的 2D 纹理。那么,让我们开始吧。

相机 Gizmo

当在场景选项卡中选择相机并且启用** Gizmo**显示时,它显示一个截锥体 Gizmo,清楚地指示相机在场景中的位置以及从该视角可以看到的内容,考虑到其其他属性,如视场,如下截图所示:

相机 Gizmo

在场景视图中选择相机时,相机显示截锥体

这个 Gizmo 特别有助于定位选定的相机以获得场景的最佳视图。然而,有时你想要实现几乎相反的效果,即定位未选中的相机视图中的对象。具体来说,你希望将特定对象移动到相机的截锥体内,并确保它对该相机可见。在正常情况下,这可能会很繁琐,因为默认情况下,相机在未选中时不显示其截锥体 Gizmo。这意味着当你移动对象时,你需要不断地选择和重新选择你的相机来检查移动的对象是否真的在相机截锥体内,并在必要时调整和微调它们的位置。为了解决这个问题,如果 Unity 允许你永久查看截锥体 Gizmo,即使相机被选中,那将非常棒,但至少在撰写本书时,它并没有这样做。然而,为了解决这个问题,你可以编写一个脚本,如下面的代码示例 5-1 所示:

01 using UnityEngine;
02 using System.Collections;
03 //-------------------------------------------------------
04 [ExecuteInEditMode]
05 [RequireComponent(typeof(Camera))]
06 //-------------------------------------------------------
07 public class DrawFrustumRefined : MonoBehaviour 
08 {
09 //-------------------------------------------------------
10 private Camera Cam = null;
11 public bool ShowCamGizmo = true;
12 //-------------------------------------------------------
13 void Awake()
14 {
15       Cam = GetComponent<Camera>();
16 }
17 //-------------------------------------------------------
18 void OnDrawGizmos()
19 {
20       //Should we show gizmo?
21       if(!ShowCamGizmo) return;
22       //Get size (dimensions) of Game Tab
23       Vector2 v = DrawFrustumRefined.GetGameViewSize();
24       float GameAspect = v.x/v.y; //Calculate tab aspect ratio
25       float FinalAspect = GameAspect / Cam.aspect; 
26 
27       Matrix4x4 LocalToWorld = transform.localToWorldMatrix;
28       Matrix4x4 ScaleMatrix = Matrix4x4.Scale(new Vector3(Cam.aspect * (Cam.rect.width / Cam.rect.height), FinalAspect,1)); 

29       Gizmos.matrix = LocalToWorld * ScaleMatrix;
30       Gizmos.DrawFrustum(transform.position, Cam.fieldOfView, Cam.nearClipPlane, Cam.farClipPlane, FinalAspect); 

31       Gizmos.matrix = Matrix4x4.identity; //Reset gizmo matrix
32 }
33 //-------------------------------------------------------
34 //Function to get dimensions of game tab
35 public static Vector2 GetGameViewSize()
36 {
37       System.Type T = System.Type.GetType("UnityEditor.GameView,UnityEditor");
38        System.Reflection.MethodInfo GetSizeOfMainGameView = T.GetMethod("GetSizeOfMainGameView",System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

39        return (Vector2)GetSizeOfMainGameView.Invoke(null,null);
40 }
41 //-------------------------------------------------------
42 }
43 //-------------------------------------------------------

以下是在代码示例 5-1 中的注释:

  • 第 27-31 行Gizmos.DrawFrustum 函数接受诸如位置和旋转等参数,这些参数是在世界空间中而不是局部空间中。这意味着所有位置参数必须首先使用从局部空间到世界空间的矩阵进行转换。这是通过 Transform 类的 localToWorldMatrix 成员实现的。此外,宽高比参数还需要在实际视口的高度和宽度以及游戏窗口的宽度和高度之间进行进一步计算。

  • 第 35-40 行GetGameViewSize 函数返回一个二维向量,表示“游戏”标签视图的实际像素尺寸。它通过未记录的编辑器功能检索这些值。应强调函数调用的“未记录”性质;这意味着代码很容易被未来的版本甚至小版本更新所破坏或失效。

以下截图显示了棱台:

相机工具

即使相机未被选中,也会显示棱台

被看到

在游戏过程中,经常会出现关于对象可见性的问题,有些是实际的,有些是假设性的。关于实际场合,我们可以提出几个问题,包括对象 X 是否现在对相机 Y 可见,对象 X 是否现在对任何相机可见,或者对象 X 何时对特定相机或任何相机变得可见或不可见。至于假设性,我们会问如果将相机移动到位置 Z,对象 X 是否会可见。在实际场合的情况下,我们关注的是基于所有相机的位置,当前帧中对象的实际可见性,而对于假设性,我们关注的是如果将相机移动到特定位置会发生什么。这两种情况对游戏都很重要。知道对象(如敌人角色)是否真正对相机可见对于定义行为和 AI 很重要。这是因为当对象不可见时,我们可以暂停许多行为和计算以节省处理工作量。此外,知道如果移动相机对象是否会变得可见是有帮助的,因为它让我们能够预测哪些对象(如果有的话)将在下一帧进入可见范围,这样我们就可以提前做好准备。现在,在考虑如何在脚本中回答这些问题之前,值得考虑的是可见性的最狭义含义。

在可见性的方面,有两个主要概念:视锥体和遮挡。每个透视相机都有一个视锥体,正如我们之前所看到的;这个视锥体是从相机镜头向外延伸的梯形体积,它包含一个由视野和裁剪平面距离属性定义的区域。从本质上讲,视锥体在数学上定义了相机的视野——场景中相机现在可以潜在观察到的区域。这个词“潜在”很重要,因为即使一个活跃且可见的对象位于相机视锥体内,也不一定意味着它对相机是可见的。这是因为视锥体内的对象可以遮挡视锥体内的其他对象;也就是说,较近的对象可以完全或部分地遮挡或隐藏它们后面的对象。因此,真正的可见性测试至少涉及两个过程:首先,确定对象是否在视锥体内,其次,确定它是否被遮挡。只有当一个对象通过这两个测试时,才能将其归类为对相机可见,即使在那时,也只是在假设对象没有被自定义着色器或其他后处理效果隐藏或渲染为不可见的情况下。简而言之,有许多原因说明真正的可见性测试是一个复杂的过程,但在这里,我会将两阶段测试视为大多数目的足够好了。

检测对象的可视性

也许,在 Unity 中对对象进行的最简单、最直接的可见性测试就是确定对象何时对任何相机变得可见或不可见。两个伴随事件,OnBecameVisibleOnBecameInvisible,会在任何具有渲染器组件的对象上自动调用,包括MeshRendererSkinnedMeshRenderer。当然,即使这些空游戏对象位于相机的视野内,也不会调用它们,因为(从技术上讲)它们不包含任何可见的部分,尽管所有部分都在空间上有位置。你可以像以下代码示例 5-2 中所示那样处理这些事件:

 //----------------------------------------------
 using UnityEngine;
 using System.Collections;
 //----------------------------------------------
 public class ViewTester : MonoBehaviour 
 {
    //----------------------------------------------
   void OnBecameVisible()
    {
          Debug.Log ("Became Visible");
    }
    /----------------------------------------------
    void OnBecameInvisible()
    {
          Debug.Log ("Became Invisible");
    }
    //----------------------------------------------
 }
 //----------------------------------------------

在事件 OnBecameVisibleOnBecameInvisible 中,有几个重要的注意事项值得注意。首先,这里的可见性仅指一个对象已经进入摄像机的视锥体内;因此,它仍然可能被其他更近的对象遮挡,所以它可能根本不可见。其次,这些事件适用于所有摄像机,而不是特定摄像机。OnBecameVisible 只调用一次,告诉你对象之前不可见,现在已进入至少一个摄像机的视锥体。同样,OnBecameInvisible 也只调用一次,告诉你对象之前可见,现在已离开所有摄像机的视锥体。最后,而且相当不实用,这些函数还包括场景摄像机的可见性。这意味着如果你在打开并可见的 场景 选项卡中测试你的游戏,并且对象在 场景 选项卡中对你可见,这将算作可见。简而言之,OnBecameVisibleOnBecameInvisible 方法只有在你的行为依赖于场景中的总可见性或不可见性时才有用,其中可见性仅对应于视锥体的存在。换句话说,这些事件是切换依赖于可见性的行为的好地方,例如,AI 行为,例如 NPC 惊慌行为和其他类型的 NPC 之间的交互。

小贴士

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

关于对象可见性的更多内容

除了测试对象何时进入和离开摄像机可见性之外,另一个重要的检查是测试对象是否现在对特定摄像机可见。与在对象进入或离开视锥体时一次性调用的 OnBecameVisibleOnBecameInvisible 不同,这种测试是关于对象当前状态的一种假设,没有关于它的先验知识。为了实现这一点,可以使用 OnWillRenderObject 事件。只要对象对该摄像机可见,这个事件就会在每个帧上连续调用一次,每次调用都是针对它可见的每个摄像机。这里的“可见”是指“在摄像机视锥体内”。同样,不应用遮挡测试。参考以下代码示例 5-3,并注意在这个事件内部,可以使用 Camera.current 成员来获取当前对象可见的摄像机的引用,包括场景视图摄像机:

   void OnWillRenderObject()
   {
        Debug.Log (Camera.current.name);
   }

视锥体测试 – 渲染器

有很多时候,Unity 本地相机事件,正如我们之前所看到的,不足以满足你的可见性和视锥测试需求。具体来说,你可能只想测试是否只有一个特定的相机可以看到渲染器,如果它是可见的,一个不可见对象是否会被看到,空间中指定的一点是否被相机看到,或者如果将相机移动到新位置,相机是否会看到特定的对象。所有这些情况在不同的场景中都可以作为重要的可见性测试,并且所有这些都需要一定程度的手动测试。为了满足这些相机可见性需求,我们需要更密集地编写代码。以下章节中的函数将被编译为在专门的 CamUtility 类中的静态函数。让我们首先创建一个函数来测试特定渲染器组件是否位于特定 Camera 对象的视锥体内,如下面的代码示例 5-4 所示:

01 using UnityEngine;
02 using System.Collections;
03 //---------------------------------------------------------
04 public class CamUtility
05 {
06 //---------------------------------------------------------
07 //Function to determine whether a renderer is within frustum of a specified camera
08 //Returns true if renderer is within frustum, else false
09 public static bool IsRendererInFrustum(Renderer Renderable, Camera Cam)

10 {
11        //Construct frustum planes from camera
12        //Each plane represents one wall of frustrum
13        Plane[] planes = GeometryUtility.CalculateFrustumPlanes(Cam);

14 
15       //Test whether renderable is within frustum planes
16       return GeometryUtility.TestPlanesAABB(planes, Renderable.bounds);

17 }
18 //---------------------------------------------------------
19 }

从第 10-17 行,GeometryUtility 类被用来生成一个描述相机视锥体的平面对象数组。平面在 3D 空间中的作用类似于线在 2D 空间中的作用;它们在 3D 中标记出一个平坦的、想象中的表面。视锥体平面是一组六个平面,它们在 3D 空间中旋转并对齐,以表示完整的梯形相机视锥体。然后,这个数组被 TestPlanesAABB 函数(轴对齐边界框AABB))使用,该函数确定网格渲染器的碰撞边界是否存在于由这些平面定义的视锥体内。

视锥测试 – 点

当然,你并不总是想测试渲染器的可见性。相反,你可能只想测试一个点。这可能有两个主要原因。首先,你可能想知道一个对象,例如粒子或枪靶位置,是否实际上是可见的。其次,你可能不仅想知道一个点是否可见,还想知道它在屏幕空间中的位置;这将由相机渲染。下面的代码示例 5-5 将执行此操作。它将测试一个点是否在相机视锥体内,如果是的话,它还会进一步返回该点在标准化视口空间(介于 1-0 之间)中屏幕上的渲染位置。

 //---------------------------------------------------------
 //Determines if point is within frustum of camera
 //Returns true if point is within frustum, else false
 //The out param ViewPortLoc defines the location 

public static bool IsPointInFrustum(Vector3 Point, Camera Cam, out Vector3 ViewPortLoc)
    {
         //Create new bounds with no size
         Bounds B = new Bounds(Point, Vector3.zero);

        //Construct frustum planes from camera
        //Each plane represents one wall of frustrum

         Plane[] planes = GeometryUtility.CalculateFrustumPlanes(Cam);

        //Test whether point is within frustum planes
        bool IsVisible = GeometryUtility.TestPlanesAABB(planes, B);
        //Assign viewport location
        ViewPortLoc = Vector3.zero;

       //If visible then get viewport location of point
       if(IsVisible)
                ViewPortLoc = Cam.WorldToViewportPoint(Point);

         return IsVisible;
    }
    //---------------------------------------------------------

视锥测试 – 遮挡

如前所述,在严格意义上,可见性主要是一个两阶段过程,而不是一个单阶段过程。迄今为止的所有可见性测试都仅限于检查物体是否位于摄像机的视锥体内。通常情况下,这已经足够了,并且应该始终优先考虑。然而,有时这真的不够,因为即使在视锥体内的物体中,一个物体也可能遮挡另一个物体,因为较近的物体可能会完全或部分地遮挡较远的物体。尽管如此,这本身并不总是问题,因为通常情况下,确定物体可见性的主要兴趣只是要知道摄像头是否足够近,以便启用一组性能密集型行为(如 AI 行为)。目的是不是真正的可见性测试,而是要知道摄像头是否足够近。在这些情况下,物体是否被遮挡并不重要;重要的是它们是否在视锥体内。然而,偶尔遮挡确实很重要,例如当玩家查看特定物体时显示 GUI 元素或弹出通知。在这些情况下,遮挡很重要,因为 GUI 元素不应该在墙的另一侧的物体上弹出。有时,你可以通过创造性地使用碰撞体、触发器和仔细放置物体来绕过这些情况,有时,除了进一步通过遮挡测试过滤视锥体内的物体外,别无选择。现在,视锥体内物体的遮挡测试是一个深奥的主题,通过某些实现,它可能会产生显著的性能开销。因此,最好的方法之一是使用简单的Physics.LineCast方法调用来确定从摄像头到目标物体之间绘制的想象线是否被其他碰撞体相交。这种方法通常效果很好,但其局限性应该被认识到。首先,它假设所有可见物体都有碰撞体;任何违反此规则的例外都不会被LineCast方法检测到。其次,由于碰撞体仅近似网格的边界,并且不围绕网格顶点进行包裹,因此当网格有内部空洞时,LineCast方法可能会失败,因为周围的碰撞体会阻止LineCast穿透它们。最后,具有透明材质的网格,这些材质会揭示其后面的物体,将始终失败LineCast方法。考虑以下代码示例 5-6:

    //---------------------------------------------------------
    //Function to determine whether an object is visible
    public static bool IsVisible(Renderer Renderable, Camera Cam)
    {
         //If in frustrum then cast line
         if(CamUtility.IsRendererInFrustum(Renderable, Cam))
               return 

//Is direct line between camera and object?
!Physics.Linecast(Renderable.transform.position, Cam.transform.position);
         return false; //No line found or not in frustum
    }
    //---------------------------------------------------------

摄像头视觉 – 前视和后视

在某些游戏中,例如 RTS 游戏或休闲游戏,相机地平线(或远裁剪平面)并不那么重要,因为相机总是能看到它前面的所有东西。在这些情况下,当对象在视锥体之外时,它们只在xy平面上之外,但不在局部的z轴上;也就是说,隐藏的对象之所以被隐藏,仅仅是因为相机没有直接看向它们。然而,当相机适当定位时,对象在远处永远不会太远而看不到远裁剪平面之外。在这种情况下,可见性测试通常可以简化为更快的简单方向测试。因此,问题从“对象是否在视锥体内且未被遮挡?”转变为“对象是在相机前面还是后面?”在这里,我们需要的是不同的答案;这不是一个可见性问题,而是一个方向问题,即相机及其主题是否定位得如此,以至于主题在相机前面或后面。为了测试这一点,可以使用向量点积。点积接受两个向量作为输入,并将它们减少为一个单维的数值输出。这个值描述了两个输入向量之间的角度关系。在以下代码示例 5-7 中,可以将CamFieldView类附加到相机上,并检测相机是否可以看到目标对象,即目标对象是否在相机前方有限视野内:

 using UnityEngine;
 using System.Collections;
 //-------------------------------------------------
 public class CamFieldView : MonoBehaviour 
 {
    //-------------------------------------------------
    //Field of view (degrees) in which can see in front of us
    //Measure in degrees from forward vector (left or right)
    public float AngleView = 30.0f;

    //Target object for seeing
    public Transform Target = null;

    //Local transform
    private Transform ThisTransform = null;
    //-------------------------------------------------
    // Use this for initialization
    void Awake () 
    {
         //Get local transform
         ThisTransform = transform;
    }
    //-------------------------------------------------
     // Update is called once per frame
     void Update ()
    {
         //Update view between camera and target
         Vector3 Forward = ThisTransform.forward.normalized;
         Vector3 ToObject = (Target.position - ThisTransform.position).normalized;

         //Get Dot Product
         float DotProduct = Vector3.Dot(Forward, ToObject);
         float Angle = DotProduct * 180f;

         //Check within field of view
        if(Angle >= 180f-AngleView)
         {
                  Debug.Log ("Object can be seen");
         }
    }
    //-------------------------------------------------
 }
 //-------------------------------------------------

正交相机

在 Unity 中,每个新创建的相机对象默认配置为透视相机,除非你更改默认设置。这种相机类型最接近现实生活中的相机,它们在 3D 空间中有一个位置,有一个弯曲的镜头,并采用将捕获的图像转换到平坦的二维表面(如屏幕)的方法。这种相机的典型症状是透视缩短,这是对渲染对象施加的畸变的名称。具体来说,随着渲染对象向远处退去,它们会变得越来越小,随着它们从视线的中心点远离,它们的形状和外观会发生变化,并且所有平行线都会在远处的某个消失点汇聚,无论是地平线本身还是次要的线条上。然而,与透视相机相对的是正交相机。这些相机对于创建 2D 和真正等距的游戏非常有用,而不仅仅是模仿等距。使用正交相机时,镜头被压扁成一个平面,结果是透视缩短的消失,即平行线保持平行,对象不会随着距离的增加而缩小,二维在远离视线的中心时仍然是二维,等等。你可以通过从对象检查器中的投影类型设置轻松地将相机从透视切换到正交,如下面的截图所示:

正交相机

将透视摄像机更改为正交摄像机

在将透视类型更改为正交后,摄像机视锥体也将从梯形体积变为盒子。盒子内的所有内容都将可见,并且靠近的对象将继续遮挡更远处的对象,但所有其他深度感知都将消失,如下面的截图所示。因此,这种摄像机被认为适合用于 2D 游戏。

正交摄像机

正交摄像机的视锥体是一个盒子

在使用正交摄像机工作时,中心问题是如何在场景中的世界单位(单位)和屏幕上的像素之间创建 1:1 的关系。这个问题产生的原因是在 2D 游戏和 GUI 中,显示在屏幕上的图形以默认和正确的尺寸显示是有用的,正如在纹理文件中定义的那样。相比之下,在大多数 3D 游戏中,纹理映射、透视和缩放意味着纹理看起来是扭曲的,即投影到 3D 对象的表面上,它们不是直接看到的,就像在照片编辑程序中一样,而是在透视中看到的。对于 2D 游戏和精灵来说,情况是不同的。这些图形通常正面观看。因此,最好以默认大小显示它们,像素对像素。这种显示称为像素完美,因为纹理中的每个像素都将显示在屏幕和游戏中,且不发生变化。然而,在实践中实现这一点需要特定的方法。简而言之,要将 1 个世界单位映射到 1 个像素,应在摄像机选项卡中将大小字段设置为游戏垂直分辨率的一半。因此,如果你的游戏以 1024 x 768 运行,则大小字段应设置为364,因为 768 / 2 = 364,如下所示:

正交摄像机

大小字段控制世界单位如何映射到屏幕上的像素

您可以直接在编辑器中设置大小字段,但这仅在游戏分辨率恒定且永不更改的情况下才有效。如果用户可以调整游戏窗口大小或更改游戏分辨率,那么您就需要在脚本中更新摄像机大小,如下面的代码示例 5-8 所示:

01 //-------------------------------------------------------
02 using UnityEngine;
03 using System.Collections;
04 //-------------------------------------------------------
05 [RequireComponent(typeof(Camera))] 
06 //-------------------------------------------------------
07 public class OrthoCam : MonoBehaviour
08 {
09 //private reference to camera component
10 private Camera Cam = null;
11 
12 //Reference to Pixels to World Units Scale
13 public float PixelsToWorldUnits = 200f;
14 //-------------------------------------------------------
15 // Use this for initialization
16 void Awake () 
17 {
18        //Get camera reference
19        Cam = GetComponent<Camera>();
20 }
21 //-------------------------------------------------------
22 // Update is called once per frame
23 void LateUpdate () 
24 {
25        //Update orthographic size
26        Cam.orthographicSize = Screen.height / 2f / PixelsToWorldUnits;

27 }
28 //-------------------------------------------------------
29 }
30 //-------------------------------------------------------

注意,成员变量PixelsToWorldUnits已添加到第 13 行,以根据导入的精灵纹理的像素到单位字段缩放正交大小,如下面的截图所示。这有助于确保精灵在屏幕上显示时将具有正确的像素大小。这是因为所有精灵都必须按此值缩放,以将纹理中的像素映射到世界中的单位。

正交摄像机

设置精灵纹理的像素到单位缩放

摄像机渲染和后期处理

官方 Unity 文档关于相机渲染和后期处理的内容相对较少。然而,这并不意味着关于这个主题没有太多可说的。相反,Unity 相机和对象在如何渲染场景方面提供了广泛的灵活性。这些主题属于后期处理的范畴。具体来说,这指的是对相机渲染输出所做的所有额外编辑和修改,这些编辑和修改不包括在正常渲染中。这包括模糊效果、颜色调整、鱼眼效果等等。应该指出的是,这些功能的访问仅限于 Unity 的专业版本,而不是免费版本。因此,免费用户将无法跟随并完成本节。然而,对于专业版本用户,有广泛的相机渲染功能可用,如图中所示。本节通过创建一个相机切换系统来考虑这些功能,其中一个相机将平滑地淡入到另一个相机。通过交叉淡入,我并不是简单地指一个相机会切换到另一个相机,这(顺便提一下)可以通过改变相机的深度场来实现,因为高级相机渲染在低级相机之上。我更指的是第一个相机的渲染输出将逐渐在透明度上溶解,以揭示第二个相机的输出。那么,让我们开始吧。

相机渲染和后期处理

创建具有多个相机的场景

以包含两个独立区域或区域的场景开始项目,如图中所示的前一个屏幕截图。示例项目包含在本书的配套文件(代码包)中,位于本章 Cameras 文件夹内。场景的每个区域应分配一个单独的相机;这样,场景中就有两个相机,每个相机组件都应该被禁用。这将防止相机自动渲染自身。在这里,我们将手动渲染相机;这将允许每个相机的渲染结果组合并淡入到其他相机之上。

小贴士

对于每个相机,都移除了 AudioListener 组件,因为 Unity 场景在任何时候只能有一个活动的 AudioListener

接下来,在场景的原点创建一个标记为 MainCamera 的第三个相机,并设置一个空的剔除遮罩,确保相机处于活动状态但不能渲染任何内容。这将代表中央主场景相机,它将组合来自所有其他相机的渲染结果,如图所示:

相机渲染和后期处理

创建用于渲染的第三个主相机

现在,场景应该有三个相机:两个位于不同位置的不同和禁用的相机(相机 XY),以及一个位于场景原点的主相机(相机 Z)。在此基础上,以下代码示例 5-9 可以分配给相机 Z,这允许在按下空格键时在相机 XY 之间进行淡入淡出。

001 //Class to fade from camera 0 to 1, and back from 1 to 0
002 //This class assumes there are only two scene cameras
003 //---------------------------------------
004 using UnityEngine;
005 using System.Collections;
006 //---------------------------------------
007 public class CameraFader : MonoBehaviour
008 {
009       //---------------------------------------
010       //All cameras in the scene to be composited
011       public Camera[] Cameras;
012 
013      //Color to multiply with render)
014      public Color[] CamCols = null;
015 
016      //Fade in/out time in seconds 
017       public float FadeTime = 2.0f;
018 
019       //Material used as shader to final render
020       public Material Mat = null;
021       //---------------------------------------
022       // Use this for initialization
023       void Start () 
024       {
025             //Assign render textures to each camera
026             foreach(Camera C in Cameras)
027                    C.targetTexture = new RenderTexture(Screen.width, Screen.height, 24); //Create texture

028       }
029       //---------------------------------------
030       //Called once per frame after the camera has 
031       //finished rendering but before the render is shown
032       //Companion function: OnPreRender
033        void OnPostRender()
034       {
035            //Define screen rect
036            Rect ScreenRct = new Rect(0,0,Screen.width,Screen.height);
037 
038            //Source Rect
039            Rect SourceRect = new Rect(0,1,1,-1);
040 
041            //Render each camera to their target texture
042            for(int i = 0; i<Cameras.Length; i++)
043             {
044                   //Render camera
045                   Cameras[i].Render();
046 
047                   //Draw textures to screen using camera
048                   GL.PushMatrix();
049                   GL.LoadPixelMatrix();
050                   Graphics.DrawTexture(ScreenRct, Cameras[i].targetTexture, SourceRect, 0,0,0,0, CamCols[i]); 
051                   GL.PopMatrix(); //Reset matrix
052            }
053       }
054       //---------------------------------------
055       //This function is called after OnPostRender
056       //And when final pixels are to be shown on screen
057       //src = current render from camera
058       //dst = texture to be shown on screen
059       void OnRenderImage(RenderTexture src, RenderTexture dst)
060       {
061             //Now push final pixels to screen with Mat
062             Graphics.Blit(src, dst, Mat);
063       }
064       //---------------------------------------
065       //Lerp color over period TotalTime
066      //Fade alpha for topmost rendered camera CamCols[1]
067      public IEnumerator Fade(Color From, Color To, float TotalTime)

068      {
069           float ElapsedTime = 0f;
070 
071            //Loop while total time is not met
072            while(ElapsedTime <= TotalTime)
073             {
074                   //Update color
075                   CamCols[1] = Color.Lerp(From, To, ElapsedTime/TotalTime);
076 
077                  //Wait until next frame
078                  yield return null;
079 
080                 //Update Time
081                 ElapsedTime += Time.deltaTime;
082             }
083 
084            //Apply final color
085             CamCols[1] = Color.Lerp(From, To, 1f);
086     }
087       //---------------------------------------
088       //Sample for testing camera functionality
089       //Press space bar to fade in and out between cameras
090       void Update()
091       {
092             //Fade camera in or out when space is pressed
093             if(Input.GetKeyDown(KeyCode.Space))
094             {
095                   StopAllCoroutines();
096 
097                   //Should we fade out or in
098                   if(CamCols[1].a <= 0f)
099                          StartCoroutine(Fade(CamCols[1], new Color(0.5f,0.5f,0.5f,1f), FadeTime)); //Fade in

100                    else
101                           StartCoroutine(Fade(CamCols[1], new Color(0.5f,0.5f,0.5f,0f), FadeTime)); //Fade out

102            }
103       }
104       //---------------------------------------
105 }

以下是对代码示例 5-9 的注释:

  • 行 011-020: CamerFader 类负责在 Camera[0]Camera[1] 之间进行交叉淡入淡出。为了实现这一点,创建了几个变量。Cameras 数组维护了一个相机列表:在这种情况下是两个相机。CamCols 数组与 Cameras 相关联。它描述了通过哪种颜色将相机渲染进行乘法;这允许通过 alpha 值使渲染透明。FadeTime 变量定义了相机在一个方向上淡入或淡出的总时间(以秒为单位)。最后,Mat 变量引用任何将应用于主相机最终渲染的有效材质,即完成渲染的像素,包括从所有其他相机复合的所有内容。

  • 行 023-038: Start 方法为每个相机创建 RenderTexture,并将纹理分配给其 TargetTexture 成员。从本质上讲,这意味着每个相机都被分配了一个内部纹理,其渲染将在本地进行复合。

  • 行 033-052: Unity 会自动调用 OnPostRender 事件,针对场景中任何活动的相机对象,每帧一次,在相机完成正常渲染之后。这给对象一个机会在正常渲染数据之上渲染额外的相机或元素。在这里,调用 Cameras 数组中每个相机的 Render 方法;此方法手动渲染相机,不是直接在屏幕上,而是在其渲染纹理上。一旦渲染到纹理上,Graphics.DrawTexture 函数按照数组的顺序将每个相机的 RenderTexture 绘制到屏幕上,一个叠一个。请注意,每个 DrawTexture 调用都会将 CamCols 颜色乘以纹理;这也考虑了 alpha 成分以实现透明度。

  • 第 059-063 行:与 OnPostRender 类似,Unity 会自动在每一帧调用活动相机对象的 OnRenderImage 事件。它在 OnPostRender 之后和相机渲染显示在屏幕上之前被调用。此事件提供两个参数,即 srcdstsrc 参数是对包含从 OnPostRender 输出的相机完成渲染的渲染纹理的引用,而 dst 参数引用定义了当 OnRenderImage 事件完成时将在屏幕上显示的渲染纹理。简而言之,此函数为我们提供了手动在代码中或通过着色器编辑渲染像素的机会。在这里,调用 Graphics.Blit 函数使用与材质引用 Mat 关联的着色器将源复制到目标渲染纹理。

  • 第 067-085 行Fade 是一个 CoRoutine,它将 From 颜色过渡到 To 颜色,过渡时间为 TotalTime。此 CoRoutine 方法用于在相机颜色之间过渡 01 的 alpha 值,分别代表透明和不透明。

以下截图显示了交叉淡入淡出相机效果:

相机渲染和后期处理

混合相机

相机抖动

现在,这是我们可以使用 Unity 免费版本实现的效果:相机抖动!对于战斗、射击和动作游戏通常来说,相机抖动效果可能很重要。它传达了冲击、危险、动作、动态和兴奋——一种动态反馈。实际上,它还可以用来代替许多其他动画,这些动画模拟了普遍的运动和情感,而在场景的其他地方找不到这些运动和情感。在这方面,相机抖动可以通过创建一个总动画来节省我们大量的工作,就像这里所展示的那样:

相机抖动

相机抖动效果

创建相机抖动有许多方法,但它们都涉及使用某种“随机”函数在最小和最大范围之间波动相机位置。有时,“随机性”保持原始状态,有时则使用阻尼功能来平滑,以创建更慢或更“流畅”的抖动。请参考以下代码示例 5-10,该示例可以附加到任何相机上以创建抖动效果:

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

     //Total time for shaking in seconds
     public float ShakeTime = 2.0f;
     //Shake amount - distance to offset in any direction
     public float ShakeAmount = 3.0f;

     //Speed of camera moving to shake points
     public float ShakeSpeed = 2.0f;

    //---------------------
    // Use this for initialization
    void Start () 
    {
         //Get transform component
         ThisTransform = GetComponent<Transform>();

         //Start shaking
        StartCoroutine(Shake());
    }
    //---------------------
    //Shake camera
    public IEnumerator Shake()
    {
         //Store original camera position
         Vector3 OrigPosition = ThisTransform.localPosition;

          //Count elapsed time (in seconds)
          float ElapsedTime = 0.0f;

         //Repeat for total shake time
         while(ElapsedTime < ShakeTime)
         {
               //Pick random point on unit sphere
                Vector3 RandomPoint = OrigPosition + Random.insideUnitSphere * ShakeAmount;

                //Update Position
                ThisTransform.localPosition = Vector3.Lerp(ThisTransform.localPosition, RandomPoint, Time.deltaTime * ShakeSpeed);

                //Break for next frame
                yield return null;

                //Update time
                 ElapsedTime += Time.deltaTime;
         }
         //Restore camera position
         ThisTransform.localPosition = OrigPosition;
    }
    //---------------------
 }
 //---------------------

相机和动画

相机飞行动画是一种动画,其中相机在特定位置随时间移动和旋转,以创建电影效果。它们的重要性主要是为了创建过场动画,尽管并非仅限于此。这对于创建需要以特定和深思熟虑的方式映射的相机运动,如风格化的第三人称相机和其他俯视视角非常有用。创建此类相机运动的最常见方法是在 Unity 的动画编辑器中使用预定义,或者使用第三方工具,如 Maya、Blender 和 3DS Max。然而,有时需要对相机进行更程序化的控制,以手动调整其位置,远离平均中心,使用平滑的曲线运动,通过一系列点或遵循特定的预定义路线。本节考虑了三种方法。

跟随相机

也许,最常见的相机需求之一就是跟随相机,即跟踪场景中指定物体的相机,并跟随它。这种相机在物体和相机之间保持一定的距离,如下面的截图所示。这对于第三人称相机非常有用,例如肩上视角和俯视视角的即时战略游戏。

跟随相机

使相机平滑地跟随一个物体

小贴士

该项目可以在本书配套文件(代码包)中找到,位于本章Camera_Smooth_Damp文件夹内。

对于这类相机,简单的跟随行为通常不足以满足你的需求。如果是这样,你只需将相机作为对象的父对象并保持即可。然而,通常你希望相机运动有一定的平滑或阻尼,也就是说,速度逐渐减慢直到停止,而不是突然立即停止,这时相机要么以全速行驶,要么完全不移动。为了实现这一点,可以使用Quaternion.SlerpVector3.SmoothDamp函数。考虑以下代码示例 5-11,这是一个可以附加到任何相机上以平滑跟随物体的类:

 using UnityEngine;
 using System.Collections;
 //---------------------------------------------------------------
 public class CamFollow : MonoBehaviour 
 {
 //---------------------------------------------------------------
    //Follow target
     public Transform Target = null;

    //Reference to local transform
    private Transform ThisTransform = null;

    //Linear distance to maintain from target (in world units)
    public float DistanceFromTarget = 10.0f;

    //Height of camera above target
    public float CamHeight = 1f;

    //Damping for rotation
    public float RotationDamp = 4f;

    //Damping for position
    public float PosDamp = 4f;
 //---------------------------------------------------------------
    void Awake()
    {
         //Get transform for camera
         ThisTransform = GetComponent<Transform>();
    }
 //---------------------------------------------------------------
    // Update is called once per frame
    void LateUpdate () 
    {
         //Get output velocity
         Vector3 Velocity = Vector3.zero;

         //Calculate rotation interpolate
         ThisTransform.rotation = Quaternion.Slerp(ThisTransform.rotation, Target.rotation, RotationDamp * Time.deltaTime);

         //Get new position
         Vector3 Dest = ThisTransform.position = Vector3.SmoothDamp(ThisTransform.position, Target.position, ref Velocity, PosDamp * Time.deltaTime);

         //Move away from target
         ThisTransform.position = Dest - ThisTransform.forward * 
DistanceFromTarget;

          //Set height
          ThisTransform.position = new Vector3(ThisTransform.position.x, CamHeight, ThisTransform.position.z);

         //Look at dest
         ThisTransform.LookAt(Dest);
    }
 //---------------------------------------------------------------
 }

小贴士

关于Quaternion.Slerp的更多信息可以在网上找到,链接为docs.unity3d.com/ScriptReference/Quaternion.Slerp.html,关于Vector3.SmoothDamp的更多信息可以在网上找到,链接为docs.unity3d.com/ScriptReference/Vector3.SmoothDamp.html

相机和曲线

对于过场动画、菜单背景或更简单的相机飞行镜头,你可能只需要让相机大致沿直线移动,允许相机移动时有一定的曲线和速度波动,使用平滑进入和平滑结束的运动。这意味着相机在路径开始时加速,并在路径结束时逐渐减速。为了实现这一点,你可以通过 Unity 的动画编辑器使用预定义的动画,或者你可以使用动画曲线,它提供了对对象随时间变换的高度灵活性和控制,如下所示:

相机和曲线

使用动画曲线移动相机

要创建一个允许你控制对象速度和随时间运动的脚本,包括曲线运动和速度的平滑或阻尼,可以使用以下代码示例 5-12:

//-----------------------------
 using UnityEngine;
 using System.Collections;
 //-----------------------------
 public class CameraMover : MonoBehaviour 
 {
    //-----------------------------
    //Total time for animation
    public float TotalTime = 5.0f;

    //Total Distance to move on each axis
    public float TotalDistance = 30.0f;
    //Curves for motion
    public AnimationCurve XCurve;
    public AnimationCurve YCurve;
    public AnimationCurve ZCurve;

    //Transform for this object
    private Transform ThisTransform = null;
    //-----------------------------
    void Start()
   {
         //Get transform component
         ThisTransform = GetComponent<Transform>();

        //Start animation
        StartCoroutine(PlayAnim());
    }
    //-----------------------------
    public IEnumerator PlayAnim()
    {
         //Time that has passed since anim start
         float TimeElapsed = 0.0f;

          while(TimeElapsed < TotalTime)
          {
                //Get normalized time
                float NormalTime = TimeElapsed / TotalTime;

               //Sample graph for X Y and Z
               Vector3 NewPos = ThisTransform.right.normalized * XCurve.Evaluate(NormalTime) * TotalDistance;

                NewPos += ThisTransform.up.normalized * YCurve.Evaluate(NormalTime) * TotalDistance;

                NewPos += ThisTransform.forward.normalized * ZCurve.Evaluate(NormalTime) * TotalDistance;

               //Update position
               ThisTransform.position = NewPos;

               //Wait until next frame
               yield return null;

               //Update time
               TimeElapsed += Time.deltaTime;
          }
    }
    //-----------------------------

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

小贴士

在本章的Camera_Anim_Curves文件夹中,可以在本书的配套文件(代码包)中找到一个使用动画曲线进行相机移动的示例项目。

要使用CameraMover类,将脚本附加到相机上,并在对象检查器中点击每个XYZ曲线字段来绘制相机随时间变化的距离和速度。通过点击一个Graph色块,你可以编辑图表,从而添加点并定义应用于该轴的运动曲线。请注意,XYZ运动是绘制到对象的局部轴(前方、向上和向右)而不是世界轴(xyz)。这允许对象运动相对于局部轴进行,从而为你提供了对对象运动的根级控制,同时尊重动画数据的相关性,如下所示:

相机和曲线

使用动画曲线绘制运动曲线

小贴士

更多有关动画曲线的信息可以在 Unity 文档中找到,请访问docs.unity3d.com/Manual/AnimatorCurves.html

相机路径 – iTween

一个非常常见的功能请求,奇怪的是,它尚未作为原生 Unity 功能实现,那就是可编程的运动路径。这指的是一个 GameObject,例如相机,能够通过球形插值平滑地跟随路径或样条曲线,其中路径由一系列连接的游戏对象定义。这个功能已经存在,因为相机运动可以通过使用 Unity 的动画编辑器创建的预定义动画来定义。然而,人们希望对运动路径有更多灵活和程序化的控制,其中路径由一组航点定义,这些航点可以在代码中随时间调整。这种功能特别有用,例如,在太空射击游戏中,敌舰的轨迹明显遵循平滑的曲线飞行路径,有时根据玩家太空船的位置而改变,如以下截图所示。在 Unity 中实现这一点的有许多方法,但一个快速简便的解决方案是使用 Bob Berkebile 提供的免费插件 iTween;这可以直接从 Unity 的 Asset Store 下载和导入。更多关于 iTween 的信息,请访问 itween.pixelplacement.com/index.php

Camera paths – iTween

使用 iTween 创建相机运动路径

除了默认的 iTween 包之外,您还可以下载免费提供的 iTween 扩展,即 Visual iTween Path Editor,可通过 pixelplacement.com/2010/12/03/visual-editor-for-itween-motion-paths/ 访问。

在导入两个 iTween 包之后,下一步是开始使用它来创建沿路径动画的对象。以相机飞行为例,将脚本 iTweenPath 拖放到相机对象上。此脚本允许您创建一个由多个航点组成的独立且命名的路径,如下所示:

Camera paths – iTween

iTweenPath 脚本允许您定义一个航点路径

要为路径定义多个航点,请在节点计数字段中输入要创建的总航点数,然后选择场景视图中每个节点的小工具,将其转换到适当的位置。注意,在定义相机行进路径的点之间绘制的曲线路径:

Camera paths – iTween

定义路径的航点

然后,为了在运行时使相机跟随路径,将以下代码示例 5-13 脚本添加到相机中:

 using UnityEngine;
 using System.Collections;

 public class cam_itween_mover : MonoBehaviour 
 {
    // Use this for initialization
    void Start () 
    {
 iTween.MoveTo(gameObject, iTween.Hash("path", iTweenPath.GetPath("Camera Fly") , "time", 4f, "easetype", iTween.EaseType.easeInOutSine));
    }
 }

小贴士

更多关于 iTween 及其使用的详细信息,可在网上找到,请访问 itween.pixelplacement.com/gettingstarted.php

摘要

本章主要集中讨论了相机常见的许多预期或必需的任务。在 Unity 和任何游戏引擎中,相机都是必不可少的,因为它们代表了场景渲染到屏幕上的视角。在 Unity 中,大多数相机功能通常被视为理所当然,因此,相机为我们提供的许多灵活性和控制性都丢失了,并且没有被讨论。具体来说,在这里,我们首先考虑了 gizmo 渲染,即如何在场景视图中永久渲染相机 gizmo,即使相机被取消选择。其次,我们看到了如何确定哪些对象对相机是可见的,哪些是不可见的。这包括了几种重要的测试,如视锥体存在性和遮挡测试。第三,我们看到了如何创建和配置正交相机,这些相机可以渲染没有透视畸变的 2D 元素。第四,我们看到了如何通过渲染纹理编辑和增强相机渲染。这涉及到覆盖一系列相机关键事件,并将来自其他相机的渲染混合以创建相机交叉淡入淡出效果。第五,我们看到了如何创建更高级的相机运动,例如相机抖动。最后,你学习了关于相机路径的知识,即相机能够跟随指定路径的能力,无论这条路径是由一系列游戏对象航点定义的,还是简单地跟随一个对象。接下来,我们将进一步探索 Mono 框架。

第六章:使用 Mono

Unity 支持两种主要的脚本语言,即 C#和 JavaScript。开发者从一开始就应该选择这两种语言中的一种,并在整个项目中一致使用;这样所有脚本都使用所选语言编写。如果不这样做(通过混合不同语言的脚本文件),通常会导致不必要的头痛和困惑。然而,一旦你确定了一种语言,比如 C#,它通常不会提供你创建游戏所需的所有功能。C#本身不能加载和解析 XML 文件以支持保存游戏数据,也不能创建窗口对象和 GUI 小部件以在复杂的数据集和集合上执行高级搜索和查询行为。为了实现这些附加行为和更多功能,我们必须转向外部库。一些库可以直接从 Unity 的 Asset Store 购买,这些库通常用于特定的和专门的目的。然而,Unity 附带 Mono 框架,这是一个免费、跨平台和开源的 Microsoft .NET Framework(编程库)实现,并提供该库中的大多数类。.NET Framework 具有处理字符串、文件输入输出、搜索和排序数据、跟踪动态列表、解析 XML 等功能。这意味着通过 Mono,你可以获得一个广泛的工具集,以有效地和高效地管理应用程序中的数据。本章通过考虑列表、栈、语言集成查询Linq)、正则表达式、枚举器等方式,探讨了 Mono 在 Unity 应用程序中部署的许多方法。

以下截图显示了 Mono 框架界面:

使用 Mono

Mono 框架随 Unity 引擎一起提供

列表和集合

在游戏编程中,最常见的一项任务可能是存储数据列表。这种数据的特点千差万别:高分、玩家统计数据、敌人统计数据、库存物品、武器、升级、关卡列表等等。尽可能选择静态数组来存储数据,因为它们速度快、效率高。静态数组在第一章中进行了详细讨论,Unity C# 快速回顾。简而言之,静态数组是在事先创建的,它们的最大容量从一开始就是固定的。可以在运行时向它们添加和删除项目,但它们的总大小永远不会改变。如果它们的最大容量没有得到利用,那么空间就会被浪费。正如其名称所暗示的,静态数组是存储保持恒定的数据列表(如游戏中的所有关卡、可能收集的所有武器、可能收集的所有升级等等)的绝佳选择。

然而,你通常会需要动态数组,它可以根据数据的变化精确地增长和缩小容量,例如,当敌人被生成和销毁,库存物品来来去去,武器被收集和丢弃等情况。Mono 框架提供了许多类来维护数据列表。这三个主要类是ListStackDictionary。每个类都适用于特定的目的。

List

如果你需要一个无序的、按顺序排列的任何单一数据类型的项列表,即一个随着存储数据的大小而增长和缩小的列表,那么List类是理想的。List类非常适合添加和删除项,以及按顺序遍历所有存储的项。此外,List对象可以通过 Unity 对象检查器进行编辑。以下代码示例 6-1 使用了一个示例 C#文件Using_List.cs

01 using UnityEngine;
02 using System.Collections;
03 using System.Collections.Generic;
04 //----------------------------------------
05 //Sample enemy class for holding enemy data
06 [System.Serializable]
07 public class Enemy
08 {
09 public int Health = 100;
10 public int Damage = 10;
11 public int Defense = 5;
12 public int Mana = 20;
13 public int ID = 0;
14 }
15 //----------------------------------------
16 public class Using_List : MonoBehaviour 
17 {
18 //----------------------------------------
19 //List of active enemies in the scene
20 public List<Enemy> Enemies = new List<Enemy>();
21 //----------------------------------------
22 // Use this for initialization
23 void Start () 
24 {
25        //Add 5 enemies to the list
26        for(int i=0; i<5; i++)
27              Enemies.Add (new Enemy()); //Add method inserts item to end of the list
28 
29        //Remove 1 enemy from start of list (index 0)
30        Enemies.RemoveRange(0,1);
31 
32        //Iterate through list 
33        foreach (Enemy E in Enemies)
34        {
35              //Print enemy ID
36              Debug.Log (E.ID);
37        }
38 }
39 }
40 //----------------------------------------

小贴士

更多关于使用List的详细信息可以在本书的配套文件(代码包)中找到,位于Chapter06\Collections。你还可以在 MSDN 上查看List类的参考文档,网址为msdn.microsoft.com/en-us/library/6sh2ey19%28v=vs.110%29.aspx

以下是对代码示例 6-1 的注释:

  • 第 03 行:要使用List类,你必须包含System.Collections.Generic命名空间。

  • 第 06 行:如果你的列表数据类型被声明为System.Serializable类,那么列表将在对象检查器中显示。

  • 第 20 行:你可以在类成员声明中仅用一行语句声明和初始化一个新的列表实例。

  • 第 27 行:使用Add方法立即将新对象添加到列表的末尾。

  • 第 30 行:可以使用多种方法删除项。RemoveRange允许你从列表中删除多个连续的项。其他删除方法包括RemoveRemoveAllRemoveAt

  • 第 33 行:你可以使用foreach循环遍历列表中的所有项。

  • 第 27-33 行:通常,在遍历列表时不要向列表中添加或删除项。

以下截图显示了对象检查器中的List类:

The List class

在对象检查器中查看List

List 类支持几种方法来单独或集体地删除项目,这些方法旨在在列表迭代(循环)之外使用。然而,有时在迭代过程中删除项目既方便又简单,例如当你需要处理完每个项目后删除它时。一个典型的例子是在需要删除场景中所有引用类型对象(如敌人)的同时,也删除它们在数组中的条目以避免空引用。然而,在循环中删除项目可能会引起问题,因为迭代器很容易在循环过程中丢失对数组的跟踪,尤其是在项目总数在循环过程中变化时。为了在单个过程中循环和删除,你应该从数组末尾开始向前遍历,而不是从前往后,如下面的代码示例 6-2 所示:

 //Remove all items from a loop 
 void RemoveAllItems()
 {
    //Traverse list backwards
 for(int i = Enemies.Count-1; i>=0; i--)
    {
         //Call function on enemy before removal
         Enemies[i].MyFunc();

        //Remove this enemy from list
        Enemies.RemoveAt(i);
    }
 }

Dictionary

List 类可能是 Mono 框架中用于内存数据存储的最有用的类之一。然而,我们不要忘记 Dictionary 类(类似于 C++ 中的 std::map 类)。这个类在需要不仅仅是简单项目列表的情况下特别有用。如果你需要根据键值搜索并立即访问特定元素,那么 Dictionary 类是必不可少的。对于列表中的每个项目,你必须保存一个相应的键或 ID,该键或 ID 可以唯一地识别该项目。然后 Dictionary 类允许你仅基于其键立即访问此项目。这使得 Dictionary 类在真正的字典中非常有用,例如,如果你需要在一个大型字典或单词数据库中查找特定单词的含义或分数值。单词本身将是键,而单词定义将是值。

当然,现在你可以使用多个 List 对象而不是 Dictionary 类来复制这种行为。然而,在性能方面,Dictionary 类非常快,几乎像闪电一样快。你可以在字典中以极小的性能成本存储大量数据。这使得它们对于从键值快速查找数据非常有价值,如下面的代码示例 6-3 所示;

01 using UnityEngine;
02 using System.Collections;
03 using System.Collections.Generic; 
04 
05 public class Using_Dictionary : MonoBehaviour 
06 {
07 //Database of words. <Word, Score> key-value pair
08 public Dictionary<string, int> WordDatabase = new Dictionary<string, int>();

09 
10 // Use this for initialization
11 void Start () 
12 {
13        //Create some words
14        string[] Words = new string[5];
15        Words[0]="hello";
16        Words[1]="today";
17        Words[2]="car";
18        Words[3]="vehicle";
19        Words[4]="computers";
20 
21        //add to dictionary with scores 
22        foreach(string Word in Words)
23             WordDatabase.Add(Word, Word.Length);
24  
25       //Pick word from list using key value
26       //Uses array syntax! 
27       Debug.Log ("Score is: " + WordDatabase["computers"].ToString());

28 }
29 }

以下是对代码示例 6-3 的注释:

  • 行 03:与 List 类一样,你必须包含 System.Collections.Generic 命名空间

  • 行 08:在这里,字典在一行中声明和创建;与 List 类不同,Dictionary 不出现在 Unity 对象检查器中

  • 行 13-23Dictionary 类使用 Add 方法进行填充

  • 行 27:在 Dictionary 类中访问元素的方式与数组类似,只是通过指定每个元素使用其键数据而不是数组索引

    提示

    关于使用Dictionary的更多详细信息,可以在第四章 事件驱动编程中找到,当考虑使用EventManager进行事件驱动编程时。

Stack

如果你正在制作一个玩家需要从牌堆中抽取顶牌的卡牌游戏,如果你需要一个撤销历史,如果你正在编写定制的路径查找,或者如果你正在创建一个复杂的施法系统,甚至是一个汉诺塔谜题游戏(en.wikipedia.org/wiki/Tower_of_Hanoi),那么你很可能在某个环节需要使用到Stack。栈是一种基于后进先出LIFO)模型的特殊列表。这个概念是关于堆叠的。你可以将项目推入列表,这些项目会堆叠成一个垂直的塔,最近推入的项目始终在顶部。然后,你可以从栈顶(从数组中移除)逐个弹出项目。你弹出项目的顺序始终是它们被推入的顺序的相反。

这就是为什么Stack在撤销或倒退功能上特别有用。请参考以下代码示例 6-4,了解如何使用Stack

 using UnityEngine;
 using System.Collections;
 using System.Collections.Generic;
 //------------------------------------------
 [System.Serializable]
 public class PlayingCard
 {
    public string Name;
    public int Attack;
    public int Defense;
 }
 //------------------------------------------
 public class Using_Stack : MonoBehaviour 
 {
    //------------------------------------------
   //Stack of cards
   public Stack<PlayingCard> CardStack = new Stack<PlayingCard>();
   //------------------------------------------
    // Use this for initialization
    void Start () 
   {
         //Create card array
         PlayingCard[] Cards = new PlayingCard[5];

        //Create cards with sample data
        for(int i=0; i<5; i++)
        {
                Cards[i] = new PlayingCard();
                Cards[i].Name = "Card_0" + i.ToString();
                Cards[i].Attack = Cards[i].Defense = i * 3;

                //Push card onto stack
                CardStack.Push(Cards[i]);
          }

          //Remove cards from stack while(CardStack.Count > 0)
          {
                PlayingCard PickedCard = CardStack.Pop();

                //Print name of selected card
                Debug.Log (PickedCard.Name);
          }
   }
   //------------------------------------------
 }
 //------------------------------------------

IEnumerableIEnumerator

当你处理数据集合时,无论是ListDictionaryStack还是其他,你通常会想遍历(或遍历)列表中的所有项目或至少基于特定标准的一些项目。在某些情况下,你可能想按顺序遍历所有项目或某些项目。最常见的是,你希望按顺序正向遍历项目,但正如我们所看到的,有时反向遍历也是合适的。你可以使用标准的for循环遍历项目。然而,这引发了一些问题,IEnumerableIEnumerator的接口可以帮助我们解决这些问题。让我们看看有哪些问题。考虑以下代码示例 6-5 中的for循环:

 //Create a total variable
 int Total = 0;

 //Loop through List object, from left to right
 for(int i=0; i<MyList.Count; i++)
 {
    //Pick number from list
    int MyNumber = MyList[i];

    //Increment total
    Total += MyNumber;
 }

在使用for循环时,有三个主要的不便之处。让我们先从前两个说起。第一个是不太吸引人使用一个仅仅从左到右、从开始到结束循环的语法,我们必须始终使用一个整数迭代变量(i)来访问每个数组元素,随着循环的进行。第二个是迭代器本身并不真正是“界限安全”的。实际上,它可以在数组限制之上或之下增加或减少,从而引发越界错误。

这些问题在一定程度上可以通过更整洁的foreach循环来解决,它具有界限安全性和更简单的语法,如以下代码示例 6-6 所示:

 //Create a total variable
 int Total = 0;

 //Loop through List object, from left to right
 foreach(int Number in MyList)
 {
    //Increment total
    Total += Number;
 }

foreach 循环更简单,在可读性方面更受欢迎,但这里的情况比表面看起来要复杂得多。foreach 循环仅适用于实现了 IEnumerable 接口的类。实现了 IEnumerable 的对象必须返回一个有效的 IEnumerator 接口实例。因此,为了使对象能在 foreach 循环中工作,它必须依赖于另外两个接口。那么随之而来的问题是,为什么简单的循环或遍历行为会有如此复杂的内部复杂性。答案是,不仅 IEnumerableIEnumerator 通过 foreach 循环解决了更简单语法和边界安全迭代的前两个问题,而且它们还解决了第三个问题。具体来说,它们允许我们遍历或迭代不是真正数组类型的对象组;也就是说,它们让我们能够像在数组中一样遍历许多不同类型的对象,无论它们是否在数组中。这可以非常强大。让我们通过一个实际例子来看看这个功能是如何工作的。

使用 IEnumerator 遍历敌人

以一个包含许多不同且邪恶的法师角色(用 Wizard 类编码)的中世纪世界为背景的 RPG 游戏为例。为了举例,这些法师将以随机位置和随机间隔出现在关卡中,可能会给玩家带来无法预料的麻烦,施放法术,并执行邪恶行为。这种随机生成的结果是,默认情况下,我们无法预先知道在任何给定时间内场景中会有多少法师,也无法知道它们被生成在哪里,因为这是随机的。然而,我们仍然有合法的理由需要找到所有的法师;也许,所有法师都必须被禁用、隐藏、暂停或杀死,或者,也许我们需要进行人数统计以防止过度生成。因此,无论法师的生成和其随机性如何,仍然有很好的理由能够在需要时访问关卡中的所有法师。

我们已经在 第二章 中看到了一种方法,即 调试,我们可以检索所有法师的可遍历列表,如下面的代码示例 6-7 所示:

 //Get all wizards
 Wizard[] WizardsInScene = Object.FindObjectsOfType<Wizard>();

 //Cycle through wizards
 foreach (Wizard W in WizardsInScene)
 {
    //Access each wizard through W
 }

FindObjectsOfType 函数的问题在于,当频繁使用时,它会变慢,并且性能受限。即使是 Unity 文档在 docs.unity3d.com/ScriptReference/Object.FindObjectsOfType.html 也建议不要重复使用它。

小贴士

在本书的配套文件(代码包)中可以找到一个使用 IEnumeratorIEnumerable 接口的 Unity 项目示例,位于 Chapter06\Enumerators

因此,我们可以使用 IEnumerableIEnumerator 实现类似的行为,这避免了显著的性能损失。使用这两个接口,我们将能够高效地遍历场景中的所有巫师,使用 foreach 循环,就像它们在数组中一样,如下面的代码示例 6-8 所示:

01 using UnityEngine;
02 using System.Collections;
03 using System.Collections.Generic;
04 //----------------------------------------------------
05 //Class derives from IEnumerator
06 //Handles bounds safe iteration of all wizards in scene
07 public class WizardEnumerator : IEnumerator
08 {
09 //Current wizard object pointed to by enumerator
10 private Wizard CurrentObj = null;
11 //----------------------------------------------------
12 //Overrides movenext
13 public bool MoveNext()
14 {
15       //Get next wizard
16       CurrentObj = (CurrentObj==null) ? Wizard.FirstCreated : CurrentObj.NextWizard;

17 
18       //Return the next wizard
19       return (CurrentObj != null);
20 }
21 //----------------------------------------------------
22 //Resets the iterator back to the first wizard
23 public void Reset()
24 {
25       CurrentObj = null;
26 }
27 //----------------------------------------------------
28 //C# Property to get current wizard
29 public object Current
30 {
31       get{return CurrentObj;} 
32 }
33 //----------------------------------------------------
34 }
35 //----------------------------------------------------
36 //Sample class defining a wizard object
37 //Derives from IEnumerable, allowing looping with foreach
38 [System.Serializable]
39 public class Wizard : MonoBehaviour, IEnumerable
40 {
41 //----------------------------------------------------
42 //Reference to last created wizard
43 public static Wizard LastCreated = null;
44 
45 //Reference to first created wizard
46 public static Wizard FirstCreated = null;
47 
48 //Reference to next wizard in the list
49 public Wizard NextWizard = null;
50 
51 //Reference to previous wizard in the list
52 public Wizard PrevWizard = null;
53 
54 //Name of this wizard
55 public string WizardName = "";
56 //----------------------------------------------------
57 //Constructor
58 void Awake()
59 {
60       //Should we update first created
61      if(FirstCreated==null)
62             FirstCreated = this;
63 
64       //Should we update last created
65       if(Wizard.LastCreated != null) 
66       {
67              Wizard.LastCreated.NextWizard = this;
68              PrevWizard = Wizard.LastCreated;
69       }
70 
71        Wizard.LastCreated = this;
72 }
73 //----------------------------------------------------
74 //Called on object destruction
75 void OnDestroy()
76 {
77       //Repair links if object in chain is destroyed
78       if(PrevWizard!=null)
79              PrevWizard.NextWizard = NextWizard;
80 
81       if(NextWizard!=null) 
82              NextWizard.PrevWizard = PrevWizard;
83 }
84 //----------------------------------------------------
85 //Get this class as enumerator
86 public IEnumerator GetEnumerator()
87 {
88        return new WizardEnumerator();
89 }
90 //----------------------------------------------------
91 }
92 //-------------------------------------------------------------------

以下是对代码示例 6-8 的注释:

  • 第 07 行和第 39 行:在这里创建了两个类:第一个是 WizardEnumerator,它实现了 IEnumerator,第二个是 Wizard,它实现了 IEnumerableWizardEnumerator 类被实例化只是为了迭代一个巫师集合,该集合跟踪迭代过程中的当前巫师。为了遍历或迭代场景中的所有巫师,它依赖于 Wizard 类的成员变量,正如我们将在接下来的部分中看到的。

  • 第 13、23 和 29 行WizardEnumerator 类实现了 IEnumerator 的方法和属性,具体来说,MoveNext(遍历到循环中的下一个巫师)、Reset(将迭代器重置回第一个巫师)和 Current(返回循环中的活动巫师)。

  • 第 39 行Wizard 类封装了场景中的巫师角色,并从两个类继承:MonoBehaviourIEnumerable。这意味着这两个类的所有功能都结合在这个派生类中。它内部维护了几个变量,允许枚举器在任何时候遍历场景中的所有巫师实例。首先,Wizard 拥有 FirstCreatedLastCreated 静态成员(这些成员对所有巫师实例都是全局的)。这些变量在对象创建时设置(参见第 58 行的 Awake 函数)。FirstCreated 总是指向首先创建的巫师实例,而 LastCreated 总是指向最近创建的实例。

  • 第 48 行和第 52 行Wizard 类还维护了实例变量 NextWizardPrevWizard。这实现了双向链表;也就是说,每个巫师的实例都指向之前和之后创建的实例,这允许所有巫师之间形成链式连接。第一个巫师将具有 PrevWizardnull,而最后一个巫师将具有 NextWizardnull。这些变量使得迭代器能够在没有巫师在数组中的情况下遍历所有巫师实例。

  • 第 86 行GetEnumerator 方法返回一个实例到 Enumerator 对象。这是 IEnumerable 接口的要求,并允许使用 foreach 循环遍历所有巫师。

一起,WizardWizardEnumerator 类提供了快速、直接且高效的 Wizard 对象循环,即使不需要真正存在一个巫师数组。为了在实践中看到这一点,在一个巫师场景中,以下代码示例 6-9 可以枚举所有巫师:

void Update()
{
   //Press space to list all wizards in scene
   if(Input.GetKeyDown(KeyCode.Space))
   {
         //Get first wizard through static member
         Wizard WizardCollection= Wizard.FirstCreated;

         //If there is at least one wizard, then loop them all
         if(Wizard.FirstCreated != null)
          {
                //Loop through all wizards in foreach
                foreach(Wizard W in WizardCollection)
                       Debug.Log (W.WizardName);

          }
   }
}

您也可以通过直接访问Enumerator对象来遍历foreach循环外的所有向导,如下面的代码示例 6-10 所示:

void Update()
{
   //Press space to list all wizards in scene
   if(Input.GetKeyDown(KeyCode.Space))
   {
         //Get Enumerator
         IEnumerator WE = Wizard.FirstCreated.GetEnumerator();

          while(WE.MoveNext())
          {
                Debug.Log(((Wizard)WE.Current).WizardName);
          }
   }
}

字符串和正则表达式

与文本数据一起工作至关重要,并且有多个原因。如果您需要显示字幕、显示游戏中的文本以及实现本地化功能(支持多种语言),那么您将处理文本,特别是文本资产。在 Unity 中,文本资产指的是 Unity 项目中包含的任何文本文件,即使涉及多行(每行由\n转义字符分隔),每个资产也被视为一个长字符串。然而,一旦代码接收到这样的字符串,通常会有许多您想要处理它的方法。让我们看看一些常见但重要的字符串操作。

空字符串、空字符串和空白

在处理字符串时,您不能总是保证其有效性;有时,字符串格式不良且没有意义。因此,在处理之前,您通常会需要验证它们。验证字符串的常见方法首先检查字符串是否为空,然后(如果非空)检查字符串的长度,因为如果长度为0,则字符串为空,因此无效,即使它不是null

再次,您可能还希望消除字符串完全由空格组成的可能性,因为一个既不是null又只包含空白字符的字符串实际上长度不是0,尽管这通常意味着没有要处理的内容。您可以单独验证字符串的这些状态,但.NET 中的字符串类为您提供了复合或一站式便利检查,具体是IsNullOrWhiteSpace方法。然而,此方法是在.NET 4.5 中引入的,Mono 不支持此版本。这意味着需要手动实现等效行为,如下面的代码示例 6-11 所示:

 using UnityEngine;
 using System.Collections;
 //-------------------------------------------------------------
 //Class extension to add Null or White Space functionality
 public static class StringExtensions {
 public static bool IsNullOrWhitespace(this string s){
          return s == null || s.Trim().Length == 0;
    }
 }
 //-------------------------------------------------------------
 public class StringOps : MonoBehaviour 
 {
    //Validate string
    public bool IsValid(string MyString)
    {
          //Check for null or white space
 if(MyString.IsNullOrWhitespace()) return false;

          //Now validate further
          return true;
    }
 }
 //-------------------------------------------------------------

字符串比较

您通常会需要比较两个独立的字符串,通常是为了比较它们是否相等,以确定两个字符串是否相同。您可以使用==运算符,例如string1 == string2,但为了最佳性能,请使用theString.Equals方法。此方法有几个版本,计算成本各不相同。通常,您应该选择包含StringComparison类型参数的任何版本。当比较类型明确指定时,操作将表现得最好,如下面的代码示例 6-12 所示:

   //Compare strings
   public bool IsSame(string Str1, string Str2)
   {
        //Ignore case
         return string.Equals(Str1, Str2, System.StringComparison.CurrentCultureIgnoreCase);
   }

小贴士

关于String.Compare方法的更多信息可以在 MSDN 上找到,网址为msdn.microsoft.com/en-us/library/system.string.compare%28v=vs.110%29.aspx

另一种快速且定期比较两个相同字符串是否相等的方法是使用字符串哈希,即把每个字符串转换成唯一的整数,然后比较这些整数,如下面的代码示例 6-13 所示:

   //Compare strings as hash
   public bool StringHashCompare(string Str1, string Str2)
   {
          int Hash1 = Animator.StringToHash(Str1);
          int Hash2 = Animator.StringToHash(Str2);

         return Hash1 == Hash2;
   }

小贴士

你也可以使用 Mono 库中的String.GetHashCode函数来获取字符串的哈希码。更多信息,请访问msdn.microsoft.com/en-us/library/system.string.gethashcode%28v=vs.110%29.aspx

有时,你可能不想比较相等性。你的意图可能是确定哪个字符串在字母顺序上具有更高的优先级,即如果它们在字典中按字母顺序排列,一个字符串是否会出现在另一个之前。你可以使用String.Compare函数来实现这一点。然而,再次提醒,确保使用具有StringComparison类型参数的版本,如下面的代码示例 6-14 所示。使用这个版本,如果Str1Str2之前,将返回-1;如果Str2Str1之前,将返回1;如果两个字符串相等,将返回0

   //Sort comparison
   public int StringOrder (string Str1, string Str2)
   {
          //Ignores case
          return string.Compare(Str1, Str2, 
System.StringComparison.CurrentCultureIgnoreCase);
   }

小贴士

虽然String.Compare返回0表示两个字符串相等,但永远不要使用这个函数进行相等性测试。对于相等性测试,请使用String.Equals或哈希,因为它们都比String.Compare执行得快得多。

字符串格式化

如果你正在创建 GUI 元素,例如高分 HUD、玩家名称、现金计数器或资源指示器,你不仅需要显示文本,还需要在字符串中显示数值,例如,通过将单词Score:与实际分数的字符串表示结合,分数会根据玩家的表现随时间变化。实现这一目标的一种方法是使用String.Format方法,如下面的代码示例 6-15 所示。

//Construct string from three numbers
public void BuildString(int Num1, int Num2, float Num3)
{
   string Output = string.Format("Number 1 is: {0}, Number 2 is: {1}, Number 3 is: {2}", Num1, Num2, Num3);

   Debug.Log (Output.ToString("n2"));
}

字符串循环

到目前为止,我们已经看到了IEnumerableIEnumerator接口。幸运的是,这些接口适用于字符串,可以用来遍历或循环字符串中的每个字母。这可以通过IEnumerator接口本身或通过foreach循环实现。让我们看看两种方法,如下面的代码示例 6-16 和 6-17 所示:

   //Sample 6-16
//Loops through string in foreach
   public void LoopLettersForEach(string Str)
   {
          //For each letter
          foreach(char C in Str)
          {
               //Print letter to console
               Debug.Log (C);
          }
   }

//Sample 6-17
   //Loop through string as iterator
   public void LoopLettersEnumerator(string Str)
   {
         //Get Enumerator
         IEnumerator StrEnum = Str.GetEnumerator();

         //Move to nextletter
         while(StrEnum.MoveNext())
        {
               Debug.Log ((char)StrEnum.Current);
        }
   }

创建字符串

为了让你的代码更易读,以更整洁的方式工作,并且通常与.NET 及其预期使用方式保持一致,避免初始化字符串变量为:string MyString = "";。相反,尝试以下代码进行字符串声明和赋值,使用String.empty

string MyString = string.Empty;

搜索字符串

如果你正在处理从文件中读取的多行文本,例如 Text Asset,你可能需要找到较大字符串中较小字符串的第一个出现,例如,在较大字符串中找到一个较小且独立的单词。你可以使用 String.IndexOf 方法来实现这一点。如果找到匹配项,函数将返回一个正整数,表示找到的单词的第一个字符在较大字符串中的位置,作为从第一个字母测量的偏移量。如果没有找到匹配项,函数返回 -1,如下面的代码示例 6-18 所示:

   //Searches string for a specified word and returns found index of first occurrence
   public int SearchString(string LargerStr, string SearchStr)
   {
          //Ignore case
          return LargerStr.IndexOf(SearchStr, System.StringComparison.CurrentCultureIgnoreCase);
   }

正则表达式

有时,你可能需要在非常大的字符串上执行更复杂的搜索,例如找到以特定字母开头的所有单词,所有以 a 开头并以 t 结尾的单词等。在这些情况下,如果你找到了任何结果,你希望结果可用在数组中。你可以有效地使用正则表达式(Regex)来实现这一点。正则表达式允许你使用传统和专门的语法定义一个字符串值,指定搜索模式。例如,字符串 [dw]ay 表示“找到所有以 ay 结尾且以 dw 开头的单词。因此,找到 dayway 的所有出现”。然后可以将正则表达式应用于较大的字符串,使用 Regex 类执行搜索。.NET 框架通过 RegularExpressions 命名空间提供对正则表达式搜索的访问,如下面的代码示例 6-19 所示:

01 //-------------------------------------------------------
02 using UnityEngine;
03 using System.Collections;
04 //Must include Regular Expression Namespace
05 using System.Text.RegularExpressions;
06 //-------------------------------------------------------
07 public class RGX : MonoBehaviour 
08 {
09 //Regular Expression Search Pattern
10 string search = "[dw]ay";
11 
12 //Larger string to search
13 string txt = "hello, today is a good day to do things my way";
14 
15 // Use this for initialization
16 void Start () 
17 {
18        //Perform search and get first result in m
19        Match m = Regex.Match(txt, search);
20 
21        //While m refers to a search result, loop
22        while(m.Success)
23        {
24              //Print result to console
25              Debug.Log (m.Value);
26 
27             //Get next result, if any
28             m = m.NextMatch();
29        }
30 }
31 }
32 //-------------------------------------------------------

以下是对代码示例 6-19 的注释:

  • 第 05 行:使用正则表达式搜索的所有源文件都必须包含 RegularExpressions 命名空间。

  • 第 09 行和第 13 行:字符串 Search 定义了正则表达式本身。字符串 txt 定义了要由正则表达式搜索的较大字符串。字符串 Search 搜索所有出现单词 dayway

  • 第 19 行:调用 Regex.Match 方法对字符串 txt 应用正则表达式搜索。结果存储在局部变量 m 中。这个变量可以被迭代以扫描所有结果。

  • 第 25 行:在 m 中的结果将包括基于字符串 txt 的三个匹配项(而不是两个)。这些匹配项将包括在 to*day* 中找到的 day,以及单独的 dayway

    小贴士

    更多有关正则表达式的信息可以在网上找到,链接为 en.wikipedia.org/wiki/Regular_expression

无限参数

虽然技术上不属于 .NET 或 Mono 的部分,但我们对这两个库的探索多次触及到接受看似无限链的参数的函数,例如 String.Format 函数。使用 String.Format,可以插入所需的所有对象参数以包含到格式化字符串中。在本节中,我想稍微(并且非常快速)偏离一下,展示你可以编写自己的函数来接受和处理无限数量的参数;它们创建起来很简单。请参考以下代码示例 6-20,该示例可以求和可能无限大的整数数组:

01 public int Sum(params int[] Numbers)
02 {
03 int Answer = 0;
04 
05 for(int i=0; i<Numbers.Length; i++)
06         Answer += Numbers[i];
07 
08 return Answer;
09 }

以下是对代码示例 6-20 的注释:

  • 行 01:要接受可能无限数量的参数,请使用 params 关键字并将参数声明为数组类型

  • 行 05params 参数可以像常规数组一样访问

语言集成查询

显然,游戏处理大量数据。它们不仅处理字符串,还处理对象、数据库、表格、文档等等,太多以至于无法在此列出。然而,尽管数据广泛且种类繁多,但总有过滤数据的共同需求,查看与当时需求相关的较小子集。例如,给定场景中所有法师对象的完整数组(或枚举列表),我们可能希望进一步限制结果,仅查看健康值小于 50% 且防御点数小于 5 的法师。目的可能是,也许,在法师中启动大规模逃跑行为,寻找附近的药水并恢复他们的健康,然后再继续攻击玩家。现在让我们考虑这个场景的实现以及一项技术,Linq,如何帮助我们。

小贴士

完整的 Linq 示例项目可以在本书的配套文件(代码包)中找到,位于 Chapter06\Linq\

首先,可以给出一个非常基本和示例性的法师敌人类定义,如下所示代码示例 6-21。此类包括对行为逻辑至关重要的 HealthDefense 成员变量:

 //-------------------------------------------
 using UnityEngine;
 using System.Collections;
 //-------------------------------------------
 public class Enemy : MonoBehaviour 
 {
    public int Health = 100;
    public int Mana = 20;
   public int Attack = 5; 
    public int Defense = 10;
 }
 //-------------------------------------------

现在,给定场景中所有敌人对象的集合,我们可以使用以下代码根据我们的标准将数据过滤到一个更小的数组中,如下所示代码示例 6-22。

此代码有效地遍历所有成员,通过一个条件 if 语句运行它们,然后,最终如果满足条件,将敌人添加到结果数组中。在这种情况下,条件是一个敌人的健康值是否小于 50%,以及他们的防御是否小于 5:

 //Get list of enemies matching search criteria
 public void FindEnemiesOldWay()
 {
    //Get all enemies in scene
    Enemy[] Enemies = Object.FindObjectsOfType<Enemy>();

    //Filtered Enemies
    List<Enemy> FilteredData = new List<Enemy>();

    //Loop through enemies and check
 foreach(Enemy E in Enemies)
    {
 if(E.Health <= 50 && E.Defense < 5)
         {
               //Found appropriate enemy
               FilteredData.Add (E);
         }
    }

    //Now we can process filtered data
    //All items in FilteredData match search criteria
    foreach(Enemy E in FilteredData)
    {
         //Process Enemy E
         Debug.Log (E.name);
    }
 }

此代码在将较大的数据集根据特定标准限制为较小的数据集方面是有效的。然而,Linq 允许我们用更少的代码和通常更高的性能实现相同的结果。Linq 是一种高级且专门的语言,用于在数据集上运行查询,包括数组、对象,以及数据库和 XML 文档。Linq 在幕后自动将查询转换为适用于所使用数据集的适当语言(例如,数据库的 SQL)。目标是提取我们需要的成果到一个常规数组中。

以下代码示例 6-23 展示了使用 Linq 对前面代码示例 6-22 的另一种方法:

01 using UnityEngine;
02 using System.Collections;
03 using System.Collections.Generic;
04 using System.Linq;
05 //-------------------------------------------------
06 public void FindEnemiesLinqWay()
07 {
08 //Get all enemies in scene
09 Enemy[] Enemies = Object.FindObjectsOfType<Enemy>();
10 
11 //Perform search
12 Enemy[] FilteredData = (from EnemyChar in Enemies
13         where EnemyChar.Health <= 50 && EnemyChar.Defense < 5
14         select EnemyChar).ToArray();
15 
16 //Now we can process filtered data
17 //All items in FilteredData match search criteria
18 foreach(Enemy E in FilteredData)
19 {
20       //Process Enemy E
21       Debug.Log (E.name);
22 }
23 }
24 //-------------------------------------------------

以下是对代码示例 6-23 的注释:

  • 第 03-04 行:要使用 Linq,必须包含 System.Collections.Linq 命名空间,并且要使用 List 对象,必须包含 System.Collections.Generic 命名空间。

  • 第 12-14 行:Linq 代码的主体部分在这里出现。它由三个主要部分组成。首先,我们指明了从源数据中选取的项目,具体是从数据集 Enemies 中选取敌人对象。其次,我们定义了搜索的标准,具体是 EnemyChar.Health <= 50 && EnemyChar.Defense < 5。然后,当满足条件时,我们选择该对象添加到结果中;我们选择了 EnemyChar。最后,我们使用 ToArray 函数将结果转换为数组。

小贴士

更多关于 Linq 的信息可以在 MSDN 上找到,网址为 msdn.microsoft.com/en-gb/library/bb397926.aspx

Linq 和正则表达式

当然,Linq 不必独立工作。例如,它可以与正则表达式结合使用,从较大的字符串中提取特定的字符串模式,并将匹配的结果转换为可遍历的数组。这在处理逗号分隔值文件(CSV 文件)时特别有用,例如,数据格式化在文本文件中,每个条目由逗号字符分隔。Linq 和正则表达式都可以快速、轻松地将每个值读入一个唯一的数组元素。例如,考虑一个即时战略(RTS)游戏,其中必须为新单位生成人类名称。这些名称本身以 CSV 格式存储,并分为两组:男性和女性。在生成角色时,它可以是男性或女性,并且必须从 CSV 数据中为它们分配一个合适的名称,如下面的代码示例 6-24 所示:

01 //Generate female name
02 //Regular Expression Search Pattern
03 //Find all names prefixed with 'female:' but do not include the prefix in the results
04 string search = @"(?<=\bfemale:)\w+\b";
05 
06 //CSV Data - names of characters
07 string CSVData = "male:john,male:tom,male:bob,female:betty,female:jessica,male:dirk";

08 
09 //Retrieve all prefixed with 'female'. Don't include prefix
10 string[] FemaleNames = (from Match m in Regex.Matches(CSVData, search)

11          select m.Groups[0].Value).ToArray();
12 
13 //Print all female names in results
14 foreach(string S in FemaleNames)
15 Debug.Log (S);
16 
17 //Now pick a random female name from collection
18 string RandomFemaleName = FemaleNames[Random.Range(0, FemaleNames.Length)];

以下是对代码示例 6-24 的注释:

  • 第 04 行:成员变量 Search 定义了一个正则表达式搜索模式。在这个例子中,Search 变量用于所有以 female: 为前缀的单词。然而,更值得注意的是,前缀本身不应包含在结果字符串中。

  • 第 07 行:成员变量CSVData定义了一个包含男性和女性名字的完整 CSV 字符串,这些名字按照预期的格式结构化。这个字符串本质上代表了数据库或数据源。

  • 第 10-11 行:在这里,Linq 与正则表达式搜索结合使用,检索 CSV 中的所有女性名字,不包括前缀。然后,这个列表被转换成一个字符串数组FemaleNames

    注意

    字符串和@符号

    从代码示例 6-24 的第 04 行可以看出,正则表达式字符串前面带有符号@。这是 C#的约定,允许你在源文件中编写字符串字面量;这个字符串字面量可以包含转义序列(如\),而不会破坏或使字符串本身无效。

与文本数据资产一起工作

在迄今为止的所有示例中,我们都考虑了直接存储在字符串对象中的文本,但你也可以在 Unity 中处理文本文件。具体来说,你可以从外部源加载文本。在这里,我将演示如何做到这一点。

文本资产 – 静态加载

第一种方法是拖放一个文本文件到 Unity 项目中,该项目导入文本资产。文件以TextAssets类型导入,如下所示:

文本资产 – 静态加载

将文本文件导入 Unity 作为 TextAssets

你可以通过公开一个TextAsset公共成员从任何脚本文件访问文件及其文本数据,如下面的代码示例 6-25 所示:

//--------------------------------------------------
using UnityEngine;
using System.Collections;
//--------------------------------------------------
public class TextFileAccess : MonoBehaviour 
{
   //Reference a text file
   public TextAsset TextData = null;

   // Use this for initialization
   void Start () 
   {
         //Display text in file
         Debug.Log (TextData.text);
   }
}
//--------------------------------------------------

这段代码意味着你只需将TextAsset文件拖放到对象检查器中的文本数据槽位,如下所示:

文本资产 – 静态加载

从脚本访问文本文件资产

文本资产 – 从本地文件加载

另一种加载文本数据的方法是从项目外部加载,即从本地硬盘上的文件加载。以这种方式加载的文本数据是通过脚本动态读入项目的,不一定是在场景启动时,而是在你执行必要的代码时。这意味着对于涉及大量处理的较长的文本文件,延迟成为一个严重的考虑因素。因此,通常最好优先选择静态加载的文本资产而不是动态形式。对于任何动态资产,我建议你在场景启动时加载和处理它们,以避免游戏中的延迟,如下面的代码示例 6-26 所示:

 using UnityEngine;
 using System.Collections;
 using System.IO;

 //Function to load text data from external file
 public static string LoadTextFromFile(string Filename)
 {
    //If file does not exist on system, then return empty string
    if(!File.Exists(Filename)) return string.Empty;

    //File exists, now load text from file
    return File.ReadAllText(Filename);
 }

代码示例 6-26 将整个文本文件加载到一个字符串对象中。然而,你可能更喜欢逐行处理文本文件,特别是如果文件是一个配置文件,其中的值在单独的行中指定。为此,请参阅下面的代码示例 6-27:

 //Function to load text data, line by line, into a string array
 public static string[] LoadTextAsLines(string Filename)
 {
    //If file does not exist on system, then return empty array
    if(!File.Exists(Filename)) return null; 

    //Get lines
    return File.ReadAllLines(Filename);
 }

文本资产 – 从 INI 文件加载

在众多文本文件类型中,可以加载的常见格式是 INI 文件。也许在 Unity 游戏中并不那么常见,因为许多开发者使用 PlayerPreferences 类来存储应用程序设置。即便如此,INI 文件的优势在于可以在一个地方以相同的格式存储应用程序配置数据,跨越许多不同的平台。因此,有充分的理由使用 INI 文件。请参考代码示例 6-28,了解使用键值对格式的示例 INI 文件:

ApplicationName=MyTestApp
Date=1st Nov 2014
Author=Alan Thorn
Engine=Unity
Build=Production

加载 INI 文件的一个理想数据结构是反映键值对结构的字典。因此,将 INI 文件加载到字典中会非常好。

然而,Unity 和 Mono 都没有提供原生支持,这意味着我们必须自己编写功能代码,如下面的代码示例 6-29 所示:

 using UnityEngine;
 using System.Collections;
 using System.Collections.Generic;
 using System.IO;
 using System.Text;

 //Function to read basic ini file to dictionary
 public static Dictionary<string, string> ReadINIFile(string Filename)
 {
    //If file does not exist on system, then return null
    if(!File.Exists(Filename)) return null;

    //Create new dictionary
    Dictionary<string, string> INIFile = new Dictionary<string, string>();

    //Create new stream reader
    using (StreamReader SR = new StreamReader(Filename))
   {
         //String for current line
         string Line;

         //Keep reading valid lines
         while(!string.IsNullOrEmpty(Line = SR.ReadLine()))
         {
                //Trim line of leading and trailing white space
                Line.Trim();

                //Split the line at key=value
                string[] Parts = Line.Split(new char[] {'='});

                //Now add to dictionary
                INIFile.Add(Parts[0].Trim(), Parts[1].Trim());
         }
    }

    //Return dictionary
    return INIFile;
 }

从此函数返回的字典将与 INI 文件的格式相匹配。因此,可以通过字符串形式 Value = MyDictionary["Key"]; 访问值。您还可以在 foreach 循环中遍历字典中的所有键和值成员,如下面的代码示例 6-30 所示:

 //Build a dictionary from an INI file
 Dictionary<string,string> DB = ReadINIFile(@"c:\myfile.ini");

 //List all entries in dictionary
 foreach(KeyValuePair<string, string> Entry in DB)
 {
    //Loop through each key and value pair
    Debug.Log("Key: " + Entry.Key + " Value: " + Entry.Value);
 }

文本资产 – 从 CSV 文件加载

在本章的早期部分,我们看到了如何处理具有男性女性角色名称的 CSV 文件。现在,让我们看看一些源代码,展示如何将磁盘上的 CSV 文件加载到字符串数组中,每个字符串由逗号分隔,如下面的代码示例 6-31 所示:

 //Function to load a string array from a CSV file
 public static string[] LoadFromCSV(string Filename)
 {
    //If file does not exist on system, then return null
    if(!File.Exists(Filename)) return null;

    //Get all text
    string AllText = File.ReadAllText(Filename);

    //Return string array
    return AllText.Split(new char[] {','});
 }

文本资产 – 从网络加载

如果您正在制作多人游戏,需要在网络上访问玩家或游戏数据,如果需要在线验证密码散列,或者需要访问网页以处理其元素,那么您将需要 WWW 类来检索在线文本数据,如下面的代码示例 6-32 所示:

 //Gets text from the web in a string
 public IEnumerator GetTextFromURL(string URL)
 {
    //Create new WWW object
 WWW TXTSource = new WWW(URL);

    //Wait for data to load
    yield return TXTSource;

    //Now get text data
 string ReturnedText = TXTSource.text;
 }

小贴士

关于 WWW 类的更多信息可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/WWW.html

摘要

本章探讨了 Mono 框架在实际环境中的广泛应用。它采用了三部分结构。首先,我们探讨了 C#中常用的数据结构,包括 ListDictionaryStack。从那里,我们继续研究它们在存储和搜索数据以及组织和处理字符串中的常见用法。我们还探讨了用于在字符串中搜索数据模式的正则表达式,以及用于过滤 Mono 中所有集合类型对象的 Linq 语言。最后,我们检查了各种导入文本数据的方法,包括项目内部和本地文件中的文本数据,以及通过网络流过的文本数据。下一章将进入人工智能的世界;它考虑了路径查找、有限状态机、视线、决策、光线投射等内容。

第七章. 人工智能

本章采用高度实用和专业的视角。在这里,我们将从开始到结束全面覆盖单个 Unity 项目的开发,创建一个具有人工智能AI)的敌人角色的迷宫场景;这些角色具有搜索和追逐我们、攻击我们以及寻找恢复生命药水的逃逸能力。以下截图显示了 Unity 中的迷宫场景:

人工智能

迷宫场景

小贴士

本书的人工智能项目可以在本书的配套文件(代码包)中找到,位于本章的ai文件夹中。

在创建此项目时,我们将几乎应用到目前为止所看到的所有概念和想法,以孤立和纯粹的形式审视它们如何在项目中结合,同时结合一系列独特的 AI 概念,如有限状态机FSMs)、导航网格、视线等。为了跟上本章内容并从中获得最大收益,我建议您创建一个新的空白 Unity 项目,并从头到尾完成每个步骤。本章结束时,最终完成的项目可以在本书的配套文件(代码包)中找到,位于本章的ai文件夹中。

游戏中的人工智能

智能的概念可以从许多角度来理解:心理的、科学的、哲学的、精神的、社会学的,等等。其中许多都是深刻的。然而,在视频游戏中,它主要关乎外观,即智能的外观。也许这就是为什么标题中出现了“人工”这个词。其想法是,视频游戏主要是关于有趣和有趣的体验。这意味着游戏的可信度取决于玩家对游戏实际发生事件的参与程度。因此,每当一个非玩家角色NPC),如 RPG 中的敌人法师,做一些“愚蠢”的事情(比如毫无理由地穿过固体墙壁,或者像被卡住一样盲目地来回走动),玩家就会感觉到有问题。他们认为角色之所以不聪明,是因为在特定情况下角色的行为不合适,并且无法用游戏中的其他任何事情来合理解释。角色行为的“错误”或“愚蠢”迫使玩家意识到一个漏洞,在这个过程中,玩家就会从体验中脱离出来,意识到这只是一个游戏。结果是,对于游戏来说,AI 在很大程度上是让角色在玩家观察时对他们的处境做出适当的反应。在 AI 用于敌人或对手的游戏中,这主要涉及调整难度,即不要让 AI 太容易或太难。从这种角度来看,AI 不是关于构建一个模拟人类思维和意识的数学模型,从内心或内部视角模拟我们思考时发生的事情。它只是关于创造行为,让角色在特定条件下表现得像我们预期的那样,如下面的截图所示。因此,游戏的 AI 有一种“空洞”的感觉,但这个哲学观察在这里不必再进一步关注。

游戏中的人工智能

使用 Unity 构造者网格创建的敌人 AI 角色

在本章中,我们将创建一个设置在迷宫环境中的第一人称样本游戏。在这里,玩家可以攻击敌人,敌人也可以攻击玩家。敌人网格本身基于 Unity 附带的可动画构造者角色,并具有行走、奔跑和跳跃动画。构造者角色(不是类构造函数)将在环境中搜索,寻找玩家,一旦找到,就会追逐并攻击他们。构造者也可以被攻击,当被攻击时,如果他们的健康值降低,他们会逃跑并寻找增强道具。所以,让我们开始吧!

开始项目

首先,创建一个空白的新 Unity 项目和一个新场景。对于这个示例,我已经通过文件菜单命令从Asset中选择Import Package选项导入了几个 Unity 资产包。这些包是Character ControllerSkyboxesParticles,如图下所示。Character Controllers包含构造网格和动画,以及一个第一人称控制器预制体。

Skyboxes包为相机将看到的场景添加了一些视觉效果,而Particles包将被用来创建传送装置,正如我们将看到的。

启动项目

将资产导入到项目中

此外,将一个第一人称控制器和迷宫网格添加到场景中(网格包含在本章的书籍配套文件中的assets文件夹中)并创建一些照明和光照贴图,以使事物看起来最初就很好。这个网格是在一个 3D 建模程序中创建的,在这个例子中,是 Blender (www.blender.org/)。然而,这些资产本身对于 AI 本身并不是关键的,但它们创建了一个可展示的灰盒场景,你可以在这个场景中工作。光照贴图的细节超出了本书的范围,但可以通过从应用程序菜单中选择Window选项中的Lightmapping来访问光照贴图功能,如图下所示。

更多关于光照贴图的信息可以在在线 Unity 文档中找到,网址为docs.unity3d.com/Manual/Lightmapping.html

启动项目

创建初始场景

导航网格烘焙

需要创建的敌人需要在关卡中智能地行走,以找到并追逐玩家,以及找到恢复生命力的道具。AI 不能简单地从任何两点之间直线行走,因为可能会有墙壁和其他角色等障碍物。当遇到这些物体时,AI 应该绕过这些物体导航。为了长期实现这一点,应该使用导航网格。这是一个不可见的网格资产,Unity 会自动生成,以近似关卡中所有可通行的水平表面,即被分类为地面的表面。导航网格本身不包含 AI。它不会使任何事物行走。导航网格实际上是一个数学模型,它包含所有必要的用于 AI 单位成功计算和避开障碍物路径的数据。要为关卡生成导航网格,请从应用程序菜单的Window选项卡中选择Navigation选项。这将显示Navigation Mesh选项卡,它可以停靠到对象检查器中。

小贴士

关于导航网格烘焙的基础知识,请参阅在线 Unity 文档,网址为docs.unity3d.com/Manual/Navmeshes.html

在烘焙导航网格时,需要注意一些额外的细节,如下面的截图所示:

烘焙导航网格

准备烘焙导航网格

首先,半径设置可能需要从默认值进行调整。简而言之,此设置定义了一个围绕角色脚部的想象中的圆圈;这个圆圈表示它们作为步行代理的大致尺寸。如果半径太大,导航网格看起来会断裂或破碎,如果太小,网格生成将花费很长时间,此外,你的代理在行走时可能会穿透墙壁。通过一定程度的试错和细化,你可以找到一个最适合你项目的值。对于这个示例,0.2 的值效果最佳。如果半径过高,你的导航网格在狭窄区域会断裂,这并不好,因为代理无法穿越缝隙,如下面的截图所示:

烘焙导航网格

狭小区域的导航网格断裂

其次,生成的导航网格(一旦生成)可能会出现在真实网格地板之上或向上偏移。如果发生这种情况,你可以将高度误差百分比设置从高级组中的1降低,如下面的截图所示。这可以防止你的代理看起来在空中悬浮。记住,在调整任何设置后,你需要重新烘焙导航网格以应用更改。

烘焙导航网格

降低高度误差百分比将生成的导航网格移动到真实地板更近的位置

从图中可以看出,迷宫场景有两个独立的迷宫区域(左和右),它们之间没有连接的网格,不允许创建路径。对于这个示例,智能代理应该能够通过传送器在各个部分之间自由移动,如果需要的话,将它们从一个区域传送到另一个区域。

要在导航网格的断裂处实现这种连接,允许 AI 在表面上计算有效路径,我们可以使用离网链接。为应该作为传送平台或平台使用的级别添加一个新的网格。在这个示例中,我使用了一个标准的盒子网格和一个粒子系统以增强效果,但这不是必需的。然后,将离网链接组件附加到网格对象上,如下所示:

烘焙导航网格

使用离网链接创建传送平台

对目的地传送器垫重复此过程。对于每个传送器,将对象的变换分配给离网格链接组件的开始字段。这表示选定的传送器为起点。然后,对于结束字段,分配目标变换。这在这两个传送器之间建立连接,创建它们之间的路径。当连接建立时,当在编辑器中打开并激活导航面板时,应在场景视图中绘制一个连接箭头,如图所示。您还可以自动生成离网格链接。有关更多信息,请访问www.youtube.com/watch?v=w3-sSozYph4

烘焙导航网格

定义离网格链接之间的连接

小贴士

本章的起始项目,准备好进行 AI 编码,可以在本书的配套文件(代码包)中找到,位于本章的Start文件夹中。

启动 NPC 代理

现在,让我们为关卡创建一个 AI 代理,一个可以与玩家交互的实体。首先,代理需要在场景中有一个物理网格表示。为此,我使用了Constructor网格,它是之前导入的 Unity Character Controllers包的一部分。从项目面板拖放此网格到场景中,然后移除可能创建的任何动画组件,如图所示。动画将很重要,但稍后将会创建一个定制的动画控制器。

启动 NPC 代理

为敌人角色添加构造网格

小贴士

记住我们并没有使用第三人称控制器预制件;这里仅使用构造网格。

接下来,通过导航到组件 | 导航 | Nav Mesh Agent为对象添加NavMeshAgent组件。这允许对象与导航网格一起工作,并在被指示时能够找到并旅行路径。将组件的半径高度值设置为与网格尺寸相匹配。将停止距离设置为2;这控制玩家在停止前应该到达目的地有多近,如图所示。当然,对于你自己的项目,停止距离的值可能需要编辑。

启动 NPC 代理

配置 NavMeshAgent 组件以进行路径查找

现在,添加一个Rigidbody组件并勾选Is Kinematic复选框,如图所示。这允许对象进入触发体积,并通过引起和接收物理事件成为物理系统的一部分。然而,当勾选Is Kinematic时,Unity 不会覆盖对象的变换(位置、旋转和缩放)。这允许NavMeshAgent专门控制角色的移动。

启动 NPC 代理

为刚体组件配置物理属性

现在,向对象添加一个BoxCollider组件,并启用Is Trigger复选框将其转换为触发体积,即允许物理对象通过而不是阻止它们的体积。这将由 AI 用来近似代理的视野或观察区域。它将跟随代理,并且只有进入其视野的其他对象才被视为值得进一步考虑。为了将体积调整到代理的视野大小,使用XYZ大小字段,如图所示:

启动 NPC 代理

使用 BoxCollider 组件为敌人代理配置视野

最后,在项目中创建一个新的 C#脚本文件,名为AI_Enemy.cs,以定义敌人的智能。此脚本将封装敌人角色的完整 AI,并将在本章的整个过程中开发。一旦文件最初创建,将其附加到场景中的敌人对象上。我们现在准备好跳入 AI 编码和图形构建了!我们将从创建 FSM 及其相关状态开始,这些状态指定了敌人应该如何行为。

Mecanim 中的有限状态机

从现在开始,我们将主要集中讨论在 C#中为敌人角色编写 AI,以及从视觉编码的角度为 Mecanim 图进行编码。Mecanim 指的是 Unity 的动画系统(docs.unity3d.com/Manual/MecanimAnimationSystem.html)。在接下来的章节中,我们将逐步构建一个完整的类,该类将查看并讨论特定的代码部分,并且完整的类源代码将随着我们的进展逐步组合。它可以在完成的项目中的AI_Enemy.cs文件中查看。

首先,从概念上考察有限状态机(FSM)。当思考敌人角色时,我们可以观察到它们具有一组特定的行为。敌人开始场景时是站立不动的,然后开始巡逻。在巡逻期间,他们可能会看到玩家角色。如果他们这样做,他们会追逐玩家,直到玩家进入攻击范围。当玩家进入攻击范围时,他们会攻击玩家。现在,这些规则唯一的例外是如果敌人受到严重的健康伤害,使他们接近死亡。达到这种临界水平时,敌人将不会采取攻击性行为,而是逃跑并寻找恢复健康的药水,直到他们的健康水平恢复正常。

在这样总结敌人行为集合时,我们识别出了一系列离散且关键的敌人智能状态。这些状态包括空闲、巡逻、追逐、攻击和逃跑。敌人在任何时刻只能处于这些状态中的一个,并且每个状态决定了敌人的行为方式。为了实现这种逻辑,我们可以使用有限状态机(FSM)设计。这并不是指特定的类或对象类型(如MonoBehaviourScriptableObject),而是一种设计模式,一种编码方式。FSM 从一个有限的状态集合(空闲、巡逻、追逐等,如前所述)开始,然后管理这些状态之间逻辑上的连接。这决定了何时以及如何从一个状态转换到另一个状态。实际上,我们这里的敌人将取决于底层的两个状态机:一个在 C#代码中,另一个在 Mecanim 动画师图表中。后者仅控制每个状态下敌人网格应播放的动画。让我们首先构建 Mecanim 图表。

右键点击项目面板,创建一个新的动画控制器资产。在动画窗口中打开该资产,该窗口可通过从应用程序主菜单中选择窗口中的动画选项访问,如图所示:

Mecanim 中的有限状态机

访问动画图表

Mecanim 动画师图表定义了网格的所有可能的动画状态,并且这些状态应该与已经概述的敌人状态相对应,即空闲、巡逻、追逐、攻击和逃跑。为了配置这些状态的动画,在项目面板中选择构造器网格资产,并在对象检查器中启用Loop TimeLoop Pose复选框,如图所示。这可以防止角色动画在播放一次后停止:

Mecanim 中的有限状态机

为 Mecanim FSM 准备动画资产

现在,让我们向图表中添加动画状态,每个状态一个动画。对于空闲状态,应播放空闲动画。对于巡逻状态,应播放行走动画,因为角色应该四处走动。对于追逐逃跑状态,应播放跑步动画,而对于攻击状态,应播放跳跃动画。构造器模型缺少专门的攻击动画,因此(在这个示例中)跳跃动画将足够作为攻击动画。

将这些动画通过拖放从项目面板拖到图表编辑器中,并为每个状态适当地命名,如图所示:

Mecanim 中的有限状态机

在动画窗口中构建 FSM

除了迄今为止添加的标准动画状态外,我们再添加一个额外的空状态。这将成为敌人的初始和默认状态;此状态不播放任何动画,实际上代表了一个无状态的状态,直到我们在关卡启动时明确地将敌人放入特定的状态。要创建一个空和默认状态,右键单击图编辑器内的空白区域,从上下文菜单中选择创建状态中的选项(适当地重命名为StartInit),然后右键单击状态并选择设置为默认状态,如图所示:

Mecanim 中的有限状态机

将空节点设置为默认状态

图现在为角色每个状态定义了一个动画,但状态之间没有连接;每个状态都是孤立的。具体来说,没有逻辑来控制一个状态如何移动到另一个状态的条件。为了解决这个问题,使用 Mecanim 窗口左下角的参数框创建五个新的触发器。触发器变量是一种特殊的布尔类型,Unity 会自动将其重置为false;每次将其设置为true时,它允许行为被启动一次,例如状态更改。正如我们将看到的,可以在 C#代码中访问触发器。

现在,创建五个触发器:空闲巡逻追逐攻击寻找健康,如图所示:

Mecanim 中的有限状态机

为每个动画状态创建一个触发器变量

在创建好状态和触发器之后,现在可以在图中更精确地定义状态之间的连接。具体来说,当触发器巡逻被激活时,空闲状态应转换为巡逻状态,当触发器追逐被激活时,巡逻状态转换为追逐状态,当触发器攻击被激活时,追逐状态转换为攻击状态,依此类推。此外,大多数状态之间存在双向链接:巡逻可以转换为追逐(例如,当敌人看到玩家时),而追逐可以转换回巡逻(当敌人失去对玩家的视线时)。要创建状态之间的连接,右键单击一个状态,从上下文菜单中选择创建转换,然后单击应建立连接的目标状态。

Mecanim 中的有限状态机

设置状态转换的条件

图现在定义了一个完整的动画状态机(FSM)用于敌人对象。将其附加到场景中的敌人对象上很简单。

Animator组件添加到对象上,然后从项目面板中将Animator控制器拖放到Animator组件的控制器字段中,如图所示:

Mecanim 中的有限状态机

将动画器附加到敌人对象上

C#中的有限状态机 – 入门

现在动画的 FSM 已经完成,我们应该将注意力转向 C# 中的 FSM,该 FSM 管理敌人的行为,并在 Mecanim 图中启动触发器,以在正确的时间播放适当的动画(行走和跑步)。为了开始实现,请将以下公共枚举添加到 AI_Enemy.cs 脚本文件的顶部,如下面的代码示例 7-1 所示。此枚举定义了敌人 FSM 中所有可能的状态,并且每个状态都分配了其唯一的字符串哈希码;即,IDLE 状态分配了 2081823275 的值,这是字符串 IDLE 的哈希码,以此类推。这将在以后与 Mecanim 一起工作,特别是启动触发器时非常重要。您可以使用 Animator 类的 StringToHash 函数检索字符串的哈希码,如下所示:

//Define possible states for enemy
public enum AI_ENEMY_STATE {IDLE = 2081823275,
                            PATROL=207038023,
                            CHASE= 1463555229,
                            ATTACK=1080829965,
                            SEEKHEALTH=-833380208};

提示

更多信息可以在网上找到:docs.unity3d.com/ScriptReference/Animator.StringToHash.html

基于 AI_ENEMY_STATE 枚举,AI_Enemy 类将维护一个公共变量 CurrentState,该变量表示敌人对象当前的活动状态。随着时间的推移,状态的变化,该变量将发生变化,如下面的代码所示:

//Current state of enemy
public AI_ENEMY_STATE CurrentState = AI_ENEMY_STATE.IDLE;

与大多数对象一样,类 AI_Enemy 具有用于检索缓存的组件引用的 Awake 函数,包括 NavMeshAgent 和本地 Transform,以及场景中的其他对象,例如 Player 对象。这些引用将在脚本的其他地方使用,如下面的代码示例 7-2 所示:

//Get Animator
ThisAnimator = GetComponent<Animator>();

//Get Navigation Mesh Agent
ThisAgent = GetComponent<NavMeshAgent>();

//Get Transform Component
ThisTransform = transform;

//Get Player Transform
PlayerTransform = GameObject.FindGameObjectWithTag("Player").transform;

//Get Collider
ThisCollider = GetComponent<BoxCollider>();

提示

此代码使用缓存的变量:ThisAnimatorThisTransformThisAgentThisCollider。这使得我们能够在级别启动时立即检索附加组件的直接引用,从而节省了我们每次需要访问对象时调用 C# 属性函数(getset)的需要。因此,This.Transform 比优化后的、缓存的变量 ThisTransform 带有更大的性能开销。

FSM 中的每个状态都将编码为一个单独的 Coroutine,每个状态一个 Coroutine。Coroutine 将无限期且专一地循环,只要状态处于活动状态,定义该状态下敌人的所有行为。状态机的主要任务是选择并启动正确的状态,以满足正确的条件。让我们首先创建 Idle 状态——敌人的默认或正常状态。

创建空闲状态

敌对对象开始于 Idle 状态(一个“无所事事”的状态),这主要是过渡性的。在这个状态下,敌人站在原地,播放空闲动画。状态在场景启动时只进入一次,但当我们退出其他状态时,我们也会返回到它,作为进入新状态的前一个中间步骤。实际上,在这个状态下,敌人应该只播放空闲动画一次,然后在动画完成后离开状态。敌人还可以自动移动到 Patrol 状态,在那里他们开始搜索场景中的玩家。这涉及两个步骤。首先,我们需要在 Idle 状态开始时开始播放空闲动画。其次,我们需要在空闲动画完成时得到通知,以启动对 Patrol 状态的改变。请参阅以下代码示例 7-3 中的 Idle 状态:

01 //--------------------------------------------------
02 //This coroutine runs when object is in idle state
03 public IEnumerator State_Idle()
04 {
05       //Set current state
06       CurrentState = AI_ENEMY_STATE.IDLE;
07 
08       //Activate idle state with Mecanim
09       ThisAnimator.SetTrigger((int) AI_ENEMY_STATE.IDLE);
10 
11       //Stop nav mesh agent movement 
12        ThisAgent.Stop();
13  
14       //Loop forever while in idle state
15       while(CurrentState == AI_ENEMY_STATE.IDLE)
16        {
17              //Check if we can see player
18              if(CanSeePlayer)
19              {
20                    // can see player?, chase to attack
21                    StartCoroutine(State_Chase());
22                    yield break;
23               }
24 
25              //Wait for next frame
26              yield return null;
27        }
28 }
29 //--------------------------------------------------

以下是对代码示例 7-3 的注释:

  • 第 03 行: State_Idle 被编码为一个 Coroutine。有关协程的更多信息,请参阅 Unity 在线文档docs.unity3d.com/Manual/Coroutines.html。简而言之,Coroutine 的工作方式类似于异步函数(作为一个在后台运行的代码块,与其他函数并行)。因此,第 15 行中的无限循环不会导致崩溃,因为 Coroutine 的工作方式就像一个单独的线程。Coroutine 总是返回类型 IEnumerator,并且它们体内总是包含一个 yield 语句。

  • 第 09 行: 在这一行调用了动画器 SetTrigger 函数;它将字符串 Idle 的哈希码作为参数传递,以在 Mecanim 图中设置 Idle 触发器,从而开始播放空闲动画。这将 C# FSM 连接到 Mecanim FSM。注意,在第 12 行,调用了 Stop 函数来停止 NavMeshAgent 组件可能正在执行的所有动作。这是因为当空闲动画播放时,敌人不应该移动。

  • 第 15 行: 在这里,State_Idle 函数进入了一个无限循环;也就是说,只要敌人处于 Idle 状态,它就会逐帧循环。当 Idle 状态处于活动状态时,循环内的所有内容都会在每一帧执行,允许对象更新并随时间改变其行为。

  • 第 18 行: 除了等待空闲动画完成之外,Idle 状态的一个退出条件是如果在期间看到了玩家。玩家可见性由布尔变量 CanSeePlayer(视线的细节将在稍后考虑)确定。如果 CanSeePlayertrue,则使用 StartCoroutine 函数激活 Chase 状态,并通过调用 yield break 终止 Idle 状态。

目前实现的Idle状态会无限循环,除非看到玩家,否则不会改变到其他状态。然而,Idle状态应该是临时的;空闲动画应该播放一次,然后通知我们其完成。为了实现这种回放通知,我们可以使用动画事件。为此,在项目面板中选择构造器角色网格,打开动画选项卡,在对象检查器中检查空闲动画。从这里,打开事件选项卡,如下面的截图所示:

创建空闲状态

在对象检查器中展开事件选项卡

然后,在时间1(在末尾)处双击动画时间轴,在该时间插入一个函数调用。当动画完成时,这会向敌人对象发送消息,如下面的截图所示。为此,我在AI_Enemy类中编写了一个名为OnIdleAnimCompleted的方法:

创建空闲状态

在动画结束时调用函数

当空闲动画完成时,Unity 会自动调用OnIdleAnimCompleted函数。以下代码示例 7-4 显示了此方法的实现方式:

   //Event called when Idle animation is completed
   public void OnIdleAnimCompleted()
   {
         //Stop active Idle state
         StopAllCoroutines();
 StartCoroutine(State_Patrol());
   }

创建巡逻状态

Patrol状态中,敌人应该在环境中徘徊并寻找玩家。此状态可以从Idle状态进入,在空闲动画完成后,也可以从Chase状态进入,如果敌人在追逐过程中失去对玩家的视线。巡逻涉及循环逻辑。具体来说,敌人应该在导航网格上的某个随机目的地选择一个目的地,然后前往该目的地。当到达目的地时,该过程应该重复,依此类推。唯一导致敌人离开此状态的条件是看到玩家,这要求进入Chase状态。

虽然解释起来很简单,但此状态依赖于两个更复杂的问题:首先,必须选择一个随机位置,其次,应执行玩家可见性检查。首先,让我们考虑随机位置选择。

场景选项卡中,我创建了一个由标记为航点的空游戏对象组成的集合。这些对象除了在NavMesh地板上标记位置外,什么都不做。这些共同代表了一个敌人巡逻期间可能前往的所有可能位置。因此,敌人需要随机选择这些目的地之一,如下所示:

创建巡逻状态

场景选项卡中创建航点目的地

要实现Patrol状态的目的地选择,AI_EnemyAwake函数将首先检索场景中所有要使用的航点列表。我们可以使用Linq来完成此操作,如下面的代码示例 7-5 所示。此示例代码检索场景中所有航点的静态变换数组,在名为Waypoints的私有变量中:

01 //Find all gameobjects with waypoint
02 GameObject[] Waypoints = GameObject.FindGameObjectsWithTag("Waypoint");
03 
04 //Select all transform components from waypoints using Linq
05 WayPoints = (from GameObject GO in Waypoints
06                   select GO.transform).ToArray();

在检索到所有航点列表后,可以编写 Patrol 状态,如下面的代码示例 7-6 所示,该示例定期选择航点作为新的移动目标:

01 //--------------------------------------------------
02 //This coroutine runs when object is in patrol state
03 public IEnumerator State_Patrol()
04 {
05 //Set current state
06 CurrentState = AI_ENEMY_STATE.PATROL;
07 
08 //Set Patrol State
09 ThisAnimator.SetTrigger((int) AI_ENEMY_STATE.PATROL);
10 
11 //Pick a random waypoint
12 Transform RandomDest = WayPoints[Random.Range(0, WayPoints.Length)];
13 
14 //Go to destination
15 ThisAgent.SetDestination(RandomDest.position);
16 
17 //Loop forever while in patrol state
18 while(CurrentState == AI_ENEMY_STATE.PATROL)
19 {
20        //Check if we can see player
21        if(CanSeePlayer)
22        {
23             //If we can see player, then chase to attack 
24             StartCoroutine(State_Chase());
25             yield break;
26        }
27 
28        //Check if we have reached destination
29        if(Vector3.Distance(ThisTransform.position, RandomDest.position) <= DistEps)

30        {
31              //Reached destination. Changed state back to Idle
32              StartCoroutine(State_Idle());
33              yield break;
34        }
35 
36        //Wait for next frame
37        yield return null;
38 }
39 }
40 //--------------------------------------------------

以下是对代码示例 7-6 的注释:

  • 行 12: 在这里,Random.Range 函数从 Waypoints 数组中随机选择一个目标。这个目标作为参数传递给 NavMeshAgent 组件的 SetDestination 函数,该函数将敌人发送到目标位置。

  • 行 28: 使用 Vector3.Distance 函数来确定代理是否到达目的地。这并不检查敌人位置和目的地位置之间的相等性,因为浮点数的不精确性意味着我们无法保证它们永远相同。相反,它检查敌人是否已经到达目的地指定距离内(DistEps),将其分类为已到达。

  • 行 32: 如果达到目标,敌人将返回到 Idle 状态。在等待空闲动画的一个周期后,敌人将再次进入 Patrol 状态。

  • 行 21: 再次,Patrol 状态取决于玩家是否对敌人可见。如果是,他们进入 Chase 状态。

布尔变量 CanSeePlayer 表示,对于任何一帧,玩家是否当前对敌人可见。这个变量在每一帧都会更新。这个过程从 Update 函数内部开始,如下面的代码示例 7-7 所示:

01 void Update()
02 {
03 //Assume we cannot see player
04 CanSeePlayer = false;
05 
06 //If player not inside bounds then exit
07 if(!ThisCollider.bounds.Contains(PlayerTransform.position)) return;

08 
09 //Player is inside bounds, update line of sight
10 CanSeePlayer = HaveLineSightToPlayer(PlayerTransform);
11 }

Update 函数的关键问题是玩家是否在附加到敌人的盒子碰撞体内;这个盒子碰撞体代表敌人的视野或范围。如果玩家在这个盒子内,玩家可能对敌人可见。在这种情况下,需要进一步的检查以确保这一点。这就是 HaveLineSightToPlayer 函数至关重要的地方。这个函数返回一个布尔值(true/false),表示玩家是否对敌人可见,如下面的代码示例 7-8 所示:

//Function to return whether player can be seen right now
private bool HaveLineSightToPlayer(Transform Player)
{
//Get angle between enemy sight and player
float Angle = Mathf.Abs(Vector3.Angle(ThisTransform.forward, (Player.position-ThisTransform.position).normalized));

    //If angle is greater than field of view, we cannot see player
    if(Angle > FieldOfView) return false;

    //Check with raycast- make sure player is not on other side of wall
    if(Physics.Linecast(ThisTransform.position, Player.position, SightMask)) return false;

    //We can see player
    return true;
 }

正如我们在前面的章节中看到的,可见性是通过两个阶段的过程确定的。首先,敌人视线向量与指向玩家方向的归一化向量之间的角度决定了可见性。如果角度小于敌人的视野角度,那么玩家就会在敌人前方,并且会被看到,前提是没有障碍物,例如墙壁,位于敌人和玩家之间。第二个测试由 Physics.Linecast 执行,确定是否可以在敌人和玩家之间画一条不间断的直线。如果可以,那么他们之间就没有障碍物,玩家就会被看到。

创建 Chase 状态

如果敌人看到玩家但不在攻击距离内,敌人会跑向玩家进行攻击。这种敌人带着敌意跑向玩家的状态就是Chase状态。这个状态有两个主要的退出条件。如果敌人到达攻击距离,他们应该从Chase状态变为Attack状态。相反,如果玩家从视线中消失,敌人应该尽可能地继续追逐一段时间,然后在一段时间后如果玩家仍然没有被看到,就放弃追逐。请参考以下代码示例 7-9:

01 //This coroutine runs when object is in chase state
02 public IEnumerator State_Chase()
03 {
04 //Set current state
05 CurrentState = AI_ENEMY_STATE.CHASE;
06 
07 //Set Chase State
08 ThisAnimator.SetTrigger((int) AI_ENEMY_STATE.CHASE);
09 
10 //Loop forever while in chase state
11 while(CurrentState == AI_ENEMY_STATE.CHASE)
12 {
13        //Set destination to player
14        ThisAgent.SetDestination(PlayerTransform.position);
15 
16       //If we lose sight of player, keep chasing 
17       if(!CanSeePlayer)
18       {
19              //Begin time out
20              float ElapsedTime = 0f;
21 
22              //Continue to chase
23              while(true)
24              {
25                    //Increment time
26                    ElapsedTime += Time.deltaTime;
27 
28                    //Set destination to player
29 ThisAgent.SetDestination( PlayerTransform.position);
30 
31                   //Wait for next frame
32                   yield return null;
33 
34                   //Has timeout expired?
35                   if(ElapsedTime >= ChaseTimeOut)
36                   {
37                          //If cannot see player, change to idle
38                          if(!CanSeePlayer)
39                          {
40                                //Change to idle 
41                                StartCoroutine(State_Idle());
42                                yield break;
43                          }
44                          else
45                                break; //can see player again
46                   }
47              }
48        }
49 
50        //If we have reached player then attack
51 if(Vector3.Distance(ThisTransform.position, PlayerTransform.position) <= DistEps)

52        {
53              //We have reached distance, now attack
54              StartCoroutine(State_Attack());
55              yield break;
56        }
57 
58        //Wait until next
59        yield return null;
60 }
61 }

以下是对代码示例 7-9 的注释:

  • 第 17-48 行: 在这个阶段,State循环确定玩家已经失去可见性。当这种情况发生时,敌人将继续追逐玩家一段时间,这段时间为ChaseTimeOut。在这段时间过去后,敌人再次检查玩家的可见性。如果在那时看到玩家,追逐将像之前一样继续。否则,敌人将变为Idle状态,准备再次开始巡逻寻找玩家。

  • 第 51-59 行: 在这里,Chase状态检查敌人是否进入了攻击范围(DistEps)。如果是这样,有限状态机(FSM)将进入State_Attack状态。

创建攻击状态

Attack状态中,只要敌人可见,敌人就会不断地攻击玩家。攻击后,敌人必须恢复才能发起新的攻击。这个状态唯一的退出条件是失去对玩家的视线。当这种情况发生时,敌人将返回Chase状态,然后根据玩家是否被找到,他们要么回到攻击状态,要么进入Idle状态,如下面的代码示例 7-10 所示:

//This coroutine runs when object is in attack state
 public IEnumerator State_Attack()
 {
    //Set current state
    CurrentState = AI_ENEMY_STATE.ATTACK;

    //Set Chase State
    ThisAnimator.SetTrigger((int) AI_ENEMY_STATE.ATTACK);
     //Stop nav mesh agent movement
 ThisAgent.Stop();

    //Set up timer for attack interval
    float ElapsedTime = 0f;

    //Loop forever while in attack state
    while(CurrentState == AI_ENEMY_STATE.ATTACK)
    {
         //Update timer
         ElapsedTime += Time.deltaTime;

        //Check if player has passed beyond the attack distance
if(!CanSeePlayer || Vector3.Distance(ThisTransform.position, PlayerTransform.position) > DistEps)
          {
               //Change to chase
               StartCoroutine(State_Chase());
               yield break;
          }

          //Check attack delay
          if(ElapsedTime >= AttackDelay)
          {
                //Reset counter
                ElapsedTime = 0f;

               //Launch attack
               PlayerTransform.SendMessage("ChangeHealth", -AttackDamage, SendMessageOptions.DontRequireReceiver);
          }

          //Wait until next frame
          yield return null;
    }
 }

创建寻找健康(或逃跑)状态

当敌人健康值降低到一定程度以下并可以通过收集医疗包来恢复时,就会进入Seek-Health状态。这个状态与大多数其他状态不同,因为它可以从任何状态到达或进入。进入这个状态不依赖于它与其他状态的关系,而只依赖于玩家的健康。具体来说,当敌人的健康值降低到最低阈值以下时,应该进入这个状态。由于这种配置,请确保将Seek-Health动画状态在 Mecanim 图中连接到允许在任何状态下触发跑步动画的Any State节点,如下所示:

创建寻找健康(或逃跑)状态

Seek-Health状态可以从任何状态访问

每个敌人都有一个Health变量;这个变量会根据敌人是否找到医疗包或被攻击而上下调整。这种变化发生在ChangeHealth方法内部,这也是我们确定是否必须启动SeekHealth状态的地方。ChangeHealth函数是公开的;它允许SendMessageBroadcastMessage在需要时将其作为事件触发,如下面的代码示例 7-11 所示:

//Event called health changed
 public void ChangeHealth(float Amount)
 {
    //Reduce health
    Health += Amount;

    //Should we die?
    if(Health <= 0)
    {
          StopAllCoroutines();
          Destroy(gameObject);
          return;
    }

    //Check health danger level
    if(Health > HealthDangerLevel) return;

    //Health is less than or equal to danger level, now seek health restores, if available
    StopAllCoroutines();
 StartCoroutine(State_SeekHealth());
 }

可以像以下代码示例 7-12 所示那样编写State_SeekHealth方法:

01 //This coroutine runs when object is in seek health state
02 public IEnumerator State_SeekHealth()
03 {
04 //Set current state
05 CurrentState = AI_ENEMY_STATE.SEEKHEALTH;
06 
07 //Set Chase State
08 ThisAnimator.SetTrigger((int) AI_ENEMY_STATE.SEEKHEALTH);
09 
10 //This is the nearest health restore
11 HealthRestore HR = null;
12 
13 //Loop forever while in seek health state
14 while(CurrentState == AI_ENEMY_STATE.SEEKHEALTH)
15 {
16        //If health restore is not valid, then get nearest
17        if(HR == null) HR = GetNearestHealthRestore(ThisTransform);
18 
19        //There is an active health restore, so move there
20        ThisAgent.SetDestination(HR.transform.position);
21 
22        //If HR is null, then no more health restore, go to idle
23        if(HR == null || Health > HealthDangerLevel)
24        {
25              //Change to idle
26              StartCoroutine(State_Idle());
27              yield break;
28 	      }
29 
30       //Wait until next frame
31       yield return null;
32 }
33 }

以下是对代码示例 7-12 的注释:

  • 行 17Health-Seek状态首先在场景中找到最近的急救包,并将其用作代理的目标。这在某种程度上是作弊,因为(当然)没有远程观察能力,敌人不应该能够知道最近的急救包在哪里。然而,记住重要的是敌人实际上知道什么,而不是它对玩家看起来如何。如果玩家不知道这个逻辑,并且无法从外观上了解它,那么这就没有任何意义。此外,还请注意,玩家或另一个敌人可能在敌人到达目的地之前收集急救包。因此,在每个帧上,敌人必须确定目标急救包是否仍然有效,如果不是,他们必须选择下一个最近的急救包。

  • 行 23:如果没有急救包可用或健康值已恢复到安全极限,敌人将返回到空闲状态。

SeekHealth状态要求我们找到并检索场景中最近的急救包的引用。这是通过使用GetNearestHealthRestore方法实现的,如下面的代码示例 7-13 所示:

01 //Function to get nearest health restore to Target in scene
02 private HealthRestore GetNearestHealthRestore(Transform Target)
03 {
04 //Get all health restores
05 HealthRestore[] Restores = Object.FindObjectsOfType<HealthRestore>();

06 
07 //Nearest
08 float DistanceToNearest = Mathf.Infinity;
09 
10 //Selected Health Restore
11 HealthRestore Nearest = null;
12 
13 //Loop through all health restores
14 foreach(HealthRestore HR in Restores)
15 {
16        //Get distance to this health restore
17 float CurrentDistance = Vector3.Distance(Target.position, HR.transform.position);

18 
19        //Found nearer health restore, so update
20        if(CurrentDistance <= DistanceToNearest)
21        {
22              Nearest = HR;
23              DistanceToNearest = CurrentDistance;
24        }
25 }
26 
27 //Return nearest or null
28 return Nearest;
29 }

总结

本章创建的完整 AI 项目可以在本书的配套文件(代码包)中找到,位于本章的ai文件夹中。我建议您打开它并进行测试。使用第一人称控制器,玩家可以导航关卡,避开敌人,并且当敌人进入射程时可以使用空格键进行攻击,如下所示:

总结

测试 AI_Enemy 类

尽管如此,还有很多方法可以进一步改进项目,例如,通过添加多种敌人类型以及每种类型的多种策略,从躲避到装死,等等。然而,尽管如此,我们已经走了很长的路,并开发了依赖于 C# FSM 以及 Mecanim FSM 进行动画播放的人工智能。

在下一章中,我们将跳出 AI 的世界,进入编辑器自定义的世界,以使游戏开发更加顺畅!

第八章. 定制 Unity 编辑器

Unity 编辑器是一个功能强大、通用的游戏开发工具。然而,在开发过程中,有时你可能希望编辑器提供一些它没有的功能,或者以特定的方式运行,仅仅是因为这对您和您的特定游戏来说会更加方便。也许您希望有路径编辑功能、批量重命名功能或网格创建工具等。在这种情况下,您可以在 Asset Store 中搜索满足您需求的插件。但即使如此,您可能仍然找不到您需要的东西。因此,关注点转向了如何调整或定制编辑器以实现您的目的。幸运的是,Unity 作为工具有很多可以改变的地方,本章重点介绍了特定的案例研究。首先,它探讨了如何创建一个批量重命名工具,以便在一次操作中重命名多个选定的对象。其次,它涵盖了如何在对象检查器中创建一个颜色范围字段,使用滑块在两种颜色之间混合。第三,它探讨了如何在外部检查器中公开 C#属性,以便设置和获取值。最后,它涵盖了如何使用 C#属性创建一个本地化工具包,允许您通过点击按钮自动将所有游戏中的字符串更改为所选语言(英语、法语等)。

批量重命名

当创建包含多个敌人、增益物品、道具或其他对象实例的场景时,你通常会使用复制功能来克隆对象(Ctrl + D)。这会导致许多对象具有相同的名称。现在,虽然名称重复本身在技术上并没有错误,但它既不方便又杂乱无章。它会导致具有相同名称的对象层次面板,而且仅凭名称几乎无法区分特定的对象。此外,在脚本中使用GameObject.Find函数进行对象搜索时,不能依赖于检索到所需的特定对象,因为它可能返回任何具有相同名称的对象。因此,解决方案是为每个对象命名唯一且适当。但这样做可能会很繁琐,尤其是当你处理许多对象时。因此,需要一个批量重命名工具。

理论上,这将允许你在层次面板中选择多个对象,然后根据编号约定自动重命名它们。这个方法唯一的技術问题是 Unity 本身并不原生支持这样的功能。但我们可以自己编写代码来实现,如下面的截图所示:

批量重命名

创建批量重命名编辑器插件

要开始自定义 Unity 编辑器,首先在项目内创建一个名为 Editor 的文件夹。这很重要。Editor 是一个被 Unity 识别为存放所有自定义脚本的特殊文件夹。因此,如果你打算更改 Unity 编辑器,请确保所有自定义脚本都位于 Editor 文件夹内。你的项目可以有多个名为 Editor 的文件夹,但唯一重要的是至少有一个 Editor 文件夹,并且其中包含一个编辑器脚本,如下所示:

批量重命名

为所有编辑器脚本创建一个编辑器文件夹

接下来,我们将从 ScriptableWizard 类创建一个批量重命名实用工具。这个类是我们从中派生新类的一个祖先。所有派生类都将像可以从 Unity 主菜单启动的弹出实用工具对话框一样工作。它们的目的是在用户按下执行一次性过程的确认按钮之前,向用户提供一组选项。换句话说,从 ScriptableWizard 派生的类非常适合对单个或多个对象执行自动化的、一次性操作。

小贴士

更多关于 ScriptableWizard 类的信息可以在 Unity 在线文档中找到,网址为 docs.unity3d.com/ScriptReference/ScriptableWizard.html

以下代码示例 8-1 列出了批量重命名实用工具的完整源代码:

01 //------------------------------------
02 using UnityEngine;
03 using UnityEditor;
04 using System.Collections;
05 //------------------------------------
06 public class BatchRename : ScriptableWizard
07 {
08 //Base name
09 public string BaseName = "MyObject_";
10 
11 //Start Count
12 public int StartNumber = 0;
13 
14 //Increment
15 public int Increment = 1;
16 
17 [MenuItem("Edit/Batch Rename...")]
18     static void CreateWizard()
19     {
20         ScriptableWizard.DisplayWizard("Batch Rename",typeof(BatchRename),"Rename");
21     }
22 //------------------------------------
23 //Called when the window first appears
24 void OnEnable()
25 {
26       UpdateSelectionHelper();
27 }
28 //------------------------------------
29 //Function called when selection changes in scene
30 void OnSelectionChange()
31 {
32       UpdateSelectionHelper();
33 }
34 //------------------------------------
35 //Update selection counter
36 void UpdateSelectionHelper()
37 {
38        helpString = "";
39 
40        if (Selection.objects != null)
41 helpString = "Number of objects selected: " + Selection.objects.Length;
42 }
43 //------------------------------------
44 //Rename
45 void OnWizardCreate()

46 {
47       //If selection empty, then exit
48       if (Selection.objects == null)
49              return;
50 
51       //Current Increment
52       int PostFix = StartNumber;
53 
54       //Cycle and rename
55       foreach(Object O in Selection.objects)
56       {
57              O.name = BaseName + PostFix;
58              PostFix += Increment;
59       }
60 }
61 //------------------------------------
62 }
63 //------------------------------------

以下是对代码示例 8-1 的注释:

  • 第 03 行:编辑器扩展应该包含 UnityEditor 命名空间,这允许你访问编辑器类和对象。

  • 第 06 行BatchRename 类并非从 MonoBehaviour 继承,这与大多数脚本文件不同,而是从 ScriptableWizard 继承。从这里继承的类将被 Unity 视为独立的工具,可以从应用程序菜单启动。

  • 第 17-21 行MenuItem 属性作为 CreateWizard 函数的前缀。这将在应用程序菜单中创建一个条目,列在 编辑/批量重命名 下,并在点击时调用 CreateWizard 函数以显示 批量重命名 窗口。

  • 第 8-16 行:在调用 CreateWizard 之后,BatchRename 窗口显示。从这里,所有公共类成员(包括 基础名称起始数字增量)将自动作为可编辑字段出现在窗口中,供用户编辑。

  • 第 45-60 行:当用户从 批量重命名 窗口中按下 重命名 按钮时,将调用 OnWizardCreate 函数作为事件。按钮被称为 重命名 是因为第 20 行。OnWizardCreate 函数遍历场景中所有选定的对象(如果有),并根据 基础名称起始数字增量字段按顺序重命名它们,如下所示:批量重命名

    批量重命名工具

要使用批量重命名工具,只需在场景中选择一组对象,然后从应用程序菜单中的编辑选项点击批量重命名基础名称字段定义了一个需要添加到所有对象名称前缀的字符串,而增量字段定义了整数计数器应该增加的量,该计数器将添加到基础名称前。起始数字值是所有增量开始的位置,如下面的截图所示:

批量重命名

使用批量重命名工具重命名的对象

C#属性和反射

从本章的这个点开始,所有编辑器扩展都将大量依赖属性和反射的概念。这些概念不仅限于 Unity,而是指计算机科学、编程以及它们在 C#语言以及.NET 框架中的应用中的更一般性思想。在继续下一个编辑器扩展之前,让我们通过Range属性(这是 Unity 的固有属性)的例子来考虑属性和相关概念。考虑以下代码行:

public float MyNumber = 0;

这个公共变量将在对象检查器中显示,并带有允许用户输入任何有效浮点数的编辑字段,从而设置MyNumber的值,如下面的截图所示:

C#属性和反射

在对象检查器中输入浮点值

此代码运行良好,并且在许多情况下都适用,但有时验证数值输入到特定范围内(在最小值和最大值之间剪辑数字)更可取。您可以在代码中使用Mathf.Clamp函数来完成此操作,但您也可以使用属性来验证输入。您可以将Range属性附加到浮点变量(MyNumber)上,以显示滑块而不是编辑框,如下面的代码所示:

 [Range(0f,1f)]
public float MyNumber = 0;

小贴士

更多关于属性的信息可以在 Unity 在线文档中找到,请参阅unity3d.com/learn/tutorials/modules/intermediate/scripting/attributes

当此代码编译时,MyNumber变量在对象检查器中的显示方式不同,遵循01之间的数值范围,如下面的截图所示。请注意,提供给Range属性作为参数的所有数字都必须是编译时已知的显式值,而不是依赖于变量(这些变量可以在运行时变化)的表达式。所有属性值都必须在编译时已知。

C#属性和反射

使用属性自定义检查器显示

那么属性是如何工作的呢?简而言之,属性是一种元数据的形式;它们就像标签。程序员可以将属性附加到类、变量或方法上,以将数据与之关联,这是编译器所知道的。属性本身完全是描述性的,因为它什么都不做;它只是数据。属性的重要性在于,所有基于.NET(或 Mono)的代码都有能力跳出自身,变得有自我意识,也就是说,能够查看程序内部包含的所有类、数据类型和实例。对于程序中的每个对象,其元数据(属性)都可以被查询和检查。程序能够“从外部观察自己”的能力被称为反射,就像在镜子中观察一样。当然,程序并不是以相反或扭曲的方式看待自己,而是以它真正的方式,包括所有其元数据。为了快速举例说明反射,尝试以下代码示例 8-2。此代码将遍历 Unity 应用程序中所有源文件中的所有自定义类。注意,它不仅列出了场景中类的所有实例,还列出了所有类本身(即蓝图,比喻来说):

01 using UnityEngine;
02 using System.Collections;
03 using System.Reflection;
04 using System;
05 
06 public class MyTestScript : MonoBehaviour 
07 {
08 // Use this for initialization
09 void Start () 
10 {
11        //List all classes in assembly
12 foreach(Type t in Assembly.GetExecutingAssembly().GetTypes())

13        {
14              Debug.Log (t.Name);
15        }
16 }
17 }

以下是对代码示例 8-2 的注释:

  • 第 03-04 行:应该包含SystemSystem.Reflection这两个命名空间,因为它们包含执行.NET 中反射所需的所有类和对象。

  • 第 12 行:这个foreach循环遍历活动程序集(即编译后的代码,包括所有自定义的脚本文件)中的所有类(类型)。

你甚至可以将反射的概念进一步扩展。例如,在列出代码示例 8-2 中的所有类型之后,你甚至可以列出类型的所有方法、属性和变量(Fields)。参考以下代码示例 8-3,它接受一个特定的类型作为参数,并将列出所有公共成员变量:

 //Function to list all public variables for class t
 public void ListAllPublicVariables(Type t)
 {
    //Loop through all public variables
    foreach(FieldInfo FI in t.GetFields(BindingFlags.Public | BindingFlags.Instance)
    {
         //Print name of variable
         Debug.Log (FI.Name);
    }
 }

小贴士

关于位运算的更多信息,如本代码示例中所用,可以在以下网址找到:www.blackwasp.co.uk/CSharpLogicalBitwiseOps.aspx

然而,最重要的是,你还可以列出分配给类型的属性。这让你可以在运行时查询类型的元数据并检查其属性,如下面的代码示例 8-4 所示:

01 public void ListAllAttributes(Type t)
02 {
03 foreach(Attribute attr in t.GetCustomAttributes(true))
04 {
05        //List the type of attribute found
06        Debug.Log (attr.GetType());
07 }
08 }

代码示例 8-4 展示了在运行时可以从代码中检索给定数据类型的所有属性数据。这意味着数据类型和变量可能与其关联元数据,这些元数据可以被检索并用于进一步影响对象的处理方式。这对于编辑器插件来说非常强大,因为通过创建可以附加到数据类型和成员变量上的自定义定义属性,我们可以在不破坏其逻辑或运行时结构的情况下将我们的代码与 Unity 编辑器集成。也就是说,我们可以通过在代码中使用属性标记变量来自定义它们在 Unity 编辑器中的显示方式,而不会在运行时逻辑或结构上无效化或影响它。接下来,我们将看到如何创建自定义属性来自定义编辑器。

颜色混合

之前探索的 Range 属性可以通过其声明方式附加到整数和浮点变量上,以限制在 Unity 编辑器中它们的最小值和最大值之间的接受值。在 Unity 编辑器中,一个滑动控件代替了可编辑字段,用于控制变量的接受值。当然,这不会影响代码中分配给相同变量的值。在代码中,在运行时,Range 属性本身没有效果。相反,Range 属性仅控制数值公共变量在对象检查器中的显示方式以及用户输入时如何输入。在幕后,一个 Editor 类通过反射查询对象的 Attribute 数据,以控制数据类型在对象检查器中的渲染方式。

Range 属性在处理数字时表现良好。但若能将其类似行为应用于除数字之外的其他数据类型,那就更完美了。例如,在场景过渡时,从黑色渐变到透明度以创建淡入和淡出效果是很常见的。这被称为颜色线性插值(Color Lerping)。也就是说,通过一个归一化的浮点数(介于 01 之间)在两个极端颜色之间生成一个中间颜色。

对于此类数据类型,合适的 Inspector 属性将是一个滑动控件,类似于 Range 属性,它控制 01 之间的插值颜色,如下所示:

颜色混合

在两种颜色之间进行线性插值

因此,本质上,我们需要自定义编辑器,以便在场景中选中一个对象时,该对象具有我们指定的自定义类型的公共成员,我们希望自定义该成员在对象检查器中的渲染方式。这让我们能够在对象检查器中提供自定义控件和输入,从而验证该成员的数据输入,而不是简单地接受其默认值。为了开始这个过程,让我们创建一个自定义类,并定义整个颜色混合的所有数据。颜色混合需要四个变量,即标记混合范围的 SourceColorDestColor。接下来,BlendFactor 是一个介于 01(起始和结束)之间的归一化浮点数,它决定了应该通过插值生成哪个中间颜色。然后,最后,输出颜色本身(BlendedColor)。此过程的完整类定义包含在下面的代码示例 8-5 中:

[System.Serializable]
 public class ColorBlend : System.Object
 {
    public Color SourceColor = Color.white;
    public Color DestColor = Color.white;
    public Color BlendedColor = Color.white;
    public float BlendFactor = 0f;
 }

由于 ColorBlend 类使用了 [System.Serializable] 属性,当它作为类的公共成员添加时,Unity 将自动在对象检查器中渲染该类及其成员。默认情况下,ColorBlend 的所有公共成员都将被渲染,BlendFactor 字段将作为一个可编辑的字段渲染,可以直接在其中输入数字,包括 01 之外的数字,如下所示:

颜色混合

通过默认值和更改其属性来公开 Color Adjuster

现在,让我们开始自定义 Unity 如何在对象检查器中渲染此类。首先,创建一个新的属性类,命名为 ColorRangeAttribute,如下面的代码示例 8-6 所示:

01 public class ColorRangeAttribute : PropertyAttribute
02 {
03 //------------------------------------------------------------
04 public Color Min;
05 public Color Max;
06 //------------------------------------------------------------
07 public ColorRangeAttribute(float r1, float g1, float b1, float a1,
08                            float r2, float g2, float b2, float a2)
09 {
10       this.Min = new Color(r1, g1, b1, a1);
11       this.Max = new Color(r2, g2, b2, a2);
12 }
13 //------------------------------------------------------------
14 }

以下是对代码示例 8-6 的注释:

  • 行 01: ColorRangeAttribute 类定义了一个元数据结构,我们可以将其标记到其他数据类型上。注意,它继承自 PropertyAttribute。这表明,除了其他一切之外,ColorRangeAttribute 是一个属性和元数据结构,而不是一个常规类。它不应该像常规类那样被实例化。

  • 行 07: 该属性有一个构造函数,它接受八个浮点值,用于定义 Lerp 的源和目标颜色的 RGBA 通道。这些值将在将属性附加到变量时很快被使用。

现在,我们将编写一个声明带有 ColorRangeAttribute 属性的 ColorBlend 实例的类。然而,即使现在,添加 ColorRangeAttribute 本身也不会产生任何效果,因为没有编写处理它的 Editor 类。这可以在下面的代码中看到:

public class ColorAdjuster : MonoBehaviour 
{
   [ColorRangeAttribute(1f,0f,0f,0f,   0f,1f,0f,1f)]
   public ColorBlend MyColorBlend;
}

在对象检查器中使用滑动控件渲染 ColorBlendEditor 类涉及处理 ColorRangeAttribute 类。具体来说,Unity 提供了 PropertyDrawer 基类扩展,我们可以从中派生新类以覆盖任何添加到变量中的特定属性的 Object Inspector 渲染。简而言之,PropertyDrawer 类让我们可以为带有公共属性的任何变量自定义检查器绘制。因此,在您的项目 Editor 文件夹中创建一个新的 ColorRangeDrawer 类,如下面的代码示例 8-7 所示:

01 using UnityEngine;
02 using UnityEditor; //Be sure to include UnityEditor for all extension classes
03 using System.Collections;
04 //------------------------------------------------------------
05 //CustomPropertyDrawer attribute for overriding drawing of all ColorRangeAttribute members

06 [CustomPropertyDrawer(typeof(ColorRangeAttribute))]
07 public class ColorRangeDrawer : PropertyDrawer
08 {
09 //------------------------------------------------------------
10 //Event called by Unity Editor for updating GUI drawing of controls

11 public override void OnGUI (Rect position, SerializedProperty property, GUIContent label)

12 {
13        //Get color range attribute meta data
14 ColorRangeAttribute range  = attribute as ColorRangeAttribute;
15 
16        //Add label to inspector
17 position = EditorGUI.PrefixLabel (position, new GUIContent ("Color Lerp"));
18 
19        //Define sizes for color rect and slider controls
20 Rect ColorSamplerRect = new Rect(position.x, position.y, 100, position.height);

21 Rect SliderRect = new Rect(position.x+105, position.y, 200, position.height);
22 
23       //Show color rect control
24 EditorGUI.ColorField(ColorSamplerRect, property.FindPropertyRelative("BlendedColor").colorValue);
25 
26      //Show slider control
27 property.FindPropertyRelative("BlendFactor").floatValue = EditorGUI.Slider(SliderRect, property.FindPropertyRelative("BlendFactor").floatValue, 0f, 1f);
28 
29      //Update blended color based on slider
30 property.FindPropertyRelative("BlendedColor").colorValue = Color.Lerp(range.Min, range.Max, property.FindPropertyRelative("BlendFactor").floatValue);
31 }
32 //------------------------------------------------------------
33 }
34 //------------------------------------------------------------

以下是对代码示例 8-7 的注释:

  • 第 01 行: 这里使用 CustomPropertyDrawer 属性将 PropertyDrawer 类与 ColorRangeAttribute 属性关联。Unity 编辑器使用此元数据内部确定哪些类型需要在对象检查器中进行自定义渲染。在这种情况下,所有带有 ColorRangeAttribute 的成员都将由 PropertyDrawer 类的 OnGUI 函数手动绘制。

  • 第 11 行: OnGUI 函数是从基类重写的,用于定义所有带有 ColorRangeAttribute 的字段在对象检查器中应该如何渲染。EditorGUI 是一个原生的 Unity 编辑器实用工具类,用于绘制 GUI 元素,如按钮、文本框和滑动条。有关 EditorGUI 的更多信息,请参阅在线文档docs.unity3d.com/ScriptReference/EditorGUI.html

  • 第 14 行: OnGUI 函数会被调用一次,可能每秒调用多次,用于在对象检查器中手动渲染每个唯一的成员。在这里,使用类型转换检索 ColorRangeAttribute 的属性数据,这使我们能够直接访问当前正在渲染的对象的所有成员。要访问对象本身的成员变量(用于读写访问),而不是其属性,应使用 SerializedProperty 参数,例如 FindPropertyRelative 方法。有关更多信息,请参阅在线 Unity 文档docs.unity3d.com/ScriptReference/SerializedProperty.html

  • 第 24 行: 从这里开始,使用 FindPropertyRelative 函数来检索所选对象中的公共成员变量,例如 SourceColorDestColorBlendedColor。这是通过移动滑动组件来实际设置值的。

    提示

    关于 PropertyDrawer 类的更多信息,可以在在线 Unity 文档docs.unity3d.com/Manual/editor-PropertyDrawers.html中找到。

代码示例 8-7 在标记有ColorRangeAttribute属性时覆盖了任何ColorBlend实例的对象检查器绘制。这提供了一种易于访问和使用的创建混合颜色的方法。记住,你可以使源颜色和目标颜色公共,以便从检查器选项卡中访问,如下所示:

颜色混合

为 ColorBlend 类创建 ColorBlender 显示

属性暴露

默认情况下,对象检查器会显示类的所有公共成员变量,除非处于调试模式或私有成员被显式标记为具有SerializeField属性,在这些情况下,私有成员变量也会显示:

属性暴露

从对象检查器访问属性

然而,C#属性在默认情况下永远不会显示,无论是发布还是调试模式。如第一章中所述,Unity C# 快速入门,C#属性类似于变量的访问器函数。它们本质上允许在每个getset操作上进行验证,因为每个getset操作都涉及内部函数调用。然而,无论 Unity 在对象检查器中的限制如何,都可以编写一个编辑器扩展,该扩展将在对象检查器中显示类的所有属性,从而允许直接获取和设置值。本节将更详细地考虑这一点。再次强调,我们将严重依赖反射。

小贴士

关于SerializeField类的更多信息可以在 Unity 在线文档中找到,网址为docs.unity3d.com/ScriptReference/SerializeField.html

考虑以下代码示例 8-8,其中包含一些属性:

 //----------------------------------------------
 using UnityEngine;
 using System.Collections;
 //----------------------------------------------
 [System.Serializable]
 public class ClassWithProperties : System.Object
 {
    //Class with some properties
    //----------------------------------------------
 public int MyIntProperty
    {
          get{return _myIntProperty;}

          //Performs some validation on values
    set{if(value <= 10)_myIntProperty = value;else _myIntProperty=0;}
    }
    //----------------------------------------------
    public float MyFloatProperty
   {
          get{return _myFloatProperty;}
          set{_myFloatProperty = value;}
   }
    //----------------------------------------------
 public Color MyColorProperty
   {
          get{return _myColorProperty;}
          set{_myColorProperty = value;}
    }
    //----------------------------------------------
    //Private members
    private int _myIntProperty;
    private float _myFloatProperty;
    private Color _myColorProperty;
    //----------------------------------------------
 }
 //----------------------------------------------

此类将作为公共成员由不同的类内部使用,如下面的代码示例 8-9 所示:

 using UnityEngine;
 using System.Collections;

 public class LargerClass : MonoBehaviour 
 {
    public ClassWithProperties MyPropClass;
 }

默认情况下,公共MyPropClass成员(尽管标记为System.Serializable)在对象检查器中不会显示其成员。这是因为 C#属性不是原生支持的:

属性暴露

默认情况下,对象检查器不会渲染 C#属性

为了解决这个问题,我们可以回到PropertyDrawer类;这次将类与一个特定的类相关联,而不是与一个属性相关联,如下面的代码示例 8-10 所示:

01 //Custom Editor class to expose global properties of a class
02 //----------------------------------------------
03 using UnityEngine;
04 using UnityEditor;
05 using System.Collections;
06 using System.Reflection;
07 //----------------------------------------------
08 [CustomPropertyDrawer(typeof(ClassWithProperties))]
09 public class PropertyLister : PropertyDrawer
10 {
11 //Height of inspector panel
12 float InspectorHeight = 0;
13 
14 //Height of single row in pixels
15 float RowHeight = 15;
16 
17 //Spacing between rows
18 float RowSpacing = 5;
19 
20 // Draw the property inside the given rect
21 public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 
22 {
23        EditorGUI.BeginProperty(position, label, property);
24 
25        //Get referenced object
26        object o = property.serializedObject.targetObject;
27 ClassWithProperties CP = o.GetType().GetField(property.name).GetValue(o) as ClassWithProperties;
28 
29        int indent = EditorGUI.indentLevel;
30        EditorGUI.indentLevel = 0;
31 
32       //Layout
33 Rect LayoutRect = new Rect(position.x, position.y, position.width, RowHeight);
34 
35       //Find all properties for object
36 foreach(var prop in typeof(ClassWithProperties).GetProperties(BindingFlags.Public | BindingFlags.Instance))
37       {
38              //If integer property
39              if(prop.PropertyType.Equals(typeof(int)))
40              {
41 prop.SetValue(CP, EditorGUI.IntField(LayoutRect, prop.Name, (int)prop.GetValue(CP,null)), null);

42 LayoutRect = new Rect(LayoutRect.x, LayoutRect.y + RowHeight+RowSpacing, LayoutRect.width, RowHeight);
43              }
44 
45             //If float property
46             if(prop.PropertyType.Equals(typeof(float)))
47             {
48 prop.SetValue(CP, EditorGUI.FloatField(LayoutRect, prop.Name, (float)prop.GetValue(CP,null)), null);

49 LayoutRect = new Rect(LayoutRect.x, LayoutRect.y + RowHeight+RowSpacing, LayoutRect.width, RowHeight);
50             }
51 
52             //If color property
53              if(prop.PropertyType.Equals(typeof(Color)))
54              {
55 prop.SetValue(CP, EditorGUI.ColorField(LayoutRect, prop.Name, (Color)prop.GetValue(CP,null)), null);

56 LayoutRect = new Rect(LayoutRect.x, LayoutRect.y + RowHeight+RowSpacing, LayoutRect.width, RowHeight);
57             }
58        }
59 
60        //Update inspector height
61        InspectorHeight = LayoutRect.y-position.y;
62 
63        EditorGUI.indentLevel = indent;
64        EditorGUI.EndProperty();
65 }
66 //----------------------------------------------
67 //This function returns how high (in pixels) the field should be
68 //This is to make controls not overlap
69 public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
70 {
71        return InspectorHeight;
72 }
73 //----------------------------------------------
74 }
75 //----------------------------------------------

以下是对代码示例 8-10 的注释:

  • 第 08 行:注意,CustomPropertyDrawer属性现在与一个常规类相关联,而不是一个属性。在这种情况下,特定类的渲染是为了对象检查器定制的,而不是不同类型的不同属性,这些属性可以共享一个公共属性。

  • 第 12-18 行: 声明了一些公共成员,主要是为了计算对象检查器中单行的高度(以像素为单位)。默认情况下,对象检查器为我们的自定义渲染分配一行(或一行),并且所有绘图都应该适应这个空间。如果我们的渲染总高度超过一行的高度,所有额外的控件和数据都将重叠并混合在下面的控件和小部件中。为了解决这个问题,可以使用GetPropertyHeight(第 69 行)函数来返回为我们的自定义绘图分配的像素高度。

  • 第 26-27 行: 这些行特别重要。它们使用反射来检索对当前正在为OnGUI调用绘制的ClassWithProperties实例的正确类型对象引用。具体来说,检索到targetObject的引用(选中的对象),然后从该引用中检索到ClassWithProperties的实例。结果是,这段代码为我们提供了对ClassWithProperties对象的直接和即时访问。

  • 第 37-58 行: 对象上的每个公共属性按顺序循环,对于有效或支持的数据类型,绘制一个检查器属性,允许对属性的读写访问,前提是该属性本身支持这两种方法。

以下截图显示了 C#属性:

属性展示

访问 C# 属性

本地化

游戏开发中最被低估和最缺乏文档记录的方面之一可能是本地化。这指的是开发者为了支持游戏中的多种自然语言而采取的广泛的技术、经济和物流措施,例如英语、法语、德语、西班牙语、世界语等等。技术目标并不是支持这个或那个特定的语言,而是建立一个可以支持在任何时候(现在或以后)选择的任何任意语言的架构。本地化在开发中的整个范围和作用超出了本书的范围,但在这里我们将探讨一种 Unity 编辑器可以定制以简化本地化工作流程的方法。例如,考虑以下示例 XML 文件,其中主菜单系统中按钮的游戏文本以英语和一种称为 Yoda 的“恶搞语言”定义:

<?xml version="1.0"?>
<text>
    <language id="english">
         <text_entry id="text_01"><![CDATA[new game]]></text_entry>
         <text_entry id="text_02"><![CDATA[load game]]></text_entry>
         <text_entry id="text_03"><![CDATA[save game]]></text_entry>
         <text_entry id="text_04"><![CDATA[exit game]]></text_entry>
   </language>
   <language id="yoda">
         <text_entry id="text_01"><![CDATA[new game, you start]]></text_entry>
         <text_entry id="text_02"><![CDATA[load game, you will]]></text_entry>
         <text_entry id="text_03"><![CDATA[game save, you have]]></text_entry>
         <text_entry id="text_04"><![CDATA[leave now, you must]]></text_entry>
   </language>
</text>

小贴士

注意,CDATA 元素包围了所有自定义文本节点,以允许使用任何字符和符号。有关 CDATA 的更多信息,可以在网上找到:www.w3schools.com/xml/xml_cdata.asp

之前定义的 XML 创建了四个文本元素,每个元素对应于示例用户界面菜单上的一个按钮。每个文本元素都被分配了一个唯一的 ID:text_01text_02text_03text_04。这些 ID 唯一地标识了游戏中的每个文本项,并且将在所有指定的语言中匹配。这里的目的是将文本文件导入 Unity,允许开发者通过点击按钮在语言之间切换,并且让游戏中的所有相关文本元素自动更改以适应语言切换。让我们看看它是如何工作的。

首先将本地化文本导入 Unity 项目中的Resources文件夹。创建一个名为Resources的文件夹,然后将本地化文本文件导入其中,如图所示。在代码中,这意味着任何对象或类都可以使用Resources.Load调用加载或打开文本文件,正如我们很快将看到的。

本地化

将本地化文本导入项目

小贴士

更多关于资源的信息可以在 Unity 文档的在线资源中找到,请参阅docs.unity3d.com/ScriptReference/Resources.html

导入的文本文件简单地包含了要包含在游戏中的所有文本数据,其中每个元素都与它的 ID 相关联。因此,每个字符串值都与一个 ID 相关联,并且 ID 在语言方案中是一致的,这允许语言之间的无缝过渡。ID 是使自动化本地化成为可能的一个共同分母。为了在代码中实现本地化系统,我们首先创建一个应该应用于所有本地化字符串的属性。该属性仅定义要附加到特定字符串变量的 ID,如以下代码示例 8-11 所示:

using UnityEngine;
 using System.Collections;

 //Attribute to attach to string objects
 public class LocalizationTextAttribute : System.Attribute
 {
    //ID to assign
    public string LocalizationID = string.Empty;

    //Constructor
    public LocalizationTextAttribute(string ID)
    {
           LocalizationID = ID;
   }
 }

现在已经创建了LocalizationTextAttribute属性,我们可以在代码中将它应用于字符串成员,将它们与特定的 ID 关联起来,如以下代码示例 8-12 所示:

//----------------------------------------------
 using UnityEngine;
 using System.Collections;
 //----------------------------------------------
 public class SampleGameMenu : MonoBehaviour 
 {
    [LocalizationTextAttribute("text_01")]
    public string NewGameText = string.Empty;

    [LocalizationTextAttribute("text_02")]
    public string LoadGameText = string.Empty;

   [LocalizationTextAttribute("text_03")]
   public string SaveGameText = string.Empty;

    [LocalizationTextAttribute("text_04")]
    public string ExitGameText = string.Empty;
 }
 //----------------------------------------------

SampleGameMenu类在对象检查器中显示为一个普通类,如图所示。稍后,通过我们的Editor类,我们将开发自动更改所有字符串成员到所选语言的能力。

本地化

SampleGameMenu 类包含示例菜单屏幕所需的所有纹理

现在,我们将编写一个Editor类来在语言之间切换。这个类将在应用程序菜单上添加菜单项,当点击时将更改活动语言,如以下代码示例 8-13 所示。这个示例借鉴了我们已经看到的一系列相关概念,包括新的概念。具体来说,它使用了ReflectionLinqEditor类以及 Mono 框架 XML 处理类:

01 //-------------------------------------------
02 using UnityEngine;
03 using UnityEditor;
04 using System.Collections;
05 using System.Xml;
06 using System.Linq;
07 using System.Reflection;
08 //-------------------------------------------
09 public class LanguageSelector
10 {
11 [MenuItem ("Localization/English")]
12 public static void SelectEnglish()
13 {
14        LanguageSelector.SelectLanguage("english");
15 }
16 
17 [MenuItem ("Localization/French")]
18 public static void SelectFrench()
19 {
20        LanguageSelector.SelectLanguage("french");
21 }
22 
23 [MenuItem ("Localization/Yoda")]
24 public static void SelectYoda()
25 {
26        LanguageSelector.SelectLanguage("yoda");
27 }
28 
29 public static void SelectLanguage(string LanguageName)
30 {
31        //Access XML Text File in Project
32 TextAsset textAsset = Resources.Load("LocalText") as TextAsset;
33 
34        //Load text into XML Reader object
35         XmlDocument xmlDoc = new XmlDocument();
36         xmlDoc.LoadXml(textAsset.text);
37 
38       //Get language nodes
39 XmlNode[] LanguageNodes = (from XmlNode Node in xmlDoc.GetElementsByTagName("language")

40 where Node.Attributes["id"].Value.ToString().Equals(LanguageName.ToLower())
41       select Node).ToArray();
42 
43        //If no matching node found, then exit
44        if(LanguageNodes.Length <= 0)
45              return;
46 
47       //Get first node
48       XmlNode LanguageNode = LanguageNodes[0];
49 
50      //Get text object
51 SampleGameMenu GM = Object.FindObjectOfType<SampleGameMenu>() as SampleGameMenu;
52 
53      //Loop through child xml nodes
54      foreach (XmlNode Child in LanguageNode.ChildNodes)
55        {
56              //Get text Id for this node
57              string TextID = Child.Attributes["id"].Value;
58               string LocalText = Child.InnerText;
59 
60              //Loop through all fields
61 foreach(var field in GM.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy))
62              {
63                    //If field is a string then is relevant
64                    if(field.FieldType == typeof(System.String))
65                    {
66                          //Get custom attributes for field
67 System.Attribute[] attrs = field.GetCustomAttributes(true) as System.Attribute[];

68 
69                    foreach (System.Attribute attr in attrs)
70                    {
71        if(attr is LocalizationTextAttribute)
72                                 {
73                                     //We've found text
74 LocalizationTextAttribute LocalAttr = attr as LocalizationTextAttribute;
75 
76               if(LocalAttr.LocalizationID.Equals( TextID ))
77                           {
78                          //id matches, now set value
79                          field.SetValue(GM, LocalText);
80                           }
81                     }
82              }
83              }
84       }
85       }
86 }
87 }
88 //-------------------------------------------

以下是对代码示例 8-13 的注释:

  • 行 02-07:请记住包括广泛的使用范围,如所示。我们的代码将在某种程度上依赖于它们。

  • 第 11-23 行:对于此示例应用程序,可以从应用程序菜单中选择三种语言:英语法语Yoda。对于您自己的项目,您的语言列表可能不同。但关键的是,根据此处提供的本地化系统,即使在较晚的时间,集成额外的语言也很容易。

  • 第 32 行:在此处调用Resources.Load函数以从项目中的Resources文件夹打开 XML 文本文件,并将其文本内容提取到一个单一的连接字符串变量中。

  • 第 35-36 行:XML 字符串被加载到一个XmlDocument对象中,这是一个封装了完整 XML 文件的 Mono 类,无论是磁盘上的还是内存中的。该类在加载时还会验证文档,这意味着如果文件包含语法错误,将在此处生成异常。

  • 第 53 行:一旦从 XML 文件中选择了一种语言,就会遍历该语言的子节点(每个节点都是唯一的字符串)以找到匹配的 ID。

  • 第 61 行:对于每个字符串条目,都会在文本类的所有公共字符串成员中搜索合适的LocalizationTextAttribute,当找到时,将字符串 ID 与现有 ID 进行比较以检查匹配。当找到匹配项时,字符串变量将被分配相应的本地化字符串。

要使用此处提供的本地化框架,首先将一个SampleGameMenu对象添加到场景中,如图所示:

本地化

将带有本地化文本成员的示例游戏菜单对象添加到场景中

然后,通过在本地化标签页中选择英语Yoda,从应用程序主菜单中选择一种语言,如图所示:

本地化

设置游戏的活动语言

一旦指定了活动语言,所有带有LocalizationTextAttribute属性的字符串都将更新,如下面的屏幕截图所示:

本地化

选择活动语言后更新本地化文本

摘要

本章深入探讨了作为概念的反射与其实际应用之间的关系,即创建扩展编辑器行为的Editor类,使其能够适应自定义意图,而不仅仅是默认行为。这种能力对于构建 Unity 游戏本身并非总是必需的,但它可以使你的工作更加轻松。此外,如果你希望开发能够帮助其他开发者的自定义插件,这也可以通过 Asset Store 带来盈利。在这里,你看到了如何使用ScriptableWizard类创建批量重命名工具,以及为对象检查器添加颜色混合属性。接下来,我们广泛使用了反射来暴露对象检查器中所有公共的 C#属性,这使我们能够直接访问设置和获取属性值,就像我们在运行时访问它们一样。进一步地,我们探讨了如何通过Editor类从 XML 文件实现本地化框架,这些类允许字符串变量自动更改以匹配所选语言。更多信息,您可以访问catlikecoding.com/unity/tutorials/editor/custom-data/catlikecoding.com/unity/tutorials/editor/custom-list/。在下一章中,我们将带着概念和技术上的负担,从更不寻常的角度探索 2D 世界。

第九章. 使用纹理、模型和 2D

今天的大多数游戏引擎都将它们的功能集定位为 3D 游戏而不是广义上的 2D。这往往具有讽刺意味,使得 3D 任务和工作流程比 2D 任务和工作流程更简单,至少在最初开始时是这样。在本章中,我们将探讨一些 2D 问题,但有一些限制。自从 Unity 4.3 发布以来,编辑器中已经添加了许多 2D 功能:最初是一个本机精灵系统,然后是一个新的 GUI 系统。虽然这两个系统在它们各自的方式中都很有用,但本章的主要焦点不会特别关注这些功能。第一个原因是许多教程已经相当详细地解释了它们,但第二个也是最重要的原因是,即使在添加了 2D 功能之后,关于在更宽松的 2D 中工作的基本问题仍然存在。这些问题包括如何操纵 2D 平面的几何形状,如顶点和边,如何调整和动画纹理坐标,如何编辑纹理,以及如何使用类似画笔的系统实时在纹理上绘制纹理,以创建贴图和血迹等。这些问题在涉及 2D 平面的几何和纹理方面比 3D 更相关,但它们对 2D 和 3D 游戏都具有相关性。它们的重要性非常显著,尽管在可用的教程中它们的覆盖范围有些不足,所以我在这里进行了覆盖。因此,我将主要限制本章的内容在 2D 的非传统方面。

天空盒

天空盒可能看起来不是开始分析 2D 的好地方,但它展示了相机的重要功能集,特别是分层。天空盒本质上是一个基于立方体的背景,它附着在相机上以显示云彩、天空和其他应始终作为场景背景的遥远细节,但它永远不会代表玩家可以靠近的任何东西。它始终是遥远的,如图所示:

天空盒

天空盒资源用于为相机显示天空背景

Unity 默认天空盒的主要问题是它们默认是静态和不动的。然而,大多数开发者希望他们的天空和云彩能够轻轻旋转,即使相机静止不动,以描绘一天或时间流逝的过程。现在,让我们使用 Unity 天空盒资源、两个分层相机和一个 C#脚本文件来创建一个改进的天空盒预制体。

小贴士

旋转天空盒的最终项目可以在本书的配套文件中找到。

对于在此处创建的示例项目,让我们导入角色控制器包以获取第一人称控制器资源,导入地形资源包以获取可以绘制到示例地形上的地形纹理,以及导入天空盒包以获取天空盒纹理,如图下所示。

所有这些都将有助于构建一个带有旋转 Skybox 的示例项目。

Skybox

导入角色控制器、Skybox 和地形资产

接下来,让我们开始创建旋转的 Skybox 预制件,以创建一个可重复使用的对象。该对象由三个主要部分或子对象组成:一个允许玩家移动并渲染大多数场景对象的第一人称控制器,一个位于第一人称相机下方并仅显示 Skybox 的第二相机,以及一个具有反转法线的立方体对象,它包围 Skybox 相机并在每个面上显示每个 Skybox 纹理。

首先,在场景原点创建一个新的空对象(命名为SkyBoxCamera),并将一个第一人称控制器对象作为子对象添加。然后,从主菜单创建六个四边形对象(通过导航到GameObject | 3D Object | Quad),将每个对象对齐到另一个对象的角落,使顶点吸附以形成一个倒置的立方体,即面朝内的立方体,如下面的截图所示。这代表了手动 Skybox 的网格。

Skybox

从六个四边形对象创建手动 Skybox

小贴士

如有必要,缩放四边形,并确保它们包含并包围第一人称控制器,该控制器应位于 Skybox 的中心。

将 Skybox 面分配到新的层SkyBoxLayer,选择第一人称控制器相机,然后更改剔除遮罩字段以排除SkyBoxLayer层。第一人称相机应仅渲染前景对象,而不是背景对象。为了实现这一点,在对象检查器中将清除标志字段更改为仅深度,如下面的截图所示。这使相机背景变为透明,允许低级相机显示出来,如果有的话。

Skybox

使相机背景透明

现在,创建一个新的Camera对象,并将其作为第一人称相机的直接子对象,并匹配其位置、旋转和缩放。这允许相机继承第一人称相机所有的变换。次要相机的目的是在匹配第一人称位置和旋转的同时,仅作为层在第一人称相机下方渲染 Skybox 对象。

为此,将新相机的深度值更改为任何小于第一人称相机深度的值;例如-1。如有必要,移除任何音频监听器组件。

Skybox

为 Skybox 渲染创建第二个相机

为每个立方体面分配一个独特的 Skybox 纹理,并注意通过旋转或调整四边形的对齐方式来无缝地对齐它们。然后,将 Skybox 纹理的材质着色器类型更改为未光照/纹理,使 Skybox 不受场景光照的影响。Skybox 网格应该开始成形,如下所示:

Skybox

将 Skybox 纹理添加到四边形

最后,将以下代码示例 9-1 应用到 Skybox 父对象上,以创建其旋转行为并将其持续对齐到相机位置。这确保了 Skybox 无论在场景中移动到何处,始终位于相机中心:

01 //--------------------------------------------------
02 using UnityEngine;
03 using System.Collections;
04 //--------------------------------------------------
05 public class SkyBox : MonoBehaviour 
06 {
07 //--------------------------------------------------
08 //Camera to follow
09 public Camera FollowCam = null;
10 
11 //Rotate Speed (Degrees per second)
12 public float RotateSpeed = 10.0f;
13 
14 //Transform
15 private Transform ThisTransform = null;
16 //--------------------------------------------------
17 // Use this for initialization
18 void Awake () {
19       ThisTransform = transform;
20 }
21 //--------------------------------------------------
22 // Update is called once per frame
23 void Update () {
24        //Update position
25        ThisTransform.position = FollowCam.transform.position;
26 
27        //Update rotation
28 ThisTransform.Rotate(new Vector3(0,RotateSpeed * Time.deltaTime,0));
29 }
30 //--------------------------------------------------
31 }
32 //--------------------------------------------------

从这里,你现在拥有了一个完整且增强的 Skybox,它围绕着相机旋转,以产生更真实和生动的场景。你甚至可以通过在每个 Skybox 内部添加多个堆叠的 Skybox 来更进一步,每个 Skybox 都具有透明度,以创建额外的效果,例如雾、薄雾等:

Skybox

完成手动 Skybox 预制件

程序化网格

尽管 Unity 现在从应用程序菜单提供了一个 Quad 原语,您可以通过导航到GameObject | 3D Object | Quad来访问它,但了解如何手动创建几何形状,如 Quads,仍然很有用。这里有几个原因。首先,你将经常需要在脚本中编辑顶点以移动、动画或扭曲网格以创建各种效果,例如,在平台游戏中创建一个类似果冻的表面,当角色踩在上面时弯曲和摇晃。其次,你可能需要编辑网格的 UV 坐标,例如,以创建动画或滚动的纹理效果,如下所示:

程序化网格

从脚本生成网格

考虑以下代码示例 9-2,它应包含在项目中的Editor文件夹内。它创建了一个编辑器插件,该插件在脚本中生成一个具有对网格枢轴点位置完全定制的 Quad。正如我们将在代码注释中看到的,这个示例包含了许多有用的提示:

001 //EDITOR CLASS TO CREATE QUAD MESH WITH SPECIFIED ANCHOR
002 //------------------------------------------------
003 using UnityEngine;
004 using UnityEditor;
005 using System.IO;
006 //------------------------------------------------
007 //Run from unity editor
008 public class CreateQuad : ScriptableWizard
009 {
010       //Anchor point for created quad
011       public enum AnchorPoint
012     {
013         TopLeft,
014         TopMiddle,
015         TopRight,
016         RightMiddle,
017         BottomRight,
018         BottomMiddle,
019         BottomLeft,
020         LeftMiddle,
021         Center,
022             Custom
023     }
024 
025      //Name of Quad Asset
026      public string MeshName = "Quad";
027 
028      //Game Object Name
029      public string GameObjectName = "Plane_Object";
030 
031      //Name of asset folder
032      public string AssetFolder = "Assets";
033 
034      //Width of quad in world units (pixels)
035      public float Width = 1.0f;
036 
037      //Height of quad in world units (pixels)
038      public float Height = 1.0f;
039 
040      //Position of Anchor
041      public AnchorPoint Anchor = AnchorPoint.Center;
042 
043      //Horz Position of Anchor on Plane
044      public float AnchorX = 0.5f;
045 
046     //Vert Position of Anchor on Plane
047      public float AnchorY = 0.5f;
048      //------------------------------------------------
049      [MenuItem("GameObject/Create Other/Custom Plane")]
050     static void CreateWizard()
051     {
052         ScriptableWizard.DisplayWizard("Create Plane",typeof(CreateQuad));
053     }
054 
055      //------------------------------------------------
056      //Function called when window is created
057      void OnEnable()
058      {
059             //Call selection change
060             OnSelectionChange();
061       }
062       //------------------------------------------------
063       //Called 10 times per second
064       void OnInspectorUpdate()
065       {
066             switch(Anchor)
067             {
068                   //Anchor is set to top-left
069                   case AnchorPoint.TopLeft:
070                         AnchorX = 0.0f * Width;
071                         AnchorY = 1.0f * Height;
072                   break;
073 
074                   //Anchor is set to top-middle
075                   case AnchorPoint.TopMiddle:
076                          AnchorX = 0.5f * Width;
077                          AnchorY = 1.0f * Height;
078                   break;
079 
080                   //Anchor is set to top-right
081                   case AnchorPoint.TopRight:
082                          AnchorX = 1.0f * Width;
083                          AnchorY = 1.0f * Height;
084                   break;
085 
086                   //Anchor is set to right-middle
087                   case AnchorPoint.RightMiddle:
088                         AnchorX = 1.0f * Width;
089                         AnchorY = 0.5f * Height;
090                   break;
091 
092                   //Anchor is set to Bottom-Right
093                   case AnchorPoint.BottomRight:
094                         AnchorX = 1.0f * Width;
095                         AnchorY = 0.0f * Height;
096                   break;
097 
098                   //Anchor is set to Bottom-Middle
099                   case AnchorPoint.BottomMiddle:
100                         AnchorX = 0.5f * Width;
101                         AnchorY = 0.0f * Height;
102                   break;
103 
104                   //Anchor is set to Bottom-Left
105                   case AnchorPoint.BottomLeft:
106                         AnchorX = 0.0f * Width;
107                         AnchorY = 0.0f * Height;
108                   break;
109 
110                   //Anchor is set to Left-Middle
111                   case AnchorPoint.LeftMiddle:
112                         AnchorX = 0.0f * Width;
113                         AnchorY = 0.5f * Height;
114                   break;
115 
116                   //Anchor is set to center
117                   case AnchorPoint.Center:
118                         AnchorX = 0.5f * Width;
119                         AnchorY = 0.5f * Height;
120                   break;
121 
122                   case AnchorPoint.Custom:
123                   default:
124                   break;
125            }
126      }
127       //------------------------------------------------
128       //Function called when window is updated
129       void OnSelectionChange()
130      {
131             //Check user selection in editor 
132      if (Selection.objects != null && Selection.objects.Length == 1)
133             {
134             //Get path from selected asset
135       AssetFolder = Path.GetDirectoryName(AssetDatabase.GetAssetPath(Selection.objects[0]));

136             }
137      }
138      //------------------------------------------------
139      //Function to create quad mesh
140      void OnWizardCreate()
141      {
142             //Create Vertices
143             Vector3[] Vertices = new Vector3[4];
144 
145             //Create UVs
146             Vector2[] UVs = new Vector2[4];
147 
148             //Two triangles of quad
149             int[] Triangles = new int[6];
150 
151             //Assign vertices based on pivot
152 
153             //Bottom-left
154             Vertices[0].x = -AnchorX;
155             Vertices[0].y = -AnchorY;
156 
157             //Bottom-right
158             Vertices[1].x = Vertices[0].x+Width;
159             Vertices[1].y = Vertices[0].y;
160
161             //Top-left
162             Vertices[2].x = Vertices[0].x;
163             Vertices[2].y = Vertices[0].y+Height;
164 
165             //Top-right
166             Vertices[3].x = Vertices[0].x+Width;
167             Vertices[3].y = Vertices[0].y+Height;
168             
169             //Assign UVs
170             //Bottom-left
171             UVs[0].x=0.0f;
172             UVs[0].y=0.0f;
173
174             //Bottom-right
175             UVs[1].x=1.0f;
176             UVs[1].y=0.0f;
177
178             //Top-left
179             UVs[2].x=0.0f;
180             UVs[2].y=1.0f;
181 
182             //Top-right
183             UVs[3].x=1.0f;
184             UVs[3].y=1.0f;
185 
186             //Assign triangles
187             Triangles[0]=3;
188             Triangles[1]=1;
189             Triangles[2]=2;
190 
191             Triangles[3]=2;
192             Triangles[4]=1;
193             Triangles[5]=0;
194 
195             //Generate mesh
196             Mesh mesh = new Mesh();
197             mesh.name = MeshName;
198             mesh.vertices = Vertices;
199             mesh.uv = UVs;
200             mesh.triangles = Triangles;
201             mesh.RecalculateNormals();
202 
203             //Create asset in database
204      AssetDatabase.CreateAsset(mesh, AssetDatabase.GenerateUniqueAssetPath(AssetFolder + "/" + MeshName) + ".asset");

205            AssetDatabase.SaveAssets();
206 
207             //Create plane game object
208             GameObject plane = new GameObject(GameObjectName);

209       MeshFilter meshFilter = (MeshFilter)plane.AddComponent(typeof(MeshFilter);

210             plane.AddComponent(typeof(MeshRenderer));
211 
212             //Assign mesh to mesh filter
213             meshFilter.sharedMesh = mesh;
214             mesh.RecalculateBounds();
215 
216             //Add a box collider component
217             plane.AddComponent(typeof(BoxCollider));
218      }
219 
220       //------------------------------------------------
221 }

以下是对代码示例 9-2 的注释:

  • 第 004 行:此示例被编码为一个编辑器插件。因此,包含了UnityEditor命名空间。有关创建编辑器插件的更多信息,请参阅第八章,自定义 Unity 编辑器

  • 第 135 行:当用户在 Unity 编辑器中通过鼠标或键盘更改选择时,会调用OnSelectionChanged事件。在这里,调用GetAssetPath方法来检索当前在项目面板中打开的文件夹。

  • 第 140 行:调用OnWizardCreate函数在脚本中生成一个 Quad 网格。这是通过填充顶点和 UV 数组,然后在第 196 行创建的Mesh对象内部填充来实现的。

  • 第 204 行:关键的是,网格本身被保存,不是作为特定场景中的对象,而是作为项目的一般资产。这样可以通过AssetDatabase类实现。这对于允许网格在需要时跨多个场景重用,以及允许其更改和细节在场景间持久化非常重要。

    小贴士

    更多关于AssetDatabase类的信息可以在 Unity 文档中在线找到,链接为docs.unity3d.com/ScriptReference/AssetDatabase.html

动画 UVs – 滚动纹理

滚动纹理是许多游戏的一般性需求,然而,Unity 本身并不原生支持滚动纹理;也就是说,你需要“动手编码”来实现它们。滚动纹理对于创建视差效果很有用;用于移动云彩、表面和水;或者表达游戏中的运动或移动。通常,滚动纹理是无缝图像,其像素在垂直和水平方向上平铺。这允许无限滚动和重复,如下所示:

动画 UVs – 滚动纹理

在四边形上的滚动纹理

当附加到四边形时,以下代码示例 9-3 将根据水平和垂直速度动画化其纹理:

01 //CLASS TO SCROLL TEXTURE ON PLANE. CAN BE USED FOR MOVING SKY
02 //------------------------------------------------
03 using UnityEngine;
04 using System.Collections;
05 //------------------------------------------------
06 [RequireComponent (typeof (MeshRenderer))] 
07 public class MatScroller : MonoBehaviour
08 {
09 //Public variables
10 //------------------------------------------------
11 //Reference to Horizontal Scroll Speed
12 public float HorizSpeed = 1.0f;
13 
14 //Reference to Vertical Scroll Speed
15 public float VertSpeed = 1.0f;
16 
17 //Reference to Min and Max Horiz and vert
18 public float HorizUVMin = 1.0f;
19 public float HorizUVMax = 2.0f;
20 
21 public float VertUVMin = 1.0f;
22 public float VertUVMax = 2.0f;
23 
24 //Private variables
25 //------------------------------------------------
26 //Reference to Mesh Renderer Component
27 private MeshRenderer MeshR = null;
28 
29 //Methods
30 //------------------------------------------------
31 // Use this for initialization
32 void Awake ()
33 {
34        //Get Mesh Renderer Component
35        MeshR = GetComponent<MeshRenderer>();
36 }
37 //------------------------------------------------
38 // Update is called once per frame
39 void Update () 
40 {
41        //Scrolls texture between min and max
42 Vector2 Offset = new Vector2((MeshR.material.mainTextureOffset.x > HorizUVMax) ? HorizUVMin : MeshR.material.mainTextureOffset.x + Time.deltaTime * HorizSpeed,
43 (MeshR.material.mainTextureOffset.y > VertUVMax) ? VertUVMin : MeshR.material.mainTextureOffset.y + Time.deltaTime * VertSpeed);

44 
45       //Update UV coordinates
46       MeshR.material.mainTextureOffset = Offset;
47 }
48 //------------------------------------------------
49 }
50 //------------------------------------------------

小贴士

MatScroller类与任何MeshRenderer组件和四边形对象一起工作。一个完整的滚动纹理项目可以在本书的配套文件(代码包)中找到。

将此脚本附加到你的四边形对象上,并调整其滚动速度以产生所需的结果,如下面的截图所示。这将有助于创建动画天空背景和侧滚动射击或平台游戏的背景。它还可以与透明度结合使用来创建流动的瀑布和体积光照!

动画 UVs – 滚动纹理

从对象检查器调整纹理滚动字段

纹理绘制

在许多实际场景中,需要在运行时将像素绘制到纹理上。有时,这种需求本身可能很简单,例如,使用 alpha 透明度在另一个表面前显示贴图纹理(如脚印或文字信息)。在这种情况下,你可以简单地通过在另一个平面前放置一个 alpha 裁剪平面来解决这个问题,作为背景。然而,有时你的需求更为复杂,实际上你需要求助于真正的纹理绘制。例如,在街头打斗游戏中,拳头和其他攻击造成的血迹会落在地面和周围景观上,你希望它成为环境纹理的一部分。另一个例子可能是一个休闲化妆艺术家游戏,玩家必须将腮红或眼影绘制到面部网格上。

在这里,你并不想在网格前面单独作为对象绘制纹理四边形来创建纹理贴图的视觉效果。相反,你真正需要将源纹理(如笔刷)绘制到应用于网格的目标纹理上。在这里,绘制不仅仅发生在两个独立的纹理之间,而是在网格及其 UV 映射之间进行。换句话说,源纹理必须应用于场景中网格表面,然后,笔刷像素必须通过网格 UV 映射重新投影回目标纹理。这确保了笔刷像素被绘制到目标纹理的正确位置,如图下所示。因此,这种方法允许任何大小的源纹理通过 UV 映射绘制到任何 3D 表面及其任何大小的目标纹理上。

在本节中,我们将探讨如何实际有效地实现这一点。然而,在开始之前,应该指出,以这种方式进行纹理绘制应作为最后手段,当其他方法(如裁剪四边形)不足够时。这是因为真正的纹理绘制在计算上非常昂贵。

纹理绘制

通过网格及其 UV 映射实时绘制纹理笔刷到其他纹理上

小贴士

完整的纹理绘制项目可以在本书的配套文件(代码包)中找到。

第一步 – 创建纹理混合着色器

首先,让我们认识到这里理想的两种层叠方法。首先,我们有笔刷源纹理本身,当用户在场景中点击网格时,它将被绘制到目标纹理上,如图所示:

第一步 – 创建纹理混合着色器

笔刷纹理,其中黑色是透明的(alpha)

然后,我们有应用于网格的目标纹理,当绘制时,笔刷笔触应该叠加到其上,如图下所示:

第一步 – 创建纹理混合着色器

应该绘制笔刷的目标纹理

然而,我们通常不希望在绘制操作期间绘制的笔刷笔触覆盖或更改原始目标纹理。这是因为目标纹理可能应用于场景中的多个对象(至少在理论上是这样),覆盖或更改原始像素将导致其效果传播到使用该纹理的所有对象。

相反,最好将绘制效果分离到具有透明背景的单独纹理上,并通过自定义材质将其层叠到目标纹理上。这会在目标纹理和绘制效果之间创建真正的分离,尽管外观上看起来像是一个统一的纹理。为了实现这种效果,必须编写自定义着色器,如下面的代码示例 9-4 所示。这个着色器在背景纹理上混合一个顶部纹理(具有 alpha 透明度):

01 Shader "TextureBlender"
02 {
03     Properties
04     {
05     _Color ("Main Color", Color) = (1,1,1,1)
06     _MainTex ("Base (RGB) Trans (A)", 2D) = "white" {}
07     _BlendTex ("Blend (RGB)", 2D) = "white"
08     }
09     
10     SubShader
11     {
12 Tags { "Queue"="Geometry-9" "IgnoreProjector"="True" "RenderType"="Transparent" }

13     Lighting Off
14     LOD 200
15     Blend SrcAlpha OneMinusSrcAlpha
16     
17     CGPROGRAM
18            #pragma surface surf Lambert
19            uniform fixed4 _Color;
20            uniform sampler2D _MainTex;
21            uniform sampler2D _BlendTex;
22 
23            struct Input 
24            {
25              float2 uv_MainTex;
26            };
27 
28            void surf (Input IN, inout SurfaceOutput o) 
29            {
30                  fixed4 c1 = tex2D( _MainTex, IN.uv_MainTex );
31                  fixed4 c2 = tex2D( _BlendTex, IN.uv_MainTex );
32 
33                  fixed4 main = c1.rgba * (1.0 - c2.a);
34                  fixed4 blendedoutput = c2.rgba * c2.a;
35 
36 o.Albedo = (main.rgb + blendedoutput.rgb) * _Color;

37                  o.Alpha = main.a + blendedoutput.a;
38            }
39     ENDCG
40     }
41     Fallback "Transparent/VertexLit"
42 }

一旦着色器编写并保存,它就会作为对象检查器中材质面板创建的任何材质的可选择着色器类型出现。这个着色器应该用于任何需要绘制细节的对象,如图所示。_MainTex插槽指的是需要叠加细节但必须保留的背景纹理。_BlendTex插槽指的是要叠加到_MainTex上的纹理,它保留了其 alpha 透明度。通常,这个插槽将在运行时由脚本通过生成一个 alpha 透明纹理来填充,以接收画笔笔触,正如我们很快将看到的。

第一步 – 创建纹理混合着色器

创建一个自定义着色器以混合最顶部的纹理到最底部的纹理

第二步 – 创建纹理绘制脚本

现在我们已经创建了一个着色器,它接受两个纹理作为输入(顶部和底部纹理)并将顶部纹理混合到底部;这允许使用 alpha 透明度。这会产生类似于 Photoshop 图层效果。这允许我们将纹理绘制分离到顶部纹理上,同时保留其下原始背景的像素,如图所示:

第二步 – 创建纹理绘制脚本

创建纹理绘制脚本

然而,在继续之前,我们必须首先通过对象检查器编辑我们计划使用的画笔纹理资产。具体来说,从 Unity 编辑器的项目面板中选择画笔纹理,并将纹理类型更改为高级。启用可读写复选框;这允许使用纹理编辑功能访问纹理。

此外,启用Alpha 是透明度并禁用生成 Mip 贴图,如图所示:

第二步 – 创建纹理绘制脚本

配置纹理以进行纹理绘制

现在,我们需要创建一个纹理绘制脚本,允许我们使用鼠标通过其 UV 坐标在场景中的 3D 对象上绘制画笔纹理。该脚本如下代码示例 9-5 所示:

001 //-----------------------------------------------------------
002 using UnityEngine;
003 using System.Collections;
004 //-----------------------------------------------------------
005 public class TexturePainter : MonoBehaviour 
006 {
007       //Square texture with alpha
008        public Texture2D BrushTexture = null;
009 
010       //Width and height of destination texture
011       public int SurfaceTextureWidth = 512;
012       public int SurfaceTextureHeight = 512;
013 
014      //Reference to painting surface texture
015       public Texture2D SurfaceTexture = null;
016 
017      //Reference to material for destination texture 
018      public Material DestMat = null;
019      //-------------------------------------------------
020      // Use this for initialization
021      void Start () 
022      {
023            //Create destination texture
024       SurfaceTexture = new Texture2D(SurfaceTextureWidth, SurfaceTextureHeight, TextureFormat.RGBA32, false);

025 
026             //Fill with black pixels (transparent; alpha=0)
027             Color[] Pixels = SurfaceTexture.GetPixels();
028             for(int i=0; i<Pixels.Length; i++)
029                    Pixels[i] = new Color(0,0,0,0);
030             SurfaceTexture.SetPixels(Pixels);
031             SurfaceTexture.Apply();
032 
033            //Set as renderer main texture
034            renderer.material.mainTexture = SurfaceTexture;
035 
036            //If destination material, set blend texture 
037           //Used with custom shader
038              if(DestMat)
039       DestMat.SetTexture("_BlendTex", SurfaceTexture);
040       }
041       //--------------------------------------------------
042       // Update is called once per frame
043       void Update () 
044       {
045            //If mouse button down, then start painting
046            if(Input.GetMouseButtonDown(0))
047            {
048                   //Get hit of mouse cursor
049                   RaycastHit hit;
050 
051                   //Convert screen point to ray in scene
052      if (!Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit))
053                          return;
054 
055                   //Get hit collider
056                   Renderer renderer = hit.collider.renderer;
057        MeshCollider Collide = hit.collider as MeshCollider;

058       if (renderer == null || renderer.sharedMaterial == null || renderer.sharedMaterial.mainTexture == null || Collide == null)
059                         return;
060 
061                   //Get UV Coords of hit surface
062                   Vector2 pixelUV = hit.textureCoord;
063       pixelUV.x *= renderer.material.mainTexture.width;
064       pixelUV.y *= renderer.material.mainTexture.height;
065 
066       //Update coords to texture middle (align brush texture center to cursor)
067                   pixelUV.x -= BrushTexture.width/2;
068                   pixelUV.y -= BrushTexture.height/2;
069 
070             //Clamp pixel values between 0 and width
071      pixelUV.x = Mathf.Clamp(pixelUV.x, 0, renderer.material.mainTexture.width);

072      pixelUV.y = Mathf.Clamp(pixelUV.y, 0, renderer.material.mainTexture.height);
073 
074             //Paint onto destination texture
075      PaintSourceToDestTexture(BrushTexture, renderer.material.mainTexture as Texture2D, (int)pixelUV.x, (int)pixelUV.y);
076             }
077      }
078      //--------------------------------------------------
079      //Paint source text to destination
080      //Will paint a brush texture onto a destination texture 
081      public static void PaintSourceToDestTexture(Texture2D Source, Texture2D Dest, int Left, int Top)
082       {
083             //Get source pixels
084             Color[] SourcePixels = Source.GetPixels();
085 
086             //Get dest pixels
087             Color[] DestPixels = Dest.GetPixels();
088 
089             for(int x=0; x<Source.width; x++)
090             {
091                     for(int y=0; y<Source.height; y++)
092                    {
093                         //Get source pixel
094      Color Pixel = GetPixelFromArray(SourcePixels, x, y, Source.width);

095 
096                          //Get offset in destination
097                          int DestOffsetX = Left + x;
098                          int DestOffsetY = Top + y;
099 
100      if(DestOffsetX < Dest.width && DestOffsetY < Dest.height)

101      SetPixelInArray(DestPixels, DestOffsetX, DestOffsetY, Dest.width, Pixel, true);
102                    }
103            }
104 
105            //Update destination texture
106            Dest.SetPixels(DestPixels);
107            Dest.Apply();
108      }
109      //-------------------------------------------------------
110       //Reads color from pixel array
111       public static Color GetPixelFromArray(Color[] Pixels, int X, int Y, int Width)
112       {
113             return Pixels[X+Y*Width];
114       }
115       //------------------------------------------------
116       //Sets color in pixel array
117        public static void SetPixelInArray(Color[] Pixels, int X, int Y, int Width, Color NewColor, bool Blending=false)
118       {
119             if(!Blending)
120                   Pixels[X+Y*Width] = NewColor; 
121             else
122             {
123      //Here we blend the color onto existing surface, preserving alpha transparency

124      Color C = Pixels[X+Y*Width] * (1.0f - NewColor.a);

125                   Color Blend = NewColor * NewColor.a;
126 
127                   Color Result = C + Blend;
128                    float Alpha = C.a + Blend.a;
129 
130       Pixels[X+Y*Width] = new Color(Result.r, Result.g, Result.b, Alpha);

131             }
132      }
133      //-------------------------------------------------
134 }
135 //-----------------------------------------------------------

以下是对代码示例 9-5 的注释:

  • 第 008 行:此行中的公共变量维护了对一个有效纹理资产的引用,该纹理资产在绘制操作期间用作画笔图形。对于每个鼠标点击,这个纹理将被“放置”或绘制到变量SurfaceTexture上。

  • 行 015: SurfaceTexture 将引用一个动态生成的纹理,默认情况下填充透明像素,这将揭示任何在其下层的纹理。此纹理将接受绘画操作期间的所有画笔笔触。简而言之,此纹理将被输入到 TextureBlender 着色器中的 _BlendTex 变量。

  • 行 026-031: 在 Start 函数期间生成一个新纹理。该纹理为 RGBA32 格式,支持 alpha 通道。使用 SetPixels 函数批量填充(洪水填充)纹理为相同颜色的像素。有关 GetPixelsSetPixels 函数的更多信息将在稍后考虑。

  • 行 046: 在 Update 函数中,检测鼠标点击以启动纹理绘制功能。

  • 行 048-059: 如果按下鼠标按钮,函数应在目标上绘制画笔纹理。在第 52 行调用 Physics.Raycast 以确定一些事情,例如查看场景中的网格对象是否被射线击中。为了正常工作,该对象应具有 Collider 组件。

  • 行 062-072: 如果检测到碰撞,应通过 RaycastHit 结构的 textureCoord 变量检索被击中位置的 UV 坐标。有关此变量的更多信息,可以在 Unity 文档的在线文档中找到,网址为 docs.unity3d.com/ScriptReference/RaycastHit-textureCoord.html。此成员仅在相交的网格具有 MeshCollider 时有效,而不是其他碰撞器类型,如 BoxColliderCapsuleCollider。然而,这意味着任何用作纹理绘制目标的对象应具有 MeshCollider 组件,因为它包含 UV 数据。第 63-72 行将 UV 坐标转换为绝对像素位置,将画笔源纹理中心对准光标位置。此代码的结果是在源画笔纹理上清楚地标识一个应作为支点或原点的位置,并在源纹理应绘制到目标纹理中的像素 xy 坐标位置处建立。

  • 行 075: 最后,调用 PaintSourceToDestTexture 函数本身执行绘画操作。

  • 行 081: PaintSourceToDestTexture 函数接受四个参数:SourceDestLeftTop。基于这些参数,Source 纹理被绘制到 DestLeftTop 位置。此函数被声明为静态的,这意味着不需要声明此类的任何实例。

  • 行 084-087:纹理绘制过程的第一步是检索 SourceDest 纹理中的所有像素。这是通过使用 GetPixels 函数实现的。有关 GetPixels 的更多信息,可以在 Unity 文档中找到,网址为 docs.unity3d.com/ScriptReference/Texture2D.GetPixels.html。现在,尽管每个图像在视觉上都是一个像素的二维数组,但 GetPixels 返回的数组实际上是线性的(一维的)。这就是为什么有 GetPixelFromArraySetPixelFromArray 函数,它们将像素的 xy 位置转换为线性数组索引的原因。

  • 行 89-101:在这里,每个像素从 Source 纹理中检索出来并绘制到目标上。这检查确保画笔纹理以目标边界绘制,并允许所需的裁剪。这是必要的,因为画笔标记原则上可以靠近纹理边缘;在这种情况下,实际上只有画笔的一部分会被绘制到目标上,因为一些像素会被“剪掉”。像素是通过 GetPixelFromArraySource 纹理中检索的,而目标像素是通过 SetPixelInArray 设置的。

  • 行 106-107:最后,目标像素被推回到目标纹理缓冲区,并调用 Apply 函数以确认操作。Unity 还支持一个 SetPixel 函数(单数)而不是 SetPixels(复数)。然而,SetPixels 由于重复调用 SetPixel 而能带来更好的性能。

  • 行 111-114GetPixelFromArray 函数接受一个像素数据数组以及一个像素的 xy 坐标以及纹理数据的像素宽度。基于此,它返回一个线性索引到像素数组中,在那里你可以找到像素颜色值。

  • 行 117-131SetPixelInArray 函数改变线性数组中像素的颜色。改变的方法由参数 Blending 决定。如果 Blending 设置为 false,则源像素将简单地替换目标像素。如果 Blendingtrue,则源像素将与目标像素混合或分层,以保留 alpha 透明度。在将 alpha 透明度画笔绘制到目标纹理时,应将 Blending 设置为 true,以允许颜色值的累积和混合。

步骤 3 – 设置纹理绘制

现在我们已经有一个工作的着色器、纹理绘制脚本和配置好的纹理,我们将逐步运行配置 Unity 中纹理绘制的过程。从一个空项目开始,只包括我们的着色器、纹理绘制脚本和两个配置好的纹理:一个背景纹理和一个画笔纹理,如图所示:

步骤 3 – 设置纹理绘制

开始纹理绘制项目

Project 面板配置画笔纹理为小尺寸(例如 32 x 32),并将其 格式 设置为 RGBA 32 位 以实现透明度,如图所示:

步骤 3 – 设置纹理绘制

配置画笔纹理

使用 TextureBlender 着色器创建一个新的材质,并将背景纹理分配给 MainTexture 插槽,如图所示:

步骤 3 – 设置纹理绘制

从我们的 TextureBlender 着色器创建一个新的材质

在场景中添加一个新的 QuadPlaneMesh,如果有的话,移除其碰撞器。这个对象将接收最终的绘制输出,尽管点击检测将在一个副本网格上发生。我保持最终输出网格和点击检测网格分开,以便目标网格在需要时可以具有其他碰撞器类型或组件。

一旦添加了 Quad,使用 TextureBlender 着色器将其分配给该 Quad,如图所示:

步骤 3 – 设置纹理绘制

添加一个新的 Quad

复制 Quad,添加 Mesh Collider,禁用 Mesh Renderer,并为其分配一个空的漫反射材质。这个网格将不会渲染,但会检测鼠标点击并执行绘制操作。

此外,将 TexturePainter 脚本添加到对象中,并将 Brush Texture 字段分配给画笔纹理本身,将 Dest Mat 字段分配给来自 Project 面板的 Custom_Mat 材质,如图所示:

步骤 3 – 设置纹理绘制

创建点击检测 Quad

现在,运行应用程序并开始点击你的网格。当你这样做时,绘制笔触将被应用到纹理上并在视口中显示,如图所示:

步骤 3 – 设置纹理绘制

纹理绘制完成

摘要

本章在“二维性”的广义范畴下涵盖了广泛的内容。在这里,二维的定义并不仅仅是在传统意义上的二维游戏,而是基于纹理的理解,这对于二维和三维游戏都具有关键意义。所涉及的主题包括与二维平面及其内部空间相关的操作和思想。具体来说,它探讨了如何通过构建手动 Skybox 来创建旋转的 Skybox 背景,结合相机深度设置以创建分层渲染。然后,它探讨了如何生成诸如平面之类的程序化几何形状。随着 Unity 四边形作为原型的加入,单独考虑时平面的生成更为有限,但用于生成四边形的方法和概念更为有用,使我们能够编辑和调整任何网格,无论它是否是四边形。实时编辑网格对于创建各种效果至关重要,从冲击波爆炸到基于果冻的蹦床。向前推进,我们考察了网格 UV 的动画。它们允许我们在可平铺的纹理上无限滚动 2D 背景四边形,这对于视差效果以及水和其他基于运动的幻觉都很有用。最后,我们考虑了在网格上进行动态纹理绘制,使用鼠标点击网格可以在网格纹理上绘制源纹理,允许其 UV 坐标和 alpha 透明度混合。这个功能集特别强大,具有广泛的应用性,可以创建实时贴图,如弹孔、血迹和基于玩家的绘制。在下一章中,我们将考虑一系列提示和技术,以更好地与 Unity 项目合作。

第十章:源代码控制和其他技巧

本章考虑了在 C#中编写脚本或与脚本一起工作的三个主要技巧和窍门。这些技巧本身强大且重要,但它们并不完全属于之前的任何一章,这些章节主要按主题划分。这些技巧按无特定顺序列出,它们被包含在内的主要理由是基于它们的实用性,以及因为它们在其他地方的文档很少且往往结论不明确。因此,本章读起来像是一个有用的“你知道吗?”技巧和窍门的集合,这些技巧结合起来,提供了大量实用和实质性的知识。这三个技巧包括:

  • Git 版本控制

  • 资源文件夹和外部文件

  • 加载和保存游戏

Git – 源代码控制

术语源代码控制版本控制指的是旨在使尽可能多的人的开发实践既简单又安全的任何软件。简而言之,它允许您轻松快速地跟踪和撤销对文件的更改,并与其他人共享这些更改。通常,软件开发(包括游戏开发)依赖于两个重要的事实或要素。首先,它是一个集体努力,多个开发者作为团队的一部分共同工作,无论是在同一物理位置(如办公室)还是在遥远的位置,但共享一个虚拟空间(如虚拟办公室、论坛,甚至是电子邮件)。其次,在开发过程中,开发者将对源代码进行微调、编辑和改进。从这两个看似简单的事实中产生了修订控制旨在满足的一系列重要需求。

这些需求如下:

  • 协作:当多个开发者共同对同一项目进行编码时,他们通常需要共享源文件。他们可以通过电子邮件或其他手动方法来回传文件,但这使得在大规模和长期项目中编码变得困难。很快,监控代码随时间的变化以及将两组更改合并到一个文件中就会变得困难。

  • 回滚:有时,代码更改和改进会被证明是错误的。提出的编辑或修复并不总是达到预期的效果,必须撤销或回滚到早期状态。您可以自己保留早期文件的副本,但随着时间的推移,维护可能许多副本将会变得繁琐且不必要地令人困惑。

  • 跟踪更改和记录:通常,您需要跟踪谁做了什么,尤其是在调试时。如果有人对代码进行了编辑,您会想知道谁更改了代码,为什么,以及何时更改。再次,您可以手动维护一个日志文件,通过注释和条目来记录更改过程,但这将会变得繁琐且耗时。

版本控制旨在解决协作、回滚和跟踪更改的三个主要问题。版本控制软件包括 Git、Perforce、Microsoft Team Foundation Server 等。本章特别考虑 Git;它被广泛使用,免费,跨平台,且开源。使用 Git,您可以首先配置一个特殊的数据库,称为 仓库,它可以是本地的(在您的计算机上)或远程的(通过网络)。然后,一旦配置完成,您就可以跟踪和维护您所有 Unity 项目的任何更改,如果需要,可以回滚到项目的早期状态,并与他人共享或协作。让我们看看如何使用图形用户界面配置 Git 以进行通用使用。

步骤 #1 – 下载

对于 Unity 项目,有许多方法可以开始使用 Git。本章探讨了官方 Git 软件与前端 TortoiseGit 的结合使用。使用这两个软件包,开发者可以跟踪和维护他们项目的所有更改,无论是单独工作还是团队合作。

要开始,请下载并安装官方 Git 软件,该软件可在 git-scm.com/ 获取:

步骤 #1 – 下载

下载和安装 Git

小贴士

关于使用 Git 的详细信息可以在免费的在线电子书 Pro Git 中找到,作者是 Scott Chacon 和 Ben Straub,由 Apress 出版,可在 git-scm.com/book/en/v2 获取。

在 Git 安装并下载后,获取 TortoiseGit 是很有用的。这不是原始 Git 软件包的一部分,但它是 Windows 的一个可选前端组件,它允许您将 Git 与 Windows 壳集成,并通过图形用户界面而不是命令行与 Git 交互。

要下载和安装 TortoiseGit,请访问 code.google.com/p/tortoisegit/

步骤 #1 – 下载

下载和安装 TortoiseGit

步骤 #2 – 构建 Unity 项目

安装 Git 的初衷是为了跟踪和维护 Unity 项目的更改,以便在需要时进行回滚,作为原始文件的备份版本,以及与其他开发者共享更改。因此,这取决于您是否已经有一个需要维护的 Unity 项目。安装 Git 和 TortoiseGit 后的下一步是创建一个新的 Unity 项目或找到一个应该被跟踪的现有项目。以下是对 Unity 项目文件夹的截图:

步骤 #2 – 构建 Unity 项目

查看 Unity 项目文件夹

找到 Unity 项目后,在 Windows 资源管理器中打开项目文件夹以查看项目文件。如果您不知道或记不起文件夹的位置,您可以直接从 Unity 编辑器界面打开它。为此,在 Unity 项目面板内右键单击,从上下文菜单中选择在资源管理器中显示

步骤 #2 – 构建 Unity 项目

从 Unity 界面访问项目文件夹

步骤 #3 – 配置 Unity 以进行源代码控制

Git 与二进制文件和文本文件都兼容,但与文本文件配合得最好。当使用 Unity 时,编辑器会为您项目和导入的资产生成许多元数据文件。默认情况下,这些文件是隐藏的,并以二进制形式存在,它们位于 Unity 项目文件夹内。一些生成的元文件仅针对运行在您计算机上的 Unity 实例特定,例如界面首选项,而其他文件则与项目中的资产和数据相关,例如网格、纹理和脚本文件。为了从 Git 获得最佳效果,您需要通过在 项目资源管理器中使元文件可见,并使用基于文本的格式而不是二进制格式来调整 Unity 的默认行为。为此,从菜单栏选择编辑 | 项目设置 | 编辑器

从这里,使用对象检查器将版本控制字段设置为可见元文件,将资产序列化字段设置为强制文本

步骤 #3 – 配置 Unity 以进行源代码控制

配置 Unity 以进行版本控制

当这些设置更改时,您将看到与每个项目资产(包括场景)关联的 .meta 文件。此外,元文件将以人类可读的文本格式存在,甚至可以编辑(尽管不建议手动编辑)。请看以下截图:

步骤 #3 – 配置 Unity 以进行源代码控制

在文本编辑器中查看场景资产(以文本格式)

步骤 #4 – 创建 Git 仓库

创建和配置 Unity 项目后的下一阶段是创建 Git 数据库或仓库本身,该仓库将跟踪和维护 Unity 文件的所有更改。仓库可以是远程的(托管在网络上或外部计算机上)或本地的(托管在同一计算机上)。仓库将保留原始文件以及随时间对它们所做的所有更改,允许您在需要时回滚到文件的早期版本。仓库还可以与其他仓库共享和合并以进行文件共享。本章仅考虑本地仓库,因此现在让我们创建一个。为此,打开 Unity 项目文件夹(根文件夹),然后右键单击以显示 Windows 上下文菜单。从菜单中选择在此处初始化 Git

步骤 #4 – 创建 Git 仓库

创建 Git 仓库

一旦创建,将生成一个名为 .git 的新隐藏文件夹。这个文件夹包含项目的所有仓库文件。文件和文件夹的图标将变为默认的红色符号,表示项目文件夹内的文件尚未添加到仓库中,因此 Git 无法跟踪对它们的更改(我们很快就会处理这个问题)。这在上面的截图中有显示:

步骤 #4 – 创建 Git 仓库

红色高亮的文件夹包含未包含在 Git 仓库中的文件

步骤 #5 – 忽略文件

Git 仓库现在已创建,准备接收其第一组文件(一个 提交)。然而,在添加它们之前,有一些特定的文件和类型可以安全地忽略。Unity 具有一些项目或系统特定的文件,这些文件对项目的关键程度不如对用户的关键程度;也就是说,一些文件总是只包含用户界面首选项以及只读文件、临时文件和其他不需要添加到仓库且可以安全忽略的特定数据。要忽略这些文件,我们可以在项目的根文件夹内创建一个 .gitignore 文本文件,并列出所有要忽略的文件和文件夹,如下所示:

步骤 #5 – 忽略文件

创建 Git 忽略文件以排除特定文件类型从仓库

对于 Unity,此文件(.gitignore)应如下所示。请确保将文件放在根文件夹内:

[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
/*.csproj
/*.unityproj
/*.sln
/*.suo
/*.user
/*.userprefs
/*.pidb
/*.booproj
sysinfo.txt

步骤 #6 – 创建第一个提交

仓库现在已配置好,以接收第一组 Unity 项目文件。要添加这些文件,在根文件夹窗口内右键单击,然后在上下文菜单中转到 Git Commit | Master。在 Git 中,文件通常是批量提交的,而不是逐个提交。提交 窗口允许你选择要提交的所有文件。

点击 All 按钮,选择文件夹中的所有文件,然后在 Message 字段中为提交分配一个描述。消息应该允许任何用户理解提交包含的文件。准备好后,点击 OK 提交文件:

步骤 #6 – 创建第一个提交

提交原始项目文件

提交完成后,文件图标将变为绿色,表示文件匹配,即表示项目文件夹中的文件与仓库中的文件相同:

步骤 #6 – 创建第一个提交

文件与仓库保持最新

步骤 #7 – 修改文件

Git 应该是一个完整的文件跟踪解决方案;这意味着它不仅应该存储你的原始文件,还应该存储所有后续的更改和编辑,允许你回滚到任何以前的版本。

如果你现在返回 Unity 并修改你的文件,添加新资产或编辑现有的资产,Windows 资源管理器内的文件图标将再次变为红色,表示本地文件与仓库文件不匹配:

步骤 #7 – 修改文件

修改文件

如果您决定最近的变化是错误的,并且希望恢复到最后一次所做的更改,您可以通过在项目文件夹窗口内右键单击并从上下文菜单中选择TortoiseGit | 还原...来实现:

步骤 #7 – 修改文件

还原(撤销)最近的变化

将显示还原对话框,允许您选择要还原的文件。选择所有必要的文件,然后选择确定。Git 将恢复所有选定的文件,用仓库中的最新版本覆盖本地版本:

步骤 #7 – 修改文件

选择要还原的文件

另一方面,您可能不想还原或撤销最近的变化。相反,您可能已经创建了一个有效的更改;这应该作为文件的最新版本添加到 Git 仓库中。如果是这样,那么只需重新提交文件。在项目文件夹窗口内右键单击,并从上下文菜单中选择Git 提交 | 主分支。务必在提交对话框的消息字段中输入一个新且描述性的消息。

步骤 #8 – 从仓库获取文件

一旦所有文件的原始提交已完成,如果您故意或意外地删除了 Unity 文件夹中的所有文件(除了.git.gitignore文件),您仍然可以再次检索所有最新文件。这是因为 Git 仓库包含这些文件。

步骤 #8 – 从仓库获取文件

您可以从 Git 仓库的项目文件夹中恢复已删除的文件

小贴士

当然,如果您真的在按照书中的指示删除自己的文件,确保在测试过程中保留手动备份,以防万一出现问题!

要实现这一点,请在项目文件夹窗口内右键单击,并从上下文菜单中选择TortoiseGit | 切换/检出

步骤 #8 – 从仓库获取文件

使用切换/检出选项从仓库获取最新文件

切换/检出对话框中,选择主分支作为切换到字段。

您可能还需要从选项中启用强制复选框(请参阅文档以获取更多详细信息)。然后,单击确定以获取最新文件。一旦所有文件都检索到,您将看到以下截图:

步骤 #8 – 从仓库获取文件

使用检出获取最新文件

或者,您可能希望将项目切换回仓库中的较早提交,而不是获取最新文件,而是获取较早的提交。为此,首先从上下文菜单中选择Tortoise Git | 切换/检出以显示检出对话框。然后,从切换到组中启用提交单选框:

步骤 #8 – 从仓库获取文件

启用提交单选按钮以获取旧提交

点击 提交 字段旁边的浏览按钮 () 以显示可用的仓库提交,并选择早期版本以切换到。然后,点击 确定 以退出 仓库提交 对话框,并再次点击 确定 以确认从所选提交中检出。所选提交的文件将随后恢复到项目文件夹。记住,每个提交都有一个作者(对于团队工作的人来说),这让你可以了解谁更改了什么:

步骤 #8 – 从仓库获取文件

从仓库中选择旧提交以恢复

步骤 #9 – 浏览仓库

有时,你可能既不想从仓库中添加文件,也不想检索文件,只想浏览它们以查看内容。你可以通过使用 TortoiseGit 的一部分——仓库浏览器工具快速轻松地做到这一点。要访问此工具,从上下文菜单中选择 TortoiseGit | 仓库浏览器

步骤 #9 – 浏览仓库

查看仓库浏览器工具

仓库浏览器工具允许你从 GUI 窗口中预览文件和层次结构:

步骤 #9 – 浏览仓库

检查仓库内的文件

资源文件夹和外部文件

你的游戏通常会依赖于从文件(如 XML 文件)加载的外部数据,例如,用于字幕、本地化或关卡序列化。请看以下截图:

资源文件夹和外部文件

打印从外部文本文件资产加载的消息,该消息将与项目一起编译

在这些情况下,你需要特定的功能范围。第一个是能够以 Unity 可以解析和理解的方式从文件中动态加载数据到内存中的能力。第二个是能够在将文件导入 Unity 后更改和编辑文件内容,并且更改的效果可以在游戏中更新,而无需进行代码更改。第三个是能够将包含在主 Unity 构建中的文件编译和分发为独立游戏,而不是作为与主可执行文件分开的独立且可编辑的文件。进一步阐述第三点,你通常不想将游戏作为独立构建与单独的外部文件(如 XML 文件)一起分发,这些文件可以被玩家打开和编辑。相反,作为开发者,你希望在 Unity 编辑器中编辑和更改文件,并且希望文件本身被编译并构建到你的最终 Unity 独立项目中,就像其他资产一样。你可以使用资源文件夹来实现这一点。

要使用资源文件夹,在 Unity 项目中创建一个名为 resources 的文件夹。一个项目可以包含零个、一个或多个 resources 文件夹。在文件夹内,添加所有资产,例如可以被 Unity 在运行时加载的文本文件:

资源文件夹和外部文件

将外部文件添加到资源文件夹

一旦文件被添加到 resources 文件夹,您就可以使用 Resources.Load 函数将其加载到内存中。请参阅以下代码示例 10-1,它将一个示例文本资源加载到一个 UI 文本组件中:

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
//---------------------------------------------
public class LoadTextData : MonoBehaviour
{
  //Reference to UI Text Component
  private Text MyText = null;

  //Reference to text asset in resources folder
  private TextAsset TextData = null;
  //---------------------------------------------
  // Use this for initialization
  void Awake () {
    //Get Text Component
    MyText = GetComponent<Text>();

    //Load text data from resources folder
    TextData = Resources.Load("TextData") as TextAsset;
  }
  //---------------------------------------------
  // Update is called once per frame
  void Update () {
    //Update text label component
    MyText.text = TextData.text;
  }
  //---------------------------------------------
}
//---------------------------------------------

小贴士

更多关于资源文件夹和 Resources 类的信息可以在在线 Unity 文档中找到,网址为 docs.unity3d.com/ScriptReference/Resources.html

AssetBundles 和外部文件

如果您使用的是 Unity Pro,并希望向用户提供动态内容,允许玩家修改mod)游戏内容,添加他们自己的资源,以及附加组件,以及支持您自己的附加组件和插件,那么 AssetBundles 将非常有用。AssetBundles 允许您将许多不同的 Unity 资源打包成一个单独的外部文件,该文件位于主项目之外,可以动态地加载到任何 Unity 项目中,无论是从磁盘上的本地文件还是通过互联网:

AssetBundles 和外部文件

从所选资源构建 AssetBundles

要开始使用,请将 Unity 资产包编辑器脚本导入到项目中,以便从 项目 面板轻松构建 AssetBundles;为此,将以下代码示例 10-2 粘贴到项目 Editor 文件夹内的一个 C# 脚本文件中;否则,您可以从以下网址下载脚本:docs.unity3d.com/ScriptReference/BuildPipeline.BuildAssetBundle.html

  // C# Example
  // Builds an asset bundle from the selected objects in the
  // project view.
  // Once compiled go to "Menu" -> "Assets" and select one of the
  // choices to build the Asset Bundle

  using UnityEngine;
  using UnityEditor;
  public class ExportAssetBundles {
    [MenuItem("Assets/Build AssetBundle From Selection - Track dependencies")]
    static void ExportResource () {
      // Bring up save panel
      string path = EditorUtility.SaveFilePanel ("Save Resource", "", "New Resource", "unity3d");
      if (path.Length != 0) {
        // Build the resource file from the active selection.
        Object[] selection = Selection.GetFiltered(typeof(Object), SelectionMode.DeepAssets);
        BuildPipeline.BuildAssetBundle(Selection.activeObject, selection, path,
          BuildAssetBundleOptions.CollectDependencies | BuildAssetBundleOptions.CompleteAssets);
        Selection.objects = selection;
      }
    }
    [MenuItem("Assets/Build AssetBundle From Selection - No dependency tracking")]
    static void ExportResourceNoTrack () {
      // Bring up save panel
      string path = EditorUtility.SaveFilePanel ("Save Resource", "", "New Resource", "unity3d");
      if (path.Length != 0) {
        // Build the resource file from the active selection.
        BuildPipeline.BuildAssetBundle(Selection.activeObject, Selection.objects, path);
      }
    }
  }

要创建 AssetBundle,请选择要包含在包中的所有资源,然后从菜单栏转到 资产 | 从选择构建 AssetBundle。一旦选择,请选择您计算机上保存包的位置。

AssetBundles 和外部文件

选择要包含在 AssetBundle 中的资源

然后,为了测试 AssetBundle,创建一个新的项目或打开一个没有资源的不同项目,您可以在运行时使用 WWW 类将它们加载到您的项目中。请参阅以下代码示例 10-3,它展示了如何从本地文件下载 AssetBundle,提取纹理资源,并将其分配给附加的网格渲染器组件的材料:

using UnityEngine;
using System.Collections;

public class LoadAssetBundle : MonoBehaviour
{
  //Mesh Renderer Reference
  private MeshRenderer MR = null;

  // Use this for initialization
  IEnumerator Start ()
  {
    //Get asset bundle file from local machine
    WWW www = new WWW (@"file:///c:\asset_textures.unity3d");

    //Wait until load is completed
    yield return www;

    //Retrieve texture from asset bundle
    Texture2D Tex = www.assetBundle.Load("texture_wood",typeof(Texture2D)) as Texture2D;

    //Assign texture in bundle to mesh
    MR = GetComponent<MeshRenderer>();
    MR.material.mainTexture = Tex;
  }
}

这就是纹理资源将呈现的样子:

AssetBundles 和外部文件

从 AssetBundle 加载纹理资源到网格渲染器

小贴士

更多关于 AssetBundles 的信息可以在在线 Unity 文档中找到,网址为 docs.unity3d.com/Manual/AssetBundlesIntro.html

持久数据和保存的游戏

允许玩家保存和加载游戏状态对于许多游戏来说很重要,尤其是持续时间较长的游戏,如冒险、实时策略和角色扮演游戏。在这些情况下,游戏应允许用户将游戏数据保存到和从外部文件中加载。

在 Unity 中,通过 XML 或二进制文件的数据序列化来实现这一点:

持久化数据和保存游戏

将对象的 Transform 属性保存到 XML 文件中

序列化是将内存中的数据(例如GameObject上的组件状态)转换为可以写入文件并从文件中重新加载以在内存中重新创建组件的流的进程。因此,创建保存游戏的过程首先是决定哪些数据必须保存和加载(这是游戏特定的),然后创建一个新的类来存储这些数据。考虑以下代码示例 10-4(ObjSerializer.cs),它可以附加到任何GameObject上,以将 Transform 组件(平移、旋转和缩放)序列化和反序列化到外部文件,无论是 XML 格式还是二进制格式。为了实现这一点,使用了XmlSerializer类将内存中的对象转换为 XML,而BinaryFormatter将内存中的对象转换为二进制文件。XML 文件是可读的文本文件,而二进制文件通常不能被人类正常读取或理解:

001 //-----------------------------------------------
002 using UnityEngine;
003 using System.Collections;
004 using System.Collections.Generic;
005 using System.Xml;
006 using System.Xml.Serialization;
007 using System.Runtime.Serialization.Formatters.Binary;
008 using System.IO;
009 //-----------------------------------------------
010 public class ObjSerializer : MonoBehaviour
011 {
012   //Data to save to file XML or Binary
013   [System.Serializable]
014   [XmlRoot("GameData")]
015   public class MySaveData
016   {
017     //Transform data to save/load to and from file
018     //represents a conversion of a transform object
019     //into simpler values, like floats
020     [System.Serializable]
021     public struct DataTransform
022     {
023       public float X;
024       public float Y;
025       public float Z;
026       public float RotX;
027       public float RotY;
028       public float RotZ;
029       public float ScaleX;
030       public float ScaleY;
031       public float ScaleZ;
032     }
033
034     //Transform object to save
035   public DataTransform MyTransform = new DataTransform();

036   }
037
038   //My Save Data Object declared here
039   public MySaveData MyData = new MySaveData();
040   //-----------------------------------------------
041   //Populate structure MyData with transform data
042   //This is the data to be saved to a file
043   private void GetTransform()
044   {
045     //Get transform component on this object
046     Transform ThisTransform = transform;
047
048     //Got transform, now fill data structure
049     MyData.MyTransform.X = ThisTransform.position.x;
050     MyData.MyTransform.Y = ThisTransform.position.y;
051     MyData.MyTransform.Z = ThisTransform.position.z;
052     MyData.MyTransform.RotX = ThisTransform.localRotation.eulerAngles.x;

053     MyData.MyTransform.RotY = ThisTransform.localRotation.eulerAngles.y;

054     MyData.MyTransform.RotZ = ThisTransform.localRotation.eulerAngles.z;

055     MyData.MyTransform.ScaleX = ThisTransform.localScale.x;

056     MyData.MyTransform.ScaleY = ThisTransform.localScale.y;

057     MyData.MyTransform.ScaleZ = ThisTransform.localScale.z;
058     }
059     //-----------------------------------------------
060     //Restore the transform component with loaded data
061     //Call this function after loading data back from a file
        // for restore
062     private void SetTransform()
063     {
064       //Get transform component on this object
065       Transform ThisTransform = transform;
066
067       //We got the transform component, now restore data
068       ThisTransform.position = new Vector3(MyData.MyTransform.X, MyData.MyTransform.Y, MyData.MyTransform.Z);

069       ThisTransform.rotation = Quaternion.Euler(MyData.MyTransform.RotX, MyData.MyTransform.RotY, MyData.MyTransform.RotZ);

070       ThisTransform.localScale = new Vector3(MyData.MyTransform.ScaleX, MyData.MyTransform.ScaleY, MyData.MyTransform.ScaleZ);

071     }
072   //-----------------------------------------------
073   //Saves game data to XML file
074   //Call this function to save data to an XML file
075   //Call as Save
076   public void SaveXML(string FileName = "GameData.xml")
077   {
078     //Get transform data
079     GetTransform();
080
081     //Now save game data
082     XmlSerializer Serializer = new XmlSerializer(typeof(MySaveData));

083     FileStream Stream = new FileStream(FileName, FileMode.Create);

084     Serializer.Serialize(Stream, MyData);
085     Stream.Close();
086   }
087   //-----------------------------------------------
088   //Load game data from XML file
089   //Call this function to load data from an XML file
090   //Call as Load
091   public void LoadXML(string FileName = "GameData.xml")
092   {
093     //If file doesn’t exist, then exit
094     if(!File.Exists(FileName)) return;
095
096     XmlSerializer Serializer = new XmlSerializer(typeof(MySaveData));

097     FileStream Stream = new FileStream(FileName, FileMode.Open);

098     MyData = Serializer.Deserialize(Stream) as MySaveData;

099     Stream.Close();
100
101     //Set transform - load back from a file
102     SetTransform();
103   }
104   //-----------------------------------------------
105   public void SaveBinary(string FileName = "GameData.sav")
106   {
107     //Get transform data
108     GetTransform();
109
110     BinaryFormatter bf = new BinaryFormatter();
111     FileStream Stream = File.Create(FileName);
112     bf.Serialize(Stream, MyData);
113     Stream.Close();
114   }
115   //-----------------------------------------------
116   public void LoadBinary(string FileName = "GameData.sav")
117   {
118     //If file doesn’t exist, then exit
119     if(!File.Exists(FileName)) return;
120
121     BinaryFormatter bf = new BinaryFormatter();
122   FileStream Stream = File.Open(FileName, FileMode.Open);

123     MyData = bf.Deserialize(Stream) as MySaveData;
124     Stream.Close();
125
126     //Set transform - load back from a file
127     SetTransform();
128   }
129   //-----------------------------------------------
130 }
131 //-----------------------------------------------

小贴士

加载和保存游戏数据的完整示例可以在本书的配套文件中找到,位于Chapter10/XML_and_Binary文件夹中。

摘要

本章考虑了三个主要的小贴士,其中可能唯一的主题是文件管理。第一个小贴士考虑了 Git 版本控制,特别是免费和开源的版本控制软件如何允许我们跟踪项目中的更改以及与其他开发者轻松协作。第二个小贴士涉及动态加载文件数据,首先使用resources文件夹内部的内部项目文件,然后使用 AssetBundles。后者对于创建可以由开发者和玩家 alike 编辑的外部资产特别有用。第三和最后一个小贴士演示了如何在游戏中将数据保存到文件中,然后通过序列化重新加载。通过序列化,用户可以保存和恢复游戏数据,允许他们在稍后时间继续播放。

posted @ 2025-10-26 09:04  绝不原创的飞龙  阅读(27)  评论(0)    收藏  举报