跳转到内容

编辑器插件

插件把你自己的工具加进编辑器:工具菜单里的一条命令、一个停靠面板、检查器里 多出来的一段、视口里画的 gizmo、一种新的资产类型。插件是 TypeScript,放在你的 项目里,而且不需要构建步骤——编辑器负责编译,你一保存就重新编译。

插件注册的一切都走编辑器自己功能所用的同一套注册表。贡献的命令就是命令;贡献 的面板就是带标签页、关闭按钮和弹出功能的停靠面板。不存在任何东西的“插件简化版”。

  1. 打开窗口 ▸ 插件,点新建插件(命令面板里搜“新建插件”也行)。

  2. 填名称。id 会按名称自动生成,也可以自己改——点分小写,形如 acme.level-tools。选装到项目(随项目版本管理,团队共享)还是 用户(个人插件,每个项目里都可用)。

  3. 勾选想要的起始样例(命令、面板、检视器分区、视口 gizmo、视口工具),点创建

编辑器写出一个已经能跑的插件,并立刻加载它:

  • 文件夹.esengine/
    • 文件夹plugins/
      • 文件夹acme.level-tools/
        • plugin.json
        • tsconfig.json
        • 文件夹src/
          • editor.ts
plugin.json
{
"id": "acme.level-tools",
"name": "Level Tools",
"version": "0.1.0",
"engines": { "editor": "^0.34" },
"main": { "editor": "src/editor.ts" }
}
src/editor.ts
import { definePlugin, type PluginContext } from '@estella/editor-api';
export default definePlugin({
activate(ctx: PluginContext) {
ctx.commands.register({
id: 'acme.level-tools.hello',
title: { en: 'Say hello', 'zh-CN': '打个招呼' },
menu: 'tools',
run: () => ctx.ui.toast(`${ctx.scene.getSelectionIds().length} 个实体被选中`),
});
},
});

你的命令现在出现在工具菜单和命令面板里。改一下 editor.ts 再保存:编辑器会 重新编译并重新激活它,它贡献的面板也会以新构建重新打开。

插件 API 是 experimental(实验性)不在 Estella 1.x 的兼容契约里——1.0 之后它还会继续变。这是一个明确的裁定, VERSIONING.md 和 SDK 自己的稳定性等级写在一处:贡献点还在向 同一套机制收敛;插件是跑在编辑器渲染进程里的受信代码而非隔离沙箱;而且还没有 任何已发布的插件在撑住这些形状。

在这个前提下,有三件事你可以依赖:

  • engines.editor 一定被尊重。 超出声明区间的插件会带着理由被拒绝加载,绝不 半加载。所以我们这边的变更,代价是你升一个版本号——绝不会是用户的编辑器坏掉。
  • 破坏性变更一定写下来,在 CHANGELOG 的 Editor plugin API 标题下,连同该 怎么改。
  • 移除一定先弃用——要撤掉的贡献点至少还能用一个次版本,并且会说明。

这套面会逐个冻结——随着真有已发布的插件在用它们——而不是靠一个版本号一次性 全冻。

插件面板里每个运行中的插件都有一条贡献,展开就是它此刻注册的全部东西—— 命令、面板、设置项、工具、gizmo、检视器分区、资产类型、实体模板、菜单项—— 连同各自的 id。

“我的面板怎么没出来”通常在这里就有答案:它要么不在列表里(activate 没跑到那一行), 要么在列表里但 id 和你以为的不一样。

插件面板上的导出把一个插件打包成单个 .esplugin 文件(就是 ZIP,改名成 .zip 任何工具都能打开)。node_modulesdist 和生成的类型不会进包。

对面用导入装:装之前会先列出包里的清单——manifest、声明的能力、每个文件—— 这一步不解压、不落盘。确认后才写入,且装上不等于运行:它会以“待信任”状态 出现,加载与否仍然是你单独的一个决定。

插件也可以是项目依赖的一个 npm 包:

Terminal window
npm install estella-plugin-tiled

任何直接依赖,只要根目录带一个 plugin.json,就是一个插件。编辑器会像列出其它 插件一样列出它,同样需要授信——而授信是按版本记的,所以升级会重新询问。

一个包也是把插件两半一起发布的方式:清单指向的编辑器那半,和游戏自己 import 的运行 时那半。

  • 文件夹node_modules/estella-plugin-tiled/
    • package.json
    • plugin.json "main": { "editor": "editor/index.js" }
    • 文件夹editor/
      • index.js
    • 文件夹runtime/
      • index.js
src/main.ts
import { addPlugin } from 'esengine';
import { TiledPlugin } from 'estella-plugin-tiled';
addPlugin(TiledPlugin);

