《游戏引擎实现原理》7.1 反射驱动的属性暴露

组件字段要能被脚本与编辑器读写,就不能靠字符串硬编码。本节把内核的类型描述符接到组件实例上,实现带类型强制、只读标记与隐藏字段的属性绑定,并用 vitest 把类型强制、快照与变更比对在本机真跑一遍,给出可供撤销栈与多选编辑复用的最小变更集。

7.1 反射驱动的属性暴露

前六章把资源、动画、物理、音频、输入依次接进了内核。从这一章开始处理脚本绑定与热重载——让游戏逻辑不必每改一行都重新编译引擎,也让编辑器能把组件字段显示成可编辑的控件。这一章三节层层递进:属性暴露(7.1)、脚本生命周期(7.2)、模块替换与状态迁移(7.3)。

这一节先解决最基础的一环:组件的字段如何被外部读写。编辑器面板要列出「生命值」并允许拖拽改数值,脚本要能读取「速度」并写回「是否着地」——它们都不该知道字段在内存里怎么排布,更不该用字符串硬编码字段名。helio 的做法是把内核的类型描述符(卷一第 16 章)接到组件实例上,产出一张可读写的属性视图。

本章与站内既有内容的分工

站内已经有不少讲脚本系统与编辑器属性的文章,本卷不重推这些设计,只做「从零实现 + 每节真跑」。

主题站内既有内容本章这一节做什么
反射底座16.1 为什么需要反射 、16.2 类型描述符把描述符接到实例上,产出可读写的属性视图
显式注册表与代码生成16.3 显式注册表与代码生成直接消费生成好的描述符,不重推注册表原理
反射驱动的绑定(渲染侧)11.2 反射驱动的绑定同样的「按描述符绑定」思路,这里绑的是脚本与编辑器而非 uniform
Defold 脚本与编辑器扩展Defold 脚本系统 、Defold 编辑器扩展不重推引擎用法,只把属性暴露写成可跑的 TS
Cocos 属性声明《Cocos 游戏开发实战》3.1 ccclass 与 @property对照装饰器式声明,helio 走显式注册表路线

从描述符到属性视图

内核已经能回答「一个 Health 类型有哪些字段、每个字段什么类型、默认值多少」——这是 defineType 产出的 TypeDescriptor。缺的是另一半:把描述符接到某个具体的组件实例上,让它变成「这个实例的 current 现在是 60」这种可以读、可以写、还能校验的东西。

coerce 是这里的关键防线。编辑器传来的值可能是字符串(输入框)、可能是浮点数(滑块),也可能根本是垃圾。写回组件之前必须先按字段类型强制一次:f32 接受 "42.5" 并转成 42.5,但拒绝 "abc";handle 一律取整;vec3 必须是三个数字。绝不静默写坏数据——强制失败就抛错,让上层知道这次编辑无效。

下面 src/subsys/script/property-binding.ts 层级 L1,完整可编译。它定义属性视图、类型强制、绑定类,以及快照与变更比对。

// file: src/subsys/script/property-binding.ts
// tier: L1
// verify: npx vitest run src/subsys/script/property-binding.test.ts
//
// helio 脚本子系统:反射驱动的属性暴露。
// 组件的可编辑字段由内核的显式类型注册表(卷一第 16 章)声明;
// 本节把描述符接到「实例」上,给脚本与编辑器一个可读可写、带类型校验的
// 属性视图。写入前统一做类型强制,绝不把坏数据写进组件。

import {
  readProperty,
  type FieldType,
  type PropertyDescriptor,
  type TypeDescriptor,
  type TypeRegistry,
} from '../../core/type-registry.ts';

/** 暴露给脚本 / 编辑器的一个属性:名字、类型、当前值、是否只读。 */
export interface ExposedProperty {
  readonly name: string;
  readonly type: FieldType;
  readonly value: unknown;
  readonly readOnly: boolean;
}

/** 暴露选项:隐藏字段不进面板,只读字段可读不可写。 */
export interface ExposureOptions {
  readonly hidden?: readonly string[];
  readonly readOnly?: readonly string[];
}

/** 把任意输入强制成字段类型;失败即抛错,绝不静默写坏数据。 */
export function coerce(type: FieldType, value: unknown): unknown {
  switch (type) {
    case 'f32':
    case 'f64': {
      const n = typeof value === 'number' ? value : Number(value);
      if (!Number.isFinite(n)) throw new Error(`Not a number: ${String(value)}`);
      return n;
    }
    case 'i32':
    case 'u32': {
      const n = typeof value === 'number' ? value : Number(value);
      if (!Number.isFinite(n)) throw new Error(`Not an integer: ${String(value)}`);
      return Math.trunc(n);
    }
    case 'handle': {
      const n = typeof value === 'number' ? value : Number(value);
      if (!Number.isFinite(n)) throw new Error(`Not a handle: ${String(value)}`);
      return Math.trunc(n);
    }
    case 'bool':
      return Boolean(value);
    case 'vec3': {
      if (!Array.isArray(value) || value.length !== 3) {
        throw new Error(`vec3 needs 3 numbers, got ${JSON.stringify(value)}`);
      }
      return value.map((v) => coerce('f32', v));
    }
    default:
      throw new Error(`Unsupported field type: ${String(type)}`);
  }
}

/** 值比较:数组按元素比,标量按 Object.is(NaN 相等、+0/-0 不等)。 */
function sameValue(a: unknown, b: unknown): boolean {
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
  }
  return Object.is(a, b);
}

/** 属性绑定:包住一个组件实例,按描述符暴露可读可写的属性。 */
export class PropertyBinding {
  readonly #target: Record<string, unknown>;
  readonly #desc: TypeDescriptor;
  readonly #hidden: ReadonlySet<string>;
  readonly #readOnly: ReadonlySet<string>;

  constructor(
    target: Record<string, unknown>,
    desc: TypeDescriptor,
    options: ExposureOptions = {},
  ) {
    this.#target = target;
    this.#desc = desc;
    this.#hidden = new Set(options.hidden ?? []);
    this.#readOnly = new Set(options.readOnly ?? []);
  }

  get typeName(): string {
    return this.#desc.name;
  }

  #prop(name: string): PropertyDescriptor {
    const p = this.#desc.properties.find((q) => q.name === name);
    if (p === undefined) throw new Error(`Unknown property ${name} on ${this.#desc.name}`);
    return p;
  }

  /** 暴露的属性列表:按描述符顺序,过滤隐藏项,附上当前值。 */
  exposed(): ExposedProperty[] {
    const out: ExposedProperty[] = [];
    for (const p of this.#desc.properties) {
      if (this.#hidden.has(p.name)) continue;
      out.push({
        name: p.name,
        type: p.type,
        value: readProperty(this.#target, p),
        readOnly: this.#readOnly.has(p.name),
      });
    }
    return out;
  }

  /** 读属性;未知属性抛错。 */
  get(name: string): unknown {
    return readProperty(this.#target, this.#prop(name));
  }

  /** 写属性:先强制类型再写回。只读属性拒绝写入并返回 false。 */
  set(name: string, value: unknown): boolean {
    const p = this.#prop(name);
    if (this.#readOnly.has(p.name)) return false;
    const coerced = coerce(p.type, value);
    if (sameValue(this.#target[p.name], coerced)) return false;
    this.#target[p.name] = coerced;
    return true;
  }

  /** 恢复描述符里的默认值;返回是否真的改动了。 */
  reset(name: string): boolean {
    return this.set(name, this.#prop(name).defaultValue);
  }
}

/** 给绑定拍一张属性快照,用于之后比对。 */
export function snapshot(binding: PropertyBinding): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const p of binding.exposed()) out[p.name] = p.value;
  return out;
}

/** 与快照比对,返回值发生变化的属性名(按描述符顺序)。 */
export function changedFrom(
  binding: PropertyBinding,
  base: Record<string, unknown>,
): string[] {
  const out: string[] = [];
  for (const p of binding.exposed()) {
    if (!sameValue(p.value, base[p.name])) out.push(p.name);
  }
  return out;
}

/** 按类型名从注册表建绑定;类型未注册即抛错。 */
export function bind(
  reg: TypeRegistry,
  typeName: string,
  target: Record<string, unknown>,
  options: ExposureOptions = {},
): PropertyBinding {
  const desc = reg.get(typeName);
  if (desc === undefined) throw new Error(`Type not registered: ${typeName}`);
  return new PropertyBinding(target, desc, options);
}

三个设计决定

其一,视图顺序就是描述符顺序。 exposed() 严格按 TypeDescriptor.properties 的顺序产出,而描述符顺序就是注册顺序。这样一来,编辑器面板每次打开看到的字段次序都一样,diff 与快照也因此稳定可复现——这是 9 章命令栈做撤销的前提。

其二,只读与隐藏是「暴露层」的概念,不是「存储层」的。 组件本身并不区分哪个字段只读;invulnerable 在运行时当然可写,只是不希望美术在面板里乱动。所以 readOnly 与 hidden 作为绑定选项传入,同一份描述符可以派生出「调试面板」(全可见可写)和「发布面板」(只暴露一部分)两套视图。

其三,set 返回「是否真的变了」。 写相同值返回 false,这不是小事:命令栈只在返回值变化时才压入一条撤销记录,否则一次「点开面板又关掉」会塞进满屏空命令。快照 + changedFrom 是同一逻辑的批量化版本,供批量编辑(10.3)计算最小变更集。

可运行产物:属性暴露的真跑

属性暴露完全是纯逻辑,可以在 Node 里真跑。下面 src/subsys/script/property-binding.test.ts 层级 L1,命令 npx vitest run src/subsys/script/property-binding.test.ts。

// file: src/subsys/script/property-binding.test.ts
// tier: L1
// verify: npx vitest run src/subsys/script/property-binding.test.ts
import { describe, expect, it } from 'vitest';
import { TypeRegistry, defineType } from '../../core/type-registry.ts';
import { PropertyBinding, bind, changedFrom, coerce, snapshot } from './property-binding.ts';

const HEALTH = defineType('Health', [
  { name: 'current', type: 'f32', defaultValue: 100 },
  { name: 'max', type: 'f32', defaultValue: 100 },
  { name: 'invulnerable', type: 'bool', defaultValue: false },
  { name: 'team', type: 'handle', defaultValue: 0 },
]);
const makeHealth = (): Record<string, unknown> => ({ current: 60, max: 100, invulnerable: false, team: 3 });

describe('属性暴露', () => {
  it('exposed 按描述符顺序返回当前值', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH);
    expect(b.exposed().map((p) => p.name)).toEqual(['current', 'max', 'invulnerable', 'team']);
    expect(b.exposed()[0]).toMatchObject({ name: 'current', type: 'f32', value: 60 });
  });

  it('hidden 字段不进视图,但仍可 get', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH, { hidden: ['team'] });
    expect(b.exposed().map((p) => p.name)).toEqual(['current', 'max', 'invulnerable']);
    expect(b.get('team')).toBe(3);
  });

  it('只读字段被标记且拒绝写入;未知属性抛错', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH, { readOnly: ['max'] });
    expect(b.exposed().find((p) => p.name === 'max')?.readOnly).toBe(true);
    expect(b.set('max', 999)).toBe(false);
    expect(b.get('max')).toBe(100);
    expect(() => b.get('nope')).toThrow(/Unknown property/);
  });
});

