# 前言

首先说说,为什么在这种时候突然研究这个:因为,如同前面面试那章所说,“明明感觉有印象,就是说不出个所以然来了...”,寻路算法说实话,曾经我在看《游戏人工智能编程》那本书的时候,还是专门研究过一段时间的。面试的时候虽然问的 Navmesh,但是下来后我发现自己对 A * 的原理,也忘得差不多了... 项目不用,自己也不复习的话,时间会让它变成记忆里的渣渣的!

所以,这儿必须得复习一下了!之所以名字不叫 “A*”,是因为可能的话,我还是想先复习 A*,然后研究下 Navmesh 的具体算法。
另外,我想把这个做成一个工具 —— 至少以后可以随便移植使用的那种。

另外,在下的口才也不是很好,所以若有没说明白的地方...

PS: 该篇文章仅代表个人理解!

# A * 算法

# 简介

A * 算法实际上是在 “Dijkstra” 算法及的基础上,加入了一个 “启发值” 进行优化而来的。

Dijkstra 算法:这个算法会在初始点向周围 “辐射”,直到找到终点,其优点是一定能找到最短路径,缺点是搜索范围很大,找到终点的时候,基本上把起点到终点那一圈的距离都搜索了一遍了。

A*:在 Dijkstra 算法的基础上,加入了 “启发值”,通常为三种:

# 1. 起点到当前点的消耗;

“起点到当前点的消耗”,一般最简单的做法是根据事先决定的每个节点的消耗,将起点至当前点路径消耗相加(若有其他需求,也可能会为不同类型的节点加入不同的消耗值。例如,若为每个区域加入不同的消耗,那么通过一堆火焰的消耗肯定就会比通过一个安全的区域要大得多,但是若通过安全区域的消耗值已经大于通过火焰的消耗,寻路就会选择通过火焰)。

# 2. 当前点到终点的消耗;

一般来说,会使用 “曼哈顿距离” 或者 “欧几里得距离”,这个用于 “启发” 节点,使其总是会尽量寻找离终点最近的方向。

曼哈顿距离:计算两个节点的 X、Y 相减的绝对值相加 —— 画成三角形的话,就是两条直边的和。
欧几里德距离:直接就是两点距离 —— 表现为三角形的斜边长度。

# 3. 前两项之和。

为总的消耗,该值决定了一个节点是否成为 “最短路径节点”。

上述便是启发值的功能,可以看出来,启发值主要起到了一种 “提示”,当然也是最重要的一点功能。若第二项启发值为 0,那么就会退化成 Dijkstra 算法了。

# 效果

效果... 因为太长了,所以特意将其移到这上边来,不然都看不见了。
效果如下:

咋一看还行... 但是不知道是参数 (启发值) 还是实现问题,虽然确实总是能找到最近的一条路径,但是有时候 (比如通过障碍很多等) 在找这条路径的过程中,会寻找过多的无用节点 (计算出路径后,显示于图中灰色的格子)—— 明明看起来不应该的。

后面我会再仔细分析下的,目前就到这儿了。

源码也已经放在了 GitHub 上 (另外暂时还有写功能没实现,比如 SimpleAStar 的事先烘焙地图数据的保存,有空再弄吧)。

链接:GitHub

——————————————————————————————————
后面优化调整了一下 H 与 G 值的计算比例,并且将其与地图格子大小相匹配,效果与效率都好了不少:

# 原理

# 分析

然后,来简单说一下具体原理吧。

除了上述的启发值之外,A * 还会维护一个 “开放表” 和 “关闭表”,对于已经处理过的点,会放入关闭表中,并将点周围的点放入开放表中。

主要分为以下几步:
1. 将初始点放入开放表。
2. 开启一个循环判断:若开放表中存在节点,取出该节点。
3. 循环判断该节点周围的点,若该节点已经位于关闭表,跳过;
若该节点为终点,结束。
否则,计算消耗,并判断其是否位于开放表,若没有,将其放入开放表,否则判断其已存在于开放表中的消耗值,若消耗更小,更新消耗数据及父节点引用。然后将已计算周围节点的节点放入关闭表。
4. 进入第二步。

