上一個項目的尋路方案是客戶端和服務器都采用了 NavMesh 作為解決方案,當時的那幾篇文章(一,二,三)是很多網友留言和后台發消息詢問最多的,看來這個方案有着廣泛的需求。但因為是商業項目,我無法貼出代碼,只能說明下我的大致思路,況且也有些懸而未決的不完美的地方,比如客戶端和服務器數據准確度和精度的問題,但是考慮到項目類型和性價比,我們忽略了這個點。
從今年5月份開始為期一個月,我的主要工作是為新項目尋找一個新的尋路方案。新項目是一個 RTS 實時競技游戲,尋路要求是:每個尋路單位之間的碰撞精確,不能出現不正確的擁擠和穿插,並且單位大小和所在的任何位置,都會影響到其他活動單位的通路性選擇,看起來就是一個典型的 RTS 游戲尋路,和紅警,星際等這些游戲非常像。
項目最初的階段為了先快速迭代功能,使用了 Unity 內建的 NavMesh 系統,當單位停下使用 NavMeshObstacle 在地上挖個坑來影響通路性,但是經常會出現兩個建築型的單位中間會有個小縫,雖然縫很細,但是也是可以走的,而很可能一個半徑巨大的單位直接就試圖傳過去,結果是被卡在這里。如果給單位的半徑很小就可以穿過去,但是感覺很怪,而且單位之間的穿插也會很明顯,十分影響游戲的觀感,更重要的是會影響游戲性。終於有一天,策划的同學們再也不能忍了,必須改掉!
在我看來我首先要解決的是能夠將現有的或者尋找到一個能支持單位半徑大小的尋路方案。但是這從一開始就排除掉了 Unity 的 NavMesh 的尋路方案,因為沒有任何 api 提供給用戶可供做類似的修改,通過修改 RecastNavigation 項目然后編譯 Native 插件的方式我也否掉了,不划算,其實本質上是因為 NavMesh 系統無法提供我們需要的高精度要求,所以我把精力集中到尋找傳統的 Grid 網格尋路上了。
過程中找到了一篇專門講解不同單位通路性的文章:《Clearance-based Pathfinding and Hierarchical Annotated A* Search》,講解深入詳細,並且還配有源碼。
這看起來似乎正好是滿足我要求的東西,但是它有個致命的缺點:所有一切都是預先烘焙和計算的,如果有任何物體或者情況影響了原有的通路性,那么整個烘焙過程必須重新進行,按照作者的算法和流程,對於我們這種時刻都在改變整個地圖通路性的情況來講,完全不可能進行不斷地實時計算,所以該方案最終還是放棄了。
后來朋友介紹了個很有意思的項目:Stratagus - GitHub - Wargus/stratagus: The Stratagus strategy game engine。這個項目很有意思,安裝到手機后,直接把星際爭霸1的資源導入,然后就可以在手機上玩星際爭霸了,狂拽酷炫吊炸天。不過我安裝並且導入星際資源到安卓手機后,一進入戰斗場景就崩潰,試了很多次都不行,其實就是想看看它尋路的表現。好了不浪費時間,直接下載代碼開始閱讀,各種查找翻閱,最后看到了它是如何處理單位的大小和通路性的關鍵判斷:位於 src/pathfinder/astar.cpp:CostMoveToCallBack_Default @line:506
1 /* build-in costmoveto code */ 2 static int CostMoveToCallBack_Default(unsigned int index, const CUnit &unit) 3 { 4 #ifdef DEBUG 5 { 6 Vec2i pos; 7 pos.y = index / Map.Info.MapWidth; 8 pos.x = index - pos.y * Map.Info.MapWidth; 9 Assert(Map.Info.IsPointOnMap(pos)); 10 } 11 #endif 12 int cost = 0; 13 const int mask = unit.Type->MovementMask; 14 const CUnitTypeFinder unit_finder((UnitTypeType)unit.Type->UnitType); 15 16 // verify each tile of the unit. 17 int h = unit.Type->TileHeight; 18 const int w = unit.Type->TileWidth; 19 do { 20 const CMapField *mf = Map.Field(index); 21 int i = w; 22 do { 23 const int flag = mf->Flags & mask; 24 if (flag && (AStarKnowUnseenTerrain || mf->playerInfo.IsExplored(*unit.Player))) { 25 if (flag & ~(MapFieldLandUnit | MapFieldAirUnit | MapFieldSeaUnit)) { 26 // we can't cross fixed units and other unpassable things 27 return -1; 28 } 29 CUnit *goal = mf->UnitCache.find(unit_finder); 30 if (!goal) { 31 // Shouldn't happen, mask says there is something on this tile 32 Assert(0); 33 return -1; 34 } 35 if (goal->Moving) { 36 // moving unit are crossable 37 cost += AStarMovingUnitCrossingCost; 38 } else { 39 // for non moving unit Always Fail unless goal is unit, or unit can attack the target 40 if (&unit != goal) { 41 if (goal->Player->IsEnemy(unit) && unit.IsAgressive() && CanTarget(*unit.Type, *goal->Type) 42 && goal->Variable[UNHOLYARMOR_INDEX].Value == 0 && goal->IsVisibleAsGoal(*unit.Player)) { 43 cost += 2 * AStarMovingUnitCrossingCost; 44 } else { 45 // FIXME: Need support for moving a fixed unit to add cost 46 return -1; 47 } 48 //cost += AStarFixedUnitCrossingCost; 49 } 50 } 51 } 52 // Add cost of crossing unknown tiles if required 53 if (!AStarKnowUnseenTerrain && !mf->playerInfo.IsExplored(*unit.Player)) { 54 // Tend against unknown tiles. 55 cost += AStarUnknownTerrainCost; 56 } 57 // Add tile movement cost 58 cost += mf->getCost(); 59 ++mf; 60 } while (--i); 61 index += AStarMapWidth; 62 } while (--h); 63 return cost; 64 }
每次進行 AStar 尋路,計算尋路 Cost 的時候,都會走到這個回調函數,請注意以上代碼中每個 Unit(建築和可活動單位)都有一個 TileWidth,這個TileWidth 就是尋路單位在地圖上所占的一個正方形的寬度(如果使用長方形會極大的增加計算復雜度,因為要考慮旋轉后占格的問題。)一次遍歷這個正方形,看看每一個格子是否被任何單位設置了使用 Mask,如果沒有就說明可通過,最終如果一個正方形內所有格子都沒有被設置任何 Mask 說明該區域可走,對於移動中的物體,認為它所占的區域屬於可走區域,但是會給予比普通可走區域更高的 Cost。所以看來原理很簡單,就是在尋找 AStar 節點的時候要遍歷該節點所占的每個格子看是否可以通過,條件變得更加嚴格。
這個方式非常適合我的需求,我決定采取這個方式來進行后一步工作。考慮到時間緊迫且對穩定性要求高,我不打算自己重新編寫整個尋路算法和框架了,尋找一個成熟的合適的插件來進行后一步工作,經過試驗考察,我選擇了使用 Unity 上一個非常強大和成熟的插件 A* Pathfinding Project,來進行擴展和升級以便達到我的要求。
我們在 Unity Asset Store 中購買了此插件(很貴:100刀),然后我針對 GridGraph 類型進行了深度的修改,增加單位的 TraverseSize 作為尋路的參數傳入,以便影響尋路結果,這樣不同單位的大小,在穿過縫隙時,就可能會有不同的路徑,如下圖,每一個尋路的 Node 設置為了0.5,大的單位半徑0.5,小的單位半徑0.25,(尋路計算最終是直徑),前往同一個地點,有如下結果:
小的單位可以通過寬度為0.5的縫隙而大的不可以,只能通過最小為1.0大小的縫隙,於是我的最基本的需求得到滿足。
接下來,我需要處理碰撞的問題,精細碰撞需要單位無論大小,速度,位置,都要准確的處理,他們只能在邊緣接觸,不能過於穿插。期初我試用了這個插件自帶的 RVO 系統,但是精度真的不夠,后來我決定采用一個比較詭異但十分湊效的方案:使用 Unity 的 NavMesh 系統的 NavMeshAgent 的 Detour 系統來實現碰撞,當初據 RecastNavigation 的作者說,Unity 深度改寫了該系統的 Detour 系統,可能這就是直接的體現吧。也就是說在地圖上生成一個沒有任何阻擋的完整的 NavMesh 可走區域,但是不用來走尋路目的,只是為了使用 NavMeshAgent 的碰撞計算而已,真實的尋路結果是 A* Pathfinding Project 提供的。
以上所有需要的基礎系統都已經完成,但這只是另一個開始,和項目的結合和調試過程也是一個不斷地改進的過程,需要和策划的同學們不斷地溝通和交流進行調校,經過一段時間的磨合,終於開始穩定的按照預期目標進行工作了。結項!