一、本篇目的
学习了A*
寻路基础之后,发现源码下载不了了,自己实现一下,并做一些扩展记录以及测试。
本文所述完整源代码在这里下载。
二、开发环境
- VS2017 C#
三、简介
关于A*
寻路,这篇文章说得很清楚:A* Pathfinding for Beginners
有人翻译成中文了,中文可以看这个:A*寻路入门
这篇文章说得很清楚,想调试源代码加深理解,但是文档中提供的源码怎么都下不来了,就萌生了自己编程实现的想法。
四、程序结构
程序结构大体如下图所示:
寻路类PathFinding
需要针对不同的地形寻路,移动方式不同也会影响寻路结果。比如沙地比较难移动,那么可以设置地形的沙地位置基本移动力大一点,爬山和下山消耗移动力也会不一样的,所以地形在不同高度差的位置之间移动会影响寻路消耗的移动力,地形的这些性质在地形类Terrain
来设置。
注意地形以左下角为原点,往右列号递增,往上行号递增。
移动时一般是移动到相邻的位置,但是有些项目(或者游戏,或者数学题目)是走别的形状的,比如马走日字。所以引入MovePattern
类,并根据具体的移动样式不同,衍生出子类,比如源码考虑了普通移动样式类NormalMovePattern
和马走日的移动样式类,要增加别的样式,继承MovePattern
,实现自己的移动样式子类就可以了。
具体的实现代码注释很清晰了,看懂了简介中的文章,很容易能看懂源码和注释,本文不赘述。
源码AStartPathFinding
项目,是寻路的库,编译后,结果是库文件AStarPathFinding.dll
。
后面描述如何使用这个库解决寻路的问题。
五、寻路库的使用
1、库添加到引用
新建一个c#
项目,叫AStarPathFindingDemo
,把AStarPathFinding.dll
加入到引用中。如下图,添加引用,浏览选取这个库文件,保证这个库文件被勾选了,确定就行了。
2、例子1
第1个先用简介中的图做例子:
文章答案给的是如下图:
行动力,斜线消耗14行动力来计算,是2步斜线,4步直线,共消耗68行动力。答案不是唯一的,比如从上方走也是一个答案,最终判定是消耗的行动力。
下面是使用的代码。
如图所示,地形是5行、7列的,先设置地形,代码如下:
/// <summary>
/// 测试5行,7列的如下图的寻路,左下角坐标是(0, 0)
/// X X X X X X X
/// X X X 1 X X X
/// X S X 1 X E X
/// X X X 1 X X X
/// X X X X X X X
/// 如上如,S为起点,E为终点,中间的1表示不可以立足也不能跃过(即墙)
/// </summary>
static private Terrain SetExam1Terrain()
{
/* 先设置地形,没有高度差,高度全为0 */
Terrain terrain = new Terrain();
terrain.Set(7, 5);
terrain.SetColumnMovableType(3, 1, 3, MovableType.NotStandonNotAcross);
return terrain;
}
地形宽为7,高为5,不考虑每个位置的高度的话,高度全是0,不需要设置,所以使用terrain.Set(7, 5)
来设置宽高,第4列的第2-4行是墙,既不可以立足,也不可以被跃过的地形,使用terrain.SetColumnMovableType(3, 1, 3, MovableType.NotStandonNotAcross)
来设置,其中第一个参数3表示第4列(从0开始算,序号是3),第2给参数1表示第2行(从0开始算,序号是1),第3个参数3表示第4行,即设置第4列的行[1, 3]的可移动类型为MovableType.NotStandonNotAcross
,即不可以立足,也不可以跃过。
接着就是寻路,代码如下:
static private void Exam1()
{
/* 设置地形 */
Terrain terrain = SetExam1Terrain();
PathFinding pathFinding = new PathFinding();
List<Node> bestPath = null;
int action = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(1, 2, 5, 2, out bestPath, out action))
{
/* 找到路径,打印 */
ShowBestPath(bestPath, action);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
}
先设置好地形,然后传入给FindPath
方法,这个方法前两个参数就是起始位置的坐标,即(1, 2),接着是终点的坐标(5, 2)。然后返回结果并打印出来。结果如下:
Found path:
(1, 2) -> (1, 3) -> (2, 4) -> (3, 4) -> (4, 4) -> (4, 3) -> (5, 2)
Action: 68
答案是6步,消耗行动力68。答案路径如下图;
和简介的文章答案不同,但是结果是对的,都是行动力68,2步斜线,4步直线
3、例子2
这个例子题目源于某小学生的课程中题目,如下图:
骑士走的日字,问骑士几步就能拿到宝物?
答案很明显是4步。假设马走日一步的行动力按照基本斜线来算14,那就是56的行动力。
程序如下:
设置地形:
/// <summary>
/// 测试日字步
/// 测试4行6列的,S点是骑士,E点是宝物,骑士最少几步拿到宝物,如下图
/// E X X X X X
/// X X X X X X
/// X X X X X X
/// X X X X X S
/// </summary>
static private Terrain SetExam2Terrain()
{
/* 先设置地形,没有高度差,高度全为0 */
Terrain terrain = new Terrain();
terrain.Set(6, 4);
return terrain;
}
地形就是6x4的,没什么特别。
寻路代码是:
static private void Exam2()
{
/* 先设置地形,没有高度差,高度全为0 */
Terrain terrain = SetExam2Terrain();
PathFinding pathFinding = new PathFinding();
List<Node> bestPath = null;
int action = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(5, 0, 0, 3, out bestPath, out action, MoveType.RiMove))
{
/* 找到路径,打印 */
ShowBestPath(bestPath, action);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
}
和例子1一样,就是先设置地形,然后传入起点、终点坐标寻路,不同的是最后的一个参数传入了MoveType.RiMove
,表示移动类型是日字步。
最后结果是:
Found path:
(5, 0) -> (3, 1) -> (2, 3) -> (1, 1) -> (0, 3)
Action: 56
路径画到图里和这个学生做题的答案有些不一样,但是很明显,结果也是对的。
最后来个比上面稍微复杂点的应用。
4、例子3
也是来自小学生的数学题,如下图:
骑士走的日字,骑士最少能用几步拿到两件宝物?
应该没有比8步更好的答案了。
地形和上面的有些不同,空白的位置是可以跃过去的,但是空白的位置是不可以立足的位置。比如图示的骑士起始的位置的左边几格都是空白,但是不影响骑士跃到标了1的位置,但是骑士却不可以跃到空白的位置去,那怕是日字的走法。
所以这个题目地形设置有些复杂,代码如下:
/// <summary>
/// 测试日字步
/// 测试9行10列的,S点是骑士,E点是宝物有2个,骑士最少几步拿到2个宝物,如下图:
/// 2表示不可以立足,但是可以跃过的位置
/// 2 2 2 2 2 2 2 2 X 2
/// 2 X X X 2 2 2 X 2 2
/// 2 X 2 X 2 2 2 2 X X
/// 2 X X E 2 X 2 2 X 2
/// 2 2 2 2 2 2 2 2 X 2
/// X X 2 2 X X X 2 X 2
/// 2 2 X 2 X 2 X 2 X 2
/// E 2 X 2 2 X X X X X
/// 2 2 2 2 2 2 2 2 S 2
/// </summary>
static private Terrain SetTest3Terrain()
{
Terrain terrain = new Terrain();
terrain.Set(10, 9);
terrain.SetPointMovableType(0, 0, MovableType.NotStandonAcross);
terrain.SetPointMovableType(0, 2, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(0, 4, 8, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(1, 0, 2, MovableType.NotStandonAcross);
terrain.SetPointMovableType(1, 4, MovableType.NotStandonAcross);
terrain.SetPointMovableType(1, 8, MovableType.NotStandonAcross);
terrain.SetPointMovableType(2, 0, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(2, 3, 4, MovableType.NotStandonAcross);
terrain.SetPointMovableType(2, 6, MovableType.NotStandonAcross);
terrain.SetPointMovableType(2, 8, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(3, 0, 4, MovableType.NotStandonAcross);
terrain.SetPointMovableType(3, 8, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(4, 0, 1, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(4, 4, 8, MovableType.NotStandonAcross);
terrain.SetPointMovableType(5, 0, MovableType.NotStandonAcross);
terrain.SetPointMovableType(5, 2, MovableType.NotStandonAcross);
terrain.SetPointMovableType(5, 4, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(5, 6, 8, MovableType.NotStandonAcross);
terrain.SetPointMovableType(6, 0, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(6, 4, 8, MovableType.NotStandonAcross);
terrain.SetPointMovableType(7, 0, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(7, 2, 6, MovableType.NotStandonAcross);
terrain.SetPointMovableType(7, 8, MovableType.NotStandonAcross);
terrain.SetPointMovableType(8, 7, MovableType.NotStandonAcross);
terrain.SetPointMovableType(9, 0, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(9, 2, 5, MovableType.NotStandonAcross);
terrain.SetColumnMovableType(9, 7, 8, MovableType.NotStandonAcross);
return terrain;
}
需要使用地形类的接口设置好很多空白地方(可以跃过,但是不可以立足的位置,类型是MovableType.NotStandonAcross
)
下面说寻路,寻路怎么做能找到最优的路径呢?
为了利于说明,给宝物编号,假设上面的宝物叫1号宝物,下面的宝物叫2号宝物。
寻路接口只支持一个起点,一个终点。那么做法是:
-
假设先获取1号宝物,再获取2号宝物。
即从起点到1号宝物寻路,再从1号宝物到达2号宝物寻路,把两次寻路的行动力求总和。
-
再假设先获取2号宝物,再获取1号宝物。
即“起点->2号宝物->1号宝物”,同样求出2次寻路的行动力总和。
-
比较上面2种假设,行动力少的就是最优方案。
-
注意,每种假设都寻路了两次,每次寻路都会造成地形力的评估值发生了改变,需要重新设置地形才可以进行第二次寻路,比如假设先取1号宝物,再取2号宝物的做法是:设置地形->寻路(起点,1号宝物)->重新设置地形->寻路(1号宝物, 2号宝物)->计算两次行动力总和。
代码如下:
static private void Exam3()
{
/* 先设置地形,没有高度差,高度全为0 */
Terrain terrain = SetTest3Terrain();
/* 先拿上面的宝物,再拿下面的宝物,所用的行动力 */
Console.WriteLine("假设先拿上面的宝物,再拿下面的宝物,寻路中...");
PathFinding pathFinding = new PathFinding();
List<Node> bestPath = null;
int action1 = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(8, 0, 3, 5, out bestPath, out action1, MoveType.RiMove))
{
/* 找到路径,打印 */
Console.WriteLine("从起点到达上面宝物的寻路为:");
ShowBestPath(bestPath, action1);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
/* 接着再计算从上面宝物走到走到下面宝物的行动力 */
/* 要先恢复地形,很重要!!!! */
terrain = SetTest3Terrain();
pathFinding = new PathFinding();
bestPath = null;
int action2 = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(3, 5, 0, 1, out bestPath, out action2, MoveType.RiMove))
{
/* 找到路径,打印 */
Console.WriteLine("从上面宝物到达下面宝物的寻路为:");
ShowBestPath(bestPath, action2);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
/* 总行动力 */
int action_type1 = action1 + action2;
Console.WriteLine("假设先拿上面的宝物,再拿下面的宝物,所用行动力是{0}", action_type1);
/* 假设先取下面的宝物,再取上面的宝物 ,再做一遍*/
Console.WriteLine();
Console.WriteLine("假设先拿下面的宝物,再拿上面的宝物,寻路中...");
terrain = SetTest3Terrain();
/* 先拿上面的宝物,再拿下面的宝物,所用的行动力 */
pathFinding = new PathFinding();
bestPath = null;
action1 = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(8, 0, 0, 1, out bestPath, out action1, MoveType.RiMove))
{
/* 找到路径,打印 */
Console.WriteLine("从起点到达下面宝物的寻路为:");
ShowBestPath(bestPath, action1);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
/* 接着再计算从上面宝物走到走到下面宝物的行动力 */
/* 要先恢复地形 */
terrain = SetTest3Terrain();
pathFinding = new PathFinding();
bestPath = null;
action2 = 0;
pathFinding.Terrain = terrain;
if (pathFinding.FindPath(0, 1, 3, 5, out bestPath, out action2, MoveType.RiMove))
{
/* 找到路径,打印 */
Console.WriteLine("从下面宝物到达上面宝物的寻路为:");
ShowBestPath(bestPath, action2);
}
else
{
/* 找不到路径 */
Console.WriteLine("Not found path!");
}
/* 总行动力 */
int action_type2 = action1 + action2;
Console.WriteLine("假设先拿下面的宝物,再拿上面的宝物,所用行动力是{0}", action_type2);
}
结果为:
假设先拿上面的宝物,再拿下面的宝物,寻路中...
从起点到达上面宝物的寻路为:
Found path:
(8, 0) -> (6, 1) -> (4, 2) -> (6, 3) -> (5, 5) -> (4, 3) -> (3, 5)
Action: 84
从上面宝物到达下面宝物的寻路为:
Found path:
(3, 5) -> (4, 3) -> (2, 2) -> (0, 1)
Action: 42
假设先拿上面的宝物,再拿下面的宝物,所用行动力是126
假设先拿下面的宝物,再拿上面的宝物,寻路中...
从起点到达下面宝物的寻路为:
Found path:
(8, 0) -> (6, 1) -> (4, 2) -> (2, 1) -> (1, 3) -> (0, 1)
Action: 70
从下面宝物到达上面宝物的寻路为:
Found path:
(0, 1) -> (2, 2) -> (4, 3) -> (3, 5)
Action: 42
假设先拿下面的宝物,再拿上面的宝物,所用行动力是112
最优的方案是和图示标的数字一样,先拿下面的宝物,再拿上面的宝物。
六、使用Excel
画地形图
上面的例子,设置地形尤其麻烦,特别是不规则的图形,比如例子3,稍微不小心很容易就会出错。
在excel
上画则简单多了,地形每个节点就是excel
的一个单元格。
比如例子1的图如下:
比如例子3的图如下:
excel
绘制地形图,如例子所示,比较简单而且直观,只要能读取excel
的图,转成Terrain
对象就可以了。
读取Excel
有开源库,可以用EPPlus
,也可以用NPOI
,经对比,我采用EPPlus
。
因为读取excel
,转成Terrain
,必须要做些约定,才可以按照约定转化,约定如下:
-
地形全部按照长方形形状的,对于不规则的,可以按照最长边补齐成长方形,不存在的地形使用不可立足的节点填充
-
所有地形的边框用实线,这样通过识别单元格的实线,来识别出来地形的长宽
-
所有背景颜色要用RGB颜色,因为
EPPlus
识别RGB颜色比较容易。如上图,要使用标准色,如果标准色中没有的,就在其他颜色中配置RGB颜色,使用过一次之后,就会记录在最近使用的颜色中了。
地形的ARGB颜色约定:
起点:绿色 #FF00B050,起点的单元格写上文字"马"标识走日字型,普通的什么都不写
终点:红色 #FFFF0000
可立足地形:灰色 #FFE6E6E6
不可立足可跃过地形:保留excel原来单元格的空白,即没有颜色
不可立足不可跃过地形:深蓝色 #FF002060
-
单元格用文字标注地形的基本行动力和高度。为了更容易绘制,更美观,如果高度是0,可以不标高度,如果基础行动力是普通的10,可以不标基础行动力。例子1的图,F5单元格的位置本来是可以不标的,因为基础行动力是10,高度是0,为了说明,标了一下,也不会有错。B: 10标识基础行动力是10,H : 0标识高度是0。(注意冒号必须使用英文字符,而且每一项要用换行隔开)
七、EPPlus
读取excel
地形图
关于EPPlus
的了解,可以网上搜索。
我的VS2017,如果直接下载源码编译,会提示.net framework版本不对。
所以我是从NuGet中下载的。直接得到可用的各个版本的EPPlus.dll
库。
和前述的添加AStarPathFinding.dll
到引用一样的方法,把EPPlus.dll
库添加到引用,程序增加一个读取Excel
转换到Terrain
的类,这个类提供方法,输入是excel
文件名以及sheet
序号,输出是Terrain
对象。
颜色
不可立足不可跃过地形:深蓝色 #FF002060
- 单元格用文字标注地形的基本行动力和高度。为了更容易绘制,更美观,如果高度是0,可以不标高度,如果基础行动力是普通的10,可以不标基础行动力。例子1的图,F5单元格的位置本来是可以不标的,因为基础行动力是10,高度是0,为了说明,标了一下,也不会有错。B: 10标识基础行动力是10,H : 0标识高度是0。(注意冒号必须使用英文字符,而且每一项要用换行隔开)
七、EPPlus
读取excel
地形图
关于EPPlus
的了解,可以网上搜索。
我的VS2017,如果直接下载源码编译,会提示.net framework版本不对。
所以我是从NuGet中下载的。直接得到可用的各个版本的EPPlus.dll
库。
和前述的添加AStarPathFinding.dll
到引用一样的方法,把EPPlus.dll
库添加到引用,程序增加一个读取Excel
转换到Terrain
的类,这个类提供方法,输入是excel
文件名以及sheet
序号,输出是Terrain
对象。
最后使用例子4和例子5做测试,具体不再说明,参照源代码。