编辑器插件
插件把你自己的工具加进编辑器:工具菜单里的一条命令、一个停靠面板、检查器里 多出来的一段、视口里画的 gizmo、一种新的资产类型。插件是 TypeScript,放在你的 项目里,而且不需要构建步骤——编辑器负责编译,你一保存就重新编译。
插件注册的一切都走编辑器自己功能所用的同一套注册表。贡献的命令就是命令;贡献 的面板就是带标签页、关闭按钮和弹出功能的停靠面板。不存在任何东西的“插件简化版”。
你的第一个插件
Section titled “你的第一个插件”-
打开窗口 ▸ 插件,点新建插件(命令面板里搜“新建插件”也行)。
-
填名称。id 会按名称自动生成,也可以自己改——点分小写,形如
acme.level-tools。选装到项目(随项目版本管理,团队共享)还是 用户(个人插件,每个项目里都可用)。 -
勾选想要的起始样例(命令、面板、检视器分区、视口 gizmo、视口工具),点创建。
编辑器写出一个已经能跑的插件,并立刻加载它:
文件夹.esengine/
文件夹plugins/
文件夹acme.level-tools/
- plugin.json
- tsconfig.json
文件夹src/
- editor.ts
{ "id": "acme.level-tools", "name": "Level Tools", "version": "0.1.0", "engines": { "editor": "^0.34" }, "main": { "editor": "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 承诺什么
Section titled “这套 API 承诺什么”插件 API 是 experimental(实验性),不在 Estella 1.x 的兼容契约里——1.0 之后它还会继续变。这是一个明确的裁定, VERSIONING.md 和 SDK 自己的稳定性等级写在一处:贡献点还在向 同一套机制收敛;插件是跑在编辑器渲染进程里的受信代码而非隔离沙箱;而且还没有 任何已发布的插件在撑住这些形状。
在这个前提下,有三件事你可以依赖:
engines.editor一定被尊重。 超出声明区间的插件会带着理由被拒绝加载,绝不 半加载。所以我们这边的变更,代价是你升一个版本号——绝不会是用户的编辑器坏掉。- 破坏性变更一定写下来,在 CHANGELOG 的 Editor plugin API 标题下,连同该 怎么改。
- 移除一定先弃用——要撤掉的贡献点至少还能用一个次版本,并且会说明。
这套面会逐个冻结——随着真有已发布的插件在用它们——而不是靠一个版本号一次性 全冻。
看插件贡献了什么
Section titled “看插件贡献了什么”插件面板里每个运行中的插件都有一条贡献,展开就是它此刻注册的全部东西—— 命令、面板、设置项、工具、gizmo、检视器分区、资产类型、实体模板、菜单项—— 连同各自的 id。
“我的面板怎么没出来”通常在这里就有答案:它要么不在列表里(activate 没跑到那一行),
要么在列表里但 id 和你以为的不一样。
插件面板上的导出把一个插件打包成单个 .esplugin 文件(就是 ZIP,改名成
.zip 任何工具都能打开)。node_modules、dist 和生成的类型不会进包。
对面用导入装:装之前会先列出包里的清单——manifest、声明的能力、每个文件—— 这一步不解压、不落盘。确认后才写入,且装上不等于运行:它会以“待信任”状态 出现,加载与否仍然是你单独的一个决定。
来自 npm
Section titled “来自 npm”插件也可以是项目依赖的一个 npm 包:
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
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"]}无需安装任何东西,也不存在会过期的副本。
插件能贡献什么
Section titled “插件能贡献什么”每个 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';左侧栏上的一个按钮
Section titled “左侧栏上的一个按钮”只能从菜单里翻出来的面板等于没人开。在最左边那条图标栏上加一个按钮,和编辑器自 己那些面板按钮并排:
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
Section titled “视口 gizmo”每帧绘制,坐标是世界坐标——由编辑器投影,所以 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
Section titled “借一个工具给 Agent”内置 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 决定要不要先问人,分档的界线画在**「还退得回去」到此为止**的地方。read 和 undoable 直接跑(这一轮的检查点就是许可)。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 里的全部改动是一个撤销步骤,不管它改了多少东西。
出问题的时候
Section titled “出问题的时候”打开窗口 ▸ 插件。编辑器找到的每个插件都在列表里——包括坏掉的,并附上原因:
清单解析失败、编译报错,或 id 被另一个插件占用。你的插件抛出的错误会出现在
输出日志里,带 plugin:<你的-id> 标记和堆栈。
如果一个插件反复抛错,编辑器会停用它,而不是让它每帧都弄坏某个界面。修好后点 重新加载。
<项目>/.esengine/plugins/<id>/——随项目版本管理,与团队共享。通常放这里。- 项目依赖的一个 npm 包——见上面的 来自 npm。
<userData>/plugins/<id>/——你个人的工具,跨所有项目。
同 id 时,这三处按上面的顺序取第一个,其余的会列出来并说明是谁占了。
项目平台配置
Section titled “项目平台配置”.esengine/platforms/<id>.mjs 打包配置(见
小游戏平台)会以完整系统权限被导入编辑器主
进程。它同样列在插件面板里、同样需要批准——在你批准之前,它对应的目标在打包对话框
里显示为未就绪。
项目原生模块
Section titled “项目原生模块”有些运行时是以 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 ← 所有小游戏平台目录名就是你取用时用的 id。module.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 集成 所用的同一条路,因此引擎支持的每个后端上它都能渲染。
各目标的行为
Section titled “各目标的行为”| 目标 | 项目模块 |
|---|---|
| 网页 / 桌面 / 试玩广告 | 取 web/ |
| 微信及其他小游戏 | 取 wechat/(或以你的 vendor id 命名的目录) |
| Play(编辑器内) | 取 web/,因此你是对着真实模块开发 |
| Android / iOS | 不支持 —— 原生宿主把模块链接进应用二进制,随内容送达的模块没有任何加载路径。请把它构建进你的原生宿主。 |
没能被打包进去的模块,绝不会被声明给运行时。这是刻意的:一个二进制并不存在的声明 只会报出“文件找不到”,而不是“这个目标不支持”,后者在手机上要难懂得多。
有一类 id 你拿不到:注册一个引擎自己拥有的 id(physics、spine:4.2 等)会被拒绝——
physics 因加载顺序不同而指向两个不同的二进制,那不是一种能力。