《游戏引擎实现原理》10.3 多选编辑与批量修改

同时选中多个对象时,属性面板该显示什么?本节实现多选编辑的纯数据部分:求公共属性、把值聚合成「全等」或「混合」两种状态、再生成只包含真正变化项的批量命令。公共属性、混合值判定与批量命令生成都在本机真跑并有 vitest 用例;面板渲染与交互属 L3/L4,需在浏览器中手动验证。

10.3 多选编辑与批量修改

10.1 和 10.2 都在处理「选中一个对象」的面板。但编辑器里选中往往不止一个——框选一片小怪、按类型全选、多选后统一改血量。多选编辑看着只是「把单选重复 N 遍」,实际多出两个单选没有的问题:该显示哪些字段,以及值不一致时怎么标。

这一节处理这两个问题的数据部分,并且把结果接到 9 章的命令栈上——批量改动不能是一个「直接改 N 个对象」的函数,而要是一份可撤销的批量命令。同样地,DOM 渲染与鼠标交互是 L3/L4,命令行不可复现;但公共属性计算、混合值判定、批量命令生成全是纯逻辑,本节真跑并测住。

公共属性与混合值

第一个问题是「显示哪些字段」。如果选中的对象都是 Health 类型,那三个字段全都能显示。但如果选中的是一堆不同类型的对象——一部分是 Health、一部分是 Enemy——就只能显示它们的公共属性:所有选中类型都有的那些字段。求公共属性就是对各类型的属性名集合求交集,并且按第一个类型的声明顺序排列,保证稳定。

第二个问题是「值不一致怎么标」。假设三个对象的 current 是 42 / 42 / 10。面板不能只显示一个数字——那会让人以为三个都是它。工业界通用的做法是显示一个混合占位符(常见是短横线或星号),表示「这几项不一致」。所以在面板数据里,一个字段的值不再是裸值,而是两种状态之一:

状态含义面板表现
single所有选中项该属性值全等显示那个值
mixed存在不一致显示占位符(如 —)

判断「全等」时要注意 vec3:它是数组,=== 永远为假,必须逐元素比较。position 这种字段如果只比引用,任何两个对象都会被判成混合,面板上就永远是占位符了。

可运行产物:多选面板与批量命令

下面 src/editor/property/multi-select.ts 层级 L1,完整可编译。它含四件事:结构相等 equalValue、公共属性 commonFieldNames、值聚合 aggregate 与多选面板 computeMultiPanel,以及批量命令生成 buildBatchEdit。

// file: src/editor/property/multi-select.ts
// tier: L1
// verify: npx vitest run src/editor/property/multi-select.test.ts
//
// helio 编辑器:多选编辑——计算公共属性、识别混合值、生成批量编辑命令。
// 纯数据:不碰 DOM,也不直接改场景,只产出「批量命令描述」,
// 交给 9 章的命令栈去执行与撤销。

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

/** 多选下的一个值:要么全部一致,要么是混合(并记录样本数)。 */
export type MultiValue<T> =
  | { readonly kind: 'single'; readonly value: T }
  | { readonly kind: 'mixed'; readonly count: number };

/** 多选面板里的一个字段。 */
export interface MultiField {
  readonly name: string;
  readonly type: FieldType;
  readonly value: MultiValue<unknown>;
}

/** 多选面板描述数据。 */
export interface MultiPanel {
  readonly typeName: string;
  readonly total: number;
  readonly fields: readonly MultiField[];
}

/** 一个选中对象:它的类型描述符 + 实例。 */
export interface Selection {
  readonly desc: TypeDescriptor;
  readonly obj: Record<string, unknown>;
}

/** 结构相等:标量按 ===,数组逐元素(vec3 用得到)。 */
export function equalValue(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((v, i) => equalValue(v, b[i]));
  }
  return false;
}

/** 多个描述符的公共属性名:按第一个描述符的顺序求交集。 */
export function commonFieldNames(descs: readonly TypeDescriptor[]): string[] {
  if (descs.length === 0) return [];
  const sets = descs.map((d) => new Set(d.properties.map((p) => p.name)));
  return descs[0].properties.map((p) => p.name).filter((n) => sets.every((s) => s.has(n)));
}

/** 从一个名字在多个选中项里找到第一个能提供它的属性描述符。 */
function findProp(sel: Selection, name: string): PropertyDescriptor | undefined {
  return sel.desc.properties.find((p) => p.name === name);
}

/** 聚合一个属性在多个选中项上的值:全等 → single,否则 mixed。 */
export function aggregate(values: readonly unknown[]): MultiValue<unknown> {
  if (values.length === 0) return { kind: 'mixed', count: 0 };
  const first = values[0];
  const same = values.every((v) => equalValue(v, first));
  return same ? { kind: 'single', value: first } : { kind: 'mixed', count: values.length };
}

