寫在前面
從一個簡單的情景開始
- 大部分情況下,漫無目的巡邏。
- 玩家進入視野,鎖定玩家為目標開始攻擊。
- Hp低到一定程度,怪會想法設法逃跑,並說幾句話。
- 不再考慮entity之間的消息傳遞機制,例如判斷玩家進入視野,不再通過事件機制觸發,而是通過該人形怪的輪詢觸發。
- 不再考慮entity的行為控制機制,簡化這個entity的控制模型。不論是底層是基於SteeringBehaviour或者是瞬移,不論是異步驅的還是主循環輪詢,都不在本文模型的討論之列。
1 public interface IUnit 2 { 3 void ChangeState(UnitStateEnum state); 4 void Patrol(); 5 IUnit GetNearestTarget(); 6 void LockTarget(IUnit unit); 7 float GetFleeBloodRate(); 8 bool CanMove(); 9 bool HpRateLessThan(float rate); 10 void Flee(); 11 void Speak(); 12 }
- 巡邏狀態: 會執行巡邏,同時檢查是否有敵對單位接近,接近的話進入戰斗狀態。
- 戰斗狀態: 會執行戰斗,同時檢查自己的血量是否達到逃跑線以下,達成檢查了就會逃跑。
- 逃跑狀態: 會逃跑,同時說一次話。
1 public interface IState<TState, TUnit> where TState : IConvertible 2 { 3 TState Enum { get; } 4 TUnit Self { get; } 5 void OnEnter(); 6 void Drive(); 7 void OnExit(); 8 }
以逃跑狀態為例:
1 public class FleeState : UnitStateBase 2 { 3 public FleeState(IUnit self) : base(UnitStateEnum.Flee, self) 4 { 5 } 6 public override void OnEnter() 7 { 8 Self.Flee(); 9 } 10 public override void Drive() 11 { 12 var unit = Self.GetNearestTarget(); 13 if (unit != null) 14 { 15 return; 16 } 17 18 Self.ChangeState(UnitStateEnum.Patrol); 19 } 20 }
決策邏輯與上下文分離
針對這一點,我們做一下優化。對這個狀態機,把Context完全剝離出來。
1 public interface IState<TState, TUnit> where TState : IConvertible 2 { 3 TState Enum { get; } 4 void OnEnter(TUnit self); 5 void Drive(TUnit self); 6 void OnExit(TUnit self); 7 }
還是拿之前實現好的逃跑狀態作為例子:
1 public class FleeState : UnitStateBase 2 { 3 public FleeState() : base(UnitStateEnum.Flee) 4 { 5 } 6 public override void OnEnter(IUnit self) 7 { 8 base.OnEnter(self); 9 self.Flee(); 10 } 11 public override void Drive(IUnit self) 12 { 13 base.Drive(self); 14 15 var unit = self.GetNearestTarget(); 16 if (unit != null) 17 { 18 return; 19 } 20 21 self.ChangeState(UnitStateEnum.Patrol); 22 } 23 }
分層有限狀態機
- 因為父狀態需要關注子狀態的運行結果,所以狀態的Drive接口需要一個運行結果的返回值。
- 子狀態一定是由父狀態驅動的。
考慮這樣一個組合狀態情景:巡邏時,需要依次得先走到一個點,然后怠工一會兒,再走到下一個點,然后再怠工一會兒,循環往復。這樣就需要父狀態(巡邏狀態)注記當前激活的子狀態,並且根據子狀態執行結果的不同來修改激活的子狀態集合。這樣不僅是Unit自身有上下文,連組合狀態也有了自己的上下文。
為了簡化討論,我們還是從non-ContextFree層次狀態機系統設計開始。
1 public interface IState<TState, TCleverUnit, TResult> 2 where TState : IConvertible 3 { 4 // ... 5 TResult Drive(); 6 // ... 7 }
組合狀態的定義:
1 public abstract class UnitCompositeStateBase : UnitStateBase 2 { 3 protected readonly LinkedList<UnitStateBase> subStates = new LinkedList<UnitStateBase>(); 4 5 // ... 6 protected Result ProcessSubStates() 7 { 8 if (subStates.Count == 0) 9 { 10 return Result.Success; 11 } 12 13 var front = subStates.First; 14 var res = front.Value.Drive(); 15 16 if (res != Result.Continue) 17 { 18 subStates.RemoveFirst(); 19 } 20 21 return Result.Continue; 22 } 23 // ... 24 } 25
1 public class PatrolState : UnitCompositeStateBase 2 { 3 // ... 4 public override void OnEnter() 5 { 6 base.OnEnter(); 7 AddSubState(new MoveToState(Self)); 8 } 9 10 public override Result Drive() 11 { 12 if (subStates.Count == 0) 13 { 14 return Result.Success; 15 } 16 17 var unit = Self.GetNearestTarget(); 18 if (unit != null) 19 { 20 Self.LockTarget(unit); 21 return Result.Success; 22 } 23 24 var front = subStates.First; 25 var ret = front.Value.Drive(); 26 27 if (ret != Result.Continue) 28 { 29 if (front.Value.Enum == CleverUnitStateEnum.MoveTo) 30 { 31 AddSubState(new IdleState(Self)); 32 } 33 else 34 { 35 AddSubState(new MoveToState(Self)); 36 } 37 } 38 39 return Result.Continue; 40 } 41 }
分層的上下文
我們對之前重構出來的層次狀態機框架再進行一次Context分離優化。
- 首先是繼續之前的,unit不應該作為一個state自己的內部status。
- 組合狀態的實例內部不應該包括自身執行的status。目前的組合狀態,可以動態增刪子狀態,也就是根據status決定了結構的狀態,理應分離靜態與動態。巡邏狀態組合了兩個子狀態——A和B,邏輯中是一個完成了就添加另一個,這樣一想的話,其實巡邏狀態應該重新描述——先進行A,再進行B,循環往復。
- 由於有了父狀態的概念,其實狀態接口的設計也可以再迭代,理論上只需要一個drive即可。因為狀態內部的上下文要全部分離出來,所以也沒必要對外提供OnEnter、OnExit,提供這兩個接口的意義只是做一層內部信息的隱藏,但是現在內部的status沒了,也就沒必要隱藏了。
- 一部分是entity本身的status,這里可以簡單的認為是unit。
- 另一部分是state本身的status。
- 對於組合狀態,這個status描述的是我當前執行到哪個substate。
- 對於原子狀態,這個status描述的種類可能有所區別。
- 例如MoveTo/Flee,OnEnter的時候,修改了unit的status,然后Drive的時候去check。
- 例如Idle,OnEnter時改了自己的status,然后Drive的時候去check。
- 父狀態A,子狀態B。
- 子狀態B向上返回Continue的同時,status記錄下來為b。
- 父狀態ADrive子狀態的結果為Continue,自身也需要向上拋出Continue,同時自己也有status為a。
1 public class Continuation 2 { 3 public Continuation SubContinuation { get; set; } 4 public int NextStep { get; set; } 5 public object Param { get; set; } 6 } 7 8 public class Context<T> 9 { 10 public Continuation Continuation { get; set; } 11 public T Self { get; set; } 12 }
1 public interface IState<TCleverUnit, TResult> 2 { 3 TResult Drive(Context<TCleverUnit> ctx); 4 }
1 public class PatrolState : IState<ICleverUnit, Result> 2 { 3 private readonly List<IState<ICleverUnit, Result>> subStates; 4 public PatrolState() 5 { 6 subStates = new List<IState<ICleverUnit, Result>>() 7 { 8 new MoveToState(), 9 new IdleState(), 10 }; 11 } 12 public Result Drive(Context<ICleverUnit> ctx) 13 { 14 var unit = ctx.Self.GetNearestTarget(); 15 if (unit != null) 16 { 17 ctx.Self.LockTarget(unit); 18 19 return Result.Success; 20 } 21 22 var nextStep = 0; 23 if (ctx.Continuation != null) 24 { 25 // Continuation 26 var thisContinuation = ctx.Continuation; 27 28 ctx.Continuation = thisContinuation.SubContinuation; 29 30 var ret = subStates[nextStep].Drive(ctx); 31 32 if (ret == Result.Continue) 33 { 34 thisContinuation.SubContinuation = ctx.Continuation; 35 ctx.Continuation = thisContinuation; 36 37 return Result.Continue; 38 } 39 else if (ret == Result.Failure) 40 { 41 ctx.Continuation = null; 42 43 return Result.Failure; 44 } 45 46 ctx.Continuation = null; 47 nextStep = thisContinuation.NextStep + 1; 48 } 49 50 for (; nextStep < subStates.Count; nextStep++) 51 { 52 var ret = subStates[nextStep].Drive(ctx); 53 if (ret == Result.Continue) 54 { 55 ctx.Continuation = new Continuation() 56 { 57 SubContinuation = ctx.Continuation, 58 NextStep = nextStep, 59 }; 60 61 return Result.Continue; 62 } 63 else if (ret == Result.Failure) 64 { 65 ctx.Continuation = null; 66 67 return Result.Failure; 68 } 69 } 70 71 ctx.Continuation = null; 72 73 return Result.Success; 74 } 75 }
subStates是readonly的,在組合狀態構造的一開始就確定了值。這樣結構本身就是靜態的,而上下文是動態的。不同的entity instance共用同一個樹的instance。
語義結點的抽象
- 巡邏結點,不考慮觸發進入戰斗的邏輯,可以歸納為一種具有這樣的行為的組合結點:依次執行每個子結點(移動到某個點、休息一會兒),某個子結點返回Success則執行下一個,返回Failure則直接向上返回,返回Continue就把Continuation拋出去。命名具有這樣語義的結點為Sequence。
- 設想攻擊狀態下,單位需要同時進行兩種子結點的嘗試,一個是釋放技能,一個是說話。兩個需要同時執行,並且結果獨立。有一個返回Success則向上返回Success,全部Failure則返回Failure,否則返回Continue。命名具有如此語義的結點為Parallel。
- 在Parallel的語義基礎上,如果要體現一個優先級/順序性質,那么就需要一個具有依次執行子結點語義的組合結點,命名為Select。
- Flee、Idle、MoveTo三個狀態,狀態進入的時候調一下宿主的某個函數,申請開始一個持續性的動作。
- 四個原子狀態都有的一個pattern,就是在Drive中輪詢,直到某個條件達成了才返回。
- Attack狀態內部,每次都輪詢都會向宿主請求一個數據,然后再判斷這個“外部”數據是否滿足一定條件。
- 一種實現是宿主的API本身就是一個返回Result的函數,第一次調用的時候,宿主會改變自己的狀態,比如設置單位開始移動,之后每幀都會驅動這個單位移動,而AI模塊再去調用MoveTo就會拿到一個Continue,直到宿主這邊內部驅動單位移動到目的地,即向上返回Success;發生無法讓單位移動完成的情況,就返回Failure。
- 另一種實現是宿主提供一些基本的查詢API,比如移動到某一點、是否到達某個點、獲得下一個巡邏點,這樣的話就相當於是把輪詢判斷寫在了AI模塊里。這樣就需要有一個Check結點,來包裹這個查詢到的值,向上返回一個IO類型的值。
AI模塊與游戲世界的數據互操作
- ioget與subtree共同hold住一個變量,ioget求得的值賦給這個變量,subtree構造的時候直接把值傳進來。
- ioget與subtree共同hold住一個env,雙方約定統一的key,ioget求完就把這個key設置一下,subtree構造的時候直接從env里根據key取值。
1 public interface IO<T> 2 { 3 T Drive(Context ctx); 4 }
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結點的實現,采用我們之前說的第一種方案:
1 public class With<T, TR> : IO<TR> 2 { 3 // ... 4 public TR Drive(Context ctx) 5 { 6 var thisContinuation = ctx.Continuation; 7 var value = default(T); 8 var skipIoGet = false; 9 10 if (thisContinuation != null) 11 { 12 // Continuation 13 ctx.Continuation = thisContinuation.SubContinuation; 14 15 // 0表示需要繼續ioGet 16 // 1表示需要繼續subTree 17 if (thisContinuation.NextStep == 1) 18 { 19 skipIoGet = true; 20 value = (T) thisContinuation.Param; 21 } 22 } 23 24 if (!skipIoGet) 25 { 26 value = ioGet.Drive(ctx); 27 28 if (ctx.Continuation != null) 29 { 30 // ioGet拋出了Continue 31 if (thisContinuation == null) 32 { 33 thisContinuation = new Continuation() 34 { 35 SubContinuation = ctx.Continuation, 36 NextStep = 0, 37 }; 38 } 39 else 40 { 41 thisContinuation.SubContinuation = ctx.Continuation; 42 thisContinuation.NextStep = 0; 43 } 44 45 ctx.Continuation = thisContinuation; 46 47 return default(TR); 48 } 49 } 50 51 var oldValue = box.SetVal(value); 52 var ret = subTree.Drive(ctx); 53 54 box.SetVal(oldValue); 55 56 if (ctx.Continuation != null) 57 { 58 // subTree拋出了Continue 59 if (thisContinuation == null) 60 { 61 thisContinuation = new Continuation() 62 { 63 SubContinuation = ctx.Continuation, 64 }; 65 } 66 67 ctx.Continuation = thisContinuation; 68 thisContinuation.Param = value; 69 } 70 71 return ret; 72 } 73 }
這樣,我們的層次狀態機就全部組件化了。我們可以用通用的語義結點來組合出任意的子狀態,這些子狀態是不具名的,對構建過程更友好。
具體的代碼例子:
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(),現在卻需要自己用代碼拼接出來一個行為邏輯。但是仔細想一下,改成這樣的描述其實對整個工作流是有好處的。之前的形式完全是硬編碼,而現在,似乎讓我們看到了轉數據驅動的可能性。
對行為結點做包裝
#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
public abstract class Thunk<T> { public abstract T GetUserValue(); }
((Box<IO<Result>> a) => With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
- instance a,執行完IOGet之后,結構變為Negate(A)。
- instance b,再執行IOGet,拿到一個B,設置box里的值為B,並且拿出來A,這時候再run subtree,其實就是按Negate(B)來跑的。
public abstract class IO<T> : Thunk<IO<T>> { public abstract T Drive(Context ctx); public override IO<T> GetUserValue() { return this; } }
BehaviourTree
- prioritized-list : 每次執行優先級最高的結點,高優先級的始終搶占低優先級的。
- sequential : 按順序執行每個子結點,執行完最后一個子結點后,父結點就finished。
- sequential-looping : 同上,但是會loop。
- probabilistic : 從子結點中隨機選擇一個執行。
- one-off : 從子結點中隨機選擇或按優先級選擇,選擇一個排除一個,直到執行完為止。
- Wait :子樹返回Success的時候向上Success,否則向上Continue。
- Forever : 永遠返回Continue。
- If-Else、Switch-Cond : 對於有編程功底的我想就不需要再多做解釋了。
- forcedXX : 對子樹結果強制取值。
略是什么
DSL
游戲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實現方案
(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)))
(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的聲明方式變了下。
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
比如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"
基於這些,我們可以做組裝出來一個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))
有了所有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"
其中,listplus是一種具有優先級的lookahead:
listplus :: [Parser a] -> Parser a listplus lst = foldr (<|>) mzero (map try lst)
-- 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。
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
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)))))))))
再擴展runtime
- runtime中壓根就沒有Closure的概念,但是DSL中我們是完全可以把一個lambda作為一個ClosureVal傳給某個函數的。
- 缺少對標准庫的支持。比如常用的math函數。
- 基於上面這點,還會引入一個With結點的性能問題,在只有runtime的時候我們也許不會With a <- 1+1。但是DSL中是有可能這樣的,而且生成出來的代碼會每次run這棵樹的時候都會重新計算一次1+1。
((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>())
- 對UnitAI,也就是外部世界的定義的接口的調用。這種調用,對於AI模塊來說,本質上是pure的,所以不需要考慮這個延遲計算的問題
- 對標准庫的調用
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); } }
其中,UserFuncApply就是之前所說的一層計算的概念。UserFunc表示的是等效於可以編譯期計算的一種標准庫函數。
這樣定義:
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)); }
與之前的runtime版本唯一表現上有區別的地方在於,對於純pure參數的userFunc,在Apply完之后會直接計算出來值,並重新包裝成一個Thunk;而對於參數中有impure的情況,返回一個UserFuncApply,在GetUserValue的時候才會求值。
TODO
- DSL中支持注釋、函數作為普通的value傳遞等等。
- parser、typechecker支持更完善的錯誤處理,我之前單獨寫一個用例的時候,就因為一些細節問題,調試了老半天。
- 標准庫支持更多,比如Y-Combinator
- 與自己定義的中間層對接良好(配置文件也好、DSL也好),具有codegen功能
- 支持工作空間、支持模塊化定義,制作一些prefab什么的
- 支持可視化調試
開通了一個微信公眾號,以后會將一些技術文章發到這個公眾號里,博客不管看起來還是寫起來都挺累的,謝謝支持!