1. 程式人生 > 其它 >在遊戲上使用面向目標行為規劃系統

在遊戲上使用面向目標行為規劃系統

本文為本人的翻譯文章,原文《Applying Goal-Oriented Planning for Games 》連線為:

http://alumni.media.mit.edu/~jorkin/GOAP_draft_AIWisdom2_2003.pdf

Jeff Orkin – Monolith Productions

http://www.jorkin.com


有相當數量的遊戲已經實現了帶有目標導向決策能力的角色。一個目標導向的角色能顯示出一些智慧的權衡,他們通過自主決定啟用一些行為,這些行為會完成那些在任何情況下最有意義的目標。面向物件行為規劃(GOAP:Goal-Oriented Action Planning)

是一個更進一步的決策架構,它允許角色不只是決定要做什麼,還有如何做。但是我們為什麼想要讓我們的角色擁有這麼大的自由度?

一個能規劃它自己的計劃來完成自己目標的角色,將會表現出很少重複性、可預期的行為,並且可以更好的針對他的當前狀況進行行動。另外,GOAP架構的結構本質能促進編寫、維護和重用行為。

No One Live Forever 2: A Spy in H.A.R.M.’s Way(NOLF2)》[Monolith02] 是一個包含了目標導向自主角色的遊戲例子,但是它沒有規劃能力。在NOLF2中的角色時常重估他們的目標,然後選擇最有意義的目標去控制他們的行為。那個啟用的目標通過一個寫死了的狀態轉換序列,決定了角色的行為。

此文探索遊戲可以從一個實時規劃系統中能得到什麼好處,用NOLF2開發過程中碰到的問題,來說明這些論點。

術語定義

我們在討論GOAP的好處之前,我們首先需要定義一些屬於。一個代理使用一個規劃者planner)去規劃formulate)一個動作actions)序列,這個序列將完成一些目標goal)。我們需要定義目標、動作、計劃、和規劃的含義。

目標Goal

一個目標是一個代理想要完成的任何情況。一個代理將有任何數量的目標。在NOLF2裡面的角色基本擁有大概25個目標。任何時刻,都有一個啟用的目標,控制著角色的行為。一個目標知道如何計算它當前的相關性,以及知道什麼時候它被完成。

在NOLF2中的目標分為三類:輕鬆型目標,調查型目標,侵略型目標。輕鬆型目標包括諸如睡覺

工作巡邏這類被動目標。調查型目標包括更帶懷疑性質的調查搜尋。侵略型目標用於戰鬥場景,好像追趕衝鋒,和從隱蔽處攻擊

雖然概念上相似,但在NOLF2中使用的目標,和GOAP指的目標兩者之間,還是有一個關鍵的不同之處。一旦一個目標被啟用,那個角色就從頭到尾的執行一遍一個預定義的步驟序列,這個序列是被硬編碼在目標裡面的。這個內嵌的行為計劃可以包含條件分支,但是這些分支都是在目標編寫的時候被預先規定好的。在GOAP裡的目標不會包含一個行動計劃。相反,他們僅僅簡單的定義,滿足這個目標需要具備什麼條件。用於到達這些滿足條件的步驟,是實時決定的。

計劃Plan

計劃只是簡單的一個動作序列的名字。滿足一個目標的計劃,指的是一個可行的動作序列,這個序列可以讓一個角色從開始狀態走到滿足目標的狀態。

動作Action

一個動作,指的是在令一個角色做某些事的計劃裡的,一個簡單的,原子的步驟。可能的動作包括移動到位置啟用物件抽出武器重新裝彈,和攻擊。一個動作的持續時間可能很短,也可能是無限長。重新裝彈動作在角色完成一個裝彈動畫後就會結束。攻擊動作可能無限存在,直到目標死亡。

每個動作都知道什麼時候可以跑,以及可以對這個遊戲世界做什麼事。換句話說,一個動作知道它的先決條件效果。先決條件和效果提供一個機制,把動作連結成一個可行的序列。例如,攻擊有一個先決條件,是那個角色的武器裝好彈了。重新裝彈的效果是武器被轉好子彈。這很容易看出來重新裝彈跟在攻擊之後,是一個有效的動作序列。每個動作都可以能有任意數量的先決條件和效果。

