本节摘要:运行中的场景树由根视口统一执掌,主场景只是树上的第一根枝。切换场景是"拆旧枝、接新枝",全局单例是挂在树根、永不离场的特殊节点。本节讲清运行时的树由谁管理、场景切换的正确姿势、实例化复用的三种模式,以及全局单例的用法边界。
游戏跑起来之后,那棵树的树根到底是谁?主场景和根节点是不是一回事?想把界面常驻在切换之上,代码该写在哪?这些问题都指向同一处:运行时场景树的组织逻辑。
引擎给每棵运行中的树固定的骨架:最顶端是场景树对象本身,其下是根视口,主场景作为根视口的第一个子节点挂上去。你在编辑器里点运行,引擎做的事就是"把主场景实例化、接到根视口下";你切换场景,引擎做的事是"把当前枝整根拆掉、换接新枝"。
理解这个骨架的价值在于常驻层的设计。界面遮罩、全局音效播放器、调试工具栏这类"不随场景切换消失"的东西,不该塞在主场景里——主场景是要被整根拆掉的。正确位置是根视口的直接子级,与主场景平级。引擎的场景切换方法只动主场景那一根枝,平级的常驻节点毫发无损。
切换场景的标准调用只需要一个参数——目标场景的引用。但"拆旧"这件事藏着两处容易翻车的水域。
其一,拆掉的是整根枝。切换后旧场景的所有节点触发离树回调,随后被销毁。任何还指着旧场景节点的成员变量、任何未断开的跨场景信号,都会变成悬空引用。第 2 章的引用计数规则在这里集中兑现。
其二,切换的发起者常常自己就在被拆的枝上。从关卡脚本里发起切换,等价于"拆房子的人站在房子里"。排队销毁机制保证了当下这一帧安全,但切换调用之后的代码仍会执行——尽量把切换写成函数的最后一句,切换后不再碰任何本场景的节点。
# 场景切换的稳妥写法:取引用、发起切换、立刻收手 func go_to_level(level_id: int) -> void: GameState.current_level = level_id # 数据先搬进全局层 get_tree().change_scene_to_file(path_for(level_id)) return # 切换之后不再访问本场景任何节点
带过渡效果的切换(淡入淡出)是常驻层的经典应用:常驻遮罩节点先播放淡出动画,动画完成后再发起切换,目标场景就绪后再淡入。动画期间主逻辑暂停,观感顺滑。第 8 章动画一节会把这套流程连起来做。
场景作为模板的价值靠实例化兑现。三种常见模式对应三种需求。
编辑器静态摆放:把保存好的场景直接拖进另一场景,形成实例节点。改模板,所有实例同步变化(除非实例上覆盖了属性)。适合关卡里固定数量、固定位置的东西——敌人点位、装饰物、检查点。这是"场景即组件"最直观的形态。
运行时动态生成:需要时实例化、挂树、用完销毁。子弹、特效、随机刷新的怪物走这条路。动态生成要记住第 2 章的孤儿节点纪律:实例化与挂树之间不留提前退出的分支。
占位与后期注入:模板里先摆一个空位节点,运行时往空位里塞内容。适合"结构固定、内容变化"的场合,比如背包格子界面。
# 动态生成敌人的标准三步 const EnemyScene := preload("res://scene/enemy.tscn") func spawn_enemy(at: Vector2) -> void: var e := EnemyScene.instantiate() e.position = at add_child(e) # 三步连贯,中间不插入可能提前退出的逻辑
一个值得单独强调的机制是实例的属性覆盖:拖进来的实例可以单独改属性,模板更新时被覆盖的属性保留实例值、未覆盖的跟随模板。这个机制是"复用与定制并存"的关键——十个敌人实例共享模板的默认血量,其中一个精英实例单独改高,互不干扰。但覆盖多了,模板与实例的差异会变得难以追踪,团队协作时要约定"覆盖只用于数据微调,不改结构"。
跨场景共享的状态与服务——玩家金币、音频管理、存档读写——放哪里?答案是全局单例:把一个场景注册为全局脚本,引擎启动时自动把它挂在树根附近,任何脚本用名字直达。它的初始化先于所有场景(第 2 章启动次序讲过),生命周期贯穿整个运行过程。
# 全局脚本示例:注册为 GameState 后,任何地方可直接写 GameState.add_coin extends Node signal coin_changed(total: int) # 全局信号,广播给所有监听者 var total_coins := 0 func add_coin(n: int) -> void: total_coins += n coin_changed.emit(total_coins)
单例的边界同样要划清。它适合放状态与服务,不适合放行为与表现——敌人怎么巡逻不该写在全局层,全局层只记"分数是多少、音乐放哪首"。单例是全局耦合的合法入口,滥用会让所有代码绕过场景结构直通全局,树形架构名存实亡。经验上限:全局单例数量控制在个位数,每个有单一明确的职责。
⚠️ 常见坑:把全局单例当垃圾场,越塞越大,最后任何场景都直接读写它几十个变量。治理办法是按职责拆分并让跨模块通信走信号,模块只订阅自己关心的事件,而不是轮询整个全局对象。
把本节工具串成一个假想项目的结构,看它们如何协作。主场景之下挂三根枝:关卡枝(地形、玩家出生点、敌人点位)、界面枝(血条、金币数、暂停菜单)、常驻枝挂在根视口下与主场景平级(过场遮罩、全局音效)。金币计数这类跨场景状态住在全局单例里,金币被拾取时它发全局信号,界面枝订阅刷新显示。敌人用静态摆放铺满固定点位,巡逻怪用动态生成按波次投放。暂停功能由常驻遮罩发起,暂停前先把自己的处理模式改为"始终处理"。
这个结构里没有一行"魔法"——每个决定都来自本节的一条规则:常驻的放平级、共享的进单例、固定的静态摆、临时的动态生。项目结构的可解释性,正是好架构的标志:任何人问"这个节点为什么在这里",答案都是一条说得出口的规则。
节点与场景都就位了,还缺最后一根神经:模块之间怎么对话而不缠死。下一节讲信号。