游戏人工智能编程实践指南-全-
游戏人工智能编程实践指南(全)
原文:
zh.annas-archive.org/md5/bc8cfbf113a524e20811246ab8cf847b译者:飞龙
前言
对于一些人来说,开发游戏是一种激情,我相信这是因为我们能够创造一个完全由我们自己想象的世界;这就像成为上帝一样,我们放置在那里的人工智能角色是我们刚刚创造的这个世界的居民。我们可以自由地想象他们的行为,我们可以根据我们的想象创造一个社会,我们可以创造一个温柔善良的角色,但也可以创造最邪恶的角色——可能性是无限的,这就是为什么我们总会涌现出新的游戏想法。无论我们决定开发什么类型的游戏,世界和他们的角色将是我们的愿景的核心;这就是使我们的游戏独特的地方;理想情况下,我们应该能够创造出我们心中所想的一切,就像我们想象的那样。这本书正是基于这个想法而构思的,即我们都应该能够创造出我们的想法,我们不应该限制我们的想象力,因此这本书将涵盖创建人工智能角色的基础,阅读之后,我们应该能够探索你所学习的所有主题,创造出与我们想象完美匹配的人工智能角色。
本书涵盖的内容
第一章,不同问题需要不同解决方案,是对视频游戏行业和游戏人工智能的简要介绍。
第二章,可能性和概率图,主要关注如何为人工智能角色创建和使用可能性和概率图。
第三章,生产系统,描述了创建一组规则,这些规则对于角色人工智能实现其目标来说是必要的。
第四章,环境和 AI,主要关注游戏中的角色与其环境之间的交互。
第五章,动画行为,展示了在游戏中实现动画的最佳实践。
第六章,导航行为和寻路,主要关注如何计算 AI 实时移动的最佳选项。
第七章,高级寻路,主要关注使用 theta 算法寻找短且看起来真实的路径。
第八章,群体交互,主要关注当场景中有许多角色时,AI 应该如何表现。
第九章,AI 规划和避障,讨论了 AI 的预见性,即在到达某个位置或面对问题时预先知道他们将做什么。
第十章,意识,主要关注与意识系统合作以创建潜行游戏机制。
您需要这本书的什么内容
建议您安装一个使用 C#的游戏引擎(Unity3D 有一个免费版本,本书中的示例就是使用的这个版本)。
这本书面向的对象
这本书是为已经用 C#创建了他们的第一个游戏并希望利用 AI 扩展其能力的开发者编写的,可以创建能够自主行动的群众、敌人或盟友。
术语约定
在这本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们现在将使用的变量是 Health、statePassive、stateAggressive 和 stateDefensive。”
代码块设置如下:
if (playerPosition == "triggerM")
{
transform.LookAt(playerSoldier); // Face the direction of the player
transform.position = Vector3.MoveTowards(transform.position,
buildingPosition.position, walkBack);
backwardsFire();
}
新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“在 Unity 中,我们在“层”按钮下点击以展开更多选项,然后点击显示“编辑层...”的地方。”
警告或重要注意事项以如下所示的框显示。
技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户中下载这本书的示例代码文件,网址为 www.packtpub.com。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载与勘误”。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买这本书的地方。
-
点击“代码下载”。
下载文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Practical-Game-AI-Programming。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/PracticalGameAIProgramming_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。请通过发送链接到疑似盗版材料至copyright@packtpub.com与我们联系。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
咨询
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。
第一章:不同的问题需要不同的解决方案
游戏人工智能的简要历史及其解决方案
为了更好地理解如何克服游戏开发者目前面临的问题,我们需要稍微深入了解一下电子游戏开发的历史,并看看当时那些重要的问题及其解决方案。其中一些解决方案如此前卫,以至于它们实际上改变了电子游戏设计本身的历史,而今天我们仍然使用相同的方法来创造独特且令人愉悦的游戏。
在谈论游戏人工智能时,首先值得提到的相关标志之一是编程用于与人类竞争的计算机象棋。这是一款非常适合开始尝试人工智能实验的游戏,因为象棋通常需要大量的思考和提前规划,这在当时是计算机无法做到的,因为为了成功地玩游戏并赢得比赛,计算机必须具备人类特征。因此,第一步是让计算机能够处理游戏规则并自行思考,以便对计算机应该采取的下一步做出良好的判断,以达到最终目标,即通过将军获胜。问题是象棋有无数种可能性;所以,即使计算机有一个完美的策略来击败游戏,每次第一个策略出现问题时,都必须重新计算那个策略,适应它,改变它,甚至创造一个新的策略。
人类可以每次都玩得不同;这使得程序员将所有可能的数据输入计算机以赢得游戏成为一项巨大的任务。因此,写下所有可能存在的情况并不是一个可行的解决方案,正因为如此,程序员需要再次思考这个问题。然后,有一天,他们终于提出了一个更好的解决方案,那就是让计算机在每个回合自行决定,选择每个回合最可能的选项;这样,计算机就能适应游戏中的任何可能性。然而,这又带来了另一个问题——计算机只会考虑短期移动,而不会为未来移动制定击败人类的计划;因此,对抗它很容易,但至少我们开始有所进展。几十年后,有人通过解决第一个问题来定义了“人工智能”(AI)这个词,即通过尝试创造一个能够击败人类玩家的计算机来解决许多研究人员的问题。亚瑟·塞缪尔是负责创建能够自行学习和记住所有可能组合的计算机的人。这样,就不一定需要任何人类干预,计算机实际上可以独立思考,这是一个巨大的进步,即使在今天也仍然令人印象深刻。
电子游戏中的敌人人工智能
现在,让我们转向视频游戏行业,分析最初的游戏敌人和游戏障碍是如何编程的;这与我们现在所做的是否有很大不同?让我们找出答案。
单人游戏与人工智能敌人相结合的游戏始于 20 世纪 70 年代,很快,一些游戏开始提升对定义视频游戏人工智能的质量和期望。其中一些例子是为街机发布的,例如来自 Taito 的赛车游戏 Speed Race,或者使用光枪的鸭子狩猎游戏 Qwak,以及来自 Atari 的飞机战斗机 Pursuit。其他值得注意的例子是针对第一台个人电脑发布的基于文本的游戏,如 Hunt the Wumpus 和 Star Trek,它们也拥有敌人。这些游戏之所以如此有趣,正是因为它们的人工智能敌人不像之前的任何其他游戏那样反应,因为它们将随机元素与传统存储模式混合在一起,使其不可预测,从而在每次玩游戏时都提供独特的体验。然而,这一切都得益于微处理器的引入,它扩大了当时程序员的编程能力。太空侵略者带来了运动模式,而 Galaxian 则改进并增加了更多变化,使人工智能变得更加复杂。PAC-MAN 之后将运动模式引入了迷宫游戏类型。
PAC-MAN 中的人工智能设计所产生的影响与游戏本身的影响一样显著。这款经典的街机游戏让玩家相信游戏中的敌人正在追赶他,但并非以粗俗的方式。幽灵以不同的方式追赶玩家(或躲避玩家),仿佛它们具有个性。这给人们一种错觉,他们实际上是在与四个或五个具有个性的幽灵而不是同一台计算机敌人的复制品进行对抗。
之后,空手道冠军 引入了第一个人工智能战斗角色,而 龙之谷 引入了角色扮演游戏类型的战术系统;多年来,探索人工智能并利用它创造独特游戏概念的游戏列表不断扩展,所有这些都源于一个简单的问题:我们如何让计算机在游戏中击败人类?
上文提到的所有游戏都属于不同的类型,它们在风格上都是独特的,但它们都使用了相同的 AI 方法,称为有限状态机(FSM)。在这里,程序员输入了所有必要的计算机行为,以便挑战玩家,就像第一个下棋的计算机一样。程序员精确地定义了计算机在不同场合应该如何行动,以便移动、躲避、攻击或执行任何其他行为来挑战玩家,这种方法甚至被用于今天最新的大型预算游戏中。

从简单到智能、类似人类的 AI
在开发人工智能角色时,程序员面临着许多挑战,其中最大的挑战之一是使人工智能的动作和行为适应玩家当前的行为,或未来可能的行为。这种困难存在的原因是因为人工智能被编程为具有预定的状态,使用概率或可能性图来根据玩家的行为调整其动作和行为。如果程序员扩展了人工智能决策的可能性,这种技术可能会变得非常复杂,就像具有游戏中可能出现的所有可能情况的棋机一样。
对于程序员来说,这是一个巨大的任务,因为需要确定玩家可以做什么,以及人工智能将如何对玩家的每个动作做出反应,这需要大量的 CPU 功率。为了克服这个挑战,程序员开始将可能性图与概率混合,并执行其他让人工智能根据玩家的动作自行决定如何反应的技术。在开发能够提高游戏质量的人工智能时,这些因素是需要考虑的,正如我们即将发现的。
游戏不断进化,玩家对视觉质量以及人工智能敌人的能力和盟友角色的要求也越来越高。为了满足玩家的期望,程序员开始为每个角色编写更多的状态,创造新的可能性,并创造更具吸引力的敌人,实施重要的盟友角色,这意味着玩家有更多的事情要做,并创造了更多帮助重新定义不同类型并创造新类型的特性。当然,这也得益于技术的不断进步,使得开发者能够探索更多视频游戏中的人工智能。一个值得提到的例子是 Metal Gear Solid,这款游戏通过实施潜行元素,而不是流行的直接射击,为视频游戏行业带来了一种新的类型。然而,由于当时的硬件限制,这些元素无法完全按照小岛秀夫的意图进行探索。从第三代跳到第五代游戏机,科乐美和小岛秀夫推出了同样的标题,但这次游戏中的 AI 元素有了更多的交互、可能性和行为,使其在视频游戏历史上如此成功和重要,以至于我们可以很容易地看到它在 Metal Gear Solid 之后的大量游戏中的影响:

Metal Gear Solid - 索尼 PlayStation 1
视觉和听觉意识
前一张截图中的游戏实现了敌对 AI 的视觉和听觉感知功能,这是一个我们将在本书中详细探讨的特性。这个特性确立了我们现在所知的潜行游戏类型。因此,游戏使用了路径查找和有限状态机(FSM),这些特性在视频游戏行业的早期就已经为人所知;但为了创造新事物,他们也创造了新的特性,例如与环境交互、导航行为、视觉/听觉感知以及 AI 交互;许多当时不存在但现在在不同游戏类型中广泛使用的事物,如体育、赛车、格斗或 FPS 游戏,也被引入:

在游戏设计迈出巨大的一步之后,开发者仍然面临其他问题,或者说,这些新的可能性带来了更多的问题,因为它们并不完美。AI 仍然没有像真实的人那样反应,还有许多其他元素需要实现,不仅是在潜行游戏中,在所有其他类型中,特别是有一个特别需要改进其 AI 以使游戏感觉更真实。
我们在谈论体育游戏,尤其是那些试图模拟现实世界团队行为的游戏,如篮球或足球。与玩家的交互并不是我们需要关注的唯一事情;我们已经远离了棋类游戏,那里是 1 对 1 的对决。现在,我们想要更多,看到其他游戏中的 AI 具有真实的行为,体育迷们开始要求在自己的游戏中拥有同样的特性。
最喜欢的游戏;毕竟,那些游戏是基于现实世界事件,因此,AI 应尽可能真实地反应。
在这个阶段,开发者和游戏设计师开始考虑人工智能之间的交互,就像来自 PAC-MAN 的敌人一样,玩家应该有印象,游戏中的每个角色都在独立思考,并且对其他角色有不同的反应。如果我们仔细分析,体育游戏中的 AI 结构类似于 FPS 或 RTS 游戏,使用不同的动画状态、一般动作、交互、个体决策,最终是战术和集体决策。因此,体育游戏能够达到与其他已经在大规模 AI 发展中取得巨大进步的游戏类型相同的真实感水平并不令人惊讶。然而,当时只有体育游戏存在一些问题:如何在同一屏幕上让这么多角色有不同的反应,同时又能协同工作以实现相同的目标。带着这个问题,开发者开始改进每个角色的个体行为,这不仅是为了与玩家对抗的 AI,也是为了与玩家一起玩的 AI。再次强调,有限状态机是人工智能的一个关键部分,但帮助在体育游戏中创造现实主义方法的是潜行游戏中使用的预判和意识。计算机需要计算玩家的行为、球的方向,并协调所有这些,同时还要给团队对同一计划的思维留下一个假象。将潜行游戏新类型中使用的功能与同一屏幕上的大量角色相结合,通过创建一种体育模拟类型的游戏,这种游戏在多年中获得了巨大的流行度。这有助于我们理解,我们可以为任何类型的游戏使用几乎相同的方法,即使它们看起来完全不同;我们在 30 年前发布的体育游戏中看到的计算机的核心原则仍然是有价值的。
让我们继续到最后一个例子,这个例子在如何使人工智能角色更加逼真方面也具有很高的价值:这个游戏是 Monolith Productions 开发的《F.E.A.R.》。在人工智能方面,这个游戏之所以特别,是因为敌对角色的对话。虽然从技术角度来看并没有改进,但它确实有助于展示投入角色人工智能开发的所有努力,这是至关重要的,因为如果人工智能没有说出来,那么它就没有发生。这是在创建逼真的人工智能角色时需要考虑的重要因素,给人们造成它是真实的错觉;这意味着计算机的反应像人类一样,人类如何互动,所以人工智能也应该这样做。对话不仅有助于营造类似人类的氛围,还帮助释放了所有投入角色中的开发,否则玩家不会注意到这些。当人工智能第一次发现玩家时,它会大声喊出它找到了玩家;当人工智能失去对玩家的视线时,它会表达这一点。当人工智能小队试图找到玩家或伏击他时,他们会谈论这件事,让玩家想象敌人真的能够对他进行思考和计划。为什么这如此重要?因为如果我们只有角色的数字和数学方程式,它们会那样反应,没有任何人类特征,只有数学,为了使其看起来更人性化,有必要将错误、错误和对话输入到角色人工智能中,以分散玩家对他正在与机器对战的这一事实的注意力。
电子游戏人工智能的历史仍然远未完善,可能需要数十年的时间才能从 20 世纪 50 年代初到今天我们所取得的成就中再提高一点,所以不要害怕探索你即将学习、结合、更改或删除一些东西以找到不同结果的内容,因为过去的大游戏就是这样做的,并且它们在这方面取得了很大的成功。
摘要
在本章中,你了解了人工智能对电子游戏历史的影响,从最初简单地将计算机用于与人类在传统游戏中竞争的想法开始,以及它是如何自然地发展到电子游戏世界的。你还了解了从第一天起就存在的挑战和困难,以及程序员偶然面对并仍然面临相同的问题。在下一章中,我们将从那个精确的点开始,即最常用的技术,以及在过去、现在和未来的游戏中引起了很多争议和演变的技术。
第二章:可能性和概率图
在本章中,我们将讨论可能性和概率图,了解它们如何以及在哪里被使用。我们还将学习创建能够对玩家做出反应并选择最佳选项的 AI 的最佳实践,因为我们致力于创建一个能够像人类一样做出决策的角色。
如我们之前所看到的,电子游戏过去一直依赖于预先确定 AI 在不同场景中的行为,这些场景要么是由游戏本身创建的,要么是由玩家的行为创建的。这种方法从第一天开始就存在,并且至今仍在使用,使其成为创建高质量 AI 角色的极其有价值的方法。在详细解释每张图的作用之前,在演示如何创建它们以开发良好的 AI 行为之前,了解可能性和概率图是什么以及它们在哪里或何时被应用,总是好的。
作为玩家,我们倾向于整体享受产品,以热情和专注的态度体验游戏的每一个部分,而忘记了游戏的技术方面。因此,我们有时会忘记,即使在玩游戏时发生的简单事情,也早已注定要以这种方式发生,并且在这背后有很多思考和规划。正如我们经常听到的,每件事都有其原因,这也适用于电子游戏。从你点击开始按钮开始游戏的那一刻起,到你所执行的最后一个令人惊叹的连招击败游戏中的最终 Boss,所有这一切都是计划好的,并且程序员需要在游戏中输入所有这些可能性。如果你点击了 A 按钮,你的角色跳了起来,那是因为它被设定成这样。对于游戏中的 AI 敌人或盟友也是如此;当它们做某事来击败或帮助你时,这种行为被编程是必要的,而为了做到这一点,我们使用状态。
游戏状态
要了解如何创建可能性或概率图,我们首先需要承认创建它们所需的原则性方面,这被称为游戏状态,或简称状态。我们将游戏状态称为在游戏的不同场合预先设定的动作,这些动作可以应用于玩家或敌人角色。一些例子可以是简单的行为,如跑、跳或攻击,这些状态可以进一步扩展,例如当角色在空中时无法攻击,或者当角色魔法能量低时无法进行魔法攻击。在这些情况下,角色从一个状态转换到另一个状态,或者如果它在做另一件事,就无法执行。

可能性图
现在,让我们更深入地看看我们在第一章的例子中遇到的那些可能性图,从棋盘游戏到《合金装备》电子游戏。正如我们所见,这是一种至今仍在使用的技巧,没有它几乎不可能创建游戏 AI。
如其名所示,可能性图允许程序员定义玩家或游戏中的 AI 角色可用的可能性。游戏内部所有可能的事情都需要被计划和编码,但当你允许角色做很多事情时,他们能同时做所有这些事情吗?如果在游戏的各个阶段进行,他们能在所有阶段都以相同的方式反应吗?为了允许和限制可能的行为,我们还需要考虑游戏中可能发生的情景,当你把这些都放在一起时,就称为可能性图。
如何使用可能性图
让我们看看一个常见的 FPS 游戏的简单例子,为此我们将使用前一张图片中展示的状态。
想象一下,我们是游戏中的敌人角色,我们的目标是仅使用行走、奔跑、掩护、跳跃、开火和防御这些状态来射击并杀死玩家。我们需要考虑玩家会尽力杀死我们,因此可能会出现很多可能的情景。让我们从基础开始,我们从一个点到另一个点行走,同时保护我们的空间,当玩家接近那个空间时,我们的目标从保护我们的空间转变为最终目标,即杀死玩家。接下来我们应该怎么做?开火?向玩家跑过去并近距离开火?掩护并等待玩家靠近?如果玩家先看到我们并准备向我们开火怎么办?很多事情都可能发生,而且只需要几个状态就可以做很多事情。所以,让我们绘制每一个可能的情景,并计划我们在每种具体情况中应该如何行动或反应。我会在我的游戏中选择的例子如下:
-
慢慢走到掩护位置,等待玩家,然后射击他
-
快速躲避并从那个位置开火
-
在跑向掩护位置的同时防御(远离子弹)
-
向玩家开火,向他跑过去,并持续开火
根据我们想要创建的游戏类型,我们可以使用相同的状态来将其塑造成不同的流派。我们还需要考虑我们正在编程的角色性格。如果它是一个机器人,它可能不会害怕对玩家持续开火,即使被摧毁的可能性是 99%。另一方面,如果它是一个没有经验的士兵,它可能会犹豫不决,并立即寻找掩护。仅通过改变角色的性格,就可以有无数的变化。

准备可能性图(FPS)
到目前为止,我们能够理解什么是可能性图以及它是如何被用来创建一个根据游戏中的不同情况做出相应行为的 AI 角色的。现在我们了解了如何使用可能性图,让我们创建一个实际例子,其中我们编写的 AI 角色成功地击败了玩家。对于这个例子,我将使用两个模型,一个代表我们正在编写的 AI 敌人,另一个代表玩家。
我们将创建一个常见例子,其中 AI 正在保护玩家需要进入以解除炸弹并完成关卡的建筑入口。让我们想象我们已经完全编写了玩家的代码,现在我们需要专注于我们的 AI 敌人,如下面的截图所示:

在编写任何代码行之前,我们需要考虑可能发生的情况以及我们的 AI 将如何对这些情况做出反应。首先,我们将通过将我们的阶段分解成一个简单的 2D 可视化来简化情况,这将作为确定距离和其他相关参数的参考。

在简化了情况之后,我们就可以开始规划可能性了。玩家被允许在建筑周围移动。请注意,这里只有一个入口,而且这个入口被我们的敌人 AI 保护。箭头表示角色面向的方向,这将是我们规划的一个重要方面。
创建可能性图(FPS)
我们将在稍后学习如何为 AI 角色创建意识行为,所以现在我们只是使用简单的布尔变量来确定玩家是否靠近我们的位置以及它面向哪个方向。考虑到这一点,让我们将我们的图像分解成触发区域,以定义我们的敌人 AI 何时应该做出反应。

YES 区域代表触发我们的 AI 从被动状态转变为攻击状态的区域。NO 区域代表对 AI 行为没有影响的区域。我将 YES 区域分为三个,因为我们希望我们的 AI 角色根据玩家的位置做出不同的反应。如果玩家从右侧(YES R)来,敌人有一堵可以作为掩护的墙;如果从左侧(YES L)来,我们就不能再使用那堵墙了,一旦玩家处于中间(YES M),AI 只能在建筑内向后移动。
让我们为敌人 AI 准备脚本。在这个例子中,我们将使用 C#语言,但你可以将脚本适应到你喜欢的任何编程语言,因为原则是相同的。我们现在将使用的变量是Health、statePassive、stateAggressive和stateDefensive:
public class Enemy : MonoBehaviour {
private int Health = 100;
private bool statePassive;
private bool stateAggressive;
private bool stateDefensive;
// Use this for initialisation
void Start () {
}
// Update is called once per frame
void Update () {
}
}
现在我们已经知道了 AI 所需的基本信息,我们需要考虑这些状态将在何时使用以及 AI 将如何在这三个可用选项之间进行选择。为此,我们将使用可能性图。我们已经知道了触发我们角色的区域,并且已经选择了三个行为状态,因此现在是根据玩家的位置和行为规划转换和反应的时候。

我们的敌人 AI 可以从被动状态转换为防御状态或攻击状态,从攻击状态转换为防御状态,从防御状态转换为攻击状态,但一旦我们的 AI 知道玩家在附近,它将永远不会回到被动行为。
定义状态
让我们定义触发每个状态的条件以及 AI 在不同场景中如何选择正确的状态。被动状态将是默认状态,游戏将从这个位置开始,直到玩家遇到我们的角色。如果玩家从右侧来,并且已经与玩家交战且血量较低,将使用防御状态。最后,如果玩家从左侧来或已经到达中间区域,将激活攻击状态:
public class Enemy : MonoBehaviour {
private int Health = 100;
private bool statePassive;
private bool stateAggressive;
private bool stateDefensive;
private bool triggerL;
private bool triggerR;
private bool triggerM;
// Use this for initialisation
void Start () {
statePassive = true;
}
// Update is called once per frame
void Update () {
// The AI will remain passive until an interaction with the player occurs
if(Health == 100 && triggerL == false && triggerR == false && triggerM
== false)
{
statePassive = true;
stateAggressive = false;
stateDefensive = false;
}
// The AI will shift to the defensive mode if player comes from the
right side or if the AI is below 20 HP
if(Health<= 100 && triggerR == true || Health<= 20)
{
statePassive = false;
stateAggressive = false;
stateDefensive = true;
}
// The AI will shift to the aggressive mode if player comes from the
left side or it's on the middle and AI is above 20HP
if(Health> 20 && triggerL == true || Health> 20 && triggerM == true)
{
statePassive = false;
stateAggressive = true;
stateDefensive = false;
}
}
}
我们添加了触发变量 triggerL、trigger 和 triggerM,并定义了 AI 何时应该从一个行为状态转换到另一个状态。此时,我们的敌人已经根据玩家的位置知道在游戏过程中可能出现的不同情况应该怎么做。
现在我们只需要确定每个状态会发生什么,因为这就是防御状态和攻击状态的区别。对于这个特定的敌人,他的主要功能是保护建筑入口,我们希望他始终待在原地,永远不要追击玩家。这是因为 AI 不知道只有一个人,如果有可能遇到几个敌人,它就不能冒险只追击一个士兵。这将有助于使敌人的行为更具现实感。我们还将使用防御行为状态,当敌人感觉到即将战败和死亡,或者当它利用建筑保护自己而玩家没有时。最后,当 AI 看到明显的优势可以杀死玩家或没有其他选择时,将使用攻击状态。
防御状态
因此,让我们从玩家从右侧来并且我们的敌人已经发现他的情况开始。我们希望我们的 AI 利用保护他的墙壁,使玩家难以接近,同时展现出类似人类的意图,而不是简单地开火。敌人将朝向墙壁移动并停留在那里,对角落进行射击,直到玩家到达那个位置。

敌人将从被动状态转变为防御状态,而不是进攻状态,仅仅是因为这样做能给他相对于玩家的略微优势。在第一次遭遇时采取防御姿态向 AI 展示了一些个性,这在使计算机角色的行为看起来可信时非常重要。在未来的章节中,我们将学习如何利用环境来深入定义我们的 AI 角色:
Void Defensive () {
if(playerPosition == "triggerR")
{
// Check if player is currently
located on the triggerR position
transform.LookAt(playerSoldier);
// Face the direction of the player
if(cover == false)
{
transform.position = Vector3.MoveTowards(transform.position,
wallPosition.position, walk);
}
if(cover == true)
{
coverFire();}
}
}
当玩家从右侧来临时,我们在我们的敌人 AI 中添加了想要实现的防御状态的核心。我们还添加了新的变量,例如速度、掩护、playerSoldier和coverFire。首先,我们需要检查玩家是否目前位于triggerR区域;如果结果是正面的,角色应该移动到掩护位置。一旦敌人 AI 到达掩护位置,他就可以开始向玩家开火(coverFire)。现在,如果玩家仍然存活,我们的敌人需要移动到另一个情况,否则它将被逼入死角,这对我们正在创建的角色来说不是一个好场景。让我们将这种情况添加到我们的脚本中。
我们希望我们的角色走回建筑物内,同时始终面向玩家并开火。我们可以使用另一种策略,或者决定更加激进并直接面对玩家,但现阶段让我们坚持简单的策略。我们可以在以后添加更复杂的行为:
if (playerPosition == "triggerM")
{
transform.LookAt(playerSoldier); // Face the direction of the player
transform.position = Vector3.MoveTowards(transform.position,
buildingPosition.position, walkBack);
backwardsFire(); }

在这段代码中,我们添加了一个玩家从右侧来并且仍然存活,前往中间的情况,因此我们需要将之前的行为更改为新的行为。我们的 AI 角色从掩护位置移动到一个新的位置,这个位置在建筑物内,并且一直向玩家开火。在这个时候,敌人将继续撤退,直到两个角色中的任何一个死亡,无论是玩家还是 AI 角色。我们现在关闭玩家从右侧来的情况。现在我们已经完成了这一部分,我们需要完成场景,并添加最后一个情况,即玩家绕过建筑物并从左侧来。我们的 AI 需要适应这些情况并表现出不同的行为,所以让我们着手处理这部分并完成示例。
进攻状态
在我们开始编程之前,我们定义了我们需要为这个敌人 AI 多少个状态,我们选择了三个不同的状态:被动、防御和进攻。现在我们已经有了两个行为状态(被动和防御)就位,我们只需要一个状态来完成我们的敌人 AI,那就是保护建筑物。
我们之前决定,如果角色无法使用墙壁作为掩护,他才会直接面对玩家,而玩家从左侧出现,敌人对其出现感到惊讶的那一刻正是如此。

