《游戏引擎实现原理》9.1 命令模式与可撤销操作

编辑器最让人信任的能力是 Ctrl+Z。本节把一次编辑封装成带 do/undo 的 Command 对象,从零实现命令栈的撤销、重做与合并判定,并用 7 条 vitest 用例把 do/undo/redo、redo 链断裂、同目标合并与栈上限逐条钉死。

9.1 命令模式与可撤销操作

第 8 章把编辑器的数据模型立了起来:一份场景文档、一套脏标记、一条通知通路。可文档本身不会「记住」你做过什么——它只有当前状态。用户真正依赖编辑器的地方,恰恰是能反悔:改错了按 Ctrl+Z,改多了按 Ctrl+Y。这一章就来补上这份「记忆」,而它的载体是命令栈。

命令栈不是把每一步操作都存成快照,而是把「一次编辑」封成一个可逆的对象。这一节先定死这个对象的契约(Command),再实现承载它的栈(CommandStack),最后用单测把最容易写错的语义钉死。9.2 在这层之上加批量事务、连续编辑合并与内存上限;9.3 把命令真正接到第 8 章的文档模型上。

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

站内已有内容从引擎子系统与数据组织的角度讲过编辑器与存档,本卷只做「从零实现 + 每节真跑」:

主题站内既有内容本章这一节做什么
编辑器与扩展Defold 子系统专题 (level-editor / editor-extensions 等)不重推引擎用法,从零实现命令栈与撤销重做
存档与序列化Defold 存档与序列化 、游戏存档系统设计那些讲「落盘」,本节讲「编辑历史」的撤销 / 重做
ECS 与数据模型Defold ECS 架构 、游戏引擎架构与 ECS命令作用的对象是 8 章的文档模型,本节只做命令层
类型系统TypeScript 专题用 TS 接口表达命令契约,并真跑单测

一句话:原理与引擎视角去站内既有文章,本节只交付一份能跑的 src/editor/command/ 代码。

把一次编辑封成对象

最朴素的做法是整份快照:每次编辑前把整个文档深拷贝一份压栈,撤销就是弹回上一份。它在小场景里能用,但一按 Ctrl+Z 就是「整棵树换掉」——内存是 O(文档大小 × 步数),而且撤销后所有对象引用都变了,选中态、缓存全得重建。编辑器要的是「只反悔那一步」。

命令模式换了个角度:不记录状态,记录意图。一次「把 Cube 的 x 从 1 改成 2」被封装成一个对象,它知道怎么正向做(do),也知道怎么反向做(undo)。只要 undo 严格是 do 的逆运算,栈就能沿着历史逐步回退。

契约里有两个容易踩的坑。第一,构造时不能有副作用:命令对象在构造阶段只应「捕获旧值」,真正的改动一律留到 do 里发生,否则压栈时会改两遍。第二,do 与 undo 必须严格互逆且幂等可重入——因为栈会反复调用它们(undo 后再 redo,就是再 do 一次)。

下面 src/editor/command/command.ts 层级 L1,完整可编译。它定义了 Command 接口与 CommandStack:栈负责 do / undo / redo,并在压入时按 mergeWith 判定是否与栈顶合并。

// file: src/editor/command/command.ts
// tier: L1
// verify: npx vitest run src/editor/command/command.test.ts
//
// helio 编辑器:命令栈的核心。命令把「一次可撤销的编辑」封成对象,
// 栈负责 do / undo / redo,并在压入时用 mergeWith 判定是否与栈顶合并。
// 编辑器 UI(10 / 11 章)只调 push / undo / redo,不直接碰文档。

/** 一条可撤销的编辑。do 与 undo 必须严格互逆。 */
export interface Command {
  /** 显示在「撤销 / 重做」菜单上的名字。 */
  readonly label: string;
  /** 正向应用。构造时不应有副作用,副作用只在 do 里发生。 */
  do(): void;
  /** 逆向回滚到 do 之前的状态。 */
  undo(): void;
  /** 可选:能否与「刚压入的上一条」合并;返回合并后的命令,或 null 表示不合并。 */
  mergeWith?(prev: Command): Command | null;
  /** 可选:估算本条命令占用的字节数,供栈的内存预算使用。 */
  byteSize?(): number;
}

function sizeOf(cmd: Command): number {
  return cmd.byteSize ? cmd.byteSize() : 0;
}

/**
 * 撤销栈。push 时先 do,再尝试与栈顶合并,最后按条数上限与字节预算裁剪最旧的命令。
 * 任何新编辑都会清空 redo 栈——这是「撤销后又编辑,重做链断裂」的标准语义。
 */
export class CommandStack {
  #undo: Command[] = [];
  #redo: Command[] = [];
  #limit = 256;
  #byteBudget = 8 * 1024 * 1024;
  #bytes = 0;

  get canUndo(): boolean { return this.#undo.length > 0; }
  get canRedo(): boolean { return this.#redo.length > 0; }
  get depth(): number { return this.#undo.length; }
  get bytes(): number { return this.#bytes; }
  get limit(): number { return this.#limit; }

  set limit(n: number) {
    this.#limit = Math.max(1, Math.floor(n));
    this.#trim();
  }

  set byteBudget(n: number) {
    this.#byteBudget = Math.max(0, n);
    this.#trim();
  }

  /** 下一次 undo 会撤销哪条命令。 */
  undoLabel(): string | null { return this.#undo.at(-1)?.label ?? null; }
  /** 下一次 redo 会重做哪条命令。 */
  redoLabel(): string | null { return this.#redo.at(-1)?.label ?? null; }

  push(cmd: Command): void {
    cmd.do();
    this.#redo.length = 0;
    const top = this.#undo.at(-1);
    const merged = top && cmd.mergeWith ? cmd.mergeWith(top) : null;
    if (merged) {
      this.#bytes -= sizeOf(top!);
      this.#undo[this.#undo.length - 1] = merged;
      this.#bytes += sizeOf(merged);
    } else {
      this.#undo.push(cmd);
      this.#bytes += sizeOf(cmd);
    }
    this.#trim();
  }

  undo(): boolean {
    const cmd = this.#undo.pop();
    if (!cmd) return false;
    cmd.undo();
    this.#redo.push(cmd);
    return true;
  }

  redo(): boolean {
    const cmd = this.#redo.pop();
    if (!cmd) return false;
    cmd.do();
    this.#undo.push(cmd);
    return true;
  }

  clear(): void {
    this.#undo.length = 0;
    this.#redo.length = 0;
    this.#bytes = 0;
  }

  /** 从最旧的(数组头部)开始丢弃,先满足条数上限,再满足字节预算。 */
  #trim(): void {
    while (this.#undo.length > this.#limit) {
      this.#bytes -= sizeOf(this.#undo.shift()!);
    }
    while (this.#bytes > this.#byteBudget && this.#undo.length > 1) {
      this.#bytes -= sizeOf(this.#undo.shift()!);
    }
  }
}

栈用两个数组分别当 undo 与 redo 链:push 后命令进 #undo;undo 时从 #undo 弹出、undo() 后推进 #redo;redo 反过来。这样「撤销 / 重做」就是两个栈顶之间的搬运,逻辑一目了然。

撤销栈:do / undo / redo 与合并判定

三处语义值得单独点出。

redo 链的断裂。push 的第一件事是 this.#redo.length = 0——只要产生了一条新编辑,之前被撤销的操作就再也回不去了。这是所有编辑器的共识:历史是一条线,不是一棵树(若要做成树状历史,那是另一套设计,本节不做)。

合并判定。属性面板里拖一次滑条会刷出几十条「把 x 从 3.01 改成 3.02」的命令。若每条都进栈,用户按一次 Ctrl+Z 只退 0.01,体验很差。于是 Command 提供可选的 mergeWith(prev):栈在 push 时若发现新命令能与栈顶合并,就用合并结果替换栈顶,而不是新增一条。合并的语义由命令自己定义——通常是「同一条命令、同一目标」,旧值取前一条的、新值取本条的。9.2 会在此基础上加「时间窗」判据,避免两次相隔很久的同名编辑被错误合并。

上限与内存。历史不能无限长。#trim 在每次压栈后从最旧的命令开始丢弃,先满足条数上限(#limit,默认 256),再满足字节预算(#byteBudget,默认 8 MiB,由命令的 byteSize() 累加)。这里有个刻意的选择:字节预算的循环带 this.#undo.length > 1 条件,永远至少保留一条——否则预算设得过小会把栈清空,用户连一步都撤不了。裁剪只动 #undo,#redo 不受影响。

可运行产物:do / undo / redo 与合并的真跑

上面全是纯逻辑,可以在 Node 里真跑。下面 src/editor/command/command.test.ts 层级 L1,用一个最小命令 SetValue(把 Box.v 从 from 设为 to,可合并同一目标上的连续赋值)覆盖三条主线。命令 npx vitest run src/editor/command/command.test.ts。

// file: src/editor/command/command.test.ts
// tier: L1
// verify: npx vitest run src/editor/command/command.test.ts
import { describe, expect, it } from 'vitest';
import { CommandStack, type Command } from './command.ts';

interface Box { v: number }

/** 最小命令:把 Box.v 从 from 设为 to,可合并同一目标上的连续赋值。 */
class SetValue implements Command {
  readonly label: string;
  #target: Box;
  #from: number;
  #to: number;

  constructor(target: Box, from: number, to: number, label = 'set value') {
    this.#target = target;
    this.#from = from;
    this.#to = to;
    this.label = label;
  }

  do(): void { this.#target.v = this.#to; }
  undo(): void { this.#target.v = this.#from; }
  byteSize(): number { return 32; }

  mergeWith(prev: Command): Command | null {
    if (!(prev instanceof SetValue) || prev.#target !== this.#target) return null;
    return new SetValue(this.#target, prev.#from, this.#to, prev.label);
  }
}

describe('命令栈的 do / undo / redo', () => {
  it('push 立即生效,undo 回滚,redo 重放', () => {
    const box: Box = { v: 0 };
    const stack = new CommandStack();
    stack.push(new SetValue(box, box.v, 5));
    expect(box.v).toBe(5);
    expect(stack.canUndo).toBe(true);
    stack.undo();
    expect(box.v).toBe(0);
    expect(stack.canRedo).toBe(true);
    stack.redo();
    expect(box.v).toBe(5);
  });

  it('空栈上 undo / redo 返回 false 且不抛错', () => {
    const stack = new CommandStack();
    expect(stack.undo()).toBe(false);
    expect(stack.redo()).toBe(false);
  });

  it('新编辑清空 redo 栈', () => {
    const box: Box = { v: 0 };
    const stack = new CommandStack();
    stack.push(new SetValue(box, 0, 1));
    stack.push(new SetValue(box, 1, 2, 'second'));
    stack.undo();
    expect(stack.canRedo).toBe(true);
    stack.push(new SetValue(box, box.v, 9, 'fresh'));
    expect(stack.canRedo).toBe(false);
    expect(stack.redoLabel()).toBeNull();
  });
});

describe('合并判定', () => {
  it('同一目标的连续赋值合并成一条栈记录', () => {
    const box: Box = { v: 0 };
    const stack = new CommandStack();
    stack.push(new SetValue(box, 0, 1));
    stack.push(new SetValue(box, 1, 2));
    stack.push(new SetValue(box, 2, 3));
    expect(stack.depth).toBe(1);
    stack.undo();
    expect(box.v).toBe(0); // 一步撤销回到最初
    expect(stack.canUndo).toBe(false);
  });

  it('不同目标不合并', () => {
    const a: Box = { v: 0 };
    const b: Box = { v: 0 };
    const stack = new CommandStack();
    stack.push(new SetValue(a, 0, 1));
    stack.push(new SetValue(b, 0, 1));
    expect(stack.depth).toBe(2);
  });
});

describe('栈上限与内存预算', () => {
  it('超出条数上限时丢弃最旧的命令', () => {
    const stack = new CommandStack();
    stack.limit = 3;
    const boxes = [0, 1, 2, 3, 4].map(() => ({ v: 0 }));
    boxes.forEach((box) => stack.push(new SetValue(box, 0, 1, 'no-merge-' + boxes.indexOf(box))));
    expect(stack.depth).toBe(3);
    // 最旧两条被丢弃,撤销只能回滚最后三条
    stack.undo(); stack.undo(); stack.undo();
    expect(stack.canUndo).toBe(false);
    expect(boxes[0]!.v).toBe(1); // 第 0 条已不可撤销
    expect(boxes[2]!.v).toBe(0); // 第 2 条仍在栈内,已回滚
  });

  it('字节预算触发裁剪,且至少保留一条', () => {
    const stack = new CommandStack();
    stack.byteBudget = 70; // 每条 32 字节,最多放 2 条
    const boxes = [0, 1, 2].map(() => ({ v: 0 }));
    boxes.forEach((box) => stack.push(new SetValue(box, 0, 1, 'k' + boxes.indexOf(box))));
    expect(stack.depth).toBe(2);
    expect(stack.bytes).toBeLessThanOrEqual(70);
    stack.byteBudget = 1; // 即使预算极小也要留一条,避免栈被清空后无法撤销
    expect(stack.depth).toBe(1);
  });
});

本机实跑:Test Files 1 passed、Tests 7 passed;tsc --noEmit 零错误。三条语义各由一组用例钉死:redo 链在 push 后断裂、同目标连续赋值合并成一条(一次撤销即回到最初)、超限时从最旧端丢弃且字节预算下至少留一条。

小结

  • 命令模式记录意图而非状态:一次编辑封成带 do / undo 的对象,避免整份快照的 O(文档 × 步数) 开销。
  • Command 契约两条铁律:构造无副作用(旧值只在构造时捕获)、do / undo 严格互逆(栈会反复调用)。
  • CommandStack 用双数组分别当 undo / redo 链;任何新编辑都清空 redo,历史是一条线。
  • 可选的 mergeWith 让「同目标的连续赋值」合并成一条;#trim 按条数上限与字节预算从最旧端裁剪,且至少保留一条。

下一节把命令栈做厚:一次批量操作要能整体撤销(事务),连续微编辑要按时间窗合并,栈还要有明确的内存上限。

阅读导航:上一节:回到目录 · 下一节:9.2 事务、合并与撤销栈 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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