游戏人工智能编程实践指南(二)
在本章中,我们回顾了如何创建点对点移动,但不是使用简单的方法,而是研究了大型且成功的游戏工作室如何解决 AI 最复杂的功能之一,即路径查找。在这里,我们学习了如何使用 theta 算法来重现人类特征,这有助于我们在正确的方向上搜索和移动,以便到达期望的目的地。在下一章中,我们将讨论现实中的群体互动,这是尝试使人工智能角色尽可能真实的一个重要方面。我们将研究不同类型游戏中使用的方法,同时我们还将探讨
原文:
zh.annas-archive.org/md5/bc8cfbf113a524e20811246ab8cf847b译者:飞龙
第七章:高级路径查找
在本章中,我们将探讨可用于各种游戏的先进路径查找方法。本章的主要目标是学习如何创建一个能够分析地图并处理所有必要信息以决定最佳路径的先进 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,这将使我们的角色完全意识到周围发生的事情,如果我们降低值,我们将达到更逼真的视野,就像在合金装备固体游戏中使用的那样。
到目前为止,我们能够选择一个潜行游戏,并复制它们最标志性的特征,如逼真的视野和音频意识。我们已经学到了基础,现在我们可以从这里开始,开发我们自己的游戏。
摘要
在本章中,我们揭示了潜行游戏的工作原理以及我们如何重新创建相同的系统,以便我们可以在游戏中使用它。我们从简单的方法过渡到复杂的方法,使我们能够决定在创建的游戏中什么更适合,如果它高度依赖于潜行,或者我们只需要一个基本系统来使我们的角色通过视觉或听觉意识来探测玩家。本章学到的特性也可以扩展并用于我们之前创建的任何实际例子中,增强碰撞检测、路径查找、决策、动画以及许多其他特性,将它们从功能性转变为现实性。
我们创建游戏的方式不断更新,每款发布的游戏都带来了一种新的或不同的创建方法,这只有在我们愿意实验并融合我们所知道的一切,调整我们的知识以实现我们想要的结果,即使它们看起来极其复杂的情况下才可能。有时这仅仅是一个探索基本概念并扩展它们的问题,将一个简单想法转变为复杂系统。
更多推荐

所有评论(0)