創建一個簡單的遊戲引擎和人工智能NPC後,咱們須要對他們進行優化,如何創建,能夠參考我在評論裏的連接python
不過咱們在這篇博客的討論中是不能僅停留在能解決需求的層面上。目前的方案至少還存在一個比較嚴重的問題,那就是邏輯複用性太差。組合狀態須要 coding 的邏輯太多了,具體的狀態內部邏輯須要人肉維護,更可怕的是須要程序員來人肉維護,再多幾個組合狀態簡直不敢想象。程序員真的沒這麼多時間維護這些東西好麼。因此咱們應該嘗試抽象一下組合狀態是否有一些通用的設計 pattern。 爲了解決這個問題,咱們再對這幾個狀態的分析一下,能夠對結點類型進行一下概括。程序員
若是把這個狀態遷移邏輯體看作一個樹結構,那其中組合結點就是非葉子結點,原子結點就是葉子結點。 對於組合結點來講,其行爲是能夠概括的。express
巡邏結點,不考慮觸發進入戰鬥的邏輯,能夠概括爲一種具備這樣的行爲的組合結點:依次執行每一個子結點(移動到某個點、休息一下子),某個子結點返回 Success 則執行下一個,返回 Failure 則直接向上返回,返回 Continue 就把 Continuation 拋出去。命名具備這樣語義的結點爲 Sequence。編程
設想攻擊狀態下,單位須要同時進行兩種子結點的嘗試,一個是釋放技能,一個是說話。兩個須要同時執行,而且結果獨立。有一個返回 Success 則向上返回 Success,所有 Failure 則返回 Failure,不然返回 Continue。命名具備如此語義的結點爲 Parallel。json
在 Parallel 的語義基礎上,若是要體現一個優先級 / 順序性質,那麼就須要一個具備依次執行子結點語義的組合結點,命名爲 Select。 Sequence 與 Select 組合起來,就能完整的描述一」 趟 「巡邏,Select(ReactAttack, Sequence(MoveTo, Idle)),能夠直接幹掉以前寫的 Patrol 組合狀態,組合狀態直接拿現成的實現好的語義結點複用便可。 組合結點的抽象問題解決了,如今咱們來看葉子結點。api
Flee、Idle、MoveTo 三個狀態,狀態進入的時候調一下宿主的某個函數,申請開始一個持續性的動做。 四個原子狀態都有的一個 pattern,就是在 Drive 中輪詢,直到某個條件達成了才返回。數組
前兩個 pattern 實際上是同一個問題,區別就在於那些邏輯應該放在宿主提供的接口裏面作實現,哪些邏輯應該在 AI 模塊裏作實現。調用宿主的某個函數,調用是一個瞬間的操做,直接改變了宿主的 status,可是截止點的判斷就有不一樣的實現方式了。數據結構
假設宿主提供了接受參數的 api,提供了查詢接口,ai 模塊須要經過調用宿主的查詢接口拿到數據,再把數據傳給宿主來執行某種行爲。 咱們稱這種語義爲 With,With 用來求出一個結點的值,併合並在當前的 env 中傳遞給子樹,子樹中能夠 resolve 到這個 symbol。閉包
有了 With 語義,咱們就能夠方便的在 AI 模塊中對遊戲世界的數據進行操做,請求一個數據 => 處理一下 => 返回一個數據,更具擴展性。app
With 語義的具體需求明確一下就是這樣的:由兩個子樹來構造,一個是 IOGet,一個是 SubTree。With 會首先求值 IOGet,而後 binding 到一個 symbol 上,SubTree 能夠直接引用這個 symbol,來當作一個普通的值用。 而後考慮下實現方式。
ioget 與 subtree 共同 hold 住一個變量,ioget 求得的值賦給這個變量,subtree 構造的時候直接把值傳進來。
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 吧:
public interface IO<T> { T Drive(Context ctx); } public interface IO<T> { T Drive(Context ctx); }
這樣,咱們以前的全部結點都應該有 IO 的 concept。
以前提出了 Parallel、Sequence、Select、Check 這樣幾個語義結點。具體的實現細節就再也不細說了,簡單列一下代碼結構:
public class Sequence : IO<Result> { private readonly ICollection<IO<Result>> subTrees; public Sequence(ICollection<IO<Result>> subTrees) { this.subTrees = subTrees; } public Result Drive(Context ctx) { throw new NotImplementedException(); } }
public class Sequence : IO<Result> { private readonly ICollection<IO<Result>> subTrees; public Sequence(ICollection<IO<Result>> subTrees) { this.subTrees = subTrees; } public Result Drive(Context ctx) { throw new NotImplementedException(); } }
public class With<T, TR> : IO<TR> { // ... public TR Drive(Context ctx) { var thisContinuation = ctx.Continuation; var value = default(T); var skipIoGet = false; if (thisContinuation != null) { // Continuation ctx.Continuation = thisContinuation.SubContinuation; // 0表示須要繼續ioGet // 1表示須要繼續subTree if (thisContinuation.NextStep == 1) { skipIoGet = true; value = (T) thisContinuation.Param; } } if (!skipIoGet) { value = ioGet.Drive(ctx); if (ctx.Continuation != null) { // ioGet拋出了Continue if (thisContinuation == null) { thisContinuation = new Continuation() { SubContinuation = ctx.Continuation, NextStep = 0, }; } else { thisContinuation.SubContinuation = ctx.Continuation; thisContinuation.NextStep = 0; } ctx.Continuation = thisContinuation; return default(TR); } } var oldValue = box.SetVal(value); var ret = subTree.Drive(ctx); box.SetVal(oldValue); if (ctx.Continuation != null) { // subTree拋出了Continue if (thisContinuation == null) { thisContinuation = new Continuation() { SubContinuation = ctx.Continuation, }; } ctx.Continuation = thisContinuation; thisContinuation.Param = value; } return ret; } }
public class With<T, TR> : IO<TR> { // ... public TR Drive(Context ctx) { var thisContinuation = ctx.Continuation; var value = default(T); var skipIoGet = false; if (thisContinuation != null) { // Continuation ctx.Continuation = thisContinuation.SubContinuation; // 0表示須要繼續ioGet // 1表示須要繼續subTree if (thisContinuation.NextStep == 1) { skipIoGet = true; value = (T) thisContinuation.Param; } } if (!skipIoGet) { value = ioGet.Drive(ctx); if (ctx.Continuation != null) { // ioGet拋出了Continue if (thisContinuation == null) { thisContinuation = new Continuation() { SubContinuation = ctx.Continuation, NextStep = 0, }; } else { thisContinuation.SubContinuation = ctx.Continuation; thisContinuation.NextStep = 0; } ctx.Continuation = thisContinuation; return default(TR); } }
var oldValue = box.SetVal(value); var ret = subTree.Drive(ctx); box.SetVal(oldValue); if (ctx.Continuation != null) { // subTree拋出了Continue if (thisContinuation == null) { thisContinuation = new Continuation() { SubContinuation = ctx.Continuation, }; } ctx.Continuation = thisContinuation; thisContinuation.Param = value; } return ret; } }
這樣,咱們的層次狀態機就所有組件化了。咱們能夠用通用的語義結點來組合出任意的子狀態,這些子狀態是不具名的,對構建過程更友好。
具體的代碼例子:
Par( Seq(IsFleeing, ((Box<object> a) => With(a, GetNearestTarget, Check(IsNull(a))))(new Box<object>()), Patrol) ,Seq(IsAttacking, ((Box<float> a) => With(a, GetFleeBloodRate, Check(HpRateLessThan(a))))(new Box<float>())) ,Seq(IsNormal, Loop(Par(((Box<object> a) => With(a, GetNearestTarget, Seq(Check(IsNull(a)), LockTarget(a)))(new Box<object>()), Seq(Seq(Check(ReachCurrentPatrolPoint), MoveToNextPatrolPoiont), Idle))))))
Par( Seq(IsFleeing, ((Box<object> a) => With(a, GetNearestTarget, Check(IsNull(a))))(new Box<object>()), Patrol) ,Seq(IsAttacking, ((Box<float> a) => With(a, GetFleeBloodRate, Check(HpRateLessThan(a))))(new Box<float>())) ,Seq(IsNormal, Loop(Par(((Box<object> a) => With(a, GetNearestTarget, Seq(Check(IsNull(a)), LockTarget(a)))(new Box<object>()), Seq(Seq(Check(ReachCurrentPatrolPoint), MoveToNextPatrolPoiont), Idle))))))
看起來彷佛是變得複雜了,原來可能只須要一句 new XXXState(),如今卻須要本身用代碼拼接出來一個行爲邏輯。可是仔細想一下,改爲這樣的描述其實對整個工做流是有好處的。以前的形式徹底是硬編碼,而如今,彷佛讓咱們看到了轉數據驅動的可能性。
固然這個示例還少解釋了一部分,就是葉子結點,或者說是行爲結點的定義。
咱們以前對行爲的定義都是在 IUnit 中,可是這裏顯然不像是以前定義的 IUnit。
若是把每一個行爲都看作是樹上的一個與 Select、Sequence 等結點無異的普通結點的話,就須要實現 IO 的接口。抽象出一個計算的概念,構造的時候能夠構造出這個計算,而後經過 Drive,來求得計算中的值。
#region HpRateLessThan private class MessageHpRateLessThan : IO<bool> { public readonly float p0; public MessageHpRateLessThan(float p0) { this.p0 = p0; } public bool Drive(Context ctx) { return ((T)ctx.Self).HpRateLessThan(p0); } } public static IO<bool> HpRateLessThan(float p0) { return new MessageHpRateLessThan(p0); } #endregion
#region HpRateLessThan private class MessageHpRateLessThan : IO<bool> { public readonly float p0; public MessageHpRateLessThan(float p0) { this.p0 = p0; } public bool Drive(Context ctx) { return ((T)ctx.Self).HpRateLessThan(p0); } } public static IO<bool> HpRateLessThan(float p0) { return new MessageHpRateLessThan(p0); } #endregion
通過包裝的行爲結點的代碼都是有規律可循的,因此咱們能夠比較容易的經過一些代碼生成的機制來作。好比經過反射拿到 IUnit 定義的接口信息,而後直接在這基礎之上作一下包裝,作出來個行爲結點的定義。
如今咱們再回憶下討論過的 With,構造一個葉子結點的時候,參數不必定是 literal value,也有多是通過 Box 包裹過的。因此就須要對 Boax 和 literal value 抽象出來一個公共的概念,葉子結點 / 行爲結點能夠從這個概念中拿到值,而行爲結點計算自己的構造也只須要依賴於這個概念。
咱們把這個概念命名爲 Thunk。Thunk 包裹一個值或者一個 box,而就目前來看,這個 Thunk,僅須要提供一個咱們能夠經過其拿到裏面的值的接口就夠了。
public abstract class Thunk<T> { public abstract T GetUserValue(); } public abstract class Thunk<T> { public abstract T GetUserValue(); }
對於常量,咱們能夠構造一個包裹了常量的 thunk;而對於 box,其自然就屬於 Thunk 的 concept。
這樣,咱們就經過一個 Thunk 的概念,硬生生把樹中的結點與值分割成了兩個概念。這樣作究竟正確不正確呢?
若是一個行爲結點的參數可能有的類型原本就是一些 primitive type,或者是外部世界(相對於 AI 世界)的類型,那確定是沒問題的。但若是須要支持這樣一種特性:外部世界的函數,返回值是 AI 世界的某個概念,好比一個樹結點;而個人 AI 世界,但願的是經過這個外部世界的函數,動態的拿到一個結點,再動態的加到個人樹中,或者再動態的傳給不通的外部世界的函數,應該怎麼作?
對於一顆 With 子樹(Negate 表示對子樹結果取反,Continue 仍取 Continue):
((Box<IO<Result>> a) => With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>()) ((Box<IO<Result>> a) => With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
語義須要保證,這顆子樹執行到任意時刻,都須要是 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。
public abstract class IO<T> : Thunk<IO<T>> { public abstract T Drive(Context ctx); public override IO<T> GetUserValue() { return this; } } public abstract class IO<T> : Thunk<IO<T>> { public abstract T Drive(Context ctx); public override IO<T> GetUserValue() { return this; } }
對 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)。
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,策劃的工做量除了配關卡就是配表。 可是,配表沒法自我進化 ,配表沒法本身描述流程是什麼樣,而是流程在描述配表是什麼樣。
針對策劃配置 AI 這個需求,咱們但願抽象出來一箇中間層,這樣,基於這個中間層,開發相應的編輯器也好,直接利用這個中間層來配 AI 也好,都可以靈活地作到調試 AI 這個最終需求。如何解決?咱們不妨設計一種 DSL。
Domain-specific Language,領域特定語言,顧名思義,專門爲特定領域設計的語言。設計一門 DSL 遠容易於設計一門通用計算語言,咱們不用考慮一些特別複雜的特性,不用加一些增長複雜度的模塊,不須要 care 跟領域無關的一些流程。Less is more。
痛點:
由於須要作目標代碼生成,並且最主要的目標代碼應該是 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 語義就須要支持從外部模塊導入符號。
因爲原則是簡單爲主,因此我在語言的設計上主要借鑑的是 Scheme。S 表達式的好處就是代碼自己即數據,也能夠是咱們須要的 AST。同時,因爲須要引入簡單類型系統,須要混入一些其餘語言的描述風格。我在 declare 類型時的語言風格借鑑了 haskell,import 語句也借鑑了 haskell。
(declare (HpRateLessThan :: (Float -> IO Result)) (GetFleeBloodRate :: Float) (IsNull :: (Object -> Bool)) (Idle :: IO Result)) (declare (check :: (Bool -> IO Result)) (loop :: (IO Result -> IO Result)) (par :: (List IO Result -> IO Result))) (declare (HpRateLessThan :: (Float -> IO Result)) (GetFleeBloodRate :: Float) (IsNull :: (Object -> Bool)) (Idle :: IO Result)) (declare (check :: (Bool -> IO Result)) (loop :: (IO Result -> IO Result)) (par :: (List IO Result -> IO Result)))
由於是以 Scheme 爲主要借鑑對象,因此內建的複雜類型實現上本質是一個 ADT,固然,有針對 list 構造專用的語法糖,可是其 parse 出來拿到的 AST 中一個 list 終究仍是一個 ADT。
(import Prelude) (import BaseAI) (define Root (par [(seq [(check IsFleeing) ((\a (check (IsNull a))) GetNearestTarget)]) (seq [(check IsAttacking) ((\b (HpRateLessThan b)) GetFleeBloodRate)]) (seq [(check IsNormal) (loop (par [((\c (seq [(check (IsNull c)) (LockTarget c)])) GetNearestTarget) (seq [(seq [(check ReachCurrentPatrolPoint) MoveToNextPatrolPoiont]) Idle])]))])])) (import Prelude) (import BaseAI) (define Root (par [(seq [(check IsFleeing) ((\a (check (IsNull a))) GetNearestTarget)]) (seq [(check IsAttacking) ((\b (HpRateLessThan b)) GetFleeBloodRate)]) (seq [(check IsNormal) (loop (par [((\c (seq [(check (IsNull c)) (LockTarget c)])) GetNearestTarget) (seq [(seq [(check ReachCurrentPatrolPoint) MoveToNextPatrolPoiont]) Idle])]))])]))
能夠看到,跟 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,實在方便。
module Common where import qualified Data.Map as Map type Identifier = String type ValEnv = Map.Map Identifier Val type TypeEnv = Map.Map Identifier Type type DecEnv = Map.Map Identifier (String,Dec) data Type = NormalType String | GenericType String Type | AppType [Type] data Dec = DefineDec Pat Exp | ImportDec String | DeclareDec Pat Type | DeclaresDec [Dec] data Exp = ConstExp Val | VarExp Identifier | LambdaExp Pat Exp | AppExp Exp Exp | ADTExp String [Exp] data Val = NilVal | BoolVal Bool | IntVal Integer | FloatVal Float | StringVal String data Pat = VarPat Identifier module Common where import qualified Data.Map as Map type Identifier = String type ValEnv = Map.Map Identifier Val type TypeEnv = Map.Map Identifier Type type DecEnv = Map.Map Identifier (String,Dec) data Type = NormalType String | GenericType String Type | AppType [Type] data Dec = DefineDec Pat Exp | ImportDec String | DeclareDec Pat Type | DeclaresDec [Dec] data Exp = ConstExp Val | VarExp Identifier | LambdaExp Pat Exp | AppExp Exp Exp | ADTExp String [Exp] data Val = NilVal | BoolVal Bool | IntVal Integer | FloatVal Float | StringVal String data Pat = VarPat Identifier
我在這裏省去了一些跟這篇文章討論的 DSL 無關的語言特性,好比 Pattern 的定義我只保留了 VarPat;Value 的定義我去掉了 ClosureVal,雖然語言自己仍然是支持 first class function 的。
algebraic data type 的一個好處就是清晰易懂,定義起來不過區區二十行,可是咱們一看就知道以後輸出的 AST 會是什麼樣。
haskell 的 ParseC 用起來其實跟 PEG 是沒有本質區別的,組合子自己是自底向上描述的,而 parser 也是經過 parse 小元素的 parser 來構建 parse 大元素的 parser。
star :: Parser a -> Parser [a] star p = star_p where star_p = try plus_p <|> (return []) plus_p = (:) <$> p <*> star_p star :: Parser a -> Parser [a] star p = star_p where star_p = try plus_p <|> (return []) plus_p = (:) <$> p <*> star_p
好比 plus:
plus :: Parser a -> Parser [a] plus p = plus_p where star_p = try plus_p <|> (return []) <?> "plus_star_p" plus_p = (:) <$> p <*> star_p <?> "plus_plus_p" plus :: Parser a -> Parser [a] plus p = plus_p where star_p = try plus_p <|> (return []) <?> "plus_star_p" plus_p = (:) <$> p <*> star_p <?> "plus_plus_p"
基於這些,咱們能夠作組裝出來一個 parse lambda-exp 的 parser(p_seperate 是對 char、plus 這些的組裝,表示形如 a,b,c 這樣的由特定字符分隔的序列):
p_lambda_exp :: Parser Exp p_lambda_exp = p_between '(' ')' inner <?> "p_lambda_exp" where inner = make_lambda_exp <$ char '\\' <*> p_seperate (p_parse p_pat) "," <*> p_parse p_exp make_lambda_exp [] e = (LambdaExp NilPat e) make_lambda_exp (p:[]) e = (LambdaExp p e) make_lambda_exp (p:ps) e = (LambdaExp p (make_lambda_exp ps e)) p_lambda_exp :: Parser Exp p_lambda_exp = p_between '(' ')' inner <?> "p_lambda_exp" where inner = make_lambda_exp <$ char '\\' <*> p_seperate (p_parse p_pat) "," <*> p_parse p_exp make_lambda_exp [] e = (LambdaExp NilPat e) make_lambda_exp (p:[]) e = (LambdaExp p e)
make_lambda_exp (p:ps) e = (LambdaExp p (make_lambda_exp ps e))
有了全部 exp 的 parser,咱們就能夠組裝出來一個通用的 exp parser:
p_exp :: Parser Exp p_exp = listplus [p_var_exp, p_const_exp, p_lambda_exp, p_app_exp, p_adt_exp, p_list_exp] <?> "p_exp" p_exp :: Parser Exp p_exp = listplus [p_var_exp, p_const_exp, p_lambda_exp, p_app_exp, p_adt_exp, p_list_exp] <?> "p_exp" 其中,listplus 是一種具備優先級的 lookahead: listplus :: [Parser a] -> Parser a listplus lst = foldr (<|>) mzero (map try lst) 1 2 listplus :: [Parser a] -> Parser a listplus lst = foldr (<|>) mzero (map try lst)
對於 parser 來講,其輸入是源文件其輸出是 AST。具體來講,其實就是 parse 出一個 Dec 數組,拿到 AST,供後續的 pipeline 消費。
-- Prelude.bh Right [DeclaresDec [ DeclareDec (VarPat "seq") (AppType [GenericType "List" (GenericType "IO" (NormalType "Result")),GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "check") (AppType [NormalType "Bool",GenericType "IO" (NormalType "Result")])]]
-- BaseAI.bh Right [DeclaresDec [ DeclareDec (VarPat "HpRateLessThan") (AppType [NormalType "Float",GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "Idle") (GenericType "IO" (NormalType "Result"))]]
-- AI00001.bh Right [ ImportDec "Prelude" ,ImportDec "BaseAI" ,DefineDec (VarPat "Root") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsFleeing") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "a")(AppExp (VarExp "check") (AppExp (VarExp "IsNull") (VarExp "a")))) (VarExp "GetNearestTarget") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsAttacking") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "b") (AppExp (VarExp "HpRateLessThan") (VarExp "b"))) (VarExp "GetFleeBloodRate") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsNormal") ,ADTExp "Cons" [ AppExp (VarExp "loop") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (LambdaExp (VarPat "c") (AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (AppExp (VarExp"IsNull") (VarExp "c")) ,ADTExp "Cons" [ AppExp (VarExp "LockTarget") (VarExp "c") ,ConstExp NilVal]]))) (VarExp "GetNearestTarget") ,ADTExp "Cons" [ AppExp (VarExp"seq") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "ReachCurrentPatrolPoint") ,ADTExp "Cons" [ VarExp "MoveToNextPatrolPoiont" ,ConstExp NilVal]]) ,ADTExp "Cons" [ VarExp "Idle" ,ConstExp NilVal]]) ,ConstExp NilVal]])) ,ConstExp NilVal]]) ,ConstExp NilVal]]]))]
-- Prelude.bh Right [DeclaresDec [ DeclareDec (VarPat "seq") (AppType [GenericType "List" (GenericType "IO" (NormalType "Result")),GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "check") (AppType [NormalType "Bool",GenericType "IO" (NormalType "Result")])]]
-- BaseAI.bh Right [DeclaresDec [ DeclareDec (VarPat "HpRateLessThan") (AppType [NormalType "Float",GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "Idle") (GenericType "IO" (NormalType "Result"))]]
-- AI00001.bh Right [ ImportDec "Prelude" ,ImportDec "BaseAI" ,DefineDec (VarPat "Root") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsFleeing") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "a")(AppExp (VarExp "check") (AppExp (VarExp "IsNull") (VarExp "a")))) (VarExp "GetNearestTarget") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsAttacking") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "b") (AppExp (VarExp "HpRateLessThan") (VarExp "b"))) (VarExp "GetFleeBloodRate") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsNormal") ,ADTExp "Cons" [ AppExp (VarExp "loop") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (LambdaExp (VarPat "c") (AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (AppExp (VarExp"IsNull") (VarExp "c")) ,ADTExp "Cons" [ AppExp (VarExp "LockTarget") (VarExp "c") ,ConstExp NilVal]]))) (VarExp "GetNearestTarget") ,ADTExp "Cons" [ AppExp (VarExp"seq") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "ReachCurrentPatrolPoint") ,ADTExp "Cons" [ VarExp "MoveToNextPatrolPoiont" ,ConstExp NilVal]]) ,ADTExp "Cons" [ VarExp "Idle" ,ConstExp NilVal]]) ,ConstExp NilVal]])) ,ConstExp NilVal]]) ,ConstExp NilVal]]]))]
前面兩部分是我把在其餘模塊定義的 declares,選擇性地拿過來兩條。第三部分是這我的形怪 AI 的整個的 AST。其中嵌套的 Cons 展開以後就是語言內置的 List。
正如咱們以前所說,作代碼生成以前須要進行一步類型檢查的工做。類型檢查工具其輸入是 AST 其輸出是一個檢查結果,同時還能夠提供 AST 中的一些輔助信息,包括各標識符的類型信息等等。
類型檢查其實主要的邏輯在於處理 Appliacative Type,這中間還有個類型推導的邏輯。形如 (\a (Func a)) 10,AST 中並不記錄 a 的 type,咱們的 DSL 也不須要支持 concept、typeclass 等有關 type、subtype 的複雜機制,推導的時候只須要着重處理 AppExp,把右邊表達式的類型求出,合併一下 env 傳給左邊表達式遞歸檢查便可。
exp_type :: Exp -> TypeEnv -> Maybe Type exp_type (AppExp lexp aexp) env = (exp_type aexp env) >>= (\at -> case lexp of LambdaExp (VarPat var) exp -> (merge_type_env (Just env) (make_type_env var (Just at))) >>= (\env1 -> exp_type lexp env1) _ -> (exp_type lexp env) >>= (\ltype -> check_type ltype at)) where check_type (AppType (t1:(t2:[]))) at = if t1 == at then (Just t2) else Nothing check_type (AppType (t:ts)) at = if t == at then (Just (AppType ts)) else Nothing exp_type :: Exp -> TypeEnv -> Maybe Type exp_type (AppExp lexp aexp) env = (exp_type aexp env) >>= (\at -> case lexp of LambdaExp (VarPat var) exp -> (merge_type_env (Just env) (make_type_env var (Just at))) >>= (\env1 -> exp_type lexp env1) _ -> (exp_type lexp env) >>= (\ltype -> check_type ltype at)) where check_type (AppType (t1:(t2:[]))) at = if t1 == at then (Just t2) else Nothing check_type (AppType (t:ts)) at = if t == at then (Just (AppType ts)) else Nothing
此外,還須要有一個通用的 CodeGenerator 模塊,其輸入也是 AST,其輸出是另外一些 AST 中的輔助信息,主要是註記下各標識符的 import 源以及具體的 define 內容,用來方便各目標語言 CodeGenerator 直接複用邏輯。
目標語言的 CodeGenerator 目前只作了 C# 的。
目標代碼生成的邏輯就比較簡單了,畢竟該有的信息前面的各模塊都提供了,這裏根據以前一個版本的 runtime,代碼生成的大體樣子:
public static IO<Result> Root = Prelude.par(Help.MakeList( Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsFleeing) ,(((Box<Object> a) => Help.With(a, BaseAI.GetNearestTarget, Prelude.check(BaseAI.IsNull())))(new Box<Object>())))) ,Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsAttacking) ,(((Box<Float> b) => Help.With(b, BaseAI.GetFleeBloodRate, BaseAI.HpRateLessThan()))(new Box<Float>())))) ,Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsNormal) ,Prelude.loop(Prelude.par(Help.MakeList( (((Box<Object> c) => Help.With(c, BaseAI.GetNearestTarget, Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsNull()) ,BaseAI.LockTarget()))))(new Box<Object>())) ,Prelude.seq(Help.MakeList( Prelude.seq(Help.MakeList( Prelude.check(BaseAI.ReachCurrentPatrolPoint) ,BaseAI.MoveToNextPatrolPoiont)) ,BaseAI.Idle))))))))) public static IO<Result> Root = Prelude.par(Help.MakeList( Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsFleeing) ,(((Box<Object> a) => Help.With(a, BaseAI.GetNearestTarget, Prelude.check(BaseAI.IsNull())))(new Box<Object>())))) ,Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsAttacking) ,(((Box<Float> b) => Help.With(b, BaseAI.GetFleeBloodRate, BaseAI.HpRateLessThan()))(new Box<Float>())))) ,Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsNormal) ,Prelude.loop(Prelude.par(Help.MakeList( (((Box<Object> c) => Help.With(c, BaseAI.GetNearestTarget, Prelude.seq(Help.MakeList( Prelude.check(BaseAI.IsNull()) ,BaseAI.LockTarget()))))(new Box<Object>())) ,Prelude.seq(Help.MakeList( Prelude.seq(Help.MakeList( Prelude.check(BaseAI.ReachCurrentPatrolPoint) ,BaseAI.MoveToNextPatrolPoiont)) ,BaseAI.Idle)))))))))
總的來講,大體分爲這幾個模塊:Parser、TypeChecker、CodeGenerator、目標語言的 CodeGenerator。再加上目標語言的 runtime,基本上就能夠組成這個 DSL 的所有了。
對比 DSL,咱們能夠發現,DSL 支持的特性要比以前實現的 runtime 版本多。好比:
runtime 中壓根就沒有 Closure 的概念,可是 DSL 中咱們是徹底能夠把一個 lambda 做爲一個 ClosureVal 傳給某個函數的。
缺乏對標準庫的支持。好比經常使用的 math 函數。 基於上面這點,還會引入一個 With 結點的性能問題,在只有 runtime 的時候咱們也許不會 With a <- 1+1。可是 DSL 中是有可能這樣的,並且生成出來的代碼會每次 run 這棵樹的時候都會從新計算一次 1+1。
針對第一個問題,咱們要作的工做就多了。首先咱們要記錄下這個閉包 hold 住的自由變量,要傳給 runtime,runtime 也要記錄,也要作各類各類,想一想都麻煩,並且徹底偏離了遊戲 AI 的話題,再也不討論。
針對第二個問題,咱們能夠經過解決第三個問題來順便解決這個問題。
針對第三個問題,咱們從新審視一下 With 語義。
把一個可能會 Continue/Lazy Evaluation 的計算結果,綁定到一個 variable 上,對於 With 下面的子表達式來講,這個 variable 的值具備 lexical scope。
可是在 runtime 中,咱們按照以前的寫法,subtree 中直接就進行了函數調用,很顯然是存在問題的。
With 結點自己的返回值不必定只是一個 IO<Result>,有多是一個 IO<float>。
((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>()) ((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<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 也許會被設計成這樣:
public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b) { return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue()); } public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b) { return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue()); }
若是 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 這種狀況的傳遞,應該具備這樣的特徵:
有了 pure 與 impure 的標記,咱們在對函數調用的時候,就須要額外走一層。
原本一個普通的函數調用,好比 UnitAI.Func(p0, p1, p2) 與 Math.Plus(p0, p1)。前者返回一種 computing 是毫無疑問的,後者就須要根據參數的類型來決定是返回一種計算仍是直接的值。
爲了不在這個 Plus 裏面改來改去,咱們把 Closure 這個概念給抽象出來。同時,爲了簡化討論,咱們只列舉 T0 -> TR 這一種狀況,對應的標準庫函數取 Abs。
public class Closure<T0, TR> : Thunk<Closure<T0, TR>> { class UserFuncApply : Thunk<TR> { private Closure<T0, TR> func; private Thunk<T0> p0; public UserFuncApply(Closure<T0, TR> func, Thunk<T0> p0) { this.func = func; this.p0 = p0; this.pure = false; } public override TR GetUserValue() { return func.funcThunk(p0).GetUserValue(); } } private bool isUserFunc = false; private FuncThunk<T0, TR> funcThunk; private Func<T0, TR> userFunc; public Closure(FuncThunk<T0, TR> funcThunk) { this.funcThunk = funcThunk; } public Closure(Func<T0, TR> func) { this.userFunc = func; this.funcThunk = p0 => Help.MakePureThunk(userFunc(p0.GetUserValue())); this.isUserFunc = true; } public override Closure<T0, TR> GetUserValue() { return this; } public Thunk<TR> Apply(Thunk<T0> p0) { if (!isUserFunc || Help.AllPure(p0)) { return funcThunk(p0); } return new UserFuncApply(this, p0); } } public class Closure<T0, TR> : Thunk<Closure<T0, TR>> { class UserFuncApply : Thunk<TR> { private Closure<T0, TR> func; private Thunk<T0> p0; public UserFuncApply(Closure<T0, TR> func, Thunk<T0> p0) { this.func = func; this.p0 = p0; this.pure = false; } public override TR GetUserValue() { return func.funcThunk(p0).GetUserValue(); } } private bool isUserFunc = false; private FuncThunk<T0, TR> funcThunk; private Func<T0, TR> userFunc; public Closure(FuncThunk<T0, TR> funcThunk) { this.funcThunk = funcThunk; } public Closure(Func<T0, TR> func) { this.userFunc = func; this.funcThunk = p0 => Help.MakePureThunk(userFunc(p0.GetUserValue())); this.isUserFunc = true; } public override Closure<T0, TR> GetUserValue() { return this; } public Thunk<TR> Apply(Thunk<T0> p0) { if (!isUserFunc || Help.AllPure(p0)) { return funcThunk(p0); } return new UserFuncApply(this, p0); } }
其中,UserFuncApply 就是以前所說的一層計算的概念。UserFunc 表示的是等效於能夠編譯期計算的一種標準庫函數。
這樣定義:
public static class Math { public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs); } public static class Math { public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs); }
Message 類型的 Closure 構造,都走 FuncThunk 構造函數;普通函數類型的構造,走 Func 構造函數,而且包裝一層。
Help.Apply 是爲了方便作代碼生成,描述一種 declarative 的 Application。其實就是直接調用 Closure 的 Apply。
考慮如下幾種 case:
public void Test() { var box1 = new Box<float>(); // Math.Abs(box1) -> UserFuncApply // 在GetUserValue的時候纔會求值 var ret1 = Help.Apply(Math.Abs, box1); // Math.Abs(0.2f) -> Thunk<float> // 直接構造出來了一個Thunk<float>(0.2f) var ret2 = Help.Apply(Math.Abs, Help.MakePureThunk(0.2f)); // UnitAISets<IUnit>.HpRateLessThan(box1) -> Message var ret3 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, box1); // UnitAISets<IUnit>.HpRateLessThan(0.2f) -> Message var ret4 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, Help.MakePureThunk(0.2f)); }
public void Test() { var box1 = new Box<float>(); // Math.Abs(box1) -> UserFuncApply // 在GetUserValue的時候纔會求值 var ret1 = Help.Apply(Math.Abs, box1); // Math.Abs(0.2f) -> Thunk<float> // 直接構造出來了一個Thunk<float>(0.2f) var ret2 = Help.Apply(Math.Abs, Help.MakePureThunk(0.2f)); // UnitAISets<IUnit>.HpRateLessThan(box1) -> Message var ret3 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, box1); // UnitAISets<IUnit>.HpRateLessThan(0.2f) -> Message var ret4 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, Help.MakePureThunk(0.2f)); }
與以前的 runtime 版本惟一表現上有區別的地方在於,對於純 pure 參數的 userFunc,在 Apply 完以後會直接計算出來值,並從新包裝成一個 Thunk;而對於參數中有 impure 的狀況,返回一個 UserFuncApply,在 GetUserValue 的時候纔會求值。
到目前爲止,已經造成了一套基本的、non-trivial 的遊戲 AI 方案,固然後續還有不少要作的工做,好比:
AI 的配置也須要有編輯器,這個編輯器至少能實現的需求有這樣幾個:
與本身定義的中間層對接良好(配置文件也好、DSL 也好),具備 codegen 功能
支持工做空間、支持模塊化定義,製做一些 prefab 什麼的
支持可視化調試