最新开源方案!Cocos Creator 写一个ECS框架+行为树,实现格斗游戏 AI
引言:实现游戏 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://forum.cocos.org/t/topic/133443