在游戲中,有一個很常見地需求,就是要讓一個角色從A點走向B點,我們期望是讓角色走最少的路。嗯,大家可能會說,直線就是最短的。沒錯,但大多數時候,A到B中間都會出現一些角色無法穿越的東西,比如牆、坑等障礙物。這個時候怎么辦呢? 是的,我們需要有一個算法來解決這個問題,算法的目標就是計算出兩點之間的最短路徑,而且要能避開障礙物。
百度百科:
A*搜尋算法俗稱A星算法。這是一種在圖形平面上,有多個節點的路徑,求出最低通過成本的算法。常用於游戲中的NPC的移動計算,或線上游戲的BOT的移動計算上。
簡化搜索區域
要實現尋路,第一步我們要把場景簡化出一個易於控制的搜索區域。
怎么處理要根據游戲來決定了。例如,我們可以將搜索區域划分成像素點,但是這樣的划分粒度對於一般的游戲來說太高了(沒必要)。
作為代替,我們使用格子(一個正方形)作為尋路算法的單元。其他的形狀類型也是可能的(比如三角形或者六邊形),但是正方形是最簡單並且最常用的。
比如地圖的長是w=2000像索,寬是h=2000像索,那么我們這個搜索區域可以是二維數組 map[w, h], 包含有400000個正方形,這實在太多了,而且很多時候地圖還會更大。
現在讓我們基於目前的區域,把區域划分成多個格子來代表搜索空間(在這個簡單的例子中,20*20個格子 = 400 個格子, 每個格式代表了100像索):

既然我們創建了一個簡單的搜索區域,我們來討論下A星算法的工作原理吧。
我們需要兩個列表 (Open和Closed列表):
- 一個記錄下所有被考慮來尋找最短路徑的格子(稱為open 列表)
- 一個記錄下不會再被考慮的格子(成為closed列表)
首先在closed列表中添加當前位置(我們把這個開始點稱為點 “A”)。然后,把所有與它當前位置相鄰的可通行格子添加到open列表中。
現在我們要從A出發到B點。
在尋路過程中,角色總是不停從一個格子移動到另一個相鄰的格子,如果單純從距離上講,移動到與自身斜對角的格子走的距離要長一些,而移動到與自身水平或垂直方面平行的格子,則要近一些。
為了描述這種區別,先引入二個概念:
節點(Node):每個格子都可以稱為節點。
代價(Cost):描述角色移動到某個節點時所走的距離(或難易程度)。

