A star介绍
在游戏中,有一个很常见地需求,就是要让一个角色从A点走向B点,我们期望是让角色走最少的路。嗯,大家可能会说,直线就是最短的。没错,但大多数时候,A到B中间都会出现一些角色无法穿越的东西,比如墙、坑等障碍物。这个时候怎么办呢? 是的,我们需要有一个算法来解决这个问题,算法的目标就是计算出两点之间的最短路径,而且要能避开障碍物。
算法步骤
生成搜索区域
要将搜索区域划分为像素点,但是这样的划分力度一般来说太高了,可以划分为一个个的正方形。在Astar插件中,这一步由烘焙完成。
在这里,创建一个Mat,指定Material的Shader为Unlit/Texture,然后指定贴图,贴图的模式设为Repeat。
然后在Unity里面创建一个Quad,指定Mat为刚刚创建的Mat,这样就创建了一个20×20大小的格子。
再创建大小合适的两个小球,代表起点和终点,以及一些障碍物。
再创建两个预制体,一个是用来描述走过的路径,一个是标记障碍物。
定义结点
上图中的每个格子都是一个结点,结点的类定义:
public class NodeItem {
// 是否是墙
public bool isWall;
// 位置
public Vector3 pos;
// 格子坐标
public int x, y;
// 与起点的长度
public int gCost;
// 与目标点的长度
public int hCost;
// 总的路径长度
public int fCost {
get {return gCost + hCost; }
}
// 父节点
public NodeItem parent;
public NodeItem(bool isWall, Vector3 pos, int x, int y) {
this.isWall = isWall;
this.pos = pos;
this.x = x;
this.y = y;
}
}
生成每个格子的结点:
// 将墙的信息写入格子中
for (int x = 0; x < w; x++) {
for (int y = 0; y < h; y++) {
Vector3 pos = new Vector3 (x*0.5f, y*0.5f, -0.25f);
// 通过节点中心发射圆形射线,检测当前位置是否可以行走
bool isWall = Physics.CheckSphere (pos, NodeRadius, WhatLayer);
// 构建一个节点
grid[x, y] = new NodeItem (isWall, pos, x, y);
// 如果是墙体,则画出不可行走的区域
if (isWall) {
GameObject obj = GameObject.Instantiate (NodeWall, pos, Quaternion.identity) as GameObject;
obj.transform.SetParent (WallRange.transform);
}
}
}
pos和x,y是有关系的。当x,y为0的时候,pos为0刚好在左下角。
节点和代价
在寻路过程中,角色总是不停从一个格子移动到另一个相邻的格子,如果单纯从距离上讲,移动到与自身斜对角的格子走的距离要长一些,而移动到与自身水平或垂直方面平行的格子,则要近一些。
为了描述这种区别,先引入二个概念:
- 节点(Node):每个格子都可以称为节点。
- 代价(Cost):描述角色移动到某个节点时所走的距离(或难易程度)。
如果每水平或垂直方向移动相邻一个节点所花的代价记为1,则相邻对角节点的代码为1.4(即2的平方根–勾股定理)
通常寻路过程中的代价用f,g,h来表示
g代表(从指定节点到相邻)节点本身的代价–即上图中的1或1.4
g等于父节点的g值加上父节点到当前节点的代价
g这个数值可能会更新,因为一开始找的路径可能不是最优的。
h代表从指定节点到目标节点(根据不同的估价公式–后面会解释估价公式)估算出来的代价。
h代价本质是乐观的,所以要么是准确的,要么是低估了代价,它永远不会比可能的移动距离更少。因为我们在路上设置障碍,就不可能以h到达目标。但h代价保持不变,因为这是一个乐观的值。
而 f = g + h 表示节点的总代价
另外:在考查从一个节点移动到另一个节点时,总是拿自身节点周围的8个相邻节点来说事儿,需要一个方法能获取相邻的结点。相对于周边的节点来讲,自身节点称为它们的父节点(parent).
代价公式
在寻路的过程中“条条道路通罗马”,路径通常不止一条,只不过所花的代价不同而已。
所以我们要做的事情,就是要尽最大努力找一条代价最小的路径。
但是,即使是代价相同的最佳路径,也有可能出现不同的走法。
用代码如何估算起点与终点之间的代价呢?
//曼哈顿估价法
private function manhattan(node:Node):Number
{
return Math.abs(node.x - _endNode.x) * _straightCost + Math.abs(node.y + _endNode.y) * _straightCost;
}
//几何估价法
private function euclidian(node:Node):Number
{
var dx:Number=node.x - _endNode.x;
var dy:Number=node.y - _endNode.y;
return Math.sqrt(dx * dx + dy * dy) * _straightCost;
}
//对角线估价法
private function diagonal(node:Node):Number
{
var dx:Number=Math.abs(node.x - _endNode.x);
var dy:Number=Math.abs(node.y - _endNode.y);
var diag:Number=Math.min(dx, dy);
var straight:Number=dx + dy;
return _diagCost * diag + _straightCost * (straight - 2 * diag);
}
上面的代码给出了三种基本的估价算法(也称估价公式),其算法示意图如下:
如上图,对于“曼哈顿算法”最贴切的描述莫过于孙燕姿唱过的那首成名曲“直来直往”,笔直的走,然后转个弯,再笔直的继续。
“几何算法”的最好解释就是“勾股定理”,算出起点与终点之间的直线距离,然后乘上代价因子。
“对角算法”综合了以上二种算法,先按对角线走,一直走到与终点水平或垂直平行后,再笔直的走。
这三种算法可以实现不同的寻路结果,我们这个例子用的是“对角算法”:
// 获取两个节点之间的距离
int getDistanceNodes(Grid.NodeItem a, Grid.NodeItem b) {
int cntX = Mathf.Abs (a.x - b.x);
int cntY = Mathf.Abs (a.y - b.y);
// 判断到底是那个轴相差的距离更远 , 实际上,为了简化计算,我们将代价*10变成了整数。
if (cntX > cntY) {
return 14 * cntY + 10 * (cntX - cntY);
} else {
return 14 * cntX + 10 * (cntY - cntX);
}
}
创建两个列表,一个开启列表,一个关闭列表。
- Open:记录所有被考虑来寻找最短路径的点的集合。
- Closed:记录不会再被考虑的点的集合。
首先在open列表中添加当前位置(我们把这个开始点称为点 “A”)。然后从open列表里面找到代价最低的一个点,这个时候open列表里面只有一个点,就是A点,然后把A点添加到关闭列表中。再然后,把所有与它当前位置相邻的可通行格子添加到open列表中。
现在我们要从A出发到B点。
完整的方法:
// A*寻路
void FindingPath(Vector3 s, Vector3 e) {
Grid.NodeItem startNode = grid.getItem (s);
Grid.NodeItem endNode = grid.getItem (e);
List<Grid.NodeItem> openSet = new List<Grid.NodeItem> ();
List<Grid.NodeItem> closeSet = new List<Grid.NodeItem> ();
openSet.Add (startNode);
//openSet里面有结点
while (openSet.Count > 0) {
//取openSet里面结点h最小的curNode
Grid.NodeItem curNode = openSet [0];
for (int i = 0, max = openSet.Count; i < max; i++) {
if (openSet [i].fCost < curNode.fCost ||
openSet [i].fCost == curNode.fCost && openSet [i].hCost < curNode.hCost) {
curNode = openSet [i];
}
}
//把最小的结点添加进关闭列表
openSet.Remove (curNode);
closeSet.Add (curNode);
// 找到的目标节点
if (curNode == endNode) {
generatePath (startNode, endNode);
return;
}
// 判断周围节点,选择一个最优的节点
foreach (var item in grid.getNeibourhood(curNode)) {
// 如果是墙或者已经在关闭列表中
if (item.isWall || closeSet.Contains (item))
continue;
// 计算当前相领节点现开始节点距离
int newCost = curNode.gCost + getDistanceNodes (curNode, item);
// 如果距离更小,或者原来不在开始列表中
if (newCost < item.gCost || !openSet.Contains (item)) {
// 更新与开始节点的距离
item.gCost = newCost;
// 更新父节点为当前选定的节点
item.parent = curNode;
// 如果节点是新加入的,将它加入打开列表中
if (!openSet.Contains (item)) {
// 更新与终点的距离 因为h是一个乐观的值,代价保持不变,只需要计算一次
item.hCost = getDistanceNodes (item, endNode);
openSet.Add (item);
}
}
}
}
generatePath (startNode, null);
}
困惑点
更新G值的演示步骤
如图所示,图中黑色表示障碍物,绿色表示开启列表中的点,深蓝色表示关闭列表中的点,数字中间代表f,左边代表g,右边代表h
当前走到①这个节点,然后计算右边③节点的g值,为4。
然后下一个节点选择开启列表f值最小的8=2+6这个节点②。这个时候遍历周围的节点,发现③节点的G代价可以更新为3。
当遇到障碍物的时候怎么走
如图,当走到节点②时候,发现开启列表中③中f最小,于是下一个节点并没有选择相邻的节点,而选择了③。
这意味着原来①至③的那条路径被舍弃了,取而代之的路径是①至③。
总结算法描述
1、首先将起始点添加进“开启列表”。
2、重复如下步骤:
a) 寻找开启列表中F值最低的节点。我们称其为“当前节点”。
b) 把它从“开启列表”中移除,并添加进“关闭列表”。
c) 检查“当前节点”是否是“目标节点”
* 如果是,停止搜索,跳到第 3 步;
* 如果不是,继续下面步骤;
d) 寻找“当前节点”邻近的节点
* 如果它不可通过或者已经在关闭列表中,略过它。反之如下。
* 如果它不在开启列表中,把它添加进开启列表。把当前节点作为这一节点的父节点。记录这一格的G和H值。
* 如果它已经在开启列表中,检查新路径对它G值的产生的影响
a. 如果它的G值因为新路径变大,那么保持原来的状态,不作任何改变;
b. 如果G值变小,说明新路径更好,将其父结点改为“当前结点”,更新G和F值。
e) 检查列表是否为空,如果为空,说明路径未找到,直接返回,不继续任何步骤。
3、保存路径。从目标节点开始,沿着每一节点的父节点移动直到回到起始节点。这就是我们要找的路径。
完整代码下载:
http://pan.baidu.com/s/1jInjLDs
参考文章:
https://www.cnblogs.com/yangyxd/articles/5447889.html
https://www.bilibili.com/video/BV1v44y1h7Dt/?spm_id_from=333.788.recommend_more_video.1