Defold 编辑器扩展与工具链开发

从编辑器扩展的能力边界讲起,覆盖 editor 目录约定、editor.script 的模块结构与 get_commands、hooks.editor_script 生命周期钩子、editor.get 与 editor.tx 事务读写、editor.ui 自定义面板,以及批量重命名、图集重建等实战场景和与 bob.jar 的配合。

引言

Defold 的编辑器不是黑盒。项目里放一个 editor/ 目录、写几个 .editor_script 文件,就能给编辑器加菜单项、加右键命令、批量改资源,甚至挂上构建前后的钩子。批量重命名、图集重建、资源校验、配置导表,都能从「手工点几十次」变成「点一次菜单」。本文覆盖目录约定、命令与生命周期、editor.get 与 editor.tx 事务、editor.ui 面板、常用场景,最后讲怎么和 bob.jar 命令行构建配合。

前置阅读:资源管线与项目结构 、原生扩展的构建与依赖解析 。


目录


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钩子不参与 bobCI 用 --settings 覆盖

一句话记忆:把重复十次以上的编辑动作写成 editor/ 下的 .editor_script,用 get_commands 挂菜单、editor.get 读、editor.transact 写;构建期的确定性动作放进 hooks.editor_script,但记得 bob.jar 不会跑它,CI 要另走 --settings。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化