最最基础的非常非常好的寻路教程
2009-09-04 15:21 宝宝合凤凰 阅读(727) 评论(0) 编辑 收藏 举报A*路径寻找算法入门
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image001.jpg)
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image002.jpg)
-
G=从起点A沿着已生成的路径到一个给定方格的移动开销。
- H=从给定方格到目的方格的估计移动开销。这种方式常叫做试探,有点困惑人吧。其实之所以叫做试探法是因为这只是一个猜测。在找到路径之前我们实际上并不知道实际的距离,因为任何东西都有可能出现在半路上(墙啊,水啊什么的)。本文中给出了一种计算H值的方法,网上还有很多其他文章介绍的不同方法。
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image003.jpg)
相反的,如果新路径的G值更小,就将该相邻方格的父方格重设为当前选中方格。(在上图中是改变其指针的方向为指向选中方格。最后,重新计算那个相邻方格的F和G值。如果你看糊涂了,下面会有图解说明。
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image004.jpg)
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image005.jpg)
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image006.jpg)
![](https://images.cnblogs.com/cnblogs_com/shinings/AS3/image007.jpg)
这种方法很适合于地图不大的情况。但这还不是最快的解决办法。真正追求速度的认真的程序员往往会使用二叉堆。这也是我在我的代码中用的方法。据我的经验,这种方法会比大多数解决方案快至少2-3倍,在路径很长的时候更快(快10倍以上)。如果你有兴趣了解更多二叉堆的内容,去看看我的这篇文章,Using Binary Heaps in A* Pathfinding。
在寻路算法中忽略其他单位意味着你将不得不写一些单独的代码来避免冲突。这完全是由游戏特性决定的。所以我把解决方案留给你自己去琢磨。本文末尾参考文章一节Bryan Stout的一些地方提供了几个解决方案(比如鲁棒式跟踪等等),不妨去看看。
-
用小一点的地图或者寻路者少一点。
-
不要在同一时间为很多个寻路者计算路径。不如把它们放进一个队列里并分散在几个游戏循环中。如果你的游戏运行时大约是,比如说,40个循环/秒的话,没人会注意到。但是要是有一堆寻路者在同一时间计算路径而导致游戏在短时间内变慢的话,就非常引人注意了。
-
将地图的区块划分得大一些。这样会减少搜索路径时的区块总数。如果你够强,可以设计两种以上的寻路系统来适应于路径长度不同的情况。这正是那些专业人士所做的,路径长的时候用大区块,当接近目标路径变短的时候又用小区块来进行精确些的搜索。要是你对这个概念感兴趣,不妨读读我的文章Two-Tiered A* Pathfinding
-
考虑采用路标系统来处理长路径的情况,或者预先处理一些对游戏来说很少变化的路径。
-
对地图进行预处理并计算出哪些区域是从其他地方根本到达不了的。我把这些区域叫做“岛”。实际中这些区域可能是岛也可能是墙围起来的地方。A*算法的一个缺陷是当你在找到这样一个不可达区域的路径时,几乎会把整个地图都搜个遍,直到地图上每一个区块都被搜索过了才会停。这样会浪费很多CPU时间。解决这个问题的办法是预先得出哪些区域是根本不可达的(通过洪泛法或者其他方式),用一个数组记录下这些数据并在寻找路径前先检查一下。在我那个Blitz版本的代码中,我创建了一个地图预处理器来完成这样的处理。这个预处理器还能提前找到可在寻路算法中忽略的死角,因而大大提高了算法速度。
这个问题很容易解决。只要在计算给定区块的G值时把地形的开销加上去就行。把某个区块加上额外的开销,A*算法仍然有效。在我描述的那个简单例子里,地形只分可通过和不可通过两种,A*算法会找到最直接最短的路径。但当在一个地形多样化的环境里时,最小开销路径可能会是比较长的一段路程。例如从公路上绕过去显然比直接通过沼泽地开销要小。
还有一个有趣的办法是专业人士们叫做“感应映射”的东西。如同上面描述的多样化地形开销一样,你可以创建一个额外的点数制度并将之应用于AI方面的路径中。假设在一张地图上有一堆家伙在守卫一个山区要道,每次电脑派遣某人通过那条要道时都会被困住。那你就可以创建一个感应地图使得发生很多战斗冲突的那些区块开销增大,以此来告知电脑去选一个更安全的路径,并避免仅凭着是最短路径(但是更危险)就持续派遣人员通过某条路径的愚蠢情形。
方法就是为不同的玩家和电脑(不是每个单位,不然会耗掉好多内存)创建一个独立的“已知可通行”数组。每个数组包含了玩家已探索区域和未知区域的信息,并把未知区域全部视为可通行区域来对待,除非后来发现是其他的地形。使用这种方法的话,移动单位就会绕到死角或犯类似错误,直到它们发现了周围的路。一旦地图都被探索过了,路径寻找算法就会正常运行了。
有几种方法可以处理这个问题。如在计算路径的时候你可以给那些改变了路径方向的方格一个额外开销,使其G值增大。这样,你可以沿着所得路径看看哪些取舍能使你的路径看起来更平滑。对于这个话题,可以看看Toward More Realistic Pathfinding(是免费的,但是查看需要注册)来了解更多。
类似的,你可以针对固定地形的地图来创建一个路标系统。路标通常是横穿路径的点,可能是在公路上也可能是在地牢的主隧道里。对于游戏设计者来说,需要提前设置好这些路标。如果两个路标所成的直线路径之间没有障碍物的话就称之为“相邻的”。在Risk游戏的例子中,你会将相邻信息保存在一个查找表中,并会在新增开放列表元素时用到这个查找表。然后你会用到相关的G值(可能通过两个节点间的直线距离得到)和H值(可能通过该节点到终点的直线距离得到)其他的运算就和普通的差不多。
另外一个使用非方格搜索区域的斜视角RPG游戏的例子参见我的文章Two-Tiered A* Pathfinding。
- Amit's A* Pages:这是Amit Patel的一篇广为引用的文章。不过如果你没读先读本文的话看他这个可能会有些困惑。非常值得一读。特别是Amit对此的独特观点。
- Smart Moves: Intelligent Path Finding: Gamasutra.com网站上Bryan的这篇文章是需要注册才能阅读的。不过注册是免费的而且为了这篇文章麻烦一点也值得。Bryan用Delphi写的那个程序帮助我了解了A*,那也是我的A*程序的灵感来源。这篇文章也写了一些A*的变种
- Terrain Analysis:这是一篇高阶的文章,不过很有趣,它是由Ensemble Studios的专家Dave Pottinger所写。这家伙负责协调帝国时代II:Age of Kings的开发。要想把这里所有东西都看明白是不可能的,不过它或许会给你自己的项目带来一些有趣的想法。文章还包括一些关于材质贴图,感应映射和其他一些高级AI/路径寻找概念。其中关于“洪泛(flood filling)”的讨论是我的死角和岛屿等地图预处理代码的灵感来源。在我的Blitz Basic版的程序中包含了这个东西。。
- aiGuru: Pathfinding
- Game AI Resource: Pathfinding
- GameDev.net: Pathfinding
- -----------------------------
游戏时代群雄并起,寻路乃中原逐鹿第一步,重要性不言而喻。今习得寻路战术之首A*算法,为大家操演一番,不足之处还望不吝赐教。可以选择阅读下面的内容,或者先看看 寻路示例 、AS3类代码 及其 API文档。
牛刀小试 – A*寻路算法简介
eidiot挂帅出征,携令牌一枚,率人马若干,编制如下:
- 寻路元帅
寻路总指挥,执“行动令牌”一枚和“开启士兵名录”、“关闭将军名录”各一册。凭“行动令牌”调兵遣将。 - 预备士兵
由元帅或预备将军派往未探索区域,完成探索任务后授“开启”军衔,晋为“开启士兵”。发令派其出者为其“父将”。 - 开启士兵
前线待命。接到“行动令牌”后晋为“预备将军”执行探索任务。 - 预备将军
凭“行动令牌”派出预备士兵至周围未探索区域,并考察周围“开启士兵”状态,以“父将”之名节制所派士兵。归还“行动令牌”后授“关闭”军衔,晋为“关闭将军”。 - 关闭将军
后方待命。到达终点后依次报告“父将”直至元帅,寻路任务完成。
为协调行动,特颁军令如下:
- “预备士兵”只能由起点或“父将”所在格横、竖或斜向移动一格,直向(横、竖)移动一格走10步,斜向一格14步(斜向是直向1.414倍,取整数),抵达后不得再移动。
- 所有人员需记下派出自己的“父将”、从起点到所在位置所走步数(G)、预计到达终点共需步数(F)。
其中 F = G + H ,F 是从起点经过该点到终点的总路程,G 为起点到该点的“已走路程”,H 为该点到终点的“预计路程”。G 的计算是“父将”的 G 值加上“父将”位置到该位置所走步数,H 的计算是该点到终点“只走直路”所需路程。
看看战图更容易理解,从红色方格出发越过黄色障碍到达蓝色方格:
图例:
由图可形象看出何谓“开启士兵”、“关闭将军”:外围的绿色方格为“开启士兵”,“前线待命”,随时可向外继续探索。内围的紫色方格是“关闭将军”,从终点开始沿箭头寻其“父将”直至起点即得最终路径。
战前会议结束,拔营出征。
- 首先派出编号为0的“预备士兵”侦查起点,然后升其为“开启士兵”,列入“开启士兵名录”。
- 检查“开启士兵名录”,找出F值最低的“开启士兵”(只有一名人员,当然是0号),发出“行动令牌”派其执行探索任务。
- 0号“开启士兵”接到“行动令牌”,晋为“预备将军”,探索周围格子。
- 向周围8个格子分别派出编号为1到8的“预备士兵”,成为这八名“预备士兵”的“父将”。
- 八名“预备士兵”到达方格后计算G值和F值,报告0号“父将”,晋为“开启士兵”。
- 0号“预备将军”收到八名“开启士兵”的报告,归还“行动令牌”,晋为“关闭将军”。
- 元帅收回“行动令牌”,将0号加入“关闭将军名录”,1到8号加入“开启士兵名录”。
此过程结果如下(方格右上角数字是人员编号,左下角是G,右下角是H,左上角是F):
第一轮探索任务完成,元帅开始检查“开启士兵名录”。此时名录中有8名人员,其中1号F值最低为40(起点右移一格,G值为10,到终点平移3格,H值为30,F = G + H = 40),向其发出“行动令牌”。
- 1号“开启士兵”接到“行动令牌”,晋为“预备将军”,探索周围格子。
- 周围8个格子中有3格障碍,跳过。一格是“关闭将军”,跳过。其余四格是“开启士兵”,检查如果从该位置过去G值是否更低。以2号为例,如果从1号过去G值为 10 + 14 = 24 (1号的G值加上1号到2号的步数),而2号原来的G值是10,不做处理(如果此时发现新的G值更低,则更新2号的G值,并改2号的“父将”为1号)。其他类推。
- 1号检测完周围的方格,不需做任何处理,归还“行动令牌”,晋为“关闭将军”。
- 元帅收回“行动令牌”,将1号加入“关闭将军名录”。
此过程结果如下:
第二轮结束,元帅再次检查“开启士兵名录”。此时还有7名“开启士兵”,5号和8号的F值都为最低的54,选择不同寻路的结果也将不同。元帅选择了最后加入的8号“开启士兵”发出“行动令牌”,过程同上,不赘述,结果如下:
重复这个过程直到某位“关闭将军”站到了终点上(或者“开启士兵”探测到了终点,这样更快捷,但某些情况找到的路径不够短),亦即找到了路径;或是“开启士兵名录”已空,无法到达终点。
下面整理一下全过程并翻译成“标准语言”,首先是各名词:
- “开启士兵名录” – 开启列表 – open list
- “关闭将军名录” – 关闭列表 – closed list
- “父将” – 父节点 – parent square
- F – 路径评分
- G – 起点到该点移动损耗
- H – 该点到终点(启发式)预计移动损耗
寻路过程:
- 1, 将起点放入开启列表
- 2, 寻找开放列表中F值最低的节点作为当前节点
- 3, 将当前节节点切换到关闭列表
- 4, 如果当前节点是终点则路径被找到,寻路结束
- 5, 对于其周围8个节点:
- 如果不可通过或已在关闭列表,跳过,否则:
- 如果已在开放列表中,检查新路径是否更好。如果新G值更低则更新其G值并改当前节点为其父节点,否则跳过
- 如果是可通过区域则放入开启列表,计算这一点的F、G、H值,并记当前节点为其父节点
- 6, 如果开启列表空了,则无法到达目标,路径不存在。否则回到2
再翻译成“编程语言”?请看第三部分,锋芒毕露 – AS3代码和示例。
如虎添翼 – 使用二叉堆优化
如何让A*寻路更快?元帅三顾茅庐,请来南阳二叉堆先生帮忙优化寻找“开启士兵名录”中最低F值的过程,将寻路速度提高了2到3倍,而且越大的地图效果越明显。下面隆重介绍二叉堆先生:
下图是一个二叉堆的例子,形式上看,它从顶点开始,每个节点有两个子节点,每个子节点又各自有自己的两个子节点;数值上看,每个节点的两个子节点都比它大或和它相等。
在二叉堆里我们要求:
- 最小的元素在顶端
- 每个元素都比它的父节点大,或者和父节点相等。
只要满足这两个条件,其他的元素怎么排都行。如上面的例子,最小的元素10在最顶端,第二小的元素20在10的下面,但是第三小的元素24在20的下面,也就是第三层,更大的30反而在第二层。
这样一“堆”东西我们在程序中怎么用呢?幸运的是,二叉堆可以用一个简单的一维数组来存储,如下图所示。
假设一个元素的位置是n(第一个元素的位置为1,而不是通常数组的第一个索引0),那么它两个子节点分别是 n × 2 和 n × 2 + 1 ,父节点是n除以2取整。比如第3个元素(例中是20)的两个子节点位置是6和7,父节点位置是1。
对于二叉堆我们通常有三种操作:添加、删除和修改元素:
- 添加元素
首先把要添加的元素加到数组的末尾,然后和它的父节点(位置为当前位置除以2取整,比如第4个元素的父节点位置是2,第7个元素的父节点位置是3)比较,如果新元素比父节点元素小则交换这两个元素,然后再和新位置的父节点比较,直到它的父节点不再比它大,或者已经到达顶端,及第1的位置。 - 删除元素
删除元素的过程类似,只不过添加元素是“向上冒”,而删除元素是“向下沉”:删除位置1的元素,把最后一个元素移到最前面,然后和它的两个子节点比较,如果较小的子节点比它小就将它们交换,直到两个子节点都比它大。 - 修改元素
和添加元素完全一样的“向上冒”过程,只是要注意被修改的元素在二叉堆中的位置。
可以看出,使用二叉堆只需很少的几步就可以完成排序,很大程度上提高了寻路速度。
关于二叉堆先生需要了解的就是这么多了,下面来看看他怎么帮助元帅工作:
- 每次派出的“预备士兵”都会获得一个唯一的编号(ID),一直到寻路结束,它所有的数据包括位置、F值、G值、“父将”编号都将按这个ID存储。
- 每次有新的“开启士兵”加入,二叉堆先生将它的编号加入“开启士兵名录”并重新排序,使F值最低的ID始终排在最前面
- 当有“开启士兵”晋为“关闭将军”,删除“开启士兵名录”的第一个元素并重新排序
- 当某个“开启士兵”的F值被修改,更新其数据并重新排序
注意,“开启士兵名录”里存的只是人员的编号,数据全都另外存储。不太明白?没关系,元帅将在 第三部分 来次真刀实枪的大演兵。
锋芒毕露 – AS3代码和示例
地形数据不属于A*寻路的范围,这里定义一个 IMapTileModel 接口,由其它(模型)类来实现地图通路的判断。其它比如寻路超时的判断这里也不介绍,具体参考 AStar类及其测试代码。这里只介绍三部分主要内容:
private var m_openCount : int; //当前开放列表中节点数量
private var m_openId : int; //节点加入开放列表时分配的唯一ID(从0开始)
开放列表 m_openList 是个二叉堆(一维数组),F值最小的节点始终排在最前。为加快排序,开放列表中只存放节点ID ,其它数据放在各自的一维数组中:
private var m_yList : Array; //节点y坐标
private var m_pathScoreList : Array; //节点路径评分F值
private var m_movementCostList : Array; //(从起点移动到)节点的移动耗费G值
private var m_fatherList : Array; //节点的父节点(ID)
这些数据列表都以节点ID为索引顺序存储。看看代码如何工作:
currId = this.m_openList[0];
//读取当前节点坐标
currNoteX = this.m_xList[currId];
currNoteY = this.m_yList[currId];
还有一个很关键的变量:
使用 m_noteMap 可以方便的存取任何位置节点的开启关闭状态,并可取其ID进而存取其它数据。m_noteMap 是个三维数组,第一维y坐标(第几行),第二维x坐标(第几列),第三维节点状态和ID。判断点(p_x, p_y)是否在开启列表中:
寻路过程
AStar类 寻路的方法是 find() :
* 开始寻路
* @param p_startX 起点X坐标
* @param p_startY 起点Y坐标
* @param p_endX 终点X坐标
* @param p_endY 终点Y坐标
* @return 找到的路径(二维数组 : [p_startX, p_startY], ... , [p_endX, p_endY])
*/
public function find(p_startX : int, p_startY : int, p_endX : int, p_endY : int) : Array{/* 寻路 */}
注意这里返回数据的形式:从起点到终点的节点数组,其中每个节点为一维数组[x, y]的形式。为了加快速度,类里没有使用Object或是Point,节点坐标全部以数组形式存储。如节点note的x坐标为note[0],y坐标为note[1]。
下面开始寻路,第一步将起点添加到开启列表:
openNote() 方法将节点加入开放列表的同时分配一个唯一的ID、按此ID存储数据、对开启列表排序。接下来是寻路过程:
{
//每次取出开放列表最前面的ID
currId = this.m_openList[0];
//将编码为此ID的元素列入关闭列表
this.closeNote(currId);
//如果终点被放入关闭列表寻路结束,返回路径
if (currNoteX == p_endX && currNoteY == p_endY)
return this.getPath(p_startX, p_startY, currId);
//...每轮寻路过程
}
//开放列表已空,找不到路径
return null;
每轮的寻路:
aroundNotes = this.getArounds(currNoteX, currNoteY);
//对于周围每个节点
for each (var note : Array in aroundNotes)
{
//计算F和G值
cost = this.m_movementCostList[currId] + ((note[0] == currNoteX || note[1] == currNoteY) ? COST_STRAIGHT : COST_DIAGONAL);
score = cost + (Math.abs(p_endX - note[0]) + Math.abs(p_endY - note[1])) * COST_STRAIGHT;
if (this.isOpen(note[0], note[1])) //如果节点已在开启列表中
{
//测试节点的ID
checkingId = this.m_noteMap[note[1]][note[0]][NOTE_ID];
//如果新的G值比节点原来的G值小,修改F,G值,换父节点
if(cost < this.m_movementCostList[checkingId])
{
this.m_movementCostList[checkingId] = cost;
this.m_pathScoreList[checkingId] = score;
this.m_fatherList[checkingId] = currId;
//对开启列表重新排序
this.aheadNote(this.getIndex(checkingId));
}
} else //如果节点不在开放列表中
{
//将节点放入开放列表
this.openNote(note[0], note[1], score, cost, currId);
}
}
从终点开始依次沿父节点回到到起点,返回找到的路径:
* 获取路径
* @param p_startX 起始点X坐标
* @param p_startY 起始点Y坐标
* @param p_id 终点的ID
* @return 路径坐标数组
*/
private function getPath(p_startX : int, p_startY : int, p_id: int) : Array
{
var arr : Array = [];
var noteX : int = this.m_xList[p_id];
var noteY : int = this.m_yList[p_id];
while (noteX != p_startX || noteY != p_startY)
{
arr.unshift([noteX, noteY]);
p_id = this.m_fatherList[p_id];
noteX = this.m_xList[p_id];
noteY = this.m_yList[p_id];
}
arr.unshift([p_startX, p_startY]);
this.destroyLists();
return arr;
}
private function aheadNote(p_index : int) : void
{
var father : int;
var change : int;
//如果节点不在列表最前
while(p_index > 1)
{
//父节点的位置
father = Math.floor(p_index / 2);
//如果该节点的F值小于父节点的F值则和父节点交换
if (this.getScore(p_index) < this.getScore(father))
{
change = this.m_openList[p_index - 1];
this.m_openList[p_index - 1] = this.m_openList[father - 1];
this.m_openList[father - 1] = change;
p_index = father;
} else
{
break;
}
}
}
/** 将(取出开启列表中路径评分最低的节点后从队尾移到最前的)节点向后移动 */
private function backNote() : void
{
//尾部的节点被移到最前面
var checkIndex : int = 1;
var tmp : int;
var change : int;
while(true)
{
tmp = checkIndex;
//如果有子节点
if (2 * tmp <= this.m_openCount)
{
//如果子节点的F值更小
if(this.getScore(checkIndex) > this.getScore(2 * tmp))
{
//记节点的新位置为子节点位置
checkIndex = 2 * tmp;
}
//如果有两个子节点
if (2 * tmp + 1 <= this.m_openCount)
{
//如果第二个子节点F值更小
if(this.getScore(checkIndex) > this.getScore(2 * tmp + 1))
{
//更新节点新位置为第二个子节点位置
checkIndex = 2 * tmp + 1;
}
}
}
//如果节点位置没有更新结束排序
if (tmp == checkIndex)
{
break;
}
//反之和新位置交换,继续和新位置的子节点比较F值
else
{
change = this.m_openList[tmp - 1];
this.m_openList[tmp - 1] = this.m_openList[checkIndex - 1];
this.m_openList[checkIndex - 1] = change;
}
}
}
其中 getScore() 方法:
* 获取某节点的路径评分F值
* @param p_index 节点在开启列表中的索引(从1开始)
*/
private function getScore(p_index : int) : int
{
//开启列表索引从1开始,ID从0开始,数组索引从0开始
return this.m_pathScoreList[this.m_openList[p_index - 1]];
}