跳转到内容

游戏 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

条件总是返回 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 重播—— 全程零注册动作。

Perceiver 每帧扫描最近的、可见的 PerceptionTarget,把结果写进同一实体上的 Perception 组件。决策读 Perception;感知只是 ECS 数据,而不是一条旁路。

import { Perceiver, PerceptionTarget, Perception } from 'esengine';
// 猎手负责感知……
cmds.spawn()
.insert(Transform, { position: { x: -200, y: 0, z: 0 } })
.insert(Perceiver, { range: 260, fovDegrees: 120 })
.insert(Perception, {}); // 由感知插件每帧写入
// ……任何被标记为目标的实体。
cmds.spawn()
.insert(Transform, { position: { x: 100, y: 0, z: 0 } })
.insert(PerceptionTarget, {});
组件 字段 默认 说明
Perceiver range 220 视野范围(世界像素)。
fovDegrees 360 视野锥角度(360 = 全向)。
Perception visible false 当前是否看见一个目标。
distance 0 到所见目标的距离。
targetXtargetY 0 所见目标位置(世界像素)。
dirXdirY 0 从观察者指向目标的单位方向。
PerceptionTarget (标签) 把实体标记为可被 Perceiver 感知。

视野锥的朝向由感知者的 Transform 旋转决定。Perception 是一个瞬态、仅运行时的组件—— 插件每帧重写它,所以你从不需要手写它的值。

导航沿着一条避开被阻挡格子的路线,把 agent 移动到某个世界坐标,底层用 A* 在一个 NavGrid 上寻路。

NavGrid 是一个由可走/被阻挡格子构成的矩形网格。把它装到 Nav 资源上,agent 才能对它寻路:

import { defineSystem, Res, Nav, NavGrid } from 'esengine';
export const setupNav = defineSystem([Res(Nav)], (nav) => {
nav.setGrid(new NavGrid({
width: 60, // 列数
height: 44, // 行数
cellSize: 20, // 每格的世界像素
origin: { x: -600, y: -440 }, // 格子 (0,0) 中心的世界坐标
}));
}, { name: 'SetupNav' });
NavGrid 选项 类型 说明
width number 格子列数。
height number 格子行数。
cellSize number 每格的世界像素(正方形)。
origin Vec2 格子 (0,0) 中心的世界坐标。默认 (0,0)
walkable Uint8Array 可选的行主序 width*height 掩码,1 = 可走,0 = 阻挡。省略则全部可走。

若想从已绘制的瓦片地图推导网格,而不是手写掩码,用 navGridFromTilemapLayer(把某图层的 实心瓦片视作阻挡)或 navGridFromTiles

加一个 NavAgent,然后给它指一个目的地。内置的导航插件会规划路径并每帧沿路径推进 agent—— 你只管设目标。

import { NavAgent, setNavDestination, stopNavAgent } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(NavAgent, { speed: 140, arriveRadius: 8 });
// 从任何能访问 world 的系统/动作里——每帧调用都安全:
setNavDestination(world, entity, { x: 320, y: -120 });
// ……以及原地停下:
stopNavAgent(world, entity);
NavAgent 字段 默认 说明
speed 120 移动速度(世界像素/秒)。
radius 12 agent 半径(到达容差 / 碰撞尺寸)。
arriveRadius 6 距最终目标的停止距离。
repathInterval 0.5 移动中两次重规划的间隔秒数;0 = 只在目标改变时重规划。
hasTarget false 是否已设目的地(自动管理)。
targetXtargetY 0 当前目的地(世界像素)。
arrived false agent 到达目标的那一帧被置真。

setNavDestination 每帧调用都安全,可用于追一个移动的目标——只有当目标真的移动、或 repathInterval 到点时,agent 才重新规划。读 agent.arrived(或 Perception)来判断何时 切换行为。

状态机编辑器——Patrol 与 Chase 状态由带守卫的转移连接

状态机编辑器:状态(Patrol / Chase)由带守卫的转移(?seesPlayer?lostPlayer)连接。

状态机是一组由带守卫的转移连接起来的状态。任一时刻恰好一个状态处于激活;每个 tick, 它的出边按顺序被检查,第一条满足条件的被采用。加一个 StateMachineAgent 并指向一个图:

import { StateMachineAgent } from 'esengine';
cmds.spawn()
.insert(NavAgent, {})
.insert(Perceiver, { range: 240 })
.insert(Perception, {})
.insert(StateMachineAgent, { fsm: 'ai/enemy.esfsm' }); // 一个编辑器编排的资产
StateMachineAgent 字段 说明
fsm 要运行的机器的 key:一个 registerFsm 名字一个 .esfsm 资产路径。
current 当前激活状态名,每 tick 写入(只读;在检视面板可见)。

推荐做法是在编辑器里把图编排成 .esfsm 资产(见在编辑器里编排)。 状态用字符串引用你注册的动作/条件名。你也可以用代码构建一台机器:

import { registerFsm } from 'esengine';
registerFsm('guard', {
initial: 'Patrol',
states: [
{
name: 'Patrol',
onUpdate: 'patrol',
transitions: [{ to: 'Chase', condition: 'seesPlayer' }],
},
{
name: 'Chase',
onEnter: 'startChase',
onUpdate: 'chase',
transitions: [{ to: 'Patrol', condition: 'lostPlayer' }],
},
],
});

每个状态最多带三个具名钩子——onEnteronUpdateonExit——以及一组 transitions。当一条 转移所指定的机制全部成立时它才被启用;一条什么都不指定的转移是无条件的:

转移字段 说明
to 目标状态名。
trigger 必须被 fire 的一次性事件名(采用时消费)。
condition 必须返回真的一个已注册条件。
guard 一个或多个黑板比较,按 AND 组合。

守卫用 ==!=<<=>>=truthyfalsy 比较一个黑板 key——例如 { key: 'health', op: '<', value: 20 }

行为树编辑器——Selector 之下是 Sequence 与一个兜底动作

行为树编辑器:Selector 根之下是一个 Sequence(Condition seesPlayer → Action chase),外加兜底的 patrol 动作。

行为树每帧从开始 tick 并返回一个 Status。组合节点控制流程;装饰器变换其唯一子节点的 结果;叶子是你注册的动作和条件。加一个 BehaviorTreeAgent:

import { BehaviorTreeAgent } from 'esengine';
cmds.spawn()
.insert(NavAgent, {})
.insert(Perception, {})
.insert(BehaviorTreeAgent, { bt: 'ai/enemy.esbt' }); // 一个编辑器编排的资产
BehaviorTreeAgent 字段 说明
bt 要运行的树的 key:一个 registerBt 名字一个 .esbt 资产路径。
status 上一次根状态,每 tick 写入(只读)。

在编辑器里可视化编排这棵树,或用 registerBt 用代码构建:

import { registerBt } from 'esengine';
registerBt('guard', {
root: {
type: 'selector', // 从左到右尝试子节点,直到一个成功
children: [
{
type: 'sequence', // 全部按序成功
children: [
{ type: 'condition', name: 'seesPlayer' },
{ type: 'action', name: 'chase' },
],
},
{ type: 'action', name: 'patrol' },
],
},
});

节点类型:

类别 类型 行为
组合 sequence 按序 tick 子节点;第一个失败即失败,全部成功才成功。
selector 按序 tick 子节点;第一个成功即成功,全部失败才失败。
parallel tick 全部子节点;policy: 'one' 任一成功即成功,'all'(默认)全部成功才成功。
装饰器 inverter 反转子节点的成功/失败。
succeeder 总是报告 Success。
repeater 把子节点重跑 count 次(0 = 永远)。
wait 运行 seconds 秒后报告 Success。
叶子 action 运行已注册动作 name;它的 Status(void 则为 Success)向上传播。
condition 已注册条件 name 为真时返回 Success,否则 Failure

每个 FSM/BT agent 拥有一块 Blackboard(黑板)——它私有的键值存储加上一次性事件触发器。 动作和条件通过 ctx.blackboard 访问它;这是 AI 状态在叶子间流动的方式,也是转移守卫取值的来源。

registerAction('takeDamage', (ctx) => {
const hp = (ctx.blackboard.get<number>('health') ?? 100) - 10;
ctx.blackboard.set('health', hp);
if (hp <= 0) ctx.blackboard.fire('died'); // 一条以 'died' 为键的转移被 fire 一次
});
方法 说明
get<T>(key) / set(key, value) 读/写一个值。
has(key) / delete(key) 测试 / 删除一个 key。
fire(trigger) fire 一个一次性事件;以它为键的转移在被采用前保持启用。
isFired(trigger) / consume(trigger) 测试 / 清除一个触发器。

黑板的值支撑 FSM 转移上的 guard 比较,而 trigger 机制就是 Unity 式的边事件:fire 一次, 被采用它的那条转移消费。

设想的工作流是图本身用数据、不用代码——你注册一次叶子逻辑,然后可视化地构建并调优机器/树:

  1. 内容浏览器里,新建一个状态机(.esfsm)或行为树(.esbt)资产,双击它打开 节点图编辑器。
  2. 添加状态/节点,拖拽连线,把每个叶子的动作条件填成你注册的某个名字。在检视面板里 设置转移的条件、触发器和黑板守卫。
  3. 在 agent 实体上,把 StateMachineAgent.fsm(或 BehaviorTreeAgent.bt)设为资产的路径。

资产按路径引用并随场景预加载,所以图在 agent 首次 tick 之前就已注册——不需要 registerFsm/registerBt 调用。因为两种范式都从同一份注册表解析叶子,你可以先把一个敌人 原型成状态机,之后再换成行为树(或两者都跑),而不用动你的动作/条件代码。

NavNavAgentPerceiver——组件层——覆盖了常见场景。它们底下的原语同样被导出, 全是不依赖引擎/wasm 的纯 TypeScript,供你在不要 agent 的情况下求一条路径、 或在没有 Perceiver 时做一次视线检查。

findPath 就是导航插件用的那个 A*:在 NavGrid 格子上做均匀代价搜索,4 连通或 8 连通,octile/曼哈顿启发式,且不切角(走对角线要求两个共享的正交格子都可通行)。 当你要自己驱动移动、画路径预览、或计算可达性(回合制的移动范围)时下沉到它—— 或者当一张网格不够用时,因为 Nav 资源只持有一张网格:

import { NavGrid, findPath, pathToWorld } from 'esengine';
const grid = new NavGrid({ width: 32, height: 24, cellSize: 32 });
const path = findPath(
grid,
grid.worldToCell(hero.x, hero.y), // Cell — integer grid coordinates
grid.worldToCell(chest.x, chest.y),
{ diagonal: false },
);
if (path) {
const waypoints = pathToWorld(grid, path); // Vec2[] — cell centers, world pixels
}

findPath(grid, start, goal, opts?) 返回含首尾两端的 Cell[] 路径,不可达时返回 nullCell 是整数网格坐标 { x, y }(与 Vec2 刻意区分,以标示格子空间);用 grid.worldToCell / grid.cellToWorld 互转,或用 pathToWorld 把整条路径转成 世界空间路点。

PathfindOptions 字段 默认 说明
diagonal true 允许 8 连通的对角移动。
snapRadius 8 起点/终点落在被阻挡格子上时,先在此环半径内吸附到最近的可走格子再搜索;0 关闭吸附。

navGridFromTiles 从任意瓦片读取器构建可走掩码——它是 navGridFromTilemapLayer 底下的核心,后者只是接上了 TilemapAPI.getTile:

import { navGridFromTiles } from 'esengine';
const grid = navGridFromTiles((x, y) => level.tiles[y][x], {
width: 32, height: 24, cellSize: 32,
blockedTileIds: [WALL_TILE, WATER_TILE],
});
BuildNavGridOptions 字段 说明
width / height / cellSize / origin? NavGrid 选项相同(见构建网格)。
blockedTileIds? 阻挡移动的瓦片 id 的精确集合。
isBlocked? 针对瓦片 id 的自定义谓词(0 = 空);覆盖 blockedTileIds。二者都不给时,任何非空瓦片都阻挡。

感知插件是四个导出函数之上的薄封装——适合在动作里做一次性视线检查、做自定义感官 (记忆、听觉、多目标),或对着一个假 world 做单元测试:

  • senseTarget(ox, oy, facing, tx, ty, range, halfFov, isBlocked?)——纯几何: 先查距离,再查视野锥(halfFov 是锥角的一半,弧度;≥ π 表示全向),最后走可选的 遮挡回调。
  • facingFromQuat(z, w)——从 Transform 旋转四元数的 z/w 分量求 2D 朝向角 (弧度)。
  • makeLosCheck(physics)——用物理射线构建遮挡回调:任何命中明显落在目标之前 (fraction < 0.98——最后约 2% 是目标自己)即视为遮挡。
  • stepPerception(world, isBlocked?)——插件每帧在 PreUpdate 跑的那一步: 对每个 Perceiver,感知全部 PerceptionTarget,留下最近的可见者,写入 Perception 组件。
import { senseTarget, facingFromQuat, Transform, registerCondition } from 'esengine';
// A one-off sight check against a POINT — no PerceptionTarget needed.
registerCondition('seesShrine', (ctx) => {
const tf = ctx.get(Transform);
const facing = facingFromQuat(tf.rotation.z, tf.rotation.w);
return senseTarget(
tf.position.x, tf.position.y, facing,
SHRINE.x, SHRINE.y,
300, Math.PI / 3, // 300 px range, 120° cone
).visible;
});

senseTarget 返回一个 SenseResult:

SenseResult 字段 说明
visible 目标在范围内、在锥内、且未被遮挡。
distance 到目标的距离——即使不可见也总是被设置。
dirX / dirY 观察者 → 目标的单位方向;不可见时为 (0, 0)

内置系统只在物理模块已加载时才接入 makeLosCheck;否则感知只做距离 + 视野锥—— AI 层没有对物理的硬依赖。

感知、FSM、BT、导航这四个插件是默认插件集的一部分,所以编辑器和 esengine Web 运行时会替你 加上它们。只有当你从一个裸的 new App() 构建应用时,才需要显式加:

import { perceptionPlugin, fsmPlugin, btPlugin, navPlugin } from 'esengine';
app.addPlugin(perceptionPlugin);
app.addPlugin(fsmPlugin);
app.addPlugin(btPlugin);
app.addPlugin(navPlugin);

每个都同时导出为一个开箱即用的单例(navPlugin)和一个类(NavPlugin),需要多个实例时用类。

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 进状态机;动作与条件出自同一个注册表。