(大致算法就是这么个样子了,至于清不清楚,那就得等往后我将这个算法又 “再次” 忘得差不多了,然后再回头看到这几句话的时候才知道了。)

# 伪代码

然后,再简单地写一下伪代码吧:

s
初始点,终点
开放表.Add(初始点);
do{
	当前节点=开放表[0];
	开放表.RemoveAt(0);
	关闭表.Add(当前节点);
	for(节点 in 当前节点的周围节点)
	{
		if(节点在关闭表中) continue;
		if(节点是终点) {
			节点.父节点=当前节点;
			return 回溯并组成路径列表(节点);
		}
		H=计算至终点消耗(节点,终点);
		G=当前节点.G+节点消耗;
		F=H+G;
		if(节点在开放表中)
		{
			if(G<节点.G)
			{
				节点.H=G;
				节点.G=G;
				节点.F=F;
				节点.父节点=当前节点;
			}
		}else{
				节点.H=G;
				节点.G=G;
				节点.F=F;
				节点.父节点=当前节点;
				开放表.Add(节点);
			}
	}
	根据F值排序开放列表();
}while(开放表.Length>0);

# 实现

OK,伪码差不多这样了,接下来就是具体的实现了。

# Node 类

第一步,为了方便存储节点的数据,一般来说,都要封装一个节点类。
在这儿,我首先创建了一个 “NodeBase”:

s
namespace SimpleAStar
{
    /// <summary>
    /// *******************************************
    ///   Powered By  Wangjiaying
    ///   Date: 2017.2.17
    ///   Func :
    /// *******************************************
    /// </summary>
    [System.Serializable]
    public class NodeBase
    {
        protected float _x;
        protected float _y;
        protected float _z;
        public float X { get { return _x; } }
        public float Y { get { return _y; } set { _y = value; } }
        public float Z { get { return _z; } }
        public NodeBase(float x, float y, float z)
        {
            _x = x;
            _y = y;
            _z = z;
        }
    }
}

这个类存放了节点的 X、Y、Z 三个值,即具体坐标。

接下来是 Node,继承于 NodeBase;Node 类中,保存了节点的 G、H、F 值以及其对于父节点的引用 —— 这是为了最后 Build 路径而存在了,多数都是如此。同时还加了一个是否是障碍物的标识:

s
private Node _parentNode;
        private bool _obstacle = false;
        private int _G;
        private int _H;
        private int _F;

然后,为了方便从地图数据中取出相应节点,还将其在地图数据的索引也存储了一下:

s
// 用于方便从地图数据中取出的索引
        private int _indexX;
        private int _indexY;

最后,为了方便地实现上述伪码中,根据节点的 F 值进行排序(以便每次都能从中取出最小的 F 值节点),还实现了系统的 “IComparable” 接口:

s
int IComparable<Node>.CompareTo(Node other)
        {
            return F - other.F;
        }

Node 类最终代码如下:

s
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace SimpleAStar
{
    /// <summary>
    /// *******************************************
    ///   Powered By  Wangjiaying
    ///   Date: 2017.2.17
    ///   Func :
    /// *******************************************
    /// </summary>
    [System.Serializable]
    public class Node : NodeBase, IComparable<Node>
    {
        private Node _parentNode;
        private bool _obstacle = false;
        private int _G;
        private int _H;
        private int _F;
        public int G { get { return _G; } set { _G = value; } }
        public int H { get { return _H; } set { _H = value; } }
        public int F { get { return _F; } }
        // 用于方便从地图数据中取出的索引
        private int _indexX;
        private int _indexY;
        public int IndexX { get { return _indexX; } }
        public int IndexY { get { return _indexY; } }
        public bool IsObstacle { get { return _obstacle; } set { _obstacle = value; } }
        public Node(float x, float y, float z) : base(x, y, z) { }
        public Node ParentNode { set { _parentNode = value; } }
        public Vector3 Position { get { return new Vector3(X, Y, Z); } }
        public void SetIndex(int x, int y)
        {
            _indexX = x;
            _indexY = y;
        }
        public void RecalcF()
        {
            _F = _G + _H;
        }
        public void BuildPath(List<Vector3> path)
        {
            path.Add(Position);
            if (_parentNode != null)
                _parentNode.BuildPath(path);
        }
        int IComparable<Node>.CompareTo(Node other)
        {
            return F - other.F;
        }
    }
}
# SimpleAStar

