跳转到内容

游戏 AI

Estella 的 AI 层把一个实体变成会感知、会决策、会移动的角色。它由四个协作部件 组成,而这四者共享同一套设计:

感知 Perception

Perceiver 在视野锥内感知附近的 PerceptionTarget,把看到的东西写进 Perception 组件。

导航 Navigation

NavGrid + A* 寻路把 NavAgent 移动到任意世界坐标,并随目标移动而重新规划。

状态机 FSM

StateMachineAgent 运行一个 .esfsm 图——带进入/更新/退出钩子和带守卫的转移的状态。

行为树 BT

BehaviorTreeAgent 运行一个 .esbt 图——选择器、序列、装饰器与叶子。

统一的核心思想:状态机和行为树是同一套构件之上的两种编排范式。 你在代码里注册一次具名 的动作条件,然后把它们连进 FSM 或 BT——图在编辑器里可视化编排,而不是写死在代码里。 感知喂给决策,决策驱动导航。

一个动作做某件事(追击、攻击、放个音效);一个条件回答一个是/否问题(我看得见玩家吗?)。 你按名字注册一次,FSM 和 BT 都会对同一份注册表按名字解析——于是一套叶子同时服务两种范式。

import { registerAction, registerCondition, Status, Perception, setNavDestination } from 'esengine';
registerCondition('seesPlayer', (ctx) =>
ctx.has(Perception) && ctx.get(Perception).visible);
registerAction('chase', (ctx) => {
const per = ctx.get(Perception);
setNavDestination(ctx.world, ctx.entity, { x: per.targetX, y: per.targetY });
});

每个动作和条件都会收到它所运行实体的 AiContext。它和一个 defineBehavior 的 update 是同一套编程模型——实体、世界、 命令的访问——外加一个每个 agent 独有的 blackboard(黑板):

成员 类型 说明
ctx.entity Entity 该动作/条件运行所在的 agent 实体。
ctx.dt number 帧间隔(秒)。
ctx.world World 完整世界访问,用于跨实体读取。
ctx.commands Commands 延迟的 spawn/despawn,tick 中调用安全。
ctx.blackboard Blackboard 该 agent 的 AI 数据面(见黑板)。
ctx.get(Comp) ComponentData 读取实体上的另一个组件。
ctx.set(Comp, data) void 写入实体上的一个组件。
ctx.has(Comp) boolean 本实体是否拥有 Comp

跑你的状态机的那个系统,够到的东西就是它的叶子够到的东西——而这只有在图加载之后才 知道。在注册时声明出来,调度器就能把这个系统和「可能碰任何东西」的系统区分开:

registerAction('chase', {
run: (ctx) => { /* ... */ },
touches: { reads: ['Perception'], writes: ['NavAgent'] },
});
registerCondition('seesPlayer', {
check: (ctx) => ctx.has(Perception) && ctx.get(Perception).visible,
touches: { reads: ['Perception'] },
});

可选,但值得知道不写的代价:只要有一个叶子没声明,整个系统就退回「碰一切」——因为 一份悄悄丢掉了读不懂那部分的并集,是一个调度器会信、而帧会推翻的声明。参见 系统碰什么

条件总是返回 boolean动作可以返回一个 Status,也可以什么都不返回:

export enum Status { Success = 'success', Failure = 'failure', Running = 'running' }
  • 行为树里,叶子返回 Status 以跨帧运行(Running)、成功或失败。什么都不返回视为 Success
  • 状态机里,返回值被忽略——FSM 动作是一次性的副作用。

写一个返回 Status 的动作,就能让同一个动作既当 BT 叶子又当 FSM 钩子。

引擎预注册了少量名字,常见的胶水逻辑因此完全不用写代码——它们会和你自己注册的 名字一起出现在编辑器调色板里。它们全部只操作代理实体自身的组件(与你的代码、 编辑器共用同一条通道):

名字 类别 效果
timeline.play 动作 拉起该实体 TimelinePlayer 的播放旗标。对已播完的片段,从头重播。
timeline.pause 动作 放下播放旗标(用 timeline.play 恢复)。
timeline.finished 条件 片段已完成(once 片段播到末尾)且未在播放时为真。
spriteAnim.play 动作 播放代理实体的精灵翻页动画;参数可切换到指定剪辑。
spriteAnim.restart 动作 回卷到第 0 帧并播放(参数可切换剪辑)。
spriteAnim.stop 动作 暂停翻页动画。
spriteAnim.finished 条件 一次性精灵剪辑播完后为真。

动作可携带一个可选字符串参数——在 FSM 状态检查器的动作名旁(或 BT 动作节点上)填写。 内置动作用它承载组件无法按状态携带的数据,比如 spriteAnim.play 要切换到哪个剪辑; 你自己注册的动作在第三个参数收到它:(ctx, blackboard, arg) => …

三者组合出一个零代码的过场状态:进入状态即播放片段,片段播完即驱动转移:

registerFsm('intro', {
initial: 'Cutscene',
states: [
{
name: 'Cutscene',
onEnter: 'timeline.play',
transitions: [{ to: 'Gameplay', condition: 'timeline.finished' }],
},
{ name: 'Gameplay' },
],
});

代理实体自己携带 TimelinePlayer(播哪个片段、速度、循环模式)——FSM 只翻它的 旗标。内建名字永远不会遮蔽你的注册:同名时你的注册获胜。游戏侧的名字请避开 timeline. 前缀,保持命名空间分离。

在 FSM / 行为树编辑器里,动作与条件输入框会随输入提示所有已注册的名字——按命名 空间分组,内建项带一行“它做什么”的说明。自由输入仍然合法,留给游戏运行时注册的 名字。

cutscene 示例 就是这个模式的完整落地:场景开始即播开场时间轴,播完解锁操作,按 R 重播—— 全程零注册动作。

enemy-ai 示例 用两个敌人猎杀玩家,它们共享同一套感官和叶子——一个由状态机驱动,另一个由行为树驱动:

import {
defineSystem, Res, Nav, NavGrid,
registerAction, registerCondition, setNavDestination, Perception,
} from 'esengine';
// 共享叶子——同一批名字同时服务 .esfsm 和 .esbt。
registerCondition('seesPlayer', (ctx) => ctx.has(Perception) && ctx.get(Perception).visible);
registerCondition('lostPlayer', (ctx) => !ctx.has(Perception) || !ctx.get(Perception).visible);
registerAction('chase', (ctx) => {
if (!ctx.has(Perception)) return;
const per = ctx.get(Perception);
if (per.visible) setNavDestination(ctx.world, ctx.entity, { x: per.targetX, y: per.targetY });
});
registerAction('patrol', () => { /* 在看见玩家之前保持原地 */ });
// 一张覆盖竞技场的开阔导航网格。
export const setupNavGrid = defineSystem([Res(Nav)], (nav) => {
nav.setGrid(new NavGrid({ width: 60, height: 44, cellSize: 20, origin: { x: -600, y: -440 } }));
}, { name: 'SetupNavGrid' });

两个敌人的 StateMachineAgent.fsmBehaviorTreeAgent.bt 分别指向 enemy.esfsm / enemy.esbt——在编辑器里编排,由引擎加载。感知、FSM/BT 的 tick、以及跟随导航全是内置的, 所以唯一的游戏代码就是上面的共享叶子加上那张一次性的网格。

  • 脚本 —— 行为是声明式系统;叶子就是普通函数。
  • 动画 —— 用状态机驱动精灵动画。
  • 场景 —— .esfsm / .esbt 资产随场景加载与序列化。
  • 事件绑定 —— 点击或触发可以 fsm.fire 进状态机;动作与条件出自同一个注册表。