如上圖,如果每水平或垂直方向移動相鄰一個節點所花的代價記為1,則相鄰對角節點的代碼為1.4(即2的平方根--勾股定理)
通常尋路過程中的代價用f,g,h來表示
g代表(從指定節點到相鄰)節點本身的代價--即上圖中的1或1.4
h代表從指定節點到目標節點(根據不同的估價公式--后面會解釋估價公式)估算出來的代價。
而 f = g + h 表示節點的總代價
/// <summary> /// 尋路節點 /// </summary> 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; } }
注意:這里有二個新的東東 isWall 和 parent。
通常障礙物本身也可以看成是由若干個不可通過的節點所組成,所以 isWall 是用來標記該節點是否為障礙物(節點)。
另外:在考查從一個節點移動到另一個節點時,總是拿自身節點周圍的8個相鄰節點來說事兒,相對於周邊的節點來講,自身節點稱為它們的父節點(parent).
前面一直在提“網格,網格”,干脆把它也封裝成類Grid.cs
using UnityEngine; using System.Collections; using System.Collections.Generic; public class Grid : MonoBehaviour { public GameObject NodeWall; public GameObject Node; // 節點半徑 public float NodeRadius = 0.5f; // 過濾牆體所在的層 public LayerMask WhatLayer; // 玩家 public Transform player; // 目標 public Transform destPos; /// <summary> /// 尋路節點 /// </summary> 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; } } private NodeItem[,] grid; private int w, h; private GameObject WallRange, PathRange; private List<GameObject> pathObj = new List<GameObject> (); void Awake() { // 初始化格子 w = Mathf.RoundToInt(transform.localScale.x * 2); h = Mathf.RoundToInt(transform.localScale.y * 2); grid = new NodeItem[w, h]; WallRange = new GameObject ("WallRange"); PathRange = new GameObject ("PathRange"); // 將牆的信息寫入格子中 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); } } } } // 根據坐標獲得一個節點 public NodeItem getItem(Vector3 position) { int x = Mathf.RoundToInt (position.x) * 2; int y = Mathf.RoundToInt (position.y) * 2; x = Mathf.Clamp (x, 0, w - 1); y = Mathf.Clamp (y, 0, h - 1); return grid [x, y]; } // 取得周圍的節點 public List<NodeItem> getNeibourhood(NodeItem node) { List<NodeItem> list = new List<NodeItem> (); for (int i = -1; i <= 1; i++) { for (int j = -1; j <= 1; j++) { // 如果是自己,則跳過 if (i == 0 && j == 0) continue; int x = node.x + i; int y = node.y + j; // 判斷是否越界,如果沒有,加到列表中 if (x < w && x >= 0 && y < h && y >= 0) list.Add (grid [x, y]); } } return list; } // 更新路徑 public void updatePath(List<NodeItem> lines) { int curListSize = pathObj.Count; for (int i = 0, max = lines.Count; i < max; i++) { if (i < curListSize) { pathObj [i].transform.position = lines [i].pos; pathObj [i].SetActive (true); } else { GameObject obj = GameObject.Instantiate (Node, lines [i].pos, Quaternion.identity) as GameObject; obj.transform.SetParent (PathRange.transform); pathObj.Add (obj); } } for (int i = lines.Count; i < curListSize; i++) { pathObj [i].SetActive (false); } } }
在尋路的過程中“條條道路通羅馬”,路徑通常不止一條,只不過所花的代價不同而已。
所以我們要做的事情,就是要盡最大努力找一條代價最小的路徑。
但是,即使是代價相同的最佳路徑,也有可能出現不同的走法。
用代碼如何估算起點與終點之間的代價呢?
//曼哈頓估價法 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); } }
好吧,下面直接貼出全部的尋路算法 FindPath.cs:
using UnityEngine; using System.Collections; using System.Collections.Generic; public class FindPath : MonoBehaviour { private Grid grid; // Use this for initialization void Start () { grid = GetComponent<Grid> (); } // Update is called once per frame void Update () { FindingPath (grid.player.position, grid.destPos.position); } // 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> (); HashSet<Grid.NodeItem> closeSet = new HashSet<Grid.NodeItem> (); openSet.Add (startNode); while (openSet.Count > 0) { Grid.NodeItem curNode = openSet [0]; for (int i = 0, max = openSet.Count; i < max; i++) { if (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.hCost = getDistanceNodes (item, endNode); // 更新父節點為當前選定的節點 item.parent = curNode; // 如果節點是新加入的,將它加入打開列表中 if (!openSet.Contains (item)) { openSet.Add (item); } } } } generatePath (startNode, null); } // 生成路徑 void generatePath(Grid.NodeItem startNode, Grid.NodeItem endNode) { List<Grid.NodeItem> path = new List<Grid.NodeItem>(); if (endNode != null) { Grid.NodeItem temp = endNode; while (temp != startNode) { path.Add (temp); temp = temp.parent; } // 反轉路徑 path.Reverse (); } // 更新路徑 grid.updatePath(path); } // 獲取兩個節點之間的距離 int getDistanceNodes(Grid.NodeItem a, Grid.NodeItem b) { int cntX = Mathf.Abs (a.x - b.x); int cntY = Mathf.Abs (a.y - b.y); // 判斷到底是那個軸相差的距離更遠 if (cntX > cntY) { return 14 * cntY + 10 * (cntX - cntY); } else { return 14 * cntX + 10 * (cntY - cntX); } } }
運行效果圖:

紅色區域是標識出來的不可以行走區域。(代碼中對兩個點的定位有點小小的問題,不過不影響算法的演示,我也就懶得改了)
完整代碼下載:
http://pan.baidu.com/s/1jInjLDs
轉載注明出處。