describe('写入强制类型', () => {
  it('字符串数字强制成 f32;非数字被拒且原值不动', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH);
    expect(b.set('current', '42.5')).toBe(true);
    expect(b.get('current')).toBe(42.5);
    expect(() => b.set('current', 'abc')).toThrow(/Not a number/);
    expect(b.get('current')).toBe(42.5);
  });

  it('handle 取整、bool 强制、写相同值返回 false', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH);
    b.set('team', 7.9);
    expect(b.get('team')).toBe(7);
    b.set('invulnerable', 0);
    expect(b.get('invulnerable')).toBe(false);
    expect(b.set('current', 60)).toBe(false);
  });

  it('reset 恢复默认值', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH);
    b.set('current', 5);
    expect(b.reset('current')).toBe(true);
    expect(b.get('current')).toBe(100);
  });
});

describe('快照与变更比对', () => {
  it('changedFrom 只报真正变化的字段', () => {
    const b = new PropertyBinding(makeHealth(), HEALTH);
    const base = snapshot(b);
    b.set('current', 10);
    b.set('invulnerable', true);
    expect(changedFrom(b, base)).toEqual(['current', 'invulnerable']);
  });

  it('vec3 按元素比较,未改不算变更', () => {
    const TR = defineType('TR', [{ name: 'pos', type: 'vec3', defaultValue: [0, 0, 0] }]);
    const b = new PropertyBinding({ pos: [1, 2, 3] }, TR);
    const base = snapshot(b);
    b.set('pos', [1, 2, 3]);
    expect(changedFrom(b, base)).toEqual([]);
    b.set('pos', [1, 2, 4]);
    expect(changedFrom(b, base)).toEqual(['pos']);
  });
});

describe('coerce 与注册表绑定', () => {
  it('vec3 元素个数不对抛错;bind 取注册表,未注册抛错', () => {
    expect(() => coerce('vec3', [1, 2])).toThrow(/vec3 needs 3/);
    const reg = new TypeRegistry();
    reg.register(HEALTH);
    expect(bind(reg, 'Health', makeHealth()).typeName).toBe('Health');
    expect(() => bind(reg, 'Missing', {})).toThrow(/Type not registered/);
  });
});

本机实跑:Test Files 1 passed、Tests 9 passed;tsc --noEmit 零错误。最值得盯的两条是「字符串 "42.5" 被强制成 42.5」与「写相同值返回 false」——前者是编辑器输入框到引擎数值的必经之路,后者决定命令栈会不会被空操作淹没。

小结

  • 属性暴露 = 描述符(类型层)+ 实例(数据层) 的桥。PropertyBinding 只做这一件事,不碰存储。
  • coerce 是写入前的唯一闸门:f32 认字符串数字但拒 "abc",handle 取整,vec3 校验元素个数。
  • readOnly / hidden 属于暴露层,同一描述符可派生多套面板视图;set 返回「是否变化」,供撤销栈与批量编辑复用。
  • 快照 + changedFrom 给出最小变更集,是后面命令栈与多选编辑的公共底座。

下一节把「回调」接上来:属性可读写了,还得有东西在每帧去读它、写它——那就是组件脚本的生命周期。

阅读导航:上一节:回到目录 · 下一节:7.2 组件脚本的生命周期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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