高级游戏开发工具包(三)

原文:The Advanced Game Developer’s Toolkit

协议:CC BY-NC-SA 4.0

六、寻找最短路径

我们需要解决寻路的最后一个主要领域:在迷宫中寻找最短的路径。事实证明,这不仅仅是一个有趣的题外话,而是你需要知道的最重要的游戏设计技巧之一。

到目前为止,我们在书中提到的所有玩家控制系统都涉及到用键盘或鼠标在屏幕上移动玩家。但是游戏通常采用点击控制系统。将鼠标指向地图上的某个地方,然后单击。这个角色会走到那里,并神奇地找到到达目的地的最短路径,同时巧妙地避开任何障碍。图形冒险游戏使用这种控制系统,几乎所有的策略和回合制游戏都是如此。它是如何工作的?这正是你将在本章中发现的!

沿着最短路径移动角色实际上是一个由两部分组成的过程:

  1. 寻找最短路径:这包括测试起点和终点之间所有最可能的瓷砖。你需要弄清楚哪些瓷砖能让你更快到达目的地,哪些包含要避开的障碍物。在这个测试的最后,你会得到一系列告诉你最短路径的方块。

  2. 跟随路径中的瓷砖:你最终得到的瓷砖阵列就像是游戏角色可以跟随的面包屑。告诉角色跟随这些面包屑从它的开始位置到目的地。

如果第一步看起来很复杂,那就是复杂!但是好消息是已经找到了很好的解决方案。这意味着你不需要担心想出自己的解决方案——你可以选择一个现成的,并在你的游戏中实现它。

您可以使用许多不同的寻路算法,包括最佳优先、广度优先和 Dijkstra 算法。所有人都会合理地解决这个问题。但最好的一般被认为是 A* (A 星)。A算法具有最佳的整体性能,并且非常灵活。如果你只需要学习一种寻路算法,A就是了。

A*是由 Peter Hart、Nils Nilsson 和 Bertram Raphael 在 20 世纪 60 年代开发的。这是对 Dijkstra 算法的修改,这是一种寻路算法,由先驱计算机科学家 Edsger Dijkstra 在 20 世纪 50 年代提出。

在我向你展示如何在游戏中使用 A*之前,我们先来详细了解一下算法是如何工作的。

了解 A*

我们可以为我们的进化祖先感到骄傲。由于良好的寻路能力,他们都成功地避开了更大的原生动物,避开了咬牙切齿的恐龙牙齿,并避免了成为斯特克方丹洞穴底部的化石残骸。寻路是一项技能,它对于像树蛙一样在亚马逊生存就像能够在早上赶上公共汽车一样重要。这是一种生存技能,可以让你自动计算出从 A 点到 B 点的最短路径,而不会被吃掉、饿死或上班迟到。看一眼图 6-1 ,你马上就能看到 A 点和 b 点之间的最短路径。寻路是我们 DNA 的一部分。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-1。A 点和 B 点之间的最短路径是什么?这对我们人类来说是显而易见的,但你如何向计算机解释呢?

另一方面,电脑就像养尊处优、屡获殊荣的波斯猫。他们整天无所事事,花太多时间在网络上,睡得很多。他们没有像我们一样,在无数个千年的原始汤里磨砺出自己的技能。我们需要用最直截了当的声音告诉他们,“这条路很好!”或者“这条路不好走!”用一根粗糙的棍子威胁他们。

但是什么是“好的道路”呢?在寻找最短路径的情况下,它是让你更快到达目的地的路径。问题是电脑看不到大局。他们一次只能看到一小步。所以告诉计算机如何找到最短路径的策略是这样的:

  • 将整个路径分成许多小步骤。

  • 对于每一步,想清楚下一步该做什么。

  • 采取下一步,重复这个过程,直到你到达你应该到达的地方。

但是计算机必须仍然能够区分好的路径和坏的路径。让我们看看如何帮助它解决这个问题。

计算成本

解开这个谜:

每天早上都会有一份报纸送到你家门口。最便宜的取货方式是什么?

A.打开前门。

B.走出后门,跳过你花园的栅栏,跑进小巷,叫一辆出租车,绕着街区骑到你的前门?

选项 A 是免费的,但选项 B 花费你大约 4.75 美元的出租车费。这意味着选项 B 更

“昂贵”是 A算法用来描述在两点之间移动需要多少工作的术语。A计算出到达目的地的最便宜的路线。它通过为你在这条路上可能采取的每一步分配一个成本来做到这一点。成本最低的步骤是更好的步骤。A*通过寻找最低成本的移动和最便宜的路径来工作。

A有自己的一套术语和词汇。成本和费用是其中的两个术语,正如您将看到的,它们是描述其一些核心概念的一种便捷方式。我将在前面介绍其他几个具体的 A术语。请特别留意即将出现的术语“节点”和“启发式”!

图 6-2 解释了我所说的成本是什么意思。想象你是一个培养皿中的细菌,自由漂浮,在 a 点关注自己的事情。突然,一个巨大、饥饿的单细胞变形虫的影子笼罩着你,唯一的目标是包容你的细胞物质。你知道如果你不马上躲起来,你会有麻烦的。你只能躲在两个地方:B 点或 c 点。你有一瞬间的时间来决定哪一个是最近的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-2。B 和 C 哪个最接近 A?

在图 6-2 中的例子中,旅行到 B 点比旅行到 C 点需要大约三分之一的时间,事实上,从 a 点旅行到 B 点需要的时间正好是 1.41 倍,这意味着 C 点是你需要游泳来逃离那个阿米巴原虫的地方。值 1.41 是对角旅行的成本。

在基于矩形网格的游戏世界中,只有两种运动选择,并且每种选择都有代价:

  • 对角:成本 14。

  • 直跨:横着走或者竖着走,成本 10。

这些成本是多少并不重要,只要它们成比例地代表了向这些方向移动所需的时间。所以,14 比 10 和 1.4 比 1 的比例是一样的,我们不需要担心小数。如果您愿意,您当然可以在自己的代码中使用 1.4 和 1。图 6-3 显示了单元间移动的成本。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-3。穿越细胞的成本

现在我们有办法向计算机描述什么是好的路径:成本最低的路径。

图 6-4 显示了从 A 到 b 的两条可能路径,你不仅可以清楚地看到路径 1 是最短的,而且恰好是最便宜的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-4。最短的路径也是最便宜的

前面我提到过,计算机在任何给定时间只能看到路径中的一步。你可以从图 6-3 中看到,每一步都是到达 b 点的代价最小的一步,但这只有在你已经知道路径的结果后才是显而易见的。计算机在开始构建路径之前并不知道这一点。它如何直接从 A 点知道下一步该做什么?

寻找第二步

在 A的术语中,路径中的每一步都称为一个节点。就我们而言,节点只是二维数组或网格中的单元。但是,从现在开始,我将开始称它们为节点,这样你们就可以习惯这个术语了。你会发现它在其他文本的寻路讨论中被广泛使用。A使用术语节点是因为除了矩形网格之外,没有理由不能用其他方式划分空间,例如使用六边形或圆形。但是就我们的目的而言,当你听到我谈论节点时,只要知道我指的是网格单元。

A*从 A 点开始搜索最短路径,A 点是父节点。父节点是路径上明确的、确定的步骤。显然,我们知道 A 点将是第一步,所以它自动成为第一个父节点。

如果我们知道第一步是什么,我们如何找到第二步?A*必须检查父节点周围的所有八个单元,以发现哪一个是下一个最可能的候选。图 6-5 显示了作为父节点的点 A,以及它需要检查的所有周围节点。(如果这些节点中的任何一个碰巧是墙或不可通过的物体,它会忽略它们。)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-5。检查父节点周围的所有节点,看哪一个可能是路径中下一个最有可能的步骤

周围的每个单元格都将当前父节点声明为其父节点。在 A*算法中执行此操作的代码如下所示:

surroundingNode.parent = currentParent;

这一点很重要,因为这意味着 A*可以通过跟踪父节点来追踪到目的地的最佳路径。现在不要太担心这个,因为您将在前面的页面中看到它是如何工作的。请记住,每个节点都有一个父属性,用于跟踪路径中它所链接到的节点。

A然后需要找出从父节点到周围子节点的开销。原来这恰好是一个重要的数字,所以 A将这个代价称为 G,图 6-6 显示了所有周围节点的 G 代价。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-6。每个周围的节点都有一个成本,在 A*的术语中称为 G

A*然后计算出哪个周围节点离目的地 B 点更近。它计算从 B 点到每个周围节点的旅行成本。(挡在路上的墙被视为暂时不存在,但正如您将看到的,这将通过以后的测试得到补偿。)图 6-7 显示了从第一个周围节点到 b 点的路径,可以看到它计算出从那个节点到测试路径的开销为 54。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-7。计算从每个周围节点到 B 点的每条路径的开销

这种距离测试被称为启发式。启发式简单地说就是通过尝试和错误找出一些东西。(它源自希腊语作品 heuriskein ,意为寻找。)然而,启发式不是随机试错法。这是在一套逻辑规则中的反复试验,很可能会产生我们正在寻找的答案。A不知道周围的子节点中哪一个会以开销最小的路径结束,所以它只尝试所有八个。每条启发式路径的成本恰好也很重要,所以 A将这个成本称为 H

实际上有三种常用的计算启发式路径的方法:曼哈顿、欧几里德和对角线。我们将在本章后面的“理解试探法”一节中详细讨论每一个。现在,只要知道这些是计算从周围的测试节点到目的地点的距离的具体方法。