运行时那半不需要任何专门机制——它就是一个普通模块,和 src/ 里的其余代码一起被打包 进你的游戏。把 esengine 声明为 peer 依赖,不要声明成普通依赖:项目的打包器会把 每一处 esengine import 留成 external,整个游戏共用一份引擎实例;而包里自带的那份副本 会是第二个组件注册表,它注册进去的系统等于注册进了虚空。

只有直接依赖会被考虑。因为别的包依赖它才被装进来的包,不是项目要求在自己编辑器里运行 的东西。

编辑器每次打开项目时,都会把 @estella/editor-api 的类型写进 .esengine/plugins/.types/editor-api.d.ts,因此它永远与你正在运行的编辑器一致。 插件的 tsconfig.json 指过去——新建插件时这份已经写好了,这里列出来是给 手写插件的人参考:

{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"paths": { "@estella/editor-api": ["../.types/editor-api.d.ts"] }
},
"include": ["src"]
}

无需安装任何东西,也不存在会过期的副本。

每个 register 都返回一个 disposable,插件卸载时全部自动撤回——你通常不需要 自己保存这些句柄。

进入命令面板,也可以进菜单。标签、快捷键提示、可用性与勾选态都来自这一处声明。

ctx.commands.register({
id: 'acme.level-tools.bake',
title: { en: 'Bake Occlusion', 'zh-CN': '烘焙遮挡' },
keybinding: 'mod+alt+b',
menu: 'tools',
isEnabled: () => ctx.scene.getSelectionIds().length > 0,
run: () => { /* … */ },
});

mount 拿到一个普通的宿主元素,返回它的拆卸函数。标签页、错误边界和弹出窗口由 编辑器负责。用编辑器的 CSS 变量(--bg--text--text-dim--accent …) 写样式,面板在明暗两种主题下都与周围的界面一致。

ctx.panels.register({
id: 'acme.level-tools.budget',
title: { en: 'Level Budget', 'zh-CN': '关卡预算' },
placement: 'bottom',
mount: (host) => {
host.textContent = 'hello';
return () => { /* 拆卸 */ };
},
});

你可以用 React——编辑器会注入它自己的实例,所以 hooks 正常工作,也不会出现第二份 副本:

import { createRoot } from 'react-dom/client';

只能从菜单里翻出来的面板等于没人开。在最左边那条图标栏上加一个按钮,和编辑器自 己那些面板按钮并排:

ctx.activityBar.register({
id: 'acme.level-tools.rail',
title: { en: 'Level Budget', 'zh-CN': '关卡预算' },
// 内联 SVG,按 19px 绘制。用 currentColor 它才会跟着侧栏走悬停和明暗主题。
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">'
+ '<path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg>',
run: () => ctx.panels.open('acme.level-tools.budget'),
});

不给 icon 就用所有贡献物共用的那个插头图标——两个插件也就都长成插头,所以自己 带一个。

这条栏又短又是公用的。你的插件是什么,就为那件事贡献一个按钮;别的都是命令, 命令的去处是「工具」菜单。

在某个组件下(或某种资产类型下)加一段,由编辑器用它自己的属性 UI 渲染你声明的 行——所以不用写样式就是原生外观。

ctx.inspector.register({
kind: 'component',
id: 'budget',
component: 'Sprite',
title: { en: 'Budget', 'zh-CN': '预算' },
build: (entity, ui) => {
ui.info({ en: 'Texture', 'zh-CN': '纹理' }, String(ctx.scene.getFieldValue(entity, 'Sprite', 'texture') ?? ''));
ui.number('weight', { en: 'Weight', 'zh-CN': '权重' }, 1, { min: 0, max: 10 });
},
write: (entity, key, value) => { /* 你建的某一行被编辑了 */ },
});

每帧绘制,坐标是世界坐标——由编辑器投影,所以 gizmo 会随平移缩放跟随场景, 以世界单位给的半径也会随之缩放。线宽和文字则保持屏幕像素。

ctx.overlays.register({
id: 'acme.spawn-radius',
render: (g) => {
const id = ctx.scene.getSelection();
if (id == null) return;
const p = ctx.scene.getFieldValue(id, 'Transform', 'position');
if (!Array.isArray(p)) return;
g.circle({ x: p[0], y: p[1] }, 120, { color: 'var(--accent)', dashed: true });
},
});

处于 armed 状态时,工具对每一笔指针操作有优先接管权。onPointerDown 返回 true 即接管这一笔及其后续 move/up。选择任何内建工具都会解除 arming,所以你不会 被卡在插件的工具里。