一個GOAP系統不會去代替一個有限狀態機(FSM)的需求,但會大大簡化所需要的FSM。每個動作都代表著一個狀態轉換,這些動作所組成的一個序列就是一個計劃。把狀態自己從狀態轉換邏輯中拆分出來,這個基礎的FSM就會非常簡單。例如,閃避重新裝彈是不同的動作,但他們都會把角色的狀態設定成活動,然後指定一個動畫去播放。不同於擁有一個巡邏或者遊蕩狀態,一個GOAP系統可以規劃一個計劃,命令那個角色到達移動狀態,從而在一批巡邏點之間移動。最後,那個移動活動狀態覆蓋了那個角色所做的大部分事情;他們僅僅是由於不同的原因去做這些事。動作定義了何時轉換進入和轉換退出一個狀態,以及這個遊戲世界發生的事情,作為這個轉換的結果。

計劃制訂Plan Formulation

一個角色,通過供應一些目標去滿足一個系統的方式,去實時生成一個計劃,這樣的角色被成為規劃者。規劃者在大量動作的範圍中搜索出一個動作序列,使一個角色從他的開始狀態去到他的目標狀態。這個過程被稱為規劃一個計劃。如果規劃者成功了,它會返回一個計劃給那個角色,讓其指揮自己的行為。這個角色執行這個計劃直到完成,失效,或者直到另外一個目標變得更加有意義。如果另外一個目標激活了,或者正在執行的計劃因為任何原因變得無效,那個角色會取消當前計劃然後規劃一個新的計劃。

圖示1. 計劃的規劃過程

圖示1描繪了一個虛擬的計劃過程圖解。長方形表示開始和目標狀態,每個圓形代表一個動作。在圖示1裡面的目標是殺到一個敵人。因此,目標狀態是敵人死掉了的世界的狀態。規劃者需要為那個角色找到一個動作序列,讓這個世界從有活著的敵人,變成有死掉的敵人的狀態。

這個過程看起來很像尋路!從某種意義上正式這樣。規劃者需要找到一條路徑,通過動作的空間,讓那個角色從他的開始狀態到某個目標狀態。每個動作都是那條路徑上的一步,這些步驟會通過某種方式改變所在世界的狀態。動作的先決條件決定了何時從一個動作移動到另外一個動作是可行的。

在很多情況下,存在超過一個可行計劃。規劃者只需要找到其中一個。類似導航尋路,規劃者的搜尋演算法可以提供指導搜尋的提示。例如,消耗可以和動作關聯起來,引導規劃者找到最小消耗的動作序列,而不是任意的武斷序列。

所有這些搜尋聽起來好像是一大堆工作。它值得嗎?現在我們已經定義了我們的術語,我們可以討論這些決策過程的好處了。

使用GOAP的好處

在開發和執行時都有很多好處。使用GOAP,遊戲裡的角色可以表現出更多變,更復雜,和更有趣的行為。在諸多行為背後的程式碼會更結構化,更能重用,和更可維護。

執行時行為的好處

一個在執行時決定他自己的計劃的角色,能自主的調整他的行為以適應環境,並且動態的找到問題的解決方案。這最好以一個例子來解釋。

想象一下,一個角色X發現了一個想要消滅的敵人。通常來說,最好的行動步驟是掏出裝好子彈的武器,然後向這個敵人開火。然而在這種情況下,X沒有武器,或者可能沒有子彈,所以他需要找到一個替換的方案。幸運的是,在那個X附近有一個固定的鐳射炮,可以用來炸到敵人。X可以規劃一個行動計劃,走去那個鐳射炮那裡,啟用它,然後用它來殺人。此計劃可能看起來像:

走到(鐳射炮)

啟用物體(鐳射炮)

固定攻擊(鐳射炮)

問題解決了!角色X要求規劃者提供到達殺死敵人的目標的方法,然後規劃者規劃了一個可行的計劃去滿足它。當規劃者想要包含掏出武器動作到他的動作序列裡的時候,碰到了一個死衚衕,因為掏出武器具有一個先決條件,要求角色有一件武器。相反它找到了一個不需要角色有一個武器的替代方案。

