mcpField
G
gdscript-advanced

gdscript-advanced

github.com/jame581开发889181 次安装4 天前更新
代码仓库

关于 gdscript-advanced

面向已掌握 GDScript 基础、正在开发正式游戏的 Godot 开发者,聚焦性能、异步、编辑器脚本和动态调用等进阶实践,并说明易导致运行时问题的细节。

主要功能

  1. 01性能惯用法与 Profiler 排查
  2. 02Callable 动态调用与安全注意
  3. 03@tool 编辑器脚本生命周期
  4. 04await 与信号的异步陷阱
  5. 05Signal 和 Callable 的选择

适用场景

  1. 01优化每帧执行的 GDScript 代码
  2. 02编写编辑器预览或资源烘焙脚本
  3. 03诊断无法恢复的异步等待
  4. 04避免动态调用和对象释放问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jame581/godotprompter --skill gdscript-advanced

安装后在 Claude Code 中输入/gdscript-advanced确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
metaprogramming-recipes.md
profiler-recipes.md
tool-script-recipes.md

详细介绍

GDScript 高级

面向生产环境的 GDScript 深入指南——用于发布游戏,而非学习语言。基础知识请结合 gdscript-patterns 阅读。

**相关技能:**语言基础请参阅 gdscript-patterns,引擎侧性能优化请参阅 godot-optimization,运行时诊断请参阅 godot-debugging,C# 替代方案请参阅 csharp-godot。

**设计意图:**此技能有意仅涵盖 GDScript(已加入允许列表)。C# 用户应阅读 csharp-godot。在此加入 C# 对等内容会破坏受众划分。

1. 何时使用高级 GDScript

当你已经超出 gdscript-patterns 的范围时:

  • 遇到性能分析器瓶颈,需要了解哪些惯用写法速度更快
  • 编写编辑器工具,需要确保 @tool 生命周期正确
  • 遇到无法恢复执行的协程,或 Callable 生命周期问题
  • 需要元编程(按名称调用函数、动态分派),同时避免潜在陷阱
  • 正在发布一款真正的游戏,希望避免那些看似可行、却会在高负载下出问题的模式

本技能假设你已了解类型化参数、@onready、await、match 和 lambda(这些内容已在 gdscript-patterns 中介绍)。

2. 性能惯用写法

静态变量和方法(Godot 4.1 起支持静态变量)可避免每个实例的额外开销:

class_name Tally extends Node

static var _global_score: int = 0

static func add_score(amount: int) -> void:
    _global_score += amount

static func get_score() -> int:
    return _global_score

如果类的静态方法就能满足需求,就不要使用单例自动加载。

Vector2i 与 Vector2 / Vector3i 与 Vector3——整数向量可确保图块坐标和网格运算精确(不会出现浮点漂移,也可用作 Dictionary 键)。不要想当然地认为速度会提升,应先进行测量。仅在渲染边界转换为浮点数:

var grid_pos: Vector2i = Vector2i(8, 12)              # cheap
var world_pos: Vector2 = Vector2(grid_pos) * TILE_SIZE  # convert at boundary

优先使用 PackedArray* 而非通用 Array——PackedInt32Array、PackedFloat32Array、PackedVector2Array 等会分配连续内存,并跳过 Variant 装箱。将它们用于缓冲区、顶点数组和热点循环累加器。

var positions: PackedVector3Array = PackedVector3Array()
positions.resize(1000)  # one allocation
for i in 1000:
    positions[i] = Vector3(i, 0, 0)

类型化 Dictionary 访问——类型化字典(Godot 4.4+)会在写入时验证键和值的类型,并为分析器提供静态读取类型(值仍以 Variant 存储):

var stats: Dictionary[String, int] = {}
stats["hp"] = 100  # type-checked; stats["hp"] is statically an int

is_instance_valid 与 null 检查——is_instance_valid() 会在引擎侧进行查找;!= null 则是指针比较。在 @onready 赋值后优先使用 != null;仅当持有引用的节点可能已被 queue_free'd 时,才使用 is_instance_valid()。

常见陷阱:每帧都在 _process 中对许多目标执行 if is_instance_valid(target)——每次调用都会查询对象数据库;单次开销很小,但累积起来会显著增加。

3. 元编程

Callable.bind、Callable.call、Callable.call_deferred 可实现动态分派,同时避免 Object.call(name) 的安全风险。

绑定参数:

var greeter: Callable = print_named.bind("Player")
greeter.call()                    # prints "Hello, Player"

func print_named(name: String) -> void:
    print("Hello, %s" % name)

延迟调用——在当前帧结束时(空闲时间)运行,适用于跨线程或避免信号风暴:

heavy_recompute.call_deferred()

Object.set / Object.get / Object.has_method——用于真正的动态代码(脚本热重载、模组):

if obj.has_method("on_damaged"):
    obj.call("on_damaged", 25)

