游戏中人工智能的优化

创建一个简单的游戏引擎和人工智能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

叶子结点也能够概括一下 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 模块中对游戏世界的数据进行操做,请求一个数据 => 处理一下 => 返回一个数据,更具扩展性。app

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 什么的
  • 支持可视化调试

心动了吗?还不赶忙动起来,打造属于本身的游戏世界!顿时满满的自豪感,真的很想知道你们的想法,还请持续关注更新,更多干货和资料请直接联系我,也能够加群710520381,邀请码:柳猫,欢迎你们共同讨论

相关文章
相关标签/搜索