转载地址如下:
http://bbs.9ria.com/thread-86464-1-1.html
对于A*寻路算法,可能是游戏开发者讨论最多的话题之一,很多游戏都会用到它来实现游戏角色的寻路。那么我这篇帖子的价值何在呢?先来看看传统A*算法存在的问题:
1.尴尬的Z型路径
当你在用A*算法实现了角色行走逻辑后,点击一个目标点,虽然你起点和目标点间没有任何障碍物,但角色还TMD蛋疼地进行了Z型行走路线,拐了好多次弯,他丫的那么风骚的走位,以为有人在用狙击枪瞄准他呢!?
2.无路可走
当你使用A*算法的时候你可能会发现,当你选择一个不可移动点或者一个被障碍物围住的“岛屿”点作为目标点的时候A*寻路算法会返回false的寻路结果,通知你它没有找到一条通路,那么此时你怎么办?角色只能站在原地干瞪眼,用户见状可能还以为自己鼠标失灵了呢。“咦?我明明点了那里,这角色咋不动捏?shit~!”
3.效率不高
你可能在网上或者书籍(如《ActionScript3 动画高级编程》)附赠光盘中下载过A*寻路源码,但运行后发现寻路一次会耗费10毫秒以上,有时候点击一个不可移动点或者一个封闭区域时更会耗费上百毫秒(因为此时A*算法会遍历全部的格子直至最终无法找到路径)。
这些问题相信很多A*算法使用者都遇到过,但是有一些人因为水平不足,无法继续深究下去,而且网上对此问题的解决方案也是没有任何参考资料可循。另一部分高手已找到解决之道,但是不愿意开源,以至于那么多年来网上依然缺乏对A*算法这些不足之处的解决之道。其实有时候想想,游戏公司的策划也蛮可怜的,他的想法很不错,但是程序员往往会以一句“没办法解决”或者“实现不了”就给浇了冷水,唉,可能这也是国内缺乏优秀游戏的原因之一吧。
那么,废话少说,马上开始我们的讲解吧,希望我这篇帖子能给一些迷途的道友们指引一下方向。
更合理的行走方式
对于第一个问题,可以用下图来解释这一现象,当我们选择一个目标点后,即使目标点与起始点间无障碍物存在,A*寻路产生的路径依然是曲折的:
这是由于A*寻路算法是基于格子进行寻路的,因此返回的寻路结果将是一个包含很多格子对象(假设格子对象的类名为Node)的数组,那么在行走的过程中自然是根据“到达路径数组中一个Node对象的屏幕坐标所在处之后以数组中下一个Node对象的屏幕坐标所在处为下一个目的地”这样的行走过程进行行走的.
那么如何避免这种情况的发生呢?我想只在必要的时候调用A*寻路行不行呢?当终点与起点间无任何障碍物的时候直线行走,有障碍物了再进行寻路行走,如下图所示:


有想法就有可能,impossible is nothing!
首先要解决的问题是如何判断起点和终点间是否存在障碍物,如果你还记得初中数学中“直线”这一章的内容(很多人看到这里估计要骂一句“holy shit”)的话应该不难想到利用直线的数学特性来解决这一难题。什么?你数学学的东西全TMD忘光了?好吧好吧,还是让贫道来指引一下吧……
先看到下图,我们把两点的中心点用直线连接起来,直线经过的格子都以屎黄色标示(我就喜欢屎黄色),当然,不包含这两个当事点^_^

此时我们就可以依次检查这些大便节点(即用屎黄色填充的点)中是否有一个是障碍点,若有任意一个是障碍点,那么就表示着我这两个当事点之间走直线是行不通地!
说着简单吧?那就做着吧……可是……用 代码怎么写啊?说到这,我当初也确实被难住了,用代码实现这一数学思想的确有些困难,那么,我们一步步来吧。
首先我们要想正确地用代码获知这些大便节点并非易事,我首先想到的方案是以一个节点宽度为步长,从起点到终点横向遍历出它们之间连线(假设此连线叫 l )与每个节点左边缘的交点,见下图:

图上绿色的点就是我们第一步需要求得的线段 l 与起点终点间所有节点的交点了,从图上我们发觉,由于 l 是起点与终点节点中心点的连线,所以第一次遍历时取的步长是半个节点宽,之后遍历的步长则是一个节点宽了。那么求得这些点有什么用呢?从图上看到,只要正确地得到了这些关键点,之后就可以求每个关键点所毗邻的全部节点以最终得到全部的大便节点,如上图中最左边这个绿色的关键点它所毗邻的两个节点是起点(红色圆球所在点)以及起点右边那个(1,0)号点。由此,我们可以先把循环体给定下来了,如果假设我们用来计算两点间是否存在障碍物的方法名叫做hasBarrier,那么它的代码雏形如下:
- /**
- * 判断两节点之间是否存在障碍物
- *
- */
- public function hasBarrier( startX:int, startY:int, endX:int, endY:int ):Boolean
- {
- //为了运算方便,以下运算全部假设格子尺寸为1,格子坐标就等于它们的行、列号
- /** 循环递增量 */
- var i:Number;
- /** 循环起始值 */
- var loopStart:Number;
-
- /** 循环终结值 */
- var loopEnd:Number;
- loopStart = Math.min( startX, endX );
- loopEnd = Math.max( startX, endX );
- //开始横向遍历起点与终点间的节点看是否存在障碍(不可移动点)
- for( i=loopStart; i<=loopEnd; i++ )
- {
- //由于线段方程是根据终起点中心点连线算出的,所以对于起始点来说需要根据其中心点
- //位置来算,而对于其他点则根据左上角来算
- if( i==loopStart )i += .5;
-
- ............
- if( i == loopStart + .5 )i -= .5;
- }
- }

按上面我所说的横向遍历的规则,第一次遍历我求得了上图左边这个绿色点,第二次遍历求得了右边这个绿色点,在求得此二关键点后求出它们各自所毗邻的节点并在图上以屎黄色标示,发现遍历结果中漏掉了中间这块节点。
那么咋办呢?细心的道友会提出一个方案:对 l 倾斜角大于45度角的情况(此时起点与终点间纵向距离大于横向距离)使用纵向遍历,而对倾斜角小于45度 的情况(此时起点与终点间横向距离大于纵向距离)使用横向遍历,这样就不会漏掉任何一个大便点了。没有错,答案就是如此,奖励这位回答正确的同学一只小红花,哦不,还是奖励小菊花吧~再回头看看刚才那个漏掉大便点的情况,那时 l 倾斜角已大于45度,因此采用纵向遍历,结果如下:

oh, yeah, perfect!
既然遍历的方向要根据情况而定,所以原先代码将更改为下面这样:
- //根据起点终点间横纵向距离的大小来判断遍历方向
- var distX:Number = Math.abs(endX - startX);
- var distY:Number = Math.abs(endY - startY);
- /**遍历方向,为true则为横向遍历,否则为纵向遍历*/
- var loopDirection:Boolean = distX > distY ? true : false;
- /** 循环递增量 */
- var i:Number;
-
- /** 循环起始值 */
- var loopStart:Number;
-
- /** 循环终结值 */
- var loopEnd:Number;
-
- //为了运算方便,以下运算全部假设格子尺寸为1,格子坐标就等于它们的行、列号
- if( loopDirection )
- {
-
- loopStart = Math.min( startX, endX );
- loopEnd = Math.max( startX, endX );
-
- //开始横向遍历起点与终点间的节点看是否存在障碍(不可移动点)
- for( i=loopStart; i<=loopEnd; i++ )
- {
- //由于线段方程是根据终起点中心点连线算出的,所以对于起始点来说需要根据其中心点
- //位置来算,而对于其他点则根据左上角来算
- if( i==loopStart )i += .5;
-
- …………
-
- if( i == loopStart + .5 )i -= .5;
- }
- }
- else
- {
- loopStart = Math.min( startY, endY );
- loopEnd = Math.max( startY, endY );
-
- //开始纵向遍历起点与终点间的节点看是否存在障碍(不可移动点)
- for( i=loopStart; i<=loopEnd; i++ )
- {
- if( i==loopStart )i += .5;
- …………
-
- if( i == loopStart + .5 )i -= .5;
- }
- }

