漫談遊戲中的人工智慧

發表於2016-07-02

 寫在前面

今天我們來談一下游戲中的人工智慧。當然,內容可能不僅僅限於遊戲人工智慧,還會擴充套件一些其他的話題。

遊戲中的人工智慧,其實還是算是遊戲開發中有點挑戰性的模組,說簡單點呢,是狀態機,說複雜點呢,是可以幫你開啟新世界大門的一把鑰匙。有時候看到知乎上一些可能還是前公司同事的同學的一些話,感覺還是挺哭笑不得的,比如這篇:http://zhi.hu/qu1h,吹捧機器學習這種玄學,對遊戲開發嗤之以鼻。我只能說,技術不到家、Vision 不夠,這些想通過換工作可培養不來。

這篇文章其實我挺早就想寫了,在我剛進工作室不久,看了內部的 AI workflow 有感而發,evernote 裡面這篇筆記的建立時間還是今年 1 月份,現在都 8 個月過去了,唉。
廢話不說了,還是聊聊遊戲中的人工智慧吧。

從一個簡單的情景開始

怪物,是遊戲中的一個基本概念。遊戲中的單位分類,不外乎玩家、NPC、怪物這幾種。其中,AI 一定是與三類實體都會產生交集的遊戲模組之一。
以我們熟悉的任意一款遊戲中的人形怪物為例,假設有一種怪物的 AI 需求是這樣的:

  •      大部分情況下,漫無目的巡邏。
  •      玩家進入視野,鎖定玩家為目標開始攻擊。
  •      Hp 低到一定程度,怪會想法設法逃跑,並說幾句話。

我們以這個為模型,進行這篇文章之後的所有討論。為了簡化問題,以省去一些不必要的討論,將文章的核心定位到人工智慧上,這裡需要注意幾點的是:

  •      不再考慮 entity 之間的訊息傳遞機制,例如判斷玩家進入視野,不再通過事件機制觸發,而是通過該人形怪的輪詢觸發。
  •      不再考慮 entity 的行為控制機制,簡化這個 entity 的控制模型。不論是底層是基於 SteeringBehaviour 或者是瞬移,不論是非同步驅的還是主迴圈輪詢,都不在本文模型的討論之列。

首先可以很容易抽象出來 IUnit:

然後,我們可以通過一個簡單的有限狀態機 (FSM) 來控制這個單位的行為。不同狀態下,單位都具有不同的行為準則,以形成智慧體。
具體來說,我們可以定義這樣幾種狀態:

  •      巡邏狀態: 會執行巡邏,同時檢查是否有敵對單位接近,接近的話進入戰鬥狀態。
  •      戰鬥狀態: 會執行戰鬥,同時檢查自己的血量是否達到逃跑線以下,達成檢查了就會逃跑。
  •      逃跑狀態: 會逃跑,同時說一次話。

最原始的狀態機的程式碼:

以逃跑狀態為例:

決策邏輯與上下文分離

上述是一個最簡單、最常規的狀態機實現。估計只有學生會這樣寫,業界肯定是沒人這樣寫 AI 的,不然遊戲怎麼死的都不知道。

首先有一個非常明顯的效能問題:狀態機本質是描述狀態遷移的,並不需要記錄 entity 的 context,如果 entity 的 context 記錄在 State上,那麼狀態機這個遷移邏輯就需要每個 entity 都來一份 instance,這麼一個簡單的狀態遷移就需要消耗大約 X 個位元組,那麼一個場景 1w 個怪,這些都屬於白白消耗的記憶體。就目前的實現來看,具體的一個 State 例項內部 hold 住了 Unit,所以 State 例項是沒辦法複用的。
針對這一點,我們做一下優化。對這個狀態機,把 Context 完全剝離出來。

修改狀態機介面定義:

還是拿之前實現好的逃跑狀態作為例子:

這樣,就區分了動態與靜態。靜態的是狀態之間的遷移邏輯,只要不做熱更新,是不會變的結構。動態的是狀態遷移過程中的上下文,根據不同的上下文來決定。

分層有限狀態機

最原始的狀態機方案除了效能存在問題,還有一個比較嚴重的問題。那就是這種狀態機框架無法描述層級結構的狀態。
假設需要對一開始的需求進行這樣的擴充套件:怪在巡邏狀態下有可能進入怠工狀態,同時要求,怠工狀態下也會進行進入戰鬥的檢查。
這樣的話,雖然在之前的框架下,單獨做一個新的怠工狀態也可以,但是仔細分析一下,我們會發現,其實本質上巡邏狀態只是一個抽象的父狀態,其存在的意義就是進行戰鬥檢查;而具體的是在按路線巡邏還是怠工,其實都是巡邏狀態的一個子狀態。
狀態之間就有了層級的概念,各自獨立的狀態機系統就無法滿足需求,需要一種分層次的狀態機,原先的狀態機介面設計就需要徹底改掉了。
在重構狀態框架之前,需要注意兩點:

  •   因為父狀態需要關注子狀態的執行結果,所以狀態的 Drive 介面需要一個執行結果的返回值。

子狀態,比如怠工,一定是有跨幀的需求在的,所以這個 Result,我們定義為 Continue、Sucess、Failure。

  •   子狀態一定是由父狀態驅動的。

考慮這樣一個組合狀態情景:巡邏時,需要依次得先走到一個點,然後怠工一會兒,再走到下一個點,然後再怠工一會兒,迴圈往復。這樣就需要父狀態(巡邏狀態)註記當前啟用的子狀態,並且根據子狀態執行結果的不同來修改啟用的子狀態集合。這樣不僅是 Unit 自身有上下文,連組合狀態也有了自己的上下文。

為了簡化討論,我們還是從 non-ContextFree 層次狀態機系統設計開始。

修改後的狀態定義:

組合狀態的定義:

巡邏狀態現在是一個組合狀態:

看過《遊戲人工智慧程式設計精粹》的同學可能看到這裡就會發現,這種層次狀態機其實就是這本書裡講的目標驅動的狀態機。組合狀態就是組合目標,子狀態就是子目標。父目標 / 狀態的排程取決於子目標 / 狀態的完成情況。這種狀態框架與普通的 trivial 狀態機模型的區別僅僅是增加了對層次狀態的支援,狀態的遷移還是需要靠顯式的 ChangeState 來做。
這本書裡面的狀態框架,每個狀態的執行 status 記錄在了例項內部,不方便後續的優化,我們這裡實現的時候首先把這個做成純驅動式的。但是還不夠。現在之前的 ContextFree 優化成果已經回退掉了,我們還需要補充回來。

分層的上下文

我們對之前重構出來的層次狀態機框架再進行一次 Context 分離優化。
要優化的點有這樣幾個:

  •   首先是繼續之前的,unit 不應該作為一個 state 自己的內部 status。
  •   組合狀態的例項內部不應該包括自身執行的 status。目前的組合狀態,可以動態增刪子狀態,也就是根據 status 決定了結構的狀態,理應分離靜態與動態。巡邏狀態組合了兩個子狀態——A 和 B,邏輯中是一個完成了就新增另一個,這樣一想的話,其實巡邏狀態應該重新描述——先進行 A,再進行 B,迴圈往復。
  •   由於有了父狀態的概念,其實狀態介面的設計也可以再迭代,理論上只需要一個 drive 即可。因為狀態內部的上下文要全部分離出來,所以也沒必要對外提供 OnEnter、OnExit,提供這兩個介面的意義只是做一層內部資訊的隱藏,但是現在內部的 status 沒了,也就沒必要隱藏了。

具體分析一下需要拆出的 status:

  •   一部分是 entity 本身的 status,這裡可以簡單的認為是 unit。
  •   另一部分是 state 本身的 status。
    • 對於組合狀態,這個 status 描述的是我當前執行到哪個 substate。
    • 對於原子狀態,這個 status 描述的種類可能有所區別。
      • 例如 MoveTo/Flee,OnEnter 的時候,修改了 unit 的 status,然後 Drive 的時候去 check。
      • 例如 Idle,OnEnter 時改了自己的 status,然後 Drive 的時候去 check。

經過總結,我們可以發現,每個狀態的 status 本質上都可以通過一個變數來描述。一個 State 作為一個最小粒度的單元,具有這樣的 Concept: 輸入一個 Context,輸出一個 Result。
Context 暫時只需要包括這個 Unit,和之前所說的 status。同時,考慮這樣一個問題:

  •   父狀態 A,子狀態 B。
  •   子狀態 B 向上返回 Continue 的同時,status 記錄下來為 b。
  •   父狀態 ADrive 子狀態的結果為 Continue,自身也需要向上丟擲 Continue,同時自己也有 status 為 a。

這樣,再還原現場時,就需要即給 A 一個 a,還需要讓 A 有能力從 Context 中拿到需要給 B 的 b。因此上下文的結構理應是遞迴定義的,是一個層級結構。

Context 如下定義:

修改 State 的介面定義為:

已經相當簡潔了。

這樣,我們對之前的巡邏狀態也做下修改,達到一個 ContextFree 的效果。利用 Context 中的 Continuation 來確定當前結點應該從什麼狀態繼續:

subStates 是 readonly 的,在組合狀態構造的一開始就確定了值。這樣結構本身就是靜態的,而上下文是動態的。不同的 entity instance 共用同一個樹的 instance。

語義結點的抽象

優化到這個版本,至少在效能上已經符合要求了,所有例項共享一個靜態的狀態遷移邏輯。面對之前提出的需求,也能夠解決。至少算是一個經過對《遊戲人工智慧程式設計精粹》中提出的目標驅動狀態機模型優化後的一個符合工業應用標準的 AI 框架。拿來做小遊戲或者是一些 AI 很簡單的遊戲已經綽綽有餘了。
不過我們在這篇部落格的討論中是不能僅停留在能解決需求的層面上。目前的方案至少還存在一個比較嚴重的問題,那就是邏輯複用性太差。組合狀態需要 coding 的邏輯太多了,具體的狀態內部邏輯需要人肉維護,更可怕的是需要程式設計師來人肉維護,再多幾個組合狀態簡直不敢想象。程式設計師真的沒這麼多時間維護這些東西好麼。所以我們應該嘗試抽象一下組合狀態是否有一些通用的設計 pattern。
為了解決這個問題,我們再對這幾個狀態的分析一下,可以對結點型別進行一下歸納。
結點基本上是分為兩個型別:組合結點、原子結點。
如果把這個狀態遷移邏輯體看做一個樹結構,那其中組合結點就是非葉子結點,原子結點就是葉子結點。
對於組合結點來說,其行為是可以歸納的。

  •   巡邏結點,不考慮觸發進入戰鬥的邏輯,可以歸納為一種具有這樣的行為的組合結點:依次執行每個子結點(移動到某個點、休息一會兒),某個子結點返回 Success 則執行下一個,返回 Failure 則直接向上返回,返回 Continue 就把 Continuation 丟擲去。命名具有這樣語義的結點為 Sequence。
  •       設想攻擊狀態下,單位需要同時進行兩種子結點的嘗試,一個是釋放技能,一個是說話。兩個需要同時執行,並且結果獨立。有一個返回 Success 則向上返回 Success,全部 Failure 則返回 Failure,否則返回 Continue。命名具有如此語義的結點為 Parallel。
  •   在 Parallel 的語義基礎上,如果要體現一個優先順序 / 順序性質,那麼就需要一個具有依次執行子結點語義的組合結點,命名為 Select。

Sequence 與 Select 組合起來,就能完整的描述一” 趟 “巡邏,Select(ReactAttack, Sequence(MoveTo, Idle)),可以直接幹掉之前寫的 Patrol 組合狀態,組合狀態直接拿現成的實現好的語義結點複用即可。
組合結點的抽象問題解決了,現在我們來看葉子結點。
葉子結點也可以歸納一下 pattern,能歸納出三種:

  • Flee、Idle、MoveTo 三個狀態,狀態進入的時候調一下宿主的某個函式,申請開始一個持續性的動作。
  • 四個原子狀態都有的一個 pattern,就是在 Drive 中輪詢,直到某個條件達成了才返回。
  • Attack 狀態內部,每次都輪詢都會向宿主請求一個資料,然後再判斷這個 “外部” 資料是否滿足一定條件。

pattern 確實是有這麼三種,但是葉子結點自身其實是兩種,一種是控制單位做某種行為,一種是向單位查詢一些資訊,其實本質上是沒區別的,只是描述問題的方式不一樣。
既然我們的最終目標是消除掉四個具體狀態的定義,轉而通過一些通用的語義結點來描述,那我們就首先需要想辦法提出一種方案來描述上述的三個 pattern。
前兩個 pattern 其實是同一個問題,區別就在於那些邏輯應該放在宿主提供的介面裡面做實現,哪些邏輯應該在 AI 模組裡做實現。呼叫宿主的某個函式,呼叫是一個瞬間的操作,直接改變了宿主的 status,但是截止點的判斷就有不同的實現方式了。

  •   一種實現是宿主的 API 本身就是一個返回 Result 的函式,第一次呼叫的時候,宿主會改變自己的狀態,比如設定單位開始移動,之後每幀都會驅動這個單位移動,而 AI 模組再去呼叫 MoveTo 就會拿到一個 Continue,直到宿主這邊內部驅動單位移動到目的地,即向上返回 Success;發生無法讓單位移動完成的情況,就返回 Failure。
  •   另一種實現是宿主提供一些基本的查詢 API,比如移動到某一點、是否到達某個點、獲得下一個巡邏點,這樣的話就相當於是把輪詢判斷寫在了 AI 模組裡。這樣就需要有一個 Check 結點,來包裹這個查詢到的值,向上返回一個 IO 型別的值。

而針對第三種 pattern,可以抽象出這樣一種需求情景,就是:

AI 模組與遊戲世界的資料互操作

假設宿主提供了接受引數的 api,提供了查詢介面,ai 模組需要通過呼叫宿主的查詢介面拿到資料,再把資料傳給宿主來執行某種行為。
我們稱這種語義為 With,With 用來求出一個結點的值,併合並在當前的 env 中傳遞給子樹,子樹中可以 resolve 到這個 symbol。
有了 With 語義,我們就可以方便的在 AI 模組中對遊戲世界的資料進行操作,請求一個資料 => 處理一下 => 返回一個資料,更具擴充套件性。
With 語義的具體需求明確一下就是這樣的:由兩個子樹來構造,一個是 IOGet,一個是 SubTree。With 會首先求值 IOGet,然後 binding 到一個 symbol 上,SubTree 可以直接引用這個 symbol,來當做一個普通的值用。
然後考慮下實現方式。
C# 中,子樹要想引用這個 symbol,有兩個方法:

  1.      ioget 與 subtree 共同 hold 住一個變數,ioget 求得的值賦給這個變數,subtree 構造的時候直接把值傳進來。
  2.      ioget 與 subtree 共同 hold 住一個 env,雙方約定統一的 key,ioget 求完就把這個 key 設定一下,subtree 構造的時候直接從 env 里根據 key 取值。

考慮第一種方法,hold 住的不應該是值本身,因為樹本身是不同例項共享的,而這個值會直接影響到子樹的結構。所以應該用一個 class instance object 對值包裹一下。
這樣經過改進後的第一種方法理論上速度應該比 env 的方式快很多,也方便做一些優化,比如說如果子樹沒有 continue 就不需要把這個值存在 env 中,比如說由於樹本身的驅動一定是單執行緒的,不同的例項可以共用一個包裹,執行子樹的時候設定下包裹中的值,執行完子樹再把包裹中的值還原。
加入了 with 語義,就需要重新審視一下 IState 的定義了。既然一個結點既有可能返回一個 Result,又有可能返回一個值,那麼就需要這樣一種抽象:
有這樣一種泛化的 concept,他只需要提供一個 drive 介面,介面需要提供一個環境 env,drive 一下,就可以輸出一個值。這個 concept 的 instance,需要是 pure 的,也就是結果唯一取決於輸入的環境。不同次輸入,只要環境相同,輸出一定相同。

因為描述的是一種與外部世界的通訊,所以就命名為 IO 吧:

這樣,我們之前的所有結點都應該有 IO 的 concept。

之前提出了 Parallel、Sequence、Select、Check 這樣幾個語義結點。具體的實現細節就不再細說了,簡單列一下程式碼結構:

With 結點的實現,採用我們之前說的第一種方案:

這樣,我們的層次狀態機就全部元件化了。我們可以用通用的語義結點來組合出任意的子狀態,這些子狀態是不具名的,對構建過程更友好。

具體的程式碼例子:

看起來似乎是變得複雜了,原來可能只需要一句 new XXXState(),現在卻需要自己用程式碼拼接出來一個行為邏輯。但是仔細想一下,改成這樣的描述其實對整個工作流是有好處的。之前的形式完全是硬編碼,而現在,似乎讓我們看到了轉資料驅動的可能性。

對行為結點做包裝

當然這個示例還少解釋了一部分,就是葉子結點,或者說是行為結點的定義。
我們之前對行為的定義都是在 IUnit 中,但是這裡顯然不像是之前定義的 IUnit。
如果把每個行為都看做是樹上的一個與 Select、Sequence 等結點無異的普通結點的話,就需要實現 IO 的介面。抽象出一個計算的概念,構造的時候可以構造出這個計算,然後通過 Drive,來求得計算中的值。

包裝後的一個行為的程式碼:

經過包裝的行為結點的程式碼都是有規律可循的,所以我們可以比較容易的通過一些程式碼生成的機制來做。比如通過反射拿到 IUnit 定義的介面資訊,然後直接在這基礎之上做一下包裝,做出來個行為結點的定義。
現在我們再回憶下討論過的 With,構造一個葉子結點的時候,引數不一定是 literal value,也有可能是經過 Box 包裹過的。所以就需要對 Boax 和 literal value 抽象出來一個公共的概念,葉子結點 / 行為結點可以從這個概念中拿到值,而行為結點計算本身的構造也只需要依賴於這個概念。

我們把這個概念命名為 Thunk。Thunk 包裹一個值或者一個 box,而就目前來看,這個 Thunk,僅需要提供一個我們可以通過其拿到裡面的值的介面就夠了。

對於常量,我們可以構造一個包裹了常量的 thunk;而對於 box,其天然就屬於 Thunk 的 concept。
這樣,我們就通過一個 Thunk 的概念,硬生生把樹中的結點與值分割成了兩個概念。這樣做究竟正確不正確呢?
如果一個行為結點的引數可能有的型別本來就是一些 primitive type,或者是外部世界(相對於 AI 世界)的型別,那肯定是沒問題的。但如果需要支援這樣一種特性:外部世界的函式,返回值是 AI 世界的某個概念,比如一個樹結點;而我的 AI 世界,希望的是通過這個外部世界的函式,動態的拿到一個結點,再動態的加到我的樹中,或者再動態的傳給不通的外部世界的函式,應該怎麼做?
對於一顆 With 子樹(Negate 表示對子樹結果取反,Continue 仍取 Continue):

語義需要保證,這顆子樹執行到任意時刻,都需要是 ContextFree 的。
假設 IOGet 返回的是一個普通的值,確實是沒問題的。
但是因為 Box 包裹的可能是任意值,例如,假設 IOGet 返回的是一個 IO,

  •      instance a,執行完 IOGet 之後,結構變為 Negate(A)。
  •      instance b,再執行 IOGet,拿到一個 B,設定 box 裡的值為 B,並且拿出來 A,這時候再 run subtree,其實就是按 Negate(B) 來跑的。

我們只有把 IO 本身,做到其就是 Thunk 這個 Concept。這樣所有的 Message 物件,都是一個 Thunk。不僅如此,所以在這個樹中出現的資料結構,理應都是一個 Thunk,比如 List。

再次改造 IO:

BehaviourTree

對 AI 有了解的同學可能已經清楚了,目前我們實現的就是一個行為樹的引擎,並且已經基本成型。到目前為止,我們接觸過的行為樹語義有:
Sequence、Select、Parallel、Check、Negate。
其中 Sequence 與 Select 是兩個比較基本的語義,一個相當於邏輯 And,一個相當於邏輯 Or。在組合子設計中這兩類組合子也比較常見。
不同的行為樹方案,對語義結點的選擇也不一樣。
比如以前在行為樹這塊比較權威的一篇 halo2 的行為樹方案的 paper,裡面提到的幾個常用的組合結點有這樣幾種:

  •     prioritized-list : 每次執行優先順序最高的結點,高優先順序的始終搶佔低優先順序的。
  •     sequential : 按順序執行每個子結點,執行完最後一個子結點後,父結點就 finished。
  •     sequential-looping : 同上,但是會 loop。
  •     probabilistic : 從子結點中隨機選擇一個執行。
  •     one-off : 從子結點中隨機選擇或按優先順序選擇,選擇一個排除一個,直到執行完為止。

而騰訊的 behaviac 對組合結點的選擇除了傳統的 Select 和 Seqence,halo 裡面提到的隨機選擇,還自己擴充套件了 SelectorProbability(雖然看起來像是一個 select,但其實每次只會根據概率選擇一個,更傾向於 halo 中的 Probabilistic),SequenceStochastic(隨機地決定執行順序,然後表現起來確實像是一個 Sequence)。
其他還有各種常用的修飾結點,比如前文實現的 Check,還有一些比較常用的:

  •   Wait :子樹返回 Success 的時候向上 Success,否則向上 Continue。
  •   Forever : 永遠返回 Continue。
  •   If-Else、Switch-Cond : 對於有程式設計功底的我想就不需要再多做解釋了。
  •   forcedXX : 對子樹結果強制取值。

還有一類屬於特色結點,雖然通過其他各種方式也都能實現,但是在行為樹這個層面實現的話肯定擴充套件性更強一些,畢竟可以分離一部分程式的職責。一個比較典型的應用情景是事件驅動,halo 的 paper 中提到了 Behaviour Impulse,但是我在在 behaviac 中並沒有找到類似的概念。
halo 的 paper 裡面還提到了一些比較細節的 hack 技巧,比如同一顆行為樹可以應用不同的 Style,Parameter Creep 等等,有興趣的同學也可以自行研究。
至此,行為樹的 runtime 話題需要告一段落了,畢竟是一項成熟了十幾年的技術。雖然這是目前遊戲 AI 的標配,但是,只有行為樹的話,離一個完整的 AI 工作流還很遠。到目前為止,行為樹還都是程式寫出來的,但是正確來說 AI 應該是由策劃或者 AI 指令碼配出來的。因此,這篇文章的話題還需要繼續,我們接下來就討論一下這個程式與策劃之間的中間層。
之前的優化思路也好,從其他語言借鑑的設計 pattern 也好,行為樹這種理念本身也好,本質上都是術。術很重要,但是無助於優化工作流。這時候,我們更需要一種略。那麼,

略是什麼

這裡我們先擴充套件下游戲 AI 開發中的一種比較經典的工作流。策劃輸出 AI 配置,直接在遊戲內除錯效果。如果現有介面不滿足需求,就向程式提開發需求,程式加上新介面之後,策劃可以在 AI 配置裡面應用新的介面。這個 AI 配置是個比較廣義的概念,既可以像很多從立項之初並沒有規劃 AI 模組的遊戲那樣,逐漸地、自發地形成了一套基於配表做的決策樹;也可以是像騰訊的 behaviac 那樣的,用 XML 檔案來描述。XML 天生就是描述資料的,騰訊系的元件普遍特別鍾愛,tdr 這種配錶轉資料的工具是 xml,tapp tcplus 什麼的配置檔案全是 XML,倒不是說 XML,而是很多問題解決起來並不直觀。
配表也好,XML 也好,json 也好,這種描述資料的形式本身並沒有錯。配表幫很多團隊跨過了從硬編碼到資料驅動的開發模式的轉變,現在國內小到創業手遊團隊,大到天諭這種幾百人的 MMO,策劃的工作量除了配關卡就是配表。
但是,配表無法自我進化 http://blog.csdn.net/noslopforever/article/details/20833931 ,配表無法自己描述流程是什麼樣,而是流程在描述配表是什麼樣。
針對策劃配置 AI 這個需求,我們希望抽象出來一箇中間層,這樣,基於這個中間層,開發相應的編輯器也好,直接利用這個中間層來配 AI 也好,都能夠靈活地做到除錯 AI 這個最終需求。如何解決?我們不妨設計一種 DSL。

DSL

Domain-specific Language,領域特定語言,顧名思義,專門為特定領域設計的語言。設計一門 DSL 遠容易於設計一門通用計算語言,我們不用考慮一些特別複雜的特性,不用加一些增加複雜度的模組,不需要 care 跟領域無關的一些流程。Less is more。

遊戲 AI 需要怎樣一種 DSL