/** 计算多选面板:公共属性 + 每个属性的聚合值。 */
export function computeMultiPanel(selections: readonly Selection[]): MultiPanel {
  const names = commonFieldNames(selections.map((s) => s.desc));
  const fields: MultiField[] = [];
  for (const name of names) {
    const values: unknown[] = [];
    let type: FieldType = 'f32';
    for (const sel of selections) {
      const prop = findProp(sel, name);
      if (!prop) continue;
      type = prop.type;
      values.push(readProperty(sel.obj, prop));
    }
    fields.push({ name, type, value: aggregate(values) });
  }
  const typeName = selections.length > 0 ? selections[0].desc.name : '';
  return { typeName, total: selections.length, fields };
}

/** 单条属性改动。target 是选中项下标,before/after 供命令栈做撤销。 */
export interface PropertyEdit {
  readonly target: number;
  readonly property: string;
  readonly before: unknown;
  readonly after: unknown;
}

/** 批量编辑命令描述:一个标签 + 一组改动。 */
export interface BatchEditCommand {
  readonly label: string;
  readonly edits: readonly PropertyEdit[];
}

/**
 * 生成批量编辑命令:把 property 在全部选中项上设成 next。
 * 已经是目标值的项被跳过——批量命令只包含真正变化的项,撤销栈因此干净。
 */
export function buildBatchEdit(
  selections: readonly Selection[],
  property: string,
  next: unknown,
): BatchEditCommand {
  const edits: PropertyEdit[] = [];
  for (let i = 0; i < selections.length; i++) {
    const prop = findProp(selections[i], property);
    if (!prop) continue;
    const before = readProperty(selections[i].obj, prop);
    if (equalValue(before, next)) continue;
    edits.push({ target: i, property, before, after: next });
  }
  return { label: `设置 ${selections.length} 个对象的 ${property}`, edits };
}

/** 把命令应用到普通对象上(演示用;真实场景走命令栈的 apply/undo)。 */
export function applyBatch(selections: readonly Selection[], cmd: BatchEditCommand): void {
  for (const e of cmd.edits) selections[e.target].obj[e.property] = e.after;
}

两个取舍值得点出。第一,批量命令只装「真正变化」的项。如果三个对象里已经有两个是目标值,buildBatchEdit 只生成一条 edit。这样撤销栈干净、序列化出来的命令也小——批量命令不该包含无意义的改动。第二,每条 edit 都带 before 和 after。这正是 9 章命令栈做撤销所需的全部信息:撤销就是把 before 写回去。buildBatchEdit 不执行任何改动,它只描述改动。

真实输出:多选 Health

下面 src/apps/editor-multiselect-demo.ts 选中三个 Health,其中 current 不一致、max 一致、invulnerable 不一致,看面板怎么标;再生成一条批量命令并应用。

// file: src/apps/editor-multiselect-demo.ts
// tier: L1
// verify: node src/apps/editor-multiselect-demo.ts
//
// 多选公共属性、混合值判定与批量命令生成——10.3 的可运行产物。

import { TypeRegistry } from '../core/type-registry.ts';
import { GENERATED_TYPES } from '../generated/type-registry.gen.ts';
import { computeMultiPanel, buildBatchEdit, applyBatch, type Selection } from '../editor/property/multi-select.ts';

const reg = new TypeRegistry();
reg.registerAll(GENERATED_TYPES);
const health = reg.get('Health')!;

const selections: Selection[] = [
  { desc: health, obj: { current: 42, max: 100, invulnerable: false } },
  { desc: health, obj: { current: 42, max: 100, invulnerable: false } },
  { desc: health, obj: { current: 10, max: 100, invulnerable: true } },
];

const panel = computeMultiPanel(selections);
console.log(`multi-panel: ${panel.typeName}, total=${panel.total}`);
for (const f of panel.fields) {
  const v = f.value.kind === 'single' ? JSON.stringify(f.value.value) : `<mixed x${f.value.count}>`;
  console.log(`  ${f.name.padEnd(13)} = ${v}`);
}

const cmd = buildBatchEdit(selections, 'current', 100);
console.log(`command: "${cmd.label}" edits=${cmd.edits.length}`);
for (const e of cmd.edits) console.log(`  #${e.target} ${e.property}: ${JSON.stringify(e.before)} -> ${JSON.stringify(e.after)}`);
applyBatch(selections, cmd);
console.log('after apply:', selections.map((s) => s.obj.current).join(', '));

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

multi-panel: Health, total=3
  current       = <mixed x3>
  max           = 100
  invulnerable  = <mixed x3>
command: "设置 3 个对象的 current" edits=3
  #0 current: 42 -> 100
  #1 current: 42 -> 100
  #2 current: 10 -> 100
after apply: 100, 100, 100

三种情况一眼可辨:current 是 42 / 42 / 10,标成 <mixed x3>;max 三个都是 100,直接显示 100;invulnerable 两个 false 一个 true,也是混合。批量命令的 label 用了选中总数 3,但 edits 也是 3——因为这次三个对象的 current 都不是 100,没有可跳过的项。把其中一个改成 100 再跑,edits 会掉到 2,这就是「跳过无变化项」在真实输出里的样子。

批量应用到场景:L3/L4,命令行不可复现

