Unity實現A*尋路算法學習1.0

一、A*尋路算法的原理

如果現在地圖上存在兩點A、B,這裡設A為起點,B為目標點(終點)

這裡為每一個地圖節點定義了三個值
gCost:距離起點的Cost(距離)
hCost:距離目標點的Cost(距離)
fCost:gCost和gCost之和。
這裡的Cost可以採用直線距離,也可以採用曼哈頓距離等,只要適合就行
那麼先計算起點周圍的所有節點的三個值
這裡設每兩個相鄰節點間的距離為10,那麼對角線距離為14

那麼計算得出,F值最小的是A點左上角的方塊,將節點放入列表(數組也行)將A設為該節點的父節點,然後計算周圍方塊的距離

因為是從A點移動過來的,所以下次比較時不再比較A點
再次計算得出F值最小的仍然為左上角的節點

這樣就求出了A到B點的最短路徑

如果A、B之間存在障礙物那麼又該怎麼辦呢?

同樣也是計算最小的 F 值

但這裡出現了三個相同的F值

那麼接下來優先選擇 H 值最小的路徑,即距離目標點最近的路徑

但是移動過後 F 值 反而變大了
那麼反過來尋找之前 F 值最小的路徑,但接下來還是 F 值更大

那麼仍然選擇 F 值最小的路徑

然後是下一個F值最小的路徑

然後是下一個
直到距離目的地的hCost為0

再來一個例子,這裡說明如何找出最短的路徑,箭頭表示父節點


第一次計算A點周圍節點的F值後,找出最小的那個,將節點的父節點設為A點
再次計算,將周圍節點設為子節點,然後後發現周圍有兩個58的點,選中gCost更小也就是下面A旁邊那個58的點
再次計算

在計算過下面那個58節點後發現旁邊節點從這裡經過所需的cost更小,所以重新設置父節點

再次說明

如果經過黃線的路徑,右下角節點的Cost會達到66

如果經過下面到達,Cost為58,會更小,會重新設置父節點

這裡重新計算的fCost為 A — 58 — 58,fCost為58更小,說明新路徑更小,重新設置父節點

按照此方法循環直至找到目標點

因為設置了父節點(圖中箭頭表示),那麼只需要從目標點開始,一直獲取父節點即可,將獲取到的所有節點存儲進列表或數組然後進行翻轉,就得到了A-B的最短路徑

二、在Unity中設置路徑點


然後添加Cuble視為障礙物

將Cube的層級設為UnWalkable

接着複製幾個

新建腳本Node,節點目前只包含坐標位置,和是否能行走

public class Node
{
    public bool walkable;		//節點是否能走動
    public Vector3 worldPos;	//節點的空間坐標
    public Node(bool _walkable, Vector3 _worldPos)	//構造器
    {
        walkable = _walkable;
        worldPos = _worldPos;
    }
}

新建腳本MyGrid,添加到新建空物體A*

public class MyGrid : MonoBehaviour
{
    public LayerMask unwalkableMask;	//節點是否能走動
    public Vector2 gridWorldSize;	//地圖的範圍,節點在地圖內創建
    public float nodeRadius;	//節點的大小
    Node[,] grid;	//節點數組
    
    private void OnDrawGizmos()
    {
        //首先畫出地圖的範圍								//寬度           厚度          長度
        Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y));
    }
}

然後設置節點地圖大小



繼續修改MyGrid

public class MyGrid : MonoBehaviour
{
    public LayerMask unwalkableMask;    //是否能行走
    public Vector2 gridWorldSize;   //需要尋路的地圖大小
    public float nodeRadius;    //節點半徑
    Node[,] grid;               //節點

    float nodeDiameter;         //節點的直徑
    int gridSizeX, gridSizeY;

    void Start()
    {
        nodeDiameter = nodeRadius * 2;
        gridSizeX = Mathf.RoundToInt(gridWorldSize.x / nodeDiameter); //計算出x軸方向有多少個節點
        gridSizeY = Mathf.RoundToInt(gridWorldSize.y / nodeDiameter); //計算出z軸方向有多少個節點
        CreateGrid();
    }
    void CreateGrid()
    {
        grid = new Node[gridSizeX, gridSizeY];	//初始化節點數組
        //計算網格的起始點(原點)
        Vector3 worldButtonLeft = transform.position 
            					- Vector3.right * gridWorldSize.x / 2 
            					- Vector3.forward * gridWorldSize.y / 2;
        for (int x = 0; x < gridSizeX; x++)
        {
            for (int y = 0; y < gridSizeY; y++)
            {
                //計算節點的空間坐標
                Vector3 worldPoint = worldButtonLeft 
                    				+ Vector3.right * (x * nodeDiameter + nodeRadius) 
                    				+ Vector3.forward * (y * nodeDiameter + nodeRadius);
                
                //判斷節點是否能行走,根據節點範圍是否與障礙物碰撞
                bool walkable = !(Physics.CheckSphere(worldPoint, nodeRadius, unwalkableMask)); 

                grid[x, y] = new Node(walkable, worldPoint);    //將節點的數據添加進二位數組
            }
        }
    }
    private void OnDrawGizmos()
    {
        Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y));
        if (grid != null)
        {
            foreach (Node node in grid)
            {	
                //繪製出所有節點,可以行走為白色,不能行走為紅色
                Gizmos.color = (node.walkable) ? Color.white : Color.red;
                Gizmos.DrawCube(node.worldPos, Vector3.one * (nodeDiameter - 0.1f));//減少Gizmos方塊的大小便於觀察
            }
        }
    }
}