A*计算出每个周围子节点的 H 成本。如果将 H 和 G 成本结合起来,就会得出第三个成本,即最终成本,称为 F。图 6-8 显示了每个节点的所有 G、H 和 F 成本。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-8。通过将 G 和 H 成本相加,找出每个节点的最终 F 成本

获胜者是 F 成本最低的节点。你能看见吗?它是直接位于父节点右侧的一个,如图 6-9 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-9。具有最低 F 成本的节点成为路径中最有可能的下一步

该节点现在成为潜在的新父节点。A还不确定这是否是最好的第二步,但据它所知,这是一个继续检查的好地方。(事实上,这不是最好的第二步,但 A很快就会发现这一点,正如您将看到的。)

从图 6-9 中可以看到,每个父节点都代表了通往 b 点的路径中的一个潜在步骤。我使用了潜在步骤这个词,因为 A在做更多的检查之前并不确定任何给定的父节点是否是最佳步骤。你已经可以在图 6-9 中看到一个问题。新的父节点是而不是*最好的下一步。最好从 a 点沿对角线向上移动。图 6-10 对此进行了说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-10。显而易见的首选并不总是最好的。A*如何计算出从起点斜向上移动更好?

幸运的是,A有一个系统可以交叉检查节点,剔除像这样的低效节点。A通过保持两个列表来跟踪用于路由的可能的最佳父节点:

  • 封闭列表:这是一个不需要检查的节点列表。每当找到一个新的父节点,它就会被添加到这个列表中。

  • 开放列表:这是围绕每个父节点的所有节点的列表。它们是需要检查的节点。

当一个新的潜在父节点检查所有周围的节点时,它在开放列表上查看每个节点的先前 G 成本。图 6-11 显示了它正上方的节点之前的 G 成本是 14。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-11。旧的 G 成本是 14。它的新成本会多还是少?

为了找到它的新 G 成本,A*采用父节点的当前 G 值,再加上 10,这是向上移动一个节点的成本。这使得新的 G 成本达到 20 英镑。图 6-12 对此进行了说明。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-12。将父节点的 G 成本(10)与向上移动一个节点的成本(10)相加,以找到新的 G 成本(20)

如果发现新的开销更低,A*会将节点的父节点更改为当前父节点:

if (newG < oldG) {
  surroundingNode.parent = currentParent;
} else {
  don't change the surrounding node's parent
}

但是如果新的 G 成本更高,节点的父节点不会改变。这个例子就是这种情况。它将保留在第一步中分配给它的父节点,即开始节点。我们正在检查的当前父节点被排除在外。

这非常重要,因为 A*通过它们的父节点将节点链接在一起,就像一条链一样,从而创建了路径。

A*对所有其他周围的节点运行相同的检查,并根据它们需要运行新的父节点的情况来计算它们新的 G 开销,如图 6-13 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-13。通过路由经过当前父节点的路径,找出其他节点的 G 开销是否更小。他们不是

从图 6-13 可以看出,通过当前父节点,所有节点的 G 代价会更高。这意味着它们都不会将其父代更改为当前父代。现任家长是吐司!它没有孩子,所以它肯定不会是最短路径的一部分。

但是 A*仍然需要确定下一步测试哪个节点。它会忽略墙节点和上一个父节点。它选择 F 代价最低的下一个节点作为新的父节点,如图 6-14 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-14。具有最低 F 成本的周围节点成为新的父节点

但是,如果一些节点具有相同的成本,会发生什么呢?从图 6-13 可以看到,父节点正上方和正下方的节点并列第一,都是同样的低分 54。在这种情况下,A会选择循环中最先运行该检查的节点。如果恰好是错误的,这将在以后通过进一步的检查来纠正。但纯属偶然,这一次得分最低的第一个节点也恰好是更好的选择。它被选为新的父节点,A继续检查。

也很可能会有不止一条可能的最短路径。A*构建的是由它如何选择成本固定的节点决定的。

我们目前已经测试了两个节点,并选择了第三个,如图 6-15 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-15。三个节点是路径上的步骤的候选,但是哪一个将进行最后的调试?

但是这些节点中只有两个是路径的一部分。我们怎么知道?因为右上方的节点已经将开始节点指定为其父节点。这是将两个节点链接在一起的关系,如图 6-16 所示。它构成了道路的前两步。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-16。开始节点是右上节点的父节点。这种关系将节点链接在一起

通过父节点链接节点

A*然后继续遵循相同的逻辑,直到它到达目的节点,点 b。在一个非常大的游戏世界中,这可能涉及检查数百个节点。

当 A*最终构建路径时,它会跟踪从父节点到父节点的路径,以将起点和终点链接在一起。如图 6-17 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-17。最终路径中的每个节点都有一个对其父节点的引用

当 A到达目的地 B 点时,它停止检查。A然后只需要从 B 点向后工作,跟随父节点,来构造路径。A*算法产生一个数组,告诉你需要走过的每个节点,以找到从 A 点到 b 点的最短路线。

现在您已经理解了这个理论,让我们来看看实际的 JavaScript 代码。

代码中的 A*

现在应该很明显,节点在 A*宇宙中是非常重要的东西。每个节点需要存储相当多的信息:

  • 它的位置(它在网格上的行和列)。

  • 它的 G、H 和 F 成本。

  • 它的父节点。

因此,创建一个节点对象来存储这些信息是有意义的。

创建节点图

处理节点的第一步是创建一个叫做节点图的东西。节点地图是一个二维数组,与游戏的迷宫地图完全匹配。但是,节点图的每个单元都包含一个节点对象。这些节点对象将存储我们需要的所有重要的节点属性和值。

这里有一个名为 nodes 的简单函数,它为地图数组中的每个单元格返回一个 node 对象数组。

let nodes = (mapArray, mapWidthInTiles) => {
  return mapArray.map((cell, index) => {

**//Figure out the row and column of this cell** 
    let column = index % mapWidthInTiles;
    let row = Math.floor(index / mapWidthInTiles);

**//The node object** 
    return node = {
      f: 0,
      g: 0,
      h: 0,
      parent: null,
      column: column,
      row: row,
      index: index
    };
  });
};

每个节点对象都包括成本属性、对其父节点的引用以及行、列和索引信息,这些信息将使我们能够将节点与其在 map 数组中的位置相匹配。您将看到如何在我们接下来要看的 shortestPath 函数的上下文中使用它。

最短路径函数

我们 A*代码的核心是最短路径函数。它返回一个包含从 A 点到 b 点的最短路径的数组。下面是一个如何调用它的示例,包括要使用的参数。

 let path = shortestPath(
getIndex(alien.x, alien.y, 64, 64, 13),          **//The start map index** 
getIndex(g.pointer.x, g.pointer.y, 64, 64, 13)), **//The destination index** 
wallMapArray,                                    **//The map array** 
13,                                              **//Map width, in tiles** 
[2, 3],                                          **//Obstacle gid array** 
"manhattan"                                      **//Heuristic to use** 
);

如你所见,这些参数与我们之前看到的信息相匹配。我们还没有讨论的一件事是使用什么样的启发式方法。我将在“理解启发式”一节中解释启发式选项及其工作原理。

下面是整个 shortestPath 函数。除了一些额外的检查,它需要确保所有的数据都是有效的,正如我在描述 A*算法如何工作时解释的那样,它正在进行路径查找。通读所有注释,并尝试将代码与之前的描述匹配。

