《游戏引擎实现原理》8.1 编辑器与运行时的关系

编辑器与运行时到底共用什么、隔离什么?本节用一份共享的场景数据模型,把编辑态与运行态拆成两份实例:进入 Play 时深拷贝文档,运行时只改副本,Stop 后编辑态原样恢复。最后用一组 vitest 用例把快照、暂停与幂等停止在本机真跑一遍,并说明为何场景文档用扁平节点表而不是 ECS。

8.1 编辑器与运行时的关系

前七章把资源、动画、物理、音频、输入、脚本依次接进了内核。从这一章起进入编辑器——本卷最后四章要回答的问题是:一个能改关卡、能按 Play 试玩、能存盘的编辑器,它的数据到底长什么样。

编辑器最容易出错的不是界面,而是数据关系。很多人第一版会把编辑器和运行时写成两套数据:编辑器一套树,运行时一套 ECS,中间靠「导出」转换。结果是每加一个字段就要改两处,Play 时看到的行为和编辑时对不上,调试时还得先想「我看的是哪一套」。这一节先把这个关系钉死:编辑器与运行时共用同一份数据模型,但各持一份实例。

这句话拆开有两个断言。第一,「同一份数据模型」意味着字段、类型、含义只有一处定义——编辑器面板里能改的字段,运行时脚本里读到的就是同一个字段,不存在中间的「导出」转换层。第二,「两份实例」意味着这份模型在内存里存在两个副本,边界由一次深拷贝划开。下面分别把这两点落到代码上。

本章四节是一条递进的线:8.1 定下「共用模型、两份实例」的关系;8.2 把这一份模型具体化成场景文档,并解决「改了一个节点,哪些东西要重算」的脏标记问题;8.3 把文档序列化落盘成《微光岛》的关卡文件,并处理稳定 ID 与版本迁移。本节先处理最上层的运行时关系,因为它是另外两节的边界前提——不知道编辑器和运行时各自持有什么,就无从谈起「改哪里要标脏」。

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

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

主题站内既有内容本章这一节做什么
关卡编辑器Defold 关卡编辑器:场景编辑与 tilemap不重推编辑器操作,把编辑器与运行时的数据关系写成可跑的 TS
编辑器扩展Defold 编辑器扩展与自定义工具从零实现 Play/Stop 的文档快照与运行态隔离
ECS 架构Defold ECS 架构场景文档为何用扁平节点表而非 ECS,说明取舍
帧与暂停2.3 时间缩放与暂停说明编辑器 Play 的暂停与运行时的暂停是两件事

同一份数据模型,两份实例

先定义「同一份数据模型」到底指什么。helio 里编辑器与运行时都操作同一种 SceneNode:一个 id、一个 name、一个 parent 句柄、一份局部平移 position。区别不在形状,而在实例:

  • 编辑器持有一份编辑态文档(#edit),它是「作者写下的那一份」,是唯一的真相来源。
  • 运行时持有另一份运行态文档(#runtime),进入 Play 时由编辑态深拷贝而来。
  • 两者之间只允许通过拷贝传递,绝不允许共享引用——否则运行时改一个坐标,编辑态就被污染了。

SceneNode 上还有一个 runtimeAge 字段:它只会在运行态副本上出现,编辑态永远没有。这就是「同一份模型、不同实例」的直观证据。

深拷贝必须逐字段深,不能只做一层 Map 浅拷贝。因为 position 是数组、children 也是数组,浅拷贝后两份文档会共享同一个数组对象,运行时一改坐标编辑态立刻跟着变。cloneDoc 里对这两个字段各做了一次展开拷贝,正是为了切断这条共享路径。这个坑很隐蔽:只在「改坐标」时才暴露,而选中、重命名这类操作不会触发,容易漏测。

为什么场景文档不是 ECS

一个自然的疑问:内核里不是已经有 ECS 了吗,编辑器为什么不直接改 ECS 世界?因为两者的访问模式恰好相反。ECS 为批量遍历同类组件而优化(一帧里跑一万个 Transform),代价是「按 id 取某个实体的某个字段」很别扭;而编辑器 90% 的操作是「按 id 取一个节点、改一个字段、刷新它和它的父节点」,这是 ECS 最不擅长的路径。所以 helio 的编辑器用扁平节点表:Map<id, SceneNode>,每个节点显式记 parent 与 children,随机访问是 O(1)。运行时在 Play 时可以把这份文档烘成 ECS,但编辑器本身始终面对节点表。

下面 src/editor/play-mode.ts 层级 L1,完整可编译。它定义了共用节点形状、深拷贝函数,以及一个管理 Play / Pause / Resume / Stop 的控制器。

// file: src/editor/play-mode.ts
// tier: L1
// verify: npx vitest run src/editor/play-mode.test.ts
//
// helio 编辑器:编辑态与运行态的关系,以及预览(Play / Pause / Resume / Stop)。
// 编辑器与运行时共用同一种场景数据形状(同一份数据模型),但各持一份实例:
// 进入 Play 时对编辑态文档做深拷贝,运行时只改自己的副本,Stop 后编辑态原样恢复。
// 只属于编辑器的状态(选择、展开、视口相机)永不进入运行态,也不写盘。

export type PlayState = 'editing' | 'playing' | 'paused';

/** 场景节点:编辑器与运行时共用同一种形状。 */
export interface SceneNode {
  id: number;
  name: string;
  parent: number | null;
  children: number[];
  /** 局部平移。运行时物理 / 脚本会就地改写它。 */
  position: [number, number, number];
  /** 运行期累计的「已存活秒数」,只存在于运行态副本。 */
  runtimeAge?: number;
}

export type SceneDoc = Map<number, SceneNode>;

/** 深拷贝一份文档。编辑器与运行时之间只允许通过拷贝传递。 */
export function cloneDoc(src: SceneDoc): SceneDoc {
  const out: SceneDoc = new Map();
  for (const [id, n] of src) {
    out.set(id, { ...n, position: [...n.position], children: [...n.children] });
  }
  return out;
}

/** 只属于编辑器的状态:不进运行时,也不写盘。 */
export interface EditorState {
  selection: number[];
  expanded: Set<number>;
  camera: [number, number, number];
}

/** 一帧运行时的推进函数:只应触碰传入的文档,不得捕获编辑态。 */
export type AdvanceFn = (doc: SceneDoc, dt: number) => void;

export class PlayModeController {
  readonly #edit: SceneDoc;
  readonly #editor: EditorState;
  #runtime: SceneDoc | null = null;
  #state: PlayState = 'editing';
  #elapsed = 0;

  constructor(doc: SceneDoc, editor: EditorState) {
    this.#edit = doc;
    this.#editor = editor;
  }

  get state(): PlayState { return this.#state; }
  /** 运行态已模拟的秒数;编辑态恒为 0。 */
  get elapsed(): number { return this.#elapsed; }
  /** 编辑态文档:始终是「作者写下的那一份」。 */
  edit(): SceneDoc { return this.#edit; }
  /** 编辑器的界面状态(选择 / 展开 / 视口)。 */
  editorState(): EditorState { return this.#editor; }
  /** 运行态文档:仅 playing / paused 时存在,编辑态为 null。 */
  runtime(): SceneDoc | null { return this.#runtime; }

  /** 进入 Play:拷贝编辑态文档,运行时从零开始模拟。 */
  play(): void {
    if (this.#state !== 'editing') return;
    this.#runtime = cloneDoc(this.#edit);
    this.#elapsed = 0;
    this.#state = 'playing';
  }

  /** Pause:冻结模拟,但保留运行态,便于检查现场。 */
  pause(): void {
    if (this.#state === 'playing') this.#state = 'paused';
  }

  resume(): void {
    if (this.#state === 'paused') this.#state = 'playing';
  }

  /** Stop:丢弃运行态副本,编辑态文档一行未改。 */
  stop(): void {
    if (this.#state === 'editing') return;
    this.#runtime = null;
    this.#elapsed = 0;
    this.#state = 'editing';
  }

  /** 模拟推进:只有 playing 会真正改运行态,返回本帧是否推进过。 */
  step(dt: number, advance: AdvanceFn): boolean {
    if (this.#state !== 'playing' || !this.#runtime) return false;
    advance(this.#runtime, dt);
    this.#elapsed += dt;
    return true;
  }
}

编辑器态 vs 运行态

EditorState 是这段关系里最容易忽视的一半。它装的是只属于编辑器的东西:当前选中哪些节点(selection)、场景树里哪些节点展开了(expanded)、视口相机在哪(camera)。这些东西:

  • 不进运行时:游戏跑起来时不需要知道「作者选中了谁」;
  • 不写盘:选择状态是会话级的,存进关卡文件只会污染 diff。

所以 PlayModeController 把 #editor 单独持有一份引用,play / stop 全程不碰它。测试里专门有一条断言:Play、改运行态、Stop 之后,selection 依然是 [1]。

把编辑器状态从运行时里分出来的好处,在多人协作与自动测试上尤其明显。一个自动化脚本可以只驱动运行态文档跑几千帧做回归,完全不必构造「哪个节点被选中」这类界面状态;反过来,一个界面测试可以只断言选择集,不必启动模拟。两者的输入输出被一次拷贝彻底解耦。

预览:Play / Pause / Stop

预览的三态语义要分清,它们分别对应三类需求:

  • Play:cloneDoc 把编辑态整体拷进运行态,elapsed 归零。从此运行时改的是副本。
  • Pause:冻结模拟但保留运行态。为什么不停在 Play 上直接停?因为暂停时要能查看现场——选中一个飞行中的子弹看它的坐标,这正是「保留副本」的价值。Pause 只是让 step 返回 false。
  • Stop:丢弃运行态副本,#runtime 置 null,elapsed 归零。编辑态文档一行未改,回到编辑状态。

step 是唯一会改运行态的方法,且只在 playing 下生效。把「改文档」这件事收敛到一个入口,是后面第 9 章命令栈能干净接管撤销的前提——所有对文档的写操作都会经过一个可插桩的点。

还有一个容易被忽略的细节:三个状态切换方法都做了幂等保护。play 在非 editing 时直接返回,stop 在 editing 时直接返回。编辑器工具栏上的按钮可以被连点,如果 play 不做保护,第二次调用会把「已经跑了几帧的运行态」重新拷贝覆盖掉,玩家刚打出的进度瞬间归零。幂等不是锦上添花,而是防住这类 UI 抖动的最小成本手段。

可运行产物:快照与暂停的真跑

上面全是纯逻辑,能在 Node 里真跑。下面 src/editor/play-mode.test.ts 层级 L1,命令 npx vitest run src/editor/play-mode.test.ts。

// file: src/editor/play-mode.test.ts
// tier: L1
// verify: npx vitest run src/editor/play-mode.test.ts
import { describe, expect, it } from 'vitest';
import { PlayModeController, type EditorState, type SceneDoc } from './play-mode.ts';

function makeDoc(): SceneDoc {
  const doc: SceneDoc = new Map();
  doc.set(0, { id: 0, name: 'Level', parent: null, children: [1], position: [0, 0, 0] });
  doc.set(1, { id: 1, name: 'Player', parent: 0, children: [], position: [0, 0, 0] });
  return doc;
}

function makeEditor(): EditorState {
  return { selection: [1], expanded: new Set([0]), camera: [0, 0, 10] };
}

describe('编辑态与运行态', () => {
  it('Play 后运行时只改副本,编辑态文档不变', () => {
    const ctl = new PlayModeController(makeDoc(), makeEditor());
    ctl.play();
    ctl.step(0.5, (d) => { d.get(1)!.position[0] += 5; });
    expect(ctl.edit().get(1)!.position[0]).toBe(0);
    expect(ctl.runtime()!.get(1)!.position[0]).toBe(5);
  });

  it('Stop 丢弃运行态,编辑态文档与编辑器状态都原样', () => {
    const doc = makeDoc();
    const ed = makeEditor();
    const ctl = new PlayModeController(doc, ed);
    ctl.play();
    ctl.step(1, (d) => { d.get(1)!.position[2] = 9; });
    ctl.stop();
    expect(ctl.state).toBe('editing');
    expect(ctl.runtime()).toBeNull();
    expect(ctl.edit().get(1)!.position[2]).toBe(0);
    expect(ed.selection).toEqual([1]);
    expect(ctl.elapsed).toBe(0);
  });
});

describe('预览的暂停与恢复', () => {
  it('Pause 冻结模拟,Resume 继续,elapsed 不倒退', () => {
    const ctl = new PlayModeController(makeDoc(), makeEditor());
    ctl.play();
    ctl.step(1, () => {});
    ctl.pause();
    expect(ctl.step(1, () => {})).toBe(false);
    expect(ctl.elapsed).toBe(1);
    ctl.resume();
    ctl.step(1, () => {});
    expect(ctl.elapsed).toBe(2);
  });

  it('编辑态不允许推进', () => {
    const ctl = new PlayModeController(makeDoc(), makeEditor());
    expect(ctl.step(1, () => {})).toBe(false);
    expect(ctl.runtime()).toBeNull();
  });

  it('重复 play / stop 是幂等的', () => {
    const ctl = new PlayModeController(makeDoc(), makeEditor());
    ctl.play();
    ctl.play();
    expect(ctl.state).toBe('playing');
    ctl.stop();
    ctl.stop();
    expect(ctl.state).toBe('editing');
  });
});

本机实跑:Test Files 1 passed、Tests 5 passed;tsc --noEmit 零错误。三条最关键的断言是:运行态改坐标后编辑态仍为 0;Stop 后编辑器 selection 不变;Pause 下 step 返回 false 且 elapsed 不倒退。

这三条恰好覆盖了本节最容易写错的三处。第一条守住「拷贝必须是深拷贝」——若 cloneDoc 漏了 position 的展开,它会立刻失败。第二条守住「编辑器状态与运行时解耦」。第三条守住「暂停只是冻结推进,不是清空状态」。任何一个实现走样,都会在这里被钉住。

小结

  • 编辑器与运行时共用同一种节点形状,但各持一份实例;两者之间只靠深拷贝传递,绝不共享引用。
  • EditorState(选择 / 展开 / 视口)只属于编辑器,不进运行时、不写盘。
  • 预览三态:Play 拷副本、Pause 冻结但保留副本、Stop 丢弃副本;step 是唯一会改运行态的入口。
  • 把「改文档」收敛到单一入口,是下一节脏标记与第 9 章命令栈能接管的前提。

下一节把「一份文档」具体化:场景文档、节点树、脏标记如何传播,以及撤销的接缝留在哪里。

阅读导航:上一节:回到目录 · 下一节:8.2 文档模型与脏标记 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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