本帖最后由 一元 于 2015-11-18 10:47 編輯
GameRes游資網(wǎng)授權(quán)發(fā)布 文 / 王迅
從一個(gè)簡(jiǎn)單的情景開始
怪物,是游戲中的一個(gè)基本概念,。游戲中的單位分類,,不外乎玩家、NPC,、怪物這幾種,。其中,AI一定是與三類實(shí)體都會(huì)產(chǎn)生交集的游戲模塊之一,。
以我們熟悉的任意一款游戲中的人形怪物為例,,假設(shè)有一種怪物的AI需求是這樣的:
- 大部分情況下,,漫無目的巡邏,。
- 玩家進(jìn)入視野,鎖定玩家為目標(biāo)開始攻擊,。
- Hp低到一定程度,,怪會(huì)想法設(shè)法逃跑,并說幾句話,。
我們以這個(gè)為模型,,進(jìn)行這篇文章之后的所有討論。為了簡(jiǎn)化問題,,以省去一些不必要的討論,,將文章的核心定位到人工智能上,這里需要注意幾點(diǎn)的是:
- 不再考慮entity之間的消息傳遞機(jī)制,,例如判斷玩家進(jìn)入視野,,不再通過事件機(jī)制觸發(fā),而是通過該人形怪的輪詢觸發(fā),。
- 不再考慮entity的行為控制機(jī)制,,簡(jiǎn)化這個(gè)entity的控制模型。不論是底層是基于SteeringBehaviour或者是瞬移,,不論是異步驅(qū)的還是主循環(huán)輪詢,,都不在本文模型的討論之列,。
首先可以很容易抽象出來IUnit:
- public interface IUnit
- {
- void ChangeState(UnitStateEnum state);
- void Patrol();
- IUnit GetNearestTarget();
- void LockTarget(IUnit unit);
- float GetFleeBloodRate();
- bool CanMove();
- bool HpRateLessThan(float rate);
- void Flee();
- void Speak();
- }
復(fù)制代碼
然后,我們可以通過一個(gè)簡(jiǎn)單的有限狀態(tài)機(jī)(FSM)來控制這個(gè)單位的行為,。不同狀態(tài)下,,單位都具有不同的行為準(zhǔn)則,以形成智能體,。
具體來說,,我們可以定義這樣幾種狀態(tài):
- 巡邏狀態(tài): 會(huì)執(zhí)行巡邏,同時(shí)檢查是否有敵對(duì)單位接近,,接近的話進(jìn)入戰(zhàn)斗狀態(tài),。
- 戰(zhàn)斗狀態(tài): 會(huì)執(zhí)行戰(zhàn)斗,同時(shí)檢查自己的血量是否達(dá)到逃跑線以下,,達(dá)成檢查了就會(huì)逃跑,。
- 逃跑狀態(tài): 會(huì)逃跑,同時(shí)說一次話,。
最原始的狀態(tài)機(jī)的代碼:
- public interface IUnit
- {
- void ChangeState(UnitStateEnum state);
- void Patrol();
- IUnit GetNearestTarget();
復(fù)制代碼
以逃跑狀態(tài)為例:- public class FleeState : UnitStateBase
- {
- public FleeState(IUnit self) : base(UnitStateEnum.Flee, self)
- {
- }
- public override void OnEnter()
- {
- Self.Flee();
- }
- public override void Drive()
- {
- var unit = Self.GetNearestTarget();
- if (unit != null)
- {
- return;
- }
-
- Self.ChangeState(UnitStateEnum.Patrol);
- }
- }
復(fù)制代碼
決策邏輯與上下文分離
上述是一個(gè)最簡(jiǎn)單,、最常規(guī)的狀態(tài)機(jī)實(shí)現(xiàn)。估計(jì)只有學(xué)生會(huì)這樣寫,,業(yè)界肯定是沒人這樣寫AI的,,不然游戲怎么死的都不知道。
首先有一個(gè)非常明顯的性能問題:狀態(tài)機(jī)本質(zhì)是描述狀態(tài)遷移的,,并不需要記錄entity的context,,如果entity的context記錄在State上,那么狀態(tài)機(jī)這個(gè)遷移邏輯就需要每個(gè)entity都來一份instance,,這么一個(gè)簡(jiǎn)單的狀態(tài)遷移就需要消耗大約X個(gè)字節(jié),,那么一個(gè)場(chǎng)景1w個(gè)怪,這些都屬于白白消耗的內(nèi)存,。就目前的實(shí)現(xiàn)來看,,具體的一個(gè)State實(shí)例內(nèi)部hold住了Unit,所以State實(shí)例是沒辦法復(fù)用的,。
針對(duì)這一點(diǎn),,我們做一下優(yōu)化。對(duì)這個(gè)狀態(tài)機(jī),,把Context完全剝離出來,。
修改狀態(tài)機(jī)接口定義:
- public interface IState<TState, TUnit> where TState : IConvertible
- {
- TState Enum { get; }
- void OnEnter(TUnit self);
- void Drive(TUnit self);
- void OnExit(TUnit self);
- }
復(fù)制代碼
還是拿之前實(shí)現(xiàn)好的逃跑狀態(tài)作為例子:
- public class FleeState : UnitStateBase
- {
- public FleeState() : base(UnitStateEnum.Flee)
- {
- }
- public override void OnEnter(IUnit self)
- {
- base.OnEnter(self);
- self.Flee();
- }
- public override void Drive(IUnit self)
- {
- base.Drive(self);
- var unit = self.GetNearestTarget();
- if (unit != null)
- {
- return;
- }
- self.ChangeState(UnitStateEnum.Patrol);
- }
- }
復(fù)制代碼
這樣,就區(qū)分了動(dòng)態(tài)與靜態(tài),。靜態(tài)的是狀態(tài)之間的遷移邏輯,,只要不做熱更新,是不會(huì)變的結(jié)構(gòu),。動(dòng)態(tài)的是狀態(tài)遷移過程中的上下文,,根據(jù)不同的上下文來決定,。
分層有限狀態(tài)機(jī)
最原始的狀態(tài)機(jī)方案除了性能存在問題,還有一個(gè)比較嚴(yán)重的問題,。那就是這種狀態(tài)機(jī)框架無法描述層級(jí)結(jié)構(gòu)的狀態(tài),。
假設(shè)需要對(duì)一開始的需求進(jìn)行這樣的擴(kuò)展:怪在巡邏狀態(tài)下有可能進(jìn)入怠工狀態(tài),同時(shí)要求,,怠工狀態(tài)下也會(huì)進(jìn)行進(jìn)入戰(zhàn)斗的檢查,。
這樣的話,雖然在之前的框架下,,單獨(dú)做一個(gè)新的怠工狀態(tài)也可以,,但是仔細(xì)分析一下,我們會(huì)發(fā)現(xiàn),,其實(shí)本質(zhì)上巡邏狀態(tài)只是一個(gè)抽象的父狀態(tài),,其存在的意義就是進(jìn)行戰(zhàn)斗檢查;而具體的是在按路線巡邏還是怠工,,其實(shí)都是巡邏狀態(tài)的一個(gè)子狀態(tài),。
狀態(tài)之間就有了層級(jí)的概念,各自獨(dú)立的狀態(tài)機(jī)系統(tǒng)就無法滿足需求,,需要一種分層次的狀態(tài)機(jī),,原先的狀態(tài)機(jī)接口設(shè)計(jì)就需要徹底改掉了。
在重構(gòu)狀態(tài)框架之前,,需要注意兩點(diǎn):
- 因?yàn)楦笭顟B(tài)需要關(guān)注子狀態(tài)的運(yùn)行結(jié)果,,所以狀態(tài)的Drive接口需要一個(gè)運(yùn)行結(jié)果的返回值。
子狀態(tài),,比如怠工,,一定是有跨幀的需求在的,所以這個(gè)Result,,我們定義為Continue、Sucess,、Failure,。
- 子狀態(tài)一定是由父狀態(tài)驅(qū)動(dòng)的。
考慮這樣一個(gè)組合狀態(tài)情景:巡邏時(shí),,需要依次得先走到一個(gè)點(diǎn),,然后怠工一會(huì)兒,再走到下一個(gè)點(diǎn),,然后再怠工一會(huì)兒,,循環(huán)往復(fù)。這樣就需要父狀態(tài)(巡邏狀態(tài))注記當(dāng)前激活的子狀態(tài),,并且根據(jù)子狀態(tài)執(zhí)行結(jié)果的不同來修改激活的子狀態(tài)集合,。這樣不僅是Unit自身有上下文,,連組合狀態(tài)也有了自己的上下文。
為了簡(jiǎn)化討論,,我們還是從non-ContextFree層次狀態(tài)機(jī)系統(tǒng)設(shè)計(jì)開始,。
修改后的狀態(tài)定義:
- public interface IState<TState, TCleverUnit, TResult>
- where TState : IConvertible
- {
- // ...
- TResult Drive();
- // ...
- }
復(fù)制代碼
組合狀態(tài)的定義:
- public abstract class UnitCompositeStateBase : UnitStateBase
- {
- protected readonly LinkedList<UnitStateBase> subStates = new LinkedList<UnitStateBase>();
- // ...
- protected Result ProcessSubStates()
- {
- if (subStates.Count == 0)
- {
- return Result.Success;
- }
- var front = subStates.First;
- var res = front.Value.Drive();
- if (res != Result.Continue)
- {
- subStates.RemoveFirst();
- }
- return Result.Continue;
- }
- // ...
- }
復(fù)制代碼
巡邏狀態(tài)現(xiàn)在是一個(gè)組合狀態(tài):
- public class PatrolState : UnitCompositeStateBase
- {
- // ...
- public override void OnEnter()
- {
- base.OnEnter();
- AddSubState(new MoveToState(Self));
- }
- public override Result Drive()
- {
- if (subStates.Count == 0)
- {
- return Result.Success;
- }
- var unit = Self.GetNearestTarget();
- if (unit != null)
- {
- Self.LockTarget(unit);
- return Result.Success;
- }
- var front = subStates.First;
- var ret = front.Value.Drive();
- if (ret != Result.Continue)
- {
- if (front.Value.Enum == CleverUnitStateEnum.MoveTo)
- {
- AddSubState(new IdleState(Self));
- }
- else
- {
- AddSubState(new MoveToState(Self));
- }
- }
-
- return Result.Continue;
- }
- }
復(fù)制代碼
看過《游戲人工智能編程精粹》的同學(xué)可能看到這里就會(huì)發(fā)現(xiàn),這種層次狀態(tài)機(jī)其實(shí)就是這本書里講的目標(biāo)驅(qū)動(dòng)的狀態(tài)機(jī),。組合狀態(tài)就是組合目標(biāo),,子狀態(tài)就是子目標(biāo)。父目標(biāo)/狀態(tài)的調(diào)度取決于子目標(biāo)/狀態(tài)的完成情況,。這種狀態(tài)框架與普通的trivial狀態(tài)機(jī)模型的區(qū)別僅僅是增加了對(duì)層次狀態(tài)的支持,,狀態(tài)的遷移還是需要靠顯式的ChangeState來做。
這本書里面的狀態(tài)框架,,每個(gè)狀態(tài)的執(zhí)行status記錄在了實(shí)例內(nèi)部,,不方便后續(xù)的優(yōu)化,我們這里實(shí)現(xiàn)的時(shí)候首先把這個(gè)做成純驅(qū)動(dòng)式的,。但是還不夠?,F(xiàn)在之前的ContextFree優(yōu)化成果已經(jīng)回退掉了,我們還需要補(bǔ)充回來,。
分層的上下文
我們對(duì)之前重構(gòu)出來的層次狀態(tài)機(jī)框架再進(jìn)行一次Context分離優(yōu)化,。
要優(yōu)化的點(diǎn)有這樣幾個(gè):
- 首先是繼續(xù)之前的,unit不應(yīng)該作為一個(gè)state自己的內(nèi)部status,。
- 組合狀態(tài)的實(shí)例內(nèi)部不應(yīng)該包括自身執(zhí)行的status,。目前的組合狀態(tài),可以動(dòng)態(tài)增刪子狀態(tài),,也就是根據(jù)status決定了結(jié)構(gòu)的狀態(tài),,理應(yīng)分離靜態(tài)與動(dòng)態(tài)。巡邏狀態(tài)組合了兩個(gè)子狀態(tài)——A和B,,邏輯中是一個(gè)完成了就添加另一個(gè),,這樣一想的話,其實(shí)巡邏狀態(tài)應(yīng)該重新描述——先進(jìn)行A,,再進(jìn)行B,,循環(huán)往復(fù)。
- 由于有了父狀態(tài)的概念,,其實(shí)狀態(tài)接口的設(shè)計(jì)也可以再迭代,,理論上只需要一個(gè)drive即可。因?yàn)闋顟B(tài)內(nèi)部的上下文要全部分離出來,,所以也沒必要對(duì)外提供OnEnter,、OnExit,提供這兩個(gè)接口的意義只是做一層內(nèi)部信息的隱藏,,但是現(xiàn)在內(nèi)部的status沒了,,也就沒必要隱藏了,。
具體分析一下需要拆出的status:
- 一部分是entity本身的status,這里可以簡(jiǎn)單的認(rèn)為是unit,。
對(duì)于組合狀態(tài),這個(gè)status描述的是我當(dāng)前執(zhí)行到哪個(gè)substate,。
對(duì)于原子狀態(tài),,這個(gè)status描述的種類可能有所區(qū)別。
例如MoveTo/Flee,,OnEnter的時(shí)候,,修改了unit的status,然后Drive的時(shí)候去check,。
例如Idle,,OnEnter時(shí)改了自己的status,然后Drive的時(shí)候去check,。
經(jīng)過總結(jié),,我們可以發(fā)現(xiàn),每個(gè)狀態(tài)的status本質(zhì)上都可以通過一個(gè)變量來描述,。一個(gè)State作為一個(gè)最小粒度的單元,,具有這樣的Concept: 輸入一個(gè)Context,輸出一個(gè)Result,。
Context暫時(shí)只需要包括這個(gè)Unit,,和之前所說的status。同時(shí),,考慮這樣一個(gè)問題:
- 父狀態(tài)A,,子狀態(tài)B。
- 子狀態(tài)B向上返回Continue的同時(shí),,status記錄下來為b,。
- 父狀態(tài)ADrive子狀態(tài)的結(jié)果為Continue,自身也需要向上拋出Continue,,同時(shí)自己也有status為a,。
這樣,再還原現(xiàn)場(chǎng)時(shí),,就需要即給A一個(gè)a,還需要讓A有能力從Context中拿到需要給B的b,。因此上下文的結(jié)構(gòu)理應(yīng)是遞歸定義的,,是一個(gè)層級(jí)結(jié)構(gòu)。
Context如下定義:
- public class Continuation
- {
- public Continuation SubContinuation { get; set; }
- public int NextStep { get; set; }
- public object Param { get; set; }
- }
- public class Context<T>
- {
- public Continuation Continuation { get; set; }
- public T Self { get; set; }
- }
復(fù)制代碼
修改State的接口定義為:
- public interface IState<TCleverUnit, TResult>
- {
- TResult Drive(Context<TCleverUnit> ctx);
- }
復(fù)制代碼
已經(jīng)相當(dāng)簡(jiǎn)潔了,。
這樣,,我們對(duì)之前的巡邏狀態(tài)也做下修改,,達(dá)到一個(gè)ContextFree的效果。利用Context中的Continuation來確定當(dāng)前結(jié)點(diǎn)應(yīng)該從什么狀態(tài)繼續(xù):
- public class PatrolState : IState<ICleverUnit, Result>
- {
- private readonly List<IState<ICleverUnit, Result>> subStates;
- public PatrolState()
- {
- subStates = new List<IState<ICleverUnit, Result>>()
- {
- new MoveToState(),
- new IdleState(),
- };
- }
- public Result Drive(Context<ICleverUnit> ctx)
- {
- var unit = ctx.Self.GetNearestTarget();
- if (unit != null)
- {
- ctx.Self.LockTarget(unit);
- return Result.Success;
- }
- var nextStep = 0;
- if (ctx.Continuation != null)
- {
- // Continuation
- var thisContinuation = ctx.Continuation;
- ctx.Continuation = thisContinuation.SubContinuation;
- var ret = subStates[nextStep].Drive(ctx);
- if (ret == Result.Continue)
- {
- thisContinuation.SubContinuation = ctx.Continuation;
- ctx.Continuation = thisContinuation;
- return Result.Continue;
- }
- else if (ret == Result.Failure)
- {
- ctx.Continuation = null;
- return Result.Failure;
- }
- ctx.Continuation = null;
- nextStep = thisContinuation.NextStep + 1;
- }
- for (; nextStep < subStates.Count; nextStep++)
- {
- var ret = subStates[nextStep].Drive(ctx);
- if (ret == Result.Continue)
- {
- ctx.Continuation = new Continuation()
- {
- SubContinuation = ctx.Continuation,
- NextStep = nextStep,
- };
- return Result.Continue;
- }
- else if (ret == Result.Failure)
- {
- ctx.Continuation = null;
-
- return Result.Failure;
- }
- }
- ctx.Continuation = null;
- return Result.Success;
- }
- }
復(fù)制代碼
subStates是readonly的,,在組合狀態(tài)構(gòu)造的一開始就確定了值,。這樣結(jié)構(gòu)本身就是靜態(tài)的,而上下文是動(dòng)態(tài)的,。不同的entity instance共用同一個(gè)樹的instance,。
語義結(jié)點(diǎn)的抽象
優(yōu)化到這個(gè)版本,至少在性能上已經(jīng)符合要求了,,所有實(shí)例共享一個(gè)靜態(tài)的狀態(tài)遷移邏輯,。面對(duì)之前提出的需求,也能夠解決,。至少算是一個(gè)經(jīng)過對(duì)《游戲人工智能編程精粹》中提出的目標(biāo)驅(qū)動(dòng)狀態(tài)機(jī)模型優(yōu)化后的一個(gè)符合工業(yè)應(yīng)用標(biāo)準(zhǔn)的AI框架,。拿來做小游戲或者是一些AI很簡(jiǎn)單的游戲已經(jīng)綽綽有余了。
不過我們?cè)谶@篇博客的討論中是不能僅停留在能解決需求的層面上,。目前的方案至少還存在一個(gè)比較嚴(yán)重的問題,,那就是邏輯復(fù)用性太差。組合狀態(tài)需要coding的邏輯太多了,,具體的狀態(tài)內(nèi)部邏輯需要人肉維護(hù),,更可怕的是需要程序員來人肉維護(hù),再多幾個(gè)組合狀態(tài)簡(jiǎn)直不敢想象,。程序員真的沒這么多時(shí)間維護(hù)這些東西好么,。所以我們應(yīng)該嘗試抽象一下組合狀態(tài)是否有一些通用的設(shè)計(jì)pattern。
為了解決這個(gè)問題,,我們?cè)賹?duì)這幾個(gè)狀態(tài)的分析一下,,可以對(duì)結(jié)點(diǎn)類型進(jìn)行一下歸納。
結(jié)點(diǎn)基本上是分為兩個(gè)類型:組合結(jié)點(diǎn),、原子結(jié)點(diǎn),。
如果把這個(gè)狀態(tài)遷移邏輯體看做一個(gè)樹結(jié)構(gòu),那其中組合結(jié)點(diǎn)就是非葉子結(jié)點(diǎn),,原子結(jié)點(diǎn)就是葉子結(jié)點(diǎn),。
對(duì)于組合結(jié)點(diǎn)來說,其行為是可以歸納的,。
- 巡邏結(jié)點(diǎn),,不考慮觸發(fā)進(jìn)入戰(zhàn)斗的邏輯,可以歸納為一種具有這樣的行為的組合結(jié)點(diǎn):依次執(zhí)行每個(gè)子結(jié)點(diǎn)(移動(dòng)到某個(gè)點(diǎn),、休息一會(huì)兒),,某個(gè)子結(jié)點(diǎn)返回Success則執(zhí)行下一個(gè),返回Failure則直接向上返回,返回Continue就把Continuation拋出去,。命名具有這樣語義的結(jié)點(diǎn)為Sequence,。
- 設(shè)想攻擊狀態(tài)下,單位需要同時(shí)進(jìn)行兩種子結(jié)點(diǎn)的嘗試,,一個(gè)是釋放技能,,一個(gè)是說話。兩個(gè)需要同時(shí)執(zhí)行,,并且結(jié)果獨(dú)立,。有一個(gè)返回Success則向上返回Success,全部Failure則返回Failure,,否則返回Continue,。命名具有如此語義的結(jié)點(diǎn)為Parallel。
- 在Parallel的語義基礎(chǔ)上,,如果要體現(xiàn)一個(gè)優(yōu)先級(jí)/順序性質(zhì),,那么就需要一個(gè)具有依次執(zhí)行子結(jié)點(diǎn)語義的組合結(jié)點(diǎn),命名為Select,。
Sequence與Select組合起來,,就能完整的描述一”趟“巡邏,Select(ReactAttack, Sequence(MoveTo, Idle)),,可以直接干掉之前寫的Patrol組合狀態(tài),,組合狀態(tài)直接拿現(xiàn)成的實(shí)現(xiàn)好的語義結(jié)點(diǎn)復(fù)用即可。
組合結(jié)點(diǎn)的抽象問題解決了,,現(xiàn)在我們來看葉子結(jié)點(diǎn),。
葉子結(jié)點(diǎn)也可以歸納一下pattern,能歸納出三種:
- Flee,、Idle,、MoveTo三個(gè)狀態(tài),狀態(tài)進(jìn)入的時(shí)候調(diào)一下宿主的某個(gè)函數(shù),,申請(qǐng)開始一個(gè)持續(xù)性的動(dòng)作,。
- 四個(gè)原子狀態(tài)都有的一個(gè)pattern,就是在Drive中輪詢,,直到某個(gè)條件達(dá)成了才返回,。
- Attack狀態(tài)內(nèi)部,每次都輪詢都會(huì)向宿主請(qǐng)求一個(gè)數(shù)據(jù),,然后再判斷這個(gè)“外部”數(shù)據(jù)是否滿足一定條件,。
pattern確實(shí)是有這么三種,但是葉子結(jié)點(diǎn)自身其實(shí)是兩種,,一種是控制單位做某種行為,,一種是向單位查詢一些信息,,其實(shí)本質(zhì)上是沒區(qū)別的,,只是描述問題的方式不一樣,。
既然我們的最終目標(biāo)是消除掉四個(gè)具體狀態(tài)的定義,轉(zhuǎn)而通過一些通用的語義結(jié)點(diǎn)來描述,,那我們就首先需要想辦法提出一種方案來描述上述的三個(gè)pattern,。
前兩個(gè)pattern其實(shí)是同一個(gè)問題,區(qū)別就在于那些邏輯應(yīng)該放在宿主提供的接口里面做實(shí)現(xiàn),,哪些邏輯應(yīng)該在AI模塊里做實(shí)現(xiàn),。調(diào)用宿主的某個(gè)函數(shù),調(diào)用是一個(gè)瞬間的操作,,直接改變了宿主的status,,但是截止點(diǎn)的判斷就有不同的實(shí)現(xiàn)方式了。
- 一種實(shí)現(xiàn)是宿主的API本身就是一個(gè)返回Result的函數(shù),,第一次調(diào)用的時(shí)候,,宿主會(huì)改變自己的狀態(tài),比如設(shè)置單位開始移動(dòng),,之后每幀都會(huì)驅(qū)動(dòng)這個(gè)單位移動(dòng),,而AI模塊再去調(diào)用MoveTo就會(huì)拿到一個(gè)Continue,直到宿主這邊內(nèi)部驅(qū)動(dòng)單位移動(dòng)到目的地,,即向上返回Success,;發(fā)生無法讓單位移動(dòng)完成的情況,就返回Failure,。
- 另一種實(shí)現(xiàn)是宿主提供一些基本的查詢API,,比如移動(dòng)到某一點(diǎn)、是否到達(dá)某個(gè)點(diǎn),、獲得下一個(gè)巡邏點(diǎn),,這樣的話就相當(dāng)于是把輪詢判斷寫在了AI模塊里。這樣就需要有一個(gè)Check結(jié)點(diǎn),,來包裹這個(gè)查詢到的值,,向上返回一個(gè)IO類型的值。
而針對(duì)第三種pattern,,可以抽象出這樣一種需求情景,,就是:
AI模塊與游戲世界的數(shù)據(jù)互操作
假設(shè)宿主提供了接受參數(shù)的api,提供了查詢接口,,ai模塊需要通過調(diào)用宿主的查詢接口拿到數(shù)據(jù),,再把數(shù)據(jù)傳給宿主來執(zhí)行某種行為。
我們稱這種語義為With,,With用來求出一個(gè)結(jié)點(diǎn)的值,,并合并在當(dāng)前的env中傳遞給子樹,子樹中可以resolve到這個(gè)symbol。
有了With語義,,我們就可以方便的在AI模塊中對(duì)游戲世界的數(shù)據(jù)進(jìn)行操作,,請(qǐng)求一個(gè)數(shù)據(jù) => 處理一下 => 返回一個(gè)數(shù)據(jù),更具擴(kuò)展性,。
With語義的具體需求明確一下就是這樣的:由兩個(gè)子樹來構(gòu)造,,一個(gè)是IOGet,一個(gè)是SubTree,。With會(huì)首先求值IOGet,,然后binding到一個(gè)symbol上,SubTree 可以直接引用這個(gè)symbol,,來當(dāng)做一個(gè)普通的值用,。
然后考慮下實(shí)現(xiàn)方式。
C#中,,子樹要想引用這個(gè)symbol,,有兩個(gè)方法:
- ioget與subtree共同hold住一個(gè)變量,ioget求得的值賦給這個(gè)變量,,subtree構(gòu)造的時(shí)候直接把值傳進(jìn)來,。
- ioget與subtree共同hold住一個(gè)env,雙方約定統(tǒng)一的key,,ioget求完就把這個(gè)key設(shè)置一下,,subtree構(gòu)造的時(shí)候直接從env里根據(jù)key取值。
考慮第一種方法,,hold住的不應(yīng)該是值本身,,因?yàn)闃浔旧硎遣煌瑢?shí)例共享的,而這個(gè)值會(huì)直接影響到子樹的結(jié)構(gòu),。所以應(yīng)該用一個(gè)class instance object對(duì)值包裹一下,。
這樣經(jīng)過改進(jìn)后的第一種方法理論上速度應(yīng)該比env的方式快很多,也方便做一些優(yōu)化,,比如說如果子樹沒有continue就不需要把這個(gè)值存在env中,,比如說由于樹本身的驅(qū)動(dòng)一定是單線程的,不同的實(shí)例可以共用一個(gè)包裹,,執(zhí)行子樹的時(shí)候設(shè)置下包裹中的值,,執(zhí)行完子樹再把包裹中的值還原。
加入了with語義,,就需要重新審視一下IState的定義了,。既然一個(gè)結(jié)點(diǎn)既有可能返回一個(gè)Result,又有可能返回一個(gè)值,,那么就需要這樣一種抽象:
有這樣一種泛化的concept,,他只需要提供一個(gè)drive接口,,接口需要提供一個(gè)環(huán)境env,drive一下,,就可以輸出一個(gè)值,。這個(gè)concept的instance,需要是pure的,,也就是結(jié)果唯一取決于輸入的環(huán)境,。不同次輸入,只要環(huán)境相同,,輸出一定相同。
因?yàn)槊枋龅氖且环N與外部世界的通信,,所以就命名為IO吧:
- public interface IO<T>
- {
- T Drive(Context ctx);
- }
復(fù)制代碼
這樣,,我們之前的所有結(jié)點(diǎn)都應(yīng)該有IO的concept。
之前提出了Parallel,、Sequence,、Select、Check這樣幾個(gè)語義結(jié)點(diǎn),。具體的實(shí)現(xiàn)細(xì)節(jié)就不再細(xì)說了,,簡(jiǎn)單列一下代碼結(jié)構(gòu):
- 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();
- }
- }
復(fù)制代碼
With結(jié)點(diǎn)的實(shí)現(xiàn),采用我們之前說的第一種方案:
- 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表示需要繼續(xù)ioGet
- // 1表示需要繼續(xù)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;
- }
- }
復(fù)制代碼
這樣,,我們的層次狀態(tài)機(jī)就全部組件化了,。我們可以用通用的語義結(jié)點(diǎn)來組合出任意的子狀態(tài),這些子狀態(tài)是不具名的,,對(duì)構(gòu)建過程更友好,。
具體的代碼例子:
- 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))))))
復(fù)制代碼
看起來似乎是變得復(fù)雜了,原來可能只需要一句new XXXState(),,現(xiàn)在卻需要自己用代碼拼接出來一個(gè)行為邏輯,。但是仔細(xì)想一下,改成這樣的描述其實(shí)對(duì)整個(gè)工作流是有好處的,。之前的形式完全是硬編碼,,而現(xiàn)在,似乎讓我們看到了轉(zhuǎn)數(shù)據(jù)驅(qū)動(dòng)的可能性,。
對(duì)行為結(jié)點(diǎn)做包裝
當(dāng)然這個(gè)示例還少解釋了一部分,,就是葉子結(jié)點(diǎn),或者說是行為結(jié)點(diǎn)的定義,。
我們之前對(duì)行為的定義都是在IUnit中,,但是這里顯然不像是之前定義的IUnit。
如果把每個(gè)行為都看做是樹上的一個(gè)與Select,、Sequence等結(jié)點(diǎn)無異的普通結(jié)點(diǎn)的話,,就需要實(shí)現(xiàn)IO的接口,。抽象出一個(gè)計(jì)算的概念,構(gòu)造的時(shí)候可以構(gòu)造出這個(gè)計(jì)算,,然后通過Drive,,來求得計(jì)算中的值。
包裝后的一個(gè)行為的代碼:
- #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
復(fù)制代碼
經(jīng)過包裝的行為結(jié)點(diǎn)的代碼都是有規(guī)律可循的,,所以我們可以比較容易的通過一些代碼生成的機(jī)制來做,。比如通過反射拿到IUnit定義的接口信息,然后直接在這基礎(chǔ)之上做一下包裝,,做出來個(gè)行為結(jié)點(diǎn)的定義,。
現(xiàn)在我們?cè)倩貞浵掠懻撨^的With,構(gòu)造一個(gè)葉子結(jié)點(diǎn)的時(shí)候,,參數(shù)不一定是literal value,,也有可能是經(jīng)過Box包裹過的。所以就需要對(duì)Boax和literal value抽象出來一個(gè)公共的概念,,葉子結(jié)點(diǎn)/行為結(jié)點(diǎn)可以從這個(gè)概念中拿到值,,而行為結(jié)點(diǎn)計(jì)算本身的構(gòu)造也只需要依賴于這個(gè)概念。
我們把這個(gè)概念命名為Thunk,。Thunk包裹一個(gè)值或者一個(gè)box,,而就目前來看,這個(gè)Thunk,,僅需要提供一個(gè)我們可以通過其拿到里面的值的接口就夠了,。
- public abstract class Thunk<T>
- {
- public abstract T GetUserValue();
- }
復(fù)制代碼
對(duì)于常量,我們可以構(gòu)造一個(gè)包裹了常量的thunk,;而對(duì)于box,,其天然就屬于Thunk的concept。
這樣,,我們就通過一個(gè)Thunk的概念,,硬生生把樹中的結(jié)點(diǎn)與值分割成了兩個(gè)概念。這樣做究竟正確不正確呢,?
如果一個(gè)行為結(jié)點(diǎn)的參數(shù)可能有的類型本來就是一些primitive type,,或者是外部世界(相對(duì)于AI世界)的類型,那肯定是沒問題的,。但如果需要支持這樣一種特性:外部世界的函數(shù),,返回值是AI世界的某個(gè)概念,比如一個(gè)樹結(jié)點(diǎn),;而我的AI世界,,希望的是通過這個(gè)外部世界的函數(shù),動(dòng)態(tài)的拿到一個(gè)結(jié)點(diǎn),,再動(dòng)態(tài)的加到我的樹中,,或者再動(dòng)態(tài)的傳給不通的外部世界的函數(shù),,應(yīng)該怎么做?
對(duì)于一顆With子樹(Negate表示對(duì)子樹結(jié)果取反,,Continue仍取Continue):
- ((Box<IO<Result>> a) =>
- With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
復(fù)制代碼
語義需要保證,,這顆子樹執(zhí)行到任意時(shí)刻,都需要是ContextFree的,。
假設(shè)IOGet返回的是一個(gè)普通的值,,確實(shí)是沒問題的。
但是因?yàn)锽ox包裹的可能是任意值,,例如,,假設(shè)IOGet返回的是一個(gè)IO,
- instance a,,執(zhí)行完IOGet之后,,結(jié)構(gòu)變?yōu)镹egate(A)。
- instance b,,再執(zhí)行IOGet,拿到一個(gè)B,,設(shè)置box里的值為B,,并且拿出來A,這時(shí)候再run subtree,,其實(shí)就是按Negate(B)來跑的,。
我們只有把IO本身,做到其就是Thunk這個(gè)Concept,。這樣所有的Message對(duì)象,,都是一個(gè)Thunk。不僅如此,,所以在這個(gè)樹中出現(xiàn)的數(shù)據(jù)結(jié)構(gòu),,理應(yīng)都是一個(gè)Thunk,比如List,。
再次改造IO:
- public abstract class IO<T> : Thunk<IO<T>>
- {
- public abstract T Drive(Context ctx);
- public override IO<T> GetUserValue()
- {
- return this;
- }
- }
復(fù)制代碼
BehaviourTree
對(duì)AI有了解的同學(xué)可能已經(jīng)清楚了,,目前我們實(shí)現(xiàn)的就是一個(gè)行為樹的引擎,并且已經(jīng)基本成型,。到目前為止,,我們接觸過的行為樹語義有:
Sequence、Select,、Parallel,、Check、Negate,。
其中Sequence與Select是兩個(gè)比較基本的語義,,一個(gè)相當(dāng)于邏輯And,,一個(gè)相當(dāng)于邏輯Or。在組合子設(shè)計(jì)中這兩類組合子也比較常見,。
不同的行為樹方案,,對(duì)語義結(jié)點(diǎn)的選擇也不一樣。
比如以前在行為樹這塊比較權(quán)威的一篇halo2的行為樹方案的paper,,里面提到的幾個(gè)常用的組合結(jié)點(diǎn)有這樣幾種:
- prioritized-list : 每次執(zhí)行優(yōu)先級(jí)最高的結(jié)點(diǎn),,高優(yōu)先級(jí)的始終搶占低優(yōu)先級(jí)的。
- sequential : 按順序執(zhí)行每個(gè)子結(jié)點(diǎn),,執(zhí)行完最后一個(gè)子結(jié)點(diǎn)后,,父結(jié)點(diǎn)就finished。
- sequential-looping : 同上,,但是會(huì)loop,。
- probabilistic : 從子結(jié)點(diǎn)中隨機(jī)選擇一個(gè)執(zhí)行。
- one-off : 從子結(jié)點(diǎn)中隨機(jī)選擇或按優(yōu)先級(jí)選擇,,選擇一個(gè)排除一個(gè),,直到執(zhí)行完為止。
而騰訊的behaviac對(duì)組合結(jié)點(diǎn)的選擇除了傳統(tǒng)的Select和Seqence,,halo里面提到的隨機(jī)選擇,,還自己擴(kuò)展了SelectorProbability(雖然看起來像是一個(gè)select,但其實(shí)每次只會(huì)根據(jù)概率選擇一個(gè),,更傾向于halo中的Probabilistic),,SequenceStochastic(隨機(jī)地決定執(zhí)行順序,然后表現(xiàn)起來確實(shí)像是一個(gè)Sequence),。
其他還有各種常用的修飾結(jié)點(diǎn),,比如前文實(shí)現(xiàn)的Check,還有一些比較常用的:
- Wait :子樹返回Success的時(shí)候向上Success,,否則向上Continue,。
- Forever : 永遠(yuǎn)返回Continue。
- If-Else,、Switch-Cond : 對(duì)于有編程功底的我想就不需要再多做解釋了,。
- forcedXX : 對(duì)子樹結(jié)果強(qiáng)制取值。
還有一類屬于特色結(jié)點(diǎn),,雖然通過其他各種方式也都能實(shí)現(xiàn),,但是在行為樹這個(gè)層面實(shí)現(xiàn)的話肯定擴(kuò)展性更強(qiáng)一些,畢竟可以分離一部分程序的職責(zé),。一個(gè)比較典型的應(yīng)用情景是事件驅(qū)動(dòng),,halo的paper中提到了Behaviour Impulse,但是我在在behaviac中并沒有找到類似的概念,。
halo的paper里面還提到了一些比較細(xì)節(jié)的hack技巧,,比如同一顆行為樹可以應(yīng)用不同的Style,,Parameter Creep等等,有興趣的同學(xué)也可以自行研究,。
至此,,行為樹的runtime話題需要告一段落了,畢竟是一項(xiàng)成熟了十幾年的技術(shù),。雖然這是目前游戲AI的標(biāo)配,,但是,只有行為樹的話,,離一個(gè)完整的AI工作流還很遠(yuǎn),。到目前為止,行為樹還都是程序?qū)懗鰜淼?,但是正確來說AI應(yīng)該是由策劃或者AI腳本配出來的,。因此,這篇文章的話題還需要繼續(xù),,我們接下來就討論一下這個(gè)程序與策劃之間的中間層,。
之前的優(yōu)化思路也好,從其他語言借鑒的設(shè)計(jì)pattern也好,,行為樹這種理念本身也好,,本質(zhì)上都是術(shù)。術(shù)很重要,,但是無助于優(yōu)化工作流。這時(shí)候,,我們更需要一種略,。那么,
略是什么
這里我們先擴(kuò)展下游戲AI開發(fā)中的一種比較經(jīng)典的工作流,。策劃輸出AI配置,,直接在游戲內(nèi)調(diào)試效果。如果現(xiàn)有接口不滿足需求,,就向程序提開發(fā)需求,,程序加上新接口之后,策劃可以在AI配置里面應(yīng)用新的接口,。這個(gè)AI配置是個(gè)比較廣義的概念,,既可以像很多從立項(xiàng)之初并沒有規(guī)劃AI模塊的游戲那樣,逐漸地,、自發(fā)地形成了一套基于配表做的決策樹,;也可以是像騰訊的behaviac那樣的,用XML文件來描述,。XML天生就是描述數(shù)據(jù)的,,騰訊系的組件普遍特別鐘愛,,tdr這種配表轉(zhuǎn)數(shù)據(jù)的工具是xml,tapp tcplus什么的配置文件全是XML,,倒不是說XML,,而是很多問題解決起來并不直觀。
配表也好,,XML也好,,json也好,這種描述數(shù)據(jù)的形式本身并沒有錯(cuò),。配表幫很多團(tuán)隊(duì)跨過了從硬編碼到數(shù)據(jù)驅(qū)動(dòng)的開發(fā)模式的轉(zhuǎn)變,,現(xiàn)在國內(nèi)小到創(chuàng)業(yè)手游團(tuán)隊(duì),大到天諭這種幾百人的MMO,,策劃的工作量除了配關(guān)卡就是配表,。
但是,配表無法自我進(jìn)化【鏈接】,,配表無法自己描述流程是什么樣,,而是流程在描述配表是什么樣。
針對(duì)策劃配置AI這個(gè)需求,,我們希望抽象出來一個(gè)中間層,,這樣,基于這個(gè)中間層,,開發(fā)相應(yīng)的編輯器也好,,直接利用這個(gè)中間層來配AI也好,都能夠靈活地做到調(diào)試AI這個(gè)最終需求,。如何解決,?我們不妨設(shè)計(jì)一種DSL。
DSL
Domain-specific Language,,領(lǐng)域特定語言,,顧名思義,專門為特定領(lǐng)域設(shè)計(jì)的語言,。設(shè)計(jì)一門DSL遠(yuǎn)容易于設(shè)計(jì)一門通用計(jì)算語言,,我們不用考慮一些特別復(fù)雜的特性,不用加一些增加復(fù)雜度的模塊,,不需要care跟領(lǐng)域無關(guān)的一些流程,。Less is more。
游戲AI需要怎樣一種DSL
痛點(diǎn):
- 對(duì)于游戲AI來說,,需要一種語言可以描述特定類型entity的行為邏輯,。
- 而對(duì)于程序員來說,只需要提供runtime即可。比如組合結(jié)點(diǎn)的類型,、表現(xiàn)等等,。而具體的行為決策邏輯,由其他層次的協(xié)作者來定義,。
- 核心需求是做另一種/幾種高級(jí)語言的目標(biāo)代碼生成,,對(duì)于當(dāng)前以及未來幾年來說,對(duì)C#的支持一定是不能少的,,對(duì)python/lua等服務(wù)端腳本的支持也可以考慮,。
- 對(duì)語言本身的要求是足夠簡(jiǎn)單易懂,declarative,,這樣既可以方便上層編輯器的開發(fā),,也可以在沒編輯器的時(shí)候快速上手。
分析需求:
- 因?yàn)樾枰瞿繕?biāo)代碼生成,,而且最主要的目標(biāo)代碼應(yīng)該是C#這種強(qiáng)類型的,,所以需要有簡(jiǎn)單的類型系統(tǒng),以及編譯期簡(jiǎn)單的類型檢查,??梢源_保語言的源文件可以最終codegen成不會(huì)導(dǎo)致編譯出錯(cuò)的C#代碼。
- 決定行為樹框架好壞的一個(gè)比較致命的因素就是對(duì)With語義的實(shí)現(xiàn),。根據(jù)我們之前對(duì)With語義的討論,,可以看到,這個(gè)With語義的描述其實(shí)是天然的可以轉(zhuǎn)化為一個(gè)lambda的,,所以這門DSL同樣需要對(duì)lambda進(jìn)行支持,。
- 關(guān)于類型系統(tǒng),需要支持一些內(nèi)建的復(fù)雜類型,,目前來看僅需要List,,只有在seq、select等結(jié)點(diǎn)的構(gòu)造時(shí)會(huì)用到,。還是由于需要支持lambda的原因,我們需要支持Applicative Type,,也就是形如A -> B應(yīng)該是first class type,,而一個(gè)lambda也應(yīng)該是first class function。根據(jù)之前對(duì)runtime的實(shí)現(xiàn)討論,,我們的DSL還需要支持Generic Type,,來支持IO<Result>這樣的類型,以及List<IO<Result>>這樣的類型,。對(duì)內(nèi)建primitive類型的支持只要有String,、Bool、Int、Float即可,。需要支持簡(jiǎn)單的類型推導(dǎo),,實(shí)現(xiàn)hindley-milner的真子集即可,這樣至少我們就不需要在聲明lambda的時(shí)候?qū)懙奶珡?fù)雜,。
- 需要支持模塊化定義,,也就是最基本的import語義。這樣的話可以方便地模塊化構(gòu)建AI接口,,也可以比較方便地定義一些預(yù)制件,。
模塊分為兩類:
一類是抽象的聲明,只有declare,。比如Prelude,,seq、select等一些結(jié)點(diǎn)的具體實(shí)現(xiàn)邏輯一定是在runtime中做的,,所以沒必要在DSL這個(gè)層面填充這類邏輯,。具體的代碼轉(zhuǎn)換則由一些特設(shè)的模塊來做。只需要類型檢查通過,,目標(biāo)語言的CodeGenerator生成了對(duì)應(yīng)的目標(biāo)代碼,,具體的邏輯就在runtime中直接實(shí)現(xiàn)了。
一類是具體的定義,,只有define,。比如定義某個(gè)具體的AIXXX中的root結(jié)點(diǎn),或者定義某個(gè)通用行為結(jié)點(diǎn),。具體的定義就需要對(duì)外部模塊的define以及declare進(jìn)行組合,。import語義就需要支持從外部模塊導(dǎo)入符號(hào)。
一種non-trivial的DSL實(shí)現(xiàn)方案
由于原則是簡(jiǎn)單為主,,所以我在語言的設(shè)計(jì)上主要借鑒的是Scheme,。S表達(dá)式的好處就是代碼本身即數(shù)據(jù),也可以是我們需要的AST,。同時(shí),,由于需要引入簡(jiǎn)單類型系統(tǒng),需要混入一些其他語言的描述風(fēng)格,。我在declare類型時(shí)的語言風(fēng)格借鑒了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)))
復(fù)制代碼
因?yàn)槭且許cheme為主要借鑒對(duì)象,,所以內(nèi)建的復(fù)雜類型實(shí)現(xiàn)上本質(zhì)是一個(gè)ADT,當(dāng)然,,有針對(duì)list構(gòu)造專用的語法糖,,但是其parse出來拿到的AST中一個(gè)list終究還是一個(gè)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])]))])]))
復(fù)制代碼
可以看到,跟S-Expression沒什么太大的區(qū)別,,可能lambda的聲明方式變了下,。
然后是詞法分析和語法分析,這里我選擇的是Haskell的ParseC,。一些更傳統(tǒng)的選擇可能是lex+yacc/flex+bison,。但是這種兩個(gè)工具一起混用學(xué)習(xí)成本就不用說了,也違背了simple is better的初衷,。ParseC使用起來就跟PEG是一樣的,,PEG這種形式,是天然的結(jié)合了正則與top-down parser,。haskell支持的algebraic data types,,天然就是用來定義AST結(jié)構(gòu)的,簡(jiǎn)單直觀,。haskell實(shí)現(xiàn)的hindly-miner類型系統(tǒng),,又是讓你寫代碼基本編譯通過就能直接run出正確結(jié)果,從一定程度上彌補(bǔ)了PEG天生不適合調(diào)試的缺陷,。一個(gè)haskell的庫就能解決lexical&grammar,,實(shí)在方便。
先是一些AST結(jié)構(gòu)的預(yù)定義:
- 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
復(fù)制代碼
我在這里省去了一些跟這篇文章討論的DSL無關(guān)的語言特性,,比如Pattern的定義我只保留了VarPat,;Value的定義我去掉了ClosureVal,雖然語言本身仍然是支持first class function的,。
algebraic data type的一個(gè)好處就是清晰易懂,,定義起來不過區(qū)區(qū)二十行,但是我們一看就知道之后輸出的AST會(huì)是什么樣,。
haskell的ParseC用起來其實(shí)跟PEG是沒有本質(zhì)區(qū)別的,,組合子本身是自底向上描述的,而parser也是通過parse小元素的parser來構(gòu)建parse大元素的parser,。
例如,,haskell的ParseC庫就有這樣幾個(gè)強(qiáng)大的特性:
- 提供了char、string,,基元的parse單個(gè)字符或字符串的parser,。
- 提供了sat,傳一個(gè)predicate,,就可以parse到符合predicate的結(jié)果的parser。
- 提供了try,,支持parse過程中的lookahead語義,。
- 提供了chainl,、chainr,這樣就省的我們?cè)跇?gòu)造parser的時(shí)候就無需考慮左遞歸了,。不過這個(gè)我也是寫完了parser才了解到的,,所以基本沒用上,更何況對(duì)于S-expression來說,,需要我來處理左遞歸的情況還是比較少的,。
我們可以先根據(jù)這些基本的,封裝出來一些通用combinator,。
比如正則規(guī)則中的star:
- star :: Parser a -> Parser [a]
- star p = star_p
- where
- star_p = try plus_p <|> (return [])
- plus_p = (:) <[ DISCUZ_CODE_1689 ]gt; p <*> star_p
復(fù)制代碼
比如plus:
- plus :: Parser a -> Parser [a]
- plus p = plus_p
- where
- star_p = try plus_p <|> (return []) <?> "plus_star_p"
- plus_p = (:) <[ DISCUZ_CODE_1690 ]gt; p <*> star_p <?> "plus_plus_p"
復(fù)制代碼
基于這些,,我們可以做組裝出來一個(gè)parse lambda-exp的parser(p_seperate是對(duì)char、plus這些的組裝,,表示形如a,b,c這樣的由特定字符分隔的序列):
- p_lambda_exp :: Parser Exp
- p_lambda_exp = p_between '(' ')' inner
- <?> "p_lambda_exp"
- where
- inner = make_lambda_exp
- <[ DISCUZ_CODE_601 ]nbsp; 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))
復(fù)制代碼
有了所有exp的parser,,我們就可以組裝出來一個(gè)通用的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"
復(fù)制代碼
其中,listplus是一種具有優(yōu)先級(jí)的lookahead:
- listplus :: [Parser a] -> Parser a
- listplus lst = foldr (<|>) mzero (map try lst)
復(fù)制代碼
對(duì)于parser來說,,其輸入是源文件其輸出是AST,。具體來說,其實(shí)就是parse出一個(gè)Dec數(shù)組,,拿到AST,,供后續(xù)的pipeline消費(fèi)。
我們之前舉的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]]]))]
復(fù)制代碼
前面兩部分是我把在其他模塊定義的declares,,選擇性地拿過來兩條。第三部分是這個(gè)人形怪AI的整個(gè)的AST,。其中嵌套的Cons展開之后就是語言內(nèi)置的List,。
正如我們之前所說,做代碼生成之前需要進(jìn)行一步類型檢查的工作,。類型檢查工具其輸入是AST其輸出是一個(gè)檢查結(jié)果,,同時(shí)還可以提供AST中的一些輔助信息,包括各標(biāo)識(shí)符的類型信息等等,。
類型檢查其實(shí)主要的邏輯在于處理Appliacative Type,,這中間還有個(gè)類型推導(dǎo)的邏輯。形如(\a (Func a)) 10,,AST中并不記錄a的type,,我們的DSL也不需要支持concept、typeclass等有關(guān)type,、subtype的復(fù)雜機(jī)制,,推導(dǎo)的時(shí)候只需要著重處理AppExp,把右邊表達(dá)式的類型求出,,合并一下env傳給左邊表達(dá)式遞歸檢查即可,。
這部分的代碼:
- 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
復(fù)制代碼
此外,,還需要有一個(gè)通用的CodeGenerator模塊,其輸入也是AST,,其輸出是另一些AST中的輔助信息,,主要是注記下各標(biāo)識(shí)符的import源以及具體的define內(nèi)容,用來方便各目標(biāo)語言CodeGenerator直接復(fù)用邏輯,。
目標(biāo)語言的CodeGenerator目前只做了C#的,。
目標(biāo)代碼生成的邏輯就比較簡(jiǎn)單了,畢竟該有的信息前面的各模塊都提供了,,這里根據(jù)之前一個(gè)版本的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)))))))))
復(fù)制代碼
總的來說,大致分為這幾個(gè)模塊:Parser,、TypeChecker,、CodeGenerator、目標(biāo)語言的CodeGenerator,。再加上目標(biāo)語言的runtime,,基本上就可以組成這個(gè)DSL的全部了。
上面列出來的代碼風(fēng)格比較混搭,,畢竟是前后差的時(shí)間比較久了,。。parser部分大概是7月左右完成的,,那時(shí)候喜歡applicative的風(fēng)格,,大量用了<$> <*>;后面的TypeChecker和CodeGenerator都是最近寫的,,寫monad expression的時(shí)候,,Maybe Monad我比較傾向于寫原生的>>=調(diào)用,IO Monad如果這樣寫就煩了,,所以比較多的用了do-notaion,。優(yōu)化什么的由于時(shí)間原因還沒看RWH的后面幾章,而且DSL的compiler對(duì)性能需求的優(yōu)先級(jí)其實(shí)很低了,,所以暫時(shí)沒有考慮過,,各位看官將就一下。
再擴(kuò)展runtime
對(duì)比DSL,,我們可以發(fā)現(xiàn),,DSL支持的特性要比之前實(shí)現(xiàn)的runtime版本多。比如:
- runtime中壓根就沒有Closure的概念,,但是DSL中我們是完全可以把一個(gè)lambda作為一個(gè)ClosureVal傳給某個(gè)函數(shù)的,。
- 缺少對(duì)標(biāo)準(zhǔn)庫的支持。比如常用的math函數(shù),。
- 基于上面這點(diǎn),,還會(huì)引入一個(gè)With結(jié)點(diǎn)的性能問題,,在只有runtime的時(shí)候我們也許不會(huì)With a <- 1+1。但是DSL中是有可能這樣的,,而且生成出來的代碼會(huì)每次run這棵樹的時(shí)候都會(huì)重新計(jì)算一次1+1。
針對(duì)第一個(gè)問題,,我們要做的工作就多了,。首先我們要記錄下這個(gè)閉包hold住的自由變量,要傳給runtime,,runtime也要記錄,,也要做各種各種,想想都麻煩,,而且完全偏離了游戲AI的話題,,不再討論。
針對(duì)第二個(gè)問題,,我們可以通過解決第三個(gè)問題來順便解決這個(gè)問題,。
針對(duì)第三個(gè)問題,我們重新審視一下With語義,。
With語義所要表達(dá)的其實(shí)是這樣一個(gè)概念:
把一個(gè)可能會(huì)Continue/Lazy Evaluation的計(jì)算結(jié)果,,綁定到一個(gè)variable上,對(duì)于With下面的子表達(dá)式來說,,這個(gè)variable的值具有l(wèi)exical scope,。
但是在runtime中,我們按照之前的寫法,,subtree中直接就進(jìn)行了函數(shù)調(diào)用,,很顯然是存在問題的。
With結(jié)點(diǎn)本身的返回值不一定只是一個(gè)IO<Result>,,有可能是一個(gè)IO<float>,。
舉例:
- ((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>())
復(fù)制代碼
這里Math.Plus屬于這門DSL標(biāo)準(zhǔn)庫的一部分,實(shí)現(xiàn)上我們就對(duì)底層數(shù)學(xué)函數(shù)做一層簡(jiǎn)單的wrapper,。但是這樣由于C#語言是pass-by-value,,我們?cè)跇?gòu)造這顆With的時(shí)候,Math.Plus(a, 0.1)已經(jīng)求值,。但是這個(gè)時(shí)候Box的值還沒有被填充,,求出來肯定是有問題的。
所以我們需要對(duì)這樣一種計(jì)算再進(jìn)行一次抽象,。希望可以得到的效果是,,對(duì)于Math.Plus(0.1, 0.2),可以在構(gòu)造樹的時(shí)候直接求值,;對(duì)于Math.Plus(0.1, a),,可以得到某種計(jì)算,,在我們需要的時(shí)候再求值。
先明確下函數(shù)調(diào)用有哪幾種情況:
- 對(duì)UnitAI,,也就是外部世界的定義的接口的調(diào)用,。這種調(diào)用,對(duì)于AI模塊來說,,本質(zhì)上是pure的,,所以不需要考慮這個(gè)延遲計(jì)算的問題
- 對(duì)標(biāo)準(zhǔn)庫的調(diào)用
按我們之前的runtime設(shè)計(jì)思路,Math.Plus這個(gè)標(biāo)準(zhǔn)庫API也許會(huì)被設(shè)計(jì)成這樣:
- public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b)
- {
- return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue());
- }
復(fù)制代碼
如果a和b都是literal value,,那就沒問題,,但是如果有一個(gè)是被box包裹的,那就很顯然是有問題的,。
所以需要對(duì)Thunk這個(gè)概念做一下擴(kuò)展,,使之能區(qū)別出動(dòng)態(tài)的值與靜態(tài)的值。一般情況下的值,,都是pure的,;box包裹的值,是impure的,。同時(shí),,這個(gè)pure的性質(zhì)具有值傳遞性,如果這個(gè)值屬于另一個(gè)值的一部分,,那么這個(gè)整體的pure性質(zhì)與值的局部的pure性質(zhì)是一致的,。這里特指的值,包括List與IO,。
整體的概念我們應(yīng)該拿haskell中的impure monad做類比,,比如haskell中的IO。haskell中的IO依賴于OS的輸入,,所以任何返回IO monad的函數(shù)都具有傳染性,,引用到的函數(shù)一定還會(huì)被包裹在IO monad之中。
所以,,對(duì)于With這種情況的傳遞,,應(yīng)該具有這樣的特征:
- With內(nèi)部引用到了With外部的symbol,那么這個(gè)With本身應(yīng)該是impure的,。
- With內(nèi)部只引用了自己的IOGet,,那么這個(gè)With本身是pure的,但是其SubTree是impure的,。
所以With結(jié)點(diǎn)構(gòu)造的時(shí)候,,計(jì)算pure應(yīng)該特殊處理一下。但是這個(gè)特殊處理的代碼污染性比較大,我在本文就不列出了,,只是這樣提一下,。
有了pure與impure的標(biāo)記,我們?cè)趯?duì)函數(shù)調(diào)用的時(shí)候,,就需要額外走一層,。
本來一個(gè)普通的函數(shù)調(diào)用,比如UnitAI.Func(p0, p1, p2)與Math.Plus(p0, p1),。前者返回一種computing是毫無疑問的,,后者就需要根據(jù)參數(shù)的類型來決定是返回一種計(jì)算還是直接的值。
為了避免在這個(gè)Plus里面改來改去,,我們把Closure這個(gè)概念給抽象出來。同時(shí),,為了簡(jiǎn)化討論,,我們只列舉T0 -> TR這一種情況,對(duì)應(yīng)的標(biāo)準(zhǔn)庫函數(shù)取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);
- }
- }
復(fù)制代碼
其中,,UserFuncApply就是之前所說的一層計(jì)算的概念。UserFunc表示的是等效于可以編譯期計(jì)算的一種標(biāo)準(zhǔn)庫函數(shù),。
這樣定義:
- public static class Math
- {
- public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs);
- }
復(fù)制代碼
Message類型的Closure構(gòu)造,,都走FuncThunk構(gòu)造函數(shù);普通函數(shù)類型的構(gòu)造,,走Func構(gòu)造函數(shù),,并且包裝一層。
Help.Apply是為了方便做代碼生成,,描述一種declarative的Application,。其實(shí)就是直接調(diào)用Closure的Apply。
考慮以下幾種case:
- public void Test()
- {
- var box1 = new Box<float>();
- // Math.Abs(box1) -> UserFuncApply
- // 在GetUserValue的時(shí)候才會(huì)求值
- var ret1 = Help.Apply(Math.Abs, box1);
- // Math.Abs(0.2f) -> Thunk<float>
- // 直接構(gòu)造出來了一個(gè)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));
- }
復(fù)制代碼
與之前的runtime版本唯一表現(xiàn)上有區(qū)別的地方在于,,對(duì)于純pure參數(shù)的userFunc,,在Apply完之后會(huì)直接計(jì)算出來值,并重新包裝成一個(gè)Thunk,;而對(duì)于參數(shù)中有impure的情況,,返回一個(gè)UserFuncApply,在GetUserValue的時(shí)候才會(huì)求值,。
TODO
到目前為止,,已經(jīng)形成了一套基本的、non-trivial的游戲AI方案,,當(dāng)然后續(xù)還有很多要做的工作,,比如:
更多的語言特性:
- DSL中支持注釋、函數(shù)作為普通的value傳遞等等。
- parser,、typechecker支持更完善的錯(cuò)誤處理,,我之前單獨(dú)寫一個(gè)用例的時(shí)候,就因?yàn)橐恍┘?xì)節(jié)問題,,調(diào)試了老半天,。
- 標(biāo)準(zhǔn)庫支持更多,比如Y-Combinator
編輯器化:
國內(nèi)游戲工業(yè)落后國外的一個(gè)比較重要的因素就是工作流太落后,,要不是因?yàn)閡nity的興起帶動(dòng)了國內(nèi)編輯器化風(fēng)潮,,可能現(xiàn)在還有大部分團(tuán)隊(duì)配技能配戰(zhàn)斗效果都還會(huì)對(duì)著excel盲配。
AI的配置也需要有編輯器,,這個(gè)編輯器至少能實(shí)現(xiàn)的需求有這樣幾個(gè):
- 與自己定義的中間層對(duì)接良好(配置文件也好,、DSL也好),具有codegen功能
- 支持工作空間,、支持模塊化定義,,制作一些prefab什么的
我們工作室自己做的編輯器是基于java的某個(gè)開源庫做的,看起來比較炫,,但是性能不行,。behaviac的編輯器就是純C#,性能應(yīng)該不錯(cuò),,沒有用過不了解,。這方面的具體話題就不再展開了。
|