function shortestPath(
  startIndex,
  destinationIndex,
  mapArray,
  mapWidthInTiles,
  obstacleGids = [],
  heuristic = "manhattan"
) {

**//The `nodes` function creates the array of node objects** 
  let nodes = (mapArray, mapWidthInTiles) => {
    return mapArray.map((cell, index) => {

**//Figure out the row and column of this cell** 
      let column = index % mapWidthInTiles;
      let row = Math.floor(index / mapWidthInTiles);

**//The node object** 
      return node = {
        f: 0,
        g: 0,
        h: 0,
        parent: null,
        column: column,
        row: row,
        index: index
      };
    });
  };

**//Initialize the shortestPath array** 
  let shortestPath = [];

**//Initialize the node map** 
  let nodeMap = nodes(mapArray, mapWidthInTiles);

**//Initialize the closed and open list arrays** 
  let closedList = [];
  let openList = [];

**//Declare the "costs" of travelling in straight or diagonal lines** 
  let straightCost = 10;
  let diagonalCost = 14;

**//Get the start node** 
  let startNode = nodeMap[startIndex];

**//Get the current center node. The first one will** 
**//match the path's start position** 
  let centerNode = startNode;

**//Push the `centerNode` into the `openList`, because** 
**//it's the first node that we're going to check** 
  openList.push(centerNode)

**//Get the current destination node. The first one will** 
**//match the path's end position** 
  let destinationNode = nodeMap[destinationIndex];

**//All the nodes that are surrounding the current map index number** 
  let surroundingNodes = (index, mapArray, mapWidthInTiles) => {

**//Find out what all the surrounding nodes are (including those that** 
**//might be beyond the borders of the map – we’ll filter these out ahead** 
**//in the `validSurroundingNodes` function)** 
    let allSurroundingNodes = [
      nodeMap[index - mapWidthInTiles - 1],
      nodeMap[index - mapWidthInTiles],
      nodeMap[index - mapWidthInTiles + 1],
      nodeMap[index - 1],
      nodeMap[index + 1],
      nodeMap[index + mapWidthInTiles - 1],
      nodeMap[index + mapWidthInTiles],
      nodeMap[index + mapWidthInTiles + 1]
    ];

**//Optionally exlude the diagonal nodes, which is often perferable** 
**//for 2D maze games** 
    let crossSurroundingNodes = [
      nodeMap[index - mapWidthInTiles],
      nodeMap[index - 1],
      nodeMap[index + 1],
      nodeMap[index + mapWidthInTiles],
    ];

**//Find the valid sourrounding nodes, which are ones inside** 
**//the map border that don't incldue obstacles. Optionally change `allSurroundingNodes`** 
**//to `crossSurroundingNodes` to prevent the path from choosing diagonal routes** 
**//between nodes** 
    let validSurroundingNodes = allSurroundingNodes.filter(node => {

**//The node will be beyond the top and bottom edges of the** 
**//map if it is `undefined`** 
      let nodeIsWithinTopAndBottomBounds = node !== undefined;

**//Only return nodes that are within the top and bottom map bounds** 
      if (nodeIsWithinTopAndBottomBounds) {

**//Some Boolean values that tell us whether the current map index is on** 
**//the left or right border of the map, and whether any of the nodes** 
**//surrounding that index extend beyond the left and right borders** 
        let indexIsOnLeftBorder = index % mapWidthInTiles === 0
        let indexIsOnRightBorder = (index + 1) % mapWidthInTiles === 0
        let nodeIsBeyondLeftBorder
          = node.column % (mapWidthInTiles - 1) === 0
          && node.column !== 0;
        let nodeIsBeyondRightBorder = node.column % mapWidthInTiles === 0

**//Find out whether of not the node contains an obstacle by looping** 
**//through the obstacle gids and and returning `true` if it** 
**//finds any at this node's location** 
        let nodeContainsAnObstacle = obstacleGids.some(obstacle => {
          return mapArray[node.index] === obstacle;
        });

**//If the index is on the left border and any nodes surrounding it are beyond the** 
**//left border, don't return that node** 
        if (indexIsOnLeftBorder) {
          return !nodeIsBeyondLeftBorder;
        }

**//If the index is on the right border and any nodes surrounding it are beyond the** 
**//right border, don't return that node** 
        else if (indexIsOnRightBorder) {
          return !nodeIsBeyondRightBorder;
        }

**//Return `false` if the node contains an obstacle** 
        else if (nodeContainsAnObstacle) {
          return false;
        }

**//If this passes the checks above, it means the index must be** 
**//inside the area defined by the left and right borders.** 
**//So, return the node** 
        else {
          return true;
        }
      }
    });

**//Return the array of `validSurroundingNodes`** 
    return validSurroundingNodes;
  };

**//Heuristic methods** 
**//1\. Manhattan** 
  let manhattan = (testNode, destinationNode) => {
    let h
      = Math.abs(testNode.row - destinationNode.row)
      * straightCost + Math.abs(testNode.column - destinationNode.column)
      * straightCost;
    return h;
  };

**//2\. Euclidean** 
  let euclidean = (testNode, destinationNode) => {
    let vx = destinationNode.column - testNode.column,
      vy = destinationNode.row - testNode.row,
      h = Math.floor(Math.sqrt(vx * vx + vy * vy) * straightCost);
    return h;
  };

**//3\. Diagonal** 
  let diagonal = (testNode, destinationNode) => {
    let vx = Math.abs(destinationNode.column - testNode.column),
      vy = Math.abs(destinationNode.row - testNode.row),
      h = 0;

    if (vx > vy) {
      h = Math.floor(diagonalCost * vy + straightCost * (vx - vy));
    } else {
      h = Math.floor(diagonalCost * vx + straightCost * (vy - vx));
    }
    return h;
  };

**//Loop through all the nodes until the current `centerNode` matches the** 
**//`destinationNode`. When they're the same we know we've reached the** 
**//end of the path** 
  while (centerNode !== destinationNode) {

**//Find all the nodes surrounding the current `centerNode`** 
    let surroundingTestNodes = surroundingNodes(centerNode.index, mapArray, mapWidthInTiles);

**//Loop through all the `surroundingTestNodes` using a classic `for` loop** 
**//(A `for` loop gives us a marginal performance boost. A* is extremely performance** 
**//hungery, so even a small performance boost with each loop iteration can** 
**//amount to a significant boost overall)** 
    for (let i = 0; i < surroundingTestNodes.length; i++) {

**//Get a reference to the current test node** 
      let testNode = surroundingTestNodes[i];

**//Find out whether the node is on a straight axis or** 
**//a diagonal axis, and assign the appropriate cost** 

**//A. Declare the cost variable** 
      let cost = 0;

**//B. Do they occupy the same row or column?** 
      if (centerNode.row === testNode.row || centerNode.column === testNode.column) {

**//If they do, assign a cost of "10"** 
        cost = straightCost;
      } else {

**//Otherwise, assign a cost of "14"** 
        cost = diagonalCost;
      }

**//C. Calculate the costs (g, h and f)** 
**//The node's current cost** 
      let g = centerNode.g + cost;

**//The cost of travelling from this node to the** 
**//destination node (the heuristic)** 
      let h;
      switch (heuristic) {
        case "manhattan":
          h = manhattan(testNode, destinationNode);
          break;

        case "euclidean":
          h = euclidean(testNode, destinationNode);
          break;

        case "diagonal":
          h = diagonal(testNode, destinationNode);
          break;

        default:
          throw new Error("Oops! It looks like you misspelled the name of the heuristic");
      }

**//The final cost** 
      let f = g + h;

**//Find out if the testNode is in either** 
**//the openList or closedList array** 
      let isOnOpenList = openList.some(node => testNode === node);
      let isOnClosedList = closedList.some(node => testNode === node);

**//If it's on either of these lists, we can check** 
**//whether this route is a lower-cost alternative** 
**//to the previous cost calculation. The new G cost** 
**//will make the difference to the final F cost** 
      if (isOnOpenList || isOnClosedList) {
        if (testNode.f > f) {
          testNode.f = f;
          testNode.g = g;
          testNode.h = h;

**//Only change the parent if the new cost is lower** 
          testNode.parent = centerNode;
        }
      }

**//Otherwise, add the testNode to the open list** 
      else {
        testNode.f = f;
        testNode.g = g;
        testNode.h = h;
        testNode.parent = centerNode;
        openList.push(testNode);
      }

**//The `for` loop ends here** 
    }

**//Push the current centerNode into the closed list** 
    closedList.push(centerNode);

**//Quit the loop if there's nothing on the open list.** 
**//This means that there is no path to the destination or the** 
**//destination is invalid, like a wall tile** 
    if (openList.length === 0) {
      return shortestPath;
    }

**//Sort the open list according to final cost** 
    openList = openList.sort((a, b) => a.f - b.f);

**//Set the node with the lowest final cost as the new centerNode** 
    centerNode = openList.shift();

**//The `while` loop ends here** 
  }

**//Now that we have all the candidates, let's find the shortest path!** 
  if (openList.length !== 0) {

**//Start with the destination node** 
    let testNode = destinationNode;
    shortestPath.push(testNode);

**//Work backwards through the node parents** 
**//until the start node is found** 
    while (testNode !== startNode) {

**//Step through the parents of each node,** 
**//starting with the destination node and ending with the start node** 
      testNode = testNode.parent;

**//Add the node to the beginning of the array** 
      shortestPath.unshift(testNode);

**//...and then loop again to the next node's parent till you** 
**//reach the end of the path** 
    }
  }

**//Return an array of nodes that link together to form** 
**//the shortest path** 
  return shortestPath;
}

使用最短路径函数

在本章的源文件中,你会找到一个名为 shortestPath.html 的文件夹。运行程序,你会看到一个外星角色坐在一个简单的迷宫环境中。用鼠标点击任意位置,程序会画出从外星人所在位置到鼠标所在位置的最短路径,如图 6-18 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-18。单击任意位置绘制最短路径

这个程序的工作原理是计算外星人精灵和鼠标位置之间的最短路径。如您所知,shortestPath 函数返回一个节点数组。每个节点都有一个行和列属性,我们可以使用该信息在屏幕上为数组中的每个节点显示一个黑色的方形精灵。

下面是来自程序设置函数的相关代码。一个名为 currentPathSprites 的数组中填充了黑色的方形 Sprites,每次释放鼠标指针时,这些 sprites 都会匹配最短路径的节点。

**//An array to store the sprites that will be used to display** 
**//the shortest path** 
currentPathSprites = [];

**//The mouse pointer's `release` function runs the code that** 
**//calculates the shortest path and draws that sprites that** 
**//represent it** 
g.pointer.release = () => {

**//calculate the shortest path** 
  let path = shortestPath(
getIndex(alien.x, alien.y, 64, 64, 13),         **//Start map index** 
getIndex(g.pointer.x, g.pointer.y, 64, 64, 13), **//End index** 
wallMapArray,                                   **//Map array** 
13,                                             **//Map width** 
[2, 3],                                         **//Obstacle gids** 
"manhattan"                                     **//Heuristic** 
  );

**//Use Hexi's `remove` method to remove any possible** 
**//sprites in the `currentPathSprites` array** 
  g.remove(currentPathSprites);

**//Display the shortest path** 
  path.forEach(node => {

**//Figure out the x and y location of each square in the path by** 
**//multiplying the node's `column` and `row` by the height, in** 
**//pixels, of each square: 64** 
    let x = node.column * 64,
        y = node.row * 64;

**//Create the square sprite and set it to the x and y location** 
**//we calculated above** 
    let square = g.rectangle(64, 64, "black");
    square.x = x;
    square.y = y;

**//Push the sprites into the `currentPath` array,** 
**//so that we can easily remove them the next time** 
**//the mouse is clicked** 
    currentPathSprites.push(square);
  });
};

为了完整起见,您会注意到上面的代码使用了一个方便的函数 remove,它内置在渲染引擎中。它的工作是从渲染器中移除单个精灵或精灵数组中的任何精灵。下面是完成这项工作的 remove 函数,以防您需要在自己的程序中做类似的事情:

function remove(...sprites) {

**//Remove sprites that's aren't in an array** 
  if (!(sprites[0] instanceof Array)) {
    if (sprites.length > 1) {
      sprites.forEach(sprite  => {
        sprite.parent.removeChild(sprite);
      });
    } else {
      sprites[0].parent.removeChild(sprites[0]);
    }
  }

**//Remove sprites in an array of sprites** 
  else {
    let spritesArray = sprites[0];
    if (spritesArray.length > 0) {
      for (let i = spritesArray.length - 1; i >= 0; i--) {
        let sprite = spritesArray[i];
        sprite.parent.removeChild(sprite);
        spritesArray.splice(spritesArray.indexOf(sprite), 1);
      }
    }
  }
}

可以看到,只要有了 shortestPath 数组,就有很多方法可以使用它。在前面的例子中,我将向您展示如何使用它来让游戏角色走过迷宫。但首先,让我们来看一个我到目前为止一直在策略上回避的话题:启发式。

理解启发式

到达目的地的最短路径通常不止一条,如图 6-19 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-19。三条路径长度相同,但它们选择的路线不同

没有哪条路径比另一条路径更好或更差,它们都具有相同的成本。但每个都有独特的风格。这种风格依赖于 A*用来计算路径的试探法。

试探法是一种迷你算法,它的工作是根据一个简单的公式计算出距离。三种著名的试探法经常与 A*一起使用:曼哈顿法、欧几里德法和对角线法。图 6-20 显示了最短路径函数中每个启发式算法产生的不同路径。你更喜欢哪个?

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-20。不同的试探法产生不同的路径

shortestPath 函数根据提供给它的最后一个参数选择要使用的启发式算法。

let path = shortestPath(
  getIndex(alien.x, alien.y, 64, 64, 13),
  getIndex(g.pointer.x, g.pointer.y, 64, 64, 13),
  wallMapArray,
  13,
  [2, 3],
**"manhattan"** 
);

while 循环中的 switch 语句通过将工作委托给指定的启发式方法来查找 H 的值。

let h;
switch (heuristic) {
  case "manhattan":
    h = manhattan(testNode, destinationNode);
    break;

  case "euclidean":
    h = euclidean(testNode, destinationNode);
    break;

  case "diagonal":
    h = diagonal(testNode, destinationNode);
    break;

  default:
    throw new Error("Oops! It looks like you misspelled the name of the heuristic");
}

每种启发式方法以不同的方式计算起点和终点之间的距离。曼哈顿方法是最简单的。它只是将行和列相加,然后将总和乘以成本。它忽略任何可能的对角线捷径。

let manhattan = (testNode, destinationNode) => {
  let h
    = Math.abs(testNode.row - destinationNode.row)
    * straightCost + Math.abs(testNode.column - destinationNode.column)
    * straightCost;
  return h;
};

它之所以被称为曼哈顿,是因为如果你走在纽约市(曼哈顿岛)的街道上,你将无法通过任何一个街区走对角线的捷径。

忽略可能的对角路线使得曼哈顿启发式算法处理起来更快。这一点很重要,因为 A*是一个极度消耗 CPU 的算法。如果你需要在每一帧上为很多游戏角色做寻路,曼哈顿会为你节省一些性能影响。但是,因为它不考虑对角线路由,所以它可能不总是保证绝对最短路径。

欧几里得方法使用勾股定理来计算距离。

let euclidean = (testNode, destinationNode) => {
  let vx = destinationNode.column - testNode.column,
    vy = destinationNode.row - testNode.row,
    h = Math.floor(Math.sqrt(vx * vx + vy * vy) * straightCost);
  return h;
};

欧几里得方法确实考虑到了对角线,所以它产生了看起来非常自然的路径。但是,由于饥饿的 Math.sqrt 方法,它的处理速度比曼哈顿方法稍慢。

对角线法补偿了直线穿过或对角移动的成本,因此最终得到非常准确的成本估计。这意味着 A*可能需要做更少的搜索并产生更快的结果,它肯定会产生最短的可能路径。

let diagonal = (testNode, destinationNode) => {
  let vx = Math.abs(destinationNode.column - testNode.column),
    vy = Math.abs(destinationNode.row - testNode.row),
    h = 0;

  if (vx > vy) {
    h = Math.floor(diagonalCost * vy + straightCost * (vx - vy));
  } else {
    h = Math.floor(diagonalCost * vx + straightCost * (vy - vx));
  }
  return h;
};

没有一种正确的启发式方法可以使用。你只需要决定哪种路径对你正在制作的游戏来说是最自然的。

圆角

我们当前的 A*算法有一个潜在的问题:它通过在单元格之间选择对角线捷径来计算最短路径。这是准确的,但它给迷宫游戏带来了一个问题。在大多数迷宫游戏中,你会希望你的角色沿着墙的边缘行走,所以沿着对角线抄近路看起来会很奇怪。图 6-21 说明了这种困境。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-21。迷宫游戏路径通常不应该在拐角处对角切割

有一个简单的解决方案可以防止路径在拐角处走对角线捷径:不要检查任何与当前中心测试节点对角线相邻的节点。在我们的示例 A*实现中的代码的 current surroundingNodes 函数中,测试了当前中心节点周围的所有八个节点:

let al**lSurroundingNodes** = [
  nodeMap[index - mapWidthInTiles - 1],
  nodeMap[index - mapWidthInTiles],
  nodeMap[index - mapWidthInTiles + 1],
  nodeMap[index - 1],
  nodeMap[index + 1],
  nodeMap[index + mapWidthInTiles - 1],
  nodeMap[index + mapWidthInTiles],
  nodeMap[index + mapWidthInTiles + 1]
];

