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 组件脚本的生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。