面板的渲染与交互是另一层。下面 src/editor/property/multi-view.ts 层级 L3——依赖 document,本节需在浏览器中手动验证,命令行不可复现(仅通过 tsc 类型检查)。多选真正被鼠标框选触发属于 L4,更无法在命令行里复现。

// file: src/editor/property/multi-view.ts
// tier: L3
//
// 多选面板的 DOM 渲染:混合值显示占位、批量改动的交互入口。
// 依赖 document,命令行不可复现,需在浏览器中手动验证。

import type { MultiPanel, MultiField, Selection } from './multi-select.ts';
import { applyBatch, buildBatchEdit } from './multi-select.ts';

/** 混合值的占位符——多选时该项值不一致。 */
export const MIXED_PLACEHOLDER = '—';

function renderField(f: MultiField): HTMLElement {
  const row = document.createElement('div');
  row.className = 'field-row';
  const label = document.createElement('label');
  label.textContent = f.name;
  const value = document.createElement('span');
  if (f.value.kind === 'single') {
    value.textContent = String(f.value.value);
  } else {
    value.textContent = MIXED_PLACEHOLDER;
    value.classList.add('is-mixed');
  }
  row.append(label, value);
  return row;
}

/** 把多选面板渲染进宿主节点:标题是「类型名 × 选中数」。 */
export function renderMultiPanel(panel: MultiPanel, host: HTMLElement): void {
  host.replaceChildren();
  const title = document.createElement('header');
  title.className = 'multi-header';
  title.textContent = `${panel.typeName} × ${panel.total}`;
  host.append(title);
  for (const f of panel.fields) host.append(renderField(f));
}

/**
 * 批量改动的交互入口:生成批量命令并交给命令栈执行。
 * 真实场景里 applyBatch 会被 9 章命令栈的 apply/undo 取代,
 * 此处为演示直接落到对象上。
 */
export function commitBatch(
  selections: readonly Selection[],
  property: string,
  next: unknown,
): boolean {
  const cmd = buildBatchEdit(selections, property, next);
  if (cmd.edits.length === 0) return false;
  applyBatch(selections, cmd);
  return true;
}

renderField 里那个分支就是混合值的落点:single 显示值,mixed 显示占位符并加一个 is-mixed 类供 CSS 置灰。commitBatch 是交互入口——它调用 buildBatchEdit 生成命令,而不是自己遍历对象去改。这一点很关键:把批量改动包成命令,才能接进 9 章的命令栈做撤销;如果在这里直接 for 循环改对象,多选改动就变成不可撤销的了。注释里的 applyBatch 是演示替身,真实代码换成命令栈的 apply。

测试

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

// snippet: src/editor/property/multi-select.test.ts 节选
expect(commonFieldNames([HEALTH, ENEMY])).toEqual(['current']); // 混合类型只留交集

expect(aggregate([[1, 2, 3], [1, 2, 3]])).toEqual({ kind: 'single', value: [1, 2, 3] }); // vec3 逐元素
expect(aggregate([[1, 2, 3], [1, 2, 4]])).toEqual({ kind: 'mixed', count: 2 });

// 已是目标值的项被跳过
const cmd = buildBatchEdit([{ desc: HEALTH, obj: { current: 50 } },
                            { desc: HEALTH, obj: { current: 50 } }], 'current', 50);
expect(cmd.edits).toHaveLength(0);

本机 npx vitest run src/editor/property/multi-select.test.ts 是 7 个用例全过(tsc --noEmit 零错误)。用例覆盖三类:公共属性求交集、vec3 逐元素判等(避免误判混合)、批量命令跳过无变化项。

几个坑:

  • 用 === 比较 vec3。 数组永远不是同一个引用,=== 恒为假,任何两个对象的 position 都会被判成混合。必须逐元素比。
  • 把「公共属性」当成「第一个对象的属性」。 混选不同类型时,第一个类型独有的字段不该出现在面板上——用户改它,另一个类型的对象会收到一个不存在的属性。求交集才对。
  • 批量命令里塞进无变化的项。 撤销栈会因此多出冗余步骤,而且撤销时「恢复原值」实际没变,行为诡异。生成命令时跳过 before === after 的项。
  • 在面板里直接改对象、不生成命令。 多选改动一旦绕过命令栈,就再也撤销不了。面板只生成命令,执行交给 9 章的命令栈。

小结

  • 多选面板要回答两个问题:显示哪些字段(公共属性 = 各类型属性名的交集)、值不一致怎么标(聚合为 single / mixed)。
  • 判等必须结构比较:vec3 逐元素比,否则永远误判混合。
  • 批量改动产出的是可撤销的命令(BatchEditCommand),每条带 before / after,并跳过无变化项;面板只生成命令,执行交给 9 章命令栈。
  • 面板渲染与鼠标框选是 L3/L4,需在浏览器中手动验证,命令行不可复现。

编辑器这四章到此收口:8 章文档模型、9 章命令栈、10 章属性面板。下一章换到编辑器的另一半——视口:gizmo 拖拽、拾取射线,以及资产浏览与关卡编辑。

阅读导航:上一节:10.2 编辑器控件与类型映射 · 下一节:11.1 gizmo 与拾取射线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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