小游戏引擎可视化编辑器架构:从 Canvas 到撤销重做的完整 TypeScript 实现

深入剖析 MiniPlay Studio 小游戏引擎的可视化场景编辑器架构设计。涵盖场景树(Scene Graph)可视化编辑、实体属性面板(Inspector)数据绑定、命令模式(Command Pattern)撤销重做系统、资源浏览器(Asset Browser)虚拟滚动、Canvas2D/WebGL 双模渲染预览、节点对齐吸附(Snap)系统、序列化与反序列化的完整 TypeScript 实现,附带可运行的在线演示架构。

小游戏引擎可视化编辑器架构

一、为什么编辑器是游戏引擎的胜负手

游戏引擎的核心竞争力不仅取决于运行时性能,更取决于开发者体验(DX, Developer Experience)。一个好用的编辑器能将开发效率提升 3-5 倍,而 Unity、Godot 等成熟引擎的编辑器正是其生态壁垒的核心。

小游戏编辑器面临三大特殊挑战:

  1. Web 端运行(必须兼容浏览器沙箱,无法像 Electron 那样随意读写文件系统)
  2. 即开即用(首次加载 ≤ 3 秒,否则用户流失率急剧上升)
  3. 移动端友好(触控操作、小屏幕适配、手势交互)

本章将从数据模型到 UI 交互,带你搭建一个可用于生产环境的小游戏可视化编辑器。


二、编辑器整体架构

编辑器的核心是Model-View-Command三层分离:

层级职责对应模块
ModelECS 场景数据 + 编辑器元数据SceneModel, EditorState
ViewReact/Vue 组件 + Canvas 预览SceneTree, Inspector, CanvasPreview
Command撤销重做 + 命令合并CommandStack, *Command
graph TD
    A[用户操作: 拖拽/输入/点击] --> B[View Layer: React/Vue]
    B --> C{生成 Command?}
    C -->|是| D[CommandStack.push]
    D --> E[Command.execute: 修改 Model]
    E --> F[SceneModel 更新]
    F --> G[事件总线通知 View 刷新]
    G --> H[CanvasPreview 重绘]
    C -->|否| I[View 内部交互: hover/scroll]

关键设计:所有数据修改必须通过 Command,View 层不能直接修改 Model。这是撤销重做的根基。


三、场景树(Scene Tree)设计

3.1 为什么场景树不是 ECS 的表层封装

ECS 的 Entity 是扁平的(只有 ID),但场景树是树形层级的(父子嵌套)。两者之间需要一层映射:

// === scene-tree/SceneTreeNode.ts ===
// 场景树节点 ≈ 组件 + 编辑器元数据
interface SceneTreeNode {
  entity: Entity;           // ECS Entity ID
  name: string;             // 用户自定义名称(如 "Player")
  visible: boolean;         // 编辑器中是否可见(不影响运行时)
  locked: boolean;          // 是否锁定(禁止选中/编辑)
  expanded: boolean;        // 场景树中是否展开
  children: SceneTreeNode[];
  parent: SceneTreeNode | null;
}
// === scene-tree/SceneTreeModel.ts ===
class SceneTreeModel {
  private root: SceneTreeNode;
  private entityToNode: Map<Entity, SceneTreeNode> = new Map();
  private selected: Set<Entity> = new Set();
  private eventBus: EventBus;

  constructor(eventBus: EventBus) {
    this.eventBus = eventBus;
    this.root = this.createRootNode();
  }

  private createRootNode(): SceneTreeNode {
    return {
      entity: 0 as Entity, // root 的 Entity 为 0(特殊值)
      name: "Scene",
      visible: true,
      locked: true,        // root 默认锁定,不可删除
      expanded: true,
      children: [],
      parent: null,
    };
  }

  // O(1) 通过 Entity ID 查找树节点
  getNodeByEntity(entity: Entity): SceneTreeNode | undefined {
    return this.entityToNode.get(entity);
  }

  // 创建新节点(只创建树节点,ECS Entity 由外部传入)
  createNode(entity: Entity, name: string, parent: SceneTreeNode = this.root): SceneTreeNode {
    const node: SceneTreeNode = {
      entity,
      name,
      visible: true,
      locked: false,
      expanded: true,
      children: [],
      parent,
    };
    parent.children.push(node);
    this.entityToNode.set(entity, node);
    this.eventBus.emit("tree.nodeAdded", { entity, parent: parent.entity });
    return node;
  }

