综合 / 后端 · 2026-10-03 16:25 · 6 阅读 · 0 赞
Godot 游戏场景结构实操:主场景常驻 + 层级容器切换关卡
介绍 Godot 4.x 中主场景常驻、只替换 LevelContainer 内容的场景组织方式,涵盖目录结构、关卡切换、跨场景数据传递、昼夜循环、加载界面挂法,并对比 change_scene_to_file 的差异。

用 Godot 做游戏,关卡一多就会撞上一个结构性问题:场景切换到底怎么组织。
主流做法有两种。方法一是用 change_scene_to_file() 直接替换整个主场景;方法二是主场景常驻,只替换其中一个层级容器里的内容。
本文按第二种方法展开:场景树怎么搭、关卡怎么换、数据怎么传、昼夜循环怎么处理、加载界面怎么挂,全部给出可复制的结构和代码。基于 Godot 4.x,方法一作为对照放在最后。
一、先看两种结构的差异
方法一:完全替换主场景
get_tree().change_scene_to_file("res://scenes/base_hall.tscn")
整个场景树被新场景替换。标题界面、UI、环境全部销毁重建。结构简单,但每次切换都是一次全量加载,跨场景传数据只能靠 Autoload 单例。
方法二:主场景常驻,替换容器内容
主场景从头到尾不被替换,真正换的只是容器里的关卡实例。下面是完整搭法。
二、主场景的目录结构
Main.tscn (Node2D) ← 永不被替换
├── UI (CanvasLayer) ← 血条、菜单,常驻
├── DayNightCycle (DirectionalLight3D + 脚本) ← 常驻,容器外
└── LevelContainer (Node2D) ← 只替换这里面
└── Level_01.tscn 实例
搭建步骤:
- 新建
Main.tscn,根节点 Node2D,设为项目主场景(项目设置 → 应用 → 运行 → 主场景) - 建一个 CanvasLayer 放 UI,常驻
- 建一个 Node2D 命名 LevelContainer,所有关卡实例放它下面
- 关卡单独做成
.tscn,放进levels/文件夹
要点就一条:凡是跨关卡存在的东西,都挂在容器外面;凡是每次切换要换的东西,都在容器里面。
三、切换关卡的写法
在 Main 的脚本里:
@onready var level_container: Node2D = $LevelContainer
func switch_level(level_scene: PackedScene) -> void:
# 清掉旧关卡
for child in level_container.get_children():
child.queue_free()
# 实例化新关卡,先隐藏
var level = level_scene.instantiate()
level.visible = false
level_container.add_child(level)
# 关卡内部生成完成时发出信号 generated
await level.generated
# 生成完再显示
level.visible = true
这段代码是方法二的核心收益所在:新关卡是先实例化、隐藏、生成完毕、再显示。方法一做不到这一点,change_scene_to_file() 一调用,你只能等整个场景加载完,中间没有介入空间。
如果关卡用程序生成(Procedural Generation),生成算法可以放在关卡场景自己的 _process() 里分帧跑,甚至扔到独立线程。玩家看到的是一个现成的关卡,而不是看着它一块块长出来。
代价是内存:旧关卡 queue_free() 之前,树上有两个关卡共存。换来的不用反复加载,也不用写“主世界暂时不存在”时的判空逻辑。
四、跨场景数据传递:不用巨型单例
新手最常见的做法是建一个 Autoload,把玩家引用、生命值、背包全塞进去。项目一大,这个脚本变成没人敢动的祖传代码。
方法二下的替代写法,一个静态变量加导出引用:
# game.gd —— 挂在 Main 根节点上
class_name Game extends Node
static var current: Game
@export var day_night_cycle: DayNightCycle
func _ready() -> void:
Game.current = self
任何节点访问昼夜循环:
Game.current.day_night_cycle
注意两点:
static var需要 Godot 4.1+day_night_cycle是导出变量,在编辑器里把 Main 场景中的节点拖进检查器即可,引用是真实可见的,不是运行时才建立的查找
游戏状态也集中在这一个脚本里管理:
enum GameState { TITLE, PLAYING, PAUSED, GAME_OVER }
var game_state: GameState = GameState.TITLE
func set_state(new_state: GameState) -> void:
game_state = new_state
state_changed.emit(new_state) # UI 监听这个信号刷新
状态转换逻辑集中一处,UI 通过信号拿状态,不需要 UI 自己去轮询或持有引用。
五、昼夜循环:普通节点 + Tool 脚本
传统做法是把昼夜循环设成 Autoload 单例。问题出在编辑器里:单例场景没法在编辑器中实时预览,每次调参数都得切过去运行验证。
方法二下的做法,它本来就是 Main 的常驻子节点,直接加 @tool 注解:
@tool
class_name DayNightCycle extends DirectionalLight3D
@export_range(0.0, 24.0) var time_of_day := 12.0
func _process(_delta: float) -> void:
# 24 小时映射到太阳角度
rotation_degrees.x = -15.0 * time_of_day + 90.0
加了 @tool 之后,在编辑器里拖动 time_of_day 滑条,光照角度即时变化。全局访问走第四节的 Game.current.day_night_cycle,编辑器调试能力一点没丢。
雾效、环境色同理,挂在这个节点下或用信号广播,不必单例。
六、加载界面的挂法
加载界面不需要做成常驻节点,也不必挂到根节点下。挂当前游戏场景下面就行:
func show_loading() -> void:
var loading = LOADING_SCENE.instantiate()
add_child(loading) # 作为当前场景的子节点
func hide_loading() -> void:
for child in get_children():
if child is LoadingScreen:
child.queue_free()
一个加载界面对象,用完即销毁。配合第三节的流程:显示加载界面 → 隐藏实例化新关卡 → 等生成完成 → 显示关卡 → 销毁加载界面。
七、哪些东西还应该保留 Autoload
方法二不等于消灭所有单例。判断标准是独立测试时是否需要它:
| 系统 | 建议 | 原因 |
|---|---|---|
| 音效管理器 | 保留 Autoload | 单独运行玩家场景时也要能发声 |
| 全局设置(音量、键位) | 保留 Autoload | 与场景结构无关 |
| UI | 挂 Main 场景 | 随主场景常驻即可 |
| 昼夜循环 | 挂 Main 场景 | 需要编辑器实时调测 |
| 关卡数据 | 存在组件自身 | 自包含,避免全局枢纽 |
方法二的已知代价,对应写法都给了:全局访问路径变长(Game.current.xxx);单例与非单例的边界要自己划;单独运行玩家场景时 UI 不会自动出现。这三条在动手前想清楚就行。
收尾
这套结构的本质,是把“全局状态放哪”这个问题从运行时的单例,挪到了编辑器里看得见的节点上。数据跟着组件走,状态集中在一个脚本,跨关卡的东西常驻主场景。
方法一没有错,小项目或者关卡间完全独立的游戏,change_scene_to_file() 足够用。项目一大、跨场景状态一多,方法二的收益才开始明显。这位做了多年 Godot 的开发者的建议是:下一个项目至少试一次这种结构,试过才有判断。
你现在的项目用的是哪种结构?踩过什么坑,评论区说说。