一个人做游戏,最容易死的不是不会写功能,而是被重复劳动耗死:每次调跳跃参数都要拿尺子量屏幕、每次加一关都要手动摆 40 个道具、每次导入新素材都要手点 30 次导入设置。这些活儿本身不创造乐趣,却吃掉你 70% 的开发时间。Godot 给了一把解决它的钥匙:让脚本在编辑器里跑。今天把 @tool、EditorScript、@export_tool_button 这三件套讲透,它是「一个人干五个人的活」的关键工程手段。

今日速览

  • @tool 让脚本在编辑器里执行:在脚本第一行加 @tool,它就会在编辑器里也跑一遍。代码里用 Engine.is_editor_hint() 分流「编辑器行为」和「游戏行为」。
  • @tool 只适合做「可视化辅助」:官方推荐场景是画大炮的抛物线、画弹簧垫的最大跳跃高度、把用代码绘制的角色在编辑器里画出来——都是帮你做关卡设计的事。
  • 三个必须知道的坑:编辑器里的修改没有撤销/重做;queue_free() 节点可能直接把编辑器搞崩;@tool 脚本引用到的其他 GDScript 也必须是 @tool,否则实例化失败(static 方法、常量、枚举可以例外)。
  • EditorScript 是「一次性批处理工具」:@tool extends EditorScript + _run(),脚本编辑器里 File > Run(Ctrl+Shift+X)就跑一次,适合批量改场景/资源。给它加 class_name,还会自动出现在命令面板里。
  • @export_tool_button 把操作变成 inspector 按钮:@export_tool_button("重算", "Reload") var btn = rebuild,点一下就执行,图标名必须是编辑器内置图标名(区分大小写),不能用自己的图。

核心学习内容

一、@tool 的本质:同一份代码,两种运行环境

Godot 脚本默认只在游戏运行时执行;加 @tool 之后,同一份脚本在编辑器打开场景时也会执行。所以关键不是「怎么写」,而是分流:

@tool
extends Node2D

func _process(delta: float) -> void:
    if Engine.is_editor_hint():
        _draw_preview()      # 只在编辑器里:画辅助线,不消耗玩家性能
    else:
        _update_gameplay(delta)  # 只在游戏里:真正的逻辑

判断口诀:没包在任何 is_editor_hint() 判断里的代码,编辑器和游戏里都会跑。这句最容易被忽略,也最容易让「本该只在游戏里跑的逻辑」在编辑器里偷偷改你的场景数据。

官方明确说过:@tool 脚本能访问当前编辑场景的节点树,编辑器对它没有任何保护。所以第一条纪律是——用 @tool 做「只读的可视化」,不要用它自动改写场景内容;要改内容,用 EditorScript(下面讲),因为那是你主动按下去的一次性动作。

一个立刻能用的例子(官方文档同款思路,我换成了平台跳跃项目最需要的):

@tool
extends Node2D
## 把「跳跃能跳多高」画在场景里,调参数不用再开游戏试

@export var jump_velocity: float = -420.0
@export var gravity: float = 1200.0
@export var samples: int = 24

func _process(_delta: float) -> void:
    if Engine.is_editor_hint():
        queue_redraw()

func _draw() -> void:
    if not Engine.is_editor_hint():
        return
    var t_total := absf(jump_velocity) / gravity * 2.0
    var prev := Vector2.ZERO
    for i in samples + 1:
        var t := t_total * float(i) / float(samples)
        var p := Vector2(120.0 * t, jump_velocity * t + 0.5 * gravity * t * t)
        if i > 0:
            draw_line(prev, p, Color(0.3, 0.9, 1.0, 0.8), 2.0)
        prev = p
    draw_line(Vector2.ZERO, Vector2(0, prev.y), Color(1, 0.4, 0.4, 0.5), 1.0)

改 jump_velocity,编辑器里那条弧线实时变形——这比「改数字 → 运行 → 跳 → 关掉 → 再改」快一个数量级。

二、@tool 的五个坑(踩过就记住了)

  1. 无撤销:工具脚本对场景的修改是永久的,Ctrl+Z 救不回来。动手前先 git commit,或者干脆别让工具脚本写场景。
  2. queue_free() 会崩编辑器:在编辑器运行期间释放节点,可能直接崩。工具脚本里别删节点。
  3. 依赖脚本也得是 @tool:@tool 脚本 preload 或 new() 一个非 tool 脚本会失败——除非你只调用它的 static 方法/常量/枚举。这是个高频报错来源。
  4. 继承不会自动继承 @tool:子类也必须写 @tool,否则父类的工具能力在子类上直接失效。
  5. 改了导出变量,面板不刷新:从脚本里改 @export 变量后,要手动 notify_property_list_changed(),否则 inspector 显示的还是旧值。

