《游戏引擎实现原理》10.2 编辑器控件与类型映射

面板拿到字段后,该给它配哪种控件?本节写一张字段类型到控件种类的映射表:数值对应数字框、bool 对应勾选框、vec3 展开成三分量、句柄对应对象引用,再叠一个可覆写的控件注册表处理个别特例。映射与解析逻辑在本机真跑并有 vitest 用例;真实 DOM 控件的创建属 L3,需在浏览器中手动验证。

10.2 编辑器控件与类型映射

10.1 把面板展开成了一串 PanelField,但每个字段旁边还是空的——它只说「这里有 position,类型 vec3,值 [0, 1.5, 0]」,没说该画成什么。这一节补上这一步:把字段类型翻译成控件种类。

这一步看起来琐碎,其实是编辑器「自动化」程度的分水岭。如果每加一个字段都要人指定「这个用数字框、那个用勾选框」,那面板还是半自动的。而字段类型是反射描述符已经给的确定信息——bool 就该是勾选框,vec3 就该展开成三个分量。所以这一步也应该由映射表自动完成,人只在个别特例上插手。

同样先划清界线:映射与解析是纯逻辑(L1,可跑可测),真实控件的创建是 L3(需在浏览器中手动验证,命令行不可复现)。本节把两者分成两个文件,各管各的。

一张映射表

映射的核心是一张「字段类型 → 控件种类」的表。helio 的字段类型来自 16 章的 FieldType,一共 7 个;控件种类是编辑器侧定义的语义标签:

字段类型控件种类说明
f32 / f64number浮点数字框,默认步进 0.01
i32 / u32number整数数字框,步进 1
boolcheckbox勾选框
vec3vector展开成 3 个数字框
handleobject-ref对象引用选择器

表里没出现 enum——因为 16 章的 FieldType 里根本没有枚举类型。枚举是编辑器元数据加上的:一个 i32 字段,只要元数据给了 enumValues,就该显示成下拉框。所以映射不是「一个类型对一个控件」的死表,而是带优先级的规则:先看有没有枚举候选,再退回类型表。

控件种类是语义,不是标签名。number 具体渲染成 <input type="number"> 还是 <input type="range">,是 L3 层的事;映射层只负责说「这是一个数值控件」。这样换一套 UI 风格时,映射逻辑一行不用改。

可运行产物:控件映射与注册表

下面 src/editor/property/widget-map.ts 层级 L1,完整可编译。它含三样东西:默认映射表与 defaultWidget、按字段补齐参数的 describeWidget、以及允许覆写的 WidgetRegistry。

// file: src/editor/property/widget-map.ts
// tier: L1
// verify: npx vitest run src/editor/property/widget-map.test.ts
//
// helio 编辑器:字段类型 → 控件种类的映射表,以及可覆写的控件注册表。
// 这一层只决定「用哪种控件、带什么参数」,不创建任何 DOM 节点;
// 控件的真实创建在 L3 的 DOM 胶水里(见 10.2 末节)。

import type { FieldType } from '../../core/type-registry.ts';
import type { PanelField } from './panel-model.ts';

/** 编辑器控件种类。控件种类是「语义」,具体标签由 L3 层决定(input / select / ...)。 */
export type WidgetKind =
  | 'number'
  | 'slider'
  | 'checkbox'
  | 'text'
  | 'dropdown'
  | 'vector'
  | 'object-ref';

/** 控件描述:种类 + 该种类需要的参数。 */
export interface WidgetSpec {
  readonly kind: WidgetKind;
  readonly options?: readonly string[];
  readonly min?: number;
  readonly max?: number;
  readonly step?: number;
  readonly components?: number;
}

/** 字段类型 → 默认控件种类。整数类型步进为 1,浮点默认 0.01。 */
const DEFAULT_WIDGET: Record<FieldType, WidgetKind> = {
  f32: 'number',
  f64: 'number',
  i32: 'number',
  u32: 'number',
  bool: 'checkbox',
  handle: 'object-ref',
  vec3: 'vector',
};

/** 分量数表:vec3 展开成三个数字框。 */
const VEC_COMPONENTS: Partial<Record<FieldType, number>> = { vec3: 3 };

/** 整数类型用 1 步进,浮点用 0.01。 */
function defaultStep(type: FieldType): number {
  return type === 'i32' || type === 'u32' ? 1 : 0.01;
}

/** 默认映射:只看字段类型。 */
export function defaultWidget(type: FieldType): WidgetKind {
  return DEFAULT_WIDGET[type];
}

/** 给定控件种类,补齐该种类需要的参数。 */
export function specForKind(kind: WidgetKind, field: PanelField): WidgetSpec {
  if (kind === 'number' || kind === 'slider') {
    return { kind, min: field.min, max: field.max, step: field.step ?? defaultStep(field.type) };
  }
  if (kind === 'vector') {
    return { kind, components: VEC_COMPONENTS[field.type] ?? 3 };
  }
  return { kind };
}