ctx.tools.register({
id: 'acme.measure',
title: { en: 'Measure', 'zh-CN': '测距' },
onPointerDown: (p, tc) => { tc.capture(p.pointerId); return true; },
onPointerMove: (p) => { const w = ctx.viewport.viewportToWorld(p.x, p.y); },
onPointerUp: (p, tc) => tc.release(p.pointerId),
});
ctx.tools.activate('acme.measure');
  • ctx.settings.register(…)——设置 ▸ 插件 ▸ 你的插件下的一行。
  • ctx.assets.registerType(…)——一种新资产类型:扩展名、图块徽章、双击动作, 以及一条**新建 ▸ …**菜单项。
  • ctx.entities.registerTemplate(…)——创建选择器里的一个现成实体。
  • ctx.contextMenus.register(…)——大纲或内容浏览器右键菜单里的一行,可按被点击 的对象决定是否出现。

导入器把引擎读不懂的文件转成它读得懂的资产。被认领的文件出现或改变时,以及在内容 浏览器里点重新导入时,编辑器会调用它:

ctx.assets.registerImporter({
id: 'ldtk', // 最终是 acme.level-tools.ldtk
extensions: ['ldtk'],
async import(path) {
const level = JSON.parse(await ctx.fs.readProject(path));
await ctx.fs.writeProject(path.replace(/\.ldtk$/, '.tmj'), toTiled(level));
},
});

你写出来的就是普通的项目资产,所以下游的一切——注册表、检查器、cook、发行包——都不 需要认识你的格式。记得声明 fs:project 能力,否则没有地方放输出。

抛异常(或 reject)会把这次失败记在你的插件名下(输出日志里),其余导入器照常运行。 同一个文件不会被同时导入两次,所以一次耗时较久的导入不会被它自己写出的文件重新触发。

内置 Agent 的全部词汇就是工具目录。插件加了能力却没加工具,等于只给人加了、没给 Agent 加——所以如果你的插件能烘焙遮蔽,就把它教给 Agent:

ctx.agentTools.register({
// 你的插件 id,点号换成 `_`——插件 `acme.level-tools` 的工具都以
// `acme_level_tools_` 开头。
name: 'acme_level_tools_bake-occlusion',
description: '为当前场景烘焙遮蔽。移动过墙体之后使用。',
schema: { type: 'object', properties: { quality: { type: 'number' } } },
effect: 'undoable',
run: ({ quality }) => bake(quality ?? 1),
});

description 不是文档——它是模型判断「该不该调这个」时唯一会读的东西。写清楚它做什么、什么时候该用。

effect 决定要不要先问人,分档的界线画在**「还退得回去」到此为止**的地方。readundoable 直接跑(这一轮的检查点就是许可)。journaled 也直接跑——当你的工具通过 ctx.fs.writeProject 写项目文件时就声明这一档:这些写入会在落盘前被留底,所以这一轮的「撤回」会把它们连同场景一起收回来。irreversible 留给检查点够不着的:动到已打开项目之外,或者跑没人枚举得清后果的代码;它会停下来问。

没有任何东西会去核实这个字段,而这不是漏洞——你的插件本来就握着整个编辑器接口,同样的事它用一个命令也能做。边界在安装时的信任提示,不在这个字段。这个字段换来的是:对一个诚实的插件,确认门能正确工作。所以请如实声明——一个自称 journaled 却绕开 ctx.fs 去写盘的工具,是在宣称一张并没有接住它的安全网。

名字必须以你的插件 id 开头,且只能含字母、数字、_-,最多 64 个。两半都要紧:不带命名空间的工具可能盖住内置工具,而模型在调 delete_entity 时是相信自己知道会发生什么的;而字符集之外的名字模型端点根本不收——它拒的是整个请求而不是那一个工具,于是这一个工具会把每一次对话都带下水。

插件 id 按惯例带点号,而工具名不能带,所以前缀是你的 id 把点号折成 _:插件 acme.level-tools 拥有 acme_level_tools_ 这个前缀。违反上述任一条的工具会被拒绝并在输出日志里说明原因,不会被静默丢掉。

run 返回什么就 JSON 编码给模型;抛出异常会作为一次失败调用上报,消息模型读得到、也能据此调整。对话进行中出现的新工具会加入下一次对话——工具列表排在提示词最前面,中途变更会让它之后的每一个缓存字节作废。

一律走 ctx.scene。这些写入经过编辑器的命令层,因此会进入撤销历史,并且能扛过 Play → Stop。绕开它直接改活的引擎,这两点都会失去。

ctx.scene.transact('Rename Markers', () => {
for (const node of ctx.scene.getSceneTree()) {
if (node.name === 'Entity') ctx.scene.renameEntity(node.id, 'Marker');
}
});

