《游戏引擎实现原理》7.2 组件脚本的生命周期

脚本不是魔法,就是一组可选回调。本节把 onCreate / onUpdate / onDestroy 接到 ECS 实体的世代句柄上,用状态机挡住误用,让调度顺序由优先级与挂载次序决定,并把帧内卸载延迟到帧末以免遍历中改表;全部用 vitest 真跑。

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 模块替换与状态迁移 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径