感知 Perception
Perceiver 在视野锥内感知附近的 PerceptionTarget,把看到的东西写进 Perception 组件。
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。写一个返回 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 |
到所见目标的距离。 | |
targetX、targetY |
0 |
所见目标位置(世界像素)。 | |
dirX、dirY |
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 |
是否已设目的地(自动管理)。 |
targetX、targetY |
0 |
当前目的地(世界像素)。 |
arrived |
false |
agent 到达目标的那一帧被置真。 |
setNavDestination 每帧调用都安全,可用于追一个移动的目标——只有当目标真的移动、或
repathInterval 到点时,agent 才重新规划。读 agent.arrived(或 Perception)来判断何时
切换行为。

状态机编辑器:状态(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' }], }, ],});每个状态最多带三个具名钩子——onEnter、onUpdate、onExit——以及一组 transitions。当一条
转移所指定的机制全部成立时它才被启用;一条什么都不指定的转移是无条件的:
| 转移字段 | 说明 |
|---|---|
to |
目标状态名。 |
trigger |
必须被 fire 的一次性事件名(采用时消费)。 |
condition |
必须返回真的一个已注册条件。 |
guard |
一个或多个黑板比较,按 AND 组合。 |
守卫用 ==、!=、<、<=、>、>=、truthy、falsy 比较一个黑板 key——例如
{ key: 'health', op: '<', value: 20 }。

行为树编辑器: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 一次,
被采用它的那条转移消费。
设想的工作流是图本身用数据、不用代码——你注册一次叶子逻辑,然后可视化地构建并调优机器/树:
.esfsm)或行为树(.esbt)资产,双击它打开
节点图编辑器。StateMachineAgent.fsm(或 BehaviorTreeAgent.bt)设为资产的路径。资产按路径引用并随场景预加载,所以图在 agent 首次 tick 之前就已注册——不需要
registerFsm/registerBt 调用。因为两种范式都从同一份注册表解析叶子,你可以先把一个敌人
原型成状态机,之后再换成行为树(或两者都跑),而不用动你的动作/条件代码。
Nav、NavAgent、Perceiver——组件层——覆盖了常见场景。它们底下的原语同样被导出,
全是不依赖引擎/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[] 路径,不可达时返回
null。Cell 是整数网格坐标 { 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.fsm 和 BehaviorTreeAgent.bt 分别指向 enemy.esfsm /
enemy.esbt——在编辑器里编排,由引擎加载。感知、FSM/BT 的 tick、以及跟随导航全是内置的,
所以唯一的游戏代码就是上面的共享叶子加上那张一次性的网格。