痛點:

  •   對於遊戲 AI 來說,需要一種語言可以描述特定型別 entity 的行為邏輯。
  •   而對於程式設計師來說,只需要提供 runtime 即可。比如組合結點的型別、表現等等。而具體的行為決策邏輯,由其他層次的協作者來定義。
  •   核心需求是做另一種 / 幾種高階語言的目的碼生成,對於當前以及未來幾年來說,對 C# 的支援一定是不能少的,對 python/lua 等服務端指令碼的支援也可以考慮。
  •   對語言本身的要求是足夠簡單易懂,declarative,這樣既可以方便上層編輯器的開發,也可以在沒編輯器的時候快速上手。

分析需求:

  •   因為需要做目的碼生成,而且最主要的目的碼應該是 C# 這種強型別的,所以需要有簡單的型別系統,以及編譯期簡單的型別檢查。可以確保語言的原始檔可以最終 codegen 成不會導致編譯出錯的 C# 程式碼。
  •   決定行為樹框架好壞的一個比較致命的因素就是對 With 語義的實現。根據我們之前對 With 語義的討論,可以看到,這個 With 語義的描述其實是天然的可以轉化為一個 lambda 的,所以這門 DSL 同樣需要對 lambda 進行支援。
  •   關於型別系統,需要支援一些內建的複雜型別,目前來看僅需要 List,只有在 seq、select 等結點的構造時會用到。還是由於需要支援 lambda 的原因,我們需要支援 Applicative Type,也就是形如 A -> B 應該是 first class type,而一個 lambda 也應該是 first class function。根據之前對 runtime 的實現討論,我們的 DSL 還需要支援 Generic Type,來支援 IO<Result> 這樣的型別,以及 List<IO<Result>> 這樣的型別。對內建 primitive 型別的支援只要有 String、Bool、Int、Float 即可。需要支援簡單的型別推導,實現 hindley-milner 的真子集即可,這樣至少我們就不需要在宣告 lambda 的時候寫的太複雜。
  •   需要支援模組化定義,也就是最基本的 import 語義。這樣的話可以方便地模組化構建 AI 介面,也可以比較方便地定義一些預製件。
    •  模組分為兩類:
    •  一類是抽象的宣告,只有 declare。比如 Prelude,seq、select 等一些結點的具體實現邏輯一定是在 runtime 中做的,所以沒必要在 DSL 這個層面填充這類邏輯。具體的程式碼轉換則由一些特設的模組來做。只需要型別檢查通過,目標語言的 CodeGenerator 生成了對應的目的碼,具體的邏輯就在 runtime 中直接實現了。
    •  一類是具體的定義,只有 define。比如定義某個具體的 AIXXX 中的 root 結點,或者定義某個通用行為結點。具體的定義就需要對外部模組的 define 以及 declare 進行組合。import 語義就需要支援從外部模組匯入符號。

一種 non-trivial 的 DSL 實現方案

由於原則是簡單為主,所以我在語言的設計上主要借鑑的是 Scheme。S 表示式的好處就是程式碼本身即資料,也可以是我們需要的 AST。同時,由於需要引入簡單型別系統,需要混入一些其他語言的描述風格。我在 declare 型別時的語言風格借鑑了 haskell,import 語句也借鑑了 haskell。

具體來說,declare 語句可能類似於這樣:

因為是以 Scheme 為主要借鑑物件,所以內建的複雜型別實現上本質是一個 ADT,當然,有針對 list 構造專用的語法糖,但是其 parse 出來拿到的 AST 中一個 list 終究還是一個 ADT。

直接拿例子來說比較直觀:

可以看到,跟 S-Expression 沒什麼太大的區別,可能 lambda 的宣告方式變了下。

然後是詞法分析和語法分析,這裡我選擇的是 Haskell 的 ParseC。一些更傳統的選擇可能是 lex+yacc/flex+bison。但是這種兩個工具一起混用學習成本就不用說了,也違背了 simple is better 的初衷。ParseC 使用起來就跟 PEG 是一樣的,PEG 這種形式,是天然的結合了正則與 top-down parser。haskell 支援的 algebraic data types,天然就是用來定義 AST 結構的,簡單直觀。haskell 實現的 hindly-miner 型別系統,又是讓你寫程式碼基本編譯通過就能直接 run 出正確結果,從一定程度上彌補了 PEG 天生不適合除錯的缺陷。一個 haskell 的庫就能解決 lexical&grammar,實在方便。
先是一些 AST 結構的預定義:

我在這裡省去了一些跟這篇文章討論的 DSL 無關的語言特性,比如 Pattern 的定義我只保留了 VarPat;Value 的定義我去掉了 ClosureVal,雖然語言本身仍然是支援 first class function 的。

algebraic data type 的一個好處就是清晰易懂,定義起來不過區區二十行,但是我們一看就知道之後輸出的 AST 會是什麼樣。

haskell 的 ParseC 用起來其實跟 PEG 是沒有本質區別的,組合子本身是自底向上描述的,而 parser 也是通過 parse 小元素的 parser 來構建 parse 大元素的 parser。

