遊戲中人工智能的優化

創建一個簡單的遊戲引擎和人工智能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

葉子結點也能夠概括一下 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 模塊中對遊戲世界的數據進行操做,請求一個數據 => 處理一下 => 返回一個數據,更具擴展性。app

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 什麼的
  • 支持可視化調試

心動了嗎?還不趕忙動起來,打造屬於本身的遊戲世界!頓時滿滿的自豪感,真的很想知道你們的想法,還請持續關注更新,更多幹貨和資料請直接聯繫我,也能夠加羣710520381,邀請碼:柳貓,歡迎你們共同討論

相關文章
相關標籤/搜索