另外两个实用细节:@export 的 Array/Dictionary 在 inspector 里被改动时,setter 会被调用(运行时不会),正好拿来「改完自动重算」;以及工具脚本不能打断点调试,要么用 print,要么开一个新编辑器实例(Debug → Customize Run Instances,Main Run Args 填 --editor)来调试。

三、EditorScript:按一下就跑的批处理

@tool 是「常驻」,EditorScript 是「一次性」。它继承 RefCounted,实现 _run(),在脚本编辑器里 File > Run 或 Ctrl+Shift+X 执行:

@tool
extends EditorScript
## 把当前场景里所有名字含 "Enemy" 的节点按 X 坐标排序重命名

func _run() -> void:
    var root := EditorInterface.get_edited_scene_root()
    if root == null:
        printerr("没有打开的场景"); return
    var targets: Array[Node] = []
    for n in root.find_children("*", "", true, false):
        if n.name.begins_with("Enemy"):
            targets.append(n)
    targets.sort_custom(func(a, b): return a.global_position.x < b.global_position.x)
    for i in targets.size():
        print("%d -> %s" % [i, targets[i].name])
    print("共 %d 个,检查无误后再执行重命名" % targets.size())

注意我故意只打印、不改——这是批处理工具的黄金法则:先 dry-run 打印,确认后再改成真正写入。想让它出现在命令面板(Ctrl+Shift+P)里,给脚本加个 class_name 即可。

四、@export_tool_button:把工具塞进属性面板

Godot 4.4+ 起,可以导出一个按钮,把「重算/重建/校验」这类操作直接放到 inspector 上,不用记快捷键:

@tool
extends Node

@export_tool_button("重算关卡统计", "Reload") var recalc = _recalc

func _recalc() -> void:
    var count := get_tree().get_nodes_in_group("props").size()
    print("场景内道具数:%d" % count)

图标名必须是 Godot 源码 editor/icons 里的文件名(如 Reload、Node2D、Callable),区分大小写,也只能用内置图标——这个限制值得记住,别浪费时间去查「怎么用自己的 png」。

🎯 DeepSeek 提示词

提示词 1 · 生成「关卡可视化」工具脚本

适用场景:想让某个数值/范围/轨迹在编辑器里直接看见(跳跃高度、炮台射程、巡逻路线、光照半径)。

你是 Godot 4.4+ 的 GDScript 工具脚本专家,熟悉 @tool 与 Engine.is_editor_hint() 的语义。

【任务】写一个 @tool 脚本,把下面这个游戏参数的「可视化辅助」画在编辑器里。
游戏参数:{例如:角色的跳跃初速度与重力;或炮台的射程与弹道;或敌人的视野扇形}
挂载节点类型:{Node2D / CollisionShape2D ...}
需要在编辑器里看到:{例如:跳跃最高点的水平线与抛物线轨迹;扇形视野边界;射程圆}

【要求】
1. 第一行必须是 @tool,并用 extends 指定挂载节点类型;
2. 所有绘制逻辑必须包在 if Engine.is_editor_hint(): 里,游戏运行时不得产生任何绘制开销;
3. 通过 @export 暴露可调参数(带类型和默认值),参数变化后能实时刷新(用 setter 里调 queue_redraw());
4. 只读:绝对不要修改场景里任何节点的属性,不要调用 queue_free()、不要 add_child();
5. 用 _draw() + draw_line/draw_circle/draw_arc 实现,颜色用半透明,避免挡住美术;
6. 中文注释说明每段在干什么,注释解释「为什么」而不是复述代码。

【输出格式】一个完整 .gd 文件内容 + 3 行使用说明(怎么挂、调哪个参数、注意什么)。

提示词 2 · 生成「批处理」EditorScript

适用场景:批量重命名、批量改导入设置、批量给场景加节点、批量生成 .tres 数据文件。

你是 Godot 4 的 EditorScript 专家。EditorScript 是继承 RefCounted 的一次性工具,
必须 @tool + extends EditorScript + 实现 _run(),通过 File > Run 执行。

