demo視訊
很久之前我曾經介紹過不少遊戲角色尋路方面實作的方法,但作為完整角色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來代表角色。
demo視訊
接下來,我把整個邏輯分成三層:
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的問題,結果後來也寫了不少内容。看來這方面主題的内容還是不容易簡短的表達,以後有機會再詳細的讨論。