场景组织
本文讨论与有效组织场景内容相关的主题。应该使用哪些节点?应该把它们放在哪里?它们应该如何互动?
如何有效地建立关系
当 Godot 用户开始制作自己的场景时,他们经常遇到以下问题:
他们创建了自己的第一个场景并填满内容,但随着应该把事情分解的烦人感觉开始积累,他们最终把场景的分支保存为单独的场景。可他们接着就注意到之前能够依赖的硬引用不能用了。在多个地方重用场景会出现问题,因为节点路径找不到目标,在编辑器中建立的信号连接也失效了。
要解决这些问题,必须实例化子场景,子场景不依赖于所处环境中的详细信息。子场景应该能够保证自身创建的时候,对如何使用它没有苛刻的要求。
在 OOP 中需要考虑的最大的事情之一是维护目标明确、单一的类,与代码库的其他部分进行松散的耦合。这样可以使对象的大小保持在较小的范围内(便于维护),提高可重用性。
这些 OOP 最佳实践对场景结构和脚本使用的有很多意义。
应该尽可能设计没有依赖的场景。也就是说,创建的场景应该将其所需的一切保留在其内部。
如果场景必须与外部环境交互,经验丰富的开发人员会建议使用依赖注入。该技术涉及使高级 API 提供低级 API 的依赖关系。为什么要这样呢?因为依赖于其外部环境的类可能会无意中触发 Bug 和意外行为。
要做到这一点,就必须暴露数据,然后依靠父级上下文对其进行初始化:
连接信号。这样做极其安全,但只能用于“响应”行为,而不是启动行为。按照惯例,信号名称通常是过去式动词,如“entered”“skill_activated”“item_collected”(已进入、已激活技能、已收集道具)。
# Parent $Child.signal_name.connect(method_on_the_object) # Child signal_name.emit() # Triggers parent-specified behavior.
// Parent GetNode("Child").Connect("SignalName", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child EmitSignal("SignalName"); // Triggers parent-specified behavior.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { // Note that get_node may return a nullptr, which would make calling the connect method crash the engine if "Child" does not exist! // So unless you are 1000% sure get_node will never return a nullptr, it's a good idea to always do a nullptr check. node->connect("signal_name", callable_mp(this, &ObjectWithMethod::method_on_the_object)); } // Child emit_signal("signal_name"); // Triggers parent-specified behavior.
调用方法。用于启动行为。
# Parent $Child.method_name = "do" # Child, assuming it has String property 'method_name' and method 'do'. call(method_name) # Call parent-specified method (which child must own).
// Parent GetNode("Child").Set("MethodName", "Do"); // Child Call(MethodName); // Call parent-specified method (which child must own).
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("method_name", "do"); } // Child call(method_name); // Call parent-specified method (which child must own).
初始化 Callable 属性。比调用方法更安全,因为不需要拥有这个方法的所有权。用于启动行为。
# Parent $Child.func_property = object_with_method.method_on_the_object # Child func_property.call() # Call parent-specified method (can come from anywhere).
// Parent GetNode("Child").Set("FuncProperty", Callable.From(ObjectWithMethod.MethodOnTheObject)); // Child FuncProperty.Call(); // Call parent-specified method (can come from anywhere).
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("func_property", Callable(&ObjectWithMethod::method_on_the_object)); } // Child func_property.call(); // Call parent-specified method (can come from anywhere).
初始化 Node 或其他 Object 的引用。
# Parent $Child.target = self # Child print(target) # Use parent-specified node.
// Parent GetNode("Child").Set("Target", this); // Child GD.Print(Target); // Use parent-specified node.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("target", this); } // Child UtilityFunctions::print(target);
初始化 NodePath。
# Parent $Child.target_path = ".." # Child get_node(target_path) # Use parent-specified NodePath.
// Parent GetNode("Child").Set("TargetPath", NodePath("..")); // Child GetNode(TargetPath); // Use parent-specified NodePath.
// Parent Node *node = get_node<Node>("Child"); if (node != nullptr) { node->set("target_path", NodePath("..")); } // Child get_node<Node>(target_path); // Use parent-specified NodePath.
这些选项隐藏了子节点的访问点。这反过来又使子节点与环境保持 松耦合 (loosely coupled)。人们可以在另外一个上下文中重新使用它,而不需要对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 partial class Left : Node
{
public Node Target = null;
public void Execute()
{
// Do something with 'Target'.
}
}
public partial class Right : Node
{
public Node Receiver = null;
public Right()
{
Receiver = ResourceLoader.Load<Script>("Receiver.cs").New();
AddChild(Receiver);
}
}
// Parent
get_node<Left>("Left")->target = get_node<Node>("Right/Receiver");
class Left : public Node {
GDCLASS(Left, Node)
protected:
static void _bind_methods() {}
public:
Node *target = nullptr;
Left() {}
void execute() {
// Do something with 'target'.
}
};
class Right : public Node {
GDCLASS(Right, Node)
protected:
static void _bind_methods() {}
public:
Node *receiver = nullptr;
Right() {
receiver = memnew(Node);
add_child(receiver);
}
};
同样的原则也适用于维护对其他对象依赖关系的非节点对象。无论哪个对象拥有其他对象,都应该管理它们之间的关系。
警告
你应该倾向于将数据保存在内部(场景内部),尽管它对外部内容有一个依赖关系,甚至是一个松散耦合的依赖,仍然意味着节点将期望其环境中的某些内容为真。项目的设计理念应避免这种情况的发生。如果不这样做,代码的继承关系将迫使开发人员使用文档, 以在微观尺度上跟踪对象关系;这就是所谓的开发地狱。通常情况下,编写依赖于外部文档才能安全使用的代码,是很容易出错的。
为了避免创建和维护此类文档,可以将依赖节点(上面的子级)转换为工具脚本,该脚本实现 _get_configuration_warnings()。从中返回的一个非空字符串紧缩数组(PackedStringArray)将使场景停靠面板生成警告图标,其中包含上述字符串作为节点的工具提示。这个警告图标和没有定义 CollisionShape2D 子节点时 Area2D 节点旁出现的图标是一样的。这样,编辑器通过脚本代码自记录(self-document)场景,也就不需要在文档里记录一些与之重复的内容了。
这样的GUI可以更好地通知项目用户有关节点的关键信息. 它具有外部依赖性吗?这些依赖性是否得到满足?其他程序员, 尤其是设计师和作家, 将需要消息中的明确指示, 告诉他们如何进行配置.
那么,为什么所有这些复杂的间接机制能奏效呢?因为场景在独立运行时表现最佳。如果无法独立运行,那么次优选择就是以匿名方式与其他场景协作(保持最小的硬性依赖,即松耦合)。当不可避免地需要修改某个类时,如果这些修改导致该类以不可预见的方式与其他场景交互,系统就会开始崩溃。所有这些间接设计的核心目的,就是避免陷入修改一个类就会对依赖它的其他类造成负面影响的困境。
脚本和场景作为引擎类的扩展, 应该遵守 所有 的OOP原则. 例如...
选择节点树结构
于是,一个开发者开始着手做游戏,却在广阔的可能性面前停了下来。他可能知道自己想做什么,想要什么样的系统,但是该把这些东西安置在 哪里 呢?好吧,自己做的游戏当然自己说了算。构造节点树的方法有无数种。但对于没把握的人而言,这份有用的指南可以给他们一个不错的结构样本作为开始。
游戏总是应该具有一个“入口点”。在这里,你能找到所有事物的起点,并跟随着逻辑找到它们去向哪里。也就是说,入口点是程序中所有其他数据和逻辑的鸟瞰点。对于传统的应用程序而言,这一般是“main”函数;而在Godot中,它就是 Main 节点。
“Main”节点(main.gd)
main.gd 脚本将作为你的游戏的主要控制器。
之后你便拥有了真正的游戏“世界”(2D或3D)。它可以是 Main 的子节点。另外,你的游戏需要一个主要GUI,来管理项目所需的各种菜单和部件。
- “Main”节点(main.gd)
Node2D/Node3D “World”(game_world.gd)
Control“GUI”(gui.gd)
当变更关卡时,可以稍后换出“World”节点的子级。手动更换场景让用户完全控制他们的游戏世界如何过渡。
下一步是考虑项目需要什么样的游戏系统。如果有这么一个系统……
跟踪所有的内部数据
应该是全局可访问的
应该是独立存在的
…接下来他该创建一个自动加载“单例”节点了。
备注
对于较小的游戏,一个更简单且更少控制的做法是使用一个“Game”单例,简单地调用 SceneTree.change_scene_to_file() 方法,用于置换出主场景的内容。这种结构多少保留了“World”作为主要游戏节点。
任一 GUI 也需要是一个单例;作为 "World" 的临时部分,或被手动添加到根节点作为其直接子节点。否则 GUI 节点也会在场景转换时自行删除。
如果一个系统需要修改另一个系统的数据,那么就应该把它们分别定义成单独的脚本或者场景,不应该使用自动加载。详情见《自动加载与普通节点》。
游戏中的每个子系统都应该在 SceneTree 中占有自己的一席之地。只有在节点确实是父节点中的元素时才应当使用父子关系。如果移除父节点的话,同时将这些子节点移除是否说得通?说不通的话,就应该在层级结构中单独列出,两者成为兄弟节点或者其他关系。
备注
某些情况下,我们仍然会需要让这些单独的节点进行相对定位。此时可以使用 RemoteTransform / RemoteTransform2D 节点。它们可以让目标节点有条件地从 Remote* 节点继承选定的变换元素。要分配 target 的 NodePath,请使用以下方法之一:
一个可靠的第三方, 可能是一个父节点, 来协调分配任务.
一个分组, 轻松提取对所需节点的引用(假设只有一个目标).
什么时候你该这样做?这个比较主观。当你必须精细管理,且一个节点必须在场景树上来回移动以保留自己时,就会出现两难的局面。例如……
添加一个“玩家”节点到一个“房间”节点。
需要改变房间了,所以必须删除当前房间节点。
在房间能被删除前,你必须保留玩家并/或将其移走。
如果不关心内存,你可以……
创建新的房间节点。
将玩家节点移动到新的房间节点。
删除旧房间。
如果比较关注内存情况,那么就需要这样……
将玩家节点移动到节点树的其他地方。
删除房间节点。
实例化并添加新的房间节点。
重新添加玩家节点到新房间中。
问题在于这里的角色是一种“特殊情况”;开发者必须知道需要以这种方式处理项目中的角色。因此,在团队中可靠地分享这些信息的唯一方法就是写文档。然而,在文档中记录实现细节是很危险的,会成为一种维护负担,使代码可读性下降,不必要地膨胀项目的知识内容。
在拥有更多的资产的,更复杂的游戏中,将整个玩家节点保留在 SceneTree 中的其他地方会更好。这样的好处是:
一致性更高。
没有“特殊情况”,不必写入文档也不必进行维护。
因为不需要考虑这些细节,所以也没有出错的机会。
相比之下,如果需要子节点不继承父节点的变换,那么就有以下选项:
声明式解决方案:在它们之间放置一个 Node。作为没有变换的节点,Node 不会将这些信息传递给其子节点。
命令式解决方案:对 CanvasItem 或者 Node3D 节点使用
top_level属性。这样就会让该节点忽略其继承的变换(transform)。
备注
如果构建的是网络游戏,请记住哪些节点和游戏系统与所有玩家相关,而哪些只与权威服务器相关。例如,用户并不需要所有人都拥有每个玩家的“PlayerController”逻辑的副本。相反,他们只需要自己的。将它们保持在从“世界”分离的独立的分支中,可以帮助简化游戏连接等的管理。
场景组织的关键是用关系树而不是空间树来考虑 SceneTree。节点是否依赖于其父节点的存在?如果不是,那么它们可以自己在别的地方茁壮成长。如果它们是依赖性的,那么理所当然它们应该是父节点的子节点(如果它们还不是父节点场景的一部分,那么很可能是父节点场景的一部分)。
这是否意味着节点本身就是组件?并不是这样。Godot 的节点树形成的是聚合关系,不是组合关系。虽然依旧可以灵活地移动节点,但在默认情况下是没有进行移动的必要的。