主题
蛋仔UGC插件系统框架和开发规范
蛋仔UGC插件系统框架目前基于 H5 + EditorAPI/Lua 完成前后端接口调用和基础编辑功能。
用户可灵活定制插件窗口界面(H5):界面样式、控件交互、JS 脚本可完全自定义(符合 Chrome 77 规范即可)。
框架提供编辑器 API 绑定方法(详见开发范式),支持在高自由度定制前端界面的同时,调用编辑器 API 从而实现插件功能。
架构概览:

插件系统同时支持 帧同步 / 状态同步(SE)编辑器,底层自动适配两种模式的 Editor API
一、版本规范
PyQt5 v5.14.2 Chrome 内核版本
User-Agent: Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) QtWebEngine/5.14.2 Chrome/77.0.3865.129 Safari/537.36
前端特性速查表如下:
| 分类 | Chrome 77 不支持特性 |
|---|---|
| JS | 可选链 ?. |
| JS | 空值合并 ?? |
| JS | Promise.allSettled |
| JS | globalThis |
| JS | String.replaceAll |
| JS | Array.at() |
| JS | structuredClone |
| HTML | inert 属性 |
| HTML | popover 属性 |
| CSS | gap(Flex) |
| CSS | clamp() |
| CSS | :is() / :where() |
| CSS | :has() |
| CSS | aspect-ratio |
| CSS | Container Queries |
| CSS | CSS Nesting |
| CSS | @property |
| Web API | FileSystem Access |
| Web API | Navigation API |
| Web API | Screen Wake Lock |
| Web API | Trusted Types |
建议
- CSS 可以使用 -webkit- 前缀确保 Chrome 77 兼容。
- 在 Chrome 77 环境下开发时,推荐配合 Babel + core-js 3 进行 JS 语法降级与 polyfill 注入,使用 PostCSS + Autoprefixer 处理 CSS 兼容,并通过 MDN 兼容性表格 或 caniuse.com 逐项核查 API 支持情况。
二、开发范式
1. 文件结构规范
以下为标准文件结构示例,实际可以自行组织。
text
sample_plugin/
├── config.json # 插件配置文件(必须)
└── index.html # 前端界面(入口文件)1.1 入口文件规范
html 页面的兼容性规范详见上文(需 Chrome 77 兼容)。
WebChannel 自动注入与
backendReady事件框架会在页面加载完成后,自动注入 qwebchannel.js 并初始化 WebChannel,前端不需要手动引入 qwebchannel.js,也不需要手动调用 new QWebChannel(...)。
前端通过监听
backendReady事件获知window.backend已就绪:
javascript
// ✅ 推荐:监听 backendReady 事件
document.addEventListener('backendReady', function() {
console.log('后端连接成功');
// window.backend 已可用,开始业务逻辑
init();
});
// ⚠️ 如果页面脚本可能在 backendReady 之后才运行(如动态加载),需要兼容判断
if (window.backend) {
init();
} else {
document.addEventListener('backendReady', function() { init(); });
}1.2 配置文件规范(config.json)
每个 UGC 插件目录必须包含 config.json 配置文件,编辑器通过它发现和加载插件。
必填字段
| 字段 | 类型 | 说明 |
|---|---|---|
| name | string | 插件名称,用于菜单项显示和标识 |
| entry | string | 入口 HTML 文件的相对路径(相对于 config.json 所在目录) |
⚠️ 缺少任意必填字段会导致插件加载失败并被跳过。
选填字段与默认值
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| description | string | "" | 插件简要描述 |
| category | string | "default" | 一级菜单分类,相同 category 的插件归入同一子菜单 |
| ui.window_title | string | 取 name 的值 | 窗口标题 |
| ui.is_docked | boolean | false | 是否 dock 模式(false = 独立窗口) |
| ui.size | [int, int] | [1024, 768] | 初始窗口尺寸 [width, height] |
| version | string | — | ⚠️ 已废弃。插件版本由插件商店的版本号承担,加载流程忽略本字段的值;存量插件保留该字段不会报错,新插件不必再写 |
entry 路径规则
- 须使用相对路径,相对于 config.json 所在目录。后端自动解析为绝对路径。
- 入口文件不存在时插件被跳过。
最简示例(仅必填字段)
json
{
"name": "UGC Eggitor Plugin",
"entry": "index.html"
}等价于展开全部默认值后的:
json
{
"name": "UGC Eggitor Plugin",
"entry": "index.html",
"description": "",
"category": "default",
"ui": {
"window_title": "UGC Eggitor Plugin",
"is_docked": false,
"size": [1024, 768]
}
}完整示例
json
{
"name": "UGC Eggitor Plugin",
"description": "Advanced editing tools",
"category": "default",
"entry": "index.html",
"ui": {
"window_title": "UGC Eggitor Plugin Window",
"is_docked": false,
"size": [1024, 768]
}
}2. 后端API定义
2.1 接口类定义
以下仅列出接口示例,前端使用范式请看下文第 3 节 API 绑定部分。
python
class PluginInterface(QObject):
@pyqtSlot(str, list, result='QVariant')
def runEditorAPI(self, api_name, arg_list):
"""
统一的编辑器 API 调用入口
:param api_name: API 方法名
:param arg_list: 参数列表
:return: 标准化返回格式 {'success': bool, 'result'|'error': any}
"""
...
@pyqtSlot(str, list, result='QVariant')
def runPluginAPI(self, api_name, arg_list):
# 插件 API 调用入口,形态与 runEditorAPI 一致,查的是插件 API 注册表
...
@pyqtSlot(str, str, result='QVariant')
def executeLuaCode(self, method, code):
# 编辑时 Lua 源码执行;结果经 backendSignal 推送,靠 method 关联
...
@pyqtSlot(str, str, result='QVariant')
def executeLuaCodeRuntime(self, method, code):
# 运行时(试玩中)Lua 源码执行;结果同样经 backendSignal 推送
...
@pyqtSlot(str, result=str)
def runLuaScript(self, path):
# Lua 脚本文件执行接口(Legacy)
...
@pyqtSlot(str, result='QVariant')
def navigateTo(self, path):
# 页面导航,切换到另一个 HTML 页面
...
@pyqtSlot(result='QVariant')
def listHtmlFiles(self):
# 列出插件目录下 HTML 文件列表 [{name, path}, ...]
...
@pyqtSlot(result='QVariant')
def testConnection(self):
# 连接测试接口
...关键点:
- 使用
@pyqtSlot装饰器声明 WebChannel 可调用方法。 result='QVariant'支持返回任意类型数据。- 统一使用
runEditorAPI作为API调用入口。
2.2 标准化返回格式
python
# 成功返回
{'success': True, 'result': result_message}
# 失败返回
{'success': False, 'error': error_message}2.3 自动类型转换
基于 WebChannel 通信机制和后端类型转换实现,前端 JavaScript 可以传递以下基础数据类型:
前后端对应的 EType 映射
| 前端类型 | 后端 EType | 转换说明 |
|---|---|---|
| string | Str/String | Unicode 编码处理 |
| number | Int/Int32 | 整数转换 |
| number | Float/Fixed | 浮点数/定点数转换 |
| boolean | Bool | 布尔值转换 |
| Array | List/ListXXX | 数组类型转换 |
| Object | Dict | 字典对象转换 |
| [x, y, z] / (x, y, z) | Vector3/Point3 | 3D 坐标转换 |
| null / undefined | None | 空值处理 |
注意
前端无需关心具体的引擎类型,只需传基础数据类型即可。
后端会根据 API 定义的参数类型描述(EdAPIDef)自动将前端传入的 JS 基础类型转换为目标 Python/引擎类型。
实际传参示例
javascript
// 创建组件
window.backend.runEditorAPI('create_obstacle', [
102818, // ObstacleKey (Int)
[0.0, 2.0, 0.0] // Position (Vector3)
], callback);
// 查询单位
window.backend.runEditorAPI('query_unit_ids', [
"方块", // Pattern (String)
true // UseRegex (Bool)
], callback);
// 复杂参数调用(示例)
window.backend.runEditorAPI('create_complex_object', [
"building_001", // ID (String)
{ // Config (Dict)
"type": "house",
"size": [10, 8, 12],
"materials": ["wood", "stone"],
"enabled": true
},
[ // Positions (List)
[0, 0, 0],
[10, 0, 0],
[0, 0, 10]
]
], callback);注意事项
类型转换限制
- JavaScript 的 undefined 会被转换为 Python 的 None。
- JavaScript 的大数值可能精度丢失。
- 循环引用对象无法序列化。
编码注意
- 中文字符串会自动进行 UTF-8 编码转换。
- JSON 字符串需要正确转义特殊字符。
坐标数据
- 3D 坐标必须是 3 元素数组:[x, y, z]、(x, y, z)。
- 也支持对象格式:{x: 0, y: 2, z: 0}。
3. 前端通信规范
3.1 backendReady 事件
前端的 backendReady 事件标志着后端初始化成功、业务逻辑可用。可以通过监听此事件来进行必要的初始化逻辑,详见 1.1 小节。
3.2 API 绑定
后端调用一律不能同步取值,也不能 await 拿业务结果。
但结果从哪里回来,分三种通道 —— 混淆通道是本框架最高频的踩坑点。
| 通道 | 包含 | 结果怎么拿 |
|---|---|---|
| slot 调用 | runEditorAPI、runPluginAPI、pickDirectory、listDirectoryFiles、navigateTo、listHtmlFiles、getPluginId、testConnection | 传回调函数,回调收到 |
| Lua 执行 | executeLuaCode、executeLuaCodeRuntime | 传 method 关联键,结果经 backendSignal 推送;slot 同步返回值只兜底 framework 级异常 |
| 被动推送 | 编辑器事件、预览帧 | 不用调用,backendSignal.connect(handler) 后被动收 |
反例 —— 下面这段代码的回调永远不会执行,因为 executeLuaCode 不走回调通道:
javascript
// ❌ 错误:executeLuaCode 是双参 (method, code),第二参不是回调
window.backend.executeLuaCode('return 1', function(r) { /* 永远不执行 */ });
// ✅ 正确:传 method 关联键,结果在 backendSignal 里等
window.backend.backendSignal.connect(onSignal);
window.backend.executeLuaCode('myMethod', 'return 1');另有 runLuaScript 属 Legacy 接口,回调收到的是纯字符串而非字典,详见 3.2.5。
3.2.1 runEditorAPI
所有编辑时 API 调用都遵循统一的 runEditorAPI 异步调用模式:
javascript
// 正确的异步调用方式
window.backend.runEditorAPI(apiName, args, function(result) {
if (result && result.success === true) {
// 成功处理
console.log('调用成功:', result.result);
} else {
// 错误处理
console.error('调用失败:', result ? result.error : '未知错误');
}
});注意事项:
- PyQt5 WebChannel 只支持异步调用,不能使用同步方式。
- 必须使用回调函数接收返回值。
- 严格判断 result.success === true。
3.2.2 runPluginAPI
插件 API 调用入口。调用形态、参数结构、返回结构与 runEditorAPI 完全一致,唯一区别是查哪张注册表:
| 方法 | 查询的注册表 | 覆盖能力 |
|---|---|---|
| runEditorAPI | 编辑器 API 注册表 | 编辑器通用能力:组件增删改查、选中、属性、撤销重做、保存等 |
| runPluginAPI | 插件 API 注册表 | 插件侧扩展能力:预览系统、能力探测、按钮配置等 |
注意
两张注册表并非互斥:预览系统等模块的 API 在编辑器 API 与插件 API 中各注册了一份,实现完全相同。因此 init_preview_scene 这类接口用 runEditorAPI 或 runPluginAPI 调用都能跑通。
建议:新插件统一走 runPluginAPI,语义更明确;存量插件用 runEditorAPI 调预览接口仍然有效,不必改。
调用签名:
javascript
window.backend.runPluginAPI(apiName, argList, function(result) {
if (result && result.success === true) {
console.log('调用成功:', result.result);
} else {
console.error('调用失败:', result ? result.error : '未知错误');
}
});
// 示例:初始化预览场景
window.backend.runPluginAPI('init_preview_scene', [MY_PLUGIN_ID, 'default'], function(r) {
if (r && r.success === true) {
console.log('预览场景已就绪');
}
});参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| api_name | string | 已注册的插件 API 方法名 |
| arg_list | Array | 参数列表,类型转换规则同 2.3 节 |
面向插件开发者开放的插件 API 共 26 个,按模块分布(其中预览系统一组在编辑器 API 注册表中另有同名同实现的注册,两边都能调):
| 模块 | 数量 | 典型能力 |
|---|---|---|
| 预览系统 | 21 | 预览场景初始化、相机控制、帧推流、单位加载 |
| 通用 | 4 | 能力探测、资源路径解析等 |
| 按钮配置 | 1 | 插件按钮项配置 |
错误情况:
| 错误信息 | 原因 |
|---|---|
PluginAPI method <name> not registered | 该名称未在插件 API 注册表中登记 |
PluginAPI method <name> not found | 已登记元信息但实现对象上找不到该方法 |
| Not enough arguments. Required: N, provided: M | 必填参数个数不足 |
| Too many arguments. Expected: N, provided: M | 参数个数超出声明 |
3.2.3 executeLuaCode
直传 Lua 源码字符串到编辑时 LuaVM(plugin_execute)执行,结构化返回多返回值。
后端签名
python
@pyqtSlot(str, str, result='QVariant')
def executeLuaCode(self, method, code):
# 1. code 求 md5,命中编译缓存直接复用字节码,否则异步编译
# 2. 提交编辑时 LuaVM 执行字节码
# 3. 结果经 backendSignal.emit({method, success, returns/error}) 推送参数
| 参数 | 类型 | 说明 |
|---|---|---|
| method | string | 前端自定义的方法标识,用于请求-响应关联;原样回填到 backendSignal payload 的 method 字段。同一时刻不要复用同一个 method,否则结果会串 |
| code | string | Lua 源码字符串。unicode / 含中文字符串自动 encode 为 UTF-8 |
返回格式
调用后 slot 不同步返回业务结果(同步返回值仅用于兜底 framework 级异常)。结果经 backendSignal 推送一条 payload:
javascript
// 成功(returns 始终为 list;0 返回为 [],N 返回为 [v1, ..., vN];nil 保留位为 null)
{ method: 'myMethod', success: true, returns: [42, null, "x", { a: 1, b: { c: 2 } }] }
// 失败
{ method: 'myMethod', success: false, error: 'compile error' }| 字段 | 类型 | 说明 |
|---|---|---|
| method | string | 回填调用时传入的 method,前端据此关联本次请求 |
| success | boolean | true 表示 chunk 跑通,false 表示出错 |
| returns | Array | 仅成功时存在。chunk return 的全部值,按位排列;nil 表现为 null;嵌套 table 透传为嵌套 dict / list |
| error | string | 仅失败时存在。编译失败时恒为 compile error,不含编译器详细信息;运行时错为含 stack traceback 的字符串;环境/VM 错为对应 message |
错误情况
| 错误信息 | 原因 |
|---|---|
| editor_context not available | Globals.editor_context 未初始化 |
| editor_context.plugin_manager not available | 编辑器插件管理器未就绪 |
| execute returned empty result | Lua 端 plugin_execute 返回空(理论不应发生) |
| compile error | Lua 源码语法错(load 失败)。该串固定,不含编译器详细信息 |
| [string "=execute"]:1: ... stack traceback: ... | Lua chunk 内 error(...) 抛出 / API 调用失败 |
| unknown error | chunk 内抛错但 payload 为空时的兜底值 |
| executeLuaCode failed: ... | Python 端框架级异常(极少见)。该串经 slot 同步返回,不走 backendSignal |
前端调用示例
先搭一套 pending 表 + 信号分发骨架,后续所有 Lua 调用复用:
javascript
var pending = {}; // method -> 回调
function onSignal(payload) {
if (!payload || !payload.method) return;
var cb = pending[payload.method];
if (!cb) return; // 非本模块发起的 method,忽略
delete pending[payload.method];
cb(payload);
}
window.backend.backendSignal.connect(onSignal);
function runLua(code, cb) {
var method = 'lua_' + Date.now() + '_' + Math.random().toString(36).slice(2);
pending[method] = cb;
window.backend.executeLuaCode(method, code);
}javascript
// 1. 简单 return
runLua('return 42', function(r) {
if (r.success === true) {
console.log('返回值:', r.returns[0]); // 42
}
});
// 2. 多返回 + nil 漏位(returns 长度严格等于 Lua 端返回个数)
runLua('return 1, nil, "x"', function(r) {
// r.returns = [1, null, "x"],长度 3
console.log('共', r.returns.length, '个返回值:', r.returns);
});
// 3. 错误分支处理(编译错 vs 运行时错)
runLua(buggyCode, function(r) {
if (r.success === true) {
handleResult(r.returns);
} else if (r.error === 'compile error') {
showResult('lang-err', '语法错,请检查 Lua 源码', true);
} else {
showResult('lang-err', '运行时错: ' + r.error, true);
}
});注意
Lua chunk 顶层 coroutine.yield 不支持(跨 C 边界报错);需要异步逻辑请改用 runEditorAPI 单调用 + 前端 setTimeout 编排。
页面销毁前记得 window.backend.backendSignal.disconnect(onSignal)。
3.2.4 executeLuaCodeRuntime
直传 Lua 源码字符串到游戏运行时 LuaVM(sandbox_execute)执行,结构化返回多返回值。仅在试玩中可用。
与 executeLuaCode 的差异
- 只有进入试玩才可用,编辑器静态状态下调用会报 game not running 错误。
- scenario 跟随运行时沙盒的 load_scenario upvalue 实时切换(与 sandbox_load 一致),调用前若已 sandbox_switch_to_float() 切换,chunk 用切换后的 scenario 编译。
- chunk 在 sandbox 沙盒下运行,runtime.api_desc 白名单生效,不可见真实 _G。
- 其余参数形态、返回结构、错误格式、结果推送方式与 executeLuaCode 完全一致(同为双参 + backendSignal 推送)。
后端签名
python
@pyqtSlot(str, str, result='QVariant')
def executeLuaCodeRuntime(self, method, code):
# 内部调用 Globals.visual_layer.blackbox.lua_mgr.execute(bytecode)
# 结果同样经 backendSignal.emit({method, success, returns/error}) 推送参数
与 executeLuaCode 一致(method + code 双参)。
返回格式
与 executeLuaCode 一致(含 method 回填字段,经 backendSignal 推送)。
错误情况
| 错误信息 | 原因 |
|---|---|
| visual_layer not available (game not running) | 不在试玩中(编辑器静态状态) |
| visual_layer.blackbox not available | visual_layer 已初始化但 blackbox 未就绪 |
| lua_mgr not available | blackbox.lua_mgr 未注入 |
| execute returned empty result | Lua 端 sandbox_execute 返回空(理论不应发生) |
| compile error | Lua 源码语法错。该串固定,不含编译器详细信息 |
| [string "=execute"]:1: ... stack traceback: ... | chunk 内 error / 沙盒 API 失败(命中 api_desc 白名单等) |
| unknown error | chunk 内抛错但 payload 为空时的兜底值 |
| executeLuaCodeRuntime failed: ... | Python 端框架级异常。该串经 slot 同步返回,不走 backendSignal |
前端调用示例
复用 3.2.3 的 pending / onSignal 骨架,只把 slot 换成 executeLuaCodeRuntime:
javascript
function runLuaRuntime(code, cb) {
var method = 'rt_' + Date.now() + '_' + Math.random().toString(36).slice(2);
pending[method] = cb;
window.backend.executeLuaCodeRuntime(method, code);
}⚠️ 下述示例 1 中的 Lua 代码仅供参考,实际开发请以 Runtime SDK 实现为准。
javascript
// 1. 试玩中查询场景单位
var code = ''
+ 'local world = game:GetService("World")\n'
+ 'local list = {}\n'
+ 'local count = 0\n'
+ 'for unitId, unit in pairs(world:GetDescendants()) do\n'
+ ' count = count + 1\n'
+ ' list[count] = { id = tostring(unitId), name = unit.Name or "" }\n'
+ 'end\n'
+ 'return count, list';
runLuaRuntime(code, function(r) {
if (r.success === true) {
var count = r.returns[0];
var units = r.returns[1];
console.log('场景共', count, '个单位:', units);
} else {
console.error(r.error);
}
});
// 2. VM 不可用兜底(试玩外调用)
runLuaRuntime('return 1', function(r) {
if (!r.success) {
if (r.error.indexOf('visual_layer not available') === 0) {
showResult('hint', '请先进入试玩再使用运行时通道', true);
} else {
showResult('hint', '运行时通道不可用: ' + r.error, true);
}
return;
}
});3.2.5 runLuaScript
⚠️ Legacy:推荐用 executeLuaCode 直传 Lua 源码字符串,无需先把代码落到 .lua 文件。runLuaScript 仅供存量插件维护。
执行指定路径的 Lua 脚本文件。
后端签名
python
@pyqtSlot(str, result=str)
def runLuaScript(self, path):
...参数
| 参数 | 类型 | 说明 |
|---|---|---|
| path | string | Lua 脚本文件的绝对路径 |
返回格式
返回字符串(非字典):
| 返回值 | 含义 |
|---|---|
| "ok" | 脚本执行成功 |
| "fail" | 脚本执行失败(lua.run() 返回 falsy) |
| "file does not exist" | 文件路径不存在 |
| "Error: ..." | 执行过程中发生异常 |
前端调用示例
javascript
window.backend.runLuaScript('C:/path/to/script.lua', function(result) {
if (result === 'ok') {
showResult('scriptResult', '脚本执行成功', false);
} else {
var isError = result.indexOf('Error') !== -1 || result === 'fail';
showResult('scriptResult', '结果: ' + result, isError);
}
});3.2.6 navigateTo
页面导航示例:
javascript
window.backend.navigateTo('abspath/plugin_dir/settings.html', function(result) {
if (result && result.success === true) {
console.log('页面切换成功');
} else {
console.error('切换失败:', result ? result.error : '未知错误');
}
});3.2.7 listHtmlFiles
列出插件目录下 html 文件列表。前端调用示例:
javascript
window.backend.listHtmlFiles(function(result) {
if (result && result.success === true) {
var files = result.result;
var select = document.getElementById('fileSelect');
select.innerHTML = '';
for (var i = 0; i < files.length; i++) {
var opt = document.createElement('option');
opt.value = files[i].path;
opt.textContent = files[i].name;
select.appendChild(opt);
}
}
});3.2.8 testConnection
WebChannel 连通性自检,无参数,回调收 {success, message}。常用于页面加载首屏验证后端是否就绪。
javascript
window.backend.testConnection(function(result) {
if (result && result.success === true) {
console.log('连接正常:', result.message); // 'Connection OK'
} else {
console.error('后端未就绪');
}
});可配合 backendReady 事件使用:backendReady 触发后调一次 testConnection,双重确认通信链路。
3.2.9 getPluginId
返回当前插件实例的唯一标识,生命周期内稳定、跨实例唯一。返回值是纯字符串,不是字典。
javascript
// 推荐:async/await 形态(Chrome 77 已支持)
async function setup() {
var pluginId = await window.backend.getPluginId();
if (!pluginId) {
console.error('插件 ID 为空,后端未就绪');
return;
}
MY_PLUGIN_ID = pluginId;
}
// 兼容:回调形态
window.backend.getPluginId(function(id) {
MY_PLUGIN_ID = id;
});典型用途:
- 作为 runPluginAPI('init_preview_scene', [plugin_id, sub_id]) 的第一参数;该接口在编辑器 API 注册表中亦有同名同实现的注册,改用 runEditorAPI 调用同样可行。
- 作为 backendSignal payload 中 plugin_id 字段的过滤依据。
3.2.10 backendSignal(推送型)
后端主动推送消息的 signal,非方法,不走请求-响应。订阅靠 connect / 退订靠 disconnect。
它同时承载两类内容:一是编辑器事件与预览帧等被动推送;二是 executeLuaCode / executeLuaCodeRuntime 的执行结果回传(见 3.2.3)。
javascript
// payload 结构
// {
// method: 'frameReady', // 消息类型
// plugin_id: 'plugin_7f3a9c', // 目标插件 ID
// sub_id: 'default', // 目标子区
// data: 'data:image/jpeg;base64,...' // 数据,类型随 method 变化
// }
function onSignal(payload) {
if (!payload || typeof payload !== 'object') return;
// 推荐:多插件 / 多预览区场景下过滤不属于自己的消息
if (payload.plugin_id !== MY_PLUGIN_ID) return;
if (payload.sub_id !== MY_SUB_ID) return;
// 按 method 分发
switch (payload.method) {
case 'frameReady':
drawFrame(payload.data);
break;
default:
// 未知 method 静默忽略,不抛错
break;
}
}
// 订阅
window.backend.backendSignal.connect(onSignal);
// 退订(页面销毁前调用)
window.addEventListener('beforeunload', function() {
try { window.backend.backendSignal.disconnect(onSignal); } catch (e) {}
});💡 method 不是封闭枚举。调用 executeLuaCode / executeLuaCodeRuntime 时传入的自定义标识会原样回填到 payload 的 method 字段,因此 switch 必须保留 default 分支,且过滤 plugin_id 前要先判断该 method 是否由本模块发起。
框架内置推送的 method 列表:
| method | data 字段 | 含义 |
|---|---|---|
| frameReady | data: string | 预览帧,格式为 data:image/jpeg;base64,<...>;可直接赋给 img 的 src |
| selectionChanged | selected_ids: number[] | 选中变化;当前选中的 unit id 列表,空选时为 [] |
| unitAdded | instance_id: number, preset_id: string | null, parent_id: number | null | 组件创建;instance_id = unit_id,preset_id = unit.unit_eid,parent_id 为父 unit 或 null |
| unitRemoved | instance_id: number, parent_id: number | null | 组件删除;语义同上 |
| propertyChanged | instance_id: number, prop_name: string, old_value: any, new_value: any | 属性修改;由 view/helper 层插桩触发 |
| undoRedoCompleted | action: 'undo' | 'redo', record: | 撤销 / 重做完成;普通 do 动作不推送 |
| saveCompleted | success: boolean | 保存完成 |
3.2.11 pickDirectory
弹原生目录选择对话框,拿绝对路径。
javascript
window.backend.pickDirectory('选择图片目录', function(result) {
if (result && result.success === true) {
var dirPath = result.result; // 'D:/my_images',分隔符已归一化为 /
// 接下来通常配合 listDirectoryFiles
} else {
var msg = result ? result.error : '未知错误';
// 用户取消时 result.error === 'cancel select'
if (msg !== 'cancel select') {
console.error('选择目录失败:', msg);
}
}
});
// 省略 title 参数(默认显示"选择目录")
window.backend.pickDirectory(function(result) { /* ... */ });注意事项:
- 用户点取消 / 按 Esc → success: false + error: 'cancel select'(英文串,前端判等时不要写成中文),应区别于真实异常。
- 发生异常时 error 为 Python 异常消息原文,无固定前缀,不要按前缀匹配。
- 返回路径分隔符已统一为 /,前端可直接拼接。
3.2.12 listDirectoryFiles
列出指定目录下符合扩展名过滤的文件,常与 pickDirectory 联用。
javascript
// 列 png/jpg 图片
window.backend.listDirectoryFiles(
'D:/my_images',
['.png', '.jpg', '.jpeg'],
function(result) {
if (result && result.success === true) {
var files = result.result;
// 每个 entry: { name: 'a.png', path: 'D:/my_images/a.png', size: 12345 }
files.forEach(function(f) {
console.log(f.name, '→', f.path, f.size + 'B');
});
} else {
console.error('列目录失败:', result ? result.error : '未知错误');
}
}
);
// 不过滤扩展名(传 null 或 [])
window.backend.listDirectoryFiles('D:/x', null, callback);
// 扩展名容忍:大小写不敏感、可省前导点
window.backend.listDirectoryFiles('D:/x', ['png', 'JPG'], callback);约束:
- 仅列当前层文件,子目录静默跳过(不递归、不报错)。
- 返回数组按文件名升序,路径分隔符 /。
- 单文件 os.path.getsize 失败 → 该 entry 的 size 兜底为 0,整体调用仍 success: true。
错误情况:
| 错误信息 | 原因 |
|---|---|
Directory does not exist: <path> | dir_path 不存在或不是目录(英文串,前端判等时注意) |
<Python 异常消息> | 其余异常直接透传 str(e),无固定前缀 |
3.3 参数传递规范
参考 2.3 自动类型转换。
javascript
// 基础参数
window.backend.runEditorAPI('log', ['Hello World'], callback);
// 坐标参数(数组形式)
var position = [parseFloat(x), parseFloat(y), parseFloat(z)];
window.backend.runEditorAPI('create_obstacle', [obstacleKey, position], callback);
// 复杂参数(对象需要序列化)
var complexArgs = [
obstacleKey,
{x: 0, y: 2, z: 0},
{enable: true, scale: 1.5}
];3.4 返回值处理
window.backend 暴露的方法返回值有四种形态,不可混判:
| 方法类别 | 返回形态 | 代表方法 |
|---|---|---|
| 字典型(结构化) | runEditorAPI / runPluginAPI / navigateTo / listHtmlFiles / testConnection / pickDirectory / listDirectoryFiles | |
| 字符串型(扁平) | 纯字符串 | runLuaScript |
| 标识型 | 纯字符串 | getPluginId |
| 推送型(信号) | 不走回调,payload 字典经信号推送 | backendSignal(connect / disconnect);executeLuaCode / executeLuaCodeRuntime 的执行结果也在这条通道上 |
3.4.1 字典型(runEditorAPI / runPluginAPI 等)
成功 / 失败统一字段,必须 === true 严格判断。
javascript
window.backend.runEditorAPI('get_unit_position', [102818], function(result) {
// 1) 防 null/undefined(WebChannel 异常会回 null)
if (!result) {
console.error('调用失败: 后端无响应');
return;
}
// 2) 严格判断成功
if (result.success === true) {
var pos = result.result; // 业务数据
// ...
} else {
console.error('调用失败:', result.error || '未知错误');
}
});3.4.2 result.result 序列化结构
runEditorAPI / runPluginAPI 成功时 result.result 已被后端递归序列化为纯 JSON 数据,不会出现游戏内原生对象。参考映射:
| 后端类型 | 前端拿到 |
|---|---|
| dict | plain object |
| list | plain array |
| tuple | array |
| Vector3 | [x, y, z] 三元素数组 |
| EditUnit(含 serialize) | serialize() 返回的 object |
| 未覆盖类型 | 原样透传,结构与精度不可预期 |
⚠️ 防御性判断:未覆盖类型可能原样透传,前端可用 Array.isArray / typeof / 字段存在性检查:
javascript
if (result.success === true) {
var data = result.result;
if (Array.isArray(data) && data.length === 3) {
// Vector3 形态:[x, y, z]
} else if (data && typeof data === 'object') {
// dict / EditUnit.serialize() 形态
} else if (typeof data === 'string') {
// 字符串返回
}
}3.4.3 字符串型(runLuaScript)
返回字符串而非字典,判错靠字符串匹配:
| 返回值 | 含义 |
|---|---|
| "ok" | 执行成功 |
| "fail" | lua.run() 返回 falsy |
| "file does not exist" | 路径不存在 |
| "Error: ..." | 异常 |
javascript
window.backend.runLuaScript(path, function(result) {
var isError = !result
|| result === 'fail'
|| result === 'file does not exist'
|| result.indexOf('Error') !== -1;
showResult('scriptResult', '执行结果: ' + result, isError);
});3.4.4 标识型(getPluginId)
返回纯字符串(如 "plugin_7f3a9c"),无 success 字段。空串视为后端未就绪。
javascript
window.backend.getPluginId(function(id) {
if (!id) {
console.error('插件 ID 为空,后端未就绪');
return;
}
MY_PLUGIN_ID = id;
});
// async/await 形态(Chrome 77 支持)
async function setup() {
var id = await window.backend.getPluginId();
if (!id) return;
MY_PLUGIN_ID = id;
}3.4.5 推送型(backendSignal)
后端信号类,非 RPC,无 success 字段(Lua 执行结果 payload 除外,它带 success)。监听后,回调接收 payload 字典,按 method 分发。
分发顺序建议:先判断 method 是否由本模块发起 → 再按 plugin_id + sub_id 过滤 → 最后进入业务分支。多插件或多预览区场景下建议按 plugin_id + sub_id 过滤,避免处理他人消息。
javascript
function onSignal(payload) {
if (!payload || typeof payload !== 'object') return;
// 1) 本模块发起的 Lua 调用结果,优先按 method 关联
if (pending[payload.method]) {
var cb = pending[payload.method];
delete pending[payload.method];
cb(payload);
return;
}
// 2) 框架推送事件:多页面窗口可按 plugin_id + sub_id 过滤
if (payload.plugin_id !== MY_PLUGIN_ID) return;
if (payload.sub_id !== MY_SUB_ID) return;
switch (payload.method) {
case 'frameReady':
drawFrame(payload.data); // data: 'data:image/jpeg;base64,...'
break;
default:
// 未知 method 静默忽略,不抛错
break;
}
}
window.backend.backendSignal.connect(onSignal);
// 页面销毁前退订
window.addEventListener('beforeunload', function() {
try { window.backend.backendSignal.disconnect(onSignal); } catch (e) {}
});3.4.6 错误信息对照
runEditorAPI 失败时 result.error 常见取值:
| 错误信息 | 原因 |
|---|---|
| editor_context not available | 编辑器上下文未初始化 |
| editor_api not available | 编辑器 API 不可用 |
| API method "xxx" not found | API 名拼写错误 |
| Not enough arguments / Too many arguments | 参数数量不匹配 |
| Type conversion failed for parameter xxx | 参数类型转换失败(参考 2.3 前后端 EType 映射) |
| API call failed: xxx | API 内部执行失败 |
runPluginAPI 失败时 result.error 常见取值:
| 错误信息 | 原因 |
|---|---|
PluginAPI method <name> not registered | 该名称未在插件 API 注册表中登记 |
PluginAPI method <name> not found | 已登记元信息但实现对象上找不到该方法 |
| Not enough arguments. Required: N, provided: M | 必填参数个数不足 |
| Too many arguments. Expected: N, provided: M | 参数个数超出声明 |
3.4.7 速查表
| 方法 | 形态 | 判成功 | 取数据 | 取错误 |
|---|---|---|---|---|
| runEditorAPI | 字典 | result.success === true | result.result | result.error |
| runPluginAPI | 字典 | result.success === true | result.result | result.error |
| executeLuaCode | 信号 | payload.success === true | payload.returns | payload.error |
| executeLuaCodeRuntime | 信号 | payload.success === true | payload.returns | payload.error |
| navigateTo | 字典 | result.success === true | — | result.error |
| listHtmlFiles | 字典 | result.success === true | result.result(数组) | result.error |
| testConnection | 字典 | result.success === true | result.message | — |
| pickDirectory | 字典 | result.success === true | result.result(路径) | result.error(取消时为 'cancel select') |
| listDirectoryFiles | 字典 | result.success === true | result.result(数组) | result.error |
| runLuaScript | 字符串 | result === 'ok' | 同 result | 字符串含 Error / fail |
| getPluginId | 字符串 | !!id | 同回调参数 | 空串视为未就绪 |
| backendSignal | 信号 | 按 payload.method 分发 | payload.data | — |
注意
表中 executeLuaCode / executeLuaCodeRuntime 的「形态」列是信号而非字典 —— 它们不经回调返回结果,这是最容易踩坑的一处。
4. 前端UI交互示例
旨在说明如何在前端显示信息和增加反馈。下面仅给出基础示例,实际可自由定制。
4.1 结果显示
javascript
function showResult(elementId, message, isError) {
var resultDiv = document.getElementById(elementId);
resultDiv.style.display = 'block';
resultDiv.className = isError ? 'result error' : 'result';
resultDiv.textContent = typeof message === 'object' ?
JSON.stringify(message, null, 2) : message;
}4.2 输入验证
javascript
function createObstacle() {
var obstacleKey = parseInt(document.getElementById('obstacleKey').value);
// 参数验证
if (isNaN(obstacleKey)) {
showResult('createResult', '请输入有效的组件编号!', true);
return;
}
// 继续处理...
}5. 异常和错误处理
前端错误处理示例:
javascript
function callAPI(apiName, args) {
try {
window.backend.runEditorAPI(apiName, args, function(result) {
if (result && result.success === true) {
showResult(elementId, '成功: ' + result.result, false);
} else {
var errorMsg = result ? result.error : '未知错误';
showResult(elementId, '失败: ' + errorMsg, true);
}
});
} catch (e) {
console.error('调用异常:', e);
showResult(elementId, '调用失败: ' + e.message, true);
}
}⚠️ 上面的 try/catch 只能兜住同步抛出的 framework 级异常。业务错误一律在回调里按 result.success 判断;Lua 执行的错误则在 backendSignal 的 payload.success 里判断,try/catch 兜不住。
三、编辑器导入插件
插件制作完毕之后,编辑器中一键导入即可使用:
打开编辑器插件管理。

