基于 Godot 4 官方文档 Best Practices 章节提炼,去掉废话只留干货。 原文:https://docs.godotengine.org/en/stable/tutorials/best_practices/index.html
脚本不是独立的类,而是对引擎内置类的扩展。
extends 时,脚本隐式继承 RefCounted,可以用代码实例化,但不能附加到 Node 类型。场景 = 节点组合(声明式)+ 根节点脚本(命令式逻辑)
每个场景应该能独立运行,不依赖外部节点路径。
拆分子场景后,节点路径失效、信号连接断掉——这是典型的耦合问题。解决方法是依赖注入:让父节点把依赖传进来,而不是让子节点自己去找。
| 方式 | 适用场景 | 说明 |
|---|---|---|
| 信号(Signal) | 响应行为 | 最安全;信号名用过去式(entered、item_collected) |
| 方法调用 | 发起行为 | 父节点指定子节点调用哪个方法 |
| Callable 属性 | 发起行为 | 比直接调用方法更安全,不需要拥有该方法 |
| 对象/节点引用 | 直接访问 | 父节点初始化时传入 |
| NodePath 属性 | 路径查找 | 父节点设置路径,子节点用 get_node() 获取 |
兄弟节点之间的通信也应由父节点来中转,而不是直接相互引用。
Main (main.gd) ← 程序入口,总调度
├── World (Node2D/3D) ← 关卡/游戏世界,可整体替换
└── GUI (Control) ← UI 层,与 World 独立
切换关卡时只需替换 World 的子节点,GUI 不受影响。
用于完全自治、管理自身数据、不修改其他系统的全局系统,例如:
删除父节点,子节点是否也应该随之消失?
如果是,才应该用父子关系。用树结构描述的是聚合关系,不是空间关系。
用 @tool 脚本实现 _get_configuration_warnings(),在编辑器中对未满足的依赖显示警告,比依赖外部文档靠谱。
const MyNode = preload("my_node.gd")
const MyScene = preload("my_scene.tscn")
var a = MyNode.new() # 脚本:直接 .new()
var b = MyScene.instantiate() # 场景:用 .instantiate()| 情况 | 推荐 |
|---|---|
| 可复用工具/插件,面向所有技能水平的用户 |
脚本(配合 class_name 和自定义图标) |
| 游戏专属概念,有节点层级和数据 | 场景(更易追踪和编辑,更安全) |
| 纯逻辑、无子节点 | 脚本 |
| 需要编辑器可视化配置 | 场景 |
场景比脚本快。 PackedScene 由引擎底层批量处理,脚本每条指令都要走脚本 API 查找链。节点层级越复杂,差距越明显。
# game.gd
class_name Game
extends RefCounted # 不会出现在节点创建对话框里
const MyScene = preload("my_scene.tscn")
# 使用方:
func _ready():
add_child(Game.MyScene.instantiate())用 Autoload 管理共享资源(如音效管理器)会导致:
让每个场景自己管理自己的节点和资源。
# 不推荐:从 Autoload 拿音效
SoundManager.play("res://sounds/jump.wav")
# 推荐:场景自己有 AudioStreamPlayer 节点
$AudioStreamPlayer.stream = preload("res://sounds/jump.wav")
$AudioStreamPlayer.play()通过 class_name 定义可复用的自定义节点类型来共享功能,而不是 Autoload。
# my_manager.gd
class_name MyManager
static var score: int = 0 # 无需实例化,全局共享Autoload 不是单例设计模式——它只是一种方便的加载机制。
当项目有大量简单数据对象时,用 Node 会有性能开销。考虑这三个轻量替代品:
class_name TreeNode
extends Object
var _parent: TreeNode = null
var _children := []
func _notification(p_what):
match p_what:
NOTIFICATION_PREDELETE:
for child in _children:
child.free() # 必须手动释放!free(),忘记就内存泄漏class_name MyData
extends RefCounted
var value: int = 0
# 引用归零时自动释放,无需手动 free()FileAccess 就是这个)class_name CharacterStats
extends Resource
@export var max_hp: int = 100
@export var attack: int = 10
# 可以保存为 .tres 文件,Inspector 可直接编辑# 1. @export — 编辑器拖拽,最直观
@export var player: CharacterBody2D
# 2. @onready — 场景树就绪时缓存
@onready var health_bar = $HUD/HealthBar
# 3. get_node() / $ 简写 — 硬编码路径,脆弱
var label = get_node("UI/Label")
var label = $UI/Label
# 4. 组(Group)— 一对多广播
get_tree().call_group("enemies", "stun")
var enemies = get_tree().get_nodes_in_group("enemies")
# 5. Autoload 单例 — 全局可访问
GameManager.start_level(1)Godot 是鸭子类型系统:不检查类型,只检查能否执行操作。
# 鸭子类型访问(直接访问,无类型检查)
node.velocity = Vector2.ZERO # 如果没有这个属性会报错
# 安全检查方式 1:has_method()
if node.has_method("take_damage"):
node.take_damage(10)
# 安全检查方式 2:is 关键字
if node is Enemy:
(node as Enemy).take_damage(10)
# 安全检查方式 3:as 强转(失败返回 null)
var enemy = node as Enemy
if enemy:
enemy.take_damage(10)# 所有"可伤害"的节点加入 "damageable" 组
# 不需要共同基类,任何节点都能加入
for body in get_tree().get_nodes_in_group("damageable"):
if body.has_method("take_damage"):
body.take_damage(10)所有 Object 都实现了 _notification(what: int) 方法。引擎在特定时机发送通知,Godot 为常用通知提供了具名方法:
| 方法 | 通知常量 | 触发时机 |
|---|---|---|
_init() |
— | 对象创建时(早于进入场景树) |
_enter_tree() |
NOTIFICATION_ENTER_TREE |
节点加入场景树 |
_ready() |
NOTIFICATION_READY |
节点及其所有子节点都进入场景树后 |
_exit_tree() |
NOTIFICATION_EXIT_TREE |
节点离开场景树 |
_process(delta) |
NOTIFICATION_PROCESS |
每帧(帧率相关) |
_physics_process(delta) |
NOTIFICATION_PHYSICS_PROCESS |
每物理帧(固定时间步) |
_draw() |
NOTIFICATION_DRAW |
CanvasItem 绘制时 |
| 方法 | 用途 |
|---|---|
_process(delta) |
帧率相关逻辑:UI 更新、动画、非物理的状态检查 |
_physics_process(delta) |
运动、碰撞、力——需要固定时间步保证一致性 |
_input(event) / _unhandled_input(event)
|
输入处理——不要在 _process 里轮询输入
|
# 错误示范:在 _process 里检测输入
func _process(delta):
if Input.is_action_pressed("jump"): # 可以,但浪费
jump()
# 正确做法:用专门的输入回调
func _unhandled_input(event):
if event.is_action_pressed("jump"):
jump()场景实例化时,属性赋值顺序:
1. 默认值初始化(不触发 setter)
2. _init() 中的赋值(触发 setter)
3. Inspector 导出值覆盖(再次触发 setter)
节点进入场景树时的调用顺序:
实例化(_init 从根到叶)
→ _enter_tree(从根到叶)
→ _ready(从叶到根,最后是根节点)
func _notification(what):
match what:
NOTIFICATION_PARENTED:
# 节点被添加到任何父节点时触发
# 适合:运行时动态创建的节点连接信号
var parent = get_parent()
if parent.has_signal("some_signal"):
parent.some_signal.connect(_on_some_signal)
NOTIFICATION_UNPARENTED:
# 节点从父节点移除时触发
pass
NOTIFICATION_PREDELETE:
# 引擎删除对象前触发(等同于析构函数)
passfunc _ready():
var timer = Timer.new()
add_child(timer)
timer.wait_time = 0.5
timer.autostart = true
timer.timeout.connect(_on_timer_timeout)
func _on_timer_timeout():
# 每 0.5 秒执行一次
pass| 操作 | Array | Dictionary | Object |
|---|---|---|---|
| 遍历 | 最快 | 快 | 慢(多层查找) |
| 按索引/键获取 | O(1) | O(1) | 慢 |
| 插入/删除(末尾) | O(1) | O(1) | — |
| 插入/删除(任意位置) | 慢 | O(1) | — |
| 按值查找 | O(n) | O(n) | — |
| 内存占用 | 紧凑 | 较大(预留空间) | 较大 |
Array — 遍历和按索引访问最优,头部插入慢(技巧:反转后在尾部操作再反转回来)
Dictionary — 增删改查均 O(1),但内存换性能,键有序(保留插入顺序)
Object — 提供信号、继承、封装,但每次属性访问都要走脚本查找链,最慢。适合需要复杂行为和 API 控制的场景。
GDScript 慢的根本原因:每次操作都要走这条多源查找链(脚本 → ClassDB → 基类……)。
# int 枚举:比较快,但打印出来是数字
enum State { IDLE = 0, RUNNING = 1, JUMPING = 2 }
print(State.IDLE) # 输出:0
# string 枚举(GDScript @export_enum 支持):直接可读
@export_enum("IDLE", "RUNNING", "JUMPING") var state: String
print(state) # 输出:"IDLE"结论:性能优先用 int;需要可读输出(调试/存档)用 string。
| 类 | 适用场景 |
|---|---|
AnimatedTexture |
最简单的帧循环,只控 FPS 和帧数;可用于 TileMapLayer 批量渲染 |
AnimatedSprite2D |
帧动画首选;配合 SpriteFrames 管理多组动画、翻转、速度 |
AnimationPlayer |
需要触发副作用(粒子、调用函数、修改其他节点属性) |
AnimationTree |
需要动画融合、状态机、分层混合 |
简单帧循环 → AnimatedTexture
帧动画切换 → AnimatedSprite2D
复杂时间轴 → AnimationPlayer
动画状态机/融合 → AnimationTree(配合 AnimationPlayer)
# 错误:加入场景树后再改属性,可能触发多次 setter 或慢速更新
var node = MyNode.new()
add_child(node)
node.position = Vector2(100, 100) # setter 可能触发不必要的更新
# 正确:加入前先设好属性
var node = MyNode.new()
node.position = Vector2(100, 100)
add_child(node)例外:global_position 等依赖场景树的属性必须在加入后才能设置。
preload() |
load() |
|
|---|---|---|
| 执行时机 | 编译期(脚本解析时) | 运行时 |
| 失败时机 | 编辑器内立刻报错 | 运行时才报错 |
| 适用场景 | 固定依赖、常量 | 动态路径、按需加载 |
| 内存管理 | 一直持有 | 可赋 null 卸载 |
# preload:固定资源,编译期确定
const EnemyScene = preload("res://enemy.tscn")
# load:动态路径,或需要按需卸载的大资源
var level_path = "res://levels/level_%d.tscn" % level_id
var level_scene = load(level_path)
# 用完后可释放:
level_scene = null建议:
preload(),能在编辑器里提前发现路径错误load()
| 规模 | 建议 |
|---|---|
| 小游戏 | 静态场景,直接在编辑器里摆好 |
| 中大型游戏 | 动态加载,用插件管理关卡,只加载当前需要的内容 |
| 时间紧 | 用动态逻辑(改代码比改场景快) |
| 对象 | 规范 | 示例 |
|---|---|---|
| 文件夹 | snake_case |
player_characters/ |
| GDScript 文件 | snake_case |
health_component.gd |
| C# 文件 | PascalCase |
HealthComponent.cs |
| 场景文件 | snake_case |
main_menu.tscn |
| 节点名称 | PascalCase |
HealthBar, PlayerBody
|
res://
├── addons/ # 第三方插件(不要放自己的代码)
├── characters/
│ ├── player/
│ │ ├── player.tscn
│ │ ├── player.gd
│ │ └── player_sprite.png
│ └── enemies/
├── levels/
├── ui/
├── audio/
└── art/
├── models/
└── textures/
核心原则:相关文件放在一起(场景、脚本、贴图同目录),而不是按类型分开。
# 在不想导入的文件夹里放一个空文件:
.gdignore
这会阻止 load()/preload() 访问该文件夹,并在 FileSystem 面板中隐藏它。
Windows/macOS 文件系统不区分大小写,Linux 区分。统一使用 snake_case 避免在 Linux 上导出时出现路径找不到的问题。
# Godot 4.x
.godot/
# 由 CSV 导入生成的二进制翻译文件
*.translation
# Godot 4.0 及以前(4.1+ 不再包含敏感信息,可按需决定)
# export_presets.cfg通过编辑器生成:Project → Version Control → Generate Version Control Metadata
git config --global core.autocrlf input自动生成的 .gitattributes 会强制 LF 换行,设了这个就不会乱改文件。
必须在第一次提交前配置好,否则迁移很麻烦。
git lfs install
# 追踪常见二进制资源类型
git lfs track "*.png"
git lfs track "*.jpg"
git lfs track "*.glb"
git lfs track "*.fbx"
git lfs track "*.mp3"
git lfs track "*.wav"
git lfs track "*.ogg"
git lfs track "*.ttf"推荐的 .gitattributes 配置(也可由编辑器自动生成):
* text=auto eol=lf
# 3D 模型
*.fbx filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.blend filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
# 图片
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
# 音频
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
# 字体
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
# Godot 二进制资源(可选,大场景/资源才需要)
*.scn filter=lfs diff=lfs merge=lfs -text
*.res filter=lfs diff=lfs merge=lfs -textGodot 有官方 Git 集成插件,支持在编辑器内直接做提交、查看 diff 等操作。 插件地址:https://github.com/godotengine/godot-git-plugin
| 问题 | 结论 |
|---|---|
| 场景还是脚本? | 游戏专属概念用场景;通用工具/插件用脚本 |
| 要全局访问怎么办? | 优先场景自给自足;必须全局时才用 Autoload |
| 节点太多性能差? | 用 Object/RefCounted/Resource 替代 Node |
| 怎么在子场景间通信? | 向上发信号,向下调方法,平级让父节点中转 |
| preload 还是 load? | 固定路径用 preload;动态/大资源用 load |
| 在哪里处理输入? |
*_input() 回调,不要在 _process 轮询 |
| 物理逻辑放哪里? |
_physics_process,不要放 _process
|
| 枚举用 int 还是 string? | 性能用 int;可读性/调试用 string |
| 文件命名用什么风格? | 文件/文件夹 snake_case,节点名 PascalCase |
| Git 忽略什么? |
.godot/ 目录和 *.translation 文件 |