SimpleAStar, 这个类继承于 MonoBehaviour, 主要用于场景中烘焙及保存地图数据的功能。
初始状态下,在 Awake 中将自己注册至 “SimpleAStarManager” 中 (SimpleAStarManager 稍后再将,主要起一个管理作用,具体进行 A * 计算也是在那边),然后... 然后就没了,功能暂时来说,还是比较简单的。

s
using UnityEngine;
namespace SimpleAStar
{
    /// <summary>
    /// *******************************************
    ///   Powered By  Wangjiaying
    ///   Date: 2017.2.17
    ///   Func :
    /// *******************************************
    /// </summary>
    public class SimpleAStar : MonoBehaviour
    {
        // 代表该 A * 的区域长和宽
        [SerializeField]
        private int _gridX = 50;
        [SerializeField]
        private int _gridY = 50;
        [SerializeField]
        // 格子的大小
        private float _gridSize = 1f;
        public float GridSize { get { return _gridSize; } }
        [SerializeField]
        [HideInInspector]
        private Node[,] _nodeList;
        private void Awake()
        {
            SimpleAStarManager.GetInstance.Register(this);
            // 测试代码
            // 因为还没做保存功能,所以开始时刷新一下
            Scan();
        }
        /// <summary>
        /// 扫描 (可以理解为烘焙一次寻路数据)
        /// </summary>
        public void Scan()
        {
            // 初始化数组
            _nodeList = new Node[_gridX, _gridY];
            // 起点,为当前物体的位置
            float x = transform.position.x;
            float y = transform.position.y;
            float z = transform.position.z;
            for (int i = 0; i < _gridX; i++)
            {
                for (int j = 0; j < _gridY; j++)
                {
                    Node node = Tools.CreateNode(new Vector3(x + i * _gridSize, y, z + j * _gridSize));
                    node.SetIndex(i, j);
                    _nodeList[i, j] = node;
                }
            }
        }
        /// <summary>
        /// 获取 A * 地图数据
        /// </summary>
        public Node[,] MapData
        {
            get { return _nodeList; }
        }
// 这儿只是为了显示 Gizmos,所以可以不用在意
// 不看都可以
#if UNITY_EDITOR
        private void OnDrawGizmos()
        {
            if (_nodeList == null) return;
            Color normal = new Color(0, 0, 1, 0.3f);
            Color obstacle = new Color(1, 0, 0, 0.3f);
            for (int i = 0; i < _nodeList.GetLength(0); i++)
            {
                for (int j = 0; j < _nodeList.GetLength(1); j++)
                {
                    Node node = _nodeList[i, j];
                    Gizmos.color = node.IsObstacle ? obstacle : normal;
                    Gizmos.DrawWireCube(node.Position, Vector3.one * _gridSize);
                }
            }
            Gizmos.color = new Color(0, 0, 1, 0.3f);
            foreach (var node in SimpleAStarManager.GetInstance.OpenList)
            {
                Gizmos.DrawCube(node.Position, Vector3.one * _gridSize);
            }
            Gizmos.color = new Color(0.5f, 0.5f, 0.5f, 0.3f);
            foreach (var node in SimpleAStarManager.GetInstance.CloseList)
            {
                Gizmos.DrawCube(node.Position, Vector3.one * _gridSize);
            }
        }
#endif
    }
}

主要注意一下 Scan 方法中,对 Tools 的调用。

# Tools

该类封装了一些工具方法,目前就只有一个,即上面引用过的创建节点的方法:

s
/// <summary>
        /// 通过一个坐标点生成一个 Node
        /// 会通过 Raycast 检查节点是否可一通行
        /// </summary>
        /// <param name="pos"></param>
        /// <returns></returns>
        public static Node CreateNode(Vector3 pos)
        {
            Node node = new Node(pos.x, pos.y, pos.z);
            RaycastHit hit;
            if (Physics.Raycast(pos + Vector3.up * 100, Vector3.down, out hit, 120))
            {
                node.Y = hit.point.y;
                // 通过 Tag 来判断是否属于障碍物
                if (hit.transform.CompareTag("Obstacle"))
                    node.IsObstacle = true;
            }
            return node;
        }

该方法通过射线检测碰撞及障碍物,所以注意使用时,必须将障碍物的 Tag 设置为 “Obstacle” 才行啊

# SimpleAStarManager

然后是 SimpleAStarManager。
这个类应该算是最主要和重要的一个类了。
上述伪代码也就主要在此实现。

s
using System.Collections.Generic;
using UnityEngine;
namespace SimpleAStar
{
    /// <summary>
    /// *******************************************
    ///   Powered By  Wangjiaying
    ///   Date: 2017.2.17
    ///   Func :
    /// *******************************************
    /// </summary>
    public class SimpleAStarManager
    {
        private static SimpleAStarManager _instance;
        public static SimpleAStarManager GetInstance
        {
            get
            {
                if (_instance == null) _instance = new SimpleAStarManager();
                return _instance;
            }
        }
        // 暂时,假设一个场景只会有一个寻路数据
        private SimpleAStar _aStar;
        public void Register(SimpleAStar aStar)
        {
            _aStar = aStar;
        }
        private Node _startNode;
        private Node _endNode;
        private List<Node> _openList = new List<Node>();
        private List<Node> _closeList = new List<Node>();
        private List<Vector3> _pathList = new List<Vector3>();
#if UNITY_EDITOR
        public List<Node> OpenList { get { return _openList; } }
        public List<Node> CloseList { get { return _closeList; } }
#endif
        /// <summary>
        /// 计算一条从指定点到指定点的路径
        /// </summary>
        /// <param name="start"></param>
        /// <param name="end"></param>
        public void CalcPath(Vector3 start, Vector3 end, System.Action<Vector3[]> callBack)
        {
            //Debug.Log("Reuest");
            // 计算起点位于数据图中的坐标
            _startNode = GetNode(start, _aStar);
            // 计算终点位于数据图中的坐标
            _endNode = GetNode(end, _aStar);
            //System.Threading.Thread thread = new System.Threading.Thread(new System.Threading.ParameterizedThreadStart(CalcPathThread));
            //thread.Start(callBack);
            CalcPathThread(callBack);
        }
        private void CalcPathThread(object callback)
        {
            //Debug.Log("Thread");
            //lock (this)
            {
                _pathList.Clear();
                _openList.Clear();
                _closeList.Clear();
                // 将其父级及消耗置为空,避免多次使用可能出现的问题
                _startNode.ParentNode = null;
                _startNode.G = 0;
                _startNode.H = 0;
                _openList.Add(_startNode);
                do
                {
                    Node currentNode = _openList[0];
                    _openList.RemoveAt(0);
                    _closeList.Add(currentNode);
                    // 计算邻边
                    for (int i = -1; i < 2; i++)
                    {
                        for (int j = -1; j < 2; j++)
                        {
                            // 目前的话,我们仅考虑四个方向
                            // 为了简单嘛
                            if (i == j) continue;
                            Node node = GetNode(currentNode.IndexX + i, currentNode.IndexY + j);
                            if (node != null)
                            {
                                // 若节点就是结束点,那么...
                                if (node == _endNode)
                                {
                                    node.ParentNode = currentNode;
                                    node.BuildPath(_pathList);
                                    if (callback != null)
                                    {
                                        Vector3[] path = _pathList.ToArray();
                                        // 最后将路径反向
                                        System.Array.Reverse(path);
                                        (callback as System.Action<Vector3[]>).Invoke(path);
                                    }
                                    return;
                                }
                                // 已经位于关闭表,跳过
                                if (_closeList.Contains(node)) continue;
                                // 若节点是障碍物,直接放入关闭列表
                                if (node.IsObstacle)
                                {
                                    _closeList.Add(node);
                                    continue;
                                }
                                // 计算开始到当前节点消耗
                                int G = CalcG(currentNode);
                                // 当前点至终点消耗
                                int H = CalcH(node, _endNode);
                                // 开启表中存在节点
                                if (_openList.Contains(node))
                                {
                                    // 判断新的路径消耗
                                    if (G < node.G)
                                    {
                                        node.G = G;
                                        node.H = H;
                                        node.ParentNode = currentNode;
                                        node.RecalcF();
                                    }
                                }
                                else
                                {
                                    // 开启列表不存在节点
                                    node.G = G;
                                    node.H = H;
                                    node.ParentNode = currentNode;
                                    node.RecalcF();
                                    // 添加至开启列表
                                    _openList.Add(node);
                                }
                            }
                        }
                    }
                    // 重新排序
                    _openList.Sort();
                    //Debug.Log(_openList.Count);
                } while (_openList.Count > 0);
                //Debug.Log("Thread End");
            }
        }
        /// <summary>
        /// 通过索引获取 Node,将会判断是否合法
        /// </summary>
        /// <param name="indexX"></param>
        /// <param name="indexY"></param>
        /// <returns></returns>
        private Node GetNode(int indexX, int indexY)
        {
            if (indexX >= _aStar.MapData.GetLength(0) || indexY >= _aStar.MapData.GetLength(1) || indexX < 0 || indexY < 0) return null;
            //Debug.Log(indexX + "  " + indexY);
            return _aStar.MapData[indexX, indexY];
        }
        private static int CalcG(Node node)
        {
            return node.G + 1;
        }
        private static int CalcH(Node node, Node endNode)
        {
            // 使用曼哈顿距离进行 Hint
            return (int)(Mathf.Abs(endNode.X - node.X) + Mathf.Abs(endNode.Z - node.Z));
            //return (int)Vector3.Distance(endNode.Position, node.Position);
        }
        private static Node GetNode(Vector3 pos, SimpleAStar aStar)
        {
            Vector3 originPos = aStar.transform.position;
            int x = (int)((pos.x - originPos.x) / aStar.GridSize);
            int y = (int)((pos.z - originPos.z) / aStar.GridSize);
            // 返回计算出的点
            return aStar.MapData[x, y];
        }
    }
}
# 测试脚本 SimpleAStarTest

