Unity示例A* 算法

A star介绍

在游戏中,有一个很常见地需求,就是要让一个角色从A点走向B点,我们期望是让角色走最少的路。嗯,大家可能会说,直线就是最短的。没错,但大多数时候,A到B中间都会出现一些角色无法穿越的东西,比如墙、坑等障碍物。这个时候怎么办呢? 是的,我们需要有一个算法来解决这个问题,算法的目标就是计算出两点之间的最短路径,而且要能避开障碍物。

算法步骤

生成搜索区域

要将搜索区域划分为像素点,但是这样的划分力度一般来说太高了,可以划分为一个个的正方形。在Astar插件中,这一步由烘焙完成。
在这里,创建一个Mat,指定Material的Shader为Unlit/Texture,然后指定贴图,贴图的模式设为Repeat。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dOJGYG2c-1645155681343)(index_files/bd1a8c40-1ab2-49ae-a7e7-f0be4d13fc04.png)]

然后在Unity里面创建一个Quad,指定Mat为刚刚创建的Mat,这样就创建了一个20×20大小的格子。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tWdtsrss-1645155681345)(index_files/85ac24d5-5876-4a78-948f-5b14c5568509.png)]

再创建大小合适的两个小球,代表起点和终点,以及一些障碍物。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pAqLQsKT-1645155681346)(index_files/b17dcc80-bf55-4151-960e-b22b54dec899.png)]

再创建两个预制体,一个是用来描述走过的路径,一个是标记障碍物。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZHDMUdn1-1645155681346)(index_files/d6a3e24f-83c2-46f2-897e-c3d24c5f1688.png)]

定义结点

上图中的每个格子都是一个结点,结点的类定义:

    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):描述角色移动到某个节点时所走的距离(或难易程度)。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FWfZ67Jf-1645155681346)(index_files/9be4b26e-391a-44bb-af8f-c893c03739c2.jpg)]

如果每水平或垂直方向移动相邻一个节点所花的代价记为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。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E2zDOXAd-1645155681347)(index_files/bd36385e-2ca8-49aa-a21b-4d3c426d887d.jpg)]

然后下一个节点选择开启列表f值最小的8=2+6这个节点②。这个时候遍历周围的节点,发现③节点的G代价可以更新为3。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lTJUjl2Z-1645155681347)(index_files/ccfc122f-f0d7-4d7d-9a26-f820166bb768.jpg)]

当遇到障碍物的时候怎么走

如图,当走到节点②时候,发现开启列表中③中f最小,于是下一个节点并没有选择相邻的节点,而选择了③。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DorbWCJC-1645155681348)(index_files/65ba3f67-cc0d-4e88-928b-6e11e753a111.jpg)]

这意味着原来①至③的那条路径被舍弃了,取而代之的路径是①至③。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7dthmKCZ-1645155681349)(index_files/787fd056-a79a-4b1e-9f38-a429c46e0b07.jpg)]

总结算法描述

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

猜你喜欢

转载自blog.csdn.net/KindSuper_liu/article/details/123000011