Unity2017-游戏人工智能编程-全-
Unity2017 游戏人工智能编程(全)
原文:
zh.annas-archive.org/md5/73ffccfb4072dbaadbe59802fa18d6ce译者:飞龙
前言
欢迎来到游戏 AI 的奇妙世界,或者更具体地说,是 Unity 中的 AI。本书专注于 Unity 中 AI 相关功能的实现,并深入探讨这些功能背后的基本概念。它甚至提供了这些功能的一些从头到尾的示例。在这个过程中,本书为读者提供了示例项目和示例代码,供读者跟随、实验,并希望在自己的项目中进一步构建。
本书面向的对象
虽然读者不需要是高级程序员,但本书假设读者对 C#和 Unity 中的脚本编写有一定的基本知识。尽管如此,本书提供的示例代码注释详尽,并在整本书中以非常详细的方式解释,以描述每个决策和每一行代码背后的原因。熟悉所提供的一些算法当然有帮助,但绝非必需。本书将解释概念的理论和起源,然后深入探讨实现,突出我们寻找的核心功能。为了使读者真正专注于本书的主要目标——学习 Unity 中的 AI 游戏编程,不必要的代码被尽量减少。
本书涵盖的内容
第一章,游戏 AI 基础,使读者熟悉我们将要使用的术语。为了达到本书中更高级的概念,我们首先为后续章节打下基础并设定期望。这一介绍性章节预览了所涵盖的一些概念,并为读者提供了在后续的示例项目和代码中取得成功所必需的知识。
第二章,有限状态机与您,直接跳入游戏人工智能中最基本的概念之一——有限状态机。本章从概念概述开始,然后深入探讨了在 Unity 中使用内置功能(如 Mecanim 和 StateMachineBehaviours)实现状态机的实现。这是第一个引导用户通过实际例子的章节,并为后续章节如何解释所涵盖的概念定下了基调。
第三章,实现传感器,通过为读者提供使他们的 AI 更具可信度的知识和技巧,在 AI 代理的概念上进行了扩展。在本章中,读者学习了如何为他们的代理实现感知功能,使他们能够从虚拟环境中收集数据和信息,从而实现与环境的更复杂交互。代理的输出仅与其输入一样好,本章确保读者能够实现感知机制,为 AI 行为提供坚实的基础输入。
第四章,寻找你的道路,将读者的知识提升到新的层次。在前面三章的技能基础上,读者现在得到了工具,可以让他们的 AI 代理在游戏世界中导航。详细解释了几种不同的替代方案,例如基于节点的路径查找、接近标准的 A*算法方法,以及最终,Unity 的 NavMesh 系统。每种方案都提供了示例,并给用户提供了必要的知识,以便为每种情况选择正确的方案。
第五章,鸟群和人群,涵盖了标准鸟群算法的历史和实现。除了该主题的历史之外,用户还被引导通过一个实现鸟群以创建逼真的 boid 系统(用于模拟鸟类、鱼类、蝗虫或任何其他集群行为的系统)的示例项目。在章节的后半部分,读者被介绍到使用 Unity 的 NavMesh 系统实现简单的群体动态。再次提供样本场景来展示不同的实现。
第六章,行为树,展示了 AI 游戏程序员工具箱中的另一个实用工具:行为树。本章向读者介绍了行为树背后的概念,引导他们通过一个自定义实现,并在两个示例中应用所学的知识:一个简单的基于数学的示例和一个更有趣且坦率地说有点愚蠢的示例,我们称之为 HomeRock,它模拟了一个流行的在线卡牌游戏,以展示行为树的实际应用。
第七章,使用模糊逻辑让你的 AI 看起来更有生命力,标题长且描述性,对吧?本章涵盖了模糊逻辑的基本概念以及将模糊值转换为具体值的方法,并解释了在 Unity 中实现模糊逻辑的简单方法。第一个示例说明了概念的最简单版本,第二个示例则展示了一个类似于 RPG 中的道德/派系系统,以说明模糊逻辑的有用性。
第八章,一切如何融合在一起,将读者在本书中学到的概念应用到样本塔防示例项目中。本章说明了通过采用一些 AI 技术,你可以快速组合一个实现 AI 非玩家角色和敌人的游戏,并赋予它们基本的决策能力。
为了充分利用本书
-
一定要下载本书的所有示例代码!跟随示例进行学习对于理解所有涵盖的概念至关重要。
-
如果你的 C#技能生疏了,请复习一下。本书将尽力不让任何人掉队,但假设读者对 C#和 Unity 脚本有初学者到中级水平理解。
-
尝试!本书涵盖了核心概念,但所有示例都设置为实验。鼓励读者在给定示例的基础上进行构建,调整值、资产和代码以实现新的结果。
-
请耐心等待。根据你的技能水平或经验,你可能觉得其中一些概念有点难以理解。务必仔细遵循说明,并彻底检查所有提供的示例代码。人工智能可能是一个令人畏惧的主题,尽管这本书旨在让你对核心概念感到舒适,但如果你需要多次阅读示例才能完全理解所有细微差别,这也是可以的。
下载示例代码文件
你可以从www.packtpub.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
对于 Windows,请使用 WinRAR/7-Zip。
-
对于 Mac,请使用 Zipeg/iZip/UnRarX。
-
对于 Linux,请使用 7-Zip/PeaZip。
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Unity-2017-Game-AI-Programming-Third-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/Unity2017GameAIProgrammingThirdEdition_ColorImages.pdf。
使用的约定
本书使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“实际上,行为是从ScriptableObject派生的,而不是从MonoBehaviour派生的,因此它们仅作为资产存在。”
代码块设置如下:
private int currentTarget;
private float distanceFromTarget;
private Transform[] waypoints = null;
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“正如你在前面的屏幕截图中所见,我们将显示网格设置为 true。”
警告或重要注意事项如下所示。
小贴士和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
总体反馈:请发送邮件至 feedback@packtpub.com 并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现任何形式的我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packtpub.com.
第一章:游戏中人工智能的基础
人工智能(AI)是一个丰富且复杂的话题。乍一看,它可能显得令人畏惧。它的用途多种多样,从机器人学到统计学,再到(对我们来说更相关的)娱乐,特别是视频游戏。本书的目标将通过将人工智能的应用分解为相关、实用的解决方案,并提供易于理解的示例,以阐明概念,从而消除噪音并直接针对核心思想,来揭开这个主题的神秘面纱。本书将引领您一头扎入人工智能的世界,并介绍您开始人工智能之旅最重要的概念。
本章将为您简要介绍学术、传统领域和游戏特定应用中的人工智能背景。以下是我们将要涉及的主题:
-
探索游戏应用和实现人工智能的方式与其他领域有何不同
-
查看游戏中对人工智能的特殊要求
-
查看游戏中使用的基
本章将作为后续章节的参考,在后续章节中,我们将将在 Unity 中实现人工智能模式。
创造生命的幻觉
在深入探讨之前,我们应该停下来片刻,定义一下智能。智能简单来说就是学习某事物然后应用该知识的能力。至少对我们来说,人工智能是智能的幻觉。我们的智能实体不必一定学习事物,但至少必须让玩家相信它们正在学习。我必须强调,这些定义仅适用于游戏人工智能。正如我们将在本节后面发现的那样,人工智能在游戏之外有许多应用,那里的定义更为恰当。
智能生物,如人类和其他动物,从它们的环境中学习。无论是通过视觉观察、听觉、触觉等,我们的大脑将这些刺激转化为我们处理和学习的知识。同样,我们创建的计算机人工智能必须观察并对其环境做出反应,以显得聪明。虽然我们使用眼睛、耳朵和其他方式来感知,但我们的游戏人工智能实体有一套不同的传感器可供使用。我们的代码将模拟数据的处理和那些模拟对数据做出逻辑和可信反应的行为,而不是使用像我们一样的大脑。
人工智能及其众多相关研究内容丰富且多样,但在深入探讨该主题之前,了解不同领域中使用的人工智能基础是非常重要的。人工智能只是一个通用术语;其各种实现和应用因不同需求而异,用于解决不同的问题集。
在我们转向特定于游戏的技巧之前,让我们看看以下在过去几十年中取得了巨大进步的 AI 应用研究领域。曾经被认为是科幻的东西正在迅速成为科学事实,例如自主机器人和自动驾驶汽车。你不需要看得很远就能找到 AI 进步的绝佳例子——你的智能手机很可能有一个依赖于一些新 AI 相关技术的数字助手功能。它可能比你更了解你的日程!以下是推动 AI 的研究领域:
-
计算机视觉: 这是指从视频和照相机等来源获取视觉输入,并对其进行分析以执行特定操作的能力,例如人脸识别、物体识别和光学字符识别。计算机视觉是自动驾驶汽车进步的前沿。即使是相对简单的系统,如碰撞缓解和自适应巡航控制,也使用一系列传感器来确定深度上下文,以帮助防止碰撞。
-
自然语言处理 (NLP): 这是指机器能够像我们通常书写和说话一样阅读和理解语言的能力。问题是,我们今天使用的语言对机器来说很难理解。表达相同意思的方式有很多种,而且同一个句子根据上下文的不同可以有不同的含义。NLP 是机器的一个重要步骤,因为它们在处理和相应地做出反应之前,需要理解我们使用的语言和表达方式。幸运的是,网络上可用的数据集数量庞大,可以帮助研究人员通过自动分析语言来进行研究。
-
常识推理: 这是一种我们的大脑可以轻松使用的技巧,即使是从我们不完全理解的领域也能得出答案。常识知识是我们尝试某些问题的通常和常见方式,因为我们的大脑可以混合和相互作用上下文、背景知识和语言能力。但是,让机器应用这种知识非常复杂,并且仍然是研究人员面临的一个主要挑战。
-
机器学习: 这可能听起来像是直接来自科幻电影的东西,而现实并不太遥远。计算机程序通常由一组静态的指令组成,这些指令接受输入并提供输出。机器学习专注于编写算法和程序的科学,这些算法和程序可以从程序处理的数据中学习,并将其应用于未来的学习。
神经网络
经过多年的研究和开发,人工智能是一个快速发展的领域。随着消费级计算机硬件变得越来越强大,开发者们正在寻找新的和令人兴奋的方法,将越来越复杂的 AI 形式应用于各种应用中。其中一个这样的 AI 概念是神经网络,这是我们之前章节中提到的机器学习的一个子集。神经网络使计算机能够“学习”,并通过重复训练,在解决各种问题方面变得越来越高效和有效。一个测试神经网络机器学习的非常流行的练习是教人工智能如何辨别一组手写数字的价值。
在我们所说的监督学习中,我们为神经网络提供一组训练数据。在手写数字场景中,我们传递数百或数千张从任何包含手写数字的来源收集的图像。使用称为反向传播的过程,网络可以通过它刚刚“学习”的值和数据来调整自己,以在下一个学习周期的迭代中创建更准确的预测。
信不信由你,神经网络的概念自 20 世纪 40 年代以来就已经存在,最早的实施发生在 20 世纪 50 年代初。从高层次来看,这个概念相当简单——一系列称为神经元的节点通过它们的轴突或连接器相互连接。如果这些术语听起来熟悉,那是因为它们是从具有相同名称的脑细胞结构中借用的,并且在某些方面具有相似的功能。
这些网络的层相互连接。通常,有一个输入层、一个隐藏层和一个输出层。这种结构可以用以下图表表示:

基本神经网络结构
输入,代表智能体正在接收的数据,例如图像、音频或其他任何东西,会通过一个隐藏层,该层将数据转换为程序可以使用的格式,然后将这些数据发送到输出层进行最终处理。
在神经网络机器学习中,并非所有输入都是平等的;至少,它不应该如此。输入在传递到隐藏层之前会被加权。虽然一开始使用相等的权重通常是可行的,但程序可以通过反向传播在每个迭代中自我调整这些权重。简单来说,权重是输入数据在预测中可能有用性的概率。
经过多次训练迭代后,人工智能将能够处理全新的数据集,即使它以前从未遇到过!虽然机器学习在游戏中的应用仍然有限,但这个领域仍在扩展,并且现在是热门话题。确保不要错过这趟列车,并查看 Rodolfo Bonnin 的开发者机器学习,深入了解与机器学习相关的一切。
用人工智能提升你的游戏水平
游戏中的 AI 可以追溯到最早的电子游戏,甚至远至南梦宫的街机热门游戏《吃豆人》。当时的 AI 非常基础,但在《吃豆人》中,每个敌人——Blinky、Pinky、Inky 和 Clyde——都有独特的行动方式,以不同的方式挑战玩家。学习这些行为并对它们做出反应,为游戏增添了巨大的深度,并使玩家即使在其发布 30 多年后仍然回来玩。
优秀的游戏设计师的职责是让游戏既有挑战性又吸引人,但难度不能过高,以免玩家永远无法获胜。为此,人工智能是一个神奇的工具,可以帮助抽象化游戏中实体遵循的规律,使它们看起来更加自然、生动和真实。就像动画师通过每一帧或艺术家通过他的画笔一样,设计师或程序员可以通过巧妙地使用本书中涵盖的人工智能技术,为他们的创作注入生命。
人工智能在游戏中的角色是通过提供具有挑战性的实体来竞争,以及游戏世界中行为真实的非玩家角色(NPCs)。这里的目的是不是复制人类或动物的全部思维过程,而是仅仅通过让 NPCs 以对玩家有意义的方式对游戏世界中的变化情况做出反应,来营造生命的幻觉并使 NPCs 看起来更聪明。
技术使我们能够设计和创建复杂的模式和行为,但我们还没有达到游戏中的 AI 甚至开始类似于真正的人类行为的程度。虽然更小、更强大的芯片、大量的内存甚至分布式计算为程序员提供了更高的计算天花板,可以用于 AI,但最终,资源仍然与其他操作共享,如图形渲染、物理模拟、音频处理、动画等,所有这些都在实时进行。所有这些系统都必须相互配合,以在整个游戏过程中保持稳定的帧率。就像游戏开发中的所有其他学科一样,优化 AI 计算对 AI 开发者来说仍然是一个巨大的挑战。
在 Unity 中使用 AI
在本节中,我们将向您介绍不同类型游戏中使用的一些人工智能技术。我们将在接下来的章节中学习如何在 Unity 中实现这些功能。Unity 是一个灵活的引擎,提供了多种实现 AI 模式的方法。其中一些可以直接使用,而另一些则需要从头开始构建。在本书中,我们将专注于在 Unity 中实现最关键的 AI 模式,以便您能够快速启动并运行游戏中的 AI 实体。学习和实现本书中的技术将是进入广阔的 AI 世界的基本第一步。本书中我们将涉及到的许多概念,如路径查找和导航网格,都是相互关联的,并且建立在彼此之上。因此,在深入研究 Unity 提供的高级 API 之前,首先确保掌握基础知识是非常重要的。
定义代理
在我们深入探讨第一种技术之前,我们应该清楚了解本书中会多次使用的关键术语——代理。在 AI 的背景下,代理是我们的人工智能实体。当我们谈论我们的 AI 时,我们并不是特指一个角色,而是一个表现出复杂行为模式、可以被称为非随机或换句话说,智能的实体。这个实体可以是角色、生物、车辆或其他任何东西。代理是自主实体,执行我们将要讨论的模式和行为。现在我们已经明确了这一点,让我们开始吧。
有限状态机
有限状态机(FSM)可以被认为是 simplest 的 AI 模型之一,并且它们在游戏中被广泛使用。状态机基本上由一组通过它们之间的转换连接在一起的状态组成。游戏实体从一个初始状态开始,然后寻找将触发转换到另一个状态的事件和规则。游戏实体在任何给定时间只能处于确切的一个状态。
例如,让我们看看一个典型射击游戏中的 AI 守卫角色。其状态可能非常简单,如巡逻、追逐和射击:

简单的 FSM 中基本上有四个组件:
-
状态:此组件定义了一组游戏实体或 NPC 可以选择的不同状态(巡逻、追逐和射击)
-
转换:此组件定义了不同状态之间的关系
-
规则:此组件用于触发状态转换(玩家被发现、足够近可以攻击,以及失去/被杀的玩家)
-
事件:这是触发以检查规则(守卫的可见区域、与玩家的距离等)的组件
状态机(FSMs)在游戏开发中是常用的 AI 模式,因为它们相对容易实现、可视化和理解。我们可以通过简单的 if/else 语句或 switch 语句轻松实现一个状态机。当我们开始有更多状态和更多转换时,事情可能会变得混乱。我们将在第二章,《有限状态机与您》中更深入地探讨如何管理一个简单的状态机。
通过我们的代理的眼睛看世界
为了使我们的 AI 更具说服力,我们的代理需要能够对他周围的事件、环境、玩家,甚至其他代理做出反应。就像真实的生物体一样,我们的代理可以依靠视觉、声音和其他“物理”刺激。然而,我们有优势,能够访问比真实生物从其周围环境获得的数据多得多的数据,例如玩家的位置,无论他们是否在附近,他们的库存,世界上物品的位置,以及你在代码中选择的任何变量,你可以将其暴露给该代理:

在前面的图中,我们代理的视野由其前面的锥形表示,其听觉范围由围绕它的灰色圆圈表示:
视觉、声音和其他感官在最基本层面上可以被视为数据。视觉只是光粒子,声音只是振动,等等。虽然我们不需要复制光粒子不断弹跳并进入我们代理眼睛的复杂性,但我们仍然可以以产生可信结果的方式对数据进行建模。
如你所想,我们可以以类似的方式模拟其他感官系统,而不仅仅是用于生物体(如视觉、声音或嗅觉)的系统,甚至可以用于敌人机器人或塔等数字和机械系统,例如声纳和雷达。
如果你曾经玩过 Metal Gear Solid,那么你肯定在游戏中见过这些概念的实际应用——敌人的视野在玩家的迷你地图上以锥形视野表示。进入锥形视野,敌人的头上会出现一个感叹号,随后是一个清晰的铃声,让玩家知道他们已经被发现了。
跟随路径和转向
有时,我们希望我们的 AI 角色在游戏世界中四处游荡,遵循大致引导或详细定义的路径。例如,在赛车游戏中,AI 对手需要导航道路。在 RTS 游戏中,你的单位需要能够从他们所在的位置到达你告诉他们的位置,通过地形和彼此周围导航。
为了显得聪明,我们的智能体需要能够确定它们要去哪里,如果它们能够到达那个点,它们应该能够规划出最有效的路径,并在导航过程中如果出现障碍物时修改该路径。正如你将在后面的章节中了解到的那样,即使是路径跟随和转向也可以通过有限状态机来表示。你将看到这些系统是如何开始相互关联的。
在这本书中,我们将介绍路径查找和导航的基本方法,从我们自己的A*路径查找系统实现开始,接着概述 Unity 内置的导航网格(NavMesh)功能。
Dijkstra 的算法
虽然可能没有 A路径查找(我们将在下一章介绍)那么流行,但理解 Dijkstra 算法至关重要,因为它为在图中找到两个节点之间最短路径的其他类似方法奠定了基础。该算法由Edsger W. Dijkstra于 1959 年发表。Dijkstra 是一位计算机科学家,尽管他可能最出名的是以其名字命名的算法,但他也参与了其他重要计算概念的开发,例如信号量。可以说,Dijkstra 在开发他的算法时可能并没有考虑到StarCraft,但这些概念在游戏人工智能编程中得到了完美的应用,并且至今仍然相关。
那么这个算法实际上做什么?简而言之,它通过给每个连接节点分配基于距离的值来计算图中两个节点之间的最短路径。起始节点被赋予零值。当算法遍历一个尚未访问的连接节点列表时,它会计算到该节点的距离并分配给该节点一个值。如果节点在循环的前一个迭代中已经被分配了值,它将保持最小的值。然后算法选择具有最小距离值的连接节点,并将之前选择的节点标记为已访问,因此它将不再被考虑。这个过程会重复,直到所有节点都被访问。有了这些信息,你就可以计算出最短路径。
需要帮助理解 Dijkstra 算法?旧金山大学创建了一个方便的可视化工具:Dijkstra 算法可视化。
虽然 Dijkstra 算法已经非常完美,但其变体已被开发出来,可以更有效地解决问题。A*就是其中之一,由于它在速度上优于 Dijkstra 原始版本,因此它是游戏中应用最广泛的路径查找算法之一。
使用 A*路径查找
在许多游戏中,你可以找到跟随玩家或避开障碍物到达特定点的怪物或敌人。例如,让我们以一个典型的实时战略(RTS)游戏为例。你可以选择一组单位并点击你希望它们移动到的位置,或者点击敌方单位进行攻击。然后,你的单位需要找到一种方法到达目标,同时不与障碍物碰撞或尽可能智能地避开它们。敌方单位也需要能够做到这一点。障碍物可能因单位、地形或其他游戏实体而异。例如,空军单位可能能够飞越山脉,而地面或炮兵单位则需要找到绕过它的方法。A*(发音为“A star”)是一种路径查找算法,由于它的性能和准确性,在游戏中被广泛使用。让我们通过一个例子来看看它是如何工作的。假设我们希望我们的单位从点 A 移动到点 B,但中间有一堵墙阻挡,它不能直接朝向目标前进。因此,它需要找到一种方法到达点 B,同时避开墙壁。以下图示说明了这一场景:

为了找到从点 A 到点 B 的路径,我们需要更多地了解地图,例如障碍物的位置。为此,我们可以将整个地图分成小块瓦片,以网格格式表示整个地图。瓦片也可以是其他形状,如六边形和三角形。以网格形式表示整个地图使搜索区域更加简化,这是路径查找的重要步骤。现在我们可以参考一个小的 2D 数组来表示我们的地图:

一旦我们的地图被一组瓦片表示,我们就可以开始通过计算起始瓦片相邻的每个瓦片的移动得分来寻找到达目标的最佳路径,这个瓦片是地图上没有被障碍物占据的瓦片,然后选择成本最低的瓦片。我们将在第三章“寻找路径”中深入探讨我们如何分配得分和遍历网格的具体方法,但简而言之,这是 A*路径查找的概念:

A*路径查找计算穿越瓦片的成本
A* 是在寻路时需要了解的重要模式,但 Unity 也为我们提供了一些开箱即用的功能,例如自动导航网格生成和 NavMesh 代理,我们将在下一节中探讨这些功能,并在第三章 Finding Your Way 中更详细地介绍。这些功能使得在游戏中实现寻路变得非常容易(无意中用了双关语)。无论你选择实现自己的 A* 解决方案还是简单地使用 Unity 内置的 NavMesh 功能,都将取决于你的项目需求。每个选项都有其优缺点,但最终,了解两者将使你能够做出最佳选择。话虽如此,让我们快速了解一下 NavMesh。
IDA* 寻路
IDA* 星号代表迭代加深 A。它是对 A 的深度优先排列,具有更低的总体内存成本,但通常在时间成本上更高。A* 在同一时间保持多个节点在内存中,而 IDA* 由于是深度优先搜索,所以不会这样做。因此,IDA* 可能会多次访问相同的节点,导致更高的时间成本。任何一种解决方案都会给出两个节点之间的最短路径。
在图太大以至于 A* 在内存方面不可行的情况下,IDA* 是首选,但普遍认为 A* 对于游戏中的大多数用例来说已经足够好了。话虽如此,我们将在第四章 Finding Your Way 中探讨这两种解决方案,这样你可以得出自己的结论,并为你的游戏选择正确的寻路算法。
使用导航网格
现在我们已经简要地了解了 A,让我们看看一些可能的情况,在这些情况下,我们可能会发现 NavMesh 是计算网格的一个合适的方法。你可能注意到,在 A 中使用简单的网格需要相当多的计算来获得到目标点的最短路径,同时避免障碍物。因此,为了使它更便宜、更容易,AI 角色能够找到路径,人们提出了使用路标点作为指南,将 AI 角色从起点移动到目标点的想法。假设我们想将我们的 AI 角色从点 A 移动到点 B,并且我们已经设置了三个路标点,如图所示:

现在我们只需要选择最近的路标点,然后跟随其连接的节点到达目标路标点。大多数游戏使用路标点进行寻路,因为它们简单且在减少计算资源方面相当有效。然而,它们确实存在一些问题。如果我们想更新地图中的障碍物怎么办?我们还需要为更新后的地图再次放置路标点,如图所示:

每次你的关卡布局发生变化时,必须手动更改航点可能会很麻烦,并且非常耗时。此外,跟随每个节点到目标意味着 AI 角色会从节点到节点移动一系列的直线。看看前面的图;AI 角色很可能会在路径靠近墙壁的地方撞到墙上。如果发生这种情况,我们的 AI 会不断尝试穿过墙壁以到达下一个目标,但它无法做到,并且会卡在那里。尽管我们可以通过将其转换为样条曲线并进行一些调整来平滑路径,以避免此类障碍,但问题在于航点没有给我们提供任何关于环境的信息,除了样条曲线在两个节点之间连接。如果我们的平滑和调整后的路径通过了悬崖或桥梁的边缘呢?新的路径可能不再是安全的路径。因此,为了使我们的 AI 实体能够有效地穿越整个关卡,我们需要大量的航点,这将非常难以实现和管理。
这是一个 NavMesh 最有意义的场景。NavMesh 是一种可以用来表示我们世界的另一种图结构,类似于我们用正方形瓦片网格或航点图所做的方式,如下面的图所示:

导航网格使用凸多边形来表示地图上 AI 实体可以到达的区域。使用导航网格最重要的好处是它比航点系统提供了更多关于环境的信息。现在我们可以安全地调整路径,因为我们知道我们的 AI 实体可以旅行的安全区域。使用导航网格的另一个优点是我们可以为不同类型的 AI 实体使用相同的网格。不同的 AI 实体可以有不同的属性,如大小、速度和移动能力。一组航点是为人类量身定制的;AI 可能不适合飞行生物或 AI 控制的车辆。这些可能需要不同的航点集。在这种情况下,使用导航网格可以节省大量时间。
根据场景程序生成导航网格可能是一个相对复杂的过程。幸运的是,Unity 3.5 引入了一个内置的导航网格生成器作为专业版功能,但自 Unity 5 个人版开始,它现在是免费的。Unity 的实现提供了一箱额外的功能。不仅包括生成 NavMesh 本身,还包括在生成的图上(当然是通过 A*)进行代理碰撞和路径查找。第四章“找到你的路”,将探讨我们可以使用 Unity 的 NavMesh 功能的一些有用和有趣的方法,并探讨 Unity 2017.1 带来的新增功能和改进。
群体和人群动态
在自然界中,我们可以观察到我们所说的群聚行为,这在几种物种中都可以观察到。群聚简单地说就是一群动物一起移动。鱼群、羊群和蝉群都是这种行为的绝佳例子。使用手动方式,如动画,来模拟这种行为可能会非常耗时,并且不够动态。在第五章“群聚与群体”中,我们将探讨一种动态和程序化的方法来以可信的方式模拟这种行为,使用一组简单的规则来驱动群体及其成员相对于其周围环境的行为。
同样,无论是步行还是乘坐车辆的人类群体,都可以通过将整个群体表示为一个实体来建模,而不是试图将每个个体作为其自己的代理来建模。群体中的每个个体实际上只需要知道群体将前往何方以及他们的最近邻在做什么,以便作为系统的一部分发挥作用。
行为树
行为树是另一种用于表示和控制人工智能代理背后逻辑的模型。行为树在 AAA 游戏如光环和孢子等应用中变得非常流行。之前,我们简要介绍了有限状态机(FSM)。它们提供了一种非常简单但高效的方法来定义代理的可能行为,基于不同的状态及其之间的转换。然而,FSMs 被认为难以扩展,因为它们可以很快变得难以控制,并且需要相当多的手动设置。我们需要添加许多状态并硬编码许多转换,以便支持我们希望代理考虑的所有场景。因此,当我们处理大型问题时,我们需要一个更可扩展的方法。这就是行为树发挥作用的地方。
行为树是一组按层次顺序组织的节点,其中节点连接到父节点,而不是状态相互连接,类似于树上的分支,因此得名。
行为树的基本元素是任务节点,而状态是 FSM 的主要元素。有几个不同的任务,如序列(Sequence)、选择器(Selector)和平行装饰器(Parallel Decorator)。跟踪它们所有的作用可能会有些令人畏惧。理解这些的最佳方式是查看一个示例。让我们将以下转换和状态分解为任务,如图所示:

让我们看看这个行为树中的一个选择器任务。选择器任务由一个带有问号的圆圈表示。选择器将按顺序评估每个子节点,从左到右。首先,它将选择攻击玩家;如果攻击任务返回成功,选择器任务就完成了,并将返回到父节点(如果有的话)。如果攻击任务失败,它将尝试追逐任务。如果追逐任务失败,它将尝试巡逻任务。以下图显示了此树概念的基本结构:

测试是行为树中的任务之一。以下图表显示了序列任务的用法,用内部带有箭头的矩形表示。根选择器可以选择第一个序列动作。这个序列动作的第一个任务是检查玩家角色是否足够接近可以攻击。如果这个任务成功,它将继续执行下一个任务,即攻击玩家。如果攻击任务也成功返回,整个序列将返回成功,选择器将完成这个行为,并且不会继续执行其他序列任务。如果接近检查任务失败,序列动作将不会继续执行攻击任务,并将返回失败状态给父选择器任务。然后选择器将选择序列中的下一个任务,玩家是否已失踪或死亡?以下图表展示了这个序列:

另外两个常见的组件是并行任务和装饰器。并行任务将同时执行其所有子任务,而序列和选择器任务则逐个执行其子任务。装饰器是另一种只有单个子任务的任务类型。它可以改变其子任务的行为,包括是否运行其子任务、应该运行多少次等。我们将在第六章中学习如何在 Unity 中实现基本的行为树系统,行为树。
使用模糊逻辑思考
最后,我们来到了模糊逻辑。简单来说,模糊逻辑是指对结果进行近似,而不是得出二进制结论。我们可以使用模糊逻辑和推理为我们的 AI 增加另一层真实性。
让我们以一款第一人称射击游戏中的通用坏蛋士兵作为我们的代理来阐述这个基本概念。无论我们使用有限状态机还是行为树,我们的代理都需要做出决策。我应该移动到状态 x、y 还是 z?这个任务会返回真还是假?如果没有模糊逻辑,我们会查看二进制值(真或假,或 0 或 1)来确定这些问题的答案。例如,我们的士兵能否看到玩家?这是一个是/否的二进制条件。然而,如果我们进一步抽象决策过程,我们可以让我们的士兵表现出更有趣的行为。一旦我们确定我们的士兵可以看到玩家,士兵就可以“询问”自己是否有足够的弹药杀死玩家,或者是否有足够的健康值来抵御射击,或者是否有其他盟友在其周围协助击倒玩家。突然间,我们的 AI 变得更加有趣、不可预测,并且更可信。
这一层额外的决策是通过使用模糊逻辑实现的,从最简单的术语来说,它归结为看似任意或模糊的术语,我们复杂的大脑可以轻松地赋予其意义,例如“热”与“温暖”,“冷”与“凉爽”,将这些转化为计算机可以轻松理解的值集。在第七章,“使用模糊逻辑使您的 AI 看起来更有生命力”中,我们将更深入地探讨如何在游戏中使用模糊逻辑。
摘要
游戏人工智能和学术人工智能有不同的目标。学术人工智能研究人员试图解决现实世界的问题,并证明一个理论,在资源方面没有太多限制。游戏人工智能专注于在有限的资源内构建 NPC,使其对玩家看起来很智能。游戏人工智能的目标是提供一个具有挑战性的对手,使游戏更具趣味性。
我们简要地了解了在游戏中广泛使用的不同人工智能技术,例如有限状态机、传感器和输入系统、群体和人群行为、路径跟随和转向行为、人工智能路径查找、导航网格、行为树和模糊逻辑。
在接下来的章节中,我们将探讨一些有趣且相关的方法,帮助您将这些概念应用到游戏中,使游戏更加有趣。我们将从第二章,“有限状态机与您”,以及我们自己的有限状态机实现开始,深入探讨代理和状态的概念以及它们在游戏中的应用。
第二章:有限状态机与您
在本章中,我们将扩展我们对 FSM 模式和其在游戏中的应用的知识,并学习如何在简单的 Unity 游戏中实现它。我们将使用本书附带示例代码创建一个坦克游戏。我们将剖析这个项目中的代码和组件。我们将讨论的主题如下:
-
理解 Unity 的状态机功能
-
创建我们自己的状态和转换
-
使用示例创建一个场景
Unity 5 引入了状态机行为,这是对 4.x 周期中引入的 Mecanim 动画状态的通用扩展。然而,这些新的状态机行为与动画系统无关,我们将学习利用这些新功能快速实现基于状态的人工智能系统。
在我们的游戏中,玩家将能够控制一个坦克。敌方坦克将在场景中以四个航点为参考移动。一旦玩家坦克进入它们的可见范围内,它们将开始追逐我们,一旦它们足够接近可以攻击,它们就会开始向我们的坦克代理射击。这个简单的例子将是一个在人工智能和状态 FSM 世界中获得乐趣的好方法。
寻找 FSM 的应用
虽然我们将主要关注使用 FSM 来实现游戏中的 AI,使其更加有趣和吸引人,但重要的是要指出,FSMs 在游戏和软件设计和编程中被广泛使用。实际上,我们将使用的 Unity 2017 系统最初是在 Mecanim 动画系统中引入的。
在我们的日常生活中,我们可以将许多事物分类为状态。编程中最有效的模式是那些模仿现实生活设计的简单性,FSM 也不例外。环顾四周,你很可能会注意到许多事物处于任何可能的多种状态之一。例如,附近有灯泡吗?灯泡可以处于两种状态之一——开或关(只要我们不谈论那些花哨的调光灯泡)。让我们暂时回到小学,回想一下我们学习物质不同状态的时间。例如,水可以是固体、液体或气体。就像在编程中的 FSM 模式中,变量可以触发状态变化一样,水的状态从一种到另一种的转变是由热量引起的:

水的三个不同状态
虽然编程设计模式中没有超出我们自身实现之外的硬性规则,但 FSM 的一个特点是任何给定时间都处于一个,并且只有一个状态。话虽如此,转换允许在两个状态之间进行“交接”,就像冰慢慢融化成水一样。此外,一个代理可以拥有多个 FSM,驱动任何数量的行为,状态甚至可以包含它们自己的状态机——想想克里斯托弗·诺兰的盗梦空间,但用状态机代替梦境。
在 C# 和 Unity 中,有限状态机(FSM)有许多不同的实现和变体,其中许多可以在 Unity Asset Store 中找到,但它们有一些关键特性:
-
它们定义了对象可能处于的各种状态
-
它们提供了一种从一种状态转换到另一种状态的机制
-
它们提供了一种定义控制转换规则的方式
Unity 的 Mecanim 系统,虽然最初是为了以基于状态的方式驱动动画而设计的,但它非常适合提供更通用、更少为人所知的 FSM 功能。
创建状态机行为
既然我们已经熟悉了状态机的概念,让我们动手实现我们自己的状态机。
截至 Unity 2017.1,状态机仍然是动画系统的一部分,但不用担心,它们是灵活的,实际上不需要动画来实现它们。如果你看到引用 Animator 组件或 AnimationController 资产的代码,不要感到惊讶或困惑,因为这仅仅是当前实现的一个特性。Unity 可能会在以后的版本中解决这个问题,但概念可能不会改变。
让我们启动 Unity,创建一个新的项目,并开始操作。
创建 AnimationController 资产
AnimationController 资产是 Unity 中的一种资源类型,用于处理状态和转换。本质上,它是一个 FSM,但它还做了更多。我们将专注于其功能中的 FSM 部分。动画控制器可以从资产菜单创建,如下面的图像所示:

一旦创建动画控制器,它就会出现在你的项目资源文件夹中,准备被命名。我们将命名为 TankFsm。当你选择动画控制器时,与大多数其他资源类型不同,层次结构是空的。这是因为动画控制器使用它们自己的窗口。你可以简单地点击层次结构中的“打开”来打开动画器窗口,或者像以下截图所示,在窗口菜单中打开:

确保选择“Animator”而不是“Animation”,因为这两个窗口和功能完全不同。
在继续之前,让我们熟悉这个窗口。
层和参数
如同其名,层允许我们在彼此之上堆叠不同的状态机级别。这个面板允许我们轻松组织层,并有一个视觉表示。目前我们在这个面板上不会做太多,因为它主要与动画相关,但了解它是好的。参考以下窗口截图以了解如何在层中导航:

以下是上一张截图中所显示的项目摘要:
-
添加层:+ 按钮在列表底部创建一个新的层。
-
层列表:这些是目前在动画控制器内部的层。你可以点击来选择一个层,并通过拖放来重新排列层。
-
层设置:齿轮图标会弹出一个菜单,用于编辑层的特定动画设置。
其次,我们有参数面板,这与我们的动画控制器使用密切相关。参数是决定何时在状态之间转换的变量,我们可以通过脚本访问它们来驱动我们的状态。有四种类型的参数——float、int、bool和trigger。你应该已经熟悉前三种,因为它们是 C#中的基本类型,但trigger是特定于动画控制器的,不要与物理触发器混淆,物理触发器在这里不适用。触发器只是显式触发状态之间转换的一种手段。
以下截图显示了参数面板中的元素:

下面是上一张截图中所展示的项目摘要:
-
搜索:我们可以在这里快速搜索我们的参数。只需输入名称,列表就会填充搜索结果。
-
添加参数:此按钮允许你添加新参数。当你点击它时,你必须选择参数类型。
-
参数列表:这是你创建的参数列表。你可以在这里分配和查看它们的值。你也可以通过拖放来重新排列参数,以符合你的喜好。这只是为了组织,根本不影响功能。
最后,有一个眼球图标,你可以点击来完全隐藏层和参数面板。当面板关闭时,你仍然可以通过点击层下拉菜单并选择创建新层来创建新层:

动画控制器检查器
动画控制器检查器与 Unity 中找到的常规检查器略有不同。虽然常规检查器允许你向游戏对象添加组件,但动画控制器检查器有一个名为“添加行为”的按钮,允许你向其中添加StateMachineBehaviour。这是两种类型检查器之间的主要区别,但除此之外,它将显示任何选定状态、子状态、过渡或混合树的序列化信息,就像常规检查器显示选定游戏对象及其组件的数据一样。
将行为引入画面
状态机行为是 Unity 5 中的一个独特、新的概念。虽然状态在 Mecanim 的原版实现中在概念上存在,但过渡是在幕后处理的,你无法对进入、转换或退出状态时发生的事情有太多控制。Unity 5 通过引入行为来解决这一问题;它们提供了处理典型有限状态机(FSM)逻辑的内置功能。
行为(Behaviors)是狡猾且复杂的。尽管它们的名称可能让你认为它们与MonoBehaviour有关联,但不要上当;实际上,这两者最多只是远亲。实际上,行为是从ScriptableObject派生出来的,而不是从MonoBehaviour,因此它们仅作为资产存在,不能放置在场景中或作为组件添加到GameObject中。
创建我们的第一个状态
好吧,所以这个标题并不完全准确,因为 Unity 在我们的动画控制器中为我们创建了一些默认状态——New State、Any State、Entry 和 Exit,但让我们先同意现在我们不考虑这些。让我们看看我们可以在新创建的动画控制器中做些什么:
-
您可以通过单击此窗口中的状态来选择它们,并且可以通过拖放它们到画布上的任何位置来移动它们。
-
选择名为“New State”的状态,并通过右键单击然后点击删除或简单地按键盘上的Delete键来删除它。
-
如果您选择 Any State 状态,您会注意到您没有删除它的选项。Entry 状态也是如此。这些是在动画控制器中必需的状态,并且有独特的用途,我们将在稍后讨论:

要创建我们的(真正的)第一个状态,请在画布上的任何位置右键单击,然后选择创建状态,这将打开一些选项,我们将从中选择空状态。其他两个选项,从所选剪辑和从新混合树创建,对我们当前的项目并不立即适用,所以我们将跳过这些选项。现在我们已经正式创建了我们的第一个状态。
状态之间的转换
您会注意到,在创建我们的状态时,一个箭头被创建,连接到 Entry 状态,并且它的节点是橙色的。Unity 会自动将默认状态设置为橙色,以区分其他状态。当您只有一个状态时,它将自动被选中为默认状态,并且因此,它将自动连接到入口状态。您可以通过右键单击它并点击设置为层默认状态来手动选择哪个状态是默认状态。它将变成橙色,入口状态将自动连接到它。连接的箭头是转换连接器。转换连接器允许我们控制转换如何以及何时发生,但从入口状态到默认状态的连接器是独特的,因为它不为我们提供任何选项,因为这个转换是自动发生的。
您可以通过右键单击状态节点并选择创建转换来手动分配状态之间的转换。这将从您选择的状态创建一个转换箭头到鼠标光标。要选择转换的目标,只需单击目标节点即可。请注意,您不能重定向转换。我们只能希望 Unity 背后的好心人在以后添加该功能,但到目前为止,您必须通过选择它并删除它来删除转换,然后手动分配全新的转换。
设置我们的玩家坦克
打开本书附带的本章示例项目。
在您的项目文件夹中按组组织类似资产是一个好主意,以保持它们有序。例如,您可以将您的状态机放在名为StateMachines的文件夹中。本章提供的资产已经分组,因此您可以将本章创建的资产和脚本拖放到相应的文件夹中。
创建敌人坦克
让我们继续在你的资产文件夹中创建一个动画控制器。这将是你敌人坦克的状态机。命名为EnemyFsm。
这个状态机将驱动坦克的基本动作。正如之前在示例中所述,敌人可以巡逻、追逐并射击玩家。让我们继续设置我们的状态机。选择EnemyFsm资产并打开动画器窗口。
现在,我们将继续创建三个空状态,这些状态在概念上和功能上代表我们的敌人坦克的状态。命名为Patrol、Chase和Shoot。一旦创建并命名,我们就要确保我们分配了正确的默认状态。目前,这取决于你创建和命名状态的顺序,但我们希望巡逻状态是默认状态,所以右键单击它并选择设置为层默认状态。现在它被涂成橙色,入口状态连接到它。
选择转换
在这一点上,我们必须就我们的状态如何相互流动做出一些设计和逻辑决策。当我们规划这些转换时,我们还想记住触发转换的条件,以确保它们是逻辑的,并且从设计角度来说是可行的。在野外,当你自己应用这些技术时,不同的因素将影响这些转换的处理方式。为了最好地说明当前的主题,我们将保持我们的转换简单且逻辑清晰:
-
巡逻:从巡逻状态,我们可以过渡到追逐状态。我们将使用一系列条件来选择我们将过渡到哪个状态,如果有的话。敌人坦克能看到玩家吗?如果是,我们进入下一步;如果不是,我们继续巡逻。
-
追逐:从这个状态开始,我们希望继续检查玩家是否在视线范围内以继续追逐,是否足够近可以射击,或者完全看不见——这将使我们回到巡逻状态。
-
射击:和之前一样,我们想要检查射击范围,然后是视线,以确定我们是否可以追击进入射程。
这个特定的例子有一组简单且清晰的转换规则。如果我们相应地连接我们的状态,最终得到的图将大致类似于这个:

请记住,节点的放置完全取决于你,并且它不会以任何方式影响状态机的功能。你可以尝试以保持它们组织的方式放置节点,这样你就可以通过视觉跟踪你的转换。
现在我们已经绘制了状态图,让我们给它们分配一些行为。
让齿轮转动
这正是你一直等待的部分。我知道,我让你等了,但这是有原因的。现在我们准备开始编码,我们对 FSM 中状态之间的逻辑联系有了很好的理解。无需多言,选择我们的巡逻状态。在层次结构中,你会看到一个标有添加行为(Add Behaviour)的按钮。点击这个按钮会弹出一个上下文菜单,它与常规游戏对象上的添加组件(Add Component)按钮非常相似,但正如我们之前提到的,这个按钮创建的是独一无二的州机行为。
好吧,给这个行为命名为TankPatrolState。这样做会在你的项目中创建一个同名的脚本,并将其附加到创建它的状态上。你可以通过项目窗口打开这个脚本,或者通过在检查器中双击脚本的名称。你将看到的内容将类似于这个:
using UnityEngine;
using System.Collections;
public class TankPatrolState : StateMachineBehaviour {
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
//override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
//
//}
// OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
//override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
//
//}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
//override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
//
//}
// OnStateMove is called right after Animator.OnAnimatorMove(). Code that processes and affects root motion should be implemented here
//override public void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
//
//}
// OnStateIK is called right after Animator.OnAnimatorIK(). Code that sets up animation IK (inverse kinematics) should be implemented here.
//override public void OnStateIK(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
//
//}
}
下载示例代码
你可以从你购买的所有 Packt Publishing 书籍的账户中下载示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
在我们开始之前,取消注释每个方法。让我们一步一步来分析。Unity 为你创建了此文件,但所有方法都被注释掉了。本质上,被注释的代码充当指南。就像为MonoBehaviour提供的那些方法一样,这些方法是由底层逻辑为你调用的。你不需要了解幕后发生了什么就可以使用它们;你只需要知道它们何时被调用,以便利用它们。幸运的是,被注释的代码提供了每个方法被调用时的简要描述,并且名称本身相当具有描述性。这里有两个我们不需要担心的方法,OnStateIK和OnStateMove,它们是动画消息,所以你可以直接删除它们并保存文件。
为了重申代码注释中所述的内容,以下事情会发生:
-
OnStateEnter在进入状态时被调用,紧接着MonoBehaviors更新之后转换开始
-
OnStateUpdate在每一帧调用,在MonoBehaviors更新之后 -
OnStateExit在离开状态完成后被调用
如我们所述,以下两个状态是动画特定的,因此我们不会为我们的目的使用它们:
-
OnStateIK在 IK 系统更新之前被调用;这是一个动画和绑定特定的概念 -
OnStateMove用于设置了根运动的头像
另一个需要注意的重要信息是传递给这些方法的参数:
-
animator参数是对包含此动画控制器以及因此状态机的动画器的引用。通过扩展,你可以获取动画控制器所在的游戏对象的引用,然后你可以从中获取附加到它上的任何其他组件。记住,状态机行为仅作为一个资产存在,并不存在于类中,这意味着这是获取运行时类引用(如 Mono 行为)的最佳方式。 -
动画器状态信息提供了关于你当前所在状态的信息;然而,这个用途主要集中在对动画状态信息的处理上,因此对我们应用程序来说并不那么有用。
-
最后,我们有层索引,它是一个整数,告诉我们我们的状态在状态机中的哪一层。基本层索引为零,并且每个高于该层的层都有一个更高的数字。
现在我们已经了解了状态机行为的基本原理,让我们整理好其余的组件。在我们真正看到这些行为发挥作用之前,我们必须回到我们的状态机并添加一些将驱动状态的参数。
设置条件
我们需要为我们的敌人坦克提供一些条件以转换状态。这些是实际驱动功能的参数。
让我们从巡逻状态开始。为了让我们的敌人坦克从巡逻状态切换到射击状态,我们需要在玩家的范围内;换句话说,我们将检查敌人和玩家之间的距离,这最好用浮点值表示。因此,在你的参数面板中添加一个浮点数,并将其命名为 distanceFromPlayer。我们还可以使用此参数来确定是否进入追逐状态。
射击状态和追逐状态将共享一个共同的条件,即玩家是否可见。我们将通过一个简单的射线投射来确定这一点,这将反过来告诉我们玩家是否在视线范围内。最适合这个的参数是布尔值,因此创建一个布尔值并将其命名为 isPlayerVisible。保持参数未选中,这意味着 false。
现在我们将通过过渡连接器的检查器分配条件。为此,只需选择一个连接器。选择后,检查器将显示有关当前过渡的一些信息,最重要的是条件,它们将显示为一个列表。要添加条件,只需单击+(加号)符号:

让我们逐个解决每个过渡:
-
巡逻追击
-
distanceFromPlayer < 5
-
isPlayerVisible == true
-

巡逻到追击的转换条件
追击巡逻变得更有趣,因为我们有两个独立的条件可以触发转换。如果我们简单地在那个转换中添加两个条件,两个条件都必须评估为真,转换才会发生。但我们要检查玩家是否超出范围或看不见。幸运的是,我们可以在相同两个状态之间有多个转换。就像通常那样添加另一个转换连接。在“追击”状态上右键单击,然后转换到“巡逻”状态。你会注意到现在在检查器顶部列出了两个转换。此外,你的转换连接指示器显示多个箭头而不是一个,以表示这两个状态之间存在多个转换。在检查器中选择每个转换,你可以为每个转换设置不同的条件:
-
追击巡逻(A)
- distanceFromPlayer > 5
-
追击巡逻(B)
- isPlayerVisible == false
-
追击射击
-
distanceFromPlayer < 3
-
isPlayerVisible == true
-
-
射击追击
-
distanceFromPlayer > 3
-
distanceFromPlayer < 5
-
isPlayerVisible == true
-
-
射击巡逻(A)
- distanceFromPlayer > 6
-
射击巡逻(B)
- isPlayerVisible == false
我们现在已经设置了状态和转换。接下来,我们需要创建一个脚本,来驱动这些值。我们只需要设置这些值,状态机就会处理其余的部分。
通过代码驱动驾驶参数
在继续之前,我们需要从本章早期导入的资产中获取一些东西。首先,打开本章的DemoScene。你会注意到场景相当简化,只包含一个环境预制体和一些航点变换。现在就放下EnemyTankPlaceholder预制体到场景中。
你可能会注意到EnemyTank上的一些你可能熟悉或不熟悉的组件。我们将在第四章“寻找你的路”中彻底探索NavMesh和NavMeshAgent,但到目前为止,这些是使整个系统工作所必需的组件。你将想要关注的是Animator组件,它将包含我们之前创建的状态机(动画控制器)。继续并放下状态机到空槽中,然后继续。
我们还需要为玩家设置一个占位符。现在就放一个PlayerTankPlaceholder预制体进去。目前我们不会对它做太多操作。就像敌人坦克占位符预制体一样,玩家坦克占位符预制体有几个组件我们现在可以忽略。只需将其放置在场景中,然后继续。
接下来,你需要在EnemyTankPlaceholder游戏对象上添加一个新的组件——位于Chapter 2脚本文件夹中的TankAi.cs脚本。如果我们打开脚本,我们会发现它里面是这样的:
using UnityEngine;
using System.Collections;
public class TankAi : MonoBehaviour {
// General state machine variables
private GameObject player;
private Animator animator;
private Ray ray;
private RaycastHit hit;
private float maxDistanceToCheck = 6.0f;
private float currentDistance;
private Vector3 checkDirection;
// Patrol state variables
public Transform pointA;
public Transform pointB;
public NavMeshAgent navMeshAgent;
private int currentTarget;
private float distanceFromTarget;
private Transform[] waypoints = null;
private void Awake() {
player = GameObject.FindWithTag("Player");
animator = gameObject.GetComponent<Animator>();
pointA = GameObject.Find("p1").transform;
pointB = GameObject.Find("p2").transform;
navMeshAgent = gameObject.GetComponent<NavMeshAgent>();
waypoints = new Transform[2] {
pointA,
pointB
};
currentTarget = 0;
navMeshAgent.SetDestination(waypoints[currentTarget].position);
}
private void FixedUpdate() {
//First we check distance from the player
currentDistance = Vector3.Distance(player.transform.position, transform.position);
animator.SetFloat("distanceFromPlayer", currentDistance);
//Then we check for visibility
checkDirection = player.transform.position - transform.position;
ray = new Ray(transform.position, checkDirection);
if (Physics.Raycast(ray, out hit, maxDistanceToCheck)) {
if(hit.collider.gameObject == player){
animator.SetBool("isPlayerVisible", true);
} else {
animator.SetBool("isPlayerVisible", false);
}
} else {
animator.SetBool("isPlayerVisible", false);
}
//Lastly, we get the distance to the next waypoint target
distanceFromTarget = Vector3.Distance(waypoints[currentTarget].position, transform.position);
animator.SetFloat("distanceFromWaypoint", distanceFromTarget);
}
public void SetNextPoint() {
switch (currentTarget) {
case 0:
currentTarget = 1;
break;
case 1:
currentTarget = 0;
break;
}
navMeshAgent.SetDestination(waypoints[currentTarget].position);
}
}
我们有一系列变量是运行此脚本所必需的,所以我们将逐一说明它们的作用:
-
GameObject player:这是我们对之前放置的玩家占位符预制体的引用。 -
Animator animator:这是我们敌方坦克的动画控制器,其中包含我们创建的状态机。 -
Ray ray:这是一个用于在FixedUpdate循环中进行射线投射测试的射线声明。 -
RaycastHit hit:这是一个用于从我们的射线投射测试中接收到的碰撞信息的声明。 -
Float maxDistanceToCheck:这个数字与我们之前在状态机中设置的值相匹配。本质上,我们是在说我们只检查玩家这个距离范围内的值。超出这个范围,我们可以假设玩家已经超出范围。 -
Float currentDistance:这是玩家和敌方坦克之间的当前距离。
你会注意到我们跳过了一些变量。不用担心,我们稍后会回来覆盖这些。这些是我们将在巡逻状态中使用的变量。
我们的 Awake 方法处理获取玩家和动画控制器变量的引用。你还可以将前面的变量声明为公共的或使用 [SerializeField] 属性作为前缀,并通过检查器设置它们。
FixedUpdate 方法相当直接;第一部分获取玩家位置和敌方坦克之间的距离。需要特别注意的部分是 animator.SetFloat("distanceFromPlayer", currentDistance),它将此脚本中的信息传递到我们之前为状态机定义的参数。对于代码的前一部分也是如此,它将射线投射的结果作为布尔值传递。最后,它设置了 distanceFromTarget 变量,我们将在下一节中使用它。
如你所见,没有代码涉及状态机如何或为什么处理转换;它只是传递状态机所需的信息,状态机处理其余部分。很酷,对吧?
让我们的敌方坦克移动
你可能已经注意到,除了我们尚未覆盖的变量之外,我们的坦克还没有移动逻辑。这可以通过一个子状态机轻松处理,这是一个状态内的状态机。这听起来可能有些令人困惑,但我们可以轻松地将巡逻状态分解为子状态。在我们的例子中,巡逻状态将处于两个子状态之一——移动到当前航标或寻找下一个航标。航标本质上是我们智能体移动的目标。为了进行这些更改,我们需要再次进入我们的状态机。
首先,通过在画布上的空白区域单击并选择创建子状态机来创建一个子状态。由于我们已经有原始的 Patrol 状态及其所有相关连接,我们只需将 Patrol 状态拖放到新创建的子状态中即可合并这两个状态。当你将 Patrol 状态拖动到子状态上时,你会注意到光标旁边出现一个加号;这意味着你正在向另一个状态添加一个状态。当你放下 Patrol 状态时,新的子状态将吸收它。子状态有独特的外观:它们是六边形的而不是矩形的。继续将子状态重命名为Patrol:

要进入子状态,只需双击它。把它想象成进入子状态的一个层级。窗口看起来相当相似,但你将会注意到一些事情——你的 Patrol 状态连接到一个名为(Up)Base Layer 的节点,这实际上是从这个层级到子状态机所在的上层级的连接。入口状态直接连接到 Patrol 状态。
很遗憾,这不是我们想要的功能,因为它是一个闭环,不允许我们进入和退出状态到我们需要创建的单独航点状态;所以让我们做一些修改。首先,我们将子状态的名字改为PatrolEntry。接下来,我们需要分配一些转换。当我们进入这个入口状态时,我们想要决定是继续移动到当前航点,还是找到一个新的航点。我们将每个结果表示为一个状态,因此创建两个状态,MovingToTarget和FindingNewTarget,然后从 PatrolEntry 状态到每个新状态创建转换。同样,你也会想要在这两个新状态之间创建一个转换,即从MovingToTarget状态到FindingNewTarget状态以及相反的转换。现在,添加一个新的浮点参数distanceFromWaypoint并设置如下条件:
-
从 PatrolEntry 到 MovingToTarget:
- distanceFromWaypoint > 1
-
从 PatrolEntry 到 FindingNewTarget:
- distanceFromWaypoint < 1
-
从 MovingToTarget 到 FindingNewTarget:
- distanceFromWaypoint < 1
你可能想知道为什么我们没有将转换规则从 FindingNewTarget 状态分配到 MovingToTarget 状态。这是因为我们将通过状态机行为执行一些代码,然后自动进入 MovingToTarget 状态,而不需要任何条件。继续选择 FindingNewTarget 状态并添加一个行为,命名为SelectWaypointState。
打开新的脚本并删除所有方法,除了OnStateEnter。向其中添加以下功能:
TankAi tankAi = animator.gameObject.GetComponent<TankAi>();
tankAi.SetNextPoint();
我们在这里做的是获取我们的TankAi脚本的引用并调用其SetNextPoint()方法。很简单,对吧?
最后,我们需要重新设计我们的输出连接。我们的新状态没有从这个级别退出的转换,因此我们需要添加一个,使用与巡逻进入状态相同的条件,到(向上)基本层状态。这就是 Any State 派上用场的地方——它允许我们从任何状态转换到另一个状态,无论是否存在单独的转换连接,这样我们就不必为每个状态添加到(向上)基本层状态的转换;我们只需将其添加一次到 Any State,就设置好了!从 Any State 添加一个到巡逻进入状态的转换,并使用与进入状态相同的条件到(向上)基本层状态。这是解决无法直接从 Any State 连接到(向上)基本层状态的解决方案。
当你完成时,你的子状态机应该看起来类似于这样:

测试
现在,我们只需按下播放键,观察我们的敌方坦克在两个提供的航点之间来回巡逻。如果我们把玩家放置在编辑器中,在敌方坦克的路径上,我们会看到在动画器中发生转换,从巡逻状态退出并进入追逐状态,当我们把玩家移出范围时,又回到巡逻状态。你会注意到我们的追逐和射击状态还没有完全完善。这是因为我们将通过我们在第三章,“实现传感器”,和第四章,“找到你的路”中将要介绍的概念来实现这些状态。
摘要
在本章中,我们学习了如何在 Unity 2017 中实现状态机,使用基于动画器和控制器的状态机来构建我们的坦克游戏。我们了解了状态机行为以及状态之间的转换。在掌握了所有这些概念之后,我们将简单的状态机应用于一个智能体,从而创建了我们的第一个人工智能实体!
在下一章中,我们将继续构建我们的坦克游戏,并给我们的智能体提供更复杂的方法来感知周围的世界。
第三章:实现传感器
在本章中,我们将学习如何使用类似于生物体所具有的感官系统概念来实现人工智能行为。正如我们之前讨论的,一个角色人工智能系统需要对其环境有所了解,例如障碍物的位置、正在寻找的敌人的位置、敌人是否在玩家的视野中,等等。我们 NPC 的人工智能质量完全取决于它从环境中获取的信息。没有任何事情能像 NPC 卡在墙后那样打破游戏的沉浸感。基于 NPC 可以收集的信息,人工智能系统可以决定对那些数据执行哪种逻辑。如果感官系统没有提供足够的数据,或者人工智能系统无法对那些数据采取适当的行动,代理可能会开始出现故障,或者以与开发者或更重要的是玩家期望相反的方式行事。有些游戏因其滑稽的糟糕人工智能故障而臭名昭著,快速进行一次网络搜索就能找到一些人工智能故障的视频,让人忍俊不禁。
如果我们想检测所有环境参数并检查它们是否与我们的预定值相符,我们可以做到。但是,使用适当的设计模式将帮助我们维护代码,从而更容易扩展。本章将介绍我们可以用来实现感官系统的一个设计模式。我们将涵盖:
-
感官系统是什么
-
存在的一些不同的感官系统
-
如何设置带有传感器的样本罐
基本感官系统
我们代理的感官系统应该能够逼真地模拟现实世界的感官,如视觉、听觉等,以构建其环境模型,就像我们作为人类所做的那样。你在关掉灯后尝试在黑暗中导航房间吗?当你从关灯时的初始位置移动时,这变得越来越困难,因为你的视角发生了变化,你必须越来越多地依赖对房间布局的模糊记忆。虽然我们的感官依赖于并吸收一个不断的数据流来导航它们的环境,但我们的代理的 AI 要宽容得多,它给了我们自由,可以在预定的间隔内检查环境。这使得我们能够构建一个更高效的系统,我们可以只关注对代理相关的环境部分。
基本感官系统的概念是它将包含两个组件,Aspect和Sense。我们的 AI 角色将具有感官,例如感知、嗅觉和触觉。这些感官将寻找特定的方面,如敌人和强盗。例如,你可以有一个具有感知感官的巡逻守卫 AI,它在寻找具有敌人方面的其他游戏对象,或者它可能是一个具有嗅觉感官的僵尸实体,它在寻找被定义为大脑方面的其他实体。
对于我们的演示,这基本上是我们将要实现的内容——一个基础接口称为Sense,它将由其他自定义感官实现。在本章中,我们将实现视角和触觉感官。视角是动物用来观察周围世界的方式。如果我们的 AI 角色看到敌人,我们希望得到通知,以便我们可以采取一些行动。同样,对于触觉,当敌人过于接近时,我们希望能够感知到这一点,几乎就像我们的 AI 角色能听到敌人就在附近一样。然后我们将编写一个最小的Aspect类,我们的感官将寻找这个类。
视野锥
在第二章,《有限状态机与您*,我们设置了我们的智能体,使用视线来检测玩家坦克,这实际上是一个以射线形式存在的线。射线是 Unity 中的一个功能,允许你确定哪些对象被从一点向给定方向发射的线投射所交叉。虽然这是一种以简单方式处理视觉检测的相当有效的方法,但它并不能准确模拟大多数实体视觉工作的方式。使用视线的替代方案是使用锥形视野。如图所示,视野实际上是用锥形形状来模拟的。这可以是 2D 或 3D,根据你的游戏类型而定:

前面的图示说明了视野锥的概念。在这种情况下,从源开始,即智能体的眼睛,锥体逐渐增长,但随着距离的增加,其准确性降低,如锥体颜色逐渐变淡所示。
锥形的实际实现可以从基本的重叠测试到更复杂的现实模型,模仿视觉。在简单实现中,只需要测试一个对象是否与视野锥重叠,忽略距离或边缘。复杂实现更接近地模仿视觉;随着锥体从源向外扩展,视野增长,但看到锥体边缘事物的机会与源中心附近的事物相比减少。
使用球体进行听觉、触觉和嗅觉
一种非常简单而有效的方式来模拟声音、触觉和嗅觉,就是通过使用球体。例如,对于声音,我们可以想象中心是声源,而响度随着听众离中心越远而逐渐消散。相反,听众可以被模拟,代替或补充声音源。听众的听觉通过一个球体来表示,离听众最近的声源更有可能被“听到”。我们可以修改球体的大小和位置相对于我们的智能体,以适应感觉和嗅觉。
下图展示了我们的球体以及我们的智能体如何适应这个设置:

就像视觉一样,根据传感器距离或简单的重叠事件(即只要源重叠球体,感官事件总是被检测到),可以修改代理注册感官事件的可能性。
通过全知全能扩展人工智能
简而言之,全知全能实际上只是让你的 AI 作弊的一种方式。虽然你的代理不一定知道一切,但这仅仅意味着他们可以知道任何事情。在某种程度上,这似乎与现实主义相反,但通常最简单的解决方案是最好的解决方案。让我们的代理访问其周围环境或游戏世界中看似隐藏的信息,可以是一个强大的工具,为游戏提供额外的复杂性。
在游戏中,我们倾向于使用具体值来建模抽象概念。例如,我们可能用一个从 0 到 100 的数值来表示玩家的健康。让我们的代理访问这类信息允许它做出现实的决定,即使获取这类信息在现实中并不现实。你也可以将全知全能视为你的代理能够使用原力或感知游戏世界中的事件,而无需物理上体验它们。
虽然全知全能不一定是一个特定的模式或技术,但它作为游戏开发者工具箱中的另一个工具,可以稍微作弊一下,通过在本质上弯曲人工智能的规则,并给你的代理提供他们可能无法通过物理手段获得的数据,从而使你的游戏更有趣。
在感知方面发挥创意
尽管锥形、球体和线条是代理可以看到、听到和感知其环境的最基本方式,但它们绝不是实现这些感官的唯一方式。如果你的游戏需要其他类型的感知,请随意组合这些模式。想要用圆柱体或球体来表示视野范围?那就这么做吧。想要用盒子来表示嗅觉?那就嗅吧!
利用你手中的工具,想出创造性的方法来根据你的玩家建模感知。通过混合和匹配这些概念,结合不同的方法为你的游戏创建独特的游戏机制。例如,一个对魔法敏感但失明的生物可以完全忽略他们面前的人物,直到他们施展或接收到魔法咒语的效果。也许某些 NPC 可以通过气味追踪玩家,而穿过标记为水的碰撞体可以清除玩家的气味,这样 NPC 就再也无法追踪他。随着你阅读本书的进展,你将获得所有实现这些和其他许多机制的工具——感知、决策、路径查找等等。在我们介绍这些技术的同时,开始思考你游戏中创意的变体。
设置场景
为了开始实现感知系统,你可以直接跳到本章提供的示例,或者按照以下步骤自己设置场景:
-
让我们创建一些障碍物来阻挡 AI 角色到坦克的视线。这些障碍物将是短而宽的立方体,它们位于一个名为
Obstacles的空游戏对象下。 -
添加一个用作地面的平面。
-
然后,我们添加一个方向光,以便我们可以看到场景中正在发生的事情。
如示例所示,有一个目标 3D 模型,我们用它作为玩家,我们用一个简单的立方体来表示我们的 AI 代理。我们还将有一个 Target 对象来显示我们的坦克在场景中将移动到何处。
为了简化,我们的示例提供了一个点光作为 Target 的子对象,这样我们就可以在游戏视图中轻松地看到我们的目标目的地。设置正确后,我们的场景层次结构将类似于以下截图:

场景层次结构
现在,我们将坦克、AI 角色和墙壁随机地放置在我们的场景中。增加平面的尺寸,使其看起来更美观。幸运的是,在这个演示中,我们的对象可以漂浮,所以没有什么会从平面上掉下来。同时,确保调整相机,以便我们可以清楚地看到以下场景:

我们的游戏场景
在完成基本设置后,我们可以开始处理驱动各种系统的代码。
设置玩家坦克和视角
我们的 Target 对象是一个简单的球体游戏对象,移除了网格渲染,因此我们最终只有 Sphere Collider。
查看以下位于 Target.cs 文件中的代码:
using UnityEngine;
public class Target : MonoBehaviour
{
public Transform targetMarker;
void Start (){}
void Update ()
{
int button = 0;
//Get the point of the hit position when the mouse is being clicked
if(Input.GetMouseButtonDown(button))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo;
if (Physics.Raycast(ray.origin, ray.direction, out hitInfo))
{
Vector3 targetPosition = hitInfo.point;
targetMarker.position = targetPosition;
}
}
}
}
你会注意到我们在代码中留下了一个空的 Start 方法。虽然留下空的 Start、Update 和其他不执行任何操作的 MonoBehaviour 事件会有一定的成本,但我们有时可以选择在开发过程中留下 Start 方法,以便在检查器中显示组件的启用/禁用切换。
将此脚本附加到我们的 Target 对象上,这是我们分配给检查器中的 targetMarker 变量的对象。该脚本检测鼠标点击事件,然后使用射线投射,检测 3D 空间中的鼠标点击点。之后,它将 Target 对象更新到场景中的世界空间中的该位置。
射线投射是 Unity 物理 API 的一个功能,它从给定的起点向给定的方向发射一个虚拟射线,并返回沿途遇到的任何碰撞器的数据。
实现玩家坦克
我们的玩家坦克是我们在 第二章 中使用的简单坦克模型,有限状态机与您,并附加了一个刚体组件。刚体组件是必需的,以便在与其他任何 AI 角色进行碰撞检测时生成触发事件。我们需要做的第一件事是将标签 Player 分配给我们的坦克。
Unity 的 Rigidbody 组件中的isKinematic标志使得外部力被忽略,这样您就可以完全从代码或从动画中控制 Rigidbody,同时仍然可以访问 Rigidbody API。
坦克由PlayerTank脚本控制,我们将在稍后创建此脚本。此脚本检索地图上的目标位置,并相应地更新其目的地点和方向。
PlayerTank.cs文件中的代码如下:
using UnityEngine;
public class PlayerTank : MonoBehaviour
{
public Transform targetTransform;
public float targetDistanceTolerance = 3.0f;
private float movementSpeed;
private float rotationSpeed;
// Use this for initialization
void Start ()
{
movementSpeed = 10.0f;
rotationSpeed = 2.0f;
}
// Update is called once per frame
void Update ()
{
if (Vector3.Distance(transform.position, targetTransform.position) < targetDistanceTolerance)
{
return;
}
Vector3 targetPosition = targetTransform.position;
targetPosition.y = transform.position.y;
Vector3 direction = targetPosition - transform.position;
Quaternion tarRot = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(transform.rotation, tarRot, rotationSpeed * Time.deltaTime);
transform.Translate(new Vector3(0, 0, movementSpeed * Time.deltaTime));
}
}

我们坦克对象的属性
前面的截图显示了我们的脚本在应用至坦克后的检查器快照。
此脚本查询地图上目标对象的位置,并相应地更新其目的地点和方向。在我们将此脚本分配给我们的坦克后,务必将我们的目标对象分配给targetTransform变量。
实现 Aspect 类
接下来,让我们看一下Aspect.cs类。Aspect是一个非常简单的类,只有一个名为aspectType的公共枚举类型属性。这就是我们在这个组件中需要的所有变量。每当我们的 AI 角色感知到某物时,我们将检查aspectType以确定它是否是 AI 正在寻找的属性。
Aspect.cs文件中的代码如下:
using UnityEngine;
public class Aspect : MonoBehaviour {
public enum AspectTypes {
PLAYER,
ENEMY,
}
public AspectTypes aspectType;
}
将此属性脚本附加到我们的玩家坦克上,并将aspectType设置为PLAYER,如下面的截图所示:

设置坦克的属性类型
创建 AI 角色
我们的 NPC 将在场景中以随机方向漫游。它将具有以下两种感官:
-
视角感官将检查坦克属性是否在设定的可见范围和距离内
-
触摸感官将检测敌人属性是否与其将要添加到坦克中的盒子碰撞器发生碰撞
由于我们的玩家坦克将具有PLAYER属性类型,NPC 将寻找任何与其自身属性类型不相同的aspectType。
Wander.cs文件中的代码如下:
using UnityEngine;
public class Wander : MonoBehaviour {
private Vector3 targetPosition;
private float movementSpeed = 5.0f;
private float rotationSpeed = 2.0f;
private float targetPositionTolerance = 3.0f;
private float minX;
private float maxX;
private float minZ;
private float maxZ;
void Start() {
minX = -45.0f;
maxX = 45.0f;
minZ = -45.0f;
maxZ = 45.0f;
//Get Wander Position
GetNextPosition();
}
void Update() {
if (Vector3.Distance(targetPosition, transform.position) <= targetPositionTolerance) {
GetNextPosition();
}
Quaternion targetRotation = Quaternion.LookRotation(targetPosition - transform.position);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
transform.Translate(new Vector3(0, 0, movementSpeed * Time.deltaTime));
}
void GetNextPosition() {
targetPosition = new Vector3(Random.Range(minX, maxX), 0.5f, Random.Range(minZ, maxZ));
}
}
当 AI 角色达到其当前目的地点时,Wander脚本会在指定范围内生成一个新的随机位置。然后,Update方法将旋转我们的敌人并将其移动到这个新目的地。将此脚本附加到我们的 AI 角色上,以便它可以在场景中移动。Wander脚本相当简单,但我们在后面的章节中会介绍更高级的运动方法。
使用 Sense 类
Sense类是我们感官系统的接口,其他自定义感官可以实现它。它定义了两个虚拟方法,Initialize和UpdateSense,这些方法将在自定义感官中实现,并分别从Start和Update方法中执行。
虚拟方法是可以使用派生类中的override修饰符重写的方法。与abstract类不同,虚拟类不需要您重写它们。
Sense.cs文件中的代码如下所示:
using UnityEngine;
public class Sense : MonoBehaviour {
public bool enableDebug = true;
public Aspect.AspectTypes aspectName = Aspect.AspectTypes.ENEMY;
public float detectionRate = 1.0f;
protected float elapsedTime = 0.0f;
protected virtual void Initialize() { }
protected virtual void UpdateSense() { }
// Use this for initialization
void Start ()
{
elapsedTime = 0.0f;
Initialize();
}
// Update is called once per frame
void Update ()
{
UpdateSense();
}
}
基本属性包括执行感知操作的检测率以及它应该寻找的方面的名称。由于我们将从它派生我们的实际感知,因此此脚本将不会附加到我们的任何对象上。
给予一点视角
视角感知将检测特定方面是否在其视野和可见距离内。如果它看到任何东西,它将执行指定的操作,在这种情况下是将消息打印到控制台。
Perspective.cs 文件中的代码如下所示:
using UnityEngine;
public class Perspective : Sense
{
public int fieldOfView = 45;
public int viewDistance = 100;
private Transform playerTransform;
private Vector3 rayDirection;
protected override void Initialize()
{
playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
}
protected override void UpdateSense()
{
elapsedTime += Time.deltaTime;
if (elapsedTime >= detectionRate)
{
DetectAspect();
}
}
//Detect perspective field of view for the AI Character
void DetectAspect()
{
RaycastHit hit;
rayDirection = playerTransform.position - transform.position;
if ((Vector3.Angle(rayDirection, transform.forward)) < fieldOfView)
{
// Detect if player is within the field of view
if (Physics.Raycast(transform.position, rayDirection, out hit, viewDistance))
{
Aspect aspect = hit.collider.GetComponent<Aspect>();
if (aspect != null)
{
//Check the aspect
if (aspect.aspectType != aspectName)
{
print("Enemy Detected");
}
}
}
}
}
我们需要实现Initialize和UpdateSense方法,这些方法将分别从父Sense类的Start和Update方法中调用。在DetectAspect方法中,我们首先检查玩家和 AI 当前方向之间的角度。如果它在视野范围内,我们将向玩家坦克所在的方向发射一条射线。射线长度是可见距离属性的值。
Raycast方法将在第一次击中另一个对象时返回。这样,即使玩家在可见范围内,AI 角色也无法看到它是否隐藏在墙壁后面。然后我们检查Aspect组件,并且只有当被击中的对象具有Aspect组件且其aspectType与其自身不同时,它才会返回 true。
OnDrawGizmos方法根据视角视野角度和观看距离绘制线条,这样我们就可以在游戏测试期间在编辑器窗口中看到 AI 角色的视线。将此脚本附加到我们的 AI 角色上,并确保方面类型设置为ENEMY。
此方法可以表示如下:
void OnDrawGizmos()
{
if (playerTransform == null)
{
return;
}
Debug.DrawLine(transform.position, playerTransform.position, Color.red);
Vector3 frontRayPoint = transform.position + (transform.forward * viewDistance);
//Approximate perspective visualization
Vector3 leftRayPoint = frontRayPoint;
leftRayPoint.x += fieldOfView * 0.5f;
Vector3 rightRayPoint = frontRayPoint;
rightRayPoint.x -= fieldOfView * 0.5f;
Debug.DrawLine(transform.position, frontRayPoint, Color.green);
Debug.DrawLine(transform.position, leftRayPoint, Color.green);
Debug.DrawLine(transform.position, rightRayPoint, Color.green);
}
}
触手可及即为信
我们接下来要实现的是Touch.cs,它在玩家坦克实体在 AI 实体附近的一定区域内时触发。我们的 AI 角色具有一个盒子碰撞体组件,并且其IsTrigger标志处于开启状态。
我们需要实现OnTriggerEnter事件,该事件将在另一个碰撞体进入此游戏对象碰撞体的碰撞区域时被调用。由于我们的坦克实体也有碰撞体和刚体组件,因此当 AI 角色的碰撞体和玩家坦克的碰撞体碰撞时,将立即引发碰撞事件。
Unity 提供了两个其他触发事件,除了OnTriggerEnter之外:OnTriggerExit和OnTriggerStay。使用这些事件来检测碰撞体何时离开触发器,以及在每个帧中触发器内的碰撞体何时被触发。
Touch.cs文件中的代码如下所示:
using UnityEngine;
public class Touch : Sense
{
void OnTriggerEnter(Collider other)
{
Aspect aspect = other.GetComponent<Aspect>();
if (aspect != null)
{
//Check the aspect
if (aspect.aspectType != aspectName)
{
print("Enemy Touch Detected");
}
}
}
}
我们的示例 NPC 和坦克已经具有BoxCollider组件。NPC 的传感器碰撞体设置为IsTrigger = true。如果您自己设置场景,请确保您自己添加BoxCollider组件,并且它覆盖足够大的区域以便于测试。我们的触发器可以在以下屏幕截图中看到:

我们玩家周围的碰撞器
上一张截图显示了我们的敌人 AI 上的盒子碰撞器,我们将使用它来触发触摸感应事件。在下一张截图中,我们可以看到我们的 AI 角色是如何设置的:

我们 NPC 的属性
为了演示目的,我们只是打印出敌人方面已被触摸感应检测到,但在你自己的游戏中,你可以实现任何你想要的事件和逻辑。这个系统与本书中涵盖的其他概念结合得非常好,例如状态,我们在第二章“有限状态机与您”中学习了这些内容。
测试结果
在 Unity 编辑器中点击播放,并通过点击地面将玩家坦克移动到附近的游荡 AI NPC 附近。你应该在控制台日志窗口中看到“敌人触摸检测”消息,每当我们的 AI 角色靠近我们的玩家坦克时:

我们正在行动中的 NPC 和坦克
上一张截图显示了一个具有触摸和视角感应的 AI 代理正在寻找另一个方面。将玩家坦克移至 NPC 前面,你会收到“敌人检测”消息。如果你在运行游戏的同时进入编辑器视图,你应该会看到正在渲染的调试线条。这是因为我们在视角“感应”类中实现了 OnDrawGizmos 方法。
概述
本章介绍了使用传感器的概念,并为我们的 AI 角色实现了两种不同的感应——视角和触摸。感官系统是整个决策系统的一个组成部分。我们可以将感官系统与行为系统结合使用,以执行某些感官的特定行为。例如,一旦我们检测到视线范围内有敌人,我们可以使用有限状态机(FSM)从巡逻状态切换到追逐和攻击状态。我们还将介绍如何在第六章“行为树”中应用行为树系统。
在下一章中,我们将探讨流行的路径查找算法。我们将学习如何使用流行的 A* 路径查找算法,以及 Unity 自身的 NavMesh 系统来让我们的 AI 代理在复杂环境中导航。
第四章:寻找路径
障碍物避免是一种简单的行为,允许 AI 实体到达目标点。需要注意的是,本章中实现的具体行为旨在用于如人群模拟等行为,其中每个代理实体的主要目标只是避免其他代理并到达目标。这里没有考虑最有效和最短的路径。我们将在下一节学习 A*路径查找算法。
在本章中,我们将涵盖以下主题:
-
跟随路径和转向
-
自定义 A*路径查找实现
-
Unity 内置的 NavMesh
沿路径行进
在深入探讨 A算法之前,这是一种路径查找的进程式方法,我们将先实现一个更基础的基于航点的系统。虽然更高级的技术,如前面提到的 A方法或 Unity 的 NavMesh,通常会是路径查找的首选方法,但查看一个更简单、更纯粹版本将有助于为理解更复杂的路径查找方法打下基础。不仅如此,还有许多场景中,基于航点的系统将绰绰有余,并允许对 AI 代理的行为进行更精细的控制。
在这个例子中,我们将创建一个路径,它由单独的航点组成。就我们的目的而言,航点只是一个具有 X、Y 和 Z 值的空間点;我们可以简单地使用Vector3来表示这些数据。通过在我们的脚本中创建一个Vector3的序列化数组,我们可以在检查器中轻松编辑这些点。如果你想挑战自己并调整这个系统使其更具用户友好性,你可能想要考虑使用游戏对象的数组,并使用它们的位置(一个Vector3)来代替。为了演示目的,提供的示例将坚持使用Vector3数组。在设置好数组中的某些点之后,我们希望得到的路径看起来像以下截图:

对象路径
在前面的截图,我们使用了一些调试线来绘制航点之间的连接。别担心,这里没有发生任何魔法。通过使用 Unity 的调试功能,我们可以可视化我们的代理将要穿越的路径。让我们分解Path.cs脚本,看看我们是如何实现这一点的。
路径脚本
这是我们的Path.cs脚本,它负责管理我们的航点:
using UnityEngine;
public class Path: MonoBehaviour
{
[SerializeField]
private Vector3[] waypoints;
public bool isDebug = true;
public float radius = 2.0f;
public float PathLength {
get { return waypoints.Length; }
}
public Vector3 GetPoint(int index)
{
return waypoints[index];
}
private void OnDrawGizmos()
{
if (!isDebug) {
return;
}
for (int i = 0; i < waypoints.Length; i++)
{
if (i + 1 < waypoints.Length)
{
Debug.DrawLine(waypoints[i], waypoints[i + 1], Color.red);
}
}
}
}
SerializeField属性可以用来强制 Unity 序列化一个私有字段,并在检查器中显示它。
我们航点的Vector3数组是之前提到的路径中航点的集合。为了初始化航点,我们必须将脚本添加到场景中的游戏对象。在示例场景中,我们简单地创建了一个空的游戏对象并将Path.cs脚本附加到它。为了清晰起见,我们还把我们的游戏对象重命名为Path。有了准备好的Path游戏对象,我们可以在检查器中分配路径值。示例值如下所示:

样本项目提供的路径值
这里截图中的值是任意的,可以根据您的喜好进行调整。您只需确保沿路径至少有两个航点。
PathLength属性简单地返回我们的航点数组的长度。它为我们私有的字段提供了一个公共的获取器,并被后来的脚本使用。radius变量允许我们定义路径查找的容差。我们不会期望代理精确地位于航点的位置,而是将使用一个半径来确定代理何时“足够接近”以考虑航点已被访问。GetPoint方法是一个简单的辅助方法,用于从数组中获取给定索引的航点。
默认将字段设置为private是一种常见且合适的做法,尤其是在包含的数据对类的功能至关重要时。在我们的例子中,航点顺序、数组大小等都不应在运行时修改,因此我们确保外部类只能通过使用辅助方法和属性从它们获取数据,并通过将它们设置为私有来保护它们免受外部更改。
最后,我们使用OnDrawGizmos,这是一个 Unity 自动为我们调用的MonoBehaviour方法,在编辑器的场景视图中绘制调试信息。我们可以通过将isDebug的值设置为true或false来切换此功能。
使用路径跟随器
接下来,我们将设置我们的代理以跟随上一节中定义的路径。在示例中,我们将使用一个简单的立方体,但您可以使用任何您想要的美术资源。让我们更仔细地看看示例代码中提供的Pathing.cs脚本:
public class Pathing : MonoBehaviour
{
[SerializeField]
private Path path;
[SerializeField]
private float speed = 20.0f;
[SerializeField]
private float mass = 5.0f;
[SerializeField]
private bool isLooping = true;
private float currentSpeed;
private int currentPathIndex = 0;
private Vector3 targetPoint;
private Vector3 direction;
private Vector3 targetDirection;
第一组字段是我们希望序列化的变量,以便可以通过检查器设置。path是我们之前创建的Path对象的引用;我们可以简单地从path游戏对象中拖放组件到这个字段。speed和mass用于计算代理沿路径的运动。isLooping用于确定是否应该沿着路径循环。当为true时,代理将到达最后一个航点,然后转到路径上的第一个航点并重新开始。一旦所有值都分配完毕,检查器应该看起来像这样:

带有默认值的路径查找脚本检查器
我们的Start方法处理一些剩余的私有字段——direction和targetPoint的初始化:
private void Start ()
{
// Initialize the direction as the agent's current facing direction
direction = transform.forward;
// We get the firt point along the path
targetPoint = path.GetPoint(currentPathIndex);
}
我们的Update方法为我们做了几件事情。首先,它进行了一些模板化的空安全检查,更新代理的速度,检查目标是否已到达,调用SetNextTarget方法来确定下一个目标点,最后,根据需要应用方向和旋转变化:
private void Update ()
{
if(path == null) {
return;
}
currentSpeed = speed * Time.deltaTime;
if(TargetReached())
{
if (!SetNextTarget()) {
return;
}
}
direction += Steer(targetPoint);
transform.position += direction; //Move the agent according to the direction
transform.rotation = Quaternion.LookRotation(direction); //Rotate the agent towards the desired direction
}
为了使内容更加清晰易读,我们将一些功能从Update方法中移出。TargetReached方法相当直接。它使用path的半径来判断代理是否足够接近目标航点,正如你在这里看到的:
private bool TargetReached()
{
return (Vector3.Distance(transform.position, targetPoint) < path.radius);
}
SetNextTarget方法有点更有趣。正如你所见,它返回一个bool。如果我们还没有到达数组的末尾,它将只增加值,但如果方法无法设置下一个点,因为我们已经到达数组的末尾,并且isLooping为false,它将返回false。如果你回到我们的Update方法,你会看到当这种情况发生时,我们只是简单地退出Update并什么都不做。这是因为我们已经到达了路的尽头,我们的代理没有其他地方可以去。在相同的场景中,但isLooping == true评估为true时,我们将下一个目标点重置为数组中的第一个(0):
private bool SetNextTarget()
{
bool success = false;
if (currentPathIndex < path.PathLength - 1) {
currentPathIndex++;
success = true;
}
else
{
if(isLooping)
{
currentPathIndex = 0;
success = true;
}
else
{
success = false;
}
}
targetPoint = path.GetPoint(currentPathIndex);
return success;
}
Steer方法使用给定的目标点进行一些计算,以获得新的方向和旋转。通过从当前位置(a)减去目标点(b),我们得到从a到b的方向向量。我们对该向量进行归一化,然后应用当前速度来确定这一帧在新targetDirection上的移动距离。最后,我们使用质量来平滑targetDirection和当前方向之间的加速度,并将该值作为acceleration返回:
public Vector3 Steer(Vector3 target)
{
// Subtracting vector b - a gives you the direction from a to b.
targetDirection = (target - transform.position);
targetDirection.Normalize();
targetDirection*= currentSpeed;
Vector3 steeringForce = targetDirection - direction;
Vector3 acceleration = steeringForce / mass;
return acceleration;
}
当你运行场景时,代理立方体将按照预期跟随路径。如果你关闭isLooping,代理将到达最终航点并停止在那里,但如果你保持它开启,代理将无限循环路径。尝试调整各种设置,看看它如何影响结果。
避免障碍
接下来,我们将查看一个避障机制。要开始,打开名为ObstacleAvoidance的相同场景。样本场景相当直接。除了相机和方向光之外,还有一个带有一系列块的面,这些块将作为我们的障碍物,一个立方体将作为我们的代理,以及包含一些说明文本的画布。场景将如下截图所示:

样本场景设置
前一场景图片的层次结构如下所示:

有序的层次结构
值得注意的是,这个Agent对象不是一个路径查找器。因此,如果我们设置太多的墙壁,我们的Agent可能很难找到目标。尝试几种墙壁设置,看看我们的Agent的表现如何。
添加自定义层
我们的机制依赖于射线投射来检测障碍物。我们不是假设每个对象都是障碍物,而是特别使用一个名为“障碍物”的层,并过滤掉其他所有内容。这不是 Unity 中的默认层,因此我们必须手动设置它。示例项目已经为您设置了,但如果您想添加自己的层,您可以通过两种不同的方式访问层设置窗口。第一种是通过菜单——编辑 | 项目设置 | 标签和层——第二种方法是在层次结构中选择层下拉菜单,然后选择添加层...以下截图显示了菜单在检查器右上角的位置:

通过前一个截图所示的菜单或通过 Unity 的菜单栏选择“标签和层”菜单,将打开一个窗口,您可以在其中自由添加、编辑或删除层(以及标签,但我们目前对此不感兴趣)。让我们在第八个插槽中添加“障碍物”,如下面的截图所示:

创建新层
在对设置进行任何更改后,您应该保存项目,但层没有专门的保存按钮。现在您可以在检查器中相同的下拉菜单中分配层,就像我们刚刚使用的那样,如下面的截图所示:

分配我们的新层
层最常被摄像机用来渲染场景的一部分,以及被灯光用来照亮场景的某些部分。但它们也可以被射线投射用来选择性地忽略碰撞体或创建碰撞。您可以在docs.unity3d.com/Documentation/Components/Layers.html了解更多信息。
避障
现在我们已经设置了场景,让我们看看我们的避障行为脚本。它包含驱动我们的代理的所有逻辑,并将避障应用于代理的运动。在示例项目中,查看Avoidance.cs脚本:
using UnityEngine;
public class Avoidance : MonoBehaviour
{
[SerializeField]
private float movementSpeed = 20.0f;
[SerializeField]
private float rotationSpeed = 5.0f;
[SerializeField]
private float force = 50.0f;
[SerializeField]
private float minimumAvoidanceDistance = 20.0f;
[SerializeField]
private float toleranceRadius = 3.0f;
private float currentSpeed;
private Vector3 targetPoint;
private RaycastHit mouseHit;
private Camera mainCamera;
private Vector3 direction;
private Quaternion targetRotation;
private RaycastHit avoidanceHit;
private Vector3 hitNormal;
private void Start ()
{
mainCamera = Camera.main;
targetPoint = Vector3.zero;
}
您将在前面的代码片段中找到一些熟悉的字段名称。例如,移动速度、旋转速度、容差半径等值与我们使用的航点系统中的值相似。同样,我们使用SerializeField属性在检查器中公开我们的私有字段,以便于编辑和分配,同时保护我们的值在运行时免受外部对象的篡改。在Start方法中,我们只是初始化一些值。例如,我们在这里缓存了对Camera.main的引用,这样我们就不必每次需要引用它时都进行查找。接下来,让我们看看Update方法:
private void Update ()
{
CheckInput();
direction = (targetPoint - transform.position);
direction.Normalize();
//Apply obstacle avoidance
ApplyAvoidance(ref direction);
//Don't move the agent when the target point is reached
if(Vector3.Distance(targetPoint, transform.position) < toleranceRadius) {
return;
}
currentSpeed = movementSpeed * Time.deltaTime;
//Rotate the agent towards its target direction
targetRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
//Move the agent forard
transform.position += transform.forward * currentSpeed;
}
立刻调用 CheckInput() 函数,它看起来是这样的:
private void CheckInput()
{
if (Input.GetMouseButtonDown(0))
{
var ray = mainCamera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out mouseHit, 100.0f)) {
targetPoint = mouseHit.point;
}
}
}
我们检查用户是否点击了左鼠标按钮(默认情况下,它映射到 "0")。如果是这样,我们检查从主摄像机发出的物理射线投射,射向鼠标的位置。如果我们得到一个积极的命中,我们只需将 mouseHit 中的命中点分配给新的 targetPoint。这就是我们的代理将尝试移动到的位置。回到 Update 函数,我们有以下几行,紧随 CheckInput() 方法之后:
direction = (targetPoint - transform.position);
direction.Normalize();
//Apply obstacle avoidance
ApplyAvoidance(ref direction);
我们以与我们在 Pathing.cs 脚本中所做的方式计算到目标点的方向,并归一化该向量,使其大小不超过 1。接下来,我们修改该方向并应用规避,通过将那个方向向量发送到我们的 ApplyAvoidance() 方法,该方法看起来像这样:
private void ApplyAvoidance(ref Vector3 direction)
{
//Only detect layer 8 (Obstacles)
//We use bitshifting to create a layermask with a value of
//0100000000 where only the 8th position is 1, so only it is active.
int layerMask = 1 << 8;
//Check that the agent hit with the obstacles within it's minimum distance to avoid
if (Physics.Raycast(transform.position, transform.forward, out avoidanceHit, minimumAvoidanceDistance, layerMask))
{
//Get the normal of the hit point to calculate the new direction
hitNormal = avoidanceHit.normal;
hitNormal.y = 0.0f; //Don't want to move in Y-Space
//Get the new directional vector by adding force to agent's current forward vector
direction = transform.forward + hitNormal * force;
}
}
在深入研究前面的代码之前,了解 Unity 如何处理掩码层非常重要。正如我们之前提到的,我们希望我们的射线投射只击中我们关心的层,在这种情况下,我们的 Obstacles 层。如果你很细心,你可能已经注意到我们的层数组有 32 个槽位,从索引 0 到 31。我们将 Obstacles 层放在槽位 8(索引 9)。这样做的原因是 Unity 使用 32 位整数值来表示层,每个位代表一个槽位,从右到左。让我们用图示来分解这一点。
假设我们想要表示一个层掩码,其中只有第一个槽位(第一个位)是激活的。在这种情况下,我们将位赋予值为 1。它看起来会是这样:
0000 0000 0000 0000 0000 0000 0000 0001
如果你对计算机科学基础知识非常扎实,你会记得,在二进制中,这个值转换为一个整数值为 1。假设你有一个只选择了前四个槽位/索引的掩码。它看起来会是这样:
0000 0000 0000 0000 0000 0000 0000 1111
再次将二进制数转换,它给我们一个整数值为 15 (1 + 2 + 4 + 8)。
在我们的脚本中,我们想要一个只有第 9 个位置激活的掩码,它看起来会是这样:
0000 0000 0000 0000 0000 0001 0000 0000
再次进行数学计算,我们知道该掩码的整数值为 256。但手动进行计算不方便。幸运的是,C# 提供了一些操作位的方法。前面代码中的这一行正是这样做的:
int layerMask = 1 << 8;
它使用位运算符——具体来说是左移运算符——来创建我们的掩码。它的工作方式相当简单:它取一个整数值操作数(表达式左侧的整数值)为 1,然后将该位表示向左移动八次。它看起来像这样:
0000 0000 0000 0000 0000 0000 0000 0001 //Int value of 1
<<<< <<<< //Shift left 8 times
0000 0000 0000 0000 0000 0001 0000 0000 //Int value of 256
如你所见,位运算符很有帮助,尽管它们并不总是导致非常可读的代码,但在这种情况下它们非常方便。
你也可以在网上找到关于在 Unity3D 中使用图层掩码的良好讨论。问答网站可以在answers.unity3d.com/questions/8715/how-do-i-use-layermasks.html找到。或者,你也可以考虑使用LayerMask.GetMask(),这是 Unity 内置的用于处理命名图层的函数。
清理完这些后,让我们回到ApplyAvoidance()代码的其余部分。在创建图层掩码后,接下来的几行代码如下所示:
//Check that the agent hit with the obstacles within it's minimum distance to avoid
if (Physics.Raycast(transform.position, transform.forward, out avoidanceHit, minimumAvoidanceDistance, layerMask))
{
//Get the normal of the hit point to calculate the new direction
hitNormal = avoidanceHit.normal;
hitNormal.y = 0.0f; //Don't want to move in Y-Space
//Get the new direction vector by adding force to agent's current forward vector
direction = transform.forward + hitNormal * force;
}
再次,我们使用射线投射,但这次,原点是代理的位置,方向是它的前进向量。你也会注意到我们使用了Physics.Raycast()方法的重载,它将我们的layerMask作为参数,这意味着我们的射线投射只会击中我们的障碍物层中的对象。当发生击中时,我们得到我们击中的表面的法线并计算新的方向向量。
我们Update函数的最后部分看起来是这样的:
//Don't move the agent when the target point is reached
if(Vector3.Distance(targetPoint, transform.position) < toleranceRadius) {
return;
}
currentSpeed = movementSpeed * Time.deltaTime;
//Rotate the agent towards its target direction
targetRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
//Move the agent forard
transform.position += transform.forward * currentSpeed;
再次,你可能认出一些这段代码,因为它与Pathing.cs脚本中使用的代码非常相似。如果我们已经到达目的地可接受半径内,我们就不做任何事情。否则,我们旋转代理并向前移动它。
在示例场景中,你可以找到一个带有Avoidance.cs脚本的Agent游戏对象。所有值都已分配的检查器看起来如下所示:

代理检查视图
尝试调整这些值,看看你能得到什么样的结果。简单地按播放并点击场景,告诉你的代理移动。你可能注意到,尽管代理智能地避开障碍物,但它并不总是选择到达目标的最有效路径。这就是 A*算法发挥作用的地方。
A*路径查找
接下来,我们将使用 C#在 Unity 环境中实现 A算法。尽管还有其他算法,如 Dijkstra 算法,但 A路径查找算法因其简单性和有效性而被广泛应用于游戏和交互式应用程序中。我们之前在第一章《游戏 AI 基础》中简要介绍了这个算法,但现在让我们从实现的角度再次回顾这个算法。
再次探讨 A*算法
我们在书中简要提到了 A*算法,所以在我们深入实现之前,让我们先回顾一下基础知识。首先,我们需要创建我们地图的基于网格的表示。最好的选择是 2D 数组。这个网格及其所有相关数据都将包含在我们的GridManager类中。GridManager类将包含一个Node对象列表,代表我们网格中的每个单元格。节点本身将包含一些关于它们自己的额外数据,例如它们的启发式成本以及它们是否是障碍物节点。
我们还需要保留两个列表——我们的开放列表,即要探索的节点列表,以及我们的关闭列表,它将包含我们已访问的节点。我们将在PriorityQueue类中实现这些,它提供了一些额外的辅助功能。
从本质上讲,我们实现的 A*算法,在AStar类中,必须执行以下操作:
-
从起始节点开始,并将其放入开放列表。
-
只要开放列表中还有节点,我们就会执行以下过程:
-
从开放列表中选择第一个节点并将其保留为当前节点。(这是假设我们已经对开放列表进行了排序,并且第一个节点具有最小的成本值,这一点将在代码的末尾提到。)
-
获取当前节点的相邻节点,这些相邻节点不是障碍物类型,例如墙壁或峡谷,无法通过。
-
对于每个相邻节点,检查此相邻节点是否已经在关闭列表中。如果没有,我们将使用以下公式计算此相邻节点的总成本(
F):
-
F = G + H
-
-
在前面的公式中,
G是从起始节点到该节点的总成本,H是从该节点到最终目标节点的总成本。 -
将此成本数据存储在相邻节点对象中。同时,将当前节点作为父节点存储。稍后,我们将使用此父节点数据来追踪实际路径。
-
将此相邻节点放入开放列表。按到达目标节点的总成本对开放列表进行升序排序。
-
如果没有更多的相邻节点要处理,将当前节点放入关闭列表,并从开放列表中删除它。
-
使用开放列表中的下一个节点回到步骤 2。
-
完成此过程后,你的当前节点应该位于目标目标节点位置,但前提是从起始节点到目标节点的路径无障碍。如果它不在目标节点,则从当前节点位置到目标节点的路径不可用。如果存在有效路径,我们现在要做的就是从当前节点的父节点开始追踪,直到再次到达起始节点。这将给我们一个在路径查找过程中选择的节点路径列表,从目标节点到起始节点排序。然后我们只需反转这个路径列表,因为我们想知道从起始节点到目标目标节点的路径。
这是我们将在 Unity 中使用 C#实现的算法的一般概述。那么,让我们开始吧。
实现
为了开始使用 A,我们必须将概念应用到代码中的具体实现。在我们的示例代码中,我们将 A系统分解为几个关键组件:Node、GridManager、PriorityQueue和AStart类。
让我们在接下来的几个部分中分解每个类的作用。
节点类
我们可以把 Node 类想象成包含我们网格中每个瓦片相关信息的容器。我们存储有关节点成本、节点父节点和其位置等信息:
using UnityEngine;
using System;
public class Node : IComparable
{
//Total cost so far for the node
public float gCost;
//Estimated cost from this node to the goal node
public float hCost;
//Is this an obstacle node
public bool bObstacle;
//Parent of the node in the linked list
public Node parent;
//Position of the node in world space
public Vector3 position;
public Node()
{
hCost = 0.0f;
gCost = 1.0f;
bObstacle = false;
parent = null;
}
public Node(Vector3 pos)
{
hCost = 0.0f;
gCost = 1.0f;
bObstacle = false;
parent = null;
position = pos;
}
public void MarkAsObstacle()
{
bObstacle = true;
}
//IComparable Interface method implementation
public int CompareTo(object obj)
{
Node node = (Node)obj;
if (hCost < node.hCost)
{
return -1;
}
if (hCost > node.hCost)
{
return 1;
}
return 0;
}
}
在代码中,我们分别用 gCost 和 hCost 来表示我们的 G 和 H 成本。G 指的是从起始节点到该节点的成本,而 H 指的是从该节点到终点节点的估计成本。根据你对 A* 的熟悉程度,你可能考虑将它们重命名为更具描述性的名称。在我们的例子中,我们希望尽可能接近于纸上概念名称的 实际版本,以便解释 C# 实现。
该类提供了一个简单的构造函数,它不接受任何参数,并且有一个重载函数接受一个位置,它将传递的值预先填充到位置字段中。这里没有什么太复杂的。
你可能已经注意到我们的类实现了 IComparable 接口,这要求我们实现 CompareTo() 方法以满足接口合同要求。
你可以把接口想象成一个合同。单独来看,它什么也不做。你无法在接口中实现任何逻辑。通过从接口继承,你只是同意在实现类中实现所有具有提供签名的所有方法。这样,任何其他想要调用你类中接口给定方法的类都可以假设该方法存在。
方法的实际实现根据给定的节点与该节点的 hCost 进行比较。我们稍后会看看它的用法。
建立优先队列
我们使用 PriorityQueue 类来表示我们的开放列表和关闭列表。这种方法允许我们实现一些方便的辅助方法。PriorityClass.cs 文件看起来像这样:
using System.Collections;
public class PriorityQueue
{
private ArrayList nodes = new ArrayList();
public int Length
{
get { return nodes.Count; }
}
public bool Contains(object node)
{
return nodes.Contains(node);
}
public Node GetFirstNode()
{
if (nodes.Count > 0)
{
return (Node)nodes[0];
}
return null;
}
public void Push(Node node)
{
nodes.Add(node);
nodes.Sort();
}
public void Remove(Node node)
{
nodes.Remove(node);
nodes.Sort();
}
}
这段代码中没有太多值得注意的,但特别是 Sort() 方法很有趣。还记得 Node 类中的 CompareTo() 方法吗?ArrayList.Sort() 实际上依赖于节点类中 CompareTo() 的实现来排序数组。更具体地说,它将根据节点的 hCost 进行排序。
设置我们的网格管理器
GridManager 类在安排和可视化我们的网格方面做了很多繁重的工作。与这本书中我们迄今为止看到的某些代码相比,它是一个相当长的类,因为它提供了几个辅助方法。打开 GridManager.cs 类以继续阅读:
[SerializeField]
private int numberOfRows = 20;
[SerializeField]
public int numberOfColumns = 20;
[SerializeField]
public float gridCellSize = 2;
[SerializeField]
public bool showGrid = true;
[SerializeField]
public bool showObstacleBlocks = true;
private Vector3 origin = new Vector3();
private GameObject[] obstacleList;
private Node[,] nodes { get; set; }
我们首先设置一些变量。我们指定网格中的行数和列数,并指定它们的大小(以世界单位计)。这里没有太多值得注意的,但我们应该指出,Node[,] 语法表示我们正在初始化一个 nodes 的二维数组,这是有意义的,因为网格本身就是一个二维数组。
在我们的 Awake 方法中,我们看到以下行:
obstacleList = GameObject.FindGameObjectsWithTag("Obstacle");
这只是通过查找标记为 "Obstacle" 的对象来初始化 obstacleList 游戏对象数组。然后 Awake 调用两个设置方法:InitializeNodes() 和 CalculateObstacles():
private void InitializeNodes()
{
nodes = new Node[numberOfColumns, numberOfRows];
int index = 0;
for (int i = 0; i < numberOfColumns; i++)
{
for (int j = 0; j < numberOfRows; j++)
{
Vector3 cellPosition = GetGridCellCenter(index);
Node node = new Node(cellPosition);
nodes[i, j] = node;
index++;
}
}
}
这些方法的名称非常直接,所以正如你可能猜到的,InitializeNodes() 初始化我们的节点,并通过填充 nodes 2D 数组来实现。此代码调用一个辅助方法 GetGridCellCenter(),我们稍后会看到,但方法相当直接。我们按列和行的顺序遍历 2D 数组,并创建根据网格大小间隔的节点:
private void CalculateObstacles()
{
if (obstacleList != null && obstacleList.Length > 0)
{
foreach (GameObject data in obstacleList)
{
int indexCell = GetGridIndex(data.transform.position);
int column = GetColumnOfIndex(indexCell);
int row = GetRowOfIndex(indexCell);
nodes[row, column].MarkAsObstacle();
}
}
}
CalculateObstacles() 方法简单地遍历我们在 Awake 期间初始化的障碍物列表,确定障碍物占据的网格槽位,并使用 MarkAsObtacle() 将该网格槽位的节点标记为障碍物。
GridManager 类有几个辅助方法来遍历网格并获取网格单元格数据。以下是一些它们的列表,以及它们所做简要描述。实现很简单,所以我们不会深入细节:
-
GetGridCellCenter:给定一个单元格的索引,它返回该单元格的中心位置(在世界空间中)。 -
GetGridCellPositionAtIndex:返回单元格的起点位置(角落)。用作GetGridCellCenter的辅助工具。 -
GetGridIndex:给定一个位置(作为世界空间中的Vector3),它返回最接近该位置的单元格。 -
GetRowOfIndex和GetColumnOfIndex:正如其名称所示,它们返回给定索引的单元格的行或列。例如,在一个 2 x 2 的网格中,索引为 2 的单元格(从 0 开始),位于第 2 行,第 1 列。
接下来,我们有一些帮助确定给定节点的邻居的方法:
public void GetNeighbors(Node node, ArrayList neighbors)
{
Vector3 neighborPosition = node.position;
int neighborIndex = GetGridIndex(neighborPosition);
int row = GetRowOfIndex(neighborIndex);
int column = GetColumnOfIndex(neighborIndex);
//Bottom
int leftNodeRow = row - 1;
int leftNodeColumn = column;
AssignNeighbor(leftNodeRow, leftNodeColumn, neighbors);
//Top
leftNodeRow = row + 1;
leftNodeColumn = column;
AssignNeighbor(leftNodeRow, leftNodeColumn, neighbors);
//Right
leftNodeRow = row;
leftNodeColumn = column + 1;
AssignNeighbor(leftNodeRow, leftNodeColumn, neighbors);
//Left
leftNodeRow = row;
leftNodeColumn = column - 1;
AssignNeighbor(leftNodeRow, leftNodeColumn, neighbors);
}
// Check the neighbor. If it's not an obstacle, assign the neighbor.
private void AssignNeighbor(int row, int column, ArrayList neighbors)
{
if (row != -1 && column != -1 && row < numberOfRows && column < numberOfColumns)
{
Node nodeToAdd = nodes[row, column];
if (!nodeToAdd.bObstacle)
{
neighbors.Add(nodeToAdd);
}
}
}
首先,我们有 GetNeighbors(),它使用给定节点在网格中的位置来确定其下方、上方、右侧和左侧的单元格。它使用 AssignNeighbor() 将节点作为邻居,该函数执行一些验证,例如检查潜在的邻居是否在数组范围内,以及邻居是否未标记为障碍物。
最后,我们有 OnDrawGizmos() 和 DebugDrawGrid(),它们用于显示我们在场景视图中指定的网格,以便进行调试。接下来,是主要内容。我们使用我们的 AStar 类将这些内容整合在一起。
深入了解 A* 的实现
AStar 类是 A* 算法的实际实现。这里发生了魔法。AStar.cs 文件中的代码如下:
using UnityEngine;
using System.Collections;
public class AStar
{
public static PriorityQueue closedList;
public static PriorityQueue openList;
private static ArrayList CalculatePath(Node node)
{
ArrayList list = new ArrayList();
while (node != null)
{
list.Add(node);
node = node.parent;
}
list.Reverse();
return list;
}
/// Calculate the estimated Heuristic cost to the goal
private static float EstimateHeuristicCost(Node curNode, Node goalNode)
{
Vector3 vecCost = curNode.position - goalNode.position;
return vecCost.magnitude;
}
// Find the path between start node and goal node using A* Algorithm
public static ArrayList FindPath(Node start, Node goal)
{
openList = new PriorityQueue();
openList.Push(start);
start.gCost = 0.0f;
start.hCost = EstimateHeuristicCost(start, goal);
closedList = new PriorityQueue();
Node node = null;
GridManager gridManager = GameObject.FindObjectOfType<GridManager>();
if(gridManager == null) {
return null;
}
while (openList.Length != 0)
{
node = openList.GetFirstNode();
if (node.position == goal.position)
{
return CalculatePath(node);
}
ArrayList neighbors = new ArrayList();
gridManager.GetNeighbors(node, neighbors);
//Update the costs of each neighbor node.
for (int i = 0; i < neighbors.Count; i++)
{
Node neighborNode = (Node)neighbors[i];
if (!closedList.Contains(neighborNode))
{
//Cost from current node to this neighbor node
float cost = EstimateHeuristicCost(node, neighborNode);
//Total Cost So Far from start to this neighbor node
float totalCost = node.gCost + cost;
//Estimated cost for neighbor node to the goal
float neighborNodeEstCost = EstimateHeuristicCost(neighborNode, goal);
//Assign neighbor node properties
neighborNode.gCost = totalCost;
neighborNode.parent = node;
neighborNode.hCost = totalCost + neighborNodeEstCost;
//Add the neighbor node to the open list if we haven't already done so.
if (!openList.Contains(neighborNode))
{
openList.Push(neighborNode);
}
}
}
closedList.Push(node);
openList.Remove(node);
}
//We handle the scenario where no goal was found after looping thorugh the open list
if (node.position != goal.position)
{
Debug.LogError("Goal Not Found");
return null;
}
//Calculate the path based on the final node
return CalculatePath(node);
}
}
这里有很多内容需要讲解,所以让我们一步一步来分析:
public static PriorityQueue closedList;
public static PriorityQueue openList;
我们首先声明我们的开放列表和关闭列表,它们将分别包含已访问和未访问的节点:
private static float EstimateHeuristicCost(Node currentNode, Node goalNode)
{
Vector3 cost= currentNode.position - goalNode.position;
return cost.magnitude;
}
在前面的代码中,我们实现了一个名为EstimateHeuristicCost的方法来计算两个给定节点之间的成本。计算很简单。我们只需通过从一个位置向量减去另一个位置向量来找到两个节点之间的方向向量。这个结果向量的幅度给出了从当前节点到目标节点的直接距离。
接下来,我们有我们的FindPath方法,它做了大部分工作:
public static ArrayList FindPath(Node start, Node goal)
{
openList = new PriorityQueue();
openList.Push(start);
start.gCost = 0.0f;
start.hCost = EstimateHeuristicCost(start, goal);
closedList = new PriorityQueue();
Node node = null;
GridManager gridManager = GameObject.FindObjectOfType<GridManager>();
if(gridManager == null) {
return null;
}
它初始化我们的开放和关闭列表。一开始,我们openList中只有起始节点。我们还初始化gCost,它为零,因为到起始节点(它自己)的距离为零。然后我们使用我们刚才讨论的EstimateHeuristicCost()方法分配hCost。
从现在开始,我们需要引用我们的GridManager,所以我们使用FindObjectOfType()获取它,并进行一些空值检查。接下来,我们开始处理开放列表:
while (openList.Length != 0)
{
node = openList.GetFirstNode();
if (node.position == goal.position)
{
return CalculatePath(node);
}
ArrayList neighbors = new ArrayList();
gridManager.GetNeighbors(node, neighbors);
//Update the costs of each neighbor node.
for (int i = 0; i < neighbors.Count; i++)
{
Node neighborNode = (Node)neighbors[i];
if (!closedList.Contains(neighborNode))
{
//Cost from current node to this neighbor node
float cost = EstimateHeuristicCost(node, neighborNode);
//Total Cost So Far from start to this neighbor node
float totalCost = node.gCost + cost;
//Estimated cost for neighbor node to the goal
float neighborNodeEstCost = EstimateHeuristicCost(neighborNode, goal);
//Assign neighbor node properties
neighborNode.gCost = totalCost;
neighborNode.parent = node;
neighborNode.hCost = totalCost + neighborNodeEstCost;
//Add the neighbor node to the open list if we haven't already done so.
if (!openList.Contains(neighborNode))
{
openList.Push(neighborNode);
}
}
}
closedList.Push(node);
openList.Remove(node);
}
//We handle the scenario where no goal was found after looping thorugh the open list
if (node.position != goal.position)
{
Debug.LogError("Goal Not Found");
return null;
}
//Calculate the path based on the final node
return CalculatePath(node);
}
这段代码实现类似于我们之前讨论过的 A*算法。现在是复习它的好时机。
用简单的话来说,前面的代码可以描述为以下步骤:
-
获取我们的
openList的第一个节点。请注意,每次添加新节点后,我们的openList总是排序的,这样第一个节点总是具有到目标节点最低估计成本的节点。 -
检查当前节点是否已经到达目标节点。如果是,退出
while循环并构建path数组。 -
创建一个数组列表来存储正在处理的当前节点的相邻节点。使用
GetNeighbors()方法从网格中检索相邻节点。 -
对于
neighbors数组中的每个节点,我们检查它是否已经在closedList中。如果没有,我们计算成本值,使用新的成本值以及父节点数据更新节点属性,并将其放入openList。 -
将当前节点推入
closedList并从openList中移除。重复此过程。
如果openList中没有更多的节点,那么如果存在有效路径,我们的当前节点应该位于目标节点。然后,我们只需将当前节点作为参数调用CalculatePath()方法。CalcualtePath()方法看起来像这样:
private static ArrayList CalculatePath(Node node)
{
ArrayList list = new ArrayList();
while (node != null)
{
list.Add(node);
node = node.parent;
}
list.Reverse();
return list;
}
CalculatePath方法遍历每个节点的父节点对象并构建一个数组列表。这给我们一个从目标节点到起始节点的节点数组列表。由于我们想要从起始节点到目标节点的路径数组,我们只需调用Reverse方法。就这样!随着我们的算法和辅助类已经处理完毕,我们可以继续到我们的测试脚本,它将所有这些整合在一起。
实现一个 TestCode 类
现在我们已经通过我们的AStar类(以及相关的辅助类)实现了 A*算法,我们实际上使用TestCode类来实现它。TestCode.cs文件看起来像这样:
using UnityEngine;
using System.Collections;
public class TestCode : MonoBehaviour
{
private Transform startPosition;
private Transform endPosition;
public Node startNode { get; set; }
public Node goalNode { get; set; }
private ArrayList pathArray;
private GameObject startCube;
private GameObject endCube;
private float elapsedTime = 0.0f;
public float intervalTime = 1.0f;
private GridManager gridManager;
我们在这里声明我们的变量,并再次设置一个变量来保存对GridManager的引用。然后,Start方法进行一些初始化并触发我们的FindPath()方法,如下所示代码:
private void Start ()
{
gridManager = FindObjectOfType<GridManager>();
startCube = GameObject.FindGameObjectWithTag("Start");
endCube = GameObject.FindGameObjectWithTag("End");
//Calculate the path using our AStart code.
pathArray = new ArrayList();
FindPath();
}
private void Update ()
{
elapsedTime += Time.deltaTime;
if(elapsedTime >= intervalTime)
{
elapsedTime = 0.0f;
FindPath();
}
}
在Update方法中,我们以一定的时间间隔检查路径,这是一种在运行时目标移动时刷新路径的暴力方法。你可能希望考虑使用事件来实现此代码,以避免在每一帧(或在这种情况下,间隔)中产生不必要的开销。在Start中调用的FindPath()方法如下所示:
private void FindPath()
{
startPosition = startCube.transform;
endPosition = endCube.transform;
startNode = new Node(gridManager.GetGridCellCenter(gridManager.GetGridIndex(startPosition.position)));
goalNode = new Node(gridManager.GetGridCellCenter(gridManager.GetGridIndex(endPosition.position)));
pathArray = AStar.FindPath(startNode, goalNode);
}
首先,它获取我们的起始和结束游戏对象的位置。然后,它使用GridManager和GetGridIndex辅助方法创建新的Node对象,以计算它们在网格中的相应行和列索引位置。有了这些必要的值,我们只需调用AStar.FindPath方法并使用起始节点和目标节点,然后将返回的数组列表存储在局部pathArray变量中。
接下来,我们实现OnDrawGizmos方法来绘制和可视化找到的路径:
private void OnDrawGizmos()
{
if (pathArray == null)
{
return;
}
if (pathArray.Count > 0)
{
int index = 1;
foreach (Node node in pathArray)
{
if (index < pathArray.Count)
{
Node nextNode = (Node)pathArray[index];
Debug.DrawLine(node.position, nextNode.position, Color.green);
index++;
}
};
}
}
我们遍历pathArray并使用Debug.DrawLine方法绘制连接pathArray中节点的线条。这样,当我们运行并测试游戏时,我们将能够看到一条从起点到终点的绿色线条,形成一个路径。
在示例场景中测试它
示例场景看起来如下:

在路径寻优网格上绘制的我们的示例场景
如前一个截图所示,有一个红色起始节点,一个绿色目标节点,一个平面和一些浅灰色障碍物。
以下截图是我们场景层次结构的快照:

在前面的截图中有几点需要注意(是的,你可以忽略方向光,因为它只是在这里让我们的场景看起来更漂亮)。首先,我们将所有的障碍物都分组在父Obstacles变换下。其次,我们在Scripts游戏对象下有单独的游戏对象用于我们的TestCode类和GridManager类。正如你之前在代码示例中看到的,GridManager中暴露了一些字段,它们在我们的示例场景中应该看起来像以下截图:

如前一个截图所示,我们已将“显示网格”选项设置为 true。这将使我们能够在场景视图中看到网格。
测试所有组件
现在我们已经了解了所有组件的连接方式,点击播放按钮并观察从我们的起始节点到目标节点的路径是如何绘制的,如下所示截图:

由于我们在Update循环中每隔一段时间检查路径,我们可以在播放模式下移动目标节点,并看到路径更新。以下截图显示了将目标节点移动到不同位置后的新路径:

如你所见,由于目标更近,所以到达它的最佳路径也近。简而言之,这就是 A*。一个非常强大的算法可以浓缩为几个类,总共只有几百行代码(其中大部分是由于格式化和注释)。
A* vs IDA*
在第一章《游戏 AI 基础》中,我们提到了 A和 IDA之间的一些区别。现在你已经实现了 A,你可以看到 A实现会保留一些内容在内存中——路径数组、开放列表和关闭列表。在实现的不同阶段,你可能会在遍历你的列表时分配更多或更少的内存。在这方面,A比 IDA更贪婪,但请记住,在大多数情况下,在现代硬件上,这并不是一个问题——即使是我们更大的网格。
IDA方法只关注当前迭代的相邻/邻近位置,并且因为它不记录访问过的节点,所以可能会多次访问相同的节点。在类似情况下,这意味着比更快的 A版本低得多的内存开销。
虽然这个观点可以争论,但这位谦逊的作者认为 IDA*在现代游戏开发中不是一个相关的模式——即使在资源敏感的应用程序,如移动游戏中也是如此。在其他领域,人们可以为迭代加深方法提出更有力的论据,但幸运的是,即使是老化的移动设备相对于将实现某种寻路功能的 99%的游戏的需求来说,也有大量的内存。
导航网格
接下来,我们将学习如何使用 Unity 内置的导航网格生成器,它可以使 AI 代理的寻路变得容易得多。在 Unity 5.x 周期的早期,NavMesh 对所有用户开放,包括个人版许可证持有者,而在此之前,它仅是 Unity Pro 的独占功能。在 2017.1 版本发布之前,该系统已升级以允许基于组件的工作流程,但由于它需要额外的可下载包,而截至写作时,这个包仅作为预览版提供,我们将坚持默认的场景基础工作流程。不用担心,概念是一致的,当最终实现最终进入 2017.x 版本时,不应该有剧烈的变化。
想了解更多关于 Unity 的 NavMesh 组件系统信息,请访问 GitHub:github.com/Unity-Technologies/NavMeshComponents。
现在,我们将深入探索这个系统所能提供的一切。AI 路径查找需要一个特定格式的场景表示;我们已经看到在 2D 地图上使用 2D 网格(数组)进行 A*路径查找。AI 代理需要知道障碍物的位置,特别是静态障碍物。处理动态移动对象之间的碰撞避免是另一个主题,主要称为转向行为。Unity 有一个内置工具用于生成 NavMesh,该工具以对 AI 代理有意义的方式表示场景,以便它们可以找到到目标的最优路径。打开演示项目,导航到 NavMesh 场景以开始。
检查我们的地图
一旦您打开了演示场景、NavMesh,它应该看起来像以下截图:

一个带有障碍物和斜坡的场景
这将是我们的沙盒,用于解释和测试 NavMesh 系统功能。一般的设置类似于实时策略(RTS)游戏。您控制蓝色坦克。只需点击一个位置,坦克就会移动到该位置。黄色指示器是坦克当前的目标位置。
导航静态
首先要指出的是,您需要将场景中将被烘焙到 NavMesh 中的任何几何体标记为导航静态。您可能在其他地方遇到过这种情况,例如在 Unity 的光照映射系统中。将游戏对象设置为静态很容易。您可以轻松切换所有目的的Static标志(导航、光照、剔除、批处理等),或者您可以使用下拉菜单来具体选择您想要的内容。切换按钮位于所选对象(s)检查器的右上角。查看以下截图以了解您要寻找的一般概念:

导航静态属性
您可以按对象逐个进行此操作,或者,如果您在层次结构中有嵌套的游戏对象层次结构,您可以将设置应用于父对象,Unity 将提示您将其应用于所有子对象。
烘焙导航网格
导航网格的导航设置是通过导航窗口在场景范围内应用的。您可以通过菜单栏中的窗口 | 导航来打开此窗口。像任何其他窗口一样,您可以将其分离为自由浮动,或者将其停靠。我们的截图显示它停靠在层次结构旁边的选项卡中,但您可以将此窗口放置在任何您想要的位置。
窗口打开后,您会注意到四个独立的选项卡。它看起来可能像以下截图:

导航窗口
在我们的例子中,前面的截图显示了已选择“烘焙”选项卡,但你的编辑器可能默认选择了其他选项卡之一。
让我们查看每个选项卡,从左侧开始,向右工作,从以下截图所示的“代理”选项卡开始:

代理标签页
如果你正在处理不同的项目,你可能会发现其中一些设置与我们从先前的截图所用的示例项目中设置的设置不同。在标签页的顶部,你可以看到一个列表,你可以通过按“+”按钮添加额外的代理类型。你可以通过选择并按“-”按钮来移除任何这些额外的代理。窗口提供了一个很好的视觉,展示了当你调整这些设置时各种设置的作用。让我们看看每个设置的作用:
-
名称: 要在代理类型下拉列表中显示的代理类型的名称。
-
半径: 可以将其视为代理的“个人空间”。代理将根据此值尝试避免与其他代理过于亲近,因为它用它来进行回避。
-
高度: 如你所猜,它决定了代理的高度,它可以用于垂直回避(例如,穿过东西)。
-
步高: 此值决定了代理可以爬越的障碍物的高度。
-
最大坡度: 正如我们将在下一节中看到的,此值决定了代理可以爬升的最大角度。这可以用来使地图上的陡峭区域对代理不可达。
接下来,我们有“区域”标签页,它看起来如下截图所示:

正如你在先前的截图中所见,Unity 提供了一些默认的区域类型,这些类型不能被编辑:可通行、不可通行和跳跃。除了命名和创建新的区域外,你还可以将这些区域的默认成本分配给它们。
区域有两个作用:根据代理使区域可访问或不可访问,以及将区域标记为导航成本较低。例如,你可能有 RPG 游戏,其中恶魔敌人不能进入标记为“圣地”的区域。你也可以在你的地图上标记一些像“沼泽”或“湿地”的区域,你的代理可以根据成本避免这些区域。
第三个标签页“烘焙”可能是最重要的。它允许你为场景创建实际的 NavMesh。你会认出一些设置。烘焙标签页看起来如下:

烘焙标签页
在此标签页中的代理大小设置决定了代理如何与环境交互,而代理标签页中的设置决定了它们如何与其他代理和移动对象交互,但它们控制相同的参数,所以我们在这里将跳过这些设置。下落高度和跳跃距离控制代理可以“跳跃”多远以到达与当前所在区域不直接相连的 NavMesh 部分。我们将在稍后详细介绍这一点,所以如果你现在还不完全清楚这意味着什么,请不要担心。
此外,还有一些默认情况下通常折叠的高级设置。只需单击“高级”标题旁边的下拉三角形即可展开这些选项。您可以将“手动体素大小”设置视为“质量”设置。大小越小,您可以在网格中捕获的细节就越多。最小区域面积用于跳过低于给定阈值的平台或表面烘焙。高度网格在烘焙网格时提供更详细的垂直数据。例如,它将帮助在爬楼梯时保持代理的正确位置。
清除按钮将清除场景中的任何 NavMesh 数据,而烘焙按钮将为您的场景创建网格。此过程相当快。只要您选择了窗口,您就可以在场景视图中看到由烘焙按钮生成的 NavMesh。请点击烘焙按钮以查看结果。在我们的示例场景中,您应该得到以下截图所示的内容:

蓝色区域代表 NavMesh。我们稍后会再次讨论这个问题。现在,让我们继续到最后一个标签页,即对象标签页,它看起来如下截图所示:

在前面的截图中所显示的三个按钮,全部、网格渲染器和地形,是您场景的过滤器。当在具有大量对象的复杂场景中工作时,这些非常有用。选择一个选项将过滤出您层次结构中的该类型,以便更容易选择。您可以使用此功能在场景中查找要标记为导航静态的对象。
使用 NavMesh 代理
现在我们已经设置了带有 NavMesh 的场景,我们需要让我们的代理使用这些信息。幸运的是,Unity 提供了一个可以附加到我们的角色上的 Nav Mesh Agent 组件。示例场景中有一个名为Tank的游戏对象,该组件已经附加到它上面。在层次结构中查看它,应该看起来像以下截图:

这里有相当多的设置,我们不会全部介绍,因为它们相当直观,您可以在官方 Unity 文档中找到完整的描述,但让我们指出一些关键点:
-
代理类型:还记得导航窗口中的代理标签吗?您在那里定义的代理类型将在这里可选择。
-
自动穿越离网链接:我们稍后会详细介绍离网链接,但此设置允许代理自动使用该功能。
-
区域遮罩:您在导航窗口的区域标签页中设置的区域将在这里可选择。
就这些了。该组件为您处理了 90%的繁重工作:路径放置、路径查找、障碍物避免等。您唯一需要做的是为代理提供一个目标目的地。让我们看看下一个。
设置目的地
现在我们已经设置了我们的 AI 代理,我们需要一种方法来告诉它去哪里。我们的示例项目提供了一个名为Target.cs的脚本,它正是这样做的。
这是一个简单的类,它做了三件事:
-
使用射线从相机原点射向鼠标世界位置
-
更新标记位置
-
更新所有导航网格代理的目标属性
代码相当简单。整个类看起来是这样的:
using UnityEngine;
using UnityEngine.AI;
public class Target : MonoBehaviour
{
private NavMeshAgent[] navAgents;
public Transform targetMarker;
private void Start ()
{
navAgents = FindObjectsOfType(typeof(NavMeshAgent)) as NavMeshAgent[];
}
private void UpdateTargets ( Vector3 targetPosition )
{
foreach(NavMeshAgent agent in navAgents)
{
agent.destination = targetPosition;
}
}
private void Update ()
{
if(GetInput())
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo;
if (Physics.Raycast(ray.origin, ray.direction, out hitInfo))
{
Vector3 targetPosition = hitInfo.point;
UpdateTargets(targetPosition);
targetMarker.position = targetPosition;
}
}
}
private bool GetInput()
{
if (Input.GetMouseButtonDown(0))
{
return true;
}
return false;
}
private void OnDrawGizmos()
{
Debug.DrawLine(targetMarker.position, targetMarker.position + Vector3.up * 5, Color.red);
}
}
这里发生了一些事情。在Start方法中,我们使用FindObjectsOfType()方法初始化我们的navAgents数组。
UpdateTargets()方法遍历我们的navAgents数组,并将它们的目标目的地设置为给定的Vector3。这实际上是使其工作的关键。你可以使用任何你想要的机制来获取目标目的地,而你需要做的只是设置NavMeshAgent.destination字段;代理会完成剩下的工作。
我们的示例使用点击移动的方法,所以每当玩家点击时,我们就从相机向世界中的鼠标光标发射一条射线,如果击中了什么,我们就将击中的位置分配给代理的新targetPosition。我们还相应地设置了目标标记,以便在游戏中轻松可视化目标目的地。
为了测试它,请确保你已按照上一节所述烘焙了导航网格,然后进入游戏模式,并选择地图上的任何区域。如果你点击得过于频繁,可能会注意到有些区域你的代理无法到达——红色立方体的顶部,最上面的平台,以及屏幕底部的平台。
在红色立方体的例子中,它们太高了。通往最上面平台的斜坡太陡峭,根据我们的最大坡度设置,代理无法爬上去。以下截图说明了最大坡度设置如何影响导航网格:

最大坡度设置为 45 的导航网格
如果你将最大坡度调整到大约 51,然后再次点击烘焙按钮重新烘焙导航网格,它将产生如下结果:

最大坡度设置为 51 的导航网格
如你所见,你可以通过简单的数值调整来调整你的关卡设计,使整个区域无法步行进入。一个这样的例子是,如果你有一个平台或边缘,你需要绳子、梯子或电梯才能到达。也许甚至需要特殊技能,比如攀爬能力?我会让你的想象力去工作,想出所有有趣的用法。
理解离网链接
你可能已经注意到我们的场景有两个缺口。第一个缺口我们的代理可以到达,但屏幕底部的那个太远了。这并不是完全随机的。Unity 的离网链接有效地在未连接的导航网格段之间架起了桥梁。你可以在编辑器中看到这些链接,如下一张截图所示:

带有连接线的蓝色圆圈是链接
Unity 可以以两种方式生成这些链接。第一种我们已经讨论过了。还记得导航窗口的烘焙选项卡中的跳跃距离值吗?当烘焙 NavMesh 时,Unity 会自动使用该值为我们生成链接。尝试在我们的测试场景中将该值调整为 5 并重新烘焙。注意,现在平台是如何连接起来的?这是因为网格现在位于新指定的阈值内。
将值恢复到 2 并重新烘焙。现在,让我们看看第二种方法。创建将用于连接两个平台的球体。将它们大致放置如下面的截图所示:

您可能已经看到了这里的发展方向,但让我们通过这个过程来了解如何连接这些。在这种情况下,我将右侧的球体命名为start,左侧的球体命名为end。您将在下一秒看到原因。接下来,在右侧的平台(相对于前面的截图)上添加 Off Mesh Link 组件。您会注意到组件有start和end字段。如您所猜,我们将把之前创建的球体放入相应的槽中——将起始球体放入start字段,将结束球体放入end字段。我们的检查器将看起来像这样:

当您将其设置为正数时,成本覆盖值开始起作用。它将对使用此链接应用成本乘数,而不是,可能的话,一条更经济的到达目标的路线。
双向值允许代理在设置为 true 时向两个方向移动。您可以将它关闭以在级别设计中创建单向链接。激活值正如其名。当设置为 false 时,代理将忽略此链接。您可以打开和关闭它以创建游戏场景,例如,玩家必须按下开关来激活它。
您无需重新烘焙即可启用此链接。看看您的 NavMesh,您会看到它看起来像以下截图:

如您所见,较小的间隙仍然自动连接,现在我们在两个球体之间通过我们的 Off Mesh Link 组件生成了一个新的链接。进入游戏模式并点击远处的平台,正如预期的那样,代理现在可以导航到分离的平台,如下面的截图所示:

在您自己的级别中,您可能需要调整这些设置以获得您期望的确切结果,但结合这些功能,您将获得很多即插即用的功能。您可以使用 Unity 的 NavMesh 功能相当快地将一个简单的游戏运行起来。
摘要
你现在已经顺利地导航到了本章的结尾(无耻地开个玩笑)。从简单的航点,到高效快速的 A*算法,再到 Unity 自带的强大且稳健的 NavMesh 系统,我们已经为你制作游戏工具箱添加了一些重要且灵活的工具。这些概念不仅彼此兼容,而且与本书中我们已经看到的其他系统也配合得很好,我们将在接下来的几章中对其进行探讨。
在下一章中,我们将开始探讨如何为需要统一移动的多个智能体创建高效且逼真的模拟。让我们开始吧!
第五章:鸟群和人群
鸟群和人群是本书中将探讨的两个额外的核心人工智能概念。正如你将在本章中看到的那样,鸟群相对容易实现,只需几行代码就能为你的模拟增加相当多的真实感。人群可能更复杂一些,但我们将探讨 Unity 附带的一些强大工具来完成这项工作。在本章中,我们将涵盖以下主题:
-
学习鸟群和兽群的历史
-
理解鸟群背后的概念
-
使用传统算法进行群聚
-
使用逼真的人群
学习鸟群的起源
群聚算法可以追溯到 80 年代中期。它最初是由克雷格·雷诺斯开发的,用于电影制作,最著名的应用是 1992 年电影《蝙蝠侠归来》中的蝙蝠群,为此他赢得了奥斯卡奖。从那时起,群聚算法的应用已经从电影领域扩展到各个领域,从游戏到科学研究。尽管相对高效和准确,但该算法也非常简单易懂和实现。
理解鸟群和人群背后的概念
与之前的概念一样,通过将它们与它们所模拟的现实生活中的行为联系起来,最容易理解鸟群和兽群。听起来很简单,这些概念描述了一组对象,或者如人工智能术语中所称的“鸟群”,作为一个群体一起移动。群聚算法的名字来源于自然界中鸟类表现出的行为,一群鸟跟随彼此向一个共同的目的地移动,大多数情况下保持固定的距离。这里的重点是群体。我们已经探讨了单个代理如何自主移动和做出决策,但鸟群是一种相对计算效率高的模拟大量代理同时移动的方式,同时每个鸟的独特移动方式不依赖于随机性或预定义的路径。
本章我们将构建的群聚实现是基于克雷格·雷诺斯本人最初开发的概念。雷诺斯的群聚行为有多种实现方式,在我们的示例中,我们采用了单线程优化版本,不分配任何内存。性能将根据硬件而变化,但一般来说,你的鸟群中的鸟越多,计算鸟群方向的 CPU 时间就会越长。有三个基本概念定义了鸟群的工作方式,这些概念自 80 年代算法引入以来一直存在:
- 分离:这意味着与其他鸟群中的邻居保持距离,以避免碰撞。以下图解说明了这一概念:

群聚中分离的图解
在前一张图中,中间的鸟以不改变航向的方式向其他鸟移动。
- 对齐:这意味着与鸟群以相同的方向和速度移动。以下图像说明了这个概念:

集群中的对齐图
在前面的图像中,中间的 boid 正在改变其航向,以匹配其周围的 boid 的航向。
- 凝聚力:这意味着保持与鸟群中心的最大距离。以下图像说明了这个概念:

集群中的凝聚力图
在前面的图像中,鸟群右侧的 boid 移动到箭头方向,以确保其与最近的 boid 组的距离最小。
使用雷诺兹算法
不再拖延,让我们深入了解雷诺兹集群算法。我们的集群实现有两个主要的脚本:Boid.cs和FlockController.cs。本章的示例代码提供了一个包含所有必要设置的场景,用于测试。你还会注意到一个名为TargetMovement.cs的第三个脚本,我们使用它来移动场景中我们的鸟群将跟随的目标。
对于我们的 boid,我们可以使用一个简单的立方体作为预制件。当然,你可以随意用任何你想要的美术作品替换立方体。让我们将Boid.cs脚本添加到我们的 boid 预制件中。代码看起来是这样的:
using UnityEngine;
public class Boid : MonoBehaviour
{
[SerializeField]
private FlockController flockController;
//The modified direction for the boid.
private Vector3 targetDirection;
//The Boid's current direction.
private Vector3 direction;
public FlockController FlockController
{
get { return flockController; }
set { flockController = value; }
}
public Vector3 Direction { get { return direction; }}
private void Awake()
{
direction = transform.forward.normalized;
if(flockController != null)
{
Debug.LogError("You must assign a flock controller!");
}
}
private void Update() {
targetDirection = FlockController.Flock(this, transform.localPosition, direction);
if(targetDirection == Vector3.zero)
{
return;
}
direction = targetDirection.normalized;
direction *= flockController.SpeedModifier;
transform.Translate(direction * Time.deltaTime);
}
}
一开始,你就会注意到对FlockController的引用,我们将在下一步创建它。你可以将FlockController视为集群/鸟群的共享大脑。每个 boid 不需要直接意识到其邻居,因为FlockController会单独处理这些信息。这使我们能够保持 boid 代码整洁。
我们在Awake方法中初始化方向向量,并确保FlockController被分配,或者我们记录一个错误。你可以通过多种方式强制执行空安全,例如,如果没有提供,则创建实例,但在我们的情况下,我们将假设你通过检查器分配了值。
Update方法完成其余的工作——它在FlockController上调用Flock()方法,并传入对自身的引用、其局部位置和其方向。这将返回一个向量,然后我们将其归一化以保持运动看起来不会太突然或太快,并使用Transform.Translate()应用运动。像往常一样,确保你在Time.deltaTime上平滑运动,以确保帧与帧之间的平滑运动。
重要的是要注意,我们确保缓存尽可能多的Vector3变量。通过避免使用new Vector3()来避免分配,尽可能减少分配。
实现 FlockController
FlockController将处理整个鸟群的协调。在变量方面,这里有很多事情要做。让我们逐块查看FlockController.cs:
private int flockSize = 20;
在这里,我们简单地分配我们鸟群的规模。你将在后面的Awake方法中看到这个值的使用:
private float speedModifier = 5;
[SerializeField]
private float alignmentWeight = 1;
[SerializeField]
private float cohesionWeight = 1;
[SerializeField]
private float separationWeight = 1;
[SerializeField]
private float followWeight = 5;
然后,我们声明一系列的修改器和权重值。speedModifier 直接影响我们的鸟可以移动多快。根据需要调整这个值。speedModifier 后面的三个值分别是对齐、凝聚和分离的权重值。这些值将在最终计算所有驱动鸟移动的方向向量的权重中乘以其权重。followWeight 用于权衡目标的变化量与鸟的变化量。如果你想使鸟更紧密地跟随目标,增加这个值。
[SerializeField]
private Boid prefab;
[SerializeField]
private float spawnRadius = 3.0f;
private Vector3 spawnLocation = Vector3.zero;
[SerializeField]
public Transform target;
以下变量块定义了一些更多的设置变量,我们在检查器中分配这些变量。首先,我们有要生成的鸟的预制体(它应该附加了 Boid.cs 组件)。spawnRadius 用于避免在所有鸟群成员都生成在同一个点上时可能出现的错误。相反,我们在给定的半径内生成它们,如这个变量所定义的。最后,target 是我们鸟群/群体的目标变换的引用。在我们的测试场景中,它是一个带有 TargetMovement.cs 组件的球体。
让我们看看 Awake 方法:
private void Awake()
{
flockList = new List<Boid>(flockSize);
for (int i = 0; i < flockSize; i++)
{
spawnLocation = Random.insideUnitSphere * spawnRadius + transform.position;
Boid boid = Instantiate(prefab, spawnLocation, transform.rotation) as Boid;
boid.transform.parent = transform;
boid.FlockController = this;
flockList.Add(boid);
}
}
我们通过循环足够多次来生成足够的鸟以填充我们的 flockSize 变量。这是我们的 spawnLocation 和 spawnRadius 发挥作用的地方。Unity 的 Random.insideUnitSphere 生成随机位置,我们将它添加到我们的变换位置以得到实际的生成位置。然后我们实例化鸟的预制体,将其分配给一个 Boid 实例,然后将其添加到我们的 flockList 中。此外,请注意,我们在这一步分配了鸟实例的 FlockController 属性。
请记住,在 Unity 中实例化预制体可能会很慢,因此增加鸟群中的鸟的数量会导致实例化帧期间性能大幅下降。
本类中唯一的另一种方法是 Flock() 方法,我们之前在 Boid 中看到过它被调用。这个方法负责计算单个鸟群成员的方向。它看起来是这样的:
public Vector3 Flock(Boid boid, Vector3 boidPosition, Vector3 boidDirection)
{
flockDirection = Vector3.zero;
flockCenter = Vector3.zero;
targetDirection = Vector3.zero;
separation = Vector3.zero;
for (int i = 0; i < flockList.Count; ++i)
{
Boid neighbor = flockList[i];
//Check only against neighbors.
if (neighbor != boid)
{
//Aggregate the direction of all the boids.
flockDirection += neighbor.Direction;
//Aggregate the position of all the boids.
flockCenter += neighbor.transform.localPosition;
//Aggregate the delta to all the boids.
separation += neighbor.transform.localPosition - boidPosition;
separation *= -1;
}
}
//Alignment. The average direction of all boids.
flockDirection /= flockSize;
flockDirection = flockDirection.normalized * alignmentWeight;
//Cohesion. The centroid of the flock.
flockCenter /= flockSize;
flockCenter = flockCenter.normalized * cohesionWeight;
//Separation.
separation /= flockSize;
separation = separation.normalized * separationWeight;
//Direction vector to the target of the flock.
targetDirection = target.localPosition - boidPosition;
targetDirection = targetDirection * followWeight;
return flockDirection + flockCenter + separation + targetDirection;
}
该方法接受有关我们的 Boid 的某些信息,以及它的一个副本。然后我们遍历 flockList 中的每个鸟,并将当前迭代索引处的鸟分配给一个临时值,称为 neighbor。为了避免进行多个循环,我们在同一个 for 循环中做几件事情:
-
求所有邻居的方向之和
-
求所有邻居的位置之和
-
求到所有邻居的位置变化量之和
一旦完成循环(从而汇总所有前面的值),我们计算以下内容:
-
鸟群方向,通过平均所有鸟的方向得到。由于我们已经得到了所有方向的总和,我们只需将其除以鸟的数量,即
flockSize。然后我们规范化这个值并应用我们之前定义的权重。这将给我们我们的对齐。 -
同样,我们通过平均所有鸟群的位置来获取鸟群的质心。与方向一样,我们在应用加权之前对向量进行归一化。
flockCenter给我们提供了我们的凝聚力向量。 -
你在这里看到了一个模式,对吧?就像前两个值一样,我们取平均值,然后归一化,最后对分离值进行加权。
-
targetDirection有一点不同。我们首先计算鸟群位置和目标位置之间的差值,然后应用权重。在这个实现中,我们不归一化这个值,但你可以自由地通过这样做来实验。归一化这个值不会破坏模拟,但你可能会注意到如果你的目标移动得太快,鸟群可能会随意地漂浮离目标。
在计算出所有的值——凝聚力、对齐和分离——之后,我们将它们加起来,并将结果返回给调用该方法的Boid实例。鸟群将使用这个向量作为它的目标方向,正如我们在Boid.cs文件中看到的。
由于我们可能有一打或几百个鸟群,因此在运行时避免任何不必要的计算是很重要的。如果你对我们的实现进行性能分析,你会注意到它根本不分配任何内存,这意味着你不会因为垃圾回收而遇到任何令人烦恼的卡顿。由于系统的单线程特性,随着鸟群数量的增加,系统会变慢,但几十个鸟群运行得非常快。正如你在下面的屏幕截图中所看到的,一个小鸟群在飞行中的计算可以在不到一毫秒内完成:

显示我们场景性能的统计面板
鸟群目标
最后但同样重要的是,我们有我们的鸟群目标。再次强调,你可以使用你喜欢的任何艺术作品,或者你可以坚持使用提供的示例项目中那个英俊的小球体。目标组件的代码位于TargetMovement.cs文件中。内容看起来是这样的:
using UnityEngine;
public class TargetMovement : MonoBehaviour
{
[SerializeField]
private Vector3 bounds;
[SerializeField]
private float moveSpeed = 10.0f;
[SerializeField]
private float turnSpeed = 3.0f;
[SerializeField]
private float targetPointTolerance = 5.0f;
private Vector3 initialPosition;
private Vector3 nextMovementPoint;
private Vector3 targetPosition;
private void Awake()
{
initialPosition = transform.position;
CalculateNextMovementPoint();
}
private void Update ()
{
transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime);
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(nextMovementPoint - transform.position), turnSpeed * Time.deltaTime);
if(Vector3.Distance(nextMovementPoint, transform.position) <= targetPointTolerance)
{
CalculateNextMovementPoint();
}
}
private void CalculateNextMovementPoint()
{
float posX = Random.Range(initialPosition.x - bounds.x, initialPosition.x + bounds.x);
float posY = Random.Range(initialPosition.y - bounds.y, initialPosition.y + bounds.y);
float posZ = Random.Range(initialPosition.z - bounds.z, initialPosition.z + bounds.z);
targetPosition.x = posX;
targetPosition.y = posY;
targetPosition.z = posZ;
nextMovementPoint = initialPosition + targetPosition;
}
}
这个类中有两个主要的工作部分。首先,Update方法将游戏对象移动到forward向量方向,同时旋转它朝向targetPosition。我们提供了两个变量来修改移动和转向速度:moveSpeed和turnSpeed。然后我们通过比较到达目标点的距离与我们在targetPointTolerance中定义的容差半径来检查我们是否已经到达目的地。如果我们足够接近,我们就通过调用CalculateNextMovementPoint()来设置下一个目标点。
在CalculateNextMovementPoint()中,我们设置一个随机的目标位置,但基于我们边界值进行约束,相对于我们首次运行脚本时目标的位置,因为我们已经在Awake中设置了initialPosition。约束这个点将防止目标慢慢偏离我们的游戏区域,并飘向日落。虽然这可能很戏剧化,但这并不是我们在这里追求的效果。
场景布局
现在我们已经覆盖了所有代码,让我们看看我们的场景。我们的示例场景看起来如下截图所示:

我们场景布局的概述
如前一张截图所示,我们的设置并没有太多复杂性。我们有一个平面,一些用于透视的环境立方体,一盏灯,一个摄像机和我们的目标。完整的层次结构如下截图所示:

带有 FlockController 高亮的场景层次结构
如前一张截图所示,我们有一个方向光,其下嵌套了一个反射探针。这完全是出于让场景看起来更美观的目的,实际上几乎没有功能价值,但嘿,一点虚荣心永远不会伤害任何人!然后我们有一个名为 FlockController 的空游戏对象,我们的 FlockController 脚本附加到它上面。目标游戏对象是一个带有明亮的黄色材质的球体,并附加了 TargetMovement 脚本。所有环境块都嵌套在 Environment 游戏对象下,在这个例子中是一个平面。最后三项是为了驱动我们的摄像机,它将自动锁定到我们的目标,并保持在画面中。由于这超出了本书的范围,我们将跳过摄像机的工作原理,但如果你是好奇的类型,你可能会想探索官方 Unity 文档,了解更多关于 Cinemachine 和 Timeline 的信息,它们驱动着我们的场景中的摄像机。
回到正题——让我们看看 FlockController,它看起来如下截图所示:

FlockController 组件
上一张截图显示了我们在示例场景中设置的值。正如你所见,分离权重略高于其他设置。在播放模式下自由调整权重值,看看它如何影响 boids 的行为。接下来,让我们看看目标游戏对象。以下截图显示了我们的示例设置:

我们目标移动脚本的测试值
截图显示了测试场景的最佳值。调整边界可能会导致一些疯狂的摄像机移动,但请尝试调整移动和转向速度,看看它如何在播放模式下影响场景。最后,让我们看看我们的 boid 预制件,它上面有 Boid 组件。提供的示例项目的 boid 设置可以在以下截图中看到:

Boid 游戏对象的全部组件和设置
前一张截图并没有太多激动人心的内容。正如你所见,Flock Controller 是空的(因为我们通过代码在运行时分配它),boid 本身没有其他可调整的值,除非你愿意调整外观,但我们的霓虹绿立方体确实是一件艺术品,如果我说了算的话。
当你按下播放时,你会看到你的鸟群孵化并跟随目标在场景中移动。它看起来可能像下面的屏幕截图:

我们的一群鸟群正涌向目标球体
就这样,我们创建了自己的群体系统。强烈建议您不仅调整检查器中的值,还尝试修改代码。查看一个值如何影响整个系统的最简单方法就是将其移除,或者将其增加到荒谬的程度。接下来,我们将探讨 Unity 2017 中的群体。
使用群体
群体模拟远非一目了然。在广义上,并没有任何一种方法可以实施它们。虽然不是一个严格的定义,但“群体模拟”这个术语通常指的是模拟人群化代理在区域内导航,同时避免彼此和环境。像鸟群一样,群体模拟在电影中的应用已经非常广泛。例如,在《指环王》中,罗翰、刚铎和魔多的史诗般的军队相互战斗,这些军队完全是通过群体模拟软件Massive进行程序化生成的,该软件是为电影制作的。虽然群体算法在视频游戏中的应用不如在电影中广泛,但某些类型比其他类型更依赖于这一概念。实时策略游戏通常涉及屏幕上移动的军队,而许多沙盒游戏模拟了密集的城市,屏幕上有许多代理在避免彼此、玩家甚至交通的同时度过他们的日常生活。
实现一个简单的群体模拟
我们的实现将快速、简单且有效,并将专注于使用 Unity 的 NavMesh 功能。幸运的是,NavMesh 将为我们处理大部分繁重的工作。我们的示例群体场景有一个简单的行走表面,上面烘焙了 NavMesh,一些目标,以及两个胶囊队伍,如下面的屏幕截图所示:

经典场景:红色对蓝色
在前面的屏幕截图中,我们可以看到我们的红色和蓝色目标分别与他们的队伍相反:红色和蓝色。关于为什么蓝色和红色部落无法相处,你的猜测和我的一样,但这对我们的样本来说适用,所以我将让他们按自己的方式行事。设置很简单。每个胶囊都附加了一个CrowdAgent.cs组件,当你按下播放时,每个代理将朝着他们的目标前进,同时避免彼此和来自对方队伍的迎面而来的胶囊。一旦他们到达目的地,他们将在目标周围聚集。
这种设置将我们的例子从第四章,《寻找你的路》提升到了下一个层次。现在我们有了大量智能体,它们不仅正在导航到目标位置,而且在避免大量其他智能体的同时这样做。正如你所见,Unity 的 NavMesh 优雅地处理了这些交互。该系统既高效又非常健壮。
在游戏运行时,你甚至可以在编辑器中选择单个胶囊或一组胶囊,以查看它们的可视化行为。只要你的导航窗口处于活动状态,你就能看到一些关于你的 NavMesh 及其上智能体的调试信息,如以下屏幕截图所示:

从智能体视角的调试视图
在编辑器中查看这一点,以真正了解它在运动中的样子是值得的,但我们已经在先前的屏幕截图中标出了一些关键元素:
-
这是指向
NavMeshAgent目的地的目的地箭头,对于这个小家伙来说,它是RedTarget。这个箭头所关心的只是目的地在哪里,而不管智能体面向或移动的方向如何。 -
这个箭头是方向箭头。它显示了智能体实际移动的方向。智能体的方向考虑了多个因素,包括其邻居的位置、NavMesh 上的空间以及目的地。
-
这个调试菜单允许你显示几个不同的事物。在我们的例子中,我们启用了显示规避和显示邻居。
-
说到规避,这个从深色到浅色、悬浮在智能体上方的正方形群代表了我们智能体和目的地之间需要规避的区域。较暗的正方形表示其他智能体密集分布或被环境阻挡的区域,而较浅的白色正方形表示可以安全穿过的区域。当然,这是一个动态显示,所以当你在这个编辑器中玩耍时,你会看到它如何变化。
使用 CrowdAgent 组件
CrowdAgent组件非常简单,但能完成任务。如前所述,Unity 为我们做了大部分繁重的工作。以下代码为我们的CrowdAgent提供了一个目的地:
using UnityEngine;
using System.Collections;
[RequireComponent(typeof(NavMeshAgent))]
public class CrowdAgent : MonoBehaviour
{
public Transform target;
private NavMeshAgent agent;
void Start ()
{
agent = GetComponent<NavMeshAgent>();
agent.speed = Random.Range(4.0f, 5.0f);
agent.SetDestination(target.position);
}
}
脚本需要一个类型为NavMeshAgent的组件,它在Start()中将它分配给agent变量。然后我们随机设置其速度在两个值之间,为我们的模拟增加一些视觉多样性。最后,我们将其目的地设置为目标标记的位置。目标标记通过检查器分配,如以下屏幕截图所示:

NavMeshAgent 的检查器设置
先前的屏幕截图展示了红色胶囊的CrowdAgent组件,其目标设置为 RedTarget(变换)。为了好玩,你可以尝试设置不同的目标。由于唯一的要求是该目标必须是Transform类型,你甚至可以将另一个智能体设置为目标!
添加一些有趣的障碍物
不需要在我们的代码中做任何其他操作,我们只需对我们的场景布局做一些修改,并启用 Unity 提供的一些组件,就可以显著改变智能体的行为。在我们的CrowdsObstacles场景中,我们在环境中添加了几堵墙,为我们的红色和蓝色胶囊团队创建了一个迷宫式的布局,如下面的截图所示:

让游戏开始吧!
这个例子有趣的部分在于,由于每个智能体的随机速度,每次运行游戏时结果都会完全不同。当智能体在环境中移动时,它们会被队友或对手的智能体阻挡,并被迫重新规划路线,找到到达目标的最快路径。当然,这个概念对我们来说并不陌生,因为我们已经在第四章“寻找路径”中看到了NavMeshAgent如何避开障碍物,除了在这个场景中我们有很多、很多更多的智能体。为了使这个例子更有趣,我们还给一面墙和一个NavMeshObstacle组件添加了一个简单的上下动画,看起来就像这样:

NavMeshObstacle 在 Unity 2017 中看起来略有不同
注意,当我们使用这个组件时,我们的障碍物不需要设置为静态。我们的障碍物主要是箱形的,所以我们保留默认的形状设置为箱形(胶囊形也是一个选择)。大小和中心选项让我们可以移动形状的轮廓并调整它的大小,但默认设置完美地适合我们的形状,这正是我们想要的,所以让我们保持这个设置不变。下一个选项“雕刻”非常重要。它基本上就是它所说的那样;它从 NavMesh 中雕刻出一个空间,如下面的截图所示:

在上下动画的两个不同点上的相同障碍物
左侧截图显示了障碍物在表面上的空间,而右侧截图显示了当障碍物抬起时 NavMesh 的连接。我们可以保持时间到静止和移动阈值为默认设置,但我们确实想确保“仅雕刻静止”选项是关闭的。这是因为我们的障碍物是移动的,如果我们不勾选这个框,它就不会从 NavMesh 中雕刻出空间,我们的智能体就会试图穿过障碍物,无论它是上升还是下降,而这并不是我们想要的这种行为。
当障碍物上下移动并且网格被雕刻出来并重新连接时,你会注意到智能体在改变航向。当启用导航调试选项时,我们还可以看到在任何给定时刻我们智能体所发生的一切的非常有趣的可视化。这样对待我们可怜的智能体可能有点残忍,但我们这样做是为了科学!
以下截图让我们一窥我们让可怜的代理所承受的混乱和无序:

我在秘密地为蓝队加油
摘要
在本章中,我们学习了如何实现一个群体行为系统。我们通过自定义方向向量来实现它,这些向量控制着通过应用克雷格·雷诺兹的三个主要群体概念——对齐、凝聚和分离——计算出的鸟群运动。然后,我们将我们的群体行为应用于飞行物体,但你也可以将这些示例中的技术应用于实现其他角色行为,例如鱼群游动、昆虫集群或陆地动物的放牧。你只需实现不同的领导者运动行为,例如限制不能上下移动的角色沿 y 轴的运动。对于二维游戏,我们只需冻结 y 位置。对于不规则的地面上的二维运动,我们必须修改我们的脚本,以确保在 y 方向上不施加任何力。
我们还研究了人群模拟,并使用 Unity 的 NavMesh 系统实现了我们自己的版本,这是我们首次在第四章,找到你的路中了解到的。我们学习了如何可视化我们的代理的行为和决策过程。
在下一章中,我们将探讨行为树模式,并学习从头开始实现我们自己的版本。
第六章:行为树
行为树(BTs)在游戏开发者中非常稳定地获得了人气。在过去十年中,BTs 已经成为许多开发者实现其 AI 代理行为规则的首选模式。像光环和战争机器这样的游戏系列是广泛使用 BTs 的著名游戏之一。PC、游戏机和移动设备中丰富的计算能力使它们成为在所有类型和规模的游戏中实现 AI 的好选择。
在本章中,我们将涵盖以下主题:
-
行为树的基本概念
-
使用现有行为树解决方案的好处
-
如何实现我们自己的行为树框架
-
如何使用我们的框架实现基本树
学习行为树的基本知识
行为树因其具有共同父节点的分层、分支节点系统而得名,这个父节点被称为根节点。正如你通过阅读本书所学到的那样,行为树也模仿了它们所命名的真实事物——在这种情况下,是树木及其分支结构。如果我们可视化一个行为树,它看起来可能就像以下图示:

基本树结构
当然,行为树可以由任意数量的节点和子节点组成。在层次结构最底层的节点被称为叶节点,就像一棵树一样。节点可以表示行为或测试。与依赖于转换规则来遍历的状态机不同,BT 的流程严格由每个节点在更大层次结构中的顺序定义。BT 从树的顶部开始评估(基于前面的可视化),然后继续通过每个子节点,这些子节点又依次运行其各自的子节点,直到满足条件或达到叶节点。BT 始终从根节点开始评估。
理解不同的节点类型
不同类型节点的名称可能因人而异,节点本身有时也被称为任务。虽然树的结构完全取决于 AI 的需求,但如果我们单独查看每个组件,关于 BT 如何工作的基本概念相对容易理解。以下是对每种类型的节点都成立的事实。节点将始终返回以下状态之一:
-
成功:节点检查的条件已经满足。
-
失败:节点检查的条件未满足,并且将不会满足。
-
运行:节点检查的条件的有效性尚未确定。这可以被视为我们的“请等待”状态。
由于 BT(行为树)的潜在复杂性,大多数实现都是异步的,至少对于 Unity 来说,这意味着评估树不会阻止游戏继续其他操作。BT 中各个节点的评估过程可能需要几个帧。如果你必须同时评估任何数量的代理上的多个树,你可以想象,如果必须等待每个树返回 true 或 false 给根节点,这将对程序的性能产生负面影响。这就是为什么“运行”状态很重要的原因。
定义复合节点
复合节点之所以被称为复合节点,是因为它们有一个或多个子节点。它们的状态完全基于评估子节点的结果,并且在评估子节点时,它们将处于“运行”状态。有几个复合节点类型,它们主要是由它们的子节点的评估方式来定义的:
- 序列:序列的定义特征是,整个子节点序列必须成功完成,它本身才能评估为成功。如果在序列的任何步骤中,任何一个子节点返回 false,序列本身将报告失败。重要的是要注意,通常情况下,序列是从左到右执行的。以下图分别显示了成功的序列和失败的序列:

成功的序列节点

不成功的序列节点
- 选择器:相比之下,选择器对它们的子节点更加宽容。如果一个选择器序列中的任何一个子节点返回 true,选择器就会说,“嗯,足够了!”并立即返回 true,而不会评估其子节点中的更多内容。选择器节点返回 false 的唯一方式是评估所有子节点,但没有一个返回成功。
当然,每种复合节点类型都有其用途,这取决于具体情况。你可以将不同类型的序列节点视为“与”和“或”条件。
理解装饰器节点
复合节点和装饰器节点之间最大的区别是,装饰器可以恰好有一个子节点,且只有一个子节点。起初,这可能会显得不必要,因为理论上你可以通过在节点本身包含条件而不是依赖其子节点来获得相同的功能,但装饰器节点是特殊的,因为它本质上接受子节点返回的状态,并根据其自己的参数评估响应。装饰器甚至可以指定其子节点的评估方式和评估频率。以下是一些常见的装饰器类型:
-
逆变器:将逆变器视为一个 NOT 修饰符。它接受其子节点返回的状态的反面。例如,如果子节点返回 TRUE,装饰器评估为 FALSE,反之亦然。这在 C#中相当于在布尔值前加上
!运算符。 -
重复器:这个装饰器会重复评估子节点指定(或无限)次数,直到根据装饰器的判断评估为 TRUE 或 FALSE。例如,您可能希望无限期地等待直到满足某些条件,例如在角色使用攻击之前“拥有足够的能量”。
-
限制器:这个装饰器简单地限制了节点被评估的次数,以避免智能体陷入尴尬的无穷行为循环。与重复器相比,这个装饰器可以用来确保角色在放弃并尝试其他方法之前,例如,只能尝试打开门几次。
一些装饰节点可用于调试和测试您的树,例如:
-
假状态:这个状态总是根据装饰器的指定评估为真或假。这对于断言您的智能体中的某些行为非常有帮助。例如,您也可以让装饰器保持一个无限期的假“运行”状态,以观察周围的其他智能体的行为。
-
断点:就像代码中的断点一样,您可以让这个节点触发逻辑,通过调试日志或其他方法通知您节点已被达到。
这些类型不是互斥的单一原型。您可以将这些类型的节点组合起来以满足您的需求。只是要小心不要将太多功能组合到一个装饰器中,以至于使用序列节点可能更高效或更方便。
描述叶子节点
我们在本章前面简要介绍了叶子节点,以说明 BT 的结构,但叶子节点实际上可以是任何类型的行为。它们在意义上是神奇的,因为它们可以用来描述智能体可以拥有的任何逻辑。叶子节点可以指定一个行走函数、射击命令或踢动作。它所做的或你决定如何评估其状态无关紧要,它只需是其自身层次结构中的最后一个节点,并返回节点可以返回的三个状态中的任何一个。
评估现有解决方案
Unity 资产商店是开发者们的一个优秀资源。您不仅能够购买艺术、音频和其他类型的资产,而且它还包含大量插件和框架。对我们来说最重要的是,资产商店上有许多行为树插件可供选择,价格从免费到几百美元不等。大多数,如果不是全部,都提供某种形式的 GUI,使得可视化和管理变得相对轻松。
从资产商店中选择现成的解决方案有许多优点。许多框架包括高级功能,如运行时(和通常可视化)调试、健壮的 API、序列化和面向数据树支持。许多甚至包括用于游戏的示例叶子逻辑节点,以最小化您需要编写的代码量来启动和运行。
本书的前一版,《Unity 4.x 游戏人工智能编程》,专注于开发者 AngryAnt 的 Behave 插件,该插件目前作为付费插件在资产商店上提供,名为 Behave 2 for Unity,继续成为满足您行为树需求(以及更多)的绝佳选择。它是一个非常健壮、性能出色且设计精良的框架。
其他一些替代方案是Behavior Machine和Behavior Designer,它们提供不同的定价层次(Behavior Machine 甚至提供免费版)和广泛的有用功能。在网络上可以找到许多其他免费选项,包括通用的 C#和针对 Unity 的实现。最终,与其他任何系统一样,选择自己构建还是使用现有解决方案将取决于您的时间、预算和项目。
实现基本行为树框架
尽管本书的范围不包括具有图形用户界面(GUI)的完整行为树实现及其众多节点类型和变体,但我们当然可以专注于核心原则,以牢固掌握本章所涵盖的概念在实际操作中的样子。本章提供了行为树的基本框架。我们的示例将专注于简单的逻辑,以突出树的功能,而不是用复杂的游戏逻辑使示例变得复杂。我们的目标是让您对在游戏人工智能中可能显得令人生畏的概念感到舒适,并为您提供必要的工具来构建自己的树,并在需要时扩展提供的代码。
实现基本节点类
每个节点都需要一个基本功能。我们的简单框架将所有节点派生自一个基本的抽象Node.cs类。这个类将提供这种基本功能或至少是扩展该功能的签名:
using UnityEngine;
using System.Collections;
[System.Serializable]
public abstract class Node {
/* Delegate that returns the state of the node.*/
public delegate NodeStates NodeReturn();
/* The current state of the node */
protected NodeStates m_nodeState;
public NodeStates nodeState {
get { return m_nodeState; }
}
/* The constructor for the node */
public Node() {}
/* Implementing classes use this method to evaluate the desired set of conditions */
public abstract NodeStates Evaluate();
}
这个类相当简单。将Node.cs视为所有其他节点类型的基础蓝图。我们以NodeReturn委托开始,这在我们的示例中没有实现,但接下来的两个字段是。然而,m_nodeState是节点在任何给定点的状态。正如我们之前所学的,它将是FAILURE、SUCCESS或RUNNING之一。nodeState值只是m_nodeState的获取器,因为它受保护,我们不希望代码的其他任何部分意外地直接设置m_nodeState。
接下来,我们有一个空构造函数,为了明确起见,尽管它没有被使用。最后,我们有了Node.cs类的核心内容——Evaluate()方法。正如我们将在实现Node.cs的类中看到的那样,Evaluate()是魔法发生的地方。它运行确定节点状态的代码。
扩展节点到选择器
要创建一个选择器,我们只需扩展我们在Node.cs类中描述的功能:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class Selector : Node {
/** The child nodes for this selector */
protected List<Node> m_nodes = new List<Node>();
/** The constructor requires a lsit of child nodes to be
* passed in*/
public Selector(List<Node> nodes) {
m_nodes = nodes;
}
/* If any of the children reports a success, the selector will
* immediately report a success upwards. If all children fail,
* it will report a failure instead.*/
public override NodeStates Evaluate() {
foreach (Node node in m_nodes) {
switch (node.Evaluate()) {
case NodeStates.FAILURE:
continue;
case NodeStates.SUCCESS:
m_nodeState = NodeStates.SUCCESS;
return m_nodeState;
case NodeStates.RUNNING:
m_nodeState = NodeStates.RUNNING;
return m_nodeState;
default:
continue;
}
}
m_nodeState = NodeStates.FAILURE;
return m_nodeState;
}
}
正如我们在本章前面所学的,选择器是复合节点:这意味着它们有一个或多个子节点。这些子节点存储在 m_nodes List<Node> 变量中。尽管可以想象扩展这个类的功能以允许在类实例化后添加更多子节点,但我们最初通过构造函数提供这个列表。
代码的下一部分更有趣,因为它展示了我们之前学到的概念的实际实现。Evaluate() 方法会遍历其所有子节点,并逐个评估它们。如果一个子节点返回 FAILURE 并不必然意味着整个选择器的失败,如果其中一个子节点返回 FAILURE,我们就简单地继续到下一个子节点。相反,如果任何一个子节点返回 SUCCESS,那么我们就已经完成了;我们可以相应地设置这个节点的状态并返回该值。如果我们遍历了所有子节点并且它们都没有返回 SUCCESS,那么我们基本上可以确定整个选择器已经失败,并分配并返回一个 FAILURE 状态。
接下来是序列
序列在实现上非常相似,但正如你可能已经猜到的,Evaluate() 方法的行为有所不同:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class Sequence : Node {
/** Children nodes that belong to this sequence */
private List<Node> m_nodes = new List<Node>();
/** Must provide an initial set of children nodes to work */
public Sequence(List<Node> nodes) {
m_nodes = nodes;
}
/* If any child node returns a failure, the entire node fails. Whence all
* nodes return a success, the node reports a success. */
public override NodeStates Evaluate() {
bool anyChildRunning = false;
foreach(Node node in m_nodes) {
switch (node.Evaluate()) {
case NodeStates.FAILURE:
m_nodeState = NodeStates.FAILURE;
return m_nodeState;
case NodeStates.SUCCESS:
continue;
case NodeStates.RUNNING:
anyChildRunning = true;
continue;
default:
m_nodeState = NodeStates.SUCCESS;
return m_nodeState;
}
}
m_nodeState = anyChildRunning ? NodeStates.RUNNING : NodeStates.SUCCESS;
return m_nodeState;
}
}
序列中的 Evaluate() 方法需要为所有子节点返回 true,如果在过程中任何一个子节点失败,整个序列就会失败,这就是为什么我们首先检查 FAILURE 并相应地设置和报告它的原因。SUCCESS 状态仅仅意味着我们还有机会再战一天,然后继续到下一个子节点。如果任何一个子节点被确定为处于 RUNNING 状态,我们就报告这个状态为节点状态,然后父节点或驱动整个树的逻辑可以再次评估它。
将装饰器实现为逆或器
Inverter.cs 的结构略有不同,但它与所有其他节点一样,都源自 Node。让我们看看代码并找出差异:
using UnityEngine;
using System.Collections;
public class Inverter : Node {
/* Child node to evaluate */
private Node m_node;
public Node node {
get { return m_node; }
}
/* The constructor requires the child node that this inverter decorator
* wraps*/
public Inverter(Node node) {
m_node = node;
}
/* Reports a success if the child fails and
* a failure if the child succeeds. Running will report
* as running */
public override NodeStates Evaluate() {
switch (m_node.Evaluate()) {
case NodeStates.FAILURE:
m_nodeState = NodeStates.SUCCESS;
return m_nodeState;
case NodeStates.SUCCESS:
m_nodeState = NodeStates.FAILURE;
return m_nodeState;
case NodeStates.RUNNING:
m_nodeState = NodeStates.RUNNING;
return m_nodeState;
}
m_nodeState = NodeStates.SUCCESS;
return m_nodeState;
}
}
如您所见,由于装饰器只有一个子节点,所以我们没有 List<Node>,而是一个单独的节点变量 m_node。我们通过构造函数传递这个节点(本质上要求这样做),但没有理由你不能修改这段代码以提供一个空构造函数和一个在实例化后分配子节点的方法。
Evalute() 的实现实现了我们在本章前面描述的逆或器的行为:当子节点评估为 SUCCESS 时,逆或器报告 FAILURE,而当子节点评估为 FAILURE 时,逆或器报告 SUCCESS。RUNNING 状态则正常报告。
创建一个通用的动作节点
现在我们来到了ActionNode.cs,这是一个通用的叶节点,可以通过委托传递一些逻辑。您可以根据自己的逻辑以任何方式实现叶节点,只要它从Node派生。这个特定的例子既灵活又有限制。它在灵活性方面允许您传递任何与委托签名匹配的方法,但正因为如此,它只提供了一个不接受任何参数的委托签名:
using System;
using UnityEngine;
using System.Collections;
public class ActionNode : Node {
/* Method signature for the action. */
public delegate NodeStates ActionNodeDelegate();
/* The delegate that is called to evaluate this node */
private ActionNodeDelegate m_action;
/* Because this node contains no logic itself,
* the logic must be passed in in the form of
* a delegate. As the signature states, the action
* needs to return a NodeStates enum */
public ActionNode(ActionNodeDelegate action) {
m_action = action;
}
/* Evaluates the node using the passed in delegate and
* reports the resulting state as appropriate */
public override NodeStates Evaluate() {
switch (m_action()) {
case NodeStates.SUCCESS:
m_nodeState = NodeStates.SUCCESS;
return m_nodeState;
case NodeStates.FAILURE:
m_nodeState = NodeStates.FAILURE;
return m_nodeState;
case NodeStates.RUNNING:
m_nodeState = NodeStates.RUNNING;
return m_nodeState;
default:
m_nodeState = NodeStates.FAILURE;
return m_nodeState;
}
}
}
使此节点工作关键是m_action委托。对于那些熟悉 C++的人来说,C#中的委托可以被视为某种函数指针。您也可以将委托视为包含(或更准确地说,指向)函数的变量。这允许您在运行时设置要调用的函数。构造函数要求您传递一个与签名匹配的方法,并期望该方法返回一个NodeStates枚举。该方法可以实施任何您想要的逻辑,只要满足以下条件。与我们所实现的其它节点不同,此节点不会切换到 switch 之外的任何状态,因此它默认为FAILURE状态。您可以选择通过修改默认返回值来默认为SUCCESS或RUNNING状态。
您可以通过从它派生或简单地对其进行所需更改来轻松扩展此类。您还可以完全跳过这个通用动作节点,并实现特定叶节点的单次版本,但尽可能重用代码是一个好的实践。只需记住要从Node派生并实现所需的代码!
测试我们的框架
我们刚刚审查的框架实际上就是这个。它为我们提供了构建树所需的所有功能,但我们必须自己构建实际的树。为了本书的目的,提供了一个部分手动构建的树。
提前规划
在我们设置树之前,让我们看看我们试图实现什么。在实现之前可视化树通常很有帮助。我们的树将从零计数到指定的值。在这个过程中,它将检查该值是否满足某些条件,并相应地报告其状态。以下图表说明了我们树的基礎层次结构:

在我们的测试中,我们将使用一个三层树,包括根节点:
-
节点 1:这是我们根节点。它有子节点,我们希望能够在任何子节点成功的情况下返回成功,因此我们将它实现为一个选择器。
-
节点 2a:我们将使用
ActionNode来实现此节点。 -
节点 2b:我们将使用此节点来演示我们的逆变器是如何工作的。
-
节点 2c:我们将再次从节点2a运行相同的
ActionNode,并看看这对我们的树评估有何影响。 -
节点 3:节点 3 恰好是树第三层的唯一节点。它是 2b 装饰节点的一个子节点。这意味着如果它报告
SUCCESS,则 2b 将报告FAILURE,反之亦然。
到目前为止,我们对实现细节仍然有些模糊,但前面的图将帮助我们可视化我们在代码中实现的树。在查看代码时,请将其保留以供参考。
检查我们的场景设置
我们已经看过了我们树的基本结构,在我们深入实际代码实现之前,让我们看看我们的场景设置。下面的截图显示了我们的层次结构;节点被突出显示以强调:

设置相当简单。有一个带有世界空间画布的四边形,它只是用于在测试期间显示一些信息。前面截图中的突出显示的节点将在代码中稍后引用,我们将使用它们来可视化每个单独节点的状态。实际场景看起来大致如下截图所示:

我们的实际布局模仿了我们之前创建的图
如您所见,我们有一个节点或框代表我们在规划阶段设置的每个节点。这些在实际情况的测试代码中会被引用,并且会根据返回的状态改变颜色。
探索 MathTree 代码
不再拖延,让我们看看驱动我们测试的代码。这是 MathTree.cs:
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
public class MathTree : MonoBehaviour {
public Color m_evaluating;
public Color m_succeeded;
public Color m_failed;
public Selector m_rootNode;
public ActionNode m_node2A;
public Inverter m_node2B;
public ActionNode m_node2C;
public ActionNode m_node3;
public GameObject m_rootNodeBox;
public GameObject m_node2aBox;
public GameObject m_node2bBox;
public GameObject m_node2cBox;
public GameObject m_node3Box;
public int m_targetValue = 20;
private int m_currentValue = 0;
[SerializeField]
private Text m_valueLabel;
前几个变量只是用于调试。三个颜色变量是我们将分配给节点框以可视化其状态的颜色。默认情况下,RUNNING 是黄色,SUCCESS 是绿色,FAILED 是红色。这是相当标准的事情;让我们继续前进。
然后我们声明我们的实际节点。如您所见,m_rootNode 是一个选择器,正如我们之前提到的。请注意,我们还没有分配任何节点变量,因为我们必须向它们的构造函数传递一些数据。
我们接下来有对我们在场景中看到的框的引用。这些只是我们拖放到检查器中的游戏对象(我们会在检查代码之后查看)。
我们接下来有几个 int 值,当我们查看逻辑时会更有意义,所以我们会跳过这些。最后,我们有一个 Unity UI Text 变量,在测试期间会显示一些值。
让我们来看看我们实际节点的初始化:
/* We instantiate our nodes from the bottom up, and assign the children
* in that order */
void Start () {
/** The deepest-level node is Node 3, which has no children. */
m_node3 = new ActionNode(NotEqualToTarget);
/** Next up, we create the level 2 nodes. */
m_node2A = new ActionNode(AddTen);
/** Node 2B is a selector which has node 3 as a child, so we'll pass
* node 3 to the constructor */
m_node2B = new Inverter(m_node3);
m_node2C = new ActionNode(AddTen);
/** Lastly, we have our root node. First, we prepare our list of children
* nodes to pass in */
List<Node> rootChildren = new List<Node>();
rootChildren.Add(m_node2A);
rootChildren.Add(m_node2B);
rootChildren.Add(m_node2C);
/** Then we create our root node object and pass in the list */
m_rootNode = new Selector(rootChildren);
m_valueLabel.text = m_currentValue.ToString();
m_rootNode.Evaluate();
UpdateBoxes();
}
为了组织结构,我们从树的底部到顶部声明我们的节点,或者说是根节点。我们这样做是因为我们不能在没有传递其子节点的情况下实例化父节点,所以我们必须首先实例化子节点。请注意,m_node2A、m_node2C和m_node3是动作节点,因此我们传递了代表(我们将在下一节中查看这些方法)。然后,作为选择器的m_node2B接受一个节点作为子节点,在这种情况下是m_node3。在我们声明了这些层级之后,我们将所有 2 级节点放入一个列表中,因为我们的 1 级节点,即根节点,是一个需要实例化子节点列表的选择器。
在我们实例化所有节点之后,我们启动流程,并开始使用其Evaluate()方法评估我们的根节点。UpdateBoxes()方法只是更新我们之前声明的box游戏对象的颜色;我们将在本节的稍后部分查看它:
private void UpdateBoxes() {
/** Update root node box */
if (m_rootNode.nodeState == NodeStates.SUCCESS) {
SetSucceeded(m_rootNodeBox);
} else if (m_rootNode.nodeState == NodeStates.FAILURE) {
SetFailed(m_rootNodeBox);
}
/** Update 2A node box */
if (m_node2A.nodeState == NodeStates.SUCCESS) {
SetSucceeded(m_node2aBox);
} else if (m_node2A.nodeState == NodeStates.FAILURE) {
SetFailed(m_node2aBox);
}
/** Update 2B node box */
if (m_node2B.nodeState == NodeStates.SUCCESS) {
SetSucceeded(m_node2bBox);
} else if (m_node2B.nodeState == NodeStates.FAILURE) {
SetFailed(m_node2bBox);
}
/** Update 2C node box */
if (m_node2C.nodeState == NodeStates.SUCCESS) {
SetSucceeded(m_node2cBox);
} else if (m_node2C.nodeState == NodeStates.FAILURE) {
SetFailed(m_node2cBox);
}
/** Update 3 node box */
if (m_node3.nodeState == NodeStates.SUCCESS) {
SetSucceeded(m_node3Box);
} else if (m_node3.nodeState == NodeStates.FAILURE) {
SetFailed(m_node3Box);
}
}
这里没有太多可讨论的内容。请注意,因为我们手动设置了此树,所以我们逐个检查每个节点并获取其nodeState,然后使用SetSucceeded和SetFailed方法设置颜色。让我们继续到类的核心部分:
private NodeStates NotEqualToTarget() {
if (m_currentValue != m_targetValue) {
return NodeStates.SUCCESS;
} else {
return NodeStates.FAILURE;
}
}
private NodeStates AddTen() {
m_currentValue += 10;
m_valueLabel.text = m_currentValue.ToString();
if (m_currentValue == m_targetValue) {
return NodeStates.SUCCESS;
} else {
return NodeStates.FAILURE;
}
}
首先,我们有NotEqualToTarget(),这是我们传递给装饰器子动作节点的方法。我们在这里实际上是在设置一个双重否定,所以尽量跟上。此方法在当前值不等于目标值时返回成功,否则返回 false。父级反转装饰器将评估为与该节点返回值相反。所以,如果值不等于,反转节点将失败;否则,它将成功。如果您现在感到有些困惑,不要担心。当我们看到它在实际操作中的表现时,一切都会变得清晰。
下一个方法是AddTen()方法,这是传递给我们的其他两个动作节点的方法。它确实做了它名字暗示的事情——将 10 加到我们的m_currentValue变量上,然后检查它是否等于我们的m_targetValue,如果是,则评估为SUCCESS,如果不是,则评估为FAILURE。
最后几个方法比较直观,所以我们不会详细说明。
执行测试
现在我们对代码的工作原理有了相当好的了解,让我们看看它在实际操作中的表现。首先,然而。让我们确保我们的组件被正确设置。从层次结构中选择 Tree 游戏对象,其检查器应该看起来类似于以下内容:

组件的默认设置
如您所见,状态颜色和盒子引用已经为您分配,以及m_valueLabel变量。m_targetValue变量也已经通过代码为您分配。确保在播放之前将其保留为(或设置为)20。播放场景,您将看到您的盒子被点亮,如下面的截图所示:

箱子被点亮,表示每个节点的评估结果
如我们所见,我们的根节点评估为 SUCCESS,这正是我们想要的,但让我们一步一步地检查原因,从第 2 层开始:
-
节点 2A:我们从
m_currentValue的0开始,所以当给它加上10后,它仍然不等于我们的m_targetValue(20),因此它失败了。因此,它是红色的。 -
节点 2B:当它评估其子节点时,再次,
m_currentValue和m_targetValue不相等。这返回SUCCESS。然后,反相逻辑启动并反转这个响应,使其报告自己的FAILURE。因此,我们继续到最后一个节点。 -
节点 2C:再次,我们将
10加到m_currentValue上。它变成了20,这等于m_targetValue,评估结果为SUCCESS,因此我们的根节点因此成功。
测试很简单,但它清楚地说明了概念。在我们认为测试成功之前,让我们再运行一次,但首先更改 m_targetValue。在检查器中将其设置为 30,如以下截图所示:

更新后的值被突出显示
当然,这是一个小的变化,但它将改变整个树的评估方式。再次播放场景,我们最终会得到以下截图所示的节点点亮集合:

与我们第一次测试明显不同的结果
如您所见,除了一个子节点外,我们的根节点的所有子节点都失败了,因此它自己报告 FAILURE。让我们看看为什么:
-
节点 2A:这里与我们原始示例相比没有真正的变化。我们的
m_currentValue变量从0开始,最终达到10,这并不等于我们的m_targetValue30,因此它失败了。 -
节点 2B:它再次评估其子节点,因为子节点报告
SUCCESS,它自己报告FAILURE,然后我们继续到下一个节点。 -
节点 2C:再次,我们将
10加到我们的m_currentValue变量上,累计到20,在更改了m_targetValue变量之后,不再评估为SUCCESS。
节点的当前实现将未评估的节点默认设置为 SUCCESS。这是因为我们的枚举顺序,如您在 NodeState.cs 中所见:
public enum NodeStates {
SUCCESS,
FAILURE,
RUNNING,
}
在我们的枚举中,SUCCESS 是第一个枚举,所以如果一个节点从未被评估,默认值永远不会改变。如果您将 m_targetValue 变量更改为 10,例如,所有节点都会点亮为绿色。这仅仅是我们的测试实现的一个副作用,实际上并不反映我们节点的设计问题。我们的 UpdateBoxes() 方法会更新所有盒子,无论它们是否被评估。在这个例子中,节点 2A 会立即评估为 SUCCESS,这反过来又会导致根节点报告 SUCCESS,而节点 2B、2C 和 3 都不会评估,对整个树的评估没有影响。
非常鼓励你尝试这个测试。例如,将根节点实现从选择器改为序列。只需将 public Selector m_rootNode; 改为 public Sequence m_rootNode;,并将 m_rootNode = new Selector(rootChildren); 改为 m_rootNode = new Sequence(rootChildren);,你就可以测试一组完全不同的功能。
HomeRock 卡牌游戏示例
为了进一步说明行为树的可能用途,让我们看看本章示例代码中的第二个例子。在CardGame Unity 场景中,你可以找到一个回合制卡牌游戏的实现,其中玩家和 AI 对手有三种不同的能力:攻击、治疗和防御。用户可以在他们的回合选择使用哪种能力,AI 将使用行为树来决定采取哪种行动。游戏在玩家生命值降至 0 时结束。以下图像展示了我们的游戏视图:

HomeRock—Unity 英雄的游戏屏幕
如你所见,玩家可以通过点击一张卡片来选择他们的攻击方式,这些卡片已经添加了一些风味文本。玩家的生命值显示在屏幕左下角,AI 敌人的生命值显示在屏幕右上角。前提很简单,即使例子有点愚蠢。在我们深入代码之前,让我们看看场景设置。
场景设置
在这个场景中发生了很多事情,因为这个例子比本书中之前的例子要复杂一些。我们将逐一介绍每个元素,但将重点放在手头的主题上:行为树。让我们看看场景层次结构,如下截图所示:

场景层次结构
在这个截图显示的层次结构中,我们会发现一些与游戏相关的元素,你也许还会注意到画布下嵌套了相当多的元素。
Game 游戏对象上有两个组件——Animator,它控制游戏状态,以及Game.cs组件,它控制游戏流程和规则。首先,让我们看看游戏状态。Animator 有一个对GameFlowStateMachine的引用,如下截图所示:

所示的状态机有几个样板状态,如 MainMenu 和 GameEnd。你会注意到 MainMenu 是我们的入口点。虽然我们的示例没有主菜单,但你可以使用这个状态来实现自己的。在示例中,状态只是自动过渡到 GameIntro 状态。再次强调,GameIntro 是一个提供给你实现任何开场序列或动画的舞台,但默认过渡到下一个阶段,MainGame。最后,我们有 GameEnd,你可以从任何状态过渡到它,只要你触发了 EndGame。你可能已经注意到 MainGame 是一个嵌套的树,如果我们双击它来深入其内容,我们会找到一个看起来像这个截图的树:

前一个截图中的设置足够简单——有一个玩家回合和一个敌人回合。这些回合在设置 EndTurn 触发器时来回弹跳。接下来,我们有 Game.cs 组件,这在检查器中看起来是这样的:

在前面的截图中,我们可以看到游戏组件对场景中其他脚本的一些引用。我们稍后会涉及到这些,但请注意,有一个对状态机的引用。让我们深入到 Game.cs 代码中,看看底层发生了什么:
using UnityEngine;
public class Game : MonoBehaviour {
[SerializeField]
private Animator stateMachine;
[SerializeField]
private PlayerController playerController;
[SerializeField]
private EnemyBehaviorTree enemyBehaviorTree;
[SerializeField]
private Player humanPlayer;
[SerializeField]
private Player aiPlayer;
[SerializeField]
private UIController uiController;
private int turn = 0;
private void Awake() {
enemyBehaviorTree.SetPlayerData(humanPlayer, aiPlayer);
enemyBehaviorTree.onTreeExecuted += EndTurn;
playerController.onActionExecuted += EndTurn;
}
public void EvaluateAITree() {
enemyBehaviorTree.Evaluate();
}
private void EndTurn() {
if(humanPlayer.CurrentHealth <= 0 || aiPlayer.CurrentHealth <= 0) {
stateMachine.SetTrigger("EndGame");
uiController.EndGame();
return;
}
stateMachine.SetTrigger("EndTurn");
turn ^= 1;
uiController.SetTurn(turn);
}
}
首先,我们有在检查器中刚刚看到的所有序列化值,还有一个私有的非序列化值,即 turn 值。这个值在玩家的回合和 AI 的回合之间在 0 和 1 之间切换。我们的 Awake 方法进行了一些设置,初始化 EnemyBehaviorTree.cs 脚本中的值,并添加了一些回调到敌人 AI 控制器和我们的玩家控制器。
EvaluateAITree() 方法简单地封装了敌人的 BT 的 Evaluate() 方法。我们这样做是为了实现一些伪解耦。最后,EndTurn() 方法执行了一些操作:检查两位玩家的健康值是否低于 0,如果是,则结束游戏;它还切换回合值,在状态机设置适当的触发器,并更新 UIController 上的回合信息。
在我们场景的层次结构中,在 Game 游戏对象下,我们有一个 HumanPlayer 游戏对象和一个 EnemyAI 游戏对象。两者都有一个 Player.cs 脚本,该脚本仅包含控制和操作该玩家值的数据和方法。下面的截图显示了 HumanPlayer 游戏对象的 Player 组件的值:

为了更好地了解前面截图中所显示的值的作用,让我们看一下 Player.cs 脚本并将其分解:
using UnityEngine;
public class Player : MonoBehaviour {
[SerializeField]
private int maxHealth = 20;
[SerializeField]
private int currentHealth;
[SerializeField]
private int lowHealthThreshold = 7;
[Header("Ability Parameters")]
private int minHealAmount = 2;
private int maxHealAmount = 5;
private int minDamage = 2;
private int maxDamage = 5;
private bool isBuffed = false;
public int CurrentHealth {
get { return currentHealth; }
}
public bool IsBuffed {
get { return isBuffed; }
}
public bool HasLowHealth {
get { return currentHealth < lowHealthThreshold; }
}
private void Awake() {
currentHealth = maxHealth;
}
public bool Buff() {
isBuffed = true;
return isBuffed;
}
public int Heal() {
int healAmount = Random.Range(minHealAmount, maxHealAmount);
currentHealth += healAmount;
return currentHealth;
}
public int Damage() {
int damageAmount = Random.Range(minDamage, maxDamage);
if(isBuffed) {
damageAmount /= 2;
isBuffed = false;
}
currentHealth -= damageAmount;
return currentHealth;
}
}
前几个值相当简单。maxHealth 代表玩家的最大健康值,currentHealth 代表当前健康值。我们使用 lowHealthThreshold 这个值让 AI 做出一些决策。它让我们能够根据其或其对手的健康状况来修改 AI 的行为。
我们随后列出了一些能力参数。minHealAmount 和 maxHealAmount 分别代表治疗能力的下限和上限。对于攻击能力,minDamage 和 maxDamage 字段也是如此。在 isBuffed 的情况下,我们使用 bool 来表示玩家是否“被增益”,这在某些游戏类型中是一个通用术语,表示角色或玩家具有有益的游戏状态。在我们的 Awake 方法中还有一些属性和初始化,然后是一系列的能力方法。
Buff() 方法只是将 isBuffed 值设置为 true。我们稍后在伤害计算中使用这个值。Heal() 方法选择一个介于 minHealAmount 和 maxHealAmount 指定范围内的随机数,并将这么多健康值恢复到玩家的 currentHealth 值。最后,Damage() 方法对玩家施加随机数量的伤害(通过从其当前健康值中减去),当玩家的 isBuffed 标志设置为 true 时,伤害减半。
我们现在可以看看 HumanPlayer 游戏对象的下一个组件,PlayerController.cs 脚本。组件的检查器值可以在以下屏幕截图中看到:

显示玩家控制器所有分配值的检查器
你会注意到一些对其自身的 Player.cs 组件以及敌人 AI 组件的引用。按钮部分包含对能力卡 UI 按钮的引用。类的代码看起来是这样的:
using UnityEngine;
using UnityEngine.UI;
public class PlayerController : MonoBehaviour {
[SerializeField]
private Player ownData;
[SerializeField]
private Player enemyData;
[Header("Buttons")]
[SerializeField]
private Button defendButton;
[SerializeField]
private Button healButton;
[SerializeField]
private Button attackButton;
public delegate void ActionExecuted();
public event ActionExecuted onActionExecuted;
void Awake () {
defendButton.onClick.AddListener(Defend);
healButton.onClick.AddListener(Heal);
attackButton.onClick.AddListener(Attack);
}
private void Attack() {
enemyData.Damage();
EndTurn();
}
private void Heal() {
ownData.Heal();
EndTurn();
}
private void Defend() {
ownData.Buff();
EndTurn();
}
private void EndTurn() {
if(onActionExecuted != null) {
onActionExecuted();
}
}
}
顶部的变量相当直接,它们正是我们在检查器中刚刚看到的那些值。你还会在这里找到一个 onActionExecuted 事件,如果你还记得的话,它是由 Game.cs 脚本的 Awake() 函数分配的。在这个类的 Awake() 方法中,我们为每个按钮分配了一个 onClick 处理器:防御、治疗和攻击。每个方法都会在 Player.cs 脚本上调用相应的技能方法,然后调用 EndTurn(),这反过来又调用 onActionExecuted 回调。请参考 Game.cs 脚本了解它所执行的操作。
敌人状态机
EnemyAI 游戏对象有自己的 Player.cs 脚本,正如我们之前看到的,但它还有一个我们最感兴趣的脚本:EnemyBehaviorTree.cs 组件。这个组件包含我们的敌人代理的 BT 以及一些辅助功能。现在让我们看看这段代码:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class EnemyBehaviorTree : MonoBehaviour {
private Player playerData;
private Player ownData;
public RandomBinaryNode buffCheckRandomNode;
public ActionNode buffCheckNode;
public ActionNode healthCheckNode;
public ActionNode attackCheckNode;
public Sequence buffCheckSequence;
public Selector rootNode;
我们像往常一样开始一些声明。最值得注意的是,我们在这里声明了我们的节点。我们有一些熟悉的节点,比如ActionNode、Sequence和Selector,你应该现在已经熟悉它们了。但你可能也注意到了一个不熟悉的节点——RandomBinaryNode。在深入挖掘EnemyBehaviorTree.cs代码之前,让我们看一下RandomBinaryNode.cs文件,看看这个节点类型的作用:
using UnityEngine;
public class RandomBinaryNode : Node {
public override NodeStates Evaluate() {
var roll = Random.Range(0, 2);
return (roll == 0 ? NodeStates.SUCCESS : NodeStates.FAILURE);
}
}
如你所见,节点非常简单。我们“掷”一个介于 0 和 1 之间的随机值(记住Random.Range(int, int)有一个排他的上限,这意味着它可以返回到那个值,但不包括它),当roll为 0 时返回SUCCESS状态,否则返回FAILURE。
回到EnemyBehaviorTree.cs类,我们还有一个委托/事件声明:
public delegate void TreeExecuted();
public event TreeExecuted onTreeExecuted;
与PlayerController.cs类上的onActionExecuted事件类似,这个事件将在 AI 执行其动作并触发回合结束检查后被调用。接下来,我们有Start()方法,它很重要,因为它设置了我们的节点结构,从最低级别的节点开始:
void Start () {
healthCheckNode = new ActionNode(CriticalHealthCheck);
attackCheckNode = new ActionNode(CheckPlayerHealth);
buffCheckRandomNode = new RandomBinaryNode();
buffCheckNode = new ActionNode(BuffCheck);
buffCheckSequence = new Sequence(new List<Node> {
buffCheckRandomNode,
buffCheckNode,
});
rootNode = new Selector(new List<Node> {
healthCheckNode,
attackCheckNode,
buffCheckSequence,
});
}
为了更好地理解代码这一部分正在发生什么,让我们看一下下面的图示:

敌人回合行为树
如你所见,敌人的回合被分解为三个步骤——健康检查、攻击检查和增益检查。健康检查节点是一个简单的ActionNode。在这种情况下,我们正在模拟一个相当保守的代理,因此它优先考虑自己的健康而不是攻击性。节点调用以下方法:
private NodeStates CriticalHealthCheck() {
if(ownData.HasLowHealth) {
return NodeStates.SUCCESS;
} else {
return NodeStates.FAILURE;
}
}
然后我们有攻击检查节点,它也是一个ActionNode。这个节点会检查人类玩家的健康值是否很低,如果是,就会尝试攻击以试图杀死玩家。这个函数它会调用:
private NodeStates CheckPlayerHealth() {
if(playerData.HasLowHealth) {
return NodeStates.SUCCESS;
} else {
return NodeStates.FAILURE;
}
}
然后我们有一个增益检查节点,实际上是一个序列,有两个子节点。这里的想法是,如果没有治疗,也没有攻击,代理将尝试给自己增益。然而,因为这会导致一个循环,其中它不断地给自己增益,玩家攻击(从而移除增益),然后它又不断地给自己增益,直到其健康值很低,所以我们通过RandomBinaryNode节点添加了一个随机化因素。实际的增益检查调用以下方法:
private NodeStates BuffCheck() {
if(!ownData.IsBuffed) {
return NodeStates.SUCCESS;
} else {
return NodeStates.FAILURE;
}
}
根节点本身是一个Selector,这意味着它只需要一个子节点返回SUCCESS,它自己就会返回SUCCESS。然而,在这个例子中,我们没有使用根节点的状态值。我们 AI 代码的最后部分是Execute()方法,正如你可能注意到的,它是一个协程。我们这样做是为了给 AI“思考”其移动的错觉。代码如下所示:
private IEnumerator Execute() {
Debug.Log("The AI is thinking...");
yield return new WaitForSeconds(2.5f);
if(healthCheckNode.nodeState == NodeStates.SUCCESS) {
Debug.Log("The AI decided to heal itself");
ownData.Heal();
} else if(attackCheckNode.nodeState == NodeStates.SUCCESS) {
Debug.Log("The AI decided to attack the player!");
playerData.Damage();
} else if (buffCheckSequence.nodeState == NodeStates.SUCCESS) {
Debug.Log("The AI decided to defend itself");
ownData.Buff();
} else {
Debug.Log("The AI finally decided to attack the player");
playerData.Damage();
}
if(onTreeExecuted != null) {
onTreeExecuted();
}
}
我们评估每个节点的状态,并相应地采取行动。如果所有节点都报告FAILURE,我们将回退到else子句,攻击敌人。在每一个阶段,我们通过调试日志调试 AI 的“过程”。在所有的if检查之后,我们简单地触发我们的回调,该回调随后通过Game.cs脚本调用我们之前传入的EndTurn()方法。
对于这个例子,我们需要查看的最后一段代码是EnemyTurnState.cs的StateMachineBehaviour脚本。它附加到状态机中的敌人回合状态。在其中,我们只实现了以下两个方法:
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
Debug.Log("********************* \n Strating the enemy's turn!");
animator.gameObject.GetComponent<Game>().EvaluateAITree();
}
正如你所见,OnStateEnter将一些信息记录到控制台,然后调用Game.cs脚本上的EvaluteAITree()方法,该方法随后在EnemyBehaviorTree.cs脚本上调用Evaluate()方法:
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
Debug.Log("Ending the enemy's turn. \n *********************");
}
OnStateExit方法只是将一些信息记录到控制台,这样当我们编辑器中进入播放模式时,我们会看到一个类似于以下截图的输出:

前面的截图显示了 AI 第一次回合后的控制台,此时 AI 代理和玩家都没有受到足够的伤害以使他们能够治疗或攻击,AI 选择防御自己,使用增益能力。
游戏测试
你所要做的就是点击播放,并参与其中。每场比赛都应该根据能力和RandomBinaryNode的随机性不同而有所不同。正如你所见,即使使用这里简单的三分支行为树,我们也能为游戏创造大量的可能结果。添加更多分支以适应你的游戏设计可以给你的游戏增加额外的挑战、重玩价值和不可预测性。
摘要
在这一章中,我们深入探讨了行为树的工作原理,然后我们查看可以组成行为树的每个单独的节点类型。我们还学习了某些节点在某些情况下比其他节点更有帮助的不同场景。在查看 Unity 资产商店中可用的现成解决方案之后,我们通过在 C#中实现自己的基本行为树框架并探索其内部工作原理来应用这些知识。在知识和工具准备就绪之后,我们使用我们的框架创建了一个示例行为树来测试本章学到的概念。然后我们继续探索实现HomeRock,一个示例卡牌游戏,展示了 AI 对手。这些知识使我们能够利用行为树在游戏中的力量,并将我们的 AI 实现提升到下一个层次。
在下一章中,第七章,“使用模糊逻辑让你的 AI 看起来更有生命力”,我们将探讨新的方法来增加我们在这章中学到的概念复杂性和功能性,修改行为树和 FSM,这些我们在第二章“有限状态机与你”中讨论过,通过模糊逻辑的概念。
第七章:使用模糊逻辑让你的 AI 看起来更有生命力
模糊逻辑是一种以更细腻的方式表示你的游戏规则的方法。也许比这本书中的其他概念更甚,模糊逻辑是一个非常数学化的主题。大部分信息都可以用纯数学函数来表示。为了教学目的,将重要概念应用于 Unity,大部分数学都已被简化,并使用 Unity 的内置功能实现。当然,如果你是那种喜欢数学的人,这个话题在某种程度上相当深入,所以请随意运用这本书中涵盖的概念!在本章中,我们将学习以下内容:
-
模糊逻辑是什么
-
模糊逻辑的应用领域
-
如何实现模糊逻辑控制器
-
模糊逻辑概念的其他创造性用途是什么
定义模糊逻辑
定义模糊逻辑最简单的方法是通过与二进制逻辑的比较。在前面的章节中,我们研究了作为真或假、0 或 1 值的转换规则。某物可见吗?它至少距离我们多远?即使在评估多个值的情况下,所有值都有确切的两个结果;因此,它们是二进制的。相比之下,模糊值代表了一个更丰富的可能性范围,其中每个值都表示为浮点数而不是整数。我们不再将值视为 0 或 1,而是将它们视为 0 到 1。
描述模糊逻辑的一个常见例子是温度。模糊逻辑允许我们根据非具体数据做出决策。我可以在阳光明媚的加利福尼亚夏日的一天外出,确信天气温暖,而无需知道确切的温度。相反,如果我冬天在阿拉斯加,我会知道天气很冷,同样,无需知道确切的温度。冷、凉爽、温暖和热这些概念都是模糊的。从温暖到热的转变点存在相当多的歧义。模糊逻辑允许我们将这些概念建模为集合,并通过使用一组规则来确定它们的有效性或真实性。
当人们做决策时,人们有一些灰色区域。也就是说,并不总是非黑即白。同样的概念也适用于依赖于模糊逻辑的代理。比如说,如果你几个小时没吃东西,开始觉得有点饿。在什么时刻你饿到足以去拿零食?你可以把饭后立刻的时间看作是 0,而 1 则是接近饥饿的点。以下图示说明了这一点:

在做决定时,有许多因素决定了最终的选择。这引出了模糊逻辑控制器的一个方面——它们可以考虑到所需的所有数据。让我们继续看看我们的“我应该吃饭吗?”的例子。我们只考虑了一个值来做这个决定,那就是自上次你吃饭以来经过的时间。然而,还有其他因素会影响这个决定,比如你当时消耗了多少能量,以及你当时有多懒。或者我是唯一一个把那作为一个决定因素的人吗?无论如何,你可以看到多个输入值如何影响输出,我们可以将其视为“再次用餐的可能性。”
由于模糊逻辑系统的通用性,它们可以非常灵活。你提供输入,模糊逻辑提供输出。这个输出对你游戏的意义完全取决于你。我们主要看了输入如何影响一个决定,实际上,这是将输出用于计算机、我们的代理可以理解的方式。然而,输出也可以用来确定做某事多少,某事发生多快,或者某事持续多久。
例如,想象一下你的代理是一个科幻赛车游戏中的汽车,它有一个“氮气助推”能力,可以让它消耗资源来加速。我们的 0 到 1 值可以代表使用该助推的标准化时间,或者可能是使用该助推的标准化燃料量。
选择模糊系统而非二元系统
就像我们在本书中之前介绍的系统一样,以及游戏编程中的大多数事情,我们必须在决定最佳解决问题的方法时评估我们游戏的需求、技术和硬件限制。
如你所想,从简单的是/否系统到更微妙的模糊逻辑系统,会有一定的性能成本,这也是我们可能选择不使用它的原因之一。当然,更复杂的系统并不一定总是更好的。有时你只是想要二元系统的简单性和可预测性,因为它可能更适合你的游戏。
虽然老话“简单就是最好”有一定的道理,但我们也应该考虑另一句话,“尽可能简单,但不能更简单”。虽然这句话通常归功于相对论的创始人阿尔伯特·爱因斯坦,但并不完全清楚是谁说的。重要的是要考虑这句话本身的意义。你应该让你的 AI 尽可能简单,但不能更简单。吃豆人的 AI 对游戏来说工作得很好——它足够简单。然而,规则说,简单在现代射击游戏或策略游戏中可能不合适。
将本书中的知识和例子拿去,找到最适合你的方法。
使用模糊逻辑
一旦你理解了模糊逻辑背后的简单概念,就很容易开始思考它可能有多少种有用的方式。实际上,它只是我们工具箱中的另一个工具,每个工作都需要不同的工具。
模糊逻辑擅长处理一些数据,并以类似于人类的方式(尽管方式更为简单)对其进行评估,然后将数据转换成系统可用的信息。
模糊逻辑控制器有几个实际应用案例。有些比其他的应用更明显,尽管这些案例与我们在游戏 AI 中的使用并不是一一对应的,但它们有助于说明一个观点:
-
供暖、通风和空调(HVAC)系统:在谈论模糊逻辑时提到的温度例子,不仅是一个很好的理论方法来解释模糊逻辑,而且也是一个模糊逻辑控制器在实际中非常常见的例子。
-
汽车:现代汽车配备了非常复杂的计算机化系统,从空调系统(再次提到),到燃油输送,再到自动制动系统。实际上,在汽车中安装计算机已经导致了比过去有时使用的旧二进制系统更加高效的系统。
-
你的智能手机:你是否注意过,根据周围光线的多少,你的屏幕会变暗或变亮?现代智能手机操作系统会考虑周围光线、正在显示的数据的颜色以及当前电池寿命,以优化屏幕亮度。
-
洗衣机:不一定是我家的洗衣机,因为它的年代相当久远,但大多数现代洗衣机(过去 20 年生产的)都使用了一些模糊逻辑。从一次循环到下一次循环,会考虑装载量、水的污浊程度、温度和其他因素,以优化用水、能耗和时间。
如果你环顾你的房子,你很可能会发现一些有趣的模糊逻辑应用,当然,除了你的电脑之外。虽然这些是概念上的巧妙应用,但它们并不特别令人兴奋或与游戏相关。我偏爱涉及巫师、魔法和怪物游戏,所以让我们看看一个更相关的例子。
实现简单的模糊逻辑系统
对于这个例子,我们将使用我的好朋友鲍勃,这位巫师。鲍勃生活在一个 RPG 世界中,他有一些非常强大的治疗魔法可以使用。鲍勃必须根据他剩余的生命值(HPs)来决定何时对自己使用这种魔法。
在二进制系统中,鲍勃的决策过程可能看起来像这样:
if(healthPoints <= 50)
{
CastHealingSpell(me);
}
我们可以看到,鲍勃的健康状态可以是两种状态之一——高于 50,或者不是。这并没有什么问题,但让我们看看这个场景的模糊版本可能是什么样子,从确定鲍勃的健康状态开始:

表示模糊值的典型函数
在看到图表和值时,在恐慌之前,让我们剖析我们所看到的内容。我们最初的冲动可能是尝试将鲍勃施展治疗咒语的概率映射到他缺失的健康程度。用简单的话说,这只是一个线性函数。这根本不是模糊的——它是一个线性关系,虽然在复杂性方面比二元决策高一个层次,但它仍然不是真正的模糊。
进入成员函数的概念。它是我们系统的关键,因为它允许我们确定一个陈述有多真实。在这个例子中,我们并不是简单地查看原始值来判断鲍勃是否应该施展咒语;相反,我们将它分解成逻辑信息块,供鲍勃使用,以确定他应该采取的行动。
在这个例子中,我们比较三个陈述,并评估每个陈述有多真实,以及哪个是最真实的:
-
鲍勃处于危急状态
-
鲍勃受伤
-
鲍勃是健康的
如果你喜欢官方术语,我们称之为确定集合的成员度。一旦我们有了这些信息,我们的代理就可以确定如何处理它。
乍一看,你会发现两个陈述同时为真是有可能的。鲍勃可以处于危急状态和受伤状态。他也可以有点受伤和有点健康。你可以自由选择每个的阈值,但在这个例子中,让我们根据前面的图表评估这些陈述。垂直值表示陈述的真实度,作为一个归一化的浮点数(0 到 1):
-
在 0%健康状态下,我们可以看到关键陈述评估为 1。当鲍勃的健康耗尽时,这是一个绝对的真实陈述。
-
在 40%健康状态下,鲍勃受伤,这是最真实的陈述。
-
在 100%健康状态下,最真实的陈述是鲍勃是健康的。
任何超出这些绝对真实陈述的东西都完全处于模糊区域。例如,假设鲍勃的健康状况为 65%。在同一张图表中,我们可以这样可视化:

鲍勃 65%的健康状况
在图表上画出的垂直线代表65表示鲍勃的健康状况。正如我们所见,它与两个集合相交,这意味着鲍勃有点受伤,但他也很健康。然而,一眼就能看出,垂直线在图表中截取受伤集合的点比健康集合的点更高。我们可以理解为鲍勃受伤的程度比健康程度更高。具体来说,鲍勃受伤 37.5%,健康 12.5%,危急状态 0%。让我们看看代码;在 Unity 中打开我们的FuzzySample场景。层次结构将如下所示:

我们样本场景中的层次结构设置
需要关注的重要游戏对象是模糊示例。它包含我们将要查看的逻辑。除此之外,我们还有包含所有标签和输入字段以及使此示例工作的按钮的Canvas。最后,还有 Unity 生成的EventSystem和Main Camera,我们可以忽略它们。场景的设置没有特别之处,但熟悉它是好主意,我们鼓励你在了解为什么一切都在那里以及它们各自的作用之后,尽情地探索和调整。
当选择模糊示例游戏对象时,检查器将类似于以下图片:

模糊示例游戏对象检查器
我们的示例实现不一定是你可以直接拿去在游戏中实现的东西,但它的目的是以清晰的方式说明前面的要点。我们为每个不同的集合使用 Unity 的AnimationCurve。这是一种快速且简单的方式来可视化我们早期图表中的相同线条。
不幸的是,没有简单的方法可以在同一个图表中绘制所有线条,所以我们为每个集合使用一个单独的AnimationCurve。在前面的截图中,它们被标记为“关键”、“受伤”和“健康”。这些曲线的巧妙之处在于它们自带一个内置方法来评估给定点的值(t)。对我们来说,t并不代表时间,而是鲍勃的健康量。
正如前面的图表所示,Unity 示例查看的是 0 到 100 的 HP 范围。这些曲线还提供了一个简单的用户界面来编辑这些值。你只需在检查器中点击曲线即可。这会打开曲线编辑窗口。你可以添加点、移动点、更改切线等,如以下截图所示:

Unity 的曲线编辑器窗口
我们的例子专注于三角形形状的集合。也就是说,每个集合的线性图。你绝对不必局限于这种形状,尽管它是最常见的。你可以使用钟形曲线或梯形,如果需要的话。为了保持简单,我们将坚持使用三角形。
你可以在docs.unity3d.com/ScriptReference/AnimationCurve.html了解更多关于 Unity 的AnimationCurve编辑器的信息。
其余的字段只是对我们在本章后面将要查看的代码中使用的不同 UI 元素的引用。然而,这些变量的名称相当直观,所以这里不需要太多的猜测。
接下来,我们可以看看场景是如何设置的。如果你播放场景,游戏视图将类似于以下截图:

一个简单的用户界面来演示模糊值
我们可以看到,我们有三个不同的组,代表“鲍勃,巫师”示例中的每个问题。鲍勃有多健康,鲍勃有多受伤,鲍勃有多危急?对于每个集合,在评估时,最初为0 真的值将动态调整以表示实际成员度。
在这里有一个输入框,你可以输入用于测试的健康百分比。这个输入框没有复杂的控制,所以请确保输入一个介于 0 到 100 之间的值。为了保持一致性,让我们在框中输入65,然后按下“评估!”按钮。
这将运行一些代码,查看曲线,并产生我们在之前的图表中看到的精确结果。虽然这不应该令人惊讶(毕竟数学就是这样),但在游戏编程中,没有比测试你的假设更重要的事情了,而且确实,我们已经测试并验证了之前的声明。
通过点击“评估!”按钮运行测试后,游戏场景将类似于以下截图:

这就是鲍勃在 65%健康时的状态
再次,值是 0.125(或 12.5%)的健康和 0.375(或 37.5%)的受伤。在这个时候,我们还没有对这个数据进行任何操作,但让我们看看处理这一切的代码:
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class FuzzySample1 : MonoBehaviour {
private const string labelText = "{0} true";
public AnimationCurve critical;
public AnimationCurve hurt;
public AnimationCurve healthy;
public InputField healthInput;
public Text healthyLabel;
public Text hurtLabel;
public Text criticalLabel;
private float criticalValue = 0f;
private float hurtValue = 0f;
private float healthyValue = 0f;
我们首先声明一些变量。labelText只是一个我们用来插入标签的常量。我们将{0}替换为实际值。
接下来,我们声明之前提到的三个AnimationCurve变量。将这些变量设置为公共或从检查器中可访问是能够通过视觉编辑它们的关键(尽管也可以通过代码构建曲线),这正是使用它们的目的。
以下四个变量只是对我们之前在检查器截图中所看到的 UI 元素的引用,而最后三个变量是我们曲线将评估到的实际浮点值:
private void Start () {
SetLabels();
}
/*
* Evaluates all the curves and returns float values
*/
public void EvaluateStatements() {
if (string.IsNullOrEmpty(healthInput.text)) {
return;
}
float inputValue = float.Parse(healthInput.text);
healthyValue = healthy.Evaluate(inputValue);
hurtValue = hurt.Evaluate(inputValue);
criticalValue = critical.Evaluate(inputValue);
SetLabels();
}
Start()方法不需要太多解释。我们在这里只是更新我们的标签,以便它们初始化为非默认文本。EvaluateStatements()方法则更有趣。我们首先对我们的输入字符串进行一些简单的空值检查。我们不希望尝试解析一个空字符串,所以如果它是空的,我们就从函数中返回。如前所述,没有检查来验证你是否输入了一个数值,所以请确保不要意外输入一个非数值,否则你会得到一个错误。
对于每个AnimationCurve变量,我们调用Evaluate(float t)方法,其中我们将t替换为从输入字段中获取的解析值。在我们运行的示例中,这个值将是65。然后,我们再次更新我们的标签以显示我们得到的价值。代码看起来像这样:
/*
* Updates the GUI with the evluated values based
* on the health percentage entered by the
* user.
*/
private void SetLabels() {
healthyLabel.text = string.Format(labelText, healthyValue);
hurtLabel.text = string.Format(labelText, hurtValue);
criticalLabel.text = string.Format(labelText, criticalValue);
}
}
我们只是取每个标签,并用格式化的labelText常量替换文本,将{0}替换为实际值。
扩展集合
我们之前详细讨论了这个问题,重要的是要理解,构成我们例子中集合的值是独一无二的,属于鲍勃和他的疼痛阈值。假设我们有一个第二位巫师吉姆,他有点鲁莽。对他来说,关键可能低于 20%,而不是鲍勃的 40%。这就是我喜欢称之为使用模糊逻辑的“快乐奖金”。游戏中的每个代理都可以有不同的规则来定义它们的集合,但系统并不关心。你可以预先定义这些规则,或者让某种程度的随机性决定极限,每个代理都会以独特的方式行为并做出反应。
此外,我们没有理由将我们的集合限制为只有三个。为什么不是四个或五个呢?对于模糊逻辑控制器来说,唯一重要的是你确定你试图达到什么真理,以及你是如何达到的;它不关心系统中存在多少不同的集合或可能性。
数据去模糊化
是的,这是一个真正的(或者说)词。我们开始于一些清晰规则,在模糊逻辑的上下文中,这意味着明确、明确的数据,然后我们通过为集合分配隶属函数来模糊化(再次,这是一个(或者说)真正的词)数据。过程的最后一步是去模糊化数据并做出决策。为此,我们使用简单的布尔运算,如下所示:
IF health IS critical THEN cast healing spell
现在,在这个时候,你可能想说,“等等,这看起来非常像二进制控制器,”你是对的。那么为什么要费这么大的劲呢?还记得我们之前说的关于模糊信息的话吗?没有模糊控制器,我们的代理如何理解什么是关键、受伤或健康呢?这些都是对计算机本身意义不大的抽象概念。
通过使用模糊逻辑,我们现在能够使用这些模糊的术语,从中推断出一些东西,并做具体的事情;在这种情况下,施展治愈咒语。此外,我们能够允许每个代理在个人层面上确定这些模糊术语对他们的意义,这不仅使我们能够在个人层面上实现不可预测性,甚至在几个类似代理之间也能实现。
以下图表最好地描述了这一过程:

模糊逻辑控制器流程
最后,它们仍然是计算机,所以我们受限于计算机最基本理解的东西,0 和 1:
-
我们从清晰的数据开始,即具体、明确的价值,它们告诉我们一些非常具体的事情。
-
模糊化步骤是我们决定我们的代理需要做出决策的抽象或模糊数据的地方。
-
在推理步骤中,我们的智能体需要决定这些数据意味着什么。智能体根据提供的一组旨在模仿人类决策细微差别的规则,来确定什么是“真实”的。
-
模糊化步骤将这种对人类友好的数据转换为简单、计算机友好的信息。
-
我们最终得到清晰的数据,准备供我们的巫师智能体使用。
使用得到的结果数据
模糊控制器的数据输出可以连接到行为树或有限状态机。当然,我们也可以组合多个控制器的输出来做决策。实际上,我们可以使用一大堆它们来达到最真实或最有趣的结果(无论如何,一个使用魔法的巫师可以有多真实)。以下图示展示了它可能用到的模糊逻辑控制器,以确定是否施放治疗术:

我们已经讨论了健康问题,但其他问题呢?我们还有另一组问题,单独来看对智能体来说并没有太多意义:
你有足够的法力吗?好吧,你可以有一点点法力,一些法力,或者很多法力。一个人类玩家在游戏中选择施放魔法或使用技能时询问这个问题并不罕见。“足够”可能是一个二进制数量,但更有可能的是,“足够施放治疗术,并且还有剩余的法力用于其他法术。”我们从一个简单清晰的价值开始——智能体可用的法力数量,然后将其连接到模糊逻辑控制器,并在另一端获得一些清晰的数据。
那么,敌人的力量如何?他可能是弱的、普通的、强大的或不可战胜的。你可以对你的模糊逻辑控制器输入进行创新。例如,你可以直接从敌人那里获取原始的“力量”值,但你也可以将你的“防御”属性与敌人的“攻击力”之间的差异放入模糊逻辑控制器。记住,在数据进入控制器之前,你处理数据的方式没有限制。
我的盟友离我近吗?正如我们在第二章“有限状态机与您”中看到的,简单的距离检查可以对简单的设计产生神奇的效果,但有时你可能需要更多。你可能需要考虑沿途的障碍——那个在锁着的门后面的盟友,使他无法到达智能体?这类问题甚至可能是一组需要评估的嵌套语句。
现在,如果我们用嵌套控制器来处理最后一个问题,它可能开始看起来有点熟悉:

前面的图示非常像树状结构,不是吗?当然,没有理由不能使用模糊逻辑来评估每个节点,构建一个行为树。通过结合这两个概念,我们最终得到一个非常灵活、强大且细腻的人工智能系统。
使用更简单的方法
如果你选择坚持使用简单的清晰输出评估,换句话说,不是特定的树或有限状态机,你可以使用更多的布尔运算符来决定你的代理将要做什么。伪代码将如下所示:
IF health IS critical AND mana IS plenty THEN cast heal
我们可以检查不成立的条件:
IF health IS critical AND allies ARE NOT close THEN cast heal
我们也可以将多个条件串联起来:
IF health IS critical AND mana IS NOT depleted AND enemy IS very strong THEN cast heal
通过查看这些简化的语句,你会注意到使用模糊逻辑的另一个“快乐奖励”——清晰的输出抽象了大部分决策条件,并将它们组合成简化的数据。
而不是需要在你的 if/else 语句中解析所有可能性,最终导致有成千上万条或者更多 switch 语句,你可以将逻辑块整洁地打包成更少、更有意义的几块数据。
换句话说,你不需要以程序化的方式嵌套所有语句,这样既难以阅读也难以重用。作为一个设计模式,通过模糊逻辑控制器抽象数据最终会变得更加面向对象和友好。
道德计示例
本章的派系/道德计示例通过 Unity 实现模糊逻辑的方法略有不同。我们基于我们在基本模糊逻辑示例中介绍的实施方法进行构建。
在这个例子中,我们创建了一个简单的对话序列,玩家将面对一系列场景或问题,然后根据他们的道德来回答。为了简化起见,我们为每个问题都包含了“好”、“中立”和“邪恶”的答案。让我们看看代码,以便更好地理解这一点。
问题和答案类
Question 和 Answer 类非常简单,用作数据容器。让我们首先看看 Question.cs 类:
[System.Serializable]
public class Question {
public string questionText;
public Answer[] answers;
}
你可能已经注意到 Question 类并没有从 MonoBehaviour 继承。它是一个普通的 C# 类。因此,Unity 默认不会序列化它,它也不会在检查器中显示。为了让 Unity 知道你想要这个类被序列化,请在类定义的顶部使用 System.Serializable 属性。
如你所见,这只有几行代码。第一个字段 questionText 将在后续步骤中通过检查器进行编辑。它是我们向用户展示的问题/场景的显示文本。answers 字段是一个 Answer 类型的数组。Answer.cs 代码如下:
[System.Serializable]
public class Answer {
public string answerText;
public float moralityValue;
}
再次,你会注意到这个类非常简单。answerText 是用于玩家响应按钮中显示的文本,而 moralityValue 字段是我们用来稍后计算玩家道德对齐的隐藏值。在这个例子中,我们假设每个问题有三个答案,每个答案的道德值分别是 0、50 和 100。
管理对话
我们的ConversationManager.cs类是本示例中所有重头戏发生的地方。它管理我们的对话 UI,处理事件,并为我们计算结果。对于第一部分,我们初始化问题数组,然后处理 UI。我们在类的顶部设置了一些变量,如下所示:
[Header("UI")]
[SerializeField]
private GameObject questionPanel;
[SerializeField]
private GameObject resultPanel;
[SerializeField]
private Text resultText;
[SerializeField]
private Text questionText;
[SerializeField]
private Button firstAnswerButton;
[SerializeField]
private Button secondAnswerButton;
[SerializeField]
private Button thirdAnswerButton;
我们将能够看到这些变量对应的 UI 元素,但请注意,我们明确期望一个固定的答案数量,因为我们只为 UI 提供了三个答案按钮。当然,你可以修改它以使其更灵活或满足你的需求,但请记住,如果你想使用更多或更少的答案,你也需要在这里做出相应的更改:
[Header("Morality Gradient")]
[SerializeField]
private AnimationCurve good;
[SerializeField]
private AnimationCurve neutral;
[SerializeField]
private AnimationCurve evil;
与我们之前的例子类似,我们使用 Unity 的AnimationCurve来指定我们的模糊值。在这个设置中,我们假设了几件事情:
-
在t=0时,我们的“好”值是 1,然后从那里下降到 0
-
在t=50时,我们的“中立”值是 1
-
在t=100时,我们的“邪恶”评分是 1
这些值可以根据你的喜好进行调整,但当前的设置对于示例来说效果很好。以下截图显示了在检查器中设置的曲线:

我们道德梯度的模糊曲线
注意这里显示的值对应于我们之前的假设,即我们的“好”答案给出 0 的值,我们的“中立”答案有 50 的值,我们的“邪恶”答案有 100 的值。
加载问题
我们提供了一个名为LoadQuestion的简单方法,用于从我们的数据类中提取值到 UI,并显示给玩家。代码如下:
private void LoadQuestion(int index)
{
if (index < questions.Length)
{
questionText.text = questions[index].questionText;
firstAnswerButton.GetComponentInChildren<Text>().text = questions[index].answers[0].answerText;
secondAnswerButton.GetComponentInChildren<Text>().text = questions[index].answers[1].answerText;
thirdAnswerButton.GetComponentInChildren<Text>().text = questions[index].answers[2].answerText;
}
else
{
EndConversation();
}
}
LoadQuestion方法接受一个问题索引,该索引对应于数组questions[]中问题的索引。我们首先检查我们的索引是否在范围内,如果不在范围内,就通过调用EndConversation()结束对话。如果我们处于范围内,我们只需为每个答案按钮填充问题文本和答案文本。
处理用户输入
当用户在 UI 上按下答案按钮时调用的事件是OnAnswerSubmitted。该方法相当简单,只有几行代码:
public void OnAnswerSubmitted(int answerIndex)
{
answerTotal += questions[questionIndex].answers[answerIndex].moralityValue;
questionIndex++;
LoadQuestion(questionIndex);
}
该方法做了几件事情:
-
它将答案值聚合到答案总数中。我们将在下面查看这些值是如何分配的。
-
它增加问题索引值。
-
最后,它使用前一个要点中增加的索引值调用
LoadQuestion。
计算结果
最后,我们有EndConversation方法,正如我们所看到的,当回答了所有问题(并且基于我们的questions[]数组长度,问题索引超出范围)时会被调用。
第一行简单地禁用了包含问题 UI 的面板游戏对象:
questionPanel.SetActive(false);
计算在下一块代码中:
float average = answerTotal / questions.Length;
float goodRating = good.Evaluate(average);
float neutralRating = neutral.Evaluate(average);
float evilRating = evil.Evaluate(average);
我们通过将 answerTotal 值(所有答案的总和)除以问题的数量来计算所有答案的平均值。然后我们使用刚刚计算出的平均值分别评估每个曲线的好、中、恶评价。我们在评估方法中使用平均值为我们的 t 值。
接下来,我们使用一些简单的 if 逻辑来确定哪个评价更高,如下面的代码片段所示:
if(goodRating > neutralRating)
{
if(goodRating > evilRating)
{
//good wins
alignmentText = "GOOD";
}
else
{
//evil wins
alignmentText = "EVIL";
}
}
else
{
if(neutralRating > evilRating)
{
//neutral wins
alignmentText = "NEUTRAL";
}
else
{
//evil win
alignmentText = "EVIL";
}
}
如前述代码所示,我们有一个分支条件结构来确定最高值,然后根据这个值设置 alignmentText 的值。
如果开始添加太多条件,if 块可能会变得有些复杂。在这种情况下,您可能想要考虑将评价放入一个数组或字典中,然后对它们进行排序,并/或使用 LINQ 从中获取最高值。有关排序字典的更多信息,请参阅 Dot Net Perls: https://www.dotnetperls.com/sort-dictionary
最后,我们向用户展示结果:
resultPanel.SetActive(true);
resultText.text = "Your morality alignment is: " + alignmentText;
我们只需简单地启用结果面板,然后将 alignmentText 添加到 "您的道德对齐是:" 消息中,在游戏模式中(如果您有“良好”评价)将看起来像这样:

当你获得“良好”评价时的游戏屏幕
接下来,我们可以看看我们的场景设置,以及所有值是如何在示例项目中初始化的。
场景设置
当您首次打开 FactionScene 示例场景时,您会注意到一个看起来像这张截图的用户界面:

示例场景用户界面设置
如前一张截图所示,用户界面由几个不同的面板组成,文本组件已经用一些示例文本初始化,以帮助组织一切。场景的层次结构如下所示:

FactionScene 层次结构
如您所见,我们的画布在根级别有两个主要面板——QuestionPanel 和默认禁用的 ResultPanel。这是因为,如您可能记得的,我们在 EndConversation 方法中通过代码将此面板设置为 enabled。列表底部是我们的 ConversationManager 游戏对象,其中包含我们的 ConversationManager 脚本。
如果您选择它,您会看到检查器看起来像这样:

我们 Conversation Manager 的检查器,所有值都已分配
初看,这里的信息量可能看起来令人畏惧,但让我们看看每一步,您会发现我们已经涵盖了所有这些内容。
我们首先有一个序列化的问题数组。在这种情况下,我们有三个问题(请随意添加更多!)。每个问题包含一个(正好)三个答案的数组,以及我们之前看到的提问文本。对于每个答案,我们都有之前看到的答案文本和道德价值。请注意,问题或答案的顺序并不一定重要,只要你的道德价值对应于善良、中立或邪恶。
然后是 UI 部分,其中我们分配所有必要的元素。层次结构中的每个元素都适当地命名,以便确保每个字段都填充了正确的游戏对象。
最后,我们有之前看到的道德曲线。再次提醒,请随意调整到您满意的程度!
测试示例
剩下的就是测试示例了!点击播放,并选择一些答案。提供的场景让你扮演一个进入城镇的冒险者。在他的路上,他遇到了一个哥布林、一个银行家和一位骑士。你会在每种情况下做什么?请随意玩弄措辞,并添加你自己的道德困境!
寻找模糊逻辑的其他用途
模糊数据非常奇特且有趣,因为它可以与我们在本书中介绍的所有主要概念一起使用。我们看到了一系列模糊逻辑控制器如何轻松地适应行为树结构,并且不难想象它如何与 FSM 一起使用。
与其他概念合并
感官系统也倾向于使用模糊逻辑。虽然看到某物可能是一个二元条件,但在低光或低对比度环境中,我们可能会突然看到条件可以变得多么模糊。你可能晚上有过这样的经历:看到远处的一个奇怪的形状,在阴影中,你可能会想“那是一只猫吗?”结果却是一只垃圾袋,或其他动物,甚至可能是你的想象。同样的情况也适用于声音和气味。
当涉及到路径查找时,我们会遇到穿越网格某些区域的成本,模糊逻辑控制器可以轻松地帮助模糊化并使其更有趣。
鲍勃应该过桥与守卫战斗,还是冒险过河与水流战斗?嗯,如果他是一个好的游泳者但不是一个好的战士,选择是明显的,对吧?
创造真正独特的体验
我们的人工智能代理可以使用模糊逻辑来模仿性格。一些代理可能比其他代理更“勇敢”。突然,他们的个人特征——他们的速度、他们能跑多远、他们的体型等——可以被用来做出独特的决策。
性格可以应用于敌人、盟友和好友、NPC,甚至可以应用于游戏规则。游戏可以从玩家的进度、游戏风格或进度水平中获取清晰的数据,并动态调整难度,以提供更独特和个性化的挑战。
模糊逻辑甚至可以用来分配技术游戏规则,例如给定多人游戏大厅中的玩家数量、向玩家显示的数据类型,甚至如何将玩家与其他玩家匹配。将玩家的统计数据输入到匹配系统中,可以通过让玩家在与风格相似的合作环境中对抗匹配他们的游戏风格,或在竞争环境中对抗技能水平相似的玩家来保持玩家的参与度。
摘要
我很高兴看到你已经到达了这一章的结尾。一旦你理解了基本概念,模糊逻辑就会变得不再那么模糊。作为书中较为纯粹数学概念之一,如果你不熟悉术语,可能会觉得有点令人畏惧,但一旦在熟悉的环境中呈现,神秘感就会消失,你将拥有一个在游戏中使用的非常强大的工具。
我们学习了模糊逻辑在现实世界中的应用,以及它如何以二进制系统无法实现的方式帮助说明模糊概念。我们还学习了如何使用成员函数、隶属度和模糊集的概念来实现我们自己的模糊逻辑控制器。除此之外,我们还尝试了一个派系/道德系统,以进一步说明在自选冒险风格交互中的模糊逻辑概念。最后,我们探讨了我们可以使用结果的多种方式,以及它如何帮助使我们的智能体更加独特。
在最后一章,我们将探讨这本书中介绍的一些概念是如何共同工作的。
第八章:一切如何汇聚在一起
我们几乎到达了旅程的终点。我们已经学会了在 Unity 游戏中实现有趣 AI 的所有基本工具。我们在整本书的过程中都强调了这一点,但重要的是要强调:我们在整本书中学到的概念和模式是单独的概念,但它们可以,并且通常应该和谐地使用,以实现我们 AI 期望的行为。在我们告别之前,我们将查看一个简单的坦克防御游戏,该游戏实现了一些我们学到的概念,以实现一个统一的“游戏”,我之所以说“游戏”,是因为这更像是一个供你扩展和玩耍的蓝图。在本章中,我们将:
-
将我们学过的某些系统整合到一个单一的项目中
-
创建一个 AI 塔楼代理
-
创建我们的
NavMeshAgent坦克 -
设置环境
-
测试我们的示例场景
设置规则
我们的“游戏”相当简单。虽然实际的游戏逻辑,如健康、伤害和胜利条件,完全由你决定,但我们的示例专注于为你搭建一个平台,以便你实现自己的坦克防御游戏。
当决定你的代理需要什么样的逻辑和行为时,重要的是游戏规则要具体化,而不仅仅是简单的一个想法。当然,随着你实现不同的功能,这些规则可能会改变,但尽早确定一套概念将有助于你选择最适合的工具。
这是对传统塔防游戏类型的一种小小颠覆。你不是建造塔来阻止即将到来的敌人;相反,你使用你的能力来帮助你的坦克穿过塔楼的迷宫。随着你的坦克穿越迷宫,路径上的塔将试图通过向它射击爆炸性投射物来摧毁你的坦克。为了帮助你的坦克到达另一边,你可以使用两种能力:
-
加速:这个能力会在短时间内加倍你的坦克移动速度。这对于摆脱困境中的投射物非常有用。
-
盾牌:这会在你的坦克周围创建一个短暂的护盾,以阻挡即将到来的投射物。
对于我们的示例,我们将使用有限状态机来实现塔楼,因为它们有有限的状态,不需要行为树额外的复杂性。塔楼还需要能够感知其周围环境,或者更具体地说,坦克是否在附近,以便它们可以射击它,因此我们将使用球体触发器来模拟塔楼的视野和感知。坦克需要能够独立于环境导航,因此我们使用 NavMesh 和NavMeshAgent来实现这一点。
创建塔楼
在本章的示例项目中,你会在Prefabs文件夹中找到一个Tower预制体。这个塔本身相当简单;它只是一组排列成看起来像大炮的原型,正如你在下面的截图中所见:

我们美丽的原始形状塔
枪管固定在塔的球形部分上。当跟踪玩家时,枪可以在其轴上自由旋转,以便向目标方向射击,但在其他方面它是不可移动的。一旦坦克足够远,塔就无法追逐它或重新定位自己。
在示例场景中,整个关卡中放置了几个塔。由于它们是预制件,因此复制塔、移动它们并在关卡之间重复使用它们非常容易。它们的设置也不太复杂。它们的层次结构看起来类似于以下截图:

检查器中的塔层次结构
层次结构的分解如下:
-
Tower:从技术上讲,这是塔的基础:支撑其余部分的圆柱体。它没有任何功能,只是用来支撑其余部分。 -
Gun:枪是大多数魔法发生的地方。它是塔上安装有枪管的球形部件。这是塔中移动并跟踪玩家的部分。 -
Barrel和Muzzle:枪口位于枪管的尖端。这是枪中子弹的发射点。
我们提到枪是塔的业务发生地,所以让我们深入了解一下。选择枪的检查器看起来类似于以下截图:

枪的检查器
在这里,检查器中有很多内容。让我们看看影响逻辑的每个组件:
-
球形碰撞体:这实际上是塔的范围。当坦克进入这个球形区域时,塔可以检测到它,并将锁定目标开始射击。这是我们为塔实现的感知功能。请注意,半径被设置为
7。这个值可以根据你的喜好进行更改,但7似乎是一个合理的值。此外,请注意,我们将“是否触发”复选框设置为“是”。我们不希望这个球形实际上引起碰撞,只是触发事件。 -
Rigidbody:这个组件对于碰撞体正常工作是必需的,无论对象是否在移动。这是因为 Unity 不会向没有
Rigidbody组件的游戏对象发送碰撞或触发事件,除非它们在移动。 -
Tower:这是塔的逻辑脚本。它与状态机和行为协同工作,但我们将稍后深入了解这些组件。
-
Animator:这是我们的塔状态机。它实际上并不处理动画。
在我们查看驱动塔的代码之前,让我们简要地看看状态机。正如你在以下截图中所见,它并不复杂:

塔的状态机
我们关注两种状态:Idle(默认状态)和 LockedOn。当 TankInRange 布尔值设置为 true 时,从 Idle 到 LockedOn 的转换发生,而当布尔值设置为 false 时,反向转换发生。
LockedOn 状态附有一个 StateMachineBehaviour 类,我们将在下一部分进行探讨:
using UnityEngine;
using System.Collections;
public class LockedOnState : StateMachineBehaviour {
GameObject player;
Tower tower;
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
player = GameObject.FindWithTag("Player");
tower = animator.gameObject.GetComponent<Tower>();
tower.LockedOn = true;
}
//OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
animator.gameObject.transform.LookAt(player.transform);
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
animator.gameObject.transform.rotation = Quaternion.identity;
tower.LockedOn = false;
}
}
当我们进入状态并调用 OnStateEnter 时,我们找到对玩家的引用。在提供的示例中,玩家被标记为 "Player",这样我们就能使用 GameObject.FindWithTag 获取对其的引用。接下来,我们获取附加到我们的塔预制件上的 Tower 组件的引用,并将其 LockedOn 布尔值设置为 true。
只要我们处于该状态,OnStateUpdate 就会在每一帧被调用。在这个方法内部,我们通过提供的 Animator 引用获取对 Gun GameObject(Tower 组件附加到的对象)的引用。我们使用这个枪的引用,通过 Transform.LookAt 来跟踪坦克。
或者,由于 Tower 的 LockedOn 布尔值被设置为 true,这个逻辑可以在 Tower.cs 脚本中处理。
最后,当我们退出状态时,会调用 OnStateExit。我们使用此方法进行一些清理工作。我们将枪的旋转重置,以表明它不再跟踪玩家,并将塔的 LockedOn 布尔值重新设置为 false。
如我们所见,这个 StateMachineBehaviour 与 Tower.cs 脚本交互,所以让我们接下来看看 Tower.cs,以获得更多关于正在发生什么的上下文:
using UnityEngine;
using System.Collections;
public class Tower : MonoBehaviour {
[SerializeField]
private Animator animator;
[SerializeField]
private float fireSpeed = 3f;
private float fireCounter = 0f;
private bool canFire = true;
[SerializeField]
private Transform muzzle;
[SerializeField]
private GameObject projectile;
private bool isLockedOn = false;
public bool LockedOn {
get { return isLockedOn; }
set { isLockedOn = value; }
}
首先,我们声明我们的变量和属性。
我们需要一个对状态机的引用;这就是 Animator 变量的作用。接下来的三个变量 fireSpeed、fireCounter 和 canFire 都与我们的塔的射击逻辑相关。我们稍后会看到它是如何工作的。
如我们之前提到的,枪口是射击时子弹将从中发射的位置。投射物是我们将要实例化的预制件。
最后,isLockedOn 通过 LockedOn 进行获取和设置。虽然这本书总体上避免强制执行任何特定的编码约定,但通常一个好的做法是除非明确需要公开,否则保持值私有,因此,我们不是将 isLockedOn 设置为公开,而是提供一个属性来远程访问它(在这种情况下,从 LockedOnState 行为访问):
private void Update() {
if (LockedOn && canFire) {
StartCoroutine(Fire());
}
}
private void OnTriggerEnter(Collider other) {
if (other.tag == "Player") {
animator.SetBool("TankInRange", true);
}
}
private void OnTriggerExit(Collider other) {
if (other.tag == "Player") {
animator.SetBool("TankInRange", false);
}
}
private void FireProjectile() {
GameObject bullet = Instantiate(projectile, muzzle.position, muzzle.rotation) as GameObject;
bullet.GetComponent<Rigidbody>().AddForce(muzzle.forward * 300);
}
private IEnumerator Fire() {
canFire = false;
FireProjectile();
while (fireCounter < fireSpeed) {
fireCounter += Time.deltaTime;
yield return null;
}
canFire = true;
fireCounter = 0f;
}
}
接下来,我们有所有我们的方法,以及塔逻辑的核心。在 Update 循环内部,我们检查两件事——我们是否锁定,以及我们是否可以开火。如果两者都为真,我们就调用 Fire() 协程。在我们回到 OnTrigger 消息之前,我们将看看为什么 Fire() 是一个协程。
如果你之前不熟悉协程,协程可能是一个难以理解的概念。有关如何使用协程的更多信息,请查看 Unity 的文档:docs.unity3d.com/Manual/Coroutines.html。
由于我们不希望我们的塔楼像疯狂发射弹丸的死亡机器一样不断向坦克射击,我们使用我们之前定义的变量来在每次射击之间创建一个缓冲区。在调用FireProjectile()并将canFire设置为false之后,我们从一个计数器从 0 开始到fireSpeed,然后再将canFire再次设置为true。FireProjectile()方法处理弹丸的实例化和向枪口指向的方向射击,使用Rigidbody.AddForce。实际的子弹逻辑在其他地方处理,但稍后我们会看看。
最后,我们有两个OnTrigger事件——一个是在有东西进入此组件附加的触发器时,另一个是在对象离开该触发器时。还记得驱动我们状态机转换的TankInRange布尔变量吗?当我们进入触发器时,这个变量被设置为true,当我们退出时,它回到false。本质上,当坦克进入枪的“视野”球体时,它会立即锁定坦克,当坦克离开球体时,锁定会释放。
让塔楼射击
如果我们回顾一下检查器中的Tower组件,你会注意到一个名为bullet的预制件被分配给projectile变量。这个预制件可以在示例项目的Prefabs文件夹中找到。预制件看起来类似于以下截图:

子弹预制件
bullet游戏对象没有什么特别的地方;它只是一个明亮的黄色球体。它附有一个球体碰撞器,并且,同样,我们必须确保IsTrigger设置为true,并且它附有一个Rigidbody(gravity被关闭)附上。我们还有一个附加到bullet预制件上的Projectile组件。这处理碰撞逻辑。让我们看看代码:
using UnityEngine;
using System.Collections;
public class Projectile : MonoBehaviour {
[SerializeField]
private GameObject explosionPrefab;
void Start () { }
private void OnTriggerEnter(Collider other) {
if (other.tag == "Player" || other.tag == "Environment") {
if (explosionPrefab == null) {
return;
}
GameObject explosion = Instantiate(explosionPrefab, transform.position, Quaternion.identity) as GameObject;
Destroy(this.gameObject);
}
}
}
我们在这里有一个相当直接的脚本。在我们的关卡中,我们将所有地板和墙壁标记为"Environment",所以在我们的OnTriggerEnter方法中,我们检查这个弹丸正在与之碰撞的触发器是玩家或环境。如果是,我们实例化一个explosion预制件并销毁弹丸。让我们看看explosion预制件,它看起来类似于以下截图:

选择带有爆炸预制件的检查员
如我们所见,这里有一个非常相似的游戏对象;我们有一个设置为true的IsTrigger的球体碰撞器。主要区别是有一个animator组件。当这个explosion被实例化时,它会像爆炸一样膨胀,然后我们使用状态机在它从爆炸状态过渡出来时销毁实例。animation控制器看起来与以下截图类似:

驱动爆炸预制件的动画控制器
你会注意到explode状态附加了一个行为。这个行为内的代码相当简单:
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
Destroy(animator.gameObject, 0.1f);
}
我们在这里所做的只是在我们退出状态时销毁对象的实例,这发生在动画结束时。
如果你想要用你自己的游戏逻辑来丰富游戏,这可能是一个触发任何次要效果的好地方,比如伤害、环境粒子,或者你能想到的任何东西!
设置坦克
示例项目还包括一个坦克预制件,它简单地称为(你猜对了)Tank,位于Prefabs文件夹中。
坦克本身是一个简单的代理,它的目标只有一个:到达迷宫的尽头。如前所述,玩家必须通过激活坦克的能力来帮助坦克,使其免受来自塔楼的来犯之火的伤害。
到现在为止,你应该已经相当熟悉你将遇到的所有组件,除了附加到预制件的Tank.cs组件。让我们看看代码,找出幕后发生了什么:
using UnityEngine;
using System.Collections;
public class Tank : MonoBehaviour {
[SerializeField]
private Transform goal;
private NavMeshAgent agent;
[SerializeField]
private float speedBoostDuration = 3;
[SerializeField]
private ParticleSystem boostParticleSystem;
[SerializeField]
private float shieldDuration = 3f;
[SerializeField]
private GameObject shield;
private float regularSpeed = 3.5f;
private float boostedSpeed = 7.0f;
private bool canBoost = true;
private bool canShield = true;
我们希望能够轻松调整一些值,因此我们首先声明相应的变量。从我们技能的持续时间到与之相关的效果,所有这些都在这里首先设置:
private bool hasShield = false;
private void Start() {
agent = GetComponent<NavMeshAgent>();
agent.SetDestination(goal.position);
}
private void Update() {
if (Input.GetKeyDown(KeyCode.B)) {
if (canBoost) {
StartCoroutine(Boost());
}
}
if (Input.GetKeyDown(KeyCode.S)) {
if (canShield) {
StartCoroutine(Shield());
}
}
}
我们的Start方法只是为我们坦克做一些设置;它获取NavMeshAgent组件并将其目的地设置为等于我们的目标变量。我们很快会详细讨论这一点。
我们使用Update方法来捕捉我们技能的输入。我们将B映射到加速,将S映射到盾牌。由于这些是计时技能,就像塔楼的射击能力一样,我们通过协程来实现这些:
private IEnumerator Shield() {
canShield = false;
shield.SetActive(true);
float shieldCounter = 0f;
while (shieldCounter < shieldDuration) {
shieldCounter += Time.deltaTime;
yield return null;
}
canShield = true;
shield.SetActive(false);
}
private IEnumerator Boost() {
canBoost = false;
agent.speed = boostedSpeed;
boostParticleSystem.Play();
float boostCounter = 0f;
while (boostCounter < speedBoostDuration) {
boostCounter += Time.deltaTime;
yield return null;
}
canBoost = true;
boostParticleSystem.Pause();
agent.speed = regularSpeed;
}
两个技能的逻辑非常相似。盾牌通过在检查器中定义的变量来启用和禁用盾牌游戏对象,当经过等于盾牌持续时间的时间后,我们将其关闭,并允许玩家再次使用盾牌。
加速代码中的主要区别在于,它不是通过启用和禁用游戏对象,而是通过检查器分配的粒子系统上的Play调用,并将我们的NavMeshAgent的速度设置为原始值的两倍,然后在技能持续时间的末尾将其重置。
你能想到给坦克赋予的其他能力吗?这是一个非常直接的模式,你可以用它来实现你自己在项目中的新能力。你还可以在这里添加额外的逻辑来自定义盾牌和加速能力。
样本场景中已经有一个坦克实例,所有变量都已正确设置。样本场景中坦克的检查器看起来类似于以下截图:

选择坦克实例的检查器
正如你在前面的屏幕截图中所见,我们将Goal变量分配给了具有相同名称的变换,它位于我们设置的迷宫末尾的场景中。我们还可以调整我们能力的时间长度,默认设置为 3。你也可以更换能力的艺术效果,无论是用于加速的粒子系统还是用于护盾的游戏对象。
最后要查看的代码是驱动摄像机的代码。我们希望摄像机跟随玩家,但只沿着其z值,水平沿着轨道。实现这一点的代码看起来类似于这样:
using UnityEngine;
using System.Collections;
public class HorizontalCam : MonoBehaviour {
[SerializeField]
private Transform target;
private Vector3 targetPositon;
private void Update() {
targetPositon = transform.position;
targetPositon.z = target.transform.position.z;
transform.position = Vector3.Lerp(transform.position, targetPositon, Time.deltaTime);
}
}
正如你所见,我们只是将摄像机的目标位置设置为它在所有轴上的当前位置,但我们然后将目标位置的z轴设置为与我们的目标相同,如果你查看检查器,会发现它已经被设置为坦克的变换。然后我们使用线性插值(Vector3.Lerp)在每一帧将摄像机从当前位置平滑地移动到目标位置。
奖励坦克能力
样本项目还包括三个额外的坦克能力供你玩耍。当然,我们非常鼓励你修改这些能力或实现你自己的自定义规则,但为了使示例更加丰富,你只需要为想要添加到坦克预制件中的每个能力添加组件。
奖励能力包括:
-
浩克模式:你的坦克在设定的时间内变大。想要挑战?实现一个类似于我们第六章,Behavior Trees中的HomeRock示例的健康和护甲系统,并且让这个增益效果通过这个能力在视觉上表示出来!
-
缩小模式:这是与浩克模式相反的,对吧!你的坦克在设定的时间内缩小。如果你觉得这个任务很有挑战性,尝试实现一个潜行系统,其中炮塔在缩小模式下无法检测到你的坦克。
-
时间扭曲,或者像我喜欢叫的,DMV 模式:这种能力将时间缓慢到几乎停止。如果你想挑战自己,尝试实现一个选择性的武器系统,其中炮塔可以使用更快的弹丸来对抗你的时间扭曲模式!
你可以选择如何使用能力系统。看到读者如何以不同的方向使用这些样本的版本总是很有趣。如果你对这或之前的任何样本有独特的想法,请通过 Twitter(@ray_barrera)与作者分享。
设置环境
由于我们的坦克使用NavMeshAgent组件来穿越环境,我们需要使用静态游戏对象设置我们的场景,以便烘焙过程能够正常工作,正如我们在第四章,Finding Your Way中所学。迷宫被设置为塔楼分布得相当合理,坦克有足够的空间轻松地移动。以下屏幕截图显示了迷宫的一般布局:

坦克必须通过的障碍
如您所见,迷宫中分布着七个塔,坦克需要绕过一些弯道才能打破视线。为了避免我们的坦克擦到墙上,我们调整了导航窗口中的设置以符合我们的喜好。默认情况下,示例场景将代理半径设置为 1.46,步高设置为 1.6。我们到达这些数字没有硬性规则;这只是试错的结果。
在烘焙 NavMesh 之后,我们将得到以下截图所示的内容:

烘焙我们的 NavMesh 之后的场景
随意调整墙壁和塔的位置以符合您的喜好。只需记住,您添加到场景中的任何阻挡物体都必须标记为静态,并且您必须设置好一切后重新烘焙场景的导航。
测试示例
示例场景可以直接播放,所以如果您没有修改默认设置的冲动,只需按播放按钮,就可以观看您的坦克移动。您会注意到我们为玩家添加了一个带有标签的画布,解释了控制方法。这里没有复杂的功能;它只是简单的“按这个按钮做那个”类型的说明:

简单的说明来引导玩家
示例项目是一个很好的例子,可以在此基础上扩展并享受乐趣。通过本书学到的概念,您可以扩展塔的类型、坦克的能力、规则,甚至给坦克更复杂、细腻的行为。目前,我们可以看到状态机、导航、感知和感知以及转向的概念在一个简单而有趣示例中结合在一起。以下截图显示了游戏的实际运行情况:

坦克防御游戏的实际运行情况
摘要
所以,我们已经到达了终点。在本章中,我们选取了书中的一些概念,并应用它们来创建一个小型坦克防御游戏。我们基于有限状态机的概念,这个概念我们在第二章“有限状态机与您”中最初介绍过,创建了一个人工智能来驱动我们的敌人塔的行为。然后我们通过结合感知和感知来增强行为,最后通过 Unity 的 NavMesh 功能实现了导航,帮助我们的坦克 AI 在我们的迷宫式关卡中导航,穿过一排自主 AI 塔,这些塔的简单 AI 思维只有一个目标:摧毁!
当我们结束这本书的时候,花点时间给自己鼓掌吧!我们已经覆盖了大量的内容,讨论了许多主题。你现在已经了解了状态机、行为树、A*算法、模糊逻辑等等。最令人兴奋的是思考所有你可以混合搭配并应用这些概念的方式。希望在这本书的阅读过程中,你已经想到了如何利用这些概念来增强你现有的或即将到来的游戏。你现在拥有了为你的数字世界创造更智能居民的工具。祝你好运!


浙公网安备 33010602011771号