但等等!如果安裝的鐳射炮需要電力,而且發電機被關掉的情況呢?規劃者也可以很好的解決這個情況。這個情況的可行方案包含首先去發電機那裡開啟它,然後使用鐳射炮。此計劃可能像這樣:

走到(發電機)

啟用物體(發電機)

走到(鐳射炮)

啟用物體(鐳射炮)

固定攻擊(鐳射炮)

GOAP決策框架執行角色X處理那些在行為開發期沒有預期到的依賴性問題。

開發的好處

用手寫程式碼或者指令碼處理每一個可能的情況會跑得毛快。想象一下一個為了殺死敵人目標的,帶有一個處理前文所述情況的內嵌進計劃的程式碼。那個目標的內嵌計劃需要處理角色有、或者沒有武器,加上尋路,替換啟用毀滅手段。

人們很容易被誘惑,去把殺死敵人目標拆分成多個更小的目標,比如用武器殺死敵人用固定裝置殺死敵人。這就是本質上我們為NOLF2所做的事,但是目標的增生有它自己的問題。越多的目標意味著更多要維護的程式碼,並且每次設計變化都要再看一遍。

表面上看,是笨拙的額外設計導致NOLF2開發期間的頭疼問題。例如這些額外設計,包括掏槍和收槍,進入黑屋子時開啟燈,以及開啟門之前啟用安全鍵盤。每個這種額外設計都需要重新過一次每個目標的程式碼,確保內嵌計劃可以處理新的需求。

GOAP提供一個更優雅簡潔的框架,用以更好的適應變化。那些額外的設計需求以增加動作,安排相關動作的先決條件的方式來解決。這樣更直觀,並且比重新過一次每個目標接觸更少的程式碼。例如,需要角色進黑屋子前開燈,可以通過給移動到位置增加一個先決條件——目的地的燈必須開著,這種方式來解決。

更進一步的是,GOAP提供了可行計劃的保證。手寫內嵌計劃可能包含錯誤。一個開發者可能編寫一個不能符合每個其他動作的序列。例如,一個角色可能被命令用武器開火,而沒有被告訴要先掏出武器。這個情況在通過GOAP系統動態生成的計劃裡面是不可能出現的,因為動作裡的先決條件能防止規劃出一個無效的計劃。

多樣化的好處

強制使用GOAP的結果能完美的創造各種各樣的角色型別,他們能表現出不同的行為,並且能在多個專案裡面共享行為。規劃者被提供了一個了動作池,從這個池子裡面規劃出計劃來。這個池子不需要是擁有所有存在動作的完全池子。不同的角色型別可以使用完全池子的子集,從而產生各種各樣的行為。

NOLF2有一大批不同的角色型別,包括士兵,模仿士,忍者,超級士兵,和小兔子。我們嘗試在所有這些角色型別中儘量多的共享AI程式碼。這有時候導致了在行為程式碼中沒預料到的分支。一個難搞的分支,就是關於角色如何處理一個關上的門。一個人類停在門前,開啟門,然後走進門去,然而一個半機械超級士兵要把門從鉸鏈上撞碎,然後繼續走。處理穿過門的程式碼,需要一個分支來檢查這是否一個粉碎門的角色。

一個GOAP系統可以更優美的處理這種情況,通過提供每個角色型別一個不同的完成同樣效果的動作。一個人類角色可以使用開門動作,然而一個超級士兵使用撞門動作。這兩個動作都有相同的效果。他們兩個都打開了一條之前被門堵上的路。

有其他的方案來解決撞門和開門的問題,但是沒有一個和GOAP方案一樣靈活。例如,開門撞門狀態可以來源於一個處理門類。使用一個像這樣的狀態類層次,一個設計者可以分配裝到到很多插槽中去,比如處理門插槽,建立具備不同的處理門方法的角色型別。但如果我們想要一個角色在放鬆的時候是開門的,在激動的時候撞門的,應該怎麼辦?狀態類層次需要一個外部機制,去換出在特定情況下的處理門插槽的狀態。GOAP方案允許一個角色始終同時具有開門撞門兩個動作。在每個動作上為所需心情,設定的一個額外先決條件,允許角色實時的選擇合適的動作去處理門的事情,而無需任何額外的干預。

