跳转到内容

编辑器扩展

编辑器提供基于注册表的扩展系统。将 TypeScript 文件放在项目的 src/editor/ 目录中,编辑器会自动编译并加载。扩展可以注册菜单、面板、Gizmo、状态栏项、设置等。

快速开始

src/editor/ 下创建 TypeScript 文件:

my-project/
├── src/
│ ├── game.ts ← 游戏脚本(打包进运行时)
│ └── editor/
│ └── my-extension.ts ← 编辑器扩展(不打包进运行时)
├── assets/
└── scenes/

src/editor/ 目录会被运行时构建排除——只有编辑器会加载这些文件。src/ 下所有 .ts 文件都会被编辑器编译,但只有 editor/ 之外的文件会包含在游戏构建中。文件变更会触发自动热重载。

访问 API

@esengine/editor 导入所有编辑器和 SDK API。编辑器构建系统会自动处理模块解析:

import {
// 注册 API
registerMenu,
registerMenuItem,
registerPanel,
registerGizmo,
registerStatusbarItem,
registerSettingsSection,
registerSettingsItem,
getSettingsValue,
setSettingsValue,
registerPropertyEditor,
registerComponentSchema,
registerBoundsProvider,
registerContextMenuItem,
registerInspectorSection,
registerComponentInspector,
// UI 工具
showToast,
showSuccessToast,
showErrorToast,
showContextMenu,
showConfirmDialog,
showInputDialog,
icons,
// 编辑器访问
getEditorInstance,
getEditorStore,
// 绘制和渲染(与 esengine SDK 相同)
Draw,
Geometry,
Material,
BlendMode,
DataType,
ShaderSources,
PostProcess,
registerDrawCallback,
unregisterDrawCallback,
// 清理
onDispose,
} from '@esengine/editor';

也可以从 esengine 导入——在编辑器上下文中两个模块暴露相同的合并 API。

菜单

注册菜单

在菜单栏添加新的顶级菜单:

registerMenu({
id: 'tools',
label: 'Tools',
order: 10,
});
字段类型必需说明
idstring唯一标识
labelstring显示文本
ordernumber排序(越小越靠左)

注册菜单项

向已有或自定义菜单添加项目:

registerMenuItem({
id: 'tools.export-png',
menu: 'tools',
label: 'Export as PNG',
shortcut: 'Ctrl+Shift+E',
order: 0,
action: () => {
showSuccessToast('Exported!');
},
});
registerMenuItem({
id: 'tools.clear-cache',
menu: 'tools',
label: 'Clear Cache',
order: 1,
separator: true,
enabled: () => cacheSize > 0,
action: () => clearCache(),
});
字段类型必需说明
idstring唯一标识
menustring父菜单 ID(fileeditviewhelp 或自定义)
labelstring显示文本
iconstringSVG 图标字符串
shortcutstring快捷键显示文本
separatorboolean在此项上方显示分隔线
ordernumber菜单内排序
enabled() => boolean动态启用/禁用
action() => void点击处理函数

内置菜单:fileeditviewhelp

面板

注册出现在编辑器布局中的自定义面板:

registerPanel({
id: 'my-debug-panel',
title: 'Debug Info',
icon: icons.settings(14),
position: 'right',
order: 10,
defaultVisible: false,
factory: (container, store) => {
const div = document.createElement('div');
div.style.padding = '8px';
div.textContent = 'Entity count: ' + store.entities.length;
container.appendChild(div);
const interval = setInterval(() => {
div.textContent = 'Entity count: ' + store.entities.length;
}, 1000);
return {
dispose() {
clearInterval(interval);
container.innerHTML = '';
},
onShow() {},
onHide() {},
};
},
});
字段类型必需说明
idstring唯一标识
titlestring面板标签标题
iconstringSVG 图标字符串
position'left' | 'right' | 'center' | 'bottom'布局位置
defaultVisibleboolean首次加载时显示(默认 false)
ordernumber同位置内的标签排序
factory(container, store) => PanelInstance创建面板 UI

