《游戏引擎实现原理》10.1 反射驱动的属性面板

属性面板不该为每个组件手写 UI。本节把卷一第 16 章的反射描述符接进编辑器:从类型描述与实例展开出纯数据的「面板描述模型」——字段名、标签、类型、当前值、只读与枚举候选,不含任何 DOM。该数据模型在本机真跑并有 vitest 用例;DOM 渲染属 L3,需在浏览器中手动验证。

10.1 反射驱动的属性面板

编辑器这一组四章走到第三组。8 章定了文档模型,9 章定了命令栈,这一章处理属性面板——把选中对象的一堆字段摆出来、让你改、改动走命令栈。它和卷一的第 16 章正好首尾相接:16 章解决了「运行时怎么知道一个类型有哪些字段」,这里解决「知道之后怎么把它变成一块可编辑的面板」。

先说清楚这一章的性质。DOM 控件与交互是 L3/L4,命令行不可复现;但面板的数据部分——从描述符展开出「该显示哪些字段、每个字段叫什么、当前值是多少」——是完全的纯逻辑,可以在 Node 里真跑、可以用 vitest 盯住。本节的做法就是把这两层彻底分开:先产出一份不含 DOM 的「面板描述数据」,再由 L3 的渲染层把它变成真实节点。分开了,逻辑才可测。

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

站内已经有不少讲编辑器用法的文章,本卷不重推这些,只做「从零实现 + 每节真跑」。

主题站内既有内容本章这一节做什么
编辑器整体用法Defold 关卡编辑器:可视编辑与场景组织不重推编辑器操作,只实现属性面板的数据模型
编辑器扩展机制Defold 编辑器扩展:自定义编辑器工具链面板这一环从零写;扩展机制本身外链
资产管线Defold 编辑器资产管线:导入与格式转换面板只读属性值,资产的导入转换不在此章
反射与类型描述符16.2 类型注册与属性描述符直接消费它的 TypeDescriptor,不重复实现

为什么面板不该手写

最直观的写法是「一个组件一段 UI」:Health 组件就手写三个输入框,Transform 就手写九个数。它跑得起来,但有个致命问题——UI 和字段是两处独立维护的真相。你在组件里加一个 invulnerable 字段,面板不会自己多出一个勾选框;你改了字段名,面板的 label 还是旧的。字段一多,这种脱节必然发生。

卷一 16 章的反射描述符就是为这个准备的。一个 TypeDescriptor 已经携带了面板需要的一切:字段的 name、type、defaultValue,还有按声明顺序排列的 properties 列表。面板只要遍历描述符,就能把字段一个个摆出来——字段增减由描述符自动跟随,UI 不再有独立真相。

这一节要产出的东西叫面板描述数据(panel model):它是一个纯 JS 对象,描述「这块面板长什么样」。它和 DOM 的关系,就像「渲染命令列表」和「GPU 命令缓冲」的关系——先有一份数据,再有一层把它消费掉。

可运行产物:面板描述数据模型

下面 src/editor/property/panel-model.ts 层级 L1,完整可编译。它把反射描述符 + 实例 + 一份编辑器元数据展开成 PanelModel。注意里面没有一个 document——这是刻意的,纯数据才可测。

// file: src/editor/property/panel-model.ts
// tier: L1
// verify: npx vitest run src/editor/property/panel-model.test.ts
//
// helio 编辑器:把反射类型描述符展开成属性面板的「描述数据」。
// 这一层是纯数据——只产出字段列表,不碰任何 DOM 节点;
// 真正的控件渲染在 10.2 的控件层与 L3 的 DOM 胶水里完成。

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

/** 每个属性的编辑器元数据:标签、隐藏、只读、枚举候选、数值范围。 */
export interface EditorHint {
  readonly label?: string;
  readonly hidden?: boolean;
  readonly readOnly?: boolean;
  readonly enumValues?: readonly string[];
  readonly min?: number;
  readonly max?: number;
  readonly step?: number;
}

/** 编辑器元数据表:键是「类型名.属性名」,如 'Health.invulnerable'。 */
export type EditorHints = Readonly<Record<string, EditorHint>>;

/** 一个面板字段:纯数据,不含任何 DOM 节点。 */
export interface PanelField {
  readonly name: string;
  readonly label: string;
  readonly type: FieldType;
  readonly value: unknown;
  readonly readOnly: boolean;
  readonly enumValues?: readonly string[];
  readonly min?: number;
  readonly max?: number;
  readonly step?: number;
}

/** 一个类型在面板里的完整描述数据。 */
export interface PanelModel {
  readonly typeName: string;
  readonly fields: readonly PanelField[];
}

/** camelCase / snake_case → 人类可读标签:grounded → Grounded,maxHealth → Max Health。 */
export function humanize(name: string): string {
  const spaced = name.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
  return spaced
    .split(' ')
    .filter((w) => w.length > 0)
    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
    .join(' ');
}

/** 取一条属性的编辑器元数据,缺省时空对象。 */
function hintOf(desc: TypeDescriptor, p: PropertyDescriptor, hints: EditorHints): EditorHint {
  return hints[`${desc.name}.${p.name}`] ?? {};
}

/** 把反射描述符 + 实例 + 编辑器元数据展开成面板描述数据。 */
export function buildPanelModel(
  desc: TypeDescriptor,
  obj: Record<string, unknown>,
  hints: EditorHints = {},
): PanelModel {
  const fields: PanelField[] = [];
  for (const p of desc.properties) {
    const hint = hintOf(desc, p, hints);
    if (hint.hidden) continue;
    fields.push({
      name: p.name,
      label: hint.label ?? humanize(p.name),
      type: p.type,
      value: readProperty(obj, p),
      readOnly: hint.readOnly ?? false,
      enumValues: hint.enumValues,
      min: hint.min,
      max: hint.max,
      step: hint.step,
    });
  }
  return { typeName: desc.name, fields };
}

/** 面板分区配置:一组字段归到一个标题下。 */
export interface GroupSpec {
  readonly title: string;
  readonly names: readonly string[];
}

/** 一个面板分区:标题 + 组内字段(保持描述符原顺序)。 */
export interface PanelGroup {
  readonly title: string;
  readonly fields: readonly PanelField[];
}

/**
 * 按配置把字段切成若干分区;未被任何分区认领的字段收进「其它」。
 * 认领按配置顺序,同名重复认领只算第一次。
 */
export function groupFields(model: PanelModel, groups: readonly GroupSpec[]): readonly PanelGroup[] {
  const byName = new Map(model.fields.map((f) => [f.name, f]));
  const claimed = new Set<string>();
  const out: PanelGroup[] = [];
  for (const g of groups) {
    const fs: PanelField[] = [];
    for (const n of g.names) {
      const f = byName.get(n);
      if (f && !claimed.has(n)) {
        fs.push(f);
        claimed.add(n);
      }
    }
    out.push({ title: g.title, fields: fs });
  }
  const rest = model.fields.filter((f) => !claimed.has(f.name));
  if (rest.length > 0) out.push({ title: '其它', fields: rest });
  return out;
}

几处设计取舍:

  • 描述符提供结构,元数据提供修饰。 name / type / value 来自反射,是「结构」;label / hidden / readOnly / enumValues 来自编辑器元数据,是「修饰」。前者不可能手写错(它由 16 章的注册表保证),后者才允许人在编辑器侧微调。能自动的就别手写,这一条贯穿整个编辑器。
  • 标签是推导的,不是写死的。 humanize 把 maxHealth 拆成 Max Health。默认标签跟着字段名走,字段改名标签自动跟;只有确实想换文案时才用元数据覆盖(如 rotation → 「旋转(度)」)。
  • value 走 readProperty。 16 章留了 accessor 逃生通道——有些属性不在紧凑记录里。面板读值必须复用这条路径,否则会读到错误的东西。

真实输出:Transform 面板

光看实现不算数。下面 src/apps/editor-panel-demo.ts 从生成好的类型注册表里取 Transform,展开成面板数据,再按分区配置切一刀。

// file: src/apps/editor-panel-demo.ts
// tier: L1
// verify: node src/apps/editor-panel-demo.ts
//
// 从反射描述符展开属性面板的描述数据——10.1 的可运行产物。

import { TypeRegistry } from '../core/type-registry.ts';
import { GENERATED_TYPES } from '../generated/type-registry.gen.ts';
import { buildPanelModel, groupFields } from '../editor/property/panel-model.ts';

const reg = new TypeRegistry();
reg.registerAll(GENERATED_TYPES);

const transform = reg.get('Transform')!;
const obj = { position: [0, 1.5, 0], rotation: [0, 90, 0], scale: [1, 1, 1] };

const model = buildPanelModel(transform, obj, {
  'Transform.rotation': { label: '旋转(度)', step: 1 },
});
console.log(`panel: ${model.typeName}, fields=${model.fields.length}`);
for (const f of model.fields) {
  console.log(`  ${f.name.padEnd(10)} label="${f.label}" type=${f.type} value=${JSON.stringify(f.value)}`);
}

const groups = groupFields(model, [{ title: '变换', names: ['position', 'rotation'] }]);
for (const g of groups) console.log(`group "${g.title}": [${g.fields.map((f) => f.name).join(', ')}]`);

本机 node src/apps/editor-panel-demo.ts 的真实输出:

panel: Transform, fields=3
  position   label="Position" type=vec3 value=[0,1.5,0]
  rotation   label="旋转(度)" type=vec3 value=[0,90,0]
  scale      label="Scale" type=vec3 value=[1,1,1]
group "变换": [position, rotation]
group "其它": [scale]

两点值得注意。第一,rotation 的标签被元数据覆盖成了「旋转(度)」,而 position / scale 走默认的 humanize;同一份面板里两种来源共存,这正是「结构自动、修饰手写」的落地。第二,groupFields 只认领了 position 与 rotation,剩下的 scale 自动落进「其它」——分区配置不必穷举所有字段,漏掉的不会消失。

DOM 渲染:L3,命令行不可复现

数据模型备好了,把它变成屏幕上的节点是另一层的事。下面 src/editor/property/panel-view.ts 层级 L3——它依赖 document,本节需在浏览器中手动验证,命令行不可复现(本文件仅通过 tsc 类型检查,不在 Node 里运行)。

// file: src/editor/property/panel-view.ts
// tier: L3
//
// 把面板描述数据渲染成 DOM。依赖 document,命令行不可复现,
// 需在浏览器中手动验证(本文件仅通过 tsc 类型检查)。

import type { PanelModel, PanelField } from './panel-model.ts';

/** 一个字段的 DOM 行:标签 + 控件槽。控件由 10.2 的工厂填进 slot。 */
export interface FieldRow {
  readonly field: PanelField;
  readonly root: HTMLElement;
  readonly label: HTMLLabelElement;
  readonly slot: HTMLElement;
}

/** 把一份面板描述数据渲染成 DOM,返回每行的句柄供后续绑定控件。 */
export function renderPanel(model: PanelModel, host: HTMLElement): FieldRow[] {
  host.replaceChildren();
  const rows: FieldRow[] = [];
  for (const field of model.fields) {
    const root = document.createElement('div');
    root.className = 'field-row';
    const label = document.createElement('label');
    label.textContent = field.label;
    label.htmlFor = `fld-${model.typeName}-${field.name}`;
    const slot = document.createElement('div');
    slot.className = 'field-slot';
    if (field.readOnly) root.classList.add('is-readonly');
    root.append(label, slot);
    host.append(root);
    rows.push({ field, root, label, slot });
  }
  return rows;
}

注意 renderPanel 只画了标签和空槽,没画控件——控件由 10.2 的工厂按字段类型填进 slot。这个切分让「面板结构」与「控件选择」各自独立:想换一套控件风格,只动工厂,不动这里。renderPanel 返回每行的句柄,就是为了让工厂拿得到 slot。

测试与常见坑

上面那些结论都被单测盯住了。挑关键断言看:

// snippet: src/editor/property/panel-model.test.ts 节选
const m = buildPanelModel(HEALTH, { current: 1, maxHealth: 2, invulnerable: false }, {
  'Health.current': { label: '当前血量', readOnly: true },
  'Health.invulnerable': { hidden: true },
});
expect(m.fields.map((f) => f.name)).toEqual(['current', 'maxHealth']); // hidden 被剔除
expect(m.fields[0].label).toBe('当前血量');
expect(m.fields[0].readOnly).toBe(true);

// 字段顺序 = 描述符顺序,值来自实例
const full = buildPanelModel(HEALTH, { current: 42, maxHealth: 100, invulnerable: true });
expect(full.fields.map((f) => f.value)).toEqual([42, 100, true]);

本机 npx vitest run src/editor/property/panel-model.test.ts 是 6 个用例全过(tsc --noEmit 零错误)。它们盯住三类不变式:字段顺序等于描述符顺序、hidden 字段被剔除、元数据能覆盖标签与只读。

实现和使用这一层时,几个反复出现的坑:

  • 在面板里直接读写对象、绕过描述符。 一旦绕过,accessor 属性就会读错。所有读写都走 readProperty,这是 16 章定的契约。
  • 把「面板字段」和「对象字段」画等号。 面板可能隐藏某些字段(hidden),也可能给同一字段换个标签。面板描述是对象的一个视图,不是对象本身。
  • 在数据层里塞 DOM。 一旦 buildPanelModel 里出现 document,它就再也测不了。本节的硬规矩是:数据层零 DOM,DOM 全在 L3。
  • 手写 label 导致字段改名后文案失联。 默认标签走 humanize,只有真需要不同文案时才用元数据覆盖。

小结

  • 属性面板不该为每个组件手写 UI——遍历反射描述符就能自动得到字段列表,UI 不再有独立真相。
  • 本节的产物是面板描述数据 PanelModel:纯数据、零 DOM,可被 vitest 真跑。
  • 描述符提供结构(name / type / value),编辑器元数据提供修饰(label / hidden / readOnly / enumValues)。
  • DOM 渲染是 L3,需在浏览器中手动验证,命令行不可复现。

字段备好了,但每个字段该配哪种控件?vec3 显然不能只给一个输入框。下一节就写这张映射表。

阅读导航:上一节:回到目录 · 下一节:10.2 编辑器控件与类型映射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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