再次强调,我们首先需要检查玩家是否触发了左侧区域,因为这将激活我们的敌人 AI 从被动状态变为我们期望的攻击状态。然后,我们需要定义在这种情况下他应该做什么。让我们开始在脚本中编写它:
Void Aggressive () {
if(playerPosition == "triggerL" || playerPosition == "triggerM")
{
transform.LookAt(playerSoldier); // Face the direction of the player
frontFire();
}
else {
transform.position = Vector3.MoveTowards(transform.position,
triggerLPosition.position, walk);
}
}
这次我们添加了两种可能的情况,当攻击从左侧来的玩家时;第一种情况是如果玩家从左侧来并继续向敌人前进,或者保持在同一位置。第二种可能发生的情况是如果玩家一看到敌人就立即撤退,在这种情况下,我们选择让敌人寻找玩家,朝向triggerL位置前进,这是玩家最初出现的位置。
这是完成后的脚本,使用了我们在本章中一直在工作的可能性图示例。让我们看一下完整的脚本:
Private int Health = 100;
Private bool statePassive;
Private bool stateAggressive;
Private bool stateDefensive;
Private bool triggerL;
Private bool triggerR;
Private bool triggerM;
public Transform wallPosition;
public Transform buildingPosition;
public Transform triggerLPosition;
private bool cover;
private float speed;
private float speedBack;
private float walk;
private float walkBack;
public Transform playerSoldier;
staticstring playerPosition;
在之前的代码块中,我们可以看到到目前为止在我们的脚本中已经使用过的所有变量。脚本的其他部分如下:
// Use this for initialization
Void Start () {
statePassive = true;
}
// Update is called once per frame
Void Update () {
// The AI will remain passive until an interaction with the player occurs
if(Health == 100 && triggerL == false && triggerR == false && triggerM == false)
{
statePassive = true;
stateAggressive = false;
stateDefensive = false;
}
// The AI will shift to the defensive mode if player comes from the right side or if the AI is below 20 HP
if(Health<= 100 && triggerR == true || Health<= 20){
statePassive = false;
stateAggressive = false;
stateDefensive = true;
}
// The AI will shift to the aggressive mode if player comes from the left side or it's on the middle and AI is above 20HP
if(Health> 20 && triggerL == true || Health> 20 && triggerM == true){
statePassive = false;
stateAggressive = true;
stateDefensive = false;
}
walk = speed * Time.deltaTime;
= speedBack * Time.deltaTime;
}
Void Defensive () {
if (playerPosition == "triggerR")
{
// Check if player is currently located on the triggerR position
transform.LookAt(playerSoldier); // Face the direction of the
player
if(cover == false)
{
transform.position = Vector3.MoveTowards(transform.position,
wallPosition.position, walk);
}
if(cover == true)
{
coverFire();
}
}
if(playerPosition == "triggerM")
{
transform.LookAt(playerSoldier); // Face the direction of the
player
transform.position = Vector3.MoveTowards(transform.position,
buildingPosition.position, walkBack);
backwardsFire();
}
}
Void Aggressive () {
if (playerPosition == "triggerL" || playerPosition == "triggerM")
{
transform.LookAt(playerSoldier); // Face the direction of the player
frontFire();
}
else {
transform.position = Vector3.MoveTowards(transform.position,
triggerLPosition.position, walk);
}
}
Void coverFire () {
// Here we can write the necessary code that makes the enemy firing while in cover position.}
Void backwardsFire () {
// Here we can write the necessary code that makes the enemy firing while going back.}
voidfrontFire() {
}
可能性图结论
我们终于完成了第一个可能性图示例。本章中分享的原则可以应用于广泛的游戏类型。事实上,几乎你计划在未来创建的任何游戏都可以从可能性图中获得很大的好处。正如我们所见,这项技术用于规划玩家可能创建的每一种情况,以及角色 AI 应该如何根据这些情况行动。通过精心规划,我们可以避免游戏中的许多问题,以及角色 AI 行为缺乏多样性。另一个值得注意的有趣点是,尝试为游戏中的不同角色创建不同的可能性图,因为并非所有人类都会以相同的方式反应。计算机 AI 应该遵循同样的规则。
概率图
概率图是可能性图的一个更复杂和详细的版本,因为它依赖于概率来改变角色的行为,而不是简单的开或关触发。它与可能性图的相似之处在于,它也用于提前规划我们角色的可能状态。然而,这一次,我们添加了一个百分比,AI 将根据这个百分比计算他将使用的行为。想象一下下一个例子,使用我们之前为可能性图创建的情况,我们的敌人 AI 在白天可能比在夜晚更具攻击性。为此,我们创建了一个说明,告诉我们的敌人如果现在是夜晚,看到玩家角色的机会较小,因此它将选择更防御性的策略而不是攻击性的策略。或者,简单地说,我们可以定义敌人根据两个角色之间的距离来计算杀死玩家的概率。如果玩家靠近敌人,AI 返回并生存的概率将小于如果他继续对玩家开火的情况,因此我们可以将这个方程添加到 AI 中。
让我们来看看人类行为,我们的选择;通常,我们会通过考虑过去的事件和我们之前所做的事情来做出决定。当我们感到饥饿并决定外出吃饭时,我们的朋友能猜到我们选择了哪家餐厅吗?我们的朋友可能会计算我们选择的概率,并通过考虑更高的百分比来给出他们的答案。这正是我们需要对我们的人工智能朋友所做的事情;我们需要为他选择分配一些概率,例如,AI 角色在白天和夜间保护建筑时入睡的概率是多少?如果敌人的生命值低,他逃跑的概率是多少?如果我们将概率应用于我们的 AI 角色,这有助于创建人类具有的不可预测行为,使游戏更加吸引人和自然。
如何使用概率地图
在这个例子中,我们将继续使用我们之前创建的相同场景,其中我们的 AI 守卫正在保护一个包含玩家需要关闭的原子弹的建筑。建筑唯一的入口被我们的 AI 角色所保护。
让我们想象一下,我们是守卫,并且我们接到命令要连续 16 小时待在那里,我们可能需要吃东西、喝水,并且稍微活动一下,以便能够保持活跃并保持警惕。因此,我们将这一点添加到我们的角色中,使玩家更加不可预测。如果 AI 决定吃东西或喝水,他将在建筑内,如果他决定稍微走动一下,他将在triggerL到triggerR位置巡逻。大多数时候,他只是站在他的守卫位置上。

这是一个概率地图,在这里我们定义了我们的角色存在于每个状态中的百分比。这意味着每次玩家看到敌人 AI 时,敌人可能正在做这些事情中的任何一件。当考虑到玩家决定出现的具体时间时,这会有很大的不同。如果玩家在早上到达,有0.87的概率在建筑前发现敌人处于守卫位置,有0.10的概率在建筑内发现他正在进食或饮水,最后有0.03的概率发现他正在从一个点到另一个点散步。如果玩家在下午到达,有0.48的概率在建筑前发现敌人处于守卫位置,有0.32的概率在建筑内发现他正在进食或饮水,最后有0.2的概率发现他正在从一个点到另一个点闲逛。在夜间,玩家有0.35的概率在守卫位置发现敌人,有0.40的概率在建筑内发现他正在进食或饮水,有0.25的概率发现敌人正在闲逛。

这将帮助我们的角色具有不可预测性,不会让他在每次玩关卡时都处于相同的位置。我们还可以大约每五分钟更新这个概率,以防玩家保持静止,等待我们的敌人改变位置。这种技术在很多游戏中都有使用,尤其是在潜行游戏中,观察是关键。这是因为玩家有机会待在一个安全的位置,观察敌人的行为,类似于他抢电影,演员在进入银行之前等待守卫换班。正因为这种我们在电影中习惯看到的行为,玩家喜欢在游戏中感受到同样的感觉,因此概率图改变了我们玩游戏的方式。
以下是如何在脚本中使用概率的一个例子。为此,我使用了 Passive 状态,并添加了我们之前决定使用的概率:
Void Passive () {
rndNumber = Random.Range(0,100);
If(morningTime == true && 13)
{
// We have 87% of chance
goGuard();
}
if(morningTime == true && rndNumber =< 13 && rndNumber< 3)
{
// We have 10% of chance
goDrink();
}
if(morningTime == true && rndNumber<= 3)
{
// We have 3% of chance
goWalk();
}
if(afternoonTime == true && rndNumber> 52)
{
// We have 48% of chance
goGuard();
}
if(afternoonTime == true && rndNumber =< 34 && rndNumber< 2)
{
// We have 32% of chance
goDrink();
}
if(afternoonTime == true && rndNumber<= 2)
{
// We have 2% of chance
goWalk();
}
if(nightTime == true && rndNumber> 65)
{
// We have 35% of chance
goGuard();
}
if(nightTime == true && rndNumber =< 65 && rndNumber< 25)
{
// We have 40% of chance
goDrink();
}
if(nightTime == true && rndNumber<= 25)
{
// We have 25% of chance
goWalk();
}
}
要计算百分比,我们首先需要从 0 到 100 创建一个随机数,然后我们创建一个将使用该数字来检查它属于哪个陈述的语句。例如,在第一个陈述中,我们有 87% 的机会让 AI 保持在守卫位置,所以如果随机数高于 13,它就属于这个类别,角色将被设定为保持在守卫位置。一个高于 3 且等于或小于 13 的数字,给我们 10% 的机会,而一个等于或小于 3 的数字给我们 3% 的机会。
接下来该去哪里
现在我们已经了解了如何使用概率和可能性图,一个可能我们自己也会问的问题就是,我们能用这个做什么呢?嗯,我们看到了使用可能性图来定义角色的行为是多么重要,以及概率是如何帮助使这些行为不可预测的,但我们可以根据我们正在创建的游戏类型或我们想要的 AI 类型做更多的事情。记住,缺陷是我们作为人类的一部分,我们周围充满了概率,即使只是 0,000001% 的意外发生的概率存在,这就是为什么没有人是完美的,所以当创建 AI 角色时,给他们一些人类行为发生的概率,或者简单地做出好或坏的决策,这将为你在创建的电脑角色中构建一个个性,这是一个有趣的事实。
另一个我们可以用概率图做的特别之处,就是给 AI 提供自我学习的机会,每次玩家决定玩游戏时,都会让 AI 变得更聪明。玩家和敌方 AI 都会学习,挑战会根据玩家在游戏上花费的时间不断更新。如果玩家倾向于使用同一武器或从同一方向来,计算机应该更新那条信息,并在未来的事件中使用它。如果玩家与计算机对抗了100次,其中有60%的时间使用了手榴弹,AI 应该记住这一点,并根据那个概率做出反应。这将促使玩家思考其他策略,并且不会那么明显地探索击败敌人的其他方法。
摘要
本章介绍了可能性与概率图,我们学习了如何让 AI 根据玩家的行为自行做出决策。可能性和概率图是 AI 角色的基础,现在我们可以探索这项技术,为我们的游戏创造新的和独特的人工智能。在下一章中,我们将学习 AI 应该如何根据它在那一刻的不同选项来表现,而不是使用可能性图。我们希望角色能够分析情况,思考要做什么,同时考虑许多因素,如健康、距离、武器、子弹以及其他相关因素。
第三章:生产系统
在本章中,我们将讨论如何完善我们的 AI 角色以及如何将这些相同的技巧应用于我们想要创建的不同类型的游戏。我们还将讨论以下主题:
-
自动有限状态机(AFSMs)
-
计算概率
-
基于效用函数
-
动态游戏 AI 平衡
在探索可能性图和概率图之后,我们需要了解如何结合其他技术和策略使用它们来创建一个平衡且类似人类的 AI 角色。可能性图甚至概率图可以单独使用来创建有趣且具有挑战性的游戏;事实上,许多电子游戏只依赖于地图,并保持相同的方法来创建它们的 AI 敌人,并且它们这样做非常成功。一个完美的例子就是任天堂的通用平台游戏,例如超级马里奥兄弟。他们不需要创建复杂的 AI 系统来使敌人具有挑战性,这就是为什么几十年来他们一直使用相同的公式来创建敌人,因为这对游戏类型来说效果完美。因此,我们也应该记住,根据我们正在创建的游戏,某些技术可能比其他技术更有效,而决定使用哪些技术以及何时使用它们取决于我们。现在,同样的原则也应该应用于我们正在创建的角色,它应该知道在游戏的每一秒应该做什么以及何时去做。
让我们继续以超级马里奥兄弟为例,分析一些常见敌人的行为:

截图中的敌人被称为 Goomba。一旦他在游戏中出现,你就会注意到他向左移动,只有当他碰到某个东西(不包括玩家)时,他才会改变方向并向右移动。如果他在一个更高的平台上,他会继续向左移动,直到他掉下来;在较低的平台,他会继续向左移动。这个敌人永远不会试图击败玩家,并且他的行为非常可预测。因此,我们可以确定他只有一个目标,那就是移动,并且可以把他放在舞台的任何地方,因为他在位置上的行为将完全相同。现在让我们转向我们的下一个敌人:

在这个第二个例子中,敌人被称作锤子兄弟(Hammer Bro),它和之前的敌人功能不同。这个 AI 可以左右移动,始终面向玩家,并且朝玩家的方向投掷锤子。因此,他在游戏中的主要目标是击败玩家。和之前的敌人一样,这个敌人也可以被放置在游戏的任何位置,并且会根据他的目标行动。现在,假设我们拾起了上一章中开发的敌人 AI,并将其放置在游戏的不同位置或阶段。他不会做出反应,因为我们没有提供关于如果放置在另一个位置时他应该做什么的指示。根据我们正在创建的游戏,我们需要开发一个 AI 角色,使其能够按照我们的意图做出反应。有时他会被固定在单个位置,但大多数时候,需要同一个 AI 在游戏的不同位置以相同的方式做出反应。想象一下,如果《超级马里奥兄弟》(Super Mario Bros)的制作者每次将 AI 角色插入游戏时都必须重新定义他们的 AI 角色,这将花费大量的时间和精力。所以,让我们学习如何使用有限状态机(FSMs)来使我们的角色能够适应游戏的每一种情况。
自动有限状态机(AFSMs)
正如我们在《超级马里奥兄弟》(Super Mario Bros)的例子中所观察到的,敌人无论被放置在什么位置都知道如何做出反应。显然,他们不需要执行复杂的任务或提前计划他们将要做什么,但这作为一个例子非常完美,尤其是在与其他不同类型的视频游戏进行比较时。例如,我们可以在《光环》(Halo)中看到相同的原理被应用于小敌人(Grunts)。他们只是从一个侧面移动到另一个侧面,如果他们发现玩家,就会开始射击。这是同样的原理,他们只是给他们的角色添加了一个个性,每次在与玩家战斗失败后都会逃跑。为此,他们使用了一个语句,每次角色的生命值(HP)低于一定数值时,他们就开始逃跑。有限状态机(FSMs)是我们用来创建可能性和概率图的东西;这也是角色在面临不同情况时应做的事情。现在,让我们创建自动有限状态机(AFSMs),其中角色将根据他能计算出的因素(位置、玩家 HP、当前武器等)选择最佳选项。如果我们计划在不同的阶段或涉及开放世界的游戏中使用同一个角色,这种方法非常有用。
在规划 AFSM(有限状态机)时,如果我们能够将动作分解为两到三个主要列,那是一个很好的开始;在一侧的列中,我们放置主要信息,例如方向、速度或目标,而在另一列中,我们放置可以在第一列动作上执行的动作,例如移动、射击、充电、寻找掩护、隐藏、使用物品等等。通过这样做,我们可以确保我们的角色可以独立于他当前所在的位置根据第一列的反应。想象一下,分配给 AI 的目标是守卫我们定义的位置。这个目标是主要目标,因此它将被放置在第一列。现在想象一下,角色开始游戏时离他应该守卫的位置很远。在那个时刻,他将使用第二列的动作来实现第一列的目标。我们应该在第二列中放置什么,以便使这一点成为可能,这很大程度上取决于我们想要创建的游戏类型。所以让我们创建一个示例,并选择最佳选项。
我们将继续使用 FPS(第一人称射击)游戏类型作为示例的主要舞台,但同样的原则几乎可以应用于任何视频游戏。这就是我们选择《超级马里奥兄弟》作为参考的原因,以展示无论我们想要创建的游戏类型如何,人工智能的开发往往遵循相同的创作过程:

因此,在我们的示例中,地图将有六个建筑,玩家和敌人都不可以进入其中,但他们可以在其他任何地方移动。这个游戏的主要目标是尽可能多地在有限的时间内击败对手;子弹和生命值会在游戏中偶尔出现。现在让我们开发一个能够在任何地图上以相同方式反应的人工智能。为此,我们需要将主要目标分配给角色,并让他了解实现该目标所需的可能性,同时确保他在游戏的每一秒都在做些什么。
在基本形式上,我们为这个 AI 角色设定了两个主要目标:击败玩家和生存。我们需要确保当我们有机会时能够杀死玩家,而在没有机会时能够生存。目前,让我们简化这个公式,通过考虑我们角色的当前生命值来定义目标。如果他总生命值的 20%以上,主要目标将是击败玩家;另一方面,如果他总生命值的 20%以下,主要目标将切换为生存:

一旦我们定义了这一点,我们就可以移动到第二列,并写下我们的 AI 将选择的次要目标,以便完成第一列的目标。所以在这个例子中,我们将给我们的角色三个次要目标:找到玩家、找到掩护和找到得分点。通过使用这三个目标,我们的 AI 将能够实现主要目标,将始终有事情可做,并且不会等待玩家采取行动,如下所示:

现在,我们已经定义了次要目标,我们将写下游戏中所有可能采取的行动,例如移动到、开火、使用物品和蹲下,如下所示。再次强调,当我们在思考游戏设计时,所有玩家或敌人 AI 能够执行的动作都应该被定义,并且所有这些都应该在这一栏中。这也是分析游戏中角色的所有可用动作是否与主要或次要目标相关的重要策略。这将节省我们未来的时间,因为没有必要编写复杂的动作,如果这些动作不会对主要目标的成功做出贡献,就像在超级马里奥兄弟中,他们选择不将复杂的动作分配给敌人,因为这对他们所创建的游戏类型来说并不必要。在这个例子中,角色有自由移动、开火、使用物品(重新装填武器或使用生命值)和蹲下的可能性:

现在,我们已经填满了三列,包含了我们 AI 选择最佳选项所需的所有信息。正如我们很快就会看到的,这与我们在上一章中使用的方法不同,因为那时我们使用地图来给他指示他应该做什么,并且我们根据那个位置只分配给他命令。在这个例子中,我们希望我们的角色无论处于地图上的哪个位置,都能为自己决定最佳选项。这将推动我们进入开发 AI 角色的下一阶段,因为如果我们观察人类行为,我们很少根据单一标准做出决定。
同样的过程将应用于我们当前的敌人:他们将会根据不同的标准选择最佳选项,我们将确保他们基于自己的决定选择最佳选项。例如,健康值降至 1%比弹药量降至仅剩 1%或完成游戏的主要目标更为紧急。
一旦我们准备好了三列,我们就可以进行下一步,将第三列中的每个动作链接到第二列,并将第二列中的所有行为链接到第一列的目标。在这样做的时候,我们需要考虑如果 AI 想要找到玩家、找到掩护位置或找到点数,他应该做什么。同时,我们需要定义他应该在什么时候寻找点数、掩护位置或玩家。为了找到玩家,我们需要使用“移动到”动作,因此我们的角色将四处走动,直到找到玩家,然后最终向他开火。然后是寻找掩护;再次使用“移动到”动作,因此我们的角色将走到靠近可以作为掩护位置的一堵墙,然后我们可以根据他想要达到的目标选择他是蹲下还是不蹲下。最后,为了找到点数,我们将使用“移动到”,然后,我们将让 AI 决定他是否使用点数(使用对象)。现在,让我们考虑当他在试图击败玩家和试图生存时,他应该选择什么样的行为或行为。为了击败玩家,我们的 AI 角色需要找到他,所以我们将使用“找到玩家”行为来实现这个目标;此外,如果他已经找到玩家并且他们靠近一堵墙,我们将让他选择“找到掩护”行为。对于“生存”目标,我们将选择“找到掩护”,以防他被玩家攻击,并选择“找到点数”,以恢复更多的 HP 点数。
计算概率
现在我们已经一切准备就绪,我们可以将所有这些信息输入到我们的代码中。我们将使用布尔值来定义主要目标,然后创建语句,让角色 AI 在所有其他选项之间进行选择。我们已经定义了将目标从击败玩家切换到仅仅生存的主要语句,但现在我们将因为这个问题添加更多细节到我们的 AI 行为中;如果敌人有足够的 HP 面对玩家,但没有足够的子弹会发生什么?如果他有很多子弹,但之前的射击目标尝试都失败了怎么办?角色需要优先考虑他的选择,并且对于他们将要做出的每一个选择,他们需要将其与其他替代方案进行比较,并选择在那个目标中成功机会更大的一个。
让我们从击中玩家的概率开始:想象一下我们的 AI 已经发射了十发子弹,但只有四发击中了玩家。我们可以说他下一次射击击中玩家的概率是 40%。现在想象一下,他枪中只剩下两发子弹;他应该怎么做?以不太有利的成功概率向玩家射击,然后变得毫无防备?或者直接跑向可以重新装填武器的位置?为了帮助做出决定,角色还将计算被玩家击中的概率;如果玩家击中角色的概率更低,我们的 AI 将冒险向玩家射击,否则它将尝试重新装填武器。我们将开始将此信息添加到我们的代码中。以下是如何呈现的示例:
Private int currentHealth = 100;
Private int currentBullets = 0;
private int firedBullets = 0;
private int hitBullets = 0;
private int pFireBullets = 0;
private int pHitBullets = 0;
private int chanceFire = 0;
private int chanceHit = 0;
public GameObject Bullet;
private bool findPlayer;
private bool findCover;
private bool findPoints;
这些是我们目前将使用的变量。firedBullets 表示角色在整个游戏中已经发射了多少发子弹;hitBullets 表示其中有多少发子弹击中了目标;pFireBullets 和 pHitBullets 与此相同,但考虑了玩家的子弹。我们可以继续计算击中目标或被击中的概率。chanceFire 将表示击中目标的子弹百分比,而 chanceHit 则表示被击中的百分比:
void Update ()
{
chanceFire = ((hitBullets / firedBullets) * 100) = 0;
chanceHit = ((pHitBullets / pFiredBullets) * 100) = 0;
if(currentHealth > 20 && currentBullets > 5)
{
Fire();
}
if(currentHealth > 20 && currentBullets < 5 && chanceFire < 80)
{
MoveToPoint();
}
if(currentHealth > 20 && currentBullets < 5 &&chanceFire>80)
{
Fire();
}
if(currentHealth > 20 && currentBullets > 5 && chanceFire < 30&&
chanceHit > 30)
{
MoveToCover();
}
if(currentHealth < 20 && currentBullets > 0 && chanceFire > 90 &&
chanceHit < 50)
{
Fire();
}
}
我们已经使用击中或被击中的概率来确定 AI 在特定情况下应该做什么。如果他拥有超过 20 点生命值并且枪中有超过五发子弹,他可以自由地向玩家射击,直到这两个条件中的任何一个不再符合。一旦他只剩下五发子弹,就是考虑下一步行动的时候了,所以在这个例子中,如果他成功击中玩家的概率低于 80%,他将决定不射击,并移动到可以重新装填武器的位置。如果他成功击中的概率超过 80%,这意味着他正在取得成功,他可以自由地尝试运气。如果在战斗过程中,AI 击中玩家的概率低于 30%,而玩家击中 AI 的概率超过 30%,角色应该立即寻找掩护。最后,如果 AI 角色剩余的总生命值低于 20%,但他有 90%的概率击中玩家,且被击中的概率低于 50%,他将选择开火。
如果我们想要对百分比更加精确,我们可以将时间变量添加到这个方程中,其中 AI 将考虑最后两分钟或更短的时间,而不是整个时间,或者比较这两个百分比,分析他在最后两分钟内与整个游戏相比成功或失败的程度:
if(recentPercentage > wholePercentage)
通过计算概率,我们给我们的 AI 足够的计算下一步的方法,他可以自由地决定在那个特定时刻哪个目标更重要,并根据那个目标选择他的行动。这样做也将使 AI 能够在两个或更多可用的选项之间进行选择。我们开始开发一个更智能的角色,他可以自己思考,我们可以通过简单地改变百分比值来定义个性,使他更愿意冒险或谨慎地选择他的可能性。
基于效用函数
现在我们已经知道了如何计算概率并使用自动有限状态机(AFSM),是时候更深入地探索它们,并让我们的角色看起来更聪明了。这次,我们将使用一个设置为在模拟游戏如《模拟人生》中自主行为的 AI 角色。这是一个测试人工智能的完美环境,因为它模仿了现实生活中的需求和选择。
在视频游戏《模拟人生》中,玩家有机会控制一个类似人类的角色,游戏的主要目标是确保这个角色始终处于良好的状况,并且他们的个人和职业生活始终处于积极的状态。与此同时,时间就像在现实生活中一样流逝,角色会逐渐变老,直到最终死亡。玩家负责这个角色的生活,但如果玩家不对角色下达任何指令,他将会自主地反应以满足自己的需求。一个 AI 角色在视频游戏中的行为方式是革命性的;人们可以与这个虚拟角色产生共鸣,就像看待一个独立的生物一样。秘密在于我们早已知道如何创建一个可以像《模拟人生》中的角色一样行为的角色。
不再拖延,让我们直接跳到下一个例子:

让我们给这个例子中的角色起个名字,叫她 Sophie。她是一个虚拟的人类。她有一个家,拥有所有必要的生活用品:沙发、淋浴、电视、床、烤箱等等。就像人类一样,她有类似人类的需求,如饥饿、能量、舒适、卫生和乐趣。随着时间的推移,她需要满足她的需求以保持健康。现在我们有了问题,让我们着手解决它,让 Sophie 完全自主,能够自己决定需要做什么:

我们可以将目标简化并分为两列:在左侧,我们放置游戏的主要目标,在右侧,放置角色执行的动作。例如,如果她饿了,她需要走向厨房并与冰箱互动。这正是我们在 FPS 示例中创建自动化有限状态机所使用的原理。但这次我们将更深入地探索这个概念。
让我们稍微思考一下饥饿感。假设当我们吃早餐时,我们满足了饥饿需求,不再感到饥饿。在这个时候,我们可以说我们的饥饿需求是 100%,对吧?想象一下,几分钟过去了,现在我们处于 98%;为什么我们不立即去满足那缺失的 2%呢?我们可以这样说,感到饥饿是一种我们不再感到饱胀的状态,并不一定意味着我们感到空虚,因此我们调整我们的优先级,转向其他需求。所以,在开发一个自主 AI 角色时,重要的是要记住这一点:当他失去 1%的食物时,他不应该立即去吃东西。这样看起来不像人类行为;我们倾向于平衡一切,否则我们可能会睡五分钟,工作五分钟,这可能不够健康或高效,因为不足以入睡,也不足以开始工作。所以,我们睡足够多的时间来补偿我们醒着的时间,我们吃足够多的食物来保持满足感几个小时。我们可以做出判断决策,决定我们有点饿,但我们需要先完成工作。此外,我们还会做出比较判断,例如,我饿了,但我更累。在创造像索菲这样的虚拟人类时,理解我们的行为是很重要的,否则它将表现得像机器人。
为了帮助索菲在任何特定时刻确定什么更重要,我们将使用百分比,然后她将能够比较并决定她想要做什么。在我们过于复杂化之前,让我们先在我们的代码中写下基本信息:
Private float Hunger = 0f;
Private float Energy = 0f;
Private float Comfort = 0f;
Private float Hygiene = 0f;
Private float Fun = 0f;
private float Overall = 0f;
public Transform Fridge;
public Transform Oven;
public Transform Sofa;
public Transform Bed;
public Transform TV;
public Transform Shower;
public Transform WC;
void Start ()
{
Hunger = 100f;
Energy = 100f;
Comfort = 100f;
Hygiene = 100f;
Fun = 100f;
}
void Update ()
{
Overall = ((Hunger + Energy + Comfort + Hygiene + Fun)/5);
Hunger -= Time.deltaTime / 9;
Energy -= Time.deltaTime / 20;
Comfort -= Time.deltaTime / 15;
Hygiene -= Time.deltaTime / 11;
Fun -= Time.deltaTime / 12;
}
我们记录了我们正在创造的角色所需的基本变量。随着时间的推移,这些值将会减少,不同的属性值取决于需求。此外,我们还有一个总体变量,用于计算角色的整体情况,并且可以完美地代表我们虚拟人类的情绪。这将成为一个重要因素,并帮助索菲决定什么是对她最好的选择。
现在,让我们为每个需求进行个性化处理,并为它们创建一个决策树。为了做到这一点,我们需要规划索菲在选择任何行动之前会思考的决策过程。让我们从饥饿需求开始:

如果索菲感到饥饿并且认为这是一个优先事项,她将遵循以下步骤。首先,她感到饥饿,然后问自己是否有足够的食物;为了回答这个问题,她走向冰箱并检查是否有。如果答案是肯定的,她将继续前进,做饭,最后吃饭。如果任何一段无法完成,过程将被中断,她将转向不同的优先事项。比如说,如果她去上班,冰箱里才有食物,而且她每工作一天,比如,可以得到两天的食物。所以,如果她想要保持健康和活着,她很快就会把去上班变成一个优先事项:
Private float Hunger = 0f;
Private float Energy = 0f;
Private float Comfort = 0f;
Private float Hygiene = 0f;
Private float Fun = 0f;
private float Overall = 0f;
public Transform Fridge;
public Transform Oven;
public Transform Sofa;
public Transform Bed;
public Transform TV;
public Transform Shower;
public Transform WC;
private int foodQuantity;
public float WalkSpeed;
public static bool atFridge;
void Start ()
{
Hunger = 100f;
Energy = 100f;
Comfort = 100f;
Hygiene = 100f;
Fun = 100f;
}
void Update ()
{
Overall = ((Hunger + Energy + Comfort + Hygiene + Fun)/5);
Hunger -= Time.deltaTime / 9;
Energy -= Time.deltaTime / 20;
Comfort -= Time.deltaTime / 15;
Hygiene -= Time.deltaTime / 11;
Fun -= Time.deltaTime / 12;
}
void Hungry ()
{
transform.LookAt(Fridge); // Face the direction of the Fridge
transform.position vector3.MoveTowards(transform.position.
Fridge.position, walkSpeed);
//checks if already triggered the fridge position
if(atFridge == true)
{
//interact with fridge
if(foodQuantity > 1)
{
Cook();
}
else()
{
// calculate next priority
}
}
}
在前面的代码中,我们有一个例子说明如何在我们的代码中表示决策树。我们将继续编写她将进行的每个需求,一旦完成,我们就可以确定她将如何优先排序,并决定哪个需求需要首先处理:

列表中的下一个是能量:

如果索菲感到困倦并且认为这是一个优先事项,她将遵循以下步骤。首先,她感到困倦,然后问自己是否需要工作。如果她有空闲时间,她将做出最终判断,决定是否需要在睡觉前去洗手间。一旦所有状态都得到批准,她最终可以完成目标并去睡觉。我们可以看到以下代码中如何表示这个过程:
void Sleepy ()
{
if(hoursToWork > 3&&Energy < Hygiene)
{
transform.LookAt(Bed); // Face the direction of the Bed
transform.position = vector3.MoveTowards(transform.position.Bed.
position, walkSpeed);
//checks if already triggered the bed position
if(atBed == true)
{
//interact with the bed
}
}
if(hoursToWork > 3 && Energy > Hygiene)
{
useWC(); //Go to the bathroom
}
if(hoursToWork < 3)
{
//choose another thing to do
}
}
假设,对于索菲每睡一小时,她获得+10 点的能量,但失去 10 点的卫生。她首先需要确认她不会在睡眠中需要使用洗手间,为此我们比较她需要的能量点和卫生点:

让我们继续到舒适需求。这个需求有一点特别,因为我们可以在获得舒适点的同时同时设定两个目标。例如,她将能够决定是否想坐在椅子上吃饭。同样的情况也适用于她看电视时。这是一个重要的例子,可以在许多游戏中应用,当角色有机会同时做两件事并且理解这样做的重要性时。在接下来的例子中,我们将考虑这一点:

如果索菲感到不舒服,她首先会检查在那个时刻她是否正在做某事。这个问题只能有两个答案:是或否。如果答案是是,她需要思考是否有可能坐着继续做这件事。否则,她会完成当时正在做的事情,然后开始提出同样的问题。如果可能或可行,她最终会坐下并感到舒适。以下代码示例展示了它应该如何看起来:
private bool isEating;
private bool isWatchingTV;
private bool Busy;
...
void Uncomfortable ()
{
if(isEating == true || isWatchingTV == true)
{
transform.LookAt(Sofa); // Face the direction of the Sofa
transform.position = vector3.MoveTowards(transform.position.
Sofa.position, walkSpeed);
//checks if already triggered the bed position
if(atSofa == true)
{
//interact with the sofa
}
}
else
{
if(Comfort < Overall&& Busy == false)
{
transform.LookAt(Sofa); // Face the direction of the
Sofa
transform.position =
vector3.MoveTowards(transform.position.Sofa.position, walkSpeed);
//checks if already triggered the bed position
if(atSofa == true)
{
//interact with the sofa
}
}
if(Busy == true && isEating == false && isWatchingTV == false)
{
//Keep doing what she is doing at that moment
}
}
}
我们增加了三个变量:isEating、isWatchingTV和Busy。这将帮助她根据这三个值决定最佳选项。如果她感到不舒服但正在吃饭或看电视,她可以同时执行这两个动作。否则,她需要比较其他需求,并判断是否比坐着做其他事情更重要。如果她当时正在做某事且无法坐下——比如说,她正在洗澡或工作——她会忽略自己感到不舒服的事实,一旦索菲有机会,她会坐下并获得舒适度分数:

还剩下两个必要条件来完成这个示例,不久我们就会有一个可以独立生活的 AI 角色,无需有人控制并决定对她最好的事情。我们正在开发一个基于模拟游戏的 AI 系统,但这种方法也可以用于不同类型的游戏。想象一下实时策略游戏,其中工人会做出自主决定,而不是无所事事地等待命令,而是立即开始处理他们认为当时更重要的事情,一旦其他优先事项出现,就切换到另一项工作。
让我们继续下一个目标,卫生。为了简化起见,我们将使用这个需求来代表所有与洗手间相关的事情:

卫生需求比之前的简单;只是问她是否有时间洗澡的问题。我们将在这个问题上使用的一个额外因素是,无论情况如何,去洗手间是最重要的,她会立即暂停正在做的事情,直接走向洗手间。
在洗澡时,她可能做的唯一事情是刷牙或任何其他与卫生标准相关的子段。但就目前而言,让我们尽量减少选项以测试 AI;一旦基本功能正常工作,我们就可以开始添加更多动作。为了原型设计的目的,这也是一种好的方法:首先让基本功能正常工作,然后我们可以继续并逐步添加更多细节。现在让我们看看Hygiene函数的一个示例:
void useBathroom ()
{
if(Hygiene<10)
{
transform.LookAt(Bathroom); // Face the direction of the
Bathroom
transform.position = vector3.MoveTowards(transform.position.
Bathroom.position, walkSpeed);
//checks if already triggered the bed position
if(atBathroom == true)
{
//choose randomly what to do in the bathroom
}
}
}

让我们直接跳到下一个也是最后一个必要条件:乐趣。这个条件将有机会成为最灵活的一个,因为我们可以在吃饭和看电视的同时进行,我们可以坐着看电视,我们可以在看电视的同时坐着吃饭。我们给我们的 AI 提供同时做三件事的选择。乍一看,允许我们的角色在三个不同的必要条件中提高她的分数似乎是理想的,但我们稍后会讨论这个问题。现在,让我们只关注乐趣因素,并规划她将使用的步骤来判断她是否需要并且可以看电视:

如果索菲感到无聊,她会开始问自己是否可以看电视。首先,如果她有空闲时间,她可以自由地看电视,在默认状态下,她坐着看电视。但如果她那时正忙,她需要问自己是否可以在她做任何事的同时看电视(在这个例子中,这代表吃饭、坐着或同时进行)。再次为了简单起见,让我们假设她不需要走到电视前才能打开它。我们可以看到以下是如何在我们的代码中表示这个例子:
private bool isSeat;
private bool televisionOn;
void Bored ()
{
if(Fun<Overall&& Busy == false)
{
televisionOn = true; // turns on the television
transform.LookAt(TV); // Face the direction of the television
transform.position = vector3.MoveTowards(transform.position.
Sofa.position, walkSpeed);
//checks if already triggered the bed position
if(atSofa == true)
{
//interact with the sofa
}
}
if(Fun < Overall && Busy == true)
{
f(isEating == true) {
televisionOn = true; // turns on the television
transform.LookAt(TV); // Face the direction of the
television
}
if(isSeat == true)
{
televisionOn = true; // turns on the television
transform.LookAt(TV); // Face the direction of the television
}
if(isSeat == true && isEating == true)
{
televisionOn = true; // turns on the television
transform.LookAt(TV); // Face the direction of the television
}
}
else()
{
//continue doing what she is doing
}
}
最后,我们使用不同的方法对每个必要行为进行总结。这样做,我们可以确保她知道在游戏的不同情况下应该做什么,并且总是寻求采取行动。AI 设计中存在一些缺陷,我们需要完善;例如,即使她感到饥饿或疲倦,她也不能决定继续看电视。为了实现这一点,我们只需简单地添加一个概率图,并使用Overall变量来定义她是否快乐;比如说,如果她的快乐度超过 50%,即使她感到疲倦,她也可以决定看电视。所有这些限制现在都可以分配给代码,我们可以不断地为我们的角色行为添加更多细节。但,目前,我们只关注两个额外的细节:AI 平衡和动态问题解决。

动态游戏 AI 平衡
另一个关于人工智能发展非常有趣且实用的主题是游戏难度。如果我们与人类玩家对战,游戏的难度将完全取决于与我们对抗的玩家的经验。如果他们在特定的电子游戏中非常熟练,显然他们将对刚开始游戏的玩家拥有更大的优势。通常,电子游戏会逐步增加难度,这样玩家就可以适应它,不会过早地感到沮丧,或者因为游戏没有提供挑战而感到无聊。动态游戏难度平衡被用来解决这个问题,为每位玩家创造有趣的体验。为了使用这种方法平衡 AI 角色,我们考虑一些可以根据玩家经验调整的动态游戏元素;这些属性可以是以下几种:
-
速度
-
生命值
-
魔法
-
力量
通常,我们使用这些属性来定义 AI 角色的难度,调整它们以达到所需的难度。另一种平衡难度的方式是通过调整玩家在游戏中遇到的武器和敌人的数量。在调整难度时,我们还需要小心不要创建出像橡皮筋一样行为的敌人;例如,如果 AI 汽车在玩家后面,它会显著加速以保持对玩家的挑战。当玩家在对手汽车后面时,那辆车会减慢速度,如果不加以调节,这种方法可能会变得乏味。
在一款通用的格斗游戏中,开发者通常这样定义 AI 战斗:如果玩家可触及,AI 使用踢或拳;如果不可触及,它就会朝玩家移动。然后通过使用攻击之间的百分比和时间间隔来调整难度。
例如,在一款 FPS 射击游戏中,游戏 AI 的调整会考虑到开发阶段玩家的表现,那时程序员输入所有与人类玩家整体表现相匹配的 AI 统计数据和战术。例如,如果玩家的射击率约为 70%,AI 角色将使用这个值来保持相对接近人类玩家的表现。
《 Crash Bandicoot》 并没有直接在 AI 角色的行为中使用动态游戏难度平衡,而是在动画速度上,如果玩家在通过关卡时遇到困难,它会减慢速度。这种难度调整是根据玩家死亡次数来进行的。这是一种智能且简单的方法来调整 AI 角色的难度,通过考虑玩家尝试通关时死亡次数。
2005 年由卡普空发布的 《生化危机 4》 基于同样的难度调整原则,但采用了更复杂的系统。调整考虑了玩家的表现,并且玩家在不知情的情况下被游戏从一到十进行评级,其中一表示他们在游戏中不太成功,而十表示他们非常熟练。考虑到这些评级,敌人的行为会有所不同,攻击性更强或更弱,受到的伤害更多或更少。评级会不断更新,许多因素都会被考虑来确定玩家的表现如何,例如他们需要多少子弹来杀死一个僵尸,他们受到了多少次打击,等等。
《左 4 死》 也考虑了玩家的表现情况,但不是仅仅通过增加敌人 AI 的难度,而是决定改变敌人出现的位置,每次玩家选择玩同一关卡时都创造不同的挑战。如果玩家刚开始游戏,敌人会出现在较容易的地方;如果玩家已经通过了那个关卡,敌人会出现在更困难的位置。
为了总结游戏开发者调整角色 AI 难度时所做的选择,我们需要提到,难度并不总是意味着要调整或根据玩家的表现来增加或减少。一个很好的例子是模拟游戏,在这些游戏中,至关重要的是要达到现实生活中的难度,而不是使它们更难或更容易,否则就不会有模拟的感觉。其他例子可以是像《鬼怪猎人》或更近期的《黑暗之魂》这样的游戏,开发者明确选择从始至终使游戏难度很高,而不改变 AI 行为以适应难度。
摘要
在本章中,我们发现了如何使用有限状态机(AFSM)创建能够自主做出决定的 AI 角色。然后我们学习了如何计算概率以及如何结合之前的技术使用它来创建一个能够为其下一步计算更好选择的角色。使用所有这些技术,我们继续讨论如何使用基于效用的函数来创建一个类似人类的角色,使其能够自主行动。最后,我们讨论了调整我们输入角色中的值的多种方法,以使它们与玩家的表现相比保持平衡。在下一章中,我们将深入探讨环境和 AI,考虑到不同类型的视频游戏和不同类型的 AI,AI 应该如何使用地图上的可用空间来为玩家创造挑战,如何与环境互动,如何利用环境来为自己谋利,以及更多。
第四章:环境与 AI
在为视频游戏创建 AI 时,最重要的方面之一是其位置。正如我们之前所发现的,AI 角色的位置可以完全改变其行为和未来的决策。在本章中,我们将探讨游戏环境如何影响我们的 AI,以及他应该如何正确地使用它,深入探讨。这将涵盖具有不同地图结构(如开放世界、街机游戏和赛车)的广泛游戏类型。
作为玩家,我们喜欢有一个生动的世界去探索,有很多事情可以做,可以与之互动。作为游戏开发者或游戏设计师,这通常意味着大量的工作,因为玩家能够与之互动的每一件事都必须仔细规划并正确执行,以避免游戏中的错误或其他不幸的干扰。对于我们所创建的 AI 角色也是如此。如果我们允许角色与环境互动,那需要大量的工作、思考、规划和编码才能使其正常工作。玩家或 AI 可用的选项数量通常等于可能发生的问题数量,因此在我们创建游戏时,我们需要特别关注环境。
并非每个游戏都必然包含地图或地形,但也许当时发生行动的位置对于游戏玩法仍然具有重要意义,AI 应该考虑到这一点。此外,有时环境或定位对游戏角色的微妙影响是我们玩游戏时没有注意到的,但大多数时候,这些微妙的变化有助于良好的游戏体验。这就是为什么在创建视频游戏时,与环境互动是一个重要方面,因为它负责赋予角色生命,没有它,他们就会仅仅停留在简单的 3D 或 2D 模型。
另一方面,我们也不能忘记相反的情况,即游戏角色与环境之间的互动。如果我们的人生可以以视频游戏的形式呈现,我们对环境的影响应该是游戏涉及的一个方面。例如,如果我们把一支烟扔进森林,它有很大概率烧毁一些树叶并引发火灾,导致栖息在那片森林的所有动物都生病,后果会不断加剧。因此,了解环境应该如何根据游戏中的情况做出反应也是非常有趣的。在游戏设计过程中,我们有选择这种互动是否与游戏玩法相关的机会,或者它只是出于视觉目的而存在,但无论如何,它确实为每个人所喜爱的丰富环境做出了贡献。在本章中,我们将有机会深入探讨上述所有选项,并开始探索那些不会改变游戏玩法的基
视觉互动
视觉交互是基本交互,它们不会直接影响游戏玩法,但有助于润色我们的视频游戏和角色,使它们成为我们正在创造的环境的一部分,对玩家的沉浸感有显著贡献。这个主题有无数例子,我们几乎可以在任何类型的游戏中找到它们。这表明环境作为游戏的一部分,而不仅仅是用来填充屏幕的存在,其重要性。在游戏中看到这些类型的交互变得越来越普遍,玩家们也期待着它们。如果游戏中有物体,它应该做些什么,无论重要与否。这使得我们正在创造的环境更加生动和真实,这无疑是件好事。
我们可以在 1986 年发布的原始版《恶魔城》中找到环境交互的第一个例子,这款游戏是为任天堂娱乐系统发布的。从一开始,玩家就可以使用鞭子来摧毁原本作为背景一部分的蜡烛和火坑。

这款游戏和当时发布的几款其他游戏,在关于游戏角色周围背景或环境的感知方面打开了众多大门和可能性。显然,由于这一代游戏机的硬件限制,要创建我们现在视为常见的简单事物变得非常困难。但每一代游戏机都带来了更多的功能,我们这样的创作者也一直在利用这些功能来创造惊人的游戏。
因此,我们第一个视觉交互的例子是背景中可以被摧毁的物体,而不会直接干扰游戏玩法。这种交互可以在多个游戏中看到,它就像编写一个在受到攻击时可以动画化的物体一样简单。然后,我们可以决定物体是否掉落一些分数或可收集物品,以此来奖励玩家探索游戏。现在,我们将转向下一个例子,即游戏中那些在角色经过时动画化或移动的资产。这与可摧毁的物体原理相同,但这次是一种更微妙的交互,需要角色移动到资产所在的位置附近。这可以应用于游戏中的各种事物,从草的移动到灰尘或水,飞走的鸟,或者做出滑稽手势的人;可能性无穷无尽。当我们分析这些交互时,我们可以很容易地确定它们不一定背后有人工智能,大多数时候,它们只是根据某些预定的动作激活的布尔函数。但它们构成了环境的一部分,因此,当我们想要环境与人工智能之间有良好的整合时,我们需要考虑它们。
基本环境交互
正如我们之前所看到的,环境成为了视频游戏体验的一部分,这激发了许多关于未来游戏标题的新概念和想法。下一步是将这些细微的变化整合到游戏玩法中,并利用它们来塑造角色在游戏中的行为方式。这无疑对视频游戏历史做出了积极的贡献,场景中的每一件事物开始获得生命,玩家也开始意识到那些丰富的环境。使用环境来实现游戏中的目标开始成为游戏体验的一部分。

为了展示一个环境对象如何直接影响游戏玩法的例子,我们可以看看《古墓丽影》系列,它完美地展示了这一点。在这个例子中,我们的角色劳拉·克劳馥需要推动立方体,直到它放置在标记区域上方。这将改变环境并解锁一条新路径,允许玩家在关卡中前进。我们可以在许多游戏中找到这种类型的挑战,其中需要触发地图上的特定位置,以便在游戏的另一部分发生某些事情,这可以用来完成游戏中的特定目标。通常,我们需要改变环境,以便在该级别上前进。因此,当我们规划地图或舞台时,我们会考虑这些交互,然后为每个交互创建所有相关的规则。例如:
if(cube.transform.position == mark.transform.position)
{
openDoor = true;
}
现在让我们想象一下,劳拉·克劳馥有一个盟友角色,其主要任务是帮助她将那个盒子放置到位?这正是我们将在本章中探讨的一种交互类型,其中 AI 角色理解环境是如何运作的以及如何使用它。
移动环境对象
让我们直接进入那个场景,尝试重现我们有一个能够帮助玩家实现目标的 AI 角色的情景。为了这个例子,让我们假设我们的玩家被困在一个位置,无法访问释放他的交互式对象。我们将创建的角色需要能够找到立方体并将其推向目标位置。

因此,现在我们已经在我们的环境示例中设置了所有角色和对象,让我们计划 AI 角色在这个情况下的行为。首先,他需要看到玩家在附近,这样他就可以开始搜索并将立方体移动到正确的位置。让我们假设,如果立方体在那个标记上,一块新的方块将从沙子中升起,允许玩家在关卡中前进。AI 角色可以将立方体推向四个不同的方向,左、右、前和后,确保它与位置标记完美对齐。

人工智能角色需要质疑和验证在行为树中之前展示的每一个动作。为了继续执行目标,最重要的是角色必须确信玩家已经站在他的标记位置上。如果玩家还没有到达,我们的角色需要等待并保持位置。如果玩家已经到达,人工智能角色将继续前进并自问自己是否靠近立方体对象。如果不是,我们的角色需要走向立方体,一旦该动作得到验证,他将会再次提出相同的问题。一旦答案是肯定的,并且角色靠近立方体,他需要计算立方体需要首先推向哪个方向。然后他将朝着Y轴或X轴推立方体,直到立方体与标记位置对齐,目标得以完成。
public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform currentPlayerPosition;
public Transform currentCubePosition;
public float proximityValueX;
public float proximityValueY;
public float nearValue;
private bool playerOnMark;
void Start () {
}
void Update () {
// Calculates the current position of the player
currentPlayerPosition.transform.position = playerMesh.transform.position;
// Calculates the distance between the player and the player mark of the X axis
proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;
// Calculates the distance between the player and the player mark of the Y axis
proximityValueYplayerMark.transform.position.y - currentPlayerPosition.transform.position.y;
// Calculates if the player is near of his MARK POSITION
if((proximityValueX + proximityValueY) < nearValue)
{
playerOnMark = true;
}
}
我们开始将信息添加到我们的代码中,允许角色验证玩家是否靠近他的标记位置。为此,我们创建了所有必要的变量来计算玩家和需要到达的位置的距离。playerMesh指的是玩家的 3D 模型,我们将从中提取位置并用作currentPlayerPosition。为了知道玩家是否靠近他的标记,我们需要一个表示标记位置的变量,在这个例子中,我们创建了playerMark变量,我们可以写下我们希望玩家所在的位置。然后我们添加了三个变量,使我们能够知道玩家是否靠近。proximityValueX将计算玩家在 X 轴上与标记之间的距离。proximityValueY将计算玩家在 Y 轴上与标记之间的距离。然后我们有nearValue,我们可以定义玩家离标记位置多远,这样我们的 AI 角色就可以开始处理目标。一旦玩家靠近他的标记,playerOnMark布尔值将变为 true。
为了计算玩家和他的标记之间的距离,我们使用了以下公式:玩家和他的标记之间的距离等于(标记位置 - 玩家位置)。
现在,为了发现人工智能角色是否靠近立方体,我们将执行相同的方程,计算人工智能与立方体之间的距离。此外,我们已经完成了以下代码,其中包含了两个标记(玩家和立方体标记)的位置:
public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform currentPlayerPosition;
public Transform currentCubePosition;
public float proximityValueX;
public float proximityValueY;
public float nearValue;
public float cubeProximityX;
public float cubeProximityY;
public float nearCube;
private bool playerOnMark;
private bool cubeIsNear;
void Start () {
Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
nearValue = 0.5f;
nearCube = 0.5f;
}
void Update () {
// Calculates the current position of the player
currentPlayerPosition.transform.position = playerMesh.transform.position;
// Calculates the distance between the player and the player mark of the X axis
proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;
// Calculates the distance between the player and the player mark of the Y axis
proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;
// Calculates if the player is near of his MARK POSITION
if((proximityValueX + proximityValueY) < nearValue)
{
playerOnMark = true;
}
cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;
if((cubeProximityX + cubeProximityY) < nearCube)
{
cubeIsNear = true;
}
else
{
cubeIsNear = false;
}
}
现在,我们的 AI 角色知道他是否靠近立方体,这将回答问题并确定他是否可以继续到我们计划中的下一个分支。但是,当我们的角色不靠近立方体时会发生什么?他将需要走向立方体。因此,我们将这一点添加到我们的代码中:
public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform cubeMesh;
public Transform currentPlayerPosition;
public Transform currentCubePosition;
public float proximityValueX;
public float proximityValueY;
public float nearValue;
public float cubeProximityX;
public float cubeProximityY;
public float nearCube;
private bool playerOnMark;
private bool cubeIsNear;
public float speed;
public bool Finding;
void Start () {
Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
nearValue = 0.5f;
nearCube = 0.5f;
speed = 1.3f;
}
void Update () {
// Calculates the current position of the player
currentPlayerPosition.transform.position = playerMesh.transform.position;
// Calculates the distance between the player and the player mark of the X axis
proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;
// Calculates the distance between the player and the player mark of the Y axis
proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;
// Calculates if the player is near of his MARK POSITION
if((proximityValueX + proximityValueY) < nearValue)
{
playerOnMark = true;
}
cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;
if((cubeProximityX + cubeProximityY) < nearCube)
{
cubeIsNear = true;
}
else
{
cubeIsNear = false;
}
if(playerOnMark == true && cubeIsNear == false && Finding == false)
{
PositionChanging();
}
if(playerOnMark == true && cubeIsNear == true)
{
Finding = false;
}
}
void PositionChanging () {
Finding = true;
Vector3 positionA = this.transform.position;
Vector3 positionB = cubeMesh.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}
到目前为止,我们的 AI 角色能够计算出自己与立方体之间的距离;如果它们相距太远,他就会向立方体移动。一旦这个任务完成,他就可以进入下一阶段,开始推动立方体。他最后需要计算的是立方体与标记位置的距离,然后根据每个侧面距离的远近决定先推动哪一侧。

立方体只能在X轴或Z轴上推动,并且目前旋转并不相关,因为按钮在立方体位于其上方时被激活。考虑到这一点,我们的角色 AI 需要计算出立方体与X 标记位置和Z 标记位置的距离。然后,他将比较两个不同的轴值,并选择离期望位置更远的那一侧开始推动。角色将朝这个方向推动,直到立方体与标记位置对齐,然后切换到另一侧,并推动它直到完全位于标记位置上方:
public GameObject playerMesh;
public Transform playerMark;
public Transform cubeMark;
public Transform cubeMesh;
public Transform currentPlayerPosition;
public Transform currentCubePosition;
public float proximityValueX;
public float proximityValueY;
public float nearValue;
public float cubeProximityX;
public float cubeProximityY;
public float nearCube;
public float cubeMarkProximityX;
public float cubeMarkProximityZ;
private bool playerOnMark;
private bool cubeIsNear;
public float speed;
public bool Finding;
void Start () {
Vector3 playerMark = new Vector3(81.2f, 32.6f, -31.3f);
Vector3 cubeMark = new Vector3(81.9f, -8.3f, -2.94f);
nearValue = 0.5f;
nearCube = 0.5f;
speed = 1.3f;
}
void Update () {
// Calculates the current position of the player
currentPlayerPosition.transform.position = playerMesh.transform.position;
// Calculates the distance between the player and the player mark of the X axis
proximityValueX = playerMark.transform.position.x - currentPlayerPosition.transform.position.x;
// Calculates the distance between the player and the player mark of the Y axis
proximityValueY = playerMark.transform.position.y - currentPlayerPosition.transform.position.y;
// Calculates if the player is near of his MARK POSITION
if((proximityValueX + proximityValueY) < nearValue)
{
playerOnMark = true;
}
cubeProximityX = currentCubePosition.transform.position.x - this.transform.position.x;
cubeProximityY = currentCubePosition.transform.position.y - this.transform.position.y;
if((cubeProximityX + cubeProximityY) < nearCube)
{
cubeIsNear = true;
}
else
{
cubeIsNear = false;
}
if(playerOnMark == true && cubeIsNear == false && Finding == false)
{
PositionChanging();
}
if(playerOnMark == true && cubeIsNear == true)
{
Finding = false;
}
cubeMarkProximityX = cubeMark.transform.position.x - currentCubePosition.transform.position.x;
cubeMarkProximityZ = cubeMark.transform.position.z - currentCubePosition.transform.position.z;
if(cubeMarkProximityX > cubeMarkProximityZ)
{
PushX();
}
if(cubeMarkProximityX < cubeMarkProximityZ)
{
PushZ();
}
}
void PositionChanging () {
Finding = true;
Vector3 positionA = this.transform.position;
Vector3 positionB = cubeMesh.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}
在将最终动作添加到我们的代码后,我们的 AI 角色应该能够完成其目标,并找到并推动立方体到期望的位置,这样玩家就可以继续前进并完成关卡。在这个例子中,我们关注了如何计算场景中物体与角色之间的距离。这将有助于创建需要将物体放置在游戏中的特定位置以产生类似类型的交互。
该示例演示了一个协助玩家的盟军 AI 角色,但如果想要产生相反的效果(成为敌人),角色则需要尽可能快地找到立方体以阻止玩家。
阻碍性环境物体
如我们之前所看到的,我们可以使用或移动游戏中的物体来实现目标,但如果角色被物体阻挡了道路怎么办?这个物体可能是玩家放置的,或者简单地设计成在地图的该位置,无论哪种情况,AI 角色都应该能够确定在这种情况下应该做什么。
我们可以在 Ensemble Studios 开发的策略游戏《帝国时代 II》中观察到这种行为。每次游戏中的角色因为被包围的加固城墙而无法进入敌方领土时,AI 角色就会集中精力开始摧毁城墙的一部分,以便进入。这种交互方式非常聪明且很重要,因为否则它们只会围绕城墙寻找入口,这看起来并不智能。由于加固城墙是由玩家创建的,它可以放置在任何地方,具有任何形状或形式,因此,在开发 AI 对手时,有必要考虑这一点。

这个例子也很有意义,因为在规划阶段,当我们创建行为树时,我们需要考虑如果有什么东西挡在角色面前,他无法完成他的目标会发生什么。这将在本书的下一章中深入探讨,但到目前为止,我们将简化这种情况,并分析如果环境对象干扰了角色的目标,AI 角色应该如何表现。

在我们的例子中,AI 角色需要进入房子,但当他到达附近时,意识到它被木栅栏包围,他无法通过。在那个时刻,我们希望角色选择一个目标并开始攻击,直到栅栏的这一部分被摧毁,这样他才能找到进入房子的方法。
对于这个例子,我们需要计算角色需要攻击哪个栅栏,考虑到距离和栅栏当前的健康状态。HP 低的栅栏应该比满 HP 的栅栏有更高的优先级被首先攻击,因此我们将将其包含在我们的计算中。

我们希望定义一个围绕角色的圆周,最近的栅栏将向 AI 提供信息,以便他可以决定哪个是最容易摧毁的。这可以通过不同的方法完成,要么通过使用由玩家触发的栅栏上的碰撞检测,要么让它们计算栅栏/对象与玩家之间的距离;我们定义一个距离值,玩家可以感知栅栏的条件。对于这个例子,我们将计算距离并使用它来提醒角色栅栏的 HP。
让我们先创建将在fence对象上实现的代码;所有这些都将使用以下相同的脚本:
public float HP;
public float distanceValue;
private Transform characterPosition;
private GameObject characterMesh;
private float proximityValueX;
private float proximityValueY;
private float nearValue;
// Use this for initialization
void Start () {
HP = 100f;
distanceValue = 1.5f;
// Find the Character Mesh
characterMesh = GameObject.Find("AICharacter");
}
// Update is called once per frame
void Update () {
// Obtain the Character Mesh Position
characterPosition = characterMesh.transform;
//Calculate the distance between this object and the AI Character
proximityValueX = characterPosition.transform.position.x - this.transform.position.x;
proximityValueY = characterPosition.transform.position.y - this.transform.position.y;
nearValue = proximityValueX + proximityValueY;
}
在这个脚本中,我们添加了关于 HP 和距离的基本信息,这些信息将用于连接 AI 角色。这次,我们将计算距离的脚本添加到environment对象中,而不是角色中;这为对象增加了更多动态性,并允许我们用它创建更多事物。例如,如果游戏中的角色负责创建栅栏,它们将具有不同的状态,如目前正在建造、已完成和损坏;然后角色将接收这些信息并利用它来达到自己的目的。
让我们继续并定义我们的 AI 角色,以便与environment对象交互。他的主要目标是进入房子,但当他到达附近时,他意识到他无法进入,因为房子被木栅栏包围。在分析情况后,我们希望我们的角色摧毁一个栅栏,这样他最终可以完成他的目标并进入房子。
在角色脚本中,我们将添加一个static函数,围栏可以输入它们当前的健康信息;这将帮助 AI 角色选择一个更好的围栏来摧毁。
public static float fenceHP;
public static float lowerFenceHP;
public static float fencesAnalyzed;
public static GameObject bestFence;
private Transform House;
private float timeWasted;
public float speed;
void Start () {
fenceHP = 100f;
lowerFenceHP = fenceHP;
fencesAnalyzed = 0;
speed = 0.8;
Vector3 House = new Vector3(300.2f, 83.3f, -13.3f);
}
void Update () {
timeWasted += Time.deltaTime;
if(fenceHP > lowerFenceHP)
{
lowerFenceHP = fenceHP;
}
if(timeWasted > 30f)
{
GoToFence();
}
}
void GoToFence() {
Vector3 positionA = this.transform.position;
Vector3 positionB = bestFence.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB, Time.deltaTime * speed);
}

我们已经将基本信息添加到了我们的角色中。fenceHP将是一个静态变量,其中每个由角色触发的围栏都会提供它们当前 HP 的信息。然后 AI 角色分析收集到的信息,并将其与表示为lowerFenceHP的最低 HP 围栏进行比较。角色有一个timeWasted变量,表示他已经花费在寻找一个好的围栏来攻击上的秒数。fencesAnalyzed将用来知道代码中是否已经存在围栏,如果没有,它将添加他找到的第一个围栏;如果围栏具有相同的 HP 值,角色将首先攻击它们。现在让我们更新我们的围栏代码,以便它们可以访问角色脚本并输入一些有用的信息。
public float HP;
public float distanceValue;
private Transform characterPosition;
private GameObject characterMesh;
private float proximityValueX;
private float proximityValueY;
private float nearValue;
void Start () {
HP = 100f;
distanceValue = 1.5f;
// Find the Character Mesh
characterMesh = GameObject.Find("AICharacter");
}
void Update () {
// Obtain the Character Mesh Position
characterPosition = characterMesh.transform;
//Calculate the distance between this object and the AI Character
proximityValueX = characterPosition.transform.position.x - this.transform.position.x;
proximityValueY = characterPosition.transform.position.y - this.transform.position.y;
nearValue = proximityValueX + proximityValueY;
if(nearValue <= distanceValue){
if(AICharacter.fencesAnalyzed == 0){
AICharacter.fencesAnalyzed = 1;
AICharacter.bestFence = this.gameObject;
}
AICharacter.fenceHP = HP;
if(HP < AICharacter.lowerFenceHP){
AICharacter.bestFence = this.gameObject;
}
}
}
我们最终完成了这个例子,其中围栏将它们当前的 HP 与角色拥有的数据(lowerFenceHP)进行比较,如果它们的 HP 低于角色拥有的最低值,那么这个围栏将被认为是bestFence。
这个例子展示了如何使 AI 角色适应游戏中的不同动态对象;同样的原则可以扩展并用于与几乎任何对象交互。同时,使用对象与角色交互也是相关且有用的,这有助于在两者之间建立信息联系。
按区域分解环境
当我们创建地图时,通常会有两个或更多不同的区域可以用来改变游戏玩法,这些区域可能包含水域、流沙、飞行区域、洞穴等等。如果我们希望创建一个可以在游戏任何级别和任何地方使用的 AI 角色,我们需要考虑这一点并使 AI 意识到地图的不同区域。通常这意味着我们需要将更多信息输入到角色的行为中,包括如何根据他当前所在的位置做出反应,或者他可以选择去往何处的情境。
他应该避免某些区域吗?他应该更喜欢其他区域吗?这类信息是相关的,因为它使角色意识到周围环境,选择或适应并考虑他的位置。如果没有正确规划,可能会导致一些不自然的决策;例如,在 Bethesda Softworks 工作室开发的《上古卷轴 V:天际》游戏中,我们可以看到一些游戏 AI 角色在不知道如何在地图的某些部分(尤其是山脉或河流)行为时,会简单地转身返回。

根据角色发现的区域,他可能会有不同的反应或更新他的行为树以适应环境。我们之前创建了一个士兵,他会根据健康状况、瞄准成功率和玩家健康状况改变他的反应方式,现在我们正在探索环境,以便角色可以利用它来更好地定义应该做什么。我们也可以用这个来更新我们之前的例子,即现实生活模拟。如果 Sofie 角色进入房子的一个特定区域,她可以利用这些信息来更新她的优先级,并补充与该部分房屋相关的一切必需品。比如说,她如果在厨房,一旦她准备好了早餐,她就会趁机把垃圾拿出来。正如我们所见,围绕我们的角色的环境可以重新定义他们的优先级或完全改变他们的行为。
这与让-雅克·卢梭关于人性的说法有些相似:“我们天生善良,但被社会腐蚀。”作为人类,我们是周围环境的代表,因此,人工智能应该遵循同样的原则。
让我们选择一个我们已经创建的之前的角色,并更新他的代码以适应不同的场景。这个例子中我们选择的是士兵,我们希望根据三个不同的区域——海滩、河流和森林——来改变他的行为。因此,我们将创建三个名为Beach、Forest和River的公共静态布尔函数;然后我们定义地图上的区域,这些区域将开启或关闭这些区域。
public static bool Beach;
public static bool River;
public static bool Forest;
因为在这个例子中,一次只能有一个选项为真,所以我们将添加一行简单的代码来禁用其他选项,一旦其中一个被激活。
if(Beach == true)
{
Forest = false;
River = false;
}
if(Forest == true){
Beach = false;
River = false;
}
if(River == true){
Forest = false;
Beach = false;
}
一旦完成这些,我们就可以开始定义每个区域的不同行为。例如,在海滩区域,角色没有地方可以躲避,所以这个选项需要被移除并更新为一个新的选项。河流区域可以用来穿越到另一边,因此角色可以隐藏在玩家视线之外并从那个位置发起攻击。总结来说,我们可以定义角色更加小心,并利用树木来作为掩护。根据不同的区域,我们可以调整值以更好地适应环境,或者创建新的功能,以便我们可以利用该区域的一些特定特性。
if (Forest == true)
{// The AI will remain passive until an interaction with the player occurs
if (Health == 100 && triggerL == false && triggerR == false && triggerM == false)
{
statePassive = true;
stateAggressive = false;
stateDefensive = false;
}
// The AI will shift to the defensive mode if player comes from the right side or if the AI is below 20 HP
if (Health <= 100 && triggerR == true || Health <= 20)
{
statePassive = false;
stateAggressive = false;
stateDefensive = true;
}
// The AI will shift to the aggressive mode if player comes from the left side or it's on the middle and AI is above 20HP
if (Health > 20 && triggerL == true || Health > 20 && triggerM == true)
{
statePassive = false;
stateAggressive = true;
stateDefensive = false;
}
walk = speed * Time.deltaTime;
walk = speedBack * Time.deltaTime;
}
这个部分将在讨论人工智能规划与决策以及战术和意识时进行深入探讨。
高级环境交互
随着视频游戏行业及其相关技术的不断发展,新的游戏玩法理念出现,并且游戏角色与环境之间的互动变得更有趣,尤其是在使用物理引擎时。这意味着环境的结果可能是完全随机的,这就要求 AI 角色能够不断适应不同的情况。在这方面值得特别一提的是由 Team17 开发的视频游戏Worms,在该游戏中,地图可以被完全摧毁,游戏中的 AI 角色能够适应并保持明智的决策。

本游戏的目的是通过杀死所有对手的虫子来摧毁对手团队,最后存活下来的玩家获胜。从游戏开始,角色可以在地图上找到一些额外的生命值或弹药,并且时不时地从天空掉落更多的点数。因此,角色的两个主要目标就是生存和杀敌。为了生存,他需要保持足够的生命值并远离敌人,另一部分则是选择最佳的射击目标并尽可能多地从他那里获取生命值。同时,地图被炸弹和角色使用的所有火力摧毁,这对人工智能来说是一个挑战。
适应不稳定地形
让我们分解这个例子,并创建一个可以用于这个游戏的角色。我们首先从查看地图开始。底部是水,它会自动杀死虫子。然后,我们有地形,虫子可以在上面行走或根据需要摧毁。最后,是地形的缺失,具体来说,是空旷的空间,无法在上面行走。然后是角色(虫子),它们在游戏开始时被放置在随机的位置,并且可以行走、跳跃和射击。

游戏角色的特性应该能够不断适应地形的不稳定性,因此我们需要利用这一点并将其作为行为树的一部分。如图上所示,角色需要了解他当前所在的位置,以及对手的位置、健康状况和物品。
由于地形可能会阻挡他们,AI 角色有可能处于无法攻击或获取物品的情况。因此,我们为他提供了在这些情况下以及他可能遇到的其他情况下的行动选项,但最重要的是定义如果他无法成功完成任何一项行动会发生什么。因为地形可以形成不同的形状,在游戏过程中,有时几乎不可能做任何事情,这就是为什么我们需要提供在这些情况下可以采取的行动选项。

例如,在这种情况下,虫子没有足够的空间移动,没有可以捡起的东西,或者没有可以正确攻击的敌人,他应该怎么办?有必要让周围的信息对我们角色可用,以便他可以对那种情况做出良好的判断。在这种情况下,我们定义了我们的角色无论如何都要射击,对抗最近的敌人,或者靠近墙壁。因为他离最近的敌人攻击可能发生的爆炸太近,所以他应该决定待在角落里,直到下一回合。
使用射线投射评估决策

理想情况下,在回合开始时,角色有两个射线投射,一个用于他的左侧,另一个用于右侧。这将检查是否有墙壁阻挡了其中一个方向。这可以用来确定如果角色想要保护自己免受攻击,他应该朝哪个方向移动。然后,我们会使用另一个射线投射在瞄准方向,以查看当角色准备射击时是否有东西阻挡了道路。如果中间有东西,角色应该计算两者之间的距离,以确定是否仍然安全射击。
因此,每个角色都应该有一个共享的列表,列出游戏中当前所有的虫子;这样他们可以比较它们之间的距离,并选择哪个最近并射击它们。此外,我们添加了两个射线投射来检查是否有东西阻挡了两侧,并且我们有了基本的信息来使角色适应地形的持续修改。
public int HP;
public int Ammunition;
public static List<GameObject> wormList = new List<GameObject>();
//creates a list with all the worms
public static int wormCount; //Amount of worms in the game
public int ID; //It's used to differentiate the worms
private float proximityValueX;
private float proximityValueY;
private float nearValue;
public float distanceValue; //how far the enemy should be
private bool canAttack;
void Awake ()
{
wormList.Add(gameObject); //add this worm to the list
wormCount++; //adds plus 1 to the amount of worms in the game
}
void Start ()
{
HP = 100;
distanceValue = 30f;
}
void Update ()
{
proximityValueX = wormList[1].transform.position.x - this.transform.position.x;
proximityValueY = wormList[1].transform.position.y - this.transform.position.y;
nearValue = proximityValueX + proximityValueY;
if(nearValue <= distanceValue)
{
canAttack = true;
}
else
{
canAttack = false;
}
Vector3 raycastRight = transform.TransformDirection(Vector3.forward);
if (Physics.Raycast(transform.position, raycastRight, 10))
print("There is something blocking the Right side!");
Vector3 raycastLEft = transform.TransformDirection(Vector3.forward);
if (Physics.Raycast(transform.position, raycastRight, -10))
print("There is something blocking the Left side!");
}
摘要
在本章中,我们探讨了与环境交互的不同方式。本章中展示的技术可以扩展到广泛的游戏类型,并用于实现 AI 角色与环境之间从基本到高级的交互。现在我们了解了如何创建 AI 角色可以使用的交互式和动态对象,这将使每次游戏都成为全新的体验。此外,我们还简要地触及了一些将在下一章深入探讨的相关主题,例如与其他游戏 AI 角色的交互和决策制定。
在我们下一章中,我们将讨论动画行为。动画构成了玩家对我们创建的人工智能角色视觉感知的一部分,并且使用它来展示我们的 AI 行为多么逼真是非常重要的。我们将讨论动画图、游戏和动画、动画行为和动画架构。
第五章:动画行为
当我们想到人工智能时,通常我们会想象智能机器人,能够完美执行大量动作的机械物体,对于视频游戏人工智能也是如此。我们倾向于认为对手或盟友会行动、反应、思考或做出许多智能决策,这是正确的,但通常还有一个更重要的方面被忽略了,那就是动画。为了创建可信和逼真的 AI 角色,动画是最重要的方面之一。动画定义了视觉交互,即角色做某事时的样子。为了让角色看起来可信,动画和功能机制一样重要。在本章中,我们将探讨一些有用的技术和解决方案,用于使用、重用和创建与角色行为无缝匹配的动画。我们创建和使用动画的方式对玩家和 AI 角色都是一样的,但我们将重点关注如何将动画与我们已经学习过的技术相结合来创建 AI。
2D 动画与 3D 动画
视频游戏动画可以分为两种类型,2D 动画和 3D 动画。两者都有独特的特点,我们在开发游戏时需要考虑这一点,并利用它们的优势。让我们来看看这两种类型之间的一些主要区别。
2D 动画 - 图像精灵
一旦控制台和计算机允许开发者将动画集成到他们制作的视频游戏中,游戏变得更加丰富,依赖于美观的视觉效果来表现角色的动作。这也为创造新的游戏类型或更新旧的游戏类型打开了大门,使它们更具吸引力,从那时起,几乎每款游戏都开始实施动画。
在视频游戏中使用的 2D 动画过程与迪士尼过去用来制作电影的过程相似。他们会绘制和上色电影的每一帧,每秒大约有 12 帧。当时游戏不能使用现实生活中的绘画,但它们可以使用坐标来绘制游戏的每一部分,使其看起来像人或动物。其余的过程大致相同。他们需要这样做,以便创建动画的每一帧,但由于这是一个艰难且漫长的过程,他们有更少的细节和复杂性。现在他们有了所有必要的帧来动画化一个角色,这就需要编程机器以特定的顺序读取这些帧,只使用属于角色正在执行的动作的帧。

在前一个图中,我们可以看到一个 8 位时代的例子,展示了名为马里奥的超级马里奥兄弟角色的每一个动画。正如我们所见,有跑步动画、跳跃、游泳、死亡、停止和蹲下,其中一些只是单个帧。平滑过渡并没有立即出现,动画被结合到游戏玩法中。因此,如果我们想在游戏中包含更多动画,就需要创建更多帧。动画的复杂性也是如此;如果我们想让动画包含更多细节,就需要创建更多帧和过渡帧。这使得创建动画的过程非常漫长,但随着硬件能力的进化,这个过程开始变得实施起来所需的时间更少,而且结果也变得闻名。
2D 动画在视频游戏中的能力的一个例子是 1989 年发布的波斯王子(以下精灵表显示了波斯王子中角色的动画)。通过使用现实世界中人物进行游戏动作的参考,质量、细节和平滑过渡都令人惊叹,甚至为下一代游戏提高了标准。因此,在这个时候,游戏开发者开始担心过渡、平滑动画以及如何在不增加精灵表中的帧的情况下创建大量动作:

今天,我们仍然在 2D 游戏中使用相同的过程,我们有一个包含所有我们想要的动画的精灵表,然后我们编码在角色执行动作的同时使它们动画化。与使用骨骼结构的 3D 动画相比,使用精灵表的工作并不那么灵活,但有一些有用的技巧我们可以使用来创建平滑过渡,并将代码动画与游戏玩法代码分开。
3D 动画 - 骨骼结构
使用 3D 模型和 3D 动画来创建游戏是目前一个非常流行的选择,其中一个主要原因是与创建它们所需的时间有关。我们只需要创建一次 3D 模型,然后我们可以实现一个骨骼结构来按我们的意愿动画化它。我们还可以使用相同的骨骼结构,将其皮肤应用到另一个 3D 模型上,它将获得与之前相同的动画。使用 3D 动画对于大型项目来说显然很方便,可以节省数小时的工作,并允许我们更新角色而无需重新创建它。这是由于角色的骨骼结构,它帮助我们提高动画质量,节省时间和资源。正因为如此,我们可以决定只动画化一个特定的区域,而让身体的其余部分完全静止或执行其他动画。从一种动画平滑过渡到另一种动画,或者同时播放两个动画非常有用:

主要区别
图精灵与骨骼结构是两种动画类型之间的两个主要区别,这将改变我们将动画与游戏玩法集成的整合方式。使用图精灵,我们坚持使用我们拥有的图像数量,并且在代码中无法改变它们的外观,而使用骨骼结构,我们可以定义我们想要动画化的角色的哪一部分,并且我们可以使用物理来根据情况塑造动画。
最近,有一些新选项允许我们在 2D 模型中实现类似于骨骼结构的类似技术,但与我们在 3D 中能做的相比,这仍然是一个非常有限的选项。
动画状态机
我们已经讨论了行为状态,其中我们定义了角色的可能动作以及如何将它们链接起来。动画状态机是一个非常类似的过程,但我们不是定义动作,而是定义角色的动画。在开发角色和创建行为状态时,我们可以在动作代码中分配动画,定义角色何时开始奔跑,一旦发生,行走动画停止,奔跑动画开始播放。这种将动画与游戏玩法代码集成的做法看起来是一个更容易的方法来做这件事,但这并不是最好的方法,如果我们想要更新代码,它会变得复杂。
解决这个问题的方法是创建一个专门用于动画的独立状态机。这将使我们能够更好地控制动画,而不用担心更改我们的角色代码。这对于程序员和动画师之间的交互也是一个好方法,因为动画师可以在动画状态机中添加更多动画,而不会干扰代码:

在前面的图中,我们可以看到一个行为状态机的简单示例,其中角色可以静止、移动、跳跃和使用梯子。一旦这部分完成,我们就可以开始设计和实现一个动画状态机,使其根据行为状态机的原则工作:

如我们所见,动画状态比行为状态要多,这就是为什么将动画集成到我们的角色中最好的方法是分离游戏玩法和动画。在开发我们的游戏时,我们使用语句和值,所以行走和奔跑之间的唯一区别是定义角色移动速度的数字。这就是为什么我们需要使用动画状态将此信息转换为视觉输出,其中角色根据游戏玩法状态进行动画。使用这种方法并不意味着我们不能使用动画来干扰游戏玩法,因为我们也可以通过简单地向我们的游戏玩法代码报告信息并更改游戏玩法来实现这一点。
这可以用于 2D 和 3D 动画。过程完全相同,并且可以与最流行的游戏引擎一起使用,例如 CryENGINE、Unity 和 Unreal Development Kit。为了使其工作,我们需要将所有动画导入到我们的游戏中,然后我们将动画分配到动画状态部分:

现在,我们已经将动画导入到动画状态部分,我们需要根据我们在代码中使用的值来配置动画播放的时间。我们可以使用的值或语句是整数、浮点数、布尔值和触发器。有了这些,我们可以定义每个动画何时播放。在链接动画时,我们将使用这些值来确定何时从一个动画状态切换到另一个状态:

这是我们定义行走和奔跑之间差异的地方。如果我们的角色移动速度达到某个值,它将开始播放奔跑动画,一旦该值降低,它将再次播放行走动画。
我们可以拥有我们想要的任意数量的动画状态,无论游戏状态如何。让我们看看运动学示例。我们可以定义,如果角色移动非常缓慢,它会像潜行一样进行动画;稍微快一点,它就开始行走;更快的话,角色开始奔跑;最终,如果移动速度非常高,他可以长出一对翅膀,给人一种他在飞行的印象。正如我们所见,将动画与游戏玩法分开会更方便,因为这样我们可以更改动画,删除它们,或添加新的动画,而无需修改我们的代码。
现在让我们继续到示例部分。使用我们在前几章中探索的所有主题,我们将配置我们的角色根据游戏玩法行为和环境进行动画处理。我们首先将模型和所有动画导入到我们的游戏中。然后我们创建一个新的动画状态机;在这种情况下,它被称为animator。之后,我们只需将那个动画状态机分配给我们的角色:

我们导入到游戏中的模型理想状态下应该是中性的姿势,例如 T 姿势(如前一张截图所示)。然后我们导入动画并将它们添加到 Animator Controller 中。

现在,如果我们点击角色并打开我们创建的动画状态机,它将是空的。这是正常的,因为我们需要手动添加我们想要使用的动画:

一旦我们完成这项工作,我们就需要组织好一切,以便轻松地链接动画:

因此,我们根据游戏状态(如IDLE、ATTACK、JUMP、LOCOMOTION和DAMAGE)将不同的动画分开,如图所示。对于IDLE状态,我们有两个不同的动画,对于ATTACK状态也有另外两个。我们希望它们以随机顺序播放,并且与游戏代码分开,这样我们就可以添加尽可能多的动画来增加多样性。在移动状态内部,我们有两组独立的动画,分别是STRAIGHT行走和CROUCH蹲下。我们选择将这两组动画都包含在移动状态中,因为它们将根据移动摇杆的位置进行动画处理。
现在,我们可以开始链接动画,在这个阶段,我们可以忘记动画是如何被激活的,而只关注播放顺序:

一旦我们将所有动画以所需的顺序链接起来,我们就可以开始定义它们将如何播放。在这个阶段,我们需要查看角色代码并使用变量来更改动画。在我们的代码中,我们有访问动画状态机的变量。在这种情况下,它们是Animator、Health和Stamina整数值,movementSpeed、rotationSpeed、maxSpeed、jumpHeight、jumpSpeed和currentSpeed浮点值,以及最后用于检查玩家是否存活的布尔变量:
public Animator characterAnimator;
public int Health;
public int Stamina;
public float movementSpeed;
public float rotationSpeed;
public float maxSpeed;
public float jumpHeight;
public float jumpSpeed;
private float currentSpeed;
private bool Dead;
void Start () {
}
void Update () {
// USING XBOX CONTROLLER
transform.Rotate(0,Time.deltaTime * (rotationSpeed *
Input.GetAxis ("xboxlefth")), 0);
if(Input.GetAxis ("xboxleft") > 0){
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed);
}
else{
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed/3);
}
if(Input.GetKeyDown("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyDown("joystick button 16") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 16") && Dead == false)
{
}
if(Health <= 0){
Dead = true;
}
}
让我们开始将这些值传递到动画状态机中。角色的移动和currentSpeed值由左侧模拟摇杆控制,所以如果我们稍微推动摇杆,角色应该播放行走动画。如果我们完全推动它,它应该播放跑步动画。

