引言
Defold 的编辑器不是黑盒。项目里放一个 editor/ 目录、写几个 .editor_script 文件,就能给编辑器加菜单项、加右键命令、批量改资源,甚至挂上构建前后的钩子。批量重命名、图集重建、资源校验、配置导表,都能从「手工点几十次」变成「点一次菜单」。本文覆盖目录约定、命令与生命周期、editor.get 与 editor.tx 事务、editor.ui 面板、常用场景,最后讲怎么和 bob.jar 命令行构建配合。
前置阅读:资源管线与项目结构 、原生扩展的构建与依赖解析 。
目录
- 1. 编辑器扩展能做什么
- 2. 目录结构与加载机制
- 3. 命令描述表与完整示例
- 4. 生命周期钩子与执行模式
- 5. editor 读写与事务
- 6. 自定义面板与对话框
- 7. 常用场景实战
- 8. 与 bob 配合及团队约定
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 编辑器扩展能做什么
1. 三类能力
编辑器扩展不是给游戏加功能,而是给开发流程加功能。按作用对象分三类:
| 类别 | 典型能力 | 用到的 API |
|---|---|---|
| 命令类 | 菜单项、右键菜单、批量改文件 | get_commands、editor.transact |
| 面板类 | 自定义对话框、表单、预览 | editor.ui.* |
| 钩子类 | 构建前生成版本号、构建后算校验和 | hooks.editor_script |
三类可以叠加,比如「导出配置表」命令既要出现在右键菜单,又要弹选择导出目标的对话框,还要在导出后刷新资源。
2. 什么值得做成扩展
判断标准很简单:这个操作你是否会重复做十次以上,且每次步骤完全一致。
值得做成扩展:
- 新增敌人:自动创建 go 文件、脚本、图集条目、加入工厂原型
- 批量重命名:assets/enemy_*.png 按规则改成 en_<type>_<idx>.png
- 图集重建:扫描目录下所有 png,按命名规则重建 images 与 animations
- 资源校验:检查每个 .go 是否挂脚本、每个 script 是否有 init
- 导表:csv 转成 lua 常量表并写回项目
不值得:一次性迁移脚本、需要人眼确认的编辑、依赖运行时状态的调试
踩坑:扩展跑在编辑器的 Lua VM 里(基于 JVM 的 luaj,Lua 5.2 语义),和游戏运行时(LuaJIT 2.1)不是同一个 Lua。共享模块要避开语法差异,比如整数除法
//、goto。
2. 目录结构与加载机制
1. 目录约定
扩展文件放在项目根目录的 editor/ 下,扩展名是 .editor_script:
project/
game.project
hooks.editor_script ← 唯一接收生命周期钩子的文件
editor/
rename_tools.editor_script
lib/naming.lua ← 纯 Lua 工具模块,可被 require
2. 模块结构与重载
每个 .editor_script 都要返回一个模块表。编辑器会收集项目和依赖里所有扩展,加载进同一个 Lua VM,所以扩展之间可以互相 require。除 get_commands,模块还可以定义 get_language_servers、get_prefs_schema、get_http_server_routes,用不到就不写。
扩展在打开项目时全部加载,执行 Project ▸ Fetch Libraries 拉库时会重新加载,但这个重载不会拾取你自己改动的代码。要生效得手动执行 Project ▸ Reload Editor Scripts。
3. 命令描述表与完整示例
1. 命令描述表
get_commands 返回一个数组,每个元素描述一条命令:
| 字段 | 必填 | 说明 |
|---|---|---|
| label | 是 | 菜单项显示的文字 |
| locations | 是 | 出现位置 |
| query | 否 | 向编辑器索取上下文,结果注入 opts |
| active | 否 | 返回布尔值,决定命令是否可用 |
| run | 是 | 用户点击后执行的回调 |
| id | 否 | 命令标识,用于持久化偏好 |
locations 取值:
菜单栏:Edit / View / Project / Debug / Help
子菜单:Bundle(Project ▸ Bundle 下)
右键菜单:Assets / Outline / Scene / Code
query 最常用的是 selection,type 可为 resource(Assets 里选中且对应真实文件的项)、outline、scene;cardinality = "one" 时 opts.selection 是单节点,"many" 时是节点数组。
2. 一个完整命令
function M.get_commands()
return {
{
label = "统计选中脚本行数",
locations = {"Assets"},
query = { selection = { type = "resource", cardinality = "many" } },
active = function(opts)
-- active 高频调用,必须足够快
return editor.get(opts.selection[1], "path"):match("%.lua$") ~= nil
end,
run = function(opts)
local text = editor.get(opts.selection[1], "text")
print("行数 " .. select(2, text:gsub("\n", "")))
end
}
}
end
active 有性能约束:菜单栏命令(Edit/View)的 active 会在每一次键盘输入、每一次鼠标点击时被调用,里面不要做重活。Assets/Scene/Outline 的 active 只在弹出右键菜单时调用,宽松得多。
4. 生命周期钩子与执行模式
1. hooks.editor_script
生命周期钩子只能写在根目录的 hooks.editor_script 里,而且只有这一个文件会被调用。这是刻意设计:构建步骤有先后顺序(先压缩再算校验和),钩子分散在多个文件里顺序就无法保证。
-- hooks.editor_script
local M = {}
function M.on_build_started(opts)
-- opts.platform 形如 "x86_64-macos"
local f = io.open("assets/build_info.json", "w")
f:write(string.format('{"built_at": "%s"}', os.date()))
f:close()
end
return M
| 钩子 | 触发时机 | 关键参数 |
|---|---|---|
| on_build_started | 本地构建或 Debug Start 前 | platform |
| on_build_finished | 构建结束(无论成败) | platform、success |
| on_bundle_started | 打包或 Build HTML5 前 | output_directory、platform、variant |
| on_bundle_finished | 打包结束 | 同上 + success |
| on_target_launched | 游戏启动成功 | url(引擎服务地址) |
| on_target_terminated | 游戏关闭 | url |
在 on_build_started / on_bundle_started 里写入的文件会进入构建产物,在这两个钩子里抛错会中止构建。
踩坑:生命周期钩子只在编辑器里生效,
bob.jar从命令行打包时不会执行。版本号注入逻辑写在钩子里,CI 上就失效了——CI 要用--settings覆盖,见第 8 节。
2. 执行模式
编辑器扩展运行时有两种模式:immediate(即时)用于需要在同一帧内拿到结果的场合,命令的 active 回调和扩展脚本的顶层代码都跑在这里;**long-running(长任务)**用于 run 回调这类不需要即时返回的场合。下面这些函数不能在即时上下文里调用,否则报 Cannot use long-running editor function in immediate context:
editor.create_directory() editor.create_resources() editor.delete_directory()
editor.save() editor.execute() editor.transact()
os.remove() file:write()
所以在 active 里只做轻量读取,写操作全部放进 run。
5. editor 读写与事务
1. 读取与能力探测
editor.get(node_id, property) 读取节点属性,node_id 可以是编辑器传给你的 userdata,也可以直接用资源路径字符串:
local path = editor.get("/main/game.script", "path") -- "/main/game.script"
local text = editor.get("/main/game.script", "text") -- 内存中的内容,含未保存的编辑
local kids = editor.get("/main", "children") -- 目录的子资源路径列表
常用属性是 path、text、children、parent。图集有 images 和 animations,图块源有 animations、collision_groups、tile_collision_groups。读取前先探测能不能读,避免抛错:if editor.can_get(node, "text") then ... end。editor.properties(node_id) 返回该节点所有可读属性的排序列表,调试时很有用。
2. 事务与执行外部命令
修改编辑器状态必须走 editor.transact,把若干步骤打包成一次可撤销的操作。步骤由 editor.tx.* 构造:
| 步骤 | 用途 |
|---|---|
| editor.tx.set | 设置属性值 |
| editor.tx.create / delete | 新建 / 删除资源 |
| editor.tx.rename | 重命名 |
| editor.tx.add / remove | 向节点列表属性增删一项 |
| editor.tx.clear / move | 清空列表 / 移动列表项顺序 |
写属性前用 editor.can_set 检查;editor.execute 可以调起外部程序并捕获输出,out = "capture" 让返回值变成标准输出,reload_resources = false 告诉编辑器这次调用没改磁盘,因此整条命令仍然可撤销:
editor.transact({
editor.tx.set(node, "position", {0, 0, 0}),
editor.tx.set(node, "rotation", {0, 0, 0}),
editor.tx.set(node, "scale", {1, 1, 1})
})
run = function(opts)
local text = editor.get(opts.selection, "text")
local formatted = editor.execute("jq", "-n", "--argjson", "data", text, "$data", {
reload_resources = false, out = "capture"
})
editor.transact({ editor.tx.set(opts.selection, "text", formatted) })
end
6. 自定义面板与对话框
1. 对话框与返回值
所有 UI 都在 editor.ui 模块里。组件用一张叫 props 的表配置,组件本身是不可变的 userdata;更新 UI 的方式是用一张新的 props 表替换旧的。对话框用 editor.ui.dialog 描述、editor.ui.show_dialog 显示。按钮用 editor.ui.dialog_button,它没有 on_pressed 回调,而是通过 result 把值返回给 show_dialog。cancel = true 的按钮在按 Esc 或关闭窗口时触发,default = true 的按钮在按 Enter 时触发:
local ok = editor.ui.show_dialog(editor.ui.dialog({
title = "确认重建图集?",
content = editor.ui.paragraph({ text = "将覆盖 atlas 现有的 images 与 animations 配置。" }),
buttons = {
editor.ui.dialog_button({ text = "取消", cancel = true, result = false }),
editor.ui.dialog_button({ text = "重建", default = true, result = true })
}
}))
if ok then rebuild_atlas() end
2. 响应式与 use_state
布局组件 horizontal、vertical、grid 负责摆放;label、heading、paragraph、image 负责展示;string_field、integer_field、number_field、select_box、check_box、button、resource_field、external_file_field 负责输入。界面靠响应式组件更新:用 editor.ui.component 包一个函数,函数接收 props、返回视图;需要局部状态就用 use_state。三条铁律:组件函数必须是纯函数;props 与状态必须不可变(要改就造新表);每次渲染必须以相同顺序调用相同数量的钩子,不要在循环或条件分支里调用。
local dialog = editor.ui.component(function(props)
local name, set_name = editor.ui.use_state("")
return editor.ui.dialog({
title = props.title,
content = editor.ui.vertical({
children = { editor.ui.string_field({ value = name, on_value_changed = set_name }) }
}),
buttons = {
editor.ui.dialog_button({ text = "取消", cancel = true }),
editor.ui.dialog_button({ text = "创建", enabled = name ~= "", result = name })
}
})
end)
local file_name = editor.ui.show_dialog(dialog({ title = "新建资源" }))
7. 常用场景实战
1. 批量重命名与图集重建
重命名先收集完整计划,一次性提交事务,这样命名冲突、目标已存在之类的问题可以在提交前一次性报出来。图集内容可以整块替换:先清空 images 与 animations,再按目录扫描结果重新添加。
run = function(opts)
local dir = editor.get(opts.selection, "path")
local txs = {}
for _, path in ipairs(editor.get(dir, "children")) do
local type_, idx = path:match("enemy_(%a+)_(%d+)%.png$")
if type_ then
local to = dir .. "/" .. string.format("en_%s_%03d.png", type_, tonumber(idx))
txs[#txs + 1] = editor.tx.rename(path, to)
end
end
editor.transact(txs)
end
run = function()
local atlas = "/assets/main.atlas"
local txs = { editor.tx.clear(atlas, "images") }
for _, path in ipairs(editor.get("/assets/sprites", "children")) do
if path:match("%.png$") then
txs[#txs + 1] = editor.tx.add(atlas, "images", { image = path })
end
end
-- 按命名规则把 run_01.png ~ run_04.png 合成动画 run
txs[#txs + 1] = editor.tx.clear(atlas, "animations")
txs[#txs + 1] = editor.tx.add(atlas, "animations", {
id = "run",
images = {
{ image = "/assets/sprites/run_01.png" },
{ image = "/assets/sprites/run_04.png" }
}
})
editor.transact(txs)
end
2. 资源校验与导表
校验类命令不需要事务,只需要读取和报告。导表则把外部工具的输出写回项目,最后用 editor.save() 落盘——它本身是长任务,只能放在 run 里。
run = function()
local gos = {}
walk("/main", gos)
for _, path in ipairs(gos) do
local text = editor.get(path, "text")
if text and not text:find("script", 1, true) then
print("[警告] 没有挂脚本:" .. path)
end
end
-- 导表:调用外部生成器,把结果写回文本资源
local code = editor.execute("python3", "tools/gen_items.py", "design/items.csv", {
reload_resources = false, out = "capture"
})
editor.transact({ editor.tx.set("/main/items.script", "text", code) })
editor.save()
end
8. 与 bob 配合及团队约定
1. 钩子不参与 bob
前面强调过:hooks.editor_script 是编辑器专属的。命令行打包用 bob.jar,它不加载编辑器扩展,也不会调用 on_build_started。所以任何「必须在产物里体现」的步骤,都要在 CI 里显式再跑一遍。正确做法是把逻辑写成普通 Lua 或 shell 脚本,让扩展钩子和 CI 都调用它:
tools/gen_version.lua ← 纯 Lua,编辑器与 CI 共用
tools/validate_assets.lua ← 资源校验,编辑器与 CI 共用
editor/build_tools.editor_script ← 只负责在钩子里 require 上面的脚本
# CI 里等价地跑一遍;版本号用 settings 覆盖,不依赖钩子
lua tools/gen_version.lua ci x86_64-linux
java -jar bob.jar --settings ci.settings --archive --platform x86_64-win32 \
--variant release --bundle-output build/win resolve build bundle
# ci.settings
[project]
version = 1.4.2
钩子负责开发期的便利,CI 负责产物的确定性。
2. 调试、分发与团队约定
调试要点:用 print / pprint 打到 Console(编辑器会自动展开 table);改完代码执行 Project ▸ Reload Editor Scripts,不必重启编辑器;命令不生效先看 active 是否返回 false,菜单项会被灰掉;报 Cannot use long-running editor function in immediate context 说明你把 editor.transact / editor.execute 放进了 active 或顶层。
扩展可以打包成库让别人用。命令放在库里编辑器会自动收集,但钩子不行——钩子必须位于使用方项目根目录,而库只能暴露子目录。所以库应把钩子逻辑写成普通 .lua 函数,让使用方在自己的 hooks.editor_script 里 require 后显式调用。如果库里带了需要执行的外部程序,把它放进库目录下的 plugins/bin/${platform}/,并在库根放一个 ext.manifest,编辑器会把该目录解包到 build/plugins/<扩展路径>/plugins/bin/<平台>/。
editor/<domain>_<verb>.editor_script 命名:领域_动作,如 atlas_rebuild
editor/lib/ 纯 Lua 工具,不依赖 editor.*
hooks.editor_script 只放生命周期钩子,保持极薄
tools/ 与 CI 共用的命令行脚本
三条约定:命令 label 用中文、id 用英文(label 给人看,id 用于持久化偏好);lib/ 里不放 editor.* 调用,这样这些模块能脱离编辑器做单元测试,也能被 CI 复用;一个扩展脚本只干一件事。
踩坑:扩展共享同一个 Lua VM 环境,两个扩展里定义同名全局变量会互相覆盖。所有扩展代码一律用
local,模块用return M暴露;用io.open直接写文件会触发资源重载并清空撤销历史,能用editor.transact表达的改动就走事务。
9. 速查表
| 需求 | 做法 | 备注 |
|---|---|---|
| 注册命令 | M.get_commands() 返回命令数组 | 模块必须 return M |
| 命令出现位置 | locations = {"Assets"} | 菜单栏 Edit/View/Project/Debug/Help |
| 判断可用 | active = function(opts) | 必须快,会高频调用 |
| 读取属性 | editor.get(node, "text") | 先 editor.can_get |
| 写属性 | editor.transact({ editor.tx.set(...) }) | 一次撤销单元 |
| 列表增删 | editor.tx.add / remove / clear | 图集 images/animations |
| 重命名 | editor.tx.rename(from, to) | 先规划再提交 |
| 调外部命令 | editor.execute(..., {out = "capture"}) | 只读加 reload_resources = false |
| 落盘 | editor.save() | 长任务,只能放 run |
| 弹对话框 | editor.ui.show_dialog(editor.ui.dialog{...}) | 按钮用 result 返回值 |
| 构建钩子 | hooks.editor_script 的 on_build_started 等 | 只有根目录这一个文件生效 |
| 钩子与 CI | 钩子不参与 bob | CI 用 --settings 覆盖 |
一句话记忆:把重复十次以上的编辑动作写成 editor/ 下的 .editor_script,用 get_commands 挂菜单、editor.get 读、editor.transact 写;构建期的确定性动作放进 hooks.editor_script,但记得 bob.jar 不会跑它,CI 要另走 --settings。
相关阅读
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。