点击导入插件。

选择 config.json 导入。

导入后可直接打开使用,也可在二级菜单中打开。


游戏内使用。

四、插件示例
编辑器随包分发了一批示例与工具插件,安装后可从插件菜单直接打开(按 config.json 的 category 归入对应子菜单)。它们是查接口用法最快的参考。
起手模板
一套完整可跑的插件,最少只需要四个文件:
text
<插件目录>/
├── config.json # 配置文件,仅 name 与 entry 必填
├── index.html # 入口页面
├── app.js # 业务脚本
└── style.css # 样式最小步骤:
- 新建插件目录。
- 写 config.json —— 只有 name 和 entry 是必填的,其余字段全有默认值(见 1.2)。
- 写入口 index.html,监听 backendReady 事件后再访问 window.backend(见 1.1)。
- 在编辑器中「导入插件」并选中该 config.json(见第三章)。
示例插件一览
| 插件 | 演示的能力 |
|---|---|
| 编辑器 API 测试示例(帧同步) | runEditorAPI 的常用调用与返回处理,含页面导航、服务端通信等多页演示 |
| 编辑器 API 测试示例(SE) | 状态同步模式下的同类示例 |
| SE 运行时场景树 | 懒加载读取节点层级、展示单位基础属性(需试玩) |
| 物理热点走查工具 | 分析场景物理面数热点与 AABB 异常 |
