场景组织

本文讨论与场景内容的有效组织相关的主题。应该使用哪些节点?应该把它们放在哪里?它们应该如何互动?

如何有效地建立关系

当 Godot 用户开始制作自己的场景时,他们经常遇到以下问题:

他们创建了自己的第一个场景并填满内容,但随着应该把事情分解的烦人感觉开始积累,他们最终把场景的分支保存为单独的场景。可他们接着就注意到之前能够依赖的硬引用不能用了。在多个地方重用场景会出现问题,因为节点路径找不到目标,在编辑器中建立的信号连接也失效了。

要解决这些问题,必须实例化子场景,而子场景不能依赖所处环境中的详细信息。子场景应该能够保证自身创建的时候对别人如何用它没有过分的要求。

在 OOP 中需要考虑的最大的事情之一是维护目标明确、单一的类,与代码库的其他部分进行松散的耦合。这样可以使对象的大小保持在较小的范围内(便于维护),提高可重用性。

这些 OOP 最佳实践对场景结构和脚本使用的有很多意义。

应该尽可能设计没有依赖项的场景。也就是说,创建的场景应该将所需的一切保留在其内部。

如果场景必须与外部环境交互,经验丰富的开发人员会建议使用依赖注入。该技术涉及使高级 API 提供低级 API 的依赖关系。为什么要这样呢?因为依赖于其外部环境的类可能会无意中触发 Bug 和意外行为。

要做到这一点,就必须暴露数据,依靠父级上下文对其进行初始化:

  1. 连接信号。极其安全,但只能用于“响应”行为,而不是启动它。请注意,信号名称通常是过去式动词,如“entered”“skill_activated”“item_collected”(已进入、已激活技能、已收集道具)。

    # Parent
    $Child.connect("signal_name", object_with_method, "method_on_the_object")
    
    # Child
    emit_signal("signal_name") # Triggers parent-defined behavior.
    
  2. 调用方法。用于启动行为。

    # Parent
    $Child.method_name = "do"
    
    # Child, assuming it has String property 'method_name' and method 'do'.
    call(method_name) # Call parent-defined method (which child must own).
    
  3. 初始化 FuncRef 属性。比方法更安全,因为方法无须考虑所有权。用于启动行为。

    # Parent
    $Child.func_property = funcref(object_with_method, "method_on_the_object")
    
    # Child
    func_property.call_func() # Call parent-defined method (can come from anywhere).
    
  4. 初始化 Node 或其他 Object 的引用。

    # Parent
    $Child.target = self
    
    # Child
    print(target) # Use parent-defined node.
    
  5. 初始化 NodePath。

    # Parent
    $Child.target_path = ".."
    
    # Child
    get_node(target_path) # Use parent-defined NodePath.
    

这些选项隐藏了子节点的访问点. 这反过来又使子节点与环境保持 松耦合 . 人们可以在另外一个上下文中重新使用它, 而不需要对API做任何额外的改变.

备注

虽然上面的例子说明了父子关系, 但是同样的原则也适用于所有对象之间的关系. 兄弟节点应该只知道它们的层次结构, 而先祖节点则负责协调它们的通信和引用.

# Parent
$Left.target = $Right.get_node("Receiver")

# Left
var target: Node
func execute():
    # Do something with 'target'.

# Right
func _init():
    var receiver = Receiver.new()
    add_child(receiver)

同样的原则也适用于, 维护对其他对象依赖关系的非节点对象. 无论哪个对象实际拥有这些对象, 都应该管理它们之间的关系.

警告

人们应该倾向于将数据保存在内部(场景内部), 尽管它对外部上下文有一个依赖, 即使是一个松散耦合的依赖, 仍然意味着节点, 将期望其环境中的某些内容为真. 项目的设计理念应防止这种情况的发生. 如果不是这样, 代码的继承的责任将迫使开发人员使用文档, 以在微观尺度上跟踪对象关系;这就是所谓的开发地狱. 默认情况下, 编写依赖于外部文档的代码, 让人们安全地使用它, 是很容易出错的.

为了避免创建和维护此类文档, 可以将依赖节点(上面的子级)转换为工具脚本, 该脚本实现 _get_configuration_warning(). 从中返回一个非空字符串, 将使场景停靠面板生成警告图标, 该字符串作为节点的工具提示. 当它没有定义 CollisionShape2D 子节点时, 是相同图标, 即显示为节点如 Area2D 节点的图标. 然后, 编辑器通过脚本代码自行记录场景. 通过文档, 没有内容复制是必要的.

这样的GUI可以更好地通知项目用户有关节点的关键信息. 它具有外部依赖性吗?这些依赖性是否得到满足?其他程序员, 尤其是设计师和作家, 将需要消息中的明确指示, 告诉他们如何进行配置.

那么, 为什么这些复杂的开关都起作用呢?嗯, 因为场景在单独工作时运行得最好. 如果不能单独工作, 那么与别人匿名运行(最小化强依赖, 即松散耦合)是下下之策. 不可避免地, 可能需要对一个类进行更改, 如果这些更改导致它以不可预见的方式与其他场景交互, 那么事情就会开始崩溃. 所有这些间接性的目的就是为了避免最终出现改变一个类导致对其他类产生不利影响的情况.

脚本和场景作为引擎类的扩展, 应该遵守 所有 的OOP原则. 例如...

选择节点树结构

