创建一个简单的游戏引擎和人工智能NPC后,咱们须要对他们进行优化,如何创建,能够参考我在评论里的连接python
不过咱们在这篇博客的讨论中是不能仅停留在能解决需求的层面上。目前的方案至少还存在一个比较严重的问题,那就是逻辑复用性太差。组合状态须要 coding 的逻辑太多了,具体的状态内部逻辑须要人肉维护,更可怕的是须要程序员来人肉维护,再多几个组合状态简直不敢想象。程序员真的没这么多时间维护这些东西好么。因此咱们应该尝试抽象一下组合状态是否有一些通用的设计 pattern。 为了解决这个问题,咱们再对这几个状态的分析一下,能够对结点类型进行一下概括。程序员
若是把这个状态迁移逻辑体看作一个树结构,那其中组合结点就是非叶子结点,原子结点就是叶子结点。 对于组合结点来讲,其行为是能够概括的。express
巡逻结点,不考虑触发进入战斗的逻辑,能够概括为一种具备这样的行为的组合结点:依次执行每一个子结点(移动到某个点、休息一下子),某个子结点返回 Success 则执行下一个,返回 Failure 则直接向上返回,返回 Continue 就把 Continuation 抛出去。命名具备这样语义的结点为 Sequence。编程
设想攻击状态下,单位须要同时进行两种子结点的尝试,一个是释放技能,一个是说话。两个须要同时执行,而且结果独立。有一个返回 Success 则向上返回 Success,所有 Failure 则返回 Failure,不然返回 Continue。命名具备如此语义的结点为 Parallel。json
在 Parallel 的语义基础上,若是要体现一个优先级 / 顺序性质,那么就须要一个具备依次执行子结点语义的组合结点,命名为 Select。 Sequence 与 Select 组合起来,就能完整的描述一” 趟 “巡逻,Select(ReactAttack, Sequence(MoveTo, Idle)),能够直接干掉以前写的 Patrol 组合状态,组合状态直接拿现成的实现好的语义结点复用便可。 组合结点的抽象问题解决了,如今咱们来看叶子结点。api
Flee、Idle、MoveTo 三个状态,状态进入的时候调一下宿主的某个函数,申请开始一个持续性的动做。 四个原子状态都有的一个 pattern,就是在 Drive 中轮询,直到某个条件达成了才返回。数组
前两个 pattern 实际上是同一个问题,区别就在于那些逻辑应该放在宿主提供的接口里面作实现,哪些逻辑应该在 AI 模块里作实现。调用宿主的某个函数,调用是一个瞬间的操做,直接改变了宿主的 status,可是截止点的判断就有不一样的实现方式了。数据结构
假设宿主提供了接受参数的 api,提供了查询接口,ai 模块须要经过调用宿主的查询接口拿到数据,再把数据传给宿主来执行某种行为。 咱们称这种语义为 With,With 用来求出一个结点的值,并合并在当前的 env 中传递给子树,子树中能够 resolve 到这个 symbol。闭包
有了 With 语义,咱们就能够方便的在 AI 模块中对游戏世界的数据进行操做,请求一个数据 => 处理一下 => 返回一个数据,更具扩展性。app
With 语义的具体需求明确一下就是这样的:由两个子树来构造,一个是 IOGet,一个是 SubTree。With 会首先求值 IOGet,而后 binding 到一个 symbol 上,SubTree 能够直接引用这个 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(); } }
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。
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; } }
对 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)。
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。
Domain-specific Language,领域特定语言,顾名思义,专门为特定领域设计的语言。设计一门 DSL 远容易于设计一门通用计算语言,咱们不用考虑一些特别复杂的特性,不用加一些增长复杂度的模块,不须要 care 跟领域无关的一些流程。Less is more。
痛点:
由于须要作目标代码生成,并且最主要的目标代码应该是 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 语义就须要支持从外部模块导入符号。
因为原则是简单为主,因此我在语言的设计上主要借鉴的是 Scheme。S 表达式的好处就是代码自己即数据,也能够是咱们须要的 AST。同时,因为须要引入简单类型系统,须要混入一些其余语言的描述风格。我在 declare 类型时的语言风格借鉴了 haskell,import 语句也借鉴了 haskell。
(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,实在方便。
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。
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 消费。
-- 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 的所有了。
对比 DSL,咱们能够发现,DSL 支持的特性要比以前实现的 runtime 版本多。好比:
runtime 中压根就没有 Closure 的概念,可是 DSL 中咱们是彻底能够把一个 lambda 做为一个 ClosureVal 传给某个函数的。
缺乏对标准库的支持。好比经常使用的 math 函数。 基于上面这点,还会引入一个 With 结点的性能问题,在只有 runtime 的时候咱们也许不会 With a <- 1+1。可是 DSL 中是有可能这样的,并且生成出来的代码会每次 run 这棵树的时候都会从新计算一次 1+1。
针对第一个问题,咱们要作的工做就多了。首先咱们要记录下这个闭包 hold 住的自由变量,要传给 runtime,runtime 也要记录,也要作各类各类,想一想都麻烦,并且彻底偏离了游戏 AI 的话题,再也不讨论。
针对第二个问题,咱们能够经过解决第三个问题来顺便解决这个问题。
针对第三个问题,咱们从新审视一下 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 这种状况的传递,应该具备这样的特征:
有了 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 的时候才会求值。
到目前为止,已经造成了一套基本的、non-trivial 的游戏 AI 方案,固然后续还有不少要作的工做,好比:
AI 的配置也须要有编辑器,这个编辑器至少能实现的需求有这样几个:
与本身定义的中间层对接良好(配置文件也好、DSL 也好),具备 codegen 功能
支持工做空间、支持模块化定义,制做一些 prefab 什么的
支持可视化调试