Godot 中的场景组织
1. 场景组织的原则
应该尽可能设计没有依赖的场景。子场景它只管好自己内部的事,不要主动去管它的父节点是谁,也不要主动去获取外部的节点。
如果场景必须与外部环境交互,使用依赖注入。
依赖注入(dependency injection,缩写为 DI)是一种软件设计模式,也是实现控制反转的其中一种技术。这种模式能让一个对象接收它所依赖的其他对象。“依赖”是指接收方所需的对象。“注入”是指将“依赖”传递给接收方的过程。在“注入”之后,接收方才会调用该“依赖”。
依赖注入涉及四个概念:
- 服务:任何类,提供了有用功能。
- 客户:使用服务的类。
- 接口:客户不应该知道服务实现的细节,只需要知道服务的名称和 API。
- 注入器:Injector,也称 assembler、container、provider 或 factory。负责把服务引入给客户。
2. 依赖注入与五种交互方式
2.1 最好的依赖就是没有依赖
- 优先追求独立性: 尽量设计“即插即用”的场景,不要让它们依赖外部环境。
- 警惕隐形契约: 任何需要外部“注入”数据的行为,都是一种隐形的契约。契约越多,系统越脆弱。
- 如果必须依赖,必须显性化: 如果一个场景确实需要外部数据才能工作,不要指望开发者能记住它。要利用引擎的工具(如配置警告),把这个需求可视化地展示在编辑器里,而不是写在只有上帝才看的文档里。
2.2 依赖注入的五种方法
官方文档中提供了五种方法,我们可以从“谁在控制”(控制权)、“耦合程度”(依赖性)以及“适用场景”这三个维度进行总结和区分分析。
这五种方法的核心目的都是为了实现解耦(Loose Coupling),即让子场景(Child)不直接依赖于具体的父场景(Parent),而是由父场景将所需的数据或逻辑注入给子场景。
2.2.1 五种方法的相同点
- 方向一致:都是由父级(外部环境)向子级(内部组件)注入信息。
- 目的相同:消除子场景对父场景的硬编码引用(如
get_node("..")),提高子场景的可复用性。 - 符合OOP原则:体现了依赖注入(Dependency Injection)的思想。
2.2.2 五种方法的区别与使用场景区分
1. 连接信号 (Connect to a signal)
- 机制:子节点发射信号,父节点监听并执行逻辑。
特点:
- 极其安全。
- 子节点完全不知道谁在听,只负责“广播”发生了什么。
- 通常用于“响应”(Reflexive)行为,而非命令。
适用场景:
- 当子节点发生了一个事件(如:按钮点击、动画结束、碰撞发生),需要通知外部,但不关心外部如何处理时。
- 命名惯例通常是过去式动词(如
entered,died)。
2. 调用方法 (Call a method)
- 机制:父节点设置子节点的一个字符串属性(方法名),子节点通过
call(method_name)动态调用该方法。 特点:
- 相对危险。依赖于字符串匹配,且子节点必须拥有该方法。
- 用于“启动”(Imperative)行为。
适用场景:
- 当你需要动态决定子节点执行其内部的哪个逻辑时。
- 注意:在现代 Godot 开发中(尤其是 Godot 4.x),这种传递方法名字符串的方式较少使用,通常不如使用 Callable 直接。
3. 初始化 Callable 属性 (Initialize a Callable property)
- 机制:父节点将一个函数(Callable)直接赋值给子节点的属性,子节点直接调用该属性。
特点:
- 比传递方法名字符串更安全、更灵活。
- 子节点不需要拥有该方法,方法的所有权属于外部对象。
- 用于“启动”行为,或者回调逻辑。
适用场景:
- 当子节点需要执行一个逻辑,但这个逻辑的具体实现完全由外部决定时(例如:一个通用的“确认”按钮,点击后执行传入的
on_confirm函数)。 - 类似于函数式编程中的回调函数。
- 当子节点需要执行一个逻辑,但这个逻辑的具体实现完全由外部决定时(例如:一个通用的“确认”按钮,点击后执行传入的
4. 初始化 Node 或 Object 引用 (Initialize a Node or other Object reference)
- 机制:父节点将某个具体的对象实例赋值给子节点的变量。
特点:
- 直接、高效。
- 子节点可以直接访问该对象的所有公开接口。
适用场景:
- 当子节点需要频繁与另一个特定对象交互时(例如:一个 AI 敌人节点需要一直知道
target玩家节点的位置)。 - 这是最标准的依赖注入形式。
- 当子节点需要频繁与另一个特定对象交互时(例如:一个 AI 敌人节点需要一直知道
5. 初始化 NodePath (Initialize a NodePath)
- 机制:父节点传递一个路径字符串(NodePath),子节点在内部通过
get_node()获取对象。 特点:
- 灵活但稍慢。
- 允许在编辑器(Inspector)中方便地指定关联节点。
适用场景:
- 主要用于编辑器内的配置。当你在设计关卡时,想通过拖拽或选择路径来告诉子节点它该关注哪个邻居节点时。
- 通常配合
export(GDScript) 变量使用,方便策划或设计人员操作。
2.2.3 总结建议:如何选择?
- 只是想通知外界“我做完了/发生了什么”? $\rightarrow$ 使用信号 (方法 1)。这是最常用的解耦方式。
- 需要子节点执行一段逻辑,但这逻辑怎么写完全取决于外部? $\rightarrow$ 使用 Callable (方法 3)。
子节点需要持续关注或操作外部的某个特定对象?
- 如果是代码动态生成的依赖 $\rightarrow$ 直接传入 Node 引用 (方法 4)。
- 如果是编辑器里手动配置的依赖 $\rightarrow$ 传入 NodePath (方法 5)。
- 尽量避免使用传递方法名字符串 (方法 2),除非有特殊的动态反射需求,否则 Callable 是更好的替代方案。
3. 如何组织 Godot 的节点树结构
3.1 建立清晰的顶层结构(“入口”思维)
- 游戏应该有一个固定的起点(如
Main节点),作为整个程序的总管。 - 推荐结构:
Main节点下挂载两个主要分支 —— 一个负责游戏内容(World),一个负责界面(GUI)。这样切换关卡时只需替换World下的内容,不会影响到 UI。
3.2 合理使用“单例”(Autoload)
- 什么时候用单例? 当一个系统需要全局访问、独立存在、且自己管理数据时(例如音频管理、全局配置)。
- 什么时候不用? 如果一个系统会修改别人的数据,或者只在特定关卡存在,就别做成单例,老老实实写成普通脚本或场景。
3.3 按照“依赖关系”而不是“空间位置”来决定父子关系
核心判断标准: 如果删掉父节点,子节点是否也应该一起消失?
- 是: 做成父子关系(例如:手臂是身体的子节点)。
- 否: 应该分开放,做成兄弟节点或其他关系(例如:玩家不应该是房间的子节点,因为删掉房间不代表删掉玩家)。
- 特殊技巧: 如果需要逻辑分离但位置跟随,可以使用
RemoteTransform节点;如果需要逻辑从属但位置独立,可以使用Top Level属性。
3.4 避免“特殊情况”带来的维护负担
- 痛点: 如果你把“玩家”放在“房间”节点下,每次换房间都要手动把玩家移出来再移进去,这属于容易出错的“特殊处理”。
- 解决方案: 一开始就不要把玩家放在房间底下。将常驻对象(如玩家)和临时对象(如关卡地图)在节点树上分开存放,保持结构的一致性,减少代码里的“补丁”逻辑。
3.5 一句话总结:
不要因为物体在空间上在一起,就强行把它们在代码里绑成父子;要根据它们在逻辑上是否共存亡,来决定节点树的层级,保持结构简单、解耦。
Godot 中的场景组织
https://blog.gamewhale.fun/archives/16/