PanelInstance

factory 必须返回至少包含 dispose 方法的对象:

interface PanelInstance {
dispose(): void;
onShow?(): void;
onHide?(): void;
}

可选实现 resize() 以处理布局变化。

Gizmo

注册自定义场景视图 Gizmo 用于可视化编辑工具:

registerGizmo({
id: 'my-anchor-gizmo',
name: 'Anchor',
icon: icons.circle(16),
shortcut: 'A',
order: 20,
draw(ctx) {
const { store, ctx: canvas, zoom } = ctx;
const selected = store.selectedEntity;
if (selected === null) return;
const transform = ctx.getWorldTransform(selected);
canvas.beginPath();
canvas.arc(transform.x, transform.y, 8 / zoom, 0, Math.PI * 2);
canvas.strokeStyle = '#ff0';
canvas.lineWidth = 2 / zoom;
canvas.stroke();
},
hitTest(worldX, worldY, ctx) {
const selected = ctx.store.selectedEntity;
if (selected === null) return { hit: false };
const transform = ctx.getWorldTransform(selected);
const dx = worldX - transform.x;
const dy = worldY - transform.y;
const dist = Math.sqrt(dx * dx + dy * dy);
return { hit: dist < 10 / ctx.zoom, data: { entityId: selected } };
},
onDragStart(worldX, worldY, hitData, ctx) {},
onDrag(worldX, worldY, hitData, ctx) {},
onDragEnd(worldX, worldY, hitData, ctx) {},
getCursor(hitData) { return 'crosshair'; },
});

GizmoDescriptor

字段类型必需说明
idstring唯一标识
namestring工具栏显示名称
iconstringSVG 图标字符串
shortcutstring快捷键
ordernumber工具栏排序
draw(ctx)function渲染 Gizmo 叠加层
hitTest(worldX, worldY, ctx)function检测鼠标是否命中 Gizmo
onDragStart(worldX, worldY, hitData, ctx)function拖拽开始
onDrag(worldX, worldY, hitData, ctx)function拖拽中
onDragEnd(worldX, worldY, hitData, ctx)function拖拽结束
onHover(worldX, worldY, hitData, ctx)function鼠标悬停
getCursor(hitData)function自定义悬停光标

GizmoContext

ctx 参数提供编辑器状态和工具方法:

属性 / 方法说明
store编辑器状态(实体、选择等)
ctxCanvas 2D 渲染上下文,用于绘制叠加层
zoom当前视口缩放级别
screenToWorld(clientX, clientY)屏幕坐标转世界坐标
getWorldTransform(entityId)获取实体的世界变换
getEntityBounds(entityData)获取实体的可视边界
requestRender()请求场景重绘

ctx.ctx 是标准的 Canvas 2D 上下文,可以在 draw() 中直接使用 fillText() 绘制视口文本:

draw(ctx) {
const { ctx: canvas, zoom } = ctx;
canvas.font = `${12 / zoom}px sans-serif`;
canvas.fillStyle = '#fff';
canvas.fillText('Label', worldX, worldY);
},

设置

注册设置分区

registerSettingsSection({
id: 'my-extension',
title: 'My Extension',
order: 10,
});

注册设置项

registerSettingsItem({
id: 'my-extension.showOverlay',
section: 'my-extension',
label: 'Show Overlay',
type: 'boolean',
defaultValue: true,
order: 0,
onChange: (value) => {
// 响应变更
},
});
registerSettingsItem({
id: 'my-extension.opacity',
section: 'my-extension',
label: 'Overlay Opacity',
type: 'range',
defaultValue: 0.8,
min: 0,
max: 1,
step: 0.1,
order: 1,
});
registerSettingsItem({
id: 'my-extension.theme',
section: 'my-extension',
label: 'Theme',
type: 'select',
defaultValue: 'dark',
options: [
{ label: 'Dark', value: 'dark' },
{ label: 'Light', value: 'light' },
],
order: 2,
});

