在上一篇文章,介紹了網格地圖的實現方式,基於該文章,咱們來實現一個A星尋路的算法,最終實現的效果爲:
git
項目源碼已上傳Github:AStarNavigategithub
在閱讀本篇文章,若是你對於裏面提到的一些關於網格地圖的建立方式的一些地圖不瞭解的話,能夠先閱讀了解一下下面的這篇文章:算法
文章連接:編程
在介紹A星尋路算法前,先介紹另一種算法:Dijkstra
尋路算法,簡單的來講是一種A星尋路的基礎版。Dijkstra
做爲一種無啓發的尋路算法,經過圍繞起始點向四周擴展遍歷,一直到找到目標點結束,簡單來講就是暴力破解,由近到遠遍歷全部可能,從而找到目標點緩存
很明顯,這種尋路方式是很的消耗性能的,很是的不高效,有沒有更好的解決方式呢dom
從實際生活中出發,若是你要到達某地,殊不知道具體的路該怎麼辦呢,是否是先大概肯定方向,邊靠近目標點邊問路呢性能
A星尋路算法也是基於這樣的思路,經過必定的邏輯找到能夠靠近物體的方向,而後一步步的走進目標點,直到到達目的地。學習
整個理解過程是一個線性結構,只須要一步步完整的走下去,基本就能夠對於A星有一個大概的瞭解。this
肯定直角斜角權重:.net
本質上來說,A星尋路是基於一種網格的地圖來實現的尋路的方式,在網格中,一個點能夠到達的位置爲周圍的八個方向。而因爲水平與垂直和傾斜的方向距離不同,因此咱們在尋路時須要設置不一樣的長度:
經過圖片能夠看出,直線距離與斜線距離是分別等腰直角三角形直角邊與斜邊。根據勾股定理咱們能夠得知二者的比例關係約爲1.41:1
,爲了方便計算,咱們就將斜邊權重爲14
,而直角邊權重爲10
,這樣的話,要獲得最短的路徑,能夠按照下面的思路去考慮:
遍歷移動格子可能性:
接下來須要考慮第二個問題,在對起始點周圍的可移動格子遍歷完成後,如何找到最短路徑上的那個格子呢,即下一步該走哪個格子,這裏就是整個A星尋路算法的核心:
如圖,當咱們第一步對起始點A周圍全部的格子遍歷後,從A出發有八個能夠移動的方向能夠到達下一個格子。若是你做爲一我的類,固然一眼能夠看出下一步向綠色箭頭方向移動產生的路徑是最短的。
咱們人類能夠根據經驗很快的判斷出方向,可是機器不能,計算機須要嚴謹的程序邏輯來實現這樣的效果,須要咱們賦予他基本的執行程序。經過重複的執行這樣的邏輯,獲得最終的效果。所以,接下來,須要思考如何讓計算機在一系列點位中找到方向最正確的那個點位
計算某一格子指望長度:
到目前,咱們的目的就是使計算機能夠找到找到全部能夠走的格子中產生路徑最短的格子。接下來以你的經驗來思考,比較長短每每是依據什麼。嘿嘿,別想歪,確實是數字的大小。因此咱們須要給每個格子一個數值來做爲路徑經過該格子的代價。
當程序進行到如今,要解決的問題是如何求得一個數字來表明該格子。實現方式是經過計算一個經過格子路徑長度的對比來找到最短的路徑。而任一格子記錄路徑長度標記爲All,並能夠將其分爲兩部分:已走路徑與預估路徑(不理解不要緊,接着往下看):
如圖(靈魂畫手,順便加個防僞標誌嘿嘿)求從A到B點的路徑,當前已經尋路到C點,如何求得通過該點的一個指望路徑的長度呢:
G
:G
值的計算是基於遞推的思想,根據上一個格子的G再加上上一個格子到這個格子的距離便可而後就能夠求出該點的整個指望路徑長度All,對G和H進行一個簡單的加法:
這樣咱們就能夠經過下一步全部可能的移動的格子中找到最短的格子
關於預估路徑長度H的計算:
- 實現對於H的計算的估計有不少,因爲原本就是預估,換句話就是否是必定準確的結果,因此咱們能夠經過計算當前節點到目標點的直線距離或者水平加垂直距離來得到
在本文章的後面演示案例中,是基於水平加垂直距離來計算預估路徑長度H,即在上面的圖中,從C到B的預估路徑計算方式爲:
Hcb = 水平格子差 * 10 + 垂直格子差 * 10
上述步驟總結升級:
假設咱們走到了C點,而且接下來只能從C點向下一步移動,能夠在下面的圖中看出接下來格子的全部可能性:
下面咱們來手動計算一下4號
和5號
的預估路徑長度來幫助你理解這個過程,開始前咱們要知道一條斜邊長14
,直邊長度爲10
:
則AC的長度爲:
Lac=4*14=56
4號:
H = Lac + 1 * 14 = 70 G = 2 * 10 + 2 * 10 = 40 All = H + G = 110
5號:
H = Lac + 1 * 10 = 66 G = 2 * 10 + 3 * 10 = 50 All = H + G = 116
通過對比,5號
格子的指望路徑長度長於4號
,在計算機運行程序時,會對1到7號都進行這樣的計算,而後求得其中的一個最小值並做爲下一步的移動目標
注意:
- 如過有兩個或者多個相同的最小值,會根據程序的寫法選擇任意一個,這不影響整個程序的運行思路
進一步升級
咱們發現,上述步驟是有一些問題,由於場景中沒有障礙物,因此物體會一直走直線。可是在實際狀況中,倘若尋路走進了死衚衕,最後的C點周圍沒有能夠移動的點位怎麼辦呢。
事實上在前面爲了便於理解,咱們在A星尋路上將問題簡化了,一直以最終點做爲下一次尋路的起始點,這種方式是沒有辦法保證最短的路徑的,而在實際的A星尋路中,在每一步中,都會記錄新的能夠移動的路徑加入到列表中,咱們命名這個列表爲開放列表,找到最短的一個節點後,將該點移除,並加入另一個節點,命名爲關閉列表,具體的能夠這麼說
圖中信息註解:
經過反覆的觀看這張動圖,相信你應該對於A星尋路有一個完整的理解,接下來,就須要經過編程來實現該尋路算法
一、製做格子預製體模板
若是你以前看過Unity 製做一個網格地圖生成組件這篇文章,你應該很清楚接下來要作什麼,若是你不瞭解也沒有關係,我這裏再演示一遍:
建立一個Cube
,並調整其縮放,掛載一個腳本Grid
,而後編輯該腳本:
因爲是做爲尋路的基本格子,所以須要其記錄一些信息,咱們定義一些變量:
//格子的座標位置 public int posX; public int posY; //格子是否爲障礙物 public bool isHinder; public Action OnClick; //計算預估路徑長度三個值 public int G = 0; public int H = 0; public int All = 0; //記錄在尋路過程當中該格子的父格子 public Grid parentGrid;
同時在本項目中格子模板須要一個能夠改變其顏色的方法用來標識當前模板所處於的狀態(障礙、起始點、終點、路徑等等),以及一個註冊點擊事件的委託方法,因此最後完整的代碼爲:
using System.Collections; using System.Collections.Generic; using UnityEngine; using System; using UnityEngine.UI; public class Grid : MonoBehaviour { public int posX; public int posY; public bool isHinder; public Action OnClick; //計算預估路徑長度三個值 public int G = 0; public int H = 0; public int All = 0; //記錄在尋路過程當中該格子的父格子 public Grid parentGrid; public void ChangeColor(Color color) { gameObject.GetComponent<MeshRenderer>().material.color = color; } //委託綁定模板點擊事件 private void OnMouseDown() { OnClick?.Invoke(); } }
完成代碼的編寫後,就能夠將其拖入咱們的資源管理窗口Project面板作成一個預製體,或者直接隱藏也能夠
注意:
- 若是你不理解我在幹什麼或者不懂代碼的內容,必定要去查看這篇文章:Unity 製做一個網格地圖生成組件
二、地圖建立
爲了提高代碼的通用性,在這篇文章中,對於網格地圖建立的腳本作出了一些修改,主要在於替換掉腳本中的Grid
變量的定義,轉換爲GameObject
,因爲以前對該腳本有了詳細的介紹,因此只貼出了代碼:
using System.Collections; using System.Collections.Generic; using UnityEngine; using System; public class GridMeshCreate : MonoBehaviour { [Serializable] public class MeshRange { public int horizontal; public int vertical; } [Header("網格地圖範圍")] public MeshRange meshRange; [Header("網格地圖起始點")] private Vector3 startPos; [Header("建立地圖網格父節點")] public Transform parentTran; [Header("網格地圖模板預製體")] public GameObject gridPre; [Header("網格地圖模板大小")] public Vector2 scale; private GameObject[,] m_grids; public GameObject[,] grids { get { return m_grids; } } //註冊模板事件 public Action<GameObject, int, int> gridEvent; /// <summary> /// 基於掛載組件的初始數據建立網格 /// </summary> public void CreateMesh() { if (meshRange.horizontal == 0 || meshRange.vertical == 0) { return; } ClearMesh(); m_grids = new GameObject[meshRange.horizontal, meshRange.vertical]; for (int i = 0; i < meshRange.horizontal; i++) { for (int j = 0; j < meshRange.vertical; j++) { CreateGrid(i, j); } } } /// <summary> /// 重載,基於傳入寬高數據來建立網格 /// </summary> /// <param name="height"></param> /// <param name="widght"></param> public void CreateMesh(int height, int widght) { if (widght == 0 || height == 0) { return; } ClearMesh(); m_grids = new GameObject[widght, height]; for (int i = 0; i < widght; i++) { for (int j = 0; j < height; j++) { CreateGrid(i, j); } } } /// <summary> /// 根據位置建立一個基本的Grid物體 /// </summary> /// <param name="row">x軸座標</param> /// <param name="column">y軸座標</param> public void CreateGrid(int row, int column) { GameObject go = GameObject.Instantiate(gridPre, parentTran); //T grid = go.GetComponent<T>(); float posX = startPos.x + scale.x * row; float posZ = startPos.z + scale.y * column; go.transform.position = new Vector3(posX, startPos.y, posZ); go.SetActive(true); m_grids[row, column] = go; gridEvent?.Invoke(go, row, column); } /// <summary> /// 刪除網格地圖,並清除緩存數據 /// </summary> public void ClearMesh() { if (m_grids == null || m_grids.Length == 0) { return; } foreach (GameObject go in m_grids) { if (go != null) { Destroy(go); } } Array.Clear(m_grids, 0, m_grids.Length); } }
三、實現尋路的過程:
建立一個腳本命名爲AStarLookRode
做爲尋路的腳本
變量定義:
在正式的邏輯代碼開始前,須要定義一些基本的變量:
完成變量的定義後,須要在尋路程序開始,對一些變量進行賦值,同時初始化列表,因此咱們定義一個初始化的方法:
public GridMeshCreate meshMap; public Grid startGrid; public Grid endGrid; public List<Grid> openGrids; public List<Grid> closeGrids; public Stack<Grid> rodes; public void Init(GridMeshCreate meshMap, Grid startGrid, Grid endGrid) { this.meshMap = meshMap; this.startGrid = startGrid; this.endGrid = endGrid; openGrids = new List<Grid>(); closeGrids = new List<Grid>(); rodes = new Stack<Grid>(); }
添加路徑點周圍格子至開放列表:
接下來進行一個功能的代碼邏輯設計,如何將一個點周圍的格子加入到開放列表。能夠觀察場景中的格子,有下面的兩種狀況:
這就須要咱們從中找到能夠取值的範圍,因爲格子的位置信息是一個二維座標,X
和Y
,單純的從X
軸來考慮,X-1
會是格子左邊的格子的座標,可是若是X-1<0
則說明其左邊沒有格子,基於這樣的計算方式,來求得當前格子item
周圍格子的座標範圍,並剔除一些不須要添加的格子,具體的選擇步驟爲:
grid
,若是存在於封閉列表closeGrids
內,不處理openGrids
中,計算該點位到目前尋路位置點的指望路徑長度,若是長度更短的話,將當前格子item
的父物體替換爲該格子的grid
grid
既不在開放列表openGrids
,也再也不閉合列表closeGrids
內,若判斷不爲障礙物,則將其加入開放列表openGrids
,並設置其父物體爲當前尋路位置item
簡單的從圖中理解:
假定咱們如今走到了A
點(A
表明當前路徑點Item
),那麼添加其周圍的格子(用grid
表明)範圍限定在紅色框,爲了便於區分不一樣的狀況,我作了一些簡單的標識:
closeList
內,不處理openList
裏面C
:最須要理解的一個格子,首先要明白,該格子已經被其上面的綠色格子遍歷過,簡單的來講是已經在開放列表內,這個時候咱們就要判斷A
點若是通過C
點過來,路徑會不會更短,若是會,則修改該A
點的父元素爲C
點,不然不處理public void TraverseItem(int i, int j) { int xMin = Mathf.Max(i - 1, 0); int xMax = Mathf.Min(i + 1, meshMap.meshRange.horizontal - 1); int yMin = Mathf.Max(j - 1, 0); int yMax = Mathf.Min(j + 1, meshMap.meshRange.vertical - 1); Grid item = meshMap.grids[i, j].GetComponent<Grid>(); for (int x = xMin; x <= xMax; x++) { for (int y = yMin; y <= yMax; y++) { Grid grid = meshMap.grids[x, y].GetComponent<Grid>(); if ((y == j && i == x) || closeGrids.Contains(grid)) { continue; } if (openGrids.Contains(grid)) { if(item.All > GetLength(grid, item)) { item.parentGrid = grid; SetNoteData(item); } continue; } if (!grid.isHinder) { openGrids.Add(grid); grid.parentGrid= item; } } } }
求任一格子的指望路徑長度:
接下來就須要計算出一個格子的指望路徑的長度,要基於的父元素的G
來計算出該格子的G
,同時預估出來該格子到達目標的距離H,計算方式在原理裏面已經介紹過,這裏直接貼出代碼的執行方式:
public int SetNoteData(Grid grid) { Grid itemParent = rodes.Count == 0 ? startGrid : grid.parentGrid; int numG = Mathf.Abs(itemParent.posX - grid.posX) + Mathf.Abs(itemParent.posY - grid.posY); int n = numG == 1 ? 10 : 14; grid.G = itemParent.G + n; int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY); grid.H = numH * 10; grid.All = grid.H + grid.G; return grid.All; }
在前面的代碼中,有一個開放列表中已經存在,對比指望長度的更改父格子的功能功能。用到了求根據一個格子求下一個格子指望路徑長度的功能。雖然與上面的代碼功能相似,可是不能直接使用,提高通用性修改起來又麻煩,因此直接再寫一個:
public int GetLength(Grid bejinGrid,Grid grid) { int numG = Mathf.Abs(bejinGrid.posX - grid.posX) + Mathf.Abs(bejinGrid.posY - grid.posY); int n = numG == 1 ? 10 : 14; int G = bejinGrid.G + n; int numH = Mathf.Abs(endGrid.posX - grid.posX) + Mathf.Abs(endGrid.posY - grid.posY); int H = numH * 10; int All = grid.H + grid.G; return All; }
開放列表中尋找指望路徑最短的格子:
在完成對於一個格子的指望路徑長度的計算,咱們就須要從開放列表中找出指望路徑長度最短的路徑加入到路徑棧中
可是在這一步有這樣的一個問題,在原理介紹中也有說到,尋路過程當中遇到障礙會進行回溯到以前的某一個路徑點,若是在在棧中執行這樣的操做呢
這裏就要用到格子模板Grid
中存儲的父格子的信息,經過對比棧中的數據,查找到父格子的位置,清除後面的數據,並將該格子插入,具體代碼爲:
/// <summary> /// 在開放列表選中路徑最短的點加入的路徑棧,同時將路徑點加入到閉合列表中 /// </summary> public void Traverse() { if (openGrids.Count == 0) { return; } Grid minLenthGrid = openGrids[0]; int minLength = SetNoteData(minLenthGrid); for (int i = 0; i < openGrids.Count; i++) { if (minLength > SetNoteData(openGrids[i])) { minLenthGrid = openGrids[i]; minLength = SetNoteData(openGrids[i]); } } minLenthGrid.ChangeColor(Color.green); Debug.Log("我在尋找人生的方向" + minLenthGrid.posX + "::::" + minLenthGrid.posY); closeGrids.Add(minLenthGrid); openGrids.Remove(minLenthGrid); rodes.Push(minLenthGrid); }
獲取最終路徑:
在完成尋路的步驟後,須要根據路徑棧和格子的父物體來找到最短的路徑,這裏比較功能邏輯比較清晰,直接貼代碼:
void GetRode() { List<Grid> grids = new List<Grid>(); rodes.Peek().ChangeColor(Color.black); grids.Insert(0, rodes.Pop()); while (rodes.Count != 0) { if (grids[0].parentGrid != rodes.Peek()) { rodes.Pop(); } else { rodes.Peek().ChangeColor(Color.black); grids.Insert(0, rodes.Pop()); } } }
封裝方法,對外暴露:
在解決三個關鍵功能的代碼後,就須要經過一個方法來完成整個尋路的過程,在方法的最後須要經過對終點座標與棧頂物體的座標進行對比,若是相同,則跳出循環,執行路徑查找完成後的操做
同時爲了在本案例中爲了使得整個尋路過程的步驟可視化,使用一個協程來完成尋路過程的方法調用,這樣,在每一次完成一格的尋路後,能夠經過協程來延時執行下一次循環:
public IEnumerator OnStart() { //Item itemRoot = Map.bolls[0].item; rodes.Push(startGrid); closeGrids.Add(startGrid); TraverseItem(startGrid.posX, startGrid.posY); yield return new WaitForSeconds(1); Traverse(); //爲了不沒法完成尋路而跳不出循環的狀況,使用For來規定尋路的最大步數 for (int i = 0; i < 6000; i++) { if (rodes.Peek().posX == endGrid.posX && rodes.Peek().posY == endGrid.posY) { GetRode(); break; } TraverseItem(rodes.Peek().posX, rodes.Peek().posY); yield return new WaitForSeconds(0.2f); Traverse(); } }
接下來須要建立一個腳本明命名爲MainRun 來執行整個項目,主要部分爲建立場景的網格地圖,在前面反覆提到的文章裏面已經有這一部分的介紹。接下來就須要對A星的調用:
using System.Collections; using System.Collections.Generic; using UnityEngine; public class MainRun : MonoBehaviour { //獲取網格建立腳本 public GridMeshCreate gridMeshCreate; //控制網格元素grid是障礙的機率 [Range(0,1)] public float probability; bool isCreateMap=false; int clickNum=0; Grid startGrid; Grid endGrid; private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { Run(); isCreateMap = false; clickNum = 0; } if (Input.GetKeyDown(KeyCode.Q)) { AStarLookRode aStarLookRode = new AStarLookRode(); aStarLookRode.Init(gridMeshCreate,startGrid,endGrid); StartCoroutine(aStarLookRode.OnStart()); } } private void Run() { gridMeshCreate.gridEvent = GridEvent; gridMeshCreate.CreateMesh(); } /// <summary> /// 建立grid時執行的方法,經過委託傳入 /// </summary> /// <param name="grid"></param> private void GridEvent(GameObject go,int row,int column) { //機率隨機決定該元素是否爲障礙 Grid grid = go.GetComponent<Grid>(); float f = Random.Range(0, 1.0f); Color color = f <= probability ? Color.red : Color.white; grid.ChangeColor(color); grid.isHinder = f <= probability; grid.posX = row; grid.posY = column; //模板元素點擊事件 grid.OnClick = () => { if (grid.isHinder) return; clickNum++; switch (clickNum) { case 1: startGrid = grid; grid.ChangeColor(Color.yellow); break; case 2: endGrid = grid; grid.ChangeColor(Color.yellow); isCreateMap = true; break; default: break; } }; } }
在該腳本中,主要是用來執行網格地圖建立的方法的,同時寫入A星腳本的執行接口。
場景執行:
建立一個空物體,並掛載網格地圖建立腳本GridMeshCreate
與運行腳本MainRun
,而後對這兩個腳本進行賦值:
在兩個腳本中,咱們能夠控制一些變量來改變網建立網格地圖大小與障礙物的佔比:
MainRun
中Probability
:用來控制地圖中障礙物的數量佔比GridMeshCreate
中Mesh Range
:用來控制網格地圖的大小範圍在完成上面的腳本掛載與設置後,就能夠運行遊戲,進入遊戲場景後,點擊空格便可建立地圖,
在建立地圖後,可使用鼠標點擊地圖中的白色格子,第一次點擊表示選擇了路徑的起始點,而第二次點擊表示選擇了目標點格子
注意:
- 這一塊Bug挺多的,我也沒有修改,因此儘可能按着提示來,不要非要點擊障礙物,或者非要在場景中點擊三次
在完成對於兩個關鍵節點的選擇後,就能夠點擊Q鍵
開始執行尋路過程,而後就能夠直接觀察整個場景中的運行流程:
算法複雜度問題:
第一張圖片:障礙物的比例比較低時,尋找的路徑接近於一條直線,同時沒有多餘的尋路節點產生:
當地圖複雜度上升後,A星尋路產生巨大的代價才能獲取最後的路徑,而這些代價產生的緣由是因爲爲了獲取最短的路徑而進行大量的回溯,而回溯又進一步形成了遍歷列表長度的增長,進一步的消耗了計算資源。
因此當地圖複雜度到達必定閾值並再次上升後,尋路的代價會急速的上升,也能夠簡單的理解爲指數的形式,而當這一數值超過了0.5
,地圖基本就處於不可用的狀態,會有大量的死衚衕,很大機率形成無路可循。
特殊狀況的尋路效果:
話很少說,先看圖:
經過上圖能夠看出,雖然場景中的網格地圖很簡單,可是當兩個尋路點之間存在比較大的橫截面時,也一樣會付出巨大的尋路代價
擴展:
- 看到這張圖,你知道Unity官方的NavMesh是如何實現尋路的嗎?
當咱們使用NavMesh
來執行尋路操做時,會事先對場景進行烘培,若是你曾經觀察過這張烘培地圖,就會發現其是由一張張三角面來構成的,而當咱們進入遊戲,執行尋路操做時,NavMesh
就會根據這些三角面的頂點來執行可移動的路徑計算。
如圖,其實NavMesh
的優點在與烘培階段對於地圖障礙的處理,經過一些頂點來大大簡化了尋路時的計算。
若是你先學習NavMesh 的使用方式:
- 能夠經過該文章:unity中Navigation實現自動尋路功能
總的來講,A星是目前應用最廣的尋路方式,其特色簡單明瞭,整個過程以最短路徑爲設計準則,逐漸的接近目標點。
可是要注意,A星雖然一直以最短爲驅動,可是最終獲得的路徑不必定最短(至少本篇文章的案例是這樣)。至於緣由,你若是理解了代碼的實現過程應該也能明白,若是你不理解,知道緣由也沒意義,嘿嘿!