/** 由字段描述出控件:枚举优先,其次按类型给参数。 */
export function describeWidget(field: PanelField): WidgetSpec {
  if (field.enumValues && field.enumValues.length > 0) {
    return { kind: 'dropdown', options: field.enumValues };
  }
  return specForKind(defaultWidget(field.type), field);
}

/** 可覆写的控件注册表:按类型或按「属性名」改写默认映射。 */
export class WidgetRegistry {
  readonly #byType = new Map<FieldType, WidgetKind>();
  readonly #byName = new Map<string, WidgetKind>();

  /** 覆写某个字段类型的默认控件(如把 f32 默认改成滑块)。 */
  overrideType(type: FieldType, kind: WidgetKind): void {
    this.#byType.set(type, kind);
  }

  /** 覆写某个属性名的控件(优先级最高,用于个别特殊字段)。 */
  overrideName(name: string, kind: WidgetKind): void {
    this.#byName.set(name, kind);
  }

  /** 解析优先级:属性名覆写 > 枚举 > 类型覆写 > 默认。 */
  resolve(field: PanelField): WidgetSpec {
    const named = this.#byName.get(field.name);
    if (named) return { kind: named };
    if (field.enumValues && field.enumValues.length > 0) {
      return { kind: 'dropdown', options: field.enumValues };
    }
    const byType = this.#byType.get(field.type);
    if (byType) return specForKind(byType, field);
    return describeWidget(field);
  }
}

/** 供文档与调试用:导出一份默认映射表的副本。 */
export function defaultWidgetTable(): ReadonlyArray<{ type: FieldType; kind: WidgetKind }> {
  return (Object.keys(DEFAULT_WIDGET) as FieldType[]).map((type) => ({
    type,
    kind: DEFAULT_WIDGET[type],
  }));
}

优先级那一行是本节最容易写错的地方。resolve 的顺序是属性名覆写 > 枚举 > 类型覆写 > 默认:先看有没有针对这个属性名的特例,再看是不是枚举,再看这个字段类型有没有被整体改过,最后才回落到默认表。顺序错了会出怪现象——比如枚举被类型覆写抢先,下拉框变成了数字框。

真实输出:从类型到控件

下面 src/apps/editor-widget-demo.ts 打印默认映射表,再对 Health 的三个字段解析控件。它故意把 f32 整体覆写成滑块,看 max 上的元数据范围怎么带进控件参数。

// file: src/apps/editor-widget-demo.ts
// tier: L1
// verify: node src/apps/editor-widget-demo.ts
//
// 把面板字段映射成控件描述——10.2 的可运行产物。

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

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

console.log('default widget table:');
for (const row of defaultWidgetTable()) console.log(`  ${row.type.padEnd(7)} -> ${row.kind}`);

const health = reg.get('Health')!;
const model = buildPanelModel(health, { current: 42, max: 100, invulnerable: false }, {
  'Health.max': { min: 1, max: 999 },
});

const widgets = new WidgetRegistry();
widgets.overrideType('f32', 'slider');
for (const f of model.fields) {
  const spec = widgets.resolve(f);
  console.log(`  ${f.name.padEnd(13)} -> ${spec.kind} ${JSON.stringify(spec)}`);
}

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

default widget table:
  f32     -> number
  f64     -> number
  i32     -> number
  u32     -> number
  bool    -> checkbox
  handle  -> object-ref
  vec3    -> vector
  current       -> slider {"kind":"slider","step":0.01}
  max           -> slider {"kind":"slider","min":1,"max":999,"step":0.01}
  invulnerable  -> checkbox {"kind":"checkbox"}

三个字段各说明一件事。current 被 overrideType('f32', 'slider') 改成了滑块,步进默认 0.01;max 同样是滑块,但把元数据里的 min: 1, max: 999 带进了参数——控件参数来自字段,控件种类来自映射,两路信息在这里汇合;invulnerable 是 bool,没被覆写,仍是 checkbox。类型覆写只影响 f32,其余类型不受牵连。

创建控件:L3,命令行不可复现

映射产出的是「语义描述」,把它变成真实节点又是另一层。下面 src/editor/property/widget-factory.ts 层级 L3——依赖 document,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。

// file: src/editor/property/widget-factory.ts
// tier: L3
//
// 按控件描述创建真实 DOM 控件。依赖 document,命令行不可复现,
// 需在浏览器中手动验证(本文件仅通过 tsc 类型检查)。

import type { PanelField } from './panel-model.ts';
import type { WidgetSpec } from './widget-map.ts';

/** 一个已创建的控件:根节点 + 读写值的两个方法。 */
export interface WidgetHandle {
  readonly root: HTMLElement;
  setValue(value: unknown): void;
  getValue(): unknown;
}

function elementId(field: PanelField): string {
  return `fld-${field.name}`;
}