let validSurroundingNodes = **allSurroundingNodes**.filter(node => {/*...*/};

为了防止出现对角线,只需测试中心节点正上方、正下方以及正左右的节点:

let **crossSurroundingNodes** = [
  nodeMap[index - mapWidthInTiles],
  nodeMap[index - 1],
  nodeMap[index + 1],
  nodeMap[index + mapWidthInTiles],
];

let validSurroundingNodes = **crossSurroundingNodes**.filter(node => {/*...*/};

这就是全部了!

走在小路上

现在我们知道如何找到一条路,我们需要教我们的游戏角色如何沿着这条路走。你可以在 walkPath.html 项目中找到一个这样的例子。点击迷宫中的任何地方,外星人精灵将采取最短的路线到达那里。A*算法使用我们刚刚看到的修改来允许路径圆角,如图 6-22 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-22。点击地图上的任何地方,游戏角色就会走到那里

这是通过使用 shortestPath 函数创建一个新的 x/y 点的 2D 数组来实现的。这些点被称为路点。每个路点代表路径中每个节点的 x/y 位置。这些点然后被用来告诉精灵向哪个方向移动。让我们看看让所有这些工作的代码。

首先,setup 函数定义了指针的释放方法。它在两个新变量 destinationX 和 destinationY 中捕获指针的 x 和 y 位置。它还将一个名为 calculateNewPath 变量的布尔变量设置为 true,以标记在下一个机会应该计算一个新路径。setup 函数还定义了一个名为 wayPoints2DArray 的变量,稍后将使用该变量来存储 x/y 道路点对。

**//An array that will be used to store sub-arrays of** 
**//x/y position value pairs that we're going to use** 
**//to change the velocity of the alien sprite** 
wayPoints2DArray = [];

**//A Boolean that will be set to true when the pointer** 
**//is clicked, and set to false when the new path** 
**//is calculated** 
calculateNewPath = false;

**//The pointer’s `release` method, which is called when the left mouse button** 
**//or touch point is released** 
g.pointer.release = () => {

**//Set the new path's destination to the pointer's** 
**//current x and y position** 
  destinationX = g.pointer.x;
  destinationY = g.pointer.y;

**//Set `calculateNewPath` to true** 
  calculateNewPath = true;
};

其余的重要代码在游戏循环中,在我们在本书中使用的实现中,发生在一个名为 play 的函数中。这里是完整的代码,注释解释了每个部分是如何工作的。(这段代码使用了您在前面章节中学到的 isCenteredOverCell 和 getIndex 辅助函数。)

function play() {

**//Find out if the alien is centered over a tile cell** 
  if (isCenteredOverCell(alien)) {

**//If `calculateNewPath` has been set to `true` by the pointer,** 
**//find the new shortest path between the alien and the pointer's** 
**//x and y position (`destinationX` and `destinationY`)** 
    if (calculateNewPath) {

**//calculate the shortest path** 
      let path = shortestPath(
getIndex(alien.centerX, alien.centerY, 64, 64, 13), **//Start index** 
getIndex(destinationX, destinationY, 64, 64, 13),   **//End index** 
wallMapArray,                                       **//Map array** 
13,                                                 **//Map width** 
[2, 3],                                             **//Gid array** 
"manhattan"                                         **//Heuristic** 
      );

**//Remove the first node of the `path` array. That's because we** 
**//don't need it: the alien sprite's current location and the** 
**//first node in the `path` array share the same location.** 
**//In the code ahead we're going to tell the alien sprite to move** 
**//from its current location, to first new node in the path.** 
      path.shift();

**//If the path isn't empty, fill the `wayPoints2DArray` with** 
**//sub arrays of x/y position value pairs.** 
      if (path.length !== 0) {

**//Get a 2D array of x/y points** 
        wayPoints2DArray = path.map(node => {

**//Figure out the x and y location of each square in the path by** 
**//multiplying the node's `column` and `row` by the height, in** 
**//pixels, of each cell: 64** 
          let x = node.column * 64,
              y = node.row * 64;

**//Return a sub-array containing the x and y position of each node** 
          return [x, y];
        });
      }

**//Set `calculateNewPath` to `false` so that this block of code.** 
**//won't run again inside the game loop. (It can be set to `true`** 
**//again by clicking the pointer.)** 
      calculateNewPath = false;
    }

**//Set the alien's new velocity based on** 
**//the alien's relative x/y position to the current, next, way point.** 
**//Because we are always going to** 
**//remove a way point element after we set this new** 
**//velocity, the first element in the `wayPoints2DArray`** 
**//will always refer to the next way point that the** 
**//alien sprite has to move to** 
    if (wayPoints2DArray.length !== 0) {

**//Left** 
      if (wayPoints2DArray[0][0] < alien.x) {
        alien.vx = -4;
        alien.vy = 0;

**//Right** 
      } else if (wayPoints2DArray[0][0] > alien.x) {
        alien.vx = 4;
        alien.vy = 0;

**//Up** 
      } else if (wayPoints2DArray[0][1] < alien.y) {
        alien.vx = 0;
        alien.vy = -4;

**//Down** 
      } else if (wayPoints2DArray[0][1] > alien.y) {
        alien.vx = 0;
        alien.vy = 4;
      }

**//Remove the current way point, so that next time around** 
**//the first element in the `wayPoints2DArray` will correctly refer** 
**//to the next way point that that alien sprite has** 
**//to move to** 
      wayPoints2DArray.shift();

**//If there are no way points remaining,** 
**//set the alien's velocity to 0** 
    } else {
      alien.vx = 0;
      alien.vy = 0;
    }
  }

**//Move the alien sprite based on the new velocity** 
  alien.x += alien.vx;
  alien.y += alien.vy;
}

在这个例子中,每当用户点击鼠标时,一个新的路径被计算出来,但是你可以在你自己的游戏中改变这个,这样,每当有任何重要的事情发生,导致精灵改变方向时,路径就会被计算出来。

扩展和定制*

你肯定需要投入一些时间来适应使用 A*并理解它的所有微妙之处。但这肯定是值得努力的,因为它是大多数游戏类型的所有平台上使用的基础游戏设计技术。

A的吸引力很大一部分在于它的灵活性。正如你所看到的,你可以通过改变启发式算法产生不同的路径。但是不仅仅是启发法让 A如此灵活。让我们看看 A*算法提供的其他一些可能性。

可变地形

在本章的例子中,我们只有一种障碍:墙。然而,你在游戏中可能会遇到不同种类的障碍,并非所有的障碍都是无法逾越的。

如果你有一个泥坑游戏会怎么样?角色仍然可以在泥泞中移动,但这会减慢他们的速度。您可以修改 A*,使包含 mud 的节点具有高开销。比如当 A遇到 mud 节点时,给你的 G 代价额外 20 或 30 点。然后 A会计算是绕过泥地快还是抄近路快。

策略游戏一直使用这种技术。部队需要考虑是坚守平原快速行进好,还是慢慢翻山越岭好。这种分析都是通过 A*对不同种类的移动进行成本分析来完成的。

影响力地图

这是另一个有趣的问题,可以用 A解决。想象一下,你有一个敌人 AI 角色正在用 A寻找去地牢的最佳出口。唯一的问题是,你已经发现,你可以很容易地通过藏在出口附近,并在敌人盲目蹒跚而过时击倒他们,从而获得高分。敌人没有办法警告他们的朋友,虽然这可能是最短的路线,但也非常危险。

你可以通过使用所谓的影响图来解决这个问题。如果游戏世界的某个区域变得特别危险,那就让那些节点付出非常高的代价。当 A*搜索路径时,它会避开那些昂贵、危险的区域。

你也可以扩展这个概念来解决很多敌人走同一条路的问题。在许多游戏中,如果所有的敌人都选择相同的最短路径,会显得非常不自然。您可以通过跟踪每个节点选择的路径,并为这些节点分配高成本,来迫使敌人采取不同的路径。A*将会避开已经被其他敌人选择的节点和路径。

Dijkstra 算法

前面我提到过 A是 Dijkstra 算法的修改。在 A上使用 Dijkstra 算法有一个很好的理由:当你不知道路径的最终目的地时。

A和 Dijkstra 算法的唯一区别是 A增加了启发式。在 Dijkstra 的算法中,H 的值总是为零。这意味着当 Dijkstra 的算法开始寻找路径时,它不知道从哪个方向开始寻找。为了找到目标,它必须做比 A*更多的搜索。

但是如果你在一个游戏中不确定角色的最终目的地会是哪里呢?想象一下,你正在设计一个策略或资源管理游戏,你的村民需要收集草莓。小镇周围有四片草莓灌木丛,但你不知道哪片灌木丛离你最近。如果用 Dijkstra 的算法,它会向四面八方向外搜索,直到找到第一个。如果你使用*的话,你将需要计算到每个灌木丛的四条不同的路径,并选择最短的一条。Dijkstra 的算法让你不必这么做,所以在这种情况下它会是一个更好的选择。

如果你想用 Dijkstra 的算法而不是 A*,只需要给所有的 H 代价赋值 0。A*代码的其余部分将是相同的。

不要多此一举!写这一章是为了让你完全理解 A是如何工作的。它旨在帮助您定制、重写和修改它。但是,JavaScript 中有很多高质量的、开源的 A实现,它们可能更高性能、更灵活,或者更容易实现。Easystar.js 和 Pathfinding.js 是很好的起点。

摘要

本章简要介绍了寻路,这应该会让你思考你自己的游戏有哪些可能。冒险游戏、策略游戏和任何需要复杂人工智能的游戏都将受益于这些技术。你已经学会了从头开始实现你自己的 A*寻路算法所需要知道的一切,以及如何使用它在你基于磁贴的游戏世界中移动精灵。

但是我们绝不会放弃基于磁贴的游戏!在下一章中,你将会学到更多基于图块的游戏设计的秘密:如何使用图块轻松地创建令人惊讶的复杂、自主的 AI 实体,以及如何实现有效的宽相位碰撞检测系统。

七、基于磁贴的更有趣的游戏

本书的大部分内容都在探索基于图块的游戏设计方法如何帮助你以简单高效的方式解决一些复杂的问题。它可以帮助你改善和简化一切,从关卡设计,碰撞,游戏逻辑,人工智能到寻路。但是如果没有对另外两个基于磁贴的设计策略的探索,这本书就不完整,你肯定会发现它们在你的游戏中有很多用处:

  • 隐藏游戏数据:你可以在地图单元格中隐藏游戏的特殊信息,这些信息可以用来创建复杂的人工智能实体。在本章中,您将看到如何为一个简单的赛车游戏原型创建一辆自动驾驶汽车。

  • 宽相位碰撞检测:通过只检查可能发生碰撞的物体之间的碰撞,提高基于计算密集型物理的游戏的性能。你将通过实现一个被称为空间网格的通用轻量级碰撞系统来学习如何操作。

所以,系好安全带,发动引擎——我们准备上路了!

为人工智能系统使用额外的游戏数据

我们将从如何为赛车驾驶游戏创建一辆计算机控制的汽车开始这一章。但在此之前,让我们来看看如何创建一辆人类控制的赛车,它可以使用键盘在赛道上导航。运行本章源文件中的 drivingGame.html 文件,得到一个工作原型,如图 7-1 所示。使用键盘驾驶汽车在赛道上行驶。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-1。使用键盘驾驶汽车在赛道上行驶,但注意不要陷入草地

这个小原型还实现了驾驶游戏最令人讨厌的必备功能之一:如果你开出了赛道,你的车就会陷在草丛里,减速。感谢我们基于瓷砖的设计系统的优雅,这是一个非常容易实现的特性,您将在前面的章节中找到所有关于它的内容。但首先,让我们快速看一下这个小原型是如何组装的。

下面你会找到让这个游戏运行的整个 JavaScript 文件。大部分代码都非常熟悉,所以可以把它看作是对基于磁贴的游戏世界构建基础的快速回顾。新的是玩家车的控制系统。代码监听键盘箭头键的按下:如果按下了向上箭头,则一个 moveForward 布尔值被设置为 true 如果左右箭头键被按下,汽车的转速被设置。这些值然后在游戏循环(播放功能)中使用,以旋转和移动汽车。下面是完整的代码清单,带有解释一切工作原理的注释。

**//Load any assets used in this game** 
let thingsToLoad = [
  "img/tileSet.png",
];

**//Create a new Hexi instance, and start it** 
let g = hexi(640, 512, setup, thingsToLoad);

**//Scale the canvas to the maximum browser dimensions** 
g.scaleToWindow();

**//Start the game engine** 
g.start();

**//Intiialize variables** 
let car, world;

function setup() {

**//Create the `world` container that defines our tile-based world** 
  world = g.group();

**//Set the `tileWidth` and `tileHeight` of each tile, in pixels** 
  world.tileWidth = 64;
  world.tileHeight = 64;

**//Define the width and height of the world, in tiles** 
  world.widthInTiles = 10;
  world.heightInTiles = 8;

**//Create the world layers** 
  world.layers = [

**//The environment layer. `1` represents the grass,** 
**//`2` represents the track** 
    [
      1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
      1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
      1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
      1, 2, 2, 1, 1, 1, 1, 2, 2, 1,
      1, 2, 2, 1, 1, 1, 1, 2, 2, 1,
      1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
      1, 2, 2, 2, 2, 2, 2, 2, 2, 1,
      1, 1, 1, 1, 1, 1, 1, 1, 1, 1
    ],

**//The character layer. `3` represents the car** 
**//`0` represents an empty cell which won't contain any** 
**//sprites** 
    [
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 3, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0
    ]
  ];

**//Build the game world by looping through each** 
**//of the layer arrays one after the other** 
  world.layers.forEach(layer => {

**//Loop through each array element** 
    layer.forEach((gid, index) => {

**//If the cell isn't empty (0) then create a sprite** 
      if (gid !== 0) {

**//Find the column and row that the sprite is on and also** 
**//its x and y pixel values that match column and row position** 
        let column, row, x, y;
        column = index % world.widthInTiles;
        row = Math.floor(index / world.widthInTiles);
        x = column * world.tileWidth;
        y = row * world.tileHeight;

**//Next, create a different sprite based on what its** 
**//`gid` number is** 
        let sprite;
        switch (gid) {

**//The track** 
          case 1:
            sprite = g.sprite(g.frame("img/tileSet.png", 192, 64, 64, 64));
            break;

**//The grass** 
          case 2:
            sprite = g.sprite(g.frame("img/tileSet.png", 192, 0, 64, 64));
            break;

**//The car** 
          case 3:
            sprite = g.sprite(g.frame("img/tileSet.png", 192, 192, 48, 48));
            car = sprite;
        }

**//Position the sprite using the calculated `x` and `y` values** 
**//that match its column and row in the tile map** 
        sprite.x = x;
        sprite.y = y;

**//Add the sprite to the `world` container** 
        world.addChild(sprite);
      }
    });
  });

**//Add some physics properties to the car** 
  car.vx = 0;
  car.vy = 0;
  car.accelerationX = 0.2;
  car.accelerationY = 0.2;
  car.rotationSpeed = 0;
  car.friction = 0.96;
  car.speed = 0;

**//Set the car's center rotation point to the middle of the sprite** 
  car.setPivot(0.5, 0.5);

**//Whether or not the car should move forward** 
  car.moveForward = false;

**//Define the arrow keys to move the car** 
  let leftArrow = g.keyboard(37),
    upArrow = g.keyboard(38),
    rightArrow = g.keyboard(39),
    downArrow = g.keyboard(40);

**//Set the car's `rotationSpeed` to -0.1 (to rotate left) if the** 
**//left arrow key is being pressed** 
  leftArrow.press = () => {
    car.rotationSpeed = -0.05;
  };

**//If the left arrow key is released and the right arrow** 
**//key isn't being pressed down, set the `rotationSpeed` to 0** 
  leftArrow.release = () => {
    if (!rightArrow.isDown) car.rotationSpeed = 0;
  };

**//Do the same for the right arrow key, but set** 
**//the `rotationSpeed` to 0.1 (to rotate right)** 
  rightArrow.press = () => {
    car.rotationSpeed = 0.05;
  };

  rightArrow.release = () => {
    if (!leftArrow.isDown) car.rotationSpeed = 0;
  };

**//Set `car.moveForward` to `true` if the up arrow key is** 
**//pressed, and set it to `false` if it's released** 
  upArrow.press = () => {
    car.moveForward = true;
  };
  upArrow.release = () => {
    car.moveForward = false;
  };

**//Start the game loop by setting the game state to `play`** 
  g.state = play;
}

**//The game loop** 
function play() {

**//Use the `rotationSpeed` to set the car's rotation** 
  car.rotation += car.rotationSpeed;

**//If `car.moveForward` is `true`, increase the speed** 
  if (car.moveForward) {
    car.speed += 0.05;
  }

**//If `car.moveForward` is `false`, use** 
**//friction to slow the car down** 
  else {
    car.speed *= car.friction;
  }

**//Use the `speed` value to figure out the acceleration in the** 
**//direction of the car’s rotation** 
  car.accelerationX = car.speed * Math.cos(car.rotation);
  car.accelerationY = car.speed * Math.sin(car.rotation);

**//Apply the acceleration and friction to the car's velocity** 
  car.vx = car.accelerationX
  car.vy = car.accelerationY
  car.vx *= car.friction;
  car.vy *= car.friction

**//Apply the car's velocity to its position to make the car move** 
  car.x += car.vx;
  car.y += car.vy;

**//Slow the car down if it's stuck in the grass** 

**//First find the car's map index position (using** 
**//the `getIndex` helper function)** 
  let carIndex = getIndex(car.x, car.y, 64, 64, 10);

**//Get a reference to the race track map** 
  let trackMap = world.layers[0];

**//Slow the car if it's on a grass tile (gid 1) by setting** 
**//the car's friction to 0.25, to make it sluggish** 
  if (trackMap[carIndex] === 1) {
    car.friction = 0.25;
  }

**//If the car isn't on a grass tile, restore its** 
**//original friction value** 
  else {
    car.friction = 0.96;
  }

}

上面的最后几行是当汽车驶入草地时减速的原因。代码比较汽车在地图上的索引位置。如果它在草地上(gid 为 1),那么通过将乘数设置为 0.25 来增加汽车的摩擦力。否则车必须在赛道上,所以摩擦乘数设为正常,0.96。

在数组中存储隐藏的游戏数据

下一步是添加一辆人工智能控制的汽车,它可以自己在赛道上导航。为了做到这一点,我们将创建一个新的地图数组来存储关于 AI 汽车应该转向哪个方向的信息,这取决于它在赛道上的位置。这些数据是“隐藏的”,因为游戏的玩家并不知道它的存在——它只是被游戏的人工智能系统用来做决定。

您已经看到了地图数组不仅仅用于绘制地图,还可以帮助解释游戏世界。在我们刚刚看到的驾驶游戏示例中,数组中的草地数据不仅有助于在屏幕上绘制草地瓷砖,而且在游戏逻辑中也起着至关重要的作用。基于图块的游戏的强大之处在于,地图数组数据包含有意义的信息,这些信息可以在游戏中用于从显示器到 AI 系统的所有事情。

你可以更进一步。如果你在数组中存储的数据包含更多关于游戏世界的信息,而不仅仅是你在屏幕上看到的信息,那会怎么样?

假设您正在创建一个幻想角色扮演游戏,玩家可以施放影响游戏世界一部分的法术。吟游诗人角色施放了一个不和谐的咒语,使得所有的敌人从施放该咒语的游戏地图区域逃跑。你将如何向游戏描述这些信息?

你可以创建一个“法术地图”来匹配游戏世界的大小。你可以用某种代码,比如 1,来标记世界上所有受到不和谐声音影响的地方。

let spellMap = [
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,1,0,0,0,0,0,
  0,0,0,1,1,1,0,0,0,0,
  0,0,0,1,1,1,0,0,0,0,
  0,0,0,0,1,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0,
  0,0,0,0,0,0,0,0,0,0
];

然后,敌人可以考虑这些信息,并决定他们是否要冒着耳膜破裂的风险进入这些瓷砖。

这些信息是不可见的;只是被游戏的逻辑所利用。当你开始习惯于用基于瓦片的方式思考你的游戏时,你会发现许多复杂的问题可以用这样的游戏数据数组轻松解决。

那么,我们如何才能像这样添加一些隐藏的游戏数据来帮助我们创建一个人工智能控制的赛车呢?

添加人工智能控制的汽车

运行 aiDrivingGame.html 程序,如图 7-2 所示。现在你有了一个对手:一辆由人工智能控制的机器人汽车,它尽最大努力让你在赛道上比赛。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-2。一辆 AI 对手车在赛道上比赛

人工智能汽车没有遵循预先编写的动画,也没有专用的人工智能控制器。相反,它正在读取一组数字,告诉它应该如何根据它所在的地图单元来尝试调整自己的角度。它在跟随一张看不见的“角度地图”角度地图是游戏的第三个地图层,它定义了地图中每个单元的角度,以度为单位。

[
  45,   45,  45,  45,  45,  45,  45,  45, 135, 135,
  315,   0,   0,   0,   0,   0,   0,  90, 135, 135,
  315,   0,   0,   0,   0,   0,   0,  90, 135, 135,
  315, 315, 270, 315, 315, 315, 315,  90,  90, 135,
  315, 315, 270, 135, 135, 135, 135,  90,  90, 135,
  315, 315, 270, 180, 180, 180, 180, 180, 225, 135,
  315, 315, 270, 180, 180, 180, 180, 180, 225, 135,
  315, 270, 270, 225, 225, 225, 225, 225, 225, 225
]

这些角度告诉人工智能汽车应该根据它所在的单元尝试确定自己的方向。(0 度直接指向右边,3 点钟位置。)你可以把这些角度值想象成指向 AI 汽车行驶方向的小箭头,如图 7-3 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-3。一系列的角度决定了人工智能汽车应该尝试的方向和自己的方向

所有的人工智能汽车需要做的就是在赛道上行驶,将自己旋转到当前所处的最佳角度。而且,只是为了好玩,代码给这些角度增加了一点随机变化,在正负 20 度之内。这种随机性使得人工智能汽车的驾驶看起来更加自然,就像一个不完美的人类司机。让我们浏览一下实现这一点的新代码。

代码首先声明三个新的全局变量:aiCar、previousMapAngle 和 targetAngle。

let car, world, aiCar, previousMapAngle, targetAngle;

然后设置函数创建游戏需要的新数据。AI 汽车被添加到世界对象的第二个地图层,用数字 4 表示。

[
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 3, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 4, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],

然后,角度数组被添加为世界对象的第三个贴图层。

[
   45,  45,  45,  45,  45,  45,  45,  45, 135, 135,
  315,   0,   0,   0,   0,   0,   0,  90, 135, 135,
  315,   0,   0,   0,   0,   0,   0,  90, 135, 135,
  315, 315, 270, 315, 315, 315, 315,  90,  90, 135,
  315, 315, 270, 135, 135, 135, 135,  90,  90, 135,
  315, 315, 270, 180, 180, 180, 180, 180, 180, 135,
  315, 315, 270, 180, 180, 180, 180, 180, 180, 135,
  315, 270, 270, 225, 225, 225, 225, 225, 225, 225
]

构建世界的 switch 语句创建了新的 aiCar sprite,如下所示。

case 4:
  sprite = g.sprite(g.frame("img/tileSet.png", 192, 128, 48, 48));
  aiCar = sprite;

然后初始化两个汽车精灵的属性。

**//A function to add physics properties to the cars** 
let addCarProperties = carSprite => {
  carSprite.vx = 0;
  carSprite.vy = 0;
  carSprite.accelerationX = 0.2;
  carSprite.accelerationY = 0.2;
  carSprite.rotationSpeed = 0;
  carSprite.friction = 0.96;
  carSprite.speed = 0;

**//Center the rotation point** 
  carSprite.setPivot(0.5, 0.5);

**//Whether or not the car should move forward** 
  carSprite.moveForward = false;
};

**//Add physics properties to the player's car** 
addCarProperties(car);

**//Add physics properties and set it to move forward** 
**//when the game begins** 
addCarProperties(aiCar);
aiCar.moveForward = true;

新代码的其余部分在 play 函数中。它计算出 AI 汽车的新目标角度应该是什么,试图将汽车旋转到该角度,添加一些随机变化,并使用其物理属性移动汽车。这段代码如何工作的所有细节都在注释中。

**//If `aICar.moveForward` is `true`, increase the speed as long** 
**//it is under the maximum speed limit of 3** 
if (aiCar.moveForward && aiCar.speed <= 3) {
  aiCar.speed += 0.08;
}

**//Find the AI car's current angle, in degrees** 
let currentAngle = aiCar.rotation * (180 / Math.PI);

**//Constrain the calculated angle to a value between 0 and 360** 
currentAngle = currentAngle + Math.ceil(-currentAngle / 360) * 360;

**//Find out its index position on the map** 
let aiCarIndex = getIndex(aiCar.x, aiCar.y, 64, 64, 10);

**//Find out what the target angle is for that map position** 
let angleMap = world.layers[2];
let mapAngle = angleMap[aiCarIndex];

**//Add an optional random variation of 20 degrees each time the aiCar** 
**//encounters a new map angle** 
if (mapAngle !== previousMapAngle) {
  targetAngle = mapAngle + randomInt(-20, 20);
  previousMapAngle = mapAngle;
}

**//If you don't want any random variation in the iaCar's angle** 
**//replace the above if statement with this line of code:** 
**//targetAngle = mapAngle;** 

**//Calculate the difference between the current** 
**//angle and the target angle** 
let difference = currentAngle - targetAngle;

**//Figure out whether to turn the car left or right** 
if (difference > 0 && difference < 180) {

**//Turn left** 
  aiCar.rotationSpeed = -0.03;
} else {

**//Turn right** 
  aiCar.rotationSpeed = 0.03;
}

**//Use the `rotationSpeed` to set the car's rotation** 
aiCar.rotation += aiCar.rotationSpeed;

**//Use the `speed` value to figure out the acceleration in the** 
**//direction of the aiCar’s rotation** 
aiCar.accelerationX = aiCar.speed * Math.cos(aiCar.rotation);
aiCar.accelerationY = aiCar.speed * Math.sin(aiCar.rotation);

**//Apply the acceleration and friction to the aiCar's velocity** 
aiCar.vx = aiCar.accelerationX
aiCar.vy = aiCar.accelerationY
aiCar.vx *= aiCar.friction;
aiCar.vy *= aiCar.friction;

**//Apply the aiCar's velocity to its position to make the aiCar move** 
aiCar.x += aiCar.vx;
aiCar.y += aiCar.vy;

但是还有一件事!为了使这成为一个公平的游戏,如果 AI 车跑进草地,我们也需要减慢它的速度。这段代码在播放函数的末尾,完成了这项工作。

**//Get a reference to the race track map** 
let trackMap = world.layers[0];

**//Slow the aiCar if it's on a grass tile (gid 1) by setting** 
**//its friction to 0.25, to make it sluggish** 
if (trackMap[aiCarIndex] === 1) {
  aiCar.friction = 0.25;

**//If the car isn't on a grass tile, restore its** 
**//original friction value** 
} else {
  aiCar.friction = 0.96;
}

运行示例文件,你会发现看着人工智能汽车驶入草地并奋力挣脱是非常有趣的。

这个基本原型封装了你需要知道的所有基本技术,以建立一个与 AI 对手的全功能赛车游戏。以下是一些你可以用来进一步发展这些想法的想法:

  • 仅仅通过改变或随机改变转速数,就可以在不同的技能水平下制造各种各样的 AI 汽车。

  • 让不同的 AI 汽车使用不同的角度地图来改变难度,让人类玩家无法预测事情。

  • 分析人类玩家在每场比赛后的表现,并增加或减少游戏难度以保持其挑战性。

  • 给人工智能汽车一个防撞系统,这样如果它们太靠近其他汽车,它们就可以避开它们。你可以在这本书的配套书中找到构建这样一个系统所需要的一切,用 HTML5 和 JavaScript 的高级游戏设计 (Apress,2015)。

一旦你开始考虑用你在本章中学到的方法来存储和使用游戏数据,你肯定会找到无数个解决棘手问题的方法。

宽相和窄相碰撞

有两种不同的通用方法来处理游戏中的碰撞检测,称为宽相位窄相位碰撞。

  • 宽相位碰撞:检查小精灵是否在碰撞的大致区域内,例如我们在前面章节中详细检查过的基于图块的碰撞。它的优点是这是一种非常高效的检查大量精灵之间碰撞的方法。这是因为它只做简单的数组查找,不需要做任何繁重的数学处理。

  • 窄相位碰撞:检查精灵的精确几何图形,找出它们的形状是否重叠。它的优点是,因为它非常精确,窄相位碰撞对于物理模拟是必不可少的,例如检查两个台球之间的碰撞。它的缺点是,因为它用大量的数学运算来加重 CPU 的负担,所以对性能要求很高。如果你检查大量精灵之间的碰撞,会很慢。虽然如何实现窄相位碰撞系统没有在本书中涉及,但是你可以在用 HTML5 和 JavaScript 进行高级游戏设计中找到从头构建这样一个系统所需要知道的一切。

这两个碰撞系统代表了所有视频游戏中碰撞检测的基础。你如何决定使用哪一个?通常选择很容易:如果你需要物理,使用窄相位;否则使用 broadphase。但是有一个灰色地带!

如果你在做一个游戏,游戏中有成千上万个圆圈需要互相弹跳,那该怎么办?你尝试实现一个物理密集的窄相位碰撞系统,但是你的帧速率下降到每秒 5 帧。太慢了!所以你实现了一个宽相位碰撞系统,很容易得到每秒 60 帧。但是现在你的圆圈在弹开之前明显地互相重叠,这看起来非常不精确。你该怎么办?

同时使用宽相位窄相位碰撞!将你的碰撞系统分解成两个步骤:

  1. Broadphase 检查:首先使用 Broadphase 碰撞来找出你感兴趣的精灵是否在彼此的大致附近,并且在这个动画帧期间实际上可能足够接近以至于可以接触到。

  2. 窄相位检查:如果你的宽相位检查确定,是的,碰撞是可能的,运行一个更精确,但更多 CPU 开销的窄相位检查。

有许多方法可以实现这种两阶段碰撞策略,但我将向您展示最简单、最有用、通常也是性能效率最高的方法:空间网格。

空间网格

要创建一个空间网格,将你的游戏世界划分成单元。这些细胞应该有多大?它应该和你的一个精灵在一个动画帧中可能移动的一样大。例如,假设您正在创建一个大理石游戏,并且您确定没有一个大理石精灵的移动速度会超过每秒 64 帧。这意味着您可以使用一个空间网格,其中每个单元格都是 64 像素宽和 64 像素高。图 7-4 显示了如果你的游戏屏幕是 512 乘 512 像素,这个网格看起来会是什么样子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-4。将你的世界分成细胞

在一个标准的窄相位碰撞系统中,在整个游戏中,每个精灵都将被检查是否与其他精灵发生碰撞。这是一种浪费,因为位于屏幕完全相反两侧的精灵之间的碰撞将被检查,即使它们没有碰撞的机会。但是,如果使用空间栅格,碰撞检查会更加集中。对于每个精灵,你只需要检查它和与其直接相邻的单元中的精灵之间的碰撞。

比如看一下图 7-5 。在屏幕的左中间是一个大的白色圆圈。白色圆圈所在的单元,加上围绕它的八个单元,代表圆圈的碰撞区域。这个碰撞区域是屏幕上这个精灵有机会与任何其他精灵碰撞的唯一区域。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-5。只需检查同一碰撞区域中精灵之间的碰撞

因此,我们不需要检查大的白色圆圈和屏幕上其他 24 个圆圈之间的碰撞,我们只需要检查它和碰撞区域右上角的 2 个较小的圆圈之间的碰撞。这减少了大约 90%的碰撞检查次数!

这就是全部了!但是你如何用代码创建一个这样的空间网格系统并在游戏中使用它呢?

实现空间网格

运行本章源文件中的 spatialGrid.html 文件作为工作示例。这是一个简单的弹珠游戏。用鼠标选择一个弹球,拖动并释放。观看弹珠在屏幕上反弹并与其他弹珠碰撞,如图 7-6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-6。单击、拖动并释放鼠标,观看弹球在屏幕上弹跳

请看完整的源代码,了解这个小游戏原型如何工作的细节。出于我们的目的,我们只是对空间网格碰撞系统如何工作感兴趣。但是,关于代码,有两件事你需要知道,我们将提前了解:

  • 它在游戏循环中运行,所以每一帧都会更新。

  • 所有的圆形精灵在一个名为 marbles.children 的数组中被引用。

好的,明白了吗?现在让我们来看看代码!

编码空间网格

我们需要做的第一件事是创建网格。这只是一个 2D 数组,其中的单元格与我们想要的网格的行数和列数相匹配。数组首先用空元素初始化。然后,代码循环遍历所有精灵,并使用我们良好的旧 getIndex 函数根据每个精灵的 x/y 像素坐标将它们插入到正确的单元格中。我们的代码在一个名为 spatialGrid 的函数中完成所有这些工作,该函数允许您以像素为单位指定网格的大小和每个单元格的大小。

let spatialGrid = (widthInPixels, heightInPixels, cellSizeInPixels, spritesArray) => {

**//Find out how many cells we need and how long the** 
**//grid array should be** 
  let width = widthInPixels / cellSizeInPixels,
    height = heightInPixels / cellSizeInPixels,
    length = width * height;

**//Initialize an empty grid** 
  let gridArray = [];

**//Add empty sub-arrays to the element** 
  for (let i = 0; i < length; i++) {

**//Add empty arrays to each element. This is where** 
**//we're going to store sprite references** 
    gridArray.push([]);
  }

**//Add the sprites to the grid** 
  spritesArray.forEach(sprite => {

**//Find out the sprite's current map index position** 
    let index = getIndex(sprite.x, sprite.y, cellSizeInPixels, cellSizeInPixels, width);

**//Add the sprite to the array at that index position** 
    gridArray[index].push(sprite);
  });

**//Return the array** 
  return gridArray;
};

**//Create the spatial grid and add the marble sprites to it** 
let grid = spatialGrid(512, 512, 64, marbles.children);

我们现在有一个叫做网格的 2D 阵列。它包含的每个子数组要么是空的,要么以匹配屏幕上子画面位置的方式包含对子画面的引用。例如,您可能有一个如下所示的数组:

[
  [],[circle1],[],[],[],[],[],[circle2],[],
  [circle3],[],[circle4],[circle5],[],[],[],[],[],
  [],[],[],[],[],[],[],[],[circle6],
  [],[],[],[circle7],[circle8],[],[],[],[],
  [],[circle9],[],[circle10],[],[circle11],[],[],[],
  [circle12, circle13],[],[],[],[],[],[circle14],[circle15],[],
  [circle16],[circle17],[],[],[],[circle18],[],[],[],
  [],[],[circle19],[],[],[circle20],[],[],[circle21],
  [],[circle20],[circle23],[],[],[],[],[circle24],[circle25]
]

(精灵的实际名称,例如 circle1 和 circle2,只是为了说明的目的——这些名称实际上并不是由代码产生的。)

图 7-7 显示了这个数组如何与相同精灵的屏幕位置进行比较。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-7。精灵在数组中的位置与它们的屏幕位置相匹配

在一个有密集精灵的游戏中,你可能有许多单元格引用了不止一个精灵。

下一步是遍历所有的精灵,并找出是否有任何其他精灵在其碰撞区。(记住,碰撞区域是精灵周围的八个单元,加上精灵本身所占据的单元。)如果碰撞区域中有任何精灵,代码会使用一个名为 movingCircleCollision 的自定义函数进行窄阶段碰撞检查,以将精灵弹开(有关 movingCircleCollision 如何工作的详细信息,请参见使用 HTML5 和 JavaScript 的基础游戏设计 (Apress,2012)。)最后,被检查的当前子画面从网格阵列中移除,因为它的所有可能的碰撞已经被检查,并且我们想要防止其他子画面在循环的稍后迭代中试图重新检查它。

**//Loop through all the sprites** 
for (let i = 0; i < marbles.children.length; i++) {

**//Get a reference to the current sprite in the loop** 
  let sprite = marbles.children[i];

**//Find out the sprite's current map index position** 
  let gridWidthInTiles = 512 / 64;
  let index = getIndex(sprite.x, sprite.y, 64, 64, gridWidthInTiles);

**//Find out what the surrounding cells contain, including those that** 
**//might be beyond the borders of the grid** 
  let allSurroundingCells = [
    grid[index - gridWidthInTiles - 1],
    grid[index - gridWidthInTiles],
    grid[index - gridWidthInTiles + 1],
    grid[index - 1],
    grid[index],
    grid[index + 1],
    grid[index + gridWidthInTiles - 1],
    grid[index + gridWidthInTiles],
    grid[index + gridWidthInTiles + 1]
  ];

**//Find all the sprites that might be colliding with this current sprite** 
  for (let j = 0; j < allSurroundingCells.length; j++) {

**//Get a reference to the current surrounding cell** 
    let cell = allSurroundingCells[j]

**//If the cell isn't `undefined` (beyond the grid borders)** 
**//and it's not empty, check for a collision between** 
**//the current sprite and sprites in the cell** 
    if (cell && cell.length !== 0) {

**//Loop through all the sprites in the cell** 
      for (let k = 0; k < cell.length; k++) {

**//Get a reference to the current sprite being checked** 
**//in the cell** 
        let surroundingSprite = cell[k];

**//If the sprite in the cell is not the same as the current** 
**//sprite in the main loop, then check for a collision** 
**//between those sprites** 
        if (surroundingSprite !== sprite) {

**//Perform a narrow-phase collision check to bounce** 
**//the sprites apart** 
          g.movingCircleCollision(sprite, surroundingSprite);
        }
      }
    }
  }

**//Finally, remove this current sprite from the current** 
**//spatial grid cell because all possible collisions** 
**//involving this sprite have been checked** 
  grid[index] = grid[index].filter(x => x !== sprite);
}

这就是全部了!通过首先进行有效的宽相位检查,然后在可能发生碰撞的情况下只进行高性能的窄相位检查,我们大大提高了碰撞系统的效率。

为什么这段代码使用老式的 for 循环,而不是更圆滑、更现代的 forEach 循环?for 循环往往更有效率。通常,这种差异不足以支持 for 循环,但是因为碰撞检测是游戏中对性能要求最高的任务之一,所以在这种情况下使用 for 循环通常会为您赢得一点点性能提升。

其他宽相位碰撞策略

空间网格是一个优秀的多用途宽相位碰撞检测系统,是游戏设计者的主食。简单、快速和低开销很难被击败。然而,有许多其他的宽相位碰撞策略,每一个都有其独特的问题。以下是最受欢迎的四种:

  • 分级网格:在固定大小的空间网格中,比如我们在本章中使用的,单元大小必须和最大的对象一样大。但是如果你有一个游戏,里面有几个很大的物体和很多很小的物体呢?单元的大小需要足够大,以容纳那些大的对象,即使它们不是很多。您最终会遇到这样一种情况:每个单元格都充满了许多小对象,每个小对象都在互相进行昂贵的距离检查。分级网格通过创建两个或更多不同大小的单元网格来解决这个可能的问题。它为大对象创建一个具有大单元的网格,为小对象创建另一个网格,并在两者之间创建任何范围的不同单元大小的网格。小对象之间的碰撞检查在小单元网格中处理,大对象之间的碰撞在大单元网格中处理。如果一个小物体需要检查与一个大物体的碰撞,系统检查对应于两个网格的单元。

  • 四叉树:一种特定类型的层次网格。游戏世界被分成 4 个矩形,依次再分成 4 个矩形,结果是 16 个。这 16 个矩形中的每一个又被分成 4 个更小的矩形,这取决于你需要多少细节。每个较小的矩形都是较大的父矩形的子矩形。四叉树系统根据对象在层次结构中的级别来计算出要测试碰撞的对象。四叉树的 3D 版本被称为八叉树。

  • 排序和扫描:根据 x 和 y 位置对数组中的对象进行排序。检查 x 轴和 y 轴上的重叠,如果发现,进行更精确的距离检查。因为物体首先被空间排序,可能的碰撞候选者首先出现在最前面。

  • BSP 树:空间以一种紧密匹配游戏对象几何形状的方式被划分。这很有用,因为这意味着分区既可以用于碰撞,也可以用于定义环境边界。二进制空间划分(BSP)树与四叉树密切相关,但是它们更加通用。BSP 树广泛用于 3D 游戏的碰撞检测。

我建议你花些时间研究这些其他的宽相位碰撞策略。你可能会发现其中一个对你可能面临的复杂碰撞问题有特别好的解决方案。

关于游戏碰撞的权威参考文本是 Christer Ericson 的经典实时碰撞检测(Morgan Kaufman,2004。)虽然源代码示例是用 c++(JavaScript 的近亲)编写的,但算法可以适用于任何编程语言或技术实现。

但是请记住:在你需要之前,不要试图先发制人地优化你的碰撞系统!如果你的游戏在所有的目标平台上都运行良好,而不需要使用空间网格或四叉树,那就不要去管它——这很好!

摘要

在这一章中,你已经学习了如何在数组中添加隐藏的游戏信息,并使用这些信息来增加游戏世界的丰富性和复杂性。AI 赛车向你展示了如何用最少、最简单的代码创建一个行为复杂、智能且不可预测的游戏实体。您还了解了如何创建和使用最好的通用且低开销的宽相位碰撞策略:空间网格。使用空间网格极大地减少了游戏需要进行的碰撞检查的数量,并且很容易根据需要进行修改和调整。

而且,我们已经到了书的结尾!你现在已经掌握了开始制作各种类型的 2D 动作游戏所需的所有技能。从关卡设计,到碰撞检测,到寻路和基于图块的架构的惊人效率,您现在已经掌握了游戏设计者的所有经典技术。现在去做一些伟大的游戏吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值