**安全注意事项:**如果 user_string 来自存档、网络或模组内容,切勿在没有允许列表的情况下传入 obj.call(user_string, ...)。call("free") 会让数据删除对象。请与已知集合进行匹配:

const ALLOWED_RPCS: PackedStringArray = ["take_damage", "apply_buff", "set_position"]
if user_method in ALLOWED_RPCS and obj.has_method(user_method):
    obj.call(user_method, args)

完整的 Callable 模式和模组安全模型请参阅 references/metaprogramming-recipes.md。

4. @tool 生命周期

@tool 脚本在编辑器和游戏运行时都会执行。最常见的两种故障模式是:

  1. 仅限编辑器的逻辑意外在游戏运行时执行
  2. 游戏运行时的逻辑意外在编辑器中执行,导致编辑器崩溃

保护方式:

@tool
extends Node

func _ready() -> void:
    if Engine.is_editor_hint():
        _setup_editor_preview()
    else:
        _setup_game_runtime()

编辑器通知——使用 _notification 处理编辑器生命周期事件(NOTIFICATION_EDITOR_PRE_SAVE、NOTIFICATION_EDITOR_POST_SAVE):

func _notification(what: int) -> void:
    if what == NOTIFICATION_EDITOR_PRE_SAVE:
        _bake_preview()

常见陷阱:@tool setter 在场景加载时调用 get_tree()。导出值会在节点进入场景树之前设置,因此 get_tree() 会返回 null 并报错——请使用 is_inside_tree() 进行保护。

完整的 @tool 模式(包括编辑器预览、烘焙和程序化网格生成)请参阅 references/tool-script-recipes.md。

5. 异步陷阱

await 会暂停函数,并将控制权交还给调用方,直到信号触发。它有三类陷阱:

陷阱 1——await 位于 _ready 中会提前返回,因此节点在初始化完成前就报告已就绪:

# BAD: the first await returns control, so `ready` is emitted and the parent's
# _ready() runs while `inventory` is still empty
func _ready() -> void:
    await get_tree().create_timer(1.0).timeout
    inventory = load_inventory()

修复方法:在第一个 await 之前,完成其他节点会在就绪时读取的所有内容。如果确实有部分设置必须等待,请设置 is_initialized 标记,并在完成时发出 initialized 信号。依赖方在等待前应先检查该标记,因为等待一个已经触发过的信号不会恢复执行(陷阱 2)。

陷阱 2——等待永远不会触发的信号会让协程永久暂停:

# BAD if `health_changed` never fires (e.g., entity already at full HP)
await health.health_changed

