Godot 技术实践(三):用资源和组合把项目结构撑住
区分 Resource 与 Node、PackedScene 实例化和 Autoload 的适用边界,用组合而不是巨型脚本撑住中型 Godot 项目。

角色能跑、关卡能摆之后,Godot 项目会进入第二种失控:数据写进节点、全局写成 Autoload、复制场景当“不同敌人”。表面上都能玩,但调数值要打开十几个 .tscn,改掉血要搜遍脚本。这一篇把边界划清楚:什么该是节点,什么该是资源,什么才值得进全局。
系列导航:上一篇:用状态机把 2D 角色做成可维护的循环|下一篇:从可玩到可发布的调试、性能与导出
一、Node 是运行时,Resource 是数据
节点活在场景树里,有 _process、进入退出树、父子关系。资源不进树,它是可以被多处引用的数据对象:数值、曲线、对话条目、掉落表、PackedScene 本身。
一个直接的对比:
| 问题 | 放进节点 | 放进 Resource |
|---|---|---|
| 这一帧怎么移动 | 是 | 否 |
| 最大生命、移速、击退力度 | 可以 @export,但敌人一多就该抽出来 | 是 |
| 动画怎么播 | 是 | 片段名可以当数据 |
| 三只史莱姆共享同一套数值 | 复制节点容易分叉 | 同一份 .tres |

class_name ActorStats
extends Resource
@export var max_health := 3
@export var speed := 180.0
@export var jump_velocity := -400.0
@export var knockback := Vector2(180, -220)
角色和敌人都只持有一份 ActorStats:
@export var stats: ActorStats
func _ready() -> void:
health = stats.max_health
史莱姆 A 和史莱姆 B 引用同一资源时,改一处数值,两处生效。需要“精英史莱姆”时,复制资源而不是复制整棵角色树。官方对 Resource 的说明也强调:它是引用,不是节点的替代品。
自定义资源记得在脚本第一行写 class_name,并打开“资源”面板保存为 .tres。不要把大型数值表写进场景根节点的导出字段里,那会让关卡文件和平衡数据绑死。
二、用 PackedScene 生成,而不是在代码里搭树
运行时创建子弹、伤害数字、掉落物,用场景实例化:
@export var bullet_scene: PackedScene
func fire(towards: Vector2) -> void:
var bullet := bullet_scene.instantiate() as Node2D
bullet.global_position = $Muzzle.global_position
bullet.rotation = towards.angle()
get_tree().current_scene.add_child(bullet)
几个实践细节:
instantiate()之后先设属性,再add_child()。 子节点_ready()发生在进树时;进树前把direction、damage赋好,监听方读到的就是最终值。- 子弹不要挂在玩家脚下。 玩家
queue_free()时会带走子节点。放到当前关卡、专门的YSort或BulletContainer。 - 对象池是优化,不是默认。 先保证生命周期清楚,出现 GC 尖刺或频繁
instantiate卡顿再池化。 - 导出
PackedScene,不要写死路径字符串。preload("res://...") 适合确定的内部依赖;关卡可配置的内容用@export,方便策划在检查器里换。
场景实例化是组合的运行时形态。编辑器里拖进去的 Player.tscn,和代码里 instantiate() 的子弹,是同一件事。
三、Autoload 只放真正全局的东西
Autoload 很香,也最容易变成“什么都往里扔的单例”。适合进 Autoload 的通常是:
- 跨关卡仍然存在的进度、解锁、设置;
- 音频总线控制、场景切换器;
- 输入重绑定后的查询入口。
不适合进 Autoload 的是:
- 当前关卡里的玩家引用;
- 这一波敌人列表;
- 某个 UI 弹窗的开关。
反例:
# 不要这样做
Game.player.take_damage(1)
关卡重载、玩家死亡、分屏或回放时,这个全局引用会过期。更好的做法是关卡持有玩家,UI 通过信号更新;确需广播时,用 Autoload 发事件,而不是让它拥有节点:
extends Node
signal player_hit(health: int)
signal scene_change_requested(path: String)
func request_scene(path: String) -> void:
scene_change_requested.emit(path)
场景切换器监听 scene_change_requested,用 get_tree().change_scene_to_file() 或手动切换。Autoload 负责事件和数据,不负责“现在树上有谁”。
四、组合:把能力做成子节点或组件
Godot 没有 C# 那种强制接口体系,但组合非常自然。受伤、可对话、可推动,都可以是独立节点:
Player (CharacterBody2D)
├── Movement
├── Health
├── Hitbox
└── Sprite2D
Health 只管理生命并发出 died;Movement 只读输入写速度;根节点把它们接线。敌人复用 Health 和 Hitbox,换一套 ActorStats 和 AI 节点即可。
避免的结构是 Player 继承 Actor 继承 Entity 再继承 Moveable。场景树已经是组合容器,再用深继承,两种模型会打架。
信号接线优先在关卡或根场景完成,而不是每个组件去 get_parent()。组件对外只发信号,根场景决定谁听谁。这样 Health 可以原样丢进箱子、敌人、可破坏墙壁。
小结
中型 Godot 项目能走多远,取决于数据和对象有没有分开。数值进 Resource,可复用物体进 PackedScene,跨关卡状态才进 Autoload,能力用子节点组合。下一篇把这套结构送出编辑器:调试、性能和导出时,哪些设置会在发布当天才爆出来。
评论 (0)