把 3.x 工程迁到 4.x,官方项目升级器(Project Converter)能自动改掉大部分 API 重命名,但有一批变更不是"换个名字"而是"语义变了",自动转换改不了,编译过了运行还是错。这篇把破坏性变更按坑的深度分四类,每类给迁移时的检查方法。
第一类:机械重命名,升级器全包
占变更量的八成,纯体力活,升级器 --convert-3to4 直接改完:
| 3.x | 4.x | 改名理由 |
|---|---|---|
| Spatial | Node3D | 3D 节点命名统一 Node3D 前缀族 |
| KinematicBody / KinematicBody2D | CharacterBody3D / CharacterBody2D | 名字误导(它从来不是 kinematic body 语义) |
| Position2D / Position3D | Marker2D / Marker3D | 它只是个变换标记 |
| Area | Area3D | 与 Area2D 对齐 |
| OS.window_fullscreen | DisplayServer.window_set_mode | OS god 类拆分 |
| funcref(o, "m") | Callable(o, "m") | Callable 成为一等公民 |
| yield(get_tree(), "idle_frame") | await get_tree().process_frame | 协程机制重写 |
| onready var | @onready var | 注解语法统一 |
这一类迁移完编译直接过,不用人工复核。真正的坑在后面三类。
第二类:语法收紧,编译期报错但语义要重想
信号声明和 emit 改了语法,行为也有变。3.x 的 signal my_signal(x) 加 emit_signal("my_signal", x) 在 4.x 是 signal my_signal(x: int) 加 my_signal.emit(x)。字符串形式的 emit_signal 和 connect 还能用,但新风格是强类型信号对象,类型不匹配在连接时就报错而不是触发时才炸。迁移时顺手把字符串 connect 全换成方法引用形式——btn.pressed.connect(_on_pressed)——一次到位,别再留 "pressed" 字符串,重构改名时字符串不跟着变,是纯地雷。
setget 被属性 setter/getter 替代,且语义不同。3.x 的 setget set_x, get_x 触发时机宽松,4.x 的属性写法:
var health := 100:
set(value):
health = clamp(value, 0, max_health)
set 块里直接写属性名赋值是递归赋值陷阱的修复点——4.x 里 health = ... 在 setter 内部指向底层存储,不会再次触发 setter。但前提是你这么写;迁移时如果机械地把 3.x 的 _set_health 函数体搬进来,里面调 set_health(v) 的旧写法会死循环。检查方法:全文搜 setter 函数名,确认没有自调用。
export 变 @export,同时类型从"导出语句自带类型"变成"类型标注说了算"。3.x 的 export(int) var x 迁移后变成 @export var x: int——顺序反了,类型声明是本体。混着写 @export var x 不给类型,导出的是 Variant,检查器里什么控件都不出现。迁移后扫一遍检查器,凡是显示为空白控件的导出属性都是漏标类型。
第三类:语义变了,编译不报错运行才错
这一类最危险,数量少但个个咬人。
yield → await 不是改名是换机制。 3.x 的 yield 恢复执行靠信号,函数返回值是个 GDScriptFunctionState 对象,你得接住它。4.x 的 await 是协程挂起点,返回值就是协程的最终值。行为差异:3.x 里 yield() 不接返回值时协程照样跑到信号来;4.x 里写了 await 的地方,调用方拿到的是 Callable 状态而不是立刻的结果——如果外层代码把返回当普通值用,拿到的是意外的类型。排查法:搜所有调用"内部有 yield/await 的函数"的点,确认调用方自己也在 await 链上。
Pool*Array 全部改名 Packed*Array 且共享语义变了。3.x 的 PoolArray 赋值是共享底层内存,4.x 的 PackedArray 是写时复制。3.x 代码里如果有"拷贝一个 PoolArray 当快照、原数组改了快照跟着变"的依赖,迁移后行为反转:快照变成真快照。这是少数语义变化方向"修复了 bug 外观"的例子——3.x 的共享行为更像 bug,4.x 的 COW 才对——但如果你的逻辑恰好依赖共享,就是静默故障。
整除和类型转换收紧。 3.x 里 5 / 2 在某些上下文是 2(取决于操作数类型推断),4.x 中 / 对 int 操作数是截断除法返回 int、float 除法必须显式。升级器会给可疑点插警告注释,迁移时把每个警告当 error 处理,别批量忽略。
第四类:API 消失,要找替代路径
3.x 的 Spatial.get_global_transform().origin 直接可用,4.x 推荐 global_position——老路径还在但已标 deprecated。真正消失的是一批 god 类功能拆分:OS 拆出 DisplayServer、Time、ResourceLoader 成为静态类、Engine.get_main_loop() 类型收窄。迁移工作流上,我的做法是升级器跑完后留着 .godot 备份,逐文件打开看脚本编辑器的黄色警告条,4.x 的 deprecation 警告写得比 3.x 用心,基本就是迁移指南本人。
最后一句话:前三类加起来决定了迁移工作量的大头不在重命名而在测试覆盖——语义类变更只在运行时显形,没有自动化测试的 3.x 工程,迁移预算里测试补齐的时间应当和改代码的时间一样多。