function numberWidget(field: PanelField, spec: WidgetSpec, slider: boolean): WidgetHandle {
  const input = document.createElement('input');
  input.type = slider ? 'range' : 'number';
  input.id = elementId(field);
  if (spec.min !== undefined) input.min = String(spec.min);
  if (spec.max !== undefined) input.max = String(spec.max);
  input.step = String(spec.step ?? 0.01);
  return {
    root: input,
    setValue: (v) => { input.value = String(v); },
    getValue: () => Number(input.value),
  };
}

function checkboxWidget(field: PanelField): WidgetHandle {
  const input = document.createElement('input');
  input.type = 'checkbox';
  input.id = elementId(field);
  return {
    root: input,
    setValue: (v) => { input.checked = Boolean(v); },
    getValue: () => input.checked,
  };
}

function dropdownWidget(field: PanelField, options: readonly string[]): WidgetHandle {
  const select = document.createElement('select');
  select.id = elementId(field);
  for (const opt of options) {
    const o = document.createElement('option');
    o.value = opt;
    o.textContent = opt;
    select.append(o);
  }
  return {
    root: select,
    setValue: (v) => { select.value = String(v); },
    getValue: () => select.value,
  };
}

function vectorWidget(field: PanelField, components: number): WidgetHandle {
  const root = document.createElement('div');
  root.className = 'vec-widget';
  const inputs: HTMLInputElement[] = [];
  for (let i = 0; i < components; i++) {
    const input = document.createElement('input');
    input.type = 'number';
    input.step = '0.01';
    input.dataset.component = String(i);
    inputs.push(input);
    root.append(input);
  }
  return {
    root,
    setValue: (v) => {
      const arr = Array.isArray(v) ? v : [];
      inputs.forEach((input, i) => { input.value = String(arr[i] ?? 0); });
    },
    getValue: () => inputs.map((input) => Number(input.value)),
  };
}

/** 按控件描述创建 DOM 控件。控件种类决定用哪个标签。 */
export function createWidget(field: PanelField, spec: WidgetSpec): WidgetHandle {
  switch (spec.kind) {
    case 'checkbox':
      return checkboxWidget(field);
    case 'dropdown':
      return dropdownWidget(field, spec.options ?? []);
    case 'vector':
      return vectorWidget(field, spec.components ?? 3);
    case 'slider':
      return numberWidget(field, spec, true);
    default:
      return numberWidget(field, spec, false);
  }
}

这里 createWidget 的 switch 就是「语义 → 标签」的翻译:checkbox 造勾选框、vector 造三分量组、slider 造 range。numberWidget 接收 slider 布尔量决定 type,所以数字框与滑块共用一个实现。vector 的 getValue 把三个输入拼回数组——读回来的仍是 vec3 的值形状,控件再花哨也不改变数据契约。

测试

映射与优先级都被单测盯住了。挑关键断言看:

// snippet: src/editor/property/widget-map.test.ts 节选
expect(defaultWidget('bool')).toBe('checkbox');
expect(defaultWidget('vec3')).toBe('vector');
expect(defaultWidgetTable()).toHaveLength(7); // 覆盖全部 7 个字段类型

const reg = new WidgetRegistry();
reg.overrideType('f32', 'slider');
reg.overrideName('speed', 'text');
expect(reg.resolve(speed).kind).toBe('text');    // 属性名覆写优先
expect(reg.resolve(grounded).kind).toBe('checkbox'); // 未覆写类型不变

本机 npx vitest run src/editor/property/widget-map.test.ts 是 8 个用例全过(tsc --noEmit 零错误)。用例覆盖三类:默认映射逐类型正确、枚举优先于类型、覆写优先级(属性名 > 类型 > 默认)。

几个坑:

  • 把控件种类当成标签名。 'number' 是语义,不是 <input>。混了之后,想换 UI 框架就得改映射层,分层就白做了。
  • 优先级写反。 若「类型覆写」排在「枚举」前面,一个被覆写成 number 的 i32 枚举字段会丢掉下拉框。顺序是契约,要用用例钉死。
  • 整数与浮点用同一个步进。 整数字段步进 0.01 会让用户能输入 1.5,而底层是 i32——值被截断,用户看到的是「输入了但没生效」。整数步进必须是 1。

小结

  • 字段类型是反射给的确定信息,控件种类应由映射表自动推导,人只在特例上插手。
  • 映射是带优先级的规则:属性名覆写 > 枚举 > 类型覆写 > 默认;枚举由编辑器元数据附加,不在 FieldType 里。
  • WidgetSpec 里种类来自映射、参数来自字段(min / max / step / components),两路信息在 resolve 汇合。
  • 真实控件创建是 L3,需在浏览器中手动验证,命令行不可复现。

单选的映射解决了。可编辑器里选中往往不止一个对象——下一节处理多选:哪些字段该显示、值不一致时怎么标、改一次怎么落到所有对象。

阅读导航:上一节:10.1 反射驱动的属性面板 · 下一节:10.3 多选编辑与批量修改 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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