小游戏引擎可视化编辑器架构
一、为什么编辑器是游戏引擎的胜负手
游戏引擎的核心竞争力不仅取决于运行时性能,更取决于开发者体验(DX, Developer Experience)。一个好用的编辑器能将开发效率提升 3-5 倍,而 Unity、Godot 等成熟引擎的编辑器正是其生态壁垒的核心。
小游戏编辑器面临三大特殊挑战:
- Web 端运行(必须兼容浏览器沙箱,无法像 Electron 那样随意读写文件系统)
- 即开即用(首次加载 ≤ 3 秒,否则用户流失率急剧上升)
- 移动端友好(触控操作、小屏幕适配、手势交互)
本章将从数据模型到 UI 交互,带你搭建一个可用于生产环境的小游戏可视化编辑器。
二、编辑器整体架构
编辑器的核心是Model-View-Command三层分离:
| 层级 | 职责 | 对应模块 |
|---|---|---|
| Model | ECS 场景数据 + 编辑器元数据 | SceneModel, EditorState |
| View | React/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 call | draw 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 ↔ 树节点的双映射 | ✅ 可直接使用 |
| CommandStack | 200 步深度 + 合并策略 | ✅ 可直接使用 |
| Inspector | Proxy 层类型转换 + 双向绑定 | ✅ 可直接使用 |
| Preview | 双模渲染 + 编辑器 Camera | ✅ 可直接使用 |
| AssetBrowser | 虚拟滚动 + lazy load | ✅ 可直接使用 |
| Serialization | JSON + 版本迁移 | ✅ 可直接使用 |
下一步扩展:
- 协作编辑(CRDT):允许多人同时编辑同一场景,基于 Yjs 或自研 CRDT 实现
- 脚本编辑器集成:在属性面板中直接编写并热重载 TypeScript 行为脚本
- 运行时调试:在预览模式中直接查看 ECS 系统的执行耗时、内存分配、事件日志
- 材质编辑器:基于节点的可视化 Shader 编辑器(类似 Unreal Blueprint)
📎 相关阅读
- 小游戏引擎 ECS 架构深度解析 — 编辑器底层的数据架构
- 小游戏引擎物理引擎集成:从 Matter.js 到自定义物理系统 — 在编辑器中添加物理预览
- 小游戏引擎跨平台适配层设计 — 编辑器如何在不同平台运行
- 小游戏引擎商业计划书 — 编辑器在商业模式中的战略地位
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。