漫谈游戏中的人工智能
写在前面
从一个简单的情景开始
- 大部分情况下,漫无目的巡逻。
- 玩家进入视野,锁定玩家为目标开始攻击。
- 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什么的
- 支持可视化调试
开通了一个微信公众号,以后会将一些技术文章发到这个公众号里,博客不管看起来还是写起来都挺累的,谢谢支持!
posted on 2015-09-05 23:12 fingerpass 阅读(14457) 评论(7) 编辑 收藏 举报