编辑器扩展
编辑器提供基于注册表的扩展系统。将 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,});| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
label | string | 是 | 显示文本 |
order | number | 否 | 排序(越小越靠左) |
注册菜单项
向已有或自定义菜单添加项目:
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(),});| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
menu | string | 是 | 父菜单 ID(file、edit、view、help 或自定义) |
label | string | 是 | 显示文本 |
icon | string | 否 | SVG 图标字符串 |
shortcut | string | 否 | 快捷键显示文本 |
separator | boolean | 否 | 在此项上方显示分隔线 |
order | number | 否 | 菜单内排序 |
enabled | () => boolean | 否 | 动态启用/禁用 |
action | () => void | 是 | 点击处理函数 |
内置菜单:file、edit、view、help。
面板
注册出现在编辑器布局中的自定义面板:
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() {}, }; },});| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
title | string | 是 | 面板标签标题 |
icon | string | 否 | SVG 图标字符串 |
position | 'left' | 'right' | 'center' | 'bottom' | 是 | 布局位置 |
defaultVisible | boolean | 否 | 首次加载时显示(默认 false) |
order | number | 否 | 同位置内的标签排序 |
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
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
name | string | 是 | 工具栏显示名称 |
icon | string | 是 | SVG 图标字符串 |
shortcut | string | 否 | 快捷键 |
order | number | 否 | 工具栏排序 |
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 | 编辑器状态(实体、选择等) |
ctx | Canvas 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 | 数字输入 | min、max、step |
string | 文本输入 | — |
color | 颜色选择器 | — |
select | 下拉菜单 | options: [{ label, value }] |
range | 滑块 | min、max、step |
读写设置
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() {}, }; },});| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
position | 'left' | 'right' | 是 | 状态栏位置 |
order | number | 否 | 排序 |
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
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
location | string | 是 | 菜单出现的位置(见下表) |
label | string | 是 | 显示文本 |
icon | string | 否 | SVG 图标字符串 |
shortcut | string | 否 | 快捷键显示文本 |
group | string | 否 | 分组名——不同分组之间自动插入分隔线 |
order | number | 否 | 组内排序 |
visible | (ctx) => boolean | 否 | 根据上下文显示/隐藏 |
enabled | (ctx) => boolean | 否 | 根据上下文启用/禁用 |
action | (ctx) => void | 是 | 点击处理函数 |
children | ContextMenuContribution[] | 否 | 子菜单项 |
内置位置
| 位置 | 触发方式 |
|---|---|
hierarchy.entity | 右键层级面板中的实体 |
hierarchy.background | 右键层级面板空白区域 |
content-browser.asset | 右键内容浏览器中的文件 |
content-browser.folder | 右键内容浏览器中的文件夹 |
inspector.component | 右键检查器中的组件头部 |
也可以使用自定义位置字符串用于自己的面板。
ContextMenuContext
传递给 visible、enabled 和 action 的 ctx 对象包含:
| 属性 | 类型 | 说明 |
|---|---|---|
location | string | 触发菜单的位置 |
entity | Entity | 实体 ID(层级面板位置) |
entityData | EntityData | 完整实体数据(层级面板位置) |
assetPath | string | 资产文件路径(内容浏览器位置) |
assetType | string | 资产类型如 'image'、'script'(内容浏览器位置) |
componentType | string | 组件类型名称(检查器位置) |
子菜单
使用 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
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
title | string | 是 | 区域头部文本 |
icon | string | 否 | 头部中的 SVG 图标 |
order | number | 否 | 排序(越大越靠下) |
target | 'entity' | 'asset' | 'both' | 是 | 区域出现的位置 |
visible | (ctx) => boolean | 否 | 根据上下文显示/隐藏 |
render | (container, ctx) => InspectorSectionInstance | 是 | 创建区域 UI |
InspectorSectionInstance
render 函数必须返回:
interface InspectorSectionInstance { dispose(): void; update?(): void; // 属性变更时调用}InspectorContext
| 属性 | 类型 | 说明 |
|---|---|---|
store | EditorStore | 编辑器状态 |
entity | Entity | 实体 ID(entity 目标) |
assetPath | string | 资产文件路径(asset 目标) |
assetType | string | 资产类型(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
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
id | string | 是 | 唯一标识 |
componentType | string | 是 | 要覆盖的组件类型 |
render | (container, ctx) => ComponentInspectorInstance | 是 | 创建 Inspector UI |
ComponentInspectorContext
| 属性 | 类型 | 说明 |
|---|---|---|
store | EditorStore | 编辑器状态 |
entity | Entity | 实体 ID |
componentType | string | 组件类型名称 |
componentData | Record<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);});