運行結果

接下來添加一個起點和一個終點

新建兩個Capsule

那麼如何知道起點現在在哪個節點呢?
繼續修改MyGrid

public class MyGrid : MonoBehaviour
{
    ......
    public Node NodeFromWorldPos(Vector3 worldPos)	//這裡傳入起點的位置
    {
        //這裡 percentX 和 percentY 計算起點位置佔地圖區域橫豎坐標的比例
        float percentX = (worldPos.x + gridWorldSize.x / 2) / gridWorldSize.x;
        float percentY = (worldPos.z + gridWorldSize.y / 2) / gridWorldSize.y;
        
        //將起點的位置限定在地圖範圍之內
        percentX = Mathf.Clamp01(percentX);
        percentY = Mathf.Clamp01(percentY);
		
        //總節點數量 * 所在區域比例 = 在第幾個節點, -1 是為了從 0 開始計算,因為0也有一個節點
        int x = Mathf.RoundToInt((gridSizeX - 1) * percentX);
        int y = Mathf.RoundToInt((gridSizeY - 1) * percentY);
        return grid[x, y];
    }
    private void OnDrawGizmos()
    {
        Gizmos.DrawWireCube(transform.position, new Vector3(gridWorldSize.x, 1, gridWorldSize.y));
        if (grid != null)
        {
            //計算出起點的位置
            Node playerNode = NodeFromWorldPos(player.position);
            foreach (Node node in grid)
            {
                Gizmos.color = (node.walkable) ? Color.white : Color.red;
                if (playerNode == node)	//設置起點位置節點的顏色
                {
                    Gizmos.color = Color.cyan;
                }
                Gizmos.DrawCube(node.worldPos, Vector3.one * (nodeDiameter - 0.1f));
            }
        }
    }
}

運行結果

三、實現尋路算法

修改Node

public class Node
{
    ......
    public int gridX;	//地圖中x方向第幾個節點
    public int gridY;	//地圖中y方向第幾個節點
    
    public int gCost;	//g值
    public int hCost;	//h值
    public Node parent;	 //父節點,最後用於存儲實際路徑
    										//重新添加了兩個參數,便於計算鄰近節點
    public Node(bool _walkable, Vector3 _worldPos,int _gridX, int _gridY)	
    {
        walkable = _walkable;
        worldPos = _worldPos;
        gridX = _gridX;
        gridY = _gridY;
    }
    public int FCost	//屬性,F值
    {
        get
        {
            return gCost + hCost;
        }
    }
}

新建腳本PathFinding,並添加到物體A*上