【任务】写一个 EditorScript,完成:{例如:遍历 res://levels/ 下所有 .tscn,把其中名为 "Spawn" 的 Marker2D 的 position 四舍五入到 8 的整数倍}

【硬性要求】
1. 第一行 @tool,extends EditorScript,只实现 _run();
2. **必须先 dry-run**:第一版只统计并 print 出「将要改动哪些文件/节点、改动前后的值」,不写入;
   用一个常量 const APPLY := false 控制,注释说明改成 true 才真正写入;
3. 遍历用 DirAccess + ResourceLoader,路径统一用 "res://" 开头;跳过 .godot/ 与 addons/ 目录;
4. 每个文件处理前检查 load() 是否返回 null,失败就 printerr 并 continue,不要中断整批;
5. 保存场景用 PackedScene.pack(root) + ResourceSaver.save(),保存前 print 完整路径;
6. 结束时打印汇总:扫描 N 个、命中 M 个、失败 K 个;
7. 不要动 EditorInterface 的撤销栈(EditorScript 不保证 undo),改为提示用户「先 git commit」。

【输出格式】完整 .gd 文件 + 「运行步骤」4 步 + 「风险提示」2 条。

提示词 3 · 让 DeepSeek 审查工具脚本的安全性

适用场景:DeepSeek 给了你一个 @tool 脚本,运行前先过一遍安检(工具脚本的 bug 代价是崩编辑器 + 丢场景)。

你是 Godot 4 工具脚本(@tool / EditorScript)的安全审查员。以下是待审查脚本:

```gdscript
{粘贴脚本}

请按下面清单逐项检查,每项给出「通过 / 风险 / 不适用」+ 行号 + 一句理由:

  1. 所有仅在编辑器中执行的逻辑,是否都包在 Engine.is_editor_hint() 里?有没有「本意只在游戏里跑」却被放进公共区的代码?
  2. 是否存在会直接崩编辑器的操作:queue_free()、free()、删除/重设正在被引用的节点?
  3. 是否存在对场景的隐式写入(改 position/rotation/属性)而没有用户明确触发?若有,建议改成只读可视化。
  4. 脚本引用到的其它自定义脚本是否也带 @tool?不带的,是否只用了 static 方法/常量/枚举?
  5. 子类是否重复声明了 @tool?(继承不自动传递)
  6. 从代码里修改 @export 变量后,是否调用 notify_property_list_changed()?
  7. 是否可能在编辑器里进入死循环或在 _process 里做重活(每帧 load()、find_children() 全树扫描)?
  8. 若脚本会保存资源,是否打印了目标路径并有 dry-run 开关?

最后输出:必须修(会导致崩溃/丢数据)/ 建议修(可维护性)/ 可以放着,每类最多 3 条,每条给出可直接替换的代码片段。


## 学习心得

今天这三件套改变的不是「代码能力」,而是**开发速度的斜率**。前 64 天我们一直在给游戏加「玩家看得见的东西」;今天加的是「只有我看得见的东西」——但正是它决定了你能不能在 3 个月里做出一个能上架 Steam 的体量。

对「用 DeepSeek 做游戏」这件事,今天还有个额外收获:**工具脚本是最适合交给 AI 的一类代码**。原因有三:它不进入最终构建(写错了不影响玩家)、它有明确的官方约束(`@tool` + `is_editor_hint` + 只读)可以写进提示词、它的正确性可以用「我在编辑器里点一下看到了什么」立刻验证。相比让 AI 写战斗逻辑(错了要玩十遍才发现),让 AI 写工具脚本是**投入产出比最高**的用法。

顺便一句实话:不要一上来就写 `EditorPlugin`。官方也说,先 `EditorScript` 解决一次性问题,真需要常驻面板/菜单了再升级成插件。**工具是用来省时间的,别把省下来的时间又花在造工具上。**

## 明日关注

- [ ] 把「跳跃弧线可视化」真的挂到自己项目的主角上,调 3 组参数对比手感(这是最便宜的关卡手感调优手段)。
- [ ] 写一个 `const APPLY := false` 的 dry-run EditorScript,先跑统计不改数据,养成批处理先看后改的习惯。
- [ ] 读一遍 `@export_tool_button` 与 `notify_property_list_changed()` 的类文档,把下一个工具直接做成 inspector 按钮。

*本文由自动化学习流水线每日生成,坚持即学即记。*