實現指引

現在你能看到各種好處,而且對在遊戲中使用GOAP的前景非常興奮,但是你可能需要清楚一些好的和壞的訊息。壞的訊息是涉及實現一個GOAP系統,會有一大批挑戰。第一個挑戰是決定搜尋動作空間的最佳方法。第二個挑戰是世界的表達方法。為了規劃出一個計劃,規劃者必須能夠以一種緊湊和簡明的形式表達那個世界的狀態。這兩個話題在學術界都是一個很大的研究領域,一個完整的論述已經超出了這個文章的範圍。好訊息是我們可以僅僅為遊戲領域釘住簡單的方案。剩下的篇幅描述了一些合理的方案,以對遊戲開發來說有意義的方式,來解決這些挑戰。

規劃者搜尋

之前我們觀察到,計劃制訂的過程明顯的很像尋路導航。這些過程非常類似,實際上,我們可以使用相同的演算法!規劃者的搜尋可以被一個大多數遊戲AI開發者私下裡已經很熟悉的演算法所驅動:叫做A*。儘管很多遊戲開發者認為A*是一個尋路演算法,但是它實際上是一個通用的搜尋演算法。如果A*被實現成模組風格的,類似[Higgins02a]所描述的那樣,這個演算法的大多數程式碼都可以在尋輪系統和規劃者之間共享。規劃者只需要實現它自己的類,用來對應A*的節點(node)、圖(map)、和目標(goal)。

A*演算法需要節點消耗的計算,以及從一個節點到目標的啟發式距離。在規劃者的搜尋中的節點,代表了世界的狀態,節點間的邊界是動作。節點的消耗,可以通過彙總所有動作的消耗計算獲得,這些動作使世界到達節點所代表的狀態。每個動作的消耗可能是多變的,越低消耗的動作是越好的。啟發性距離可以用未滿足的目標狀態的特性彙總起來計算得到。

在用A*搜尋時我們有兩個選擇。我們可以向前搜尋,從當前狀態開始然後搜尋一個通向目標狀態的路徑,或者我們可以向後搜尋,從目標到開始狀態。我們來先測試一下向前搜尋是如何工作的,我們的例子還是之前描述的情況,當一個沒有武裝的角色想消滅一個敵人,同時有一個固定的需要電力的鐳射炮。向前搜尋首先會用跑去位置動作告訴角色跑去鐳射炮那裡,然後用固定武器攻擊動作告訴角色使用那個鐳射炮。如果電源沒開,固定武器攻擊的先決條件會失敗。這會讓一個詳盡的強力搜尋得出一個可行的計劃,首先讓角色去打開發電機,然後使用鐳射。

一個退化的搜尋會更高效和直觀。向後搜尋會從目標觸發,然後發現固定武器攻擊動作會滿足這個目標。從那裡起,這個搜尋會繼續根據滿足固定武器攻擊動作的先決條件來查詢動作。先決條件會引導角色一步步的到達最終計劃,首先打開發電機,然後使用鐳射炮。

世界描述

為了搜尋動作空間,規劃者需要以某種方式描述世界的狀態,以便能容易的應用動作的先決條件和效果,並且辨識出合適到達了目標狀態。其中一個緊湊的描述世界狀態的方法是使用一個世界屬性結構列表,它包含一個列舉型別的屬性key,一個value,和一個處理某題目的handle。

struct SWorldProperty
{
GAME_OBJECT_ID hSubjectID;
WORLD_PROP_KEY eKey;
union value
{
bool bValue;
float fValue;
int nValue;
...
};
};

這樣,如果我們想描述那個能滿足殺死敵人目標的世界狀態,我們會用一個像下面這樣的屬性來提供目標狀態:

SWorldProperty Prop;

Prop.hSubjectID = hShooterID;

Prop.eKey = kTargetIsDead;

Prop.bValue = true;

以這種方式來描述世界的每個方面,將會是壓倒性和不可能的任務,但這是不必要的。我們只需要描述對規劃者想要去滿足的目標,所相關的世界狀態的最小屬性集合就可以了。如果規劃者嘗試去滿足殺死敵人這個目標,它就無需去知道射手的體力值,當前位置,或者其他任何事情。規劃者甚至不需要知道射手想去殺誰!它只需要找到一個能導致這個射手的目標死掉的動作序列就行了,無論這個目標是誰。

