遊戲中人工智能的優化 原 薦

建立一個簡單的遊戲引擎和人工智能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 什麼的
  • 支持可視化調試

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章