Godot 最佳实践精髓

基于 Godot 4 官方文档 Best Practices 章节提炼,去掉废话只留干货。 原文:https://docs.godotengine.org/en/stable/tutorials/best_practices/index.html


目录

  1. Godot 的类系统
  2. 场景组织与解耦
  3. 场景 vs 脚本,怎么选
  4. Autoload vs 内部节点
  5. 节点的轻量替代品
  6. 获取对象引用的方式
  7. 通知系统与生命周期回调
  8. 数据结构选择
  9. 编码逻辑偏好
  10. 项目文件组织
  11. 版本控制

1. Godot 的类系统

脚本不是独立的类,而是对引擎内置类的扩展。

  • 脚本附加到节点后,会扩展该节点在 ClassDB 中已有的方法、属性和信号。
  • 没有写 extends 时,脚本隐式继承 RefCounted,可以用代码实例化,但不能附加到 Node 类型。
  • 场景本质上等价于脚本:场景是可复用、可实例化、可继承的节点组合,根节点上的脚本就是这个"类"的逻辑层。
场景 = 节点组合(声明式)+ 根节点脚本(命令式逻辑)
  • OOP 原则(单一职责、封装等)同样适用于场景和脚本。

2. 场景组织与解耦

核心原则

每个场景应该能独立运行,不依赖外部节点路径。

拆分子场景后,节点路径失效、信号连接断掉——这是典型的耦合问题。解决方法是依赖注入:让父节点把依赖传进来,而不是让子节点自己去找。

5 种依赖注入方式

方式 适用场景 说明
信号(Signal) 响应行为 最安全;信号名用过去式(entereditem_collected
方法调用 发起行为 父节点指定子节点调用哪个方法
Callable 属性 发起行为 比直接调用方法更安全,不需要拥有该方法
对象/节点引用 直接访问 父节点初始化时传入
NodePath 属性 路径查找 父节点设置路径,子节点用 get_node() 获取

兄弟节点之间的通信也应由父节点来中转,而不是直接相互引用。

推荐的场景树结构

Main (main.gd)        ← 程序入口,总调度
├── World (Node2D/3D) ← 关卡/游戏世界,可整体替换
└── GUI (Control)     ← UI 层,与 World 独立

切换关卡时只需替换 World 的子节点,GUI 不受影响。

Autoload 的适用场景

用于完全自治、管理自身数据、不修改其他系统的全局系统,例如:

  • 任务系统(QuestManager)
  • 对话系统(DialogueManager)
  • 成就系统

父子关系的判断依据

删除父节点,子节点是否也应该随之消失?

如果是,才应该用父子关系。用树结构描述的是聚合关系,不是空间关系。

自文档化依赖

@tool 脚本实现 _get_configuration_warnings(),在编辑器中对未满足的依赖显示警告,比依赖外部文档靠谱。


3. 场景 vs 脚本,怎么选

场景实例化 vs 脚本实例化

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())

4. Autoload vs 内部节点

Autoload 的问题

用 Autoload 管理共享资源(如音效管理器)会导致:

  1. 全局状态:一个对象负责所有人的数据,出错时影响面广
  2. 调试困难:任何地方都可能传入错误数据,难以定位来源
  3. 资源浪费:预分配池要么不够用,要么占用多余内存

推荐做法

让每个场景自己管理自己的节点和资源

# 不推荐:从 Autoload 拿音效
SoundManager.play("res://sounds/jump.wav")

# 推荐:场景自己有 AudioStreamPlayer 节点
$AudioStreamPlayer.stream = preload("res://sounds/jump.wav")
$AudioStreamPlayer.play()

通过 class_name 定义可复用的自定义节点类型来共享功能,而不是 Autoload。

Autoload 的合理使用场景

  • 完全自治的系统(任务、成就、对话)
  • 系统只管理自己的数据,不修改其他系统

静态变量替代方案(Godot 4.1+)

# my_manager.gd
class_name MyManager

static var score: int = 0  # 无需实例化,全局共享

Autoload 不是单例设计模式——它只是一种方便的加载机制。


5. 节点的轻量替代品

当项目有大量简单数据对象时,用 Node 会有性能开销。考虑这三个轻量替代品:

Object — 最轻量

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(),忘记就内存泄漏
  • 适用:自定义树、链表、图等数据结构

RefCounted — 自动内存管理

class_name MyData
extends RefCounted

var value: int = 0
# 引用归零时自动释放,无需手动 free()
  • 优点:自动引用计数,不用手动管理内存
  • 适用:临时数据容器、文件访问(FileAccess 就是这个)

Resource — 可序列化

class_name CharacterStats
extends Resource

@export var max_hp: int = 100
@export var attack: int = 10
# 可以保存为 .tres 文件,Inspector 可直接编辑
  • 优点:可序列化到文件,Inspector 可见和可编辑
  • 适用:角色数据、配置、游戏数据表
  • 注意:比 RefCounted 稍重,但比 Node 轻得多

6. 获取对象引用的方式

获取节点引用(推荐顺序)

# 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)

用组(Group)作为接口

# 所有"可伤害"的节点加入 "damageable" 组
# 不需要共同基类,任何节点都能加入
for body in get_tree().get_nodes_in_group("damageable"):
    if body.has_method("take_damage"):
        body.take_damage(10)

7. 通知系统与生命周期回调

核心概念

所有 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 vs _physics_process vs *_input

方法 用途
_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:
            # 引擎删除对象前触发(等同于析构函数)
            pass

Timer 的正确用法

func _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

8. 数据结构选择

Array vs Dictionary vs Object

操作 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 vs string

# 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)

9. 编码逻辑偏好

先改属性,再加入场景树

# 错误:加入场景树后再改属性,可能触发多次 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() vs load()

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()

关卡设计:静态 vs 动态

规模 建议
小游戏 静态场景,直接在编辑器里摆好
中大型游戏 动态加载,用插件管理关卡,只加载当前需要的内容
时间紧 用动态逻辑(改代码比改场景快)

10. 项目文件组织

命名规范

对象 规范 示例
文件夹 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 上导出时出现路径找不到的问题。


11. 版本控制

.gitignore 必须忽略的内容

# Godot 4.x
.godot/

# 由 CSV 导入生成的二进制翻译文件
*.translation

# Godot 4.0 及以前(4.1+ 不再包含敏感信息,可按需决定)
# export_presets.cfg

通过编辑器生成:Project → Version Control → Generate Version Control Metadata

Windows Git 换行符问题

git config --global core.autocrlf input

自动生成的 .gitattributes 会强制 LF 换行,设了这个就不会乱改文件。

Git LFS(大文件存储)

必须在第一次提交前配置好,否则迁移很麻烦。

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 -text

编辑器内 Git 插件

Godot 有官方 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 文件