Skip to content

蛋仔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空值合并 ??
JSPromise.allSettled
JSglobalThis
JSString.replaceAll
JSArray.at()
JSstructuredClone
HTMLinert 属性
HTMLpopover 属性
CSSgap(Flex)
CSSclamp()
CSS:is() / :where()
CSS:has()
CSSaspect-ratio
CSSContainer Queries
CSSCSS Nesting
CSS@property
Web APIFileSystem Access
Web APINavigation API
Web APIScreen Wake Lock
Web APITrusted Types

建议

  1. CSS 可以使用 -webkit- 前缀确保 Chrome 77 兼容。
  2. 在 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 配置文件,编辑器通过它发现和加载插件。

必填字段
字段类型说明
namestring插件名称,用于菜单项显示和标识
entrystring入口 HTML 文件的相对路径(相对于 config.json 所在目录)

⚠️ 缺少任意必填字段会导致插件加载失败并被跳过。

选填字段与默认值
字段类型默认值说明
descriptionstring""插件简要描述
categorystring"default"一级菜单分类,相同 category 的插件归入同一子菜单
ui.window_titlestring取 name 的值窗口标题
ui.is_dockedbooleanfalse是否 dock 模式(false = 独立窗口)
ui.size[int, int][1024, 768]初始窗口尺寸 [width, height]
versionstring⚠️ 已废弃。插件版本由插件商店的版本号承担,加载流程忽略本字段的值;存量插件保留该字段不会报错,新插件不必再写
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转换说明
stringStr/StringUnicode 编码处理
numberInt/Int32整数转换
numberFloat/Fixed浮点数/定点数转换
booleanBool布尔值转换
ArrayList/ListXXX数组类型转换
ObjectDict字典对象转换
[x, y, z] / (x, y, z)Vector3/Point33D 坐标转换
null / undefinedNone空值处理

注意

前端无需关心具体的引擎类型,只需传基础数据类型即可。

后端会根据 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_namestring已注册的插件 API 方法名
arg_listArray参数列表,类型转换规则同 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}) 推送
参数
参数类型说明
methodstring前端自定义的方法标识,用于请求-响应关联;原样回填到 backendSignal payload 的 method 字段。同一时刻不要复用同一个 method,否则结果会串
codestringLua 源码字符串。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' }
字段类型说明
methodstring回填调用时传入的 method,前端据此关联本次请求
successbooleantrue 表示 chunk 跑通,false 表示出错
returnsArray仅成功时存在。chunk return 的全部值,按位排列;nil 表现为 null;嵌套 table 透传为嵌套 dict / list
errorstring仅失败时存在。编译失败时恒为 compile error,不含编译器详细信息;运行时错为含 stack traceback 的字符串;环境/VM 错为对应 message
错误情况
错误信息原因
editor_context not availableGlobals.editor_context 未初始化
editor_context.plugin_manager not available编辑器插件管理器未就绪
execute returned empty resultLua 端 plugin_execute 返回空(理论不应发生)
compile errorLua 源码语法错(load 失败)。该串固定,不含编译器详细信息
[string "=execute"]:1: ... stack traceback: ...Lua chunk 内 error(...) 抛出 / API 调用失败
unknown errorchunk 内抛错但 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 availablevisual_layer 已初始化但 blackbox 未就绪
lua_mgr not availableblackbox.lua_mgr 未注入
execute returned empty resultLua 端 sandbox_execute 返回空(理论不应发生)
compile errorLua 源码语法错。该串固定,不含编译器详细信息
[string "=execute"]:1: ... stack traceback: ...chunk 内 error / 沙盒 API 失败(命中 api_desc 白名单等)
unknown errorchunk 内抛错但 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):
    ...
参数
参数类型说明
pathstringLua 脚本文件的绝对路径
返回格式

返回字符串(非字典):

返回值含义
"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 列表:

methoddata 字段含义
frameReadydata: string预览帧,格式为 data:image/jpeg;base64,<...>;可直接赋给 img 的 src
selectionChangedselected_ids: number[]选中变化;当前选中的 unit id 列表,空选时为 []
unitAddedinstance_id: number, preset_id: string | null, parent_id: number | null组件创建;instance_id = unit_id,preset_id = unit.unit_eid,parent_id 为父 unit 或 null
unitRemovedinstance_id: number, parent_id: number | null组件删除;语义同上
propertyChangedinstance_id: number, prop_name: string, old_value: any, new_value: any属性修改;由 view/helper 层插桩触发
undoRedoCompletedaction: 'undo' | 'redo', record:撤销 / 重做完成;普通 do 动作不推送
saveCompletedsuccess: 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 数据,不会出现游戏内原生对象。参考映射:

后端类型前端拿到
dictplain object
listplain array
tuplearray
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 foundAPI 名拼写错误
Not enough arguments / Too many arguments参数数量不匹配
Type conversion failed for parameter xxx参数类型转换失败(参考 2.3 前后端 EType 映射)
API call failed: xxxAPI 内部执行失败

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 === trueresult.resultresult.error
runPluginAPI字典result.success === trueresult.resultresult.error
executeLuaCode信号payload.success === truepayload.returnspayload.error
executeLuaCodeRuntime信号payload.success === truepayload.returnspayload.error
navigateTo字典result.success === trueresult.error
listHtmlFiles字典result.success === trueresult.result(数组)result.error
testConnection字典result.success === trueresult.message
pickDirectory字典result.success === trueresult.result(路径)result.error(取消时为 'cancel select')
listDirectoryFiles字典result.success === trueresult.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 兜不住。

三、编辑器导入插件

插件制作完毕之后,编辑器中一键导入即可使用:

  1. 打开编辑器插件管理。

  2. 点击导入插件。

  3. 选择 config.json 导入。

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

  5. 游戏内使用。

四、插件示例

编辑器随包分发了一批示例与工具插件,安装后可从插件菜单直接打开(按 config.json 的 category 归入对应子菜单)。它们是查接口用法最快的参考。

起手模板

一套完整可跑的插件,最少只需要四个文件:

text
<插件目录>/
├── config.json     # 配置文件,仅 name 与 entry 必填
├── index.html      # 入口页面
├── app.js          # 业务脚本
└── style.css       # 样式

最小步骤:

  1. 新建插件目录。
  2. 写 config.json —— 只有 name 和 entry 是必填的,其余字段全有默认值(见 1.2)。
  3. 写入口 index.html,监听 backendReady 事件后再访问 window.backend(见 1.1)。
  4. 在编辑器中「导入插件」并选中该 config.json(见第三章)。

示例插件一览

插件演示的能力
编辑器 API 测试示例(帧同步)runEditorAPI 的常用调用与返回处理,含页面导航、服务端通信等多页演示
编辑器 API 测试示例(SE)状态同步模式下的同类示例
SE 运行时场景树懒加载读取节点层级、展示单位基础属性(需试玩)
物理热点走查工具分析场景物理面数热点与 AABB 异常