从前有个开发者开始开发游戏,却因为海量的可能性而犹豫不前。他可能知道自己想做什么、想要什么样的系统,但是应该把这些东西落实在 哪里 呢?好吧,自己做的游戏当然自己说了算。虽然节点树的构造方法有无数种,但对于没把握的人而言,这份指南可以展示一个比较像样的结构作为基础。

一个游戏总是应该有一种“入口点”;这是开发者可以明确地追踪到运行的开始位置,以便他们可以在其他地方继续运行逻辑。这个地方也可以作为程序中所有其他数据和逻辑的总览图。对于传统的应用程序,这将是“main”函数。在这种情况下,它将是一个 Main 节点。

  • “Main”节点(main.gd)

main.gd 脚本将作为游戏的主要控制器。

然后你便拥有了真正的游戏“世界”(二维或三维)。这可以是 Main 的子节点。另外,他们的游戏将需要一个主要的 GUI,来管理项目所需的各种菜单和小部件。

  • “Main”节点(main.gd)
    • Node2D/Spatial“世界”(game_world.gd)

    • Control“GUI”(gui.gd)

当变更关卡时,可以稍后换出“World”节点的子级。手动更换场景让用户完全控制他们的游戏世界如何过渡。

下一步是考虑项目需要什么样的游戏系统。如果有这么一个系统……

  1. 跟踪所有的内部数据

  2. 应该是全局可访问的

  3. 应该是独立存在的

…那么应该创建一个 自动加载单例节点 节点.

备注

对于较小的游戏, 一个更简单, 具有更少控制的选择, 是拥有一个游戏单例, 简单地调用 SceneTree.change_scene() 方法以交换出主场景的内容. 这种结构或多或少保留“World”作为主要游戏节点.

任何GUI也需要是一个单例;是 "世界" 的一个过渡部分;或者是作为根节点的直接子节点手动添加. 否则,GUI节点也会在场景转换时自行删除.

如果一个系统需要修改另一个系统的数据,那么就应该把它们分别定义成单独的脚本或者场景,不应该使用自动加载。其原因请参考文档自动加载与普通节点

游戏中的每个子系统在 SceneTree 中应有其自己的部分. 仅在节点是其父级的有效元素的情况下, 才应使用父子关系. 合理地移除父级是否意味着也应删除子级?如果没有, 那么它应在层次结构中有自己的位置, 作为同级关系或其他关系.

备注

在某些情况下,我们会需要让这些分离的节点仍然相对彼此进行定位。为此,可以使用 RemoteTransform / RemoteTransform2D 节点。它们将允许目标节点有条件地从 Remote* 节点继承选定的变换元素。要分配 targetNodePath,请使用以下方法之一:

  1. 一个可靠的第三方, 可能是一个父节点, 来协调分配任务.

  2. 一个编组, 轻松提取对所需节点的引用(假设只有一个目标).

什么时候应该这样做呢?哎,这是比较主观了。当一个节点必须在 SceneTree 上移动以保护自己时,就会出现两难的局面。例如……

  • 将“玩家”节点到“房间”。

  • 要改变房间,那么就必须删除当前房间。

  • 在删除这个房间之前,必须保留并且/或者移动玩家。

    需要关心内存吗?

    • 如果不关心,那么就可以创建两个房间,移动玩家,然后删掉旧房间。没有问题。

    如果关心,那么就需要……

    • 将玩家移动到树的其他地方。

    • 删除房间。

    • 实例化并添加房间。

    • 重新添加玩家。

问题在于这里的角色是一种“特殊情况”;开发者必须知道需要以这种方式处理项目中的角色。因此,在团队中可靠地分享这些信息的唯一方法就是写文档。然而,在文档中记录实现细节是很危险的,会成为一种维护负担,使代码可读性下降,不必要地膨胀项目的知识内容。

在拥有更多的资产的,更复杂的游戏,简单地将玩家完全保留在 SceneTree 中的其他地方会更好。这样的好处是:

  1. 一致性更高。

  2. 没有“特殊情况”,不必写入文档也不必进行维护。

  3. 因为不需要考虑这些细节,所以也没有出错的机会。

相比之下,如果需要子节点继承父节点的变换,那么就有以下选项:

  1. 声明式解决方案:在它们之间放置一个 Node。作为没有变换的节点,Node 不会将这些信息传递给其子节点。

  2. 命令式解决方案:对 CanvasItem 或者 Spatial 节点使用 set_as_toplevel 函数。这样就会让该节点忽略其继承的变换。

备注

如果构建的是网络游戏,请记住哪些节点和游戏系统与所有玩家相关,而哪些只与权威服务器相关。例如,用户并不需要所有人都拥有每个玩家的“PlayerController”逻辑的副本。相反,他们只需要自己的。这样,将它们保持在从“世界”分离的独立的分支中,可以帮助简化游戏连接等的管理。

场景组织的关键是用关系树而不是空间树来考虑 SceneTree。节点是否依赖于其父节点的存在?如果不是,那么它们可以自己在别的地方茁壮成长。如果它们是依赖性的,那么理所当然它们应该是父节点的子节点(如果它们还不是父节点场景的一部分,那么很可能是父节点场景的一部分)。

这是否意味着节点本身就是组件?并不是这样。Godot 的节点树形成的是聚合关系,不是组合关系。虽然依旧可以灵活地移动节点,但在默认情况下是没有进行移动的必要的。