要求得绿点的 y 值,只需要将它们的 x 值代入线段 l 的直线方程(假设直线方程为 y = ax + b )中即可。所以接下来要做的事情就是先求出这个直线方程中的未知数 a 与 b 的值。
既然我们已知了该线段两段的端点坐标,把它们的坐标值代入方程即可求得未知数 a 与 b。我把这一数学求解方程的代码放在一个数学类MathUtil.as中,代码如下:
- package
- {
- import flash.geom.Point;
- /**
- * 寻路算法中使用到的数学方法
- * @author Wangzhouquan
- *
- */
- public class MathUtil
- {
-
- /**
- * 根据两点确定这两点连线的二元一次方程 y = ax + b或者 x = ay + b
- * @param ponit1
- * @param point2
- * @param type 指定返回函数的形式。为0则根据x值得到y,为1则根据y得到x
- *
- * @return 由参数中两点确定的直线的二元一次函数
- */
- public static function getLineFunc(ponit1:Point, point2:Point, type:int=0):Function
- {
- var resultFuc:Function;
-
- // 先考虑两点在一条垂直于坐标轴直线的情况,此时直线方程为 y = a 或者 x = a 的形式
- if( ponit1.x == point2.x )
- {
- if( type == 0 )
- {
- throw new Error("两点所确定直线垂直于y轴,不能根据x值得到y值");
- }
- else if( type == 1 )
- {
- resultFuc = function( y:Number ):Number
- {
- return ponit1.x;
- }
-
- }
- return resultFuc;
- }
- else if( ponit1.y == point2.y )
- {
- if( type == 0 )
- {
- resultFuc = function( x:Number ):Number
- {
- return ponit1.y;
- }
- }
- else if( type == 1 )
- {
- throw new Error("两点所确定直线垂直于y轴,不能根据x值得到y值");
- }
- return resultFuc;
- }
-
- // 当两点确定直线不垂直于坐标轴时直线方程设为 y = ax + b
- var a:Number;
-
- // 根据
- // y1 = ax1 + b
- // y2 = ax2 + b
- // 上下两式相减消去b, 得到 a = ( y1 - y2 ) / ( x1 - x2 )
- a = (ponit1.y - point2.y) / (ponit1.x - point2.x);
-
- var b:Number;
-
- //将a的值代入任一方程式即可得到b
- b = ponit1.y - a * ponit1.x;
-
- //把a,b值代入即可得到结果函数
- if( type == 0 )
- {
- resultFuc = function( x:Number ):Number
- {
- return a * x + b;
- }
- }
- else if( type == 1 )
- {
- resultFuc = function( y:Number ):Number
- {
- return (y - b) / a;
- }
- }
-
- return resultFuc;
- }
- }
- }
好了,有了这个方法以后我们要求出绿色的关键点应该是没有问题了,接下来要做的就是根据一个关键点求出它所毗邻的节点有几个,它们分别是哪些。一般来说,最多可能有4个节点共享一个关键点,最少则是一个节点拥有一个关键点:

如果假设一个节点的宽、高均为1,那么如果一个点的 x 、y 值都不是整数那就可以判定它只可能由一个节点拥有;如果 x 值为整数则表示此点会落在两个节点横向的临边上;如果 y 值为整数则表示此点会落在两个节点纵向的临边上。由此可得getNodesUnderPoint方法:
- /**
- * 得到一个点下的所有节点
- * @param xPos 点的横向位置
- * @param yPos 点的纵向位置
- * @param exception 例外格,若其值不为空,则在得到一个点下的所有节点后会排除这些例外格
- * @return 共享此点的所有节点
- *
- */
- public function getNodesUnderPoint( xPos:Number, yPos:Number, exception:Array=null ):Array
- {
- var result:Array = [];
- var xIsInt:Boolean = xPos % 1 == 0;
- var yIsInt:Boolean = yPos % 1 == 0;
-
- //点由四节点共享情况
- if( xIsInt && yIsInt )
- {
- result[0] = getNode( xPos - 1, yPos - 1);
- result[1] = getNode( xPos, yPos - 1);
- result[2] = getNode( xPos - 1, yPos);
- result[3] = getNode( xPos, yPos);
- }
- //点由2节点共享情况
- //点落在两节点左右临边上
- else if( xIsInt && !yIsInt )
- {
- result[0] = getNode( xPos - 1, int(yPos) );
- result[1] = getNode( xPos, int(yPos) );
- }
- //点落在两节点上下临边上
- else if( !xIsInt && yIsInt )
- {
- result[0] = getNode( int(xPos), yPos - 1 );
- result[1] = getNode( int(xPos), yPos );
- }
- //点由一节点独享情况
- else
- {
- result[0] = getNode( int(xPos), int(yPos) );
- }
-
- //在返回结果前检查结果中是否包含例外点,若包含则排除掉
- if( exception && exception.length > 0 )
- {
- for( var i:int=0; i<result.length; i++ )
- {
- if( exception.indexOf(result[i]) != -1 )
- {
- result.splice(i, 1);
- i--;
- }
- }
- }
-
- return result;
- }
- /**
- * 判断两节点之间是否存在障碍物
- *
- */
- public function hasBarrier( startX:int, startY:int, endX:int, endY:int ):Boolean
- {
- //如果起点终点是同一个点那傻子都知道它们间是没有障碍物的
- if( startX == endX && startY == endY )return false;
-
- //两节点中心位置
- var point1:Point = new Point( startX + 0.5, startY + 0.5 );
- var point2:Point = new Point( endX + 0.5, endY + 0.5 );
-
- //根据起点终点间横纵向距离的大小来判断遍历方向
- var distX:Number = Math.abs(endX - startX);
- var distY:Number = Math.abs(endY - startY);
-
- /**遍历方向,为true则为横向遍历,否则为纵向遍历*/
- var loopDirection:Boolean = distX > distY ? true : false;
-
- /**起始点与终点的连线方程*/
- var lineFuction:Function;
-
- /** 循环递增量 */
- var i:Number;
-
- /** 循环起始值 */
- var loopStart:Number;
-
- /** 循环终结值 */
- var loopEnd:Number;
-
- /** 起终点连线所经过的节点 */
- var passedNodeList:Array;
- var passedNode:Node;
-
- //为了运算方便,以下运算全部假设格子尺寸为1,格子坐标就等于它们的行、列号
- if( loopDirection )
- {
- lineFuction = MathUtil.getLineFunc(point1, point2, 0);
-
- loopStart = Math.min( startX, endX );
- loopEnd = Math.max( startX, endX );
-
- //开始横向遍历起点与终点间的节点看是否存在障碍(不可移动点)
- for( i=loopStart; i<=loopEnd; i++ )
- {
- //由于线段方程是根据终起点中心点连线算出的,所以对于起始点来说需要根据其中心点
- //位置来算,而对于其他点则根据左上角来算
- if( i==loopStart )i += .5;
- //根据x得到直线上的y值
- var yPos:Number = lineFuction(i);
-
- //检查经过的节点是否有障碍物,若有则返回true
- passedNodeList = getNodesUnderPoint( i, yPos );
- for each( passedNode in passedNodeList )
- {
- if( passedNode.walkable == false )return true;
- }
-
- if( i == loopStart + .5 )i -= .5;
- }
- }
- else
- {
- lineFuction = MathUtil.getLineFunc(point1, point2, 1);
-
- loopStart = Math.min( startY, endY );
- loopEnd = Math.max( startY, endY );
-
- //开始纵向遍历起点与终点间的节点看是否存在障碍(不可移动点)
- for( i=loopStart; i<=loopEnd; i++ )
- {
- if( i==loopStart )i += .5;
- //根据y得到直线上的x值
- var xPos:Number = lineFuction(i);
-
- passedNodeList = getNodesUnderPoint( xPos, i );
-
- for each( passedNode in passedNodeList )
- {
- if( passedNode.walkable == false )return true;
- }
-
- if( i == loopStart + .5 )i -= .5;
- }
- }
- return false;
- }
问:写hasBarrier这个方法的目的是什么?()
A:随便写着玩玩
B:for the lich king!
C:避免在不必要的时候依然使用A*寻路
D:喂,不要问不该问的东西
上题的参考答案是 C,你答对了吗?如果你答对了,那么在恭喜你的同时我们也该继续下一步操作了。通常人物的行走是由鼠标点击触发的,那么在鼠标点击事件的处理函数中,需要根据点击的目的地来选择是否启用A*寻路算法来进行寻路。
- private function onGridClick(event:MouseEvent):void
- {
- //起点是玩家对象所在节点位置,终点是鼠标点击的节点
- var startPosX:int = Math.floor(_player.x / _cellSize);
- var startPosY:int = Math.floor(_player.y / _cellSize);
-
- var endPosX:int = Math.floor(mouseX / _cellSize);
- var endPosY:int = Math.floor(mouseY / _cellSize);
-
- //判断起终点间是否存在障碍物,若存在则调用A*算法进行寻路,通过A*寻路得到的路径是一个个所要经过的节点数组;否不存在障碍则直接把路径设置为只含有一个终点元素的数组
- var hasBarrier:Boolean = _grid.hasBarrier(startPosX, startPosY, endPosX, endPosY);
- if( hasBarrier )
- {
- _grid.setStartNode(startPosX, startPosY);
- _grid.setEndNode(endPosX, endPosY);
-
- findPath();
- }
- else
- {
- _path = [_grid.getNode(endPosX, endPosY)];
- _index = 0;
- addEventListener(Event.ENTER_FRAME, onEnterFrame);//开始行走
- }
-
- }
-
- private function findPath():void
- {
- var astar:AStar = new AStar();
- if(astar.findPath(_grid))
- {
- _path = astar.path;
- _index = 0;
- addEventListener(Event.ENTER_FRAME, onEnterFrame);//开始行走
- }
- }