建立一個簡單的遊戲引擎和人工智能NPC後,我們需要對他們進行優化,如何建立,可以參考我在評論裏的鏈接
語義結點的抽象
不過我們在這篇博客的討論中是不能僅停留在能解決需求的層面上。目前的方案至少還存在一個比較嚴重的問題,那就是邏輯複用性太差。組合狀態需要 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,有兩個方法:
-
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();
}
}
With 結點的實現,採用我們之前說的第一種方案:
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。
再次改造 IO:
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;
}
}
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,策劃的工作量除了配關卡就是配表。 但是,配表無法自我進化 ,配表無法自己描述流程是什麼樣,而是流程在描述配表是什麼樣。
針對策劃配置 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 語句可能類似於這樣:
(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,實在方便。
先是一些 AST 結構的預定義:
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。
例如,haskell 的 ParseC 庫就有這樣幾個強大的特性:
- 提供了 char、string,基元的 parse 單個字符或字符串的 parser。
- 提供了 sat,傳一個 predicate,就可以 parse 到符合 predicate 的結果的 parser。
- 提供了 try,支持 parse 過程中的 lookahead 語義。
- 提供了 chainl、chainr,這樣就省的我們在構造 parser 的時候就無需考慮左遞歸了。不過這個我也是寫完了 parser 才瞭解到的,所以基本沒用上,更何況對於 S-expression 來說,需要我來處理左遞歸的情況還是比較少的。 我們可以先根據這些基本的,封裝出來一些通用 combinator。
比如正則規則中的 star:
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 消費。
我們之前舉的 AI 的例子,parse 出來的 AST 大概是這副模樣:
-- 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 的全部了。
再擴展 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>。
舉例:
((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 這種情況的傳遞,應該具有這樣的特徵:
- 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。
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 的時候纔會求值。
TODO
到目前爲止,已經形成了一套基本的、non-trivial 的遊戲 AI 方案,當然後續還有很多要做的工作,比如:
更多的語言特性:
- DSL 中支持註釋、函數作爲普通的 value 傳遞等等。
- parser、typechecker 支持更完善的錯誤處理,我之前單獨寫一個用例的時候,就因爲一些細節問題,調試了老半天。
- 標準庫支持更多,比如 Y-Combinator
編輯器化:
AI 的配置也需要有編輯器,這個編輯器至少能實現的需求有這樣幾個:
-
與自己定義的中間層對接良好(配置文件也好、DSL 也好),具有 codegen 功能
-
支持工作空間、支持模塊化定義,製作一些 prefab 什麼的
-
支持可視化調試