當規劃者新增動作,和動作的先決條件一起增加的目標轉檯,被新增到目標的滿足狀態中。圖示2描述了一個規劃者的滿足殺死敵人目標的退化搜尋。在當前狀態匹配目標狀態時,這個搜尋就成功的完成了。和動作一起增加的目標狀態增加了他們的先決條件。當前狀態會根據規劃者為了滿足額外的目標屬性所增加的額外動作所增加。

圖示2. 規劃者的退化搜尋

在退化搜尋的每一步,規劃者嘗試去找到一個動作,這個動作擁有一個效果能滿足那些未被滿足的目標條件。當目標狀態的屬性和當前狀態的屬性具有不同的值的時候,這個世界的屬性就會被認為是未被滿足的。通常,解決一個未滿足條件的動作會增加額外的被滿足的先決條件。當搜尋結束,我們可以看到一個有效的計劃,來滿足殺死敵人這個目標:

DrawWeapon
LoadWeapon
Attack

在圖示2裡的計劃例子,由具備表示先決條件和效果的Boolean常量值的動作組成,但必須指出的是,先決條件和效果也可以由變量表示。規劃者在對目標退化的時候解決這些變數。變數給了規劃者能力和靈活性,因為它現在可以滿足更一般性的先決條件。舉個例子,一個移動動作具備移動一個角色到一個可變距離的效果,會比一個只能移動到固定的,預先定義位置的移動動作更強。

動作可以用預先描述的世界狀態描述來表示他們的先決條件和效果。比如,攻擊動作的構造器定義了它的先決條件和效果如下:

CAIActionAttack::CAIActionAttack()
{
m_nNumPreconditions = 1;
m_Preconditions[0].eKey = kWeaponIsLoaded;
m_Preconditions[0].bValue = true;
m_nNumEffects = 1;
m_Effects[0].eKey = kTargetIsDead;
m_Effects[0].bValue = true;
}

如果動作即將參與規劃者的搜尋,它們只需要指定以這個象徵方式的先決條件。有可能會有一些能被叫做上下文(context)先決條件的額外條件。一個上下文先決條件類似於需要是true,但是規劃者從來不會滿足。例如,攻擊動作可能需要一個目標是在某個視野距離和範圍之內。這是一個比可以用一個列舉值描述的更復雜的檢查,並且規劃者沒有動作可以使這個值變成true,如果它已經不是true了。

當規劃者正在搜尋動作,它呼叫兩個不同的函式。一個函式檢查連結規劃者先決條件,而另外一個檢查自由形態的上下文先決條件。這個上下文先決條件確認函式可以包含任何武斷的返回一個布林值的程式碼片。因為一個動作的上下文先決條件,會在每次規劃者嘗試增加這個動作到一個計劃時,被從新計算,最小化需要這個校驗的過程是很重要的。可能的優化包括,緩從之前的校驗下存結果,並且查詢那些在規劃者之外定期計算的值。

規劃者優化

必須對優化規劃者的搜尋多一些考慮。隨著動作的數量,以及在動作上的先決條件的增長,規劃一個計劃的複雜性會增加。我們可以用一些在優化尋路導航上的策略來進攻這個問題。這些策略包括優化搜尋演算法[Higgins02b],快取之前搜尋的結果,以及把計劃的規劃任務分解到多個更新幀裡面。上下文先決條件也可以用來裁減搜尋,用來減少無用的路徑。

對GOAP的需求

隨著每個新遊戲釋出,業界總是設定更高的AI行為。由於對於角色行為複雜性的期望變大我們需要繼續看更多的結構化、正式化的方案來建立可伸縮,可維護,和可重用的決策系統。面向目標動作計劃是這些方案中的一個。通過放手允許遊戲在執行時去規劃計劃,我們要把關鍵的決定權交給那些最有利去做這個決定的人;那些角色他們自己。

感謝大家的閱讀,如覺得此文對你有那麼一丁點的作用,麻煩動動手指轉發或分享至朋友圈。如有不同意見,歡迎後臺留言探討。