设置项类型

类型说明额外字段
boolean开关
number数字输入minmaxstep
string文本输入
color颜色选择器
select下拉菜单options: [{ label, value }]
range滑块minmaxstep

读写设置

const show = getSettingsValue<boolean>('my-extension.showOverlay');
setSettingsValue('my-extension.opacity', 0.5);

设置自动持久化到 localStorage

状态栏项

在底部状态栏添加自定义控件:

registerStatusbarItem({
id: 'my-fps-counter',
position: 'right',
order: 0,
render: (container) => {
const span = document.createElement('span');
span.style.fontSize = '11px';
container.appendChild(span);
let lastTime = performance.now();
let frameCount = 0;
const tick = () => {
frameCount++;
const now = performance.now();
const elapsed = now - lastTime;
if (elapsed >= 1000) {
span.textContent = `FPS: ${Math.round(frameCount * 1000 / elapsed)}`;
frameCount = 0;
lastTime = now;
}
handle = requestAnimationFrame(tick);
};
let handle = requestAnimationFrame(tick);
return {
dispose() { cancelAnimationFrame(handle); },
update() {},
};
},
});
字段类型必需说明
idstring唯一标识
position'left' | 'right'状态栏位置
ordernumber排序
render(container) => { dispose, update? }创建控件

UI 工具

Toast 通知

showToast({ title: 'Hello', message: 'World', type: 'info', duration: 3000 });
showSuccessToast('Done!', 'Build completed');
showErrorToast('Error', 'Failed to compile');

对话框

const name = await showInputDialog({
title: 'Rename Entity',
label: 'New name:',
defaultValue: 'Entity',
});
const confirmed = await showConfirmDialog({
title: 'Delete Entity',
message: 'Are you sure?',
});

右键菜单

showContextMenu({
x: event.clientX,
y: event.clientY,
items: [
{ label: 'Copy', action: () => copy() },
{ label: 'Paste', action: () => paste() },
{ separator: true },
{ label: 'Delete', action: () => del() },
],
});

组件 Schema

为检查器面板注册自定义组件 Schema:

registerComponentSchema({
name: 'Health',
category: 'script',
properties: [
{ name: 'current', type: 'number', min: 0, max: 1000 },
{ name: 'max', type: 'number', min: 1, max: 1000 },
{ name: 'regenRate', type: 'number', min: 0, step: 0.1 },
],
});

属性编辑器

为属性类型注册自定义检查器控件:

registerPropertyEditor('my-gradient', (container, ctx) => {
const input = document.createElement('input');
input.type = 'color';
input.value = ctx.value as string;
input.addEventListener('input', () => ctx.onChange(input.value));
container.appendChild(input);
return {
update(value) { input.value = value as string; },
dispose() { container.innerHTML = ''; },
};
});

然后在组件 Schema 中引用该类型:

registerComponentSchema({
name: 'Gradient',
category: 'script',
properties: [
{ name: 'startColor', type: 'my-gradient' },
{ name: 'endColor', type: 'my-gradient' },
],
});

Bounds Provider

注册自定义边界计算用于 Gizmo 显示:

registerBoundsProvider('CircleCollider', {
getBounds(data: any) {
const r = data.radius ?? 50;
return { width: r * 2, height: r * 2 };
},
});

右键菜单贡献

向编辑器内置的右键菜单(层级面板、内容浏览器等)添加自定义项:

registerContextMenuItem({
id: 'my-ext.log-entity',
location: 'hierarchy.entity',
label: 'Log Entity Info',
icon: icons.settings(14),
group: 'debug',
order: 0,
visible: (ctx) => ctx.entity !== undefined,
action: (ctx) => {
console.log('Entity:', ctx.entity, ctx.entityData);
},
});

ContextMenuContribution

