系统
系统是每帧运行的函数。你声明它需要什么——一个组件查询,加上资源和事件—— Estella 就会在调度中用恰好这些参数调用它。
import { defineComponent, defineSystem, addSystem, Query, Mut, Res, Time, Transform} from 'esengine';
const Speed = defineComponent('Speed', { value: 200 });
addSystem(defineSystem( [Res(Time), Query(Mut(Transform), Speed)], (time, query) => { for (const [entity, transform, speed] of query) { transform.position.x += speed.value * time.delta; } }));系统的第一个参数是它的参数列表——它将按顺序收到的数据:
Query(...)—— 遍历同时拥有列出的每个组件的实体。每次迭代产出[entity, ...组件]。Mut(Component)—— 系统会写入的组件。未包裹的组件是只读的。Res(Resource)/ResMut(Resource)—— 一个共享资源,只读或可写(例如提供帧计时的Time)。Res给你资源本身;ResMut给你包在它外面的句柄——.get()/.set(v)/.modify(fn)。Commands()—— 延迟的结构性改动(生成 / 销毁 / 增删组件),在声明它的那个系统返回时 应用——就在当前帧,同一帧里排在它后面的系统已经能查到这些改动。EventReader(E)/EventWriter(E)—— 读取或发送一条类型化事件流。
系统活在由有序阶段组成的**调度(schedule)**里。用 addSystem 注册进默认的
Update 阶段,用 addStartupSystem 注册进 Startup(只跑一次),或用
addSystemToSchedule(Schedule.X, system) 注册进任意阶段:
| 阶段 | 何时运行 |
|---|---|
Startup |
首帧之前,一次。 |
First → PreUpdate → Update → PostUpdate → Last |
每帧,按此顺序。 |
FixedPreUpdate → FixedUpdate → FixedPostUpdate |
固定步长(物理 / 复制节拍)——每帧 0..n 次。 |
同一阶段内,系统按注册顺序运行——线性流程一个标注都不用写。再往上,按“能表达你的 意思的最粗那一层”来选工具。
大多数“我要跑在它之后”其实是“我属于下一个阶段”。跨阶段的先后是固定的,不用任何依赖边:
PreUpdate 收输入,Update 移动,PostUpdate 跟相机。
同一阶段内确实存在依赖时,把它写出来。依赖边按系统名字引用,所以要给参与排序的系统
起名。写在 defineSystem 上的排序会跟着定义走——顶层的 addSystem 自身不收选项,靠
的就是这条:
const move = defineSystem([...], moveFn, { name: 'move' });const camera = defineSystem([...], cameraFn, { name: 'camera', runAfter: ['move'], // 相机跟在移动之后});
addSystem(move);addSystem(camera);在 App 上注册可以写同样的边,外加 runIf;它是在定义处已声明的边之上追加,而不是
覆盖:
import { Schedule } from 'esengine';
app.addSystemToSchedule(Schedule.Update, camera, { runAfter: ['input'], // 叠加在上面那条 'move' 边之上 runIf: () => isPlaying, // 条件为 false 时跳过该系统});指向本阶段内不存在的系统的边会被忽略;依赖边一旦成环,调度器会带着成环路径抛错——
Circular system dependency: move → camera → move。
系统集(System Set)
Section titled “系统集(System Set)”一条条给每个系统写依赖很快就烦了。defineSystemSet 给一组系统起一个名字:集合的
runIf 和依赖边对每个成员生效,别的系统也只要引用这个名字就能一次约束整组。
import { Schedule, defineSystemSet } from 'esengine';
const physics = defineSystemSet('physics', { systems: [applyForces, integrateVelocity, resolveContacts], runBefore: ['render'], // 三个成员都跑在 'render' 之前 runIf: () => !paused, // ……暂停时三个都不跑});
app.addSystemSetToSchedule(Schedule.FixedUpdate, physics);
// 一条边就等整组:app.addSystemToSchedule(Schedule.FixedUpdate, debugDraw, { runAfter: ['physics'] });成员按列出的顺序运行。app.addSystemSet(set) 是注册进 Update 阶段的快捷方式。
没有 order: 100 这种选项,也不需要:注册顺序就是默认顺序,所以数值方案就是调用处
的一次排序。
const ordered = [ { order: 100, system: input }, { order: 200, system: move }, { order: 300, system: camera },];for (const { system } of ordered.sort((a, b) => a.order - b.order)) { app.addSystemToSchedule(Schedule.Update, system);}引擎不内建它,是因为数值是一份隐式的全局协议:数字会撞车,想往两者之间插入就得靠魔法
间隔,删掉一个系统还会悄悄带走一条隐含依赖。而 runAfter: ['move'] 陈述的是依赖本身
——局部、可跨插件组合、且可校验。
场景系统只在其所属场景处于激活状态时运行,因此多个场景可以共存于同一个世界而互不干扰。
过滤与变更检测
Section titled “过滤与变更检测”查询可以在“拥有这些组件”之外进一步收窄,并对变更做出反应:
// 额外的存在性过滤——要求/排除但不读取的组件:Query(Transform, Mut(Velocity)).without(Frozen).with(Enemy)
// 用 filter() 做布尔组合:import { Query, With, Without, Or, Not } from 'esengine';Query(Transform).filter(Or(With(Player), With(Ally)))
// 变更检测——只要该组件自本系统上次运行以来被添加/写过的实体:import { Query, Added, Changed } from 'esengine';Query(Added(Health)) // 本帧新增Query(Changed(Transform)) // 上次运行后被写过Removed(C) 是它自己的查询,产出丢失了 C 的实体:
import { defineSystem, Removed } from 'esengine';defineSystem([Removed(Health)], (removed) => { // Health 是你自己的组件 for (const entity of removed) { /* 清理 */ }});查询结果在 for…of 之外还有便捷方法:
| 方法 | 返回 |
|---|---|
query.single() |
唯一的匹配(否则 null)——用于单独的玩家/相机。 |
query.count() / query.isEmpty() |
匹配数 / 是否为空。 |
query.toArray() |
所有结果组成数组。 |
query.forEach((e, ...c) => …) |
每个匹配调用一次回调。 |
系统的参数表同时也是它的声明:Query(Mut(Transform)) 说的是「我写 Transform」,
Res(Time) 说的是「我读 Time」。调度器读这份声明来回答两个问题——两个系统有没有可能
同时跑,以及它们实际的先后顺序是不是有人决定过。
GetWorld() 是逃生舱,这两个问题它都答不了:握着整个 World 的系统可能碰任何东西,
所以它和所有系统冲突。确实需要它的地方,把你伸手够的东西说出来,答案就回来了:
defineSystem([GetWorld()], (world) => { /* ... */ }, { name: 'Perception', touches: { reads: ['Perceiver', 'Transform'], writes: ['Perception'], },});这里组件用字符串指名,因为要走 World 的理由通常正是「这个类型注册得比系统晚」。 它支持三件事:
opaque: true—— 你确实说不出来。这和干脆不写touches效果相同(都按「碰一切」 处理),区别是这一种是明说的。- 传函数而不是对象,每次读取访问信息时都会重新问一遍。跑「用户编写的数据」的系统
靠它回答:状态机系统够到的东西,是已加载的
.esfsm图里那些叶子的并集。 defineBehavior({ touches }),同样的道理——行为体是你的代码,不声明就按碰一切处理。
两个要等待的系统
Section titled “两个要等待的系统”系统按调度顺序启动,而同步系统一启动就跑到底,下一个才开始——这中间没有任何重叠。
async 系统不一样:当它挂在 await 上时,调度器可以启动下一个系统,前提是后者
既不依赖它、也不碰它碰的东西。
于是两个加载不同东西的 async 系统是同时在等,而不是一个等完另一个再等。如果你需要
它们之间有确定的先后,那是一条顺序边(runAfter),不是从注册顺序里推出来的东西。
找出没人定过顺序的那些对
Section titled “找出没人定过顺序的那些对”同一阶段里两个系统碰同一份数据、之间又没有边,它们的先后就是注册顺序碰巧产生的——于是 加进第三个系统、或者在构建列表里挪一下插件,游戏行为就变了,而且没有任何地方能指。问它:
app.scheduleAmbiguities(Schedule.Update);// [{ a: 'Move', b: 'Cull', over: ['Transform'] }]
app.scheduleBatches(Schedule.Update);// [['Move', 'Spin'], ['Cull']] —— 这个阶段有多少是天然串行的修法是二选一:把顺序定下来(runAfter),或者让这个重叠根本不存在。
事件是一条类型化、解耦的消息流——一个系统 send,另一个下一帧读取,两者之间无直接引用。
定义一个事件,在 app 上注册,然后把 EventWriter / EventReader 作为系统参数:
import { defineEvent, defineSystem, EventWriter, EventReader, Commands } from 'esengine';
const Damaged = defineEvent<{ entity: number; amount: number }>('Damaged');app.addEvent(Damaged);
// 生产者:addSystem(defineSystem([EventWriter(Damaged)], (damaged) => { damaged.send({ entity: 12, amount: 5 });}));
// 消费者(读取自上次运行以来发送的):addSystem(defineSystem([EventReader(Damaged), Commands()], (damaged, cmds) => { for (const evt of damaged) { /* evt.entity、evt.amount */ }}));reader 可迭代,还提供 .isEmpty() / .toArray()。完整的生产者/消费者模式见
event-system 示例。