这次故事背景的主人翁是我的一个同事,他虽然是个3D网游苦手,但热情不减。每周游戏时间,他都非常积极参与副本活动,玩得非常爽。不过偶然也有不爽的时候,比如说最近这次,他发现天下贰的自动寻路出bug了,在点了自动寻路后,人物在路上掉到一个坑里面,再也上不来了。
这到底是什么原因呢?上次的科普中,我只是简单把地图的点划分为walkable/unwalkable,但实际上,游戏当中的地形当然没有这么简单了,除了平地和障碍物,还有水、泥沙地、斜坡、悬崖等等。不同的地形,按游戏的设计,可以有不同的耗费“cost”(比如斜坡、泥沙地走起来要“费力”一点,cost可以设置比平地大一点;又比如像WOW和天下贰,人物都是水陆两用的,但水里面移动也显然要“费力”一点,所以cost应适当大一些;有些比较老的游戏,水则是归为unwalkable一类,这都要看整个游戏的设计)。而3D游戏中,还牵扯到一个地势的问题,也就是“海拔”问题,等下会详细探讨。
扯到地形地势这些,必须讲下地图建模(map representation,下面简称构图)。上次科普中,我漏了介绍这个东西,实际上它是A*算法中一个非常重要的component。什么叫构图?就像我上次用的那个二维矩阵map,它就是一种构图,叫格子图(grids)。像A*这种算法,当然不限于在格子图上跑了,它还可以在比如:多边形地图(polygonal map)、导航网(navigation meshes)、路径点(waypoints)等等的构图上跑。下面稍微简单介绍一下:
多边形地图,实际上是以障碍物(也就是unwalkable的地方)的顶点作一个多边形,然后在不同多边形的顶点之间连上直线(直线不能穿过任何多边形),比如这样一个图:
人物可以沿着障碍物的边或者在绿色直线表示的路径上移动。当穿越一个大区域的耗费是一个固定值时,这种构图能够获得很高的效率。
导航网跟多边形地图相反,它是以walkable的地方作多边形,比如这样一个图:
人物在绿色直线构成的“网”内移动,具体移动的节点,可以是多边形的中心,也可以是顶点,边的中点,或者三者的结合。如图,我们选取顶点和边的中点作为节点,黄色线条是A*跑出来的“最短路径”,粉红色的则是通过path smoothing技术得出来的一条更理想的路径(path smoothing解释:假如节点i和节点i+2能够直达,则移除节点i+1,让节点i直接指向节点i+2;重复这个过程直到相邻节点不能够直达)。
剩下的路径点都听很多了,像CS和DOTA里面的AI图,都要用到路径点。简单来说就是在地图上设定若干个点,人物可以在点与点之间的连线移动。一般来说,合理的设置waypoints可以减轻A*的负担,如果点不多,连线较少,甚至可以把每个点到其它点的最短路径预处理好,而不用实时计算。当然,waypoints不好的地方就是出来的路线比较生硬,所以你看CS里面的机器人走路都是傻傻的,而DOTA里的电脑玩家也不会绕树林或者去打roshan(roshan处没放路径点)。
上面说的是一些常用的构图,实际游戏开发当中,尤其是地图比较大时,往往不会使用单一的构图方式,地图通常都是hierarchical。也就是说,你的地图有多个level的精细度,每一个level选取一种“性价比”最高的构图方式。举个例子,从一个城市的一个小区的一个平房里,去到另外一个城市的一个小区的一个平房里,我们可以这样划分:平房里的障碍物布置比较精细,选用grids;出了房间,小区障碍物数量适中,我们用navigation mesh;到了城市与城市之间,地形相当开阔,这时我们用polygonal map或者预处理好的waypoints就行了。这样得出来的路径往往不是最优的,但会接近最优。其实这么大范围的寻路,路径是否最优,玩家是感觉不出来的;但如果你为了追求最优路径而让A*跑得很慢,机器卡在那,玩家估计砸了屏幕的心都有了。所以说这里除了算法,还蕴涵了我经常挂嘴边的:tradeoff的思想。在这次科普中,我会继续选用grids来构图,因为demo还是console输出,而grids在console条件下的展示比较直观。如果用其他构图,效果就不是那么好呈现了。
回到我们刚才那个问题,人物掉到坑里,到底是怎样一回事呢?看下面这个图:
t |||||||||||||||||||| | ||| 1 | || | || | || || 2 ||| || || || || || || |||||||| |||| s ||||||||||||
区域“1”是起点“s”和目标“t”所在的平地,处于同一地势高度;区域“2”是一个盆地,地势比区域“1”要低,它被“|”表示的陡坡紧紧围住。这个地图有个特征,从区域“1”到区域“2”没问题,从区域“2”再上来就有问题了。我同事遇到的那个情景正是如此,从“s”到“t”的最短路径,如果不考虑陡坡,肯定是它们之间的连线,但如果考虑到这种“下去了就上不来”的情况,该如何是好?我们要怎样绕过这个“坑”?你可能说,把陡坡设为unwalkable行不?当然不行,假如我就是要寻路到2里面的点(这种情况需要2里面有些回城点,或者人物自己具有回城的能力才行,不然还是有问题),或者我在靠近“t”的位置开一个缺口,用斜坡“/”这种地形取代陡坡,让人物可以从这个缺口走上来,那这种设定就会有问题了。
这时,我们需要一个辅助的二维矩阵,来记录每个区域的地势。例如区域“1”的所有点,我们把地势设为0;区域“2”的点,设为-10;到了陡坡“|”,该怎样设呢?其实我们要表达陡坡,那就将其地势设置得跟其中一边的地势相当接近就行了,可以设为跟区域“1”相当接近的-1,用这个图表示一目了然:
设好后,我们只要在扩展节点时,增加判断:如果相邻节点减去当前节点的“地势差”大于某个阈值时,就判断成unwalkable,不进行扩展就行了。在这里,阈值可以设为7(只要<9且>=5就可以了,想想为什么?),这个数值使得地势为0的区域可以经过“陡坡”,进入地势为-10的区域,反之不行;而且能够让这两个区域经过“斜坡”都能互相进入,这就是我们想要的。好,说了这么多,看代码和演示吧:
from heapq import heappush, heappop class Node(object): def __init__(self, x, y, g, parent_node): self._x = x self._y = y self._g = g self._parent_node = parent_node @property def x(self): return self._x @property def y(self): return self._y @property def g(self): return self._g @property def parent_node(self): return self._parent_node class OpenList(object): # open list 的封装 def __init__(self): self._heap = [] # 保存(f, (x, y)),相当于优先队列,每次弹出的必定是f值最少的坐标 self._dict = {} # 保存(x, y)到相应node的映射,用于快速从open list里判重 def add_node(self, f, node): heappush(self._heap, (f, (node.x, node.y))) self._dict[(node.x, node.y)] = node def get_node(self, x, y): return self._dict.get((x, y), None) def pop_node(self): f, (x, y) = heappop(self._heap) node = self._dict.get((x, y), None) if node: # 我的A*实现使用了一个小trick来提高判重效率,使得self._heap和self._dict不是100%同步的,但不影响正确性 del self._dict[(x, y)] return (x, y), node def __len__(self): return len(self._heap) map = [ # 空白表示平地,“|”表示陡坡,等下还有个“/”表示斜坡,“s”和“t”为了编程方便,没有在地图里写死 ' ', ' ', ' ', ' |||||||||||||||||||| ', ' | ||| ', ' | || ', ' | || ', ' | || ', ' || ||| ', ' || || ', ' || || ', ' || || ', ' |||||||| |||| ', ' |||||||||||| ', ' ', ' ', ' ', ' ', ] terrain_cost = { # 不同地形的cost,这里比较简单,只有3种。 # 陡坡为什么是1?因为从高地方走到低地方,跟平地一样容易;而反之会有unwalkable的判定。所以障碍“x”也不需要写在这里 ' ': 1, # 平地 '|': 1, # 陡坡 '/': 2, # 斜坡 } # 地势高度,默认设为0 terrain_height = [[0 for v in range(len(map[0]))] for w in range(len(map))] # 地势高度差阈值 MAX_HEIGHT_DELTA = 7 # directions: # NW N NE # W M E # SW S SE directs = ( {'dx': -1, 'dy': 0, 'cost': 10}, # N {'dx': 0, 'dy': 1, 'cost': 10}, # E {'dx': 1, 'dy': 0, 'cost': 10}, # S {'dx': 0, 'dy': -1, 'cost': 10}, # W {'dx': -1, 'dy': 1, 'cost': 14}, # NE {'dx': -1, 'dy': -1, 'cost': 14}, # NW {'dx': 1, 'dy': -1, 'cost': 14}, # SW {'dx': 1, 'dy': 1, 'cost': 14}, # SE ) def set_area_height(x, y, terrain, height): # 小广搜,对同一片相连的地形区域染色,设为同一地势高度;实际当中这事情都是预处理好的,不需要在跑A*之前重新设 assert(map[x][y] == terrain) to_visit = set([(x, y)]) visited = set() max_x, max_y = len(map) - 1, len(map[0]) - 1 while len(to_visit) > 0: cur_x, cur_y = to_visit.pop() terrain_height[cur_x][cur_y] = height visited.add((cur_x, cur_y)) for v in directs: next_x, next_y = cur_x + v['dx'], cur_y + v['dy'] if next_x > max_x or next_x < 0 or next_y > max_y or next_y < 0 \ or map[next_x][next_y] != terrain \ or (next_x, next_y) in visited \ or (next_x, next_y) in to_visit: continue to_visit.add((next_x, next_y)) def astar(start_node, target): open_list = OpenList() close_list = set() max_x, max_y = len(map) - 1, len(map[0]) - 1 open_list.add_node(0, start_node) while len(open_list) > 0: (cur_x, cur_y), cur_node = open_list.pop_node() if (cur_x, cur_y) in close_list: continue close_list.add((cur_x, cur_y)) if cur_x == target.x and cur_y == target.y: # 当前节点是目标节点,通过回溯找到最短路径 path = [cur_node, ] next_parent = cur_node.parent_node while next_parent is not None: path.append(next_parent) next_parent = next_parent.parent_node return True, path # 遍历当前节点各个方向上的相邻节点 for v in directs: next_x, next_y = cur_x + v['dx'], cur_y + v['dy'] if next_x > max_x or next_x < 0 or next_y > max_y or next_y < 0 \ or map[next_x][next_y] == 'x' \ or (next_x, next_y) in close_list \ or terrain_height[next_x][next_y] - terrain_height[cur_x][cur_y] > MAX_HEIGHT_DELTA: # 节点不可达,或者已经在close_list里面,或者地势差太高,不能前进 continue # f = g + h, g表示起点到该相邻节点的实际距离,h是相邻节点到目标节点的估算值 next_g = cur_node.g + v['cost'] * terrain_cost[map[next_x][next_y]] # manhattan距离 next_h = 10 * (abs(next_x - target.x) + abs(next_y - target.y)) next_f = next_g + next_h # 看该相邻节点是否已经在open_list里面 old_node = open_list.get_node(next_x, next_y) if old_node: if next_g < old_node.g: # 更新g、f值,和改变父节点:直接用新构造的node覆盖原来的node next_node = Node(next_x, next_y, next_g, cur_node) open_list.add_node(next_f, next_node) else: next_node = Node(next_x, next_y, next_g, cur_node) open_list.add_node(next_f, next_node) return False, None def test_astar(): print "before A* :" for v in map: print v start_node = Node(13, 10, 0, None) target_node = Node(1, 40, 0, None) # 设置地势高度(设最大那片面积为基点,默认为0,不用重新设) set_area_height(3, 25, '|', -1) set_area_height(4, 25, ' ', -10) #set_area_height(6, 36, '/', -5) success, path = astar(start_node, target_node) print "after A* :" if not success: print 'target unreachable' else: # 显示找到的路径 res = [] for i in xrange(len(map)): res.append([]) for j in xrange(len(map[0])): res[i].append(map[i][j]) for v in path: res[v.x][v.y] = '.' for row in res: print "".join(v for v in row) if __name__ == '__main__': test_astar()
程序运行结果:
before A* : |||||||||||||||||||| | ||| | || | || | || || ||| || || || || || || |||||||| |||| |||||||||||| after A* : ......................... . |.|||||||||||||||||| . ||| . || . || . || .| ||| .|| || . || || . || || . |||||||| |||| . ||||||||||||
可以看到,找到的最优路径是紧贴陡坡而没有掉下去的。那我们改改地图,在陡坡圈的右上方开一个缺口,做成“斜坡”:
|||||||||||||||||||| | ||| | || | |/ <---- 看到没有?在这个地方 | || || ||| || || || || || || |||||||| |||| ||||||||||||
接着将该点的地势设为-5,再跑跑A*,看看这次效果是怎样的:
before A* : |||||||||||||||||||| | ||| | || | |/ | || || ||| || || || || || || |||||||| |||| |||||||||||| after A* : . . |||||||||||||||||||| . | ||| . | || . | |. | ....................|| ||. ||| .| || . || || . || || . |||||||| |||| . ||||||||||||
看到没有?程序知道这后边有个缺口了,果断走下去从缺口来到目标;而不是像刚才那样,知道下去了就不能上来,被迫去抄远路。
不过话说回来,我同事遇到的那个问题,也不一定是它的A*算法写错了,也有可能是它的数据,比如说地势这个值没设好,使得A*做出了错误的判断。再牛逼的算法,在错误的数据面前,也是无能为力的。
好,A*简介就到这里,下次有机会,我会从构图或者启发式函数当中选取一个方面,进行深入探讨,谢谢大家!