修复方法:等待前检查前置条件。如果确实需要等待,请让信号与超时进行竞速。已发布的 Godot 没有 Signal.any(),也没有其他内置方法可以同时等待多个信号(godot-proposals#13597 提议了全局 any()/all()),因此请将两个信号汇入你自行创建的一个信号:

signal _health_wait_finished(changed: bool)

func wait_for_health_change(timeout_sec: float) -> bool:
    var timer := get_tree().create_timer(timeout_sec)
    var on_changed := func(_hp: int) -> void: _health_wait_finished.emit(true)
    var on_timeout := func() -> void: _health_wait_finished.emit(false)
    health.health_changed.connect(on_changed)
    timer.timeout.connect(on_timeout)
    var changed: bool = await _health_wait_finished
    if is_instance_valid(health):  # freed while we waited (see Trap 3)
        health.health_changed.disconnect(on_changed)
    timer.timeout.disconnect(on_timeout)  # or a stale timer ends the next wait early
    return changed

这些 lambda 会发出信号,而不是设置局部标记:GDScript lambda 会按值捕获局部变量,因此在其中设置 changed = true 不会影响外部变量。每个 _health_wait_finished 等待者都会在第一次发出信号时恢复执行,因此每个节点一次只运行一个等待操作。

**陷阱 3——等待期间对象被释放。**如果运行协程的节点被释放,协程会被静默丢弃——不会报错,await 之后的任何内容(清理、发出 finished)都不会执行。如果节点仍然存在,但它引用的对象已被释放,那么恢复执行后访问该引用会报错,提示“previously freed”。每次 await 之后都要重新验证:

func flash(target: Node2D) -> void:
    target.modulate = Color.RED
    await get_tree().create_timer(0.2).timeout
    if not is_instance_valid(target):  # freed while we waited
        return
    target.modulate = Color.WHITE

6. 信号与 Callable 的设计选择

信号——多对多、解耦、边沿触发。由于需要查找连接列表,每次发出信号都会产生少量开销。

Callable——一对一、显式、水平触发。每次调用的开销更低,但耦合更紧密。

以下情况使用信号:

  • 跨系统事件(player_died、item_collected、level_complete)
  • 游戏逻辑触发的 UI 更新
  • 通常会有 0 到 N 个监听器的任何情况

以下情况使用 Callable:

  • 策略注入(排序比较器、谓词函数)
  • 延迟工作调度(call_deferred)
  • Tween 方法(tween_method 接受一个 Callable)

常见陷阱:不使用 self 的 lambda 属于脚本,而不是节点,因此释放节点不会断开它——它会继续保留其捕获的变量,并在下次发出信号时继续运行。请在 _exit_tree 中显式断开连接,或改用绑定方法。

7. 由分析器驱动的惯用写法

打开 Debugger → Profiler 面板。最常见的模式如下:

分析器热点可能原因修复方法
_process 中分配 String每帧调用 print() / "%s" % var在循环外预先格式化,或使用循环缓冲区批量记录日志
Node.get_node 自身耗时较高每帧重复访问 $Path/Sub/Node使用 @onready var 缓存
Signal.emit 调用次数较多每帧触发大量信号(例如位置更新)降至 10 Hz,或使用轮询模式
CharacterBody2D/3D.move_and_slide 自身耗时较高同一帧中有许多角色体根据与摄像机的距离调整;使用 Area 进行低开销检测
分配导致帧时间尖峰(GDScript 没有 GC;它通过引用计数释放内存)临时 Array/String 导致分配器频繁分配和释放对数组进行池化;在启动时预分配

有关每一行的前后对比注释示例,请参阅 references/profiler-recipes.md。

8. 常见陷阱

Lambda 按值捕获局部变量——只在创建 Lambda 时捕获一次。因此,循环中的 Lambda 各自保留自己的 i,不需要 bind。相反的情况才是陷阱:为捕获的局部变量赋值只会更改 Lambda 的副本,该副本在下次调用时会再次从捕获的值开始:

var count := 0
var bump := func() -> int:
    count += 1  # CONFUSABLE_CAPTURE_REASSIGNMENT warning
    return count
bump.call()  # 1
bump.call()  # 1 again, and `count` out here is still 0

修复方法:将共享状态保存在成员变量或引用类型中——捕获的 Array、Dictionary 或对象会指向同一个实例:

var state := {"count": 0}
var bump := func() -> int:
    state.count += 1
    return state.count  # 1, then 2

@onready 的执行顺序——@onready 变量在 _init 之后、_ready 之前设置。子节点的 _ready 会先于父节点的 _ready 运行。因此:

  • 除非确定父节点已初始化,否则不要在子节点的 _ready 中引用父节点状态
  • 对于跨节点设置,建议由父节点在自己的 _ready 中调用 child.setup_with(self)

场景重新加载时的静态变量生命周期——类的静态变量会在脚本保持加载期间持续存在(通常是整个运行期间;@static_unload 可让不再被引用的脚本释放它们),而不是只在场景期间存在。重新加载场景不会重置它们。如果需要每个场景各自的单例,请使用自动加载,而不是静态变量。

资源共享带来的意外——如果两个场景中的 @export var item: ItemData 使用同一个 Resource 资源文件,它们会按引用共享状态。修改一个会同时影响另一个。如果每个实例都需要独立状态,请使用 item.duplicate()。

Packed 数组属性的元素写入会跳过 setter

⚠️ **Godot 4.7 中的变更:**设置 Packed 数组属性的元素(例如 obj.packed_prop[i] = x)不再调用整个 Packed 数组属性的 setter。依赖逐元素写入触发 setter 的代码会悄无声息地失效——请重新赋值整个数组以触发 setter。请参阅 4.7 迁移指南。

var points: PackedVector2Array:
    set(value):
        points = value
        _rebuild_mesh()

func move_point() -> void:
    points[0] = Vector2.ONE    # 4.6: setter (and _rebuild_mesh) ran; 4.7+: it does NOT
    var updated := points      # fix: modify a copy...
    updated[0] = Vector2.ONE
    points = updated           # ...then reassign — the setter fires

**Godot 4.7 及更高版本:**新增的 CONFUSABLE_TEMPORARY_MODIFICATION 警告会标记对临时(已丢弃)值的修改——例如,通过复杂赋值链或非 const 方法调用修改内置的 Packed*Array 属性时,只有临时副本发生变化,而属性仍保留旧值。该警告由 debug/gdscript/warnings/confusable_temporary_modification 控制(默认值为 1,即警告)。

实现检查清单

  • 确定适用哪种性能优化惯用法(类型化向量、PackedArray、静态方法)
  • 如果使用元编程,将所有动态方法名列入允许列表
  • 如果使用 @tool,使用 Engine.is_editor_hint() 区分编辑器分支和运行时分支
  • 检查 await 调用,确认等待的信号可能触发;检查 _ready 是否在设置完成前返回,以及等待期间引用是否会被释放
  • 根据权衡表选择信号或 Callable;在 _exit_tree 中断开 lambda
  • 优化前先进行性能分析;根据第 7 节的表格匹配性能瓶颈
  • 检查以下已列出的陷阱:为捕获的局部变量赋值的 lambda(修改不会逃逸 lambda)、@onready 顺序、静态变量生命周期、Resource 共享,以及打包数组属性设置器(Godot 4.7)

评论

支持 Markdown