  // 删除节点(递归删除子树)
  removeNode(entity: Entity): SceneTreeNode | null {
    const node = this.entityToNode.get(entity);
    if (!node || node === this.root) return null;

    // 递归删除子节点
    const children = [...node.children];
    for (const child of children) {
      this.removeNode(child.entity);
    }

    // 从父节点的 children 数组中移除
    const parent = node.parent!;
    const idx = parent.children.indexOf(node);
    if (idx !== -1) parent.children.splice(idx, 1);

    this.entityToNode.delete(entity);
    this.selected.delete(entity);
    this.eventBus.emit("tree.nodeRemoved", { entity });
    return node;
  }

  // 移动节点(改变父节点)— 需要检测循环引用
  moveNode(entity: Entity, newParent: SceneTreeNode, insertIndex?: number): boolean {
    const node = this.entityToNode.get(entity);
    if (!node || node === this.root) return false;

    // 防止循环引用:newParent 不能是 node 或其子树中的节点
    if (this.isDescendant(newParent, node)) return false;

    const oldParent = node.parent!;
    // 从旧父节点移除
    const oldIdx = oldParent.children.indexOf(node);
    if (oldIdx !== -1) oldParent.children.splice(oldIdx, 1);

    // 插入新父节点
    if (insertIndex !== undefined && insertIndex >= 0) {
      newParent.children.splice(insertIndex, 0, node);
    } else {
      newParent.children.push(node);
    }
    node.parent = newParent;

    this.eventBus.emit("tree.nodeMoved", {
      entity,
      oldParent: oldParent.entity,
      newParent: newParent.entity,
    });
    return true;
  }

  private isDescendant(ancestor: SceneTreeNode, descendant: SceneTreeNode): boolean {
    let current = ancestor.parent;
    while (current) {
      if (current === descendant) return true;
      current = current.parent;
    }
    return false;
  }

  // 选中/取消选中
  select(entity: Entity, multi: boolean = false): void {
    if (!multi) this.selected.clear();
    this.selected.add(entity);
    this.eventBus.emit("tree.selectionChanged", { selected: Array.from(this.selected) });
  }

  getSelected(): Entity[] {
    return Array.from(this.selected);
  }
}

3.2 拖拽排序的实现细节

场景树的拖拽排序涉及 DOM 事件与数据模型的同步:

// === scene-tree/DragDropHandler.ts ===
class DragDropHandler {
  private dragEntity: Entity | null = null;
  private dropTarget: { parent: SceneTreeNode; index: number } | null = null;

  constructor(
    private treeModel: SceneTreeModel,
    private commandStack: CommandStack,
  ) {}

  onDragStart(entity: Entity): void {
    this.dragEntity = entity;
    const node = this.treeModel.getNodeByEntity(entity);
    if (node) node.locked = true; // 拖拽时临时锁定,防止点击触发选择
  }

  onDragOver(targetEntity: Entity, position: "before" | "after" | "inside"): void {
    const target = this.treeModel.getNodeByEntity(targetEntity);
    if (!target || !this.dragEntity) return;

    if (position === "inside") {
      this.dropTarget = { parent: target, index: target.children.length };
    } else {
      const parent = target.parent!;
      const idx = parent.children.indexOf(target);
      this.dropTarget = {
        parent,
        index: position === "before" ? idx : idx + 1,
      };
    }
  }

  onDrop(): void {
    if (!this.dragEntity || !this.dropTarget) return;

    const cmd = new MoveNodeCommand(
      this.treeModel,
      this.dragEntity,
      this.dropTarget.parent.entity,
      this.dropTarget.index,
    );
    this.commandStack.push(cmd);

    // 清理
    const node = this.treeModel.getNodeByEntity(this.dragEntity);
    if (node) node.locked = false;
    this.dragEntity = null;
    this.dropTarget = null;
  }
}

四、命令模式与撤销重做系统

4.1 Command 接口设计

// === command/Command.ts ===
interface Command {
  readonly id: string;           // 唯一标识,用于日志与调试
  readonly name: string;         // 人类可读的操作名称("移动节点")
  readonly mergeable: boolean;   // 是否可与同类型连续命令合并
  readonly timestamp: number;

  execute(): void;
  undo(): void;

  // 合并策略:返回 true 表示已合并,原命令无需入栈
  merge(next: Command): boolean;
}
// === command/CommandStack.ts ===
class CommandStack {
  private stack: Command[] = [];
  private pointer: number = -1; // -1 表示栈为空
  private maxSize: number = 200;

  constructor(private eventBus: EventBus) {}

  push(cmd: Command): void {
    // 如果当前 pointer 不在栈顶,截断后续命令(分支历史)
    if (this.pointer < this.stack.length - 1) {
      this.stack = this.stack.slice(0, this.pointer + 1);
    }

    // 尝试合并
    const top = this.stack[this.pointer];
    if (top && top.mergeable && top.merge(cmd)) {
      this.eventBus.emit("cmd.merged", { top, incoming: cmd });
      return;
    }

    cmd.execute();
    this.stack.push(cmd);
    this.pointer++;

    // 栈溢出时从底部淘汰
    if (this.stack.length > this.maxSize) {
      this.stack.shift();
      this.pointer--;
    }

    this.eventBus.emit("cmd.executed", { cmd, canUndo: true, canRedo: false });
  }

  undo(): void {
    if (this.pointer < 0) return;
    const cmd = this.stack[this.pointer];
    cmd.undo();
    this.pointer--;
    this.eventBus.emit("cmd.undone", {
      cmd,
      canUndo: this.pointer >= 0,
      canRedo: true,
    });
  }

  redo(): void {
    if (this.pointer >= this.stack.length - 1) return;
    this.pointer++;
    const cmd = this.stack[this.pointer];
    cmd.execute(); // redo 就是重新 execute
    this.eventBus.emit("cmd.redone", {
      cmd,
      canUndo: true,
      canRedo: this.pointer < this.stack.length - 1,
    });
  }

  canUndo(): boolean { return this.pointer >= 0; }
  canRedo(): boolean { return this.pointer < this.stack.length - 1; }

  // 批量执行一组命令(一个操作单元,undo 时整体回退)
  pushBatch(name: string, cmds: Command[]): void {
    const batch = new BatchCommand(name, cmds);
    this.push(batch);
  }
}

4.2 常用命令实现

// === command/MoveNodeCommand.ts ===
class MoveNodeCommand implements Command {
  readonly id = `move-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
  readonly name = "移动节点";
  readonly mergeable = false; // 节点移动不合并,每次位置变化都要可撤销
  readonly timestamp = Date.now();

  private oldParent: Entity;
  private oldIndex: number;

  constructor(
    private treeModel: SceneTreeModel,
    private entity: Entity,
    private newParent: Entity,
    private newIndex: number,
  ) {
    const node = treeModel.getNodeByEntity(entity);
    this.oldParent = node!.parent!.entity;
    this.oldIndex = node!.parent!.children.indexOf(node!);
  }

  execute(): void {
    const parent = this.treeModel.getNodeByEntity(this.newParent)!;
    this.treeModel.moveNode(this.entity, parent, this.newIndex);
  }

  undo(): void {
    const parent = this.treeModel.getNodeByEntity(this.oldParent)!;
    this.treeModel.moveNode(this.entity, parent, this.oldIndex);
  }

  merge(): boolean { return false; }
}
// === command/SetPropertyCommand.ts ===
class SetPropertyCommand<T> implements Command {
  readonly id = `prop-${Date.now()}`;
  readonly name: string;
  readonly mergeable = true;
  readonly timestamp = Date.now();

  private oldValue: T;

  constructor(
    name: string,
    private target: Record<string, any>,
    private property: string,
    private newValue: T,
  ) {
    this.name = name;
    this.oldValue = target[property];
  }

  execute(): void {
    this.target[this.property] = this.newValue;
  }

  undo(): void {
    this.target[this.property] = this.oldValue;
  }

  merge(next: Command): boolean {
    if (!(next instanceof SetPropertyCommand)) return false;
    if (next.target !== this.target || next.property !== this.property) return false;

    // 同属性连续修改:只保留最新值,旧值不变
    this.newValue = next.newValue;
    return true;
  }
}
// === command/BatchCommand.ts ===
class BatchCommand implements Command {
  readonly id = `batch-${Date.now()}`;
  readonly mergeable = false;
  readonly timestamp = Date.now();

  constructor(
    readonly name: string,
    private commands: Command[],
  ) {}

  execute(): void {
    for (const cmd of this.commands) cmd.execute();
  }

  undo(): void {
    // undo 顺序与 execute 相反
    for (let i = this.commands.length - 1; i >= 0; i--) {
      this.commands[i].undo();
    }
  }

  merge(): boolean { return false; }
}

五、属性面板(Inspector)双向绑定

5.1 编辑器数据代理层

ECS Component 的数据结构对 UI 不友好(如 Float32Array 表示向量),需要中间代理层做类型转换:

// === inspector/EditorComponentProxy.ts ===
interface PropertyDescriptor {
  name: string;               // 字段名(如 "position")
  type: "number" | "vec2" | "vec3" | "color" | "string" | "boolean" | "enum" | "asset";
  label: string;              // UI 显示名称(如 "位置")
  min?: number; max?: number; // 数值范围
  step?: number;              // 步进值
  options?: string[];         // enum 选项
  category?: string;          // 分组(如 "Transform", "Rendering")
}

class EditorComponentProxy {
  private descriptors: PropertyDescriptor[] = [];

  constructor(
    private componentType: string,
    private entity: Entity,
    private ecs: ECSWorld,
    private commandStack: CommandStack,
  ) {
    this.buildDescriptors();
  }

  private buildDescriptors(): void {
    // 根据 componentType 从元数据注册表读取字段定义
    // 实际实现中可从装饰器或配置文件加载
    const meta = ComponentRegistry.getMetadata(this.componentType);
    this.descriptors = meta.properties.map((p: any) => ({
      name: p.name,
      type: p.type,
      label: p.label || p.name,
      min: p.min,
      max: p.max,
      step: p.step,
      options: p.options,
      category: p.category || "General",
    }));
  }

  getDescriptors(): PropertyDescriptor[] {
    return this.descriptors;
  }

  // 读取当前值(供 UI 渲染)
  getValue(propName: string): any {
    const comp = this.ecs.getComponent(this.entity, this.componentType);
    if (!comp) return undefined;
    const v = (comp as any)[propName];

    // 类型转换
    const desc = this.descriptors.find(d => d.name === propName);
    if (!desc) return v;

    switch (desc.type) {
      case "vec2": return { x: v[0], y: v[1] };
      case "vec3": return { x: v[0], y: v[1], z: v[2] };
      case "color": return this.numToHex(v);
      default: return v;
    }
  }

  // UI 变更时调用,生成 Command 入栈
  setValue(propName: string, value: any): void {
    const comp = this.ecs.getComponent(this.entity, this.componentType);
    if (!comp) return;

    const desc = this.descriptors.find(d => d.name === propName);
    let rawValue = value;

    // 反转换为 ECS 原始格式
    if (desc) {
      switch (desc.type) {
        case "vec2": rawValue = new Float32Array([value.x, value.y]); break;
        case "vec3": rawValue = new Float32Array([value.x, value.y, value.z]); break;
        case "color": rawValue = this.hexToNum(value); break;
      }
    }

    const cmd = new SetPropertyCommand(
      `修改 ${this.componentType}.${propName}`,
      comp as any,
      propName,
      rawValue,
    );
    this.commandStack.push(cmd);
  }

  private numToHex(n: number): string {
    return "#" + (n >>> 0).toString(16).padStart(8, "0");
  }
  private hexToNum(s: string): number {
    return parseInt(s.replace("#", ""), 16) >>> 0;
  }
}

5.2 Inspector UI 分类渲染

// === inspector/InspectorPanel.tsx (伪代码结构) ===
// React/Vue 中的属性面板渲染逻辑
function InspectorPanel({ selectedEntities, ecs, commandStack }: InspectorProps) {
  if (selectedEntities.length === 0) return <EmptyPanel />;
  if (selectedEntities.length > 1) return <MultiSelectPanel count={selectedEntities.length} />;

  const entity = selectedEntities[0];
  const components = ecs.getComponents(entity); // 获取该 Entity 的所有 Component

  return (
    <div className="inspector">
      {/* Entity 名称编辑 */}
      <EntityNameField entity={entity} commandStack={commandStack} />

      {/* 按 Component 分组渲染 */}
      {components.map(comp => (
        <ComponentSection
          key={comp.type}
          proxy={new EditorComponentProxy(comp.type, entity, ecs, commandStack)}
          onRemove={() => ecs.removeComponent(entity, comp.type)}
        />
      ))}

      {/* 添加 Component 按钮 */}
      <AddComponentButton entity={entity} ecs={ecs} />
    </div>
  );
}

六、Canvas 预览与交互系统

6.1 双模渲染预览

// === preview/EditorPreview.ts ===
class EditorPreview {
  private renderer: RenderBackend;
  private canvas: HTMLCanvasElement;
  private editorCamera: EditorCamera;
  private gridRenderer: GridRenderer;
  private gizmoRenderer: GizmoRenderer;

  constructor(
    canvas: HTMLCanvasElement,
    private sceneModel: SceneModel,
    private ecs: ECSWorld,
  ) {
    this.canvas = canvas;
    this.editorCamera = new EditorCamera(canvas);
    this.gridRenderer = new GridRenderer();
    this.gizmoRenderer = new GizmoRenderer();
    this.switchRenderer("canvas2d"); // 默认 Canvas2D
  }

  switchRenderer(mode: "canvas2d" | "webgl"): void {
    const rect = this.canvas.getBoundingClientRect();
    const width = rect.width * window.devicePixelRatio;
    const height = rect.height * window.devicePixelRatio;

    if (this.renderer) this.renderer.dispose();

    if (mode === "canvas2d") {
      this.renderer = new Canvas2DEditorBackend(this.canvas, width, height);
    } else {
      this.renderer = new WebGLEditorBackend(this.canvas, width, height);
    }
  }

  render(): void {
    const ctx = this.renderer;
    ctx.clear(0.15, 0.15, 0.15, 1); // 编辑器背景色 #262626

    // 1. 绘制网格
    this.gridRenderer.render(ctx, this.editorCamera);

    // 2. 绘制实体(只绘制可见的)
    const sprites = this.ecs.query(["Transform", "Sprite"]);
    for (const [entity, transform, sprite] of sprites) {
      const node = this.sceneModel.getNodeByEntity(entity);
      if (node && !node.visible) continue;
      ctx.drawSprite(transform, sprite);
    }

    // 3. 绘制选中框(Gizmo)
    const selected = this.sceneModel.getSelected();
    for (const entity of selected) {
      const transform = this.ecs.getComponent<Transform>(entity, "Transform");
      if (transform) this.gizmoRenderer.render(ctx, transform, this.editorCamera);
    }
  }
}

6.2 编辑器专用 Camera(与运行时 Camera 分离)

// === preview/EditorCamera.ts ===
class EditorCamera {
  position: Vec2 = { x: 0, y: 0 };
  zoom: number = 1;
  private isDragging: boolean = false;
  private lastMouse: Vec2 = { x: 0, y: 0 };

  constructor(private canvas: HTMLCanvasElement) {
    canvas.addEventListener("wheel", this.onWheel.bind(this));
    canvas.addEventListener("mousedown", this.onMouseDown.bind(this));
    canvas.addEventListener("mousemove", this.onMouseMove.bind(this));
    canvas.addEventListener("mouseup", this.onMouseUp.bind(this));
  }

  private onWheel(e: WheelEvent): void {
    e.preventDefault();
    const zoomSpeed = 0.001;
    const oldZoom = this.zoom;
    this.zoom *= 1 - e.deltaY * zoomSpeed;
    this.zoom = Math.max(0.1, Math.min(10, this.zoom));

    // 以鼠标位置为中心缩放
    const rect = this.canvas.getBoundingClientRect();
    const mouseX = e.clientX - rect.left;
    const mouseY = e.clientY - rect.top;
    this.position.x += mouseX * (1 / oldZoom - 1 / this.zoom);
    this.position.y += mouseY * (1 / oldZoom - 1 / this.zoom);
  }

  private onMouseDown(e: MouseEvent): void {
    if (e.button === 1 || (e.button === 0 && e.altKey)) {
      // 中键或 Alt+左键 = 平移
      this.isDragging = true;
      this.lastMouse = { x: e.clientX, y: e.clientY };
    }
  }

  private onMouseMove(e: MouseEvent): void {
    if (!this.isDragging) return;
    const dx = e.clientX - this.lastMouse.x;
    const dy = e.clientY - this.lastMouse.y;
    this.position.x -= dx / this.zoom;
    this.position.y -= dy / this.zoom;
    this.lastMouse = { x: e.clientX, y: e.clientY };
  }

  private onMouseUp(): void {
    this.isDragging = false;
  }

  // 屏幕坐标 → 世界坐标(用于选中检测、对象放置)
  screenToWorld(screenX: number, screenY: number): Vec2 {
    const rect = this.canvas.getBoundingClientRect();
    const x = (screenX - rect.left) / this.zoom - this.position.x;
    const y = (screenY - rect.top) / this.zoom - this.position.y;
    return { x, y };
  }
}

6.3 网格对齐与吸附系统

// === preview/SnapSystem.ts ===
class SnapSystem {
  gridSize: number = 16;     // 网格间距(像素)
  snapThreshold: number = 8; // 吸附阈值(像素)
  showGrid: boolean = true;

  // 将世界坐标吸附到网格
  snapToGrid(worldPos: Vec2): Vec2 {
    if (!this.showGrid) return worldPos;
    return {
      x: Math.round(worldPos.x / this.gridSize) * this.gridSize,
      y: Math.round(worldPos.y / this.gridSize) * this.gridSize,
    };
  }

  // 吸附到其他对象的边界/中心
  snapToObjects(
    worldPos: Vec2,
    movingEntity: Entity,
    otherEntities: Entity[],
    ecs: ECSWorld,
  ): { pos: Vec2; snapped: boolean } {
    let bestPos = worldPos;
    let minDist = this.snapThreshold;
    let snapped = false;

    const movingTransform = ecs.getComponent<Transform>(movingEntity, "Transform");
    if (!movingTransform) return { pos: worldPos, snapped: false };

    const movingBounds = this.getBounds(movingTransform);

    for (const other of otherEntities) {
      if (other === movingEntity) continue;
      const t = ecs.getComponent<Transform>(other, "Transform");
      if (!t) continue;
      const bounds = this.getBounds(t);

      // 尝试水平方向的边界对齐
      for (const ma of ["minX", "midX", "maxX"] as const) {
        for (const oa of ["minX", "midX", "maxX"] as const) {
          const diff = movingBounds[ma] - bounds[oa];
          if (Math.abs(diff) < minDist) {
            bestPos.x = worldPos.x - diff;
            minDist = Math.abs(diff);
            snapped = true;
          }
        }
      }
    }

    return { pos: bestPos, snapped };
  }

  private getBounds(t: Transform): { minX: number; midX: number; maxX: number } {
    const w = t.scaleX * 32; // 假设 sprite 默认宽 32
    const h = t.scaleY * 32;
    return {
      minX: t.x - w / 2,
      midX: t.x,
      maxX: t.x + w / 2,
    };
  }
}

七、资源浏览器(Asset Browser)

7.1 虚拟滚动实现

资源浏览器需要支持 1000+ 资源的流畅渲染:

// === asset-browser/VirtualScrollList.ts ===
class VirtualScrollList<T> {
  private container: HTMLElement;
  private viewportHeight: number = 0;
  private itemHeight: number = 64; // 每项高度(缩略图 + 名称)
  private bufferSize: number = 5;  // 上下缓冲区域项数
  private visibleStart: number = 0;
  private visibleEnd: number = 0;

  constructor(
    container: HTMLElement,
    private data: T[],
    private renderItem: (item: T, index: number) => HTMLElement,
  ) {
    this.container = container;
    this.updateViewport();
    container.addEventListener("scroll", this.onScroll.bind(this));
    window.addEventListener("resize", this.updateViewport.bind(this));
  }

  private updateViewport(): void {
    this.viewportHeight = this.container.clientHeight;
    this.render();
  }

  private onScroll(): void {
    this.render();
  }

  private render(): void {
    const scrollTop = this.container.scrollTop;
    const startIdx = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.bufferSize);
    const endIdx = Math.min(
      this.data.length,
      Math.ceil((scrollTop + this.viewportHeight) / this.itemHeight) + this.bufferSize,
    );

    if (startIdx === this.visibleStart && endIdx === this.visibleEnd) return;

    // 只在 DOM 中保留可见区域内的元素 + 缓冲
    this.container.innerHTML = "";
    const spacerTop = document.createElement("div");
    spacerTop.style.height = `${startIdx * this.itemHeight}px`;
    this.container.appendChild(spacerTop);

    for (let i = startIdx; i < endIdx; i++) {
      const el = this.renderItem(this.data[i], i);
      el.style.height = `${this.itemHeight}px`;
      this.container.appendChild(el);
    }

    const spacerBottom = document.createElement("div");
    spacerBottom.style.height = `${(this.data.length - endIdx) * this.itemHeight}px`;
    this.container.appendChild(spacerBottom);

    this.visibleStart = startIdx;
    this.visibleEnd = endIdx;
  }

  setData(newData: T[]): void {
    this.data = newData;
    this.render();
  }
}

八、序列化与场景文件格式

8.1 编辑器场景格式(JSON)

// === serialization/SceneSerializer.ts ===
interface SerializedScene {
  version: string;           // 格式版本,用于向后兼容
  entities: SerializedEntity[];
  tree: SerializedTreeNode;  // 场景树结构(仅编辑器需要)
  assets: string[];          // 引用的资源 ID 列表
  settings: EditorSettings;
}

interface SerializedEntity {
  id: number;                // Entity 索引(反序列化时重建)
  components: Record<string, any>;
}

interface SerializedTreeNode {
  entityId: number;
  name: string;
  visible: boolean;
  locked: boolean;
  expanded: boolean;
  children: SerializedTreeNode[];
}

class SceneSerializer {
  constructor(
    private ecs: ECSWorld,
    private treeModel: SceneTreeModel,
  ) {}

  serialize(): SerializedScene {
    const entities: SerializedEntity[] = [];
    const allEntities = this.ecs.getAllEntities();

    for (const entity of allEntities) {
      const components = this.ecs.getComponents(entity);
      const compData: Record<string, any> = {};
      for (const comp of components) {
        compData[comp.type] = this.serializeComponent(comp);
      }
      entities.push({ id: entity, components: compData });
    }

    return {
      version: "1.0.0",
      entities,
      tree: this.serializeTreeNode(this.treeModel.getRoot()),
      assets: this.collectAssetIds(),
      settings: this.getEditorSettings(),
    };
  }

  private serializeTreeNode(node: SceneTreeNode): SerializedTreeNode {
    return {
      entityId: node.entity,
      name: node.name,
      visible: node.visible,
      locked: node.locked,
      expanded: node.expanded,
      children: node.children.map(c => this.serializeTreeNode(c)),
    };
  }

  private serializeComponent(comp: Component): any {
    // 将 typed array 转为普通数组以便 JSON 序列化
    const data = { ...comp };
    for (const key of Object.keys(data)) {
      const v = data[key];
      if (v instanceof Float32Array || v instanceof Float64Array) {
        data[key] = Array.from(v);
      }
    }
    return data;
  }

  private collectAssetIds(): string[] {
    const ids = new Set<string>();
    const sprites = this.ecs.query(["Sprite"]);
    for (const [entity, sprite] of sprites) {
      if (sprite.textureId) ids.add(sprite.textureId);
    }
    return Array.from(ids);
  }

  private getEditorSettings(): EditorSettings {
    return {
      gridSize: 16,
      snapEnabled: true,
      showGizmos: true,
      lastCamera: { x: 0, y: 0, zoom: 1 },
    };
  }
}

8.2 加载与版本兼容

// === serialization/SceneDeserializer.ts ===
class SceneDeserializer {
  constructor(private ecs: ECSWorld, private treeModel: SceneTreeModel) {}

  deserialize(data: SerializedScene): void {
    // 清空当前场景
    this.ecs.clear();
    this.treeModel.clear();

    // 版本检查与迁移
    if (data.version !== "1.0.0") {
      data = this.migrate(data);
    }

    // 先创建所有 Entity(不附加 Component)
    const entityMap = new Map<number, Entity>();
    for (const se of data.entities) {
      const entity = this.ecs.createEntity();
      entityMap.set(se.id, entity);
    }

    // 再附加 Component(因为 Component 可能引用其他 Entity)
    for (const se of data.entities) {
      const entity = entityMap.get(se.id)!;
      for (const [type, compData] of Object.entries(se.components)) {
        const component = this.deserializeComponent(type, compData);
        this.ecs.addComponent(entity, type, component);
      }
    }

    // 重建场景树
    this.buildTree(data.tree, entityMap, null);
  }

  private migrate(data: SerializedScene): SerializedScene {
    // 示例:v0.9.0 → v1.0.0 的迁移
    if (data.version === "0.9.0") {
      // 重命名字段、调整结构...
    }
    return data;
  }

  private deserializeComponent(type: string, data: any): Component {
    // 将普通数组还原为 typed array
    for (const key of Object.keys(data)) {
      const meta = ComponentRegistry.getFieldMeta(type, key);
      if (meta && meta.isArray) {
        data[key] = new Float32Array(data[key]);
      }
    }
    return data as Component;
  }

  private buildTree(
    sNode: SerializedTreeNode,
    entityMap: Map<number, Entity>,
    parent: SceneTreeNode | null,
  ): void {
    const entity = entityMap.get(sNode.entityId);
    if (!entity) return;

    const node = parent
      ? this.treeModel.createNode(entity, sNode.name, parent)
      : this.treeModel.getRoot();
    node.visible = sNode.visible;
    node.locked = sNode.locked;
    node.expanded = sNode.expanded;

    for (const child of sNode.children) {
      this.buildTree(child, entityMap, node);
    }
  }
}

九、性能优化策略

9.1 编辑器常见瓶颈与对策

瓶颈原因解决方案预期效果
场景树渲染卡顿1000+ 节点导致 React/Vue diff 耗时虚拟树 + 局部更新(只展开的路径渲染为真实 DOM)树渲染 < 16ms
属性面板批量刷新每帧所有 input 重渲染按需订阅:只有值变化的字段才更新CPU 降低 60%
Canvas 预览掉帧每个实体独立绘制调用批处理:同图集 Sprite 合并为一次 draw calldraw call 降低 80%
撤销栈内存泄漏大数组(地形/粒子)被反复复制结构性共享(immutable + 路径拷贝)内存占用降低 70%
首次加载慢资源文件过多、未压缩资源 lazy load + Worker 解压 + IndexedDB 缓存首屏 < 2s

9.2 撤销栈内存优化:Immutable 快照

// === command/ImmutableSnapshot.ts ===
// 对于大数组(如 tilemap),用 immutable 结构避免全量复制
class ImmutableArray<T> {
  private base: T[];
  private changes: Map<number, T> = new Map();

  constructor(base: T[] = []) {
    this.base = base;
  }

  get(index: number): T {
    return this.changes.has(index) ? this.changes.get(index)! : this.base[index];
  }

  set(index: number, value: T): ImmutableArray<T> {
    const next = new ImmutableArray(this.base);
    next.changes = new Map(this.changes);
    next.changes.set(index, value);
    return next;
  }

  // 获取实际数组(编辑状态下需要)
  toArray(): T[] {
    const result = [...this.base];
    for (const [idx, val] of this.changes) {
      result[idx] = val;
    }
    return result;
  }
}

十、Mermaid 编辑器架构图

10.1 编辑器数据流全景图

graph LR
    subgraph 用户操作
      U1[点击选中]
      U2[拖拽移动]
      U3[属性输入]
    end

    subgraph View层
      V1[SceneTreePanel]
      V2[CanvasPreview]
      V3[InspectorPanel]
    end

    subgraph Command层
      C1[SelectCommand]
      C2[MoveNodeCommand]
      C3[SetPropertyCommand]
      C4[CommandStack]
    end

    subgraph Model层
      M1[SceneTreeModel]
      M2[ECSWorld]
      M3[EditorState]
    end

    U1 --> V1 --> C1 --> C4
    U2 --> V2 --> C2 --> C4
    U3 --> V3 --> C3 --> C4
    C4 --> M1
    C4 --> M2
    M2 --> V2
    M1 --> V1
    M2 --> V3

10.2 预览渲染管线

graph TD
    A[EditorPreview.render] --> B[Clear Canvas #262626]
    B --> C[Render Grid]
    C --> D{WebGL or Canvas2D?}
    D -->|WebGL| E[WebGLEditorBackend]
    D -->|Canvas2D| F[Canvas2DEditorBackend]
    E --> G[Batch Sprites by Texture]
    G --> H[Draw Instanced Quads]
    F --> I[Draw Each Sprite]
    H --> J[Render Gizmos]
    I --> J
    J --> K[Render Selection Box]

十一、总结与下一步

本文从场景树、命令模式、属性面板、Canvas 预览到资源浏览器,完整搭建了小游戏引擎可视化编辑器的核心架构:

模块关键设计生产可用性
SceneTree扁平 Entity ↔ 树节点的双映射✅ 可直接使用
CommandStack200 步深度 + 合并策略✅ 可直接使用
InspectorProxy 层类型转换 + 双向绑定✅ 可直接使用
Preview双模渲染 + 编辑器 Camera✅ 可直接使用
AssetBrowser虚拟滚动 + lazy load✅ 可直接使用
SerializationJSON + 版本迁移✅ 可直接使用

下一步扩展

  • 协作编辑(CRDT):允许多人同时编辑同一场景,基于 Yjs 或自研 CRDT 实现
  • 脚本编辑器集成:在属性面板中直接编写并热重载 TypeScript 行为脚本
  • 运行时调试:在预览模式中直接查看 ECS 系统的执行耗时、内存分配、事件日志
  • 材质编辑器:基于节点的可视化 Shader 编辑器(类似 Unreal Blueprint)

📎 相关阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页