一提到“A*算法”,可能很多人都有"如雷贯耳"的感觉。用最白话的语言来讲:把游戏中的某个角色放在一个网格环境中,并给定一个目标点和一些障碍物,如何让角色快速“绕过障碍物”找出通往目标点的路径。(如下图)
在寻路过程中,角色总是不停从一个格子移动到另一个相邻的格子,如果单纯从距离上讲,移动到与自身斜对角的格子走的距离要长一些,而移动到与自身水平或垂直方面平行的格子,则要近一些。为了描述这种区别,先引入二个概念:
节点(Node):每个格子都可以称为节点。
代价(Cost):描述角色移动到某个节点时所走的距离(或难易程度)。
如上图,如果每水平或垂直方向移动相邻一个节点所花的代价记为1,则相邻对角节点的代码为1.4(即2的平方根--勾股定理)
通常寻路过程中的代价用f,g,h来表示
g代表(从指定节点到相邻)节点本身的代价--即上图中的1或1.4
h代表从指定节点到目标节点(根据不同的估价公式--后面会解释估价公式)估算出来的代价。
而 f = g + h 表示节点的总代价,为了方便后面的代码描述,这里把节点封装成一个类Node.as
09 |
public var walkable: Boolean = true ; //是否可穿越(通常把障碍物节点设置为false) |
10 |
public var parent:Node; |
11 |
public var costMultiplier: Number = 1.0 ; //代价因子 |
13 |
public function Node(x: int , y: int ) |
注意:这里有二个新的东东walkable和parent。
通常障碍物本身也可以看成是由若干个不可通过的节点所组成,所以walkable实际上是用来标记该节点是否为障碍物(节点)。
另外:在考查从一个节点移动到另一个节点时,总是拿自身节点周围的8个相邻节点来说事儿,相对于周边的节点来讲,自身节点称为它们的父节点(parent).
前面一直在提“网格,网格”,干脆把它也封装成类Grid.as
06 |
private var _startNode:Node; //开始节点 |
07 |
private var _endNode:Node; //目标节点 |
08 |
private var _nodes: Array ; //节点数组 |
09 |
private var _numCols: int ; //列数 |
10 |
private var _numRows: int ; //行数 |
12 |
public function Grid(numCols: int , numRows: int ) |
17 |
for ( var i: int = 0 ; i < _numCols; i++) |
19 |
_nodes[i]= new Array (); |
20 |
for ( var j: int = 0 ; j < _numRows; j++) |
22 |
_nodes[i][j]= new Node(i, j); |
27 |
public function getNode(x: int , y: int ):Node |
29 |
return _nodes[x][y] as Node; |
33 |
public function setEndNode(x: int , y: int ): void |
35 |
_endNode=_nodes[x][y] as Node; |
39 |
public function setStartNode(x: int , y: int ): void |
41 |
_startNode=_nodes[x][y] as Node; |
45 |
public function setWalkable(x: int , y: int , value: Boolean ): void |
47 |
_nodes[x][y].walkable=value; |
51 |
public function get endNode():Node |
57 |
public function get numCols(): int |
63 |
public function get numRows(): int |
69 |
public function get startNode():Node |
然而,在寻路的过程中“条条道路通罗马”,路径通常不止一条,只不过所花的代价不同而已
如上图,如果按照黄色路径走,所花的总代价是14,而按照粉红色路径走,所花的总代价是16,所以我们要做的事情,就是要尽最大努力找一条代价最小的路径。
但是,“好事总多磨”,即使是代价相同的最佳路径,也有可能出现不同的走法:
上图中三种不同的走法,总代价都是4.8,就上图而言,最佳路径(最小代价)用肉眼就能很快找出来,但是用代码如何估算起点与终点之间的代价呢?
02 | private function manhattan(node:Node): Number |
04 |
return Math.abs(node.x - _endNode.x) * _straightCost + Math.abs(node.y + _endNode.y) * _straightCost; |
08 | private function euclidian(node:Node): Number |
10 |
var dx: Number =node.x - _endNode.x; |
11 |
var dy: Number =node.y - _endNode.y; |
12 |
return Math.sqrt(dx * dx + dy * dy) * _straightCost; |
16 | private function diagonal(node:Node): Number |
18 |
var dx: Number =Math.abs(node.x - _endNode.x); |
19 |
var dy: Number =Math.abs(node.y - _endNode.y); |
20 |
var diag: Number =Math.min(dx, dy); |
21 |
var straight: Number =dx + dy; |
22 |
return _diagCost * diag + _straightCost * (straight - 2 * diag); |
上面的代码给出了三种基本的估价算法(也称估价公式),其算法示意图如下:
如上图,对于“曼哈顿算法”最贴切的描述莫过于孙燕姿唱过的那首成名曲“直来直往”,笔直的走,然后转个弯,再笔直的继续。
“几何算法”的最好解释就是“勾股定理”,算出起点与终点之间的直线距离,然后乘上代价因子。
“对角算法”综合了以上二种算法,先按对角线走,一直走到与终点水平或垂直平行后,再笔直的走。
我们可以针对刚才的情况做下测试:
03 |
import flash.display.Sprite; |
05 |
public class GridTest extends Sprite |
07 |
private var _endNode:Node; |
08 |
private var _startNode:Node; |
09 |
private var _straightCost: Number = 1.0 ; |
10 |
private var _diagCost: Number = 1.4 ; |
13 |
public function GridTest() |
15 |
var g:Grid= new Grid( 5 , 5 ); |
20 |
_startNode = g.startNode; |
22 |
var c1: Number = manhattan(_startNode); //8 |
23 |
var c2: Number = euclidian(_startNode); //4.47213595499958 |
24 |
var c3: Number = diagonal(_startNode); //4.8 |
30 |
private function manhattan(node:Node): Number |
32 |
return Math.abs(node.x - _endNode.x) * _straightCost + Math.abs(node.y - _endNode.y) * _straightCost; |
36 |
private function euclidian(node:Node): Number |
38 |
var dx: Number =node.x - _endNode.x; |
39 |
var dy: Number =node.y - _endNode.y; |
40 |
return Math.sqrt(dx * dx + dy * dy) * _straightCost; |
44 |
private function diagonal(node:Node): Number |
46 |
var dx: Number =Math.abs(node.x - _endNode.x); |
47 |
var dy: Number =Math.abs(node.y - _endNode.y); |
48 |
var diag: Number =Math.min(dx, dy); |
49 |
var straight: Number =dx + dy; |
50 |
return _diagCost * diag + _straightCost * (straight - 2 * diag); |
从输出结果可以看到“对角线估价法”跟肉眼预测的实际结果完全一致,总代价为4.8,以后默认情况下就用它了,不过这里提醒一下:这种代价是大概估计出来的,没有考虑到障碍物的因素,并非寻路过程中的实际代价,所以这也是“估价计算公式”而非“代价计算公式”得名的由来。
上一部分提到了节点(Node),代价(Cost),估价公式等基本概念,有了这些知识铺垫 就可以正式开启寻路之旅了!
如上图,这是一个5行8列的网格,黄色节点为起点,红色节点为终点,黑色节点为障碍物(节点)。
寻路过程可以这样考虑:
1、先以起点为中心,向周边扩张一圈,同时计算出周边节点(最多有8个)的单步代价g(即从中心点移动到相邻格子的代价:水平或垂直为1,对角为1.4);然后再计算周边每个节点到终点的估算代价h(利用上一部分最后讲到的估算公式),从而得出周围每个节点的总代价 f = g+h
2、同时准备二个数组,一个称为开放列表(open),一个称为封闭列表(closed),把周边节点放入open数组当中,然后利用数组排序对周边节点的f值从小到大排序,从而找出最小代价的节点----代价最小,意味着应该向这个节点移动。然后再把本轮计算中的中心点放入close数组(也就是意味着该既然找到了下一步,这一步就不用再考虑了)
3、把第2步中得到的代价最小的节点做为中心点,同时从open数组中删除该节点(因为既然已经找到了正确的一步,这一个节点就不用参与下次的排序了),继续向外扩展一圈,得到新的周边节点,同样计算g值和h值,但有一点要注意:因为现在的中心不是刚才的起点了,所以g值的计算实际由二部分组成,一部分为本次中心节点距离起点的g值(记为g1),一部分为本次中心节点到周围节点的g值(记为g2),最终每个周边节点的g = g1 + g2;而且还有一个要注意的地方:节点的相对位置可能发生变化,比如在前一轮中某些节点相对于前一轮中心节点处于水平或垂直位置,而在本轮计算中,由于中心点向前移动了一位,所以同样的节点在本轮计算时,可能会变成对中心节点的对角节点(即g2由1变成1.4),反之亦然;h值计算跟以前一样,类似前面的处理,得到最终代价f = g + h,同样:把新的周边节点放入open数组(如果还没有放入的话),对于在第2步中就已经放入open数组或close数组的节点,因为g值有可能发生变化了,所以也要重新比较本次得到的总代价f,跟上次计算得到的总代价f,如果本次计算的f值更小,则以本次计算的f值为准(把上一轮节点的f,h,g值更新掉),然后同样的处理,对open数组进行f值从小到大排序,得到代价最小的新节点,做为下一轮计算的中心。(当然下一轮计算前,同样要把已经找到的代价最小的节点从open数组中删除,同时把本次计算的中心点放入close数组)
4、按照前面的处理,如此这般反复下去,路径会一直不断的延伸下去(当然延伸的过程可能会有曲折或反复),但因为我们一直是在取代价最小的节点,所以总体来讲,肯定是越来越靠近终点。
5、在上面反复处理的过程中,一旦发现本轮计算的中心点就是终点,那么恭喜你,我们走到终点了!
好象看起来比较复杂,但其实想通了,就是不断的计算g,h,f,然后不断的对open数组的f值排序,然后找到最小f的节点,向前不断推进!为了方便测试每轮计算中各周边节点g,h,f,我写了一个粗糙的测试程序(很多参数要手动调整):
03 |
import flash.display.Sprite; |
06 |
public class GridTest extends Sprite |
08 |
private var _endNode:Node; //终点 |
09 |
private var _startNode:Node; //起点 |
10 |
private var _centerNode:Node; //本次计算的中心点 |
11 |
private var _straightCost: Number = 1.0 ; |
12 |
private var _diagCost: Number =Math.SQRT2; |
14 |
public function GridTest() |
16 |
var g:Grid= new Grid( 8 , 5 ); //生成一个5行8列的网格 |
17 |
g.setStartNode( 1 , 1 ); //设置起点 |
18 |
g.setEndNode( 6 , 3 ); //设置终点 |
21 |
_startNode=g.startNode; |
22 |
_centerNode = g.getNode( 2 , 1 ); //大家可以调整此中心点位置,以观察周边节点的f,h,g值 |
24 |
//这里借用了ascb第三方库,对数字进行格式化,取小数点后一个小数(如果大家没有ascb官方库,也可以直接输出数字) |
25 |
var fmr:NumberFormat = new NumberFormat(); |
28 |
var _g1: Number = diagonal(_centerNode,_startNode); //中心点相对起点的g值 |
30 |
//这里的x即为中心点周围节点的x范围(可手动调整) |
31 |
for ( var x: uint = 1 ; x <= 3 ; x++) |
33 |
//这里的y即为中心点周围节点的y范围(可手动调整) |
34 |
for ( var y: uint = 0 ; y <= 2 ; y++) |
36 |
var test:Node=g.getNode(x, y); |
37 |
var _h: Number =diagonal(test,_endNode); |
38 |
var _g2: Number = diagonal(test,_centerNode); |
39 |
var _g: Number =_g1 + _g2; //计算g值 |
41 |
trace ( "x=" , test.x, ",y=" , test.y, ",f=" , fmr.format(_f), ",g=" , fmr.format(_g), ",h=" , fmr.format(_h)); |
47 |
private function diagonal(node:Node,target:Node): Number |
49 |
var dx: Number =Math.abs(node.x - target.x); |
50 |
var dy: Number =Math.abs(node.y - target.y); |
51 |
var diag: Number =Math.min(dx, dy); |
52 |
var straight: Number =dx + dy; |
53 |
return _diagCost * diag + _straightCost * (straight - 2 * diag); |
跑一下,能得到下列输出结果:
x= 1 ,y= 0 ,f= 8.7 ,g= 2.4 ,h= 6.2
x= 1 ,y= 1 ,f= 7.8 ,g= 2.0 ,h= 5.8
x= 1 ,y= 2 ,f= 7.8 ,g= 2.4 ,h= 5.4
x= 2 ,y= 0 ,f= 7.2 ,g= 2.0 ,h= 5.2
x= 2 ,y= 1 ,f= 5.8 ,g= 1.0 ,h= 4.8
x= 2 ,y= 2 ,f= 6.4 ,g= 2.0 ,h= 4.4
x= 3 ,y= 0 ,f= 6.7 ,g= 2.4 ,h= 4.2
x= 3 ,y= 1 ,f= 5.8 ,g= 2.0 ,h= 3.8
x= 3 ,y= 2 ,f= 5.8 ,g= 2.4 ,h= 3.4
ok,还有一个重要问题,按上面的处理,我们就算走到了终点,也得到最后一个中心点(其实就是终点),如何把路径给构建(还原)出来呢?因为我们在处理过程中,并没有其它变量用来保存中间计算的结果(即每次找出的最小代价结节)?另外:就算用一个变量做为中介来保存每轮计算中的最佳节点,前面也提到了,向周边探讨寻路的过程中,完全有可能出现曲折反复的过程,难道最终找到的路径还要往回绕个圈(或打个结)走吗?如果是这样,那就违背了我们上一部分里寻找最佳(最短)路径的初衷。
其实这个问题不难处理:上一部分提到了一个父节点的概念! 在每轮计算过程中,相对于中心点周围的相邻节点而言,中心节点就是其它节点的父节点(也可理解为周边节点全都指向它!)如果每轮计算中找到最小代价节点后,把它的父节点指向为中心节点(也就是上一轮找到的最小代价节点),这样到最后走到终点时,利用父节点指向,从终点反向指向起点就能得到最佳路径。
无图无真相,还是上一张图吧
当然,上图只向前推进了二步,有耐心的同学可以根据前面给出的测试程序把剩下的步骤全部画出来(我自己画了几乎一整天),总体大概要经过13轮计算才能最终走到终点(因为中间有很多轮计算都会出现反复)
注:不知道有人注意到没有,在第一轮计算结束时,周边节点中有二个节点x2y1,x2y2,它们的总代价都是最小值5.8,为什么应该选择x2y1,而非x2y2呢?其实这个取决于循环的顺序,在遍历周边节点时,通常是用二重循环来处理的,如果按先x后y的顺序遍历,open数组排序后,x2y1就会排在第一个,如果是按先y后x的顺序遍历,open数组排序后,x2y2就会排在第一个,所以这个其实无所谓,完全取决于你的循环是怎么写的。(本文中采用的是先x后y的顺序遍历)
好了,该AStar.as类出场了,刚才的分析过程全部封装在里面了:
003 |
import flash.display.Sprite; |
005 |
public class AStar extends Sprite |
007 |
private var _open: Array ; //开放列表 |
008 |
private var _closed: Array ; //封闭列表 |
009 |
private var _grid:Grid; |
010 |
private var _endNode:Node; //终点 |
011 |
private var _startNode:Node; //起点 |
012 |
private var _path: Array ; //最终的路径节点 |
013 |
// private var _heuristic:Function = manhattan; |
014 |
// private var _heuristic:Function = euclidian; |
015 |
private var _heuristic:Function=diagonal; //估计公式 |
016 |
private var _straightCost: Number = 1.0 ; //直线代价 |
017 |
private var _diagCost: Number =Math.SQRT2; //对角线代价 |
019 |
public function AStar() |
025 |
private function isOpen(node:Node): Boolean |
027 |
for ( var i: int = 0 ; i < _open.length; i++) |
029 |
if (_open[i] == node) |
038 |
private function isClosed(node:Node): Boolean |
040 |
for ( var i: int = 0 ; i < _closed.length; i++) |
042 |
if (_closed[i] == node) |
051 |
public function findPath(grid:Grid): Boolean |
056 |
_startNode=_grid.startNode; |
057 |
_endNode=_grid.endNode; |
059 |
_startNode.h=_heuristic(_startNode); |
060 |
_startNode.f=_startNode.g + _startNode.h; |
065 |
public function search(): Boolean |
068 |
var node:Node=_startNode; |
070 |
while (node != _endNode) |
073 |
var startX: int =Math.max( 0 , node.x - 1 ); |
074 |
var endX: int =Math.min(_grid.numCols - 1 , node.x + 1 ); |
075 |
var startY: int =Math.max( 0 , node.y - 1 ); |
076 |
var endY: int =Math.min(_grid.numRows - 1 , node.y + 1 ); |
079 |
for ( var i: int =startX; i <= endX; i++) |
081 |
for ( var j: int =startY; j <= endY; j++) |
083 |
var test:Node=_grid.getNode(i, j); |
084 |
//如果是当前节点,或者是不可通过的,则跳过 |
085 |
if (test == node || !test.walkable) |
090 |
var cost: Number =_straightCost; |
092 |
if (!((node.x == test.x) || (node.y == test.y))) |
098 |
var g: Number =node.g + cost * test.costMultiplier; |
099 |
var h: Number =_heuristic(test); |
104 |
if (isOpen(test) || isClosed(test)) |
106 |
//如果本次计算的代价更小,则以本次计算为准 |
109 |
trace ( "\n第" ,_t, "轮,有节点重新指向,x=" ,i, ",y=" ,j, ",g=" ,g, ",h=" ,h, ",f=" ,f, ",test=" ,test.toString()); |
113 |
test.parent=node; //重新指定该点的父节点为本轮计算中心点 |
116 |
else //如果还不在open列表中,则除了更新代价以及设置父节点,还要加入open数组 |
126 |
_closed.push(node); //把处理过的本轮中心节点加入close节点 |
128 |
//辅助调试,输出open数组中都有哪些节点 |
129 |
for (i= 0 ;i<_open.length;i++){ |
130 |
trace (_open[i].toString()); |
133 |
if (_open.length == 0 ) |
135 |
trace ( "没找到最佳节点,无路可走!" ); |
138 |
_open.sortOn( "f" , Array .NUMERIC); //按总代价从小到大排序 |
139 |
node=_open.shift() as Node; //从open数组中删除代价最小的结节,同时把该节点赋值为node,做为下次的中心点 |
140 |
trace ( "第" ,_t, "轮取出的最佳节点为:" ,node.toString()); |
149 |
private function buildPath(): void |
152 |
var node:Node=_endNode; |
154 |
while (node != _startNode) |
162 |
private function manhattan(node:Node): Number |
164 |
return Math.abs(node.x - _endNode.x) * _straightCost + Math.abs(node.y - _endNode.y) * _straightCost; |
168 |
private function euclidian(node:Node): Number |
170 |
var dx: Number =node.x - _endNode.x; |
171 |
var dy: Number =node.y - _endNode.y; |
172 |
return Math.sqrt(dx * dx + dy * dy) * _straightCost; |
176 |
private function diagonal(node:Node): Number |
178 |
var dx: Number =Math.abs(node.x - _endNode.x); |
179 |
var dy: Number =Math.abs(node.y - _endNode.y); |
180 |
var diag: Number =Math.min(dx, dy); |
181 |
var straight: Number =dx + dy; |
182 |
return _diagCost * diag + _straightCost * (straight - 2 * diag); |
186 |
public function get visited(): Array |
188 |
return _closed.concat(_open); |
192 |
public function get openArray(): Array { |
197 |
public function get closedArray(): Array { |
201 |
public function get path(): Array |
为了方便调试输出信息,我还在Node.as中增加了一个toString方法
2 | public function toString(): String { |
3 |
var fmr:NumberFormat = new NumberFormat(); |
5 |
return "x=" + this .x.toString() + ",y=" + this .y.toString() + ",g=" + fmr.format( this .g) + ",h=" + fmr.format( this .h) + ",f=" + fmr.format( this .f); |
为了方便测试,又弄了一个类GridView.as,把画格子,高亮显示open数组/closed数组,画路径等操作封装起来了:
003 |
import flash.display.Sprite; |
004 |
import flash.events.MouseEvent; |
006 |
public class GridView extends Sprite |
008 |
private var _cellSize: int = 40 ; |
009 |
private var _grid:Grid; |
012 |
public function GridView(grid:Grid) |
017 |
addEventListener(MouseEvent.CLICK, onGridClick); |
021 |
public function drawGrid(): void |
024 |
for ( var i: int = 0 ; i < _grid.numCols; i++) |
026 |
for ( var j: int = 0 ; j < _grid.numRows; j++) |
028 |
var node:Node=_grid.getNode(i, j); |
029 |
graphics.lineStyle( 0 ); |
030 |
graphics.beginFill(getColor(node)); |
031 |
graphics.drawRect(i * _cellSize, j * _cellSize, _cellSize, _cellSize); |
037 |
private function getColor(node:Node): uint |
043 |
if (node == _grid.startNode) |
047 |
if (node == _grid.endNode) |
055 |
private function onGridClick(event:MouseEvent): void |
057 |
var xpos: int =Math.floor(event.localX / _cellSize); |
058 |
var ypos: int =Math.floor(event.localY / _cellSize); |
059 |
_grid.setWalkable(xpos, ypos, !_grid.getNode(xpos, ypos).walkable); |
065 |
private function findPath(): void |
067 |
var astar:AStar= new AStar; |
068 |
if (astar.findPath(_grid)) |
076 |
private function showVisited(astar:AStar): void |
080 |
var opened: Array =astar.openArray; |
081 |
for ( var i: int = 0 ; i < opened.length; i++) |
083 |
var node:Node = opened[i] as Node; |
085 |
graphics.beginFill( 0xcccccc ); |
086 |
if (node==_grid.startNode){ |
087 |
graphics.beginFill( 0xffff00 ); |
090 |
graphics.drawRect(opened[i].x * _cellSize, opened[i].y * _cellSize, _cellSize, _cellSize); |
094 |
var closed: Array =astar.closedArray; |
095 |
for (i= 0 ; i < closed.length; i++) |
097 |
node = opened[i] as Node; |
099 |
graphics.beginFill( 0xffff00 ); |
101 |
graphics.drawRect(closed[i].x * _cellSize, closed[i].y * _cellSize, _cellSize, _cellSize); |
107 |
private function showPath(astar:AStar): void |
109 |
var path: Array =astar.path; |
110 |
for ( var i: int = 0 ; i < path.length; i++) |
112 |
graphics.lineStyle( 0 ); |
113 |
graphics.beginFill( 0 ); |
114 |
graphics.drawCircle(path[i].x * _cellSize + _cellSize / 2 , path[i].y * _cellSize + _cellSize / 2 , _cellSize / 3 ); |
正式测试:
03 |
import flash.display.Sprite; |
04 |
import flash.display.StageAlign; |
05 |
import flash.display.StageScaleMode; |
06 |
import flash.events.MouseEvent; |
08 |
[SWF(backgroundColor= 0xffffff ,width= 360 ,height= 240 )] |
09 |
public class Pathfinding extends Sprite |
11 |
private var _grid:Grid; |
12 |
private var _gridView:GridView; |
14 |
public function Pathfinding() |
16 |
stage.align=StageAlign.TOP_LEFT; |
17 |
stage.scaleMode=StageScaleMode.NO_SCALE; |
19 |
_grid.setStartNode( 1 , 1 ); |
20 |
_grid.setEndNode( 6 , 3 ); |
23 |
_grid.getNode( 4 , 0 ).walkable = false ; |
24 |
_grid.getNode( 4 , 1 ).walkable = false ; |
25 |
_grid.getNode( 4 , 2 ).walkable = false ; |
26 |
_grid.getNode( 4 , 3 ).walkable = false ; |
28 |
_gridView= new GridView(_grid); |
黄色显示的是open列表,灰色显示的是closed列表,在每个节点上点击,可以在把相应节点切换为障碍物节点或普通节点.
当然这里面有一个小bug,不知道您看出来没有,下次再详解可能存在的问题。