您的位置:首页 > 移动开发 > Unity3D

在Unity里调试A星(A*)寻路

2015-05-28 16:05 232 查看
原文地址:http://t-machine.org/index.php/2014/08/10/debugging-a-pathfinding-in-unity/

说明:在文章中有时出现A星有时出现A*,都是一个东西,要是不理解,可以先看看原作者推荐的那几篇文章……

正文:

我的项目需要一个快速、高效、强大和适应性强的寻路系统。我的角色要飞、走、爬、瞬移穿过一个程序生成的陆地。完成这个要求有些复杂,但是过程却很有趣。

我想要一个模块,它能智能的引导障碍周围的怪物,并选择比较容易走的路,而不是从悬崖边走过去(反之亦然,比如巨型蜘蛛)。就像这样:



Unity Asset Store

Unity里要做任何事,访问Asset Store都应该是第一步。

我搜索排名靠前的几个寻路资源包,并且测试了有webPlayer或者免费版本的demo。

其中有些挺好,但是总的来说让人失望。性能不错,其中大部分都用了协程或者后台线程来控制CPU的使用,这很不错!(相对容易自己实现,话说这是A*的两大特性之一)

……但是用户接口很糟糕(我理解不了……)或者代码很难写,或者丢失了A星的核心元素的设置(比如动态边界dynamic edge:在游戏里使用A*的另一个原因)。

如果只是简单用用,它们很多都不错。24小时热卖资源里有一个看起来很好,由一个大团队维护——但是他们很精明(只能在你购买后才能看到文档!)而且看上去代码相对难写。

最后,我放弃了,于是我想:

也许……每个创新的游戏都需要你重新实现A*算法,来保证你的游戏独一无二的特点?

(A*不难实现,有很多选择方式,也许在这件事上重复造轮子是一件好事)。

A星是什么,如何工作?如何实现?

我推荐以下几篇文章:

2014: AmitP’s interactive A-star explanation— 很好理解

2009: AmitP’s original A-star explanation (non-interactive) —很多人自己实现A*时都参考它

这几篇文章留给我们一个基本算法:

OPEN = priority queue containing START
CLOSED = empty set
while lowest rank in OPEN is not the GOAL:
current = remove lowest rank item from OPEN
add current to CLOSED
for neighbors of current:
cost = g(current) + movementcost(current, neighbor)
if neighbor in OPEN and cost less than g(neighbor):
remove neighbor from OPEN, because new path is better
if neighbor in CLOSED and cost less than g(neighbor): **
remove neighbor from CLOSED
if neighbor not in OPEN and neighbor not in CLOSED:
set g(neighbor) to cost
add neighbor to OPEN
set priority queue rank to g(neighbor) + h(neighbor)
set neighbor's parent to current

reconstruct reverse path from goal to start
by following parent pointers


在Unity里用C#实现

首先:Unity在使用C#语法时有些问题。泛泛的说,一些C#代码在Unity里会引起问题——最常见的例子:多维数组。你可以给Unity打补丁修复它,但是这么做不太常规。替代方法是简单粗暴的实现一个更底层的结构(很烂,但是实用)。

其次:微软C#/.NET标准库有一个内置的SortedList,我直说吧它名字起错了。你在每个位置只能有一个元素(这是sorted Map或者Set-sorted List,不是sorted List)。在我们的例子里这是一个错误的数据结构,虽然你可以通过定制它让它工作(让每个物品一个List,把节点从List移到另一个List)——哎呦,复杂!

还是一样:很烂,但是实用。我个人写了我自己的SortedList。

Node类

可能你游戏里已经有类似的结构了:

public class Node
{
public int x, int y;
}


graph / grid类

举个例子:

public class Grid
{
public Node[] allNodes;
public int nodesAcross, nodesDown; // needed to workaround Unity bugs
public Node NodeAt( int x, int y ) // needed to workaround Unity bugs
{
return allNodes[ x + y*nodesAcross];
}
public void SetNodeAt( int x, int y, Node n ) // needed to workaround Unity bugs
{
allNodes[ x + y*nodesAcross] = n;
}


写一个自己的SortedList

超简单,但是第一次写的时候我绕进去了,因为我不熟悉C#内部的foreach循环里如何保持整洁。我认为你也可能有类似的问题。对于A*你只需要一个包含以下方法的类:

public class MySortedList<T> where T: class
{
public MySortedList();
public int Count();
public T GetFirst();
public T RemoveFirst();
public void RemoveItem( T victimItem );
public bool Contains( T searchItem );
public int AddWithValue( T newItem, float newValue );
}


检查你的算法

这是一个数据敏感的算法,我们创建了一些数据结构(不太危险)和一个自定义的容器(恩,危险!容易出错)。

我强烈建议你给MySortedList写单元测试。我没有这么做(我还没找到一个适用于Unity的好的单元测试框架),而且很后悔。特别关注以下几点:

测试Contains方法是否正确
测试Remove方法是否真的移除了对象
省略上面的任何一项,A星有可能陷入死循环,因为它持续添加节点到列表里。尤其是对Contains的测试,确保为你的自定义类实现了Equals和GetHashcode!

调试你的算法

现在到有趣的部分了,这是一个重度数据的算法:在真正的游戏中,它会处理成千上万的节点,以及数以万计的边界。调试它简直就是下地狱,千万别那么做啊。

你第一步的思考可能是:

我可以给调试A星弄个界面吗?这样调试它就变得又快又容易?

我的思路:

做个Calculate A Path的组件
把开始节点和结束节点放进去
写一个编辑器类,增加一个按钮“Calculate Path”

A星输出一个Path对象
Calculate A Path组件持有这个对象
创建一个新的GameObject
把Path对象作为它的子对象
给它附加一个PathVisualizer组件

PathVisualizer组件实现了OnGizmos()
在每个节点都绘制一个立方体
在开始和结束节点的立方体和其他立方体颜色不同
在节点之间通过线段连接

需要注意的事:

因为每个路径都附加到一个GameObject上,你可以微调一下你的A*实现,在编辑器里重新跑一下,对比一下输出的2条路径,看看是否符合你修改的初衷。

我不需要我的Nodes和Paths类继承MonoBehaviours,因为我创建了一个可视化behaviour,而不是保存了一个需要可视化的路径的引用。(我也没懂)

本文的第一张图就是我的第一版可视化寻路。

可视化图和代价

我同时也做了一些可视化地图的工作:



以及代价的可视化。我实现了“双重”的代价:下坡比上坡更容易,因此每个边界都有两种颜色,一种是上坡的代价,一种是下坡的代价:



编辑器优化

没有优化的情况下,你的A星算法在很短的时间内运行完,它可能要计算成千上万的节点,这是即时的。

但是,当你有bug时,A*会陷入死循环,Unity会卡住
a9a1
(Unity对于代码死循环基本没有什么措施)。

因此从实用性的角度讲,你会想把计算放到协程里,并且在出错时,在编辑器里显示一些信息。我计划添加一个进度条给Open和Close表,显示它们的容量。我知道节点的总数量,两个列表都不能超过节点的总数量,我可以这样显示:



编辑器里的协程

可悲的是,虽然Unity允许在编辑器里使用协程(Unity的员工在4.5里这样用过),你这样用却不行。

但是Unity实现协程很简单,自己写也很简单。使用类似这样的类你就可以自己做协程。

我也这样做了(把你自己的回调添加到update里,手动运行IEnumerator),但是我添加了一些东西重绘GUI(经常但是不频繁)和其他一些小的特性。

断言和异常

让我们回顾前文:

这是一个重度数据的算法:在真正的游戏中,它会处理成千上万的节点,以及数以万计的边界。调试它简直就是下地狱,千万别那么做啊。
断言(Assertions)是我们的朋友。许多牛b的游戏开发者都喜欢它,尤其是在控制台上(一些机器运行不起来单元测试,断言能帮你在这种机器上完成单元测试)。

我修改了代码的实现,增加了下面的断言:

从一个节点到另一个节点,代价不能是负的
这应该是不可能的,即使你想在游戏里实现点特别的东西,你也不可能想要这个结果
因为A*会滥用这个,在你的“负”边界上来回跑一跑,会发现总的路径代价因为它们而减少
你可能想要0代价的边界——比如一个传送点——但是小心:它们也可能引起无限循环的问题
一般来讲当你读到一个边界代价<=0f时应该断言(Assert)

伪代码的核心循环的最后一行,把当前节点作为相邻节点的父节点。如果这个节点已经指向相邻节点了,就不要这么做!
再说一下:这个情况不会发生,如果算法正确,它就是绝对错误的
(0或者负的代价会导致这个情况)
更糟糕的是:一个链表里包含多个相同节点,这会在后面导致死循环
我增加了一个断言:当给一个链表的末尾添加了一个节点,确保这个节点之前不在链表里

不要允许open表里的节点数超过总节点数
应该不可能!不能包含相同的节点!

close表也一样(我把close表用集合实现了,但是如果你的Equals方法有bug,用标准库里的set也不管用)
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: