建立一个简单的游戏引擎和人工智能NPC后,我们需要对他们进行优化,如何建立,可以参考我在评论里的链接
语义结点的抽象
不过我们在这篇博客的讨论中是不能仅停留在能解决需求的层面上。目前的方案至少还存在一个比较严重的问题,那就是逻辑复用性太差。组合状态需要 coding 的逻辑太多了,具体的状态内部逻辑需要人肉维护,更可怕的是需要程序员来人肉维护,再多几个组合状态简直不敢想象。程序员真的没这么多时间维护这些东西好么。所以我们应该尝试抽象一下组合状态是否有一些通用的设计 pattern。 为了解决这个问题,我们再对这几个状态的分析一下,可以对结点类型进行一下归纳。
结点基本上是分为两个类型:组合结点、原子结点。
如果把这个状态迁移逻辑体看做一个树结构,那其中组合结点就是非叶子结点,原子结点就是叶子结点。 对于组合结点来说,其行为是可以归纳的。
巡逻结点,不考虑触发进入战斗的逻辑,可以归纳为一种具有这样的行为的组合结点:依次执行每个子结点(移动到某个点、休息一会儿),某个子结点返回 Success 则执行下一个,返回 Failure 则直接向上返回,返回 Continue 就把 Continuation 抛出去。命名具有这样语义的结点为 Sequence。
设想攻击状态下,单位需要同时进行两种子结点的尝试,一个是释放技能,一个是说话。两个需要同时执行,并且结果独立。有一个返回 Success 则向上返回 Success,全部 Failure 则返回 Failure,否则返回 Continue。命名具有如此语义的结点为 Parallel。
在 Parallel 的语义基础上,如果要体现一个优先级 / 顺序性质,那么就需要一个具有依次执行子结点语义的组合结点,命名为 Select。 Sequence 与 Select 组合起来,就能完整的描述一” 趟 “巡逻,Select(ReactAttack, Sequence(MoveTo, Idle)),可以直接干掉之前写的 Patrol 组合状态,组合状态直接拿现成的实现好的语义结点复用即可。 组合结点的抽象问题解决了,现在我们来看叶子结点。
叶子结点也可以归纳一下 pattern,能归纳出三种:
Flee、Idle、MoveTo 三个状态,状态进入的时候调一下宿主的某个函数,申请开始一个持续性的动作。 四个原子状态都有的一个 pattern,就是在 Drive 中轮询,直到某个条件达成了才返回。
- Attack 状态内部,每次都轮询都会向宿主请求一个数据,然后再判断这个 “外部” 数据是否满足一定条件。
- pattern 确实是有这么三种,但是叶子结点自身其实是两种,一种是控制单位做某种行为,一种是向单位查询一些信息,其实本质上是没区别的,只是描述问题的方式不一样。 既然我们的最终目标是消除掉四个具体状态的定义,转而通过一些通用的语义结点来描述,那我们就首先需要想办法提出一种方案来描述上述的三个 pattern。
前两个 pattern 其实是同一个问题,区别就在于那些逻辑应该放在宿主提供的接口里面做实现,哪些逻辑应该在 AI 模块里做实现。调用宿主的某个函数,调用是一个瞬间的操作,直接改变了宿主的 status,但是截止点的判断就有不同的实现方式了。
- 一种实现是宿主的 API 本身就是一个返回 Result 的函数,第一次调用的时候,宿主会改变自己的状态,比如设置单位开始移动,之后每帧都会驱动这个单位移动,而 AI 模块再去调用 MoveTo 就会拿到一个 Continue,直到宿主这边内部驱动单位移动到目的地,即向上返回 Success;发生无法让单位移动完成的情况,就返回 Failure。
- 另一种实现是宿主提供一些基本的查询 API,比如移动到某一点、是否到达某个点、获得下一个巡逻点,这样的话就相当于是把轮询判断写在了 AI 模块里。这样就需要有一个 Check 结点,来包裹这个查询到的值,向上返回一个 IO 类型的值。
- 而针对第三种 pattern,可以抽象出这样一种需求情景,就是:
AI 模块与游戏世界的数据互操作
假设宿主提供了接受参数的 api,提供了查询接口,ai 模块需要通过调用宿主的查询接口拿到数据,再把数据传给宿主来执行某种行为。 我们称这种语义为 With,With 用来求出一个结点的值,并合并在当前的 env 中传递给子树,子树中可以 resolve 到这个 symbol。
有了 With 语义,我们就可以方便的在 AI 模块中对游戏世界的数据进行操作,请求一个数据 => 处理一下 => 返回一个数据,更具扩展性。
With 语义的具体需求明确一下就是这样的:由两个子树来构造,一个是 IOGet,一个是 SubTree。With 会首先求值 IOGet,然后 binding 到一个 symbol 上,SubTree 可以直接引用这个 symbol,来当做一个普通的值用。 然后考虑下实现方式。
C# 中,子树要想引用这个 symbol,有两个方法:
ioget 与 subtree 共同 hold 住一个变量,ioget 求得的值赋给这个变量,subtree 构造的时候直接把值传进来。
ioget 与 subtree 共同 hold 住一个 env,双方约定统一的 key,ioget 求完就把这个 key 设置一下,subtree 构造的时候直接从 env 里根据 key 取值。
考虑第一种方法,hold 住的不应该是值本身,因为树本身是不同实例共享的,而这个值会直接影响到子树的结构。所以应该用一个 class instance object 对值包裹一下。
这样经过改进后的第一种方法理论上速度应该比 env 的方式快很多,也方便做一些优化,比如说如果子树没有 continue 就不需要把这个值存在 env 中,比如说由于树本身的驱动一定是单线程的,不同的实例可以共用一个包裹,执行子树的时候设置下包裹中的值,执行完子树再把包裹中的值还原。
加入了 with 语义,就需要重新审视一下 IState 的定义了。既然一个结点既有可能返回一个 Result,又有可能返回一个值,那么就需要这样一种抽象:
有这样一种泛化的 concept,他只需要提供一个 drive 接口,接口需要提供一个环境 env,drive 一下,就可以输出一个值。这个 concept 的 instance,需要是 pure 的,也就是结果唯一取决于输入的环境。不同次输入,只要环境相同,输出一定相同。
因为描述的是一种与外部世界的通信,所以就命名为 IO 吧:
public interface IO<T>
{
T Drive(Context ctx);
}
public interface IO<T>
{
T Drive(Context ctx);
}
这样,我们之前的所有结点都应该有 IO 的 concept。
之前提出了 Parallel、Sequence、Select、Check 这样几个语义结点。具体的实现细节就不再细说了,简单列一下代码结构:
public class Sequence : IO<Result>
{
private readonly ICollection<IO<Result>> subTrees;
public Sequence(ICollection<IO<Result>> subTrees)
{
this.subTrees = subTrees;
}
public Result Drive(Context ctx)
{
throw new NotImplementedException();
}
}
public class Sequence : IO<Result>
{
private readonly ICollection<IO<Result>> subTrees;
public Sequence(ICollection<IO<Result>> subTrees)
{
this.subTrees = subTrees;
}
public Result Drive(Context ctx)
{
throw new NotImplementedException();
}
}
With 结点的实现,采用我们之前说的第一种方案:
public class With<T, TR> : IO<TR>
{
// ...
public TR Drive(Context ctx)
{
var thisContinuation = ctx.Continuation;
var value = default(T);
var skipIoGet = false;
if (thisContinuation != null)
{
// Continuation
ctx.Continuation = thisContinuation.SubContinuation;
// 0表示需要继续ioGet
// 1表示需要继续subTree
if (thisContinuation.NextStep == 1)
{
skipIoGet = true;
value = (T) thisContinuation.Param;
}
}
if (!skipIoGet)
{
value = ioGet.Drive(ctx);
if (ctx.Continuation != null)
{
// ioGet抛出了Continue
if (thisContinuation == null)
{
thisContinuation = new Continuation()
{
SubContinuation = ctx.Continuation,
NextStep = 0,
};
}
else
{
thisContinuation.SubContinuation = ctx.Continuation;
thisContinuation.NextStep = 0;
}
ctx.Continuation = thisContinuation;
return default(TR);
}
}
var oldValue = box.SetVal(value);
var ret = subTree.Drive(ctx);
box.SetVal(oldValue);
if (ctx.Continuation != null)
{
// subTree抛出了Continue
if (thisContinuation == null)
{
thisContinuation = new Continuation()
{
SubContinuation = ctx.Continuation,
};
}
ctx.Continuation = thisContinuation;
thisContinuation.Param = value;
}
return ret;
}
}
public class With<T, TR> : IO<TR>
{
// ...
public TR Drive(Context ctx)
{
var thisContinuation = ctx.Continuation;
var value = default(T);
var skipIoGet = false;
if (thisContinuation != null)
{
// Continuation
ctx.Continuation = thisContinuation.SubContinuation;
// 0表示需要继续ioGet
// 1表示需要继续subTree
if (thisContinuation.NextStep == 1)
{
skipIoGet = true;
value = (T) thisContinuation.Param;
}
}
if (!skipIoGet)
{
value = ioGet.Drive(ctx);
if (ctx.Continuation != null)
{
// ioGet抛出了Continue
if (thisContinuation == null)
{
thisContinuation = new Continuation()
{
SubContinuation = ctx.Continuation,
NextStep = 0,
};
}
else
{
thisContinuation.SubContinuation = ctx.Continuation;
thisContinuation.NextStep = 0;
}
ctx.Continuation = thisContinuation;
return default(TR);
}
}
var oldValue = box.SetVal(value);
var ret = subTree.Drive(ctx);
box.SetVal(oldValue);
if (ctx.Continuation != null)
{
// subTree抛出了Continue
if (thisContinuation == null)
{
thisContinuation = new Continuation()
{
SubContinuation = ctx.Continuation,
};
}
ctx.Continuation = thisContinuation;
thisContinuation.Param = value;
}
return ret;
}
}
这样,我们的层次状态机就全部组件化了。我们可以用通用的语义结点来组合出任意的子状态,这些子状态是不具名的,对构建过程更友好。
具体的代码例子:
Par(
Seq(IsFleeing, ((Box<object> a) => With(a, GetNearestTarget, Check(IsNull(a))))(new Box<object>()), Patrol)
,Seq(IsAttacking, ((Box<float> a) => With(a, GetFleeBloodRate, Check(HpRateLessThan(a))))(new Box<float>()))
,Seq(IsNormal, Loop(Par(((Box<object> a) => With(a, GetNearestTarget, Seq(Check(IsNull(a)), LockTarget(a)))(new Box<object>()), Seq(Seq(Check(ReachCurrentPatrolPoint), MoveToNextPatrolPoiont), Idle))))))
Par(
Seq(IsFleeing, ((Box<object> a) => With(a, GetNearestTarget, Check(IsNull(a))))(new Box<object>()), Patrol)
,Seq(IsAttacking, ((Box<float> a) => With(a, GetFleeBloodRate, Check(HpRateLessThan(a))))(new Box<float>()))
,Seq(IsNormal, Loop(Par(((Box<object> a) => With(a, GetNearestTarget, Seq(Check(IsNull(a)), LockTarget(a)))(new Box<object>()), Seq(Seq(Check(ReachCurrentPatrolPoint), MoveToNextPatrolPoiont), Idle))))))
看起来似乎是变得复杂了,原来可能只需要一句 new XXXState(),现在却需要自己用代码拼接出来一个行为逻辑。但是仔细想一下,改成这样的描述其实对整个工作流是有好处的。之前的形式完全是硬编码,而现在,似乎让我们看到了转数据驱动的可能性。
对行为结点做包装
当然这个示例还少解释了一部分,就是叶子结点,或者说是行为结点的定义。
我们之前对行为的定义都是在 IUnit 中,但是这里显然不像是之前定义的 IUnit。
如果把每个行为都看做是树上的一个与 Select、Sequence 等结点无异的普通结点的话,就需要实现 IO 的接口。抽象出一个计算的概念,构造的时候可以构造出这个计算,然后通过 Drive,来求得计算中的值。
包装后的一个行为的代码:
#region HpRateLessThan
private class MessageHpRateLessThan : IO<bool>
{
public readonly float p0;
public MessageHpRateLessThan(float p0)
{
this.p0 = p0;
}
public bool Drive(Context ctx)
{
return ((T)ctx.Self).HpRateLessThan(p0);
}
}
public static IO<bool> HpRateLessThan(float p0)
{
return new MessageHpRateLessThan(p0);
}
#endregion
#region HpRateLessThan
private class MessageHpRateLessThan : IO<bool>
{
public readonly float p0;
public MessageHpRateLessThan(float p0)
{
this.p0 = p0;
}
public bool Drive(Context ctx)
{
return ((T)ctx.Self).HpRateLessThan(p0);
}
}
public static IO<bool> HpRateLessThan(float p0)
{
return new MessageHpRateLessThan(p0);
}
#endregion
经过包装的行为结点的代码都是有规律可循的,所以我们可以比较容易的通过一些代码生成的机制来做。比如通过反射拿到 IUnit 定义的接口信息,然后直接在这基础之上做一下包装,做出来个行为结点的定义。
现在我们再回忆下讨论过的 With,构造一个叶子结点的时候,参数不一定是 literal value,也有可能是经过 Box 包裹过的。所以就需要对 Boax 和 literal value 抽象出来一个公共的概念,叶子结点 / 行为结点可以从这个概念中拿到值,而行为结点计算本身的构造也只需要依赖于这个概念。
我们把这个概念命名为 Thunk。Thunk 包裹一个值或者一个 box,而就目前来看,这个 Thunk,仅需要提供一个我们可以通过其拿到里面的值的接口就够了。
public abstract class Thunk<T>
{
public abstract T GetUserValue();
}
public abstract class Thunk<T>
{
public abstract T GetUserValue();
}
对于常量,我们可以构造一个包裹了常量的 thunk;而对于 box,其天然就属于 Thunk 的 concept。
这样,我们就通过一个 Thunk 的概念,硬生生把树中的结点与值分割成了两个概念。这样做究竟正确不正确呢?
如果一个行为结点的参数可能有的类型本来就是一些 primitive type,或者是外部世界(相对于 AI 世界)的类型,那肯定是没问题的。但如果需要支持这样一种特性:外部世界的函数,返回值是 AI 世界的某个概念,比如一个树结点;而我的 AI 世界,希望的是通过这个外部世界的函数,动态的拿到一个结点,再动态的加到我的树中,或者再动态的传给不通的外部世界的函数,应该怎么做?
对于一颗 With 子树(Negate 表示对子树结果取反,Continue 仍取 Continue):
((Box<IO<Result>> a) =>
With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
((Box<IO<Result>> a) =>
With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
语义需要保证,这颗子树执行到任意时刻,都需要是 ContextFree 的。
假设 IOGet 返回的是一个普通的值,确实是没问题的。
但是因为 Box 包裹的可能是任意值,例如,假设 IOGet 返回的是一个 IO,
instance a,执行完 IOGet 之后,结构变为 Negate(A)。
instance b,再执行 IOGet,拿到一个 B,设置 box 里的值为 B,并且拿出来 A,这时候再 run subtree,其实就是按 Negate(B) 来跑的。
我们只有把 IO 本身,做到其就是 Thunk 这个 Concept。这样所有的 Message 对象,都是一个 Thunk。不仅如此,所以在这个树中出现的数据结构,理应都是一个 Thunk,比如 List。
再次改造 IO:
public abstract class IO<T> : Thunk<IO<T>>
{
public abstract T Drive(Context ctx);
public override IO<T> GetUserValue()
{
return this;
}
}
public abstract class IO<T> : Thunk<IO<T>>
{
public abstract T Drive(Context ctx);
public override IO<T> GetUserValue()
{
return this;
}
}
BehaviourTree
对 AI 有了解的同学可能已经清楚了,目前我们实现的就是一个行为树的引擎,并且已经基本成型。到目前为止,我们接触过的行为树语义有:
Sequence、Select、Parallel、Check、Negate。
其中 Sequence 与 Select 是两个比较基本的语义,一个相当于逻辑 And,一个相当于逻辑 Or。在组合子设计中这两类组合子也比较常见。
不同的行为树方案,对语义结点的选择也不一样。
比如以前在行为树这块比较权威的一篇 halo2 的行为树方案的 paper,里面提到的几个常用的组合结点有这样几种:
prioritized-list : 每次执行优先级最高的结点,高优先级的始终抢占低优先级的。
sequential : 按顺序执行每个子结点,执行完最后一个子结点后,父结点就 finished。
sequential-looping : 同上,但是会 loop。
probabilistic : 从子结点中随机选择一个执行。
one-off : 从子结点中随机选择或按优先级选择,选择一个排除一个,直到执行完为止。
而腾讯的 behaviac 对组合结点的选择除了传统的 Select 和 Seqence,halo 里面提到的随机选择,还自己扩展了 SelectorProbability(虽然看起来像是一个 select,但其实每次只会根据概率选择一个,更倾向于 halo 中的 Probabilistic),SequenceStochastic(随机地决定执行顺序,然后表现起来确实像是一个 Sequence)。
其他还有各种常用的修饰结点,比如前文实现的 Check,还有一些比较常用的:
- Wait :子树返回 Success 的时候向上 Success,否则向上 Continue。
- Forever : 永远返回 Continue。
- If-Else、Switch-Cond : 对于有编程功底的我想就不需要再多做解释了。
- forcedXX : 对子树结果强制取值。 还有一类属于特色结点,虽然通过其他各种方式也都能实现,但是在行为树这个层面实现的话肯定扩展性更强一些,毕竟可以分离一部分程序的职责。一个比较典型的应用情景是事件驱动,halo 的 paper 中提到了 Behaviour Impulse,但是我在在 behaviac 中并没有找到类似的概念。
halo 的 paper 里面还提到了一些比较细节的 hack 技巧,比如同一颗行为树可以应用不同的 Style,Parameter Creep 等等,有兴趣的同学也可以自行研究。
至此,行为树的 runtime 话题需要告一段落了,毕竟是一项成熟了十几年的技术。虽然这是目前游戏 AI 的标配,但是,只有行为树的话,离一个完整的 AI 工作流还很远。到目前为止,行为树还都是程序写出来的,但是正确来说 AI 应该是由策划或者 AI 脚本配出来的。因此,这篇文章的话题还需要继续,我们接下来就讨论一下这个程序与策划之间的中间层。 之前的优化思路也好,从其他语言借鉴的设计 pattern 也好,行为树这种理念本身也好,本质上都是术。术很重要,但是无助于优化工作流。这时候,我们更需要一种略。
那么,略是什么
这里我们先扩展下游戏 AI 开发中的一种比较经典的工作流。策划输出 AI 配置,直接在游戏内调试效果。如果现有接口不满足需求,就向程序提开发需求,程序加上新接口之后,策划可以在 AI 配置里面应用新的接口。这个 AI 配置是个比较广义的概念,既可以像很多从立项之初并没有规划 AI 模块的游戏那样,逐渐地、自发地形成了一套基于配表做的决策树;也可以是像腾讯的 behaviac 那样的,用 XML 文件来描述。XML 天生就是描述数据的,腾讯系的组件普遍特别钟爱,tdr 这种配表转数据的工具是 xml,tapp tcplus 什么的配置文件全是 XML,倒不是说 XML,而是很多问题解决起来并不直观。
配表也好,XML 也好,json 也好,这种描述数据的形式本身并没有错。配表帮很多团队跨过了从硬编码到数据驱动的开发模式的转变,现在国内小到创业手游团队,大到天谕这种几百人的 MMO,策划的工作量除了配关卡就是配表。 但是,配表无法自我进化 ,配表无法自己描述流程是什么样,而是流程在描述配表是什么样。
针对策划配置 AI 这个需求,我们希望抽象出来一个中间层,这样,基于这个中间层,开发相应的编辑器也好,直接利用这个中间层来配 AI 也好,都能够灵活地做到调试 AI 这个最终需求。如何解决?我们不妨设计一种 DSL。
DSL
Domain-specific Language,领域特定语言,顾名思义,专门为特定领域设计的语言。设计一门 DSL 远容易于设计一门通用计算语言,我们不用考虑一些特别复杂的特性,不用加一些增加复杂度的模块,不需要 care 跟领域无关的一些流程。Less is more。
游戏 AI 需要怎样一种 DSL
痛点:
- 对于游戏 AI 来说,需要一种语言可以描述特定类型 entity 的行为逻辑。
- 而对于程序员来说,只需要提供 runtime 即可。比如组合结点的类型、表现等等。而具体的行为决策逻辑,由其他层次的协作者来定义。
- 核心需求是做另一种 / 几种高级语言的目标代码生成,对于当前以及未来几年来说,对 C# 的支持一定是不能少的,对 python/lua 等服务端脚本的支持也可以考虑。
- 对语言本身的要求是足够简单易懂,declarative,这样既可以方便上层编辑器的开发,也可以在没编辑器的时候快速上手。
分析需求:
因为需要做目标代码生成,而且最主要的目标代码应该是 C# 这种强类型的,所以需要有简单的类型系统,以及编译期简单的类型检查。可以确保语言的源文件可以最终 codegen 成不会导致编译出错的 C# 代码。 决定行为树框架好坏的一个比较致命的因素就是对 With 语义的实现。根据我们之前对 With 语义的讨论,可以看到,这个 With 语义的描述其实是天然的可以转化为一个 lambda 的,所以这门 DSL 同样需要对 lambda 进行支持。 关于类型系统,需要支持一些内建的复杂类型,目前来看仅需要 List,只有在 seq、select 等结点的构造时会用到。还是由于需要支持 lambda 的原因,我们需要支持 Applicative Type,也就是形如 A -> B 应该是 first class type,而一个 lambda 也应该是 first class function。根据之前对 runtime 的实现讨论,我们的 DSL 还需要支持 Generic Type,来支持 IO<Result> 这样的类型,以及 List<IO<Result>> 这样的类型。对内建 primitive 类型的支持只要有 String、Bool、Int、Float 即可。需要支持简单的类型推导,实现 hindley-milner 的真子集即可,这样至少我们就不需要在声明 lambda 的时候写的太复杂。 需要支持模块化定义,也就是最基本的 import 语义。这样的话可以方便地模块化构建 AI 接口,也可以比较方便地定义一些预制件。
模块分为两类:
一类是抽象的声明,只有 declare。比如 Prelude,seq、select 等一些结点的具体实现逻辑一定是在 runtime 中做的,所以没必要在 DSL 这个层面填充这类逻辑。具体的代码转换则由一些特设的模块来做。只需要类型检查通过,目标语言的 CodeGenerator 生成了对应的目标代码,具体的逻辑就在 runtime 中直接实现了。 一类是具体的定义,只有 define。比如定义某个具体的 AIXXX 中的 root 结点,或者定义某个通用行为结点。具体的定义就需要对外部模块的 define 以及 declare 进行组合。import 语义就需要支持从外部模块导入符号。
一种 non-trivial 的 DSL 实现方案
由于原则是简单为主,所以我在语言的设计上主要借鉴的是 Scheme。S 表达式的好处就是代码本身即数据,也可以是我们需要的 AST。同时,由于需要引入简单类型系统,需要混入一些其他语言的描述风格。我在 declare 类型时的语言风格借鉴了 haskell,import 语句也借鉴了 haskell。
具体来说,declare 语句可能类似于这样:
(declare
(HpRateLessThan :: (Float -> IO Result))
(GetFleeBloodRate :: Float)
(IsNull :: (Object -> Bool))
(Idle :: IO Result))
(declare
(check :: (Bool -> IO Result))
(loop :: (IO Result -> IO Result))
(par :: (List IO Result -> IO Result)))
(declare
(HpRateLessThan :: (Float -> IO Result))
(GetFleeBloodRate :: Float)
(IsNull :: (Object -> Bool))
(Idle :: IO Result))
(declare
(check :: (Bool -> IO Result))
(loop :: (IO Result -> IO Result))
(par :: (List IO Result -> IO Result)))
因为是以 Scheme 为主要借鉴对象,所以内建的复杂类型实现上本质是一个 ADT,当然,有针对 list 构造专用的语法糖,但是其 parse 出来拿到的 AST 中一个 list 终究还是一个 ADT。
直接拿例子来说比较直观:
(import Prelude)
(import BaseAI)
(define Root
(par [(seq [(check IsFleeing) ((\a (check (IsNull a))) GetNearestTarget)])
(seq [(check IsAttacking) ((\b (HpRateLessThan b)) GetFleeBloodRate)])
(seq [(check IsNormal) (loop (par [((\c (seq [(check (IsNull c)) (LockTarget c)])) GetNearestTarget) (seq [(seq [(check ReachCurrentPatrolPoint) MoveToNextPatrolPoiont]) Idle])]))])]))
(import Prelude)
(import BaseAI)
(define Root
(par [(seq [(check IsFleeing) ((\a (check (IsNull a))) GetNearestTarget)])
(seq [(check IsAttacking) ((\b (HpRateLessThan b)) GetFleeBloodRate)])
(seq [(check IsNormal) (loop (par [((\c (seq [(check (IsNull c)) (LockTarget c)])) GetNearestTarget) (seq [(seq [(check ReachCurrentPatrolPoint) MoveToNextPatrolPoiont]) Idle])]))])]))
可以看到,跟 S-Expression 没什么太大的区别,可能 lambda 的声明方式变了下。
然后是词法分析和语法分析,这里我选择的是 Haskell 的 ParseC。一些更传统的选择可能是 lex+yacc/flex+bison。但是这种两个工具一起混用学习成本就不用说了,也违背了 simple is better 的初衷。ParseC 使用起来就跟 PEG 是一样的,PEG 这种形式,是天然的结合了正则与 top-down parser。haskell 支持的 algebraic data types,天然就是用来定义 AST 结构的,简单直观。haskell 实现的 hindly-miner 类型系统,又是让你写代码基本编译通过就能直接 run 出正确结果,从一定程度上弥补了 PEG 天生不适合调试的缺陷。一个 haskell 的库就能解决 lexical&grammar,实在方便。
先是一些 AST 结构的预定义:
module Common where
import qualified Data.Map as Map
type Identifier = String
type ValEnv = Map.Map Identifier Val
type TypeEnv = Map.Map Identifier Type
type DecEnv = Map.Map Identifier (String,Dec)
data Type =
NormalType String
| GenericType String Type
| AppType [Type]
data Dec =
DefineDec Pat Exp
| ImportDec String
| DeclareDec Pat Type
| DeclaresDec [Dec]
data Exp =
ConstExp Val
| VarExp Identifier
| LambdaExp Pat Exp
| AppExp Exp Exp
| ADTExp String [Exp]
data Val =
NilVal
| BoolVal Bool
| IntVal Integer
| FloatVal Float
| StringVal String
data Pat =
VarPat Identifier
module Common where
import qualified Data.Map as Map
type Identifier = String
type ValEnv = Map.Map Identifier Val
type TypeEnv = Map.Map Identifier Type
type DecEnv = Map.Map Identifier (String,Dec)
data Type =
NormalType String
| GenericType String Type
| AppType [Type]
data Dec =
DefineDec Pat Exp
| ImportDec String
| DeclareDec Pat Type
| DeclaresDec [Dec]
data Exp =
ConstExp Val
| VarExp Identifier
| LambdaExp Pat Exp
| AppExp Exp Exp
| ADTExp String [Exp]
data Val =
NilVal
| BoolVal Bool
| IntVal Integer
| FloatVal Float
| StringVal String
data Pat =
VarPat Identifier
我在这里省去了一些跟这篇文章讨论的 DSL 无关的语言特性,比如 Pattern 的定义我只保留了 VarPat;Value 的定义我去掉了 ClosureVal,虽然语言本身仍然是支持 first class function 的。
algebraic data type 的一个好处就是清晰易懂,定义起来不过区区二十行,但是我们一看就知道之后输出的 AST 会是什么样。
haskell 的 ParseC 用起来其实跟 PEG 是没有本质区别的,组合子本身是自底向上描述的,而 parser 也是通过 parse 小元素的 parser 来构建 parse 大元素的 parser。
例如,haskell 的 ParseC 库就有这样几个强大的特性:
- 提供了 char、string,基元的 parse 单个字符或字符串的 parser。
- 提供了 sat,传一个 predicate,就可以 parse 到符合 predicate 的结果的 parser。
- 提供了 try,支持 parse 过程中的 lookahead 语义。
- 提供了 chainl、chainr,这样就省的我们在构造 parser 的时候就无需考虑左递归了。不过这个我也是写完了 parser 才了解到的,所以基本没用上,更何况对于 S-expression 来说,需要我来处理左递归的情况还是比较少的。 我们可以先根据这些基本的,封装出来一些通用 combinator。
比如正则规则中的 star:
star :: Parser a -> Parser [a]
star p = star_p
where
star_p = try plus_p <|> (return [])
plus_p = (:) <$> p <*> star_p
star :: Parser a -> Parser [a]
star p = star_p
where
star_p = try plus_p <|> (return [])
plus_p = (:) <$> p <*> star_p
比如 plus:
plus :: Parser a -> Parser [a]
plus p = plus_p
where
star_p = try plus_p <|> (return []) <?> "plus_star_p"
plus_p = (:) <$> p <*> star_p <?> "plus_plus_p"
plus :: Parser a -> Parser [a]
plus p = plus_p
where
star_p = try plus_p <|> (return []) <?> "plus_star_p"
plus_p = (:) <$> p <*> star_p <?> "plus_plus_p"
基于这些,我们可以做组装出来一个 parse lambda-exp 的 parser(p_seperate 是对 char、plus 这些的组装,表示形如 a,b,c 这样的由特定字符分隔的序列):
p_lambda_exp :: Parser Exp
p_lambda_exp = p_between '(' ')' inner
<?> "p_lambda_exp"
where
inner = make_lambda_exp
<$ char '\\'
<*> p_seperate (p_parse p_pat) ","
<*> p_parse p_exp
make_lambda_exp [] e = (LambdaExp NilPat e)
make_lambda_exp (p:[]) e = (LambdaExp p e)
make_lambda_exp (p:ps) e = (LambdaExp p (make_lambda_exp ps e))
p_lambda_exp :: Parser Exp
p_lambda_exp = p_between '(' ')' inner
<?> "p_lambda_exp"
where
inner = make_lambda_exp
<$ char '\\'
<*> p_seperate (p_parse p_pat) ","
<*> p_parse p_exp
make_lambda_exp [] e = (LambdaExp NilPat e)
make_lambda_exp (p:[]) e = (LambdaExp p e)
make_lambda_exp (p:ps) e = (LambdaExp p (make_lambda_exp ps e))
有了所有 exp 的 parser,我们就可以组装出来一个通用的 exp parser:
p_exp :: Parser Exp
p_exp = listplus [p_var_exp, p_const_exp, p_lambda_exp, p_app_exp, p_adt_exp, p_list_exp]
<?> "p_exp"
p_exp :: Parser Exp
p_exp = listplus [p_var_exp, p_const_exp, p_lambda_exp, p_app_exp, p_adt_exp, p_list_exp]
<?> "p_exp"
其中,listplus 是一种具有优先级的 lookahead:
listplus :: [Parser a] -> Parser a
listplus lst = foldr (<|>) mzero (map try lst)
1
2
listplus :: [Parser a] -> Parser a
listplus lst = foldr (<|>) mzero (map try lst)
对于 parser 来说,其输入是源文件其输出是 AST。具体来说,其实就是 parse 出一个 Dec 数组,拿到 AST,供后续的 pipeline 消费。
我们之前举的 AI 的例子,parse 出来的 AST 大概是这副模样:
-- Prelude.bh
Right [DeclaresDec [
DeclareDec (VarPat "seq") (AppType [GenericType "List" (GenericType "IO" (NormalType "Result")),GenericType "IO" (NormalType "Result")])
,DeclareDec (VarPat "check") (AppType [NormalType "Bool",GenericType "IO" (NormalType "Result")])]]
-- BaseAI.bh
Right [DeclaresDec [
DeclareDec (VarPat "HpRateLessThan") (AppType [NormalType "Float",GenericType "IO" (NormalType "Result")])
,DeclareDec (VarPat "Idle") (GenericType "IO" (NormalType "Result"))]]
-- AI00001.bh
Right [
ImportDec "Prelude"
,ImportDec "BaseAI"
,DefineDec (VarPat "Root") (AppExp (VarExp "par") (ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsFleeing")
,ADTExp "Cons" [
AppExp (LambdaExp (VarPat "a")(AppExp (VarExp "check") (AppExp (VarExp "IsNull") (VarExp "a")))) (VarExp "GetNearestTarget")
,ConstExp NilVal]])
,ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsAttacking")
,ADTExp "Cons" [
AppExp (LambdaExp (VarPat "b") (AppExp (VarExp "HpRateLessThan") (VarExp "b"))) (VarExp "GetFleeBloodRate")
,ConstExp NilVal]])
,ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsNormal")
,ADTExp "Cons" [
AppExp (VarExp "loop") (AppExp (VarExp "par") (ADTExp "Cons" [
AppExp (LambdaExp (VarPat "c") (AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (AppExp (VarExp"IsNull") (VarExp "c"))
,ADTExp "Cons" [
AppExp (VarExp "LockTarget") (VarExp "c")
,ConstExp NilVal]]))) (VarExp "GetNearestTarget")
,ADTExp "Cons" [
AppExp (VarExp"seq") (ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "ReachCurrentPatrolPoint")
,ADTExp "Cons" [
VarExp "MoveToNextPatrolPoiont"
,ConstExp NilVal]])
,ADTExp "Cons" [
VarExp "Idle"
,ConstExp NilVal]])
,ConstExp NilVal]]))
,ConstExp NilVal]])
,ConstExp NilVal]]]))]
-- Prelude.bh
Right [DeclaresDec [
DeclareDec (VarPat "seq") (AppType [GenericType "List" (GenericType "IO" (NormalType "Result")),GenericType "IO" (NormalType "Result")])
,DeclareDec (VarPat "check") (AppType [NormalType "Bool",GenericType "IO" (NormalType "Result")])]]
-- BaseAI.bh
Right [DeclaresDec [
DeclareDec (VarPat "HpRateLessThan") (AppType [NormalType "Float",GenericType "IO" (NormalType "Result")])
,DeclareDec (VarPat "Idle") (GenericType "IO" (NormalType "Result"))]]
-- AI00001.bh
Right [
ImportDec "Prelude"
,ImportDec "BaseAI"
,DefineDec (VarPat "Root") (AppExp (VarExp "par") (ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsFleeing")
,ADTExp "Cons" [
AppExp (LambdaExp (VarPat "a")(AppExp (VarExp "check") (AppExp (VarExp "IsNull") (VarExp "a")))) (VarExp "GetNearestTarget")
,ConstExp NilVal]])
,ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsAttacking")
,ADTExp "Cons" [
AppExp (LambdaExp (VarPat "b") (AppExp (VarExp "HpRateLessThan") (VarExp "b"))) (VarExp "GetFleeBloodRate")
,ConstExp NilVal]])
,ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "IsNormal")
,ADTExp "Cons" [
AppExp (VarExp "loop") (AppExp (VarExp "par") (ADTExp "Cons" [
AppExp (LambdaExp (VarPat "c") (AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (AppExp (VarExp"IsNull") (VarExp "c"))
,ADTExp "Cons" [
AppExp (VarExp "LockTarget") (VarExp "c")
,ConstExp NilVal]]))) (VarExp "GetNearestTarget")
,ADTExp "Cons" [
AppExp (VarExp"seq") (ADTExp "Cons" [
AppExp (VarExp "seq") (ADTExp "Cons" [
AppExp (VarExp "check") (VarExp "ReachCurrentPatrolPoint")
,ADTExp "Cons" [
VarExp "MoveToNextPatrolPoiont"
,ConstExp NilVal]])
,ADTExp "Cons" [
VarExp "Idle"
,ConstExp NilVal]])
,ConstExp NilVal]]))
,ConstExp NilVal]])
,ConstExp NilVal]]]))]
前面两部分是我把在其他模块定义的 declares,选择性地拿过来两条。第三部分是这个人形怪 AI 的整个的 AST。其中嵌套的 Cons 展开之后就是语言内置的 List。
正如我们之前所说,做代码生成之前需要进行一步类型检查的工作。类型检查工具其输入是 AST 其输出是一个检查结果,同时还可以提供 AST 中的一些辅助信息,包括各标识符的类型信息等等。
类型检查其实主要的逻辑在于处理 Appliacative Type,这中间还有个类型推导的逻辑。形如 (\a (Func a)) 10,AST 中并不记录 a 的 type,我们的 DSL 也不需要支持 concept、typeclass 等有关 type、subtype 的复杂机制,推导的时候只需要着重处理 AppExp,把右边表达式的类型求出,合并一下 env 传给左边表达式递归检查即可。
这部分的代码:
exp_type :: Exp -> TypeEnv -> Maybe Type
exp_type (AppExp lexp aexp) env =
(exp_type aexp env) >>= (\at ->
case lexp of
LambdaExp (VarPat var) exp -> (merge_type_env (Just env) (make_type_env var (Just at))) >>= (\env1 -> exp_type lexp env1)
_ -> (exp_type lexp env) >>= (\ltype -> check_type ltype at))
where
check_type (AppType (t1:(t2:[]))) at =
if t1 == at then (Just t2) else Nothing
check_type (AppType (t:ts)) at =
if t == at then (Just (AppType ts)) else Nothing
exp_type :: Exp -> TypeEnv -> Maybe Type
exp_type (AppExp lexp aexp) env =
(exp_type aexp env) >>= (\at ->
case lexp of
LambdaExp (VarPat var) exp -> (merge_type_env (Just env) (make_type_env var (Just at))) >>= (\env1 -> exp_type lexp env1)
_ -> (exp_type lexp env) >>= (\ltype -> check_type ltype at))
where
check_type (AppType (t1:(t2:[]))) at =
if t1 == at then (Just t2) else Nothing
check_type (AppType (t:ts)) at =
if t == at then (Just (AppType ts)) else Nothing
此外,还需要有一个通用的 CodeGenerator 模块,其输入也是 AST,其输出是另一些 AST 中的辅助信息,主要是注记下各标识符的 import 源以及具体的 define 内容,用来方便各目标语言 CodeGenerator 直接复用逻辑。
目标语言的 CodeGenerator 目前只做了 C# 的。
目标代码生成的逻辑就比较简单了,毕竟该有的信息前面的各模块都提供了,这里根据之前一个版本的 runtime,代码生成的大致样子:
public static IO<Result> Root =
Prelude.par(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsFleeing)
,(((Box<Object> a) => Help.With(a, BaseAI.GetNearestTarget, Prelude.check(BaseAI.IsNull())))(new Box<Object>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsAttacking)
,(((Box<Float> b) => Help.With(b, BaseAI.GetFleeBloodRate, BaseAI.HpRateLessThan()))(new Box<Float>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNormal)
,Prelude.loop(Prelude.par(Help.MakeList(
(((Box<Object> c) => Help.With(c, BaseAI.GetNearestTarget, Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNull())
,BaseAI.LockTarget()))))(new Box<Object>()))
,Prelude.seq(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.ReachCurrentPatrolPoint)
,BaseAI.MoveToNextPatrolPoiont))
,BaseAI.Idle)))))))))
public static IO<Result> Root =
Prelude.par(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsFleeing)
,(((Box<Object> a) => Help.With(a, BaseAI.GetNearestTarget, Prelude.check(BaseAI.IsNull())))(new Box<Object>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsAttacking)
,(((Box<Float> b) => Help.With(b, BaseAI.GetFleeBloodRate, BaseAI.HpRateLessThan()))(new Box<Float>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNormal)
,Prelude.loop(Prelude.par(Help.MakeList(
(((Box<Object> c) => Help.With(c, BaseAI.GetNearestTarget, Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNull())
,BaseAI.LockTarget()))))(new Box<Object>()))
,Prelude.seq(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.ReachCurrentPatrolPoint)
,BaseAI.MoveToNextPatrolPoiont))
,BaseAI.Idle)))))))))
总的来说,大致分为这几个模块:Parser、TypeChecker、CodeGenerator、目标语言的 CodeGenerator。再加上目标语言的 runtime,基本上就可以组成这个 DSL 的全部了。
再扩展 runtime
对比 DSL,我们可以发现,DSL 支持的特性要比之前实现的 runtime 版本多。比如:
runtime 中压根就没有 Closure 的概念,但是 DSL 中我们是完全可以把一个 lambda 作为一个 ClosureVal 传给某个函数的。
缺少对标准库的支持。比如常用的 math 函数。 基于上面这点,还会引入一个 With 结点的性能问题,在只有 runtime 的时候我们也许不会 With a <- 1+1。但是 DSL 中是有可能这样的,而且生成出来的代码会每次 run 这棵树的时候都会重新计算一次 1+1。
针对第一个问题,我们要做的工作就多了。首先我们要记录下这个闭包 hold 住的自由变量,要传给 runtime,runtime 也要记录,也要做各种各种,想想都麻烦,而且完全偏离了游戏 AI 的话题,不再讨论。
针对第二个问题,我们可以通过解决第三个问题来顺便解决这个问题。
针对第三个问题,我们重新审视一下 With 语义。
With 语义所要表达的其实是这样一个概念:
把一个可能会 Continue/Lazy Evaluation 的计算结果,绑定到一个 variable 上,对于 With 下面的子表达式来说,这个 variable 的值具有 lexical scope。
但是在 runtime 中,我们按照之前的写法,subtree 中直接就进行了函数调用,很显然是存在问题的。
With 结点本身的返回值不一定只是一个 IO<Result>,有可能是一个 IO<float>。
举例:
((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>())
((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>())
这里 Math.Plus 属于这门 DSL 标准库的一部分,实现上我们就对底层数学函数做一层简单的 wrapper。但是这样由于 C# 语言是 pass-by-value,我们在构造这颗 With 的时候,Math.Plus(a, 0.1) 已经求值。但是这个时候 Box 的值还没有被填充,求出来肯定是有问题的。
所以我们需要对这样一种计算再进行一次抽象。希望可以得到的效果是,对于 Math.Plus(0.1, 0.2),可以在构造树的时候直接求值;对于 Math.Plus(0.1, a),可以得到某种计算,在我们需要的时候再求值。 先明确下函数调用有哪几种情况:
对 UnitAI,也就是外部世界的定义的接口的调用。这种调用,对于 AI 模块来说,本质上是 pure 的,所以不需要考虑这个延迟计算的问题
对标准库的调用
按我们之前的 runtime 设计思路,Math.Plus 这个标准库 API 也许会被设计成这样:
public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b)
{
return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue());
}
public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b)
{
return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue());
}
如果 a 和 b 都是 literal value,那就没问题,但是如果有一个是被 box 包裹的,那就很显然是有问题的。
所以需要对 Thunk 这个概念做一下扩展,使之能区别出动态的值与静态的值。一般情况下的值,都是 pure 的;box 包裹的值,是 impure 的。同时,这个 pure 的性质具有值传递性,如果这个值属于另一个值的一部分,那么这个整体的 pure 性质与值的局部的 pure 性质是一致的。这里特指的值,包括 List 与 IO。
整体的概念我们应该拿 haskell 中的 impure monad 做类比,比如 haskell 中的 IO。haskell 中的 IO 依赖于 OS 的输入,所以任何返回 IO monad 的函数都具有传染性,引用到的函数一定还会被包裹在 IO monad 之中。
所以,对于 With 这种情况的传递,应该具有这样的特征:
- With 内部引用到了 With 外部的 symbol,那么这个 With 本身应该是 impure 的。
- With 内部只引用了自己的 IOGet,那么这个 With 本身是 pure 的,但是其 SubTree 是 impure 的。
- 所以 With 结点构造的时候,计算 pure
有了 pure 与 impure 的标记,我们在对函数调用的时候,就需要额外走一层。
本来一个普通的函数调用,比如 UnitAI.Func(p0, p1, p2) 与 Math.Plus(p0, p1)。前者返回一种 computing 是毫无疑问的,后者就需要根据参数的类型来决定是返回一种计算还是直接的值。
为了避免在这个 Plus 里面改来改去,我们把 Closure 这个概念给抽象出来。同时,为了简化讨论,我们只列举 T0 -> TR 这一种情况,对应的标准库函数取 Abs。
public class Closure<T0, TR> : Thunk<Closure<T0, TR>>
{
class UserFuncApply : Thunk<TR>
{
private Closure<T0, TR> func;
private Thunk<T0> p0;
public UserFuncApply(Closure<T0, TR> func, Thunk<T0> p0)
{
this.func = func;
this.p0 = p0;
this.pure = false;
}
public override TR GetUserValue()
{
return func.funcThunk(p0).GetUserValue();
}
}
private bool isUserFunc = false;
private FuncThunk<T0, TR> funcThunk;
private Func<T0, TR> userFunc;
public Closure(FuncThunk<T0, TR> funcThunk)
{
this.funcThunk = funcThunk;
}
public Closure(Func<T0, TR> func)
{
this.userFunc = func;
this.funcThunk = p0 => Help.MakePureThunk(userFunc(p0.GetUserValue()));
this.isUserFunc = true;
}
public override Closure<T0, TR> GetUserValue()
{
return this;
}
public Thunk<TR> Apply(Thunk<T0> p0)
{
if (!isUserFunc || Help.AllPure(p0))
{
return funcThunk(p0);
}
return new UserFuncApply(this, p0);
}
}
public class Closure<T0, TR> : Thunk<Closure<T0, TR>>
{
class UserFuncApply : Thunk<TR>
{
private Closure<T0, TR> func;
private Thunk<T0> p0;
public UserFuncApply(Closure<T0, TR> func, Thunk<T0> p0)
{
this.func = func;
this.p0 = p0;
this.pure = false;
}
public override TR GetUserValue()
{
return func.funcThunk(p0).GetUserValue();
}
}
private bool isUserFunc = false;
private FuncThunk<T0, TR> funcThunk;
private Func<T0, TR> userFunc;
public Closure(FuncThunk<T0, TR> funcThunk)
{
this.funcThunk = funcThunk;
}
public Closure(Func<T0, TR> func)
{
this.userFunc = func;
this.funcThunk = p0 => Help.MakePureThunk(userFunc(p0.GetUserValue()));
this.isUserFunc = true;
}
public override Closure<T0, TR> GetUserValue()
{
return this;
}
public Thunk<TR> Apply(Thunk<T0> p0)
{
if (!isUserFunc || Help.AllPure(p0))
{
return funcThunk(p0);
}
return new UserFuncApply(this, p0);
}
}
其中,UserFuncApply 就是之前所说的一层计算的概念。UserFunc 表示的是等效于可以编译期计算的一种标准库函数。
这样定义:
public static class Math
{
public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs);
}
public static class Math
{
public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs);
}
Message 类型的 Closure 构造,都走 FuncThunk 构造函数;普通函数类型的构造,走 Func 构造函数,并且包装一层。
Help.Apply 是为了方便做代码生成,描述一种 declarative 的 Application。其实就是直接调用 Closure 的 Apply。
考虑以下几种 case:
public void Test()
{
var box1 = new Box<float>();
// Math.Abs(box1) -> UserFuncApply
// 在GetUserValue的时候才会求值
var ret1 = Help.Apply(Math.Abs, box1);
// Math.Abs(0.2f) -> Thunk<float>
// 直接构造出来了一个Thunk<float>(0.2f)
var ret2 = Help.Apply(Math.Abs, Help.MakePureThunk(0.2f));
// UnitAISets<IUnit>.HpRateLessThan(box1) -> Message
var ret3 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, box1);
// UnitAISets<IUnit>.HpRateLessThan(0.2f) -> Message
var ret4 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, Help.MakePureThunk(0.2f));
}
public void Test()
{
var box1 = new Box<float>();
// Math.Abs(box1) -> UserFuncApply
// 在GetUserValue的时候才会求值
var ret1 = Help.Apply(Math.Abs, box1);
// Math.Abs(0.2f) -> Thunk<float>
// 直接构造出来了一个Thunk<float>(0.2f)
var ret2 = Help.Apply(Math.Abs, Help.MakePureThunk(0.2f));
// UnitAISets<IUnit>.HpRateLessThan(box1) -> Message
var ret3 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, box1);
// UnitAISets<IUnit>.HpRateLessThan(0.2f) -> Message
var ret4 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, Help.MakePureThunk(0.2f));
}
与之前的 runtime 版本唯一表现上有区别的地方在于,对于纯 pure 参数的 userFunc,在 Apply 完之后会直接计算出来值,并重新包装成一个 Thunk;而对于参数中有 impure 的情况,返回一个 UserFuncApply,在 GetUserValue 的时候才会求值。
TODO
到目前为止,已经形成了一套基本的、non-trivial 的游戏 AI 方案,当然后续还有很多要做的工作,比如:
更多的语言特性:
- DSL 中支持注释、函数作为普通的 value 传递等等。
- parser、typechecker 支持更完善的错误处理,我之前单独写一个用例的时候,就因为一些细节问题,调试了老半天。
- 标准库支持更多,比如 Y-Combinator
编辑器化:
AI 的配置也需要有编辑器,这个编辑器至少能实现的需求有这样几个:
与自己定义的中间层对接良好(配置文件也好、DSL 也好),具有 codegen 功能
支持工作空间、支持模块化定义,制作一些 prefab 什么的
支持可视化调试