public class PathFinding : MonoBehaviour
{
    public Transform seeker, target;	//聲明兩個坐標,起始點和目標點
    private MyGrid grid;
    ......
    private void Update()
    {
        FindPath(seeker.position, target.position);	//計算路徑
    }
    private void FindPath(Vector3 startPos, Vector3 targetPos)
    {
        Node startNode = grid.NodeFromWorldPos(startPos);   //輸入空間坐標,計算出起始點處於哪個節點位置
        Node targwtNode = grid.NodeFromWorldPos(targetPos); //輸入空間坐標,計算出目標點處於哪個節點位置

        List<Node> openSet = new List<Node>();          //用於存儲需要評估的節點
        HashSet<Node> closedSet = new HashSet<Node>();  //用於存儲已經評估的節點

        openSet.Add(startNode);	//將起始點加入openSet,進行評估
        
        while (openSet.Count > 0)   //如果還有待評估的節點
        {
            #region //獲取待評估列表中 F 值最小的節點
            Node currentNode = openSet[0];  //獲取其中一個待評估的節點
            for (int i = 0; i < openSet.Count; i++) //將該節點與所有待評估的節點比較,找出 F 值 最小的節點,F
                								 //值相同就h值更小的節點
            {
                if (openSet[i].FCost < currentNode.FCost 
                    || openSet[i].FCost == currentNode.FCost 
                    && openSet[i].hCost < currentNode.hCost)
                {
                    currentNode = openSet[i];
                }
            }
            #endregion

            openSet.Remove(currentNode);    //待評估節點中去掉 F 值最小的節點
            closedSet.Add(currentNode);     //將該節點加入已評估的節點,之後不再參與評估
            
            if (currentNode == targwtNode)  //如果該節點為目標終點,就計算出實際路徑並結束循環
            {
                RetracePath(startNode, targwtNode);
                return;
            }

            //如果該節點不是目標點,遍歷該點周圍的所有節點
            foreach (Node neighbor in grid.GetNeighbors(currentNode))
            {
                //如果周圍某節點不能行走 或 周圍某節點已經評估,為上一個節點,則跳過
                //                          說明某節點已經設置父節點
                if (!neighbor.walkable || closedSet.Contains(neighbor))
                {
                    continue;
                }

                //計算前起始點前往某節點的 gCost 值,起始點的 gCost 值就是0  
                //經過循環這裡會計算周圍所有節點的g值
                int newMovementCostToNeighbor = currentNode.gCost + GetDinstance(currentNode, neighbor);
                
                //如果新路線 gCost 值更小(更近), 或 某節點沒有評估過(為全新的節點)
                if (newMovementCostToNeighbor < neighbor.gCost || !openSet.Contains(neighbor))
                {
                                                                            
                    neighbor.gCost = newMovementCostToNeighbor;             //計算某節點gCost
                    neighbor.hCost = GetDinstance(neighbor, targwtNode);    //計算某節點hCost
                    neighbor.parent = currentNode;                          //將中間節點設為某節點的父節點
                                                      //如果存在某節點gCost更小的節點,會重新將中間節點設為某節點父節點

                    if (!openSet.Contains(neighbor))    //如果某節點沒有評估過
                    {
                        openSet.Add(neighbor);          //將某節點加入待評估列表,在下一次循環進行評估,
                                                        //下一次循環又會找出這些周圍節點 F 值最小的節點
                    }
                }
            }
        }
    }
    private void RetracePath(Node startNode, Node endNode)	//獲取實際路徑
    {
        List<Node> path = new List<Node>();
        Node currentNode = endNode;	
        while (currentNode != startNode)	//如果當前不為目標點
        {
            path.Add(currentNode);			//將當前節點加入路徑
            currentNode = currentNode.parent;//獲取下一個節點(當前節點的父節點)
        }
        path.Reverse();		//反轉所有元素的順序
        grid.path = path;	//返回實際路徑
    }
    private int GetDinstance(Node nodeA, Node nodeB)	//計算兩個節點間的cost
    {
        int dstX = Mathf.Abs(nodeA.gridX - nodeB.gridX);
        int dstY = Mathf.Abs(nodeA.gridY - nodeB.gridY);
        if (dstX > dstY)
        {
            return 14 * dstY + 10 * (dstX - dstY);
        }
        return 14 * dstX + 10 * (dstY - dstX);
    }
}

修改腳本MyGrid

public class MyGrid : MonoBehaviour
{
    ......

    public List<Node> path;
    ......
    void CreateGrid()
    {
        ......
        for (int x = 0; x < gridSizeX; x++)
        {
            for (int y = 0; y < gridSizeY; y++)
            {
                ......									//多了兩個參數,方便計算周圍節點
                grid[x, y] = new Node(walkable, worldPoint, x, y);    //將節點的數據添加進二位數組
            }
        }
    }
    ......
    public List<Node> GetNeighbors(Node node)	//獲取節點周圍的所有節點
    {
        List<Node> neighbors = new List<Node>();
        //節點的相對坐標左側為x-1,右側為x+1,下方y-1,上方y+1
        for (int x = -1; x <= 1; x++)
        {
            for (int y = -1; y <= 1; y++)
            {
                if (x == 0 && y == 0)	//跳過中間的節點
                {
                    continue;
                }
                //從x、y相對於中間節點的坐標 加上 中間節點位於地圖的坐標,得到了周圍節點位於地圖的坐標
                int checkX = node.gridX + x;
                int checkY = node.gridY + y;
                
                //限定節點範圍,防止出現地圖外的不存在的節點
                if (checkX >= 0 && checkX < gridSizeX && checkY >= 0 && checkY < gridSizeY)
                {
                    neighbors.Add(grid[checkX, checkY]);//添加周圍節點
                }
            }
        }
        return neighbors;
    }

    private void OnDrawGizmos()
    {
        ......
                if (path != null)
                {
                    if (path.Contains(node))	//給路徑添加顏色
                    {
                        Gizmos.color = Color.yellow;
                    }
                }
                Gizmos.DrawCube(node.worldPos, Vector3.one * (nodeDiameter - 0.1f));
            }
        }
    }
}

自行在Inspector面板中設置相應的參數
運行結果


可以隨時修改起點和終點的位置
演示視頻://www.bilibili.com/video/BV14B4y127YN/
下一篇 A*尋路算法2.0 將使用數組實現堆來代替List列表存儲節點,算法消耗的時間將減少約60%

Tags: