当前位置:   article > 正文

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

cocoscreator ecs

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

99f854025293248d5a49ca3c8fba6826.gif

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 框架做了什么

35ab8096c7b30dae3d81502f4f7e0b3d.png

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 的关系可以用一张二维表维护。

eb50c7e6a54945536a1a796cbe806dbe.png

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

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

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

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

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

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

  1. // 项目中一个Component.
  2. @ECSComponent(ComType.ComMovable)
  3. export class ComMovable {
  4.     public running = false;
  5.     public speed = 0;
  6.     public points: cc.Vec2[] = [];
  7.     public pointIdx = 0;
  8.     public keepDir = false;
  9.     public speedDirty = false;
  10. }

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

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

  1. const FILTER_MOVE = GenFilterKey([ComMovable, ComTransform, ComCocosNode]);
  2. export class SysMovable extends ECSSystem {
  3.     /** 更新 */
  4.     public onUpdate(world: ECSWorld, dt:number): void {
  5.         world.getFilter(FILTER_MOVE).walk((entity: number) => {
  6.             let comMovable = world.getComponent(entity, ComMovable);
  7.             let comTrans = world.getComponent(entity, ComTransform);
  8.             if(comMovable.speed <= 0 || comMovable.pointIdx >= comMovable.points.length) {
  9.                 return ;
  10.             }
  11.             if(!comMovable.running) {
  12.                 comMovable.running = true;
  13.             }
  14.             let moveLen = comMovable.speed * dt;
  15.             while(moveLen > 0 && comMovable.pointIdx < comMovable.points.length) {
  16.                 let nextPoint = comMovable.points[comMovable.pointIdx];
  17.                 let offsetX = nextPoint.x - comTrans.x;
  18.                 let offsetY = nextPoint.y - comTrans.y;
  19.                 let offsetLen = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
  20.                 if(offsetLen <= moveLen) {
  21.                     moveLen -= offsetLen;
  22.                     comTrans.x = nextPoint.x;
  23.                     comTrans.y = nextPoint.y;
  24.                     comMovable.pointIdx ++;
  25.                     continue;
  26.                 }
  27.                 if(!comMovable.keepDir) {
  28.                     comTrans.dir.x = offsetX / offsetLen || comTrans.dir.x;
  29.                     comTrans.dir.y = offsetY / offsetLen;
  30.                 }
  31.                 comTrans.x += moveLen * offsetX / offsetLen;
  32.                 comTrans.y += moveLen * offsetY / offsetLen;
  33.                 
  34.                 moveLen = -1;
  35.             }
  36.             if(comMovable.pointIdx >= comMovable.points.length) {
  37.                 comMovable.speed = 0;
  38.                 comMovable.speedDirty = true;
  39.             }
  40.             return false;
  41.         });
  42.     }
  43. }

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。

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

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

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

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

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

2、设计处理 ComNodeConfig 的 System

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

  1. const FILTER_COCOS_NODE = GenFillterKey([ComNodeConfig], [ComCocosNode]);
  2. const FILTER_NODE_EVENT = GenFillterKey([ComCocosNode, ComTransform]);
  3. export class SysCocosView extends ECSSystem implements ITouchProcessor {
  4.     onUpdate(world:ECSWorld, dt:number) {
  5.         world.getFilter(FILTER_COCOS_NODE).walk((entity: number) => {
  6.             let comNodeConfig = world.getComponent(entity, ComNodeConfig);
  7.             let comView = world.addComponent(entity, ComCocosNode);
  8.             let comRoleConfig = world.getComponent(entity, ComRoleConfig);
  9.             this._loadView(world, entity, comNodeConfig).then((node: cc.Node) => {
  10.                 console.log('load view success');
  11.             });
  12.             return false;
  13.         });
  14.         world.getFilter(FILTER_NODE_EVENT).walk((entity: number) => {
  15.             let comCocosNode = world.getComponent(entity, ComCocosNode);
  16.             if(!comCocosNode.loaded) return ;
  17.             let eventProcess = comCocosNode.node.getComponent(EventProcess);
  18.             if(!eventProcess) return ;
  19.             let comTrans = world.getComponent(entity, ComTransform);
  20.             eventProcess.sync(comTrans.x, comTrans.y, comTrans.dir);
  21.             while(comCocosNode.events.length) {
  22.                 let event = comCocosNode.events.shift();
  23.                 eventProcess.processEvent(event);
  24.             }
  25.             return true;
  26.         });
  27.     }
  28. }

写一个 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 的实现例子。

1fb9e23e511d9e0d6b0a085f62387e8d.png

  1. /** Sequence node */
  2. NodeHandlers[NodeType.Sequence] = {
  3.     onEnter(node: SequenceNode, context: ExecuteContext) : void {
  4.         node.currIdx = 0;
  5.         context.executor.onEnterBTNode(node.children[node.currIdx], context);
  6.         node.state = NodeState.Executing;
  7.     },
  8.     onUpdate(node: SequenceNode, context: ExecuteContext) : void {
  9.         if(node.currIdx < 0 || node.currIdx >= node.children.length) {
  10.             // 越界了, 不应该发生, 直接认为是失败了
  11.             node.state = NodeState.Fail;
  12.             return;
  13.         }
  14.         // 检查前置条件是否满足
  15.         for(let i=0; i<node.currIdx; i++) {
  16.             context.executor.updateBTNode(node.children[i], context);
  17.             if(node.children[i].state !== NodeState.Success) return;
  18.         }
  19.         context.executor.updateBTNode(node.children[node.currIdx], context);
  20.         let state = node.children[node.currIdx].state;
  21.         if(state == NodeState.Executing) return;
  22.         if(state === NodeState.Fail && !node.ignoreFailure) {
  23.             node.state = NodeState.Fail;
  24.             return;
  25.         }
  26.         if(state === NodeState.Success && node.currIdx == node.children.length-1) {
  27.             node.state = NodeState.Success;
  28.             return ;
  29.         }
  30.         context.executor.onEnterBTNode(node.children[++node.currIdx], context);
  31.     }
  32. };

这两个节点的组合就可以实现 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。

  1. class NodeHandler {
  2. onEnter:(node: NodeBase, context: ExecuteContext) => void;
  3. onUpdate:(node: NodeBase, context: ExecuteContext) => void;
  4. }

这个时候在带入上述的场景就没问题了,当用户输入 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 中的完整行为树:

0b86229362171252b8d60792579e6705.png


Demo 下载

https://github.com/kirikayakazuto/CocosCreator_ECS

论坛讨论帖

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

往期精彩

5dad8eb8660e3b045df8ca122fda8f8f.png

926924e062cec5565471f4eb80312bba.png

b7cae4b8ff50392a4e0ed32c3541b6cd.pngcad3c1c0959bc55ba9b9a65533b15373.gif

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/152478
推荐阅读
相关标签
  

闽ICP备14008679号