技术博客

@tool 脚本与编辑器内执行:同一份代码跑在两个进程模型里

第 83 / 149 篇 · 2025-05-28 · godot · GDScript编辑器工具脚本

Godot 编辑器有一个常被低估的事实:它本身就是一个 Godot 程序。编辑器界面是 editor/ 下的 Control 场景树,你的游戏场景作为它的子树被打开。这不是比喻——EditorNode 是一个节点,你的场景挂在它的 SceneTree 里。@tool 脚本的全部魔法和麻烦都来自这个事实:它和编辑器运行在同一个进程、同一棵场景树里。

tool 标记到底改变了什么

从语法看 @tool 只是个注解,它的实际作用链条是:parser 把它记成脚本的一个标志位,GDScript 对象上对应 is_tool() 查询。关键的判断在实例化许可处(gdscript.cpp,4.2):

// modules/gdscript/gdscript.cpp, 4.2(删减)
bool GDScript::can_instance() const {
#ifdef TOOLS_ENABLED
    return valid && (tool || !Engine::get_singleton()->is_editor_hint());
#else
    return valid;
#endif
}

这段代码值得逐字读。非编辑器构建(导出的游戏)里 can_instance 只看脚本是否有效,tool 标志完全不存在——@tool 对发布包零影响,纯粹是编辑器期的概念。编辑器构建里则多一个条件:如果当前处于编辑器模式(is_editor_hint() 为真,即你正打开着场景编辑)且脚本不是 tool,实例化被拒绝——节点照常显示,但没有脚本实例挂在上面,_ready、_process、导出的属性全都不激活。

这个设计的用意是把"游戏逻辑"和"编辑器逻辑"在同一场景里强行分层:普通脚本是游戏逻辑,只在运行(is_editor_hint() == false)时活;tool 脚本声明自己"我知道自己在编辑器里跑",两边都活。Engine.is_editor_hint() 就是这个分层的运行时开关,脚本里查它来判断自己当前活在哪个世界。

同一个 SceneTree,两套运行上下文

tool 脚本在编辑器里跑起来之后,和游戏运行的差别不在机制而在环境参数:

维度 编辑器内(tool) 游戏运行
is_editor_hint() true false
SceneTree 根 EditorNode 的场景 你的主场景
物理/音频服务器 在跑(编辑器预览需要) 在跑
脚本保存时 重新编译 + 重建实例 不存在
崩溃后果 整个编辑器退出 只死游戏进程

最后一行是 tool 脚本最让人肉疼的性质:没有崩溃隔离。你的 tool 脚本和编辑器共享一个 C++ 进程、一个场景树、一套单例。tool 脚本里数组越界,如果是 GDScript 越界会被 VM 拦住报运行时错误;但 tool 脚本调 call_deferred 触发的回调链、或者 GDExtension 写的 tool 类里一个裸指针错误,崩溃的是编辑器进程——你半小时没保存的编辑器状态一起陪葬。

Godot 对此的官方对策是 safe mode:命令行 --safe-mode 启动编辑器会跳过所有 tool 脚本的实例化(等效于把 can_instance 的 tool 分支临时关掉),让你能打开一个"带毒的工程"把问题脚本删掉或修掉再正常启动。这个模式的存在本身就是证据:引擎开发者承认同进程执行 tool 脚本是不可隔离的风险,只能给逃生通道。

保存即重载:tool 脚本的特殊生命周期

编辑器里改 tool 脚本按下 Ctrl+S 时发生的事情,比游戏运行时的任何路径都复杂:ResourceFormatSaverGDScript::save() 落盘之后,如果项目设置"保存时重新加载脚本"开着(默认开),GDScriptLanguage::reload_tool_script() 会被调起——旧脚本实例上的导出变量值被尽量保住(按属性名对拷),函数体整个换成新编译的,节点上的脚本实例重建。

"尽量保住"是个重要的限定。函数里闭包捕获的局部状态、static 变量的值、await 挂起的协程,这些不在属性保值范围内。依赖它们的 tool 逻辑在重载后会表现为静默的状态归零——表现就是"改了代码之后插件行为怪了,重开场景又好了"。这不是 bug,是重载语义的定义:保值范围限定在 @export 能表达的东西。

顺带一提,@static_unload(4.2 引入)处理的是另一种泄漏:静态变量在脚本重载后旧值该归谁。默认行为是静态变量随旧脚本字节码一起被持有到所有引用消失,长期编辑会话里可能堆积;标注 @static_unload 等于声明"重载时清零我的静态状态"。

tool 脚本在写什么:三个典型场景

理解了机制,tool 脚本的正当用途就清晰了,它们全是"需要编辑器环境"的活:

  1. 可视化辅助:自定义节点的 gizmo 辅助绘制、在视口里画辅助线。这类代码读 Engine.is_editor_hint() 判断要不要画,几乎无状态,是最安全的 tool 用法。
  2. 批量场景修改器:遍历场景树改导出参数、对齐节点。这类脚本改变了场景数据,意味着 Ctrl+S 会把修改写进 .tscn——是把双刃剑,方便也危险,误跑一遍可能就批量改坏了几百个节点。配合编辑器插件体系的 UndoRedo(大纲十-8 那篇的主角)才是正规军做法,裸 tool 脚本改场景是民兵。
  3. 编辑器内预览逻辑:游戏里由 GameManager 驱动的时间系统,想在编辑器里看白天黑夜变化。注意这条路径会让 _process 在编辑器每帧都跑——编辑器里拖个 gizmo 都触发你的逻辑,写的重了整个编辑器变卡。规则只有一条:tool 脚本的 _process 里干的活,按"每秒跑 60 次且用户没法关"的标准来要求自己。

@tool 脚本的两个运行世界:同一进程,不同上下文 编辑器进程(is_editor_hint = true) SceneTree 根 = EditorNode 的场景 tool 脚本实例化并执行 _process 保存触发 reload_tool_script 重建实例 崩溃 = 整个编辑器退出,无隔离 --safe-mode 跳过全部 tool 实例化 游戏运行(is_editor_hint = false) SceneTree 根 = 你的主场景 全部脚本实例化,tool 标志无影响 无重载:脚本编译一次跑到结束 崩溃只死游戏进程 can_instance 判断见 gdscript.cpp 同一份 .gd 源文件,两套编译产物共享同一份字节码 判别与自保清单 1. 进 _process 先查 Engine.is_editor_hint(),编辑器路径只留可视化 2. 改场景数据的 tool 脚本配 UndoRedo;3. 插件崩过两次就考虑下沉 GDExtension 并加边界检查

本文代码引自 Godot 4.2.2-stable 的 modules/gdscript/gdscript.cpp 与 modules/gdscript/gdscript_editor.cpp,有删减;safe mode 与 @static_unload 行为以 4.2+ 为准。