7.2 组件脚本的生命周期
7.1 把组件字段变成了可读写的属性视图,但「谁来每帧读它、写它」还没回答。游戏逻辑不该写进引擎内核——它应该是一段挂在实体上的脚本:怪物 AI、玩家控制器、旋转的宝箱,各自是一小段代码,引擎在合适的时机回调它。
这一节实现脚本的生命周期:什么时候 onCreate、什么时候 onUpdate、什么时候 onDestroy,以及一堆脚本挂在一起时谁先谁后。
脚本就是一组回调
helio 的脚本不继承基类、不实现接口方法表,就是一个普通对象:三个可选回调 onCreate / onUpdate / onDestroy,外加一个名字和一个可选的 priority。回调拿到的是 ScriptContext——实体句柄、当前帧号、以及一块脚本间共享的 blackboard。
这个设计把「脚本是什么」压到最小:一个函数集合。没有虚表、没有反射扫描方法名、没有 instanceof。引擎只认这三个回调,其余一概不管。脚本要读别的组件,就通过 7.1 的属性绑定,或者直接拿实体句柄去 ECS 查——脚本本身不持有组件引用,避免实体销毁后留下悬垂指针。
生命周期状态机
把脚本「挂上去」和「跑起来」分开,中间夹一个显式状态机:pending → created → destroyed。ScriptInstance 用私有字段记住状态,任何越界的调用都当场抛错——update 一个还没 create 的脚本、destroy 两次、create 之后再 create,全是硬错误。宁可抛错也不要静默:脚本生命周期出错往往是挂载代码写错,早炸早发现。
下面 src/subsys/script/script-component.ts 层级 L1,完整可编译。它定义脚本接口、实例状态机,以及按优先级调度的 ScriptHost。
// file: src/subsys/script/script-component.ts
// tier: L1
// verify: npx vitest run src/subsys/script/script-component.test.ts
//
// helio 脚本子系统:组件脚本的生命周期。
// 脚本 = 一组可选回调(onCreate / onUpdate / onDestroy)。它挂在 ECS 实体的
// 世代句柄上,由 ScriptHost 按确定顺序调度。生命周期状态机把「没 create 就
// update」「destroy 后还 update」这类误用挡在门口。
import type { Handle } from '../../core/slotmap.ts';
/** 传给每个回调的上下文:实体、帧号、脚本间共享的黑板。 */
export interface ScriptContext {
readonly entity: Handle;
readonly frame: number;
readonly blackboard: Map<string, unknown>;
}
/** 一个组件脚本。三个回调都可选;priority 越小越早执行。 */
export interface Script {
readonly name: string;
readonly priority?: number;
onCreate?(ctx: ScriptContext): void;
onUpdate?(ctx: ScriptContext, dt: number): void;
onDestroy?(ctx: ScriptContext): void;
}
export type LifecycleState = 'pending' | 'created' | 'destroyed';
/** 脚本实例:把脚本与实体绑在一起,并跟踪生命周期状态。 */
export class ScriptInstance {
readonly script: Script;
readonly entity: Handle;
readonly priority: number;
#state: LifecycleState = 'pending';
#detached = false;
constructor(script: Script, entity: Handle) {
this.script = script;
this.entity = entity;
this.priority = script.priority ?? 0;
}
get state(): LifecycleState {
return this.#state;
}
/** 已被移出调度(本帧内不再 update),但 onDestroy 可能还没跑。 */
get detached(): boolean {
return this.#detached;
}
markDetached(): void {
this.#detached = true;
}
create(ctx: ScriptContext): void {
if (this.#state !== 'pending') {
throw new Error(`create on ${this.script.name}: state is ${this.#state}`);
}
this.#state = 'created';
this.script.onCreate?.(ctx);
}
update(ctx: ScriptContext, dt: number): void {
if (this.#state !== 'created') {
throw new Error(`update on ${this.script.name}: state is ${this.#state}`);
}
this.script.onUpdate?.(ctx, dt);
}
destroy(ctx: ScriptContext): void {
if (this.#state !== 'created') {
throw new Error(`destroy on ${this.script.name}: state is ${this.#state}`);
}
this.#state = 'destroyed';
this.script.onDestroy?.(ctx);
}
}
/** 脚本宿主:按 (priority, 挂载顺序) 稳定排序调度一实体集合的脚本。 */
export class ScriptHost {
#instances: ScriptInstance[] = [];
#byEntity = new Map<Handle, ScriptInstance[]>();
#blackboard = new Map<string, unknown>();
#frame = 0;
#updating = false;
#deferredDestroy: ScriptInstance[] = [];
get frame(): number {
return this.#frame;
}
get count(): number {
return this.#instances.length;
}
get blackboard(): Map<string, unknown> {
return this.#blackboard;
}
#ctx(entity: Handle): ScriptContext {
return { entity, frame: this.#frame, blackboard: this.#blackboard };
}
/** 挂脚本:立即 create,并按优先级插入调度表。 */
attach(entity: Handle, script: Script): ScriptInstance {
const inst = new ScriptInstance(script, entity);
inst.create(this.#ctx(entity));
this.#instances.push(inst);
// 稳定排序:priority 相同者保持插入顺序(Array.sort 在 ES2019 起保证稳定)。
this.#instances.sort((a, b) => a.priority - b.priority);
let list = this.#byEntity.get(entity);
if (list === undefined) {
list = [];
this.#byEntity.set(entity, list);
}
list.push(inst);
return inst;
}
/** 卸载单个脚本:更新中则延迟到本帧末销毁,否则立即销毁。 */
detach(inst: ScriptInstance): void {
inst.markDetached();
const i = this.#instances.indexOf(inst);
if (i !== -1) this.#instances.splice(i, 1);
if (this.#updating) {
this.#deferredDestroy.push(inst);
} else {
inst.destroy(this.#ctx(inst.entity));
}
}
/** 卸载某实体的全部脚本(实体销毁时调用)。 */
detachEntity(entity: Handle): void {
const list = this.#byEntity.get(entity);
if (list === undefined) return;
this.#byEntity.delete(entity);
for (const inst of [...list]) this.detach(inst);
}
/** 跑一帧:先按序 update 快照,再统一处理本帧内的延迟销毁。 */
runFrame(dt: number): void {
this.#frame++;
const snapshot = this.#instances.slice();
this.#updating = true;
try {
for (const inst of snapshot) {
if (inst.detached) continue;
inst.update(this.#ctx(inst.entity), dt);
}
} finally {
this.#updating = false;
}
this.#flush();
}
#flush(): void {
const pending = this.#deferredDestroy;
this.#deferredDestroy = [];
for (const inst of pending) inst.destroy(this.#ctx(inst.entity));
}
}
确定性的调度顺序
一帧里挂着几十个脚本,谁先跑?helio 用两条规则定死顺序:先按 priority 升序,priority 相同则按挂载顺序。priority 让「先算输入、再跑 AI、最后更新相机」这种依赖可以被显式表达,而不必靠挂载代码的偶然顺序。Array.prototype.sort 从 ES2019 起保证稳定,所以同优先级的相对次序就是 attach 的次序——可复现。
比排序更微妙的是遍历中改表。脚本在 onUpdate 里挂新脚本、或销毁自己,都可能发生。如果直接改正在遍历的数组,轻则漏跑、重则越界。runFrame 的办法是:先对 #instances 做一次 slice() 快照再遍历;detach 时立刻把实例移出 #instances 并标记 detached,但真正的 onDestroy 推迟到 #flush(本帧末尾)才跑。于是「A 在 update 里 detach B」时,B 本帧不再 update,但它的 onDestroy 仍然被调用,且不会被漏掉。
可运行产物:生命周期真跑
生命周期与调度都是纯逻辑,可以在 Node 里真跑。下面 src/subsys/script/script-component.test.ts 层级 L1,命令 npx vitest run src/subsys/script/script-component.test.ts。
// file: src/subsys/script/script-component.test.ts
// tier: L1
// verify: npx vitest run src/subsys/script/script-component.test.ts
import { describe, expect, it } from 'vitest';
import { ScriptHost, ScriptInstance, type Script } from './script-component.ts';
/** 记录调用顺序的探针脚本。 */
function probe(name: string, log: string[], priority?: number): Script {
return {
name,
priority,
onCreate: () => log.push(`create:${name}`),
onUpdate: (_ctx, dt) => log.push(`update:${name}:${dt}`),
onDestroy: () => log.push(`destroy:${name}`),
};
}
const ctx = { entity: 1, frame: 0, blackboard: new Map<string, unknown>() };
describe('生命周期状态机', () => {
it('没 create 就 update 抛错', () => {
expect(() => new ScriptInstance(probe('a', []), 1).update(ctx, 0.016)).toThrow(/state is pending/);
});
it('destroy 后不能再 update,也不能重复 destroy', () => {
const inst = new ScriptInstance(probe('a', []), 1);
inst.create(ctx);
inst.destroy(ctx);
expect(inst.state).toBe('destroyed');
expect(() => inst.update(ctx, 0.016)).toThrow(/state is destroyed/);
expect(() => inst.destroy(ctx)).toThrow(/state is destroyed/);
});
});
describe('挂载与调度顺序', () => {
it('attach 立即触发 onCreate', () => {
const log: string[] = [];
const host = new ScriptHost();
host.attach(1, probe('a', log));
expect(log).toEqual(['create:a']);
expect(host.count).toBe(1);
});
it('update 按 priority 排序,同优先级保持挂载顺序', () => {
const log: string[] = [];
const host = new ScriptHost();
host.attach(1, probe('low', log, 10));
host.attach(1, probe('high', log, -5));
host.attach(1, probe('midA', log, 0));
host.attach(1, probe('midB', log, 0));
log.length = 0;
host.runFrame(0.016);
expect(log).toEqual([
'update:high:0.016', 'update:midA:0.016', 'update:midB:0.016', 'update:low:0.016',
]);
});
it('ctx.frame 每帧自增,黑板在脚本间共享', () => {
const seen: number[] = [];
const host = new ScriptHost();
host.attach(1, { name: 'writer', onUpdate: (c) => c.blackboard.set('hp', 7) });
host.attach(1, { name: 'reader', priority: 1, onUpdate: (c) => seen.push(c.blackboard.get('hp') as number) });
host.runFrame(0.016);
host.runFrame(0.016);
expect(host.frame).toBe(2);
expect(seen).toEqual([7, 7]);
});
});
describe('帧内增删', () => {
it('update 中 detach 自己:本帧不再 update,onDestroy 在本帧末才跑', () => {
const log: string[] = [];
const host = new ScriptHost();
let self: ScriptInstance | undefined;
self = host.attach(1, {
name: 'suicide',
onUpdate: () => {
log.push('update:suicide');
host.detach(self!);
},
onDestroy: () => log.push('destroy:suicide'),
});
host.runFrame(0.016);
expect(log).toEqual(['update:suicide', 'destroy:suicide']);
log.length = 0;
host.runFrame(0.016);
expect(log).toEqual([]);
expect(host.count).toBe(0);
});
it('A 在 update 中 detach B:B 本帧不再 update', () => {
const log: string[] = [];
const host = new ScriptHost();
const b = host.attach(1, probe('B', log, 1));
host.attach(1, { name: 'A', priority: 0, onUpdate: () => host.detach(b) });
host.runFrame(0.016);
expect(log).toEqual(['create:B', 'destroy:B']);
});
it('detachEntity 清掉该实体的全部脚本', () => {
const log: string[] = [];
const host = new ScriptHost();
host.attach(1, probe('a', log));
host.attach(1, probe('b', log));
host.attach(2, probe('c', log));
log.length = 0;
host.detachEntity(1);
expect(log).toEqual(['destroy:a', 'destroy:b']);
expect(host.count).toBe(1);
});
});
本机实跑:Test Files 1 passed、Tests 8 passed;tsc --noEmit 零错误。最容易写错的是「帧内卸载」两条:脚本自己 detach 自己时,它不该在本帧后半段再被 update,但 onDestroy 必须照跑——把销毁推迟到帧末就是为了同时满足这两点。
小结
- 脚本被压到最小:三个可选回调 + 名字 + 优先级,没有基类、没有虚表、不持有组件引用。
ScriptInstance用显式状态机pending → created → destroyed挡住误用;越界调用直接抛错,不静默。- 调度顺序 =
priority升序,同级按挂载顺序(稳定排序);ctx.frame每帧自增,blackboard跨脚本共享。 - 帧内卸载走延迟销毁:先出调度表、标
detached,onDestroy推到帧末#flush统一跑,遍历因此安全。
下一节处理最后一环:脚本代码本身怎么在运行时被换掉——模块替换与状态迁移。
阅读导航:上一节:7.1 反射驱动的属性暴露 · 下一节:7.3 模块替换与状态迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。