请叫我标题党!请叫我标题党!请叫我标题党!由于下面的文字既不发生在美国曼哈顿,也不是一个讲述美国梦的故事。相反,这可能只是一篇没有那么枯燥的关于算法的文章。A星算法,这个在游戏寻路开发中不免会用到的算法即是我这篇文章的主角。算法
这是一张美国曼哈顿的俯视图,放眼望去除了能看到这里高楼林立以外,咱们也能发现其另一个特色,即横平竖直的街道将一整块地区整整齐齐的分红了好几个区块。人和车流只能行进在横穿其中的街道上,也只能在街道的交叉口改变本身的前进的方向。例如要找出地图中A点到B点的最佳路线,事实上就是从A点所在的交叉口沿着街道走到B点所在的交叉口,咱们没法从区块内部穿越过去,除了沿街道走别无选择。
下面让咱们把曼哈顿的这些街道交叉口当作结点,两个交叉口之间的街道当作边,作出一个以下图所示的二维网格。
那么A点到B点的实际距离是多少呢?考虑到咱们只能沿着街道行走,而没法从街道围成的区块中穿越,所以在这种状况下A点到B点的实际距离并非它们之间的直线距离,而是应该以下图所示的这样:
转换成数学语言就是这样:函数
dis = abs(A.x - B.x) + abs(A.y - B.y)
对了,这就是曼哈顿距离。也就是在A星算法中经常被用来做为启发函数的家伙。等等,启发函数是什么?让我继续。设计
从A点到B点的这条路径,显然包括了以A为起点B为终点的一系列结点,而每一个结点也只能从和本身相邻的结点中选择下一个行走目标。可是正如现实生活同样,畅通无阻的街道老是奢求,在路上总会花费一些代价,例如路况不佳,交通拥堵等等缘由形成从这条道路行走时会花费更多的时间。所以在寻路中,一条路径的代价等于在每一个路口选择的道路的代价之和。
了解了这些以后,就让咱们来实现一个最粗暴的寻路方式,仿佛一个醉汉,无视每条道路是否已经走过,也不关心每条道路所花费的时间代价,反正只须要在路口闭着眼睛作出一个选择就行了。3d
//伪代码 q = newqueue q.enqueue(newpath(start)) while q is not empty p = q.dequeue if p.lastNode == destination return p foreach n in p.lastNode.neighbours q.enqueue(p.continuepath(n)) //找不到合适路径 return null
这样作的后果是什么呢?不错,就像一个醉汉同样,从路口的四个方向中随机选择一个方向,甚至还有可能走回头路(由于没有记录他已经走过的路口),也许最后的确可以找到家,可是这个过程当中殊不知道消耗了多少时间,走了多少冤枉路。更有甚者,若是实际上并无一条可以到达目的地的路径,甚至会出现“鬼打墙”的状况,即进入了一个无限的死循环之中没法自拔。
因此,让咱们来帮他一下吧,既然醉汉不记得已经走过了哪些路口,那么就让咱们来帮他记住他走过的路口。咱们为上面的代码引入一个closed集合,用来保存已经走过路口。code
//伪代码 //引入一个集合,用来保存已经走过的路口 closed = {} q = newqueue q.enqueue(newpath(start)) while q is not empty p = q.dequeue //若是下面closed集合中包含了路径p的最后一个路口 //p.last则忽略 if closed contains p.last continue //若是路径p的最后一个路口便是目的地,则直接返回p if p.last == destination return p //不然将该点p.last加入到closed集合中 closed.add(p.last) //把点p.last相邻的点加入到队列中 foreach n in p.last.neighbours q.enqueue(p.continuepath(n)) //找不到合适的路 return null
这样,咱们就帮醉汉解决了走回头路的问题,也消除了“鬼打墙”的隐患。可是,醉汉在选择道路时仍然没有一个明确的目标,这也就决定了他在寻找目的地的效率并不高效。由于他仍然会向四面八方寻路,虽然他在咱们的帮助下已经不会走回头路了。显然,为了尽早让醉汉回到家,咱们须要为他选择一条最佳的道路。可是,这条最佳的道路到底应该如何选择(预估)呢?blog
在考虑如何寻找最佳路径以前,咱们第一步要作的显然就是为最佳路径定义一个能够量化的标准。到底以什么为标准来评价一条路径呢?最简单的,咱们就选择两个路口之间的距离做为标准,这里咱们将距离长度称之为路径的开销,且一个路口上下左右相邻的路口的消耗为1,而对角线上的路口消耗则为1.41。
而咱们评价一条潜在路径的开销时,所依据的数据主要来自两个方面:排序
而咱们所要作的,即是在帮助醉汉不走回头路的基础上,再为醉汉指一个回家的方向。醉汉只要按照这个方向走,便可以很快的找到家。而这个方向又是如何肯定的呢?其实十分简单,咱们只需找到总消耗最小的路径即可以了。这里咱们记总消耗为F,那么显然有以下这样的等式:队列
F = G + H游戏
那么具体应该如何操做呢?咱们须要一个优先队列,记录每条路径的总消耗以及这条路径,而且根据路径的总消耗来对该队列进行排序,这样消耗最小的路径便能轻易地获取了。因此,咱们的代码拓展成了下面这个样子:图片
//伪代码 //引入一个集合,用来保存已经走过的路口 closed = {} q = newqueue; //q为优先队列,记录路径的消耗以及路径,起始点消耗为0 q.enqueue(0, newpath(start)) while q is not empty //优先队列弹出消耗最小的路径 p = q.dequeueCheapest if closed contains p.last continue; if p.last == destination return p closed.add(p.last) foreach n in p.last.neighbours //得到新的路径 newpath2 = p.continuepath(n) //将新路径的总消耗(G+H),和新路径分别入队 q.enqueue(newpath.G + estimateCost(n, destination), newpath2) return null
其中,咱们能够发现预估到目的地消耗的函数叫“estimateCost”,这即是在A星算法中咱们经常提起的启发函数。它的做用即是估算当前位置到目的地的大概距离,而在本文一开始介绍的曼哈顿距离即是一种经常使用的启发函数。即计算当前路口(格子)到目标路口(格子)之间的垂直和水平的路口(格子)数量总和。
dis = abs(A.x - B.x) + abs(A.y - B.y)
而这个启发函数,即是咱们送给醉汉回家的指南针。
固然,借这个醉汉回家的例子说明的仅仅是A星算法最基本的实现原理。而在实际的工程中,它也有更加复杂的使用环境,下面我就简单的介绍几种工程中实现A星寻路的工做方式。
咱们有了算法的实现思路,接下来即是如何在游戏中实现A星算法了。
要在游戏中进行寻路,首先要作的即是借助图来将游戏地形表示出来,而这个图即是导航图。
而最多见的导航图即是以下三种:
如上图所示,将游戏地图划分为许多单元格的形式即是咱们所说的基于单元格的导航图。这种表示方式的结构十分规则,所以最容易理解和使用,且易于动态更新。所以在须要频繁动态更新场景的游戏中使用这种基于单元格的导航图便十分的恰当。
可是,为了追求寻路的结果更加精确,单元格的大小就成为了关键,过大的单元格显然和精确无缘,可是若是为了追求精确而使用很小的单元格,却又不得不面对另外一个问题——须要存储和搜索的结点的数量会十分大。这样不只须要大量的消耗内存,同时也会影响搜索效率。
若是咱们经过人工不规则的放置一些用来导航的点来代替刚刚的单元结点,那么是否会有更好的表现呢?所以,基于可视点,或者被称为路点(The waypoints)的导航图便出现了。如上图所示,红色的结点即是放置的路点,而路点之间的连线是游戏单位能够行走的路径。
这种基于路点的导航图的优点即是可让场景设计师按照场景的特色来布置路点,因为能够按照设计师的想法来放置,所以基于路点的导航图的一大特色即是灵活性很高,且不像基于单元格的导航图那样,须要存储和搜索大量的结点,所以须要的内存和搜索的效率较前者都要优秀。
可是它的缺点也一样明显,那就是若是场景过大,放置少许的路点显然没法知足须要,可是放置不少路点时,会使得场景设计师的工做变得复杂且容易出错。而因为游戏单位只能在两个路点之间的连线上进行移动,所以若是游戏单位不在结点或结点间连线上的时候,会先到离它最近的路点上,以后再次移动,这样从视觉上看会出现不天然的状况。
如图,导航网格将游戏地形划分红了大大小小的三角形,而这些三角形也就成为了A星算法中的节点。相邻的三角形能够直达,换言之,三角形相邻的其余三角形既其相邻的结点。 所以,与前两种导航图相比,因为其“节点”面积大,所以只须要少许的“节点”便可覆盖整个游戏区域,从而减小了“节点”的数量。其次,也正是因为节点所有覆盖了游戏场景,所以没必要担忧像基于路点的导航图那样因为缺乏路点而形成的寻路不精确的问题。 可是,它一样并不是十全十美的,相较前二者而言,生成导航网格的时间较长,所以推荐在静态场景中使用,而在地形常常发生变化的场景中减小使用。