AX 爱鲜报

综合 / 后端 · 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 的开发者的建议是:下一个项目至少试一次这种结构,试过才有判断。

你现在的项目用的是哪种结构?踩过什么坑,评论区说说。

标签 游戏开发 Godot 场景管理 GDScript 架构设计

评论

登录后才可评论

0 条评论

  • 暂无评论,来写第一条吧。