一、A星尋路算法介紹
當你在制作一款游戲的時候是否想過讓你的角色避開道路上的障礙物從而抵達終點呢?
如果有的話,那么這篇文章你要認真看下去,至少可以幫助你初步建立一個利用A星算法的思路實現它!
本篇文章將從算法最基本的思路講起,讓我們開始吧!
二、一張棋盤格
讓我們來看這張圖,你創造的主角小紅,想要到達小黃所在的位置,這條線路我們應該怎么找。
顯然圖片中黑色的部分不像是小紅能直接穿過的地方,我們需要繞一繞,也許你有很多條路可以走,但我現在告訴你,我們趕時間,我們需要找出最短的路!
如何解決這個問題呢,A星算法來了。
三、基本思路
我們將每個位置默認為一個正方格,他的目的是便於我們之后的計算
不要質疑為什么你的主角變了顏色,這並不影響我們的講解。
我們創造了一個簡單的搜索區域,八個方向,並且我們用小本本記下了兩個列表:open列表(記錄下所有被考慮來尋找最短路徑的方塊) 和 close列表(記錄下不會再被考慮的方塊)
首先我們將起點添加入close列表中(我們將起點設為“A”,深綠色方框),再將A附近所有可行方塊添加入open列表中(綠色描邊方框)
路徑增量
我們給每一個方塊一個G+H和值
G為從起點A到當前點的移動量(代表本文中的方塊數量),所以從A開始到相鄰點的G值為1,這個值會隨着角色的移動(或者說距離開始點)越來越遠而增大。
H為從當前所在點到終點(我們將它設為B!)的移動估算量,這個常被成為探視,因為我們不確定它的移動量的准確數值,所以這個H僅僅只是一個估算值。
(值得一提的是,這個H的估算值我們有多種辦法算取,你可以使用“曼哈頓距離算法”,或是歐拉公式等,它只是計算出點B剩下的水平垂直方塊的數量,忽略掉中途的任何障礙物)
在A星算法中移動量這個值是由你來決定的,你可以僅僅允許主角進行上下左右四個方向的移動,或者你可以將移動量針對地形調整到大一點。
四、算法原理
既然你已經知道G和F,我們來認識一下這個算法最核心的值——F值,它有一個公式:F=G+H,它的意義是方塊的移動總代價(或稱為和值)
在算法中,角色將重復下列幾個步驟來尋找最短路徑:
1,將方塊添加到open列表中,且該方塊擁有最小的F和值,我們暫且將它稱為S。
2、將其從open列表中移除,然后添加入close列表中。
3、對於S相鄰的每一個方塊,都有:
若該方塊在close列表中,我們不管它。
若改方塊不在open列表中,計算它的F和值並將其添加入open列表中。
若該方塊已經在open列表中,當我們沿着當前路徑到達它時,計算它的F和值是否更小,如果是,前進並更新它的和值和它的前繼。
為了幫助你理解它的原理,我們來舉個例子吧:
在接下來的每一步中,綠色方框代表我們可以選擇的方塊,而已選擇的方塊我們會用紅色邊框將它點亮。
第一步,我們要確定起點附近的每一個方塊,計算他們的F值並將其添加入open列表中,在圖片中,方框左下方的數字代表G值,為了確保你學會了怎么使用“截取距離算法(忽略障礙物由A到B的位移量)”H值,我們不打算將其標入方框中,最終,將G與你計算出的H值相加便得到左上角的F和值。
第二步,選擇其中F值最小的方塊並將其添加入close列表中,再次檢索它相鄰的可行方塊,我們發現有兩個一模一樣的方塊可選,而根據剛才講到的第三條定理,我們發現上下兩個方塊都已經在open列表中,且我們通過計算發現,第一步時它的G值為1,但當我們經由當前已在的“4,1”方格在到達那里時,它的G值將變為2(因為我們繞了一下,所以移動了兩步),顯然2比1大,因此從“4,1”再走到“5,1”並不是最優路徑,我相信你是一個有遠見的人,所以你會從第一步就選擇“5,1”方塊。
第三步、當你選擇走最優路徑時,你會發現一個問題,“5,1”方塊有兩個,也就是說有兩條一模一樣的路可以走,但真的是這樣嗎,我們保留這個疑問,隨便選擇一個,比如我選擇走上邊的“5,1”方塊。
再次檢索周圍方塊,並忽略掉障礙物。我們得到如下圖的信息。
我們發現有好幾個F值都為6的,沒關系,我們都考慮上並計算他附近的F值,在這里我們也順便將剛才未選擇的下方“5,1”方塊周圍的F值計算一下
可以看到其實左邊的方塊其實是不用考慮的,我們人眼一看就知道接着剛才的路繼續尋找就好了,但是程序並不知道,他只會老老實實運行你給他規定的步驟,這算是必踩的坑。
但是有一種比較簡便的方法是,規定一直沿着最近被添加入open列表的方塊。
好了現在你已經訓練有素了,經過幾次重復你得到了下圖這樣的路徑
你成功到達了終點,他已經在open列表中了,當你邁出最后一步時,程序會將它從open中移除並添加入close列表中。
最后,算法,算法需要做的是就是沿着路徑返回並計算出最優路徑。
讓我們將最終路徑用藍色方框強調出來。
代碼部分
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title></title> <style type="text/css"> #ul1{ margin: 30px auto; border: 1px solid black; border-bottom: none; border-right:none ; padding: 0; height: auto; overflow: hidden; } #ul1 li{ list-style: none; border: 1px solid black; border-top:none ; border-left:none ; float: left; } #ul1 li.style1{ background-color: red; } #ul1 li.style2{ background-color: black; } #ul1 li.style3{ background-color: orange; } #btn{ position: absolute; left: 50%; margin-left: -50px; } #btn:hover{ background-color: #E21918; color: white; border-radius: 4px; } </style> </head> <body> <ul id="ul1"> </ul> <input id="btn" type="button" value="開始尋路"/> <script type="text/javascript"> var oUl = document.getElementById("ul1"); var aLi = document.getElementsByTagName("li"); var beginLi = document.getElementsByClassName("style1"); var endLi = document.getElementsByClassName("style3"); var oBtn = document.getElementById("btn") //算法實現 /** * open隊列: 收集可能會需要走的路線 要走的路線放在open隊列中 * close隊列: 排除掉不能走的路線 不走的路線放在close隊列中 * */ //可能要走的路線 var openArr = [] //已經關閉的路線 var closeArr = [] var map = [ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,2,2,2,2,0,0,0,0,3,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 ] //最終線路數組 var resultParent = []; init(); //初始化函數 function init(){ createMap() //點擊按鈕的時候 需要去收集可能走的路線 oBtn.onclick = function(){ openFn(); } } //創建地圖 function createMap(){ var liSize = 20; for(var i=0;i<map.length;i++){ var oLi = document.createElement("li"); oLi.style.width = liSize +"px"; oLi.style.height = liSize + "px"; oUl.appendChild(oLi); if(map[i]==1){ oLi.className = "style1"; //當元素剛開始創建的時候,open隊列中的元素只有 起始節點 也就是說將紅色點都放到open隊列中 並且 剛開始的時候 起始點只有一個 openArr.push(oLi); }else if(map[i]==2){ //當元素剛剛開始創建的時候 close隊列中的元素 就是 值為2的元素 也就是說 把黑色的點都放到close隊列中 這些作為障礙物 是不會走的 oLi.className = "style2"; closeArr.push(oLi); }else if(map[i]==3){ oLi.className = "style3" } } //ul的寬帶等於 ul的左邊 1 + 20個節點的寬帶 20*(liSize+1) 其中 liSize+1 是因為 節點有1個像素的右邊框 oUl.style.width = 20*(liSize+1)+1+"px" } //估價函數 function fn(nowLi){ return g(nowLi)+h(nowLi) } //初始點到當前節點的實際代價 function g(nowLi){ //勾股定理 var a = nowLi.offsetLeft-beginLi[0].offsetLeft; var b = nowLi.offsetTop - beginLi[0].offsetTop; return Math.sqrt(a*a+b*b) } //當前節點到目標點的實際代價 function h(nowLi){ //勾股定理 var a = nowLi.offsetLeft-endLi[0].offsetLeft; var b = nowLi.offsetTop - endLi[0].offsetTop; return Math.sqrt(a*a+b*b) } /** * 實現的功能: 1 把open隊列中的元素移到close隊列中,表示起始節點已經走過了,那么接下來應該走哪一步呢? * 2 把起始位置周圍的 8 個點都找出來 並且 計算出 估價函數值最低的那個元素 那么這個元素就是接下來要走的這步 * 3 接下來走的這步確定了 那么就又該把這個位置的點移動到 close隊列中,然后繼續找周圍的點 並且進行估價 以此類推 */ function openFn(){ //nodeLi 表示 當前open隊列中的元素 也就是說 先去除第一個起始節點 //shift 方法的作用: 把數組中的第一個元素刪除,並且返回這個被刪除的元素 var nodeLi = openArr.shift(); //如果nodeLi 和 endLi 一樣了 那么證明已經走到目標點了 ,這個時候需要停止調用 if(nodeLi == endLi[0]){ showPath(); return; } //把open隊列中刪除的元素 添加到 close隊列中 closeFn(nodeLi) //接下來 需要找到 nodeLi 周圍的節點 findLi(nodeLi); //經過上面的步驟 已經能夠找到相鄰的元素了 接下來需要對這些元素的估值進行排序 openArr.sort(function(li1,li2){ return li1.num - li2.num }) //進行遞歸操作 找下一步需要走的節點 在這個過程中,也需要執行相同的步驟 那就是查找相鄰的節點 但是查找出來的結果可能和上一次的重復,也就是說上一次動作已經把這個元素添加到open隊列中了 //那么就沒有必要再進行push操作了 所以還需要在過濾函數中加一段代碼 openFn(); } function closeFn(nodeLi){ //open隊列中刪除的元素 被 push到close隊列中 closeArr.push(nodeLi); } /** * 封裝函數查找某個節點周圍的節點 */ function findLi(nodeLi){ //創建一個結果數組 把查找到的結果放到這個數組中 var result = []; //循環所有的li節點 進行查找 for(var i=0;i<aLi.length;i++){ //如果經過過濾 返回的是true 表示 這個節點不是障礙物 那么需要添加到result結果數組中 if(filter(aLi[i])){ result.push(aLi[i]); } } //接下來需要在沒有障礙物的結果中去找 和 當前節點相鄰的節點 //判斷條件是 他們的橫縱坐標的差值需要小於 等於 網格大小 for(var i=0;i<result.length;i++){ if(Math.abs(nodeLi.offsetLeft - result[i].offsetLeft)<=21 && Math.abs(nodeLi.offsetTop - result[i].offsetTop)<=20+1 ){ //這里的result[i]就是當前目標點相鄰的節點 把這些節點傳入到估價函數就能得到他們的估值,並且要把這些估值掛載到他們自身的一個自定義屬性上 result[i].num = fn(result[i]); //nodeLi 是當前的位置 result[i] 是當前位置相鄰的點 下一次要走的位置就在這幾個點中,所以給result[i]定義一個parent屬性 //來存上一次的路徑 ,最終把這些路徑聯系起來就是完整的路徑 result[i].parent = nodeLi; openArr.push(result[i]); } } } /** * 封裝函數 實現過濾功能 * 這個函數的功能就是 接收到一個li 判斷是否是障礙物 如果是 就返回false 如果不是就返回true */ function filter(nodeLi){ //循環close隊列中的所有元素 與傳過來的節點進行比對 如果比對成功 返回false for(var i=0;i<closeArr.length;i++){ if(nodeLi == closeArr[i]){ return false; } } for(var i=0;i<openArr.length;i++){ if(nodeLi == openArr[i]){ return false; } } //如果循環完都沒有匹配上 那么證明當前傳過來的 li節點 並不是障礙物 return true; } /** * 打印出所走過的路徑 */ function showPath(){ //closeArr中最后一個 就是 找到目標點的前一個位置 因為走過的位置都會被存放在closeArr中 var lastLi = closeArr.pop(); var iNow = 0; //調用findParent函數 來找上一個節點 findParent(lastLi) var timer = setInterval(function(){ resultParent[iNow].style.background = "red"; iNow++; if(iNow == resultParent.length){ clearInterval(timer); } },500) } /** * 定義一個函數來找到上一次走過的節點 */ function findParent(li){ resultParent.unshift(li); if(li.parent == beginLi[0]){ return; } findParent(li.parent); } </script> </body> </html>
(我們在該代碼中使用的是勾股定理來計算,實際上與街區算法原理)
這便是所有代碼部分了,其中都包含有對每部分的注釋講解,讀者可自行閱讀理解。
運行結果
我在這里插入一個比較優秀的A星算法演示鏈接,方便各位理解算法思路。
http://qiao.github.io/PathFinding.js/visual/
成品展示鏈接
鏈接:https://pan.baidu.com/s/1Y4OaovodEtBeXUCRdOKDIg
提取碼:dt68
小組成員:楊豪傑 劉益 謝君 楊千禧