Unity-游戏-AI-入门手册-全-
Unity 游戏 AI 入门手册(全)
一、介绍
在过去十年蓬勃发展的所有技术中,有一项对我们的社会变得至关重要,它增强了所有其他技术领域以及我们生活的方方面面:人工智能(AI)。从导航系统到智能汽车,从虚拟助手到我们智能手机上的增强现实(AR)应用,几乎我们使用的每一个软件和设备都在引擎盖下具有人工智能。电子游戏也不例外。
我们越深入,越多的人工智能以非玩家角色(NPC)、模拟和最近旨在增强用户体验的 AR 应用的形式出现在游戏应用中。机器学习算法开始成为增强图形和动画甚至实现新游戏功能的常见解决方案。
在这一章中,我将向你简要介绍人工智能,并谈谈它与电子游戏的关系。最后,我将向你展示这本书能为你提供什么,以及能从中期待什么。
先说基本问题:什么是人工智能?
1.1 人工智能
智力是我们人类最感兴趣的特征。我们在野生世界中生存下来,这要感谢我们的智慧,它让我们能够通过理解自然法则以及如何制作和使用工具来利用它们来维持我们的物种,从而将自己强加于动物和环境本身。
感谢我们的智慧,我们不仅设法生存下来,还进化并建立了一个能最大化我们生存机会的世界,创造了复杂的组织系统来满足我们所有的需求。
这就是为什么我们现在仍然如此重视智力。我们根据人们的智力来判断他们;我们看重他们跳出框框思考、优化流程或在特定流程中获得最佳结果的能力。对我们来说,智力不仅仅是思考的能力,更重要的是感知和理解我们周围的世界,并采取行动以利用它来达到我们的目标的能力。
智能是大自然赐予我们的祝福,通过人工智能,我们的目标是将火炬传递给我们自己的创造:机器。
人们仍然在争论,人工智能更多的是让机器像人类一样思考和行动,还是创造出具有理性思维天赋的机器,以尽可能最好的方式利用环境有目的地做事。这两个方向都是完全合法的,并且会导致非常有趣的结果。在本书中,我们将探索人工智能的解释,旨在创建理性的代理,这些代理可以处理来自环境的感知,并应用推理过程来阐述它们,以提出解决复杂问题的解决方案(一个动作或一系列动作)。
在下一节中,我们将更详细地了解什么是智能代理以及它们涉及哪些应用。
智能代理
智能代理是智能系统和代理的结合。
智能系统是一种能够处理信息以达到特定目标的装置,而智能体是一种对其接收到的信息做出反应的东西。因此,智能代理是一个能够处理感知信息并采取行动以达到特定目标的系统。
智能体由感知环境的传感器和允许它们采取行动的执行器组成(图 1-1 )。
图 1-1
智能体拥有感知环境的传感器和对环境做出反应的执行器
在我们的脑海中,很容易将智能代理描绘成一个机器人,它有光电池、照相机、天线或其他类型的传感器等设备来感知环境,一台计算机来处理信息,并使用某种致动器来执行某个动作,如机械臂、轮子或其他类型的与世界交互的机制。
从数学的角度来看,智能代理可以被视为一个函数,其中参数是感知;该函数的逻辑允许对结果(返回值)的理解进行细化,该结果是要采取的最终动作。
这可以用下面的定义来表示:
→→ P* → A
这意味着代理函数→将每个可能的感知序列P*
映射到一个适当的动作A
。显然,这个概念很容易用编程语言来表达,它将是我们创造具有统一性的智能代理的基础。
在开始谈论 Unity,我们将用来探索 AI 的游戏引擎之前,让我们看看 AI 和智能代理如何与视频游戏世界相关联。
1.1.2 视频游戏中的人工智能
从视频游戏历史的最开始,人工智能就是其中的一部分。事实上,最早的人工智能应用之一是智能代理,它们能够自己与其他人工智能和人类玩游戏。那种智能代理只是程序,可以自己玩一个游戏,不需要人类的帮助。他们的目标是做出最佳选择来赢得比赛。他们感知游戏环境,精心整理收集的数据,拿出最方便的动作。
1997 年,由 IBM 开发的人工智能“深蓝”在一场国际象棋比赛中击败了世界冠军加里·卡斯帕罗夫,这是自主游戏代理人的第一次伟大胜利。卡斯帕罗夫在评论那场比赛时说,“那天我感觉到了一种新的智慧。”
今天,赢下一盘对抗人工智能的棋是不可能的。人工智能对人类的另一个更近的胜利是在 2011 年,IBM 制造的新人工智能 Watson 在 Jeopardy 中遥遥领先!对战人类玩家。沃森的成就比深蓝更令人印象深刻,因为要赢得的危险!你需要对流行文化有很深的了解,并且能够联系音乐、八卦、电影等领域的事实。虽然我们期望人工智能擅长数学和象棋等理性推理活动,但我们并不期望它们对我们的文化有更好的理解,而沃森已经证明了这一点——这正是这一成就如此重要的原因。
随着视频游戏越来越受欢迎,在 20 世纪 70 年代,人工智能开始专注于为单人游戏创造引人注目的敌人。一些最早这样做的游戏是速度竞赛(1974 年台东)、 Qwak!(雅达利,1974),以及追击 (Kee Games,1975)。继这些先驱之后,在所谓的视频游戏黄金时代,人工智能开始在视频游戏中变得越来越受欢迎和常见,一些最初有趣的应用开始出现,例如,在像 Galaxian (Namco,1979)和 Galaga (Namco,1981)这样的游戏中,敌人突破队形的杂技动作,或者是 Pac-Man (Namco,1980)的原始方法,其中敌人具有独特的个性并试图
随着人工智能应用在黄金时代的流行,视频游戏开始实现智能行为,其特征是复杂性增加。特别是,在这些年里,战术和动作 RPG 开始出现在像龙之任务 IV 、博德之门和魔法的秘密这样的游戏中,这些游戏实现了一些有趣的新功能,如发布命令和为团队成员设定行为的可能性,以便他们可以在战斗中自主作战。其他有趣的技术也开始兴起来模拟体育比赛中的团队努力。随着应用复杂性的增加,当前技术的局限性开始显现,人们找到了更复杂的解决方案。特别是,在 20 世纪 90 年代,视频游戏开始实现正式的人工智能技术。有限状态机(FSM)就是其中之一,它过去是(现在仍然是)最流行的一种。有限状态机可以很好地解决影响几乎所有游戏类型的一系列问题,但最受欢迎的应用可能是动作游戏中敌人的行为;在这种类型的游戏中,敌人必须对情况做出反应并执行适当的动作,这些行为可以表示为 FSM。
使用有限状态机的视频游戏最受欢迎的例子是吃豆人 (Namco,1980)和吃豆人女士 (Namco,1981)。他们引入了敌人适应不同情况的概念,并改变他们的行为决定追逐或逃离玩家。但这还不是全部;事实上,《吃豆人》和《吃豆人小姐》也是第一款敌人看起来为了打败玩家的共同目标而合作的游戏。事实上,两个游戏中的敌人是四个不同的鬼魂,具有四种不同的性格,他们遵循互补的策略来追逐玩家,使他们看起来像是实际上在合作遵循一个庞大而组织良好的追逐计划。所有这些都是由于 FSM 实现的,它基本上是一个将状态与动作相关联的图,使 NPC 根据它(或游戏世界)所处的状态采取特定的动作。这样,玩家就有了这样的印象:NPC 实际上是在对特定的情况进行理性的思考和反应。
有限状态机并不是过去的事情,事实上,我们仍然可以在非常现代和复杂的游戏中找到它,如古墓丽影系列游戏或战场或使命召唤系列游戏。一般来说,大多数动作游戏都是通过 FSM 来管理 NPC 的行为。这是因为 FSM 易于实现,对性能的影响很小,但如果设计得当,它可以提供真实可信的用户体验。其他人工智能方法需要更多的资源和工作,在一些游戏类型和情况下,与 FSM 相比,它们甚至不能创建更身临其境或可信的结果。
在今天的电子游戏中,AI 不仅用于创建智能代理。我们最近看到了增强现实(AR)的兴起,它使用计算机视觉技术,通过虚拟元素来增强现实世界中的物体。这类最重要的应用之一是神奇宝贝 Go (Niantic,2016),它允许玩家使用智能手机在现实世界中观看和捕捉神奇宝贝。
人工智能在视频游戏中的其他有趣应用是随着机器学习的进步和普及而出现的,机器学习用于增强许多新视频游戏中的图形和游戏元素。在 Warframe (Digital Extremes,2013)中可以看到一个非常有趣的例子,其中使用了一种机器学习算法,通过观察玩家如何爬墙,并处理数据来标记所有可跑的墙以及这些爬墙的起点和着陆点,来教会敌人如何爬墙。
视频游戏中使用了一些更先进的人工智能技术的著名例子,如史蒂夫·格兰德(Steve Grand)创作的生物系列游戏,这是最著名和最重要的人工生命(al)游戏之一,今天仍被认为是最复杂和最天才的人工生命游戏之一。生物使用许多有趣的人工智能技术,如遗传算法和神经网络,来创造虚拟生物,这些虚拟生物可以通过交配将自己的遗传特征传递给后代,并能够通过在自己的环境中进行实验和从自己的行为后果中学习。这是 AL 最有趣和最重要的产品之一,我强烈建议你购买并玩这个游戏,并阅读史蒂夫格兰德写的关于游戏制作的书:创作:生活和如何制作它。
毫无疑问,AI 正变得越来越重要,以许多不同的方式增强视频游戏的整体体验,并跟上 AI 带来的所有变化和进化。从头开始学习是明智的;这意味着理解如何创建基本的智能代理。为了达到这个目标,我们将利用许多属于计算机科学和数学的不同工具。解释的概念将使用 C# 编程语言在 Unity 中实现。为了简单起见,我们将不使用复杂的 3D 模型,而只使用非常基本的 3D 对象(主要是几何 3D 形状,如立方体和球体),因为这里的重点不是制作一个好看的视频游戏,而是了解有用的游戏人工智能技术,以重用和适应任何游戏项目。
1.2 统一性
团结不需要介绍。它可能是最受欢迎的游戏引擎,它帮助独立开发者和小工作室更容易开发游戏。
我们选择使用 Unity 主要是因为它的简单性和完整性。这是一个非常容易上手的游戏引擎,但同时,它具有许多先进的工具,它的库提供了许多有趣和有用的功能,使我们的工作更容易,让我们专注于有趣的部分。
在 Unity 中,你使用 C# 编程,这是一种 OOP 语言,它提供了许多高级特性,打包在华丽的类似 C 的语法中。它是一种极其强大和清晰的编程语言,允许我们用智能工具和清晰性来实现复杂的目标。此外,如果我们想使用 Unity 提供的所有功能,这是最好的选择。最后但同样重要的是,它基于面向对象编程(OOP)范式,允许更结构化和模块化的编码风格。
所有这些特点使 C# 成为本书的一个好选择,不仅因为它是完成工作的好工具,还因为它对学习编码和掌握我们将在本书中使用的一些技术非常有用。
获得使用 Unity 和 C# 的好处的第一步是实际安装 Unity,所以让我们看看如何做!
安装 Unity Hub
Unity 是一个自由软件;把它安装到你的机器上的第一步是从官网下载 Unity Hub:https://stsssore.unity.com/download
。
Unity Hub 是一款可以帮助你组织和跟踪所有 Unity 项目和 Unity 版本的软件。使用 Unity Hub,您可以安装或卸载特定版本的 Unity,添加或删除 Unity 项目,并非常容易地将它们链接到特定版本的 Unity。
一旦进入下载页面,您将被要求接受许可协议,之后可以单击下载按钮来获取安装程序。
下载安装程序后,根据是在 Windows、Mac 还是 Linux 上安装,您需要遵循不同的过程:
-
在 Windows 上,您必须接受许可协议,并在您的驱动器上选择一个您希望安装 Unity Hub 的路径,然后按“安装”,该软件将为您的机器获取最新版本的 Unity Hub 软件。
-
在 Mac 上,你只需要像往常一样将应用拖到应用文件夹中。
-
在 Linux 上,您需要使用 AppImage 文件启动器打开 Unity Hub 安装程序(在本例中是一个 AppImage 文件)。
1.2.2 激活您的 Unity ID 和许可证
首次启动 Unity Hub 时,会提示您进入许可管理窗口:在这里,您将被要求激活一个许可以开始使用 Unity(图 1-2 )。
图 1-2
许可证管理窗口。您需要有一个 Unity ID 并激活许可证才能开始使用 Unity
在此之前,您需要创建一个免费的 Unity 帐户,方法是单击屏幕右上角的 Unity ID 图标,然后选择登录(图 1-3 )和“创建一个”(图 1-4 )。
图 1-4
这是登录表单;点击“创建一个”注册一个 Unity ID
图 1-3
单击 Unity ID 图标,然后登录或注册您的 Unity ID
一个包含注册表单的新窗口将会打开(图 1-5 )。如果你想用谷歌或脸书账户注册,只需填写表格或使用两个按钮中的一个。
图 1-5
填写此表格以创建一个 Unity ID,或者如果您想使用您的 Google 或脸书帐户创建,请单击窗口底部的按钮
现在您已使用您的 Unity ID 登录,您可以激活许可证。您可以通过两种方式在许可证管理窗口(图 1-6 )中完成此操作:
图 1-6
现在您已登录您的 Unity ID,您可以使用窗口右上角的两个蓝色按钮之一申请许可证
-
手动激活:如果你没有活跃的互联网连接,这很有用。您可以创建一个许可证申请文件,稍后上传到
https://license.unity3d.com/manual
。 -
引导激活:您需要遵循一些步骤并回答一些问题,以自动激活您的 Unity ID 上的许可证。
1.2.2.1 手动激活
如果您想要手动激活您的许可证,请单击许可证管理部分中的手动激活按钮(图 1-6 )。会出现一个小窗口提示您生成并保存一个许可证请求文件,稍后上传到 https://license.unity3d.com/manual
(图 1-7 )。点击“保存许可请求”按钮,将许可请求文件保存在您的计算机上(图 1-8 )。
图 1-8
保存您的许可请求文件,并将其上传到 https://license.unity3d.com/manual
图 1-7
手动激活视图。在这里,您可以请求稍后在 https://license.unity3d.com/manual
上传您的许可证
将文件保存到您的计算机后,您需要进入 https://license.unity3d.com/manual
,将您刚刚保存的文件(图 1-9 )上传到上传表单,然后按下一步按钮。
图 1-9
在 https://license.unity3d.com/manual
上传许可证申请文件
上传您的许可证请求将在 Unity Hub 中自动激活您的 Unity ID 帐户,允许您在 PC 上下载 Unity 并开始使用。
1.2.2.2 引导激活
引导式激活需要您有一个可用的互联网连接,但它更快更容易。
您需要在图 1-6 所示的窗口中点击“激活新许可证”;然后你会看到一个新窗口,询问你想要激活哪种许可证(图 1-10 )以及你是否打算将 Unity 用于商业或学习目的(图 1-11 )。根据您的使用案例做出选择,然后单击“完成”
图 1-11
如果你不打算出售你的游戏,你可以免费使用 Unity,但如果你的公司收入低于一定数额,也有一些有趣的计划
图 1-10
您可以在两类许可证之间进行选择:Unity Personal(为个人和业余爱好者制作)和 Unity Plus 或 Pro(为专业人士制作)
完成所有步骤后,许可证管理窗口将包含您新的有效许可证的详细信息,这意味着您已经设置完毕(图 1-12 )。
图 1-12
一个新的许可证被激活,Unity Hub 现在可以使用了!
1.2.2.3 安装统一
激活您的 Unity ID 和许可证后,您需要从 Unity Hub 下载 Unity 版本。
点击窗口左侧的Installs
,系统会提示您进入Installs
视图,该视图会列出电脑上安装的所有可用的 Unity 版本(图 1-13 )。
图 1-13
这是安装视图。在这里,您可以添加或删除不同版本的 Unity
在Installs view
中,点击Add
按钮选择要安装的 Unity 新版本,会弹出一个列表,列出所有可用的 Unity 版本,如图 1-14 所示。
图 1-14
安装 Unity 的第一步要求您选择引擎的版本
按照这本书,你不会被迫使用 Unity 的特定版本,因为我们不打算使用该软件的任何特定功能;无论如何,引擎的 UI 可能会因版本而异,所以我建议你不要使用比 2018 年 LTS 版更老的版本。我将从 2019 年开始为这本书使用 LTS 版本。
选择正确的 Unity 版本后,点击Next
按钮。
一旦您选择了正确的版本并点击Next
,您将被要求选择要安装的模块(图 1-15 )。这些模块将允许你把你的项目导出到几个不同的平台,从桌面到移动和网络,以及其他更具体的平台,比如 tvOS 和 Vuforia。
图 1-15
安装 Unity 的第二步要求您选择要安装的模块。这些模块将允许您将项目导出到许多不同的平台
对于本书,我们将仅在桌面上导出,因此请选择与您使用的桌面操作系统(OS)相关的模块。比如你用的是 Mac,可能要安装Mac Build Support
模块;如果你使用的是 Linux,安装Linux Build Support
;如果你用的是 Windows(像我一样),安装Windows Build Support
。我还建议你安装Documentation
模块,在你的电脑上安装这个模块总是有用的。
最后,单击Done
按钮结束该过程,并开始下载和安装您刚才选择的 Unity 版本。
二、基础知识
正如 Edwin Abbott 在他的书 Flatland 中巧妙地解释的那样,现实是由许多维度构成的,根据你的感知技能,你只能看到其中的一部分并对其采取行动。在电子游戏中也是如此:电子游戏可以设置在 3D 或 2D 世界中,这种区别决定了代理人感知周围世界的方式,从而决定了他们移动和行动的能力。
N 维空间是一种几何设置,其中空间中的点由 N 个值或参数来标识,通常以字母表的最后几个字母命名。
在二维空间(2D 空间或平面)中,空间中的点由两个值定义,这两个值称为宽度和高度(通常称为 x 和 y)。你可以在 2D 空间中表现的对象是点、线以及所有的平面几何图形,如三角形、正方形、圆形等等(图 2-1 )。
图 2-1
2D 空间有两个维度:宽度和高度
在三维空间(3D space)——也就是我们能够感知的空间——中,空间中的点由三个参数来标识:高度、宽度和深度,通常称为x
、y
、z
。在 3D 空间中,您可以表示所有 2D 对象以及除了高度和宽度之外还具有第三维度:深度的所有对象。三维的几何物体有立方体、球体、金字塔,以及……嗯,基本上所有我们知道的宇宙中的物质(图 2-2 )!
图 2-2
三维空间有三个维度:宽度、高度和深度
所以,正如我们所说的,根据空间的维数,你需要足够数量的值来标识空间中的一个点。这些值由矢量表示。
2.1 矢量
向量是在 N 维空间中由 N 个值定义的量,它有大小和方向。向量的大小基本上就是向量的大小,而方向就是它在空间中的方位(图 2-3 )。
图 2-3
向量的基本表示
矢量的一个简单例子是加速度。假设你正以 50 km/h 的速度驾驶一辆汽车,如果你一直以 50 km/h 的速度行驶,加速度为 0km/h2;如果你再多踩一点油门,速度会以 5 公里/小时的速度增长 2 。这个加速度值是一个向量,方向等于汽车的方位,大小为 5 km/h 2 。
所以,有了向量,我们可以追踪空间中的运动和作用力。例如,在电子游戏(2D 或 3D,无所谓)中,角色从一个点A
移动到一个点B
的运动由一个向量表示,该向量的大小m = B-A
和方向等于从A
到B
的箭头的方向(图 2-4 )。
图 2-4
箭头表示 2D 平台中角色的运动向量
在 Unity 中,向量使用特定的数据类型来表示。您可以使用Vector2
定义一个 2D 矢量,使用Vector3
定义一个 3D 矢量。
您可以使用它们的构造函数来声明它们,如下所示:
Vector2 my2DVector = new Vector2(x, y);
Vector3 my3DVector = new Vector3(x, y, z);
向量是 N-空间中所有运算的核心,无论是线性代数还是 Unity。例如,对象的位置由 3D 向量及其比例值表示。这两个值可以通过向量运算来修改。让我们快速看一下向量最重要的操作以及它们在视频游戏环境中的意义。
添加
如图 2-5 所示,相同类型的两个向量(例如两个 3D 向量)之间的相加是通过计算两个向量的分量之和来实现的。
图 2-5
两个向量 A 和 B 之和的图形表示
因此,举例来说,如果你想对两个向量[1, 2, 3]
和[4, 5, 6]
求和,你只需计算出结果向量[1+4, 2+5, 3+6]
,也就是[5, 7, 9]
。
由于一个矢量可以表示空间中的一个点,所以两个矢量之和用来表示从该点到空间中一个新点的运动。所以,基本上,当你把一个矢量A
和一个矢量B
相加时,矢量A
将是起点,矢量B
是引导你到新的点C = A+B
的偏移量。
在 Unity 中,您可以使用+
(加号)运算符对两个向量求和,如下所示:
Vector3 result = new Vector3(1,2,3) + new Vector3(4,5,6);
减法
两个向量之间的减法非常类似于加法。唯一不同的是二次元的方向反了。
例如,如果你想计算一个向量A = [4,5,6]
和一个向量B = [1,2,3]
之间的差,你必须计算得到的向量C = A - B = [4,5,6] - [1,2,3] = [4,5,6] + [-1,-2,-3] = [4-1, 5-2, 6-3] = [3, 3, 3
。
两个向量之间的减法用于找出它们之间的差,在空间上下文中,差表示由两个向量表示的两点之间的距离。
在 Unity 中,您可以使用-(减号)运算符减去两个向量,如下所示:
Vector3 result = new Vector3(4,5,6) - new Vector(1,2,3);
标量乘法
我们说过,矢量有大小和方向。幅度是矢量的长度。
为了计算矢量V = [ a, b, c ]
的幅度|V|,我们应用以下公式:
|V| =
您可以通过将向量的所有值乘以或除以所需的量来更改向量的大小。图 2-6 显示了向量上标量乘法的图形表示。
图 2-6
向量上标量乘法的图形表示
例如,如果你想将向量V = [1, 2, 3]
的大小乘以一个标量值x = 2
,你可以将V
的每个元素乘以x
,就像这样:V*x = [1*2, 2*2, 3*2] = [2, 4, 6]
。
类似地,如果你想用一个标量值x = 2
来减少一个向量W = [2, 4, 6]
的大小,你可以用x
来除W
的每个元素,就像这样:W/x = [2/2, 4/2, 6/2] = [1, 2, 3]
。
点积
向量的另一个重要运算是点积。这是一个代数运算,你可以对两个向量进行运算来得到一个标量值。两个向量点积的结果是它们所面对的方向之差。
通常,点积应用于归一化向量,即长度为 1 的向量。这是因为当我们想要计算两个向量的方向之差时,我们并不太关心它们的长度,而只关心它们的方向。
当您将点积应用于一对归一化向量时,结果包含在1
和-1
之间的范围内。如果得到的值是1
,两个向量面向同一个方向;如果是0
,它们是垂直的;如果是-1
,他们面向相反的方向(图 2-7 )。
图 2-7
点积对于计算 3D 空间中的亮度值非常有用
点积的一个实际应用是根据光源的位置计算表面的亮度。设L
为光线矢量,表示光源的位置和方向,N
为表示一个曲面的法向量的矢量(指垂直于一个曲面的矢量)。计算B = L dot N
会给我们B
一个浮点数,代表表面的亮度,其中N
是法向量,小于或等于0
的值表示黑暗,1
表示最大亮度。
在 Unity 中,您可以使用 Vector3 的点函数来计算我们在前面的示例中提到的两个向量L
和N
的点积,如下所示:
float B = Vector3.Dot(N, L);
2.2 第一个项目!
之前,我们说过向量是如何用来表示位置和方向的。让我们用 Unity 来实践这一点吧!
打开 Unity,点击新建按钮创建一个新项目(图 2-8 )。
图 2-8
在 Unity Hub 中创建新项目
从模板列表中选择 3D 项目模板,并为您的新项目选择一个名称和文件夹,如图 2-9 所示,然后点击Create
。
图 2-9
在 Unity Hub 中创建 3D 项目
Unity 将在几秒钟内为您建立一个项目。
创建项目时,您将看到经典布局,其中不同部分专用于项目的不同部分。下面就来探究一下主要的!以下列表参照图 2-10 和 2-11 :
图 2-11
该图显示了 Unity 编辑器 UI 的一些重要部分(在前面的编号列表中可以找到解释)
图 2-10
该图显示了 Unity 编辑器 UI 的一些重要部分(在前面的编号列表中可以找到解释)
-
工具栏:它可以让你访问一些基本的功能,比如操作场景视图和其中的游戏对象的工具,运行和停止游戏的按钮以及调试游戏的步骤按钮,访问云服务和版本控制功能的按钮。
-
层次窗口:显示当前场景中所有对象的列表。从“层次”面板中,您可以通过检查器访问每个对象并修改它们的属性。
-
检查器窗口:检查器向您显示与当前选择的资产相关的所有详细信息。此窗口没有标准视图,因为不同种类的资产具有不同种类的属性。
-
项目窗口:它基本上是一个资产浏览器,显示并列出与您的项目相关的所有资产。随着新资产的创建,它们将显示在项目窗口中。
-
场景视图:显示选中的场景,允许你浏览和编辑其中的游戏对象。通过选择相应的按钮,可以在 3D 或 2D 模式下与场景视图中的场景进行交互。
-
游戏视图(Game View):它可以让你看到你最终渲染的游戏是什么样子。按下播放按钮,您可以在此视图中开始游戏。
-
控制台窗口:显示由 Unity 生成的错误、警告和其他信息,或由程序员使用调试程序创建的自定义信息。日志,调试。日志警告和调试。LogError 函数。
2.2.1 第一现场!
在我们的第一个项目中,我们希望通过创建一个允许我们通过修改对象的位置向量来移动对象的应用来探索向量的基础。该应用将包括一个简单的立方体上的平面。使用鼠标,我们可以点击平面的不同部分,并通过修改立方体的位置向量来改变立方体的位置。
可以想象,3D 对象在 3D 空间中是由它们顶点的 3D 位置向量定义的。不过,在 Unity 中,为了简单起见,我们只需要修改一个称为轴心点的向量。枢轴点是与表示其在 3D 空间中的位置的对象相关联的向量。
所以,让我们从添加一些 3D 物体到我们的起始场景开始。
右键单击层次或资产面板,并从上下文菜单中选择3D Object
➤ Plane
。这将在场景中创建一个平面。
现在,我们想改变我们的平面的位置,这样我们就可以让它正好在场景的中心。要做到这一点,我们需要修改枢轴点,正如我们刚才所说,这是一个三维向量。左键单击层次窗口中的平面,对象的信息和属性将显示在检查器面板中。找到转换段,将x
、y
、z
属性改为x = 0
、y = 0
、z = 0
。这将在场景的原点捕捉我们的平面(图 2-12 )。
图 2-12
在检查器中看到的平面对象
Tip
原点是任意 N 空间的点 0。在 2D 空间的情况下,像笛卡尔平面一样,原点在位置x = 0
、y = 0
。在 3D 空间中,就像我们在 Unity 中的场景一样,它位于x = 0
、y = 0
、z = 0
的位置。
现在用同样的方法创建一个立方体,右击“资产”或“层次”面板并选择3D object
➤ cube
。通过在“层次”窗口中左键单击立方体来选择它,使其属性列在检查器页面中。在这里,将变换位置部分的x
、y
和z
属性的值更改为x = 0
、y = 0.3
和z = 0
(图 2-13 )。
图 2-13
在检查器中看到的立方体对象
现在我们已经有了 3D 物体,我们唯一需要的就是一个好的视角!游戏中的可视场景是由Camera
对象定义的,它是一个由许多属性组成的GameObject
,允许你从不同的视角和不同的图形设置来展示你的游戏世界。无论如何,我们不会在这方面陷得太深,因为这会使我们远离本书的范围。我们唯一需要知道的是,Camera
允许我们定义我们能看到什么以及如何看到。同样,摄像机的位置是由一个我们需要修改的向量定义的。
在层次窗口中选择Main Camera
对象,导航到检查器的变换位置部分,将其x
、y
、z
属性更改为x = 0
、y = 4
、z = 0
。在检查器中,定位Transform Rotation
属性,将x
、y
和z
设置为x = 90
、y = 0
和z = 0
(图 2-14 )。
图 2-14
在检查器中看到的主摄像机
好了,现在场景完成了!我们让摄像机俯视立方体位于其中心的平面。我们现在需要添加功能,使立方体移动到鼠标指向的不同位置。我们可以使用 C# 脚本来做到这一点。让我们看看如何!
2.2.2 第一个剧本!
如你所知,如果你已经使用过 Unity,脚本是通过编写脚本文件并将它们分配给对象来实现的。每个脚本都可以实现标准函数,这些函数定义了执行动作的时刻。我们要做的是不断检查鼠标的位置,如果用户点击,我们希望立方体移动到这些坐标。
通过右键单击项目窗口并选择Create
➤ C# Script
来创建脚本。将剧本重命名为Move
。现在双击脚本打开文本编辑器,开始编写代码。
您刚刚创建的脚本将包含以下模板代码:
1\. using System.Collections;
2\. using System.Collections.Generic;
3\. using UnityEngine;
4
5 public class Move : MonoBehaviour
6 {
7 // Start is called before the first frame update
8 void Start()
9 {
10
11 }
12
13 // Update is called once per frame
14 void Update()
15 {
16
17 }
18 }
第 1–3 行只是包含一些库和模块的行。有趣的部分紧接着开始:一个新的具有文件名的类被声明继承自类MonoBehaviour
( 第 5 行);这允许我们覆盖一些有用的方法,比如每次游戏开始时调用的Start
( line 8 )和每帧调用的Update
( line 14 )。我们可以去掉Start
功能,因为我们不会使用它。
计划是等待用户单击空间中的一个点,然后读取鼠标光标的坐标,并将立方体移动到这些坐标。为此,我们将利用raycasting
技术,该技术在 Unity 中被广泛用于许多目的。每一帧,我们都会从相机到鼠标位置投一个ray
,当用户点击时,我们会计算光线与 3D 平面碰撞的位置,并将立方体移动到那个位置。
首先要做的是将我们刚刚创建的脚本分配给立方体对象,因此在层次窗口中找到该对象,并通过单击它来选择它。该对象的属性将显示在检查器中。现在拖动移动脚本并将其放在显示立方体属性的层次窗口中。就这样!现在,您的脚本与该对象相关联(图 2-15 )。
图 2-15
检查器中显示的立方体对象,其中列出了它的所有设置
既然脚本与多维数据集相关联,我们可以开始修改它并添加功能。双击脚本,在您喜欢的编辑器/IDE 中打开它,并用以下代码替换内容:
1\. using UnityEngine;
2.
3\. public class Move : MonoBehaviour
4\. {
5\. private void Update()
6\. {
7\. RaycastHit hit;
8\. Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
9\. if (Physics.Raycast(ray, out hit) && Input.GetMouseButtonDown(0))
10\. {
11\. Vector3 newPosition = new Vector3(hit.point.x, this.transform.position.y, hit.point.z);
12\. this.transform.position = newPosition;
13\. Debug.Log("Current position vector: " + newPosition.ToString());
14\. }
15\. }
16\. }
这是添加我们谈到的功能的完整代码;还是更好的分析一下吧!
我们已经看到了文件的结构,以及 using 语句和类声明。我们不需要Start
方法,所以我们可以去掉它。我们将只需要Update
方法;让我们看看如何!
在的第 7 行,我们声明了RaycastHit
类型的hit
变量。这是一个用于从ray
与Collider
的碰撞中获取信息的结构。
这个想法是当用户点击鼠标时,从相机向光标的坐标投射光线。光线和第一个碰撞器之间的碰撞点——在本例中,是 3D 平面的碰撞器——将存储在hit
变量中。这将允许我们计算我们想要立方体移动到的位置。
在的第 8 行,我们创建了Ray
类型的ray
变量:这是我们要通过函数ScreenPointToRay
将鼠标在向量Input.mousePosition
中的位置作为参数从相机射到鼠标位置的实际光线。
在第 9 行,我们调用Physics.Raycast(ray, out hit)
函数将光线从相机投射到鼠标位置。如果射线击中了碰撞器,该函数返回true
,击中的位置存储在我们传递给该函数的hit
变量中。在同一行中,我们还调用了Input.GetMouseButtonDown(0)
函数,如果按下索引为0
的鼠标按钮,该函数将返回true
。可以想象,默认情况下,索引为0
的鼠标是一级按钮:左键;右边的按钮标记为1
,中间的按钮标记为2
。我们使用 AND 操作符将这两个函数调用放在一起。;如果两者都返回 true,我们执行从第 11 行到第 13 行的指令。
在第 11 行,我们使用在hit
变量中找到的x
和z
坐标创建新的位置向量(光线击中 3D 平面碰撞器的坐标),对于y
坐标,我们使用立方体对象的坐标,这样我们可以保持它在相同的高度。
在第 12 行,我们将位置向量分配给当前对象的位置,在第 13 行我们使用Debug.Log
函数将该信息打印到调试控制台,只是为了查看位置向量值的变化。
现在我们有了第一个脚本,我们可以玩游戏并测试它了。按下播放按钮编译并运行游戏。你将看到场景和立方体在中心(图 2-16 )。单击 3D 平面中的任意位置,将立方体移动到那里。
图 2-16
玩游戏时,你将从中心的立方体开始。当你点击一个点时,立方体将移动到那里
现在我们已经在实践中探索了一些向量,让我们更进一步。让立方体朝着我们点击的点移动,而不是仅仅传送到那里,这将是很好的。让我们看看我们如何能做到这一点。
2.2.3 向一个点移动
在上一节中,我们设法通过单击鼠标将立方体立即移动到我们选择的某个位置。我们在这一部分想要实现的是,在一定的时间内,将它逐渐向选定的点移动。基本的区别在于,物体不是一次移动到那个点,而是向目标位置迈出几小步。
我们想在一定的时间跨度内重建从一点A
到一点B
的空间运动概念;为此,我们可以使用几何平移的概念。
几何平移是将图形或空间的所有点向同一方向移动的几何变换。这种运动是通过在图形的每一点上加上一个恒定的矢量来实现的;这个恒定矢量就是运动矢量,它定义了我们想要到达的空间点。
在 Unity 中,我们有 Transform 类的 Translate 方法,它实现了几何平移的概念。Unity 场景中的每个对象都有一个变换,允许存储和操作对象的位置、旋转和缩放。
我们可以用这样的运动矢量统一平移一个物体:
myObject.transform.Translate(myMovementVector);
运动矢量是我们希望物体在某个方向上移动的空间量。我们希望对象在每一个时间间隔内都要遍历将它与目标分开的一小部分空间,但是我们如何计算对象每秒应该遍历多少空间呢?
在物理学中,以一定速度运动的物体在一个时间间隔内走过的平均空间称为δs
(δ空间),用下面的公式描述:
Δs = v m * Δt
其中v
m
是物体移动的平均速度,δt
是物体移动量δs
(我们要计算的量)的时间量。
所以如果我们的运动矢量是δs
,要计算它,我们只需要把平均速度乘以时间间隔。
我们想要移动物体的速度是一个我们可以虚构的任意量。我将选择值 1,这意味着物体将以每秒 1 个单位的速度移动。
对于时间间隔δt
,Unity 向我们提供了一个值,该值已经准备好了该信息。这是Time.deltaTime
,是上一帧的帧时间和当前帧的帧时间之差。
Note
帧时间是渲染一帧所需的时间。它是一个浮点值,可以根据要渲染的场景的复杂程度而逐帧变化。当然,不稳定的平均帧时间值(意味着每一帧的所有帧时间之间的平均值)是糟糕性能的征兆,因为它可能会导致口吃,破坏玩家的体验。
Time.deltaTime
以秒表示;这意味着它也帮助我们将运动表示为以速度v
m
每秒穿越的空间量。
我们可以把这个概念用于我们的运动矢量,就像这样:
myObject.transform.Translate(0, 0, speed * Time.deltaTime);
我们申请转型。平移一个向量[0, 0, speed * Time.deltaTime]
,因为我们希望对象向前移动。
我们只需要一点点!既然我们在向前移动我们的对象,我们也需要让它转向新的位置。为此,Unity 在 transform 类中提供了一个非常方便的函数:LookAt。我们可以很容易地使用 LookAt,就像这样:
this.transform.LookAt(positionToLookAt);
其中 positionToLookAt 是一个 Vector3,表示我们希望对象转向的空间点。
让我们看看如何将这些新信息应用到我们的代码中:
1\. using UnityEngine;
2\. using System.Collections;
3.
4\. public class Move : MonoBehaviour
5\. {
6\. Vector3 goal;
7\. float speed = 1.0f;
8\. float accuracy = 1.0f;
9.
10\. void Start()
11\. {
12\. goal = this.transform.position;
13\. }
14.
15\. void Update()
16\. {
17\. RaycastHit hit;
18\. Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
19.
20\. if(Physics.Raycast(ray, out hit) && Input.GetMouseButtonDown(0))
21\. {
22\. goal = new Vector3(hit.point.x, this.transform.position.y, hit.point.z);
23\. }
24.
25\. this.transform.LookAt(goal);
26\. if(Vector3.Distance(transform.position, goal) > accuracy)
27\. {
28\. this.transform.Translate(0,0, speed*Time.deltaTime);
29\. }
30\. }
31\. }
在的第 6–8 行,我们定义了我们将在后面的代码中使用的变量来定义目标位置、移动速度和精确度。精度是一个偏移值,我们需要它来避免对象不断地做微小的运动,而是使用一种更近似的运动。这意味着,如果物体足够接近该点,它将停止,这种近似的精度由accuracy
变量表示。
我们恢复了Start
方法,并使用它来初始化目标向量,该向量在开始时存储对象的当前位置(第 10–13 行)。
我们已经看到,我们需要创建在Physics.Raycast
方法(第 20 行)中使用的hit
和ray
变量(第 17–18 行)。单击鼠标左键时,我们将使用从hit
变量中获取的坐标设置目标 3D 矢量,但保持当前的 y 坐标(第 20–23 行)。
在线 25 处,我们将物体转向面对我们想要到达的新位置,如果物体与目标之间的距离大于我们设定的精度值(线 26 ,我们使用Translate
方法以我们定义的速度将物体移向目标(线 28 )。
保存代码并按下播放按钮进行测试!现在,当您单击 3D 平面上的某个点时,立方体将一点一点地(取决于您设置的速度)向您单击的点移动。
在这里,你刚刚创建了你的第一个 NPC 走向一个点!
我们想更进一步,让我们的立方体逐渐向目标旋转,就像我们在实际运动中所做的那样。这个概念被称为转向,它在游戏中非常常用来模拟物体的自然旋转。让我们看看它是如何工作的!
转向行为
转向行为对于几乎每一种游戏都是非常重要的,尤其是对于像汽车游戏这样的模拟游戏,它们通常使用线性插值的概念来实现。不用深入数学,两点A
和B
之间的线性插值计算出从点A
到点B
的点。这个概念可以应用于从 A 点到 B 点的空间遍历,以及将对象从角度旋转到角度β。
有两种非常流行的技术来实现线性插值以旋转对象:
-
线性插值
-
球形线性插值
两者的主要区别在于,Lerp 使用恒定速度移动对象,而 Slerp 使用可变速度来移动对象。这个变速基本上就是物体开始移动后逐渐加速,然后在接近目标时逐渐减速的效果。
在 Unity 中,我们可以使用 Slerp 和Quaternion.Slerp
方法,如下所示:
Quaternion.Slerp(startingRotation, goalRotation, rotationSpeed);
该函数返回以速度rotationSpeed
从startingRotation
转到goalRotation
所需旋转的一小部分。我们的startingRotation
将是立方体当前旋转角度的值,而goalRotation
必须使用静态类Quaternion
中的方法LookRotation
来计算。例如,如果我们想计算旋转角度以转向我们的目标位置的方向,我们会这样做:
Vector3 direction = goal - this.transform.position;
goalRotation = Quaternion.LookRotation(direction);
这看起来很简单,是吗?让我们在我们的脚本中使用它!
打开Move.cs
让我们做一些修改!首先声明一个名为rotSpeed
的float
变量来定义物体的旋转速度,就在goal
、speed
和accuracy
的声明下:
float rotSpeed = 2f;
然后,删除LookAt
行,用这两行替换:
Vector3 direction = goal - this.transform.position;
this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime*rotSpeed);
这里,我们声明了一个direction
向量,它告诉我们目标相对于立方体当前位置的距离和方向;然后我们使用这个信息通过LookRotation
方法计算旋转角度,我们将这个信息连同当前立方体的旋转值和旋转速度乘以时间增量一起传递给Slerp
方法。
该脚本现在应该如下所示:
1\. using UnityEngine;
2\. using System.Collections;
3.
4\. public class Move : MonoBehaviour
5\. {
6\. Vector3 goal;
7\. float speed = 1.0f;
8\. float accuracy = 0.5f;
9\. float rotSpeed = 2f;
10.
11\. void Start()
12\. {
13\. goal = this.transform.position;
14\. }
15.
16\. void Update()
17\. {
18\. RaycastHit hit;
19\. Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
20.
21\. if(Physics.Raycast(ray, out hit) && Input.GetMouseButtonDown(0))
22\. {
23\. goal = new Vector3(hit.point.x, this.transform.position.y, hit.point.z);
24\. }
25.
26\. Vector3 direction = goal - this.transform.position;
27.
28\. if (Vector3.Distance(transform.position, goal) > accuracy)
29\. {
30\. this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * rotSpeed);
31\. this.transform.Translate(0,0, speed*Time.deltaTime);
32\. }
33\. }
34\. }
再一次,让我们保存脚本并按播放!你会看到现在立方体在前进的同时会逐渐向目标点旋转。
干得好!您刚刚创建了第一个也是最基本的算法,将一个对象从点A
移动到点B
!这是接下来的重要基础:寻路!
在下一章中,我们将学习寻路的基础,让我们的小立方体找到通往目标的路,即使有障碍和没有明显的路径。
2.3 考考你的知识!
-
什么是 2D 空间?
-
2D 空间中的点是如何定义的?
-
什么是 3D 空间?
-
3D 空间中的点是如何定义的?
-
什么是向量?
-
2D 和 3D 矢量有什么区别?
-
向量在电子游戏中有哪些可能的应用?
-
向量和是如何工作的?
-
向量减法是如何工作的?
-
什么是标量乘法?它是如何工作的?
-
什么是点积?它是如何工作的?你如何在电子游戏中使用它?
-
什么是几何平移?你怎么能在电子游戏中使用它呢?
-
解释转向行为的概念。为什么重要?
-
如何在 Unity 中实现转向行为?
-
分析我们刚刚为这一章的项目写的代码,找出所有我们使用(或者 Unity 可能在幕后使用)我们刚刚学到的向量运算的地方。
三、路径和路点
在上一章中,我们看到了如何创建一个 3D 对象,并使它在空间中向特定的点移动。如您所料,这些代码在复杂的环境中无法工作;例如,如果你把小立方体放入迷宫,它将永远无法到达目标坐标。这类问题属于寻路范畴,其重点是在图中寻找最优路径。寻路可以用来解决几乎所有的问题,只要它是一个图形问题。
在这一章中,我们将会看到我们如何使用称为图形和搜索算法的基本数学工具来解决寻路问题。我们将介绍理论和基本概念,然后我们将深入研究一些最有趣的 Unity 和 C# 技术的实现。
3.1 图表
一个图是一组用于表示概念间关系的节点(或顶点)和边。基本上,节点代表概念,而边代表连接这些概念的关系。这些关系可以是单向或双向的。当一个图由单向边组成时,称之为有向,而当它由双向边组成时,称之为无向(图 3-1 )。
图 3-1
无向图和有向图的比较
我们能想到的最简单的例子就是地图,节点代表地点,边代表街道。例如,在图 3-2 中,你可以看到英国主要火车路线的地图,其中节点是车站,边是连接车站的铁轨。
图 3-2 中地图的边缘代表动词 is-connected-to 。比如图 3-2 中伦敦和多佛的关系,可以表示为London
is-connected-to
Dover
。
图 3-2
英国主要铁路的地图是一张无向图
英国的火车路线图是一张无向图。事实上,这两个车站是双向连接的,这意味着你可以从伦敦到多佛,再从多佛回到伦敦。
道路和铁路不是唯一可以用图表建模的东西。例如,家谱是一个有向图,描述了同一家庭成员之间的父母关系。在这种情况下,边所代表的概念是generated
。在图 3-3 中,你可以看到我家谱的一部分作为例子。连接我和我父母的边可以表示为Dad
generated
Me
和Mom
generated
Me
。有向图和无向图的区别在于连接是单向的,所以不能以相反的顺序遍历。
图 3-3
家谱是一个有向图
你必须记住的最重要的概念是,每个被建模为图的问题都可以通过在图中找到一条路径来解决。
比如我们再挑图 3-2 (英国铁路)。假设你在爱丁堡有一个朋友,你刚到伦敦。你想去看你的朋友,所以你决定坐火车。从伦敦到爱丁堡你应该走哪条路?为了回答你的问题,你看一下地图,发现有许多路径可以选择,最短的路径是前往约克,然后是纽卡斯尔,最后是爱丁堡。原来你在这里!你在图中找到了一条路径,你解决了问题!但是我们怎样才能教会 NPC 人做同样的事情呢?
现在我们知道了什么是图,让我们看看如何用它来表示地图,然后我们会发现如何编写一个 NPC 来智能地遍历地图以到达一个任意选择的点。
3.2 路点
在空间中表示地点和坐标的传统方式是使用路点。路点是旅行路线上标记特定位置的点。可能是标注重要的地标,主要路线的变化等等。它们被所有的导航系统使用,从制图到基于 GPS 的导航系统,甚至是最简单的地图,比如藏宝图(图 3-4 )。
将图形搜索算法应用到航路点系统非常容易,因为它们实际上是图形!这就是为什么我们的寻路算法的第一个实现将基于航路点系统。这不是目前在游戏中最常用的创建地图的方式,但它是一个非常有趣的起点,因为它可以帮助你更好地理解更复杂或现代的技术是如何工作的,以及图形和搜索算法是如何工作的。
图 3-4
航路点广泛用于制图,甚至是非常简单的地图
简单的路径
先说简单的吧!我们将建立一条由路点组成的路径,并指导我们的代理走过这条路径。
创建一个新的 3D 项目,在默认场景中,右键单击层次,选择创建➤ 3D 对象➤平面,将平面定位在坐标 X = 10,Y= 0,Z = 0,所有轴的比例值为 10,如图 3-5 所示。
图 3-5
平面的坐标值和比例值
现在我们需要创建更多的对象。首先,我们将创建代表代理的对象。因此,再次右键单击层次窗口,选择创建➤三维物体➤立方体,并将其重命名为代理,并将其放置在平面的任何地方;唯一重要的是,它正好放在平面上,所以看起来像是在平面上运动。您可以使用图 3-6 中的设置作为参考。
图 3-6
代理的坐标值和比例值
现在,我们需要创建实际的路点来标记我们的代理将遵循的路径。
我们将使用简单的 3D 球体制作航路点,因此让我们通过右键单击层次并选择 3D 对象➤球体来创建一些 3D 球体。按照你喜欢的顺序放置球体。比如图 3-7 ,你可以看到我把它们放在了一个圆圈里。
图 3-7
航路点正在创建一个圆形路径
现在我们已经设置好了,我们只需要创建代码来让代理走路标路径。
计划是创建一个包含所有路点的数组,并遍历这些路点,以允许代理按照它们在数组中的排序顺序一个接一个地走向它们。一旦代理到达最后一个航路点,它将从第一个航路点重新开始。我们将使用我们在第二章中学到的知识来实现代理的动作。
创建一个新的 C# 脚本(右键单击“资源”面板➤创建➤ C# 脚本),将其重命名为 WalkWP.cs ,并将其分配给代理对象(立方体);你可以通过简单地将脚本拖放到游戏对象上来实现。和往常一样,我会给你看代码,然后我会解释每一行和整体逻辑。
1\. using System.Collections;
2\. using System.Collections.Generic;
3\. using UnityEngine;
4.
5\. public class WalkWP : MonoBehaviour
6\. {
7\. public GameObject[] path;
8\. private Vector3 goal;
9\. private float speed = 4.0f;
10\. private float accuracy = 0.5f;
11\. private float rotSpeed = 4f;
12\. private int curNode = 0;
13.
14\. void Update()
15\. {
16\. goal = new Vector3(path[curNode].transform.position.x,
17\. this.transform.position.y, path[curNode].transform.position.z);
18\. Vector3 direction = goal - this.transform.position;
19.
20\. if (direction.magnitude > accuracy)
21\. {
22\. this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * rotSpeed);
23\. this.transform.Translate(0, 0, speed * Time.deltaTime);
24\. }
25\. else
26\. {
27\. if (curNode < path.Length - 1)
28\. {
29\. curNode++;
30\. }
31\. else
32\. {
33\. curNode = 0;
34\. }
35\. }
36\. }
37\. }
在第 7 行,我们声明一个数组来保存路点,这样我们就可以遍历它们。将它声明为公共变量将允许我们从检查器中填充它。
在第 9 行,我们声明了包含代理需要达到的当前目标的变量。每次代理到达当前目标时,该目标将随路径数组中的下一个航路点更新。
在行 10–12处,我们声明并设置与代理的移动相关的值,如它的移动速度(第 10 行)、它的旋转速度(第 12 行)和精确度(第 11 行),精确度是代理从目标停止的距离。
在第 13 行,我们声明 curNode,它将保存数组路径中的索引,该索引指向我们认为是代理当前目标的当前路点。
在更新函数中,有我们需要利用我们的路点路径的所有逻辑。
在第 17 行,我们用我们从路径数组中选取的当前航路点更新目标,而在第 18 行,我们设置代理的方向指向新目标。
在第 21 行,我们根据精度值检查代理是否足够接近目标,如果不是,我们旋转代理面向目标(第 23 行),然后我们将它向前移动速度值(第 24 行)。
从行 21 到行 36 ,有逻辑更新指向路径数组中当前航路点的数组索引。如果代理足够接近目标,我们希望更新索引,因此在下一次迭代中,我们可以用下一个航路点更新目标。因此,正如我们所说的,我们检查索引 curNode 的值是否仍然在数组的边界内(第 28 行),如果是,我们将它加 1(第 30 行),这样在下一次迭代中,我们可以将目标设置为路径中的下一个路点;否则,我们将索引设置为零,这样我们可以从数组中的第一个路点重新开始。
现在回到 Unity 编辑器,从层次结构中选择对象代理,在检查器中,你会看到我们刚刚在脚本文件中创建的路径数组,如图 3-8 所示。
图 3-8
路径数组为空(大小= 0)
要填充数组,您需要将路点游戏对象(球体)拖放到检查器中的数组名称上。为此,您必须选择对象代理并锁定其检查器页面,这样当您选择另一个对象时,它就不会被替换。要锁定检查器页面,您可以点按“检查器”标签标题上的小挂锁。一旦点击,挂锁图标将变成关闭的挂锁(图 3-9 )。您可以通过再次点按挂锁来再次解锁。
图 3-9
挂锁是关闭的;这意味着当选择另一个对象时,当前的检查器页面不会被替换
锁定检查器页面后,从层级或 3D 场景窗口中选择所有的路点对象(球体),拖动它们,并将其放在检查器中的路径数组上,以用它们填充路径数组(图 3-10 )。
图 3-10
路径数组现在填充了所有的路点
现在一切都设置好了,我们只需要测试它。保存并运行游戏!你会看到代理人走来走去,一个接一个地跟随所有的路点。现在所有的逻辑都正常工作了,您可以重新安排路点的位置,为代理创建不同的路径。
迷宫!
我们看到了如何创建一个路点系统来构建一条路径,以便代理可以沿着这些点移动。我们现在将进行下一步,创建一些更复杂的东西。我们将使用棋盘游戏的原理来表示 3D 空间:在一个网格中划分 3D 空间,每个方格都是一个航点。就像在跳棋这样的棋盘游戏中一样(图 3-11 ,我们的代理只能在这些路点/方格之间移动。瓷砖可以是可行走的或不可行走的,这取决于我们的游戏规则;我们的目标是教代理如何在迷宫中移动并到达目标——这将是我们指定的瓷砖。
图 3-11
跳棋板。棋盘游戏中的棋盘可以很容易地用图形来表示
Note
使用网格并不是创建航点系统的唯一方法,也不是最好的方法。这只是其中一种方法,我个人认为这是最简单和最直接的方法之一,对于学习目的来说至关重要。用航点系统来表现你的世界的最佳方式取决于你在游戏或应用中需要做什么。软件工程不是关于绝对的答案或背诵公式,而是关于使用科学原理来设计和实现问题的适当解决方案。
在代码中,这将被表示为一个 2D 矩阵,其中每个元素都是一个路点。我们将通过实现一个名为Node
的自定义类来存储与每个路点相关的信息(记住路点代表与节点相同的概念)。
让我们从创建一个新的 3D 项目开始,在默认场景中,让我们修改Main Camera
对象的视点。我们希望它直接面对地板,这样我们就可以从顶部看到场景。为此,从Hierarchy
窗口选择Main Camera
,并将其在Inspector
中的Position
更改为X = 15, Y = 35, Z = 15
,将其Rotation
更改为X = 90, Y = 0, Z = 0
。
通过右击Hierarchy
窗口并选择3D Object
➤ Plane.
,创建一个 3D 项目并在场景中创建新的 3D 平面
选择新创建的飞机,在检查器中将其Position
更改为X = 10, Y = 0, Z = 0
,将其Scale
更改为X = 10, Y = 10, Z = 10
,如图 3-12 所示。这只是一个静态的空白平面,作为地图的基础。
图 3-12
在检查器中看到的平面对象的属性
现在,通过右键单击层次窗口并选择3D Object
➤ Cube
,为代理创建一个新对象。将这个立方体称为“代理”,并将其位置更改为X = 0, Y = 0, Z = 0
(图 3-13 )。
图 3-13
检查器中显示的对象代理的属性
最后,我们需要创建一个对象,用图形表示 3D 世界中的一个路点。当然,只要我们有这些点的坐标,就不需要视觉表现,但是视觉化屏幕上发生的事情会让我们更容易理解正在发生的事情。所以创建一个新的平面,右击层次窗口,选择3D Object
➤ Plane
,并将其命名为航路点。然后,如图 3-14 所示,将其Position property
改为X = 0, Y = 0, Z = 0
,将其Scale
值改为X = 0.4, Y = 1, and Z = 0.4
。
图 3-14
在检查器中看到的航路点对象的属性
现在拖动Waypoint
对象并放到Assets
窗口中。该操作会将对象变成一个prefab
,这是一个可重复使用的对象。我们将需要它作为一个预置来为地图生成所有的路点。你现在可以从Hierarchy
窗口中删除该对象,因为它现在已经在Assets
窗口中了。
我们还需要不同的材料来应用到路点上,将它们标记为可行走或不可行走,并标记出最终目标点。因此,通过右击Assets
窗口并选择Create
➤ Material
并在Inspector
窗口中更改它们的颜色值,创建三种不同颜色的三个Materials
。称它们为GoalMat
、PointMat
、WallMat
。waypoint
的默认材质应该是PointMat
,所以将其拖放到对象Waypoint
上;该操作将把材料应用到prefab
。
好了,现在场景和物体都设置好了,我们可以专注于代码了。右键单击Assets
窗口,选择Create
➤ C# Script,
调用新脚本GridWP.cs
,双击它在你喜欢的编辑器中打开。
正如我们所说的,我们需要一个Node
类来表示路点,我们可以在这里创建它,在GridWP.cs
里面,所以让我们开始吧!
1\. public class GridWP : MonoBehaviour
2\. {
3.
4\. public class Node
5\. {
6\. private int depth;
7\. private bool walkable;
8.
9\. private GameObject waypoint = new GameObject();
10\. private List<Node> neighbors = new List<Node>();
11.
12\. public int Depth { get => depth; set => depth = value; }
13\. public bool Walkable { get => walkable; set => walkable = value; }
14.
15\. public GameObject Waypoint { get => waypoint; set => waypoint = value; }
16\. public List<Node> Neighbors { get => neighbors; set => neighbors = value; }
17.
18\. public Node()
19\. {
20\. this.depth = -1;
21\. this.walkable = true;
22\. }
23.
24\. public Node(bool walkable)
25\. {
26\. this.depth = -1;
27\. this.walkable = walkable;
28\. }
29.
30\. public override bool Equals(System.Object obj)
31\. {
32\. if (obj == null) return false;
33\. Node n = obj as Node;
34\. if ((System.Object)n == null)
35\. {
36\. return false;
37\. }
38\. if (this.waypoint.transform.position.x == n.Waypoint.transform.position.x &&
39\. this.waypoint.transform.position.z == n.Waypoint.transform.position.z)
40\. {
41\. return true;
42\. }
43\. return false;
44\. }
45\. }
46\. }
在第 6 行,我们声明了一个变量,它表示当前节点相对于开始节点在图中位置的深度。我们将在图搜索算法的实现中使用这些信息来重建最短路径。
在第 7 行的处,我们声明了一个 walkable 变量,它将告诉我们这个节点是否是可行走的。
在的第 9 行,我们声明了一个GameObject
变量,该变量将用于存储我们在Unity Editor
中创建的Waypoint prefab
的实例。对GameObject
的引用将允许我们轻松地做一些事情,比如根据材料的特性在路点上应用材料,并获得 3D 空间中的坐标。
在的第 10 行,我们声明了一个Nodes
的List
,它将包含对当前节点的邻居的所有节点的引用。对于单词邻居,我指的是直接连接到当前节点的任何节点。在这种情况下,节点代表网格中的瓦片,如果它们在网格中在四个基本方向之一上相邻,则它们是连接的:上、下、左、右;我们不考虑对角线运动。
第 12–16 行只是在第 4–8 行中定义的类属性的获取器和设置器。
在第 18–22 行中,我们定义了基本的类构造函数方法,该方法采用零参数并用预定义的值设置属性。我们默认将depth
设置为负值(第 21 行),因为这是一种我们在运行算法时无法获得的值,因为目标节点和起始节点之间的距离只能是正值。我们还将walkable
属性设置为 true,因为我们希望新节点在默认情况下是可行走的(第 22 行)。
在第 24–28 行,我们定义了另一个构造函数来快速地将一个节点在创建过程中设置为不可行走。这个构造函数与前一个构造函数之间唯一不同的是,它使用一个布尔参数(第 26 行)来初始化walkable
属性(第 29 行)。
在第 30–44 行,我们覆盖了节点类的 Equal 方法,以定义我们自己比较节点的方式。事实上,Equal 是用来比较两个对象的方法,这两个对象是同一个类的实例;通过覆盖它,我们可以重新定义它们应该被比较的方式。在这种情况下,我们说如果两个节点包含相同的 X 和 Z 坐标,则它们是等价的。我们将在搜索算法中使用这种方法来检查我们是否到达了目标节点。
现在我们有了Node
类,我们需要更多的变量来声明为GridWP
的属性。让我们列出它们并快速描述它们:
1\. public Node[,] grid;
2\. List<Node> path = new List<Node>();
3\. int curNode = 0;
4.
5\. public GameObject prefabWaypoint;
6\. public Material goalMat;
7\. public Material wallMat;
8.
9\. Vector3 goal;
10\. float speed = 4.0f;
11\. float accuracy = 0.5f;
12\. float rotSpeed = 4f;
13.
14\. int spacing = 5;
15.
16\. Node startNode;
17\. Node endNode;
在第 1 行,我们定义了一个矩阵,用于存储我们所有的路点。这个矩阵将空间表示为棋盘游戏中的平铺棋盘,正如我们在上一节中所说的。
在第 2 行,我们声明了一个节点列表,我们将使用它来表示代理到达列表中每个路点/节点所走的最终路径。将使用第 3 行定义的计数器遍历列表。
在的第 5–7 行,我们声明了公共字段,这些字段将包含我们的Waypoint prefab
以及代表目标和不可行走节点的不同材料,而在的第 14 行,我们定义了一个整数变量,我们将使用它作为偏移量,在 3D 场景中的点之间放置一些空间。对于每个节点,我们将创建一个Waypoint prefab
的实例,以便可以在 3D 空间中直观地表示该节点。
在第 9–12 行,我们声明了一些我们在上一节已经看到的变量。这些变量是目标、速度、准确性和旋转速度,它们将有助于实现代理在 3D 空间中的实际移动。代理如何向目标点移动的原理是相同的,但逻辑会有点不同,因为我们有一个构成路径的点的列表,而不仅仅是单个节点。第一次将目标设置为路径列表的第一个节点,当代理到达该节点时,它将更改为列表中的下一个节点。
最后,在第行第 16 行和第行第 17 行,我们定义了两个容器,我们将在其中放置对起始和最终节点的引用。
好了,我们声明完变量了;现在我们可以通过实现一些方法来关注实际的功能。
正如我们所说的,对于每个节点,我们都需要它的相邻节点的列表,并将其存储在 neighbors 属性中。所以我们写一个方法来计算这个列表。我们假设我们将收到一个对包含所有节点的矩阵的引用,以及我们想要计算其相邻节点的当前节点的坐标。
仅考虑四个基本方向来计算相邻节点:上、下、左、右–这意味着我们最多有四个相邻节点。
为了以我刚才解释的方式获得 2D 矩阵中的相邻节点,我们需要将行号和列号加 1 或减 1。例如,给定一个名为 M 的矩阵,我们可以得到一个元素在坐标 Mr,c 处的邻居,如下所示:
Up: M[r-1, c]
Right: M[r, c+1]
Down: M[r+1, c]
Left: M[r, c-1]
现在我们有了一个策略,让我们编写这个方法的代码。这将放在 GridWP 类内部,但在 Node 类外部:
1\. List<Node> getAdjacentNodes(Node[,] m, int i, int j)
2\. {
3\. List<Node> l = new List<Node>();
4.
5\. // node up
6\. if (i-1 >= 0)
7\. if (m[i-1, j].Walkable)
8\. {
9\. l.Add(m[i - 1, j]);
10\. }
11.
12\. // node down
13\. if (i+1 < m.GetLength(0))
14\. if (m[i + 1, j].Walkable)
15\. {
16\. l.Add(m[i + 1, j]);
17\. }
18.
19\. // node left
20\. if (j-1 >= 0)
21\. if (m[i, j - 1].Walkable)
22\. {
23\. l.Add(m[i, j-1]);
24\. }
25.
26\. // node right
27\. if (j+1 < m.GetLength(1))
28\. if (m[i, j + 1].Walkable)
29\. {
30\. l.Add(m[i, j+1]);
31\. }
32.
33\. return l;
34\. }
在签名中,我们声明该方法返回一个表示相邻节点列表的节点列表,并接受三个参数:标记为 m 的矩阵和我们要计算其相邻节点的矩阵中的坐标,标记为 I 和 j(第 1 行)。
在第 3 行,我们开始创建一个临时列表来包含邻居,我们称之为l
。
在挑选节点之前,我们需要做的第一件事是检查邻居节点的索引是否超出了矩阵的范围。事实上,例如,我们不能向矩阵请求元素M[-1][c]
,因为矩阵的索引只能是正值,所以我们需要在访问矩阵之前检查索引是否有效,我们在行第 6 、 13 、 20 和 27 中完成。
在检查索引是否有效之后,我们检查当前节点是否被标记为可行走,因为如果它不可行走,这意味着它没有连接到当前节点——事实上,正如我们前面所说,图中的节点只有在它们相关时才是连接的,在这种情况下,关系意味着两个节点之间有一条路径,所以如果两个节点中的一个不可行走,就不可能有路径;因此,它不是邻居。我们检查所有四个方向的行 7 、 14 、 21 和 28 (如果之前的检查成功)。
最后,如果两次检查都成功,我们将节点添加到列表中的第 9 、 16 、 23 和 30 行,并在第 33 行返回列表。
既然与节点表示相关的所有属性和功能都已完成,我们需要初始化包含我们的路点的矩阵。我们将在Start
方法中这样做。我们需要做的是初始化包含所有节点的矩阵,然后循环进入矩阵来初始化节点内部的GameObject
,并计算每个节点的相邻节点列表。最后,我们将其中一个节点设置为目标,并将代理定位到起点。那么让我们把我们的Start
方法写出来:
1\. void Start()
2\. {
3\. // create grid
4\. grid = new Node[,] {
5\. { new Node(), new Node(), new Node(false), new Node(), new Node(), new Node() },
6\. { new Node(), new Node(false), new Node(), new Node(), new Node(), new Node() },
7\. { new Node(), new Node(false), new Node(), new Node(), new Node(), new Node() },
8\. { new Node(), new Node(), new Node(), new Node(false), new Node(), new Node() },
9\. { new Node(), new Node(), new Node(), new Node(), new Node(false), new Node() },
10\. { new Node(), new Node(), new Node(false), new Node(), new Node(false), new Node() },
11\. { new Node(), new Node(false), new Node(false), new Node(), new Node(), new Node() }
12\. };
13.
14\. // initialize grid points
15\. for (int i = 0; i < grid.GetLength(0); i++)
16\. {
17\. for (int j = 0; j < grid.GetLength(1); j++)
18\. {
19\. grid[i, j].Waypoint = Instantiate(prefabWaypoint, new Vector3(i * spacing, this.transform.position.y, j * spacing), Quaternion.identity);
20.
21\. if (!grid[i, j].Walkable)
22\. {
23\. grid[i, j].Waypoint.GetComponent<Renderer>().material = wallMat;
24\. }
25\. else
26\. {
27\. grid[i, j].Neighbors = getAdjacentNodes(grid, i, j);
28\. }
29\. }
30\. }
31.
32\. startNode = grid[0, 0];
33\. endNode = grid[6, 5];
34\. startNode.Walkable = true;
35\. endNode.Walkable = true;
36\. endNode.Waypoint.GetComponent<Renderer>().material = goalMat;
37.
38\. this.transform.position = new Vector3(startNode.Waypoint.transform.position.x, this.transform.position.y, startNode.Waypoint.transform.position.z);
39\. }
在第 4–12 行,我们用任意数量的节点初始化一个叫做 grid 的矩阵。在这个阶段,我们通过使用节点类的初始化器来决定哪些是可行走的,哪些是不可行走的。
在的第 15–30 行中,我们循环遍历矩阵,在这样做的时候,我们创建了一个prefabWaypoint
的实例,并将其与当前节点相关联,该节点存储在矩阵中的当前坐标处(第 19 行)。
在的第 21–28 行,我们检查节点是否是可行走的:如果是,我们计算相邻节点并将它们存储在邻居列表中(第 27 行);如果不是,我们将wallMat
材质设置为我们刚刚关联到节点的prefabWaypoint
(第 23 行)。
一旦我们初始化了网格和所有的节点,我们在行 32–33设置开始和结束节点;我们确保在第 34–36 行处两者都是可行走的,并且我们将goalMat
材质应用到endNode
的关联prefabWaypoint
实例。
最后,我们在线 38 处设置代理的位置为任意起点。
现在网格已经设置好了,我们可以看看它是什么样子的。回到Unity Editor
,将GridWP
脚本拖放到Hierarchy
窗口中的Agent
上。
现在通过在Hierarchy
窗口中左键单击选择Agent
,您将看到附加的脚本和我们刚刚创建的公共字段:Prefab Waypoint
、Goal Mat
和Wall Mat
。从Assets
窗口中拖动Waypoint prefab
并将其放在Inspector
中GridWP
脚本的Prefab Point
字段上。然后拖动GoalMat
物料并将其放到Goal Mat
区域。最后,拖动WallMat
物料并将其放到Wall Mat
区域。
现在脚本已经设置好,您可以按下Play
按钮了!我们刚刚在代码中创建的网格将被渲染到屏幕上,可行走的、不可行走的和目标瓷砖用正确的材料高亮显示,如图 3-15 所示。
图 3-15
在 3D 场景中以物理方式表示的网格,不同的材料标记了路点/图块的不同特征
为了结束脚本的一般设置,并开始讨论寻路算法,我们只需要添加一些功能,让代理四处走动并一个接一个地向路径列表中的节点移动。这部分代码将非常类似于我们在上一节中为更简单的路点示例所做的代码。
我们可以在 Update 方法中实现这个功能,因为我们希望在游戏中不断地做出这些决定。因此,像往常一样,让我们看看代码并解释它的作用:
1\. void LateUpdate()
2\. {
3\. // calculate the shortest path when the return key is pressed
4\. if (Input.GetKeyDown(KeyCode.Return))
5\. {
6\. this.transform.position = new Vector3(startNode.Waypoint.transform.position.x, this.transform.position.y, startNode.Waypoint.transform.position.z);
7\. curNode = 0;
8\. path.add(grid[0,1]);
9\. path.add(endNode);
10\. }
11.
12\. // if there's no path, do nothing
13\. if (path.Count == 0) return;
14.
15\. // set the goal position
16\. goal = new Vector3(path[curNode].Waypoint.transform.position.x, this.transform.position.y, path[curNode].Waypoint.transform.position.z);
17.
18\. // set the direction
19\. Vector3 direction = goal - this.transform.position;
20.
21\. // move toward the goal or increase the counter to set another goal in the next iteration
22\. if (direction.magnitude > accuracy)
23\. {
24\. this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * rotSpeed);
25\. this.transform.Translate(0, 0, speed * Time.deltaTime);
26\. }
27\. else
28\. {
29\. if (curNode < path.Count - 1)
30\. {
31\. curNode++;
32\. }
33\. }
34\. }
当用户按下回车键(第 4 行)时,代码将代理的位置重置为网格的起始位置(第 6 行),将 curNode 变量重置为 0(第 7 行),并将几个任意选择的节点添加到 path 变量(第 8–9 行)。这只是为了测试,看看一切都工作正常,我们可以开始实现一个算法,以节点列表的形式返回一个路径,并确保它将被代理正确解释。
在第 13 行,我们检查路径是否为空,如果是,我们什么都不做,只是从方法返回。如果路径不为空,我们将第一个目标设置为列表中的第一个节点(第 16 行),并且像我们在上一章中所做的那样,我们设置方向(第 19 行),然后如果代理还没有到达目标,我们将它移向它(第 22–26 行);否则,我们增加 curNode 计数器,这样在下一次迭代中,目标将被设置为列表中的下一个节点(第 27–33 行)。
按下Play
按钮保存脚本并运行游戏。网格将像往常一样创建,只要您按下 enter 按钮,代理将开始向grid[0,1]
中的点移动,然后向endNode
移动。
好了,我们有了我们的航路点系统!现在我们可以开始讨论算法了;我们先从一个最著名也是最重要的算法说起:广度优先搜索。
3.3 广度优先搜索
广度优先搜索 (BFS)是最重要的图搜索算法之一,许多更复杂和广泛使用的算法都建立在它的基础上。
如果存在路径,BFS 保证总能找到连接图中两个给定节点的最短路径。听起来是一个非常有用的算法,对吗?让我们用一个例子来看看它是如何工作的!
假设你想组建一个摇滚乐队,你需要一个会打鼓的人。你会问你的朋友是否有人会打鼓,如果他们都不会,你可能会让他们告诉你他们是否认识会打鼓的人。你的朋友可能也会这样做:他们会问他们的朋友,然后让他们也检查他们的朋友。这个问题我们绝对可以用图来建模!
图 3-16
一群朋友也是一幅图!
在图 3-16 中,你可以看到一群朋友被建模成一个图,其边代表动词是-朋友-与。为了方便起见,你不喜欢太深入那个网络,因为与一个完全陌生的人相比,与你和你信任的人更亲近的人一起玩更好。所以如果你想在那个网络里找到一个打鼓的人,你需要一个一个的查你所有的朋友(一级人脉),看他们有没有打鼓;如果他们没有,你再深入一层,一个一个查他们的朋友;如果他们的朋友都不打鼓,你再深入一点,查查你朋友的朋友;等等。如果你能成功找到,你最终会和那个打鼓的人联系上。
如果我们将这个朋友网络表示为一个图,那么你和打鼓的人之间的连接就是一个连接图中两个节点的路径,如果最后一个节点(鼓手)也是你在那个网络中可以拥有的最亲密的朋友,那么这个路径也是最短的一个(图 3-17 )。
图 3-17
我们可以通过将 BFS 应用于一群朋友来解决寻找鼓手的问题
通过为你的乐队寻找鼓手,你应用了广度优先搜索(BFS)。在 BFS,你从一个特定的节点开始,第一件事是检查它不是你正在寻找的节点,最后你检查它所有的相邻节点。如果在相邻节点中没有找到您要查找的内容,则检查您刚刚检查的节点的所有相邻节点...诸如此类。要重新创建连接起点和目标点的路径,您只需从目标节点往回走,并寻找邻居来重建最短路径。请记住,我们向节点添加了一个depth
属性,该属性将被设置为一个值,该值表示我们发现该节点从起始位置遍历图形的深度。我们将使用该值来决定哪一个邻居是最短路径的一部分。
让我们从编写方法的签名开始:
List<Node> BFS(Node start, Node end)
BFS 方法将起始节点和结束节点作为参数,并返回一个节点列表,该列表是连接起始节点和目标节点的最终路径。
为了实现 BFS,我们需要跟踪我们想要访问的所有节点和我们已经访问过的所有节点,这样我们就不会检查一个节点两次。所以我们需要两种数据结构。
由于 BFS 首先检查所有邻居,然后深入检查邻居的所有邻居,所以最后添加到要访问的节点集合中的节点必须最后访问。这意味着对于我们需要访问的节点集合,有一个FIFO
(先入先出)数据结构更方便。我们将用一个队列来实现这一点。
对于访问过的节点的集合,我们可以使用任何需要一个常量或线性时间来检查是否包含特定节点的集合。事实上,我们只需要这个集合来检查我们是否已经访问了一个节点,以确保我们没有检查两次。因为那些必需品,我决定带着一张清单去。
这些是我们在实现时必须考虑的唯一事项,因为我们已经讨论过算法的逻辑,所以让我们来看看 BFS 的完整代码。
1\. List<Node> BFS(Node start, Node end)
2\. {
3\. Queue<Node> toVisit = new Queue<Node>();
4\. List<Node> visited = new List<Node>();
5.
6\. Node currentNode = start;
7\. currentNode.Depth = 0;
8\. toVisit.Enqueue(currentNode);
9.
10\. List<Node> finalPath = new List<Node>();
11.
12\. while(toVisit.Count > 0)
13\. {
14\. currentNode = toVisit.Dequeue();
15.
16\. if (visited.Contains(currentNode))
17\. continue;
18.
19\. visited.Add(currentNode);
20.
21\. if (currentNode.Equals(end))
22\. {
23\. while (currentNode.Depth != 0)
24\. {
25\. foreach(Node n in currentNode.Neighbors)
26\. {
27\. if (n.Depth == currentNode.Depth-1)
28\. {
29\. finalPath.Add(currentNode);
30\. currentNode = n;
31\. break;
32\. }
33\. }
34\. }
35\. finalPath.Reverse();
36\. break;
37\. }
38\. else
39\. {
40\. foreach (Node n in currentNode.Neighbors)
41\. {
42\. if (!visited.Contains(n) && n.Walkable)
43\. {
44\. n.Depth = currentNode.Depth+1;
45\. toVisit.Enqueue(n);
46\. }
47\. }
48\. }
49\. }
50\. return finalPath;
51\. }
在第 3 行和第 4 行,我们声明了我们在上一段谈到的两个数据结构:toVisit 和 visited。前者是所有仍待访问节点的集合,而后者是所有已访问节点的集合。
在的第 6–8 行,我们将起始节点存储在一个临时变量中,我们将使用该变量作为要访问的当前节点的引用(第 7 行),然后我们在第 8 行将其depth
值设置为 0(因为起始节点具有最低的depth
值)。然后,我们将起始节点放入 toVisit 队列,这样我们就可以开始搜索了。
在第 10 行,我们初始化路径列表,该列表最终应该包含连接起始节点和目标的最短路径。
第 12–48 行包含 BFS 的主循环;让我们仔细看看!
在主循环中,我们将下一个要访问的节点分配给currentNode
变量,将其从toVisit
队列中取出(第 14 行),然后我们检查这个节点是否已经被访问过(第 16–17 行);如果不是,我们通过将它添加到已访问列表中来开始访问(第 19 行)。
在第 21 行,我们检查当前节点是否也是目标节点。
如果当前节点不是目标节点(第 39–49 行),我们循环遍历它的所有相邻节点,如果它们没有被访问并且是可行走的,我们将它们添加到要访问的节点队列中。
如果当前节点是实际的目标(第 21 行),我们开始一个循环,寻找返回到起始节点(第 23 行)的方法。对于从目标开始的每个节点,我们搜索它的所有相邻节点(第 25 行),寻找深度值等于currentNode.Depth-1
(第 27 行)的节点——这意味着它是最短路径中的前一步。当我们找到那个节点时,我们将当前节点放入最终路径,然后我们将刚刚找到的新节点设置为currentNode
的新值(第 30 行),我们通过调用一个break
(第 31 行)退出 foreach。我们对所有节点重复这个过程,直到我们到达起点(这意味着currentNode.Depth
将等于 0),然后我们反转路径并返回它(第 36–37 行)。
最后,如果没有连接起始节点和目标节点的路径,则算法只返回一个空列表。
现在 BFS 已经准备好了,我们只需要在 Update 方法内部使用它,所以我们只需要在捕获用户按下的 enter 键后生成路径时修改这一部分。让我们看看更新方法现在应该是什么样子:
1\. void LateUpdate()
2\. {
3\. // calculate the shortest path when the return key is pressed
4\. if (Input.GetKeyDown(KeyCode.Return))
5\. {
6\. this.transform.position = new Vector3(startNode.Waypoint.transform.position.x, this.transform.position.y, startNode.Waypoint.transform.position.z);
7\. curNode = 0;
8\. path = BFS(startNode, endNode);
9\. }
10.
11\. // if there's no path, do nothing
12\. if (path.Count == 0) return;
13.
14\. // set the goal position
15\. goal = new Vector3(path[curNode].Waypoint.transform.position.x,
16\. this.transform.position.y,
17\. path[curNode].Waypoint.transform.position.z);
18.
19\. // set the direction
20\. Vector3 direction = goal - this.transform.position;
21.
22\. // move toward the goal or increase the counter to set another goal in the next iteration
23\. if (direction.magnitude > accuracy)
24\. {
25\. this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * rotSpeed);
26\. this.transform.Translate(0, 0, speed * Time.deltaTime);
27\. }
28\. else
29\. {
30\. if (curNode < path.Count - 1)
31\. {
32\. curNode++;
33\. }
34\. }
35\. }
唯一改变的一行是行 8 ,它现在从BFS
算法生成路径。如果有一条连接起点和目标的路径,它将被存储为路径列表内部的节点列表,然后由Agent
遍历。如果没有连接这两个节点的路径,path
将为空,代理将保持不动(第 11 行)。
万事俱备,你终于可以测试你的第一个基于航路点的寻路算法了!让我们保存代码并按下Unity Editor
中的Play
。只要按下Enter
,Agent
就会开始一步一步的走过所有的路点,直到最终到达目标路点(图 3-18 )。
花点时间做实验,改变网格的大小和形状以及起点和目标的位置,并享受您的第一个智能代理能够在迷宫中找到最短的路径!
图 3-18
期末项目!代理找到了通往目标的最短路径!
在下一章,我们将探索另一种在视频游戏中广泛使用的解决寻路问题的技术。我们将介绍 NavMesh 的概念,谈一谈 A*,游戏界最流行的寻路算法。
3.4 练习
另一个非常重要和基本的算法是深度优先搜索(DFS)。在本节中,我将解释这个算法的理论,但是因为我们已经有了一个工作的航点系统,所以我将把 C# 实现留给您作为练习。
如果 BFS 搜索节点,同时查看所有可能的分支,DFS 算法会选择一条路并向下搜索到它的底部。如果没有找到目标,它返回并尝试另一个未访问的分支。
当你有一棵非常深的树时,DFS 会非常有用;在这种情况下,它能在 BFS 之前找到解决办法。对于非常宽而不是非常深的树,BFS 表现得更好,但主要区别是 BFS 总是找到最短的路径,而 DFS 只找到一条路径,不一定是最短的。
由于显而易见的原因,DFS 通常被实现为递归算法,但是根据树的大小和可用的资源,它也可以被实现为迭代算法。
尝试使用本章中创建的航路点系统实现 DFS!
四、导航
在第二章中,我们看到了如何对导航代理的基本特征进行编程,比如转向和向目标移动,在第三章中,我们学习了什么是寻路算法以及它是如何工作的,并且我们实现了一个使用路点系统来表示可行走区域的算法。
在这一章中,我们将使用我们在前面章节中学到的知识,同时探索解决导航问题的不同方法。我将在本章介绍的新方法在复杂的情况下更有效。它们是在视频游戏中的 3D 环境开始变得流行时引入的,并且它们现在仍然在游戏行业中广泛使用,因为这些年来它们成为解决 3D 环境中导航问题的事实上的标准。我所说的技术是最佳优先搜索算法和加权图,特别是 A*和导航网格。
4.1 加权图
我们在第三章中看到了广度优先搜索算法是如何工作的,通过展开同一级别的所有节点,然后展开较低级别的节点。这种平衡的探索过程在许多情况下工作得很好,但是它牺牲了内存中使用的空间(在算法找到目标之前,为存储被探索的节点而分配的内存可能变得非常大),更重要的是,在某些情况下,它可能会花费很多时间。
想象以下场景(图 4-1 ):你有一个名为 A 的根节点和三个名为 B、C 和 D 的子节点,D 是目标节点,它直接连接到 A,因此到达它的时间应该是最快的。不幸的是,如果我们使用广度优先搜索,我们只会在检查 A→B 和 A→C 之后找到路径 A→D,这是我们最初应该花费在找到 A→D 上的时间的三倍。如果 B、C 和 D 有孩子,这个问题会变得更糟,解决方案在 D 的分支中有两到三层。
图 4-1
一个非常简单的图表显示了 BFS 可能会失败的情况
浪费时间和资源来寻找到目标节点的路径的问题显然是人工智能研究中的一个重要和关键的问题,并且对于该问题仍然没有完美的解决方案,并且在具有非常大的图的复杂情况下,在合理的时间和资源量内寻找到目标的路径仍然是一个挑战。唯一真正有帮助的方法是在加权图中使用最佳优先搜索,使用一个好的启发式函数。这是什么意思?来看看吧!
加权图是这样的图,其中走到一个节点具有成本,并且该成本在不同的节点之间不同。连接两个节点的路径具有成本 X,其中 X 是连接这两个节点的所有节点的成本之和。在图 4-2 的例子中,连接 A 到 G 的路径 ABEG 的开销为 1+2+1 = 4。ABEG 也是最短路径;事实上,连接 A 和 G 的另一条路径是 ACEG,开销为 2+2+1 = 5。
图 4-2
加权图
在上一章的图中,所有节点的成本都是 1,所以路径的成本等于路径中节点的数量。
在未加权的图中,我们认为最短路径是具有较少节点的路径,而在加权的图中,我们更喜欢具有最小成本的路径——即使它包含更多节点。
加权图中的成本具有与该图是其数学模型的空间相关的语义。
为每个节点增加成本背后的逻辑与我们在日常生活中必须在需要不同努力的不同长度的不同方式之间做出决定的情况相同,例如在需要更多努力的较短方式和需要较少努力的较长方式之间做出决定。沿着一条路径前进所需的努力变成了一种附加成本,其总和等于路径本身的长度。考虑这个总和可以让我们理解一条路径的实际整体便利性。
图 4-3
与在舒适的路径上行走 500 米的成本相比,没有考虑攀登 50 米石墙的成本的寻路算法可能会做出错误的决定,判断两条路径中哪一条是最好的
例如,想象在一个山区,你想登上山顶欣赏风景(图 4-3 )。有两种方法可以到达那里:爬 50 米的岩壁或走 500 米长的小路。很有可能你会更喜欢走在这条路上,因为它需要更少的努力,使它成为到达那里的最佳和可能最短(和更安全)的方式,即使这条路本身实际上是它的十倍长。我们可以说,爬 1 米比走 1 米的成本高得多,这使得 50 米的攀登不那么吸引人(图 4-4 )。
图 4-4
增加遵循这两条路径所需的工作成本完全改变了这种情况
Unity 使用一个在游戏行业广受好评和使用的解决方案:导航网格来实现导航的加权图。让我们看看这是怎么回事!
4.2 导航网格
导航网格(或 NavMesh)是凸多边形的集合,用于在 3D 空间的表面上标记可行走的区域。与航路点非常相似,NavMeshes 在内部表示为图形,因此图形算法可以用于解决寻路问题。
虽然航路点是空间中非常精确的点,但导航网格是 3D 空间中区域的集合(凸多边形)。这种差异使 NavMesh 成为更平滑、更自然运动的更好解决方案。
使用一个非常用户友好的界面,你可以在 Unity 中烘焙一个 NavMesh 来为一个预定义的代理标记表面上的可行走区域。让我们看看这是如何工作的。
打开 Unity 并创建一个新的 3D 项目。在主场景中,创建一个游戏对象,并将其命名为关卡。这将是我们将要用原语创建的(非常简单的)级别的容器。
使用如图 4-5 所示的属性设置,从立方体中创建一个平台。这将是我们级别的基础。
图 4-5
将要代表楼层的平台的属性
现在让我们在那个表面上创建一些墙和障碍物。创建一堆立方体,塑造并移动它们,在我们的平台上做一些墙。一旦你完成了,你将会得到类似图 4-6 的东西(希望更好!).
你可以在关卡中添加任意数量的 3D 对象,并应用不同的材质来定义场景的不同部分,就像我在图 4-6 中所做的那样。当你完成制作后,只需将所有的对象拖入游戏对象关卡。
图 4-6
一个非常简单的地图的例子
现在选中关卡 GameObject,在检查器中点击静态设置,就在包含对象名称的文本框旁边,会弹出一个下拉菜单;从下拉菜单中选择设置导航静态。这将告诉 Unity 把游戏对象和它的所有子对象看作静态对象,是可导航的 3D 空间的一部分。这个设置的结果是,当一个 NavMesh 将被烘焙时,Unity 将考虑所有被标记为导航静态的 3D 对象,并基于我们还没有设置的预定义代理的特征来决定它们是否可以以任何方式行走或到达...让我们现在就做吧!
要打开导航面板,需要进入顶部的窗口菜单,选择 AI ➤导航,如图 4-7 所示。
图 4-7
如何访问导航面板
导航面板将如图 4-8 所示。
图 4-8
导航面板的烘焙部分
图 4-8 显示了导航面板的烘焙部分。在此部分,您可以自定义与代理相关的一些设置。让我们更详细地看看它们:
-
代理半径:定义代理可以走过的区域的宽度(墙之间的距离)
-
代理高度:定义代理可以走过的地方的高度
-
最大坡度:代理可以走的最大坡度
-
台阶高度:代理可以攀爬的台阶的最大高度
紧接着是与脱离网格链接相关的设置。脱离网格链接连接因任何原因分离的网格。这可能是因为两个曲面之间有间隙,也可能是因为有一个台阶高于上一节中设置的台阶高度。生成脱离网格链接时,会根据以下设置连接这些网格:
-
跌落高度:代理的最大跌落高度——如果平台的跌落高度高于此值,则无法生成脱离网格。
-
跳跃距离:最大代理跳跃距离——如果两个平台之间的距离大于该距离,则它们无法通过脱离网格链接进行链接。
如果您单击“烘焙”, Unity 将根据这些设置为所有标记为导航静态的区域生成一个导航网格。让我们这样做,你应该有类似于图 4-9 的东西。
图 4-9
我们刚刚创建的场景的烘焙导航网格
图 4-9 显示了图 4-6 中创建的几何图形的导航网格。蓝色区域适合步行。没有脱离网格的链接,因为整个级别非常简单,没有其他类型的区域;它们可以在导航面板的区域部分中定义。您也可以在这里为不同的区域设置不同的费用,如图 4-10 所示。
图 4-10
导航面板中的区域选项卡允许您创建不同类型的区域,并为它们分配不同的成本
成本不同的不同区域会导致代理避开或偏好某些路径,而不是其他路径。也可以指定代理可以行走的区域,这样你就可以防止一些代理移动到一些特定的区域或者使用它们来达到他们的目标。这个特性可以引入大量的可能性来迫使代理走向特定的路径。
要指定代理可以行走的区域,请选择您的代理,并在检查器中查找区域掩码下拉列表;通过点击它,您可以检查您希望您的代理能够行走的区域(图 4-11 )。
图 4-11
在区域掩码字段中,您可以为您的代理指定可行走的区域
让我们试着在地图上添加一些非网状链接!选择其中一面墙,增加它的宽度,比如值为 2,选择该对象,打开导航面板的对象部分;在那里,勾选如图 4-12 所示的生成离线链接框,将导航区域标记为可行走。这将确保 Unity 将根据代理的设置尝试使该对象可行走,并可能将其链接到地板网格。
图 4-12
激活 3D 对象的脱离网格链接的选项位于导航面板的对象部分
现在一切都设置好了,回到烘焙部分,如图 4-13 所示将下落高度和跳跃距离设置为 1,然后按下烘焙。
图 4-13
NavMesh 设置使用新的跌落高度和跳跃距离值进行了更新
新的 NavMesh 将根据设置进行烘焙,在修改后的墙的顶部生成一条可行走的路径,并使用脱离网格链接将该区域连接到地板(图 4-14 )。
图 4-14
新的 NavMesh 还包含非网状链接
新的 NavMesh 允许代理人在地图上走动,并跳到足够宽的墙上。代理现在将能够在考虑不同成本的情况下在关卡中导航,并做出最佳决策,以最短的路径到达地图上指定的目标位置。
但是我们如何在 Unity 中编写这样一个代理程序呢?更一般地说,在这样的加权系统中,寻路是如何工作的?
我们需要一类搜索算法,通过扩展节点,优先考虑最方便的节点,来探索考虑节点不同成本的图。这种搜索算法被称为最佳优先搜索。让我们仔细看看!
4.3 导航星
最佳优先搜索算法是一种搜索算法,该算法探索对最有希望的节点进行优先排序的图。扩展或不扩展节点的便利性通过启发式函数来衡量,该函数允许算法将节点组织在优先级队列中,并建议它们应该扩展的顺序。
解决问题的启发式方法是一种实用的方法,它不保证是最优的,但它足够快和足够好来实现短期目标或找到问题的满意解决方案。
在最佳优先搜索算法的情况下,启发式算法的目的是通过在节点到来时对其进行评估,找到可能导致最短路径的节点扩展策略。
关于启发式的真实例子,为了充分理解它的威力,考虑这样一个场景,你正试图决定去商店的最短路径,有两种方法:一种是绕过建筑物,将你连接到商店的正门,另一种是穿过一群房子的小直路,将你带到商店的后面(图 4-15 )。即使没有测量两条路径的长度,你的大脑也会立即评估这两条路径,并向你暗示这条小直路更可能是最短的一条,因为它是一条直接连接你和商店的直路。你的大脑只是运用了一种启发式方法,根据过去的经验和感知周围环境的能力,给两条路径分配一个近似的成本。
图 4-15
当考虑实现目标的不同方法时,你的大脑会本能地运用基于过去经验和知识的启发式方法来估计整体最佳路径
这类算法中最著名的是 A*(发音为 A-star),它也是解决视频游戏中复杂寻路问题(尤其是在 3D 空间中)的事实标准,这也是 Unity 内部完全支持它的原因。
A*使用了我们在导航时经常使用的非常熟悉的启发式方法。它粗略估计了目标可能达到的距离,没有考虑障碍。我们称之为直线距离;在计算机科学中,这被称为曼哈顿距离。
A*算法为每个节点分配分数 F = G + H,其中
-
G 是从起始节点到当前节点的代价。
-
H 是当前节点与目标之间的曼哈顿距离。
A* agent 每次展开一个节点,都会给周围的所有节点分配一个 F 分,并移动到得分最低的节点。
这种方法允许代理快速到达目标,而不会遭受广度优先搜索的副作用,即被迫扩展图中的每个节点。
很明显,A提供了更好的整体性能。同样重要的是要注意效率,特别是 A的时间复杂度很大程度上取决于所使用的启发式算法。一个好的启发会给你一个好的效率,而一个坏的启发可能会使算法完全无效。
4.4 编程代理
我们终于有了编写代理程序的所有元素,它可以使用加权 NavMesh 上的找到通向目标点的路径。这在 Unity 中是极其容易做到的,我们甚至不需要从头开始实现 A!
通过右键单击层次并选择 3D 对象➤立方体,在场景中创建一个立方体。让我们称这个新对象为代理。随意将材质应用到对象,使其从场景的其余部分中突出出来。
必须通知 Unity,我们刚刚创建的对象实际上是我们刚刚创建的导航网格的代理。我们可以通过向代理对象添加一个 NavMesh 代理组件来实现这一点。选择代理对象,转到检查器,并单击添加组件;在那里,查找 NavMesh 代理组件并将其添加到对象中。
在图 4-16 中,你可以看到 NavMesh 代理组件的样子。
图 4-16
NavMesh 代理组件允许您个性化代理
在那里,您可以自定义与代理相关的所有设置。让我们仔细看看它们:
-
代理类型:该代理的类型。可以在导航面板的“代理”部分定义代理类型——无论如何,因为您只能基于单一类型的代理定义单一导航网格,所以您可能希望只使用一种类型的代理。
-
基准偏移量:物体的相对垂直位移。
以下是所有与转向相关的设置。我们在第二章中详细介绍了转向。如果你认为你需要刷新那些想法,在继续阅读之前,回去快速阅读一下。
-
速度:代理的导航速度。
-
角速度:代理的旋转速度。
-
加速度:代理的最大加速度值。
-
停止距离:代理到达目标后与目标保持的距离。
-
自动刹车:自动刹车允许代理停止以避免超过目的地点(因为导航速度高)。
在转向设置之后,还有避障设置:
-
半径:智能体的避障半径。
-
高度:智能体的避障高度。
-
质量:这让你可以在回避精度和性能之间进行权衡——事实上,计算回避距离可能需要一些繁重的处理器工作,这取决于情况和这里设置的质量水平。
-
优先级:代理人的回避优先级。当代理执行回避时,较低优先级的代理将被忽略。
最后,还有一些特定于寻路的设置:
-
自动遍历网外链接:代理是否应该自动遍历网外链接?
-
自动重新路径:如果当前路径无效,代理是否应该计算另一条路径?
-
区域屏蔽:指定该代理可以通过的区域种类(可以多选)。
现在我们已经做好了所有准备,我们只需要添加一些非常基本的功能来允许代理在我们选择目的地时移动。
让我们创建一个名为 AgentController.cs 的新 C# 脚本。
该脚本将包含以下代码:
1\. using System.Collections;
2\. using System.Collections.Generic;
3\. using UnityEngine;
4.
5\. public class AgentController : MonoBehaviour
6\. {
7\. void Update()
8\. {
9\. if(Input.GetMouseButtonDown(0))
10\. {
11\. RaycastHit hit;
12\. if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out hit, 100))
13\. {
14\. this.GetComponent<UnityEngine.AI.NavMeshAgent>().SetDestination(hit.point);
15\. }
16\. }
17\. }
18\. }
正如我们在前面章节中看到的,在第 9–12 行中,我们向地图上我们点击的点投射光线,以便我们可以在 3D 空间中有一个位置来设置代理的目标。第 14 行是神奇的地方:就这一行,我们告诉 NavMesh 代理组件将代理的目标设置为我们点击的位置。这将使代理使用我们刚刚烘焙的导航网格来计算到达目标位置的最佳路径。
就是这样;我们不需要任何其他功能,没有转向行为,没有寻路实现:Unity 已经实现和设置了一切。但重要的是要清楚它们是如何工作的!
保存脚本,将其附加到代理对象,然后运行游戏。
运行游戏(图 4-17 ),你会看到点击关卡中的任何一点都会使代理沿着最短的路径走向那个点,这要感谢 A*算法在我们刚刚烘焙的加权 NavMesh 上的后台工作!
图 4-17
代理走向我们在地图上点击的任何一点
在本章中,我们看到了使用 Unity 提供的两个强大工具解决 3D 环境中的导航问题是多么容易:Navigation Mesh 和 A*。
在下一章,我们将做下一步,给我们的代理添加行为。我们将创建一个迷你秘密行动游戏,你必须避开正在寻找你的巡逻警卫。我们将探索和实现许多有趣的想法,如一个视觉锥,让警卫感知球员和一个非常基本的噪音系统,以吸引附近的警卫,并迫使他们调查声音传来的位置。
4.5 测试你的知识
-
什么是加权图?
-
什么是导航网格?
-
航路点和导航网格有什么区别?
-
NavMesh 比航路点系统更好的原因是什么?
-
如何在 Unity 中烘焙导航网格?
-
如何在 Unity 中更改 NavMesh 面积成本?
-
什么是 A*?它是如何工作的?
-
什么时候方便?
-
如何在 Unity 中创建 NavMesh 代理?
五、行为
在这一章中,我们将进一步扩展我们关于游戏人工智能的对话,为我们游戏中的 NPC 创造可信的人工智能行为迈出第一步。
曾经有一段时间,电子游戏只有敌人,他们漫无目的地四处活动。玩家只需要走过敌人,避开他们的模式和子弹,这就是全部的威胁。
1980 年,Namco 通过引入新益智游戏 Pac-Man 的敌人行为,永远改变了这一趋势。《??》中吃豆人的敌人是在迷宫中游荡的有色幽灵,偶尔会试图追赶协调他们行动的玩家。关于吃豆人不可思议的事情是每个敌人都有不同的追逐玩家的方法,并且它与其他敌人的方法是互补的。这让游戏在一个全新的层面上感受到了挑战,因为玩家第一次感觉到他们在与一种“新型智能”对抗,正如加里·卡斯帕罗夫在 1997 年遭遇惨败后所描述的那样。每场比赛都感觉与之前的不同,因为敌人正在适应每一个新的情况,击败他们的唯一方法是试图通过预测他们的方法来击败他们,因为这种新的协作人工智能系统的本质,不是很容易。
《吃豆人》中革命性的人工智能是基于一系列不同的状态,根据某些条件,敌人可能在游戏中的任何时刻出现。
图 5-1 显示了吃豆人幽灵在任何时刻可能处于的各种状态。在游戏开始时,它们产卵并进入漫游状态,开始在迷宫中漫游。鬼魂一看到吃豆人,就进入追逐状态,开始使用他们独特的策略追逐吃豆人。如果幽灵失去了对吃豆人的追踪,他们会回到漫游状态。当吃了吃豆人的黄色药丸后,鬼魂进入躲避状态,它们变得蓝色,容易受到吃豆人的攻击,它们开始逃离他。
图 5-1
吃豆人幽灵有限状态机
你可以在图 5-1 中看到的这种表示叫做Finite-State Machine (FSM)
。
FSM
是一种计算模型,用于许多不同的领域(软件和硬件)来设计和模拟逻辑过程。在许多计算机科学领域至关重要之后,FSM
也在游戏人工智能中找到了自己的位置,成为最早和最容易(并且仍然在使用)的表示和管理简单人工智能行为的方法之一。
我们将使用一个FSM
为一个代理创建一个行为,我们将把它添加到我们在第四章中创建的场景中。新的代理将在场景中四处游荡,一旦看到玩家控制的角色,它就会试图追逐它。当玩家控制的角色离开代理人的视线范围后,他们会回去巡逻这个区域。
现在我们有了一个计划,拿起我们在第四章中做的项目,让我们继续做下去!
5.1 警卫!卫兵!
让我们从创建一个新的Game Object
开始,它将在场景中代表我们的警卫代理。右键单击Hierarchy
并选择3D Object
➤ Cube
,创建一个立方体。
重命名这个新对象"Guard"
,选择它并点击检查器中的Add Component
,然后点击Navigation
➤ Nav Mesh Agent
,给它分配一个NavMeshAgent
。这将允许新代理使用 Unity 导航系统,就像我们在第四章中制作的代理一样。
这个新的代理将是一个自主的代理,能够自己推理和采取行动,所以我们需要为它提供一些传感器,让它感知周围的世界。我们要给这个特工一个礼物视力!
视野
为了让我们的代理能够看到,我们将实现一个视野 (FOV),它代表了在任何给定时刻可见的可见世界的范围。视野内的每个对象都可以被代理看到。
我们可以将这一概念转化为 C# Unity 编程,方法是从代理向玩家投射一条光线,并检查该光线与玩家相遇的位置是否位于与代理的位置和方向成一定角度和距离的区域内,如图 5-2 所示。
在我们开始编码之前,我们需要创建一个标签分配给玩家,以确保我们知道我们击中了正确的对象。
图 5-2
我们将要实现的 FOV 的表示
让我们从层次中选择 Player 对象,然后在检查器中,单击 Tag 字段以显示一个下拉菜单,您可以从中选择或创建一个新的标签。出于本章的目的,我们可以安全地使用默认情况下已经存在的“Player”标签(图 5-3 )。
图 5-3
玩家标签
现在,让我们创建一个名为“GuardController.cs
”的新 C# 脚本,并将其分配给Guard
代理。
通过双击打开脚本,并在类定义的顶部添加以下类成员:
1\. public Transform player;
2\. float fovDist = 20.0f;
3\. float fovAngle = 45.0f;
第一个类成员(第 1 行)叫做player
,它代表玩家的位置。我们需要将玩家头像的实例与这个成员连接起来,这样代理就会一直知道当前的位置。这只是为了容易地实现这个机制,但是如果玩家不在代理的视野之内,代理就不能追逐玩家。
第二个和第三个类成员(第 2-3 行)表示视图锥的深度(您可以将此视为从代理位置开始的半径),其宽度表示为以度为单位的角度。
正如我们所说,我们需要将公共成员玩家连接到实际的玩家对象。为此,您需要选择Guard
对象,然后在检查器的 GuardController 脚本部分,单击player
字段并从列表中选择玩家对象,或者只需将玩家对象拖放到玩家字段中。
现在我们有了关于Player
位置的信息,让我们在GuardController
中定义一个新方法来决定它是否能被fovDist
和fovAngle
定义的Guard
的视野看到。
1\. bool ICanSee(Transform player)
2\. {
3\. Vector3 direction = player.position - this.transform.position;
4\. float angle = Vector3.Angle(direction, this.transform.forward);
5.
6\. RaycastHit hit;
7\. if (
8\. Physics.Raycast(this.transform.position, direction, out hit) && // Can I cast a ray from my position to the player's position?
9\. hit.collider.gameObject.tag == "Player" && // Did the ray hit the player?
10\. direction.magnitude < fovDist && // Is the player close enough to be seen?
11\. angle < fovAngle // Is the player in the view cone?
12\. )
13\. {
14\. return true;
15\. }
16\. return false;
17\. }
该方法将一个Transform
组件作为参数,并返回一个布尔值。它从当前对象(this
)向作为参数传递的对象(第 8 行)投射光线,并检查被击中的对象是否被标记为"Player"
(第 9 行)以及是否在视野内(第 10-11 行)。如果所有条件都得到验证,这意味着对象是可见的,因此函数返回true
(第 14 行);否则,它返回false
(第 16 行)。
这个代码将被用在代理的Update
方法中,这样它就可以在每一次点击时看到,并检查玩家是否在视野范围内,并据此采取行动。
让我们通过在Update
方法中使用ICanSee
来做一个小测试,看看它是如何工作的。这样修改Update
方法:
1\. void Update()
2\. {
3\. if (ICanSee(player))
4\. {
5\. Debug.Log("I saw the player at " + player.position);
6\. }
7\. else
8\. {
9\. Debug.Log("All quiet here...");
10\. }
11\. }
现在保存脚本并运行游戏。
守卫会看到正前方视野范围内的任何东西,所以只需点击视野范围内的一个点,就可以让玩家的化身走到那个点,让守卫注意到。你可以检查控制台来验证守卫是否真的看到玩家站在它的视野中。
现在我们已经有了合适的视野,让我们设计和编码代理的实际行为。
5.1.2 特工,规矩点!
为了让我们的守卫代理表现得像一个合适的守卫,我们需要教他们一个真正的守卫是如何行为的,所以让我们设计一个 FSM 来描述我们希望他们遵循的行为。
Tip
无论 FSM 看起来有多简单,拥有一个设计阶段总是一个很好的实践,可以让你熟悉流程,并抓住机会尽可能地最小化 FSM。
正如我们所说的,我们希望警卫在这个区域随意巡逻。我们也希望守卫一看到玩家就追上去。如果玩家成功逃脱,我们希望守卫调查他们最后一次看到玩家的地方,然后,如果玩家不可见,开始巡逻,或者追逐他们。
这是一种也在经典游戏合金装备 (Konami,1998)中使用的FSM
,它是理解一个简单行为如何对游戏有效的一个很好的基础。
从对行为的描述中,我们可以得出三种状态:
-
巡逻:警卫正在巡逻这个地区。
-
调查:守卫正在向他们最后看到玩家的地方移动。
-
追逐:守卫知道玩家的当前位置,正在追逐他们。
从行为的描述中,我们还可以推导出我们推导出的状态之间的联系,如图 5-4 所示。
图 5-4
第章第五部分代理的 FSM
在编程语言中实现FSM
的最简单也是最常见的方式是使用enum
来描述不同的状态,FSM
可以进入这些状态并检查其当前状态以执行不同的代码。
使用枚举在 C# 中实现我们的FSM
,如下所示:
1\. enum State { Patrol, Investigate, Chase };
2.
3\. /* ... */
4.
5\. switch (state)
6\. {
7\. case State.Patrol:
8\. // Patrolling actions
9\. break;
10\. case State.Investigate:
11\. // Investigating actions
12\. break;
13\. case State.Chase:
14\. // Chasing actions
15\. break;
16\. }
前面的代码将是允许我们根据当前状态执行正确操作的框架。我们需要将这段代码与我们已经为视场编写的代码混合,这样我们就可以遵循图 5-4 中FSM
所表达的逻辑。
为了实现我们的行为,首先我们需要一些支持逻辑和存储一些重要信息的类成员,所以在GuardController
类的定义顶部定义以下类字段:
1\. // FSM
2\. enum State { Patrol, Investigate, Chase };
3\. State curState = State.Patrol;
4.
5\. // Player info
6\. public Transform player;
7.
8\. // Field of View settings
9\. public float fovDist = 20.0f;
10\. public float fovAngle = 45.0f;
11.
12\. // Last place the player was seen
13\. Vector3 lastPlaceSeen;
让我们逐一简单描述一下:
第 1–2 行:这里我们定义了代表我们的FSM
(第 1 行)和State
变量的所有状态的枚举,这些变量将存储代理的当前状态。
第 6 行:正如我们已经看到的,这里我们定义了将连接到实际玩家对象的公共成员,这样我们就可以访问玩家的当前位置并检查FOV
代码。
第 9–10 行:同样,我们在这里定义FOV
的设置,它的视角和距离。
第 13 行:这里,我们定义了一个Vector3
变量,它将存储我们最后一次看到玩家的地方的信息。当我们执行调查和巡逻行动时,我们将使用它。
然后,修改GuardController.cs
脚本中的更新方法,使其看起来像这样:
1\. void Update()
2\. {
3\. State tmpstate = curState; // temporary variable to check if the state has changed
4.
5\. // -- Field of View logic --
6\. if (ICanSee(player))
7\. {
8\. curState = State.Chase;
9\. lastPlaceSeen = player.position;
10\. }
11\. else
12\. {
13\. if (curState == State.Chase)
14\. {
15\. curState = State.Investigate;
16\. }
17\. }
18.
19\. // -- State check --
20\. switch (curState)
21\. {
22\. case State.Patrol: // Start patrolling
23\. Patrol();
24\. break;
25\. case State.Investigate:
26\. Investigate()
27\. break;
28\. case State.Chase: // Move towards the player
29\. Chase(player);
30\. break;
31\. }
32.
33\. if (tmpstate != curState)
34\. Debug.Log("Guard's state: " + curState);
35\. }
前面的代码包含一些占位符函数(Chase
、Investigate
和Patrol
),我们稍后将实现这些函数;现在,让我们只关注行为的一般逻辑。
第 6–17 行:我们可以看到FOV
逻辑被用于确定当前状态。如果守卫能看到玩家,它会追逐他们(第 8–9 行);否则,如果它目前处于追逐状态,这意味着警卫刚刚失去了玩家的踪迹,因此它需要调查玩家最后被看到的地点(第 15–16 行)。我们将在 Investigate 函数中通过将NavMeshAgent
目标设置为我们想要调查的点来实现这一点,在代理到达调查点之前,我们不会中断调查。
如果守卫看不到玩家,但是当前状态与State.Chase
不同,我们不想改变它,因为这意味着守卫正在巡逻或调查,所以我们不想中断这个活动,直到它结束或直到守卫看到玩家。
第 21–31 行:在这里,我们检查所有的状态并采取相应的行动。让我们仔细看看:
-
第 22-24 行:守卫处于
Patrol
状态时,只是在lastPlaceSeen
点附近巡逻。Patrol()
方法内部的巡查逻辑将从lastPlaceSeen
开始创建一个新的随机检查点,并将其设置为新的目标。 -
第 25–27 行:如果守卫处于
Investigate
状态,我们希望他们继续调查。调查方法将包含一个检查,当守卫到达调查点时,检查将停止调查并开始巡逻。 -
第 28–30 行:最后,如果守卫处于
Chase
状态,它只是继续追逐玩家。如果你离玩家不够近的话,Chase()
方法将包含向玩家移动的逻辑。我们稍后会看到这一点。
第 3 行和第 33–34 行只是记录控制台的任何状态变化,这样你就可以跟踪Guard
的行为。
好了,现在我们有了行为的结构,让我们来实现守卫可以做的三个动作。
5.1.3 追!
让我们从最容易实现的操作开始。Chase
动作的作用是在守卫不够近的情况下,以确定的速度向玩家的位置移动。我们需要使用我们在第 2 和 3 章节中看到的移动和转向原理来实现这一点。
我们还需要三个类成员来定义行走和旋转的速度和准确性,这基本上是我们希望在目标和代理之间保持的空间。让我们将这三个类成员添加到类定义中:
1\. // Chasing settings
2\. public float chasingSpeed = 2.0f;
3\. public float chasingRotSpeed = 2.0f;
4\. public float chasingAccuracy = 5.0f;
现在让我们定义实际的Chase
方法:
1\. void Chase(Transform player)
2\. {
3\. this.GetComponent<UnityEngine.AI.NavMeshAgent>().Stop();
4\. this.GetComponent<UnityEngine.AI.NavMeshAgent>().ResetPath();
5.
6\. Vector3 direction = player.position - this.transform.position;
7\. this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(direction), Time.deltaTime * this.chasingRotSpeed);
8.
9\. if (direction.magnitude > this.chasingAccuracy)
10\. {
11\. this.transform.Translate(0, 0, Time.deltaTime * this.chasingSpeed);
12\. }
13\. }
首先,我们想重置NavMeshAgent
组件,因为当我们追逐时,我们希望代理只专注于跟随玩家,因此它需要忘记任何巡逻或调查目标,我们通过调用NavMeshAgent
组件的Stop
和ResetPath
方法(第 3–4 行)来完成。
然后,我们使用代表Guard
和玩家位置的向量(线 6 )来定义我们需要守卫看的方向,然后我们进行实际的旋转,这样Guard
就可以面对Player
( 线 7 )。
在第 9-12 行,我们检查Guard
是否离玩家足够近,如果不是,我们希望代理向前移动——多亏了第 7 行,这是Player
所在的方向。
这就是我们为Chase
方法所要做的一切。
让我们看看下一个:Investigate
!
5.1.4 调查!
Investigate
方法是另一个简单的方法。它来源于我们在第四章中所学的关于NavMeshes
和A*
的内容,由于 Unity 的特性,在这个方法中我们只需要为NavMeshAgent
组件设定一个目标,并确保它永远不会被覆盖,直到代理达到它或者他们看到玩家。
我们不需要为这个方法添加任何额外的类参数,因为我们将只使用已经定义的lastPlaceSeen
和curState
。
这是Investigate
方法的代码:
1\. void Investigate()
2\. {
3\. // If the agent arrived at the investigating goal, they should start patrolling there
4\. if (transform.position == lastPlaceSeen)
5\. {
6\. curState = State.Patrol;
7\. }
8\. else
9\. {
10\. this.GetComponent<UnityEngine.AI.NavMeshAgent>().SetDestination(lastPlaceSeen);
11\. Debug.Log("Guard's state: " + curState + " point " + lastPlaceSeen);
12\. }
13\. }
正如我们所说的,当我们处于Investigate
状态时,只有当警卫到达我们希望他们调查的点时,我们才希望停止调查。我们在4 号线对此进行检查,如果该点仍然无法到达,我们将该点设置为NavMeshAgent
目的地(10 号线);否则,如果到达该点,我们希望警卫开始巡逻(线 6 )。
Investigate
方法完成;现在我们只需要添加Patrol
方法。我们来看看怎么做。
5.1.5 巡逻!
在这个方法中,我们希望Guard
从最后一次看到Player
的地方选择一个确定距离的随机位置,然后去那里。
我们希望Guard
不时地找到一个新的随机地点进行访问。我们不希望这太频繁,以避免神经过敏和怪异的行为。我们想给人的感觉是,警卫心甘情愿地走向空间中的一个精确点,只是为了环视一个区域,然后走向另一个区域。
首先,我们需要为类GuardController
定义一些类成员,我们将使用它们来设置巡视和控制流:
1\. // Patrol settings
2\. public float patrolDistance = 10.0f;
3\. float patrolWait = 5.0f;
4\. float patrolTimePassed = 0;
在第 2 行,我们定义了 patrolDistance,这是从我们想要生成随机步行点的最后一个 PlaceSeen 点的距离。
在行 3 和行 4 ,我们定义了 patrolWait 和 patrolTimePassed。前者代表我们希望守卫在找到新的随机地点之前等待的时间。后者是从上一次随机点生成开始经过的实际时间量。
在我们定义了逻辑和那些设置之后,实现非常简单:
1\. void Patrol()
2\. {
3\. patrolTimePassed += Time.deltaTime;
4.
5\. if (patrolTimePassed > patrolWait)
6\. {
7\. patrolTimePassed = 0; // reset the timer
8\. Vector3 patrollingPoint = lastPlaceSeen;
9.
10\. // Generate a random point on the X,Z axis at 'patrolDistance' distance from the lastPlaceSeen position
11\. patrollingPoint += new Vector3(Random.Range(-patrolDistance, patrolDistance), 0, Random.Range(-patrolDistance, patrolDistance));
12.
13\. // Make the generated point a goal for the agent
14\. this.GetComponent<UnityEngine.AI.NavMeshAgent>().SetDestination(patrollingPoint);
15\. }
16\. }
Update
方法在守卫处于巡逻状态的每一帧都会调用这个方法,所以我们做的第一件事就是增加Time.deltaTime
经过的时间的值,也就是从上一帧(第 3 行)经过的时间(以秒为单位)。
在更新当前经过的时间后,我们检查是否超过了我们希望代理在生成新的步行点(线 5 )之前等待的时间量,如果是这样,我们重置计时器(线 7 )并在X
和Z
轴上生成一个随机点,从lastPlaceSeen
位置(最后一次看到Player
的位置)开始,在-patrolDistance
和+patrolDistance
( 线 8 和的范围内最后,新的随机位置被指定为NavMeshAgent
组件的新目的地(行 14** )。**
作为点睛之笔,我们要初始化patrolTimePassed
和lastPlaceSeen
,让第一个点在游戏开始时从后卫的当前位置开始产生。为此,我们需要使用Start
方法:
1\. void Start()
2\. {
3\. patrolTimePassed = patrolWait;
4\. lastPlaceSeen = this.transform.position;
5\. }
就这样!都准备好了!保存脚本并运行游戏,观察您的第一个动作FSM
!
运行游戏时,你会看到守卫从原来的位置开始在随机的位置上移动,如果玩家出现在它的视野范围内,它会追逐他们,一旦玩家成功逃脱,它会调查最后一次看到他们的地方,并从那里开始巡逻,以防没有玩家的踪迹(图 5-5 )。
图 5-5
玩家(绿色)躲在墙后躲避巡逻的警卫(蓝色)
在看到Guard
四处巡逻、追逐和调查之后,你可能已经理解了FSM
驱动的行为的力量,但是为了给你一点提示FSM
可以变得多么复杂和有趣,让我们利用我们刚刚创建的东西来构建一个有趣的功能。
5.2 咚-咚-谁在那里?
在经典的隐形游戏合金装备 (Konami,1998)中,你扮演索利德·斯内克,一名间谍,其目的是渗透一个秘密基地并发现威胁世界的军事秘密!蛇腰带上最锋利的武器之一是能够敲击墙壁来吸引巡逻警卫的注意,并迫使他们离开巡逻的地方去调查他们听到噪音的位置。这是一个需要掌握的关键能力,因为它可以让你预测守卫的移动,并在一小段时间内释放一些区域,让你可以继续前进。我们将在我们的迷你游戏中实现这个功能!
所以正如我们所说的,除了我们已经做的,我们希望有可能通过敲门引起Guard
的注意。我们希望这个动作可以迫使守卫调查玩家的当前位置,如果敲门发生时守卫离玩家有一定的距离。
我们首先需要的是一个可以从Guard
类外部触发的方法,所以让我们在GuardController
类内部定义这个额外的方法:
1\. public void InvestigatePoint(Vector3 point)
2\. {
3\. lastPlaceSeen = point;
4\. curState = State.Investigate;
5\. }
这个新方法将一个Vector3
作为参数,这是我们希望Guard
调查的位置。为了迫使Guard
调查那个点,我们只需假装那是玩家最后出现的地方(行 3 ),然后我们将Guard
的状态改为Investigate
( 行 4 )。就这样!在下一次迭代中,Guard
将开始调查新的点。
现在,为了实现敲击地板的实际功能,我们希望在每次按下敲击键时在玩家周围生成一个球体,并检查这个球体内部的碰撞。如果球体与守卫发生碰撞,我们希望提醒守卫,让他们使用InvestigatePoint
功能调查玩家的当前位置。
我们需要的第一件事是为我们的Guard
添加一个标签,这样我们就可以在所有其他对象中识别它。就像我们为播放器所做的一样,在Hierarchy
中选择Guard
对象,然后转到检查器并点击Tag
下拉菜单,这次通过点击Add Tag...
创建一个自定义标签。调用新标签Guard
并将其分配给Guard
对象。
我们希望Player
真正发出敲击声,为此,我们需要给我们的Player
对象添加一个AudioSource
组件。在Hierarchy,
中点击Player
对象,然后在检查器中点击Add Component
。从列表中找到AudioSource
组件,并将其添加到对象中。在那个组件中有许多有趣的设置要调整,但是我们只需要添加我们的音频文件,真的。因此,让我们单击AudioSource
的AudioClip
字段来选择我们的敲击音频文件。
现在,让我们打开与Player
对象相关联的脚本,并添加这个方法来播放敲门音频文件:
1\. IEnumerator PlayKnock()
2\. {
3\. AudioSource audio = GetComponent<AudioSource>();
4.
5\. audio.Play();
6\. yield return new WaitForSeconds(audio.clip.length);
7\. }
这是一段非常简单的代码:在第 3 行的处,我们加载AudioSource
组件,并在第 5 行的处运行它。第 6 行确保只有音频文件播放完毕后,程序才能再次运行。
我们希望能够定义敲击的强度,以防我们希望给玩家配备不同的物体或选项来制造不同强度的噪音。因此,让我们定义这个类成员,它将定义碰撞球的半径,这将触发警卫的调查:
1\. public float knockRadius = 20.0f;
最后,在 Update 方法中,添加以下代码:
1\. if (Input.GetKey("space"))
2\. {
3\. StartCoroutine(PlayKnock()); // Play audio file
4.
5\. // Create the sphere collider
6\. Collider[] hitColliders = Physics.OverlapSphere(transform.position, knockRadius);
7\. for (int i = 0; i < hitColliders.Length; i++) // check the collisions
8\. {
9\. // If it's a guard, trigger the Investigation!
10\. if (hitColliders[i].tag == "guard")
11\. {
12\. hitColliders[i].GetComponent<GuardController>().InvestigatePoint(this.transform.position);
13\. }
14\. }
15\. }
我们说过,当敲击键被按下(第 1 行,我们播放声音文件(第 3 行,我们创建一个半径为knockRadius
( 第 6 行)的球体碰撞器;然后,对于每个与我们的球体发生碰撞的物体(线 7 ,我们检查它是否被标记为Guard
( 线 10 ),在这种情况下,我们希望触发对我们当前位置的调查(线 12 )。
保存脚本并运行游戏!
你现在有能力欺骗Guard
迫使他们调查一个点,让他们离开一个地方。就像传说中的索利德·斯内克!
在这一章中,你发现了表达的力量,你可以用FSM
驱动的行为来教会一个代理如何聪明地(或明显地)根据他们所处的不同情况做不同的动作。以不同方式面对不同情况的能力是理性智能概念的一大部分。
即使FSM
驱动的行为是视频游戏中使用的第一个行为系统,它们仍然在许多视频游戏流派中广泛使用,这是因为这种方法的效率,它非常轻便而强大,只需很少的计算和编程工作就能产生很好的结果。
5.3 再见,艾
在这五章中,我们发现并阐述了许多游戏人工智能原理,从人工智能和智能体的定义开始,经过寻路和搜索算法,最终创建了一个小型隐形游戏,其中一个自主智能体能够巡逻,追逐和调查奇怪的噪音和入侵者。那是一段漫长的旅程!
人工智能是一个非常广泛和复杂的领域,它总是在不断发展。我希望这本小书能够给你一个很好的、清晰的关于游戏 AI 开发基础的介绍,并帮助你在游戏/AI 开发者的职业生涯中提高你的技能,构建有趣的、好玩的、以智能(或显然如此)NPC 为特色的游戏!
祝你好运,玩得开心!