字段类型必需说明
idstring唯一标识
locationstring菜单出现的位置(见下表)
labelstring显示文本
iconstringSVG 图标字符串
shortcutstring快捷键显示文本
groupstring分组名——不同分组之间自动插入分隔线
ordernumber组内排序
visible(ctx) => boolean根据上下文显示/隐藏
enabled(ctx) => boolean根据上下文启用/禁用
action(ctx) => void点击处理函数
childrenContextMenuContribution[]子菜单项

内置位置

位置触发方式
hierarchy.entity右键层级面板中的实体
hierarchy.background右键层级面板空白区域
content-browser.asset右键内容浏览器中的文件
content-browser.folder右键内容浏览器中的文件夹
inspector.component右键检查器中的组件头部

也可以使用自定义位置字符串用于自己的面板。

ContextMenuContext

传递给 visibleenabledactionctx 对象包含:

属性类型说明
locationstring触发菜单的位置
entityEntity实体 ID(层级面板位置)
entityDataEntityData完整实体数据(层级面板位置)
assetPathstring资产文件路径(内容浏览器位置)
assetTypestring资产类型如 'image''script'(内容浏览器位置)
componentTypestring组件类型名称(检查器位置)

子菜单

使用 children 创建嵌套菜单:

registerContextMenuItem({
id: 'my-ext.add-collider',
location: 'hierarchy.entity',
label: 'Add Collider',
group: 'components',
action: () => {},
children: [
{
id: 'my-ext.add-box-collider',
location: 'hierarchy.entity',
label: 'Box Collider',
action: (ctx) => {
const store = getEditorStore();
store.addComponent(ctx.entity!, 'BoxCollider', {});
},
},
{
id: 'my-ext.add-circle-collider',
location: 'hierarchy.entity',
label: 'Circle Collider',
action: (ctx) => {
const store = getEditorStore();
store.addComponent(ctx.entity!, 'CircleCollider', { radius: 50 });
},
},
],
});

Inspector 自定义区域

在实体或资产检查器中添加自定义可折叠区域:

registerInspectorSection({
id: 'my-ext.entity-stats',
title: 'Entity Stats',
icon: icons.settings(14),
target: 'entity',
order: 100,
visible: (ctx) => ctx.entity !== undefined,
render: (container, ctx) => {
const div = document.createElement('div');
div.className = 'es-property-row';
function refresh() {
const data = ctx.store.getEntityData(ctx.entity!);
div.textContent = data
? `Components: ${data.components.length}`
: '';
}
refresh();
return {
update() { refresh(); },
dispose() { container.innerHTML = ''; },
};
},
});

InspectorSectionDescriptor

字段类型必需说明
idstring唯一标识
titlestring区域头部文本
iconstring头部中的 SVG 图标
ordernumber排序(越大越靠下)
target'entity' | 'asset' | 'both'区域出现的位置
visible(ctx) => boolean根据上下文显示/隐藏
render(container, ctx) => InspectorSectionInstance创建区域 UI

InspectorSectionInstance

render 函数必须返回:

interface InspectorSectionInstance {
dispose(): void;
update?(): void; // 属性变更时调用
}

InspectorContext

属性类型说明
storeEditorStore编辑器状态
entityEntity实体 ID(entity 目标)
assetPathstring资产文件路径(asset 目标)
assetTypestring资产类型(asset 目标)

资产 Inspector 区域

对于面向资产的区域,使用 assetType 字段过滤:

registerInspectorSection({
id: 'my-ext.image-analysis',
title: 'Image Analysis',
target: 'asset',
order: 200,
visible: (ctx) => ctx.assetType === 'image',
render: (container, ctx) => {
const div = document.createElement('div');
div.textContent = `Analyzing: ${ctx.assetPath}`;
container.appendChild(div);
return { dispose() { container.innerHTML = ''; } };
},
});

组件 Inspector

用完全自定义的 Inspector 替换特定组件类型的默认属性编辑 UI:

registerComponentInspector({
id: 'my-ext.health-inspector',
componentType: 'Health',
render: (container, ctx) => {
const bar = document.createElement('div');
bar.style.cssText = 'height:20px; background:#333; border-radius:4px; overflow:hidden;';
const fill = document.createElement('div');
fill.style.cssText = 'height:100%; background:#4caf50; transition:width 0.2s;';
bar.appendChild(fill);
const label = document.createElement('div');
label.style.cssText = 'font-size:11px; margin-top:4px; color:#aaa;';
container.appendChild(bar);
container.appendChild(label);
function refresh(data: Record<string, unknown>) {
const current = (data.current as number) ?? 0;
const max = (data.max as number) ?? 100;
const pct = max > 0 ? (current / max) * 100 : 0;
fill.style.width = `${pct}%`;
fill.style.background = pct > 50 ? '#4caf50' : pct > 25 ? '#ff9800' : '#f44336';
label.textContent = `${current} / ${max}`;
}
refresh(ctx.componentData);
return {
update(data) { refresh(data); },
dispose() { container.innerHTML = ''; },
};
},
});

注册了 ComponentInspector 的组件类型会完全替换默认的属性行。自定义 Inspector 接收所有组件数据和用于修改的 onChange 回调。

ComponentInspectorDescriptor

字段类型必需说明
idstring唯一标识
componentTypestring要覆盖的组件类型
render(container, ctx) => ComponentInspectorInstance创建 Inspector UI

ComponentInspectorContext

属性类型说明
storeEditorStore编辑器状态
entityEntity实体 ID
componentTypestring组件类型名称
componentDataRecord<string, unknown>当前组件数据
onChange(property, oldValue, newValue) => void报告属性变更

ComponentInspectorInstance

interface ComponentInspectorInstance {
dispose(): void;
update?(data: Record<string, unknown>): void; // 使用新数据调用
}

资源清理

编辑器在重载时会自动清理扩展注册的菜单、面板、Gizmo 和设置。对于自定义资源(定时器、事件监听器、GPU 资源),使用 onDispose

const interval = setInterval(tick, 100);
onDispose(() => clearInterval(interval));
const shader = Material.createShader(vertSrc, fragSrc);
// 通过扩展 API 创建的着色器会自动清理
registerDrawCallback('my-overlay', drawFn);
// 通过扩展 API 注册的绘制回调会自动清理

自动跟踪的资源:

  • 绘制回调(registerDrawCallback
  • 后处理 Pass(PostProcess.addPass
  • 着色器(Material.createShader

示例:原点十字准星

一个完整的扩展示例,在世界原点绘制十字准星并提供设置开关:

import {
registerSettingsSection,
registerSettingsItem,
getSettingsValue,
registerDrawCallback,
Draw,
} from '@esengine/editor';
registerSettingsSection({ id: 'debug-overlay', title: 'Debug Overlay', order: 20 });
registerSettingsItem({
id: 'debug-overlay.showOrigin',
section: 'debug-overlay',
label: 'Show Origin Crosshair',
type: 'boolean',
defaultValue: true,
});
registerSettingsItem({
id: 'debug-overlay.size',
section: 'debug-overlay',
label: 'Crosshair Size',
type: 'range',
defaultValue: 50,
min: 10,
max: 200,
step: 10,
});
registerDrawCallback('origin-crosshair', () => {
if (!getSettingsValue('debug-overlay.showOrigin')) return;
const size = getSettingsValue<number>('debug-overlay.size');
const red = { r: 1, g: 0, b: 0, a: 1 };
const green = { r: 0, g: 1, b: 0, a: 1 };
Draw.line({ x: 0, y: 0 }, { x: size, y: 0 }, red, 2);
Draw.line({ x: 0, y: 0 }, { x: 0, y: size }, green, 2);
Draw.circleOutline({ x: 0, y: 0 }, 4, { r: 1, g: 1, b: 1, a: 1 }, 1);
});

下一步