最新开源方案!Cocos Creator 写一个ECS框架+行为树,实现格斗游戏 AI

共 7225字,需浏览 15分钟

 ·

2022-05-13 12:05

引言:实现游戏 AI 的方式有很多,目前最为常用的主要有有限状态机和行为树。和有限状态机相比,行为树有更好的可扩展性和灵活性,能实现更复杂的 AI 需求。开发者 honmono 在 Cocos Creator 中用一个 ECS + BehaviorTree 框架实现了一个格斗 AI Demo,一起来看看他的方案。


Demo 示例


这个格斗 AI Demo 包含了巡逻、追踪、攻击、躲避攻击、受伤打断攻击、攻击打断闪避等。源码见文末。


写一个 ECS 框架


ECS 全称 Entity - Component - System(实体-组件-系统)。组件只有属性没有行为,系统只有行为没有属性。


什么是 ECS 呢?网上已经有很多介绍 ECS 的文章了,这里不再赘述,直接贴几篇我个人觉得写的好的,谈一谈我的理解:


  • 《浅谈<守望先锋>中的 ECS 架构》

https://blog.codingnow.com/2017/06/overwatch_ecs.html

这篇文章应该是最早一批介绍 ECS 架构的文章了,不仅全面地介绍了 ECS 架构,还对比了 ECS 和传统游戏开发架构的区别,以及在网络同步时的处理。


  • 《游戏开发中的 ECS 架构概述》

https://zhuanlan.zhihu.com/p/30538626

这篇文章比较接地气,文中提到:

ECS 的模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件 MoveComponent 包含速度、位置、朝向等属性,一旦一个实体拥有了 MoveComponent 组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有 MoveComponent 组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。

实体组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。

这段话对于 ECS 的关系也是我设计的框架遵守的规则,即 Component 只包含属性,System 只包含行为。


这个 ECS 框架做了什么


World-Entity-Component-System 的关系图


World 每帧根据顺序调用所有的 System,System 中会处理对应的 Component。在这个过程中,使用者可以动态创建或销毁 Entity,给 Entity 添加或移除 Component。


为了更高效地维护 Entity-Component-System 的关系,我采取了一些办法。


1、所有的 Component 都通过其对应的 ComponentPool 维护。


例如 MoveComponent 会生成一个 MoveComponentPool 进行维护,方便实现复用。 外面不直接持有 Component,而是通过 component 在 pool 中的 index 索引便可以在对应的 pool 中获取到对应的 Component。


2、Entity 使用自增 Idx 标识。当有 entity 被销毁时,会回收 idx。


外部不需要持有 Entity 对象,或者说没有 Entity 对象,所有的 Entity 都是一个 Idx, 通过这个 Idx 在 world 内进行操作。


3、System 通过 Filter 类管理关注的 Entity。


上文中提到 System 为了处理其关心的 ComponentA,会遍历所有拥有该 ComponentA 的 Entity。但是怎么判断哪下 Entity 有这个 ComponentA 呢? 传统的方法会遍历所有的 Entity,判断其是否有 ComponentA,这种方案明显是不够高效的。 所以这里引入了 Filter 类, 其方法的核心是空间换时间并有一套判断规则(接收某些类型的组件, 拒接某些类型的组件), 当每次进行 AddComponent 和 RemoveComponent 或者 RemoveEntity 等会影响实体的操作时,会通知所有的 Filter 有实体进行了修改,Filter 会判断该实体是否还符合条件(也就是判断是否有 ComponentA),选择是否在 Filter 中保留该实体。那么当 System 需要遍历某些特定的 Entity 时,就可以直接通过对应的 Filter 就可以获得了。


4、Entity 和 Component 的关系可以用一张二维表维护。



如上述表格中 Component 数字的意思是其在 Pool 中的 index 索引,-1表示没有。所以 Entity1 有组件 ComponentB,ComponentD。Entity1 有组件 ComponentB, ComponentC。


还有最后一个问题就是 如何将 Entity 和 Component 转换成 0~N 的整数以方便构建二维数组呢?


对于 Entity 可以通过 id 的自增实现,每创建一个 Entity,id++。而 Component 可以根据类型的枚举值得到唯一标识。如下面的枚举值。

export enum ComType {
    ComCocosNode = 0,
    ComMovable = 1,
    ComNodeConfig = 2,
    ComBehaviorTree = 3,
    ComTransform = 4,
    ComMonitor = 5,
    ComRoleConfig = 6,
    ComAttackable = 7,
    ComBeAttacked = 8
}


这样就可以构建出上面的二维表了。


最后还可以通过 ts 的注解,将 ComType 注入到对应的 Component 类中。将 type 和 component 一一对应起来。

// 项目中一个Component.
@ECSComponent(ComType.ComMovable)
export class ComMovable {
    public running = false;
    public speed = 0;
    public points: cc.Vec2[] = [];
    public pointIdx = 0;
    public keepDir = false;
    public speedDirty = false;
}


到这一步就已经完成框架部分了。再展示一下 ComMovable 对应的 SysMovable。这个 System 会每帧根据 ComMovable 的当前状态,计算出下一帧的 ComMovable 状态。


这里插入一下对于 Filter 的更详细的介绍。Filter 的判断规则是通过参数判断接收某些类型的组件,拒接某些类型的组件。比如这个参数 [ComMovable, ComTransform, ComCocosNode] 表示这个 Filter 保存的是同时含有 ComMovable,ComTransform,ComCocosNode 组件的实体。

const FILTER_MOVE = GenFilterKey([ComMovable, ComTransform, ComCocosNode]);
export class SysMovable extends ECSSystem {
    /** 更新 */
    public onUpdate(world: ECSWorld, dt:number): void {
        world.getFilter(FILTER_MOVE).walk((entity: number) => {
            let comMovable = world.getComponent(entity, ComMovable);
            let comTrans = world.getComponent(entity, ComTransform);
            if(comMovable.speed <= 0 || comMovable.pointIdx >= comMovable.points.length) {
                return ;
            }
            if(!comMovable.running) {
                comMovable.running = true;
            }
            let moveLen = comMovable.speed * dt;
            while(moveLen > 0 && comMovable.pointIdx < comMovable.points.length) {
                let nextPoint = comMovable.points[comMovable.pointIdx];
                let offsetX = nextPoint.x - comTrans.x;
                let offsetY = nextPoint.y - comTrans.y;
                let offsetLen = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
                if(offsetLen <= moveLen) {
                    moveLen -= offsetLen;
                    comTrans.x = nextPoint.x;
                    comTrans.y = nextPoint.y;
                    comMovable.pointIdx ++;
                    continue;
                }
                if(!comMovable.keepDir) {
                    comTrans.dir.x = offsetX / offsetLen || comTrans.dir.x;
                    comTrans.dir.y = offsetY / offsetLen;
                }
                comTrans.x += moveLen * offsetX / offsetLen;
                comTrans.y += moveLen * offsetY / offsetLen;
                
                moveLen = -1;
            }
            if(comMovable.pointIdx >= comMovable.points.length) {
                comMovable.speed = 0;
                comMovable.speedDirty = true;
            }
            return false;
        });
    }
}


ECS 框架和 Cocos Creator 结合


ECS 框架本身的实现不难,核心代码只有几百行的样子,但是如何将这个框架和 Cocos Creator 结合起来,或者说怎么展示一个 Node,并可以通过 ECS 的方式操控 Node 呢?


以上面的 ComMovable 和 SysMovable 为例,ECS 本身更多的是数据上的逻辑处理,Entity 添加了 ComMovable 组件就获得了 SysMovable 的能力,那么给 Entity 添加一个显示 Node 的组件(ComCocosNode),再通过一个处理 ComCocosNode 的 System(SysCocosView) 是不是就实现了展示 node 的能力呢。


1、设计结合 Cocos 中 Node 的组件


基于这个想法我设计了 ComCocosNode。

@ECSComponent(ComType.ComCocosNode)
export class ComCocosNode {
    public node: cc.Node = null;
    public loaded = false;
    public events: EventBase[] = [];
}


ComCocosNode 中有 node 属性。通过 Entity 获取到 ComCocosNode 组件就可以修改 node 的属性了,而 events 是因为对于 node 我们不仅有同步的处理,也有一些异步的处理,比如播放一系列动画,这种可以通过添加事件的方式,即在 system 不直接调用 node 的组件方法,而是让组件自己读取 event,自己去处理。


这个时候 node 还没有赋值,所以我又设计了一个组件 ComNodeConfig。

@ECSComponent(ComType.ComNodeConfig)
export class ComNodeConfig {
    id = 0;                 // 唯一标识
    prefabUrl = '' 
    layer = 0;              // 层级
}


这里可能会有人有疑问,为什么不把这两个组件的属性合并到一起,这个其实是为了方便配置,ComNodeConfig 的属性都是可以直接配置在表上的,这样的话就方便配置同学了。


2、设计处理 ComNodeConfig 的 System


最后通过一个 SysCocosView 系统,这个系统处理的实体是有 ComNodeConfig 组件, 但是没有 ComCocosNode 组件的实体。每次遍历时根据 ComNodeConfig 组件的 prefabUrl 加载 prefab 生成 node,根据 layer 层级将 node 添加到指定位置,然后给这个实体添加 ComCocosNode 组件,将 node 值赋上。这样下一次就不会处理这个实体了。下面的代码是 Demo 已经完成后的代码了,我就不还原到刚开始时候的样子了。

const FILTER_COCOS_NODE = GenFillterKey([ComNodeConfig], [ComCocosNode]);
const FILTER_NODE_EVENT = GenFillterKey([ComCocosNode, ComTransform]);
export class SysCocosView extends ECSSystem implements ITouchProcessor {

    onUpdate(world:ECSWorld, dt:number) {
        world.getFilter(FILTER_COCOS_NODE).walk((entity: number) => {
            let comNodeConfig = world.getComponent(entity, ComNodeConfig);
            let comView = world.addComponent(entity, ComCocosNode);
            let comRoleConfig = world.getComponent(entity, ComRoleConfig);
            this._loadView(world, entity, comNodeConfig).then((node: cc.Node) => {
                console.log('load view success');
            });
            return false;
        });

        world.getFilter(FILTER_NODE_EVENT).walk((entity: number) => {
            let comCocosNode = world.getComponent(entity, ComCocosNode);
            if(!comCocosNode.loaded) return ;
            let eventProcess = comCocosNode.node.getComponent(EventProcess);
            if(!eventProcess) return ;
            let comTrans = world.getComponent(entity, ComTransform);
            eventProcess.sync(comTrans.x, comTrans.y, comTrans.dir);
            while(comCocosNode.events.length) {
                let event = comCocosNode.events.shift();
                eventProcess.processEvent(event);
            }
            return true;
        });
    }
}


写一个 BehaviorTree


对 BehaviroTree(行为树)的介绍网上也有很多文章可以参考,感兴趣的可以深入了解一下:


  • 《游戏 AI 之决策结构——行为树》

https://www.cnblogs.com/KillerAery/p/10007887.html


  • 《AI 行为树的工作原理》

https://indienova.com/indie-game-development/ai-behavior-trees-how-they-work/

行为树的名字很好地解释了它是什么。不像有限状态机(Finite State Machine)或其他用于 AI 编程的系统,行为树是一棵用于控制 AI 决策行为的、包含了层级节点的树结构。树的最末端——叶子,就是这些 AI 实际上去做事情的命令;连接树叶的树枝,就是各种类型的节点,这些节点决定了 AI 如何从树的顶端根据不同的情况,来沿着不同的路径来到最终的叶子这一过程。

行为树可以非常地“深”,层层节点向下延伸。凭借调用实现具体功能的子行为树,开发者可以建立相互连接的行为树库来做出非常让人信服的 AI 行为。并且,行为树的开发是高度迭代的,你可以从一个很简单的行为开始,然后做一些分支来应对不同的情境或是实现不同的目标,让 AI 的诉求来驱动行为,或是允许 AI 在行为树没有覆盖到的情境下使用备用方案等等。

树的最末端叶子是 AI 实际上做的事的命令,可以称为行为,行为是需要用户编写的具体的动作,比如移动到某位置、攻击、闪避等。联系根到叶子的节点的中间节点可以称为决策节点,决策节点并没有实际的行为,而是决定是否可以向下执行到叶子节点。


那么它是如何决定的呢?


工作原理


每一个节点执行后都会返回一个状态,状态有三种:Success、Fail 和 Running


Success 和 Fail 很好理解,比如一个监视节点,看到了敌人返回 Success,没看到返回 Fail。


但是对于一个需要执行一段时间的节点,比如 1s 内移动五步,在不到 1s 时去看节点的状态,这个时候返回成功或者失败都是不合理的,所以这种情况应该返回 Running 表示这个节点还在执行中,等下一帧在继续判断。


这个时候我们可以设计这样一个节点,它的状态是和子节点状态挂钩的,按顺序执行子节点,如果遇到了执行失败的节点则返回失败,如果全部执行成功则返回成功。这种节点可以称为 Sequence


类似的节点还有 Selector,这个节点的状态是按顺序执行子节点,如果全部执行失败则返回失败,如果遇到执行成功则返回成功。


下面举一个实际项目 Sequence 的实现例子。


/** Sequence node */
NodeHandlers[NodeType.Sequence] = {
    onEnter(node: SequenceNode, context: ExecuteContext) : void {
        node.currIdx = 0;
        context.executor.onEnterBTNode(node.children[node.currIdx], context);
        node.state = NodeState.Executing;
    },
    onUpdate(node: SequenceNode, context: ExecuteContext) : void {
        if(node.currIdx < 0 || node.currIdx >= node.children.length) {
            // 越界了, 不应该发生, 直接认为是失败了
            node.state = NodeState.Fail;
            return;
        }
        // 检查前置条件是否满足
        for(let i=0; i            context.executor.updateBTNode(node.children[i], context);
            if(node.children[i].state !== NodeState.Success) return;
        }
        context.executor.updateBTNode(node.children[node.currIdx], context);
        let state = node.children[node.currIdx].state;
        if(state == NodeState.Executing) return;

        if(state === NodeState.Fail && !node.ignoreFailure) {
            node.state = NodeState.Fail;
            return;
        }
        if(state === NodeState.Success && node.currIdx == node.children.length-1) {
            node.state = NodeState.Success;
            return ;
        }
        context.executor.onEnterBTNode(node.children[++node.currIdx], context);
    }
};


这两个节点的组合就可以实现 if else 的效果,假设当角色在门前,如果有钥匙就开门,如果没有就砸门:

  • 如果有钥匙,Sequence 的第一个子节点(检查是否有钥匙)执行成功,那么就会去执行第二个子节点(钥匙开门)。Sequence 执行成功即不会执行后面的斧头砸门节点。

  • 如果没有钥匙,Sequence 执行失败,那么就执行后面的斧头砸门节点。


决策的时效性


根据我看的一些文档,对于行为树的决策是每一帧都要更新的,比如现在有一个场景,用户可以输入文本,输入 move 让方块 A 向前移动10格子,输入 stop 方块 A 停止移动。那么对于行为树来说,每一帧都要判断当前用户输入的是 move,还是 stop,从而下达是移动还是停止的行为。


  • 对于移动,行为是 sequence([用户输入move, 移动]),用 ActionA 代替;

  • 对于停止,行为是 sequence([用户输入stop, 停止]),用 ActionB 代替;

  • 最终行为是 select([ActionA, ActionB])。


sequence 表示按顺序执行子行为,如果遇到子行为执行失败,那么立刻停止,并返回失败,如果全部执行成功,那么返回成功。

select 表示按顺序执行子行为,如果遇到子行为执行成功,那么立即停止,并返回成功。如果全部执行失败,那么返回失败。


假设现在用户点击一下,那么每帧都需要从头一层层向下判断执行,直到判断到移动再执行——当然这是有必要的,对于行为来说确定自己能否应该执行是十分重要的。


但是这对执行一个持续性的行为很不友好。假设还是上面的场景,用户输入 sleep,方块停止移动 2s。就是 sequence([用户输入sleep,停止移动2s]),这个时候行为树是 select([ActionA,ActionB,ActionC]);


那么当我输入 sleep,方块停止移动的时间内,输入 move,那么下一帧的决策就进入了 ActionA,导致方块移动。停止移动 2s 的行为被打断了。


这个问题我称为行为树决策的时效性,也就是行为得到的决策并不能维持一定时间。这个决策其实目前只是 Sequence 和 Select  才拥有的。


如何解决?


因为我是自己想的, 所以解决方案可能不是最优的。


首先我加入了具有时效性的 LockedSequence,LockedSelect,也就是当前行为的决策一旦做出,就必须在当前行为完全结束后才能被修改。


引入一丢丢代码,在 onEnter 时,将当前行为的状态置为 running,在 onUpdate 时判断,如果当前状态不是 running 就 return。所以一旦状态确定,就不会onUpdate 中被修改,直到下一次进入 onEnter。

class NodeHandler {

onEnter:(node: NodeBase, context: ExecuteContext) => void;

onUpdate:(node: NodeBase, context: ExecuteContext) => void;

}


这个时候在带入上述的场景就没问题了,当用户输入 sleep 时,方块停止移动 2s,在 2s 内输入 move,并不会导致 ActionC 的决策更改,直到停止移动 2s 的行为结束,进入下一个周期后才会进入 ActionA。


但是这个同样也导致了另一个问题,就是并行的行为。比如假设一个场景,士兵一边巡逻,一边观察是否有敌人,如果有敌人,那么停止巡逻,去追击敌人。


行为树如下:

  • ActionA = 巡逻

  • ActionB = sequence([观察是否有敌人, 追击敌人]);

  • repeat(sequence([ActionB, ActionA]))


因为上面的方法在行为结束前不会修改决策,那么就会出现士兵先观察是否有敌人,没有就去巡逻,巡逻完了,再去观察是否有敌人,这就太蠢了。


我的解决方案是添加一个新的决策 Node,这个 Node 就是处理并行行为的。


parallel 的能力是顺序处理子节点,但是并不需要等待前一个节点执行完毕后才能执行后一个。当有行为返回失败时,立即退出返回失败,当所有行为返回成功时,停止返回成功。


行为树如下:

  • repeat(selector([parallel([Inverter(观察是否有敌人), 巡逻]), 追击敌人]))

注:Inverter 表示取反。


当前没有发现有敌人,那么行为在巡逻,parallel 还在 running 阶段,因为巡逻不会失败,所以最后一种情况是如果发现敌人,那么 parallel 立即返回失败,那么行为就到了追击敌人了。


最后展示一下 Demo 中的完整行为树:





Demo 下载

https://github.com/kirikayakazuto/CocosCreator_ECS


论坛讨论帖

https://forum.cocos.org/t/topic/133443


往期精彩
浏览 200
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报