场景组织

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

如何有效地建立关系

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

他们创建了自己的第一个场景,并在其中填充了内容,然后才逐渐感觉到,需要将其拆分为可重复使用的片段,这将困扰他们。他们将场景的分支保存到自己的场景中。但是,他们然后注意到,他们以前能够依靠的硬引用已不再可能。在多个位置重复使用场景会产生问题,因为节点路径找不到目标。在编辑器中建立的信号连接中断。

要解决这些问题,必须实例化子场景,而子场景不需要有关其环境的详细信息。人们必须能够相信子场景会自己创建自己,而无需挑剔人们如何使用它。

在面向对象的程序设计中,需要考虑的最重要的事情之一是保持专注、单一用途的类,使用 松散耦合 到代码库的其他部分。这样可以使对象的大小保持较小(为了可维护性),并提高了对象的可重用性,因此无需重写已完成的逻辑。

这些OOP最佳实践,对场景结构和脚本使用方面的最佳实践,有 多个 分支。

如果可能的话,应该设计没有依赖性的场景。 也就是说,人们应该创建场景,而场景将所需的一切保留在其内部。

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

要做到这一点,必须公开数据,然后依赖父级上下文来初始化它:

  1. 连接到一个信号。极为安全,但仅应用于响应行为,而不是启动行为。请注意,信号名称通常是过去时态动词,例如 enteredskill_activateditem_collected

    # Parent
    $Child.connect("signal_name", object_with_method, "method_on_the_object")
    
    # Child
    emit_signal("signal_name") # Triggers parent-defined behavior.
    
    // Parent
    GetNode("Child").Connect("SignalName", ObjectWithMethod, "MethodOnTheObject");
    
    // Child
    EmitSignal("SignalName"); // 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).
    
    // Parent
    GetNode("Child").Set("MethodName", "Do");
    
    // Child
    Call(MethodName); // 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).
    
    // Parent
    GetNode("Child").Set("FuncProperty", GD.FuncRef(ObjectWithMethod, "MethodOnTheObject"));
    
    // Child
    FuncProperty.CallFunc(); // Call parent-defined method (can come from anywhere).
    
  4. 初始化一个 节点(Node) 或其他 对象(Object) 引用。

    # Parent
    $Child.target = self
    
    # Child
    print(target) # Use parent-defined node.
    
    // Parent
    GetNode("Child").Set("Target", this);
    
    // Child
    GD.Print(Target); // Use parent-defined node.
    
  5. 初始化一个 节点路径(NodePath)

    # Parent
    $Child.target_path = ".."
    
    # Child
    get_node(target_path) # Use parent-defined NodePath.
    
    // Parent
    GetNode("Child").Set("TargetPath", NodePath(".."));
    
    // Child
    GetNode(TargetPath); // 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)
// Parent
GetNode<Left>("Left").Target = GetNode("Right/Receiver");

public class Left : Node
{
    public Node Target = null;

    public void Execute()
    {
        // Do something with 'Target'.
    }
}

public class Right : Node
{
    public Node Receiver = null;

    public Right()
    {
        Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
        AddChild(Receiver);
    }
}

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

警告

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

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

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

那么,为什么所有这些复杂的切换都能工作呢?因为当场景单独运行时,效果最好。如果不能独自工作,那么就匿名与其他对象合作(具有最小的硬依赖关系,即松散耦合)。如果一个类不可避免的变化导致它以不可预见的方式与其他场景交互,然后事情就发生了变化。对一个类的更改可能会对其他类造成破坏性的影响。

脚本和场景,作为引擎类的扩展,应该遵循 所有 面向对象的程序设计原则。例子包括……

选择一个节点树结构

因此,一个开发者开始开发一款游戏,却在眼前的巨大可能性面前止步不前。他们可能知道自己想做什么,他们想要什么样的系统,但是把它们都放在 哪里 呢?好吧,如何创造他们的游戏总是取决于他们自己。可以用多种方法构造节点树。但是,对于那些不确定的人来说,这篇有用的指南可以为他们提供一个良好结构的示例来开始。

游戏应该始终有一个 切入点;开发人员可以在某个地方明确地跟踪事物的开始位置,以便他们可以按照逻辑在其他地方继续进行。该位置还可以俯瞰程序中的所有其他数据和逻辑。对于传统应用程序,这将是 Main 函数。在本例中,它将是一个 Main 节点。

  • 节点 Mainmain.gd

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

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

  • 节点 Mainmain.gd
    • Node2D/Spatial 世界game_world.gd
    • 控制图形用户界面(gui.gd

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

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

  1. 跟踪所有的内部数据
  2. 应该是全局可访问的
  3. 应该是独立存在的

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

注解

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

任何GUI也必须是一个单例,是 世界 的过渡部分,或者手动添加为根的直接子级。否则,GUI节点还将在场景转换期间删除自己。

如果人们具有修改其他系统数据的系统,则应将其定义为他们自己的脚本或场景,而不是自动加载。有关原因的更多信息,请参见 自动加载VS内部节点 文档。

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

注解

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

  1. 一个可靠的第三方,可能是一个父节点,来协调分配任务。
  2. 一个编组,轻松提取对所需节点的引用(假设只有一个目标)。

什么时候应该这样做?好吧,这取决于他们的决定。当一个节点必须围绕场景树移动,且必须对其进行微观管理,以保持自身时,就会出现两难境地。例如…

  • 添加一个 玩家 节点到一个 房间

  • 需要更改 房间,因此必须删除当前 房间

  • 在可以删除 房间 之前,必须保存和/或移动 玩家

    需要关心内存吗?

    • 如果不是这样,一次可以创建两个 房间,移动 玩家 并删除旧的那个。没有问题。

    如果是这样,那么需要…

    • 玩家 移动到树的其他位置。
    • 删除 房间
    • 实例化并添加新 房间
    • 重新添加 玩家

问题在于,这里的 玩家 是一个 特殊情况,其中开发者必须 懂得 他们需要以这种方式处理项目中的 玩家。因此,作为一个团队可靠地共享此信息的唯一方法就是 记录 它。然而,在文档中保存实现细节是危险的。这是一种维护负担,增加了代码的可读性压力,以及增加了项目内不必要的知识内容。

在拥有更多的素材的、更复杂的游戏,简单地将 玩家 完全保留在场景树中的其他地方,可能是一个更好的主意。这涉及到…

  1. 更多的一致性。
  2. 没有必须被记录和维护在某地的 特殊情况
  3. 因为没有考虑这些细节,所以没有机会发生错误。

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

  1. 声明性 解决方案:在它们之间放置一个 Node 。作为没有转换的节点,节点不会将这些信息传递给其子节点。
  2. 命令性 解决方案:对 CanvasItem 或者 Spatial 节点,使用 set_as_toplevel 设值函数。这将使节点忽略其继承的转换。

注解

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

场景组织的关键,是用关系术语,而不是空间术语,来考虑场景树。节点是否需要依赖于其父节点的存在?如果不是,那么它们可以自己在其他地方蓬勃发展。如果是这样,那么很显然,它们应该是父节点的子节点(如果还不是,则可能是该父级的场景的一部分)。

这是否意味着节点本身就是组件?并不是这样。Godot的节点树形成的是聚合关系,而不是组合关系。虽然我们依旧可以灵活地移动节点,但在默认情况下,无需移动,仍然是最好的选择。