一次 transact 里的全部改动是一个撤销步骤,不管它改了多少东西。

打开窗口 ▸ 插件。编辑器找到的每个插件都在列表里——包括坏掉的,并附上原因: 清单解析失败、编译报错,或 id 被另一个插件占用。你的插件抛出的错误会出现在 输出日志里,带 plugin:<你的-id> 标记和堆栈。

如果一个插件反复抛错,编辑器会停用它,而不是让它每帧都弄坏某个界面。修好后点 重新加载

  • <项目>/.esengine/plugins/<id>/——随项目版本管理,与团队共享。通常放这里。
  • 项目依赖的一个 npm 包——见上面的 来自 npm
  • <userData>/plugins/<id>/——你个人的工具,跨所有项目。

同 id 时,这三处按上面的顺序取第一个,其余的会列出来并说明是谁占了。

.esengine/platforms/<id>.mjs 打包配置(见 小游戏平台)会以完整系统权限被导入编辑器主 进程。它同样列在插件面板里、同样需要批准——在你批准之前,它对应的目标在打包对话框 里显示为未就绪。

有些运行时是以 WASM 形态存在的:矢量动画播放器、各种求解器,凡是出身 C++、并且有 官方 emscripten 构建的东西都算。引擎自己就以这种方式加载着五个——物理、Basis 转码 器、各版本 Spine 运行时——项目也可以按同样的规则加自己的。

自己 fetch 一个只在网页上行得通,别处都不行:小游戏没有 fetch,二进制必须在 包里;试玩广告根本没有文件这个概念。而项目模块得到的是引擎自家模块同等的待 遇:一次调用即可取用、被打进每个包、在生成的小游戏入口里按名字 require、并且在 Play 里也会加载,好让你能对着它开发。

<项目>/.esengine/modules/rive/
module.json { "file": "rive", "globalName": "RiveModule" }
web/rive.js rive.wasm ← 网页、桌面、试玩广告,以及 Play
wechat/rive.js rive.wasm ← 所有小游戏平台

目录名就是你取用时用的 idmodule.json 是可选的:没有它时产物基名默认取目录 名,所以文件按 rive/web/rive.js 摆放的模块什么都不用写。globalName 是 emscripten 的 EXPORT_NAME——胶水用 MODULARIZE 且带具名导出时填它,胶水是 default 即工厂 的 ES 模块时省略。

按平台分目录不是官僚主义。小游戏宿主需要自己那份 emscripten 构建(WXWebAssembly 胶 水、更低的 es-target)——引擎自家的模块也正因如此要编两遍。编辑器不会拿 web 构建 去顶替小游戏构建:那会产出一个构建干净、上真机就死的包,所以它被拒绝,并给出一条点名 了它找过哪个文件的警告。

acquire 交还给你的是 emscripten 实例。它的类型是 Record<string, unknown>——引擎 无从知道你的模块导出了什么——所以请自行声明你要用的那一小片并包一层,内置的 Spine 集成就是这么做的:

interface RiveModule {
cwrap(name: string, ret: string | null, args: string[]): (...a: unknown[]) => unknown;
_malloc(size: number): number;
_free(ptr: number): void;
HEAPU8: Uint8Array;
}
const raw = await app.sideModules?.acquire('rive');
if (!raw) return; // 这个目标没打包它——降级处理
const rive = raw as unknown as RiveModule;
const version = rive.cwrap('rive_version', 'string', [])() as string;

acquire 按 id 缓存——包括失败,所以缺失的产物不会每帧重新请求——并且在模块不可 用时返回 null 而不是抛异常。请像游戏代码判断 Ads.available 那样判断它:缺这个模块 的目标应当降级,而不是崩溃。

要把这类运行时产出的东西画出来,把它的三角形交给 Mesh2D——顶点位置、UV、颜色和索引走的正是引擎内置 Spine 集成 所用的同一条路,因此引擎支持的每个后端上它都能渲染。

目标 项目模块
网页 / 桌面 / 试玩广告 web/
微信及其他小游戏 wechat/(或以你的 vendor id 命名的目录)
Play(编辑器内) web/,因此你是对着真实模块开发
Android / iOS 不支持 —— 原生宿主把模块链接进应用二进制,随内容送达的模块没有任何加载路径。请把它构建进你的原生宿主。

没能被打包进去的模块,绝不会被声明给运行时。这是刻意的:一个二进制并不存在的声明 只会报出“文件找不到”,而不是“这个目标不支持”,后者在手机上要难懂得多。

有一类 id 你拿不到:注册一个引擎自己拥有的 id(physicsspine:4.2 等)会被拒绝—— physics 因加载顺序不同而指向两个不同的二进制,那不是一种能力。