請叫我標題黨!請叫我標題黨!請叫我標題黨!由於下面的文字既不發生在美國曼哈頓,也不是一個講述美國夢的故事。相反,這可能只是一篇沒有那麼枯燥的關於算法的文章。A星算法,這個在遊戲尋路開發中不免會用到的算法即是我這篇文章的主角。算法
這是一張美國曼哈頓的俯視圖,放眼望去除了能看到這裏高樓林立以外,咱們也能發現其另一個特色,即橫平豎直的街道將一整塊地區整整齊齊的分紅了好幾個區塊。人和車流只能行進在橫穿其中的街道上,也只能在街道的交叉口改變本身的前進的方向。例如要找出地圖中A點到B點的最佳路線,事實上就是從A點所在的交叉口沿着街道走到B點所在的交叉口,咱們沒法從區塊內部穿越過去,除了沿街道走別無選擇。
下面讓咱們把曼哈頓的這些街道交叉口當作結點,兩個交叉口之間的街道當作邊,作出一個以下圖所示的二維網格。
那麼A點到B點的實際距離是多少呢?考慮到咱們只能沿着街道行走,而沒法從街道圍成的區塊中穿越,所以在這種狀況下A點到B點的實際距離並非它們之間的直線距離,而是應該以下圖所示的這樣:
轉換成數學語言就是這樣:函數
dis = abs(A.x - B.x) + abs(A.y - B.y)
對了,這就是曼哈頓距離。也就是在A星算法中經常被用來做爲啓發函數的傢伙。等等,啓發函數是什麼?讓我繼續。設計
從A點到B點的這條路徑,顯然包括了以A爲起點B爲終點的一系列結點,而每一個結點也只能從和本身相鄰的結點中選擇下一個行走目標。可是正如現實生活同樣,暢通無阻的街道老是奢求,在路上總會花費一些代價,例如路況不佳,交通擁堵等等緣由形成從這條道路行走時會花費更多的時間。所以在尋路中,一條路徑的代價等於在每一個路口選擇的道路的代價之和。
瞭解了這些以後,就讓咱們來實現一個最粗暴的尋路方式,彷彿一個醉漢,無視每條道路是否已經走過,也不關心每條道路所花費的時間代價,反正只須要在路口閉着眼睛作出一個選擇就行了。3d
//僞代碼 q = newqueue q.enqueue(newpath(start)) while q is not empty p = q.dequeue if p.lastNode == destination return p foreach n in p.lastNode.neighbours q.enqueue(p.continuepath(n)) //找不到合適路徑 return null
這樣作的後果是什麼呢?不錯,就像一個醉漢同樣,從路口的四個方向中隨機選擇一個方向,甚至還有可能走回頭路(由於沒有記錄他已經走過的路口),也許最後的確可以找到家,可是這個過程當中殊不知道消耗了多少時間,走了多少冤枉路。更有甚者,若是實際上並無一條可以到達目的地的路徑,甚至會出現「鬼打牆」的狀況,即進入了一個無限的死循環之中沒法自拔。
因此,讓咱們來幫他一下吧,既然醉漢不記得已經走過了哪些路口,那麼就讓咱們來幫他記住他走過的路口。咱們爲上面的代碼引入一個closed集合,用來保存已經走過路口。code
//僞代碼 //引入一個集合,用來保存已經走過的路口 closed = {} q = newqueue q.enqueue(newpath(start)) while q is not empty p = q.dequeue //若是下面closed集合中包含了路徑p的最後一個路口 //p.last則忽略 if closed contains p.last continue //若是路徑p的最後一個路口便是目的地,則直接返回p if p.last == destination return p //不然將該點p.last加入到closed集合中 closed.add(p.last) //把點p.last相鄰的點加入到隊列中 foreach n in p.last.neighbours q.enqueue(p.continuepath(n)) //找不到合適的路 return null
這樣,咱們就幫醉漢解決了走回頭路的問題,也消除了「鬼打牆」的隱患。可是,醉漢在選擇道路時仍然沒有一個明確的目標,這也就決定了他在尋找目的地的效率並不高效。由於他仍然會向四面八方尋路,雖然他在咱們的幫助下已經不會走回頭路了。顯然,爲了儘早讓醉漢回到家,咱們須要爲他選擇一條最佳的道路。可是,這條最佳的道路到底應該如何選擇(預估)呢?blog
在考慮如何尋找最佳路徑以前,咱們第一步要作的顯然就是爲最佳路徑定義一個能夠量化的標準。到底以什麼爲標準來評價一條路徑呢?最簡單的,咱們就選擇兩個路口之間的距離做爲標準,這裏咱們將距離長度稱之爲路徑的開銷,且一個路口上下左右相鄰的路口的消耗爲1,而對角線上的路口消耗則爲1.41。
而咱們評價一條潛在路徑的開銷時,所依據的數據主要來自兩個方面:排序
而咱們所要作的,即是在幫助醉漢不走回頭路的基礎上,再爲醉漢指一個回家的方向。醉漢只要按照這個方向走,便可以很快的找到家。而這個方向又是如何肯定的呢?其實十分簡單,咱們只需找到總消耗最小的路徑即可以了。這裏咱們記總消耗爲F,那麼顯然有以下這樣的等式:隊列
F = G + H遊戲
那麼具體應該如何操做呢?咱們須要一個優先隊列,記錄每條路徑的總消耗以及這條路徑,而且根據路徑的總消耗來對該隊列進行排序,這樣消耗最小的路徑便能輕易地獲取了。因此,咱們的代碼拓展成了下面這個樣子:圖片
//僞代碼 //引入一個集合,用來保存已經走過的路口 closed = {} q = newqueue; //q爲優先隊列,記錄路徑的消耗以及路徑,起始點消耗爲0 q.enqueue(0, newpath(start)) while q is not empty //優先隊列彈出消耗最小的路徑 p = q.dequeueCheapest if closed contains p.last continue; if p.last == destination return p closed.add(p.last) foreach n in p.last.neighbours //得到新的路徑 newpath2 = p.continuepath(n) //將新路徑的總消耗(G+H),和新路徑分別入隊 q.enqueue(newpath.G + estimateCost(n, destination), newpath2) return null
其中,咱們能夠發現預估到目的地消耗的函數叫「estimateCost」,這即是在A星算法中咱們經常提起的啓發函數。它的做用即是估算當前位置到目的地的大概距離,而在本文一開始介紹的曼哈頓距離即是一種經常使用的啓發函數。即計算當前路口(格子)到目標路口(格子)之間的垂直和水平的路口(格子)數量總和。
dis = abs(A.x - B.x) + abs(A.y - B.y)
而這個啓發函數,即是咱們送給醉漢回家的指南針。
固然,借這個醉漢回家的例子說明的僅僅是A星算法最基本的實現原理。而在實際的工程中,它也有更加複雜的使用環境,下面我就簡單的介紹幾種工程中實現A星尋路的工做方式。
咱們有了算法的實現思路,接下來即是如何在遊戲中實現A星算法了。
要在遊戲中進行尋路,首先要作的即是藉助圖來將遊戲地形表示出來,而這個圖即是導航圖。
而最多見的導航圖即是以下三種:
如上圖所示,將遊戲地圖劃分爲許多單元格的形式即是咱們所說的基於單元格的導航圖。這種表示方式的結構十分規則,所以最容易理解和使用,且易於動態更新。所以在須要頻繁動態更新場景的遊戲中使用這種基於單元格的導航圖便十分的恰當。
可是,爲了追求尋路的結果更加精確,單元格的大小就成爲了關鍵,過大的單元格顯然和精確無緣,可是若是爲了追求精確而使用很小的單元格,卻又不得不面對另外一個問題——須要存儲和搜索的結點的數量會十分大。這樣不只須要大量的消耗內存,同時也會影響搜索效率。
若是咱們經過人工不規則的放置一些用來導航的點來代替剛剛的單元結點,那麼是否會有更好的表現呢?所以,基於可視點,或者被稱爲路點(The waypoints)的導航圖便出現了。如上圖所示,紅色的結點即是放置的路點,而路點之間的連線是遊戲單位能夠行走的路徑。
這種基於路點的導航圖的優點即是可讓場景設計師按照場景的特色來佈置路點,因爲能夠按照設計師的想法來放置,所以基於路點的導航圖的一大特色即是靈活性很高,且不像基於單元格的導航圖那樣,須要存儲和搜索大量的結點,所以須要的內存和搜索的效率較前者都要優秀。
可是它的缺點也一樣明顯,那就是若是場景過大,放置少許的路點顯然沒法知足須要,可是放置不少路點時,會使得場景設計師的工做變得複雜且容易出錯。而因爲遊戲單位只能在兩個路點之間的連線上進行移動,所以若是遊戲單位不在結點或結點間連線上的時候,會先到離它最近的路點上,以後再次移動,這樣從視覺上看會出現不天然的狀況。
如圖,導航網格將遊戲地形劃分紅了大大小小的三角形,而這些三角形也就成爲了A星算法中的節點。相鄰的三角形能夠直達,換言之,三角形相鄰的其餘三角形既其相鄰的結點。 所以,與前兩種導航圖相比,因爲其「節點」面積大,所以只須要少許的「節點」便可覆蓋整個遊戲區域,從而減小了「節點」的數量。其次,也正是因爲節點所有覆蓋了遊戲場景,所以沒必要擔憂像基於路點的導航圖那樣因爲缺乏路點而形成的尋路不精確的問題。 可是,它一樣並不是十全十美的,相較前二者而言,生成導航網格的時間較長,所以推薦在靜態場景中使用,而在地形常常發生變化的場景中減小使用。