在Animator部分,我们可以选择四个参数之一,对于角色的移动,我们选择了 Float。现在我们需要将这个值与代码中存在的currentSpeed变量链接起来。我们将在Update函数的开始处进行赋值:
public Animator characterAnimator;
public int Health;
public int Stamina;
public float movementSpeed;
public float rotationSpeed;
public float maxSpeed;
public float jumpHeight;
public float jumpSpeed;
private float currentSpeed;
private bool Dead;
void Start () {
}
void Update () {
// Sets the movement speed of the animator, to change from
idle to walk and walk to run
characterAnimator.SetFloat("currentSpeed",currentSpeed);
// USING XBOX CONTROLLER
transform.Rotate(0,Time.deltaTime * (rotationSpeed *
Input.GetAxis ("xboxlefth")), 0);
if(Input.GetAxis ("xboxleft") > 0){
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed);
}
else{
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed/3);
}
if(Input.GetKeyDown("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyDown("joystick button 16") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 16") && Dead == false)
{
}
if(Health <= 0){
Dead = true;
}
}
我们已经连接了这两个参数。这样,动画状态机就可以使用代码中找到的currentSpeed的相同值。我们在Animator部分给它取的名字与代码中的完全一样。这不是必需的,但它使得理解它们代表什么值变得更容易。

因此,在这个阶段,我们可以开始定义连接角色移动动画的链接值。在这种情况下,我们可以点击链接,将打开一个新窗口,以便我们可以添加将动画从一个状态切换到另一个状态的值:

我们也可以点击我们想要配置的动画,例如闲置动画,然后会打开一个新窗口,显示与该动画连接的所有动画。我们可以选择允许播放下一个动画的链接。以下截图展示了这一过程:

我们点击了闲置以行走,并添加了我们之前创建的条件,currentSpeed:

在这里,我们可以选择值是否需要大于或小于期望的值以开始播放下一个动画。对于这个例子,我们将值设置为大于 0.1,所以一旦角色开始移动,它就会停止闲置动画并播放行走动画。我们不需要在代码中写任何内容,因为动画状态机独立于代码工作:

然而,因为我们还有另一个动画在行走动画之后播放,我们需要为行走动画设置一个限制值。在这种情况下,让我们假设当currentSpeed达到 5 时,我们的角色开始奔跑;这意味着我们的角色在 4.9 时停止行走。所以,我们在这里添加另一个条件,告诉角色一旦他的currentSpeed达到 4.9,他就停止行走:

现在我们已经定义了角色何时开始行走,我们还需要做相反的操作,即定义何时停止行走并播放闲置动画。我们需要记住,这不会影响游戏玩法,这意味着如果我们从这个点开始游戏,角色将开始播放行走动画,因为我们已经设置了这一点。但即使我们没有,角色在没有动画的情况下也会在环境中移动。我们只是在代码中存储的值来连接动画状态,并需要定义在某个值时将播放哪个动画。如果我们忘记为所有动画设置该值,角色仍然会执行行为,但没有正确的动画。所以,我们还需要检查是否所有链接都分配了条件。
现在为了让角色在停止移动后回到闲置动画,我们点击从行走到闲置的链接,并添加一个新条件,表示如果当前速度小于 0.1,他就停止播放行走动画并开始播放闲置动画:

现在我们可以为使用currentSpeed值的其余动画完成Locomotion状态。一旦我们准备好了所有这些,我们就可以继续到蹲下动画。它们也使用currentSpeed值,但我们需要一个额外的值来使 WALK 动画无效并启用蹲下动画。有两种方法可以实现这一点:在向前移动的同时按下蹲下按钮,或者定义地图上的区域,使角色直接进入蹲下模式。对于这个例子,因为我们正在处理一个 AI 角色,我们将使用第二种选项,在地图上定义区域,使角色进入蹲下模式:

在这个例子中,让我们假设角色不应该在草地上行走,因此他试图通过蹲下来隐藏。我们也可以选择一个如果直立行走就不可能进入的小地点,因此角色会自动开始以蹲下的姿势行走。
因此,为了做到这一点,我们需要在地图上创建触发位置,根据角色当前的位置改变动画。在代码中,我们创建一个新的布尔变量,并将其命名为steppingGrass,在将currentSpeed值与动画状态机连接的行之后。我们将添加一个新行,将这个布尔值连接到我们将在动画状态机上创建的新参数。我们可以从创建新参数开始:

在我们的代码中,我们将添加碰撞检测,一旦我们的角色在草地上,就会打开这个值,一旦他离开这个区域,就会关闭它:
public Animator characterAnimator;
public int Health;
public int Stamina;
public float movementSpeed;
public float rotationSpeed;
public float maxSpeed;
public float jumpHeight;
public float jumpSpeed;
private float currentSpeed;
private bool Dead;
private bool steppingGrass;
void Start () {
}
void Update () {
// Sets the movement speed of the animator, to change from
idle to walk and walk to run
characterAnimator.SetFloat("currentSpeed",currentSpeed);
// Sets the stepping grass Boolean to the animator value
characterAnimator.SetBool("steppingGrass",steppingGrass);
// USING XBOX CONTROLLER
transform.Rotate(0,Time.deltaTime * (rotationSpeed *
Input.GetAxis ("xboxlefth")), 0);
if(Input.GetAxis ("xboxleft") > 0){
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed);
}
else{
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed/3);
}
if(Input.GetKeyDown("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyDown("joystick button 16") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 16") && Dead == false)
{
}
if(Health <= 0){
Dead = true;
}
}
void OnTriggerEnter(Collider other) {
if(other.gameObject.tag == "Grass")
{
steppingGrass = true;
}
}
void OnTriggerExit(Collider other) {
if(other.gameObject.tag == "Grass")
{
steppingGrass = false;
}
}
现在,我们可以继续并添加这个新参数到蹲下动画。我们首先选择从 IDLE 到蹲下动画的链接,并设置currentSpeed值和新的steppingGrass参数。因为我们有一个蹲下空闲动画,即使角色没有移动,它也会播放这个动画而不是正常的 IDLE 动画:

我们将currentSpeed设置为小于 0.1,这意味着角色没有移动,并将steppingGrass设置为 true,这停止了正常的 IDLE 动画并开始播放蹲下空闲动画。其余的蹲下动画遵循与 WALK 和 RUN 动画相同的原理。一旦角色开始移动,这代表currentSpeed值,蹲下空闲停止,蹲下行走开始。最后,我们将蹲下空闲链接到 IDLE,将蹲下行走链接到 WALK,确保如果角色离开草地,WALK 动画不会停止,角色会继续直立行走。
关于攻击,我们将使用整数在 1 到 10 之间随机生成一个数字,如果这个数字大于5,它将播放踢动画。如果数字小于5,它将播放拳动画。因此,当角色进入与对手的战斗模式时,它将播放不同的攻击。再次强调,使用这种方法允许我们在未来添加更多动画,从而增加攻击的多样性。
再次,我们创建一个新的参数,在这个例子中,我们将创建一个整数参数,并称之为attackRandomNumber:

在我们的代码中,我们将添加一个新的变量,并给它相同的名字(不需要用相同的名字创建它,但这确实使一切更有条理)。在之前连接变量与动画状态机参数的行之后,我们将创建一个新的变量,将其连接到attackRandomNumber值。然后我们创建一个函数,一旦角色进入战斗模式,就会随机生成一个数字:
public Animator characterAnimator;
public int Health;
public int Stamina;
public float movementSpeed;
public float rotationSpeed;
public float maxSpeed;
public float jumpHeight;
public float jumpSpeed;
private float currentSpeed;
private bool Dead;
private bool steppingGrass;
private int attackRandomNumber;
void Start () {
}
void Update () {
// Sets the movement speed of the animator, to change from
idle to walk and walk to run
characterAnimator.SetFloat("currentSpeed",currentSpeed);
// Sets the stepping grass Boolean to the animator value
characterAnimator.SetBool("steppingGrass",steppingGrass);
// Sets the attackrandomnumber to the animator value
characterAnimator.SetInteger("attackRandomNumber",
attackRandomNumber);
// USING XBOX CONTROLLER
transform.Rotate(0,Time.deltaTime * (rotationSpeed *
Input.GetAxis ("xboxlefth")), 0);
if(Input.GetAxis ("xboxleft") > 0){
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed);
}
else{
transform.position += transform.forward * Time.deltaTime *
currentSpeed;
currentSpeed = Time.deltaTime * (Input.GetAxis
("xboxleft") * movementSpeed/3);
}
if(Input.GetKeyDown("joystick button 18") && Dead == false)
{
fightMode();
}
if(Input.GetKeyUp("joystick button 18") && Dead == false)
{
}
if(Input.GetKeyDown("joystick button 16") && Dead == false)
{
}
if(Input.GetKeyUp("joystick button 16") && Dead == false)
{
}
if(Health <= 0){
Dead = true;
}
}
void OnTriggerEnter(Collider other) {
if(other.gameObject.tag == "Grass")
{
steppingGrass = true;
}
}
void OnTriggerExit(Collider other) {
if(other.gameObject.tag == "Grass")
{
steppingGrass = false;
}
}
void fightMode ()
{
attackRandomNumber = (Random.Range(1, 10));
}
在完成这个步骤后,我们需要将值分配给动画。这个过程与之前的动画相同,只是这次我们使用了一个不同的值。如果attackRandomNumber大于 1,这意味着他在攻击,攻击动画应该开始播放。因为我们有两种不同的攻击,我们决定随机使用它们,但如果是一个玩家控制的角色,我们可以在代码内部手动分配数字,当玩家按下游戏手柄上的特定按钮时,角色就会出拳或踢腿。
平滑过渡
另一个值得注意的重要方面是动画之间的平滑过渡。保持动画的完整性非常重要,这样角色的每一个动作看起来都流畅,有助于玩家的虚拟沉浸感。
关于这个问题,2D 和 3D 动画有相当大的不同。如果我们使用 2D 精灵,我们需要绘制每个过渡所需的必要帧,并且每次我们想要角色从一个动画切换到另一个动画时,都会播放过渡动画。

另一方面,对于 3D 角色,我们可以使用骨骼结构自动创建过渡,其中每个骨骼的坐标将从上一个动画移动到新的动画。即使我们选择使用骨骼结构来帮助创建过渡,有时可能有必要,或者这可能是更好的选择,手动创建新的动画作为过渡。如果角色在使用需要保存到下一个动画之前使用的对象或武器,这是一个常见的流程。

为了创建平滑的过渡,我们需要下一个动画的第一帧与当前动画的最后一帧相等。我们需要从当前动画的相同位置开始下一个动画。这是避免在过渡过程中注意到任何断裂的关键。然后我们可以利用游戏引擎的优势,使用过渡系统来处理动画。这将有助于创建更平滑的过渡。正如我们上图所看到的,我们可以调整过渡将持续多长时间,我们可以创建一个快速过渡或一个较长的过渡,始终尝试哪种方式对我们想要的结果看起来更好。
有时候,为了获得更好的游戏体验,我们需要牺牲更平滑的过渡。一个例子是在格斗游戏中,快速过渡比平滑过渡更重要,因此我们需要考虑角色从一个状态转换到另一个状态所需的时间。
摘要
本章介绍了如何使用二维或三维动画来补充角色的动作。动画在可信的 AI 角色开发中扮演着重要的角色,并且通过正确使用它们,角色可以向玩家传达一种角色是活生生的,并且能够自主反应的真实感觉。即使角色动作有限,我们也可以使用动画来模拟或隐藏其中的一些,给人一种角色这样反应是因为它在自己思考的印象。
在下一章中,我们将讨论导航行为和路径查找,即如何编程 AI 角色走向目标位置并选择最佳路线。
第六章:导航行为和路径查找
在本章中,我们将详细解释人工智能角色如何移动以及如何理解他可以去哪里以及不能去哪里。对于不同类型的游戏,有不同解决方案,我们将在本章中讨论这些解决方案,探讨可以用来开发在地图上正确移动的角色的一些常用方法。此外,我们希望我们的角色能够计算出到达特定目的地的最佳轨迹,在移动过程中避开障碍物并完成目标。我们将介绍如何创建简单的导航行为,然后我们将继续探讨点到点移动,最后深入探讨如何创建更复杂的点到点移动(RTS/RPG 系统)。
导航行为
当我们谈论导航行为时,我们指的是角色面对需要计算去哪里或做什么的情况时的行动。地图上可能有多个点,在这些点上必须跳跃或爬楼梯才能到达最终目的地。角色应该知道如何使用这些动作来保持正确的移动;否则,他可能会掉进洞里或者继续走进他应该爬楼梯的墙。为了避免这种情况,我们需要在角色移动时规划所有可能的选择,确保他可以跳跃或执行任何其他必要的动作以保持正确的方向移动。
选择新的方向
AI 角色应该具备的一个重要方面是在面对阻挡他前进且无法穿过的物体时选择新的方向。角色应该意识到他面前有哪些物体,如果他无法继续在那个方向前进,他应该能够选择一个新的方向,避免与物体碰撞并继续远离它。
避免撞墙
如果我们的角色面对一堵墙,他需要知道他不能穿过那堵墙,应该选择另一个选项。除非我们允许角色爬墙或摧毁它,否则角色需要面对一个新的方向,这个方向没有被阻挡,然后在这个新的未阻挡方向上行走。
我们将从一个简单的方法开始,这种方法通常非常有用,也许是我们创建游戏时最好的选择。在我们将要演示的例子中,所讨论的角色需要像吃豆人敌人一样在关卡中不断移动。从一个基本示例开始,我们赋予我们的角色选择移动方向的权利,稍后我们将向角色的 AI 添加更多信息,使他能够使用这种方法在地图上追求特定的目标。

我们创建了一个网格,并将不允许角色 AI 行走的方块涂成黑色。现在我们将编写代码让角色向前移动,直到他发现前方有一个黑色方块;然后,他需要随机选择向左或向右转向,做出这个决定。这将允许我们的角色在地图上自由移动,没有任何特定的模式。相应的代码如下:
public float Speed;
public float facingLeft;
public float facingRight;
public float facingBack;
public static bool availableLeft;
public static bool availableRight;
public bool aLeft;
public bool aRight;
void Start ()
{
}
void Update ()
{
aLeft = availableLeft;
aRight = availableRight;
transform.Translate(Vector2.up * Time.deltaTime * Speed);
if(facingLeft > 270)
{
facingLeft = 0;
}
if(facingRight < -270)
{
facingRight = 0;
}
}
void OnTriggerEnter2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(availableLeft == true && availableRight == false)
{
turnLeft();
}
if(availableRight == true && availableLeft == false)
{
turnRight();
}
if(availableRight == true && availableLeft == true)
{
turnRight();
}
if(availableRight == false && availableLeft == false)
{
turnBack();
}
}
}
void turnLeft ()
{
facingLeft = transform.rotation.eulerAngles.z + 90;
transform.localRotation = Quaternion.Euler(0, 0, facingLeft);
}
void turnRight ()
{
facingRight = transform.rotation.eulerAngles.z - 90;
transform.localRotation = Quaternion.Euler(0, 0, facingRight);
}
void turnBack ()
{
facingBack = transform.rotation.eulerAngles.z + 180;
transform.localRotation = Quaternion.Euler(0, 0, facingBack);
}
在这个例子中,我们向黑色方块添加了碰撞器,让角色知道当他接触它们时。这样,他将一直移动,直到与一个黑色方块碰撞,此时将有三种选择:向左转,向右转,或返回。为了知道哪些方向是畅通的,我们创建了两个独立的碰撞器,并将它们添加到我们的角色上。每个碰撞器都有一个脚本,为角色提供信息,让他知道哪一侧是畅通的或不是。
availableLeft布尔值对应左侧,而availableRight对应右侧。如果左侧或右侧的碰撞器接触到黑色方块,则值设置为false。否则,它设置为true。我们使用aLeft和aRight只是简单地实时检查这些值是否工作正确。这样,我们可以看到是否存在任何问题:
public bool leftSide;
public bool rightSide;
void Start ()
{
if(leftSide == true)
{
rightSide = false;
}
if(rightSide == true)
{
leftSide = false;
}
}
void Update () {
}
void OnTriggerStay2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(leftSide == true && rightSide == false)
{
Character.availableLeft = false;
}
if(rightSide == true && leftSide == false)
{
Character.availableRight = false;
}
}
}
void OnTriggerExit2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(leftSide == true)
{
Character.availableLeft = true;
}
if(rightSide == true)
{
Character.availableRight = true;
}
}
}
当我们开始游戏时,我们可以看到角色 AI 开始在白色方块上移动,并且每次面对黑色方块时都会向左或向右转向:

但如果我们让游戏运行几分钟,我们会意识到角色一直在做出相同的决定,因此他只会在这个地图的小部分区域里走来走去。这是因为他在与黑色方块碰撞时才会做出决定,而忽略了其他转向的机会:

如前图所示,角色总是遵循相同的模式,如果我们希望他不断选择不同的路径,这并不是一个理想的情况。
选择替代路径
我们的字符每次接近墙壁时都会成功选择一个新的方向,现在我们希望他能够在整个地图上移动。为了实现这一点,我们将向角色添加更多信息,让他知道如果有一个可用的转向左或右的机会,即使前方路径是畅通的,角色也可以自由转向。我们可以使用概率来确定角色是否会转向,在这个例子中,我们选择如果有机会,有 90%的几率选择一个新的方向。这样,我们可以非常快速地看到结果:
public float Speed;
public float facingLeft;
public float facingRight;
public float facingBack;
public static bool availableLeft;
public static bool availableRight;
public static int probabilityTurnLeft;
public static int probabilityTurnRight;public int probabilitySides;
public bool forwardBlocked;
public bool aLeft;
public bool aRight;
在添加了变量之后,我们可以继续到Start方法,这是游戏第一帧上将被调用的所有内容。
void Start ()
{
availableLeft = false;
availableRight = false;
probabilityTurnLeft = 0;
probabilityTurnRight = 0;
}
然后我们可以继续到Update方法,这是游戏每一帧上将被调用的所有内容。
void Update ()
{
aLeft = availableLeft;
aRight = availableRight;
transform.Translate(Vector2.up * Time.deltaTime * Speed);
if(facingLeft > 270)
{
facingLeft = 0;
}
if(facingRight < -270)
{
facingRight = 0;
}
if (forwardBlocked == false)
{
if (availableLeft == true && availableRight == false)
{
if (probabilityTurnLeft > 10)
{
turnLeft();
}
}
if (availableLeft == false && availableRight == true)
{
if (probabilityTurnRight > 10)
{
turnRight();
}
}
if (availableLeft == true && availableRight == true)
{
probabilityTurnLeft = 0;
probabilityTurnRight = 0;
}
}
}
在这里,我们添加了触发函数,当他在进入/碰撞到 2D 对象时会发生什么:
void OnTriggerEnter2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
forwardBlocked = true;
if(availableLeft == true && availableRight == false)
{
turnLeft();
}
if(availableRight == true && availableLeft == false)
{
turnRight();
}
if(availableRight == true && availableLeft == true)
{
probabilitySides = Random.Range(0, 1);
if(probabilitySides == 0)
{
turnLeft();
}
if(probabilitySides == 1)
{
turnRight();
}
}
if(availableRight == false && availableLeft == false)
{
turnBack();
}
}
}
void OnTriggerExit2D(Collider2D other)
{
forwardBlocked = false;
}
void turnLeft ()
{
probabilityTurnLeft = 0;
facingLeft = transform.rotation.eulerAngles.z + 90;
transform.localRotation = Quaternion.Euler(0, 0, facingLeft);
}
void turnRight ()
{
probabilityTurnRight = 0;
facingRight = transform.rotation.eulerAngles.z - 90;
transform.localRotation = Quaternion.Euler(0, 0, facingRight);
}
void turnBack ()
{
facingBack = transform.rotation.eulerAngles.z + 180;
transform.localRotation = Quaternion.Euler(0, 0, facingBack);
}
我们已经为我们角色的 AI 脚本添加了四个新变量,即probabilityTurnLeft静态变量,它计算角色向左转的概率;probabilityTurnRight,它计算角色向右转的概率;一个新的概率生成器probabilitySides,当两者都可用且前方路径被阻塞时,将决定转向哪个方向;最后,一个布尔值forwardBlocked,用于检查前方路径是否被阻塞。角色需要检查前方路径是否被阻塞,以知道他是否可以转向。这将防止角色在面对黑色方块时多次转向。

在控制侧面触发的脚本中,我们添加了一个名为probabilityTurn的新变量,它给角色提供关于概率的信息。每次触发器退出碰撞体时,它会计算概率并向角色发送消息,告诉它侧面是空的,他可以决定转向那个侧面:
public bool leftSide;
public bool rightSide;
public int probabilityTurn;
void Start ()
{
if(leftSide == true)
{
rightSide = false;
}
if(rightSide == true)
{
leftSide = false;
}
}
void Update ()
{
}
void OnTriggerEnter2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(leftSide == true && rightSide == false)
{
Character.availableLeft = false;
probabilityTurn = 0;
Character.probabilityTurnLeft = probabilityTurn;
}
if(rightSide == true && leftSide == false)
{
Character.availableRight = false;
probabilityTurn = 0;
Character.probabilityTurnRight = probabilityTurn;
}
}
}
void OnTriggerStay2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(leftSide == true && rightSide == false)
{
Character.availableLeft = false;
probabilityTurn = 0;
Character.probabilityTurnLeft = probabilityTurn;
}
if(rightSide == true && leftSide == false)
{
Character.availableRight = false;
probabilityTurn = 0;
Character.probabilityTurnRight = probabilityTurn;
}
}
}
void OnTriggerExit2D(Collider2D other)
{
if(other.gameObject.tag == "BlackCube")
{
if(leftSide == true)
{
probabilityTurn = Random.Range(0, 100);
Character.probabilityTurnLeft = probabilityTurn;
Character.availableLeft = true;
}
if(rightSide == true)
{
probabilityTurn = Random.Range(0, 100);
Character.probabilityTurnRight = probabilityTurn;
Character.availableRight = true;
}
}
}
如果我们玩这个游戏,我们可以看到对角色实施的新变化。现在他不可预测,每次选择不同的路径,在地图上四处移动,与我们之前的情况相反。一旦完成,我们可以创建尽可能多的地图,因为角色总是会找到正确的路径并避开墙壁。

在更大的地图上进行测试,角色以相同的方式反应,在整个地图上移动。这意味着我们的主要目标已经完成,现在我们可以轻松地创建新的地图,并使用角色作为游戏的主要敌人,这样他总是会以不同的方式移动,不会遵循任何模式。

我们可以根据我们希望角色如何反应来调整百分比值,并且还可以实现更多变量,使其符合我们的游戏理念。
点对点移动
现在我们已经了解了如何在迷宫类游戏中创建一个可以自由移动的角色的基础,我们将看看相反的情况:如何从一点到另一点创建移动模式。这也是人工智能移动的一个重要方面,因为稍后我们可以结合这两种技术来创建一个从一个点到另一个点的角色,避开墙壁和障碍物。
塔防游戏类型
再次强调,我们将用于使我们的角色从一个点到另一个点移动的原则可以应用于 2D 和 3D 游戏。在这个例子中,我们将探讨如何创建塔防游戏的主要特征:敌人模式。目标是让敌人从起始点生成并沿着路径移动,以便它们可以到达终点。塔防游戏中的敌人通常只考虑这一点,因此它是测试如何创建点对点移动的完美例子。
一款 塔防 游戏通常由两个区域组成:敌人从起始位置走到最终位置的区域,以及玩家被允许建造攻击敌人的塔的区域,试图阻止他们到达最终位置。因为玩家不允许在敌人将通过的路径内部建造任何东西,所以 AI 不需要意识到其周围环境,因为它将始终可以自由通过,因此我们只需要关注角色的点对点移动。

在导入我们将用于游戏的地图和角色之后,我们需要配置角色将使用的航点,以便它们知道它们需要去哪里。我们可以通过手动将坐标添加到我们的代码中来实现这一点,但为了简化过程,我们将创建场景中的对象作为航点,并删除 3D 网格,因为它将不再必要。
现在我们将所有创建的航点分组并命名为航点组。一旦我们将航点放置并分组,我们就可以开始创建代码,告诉我们的角色它需要跟随多少个航点。这段代码非常有用,因为我们可以使用我们需要的任意数量的航点创建不同的地图,而无需更新角色的代码:
public static Transform[] points;
void Awake ()
{
points = new Transform[transform.childCount];
for (int i = 0; i < points.Length; i++)
{
points[i] = transform.GetChild(i);
}
}
这段代码将被分配到我们创建的组中,并计算它内部有多少个航点,并对它们进行排序。

我们在前面的图像中可以看到的蓝色球体代表我们用作航点的 3D 网格。在这个例子中,角色将跟随八个点,直到完成路径。现在让我们继续到 AI 角色代码,看看我们如何使用我们创建的点使角色从一个点到另一个点移动。
我们首先创建角色的基本功能,即健康和速度。然后我们可以创建一个新的变量,告诉角色他需要移动到的下一个位置,以及另一个变量,将用于显示它需要跟随哪个航点:
public float speed;
public int health;
private Transform target;
private int wavepointIndex = 0;
现在我们有了制作敌人角色从点到点移动直到死亡或到达终点所需的基本变量。让我们看看如何使用这些变量使其可玩:
public float speed;
public int health;
private Transform target;
private int wavepointIndex = 0;
void Start ()
{
target = waypoints.points[0]; speed = 10f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
在Start函数中,角色需要跟随的第一个航点是航点编号零,即我们在waypoints代码中创建的 Transform 列表中的第一个。此外,我们还确定了角色的速度,在这个例子中我们选择了10f。
然后在Update函数中,角色将计算下一个位置和当前位置之间的距离,使用 Vector 3 dir。角色将不断移动,因此我们创建了一行代码作为角色的移动,即transform.Translate。知道距离和速度信息后,角色将知道它距离下一个位置有多远,一旦他到达从该点期望的距离,他就可以继续移动到下一个点。为了实现这一点,我们创建了一个if语句,告诉角色,如果他距离他正在移动到的点的距离达到 0.4f(在这个例子中),这意味着他已经到达了那个目的地,可以开始移动到下一个点,调用GetNextWaypoint()。
在GetNextWaypoint()函数中,角色将尝试确认他是否已经到达了最终目的地。如果角色已经到达了最终航点,那么对象可以被销毁;如果没有,它可以继续到下一个航点。在这里,waypointIndex++会在角色到达一个航点时每次将一个数字加到索引上,从 0>1>2>3>4>5,依此类推。
现在我们将代码分配给我们的角色,并将角色放置在起始位置,测试游戏以查看它是否正常工作:

一切都按预期工作:角色将从一点移动到另一点,直到他到达最后一个点,然后他从游戏中消失。然而,我们仍然需要做一些改进,因为角色总是朝同一个方向;他在改变方向时不会旋转。让我们趁机也创建实例化代码,以便将敌人持续生成到地图中。
与我们创建对象来定义航点的方式相同,我们也将为起始位置做同样的事情,创建一个将仅作为位置的对象,这样我们就可以从那个点生成敌人。为了实现这一点,我们创建了一行简单的代码,仅用于测试游戏玩法,而不需要手动将角色添加到游戏中:
public Transform enemyPrefab;
public float timeBetweenWaves = 3f;
public Transform spawnPoint;
private float countdown = 1f;
private int waveNumber = 1;
void Update ()
{
if(countdown <= 0f)
{
StartCoroutine(SpawnWave());
countdown = timeBetweenWaves;
}
countdown -= Time.deltaTime;
}
IEnumerator SpawnWave ()
{
waveNumber++;
for (int i = 0; i < waveNumber; i++)
{
SpawnEnemy();
yield return new WaitForSeconds(0.7f);
}
}
void SpawnEnemy()
{
Instantiate(enemyPrefab, spawnPoint.position,
spawnPoint.rotation);
}
在此刻,我们已经有了一个正在工作的wave spawner,每三秒生成一波新的敌人。这将帮助我们可视化我们为我们的 AI 角色创建的游戏玩法。我们有五个变量。enemyPrefab是我们正在创建的角色,因此代码可以生成它。timeBetweenWaves表示生成新波前等待的时间。spawnPoint变量决定了角色将出现的位置,即起始位置。在这里,countdown是我们等待第一波出现的时间。waveNumber是最后一个变量,用于计算当前波次(通常,这用于区分一波和另一波敌人的难度)。
如果我们现在运行游戏,我们可以看到游戏中出现的角色数量远不止一个,每三秒增加。在我们开发 AI 角色时同时做这件事非常有用,因为如果我们的角色有特殊能力或速度不同,我们可以在开发它们时立即修复。因为我们只是在创建一个小的示例,所以期望它能平稳运行,没有任何错误。
让我们现在测试一下看看会发生什么:

现在看起来更有趣了!我们可以看到点对点移动按预期工作,所有被生成到游戏中的角色都知道他们需要去哪里,并沿着正确的路径前进。
我们现在可以更新角色代码,使其在转弯时能够转向下一个点位置。为了创建这个,我们在敌人代码中添加了几行:
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
void Start ()
{
target = waypoints.points[0];
speed = 10f;
speedTurn = 0.2f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward, dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
如前述代码所示,我们添加了一个名为speedTurn的新变量,它将代表角色转向时的速度,在start函数中,我们确定速度值为0.2f。然后,在update函数中,我们通过乘以Time.deltaTime来计算速度,无论玩家体验到的FPS数值是多少,都给出一个恒定的值。然后我们创建了一个新的Vector3变量,名为newDir,这将使我们的角色转向目标位置。
现在如果我们再次测试游戏,我们可以看到角色会转向他们的下一个点位置:

在这一点上,我们可以看到 AI 角色正在正确地反应,从一点移动到另一点,并转向他们的下一个位置。现在我们有了塔防游戏的基础,我们可以添加独特的代码来创建一个新颖且有趣的游戏。
赛车游戏类型
点对点移动是一种可以应用于几乎任何游戏类型的方法,并且在多年来被广泛使用。我们的下一个例子是一个赛车游戏,其中人工智能驾驶员使用点对点移动来与玩家竞争。为了创建这个,我们需要一条道路和一个驾驶员,然后我们将航点放置在道路上,并告诉我们的 AI 驾驶员跟随这条路径。这与我们之前所做的是非常相似的,但在我们的角色中会有一些行为上的差异,因为我们不希望它在转弯时看起来僵硬,而且同一张地图上还会有其他驾驶员,他们不能一个压在另一个上面。
不再拖延,让我们开始吧,首先我们需要建立地图,在这个例子中是赛道:

在设计完我们的赛道后,我们需要定义我们的驾驶员需要到达的每个点位置,因为我们有很多曲线,所以我们需要创建比之前更多的点位置,以便汽车能够平滑地跟随道路。
我们与之前一样进行了同样的过程,在游戏中创建对象,并将它们用作仅作为位置参考:

这是我们的地图,已经放置了航点,正如我们所见,曲线上有更多的点。如果我们想要从一个点到另一个点实现平滑过渡,这一点非常重要。
现在,让我们再次将所有航点分组,这次我们将创建不同的代码。我们不会创建一个管理航点的代码,而是将计算实现在我们的人工智能驾驶员代码中,并创建一个简单的代码应用于每个航点,以指定要跟随的下一个位置。
我们有很多方法可以开发我们的代码,根据我们的偏好或我们正在制作的游戏类型,有些方法可能比其他方法更有效。在这种情况下,我们发现我们为塔防角色开发的代码与这种游戏类型不匹配。
从人工智能驾驶员代码开始,我们使用了十个变量,如下面的代码块所示:
public static bool raceStarted = false;
public float aiSpeed = 10.0f;
public float aiTurnSpeed = 2.0f;
public float resetAISpeed = 0.0f;
public float resetAITurnSpeed = 0.0f;
public GameObject waypointController;
public List<Transform> waypoints;
public int currentWaypoint = 0;
public float currentSpeed;
public Vector3 currentWaypointPosition;
第一个,raceStarted,是一个静态布尔值,它将告诉我们的驾驶员比赛是否已经开始。这考虑到了比赛只有在绿灯亮起时才开始的事实;如果不是,raceStarted被设置为false。接下来,我们有aiSpeed,它代表汽车的速度。这是一个用于测试的简化版本;否则,我们需要速度函数来确定汽车根据设定的档位可以多快。aiTurnSpeed代表汽车在转弯时的速度,我们希望汽车在面向新方向时如何快速转向。接下来,我们有waypointController,它将被链接到航点组;以及waypoints列表,它将从该组中获取。
在这里,currentWaypoint将告诉我们的驾驶员他目前正在跟随哪个航点编号。currentSpeed变量将显示汽车当前的速度。最后,currentWaypointPosition是汽车将要跟随的航点的 Vector 3 位置:
void Start ()
{
GetWaypoints();
resetAISpeed = aiSpeed;
resetAITurnSpeed = aiTurnSpeed;
}
在我们的start函数中,我们只有三行代码:GetWaypoints(),它将访问组内存在的所有航点,以及resetAISpeed和resetAITurnSpeed,它们将重置速度值,因为它们将影响放置在车上的刚体:
void Update ()
{
if(raceStarted)
{
MoveTowardWaypoints();
}
}
在更新函数中,我们有一个简单的if语句,检查比赛是否已经开始。如果比赛已经开始,那么他可以继续到下一步,这对我们的 AI 驾驶员来说是最重要的,即MoveTowardWaypoints()。在这个例子中,当汽车等待绿灯时,我们没有做任何声明,但我们可以实现引擎启动和汽车的预加速,例如:
void GetWaypoints()
{
Transform[] potentialWaypoints = waypointController.
GetComponentsInChildren<Transform>();
waypoints = new List<Transform>();
for each(Transform potentialWaypoint in potentialWaypoints)
{
if(potentialWaypoint != waypointController.transform)
{
waypoints.Add(potentialWaypoint);
}
}
}
接下来,我们有GetWaypoints(),它在Start函数中被实例化。在这里,我们访问waypointController组并检索其中存储的所有航点位置信息。因为我们将在不同的代码中按顺序排列航点,所以我们在这里不需要做那件事:
void MoveTowardWaypoints()
{
float currentWaypointX = waypoints[currentWaypoint].position.x;
float currentWaypointY = transform.position.y;
float currentWaypointZ = waypoints[currentWaypoint].position.z;
Vector3 relativeWaypointPosition = transform.
InverseTransformPoint (new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ));
currentWaypointPosition = new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ);
Quaternion toRotation = Quaternion.LookRotation
(currentWaypointPosition - transform.position);
transform.rotation = Quaternion.RotateTowards
(transform.rotation, toRotation, aiTurnSpeed);
GetComponent<Rigidbody>().AddRelativeForce(0, 0, aiSpeed);
if(relativeWaypointPosition.sqrMagnitude < 15.0f)
{
currentWaypoint++;
if(currentWaypoint >= waypoints.Count)
{
currentWaypoint = 0;
}
}
currentSpeed = Mathf.Abs(transform.
InverseTransformDirection
(GetComponent<Rigidbody>().velocity).z);
float maxAngularDrag = 2.5f;
float currentAngularDrag = 1.0f;
float aDragLerpTime = currentSpeed * 0.1f;
float maxDrag = 1.0f;
float currentDrag = 3.5f;
float dragLerpTime = currentSpeed * 0.1f;
float myAngularDrag = Mathf.Lerp(currentAngularDrag,
maxAngularDrag, aDragLerpTime);
float myDrag = Mathf.Lerp(currentDrag, maxDrag, dragLerpTime);
GetComponent<Rigidbody>().angularDrag = myAngularDrag;
GetComponent<Rigidbody>().drag = myDrag;
}
最后,我们有MoveTowardsWaypoints()函数。因为汽车在移动性方面比简单的 Tower Defense 角色更深入,我们决定扩展并在这个代码部分实现更多内容。
首先,我们检索当前正在使用的航点的 Vector 3 位置。我们选择分别检索这些信息并分配轴,因此我们有currentWaypointX用于 X 轴,currentWaypointY用于 Y 轴,currentWaypointZ用于 Z 轴。
然后我们创建一个新的 Vector 3 方向relativeWaypointPosition,它将计算航点和汽车当前位置之间的距离,并将从世界空间转换为局部空间,在这种情况下我们使用了InverseTransformDirection。

如前图所示,我们想要计算汽车和航点之间的局部空间距离。这将告诉我们的驾驶员航点是在他的右侧还是左侧。这是推荐的,因为车轮控制汽车速度,并且它们有一个独立的旋转值,如果我们继续开发这个游戏,这将是一个仍然需要开发的功能。
为了平滑从一个航点到另一个航点的旋转,我们使用了以下代码:
Quaternion toRotation = Quaternion.LookRotation
(currentWaypointPosition - transform.position);
transform.rotation = Quaternion.RotateTowards
(transform.rotation, toRotation, aiTurnSpeed);
这是 Tower Defense 中我们使用的一个更新版本。它将使我们的汽车平滑地移动到汽车正在行驶的航点。这给出了汽车转弯的效果;否则,他将会直接向航点右转,这看起来不真实:

如我们所见,直线并不适合我们目前正在创作的游戏类型。它在其他类型,如塔防游戏中运行得非常完美,但对于赛车游戏来说,我们必须重新定义代码以适应我们正在创造的情况。
其余的代码正是如此,针对我们正在创造的情况进行的调整,即一辆在赛道上行驶的汽车。其中包含如drag这样的力元素,这是汽车与道路之间的摩擦力,在代码中得到了体现。当我们转向汽车时,它会根据那一刻汽车的速度滑动,这些细节在这里都被考虑到了,创造出一个更真实的点对点移动,我们可以看到汽车是按照物理规律反应的。
这是我们在示例中使用过的完整代码:
public static bool raceStarted = false;
public float aiSpeed = 10.0f;
public float aiTurnSpeed = 2.0f;
public float resetAISpeed = 0.0f;
public float resetAITurnSpeed = 0.0f;
public GameObject waypointController;
public List<Transform> waypoints;
public int currentWaypoint = 0;
public float currentSpeed;
public Vector3 currentWaypointPosition;
void Start ()
{
GetWaypoints();
resetAISpeed = aiSpeed;
resetAITurnSpeed = aiTurnSpeed;
}
void Update ()
{
if(raceStarted)
{
MoveTowardWaypoints();
}
}
void GetWaypoints()
{
Transform[] potentialWaypoints =
waypointController.GetComponentsInChildren<Transform>();
waypoints = new List<Transform>();
foreach(Transform potentialWaypoint in potentialWaypoints)
{
if(potentialWaypoint != waypointController.transform)
{
waypoints.Add(potentialWaypoint);
}
}
}
void MoveTowardWaypoints()
{
float currentWaypointX = waypoints[currentWaypoint].position.x;
float currentWaypointY = transform.position.y;
float currentWaypointZ = waypoints[currentWaypoint].position.z;
Vector3 relativeWaypointPosition = transform.
InverseTransformPoint (new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ));
currentWaypointPosition = new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ);
Quaternion toRotation = Quaternion.
LookRotation(currentWaypointPosition - transform.position);
transform.rotation = Quaternion.RotateTowards
(transform.rotation, toRotation, aiTurnSpeed);
GetComponent<Rigidbody>().AddRelativeForce(0, 0, aiSpeed);
if(relativeWaypointPosition.sqrMagnitude < 15.0f)
{
currentWaypoint++;
if(currentWaypoint >= waypoints.Count)
{
currentWaypoint = 0;
}
}
currentSpeed = Mathf.Abs(transform.
InverseTransformDirection
(GetComponent<Rigidbody>().velocity).z);
float maxAngularDrag = 2.5f;
float currentAngularDrag = 1.0f;
float aDragLerpTime = currentSpeed * 0.1f;
float maxDrag = 1.0f;
float currentDrag = 3.5f;
float dragLerpTime = currentSpeed * 0.1f;
float myAngularDrag = Mathf.Lerp(currentAngularDrag,
maxAngularDrag, aDragLerpTime);
float myDrag = Mathf.Lerp(currentDrag, maxDrag, dragLerpTime);
GetComponent<Rigidbody>().angularDrag = myAngularDrag;
GetComponent<Rigidbody>().drag = myDrag;
}
如果我们开始游戏并测试它,我们可以看到它运行得很好。汽车可以自行驾驶,转弯顺畅,并按照预期完成赛道。
现在我们已经完成了基本的点对点移动,我们可以为 AI 驾驶员实现更多功能,并开始按照我们的意愿开发游戏。在开发任何细节之前,始终建议先从游戏的主功能开始。这将帮助我们识别出那些我们原本以为会很好地工作的游戏想法,但实际上并不如预期。

MOBA 游戏类型
点对点移动是控制角色移动最常用的方法之一。为什么它被广泛使用,这一点不言而喻,因为角色从一个点移动到另一个点,通常这正是我们想要的;我们希望角色到达某个目的地或跟随另一个角色。另一种也需要这种移动类型的游戏类型是最近变得非常流行的多人在线战斗竞技场(MOBA)游戏。通常,NPC 角色会在起始位置生成,并沿着预定的路径向敌方塔楼移动,类似于塔防游戏中的敌人,但在这个情况下,AI 角色与玩家在相同的地图上移动,并且可以相互干扰。
地图被分为两个相等的部分,其中一边需要与另一边战斗,并且每个部分都会生成一个不同的连队,由被称为小兵或 creep 的小型敌人组成。当它们沿着路径移动时,如果一个连队遇到另一个,它们就会停止前进并开始攻击。战斗结束后,幸存者继续前进:

在这个例子中,我们将重新创建游戏的一部分,其中小队从起始位置出生,沿着路径前进,当它们找到敌人时停止,并继续向下一个方向移动,直到赢得战斗。然后我们将创建由玩家或计算机控制的英雄角色的基本移动:两者都有在地图上自由移动的自由,角色需要遵循玩家或计算机指示的方向,同时避开所有障碍。
我们将首先将地图导入到我们的游戏中。我们选择了一个通用的 MOBA 风格地图,就像我们在以下截图中所看到的那样:

下一步是在地图中创建航点。这里我们将有六个不同的航点组,因为每个队伍有三条不同的路径,每个小队只能遵循一条路径。我们从基地位置开始,然后添加更多的航点,直到我们到达敌方基地。以下图像显示了我们所创建的示例。

我们需要为每个队伍创建三个不同的航点组,因为也会有三个不同的出生点;它们将独立工作。设置航点后,我们可以将它们分组并分配用于收集位置信息和排应该遵循的顺序的代码。对于这个例子,我们可以使用我们之前用于塔防航点的相同代码,因为敌人跟随路径的方式是相似的:
public static Transform[] points;
void Awake ()
{
points = new Transform[transform.childCount];
for (int i = 0; i < points.Length; i++)
{
points[i] = transform.GetChild(i);
}
}
由于我们有六个不同的航点组,有必要将相同的代码复制六次并相应地重命名。我们的“可出生”敌人将稍后访问它们正确的路径,因此建议重命名组和代码,以便我们可以轻松理解哪个组代表哪个路线,例如,1_Top/1_Middle/1_Bottom 和 2_Top/2_Middle/2_Bottom。数字代表他们的队伍,位置名称代表位置。在这种情况下,我们将代码中的points名称更改为代表每个路线的正确名称:
Lane Team 1 Top:
public static Transform[] 1_Top;
void Awake ()
{
1_Top = new Transform[transform.childCount];
for (int i = 0; i < 1_Top.Length; i++)
{
1_Top[i] = transform.GetChild(i);
}
}
Lane Team 1 Middle:
public static Transform[] 1_Middle;
void Awake ()
{
1_Middle = new Transform[transform.childCount];
for (int i = 0; i < 1_Top.Length; i++)
{
1_Middle[i] = transform.GetChild(i);
}
}
Lane Team 1 Bottom:
public static Transform[] 1_Bottom;
void Awake ()
{
1_Bottom = new Transform[transform.childCount];
for (int i = 0; i < 1_Top.Length; i++)
{
1_Bottom[i] = transform.GetChild(i);
}
}
Lane Team 2 Top:
public static Transform[] 2_Top;
void Awake ()
{
2_Top = new Transform[transform.childCount];
for (int i = 0; i < 1_Top.Length; i++)
{
2_Top[i] = transform.GetChild(i);
}
}
Lane Team 2 Middle:
public static Transform[] 2_Middle;
void Awake ()
{
2_Middle = new Transform[transform.childCount];
for (int i = 0; i < 2_Middle.Length; i++)
{
2_Middle[i] = transform.GetChild(i);
}
}
Lane Team 2 Bottom:
public static Transform[] 2_Bottom;
void Awake ()
{
2_Bottom = new Transform[transform.childCount];
for (int i = 0; i < 2_Bottom.Length; i++)
{
2_Bottom[i] = transform.GetChild(i);
}
}
现在我们已经为每个队伍创建了所有组和代码,我们可以继续到跟随路径向敌方基地前进的角色 AI。我们可以选择为每个队伍复制代码,或者将所有内容整合到同一代码中,使用if语句来决定角色应该遵循哪个路径。对于这个例子,我们选择将所有内容整合到同一代码中。这样,我们只需更新一次角色代码,它就会同时适用于两个队伍。再次提醒,我们可以从在塔防游戏中使用的相同代码开始。我们可以更改代码,使其适合我们目前正在创建的游戏:
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
void Start ()
{
target = waypoints.points[0];
speed = 10f;
speedTurn = 0.2f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward, dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
使用这段代码,我们可以让角色沿着路径移动,并在从一个点到另一个点时平滑地转向。在这个阶段,我们只需要更改代码,使其适合我们正在创建的游戏类型。为此,我们首先需要考虑的是将点名称更改为我们之前创建的名称,并添加if语句来选择角色需要跟随的侧面。
让我们从添加区分一个团队角色与另一个团队角色的信息开始。为此,我们需要创建两个新的布尔变量:
public bool Team1;
public bool Team2;
这将使我们能够决定角色是来自 Team1 还是 Team2,两者不能同时为真。现在我们可以将更多细节添加到角色代码中,让他知道他应该走哪条路线:
public bool Top;
public bool Middle;
public bool Bottom;
我们添加了三个额外的布尔值,将指示角色需要跟随的路线。在确定角色是从哪个团队出生后,我们将添加另一个if语句来确定角色将遵循的路线。
一旦我们添加了这些变量,我们需要根据角色将遵循的路线分配我们之前创建的航点组。我们可以在start函数中实现这一点:
if(Team1 == true)
{
if(Top == true)
{
target = 1_Top.1_Top[0];
}
if(Middle == true)
{
target = 1_Middle.1_Middle[0];
}
if(Bottom == true)
{
target = 1_Bottom.1_Top[0];
}
}
if(Team2 == true)
{
if(Top == true)
{
target = 2_Top.2_Top[0];
}
if(Middle == true)
{
target = 2_Middle.2_Middle[0];
}
if(Bottom == true)
{
target = 2_Bottom.2_Top[0];
}
}
这允许角色询问它所代表的团队、它出生的路线以及他将遵循的路径。我们需要调整其余的代码,以便它适用于这个示例。下一个修改将在GetNextWaypoint()函数中。我们需要添加if语句,让角色知道他需要遵循的正确下一个航点,类似于我们在Start函数中所做的:
void GetNextWaypoint()
{
if(Team1 == true)
{
if(Top == true)
{
if(wavepointIndex >= 1_Top.1_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Top.1_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 1_Middle.1_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Middle.1_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 1_Bottom.1_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Bottom.1_Bottom[wavepointIndex];
}
}
if(Team2 == true)
{
if(Top == true)
{
if(wavepointIndex >= 2_Top.2_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Top.2_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 2_Middle.2_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Middle.2_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 2_Bottom.2_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Bottom.2_Bottom[wavepointIndex];
}
}
}
在这个阶段,如果我们向游戏中添加一个角色并分配 AI 代码,它将遵循所选路径:

它正在正常工作,我们现在准备实现更多功能,以创建一个完美的连队,该连队沿着通往敌方塔楼的道路前进,并在必要时停下来与另一连队或英雄战斗。现在我们已经有了基本移动功能,我们可以添加任何我们想要添加到我们的连队中的细节或独特性。在这里,我们附上了连队 AI 角色的完整代码:
public float speed;
public int health;
public float speedTurn;
public bool Team1;
public bool Team2;
public bool Top;
public bool Middle;
public bool Bottom;
private Transform target;
private int wavepointIndex = 0;
在更新前面代码中的变量之后,我们可以继续到Start方法,该方法将在第一帧被调用:
void Start ()
{
if(Team1 == true)
{
if(Top == true)
{
target = 1_Top.1_Top[0];
}
if(Middle == true)
{
target = 1_Middle.1_Middle[0];
}
if(Bottom == true)
{
target = 1_Bottom.1_Top[0];
}
}
if(Team2 == true)
{
if(Top == true)
{
target = 2_Top.2_Top[0];
}
if(Middle == true)
{
target = 2_Middle.2_Middle[0];
}
if(Bottom == true)
{
target = 2_Bottom.2_Top[0];
}
}
speed = 10f;
speedTurn = 0.2f;
}
这是每帧游戏都会调用的Update方法:
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward, dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
void GetNextWaypoint()
{
if(Team1 == true)
{
if(Top == true)
{
if(wavepointIndex >= 1_Top.1_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Top.1_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 1_Middle.1_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Middle.1_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 1_Bottom.1_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Bottom.1_Bottom[wavepointIndex];
}
}
if(Team2 == true)
{
if(Top == true)
{
if(wavepointIndex >= 2_Top.2_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Top.2_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 2_Middle.2_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Middle.2_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 2_Bottom.2_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Bottom.2_Bottom[wavepointIndex];
}
}
}
MOBA 游戏的一个重要方面是英雄的移动。即使它由玩家控制,角色也有 AI 来决定他需要遵循的路径,以便到达所选目的地。为了完成这个任务,我们首先介绍点对点方法;然后我们将继续使用相同的方法,但使用一种更高级的方法,让我们的角色决定到达最终目的地的最佳路径,而不需要实现任何航点。
这个例子也将作为如何创建跟随玩家的角色的示例。为了做到这一点,我们需要设置所有角色允许跟随的可能路径。我们希望 AI 避免与物体碰撞或穿过墙壁,例如:

让我们关注地图的这个区域。正如我们所见,墙壁和树木阻挡了地图的一部分,角色不应被允许穿过它们。使用航点方法,我们将在地图上创建角色应该跟随以到达特定目的地的点。它不会有像之前例子中创建的任何特定顺序,因为角色可以朝任何方向移动,因此我们无法预测它将选择哪条路径。
我们首先将航点定位在可通行位置。这将防止角色在不可通行区域移动:

地图上我们看到的小星星代表我们创建的航点,因此我们应该只在角色能够行走的地方放置它们。如果角色想要从一个位置移动到另一个位置,它必须遵循航点,直到到达离目标目的地最近的航点。
在游戏机制中,我们可以选择角色为什么需要到达某个特定目的地,例如跟随玩家、前往基地恢复生命值、向敌方墙壁移动以摧毁它,以及许多其他选择。无论角色 AI 需要实现什么,它都需要在地图上正确移动,而这个航点系统在任何情况下都会起作用。
在这里,我们可以找到使这一切工作的完整代码。然后我们将详细解释,以便更好地理解如何复制此代码,使其能在不同的游戏类型中工作:
public float speed;
private List <GameObject> wayPointsList;
private Transform target;
private GameObject[] wayPoints;
void Start ()
{
target = GameObject.FindGameObjectWithTag("target").transform;
wayPointsList = new List<GameObject>();
wayPoints = GameObject.FindGameObjectsWithTag("wayPoint");
for each(GameObject newWayPoint in wayPoints)
{
wayPointsList.Add(newWayPoint);
}
}
void Update ()
{
Follow();
}
void Follow ()
{
GameObject wayPoint = null;
if (Physics.Linecast(transform.position, target.position))
{
wayPoint = findBestPath();
}
else
{
wayPoint = GameObject.FindGameObjectWithTag("target");
}
Vector3 Dir = (wayPoint.transform.position -
transform.position).normalized;
transform.position += Dir * Time.deltaTime * speed;
transform.rotation = Quaternion.LookRotation(Dir);
}
GameObject findBestPath()
{
GameObject bestPath = null;
float distanceToBestPath = Mathf.Infinity;
for each(GameObject go in wayPointsList)
{
float distToWayPoint = Vector3.
Distance(transform.position, go.transform.position);
float distWayPointToTarget = Vector3.
Distance(go.transform.position,
target.transform.position);
float distToTarget = Vector3.
Distance(transform.position, target.position);
bool wallBetween = Physics.Linecast
(transform.position, go.transform.position);
if((distToWayPoint < distanceToBestPath)
&& (distToTarget > distWayPointToTarget)
&& (!wallBetween))
{
distanceToBestPath = distToWayPoint;
bestPath = go;
}
else
{
bool wayPointToTargetCollision = Physics.Linecast
(go.transform.position, target.position);
if(!wayPointToTargetCollision)
{
bestPath = go;
}
}
}
return bestPath;
}
如果我们将此代码分配给我们的角色并按下播放按钮,我们可以测试游戏并看到我们所创建的内容工作得非常完美。角色应该使用航点位置在地图上移动,以达到目标目的地。这种方法同样适用于 NPC 角色和可玩角色,因为在这两种情况下,角色都需要避免与墙壁和障碍物碰撞:

如果我们继续这个例子,并将航点扩展到整个地图上,我们就会有一个运行良好的基本 MOBA 游戏,每个基地都会生成一群怪物,它们会沿着正确的路径前进,同时英雄角色可以在地图上自由移动,不会撞到墙壁。
点对点移动和避免动态物体
现在我们有了能够跟随正确路径并避开静态物体的角色,我们准备进入下一个级别,让这些角色在从点到点移动时避开动态物体。我们将回顾本章中创建的三个不同示例,并看看我们如何将这些避免技术添加到示例中的 AI 角色中。
这三个方法几乎涵盖了所有使用点对点移动作为其主要移动方式的游戏类型,我们将能够根据这些示例作为指南来创造新的想法:

让我们从赛车游戏开始,在这个游戏中,我们有一辆在赛道上行驶直到完成比赛的汽车。如果汽车独自驾驶且路上没有障碍物,那么它就不需要避开任何障碍,但通常障碍物会使游戏更有趣或更具挑战性,尤其是在它们是突然出现且我们没有预料到的情况下。一个很好的例子是马里奥赛车游戏,在那里他们扔香蕉和其他物体来使对手不稳定,而这些物体没有预定义的位置,所以角色无法预测它们将出现在哪里。因此,对于驾驶员来说,拥有避免与这些物体碰撞的必要功能并在实时中做到这一点是非常重要的。
假设当 AI 角色正在跟随下一个航点时,道路上意外出现了两个物体,我们希望角色能够预测碰撞并转身避开物体。我们将使用的方法是航点移动与迷宫移动的结合。角色一次只能服从一个命令,他要么遵守航点移动,要么遵守迷宫移动,这正是我们需要添加到我们的代码中的,以便角色 AI 可以根据他当前面临的情况选择最佳选项:
public static bool raceStarted = false;
public float aiSpeed = 10.0f;
public float aiTurnSpeed = 2.0f;
public float resetAISpeed = 0.0f;
public float resetAITurnSpeed = 0.0f;
public GameObject waypointController;
public List<Transform> waypoints;
public int currentWaypoint = 0;
public float currentSpeed;
public Vector3 currentWaypointPosition;
public static bool isBlocked;
public static bool isBlockedFront;
public static bool isBlockedRight;
public static bool isBlockedLeft;
在更新前面的变量之后,我们可以继续到Start方法。这将在第一帧被调用:
void Start ()
{
GetWaypoints();
resetAISpeed = aiSpeed;
resetAITurnSpeed = aiTurnSpeed;
}
这里是每帧游戏都会调用的Update方法:
void Update ()
{
if(raceStarted && isBlocked == false)
{
MoveTowardWaypoints();
}
if(raceStarted && isBlockedFront == true
&& isBlockedLeft == false && isBlockedRight == false)
{
TurnRight();
}
if(raceStarted && isBlockedFront == false
&& isBlockedLeft == true && isBlockedRight == false)
{
TurnRight();
}
if(raceStarted && isBlockedFront == false
&& isBlockedLeft == false && isBlockedRight == true)
{
TurnLeft();
}
}
void GetWaypoints()
{
Transform[] potentialWaypoints = waypointController.
GetComponentsInChildren<Transform>();
waypoints = new List<Transform>();
for each(Transform potentialWaypoint in potentialWaypoints)
{
if(potentialWaypoint != waypointController.transform)
{
waypoints.Add(potentialWaypoint);
}
}
}
void MoveTowardWaypoints()
{
float currentWaypointX = waypoints[currentWaypoint].position.x;
float currentWaypointY = transform.position.y;
float currentWaypointZ = waypoints[currentWaypoint].position.z;
Vector3 relativeWaypointPosition = transform.
InverseTransformPoint (new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ));
currentWaypointPosition = new Vector3(currentWaypointX,
currentWaypointY, currentWaypointZ);
Quaternion toRotation = Quaternion.
LookRotation(currentWaypointPosition - transform.position);
transform.rotation = Quaternion.
RotateTowards(transform.rotation, toRotation, aiTurnSpeed);
GetComponent<Rigidbody>().AddRelativeForce(0, 0, aiSpeed);
if(relativeWaypointPosition.sqrMagnitude < 15.0f)
{
currentWaypoint++;
if(currentWaypoint >= waypoints.Count)
{
currentWaypoint = 0;
}
}
currentSpeed = Mathf.Abs(transform.
InverseTransformDirection(GetComponent<Rigidbody>().
velocity).z);
float maxAngularDrag = 2.5f;
float currentAngularDrag = 1.0f;
float aDragLerpTime = currentSpeed * 0.1f;
float maxDrag = 1.0f;
float currentDrag = 3.5f;
float dragLerpTime = currentSpeed * 0.1f;
float myAngularDrag = Mathf.Lerp(currentAngularDrag,
maxAngularDrag, aDragLerpTime);
float myDrag = Mathf.Lerp(currentDrag, maxDrag,
dragLerpTime);
GetComponent<Rigidbody>().angularDrag = myAngularDrag;
GetComponent<Rigidbody>().drag = myDrag;
}
void TurnLeft()
{
//turning left function here
}
void TurnRight()
{
//turning right function here
}
我们在我们的代码中添加了四个新的静态变量:isBlocked、isBlockedFront、isBlockedRight和isBlockedLeft。这将检查汽车前方的路径是否没有障碍物。汽车将继续沿着航点路径行驶,直到出现某些东西,汽车需要左转或右转以通过障碍物。为了使这一点生效,我们需要在汽车前方至少添加三个传感器。当它们与某个物体交互时,传感器将信息传递给 AI 驾驶员,此时它将根据该信息选择最佳选项:

正如我们在前面的图像中所看到的,汽车现在有三个传感器附着在其上。在这个例子中,右边的传感器将报告它被障碍物阻挡,驾驶员将向左转直到那一边再次畅通。一旦三个传感器报告说没有东西阻挡驾驶员的路径,汽车将返回到之前移动的航点。如果我们注意到驾驶员没有识别某些障碍物,建议增加传感器的数量以覆盖更大的区域。
现在让我们继续到我们为 MOBA 示例创建的编队角色。在这里,我们需要创建一个不同的方法,因为角色将移动到下一个航点,直到他们找到某个东西,但这次我们不希望他们离开。相反,我们希望角色向他们找到的角色移动。

为了创建这个功能,我们将为我们的角色添加一个圆形或球形的碰撞器。这将作为检测器使用。如果某个物体触发了该区域,角色将停止向其航点移动,并使用触发碰撞器的英雄的位置作为航点来追击:
public float speed;
public int health;
public float speedTurn;
public bool Team1;
public bool Team2;
public bool Top;
public bool Middle;
public bool Bottom;
private Transform target;
private int wavepointIndex = 0;
static Transform heroTarget;
static bool heroTriggered;
在更新前面的变量之后,我们可以继续到Start方法,它将在第一帧被调用:
void Start ()
{
if(Team1 == true)
{
if(Top == true)
{
target = 1_Top.1_Top[0];
}
if(Middle == true)
{
target = 1_Middle.1_Middle[0];
}
if(Bottom == true)
{
target = 1_Bottom.1_Top[0];
}
}
if(Team2 == true)
{
if(Top == true)
{
target = 2_Top.2_Top[0];
}
if(Middle == true)
{
target = 2_Middle.2_Middle[0];
}
if(Bottom == true)
{
target = 2_Bottom.2_Top[0];
}
}
speed = 10f;
speedTurn = 0.2f;
}
这里是每帧游戏都会调用的Update方法:
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <=
0.4f && heroTriggered == false)
{
GetNextWaypoint();
}
if(heroTriggered == true)
{
GetHeroWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.
forward, dir, speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
这个GetNextWaypoint方法用于收集角色需要跟随的下一个航点的相关信息:
void GetNextWaypoint()
{
if(Team1 == true)
{
if(Top == true)
{
if(wavepointIndex >= 1_Top.1_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Top.1_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 1_Middle.1_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Middle.1_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 1_Bottom.1_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 1_Bottom.1_Bottom[wavepointIndex];
}
}
if(Team2 == true)
{
if(Top == true)
{
if(wavepointIndex >= 2_Top.2_Top.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Top.2_Top[wavepointIndex];
}
if(Middle == true)
{
if(wavepointIndex >= 2_Middle.2_Middle.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Middle.2_Middle[wavepointIndex];
}
if(Bottom == true)
{
if(wavepointIndex >= 2_Bottom.2_Bottom.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = 2_Bottom.2_Bottom[wavepointIndex];
}
}
}
在GetHeroWaypoint方法中,我们设置当角色需要跟随英雄方向时发生的情况,例如攻击或其他功能:
void GetHeroWaypoint()
{
target = heroTarget.transform;
}
我们已经为角色添加了一个球形碰撞器,它向角色提供触发信息,以便知道是否有英雄角色进入了该区域。如果没有英雄触发该区域,角色将继续跟随航点,否则它将集中注意力在英雄身上,并使用他作为目标点。
通过这个例子,我们学习了 MOBA 游戏中可以找到的人工智能移动的核心功能,现在我们可以重新创建这种流行的游戏类型。从这一章开始,我们可以创建从简单到复杂的导航系统,并使用它们使我们的 AI 角色在游戏中更加活跃,不断追求目标,即使这个目标是移动。
摘要
在本章中,我们介绍了点对点移动,这是一种在许多游戏中广泛使用的方法,并且我们可以将我们创建的代码适应到几乎任何游戏中。到目前为止,我们能够重新创建许多流行的游戏,并给它们添加我们个人的特色。在下一章中,我们将继续讨论移动,但我们将关注一个称为 Theta 算法的高级方面。这将作为本章所学内容的延续,我们将能够创建一个角色 AI,它无需任何先前的信息或位置,就能为自己找到到达特定目的地的最佳路径。
第七章:高级路径查找
在本章中,我们将探讨可用于各种游戏的先进路径查找方法。本章的主要目标是学习如何创建一个能够分析地图并处理所有必要信息以决定最佳路径的先进 AI 元素的基础。在许多流行的游戏标题中,高级路径查找方法被用于使 AI 角色能够在实时中选择最佳路径,我们将分析一些最著名的例子以及我们如何复制相同的结果。
简单与高级路径查找
正如我们在上一章中发现的,路径查找被 AI 角色用来发现它们需要移动的方向以及如何正确地移动。根据我们正在开发的游戏,我们可以使用简单的路径查找系统或复杂的路径查找系统。两者都可以非常有用。在某些情况下,简单的路径查找系统足以完成我们寻找的任务,但在其他情况下,我们需要一种不同于我们之前介绍的方法的替代方案,以便实现我们 AI 角色所需的复杂性和真实性。
在讨论任何高级路径查找方法的系统之前,让我们先了解为什么我们需要使用它,以及在什么情况下需要更新我们的角色,使其更加智能和警觉。通过使用我们之前的例子,我们将探讨一个普通路径查找方法存在的局限性。了解简单路径查找系统的局限性将帮助我们认识到我们即将创建更复杂系统时所缺少的内容和面临的挑战。因此,首先学习我们如何设置一个简单的路径查找系统是一个很好的开始,然后我们可以继续研究更复杂的一个。由于游戏的发展速度与创造它们的技术的进步速度相同,我们的第一个例子将是一个较老的游戏,然后我们将看到这个游戏是如何演变的,特别是人工智能路径查找。
开放世界地图现在非常普遍,许多不同类型的游戏都使用它来创造丰富的体验,但并非总是如此。让我们以第一代侠盗猎车手(GTA)游戏为例。分析地图上行驶的汽车的模式,我们可以看到它们没有复杂的系统,驾驶员们被困在各自被分配的预定义路线或圈中。显然,在那个时代,这个 AI 路径查找系统非常先进,即使我们今天玩它,我们也不会因为 AI 角色而感到沮丧,因为它为那个游戏工作得非常好。
AI 驾驶员遵循他们的路径,每当玩家挡在他们面前时都会停下来。这表明他们每辆车前都有一个碰撞检测器,告诉他们是否有东西阻挡了路径。如果车前有东西,驾驶员会立即停车,直到路径畅通,他们才会再次驾驶。这是驾驶员拥有某种寻路系统的迹象,该系统无法解决他们无法继续以同一方向行驶的不同情况。因此,为了避免游戏中出现任何错误或漏洞,程序员选择让驾驶员在这种情况下停车。

前面的案例场景,即驾驶员在无法继续前进时停车,成为他们未来游戏中最大的优势之一。在 GTA 游戏中,许多事物都发生了演变,AI 无疑是其中之一。他们已经改进了 AI 驾驶员,使他们意识到情况及其周围环境。让我们分析一下GTA San Andreas,这款游戏也适用于手机。在这款游戏中,如果我们把车停在 AI 驾驶员面前,结果会完全不同。根据 AI 驾驶员的性格,他们的反应会有所不同;例如,其中一些驾驶员可能会简单地按喇叭并稍作等待,如果玩家继续阻挡他们的道路,驾驶员会超过玩家。其他人可能会更加激进,下车与玩家进行身体对抗。
如果 AI 驾驶员通过听到枪声意识到环境变得危险,他们会加速并选择最快的路径逃离该情况。这种行为表明,AI 角色在结合可能性地图的情况下,拥有更复杂和精细的寻路系统,周围的环境将反映他们最终选择的路径。

如我们所见,现在的驾驶员在游戏中的存在感比第一代游戏要明显得多。在前一章中,我们学习了如何创建一个简单的寻路系统,这与我们在第一代 GTA 游戏中分析的系统非常相似。现在,我们将深入探讨如何创建一个能够应对任何突发情况的 AI 角色。
这仍然是一些尚未完美解决的问题之一,许多开发者正在尝试新的方法来创建能够像被困在相同情况下的真人一样行为的 AI 角色。一些公司已经接近这一目标——一个很好的例子是 Rockstar Games 及其 GTA 系列,因此我们选择从他们的例子开始。
A*搜索算法
不可预测的情况通常会导致大量时间用于编写角色可能性的广泛可能性。因此,有必要考虑一种新的方法来创建更好的寻路系统,其中角色可以自己实时分析周围环境并选择最佳路径。为此效果而变得非常流行的一种方法是使用theta 算法,它允许角色不断搜索最佳路径,而无需手动设置它们需要遵循的点。
Theta 搜索算法(A*)是一种广泛使用的搜索算法,可用于解决许多问题,寻路就是其中之一。使用此算法解决寻路问题非常常见,因为它结合了均匀成本搜索和启发式搜索。Theta 搜索算法检查地图的每个角落,以帮助角色确定是否可以使用该位置,同时试图达到目标地点。
它是如何工作的
在 theta 算法可以工作之前,游戏地图或场景需要准备或预先分析。包括地图所有资产的环境将被处理为一个图。这意味着地图将被分割成不同的点和位置,这些被称为节点。这些节点用于记录搜索的所有进度。在记住地图位置的同时,每个单独的节点都有其他属性,如适应性、目标和启发式,通常用字母 f、g 和 h 表示。适应性、目标和启发式属性的目的在于根据当前节点对路径的优劣进行排序。
节点之间的路径被分配不同的值。这些值通常表示节点之间的距离。节点之间的值不一定是距离。它也可以是时间;这有助于我们找到最快的路径而不是最短的路径,例如。Theta 算法使用两个列表,一个开放列表和一个关闭列表。开放列表包含已完全探索的节点。标记数组也可以用来确定某个状态是否在开放列表或关闭列表中。

这意味着角色将不断搜索最佳节点以实现最快或最短的结果。正如我们在前面的截图中所见,地图已经预先分析过,可通行区域由小灰色方块表示,而大方块则代表被某些物体或环境资产阻挡的区域。由黑白圆圈表示的 AI 角色需要逐节点移动,直到到达星形物体。如果某个节点因某种原因被阻挡,角色将迅速切换到最近的节点,然后继续前进。
如我们所见,这种路径查找方法的原理与我们之前创建的非常相似,其中角色逐点跟随直到到达最终目的地。主要区别在于,使用 Theta 算法时,点是由 AI 自动生成的,这使得它成为开发大型或复杂场景时的最佳选择。
使用 A*的缺点
Theta 算法并不是一个可以在任何地方或任何游戏中使用的完美解决方案,我们应该牢记这一点。因为 AI 角色一直在寻找最佳路径,所以 CPU 的大量资源被专门用于这项任务。鉴于平板电脑和移动设备现在是流行的游戏平台,值得提到的是,为这些平台开发游戏需要特别注意 CPU 和 GPU 的使用,因此,A*路径查找在这里可能是一个缺点。
但硬件限制并不是唯一的缺点。当我们让 AI 承担所有工作而不进行任何人工控制时,bug 出现的可能性非常高。这也是为什么现代游戏更喜欢使用开放世界地图并遇到很多 bug 和奇怪的 AI 反应的原因之一,因为在庞大的游戏区域中很难缩小所有可能的结果。
"在最新的演示中,开放世界游戏中的 bug 是自然的"
最终幻想 XV 导演

最终幻想 XV 的导演对此问题进行了评论,表示在每一个开放世界游戏中都会出现 bug。这完美地总结了为什么在开发开放世界游戏时使用 theta 算法进行 AI 路径查找是一个流行且有效的方法,但它并不完美,bug 肯定会发生。
现在我们对 theta 算法及其优缺点有了基本的了解,让我们继续到实际部分。
直接从 A 到 B
我们将从一个非常简单的例子开始,一个点与另一个点之间没有任何障碍。这将帮助我们可视化算法如何找到最佳路径。然后我们将添加一个障碍物,并观察算法在同时绕过障碍物时如何选择最佳路径。

在这个网格上,我们有两个点,A 是起点,B 是终点。我们想要找到这两个点之间的最短路径。为了帮助我们解决这个问题,我们将使用 A* 算法,并看看它是如何找到最短路径的。

因此,算法计算每一步以找到最短路径。为了计算这个,算法使用了之前发现的两个节点,G 节点和 H 节点。G 代表从起点到距离,因此它计算从A位置有多远。H 代表从终点到距离,因此它计算从B位置有多远。如果我们把两个节点相加(G + H = F),我们得到 F 节点值,它代表最短路径。
在这种情况下,最短数字是42,因此我们可以移动到那个位置并再次计算所有可用的假设。

再次,算法计算从我们所在位置可用的最佳选项。我们接近 B 点,因此 H 节点的值正在变小,而 G 节点的值正在变大,这是完全正常的。在所有当前的可能性中,数字42再次是最低的,是最好的选择。所以自然的决定是朝着那个位置移动。

最后,我们到达了B点。一旦算法发现 H 节点值为零,这意味着它已经到达了目标位置,没有必要继续寻找更好的路径。
从点 A 到 B,途中存在障碍物
这正是 A*路径查找的工作方式;它从一个点到另一个点评估最佳选项,追求最短路径,直到达到最终目的地。之前的例子很简单,现在我们将使其更有趣,看看如果我们在地图上添加障碍物会发生什么。

使用相同的地图,我们用黑色画了一些方格,表示这些位置不能使用。现在,这开始变得稍微有点意思,因为我们尝试猜测最佳路径时可能会出错。再次,让我们计算最佳选项如下:

我们得到的结果与第一次测试完全相同,这是正常的,因为围绕A位置的所有点都没有位于黑格上。再次,我们可以朝着最低的数字42前进。

现在我们已经做出了第一步,并计算了从那个点可以采取的最佳选项,我们处于一个有趣的情况。在这个时候,我们有三个最低的数字,我们必须选择一个。我们需要找到通往B位置的最短路径,因为三个最低的数字相同,我们需要根据 H 节点来做出决定,它代表我们当前位置和B位置之间的距离。两个位置有38的 H 值,而只有一个位置的值为24,这使得它成为三个中最低的 H 值。所以让我们朝那个方向前进,这似乎更接近最终目的地。

从现在开始,我们可以注意到 F 值正在增加,这代表最短路径值。这是由于我们在地图上添加的黑方块。因为它们,我们需要绕行,增加我们需要采取的路径长度。这就是 AI 将感知墙壁的方式;他们知道最终目的地很近,但为了到达那里,他们不能穿过墙壁,所以他们需要绕行,直到找到一扇开放的门或类似的东西。
现在,最低的值在另一个方向,这意味着我们需要返回以找到更好的路径。这是算法中的一个非常重要的方面,因为如果我们让角色在行走的同时搜索最佳路径,我们将得到更接近人类的结果。它看起来就像他在寻找正确的路径以达到目标地点,就像一个人不知道正确路径时一样。另一方面,角色可以被编程在开始移动之前完成所有计算,在这种情况下,我们会看到一个角色直接走向正确的路径,直到到达终点。这两种方法都是有效的,可以根据不同的目的和不同的游戏来使用。
在继续我们的寻路过程中,我们需要持续选择最小值,因此在这个点上,我们需要返回并在这两个最小值之间进行选择,48。它们都有相同的 G 和 H 值,所以找出最佳路径的唯一方法就是随机选择其中一个点,或者预先计算它们,看看哪一个会有最低的值。所以让我们随机选择一个点,看看会出现哪些值。

在选择了两种最短可能性之一后,我们发现数值正在增加,因此我们需要回过头来计算另一个值,看看在那之后是否还有更低的数值。因为我们已经可以看到地图,并且已经知道B点的位置,所以我们确信最低的数值实际上比刚才出现的68数值还要远。但如果我们不知道 B 点的位置,我们仍然需要检查那个48数值,看看目标点是否接近那个位置。这就是 AI 角色在游戏过程中会不断检查最低 F 值的原因。

在选择了新的位置之后,我们可以看到它并没有提供任何更好的机会,我们需要继续寻找更好的路径,在这种情况下,将是我们已经发现但尚未计算结果的点。再一次,我们有两个最低的 F 值,我们将选择最低的 H 值,即20。

在计算新的可能性之后,我们注意到我们需要再次选择54,看看最终目的地是否更接近那个点。这正是当我们编程 AI 寻找到达最终目的地的最短路径时会发生的过程。计算需要在实时完成,并且正如我们开始注意到的那样,它们可以变得非常复杂。这就是为什么它消耗了大量的 CPU 功率,因为它是由硬件组件指定的这个功能(计算)。
现在,我们将选择数字54,因为它是地图上最低的数字。

如果我们继续向下移动,数值将会增加,这意味着我们正在远离我们需要到达的地方。如果我们是 AI 并且不知道最终目的地在顶部,我们就需要检查60这个数字,因为它在目前是最有希望的。所以,让我们计算结果。

现在,我们可以看到有很多相同的最低数值,它们是62,所以我们需要探索它们所有,并继续计算,直到角色找到正确的路径。为了举例,我们将移动到地图上现在可以看到的所有最低数值。

在探索了所有最低的可能性之后,我们可以看到我们正在接近最终目的地。在这个时候,可用的最低值是68,在那之后到达最终点将变得容易。

最后,我们到达了点B目的地。这是 A*算法的视觉方面,其中较深的灰色区域表示计算机已访问的位置,较浅的灰色区域表示我们已访问的区域的结果计算。
计算机可以实时计算最佳路径,或者开发者也可以选择在导出游戏之前让 AI 计算最佳选项。这样,AI 将自动知道游戏开始时需要遵循的路径,从而节省一些 CPU 功率。
为了解释如何在编程语言中实现这一点,我们将使用伪代码来演示这个示例。这样我们可以从头到尾理解我们如何在任何编程语言中创建搜索方法,以及我们如何自己适应它:
OPEN // the set of nodes to be evaluated
CLOSED // the set of nodes already evaluated
Add the start node to OPEN
loop
current = node in OPEN with the lowest f_cost
remove current from OPEN
add current to CLOSED
if current is the target node // path has been found
return
foreach neighbor of the current node
if neighbor is not traversable or neighbor is in CLOSED
skip to the next neighbor
if new path to neighbor is shorter OR neighbor is not in OPEN
set f_cost of neighbor
set parent of neighbor to current
if neighbor is not in OPEN
add neighbor to OPEN
让我们分析一下我们用来创建示例的每一行代码。我们将网格地图分为两个不同的类别:OPEN和CLOSED。OPEN的是我们已经探索的方块,在图像上由深灰色块表示。而CLOSED的是我们尚未探索的白色块。这将允许 AI 区分已探索和未探索的方块,从一点到另一点寻找最佳路径:
Add the start node to OPEN
然后,我们分配了第一个被认为是OPEN的方块;这将设置起点,并会自动从这个位置开始计算最佳选项:
loop
current = node in OPEN with the lowest f_cost
remove current from OPEN
add current to CLOSED
之后,我们需要创建一个循环,并在循环内部有一个名为current的临时变量;这等于OPEN列表中具有最低 F 成本的节点。然后它将从OPEN列表中移除并添加到CLOSED列表中:
if current is the target node // path has been found
return
然后,如果当前节点是目标节点,代码假设最终目的地已被探索,我们可以直接退出循环:
foreach neighbor of the current node
if neighbor is not traversable or neighbor is in CLOSED
skip to the next neighbor
否则,我们必须遍历当前节点的每个neighbor节点。如果它不可遍历,意味着我们无法通过该位置,或者如果它之前已被探索并且位于CLOSED列表中,代码可以跳到下一个邻居。这部分设置了可以移动的位置,并告诉 AI 不要考虑之前已探索的位置:
if new path to neighbor is shorter OR neighbor is not in OPEN
set f_cost of neighbor
set parent of neighbor to current
if neighbor is not in OPEN
add neighbor to OPEN
如果不是这种情况,那么我们可以继续前进并检查一些事情。如果新路径到neighbor比旧路径短,或者如果neighbor不在OPEN列表中,那么我们就通过计算g_cost和h_cost来设置neighbor的f_cost。我们看到新的可能方块有来自当前方块的孩子,因此我们可以追踪正在采取的步骤。最后,如果neighbor不在OPEN列表中,我们可以将其添加进去。
通过这种方式循环,代码将不断寻找最佳选项,并朝着最近的值移动,直到到达目标节点值。

我们刚刚学到的相同原理可以在 GTA 5 的行人中找到。显然,许多其他游戏也使用这种方法,但我们想用这个游戏作为大多数游戏中可以找到的两个寻路系统的例子。如果我们将这个系统应用于 AI 警察以搜索和找到玩家,我们就会得到在实际游戏玩法中可以看到的大致相同的结果。
除了搜索最终目的地之外,这只是一个最终代码的小部分,但我们将会看到 AI 角色逐步避开墙壁并接近玩家位置。除此之外,还需要向 AI 代码中添加更多内容,让角色知道在可能出现的多种情况下应该做什么,例如路径中间有水、楼梯、移动的汽车等等。
生成网格节点
现在我们将把到目前为止学到的知识应用到实际练习中。让我们首先创建或导入我们的场景到游戏编辑器中。

对于这个例子,我们将使用建筑物作为不可行走对象,但可以是任何我们选择的东西,然后我们需要将我们刚刚导入的对象与地面分开。为此,我们将它们分配到一个单独的层,并将其命名为不可行走。
然后,我们可以开始创建游戏的第一类,我们将从节点类开始:
public bool walkable;
public Vector3 worldPosition; public Node(bool _walkable, Vector3
_worldPos, int _gridX, int _gridY) {
walkable = _walkable;
worldPosition = _worldPos;
我们已经看到节点有两种不同的状态,要么是可行走的,要么是不可行走的,所以我们可以从创建一个名为walkable的布尔值开始。然后我们需要知道节点在世界中的哪个点表示,因此我们创建一个Vector 3用于worldPosition。现在,我们需要一种方法在创建节点时分配这些值,因此我们创建一个Node变量,它将包含有关节点的所有重要信息。
在创建这个类的必要部分之后,我们可以继续到grid类:
Node[,] grid;
public LayerMask unwalkableMask;
public Vector2 gridWorldSize;
publicfloatnodeRadius;
void OnDrawGizmos()
{
Gizmos.DrawWireCube(transform.position,new
Vector3(gridWorldSize.x,1,gridWorldSize.y));
}
首先,我们需要一个二维数组来表示我们的网格,所以让我们创建一个二维节点数组,我们可以称它为grid。然后我们可以创建一个Vector2来定义这个网格在世界坐标中覆盖的区域,并称它为gridWorldSize。我们还需要一个float变量来定义每个单独的节点覆盖的空间量,在这个类中称为nodeRadius。然后我们需要创建一个LayerMask来定义不可行走区域,并将其命名为unwalkableMask。
为了在我们的游戏编辑器中可视化我们刚刚创建的网格,我们决定使用OnDrawGizmos方法;使用这个方法是很有用的,但不是强制性的:
public LayerMask unwalkableMask;
public Vector2 gridWorldSize;
public float nodeRadius;
Node[,] grid;
float nodeDiameter;
int gridSizeX, gridSizeY;
void Start() {
nodeDiameter = nodeRadius*2;
gridSizeX = Mathf.RoundToInt(gridWorldSize.x/nodeDiameter);
gridSizeY = Mathf.RoundToInt(gridWorldSize.y/nodeDiameter);
CreateGrid();
}
void CreateGrid(){
grid = new Node[gridSizeX,gridSizeY];
Vector3 worldBottomLeft = transform.position - Vector3.right *
gridWorldSize.x/2 - Vector3.forward * gridWorldSize.y/2;
}
让我们创建一个Start方法,我们将添加一些基本的计算。我们需要弄清楚的是,我们可以在我们的网格中放入多少个节点。我们首先创建一个新的float变量,称为nodeDiameter,以及新的int变量,称为gridSizeX和gridSizeY。然后,在我们的Start方法内部,我们将添加nodeDiameter的值,它等于nodeRadius*2。gridSizeX等于gridWorldSize.x/nodeDiameter,这将告诉我们gridWorldSize.x中可以放入多少个节点。然后我们将数字四舍五入以适应整数,因此我们将使用Mathf.RoundToInt来实现这一点。在创建x轴的计算之后,我们可以复制相同的代码并更改它以使其适用于y轴。为了最终完成我们的Start方法,我们创建一个新的函数,我们将称之为CreateGrid():
public LayerMask unwalkableMask;
public Vector2 gridWorldSize;
public float nodeRadius;
Node[,] grid;
float nodeDiameter;
int gridSizeX, gridSizeY;
void Start(){
nodeDiameter = nodeRadius*2;
gridSizeX = Mathf.RoundToInt(gridWorldSize.x/nodeDiameter);
gridSizeY = Mathf.RoundToInt(gridWorldSize.y/nodeDiameter);
CreateGrid();
}
void CreateGrid()
{
grid = new Node[gridSizeX,gridSizeY];
Vector3 worldBottomLeft = transform.position - Vector3.right *
gridWorldSize.x/2 - Vector3.forward * gridWorldSize.y/2;
for (int x = 0; x < gridSizeX; x ++) {
for (int y = 0; y < gridSizeY; y ++) {
Vector3 worldPoint = worldBottomLeft + Vector3.right *
(x * nodeDiameter + nodeRadius) + Vector3.forward * (y
* nodeDiameter + nodeRadius);
bool walkable = !(Physics.CheckSphere(worldPoint,
nodeRadius,unwalkableMask));
grid[x,y] = new Node(walkable,worldPoint);
}
}
}
在这里,我们添加了grid变量的值,grid = new Node[gridSizeX, gridSizeY];。现在我们需要添加碰撞检测,这将确定地图的可通行和非通行区域。为此,我们创建了一个循环,这在之前展示的代码中可以看到。我们简单地添加了一个新的Vector3变量来获取地图的左下角,称为worldBottomLeft。然后我们分配了碰撞检测,它将通过使用Physics.Check来搜索任何与可通行区域发生碰撞的对象:
void OnDrawGizmos() {
Gizmos.DrawWireCube(transform.position,new
Vector3(gridWorldSize.x,1,gridWorldSize.y));
if (grid != null) {
foreach (Node n in grid) {
Gizmos.color = (n.walkable)?Color.white:Color.red;
Gizmos.DrawCube(n.worldPosition, Vector3.one *
(nodeDiameter-.1f));
}
}
}
在测试之前,我们需要更新我们的OnDrawGizmos函数,以便我们可以在地图上看到网格。为了使网格可见,我们使用nodeDiameter值来设置每个立方体的尺寸,并分配了红色和白色的颜色。如果一个节点是可通行的,颜色将被设置为白色;否则,它将被设置为红色。现在我们可以测试它了:

结果非常棒;现在我们有一个可以自动分析地图并指示可通行和非通行区域的网格。这部分完成后,其余部分将更容易实现。在继续下一部分之前,我们需要添加一个方法,告诉我们的角色它站在哪个节点上。在我们的代码中,我们将添加一个名为NodeFromWorldPoint的函数,使其成为可能:
public LayerMask unwalkableMask;
public Vector2 gridWorldSize;
public float nodeRadius;
Node[,] grid;
float nodeDiameter;
int gridSizeX, gridSizeY;
void Start(){
nodeDiameter = nodeRadius*2;
gridSizeX = Mathf.RoundToInt(gridWorldSize.x/nodeDiameter);
gridSizeY = Mathf.RoundToInt(gridWorldSize.y/nodeDiameter);
CreateGrid();
}
void CreateGrid()
{
grid = new Node[gridSizeX,gridSizeY];
Vector3 worldBottomLeft = transform.position - Vector3.right *
gridWorldSize.x/2 - Vector3.forward * gridWorldSize.y/2;
for (int x = 0; x < gridSizeX; x ++) {
for (int y = 0; y < gridSizeY; y ++) {
Vector3 worldPoint = worldBottomLeft + Vector3.right *
(x * nodeDiameter + nodeRadius) + Vector3.forward * (y
* nodeDiameter + nodeRadius);
bool walkable = !(Physics.CheckSphere(worldPoint,
nodeRadius,unwalkableMask));
grid[x,y] = new Node(walkable,worldPoint);
}
}
}
public Node NodeFromWorldPoint(Vector3 worldPosition) {
float percentX = (worldPosition.x + gridWorldSize.x/2) /
gridWorldSize.x;
float percentY = (worldPosition.z + gridWorldSize.y/2) /
gridWorldSize.y;
percentX = Mathf.Clamp01(percentX);
percentY = Mathf.Clamp01(percentY);
int x = Mathf.RoundToInt((gridSizeX-1) * percentX);
int y = Mathf.RoundToInt((gridSizeY-1) * percentY);
return grid[x,y];
} void OnDrawGizmos() {
Gizmos.DrawWireCube(transform.position,new
Vector3(gridWorldSize.x,1,gridWorldSize.y));
if (grid != null) {
foreach (Node n in grid) {
Gizmos.color = (n.walkable)?Color.white:Color.red;
Gizmos.DrawCube(n.worldPosition, Vector3.one *
(nodeDiameter-.1f));
}
}
}
我们终于完成了示例的第一部分。我们有一个可以在任何场景中工作的代码,我们只需要定义我们想要代码搜索可通行和非通行区域的地图比例,以及每个节点的尺寸,以防我们想要改变寻路的精度记住,如果我们增加地图上的节点数量,将需要更多的 CPU 功率来计算寻路系统)。
寻路实现
下一步是将角色设置为搜索我们想要的最终目的地。让我们先创建一个新的类,我们将称之为pathfinding。这个类将管理搜索最佳路径以到达最终目的地。它将实时计算角色需要遵循的最短路径,并且每秒更新一次,所以如果最终目的地在移动,它将保持跟随并重新计算最佳路径。

我们首先将 AI 角色添加到我们的游戏编辑器中,它最终将搜索游戏中的另一个角色。为了测试目的,我们将简单地为我们的人物添加一些基本功能,使他能够在地图上移动,但我们也可以使用一个简单的立方体来测试路径查找系统是否工作。
在将我们的角色导入到游戏中后,我们可以开始创建一个将被分配给它的类:
Grid grid;
void Awake(){
requestManager = GetComponent<PathRequestManager>();
grid = GetComponent<Grid>();
}
void FindPath(Vector3 startPos, Vector3 targetPos)
{
Node startNode = grid.NodeFromWorldPoint(startPos);
Node targetNode = grid.NodeFromWorldPoint(targetPos);
}
我们首先创建一个名为FindPath的函数,该函数将存储计算起始位置和目标位置之间距离所需的所有必要值。然后我们添加一个Grid变量,它的值将与我们之前创建的grid相同。然后我们使用Awake函数来访问grid值:
void FindPath(Vector3 startPos, Vector3 targetPos)
{
Node startNode = grid.NodeFromWorldPoint(startPos);
Node targetNode = grid.NodeFromWorldPoint(targetPos);
List<Node> openSet = new List<Node>();
HashSet<Node> closedSet = new HashSet<Node>();
openSet.Add(startNode);
}
然后我们需要创建一个列表,将包含游戏中所有存在的节点,正如我们之前所演示的那样。一个列表包含所有OPEN节点,另一个将包含所有CLOSED节点:
public bool walkable;
public Vector3 worldPosition;
public int gCost;
public int hCost;
public Node parent;
public Node(bool _walkable, Vector3 _worldPos, int _gridX, int _gridY)
{
walkable = _walkable;
worldPosition = _worldPos;
}
public int fCost
{
get {
return gCost + hCost;
}
}
现在我们已经打开了Node类,并添加了名为gCost和hCost的新变量。这个类的想法是计算最短路径值,正如我们之前所看到的,为了得到代表最短路径的fCost,我们需要将g和h节点的值相加。
f(n)=g(n)+h(n)。
一旦编辑了Node类,我们就可以回到我们的路径查找类,继续实现那些将使我们的 AI 角色搜索最佳路径的代码行:
Grid grid;
void Awake()
{
grid = GetComponent<Grid> ();
}
void FindPath(Vector3 startPos, Vector3 targetPos)
{
Node startNode = grid.NodeFromWorldPoint(startPos);
Node targetNode = grid.NodeFromWorldPoint(targetPos);
List<Node> openSet = new List<Node>();
HashSet<Node> closedSet = new HashSet<Node>();
openSet.Add(startNode);
while (openSet.Count > 0)
{
Node node = openSet[0];
for (int i = 1; i < openSet.Count; i ++) {
if (openSet[i].fCost < node.fCost || openSet[i].fCost ==
node.fCost) {
if (openSet[i].hCost < node.hCost)
node = openSet[i];
}
}
回到我们的路径查找类;我们需要定义角色所在的位置的当前节点。为了实现这一点,我们添加了Node currentNode = openSet[0];这将 0 设置为默认节点。然后我们创建循环,比较可能节点的fCost以选择最佳选项,openSet[i].fCost < node.fCost || openSet[i].fCost == node.fCost。这是我们用来实现这个例子所需结果所使用的代码,但如果需要,它仍然可以进一步优化:
Grid grid;
void Awake()
{
grid = GetComponent<Grid> ();
}
void FindPath(Vector3 startPos, Vector3 targetPos)
{
Node startNode = grid.NodeFromWorldPoint(startPos);
Node targetNode = grid.NodeFromWorldPoint(targetPos);
List<Node> openSet = new List<Node>();
HashSet<Node> closedSet = new HashSet<Node>();
openSet.Add(startNode);
while (openSet.Count > 0)
{
Node node = openSet[0];
for (int i = 1; i < openSet.Count; i ++)
{
if (openSet[i].fCost < node.fCost || openSet[i].fCost ==
node.fCost){
if (openSet[i].hCost < node.hCost)
node = openSet[i];
}
}
openSet.Remove(node);
closedSet.Add(node);
if (node == targetNode) {
RetracePath(startNode,targetNode);
return;
}
继续我们的循环,我们现在已经定义了当前节点被设置为OPEN或CLOSED的情况,并确定如果当前节点值等于目标节点值,这意味着角色已经到达了最终目的地if (currentNode == targetNode):
public List<Node> GetNeighbors(Node node)
{
List<Node> neighbors = new List<Node>();
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0)
continue;
int checkX = node.gridX + x;
int checkY = node.gridY + y;
if (checkX >= 0 && checkX < gridSizeX && checkY >= 0 &&
checkY < gridSizeY) {
neighbors.Add(grid[checkX,checkY]);
}
}
}
}
现在,我们需要遍历 current node 的每个 neighbor 节点。为了做到这一点,我们决定将其添加到我们的网格代码中,因此我们需要打开在示例开头创建的 grid 类,并添加之前演示的 List 函数。然后我们将添加必要的值到 Node 类(gridX 和 gridY):
public bool walkable;
public Vector3 worldPosition;
public int gridX;
public int gridY;
public int gCost;
public int hCost;
public Node parent;
public Node(bool _walkable, Vector3 _worldPos, int _gridX, int _gridY)
{
walkable = _walkable;
worldPosition = _worldPos;
gridX = _gridX;
gridY = _gridY;
}
public int fCost
{
get
{
return gCost + hCost;
}
}
在这里,我们添加了 Node 类的最终内容,该类包含 gridX 和 gridY 值,这些值将被 grid 代码使用。这是对 Node 类的最终查看。现在,我们可以再次转向路径查找类:
foreach (Node neighbor in grid.GetNeighbors(node)) {
if (!neighbor.walkable || closedSet.Contains(neighbor))
{
continue;
}
}
在这里,我们添加了一个 foreach 循环,该循环将遍历邻居节点以检查它们是否可通行或不可通行。
为了更好地理解我们接下来要采取的步骤,我们将有一些示例图来展示我们想要实现的内容以完成路径查找系统:

我们首先需要沿着 X 轴计数,以了解我们距离 X 轴上的最终位置有多少个节点,然后我们沿着 Y 轴计数,以找出我们距离 Y 轴上的最终位置有多少个节点:

在这个例子中,我们可以看到,为了到达 B 位置,我们需要向上移动两个点。因为我们总是在寻找最短路径,所以在向上移动的同时,我们在 X 轴上移动:

要计算到达 B 位置所需的垂直或水平移动次数,我们只需将较大的数字减去较小的数字。例如,在直线上到达 B 位置之前,我们需要计算 5-2 = 3,这告诉我们需要多少次水平移动才能到达最终目的地。
现在,我们可以回到路径查找代码,并添加我们刚刚学到的公式:
int GetDistance(Node nodeA, Node nodeB)
{
int dstX = Mathf.Abs(nodeA.gridX - nodeB.gridX);
int dstY = Mathf.Abs(nodeA.gridY - nodeB.gridY);
if (dstX > dstY)
return 14*dstY + 10* (dstX-dstY);
return 14*dstX + 10 * (dstY-dstX);
}
在这里,我们只是添加了代码行,这些代码将告诉我们 AI 需要多少次水平和垂直步骤才能到达目标目的地。现在,如果我们回顾一下我们在本章开头创建的伪代码,以检查还需要创建什么,我们可以看到我们遵循了相同的结构,并且我们几乎完成了。伪代码如下:
OPEN // the set of nodes to be evaluated
CLOSED // the set of nodes already evaluated
Add the start node to OPEN
loop
current = node in OPEN with the lowest f_cost
remove current from OPEN
add current to CLOSED
if current is the target node // path has been found
return
foreach neighbor of the current node
if neighbor is not traversable or neighbor is in CLOSED
skip to the next neighbor
if new path to neighbor is shorter OR neighbor is not in OPEN
set f_cost of neighbor
set parent of neighbor to current
if neighbor is not in OPEN
add neighbor to OPEN
因此,让我们继续将更多重要内容添加到我们的代码中,并继续向路径查找类的结论迈进。
我们需要设置邻居的 f_cost,正如我们已知的那样,为了计算这个值,我们需要使用邻居节点的 g_Cost 和 h_Cost:
foreach (Node neighbor in grid.GetNeighbors(node))
{
if (!neighbor.walkable || closedSet.Contains(neighbor)) {
continue;
}
int newCostToNeighbor = node.gCost + GetDistance(node, neighbor);
if (newCostToNeighbor < neighbor.gCost ||
!openSet.Contains(neighbor)) {
neighbor.gCost = newCostToNeighbor;
neighbor.hCost = GetDistance(neighbor, targetNode);
neighbor.parent = node;
}
在路径查找类中,我们添加了以下代码,该代码将计算邻居节点以检查它们的 f_cost:
void RetracePath(Node startNode, Node endNode) {
List<Node> path = new List<Node>();
Node currentNode = endNode;
while (currentNode != startNode) {
path.Add(currentNode);
currentNode = currentNode.parent;
}
path.Reverse();
grid.path = path;
}
在退出循环之前,我们将调用一个名为RetracePath的函数,并给它提供startNode和targetNode。然后我们必须创建一个具有相同名称的新函数,并分配一个已经探索的节点列表。为了可视化路径查找,看看它是否正常工作,我们还在grid类中创建了一个路径:
public List<Node> path;
void OnDrawGizmos()
{
Gizmos.DrawWireCube(transform.position,new Vector3(gridWorldSize.x,1,gridWorldSize.y));
if (grid != null) {
foreach (Node n in grid) {
Gizmos.color = (n.walkable)?Color.white:Color.red;
if (path != null)
if (path.Contains(n))
Gizmos.color = Color.black;
Gizmos.DrawCube(n.worldPosition, Vector3.one * (nodeDiameter-.1f));
}
}
}
grid类的这一部分已被更新,现在包含List、path以及一个新的小工具,它将在编辑器视图中显示 AI 位置和目标位置之间的路径:
public Transform seeker, target;
Grid grid;
void Awake()
{
grid = GetComponent<Grid> ();
}
void Update()
{
FindPath (seeker.position, target.position);
}
最后,为了总结我们的例子,我们在路径查找类中添加了一个void Update()方法,这将使 AI 不断搜索目标位置。
现在,我们可以继续到我们的游戏编辑器,并将我们创建的路径查找代码分配给网格。然后我们简单地分配 AI 角色和我们想要的目标位置:

如果我们测试路径查找系统,我们可以看到它运行得非常完美。在上面的截图里,左上角是 AI 角色的位置,右下角是目标目的地。我们可以看到角色规划了最短路径,并且避开了与建筑的碰撞:

然后,我们禁用了建筑物的网格,以便更好地查看地图的可行走和不可行走区域。我们可以看到角色只选择可行走区域,并避开其路径上的任何障碍。用静态图像展示它很复杂,但如果我们在实时改变目标位置,我们可以看到路径查找正在调整角色需要采取的路线,并且它总是选择最短路径。

我们刚刚创建的高级路径查找系统可以在许多大家喜爱的流行游戏中找到。现在我们已经学会了如何创建复杂的路径查找系统,我们能够重新创建现代游戏中如 GTA 或刺客信条中最先进 AI 角色的某些部分。谈到刺客信条,它将是我们的下一款游戏,作为下一章的参考,因为其 AI 角色在 A*路径查找和现实人群交互之间完美连接,正如我们在上面的截图中所看到的。
摘要
在本章中,我们回顾了如何创建点对点移动,但不是使用简单的方法,而是研究了大型且成功的游戏工作室如何解决 AI 最复杂的功能之一,即路径查找。在这里,我们学习了如何使用 theta 算法来重现人类特征,这有助于我们在正确的方向上搜索和移动,以便到达期望的目的地。
在下一章中,我们将讨论现实中的群体互动,这是尝试使人工智能角色尽可能真实的一个重要方面。我们将研究不同类型游戏中使用的方法,同时我们还将探讨人类和动物在其环境中如何互动,以及我们如何将这一点应用到我们的 AI 代码中。
第八章:群体互动
在理解了如何开发一个可以在地图上自由移动的 AI 角色,并寻找到达特定目的地的最佳路径之后,我们可以开始着手角色之间的互动。在本章中,我们将探讨现实中的群体互动,如何开发可信的群体行为,以及角色应该如何感知其他群体成员。本章的目标是继续向我们的 AI 角色提供关于环境的信息,在这个特定案例中,关于游戏中的其他智能代理。在本章中,我们将讨论 AI 协调、通信和群体碰撞避免。
什么是群体互动
群体互动是一个现实生活中的主题,通常指的是多个生物共享同一空间。一个很大的例子是人类生活,人类如何与其他人类和其他物种互动。我们大多数时候所做的决定都涉及其他人,从简单的决定到最先进和复杂的决定。让我们假设我们想要买一张电影票,电影在下午 3 点开始。如果我们是唯一对看这部电影感兴趣的人,我们可以在电影开始前 2 分钟到达电影院买票,这样我们就能准时看电影。但如果超过 100 人对看同一部电影感兴趣,我们就需要提前做好预测,并更早地到达电影院,以便有时间买票。一旦我们到达电影院,就有关于我们如何等待直到轮到我们买票的规则。通常我们会排在最后一个人的后面。这种行为是群体互动的一个例子。我们生活在其他人类周围,因此我们需要相应地调整我们的目标。
在视频游戏中,我们也可以找到这种类型的互动,并且可以从简单的行为到高级和复杂的行为。如果我们游戏中有多于一个 AI 角色,并且它们共享同一空间,那么有时一个角色可能会与另一个角色发生碰撞。这取决于创作者思考,如果两个角色试图同时做同一件事会发生什么,这是否合理,或者它会导致错误。为了解决这些问题,我们需要思考并实施帮助角色共享同一空间、避免错误并更真实地行为的决策。
视频游戏和群体互动
正如我们之前所发现的,群体互动是现实生活中的问题,但它也可以在视频游戏中找到,尤其是在那些依赖于类似人类方面的游戏中。由于开放世界地图的流行,群体互动在游戏开发中成为一个非常重要的方面,因为游戏中的 AI 代理始终共享同一空间。这意味着几乎每个开放世界游戏都有必要规划一个群体互动系统。
刺客信条
在视频游戏中,一个非常流行的群体互动系统案例可以在《刺客信条》系列中找到。非玩家角色成群结队地在地图上行走,以简单的方式避免碰撞并与环境互动。这有助于为游戏创造一种真实氛围,这是一个至关重要的点,可以使游戏可信并让玩家沉浸于虚拟世界:

我们不仅能在游戏的一般人群中看到群体互动,还能在守卫和尤其是在战斗中看到。时不时地,玩家需要与几个守卫战斗,通常不止一个守卫准备攻击玩家。一个有趣的观点是守卫不会同时攻击;他们会评估情况,等待更好的攻击机会。
这个概念给多个非玩家角色之间的互动带来了一种感觉:

《侠盗猎车手》(GTA)
《侠盗猎车手》游戏系列是我们可以从中学到许多有趣教训的源泉。不断寻求通过尝试使其更加真实和可信来改进游戏,改变了玩家的关注点,从简单地关注主要角色转向周围环境。为了使环境更具吸引力和真实感,游戏的创作者开始花更多时间开发人工智能代理,如何移动,如何反应,以及如何互动。当时人工智能角色的互动具有开创性。
玩家可以看到角色停止交谈,在更戏剧性的事件中身体对抗,所有这些都使得环境更加生动:

如前述截图所示,游戏中的街道上挤满了不同的个体,他们正在相互互动。我们可以看到一个男人带着他的狗散步,两个女孩在交谈,一个年轻女人在给另一个女人拍照,所有这些都不以任何方式对游戏玩法做出贡献,但它们使体验更加生动和真实。
《模拟人生》
另一个群体互动的绝佳例子可以在现实生活模拟游戏《模拟人生》中找到。再次提到这款游戏是因为它塑造了开发者创造游戏的方式,在人工智能方面,他们对此做出了很多贡献。
非玩家角色并不意味着他们只需要处于闲置位置,等待事情发生。在这里我们可以看到所有角色都有独特的个性,并且它们相互互动。即使玩家放下控制器,只是观看游戏,也会有许多有趣的事情发生,所有这些都来自人工智能角色:

在本书之前的部分,我们已经分析了《模拟人生》角色的优先级,我们知道如果当时有更重要的事情,他们可以决定不做某件事。而现在我们知道了路径查找的工作原理,我们甚至可以给角色实现一个更高级的系统,例如,让他们根据自己的优先级进行组织,考虑他们到达特定目的地所需的时间,以便完成特定任务。但所有这些将在稍后进行探讨。
FIFA/职业进化足球
另一个需要特别提到的例子是多款体育游戏中可以找到的 AI 角色。即使从外表上看,它不是一个复杂的游戏类型,但体育游戏在 AI 开发方面可能是最先进的。
原因是这些游戏基于现实生活中的体育项目,其中许多是团队运动。开发一个真实且功能齐全的团队体育游戏存在许多困难,因此它是一个很好的案例研究:

上一张截图显示了 FIFA 17 的游戏画面。在这里我们可以看到,只有一名角色拥有球权,而其他所有人则分散开来,要么等待角色传球,要么预测角色的位置,试图从他那里赢得球。总共,游戏中 22 个角色(每边 11 个)只有一个球。这就是为什么体育游戏需要高度发展的 AI 角色,因为他们即使没有球也在不断工作。个别来看,他们都有自己的位置/角色,扮演防守或进攻,左边、右边或中间位置,等等。在团队中,他们都需要共同遵循策略并遵守游戏规则。如果我们的队友有球并且在向前跑,我们可以通过朝同一方向跑来支持他,这样他传球就会更容易,或者我们可以留在后面,因为如果那个球员失去球,就需要有人去捡回来。
其他角色的互动是持续发生的,不仅关乎追逐球以看谁能先拿到球,还关乎他们之间共享大量信息并试图赢得比赛。
规划人群互动
有时候我们在制作游戏的过程中会忽略规划阶段,认为只要有一个好点子,一切就会从我们的脑海中顺畅地流淌出来。成功的游戏之所以成功,是因为每个开发步骤都被计划到了最细节的程度,我们在创建自己的游戏时也应该记住这一点。目前,我们拥有强大的技术知识,可以开发出具有丰富 AI 功能的挑战性和有趣的游戏,因此我们的下一步是将创建游戏的能力与使它们看起来更好的计划相结合。
现在我们已经分析了一些视频游戏中流行的群体交互系统示例,我们可以看看如何规划这些类型的交互。我们将遵循之前的例子,看看我们如何将这些类似的群体交互规划到我们的游戏中。
群体斗争
让我们创建一个场景,在这个场景中,我们有多个 AI 角色在与玩家战斗。我们首先将战斗功能实现到角色代码中,比如单手攻击、双手攻击、防御、追击玩家等等。一旦我们实现了这些功能,角色就能与玩家战斗,这就是起点。如果我们没有做任何计划,而有四个角色在与玩家战斗,他们都会同时攻击以击败玩家。
这样做可能会有一些小错误,但如果我们没有时间创建一个更好的系统,它也能完成任务。我们想要的是让 AI 角色之间有一些互动,这样他们就不会在未分析情况的情况下同时攻击玩家,看起来很愚蠢:

因此,现在游戏已经运行,我们也拥有了跟随玩家并攻击他的敌人角色,我们想要规划 AI 角色之间的交互,让它们决定谁应该先攻击以及何时其他角色也可以发起攻击。
我们可以从众多因素中选择,这些因素将决定角色的特性,以便做出这个决定,而且我们计划得越多,AI 角色就越发达、越具挑战性:

在这个例子中,我们使用了 AI 角色与玩家之间的距离来确定哪个角色将先攻击。我们希望距离最近的角色先攻击,其他所有角色将等待直到那个角色的生命值变低。一旦那个角色的生命值变低,第二个最近的角色将介入战斗并攻击玩家。
现在我们已经为角色设定了第一个标准来决定哪个角色应该首先攻击,我们可以继续确定其他角色在等待时会发生什么。我们还需要考虑玩家可以随时决定攻击任何其他角色,我们不希望 AI 角色因为不是他的攻击时间而停留在闲置位置。所以,想法是考虑可能发生的情况,并计划 AI 在这些情况下的行为,特别是,特别是它们将如何相互交互:
public static int attackOrder;
public bool nearPlayer;
public float distancePlayer;
public static int charactersAttacking;
private bool Attack;
private bool Defend;
private bool runAway;
private bool surpriseAttack;
void Update ()
{
if(distancePlayer < 30f)
{
nearPlayer = true;
}
if(distancePlayer > 30f)
{
nearPlayer = false;
}
if(nearPlayer == true && attackOrder == 1)
{
Attack = true;
}
else
{
Defend = true;
}
}
我们可以从一个简单的代码开始,仅为了确定角色根据我们正在处理的情况的行为,然后我们可以根据需要继续添加更多内容,使其按我们的意愿工作。在这个例子中,我们创建了一个静态整数attackOrder,它将包含每个角色的攻击顺序,这样他们就知道是否是他们的攻击时间。之后,我们有一个公共布尔值nearPlayer,它将检查玩家是否靠近玩家角色。地图上可以有 30 个角色,但我们只想让最近的那几个攻击角色。在这个例子中,其他角色将简单地忽略玩家。为了确定 AI 角色是否靠近,我们有一个公共浮点值distancePlayer,它将是 AI 角色和玩家之间的距离。然后我们添加了一个公共静态整数charactersAttacking,其数值将在每个新角色靠近玩家时增加。我们可以使用这个信息来向其他角色提供有多少骨骼正在攻击玩家的信息。
就像这样的一个小而简单的代码可以为我们在进行的群体互动带来巨大的变化,因为我们可以使用关于有多少角色正在攻击玩家的信息来决定他们的行为。例如,我们可以确定如果只有两个角色在攻击,一个将不断防御玩家的攻击,而另一个进行攻击,当玩家从一个角色切换到另一个角色时,他们将做同样的事情并交换他们的角色,这使得玩家更难击败敌人:

这在前面的截图中可以体现出来,其中一个骨骼角色告诉另一个角色它将进行防御,而另一个角色可以从背后攻击玩家。这正是群体互动的本质,一个角色向另一个角色提供关于它能做什么或应该如何表现的信息。角色之间共享的信息越多,他们能做的选择就越多,他们的互动看起来就越真实,因为他们不是在单独行动。
如我们所见,即使使用简单的代码,我们也能实现复杂的结果,但思考并提前规划一切是必要的,并且显然,每次我们添加更多细节和选项时,代码都会变得更长。
通信(注意区域)
继续以同样的例子为例,我们地图上有几个骨骼,如果玩家靠近他们,他们就会开始攻击玩家,我们可以添加一个额外的功能,使他们之间的交互更加紧密。另一个能让角色像一群人而不是游戏中的单个角色一样行动的因素是沟通。例如,这里我们有骨骼,只有当玩家靠近时才会攻击,但如果靠近玩家的一个骨骼大声呼喊他看到了玩家角色,会发生什么呢?我们可以假设该区域周围的所有 AI 角色都会听到呼喊,并开始朝那个方向奔跑,以帮助他们的朋友。
一次又一次,我们可以使用简单的代码行来实现这一点,但如果我们没有计划交互以及角色应该如何作为一个群体行动,这种类型的元素将缺失在 AI 角色中,它们将独立行动,这会使它们不够智能。

如我们所见,这是我们目前拥有的系统。AI 角色之间没有沟通,所以只有足够靠近玩家的骨骼才知道玩家的位置。如果我们试图创建一个群体系统,我们需要计划类似这种情况。仅仅因为其他人看不到玩家角色,并不意味着他们必须像什么都没发生一样反应。
让我们思考一个现实生活中的场景。例如,我们有一个人在房子里,另一个人在外面。外面的人看到了一只令人难以置信的美丽的鸟,而房子里的人却看不到,所以它将留在房子里。如果看到鸟的人不与另一个人沟通,房子里的人将永远不知道这件事。所以,通常会发生的情况是,看到鸟的人会叫另一个人出来,这样他也能看到这只美丽的鸟。这是可以在我们的群体交互系统中实现的一种现实行为。
要将这种非交互情况转变为更现实版本,我们需要给我们的角色添加一个额外的功能,使他们能够相互沟通。在这个阶段,我们只需要简单的沟通,我们可以使用与之前用来确定角色是否可以看到玩家相似的代码:

因此现在我们有一个 AI 角色进入了玩家的触发区域,因此他会大声喊叫,让附近的 AI 角色也意识到玩家的位置。在先前的图中,我们可以看到现在不仅玩家有一个触发区域,被玩家发现的敌人也有一个。这个新的触发区域将用于警告其他角色,它代表的是一声喊叫。所以当我们玩游戏时,如果敌人发现了我们,我们会听到一声喊叫,这会给 AI 角色之间的交流带来一种感觉:
public static int attackOrder;
public bool nearPlayer;
public bool nearEnemyAttacked;
public float distancePlayer;
public static int charactersAttacking;
private bool Attack;
private bool Defend;
private bool runAway;
private bool surpriseAttack;
void Update ()
{
if(distancePlayer < 30f)
{
nearPlayer = true;
}
if(distancePlayer > 30f)
{
nearPlayer = false;
}
if(nearPlayer == true && attackOrder == 1)
{
Attack = true;
}
else
{
Defend = true;
}
if(nearEnemyAttacked == true)
{
runPlayerDirection();
}
}
要实现这一点,我们简单地添加了一个新的布尔值nearEnemyAttacked。与此相结合,我们添加了一个触发检测来检查是否有发现玩家的近处骨骼。如果触发,布尔值变为真;否则,它将保持为假。
一旦触发,该 AI 角色就需要呼叫周围的其他角色:

如前图所示,由于我们实施的交流系统,现在有三个角色完全清楚玩家的位置。最后一个角色也会喊叫,试图告诉其他人玩家的位置,但如果触发区域没有与 AI 角色重叠,则不会发生任何事情:

例如,敌人 4 离得太远,无法受到触发区域的影响,所以它会保持在那个位置,直到玩家接近他的位置;否则,他不会知道发生了什么。
这个例子中的技巧是让角色之间进行交谈,大声喊叫或试图引起附近角色的注意。这将使交流变得简单,将个体行为转化为更具吸引力的群体互动。
交流(与其他 AI 角色交谈)
在交流方面,可以展示的例子还有很多,因为总有可能找到新的方法在角色之间进行交流;就像在现实生活中,我们总是在寻找新的交流方式。但就目前而言,我们将坚持基本的交流形式,即谈话。
如果我们计划在游戏中拥有很多 AI 角色,这将会迅速占据游戏的大部分内容,玩家的焦点将直接或间接地集中到他们身上。可能我们不会创建的每个游戏都会有对玩家出现立即做出反应的角色,也许玩家在游戏中只是另一个角色,因此可能会被忽略。所以在这个部分,我们将排除玩家部分,并将专门规划 AI 角色的交互:

让我们创建一个拥有大量人群的城市,并为其中的一些人分配一些细节,使他们能够像真实的人群一样行动。我们可以从在我们的角色中添加基本的移动信息开始,比如行走、跑步、闲置和路径查找。在我们的角色中实现这一点后,我们就有一个可以在城市中四处走动、避免碰撞建筑并在人行道上行走的人物。
对于这个例子,我们首先的建议是添加一个简单的触发检测,使角色意识到当另一个角色经过附近时:

在为角色添加触发区域之后,我们可以进入下一步,并着手处理它们之间的交互。我们的计划是使用一个概率图来确定找到可以开始对话的已知人物的概率:
If(probabilityFriendly > 13)
{
// We have 87% of chance
talkWith();
}
为了使这个系统能够工作,我们添加了一个整数函数,在这个例子中我们称之为probabilityFriendly。这指的是找到友好人物的概率。当一个新角色进入触发区域时,计算将随机进行,如果数字符合我们的百分比,两个角色将停止四处走动并开始交谈。之后,我们可以继续添加更多细节到这个场景中,比如当他们的对话结束时,我们可以让他们边走边聊,还有无数其他可能从这个小的触发检测和概率图中派生出来的选项。
这个想法的背后的目的是拥有可以相互随机交互的角色。从玩家的角度来看,这看起来就像角色们是朋友,他们只是因为彼此认识而停下来聊天。这有助于创造一个逼真的氛围,这更多是关于规划角色之间所有可能的交互,而不是技术点。
团队运动
正如我们之前在解释一些流行的游戏中的群体交互系统时所见,体育游戏有一个高度发展的 AI 系统,在团队运动中特别有效。现在我们将深入探讨一些团队体育视频游戏的核心功能,看看它们是如何取得一些有趣的结果,使得这些游戏的 AI 角色既具有挑战性又逼真。
如果我们分析现实生活中的足球运动,我们会看到有两个队伍,每个队伍由十一个单独的球员组成。为了赢得比赛,球队需要比对手进更多的球,因此比赛可以分为两种基本形式:进攻,重点是进球;防守,重点是避免失球。比赛中只有一个球,所以球员的大部分时间都是在没有球权的情况下度过的,而这段时间对比赛的结果可能非常重要。球员要么试图从对手那里抢球,要么找到一个好的位置来接球。这就是当球员没有球权时的两种基本形式。
电子游戏试图模仿体育的每一个细节,由于它是一项团队运动,因此在人工智能群体交互的开发上投入了大量的工作。人工智能角色的心态需要更多地关注团队合作,而不是简单的个人表现。因此,他们只会做出某些决定,如果这些决定符合团队目标的话。
如果我们观看一场足球比赛,我们可以听到球员之间互相交谈,传递球,向前移动,向后移动等等。想法是在电子游戏中也有这种类型的交流。这并不一定需要是口头交流,而是关于使游戏更加逼真的动作。
让我们逐步分析角色在游戏中做出的基本人工智能决策。我们将从查看角色在场地的组织结构开始,如下面的图表所示:

这是在场地上足球队简单阵型的例子之一。在底部,我们可以看到一个圆圈,它代表守门员,负责防守球门。这个角色是唯一一个始终围绕这个区域的人;其他人如果愿意的话可以自由移动。现在我们已经有了足球队在场地上分布的视觉表示,我们可以继续这个例子。
游戏中的每个角色都有一个个人目标。这可能包括将球传给进攻球员,尽可能多地射门以尝试进球,简单地留在后面防守等等。虽然他们有这些个人目标,但他们也需要考虑团队目标,并决定在某个时刻哪个目标更重要,以及他们所做的决定是否有助于成功实现目标。
让我们继续创建单个球员。我们从基础开始,跟随球跑。为了创建这个,我们可以使用书中之前解释过的技术,例如走向一个物体的位置:
public float speed;
public Transform ball;
public bool hasBall;
void Start ()
{
speed = 1f;
}
void Update ()
{
if(hasBall == false)
{
Vector3 positionA = this.transform.position;
Vector3 positionB = ball.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB,
Time.deltaTime * speed);
}
if(hasBall == true)
{
}
}
在这里,我们有使角色追球的代码。在这个时候,我们将只处理一个角色,然后我们将逐步添加团队互动,以便至少有一个基本的我们可以在完全开发的游戏中看到的形式。所以如果我们只使用这段代码玩游戏,我们可以看到角色会朝向球的位置移动,这就是足球游戏的基本原则,即达到球:

目前,我们有一个单独的玩家正在正常工作,这是我们目前所期望的。如果我们向游戏中添加更多角色,他们都会朝向球移动,忽略其他一切,所以在游戏中不会发生任何沟通或互动:

如果游戏中的所有角色都朝向球的位置移动,就像我们在前面的图中看到的那样,那么这些角色就好像没有意识到周围的其他角色一样。为了避免这种情况,我们可以让离球最近的角色将这一信息传达给其他角色,这样他们就不需要为球而奔跑了。为了实现这一点,我们可以在每个角色与球之间的距离上进行一个恒定的计算:
public float speed;
public Transform ball;
public bool hasBall;
public float ballDistance;
void Start ()
{
speed = 1f;
}
void Update ()
{
if(hasBall == false)
{
Vector3 positionA = this.transform.position;
Vector3 positionB = ball.transform.position;
this.transform.position = Vector3.Lerp(positionA,
positionB, Time.deltaTime * speed);
}
if(hasBall == true)
{
}
ballDistance =Vector3.Distance(transform.position,ball.position);
}
为了实现这一点,我们使用了在书中之前探索过的距离计算方法。因此,现在代码中有三个新的变量,ballDistance是一个浮点数,它将测量角色与球之间的距离。
现在我们有了这个设定,我们需要让角色验证自己是否是所有人中最接近球的人,如果是的话,他就可以继续前进并朝向球的位置奔跑:
public float speed;
public Transform ball;
public bool hasBall;
public float ballDistance;
public static float teamDistance;
void Start ()
{
speed = 1f;
}
void Update ()
{
if(hasBall == false)
{
Vector3 positionA = this.transform.position;
Vector3 positionB = ball.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB,
Time.deltaTime * speed);
}
if(hasBall == true)
{
}
ballDistance =Vector3.Distance(transform.position,ball.position);
if(teamDistance < ballDistance)
{
teamDistance = ballDistance;
}
}
对于这个例子,我们决定简单地添加一个变量,这个变量将被所有角色共享,所以我们添加了一个静态浮点变量,称为teamDistance。这个变量将存储离球最近的角色的值。在这个时候,角色将知道他们是否是离球最近的人。从这个点开始,简单地移动到下一步,让角色检查自己是否是最接近球的人,如果是的话,它就可以朝向球的位置奔跑。这将是我们将添加到我们的 AI 角色中的第一个团队元素。他们将与其他角色核对,看看哪个角色应该得到球,正如我们计划的那样,离球最近的角色更有意义,但我们可以进一步分解,让他们检查哪个角色会先到达球。然而,对于这个例子,我们将坚持所有角色以相同速度移动的原则:
public float speed;
public Transform ball;
public bool hasBall;
public float ballDistance;
public static float teamDistance;
public bool nearTheBall;
public float teamdist;
void Start ()
{
speed = 0.1f;
teamDistance = 10;
}
void Update ()
{
teamdist = teamDistance;
if(hasBall == false && nearTheBall == true)
{
Vector3 positionA = this.transform.position;
Vector3 positionB = ball.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB,
Time.deltaTime * speed);
}
if(hasBall == true)
{
}
ballDistance =Vector3.Distance(transform.position,ball.position);
if(teamDistance > ballDistance)
{
teamDistance = ballDistance;
}
if(teamDistance == ballDistance)
{
nearTheBall = true;
}
if(teamDistance < ballDistance)
{
nearTheBall = false;
}
}

如此一来,我们可以看到只有一名角色在追球。其他所有角色都有一种感觉,即他们的某个队友离球更近,所以那个队友会得到球。在这个时刻,我们已经有了一种简单的群体互动形式,并且我们正在正确的道路上。
我们接下来需要处理的问题是球将在整个游戏中移动,而我们的代码是在静态场景中工作的,但如果球被移动,团队距离检查应该重置。原因是当角色 AI 靠近球时,这个值会降低,而这个值永远不会增加,所以我们需要更新它。我们首先为球创建一个新的脚本:
public Vector2 curPos;
public Vector2 lastPos;
public bool ballMoving;
void Update ()
{
curPos = transform.position;
if(curPos == lastPos)
{
ballMoving = false;
}
else
{
ballMoving = true;
characterAI.teamDistance = 10;
}
lastPos = curPos;
}
将这个脚本添加到球上后,每当球被移动时,球员的距离检查都会更新。现在让我们确保球可以移动。为了实现这一点,我们需要允许角色踢球。
首先,我们将更新我们刚刚创建的球脚本。我们想要添加一个变量来存储角色射击后球将落下的位置:
public Vector2 curPos;
public Vector2 lastPos;
public static Transform characterPos;
public float speed;
public bool ballMoving;
void Start ()
{
characterPos = this.transform;
speed = 2f;
}
void Update ()
{
curPos = transform.position;
if(curPos == lastPos)
{
ballMoving = false;
}
else
{
ballMoving = true;
characterAI.teamDistance = 10;
}
lastPos = curPos;
Vector2 positionA = this.transform.position;
Vector2 positionB = characterPos.transform.position;
this.transform.position = Vector2.Lerp(positionA, positionB,
Time.deltaTime * speed);
}
因此,我们在这里提供的是关于球落点位置的信息。为了实现这一点,我们添加了一个名为characterPos的public static Transform变量。我们选择在这里使用角色位置进行测试,因为我们希望角色传球而不是简单地踢球:
public float speed;
public Transform ball;
public bool hasBall;
public float ballDistance;
public static float teamDistance;
public bool nearTheBall;
public List<Transform> teamCharacters;
public int randomChoice;
public float teamdist;
然后我们更新了角色 AI 脚本的变量。在这里,我们有一个列表,将包含所有球员的坐标。想法是让角色选择一个友好的队友传球并朝那个方向射击。
因此,在这个例子中,我们选择使用角色的坐标作为球的航点。为了使这个特性更加逼真,我们可以添加更多关于球轨迹的细节,比如球受到重力或风的影响:
void Update ()
{
teamdist = teamDistance;
if(hasBall == false && nearTheBall == true)
{
Vector3 positionA = this.transform.position;
Vector3 positionB = ball.transform.position;
this.transform.position = Vector3.Lerp(positionA, positionB,
Time.deltaTime * speed);
}
if(ballDistance < 0.1)
{
hasBall = true;
}
if(hasBall == true)
{
passBall();
hasBall = false;
}
ballDistance =Vector3.Distance(transform.position,ball.position);
if(teamDistance > ballDistance)
{
teamDistance = ballDistance;
}
if(teamDistance == ballDistance)
{
nearTheBall = true;
}
if(teamDistance < ballDistance)
{
nearTheBall = false;
}
}
void passBall ()
{
randomChoice = Random.Range(0, 9);
ballScript.characterPos = teamCharacters[randomChoice];
}
然后,我们使用刚刚添加到代码中的变量,在角色 AI 足够接近球时将新的方向发送给球。void passBall()是我们创建的函数,每次角色想要传球时都会调用它。在这个时候,我们只想让角色互相传球,所以我们给列表中的角色分配了一个随机数来选择一个角色。

如果我们测试游戏,我们可以看到有更多的移动和交互正在进行。所以我们可以看到的是,最近的角色会靠近球,当这种情况发生时,他会将球传给另一个角色。球会朝向角色移动,角色会靠近球,以便他将球传给另一个角色。目前,这将在循环中无限发生,一个角色得到球,传球,另一个角色得到球并传球,如此循环。
现在我们有了简单足球游戏的基础,我们可以简单地继续添加更多像我们刚刚创建的功能,使它们能够沟通,看看谁将得到球并将球传给队友。
群体碰撞避免
为了完成这一章,我们将讨论人群避碰。在同一个地图上有许多角色的想法正在成为开放世界游戏的标准。但这也常常带来一个问题,即避碰:

我们已经发现了路径查找是如何工作的,我们知道这是一个在开发人工智能移动时非常强大的系统。但是,如果我们有很多角色同时试图到达同一个位置,它们可能会相互碰撞,并且可能会阻塞通往那个目的地的必经之路。正如我们在前面的截图中所看到的,一切都在顺利运行,没有任何异常情况,因为角色们正在遵循不同的方向,很少会相互干扰。
但是,如果所有角色都试图同时访问同一个位置,比如试图进入房子,那么一次只能有一个角色通过门,这意味着许多其他角色将排队等待进入。
对于这个问题,解决方案仍在探索中,还没有一个确定的答案,但有一些方法可以绕过这个问题。

目前,人群动态解决方案通常涉及两个不同的层次,一个用于路径查找,另一个用于局部避碰。使用这种方法,我们有几个好处,它将产生高质量的移动,并且它将在小范围内进行避碰,这是在多个游戏中非常常见的方法。
有不同的方法可以达到这个目的并获得令人满意的结果。许多游戏的一个流行选择是将 Theta 算法 A*与速度障碍结合使用。这使我们能够计算我们的角色与其他将要与我们碰撞的角色之间的距离。
在高密度人群情况下,仅仅依靠局部避碰和理想化的路径查找会导致代理在流行的、共享的路径航点上堆积。避碰算法仅有助于在追求理想路径的过程中避免局部碰撞。通常,游戏依赖于这些算法在高密度情况下将代理引导到不太拥挤、不太直接的路线。在某些情况下,避碰可以导致这种期望的行为,尽管这始终是系统的一个副作用,而不是一个有意的考虑。
已经有人研究了将聚合人群移动和人群密度整合到路径查找计算中的方法。通过人群密度增强路径的方法没有考虑到人群的整体移动或移动方向,这会导致对这种现象的过度纠正,这在以下图像中可以观察到:

拥挤地图在很多方面与现有的合作路径查找算法类似,例如方向图(DMs),但在一些关键方面有所不同。DMs 使用随时间变化的平均群体运动来鼓励代理与群体一起移动。正因为如此,拥挤地图方法中存在的许多振荡都得到了平滑解决。相反,这种时间平滑阻止了 DMs 快速准确地对外界环境和群体行为的变化做出反应。拥挤地图和 DMs 都以类似的方式将群体移动信息汇总应用于路径规划过程;然而,拥挤地图处理不同大小和形状的代理,而 DMs 传统上假设同质性。
DMs 和拥挤地图之间最后的重大区别在于,拥挤地图根据群体的密度来权衡移动惩罚。如果不考虑密度,DMs 会表现出过于悲观的路径查找行为,鼓励代理绕过稀疏的代理群体来避开理想路径。
摘要
在本章中,我们探讨了在流行视频游戏中使用的流行群体交互系统的几个示例,并看到了为什么计划我们所能想到的每一个交互是多么重要,因为这正是将几行简单的代码变成看起来逼真的游戏的关键。为了结束本章,我们回顾了高级路径查找系统,并看到了游戏中的多个角色如何可以共享同一个最终目的地,采取替代路径以避免碰撞,并在其他角色前进时排队等待。
在下一章中,我们将探讨人工智能规划和决策。我们将看到人工智能如何能够预测事物,提前知道它在到达某个位置或面对某个问题时将做什么。
第九章:AI 规划和避障
在本章中,我们将介绍一些有助于提高 AI 角色复杂性的主题。本章的目的是赋予角色规划和决策的能力。我们已经在之前的章节中探索了一些实现这一目标所需的技术知识,现在我们将详细探讨创建一个能够提前规划决策的 AI 角色的过程。
搜索
我们将从视频游戏中的搜索开始讨论。搜索可能是我们的角色做出的第一个决策,因为在大多数情况下,我们希望角色去寻找某物,无论是寻找玩家还是其他能引导角色走向胜利的东西。
让我们的角色能够成功找到某物非常有用,并且可能非常重要。这是一个可以在大量视频游戏中找到的功能,因此我们很可能也需要使用它。
正如我们在之前的例子中所看到的,大多数情况下,我们有一个在地图上四处走动的玩家,当他们遇到敌人时,那个敌人会从闲置状态变为攻击状态。现在,我们希望敌人能够主动出击,不断寻找玩家而不是等待他。在我们的脑海中,我们可以开始思考敌人开始寻找玩家的过程。我们脑海中已有的这个过程需要被规划,而这个计划需要存储在 AI 角色的脑海中。基本上,我们希望 AI 的思考过程与我们的思考过程相同,因为这样看起来更真实,这正是我们想要的。
有时,我们可能希望搜索成为次要任务,此时角色的主要优先事项是其他事情。这在实时策略游戏中非常常见,AI 角色开始探索地图,并在某个时刻发现敌人的基地。搜索并不是他们的首要任务,但即便如此,它仍然是游戏的一部分——探索地图和获取对手的位置。在发现玩家的位置后,AI 角色可以决定是否将探索更多区域作为优先事项,以及他们的下一步行动。
此外,我们还可以为狩猎游戏创建逼真的动物,例如,动物的主要目标是进食和饮水,因此它们必须不断寻找食物或水源,如果它们不再饥饿或口渴,它们可以寻找一个温暖的地方休息。然而,与此同时,如果动物发现捕食者,它们的优先级会立即改变,动物将开始寻找一个安全的地方休息。
许多决策都可能取决于搜索系统,这是一个模仿现实生活中人类或动物行为的特征。我们将介绍视频游戏中最常见的搜索类型,目标是使 AI 角色能够搜索并成功找到任何东西。
攻击性搜索
我们将要创建的第一种搜索类型是攻击性搜索。通过攻击性搜索,我们指的是这是设置为 AI 角色的主要目标。想法是游戏中的角色由于某种原因需要找到玩家,类似于捉迷藏游戏,其中一名玩家需要藏起来,而另一名玩家需要找到他们。
我们有一个角色可以自由走动的地图,只需考虑他们必须避免的碰撞(树木、山丘和岩石):

因此,第一步是创建一个系统,让角色可以在地图上四处走动。在这个例子中,我们选择创建一个waypoint系统,角色可以从一个点到另一个点移动并探索整个地图。
在导入游戏中所使用的地图和角色之后,我们需要配置角色将使用的waypoint,以便知道他们需要去哪里。我们可以手动将坐标添加到我们的代码中,但为了简化过程,我们将在场景中创建作为waypoint的对象,并删除 3D 网格,因为它将不再必要。
现在,我们将我们创建的所有waypoint分组,并将该组命名为waypoints。一旦我们将waypoint放置并分组,我们就可以开始创建代码,告诉我们的角色他们需要遵循多少个waypoint。这段代码非常有用,因为这样我们可以创建不同的地图,使用我们需要的任意数量的waypoint,而不必更新角色代码:
public static Transform[] points;
void Awake ()
{
points = new Transform[transform.childCount];
for (int i = 0; i < points.Length; i++)
{
points[i] = transform.GetChild(i);
}
}
这段代码将被分配到我们创建的组中,并计算组内包含的waypoint数量并对它们进行排序。

我们在前面的图像中可以看到的蓝色球体代表我们用作waypoint的 3D 网格。在这个例子中,角色将跟随八个点直到完成路径。现在,让我们继续到 AI 角色代码,看看我们如何使用我们创建的点让 AI 角色从一点移动到另一点。
我们将首先创建角色的基本功能——健康和速度——然后我们将创建一个新的变量,它将给出他们的下一个位置,另一个变量将用于知道他们需要遵循哪个waypoint:
public float speed;
public int health;
private Transform target;
private int wavepointIndex = 0;
现在,我们有了制作敌人角色从一点移动到另一点直到找到玩家的基本变量。让我们看看如何使用这些变量来使其现在可玩:
private float speed;
public int health;
private Transform target;
private int wavepointIndex = 0;
void Start ()
{
target = waypoints.points[0]; speed = 10f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime, Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
在Start函数中,我们分配了角色需要遵循的第一个waypoint,即waypoint编号零,也就是我们在waypoint代码中之前创建的变换列表中的第一个。此外,我们还确定了角色的速度,在这个例子中,我们选择了10f。
然后,在Update函数中,角色将计算下一个位置与当前位置之间的距离,使用Vector3 dir。角色将不断移动,因此我们创建了一行代码,作为角色移动的代码transform.Translate。知道距离和速度信息后,角色将知道他们距离下一个位置有多远,一旦他们到达从该点期望的距离,他们就可以移动到下一个点。为了实现这一点,我们将创建一个if语句,告诉角色当他们接近他们正在移动进入的点0.4f(在这个例子中)时,这意味着他们已经到达了那个目的地,并且可以开始移动到下一个点GetNextWaypoint()。
在GetNextWaypoint()函数中,角色将开始确认他们是否已经到达了最终目的地;如果是,则可以销毁该物体,如果不是,则可以跟随下一个航标点。每次角色到达航标位置时,wavepointIndex++将向索引添加一个数字,从而从0>1>2>3>4>5 等等继续。
现在,我们将代码分配给我们的角色,并将角色放置在起始位置,测试游戏以检查它是否正常工作:

现在,角色从一个点到另一个点移动,这是开发搜索系统的第一步和必要步骤——角色需要在地图上移动。现在,我们只需要让它转向他们面对的方向,然后我们就可以开始关注搜索功能了:
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
void Start ()
{
target = waypoints.points[0];
speed = 10f;
speedTurn = 0.2f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime, Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward, dir, speedTurn,
0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
现在,角色面向他们移动的方向,我们准备添加搜索系统。
因此,我们有一个在地图上从一点走到另一点的角色,在这个时候,即使他们找到了玩家,他们也不会停止行走,什么也不会发生。所以,这就是我们现在要做的。

我们选择添加一个触发区域来实现预期结果,这个触发区域是以角色为中心的圆形,正如我们在前面的截图中所看到的。角色将在地图上行走,当触发区域检测到玩家时,角色就找到了主要目标。让我们将这个功能添加到我们的角色代码中:
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
private bool Found;
void Start ()
{
target = waypoints.points[0];
speed = 10f;
speedTurn = 0.2f;
}
void Update ()
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed * Time.deltaTime,
Space.World);
if(Vector3.Distance(transform.position, target.position) <= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward, dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag =="Player")
{
Found = true;
}
}
因此,我们现在添加了一个void OnTriggerEnter函数,用于验证触发区域是否与其他物体接触。为了检查进入触发区域的物体是否是玩家,我们有一个 if 语句,它会检查游戏中的物体是否有Player标签。如果是这样,布尔变量Found会被设置为 true。这个布尔变量在接下来会非常有用。
让我们测试一下游戏,看看角色是否能够穿过玩家,并且在这个时候变量Found是否从 false 变为 true:

我们刚刚实现的搜索系统效果很好;角色将在地图上四处走动寻找玩家,并且可以毫无问题地找到玩家。下一步是告诉角色,当他们已经找到玩家时,停止搜索。
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
public bool Found;
void Start ()
{
target = waypoints.points[0];
speed = 40f;
speedTurn = 0.2f;
}
void Update ()
{
if (Found == false)
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed *
Time.deltaTime,
Space.World);
if (Vector3.Distance(transform.position, target.position)
<= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward,
dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag == "Player")
{
Found = true;
}
}
经过这些最后的修改,我们得到了一个 AI 角色,它在地图上四处走动,直到找到玩家。当他们最终找到玩家时,他们停止四处走动,并准备计划下一步的行动。
我们在这里所做的是使用Found布尔值来确定玩家是否应该搜索玩家。

前面的图像代表了我们的角色当前的状态,我们准备在它上面实现更多功能,使其能够计划和做出最佳决策。
这个搜索系统可以应用于许多不同的游戏类型,并且我们可以相当快速地设置它,这使得它成为规划 AI 角色的完美方式。现在,让我们继续工作,并着手实现玩家角色的预期功能。
预测对手行动
现在,让我们让角色在对抗玩家之前就预期将要发生的事情。这是角色 AI 开始计划实现目标的最佳选项的部分。
让我们看看如何将预期系统集成到角色 AI 中。我们将继续使用前面提到的例子,其中有一个士兵在地图上寻找另一个士兵。目前,我们有一个在地图上移动并在找到玩家时停止的角色。
如果我们的角色 AI 找到了玩家,最可能的情况是玩家也会找到角色 AI,这样两个角色都会意识到对方的存在。玩家攻击角色 AI 的可能性有多大?玩家是否有足够的子弹射击角色?所有这些都是非常主观的和不可预测的。然而,我们希望我们的角色能够考虑到这一点,并预期玩家的可能行动。
因此,让我们从一个简单的问题开始:玩家是否面对着角色?让角色检查这一点将帮助他们判断可能的后果。为了达到这个结果,我们将在角色的后面添加一个触发器Collider,并在每个游戏角色的前面也添加一个,包括玩家,正如我们在下面的截图中所看到的:

在每个角色上放置两个额外的Collider的目的是帮助其他角色识别他们是否在查看角色的背面或正面。因此,让我们将这个功能添加到游戏中的每个角色,并将触发器Collider命名为back和front。
现在,让我们让角色区分背面和正面触发器。这可以通过两种不同的方式实现——第一种方式是在角色前方添加一个拉伸的触发器碰撞器,代表观察范围:

或者,我们可以从角色的位置创建一个射线投射,直到我们认为可能是角色视野范围的距离,如下面的截图所示:

这两种方法都有其优缺点,而且我们并不一定需要不断使用最复杂的方法来取得好结果。所以,这里的建议是使用我们更熟悉的方法,而对于这个例子来说,使用触发器Collider来代表角色的视野范围是一个不错的选择。
让我们在角色前方添加触发器Collider,然后我们可以开始编写代码,使其检测角色的正面或背面。我们需要在代码中做的第一件事是在他们看到玩家时让角色面向玩家的方向。如果他们没有看着玩家,角色将无法预测任何事情,所以让我们先解决这个问题:
void Update ()
{
if (Found == false)
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed *
Time.deltaTime,
Space.World);
if (Vector3.Distance(transform.position, target.position)
<= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward,
dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
if (Found == true)
{
transform.LookAt(target);
}
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag == "Player")
{
Found = true;
target = other.gameObject.transform;
}
}
现在,当我们的 AI 角色看到玩家时,他们会始终面对玩家。为了使这起作用,我们在if (Found == true)内部添加了我们的第一行代码。在这里,我们使用了transform.LookAt,这使得 AI 面对玩家角色。当我们的 AI 角色发现玩家时,它自动成为目标:

现在,我们的 AI 角色面对着玩家,我们可以检查他们是否在看着玩家的背面或正面。
对于我们来说,认为角色不知道区别可能看起来不太合逻辑,但在开发 AI 角色时,所有内容都需要写入代码中,尤其是像这种可能对预测、计划和最终做出决策产生巨大影响的细节。
因此,现在我们必须使用之前添加的触发器Collider来检查我们的 AI 角色是否面对着他们前面的玩家的正面或背面。让我们先添加以下两个新变量:
public bool facingFront;
public bool facingBack;
我们添加的变量是布尔值facingFront和facingBack。触发器将其中一个值设置为 true,这样角色 AI 就会知道他们正在看哪一侧。所以,让我们配置触发器:
void Update ()
{
if (Found == false)
{
Vector3 dir = target.position - transform.position;
transform.Translate(dir.normalized * speed *
Time.deltaTime,
Space.World);
if (Vector3.Distance(transform.position, target.position)
<= 0.4f)
{
GetNextWaypoint();
}
Vector3 newDir = Vector3.RotateTowards(transform.forward,
dir,
speedTurn, 0.0F);
transform.rotation = Quaternion.LookRotation(newDir);
}
if (Found == true)
{
transform.LookAt(target);
}
}
void GetNextWaypoint()
{
if(wavepointIndex >= waypoints.points.Length - 1)
{
Destroy(gameObject);
return;
}
wavepointIndex++;
target = waypoints.points[wavepointIndex];
}
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag == "Player")
{
Found = true;
target = other.gameObject.transform;
}
if(other.gameObject.name == "frontSide")
{
facingFront = true;
facingBack = false;
}
if(other.gameObject.name == "backSide")
{
facingFront = false;
facingBack = true;
}
}
因此,我们所做的是设置触发器来检查是否与另一个角色的背面或正面发生碰撞。为了达到这个结果,我们让触发器询问它检测到的碰撞是frontSide对象还是backSide对象。一次只能有一个为真。

现在,我们的角色已经能够区分玩家的背面和正面,我们希望他能够分析这两种情况的风险。所以,我们首先要做的是让角色在发现玩家背对或面对他时,情况有非常明显的区别。当面对正面时,玩家准备向我们的人工智能角色开枪,所以这是一个更加危险的情况。我们将创建一个危险计分器,并将这种状况纳入等式中:
public float speed;
public int health;
public float speedTurn;
private Transform target;
private int wavepointIndex = 0;
public bool Found;
public bool facingFront;
public bool facingBack;
public int dangerMeter;
在变量部分,我们添加了一个新的整数变量,称为dangerMeter。现在,我们将添加一些值,以帮助我们确定我们的 AI 角色面临的情况是高风险还是低风险:
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag == "Player")
{
Found = true;
target = other.gameObject.transform;
}
if(other.gameObject.name == "frontSide")
{
facingFront = true;
facingBack = false;
dangerMeter += 50;
}
if(other.gameObject.name == "backSide")
{
facingFront = false;
facingBack = true;
dangerMeter += 5;
}
}
因此,根据具体情况,我们可以添加一个小的数值来代表小的风险,或者添加一个大的数值来代表大的风险。如果危险值很高,AI 角色需要预见到可能危及生命的情况,因此可能会做出戏剧性的决定。另一方面,如果我们的角色面临的是低风险情况,他们可以开始制定更精确和有效的计划。
可以将许多因素添加到dangerMeter中,例如我们的角色相对于玩家的位置。为了做到这一点,我们需要将地图划分为不同的区域,并为每个区域分配一个风险等级。例如,如果角色位于森林中央,它可以被认为是一个中等风险区域,而如果他们在开阔地带,它可以被认为是一个高风险区域。角色的子弹数量、剩余的生命线等等都可以添加到我们的dangerMeter等式中。将这一功能实现到角色中,将帮助他预见到可能发生的情况。
碰撞避免
预测碰撞是我们 AI 角色应该具备的非常有用的功能,也可以用于人群系统中,以便在一个人物向另一个人物行进的方向上时,使人群移动得更自然。现在,让我们看看实现这一功能的一种简单方法:

为了预测碰撞,我们需要至少两个物体或角色。在上面的图像中,我们有两个代表两个角色的球体,虚线代表它们的移动。如果蓝色球体向红色球体移动,在某个时刻,它们将相互碰撞。这里的主要目标是预测何时会发生碰撞,并调整球体的轨迹,使其能够避免碰撞。

在前面的图像中,我们可以看到如果我们想让我们的角色避开障碍物碰撞,我们需要做什么。我们需要一个速度向量来指示角色的方向。这个相同的向量也将被用来产生一个新的向量,称为ahead,它是速度向量的一个副本,但长度更长。这意味着ahead向量代表了角色的视线,一旦他们看到障碍物,他们就会调整方向以避开它。这就是我们计算ahead向量的方式:
ahead = transform.position + Vector3.Normalize(velocity) * MAX_SEE_AHEAD;
ahead是一个Vector3变量,velocity是一个Vector3变量,MAX_SEE_AHEAD是一个浮点变量,它将告诉我们我们能看到多远。如果我们增加MAX_SEE_AHEAD的值,角色将更早地开始调整方向,如下面的图所示:

为了检查碰撞,一个可以使用的解决方案是线-球相交,其中线是ahead向量,球是障碍物。这种方法是有效的,但我们将使用一个简化版本,它更容易理解并且具有相同的结果。因此,ahead向量将被用来产生另一个向量,这个向量将是其长度的一半:

在前面的图像中,我们可以看到ahead和ahead2指向同一方向,它们之间的唯一区别是长度:
ahead = transform.position + Vector3.Normalize(velocity) * MAX_SEE_AHEAD;
ahead2 = transform.position + Vector3.Normalize(velocity) * (MAX_SEE_AHEAD * 0.5);
我们需要检查碰撞,以确定这两个向量中的任何一个是否在障碍区域内。为了计算这一点,我们可以比较向量与障碍物中心之间的距离。如果距离小于或等于障碍区域,那么这意味着我们的向量在障碍区域内,并且检测到了碰撞。

ahead2向量在先前的图中没有显示,只是为了简化它。
如果两个ahead向量中的任何一个进入障碍区域,这意味着障碍物阻挡了路径,为了解决我们的问题,我们将计算两点之间的距离:
public Vector3 velocity;
public Vector3 ahead;
public float MAX_SEE_AHEAD;
public Transform a;
public Transform b;
void Start (){
ahead = transform.position + Vector3.Normalize(velocity) * MAX_SEE_AHEAD;
}
void Update ()
{
float distA = Vector3.Distance(a.position, transform.position);
float distB = Vector3.Distance(b.position, transform.position);
if(distA > distB)
{
avoidB();
}
if(distB > distA)
{
avoidA();
}
}
void avoidB()
{
}
void avoidA()
{
}
}
如果有多于一个障碍物阻挡路径,我们需要检查哪个离我们的角色更近,然后我们可以先避开较近的障碍物,然后再处理第二个障碍物:

最接近的障碍物,最危险的障碍物,将被选中进行计算。现在,让我们看看我们如何计算和执行避开操作:
public Vector3 velocity;
public Vector3 ahead;
public float MAX_SEE_AHEAD;
public float MAX_AVOID;
public Transform a;
public Transform b;
public Vector3 avoidance;
void Start () {
ahead = transform.position + Vector3.Normalize(velocity) * MAX_SEE_AHEAD;
}
void Update ()
{
float distA = Vector3.Distance(a.position, transform.position);
float distB = Vector3.Distance(b.position, transform.position);
if(distA > distB)
{
avoidB();
}
if(distB > distA)
{
avoidA();
}
}
void avoidB()
{
avoidance = ahead - b.position;
avoidance = Vector3.Normalize(avoidance) * MAX_AVOID;
}
void avoidA()
{
avoidance = ahead - a.position;
avoidance = Vector3.Normalize(avoidance) * MAX_AVOID;
}
}
在计算避开后,它会被MAX_AVOID归一化和缩放,MAX_AVOID是一个用来定义避开长度的数字。MAX_AVOID的值越高,避开的效果越强,将我们的角色推离障碍物。

任何实体的位置都可以设置为向量,因此它们可以用于与其他向量和力的计算。
现在,我们已经有了让我们的角色预测并避开障碍物位置的基础,避免与之碰撞。结合路径查找,我们可以让我们的角色在游戏中自由移动并享受结果。
摘要
在本章中,我们探讨了如何让我们的 AI 角色创建并遵循一个计划以执行一个确定的目标。这个想法是提前思考将要发生的事情,并为这种情况做好准备。为了完成这个目标,我们还探讨了如何让我们的 AI 角色预测与物体或另一个角色的碰撞。这不仅对于让我们的角色在地图上自由移动是基本的,而且它也作为在规划要做什么时需要考虑的新方程。在我们下一章中,我们将讨论意识,如何发展潜行游戏中最具标志性的特征,并让我们的 AI 角色通过真实的视野范围意识到周围发生的事情。
第十章:意识
在我们最后一章中,我们将探讨如何开发使用战术和意识来实现其目标的 AI 角色。在这里,我们将使用之前探索的一切,了解我们如何将所有这些结合起来,以创建可用于潜行游戏或也依赖战术或意识的游戏的 AI 角色。
潜行子类型
潜行游戏是一个非常受欢迎的子类型,其中玩家的主要目标是利用潜行元素,不被对手发现以完成主要目标。尽管这个子类型在军事游戏中非常流行,但几乎可以在任何游戏中看到它的应用。如果我们深入观察,任何游戏中如果敌人角色被玩家的噪音或视觉触发,就是在使用潜行元素。这意味着在某个时候,在我们的 AI 角色中实现意识甚至战术可能非常有用,无论我们正在开发的游戏类型是什么。
关于战术
战术是指角色或一组角色为了实现特定目标所采取的过程。这通常意味着角色可以使用他们所有的能力,根据情况选择最佳的能力来击败对手。在视频游戏中,战术的概念是赋予 AI 决策能力,使其在试图达到主要目标时表现得聪明。我们可以将此与士兵或警察在现实世界中用来抓捕坏蛋的战术进行比较。
他们拥有广泛的技术和人力资源来捕捉强盗,但为了成功完成这项任务,他们需要明智地选择他们将采取的行动,一步一步来。同样的原则也可以应用于我们的 AI 角色;我们可以让它们选择实现其目标的最佳选项。
为了创建这个,我们可以使用这本书中之前涵盖的每一个主题,并且通过这样,我们能够开发出一个能够选择最佳战术以击败玩家或实现其目标的 AI 角色。
关于意识
与战术相关的一个非常重要的方面是角色的意识。一些常见的因素可以构成 AI 角色的意识的一部分,例如音频、视觉和感知。这些因素受到了我们所有人共有的特征——视觉、音频、触觉和对周围发生的事情的感知——的启发。
因此,我们追求的是创建能够同时处理所有这些信息的人工智能角色,在它们做其他事情的同时,使它们对周围环境保持警觉,对在那个特定时刻应该做出的决策做出更好的判断。
实现视觉意识
在开始战术之前,我们将看看如何将意识系统实现到我们的角色中。
让我们从将视觉感知融入到我们的游戏角色中开始。这个想法是模拟人类的视觉,我们可以在近距离看得很清楚,而当某物真的很远时,我们看不清楚。许多游戏都采用了这个系统,它们都有所不同,有些系统更复杂,而有些则更简单。基本示例可以在像《塞尔达传说 - 时之笛》这样的更幼稚的冒险游戏中找到,例如,敌人只有在达到某个触发区域时才会出现或做出反应,如下面的截图所示:

例如,在这种情况下,如果玩家返回并退出敌人的触发区域,敌人将保持在空闲位置,即使他显然能看到玩家。这是一个基本的感知系统,我们可以将其包括在视觉部分。
同时,其他游戏已经围绕这个主题(视觉感知)开发了整个游戏玩法,其中视觉范围对游戏本身有极其重要的方面。几个例子之一是育碧的《细胞分裂》。

在这个游戏中使用了所有类型的感知系统,包括声音、视觉、触觉和感知。如果玩家在阴暗区域保持安静,被发现的机会比在明亮区域保持安静要小,声音也是如此。因此,在前面截图的例子中,玩家已经非常接近正在看向另一个方向的敌人。
为了让玩家接近到这种程度,必须非常安静地移动并在阴影中行动。如果玩家发出噪音或直接走进明亮区域,敌人就会注意到他。这比《塞尔达》游戏中的系统要复杂得多,但同样,这完全取决于我们正在创建的游戏以及哪个系统更适合我们寻找的游戏玩法。我们将演示基本示例,然后转向更高级的示例。
基本视觉检测
首先,我们开始在游戏中创建并添加一个场景,然后添加玩家。

我们将所有必要的代码分配给玩家,这样我们就可以移动并测试游戏。在这个例子中,我们迅速将一些基本的移动信息分配给我们的玩家,因为这是玩家和 AI 角色之间唯一会发生的交互。

现在,我们的角色可以在场景中自由移动,我们准备开始处理敌人角色。我们想要复制《塞尔达》游戏中那个特定的时刻,即当玩家从他的位置靠近时,敌人从地面出现,而当玩家远离时,敌人回到地面。

在截图中所看到的兔子是我们刚刚导入游戏的 AI 角色,现在我们需要定义围绕它的区域,这将作为它的感知区域。因此,如果玩家靠近兔子,它将检测到玩家,并最终从洞中出来。

假设我们想让兔子能够从它的洞中看到由虚线表示的区域。我们接下来该如何操作?在这里我们可以做两件事,一是将触发器Collider添加到洞对象中,它将检测到玩家并从洞的位置实例化兔子,二是将触发器Collider直接添加到兔子身上(假设它在洞内不可见)并在代码中有一个当兔子在洞内时的状态,以及当它在外面的状态。
在这个例子中,我们决定将洞作为兔子藏身的主要对象,以及玩家进入触发区域的那一刻,洞对象实例化 AI 角色。

我们将兔子转换成了一个预制体,这样我们就可以稍后实例化它,然后我们从场景中移除了它。然后我们在游戏中创建了一个立方体,并将其放置在洞的位置。由于在这个例子中我们不需要洞是可见的,我们将关闭这个对象的网格。
使用立方体而不是空对象,使我们能够在游戏编辑器中更好地可视化对象,以防我们需要更改某些内容或只是有一个关于这些对象的位置概念。
在这一点上,我们需要让这个对象检测到玩家,因此我们将添加一个具有我们之前计划使用的维度的触发器。

我们删除了当创建立方体时自动出现的默认立方体触发器,然后分配了一个新的球体触发器。为什么我们不使用立方体触发器?我们本可以使用立方体触发器,技术上它也会工作,但覆盖的区域将与我们计划的圆形区域完全不同,因此我们删除了默认触发器,并分配了一个适合我们目的的新触发器。
既然我们已经用球体触发器覆盖了我们想要覆盖的区域,我们就需要让它检测到玩家。为此,我们需要创建一个将被分配给立方体/洞的脚本:
void OnTriggerEnter (Collider other) {
if(other.gameObject.tag == "Player")
{
Debug.Log("Player Detected");
} }
在脚本内部,我们添加了这一行代码。这是一条简单的触发器检查,用于当对象进入触发区域时(我们曾用它来演示之前的例子)。目前我们只是让触发器检查是否检测到玩家,使用Debug.Log("玩家被检测到");。我们将这个脚本分配给立方体/洞对象,然后我们可以测试它。

如果我们将玩家移动到我们创建的触发区域内,我们可以看到“玩家被检测到”的消息。

好的,这是基本示例的第一部分;我们有玩家在地图上移动,洞能够检测到玩家靠近时的情况。
我们使用触发 Collider 来检测某种东西的方法并不直接与任何类型的意识相关联,因为这仅仅是技术部分,我们使用它的方式将决定这是否是我们 AI 角色的视野。
现在,我们可以开始处理兔子,我们的 AI 角色。我们已经有它创建并设置为预制体,准备在游戏中出现。所以下一步是让洞对象实例化兔子,将兔子看到玩家的感觉传递给玩家,因此兔子决定从洞中出来。在洞对象代码中,我们将Player Detected消息更新为instantiate:
public GameObject rabbit;
public Transform startPosition;
public bool isOut;
void Start ()
{
isOut = false;
}
void OnTriggerEnter (Collider other)
{
if(other.gameObject.tag == "Player" && isOut == false)
{
isOut = true;
Instantiate(rabbit, startPosition.position,
startPosition.rotation);
}
}
所以我们做的是定义了将要实例化的对象,在这个例子中是 AI 角色“兔子”。然后我们添加了startPosition变量,它将设置我们希望角色出现的位置,作为替代,我们也可以使用洞对象的位置,这对于这个例子来说效果同样好。最后,我们添加了一个简单的布尔值isOut,以防止洞在同一时间创建多个兔子。
当玩家进入触发区域时,兔子就会被实例化并从洞中跳出来。

现在,我们有一个兔子,当它看到玩家时会从洞中跳出来。我们的下一步是也给兔子本身添加相同的视野,但这次我们希望兔子能够持续检查玩家是否在触发区域内,这表示它可以看到玩家,如果玩家离开它的视野,兔子就再也看不到他,并返回洞中。
对于 AI 角色,我们可以使用比洞更宽的区域。

所以,正如我们所看到的,那将是兔子可以看到玩家的区域,如果玩家离开那个区域,兔子就再也看不到玩家了。

再次,让我们给兔子添加一个球体Collider。
启用“是触发器”选项,以便将 Collider 转换为激活区域。否则它将不起作用。

这是我们目前所做的工作,球体Collider具有我们计划的尺寸,并准备好接收玩家位置信息,这将作为我们 AI 角色的视野。
现在,我们需要做的是将负责触发区域的代码部分添加到兔子脚本中:
void OnTriggerStay (Collider other) {
if(other.gameObject.tag == "Player")
{
Debug.Log("I can see the player");
}
}
我们这里有一个触发检查,用来查看玩家是否继续在触发区域内,为此我们简单地使用OnTriggerStay,这对于我们正在创建的例子来说工作得非常好。
我们使用Debug.Log("I can see the player");只是为了测试这是否按预期工作。

我们测试了游戏,并注意到当玩家进入兔子区域时,我们收到了我们编写的控制台消息,这意味着它正在工作。
现在,让我们添加兔子视觉的第二部分,即玩家离开触发区域,兔子再也无法看到他。为此,我们需要添加另一个触发检查,用来检查玩家是否已经离开了该区域:
void OnTriggerStay (Collider other) {
if(other.gameObject.tag == "Player")
{
Debug.Log("I can see the player");
}
}
void OnTriggerExit (Collider other){
if(other.gameObject.tag == "Player")
{
Debug.Log("I've lost the player");
}
}
下面是我们在 AI 角色代码中添加的OnTriggerStay,我们添加了一些新的代码行来检查玩家是否已经离开了触发区域。为此,我们使用了OnTriggerExit,它做的是名字所描述的事情,检查进入触发区域的对象的退出。但为了使这个功能正常工作,我们首先需要设置一个OnTriggerEnter,否则它不会计算玩家是否进入了区域,它只知道玩家是否在那里:
void OnTriggerEnter (Collider other) {
if(other.gameObject.tag == "Player")
{
Debug.Log("I can see the player");
}
}
void OnTriggerStay (Collider other){
if(other.gameObject.tag == "Player")
{
Debug.Log("I can see the player");
}
}
void OnTriggerExit (Collider other){
if(other.gameObject.tag == "Player")
{
Debug.Log("I've lost the player");
}
}
现在,我们已经有了玩家进入区域、保持在区域内部以及离开该区域的触发计数。这代表了兔子开始看到玩家、持续看到他以及与玩家失去目光接触的时刻。

到目前为止,我们可以测试游戏,看看我们所做的是否工作正常。当我们开始游戏时,我们可以通过查看我们编写的控制台消息来确认一切是否按预期工作。

在OnTriggerStay函数上看到更高的数字是正常的,因为它会不断检查每一帧的玩家,所以正如我们在前面的截图中所见,我们的 AI 角色现在已经实现了基本的视觉检测。
高级视觉检测
现在我们已经了解了在许多动作/冒险游戏中可以找到的基本视觉检测是如何工作的,我们可以继续前进,看看潜行游戏中可以找到的高级视觉检测。让我们深入探讨一下《合金装备》游戏,看看 AI 角色的视觉是如何发展的。

如果我们看一下这张截图,我们会注意到敌人 AI 看不到玩家,但玩家就在敌人能够清晰看到的位置区域内。那么为什么 AI 角色不转向玩家并开始攻击他呢?简单来说,是因为触发区域只设置在敌人视线前方。
因此,如果玩家在敌人后面,敌人就不会注意到玩家。

如我们在第二张截图中所见,在较暗的区域中,敌人无法获取有关玩家存在的任何信息,而明亮区域则代表了角色的视野,在那里他可以看到所有发生的事情。现在我们将探讨如何将类似系统开发到我们的 AI 角色中。
让我们先创建一个测试场景。现在可以使用简单的立方体网格,稍后我们可以将它们改为外观更好的对象。

我们已经创建了一些立方体网格,并将它们随机放置在一个平面上(这将是地面)。下一步将是创建角色,我们将使用胶囊来表示角色。

我们可以将新创建的胶囊放置在地图上的任何位置。现在,我们需要创建一些目标,这些目标将被我们的 AI 角色发现。

我们还可以将目标对象放置在地图上的任何位置。现在,我们需要定义两个不同的图层,一个用于障碍物,另一个用于目标。

在 Unity 中,我们点击图层按钮下方的部分以展开更多选项,然后点击显示为“编辑图层....”的地方。

这列将展开,在这里我们可以写下我们需要创建的图层。正如我们所看到的,已经有我们需要的两个图层,一个称为障碍物,另一个称为目标。之后,我们需要将它们分配给对象。

要做到这一点,我们只需选择障碍物对象,然后点击图层按钮,选择障碍物图层。我们同样也为目标对象做同样的操作,选择目标图层。
接下来要做的事情是将必要的代码添加到我们的角色中。我们还需要为角色添加一个刚体,并冻结以下截图中所展示的所有旋转轴:

然后,我们可以为角色创建一个新的脚本:
public float moveSpeed = 6;
Rigidbody myRigidbody;
Camera viewCamera;
Vector3 velocity;
void Start ()
{
myRigidbody = GetComponent<Rigidbody> ();
viewCamera = Camera.main;
}
void Update ()
{
Vector3 mousePos = viewCamera.ScreenToWorldPoint(new
Vector3(Input.mousePosition.x, Input.mousePosition.y,
viewCamera.transform.position.y));
transform.LookAt (mousePos + Vector3.up * transform.position.y);
velocity = new Vector3 (Input.GetAxisRaw ("Horizontal"), 0,
Input.GetAxisRaw ("Vertical")).normalized * moveSpeed;
}
void FixedUpdate()
{
myRigidbody.MovePosition (myRigidbody.position + velocity *
Time.fixedDeltaTime);
}
这里展示的是我们角色的基本移动,因此我们可以通过控制角色移动到任何我们想要的地方来自行测试它。完成这些后,我们能够用角色在地图上移动,并且用鼠标可以模拟角色所看的方向。
现在,让我们来编写模拟我们角色视力的脚本:
public float viewRadius;
public float viewAngle; public Vector3 DirFromAngle(float
angleInDegrees)
{
}
我们从两个公共浮点数开始,一个用于viewRadius,另一个用于viewAngle。然后我们创建一个名为DirFromAngle的公共Vector3,我们希望结果以度为单位,因此我们将使用三角学来解决这个问题。

上述图表表示默认的度数三角学值,它从右侧的零开始,值以逆时针方向增加。

由于我们在这个 Unity 示例中开发,我们需要记住三角学值的顺序是不同的,正如前面图表所示。在这里,零数字从顶部开始,值以顺时针方向增加。
在了解这些信息后,我们现在可以继续处理角色将查看的方向角度:
public float viewRadius;
public float viewAngle; public Vector3 DirFromAngle(float
angleInDegrees)
{
return new Vector3(Mathf.Sin(angleInDegrees *
Mathf.Deg2Rad), 0,
Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
现在,我们的练习的基本基础已经完成,但为了在游戏编辑器上直观地看到它,我们需要创建一个新的脚本,以显示角色视野的半径:

要做到这一点,我们首先在项目部分创建一个新的文件夹:

为了让游戏引擎使用将在游戏编辑器中出现的这个内容,我们需要将文件夹命名为Editor。这个文件夹内的所有内容都可以在游戏编辑器中使用/查看,无需点击播放按钮,这在许多情况下都非常方便,就像我们正在创建的那样。
然后在刚刚创建的Editor文件夹内部,我们创建一个新的脚本,该脚本将负责角色视场的可视化:
using UnityEngine;
using System.Collections;
using UnityEditor;
因为我们想在编辑模式下使用这个脚本,所以我们需要在脚本顶部指定这一点。为此,我们首先添加using UnityEditor。
然后,我们再添加一行,以便与之前创建的脚本连接,以便在编辑模式下使用:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
现在让我们来处理屏幕上将要出现的内容,以表示我们创建的视野:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor{
void OnSceneGUI(){
FieldOfView fow = (FieldOfView)target; } }
我们创建了一个void OnSceneGUI(),这将包含我们希望在游戏编辑器上可见的所有信息。我们首先添加视野的目标;这将获取视野对象引用:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor{
void OnSceneGUI(){
FieldOfView fow = (FieldOfView)target; Handles.color = color.white; } }
接下来,我们定义我们想要表示角色视野的颜色,为此我们添加了Handles.color,并选择了白色。这不会在我们游戏的导出版本中可见,因此我们可以选择在编辑器中更容易看到的颜色:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor{
void OnSceneGUI(){
FieldOfView fow = (FieldOfView)target; Handles.color = color.white;
Handles.DrawWireArc (fow.transform.position, Vector3.up,
Vector3.forward, 360, fow.viewRadius); } }
我们现在所做的是给正在创建的可视化赋予一个形状。形状被设置为弓形,这就是为什么我们使用DrawWireArc。现在,让我们看看到目前为止我们做了什么:

在我们为角色创建并分配的脚本中,我们需要将视场半径的值更改为任何期望的值。

当增加这个值时,我们会注意到围绕角色生长的圆圈,这意味着我们的脚本到目前为止工作得很好。这个圆圈代表我们角色的视野,现在让我们做一些修改,使其看起来像我们用作参考的《合金装备固体》图像。
让我们再次打开FieldOfView脚本以添加新的修改:
public float viewRadius;
[Range(0,360)]
public float viewAngle;
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
{
if(!angleIsGlobal)
{
angleInDegrees += transform.eulerAngles.y;
}
return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0,
Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
我们为viewRadius添加了一个范围,以确保圆圈不会超过360度的标记。然后我们添加了一个布尔参数到public Vector3 DirFromAngle,以检查角度值是否设置为全局,这样我们就可以控制角色面向的方向。
然后,我们再次打开 FieldOfViewEditor 脚本来添加 viewAngle 信息:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor
{
void OnSceneGUI()
{
FieldOfView fow = (FieldOfView)target;
Handles.color = color.white;
Handles.DrawWireArc (fow.transform.position, Vector3.up,
Vector3.forward, 360, fow.viewRadius);
Vector3 viewAngleA =
fow.DirFromAngle(-fow.viewAngle/2, false);
Handles.DrawLine(fow.transform.position, fow.transform.position +
viewAngleA * fow.viewRadius);
Handles.DrawLine(fow.transform.position,
fow.transform.position +
viewAngleB * fow.viewRadius);
}
}
现在,让我们再次测试以查看我们所做的新的修改:

在 View Angle 选项中,我们将值从零更改为任何其他值以查看它在做什么:

现在,如果我们观察围绕角色的圆圈,我们会注意到里面有一个三角形形状。该形状的大小可以通过 View Angle 选项精确控制,三角形形状代表角色的视野,因此此刻我们可以注意到角色略微朝向右下方看。

由于我们将角度值设置为全局角度,因此我们可以旋转角色,视图角度将跟随角色旋转。
现在,让我们处理视野射线投射,这部分负责检测角色正在注视的方向上存在什么。再次,我们将编辑我们为角色创建的 FieldOfView 脚本:
public float viewRadius;
[Range(0,360)]
public float viewAngle;
public LayerMask targetMask;
public LayerMask obstacleMask;
public List<Transform> visibleTargets = new List<Transform>();
void FindVisibleTargets ()
{
visibleTargets.Clear ();
Collider[] targetInViewRadius =
Physics.OverlapSphere(transform.position, viewRadius, targetMask);
for (int i = 0; i < targetsInViewRadius.Length; i++)
{
Transform target = targetInViewRadius [i].transform; Vector3
dirToTarget = (target.position - transform.position).normalized;
if (Vector3.Angle (transform.forward, dirToTarget) < viewAngle / 2)
{
float dstToTarget = Vector3.Distance (transform.position,
target.position);
if (!Physics.Raycast(transform.position,
dirToTarget, dstToTarget, obstacleMask))
{
visibleTargets.Add (target);
}
}
}
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal) {
if(!angleIsGlobal)
{
angleInDegrees += transform.eulerAngles.y;
}
return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0,
Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
我们在这里所做的是将 Physics 信息添加到我们的脚本中,仅检测角色 View Angle 内可以找到的对象。为了检查是否有东西在我们的角色视野中,我们使用 Raycast 来检查是否有带有 obstacleMask 层的对象被检测到。现在让我们创建一个 IEnumerator 函数来实现角色检测新障碍物时的小延迟:
public float viewRadius; [Range(0,360)]
public float viewAngle; public LayerMask targetMask;
public LayerMask obstacleMask;
[HideInInspector] public List<Transform> visibleTargets = new List<Transform>();
void Start ()
{
StartCoroutine("FindTargetsWithDelay", .2f);
}
IEnumerator FindTargetsWithDelay(float delay)
{
while (true) {
yield return new WaitForSeconds (delay);
FindVisibleTargets ();
}
}
void FindVisibleTargets ()
{
visibleTargets.Clear ();
Collider[] targetInViewRadius
=Physics.OverlapSphere(transform.position,viewRadius, targetMask);
for (int i = 0; i < targetsInViewRadius.Length; i++)
{
Transform target = targetInViewRadius [i].transform; Vector3 dirToTarget = (target.position - transform.position).normalized;
if (Vector3.Angle (transform.forward, dirToTarget) < viewAngle / 2) { float dstToTarget = Vector3.Distance (transform.position, target.position);
if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget,
obstacleMask))
{
visibleTargets.Add (target);
}
}
}
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal) {
if(!angleIsGlobal)
{
angleInDegrees += transform.eulerAngles.y;
}
return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0,
Mathf.Cos(angleInDegrees * Mathf.Deg2Rad)); }
现在,我们已经创建了一个 IEnumerator,角色有一个小的反应时间,在这个例子中设置为 .2f 以在视野区域内寻找目标。为了测试这一点,我们需要在我们的 FieldOfViewEditor 脚本中做一些新的修改。所以让我们打开它并添加几行新的代码:
using UnityEngine;
using System.Collections;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor{
void OnSceneGUI(){
FieldOfView fow = (FieldOfView)target;
Handles.color = color.white; Handles.DrawWireArc
(fow.transform.position, Vector3.up,
Vector3.forward, 360, fow.viewRadius); Vector3 viewAngleA =
fow.DirFromAngle(-fow.viewAngle/2, false);
Handles.DrawLine(fow.transform.position, fow.transform.position +
viewAngleA * fow.viewRadius);
Handles.DrawLine(fow.transform.position,fow.transform.position +
viewAngleB * fow.viewRadius); Handles.color = Color.red;
Foreach (Transform visibleTarget in fow.visibleTargets)
{
Handles.DrawLine(fow.transform.position, visibleTarget.position);
}
}
}
在代码的新修改后,我们应该能够看到角色何时检测到障碍物,以及何时障碍物脱离了他的视野区域。

为了测试这一点,我们首先需要选择游戏中的所有障碍物:

然后将它们分配到障碍层:

我们还需要选择游戏中的所有目标:

然后,我们将它们分配到目标层。这一步非常重要,以便我们的射线投射能够识别角色视野内的内容。现在,让我们点击角色对象并定义哪个图层代表目标,哪个图层代表障碍物:

我们转到视野脚本选项中的图层遮罩选项:

然后,我们选择目标层:

然后我们转到障碍物选项:

我们选择障碍层。
在这部分完成之后,我们最终可以测试练习,看看当角色找到目标时会发生什么。

在进行练习时,我们可以看到当目标进入视野区域时,会出现一条连接角色和目标的红色线条。这表示我们的角色已经发现了敌人,例如。

但是,当我们移动我们的角色并且目标前方有障碍物时,即使目标在视野区域内,角色也无法检测到它,因为有一个物体在他面前阻挡了他的视线。这就是为什么我们需要将障碍层分配给可能阻挡角色视线的每个对象,这样他就不会有任何 X 射线视野。

我们也可以将我们的角色指向两个目标,这两个目标都会连接到,这意味着我们的角色也能够同时检测到多个目标,这对于制定更好的策略和战术非常有用。
逼真的视野效果
现在我们已经使视觉检测工作正常,我们可以继续下一步并添加一个逼真的视野效果。这将使角色具有边缘视野,使得看到的侧面内容更不详细,而前面看到的内容更详细。这是对我们真实人类视觉的模拟,我们倾向于更多地关注我们面前的事物,如果我们需要检查侧面的某个东西,我们需要转向那个方向以便更好地查看。
让我们从打开我们的FieldOfView脚本开始。然后我们添加一个新的浮点变量,称为meshResolution:
public float viewRadius; [Range(0,360)]
public float viewAngle; public LayerMask targetMask; public LayerMask obstacleMask; [HideInInspector] public List<Transform> visibleTargets = new List<Transform>(); public float meshResolution;
现在,我们需要创建一个新的方法,我们将称之为DrawFieldOfView。在这个方法中,我们将定义我们的视野将有多少条Raycast线。我们还将定义每条将被绘制的线的角度:
void DrawFieldOfView() {
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
float stepAngleSize = viewAngle / stepCount;
for (int i = 0; i <= stepCount; i++) {
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;Debug.DrawLine (transform.position, transform.position + DirFromAngle (angle, true) * viewRadius, Color.red);
}
}
在创建了这个新方法之后,我们只需要从更新中调用它:
void LateUpdate() {
DrawFieldOfView ();
}
在这一点上,我们可以打开游戏编辑器并测试它,以可视化我们所创建的内容:

一旦我们按下播放按钮来测试我们的脚本,我们不会在旧版本和新版本之间看到任何区别。这是正常的,因为我们需要增加我们角色的网格分辨率。

正如我们在前面的屏幕截图中所看到的,我们需要在网格分辨率变量中添加一个值,以便看到期望的结果。

将 0.08 添加到网格分辨率变量中,我们就可以注意到在游戏编辑器窗口中已经出现了一些红色线条,这正是我们想要的。
如果我们继续增加这个值,将会添加更多的线条,这意味着视野将更加详细,这在下面的屏幕截图中有示例:

但是,我们需要记住,增加这个值也会增加设备的 CPU 使用率,我们需要考虑这一点,尤其是如果我们打算在屏幕上同时显示多个角色时。
现在,让我们回到我们的脚本,并为每行添加碰撞检测,使我们的角色能够同时接收来自多条线的信息。我们首先创建一个新的方法,我们将存储有关将要创建的射线投射的所有信息:
public struct ViewCastInfo {
public bool hit;
public Vector3 point;
public float dst;
public float angle;
public ViewCastInfo(bool _hit, Vector3 _point, float _dst, float
_angle) {
hit = _hit;
point = _point;
dst = _dst;
angle = _angle;
} }
一旦创建了新的方法,我们就可以回到我们的 DrawFieldOfView() 方法,并开始添加将检测每行碰撞的射线投射:
void DrawFieldOfView() {
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
float stepAngleSize = viewAngle / stepCount;
List<Vector3> viewPoints = new List<Vector3>();
for (int i = 0; i <= stepCount; i++)
{
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize
* i;
ViewCastInfo newViewCast = ViewCast(angle);
Debug.DrawLine(transform.position, transform.position +
DirFromAngle(angle, true) *
viewRadius, Color.red);
viewPoints.Add(newViewCast.point);
}
}
为了理解下一步,让我们看看如何从脚本中生成网格:

在前面的图中,我们可以看到一个代表角色的黑色圆圈和四个带有圆圈的圆圈,代表射线投射的结束位置。

每个顶点都分配了一个值,从角色开始的第一个顶点是数字零,然后以顺时针方向继续,下一个顶点从左侧开始,并继续向右计数。

顶点零连接到顶点 1。

然后顶点一连接到顶点 2。

然后顶点二连接回顶点 0,创建一个三角形网格。

一旦创建了第一个三角形网格,它将继续到下一个,从 0 > 2 > 3 > 0 开始,第二个三角形也被创建。最后一个是 0 > 3 > 4 > 0。现在,我们想要将这个信息转录到我们的代码中,所以在这种情况下,视野的数组是:
[0,1,2,0,2,3,0,3,4]
这个示例中的顶点总数是五个:
v = 5
创建的三角形总数是三个:
t = 3
因此,三角形的数量是:
t = v-2
这意味着我们数组的长度将是:
(v-2)*3
现在,让我们回到我们的脚本,并添加我们在这里解决的信息:
void DrawFieldOfView() {
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
float stepAngleSize = viewAngle / stepCount;
List<Vector3> viewPoints = new List<Vector3> ();
ViewCastInfo oldViewCast = new ViewCastInfo ();
for (int i = 0; i <= stepCount; i++) {
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
ViewCastInfo newViewCast = ViewCast (angle);
Debug.DrawLine(transform.position, transform.position + DirFromAngle(angle, true) * viewRadius, Color.red);
viewPoints.Add (newViewCast.point);
}
int vertexCount = viewPoints.Count + 1;
Vector3[] vertices = new Vector3[vertexCount];
int[] triangles = newint[(vertexCount-2) * 3];
vertices [0] = Vector3.zero;
for (int i = 0; i < vertexCount - 1; i++) {
vertices [i + 1] = viewPoints [i];
if (i < vertexCount - 2) {
triangles [i * 3] = 0;
triangles [i * 3 + 1] = i + 1;
triangles [i * 3 + 2] = i + 2;
}
} }
现在,让我们回到脚本的顶部并添加两个新的变量,public MeshFilter viewMeshFilter 和 Mesh viewMesh:
publicfloat viewRadius;
[Range(0,360)]
publicfloat viewAngle;
public LayerMask targetMask;
public LayerMask obstacleMask;
[HideInInspector]
public List<Transform> visibleTargets = new List<Transform>();
publicfloat meshResolution;
public MeshFilter viewMeshFilter;
Mesh viewMesh;
接下来,我们需要在我们的 start 方法中调用这些变量:
void Start() {
viewMesh = new Mesh ();
viewMesh.name = "View Mesh";
viewMeshFilter.mesh = viewMesh;
StartCoroutine ("FindTargetsWithDelay", .2f);
}

下一步是在游戏编辑器中选择我们的 Character 对象:

进入 GameObejct 部分,并选择创建空子对象:

将对象重命名为 View Visualization。

使用相同的选择对象,我们转到:组件 | 网格 | 网格过滤器,为我们对象添加一个网格过滤器。

然后我们需要对 Mesh Renderer,组件 | 网格 | 网格渲染器做同样的操作。

我们可以关闭“投射阴影”和“接收阴影”。

最后,我们将我们刚刚创建的对象添加到我们的脚本变量 View Mesh Filter 中,并将网格分辨率更改为任何期望的值,在这种情况下我们选择了 1。
现在,我们可以回到我们的脚本中,再次编辑DrawFieldOfView方法:
void DrawFieldOfView() {
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
float stepAngleSize = viewAngle / stepCount;
List<Vector3> viewPoints = new List<Vector3> ();
ViewCastInfo oldViewCast = new ViewCastInfo ();
for (int i = 0; i <= stepCount; i++) {
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
ViewCastInfo newViewCast = ViewCast (angle);
viewPoints.Add (newViewCast.point);
}
int vertexCount = viewPoints.Count + 1;
Vector3[] vertices = new Vector3[vertexCount];
int[] triangles = newint[(vertexCount-2) * 3];
vertices [0] = Vector3.zero;
for (int i = 0; i < vertexCount - 1; i++) {
vertices [i + 1] = viewPoints [i];
if (i < vertexCount - 2) {
triangles [i * 3] = 0;
triangles [i * 3 + 1] = i + 1;
triangles [i * 3 + 2] = i + 2;
}
}
viewMesh.Clear ();
viewMesh.vertices = vertices;
viewMesh.triangles = triangles;
viewMesh.RecalculateNormals ();
}
让我们测试游戏,看看我们在这里做了什么:

当我们玩游戏时,我们会注意到网格在游戏中的渲染,这是我们目前的目标。
记得删除Debug.DrawLine这一行代码,否则网格在游戏编辑器中不会显示。
为了优化可视化,我们需要将viewPoints从全局空间点更改为局部空间点。为此,我们将使用InverseTransformPoint:
void DrawFieldOfView() {
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
float stepAngleSize = viewAngle / stepCount;
List<Vector3> viewPoints = new List<Vector3> ();
ViewCastInfo oldViewCast = new ViewCastInfo ();
for (int i = 0; i <= stepCount; i++) {
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
ViewCastInfo newViewCast = ViewCast (angle);
viewPoints.Add (newViewCast.point);
}
int vertexCount = viewPoints.Count + 1;
Vector3[] vertices = new Vector3[vertexCount];
int[] triangles = newint[(vertexCount-2) * 3];
vertices [0] = Vector3.zero;
for (int i = 0; i < vertexCount - 1; i++) {
vertices [i + 1] = transform.InverseTransformPoint(viewPoints [i]) + Vector3.forward * maskCutawayDst;
if (i < vertexCount - 2) {
triangles [i * 3] = 0;
triangles [i * 3 + 1] = i + 1;
triangles [i * 3 + 2] = i + 2;
}
}
viewMesh.Clear ();
viewMesh.vertices = vertices;
viewMesh.triangles = triangles;
viewMesh.RecalculateNormals (); }
现在,如果我们再次测试它,它将更加准确。

看起来已经不错了,但我们可以通过将Update改为LateUpdate来进一步改进:
void LateUpdate() {
DrawFieldOfView ();
}
这样做,我们网格的移动将更加平滑。

更新了这部分脚本后,我们总结了我们的示例,将一个逼真的视野系统整合到我们的角色中。

我们只需要改变数值以适应我们想要的结果,使我们的角色或多或少地意识到他的周围环境。

例如,如果我们设置View Angle值为360,这将使我们的角色完全意识到周围发生的事情,如果我们降低值,我们将达到更逼真的视野,就像在合金装备固体游戏中使用的那样。

到目前为止,我们能够选择一个潜行游戏,并复制它们最标志性的特征,如逼真的视野和音频意识。我们已经学到了基础,现在我们可以从这里开始,开发我们自己的游戏。
摘要
在本章中,我们揭示了潜行游戏的工作原理以及我们如何重新创建相同的系统,以便我们可以在游戏中使用它。我们从简单的方法过渡到复杂的方法,使我们能够决定在创建的游戏中什么更适合,如果它高度依赖于潜行,或者我们只需要一个基本系统来使我们的角色通过视觉或听觉意识来探测玩家。本章学到的特性也可以扩展并用于我们之前创建的任何实际例子中,增强碰撞检测、路径查找、决策、动画以及许多其他特性,将它们从功能性转变为现实性。
我们创建游戏的方式不断更新,每款发布的游戏都带来了一种新的或不同的创建方法,这只有在我们愿意实验并融合我们所知道的一切,调整我们的知识以实现我们想要的结果,即使它们看起来极其复杂的情况下才可能。有时这仅仅是一个探索基本概念并扩展它们的问题,将一个简单想法转变为复杂系统。


浙公网安备 33010602011771号