最后,写了一个 SimpleAStarTest 的测试脚本,用于测试算法的实际功能。

s
using UnityEngine;
namespace SimpleAStar
{
    /// <summary>
    /// *******************************************
    ///   Powered By  Wangjiaying
    ///   Date: 2017.2.17
    ///   Func :
    /// *******************************************
    /// </summary>
    public class SimpleAStarTest : MonoBehaviour
    {
        private Vector3 _startPos;
        private Vector3 _endPos;
        private Vector3[] _path;
        private void Update()
        {
            if (Input.GetMouseButtonDown(0))
            {
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                if (Physics.Raycast(ray, out hit, 100))
                {
                    if (_startPos == Vector3.zero)
                    {
                        _startPos = hit.point;
                        return;
                    }
                    _endPos = hit.point;
                    SimpleAStarManager.GetInstance.CalcPath(_startPos, _endPos, (path) => _path = path);
                }
            }
        }
        public void OnGUI()
        {
            if (GUILayout.Button("清除寻路数据"))
            {
                _startPos = Vector3.zero;
                _endPos = Vector3.zero;
                _path = null;
            }
        }
        private void OnDrawGizmos()
        {
            Gizmos.color = new Color(1, 1, 1, 1f);
            if (_startPos != Vector3.zero)
                Gizmos.DrawSphere(_startPos, 0.5f);
            Gizmos.color = new Color(0, 0, 0, 1f);
            if (_endPos != Vector3.zero)
                Gizmos.DrawSphere(_endPos, 0.5f);
            Gizmos.color = Color.green;
            if (_path != null)
                for (int i = 0; i < _path.Length - 1; i++)
                {
                    Gizmos.DrawLine(_path[i], _path[i + 1]);
                }
        }
    }
}