只需使用: .editor_script
扩展名的 Lua 脚本就可以创建自定义菜单项和编辑器生命周期回调. 使用这种方法, 你可以调整编辑器创建适合自己的开发流.
编辑器脚本运行于编辑器中, 在一个Java虚拟机下的Lua虚拟机下运行. 所有脚本共享一个环境, 也就是说它们能彼此访问. 你可以导入Lua模块, 就像 .script
文件一样, 但是编辑器内lua运行版本不同, 所以要注意代码兼容性. 编辑器使用 Lua 版本 5.2.x, 具体来说就是 luaj 运行时, 目前只有这个运行时能运行在Java虚拟机下. 除了这些, 还有一些限制:
debug
和 coroutine
包;os.execute
— 我们在 actions 部分提供了更有效安全的方法;os.tmpname
和 io.tmpfile
— 目前编辑器可存取文件仅限于项目文件夹内的文件;os.rename
, 以后可能加入;os.exit
和 os.setlocale
.用编辑器脚本定义的编辑器扩展会在打开项目时加载. 获取依赖库时, 扩展会重新加载, 因为依赖库里有可能有扩展脚本存在. 重新加载时, 不会改变当前扩展脚本, 因为此时也许你正在编辑它们. 要完全重新加载, 可以使用 Project → Reload 编辑器命令.
.editor_script
构成
每个编辑器脚本需要返回一个模块, 如下:
local M = {}
function M.get_commands()
-- TODO - 定义编辑器命令
end
function M.get_language_servers()
-- TODO - 定义语言服务器
end
return M
然后编辑器会收集项目中和共享库里的所有的编辑器脚本, 把它们加载到Lua虚拟机中并在需要的时候调用它们 (详情请见 commands 和 lifecycle hooks 部分).
可以使用API中 editor
包与编辑器进行交互:
editor.platform
—字符串, 在Windows上是 "x86_64-win32"
, 在macOS上是 "x86_64-macos"
, 在Linux上是 "x86_64-linux"
.editor.get(node_id, property)
— 得到编辑器里某些节点的值. 编辑器里的节点是可变实体, 比如脚本或者集合文件, 集合中的游戏对象, 作为资源加载的 json 文件, 等等. node_id
是由编辑器发往编辑器脚本的一个 userdata. 或者, 可以用资源路径代替节点 id, 比如 "/main/game.script"
. property
是一个字符串. 目前支持以下属性:
"path"
— 基于项目文件夹对 resources 的相对路径 — 资源即代表文件. 有效值举例: "/main/game.script"
"text"
— 可编辑文本资源文件 (比如脚本文件或者 json). 有效值举例: "function init(self)\nend"
. 注意这里跟用 io.open()
读取文件不同, 文本资源可以只编辑不保存, 这些编辑仅在访问 "text"
属性时有效.注意这些属性有的不是只读的, 而且基于上下文有些可能不可用, 所以要在读取之前执行 editor.can_get
, 设置之前执行 editor.can_set
. 属性面板里用鼠标悬停在属性名上会显示一个信息提示标明该属性在编辑器脚本里是如何命名的. 资源属性赋值为 ""
代表 nil 值.
editor.can_get(node_id, property)
— 检查属性是否可读, 确保 editor.get()
不会报错editor.can_set(node_id, property)
— 检查属性是否可写, 确保设置操作不会报错editor.create_directory(resource_path)
— 新建文件夹, 及其所有父文件夹editor.delete_directory(resource_path)
— 删除文件夹, 及其所有子文件夹和文件.如果编辑器脚本模块定义了 get_commands
函数, 它会在扩展重载时被调用, 返回的命令可以在编辑器菜单栏或者资源和大纲视图的右键菜单里使用. 例如:
local M = {}
function M.get_commands()
return {
{
label = "Remove Comments",
locations = {"Edit", "Assets"},
query = {
selection = {type = "resource", cardinality = "one"}
},
active = function(opts)
local path = editor.get(opts.selection, "path")
return ends_with(path, ".lua") or ends_with(path, ".script")
end,
run = function(opts)
local text = editor.get(opts.selection, "text")
return {
{
action = "set",
node_id = opts.selection,
property = "text",
value = strip_comments(text)
}
}
end
},
{
label = "Minify JSON"
locations = {"Assets"},
query = {
selection = {type = "resource", cardinality = "one"}
},
active = function(opts)
return ends_with(editor.get(opts.selection, "path"), ".json")
end,
run = function(opts)
local path = editor.get(opts.selection, "path")
return {
{
action = "shell",
command = {"./scripts/minify-json.sh", path:sub(2)}
}
}
end
}
}
end
return M
编辑器需要 get_commands()
返回一组 table, 每个 table 描述一个命令. 命令描述由以下部分组成:
label
(必要) — 显示在菜单栏项上的文字locations
(必要) — 包含 "Edit"
, "View"
, "Assets"
或者 "Outline"
的数组, 描述该命令在哪里生效. "Edit"
和 "View"
表示菜单栏最高层, "Assets"
表示在资源视图右键菜单里, “Outline”` 表示在大纲视图右键菜单里.query
— 命令向编辑器查询信息并定义被操作数据的地方. 在 query
表里的每个键都会一一对应包裹在 opts
表里, 作为参数传给 active
和 run
回调函数. 支持的 key 有:
selection
意思是在选择了什么时可用, 操作将作用于被选择的东西上.
type
命令能作用于选择节点的类型, 目前支持以下几种:
"resource"
— 大纲视图或者资源视图里, 被选择资源对应的文件. 在菜单栏 (Edit 或 View), 资源是当前打开了的文件;"outline"
— 在大纲视图显示的东西. 在大纲视图被选择的项, 在菜单栏是当前打开了的文件;cardinality
定义备选项的个数. 如果是 "one"
, 将传给命令回调一个节点 id. 如果是 "many"
, 将传给命令回调一个数组, 包含一个或多个节点 id.active
- 检测命令是否可用的回调, 返回布尔值. 如果 locations
包含 "Assets"
或 "Outline"
, active
会在显示右键菜单时被调用. 如果包含 "Edit"
或 "View"
, 它会在每个用户交互时被调用, 比如按键盘或者点鼠标的时候, 所以 active
应该快速执行完毕.run
- 用户点选菜单项时运行的回调, 返回包含 actions 的数组.行为是描述编辑器要做什么的表. 每个行为包含一个 action
键. 行为有两种: 可撤销行为和不可撤销行为.
可撤销行为在执行后可以撤销. 如果一个命令返回了多个可撤销行为, 它们会一起执行, 撤销时也一起被撤销. 应尽量使用可撤销行为. 只是可撤销行为有更多一些限制.
目前的可撤销行为有:
"set"
— 设置编辑器里一个节点的属性为指定值. 例如:
{
action = "set",
node_id = opts.selection,
property = "text",
value = "current time is " .. os.date()
}
"set"
行为有如下键:
node_id
— 表示节点 id 的 userdata. 或者, 可以用资源路径代替编辑器发来的节点 id, 例如 "/main/game.script"
;property
— 要设置的节点属性, 目前只支持 "text"
;value
— 给节点属性设置的新值. 对于 "text"
属性来说该值应该是一个字符串.不可撤销行为会清空可撤销历史, 所以要撤销这种行为, 必须使用其他特殊方法, 比如版本控制系统.
目前可用的不可撤销行为:
"shell"
— 执行一个 shell 脚本. 例如:
{
action = "shell",
command = {
"./scripts/minify-json.sh",
editor.get(opts.selection, "path"):sub(2) -- trim leading "/"
}
}
"shell"
行为要有一个 command
键, 它是一组命令连同其参数. 它与 os.execute
主要区别在于, 鉴于它是一种潜在危险操作, 编辑器会弹出一个对话框询问用户是否确认进行此操作. 用户允许的每个命令授权都会被记住.
可以混用可撤销行为和不可撤销行为. 行为是依次执行的, 根据执行顺序撤销操作会停在不可撤销行为上.
除了从函数返回行为, 还可以直接用 io.open()
读写文件. 这会触发资源重载并且清空撤销历史记录.
有一个特殊的编辑器脚本文件: hooks.editor_script
, 位于项目根目录, 就是跟 game.project 并存于同一目录. 只有这个编辑器脚本会从编辑器获得生命周期事件. 脚本文件举例:
local M = {}
function M.on_build_started(opts)
local file = io.open("assets/build.json", "w")
file:write("{\"build_time\": \"".. os.date() .."\"}")
file:close()
end
return M
我们决定将生命周期事件只发给这个文件, 这里构建事件顺序比加入构建步骤容易度更重要. 命令互相独立, 所以它们在菜单里的次序并不重要, 用户回选择需要的命令来执行. 编译事件也可以发给多个脚本, 但这会产生一个问题: 事件顺序是什么样的? 你可能希望压缩资源后检查校验和… 用单一文件通过每步的函数配置好构建步骤不失为一种解决方案.
生命周期函数可以返回行为或者在项目文件夹的文件里写入数据.
目前的生命周期脚本 /hooks.editor_script
可以指定:
on_build_started(opts)
— 游戏开始构建到本地或某远程设备上时执行. 你的更改, 不论是返回行为还是更新文件内容, 都会反应在构建好的游戏中. 在这里抛出错误的话会导致构建终止. opts
是包含如下 key 的表:
platform
— %arch%-%os%
格式的字符串, 描述了构建的目标平台, 目前其值与 editor.platform
中的值相同.on_build_finished(opts)
— 构建完成时执行, 无论构建成功与否. opts
是包含如下 key 的表:
platform
— 与 on_build_started
中的值相同success
— 构建是否成功, 其值为 true
或 false
on_bundle_started(opts)
— 当游戏打包或生成 HTML5 游戏版本时执行. 像 on_build_started
一样, 这里做出的更改会反应在打包好的游戏中, 抛出错误的话会导致打包终止. opts
包含如下 key:
output_directory
— 指定打包输出的文件路径, 比如 "/path/to/project/build/default/__htmlLaunchDir"
platform
— 打包的目标平台. 支持的平台值详见 Bob 教程.variant
— 打包变体, 可以是 "debug"
, "release"
或 "headless"
on_bundle_finished(opts)
— 打包完成时执行, 无论打包成功与否. opts
与 on_bundle_started
里的 opts
相同, 加上 success
键代表打包是否成功.on_target_launched(opts)
— 游戏成功启动时执行. opts
包含一个 url
键指定已启动引擎的服务地址, 例如, "http://127.0.0.1:35405"
on_target_terminated(opts)
— 已启动的游戏关闭时执行, 参数与 on_target_launched
相同.注意目前生命周期处理脚本只是编辑器特性, 使用 Bob 以命令行编译打包时该脚本不会被执行.
可以为他人发布包含命令的库, 编辑器会自动配置它们. 事件处理脚本除外, 因为它要放在项目根目录, 而库则是解压在子目录里. 这是为了在构建处理时提供更多控制权: 可以在 .lua
文件里提供简单的事件处理函数, 库用户则可以在他们的 /hooks.editor_script
文件里引入并使用它们.
还要注意虽然依赖库显示在资源视窗里, 它们却不是文件 (而是 zip 包), 所以目前没办法从依赖库里执行 shell 脚本. 如果实在要执行, 需要先用 editor.get()
读取脚本, 然后用 file:write()
写入脚本文件, 比如写到 build/editor-scripts/your-extension-name
目录下.
更简单的办法是使用原生扩展插件系统.
首先在库目录创建 ext.manifest
文件, 然后在 ext.manifest
文件所在文件夹里创建 plugins/bin/${platform}
. 该文件夹下的文件会被自动提取到 /build/plugins/${extension-path}/plugins/bin/${platform}
目录下, 可以在编辑器脚本中引用它们.
编辑器支持 Language Server Protocol 的小子集. 我们计划以后全面支持 LSP 特性, 但是目前只支持显示编辑文件的代码审查 (比如 lints).
要定义 language server, 需要设置编辑器脚本的 get_language_servers
函数如下:
function M.get_language_servers()
local command = 'build/plugins/my-ext/plugins/bin/' .. editor.platform .. '/lua-lsp'
if editor.platform == 'x86_64-win32' then
command = command .. '.exe'
end
return {
{
languages = {'lua'},
watched_files = {
{ pattern = '**/.luacheckrc' }
},
command = {command, '--stdio'}
}
}
end
编辑器会使用指定 command
启动 language server, 使用服务器进程的标准输入和输出进行通信.
Language server 定义表可以指定:
Did you spot an error or do you have a suggestion? Please let us know on GitHub!
GITHUB