例如,haskell 的 ParseC 庫就有這樣幾個強大的特性:

  • 提供了 char、string,基元的 parse 單個字元或字串的 parser。
  • 提供了 sat,傳一個 predicate,就可以 parse 到符合 predicate 的結果的 parser。
  • 提供了 try,支援 parse 過程中的 lookahead 語義。
  • 提供了 chainl、chainr,這樣就省的我們在構造 parser 的時候就無需考慮左遞迴了。不過這個我也是寫完了 parser 才瞭解到的,所以基本沒用上,更何況對於 S-expression 來說,需要我來處理左遞迴的情況還是比較少的。

我們可以先根據這些基本的,封裝出來一些通用 combinator。

比如正則規則中的 star:

比如 plus:

基於這些,我們可以做組裝出來一個 parse lambda-exp 的 parser(p_seperate 是對 char、plus 這些的組裝,表示形如 a,b,c 這樣的由特定字元分隔的序列):


有了所有 exp 的 parser,我們就可以組裝出來一個通用的 exp parser:

其中,listplus 是一種具有優先順序的 lookahead:

對於 parser 來說,其輸入是原始檔其輸出是 AST。具體來說,其實就是 parse 出一個 Dec 陣列,拿到 AST,供後續的 pipeline 消費。
我們之前舉的 AI 的例子,parse 出來的 AST 大概是這副模樣:

前面兩部分是我把在其他模組定義的 declares,選擇性地拿過來兩條。第三部分是這個人形怪 AI 的整個的 AST。其中巢狀的 Cons 展開之後就是語言內建的 List。

正如我們之前所說,做程式碼生成之前需要進行一步型別檢查的工作。型別檢查工具其輸入是 AST 其輸出是一個檢查結果,同時還可以提供 AST 中的一些輔助資訊,包括各識別符號的型別資訊等等。
型別檢查其實主要的邏輯在於處理 Appliacative Type,這中間還有個型別推導的邏輯。形如 (\a (Func a)) 10,AST 中並不記錄 a 的 type,我們的 DSL 也不需要支援 concept、typeclass 等有關 type、subtype 的複雜機制,推導的時候只需要著重處理 AppExp,把右邊表示式的型別求出,合併一下 env 傳給左邊表示式遞迴檢查即可。

這部分的程式碼:

此外,還需要有一個通用的 CodeGenerator 模組,其輸入也是 AST,其輸出是另一些 AST 中的輔助資訊,主要是註記下各識別符號的 import 源以及具體的 define 內容,用來方便各目標語言 CodeGenerator 直接複用邏輯。
目標語言的 CodeGenerator 目前只做了 C# 的。

目的碼生成的邏輯就比較簡單了,畢竟該有的資訊前面的各模組都提供了,這裡根據之前一個版本的 runtime,程式碼生成的大致樣子:

總的來說,大致分為這幾個模組:Parser、TypeChecker、CodeGenerator、目標語言的 CodeGenerator。再加上目標語言的 runtime,基本上就可以組成這個 DSL 的全部了。
上面列出來的程式碼風格比較混搭,畢竟是前後差的時間比較久了。。parser 部分大概是 7 月左右完成的,那時候喜歡 applicative 的風格,大量用了 <$> <*>;後面的 TypeChecker 和 CodeGenerator 都是最近寫的,寫 monad expression 的時候,Maybe Monad 我比較傾向於寫原生的 >>= 呼叫,IO Monad 如果這樣寫就煩了,所以比較多的用了 do-notaion。優化什麼的由於時間原因還沒看 RWH 的後面幾章,而且 DSL 的 compiler 對效能需求的優先順序其實很低了,所以暫時沒有考慮過,各位看官將就一下。

再擴充套件 runtime

對比 DSL,我們可以發現,DSL 支援的特性要比之前實現的 runtime 版本多。比如:

  • runtime 中壓根就沒有 Closure 的概念,但是 DSL 中我們是完全可以把一個 lambda 作為一個 ClosureVal 傳給某個函式的。
  • 缺少對標準庫的支援。比如常用的 math 函式。
  • 基於上面這點,還會引入一個 With 結點的效能問題,在只有 runtime 的時候我們也許不會 With a <- 1+1。但是 DSL 中是有可能這樣的,而且生成出來的程式碼會每次 run 這棵樹的時候都會重新計算一次 1+1。

針對第一個問題,我們要做的工作就多了。首先我們要記錄下這個閉包 hold 住的自由變數,要傳給 runtime,runtime 也要記錄,也要做各種各種,想想都麻煩,而且完全偏離了遊戲 AI 的話題,不再討論。
針對第二個問題,我們可以通過解決第三個問題來順便解決這個問題。
針對第三個問題,我們重新審視一下 With 語義。

With 語義所要表達的其實是這樣一個概念:
把一個可能會 Continue/Lazy Evaluation 的計算結果,繫結到一個 variable 上,對於 With 下面的子表示式來說,這個 variable 的值具有 lexical scope。
但是在 runtime 中,我們按照之前的寫法,subtree 中直接就進行了函式呼叫,很顯然是存在問題的。

With 結點本身的返回值不一定只是一個 IO<Result>,有可能是一個 IO<float>。

舉例:

這裡 Math.Plus 屬於這門 DSL 標準庫的一部分,實現上我們就對底層數學函式做一層簡單的 wrapper。但是這樣由於 C# 語言是 pass-by-value,我們在構造這顆 With 的時候,Math.Plus(a, 0.1) 已經求值。但是這個時候 Box 的值還沒有被填充,求出來肯定是有問題的。
所以我們需要對這樣一種計算再進行一次抽象。希望可以得到的效果是,對於 Math.Plus(0.1, 0.2),可以在構造樹的時候直接求值;對於 Math.Plus(0.1, a),可以得到某種計算,在我們需要的時候再求值。
先明確下函式呼叫有哪幾種情況:

  • 對 UnitAI,也就是外部世界的定義的介面的呼叫。這種呼叫,對於 AI 模組來說,本質上是 pure 的,所以不需要考慮這個延遲計算的問題
  • 對標準庫的呼叫

按我們之前的 runtime 設計思路,Math.Plus 這個標準庫 API 也許會被設計成這樣:

如果 a 和 b 都是 literal value,那就沒問題,但是如果有一個是被 box 包裹的,那就很顯然是有問題的。

所以需要對 Thunk 這個概念做一下擴充套件,使之能區別出動態的值與靜態的值。一般情況下的值,都是 pure 的;box 包裹的值,是 impure 的。同時,這個 pure 的性質具有值傳遞性,如果這個值屬於另一個值的一部分,那麼這個整體的 pure 性質與值的區域性的 pure 性質是一致的。這裡特指的值,包括 List 與 IO。

整體的概念我們應該拿 haskell 中的 impure monad 做類比,比如 haskell 中的 IO。haskell 中的 IO 依賴於 OS 的輸入,所以任何返回 IO monad 的函式都具有傳染性,引用到的函式一定還會被包裹在 IO monad 之中。

所以,對於 With 這種情況的傳遞,應該具有這樣的特徵:

  • With 內部引用到了 With 外部的 symbol,那麼這個 With 本身應該是 impure 的。
  • With 內部只引用了自己的 IOGet,那麼這個 With 本身是 pure 的,但是其 SubTree 是 impure 的。

所以 With 結點構造的時候,計算 pure 應該特殊處理一下。但是這個特殊處理的程式碼汙染性比較大,我在本文就不列出了,只是這樣提一下。

有了 pure 與 impure 的標記,我們在對函式呼叫的時候,就需要額外走一層。

本來一個普通的函式呼叫,比如 UnitAI.Func(p0, p1, p2) 與 Math.Plus(p0, p1)。前者返回一種 computing 是毫無疑問的,後者就需要根據引數的型別來決定是返回一種計算還是直接的值。

為了避免在這個 Plus 裡面改來改去,我們把 Closure 這個概念給抽象出來。同時,為了簡化討論,我們只列舉 T0 -> TR 這一種情況,對應的標準庫函式取 Abs。

其中,UserFuncApply 就是之前所說的一層計算的概念。UserFunc 表示的是等效於可以編譯期計算的一種標準庫函式。

這樣定義:

Message 型別的 Closure 構造,都走 FuncThunk 建構函式;普通函式型別的構造,走 Func 建構函式,並且包裝一層。

Help.Apply 是為了方便做程式碼生成,描述一種 declarative 的 Application。其實就是直接呼叫 Closure 的 Apply。

考慮以下幾種 case:

 與之前的 runtime 版本唯一表現上有區別的地方在於,對於純 pure 引數的 userFunc,在 Apply 完之後會直接計算出來值,並重新包裝成一個 Thunk;而對於引數中有 impure 的情況,返回一個 UserFuncApply,在 GetUserValue 的時候才會求值。

TODO

到目前為止,已經形成了一套基本的、non-trivial 的遊戲 AI 方案,當然後續還有很多要做的工作,比如:

更多的語言特性:

  •   DSL 中支援註釋、函式作為普通的 value 傳遞等等。
  •   parser、typechecker 支援更完善的錯誤處理,我之前單獨寫一個用例的時候,就因為一些細節問題,除錯了老半天。
  •   標準庫支援更多,比如 Y-Combinator

編輯器化:
國內遊戲工業落後國外的一個比較重要的因素就是工作流太落後,要不是因為 unity 的興起帶動了國內編輯器化風潮,可能現在還有大部分團隊配技能配戰鬥效果都還會對著 excel 盲配。
AI 的配置也需要有編輯器,這個編輯器至少能實現的需求有這樣幾個:

  •      與自己定義的中間層對接良好(配置檔案也好、DSL 也好),具有 codegen 功能
  •      支援工作空間、支援模組化定義,製作一些 prefab 什麼的
  •      支援視覺化除錯

我們工作室自己做的編輯器是基於 java 的某個開源庫做的,看起來比較炫,但是效能不行。behaviac 的編輯器就是純 C#,效能應該不錯,沒有用過不了解。這方面的具體話題就不再展開了。

前段時間稍微整理了下文章中涉及的程式碼,放在了 github 上。Behaviour
當然,裡面只是示例實現,有時間的話我會把其他東西補充上。
只是工作量的問題。

相關文章