场景组织

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

如何有效地建立关系

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

They create their first scene and fill it with content only to eventually end up saving branches of their scene into separate scenes as the nagging feeling that they should split things up starts to accumulate. However, they then notice that the hard references they were able to rely on before are no longer possible. Re-using the scene in multiple places creates issues because the node paths do not find their targets and signal connections established in the editor break.

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

One of the biggest things to consider in OOP is maintaining focused, singular-purpose classes with loose coupling to other parts of the codebase. This keeps the size of objects small (for maintainability) and improves their reusability.

These OOP best practices have several implications for best practices in scene structure and script usage.

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

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

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

  1. Connect to a signal. Extremely safe, but should be used only to “respond” to behavior, not start it. Note that signal names are usually past-tense verbs like “entered”, “skill_activated”, or “item_collected”.

    GDScript

    C#

    1. # Parent
    2. $Child.connect("signal_name", object_with_method, "method_on_the_object")
    3. # Child
    4. emit_signal("signal_name") # Triggers parent-defined behavior.
    1. // Parent
    2. GetNode("Child").Connect("SignalName", ObjectWithMethod, "MethodOnTheObject");
    3. // Child
    4. EmitSignal("SignalName"); // Triggers parent-defined behavior.
  2. 调用一个方法。用于启动行为。

    GDScript

    C#

    1. # Parent
    2. $Child.method_name = "do"
    3. # Child, assuming it has String property 'method_name' and method 'do'.
    4. call(method_name) # Call parent-defined method (which child must own).
    1. // Parent
    2. GetNode("Child").Set("MethodName", "Do");
    3. // Child
    4. Call(MethodName); // Call parent-defined method (which child must own).
  3. 初始化一个 FuncRef 属性。比方法更安全,因为方法的所有权是没必要的。用于启动行为。

    GDScript

    C#

    1. # Parent
    2. $Child.func_property = funcref(object_with_method, "method_on_the_object")
    3. # Child
    4. func_property.call_func() # Call parent-defined method (can come from anywhere).
    1. // Parent
    2. GetNode("Child").Set("FuncProperty", GD.FuncRef(ObjectWithMethod, "MethodOnTheObject"));
    3. // Child
    4. FuncProperty.CallFunc(); // Call parent-defined method (can come from anywhere).
  4. 初始化一个 节点(Node) 或其他 对象(Object) 引用。

    GDScript

    C#

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

    GDScript

    C#

    1. # Parent
    2. $Child.target_path = ".."
    3. # Child
    4. get_node(target_path) # Use parent-defined NodePath.
    1. // Parent
    2. GetNode("Child").Set("TargetPath", NodePath(".."));
    3. // Child
    4. GetNode(TargetPath); // Use parent-defined NodePath.

These options hide the points of access from the child node. This in turn keeps the child loosely coupled to its environment. One can re-use it in another context without any extra changes to its API.

注解

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

GDScript

C#

  1. # Parent
  2. $Left.target = $Right.get_node("Receiver")
  3. # Left
  4. var target: Node
  5. func execute():
  6. # Do something with 'target'.
  7. # Right
  8. func _init():
  9. var receiver = Receiver.new()
  10. add_child(receiver)
  1. // Parent
  2. GetNode<Left>("Left").Target = GetNode("Right/Receiver");
  3. public class Left : Node
  4. {
  5. public Node Target = null;
  6. public void Execute()
  7. {
  8. // Do something with 'Target'.
  9. }
  10. }
  11. public class Right : Node
  12. {
  13. public Node Receiver = null;
  14. public Right()
  15. {
  16. Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
  17. AddChild(Receiver);
  18. }
  19. }

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

警告

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

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

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

So, why do all this complex switcharoo work? Well, because scenes operate best when they operate alone. If unable to work alone, then working with others anonymously (with minimal hard dependencies, i.e. loose coupling) is the next best thing. Inevitably, changes may need to be made to a class and if these changes cause it to interact with other scenes in unforeseen ways, then things will start to break down. The whole point of all this indirection is to avoid ending up in a situation where changing one class results in adversely effecting other classes.

Scripts and scenes, as extensions of engine classes, should abide by all OOP principles. Examples include…

选择一个节点树结构

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

A game should always have a sort of “entry point”; somewhere the developer can definitively track where things begin so that they can follow the logic as it continues elsewhere. This place also serves as a bird’s eye view of all of the other data and logic in the program. For traditional applications, this would be the “main” function. In this case, it would be a Main node.

  • 节点 Mainmain.gd

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

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

  • 节点 Mainmain.gd

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

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

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

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

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

注解

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

Any GUI would need to also be a singleton; be a transitory part of the “World”; or be manually added as a direct child of the root. Otherwise, the GUI nodes would also delete themselves during scene transitions.

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

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

注解

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

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

When should one do this? Well, this is subjective. The dilemma arises when one must micro-manage when a node must move around the SceneTree to preserve itself. For example…

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

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

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

    需要关心内存吗?

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

    如果是这样,那么需要…

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

The issue is that the player here is a “special case”; one where the developers must know that they need to handle the player this way for the project. As such, the only way to reliably share this information as a team is to document it. Keeping implementation details in documentation however is dangerous. It’s a maintenance burden, strains code readability, and bloats the intellectual content of a project unnecessarily.

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

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

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

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

注解

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

The key to scene organization is to consider the SceneTree in relational terms rather than spatial terms. Are the nodes dependent on their parent’s existance? If not, then they can thrive all by themselves somewhere else. If they are dependent, then it stands to reason that they should be children of that parent (and likely part of that parent’s scene if they aren’t already).

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