遊戲角色ai的實現方式討論
很久之前我曾經介紹過不少遊戲角色尋路方面實現的方法,但作為完整角色ai行為,我覺得比較難以介紹,首先這涉及到比較多的知識面,然後實現的方式也很多,比如有限狀態機、決策樹、神經網路等,我認為各有各的優缺點。最後,能實現這個完整過程的手段和框架設計也很多。所以一般介紹角色ai的文章都比較長篇大論,甚至可以寫出很多幾百頁的書籍。
我的寫作能力有限,技術水平也有限,所以一直覺得難以表達這方面的知識。於是我做了一個很簡單的demo,打算根據這個demo,來分析一下我自己做遊戲角色ai時候的一些思路和方法。
一、demo介紹
這個demo比較簡單,主要想實現的目標是這樣的:有多種不同的角色共存在場景中,不同的角色從行為、思考的習慣上都是不一樣的,而最終會產生不同的表現效果。
這裡介紹一些demo裡面的角色:
1、主玩家角色。受玩家的輸入控制作出各種行為,由於這裡主要是研究電腦ai的表現,所以主玩家收到的輸入控制暫時只有移動
2、怪物。怪物可以有的思考包括休閒待機、追蹤玩家、攻擊等。
對於不同的怪物,同一種思考型別的行為方式可以不一樣。這裡也簡單介紹一下:
1.普通小怪。在它覺得應該待機的時候,它可以在指定的巡邏點做環形來回的巡邏行動。當發現玩家進入索敵範圍時,採取直接跟隨的追逐方式接近玩家,然後在進入攻擊範圍後採取近身攻擊方式。在玩家超出索敵範圍時或者離巡邏點超過距離後,會脫離戰鬥並返回巡邏點繼續巡邏。
2.快速追擊怪物。在待機的過程中,在兩個指定的巡邏點之間做來回往返巡邏。,當發現玩家時,進行攔截式的追逐方式接近玩家。其他行為和普通小怪類似。
3.遠端攻擊怪物。在待機過程中,站在指定的待機點不動。當發現玩家後,以跟隨的追逐方式接近玩家,並在遠端距離停下來開始進行遠端攻擊。當玩家靠近怪物時,怪物會逃走到一定距離後再向玩家進行遠端攻擊。
由於避免demo過於複雜,所以沒有做hp和mp的計算。如果加上了這些計算,角色的ai行為會變得更加複雜,比如會根據當前hp的量來思考是否需要逃跑,根據mp的多少和技能的冷卻時間進行技能的選擇等。但實際上條件再多實現的原理也是一樣,反而增加了說明的難度。
二、製作思路
接下來說一下製作的思路。
首先需要說明的一點是,在做任何邏輯之前,我強烈建議必須做到邏輯和美術資源分離。比如說,一開始的時候,我們並不需要真的有一堆角色模型,一個豐富美觀的場景,也不需要有任何的動畫特效表現。
這樣做的原因很多:
首先,我們的ai邏輯並不一定限於在客戶端實現的,而實際上更多的網路遊戲的怪物ai都是在服務端做的,所以我們不能被任何美術資源形式所限制,我們的邏輯和演算法必須能單獨的執行。
然後,如果是客戶端做幀同步戰鬥,必須每個客戶端在同一輸入條件的情況下得出完全一樣的結果,不然就會產生誤差。而如果計算是依賴美術資源的,那樣產生誤差的可能性會比純邏輯計算大很多。
最後,過多的受到美術資源的限制,不僅會讓開發程式的週期變長,還會做思路上受到很多制約。
所以,在開發這種ai行為的程式時,完全可以用純資料的形式去模擬,最簡單的就是在資料層模擬各種角色的座標、朝向、當前動作和動作時間等。當然純資料模擬不利於直觀的觀察計算結果,所以一般可以簡單等模擬表現。比如用C++或者AS開發時,可以每一幀都清空渲染,然後重新把需要的角色用點來表示繪製在相應的座標上。由於我是用Unity來做這個demo的,所以為用了一個簡單的帶方向的box來代表角色。
接下來,我把整個邏輯分成三層:
1、角色實現層
2、行為層
3、思考層
三、具體實現
1、角色實現層
首先來說角色實現層。對於一個角色來說,不管它是由玩家控制的,還是由電腦控制的,實際上它能做的事情都是一樣的。比如我們玩格鬥遊戲,角色能進行的活動無非是包括了方向的移動,出拳、出腳、出絕招等。可以通過按鍵來實現的動作行為,我覺得就應該在角色實現層裡面具體實現。
針對上面的demo,我們可以把一些基礎熟悉建立基類。比如我建立了一個Role類作為角色的基類。裡面實現了一些基礎的方法,比如Stand、Move、Attack、PlayAnim等。虛擬碼如下
public class Role {
public int teamId = 1;
public float roteSpeed = 5;
public float speed = 10;
public virtual void Stand()
{
}
public virtual void StopMove()
{
}
public virtual void Move()
{
}
public virtual void Turn(float ang)
{
}
public virtual void PlayAnim(string n)
{
}
}
需要說明的是,角色移動的具體實現一般有2種方式,第一種是直接通過向量來把角色從起點移動到終點,然後把角色的旋轉角度轉向終點。另外一種是給角色施加往前或者往後的力,角色想要往不同的方向移動,必須通過旋轉角度來達到。
第一種方式一般適合於對角度不敏感的遊戲,不需要有轉向的過程,角色朝向純粹是客戶端表現。第二種方式相對比較複雜,但可以模擬出角色真實的移動過程,包括弧線前進、被擠開等。選擇哪種方式還是根據自己需要。我這裡採取了第二種方式,通過旋轉和施加力的模擬方式讓玩家移動。
2、行為層
然後說一下行為層。一些角色的行為比較複雜,比如我們玩kof,角色不同角色有很多固定的連招,比如舊版的草薙京可以蹲下輕拳->輕腳->七十五式改->大蛇薙組成一個連招。在我的角度看,這些連招是把角色基礎的動作行為進行組裝並按順序或者條件進行執行,所以連招是一種行為。
上面的demo我們舉了一些行為,比如追逐玩家,我們可以通過跟隨的方式靠近,或者通過攔截的方式靠近目標。這兩種靠近方式,都是使用了基礎角色的Move方法去實際實行的,但實現過程中會有演算法上的區別,所以我認為他們是不同的行為。
針對demo,我可以建立一個BaseAction的類作為所有行為的基類,裡面只有一個方法,叫做Tick(),這個方法用於在每一幀的時候修正當前的行為該有的執行動作。然後建立一個ActionRole的類繼承Role類,在ActionRole增加一個型別為BaseAction的叫做curAction的變數。虛擬碼如下:
public class BaseAction{
public virtual void Tick()
{
}
}
public class ActionRole:Role {
protected BaseAction curAction;
void OnUpdate()
{
if(curAction!=null)
{
curAction.Tick();
}
}
}
在實際需要進行某種行為時,可以寫各種的action類繼承BaseAction,然後在Tick裡面寫具體的實現方法。
3、思考層
最後說一下思考層。還是以格鬥遊戲來舉例子。在玩kof的時候,我們知道了怎樣操作角色,怎樣出招數,怎樣出連招了。但在什麼時候該閃避,什麼時候該出哪個招數,什麼時候可以出連招,這需要有一個思考的過程。
影響你思考的因素有各方面的,比如和敵人的距離,敵人的血量,自己的血量,自己是否可以出絕招,敵人當前的行為,等等。有經驗和技術的玩家,可以根據這些條件,準確的判斷出自己應該採取哪種行為來應對敵人,從而獲勝。
當把這個判斷的思考過程交給電腦,其實電腦需要做的事情也是一樣的,根據條件,判斷當前需要作出什麼行為。
這裡會遇到一個問題,判斷的依據是很主觀的,比如多遠的距離可以開始攻擊,低於多少血量應該開始逃跑,多遠的距離可以開始追逐敵人,等等。電腦依據這些判斷的引數做出各種的行為,而體現出電腦是聰明還是笨,很大程度上就在於這些引數的取捨。
先暫時忽略這個問題,假設我們已經得到了一些比較合理的引數了,我們繼續接下來的實現過程。
首先,我們先建立一個ai角色類叫做AIRole繼承ActionRole,在執行Action之前,先呼叫一個Think方法。
虛擬碼如下:
public class AIRole:ActionRole{
void Update()
{
DoSomeThing();
}
protected virtual void DoSomeThing()
{
Think();
DoAction();
}
protected virtual void Think()
{
//實現思考的過程
}
protected virtual void DoAction()
{
if(curAction!=null)
{
curAction.Tick();
}
}
然後,我們需要定義一些思考的結果型別,或者說是當前我們處於執行什麼行為的一個狀態。
public class AIType
{
//沒有任何ai
public const int NULL = -1;
//站立不動
public const int STAND = 0;
//巡邏
public const int PATROLACTION = 1;
//追捕
public const int CATCH = 2;
//逃走
public const int ESCAPE = 3;
//攻擊
public const int ATTACK = 4;
}
然後我們角色身上,或者某個資料層可以獲取該角色相關的一堆屬性和狀態。針對我們的demo,具體就有這麼幾個
自身座標
是否有目標
目標的座標
索敵範圍
攻擊範圍
脫戰範圍
是否有返回點
返回點的座標
等等
然後我們可以用多種方法來實現思考的過程
1、簡單的有限狀態機
2、標準狀態機
3、行為樹
4、神經網路
下面逐個來看實現的思路,選擇一種合適自己的方法來實現思考:
1、簡單的有限狀態機
這是最簡單的一種實現方式,實際上就是通過if else來組合各種的條件。虛擬碼如下:
假設有一個變數記錄當前的思考狀態叫做curAIType,那麼Think程式碼就會變成這樣:
protected virtual void Think()
{
if(curAIType == AIType.Stand)
{
//通過各種條件判斷是否需要轉換其他ai
}
else if(curAIType == AIType.PATROLACTION )
{
//通過各種條件判斷是否需要轉換其他ai
}
……
}
如果當前在某個AI狀態中,又符合某些條件,那麼就會在think方法裡面轉換另外一種AI,把當前的curAction改成另外一種Action,然後執行。
簡單有限狀態機的缺點在於需要手寫很多程式碼,修改條件和引數也很不方便,優點是實現簡單,而且執行效率高。如果本身需要實現的AI很簡單,不妨可以試試。
2、標準狀態機
接下來說的是一個比較標準的狀態機。先來說實現。
我們先建立一個狀態節點類叫做StatusNode,一個檢查條件的類叫做CheckConditionData,需要一個條件資料類叫做ConditionData
虛擬碼如下:
public class ConditionData{
//自己本身的條件
private string key;//需要取哪個引數作為條件
private string operaType;//可以自己定義,比如大於、小於、等於、不等於之類
private string val;//參與判斷的值
private string paramType;//需要判斷的引數的型別,比如int、string之類
public bool Excute()
{
if(CheckSelfCondition()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfCondition()
{
if(string.IsNullOrEmpty(key)==true)
{
return true;
}
//這裡寫具體條件判斷的實現
}
}
然後寫檢查條件的類的虛擬碼:
public class CheckConditionData{
private List<ConditionData> conditions;
private int relationType = 0;//0代表and,1代表or。這裡指的是條件之間是什麼關係
private AIType nextAIType;
public AIType Excute()
{
if(CheckConditions()==true)
{
return nextAIType;
}
else
{
return AIType.NULL;
}
}
private bool CheckConditions()
{
if(conditions==null||conditions.Count == 0)
{
return true;
}
if(relationType == 0)
{
for(int i = 0;i<conditions.Count;i++)
{
if(conditions[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<conditions.Count;i++)
{
if(conditions[i].Excute()==true)
{
return true;
}
}
}
}
}
最後寫狀態節點的虛擬碼:
public class StatusNode{
private List<CheckConditionData> subNodes;
public AIType Excute()
{
AIType tempAI = null;
for(int i = 0;i<subNodes.Count;i++)
{
tempAI = subNodes[i].Excute();
if(tempAI!=AIType.NULL)
{
return tempAI;
}
}
return AIType.NULL;
}
}
最後,實現AIRole的Think方法:
protected virtual void Think()
{
if(curStatusNode!=null)
{
AIType tempAI = curStatusNode.Excute();
if(tempAI!=AIType.NULL)
{
//進行切換AI的操作
}
}
}
看起來程式碼很多,好像很複雜。但可以發現,這些實現的程式碼其實不涉及到具體條件的改變。而具體的條件改變的設定,可以寫一個編輯器工具,用於編輯狀態節點。
對比簡單的有限狀態機,這種標準的狀態機的優缺點很明顯了。缺點是實現複雜,需要寫各種物件實現,還需要寫編輯器工具。優點是把邏輯和條件編輯分離,可以在不改變程式碼的情況下,把編輯條件和狀態型別的工作交給其他人做,比如策劃人員。
3、行為樹
所謂的行為樹,其實就是由各種節點連線起來的一個樹形結構。從根節點出發,經過了多層的枝節點,最終選擇到了合適的葉節點。
行為樹和狀態機,都是通過條件作為判斷轉換AI的依據。區別在於,狀態機的節點是包含著條件,每組條件只對應一個可執行的結果,從層次結構來看,其實它是隻有一層的。而且它是線性的,從一個狀態,過渡到另外一個狀態。但行為樹實際上每次都是從根節點出發,去判斷每個分支的條件,然後分支還可以繼續的套分支,形成的結構是多層的。從根節點出發去判斷每一層的條件節點,直到有一個分支的條件全部達到,到達了行為節點(葉節點),那麼這條分支就可以返回true,得到了想要的結果並執行。如果在分支的條件節點裡面有一層達不到,那就不需要繼續往子節點發展,直接返回false,程式繼續檢索其他分支節點的條件,直到達到一個葉節點位置。
行為樹比有限狀態機有趣的地方是他並不固定狀態的線性轉換,可以根據不同的條件配出很複雜的行為,在條件節點裡面,還可以加入隨機數或者學習係數在裡面,做出更多變化的行為。
同樣的,行為樹在寫完程式碼之後也需要寫編輯器工具,讓策劃人員通過生成節點、拖動節點和連線,生成不同型別角色的不同的行為樹配置檔案。
虛擬碼如下:
首先我們需要知道節點可以分為幾類:根節點、條件節點、行為節點,我們可以先寫一個節點的基類,由於每個節點都需要有一個返回判斷這個節點是否走得通,所以需要一個Excute方法,由於節點之間是可以連線的,所以一個節點最基本的功能是可以繼續發展下一級的節點,所以我們需要儲存一個子節點的列表:
public class BaseNode{
protected List<BaseNode> childrenNodes;//子節點佇列
protected int childrenRelationType = 0;//0代表and,1代表or。這裡指的是子節點之間是什麼關係
public virtual bool Excute()
{
return CheckSelfConditions();
}
protected virtual bool CheckSelfConditions()
{
if(childrenNodes==null||childrenNodes.Count==0)
{
return false;
}
else
{
if(childrenRelationType == 0)
{
for(int i = 0;i<childrenNodes.Count;i++)
{
if(childrenNodes[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<childrenNodes.Count;i++)
{
if(childrenNodes[i].Excute()==true)
{
return true;
}
}
return false;
}
}
}
}
接下來寫條件節點。我們可以用和狀態機一樣的條件資料類
public class ConditionData{
//自己本身的條件
private string key;//需要取哪個引數作為條件
private string operaType;//可以自己定義,比如大於、小於、等於、不等於之類
private string val;//參與判斷的值
private string paramType;//需要判斷的引數的型別,比如int、string之類
public bool Excute()
{
if(CheckSelfCondition()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfCondition()
{
if(string.IsNullOrEmpty(key)==true)
{
return true;
}
//這裡寫具體條件判斷的實現
}
}
然後寫條件節點
public class ConditionNode:BaseNode{
//自身條件
private List<ConditionData> selfConditions;
private int relationType = 0;//0代表and,1代表or。這裡指的是子條件之間
public override bool Excute()
{
if(CheckSelfConditions()==true&&CheckChildrenNodes()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfConditions()
{
if(selfConditions==null||selfConditions.Count==0)
{
return true;
}
else
{
if(relationType == 0)
{
for(int i = 0;i<selfConditions.Count;i++)
{
if(selfConditions[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<selfConditions.Count;i++)
{
if(selfConditions[i].Excute()==true)
{
return true;
}
}
return false;
}
}
}
}
最後可以寫行為節點
public class ActionNode:BaseNode
{
public override bool Excute()
{
//進行切換action的操作
//最後必然返回true來代表一個分支已經順利達成
return true;
}
}
所有節點都已經準備完畢了,我們就可以實現AIRole裡面的Think方法:
假設有變數aiNode儲存了當前角色行為樹的根節點,注意總的根節點物件裡面的子節點關係必然是or的,這代表了只有一個子節點達成了,就可以停止執行。所以最後程式碼變得非常簡單:
protected virtual void Think()
{
if(aiNode!=null)
{
aiNode.Excute();
}
}
4、神經網路和遺傳演算法
人工神經網路是由人工神經細胞構成。每一個神經細胞,都有若干個輸入,每個輸入分配一個權重,然後只有一個輸出。整個神經網路是由多層的神經細胞組成。
實際上所謂的若干個輸入,也就是影響角色的各種行為,像剛才說的自身的血量啊,敵人的距離啊之類。但實際上神經網路比行為樹更徹底的地方是它根本沒有固定的判斷條件,每個輸入的權重都是可以隨意的。到了最後,每個細胞只有2種狀態:興奮(啟用)和不興奮(不啟用)的。而至於各種輸入乘以權重加起來的值達到多少算興奮,標準也是各種各樣的。所以如果權重設定得對,神經網路會表現得非常智慧,而權重不對,神經網路有可能做出很傻的判斷。
為了讓神經網路做出正確的選擇,我們可以對他進行訓練。比如用遺傳演算法,先生成非常多的DNA(各種引數)樣品,然後賦予給不同的角色讓他們行動,以一定時間為一個時代,當一個時代結束後,根據標準評分來給予每一個DNA權重獎勵,給它加分。然後用賭輪選擇法或者種子選擇法之類的選擇方式挑選出權重分數高的DNA組成父母進行雜交生成下一代。一直到總體趨勢達到合理標準為止。
評分的標準可以是有多項的,比如血量剩餘的多少,比如是否有攻擊到玩家,比如是否擊殺了玩家,這些可以加分。有些是需要扣分的,比如完全沒做出行為在發呆的,比如死亡的之類。那麼經過很多世代之後,能留下了的DNA都是優良的,可以比較合理的使用的。
由於我對人工神經網路的研究不深,也沒有實際應用到專案之中,所以也不提供虛擬碼了,以免誤導別人。
四、一些個人看法
最後說說我個人對選擇AI技術的一些小看法。
上面這麼多種實現的方式,我感覺是各有好處,不一定越高階的方法就約好。簡單的有限狀態機是非常簡陋,但它實現簡單,出錯的可能性小,程式執行時的效率也很高。標準狀態機和行為樹的寫法和配置方式比較類似,從程式碼執行效率上來說,還是狀態機比較高。如果不是需要特別複雜的行為判斷,我個人覺得狀態機已經可以解決大部分問題了。至於人工神經網路,現在運用到遊戲領域的例子反而不多見。因為遊戲角色的AI不見得是越聰明約好,還需要特意的蠢一下,讓玩家覺得玩得高興,才符合遊戲設計的目的。如果不是想比較真實的模擬某種群體行為,想讓AI看起來非常真實,讓AI具有學習功能,我覺得沒必要用到這麼高階的技術。而且演算法越複雜的方法,執行效率肯定是越低的。
順帶提一下自動尋路技術的選擇
自動尋路也被認為是人工智慧的一種。我熟悉的尋路演算法有2種,一種是A星尋路,另外一種是導航網格尋路(NavMesh)。兩種尋路演算法究竟哪種比較好呢?
從通用性和效率來說,NavMesh按道理是會比A星要好的。
舉個例子,假如我有一個地圖,面積是200米*200米的。如果用A星尋路演算法,假設我們按1米作為一個格子,總共就會有4萬個格子,在A星尋路的過程中,如果遇到了不可走的情況,有可能需要走遍幾萬個格子,才能得出結論不能走。就算能走,如果從地圖的一端走到另外一段,勢必也要走幾千上萬個格子才能找出通路。而且1米一個格子,對於3d遊戲來說,是非常不精確的,假如想提高精度,只能把格子縮小,比如縮小到0.5米一個,那麼整個地圖就變成有16萬個格子,這個增長的幅度是很可怕的。如果還需要考慮到三維空間的層疊尋路,A星的每個格子連結的下個格子數量又會多一些,演算法就更復雜。
如果換成NavMesh,不管地圖有多大,是不是3D層疊的,基本上都可以用幾百個以內的凸多邊形來表示。如果障礙物少的地圖,甚至只有個位數的凸多邊形就解決問題了。那麼在判斷是否有通路的過程,速度理論上只有傳統A星演算法的幾千分之一,甚至幾十萬分之一。NavMesh的第二部是需要用拐點演算法,算向量點積來判斷在凸多邊形之間是否需要拐彎。這個演算法稍微有點複雜,不過由於不需要執行很多次,所以總體來說也不會特別有效率問題。
說了這麼多,好像NavMesh很完美,但實際上NavMesh也不是什麼時候都適用的。這是因為生成A星資料很簡單,只要生成一個二維陣列就行了,想臨時把某些點變成不可走,也非常容易。但NavMesh的資料是頂點資料和三角形索引之類構成多邊形網格的資料,如果中間突然多了一個障礙物,有可能整份多邊形尋路資料都要重新生成一遍。而生成多邊形尋路資料的演算法比較複雜,效率不會很高。這樣就導致瞭如果經常有動態阻擋的情況下,NavMesh就不太合適了。或者可以使用NavMesh結合其他技術來實現動態阻擋,比如在正常尋路的過程,加入觸角阻擋判斷的方式來繞開阻擋物體。
所以選擇什麼樣的技術手段,還是需要根據自己的實際情況來選擇。
本來打算簡單的討論一下角色AI的問題,結果後來也寫了不少內容。看來這方面主題的內容還是不容易簡短的表達,以後有機會再詳細的討論。